feat: rebuild xiteng.site homelab platform
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
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<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) : "新对话";
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user