feat(chat): add local-first conversation workspace
This commit is contained in:
@@ -0,0 +1,485 @@
|
||||
import type {Conversation, StoredChatMessage, WorkingItem} from "./conversation-types";
|
||||
import {defaultGenerationSettings, normalizeGenerationSettings, type GenerationSettings} from "./generation-settings";
|
||||
|
||||
export type SessionTransferFormat = "xiteng" | "codex" | "claude" | "omp";
|
||||
|
||||
export type TransferNode = {
|
||||
sourceId: string;
|
||||
parentSourceId: string | null;
|
||||
role: StoredChatMessage["role"];
|
||||
parts: StoredChatMessage["parts"];
|
||||
createdAt: string;
|
||||
completedAt: string;
|
||||
completion?: StoredChatMessage["completion"];
|
||||
origin?: StoredChatMessage["origin"];
|
||||
metadata?: StoredChatMessage["metadata"];
|
||||
};
|
||||
|
||||
export type TransferSession = {
|
||||
sourceId: string;
|
||||
name: string;
|
||||
headSourceId: string | null;
|
||||
providerId: string;
|
||||
model: string;
|
||||
generationSettings: GenerationSettings;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type TransferDocument = {
|
||||
format: SessionTransferFormat;
|
||||
sessions: TransferSession[];
|
||||
nodes: TransferNode[];
|
||||
workingItems?: WorkingItem[];
|
||||
};
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, fallback = "") {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function isoTimestamp(value: unknown, fallback = new Date().toISOString()) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
const milliseconds = value < 10_000_000_000 ? value * 1000 : value;
|
||||
return new Date(milliseconds).toISOString();
|
||||
}
|
||||
if (typeof value === "string" && Number.isFinite(Date.parse(value))) return new Date(value).toISOString();
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function jsonLines(text: string) {
|
||||
const values: JsonRecord[] = [];
|
||||
for (const [index, line] of text.split(/\r?\n/).entries()) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const value: unknown = JSON.parse(line);
|
||||
if (isRecord(value)) values.push(value);
|
||||
} catch {
|
||||
throw new Error(`JSONL 第 ${index + 1} 行无法解析`);
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function contentParts(value: unknown, textTypes = ["text", "input_text", "output_text"]): StoredChatMessage["parts"] {
|
||||
if (typeof value === "string") return value ? [{type: "text", text: value}] : [];
|
||||
if (!Array.isArray(value)) return [];
|
||||
const parts: StoredChatMessage["parts"] = [];
|
||||
for (const candidate of value) {
|
||||
if (!isRecord(candidate)) continue;
|
||||
if (textTypes.includes(stringValue(candidate.type)) && typeof candidate.text === "string") {
|
||||
parts.push({type: "text", text: candidate.text});
|
||||
} else if (candidate.type === "thinking" && typeof candidate.thinking === "string") {
|
||||
parts.push({type: "reasoning", text: candidate.thinking, ...(typeof candidate.signature === "string" ? {signature: candidate.signature} : {})});
|
||||
} else if (candidate.type === "image" && isRecord(candidate.source)) {
|
||||
const data = stringValue(candidate.source.data);
|
||||
const mediaType = stringValue(candidate.source.media_type);
|
||||
if (data && mediaType) parts.push({type: "image", data, mimeType: mediaType});
|
||||
} else if (candidate.type === "input_image" && typeof candidate.image_url === "string") {
|
||||
parts.push({type: "image-url", url: candidate.image_url, ...(typeof candidate.detail === "string" ? {detail: candidate.detail} : {})});
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function firstText(nodes: TransferNode[]) {
|
||||
for (const node of nodes) {
|
||||
if (node.role !== "user") continue;
|
||||
const text = node.parts.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => String(part.text)).join("").trim();
|
||||
if (text && !/^<(environment_context|permissions instructions)>/i.test(text) && !/^# AGENTS\.md instructions/i.test(text)) return text.replace(/\s+/g, " ").slice(0, 80);
|
||||
}
|
||||
return "导入的会话";
|
||||
}
|
||||
|
||||
function newestLeaf(nodes: TransferNode[]) {
|
||||
const parents = new Set(nodes.map((node) => node.parentSourceId).filter((id): id is string => Boolean(id)));
|
||||
return [...nodes].filter((node) => !parents.has(node.sourceId)).sort((left, right) => right.completedAt.localeCompare(left.completedAt))[0]?.sourceId || nodes.at(-1)?.sourceId || null;
|
||||
}
|
||||
|
||||
function transferNode(sourceId: string, parentSourceId: string | null, role: StoredChatMessage["role"], parts: StoredChatMessage["parts"], timestamp: string, extra: Partial<TransferNode> = {}): TransferNode {
|
||||
return {
|
||||
sourceId,
|
||||
parentSourceId,
|
||||
role,
|
||||
parts,
|
||||
createdAt: timestamp,
|
||||
completedAt: timestamp,
|
||||
completion: {status: "complete"},
|
||||
origin: role === "user" ? {type: "user"} : role === "system" ? {type: "system", source: "session-import"} : {type: "legacy"},
|
||||
...extra
|
||||
};
|
||||
}
|
||||
|
||||
function parseXiteng(value: JsonRecord): TransferDocument {
|
||||
if (value.type !== "xiteng-chat-archive" || value.version !== 1 || !Array.isArray(value.conversations) || !Array.isArray(value.objects)) {
|
||||
throw new Error("不支持的 Xiteng Chat 备份版本");
|
||||
}
|
||||
const nodes: TransferNode[] = [];
|
||||
for (const candidate of value.objects) {
|
||||
if (!isRecord(candidate) || typeof candidate.id !== "string" || !["system", "user", "assistant"].includes(String(candidate.role)) || !Array.isArray(candidate.parts)) continue;
|
||||
nodes.push({
|
||||
sourceId: candidate.id,
|
||||
parentSourceId: typeof candidate.parentMessageId === "string" ? candidate.parentMessageId : null,
|
||||
role: candidate.role as StoredChatMessage["role"],
|
||||
parts: candidate.parts as StoredChatMessage["parts"],
|
||||
origin: isRecord(candidate.origin) ? candidate.origin as StoredChatMessage["origin"] : {type: "legacy"},
|
||||
completion: isRecord(candidate.completion) ? candidate.completion as StoredChatMessage["completion"] : {status: "complete"},
|
||||
createdAt: isoTimestamp(candidate.createdAt),
|
||||
completedAt: isoTimestamp(candidate.completedAt, isoTimestamp(candidate.createdAt)),
|
||||
...(isRecord(candidate.metadata) ? {metadata: candidate.metadata as StoredChatMessage["metadata"]} : {})
|
||||
});
|
||||
}
|
||||
const sessions: TransferSession[] = value.conversations.flatMap((candidate) => {
|
||||
if (!isRecord(candidate) || typeof candidate.id !== "string") return [];
|
||||
return [{
|
||||
sourceId: candidate.id,
|
||||
name: stringValue(candidate.name),
|
||||
headSourceId: typeof candidate.headMessageId === "string" ? candidate.headMessageId : null,
|
||||
providerId: stringValue(candidate.providerId, "imported"),
|
||||
model: stringValue(candidate.model, "imported"),
|
||||
generationSettings: normalizeGenerationSettings(candidate.generationSettings),
|
||||
createdAt: isoTimestamp(candidate.createdAt),
|
||||
updatedAt: isoTimestamp(candidate.updatedAt)
|
||||
}];
|
||||
});
|
||||
return {format: "xiteng", sessions, nodes, workingItems: Array.isArray(value.workingItems) ? value.workingItems as WorkingItem[] : []};
|
||||
}
|
||||
|
||||
function parseCodex(records: JsonRecord[]): TransferDocument {
|
||||
const metadata = records.find((record) => record.type === "session_meta" && isRecord(record.payload));
|
||||
const sessionPayload = metadata && isRecord(metadata.payload) ? metadata.payload : {};
|
||||
const sessionId = stringValue(sessionPayload.id, crypto.randomUUID());
|
||||
let model = "codex";
|
||||
let name = "";
|
||||
let parentSourceId: string | null = null;
|
||||
let ordinal = 0;
|
||||
const nodes: TransferNode[] = [];
|
||||
const canonicalUserText = new Set<string>();
|
||||
const canonicalAssistantText = new Set<string>();
|
||||
for (const record of records) {
|
||||
if (record.type !== "response_item" || !isRecord(record.payload) || record.payload.type !== "message") continue;
|
||||
const text = contentParts(record.payload.content).filter((part) => part.type === "text").map((part) => String(part.text || "")).join("");
|
||||
if (record.payload.role === "user") canonicalUserText.add(text);
|
||||
if (record.payload.role === "assistant") canonicalAssistantText.add(text);
|
||||
}
|
||||
const pushNode = (role: StoredChatMessage["role"], parts: StoredChatMessage["parts"], timestamp: string) => {
|
||||
if (!parts.length) return;
|
||||
const sourceId = `codex-${sessionId}-${++ordinal}`;
|
||||
nodes.push(transferNode(sourceId, parentSourceId, role, parts, timestamp));
|
||||
parentSourceId = sourceId;
|
||||
};
|
||||
for (const record of records) {
|
||||
const timestamp = isoTimestamp(record.timestamp, isoTimestamp(sessionPayload.timestamp));
|
||||
if (!isRecord(record.payload)) continue;
|
||||
const payload = record.payload;
|
||||
if (record.type === "turn_context" && typeof payload.model === "string") model = payload.model;
|
||||
if (record.type === "response_item") {
|
||||
if (payload.type === "message" && (payload.role === "user" || payload.role === "assistant")) {
|
||||
const parts = contentParts(payload.content);
|
||||
const text = parts.filter((part) => part.type === "text").map((part) => String(part.text || "")).join("").trim();
|
||||
if (payload.role !== "user" || !/^<(environment_context|permissions instructions)>/i.test(text)) pushNode(payload.role, parts, timestamp);
|
||||
} else if (payload.type === "reasoning") {
|
||||
const parts = [...(Array.isArray(payload.summary) ? payload.summary : []), ...(Array.isArray(payload.content) ? payload.content : [])]
|
||||
.flatMap((item) => isRecord(item) && typeof item.text === "string" ? [{type: "reasoning", text: item.text}] : []);
|
||||
pushNode("assistant", parts, timestamp);
|
||||
} else if (["function_call", "custom_tool_call", "web_search_call", "tool_search_call"].includes(String(payload.type))) {
|
||||
const callId = stringValue(payload.call_id, stringValue(payload.id, `call-${ordinal + 1}`));
|
||||
const toolName = stringValue(payload.name, payload.type === "web_search_call" ? "web_search" : payload.type === "tool_search_call" ? "tool_search" : "unknown");
|
||||
let argumentsValue: unknown = payload.type === "custom_tool_call" ? payload.input : payload.arguments ?? payload.action;
|
||||
if (typeof argumentsValue === "string") {
|
||||
try { argumentsValue = JSON.parse(argumentsValue); } catch { argumentsValue = {input: argumentsValue}; }
|
||||
}
|
||||
pushNode("assistant", [{type: "tool-call", id: callId, name: toolName, arguments: isRecord(argumentsValue) ? argumentsValue : {}}], timestamp);
|
||||
} else if (["function_call_output", "custom_tool_call_output", "tool_search_output"].includes(String(payload.type))) {
|
||||
pushNode("assistant", [{type: "tool-result", toolCallId: stringValue(payload.call_id), content: payload.output ?? payload.tools, isError: payload.status === "failed"}], timestamp);
|
||||
}
|
||||
} else if (record.type === "event_msg") {
|
||||
if (payload.type === "thread_name_updated" && typeof payload.thread_name === "string") name = payload.thread_name;
|
||||
if (payload.type === "user_message" && typeof payload.message === "string" && !canonicalUserText.has(payload.message)) pushNode("user", [{type: "text", text: payload.message}], timestamp);
|
||||
if (payload.type === "agent_message" && typeof payload.message === "string" && !canonicalAssistantText.has(payload.message)) pushNode("assistant", [{type: "text", text: payload.message}], timestamp);
|
||||
}
|
||||
}
|
||||
const createdAt = isoTimestamp(sessionPayload.timestamp, nodes[0]?.createdAt);
|
||||
return {
|
||||
format: "codex",
|
||||
nodes,
|
||||
sessions: [{sourceId: sessionId, name: name || firstText(nodes), headSourceId: parentSourceId, providerId: "openai", model, generationSettings: defaultGenerationSettings, createdAt, updatedAt: nodes.at(-1)?.completedAt || createdAt}]
|
||||
};
|
||||
}
|
||||
|
||||
function nearestRetainedParent(sourceParentId: string | null, sourceParents: Map<string, string | null>, retained: Map<string, string>) {
|
||||
const seen = new Set<string>();
|
||||
let cursor = sourceParentId;
|
||||
while (cursor && !seen.has(cursor)) {
|
||||
seen.add(cursor);
|
||||
const retainedId = retained.get(cursor);
|
||||
if (retainedId) return retainedId;
|
||||
cursor = sourceParents.get(cursor) || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseClaude(records: JsonRecord[]): TransferDocument {
|
||||
const sourceParents = new Map<string, string | null>();
|
||||
for (const record of records) if (typeof record.uuid === "string") sourceParents.set(record.uuid, typeof record.parentUuid === "string" ? record.parentUuid : null);
|
||||
const retained = new Map<string, string>();
|
||||
const nodes: TransferNode[] = [];
|
||||
let sessionId = "";
|
||||
let name = "";
|
||||
let model = "claude";
|
||||
for (const [index, record] of records.entries()) {
|
||||
sessionId ||= stringValue(record.sessionId);
|
||||
if (record.type === "custom-title") name = stringValue(record.customTitle, name);
|
||||
if (record.type === "ai-title" && !name) name = stringValue(record.aiTitle, name);
|
||||
if ((record.type !== "user" && record.type !== "assistant") || record.isSidechain === true || record.isMeta === true || !isRecord(record.message)) continue;
|
||||
const sourceUuid = stringValue(record.uuid, `claude-line-${index + 1}`);
|
||||
let parentSourceId = nearestRetainedParent(typeof record.parentUuid === "string" ? record.parentUuid : null, sourceParents, retained);
|
||||
const timestamp = isoTimestamp(record.timestamp);
|
||||
const converted: TransferNode[] = [];
|
||||
if (record.type === "assistant") {
|
||||
model = stringValue(record.message.model, model);
|
||||
const parts = contentParts(record.message.content);
|
||||
if (Array.isArray(record.message.content)) {
|
||||
for (const block of record.message.content) {
|
||||
if (!isRecord(block) || block.type !== "tool_use") continue;
|
||||
parts.push({type: "tool-call", id: stringValue(block.id), name: stringValue(block.name, "unknown"), arguments: isRecord(block.input) ? block.input : {}});
|
||||
}
|
||||
}
|
||||
if (parts.length) converted.push(transferNode(sourceUuid, parentSourceId, "assistant", parts, timestamp));
|
||||
} else {
|
||||
const toolResults = Array.isArray(record.message.content) ? record.message.content.filter((block) => isRecord(block) && block.type === "tool_result") : [];
|
||||
if (toolResults.length) {
|
||||
for (const block of toolResults) {
|
||||
if (!isRecord(block)) continue;
|
||||
converted.push(transferNode(`${sourceUuid}-tool-result-${converted.length}`, parentSourceId, "assistant", [{type: "tool-result", toolCallId: stringValue(block.tool_use_id), content: block.content, isError: block.is_error === true}], timestamp));
|
||||
parentSourceId = converted.at(-1)!.sourceId;
|
||||
}
|
||||
} else {
|
||||
const parts = contentParts(record.message.content);
|
||||
if (parts.length) converted.push(transferNode(sourceUuid, parentSourceId, "user", parts, timestamp));
|
||||
}
|
||||
}
|
||||
for (const node of converted) nodes.push(node);
|
||||
const tail = converted.at(-1)?.sourceId;
|
||||
if (tail) retained.set(sourceUuid, tail);
|
||||
}
|
||||
sessionId ||= crypto.randomUUID();
|
||||
const createdAt = nodes[0]?.createdAt || new Date().toISOString();
|
||||
return {format: "claude", nodes, sessions: [{sourceId: sessionId, name: name || firstText(nodes), headSourceId: newestLeaf(nodes), providerId: "anthropic", model, generationSettings: defaultGenerationSettings, createdAt, updatedAt: nodes.at(-1)?.completedAt || createdAt}]};
|
||||
}
|
||||
|
||||
function ompMessageParts(message: JsonRecord) {
|
||||
const parts = contentParts(message.content);
|
||||
if (Array.isArray(message.content)) {
|
||||
for (const block of message.content) {
|
||||
if (!isRecord(block)) continue;
|
||||
if (block.type === "toolCall") parts.push({type: "tool-call", id: stringValue(block.id), name: stringValue(block.name, "unknown"), arguments: isRecord(block.arguments) ? block.arguments : {}});
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function parseOmp(records: JsonRecord[]): TransferDocument {
|
||||
const header = records.find((record) => record.type === "session");
|
||||
if (!header) throw new Error("OMP JSONL 缺少 session header");
|
||||
const titleSlot = records.find((record) => record.type === "title");
|
||||
const titleChanges = records.filter((record) => record.type === "title_change");
|
||||
const sourceParents = new Map<string, string | null>();
|
||||
for (const record of records) if (typeof record.id === "string" && record.type !== "session") sourceParents.set(record.id, typeof record.parentId === "string" ? record.parentId : null);
|
||||
const retained = new Map<string, string>();
|
||||
const nodes: TransferNode[] = [];
|
||||
let model = "omp";
|
||||
for (const record of records) {
|
||||
if (record.type === "model_change" && typeof record.model === "string") model = record.model.includes("/") ? record.model.slice(record.model.indexOf("/") + 1) : record.model;
|
||||
if (record.type !== "message" || typeof record.id !== "string" || !isRecord(record.message)) continue;
|
||||
const parentSourceId = nearestRetainedParent(typeof record.parentId === "string" ? record.parentId : null, sourceParents, retained);
|
||||
const timestamp = isoTimestamp(record.timestamp);
|
||||
const role = record.message.role;
|
||||
let parts: StoredChatMessage["parts"] = [];
|
||||
if (role === "toolResult") parts = [{type: "tool-result", toolCallId: stringValue(record.message.toolCallId), toolName: stringValue(record.message.toolName, "unknown"), content: record.message.content, isError: record.message.isError === true}];
|
||||
else parts = ompMessageParts(record.message);
|
||||
if (!parts.length || (role !== "user" && role !== "assistant" && role !== "toolResult")) continue;
|
||||
nodes.push(transferNode(record.id, parentSourceId, role === "toolResult" ? "assistant" : role, parts, timestamp));
|
||||
retained.set(record.id, record.id);
|
||||
}
|
||||
const createdAt = isoTimestamp(header.timestamp, nodes[0]?.createdAt);
|
||||
const latestTitle = titleChanges.at(-1);
|
||||
const name = stringValue(latestTitle?.title, stringValue(titleSlot?.title, stringValue(header.title, firstText(nodes))));
|
||||
return {format: "omp", nodes, sessions: [{sourceId: stringValue(header.id, crypto.randomUUID()), name, headSourceId: nodes.at(-1)?.sourceId || null, providerId: model.includes("claude") ? "anthropic" : model.includes("gpt") || model.includes("codex") ? "openai" : "imported", model, generationSettings: defaultGenerationSettings, createdAt, updatedAt: nodes.at(-1)?.completedAt || createdAt}]};
|
||||
}
|
||||
|
||||
export function detectSessionTransferFormat(text: string, filename = ""): SessionTransferFormat {
|
||||
const trimmed = text.trimStart();
|
||||
if (trimmed.startsWith("{")) {
|
||||
try {
|
||||
const firstLine = JSON.parse(trimmed.split(/\r?\n/, 1)[0]) as unknown;
|
||||
if (isRecord(firstLine)) {
|
||||
if (firstLine.type === "xiteng-chat-archive") return "xiteng";
|
||||
if (firstLine.type === "session_meta") return "codex";
|
||||
if (firstLine.type === "session" || firstLine.type === "title") return "omp";
|
||||
if (typeof firstLine.sessionId === "string" || typeof firstLine.uuid === "string" || ["user", "assistant", "custom-title", "ai-title"].includes(String(firstLine.type))) return "claude";
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
const lower = filename.toLowerCase();
|
||||
if (lower.endsWith(".xiteng-chat.json")) return "xiteng";
|
||||
throw new Error("无法识别会话格式;请选择 Xiteng Chat JSON 或 Codex / Claude Code / OMP JSONL");
|
||||
}
|
||||
|
||||
export function parseSessionTransfer(text: string, filename = ""): TransferDocument {
|
||||
const format = detectSessionTransferFormat(text, filename);
|
||||
if (format === "xiteng") {
|
||||
const value: unknown = JSON.parse(text);
|
||||
if (!isRecord(value)) throw new Error("Xiteng Chat 备份不是 JSON 对象");
|
||||
return parseXiteng(value);
|
||||
}
|
||||
const records = jsonLines(text);
|
||||
if (format === "codex") return parseCodex(records);
|
||||
if (format === "claude") return parseClaude(records);
|
||||
return parseOmp(records);
|
||||
}
|
||||
|
||||
function nodeTextParts(node: TransferNode) {
|
||||
return node.parts.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => ({type: node.role === "assistant" ? "output_text" : "input_text", text: String(part.text)}));
|
||||
}
|
||||
|
||||
function assistantReasoningParts(node: TransferNode) {
|
||||
return node.parts.filter((part) => part.type === "reasoning" && typeof part.text === "string").map((part) => String(part.text));
|
||||
}
|
||||
|
||||
function portableUuid(value: string) {
|
||||
const match = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i.exec(value);
|
||||
return match?.[0] || crypto.randomUUID();
|
||||
}
|
||||
|
||||
function jsonl(records: JsonRecord[]) {
|
||||
return `${records.map((record) => JSON.stringify(record)).join("\n")}\n`;
|
||||
}
|
||||
|
||||
function serializeCodex(document: TransferDocument) {
|
||||
const session = document.sessions[0];
|
||||
if (!session) throw new Error("没有可导出的会话");
|
||||
const id = portableUuid(session.sourceId);
|
||||
const records: JsonRecord[] = [{type: "session_meta", timestamp: session.createdAt, payload: {id, timestamp: session.createdAt, cwd: "/workspace", originator: "xiteng-chat", cli_version: "0.147.0", source: "cli", model_provider: session.providerId}}];
|
||||
records.push({type: "turn_context", timestamp: session.createdAt, payload: {cwd: "/workspace", model: session.model}});
|
||||
for (const node of currentPath(document.nodes, session.headSourceId)) {
|
||||
const text = nodeTextParts(node);
|
||||
if (text.length) records.push({type: "response_item", timestamp: node.createdAt, payload: {type: "message", role: node.role, content: text, ...(node.role === "assistant" ? {phase: "final_answer"} : {})}});
|
||||
for (const reasoning of assistantReasoningParts(node)) records.push({type: "response_item", timestamp: node.createdAt, payload: {type: "reasoning", summary: [{type: "summary_text", text: reasoning}]}});
|
||||
for (const part of node.parts) {
|
||||
if (part.type === "tool-call") records.push({type: "response_item", timestamp: node.createdAt, payload: {type: "function_call", call_id: stringValue(part.id, crypto.randomUUID()), name: stringValue(part.name, "unknown"), arguments: JSON.stringify(isRecord(part.arguments) ? part.arguments : {})}});
|
||||
if (part.type === "tool-result") records.push({type: "response_item", timestamp: node.createdAt, payload: {type: "function_call_output", call_id: stringValue(part.toolCallId), output: part.content ?? ""}});
|
||||
}
|
||||
}
|
||||
if (session.name) records.push({type: "event_msg", timestamp: session.updatedAt, payload: {type: "thread_name_updated", thread_name: session.name}});
|
||||
return jsonl(records);
|
||||
}
|
||||
|
||||
function claudeContent(node: TransferNode) {
|
||||
const content: JsonRecord[] = [];
|
||||
for (const part of node.parts) {
|
||||
if (part.type === "text" && typeof part.text === "string") content.push({type: "text", text: part.text});
|
||||
if (part.type === "reasoning" && typeof part.text === "string") content.push({type: "thinking", thinking: part.text, signature: stringValue(part.signature)});
|
||||
if (part.type === "tool-call") content.push({type: "tool_use", id: stringValue(part.id, crypto.randomUUID()), name: stringValue(part.name, "unknown"), input: isRecord(part.arguments) ? part.arguments : {}});
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
function serializeClaude(document: TransferDocument) {
|
||||
const session = document.sessions[0];
|
||||
if (!session) throw new Error("没有可导出的会话");
|
||||
const sessionId = portableUuid(session.sourceId);
|
||||
const idMap = new Map(document.nodes.map((node) => [node.sourceId, portableUuid(node.sourceId)]));
|
||||
const records: JsonRecord[] = [];
|
||||
for (const node of document.nodes) {
|
||||
const common = {sessionId, uuid: idMap.get(node.sourceId), parentUuid: node.parentSourceId ? idMap.get(node.parentSourceId) || null : null, timestamp: node.createdAt, cwd: "/workspace", version: "2.1.81", gitBranch: ""};
|
||||
const toolResults = node.parts.filter((part) => part.type === "tool-result");
|
||||
const content = claudeContent(node);
|
||||
if (toolResults.length) {
|
||||
records.push({...common, type: "user", message: {role: "user", content: toolResults.map((part) => ({type: "tool_result", tool_use_id: stringValue(part.toolCallId), content: part.content ?? "", is_error: part.isError === true}))}});
|
||||
} else if (node.role === "user") {
|
||||
records.push({...common, type: "user", message: {role: "user", content}});
|
||||
} else if (node.role === "assistant" && content.length) {
|
||||
records.push({...common, type: "assistant", message: {id: `msg_${idMap.get(node.sourceId)?.replaceAll("-", "")}`, type: "message", role: "assistant", model: session.model, content, stop_reason: "end_turn", stop_sequence: null, usage: {input_tokens: 0, output_tokens: 0}}});
|
||||
}
|
||||
}
|
||||
if (session.name) records.push({type: "custom-title", customTitle: session.name, sessionId});
|
||||
return jsonl(records);
|
||||
}
|
||||
|
||||
function ompContent(node: TransferNode) {
|
||||
const content: JsonRecord[] = [];
|
||||
for (const part of node.parts) {
|
||||
if (part.type === "text" && typeof part.text === "string") content.push({type: "text", text: part.text});
|
||||
if (part.type === "reasoning" && typeof part.text === "string") content.push({type: "thinking", thinking: part.text});
|
||||
if (part.type === "tool-call") content.push({type: "toolCall", id: stringValue(part.id, crypto.randomUUID()), name: stringValue(part.name, "unknown"), arguments: isRecord(part.arguments) ? part.arguments : {}});
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
function serializeOmp(document: TransferDocument) {
|
||||
const session = document.sessions[0];
|
||||
if (!session) throw new Error("没有可导出的会话");
|
||||
const idMap = new Map(document.nodes.map((node, index) => [node.sourceId, `xt${(index + 1).toString(36).padStart(6, "0")}`]));
|
||||
const records: JsonRecord[] = [{type: "session", version: 3, id: portableUuid(session.sourceId), timestamp: session.createdAt, cwd: "/workspace", title: session.name, titleSource: "user"}];
|
||||
let modelParent: string | null = null;
|
||||
if (session.model) {
|
||||
modelParent = "xtmodel0";
|
||||
records.push({type: "model_change", id: modelParent, parentId: null, timestamp: session.createdAt, model: `${session.providerId}/${session.model}`});
|
||||
}
|
||||
for (const node of document.nodes) {
|
||||
const parentId = node.parentSourceId ? idMap.get(node.parentSourceId) || null : modelParent;
|
||||
const toolResult = node.parts.find((part) => part.type === "tool-result");
|
||||
if (toolResult) {
|
||||
records.push({type: "message", id: idMap.get(node.sourceId), parentId, timestamp: node.createdAt, message: {role: "toolResult", toolCallId: stringValue(toolResult.toolCallId), toolName: stringValue(toolResult.toolName, "unknown"), content: toolResult.content ?? "", isError: toolResult.isError === true, timestamp: Date.parse(node.createdAt)}});
|
||||
} else {
|
||||
records.push({type: "message", id: idMap.get(node.sourceId), parentId, timestamp: node.createdAt, message: {role: node.role, content: ompContent(node), ...(node.role === "assistant" ? {api: "openai-responses", provider: session.providerId, model: session.model, usage: {input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: {input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0}}, stopReason: "stop"} : {}), timestamp: Date.parse(node.createdAt)}});
|
||||
}
|
||||
}
|
||||
return jsonl(records);
|
||||
}
|
||||
|
||||
export function currentPath(nodes: TransferNode[], headSourceId: string | null) {
|
||||
const byId = new Map(nodes.map((node) => [node.sourceId, node]));
|
||||
const reversed: TransferNode[] = [];
|
||||
const seen = new Set<string>();
|
||||
let cursor = headSourceId;
|
||||
while (cursor && !seen.has(cursor)) {
|
||||
seen.add(cursor);
|
||||
const node = byId.get(cursor);
|
||||
if (!node) break;
|
||||
reversed.push(node);
|
||||
cursor = node.parentSourceId;
|
||||
}
|
||||
return reversed.reverse();
|
||||
}
|
||||
|
||||
export function serializeSessionTransfer(document: TransferDocument, format: Exclude<SessionTransferFormat, "xiteng">) {
|
||||
if (format === "codex") return serializeCodex(document);
|
||||
if (format === "claude") return serializeClaude(document);
|
||||
return serializeOmp(document);
|
||||
}
|
||||
|
||||
export function serializeXitengArchive(conversations: Conversation[], objects: StoredChatMessage[], workingItems: WorkingItem[]) {
|
||||
return JSON.stringify({
|
||||
type: "xiteng-chat-archive",
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
conversations: conversations.map(({messages: _messages, ...conversation}) => conversation),
|
||||
objects,
|
||||
workingItems
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
export function conversationTransferDocument(conversation: Conversation, nodes: StoredChatMessage[]): TransferDocument {
|
||||
return {
|
||||
format: "xiteng",
|
||||
sessions: [{sourceId: conversation.id, name: conversation.name, headSourceId: conversation.headMessageId, providerId: conversation.providerId, model: conversation.model, generationSettings: conversation.generationSettings, createdAt: conversation.createdAt, updatedAt: conversation.updatedAt}],
|
||||
nodes: nodes.map((message) => ({sourceId: message.id, parentSourceId: message.parentMessageId, role: message.role, parts: message.parts, origin: message.origin, completion: message.completion, createdAt: message.createdAt, completedAt: message.completedAt, ...(message.metadata ? {metadata: message.metadata} : {})}))
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user