mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 10:14:35 +08:00
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:
1 parent
1294448959
commit
3830ccdd1b
3 files changed
+297
-85
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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 ====================
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in new issue
Block a user