feat: 完成助手模式初版

This commit is contained in:
digua
2026-03-02 01:19:54 +08:00
parent c6aaa733ae
commit f36623d72e
24 changed files with 2086 additions and 64 deletions
+36 -7
View File
@@ -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)
}
@@ -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"]
}
@@ -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"]
}
@@ -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"]
}
@@ -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
View File
@@ -0,0 +1,16 @@
/**
* 助手模块入口
*/
export * from './types'
export {
initAssistantManager,
getAllAssistants,
getAssistantConfig,
hasAssistant,
updateAssistant,
createAssistant,
deleteAssistant,
resetAssistant,
backupOldPromptPresets,
} from './manager'
+351
View 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
View 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
View 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
}
+21 -9
View File
@@ -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 = ?
`
+9 -2
View File
@@ -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))
}
+101 -4
View File
@@ -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<AssistantConfig>) => {
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<AssistantConfig, 'id' | 'version'>) => {
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<string, unknown>; 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
)
// 异步执行,通过事件发送流式数据
+10 -3
View File
@@ -82,7 +82,11 @@ export function getSchema(sessionId: string): TableSchema[] {
* - 强制 stmt.readonly 检查(better-sqlite3 原生特性)
* - 参数化执行(防注入 + 预编译缓存)
*/
export function executePluginQuery<T = Record<string, any>>(sessionId: string, sql: string, params: any[] = []): T[] {
export function executePluginQuery<T = Record<string, any>>(
sessionId: string,
sql: string,
params: any[] | Record<string, any> = []
): T[] {
const db = openDatabase(sessionId)
if (!db) {
throw new Error('数据库不存在')
@@ -95,8 +99,11 @@ export function executePluginQuery<T = Record<string, any>>(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[]
}
/**
+1 -1
View File
@@ -237,7 +237,7 @@ export async function query<T = any>(type: string, payload: any): Promise<T> {
export async function pluginQuery<T = Record<string, any>>(
sessionId: string,
sql: string,
params: any[] = []
params: any[] | Record<string, any> = []
): Promise<T[]> {
return sendToWorker('pluginQuery', { sessionId, sql, params }, 120000)
}
+77 -4
View File
@@ -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<AIConversation> => {
return ipcRenderer.invoke('ai:createConversation', sessionId, title)
createConversation: (sessionId: string, title?: string, assistantId?: string): Promise<AIConversation> => {
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<AssistantSummary[]> => {
return ipcRenderer.invoke('assistant:getAll')
},
getConfig: (id: string): Promise<AssistantConfigFull | null> => {
return ipcRenderer.invoke('assistant:getConfig', id)
},
update: (
id: string,
updates: Partial<AssistantConfigFull>
): Promise<{ success: boolean; error?: string }> => {
return ipcRenderer.invoke('assistant:update', id, updates)
},
create: (
config: Omit<AssistantConfigFull, 'id' | 'version'>
): 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<string, unknown>
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)
+53 -2
View File
@@ -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<AIConversation>
createConversation: (sessionId: string, title?: string, assistantId?: string) => Promise<AIConversation>
getConversations: (sessionId: string) => Promise<AIConversation[]>
getConversation: (conversationId: string) => Promise<AIConversation | null>
updateConversationTitle: (conversationId: string, title: string) => Promise<boolean>
@@ -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<AssistantSummary[]>
getConfig: (id: string) => Promise<AssistantConfigFull | null>
update: (id: string, updates: Partial<AssistantConfigFull>) => Promise<{ success: boolean; error?: string }>
create: (config: Omit<AssistantConfigFull, 'id' | 'version'>) => 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<string, unknown>
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,
+4 -1
View File
@@ -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