diff --git a/apps/desktop/main/ipc/ai.ts b/apps/desktop/main/ipc/ai.ts index f0df77a1..ce8fbb2e 100644 --- a/apps/desktop/main/ipc/ai.ts +++ b/apps/desktop/main/ipc/ai.ts @@ -1,38 +1,18 @@ /** * AI IPC handlers — Electron-only subset * - * Streaming (LLM chat-stream, Agent stream/abort) has been migrated to - * shared HTTP SSE routes. This file retains: - * - Debug mode, log file, desensitize rules - * - LLM non-streaming chat - * - Tool catalog / execute / cancel - * - Context token estimation + * Most AI functionality has been migrated to shared HTTP/SSE routes. + * This file retains only: debug mode toggle + native shell log file opening. */ import { ipcMain, shell } from 'electron' import * as fs from 'fs' import * as path from 'path' import { aiLogger, setDebugMode } from '../ai/logger' import { getLogsDir } from '../paths' -import { getDefaultAssistantConfig, buildPiModel } from '../ai/llm' -import { countMessagesTokens, completeSimple, type PiMessage, type PiTextContent } from '@openchatlab/node-runtime' -import { stripAvatarFields } from '@openchatlab/core' import * as assistantManager from '../ai/assistant' import * as skillManager from '../ai/skills' -import { getManager as getConversationManager } from '../ai/conversations' -import { t } from '../i18n' -import type { ToolContext } from '../ai/tools/types' -import { TOOL_REGISTRY } from '../ai/tools/definitions' -import { getDefaultRulesForLocale, mergeRulesForLocale } from '@openchatlab/node-runtime' import type { IpcContext } from './types' -function toPiSimpleMessages(messages: Array<{ role: string; content: string }>, timestamp: number): PiMessage[] { - return messages.map((message) => ({ - role: message.role as 'user' | 'assistant', - content: message.content, - timestamp, - })) as unknown as PiMessage[] -} - export function registerAIHandlers(_ctx: IpcContext): void { console.log('[IPC] Registering AI handlers...') @@ -94,160 +74,6 @@ export function registerAIHandlers(_ctx: IpcContext): void { } }) - // ==================== 脱敏规则 ==================== - - ipcMain.handle('ai:getDefaultDesensitizeRules', (_, locale: string) => { - return getDefaultRulesForLocale(locale) - }) - - ipcMain.handle('ai:mergeDesensitizeRules', (_, existingRules: unknown[], locale: string) => { - return mergeRulesForLocale(existingRules as any[], locale) - }) - - // ==================== LLM 直接调用 API(SQLLab 等非 Agent 场景) ==================== - - ipcMain.handle( - 'llm:chat', - async ( - _, - messages: Array<{ role: string; content: string }>, - options?: { temperature?: number; maxTokens?: number } - ) => { - try { - const activeConfig = getDefaultAssistantConfig() - if (!activeConfig) { - return { success: false, error: t('llm.notConfigured') } - } - const piModel = buildPiModel(activeConfig) - const now = Date.now() - const systemMsg = messages.find((m) => m.role === 'system') - const nonSystemMsgs = messages.filter((m) => m.role !== 'system') - - const result = await completeSimple( - piModel, - { - systemPrompt: systemMsg?.content, - messages: toPiSimpleMessages(nonSystemMsgs, now), - }, - { - apiKey: activeConfig.apiKey, - temperature: options?.temperature, - maxTokens: options?.maxTokens, - } - ) - - const content = result.content - .filter((item): item is PiTextContent => item.type === 'text') - .map((item) => item.text) - .join('') - - return { success: true, content } - } catch (error) { - aiLogger.error('IPC', 'llm:chat error', { error: String(error) }) - return { success: false, error: String(error) } - } - } - ) - - // ==================== 工具测试 API ==================== - - const activeToolTests = new Map() - - ipcMain.handle('ai:getToolCatalog', async () => { - try { - return TOOL_REGISTRY.map((entry) => { - const dummyContext: ToolContext = { sessionId: '__catalog__' } - const tool = entry.factory(dummyContext) - const descKey = `ai.tools.${entry.name}.desc` - const translated = t(descKey) - return { - name: entry.name, - category: entry.category, - description: translated !== descKey ? translated : (tool.description ?? ''), - parameters: tool.parameters ?? {}, - } - }) - } catch (error) { - console.error('Failed to get tool catalog:', error) - return [] - } - }) - - ipcMain.handle( - 'ai:executeTool', - async (_, testId: string, toolName: string, params: Record, sessionId: string) => { - const MAX_RESULT_CHARS = 500_000 - const abortController = new AbortController() - activeToolTests.set(testId, abortController) - - try { - const entry = TOOL_REGISTRY.find((e) => e.name === toolName) - if (!entry) { - return { success: false, error: `Tool not found: ${toolName}` } - } - - const context: ToolContext = { sessionId } - const tool = entry.factory(context) - const startTime = Date.now() - const result = await tool.execute(`test_${Date.now()}`, params) - const elapsed = Date.now() - startTime - - if (abortController.signal.aborted) { - return { success: false, error: 'cancelled' } - } - - let details = result.details as Record | undefined - let truncated = false - - if (details) { - stripAvatarFields(details) - const raw = JSON.stringify(details) - if (raw.length > MAX_RESULT_CHARS) { - truncated = true - details = { _truncated: true, _originalSize: raw.length, _preview: raw.slice(0, MAX_RESULT_CHARS) } - } - } - - return { - success: true, - elapsed, - content: result.content, - details, - truncated, - } - } catch (error) { - if (abortController.signal.aborted) { - return { success: false, error: 'cancelled' } - } - console.error(`Failed to execute tool ${toolName}:`, error) - return { success: false, error: String(error) } - } finally { - activeToolTests.delete(testId) - } - } - ) - - ipcMain.handle('ai:cancelToolTest', async (_, testId: string) => { - const controller = activeToolTests.get(testId) - if (controller) { - controller.abort() - activeToolTests.delete(testId) - return { success: true } - } - return { success: false } - }) - - // Agent streaming has been migrated to shared SSE route (ai-agent-stream.ts) - - // ==================== 上下文 token 估算 ==================== - - ipcMain.handle('ai:estimateContextTokens', async (_, conversationId: string) => { - try { - const history = getConversationManager().getHistoryForAgent(conversationId) - const tokens = countMessagesTokens(history.map((m) => ({ role: m.role, content: m.content }))) - return { success: true, tokens, messageCount: history.length } - } catch (error) { - return { success: false, tokens: 0, error: String(error) } - } - }) + // Desensitize rules, LLM chat, estimateContextTokens, tool testing + // have all been migrated to shared HTTP routes. } diff --git a/apps/desktop/main/ipc/messages.ts b/apps/desktop/main/ipc/messages.ts index 4dfebfd9..12641d20 100644 --- a/apps/desktop/main/ipc/messages.ts +++ b/apps/desktop/main/ipc/messages.ts @@ -1,9 +1,8 @@ /** - * 消息查询/导出 IPC 处理器 + * 消息导出 IPC 处理器 * - * 大部分消息查询已迁移到 Internal HTTP Server (FetchMessageAdapter)。 - * 保留 filterMessagesWithContext / getMultipleSessionsMessages(ElectronAIAdapter 依赖) - * 和导出功能(需要文件系统写入 + 进度推送)。 + * filterMessagesWithContext / getMultipleSessionsMessages 已迁移到共享 HTTP 路由。 + * 仅保留导出功能(需要文件系统写入 + 进度推送)。 */ import { ipcMain } from 'electron' @@ -11,56 +10,6 @@ import type { IpcContext } from './types' import * as worker from '../worker/workerManager' export function registerMessagesHandlers({ win }: IpcContext): void { - // ElectronAIAdapter 依赖的消息筛选/多会话查询 - ipcMain.handle( - 'ai:filterMessagesWithContext', - async ( - _, - sessionId: string, - keywords?: string[], - timeFilter?: { startTs: number; endTs: number }, - senderIds?: number[], - contextSize?: number, - page?: number, - pageSize?: number - ) => { - try { - return await worker.filterMessagesWithContext( - sessionId, - keywords, - timeFilter, - senderIds, - contextSize, - page, - pageSize - ) - } catch (error) { - console.error('Failed to filter messages:', error) - return { - blocks: [], - stats: { totalMessages: 0, hitMessages: 0, totalChars: 0 }, - pagination: { page: 1, pageSize: 50, totalBlocks: 0, totalHits: 0, hasMore: false }, - } - } - } - ) - - ipcMain.handle( - 'ai:getMultipleSessionsMessages', - async (_, sessionId: string, chatSessionIds: number[], page?: number, pageSize?: number) => { - try { - return await worker.getMultipleSessionsMessages(sessionId, chatSessionIds, page, pageSize) - } catch (error) { - console.error('Failed to get multiple sessions messages:', error) - return { - blocks: [], - stats: { totalMessages: 0, hitMessages: 0, totalChars: 0 }, - pagination: { page: 1, pageSize: 50, totalBlocks: 0, totalHits: 0, hasMore: false }, - } - } - } - ) - // 导出(需要文件系统写入 + 进度推送) ipcMain.handle( 'ai:exportFilterResultToFile', diff --git a/apps/desktop/preload/apis/ai.ts b/apps/desktop/preload/apis/ai.ts index 9d6fd40b..1bc985ab 100644 --- a/apps/desktop/preload/apis/ai.ts +++ b/apps/desktop/preload/apis/ai.ts @@ -12,16 +12,6 @@ export type { TokenUsage, AgentRuntimeStatus, SerializedErrorInfo } from '../../ // ==================== 类型定义 ==================== -export interface ChatMessage { - role: 'system' | 'user' | 'assistant' - content: string -} - -export interface ChatOptions { - temperature?: number - maxTokens?: number -} - export interface DesensitizeRule { id: string label: string @@ -32,22 +22,6 @@ export interface DesensitizeRule { locales: string[] } -export interface ToolCatalogEntry { - name: string - category: 'core' | 'analysis' - description: string - parameters: Record -} - -export interface ToolExecuteResult { - success: boolean - elapsed?: number - content?: Array<{ type: string; text: string }> - details?: Record - error?: string - truncated?: boolean -} - export interface PreprocessConfig { dataCleaning: boolean mergeConsecutive: boolean @@ -62,101 +36,7 @@ export interface PreprocessConfig { // ==================== AI API (IPC-only subset) ==================== export const aiApi = { - // ===== 消息筛选/导出(worker-dependent) ===== - - filterMessagesWithContext: ( - sessionId: string, - keywords?: string[], - timeFilter?: { startTs: number; endTs: number }, - senderIds?: number[], - contextSize?: number, - page?: number, - pageSize?: number - ): Promise<{ - blocks: Array<{ - startTs: number - endTs: number - messages: Array<{ - id: number - senderName: string - senderPlatformId: string - senderAliases: string[] - senderAvatar: string | null - content: string - timestamp: number - type: number - replyToMessageId: string | null - replyToContent: string | null - replyToSenderName: string | null - isHit: boolean - }> - hitCount: number - }> - stats: { - totalMessages: number - hitMessages: number - totalChars: number - } - pagination: { - page: number - pageSize: number - totalBlocks: number - totalHits: number - hasMore: boolean - } - }> => { - return ipcRenderer.invoke( - 'ai:filterMessagesWithContext', - sessionId, - keywords, - timeFilter, - senderIds, - contextSize, - page, - pageSize - ) - }, - - getMultipleSessionsMessages: ( - sessionId: string, - chatSessionIds: number[], - page?: number, - pageSize?: number - ): Promise<{ - blocks: Array<{ - startTs: number - endTs: number - messages: Array<{ - id: number - senderName: string - senderPlatformId: string - senderAliases: string[] - senderAvatar: string | null - content: string - timestamp: number - type: number - replyToMessageId: string | null - replyToContent: string | null - replyToSenderName: string | null - isHit: boolean - }> - hitCount: number - }> - stats: { - totalMessages: number - hitMessages: number - totalChars: number - } - pagination: { - page: number - pageSize: number - totalBlocks: number - totalHits: number - hasMore: boolean - } - }> => { - return ipcRenderer.invoke('ai:getMultipleSessionsMessages', sessionId, chatSessionIds, page, pageSize) - }, + // filterMessagesWithContext / getMultipleSessionsMessages migrated to shared HTTP routes. exportFilterResultToFile: (params: { sessionId: string @@ -188,50 +68,8 @@ export const aiApi = { return ipcRenderer.invoke('ai:showLogFile') }, - // ===== 脱敏规则(node-runtime) ===== - - getDefaultDesensitizeRules: (locale: string): Promise => { - return ipcRenderer.invoke('ai:getDefaultDesensitizeRules', locale) - }, - - mergeDesensitizeRules: (existingRules: DesensitizeRule[], locale: string): Promise => { - return ipcRenderer.invoke('ai:mergeDesensitizeRules', existingRules, locale) - }, - - // ===== 工具测试(tool registry on main process) ===== - - getToolCatalog: (): Promise => { - return ipcRenderer.invoke('ai:getToolCatalog') - }, - - executeTool: ( - testId: string, - toolName: string, - params: Record, - sessionId: string - ): Promise => { - return ipcRenderer.invoke('ai:executeTool', testId, toolName, params, sessionId) - }, - - cancelToolTest: (testId: string): Promise<{ success: boolean }> => { - return ipcRenderer.invoke('ai:cancelToolTest', testId) - }, - - // ===== 上下文估算(needs conversationManager) ===== - - estimateContextTokens: ( - conversationId: string - ): Promise<{ success: boolean; tokens: number; messageCount?: number; error?: string }> => { - return ipcRenderer.invoke('ai:estimateContextTokens', conversationId) - }, + // Desensitize rules, tool testing, estimateContextTokens have been migrated to shared HTTP routes. } -// LLM chat (non-streaming) still uses IPC for Electron-specific config resolution -export const llmApi = { - chat: ( - messages: ChatMessage[], - options?: ChatOptions - ): Promise<{ success: boolean; content?: string; error?: string }> => { - return ipcRenderer.invoke('llm:chat', messages, options) - }, -} +// LLM chat (non-streaming) IPC has been removed — no frontend consumers remain. +// All LLM calls now go through shared HTTP/SSE routes. diff --git a/apps/desktop/preload/apis/core.ts b/apps/desktop/preload/apis/core.ts index 7a1854d1..82a3e282 100644 --- a/apps/desktop/preload/apis/core.ts +++ b/apps/desktop/preload/apis/core.ts @@ -21,14 +21,7 @@ export const api = { } }, receive: (channel: string, func: (...args: unknown[]) => void) => { - const validChannels = [ - 'show-message', - 'chat:importProgress', - 'merge:parseProgress', - 'llm:streamChunk', - 'agent:streamChunk', - 'agent:complete', - ] + const validChannels = ['show-message', 'chat:importProgress', 'merge:parseProgress'] if (validChannels.includes(channel)) { // Deliberately strip event as it includes `sender` ipcRenderer.on(channel, (_event, ...args) => func(...args)) diff --git a/apps/desktop/preload/index.d.ts b/apps/desktop/preload/index.d.ts index f202e731..b15d7caf 100644 --- a/apps/desktop/preload/index.d.ts +++ b/apps/desktop/preload/index.d.ts @@ -156,27 +156,10 @@ interface FilterResultWithPagination extends FilterResult { /** * AiApi — IPC-only subset * - * Conversation/message CRUD and debug queries have been migrated to HTTP - * shared routes (FetchAIAdapter via Internal Server). Only features that - * require worker, native shell, or tool registry remain on IPC. + * Most AI functionality has been migrated to HTTP shared routes. + * Only export (fs write + progress push) and native shell remain on IPC. */ interface AiApi { - // Message filtering / export (worker-dependent) - filterMessagesWithContext: ( - sessionId: string, - keywords?: string[], - timeFilter?: TimeFilter, - senderIds?: number[], - contextSize?: number, - page?: number, - pageSize?: number - ) => Promise - getMultipleSessionsMessages: ( - sessionId: string, - chatSessionIds: number[], - page?: number, - pageSize?: number - ) => Promise exportFilterResultToFile: (params: { sessionId: string sessionName: string @@ -189,24 +172,7 @@ interface AiApi { chatSessionIds?: number[] }) => Promise<{ success: boolean; filePath?: string; error?: string }> onExportProgress: (callback: (progress: ExportProgress) => void) => () => void - // Native shell showAiLogFile: () => Promise<{ success: boolean; path?: string; error?: string }> - // Desensitize rules (node-runtime) - getDefaultDesensitizeRules: (locale: string) => Promise - mergeDesensitizeRules: (existingRules: DesensitizeRule[], locale: string) => Promise - // Tool testing (tool registry on main process) - getToolCatalog: () => Promise - executeTool: ( - testId: string, - toolName: string, - params: Record, - sessionId: string - ) => Promise - cancelToolTest: (testId: string) => Promise<{ success: boolean }> - // Context estimation (needs conversationManager) - estimateContextTokens: ( - conversationId: string - ) => Promise<{ success: boolean; tokens: number; messageCount?: number; error?: string }> } // ==================== 新模型系统类型 ==================== @@ -244,27 +210,8 @@ interface ModelDefinition { editable: boolean } -interface LLMChatMessage { - role: 'system' | 'user' | 'assistant' - content: string -} - -interface LLMChatOptions { - temperature?: number - maxTokens?: number -} - -/** - * LLM CRUD (config, provider, model, validate, etc.) has been migrated to - * HTTP service layer via FetchLLMAdapter. Streaming chat uses shared SSE route. - * Only non-streaming chat remains on IPC. - */ -interface LlmApi { - chat: ( - messages: LLMChatMessage[], - options?: LLMChatOptions - ) => Promise<{ success: boolean; content?: string; error?: string }> -} +// LLM API has been fully migrated to shared HTTP/SSE routes. +// LlmApi interface removed — no IPC consumers remain. /** Owner 信息(当前用户在对话中的身份) */ interface OwnerInfo { @@ -285,24 +232,6 @@ interface DesensitizeRule { locales: string[] } -/** 工具目录条目(实验室 - 基础工具) */ -interface ToolCatalogEntry { - name: string - category: 'core' | 'analysis' - description: string - parameters: Record -} - -/** 工具执行结果 */ -interface ToolExecuteResult { - success: boolean - elapsed?: number - content?: Array<{ type: string; text: string }> - details?: Record - error?: string - truncated?: boolean -} - /** 聊天记录预处理配置 */ interface PreprocessConfig { dataCleaning: boolean @@ -444,79 +373,7 @@ interface ApiServerApi { onImportCompleted: (callback: () => void) => () => void } -// Session Index API 类型 - 会话索引功能 -interface SessionStats { - sessionCount: number - hasIndex: boolean - gapThreshold: number -} - -interface ChatSessionItem { - id: number - startTs: number - endTs: number - messageCount: number - firstMessageId: number - /** 会话摘要(如果有) */ - summary?: string | null -} - -interface SessionApi { - generate: (sessionId: string, gapThreshold?: number) => Promise - generateIncremental: (sessionId: string, gapThreshold?: number) => Promise - hasIndex: (sessionId: string) => Promise - getStats: (sessionId: string) => Promise - getAllIndexStats: () => Promise> - clear: (sessionId: string) => Promise - updateGapThreshold: (sessionId: string, gapThreshold: number | null) => Promise - getSessions: (sessionId: string) => Promise - /** 生成单个会话摘要 */ - generateSummary: ( - dbSessionId: string, - chatSessionId: number, - locale?: string, - forceRegenerate?: boolean, - strategy?: 'brief' | 'standard' - ) => Promise<{ success: boolean; summary?: string; error?: string }> - /** 批量生成会话摘要 */ - generateSummaries: ( - dbSessionId: string, - chatSessionIds: number[], - locale?: string - ) => Promise<{ success: number; failed: number; skipped: number }> - /** 批量检查会话是否可以生成摘要 */ - checkCanGenerateSummary: ( - dbSessionId: string, - chatSessionIds: number[] - ) => Promise> - /** 根据时间范围查询会话列表 */ - getByTimeRange: ( - dbSessionId: string, - startTs: number, - endTs: number - ) => Promise< - Array<{ - id: number - startTs: number - endTs: number - messageCount: number - summary: string | null - }> - > - /** 获取最近 N 条会话 */ - getRecent: ( - dbSessionId: string, - limit: number - ) => Promise< - Array<{ - id: number - startTs: number - endTs: number - messageCount: number - summary: string | null - }> - > -} +// Session index API has been migrated to shared HTTP routes (FetchSessionIndexAdapter). declare global { interface Window { @@ -524,10 +381,8 @@ declare global { api: Api chatApi: ChatApi aiApi: AiApi - llmApi: LlmApi cacheApi: CacheApi networkApi: NetworkApi - sessionApi: SessionApi apiServerApi: ApiServerApi internalApi: InternalApi } @@ -546,29 +401,19 @@ export { ChatApi, Api, AiApi, - LlmApi, ProviderDefinition, ProviderKind, ModelDefinition, ModelCapability, ModelStatus, ModelRecommendedFor, - AgentApi, CacheApi, NetworkApi, ProxyConfig, - LLMChatMessage, - LLMChatOptions, - LLMChatStreamChunk, - AgentStreamChunk, AgentRuntimeStatus, - AgentResult, SerializedErrorInfo, - ToolContext, DesensitizeRule, PreprocessConfig, - ToolCatalogEntry, - ToolExecuteResult, TokenUsage, FilterMessage, ContextBlock, diff --git a/packages/http-routes/package.json b/packages/http-routes/package.json index f47df809..cd726b16 100644 --- a/packages/http-routes/package.json +++ b/packages/http-routes/package.json @@ -11,6 +11,7 @@ "@openchatlab/core": "workspace:*", "@openchatlab/node-runtime": "workspace:*", "@openchatlab/shared-types": "workspace:*", + "@openchatlab/tools": "workspace:*", "fastify": "^5.8.4" } } diff --git a/packages/http-routes/src/index.ts b/packages/http-routes/src/index.ts index df773158..0aaa505a 100644 --- a/packages/http-routes/src/index.ts +++ b/packages/http-routes/src/index.ts @@ -33,3 +33,7 @@ export { registerAiSkillRoutes } from './routes/web/ai-skills' export { registerAiLlmRoutes } from './routes/web/ai-llm' export { registerAiConversationRoutes } from './routes/web/ai-conversations' export { registerAiSummaryRoutes } from './routes/web/ai-summaries' +export { registerAiLlmStreamRoutes } from './routes/web/ai-llm-stream' +export { registerAiAgentStreamRoutes } from './routes/web/ai-agent-stream' +export { registerAiFilterRoutes } from './routes/web/ai-filter' +export { registerAiToolRoutes } from './routes/web/ai-tools' diff --git a/packages/http-routes/src/register.ts b/packages/http-routes/src/register.ts index c1acee40..ce25c3ef 100644 --- a/packages/http-routes/src/register.ts +++ b/packages/http-routes/src/register.ts @@ -22,6 +22,8 @@ import { registerAiSkillRoutes } from './routes/web/ai-skills' import { registerAiLlmRoutes } from './routes/web/ai-llm' import { registerAiLlmStreamRoutes } from './routes/web/ai-llm-stream' import { registerAiAgentStreamRoutes } from './routes/web/ai-agent-stream' +import { registerAiFilterRoutes } from './routes/web/ai-filter' +import { registerAiToolRoutes } from './routes/web/ai-tools' import { registerAiConversationRoutes } from './routes/web/ai-conversations' import { registerAiSummaryRoutes } from './routes/web/ai-summaries' import { registerMergeRoutes } from './routes/web/merge' @@ -60,6 +62,7 @@ export function registerSharedRoutes( if (!ctx.llmConfigStore) missing.push('llmConfigStore') if (!ctx.customProviderStore) missing.push('customProviderStore') if (!ctx.customModelStore) missing.push('customModelStore') + if (!ctx.runAgentStream) missing.push('runAgentStream') if (missing.length > 0) { throw new Error(`[http-routes] requireAi is set but missing AI dependencies: ${missing.join(', ')}`) } @@ -70,6 +73,8 @@ export function registerSharedRoutes( registerAiLlmRoutes(server, ctx) registerAiLlmStreamRoutes(server, ctx) registerAiAgentStreamRoutes(server, ctx) + registerAiFilterRoutes(server, ctx) + registerAiToolRoutes(server, ctx) registerAiConversationRoutes(server, ctx) registerAiSummaryRoutes(server, ctx) diff --git a/packages/http-routes/src/routes/web/ai-conversations.ts b/packages/http-routes/src/routes/web/ai-conversations.ts index 62792dd8..2f4fca66 100644 --- a/packages/http-routes/src/routes/web/ai-conversations.ts +++ b/packages/http-routes/src/routes/web/ai-conversations.ts @@ -1,5 +1,6 @@ import type { FastifyInstance } from 'fastify' import type { HttpRouteContext } from '../../context' +import { countMessagesTokens } from '@openchatlab/node-runtime' export function registerAiConversationRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { const cm = ctx.conversationManager @@ -159,4 +160,12 @@ export function registerAiConversationRoutes(server: FastifyInstance, ctx: HttpR const cleared = cm.clearAllDebugContext() return { success: true, cleared } }) + + server.get<{ + Params: { id: string } + }>('/_web/ai/conversations/:id/estimate-tokens', async (request) => { + const history = cm.getHistoryForAgent(request.params.id) + const tokens = countMessagesTokens(history.map((m) => ({ role: m.role, content: m.content }))) + return { success: true, tokens, messageCount: history.length } + }) } diff --git a/packages/http-routes/src/routes/web/ai-filter.ts b/packages/http-routes/src/routes/web/ai-filter.ts new file mode 100644 index 00000000..c1d0d791 --- /dev/null +++ b/packages/http-routes/src/routes/web/ai-filter.ts @@ -0,0 +1,46 @@ +import type { FastifyInstance } from 'fastify' +import type { HttpRouteContext } from '../../context' +import { filterMessagesWithContext, getMultipleSessionsMessages } from '@openchatlab/core' + +export function registerAiFilterRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + server.post<{ + Body: { + sessionId: string + keywords?: string[] + timeFilter?: { startTs: number; endTs: number } + senderIds?: number[] + contextSize?: number + page?: number + pageSize?: number + } + }>('/_web/ai/filter-messages', async (request, reply) => { + const { sessionId, keywords, timeFilter, senderIds, contextSize, page, pageSize } = request.body + const db = ctx.dbManager.open(sessionId) + if (!db) return reply.code(404).send({ error: `Session not found: ${sessionId}` }) + + try { + return filterMessagesWithContext(db, { keywords, timeFilter, senderIds, contextSize, page, pageSize }) + } finally { + db.close() + } + }) + + server.post<{ + Body: { + sessionId: string + chatSessionIds: number[] + page?: number + pageSize?: number + } + }>('/_web/ai/multiple-sessions-messages', async (request, reply) => { + const { sessionId, chatSessionIds, page, pageSize } = request.body + const db = ctx.dbManager.open(sessionId) + if (!db) return reply.code(404).send({ error: `Session not found: ${sessionId}` }) + + try { + return getMultipleSessionsMessages(db, chatSessionIds, page, pageSize) + } finally { + db.close() + } + }) +} diff --git a/packages/http-routes/src/routes/web/ai-llm.ts b/packages/http-routes/src/routes/web/ai-llm.ts index 7dc9d414..83e97942 100644 --- a/packages/http-routes/src/routes/web/ai-llm.ts +++ b/packages/http-routes/src/routes/web/ai-llm.ts @@ -1,7 +1,12 @@ import type { FastifyInstance } from 'fastify' import type { HttpRouteContext } from '../../context' import { BUILTIN_PROVIDERS, BUILTIN_MODELS, getBuiltinModelsByProvider } from '@openchatlab/core' -import { validateApiKey, fetchRemoteModels } from '@openchatlab/node-runtime' +import { + validateApiKey, + fetchRemoteModels, + getDefaultRulesForLocale, + mergeRulesForLocale, +} from '@openchatlab/node-runtime' // ==================== API key display masking ==================== @@ -212,4 +217,18 @@ export function registerAiLlmRoutes(server: FastifyInstance, ctx: HttpRouteConte const { provider, apiKey, baseUrl, apiFormat } = request.body return fetchRemoteModels(provider, apiKey, baseUrl, apiFormat) }) + + // ---------- Desensitize Rules ---------- + + server.get<{ + Querystring: { locale?: string } + }>('/_web/ai/desensitize-rules/defaults', async (request) => { + return getDefaultRulesForLocale(request.query.locale ?? 'zh-CN') + }) + + server.post<{ + Body: { existingRules: unknown[]; locale: string } + }>('/_web/ai/desensitize-rules/merge', async (request) => { + return mergeRulesForLocale(request.body.existingRules as any[], request.body.locale) + }) } diff --git a/packages/http-routes/src/routes/web/ai-tools.ts b/packages/http-routes/src/routes/web/ai-tools.ts new file mode 100644 index 00000000..a9b83e49 --- /dev/null +++ b/packages/http-routes/src/routes/web/ai-tools.ts @@ -0,0 +1,103 @@ +import type { FastifyInstance } from 'fastify' +import type { HttpRouteContext } from '../../context' +import { AGENT_TOOL_REGISTRY, CoreDataProvider } from '@openchatlab/tools' +import type { ToolExecutionContext } from '@openchatlab/tools' +import { stripAvatarFields } from '@openchatlab/core' + +const MAX_RESULT_CHARS = 500_000 +const activeToolTests = new Map() + +export function registerAiToolRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + server.get('/_web/ai/tools/full-catalog', async () => { + return AGENT_TOOL_REGISTRY.map((tool) => ({ + name: tool.name, + category: tool.category ?? 'core', + description: tool.description, + parameters: tool.inputSchema ?? {}, + })) + }) + + server.post<{ + Body: { + testId: string + toolName: string + params: Record + sessionId: string + } + }>('/_web/ai/tools/execute', async (request, reply) => { + const { testId, toolName, params, sessionId } = request.body + + const entry = AGENT_TOOL_REGISTRY.find((t) => t.name === toolName) + if (!entry) { + return reply.code(404).send({ success: false, error: `Tool not found: ${toolName}` }) + } + + const db = ctx.dbManager.open(sessionId) + if (!db) { + return reply.code(404).send({ success: false, error: `Session not found: ${sessionId}` }) + } + + const abortController = new AbortController() + activeToolTests.set(testId, abortController) + + try { + const execCtx: ToolExecutionContext = { + sessionId, + db, + dataProvider: new CoreDataProvider(db), + } + + const startTime = Date.now() + const result = await entry.handler(params, execCtx) + const elapsed = Date.now() - startTime + + if (abortController.signal.aborted) { + return { success: false, error: 'cancelled' } + } + + let details = (result.data as Record | undefined) ?? undefined + let truncated = false + + if (details) { + stripAvatarFields(details) + const raw = JSON.stringify(details) + if (raw.length > MAX_RESULT_CHARS) { + truncated = true + details = { _truncated: true, _originalSize: raw.length, _preview: raw.slice(0, MAX_RESULT_CHARS) } + } + } + + return { + success: true, + elapsed, + content: [{ type: 'text', text: result.content }], + details, + truncated, + } + } catch (error) { + if (abortController.signal.aborted) { + return { success: false, error: 'cancelled' } + } + console.error(`Failed to execute tool ${toolName}:`, error) + return { success: false, error: String(error) } + } finally { + activeToolTests.delete(testId) + try { + db.close() + } catch { + /* ignore close errors */ + } + } + }) + + server.post<{ Body: { testId: string } }>('/_web/ai/tools/cancel', async (request) => { + const { testId } = request.body + const controller = activeToolTests.get(testId) + if (controller) { + controller.abort() + activeToolTests.delete(testId) + return { success: true } + } + return { success: false } + }) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aec85e37..110b0dce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -251,6 +251,9 @@ importers: '@openchatlab/shared-types': specifier: workspace:* version: link:../shared-types + '@openchatlab/tools': + specifier: workspace:* + version: link:../tools fastify: specifier: ^5.8.4 version: 5.8.4 diff --git a/src/services/ai/electron.ts b/src/services/ai/electron.ts index 6dcfc05a..8cddfb83 100644 --- a/src/services/ai/electron.ts +++ b/src/services/ai/electron.ts @@ -1,59 +1,14 @@ -import type { - FilterResultWithPagination, - ExportFilterParams, - ExportProgress, - ToolCatalogEntry, - ToolExecuteResult, - DesensitizeRule, -} from './types' -import type { TimeFilter } from '@/types/base' +import type { ExportFilterParams, ExportProgress } from './types' import { FetchAIAdapter } from './fetch' /** * Electron AI Adapter * - * Extends FetchAIAdapter so conversation/message CRUD and debug queries - * go through the Internal HTTP Server (shared routes). Only features - * that require IPC (worker, native shell, tool registry) are overridden. + * Extends FetchAIAdapter so most queries go through the Internal HTTP Server + * (shared routes). Only features that require IPC (filesystem export with + * progress push, native shell) are overridden here. */ export class ElectronAIAdapter extends FetchAIAdapter { - // ===== Context estimation (needs conversationManager on main process) ===== - override async estimateContextTokens( - conversationId: string - ): Promise<{ success: boolean; tokens: number; messageCount?: number; error?: string }> { - return window.aiApi.estimateContextTokens(conversationId) - } - - // ===== Message filtering / export (worker-dependent) ===== - override async filterMessagesWithContext( - sessionId: string, - keywords?: string[], - timeFilter?: TimeFilter, - senderIds?: number[], - contextSize?: number, - page?: number, - pageSize?: number - ): Promise { - return window.aiApi.filterMessagesWithContext( - sessionId, - keywords, - timeFilter, - senderIds, - contextSize, - page, - pageSize - ) - } - - override async getMultipleSessionsMessages( - sessionId: string, - chatSessionIds: number[], - page?: number, - pageSize?: number - ): Promise { - return window.aiApi.getMultipleSessionsMessages(sessionId, chatSessionIds, page, pageSize) - } - override async exportFilterResultToFile( params: ExportFilterParams ): Promise<{ success: boolean; filePath?: string; error?: string }> { @@ -64,34 +19,6 @@ export class ElectronAIAdapter extends FetchAIAdapter { return window.aiApi.onExportProgress(callback) } - // ===== Tool testing (localized catalog + worker execution on main process) ===== - override async getToolCatalog(): Promise { - return window.aiApi.getToolCatalog() - } - - override async executeTool( - testId: string, - toolName: string, - params: Record, - sessionId: string - ): Promise { - return window.aiApi.executeTool(testId, toolName, params, sessionId) - } - - override async cancelToolTest(testId: string): Promise<{ success: boolean }> { - return window.aiApi.cancelToolTest(testId) - } - - // ===== Desensitize rules (needs node-runtime) ===== - override async getDefaultDesensitizeRules(locale: string): Promise { - return window.aiApi.getDefaultDesensitizeRules(locale) - } - - override async mergeDesensitizeRules(existingRules: DesensitizeRule[], locale: string): Promise { - return window.aiApi.mergeDesensitizeRules(existingRules, locale) - } - - // ===== Native shell ===== override async showAiLogFile(): Promise<{ success: boolean; path?: string; error?: string }> { return window.aiApi.showAiLogFile() } diff --git a/src/services/ai/fetch.ts b/src/services/ai/fetch.ts index 6e528885..8bff34c2 100644 --- a/src/services/ai/fetch.ts +++ b/src/services/ai/fetch.ts @@ -114,38 +114,65 @@ export class FetchAIAdapter implements AIAdapter { } async estimateContextTokens( - _conversationId: string + conversationId: string ): Promise<{ success: boolean; tokens: number; messageCount?: number; error?: string }> { - return { success: false, tokens: 0, error: NOT_AVAILABLE_WEB } + try { + return await get<{ success: boolean; tokens: number; messageCount?: number }>( + `/ai/conversations/${conversationId}/estimate-tokens` + ) + } catch (error) { + return { success: false, tokens: 0, error: error instanceof Error ? error.message : String(error) } + } } - // ===== 消息筛选/导出 (降级) ===== + // ===== 消息筛选 ===== async filterMessagesWithContext( - _sessionId: string, - _keywords?: string[], - _timeFilter?: TimeFilter, - _senderIds?: number[], - _contextSize?: number, - _page?: number, - _pageSize?: number + sessionId: string, + keywords?: string[], + timeFilter?: TimeFilter, + senderIds?: number[], + contextSize?: number, + page?: number, + pageSize?: number ): Promise { - return { - blocks: [], - stats: { totalMessages: 0, hitMessages: 0, totalChars: 0 }, - pagination: { page: 1, pageSize: 50, totalBlocks: 0, totalHits: 0, hasMore: false }, + try { + return await post('/ai/filter-messages', { + sessionId, + keywords, + timeFilter, + senderIds, + contextSize, + page, + pageSize, + }) + } catch { + 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 + sessionId: string, + chatSessionIds: number[], + page?: number, + pageSize?: number ): Promise { - return { - blocks: [], - stats: { totalMessages: 0, hitMessages: 0, totalChars: 0 }, - pagination: { page: 1, pageSize: 50, totalBlocks: 0, totalHits: 0, hasMore: false }, + try { + return await post('/ai/multiple-sessions-messages', { + sessionId, + chatSessionIds, + page, + pageSize, + }) + } catch { + return { + blocks: [], + stats: { totalMessages: 0, hitMessages: 0, totalChars: 0 }, + pagination: { page: 1, pageSize: 50, totalBlocks: 0, totalHits: 0, hasMore: false }, + } } } @@ -174,29 +201,49 @@ export class FetchAIAdapter implements AIAdapter { // ===== 工具 ===== async getToolCatalog(): Promise { - return get('/ai/tools/catalog') + try { + return await get('/ai/tools/full-catalog') + } catch { + return get('/ai/tools/catalog') + } } async executeTool( - _testId: string, - _toolName: string, - _params: Record, - _sessionId: string + testId: string, + toolName: string, + params: Record, + sessionId: string ): Promise { - return { success: false, error: NOT_AVAILABLE_WEB } + try { + return await post('/ai/tools/execute', { testId, toolName, params, sessionId }) + } catch (error) { + return { success: false, error: String(error) } + } } - async cancelToolTest(_testId: string): Promise<{ success: boolean }> { - return { success: false } + async cancelToolTest(testId: string): Promise<{ success: boolean }> { + try { + return await post<{ success: boolean }>('/ai/tools/cancel', { testId }) + } catch { + return { success: false } + } } // ===== 脱敏 ===== - async getDefaultDesensitizeRules(_locale: string): Promise { - return [] + async getDefaultDesensitizeRules(locale: string): Promise { + try { + return await get(`/ai/desensitize-rules/defaults?locale=${encodeURIComponent(locale)}`) + } catch { + return [] + } } - async mergeDesensitizeRules(existingRules: DesensitizeRule[], _locale: string): Promise { - return existingRules + async mergeDesensitizeRules(existingRules: DesensitizeRule[], locale: string): Promise { + try { + return await post('/ai/desensitize-rules/merge', { existingRules, locale }) + } catch { + return existingRules + } } // ===== 日志 =====