mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-04 04:01:16 +08:00
feat: 完成助手模式初版
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
import { getActiveConfig, buildPiModel } from '../llm'
|
||||
import { getAllTools } from '../tools'
|
||||
import type { ToolContext, OwnerInfo } from '../tools/types'
|
||||
import { createSkillTools } from '../assistant/skillRunner'
|
||||
import { getHistoryForAgent } from '../conversations'
|
||||
import { aiLogger, isDebugMode } from '../logger'
|
||||
import { t as i18nT } from '../../i18n'
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
} from '@mariozechner/pi-ai'
|
||||
|
||||
import type { AgentConfig, AgentStreamChunk, AgentResult, PromptConfig, TokenUsage } from './types'
|
||||
import type { AssistantConfig } from '../assistant/types'
|
||||
import { buildSystemPrompt } from './prompt-builder'
|
||||
import { extractThinkingContent, stripToolCallTags } from './content-parser'
|
||||
import { AgentEventHandler } from './event-handler'
|
||||
@@ -38,6 +40,7 @@ export class Agent {
|
||||
private abortSignal?: AbortSignal
|
||||
private chatType: 'group' | 'private' = 'group'
|
||||
private promptConfig?: PromptConfig
|
||||
private assistantConfig?: AssistantConfig
|
||||
private locale: string = 'zh-CN'
|
||||
|
||||
constructor(
|
||||
@@ -47,7 +50,8 @@ export class Agent {
|
||||
config: AgentConfig = {},
|
||||
chatType: 'group' | 'private' = 'group',
|
||||
promptConfig?: PromptConfig,
|
||||
locale: string = 'zh-CN'
|
||||
locale: string = 'zh-CN',
|
||||
assistantConfig?: AssistantConfig
|
||||
) {
|
||||
this.context = context
|
||||
this.piModel = piModel
|
||||
@@ -55,6 +59,7 @@ export class Agent {
|
||||
this.abortSignal = config.abortSignal
|
||||
this.chatType = chatType
|
||||
this.promptConfig = promptConfig
|
||||
this.assistantConfig = assistantConfig
|
||||
this.locale = locale
|
||||
this.config = {
|
||||
maxToolRounds: config.maxToolRounds ?? 5,
|
||||
@@ -74,7 +79,16 @@ export class Agent {
|
||||
aiLogger.info('Agent', 'User question', userMessage)
|
||||
|
||||
const maxToolRounds = Math.max(0, this.config.maxToolRounds ?? 0)
|
||||
const systemPrompt = buildSystemPrompt(this.chatType, this.promptConfig, this.context.ownerInfo, this.locale)
|
||||
|
||||
// 当有 AssistantConfig 时,将其 systemPrompt/responseRules 映射为 PromptConfig
|
||||
const effectivePromptConfig: PromptConfig | undefined = this.assistantConfig
|
||||
? {
|
||||
roleDefinition: this.assistantConfig.systemPrompt,
|
||||
responseRules: this.assistantConfig.responseRules || '',
|
||||
}
|
||||
: this.promptConfig
|
||||
|
||||
const systemPrompt = buildSystemPrompt(this.chatType, effectivePromptConfig, this.context.ownerInfo, this.locale)
|
||||
const answerWithoutToolsPrompt = i18nT('ai.agent.answerWithoutTools', { lng: this.locale })
|
||||
|
||||
const handler = new AgentEventHandler({
|
||||
@@ -137,7 +151,16 @@ export class Agent {
|
||||
|
||||
// 配置 prompt、工具、历史
|
||||
coreAgent.setSystemPrompt(systemPrompt)
|
||||
const piTools = getAllTools({ ...this.context, locale: this.locale })
|
||||
const allowedTools = this.assistantConfig?.allowedBuiltinTools
|
||||
const toolContext = { ...this.context, locale: this.locale }
|
||||
const piTools = getAllTools(toolContext, allowedTools)
|
||||
|
||||
// 合并声明式 SQL 技能(Phase 2)
|
||||
if (this.assistantConfig?.customSkills?.length) {
|
||||
const skillTools = createSkillTools(this.assistantConfig.customSkills, toolContext)
|
||||
piTools.push(...skillTools)
|
||||
}
|
||||
|
||||
coreAgent.setTools(maxToolRounds > 0 ? piTools : [])
|
||||
|
||||
const limit = this.config.contextHistoryLimit ?? 48
|
||||
@@ -278,12 +301,15 @@ export async function runAgent(
|
||||
userMessage: string,
|
||||
context: ToolContext,
|
||||
config?: AgentConfig,
|
||||
chatType?: 'group' | 'private'
|
||||
chatType?: 'group' | 'private',
|
||||
promptConfig?: PromptConfig,
|
||||
locale?: string,
|
||||
assistantConfig?: AssistantConfig
|
||||
): Promise<AgentResult> {
|
||||
const activeConfig = getActiveConfig()
|
||||
if (!activeConfig) throw new Error('LLM service not configured')
|
||||
const piModel = buildPiModel(activeConfig)
|
||||
const agent = new Agent(context, piModel, activeConfig.apiKey, config, chatType)
|
||||
const agent = new Agent(context, piModel, activeConfig.apiKey, config, chatType, promptConfig, locale, assistantConfig)
|
||||
return agent.execute(userMessage)
|
||||
}
|
||||
|
||||
@@ -295,11 +321,14 @@ export async function runAgentStream(
|
||||
context: ToolContext,
|
||||
onChunk: (chunk: AgentStreamChunk) => void,
|
||||
config?: AgentConfig,
|
||||
chatType?: 'group' | 'private'
|
||||
chatType?: 'group' | 'private',
|
||||
promptConfig?: PromptConfig,
|
||||
locale?: string,
|
||||
assistantConfig?: AssistantConfig
|
||||
): Promise<AgentResult> {
|
||||
const activeConfig = getActiveConfig()
|
||||
if (!activeConfig) throw new Error('LLM service not configured')
|
||||
const piModel = buildPiModel(activeConfig)
|
||||
const agent = new Agent(context, piModel, activeConfig.apiKey, config, chatType)
|
||||
const agent = new Agent(context, piModel, activeConfig.apiKey, config, chatType, promptConfig, locale, assistantConfig)
|
||||
return agent.executeStream(userMessage, onChunk)
|
||||
}
|
||||
|
||||
27
electron/main/ai/assistant/builtins/community_analyst.json
Normal file
27
electron/main/ai/assistant/builtins/community_analyst.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"id": "community_analyst",
|
||||
"name": "社群分析师",
|
||||
"description": "专精于分析群成员活跃度、话题趋势和社群运营数据的运营专家。",
|
||||
"version": 1,
|
||||
"order": 2,
|
||||
"systemPrompt": "你是一位资深的社群运营分析师,擅长从聊天数据中挖掘社群运营洞察。\n你的核心能力是:\n- 分析成员活跃度排行和变化趋势\n- 识别群内热门话题和讨论焦点\n- 发现社群的活跃时段规律\n- 识别关键意见领袖(KOL)和活跃贡献者\n- 给出可执行的社群运营建议\n\n你的回答风格应该专业、数据驱动,用数据图表思维呈现分析结果。",
|
||||
"responseRules": "1. 每个分析结论都必须有数据支撑,引用具体数字\n2. 使用 Markdown 表格和列表清晰呈现排行和对比\n3. 在数据分析后给出可执行的运营建议\n4. 主动发现数据中的异常点和有趣模式\n5. 如果用户问题模糊,主动推荐合适的分析维度",
|
||||
"presetQuestions": [
|
||||
"本周最活跃的成员 Top 10 是谁?",
|
||||
"群里最近在讨论什么热门话题?",
|
||||
"分析一下群的活跃时段分布",
|
||||
"哪些成员最近发言明显减少了?"
|
||||
],
|
||||
"allowedBuiltinTools": [
|
||||
"get_member_stats",
|
||||
"get_time_stats",
|
||||
"search_messages",
|
||||
"get_group_members",
|
||||
"get_member_name_history",
|
||||
"search_sessions",
|
||||
"get_session_summaries"
|
||||
],
|
||||
"customSkills": [],
|
||||
"applicableChatTypes": ["group"],
|
||||
"supportedLocales": ["zh"]
|
||||
}
|
||||
28
electron/main/ai/assistant/builtins/customer_service.json
Normal file
28
electron/main/ai/assistant/builtins/customer_service.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"id": "customer_service",
|
||||
"name": "客服助手",
|
||||
"description": "专业的客服对话分析助手,帮助复盘客服记录、提取常见问题和优化话术。",
|
||||
"version": 1,
|
||||
"order": 4,
|
||||
"systemPrompt": "你是一位专业的客服对话分析专家,擅长从客服聊天记录中提炼有价值的信息。\n你的核心能力是:\n- 归纳和分类用户的常见问题(FAQ 提取)\n- 分析客服响应速度和服务质量\n- 识别未解决的问题和客户投诉\n- 提炼优秀的话术模板和应对策略\n- 发现服务流程中可以优化的环节\n\n你的回答风格应该专业、条理清晰,注重可操作性。",
|
||||
"responseRules": "1. 分析结果按优先级和重要性排列\n2. 归纳问题时使用分类标签,便于后续跟进\n3. 给出具体可执行的改进建议\n4. 引用原始对话作为证据支撑\n5. 使用结构化格式(表格、编号列表)呈现结果",
|
||||
"presetQuestions": [
|
||||
"最近客户最常问的问题有哪些?",
|
||||
"有没有未解决的客户问题?",
|
||||
"帮我分析最近的客服对话质量",
|
||||
"提炼一些优秀的客服话术模板"
|
||||
],
|
||||
"allowedBuiltinTools": [
|
||||
"search_messages",
|
||||
"get_recent_messages",
|
||||
"get_message_context",
|
||||
"get_group_members",
|
||||
"get_conversation_between",
|
||||
"search_sessions",
|
||||
"get_session_messages",
|
||||
"get_session_summaries"
|
||||
],
|
||||
"customSkills": [],
|
||||
"applicableChatTypes": ["private"],
|
||||
"supportedLocales": ["zh"]
|
||||
}
|
||||
25
electron/main/ai/assistant/builtins/emotion_analyst.json
Normal file
25
electron/main/ai/assistant/builtins/emotion_analyst.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"id": "emotion_analyst",
|
||||
"name": "情感助手",
|
||||
"description": "专注于分析聊天中的情感变化、人际关系和互动模式的温暖助手。",
|
||||
"version": 1,
|
||||
"order": 3,
|
||||
"systemPrompt": "你是一位善于洞察人际关系和情感变化的温暖助手。\n你的核心能力是:\n- 分析聊天中的情感色彩和情绪变化\n- 识别成员之间的互动关系和亲密度\n- 发现对话中的冲突、和解、默契等情感模式\n- 帮助用户回顾与特定好友的珍贵对话回忆\n- 从聊天记录中发现温暖的、有趣的、值得纪念的瞬间\n\n你的语气应该温暖、共情,像一位善解人意的朋友。",
|
||||
"responseRules": "1. 分析情感时要细腻具体,引用关键对话片段\n2. 保持温暖积极的语气,避免过度解读或负面判断\n3. 尊重隐私,不对敏感内容过度挖掘\n4. 用讲故事的方式呈现发现,让用户感到有温度\n5. 适当使用 emoji 增加亲和力",
|
||||
"presetQuestions": [
|
||||
"我和好友最近聊了些什么开心的事?",
|
||||
"群里谁和谁互动最频繁?",
|
||||
"帮我找找群里最温暖的对话",
|
||||
"分析一下我和对方最近的聊天情绪变化"
|
||||
],
|
||||
"allowedBuiltinTools": [
|
||||
"search_messages",
|
||||
"get_recent_messages",
|
||||
"get_message_context",
|
||||
"get_group_members",
|
||||
"get_conversation_between",
|
||||
"get_member_stats"
|
||||
],
|
||||
"customSkills": [],
|
||||
"supportedLocales": ["zh"]
|
||||
}
|
||||
18
electron/main/ai/assistant/builtins/general.json
Normal file
18
electron/main/ai/assistant/builtins/general.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "general",
|
||||
"name": "通用分析助手",
|
||||
"description": "全能聊天记录分析助手,适合各种问题。风格轻松专业,适度使用网络热梗活跃气氛。",
|
||||
"version": 1,
|
||||
"order": 1,
|
||||
"systemPrompt": "你是一个专业但风格轻松的聊天记录分析助手。\n你的任务是帮助用户理解和分析他们的聊天记录数据,同时可以适度使用网络热梗和表情/颜文字活跃气氛,但不影响结论的准确性。",
|
||||
"responseRules": "1. 基于工具返回的数据回答,不要编造信息\n2. 如果数据不足以回答问题,请说明\n3. 回答要简洁明了,使用 Markdown 格式\n4. 可以引用具体的发言作为证据\n5. 对于统计数据,可以适当总结趋势和特点\n6. 可以适度加入网络热梗、表情/颜文字(强度适中)\n7. 玩梗不得影响事实准确与结论清晰,避免低俗或冒犯性表达",
|
||||
"presetQuestions": [
|
||||
"最近大家都在聊什么?",
|
||||
"谁是群里最活跃的人?",
|
||||
"帮我搜索关于「旅游」的聊天记录",
|
||||
"分析一下群的活跃时间段"
|
||||
],
|
||||
"allowedBuiltinTools": [],
|
||||
"customSkills": [],
|
||||
"supportedLocales": ["zh"]
|
||||
}
|
||||
16
electron/main/ai/assistant/index.ts
Normal file
16
electron/main/ai/assistant/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 助手模块入口
|
||||
*/
|
||||
|
||||
export * from './types'
|
||||
export {
|
||||
initAssistantManager,
|
||||
getAllAssistants,
|
||||
getAssistantConfig,
|
||||
hasAssistant,
|
||||
updateAssistant,
|
||||
createAssistant,
|
||||
deleteAssistant,
|
||||
resetAssistant,
|
||||
backupOldPromptPresets,
|
||||
} from './manager'
|
||||
351
electron/main/ai/assistant/manager.ts
Normal file
351
electron/main/ai/assistant/manager.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* 助手管理器
|
||||
* 负责助手配置的加载、CRUD、版本比对更新和内置助手同步
|
||||
*
|
||||
* 存储策略:
|
||||
* - 内置助手打包在 electron/main/ai/assistant/builtins/ 中
|
||||
* - 首次启动时复制到 {userData}/data/ai/assistants/
|
||||
* - 用户可修改,修改后标记 isUserModified = true
|
||||
* - 应用更新时,未被用户修改的内置助手自动更新为新版本
|
||||
*/
|
||||
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { getAiDataDir, ensureDir } from '../../paths'
|
||||
import { aiLogger } from '../logger'
|
||||
import type { AssistantConfig, AssistantSummary, AssistantSyncResult, AssistantSaveResult } from './types'
|
||||
|
||||
// 直接 import 内置助手 JSON(构建时嵌入 bundle,无需运行时文件系统读取)
|
||||
import builtinGeneral from './builtins/general.json'
|
||||
import builtinCommunityAnalyst from './builtins/community_analyst.json'
|
||||
import builtinEmotionAnalyst from './builtins/emotion_analyst.json'
|
||||
import builtinCustomerService from './builtins/customer_service.json'
|
||||
|
||||
const BUILTIN_CONFIGS: AssistantConfig[] = [
|
||||
builtinGeneral as AssistantConfig,
|
||||
builtinCommunityAnalyst as AssistantConfig,
|
||||
builtinEmotionAnalyst as AssistantConfig,
|
||||
builtinCustomerService as AssistantConfig,
|
||||
]
|
||||
|
||||
const ASSISTANTS_DIR_NAME = 'assistants'
|
||||
|
||||
let cachedAssistants: Map<string, AssistantConfig> = new Map()
|
||||
let initialized = false
|
||||
|
||||
/**
|
||||
* 获取用户助手配置目录
|
||||
*/
|
||||
function getAssistantsDir(): string {
|
||||
return path.join(getAiDataDir(), ASSISTANTS_DIR_NAME)
|
||||
}
|
||||
|
||||
// ==================== 初始化与同步 ====================
|
||||
|
||||
/**
|
||||
* 初始化助手管理器
|
||||
* - 确保目录存在
|
||||
* - 同步内置助手到用户目录
|
||||
* - 加载所有助手配置
|
||||
*/
|
||||
export function initAssistantManager(): AssistantSyncResult {
|
||||
const assistantsDir = getAssistantsDir()
|
||||
ensureDir(assistantsDir)
|
||||
|
||||
const syncResult = syncBuiltinAssistants()
|
||||
loadAllAssistants()
|
||||
|
||||
initialized = true
|
||||
aiLogger.info('AssistantManager', 'Initialized', {
|
||||
total: cachedAssistants.size,
|
||||
...syncResult,
|
||||
})
|
||||
|
||||
return syncResult
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步内置助手到用户目录
|
||||
* - 新增的内置助手:复制到用户目录
|
||||
* - 已有且未修改:如果版本更高则更新
|
||||
* - 已有且已修改:跳过
|
||||
*/
|
||||
function syncBuiltinAssistants(): AssistantSyncResult {
|
||||
const result: AssistantSyncResult = { total: 0, added: 0, updated: 0, skipped: 0 }
|
||||
|
||||
for (const builtinConfig of BUILTIN_CONFIGS) {
|
||||
try {
|
||||
if (!builtinConfig || !builtinConfig.id) continue
|
||||
|
||||
const userFilePath = path.join(getAssistantsDir(), `${builtinConfig.id}.json`)
|
||||
|
||||
if (!fs.existsSync(userFilePath)) {
|
||||
const configToWrite: AssistantConfig = {
|
||||
...builtinConfig,
|
||||
builtinId: builtinConfig.id,
|
||||
isUserModified: false,
|
||||
}
|
||||
writeJsonFile(userFilePath, configToWrite)
|
||||
result.added++
|
||||
} else {
|
||||
const userConfig = readJsonFile<AssistantConfig>(userFilePath)
|
||||
if (!userConfig) continue
|
||||
|
||||
if (userConfig.isUserModified) {
|
||||
result.skipped++
|
||||
} else if (builtinConfig.version > (userConfig.version || 0)) {
|
||||
const configToWrite: AssistantConfig = {
|
||||
...builtinConfig,
|
||||
builtinId: builtinConfig.id,
|
||||
isUserModified: false,
|
||||
}
|
||||
writeJsonFile(userFilePath, configToWrite)
|
||||
result.updated++
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
aiLogger.warn('AssistantManager', `Failed to sync builtin: ${builtinConfig.id}`, { error: String(error) })
|
||||
}
|
||||
}
|
||||
|
||||
result.total = BUILTIN_CONFIGS.length
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 从用户目录加载所有助手配置到内存缓存
|
||||
*/
|
||||
function loadAllAssistants(): void {
|
||||
cachedAssistants.clear()
|
||||
|
||||
const assistantsDir = getAssistantsDir()
|
||||
if (!fs.existsSync(assistantsDir)) return
|
||||
|
||||
const files = fs.readdirSync(assistantsDir).filter((f) => f.endsWith('.json'))
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const config = readJsonFile<AssistantConfig>(path.join(assistantsDir, file))
|
||||
if (config && config.id) {
|
||||
cachedAssistants.set(config.id, config)
|
||||
}
|
||||
} catch (error) {
|
||||
aiLogger.warn('AssistantManager', `Failed to load assistant: ${file}`, { error: String(error) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 查询 API ====================
|
||||
|
||||
/**
|
||||
* 获取所有助手的摘要列表(用于前端展示)
|
||||
* 按 order 排序,order 相同时按名称排序
|
||||
*/
|
||||
export function getAllAssistants(): AssistantSummary[] {
|
||||
ensureInitialized()
|
||||
|
||||
return Array.from(cachedAssistants.values())
|
||||
.sort((a, b) => {
|
||||
const orderDiff = (a.order ?? 100) - (b.order ?? 100)
|
||||
if (orderDiff !== 0) return orderDiff
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
.map(toSummary)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个助手的完整配置
|
||||
*/
|
||||
export function getAssistantConfig(id: string): AssistantConfig | null {
|
||||
ensureInitialized()
|
||||
return cachedAssistants.get(id) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查助手是否存在
|
||||
*/
|
||||
export function hasAssistant(id: string): boolean {
|
||||
ensureInitialized()
|
||||
return cachedAssistants.has(id)
|
||||
}
|
||||
|
||||
// ==================== 修改 API ====================
|
||||
|
||||
/**
|
||||
* 更新助手配置(用于配置弹窗保存)
|
||||
*/
|
||||
export function updateAssistant(id: string, updates: Partial<AssistantConfig>): AssistantSaveResult {
|
||||
ensureInitialized()
|
||||
|
||||
const existing = cachedAssistants.get(id)
|
||||
if (!existing) {
|
||||
return { success: false, error: `Assistant not found: ${id}` }
|
||||
}
|
||||
|
||||
const updated: AssistantConfig = {
|
||||
...existing,
|
||||
...updates,
|
||||
id, // id 不可变
|
||||
}
|
||||
|
||||
// 如果是内置助手被修改,标记 isUserModified
|
||||
if (existing.builtinId) {
|
||||
updated.isUserModified = true
|
||||
}
|
||||
|
||||
return saveAssistantToDisk(updated)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建自定义助手
|
||||
*/
|
||||
export function createAssistant(config: Omit<AssistantConfig, 'id' | 'version'>): AssistantSaveResult & { id?: string } {
|
||||
ensureInitialized()
|
||||
|
||||
const id = `custom_${randomUUID().replace(/-/g, '').slice(0, 12)}`
|
||||
const newConfig: AssistantConfig = {
|
||||
...config,
|
||||
id,
|
||||
version: 1,
|
||||
builtinId: undefined,
|
||||
isUserModified: undefined,
|
||||
}
|
||||
|
||||
const result = saveAssistantToDisk(newConfig)
|
||||
return { ...result, id: result.success ? id : undefined }
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除助手
|
||||
* 内置助手不允许删除,只能重置
|
||||
*/
|
||||
export function deleteAssistant(id: string): AssistantSaveResult {
|
||||
ensureInitialized()
|
||||
|
||||
const existing = cachedAssistants.get(id)
|
||||
if (!existing) {
|
||||
return { success: false, error: `Assistant not found: ${id}` }
|
||||
}
|
||||
|
||||
if (existing.builtinId) {
|
||||
return { success: false, error: 'Cannot delete builtin assistant. Use resetAssistant() instead.' }
|
||||
}
|
||||
|
||||
try {
|
||||
const filePath = path.join(getAssistantsDir(), `${id}.json`)
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath)
|
||||
}
|
||||
cachedAssistants.delete(id)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置内置助手为出厂默认
|
||||
*/
|
||||
export function resetAssistant(id: string): AssistantSaveResult {
|
||||
ensureInitialized()
|
||||
|
||||
const existing = cachedAssistants.get(id)
|
||||
if (!existing?.builtinId) {
|
||||
return { success: false, error: 'Only builtin assistants can be reset' }
|
||||
}
|
||||
|
||||
const builtinConfig = BUILTIN_CONFIGS.find((c) => c.id === existing.builtinId)
|
||||
if (!builtinConfig) {
|
||||
return { success: false, error: `Builtin config not found: ${existing.builtinId}` }
|
||||
}
|
||||
|
||||
const resetConfig: AssistantConfig = {
|
||||
...builtinConfig,
|
||||
builtinId: builtinConfig.id,
|
||||
isUserModified: false,
|
||||
}
|
||||
|
||||
return saveAssistantToDisk(resetConfig)
|
||||
}
|
||||
|
||||
// ==================== 提示词预设迁移 ====================
|
||||
|
||||
/**
|
||||
* 备份旧的提示词预设数据到 data/backup 目录
|
||||
* 由前端在首次检测到旧数据时调用
|
||||
*/
|
||||
export function backupOldPromptPresets(data: {
|
||||
customPresets?: unknown[]
|
||||
builtinOverrides?: Record<string, unknown>
|
||||
remotePresetIds?: string[]
|
||||
}): { success: boolean; filePath?: string; error?: string } {
|
||||
try {
|
||||
const backupDir = path.join(getAiDataDir(), '..', 'backup')
|
||||
ensureDir(backupDir)
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
||||
const filePath = path.join(backupDir, `prompt-presets-${timestamp}.json`)
|
||||
|
||||
const backupContent = {
|
||||
backupTime: new Date().toISOString(),
|
||||
description: 'ChatLab 旧提示词预设系统备份(已被多助手系统替代)',
|
||||
...data,
|
||||
}
|
||||
|
||||
writeJsonFile(filePath, backupContent)
|
||||
aiLogger.info('AssistantManager', 'Old prompt presets backed up', { filePath })
|
||||
|
||||
return { success: true, filePath }
|
||||
} catch (error) {
|
||||
aiLogger.error('AssistantManager', 'Failed to backup prompt presets', { error: String(error) })
|
||||
return { success: false, error: String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 内部工具函数 ====================
|
||||
|
||||
function ensureInitialized(): void {
|
||||
if (!initialized) {
|
||||
initAssistantManager()
|
||||
}
|
||||
}
|
||||
|
||||
function toSummary(config: AssistantConfig): AssistantSummary {
|
||||
return {
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
description: config.description,
|
||||
presetQuestions: config.presetQuestions,
|
||||
order: config.order,
|
||||
builtinId: config.builtinId,
|
||||
isUserModified: config.isUserModified,
|
||||
applicableChatTypes: config.applicableChatTypes,
|
||||
supportedLocales: config.supportedLocales,
|
||||
}
|
||||
}
|
||||
|
||||
function saveAssistantToDisk(config: AssistantConfig): AssistantSaveResult {
|
||||
try {
|
||||
const filePath = path.join(getAssistantsDir(), `${config.id}.json`)
|
||||
writeJsonFile(filePath, config)
|
||||
cachedAssistants.set(config.id, config)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
aiLogger.error('AssistantManager', `Failed to save assistant: ${config.id}`, { error: String(error) })
|
||||
return { success: false, error: String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
function readJsonFile<T>(filePath: string): T | null {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8')
|
||||
return JSON.parse(content) as T
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonFile(filePath: string, data: unknown): void {
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8')
|
||||
}
|
||||
115
electron/main/ai/assistant/skillRunner.ts
Normal file
115
electron/main/ai/assistant/skillRunner.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* 声明式 SQL 技能运行器
|
||||
*
|
||||
* 将 CustomSkillDef JSON 配置转换为可执行的 AgentTool,
|
||||
* 通过 pluginQuery 执行参数化 SQL 并格式化结果。
|
||||
*/
|
||||
|
||||
import { Type, type TObject, type TProperties } from '@mariozechner/pi-ai'
|
||||
import type { AgentTool } from '@mariozechner/pi-agent-core'
|
||||
import type { ToolContext } from '../tools/types'
|
||||
import type { CustomSkillDef, JsonSchemaObject, JsonSchemaProperty } from './types'
|
||||
import * as workerManager from '../../worker/workerManager'
|
||||
|
||||
/**
|
||||
* 将简化 JSON Schema 对象转换为 TypeBox TObject
|
||||
*
|
||||
* 仅覆盖技能参数定义的常见类型(string / number / integer / boolean),
|
||||
* 足以满足声明式 SQL 技能的参数需求。
|
||||
*/
|
||||
export function jsonSchemaToTypeBox(schema: JsonSchemaObject): TObject<TProperties> {
|
||||
const props: TProperties = {}
|
||||
|
||||
for (const [key, prop] of Object.entries(schema.properties)) {
|
||||
const isRequired = schema.required?.includes(key) ?? false
|
||||
const opts: Record<string, unknown> = {}
|
||||
if (prop.description) opts.description = prop.description
|
||||
if (prop.default !== undefined) opts.default = prop.default
|
||||
|
||||
let typeBoxProp
|
||||
switch (prop.type) {
|
||||
case 'string':
|
||||
typeBoxProp = Type.String(opts)
|
||||
break
|
||||
case 'number':
|
||||
typeBoxProp = Type.Number(opts)
|
||||
break
|
||||
case 'integer':
|
||||
typeBoxProp = Type.Integer(opts)
|
||||
break
|
||||
case 'boolean':
|
||||
typeBoxProp = Type.Boolean(opts)
|
||||
break
|
||||
default:
|
||||
typeBoxProp = Type.String(opts)
|
||||
}
|
||||
|
||||
props[key] = isRequired ? typeBoxProp : Type.Optional(typeBoxProp)
|
||||
}
|
||||
|
||||
return Type.Object(props)
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据行格式化模板格式化单行数据
|
||||
* 模板使用 {columnName} 占位符
|
||||
*/
|
||||
function formatRow(template: string, row: Record<string, unknown>): string {
|
||||
return template.replace(/\{(\w+)\}/g, (_, col) => {
|
||||
const val = row[col]
|
||||
return val !== null && val !== undefined ? String(val) : ''
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 CustomSkillDef 创建可执行的 AgentTool
|
||||
*/
|
||||
export function createSkillTool(skill: CustomSkillDef, context: ToolContext): AgentTool<any> {
|
||||
const schema = jsonSchemaToTypeBox(skill.parameters)
|
||||
|
||||
return {
|
||||
name: skill.name,
|
||||
label: skill.name,
|
||||
description: skill.description,
|
||||
parameters: schema,
|
||||
execute: async (_toolCallId: string, params: Record<string, unknown>) => {
|
||||
// 构建命名参数对象(添加 @ 前缀)
|
||||
const namedParams: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
namedParams[`@${key}`] = value
|
||||
}
|
||||
|
||||
const rows = await workerManager.pluginQuery(
|
||||
context.sessionId,
|
||||
skill.execution.query,
|
||||
namedParams
|
||||
)
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
return { content: skill.execution.fallback }
|
||||
}
|
||||
|
||||
const lines: string[] = []
|
||||
|
||||
if (skill.execution.summaryTemplate) {
|
||||
lines.push(
|
||||
skill.execution.summaryTemplate.replace(/\{rowCount\}/g, String(rows.length))
|
||||
)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
lines.push(formatRow(skill.execution.rowTemplate, row as Record<string, unknown>))
|
||||
}
|
||||
|
||||
return { content: lines.join('\n') }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从技能定义列表批量创建 AgentTool 数组
|
||||
*/
|
||||
export function createSkillTools(skills: CustomSkillDef[], context: ToolContext): AgentTool<any>[] {
|
||||
return skills.map((skill) => createSkillTool(skill, context))
|
||||
}
|
||||
194
electron/main/ai/assistant/types.ts
Normal file
194
electron/main/ai/assistant/types.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 助手系统类型定义
|
||||
* 定义助手配置、声明式 SQL 技能等核心类型
|
||||
*/
|
||||
|
||||
// ==================== 助手配置 ====================
|
||||
|
||||
/**
|
||||
* 助手配置(JSON 配置文件的完整结构)
|
||||
*
|
||||
* 每个助手对应一个 JSON 文件,存储在 {userData}/data/ai/assistants/ 目录下。
|
||||
* 内置助手同时打包在应用 electron/main/ai/assistant/builtins/ 中,首次启动时复制到 userData。
|
||||
*/
|
||||
export interface AssistantConfig {
|
||||
/** 助手唯一标识 */
|
||||
id: string
|
||||
/** 助手显示名称 */
|
||||
name: string
|
||||
/** 助手简介 */
|
||||
description: string
|
||||
|
||||
/** 系统提示词(替代旧的 PromptConfig.roleDefinition) */
|
||||
systemPrompt: string
|
||||
/** 回答要求(替代旧的 PromptConfig.responseRules,可选) */
|
||||
responseRules?: string
|
||||
|
||||
/** 预设问题列表(前端展示,用户可点击直接发送) */
|
||||
presetQuestions: string[]
|
||||
|
||||
/**
|
||||
* 允许使用的内置工具名称白名单
|
||||
* - undefined / 空数组 = 全部内置工具可用
|
||||
* - 非空数组 = 仅列出的工具可用
|
||||
*/
|
||||
allowedBuiltinTools?: string[]
|
||||
|
||||
/** 声明式 SQL 技能(Phase 2) */
|
||||
customSkills?: CustomSkillDef[]
|
||||
|
||||
/** 配置版本号,用于内置助手的版本比对更新 */
|
||||
version: number
|
||||
/**
|
||||
* 内置助手来源标识
|
||||
* 非空 = 该配置派生自某个内置助手(值为内置助手的 id)
|
||||
*/
|
||||
builtinId?: string
|
||||
/** 用户是否修改过内置助手的默认值(用于版本更新时判断是否可以覆盖) */
|
||||
isUserModified?: boolean
|
||||
/** 助手排序权重(越小越靠前,默认 100) */
|
||||
order?: number
|
||||
|
||||
/**
|
||||
* 适用的聊天类型
|
||||
* - undefined / [] = 通用(群聊+私聊均适用)
|
||||
* - ['group'] = 仅群聊
|
||||
* - ['private'] = 仅私聊
|
||||
*/
|
||||
applicableChatTypes?: ('group' | 'private')[]
|
||||
|
||||
/**
|
||||
* 适用的语言/地区(前缀匹配,如 'zh' 匹配 'zh-CN'、'zh-TW')
|
||||
* - undefined / [] = 全语言通用
|
||||
* - ['zh'] = 仅中文用户
|
||||
* - ['en'] = 仅英文用户
|
||||
*/
|
||||
supportedLocales?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 传递给前端的助手摘要信息(不含 systemPrompt 等大字段)
|
||||
*/
|
||||
export interface AssistantSummary {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
presetQuestions: string[]
|
||||
order?: number
|
||||
builtinId?: string
|
||||
isUserModified?: boolean
|
||||
applicableChatTypes?: ('group' | 'private')[]
|
||||
supportedLocales?: string[]
|
||||
}
|
||||
|
||||
// ==================== 声明式 SQL 技能(Phase 2) ====================
|
||||
|
||||
/**
|
||||
* 自定义 SQL 技能定义
|
||||
*
|
||||
* 每个技能在 LLM 眼中是一个 Function Calling 工具,
|
||||
* 执行时通过参数化 SQL 查询数据库,将结果格式化为文本返回给 LLM。
|
||||
*/
|
||||
export interface CustomSkillDef {
|
||||
/** 技能名称(作为 Function Calling 的 tool name) */
|
||||
name: string
|
||||
/** 技能描述(作为 Function Calling 的 tool description) */
|
||||
description: string
|
||||
/**
|
||||
* 参数定义(标准 JSON Schema 格式)
|
||||
*
|
||||
* 示例:
|
||||
* ```json
|
||||
* {
|
||||
* "type": "object",
|
||||
* "properties": {
|
||||
* "days": { "type": "number", "description": "查询天数" }
|
||||
* },
|
||||
* "required": ["days"]
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* 运行时会通过 jsonSchemaToTypeBox() 转换为 TypeBox 格式,
|
||||
* 以满足 pi-agent-core AgentTool 的类型约束。
|
||||
*/
|
||||
parameters: JsonSchemaObject
|
||||
|
||||
/** 执行配置 */
|
||||
execution: SqlSkillExecution
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON Schema 对象类型(简化版,覆盖技能参数定义的常见场景)
|
||||
*/
|
||||
export interface JsonSchemaObject {
|
||||
type: 'object'
|
||||
properties: Record<string, JsonSchemaProperty>
|
||||
required?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON Schema 属性定义
|
||||
*/
|
||||
export interface JsonSchemaProperty {
|
||||
type: 'string' | 'number' | 'integer' | 'boolean'
|
||||
description?: string
|
||||
default?: unknown
|
||||
enum?: unknown[]
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL 技能执行配置
|
||||
*/
|
||||
export interface SqlSkillExecution {
|
||||
/** 执行类型(目前仅支持 sqlite) */
|
||||
type: 'sqlite'
|
||||
/**
|
||||
* 参数化 SQL 查询语句
|
||||
* - 使用命名参数 @paramName(对应 parameters 中的属性名)
|
||||
* - 必须是只读查询(better-sqlite3 的 stmt.readonly 会强制检查)
|
||||
*
|
||||
* 示例:
|
||||
* ```sql
|
||||
* SELECT sender_name, COUNT(*) as msg_count
|
||||
* FROM message
|
||||
* WHERE ts > unixepoch('now', '-' || @days || ' days')
|
||||
* GROUP BY sender_name
|
||||
* ORDER BY msg_count DESC
|
||||
* LIMIT 10
|
||||
* ```
|
||||
*/
|
||||
query: string
|
||||
/**
|
||||
* 行格式化模板,使用 {columnName} 占位符
|
||||
* 示例:'用户【{sender_name}】共发言 {msg_count} 次'
|
||||
*/
|
||||
rowTemplate: string
|
||||
/** 可选的汇总模板,在所有行之前输出(支持 {rowCount} 占位符) */
|
||||
summaryTemplate?: string
|
||||
/** 查询结果为空时返回的文本 */
|
||||
fallback: string
|
||||
}
|
||||
|
||||
// ==================== 助手管理器相关 ====================
|
||||
|
||||
/**
|
||||
* AssistantManager 初始化/同步的结果
|
||||
*/
|
||||
export interface AssistantSyncResult {
|
||||
/** 加载的助手总数 */
|
||||
total: number
|
||||
/** 新增的内置助手数 */
|
||||
added: number
|
||||
/** 自动更新的内置助手数(未被用户修改的) */
|
||||
updated: number
|
||||
/** 跳过更新的助手数(已被用户修改) */
|
||||
skipped: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 助手配置的保存/更新结果
|
||||
*/
|
||||
export interface AssistantSaveResult {
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
@@ -76,14 +76,24 @@ function getAiDb(): Database.Database {
|
||||
function migrateAiDatabase(db: Database.Database): void {
|
||||
try {
|
||||
// 获取 ai_message 表的列信息
|
||||
const tableInfo = db.pragma('table_info(ai_message)') as Array<{ name: string }>
|
||||
const columnNames = tableInfo.map((col) => col.name)
|
||||
const messageTableInfo = db.pragma('table_info(ai_message)') as Array<{ name: string }>
|
||||
const messageColumns = messageTableInfo.map((col) => col.name)
|
||||
|
||||
// 检查并添加 content_blocks 列
|
||||
if (!columnNames.includes('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')
|
||||
}
|
||||
|
||||
// 获取 ai_conversation 表的列信息
|
||||
const convTableInfo = db.pragma('table_info(ai_conversation)') as Array<{ name: string }>
|
||||
const convColumns = convTableInfo.map((col) => col.name)
|
||||
|
||||
// 检查并添加 assistant_id 列(旧对话默认归属 general 助手)
|
||||
if (!convColumns.includes('assistant_id')) {
|
||||
db.exec("ALTER TABLE ai_conversation ADD COLUMN assistant_id TEXT DEFAULT 'general'")
|
||||
console.log('[AI DB Migration] Adding assistant_id column to ai_conversation')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AI DB Migration] Migration failed:', error)
|
||||
}
|
||||
@@ -108,6 +118,7 @@ export interface AIConversation {
|
||||
id: string
|
||||
sessionId: string
|
||||
title: string | null
|
||||
assistantId: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
@@ -148,22 +159,23 @@ export interface AIMessage {
|
||||
/**
|
||||
* 创建新对话
|
||||
*/
|
||||
export function createConversation(sessionId: string, title?: string): AIConversation {
|
||||
export function createConversation(sessionId: string, title?: string, 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, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
INSERT INTO ai_conversation (id, session_id, title, assistant_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
).run(id, sessionId, title || null, now, now)
|
||||
).run(id, sessionId, title || null, assistantId || 'general', now, now)
|
||||
|
||||
return {
|
||||
id,
|
||||
sessionId,
|
||||
title: title || null,
|
||||
assistantId: assistantId || 'general',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
@@ -197,7 +209,7 @@ export function getConversations(sessionId: string): AIConversation[] {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id, session_id as sessionId, title, created_at as createdAt, updated_at as updatedAt
|
||||
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
|
||||
@@ -217,7 +229,7 @@ export function getConversation(conversationId: string): AIConversation | null {
|
||||
const row = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id, session_id as sessionId, title, created_at as createdAt, updated_at as updatedAt
|
||||
SELECT id, session_id as sessionId, title, assistant_id as assistantId, created_at as createdAt, updated_at as updatedAt
|
||||
FROM ai_conversation
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
@@ -207,13 +207,20 @@ function anonymizeMessageNames(messages: PreprocessableMessage[], ownerPlatformI
|
||||
* 根据配置动态过滤工具(如:语义搜索工具仅在启用 Embedding 时可用)
|
||||
* 根据当前 locale 动态翻译工具描述
|
||||
* 统一包装预处理层
|
||||
*
|
||||
* @param context 工具上下文
|
||||
* @param allowedTools 工具名称白名单(为空或 undefined 时返回全部工具)
|
||||
*/
|
||||
export function getAllTools(context: ToolContext): AgentTool<any>[] {
|
||||
const tools: AgentTool<any>[] = coreFactories.map((f) => f(context))
|
||||
export function getAllTools(context: ToolContext, allowedTools?: string[]): AgentTool<any>[] {
|
||||
let tools: AgentTool<any>[] = coreFactories.map((f) => f(context))
|
||||
|
||||
if (isEmbeddingEnabled()) {
|
||||
tools.push(createSemanticSearchMessages(context))
|
||||
}
|
||||
|
||||
if (allowedTools && allowedTools.length > 0) {
|
||||
tools = tools.filter((t) => allowedTools.includes(t.name))
|
||||
}
|
||||
|
||||
return tools.map(translateTool).map((t) => wrapWithPreprocessing(t, context))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user