feat(chat): add local-first conversation workspace

This commit is contained in:
2026-08-13 15:55:15 +08:00
parent d1d50d722c
commit 8991f78f9f
43 changed files with 5450 additions and 592 deletions
+599 -129
View File
@@ -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)!)};
}