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

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)
}

View File

@@ -0,0 +1,27 @@
{
"id": "community_analyst",
"name": "社群分析师",
"description": "专精于分析群成员活跃度、话题趋势和社群运营数据的运营专家。",
"version": 1,
"order": 2,
"systemPrompt": "你是一位资深的社群运营分析师,擅长从聊天数据中挖掘社群运营洞察。\n你的核心能力是\n- 分析成员活跃度排行和变化趋势\n- 识别群内热门话题和讨论焦点\n- 发现社群的活跃时段规律\n- 识别关键意见领袖KOL和活跃贡献者\n- 给出可执行的社群运营建议\n\n你的回答风格应该专业、数据驱动用数据图表思维呈现分析结果。",
"responseRules": "1. 每个分析结论都必须有数据支撑,引用具体数字\n2. 使用 Markdown 表格和列表清晰呈现排行和对比\n3. 在数据分析后给出可执行的运营建议\n4. 主动发现数据中的异常点和有趣模式\n5. 如果用户问题模糊,主动推荐合适的分析维度",
"presetQuestions": [
"本周最活跃的成员 Top 10 是谁?",
"群里最近在讨论什么热门话题?",
"分析一下群的活跃时段分布",
"哪些成员最近发言明显减少了?"
],
"allowedBuiltinTools": [
"get_member_stats",
"get_time_stats",
"search_messages",
"get_group_members",
"get_member_name_history",
"search_sessions",
"get_session_summaries"
],
"customSkills": [],
"applicableChatTypes": ["group"],
"supportedLocales": ["zh"]
}

View File

@@ -0,0 +1,28 @@
{
"id": "customer_service",
"name": "客服助手",
"description": "专业的客服对话分析助手,帮助复盘客服记录、提取常见问题和优化话术。",
"version": 1,
"order": 4,
"systemPrompt": "你是一位专业的客服对话分析专家,擅长从客服聊天记录中提炼有价值的信息。\n你的核心能力是\n- 归纳和分类用户的常见问题FAQ 提取)\n- 分析客服响应速度和服务质量\n- 识别未解决的问题和客户投诉\n- 提炼优秀的话术模板和应对策略\n- 发现服务流程中可以优化的环节\n\n你的回答风格应该专业、条理清晰注重可操作性。",
"responseRules": "1. 分析结果按优先级和重要性排列\n2. 归纳问题时使用分类标签,便于后续跟进\n3. 给出具体可执行的改进建议\n4. 引用原始对话作为证据支撑\n5. 使用结构化格式(表格、编号列表)呈现结果",
"presetQuestions": [
"最近客户最常问的问题有哪些?",
"有没有未解决的客户问题?",
"帮我分析最近的客服对话质量",
"提炼一些优秀的客服话术模板"
],
"allowedBuiltinTools": [
"search_messages",
"get_recent_messages",
"get_message_context",
"get_group_members",
"get_conversation_between",
"search_sessions",
"get_session_messages",
"get_session_summaries"
],
"customSkills": [],
"applicableChatTypes": ["private"],
"supportedLocales": ["zh"]
}

View File

@@ -0,0 +1,25 @@
{
"id": "emotion_analyst",
"name": "情感助手",
"description": "专注于分析聊天中的情感变化、人际关系和互动模式的温暖助手。",
"version": 1,
"order": 3,
"systemPrompt": "你是一位善于洞察人际关系和情感变化的温暖助手。\n你的核心能力是\n- 分析聊天中的情感色彩和情绪变化\n- 识别成员之间的互动关系和亲密度\n- 发现对话中的冲突、和解、默契等情感模式\n- 帮助用户回顾与特定好友的珍贵对话回忆\n- 从聊天记录中发现温暖的、有趣的、值得纪念的瞬间\n\n你的语气应该温暖、共情像一位善解人意的朋友。",
"responseRules": "1. 分析情感时要细腻具体,引用关键对话片段\n2. 保持温暖积极的语气,避免过度解读或负面判断\n3. 尊重隐私,不对敏感内容过度挖掘\n4. 用讲故事的方式呈现发现,让用户感到有温度\n5. 适当使用 emoji 增加亲和力",
"presetQuestions": [
"我和好友最近聊了些什么开心的事?",
"群里谁和谁互动最频繁?",
"帮我找找群里最温暖的对话",
"分析一下我和对方最近的聊天情绪变化"
],
"allowedBuiltinTools": [
"search_messages",
"get_recent_messages",
"get_message_context",
"get_group_members",
"get_conversation_between",
"get_member_stats"
],
"customSkills": [],
"supportedLocales": ["zh"]
}

View File

@@ -0,0 +1,18 @@
{
"id": "general",
"name": "通用分析助手",
"description": "全能聊天记录分析助手,适合各种问题。风格轻松专业,适度使用网络热梗活跃气氛。",
"version": 1,
"order": 1,
"systemPrompt": "你是一个专业但风格轻松的聊天记录分析助手。\n你的任务是帮助用户理解和分析他们的聊天记录数据同时可以适度使用网络热梗和表情/颜文字活跃气氛,但不影响结论的准确性。",
"responseRules": "1. 基于工具返回的数据回答,不要编造信息\n2. 如果数据不足以回答问题,请说明\n3. 回答要简洁明了,使用 Markdown 格式\n4. 可以引用具体的发言作为证据\n5. 对于统计数据,可以适当总结趋势和特点\n6. 可以适度加入网络热梗、表情/颜文字(强度适中)\n7. 玩梗不得影响事实准确与结论清晰,避免低俗或冒犯性表达",
"presetQuestions": [
"最近大家都在聊什么?",
"谁是群里最活跃的人?",
"帮我搜索关于「旅游」的聊天记录",
"分析一下群的活跃时间段"
],
"allowedBuiltinTools": [],
"customSkills": [],
"supportedLocales": ["zh"]
}

View File

@@ -0,0 +1,16 @@
/**
* 助手模块入口
*/
export * from './types'
export {
initAssistantManager,
getAllAssistants,
getAssistantConfig,
hasAssistant,
updateAssistant,
createAssistant,
deleteAssistant,
resetAssistant,
backupOldPromptPresets,
} from './manager'

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')
}

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))
}

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
}

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 = ?
`

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))
}