feat: auto-sync bound codex threads into conversations
This commit is contained in:
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user