From b479fd3b2dbcf276bb02e536eec05923c376d195 Mon Sep 17 00:00:00 2001 From: digua Date: Thu, 14 May 2026 20:55:54 +0800 Subject: [PATCH] feat(web): enable AI chat in web mode with shared conversation module - Extract AIConversationManager to @openchatlab/node-runtime for Electron + Server reuse - Add /_web/ai/conversations/* HTTP endpoints for conversation/message CRUD - Create FetchAIAdapter to replace WebAIAdapter stub - Implement server-side Agent with SSE streaming (/_web/ai/agent/stream) - Add agentApi SSE client shim in frontend registry - Migrate API key storage from encrypted llm-config.json to auth-profiles.json - Add @openchatlab/config auth-profiles module for credential management --- electron/main/ai/conversations.ts | 787 ++---------------- electron/main/ai/llm/crypto.ts | 23 +- electron/main/ai/llm/device-key.ts | 66 +- electron/main/ai/llm/index.ts | 76 +- packages/config/src/auth-profiles.ts | 110 +++ packages/config/src/index.ts | 9 + packages/config/src/loader.ts | 2 - packages/config/src/schema.ts | 1 - packages/node-runtime/src/ai/conversations.ts | 595 +++++++++++++ packages/node-runtime/src/ai/index.ts | 11 +- packages/node-runtime/src/index.ts | 11 +- packages/server/package.json | 2 + packages/server/src/ai/agent.ts | 281 +++++++ packages/server/src/ai/assistant-loader.ts | 22 + packages/server/src/ai/llm-config.ts | 183 ++++ packages/server/src/ai/tool-adapter.ts | 49 ++ packages/server/src/http/index.ts | 10 +- packages/server/src/http/routes/ai.ts | 183 +++- pnpm-lock.yaml | 17 +- src/services/ai/fetch.ts | 173 ++++ src/services/registry.ts | 138 ++- src/services/utils/http.ts | 13 + 22 files changed, 1987 insertions(+), 775 deletions(-) create mode 100644 packages/config/src/auth-profiles.ts create mode 100644 packages/node-runtime/src/ai/conversations.ts create mode 100644 packages/server/src/ai/agent.ts create mode 100644 packages/server/src/ai/assistant-loader.ts create mode 100644 packages/server/src/ai/llm-config.ts create mode 100644 packages/server/src/ai/tool-adapter.ts create mode 100644 src/services/ai/fetch.ts diff --git a/electron/main/ai/conversations.ts b/electron/main/ai/conversations.ts index 0372a89a..bf79eec4 100644 --- a/electron/main/ai/conversations.ts +++ b/electron/main/ai/conversations.ts @@ -1,765 +1,138 @@ /** - * AI 对话历史管理模块 - * 在主进程中执行,管理 AI 对话的持久化存储 + * AI 对话历史管理模块(Electron 薄包装层) + * + * 委托给 @openchatlab/node-runtime 的 AIConversationManager, + * 保留原有的模块级函数签名以兼容现有 IPC 调用。 */ -import Database from 'better-sqlite3' -import * as path from 'path' -import { getAiDataDir, ensureDir } from '../paths' +import { AIConversationManager } from '@openchatlab/node-runtime' +import { getAiDataDir } from '../paths' import { aiLogger } from './logger' -const DEFAULT_GENERAL_ID = 'general_cn' +export type { AIConversation, AIMessage, AIMessageRole, ContentBlock, TokenUsageData } from '@openchatlab/node-runtime' -// AI 数据库实例 -let AI_DB: Database.Database | null = null +let manager: AIConversationManager | null = null -// DEBUG 模式:暂存待写入的 debug context(key = conversationId) -const pendingDebugContextMap = new Map() - -/** - * 获取 AI 数据库目录 - */ -function getAiDbDir(): string { - return getAiDataDir() -} - -/** - * 确保 AI 数据库目录存在 - */ -function ensureAiDbDir(): void { - ensureDir(getAiDbDir()) -} - -/** - * 获取 AI 数据库实例(单例) - */ -function getAiDb(): Database.Database { - if (AI_DB) return AI_DB - - ensureAiDbDir() - const dbPath = path.join(getAiDbDir(), 'conversations.db') - AI_DB = new Database(dbPath) - AI_DB.pragma('journal_mode = WAL') - - // 创建表结构 - AI_DB.exec(` - -- AI 对话表 - CREATE TABLE IF NOT EXISTS ai_conversation ( - id TEXT PRIMARY KEY, - session_id TEXT NOT NULL, - title TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - - -- AI 消息表 - CREATE TABLE IF NOT EXISTS ai_message ( - id TEXT PRIMARY KEY, - conversation_id TEXT NOT NULL, - role TEXT NOT NULL, - content TEXT NOT NULL, - timestamp INTEGER NOT NULL, - data_keywords TEXT, - data_message_count INTEGER, - content_blocks TEXT, - FOREIGN KEY(conversation_id) REFERENCES ai_conversation(id) ON DELETE CASCADE - ); - - -- 索引 - CREATE INDEX IF NOT EXISTS idx_ai_conversation_session ON ai_conversation(session_id); - CREATE INDEX IF NOT EXISTS idx_ai_message_conversation ON ai_message(conversation_id); - `) - - // 数据库迁移:为旧数据库添加缺失的列 - migrateAiDatabase(AI_DB) - - return AI_DB -} - -/** - * 数据库迁移:检查并添加缺失的列 - */ -function migrateAiDatabase(db: Database.Database): void { - try { - // 获取 ai_message 表的列信息 - const messageTableInfo = db.pragma('table_info(ai_message)') as Array<{ name: string }> - const messageColumns = messageTableInfo.map((col) => col.name) - - // 检查并添加 content_blocks 列 - if (!messageColumns.includes('content_blocks')) { - db.exec('ALTER TABLE ai_message ADD COLUMN content_blocks TEXT') - console.log('[AI DB Migration] Adding content_blocks column') - } - - // 检查并添加 token_usage 列(JSON: {promptTokens, completionTokens, totalTokens}) - if (!messageColumns.includes('token_usage')) { - db.exec('ALTER TABLE ai_message ADD COLUMN token_usage TEXT') - console.log('[AI DB Migration] Adding token_usage column to ai_message') - } - - // 检查并添加 debug_context 列(仅 DEBUG 模式下写入,存储发给 LLM 的完整上下文) - if (!messageColumns.includes('debug_context')) { - db.exec('ALTER TABLE ai_message ADD COLUMN debug_context TEXT') - console.log('[AI DB Migration] Adding debug_context column to ai_message') - } - - // 获取 ai_conversation 表的列信息 - const convTableInfo = db.pragma('table_info(ai_conversation)') as Array<{ name: string }> - const convColumns = convTableInfo.map((col) => col.name) - - // 检查并添加 assistant_id 列。 - // 这里只写新默认助手 ID;旧数据由用户手动清理,不在运行时兼容。 - if (!convColumns.includes('assistant_id')) { - db.exec(`ALTER TABLE ai_conversation ADD COLUMN assistant_id TEXT DEFAULT '${DEFAULT_GENERAL_ID}'`) - console.log('[AI DB Migration] Adding assistant_id column to ai_conversation') - } - } catch (error) { - console.error('[AI DB Migration] Migration failed:', error) +function getManager(): AIConversationManager { + if (!manager) { + manager = new AIConversationManager(getAiDataDir(), { + logger: { + warn(category, message, extra) { + aiLogger.warn(category, message, extra) + }, + }, + }) } + return manager } -/** - * 关闭 AI 数据库连接 - */ export function closeAiDatabase(): void { - if (AI_DB) { - AI_DB.close() - AI_DB = null + if (manager) { + manager.close() + manager = null } } -// ==================== Debug: AI DB 直接访问 ==================== - -/** - * 获取 AI 数据库的 schema(供 Debug 表格浏览器使用) - */ -export function getAiSchema(): Array<{ - name: string - columns: Array<{ name: string; type: string; notnull: boolean; pk: boolean }> -}> { - const db = getAiDb() - const tables = db - .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") - .all() as Array<{ name: string }> - - return tables.map((t) => { - const columns = db.pragma(`table_info("${t.name}")`) as Array<{ - name: string - type: string - notnull: number - pk: number - }> - return { - name: t.name, - columns: columns.map((c) => ({ - name: c.name, - type: c.type, - notnull: !!c.notnull, - pk: !!c.pk, - })), - } - }) +export function getAiSchema() { + return getManager().getAiSchema() } -/** - * 在 AI 数据库上执行原始 SQL(供 Debug 使用) - */ -export function executeAiSQL(sql: string): { - columns: string[] - rows: any[][] - rowCount: number - duration: number - limited: boolean -} { - const db = getAiDb() - const start = Date.now() - const trimmed = sql.trim() - const isSelect = /^SELECT/i.test(trimmed) - - if (isSelect) { - const stmt = db.prepare(trimmed) - const rows = stmt.all() as Record[] - const duration = Date.now() - start - const columns = rows.length > 0 ? Object.keys(rows[0]) : [] - return { - columns, - rows: rows.map((r) => columns.map((c) => r[c])), - rowCount: rows.length, - duration, - limited: false, - } - } else { - const result = db.prepare(trimmed).run() - const duration = Date.now() - start - return { - columns: ['changes', 'lastInsertRowid'], - rows: [[result.changes, Number(result.lastInsertRowid)]], - rowCount: 1, - duration, - limited: false, - } - } +export function executeAiSQL(sql: string) { + return getManager().executeAiSQL(sql) } -// ==================== 类型定义 ==================== - -/** - * AI 对话类型 - */ -export interface AIConversation { - id: string - sessionId: string - title: string | null - assistantId: string - createdAt: number - updatedAt: number +export function createConversation(sessionId: string, title: string | undefined, assistantId: string) { + return getManager().createConversation(sessionId, title, assistantId) } -/** - * 内容块类型(用于 AI 消息的混合渲染) - */ -export type ContentBlock = - | { type: 'text'; text: string } - | { type: 'think'; tag: string; text: string; durationMs?: number } - | { - type: 'tool' - tool: { - name: string - displayName: string - status: 'running' | 'done' | 'error' - params?: Record - } - } - | { - type: 'summary_meta' - bufferBoundaryTimestamp: number - compressedMessageCount: number - } - -/** - * AI 消息类型 - */ -export type AIMessageRole = 'user' | 'assistant' | 'summary' - -export interface TokenUsageData { - promptTokens: number - completionTokens: number - totalTokens: number +export function getConversationCountsBySession() { + return getManager().getConversationCountsBySession() } -export interface AIMessage { - id: string - conversationId: string - role: AIMessageRole - content: string - timestamp: number - dataKeywords?: string[] - dataMessageCount?: number - /** AI 消息的内容块数组(按时序排列的文本和工具调用) */ - contentBlocks?: ContentBlock[] - /** 本次 Agent 执行的 token 使用量(仅 assistant 消息) */ - tokenUsage?: TokenUsageData +export function getConversations(sessionId: string) { + return getManager().getConversations(sessionId) } -// ==================== 对话管理 ==================== - -/** - * 创建新对话 - */ -export function createConversation(sessionId: string, title: string | undefined, assistantId: string): AIConversation { - const db = getAiDb() - const now = Math.floor(Date.now() / 1000) - const id = `conv_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` - - db.prepare( - ` - INSERT INTO ai_conversation (id, session_id, title, assistant_id, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?) - ` - ).run(id, sessionId, title || null, assistantId, now, now) - - return { - id, - sessionId, - title: title || null, - assistantId, - createdAt: now, - updatedAt: now, - } +export function getConversation(conversationId: string) { + return getManager().getConversation(conversationId) } -/** - * 获取会话的所有对话列表 - */ -/** - * 获取所有会话的 AI 对话计数(按 session_id 分组) - */ -export function getConversationCountsBySession(): Map { - const result = new Map() - try { - const db = getAiDb() - const rows = db - .prepare('SELECT session_id, COUNT(*) as count FROM ai_conversation GROUP BY session_id') - .all() as Array<{ session_id: string; count: number }> - for (const row of rows) { - result.set(row.session_id, row.count) - } - } catch { - // AI 数据库可能尚未初始化 - } - return result +export function updateConversationTitle(conversationId: string, title: string) { + return getManager().updateConversationTitle(conversationId, title) } -export function getConversations(sessionId: string): AIConversation[] { - const db = getAiDb() - - const rows = db - .prepare( - ` - SELECT id, session_id as sessionId, title, assistant_id as assistantId, created_at as createdAt, updated_at as updatedAt - FROM ai_conversation - WHERE session_id = ? - ORDER BY updated_at DESC - ` - ) - .all(sessionId) as AIConversation[] - - return rows +export function deleteConversation(conversationId: string) { + return getManager().deleteConversation(conversationId) } -/** - * 获取单个对话 - */ -export function getConversation(conversationId: string): AIConversation | null { - const db = getAiDb() - - const row = db - .prepare( - ` - SELECT id, session_id as sessionId, title, assistant_id as assistantId, created_at as createdAt, updated_at as updatedAt - FROM ai_conversation - WHERE id = ? - ` - ) - .get(conversationId) as AIConversation | undefined - - return row || null -} - -/** - * 更新对话标题 - */ -export function updateConversationTitle(conversationId: string, title: string): boolean { - const db = getAiDb() - const now = Math.floor(Date.now() / 1000) - - const result = db - .prepare( - ` - UPDATE ai_conversation - SET title = ?, updated_at = ? - WHERE id = ? - ` - ) - .run(title, now, conversationId) - - return result.changes > 0 -} - -/** - * 删除对话(级联删除消息) - */ -export function deleteConversation(conversationId: string): boolean { - const db = getAiDb() - - // 先删除消息 - db.prepare('DELETE FROM ai_message WHERE conversation_id = ?').run(conversationId) - // 再删除对话 - const result = db.prepare('DELETE FROM ai_conversation WHERE id = ?').run(conversationId) - - return result.changes > 0 -} - -// ==================== 消息管理 ==================== - -/** - * 添加消息到对话 - */ export function addMessage( conversationId: string, - role: AIMessageRole, + role: import('@openchatlab/node-runtime').AIMessageRole, content: string, dataKeywords?: string[], dataMessageCount?: number, - contentBlocks?: ContentBlock[], - tokenUsage?: TokenUsageData -): AIMessage { - const db = getAiDb() - const now = Math.floor(Date.now() / 1000) - const id = `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` - - // 检查是否有暂存的 debug context 需要写入(仅 assistant 消息) - const pendingDebug = role === 'assistant' ? pendingDebugContextMap.get(conversationId) : undefined - if (pendingDebug) { - pendingDebugContextMap.delete(conversationId) - } - - db.prepare( - ` - INSERT INTO ai_message (id, conversation_id, role, content, timestamp, data_keywords, data_message_count, content_blocks, token_usage, debug_context) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ` - ).run( - id, + contentBlocks?: import('@openchatlab/node-runtime').ContentBlock[], + tokenUsage?: import('@openchatlab/node-runtime').TokenUsageData +) { + return getManager().addMessage( conversationId, role, content, - now, - dataKeywords ? JSON.stringify(dataKeywords) : null, - dataMessageCount ?? null, - contentBlocks ? JSON.stringify(contentBlocks) : null, - tokenUsage ? JSON.stringify(tokenUsage) : null, - pendingDebug ?? null - ) - - // 更新对话的 updated_at - db.prepare('UPDATE ai_conversation SET updated_at = ? WHERE id = ?').run(now, conversationId) - - return { - id, - conversationId, - role, - content, - timestamp: now, dataKeywords, dataMessageCount, contentBlocks, - tokenUsage, - } -} - -/** - * 获取对话的所有消息 - */ -export function getMessages(conversationId: string): AIMessage[] { - const db = getAiDb() - - const rows = db - .prepare( - ` - SELECT - id, - conversation_id as conversationId, - role, - content, - timestamp, - data_keywords as dataKeywords, - data_message_count as dataMessageCount, - content_blocks as contentBlocks, - token_usage as tokenUsage - FROM ai_message - WHERE conversation_id = ? - ORDER BY timestamp ASC - ` - ) - .all(conversationId) as Array<{ - id: string - conversationId: string - role: string - content: string - timestamp: number - dataKeywords: string | null - dataMessageCount: number | null - contentBlocks: string | null - tokenUsage: string | null - }> - - return rows.map((row) => ({ - id: row.id, - conversationId: row.conversationId, - role: row.role as AIMessageRole, - content: row.content, - timestamp: row.timestamp, - dataKeywords: row.dataKeywords ? JSON.parse(row.dataKeywords) : undefined, - dataMessageCount: row.dataMessageCount ?? undefined, - contentBlocks: row.contentBlocks ? JSON.parse(row.contentBlocks) : undefined, - tokenUsage: row.tokenUsage ? JSON.parse(row.tokenUsage) : undefined, - })) -} - -/** - * 删除单条消息 - */ -export function deleteMessage(messageId: string): boolean { - const db = getAiDb() - const result = db.prepare('DELETE FROM ai_message WHERE id = ?').run(messageId) - return result.changes > 0 -} - -/** - * 暂存 debug context,等待下一次 addMessage(assistant) 时自动写入 - */ -export function setPendingDebugContext(conversationId: string, debugContext: string): void { - pendingDebugContextMap.set(conversationId, debugContext) -} - -/** - * 更新消息的 debug_context 字段(仅 DEBUG 模式下调用) - */ -export function setDebugContext(messageId: string, debugContext: string): void { - const db = getAiDb() - db.prepare('UPDATE ai_message SET debug_context = ? WHERE id = ?').run(debugContext, messageId) -} - -/** - * 一键清除所有消息的 debug_context 数据 - */ -export function clearAllDebugContext(): number { - const db = getAiDb() - const result = db.prepare('UPDATE ai_message SET debug_context = NULL WHERE debug_context IS NOT NULL').run() - return result.changes -} - -/** - * 获取对话的累计 token 使用量(聚合所有 assistant 消息的 token_usage) - */ -export function getConversationTokenUsage(conversationId: string): TokenUsageData { - const db = getAiDb() - const row = db - .prepare( - ` - SELECT - COALESCE(SUM(json_extract(token_usage, '$.promptTokens')), 0) as promptTokens, - COALESCE(SUM(json_extract(token_usage, '$.completionTokens')), 0) as completionTokens, - COALESCE(SUM(json_extract(token_usage, '$.totalTokens')), 0) as totalTokens - FROM ai_message - WHERE conversation_id = ? AND token_usage IS NOT NULL - ` - ) - .get(conversationId) as { promptTokens: number; completionTokens: number; totalTokens: number } - - return { - promptTokens: row.promptTokens, - completionTokens: row.completionTokens, - totalTokens: row.totalTokens, - } -} - -// ==================== Agent 专用 ==================== - -/** - * 为 Agent 提供对话历史 - * - * 返回简化的 {role, content} 格式,按时间升序排列。 - * 当存在 summary 消息时,返回:[summary] + [buffer 消息] + [新消息]。 - * Buffer 边界从 summary 的 content_blocks 中的 summary_meta block 获取。 - * - * @param conversationId 对话 ID - * @param maxMessages 最大返回条数(取最近 N 条,仅对 buffer+新消息生效) - */ -export function getHistoryForAgent( - conversationId: string, - maxMessages?: number -): Array<{ role: 'user' | 'assistant' | 'summary'; content: string }> { - const messages = getMessages(conversationId) - const validMessages = messages.filter( - (m) => (m.role === 'user' || m.role === 'assistant' || m.role === 'summary') && m.content?.trim() + tokenUsage ) - - // 查找最新的 summary 消息 - let summaryMsg: AIMessage | undefined - for (let i = validMessages.length - 1; i >= 0; i--) { - if (validMessages[i].role === 'summary') { - summaryMsg = validMessages[i] - break - } - } - - let result: Array<{ role: 'user' | 'assistant' | 'summary'; content: string }> - - if (summaryMsg) { - // 从 content_blocks 中解析 summary_meta 获取 buffer 边界 - const metaBlock = summaryMsg.contentBlocks?.find( - (b): b is Extract => b.type === 'summary_meta' - ) - const bufferBoundary = metaBlock?.bufferBoundaryTimestamp - - if (!metaBlock) { - aiLogger.warn('Conversations', 'summary message missing summary_meta; agent context will be summary-only', { - conversationId, - messageId: summaryMsg.id, - }) - } - - // 取 timestamp >= boundary 的 user/assistant 消息(buffer + 新消息) - const contextMessages = bufferBoundary - ? validMessages.filter((m) => m.role !== 'summary' && m.timestamp >= bufferBoundary) - : [] - - result = [ - { role: 'summary' as const, content: summaryMsg.content }, - ...contextMessages.map((m) => ({ role: m.role, content: m.content })), - ] - } else { - result = validMessages.map((m) => ({ role: m.role, content: m.content })) - } - - if (maxMessages && result.length > maxMessages) { - if (result.length > 0 && result[0].role === 'summary') { - const rest = result.slice(1) - const truncated = rest.slice(-(maxMessages - 1)) - return [result[0], ...truncated] - } - return result.slice(-maxMessages) - } - return result } -// ==================== Summary / 压缩专用 ==================== +export function getMessages(conversationId: string) { + return getManager().getMessages(conversationId) +} + +export function deleteMessage(messageId: string) { + return getManager().deleteMessage(messageId) +} + +export function setPendingDebugContext(conversationId: string, debugContext: string) { + return getManager().setPendingDebugContext(conversationId, debugContext) +} + +export function setDebugContext(messageId: string, debugContext: string) { + return getManager().setDebugContext(messageId, debugContext) +} + +export function clearAllDebugContext() { + return getManager().clearAllDebugContext() +} + +export function getConversationTokenUsage(conversationId: string) { + return getManager().getConversationTokenUsage(conversationId) +} + +export function getHistoryForAgent(conversationId: string, maxMessages?: number) { + return getManager().getHistoryForAgent(conversationId, maxMessages) +} -/** - * 添加 summary 消息并替换旧的 summary(每个对话只保留一条最新压缩摘要) - * - * Summary 时间戳 = NOW(UI 中显示在触发压缩的位置)。 - * Buffer 边界信息存入 content_blocks 的 summary_meta block 中,供 getHistoryForAgent 使用。 - */ export function addSummaryMessage( conversationId: string, content: string, meta: { bufferBoundaryTimestamp: number; compressedMessageCount: number } -): AIMessage { - const db = getAiDb() - - db.prepare("DELETE FROM ai_message WHERE conversation_id = ? AND role = 'summary'").run(conversationId) - - const contentBlocks: ContentBlock[] = [ - { - type: 'summary_meta', - bufferBoundaryTimestamp: meta.bufferBoundaryTimestamp, - compressedMessageCount: meta.compressedMessageCount, - }, - ] - - return addMessage(conversationId, 'summary', content, undefined, undefined, contentBlocks) +) { + return getManager().addSummaryMessage(conversationId, content, meta) } -/** - * 获取对话中最新的 summary 消息 - */ -export function getLatestSummary(conversationId: string): AIMessage | null { - const db = getAiDb() - const row = db - .prepare( - ` - SELECT id, conversation_id as conversationId, role, content, timestamp, - data_keywords as dataKeywords, data_message_count as dataMessageCount, content_blocks as contentBlocks - FROM ai_message - WHERE conversation_id = ? AND role = 'summary' - ORDER BY timestamp DESC - LIMIT 1 - ` - ) - .get(conversationId) as - | { - id: string - conversationId: string - role: string - content: string - timestamp: number - dataKeywords: string | null - dataMessageCount: number | null - contentBlocks: string | null - } - | undefined - - if (!row) return null - return { - id: row.id, - conversationId: row.conversationId, - role: row.role as AIMessageRole, - content: row.content, - timestamp: row.timestamp, - dataKeywords: row.dataKeywords ? JSON.parse(row.dataKeywords) : undefined, - dataMessageCount: row.dataMessageCount ?? undefined, - contentBlocks: row.contentBlocks ? JSON.parse(row.contentBlocks) : undefined, - } +export function getLatestSummary(conversationId: string) { + return getManager().getLatestSummary(conversationId) } -/** - * 获取 system(压缩摘要)之后的所有 user/assistant 消息(用于压缩计算) - */ -export function getMessagesAfterSummary( - conversationId: string, - summaryTimestamp: number -): Array<{ role: AIMessageRole; content: string; timestamp: number }> { - const db = getAiDb() - const rows = db - .prepare( - ` - SELECT role, content, timestamp - FROM ai_message - WHERE conversation_id = ? AND timestamp > ? AND role IN ('user', 'assistant') - ORDER BY timestamp ASC - ` - ) - .all(conversationId, summaryTimestamp) as Array<{ - role: string - content: string - timestamp: number - }> - - return rows.map((r) => ({ role: r.role as AIMessageRole, content: r.content, timestamp: r.timestamp })) +export function getMessagesAfterSummary(conversationId: string, summaryTimestamp: number) { + return getManager().getMessagesAfterSummary(conversationId, summaryTimestamp) } -/** - * 获取对话中所有 user/assistant 消息(不含 summary,用于首次压缩) - */ -export function getAllUserAssistantMessages( - conversationId: string -): Array<{ role: AIMessageRole; content: string; timestamp: number }> { - const db = getAiDb() - const rows = db - .prepare( - ` - SELECT role, content, timestamp - FROM ai_message - WHERE conversation_id = ? AND role IN ('user', 'assistant') - ORDER BY timestamp ASC - ` - ) - .all(conversationId) as Array<{ - role: string - content: string - timestamp: number - }> - - return rows.map((r) => ({ role: r.role as AIMessageRole, content: r.content, timestamp: r.timestamp })) +export function getAllUserAssistantMessages(conversationId: string) { + return getManager().getAllUserAssistantMessages(conversationId) } -/** - * 获取对话中 summary 之后的 user/assistant 消息数量 - */ -export function getMessageCountAfterSummary(conversationId: string): number { - const summary = getLatestSummary(conversationId) - if (!summary) { - const db = getAiDb() - const row = db - .prepare("SELECT COUNT(*) as count FROM ai_message WHERE conversation_id = ? AND role IN ('user', 'assistant')") - .get(conversationId) as { count: number } - return row.count - } - - // 从 summary_meta 获取 buffer 边界,统计 >= boundary 的消息数 - const metaBlock = summary.contentBlocks?.find( - (b): b is Extract => b.type === 'summary_meta' - ) - const boundary = metaBlock?.bufferBoundaryTimestamp ?? summary.timestamp - - const db = getAiDb() - const row = db - .prepare( - "SELECT COUNT(*) as count FROM ai_message WHERE conversation_id = ? AND timestamp >= ? AND role IN ('user', 'assistant')" - ) - .get(conversationId, boundary) as { count: number } - return row.count +export function getMessageCountAfterSummary(conversationId: string) { + return getManager().getMessageCountAfterSummary(conversationId) } diff --git a/electron/main/ai/llm/crypto.ts b/electron/main/ai/llm/crypto.ts index 910b9182..7e188c4c 100644 --- a/electron/main/ai/llm/crypto.ts +++ b/electron/main/ai/llm/crypto.ts @@ -5,7 +5,7 @@ import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto' import { execSync } from 'child_process' -import { getDeviceKey } from './device-key' +import { getDeviceKey, getAllDeviceKeys } from './device-key' // 加密算法 const ALGORITHM = 'aes-256-gcm' @@ -148,24 +148,25 @@ function tryDecryptWithKey(encrypted: string, key: Buffer): string | null { export function decryptApiKey(encrypted: string): string { if (!encrypted) return '' - // 如果不是加密格式,直接返回(兼容旧的明文数据) if (!isEncrypted(encrypted)) { return encrypted } - // 尝试当前密钥 - const currentKey = getKey() - const result = tryDecryptWithKey(encrypted, currentKey) - if (result !== null) return result + // 1. 尝试所有 device key 文件(当前 + Electron legacy 位置) + const allDeviceKeys = getAllDeviceKeys() + for (const dk of allDeviceKeys) { + const derived = createHash('sha256') + .update(dk + SALT) + .digest() + const result = tryDecryptWithKey(encrypted, derived) + if (result !== null) return result + } - // 当前密钥失败,尝试旧版 machine-id 密钥(迁移场景) + // 2. 尝试旧版 machine-id 密钥 const legacyKeys = deriveLegacyKeys() for (const legacyKey of legacyKeys) { const legacyResult = tryDecryptWithKey(encrypted, legacyKey) - if (legacyResult !== null) { - console.log('[Crypto] Decrypted with legacy key, migration needed') - return legacyResult - } + if (legacyResult !== null) return legacyResult } console.error('[Crypto] Failed to decrypt API Key with all available keys') diff --git a/electron/main/ai/llm/device-key.ts b/electron/main/ai/llm/device-key.ts index eb45e73e..f96e6f01 100644 --- a/electron/main/ai/llm/device-key.ts +++ b/electron/main/ai/llm/device-key.ts @@ -5,14 +5,27 @@ */ import * as fs from 'fs' +import * as os from 'os' import * as path from 'path' import { randomBytes } from 'crypto' -import { getSystemDataDir, ensureDir } from '../../paths' +import { getSystemDataDir, getElectronLegacyDataDir, ensureDir } from '../../paths' const DEVICE_KEY_FILE = '.device-key' let cachedDeviceKey: string | null = null +function readKeyFromFile(keyPath: string): string | null { + try { + if (fs.existsSync(keyPath)) { + const key = fs.readFileSync(keyPath, 'utf-8').trim() + if (key.length >= 32) return key + } + } catch { + // ignore + } + return null +} + /** * 获取设备密钥(32 字节随机值的 hex 字符串) * 首次调用时从文件读取,文件不存在则生成并写入。 @@ -24,23 +37,15 @@ export function getDeviceKey(): string { ensureDir(dataDir) const keyPath = path.join(dataDir, DEVICE_KEY_FILE) - try { - if (fs.existsSync(keyPath)) { - const key = fs.readFileSync(keyPath, 'utf-8').trim() - if (key.length >= 32) { - cachedDeviceKey = key - return cachedDeviceKey - } - } - } catch (error) { - console.warn('[DeviceKey] Failed to read device key file:', error) + const key = readKeyFromFile(keyPath) + if (key) { + cachedDeviceKey = key + return cachedDeviceKey } - // 生成新密钥 const newKey = randomBytes(32).toString('hex') try { fs.writeFileSync(keyPath, newKey, { encoding: 'utf-8', mode: 0o600 }) - console.log('[DeviceKey] Generated new device key') } catch (error) { console.error('[DeviceKey] Failed to write device key file:', error) } @@ -49,6 +54,41 @@ export function getDeviceKey(): string { return cachedDeviceKey } +/** + * 获取所有可能的 device key(当前 + Electron legacy 位置) + * 用于解密迁移前加密的 API Key + */ +export function getAllDeviceKeys(): string[] { + const keys: string[] = [] + const seen = new Set() + + const addKey = (k: string | null) => { + if (k && !seen.has(k)) { + seen.add(k) + keys.push(k) + } + } + + addKey(readKeyFromFile(path.join(getSystemDataDir(), DEVICE_KEY_FILE))) + + try { + addKey(readKeyFromFile(path.join(getElectronLegacyDataDir(), DEVICE_KEY_FILE))) + } catch { + // getElectronLegacyDataDir() may throw outside Electron + } + + if (process.platform === 'darwin') { + addKey( + readKeyFromFile(path.join(os.homedir(), 'Library', 'Application Support', 'ChatLab', 'data', DEVICE_KEY_FILE)) + ) + } else if (process.platform === 'win32') { + const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming') + addKey(readKeyFromFile(path.join(appData, 'ChatLab', 'data', DEVICE_KEY_FILE))) + } + + return keys +} + /** * 重置缓存(仅测试用) */ diff --git a/electron/main/ai/llm/index.ts b/electron/main/ai/llm/index.ts index 4c9a81de..b1ea5105 100644 --- a/electron/main/ai/llm/index.ts +++ b/electron/main/ai/llm/index.ts @@ -10,7 +10,8 @@ import { getAiDataDir } from '../../paths' import type { LLMProvider, ProviderInfo, AIServiceConfig, AIConfigStore } from './types' import { MAX_CONFIG_COUNT } from './types' import { aiLogger } from '../logger' -import { encryptApiKey, decryptApiKey, isEncrypted } from './crypto' +import { decryptApiKey, isEncrypted } from './crypto' +import { resolveApiKey, writeAuthProfile } from '@openchatlab/config' import { buildChatLabUserAgentHeaders } from '../../utils/httpHeaders' import { t } from '../../i18n' import { completeSimple, type Model as PiModel, type Api as PiApi } from '@mariozechner/pi-ai' @@ -271,32 +272,48 @@ export function loadConfigStore(): AIConfigStore { needsSchemaSave = true } - let needsEncryptionMigration = false - const decryptedConfigs = store.configs.map((config) => { - if (config.apiKey && !isEncrypted(config.apiKey)) { - needsEncryptionMigration = true - aiLogger.info('LLM', `Config "${config.name}" API Key needs encryption migration`) - } - return { - ...config, - apiKey: config.apiKey ? decryptApiKey(config.apiKey) : '', + let needsMigrationSave = false + const resolvedConfigs = store.configs.map((config) => { + // 一次性迁移:旧加密 Key → auth-profiles.json + if (config.apiKey && isEncrypted(config.apiKey)) { + const plainKey = decryptApiKey(config.apiKey) + if (plainKey) { + const profileName = config.name?.toLowerCase().replace(/\s+/g, '-') || config.provider + writeAuthProfile(profileName, { + type: 'api_key', + provider: config.provider, + key: plainKey, + }) + aiLogger.info('LLM', `Migrated API Key for "${config.name}" to auth-profiles.json`) + needsMigrationSave = true + return { ...config, apiKey: plainKey } + } + aiLogger.warn('LLM', `Failed to decrypt API Key for "${config.name}", skipping migration`) + return { ...config, apiKey: '' } } + + // 正常流程:从 auth-profiles.json 读取 + const profileKey = resolveApiKey( + config.provider, + (config as Record).authProfile as string | undefined + ) + return { ...config, apiKey: profileKey || config.apiKey || '' } }) - if (needsEncryptionMigration || needsSchemaSave) { - aiLogger.info('LLM', 'Saving migrated config store') + if (needsMigrationSave || needsSchemaSave) { + aiLogger.info('LLM', 'Saving migrated config store (removing encrypted keys)') saveConfigStoreRaw({ ...store, configs: store.configs.map((config) => ({ ...config, - apiKey: config.apiKey ? encryptApiKey(decryptApiKey(config.apiKey)) : '', + apiKey: '', })), }) } return { ...store, - configs: decryptedConfigs, + configs: resolvedConfigs, } } catch (error) { aiLogger.error('LLM', 'Failed to load configs', error) @@ -305,17 +322,17 @@ export function loadConfigStore(): AIConfigStore { } /** - * 保存配置存储(自动加密 API Key) + * 保存配置存储 + * API Key 不再存储在 llm-config.json 中,统一由 auth-profiles.json 管理 */ export function saveConfigStore(store: AIConfigStore): void { - const encryptedStore: AIConfigStore = { + saveConfigStoreRaw({ ...store, configs: store.configs.map((config) => ({ ...config, - apiKey: config.apiKey ? encryptApiKey(config.apiKey) : '', + apiKey: '', })), - } - saveConfigStoreRaw(encryptedStore) + }) } function saveConfigStoreRaw(store: AIConfigStore): void { @@ -399,6 +416,15 @@ export function addConfig(config: Omit +} + +const AUTH_PROFILES_FILE = 'auth-profiles.json' + +function getAuthProfilesPath(): string { + return path.join(getConfigDir(), AUTH_PROFILES_FILE) +} + +/** + * 加载 auth-profiles.json + */ +export function loadAuthProfiles(): AuthProfilesData { + const filePath = getAuthProfilesPath() + if (!fs.existsSync(filePath)) { + return { version: 1, profiles: {} } + } + + try { + const content = fs.readFileSync(filePath, 'utf-8') + const data = JSON.parse(content) as AuthProfilesData + if (!data.profiles || typeof data.profiles !== 'object') { + return { version: 1, profiles: {} } + } + return data + } catch { + return { version: 1, profiles: {} } + } +} + +/** + * 按 profile 名称获取 API Key + */ +export function getApiKeyByProfile(profileName: string): string { + const data = loadAuthProfiles() + const profile = data.profiles[profileName] + return profile?.key || '' +} + +/** + * 按 provider 名称获取 API Key(模糊匹配,取第一个匹配的 profile) + */ +export function getApiKeyByProvider(provider: string): string { + const data = loadAuthProfiles() + for (const profile of Object.values(data.profiles)) { + if (profile.provider === provider) { + return profile.key || '' + } + } + return '' +} + +/** + * 查找 API Key:先按 authProfile 精确匹配,再按 provider 兜底 + */ +export function resolveApiKey(provider: string, authProfile?: string): string { + if (authProfile) { + const key = getApiKeyByProfile(authProfile) + if (key) return key + } + return getApiKeyByProvider(provider) +} + +/** + * 写入/更新一个 auth profile + */ +export function writeAuthProfile(name: string, profile: AuthProfile): void { + const configDir = getConfigDir() + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }) + } + + const data = loadAuthProfiles() + data.profiles[name] = profile + + const filePath = getAuthProfilesPath() + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), { encoding: 'utf-8', mode: 0o600 }) +} + +/** + * 删除一个 auth profile + */ +export function deleteAuthProfile(name: string): boolean { + const data = loadAuthProfiles() + if (!(name in data.profiles)) return false + + delete data.profiles[name] + const filePath = getAuthProfilesPath() + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), { encoding: 'utf-8', mode: 0o600 }) + return true +} diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index c5aa15f0..1935a004 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -7,3 +7,12 @@ export { loadConfig, getConfigPath, getConfigDir, writeConfigField } from './loader' export { configSchema } from './schema' export type { ChatLabConfig, LlmConfig, DataConfig, ApiConfig, LocaleConfig } from './schema' +export { + loadAuthProfiles, + getApiKeyByProfile, + getApiKeyByProvider, + resolveApiKey, + writeAuthProfile, + deleteAuthProfile, +} from './auth-profiles' +export type { AuthProfile, AuthProfilesData } from './auth-profiles' diff --git a/packages/config/src/loader.ts b/packages/config/src/loader.ts index 634260b3..7951b557 100644 --- a/packages/config/src/loader.ts +++ b/packages/config/src/loader.ts @@ -77,7 +77,6 @@ function loadConfigFile(): Record { * - CHATLAB_API_PORT -> api.port * - CHATLAB_API_HOST -> api.host * - CHATLAB_LLM_PROVIDER -> llm.provider - * - CHATLAB_LLM_API_KEY -> llm.api_key * - CHATLAB_LLM_MODEL -> llm.model * - CHATLAB_LLM_BASE_URL -> llm.base_url * - CHATLAB_LOCALE_LANG -> locale.lang @@ -90,7 +89,6 @@ function loadEnvConfig(): Record { { env: 'CHATLAB_API_PORT', section: 'api', key: 'port', transform: (v) => parseInt(v, 10) }, { env: 'CHATLAB_API_HOST', section: 'api', key: 'host' }, { env: 'CHATLAB_LLM_PROVIDER', section: 'llm', key: 'provider' }, - { env: 'CHATLAB_LLM_API_KEY', section: 'llm', key: 'api_key' }, { env: 'CHATLAB_LLM_MODEL', section: 'llm', key: 'model' }, { env: 'CHATLAB_LLM_BASE_URL', section: 'llm', key: 'base_url' }, { env: 'CHATLAB_LOCALE_LANG', section: 'locale', key: 'lang' }, diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index a65ad97d..a31b202b 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -8,7 +8,6 @@ import { z } from 'zod' export const llmConfigSchema = z.object({ provider: z.string().default(''), - api_key: z.string().default(''), model: z.string().default(''), base_url: z.string().default(''), }) diff --git a/packages/node-runtime/src/ai/conversations.ts b/packages/node-runtime/src/ai/conversations.ts new file mode 100644 index 00000000..919c4f19 --- /dev/null +++ b/packages/node-runtime/src/ai/conversations.ts @@ -0,0 +1,595 @@ +/** + * AI 对话历史管理模块(平台无关) + * + * 管理 AI 对话的持久化存储(conversations.db), + * 供 Electron 主进程和 CLI serve 共用。 + */ + +import Database from 'better-sqlite3' +import * as fs from 'fs' +import * as path from 'path' + +const DEFAULT_GENERAL_ID = 'general_cn' + +// ==================== 类型定义 ==================== + +export interface AIConversation { + id: string + sessionId: string + title: string | null + assistantId: string + createdAt: number + updatedAt: number +} + +export type ContentBlock = + | { type: 'text'; text: string } + | { type: 'think'; tag: string; text: string; durationMs?: number } + | { + type: 'tool' + tool: { + name: string + displayName: string + status: 'running' | 'done' | 'error' + params?: Record + } + } + | { + type: 'summary_meta' + bufferBoundaryTimestamp: number + compressedMessageCount: number + } + +export type AIMessageRole = 'user' | 'assistant' | 'summary' + +export interface TokenUsageData { + promptTokens: number + completionTokens: number + totalTokens: number +} + +export interface AIMessage { + id: string + conversationId: string + role: AIMessageRole + content: string + timestamp: number + dataKeywords?: string[] + dataMessageCount?: number + contentBlocks?: ContentBlock[] + tokenUsage?: TokenUsageData +} + +export interface ConversationManagerLogger { + warn(category: string, message: string, extra?: Record): void +} + +const defaultLogger: ConversationManagerLogger = { + warn(_category, message, extra) { + console.warn(`[AI Conversations] ${message}`, extra ?? '') + }, +} + +// ==================== AIConversationManager ==================== + +export class AIConversationManager { + private db: Database.Database | null = null + private readonly aiDataDir: string + private readonly logger: ConversationManagerLogger + private readonly nativeBinding?: string + private readonly pendingDebugContextMap = new Map() + + constructor(aiDataDir: string, options?: { logger?: ConversationManagerLogger; nativeBinding?: string }) { + this.aiDataDir = aiDataDir + this.logger = options?.logger ?? defaultLogger + this.nativeBinding = options?.nativeBinding + } + + private ensureDir(dir: string): void { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + } + + private getDb(): Database.Database { + if (this.db) return this.db + + this.ensureDir(this.aiDataDir) + const dbPath = path.join(this.aiDataDir, 'conversations.db') + this.db = this.nativeBinding ? new Database(dbPath, { nativeBinding: this.nativeBinding }) : new Database(dbPath) + this.db.pragma('journal_mode = WAL') + + this.db.exec(` + CREATE TABLE IF NOT EXISTS ai_conversation ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + title TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS ai_message ( + id TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + timestamp INTEGER NOT NULL, + data_keywords TEXT, + data_message_count INTEGER, + content_blocks TEXT, + FOREIGN KEY(conversation_id) REFERENCES ai_conversation(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_ai_conversation_session ON ai_conversation(session_id); + CREATE INDEX IF NOT EXISTS idx_ai_message_conversation ON ai_message(conversation_id); + `) + + this.migrateDatabase(this.db) + return this.db + } + + private migrateDatabase(db: Database.Database): void { + try { + const messageTableInfo = db.pragma('table_info(ai_message)') as Array<{ name: string }> + const messageColumns = messageTableInfo.map((col) => col.name) + + if (!messageColumns.includes('content_blocks')) { + db.exec('ALTER TABLE ai_message ADD COLUMN content_blocks TEXT') + } + if (!messageColumns.includes('token_usage')) { + db.exec('ALTER TABLE ai_message ADD COLUMN token_usage TEXT') + } + if (!messageColumns.includes('debug_context')) { + db.exec('ALTER TABLE ai_message ADD COLUMN debug_context TEXT') + } + + const convTableInfo = db.pragma('table_info(ai_conversation)') as Array<{ name: string }> + const convColumns = convTableInfo.map((col) => col.name) + + if (!convColumns.includes('assistant_id')) { + db.exec(`ALTER TABLE ai_conversation ADD COLUMN assistant_id TEXT DEFAULT '${DEFAULT_GENERAL_ID}'`) + } + } catch (error) { + console.error('[AI DB Migration] Migration failed:', error) + } + } + + private generateId(prefix: string): string { + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` + } + + // ==================== 生命周期 ==================== + + close(): void { + if (this.db) { + this.db.close() + this.db = null + } + } + + // ==================== Debug ==================== + + getAiSchema(): Array<{ + name: string + columns: Array<{ name: string; type: string; notnull: boolean; pk: boolean }> + }> { + const db = this.getDb() + const tables = db + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") + .all() as Array<{ name: string }> + + return tables.map((t) => { + const columns = db.pragma(`table_info("${t.name}")`) as Array<{ + name: string + type: string + notnull: number + pk: number + }> + return { + name: t.name, + columns: columns.map((c) => ({ + name: c.name, + type: c.type, + notnull: !!c.notnull, + pk: !!c.pk, + })), + } + }) + } + + executeAiSQL(sql: string): { + columns: string[] + rows: unknown[][] + rowCount: number + duration: number + limited: boolean + } { + const db = this.getDb() + const start = Date.now() + const trimmed = sql.trim() + const isSelect = /^SELECT/i.test(trimmed) + + if (isSelect) { + const stmt = db.prepare(trimmed) + const rows = stmt.all() as Record[] + const duration = Date.now() - start + const columns = rows.length > 0 ? Object.keys(rows[0]) : [] + return { + columns, + rows: rows.map((r) => columns.map((c) => r[c])), + rowCount: rows.length, + duration, + limited: false, + } + } else { + const result = db.prepare(trimmed).run() + const duration = Date.now() - start + return { + columns: ['changes', 'lastInsertRowid'], + rows: [[result.changes, Number(result.lastInsertRowid)]], + rowCount: 1, + duration, + limited: false, + } + } + } + + // ==================== 对话管理 ==================== + + createConversation(sessionId: string, title: string | undefined, assistantId: string): AIConversation { + const db = this.getDb() + const now = Math.floor(Date.now() / 1000) + const id = this.generateId('conv') + + db.prepare( + `INSERT INTO ai_conversation (id, session_id, title, assistant_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)` + ).run(id, sessionId, title || null, assistantId, now, now) + + return { id, sessionId, title: title || null, assistantId, createdAt: now, updatedAt: now } + } + + getConversationCountsBySession(): Map { + const result = new Map() + try { + const db = this.getDb() + const rows = db + .prepare('SELECT session_id, COUNT(*) as count FROM ai_conversation GROUP BY session_id') + .all() as Array<{ session_id: string; count: number }> + for (const row of rows) { + result.set(row.session_id, row.count) + } + } catch { + // AI DB may not be initialized yet + } + return result + } + + getConversations(sessionId: string): AIConversation[] { + const db = this.getDb() + return db + .prepare( + `SELECT id, session_id as sessionId, title, assistant_id as assistantId, + created_at as createdAt, updated_at as updatedAt + FROM ai_conversation WHERE session_id = ? ORDER BY updated_at DESC` + ) + .all(sessionId) as AIConversation[] + } + + getConversation(conversationId: string): AIConversation | null { + const db = this.getDb() + const row = db + .prepare( + `SELECT id, session_id as sessionId, title, assistant_id as assistantId, + created_at as createdAt, updated_at as updatedAt + FROM ai_conversation WHERE id = ?` + ) + .get(conversationId) as AIConversation | undefined + return row || null + } + + updateConversationTitle(conversationId: string, title: string): boolean { + const db = this.getDb() + const now = Math.floor(Date.now() / 1000) + const result = db + .prepare('UPDATE ai_conversation SET title = ?, updated_at = ? WHERE id = ?') + .run(title, now, conversationId) + return result.changes > 0 + } + + deleteConversation(conversationId: string): boolean { + const db = this.getDb() + db.prepare('DELETE FROM ai_message WHERE conversation_id = ?').run(conversationId) + const result = db.prepare('DELETE FROM ai_conversation WHERE id = ?').run(conversationId) + return result.changes > 0 + } + + // ==================== 消息管理 ==================== + + addMessage( + conversationId: string, + role: AIMessageRole, + content: string, + dataKeywords?: string[], + dataMessageCount?: number, + contentBlocks?: ContentBlock[], + tokenUsage?: TokenUsageData + ): AIMessage { + const db = this.getDb() + const now = Math.floor(Date.now() / 1000) + const id = this.generateId('msg') + + const pendingDebug = role === 'assistant' ? this.pendingDebugContextMap.get(conversationId) : undefined + if (pendingDebug) { + this.pendingDebugContextMap.delete(conversationId) + } + + db.prepare( + `INSERT INTO ai_message (id, conversation_id, role, content, timestamp, data_keywords, data_message_count, content_blocks, token_usage, debug_context) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + id, + conversationId, + role, + content, + now, + dataKeywords ? JSON.stringify(dataKeywords) : null, + dataMessageCount ?? null, + contentBlocks ? JSON.stringify(contentBlocks) : null, + tokenUsage ? JSON.stringify(tokenUsage) : null, + pendingDebug ?? null + ) + + db.prepare('UPDATE ai_conversation SET updated_at = ? WHERE id = ?').run(now, conversationId) + + return { + id, + conversationId, + role, + content, + timestamp: now, + dataKeywords, + dataMessageCount, + contentBlocks, + tokenUsage, + } + } + + getMessages(conversationId: string): AIMessage[] { + const db = this.getDb() + const rows = db + .prepare( + `SELECT id, conversation_id as conversationId, role, content, timestamp, + data_keywords as dataKeywords, data_message_count as dataMessageCount, + content_blocks as contentBlocks, token_usage as tokenUsage + FROM ai_message WHERE conversation_id = ? ORDER BY timestamp ASC` + ) + .all(conversationId) as Array<{ + id: string + conversationId: string + role: string + content: string + timestamp: number + dataKeywords: string | null + dataMessageCount: number | null + contentBlocks: string | null + tokenUsage: string | null + }> + + return rows.map((row) => ({ + id: row.id, + conversationId: row.conversationId, + role: row.role as AIMessageRole, + content: row.content, + timestamp: row.timestamp, + dataKeywords: row.dataKeywords ? JSON.parse(row.dataKeywords) : undefined, + dataMessageCount: row.dataMessageCount ?? undefined, + contentBlocks: row.contentBlocks ? JSON.parse(row.contentBlocks) : undefined, + tokenUsage: row.tokenUsage ? JSON.parse(row.tokenUsage) : undefined, + })) + } + + deleteMessage(messageId: string): boolean { + const db = this.getDb() + const result = db.prepare('DELETE FROM ai_message WHERE id = ?').run(messageId) + return result.changes > 0 + } + + getConversationTokenUsage(conversationId: string): TokenUsageData { + const db = this.getDb() + const row = db + .prepare( + `SELECT + COALESCE(SUM(json_extract(token_usage, '$.promptTokens')), 0) as promptTokens, + COALESCE(SUM(json_extract(token_usage, '$.completionTokens')), 0) as completionTokens, + COALESCE(SUM(json_extract(token_usage, '$.totalTokens')), 0) as totalTokens + FROM ai_message WHERE conversation_id = ? AND token_usage IS NOT NULL` + ) + .get(conversationId) as { promptTokens: number; completionTokens: number; totalTokens: number } + return { promptTokens: row.promptTokens, completionTokens: row.completionTokens, totalTokens: row.totalTokens } + } + + // ==================== Debug context ==================== + + setPendingDebugContext(conversationId: string, debugContext: string): void { + this.pendingDebugContextMap.set(conversationId, debugContext) + } + + setDebugContext(messageId: string, debugContext: string): void { + const db = this.getDb() + db.prepare('UPDATE ai_message SET debug_context = ? WHERE id = ?').run(debugContext, messageId) + } + + clearAllDebugContext(): number { + const db = this.getDb() + const result = db.prepare('UPDATE ai_message SET debug_context = NULL WHERE debug_context IS NOT NULL').run() + return result.changes + } + + // ==================== Agent 专用 ==================== + + getHistoryForAgent( + conversationId: string, + maxMessages?: number + ): Array<{ role: 'user' | 'assistant' | 'summary'; content: string }> { + const messages = this.getMessages(conversationId) + const validMessages = messages.filter( + (m) => (m.role === 'user' || m.role === 'assistant' || m.role === 'summary') && m.content?.trim() + ) + + let summaryMsg: AIMessage | undefined + for (let i = validMessages.length - 1; i >= 0; i--) { + if (validMessages[i].role === 'summary') { + summaryMsg = validMessages[i] + break + } + } + + let result: Array<{ role: 'user' | 'assistant' | 'summary'; content: string }> + + if (summaryMsg) { + const metaBlock = summaryMsg.contentBlocks?.find( + (b): b is Extract => b.type === 'summary_meta' + ) + const bufferBoundary = metaBlock?.bufferBoundaryTimestamp + + if (!metaBlock) { + this.logger.warn('Conversations', 'summary message missing summary_meta; agent context will be summary-only', { + conversationId, + messageId: summaryMsg.id, + }) + } + + const contextMessages = bufferBoundary + ? validMessages.filter((m) => m.role !== 'summary' && m.timestamp >= bufferBoundary) + : [] + + result = [ + { role: 'summary' as const, content: summaryMsg.content }, + ...contextMessages.map((m) => ({ role: m.role, content: m.content })), + ] + } else { + result = validMessages.map((m) => ({ role: m.role, content: m.content })) + } + + if (maxMessages && result.length > maxMessages) { + if (result.length > 0 && result[0].role === 'summary') { + const rest = result.slice(1) + const truncated = rest.slice(-(maxMessages - 1)) + return [result[0], ...truncated] + } + return result.slice(-maxMessages) + } + return result + } + + // ==================== Summary / 压缩专用 ==================== + + addSummaryMessage( + conversationId: string, + content: string, + meta: { bufferBoundaryTimestamp: number; compressedMessageCount: number } + ): AIMessage { + const db = this.getDb() + db.prepare("DELETE FROM ai_message WHERE conversation_id = ? AND role = 'summary'").run(conversationId) + + const contentBlocks: ContentBlock[] = [ + { + type: 'summary_meta', + bufferBoundaryTimestamp: meta.bufferBoundaryTimestamp, + compressedMessageCount: meta.compressedMessageCount, + }, + ] + + return this.addMessage(conversationId, 'summary', content, undefined, undefined, contentBlocks) + } + + getLatestSummary(conversationId: string): AIMessage | null { + const db = this.getDb() + const row = db + .prepare( + `SELECT id, conversation_id as conversationId, role, content, timestamp, + data_keywords as dataKeywords, data_message_count as dataMessageCount, + content_blocks as contentBlocks + FROM ai_message WHERE conversation_id = ? AND role = 'summary' + ORDER BY timestamp DESC LIMIT 1` + ) + .get(conversationId) as + | { + id: string + conversationId: string + role: string + content: string + timestamp: number + dataKeywords: string | null + dataMessageCount: number | null + contentBlocks: string | null + } + | undefined + + if (!row) return null + return { + id: row.id, + conversationId: row.conversationId, + role: row.role as AIMessageRole, + content: row.content, + timestamp: row.timestamp, + dataKeywords: row.dataKeywords ? JSON.parse(row.dataKeywords) : undefined, + dataMessageCount: row.dataMessageCount ?? undefined, + contentBlocks: row.contentBlocks ? JSON.parse(row.contentBlocks) : undefined, + } + } + + getMessagesAfterSummary( + conversationId: string, + summaryTimestamp: number + ): Array<{ role: AIMessageRole; content: string; timestamp: number }> { + const db = this.getDb() + const rows = db + .prepare( + `SELECT role, content, timestamp FROM ai_message + WHERE conversation_id = ? AND timestamp > ? AND role IN ('user', 'assistant') + ORDER BY timestamp ASC` + ) + .all(conversationId, summaryTimestamp) as Array<{ role: string; content: string; timestamp: number }> + return rows.map((r) => ({ role: r.role as AIMessageRole, content: r.content, timestamp: r.timestamp })) + } + + getAllUserAssistantMessages( + conversationId: string + ): Array<{ role: AIMessageRole; content: string; timestamp: number }> { + const db = this.getDb() + const rows = db + .prepare( + `SELECT role, content, timestamp FROM ai_message + WHERE conversation_id = ? AND role IN ('user', 'assistant') + ORDER BY timestamp ASC` + ) + .all(conversationId) as Array<{ role: string; content: string; timestamp: number }> + return rows.map((r) => ({ role: r.role as AIMessageRole, content: r.content, timestamp: r.timestamp })) + } + + getMessageCountAfterSummary(conversationId: string): number { + const summary = this.getLatestSummary(conversationId) + if (!summary) { + const db = this.getDb() + const row = db + .prepare("SELECT COUNT(*) as count FROM ai_message WHERE conversation_id = ? AND role IN ('user', 'assistant')") + .get(conversationId) as { count: number } + return row.count + } + + const metaBlock = summary.contentBlocks?.find( + (b): b is Extract => b.type === 'summary_meta' + ) + const boundary = metaBlock?.bufferBoundaryTimestamp ?? summary.timestamp + + const db = this.getDb() + const row = db + .prepare( + "SELECT COUNT(*) as count FROM ai_message WHERE conversation_id = ? AND timestamp >= ? AND role IN ('user', 'assistant')" + ) + .get(conversationId, boundary) as { count: number } + return row.count + } +} diff --git a/packages/node-runtime/src/ai/index.ts b/packages/node-runtime/src/ai/index.ts index 65a077e9..53460096 100644 --- a/packages/node-runtime/src/ai/index.ts +++ b/packages/node-runtime/src/ai/index.ts @@ -1,9 +1,18 @@ /** * AI 模块(Node.js 实现) * - * 助手/技能 MD 文件解析器和共享类型。 + * 助手/技能 MD 文件解析器、共享类型、对话管理。 */ export type { AssistantConfig, AssistantSummary, SkillDef, SkillSummary } from './types' export { parseAssistantFile, serializeAssistant } from './assistant-parser' export { parseSkillFile, extractSkillId } from './skill-parser' +export { AIConversationManager } from './conversations' +export type { + AIConversation, + AIMessage, + AIMessageRole, + ContentBlock, + TokenUsageData, + ConversationManagerLogger, +} from './conversations' diff --git a/packages/node-runtime/src/index.ts b/packages/node-runtime/src/index.ts index 38e6b602..11c4c46e 100644 --- a/packages/node-runtime/src/index.ts +++ b/packages/node-runtime/src/index.ts @@ -30,6 +30,15 @@ export { ensureDefaultDict, } from './nlp' -// AI 助手/技能解析器 +// AI 助手/技能解析器 + 对话管理 export type { AssistantConfig, AssistantSummary, SkillDef, SkillSummary } from './ai' export { parseAssistantFile, serializeAssistant, parseSkillFile, extractSkillId } from './ai' +export { AIConversationManager } from './ai' +export type { + AIConversation, + AIMessage, + AIMessageRole, + ContentBlock, + TokenUsageData, + ConversationManagerLogger, +} from './ai' diff --git a/packages/server/package.json b/packages/server/package.json index c7dc529e..cdabf7c1 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -16,6 +16,8 @@ "dependencies": { "@fastify/multipart": "^10.0.0", "@fastify/static": "^9.1.3", + "@mariozechner/pi-agent-core": "0.54.2", + "@mariozechner/pi-ai": "0.54.2", "@modelcontextprotocol/sdk": "^1.12.1", "@openchatlab/config": "workspace:*", "@openchatlab/core": "workspace:*", diff --git a/packages/server/src/ai/agent.ts b/packages/server/src/ai/agent.ts new file mode 100644 index 00000000..326bbac5 --- /dev/null +++ b/packages/server/src/ai/agent.ts @@ -0,0 +1,281 @@ +/** + * 服务端 Agent + * + * 使用 @mariozechner/pi-agent-core 编排对话流程, + * 将流式事件通过回调输出给 SSE 端点。 + */ + +import { Agent as PiAgentCore } from '@mariozechner/pi-agent-core' +import type { AgentEvent as PiAgentEvent } from '@mariozechner/pi-agent-core' +import { + type AssistantMessage as PiAssistantMessage, + type Message as PiMessage, + type Usage as PiUsage, + streamSimple, +} from '@mariozechner/pi-ai' +import type { AIConversationManager } from '@openchatlab/node-runtime' + +import { getDefaultAssistantConfig, buildPiModel } from './llm-config' + +export interface AgentStreamEvent { + type: 'content' | 'think' | 'tool_start' | 'tool_result' | 'status' | 'done' | 'error' + content?: string + thinkTag?: string + thinkDurationMs?: number + toolName?: string + toolParams?: Record + toolResult?: unknown + error?: { name: string | null; message: string | null } + isFinished?: boolean + usage?: { promptTokens: number; completionTokens: number; totalTokens: number } + status?: { + phase: string + round: number + toolsUsed: number + currentTool?: string + } +} + +export interface RunAgentOptions { + userMessage: string + conversationId: string + chatType?: 'group' | 'private' + locale?: string + assistantSystemPrompt?: string + tools?: import('@mariozechner/pi-agent-core').AgentTool[] + aiDataDir: string + convManager: AIConversationManager + onEvent: (event: AgentStreamEvent) => void + abortSignal?: AbortSignal +} + +type SimpleHistoryMessage = { role: 'user' | 'assistant' | 'summary'; content: string } + +function buildSystemPrompt( + chatType: 'group' | 'private', + assistantSystemPrompt?: string, + locale: string = 'zh-CN' +): string { + const now = new Date() + const dateLocale = locale.startsWith('zh') ? 'zh-CN' : 'en-US' + const currentDate = now.toLocaleDateString(dateLocale, { + year: 'numeric', + month: 'long', + day: 'numeric', + weekday: 'long', + }) + + const isZh = locale.startsWith('zh') + const role = + assistantSystemPrompt || + (isZh + ? '你是 ChatLab AI 助手,一个智能对话助手。请友好、准确地回答用户的问题。' + : 'You are ChatLab AI assistant, an intelligent conversational assistant. Please answer questions in a friendly and accurate manner.') + + const datePrefix = isZh ? '当前日期是' : 'Current date is' + const responseNote = isZh + ? '请直接回答用户的问题,不要使用工具除非确实需要。' + : "Answer the user's question directly. Only use tools when truly necessary." + + return `${role} + +${datePrefix} ${currentDate}。 + +${responseNote}` +} + +function createEmptyPiUsage(): PiUsage { + return { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + } +} + +function toPiHistoryMessages(messages: SimpleHistoryMessage[]): PiMessage[] { + return messages.map((msg): PiMessage => { + if (msg.role === 'user') { + return { + role: 'user', + content: [{ type: 'text', text: msg.content || '' }], + timestamp: Date.now(), + } + } + return { + role: 'assistant', + content: [{ type: 'text', text: msg.content || '' }], + api: 'openai-completions', + provider: 'chatlab', + model: 'unknown', + usage: createEmptyPiUsage(), + stopReason: 'stop', + timestamp: Date.now(), + } + }) +} + +function stripThinkingTags(content: string): string { + const tags = ['think', 'analysis', 'reasoning', 'reflection', 'thought', 'thinking'] + const pattern = tags.join('|') + return content.replace(new RegExp(`<(${pattern})>[\\s\\S]*?<\\/\\1>`, 'gi'), '').trim() +} + +function stripToolCallTags(content: string): string { + return content.replace(/[\s\S]*?<\/tool_call>/gi, '').trim() +} + +export async function runServerAgent(options: RunAgentOptions): Promise { + const { + userMessage, + conversationId, + chatType = 'group', + locale = 'zh-CN', + assistantSystemPrompt, + tools = [], + aiDataDir, + convManager, + onEvent, + abortSignal, + } = options + + const llmConfig = getDefaultAssistantConfig(aiDataDir) + if (!llmConfig) { + onEvent({ type: 'error', error: { name: 'ConfigError', message: 'LLM service not configured' } }) + onEvent({ type: 'done', isFinished: true }) + return + } + + const piModel = buildPiModel(llmConfig) + const maxToolRounds = 5 + + const systemPrompt = buildSystemPrompt(chatType, assistantSystemPrompt, locale) + const totalUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 } + const toolsUsed: string[] = [] + let toolRounds = 0 + + const addPiUsage = (usage?: PiUsage) => { + if (!usage) return + totalUsage.promptTokens += usage.input || 0 + totalUsage.completionTokens += usage.output || 0 + totalUsage.totalTokens += usage.totalTokens || usage.input + usage.output || 0 + } + + const isAborted = () => abortSignal?.aborted ?? false + + if (isAborted()) { + onEvent({ type: 'done', isFinished: true, usage: totalUsage }) + return + } + + const coreAgent = new PiAgentCore({ + initialState: { + model: piModel, + thinkingLevel: piModel.reasoning ? 'medium' : 'off', + }, + getApiKey: () => llmConfig.apiKey, + streamFn: streamSimple, + convertToLlm: (messages) => + messages.filter( + (msg): msg is PiMessage => msg.role === 'user' || msg.role === 'assistant' || msg.role === 'toolResult' + ), + }) + + coreAgent.setSystemPrompt(systemPrompt) + coreAgent.setTools(maxToolRounds > 0 ? tools : []) + + let history: SimpleHistoryMessage[] = [] + try { + history = convManager.getHistoryForAgent(conversationId) + } catch { + // empty history on failure + } + coreAgent.replaceMessages(toPiHistoryMessages(history)) + + onEvent({ + type: 'status', + status: { phase: 'preparing', round: 0, toolsUsed: 0 }, + }) + + // Subscribe to events + let hasReachedToolRoundLimit = false + const thinkingStartTime = new Map() + + const unsubscribe = coreAgent.subscribe((event: PiAgentEvent) => { + if (event.type === 'message_update') { + const update = event.assistantMessageEvent + if (update.type === 'text_delta') { + onEvent({ type: 'content', content: update.delta }) + } else if (update.type === 'thinking_start') { + thinkingStartTime.set(update.contentIndex, Date.now()) + onEvent({ type: 'status', status: { phase: 'thinking', round: toolRounds, toolsUsed: toolsUsed.length } }) + } else if (update.type === 'thinking_delta') { + onEvent({ type: 'think', content: update.delta, thinkTag: 'thinking' }) + } else if (update.type === 'thinking_end') { + const startedAt = thinkingStartTime.get(update.contentIndex) + const durationMs = startedAt ? Date.now() - startedAt : undefined + onEvent({ type: 'think', content: '', thinkTag: 'thinking', thinkDurationMs: durationMs }) + thinkingStartTime.delete(update.contentIndex) + } + } else if (event.type === 'tool_execution_start') { + toolsUsed.push(event.toolName) + onEvent({ + type: 'tool_start', + toolName: event.toolName, + toolParams: (event.args || {}) as Record, + }) + onEvent({ + type: 'status', + status: { phase: 'tool_running', round: toolRounds, toolsUsed: toolsUsed.length, currentTool: event.toolName }, + }) + } else if (event.type === 'tool_execution_end') { + onEvent({ type: 'tool_result', toolName: event.toolName, toolResult: event.result }) + } else if (event.type === 'turn_end') { + if (event.toolResults.length > 0) { + toolRounds += 1 + if (!hasReachedToolRoundLimit && maxToolRounds > 0 && toolRounds >= maxToolRounds) { + hasReachedToolRoundLimit = true + coreAgent.setTools([]) + coreAgent.steer({ + role: 'user', + content: [{ type: 'text', text: 'Please provide your final answer based on the information gathered.' }], + timestamp: Date.now(), + } as PiMessage) + } + } + } else if (event.type === 'message_end') { + if (event.message.role === 'assistant') { + addPiUsage(event.message.usage) + } + } + }) + + const forwardAbort = () => coreAgent.abort() + if (abortSignal) { + abortSignal.addEventListener('abort', forwardAbort, { once: true }) + } + + try { + await coreAgent.prompt(userMessage) + + if (coreAgent.state.error) { + onEvent({ + type: 'error', + error: { name: 'AgentError', message: coreAgent.state.error }, + }) + } + + onEvent({ type: 'done', isFinished: true, usage: totalUsage }) + } catch (error) { + const msg = error instanceof Error ? error.message : String(error) + onEvent({ type: 'error', error: { name: 'AgentError', message: msg } }) + onEvent({ type: 'done', isFinished: true, usage: totalUsage }) + } finally { + unsubscribe() + if (abortSignal) { + abortSignal.removeEventListener('abort', forwardAbort) + } + } +} diff --git a/packages/server/src/ai/assistant-loader.ts b/packages/server/src/ai/assistant-loader.ts new file mode 100644 index 00000000..76815f74 --- /dev/null +++ b/packages/server/src/ai/assistant-loader.ts @@ -0,0 +1,22 @@ +/** + * 助手配置加载器 + * + * 从 ~/.chatlab/ai/assistants/*.md 加载助手系统提示词。 + */ + +import * as fs from 'fs' +import * as path from 'path' +import { parseAssistantFile } from '@openchatlab/node-runtime' +import type { AssistantConfig } from '@openchatlab/node-runtime' + +export function loadAssistantConfig(aiDataDir: string, assistantId: string): AssistantConfig | null { + const filePath = path.join(aiDataDir, 'assistants', `${assistantId}.md`) + if (!fs.existsSync(filePath)) return null + + try { + const content = fs.readFileSync(filePath, 'utf-8') + return parseAssistantFile(content, filePath) + } catch { + return null + } +} diff --git a/packages/server/src/ai/llm-config.ts b/packages/server/src/ai/llm-config.ts new file mode 100644 index 00000000..f409bb6b --- /dev/null +++ b/packages/server/src/ai/llm-config.ts @@ -0,0 +1,183 @@ +/** + * 服务端 LLM 配置加载 + * + * API Key 从 ~/.chatlab/auth-profiles.json 读取, + * 匹配顺序:config.authProfile 精确匹配 > config.provider 兜底匹配。 + * + * 模型/Provider 等配置仍从 llm-config.json 读取。 + */ + +import * as fs from 'fs' +import * as path from 'path' +import { type Model as PiModel, type Api as PiApi } from '@mariozechner/pi-ai' +import { BUILTIN_PROVIDERS, getBuiltinModelsByProvider, BUILTIN_MODELS } from '@openchatlab/core' +import type { ModelDefinition } from '@openchatlab/core' +import { resolveApiKey } from '@openchatlab/config' + +// ==================== Types ==================== + +export interface AIServiceConfig { + id: string + name: string + provider: string + apiKey: string + model?: string + baseUrl?: string + maxTokens?: number + apiFormat?: string + disableThinking?: boolean + isReasoningModel?: boolean + authProfile?: string +} + +interface ModelSlot { + configId: string + modelId?: string +} + +interface AIConfigStore { + configs: AIServiceConfig[] + defaultAssistant: ModelSlot | null + fastModel: ModelSlot | null +} + +// ==================== Config Loading ==================== + +export function loadLlmConfig(aiDataDir: string): AIConfigStore { + const configPath = path.join(aiDataDir, 'llm-config.json') + if (!fs.existsSync(configPath)) { + return { configs: [], defaultAssistant: null, fastModel: null } + } + + try { + const data = JSON.parse(fs.readFileSync(configPath, 'utf-8')) + + const configs = (data.configs || []).map((c: Record) => { + const provider = (c.provider as string) || '' + const authProfile = c.authProfile as string | undefined + const profileKey = resolveApiKey(provider, authProfile) + const rawKey = (c.apiKey as string) || '' + const fallbackKey = rawKey.startsWith('enc:') ? '' : rawKey + return { + ...c, + apiKey: profileKey || fallbackKey, + } + }) + return { + configs, + defaultAssistant: data.defaultAssistant ?? null, + fastModel: data.fastModel ?? null, + } + } catch { + return { configs: [], defaultAssistant: null, fastModel: null } + } +} + +function resolveSlot(slot: ModelSlot | null | undefined, configs: AIServiceConfig[]): ModelSlot | null { + if (slot && configs.some((c) => c.id === slot.configId)) return slot + const fallback = configs[0] + if (!fallback) return null + return { configId: fallback.id, modelId: fallback.model } +} + +export function getDefaultAssistantConfig(aiDataDir: string): AIServiceConfig | null { + const store = loadLlmConfig(aiDataDir) + const slot = resolveSlot(store.defaultAssistant, store.configs) + if (!slot) return null + const config = store.configs.find((c) => c.id === slot.configId) + if (!config) return null + return { ...config, model: slot.modelId || config.model } +} + +// ==================== PiModel Builder ==================== + +const DEFAULT_CONTEXT_WINDOW = 128000 + +function findModelDefinition(providerId: string, modelId: string): ModelDefinition | null { + const builtinForProvider = getBuiltinModelsByProvider(providerId) + return builtinForProvider.find((m) => m.id === modelId) || BUILTIN_MODELS.find((m) => m.id === modelId) || null +} + +function normalizeAnthropicBaseUrl(url: string): string { + return url.replace(/\/v1\/?$/, '') +} + +function normalizeOpenAICompatibleBaseUrl(url: string): string { + if (!url) return url + const trimmed = url.replace(/\/+$/, '') + if (trimmed.endsWith('/v1')) return trimmed + try { + const parsed = new URL(trimmed) + if (parsed.pathname === '/' || parsed.pathname === '') { + return trimmed + '/v1' + } + } catch { + // ignore + } + return trimmed +} + +export function buildPiModel(config: AIServiceConfig): PiModel { + const providerDef = BUILTIN_PROVIDERS.find((p) => p.id === config.provider) + const baseUrl = config.baseUrl || providerDef?.defaultBaseUrl || '' + const modelId = config.model || '' + + const modelDef = findModelDefinition(config.provider, modelId) + const contextWindow = modelDef?.contextWindow ?? DEFAULT_CONTEXT_WINDOW + + const BUILTIN_PROVIDER_API: Record = { + gemini: 'google-generative-ai', + anthropic: 'anthropic-messages', + } + + const apiFormat: PiApi = (config.apiFormat as PiApi) || BUILTIN_PROVIDER_API[config.provider] || 'openai-completions' + + if (apiFormat === 'google-generative-ai') { + return { + id: modelId, + name: modelId, + api: 'google-generative-ai', + provider: 'google', + baseUrl, + reasoning: false, + input: ['text'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow, + maxTokens: config.maxTokens ?? 8192, + } + } + + if (apiFormat === 'anthropic-messages') { + return { + id: modelId, + name: modelId, + api: 'anthropic-messages', + provider: 'anthropic', + baseUrl: normalizeAnthropicBaseUrl(baseUrl), + reasoning: false, + input: ['text'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow, + maxTokens: config.maxTokens ?? 8192, + } + } + + const resolvedBaseUrl = + config.provider === 'openai-compatible' && (apiFormat === 'openai-completions' || apiFormat === 'openai-responses') + ? normalizeOpenAICompatibleBaseUrl(baseUrl) + : baseUrl + + return { + id: modelId, + name: modelId, + api: apiFormat, + provider: config.provider, + baseUrl: resolvedBaseUrl, + reasoning: config.isReasoningModel ?? false, + input: ['text'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow, + maxTokens: config.maxTokens ?? 4096, + compat: config.disableThinking ? { thinkingFormat: 'qwen' } : undefined, + } +} diff --git a/packages/server/src/ai/tool-adapter.ts b/packages/server/src/ai/tool-adapter.ts new file mode 100644 index 00000000..74cb7338 --- /dev/null +++ b/packages/server/src/ai/tool-adapter.ts @@ -0,0 +1,49 @@ +/** + * 工具适配层 + * + * 将 @openchatlab/tools 的 ToolDefinition 适配为 @mariozechner/pi-agent-core 的 AgentTool 格式。 + */ + +import type { AgentTool } from '@mariozechner/pi-agent-core' +import type { ToolDefinition, ToolExecutionContext } from '@openchatlab/tools' +import type { DatabaseAdapter } from '@openchatlab/core' + +export interface ServerToolContext { + db: DatabaseAdapter + sessionId: string + locale?: string +} + +function convertJsonSchemaToParameters(schema: ToolDefinition['inputSchema']): AgentTool['parameters'] { + const properties: Record = {} + for (const [key, prop] of Object.entries(schema.properties)) { + properties[key] = { ...prop } + } + return { + type: 'object', + properties, + required: schema.required || [], + } +} + +export function adaptToolsForAgent(tools: ToolDefinition[], getContext: () => ServerToolContext): AgentTool[] { + return tools.map((tool) => ({ + name: tool.name, + description: tool.description, + parameters: convertJsonSchemaToParameters(tool.inputSchema), + async execute(params: Record): Promise { + const ctx = getContext() + const execCtx: ToolExecutionContext = { + db: ctx.db, + sessionId: ctx.sessionId, + locale: ctx.locale, + } + try { + const result = tool.handler(params, execCtx) + return result.content + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}` + } + }, + })) +} diff --git a/packages/server/src/http/index.ts b/packages/server/src/http/index.ts index 4d302c3d..0a105b9c 100644 --- a/packages/server/src/http/index.ts +++ b/packages/server/src/http/index.ts @@ -11,7 +11,7 @@ import * as crypto from 'crypto' import type { FastifyInstance } from 'fastify' import { loadConfig, getConfigDir } from '@openchatlab/config' import type { ChatLabConfig } from '@openchatlab/config' -import { NodePathProvider, DatabaseManager } from '@openchatlab/node-runtime' +import { NodePathProvider, DatabaseManager, AIConversationManager } from '@openchatlab/node-runtime' import { createServer } from './server' import { setAuthToken } from './auth' import { registerSystemRoutes } from './routes/system' @@ -22,6 +22,7 @@ import { registerAiRoutes } from './routes/ai' let server: FastifyInstance | null = null let dbManager: DatabaseManager | null = null +let convManager: AIConversationManager | null = null export interface HttpServerOptions { port?: number @@ -84,6 +85,7 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{ pathProvider.ensureAllDirs() const nativeBinding = resolveNativeBinding() dbManager = new DatabaseManager(pathProvider, { nativeBinding }) + convManager = new AIConversationManager(pathProvider.getAiDataDir(), { nativeBinding }) setAuthToken(token) @@ -97,7 +99,7 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{ registerSystemRoutes(server, dbManager) registerSessionRoutes(server, dbManager) registerNlpRoutes(server, dbManager) - registerAiRoutes(server, dbManager) + registerAiRoutes(server, dbManager, convManager) registerWebRoutes(server, dbManager) // 托管 Web SPA 静态资源 @@ -128,6 +130,10 @@ export async function stopHttpServer(): Promise { try { await server.close() } finally { + if (convManager) { + convManager.close() + convManager = null + } if (dbManager) { dbManager.closeAll() dbManager = null diff --git a/packages/server/src/http/routes/ai.ts b/packages/server/src/http/routes/ai.ts index 4aad884e..47488748 100644 --- a/packages/server/src/http/routes/ai.ts +++ b/packages/server/src/http/routes/ai.ts @@ -12,10 +12,14 @@ import * as fs from 'fs' import * as path from 'path' import type { FastifyInstance } from 'fastify' -import type { DatabaseManager } from '@openchatlab/node-runtime' +import type { DatabaseManager, AIConversationManager } from '@openchatlab/node-runtime' import { parseAssistantFile, parseSkillFile } from '@openchatlab/node-runtime' import { BUILTIN_TOOL_CATALOG, BUILTIN_PROVIDERS, BUILTIN_MODELS, getBuiltinModelsByProvider } from '@openchatlab/core' import type { AssistantSummary, SkillSummary } from '@openchatlab/node-runtime' +import { TOOL_REGISTRY } from '@openchatlab/tools' +import { adaptToolsForAgent } from '../../ai/tool-adapter' +import { loadAssistantConfig } from '../../ai/assistant-loader' +import { runServerAgent, type AgentStreamEvent } from '../../ai/agent' function getAiDir(dbManager: DatabaseManager): string { const pathProvider = (dbManager as any)['pathProvider'] @@ -33,7 +37,11 @@ function scanMdFiles(dir: string): string[] { .map((f) => path.join(dir, f)) } -export function registerAiRoutes(server: FastifyInstance, dbManager: DatabaseManager): void { +export function registerAiRoutes( + server: FastifyInstance, + dbManager: DatabaseManager, + convManager?: AIConversationManager +): void { // ==================== Assistants ==================== server.get('/_web/ai/assistants', async () => { @@ -230,4 +238,175 @@ export function registerAiRoutes(server: FastifyInstance, dbManager: DatabaseMan server.get('/_web/ai/tools/catalog', async () => { return BUILTIN_TOOL_CATALOG }) + + // ==================== AI Conversations CRUD ==================== + + if (!convManager) return + + server.post<{ + Body: { sessionId: string; title?: string; assistantId: string } + }>('/_web/ai/conversations', async (request) => { + const { sessionId, title, assistantId } = request.body + return convManager.createConversation(sessionId, title, assistantId) + }) + + server.get<{ + Querystring: { sessionId: string } + }>('/_web/ai/conversations', async (request) => { + const { sessionId } = request.query + if (!sessionId) return [] + return convManager.getConversations(sessionId) + }) + + server.get<{ Params: { id: string } }>('/_web/ai/conversations/:id', async (request, reply) => { + const conv = convManager.getConversation(request.params.id) + if (!conv) return reply.code(404).send({ error: 'Conversation not found' }) + return conv + }) + + server.put<{ + Params: { id: string } + Body: { title: string } + }>('/_web/ai/conversations/:id/title', async (request) => { + return convManager.updateConversationTitle(request.params.id, request.body.title) + }) + + server.delete<{ Params: { id: string } }>('/_web/ai/conversations/:id', async (request) => { + return convManager.deleteConversation(request.params.id) + }) + + // ==================== AI Messages CRUD ==================== + + server.post<{ + Params: { id: string } + Body: { + role: 'user' | 'assistant' | 'summary' + content: string + dataKeywords?: string[] + dataMessageCount?: number + contentBlocks?: unknown[] + tokenUsage?: { promptTokens: number; completionTokens: number; totalTokens: number } + } + }>('/_web/ai/conversations/:id/messages', async (request) => { + const { role, content, dataKeywords, dataMessageCount, contentBlocks, tokenUsage } = request.body + return convManager.addMessage( + request.params.id, + role, + content, + dataKeywords, + dataMessageCount, + contentBlocks as any, + tokenUsage + ) + }) + + server.get<{ Params: { id: string } }>('/_web/ai/conversations/:id/messages', async (request) => { + return convManager.getMessages(request.params.id) + }) + + server.get<{ Params: { id: string } }>('/_web/ai/conversations/:id/token-usage', async (request) => { + return convManager.getConversationTokenUsage(request.params.id) + }) + + // ==================== Agent SSE Stream ==================== + + const activeAgentAborts = new Map() + + server.post<{ + Body: { + userMessage: string + conversationId: string + sessionId: string + chatType?: 'group' | 'private' + locale?: string + assistantId?: string + } + }>('/_web/ai/agent/stream', async (request, reply) => { + const { userMessage, conversationId, sessionId, chatType, locale, assistantId } = request.body + + const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` + const abortController = new AbortController() + activeAgentAborts.set(requestId, abortController) + + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Request-Id': requestId, + }) + + const sendSSE = (event: string, data: unknown) => { + reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`) + } + + sendSSE('meta', { requestId }) + + const aiDataDir = getAiDir(dbManager) + + let assistantSystemPrompt: string | undefined + if (assistantId) { + const assistantConfig = loadAssistantConfig(aiDataDir, assistantId) + if (assistantConfig?.systemPrompt) { + assistantSystemPrompt = assistantConfig.systemPrompt + } + } + + const db = (dbManager as any).open?.(sessionId) + const agentTools = db + ? adaptToolsForAgent(TOOL_REGISTRY, () => ({ + db, + sessionId, + locale, + })) + : [] + + const onEvent = (event: AgentStreamEvent) => { + sendSSE(event.type, event) + if (event.type === 'done') { + activeAgentAborts.delete(requestId) + reply.raw.end() + } + } + + reply.raw.on('close', () => { + if (!abortController.signal.aborted) { + abortController.abort() + } + activeAgentAborts.delete(requestId) + }) + + try { + await runServerAgent({ + userMessage, + conversationId, + chatType, + locale, + assistantSystemPrompt, + tools: agentTools, + aiDataDir, + convManager, + onEvent, + abortSignal: abortController.signal, + }) + } catch (error) { + const msg = error instanceof Error ? error.message : String(error) + sendSSE('error', { type: 'error', error: { name: 'ServerError', message: msg } }) + sendSSE('done', { type: 'done', isFinished: true }) + activeAgentAborts.delete(requestId) + reply.raw.end() + } + }) + + server.post<{ + Body: { requestId: string } + }>('/_web/ai/agent/abort', async (request) => { + const { requestId } = request.body + const controller = activeAgentAborts.get(requestId) + if (controller) { + controller.abort() + activeAgentAborts.delete(requestId) + return { success: true } + } + return { success: false } + }) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 84945f1e..a4bf498d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -158,6 +158,9 @@ importers: '@openchatlab/core': specifier: workspace:* version: link:../core + gray-matter: + specifier: ^4.0.3 + version: 4.0.3 packages/parser: dependencies: @@ -173,6 +176,12 @@ importers: '@fastify/static': specifier: ^9.1.3 version: 9.1.3 + '@mariozechner/pi-agent-core': + specifier: 0.54.2 + version: 0.54.2(@modelcontextprotocol/sdk@1.29.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) + '@mariozechner/pi-ai': + specifier: 0.54.2 + version: 0.54.2(@modelcontextprotocol/sdk@1.29.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) '@modelcontextprotocol/sdk': specifier: ^1.12.1 version: 1.29.0(zod@3.25.76) @@ -3552,10 +3561,6 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - ip-address@10.1.0: - resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} - engines: {node: '>= 12'} - ip-address@10.2.0: resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} @@ -9723,8 +9728,6 @@ snapshots: ini@1.3.8: {} - ip-address@10.1.0: {} - ip-address@10.2.0: {} ipaddr.js@1.9.1: {} @@ -11014,7 +11017,7 @@ snapshots: socks@2.8.7: dependencies: - ip-address: 10.1.0 + ip-address: 10.2.0 smart-buffer: 4.2.0 sonic-boom@4.2.1: diff --git a/src/services/ai/fetch.ts b/src/services/ai/fetch.ts new file mode 100644 index 00000000..1742e774 --- /dev/null +++ b/src/services/ai/fetch.ts @@ -0,0 +1,173 @@ +/** + * CLI serve Web 模式下的 AIAdapter 实现 + * + * 通过 HTTP 调用 chatlab serve 后端的 /_web/ai/* 端点。 + * 不支持 Web 模式的功能(文件导出等)返回安全的降级响应。 + */ + +import type { + AIAdapter, + AIConversation, + AIMessage, + AIMessageRole, + ContentBlock, + TokenUsageData, + FilterResultWithPagination, + ExportFilterParams, + ExportProgress, + AiSQLResult, + AiSchemaTable, + ToolCatalogEntry, + ToolExecuteResult, + DesensitizeRule, +} from './types' +import type { TimeFilter } from '@/types/base' +import { get, post, put, del } from '../utils/http' + +const NOT_AVAILABLE_WEB = 'This feature is not available in web mode' + +export class FetchAIAdapter implements AIAdapter { + // ===== 对话管理 ===== + async getConversation(conversationId: string): Promise { + try { + return await get(`/ai/conversations/${conversationId}`) + } catch { + return null + } + } + + async getConversations(sessionId: string): Promise { + return get(`/ai/conversations?sessionId=${encodeURIComponent(sessionId)}`) + } + + async createConversation(sessionId: string, title: string | undefined, assistantId: string): Promise { + return post('/ai/conversations', { sessionId, title, assistantId }) + } + + async updateConversationTitle(conversationId: string, title: string): Promise { + return put(`/ai/conversations/${conversationId}/title`, { title }) + } + + async deleteConversation(conversationId: string): Promise { + return del(`/ai/conversations/${conversationId}`) + } + + // ===== 消息 ===== + async getMessages(conversationId: string): Promise { + return get(`/ai/conversations/${conversationId}/messages`) + } + + async addMessage( + conversationId: string, + role: AIMessageRole, + content: string, + dataKeywords?: string[], + dataMessageCount?: number, + contentBlocks?: ContentBlock[], + tokenUsage?: TokenUsageData + ): Promise { + return post(`/ai/conversations/${conversationId}/messages`, { + role, + content, + dataKeywords, + dataMessageCount, + contentBlocks, + tokenUsage, + }) + } + + async getConversationTokenUsage(conversationId: string): Promise { + return get(`/ai/conversations/${conversationId}/token-usage`) + } + + async estimateContextTokens( + _conversationId: string + ): Promise<{ success: boolean; tokens: number; messageCount?: number; error?: string }> { + return { success: false, tokens: 0, error: NOT_AVAILABLE_WEB } + } + + // ===== 消息筛选/导出 (降级) ===== + async filterMessagesWithContext( + _sessionId: string, + _keywords?: string[], + _timeFilter?: TimeFilter, + _senderIds?: number[], + _contextSize?: number, + _page?: number, + _pageSize?: number + ): Promise { + return { + blocks: [], + stats: { totalMessages: 0, hitMessages: 0, totalChars: 0 }, + pagination: { page: 1, pageSize: 50, totalBlocks: 0, totalHits: 0, hasMore: false }, + } + } + + async getMultipleSessionsMessages( + _sessionId: string, + _chatSessionIds: number[], + _page?: number, + _pageSize?: number + ): Promise { + return { + blocks: [], + stats: { totalMessages: 0, hitMessages: 0, totalChars: 0 }, + pagination: { page: 1, pageSize: 50, totalBlocks: 0, totalHits: 0, hasMore: false }, + } + } + + async exportFilterResultToFile( + _params: ExportFilterParams + ): Promise<{ success: boolean; filePath?: string; error?: string }> { + return { success: false, error: NOT_AVAILABLE_WEB } + } + + onExportProgress(_callback: (progress: ExportProgress) => void): () => void { + return () => {} + } + + // ===== 调试 (降级) ===== + async executeAiSQL(_sql: string): Promise { + return { columns: [], rows: [], rowCount: 0, duration: 0, limited: false } + } + + async getAiSchema(): Promise { + return [] + } + + async clearDebugContext(): Promise<{ success: boolean; cleared: number }> { + return { success: false, cleared: 0 } + } + + // ===== 工具 ===== + async getToolCatalog(): Promise { + return get('/ai/tools/catalog') + } + + async executeTool( + _testId: string, + _toolName: string, + _params: Record, + _sessionId: string + ): Promise { + return { success: false, error: NOT_AVAILABLE_WEB } + } + + async cancelToolTest(_testId: string): Promise<{ success: boolean }> { + return { success: false } + } + + // ===== 脱敏 ===== + async getDefaultDesensitizeRules(_locale: string): Promise { + return [] + } + + async mergeDesensitizeRules(existingRules: DesensitizeRule[], _locale: string): Promise { + return existingRules + } + + // ===== 日志 ===== + async showAiLogFile(): Promise<{ success: boolean; path?: string; error?: string }> { + return { success: false, error: NOT_AVAILABLE_WEB } + } +} diff --git a/src/services/registry.ts b/src/services/registry.ts index a53d63c3..741ebb0b 100644 --- a/src/services/registry.ts +++ b/src/services/registry.ts @@ -95,8 +95,8 @@ async function initWebServeAdapters(): Promise { const { WebPlatformAdapter } = await import('./platform/web') registerAdapter('platform', new WebPlatformAdapter()) - const { WebAIAdapter } = await import('./ai/web') - registerAdapter('ai', new WebAIAdapter()) + const { FetchAIAdapter } = await import('./ai/fetch') + registerAdapter('ai', new FetchAIAdapter()) await installChartPluginShims() await installNlpApiShim() @@ -199,13 +199,135 @@ async function installAiApiShims(): Promise { updateCustomModel: () => Promise.resolve(WEB_STUB), deleteCustomModel: () => Promise.resolve(WEB_STUB), } - ;(window as any).agentApi = { - runStream: () => ({ - requestId: '', - promise: Promise.resolve({ success: false, error: 'Not available in web mode' }), - }), - abort: () => Promise.resolve(), + const { fetchSSE } = await import('./utils/sse') + const { post: httpPost } = await import('./utils/http') + + const agentApiImpl = { + runStream: ( + userMessage: string, + context: Record, + onChunk?: (chunk: Record) => void, + chatType?: string, + locale?: string, + assistantId?: string, + _skillId?: string | null, + _enableAutoSkill?: boolean, + _compressionConfig?: Record + ): { + requestId: string + promise: Promise<{ success: boolean; result?: Record; error?: Record }> + } => { + const localRequestId = `agent_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` + let serverRequestId = localRequestId + const abortController = new AbortController() + + const promise = new Promise<{ + success: boolean + result?: Record + error?: Record + }>((resolve) => { + let content = '' + const toolsUsed: string[] = [] + let toolRounds = 0 + let lastUsage: Record | undefined + let hasError = false + let streamError: Record | undefined + + fetchSSE({ + url: '/_web/ai/agent/stream', + method: 'POST', + body: { + userMessage, + conversationId: context.conversationId, + sessionId: context.sessionId, + chatType: chatType || 'group', + locale: locale || 'zh-CN', + assistantId, + }, + signal: abortController.signal, + onEvent: ({ event, data }) => { + try { + const parsed = JSON.parse(data) + + if (event === 'meta') { + serverRequestId = parsed.requestId || localRequestId + return + } + + if (onChunk) onChunk(parsed) + + switch (event) { + case 'content': + if (parsed.content) content += parsed.content + break + case 'tool_start': + if (parsed.toolName) toolsUsed.push(parsed.toolName) + break + case 'tool_result': + break + case 'status': + if (parsed.status?.round !== undefined) toolRounds = parsed.status.round + break + case 'error': + hasError = true + streamError = parsed.error + break + case 'done': + if (parsed.usage) lastUsage = parsed.usage + break + } + } catch { + // skip malformed + } + }, + }) + .then(() => { + if (hasError) { + resolve({ success: false, error: streamError }) + } else { + resolve({ + success: true, + result: { content, toolsUsed, toolRounds, totalUsage: lastUsage }, + }) + } + }) + .catch((err) => { + if (abortController.signal.aborted) { + resolve({ success: false, error: { name: 'AbortError', message: 'Aborted' } }) + } else { + resolve({ success: false, error: { name: 'NetworkError', message: err?.message || String(err) } }) + } + }) + }) + + // Store abort controller for external abort + ;(agentApiImpl as any)._abortControllers = (agentApiImpl as any)._abortControllers || new Map() + ;(agentApiImpl as any)._abortControllers.set(localRequestId, { + abortController, + serverRequestId: () => serverRequestId, + }) + + return { requestId: localRequestId, promise } + }, + + abort: async (requestId: string) => { + const controllers = (agentApiImpl as any)._abortControllers as + | Map string }> + | undefined + const entry = controllers?.get(requestId) + if (entry) { + entry.abortController.abort() + controllers!.delete(requestId) + try { + await httpPost('/ai/agent/abort', { requestId: entry.serverRequestId() }) + } catch { + // best-effort + } + } + }, } + + ;(window as any).agentApi = agentApiImpl } async function initWebBrowserAdapters(): Promise { diff --git a/src/services/utils/http.ts b/src/services/utils/http.ts index 47222b31..b4b709da 100644 --- a/src/services/utils/http.ts +++ b/src/services/utils/http.ts @@ -37,6 +37,19 @@ export async function del(path: string): Promise { return resp.json() as Promise } +export async function put(path: string, body?: unknown): Promise { + const resp = await fetch(`${BASE}${path}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: body != null ? JSON.stringify(body) : undefined, + }) + if (!resp.ok) { + const text = await resp.text().catch(() => '') + throw new Error(`HTTP ${resp.status}: ${text}`) + } + return resp.json() as Promise +} + export async function patch(path: string, body?: unknown): Promise { const resp = await fetch(`${BASE}${path}`, { method: 'PATCH',