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.
This commit is contained in:
digua committed 2026-05-18 19:43:16 +08:00
1 parent 1294448959
commit 3830ccdd1b
3 files changed
+297 -85

No files matched your search

+74
View File
@@ -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<string, { assistant: AssistantManager; skill: SkillManagerCore }>()
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
}
+83 -84
View File
@@ -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<AssistantConfig, 'id'>
}>('/_web/ai/assistants', async (request) => {
const mgr = getAssistantManager(getAiDir(dbManager))
return mgr.createAssistant(request.body)
})
server.put<{
Params: { id: string }
Body: Partial<AssistantConfig>
}>('/_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 ====================
+140 -1
View File
@@ -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<string, string | undefined>): 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<DatabaseManager['open']>,
llmConfig: ReturnType<typeof getDefaultAssistantConfig> & 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'