diff --git a/electron/main/ai/agent/index.ts b/electron/main/ai/agent/index.ts index 7d012dc..db8d674 100644 --- a/electron/main/ai/agent/index.ts +++ b/electron/main/ai/agent/index.ts @@ -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 { 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 { 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) } diff --git a/electron/main/ai/assistant/builtins/community_analyst.json b/electron/main/ai/assistant/builtins/community_analyst.json new file mode 100644 index 0000000..37ec818 --- /dev/null +++ b/electron/main/ai/assistant/builtins/community_analyst.json @@ -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"] +} diff --git a/electron/main/ai/assistant/builtins/customer_service.json b/electron/main/ai/assistant/builtins/customer_service.json new file mode 100644 index 0000000..39a62f0 --- /dev/null +++ b/electron/main/ai/assistant/builtins/customer_service.json @@ -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"] +} diff --git a/electron/main/ai/assistant/builtins/emotion_analyst.json b/electron/main/ai/assistant/builtins/emotion_analyst.json new file mode 100644 index 0000000..0b87cf3 --- /dev/null +++ b/electron/main/ai/assistant/builtins/emotion_analyst.json @@ -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"] +} diff --git a/electron/main/ai/assistant/builtins/general.json b/electron/main/ai/assistant/builtins/general.json new file mode 100644 index 0000000..e0dc34f --- /dev/null +++ b/electron/main/ai/assistant/builtins/general.json @@ -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"] +} diff --git a/electron/main/ai/assistant/index.ts b/electron/main/ai/assistant/index.ts new file mode 100644 index 0000000..c291f82 --- /dev/null +++ b/electron/main/ai/assistant/index.ts @@ -0,0 +1,16 @@ +/** + * 助手模块入口 + */ + +export * from './types' +export { + initAssistantManager, + getAllAssistants, + getAssistantConfig, + hasAssistant, + updateAssistant, + createAssistant, + deleteAssistant, + resetAssistant, + backupOldPromptPresets, +} from './manager' diff --git a/electron/main/ai/assistant/manager.ts b/electron/main/ai/assistant/manager.ts new file mode 100644 index 0000000..eedc324 --- /dev/null +++ b/electron/main/ai/assistant/manager.ts @@ -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 = 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(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(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): 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): 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 + 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(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') +} diff --git a/electron/main/ai/assistant/skillRunner.ts b/electron/main/ai/assistant/skillRunner.ts new file mode 100644 index 0000000..67b59c7 --- /dev/null +++ b/electron/main/ai/assistant/skillRunner.ts @@ -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 { + const props: TProperties = {} + + for (const [key, prop] of Object.entries(schema.properties)) { + const isRequired = schema.required?.includes(key) ?? false + const opts: Record = {} + 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 { + 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 { + const schema = jsonSchemaToTypeBox(skill.parameters) + + return { + name: skill.name, + label: skill.name, + description: skill.description, + parameters: schema, + execute: async (_toolCallId: string, params: Record) => { + // 构建命名参数对象(添加 @ 前缀) + const namedParams: Record = {} + 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)) + } + + return { content: lines.join('\n') } + }, + } +} + +/** + * 从技能定义列表批量创建 AgentTool 数组 + */ +export function createSkillTools(skills: CustomSkillDef[], context: ToolContext): AgentTool[] { + return skills.map((skill) => createSkillTool(skill, context)) +} diff --git a/electron/main/ai/assistant/types.ts b/electron/main/ai/assistant/types.ts new file mode 100644 index 0000000..f768ccb --- /dev/null +++ b/electron/main/ai/assistant/types.ts @@ -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 + 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 +} diff --git a/electron/main/ai/conversations.ts b/electron/main/ai/conversations.ts index 5b54164..271cdd8 100644 --- a/electron/main/ai/conversations.ts +++ b/electron/main/ai/conversations.ts @@ -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 = ? ` diff --git a/electron/main/ai/tools/index.ts b/electron/main/ai/tools/index.ts index aaaed5d..77f421f 100644 --- a/electron/main/ai/tools/index.ts +++ b/electron/main/ai/tools/index.ts @@ -207,13 +207,20 @@ function anonymizeMessageNames(messages: PreprocessableMessage[], ownerPlatformI * 根据配置动态过滤工具(如:语义搜索工具仅在启用 Embedding 时可用) * 根据当前 locale 动态翻译工具描述 * 统一包装预处理层 + * + * @param context 工具上下文 + * @param allowedTools 工具名称白名单(为空或 undefined 时返回全部工具) */ -export function getAllTools(context: ToolContext): AgentTool[] { - const tools: AgentTool[] = coreFactories.map((f) => f(context)) +export function getAllTools(context: ToolContext, allowedTools?: string[]): AgentTool[] { + let tools: AgentTool[] = 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)) } diff --git a/electron/main/ipc/ai.ts b/electron/main/ipc/ai.ts index b635f8a..84b9033 100644 --- a/electron/main/ipc/ai.ts +++ b/electron/main/ipc/ai.ts @@ -9,6 +9,8 @@ import { aiLogger, setDebugMode } from '../ai/logger' import { getLogsDir } from '../paths' import { Agent, type AgentStreamChunk, type PromptConfig } from '../ai/agent' import { getActiveConfig, buildPiModel } from '../ai/llm' +import * as assistantManager from '../ai/assistant' +import type { AssistantConfig } from '../ai/assistant/types' import { completeSimple, streamSimple, type TextContent as PiTextContent } from '@mariozechner/pi-ai' import { t } from '../i18n' import type { ToolContext } from '../ai/tools/types' @@ -114,6 +116,14 @@ function formatAIError(error: unknown): string { export function registerAIHandlers({ win }: IpcContext): void { console.log('[IPC] Registering AI handlers...') + // 初始化助手管理器(同步内置助手、加载用户助手) + try { + assistantManager.initAssistantManager() + console.log('[IPC] Assistant manager initialized') + } catch (error) { + console.error('[IPC] Failed to initialize assistant manager:', error) + } + // ==================== Debug 模式 ==================== ipcMain.on('app:setDebugMode', (_, enabled: boolean) => { @@ -127,9 +137,9 @@ export function registerAIHandlers({ win }: IpcContext): void { * 创建新的 AI 对话 * 参数契约与 preload / 数据层保持一致:(sessionId, title?) */ - ipcMain.handle('ai:createConversation', async (_, sessionId: string, title?: string) => { + ipcMain.handle('ai:createConversation', async (_, sessionId: string, title?: string, assistantId?: string) => { try { - return aiConversations.createConversation(sessionId, title) + return aiConversations.createConversation(sessionId, title, assistantId) } catch (error) { console.error('Failed to create AI conversation:', error) throw error @@ -559,6 +569,80 @@ export function registerAIHandlers({ win }: IpcContext): void { } ) + // ==================== 助手管理 API ==================== + + ipcMain.handle('assistant:getAll', async () => { + try { + return assistantManager.getAllAssistants() + } catch (error) { + console.error('Failed to get assistants:', error) + return [] + } + }) + + ipcMain.handle('assistant:getConfig', async (_, id: string) => { + try { + return assistantManager.getAssistantConfig(id) + } catch (error) { + console.error('Failed to get assistant config:', error) + return null + } + }) + + ipcMain.handle('assistant:update', async (_, id: string, updates: Partial) => { + try { + return assistantManager.updateAssistant(id, updates) + } catch (error) { + console.error('Failed to update assistant:', error) + return { success: false, error: String(error) } + } + }) + + ipcMain.handle( + 'assistant:create', + async (_, config: Omit) => { + try { + return assistantManager.createAssistant(config) + } catch (error) { + console.error('Failed to create assistant:', error) + return { success: false, error: String(error) } + } + } + ) + + ipcMain.handle('assistant:delete', async (_, id: string) => { + try { + return assistantManager.deleteAssistant(id) + } catch (error) { + console.error('Failed to delete assistant:', error) + return { success: false, error: String(error) } + } + }) + + ipcMain.handle('assistant:reset', async (_, id: string) => { + try { + return assistantManager.resetAssistant(id) + } catch (error) { + console.error('Failed to reset assistant:', error) + return { success: false, error: String(error) } + } + }) + + ipcMain.handle( + 'assistant:backupOldPresets', + async ( + _, + data: { customPresets?: unknown[]; builtinOverrides?: Record; remotePresetIds?: string[] } + ) => { + try { + return assistantManager.backupOldPromptPresets(data) + } catch (error) { + console.error('Failed to backup old presets:', error) + return { success: false, error: String(error) } + } + } + ) + // ==================== AI Agent API ==================== /** @@ -569,6 +653,7 @@ export function registerAIHandlers({ win }: IpcContext): void { * @param promptConfig 用户自定义提示词配置(可选) * @param locale 语言设置(可选,默认 'zh-CN') * @param maxHistoryRounds 前端用户配置的最大历史轮数(可选,每轮 = user + assistant = 2 条) + * @param assistantId 助手 ID(可选,传入时从 AssistantManager 获取配置) */ ipcMain.handle( 'agent:runStream', @@ -580,7 +665,8 @@ export function registerAIHandlers({ win }: IpcContext): void { chatType?: 'group' | 'private', promptConfig?: PromptConfig, locale?: string, - maxHistoryRounds?: number + maxHistoryRounds?: number, + assistantId?: string ) => { aiLogger.info('IPC', `Agent stream request received: ${requestId}`, { userMessage: userMessage.slice(0, 100), @@ -588,6 +674,7 @@ export function registerAIHandlers({ win }: IpcContext): void { conversationId: context.conversationId, chatType: chatType ?? 'group', hasPromptConfig: !!promptConfig, + assistantId: assistantId ?? '(none)', }) try { @@ -622,6 +709,15 @@ export function registerAIHandlers({ win }: IpcContext): void { : '(disabled)', }) + // 如果指定了 assistantId,从 AssistantManager 加载助手配置 + let assistantConfig: AssistantConfig | undefined + if (assistantId) { + assistantConfig = assistantManager.getAssistantConfig(assistantId) ?? undefined + if (!assistantConfig) { + aiLogger.warn('IPC', `Assistant not found: ${assistantId}, falling back to default`) + } + } + const agent = new Agent( context, piModel, @@ -629,7 +725,8 @@ export function registerAIHandlers({ win }: IpcContext): void { { abortSignal: abortController.signal, contextHistoryLimit }, chatType ?? 'group', promptConfig, - locale ?? 'zh-CN' + locale ?? 'zh-CN', + assistantConfig ) // 异步执行,通过事件发送流式数据 diff --git a/electron/main/worker/query/sql.ts b/electron/main/worker/query/sql.ts index 8aae29d..11db894 100644 --- a/electron/main/worker/query/sql.ts +++ b/electron/main/worker/query/sql.ts @@ -82,7 +82,11 @@ export function getSchema(sessionId: string): TableSchema[] { * - 强制 stmt.readonly 检查(better-sqlite3 原生特性) * - 参数化执行(防注入 + 预编译缓存) */ -export function executePluginQuery>(sessionId: string, sql: string, params: any[] = []): T[] { +export function executePluginQuery>( + sessionId: string, + sql: string, + params: any[] | Record = [] +): T[] { const db = openDatabase(sessionId) if (!db) { throw new Error('数据库不存在') @@ -95,8 +99,11 @@ export function executePluginQuery>(sessionId: string, s throw new Error('Plugin Security Violation: Only READ-ONLY statements are allowed.') } - // 参数化执行 - return stmt.all(...params) as T[] + // better-sqlite3 支持位置参数(数组展开)和命名参数(对象) + if (Array.isArray(params)) { + return stmt.all(...params) as T[] + } + return stmt.all(params) as T[] } /** diff --git a/electron/main/worker/workerManager.ts b/electron/main/worker/workerManager.ts index b03c0a4..caf26b5 100644 --- a/electron/main/worker/workerManager.ts +++ b/electron/main/worker/workerManager.ts @@ -237,7 +237,7 @@ export async function query(type: string, payload: any): Promise { export async function pluginQuery>( sessionId: string, sql: string, - params: any[] = [] + params: any[] | Record = [] ): Promise { return sendToWorker('pluginQuery', { sessionId, sql, params }, 120000) } diff --git a/electron/preload/apis/ai.ts b/electron/preload/apis/ai.ts index 2a7387a..ee4976c 100644 --- a/electron/preload/apis/ai.ts +++ b/electron/preload/apis/ai.ts @@ -22,6 +22,7 @@ export interface AIConversation { id: string sessionId: string title: string | null + assistantId: string createdAt: number updatedAt: number } @@ -416,8 +417,8 @@ export const aiApi = { /** * 创建 AI 对话 */ - createConversation: (sessionId: string, title?: string): Promise => { - return ipcRenderer.invoke('ai:createConversation', sessionId, title) + createConversation: (sessionId: string, title?: string, assistantId?: string): Promise => { + return ipcRenderer.invoke('ai:createConversation', sessionId, title, assistantId) }, /** @@ -661,6 +662,76 @@ export const llmApi = { }, } +// ==================== Assistant API ==================== + +export interface AssistantSummary { + id: string + name: string + description: string + presetQuestions: string[] + order?: number + builtinId?: string + isUserModified?: boolean + applicableChatTypes?: ('group' | 'private')[] + supportedLocales?: string[] +} + +export interface AssistantConfigFull { + id: string + name: string + description: string + systemPrompt: string + responseRules?: string + presetQuestions: string[] + allowedBuiltinTools?: string[] + customSkills?: unknown[] + version: number + builtinId?: string + isUserModified?: boolean + order?: number + applicableChatTypes?: ('group' | 'private')[] + supportedLocales?: string[] +} + +export const assistantApi = { + getAll: (): Promise => { + return ipcRenderer.invoke('assistant:getAll') + }, + + getConfig: (id: string): Promise => { + return ipcRenderer.invoke('assistant:getConfig', id) + }, + + update: ( + id: string, + updates: Partial + ): Promise<{ success: boolean; error?: string }> => { + return ipcRenderer.invoke('assistant:update', id, updates) + }, + + create: ( + config: Omit + ): Promise<{ success: boolean; id?: string; error?: string }> => { + return ipcRenderer.invoke('assistant:create', config) + }, + + delete: (id: string): Promise<{ success: boolean; error?: string }> => { + return ipcRenderer.invoke('assistant:delete', id) + }, + + reset: (id: string): Promise<{ success: boolean; error?: string }> => { + return ipcRenderer.invoke('assistant:reset', id) + }, + + backupOldPresets: (data: { + customPresets?: unknown[] + builtinOverrides?: Record + remotePresetIds?: string[] + }): Promise<{ success: boolean; filePath?: string; error?: string }> => { + return ipcRenderer.invoke('assistant:backupOldPresets', data) + }, +} + // ==================== Agent API ==================== export const agentApi = { @@ -680,7 +751,8 @@ export const agentApi = { chatType?: 'group' | 'private', promptConfig?: PromptConfig, locale?: string, - maxHistoryRounds?: number + maxHistoryRounds?: number, + assistantId?: string ): { requestId: string; promise: Promise<{ success: boolean; result?: AgentResult; error?: string }> } => { // 防御性处理:确保传给 IPC 的 context 是“可结构化克隆”的纯对象 // 避免调用方误传入响应式 Proxy(例如 Pinia/Vue state)导致 invoke 失败 @@ -759,7 +831,8 @@ export const agentApi = { chatType, promptConfig, locale, - maxHistoryRounds + maxHistoryRounds, + assistantId ) .then((result) => { console.log('[preload] Agent invoke 返回:', result) diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index 8bfa278..0280013 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -281,6 +281,7 @@ interface AIConversation { id: string sessionId: string title: string | null + assistantId: string createdAt: number updatedAt: number } @@ -356,7 +357,7 @@ interface AiApi { senderId?: number, keywords?: string[] ) => Promise<{ messages: SearchMessageResult[]; hasMore: boolean }> - createConversation: (sessionId: string, title?: string) => Promise + createConversation: (sessionId: string, title?: string, assistantId?: string) => Promise getConversations: (sessionId: string) => Promise getConversation: (conversationId: string) => Promise updateConversationTitle: (conversationId: string, title: string) => Promise @@ -670,11 +671,57 @@ interface AgentApi { chatType?: 'group' | 'private', promptConfig?: PromptConfig, locale?: string, - maxHistoryRounds?: number + maxHistoryRounds?: number, + assistantId?: string ) => { requestId: string; promise: Promise<{ success: boolean; result?: AgentResult; error?: string }> } abort: (requestId: string) => Promise<{ success: boolean; error?: string }> } +// ==================== 助手管理 ==================== + +interface AssistantSummary { + id: string + name: string + description: string + presetQuestions: string[] + order?: number + builtinId?: string + isUserModified?: boolean + applicableChatTypes?: ('group' | 'private')[] + supportedLocales?: string[] +} + +interface AssistantConfigFull { + id: string + name: string + description: string + systemPrompt: string + responseRules?: string + presetQuestions: string[] + allowedBuiltinTools?: string[] + customSkills?: unknown[] + version: number + builtinId?: string + isUserModified?: boolean + order?: number + applicableChatTypes?: ('group' | 'private')[] + supportedLocales?: string[] +} + +interface AssistantApi { + getAll: () => Promise + getConfig: (id: string) => Promise + update: (id: string, updates: Partial) => Promise<{ success: boolean; error?: string }> + create: (config: Omit) => Promise<{ success: boolean; id?: string; error?: string }> + delete: (id: string) => Promise<{ success: boolean; error?: string }> + reset: (id: string) => Promise<{ success: boolean; error?: string }> + backupOldPresets: (data: { + customPresets?: unknown[] + builtinOverrides?: Record + remotePresetIds?: string[] + }) => Promise<{ success: boolean; filePath?: string; error?: string }> +} + // Cache API 类型 interface CacheDirectoryInfo { id: string @@ -867,6 +914,7 @@ declare global { llmApi: LlmApi embeddingApi: EmbeddingApi agentApi: AgentApi + assistantApi: AssistantApi cacheApi: CacheApi networkApi: NetworkApi sessionApi: SessionApi @@ -884,6 +932,9 @@ export { EmbeddingServiceConfig, EmbeddingServiceConfigDisplay, AgentApi, + AssistantApi, + AssistantSummary, + AssistantConfigFull, CacheApi, NetworkApi, NlpApi, diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 5aae79b..c5ac59c 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -8,7 +8,7 @@ import { electronAPI } from '@electron-toolkit/preload' // 从拆分的模块导入 API import { extendedApi } from './apis/core' import { chatApi, mergeApi } from './apis/chat' -import { aiApi, llmApi, agentApi, embeddingApi } from './apis/ai' +import { aiApi, llmApi, agentApi, embeddingApi, assistantApi } from './apis/ai' import { nlpApi, networkApi, cacheApi, sessionApi } from './apis/utils' // Use `contextBridge` APIs to expose Electron APIs to @@ -24,6 +24,7 @@ if (process.contextIsolated) { contextBridge.exposeInMainWorld('llmApi', llmApi) contextBridge.exposeInMainWorld('agentApi', agentApi) contextBridge.exposeInMainWorld('embeddingApi', embeddingApi) + contextBridge.exposeInMainWorld('assistantApi', assistantApi) contextBridge.exposeInMainWorld('cacheApi', cacheApi) contextBridge.exposeInMainWorld('networkApi', networkApi) contextBridge.exposeInMainWorld('sessionApi', sessionApi) @@ -49,6 +50,8 @@ if (process.contextIsolated) { // @ts-ignore (define in dts) window.embeddingApi = embeddingApi // @ts-ignore (define in dts) + window.assistantApi = assistantApi + // @ts-ignore (define in dts) window.cacheApi = cacheApi // @ts-ignore (define in dts) window.networkApi = networkApi diff --git a/src/components/analysis/AIChat/AssistantCard.vue b/src/components/analysis/AIChat/AssistantCard.vue new file mode 100644 index 0000000..c68253f --- /dev/null +++ b/src/components/analysis/AIChat/AssistantCard.vue @@ -0,0 +1,50 @@ + + + diff --git a/src/components/analysis/AIChat/AssistantConfigModal.vue b/src/components/analysis/AIChat/AssistantConfigModal.vue new file mode 100644 index 0000000..475deeb --- /dev/null +++ b/src/components/analysis/AIChat/AssistantConfigModal.vue @@ -0,0 +1,480 @@ + + + diff --git a/src/components/analysis/AIChat/AssistantSelector.vue b/src/components/analysis/AIChat/AssistantSelector.vue new file mode 100644 index 0000000..970d70f --- /dev/null +++ b/src/components/analysis/AIChat/AssistantSelector.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/src/components/analysis/AIChat/ChatExplorer.vue b/src/components/analysis/AIChat/ChatExplorer.vue index 8751b07..1a11f6a 100644 --- a/src/components/analysis/AIChat/ChatExplorer.vue +++ b/src/components/analysis/AIChat/ChatExplorer.vue @@ -9,11 +9,16 @@ import AIThinkingIndicator from './AIThinkingIndicator.vue' import ChatStatusBar from './ChatStatusBar.vue' import { useAIChat } from '@/composables/useAIChat' import CaptureButton from '@/components/common/CaptureButton.vue' +import AssistantSelector from './AssistantSelector.vue' +import AssistantConfigModal from './AssistantConfigModal.vue' +import PresetQuestions from './PresetQuestions.vue' import { usePromptStore } from '@/stores/prompt' import { useSettingsStore } from '@/stores/settings' +import { useAssistantStore } from '@/stores/assistant' const { t } = useI18n() const settingsStore = useSettingsStore() +const assistantStore = useAssistantStore() // Props const props = defineProps<{ @@ -46,6 +51,16 @@ const { // Store const promptStore = usePromptStore() +// 助手选择状态 +const showAssistantSelector = ref(true) +const configModalVisible = ref(false) +const configModalAssistantId = ref(null) + +// 当前选中助手的预设问题 +const currentPresetQuestions = computed(() => { + return assistantStore.selectedAssistant?.presetQuestions ?? [] +}) + // 当前聊天类型 const currentChatType = computed(() => props.chatType ?? 'group') @@ -132,6 +147,35 @@ function generateWelcomeMessage() { return t('ai.chat.welcome.message', { sessionName: props.sessionName, configHint }) } +// 选择助手 +function handleSelectAssistant(id: string) { + assistantStore.selectAssistant(id) + showAssistantSelector.value = false + startNewConversation(generateWelcomeMessage()) +} + +// 打开助手配置弹窗 +function handleConfigureAssistant(id: string) { + configModalAssistantId.value = id + configModalVisible.value = true +} + +// 返回助手选择 +function handleBackToSelector() { + assistantStore.clearSelection() + showAssistantSelector.value = true +} + +// 助手配置保存后刷新列表 +async function handleAssistantConfigSaved() { + await assistantStore.loadAssistants() +} + +// 发送消息(包括从预设问题点击发送) +function handlePresetQuestion(question: string) { + handleSend(question) +} + // 发送消息 async function handleSend(content: string) { await sendMessage(content) @@ -194,10 +238,11 @@ async function handleLoadMore() { await loadMoreSourceMessages() } -// 选择对话 +// 选择对话(切换到已有对话时恢复其绑定的助手) async function handleSelectConversation(convId: string) { await loadConversation(convId) - scrollToBottom(true) // 切换对话时强制滚动到底部 + showAssistantSelector.value = false + scrollToBottom(true) } // 创建新对话 @@ -277,7 +322,7 @@ watch( diff --git a/src/components/analysis/AIChat/PresetQuestions.vue b/src/components/analysis/AIChat/PresetQuestions.vue new file mode 100644 index 0000000..cbb29c5 --- /dev/null +++ b/src/components/analysis/AIChat/PresetQuestions.vue @@ -0,0 +1,24 @@ + + + diff --git a/src/composables/useAIChat.ts b/src/composables/useAIChat.ts index de9c1d2..f7ed9fe 100644 --- a/src/composables/useAIChat.ts +++ b/src/composables/useAIChat.ts @@ -8,6 +8,7 @@ import { storeToRefs } from 'pinia' import { usePromptStore } from '@/stores/prompt' import { useSessionStore } from '@/stores/session' import { useSettingsStore } from '@/stores/settings' +import { useAssistantStore } from '@/stores/assistant' import type { TokenUsage, AgentRuntimeStatus } from '@electron/shared/types' // 工具调用记录 @@ -89,10 +90,11 @@ export function useAIChat( chatType: 'group' | 'private' = 'group', locale: string = 'zh-CN' ) { - // 获取 chat store 中的提示词配置和全局设置 + // 获取 store const promptStore = usePromptStore() const sessionStore = useSessionStore() const settingsStore = useSettingsStore() + const assistantStore = useAssistantStore() const { activePreset, aiGlobalSettings } = storeToRefs(promptStore) // 获取当前聊天类型对应的提示词配置(使用统一的激活预设) @@ -362,10 +364,13 @@ export function useAIChat( } try { + // 当前选中的助手 ID(如果有) + const currentAssistantId = assistantStore.selectedAssistantId ?? undefined + // 确保对话 ID 存在(数据流倒置:Agent 从 SQLite 读取历史,需要有效的 conversationId) if (!currentConversationId.value) { const title = content.slice(0, 50) + (content.length > 50 ? '...' : '') - const conversation = await window.aiApi.createConversation(sessionId, title) + const conversation = await window.aiApi.createConversation(sessionId, title, currentAssistantId) currentConversationId.value = conversation.id contextConversationId.value = conversation.id console.log('[AI] 提前创建对话:', conversation.id) @@ -533,12 +538,15 @@ export function useAIChat( } }, chatType, - { - roleDefinition: currentPromptConfig.value.roleDefinition, - responseRules: currentPromptConfig.value.responseRules, - }, + currentAssistantId + ? undefined + : { + roleDefinition: currentPromptConfig.value.roleDefinition, + responseRules: currentPromptConfig.value.responseRules, + }, locale, - maxHistoryRounds + maxHistoryRounds, + currentAssistantId ) // 存储 Agent 请求 ID(用于中止) @@ -650,9 +658,14 @@ export function useAIChat( async function loadConversation(conversationId: string): Promise { console.log('[AI] 加载对话历史,conversationId:', conversationId) try { + // 获取对话元信息以恢复助手绑定 + const conversation = await window.aiApi.getConversation(conversationId) + if (conversation?.assistantId) { + assistantStore.selectAssistant(conversation.assistantId) + } + const history = await window.aiApi.getMessages(conversationId) currentConversationId.value = conversationId - // 加载历史对话时,绑定到真实 conversationId,确保同一历史会话复用上下文时间线 contextConversationId.value = conversationId console.log( diff --git a/src/stores/assistant.ts b/src/stores/assistant.ts new file mode 100644 index 0000000..4beeb70 --- /dev/null +++ b/src/stores/assistant.ts @@ -0,0 +1,198 @@ +/** + * 助手管理 Store + * 管理助手列表缓存、当前选中助手、配置 CRUD + */ + +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' + +export interface AssistantSummary { + id: string + name: string + description: string + presetQuestions: string[] + order?: number + builtinId?: string + isUserModified?: boolean + applicableChatTypes?: ('group' | 'private')[] + supportedLocales?: string[] +} + +export interface AssistantConfigFull { + id: string + name: string + description: string + systemPrompt: string + responseRules?: string + presetQuestions: string[] + allowedBuiltinTools?: string[] + customSkills?: unknown[] + version: number + builtinId?: string + isUserModified?: boolean + order?: number + applicableChatTypes?: ('group' | 'private')[] + supportedLocales?: string[] +} + +export const useAssistantStore = defineStore('assistant', () => { + const assistants = ref([]) + const selectedAssistantId = ref(null) + const isLoaded = ref(false) + + /** 当前过滤条件 */ + const currentChatType = ref<'group' | 'private'>('group') + const currentLocale = ref('zh-CN') + + const selectedAssistant = computed(() => { + if (!selectedAssistantId.value) return null + return assistants.value.find((a) => a.id === selectedAssistantId.value) ?? null + }) + + /** 根据聊天类型和语言过滤后的助手列表 */ + const filteredAssistants = computed(() => { + return assistants.value.filter((a) => { + const typeMatch = + !a.applicableChatTypes?.length || a.applicableChatTypes.includes(currentChatType.value) + const localeMatch = + !a.supportedLocales?.length || + a.supportedLocales.some((l) => currentLocale.value.startsWith(l)) + return typeMatch && localeMatch + }) + }) + + /** 默认展示的前 N 个助手 */ + const defaultVisibleCount = 4 + + const defaultAssistants = computed(() => filteredAssistants.value.slice(0, defaultVisibleCount)) + + const moreAssistants = computed(() => filteredAssistants.value.slice(defaultVisibleCount)) + + const hasMoreAssistants = computed(() => filteredAssistants.value.length > defaultVisibleCount) + + function setFilterContext(chatType: 'group' | 'private', locale: string): void { + currentChatType.value = chatType + currentLocale.value = locale + } + + async function loadAssistants(): Promise { + try { + assistants.value = await window.assistantApi.getAll() + isLoaded.value = true + } catch (error) { + console.error('[AssistantStore] Failed to load assistants:', error) + } + } + + function selectAssistant(id: string): void { + selectedAssistantId.value = id + } + + function clearSelection(): void { + selectedAssistantId.value = null + } + + async function getAssistantConfig(id: string): Promise { + try { + return await window.assistantApi.getConfig(id) + } catch (error) { + console.error('[AssistantStore] Failed to get config:', error) + return null + } + } + + async function updateAssistant( + id: string, + updates: Partial + ): Promise<{ success: boolean; error?: string }> { + try { + const result = await window.assistantApi.update(id, updates) + if (result.success) { + await loadAssistants() + } + return result + } catch (error) { + return { success: false, error: String(error) } + } + } + + async function resetAssistant(id: string): Promise<{ success: boolean; error?: string }> { + try { + const result = await window.assistantApi.reset(id) + if (result.success) { + await loadAssistants() + } + return result + } catch (error) { + return { success: false, error: String(error) } + } + } + + const promptMigrationDone = ref(false) + + /** + * 检查并迁移旧提示词预设数据 + * 仅在首次检测到旧数据时执行,备份后标记为已完成 + */ + async function migrateOldPromptPresets(): Promise { + if (promptMigrationDone.value) return + + try { + const raw = localStorage.getItem('prompt') + if (!raw) { + promptMigrationDone.value = true + return + } + + const data = JSON.parse(raw) + const hasCustomPresets = Array.isArray(data.customPromptPresets) && data.customPromptPresets.length > 0 + const hasOverrides = data.builtinPresetOverrides && Object.keys(data.builtinPresetOverrides).length > 0 + const hasRemoteIds = Array.isArray(data.fetchedRemotePresetIds) && data.fetchedRemotePresetIds.length > 0 + + if (!hasCustomPresets && !hasOverrides && !hasRemoteIds) { + promptMigrationDone.value = true + return + } + + console.log('[AssistantStore] Backing up old prompt presets...') + const result = await window.assistantApi.backupOldPresets({ + customPresets: data.customPromptPresets, + builtinOverrides: data.builtinPresetOverrides, + remotePresetIds: data.fetchedRemotePresetIds, + }) + + if (result.success) { + console.log('[AssistantStore] Backup saved to:', result.filePath) + } else { + console.warn('[AssistantStore] Backup failed:', result.error) + } + + promptMigrationDone.value = true + } catch (error) { + console.error('[AssistantStore] Migration check failed:', error) + promptMigrationDone.value = true + } + } + + return { + assistants, + selectedAssistantId, + selectedAssistant, + isLoaded, + currentChatType, + currentLocale, + filteredAssistants, + defaultAssistants, + moreAssistants, + hasMoreAssistants, + promptMigrationDone, + loadAssistants, + selectAssistant, + clearSelection, + setFilterContext, + getAssistantConfig, + updateAssistant, + resetAssistant, + migrateOldPromptPresets, + } +})