import {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"; const databasePath = process.env.CHAT_DATABASE_PATH || "/data/chat.db"; let database: Database | undefined; function getDatabase() { if (database) return database; mkdirSync(path.dirname(databasePath), {recursive: true}); const opened = new Database(databasePath, {create: true, strict: true}); opened.run(` PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000; CREATE TABLE IF NOT EXISTS chat_conversation ( id TEXT PRIMARY KEY, owner_issuer TEXT NOT NULL, owner_sub TEXT NOT NULL, title TEXT NOT NULL, provider_id TEXT NOT NULL, model TEXT NOT NULL, settings_json TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS chat_conversation_owner_updated ON chat_conversation (owner_issuer, owner_sub, updated_at DESC); CREATE TABLE IF NOT EXISTS chat_message ( conversation_id TEXT NOT NULL REFERENCES chat_conversation(id) ON DELETE CASCADE, id TEXT NOT NULL, ordinal INTEGER NOT NULL, role TEXT NOT NULL CHECK (role IN ('system', 'user', 'assistant')), parts_json TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (conversation_id, id), 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")) { opened.run("ALTER TABLE chat_conversation ADD COLUMN settings_json TEXT NOT NULL DEFAULT '{}'"); } 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 conversationSummary(row: ConversationRow): ConversationSummary { return { id: row.id, title: row.title, providerId: row.provider_id, model: row.model, messageCount: Number(row.message_count), createdAt: row.created_at, updatedAt: row.updated_at }; } 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; 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) : "新对话"; } 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 WHERE c.id = ? AND c.owner_issuer = ? AND c.owner_sub = ? `).get(id, identity.issuer, identity.sub) as ConversationRow | undefined; } 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 WHERE c.owner_issuer = ? AND c.owner_sub = ? 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 { const providerId = requiredString(input.providerId, "providerId", 80); const model = requiredString(input.model, "model", 300); 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: []}; } 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}; } 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; }) { 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 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); }); getDatabase().query(` UPDATE chat_conversation SET title = ?, provider_id = ?, model = ?, updated_at = ? WHERE id = ? AND owner_issuer = ? AND owner_sub = ? `).run(title, providerId, model, timestamp, id, identity.issuer, identity.sub); getDatabase().run("COMMIT"); } catch (error) { getDatabase().run("ROLLBACK"); throw error; } return getConversation(identity, id); } export function deleteConversation(identity: ChatIdentity, id: string) { const result = getDatabase().query(` DELETE FROM chat_conversation WHERE id = ? AND owner_issuer = ? AND owner_sub = ? `).run(id, identity.issuer, identity.sub); return result.changes > 0; }