From 3830ccdd1bf1aaa122229c4874abd37b3f3dfb68 Mon Sep 17 00:00:00 2001 From: digua Date: Mon, 18 May 2026 19:43:16 +0800 Subject: [PATCH] feat(server): wire shared managers, FTS, and summary routes to web API Add Assistant/Skill CRUD routes using shared AssistantManager/SkillManagerCore, FTS search/rebuild routes, and session summary generation routes. Clean up unused imports in ai.ts. --- packages/server/src/ai/manager-factory.ts | 74 ++++++++++ packages/server/src/http/routes/ai.ts | 167 +++++++++++----------- packages/server/src/http/routes/web.ts | 141 +++++++++++++++++- 3 files changed, 297 insertions(+), 85 deletions(-) create mode 100644 packages/server/src/ai/manager-factory.ts diff --git a/packages/server/src/ai/manager-factory.ts b/packages/server/src/ai/manager-factory.ts new file mode 100644 index 00000000..e34aa9b0 --- /dev/null +++ b/packages/server/src/ai/manager-factory.ts @@ -0,0 +1,74 @@ +/** + * Factory functions for creating AssistantManager and SkillManagerCore instances. + * + * Server-side equivalent of the Electron adapter in electron/main/ai/assistant/manager.ts. + * Uses Node.js fs directly (no Electron dependency). + */ + +import * as fs from 'fs' +import * as path from 'path' +import { createHash, randomUUID } from 'crypto' +import { + AssistantManager, + SkillManagerCore, + type AssistantManagerFs, + type SkillManagerFs, +} from '@openchatlab/node-runtime' + +const nodeFs: AssistantManagerFs & SkillManagerFs = { + ensureDir(dir: string) { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) + }, + listFiles(dir: string, ext: string) { + if (!fs.existsSync(dir)) return [] + return fs.readdirSync(dir).filter((f) => f.endsWith(ext)) + }, + readFile(filePath: string) { + return fs.readFileSync(filePath, 'utf-8') + }, + writeFile(filePath: string, content: string) { + fs.writeFileSync(filePath, content, 'utf-8') + }, + deleteFile(filePath: string) { + if (fs.existsSync(filePath)) fs.unlinkSync(filePath) + }, + fileExists(filePath: string) { + return fs.existsSync(filePath) + }, + joinPath(...parts: string[]) { + return path.join(...parts) + }, +} + +const managerCache = new Map() + +export function getAssistantManager(aiDataDir: string): AssistantManager { + let cached = managerCache.get(aiDataDir) + if (!cached) { + cached = { + assistant: new AssistantManager({ + fs: nodeFs, + assistantsDir: path.join(aiDataDir, 'assistants'), + builtinRawConfigs: [], + generateId: () => `custom_${randomUUID().replace(/-/g, '').slice(0, 12)}`, + }), + skill: new SkillManagerCore({ + fs: nodeFs, + skillsDir: path.join(aiDataDir, 'skills'), + builtinRawSkills: [], + contentHash: (content: string) => createHash('md5').update(content).digest('hex'), + }), + } + managerCache.set(aiDataDir, cached) + } + return cached.assistant +} + +export function getSkillManagerCore(aiDataDir: string): SkillManagerCore { + let cached = managerCache.get(aiDataDir) + if (!cached) { + getAssistantManager(aiDataDir) + cached = managerCache.get(aiDataDir)! + } + return cached.skill +} diff --git a/packages/server/src/http/routes/ai.ts b/packages/server/src/http/routes/ai.ts index 60e91744..d713e35c 100644 --- a/packages/server/src/http/routes/ai.ts +++ b/packages/server/src/http/routes/ai.ts @@ -13,12 +13,13 @@ import * as fs from 'fs' import * as path from 'path' import type { FastifyInstance } from 'fastify' import type { DatabaseManager, AIConversationManager } from '@openchatlab/node-runtime' -import { parseAssistantFile, parseSkillFile, SkillManager, createActivateSkillTool } from '@openchatlab/node-runtime' +import { SkillManager, createActivateSkillTool } from '@openchatlab/node-runtime' +import type { AssistantConfig } 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 { AGENT_TOOL_REGISTRY } from '@openchatlab/tools' import { adaptToolsForAgent } from '../../ai/tool-adapter' import { loadAssistantConfig } from '../../ai/assistant-loader' +import { getAssistantManager, getSkillManagerCore } from '../../ai/manager-factory' import { runServerAgent, type AgentStreamEvent } from '../../ai/agent' import { addLlmConfig, @@ -45,107 +46,105 @@ function getAiDir(dbManager: DatabaseManager): string { return pathProvider.getAiDataDir() } -function scanMdFiles(dir: string): string[] { - if (!fs.existsSync(dir)) return [] - return fs - .readdirSync(dir) - .filter((f) => f.endsWith('.md')) - .map((f) => path.join(dir, f)) -} - export function registerAiRoutes( server: FastifyInstance, dbManager: DatabaseManager, convManager?: AIConversationManager ): void { - // ==================== Assistants ==================== + // ==================== Assistants (via shared AssistantManager) ==================== server.get('/_web/ai/assistants', async () => { - const dir = path.join(getAiDir(dbManager), 'assistants') - const files = scanMdFiles(dir) - const results: AssistantSummary[] = [] - for (const filePath of files) { - try { - const content = fs.readFileSync(filePath, 'utf-8') - const parsed = parseAssistantFile(content, filePath) - if (parsed) { - results.push({ - id: parsed.id, - name: parsed.name, - systemPrompt: parsed.systemPrompt, - presetQuestions: parsed.presetQuestions, - builtinId: parsed.builtinId, - applicableChatTypes: parsed.applicableChatTypes, - supportedLocales: parsed.supportedLocales, - }) - } - } catch { - // skip unparseable files - } - } - return results + const mgr = getAssistantManager(getAiDir(dbManager)) + return mgr.getAllAssistants() }) server.get<{ Params: { id: string } }>('/_web/ai/assistants/:id', async (request, reply) => { - const { id } = request.params - const dir = path.join(getAiDir(dbManager), 'assistants') - const filePath = path.join(dir, `${id}.md`) - - if (!fs.existsSync(filePath)) { - return reply.code(404).send({ error: 'Not found' }) - } - - const content = fs.readFileSync(filePath, 'utf-8') - const parsed = parseAssistantFile(content, filePath) - if (!parsed) { - return reply.code(404).send({ error: 'Parse failed' }) - } - return parsed + const mgr = getAssistantManager(getAiDir(dbManager)) + const config = mgr.getAssistantConfig(request.params.id) + if (!config) return reply.code(404).send({ error: 'Not found' }) + return config }) - // ==================== Skills ==================== + server.post<{ + Body: Omit + }>('/_web/ai/assistants', async (request) => { + const mgr = getAssistantManager(getAiDir(dbManager)) + return mgr.createAssistant(request.body) + }) + + server.put<{ + Params: { id: string } + Body: Partial + }>('/_web/ai/assistants/:id', async (request, reply) => { + const mgr = getAssistantManager(getAiDir(dbManager)) + const result = mgr.updateAssistant(request.params.id, request.body) + if (!result.success) return reply.code(404).send(result) + return result + }) + + server.delete<{ Params: { id: string } }>('/_web/ai/assistants/:id', async (request, reply) => { + const mgr = getAssistantManager(getAiDir(dbManager)) + const result = mgr.deleteAssistant(request.params.id) + if (!result.success) return reply.code(400).send(result) + return result + }) + + server.post<{ Params: { id: string } }>('/_web/ai/assistants/:id/reset', async (request, reply) => { + const mgr = getAssistantManager(getAiDir(dbManager)) + const result = mgr.resetAssistant(request.params.id) + if (!result.success) return reply.code(400).send(result) + return result + }) + + server.post<{ Body: { rawMd: string } }>('/_web/ai/assistants/import', async (request) => { + const mgr = getAssistantManager(getAiDir(dbManager)) + return mgr.importAssistantFromMd(request.body.rawMd) + }) + + // ==================== Skills (via shared SkillManagerCore) ==================== server.get('/_web/ai/skills', async () => { - const dir = path.join(getAiDir(dbManager), 'skills') - const files = scanMdFiles(dir) - const results: SkillSummary[] = [] - for (const filePath of files) { - try { - const content = fs.readFileSync(filePath, 'utf-8') - const parsed = parseSkillFile(content, filePath) - if (parsed) { - results.push({ - id: parsed.id, - name: parsed.name, - description: parsed.description, - tags: parsed.tags, - chatScope: parsed.chatScope, - tools: parsed.tools, - builtinId: parsed.builtinId, - }) - } - } catch { - // skip unparseable files - } - } - return results + const mgr = getSkillManagerCore(getAiDir(dbManager)) + return mgr.getAllSkills() }) server.get<{ Params: { id: string } }>('/_web/ai/skills/:id', async (request, reply) => { - const { id } = request.params - const dir = path.join(getAiDir(dbManager), 'skills') - const filePath = path.join(dir, `${id}.md`) + const mgr = getSkillManagerCore(getAiDir(dbManager)) + const config = mgr.getSkillConfig(request.params.id) + if (!config) return reply.code(404).send({ error: 'Not found' }) + return config + }) - if (!fs.existsSync(filePath)) { - return reply.code(404).send({ error: 'Not found' }) - } + server.post<{ Body: { rawMd: string } }>('/_web/ai/skills', async (request) => { + const mgr = getSkillManagerCore(getAiDir(dbManager)) + return mgr.createSkill(request.body.rawMd) + }) - const content = fs.readFileSync(filePath, 'utf-8') - const parsed = parseSkillFile(content, filePath) - if (!parsed) { - return reply.code(404).send({ error: 'Parse failed' }) - } - return parsed + server.put<{ + Params: { id: string } + Body: { rawMd: string } + }>('/_web/ai/skills/:id', async (request, reply) => { + const mgr = getSkillManagerCore(getAiDir(dbManager)) + const result = mgr.updateSkill(request.params.id, request.body.rawMd) + if (!result.success) return reply.code(404).send(result) + return result + }) + + server.delete<{ Params: { id: string } }>('/_web/ai/skills/:id', async (request, reply) => { + const mgr = getSkillManagerCore(getAiDir(dbManager)) + const result = mgr.deleteSkill(request.params.id) + if (!result.success) return reply.code(400).send(result) + return result + }) + + server.post<{ Body: { rawMd: string } }>('/_web/ai/skills/import', async (request) => { + const mgr = getSkillManagerCore(getAiDir(dbManager)) + return mgr.importSkillFromMd(request.body.rawMd) + }) + + server.get('/_web/ai/skills/builtin-catalog', async () => { + const mgr = getSkillManagerCore(getAiDir(dbManager)) + return mgr.getBuiltinCatalog() }) // ==================== LLM Config ==================== diff --git a/packages/server/src/http/routes/web.ts b/packages/server/src/http/routes/web.ts index c54bda03..e084a640 100644 --- a/packages/server/src/http/routes/web.ts +++ b/packages/server/src/http/routes/web.ts @@ -37,8 +37,24 @@ import { generateSessionIndex, clearSessionIndex, } from '@openchatlab/core' -import { createJiebaNlpProvider } from '@openchatlab/node-runtime' +import { + createJiebaNlpProvider, + hasFtsTable, + searchByFts, + rebuildFtsIndex, + completeSimple, + type PiTextContent, + generateSessionSummary, + type SummaryDeps, +} from '@openchatlab/node-runtime' +import { + getChatSessionSummary, + saveChatSessionSummary, + getChatSessionList, + getSessionMessages, +} from '@openchatlab/core' import { streamImport, detectFormat, detectAllFormats, getSupportedFormats, scanMultiChatFile } from '../../import' +import { getDefaultAssistantConfig, buildPiModel } from '../../ai/llm-config' function resolveNativeBinding(): string | undefined { if (process.versions.electron) return undefined @@ -74,6 +90,50 @@ function parseTimeFilter(query: Record): TimeFilter return filter } +function getAiDataDir(dbManager: DatabaseManager): string { + const pathProvider = (dbManager as any)['pathProvider'] + if (!pathProvider) { + throw Object.assign(new Error('PathProvider not available'), { statusCode: 500 }) + } + return pathProvider.getAiDataDir() +} + +function buildSummaryDeps( + db: ReturnType, + llmConfig: ReturnType & object, + _aiDataDir: string +): SummaryDeps { + const piModel = buildPiModel(llmConfig) + return { + loadMessages(chatSessionId, limit = 500) { + const data = getSessionMessages(db!, chatSessionId, limit) + if (!data) return null + return data.messages.map((m) => ({ senderName: m.senderName, content: m.content })) + }, + saveSummary(chatSessionId, summary) { + saveChatSessionSummary(db!, chatSessionId, summary) + }, + getSummary(chatSessionId) { + return getChatSessionSummary(db!, chatSessionId) + }, + async llmComplete(systemPrompt, userPrompt, options) { + const result = await completeSimple( + piModel, + { + systemPrompt, + messages: [{ role: 'user', content: [{ type: 'text', text: userPrompt }], timestamp: Date.now() }] as any, + }, + { apiKey: llmConfig.apiKey, maxTokens: options?.maxTokens, temperature: options?.temperature } + ) + return result.content + .filter((item): item is PiTextContent => item.type === 'text') + .map((item) => item.text) + .join('') + }, + t: (key: string) => key, + } +} + export function registerWebRoutes(server: FastifyInstance, dbManager: DatabaseManager): void { // ==================== 会话管理 ==================== @@ -470,6 +530,85 @@ export function registerWebRoutes(server: FastifyInstance, dbManager: DatabaseMa return { success: true } }) + // ==================== FTS Search & Rebuild ==================== + + server.get<{ + Params: { id: string } + Querystring: { keywords: string; limit?: string; offset?: string } + }>('/_web/sessions/:id/search/fts', async (request, reply) => { + const db = ensureDb(dbManager, request.params.id) + if (!hasFtsTable(db)) { + return reply.code(400).send({ error: 'FTS index not built for this session' }) + } + const keywords = request.query.keywords.split(/\s+/).filter(Boolean) + if (keywords.length === 0) return { rowids: [], total: 0 } + const limit = parseInt(request.query.limit || '100', 10) + const offset = parseInt(request.query.offset || '0', 10) + return searchByFts(db, keywords, limit, offset) + }) + + server.get<{ Params: { id: string } }>('/_web/sessions/:id/fts/status', async (request) => { + const db = ensureDb(dbManager, request.params.id) + return { hasFtsIndex: hasFtsTable(db) } + }) + + server.post<{ Params: { id: string } }>('/_web/sessions/:id/fts/rebuild', async (request) => { + const db = ensureWritableDb(dbManager, request.params.id) + const result = rebuildFtsIndex(db) + return { success: true, indexed: result.indexed } + }) + + // ==================== Session Summary (LLM-generated) ==================== + + server.post<{ + Params: { id: string } + Body: { chatSessionId: number; locale?: string; forceRegenerate?: boolean } + }>('/_web/sessions/:id/summaries/generate', async (request, reply) => { + const sessionId = request.params.id + const { chatSessionId, locale, forceRegenerate } = request.body + + const aiDataDir = getAiDataDir(dbManager) + const llmConfig = getDefaultAssistantConfig(aiDataDir) + if (!llmConfig) { + return reply.code(400).send({ error: 'No LLM configuration available' }) + } + + const db = ensureWritableDb(dbManager, sessionId) + const deps = buildSummaryDeps(db, llmConfig, aiDataDir) + + const result = await generateSessionSummary(deps, chatSessionId, { locale, forceRegenerate }) + return result + }) + + server.post<{ + Params: { id: string } + Body: { locale?: string; forceRegenerate?: boolean } + }>('/_web/sessions/:id/summaries/generate-all', async (request, reply) => { + const sessionId = request.params.id + const { locale, forceRegenerate } = request.body + + const aiDataDir = getAiDataDir(dbManager) + const llmConfig = getDefaultAssistantConfig(aiDataDir) + if (!llmConfig) { + return reply.code(400).send({ error: 'No LLM configuration available' }) + } + + const db = ensureWritableDb(dbManager, sessionId) + const chatSessions = getChatSessionList(db) + const deps = buildSummaryDeps(db, llmConfig, aiDataDir) + + let success = 0 + let failed = 0 + + for (const cs of chatSessions) { + const result = await generateSessionSummary(deps, cs.id, { locale, forceRegenerate }) + if (result.success) success++ + else failed++ + } + + return { success, failed, total: chatSessions.length } + }) + // ==================== Demo 示例数据 ==================== const DEMO_BASE_URL = 'https://chatlab.fun/assets/demo'