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
This commit is contained in:
digua committed 2026-05-14 20:55:54 +08:00
1 parent 12f9ae90eb
commit b479fd3b2d
22 files changed
+1987 -775

No files matched your search

+80 -707
View File
@@ -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<string, string>()
/**
* 获取 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<string, any>[]
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<string, unknown>
}
}
| {
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<string, number> {
const result = new Map<string, number>()
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<ContentBlock, { type: 'summary_meta' }> => 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<ContentBlock, { type: 'summary_meta' }> => 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)
}
+12 -11
View File
@@ -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')
+53 -13
View File
@@ -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<string>()
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
}
/**
* 重置缓存(仅测试用)
*/
+56 -20
View File
@@ -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<string, unknown>).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<AIServiceConfig, 'id' | 'createdAt' | 'up
store.defaultAssistant = { configId: newConfig.id, modelId: newConfig.model || '' }
}
if (newConfig.apiKey) {
const profileName = newConfig.name?.toLowerCase().replace(/\s+/g, '-') || newConfig.provider
writeAuthProfile(profileName, {
type: 'api_key',
provider: newConfig.provider,
key: newConfig.apiKey,
})
}
saveConfigStore(store)
return { success: true, config: newConfig }
}
@@ -414,11 +440,21 @@ export function updateConfig(
return { success: false, error: t('llm.configNotFound') }
}
store.configs[index] = {
const updated = {
...store.configs[index],
...updates,
updatedAt: Date.now(),
}
store.configs[index] = updated
if (updates.apiKey) {
const profileName = updated.name?.toLowerCase().replace(/\s+/g, '-') || updated.provider
writeAuthProfile(profileName, {
type: 'api_key',
provider: updated.provider,
key: updates.apiKey,
})
}
saveConfigStore(store)
return { success: true }
+110
View File
@@ -0,0 +1,110 @@
/**
* auth-profiles.json 凭证管理
*
* 存储位置:~/.chatlab/auth-profiles.json
* 用途:将 API Key 等敏感凭证与主配置文件分离
*/
import * as fs from 'fs'
import * as path from 'path'
import { getConfigDir } from './loader'
export interface AuthProfile {
type: 'api_key'
provider: string
key: string
}
export interface AuthProfilesData {
version: number
profiles: Record<string, AuthProfile>
}
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
}
+9
View File
@@ -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'
-2
View File
@@ -77,7 +77,6 @@ function loadConfigFile(): Record<string, unknown> {
* - 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<string, unknown> {
{ 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' },
-1
View File
@@ -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(''),
})
@@ -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<string, unknown>
}
}
| {
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<string, unknown>): 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<string, string>()
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<string, unknown>[]
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<string, number> {
const result = new Map<string, number>()
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<ContentBlock, { type: 'summary_meta' }> => 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<ContentBlock, { type: 'summary_meta' }> => 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
}
}
+10 -1
View File
@@ -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'
+10 -1
View File
@@ -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'
+2
View File
@@ -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:*",
+281
View File
@@ -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<string, unknown>
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(/<tool_call>[\s\S]*?<\/tool_call>/gi, '').trim()
}
export async function runServerAgent(options: RunAgentOptions): Promise<void> {
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<number, number>()
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<string, unknown>,
})
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)
}
}
}
@@ -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
}
}
+183
View File
@@ -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<string, unknown>) => {
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<PiApi> {
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<string, PiApi> = {
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,
}
}
+49
View File
@@ -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<string, unknown> = {}
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<string, unknown>): Promise<string> {
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)}`
}
},
}))
}
+8 -2
View File
@@ -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<void> {
try {
await server.close()
} finally {
if (convManager) {
convManager.close()
convManager = null
}
if (dbManager) {
dbManager.closeAll()
dbManager = null
+181 -2
View File
@@ -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<string, AbortController>()
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 }
})
}
+10 -7
View File
@@ -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:
+173
View File
@@ -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<AIConversation | null> {
try {
return await get<AIConversation>(`/ai/conversations/${conversationId}`)
} catch {
return null
}
}
async getConversations(sessionId: string): Promise<AIConversation[]> {
return get<AIConversation[]>(`/ai/conversations?sessionId=${encodeURIComponent(sessionId)}`)
}
async createConversation(sessionId: string, title: string | undefined, assistantId: string): Promise<AIConversation> {
return post<AIConversation>('/ai/conversations', { sessionId, title, assistantId })
}
async updateConversationTitle(conversationId: string, title: string): Promise<boolean> {
return put<boolean>(`/ai/conversations/${conversationId}/title`, { title })
}
async deleteConversation(conversationId: string): Promise<boolean> {
return del<boolean>(`/ai/conversations/${conversationId}`)
}
// ===== 消息 =====
async getMessages(conversationId: string): Promise<AIMessage[]> {
return get<AIMessage[]>(`/ai/conversations/${conversationId}/messages`)
}
async addMessage(
conversationId: string,
role: AIMessageRole,
content: string,
dataKeywords?: string[],
dataMessageCount?: number,
contentBlocks?: ContentBlock[],
tokenUsage?: TokenUsageData
): Promise<AIMessage> {
return post<AIMessage>(`/ai/conversations/${conversationId}/messages`, {
role,
content,
dataKeywords,
dataMessageCount,
contentBlocks,
tokenUsage,
})
}
async getConversationTokenUsage(conversationId: string): Promise<TokenUsageData> {
return get<TokenUsageData>(`/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<FilterResultWithPagination> {
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<FilterResultWithPagination> {
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<AiSQLResult> {
return { columns: [], rows: [], rowCount: 0, duration: 0, limited: false }
}
async getAiSchema(): Promise<AiSchemaTable[]> {
return []
}
async clearDebugContext(): Promise<{ success: boolean; cleared: number }> {
return { success: false, cleared: 0 }
}
// ===== 工具 =====
async getToolCatalog(): Promise<ToolCatalogEntry[]> {
return get<ToolCatalogEntry[]>('/ai/tools/catalog')
}
async executeTool(
_testId: string,
_toolName: string,
_params: Record<string, unknown>,
_sessionId: string
): Promise<ToolExecuteResult> {
return { success: false, error: NOT_AVAILABLE_WEB }
}
async cancelToolTest(_testId: string): Promise<{ success: boolean }> {
return { success: false }
}
// ===== 脱敏 =====
async getDefaultDesensitizeRules(_locale: string): Promise<DesensitizeRule[]> {
return []
}
async mergeDesensitizeRules(existingRules: DesensitizeRule[], _locale: string): Promise<DesensitizeRule[]> {
return existingRules
}
// ===== 日志 =====
async showAiLogFile(): Promise<{ success: boolean; path?: string; error?: string }> {
return { success: false, error: NOT_AVAILABLE_WEB }
}
}
+130 -8
View File
@@ -95,8 +95,8 @@ async function initWebServeAdapters(): Promise<void> {
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<void> {
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<string, unknown>,
onChunk?: (chunk: Record<string, unknown>) => void,
chatType?: string,
locale?: string,
assistantId?: string,
_skillId?: string | null,
_enableAutoSkill?: boolean,
_compressionConfig?: Record<string, unknown>
): {
requestId: string
promise: Promise<{ success: boolean; result?: Record<string, unknown>; error?: Record<string, unknown> }>
} => {
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<string, unknown>
error?: Record<string, unknown>
}>((resolve) => {
let content = ''
const toolsUsed: string[] = []
let toolRounds = 0
let lastUsage: Record<string, unknown> | undefined
let hasError = false
let streamError: Record<string, unknown> | 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, { abortController: AbortController; serverRequestId: () => 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<void> {
+13
View File
@@ -37,6 +37,19 @@ export async function del<T = boolean>(path: string): Promise<T> {
return resp.json() as Promise<T>
}
export async function put<T>(path: string, body?: unknown): Promise<T> {
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<T>
}
export async function patch<T>(path: string, body?: unknown): Promise<T> {
const resp = await fetch(`${BASE}${path}`, {
method: 'PATCH',