feat(chat): add local-first conversation workspace
This commit is contained in:
+139
-99
@@ -1,15 +1,50 @@
|
||||
import type {Conversation, ConversationSummary, StoredChatMessage} from "./conversation-types";
|
||||
import type {
|
||||
Conversation,
|
||||
ConversationRefState,
|
||||
ConversationSummary,
|
||||
MessageCompletion,
|
||||
MessageOrigin,
|
||||
RepositoryFetch,
|
||||
StoredChatMessage
|
||||
} from "./conversation-types";
|
||||
import type {GenerationSettings} from "./generation-settings";
|
||||
import {
|
||||
applyRepositoryFetch,
|
||||
applyRepositoryPushResults,
|
||||
cacheConversation,
|
||||
cacheConversationSummaries,
|
||||
commitLocalMessage,
|
||||
createLocalConversation,
|
||||
deleteLocalConversation,
|
||||
listCachedObjectIds,
|
||||
listPendingConversationChanges,
|
||||
loadCachedConversation,
|
||||
loadCachedConversationSummaries,
|
||||
moveLocalConversationHead,
|
||||
queueConversationChange,
|
||||
removeCachedConversation,
|
||||
removePendingConversationChange
|
||||
queueLocalRefUpdate,
|
||||
removePendingConversationChange,
|
||||
repositoryPushPayload
|
||||
} from "./offline-history";
|
||||
import {repositoryPushBatches} from "./repository-push-batches";
|
||||
|
||||
const chatBasePath = "/chat";
|
||||
const chatApi = (pathname: string) => `${chatBasePath}${pathname}`;
|
||||
|
||||
export type MessageCommitInput = {
|
||||
id: string;
|
||||
expectedHeadId: string | null;
|
||||
parentMessageId: string | null;
|
||||
role: StoredChatMessage["role"];
|
||||
parts: StoredChatMessage["parts"];
|
||||
origin: MessageOrigin;
|
||||
completion: MessageCompletion;
|
||||
createdAt: string;
|
||||
completedAt: string;
|
||||
metadata?: StoredChatMessage["metadata"];
|
||||
providerId?: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
class ConversationHttpError extends Error {
|
||||
constructor(message: string, readonly status: number) {
|
||||
@@ -33,127 +68,132 @@ async function conversationRequest<T>(path: string, init?: RequestInit) {
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
function isNetworkFailure(error: unknown) {
|
||||
return error instanceof TypeError || (typeof navigator !== "undefined" && !navigator.onLine);
|
||||
export function listConversationHistory() {
|
||||
return loadCachedConversationSummaries();
|
||||
}
|
||||
|
||||
export async function listConversationHistory() {
|
||||
try {
|
||||
const payload = await conversationRequest<{conversations: ConversationSummary[]}>("/api/conversations");
|
||||
await cacheConversationSummaries(payload.conversations);
|
||||
return payload.conversations;
|
||||
} catch (error) {
|
||||
if (!isNetworkFailure(error)) throw error;
|
||||
const cached = await loadCachedConversationSummaries();
|
||||
if (cached.length === 0) throw error;
|
||||
return cached;
|
||||
}
|
||||
export async function createConversationHistory(
|
||||
providerId: string,
|
||||
model: string,
|
||||
generationSettings: GenerationSettings,
|
||||
name: string,
|
||||
headMessageId: string | null = null,
|
||||
messages: StoredChatMessage[] = []
|
||||
) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const conversation: Conversation = {
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
headMessageId,
|
||||
upstreamHeadMessageId: null,
|
||||
providerId,
|
||||
model,
|
||||
generationSettings,
|
||||
headVersion: 0,
|
||||
metadataVersion: 0,
|
||||
messageCount: messages.length,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
messages
|
||||
};
|
||||
return createLocalConversation(conversation);
|
||||
}
|
||||
|
||||
export async function createConversationHistory(providerId: string, model: string, generationSettings: GenerationSettings) {
|
||||
const payload = await conversationRequest<{conversation: Conversation}>("/api/conversations", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({providerId, model, generationSettings})
|
||||
});
|
||||
export async function getConversationHistory(id: string) {
|
||||
const cached = await loadCachedConversation(id);
|
||||
if (!cached) throw new Error("Local conversation is unavailable; fetch may still be in progress");
|
||||
return cached;
|
||||
}
|
||||
|
||||
export async function updateConversationHistory(
|
||||
id: string,
|
||||
providerId: string,
|
||||
model: string,
|
||||
generationSettings: GenerationSettings,
|
||||
name?: string
|
||||
) {
|
||||
const cached = await getConversationHistory(id);
|
||||
const updated: Conversation = {
|
||||
...cached,
|
||||
providerId,
|
||||
model,
|
||||
generationSettings,
|
||||
...(name === undefined ? {} : {name}),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
return queueLocalRefUpdate(updated);
|
||||
}
|
||||
|
||||
export async function commitConversationMessage(conversationId: string, input: MessageCommitInput) {
|
||||
const cached = await getConversationHistory(conversationId);
|
||||
if (cached.headMessageId !== input.expectedHeadId || input.parentMessageId !== input.expectedHeadId) throw new Error("Local conversation head changed");
|
||||
const message: StoredChatMessage = {
|
||||
id: input.id,
|
||||
parentMessageId: input.parentMessageId,
|
||||
role: input.role,
|
||||
parts: input.parts,
|
||||
origin: input.origin,
|
||||
completion: input.completion,
|
||||
createdAt: input.createdAt,
|
||||
completedAt: input.completedAt,
|
||||
...(input.metadata ? {metadata: input.metadata} : {})
|
||||
};
|
||||
return commitLocalMessage(conversationId, message);
|
||||
}
|
||||
|
||||
export function moveConversationHead(conversationId: string, headMessageId: string | null) {
|
||||
return moveLocalConversationHead(conversationId, headMessageId);
|
||||
}
|
||||
|
||||
// Compatibility for queued operations produced by an older client.
|
||||
export async function saveConversationHistory(id: string, providerId: string, model: string, messages: StoredChatMessage[]) {
|
||||
const body = JSON.stringify({providerId, model, messages});
|
||||
const payload = await conversationRequest<{conversation: Conversation}>(chatApi(`/api/conversations/${encodeURIComponent(id)}`), {method: "PUT", body});
|
||||
await cacheConversation(payload.conversation);
|
||||
return payload.conversation;
|
||||
}
|
||||
|
||||
export async function getConversationHistory(id: string) {
|
||||
try {
|
||||
const payload = await conversationRequest<{conversation: Conversation}>(`/api/conversations/${encodeURIComponent(id)}`);
|
||||
await cacheConversation(payload.conversation);
|
||||
return payload.conversation;
|
||||
} catch (error) {
|
||||
if (!isNetworkFailure(error)) throw error;
|
||||
const cached = await loadCachedConversation(id);
|
||||
if (!cached) throw error;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateConversationHistory(id: string, providerId: string, model: string, generationSettings: GenerationSettings) {
|
||||
const body = JSON.stringify({providerId, model, generationSettings});
|
||||
try {
|
||||
return await conversationRequest<{updated: true}>(`/api/conversations/${encodeURIComponent(id)}`, {method: "PATCH", body});
|
||||
} catch (error) {
|
||||
if (!isNetworkFailure(error)) throw error;
|
||||
const cached = await loadCachedConversation(id);
|
||||
if (cached) await cacheConversation({...cached, providerId, model, generationSettings, updatedAt: new Date().toISOString()});
|
||||
await queueConversationChange({conversationId: id, method: "PATCH", body});
|
||||
return {updated: true as const};
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveConversationHistory(id: string, providerId: string, model: string, messages: StoredChatMessage[]) {
|
||||
const body = JSON.stringify({providerId, model, messages});
|
||||
try {
|
||||
const payload = await conversationRequest<{conversation: Conversation}>(`/api/conversations/${encodeURIComponent(id)}`, {method: "PUT", body});
|
||||
await cacheConversation(payload.conversation);
|
||||
return payload.conversation;
|
||||
} catch (error) {
|
||||
if (!isNetworkFailure(error)) throw error;
|
||||
const cached = await loadCachedConversation(id);
|
||||
if (!cached) throw error;
|
||||
const firstUserText = messages.find((message) => message.role === "user")?.parts
|
||||
.filter((part) => part.type === "text" && typeof part.text === "string")
|
||||
.map((part) => String(part.text)).join(" ").replace(/\s+/g, " ").trim();
|
||||
const updated: Conversation = {
|
||||
...cached,
|
||||
title: firstUserText?.slice(0, 48) || cached.title,
|
||||
providerId,
|
||||
model,
|
||||
messages,
|
||||
messageCount: messages.length,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
await cacheConversation(updated);
|
||||
await queueConversationChange({conversationId: id, method: "PUT", body});
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteConversationHistory(id: string) {
|
||||
try {
|
||||
await conversationRequest<void>(`/api/conversations/${encodeURIComponent(id)}`, {method: "DELETE"});
|
||||
} catch (error) {
|
||||
if (!isNetworkFailure(error)) throw error;
|
||||
await queueConversationChange({conversationId: id, method: "DELETE"});
|
||||
}
|
||||
await removeCachedConversation(id);
|
||||
await deleteLocalConversation(id);
|
||||
}
|
||||
|
||||
export async function flushPendingConversationChanges() {
|
||||
const pending = await listPendingConversationChanges();
|
||||
for (const change of pending) {
|
||||
const requestPath = change.requestPath || `/api/conversations/${encodeURIComponent(change.conversationId)}`;
|
||||
try {
|
||||
await conversationRequest<void>(`/api/conversations/${encodeURIComponent(change.conversationId)}`, {
|
||||
method: change.method,
|
||||
body: change.body
|
||||
});
|
||||
await conversationRequest<void>(chatApi(requestPath), {method: change.method, body: change.body});
|
||||
await removePendingConversationChange(change.cacheKey);
|
||||
} catch (error) {
|
||||
if (error instanceof ConversationHttpError && change.method === "DELETE" && error.status === 404) {
|
||||
await removePendingConversationChange(change.cacheKey);
|
||||
continue;
|
||||
}
|
||||
if (isNetworkFailure(error)) break;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function synchronizeOfflineConversationHistory(summaries?: ConversationSummary[]) {
|
||||
const history = summaries || (await conversationRequest<{conversations: ConversationSummary[]}>("/api/conversations")).conversations;
|
||||
await cacheConversationSummaries(history);
|
||||
let cursor = 0;
|
||||
const worker = async () => {
|
||||
while (cursor < history.length) {
|
||||
const summary = history[cursor++];
|
||||
const payload = await conversationRequest<{conversation: Conversation}>(`/api/conversations/${encodeURIComponent(summary.id)}`);
|
||||
await cacheConversation(payload.conversation);
|
||||
export async function synchronizeConversationRepository() {
|
||||
await flushPendingConversationChanges();
|
||||
const push = await repositoryPushPayload();
|
||||
let conflicts = 0;
|
||||
if (push.refs.length || push.objects.length) {
|
||||
for (const batch of repositoryPushBatches(push)) {
|
||||
const pushed = await conversationRequest<{
|
||||
refs: Array<{conversationId: string; status: "ok" | "conflict"; ref: ConversationRefState | null}>;
|
||||
}>(chatApi("/api/sync/push"), {method: "POST", body: JSON.stringify(batch)});
|
||||
await applyRepositoryPushResults(pushed.refs);
|
||||
conflicts += pushed.refs.filter((result) => result.status === "conflict").length;
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({length: Math.min(4, history.length)}, () => worker()));
|
||||
return history;
|
||||
}
|
||||
const haveObjectIds = await listCachedObjectIds();
|
||||
const fetched = await conversationRequest<RepositoryFetch>(chatApi("/api/sync/fetch"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({haveObjectIds})
|
||||
});
|
||||
await applyRepositoryFetch(fetched);
|
||||
return {summaries: await loadCachedConversationSummaries(), fetchedAt: fetched.fetchedAt, conflicts};
|
||||
}
|
||||
|
||||
export const synchronizeOfflineConversationHistory = synchronizeConversationRepository;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import {describe, expect, test} from "bun:test";
|
||||
import {conversationTitlePrompt, normalizeGeneratedConversationTitle} from "./conversation-title.ts";
|
||||
|
||||
describe("AI conversation titles", () => {
|
||||
test("builds a bounded prompt from the first messages", () => {
|
||||
const prompt = conversationTitlePrompt([
|
||||
{role: "user", parts: [{type: "text", text: "如何优化流式 Markdown?"}]},
|
||||
{role: "assistant", parts: [{type: "text", text: "可以按稳定块增量渲染。"}]}
|
||||
]);
|
||||
expect(prompt).toContain("用户:如何优化流式 Markdown?");
|
||||
expect(prompt).toContain("助手:可以按稳定块增量渲染。");
|
||||
});
|
||||
|
||||
test("removes common model formatting and unsafe path separators", () => {
|
||||
expect(normalizeGeneratedConversationTitle("## 标题:\“流式 Markdown / 渲染优化\”\n说明")).toBe("流式 Markdown / 渲染优化");
|
||||
});
|
||||
|
||||
test("allows an empty result to remain untitled", () => {
|
||||
expect(normalizeGeneratedConversationTitle("<think>no title</think>\n")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import type {StoredChatMessage} from "./conversation-types";
|
||||
|
||||
export const untitledConversationLabel = "未命名会话";
|
||||
|
||||
function messageText(message: StoredChatMessage) {
|
||||
return message.parts
|
||||
.filter((part) => part.type === "text" && typeof part.text === "string")
|
||||
.map((part) => String(part.text))
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function conversationTitlePrompt(messages: StoredChatMessage[]) {
|
||||
const transcript = messages
|
||||
.filter((message) => message.role === "user" || message.role === "assistant")
|
||||
.slice(0, 4)
|
||||
.map((message) => `${message.role === "user" ? "用户" : "助手"}:${messageText(message).slice(0, 2000)}`)
|
||||
.filter((line) => !line.endsWith(":"))
|
||||
.join("\n\n");
|
||||
return `请为下面的对话生成一个简洁、具体的中文标题。只输出标题,不要解释,不要加引号、Markdown 或“标题:”前缀。标题不超过 30 个字符,不要使用斜杠。\n\n${transcript}`;
|
||||
}
|
||||
|
||||
export function normalizeGeneratedConversationTitle(value: string) {
|
||||
let title = value
|
||||
.replace(/<think>[\s\S]*?<\/think>/gi, "")
|
||||
.trim()
|
||||
.split(/\r?\n/)[0]
|
||||
.replace(/^#{1,6}\s*/, "")
|
||||
.replace(/^(?:标题|title)\s*[::]\s*/i, "")
|
||||
.replace(/^[`'“”‘’\"]+|[`'“”‘’\"]+$/g, "")
|
||||
.replace(/[\u0000-\u001f]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.replaceAll("/", "/")
|
||||
.trim();
|
||||
title = [...title].slice(0, 60).join("").trim();
|
||||
return title;
|
||||
}
|
||||
@@ -8,24 +8,101 @@ export type ResponseMetadata = {
|
||||
tokensPerSecond: number | null;
|
||||
};
|
||||
|
||||
export type MessageOrigin =
|
||||
| {type: "user"; clientId?: string; sourceMessageId?: string}
|
||||
| {type: "manual"; clientId?: string; sourceMessageId?: string}
|
||||
| {type: "model"; providerId: string; model: string; attemptId: string}
|
||||
| {type: "system"; source: string}
|
||||
| {type: "legacy"};
|
||||
|
||||
export type MessageCompletion = {
|
||||
status: "complete" | "partial";
|
||||
reason?: "stop" | "user-cancelled" | "connection-lost" | "provider-error" | "timeout";
|
||||
};
|
||||
|
||||
export type StoredChatMessage = {
|
||||
id: string;
|
||||
parentMessageId: string | null;
|
||||
role: "system" | "user" | "assistant";
|
||||
parts: Array<Record<string, unknown> & {type: string}>;
|
||||
origin: MessageOrigin;
|
||||
completion: MessageCompletion;
|
||||
createdAt: string;
|
||||
completedAt: string;
|
||||
metadata?: {custom?: {response?: ResponseMetadata}};
|
||||
};
|
||||
|
||||
export type ConversationSummary = {
|
||||
id: string;
|
||||
title: string;
|
||||
name: string;
|
||||
headMessageId: string | null;
|
||||
providerId: string;
|
||||
model: string;
|
||||
messageCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
upstreamHeadMessageId?: string | null;
|
||||
headVersion?: number;
|
||||
metadataVersion?: number;
|
||||
};
|
||||
|
||||
export type Conversation = ConversationSummary & {
|
||||
generationSettings: GenerationSettings;
|
||||
messages: StoredChatMessage[];
|
||||
};
|
||||
|
||||
export type WorkingItemKind = "user-draft" | "assistant-stream";
|
||||
export type WorkingItemStatus = "editing" | "streaming" | "interrupted" | "failed";
|
||||
|
||||
export type WorkingItem = {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
kind: WorkingItemKind;
|
||||
observedHeadId: string | null;
|
||||
editSourceMessageId?: string;
|
||||
messageRole?: "user" | "assistant";
|
||||
requestAssistantReply?: boolean;
|
||||
incompleteTargetAction?: "interrupt" | "append";
|
||||
parts: StoredChatMessage["parts"];
|
||||
status: WorkingItemStatus;
|
||||
attemptId?: string;
|
||||
providerId?: string;
|
||||
model?: string;
|
||||
failureReason?: MessageCompletion["reason"];
|
||||
metadata?: StoredChatMessage["metadata"];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type ConversationRefState = {
|
||||
id: string;
|
||||
name: string;
|
||||
headMessageId: string | null;
|
||||
providerId: string;
|
||||
model: string;
|
||||
generationSettings: GenerationSettings;
|
||||
headVersion: number;
|
||||
metadataVersion: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type RepositoryFetch = {
|
||||
refs: ConversationRefState[];
|
||||
objects: StoredChatMessage[];
|
||||
fetchedAt: string;
|
||||
};
|
||||
|
||||
export type RepositoryRefUpdate = {
|
||||
conversationId: string;
|
||||
expectedHeadMessageId: string | null;
|
||||
expectedHeadVersion: number;
|
||||
expectedMetadataVersion: number;
|
||||
headMessageId: string | null;
|
||||
name: string;
|
||||
providerId: string;
|
||||
model: string;
|
||||
generationSettings: GenerationSettings;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
+599
-129
@@ -1,14 +1,178 @@
|
||||
import {randomUUID} from "node:crypto";
|
||||
import {createHash, randomUUID} from "node:crypto";
|
||||
import {mkdirSync} from "node:fs";
|
||||
import path from "node:path";
|
||||
import {Database} from "bun:sqlite";
|
||||
import {normalizeGenerationSettings, type GenerationSettings} from "./generation-settings";
|
||||
import type {ChatIdentity} from "./key-vault";
|
||||
import type {Conversation, ConversationSummary, ResponseMetadata, StoredChatMessage} from "./conversation-types";
|
||||
import type {
|
||||
Conversation,
|
||||
ConversationRefState,
|
||||
ConversationSummary,
|
||||
MessageCompletion,
|
||||
MessageOrigin,
|
||||
ResponseMetadata,
|
||||
RepositoryFetch,
|
||||
RepositoryRefUpdate,
|
||||
StoredChatMessage
|
||||
} from "./conversation-types";
|
||||
import {canonicalMessage} from "./message-object";
|
||||
|
||||
const databasePath = process.env.CHAT_DATABASE_PATH || "/data/chat.db";
|
||||
let database: Database | undefined;
|
||||
|
||||
type ConversationRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
head_message_id: string | null;
|
||||
provider_id: string;
|
||||
model: string;
|
||||
settings_json: string;
|
||||
head_version: number;
|
||||
metadata_version: number;
|
||||
message_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
type MessageRow = {
|
||||
id: string;
|
||||
parent_message_id: string | null;
|
||||
role: StoredChatMessage["role"];
|
||||
parts_json: string;
|
||||
origin_json: string;
|
||||
completion_json: string;
|
||||
metadata_json: string;
|
||||
depth: number;
|
||||
created_at: string;
|
||||
completed_at: string;
|
||||
};
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function columns(opened: Database, table: string) {
|
||||
return opened.query(`PRAGMA table_info(${table})`).all() as Array<{name: string}>;
|
||||
}
|
||||
|
||||
function migrateLegacyMessages(opened: Database) {
|
||||
const conversations = opened.query(`
|
||||
SELECT id, owner_issuer, owner_sub FROM chat_conversation
|
||||
WHERE head_message_id IS NULL
|
||||
AND EXISTS (SELECT 1 FROM chat_message WHERE conversation_id = chat_conversation.id)
|
||||
`).all() as Array<{id: string; owner_issuer: string; owner_sub: string}>;
|
||||
if (!conversations.length) return;
|
||||
opened.run("BEGIN IMMEDIATE");
|
||||
try {
|
||||
const existingNode = opened.query("SELECT id FROM chat_message_node WHERE id = ?");
|
||||
const insertNode = opened.query(`
|
||||
INSERT INTO chat_message_node (
|
||||
id, owner_issuer, owner_sub, parent_message_id, role, parts_json, origin_json,
|
||||
completion_json, metadata_json, depth, created_at, completed_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const updateHead = opened.query("UPDATE chat_conversation SET head_message_id = ? WHERE id = ?");
|
||||
for (const conversation of conversations) {
|
||||
const messages = opened.query(`
|
||||
SELECT id, role, parts_json, created_at FROM chat_message
|
||||
WHERE conversation_id = ? ORDER BY ordinal
|
||||
`).all(conversation.id) as Array<{id: string; role: StoredChatMessage["role"]; parts_json: string; created_at: string}>;
|
||||
let parentId: string | null = null;
|
||||
messages.forEach((legacy, depth) => {
|
||||
let id = legacy.id;
|
||||
if (existingNode.get(id)) id = randomUUID();
|
||||
const parsed = JSON.parse(legacy.parts_json) as StoredChatMessage["parts"];
|
||||
const metadataPart = parsed.find((part) => part.type === "data-response-metadata" && part.data && typeof part.data === "object");
|
||||
const parts = parsed.filter((part) => part.type !== "data-response-metadata");
|
||||
const metadata = metadataPart ? {custom: {response: metadataPart.data as ResponseMetadata}} : {};
|
||||
insertNode.run(
|
||||
id,
|
||||
conversation.owner_issuer,
|
||||
conversation.owner_sub,
|
||||
parentId,
|
||||
legacy.role,
|
||||
JSON.stringify(parts),
|
||||
JSON.stringify({type: "legacy"}),
|
||||
JSON.stringify({status: "complete"}),
|
||||
JSON.stringify(metadata),
|
||||
depth,
|
||||
legacy.created_at,
|
||||
legacy.created_at
|
||||
);
|
||||
parentId = id;
|
||||
});
|
||||
updateHead.run(parentId, conversation.id);
|
||||
}
|
||||
opened.run("COMMIT");
|
||||
} catch (error) {
|
||||
opened.run("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function migrateConversationHeadsToContentObjects(opened: Database) {
|
||||
const conversations = opened.query(`
|
||||
SELECT id, owner_issuer, owner_sub, head_message_id
|
||||
FROM chat_conversation
|
||||
WHERE head_message_id IS NOT NULL AND head_message_id NOT LIKE 'sha256:%'
|
||||
`).all() as Array<{id: string; owner_issuer: string; owner_sub: string; head_message_id: string}>;
|
||||
if (!conversations.length) return;
|
||||
opened.run("BEGIN IMMEDIATE");
|
||||
try {
|
||||
const read = opened.query(`
|
||||
SELECT id, parent_message_id, role, parts_json, origin_json, completion_json, metadata_json,
|
||||
depth, created_at, completed_at
|
||||
FROM chat_message_node WHERE id = ? AND owner_issuer = ? AND owner_sub = ?
|
||||
`);
|
||||
const insert = opened.query(`
|
||||
INSERT OR IGNORE INTO chat_message_node (
|
||||
id, owner_issuer, owner_sub, parent_message_id, role, parts_json, origin_json,
|
||||
completion_json, metadata_json, depth, created_at, completed_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const update = opened.query("UPDATE chat_conversation SET head_message_id = ?, head_version = head_version + 1 WHERE id = ?");
|
||||
for (const conversation of conversations) {
|
||||
const path: MessageRow[] = [];
|
||||
let cursor: string | null = conversation.head_message_id;
|
||||
while (cursor) {
|
||||
const row = read.get(cursor, conversation.owner_issuer, conversation.owner_sub) as MessageRow | undefined;
|
||||
if (!row) throw new Error(`Legacy object ${cursor} is unavailable`);
|
||||
path.push(row);
|
||||
cursor = row.parent_message_id;
|
||||
}
|
||||
path.reverse();
|
||||
const namespace = createHash("sha256").update(`${conversation.owner_issuer}\0${conversation.owner_sub}`).digest("hex").slice(0, 32);
|
||||
let parentMessageId: string | null = null;
|
||||
for (const row of path) {
|
||||
const parsed = parsedMessage(row);
|
||||
const {id: _legacyId, ...legacyContent} = parsed;
|
||||
const content: Omit<StoredChatMessage, "id"> = {...legacyContent, parentMessageId};
|
||||
const id: string = `sha256:${createHash("sha256").update(`${namespace}\0${canonicalMessage(content)}`).digest("hex")}`;
|
||||
insert.run(
|
||||
id,
|
||||
conversation.owner_issuer,
|
||||
conversation.owner_sub,
|
||||
parentMessageId,
|
||||
parsed.role,
|
||||
JSON.stringify(parsed.parts),
|
||||
JSON.stringify(parsed.origin),
|
||||
JSON.stringify(parsed.completion),
|
||||
JSON.stringify(parsed.metadata || {}),
|
||||
row.depth,
|
||||
parsed.createdAt,
|
||||
parsed.completedAt
|
||||
);
|
||||
parentMessageId = id;
|
||||
}
|
||||
update.run(parentMessageId, conversation.id);
|
||||
}
|
||||
opened.run("COMMIT");
|
||||
} catch (error) {
|
||||
opened.run("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function getDatabase() {
|
||||
if (database) return database;
|
||||
mkdirSync(path.dirname(databasePath), {recursive: true});
|
||||
@@ -41,189 +205,359 @@ function getDatabase() {
|
||||
UNIQUE (conversation_id, ordinal)
|
||||
);
|
||||
`);
|
||||
const columns = opened.query("PRAGMA table_info(chat_conversation)").all() as Array<{name: string}>;
|
||||
if (!columns.some((column) => column.name === "settings_json")) {
|
||||
const conversationColumns = columns(opened, "chat_conversation");
|
||||
if (!conversationColumns.some((column) => column.name === "settings_json")) {
|
||||
opened.run("ALTER TABLE chat_conversation ADD COLUMN settings_json TEXT NOT NULL DEFAULT '{}'");
|
||||
}
|
||||
if (!conversationColumns.some((column) => column.name === "name")) {
|
||||
opened.run("ALTER TABLE chat_conversation ADD COLUMN name TEXT");
|
||||
opened.run("UPDATE chat_conversation SET name = title WHERE name IS NULL");
|
||||
}
|
||||
if (!conversationColumns.some((column) => column.name === "head_message_id")) {
|
||||
opened.run("ALTER TABLE chat_conversation ADD COLUMN head_message_id TEXT");
|
||||
}
|
||||
if (!conversationColumns.some((column) => column.name === "head_version")) {
|
||||
opened.run("ALTER TABLE chat_conversation ADD COLUMN head_version INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
if (!conversationColumns.some((column) => column.name === "metadata_version")) {
|
||||
opened.run("ALTER TABLE chat_conversation ADD COLUMN metadata_version INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
opened.run(`
|
||||
CREATE TABLE IF NOT EXISTS chat_message_node (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_issuer TEXT NOT NULL,
|
||||
owner_sub TEXT NOT NULL,
|
||||
parent_message_id TEXT REFERENCES chat_message_node(id),
|
||||
role TEXT NOT NULL CHECK (role IN ('system', 'user', 'assistant')),
|
||||
parts_json TEXT NOT NULL,
|
||||
origin_json TEXT NOT NULL,
|
||||
completion_json TEXT NOT NULL,
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
depth INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS chat_message_node_owner_parent
|
||||
ON chat_message_node (owner_issuer, owner_sub, parent_message_id);
|
||||
CREATE INDEX IF NOT EXISTS chat_conversation_owner_name
|
||||
ON chat_conversation (owner_issuer, owner_sub, name);
|
||||
`);
|
||||
migrateLegacyMessages(opened);
|
||||
migrateConversationHeadsToContentObjects(opened);
|
||||
database = opened;
|
||||
return opened;
|
||||
}
|
||||
|
||||
type ConversationRow = {
|
||||
id: string;
|
||||
title: string;
|
||||
provider_id: string;
|
||||
model: string;
|
||||
settings_json: string;
|
||||
message_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
type MessageRow = {id: string; role: StoredChatMessage["role"]; parts_json: string};
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, field: string, maximum: number) {
|
||||
if (typeof value !== "string" || !value.trim()) throw new Error(`${field} is required`);
|
||||
return value.trim().slice(0, maximum);
|
||||
}
|
||||
|
||||
function conversationName(value: unknown) {
|
||||
if (typeof value !== "string") throw new Error("name must be a string");
|
||||
return value.trim().slice(0, 300);
|
||||
}
|
||||
|
||||
function nullableId(value: unknown, field: string) {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
if (typeof value !== "string" || !value.trim()) throw new Error(`${field} is invalid`);
|
||||
return value.trim().slice(0, 160);
|
||||
}
|
||||
|
||||
function conversationSummary(row: ConversationRow): ConversationSummary {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
name: row.name,
|
||||
headMessageId: row.head_message_id,
|
||||
providerId: row.provider_id,
|
||||
model: row.model,
|
||||
messageCount: Number(row.message_count),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
updatedAt: row.updated_at,
|
||||
upstreamHeadMessageId: row.head_message_id,
|
||||
headVersion: Number(row.head_version || 0),
|
||||
metadataVersion: Number(row.metadata_version || 0)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMessages(value: unknown): StoredChatMessage[] {
|
||||
if (!Array.isArray(value) || value.length > 500) throw new Error("messages must contain at most 500 entries");
|
||||
return value.map((message, index) => {
|
||||
if (!message || typeof message !== "object" || Array.isArray(message)) throw new Error(`messages[${index}] is invalid`);
|
||||
const record = message as Record<string, unknown>;
|
||||
const role = record.role;
|
||||
if (!["system", "user", "assistant"].includes(String(role))) throw new Error(`messages[${index}].role is invalid`);
|
||||
if (!Array.isArray(record.parts)) throw new Error(`messages[${index}].parts is required`);
|
||||
const partsJson = JSON.stringify(record.parts);
|
||||
if (Buffer.byteLength(partsJson) > 1024 * 1024) throw new Error(`messages[${index}] is too large`);
|
||||
const metadataJson = JSON.stringify(record.metadata || {});
|
||||
if (Buffer.byteLength(metadataJson) > 64 * 1024) throw new Error(`messages[${index}].metadata is too large`);
|
||||
return {
|
||||
id: typeof record.id === "string" && record.id.trim() ? record.id.trim().slice(0, 160) : randomUUID(),
|
||||
role: role as StoredChatMessage["role"],
|
||||
parts: JSON.parse(partsJson) as StoredChatMessage["parts"],
|
||||
...(record.metadata && typeof record.metadata === "object" && !Array.isArray(record.metadata) ? {metadata: JSON.parse(metadataJson) as StoredChatMessage["metadata"]} : {})
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function inferredTitle(messages: StoredChatMessage[]) {
|
||||
const userMessage = messages.find((message) => message.role === "user");
|
||||
const text = userMessage?.parts
|
||||
.filter((part) => part.type === "text" && typeof part.text === "string")
|
||||
.map((part) => String(part.text))
|
||||
.join(" ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return text ? text.slice(0, 60) : "新对话";
|
||||
}
|
||||
const conversationSelect = `
|
||||
SELECT c.id, COALESCE(c.name, c.title) AS name, c.head_message_id, c.provider_id, c.model,
|
||||
c.head_version, c.metadata_version,
|
||||
c.settings_json, c.created_at, c.updated_at, COALESCE(h.depth + 1, 0) AS message_count
|
||||
FROM chat_conversation c
|
||||
LEFT JOIN chat_message_node h ON h.id = c.head_message_id
|
||||
`;
|
||||
|
||||
function ownedConversation(identity: ChatIdentity, id: string) {
|
||||
return getDatabase().query(`
|
||||
SELECT c.id, c.title, c.provider_id, c.model, c.settings_json, c.created_at, c.updated_at,
|
||||
(SELECT COUNT(*) FROM chat_message m WHERE m.conversation_id = c.id) AS message_count
|
||||
FROM chat_conversation c
|
||||
return getDatabase().query(`${conversationSelect}
|
||||
WHERE c.id = ? AND c.owner_issuer = ? AND c.owner_sub = ?
|
||||
`).get(id, identity.issuer, identity.sub) as ConversationRow | undefined;
|
||||
}
|
||||
|
||||
function ownedMessage(identity: ChatIdentity, id: string) {
|
||||
return getDatabase().query(`
|
||||
SELECT id, parent_message_id, role, parts_json, origin_json, completion_json, metadata_json,
|
||||
depth, created_at, completed_at
|
||||
FROM chat_message_node WHERE id = ? AND owner_issuer = ? AND owner_sub = ?
|
||||
`).get(id, identity.issuer, identity.sub) as MessageRow | undefined;
|
||||
}
|
||||
|
||||
function parsedMessage(row: MessageRow): StoredChatMessage {
|
||||
const metadata = JSON.parse(row.metadata_json || "{}") as StoredChatMessage["metadata"];
|
||||
return {
|
||||
id: row.id,
|
||||
parentMessageId: row.parent_message_id,
|
||||
role: row.role,
|
||||
parts: JSON.parse(row.parts_json),
|
||||
origin: JSON.parse(row.origin_json),
|
||||
completion: JSON.parse(row.completion_json),
|
||||
createdAt: row.created_at,
|
||||
completedAt: row.completed_at,
|
||||
...(metadata && Object.keys(metadata).length ? {metadata} : {})
|
||||
};
|
||||
}
|
||||
|
||||
function messagePath(identity: ChatIdentity, headId: string | null) {
|
||||
const reversed: StoredChatMessage[] = [];
|
||||
const seen = new Set<string>();
|
||||
let id = headId;
|
||||
while (id) {
|
||||
if (seen.has(id) || reversed.length >= 500) throw new Error("Message history is cyclic or too long");
|
||||
seen.add(id);
|
||||
const row = ownedMessage(identity, id);
|
||||
if (!row) throw new Error("Conversation points to an unavailable message");
|
||||
reversed.push(parsedMessage(row));
|
||||
id = row.parent_message_id;
|
||||
}
|
||||
return reversed.reverse();
|
||||
}
|
||||
|
||||
function normalizedParts(value: unknown) {
|
||||
if (!Array.isArray(value)) throw new Error("parts is required");
|
||||
const encoded = JSON.stringify(value);
|
||||
if (Buffer.byteLength(encoded) > 1024 * 1024) throw new Error("message is too large");
|
||||
return JSON.parse(encoded) as StoredChatMessage["parts"];
|
||||
}
|
||||
|
||||
function normalizedOrigin(value: unknown, role: StoredChatMessage["role"]): MessageOrigin {
|
||||
if (value && typeof value === "object" && !Array.isArray(value) && typeof (value as {type?: unknown}).type === "string") {
|
||||
return JSON.parse(JSON.stringify(value)) as MessageOrigin;
|
||||
}
|
||||
if (role === "user") return {type: "user"};
|
||||
if (role === "system") return {type: "system", source: "chat"};
|
||||
return {type: "legacy"};
|
||||
}
|
||||
|
||||
function normalizedCompletion(value: unknown): MessageCompletion {
|
||||
if (value && typeof value === "object" && !Array.isArray(value) && (value as {status?: unknown}).status === "partial") {
|
||||
return JSON.parse(JSON.stringify(value)) as MessageCompletion;
|
||||
}
|
||||
return {status: "complete"};
|
||||
}
|
||||
|
||||
function normalizedMetadata(value: unknown) {
|
||||
const metadata = value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
||||
const encoded = JSON.stringify(metadata);
|
||||
if (Buffer.byteLength(encoded) > 64 * 1024) throw new Error("message metadata is too large");
|
||||
return encoded;
|
||||
}
|
||||
|
||||
export function listConversations(identity: ChatIdentity): ConversationSummary[] {
|
||||
return (getDatabase().query(`
|
||||
SELECT c.id, c.title, c.provider_id, c.model, c.settings_json, c.created_at, c.updated_at,
|
||||
(SELECT COUNT(*) FROM chat_message m WHERE m.conversation_id = c.id) AS message_count
|
||||
FROM chat_conversation c
|
||||
return (getDatabase().query(`${conversationSelect}
|
||||
WHERE c.owner_issuer = ? AND c.owner_sub = ?
|
||||
ORDER BY c.updated_at DESC
|
||||
LIMIT 100
|
||||
ORDER BY c.updated_at DESC LIMIT 100
|
||||
`).all(identity.issuer, identity.sub) as ConversationRow[]).map(conversationSummary);
|
||||
}
|
||||
|
||||
export function createConversation(identity: ChatIdentity, input: {providerId: unknown; model: unknown; generationSettings?: unknown}): Conversation {
|
||||
export function createConversation(identity: ChatIdentity, input: {
|
||||
providerId: unknown;
|
||||
model: unknown;
|
||||
generationSettings?: unknown;
|
||||
name?: unknown;
|
||||
headMessageId?: unknown;
|
||||
}): Conversation {
|
||||
const providerId = requiredString(input.providerId, "providerId", 80);
|
||||
const model = requiredString(input.model, "model", 300);
|
||||
const name = input.name === undefined ? "" : conversationName(input.name);
|
||||
const headMessageId = nullableId(input.headMessageId, "headMessageId");
|
||||
if (headMessageId && !ownedMessage(identity, headMessageId)) throw new Error("headMessageId is unavailable");
|
||||
const generationSettings = normalizeGenerationSettings(input.generationSettings);
|
||||
const timestamp = now();
|
||||
const id = randomUUID();
|
||||
getDatabase().run("BEGIN IMMEDIATE");
|
||||
try {
|
||||
getDatabase().query(`
|
||||
DELETE FROM chat_conversation
|
||||
WHERE owner_issuer = ? AND owner_sub = ?
|
||||
AND NOT EXISTS (SELECT 1 FROM chat_message WHERE conversation_id = chat_conversation.id)
|
||||
`).run(identity.issuer, identity.sub);
|
||||
getDatabase().query(`
|
||||
INSERT INTO chat_conversation (id, owner_issuer, owner_sub, title, provider_id, model, settings_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, '新对话', ?, ?, ?, ?, ?)
|
||||
`).run(id, identity.issuer, identity.sub, providerId, model, JSON.stringify(generationSettings), timestamp, timestamp);
|
||||
getDatabase().run("COMMIT");
|
||||
} catch (error) {
|
||||
getDatabase().run("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
return {...conversationSummary({id, title: "新对话", provider_id: providerId, model, settings_json: JSON.stringify(generationSettings), message_count: 0, created_at: timestamp, updated_at: timestamp}), generationSettings, messages: []};
|
||||
getDatabase().query(`
|
||||
INSERT INTO chat_conversation (
|
||||
id, owner_issuer, owner_sub, title, name, head_message_id, provider_id, model,
|
||||
settings_json, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, identity.issuer, identity.sub, name, name, headMessageId, providerId, model, JSON.stringify(generationSettings), timestamp, timestamp);
|
||||
return getConversation(identity, id)!;
|
||||
}
|
||||
|
||||
export function getConversation(identity: ChatIdentity, id: string): Conversation | null {
|
||||
const row = ownedConversation(identity, id);
|
||||
if (!row) return null;
|
||||
const messages = (getDatabase().query(`
|
||||
SELECT id, role, parts_json FROM chat_message WHERE conversation_id = ? ORDER BY ordinal
|
||||
`).all(id) as MessageRow[]).map((message) => {
|
||||
const parsed = JSON.parse(message.parts_json) as StoredChatMessage["parts"];
|
||||
const metadataPart = parsed.find((part) => part.type === "data-response-metadata" && part.data && typeof part.data === "object");
|
||||
return {
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
parts: parsed.filter((part) => part.type !== "data-response-metadata"),
|
||||
...(metadataPart ? {metadata: {custom: {response: metadataPart.data as ResponseMetadata}}} : {})
|
||||
};
|
||||
});
|
||||
return {...conversationSummary(row), generationSettings: normalizeGenerationSettings(JSON.parse(row.settings_json || "{}")), messages};
|
||||
return {
|
||||
...conversationSummary(row),
|
||||
generationSettings: normalizeGenerationSettings(JSON.parse(row.settings_json || "{}")),
|
||||
messages: messagePath(identity, row.head_message_id)
|
||||
};
|
||||
}
|
||||
|
||||
export function updateConversationSettings(identity: ChatIdentity, id: string, input: {providerId: unknown; model: unknown; generationSettings?: unknown}) {
|
||||
if (!ownedConversation(identity, id)) return false;
|
||||
const providerId = requiredString(input.providerId, "providerId", 80);
|
||||
const model = requiredString(input.model, "model", 300);
|
||||
const generationSettings = normalizeGenerationSettings(input.generationSettings);
|
||||
getDatabase().query(`
|
||||
UPDATE chat_conversation SET provider_id = ?, model = ?, settings_json = ?, updated_at = ?
|
||||
WHERE id = ? AND owner_issuer = ? AND owner_sub = ?
|
||||
`).run(providerId, model, JSON.stringify(generationSettings), now(), id, identity.issuer, identity.sub);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function saveConversationMessages(identity: ChatIdentity, id: string, input: {
|
||||
providerId: unknown;
|
||||
model: unknown;
|
||||
messages: unknown;
|
||||
export function updateConversation(identity: ChatIdentity, id: string, input: {
|
||||
providerId?: unknown;
|
||||
model?: unknown;
|
||||
generationSettings?: unknown;
|
||||
name?: unknown;
|
||||
}) {
|
||||
const existing = ownedConversation(identity, id);
|
||||
if (!existing) return null;
|
||||
const providerId = requiredString(input.providerId, "providerId", 80);
|
||||
const model = requiredString(input.model, "model", 300);
|
||||
const messages = normalizeMessages(input.messages);
|
||||
const providerId = input.providerId === undefined ? existing.provider_id : requiredString(input.providerId, "providerId", 80);
|
||||
const model = input.model === undefined ? existing.model : requiredString(input.model, "model", 300);
|
||||
const name = input.name === undefined ? existing.name : conversationName(input.name);
|
||||
const generationSettings = input.generationSettings === undefined
|
||||
? normalizeGenerationSettings(JSON.parse(existing.settings_json || "{}"))
|
||||
: normalizeGenerationSettings(input.generationSettings);
|
||||
getDatabase().query(`
|
||||
UPDATE chat_conversation SET title = ?, name = ?, provider_id = ?, model = ?, settings_json = ?, metadata_version = metadata_version + 1, updated_at = ?
|
||||
WHERE id = ? AND owner_issuer = ? AND owner_sub = ?
|
||||
`).run(name, name, providerId, model, JSON.stringify(generationSettings), now(), id, identity.issuer, identity.sub);
|
||||
return getConversation(identity, id);
|
||||
}
|
||||
|
||||
export function appendConversationMessage(identity: ChatIdentity, conversationId: string, input: {
|
||||
id?: unknown;
|
||||
expectedHeadId?: unknown;
|
||||
parentMessageId?: unknown;
|
||||
role?: unknown;
|
||||
parts?: unknown;
|
||||
origin?: unknown;
|
||||
completion?: unknown;
|
||||
metadata?: unknown;
|
||||
providerId?: unknown;
|
||||
model?: unknown;
|
||||
}) {
|
||||
const role = String(input.role || "") as StoredChatMessage["role"];
|
||||
if (!["system", "user", "assistant"].includes(role)) throw new Error("role is invalid");
|
||||
const id = input.id === undefined ? randomUUID() : requiredString(input.id, "id", 160);
|
||||
const expectedHeadId = nullableId(input.expectedHeadId, "expectedHeadId");
|
||||
const parentMessageId = nullableId(input.parentMessageId, "parentMessageId");
|
||||
const parts = normalizedParts(input.parts);
|
||||
const origin = normalizedOrigin(input.origin, role);
|
||||
const completion = normalizedCompletion(input.completion);
|
||||
const metadataJson = normalizedMetadata(input.metadata);
|
||||
const timestamp = now();
|
||||
const title = existing.title === "新对话" ? inferredTitle(messages) : existing.title;
|
||||
|
||||
getDatabase().run("BEGIN IMMEDIATE");
|
||||
try {
|
||||
getDatabase().query("DELETE FROM chat_message WHERE conversation_id = ?").run(id);
|
||||
const insert = getDatabase().query(`
|
||||
INSERT INTO chat_message (conversation_id, id, ordinal, role, parts_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
messages.forEach((message, ordinal) => {
|
||||
const parts = message.metadata?.custom?.response
|
||||
? [...message.parts, {type: "data-response-metadata", data: message.metadata.custom.response}]
|
||||
: message.parts;
|
||||
insert.run(id, message.id, ordinal, message.role, JSON.stringify(parts), timestamp);
|
||||
});
|
||||
const existing = ownedConversation(identity, conversationId);
|
||||
if (!existing) {
|
||||
getDatabase().run("ROLLBACK");
|
||||
return {status: "missing" as const};
|
||||
}
|
||||
if (existing.head_message_id !== expectedHeadId) {
|
||||
getDatabase().run("ROLLBACK");
|
||||
return {status: "conflict" as const, conversation: getConversation(identity, conversationId)!};
|
||||
}
|
||||
const parent = parentMessageId ? ownedMessage(identity, parentMessageId) : undefined;
|
||||
if (parentMessageId && !parent) throw new Error("parentMessageId is unavailable");
|
||||
const depth = parent ? parent.depth + 1 : 0;
|
||||
const already = ownedMessage(identity, id);
|
||||
if (already) {
|
||||
const same = already.parent_message_id === parentMessageId
|
||||
&& already.role === role
|
||||
&& already.parts_json === JSON.stringify(parts)
|
||||
&& already.origin_json === JSON.stringify(origin)
|
||||
&& already.completion_json === JSON.stringify(completion)
|
||||
&& already.metadata_json === metadataJson;
|
||||
if (!same) throw new Error("message id already exists with different content");
|
||||
} else {
|
||||
getDatabase().query(`
|
||||
INSERT INTO chat_message_node (
|
||||
id, owner_issuer, owner_sub, parent_message_id, role, parts_json, origin_json,
|
||||
completion_json, metadata_json, depth, created_at, completed_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
identity.issuer,
|
||||
identity.sub,
|
||||
parentMessageId,
|
||||
role,
|
||||
JSON.stringify(parts),
|
||||
JSON.stringify(origin),
|
||||
JSON.stringify(completion),
|
||||
metadataJson,
|
||||
depth,
|
||||
timestamp,
|
||||
timestamp
|
||||
);
|
||||
}
|
||||
const providerId = input.providerId === undefined ? existing.provider_id : requiredString(input.providerId, "providerId", 80);
|
||||
const model = input.model === undefined ? existing.model : requiredString(input.model, "model", 300);
|
||||
const name = existing.name;
|
||||
getDatabase().query(`
|
||||
UPDATE chat_conversation SET title = ?, provider_id = ?, model = ?, updated_at = ?
|
||||
UPDATE chat_conversation SET title = ?, name = ?, head_message_id = ?, provider_id = ?, model = ?, head_version = head_version + 1, updated_at = ?
|
||||
WHERE id = ? AND owner_issuer = ? AND owner_sub = ?
|
||||
`).run(title, providerId, model, timestamp, id, identity.issuer, identity.sub);
|
||||
`).run(name, name, id, providerId, model, timestamp, conversationId, identity.issuer, identity.sub);
|
||||
getDatabase().run("COMMIT");
|
||||
return {status: "ok" as const, conversation: getConversation(identity, conversationId)!};
|
||||
} catch (error) {
|
||||
getDatabase().run("ROLLBACK");
|
||||
try { getDatabase().run("ROLLBACK"); } catch {}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLegacyMessages(value: unknown): StoredChatMessage[] {
|
||||
if (!Array.isArray(value) || value.length > 500) throw new Error("messages must contain at most 500 entries");
|
||||
const timestamp = now();
|
||||
let parentMessageId: string | null = null;
|
||||
return value.map((message, index) => {
|
||||
if (!message || typeof message !== "object" || Array.isArray(message)) throw new Error(`messages[${index}] is invalid`);
|
||||
const record = message as Record<string, unknown>;
|
||||
const role = String(record.role) as StoredChatMessage["role"];
|
||||
if (!["system", "user", "assistant"].includes(role)) throw new Error(`messages[${index}].role is invalid`);
|
||||
const normalized: StoredChatMessage = {
|
||||
id: typeof record.id === "string" && record.id.trim() ? record.id.trim().slice(0, 160) : randomUUID(),
|
||||
parentMessageId,
|
||||
role,
|
||||
parts: normalizedParts(record.parts),
|
||||
origin: normalizedOrigin(record.origin, role),
|
||||
completion: normalizedCompletion(record.completion),
|
||||
createdAt: typeof record.createdAt === "string" ? record.createdAt : timestamp,
|
||||
completedAt: typeof record.completedAt === "string" ? record.completedAt : timestamp,
|
||||
...(record.metadata ? {metadata: JSON.parse(normalizedMetadata(record.metadata))} : {})
|
||||
};
|
||||
parentMessageId = normalized.id;
|
||||
return normalized;
|
||||
});
|
||||
}
|
||||
|
||||
// Compatibility for pending writes created by the previous client. New code commits one immutable message at a time.
|
||||
export function saveConversationMessages(identity: ChatIdentity, id: string, input: {providerId: unknown; model: unknown; messages: unknown}) {
|
||||
const existing = ownedConversation(identity, id);
|
||||
if (!existing) return null;
|
||||
const messages = normalizeLegacyMessages(input.messages);
|
||||
let expectedHeadId = existing.head_message_id;
|
||||
for (const message of messages) {
|
||||
const already = ownedMessage(identity, message.id);
|
||||
if (already) {
|
||||
expectedHeadId = message.id;
|
||||
continue;
|
||||
}
|
||||
const result = appendConversationMessage(identity, id, {
|
||||
...message,
|
||||
expectedHeadId,
|
||||
providerId: input.providerId,
|
||||
model: input.model
|
||||
});
|
||||
if (result.status !== "ok") throw new Error("Unable to import legacy conversation path");
|
||||
expectedHeadId = message.id;
|
||||
}
|
||||
if (!messages.length && existing.head_message_id) {
|
||||
getDatabase().query(`
|
||||
UPDATE chat_conversation SET head_message_id = NULL, provider_id = ?, model = ?, updated_at = ?
|
||||
WHERE id = ? AND owner_issuer = ? AND owner_sub = ?
|
||||
`).run(requiredString(input.providerId, "providerId", 80), requiredString(input.model, "model", 300), now(), id, identity.issuer, identity.sub);
|
||||
}
|
||||
return getConversation(identity, id);
|
||||
}
|
||||
|
||||
@@ -233,3 +567,139 @@ export function deleteConversation(identity: ChatIdentity, id: string) {
|
||||
`).run(id, identity.issuer, identity.sub);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
function conversationRef(row: ConversationRow): ConversationRefState {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
headMessageId: row.head_message_id,
|
||||
providerId: row.provider_id,
|
||||
model: row.model,
|
||||
generationSettings: normalizeGenerationSettings(JSON.parse(row.settings_json || "{}")),
|
||||
headVersion: Number(row.head_version || 0),
|
||||
metadataVersion: Number(row.metadata_version || 0),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchRepository(identity: ChatIdentity, haveObjectIds: unknown): RepositoryFetch {
|
||||
const have = new Set(Array.isArray(haveObjectIds)
|
||||
? haveObjectIds.filter((value): value is string => typeof value === "string").slice(0, 100_000)
|
||||
: []);
|
||||
const refs = (getDatabase().query(`${conversationSelect}
|
||||
WHERE c.owner_issuer = ? AND c.owner_sub = ? ORDER BY c.updated_at DESC
|
||||
`).all(identity.issuer, identity.sub) as ConversationRow[]).map(conversationRef);
|
||||
const objects = (getDatabase().query(`
|
||||
SELECT id, parent_message_id, role, parts_json, origin_json, completion_json, metadata_json,
|
||||
depth, created_at, completed_at
|
||||
FROM chat_message_node
|
||||
WHERE owner_issuer = ? AND owner_sub = ?
|
||||
ORDER BY depth, created_at, id
|
||||
`).all(identity.issuer, identity.sub) as MessageRow[])
|
||||
.filter((row) => !have.has(row.id))
|
||||
.map(parsedMessage);
|
||||
return {refs, objects, fetchedAt: now()};
|
||||
}
|
||||
|
||||
export function putRepositoryObjects(identity: ChatIdentity, objects: unknown) {
|
||||
if (!Array.isArray(objects) || objects.length > 1000) throw new Error("objects must contain at most 1000 entries");
|
||||
let inserted = 0;
|
||||
getDatabase().run("BEGIN IMMEDIATE");
|
||||
try {
|
||||
const insert = getDatabase().query(`
|
||||
INSERT INTO chat_message_node (
|
||||
id, owner_issuer, owner_sub, parent_message_id, role, parts_json, origin_json,
|
||||
completion_json, metadata_json, depth, created_at, completed_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const value of objects) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("repository object is invalid");
|
||||
const object = value as StoredChatMessage;
|
||||
if (typeof object.id !== "string" || !object.id.startsWith("sha256:")) throw new Error("repository object id is invalid");
|
||||
if (ownedMessage(identity, object.id)) continue;
|
||||
const role = object.role;
|
||||
if (!["system", "user", "assistant"].includes(role)) throw new Error("repository object role is invalid");
|
||||
const parentMessageId = nullableId(object.parentMessageId, "parentMessageId");
|
||||
const parent = parentMessageId ? ownedMessage(identity, parentMessageId) : undefined;
|
||||
if (parentMessageId && !parent) throw new Error(`parent object ${parentMessageId} is unavailable`);
|
||||
insert.run(
|
||||
object.id,
|
||||
identity.issuer,
|
||||
identity.sub,
|
||||
parentMessageId,
|
||||
role,
|
||||
JSON.stringify(normalizedParts(object.parts)),
|
||||
JSON.stringify(normalizedOrigin(object.origin, role)),
|
||||
JSON.stringify(normalizedCompletion(object.completion)),
|
||||
normalizedMetadata(object.metadata),
|
||||
parent ? parent.depth + 1 : 0,
|
||||
requiredString(object.createdAt, "createdAt", 80),
|
||||
requiredString(object.completedAt, "completedAt", 80)
|
||||
);
|
||||
inserted += 1;
|
||||
}
|
||||
getDatabase().run("COMMIT");
|
||||
} catch (error) {
|
||||
try { getDatabase().run("ROLLBACK"); } catch {}
|
||||
throw error;
|
||||
}
|
||||
return inserted;
|
||||
}
|
||||
|
||||
export function pushRepositoryRef(identity: ChatIdentity, update: RepositoryRefUpdate) {
|
||||
const id = requiredString(update.conversationId, "conversationId", 160);
|
||||
const expectedHeadMessageId = nullableId(update.expectedHeadMessageId, "expectedHeadMessageId");
|
||||
const headMessageId = nullableId(update.headMessageId, "headMessageId");
|
||||
const existing = ownedConversation(identity, id);
|
||||
if (!existing) {
|
||||
if (expectedHeadMessageId !== null || Number(update.expectedHeadVersion || 0) !== 0 || Number(update.expectedMetadataVersion || 0) !== 0) {
|
||||
return {status: "conflict" as const, ref: null};
|
||||
}
|
||||
if (headMessageId && !ownedMessage(identity, headMessageId)) throw new Error("head object is unavailable");
|
||||
const name = conversationName(update.name);
|
||||
const providerId = requiredString(update.providerId, "providerId", 80);
|
||||
const model = requiredString(update.model, "model", 300);
|
||||
const timestamp = now();
|
||||
getDatabase().query(`
|
||||
INSERT INTO chat_conversation (
|
||||
id, owner_issuer, owner_sub, title, name, head_message_id, provider_id, model,
|
||||
settings_json, head_version, metadata_version, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?)
|
||||
`).run(id, identity.issuer, identity.sub, name, name, headMessageId, providerId, model, JSON.stringify(normalizeGenerationSettings(update.generationSettings)), update.createdAt || timestamp, timestamp);
|
||||
return {status: "ok" as const, ref: conversationRef(ownedConversation(identity, id)!)};
|
||||
}
|
||||
if (existing.head_message_id !== expectedHeadMessageId
|
||||
|| Number(existing.head_version || 0) !== Number(update.expectedHeadVersion || 0)
|
||||
|| Number(existing.metadata_version || 0) !== Number(update.expectedMetadataVersion || 0)) {
|
||||
return {status: "conflict" as const, ref: conversationRef(existing)};
|
||||
}
|
||||
if (headMessageId && !ownedMessage(identity, headMessageId)) throw new Error("head object is unavailable");
|
||||
const name = conversationName(update.name);
|
||||
const providerId = requiredString(update.providerId, "providerId", 80);
|
||||
const model = requiredString(update.model, "model", 300);
|
||||
const headChanged = existing.head_message_id !== headMessageId;
|
||||
const metadataChanged = existing.name !== name
|
||||
|| existing.provider_id !== providerId
|
||||
|| existing.model !== model
|
||||
|| existing.settings_json !== JSON.stringify(normalizeGenerationSettings(update.generationSettings));
|
||||
getDatabase().query(`
|
||||
UPDATE chat_conversation SET title = ?, name = ?, head_message_id = ?, provider_id = ?, model = ?, settings_json = ?,
|
||||
head_version = head_version + ?, metadata_version = metadata_version + ?, updated_at = ?
|
||||
WHERE id = ? AND owner_issuer = ? AND owner_sub = ?
|
||||
`).run(
|
||||
name,
|
||||
name,
|
||||
headMessageId,
|
||||
providerId,
|
||||
model,
|
||||
JSON.stringify(normalizeGenerationSettings(update.generationSettings)),
|
||||
headChanged ? 1 : 0,
|
||||
metadataChanged ? 1 : 0,
|
||||
now(),
|
||||
id,
|
||||
identity.issuer,
|
||||
identity.sub
|
||||
);
|
||||
return {status: "ok" as const, ref: conversationRef(ownedConversation(identity, id)!)};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import {describe, expect, test} from "bun:test";
|
||||
import {
|
||||
fullscreenEditorCharacterThreshold,
|
||||
fullscreenEditorLineThreshold,
|
||||
shouldOpenFullscreenEditor
|
||||
} from "./fullscreen-editor.ts";
|
||||
|
||||
describe("fullscreen editor", () => {
|
||||
test("keeps short messages in the compact composer", () => {
|
||||
expect(shouldOpenFullscreenEditor("a".repeat(fullscreenEditorCharacterThreshold - 1))).toBe(false);
|
||||
expect(shouldOpenFullscreenEditor(Array(fullscreenEditorLineThreshold - 1).fill("line").join("\n"))).toBe(false);
|
||||
});
|
||||
|
||||
test("opens for a long single-line message", () => {
|
||||
expect(shouldOpenFullscreenEditor("a".repeat(fullscreenEditorCharacterThreshold))).toBe(true);
|
||||
});
|
||||
|
||||
test("opens for a message with many lines", () => {
|
||||
expect(shouldOpenFullscreenEditor(Array(fullscreenEditorLineThreshold).fill("line").join("\n"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
export const fullscreenEditorCharacterThreshold = 600;
|
||||
export const fullscreenEditorLineThreshold = 8;
|
||||
|
||||
export function shouldOpenFullscreenEditor(value: unknown) {
|
||||
const text = String(value ?? "");
|
||||
const lineCount = text ? text.split(/\r?\n/).length : 0;
|
||||
return text.length >= fullscreenEditorCharacterThreshold || lineCount >= fullscreenEditorLineThreshold;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {describe, expect, test} from "bun:test";
|
||||
import {applyImportTitleTemplate, importFileStem, importSourceFolder} from "./import-title-template.ts";
|
||||
|
||||
const context = {
|
||||
title: "Repair auth",
|
||||
format: "codex",
|
||||
file: "rollout-123",
|
||||
folder: "13",
|
||||
date: "2026-08-13",
|
||||
model: "gpt-5.6-sol",
|
||||
provider: "openai",
|
||||
index: 7
|
||||
};
|
||||
|
||||
describe("import title templates", () => {
|
||||
test("renders paths and import metadata", () => {
|
||||
expect(applyImportTitleTemplate("{format}/{date}/{title}", context)).toBe("codex/2026-08-13/Repair auth");
|
||||
expect(applyImportTitleTemplate("{index} · {file} · {model}", context)).toBe("7 · rollout-123 · gpt-5.6-sol");
|
||||
});
|
||||
|
||||
test("defaults to the source title and rejects unknown variables", () => {
|
||||
expect(applyImportTitleTemplate("", context)).toBe("Repair auth");
|
||||
expect(() => applyImportTitleTemplate("{project}/{title}", context)).toThrow("未知标题变量");
|
||||
});
|
||||
|
||||
test("extracts source file and folder labels", () => {
|
||||
expect(importFileStem("backup.xiteng-chat.json")).toBe("backup");
|
||||
expect(importFileStem("rollout.jsonl")).toBe("rollout");
|
||||
expect(importSourceFolder("sessions.zip / nested / rollout.jsonl")).toBe("nested");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
export type ImportTitleContext = {
|
||||
title: string;
|
||||
format: string;
|
||||
file: string;
|
||||
folder: string;
|
||||
date: string;
|
||||
model: string;
|
||||
provider: string;
|
||||
index: number;
|
||||
};
|
||||
|
||||
const placeholders = new Set(["title", "format", "file", "folder", "date", "model", "provider", "index"]);
|
||||
|
||||
export function applyImportTitleTemplate(template: string, context: ImportTitleContext) {
|
||||
const source = template.trim() || "{title}";
|
||||
const unknown = [...source.matchAll(/\{([^{}]+)\}/g)]
|
||||
.map((match) => match[1])
|
||||
.filter((name) => !placeholders.has(name));
|
||||
if (unknown.length) throw new Error(`未知标题变量:${[...new Set(unknown)].map((name) => `{${name}}`).join("、")}`);
|
||||
const values: Record<string, string> = {...context, index: String(context.index)};
|
||||
const rendered = source.replace(/\{([^{}]+)\}/g, (match, name) => values[name] ?? match).trim();
|
||||
return rendered || context.title.trim() || "导入的会话";
|
||||
}
|
||||
|
||||
export function importFileStem(filename: string) {
|
||||
return filename.replace(/\.xiteng-chat\.json$/i, "").replace(/\.(?:jsonl|json)$/i, "");
|
||||
}
|
||||
|
||||
export function importSourceFolder(source: string) {
|
||||
const parts = source.replaceAll("\\", "/").split("/").map((part) => part.trim()).filter(Boolean);
|
||||
return parts.length > 1 ? parts.at(-2)! : "";
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import {describe, expect, test} from "bun:test";
|
||||
import {IncrementalMarkdownCache} from "./incremental-markdown-cache.ts";
|
||||
|
||||
describe("incremental markdown cache", () => {
|
||||
test("never parses a stable prefix again", () => {
|
||||
const cache = new IncrementalMarkdownCache();
|
||||
const calls = [];
|
||||
const render = (source, type, index, stable) => {
|
||||
calls.push({source, type, index, stable});
|
||||
return {rendered: source.toUpperCase()};
|
||||
};
|
||||
|
||||
cache.render("message", "first", render);
|
||||
const afterFirst = calls.length;
|
||||
const closed = cache.render("message", "first\n\n", render);
|
||||
const afterClosed = calls.length;
|
||||
const growingTail = cache.render("message", "first\n\nsecond", render);
|
||||
const longerTail = cache.render("message", "first\n\nsecond grows", render);
|
||||
|
||||
expect(afterFirst).toBe(1);
|
||||
expect(afterClosed).toBe(2);
|
||||
expect(closed.blocks[0].stable).toBe(true);
|
||||
expect(growingTail.reused).toBe(1);
|
||||
expect(growingTail.parsed).toBe(1);
|
||||
expect(longerTail.reused).toBe(1);
|
||||
expect(longerTail.parsed).toBe(1);
|
||||
expect(calls.filter((call) => call.source === "first")).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("assigns stable indexes as blocks become frozen", () => {
|
||||
const cache = new IncrementalMarkdownCache();
|
||||
const render = () => ({});
|
||||
const first = cache.render("message", "one\n\ntwo", render);
|
||||
const second = cache.render("message", "one\n\ntwo\n\nthree", render);
|
||||
|
||||
expect(first.blocks.map((block) => [block.index, block.stable])).toEqual([[0, true], [1, false]]);
|
||||
expect(second.blocks.map((block) => [block.index, block.stable])).toEqual([[0, true], [1, true], [2, false]]);
|
||||
});
|
||||
|
||||
test("resets when the source is edited before the stable prefix", () => {
|
||||
const cache = new IncrementalMarkdownCache();
|
||||
const render = (source) => ({rendered: source});
|
||||
cache.render("message", "one\n\ntwo", render);
|
||||
const result = cache.render("message", "changed\n\ntwo", render);
|
||||
expect(result.reused).toBe(0);
|
||||
expect(result.blocks[0].source).toBe("changed");
|
||||
});
|
||||
|
||||
test("moves cached blocks when a working message receives its immutable id", () => {
|
||||
const cache = new IncrementalMarkdownCache();
|
||||
const render = (source) => ({rendered: source});
|
||||
cache.render("working", "first\n\n", render);
|
||||
|
||||
cache.move("working", "immutable");
|
||||
const result = cache.render("immutable", "first\n\nsecond", render);
|
||||
|
||||
expect(cache.has("working")).toBe(false);
|
||||
expect(cache.has("immutable")).toBe(true);
|
||||
expect(result.reused).toBe(1);
|
||||
expect(result.blocks.map((block) => block.index)).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
test("freezes the final open block when the stream completes", () => {
|
||||
const cache = new IncrementalMarkdownCache();
|
||||
const render = (source) => ({rendered: source});
|
||||
cache.render("message", "last paragraph", render);
|
||||
|
||||
const result = cache.render("message", "last paragraph", render, undefined, true);
|
||||
|
||||
expect(result.blocks).toHaveLength(1);
|
||||
expect(result.blocks[0].stable).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import {splitStreamingMarkdown} from "./streaming-markdown";
|
||||
|
||||
export type IncrementalMarkdownBlock<T> = T & {
|
||||
source: string;
|
||||
type: string;
|
||||
index: number;
|
||||
stable: boolean;
|
||||
};
|
||||
|
||||
export type IncrementalMarkdownResult<T> = {
|
||||
blocks: Array<IncrementalMarkdownBlock<T>>;
|
||||
parsed: number;
|
||||
reused: number;
|
||||
};
|
||||
|
||||
type IncrementalMarkdownState<T> = {
|
||||
stableSource: string;
|
||||
stableBlocks: Array<IncrementalMarkdownBlock<T>>;
|
||||
nextIndex: number;
|
||||
};
|
||||
|
||||
export class IncrementalMarkdownCache<T> {
|
||||
private readonly states = new Map<string, IncrementalMarkdownState<T>>();
|
||||
|
||||
render(
|
||||
messageId: string,
|
||||
value: string,
|
||||
renderBlock: (source: string, type: string, index: number, stable: boolean) => T,
|
||||
onSplit?: (durationMs: number) => void,
|
||||
complete = false
|
||||
): IncrementalMarkdownResult<T> {
|
||||
let state = this.states.get(messageId);
|
||||
if (!state || !value.startsWith(state.stableSource)) {
|
||||
state = {stableSource: "", stableBlocks: [], nextIndex: 0};
|
||||
this.states.set(messageId, state);
|
||||
}
|
||||
if (value === state.stableSource) return {blocks: state.stableBlocks, parsed: 0, reused: state.stableBlocks.length};
|
||||
|
||||
const tail = value.slice(state.stableSource.length);
|
||||
const startedAt = performance.now();
|
||||
const split = splitStreamingMarkdown(tail, complete);
|
||||
onSplit?.(performance.now() - startedAt);
|
||||
const renderedTail = split.blocks.map((block, offset) => ({
|
||||
...renderBlock(block.source, block.type, state!.nextIndex + offset, block.stable),
|
||||
source: block.source,
|
||||
type: block.type,
|
||||
index: state!.nextIndex + offset,
|
||||
stable: block.stable
|
||||
}));
|
||||
const newlyStable = renderedTail.filter((_block, index) => split.blocks[index].end <= split.stableOffset);
|
||||
const previouslyStableCount = state.stableBlocks.length;
|
||||
if (split.stableOffset > 0) {
|
||||
state.stableSource += tail.slice(0, split.stableOffset);
|
||||
state.stableBlocks.push(...newlyStable);
|
||||
state.nextIndex += newlyStable.length;
|
||||
}
|
||||
return {
|
||||
blocks: [...state.stableBlocks.slice(0, state.stableBlocks.length - newlyStable.length), ...renderedTail],
|
||||
parsed: renderedTail.length,
|
||||
reused: previouslyStableCount
|
||||
};
|
||||
}
|
||||
|
||||
delete(messageId: string) {
|
||||
this.states.delete(messageId);
|
||||
}
|
||||
|
||||
move(fromMessageId: string, toMessageId: string) {
|
||||
if (fromMessageId === toMessageId) return;
|
||||
const state = this.states.get(fromMessageId);
|
||||
if (!state) return;
|
||||
this.states.set(toMessageId, state);
|
||||
this.states.delete(fromMessageId);
|
||||
}
|
||||
|
||||
has(messageId: string) {
|
||||
return this.states.has(messageId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {describe, expect, test} from "bun:test";
|
||||
import {marked} from "marked";
|
||||
import {protectMath, restoreMath} from "./math-markdown.ts";
|
||||
|
||||
function parse(value) {
|
||||
const {source, fragments} = protectMath(value);
|
||||
return {html: restoreMath(marked.parse(source), fragments), fragments};
|
||||
}
|
||||
|
||||
describe("math markdown boundary", () => {
|
||||
test("preserves inline and display TeX delimiters through Markdown", () => {
|
||||
const {html, fragments} = parse("Inline \\(x^2 + y^2\\) and $z^2$.\n\n$$\\int_0^1 x\\,dx$$\n\n\\[\\frac{a}{b}\\]");
|
||||
expect(fragments).toHaveLength(4);
|
||||
expect(html).toContain("\\(x^2 + y^2\\)");
|
||||
expect(html).toContain("$z^2$");
|
||||
expect(html).toContain("$$\\int_0^1 x\\,dx$$");
|
||||
expect(html).toContain("\\[\\frac{a}{b}\\]");
|
||||
expect(html.match(/class="math-fragment"/g)).toHaveLength(4);
|
||||
});
|
||||
|
||||
test("does not treat escaped dollars as math", () => {
|
||||
const {fragments} = protectMath("Price: \\$5");
|
||||
expect(fragments).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("keeps TeX-looking code as code for MathJax skip tags", () => {
|
||||
const {html} = parse("`$not_math$` and $math$");
|
||||
expect(html).toContain("<code>$not_math$</code>");
|
||||
expect(html).toContain("and <span");
|
||||
expect(html).toContain(">$math$</span>");
|
||||
expect(html.match(/class="math-fragment"/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("escapes HTML embedded inside a math fragment", () => {
|
||||
const {html} = parse("$x <img src=x onerror=alert(1)> y$");
|
||||
expect(html).not.toContain("<img");
|
||||
expect(html).toContain("<img");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
const mathPattern = /(?<!\\)\$\$[\s\S]*?(?<!\\)\$\$|\\\[[\s\S]*?\\\]|\\\([^\n]*?\\\)|(?<![\\$])\$(?!\$)(?:\\.|[^\\$\n])+?(?<!\\)\$(?!\$)/g;
|
||||
const tokenPattern = /\uE000xiteng-math-(\d+)\uE001/g;
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value.replace(/[&<>"']/g, (character) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'"
|
||||
})[character]!);
|
||||
}
|
||||
|
||||
export function protectMath(value: string) {
|
||||
const fragments: string[] = [];
|
||||
const source = value.replace(mathPattern, (fragment) => {
|
||||
const index = fragments.push(fragment) - 1;
|
||||
return `\uE000xiteng-math-${index}\uE001`;
|
||||
});
|
||||
return {source, fragments};
|
||||
}
|
||||
|
||||
function mathFragmentKey(fragment: string, index: number) {
|
||||
let hash = 2166136261;
|
||||
for (let offset = 0; offset < fragment.length; offset += 1) {
|
||||
hash ^= fragment.charCodeAt(offset);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return `${index}-${fragment.length}-${(hash >>> 0).toString(36)}`;
|
||||
}
|
||||
|
||||
export function restoreMath(value: string, fragments: string[]) {
|
||||
return value.replace(tokenPattern, (_token, rawIndex: string, offset: number) => {
|
||||
const index = Number(rawIndex);
|
||||
const fragment = fragments[index] || "";
|
||||
const codeStart = value.lastIndexOf("<code", offset);
|
||||
const codeEnd = value.lastIndexOf("</code>", offset);
|
||||
if (codeStart > codeEnd) return escapeHtml(fragment);
|
||||
return `<span class="math-fragment" data-math-key="${mathFragmentKey(fragment, index)}">${escapeHtml(fragment)}</span>`;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {describe, expect, test} from "bun:test";
|
||||
import {mergeMessageGraph, messageChildrenInGraph, messagePathInGraph, newestBranchTipInGraph, rootEditAlternativesInGraph} from "./message-graph.ts";
|
||||
|
||||
const node = (id, parentMessageId, createdAt) => ({
|
||||
id, parentMessageId, role: id.startsWith("a") ? "assistant" : "user", parts: [],
|
||||
origin: {type: "legacy"}, completion: {status: "complete"}, createdAt, completedAt: createdAt
|
||||
});
|
||||
|
||||
describe("message graph", () => {
|
||||
const root = node("u1", null, "2026-01-01T00:00:00Z");
|
||||
const answerA = node("a1", "u1", "2026-01-01T00:00:01Z");
|
||||
const answerB = node("a2", "u1", "2026-01-01T00:00:02Z");
|
||||
const followupB = node("u2", "a2", "2026-01-01T00:00:03Z");
|
||||
const graph = mergeMessageGraph([root, answerA], [answerB, followupB]);
|
||||
|
||||
test("sorts sibling branches deterministically", () => {
|
||||
expect(messageChildrenInGraph(graph, "u1").map((message) => message.id)).toEqual(["a1", "a2"]);
|
||||
});
|
||||
|
||||
test("builds the selected path without changing a ref", () => {
|
||||
expect(messagePathInGraph(graph, "u2").map((message) => message.id)).toEqual(["u1", "a2", "u2"]);
|
||||
});
|
||||
|
||||
test("uses the current ref for its branch and newest descendants for alternatives", () => {
|
||||
expect(newestBranchTipInGraph(graph, "a1", new Set(["u1", "a1"]), "a1")).toBe("a1");
|
||||
expect(newestBranchTipInGraph(graph, "a2", new Set(["u1", "a1"]), "a1")).toBe("u2");
|
||||
});
|
||||
|
||||
test("does not mix unrelated root messages into first-message edit branches", () => {
|
||||
const editedRoot = {...node("u3", null, "2026-01-01T00:00:04Z"), origin: {type: "user", sourceMessageId: "u1"}};
|
||||
const unrelated = node("u4", null, "2026-01-01T00:00:05Z");
|
||||
const roots = mergeMessageGraph([...graph.values()], [editedRoot, unrelated]);
|
||||
expect(rootEditAlternativesInGraph(roots, "u3").map((message) => message.id)).toEqual(["u1", "u3"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import type {StoredChatMessage} from "./conversation-types";
|
||||
|
||||
export function mergeMessageGraph(...groups: StoredChatMessage[][]) {
|
||||
return new Map(groups.flat().map((message) => [message.id, message]));
|
||||
}
|
||||
|
||||
export function messagePathInGraph(messages: Map<string, StoredChatMessage>, headMessageId: string | null) {
|
||||
const reversed: StoredChatMessage[] = [];
|
||||
const seen = new Set<string>();
|
||||
let cursor = headMessageId;
|
||||
while (cursor) {
|
||||
if (seen.has(cursor) || reversed.length >= 500) return [];
|
||||
seen.add(cursor);
|
||||
const message = messages.get(cursor);
|
||||
if (!message) return [];
|
||||
reversed.push(message);
|
||||
cursor = message.parentMessageId;
|
||||
}
|
||||
return reversed.reverse();
|
||||
}
|
||||
|
||||
export function messageChildrenInGraph(messages: Map<string, StoredChatMessage>, parentMessageId: string | null) {
|
||||
return [...messages.values()]
|
||||
.filter((message) => message.parentMessageId === parentMessageId)
|
||||
.sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
export function rootEditAlternativesInGraph(messages: Map<string, StoredChatMessage>, messageId: string) {
|
||||
const roots = [...messages.values()].filter((message) => message.parentMessageId === null);
|
||||
const related = new Set([messageId]);
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const message of roots) {
|
||||
const sourceMessageId = "sourceMessageId" in message.origin ? message.origin.sourceMessageId : undefined;
|
||||
if (!related.has(message.id) && (!sourceMessageId || !related.has(sourceMessageId))) continue;
|
||||
if (!related.has(message.id)) { related.add(message.id); changed = true; }
|
||||
if (sourceMessageId && !related.has(sourceMessageId)) { related.add(sourceMessageId); changed = true; }
|
||||
}
|
||||
}
|
||||
return roots
|
||||
.filter((message) => related.has(message.id))
|
||||
.sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
export function newestBranchTipInGraph(
|
||||
messages: Map<string, StoredChatMessage>,
|
||||
startId: string,
|
||||
currentPathIds: Set<string>,
|
||||
currentHeadMessageId: string | null
|
||||
) {
|
||||
if (currentPathIds.has(startId)) return currentHeadMessageId || startId;
|
||||
let cursor = startId;
|
||||
const seen = new Set<string>();
|
||||
while (!seen.has(cursor)) {
|
||||
seen.add(cursor);
|
||||
const children = messageChildrenInGraph(messages, cursor);
|
||||
if (!children.length) return cursor;
|
||||
cursor = children.at(-1)!.id;
|
||||
}
|
||||
return startId;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type {StoredChatMessage} from "./conversation-types";
|
||||
|
||||
function canonicalValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(canonicalValue);
|
||||
if (!value || typeof value !== "object") return value;
|
||||
return Object.fromEntries(Object.entries(value as Record<string, unknown>)
|
||||
.filter(([, entry]) => entry !== undefined)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, entry]) => [key, canonicalValue(entry)]));
|
||||
}
|
||||
|
||||
export function canonicalMessage(message: Omit<StoredChatMessage, "id">) {
|
||||
return JSON.stringify(canonicalValue(message));
|
||||
}
|
||||
|
||||
export async function messageObjectId(message: Omit<StoredChatMessage, "id">, namespace = "") {
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`${namespace}\0${canonicalMessage(message)}`));
|
||||
return `sha256:${[...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
||||
}
|
||||
|
||||
export async function createMessageObject(message: Omit<StoredChatMessage, "id">, namespace = ""): Promise<StoredChatMessage> {
|
||||
return {...message, id: await messageObjectId(message, namespace)};
|
||||
}
|
||||
|
||||
export async function validMessageObjectId(message: StoredChatMessage, namespace = "") {
|
||||
if (!message.id.startsWith("sha256:")) return false;
|
||||
const {id: _id, ...content} = message;
|
||||
return message.id === await messageObjectId(content, namespace);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import {describe, expect, test} from "bun:test";
|
||||
import {compactModelName} from "./model-display.ts";
|
||||
|
||||
describe("compact model names", () => {
|
||||
test("hides an Ollama model tag in compact UI", () => {
|
||||
expect(compactModelName("gemma4:e4b-it-qat")).toBe("gemma4");
|
||||
expect(compactModelName("qwen3.5:9b-q4_K_M")).toBe("qwen3.5");
|
||||
});
|
||||
|
||||
test("keeps ordinary model identifiers unchanged", () => {
|
||||
expect(compactModelName("claude-sonnet-4-6")).toBe("claude-sonnet-4-6");
|
||||
expect(compactModelName("Qwen3.5-9B-Q4_K_M.gguf")).toBe("Qwen3.5-9B-Q4_K_M.gguf");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
export function compactModelName(value: unknown) {
|
||||
const name = String(value ?? "").trim();
|
||||
const tagIndex = name.indexOf(":");
|
||||
return tagIndex > 0 ? name.slice(0, tagIndex) : name;
|
||||
}
|
||||
+628
-38
@@ -1,7 +1,7 @@
|
||||
import type {Conversation, ConversationSummary} from "./conversation-types";
|
||||
import type {Conversation, ConversationRefState, ConversationSummary, RepositoryFetch, RepositoryRefUpdate, StoredChatMessage, WorkingItem} from "./conversation-types";
|
||||
|
||||
const databaseName = "xiteng-chat-offline";
|
||||
const databaseVersion = 1;
|
||||
const databaseVersion = 3;
|
||||
const activeProfileKey = "xiteng-chat-offline-profile";
|
||||
|
||||
type CachedProfile<T = unknown> = {
|
||||
@@ -9,15 +9,46 @@ type CachedProfile<T = unknown> = {
|
||||
config: T;
|
||||
summaries: ConversationSummary[];
|
||||
updatedAt: string;
|
||||
lastFetchAt?: string;
|
||||
};
|
||||
|
||||
type CachedConversation = Conversation & {cacheKey: string; profileId: string};
|
||||
type CachedConversationRef = Omit<Conversation, "messages"> & {
|
||||
cacheKey: string;
|
||||
profileId: string;
|
||||
messages?: StoredChatMessage[];
|
||||
};
|
||||
|
||||
type CachedMessage = StoredChatMessage & {cacheKey: string; profileId: string};
|
||||
type CachedWorkingItem = WorkingItem & {cacheKey: string; profileId: string};
|
||||
|
||||
type CachedReflog = {
|
||||
cacheKey: string;
|
||||
profileId: string;
|
||||
conversationId: string;
|
||||
oldHeadMessageId: string | null;
|
||||
newHeadMessageId: string | null;
|
||||
reason: "commit" | "create" | "fetch" | "reset" | "rename" | "delete";
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type RepositoryOutboxRecord = {
|
||||
cacheKey: string;
|
||||
profileId: string;
|
||||
conversationId: string;
|
||||
objectIds: string[];
|
||||
expectedHeadMessageId: string | null;
|
||||
expectedHeadVersion: number;
|
||||
expectedMetadataVersion: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type PendingConversationChange = {
|
||||
cacheKey: string;
|
||||
profileId: string;
|
||||
conversationId: string;
|
||||
method: "PUT" | "PATCH" | "DELETE";
|
||||
requestPath?: string;
|
||||
method: "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
body?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
@@ -36,6 +67,23 @@ function openDatabase() {
|
||||
const pending = database.createObjectStore("pending", {keyPath: "cacheKey"});
|
||||
pending.createIndex("profileId", "profileId");
|
||||
}
|
||||
if (!database.objectStoreNames.contains("messages")) {
|
||||
const messages = database.createObjectStore("messages", {keyPath: "cacheKey"});
|
||||
messages.createIndex("profileId", "profileId");
|
||||
}
|
||||
if (!database.objectStoreNames.contains("working")) {
|
||||
const working = database.createObjectStore("working", {keyPath: "cacheKey"});
|
||||
working.createIndex("profileId", "profileId");
|
||||
working.createIndex("profileConversation", ["profileId", "conversationId"]);
|
||||
}
|
||||
if (!database.objectStoreNames.contains("reflog")) {
|
||||
const reflog = database.createObjectStore("reflog", {keyPath: "cacheKey"});
|
||||
reflog.createIndex("profileConversation", ["profileId", "conversationId"]);
|
||||
}
|
||||
if (!database.objectStoreNames.contains("repositoryOutbox")) {
|
||||
const outbox = database.createObjectStore("repositoryOutbox", {keyPath: "cacheKey"});
|
||||
outbox.createIndex("profileId", "profileId");
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error("Unable to open offline history"));
|
||||
@@ -58,14 +106,110 @@ function activeProfileId() {
|
||||
return window.localStorage.getItem(activeProfileKey) || "";
|
||||
}
|
||||
|
||||
function conversationCacheKey(profileId: string, conversationId: string) {
|
||||
return `${profileId}:${conversationId}`;
|
||||
function profileCacheKey(profileId: string, id: string) {
|
||||
return `${profileId}:${id}`;
|
||||
}
|
||||
|
||||
function normalizedCachedMessage(message: Partial<StoredChatMessage>, parentMessageId: string | null, timestamp: string): StoredChatMessage {
|
||||
const role = message.role || "user";
|
||||
return {
|
||||
id: message.id || crypto.randomUUID(),
|
||||
parentMessageId: message.parentMessageId === undefined ? parentMessageId : message.parentMessageId,
|
||||
role,
|
||||
parts: Array.isArray(message.parts) ? message.parts : [],
|
||||
origin: message.origin || (role === "user" ? {type: "user"} : role === "system" ? {type: "system", source: "legacy-cache"} : {type: "legacy"}),
|
||||
completion: message.completion || {status: "complete"},
|
||||
createdAt: message.createdAt || timestamp,
|
||||
completedAt: message.completedAt || timestamp,
|
||||
...(message.metadata ? {metadata: message.metadata} : {})
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedConversationSummary(summary: Partial<ConversationSummary> & {title?: unknown}): ConversationSummary | null {
|
||||
if (typeof summary.id !== "string" || !summary.id) return null;
|
||||
const timestamp = new Date().toISOString();
|
||||
return {
|
||||
id: summary.id,
|
||||
name: typeof summary.name === "string" ? summary.name : typeof summary.title === "string" ? summary.title : "",
|
||||
headMessageId: typeof summary.headMessageId === "string" ? summary.headMessageId : null,
|
||||
providerId: typeof summary.providerId === "string" ? summary.providerId : "",
|
||||
model: typeof summary.model === "string" ? summary.model : "",
|
||||
messageCount: typeof summary.messageCount === "number" && Number.isFinite(summary.messageCount) ? summary.messageCount : 0,
|
||||
createdAt: typeof summary.createdAt === "string" ? summary.createdAt : timestamp,
|
||||
updatedAt: typeof summary.updatedAt === "string" ? summary.updatedAt : typeof summary.createdAt === "string" ? summary.createdAt : timestamp,
|
||||
...(summary.upstreamHeadMessageId === null || typeof summary.upstreamHeadMessageId === "string" ? {upstreamHeadMessageId: summary.upstreamHeadMessageId} : {}),
|
||||
...(typeof summary.headVersion === "number" ? {headVersion: summary.headVersion} : {}),
|
||||
...(typeof summary.metadataVersion === "number" ? {metadataVersion: summary.metadataVersion} : {})
|
||||
};
|
||||
}
|
||||
|
||||
export function activateOfflineProfile(profileId: string) {
|
||||
window.localStorage.setItem(activeProfileKey, profileId);
|
||||
}
|
||||
|
||||
export function activeOfflineProfileId() {
|
||||
return activeProfileId();
|
||||
}
|
||||
|
||||
export async function mergeOfflineProfiles(sourceProfileId: string, targetProfileId: string) {
|
||||
if (!sourceProfileId || sourceProfileId === targetProfileId) return;
|
||||
const database = await openDatabase();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const storeNames = ["profiles", "conversations", "pending", "messages", "working", "reflog", "repositoryOutbox"];
|
||||
const current = database.transaction(storeNames, "readwrite");
|
||||
const profiles = current.objectStore("profiles");
|
||||
const sourceProfileRequest = profiles.get(sourceProfileId);
|
||||
const targetProfileRequest = profiles.get(targetProfileId);
|
||||
let sourceProfile: CachedProfile | undefined;
|
||||
let targetProfile: CachedProfile | undefined;
|
||||
const profileReady = () => {
|
||||
if (sourceProfileRequest.readyState !== "done" || targetProfileRequest.readyState !== "done") return;
|
||||
sourceProfile = sourceProfileRequest.result as CachedProfile | undefined;
|
||||
targetProfile = targetProfileRequest.result as CachedProfile | undefined;
|
||||
if (!sourceProfile) return;
|
||||
const summaries = new Map<string, ConversationSummary>();
|
||||
for (const summary of [...(sourceProfile.summaries || []), ...(targetProfile?.summaries || [])]) {
|
||||
const existing = summaries.get(summary.id);
|
||||
if (!existing || summary.updatedAt > existing.updatedAt) summaries.set(summary.id, summary);
|
||||
}
|
||||
profiles.put({
|
||||
...(sourceProfile || {}),
|
||||
...(targetProfile || {}),
|
||||
id: targetProfileId,
|
||||
summaries: [...summaries.values()].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)),
|
||||
updatedAt: new Date().toISOString(),
|
||||
lastFetchAt: targetProfile?.lastFetchAt || sourceProfile.lastFetchAt
|
||||
} satisfies CachedProfile);
|
||||
};
|
||||
sourceProfileRequest.onsuccess = profileReady;
|
||||
targetProfileRequest.onsuccess = profileReady;
|
||||
|
||||
for (const storeName of storeNames.slice(1)) {
|
||||
const store = current.objectStore(storeName);
|
||||
const indexName = storeName === "reflog" ? "profileConversation" : "profileId";
|
||||
const range = storeName === "reflog"
|
||||
? IDBKeyRange.bound([sourceProfileId, ""], [sourceProfileId, "\uffff"])
|
||||
: IDBKeyRange.only(sourceProfileId);
|
||||
const request = store.index(indexName).getAll(range);
|
||||
request.onsuccess = () => {
|
||||
for (const raw of request.result as Array<Record<string, unknown>>) {
|
||||
const oldKey = String(raw.cacheKey || "");
|
||||
const suffix = oldKey.startsWith(`${sourceProfileId}:`) ? oldKey.slice(sourceProfileId.length) : `:${crypto.randomUUID()}`;
|
||||
const migrated = {...raw, profileId: targetProfileId, cacheKey: `${targetProfileId}${suffix}`};
|
||||
const existingRequest = store.get(migrated.cacheKey as IDBValidKey);
|
||||
existingRequest.onsuccess = () => {
|
||||
const existing = existingRequest.result as Record<string, unknown> | undefined;
|
||||
if (!existing || String(raw.updatedAt || raw.createdAt || "") > String(existing.updatedAt || existing.createdAt || "")) store.put(migrated);
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
current.oncomplete = () => { database.close(); resolve(); };
|
||||
current.onerror = () => { database.close(); reject(current.error || new Error("Unable to merge local repositories")); };
|
||||
current.onabort = () => { database.close(); reject(current.error || new Error("Local repository merge was aborted")); };
|
||||
});
|
||||
}
|
||||
|
||||
export async function cacheChatConfig<T>(profileId: string, config: T) {
|
||||
activateOfflineProfile(profileId);
|
||||
const current = await transaction<CachedProfile<T> | undefined>("profiles", "readonly", (store) => store.get(profileId));
|
||||
@@ -73,16 +217,32 @@ export async function cacheChatConfig<T>(profileId: string, config: T) {
|
||||
id: profileId,
|
||||
config,
|
||||
summaries: current?.summaries || [],
|
||||
updatedAt: new Date().toISOString()
|
||||
updatedAt: new Date().toISOString(),
|
||||
lastFetchAt: current?.lastFetchAt
|
||||
};
|
||||
await transaction<IDBValidKey>("profiles", "readwrite", (store) => store.put(profile));
|
||||
}
|
||||
|
||||
export async function loadCachedChatConfig<T>() {
|
||||
const profileId = activeProfileId();
|
||||
export async function loadCachedChatConfig<T>(requestedProfileId?: string) {
|
||||
const profileId = requestedProfileId || activeProfileId();
|
||||
if (!profileId) return null;
|
||||
const profile = await transaction<CachedProfile<T> | undefined>("profiles", "readonly", (store) => store.get(profileId));
|
||||
return profile ? {profileId, config: profile.config, updatedAt: profile.updatedAt} : null;
|
||||
return profile ? {profileId, config: profile.config, updatedAt: profile.updatedAt, lastFetchAt: profile.lastFetchAt || ""} : null;
|
||||
}
|
||||
|
||||
export async function cachedLastFetchAt() {
|
||||
const profileId = activeProfileId();
|
||||
if (!profileId) return "";
|
||||
const profile = await transaction<CachedProfile | undefined>("profiles", "readonly", (store) => store.get(profileId));
|
||||
return profile?.lastFetchAt || "";
|
||||
}
|
||||
|
||||
export async function recordRepositoryFetch(timestamp: string) {
|
||||
const profileId = activeProfileId();
|
||||
if (!profileId) return;
|
||||
const profile = await transaction<CachedProfile | undefined>("profiles", "readonly", (store) => store.get(profileId));
|
||||
if (!profile) return;
|
||||
await transaction<IDBValidKey>("profiles", "readwrite", (store) => store.put({...profile, lastFetchAt: timestamp}));
|
||||
}
|
||||
|
||||
export async function cacheConversationSummaries(summaries: ConversationSummary[]) {
|
||||
@@ -90,57 +250,487 @@ export async function cacheConversationSummaries(summaries: ConversationSummary[
|
||||
if (!profileId) return;
|
||||
const current = await transaction<CachedProfile | undefined>("profiles", "readonly", (store) => store.get(profileId));
|
||||
if (!current) return;
|
||||
await transaction<IDBValidKey>("profiles", "readwrite", (store) => store.put({
|
||||
...current,
|
||||
summaries,
|
||||
updatedAt: new Date().toISOString()
|
||||
}));
|
||||
const normalized = summaries.map((summary) => normalizedConversationSummary(summary)).filter((summary): summary is ConversationSummary => Boolean(summary));
|
||||
await transaction<IDBValidKey>("profiles", "readwrite", (store) => store.put({...current, summaries: normalized, updatedAt: new Date().toISOString()}));
|
||||
}
|
||||
|
||||
export async function loadCachedConversationSummaries() {
|
||||
const profileId = activeProfileId();
|
||||
if (!profileId) return [];
|
||||
const profile = await transaction<CachedProfile | undefined>("profiles", "readonly", (store) => store.get(profileId));
|
||||
return profile?.summaries || [];
|
||||
return (profile?.summaries || []).map((summary) => normalizedConversationSummary(summary)).filter((summary): summary is ConversationSummary => Boolean(summary));
|
||||
}
|
||||
|
||||
export async function cacheConversation(conversation: Conversation) {
|
||||
const profileId = activeProfileId();
|
||||
if (!profileId) return;
|
||||
const record: CachedConversation = {
|
||||
...conversation,
|
||||
cacheKey: conversationCacheKey(profileId, conversation.id),
|
||||
let parentMessageId: string | null = null;
|
||||
for (const candidate of conversation.messages) {
|
||||
const message = normalizedCachedMessage(candidate, parentMessageId, conversation.updatedAt);
|
||||
const record: CachedMessage = {...message, cacheKey: profileCacheKey(profileId, message.id), profileId};
|
||||
await transaction<IDBValidKey>("messages", "readwrite", (store) => store.put(record));
|
||||
parentMessageId = message.id;
|
||||
}
|
||||
const {messages: _messages, ...summary} = conversation;
|
||||
const ref: CachedConversationRef = {
|
||||
...summary,
|
||||
upstreamHeadMessageId: conversation.upstreamHeadMessageId === undefined ? conversation.headMessageId : conversation.upstreamHeadMessageId,
|
||||
headVersion: conversation.headVersion || 0,
|
||||
metadataVersion: conversation.metadataVersion || 0,
|
||||
cacheKey: profileCacheKey(profileId, conversation.id),
|
||||
profileId
|
||||
};
|
||||
await transaction<IDBValidKey>("conversations", "readwrite", (store) => store.put(record));
|
||||
const summaries = await loadCachedConversationSummaries();
|
||||
const summary: ConversationSummary = {
|
||||
id: conversation.id,
|
||||
title: conversation.title,
|
||||
providerId: conversation.providerId,
|
||||
model: conversation.model,
|
||||
messageCount: conversation.messageCount,
|
||||
createdAt: conversation.createdAt,
|
||||
updatedAt: conversation.updatedAt
|
||||
};
|
||||
await cacheConversationSummaries([summary, ...summaries.filter((item) => item.id !== conversation.id)].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)));
|
||||
await transaction<IDBValidKey>("conversations", "readwrite", (store) => store.put(ref));
|
||||
await cacheConversationSummaries([
|
||||
summary,
|
||||
...(await loadCachedConversationSummaries()).filter((item) => item.id !== conversation.id)
|
||||
].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)));
|
||||
}
|
||||
|
||||
async function loadCachedMessage(profileId: string, id: string) {
|
||||
const record = await transaction<CachedMessage | undefined>("messages", "readonly", (store) => store.get(profileCacheKey(profileId, id)));
|
||||
if (!record) return null;
|
||||
const {cacheKey: _cacheKey, profileId: _profileId, ...message} = record;
|
||||
return message;
|
||||
}
|
||||
|
||||
export async function loadCachedConversation(id: string) {
|
||||
const profileId = activeProfileId();
|
||||
if (!profileId) return null;
|
||||
const record = await transaction<CachedConversation | undefined>("conversations", "readonly", (store) => store.get(conversationCacheKey(profileId, id)));
|
||||
const record = await transaction<CachedConversationRef | undefined>("conversations", "readonly", (store) => store.get(profileCacheKey(profileId, id)));
|
||||
if (!record) return null;
|
||||
const {cacheKey: _cacheKey, profileId: _profileId, ...conversation} = record;
|
||||
return conversation;
|
||||
const {cacheKey: _cacheKey, profileId: _profileId, messages: legacyMessages, ...conversation} = record;
|
||||
const normalizedSummary = normalizedConversationSummary(conversation as Partial<ConversationSummary> & {title?: unknown});
|
||||
if (!normalizedSummary) return null;
|
||||
if (legacyMessages) {
|
||||
const normalized: Conversation = {
|
||||
...conversation,
|
||||
...normalizedSummary,
|
||||
headMessageId: normalizedSummary.headMessageId || legacyMessages.at(-1)?.id || null,
|
||||
messages: legacyMessages.map((message, index) => normalizedCachedMessage(message, index ? legacyMessages[index - 1].id : null, conversation.updatedAt))
|
||||
};
|
||||
await cacheConversation(normalized);
|
||||
return normalized;
|
||||
}
|
||||
const reversed: StoredChatMessage[] = [];
|
||||
const seen = new Set<string>();
|
||||
let messageId = conversation.headMessageId;
|
||||
while (messageId) {
|
||||
if (seen.has(messageId) || reversed.length >= 500) return null;
|
||||
seen.add(messageId);
|
||||
const message = await loadCachedMessage(profileId, messageId);
|
||||
if (!message) return null;
|
||||
reversed.push(message);
|
||||
messageId = message.parentMessageId;
|
||||
}
|
||||
return {...conversation, ...normalizedSummary, messages: reversed.reverse()};
|
||||
}
|
||||
|
||||
export async function removeCachedConversation(id: string) {
|
||||
const profileId = activeProfileId();
|
||||
if (!profileId) return;
|
||||
await transaction<undefined>("conversations", "readwrite", (store) => store.delete(conversationCacheKey(profileId, id)));
|
||||
const summaries = await loadCachedConversationSummaries();
|
||||
await cacheConversationSummaries(summaries.filter((conversation) => conversation.id !== id));
|
||||
await transaction<undefined>("conversations", "readwrite", (store) => store.delete(profileCacheKey(profileId, id)));
|
||||
await transaction<undefined>("repositoryOutbox", "readwrite", (store) => store.delete(repositoryOutboxKey(profileId, id)));
|
||||
await cacheConversationSummaries((await loadCachedConversationSummaries()).filter((conversation) => conversation.id !== id));
|
||||
}
|
||||
|
||||
export async function deleteLocalConversation(id: string) {
|
||||
const profileId = activeProfileId();
|
||||
if (!profileId) return;
|
||||
const conversation = await loadCachedConversation(id);
|
||||
await transaction<undefined>("conversations", "readwrite", (store) => store.delete(profileCacheKey(profileId, id)));
|
||||
await cacheConversationSummaries((await loadCachedConversationSummaries()).filter((item) => item.id !== id));
|
||||
if (conversation?.upstreamHeadMessageId !== undefined && ((conversation.headVersion || 0) > 0 || (conversation.metadataVersion || 0) > 0)) {
|
||||
await queueConversationChange({conversationId: id, method: "DELETE"});
|
||||
}
|
||||
await transaction<undefined>("repositoryOutbox", "readwrite", (store) => store.delete(repositoryOutboxKey(profileId, id)));
|
||||
}
|
||||
|
||||
export async function saveWorkingItem(item: WorkingItem) {
|
||||
const profileId = activeProfileId();
|
||||
if (!profileId) return item;
|
||||
const record: CachedWorkingItem = {...item, cacheKey: profileCacheKey(profileId, item.id), profileId};
|
||||
await transaction<IDBValidKey>("working", "readwrite", (store) => store.put(record));
|
||||
return item;
|
||||
}
|
||||
|
||||
export async function listWorkingItems(conversationId?: string) {
|
||||
const profileId = activeProfileId();
|
||||
if (!profileId) return [];
|
||||
const database = await openDatabase();
|
||||
return new Promise<WorkingItem[]>((resolve, reject) => {
|
||||
const current = database.transaction("working", "readonly");
|
||||
const store = current.objectStore("working");
|
||||
const request = conversationId
|
||||
? store.index("profileConversation").getAll([profileId, conversationId])
|
||||
: store.index("profileId").getAll(profileId);
|
||||
request.onsuccess = () => resolve((request.result as CachedWorkingItem[])
|
||||
.map(({cacheKey: _cacheKey, profileId: _profileId, ...item}) => item)
|
||||
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)));
|
||||
request.onerror = () => reject(request.error || new Error("Unable to read working items"));
|
||||
current.oncomplete = () => database.close();
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeWorkingItem(id: string) {
|
||||
const profileId = activeProfileId();
|
||||
if (!profileId) return;
|
||||
await transaction<undefined>("working", "readwrite", (store) => store.delete(profileCacheKey(profileId, id)));
|
||||
}
|
||||
|
||||
export async function listCachedObjectIds() {
|
||||
const profileId = activeProfileId();
|
||||
if (!profileId) return [];
|
||||
const database = await openDatabase();
|
||||
return new Promise<string[]>((resolve, reject) => {
|
||||
const current = database.transaction("messages", "readonly");
|
||||
const request = current.objectStore("messages").index("profileId").getAllKeys(IDBKeyRange.only(profileId));
|
||||
request.onsuccess = () => resolve(request.result.map((key) => String(key).slice(profileId.length + 1)));
|
||||
request.onerror = () => reject(request.error || new Error("Unable to list local objects"));
|
||||
current.oncomplete = () => database.close();
|
||||
});
|
||||
}
|
||||
|
||||
export async function listCachedMessages() {
|
||||
const profileId = activeProfileId();
|
||||
if (!profileId) return [];
|
||||
const database = await openDatabase();
|
||||
return new Promise<StoredChatMessage[]>((resolve, reject) => {
|
||||
const current = database.transaction("messages", "readonly");
|
||||
const request = current.objectStore("messages").index("profileId").getAll(profileId);
|
||||
request.onsuccess = () => resolve((request.result as CachedMessage[]).map(({cacheKey: _cacheKey, profileId: _profileId, ...message}) => message));
|
||||
request.onerror = () => reject(request.error || new Error("Unable to list local message objects"));
|
||||
current.oncomplete = () => database.close();
|
||||
});
|
||||
}
|
||||
|
||||
function repositoryOutboxKey(profileId: string, conversationId: string) {
|
||||
return `${profileId}:${conversationId}:repository`;
|
||||
}
|
||||
|
||||
export async function commitLocalMessage(conversationId: string, message: StoredChatMessage) {
|
||||
const profileId = activeProfileId();
|
||||
if (!profileId) throw new Error("Local repository profile is unavailable");
|
||||
const database = await openDatabase();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const current = database.transaction(["conversations", "messages", "reflog", "repositoryOutbox"], "readwrite");
|
||||
const refs = current.objectStore("conversations");
|
||||
const objects = current.objectStore("messages");
|
||||
const reflog = current.objectStore("reflog");
|
||||
const outbox = current.objectStore("repositoryOutbox");
|
||||
const refKey = profileCacheKey(profileId, conversationId);
|
||||
const outboxKey = repositoryOutboxKey(profileId, conversationId);
|
||||
const refRequest = refs.get(refKey);
|
||||
refRequest.onsuccess = () => {
|
||||
const ref = refRequest.result as CachedConversationRef | undefined;
|
||||
if (!ref) {
|
||||
current.abort();
|
||||
reject(new Error("Local conversation ref is unavailable"));
|
||||
return;
|
||||
}
|
||||
if (ref.headMessageId !== message.parentMessageId) {
|
||||
current.abort();
|
||||
reject(new Error("Local conversation head changed"));
|
||||
return;
|
||||
}
|
||||
const existingOutboxRequest = outbox.get(outboxKey);
|
||||
existingOutboxRequest.onsuccess = () => {
|
||||
const timestamp = new Date().toISOString();
|
||||
const existing = existingOutboxRequest.result as RepositoryOutboxRecord | undefined;
|
||||
const object: CachedMessage = {...message, cacheKey: profileCacheKey(profileId, message.id), profileId};
|
||||
objects.put(object);
|
||||
refs.put({...ref, headMessageId: message.id, messageCount: ref.messageCount + 1, updatedAt: timestamp});
|
||||
const log: CachedReflog = {
|
||||
cacheKey: `${profileId}:${conversationId}:${timestamp}:${crypto.randomUUID()}`,
|
||||
profileId,
|
||||
conversationId,
|
||||
oldHeadMessageId: ref.headMessageId,
|
||||
newHeadMessageId: message.id,
|
||||
reason: "commit",
|
||||
createdAt: timestamp
|
||||
};
|
||||
reflog.put(log);
|
||||
outbox.put({
|
||||
cacheKey: outboxKey,
|
||||
profileId,
|
||||
conversationId,
|
||||
objectIds: [...new Set([...(existing?.objectIds || []), message.id])],
|
||||
expectedHeadMessageId: existing?.expectedHeadMessageId ?? ref.upstreamHeadMessageId ?? null,
|
||||
expectedHeadVersion: existing?.expectedHeadVersion ?? ref.headVersion ?? 0,
|
||||
expectedMetadataVersion: existing?.expectedMetadataVersion ?? ref.metadataVersion ?? 0,
|
||||
createdAt: existing?.createdAt || timestamp,
|
||||
updatedAt: timestamp
|
||||
} satisfies RepositoryOutboxRecord);
|
||||
};
|
||||
};
|
||||
current.oncomplete = () => { database.close(); resolve(); };
|
||||
current.onerror = () => { database.close(); reject(current.error || new Error("Local commit failed")); };
|
||||
current.onabort = () => database.close();
|
||||
});
|
||||
const conversation = await loadCachedConversation(conversationId);
|
||||
if (!conversation) throw new Error("Local commit could not be loaded");
|
||||
await cacheConversationSummariesFromConversation(conversation);
|
||||
return conversation;
|
||||
}
|
||||
|
||||
export async function moveLocalConversationHead(conversationId: string, headMessageId: string | null) {
|
||||
const profileId = activeProfileId();
|
||||
if (!profileId) throw new Error("Local repository profile is unavailable");
|
||||
const conversation = await loadCachedConversation(conversationId);
|
||||
if (!conversation) throw new Error("Local conversation ref is unavailable");
|
||||
const targetPath = await messagePathFromCache(profileId, headMessageId);
|
||||
if (headMessageId !== null && targetPath.at(-1)?.id !== headMessageId) throw new Error("Target message is unavailable in the local graph");
|
||||
if (conversation.headMessageId === headMessageId) return conversation;
|
||||
|
||||
const database = await openDatabase();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const current = database.transaction(["conversations", "reflog", "repositoryOutbox"], "readwrite");
|
||||
const refs = current.objectStore("conversations");
|
||||
const reflog = current.objectStore("reflog");
|
||||
const outbox = current.objectStore("repositoryOutbox");
|
||||
const refKey = profileCacheKey(profileId, conversationId);
|
||||
const outboxKey = repositoryOutboxKey(profileId, conversationId);
|
||||
const refRequest = refs.get(refKey);
|
||||
refRequest.onsuccess = () => {
|
||||
const ref = refRequest.result as CachedConversationRef | undefined;
|
||||
if (!ref || ref.headMessageId !== conversation.headMessageId) {
|
||||
current.abort();
|
||||
reject(new Error("Local conversation head changed"));
|
||||
return;
|
||||
}
|
||||
const outboxRequest = outbox.get(outboxKey);
|
||||
outboxRequest.onsuccess = () => {
|
||||
const timestamp = new Date().toISOString();
|
||||
const existing = outboxRequest.result as RepositoryOutboxRecord | undefined;
|
||||
refs.put({...ref, headMessageId, messageCount: targetPath.length, updatedAt: timestamp});
|
||||
reflog.put({
|
||||
cacheKey: `${profileId}:${conversationId}:${timestamp}:${crypto.randomUUID()}`,
|
||||
profileId,
|
||||
conversationId,
|
||||
oldHeadMessageId: ref.headMessageId,
|
||||
newHeadMessageId: headMessageId,
|
||||
reason: "reset",
|
||||
createdAt: timestamp
|
||||
} satisfies CachedReflog);
|
||||
outbox.put({
|
||||
cacheKey: outboxKey,
|
||||
profileId,
|
||||
conversationId,
|
||||
objectIds: existing?.objectIds || [],
|
||||
expectedHeadMessageId: existing?.expectedHeadMessageId ?? ref.upstreamHeadMessageId ?? null,
|
||||
expectedHeadVersion: existing?.expectedHeadVersion ?? ref.headVersion ?? 0,
|
||||
expectedMetadataVersion: existing?.expectedMetadataVersion ?? ref.metadataVersion ?? 0,
|
||||
createdAt: existing?.createdAt || timestamp,
|
||||
updatedAt: timestamp
|
||||
} satisfies RepositoryOutboxRecord);
|
||||
};
|
||||
};
|
||||
current.oncomplete = () => { database.close(); resolve(); };
|
||||
current.onerror = () => { database.close(); reject(current.error || new Error("Unable to move local conversation head")); };
|
||||
current.onabort = () => database.close();
|
||||
});
|
||||
const updated = await loadCachedConversation(conversationId);
|
||||
if (!updated) throw new Error("Moved conversation could not be loaded");
|
||||
await cacheConversationSummariesFromConversation(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function cacheConversationSummariesFromConversation(conversation: Conversation) {
|
||||
const summary: ConversationSummary = {
|
||||
id: conversation.id,
|
||||
name: conversation.name,
|
||||
headMessageId: conversation.headMessageId,
|
||||
providerId: conversation.providerId,
|
||||
model: conversation.model,
|
||||
messageCount: conversation.messageCount,
|
||||
createdAt: conversation.createdAt,
|
||||
updatedAt: conversation.updatedAt,
|
||||
upstreamHeadMessageId: conversation.upstreamHeadMessageId,
|
||||
headVersion: conversation.headVersion,
|
||||
metadataVersion: conversation.metadataVersion
|
||||
};
|
||||
await cacheConversationSummaries([summary, ...(await loadCachedConversationSummaries()).filter((item) => item.id !== conversation.id)]
|
||||
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)));
|
||||
}
|
||||
|
||||
export async function createLocalConversation(conversation: Conversation) {
|
||||
const profileId = activeProfileId();
|
||||
if (!profileId) throw new Error("Local repository profile is unavailable");
|
||||
const timestamp = new Date().toISOString();
|
||||
const local: Conversation = {...conversation, upstreamHeadMessageId: null, headVersion: 0, metadataVersion: 0};
|
||||
const database = await openDatabase();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const current = database.transaction(["conversations", "messages", "reflog", "repositoryOutbox"], "readwrite");
|
||||
const refs = current.objectStore("conversations");
|
||||
const objects = current.objectStore("messages");
|
||||
const reflog = current.objectStore("reflog");
|
||||
const outbox = current.objectStore("repositoryOutbox");
|
||||
for (const message of local.messages) objects.put({...message, cacheKey: profileCacheKey(profileId, message.id), profileId} satisfies CachedMessage);
|
||||
const {messages: _messages, ...summary} = local;
|
||||
refs.put({...summary, cacheKey: profileCacheKey(profileId, local.id), profileId} satisfies CachedConversationRef);
|
||||
reflog.put({
|
||||
cacheKey: `${profileId}:${local.id}:${timestamp}:${crypto.randomUUID()}`,
|
||||
profileId,
|
||||
conversationId: local.id,
|
||||
oldHeadMessageId: null,
|
||||
newHeadMessageId: local.headMessageId,
|
||||
reason: "create",
|
||||
createdAt: timestamp
|
||||
} satisfies CachedReflog);
|
||||
outbox.put({
|
||||
cacheKey: repositoryOutboxKey(profileId, local.id),
|
||||
profileId,
|
||||
conversationId: local.id,
|
||||
objectIds: local.messages.filter((message) => message.id.startsWith("sha256:")).map((message) => message.id),
|
||||
expectedHeadMessageId: null,
|
||||
expectedHeadVersion: 0,
|
||||
expectedMetadataVersion: 0,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp
|
||||
} satisfies RepositoryOutboxRecord);
|
||||
current.oncomplete = () => { database.close(); resolve(); };
|
||||
current.onerror = () => { database.close(); reject(current.error || new Error("Unable to create local ref")); };
|
||||
});
|
||||
await cacheConversationSummariesFromConversation(local);
|
||||
return local;
|
||||
}
|
||||
|
||||
export async function queueLocalRefUpdate(conversation: Conversation) {
|
||||
const profileId = activeProfileId();
|
||||
if (!profileId) throw new Error("Local repository profile is unavailable");
|
||||
await cacheConversation(conversation);
|
||||
const key = repositoryOutboxKey(profileId, conversation.id);
|
||||
const existing = await transaction<RepositoryOutboxRecord | undefined>("repositoryOutbox", "readonly", (store) => store.get(key));
|
||||
const timestamp = new Date().toISOString();
|
||||
const record: RepositoryOutboxRecord = {
|
||||
cacheKey: key,
|
||||
profileId,
|
||||
conversationId: conversation.id,
|
||||
objectIds: existing?.objectIds || [],
|
||||
expectedHeadMessageId: existing?.expectedHeadMessageId ?? conversation.upstreamHeadMessageId ?? null,
|
||||
expectedHeadVersion: existing?.expectedHeadVersion ?? conversation.headVersion ?? 0,
|
||||
expectedMetadataVersion: existing?.expectedMetadataVersion ?? conversation.metadataVersion ?? 0,
|
||||
createdAt: existing?.createdAt || timestamp,
|
||||
updatedAt: timestamp
|
||||
};
|
||||
await transaction<IDBValidKey>("repositoryOutbox", "readwrite", (store) => store.put(record));
|
||||
return conversation;
|
||||
}
|
||||
|
||||
export async function repositoryPushPayload() {
|
||||
const profileId = activeProfileId();
|
||||
if (!profileId) return {repositoryId: "", objects: [] as StoredChatMessage[], refs: [] as RepositoryRefUpdate[]};
|
||||
const database = await openDatabase();
|
||||
const outbox = await new Promise<RepositoryOutboxRecord[]>((resolve, reject) => {
|
||||
const current = database.transaction("repositoryOutbox", "readonly");
|
||||
const request = current.objectStore("repositoryOutbox").index("profileId").getAll(profileId);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
current.oncomplete = () => database.close();
|
||||
});
|
||||
const objects: StoredChatMessage[] = [];
|
||||
const refs: RepositoryRefUpdate[] = [];
|
||||
for (const pending of outbox) {
|
||||
const conversation = await loadCachedConversation(pending.conversationId);
|
||||
if (!conversation) continue;
|
||||
for (const id of pending.objectIds) {
|
||||
const object = await loadCachedMessage(profileId, id);
|
||||
if (object) objects.push(object);
|
||||
}
|
||||
refs.push({
|
||||
conversationId: conversation.id,
|
||||
expectedHeadMessageId: pending.expectedHeadMessageId,
|
||||
expectedHeadVersion: pending.expectedHeadVersion,
|
||||
expectedMetadataVersion: pending.expectedMetadataVersion,
|
||||
headMessageId: conversation.headMessageId,
|
||||
name: conversation.name,
|
||||
providerId: conversation.providerId,
|
||||
model: conversation.model,
|
||||
generationSettings: conversation.generationSettings,
|
||||
createdAt: conversation.createdAt,
|
||||
updatedAt: conversation.updatedAt
|
||||
});
|
||||
}
|
||||
return {repositoryId: profileId, objects, refs};
|
||||
}
|
||||
|
||||
export async function applyRepositoryFetch(repository: RepositoryFetch) {
|
||||
const profileId = activeProfileId();
|
||||
if (!profileId) return;
|
||||
for (const object of repository.objects) {
|
||||
await transaction<IDBValidKey>("messages", "readwrite", (store) => store.put({...object, cacheKey: profileCacheKey(profileId, object.id), profileId}));
|
||||
}
|
||||
for (const remote of repository.refs) {
|
||||
const local = await loadCachedConversation(remote.id);
|
||||
const pending = await transaction<RepositoryOutboxRecord | undefined>("repositoryOutbox", "readonly", (store) => store.get(repositoryOutboxKey(profileId, remote.id)));
|
||||
const canFastForward = !local || (!pending && local.headMessageId === (local.upstreamHeadMessageId ?? local.headMessageId));
|
||||
const headMessageId = canFastForward ? remote.headMessageId : local!.headMessageId;
|
||||
const messages = await messagePathFromCache(profileId, headMessageId);
|
||||
const conversation: Conversation = {
|
||||
id: remote.id,
|
||||
name: pending && local ? local.name : remote.name,
|
||||
headMessageId,
|
||||
upstreamHeadMessageId: remote.headMessageId,
|
||||
providerId: pending && local ? local.providerId : remote.providerId,
|
||||
model: pending && local ? local.model : remote.model,
|
||||
generationSettings: pending && local ? local.generationSettings : remote.generationSettings,
|
||||
headVersion: remote.headVersion,
|
||||
metadataVersion: remote.metadataVersion,
|
||||
messageCount: messages.length,
|
||||
createdAt: remote.createdAt,
|
||||
updatedAt: pending && local ? local.updatedAt : remote.updatedAt,
|
||||
messages
|
||||
};
|
||||
await cacheConversation(conversation);
|
||||
}
|
||||
const remoteIds = new Set(repository.refs.map((ref) => ref.id));
|
||||
for (const local of await loadCachedConversationSummaries()) {
|
||||
if (remoteIds.has(local.id)) continue;
|
||||
const pending = await transaction<RepositoryOutboxRecord | undefined>("repositoryOutbox", "readonly", (store) => store.get(repositoryOutboxKey(profileId, local.id)));
|
||||
if (!pending && ((local.headVersion || 0) > 0 || (local.metadataVersion || 0) > 0)) await removeCachedConversation(local.id);
|
||||
}
|
||||
await recordRepositoryFetch(repository.fetchedAt);
|
||||
}
|
||||
|
||||
async function messagePathFromCache(profileId: string, headMessageId: string | null) {
|
||||
const reversed: StoredChatMessage[] = [];
|
||||
const seen = new Set<string>();
|
||||
let id = headMessageId;
|
||||
while (id) {
|
||||
if (seen.has(id) || reversed.length >= 500) throw new Error("Local object history is cyclic or too long");
|
||||
seen.add(id);
|
||||
const object = await loadCachedMessage(profileId, id);
|
||||
if (!object) throw new Error(`Local object ${id} is unavailable`);
|
||||
reversed.push(object);
|
||||
id = object.parentMessageId;
|
||||
}
|
||||
return reversed.reverse();
|
||||
}
|
||||
|
||||
export async function applyRepositoryPushResults(results: Array<{conversationId: string; status: "ok" | "conflict"; ref: ConversationRefState | null}>) {
|
||||
const profileId = activeProfileId();
|
||||
if (!profileId) return;
|
||||
for (const result of results) {
|
||||
const local = await loadCachedConversation(result.conversationId);
|
||||
if (!local || !result.ref) continue;
|
||||
if (result.status === "ok") {
|
||||
await cacheConversation({...local, upstreamHeadMessageId: result.ref.headMessageId, headVersion: result.ref.headVersion, metadataVersion: result.ref.metadataVersion});
|
||||
const key = repositoryOutboxKey(profileId, result.conversationId);
|
||||
if (local.headMessageId === result.ref.headMessageId) {
|
||||
await transaction<undefined>("repositoryOutbox", "readwrite", (store) => store.delete(key));
|
||||
} else {
|
||||
const pending = await transaction<RepositoryOutboxRecord | undefined>("repositoryOutbox", "readonly", (store) => store.get(key));
|
||||
if (pending) await transaction<IDBValidKey>("repositoryOutbox", "readwrite", (store) => store.put({
|
||||
...pending,
|
||||
expectedHeadMessageId: result.ref!.headMessageId,
|
||||
expectedHeadVersion: result.ref!.headVersion,
|
||||
expectedMetadataVersion: result.ref!.metadataVersion
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
await cacheConversation({...local, upstreamHeadMessageId: result.ref.headMessageId, headVersion: result.ref.headVersion, metadataVersion: result.ref.metadataVersion});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function queueConversationChange(change: Omit<PendingConversationChange, "cacheKey" | "profileId" | "createdAt">) {
|
||||
@@ -148,7 +738,7 @@ export async function queueConversationChange(change: Omit<PendingConversationCh
|
||||
if (!profileId) return;
|
||||
const record: PendingConversationChange = {
|
||||
...change,
|
||||
cacheKey: `${profileId}:${change.conversationId}:${change.method}`,
|
||||
cacheKey: `${profileId}:${change.conversationId}:${change.method}:${change.requestPath || "conversation"}`,
|
||||
profileId,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import {describe, expect, test} from "bun:test";
|
||||
import {repositoryPushBatches} from "./repository-push-batches.ts";
|
||||
|
||||
describe("repository push batches", () => {
|
||||
test("keeps requests within server limits", () => {
|
||||
const objects = Array.from({length: 2001}, (_, index) => ({id: `sha256:${index}`}));
|
||||
const refs = Array.from({length: 201}, (_, index) => ({conversationId: String(index)}));
|
||||
const batches = repositoryPushBatches({repositoryId: "local:test", objects, refs});
|
||||
expect(batches.map((batch) => [batch.objects.length, batch.refs.length])).toEqual([
|
||||
[1000, 0], [1000, 0], [1, 0], [0, 100], [0, 100], [0, 1]
|
||||
]);
|
||||
});
|
||||
|
||||
test("deduplicates shared message objects", () => {
|
||||
const object = {id: "sha256:shared"};
|
||||
const batches = repositoryPushBatches({repositoryId: "local:test", objects: [object, object], refs: []});
|
||||
expect(batches).toHaveLength(1);
|
||||
expect(batches[0].objects).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import type {RepositoryRefUpdate, StoredChatMessage} from "./conversation-types";
|
||||
|
||||
export type RepositoryPushPayload = {
|
||||
repositoryId: string;
|
||||
objects: StoredChatMessage[];
|
||||
refs: RepositoryRefUpdate[];
|
||||
};
|
||||
|
||||
function chunks<T>(items: T[], size: number) {
|
||||
const result: T[][] = [];
|
||||
for (let index = 0; index < items.length; index += size) result.push(items.slice(index, index + size));
|
||||
return result;
|
||||
}
|
||||
|
||||
export function repositoryPushBatches(payload: RepositoryPushPayload) {
|
||||
const objectBatches = chunks([...new Map(payload.objects.map((object) => [object.id, object])).values()], 1000)
|
||||
.map((objects) => ({repositoryId: payload.repositoryId, objects, refs: [] as RepositoryRefUpdate[]}));
|
||||
const refBatches = chunks(payload.refs, 100)
|
||||
.map((refs) => ({repositoryId: payload.repositoryId, objects: [] as StoredChatMessage[], refs}));
|
||||
return [...objectBatches, ...refBatches];
|
||||
}
|
||||
@@ -16,4 +16,11 @@ describe("response metadata", () => {
|
||||
expect(metadata.outputTokens).toBeNull();
|
||||
expect(metadata.tokensPerSecond).toBeNull();
|
||||
});
|
||||
|
||||
test("falls back to estimated tokens when provider output is absent", () => {
|
||||
const metadata = responseMetadata("local", "model", performance.now() - 2000, undefined, 80);
|
||||
expect(metadata.outputTokens).toBe(80);
|
||||
expect(metadata.tokensPerSecond).toBeGreaterThanOrEqual(39);
|
||||
expect(metadata.tokensPerSecond).toBeLessThanOrEqual(41);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import type {ResponseMetadata} from "./conversation-types";
|
||||
|
||||
export function responseMetadata(providerId: string, model: string, startedAt: number, outputTokens: number | undefined | null): ResponseMetadata {
|
||||
function normalizeTokenCount(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value >= 0) return Math.round(value);
|
||||
if (typeof value === "bigint" && value >= 0n) return Number(value);
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed) && parsed >= 0) return Math.round(parsed);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function responseMetadata(
|
||||
providerId: string,
|
||||
model: string,
|
||||
startedAt: number,
|
||||
outputTokens: number | undefined | null,
|
||||
fallbackOutputTokens?: number | undefined | null
|
||||
): ResponseMetadata {
|
||||
const durationMs = Math.max(1, Math.round(performance.now() - startedAt));
|
||||
const normalizedTokens = typeof outputTokens === "number" && Number.isFinite(outputTokens) && outputTokens >= 0
|
||||
? Math.round(outputTokens)
|
||||
: null;
|
||||
const normalizedTokens = normalizeTokenCount(outputTokens) ?? normalizeTokenCount(fallbackOutputTokens);
|
||||
const tokensPerSecond = normalizedTokens === null
|
||||
? null
|
||||
: Math.round((normalizedTokens / (durationMs / 1000)) * 10) / 10;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import {describe, expect, test} from "bun:test";
|
||||
import {
|
||||
currentPath,
|
||||
detectSessionTransferFormat,
|
||||
parseSessionTransfer,
|
||||
serializeSessionTransfer,
|
||||
serializeXitengArchive
|
||||
} from "./session-transfer.ts";
|
||||
|
||||
const lines = (values) => `${values.map((value) => JSON.stringify(value)).join("\n")}\n`;
|
||||
|
||||
describe("session transfer formats", () => {
|
||||
test("imports Codex rollout records without duplicate event messages", () => {
|
||||
const text = lines([
|
||||
{type: "session_meta", timestamp: "2026-08-13T00:00:00Z", payload: {id: "11111111-1111-4111-8111-111111111111", timestamp: "2026-08-13T00:00:00Z", cwd: "/tmp"}},
|
||||
{type: "turn_context", timestamp: "2026-08-13T00:00:00Z", payload: {model: "gpt-5.6-sol"}},
|
||||
{type: "response_item", timestamp: "2026-08-13T00:00:01Z", payload: {type: "message", role: "user", content: [{type: "input_text", text: "hello"}]}},
|
||||
{type: "event_msg", timestamp: "2026-08-13T00:00:01Z", payload: {type: "user_message", message: "hello"}},
|
||||
{type: "response_item", timestamp: "2026-08-13T00:00:02Z", payload: {type: "message", role: "assistant", content: [{type: "output_text", text: "hi"}]}},
|
||||
{type: "event_msg", timestamp: "2026-08-13T00:00:02Z", payload: {type: "agent_message", message: "hi"}}
|
||||
]);
|
||||
const parsed = parseSessionTransfer(text, "rollout.jsonl");
|
||||
expect(parsed.format).toBe("codex");
|
||||
expect(parsed.nodes.map((node) => node.role)).toEqual(["user", "assistant"]);
|
||||
expect(parsed.sessions[0].model).toBe("gpt-5.6-sol");
|
||||
});
|
||||
|
||||
test("preserves Claude parent branches", () => {
|
||||
const text = lines([
|
||||
{type: "user", sessionId: "s1", uuid: "u1", parentUuid: null, timestamp: "2026-08-13T00:00:00Z", message: {role: "user", content: "question"}},
|
||||
{type: "assistant", sessionId: "s1", uuid: "a1", parentUuid: "u1", timestamp: "2026-08-13T00:00:01Z", message: {role: "assistant", model: "claude-sonnet-4-5", content: [{type: "text", text: "first"}]}},
|
||||
{type: "assistant", sessionId: "s1", uuid: "a2", parentUuid: "u1", timestamp: "2026-08-13T00:00:02Z", message: {role: "assistant", model: "claude-sonnet-4-5", content: [{type: "text", text: "second"}]}},
|
||||
{type: "custom-title", sessionId: "s1", customTitle: "branched"}
|
||||
]);
|
||||
const parsed = parseSessionTransfer(text, "claude.jsonl");
|
||||
expect(parsed.nodes.map((node) => node.parentSourceId)).toEqual([null, "u1", "u1"]);
|
||||
expect(parsed.sessions[0].headSourceId).toBe("a2");
|
||||
expect(parsed.sessions[0].name).toBe("branched");
|
||||
});
|
||||
|
||||
test("imports current OMP title slot and tree", () => {
|
||||
const text = lines([
|
||||
{type: "title", v: 1, title: "OMP task", updatedAt: "2026-08-13T00:00:00Z", pad: ""},
|
||||
{type: "session", version: 3, id: "omp-1", timestamp: "2026-08-13T00:00:00Z", cwd: "/tmp"},
|
||||
{type: "message", id: "u1", parentId: null, timestamp: "2026-08-13T00:00:01Z", message: {role: "user", content: [{type: "text", text: "hello"}]}},
|
||||
{type: "message", id: "a1", parentId: "u1", timestamp: "2026-08-13T00:00:02Z", message: {role: "assistant", content: [{type: "text", text: "hi"}]}}
|
||||
]);
|
||||
const parsed = parseSessionTransfer(text, "omp.jsonl");
|
||||
expect(parsed.sessions[0].name).toBe("OMP task");
|
||||
expect(currentPath(parsed.nodes, "a1").map((node) => node.sourceId)).toEqual(["u1", "a1"]);
|
||||
});
|
||||
|
||||
test("exports parseable Codex, Claude and OMP JSONL", () => {
|
||||
const document = {
|
||||
format: "xiteng",
|
||||
sessions: [{sourceId: "22222222-2222-4222-8222-222222222222", name: "round trip", headSourceId: "a1", providerId: "openai", model: "gpt-5.6", generationSettings: {reasoning: "auto", showReasoningSummary: false, temperature: null, maxOutputTokens: null}, createdAt: "2026-08-13T00:00:00Z", updatedAt: "2026-08-13T00:00:02Z"}],
|
||||
nodes: [
|
||||
{sourceId: "u1", parentSourceId: null, role: "user", parts: [{type: "text", text: "hello"}], createdAt: "2026-08-13T00:00:01Z", completedAt: "2026-08-13T00:00:01Z"},
|
||||
{sourceId: "a1", parentSourceId: "u1", role: "assistant", parts: [{type: "reasoning", text: "brief"}, {type: "text", text: "hi"}], createdAt: "2026-08-13T00:00:02Z", completedAt: "2026-08-13T00:00:02Z"}
|
||||
]
|
||||
};
|
||||
for (const format of ["codex", "claude", "omp"]) {
|
||||
const exported = serializeSessionTransfer(document, format);
|
||||
expect(detectSessionTransferFormat(exported)).toBe(format);
|
||||
const imported = parseSessionTransfer(exported);
|
||||
expect(imported.nodes.some((node) => node.parts.some((part) => part.type === "text" && part.text === "hi"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("native archive keeps graph objects and working items", () => {
|
||||
const text = serializeXitengArchive([
|
||||
{id: "c1", name: "native", headMessageId: "m1", providerId: "p", model: "m", messageCount: 1, createdAt: "2026-08-13T00:00:00Z", updatedAt: "2026-08-13T00:00:01Z", generationSettings: {reasoning: "auto", showReasoningSummary: false, temperature: null, maxOutputTokens: null}, messages: []}
|
||||
], [
|
||||
{id: "m1", parentMessageId: null, role: "user", parts: [{type: "text", text: "draft"}], origin: {type: "user"}, completion: {status: "complete"}, createdAt: "2026-08-13T00:00:00Z", completedAt: "2026-08-13T00:00:00Z"}
|
||||
], []);
|
||||
const parsed = parseSessionTransfer(text, "backup.xiteng-chat.json");
|
||||
expect(parsed.format).toBe("xiteng");
|
||||
expect(parsed.sessions).toHaveLength(1);
|
||||
expect(parsed.nodes).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -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} : {})}))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {describe, expect, test} from "bun:test";
|
||||
import {splitStreamingMarkdown} from "./streaming-markdown.ts";
|
||||
|
||||
describe("streaming markdown blocks", () => {
|
||||
test("freezes a paragraph after its blank-line boundary", () => {
|
||||
const open = splitStreamingMarkdown("first paragraph");
|
||||
expect(open.blocks.map((block) => block.stable)).toEqual([false]);
|
||||
expect(open.stableOffset).toBe(0);
|
||||
|
||||
const closed = splitStreamingMarkdown("first paragraph\n\n");
|
||||
expect(closed.blocks.map((block) => block.stable)).toEqual([true]);
|
||||
expect(closed.stableOffset).toBe("first paragraph\n\n".length);
|
||||
});
|
||||
|
||||
test("keeps only the trailing paragraph mutable", () => {
|
||||
const source = "first paragraph\n\nsecond paragraph";
|
||||
const result = splitStreamingMarkdown(source);
|
||||
expect(result.blocks.map(({source, stable}) => [source, stable])).toEqual([
|
||||
["first paragraph", true],
|
||||
["second paragraph", false]
|
||||
]);
|
||||
expect(source.slice(0, result.stableOffset)).toBe("first paragraph\n\n");
|
||||
});
|
||||
|
||||
test("keeps a list mutable because another item can merge after a blank line", () => {
|
||||
const result = splitStreamingMarkdown("- first\n\n");
|
||||
expect(result.blocks).toHaveLength(1);
|
||||
expect(result.blocks[0].stable).toBe(false);
|
||||
expect(result.stableOffset).toBe(0);
|
||||
});
|
||||
|
||||
test("freezes fenced code only after its closing fence", () => {
|
||||
expect(splitStreamingMarkdown("```ts\nconst x = 1").blocks[0].stable).toBe(false);
|
||||
expect(splitStreamingMarkdown("```ts\nconst x = 1\n```\n").blocks[0].stable).toBe(true);
|
||||
});
|
||||
|
||||
test("freezes a standalone display formula when its delimiter closes", () => {
|
||||
expect(splitStreamingMarkdown("$$\\int_0^1 x dx").blocks[0].stable).toBe(false);
|
||||
expect(splitStreamingMarkdown("$$\\int_0^1 x dx$$").blocks[0].stable).toBe(true);
|
||||
expect(splitStreamingMarkdown("\\[\\frac{a}{b}\\]").blocks[0].stable).toBe(true);
|
||||
});
|
||||
|
||||
test("marks every block stable after the stream completes", () => {
|
||||
const result = splitStreamingMarkdown("paragraph without trailing newline", true);
|
||||
expect(result.blocks[0].stable).toBe(true);
|
||||
expect(result.stableOffset).toBe("paragraph without trailing newline".length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import {marked} from "marked";
|
||||
|
||||
export type StreamingMarkdownBlock = {
|
||||
source: string;
|
||||
type: string;
|
||||
start: number;
|
||||
end: number;
|
||||
stable: boolean;
|
||||
};
|
||||
|
||||
export type StreamingMarkdownSplit = {
|
||||
blocks: StreamingMarkdownBlock[];
|
||||
stableOffset: number;
|
||||
};
|
||||
|
||||
type BlockToken = {type: string; raw: string};
|
||||
|
||||
function hasClosedFence(raw: string) {
|
||||
const opening = raw.match(/^( {0,3})(`{3,}|~{3,})[^\n]*(?:\n|$)/);
|
||||
if (!opening) return false;
|
||||
const marker = opening[2];
|
||||
const character = marker[0].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const closing = new RegExp(`(?:^|\\n) {0,3}${character}{${marker.length},}[ \\t]*(?:\\n|$)`);
|
||||
return closing.test(raw.slice(opening[0].length));
|
||||
}
|
||||
|
||||
function isClosedDisplayMath(raw: string) {
|
||||
const value = raw.trim();
|
||||
return /^\$\$(?!\$)[\s\S]*?(?<!\\)\$\$$/.test(value)
|
||||
|| /^\\\[[\s\S]*?\\\]$/.test(value);
|
||||
}
|
||||
|
||||
function isSelfClosing(token: BlockToken, following: BlockToken | undefined) {
|
||||
if (token.type === "heading") return token.raw.endsWith("\n");
|
||||
if (token.type === "hr") return true;
|
||||
if (token.type === "code") return hasClosedFence(token.raw);
|
||||
if (token.type === "paragraph") {
|
||||
if (isClosedDisplayMath(token.raw)) return true;
|
||||
return following?.type === "space" && /\n[\t ]*\n/.test(following.raw);
|
||||
}
|
||||
if (token.type === "def") return token.raw.endsWith("\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
export function splitStreamingMarkdown(source: string, complete = false): StreamingMarkdownSplit {
|
||||
if (!source) return {blocks: [], stableOffset: 0};
|
||||
const tokens = marked.lexer(source) as unknown as BlockToken[];
|
||||
const records: Array<BlockToken & {start: number; end: number}> = [];
|
||||
let offset = 0;
|
||||
for (const token of tokens) {
|
||||
const start = offset;
|
||||
offset += token.raw.length;
|
||||
records.push({...token, start, end: offset});
|
||||
}
|
||||
|
||||
const semanticIndexes = records.flatMap((token, index) => token.type === "space" ? [] : [index]);
|
||||
const lastSemanticIndex = semanticIndexes.at(-1) ?? -1;
|
||||
let firstUnstableStart = source.length;
|
||||
const blocks: StreamingMarkdownBlock[] = [];
|
||||
|
||||
for (const index of semanticIndexes) {
|
||||
const token = records[index];
|
||||
const stable = complete || index < lastSemanticIndex || isSelfClosing(token, records[index + 1]);
|
||||
if (!stable && firstUnstableStart === source.length) firstUnstableStart = token.start;
|
||||
if (token.type === "def") continue;
|
||||
blocks.push({
|
||||
source: token.raw,
|
||||
type: token.type,
|
||||
start: token.start,
|
||||
end: token.end,
|
||||
stable
|
||||
});
|
||||
}
|
||||
|
||||
if (complete || firstUnstableStart === source.length) return {blocks, stableOffset: source.length};
|
||||
return {blocks, stableOffset: firstUnstableStart};
|
||||
}
|
||||
Reference in New Issue
Block a user