feat: auto-sync bound codex threads into conversations

This commit is contained in:
kris
2026-03-30 13:01:37 +08:00
parent 9c15c30a41
commit 98dd0e3cd5
9 changed files with 755 additions and 82 deletions

View File

@@ -5314,6 +5314,7 @@ export async function upsertDeviceHeartbeat(payload: {
}>;
}) {
const result = await mutateState((state) => {
const existingDevice = state.devices.find((item) => item.id === payload.deviceId) ?? null;
const claimedEnrollment = claimEnrollment(
state,
payload.deviceId,
@@ -5336,7 +5337,7 @@ export async function upsertDeviceHeartbeat(payload: {
);
const shouldAutoImportLegacyProjects = normalizedCandidates.length === 0;
let device = state.devices.find((item) => item.id === payload.deviceId);
let device = existingDevice;
if (!device) {
device = {
id: payload.deviceId,
@@ -5409,12 +5410,56 @@ export async function upsertDeviceHeartbeat(payload: {
}
}
}
const draft = upsertDeviceImportDraftFromHeartbeat(state, {
let draft = upsertDeviceImportDraftFromHeartbeat(state, {
deviceId: payload.deviceId,
enrollmentId: claimedEnrollment?.enrollmentId,
candidates: normalizedCandidates,
});
if (
draft &&
shouldAutoSyncHeartbeatCandidates({
wasExistingDevice: Boolean(existingDevice),
device,
claimedEnrollment,
draft,
})
) {
const autoSyncDraft = draft;
const selectedCandidateIds = resolveAutoSyncCandidateIds(autoSyncDraft);
if (selectedCandidateIds.length > 0) {
autoSyncDraft.selectedCandidateIds = selectedCandidateIds;
autoSyncDraft.status = "pending_resolution";
autoSyncDraft.updatedAt = nowIso();
autoSyncDraft.reviewedAt = undefined;
autoSyncDraft.reviewedBy = undefined;
autoSyncDraft.resolutionId = undefined;
state.deviceImportResolutions = state.deviceImportResolutions.filter(
(item) => item.draftId !== autoSyncDraft.draftId,
);
const selectedCandidates = autoSyncDraft.candidates.filter((candidate) =>
autoSyncDraft.selectedCandidateIds.includes(candidate.candidateId),
);
const items = selectedCandidates.map((candidate) =>
resolveDeviceImportAction(state, payload.deviceId, candidate),
);
upsertDeviceImportResolutionInState(state, {
deviceId: payload.deviceId,
reviewedBy: "system:auto_sync",
summary: summarizeDeviceImportResolution(device.name, items),
items,
draftId: autoSyncDraft.draftId,
});
const applied = applyDeviceImportResolutionInState(state, {
deviceId: payload.deviceId,
appliedBy: "system:auto_sync",
draftId: autoSyncDraft.draftId,
});
draft = applied.draft;
}
}
return {
device,
token: claimedEnrollment?.token ?? device.token,
@@ -5486,6 +5531,35 @@ function summarizeDeviceImportResolution(
return `${deviceName} 导入建议:新建 ${createCount} 个会话,关联 ${attachCount} 个现有会话${skipCount > 0 ? `,跳过 ${skipCount}` : ""}`;
}
function resolveAutoSyncCandidateIds(draft: DeviceImportDraft) {
const suggestedCandidateIds = draft.candidates
.filter((candidate) => candidate.suggestedImport !== false)
.map((candidate) => candidate.candidateId);
return dedupeStrings(
suggestedCandidateIds.length > 0
? suggestedCandidateIds
: draft.candidates.map((candidate) => candidate.candidateId),
);
}
function shouldAutoSyncHeartbeatCandidates(input: {
wasExistingDevice: boolean;
device: Device;
claimedEnrollment: DeviceEnrollment | null;
draft: DeviceImportDraft | null;
}) {
if (!input.wasExistingDevice) return false;
if (input.device.source !== "production") return false;
if (!input.draft || input.draft.candidates.length === 0) return false;
if (
input.claimedEnrollment?.enrollmentId &&
input.draft.enrollmentId === input.claimedEnrollment.enrollmentId
) {
return false;
}
return true;
}
export async function getLatestDeviceImportDraft(deviceId: string) {
const state = await readState();
const draft = state.deviceImportDrafts.find((item) => item.deviceId === deviceId) ?? null;
@@ -5750,84 +5824,101 @@ function buildImportedThreadProject(device: Device, candidate: DeviceImportCandi
});
}
function applyDeviceImportResolutionInState(
state: BossState,
input: {
deviceId: string;
appliedBy: string;
draftId?: string;
},
) {
const draft =
state.deviceImportDrafts.find(
(item) => item.draftId === input.draftId || item.deviceId === input.deviceId,
) ?? null;
if (!draft || !draft.resolutionId) throw new Error("DEVICE_IMPORT_RESOLUTION_NOT_FOUND");
const resolution = state.deviceImportResolutions.find(
(item) => item.resolutionId === draft.resolutionId,
);
if (!resolution) throw new Error("DEVICE_IMPORT_RESOLUTION_NOT_FOUND");
const device = state.devices.find((item) => item.id === input.deviceId);
if (!device) throw new Error("DEVICE_NOT_FOUND");
const importedProjects: Project[] = [];
for (const item of resolution.items) {
const candidate = draft.candidates.find((entry) => entry.candidateId === item.candidateId);
if (!candidate || item.action === "skip") {
continue;
}
let targetProject = item.targetProjectId
? state.projects.find((project) => project.id === item.targetProjectId)
: undefined;
if (item.action === "create_thread_conversation" && !targetProject) {
const draftProject = buildImportedThreadProject(device, candidate);
targetProject =
state.projects.find((project) => project.id === draftProject.id) ??
state.projects.find(
(project) =>
!project.isGroup &&
project.deviceIds.includes(device.id) &&
((candidate.codexThreadRef &&
project.threadMeta.codexThreadRef === candidate.codexThreadRef) ||
project.threadMeta.threadId === candidate.threadId),
);
if (!targetProject) {
targetProject = draftProject;
state.projects.unshift(targetProject);
}
} else if (item.action === "attach_existing" && !targetProject) {
continue;
}
if (!targetProject) continue;
if (!targetProject.deviceIds.includes(device.id)) {
targetProject.deviceIds.push(device.id);
}
targetProject.threadMeta.threadDisplayName = candidate.threadDisplayName;
targetProject.threadMeta.folderName = candidate.folderName;
targetProject.threadMeta.threadId = candidate.threadId;
targetProject.threadMeta.codexFolderRef = candidate.codexFolderRef ?? candidate.folderRef;
targetProject.threadMeta.codexThreadRef = candidate.codexThreadRef;
targetProject.threadMeta.updatedAt = candidate.lastActiveAt;
targetProject.preview = `已导入 ${candidate.threadDisplayName}`;
targetProject.updatedAt = nowIso();
targetProject.lastMessageAt = targetProject.updatedAt;
importedProjects.push({ ...targetProject });
}
device.projects = dedupeStrings(
draft.candidates
.filter((candidate) => draft.selectedCandidateIds.includes(candidate.candidateId))
.map((candidate) => candidate.folderName),
);
resolution.status = "applied";
resolution.appliedAt = nowIso();
resolution.appliedBy = input.appliedBy;
draft.status = "applied";
draft.updatedAt = nowIso();
return {
draft: { ...draft },
resolution: { ...resolution },
importedProjects,
};
}
export async function applyDeviceImportResolution(input: {
deviceId: string;
appliedBy: string;
}) {
const result = await mutateState((state) => {
const draft = state.deviceImportDrafts.find((item) => item.deviceId === input.deviceId);
if (!draft || !draft.resolutionId) throw new Error("DEVICE_IMPORT_RESOLUTION_NOT_FOUND");
const resolution = state.deviceImportResolutions.find(
(item) => item.resolutionId === draft.resolutionId,
);
if (!resolution) throw new Error("DEVICE_IMPORT_RESOLUTION_NOT_FOUND");
const device = state.devices.find((item) => item.id === input.deviceId);
if (!device) throw new Error("DEVICE_NOT_FOUND");
const importedProjects: Project[] = [];
for (const item of resolution.items) {
const candidate = draft.candidates.find((entry) => entry.candidateId === item.candidateId);
if (!candidate || item.action === "skip") {
continue;
}
let targetProject = item.targetProjectId
? state.projects.find((project) => project.id === item.targetProjectId)
: undefined;
if (item.action === "create_thread_conversation" && !targetProject) {
const draftProject = buildImportedThreadProject(device, candidate);
targetProject =
state.projects.find((project) => project.id === draftProject.id) ??
state.projects.find(
(project) =>
!project.isGroup &&
project.deviceIds.includes(device.id) &&
((candidate.codexThreadRef &&
project.threadMeta.codexThreadRef === candidate.codexThreadRef) ||
project.threadMeta.threadId === candidate.threadId),
);
if (!targetProject) {
targetProject = draftProject;
state.projects.unshift(targetProject);
}
} else if (item.action === "attach_existing" && !targetProject) {
continue;
}
if (!targetProject) continue;
if (!targetProject.deviceIds.includes(device.id)) {
targetProject.deviceIds.push(device.id);
}
targetProject.threadMeta.threadDisplayName = candidate.threadDisplayName;
targetProject.threadMeta.folderName = candidate.folderName;
targetProject.threadMeta.threadId = candidate.threadId;
targetProject.threadMeta.codexFolderRef = candidate.codexFolderRef ?? candidate.folderRef;
targetProject.threadMeta.codexThreadRef = candidate.codexThreadRef;
targetProject.threadMeta.updatedAt = candidate.lastActiveAt;
targetProject.preview = `已导入 ${candidate.threadDisplayName}`;
targetProject.updatedAt = nowIso();
targetProject.lastMessageAt = targetProject.updatedAt;
importedProjects.push({ ...targetProject });
}
device.projects = dedupeStrings(
draft.candidates
.filter((candidate) => draft.selectedCandidateIds.includes(candidate.candidateId))
.map((candidate) => candidate.folderName),
);
resolution.status = "applied";
resolution.appliedAt = nowIso();
resolution.appliedBy = input.appliedBy;
draft.status = "applied";
draft.updatedAt = nowIso();
return {
draft: { ...draft },
resolution: { ...resolution },
importedProjects,
};
});
const result = await mutateState((state) =>
applyDeviceImportResolutionInState(state, {
deviceId: input.deviceId,
appliedBy: input.appliedBy,
}),
);
publishBossEvent("devices.updated", { deviceId: input.deviceId });
publishBossEvent("conversation.updated", { deviceId: input.deviceId });