mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
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:
1 parent
12f9ae90eb
commit
b479fd3b2d
22 files changed
+1987
-775
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置缓存(仅测试用)
|
||||
*/
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)}`
|
||||
}
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
Generated
+10
-7
@@ -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:
|
||||
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user