feat: 完善助手功能,新增分析tools

This commit is contained in:
digua
2026-03-10 20:42:25 +08:00
parent f36623d72e
commit 60be78b767
26 changed files with 1986 additions and 445 deletions
+4 -5
View File
@@ -6,7 +6,7 @@
import { getActiveConfig, buildPiModel } from '../llm'
import { getAllTools } from '../tools'
import type { ToolContext, OwnerInfo } from '../tools/types'
import { createSkillTools } from '../assistant/skillRunner'
import { createSqlTools } from '../assistant/sqlToolRunner'
import { getHistoryForAgent } from '../conversations'
import { aiLogger, isDebugMode } from '../logger'
import { t as i18nT } from '../../i18n'
@@ -155,10 +155,9 @@ export class Agent {
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)
// 用户自定义 SQL 工具(内置 SQL 工具已由 getAllTools 统一管控
if (this.assistantConfig?.customSqlTools?.length) {
piTools.push(...createSqlTools(this.assistantConfig.customSqlTools, toolContext))
}
coreAgent.setTools(maxToolRounds > 0 ? piTools : [])
@@ -0,0 +1,32 @@
/**
* 内置工具目录查询
*
* SQL 工具定义位于 tools/definitions/sql-analysis.ts
* TS 工具名称列表位于 tools/definitions/index.ts。
* 本模块仅提供前端展示所需的目录信息和名称查询。
*/
import type { BuiltinSqlToolInfo } from './types'
import { getSqlToolCatalog, SQL_TOOL_NAMES } from '../tools/definitions/sql-analysis'
import { TS_TOOL_NAMES } from '../tools/definitions'
/**
* 获取内置 SQL 工具的精简目录(供前端展示勾选列表)
*/
export function getBuiltinSqlToolCatalog(): BuiltinSqlToolInfo[] {
return getSqlToolCatalog()
}
/**
* 获取内置 TS 工具的名称列表(供前端展示勾选列表)
*/
export function getBuiltinTsToolNames(): string[] {
return TS_TOOL_NAMES
}
/**
* 检查名称是否为内置 SQL 工具
*/
export function isBuiltinSqlTool(name: string): boolean {
return SQL_TOOL_NAMES.includes(name)
}
@@ -1,7 +1,6 @@
{
"id": "community_analyst",
"name": "社群分析师",
"description": "专精于分析群成员活跃度、话题趋势和社群运营数据的运营专家。",
"version": 1,
"order": 2,
"systemPrompt": "你是一位资深的社群运营分析师,擅长从聊天数据中挖掘社群运营洞察。\n你的核心能力是:\n- 分析成员活跃度排行和变化趋势\n- 识别群内热门话题和讨论焦点\n- 发现社群的活跃时段规律\n- 识别关键意见领袖(KOL)和活跃贡献者\n- 给出可执行的社群运营建议\n\n你的回答风格应该专业、数据驱动,用数据图表思维呈现分析结果。",
@@ -19,9 +18,12 @@
"get_group_members",
"get_member_name_history",
"search_sessions",
"get_session_summaries"
"get_session_summaries",
"member_activity_trend",
"silent_members",
"reply_interaction_ranking"
],
"customSkills": [],
"customSqlTools": [],
"applicableChatTypes": ["group"],
"supportedLocales": ["zh"]
}
@@ -1,7 +1,6 @@
{
"id": "customer_service",
"name": "客服助手",
"description": "专业的客服对话分析助手,帮助复盘客服记录、提取常见问题和优化话术。",
"version": 1,
"order": 4,
"systemPrompt": "你是一位专业的客服对话分析专家,擅长从客服聊天记录中提炼有价值的信息。\n你的核心能力是:\n- 归纳和分类用户的常见问题(FAQ 提取)\n- 分析客服响应速度和服务质量\n- 识别未解决的问题和客户投诉\n- 提炼优秀的话术模板和应对策略\n- 发现服务流程中可以优化的环节\n\n你的回答风格应该专业、条理清晰,注重可操作性。",
@@ -20,9 +19,11 @@
"get_conversation_between",
"search_sessions",
"get_session_messages",
"get_session_summaries"
"get_session_summaries",
"unanswered_messages",
"message_type_distribution"
],
"customSkills": [],
"customSqlTools": [],
"applicableChatTypes": ["private"],
"supportedLocales": ["zh"]
}
@@ -1,7 +1,6 @@
{
"id": "emotion_analyst",
"name": "情感助手",
"description": "专注于分析聊天中的情感变化、人际关系和互动模式的温暖助手。",
"version": 1,
"order": 3,
"systemPrompt": "你是一位善于洞察人际关系和情感变化的温暖助手。\n你的核心能力是:\n- 分析聊天中的情感色彩和情绪变化\n- 识别成员之间的互动关系和亲密度\n- 发现对话中的冲突、和解、默契等情感模式\n- 帮助用户回顾与特定好友的珍贵对话回忆\n- 从聊天记录中发现温暖的、有趣的、值得纪念的瞬间\n\n你的语气应该温暖、共情,像一位善解人意的朋友。",
@@ -18,8 +17,10 @@
"get_message_context",
"get_group_members",
"get_conversation_between",
"get_member_stats"
"get_member_stats",
"mutual_interaction_pairs",
"member_message_length_stats"
],
"customSkills": [],
"customSqlTools": [],
"supportedLocales": ["zh"]
}
@@ -1,7 +1,6 @@
{
"id": "general",
"name": "通用分析助手",
"description": "全能聊天记录分析助手,适合各种问题。风格轻松专业,适度使用网络热梗活跃气氛。",
"version": 1,
"order": 1,
"systemPrompt": "你是一个专业但风格轻松的聊天记录分析助手。\n你的任务是帮助用户理解和分析他们的聊天记录数据,同时可以适度使用网络热梗和表情/颜文字活跃气氛,但不影响结论的准确性。",
@@ -13,6 +12,6 @@
"分析一下群的活跃时间段"
],
"allowedBuiltinTools": [],
"customSkills": [],
"customSqlTools": [],
"supportedLocales": ["zh"]
}
+4
View File
@@ -12,5 +12,9 @@ export {
createAssistant,
deleteAssistant,
resetAssistant,
getBuiltinCatalog,
importAssistant,
reimportAssistant,
backupOldPromptPresets,
} from './manager'
export { getBuiltinSqlToolCatalog, getBuiltinTsToolNames } from './builtinSqlTools'
+127 -78
View File
@@ -1,12 +1,13 @@
/**
* 助手管理器
* 负责助手配置的加载、CRUD、版本比对更新和内置助手同步
* 负责助手配置的加载、CRUD 和内置助手导入
*
* 存储策略:
* - 内置助手打包在 electron/main/ai/assistant/builtins/
* - 首次启动时复制到 {userData}/data/ai/assistants/
* - 用户可修改,修改后标记 isUserModified = true
* - 应用更新时,未被用户修改的内置助手自动更新为新版本
* 存储策略(导入模型)
* - 内置助手作为模板目录打包在 BUILTIN_CONFIGS
* - 启动时仅自动导入 general 助手
* - 用户通过"助手市场"主动导入其他内置助手
* - 导入后完全属于用户,可自由编辑/删除(general 除外)
* - 市场可查看内置助手是否有新版本,用户可手动重新导入
*/
import * as fs from 'fs'
@@ -14,9 +15,14 @@ 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 type {
AssistantConfig,
AssistantSummary,
AssistantInitResult,
AssistantSaveResult,
BuiltinAssistantInfo,
} 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'
@@ -34,83 +40,50 @@ 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)
}
// ==================== 初始化与同步 ====================
// ==================== 初始化 ====================
/**
* 初始化助手管理器
* - 确保目录存在
* - 同步内置助手到用户目录
* - 加载所有助手配置
* - 确保 general 助手已导入
* - 加载所有用户助手配置
*/
export function initAssistantManager(): AssistantSyncResult {
export function initAssistantManager(): AssistantInitResult {
const assistantsDir = getAssistantsDir()
ensureDir(assistantsDir)
const syncResult = syncBuiltinAssistants()
const generalCreated = ensureGeneralAssistant()
loadAllAssistants()
initialized = true
aiLogger.info('AssistantManager', 'Initialized', {
total: cachedAssistants.size,
...syncResult,
generalCreated,
})
return syncResult
return { total: cachedAssistants.size, generalCreated }
}
/**
* 同步内置助手到用户目录
* - 新增的内置助手:复制到用户目录
* - 已有且未修改:如果版本更高则更新
* - 已有且已修改:跳过
* 确保 general 助手存在于用户目录(首次启动自动导入)
*/
function syncBuiltinAssistants(): AssistantSyncResult {
const result: AssistantSyncResult = { total: 0, added: 0, updated: 0, skipped: 0 }
function ensureGeneralAssistant(): boolean {
const generalConfig = BUILTIN_CONFIGS.find((c) => c.id === 'general')
if (!generalConfig) return false
for (const builtinConfig of BUILTIN_CONFIGS) {
try {
if (!builtinConfig || !builtinConfig.id) continue
const userFilePath = path.join(getAssistantsDir(), 'general.json')
if (fs.existsSync(userFilePath)) return false
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) })
}
const configToWrite: AssistantConfig = {
...generalConfig,
builtinId: generalConfig.id,
}
result.total = BUILTIN_CONFIGS.length
return result
writeJsonFile(userFilePath, configToWrite)
return true
}
/**
@@ -139,8 +112,7 @@ function loadAllAssistants(): void {
// ==================== 查询 API ====================
/**
* 获取所有助手的摘要列表(用于前端展示)
* 按 order 排序,order 相同时按名称排序
* 获取所有已导入助手的摘要列表(用于前端展示)
*/
export function getAllAssistants(): AssistantSummary[] {
ensureInitialized()
@@ -170,6 +142,87 @@ export function hasAssistant(id: string): boolean {
return cachedAssistants.has(id)
}
// ==================== 内置助手目录(市场) ====================
/**
* 获取内置助手模板目录(用于助手市场展示)
* 对每个内置助手检查用户是否已导入、是否有版本更新
*/
export function getBuiltinCatalog(): BuiltinAssistantInfo[] {
ensureInitialized()
return BUILTIN_CONFIGS.map((builtin) => {
const userAssistant = findImportedByBuiltinId(builtin.id)
const imported = !!userAssistant
const hasUpdate = imported && builtin.version > (userAssistant!.version || 0)
return {
id: builtin.id,
name: builtin.name,
systemPrompt: builtin.systemPrompt,
version: builtin.version,
order: builtin.order,
applicableChatTypes: builtin.applicableChatTypes,
supportedLocales: builtin.supportedLocales,
imported,
hasUpdate,
}
})
}
/**
* 从内置模板导入助手到用户目录
* - 同一 builtinId 不可重复导入
*/
export function importAssistant(builtinId: string): AssistantSaveResult {
ensureInitialized()
const builtinConfig = BUILTIN_CONFIGS.find((c) => c.id === builtinId)
if (!builtinConfig) {
return { success: false, error: `Builtin assistant not found: ${builtinId}` }
}
const existing = findImportedByBuiltinId(builtinId)
if (existing) {
return { success: false, error: `Assistant already imported: ${builtinId}` }
}
const newConfig: AssistantConfig = {
...builtinConfig,
builtinId: builtinConfig.id,
}
return saveAssistantToDisk(newConfig)
}
/**
* 重新导入内置助手(覆盖用户副本为最新模板版本,保留 id)
*/
export function reimportAssistant(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: 'Only imported builtin assistants can be reimported' }
}
const builtinConfig = BUILTIN_CONFIGS.find((c) => c.id === existing.builtinId)
if (!builtinConfig) {
return { success: false, error: `Builtin template not found: ${existing.builtinId}` }
}
const updatedConfig: AssistantConfig = {
...builtinConfig,
id: existing.id,
builtinId: existing.builtinId,
}
return saveAssistantToDisk(updatedConfig)
}
// ==================== 修改 API ====================
/**
@@ -186,12 +239,7 @@ export function updateAssistant(id: string, updates: Partial<AssistantConfig>):
const updated: AssistantConfig = {
...existing,
...updates,
id, // id 不可变
}
// 如果是内置助手被修改,标记 isUserModified
if (existing.builtinId) {
updated.isUserModified = true
id,
}
return saveAssistantToDisk(updated)
@@ -209,7 +257,6 @@ export function createAssistant(config: Omit<AssistantConfig, 'id' | 'version'>)
id,
version: 1,
builtinId: undefined,
isUserModified: undefined,
}
const result = saveAssistantToDisk(newConfig)
@@ -218,20 +265,20 @@ export function createAssistant(config: Omit<AssistantConfig, 'id' | 'version'>)
/**
* 删除助手
* 内置助手不允许删除,只能重置
* general 助手不删除,其他导入的内置助手可以删除
*/
export function deleteAssistant(id: string): AssistantSaveResult {
ensureInitialized()
if (id === 'general') {
return { success: false, error: 'Cannot delete the default assistant (general)' }
}
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)) {
@@ -262,8 +309,8 @@ export function resetAssistant(id: string): AssistantSaveResult {
const resetConfig: AssistantConfig = {
...builtinConfig,
builtinId: builtinConfig.id,
isUserModified: false,
id: existing.id,
builtinId: existing.builtinId,
}
return saveAssistantToDisk(resetConfig)
@@ -273,7 +320,6 @@ export function resetAssistant(id: string): AssistantSaveResult {
/**
* 备份旧的提示词预设数据到 data/backup 目录
* 由前端在首次检测到旧数据时调用
*/
export function backupOldPromptPresets(data: {
customPresets?: unknown[]
@@ -311,15 +357,18 @@ function ensureInitialized(): void {
}
}
function findImportedByBuiltinId(builtinId: string): AssistantConfig | undefined {
return Array.from(cachedAssistants.values()).find((c) => c.builtinId === builtinId)
}
function toSummary(config: AssistantConfig): AssistantSummary {
return {
id: config.id,
name: config.name,
description: config.description,
systemPrompt: config.systemPrompt,
presetQuestions: config.presetQuestions,
order: config.order,
builtinId: config.builtinId,
isUserModified: config.isUserModified,
applicableChatTypes: config.applicableChatTypes,
supportedLocales: config.supportedLocales,
}
@@ -1,21 +1,21 @@
/**
* SQL
* SQL
*
* CustomSkillDef JSON AgentTool
* CustomSqlToolDef 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 type { CustomSqlToolDef, JsonSchemaObject } from './types'
import * as workerManager from '../../worker/workerManager'
import { t as i18nT } from '../../i18n'
/**
* JSON Schema TypeBox TObject
*
* string / number / integer / boolean
* SQL
* SQL string / number / integer / boolean
*/
export function jsonSchemaToTypeBox(schema: JsonSchemaObject): TObject<TProperties> {
const props: TProperties = {}
@@ -50,10 +50,6 @@ export function jsonSchemaToTypeBox(schema: JsonSchemaObject): TObject<TProperti
return Type.Object(props)
}
/**
*
* 使 {columnName}
*/
function formatRow(template: string, row: Record<string, unknown>): string {
return template.replace(/\{(\w+)\}/g, (_, col) => {
const val = row[col]
@@ -62,54 +58,64 @@ function formatRow(template: string, row: Record<string, unknown>): string {
}
/**
* CustomSkillDef AgentTool
* Resolve an i18n template string for a SQL tool, falling back to the definition's original value.
*/
export function createSkillTool(skill: CustomSkillDef, context: ToolContext): AgentTool<any> {
const schema = jsonSchemaToTypeBox(skill.parameters)
function resolveTemplate(toolName: string, key: string, fallback: string): string {
const i18nKey = `ai.tools.${toolName}.${key}`
const translated = i18nT(i18nKey)
return translated !== i18nKey ? translated : fallback
}
/**
* CustomSqlToolDef AgentTool
*/
export function createSqlTool(def: CustomSqlToolDef, context: ToolContext): AgentTool<any> {
const schema = jsonSchemaToTypeBox(def.parameters)
return {
name: skill.name,
label: skill.name,
description: skill.description,
name: def.name,
label: def.name,
description: def.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
def.execution.query,
params
)
const fallback = resolveTemplate(def.name, 'fallback', def.execution.fallback)
if (!rows || rows.length === 0) {
return { content: skill.execution.fallback }
return { content: [{ type: 'text' as const, text: fallback }] }
}
const rowTemplate = resolveTemplate(def.name, 'rowTemplate', def.execution.rowTemplate)
const summaryTemplate = def.execution.summaryTemplate
? resolveTemplate(def.name, 'summaryTemplate', def.execution.summaryTemplate)
: undefined
const lines: string[] = []
if (skill.execution.summaryTemplate) {
if (summaryTemplate) {
lines.push(
skill.execution.summaryTemplate.replace(/\{rowCount\}/g, String(rows.length))
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>))
lines.push(formatRow(rowTemplate, row as Record<string, unknown>))
}
return { content: lines.join('\n') }
return { content: [{ type: 'text' as const, text: lines.join('\n') }] }
},
}
}
/**
* AgentTool
* SQL AgentTool
*/
export function createSkillTools(skills: CustomSkillDef[], context: ToolContext): AgentTool<any>[] {
return skills.map((skill) => createSkillTool(skill, context))
export function createSqlTools(defs: CustomSqlToolDef[], context: ToolContext): AgentTool<any>[] {
return defs.map((def) => createSqlTool(def, context))
}
+49 -30
View File
@@ -1,6 +1,6 @@
/**
* 助手系统类型定义
* 定义助手配置、声明式 SQL 技能等核心类型
* 定义助手配置、声明式 SQL 工具等核心类型
*/
// ==================== 助手配置 ====================
@@ -9,15 +9,14 @@
* 助手配置(JSON 配置文件的完整结构)
*
* 每个助手对应一个 JSON 文件,存储在 {userData}/data/ai/assistants/ 目录下。
* 内置助手同时打包在应用 electron/main/ai/assistant/builtins/ 中,首次启动时复制到 userData。
* 内置助手作为模板目录打包在 electron/main/ai/assistant/builtins/ 中,
* 用户通过"助手市场"导入后才会复制到 userData。general 助手自动导入。
*/
export interface AssistantConfig {
/** 助手唯一标识 */
id: string
/** 助手显示名称 */
name: string
/** 助手简介 */
description: string
/** 系统提示词(替代旧的 PromptConfig.roleDefinition */
systemPrompt: string
@@ -34,18 +33,16 @@ export interface AssistantConfig {
*/
allowedBuiltinTools?: string[]
/** 声明式 SQL 技能(Phase 2 */
customSkills?: CustomSkillDef[]
/** 用户自定义声明式 SQL 工具 */
customSqlTools?: CustomSqlToolDef[]
/** 配置版本号,用于内置助手的版本比对更新 */
/** 配置版本号,用于内置助手的版本比对 */
version: number
/**
* 内置助手来源标识
* 非空 = 该配置派生自某个内置助手(值为内置助手的 id
* 非空 = 该配置某个内置助手导入而来(值为内置助手的 id
*/
builtinId?: string
/** 用户是否修改过内置助手的默认值(用于版本更新时判断是否可以覆盖) */
isUserModified?: boolean
/** 助手排序权重(越小越靠前,默认 100) */
order?: number
@@ -67,32 +64,48 @@ export interface AssistantConfig {
}
/**
* 传递给前端的助手摘要信息(不含 systemPrompt 等大字段)
* 传递给前端的助手摘要信息
*/
export interface AssistantSummary {
id: string
name: string
description: string
systemPrompt: string
presetQuestions: string[]
order?: number
builtinId?: string
isUserModified?: boolean
applicableChatTypes?: ('group' | 'private')[]
supportedLocales?: string[]
}
// ==================== 声明式 SQL 技能(Phase 2 ====================
/**
* 助手市场中的内置助手信息(模板目录项)
*/
export interface BuiltinAssistantInfo {
id: string
name: string
systemPrompt: string
version: number
order?: number
applicableChatTypes?: ('group' | 'private')[]
supportedLocales?: string[]
/** 用户是否已导入该助手 */
imported: boolean
/** 已导入的助手是否有新版本可用 */
hasUpdate: boolean
}
// ==================== 声明式 SQL 工具 ====================
/**
* 自定义 SQL 技能定义
* 声明式 SQL 工具定义
*
* 每个技能在 LLM 眼中是一个 Function Calling 工具,
* 每个定义在 LLM 眼中是一个 Function Calling 工具,
* 执行时通过参数化 SQL 查询数据库,将结果格式化为文本返回给 LLM。
*/
export interface CustomSkillDef {
/** 技能名称(作为 Function Calling 的 tool name */
export interface CustomSqlToolDef {
/** 工具名称(作为 Function Calling 的 tool name */
name: string
/** 技能描述(作为 Function Calling 的 tool description */
/** 工具描述(作为 Function Calling 的 tool description */
description: string
/**
* 参数定义(标准 JSON Schema 格式)
@@ -114,7 +127,7 @@ export interface CustomSkillDef {
parameters: JsonSchemaObject
/** 执行配置 */
execution: SqlSkillExecution
execution: SqlToolExecution
}
/**
@@ -137,9 +150,9 @@ export interface JsonSchemaProperty {
}
/**
* SQL 技能执行配置
* SQL 工具执行配置
*/
export interface SqlSkillExecution {
export interface SqlToolExecution {
/** 执行类型(目前仅支持 sqlite) */
type: 'sqlite'
/**
@@ -172,17 +185,13 @@ export interface SqlSkillExecution {
// ==================== 助手管理器相关 ====================
/**
* AssistantManager 初始化/同步的结果
* AssistantManager 初始化结果
*/
export interface AssistantSyncResult {
export interface AssistantInitResult {
/** 加载的助手总数 */
total: number
/** 新增的内置助手数 */
added: number
/** 自动更新的内置助手数(未被用户修改的) */
updated: number
/** 跳过更新的助手数(已被用户修改) */
skipped: number
/** general 助手是否为首次自动导入 */
generalCreated: boolean
}
/**
@@ -192,3 +201,13 @@ export interface AssistantSaveResult {
success: boolean
error?: string
}
/**
* 内置 SQL 工具的精简信息(供前端展示勾选列表)
*/
export interface BuiltinSqlToolInfo {
/** 工具名称(唯一标识,同 CustomSqlToolDef.name */
name: string
/** 工具中文描述 */
description: string
}
@@ -15,3 +15,19 @@ export { createTool as createSearchSessions } from './search-sessions'
export { createTool as createGetSessionMessages } from './get-session-messages'
export { createTool as createGetSessionSummaries } from './get-session-summaries'
export { createTool as createSemanticSearchMessages } from './semantic-search-messages'
export { sqlToolFactories, getSqlToolCatalog, SQL_TOOL_NAMES } from './sql-analysis'
export const TS_TOOL_NAMES = [
'search_messages',
'get_recent_messages',
'get_member_stats',
'get_time_stats',
'get_group_members',
'get_member_name_history',
'get_conversation_between',
'get_message_context',
'search_sessions',
'get_session_messages',
'get_session_summaries',
'semantic_search_messages',
]
@@ -0,0 +1,231 @@
/**
* SQL 分析工具定义集合
*
* 声明式 SQL 工具,与 TS 工具遵循相同的工厂函数模式。
* 每个工具通过 createSqlTool() 将 JSON 定义转化为 AgentTool。
*/
import type { AgentTool } from '@mariozechner/pi-agent-core'
import type { ToolContext } from '../types'
import type { CustomSqlToolDef } from '../../assistant/types'
import { createSqlTool } from '../../assistant/sqlToolRunner'
import { t as i18nT } from '../../../i18n'
const SQL_TOOL_DEFS: CustomSqlToolDef[] = [
// ==================== 通用分析 ====================
{
name: 'daily_message_type_breakdown',
description: '按消息类型统计近 N 天的消息分布(文本、图片、语音、表情等各有多少条)。适用于了解群聊的沟通方式偏好。',
parameters: {
type: 'object',
properties: {
days: { type: 'number', description: '统计最近多少天的数据' },
},
required: ['days'],
},
execution: {
type: 'sqlite',
query:
"SELECT CASE type WHEN 0 THEN '文本' WHEN 1 THEN '图片' WHEN 2 THEN '语音' WHEN 3 THEN '视频' WHEN 4 THEN '文件' WHEN 5 THEN '表情' WHEN 7 THEN '链接' WHEN 20 THEN '红包' WHEN 22 THEN '拍一拍' WHEN 80 THEN '系统消息' WHEN 81 THEN '撤回' ELSE '其他' END AS type_name, COUNT(*) AS msg_count, ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER(), 1) AS percentage FROM message WHERE ts > unixepoch('now', '-' || @days || ' days') GROUP BY type ORDER BY msg_count DESC",
rowTemplate: '{type_name}{msg_count} 条(占 {percentage}%',
summaryTemplate: '近 {rowCount} 种消息类型的分布:',
fallback: '该时间范围内没有消息记录',
},
},
{
name: 'peak_chat_hours_by_member',
description:
'分析指定成员在近 N 天内每小时的发言量分布,找出其最活跃的时段。需要先通过 get_group_members 获取 member_id。',
parameters: {
type: 'object',
properties: {
member_id: { type: 'number', description: '成员 ID(通过 get_group_members 获取)' },
days: { type: 'number', description: '统计最近多少天的数据', default: 30 },
},
required: ['member_id'],
},
execution: {
type: 'sqlite',
query:
"SELECT CAST(strftime('%H', ts, 'unixepoch', 'localtime') AS INTEGER) AS hour, COUNT(*) AS msg_count FROM message WHERE sender_id = @member_id AND ts > unixepoch('now', '-' || @days || ' days') GROUP BY hour ORDER BY msg_count DESC",
rowTemplate: '{hour}:00 — {msg_count} 条消息',
summaryTemplate: '该成员各时段发言量(共 {rowCount} 个活跃时段):',
fallback: '该成员在指定时间范围内没有发言记录',
},
},
// ==================== 社群分析 ====================
{
name: 'member_activity_trend',
description:
'查看指定成员近 N 天的每日发言数量变化趋势。适用于观察某人是否变得更活跃或更沉默。需要先通过 get_group_members 获取 member_id。',
parameters: {
type: 'object',
properties: {
member_id: { type: 'number', description: '成员 ID(通过 get_group_members 获取)' },
days: { type: 'number', description: '查看最近多少天的趋势' },
},
required: ['member_id', 'days'],
},
execution: {
type: 'sqlite',
query:
"SELECT date(ts, 'unixepoch', 'localtime') AS day, COUNT(*) AS msg_count FROM message WHERE sender_id = @member_id AND ts > unixepoch('now', '-' || @days || ' days') GROUP BY day ORDER BY day",
rowTemplate: '{day}{msg_count} 条',
summaryTemplate: '该成员近 {rowCount} 天有发言记录:',
fallback: '该成员在指定时间范围内没有发言记录',
},
},
{
name: 'silent_members',
description: '检测超过 N 天未发言的「沉默成员」。适用于社群运营中发现流失风险用户。',
parameters: {
type: 'object',
properties: {
days: { type: 'number', description: '多少天未发言算沉默', default: 7 },
},
required: ['days'],
},
execution: {
type: 'sqlite',
query:
"SELECT m.id AS member_id, COALESCE(m.group_nickname, m.account_name, m.platform_id) AS name, MAX(msg.ts) AS last_msg_ts, CAST((unixepoch('now') - MAX(msg.ts)) / 86400 AS INTEGER) AS silent_days FROM member m JOIN message msg ON msg.sender_id = m.id GROUP BY m.id HAVING silent_days >= @days ORDER BY silent_days DESC LIMIT 30",
rowTemplate: '{name} — 已沉默 {silent_days} 天',
summaryTemplate: '共发现 {rowCount} 位沉默成员:',
fallback: '没有发现超过指定天数未发言的成员,社群活跃度良好!',
},
},
{
name: 'reply_interaction_ranking',
description: '分析群内的回复互动关系排行,找出谁回复谁最多。适用于发现社群中的核心互动关系和意见领袖。',
parameters: {
type: 'object',
properties: {
days: { type: 'number', description: '统计最近多少天的数据' },
limit: { type: 'number', description: '返回前多少对互动关系', default: 10 },
},
required: ['days'],
},
execution: {
type: 'sqlite',
query:
"SELECT COALESCE(replier.group_nickname, replier.account_name) AS replier_name, COALESCE(original.group_nickname, original.account_name) AS original_name, COUNT(*) AS reply_count FROM message reply_msg JOIN message orig_msg ON reply_msg.reply_to_message_id = CAST(orig_msg.id AS TEXT) JOIN member replier ON reply_msg.sender_id = replier.id JOIN member original ON orig_msg.sender_id = original.id WHERE reply_msg.reply_to_message_id IS NOT NULL AND reply_msg.ts > unixepoch('now', '-' || @days || ' days') GROUP BY reply_msg.sender_id, orig_msg.sender_id ORDER BY reply_count DESC LIMIT @limit",
rowTemplate: '{replier_name} → {original_name}{reply_count} 次回复',
summaryTemplate: '回复互动 Top {rowCount}',
fallback: '该时间范围内没有回复互动记录',
},
},
// ==================== 情感分析 ====================
{
name: 'mutual_interaction_pairs',
description:
'找出互动最频繁的成员对,基于双向消息时间接近度(一方发言后 5 分钟内另一方也发言即视为一次互动)。适用于发现关系亲密的好友组合。',
parameters: {
type: 'object',
properties: {
days: { type: 'number', description: '统计最近多少天的数据' },
limit: { type: 'number', description: '返回前多少对', default: 10 },
},
required: ['days'],
},
execution: {
type: 'sqlite',
query:
"SELECT COALESCE(m1.group_nickname, m1.account_name) AS member_a, COALESCE(m2.group_nickname, m2.account_name) AS member_b, COUNT(*) AS interaction_count FROM message a JOIN message b ON b.sender_id != a.sender_id AND b.ts > a.ts AND b.ts <= a.ts + 300 JOIN member m1 ON a.sender_id = m1.id JOIN member m2 ON b.sender_id = m2.id WHERE a.sender_id < b.sender_id AND a.ts > unixepoch('now', '-' || @days || ' days') AND a.type = 0 AND b.type = 0 GROUP BY a.sender_id, b.sender_id ORDER BY interaction_count DESC LIMIT @limit",
rowTemplate: '{member_a} ↔ {member_b}{interaction_count} 次互动',
summaryTemplate: '互动最频繁的 {rowCount} 对好友:',
fallback: '该时间范围内没有检测到明显的互动关系',
},
},
{
name: 'member_message_length_stats',
description:
'统计各成员的平均消息长度(仅文本消息),长消息通常意味着更用心的交流。适用于发现深度交流者。',
parameters: {
type: 'object',
properties: {
days: { type: 'number', description: '统计最近多少天的数据' },
top_n: { type: 'number', description: '返回前多少名', default: 10 },
},
required: ['days'],
},
execution: {
type: 'sqlite',
query:
"SELECT COALESCE(m.group_nickname, m.account_name) AS name, COUNT(*) AS msg_count, ROUND(AVG(LENGTH(msg.content)), 1) AS avg_length, MAX(LENGTH(msg.content)) AS max_length FROM message msg JOIN member m ON msg.sender_id = m.id WHERE msg.type = 0 AND msg.content IS NOT NULL AND LENGTH(msg.content) > 0 AND msg.ts > unixepoch('now', '-' || @days || ' days') GROUP BY msg.sender_id HAVING msg_count >= 5 ORDER BY avg_length DESC LIMIT @top_n",
rowTemplate: '{name} — 平均 {avg_length} 字/条(共 {msg_count} 条,最长 {max_length} 字)',
summaryTemplate: '消息长度 Top {rowCount}(更长 = 更用心):',
fallback: '该时间范围内没有足够的文本消息数据',
},
},
// ==================== 客服分析 ====================
{
name: 'unanswered_messages',
description:
'查找近 N 天内未被回复的消息,这些可能是未解决的客户问题。仅统计文本消息且内容超过 10 字的(过滤简短寒暄)。',
parameters: {
type: 'object',
properties: {
days: { type: 'number', description: '查找最近多少天的数据' },
limit: { type: 'number', description: '最多返回多少条', default: 20 },
},
required: ['days'],
},
execution: {
type: 'sqlite',
query:
"SELECT COALESCE(m.group_nickname, m.account_name) AS sender_name, datetime(msg.ts, 'unixepoch', 'localtime') AS send_time, SUBSTR(msg.content, 1, 100) AS content_preview FROM message msg JOIN member m ON msg.sender_id = m.id WHERE msg.type = 0 AND msg.content IS NOT NULL AND LENGTH(msg.content) > 10 AND msg.ts > unixepoch('now', '-' || @days || ' days') AND NOT EXISTS (SELECT 1 FROM message reply WHERE reply.reply_to_message_id = CAST(msg.id AS TEXT)) AND NOT EXISTS (SELECT 1 FROM message next WHERE next.sender_id != msg.sender_id AND next.ts > msg.ts AND next.ts <= msg.ts + 1800) ORDER BY msg.ts DESC LIMIT @limit",
rowTemplate: '[{send_time}] {sender_name}{content_preview}',
summaryTemplate: '共发现 {rowCount} 条可能未被回复的消息:',
fallback: '该时间范围内所有消息都已得到回复,服务质量很好!',
},
},
{
name: 'message_type_distribution',
description:
'统计近 N 天内各种消息类型的数量分布(文本、图片、语音、文件等),帮助了解客服沟通的方式偏好和优化方向。',
parameters: {
type: 'object',
properties: {
days: { type: 'number', description: '统计最近多少天的数据' },
},
required: ['days'],
},
execution: {
type: 'sqlite',
query:
"SELECT CASE type WHEN 0 THEN '文本' WHEN 1 THEN '图片' WHEN 2 THEN '语音' WHEN 3 THEN '视频' WHEN 4 THEN '文件' WHEN 5 THEN '表情' WHEN 7 THEN '链接' WHEN 80 THEN '系统消息' ELSE '其他' END AS type_name, COUNT(*) AS msg_count, ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER(), 1) AS percentage FROM message WHERE ts > unixepoch('now', '-' || @days || ' days') GROUP BY type ORDER BY msg_count DESC",
rowTemplate: '{type_name}{msg_count} 条({percentage}%',
summaryTemplate: '消息类型分布(共 {rowCount} 种类型):',
fallback: '该时间范围内没有消息记录',
},
},
]
/**
* SQL 分析工具工厂函数数组(与 TS 工具 createTool 模式一致)
*/
export const sqlToolFactories = SQL_TOOL_DEFS.map(
(def) => (context: ToolContext): AgentTool<any> => createSqlTool(def, context)
)
/**
* 所有内置 SQL 工具的名称集合(用于前端分组展示)
*/
export const SQL_TOOL_NAMES = SQL_TOOL_DEFS.map((d) => d.name)
/**
* 获取内置 SQL 工具目录(供前端展示)
*/
export function getSqlToolCatalog(): Array<{ name: string; description: string }> {
return SQL_TOOL_DEFS.map((d) => {
const descKey = `ai.tools.${d.name}.desc`
const translated = i18nT(descKey)
return {
name: d.name,
description: translated !== descKey ? translated : d.description,
}
})
}
+2
View File
@@ -20,6 +20,7 @@ import {
createGetSessionMessages,
createGetSessionSummaries,
createSemanticSearchMessages,
sqlToolFactories,
} from './definitions'
import { isEmbeddingEnabled } from '../rag'
import { t as i18nT } from '../../i18n'
@@ -43,6 +44,7 @@ const coreFactories: ToolFactory[] = [
createSearchSessions,
createGetSessionMessages,
createGetSessionSummaries,
...sqlToolFactories,
]
/**
+82
View File
@@ -196,6 +196,88 @@ Returned summaries are brief descriptions of each session, helping quickly locat
end_time: 'End time, format "YYYY-MM-DD HH:mm"',
},
},
// ===== SQL Analysis Tools =====
daily_message_type_breakdown: {
desc: 'Break down message types over the last N days (text, image, voice, emoji, etc.). Useful for understanding communication preferences.',
params: { days: 'Number of recent days to analyze' },
rowTemplate: '{type_name}: {msg_count} messages ({percentage}%)',
summaryTemplate: 'Message type distribution ({rowCount} types):',
fallback: 'No messages found in this time range',
},
peak_chat_hours_by_member: {
desc: 'Analyze a specific member\'s hourly message distribution over the last N days to find their most active hours. Requires member_id from get_group_members.',
params: {
member_id: 'Member ID (from get_group_members)',
days: 'Number of recent days to analyze',
},
rowTemplate: '{hour}:00 — {msg_count} messages',
summaryTemplate: 'Message volume by hour ({rowCount} active hours):',
fallback: 'This member has no messages in the specified time range',
},
member_activity_trend: {
desc: 'View a specific member\'s daily message count trend over the last N days. Useful for observing whether someone is becoming more or less active. Requires member_id from get_group_members.',
params: {
member_id: 'Member ID (from get_group_members)',
days: 'Number of recent days to view',
},
rowTemplate: '{day}: {msg_count} messages',
summaryTemplate: 'This member was active on {rowCount} days:',
fallback: 'This member has no messages in the specified time range',
},
silent_members: {
desc: 'Detect "silent members" who haven\'t sent messages for more than N days. Useful for identifying at-risk users in community management.',
params: { days: 'Days of silence to qualify' },
rowTemplate: '{name} — silent for {silent_days} days',
summaryTemplate: 'Found {rowCount} silent members:',
fallback: 'No members found who have been silent for that long. Community engagement is healthy!',
},
reply_interaction_ranking: {
desc: 'Analyze reply interaction rankings in the group — who replies to whom the most. Useful for discovering core interaction relationships and key opinion leaders.',
params: {
days: 'Number of recent days to analyze',
limit: 'Number of top interaction pairs to return',
},
rowTemplate: '{replier_name} → {original_name}: {reply_count} replies',
summaryTemplate: 'Top {rowCount} reply interactions:',
fallback: 'No reply interactions found in this time range',
},
mutual_interaction_pairs: {
desc: 'Find the most frequently interacting member pairs, based on bidirectional message timing (if one person speaks and another responds within 5 minutes, it counts as an interaction). Useful for discovering close friendships.',
params: {
days: 'Number of recent days to analyze',
limit: 'Number of top pairs to return',
},
rowTemplate: '{member_a} ↔ {member_b}: {interaction_count} interactions',
summaryTemplate: 'Top {rowCount} most interactive pairs:',
fallback: 'No significant interaction patterns detected in this time range',
},
member_message_length_stats: {
desc: 'Analyze average message length per member (text messages only). Longer messages often indicate more thoughtful communication. Useful for finding deep communicators.',
params: {
days: 'Number of recent days to analyze',
top_n: 'Number of top members to return',
},
rowTemplate: '{name} — avg {avg_length} chars/msg ({msg_count} msgs, max {max_length} chars)',
summaryTemplate: 'Message length Top {rowCount} (longer = more thoughtful):',
fallback: 'Not enough text message data in this time range',
},
unanswered_messages: {
desc: 'Find messages in the last N days that may not have been replied to — potential unresolved customer issues. Only counts text messages over 10 characters (filters out short greetings).',
params: {
days: 'Number of recent days to search',
limit: 'Maximum number of results',
},
rowTemplate: '[{send_time}] {sender_name}: {content_preview}',
summaryTemplate: 'Found {rowCount} potentially unanswered messages:',
fallback: 'All messages have been replied to in this time range. Great service quality!',
},
message_type_distribution: {
desc: 'Analyze message type distribution over the last N days (text, image, voice, file, etc.). Helps understand communication preferences and optimization opportunities.',
params: { days: 'Number of recent days to analyze' },
rowTemplate: '{type_name}: {msg_count} messages ({percentage}%)',
summaryTemplate: 'Message type distribution ({rowCount} types):',
fallback: 'No messages found in this time range',
},
},
// ===== AI Agent system prompts =====
+82
View File
@@ -187,6 +187,88 @@ export default {
end_time: '结束时间,格式 "YYYY-MM-DD HH:mm"',
},
},
// ===== SQL 分析工具 =====
daily_message_type_breakdown: {
desc: '按消息类型统计近 N 天的消息分布(文本、图片、语音、表情等各有多少条)。适用于了解群聊的沟通方式偏好。',
params: { days: '统计最近多少天的数据' },
rowTemplate: '{type_name}{msg_count} 条(占 {percentage}%',
summaryTemplate: '近 {rowCount} 种消息类型的分布:',
fallback: '该时间范围内没有消息记录',
},
peak_chat_hours_by_member: {
desc: '分析指定成员在近 N 天内每小时的发言量分布,找出其最活跃的时段。需要先通过 get_group_members 获取 member_id。',
params: {
member_id: '成员 ID(通过 get_group_members 获取)',
days: '统计最近多少天的数据',
},
rowTemplate: '{hour}:00 — {msg_count} 条消息',
summaryTemplate: '该成员各时段发言量(共 {rowCount} 个活跃时段):',
fallback: '该成员在指定时间范围内没有发言记录',
},
member_activity_trend: {
desc: '查看指定成员近 N 天的每日发言数量变化趋势。适用于观察某人是否变得更活跃或更沉默。需要先通过 get_group_members 获取 member_id。',
params: {
member_id: '成员 ID(通过 get_group_members 获取)',
days: '查看最近多少天的趋势',
},
rowTemplate: '{day}{msg_count} 条',
summaryTemplate: '该成员近 {rowCount} 天有发言记录:',
fallback: '该成员在指定时间范围内没有发言记录',
},
silent_members: {
desc: '检测超过 N 天未发言的「沉默成员」。适用于社群运营中发现流失风险用户。',
params: { days: '多少天未发言算沉默' },
rowTemplate: '{name} — 已沉默 {silent_days} 天',
summaryTemplate: '共发现 {rowCount} 位沉默成员:',
fallback: '没有发现超过指定天数未发言的成员,社群活跃度良好!',
},
reply_interaction_ranking: {
desc: '分析群内的回复互动关系排行,找出谁回复谁最多。适用于发现社群中的核心互动关系和意见领袖。',
params: {
days: '统计最近多少天的数据',
limit: '返回前多少对互动关系',
},
rowTemplate: '{replier_name} → {original_name}{reply_count} 次回复',
summaryTemplate: '回复互动 Top {rowCount}',
fallback: '该时间范围内没有回复互动记录',
},
mutual_interaction_pairs: {
desc: '找出互动最频繁的成员对,基于双向消息时间接近度(一方发言后 5 分钟内另一方也发言即视为一次互动)。适用于发现关系亲密的好友组合。',
params: {
days: '统计最近多少天的数据',
limit: '返回前多少对',
},
rowTemplate: '{member_a} ↔ {member_b}{interaction_count} 次互动',
summaryTemplate: '互动最频繁的 {rowCount} 对好友:',
fallback: '该时间范围内没有检测到明显的互动关系',
},
member_message_length_stats: {
desc: '统计各成员的平均消息长度(仅文本消息),长消息通常意味着更用心的交流。适用于发现深度交流者。',
params: {
days: '统计最近多少天的数据',
top_n: '返回前多少名',
},
rowTemplate: '{name} — 平均 {avg_length} 字/条(共 {msg_count} 条,最长 {max_length} 字)',
summaryTemplate: '消息长度 Top {rowCount}(更长 = 更用心):',
fallback: '该时间范围内没有足够的文本消息数据',
},
unanswered_messages: {
desc: '查找近 N 天内未被回复的消息,这些可能是未解决的客户问题。仅统计文本消息且内容超过 10 字的(过滤简短寒暄)。',
params: {
days: '查找最近多少天的数据',
limit: '最多返回多少条',
},
rowTemplate: '[{send_time}] {sender_name}{content_preview}',
summaryTemplate: '共发现 {rowCount} 条可能未被回复的消息:',
fallback: '该时间范围内所有消息都已得到回复,服务质量很好!',
},
message_type_distribution: {
desc: '统计近 N 天内各种消息类型的数量分布(文本、图片、语音、文件等),帮助了解客服沟通的方式偏好和优化方向。',
params: { days: '统计最近多少天的数据' },
rowTemplate: '{type_name}{msg_count} 条({percentage}%',
summaryTemplate: '消息类型分布(共 {rowCount} 种类型):',
fallback: '该时间范围内没有消息记录',
},
},
// ===== AI Agent 系统提示词 =====
+45
View File
@@ -628,6 +628,51 @@ export function registerAIHandlers({ win }: IpcContext): void {
}
})
ipcMain.handle('assistant:getBuiltinSqlTools', async () => {
try {
return assistantManager.getBuiltinSqlToolCatalog()
} catch (error) {
console.error('Failed to get builtin sql tools:', error)
return []
}
})
ipcMain.handle('assistant:getBuiltinTsToolNames', async () => {
try {
return assistantManager.getBuiltinTsToolNames()
} catch (error) {
console.error('Failed to get builtin ts tool names:', error)
return []
}
})
ipcMain.handle('assistant:getBuiltinCatalog', async () => {
try {
return assistantManager.getBuiltinCatalog()
} catch (error) {
console.error('Failed to get builtin catalog:', error)
return []
}
})
ipcMain.handle('assistant:import', async (_, builtinId: string) => {
try {
return assistantManager.importAssistant(builtinId)
} catch (error) {
console.error('Failed to import assistant:', error)
return { success: false, error: String(error) }
}
})
ipcMain.handle('assistant:reimport', async (_, id: string) => {
try {
return assistantManager.reimportAssistant(id)
} catch (error) {
console.error('Failed to reimport assistant:', error)
return { success: false, error: String(error) }
}
})
ipcMain.handle(
'assistant:backupOldPresets',
async (
+39 -5
View File
@@ -667,11 +667,10 @@ export const llmApi = {
export interface AssistantSummary {
id: string
name: string
description: string
systemPrompt: string
presetQuestions: string[]
order?: number
builtinId?: string
isUserModified?: boolean
applicableChatTypes?: ('group' | 'private')[]
supportedLocales?: string[]
}
@@ -679,20 +678,35 @@ export interface AssistantSummary {
export interface AssistantConfigFull {
id: string
name: string
description: string
systemPrompt: string
responseRules?: string
presetQuestions: string[]
allowedBuiltinTools?: string[]
customSkills?: unknown[]
customSqlTools?: unknown[]
version: number
builtinId?: string
isUserModified?: boolean
order?: number
applicableChatTypes?: ('group' | 'private')[]
supportedLocales?: string[]
}
export interface BuiltinAssistantInfo {
id: string
name: string
systemPrompt: string
version: number
order?: number
applicableChatTypes?: ('group' | 'private')[]
supportedLocales?: string[]
imported: boolean
hasUpdate: boolean
}
export interface BuiltinSqlToolInfo {
name: string
description: string
}
export const assistantApi = {
getAll: (): Promise<AssistantSummary[]> => {
return ipcRenderer.invoke('assistant:getAll')
@@ -723,6 +737,26 @@ export const assistantApi = {
return ipcRenderer.invoke('assistant:reset', id)
},
getBuiltinCatalog: (): Promise<BuiltinAssistantInfo[]> => {
return ipcRenderer.invoke('assistant:getBuiltinCatalog')
},
getBuiltinSqlTools: (): Promise<BuiltinSqlToolInfo[]> => {
return ipcRenderer.invoke('assistant:getBuiltinSqlTools')
},
getBuiltinTsToolNames: (): Promise<string[]> => {
return ipcRenderer.invoke('assistant:getBuiltinTsToolNames')
},
importAssistant: (builtinId: string): Promise<{ success: boolean; error?: string }> => {
return ipcRenderer.invoke('assistant:import', builtinId)
},
reimportAssistant: (id: string): Promise<{ success: boolean; error?: string }> => {
return ipcRenderer.invoke('assistant:reimport', id)
},
backupOldPresets: (data: {
customPresets?: unknown[]
builtinOverrides?: Record<string, unknown>
+26 -5
View File
@@ -682,11 +682,10 @@ interface AgentApi {
interface AssistantSummary {
id: string
name: string
description: string
systemPrompt: string
presetQuestions: string[]
order?: number
builtinId?: string
isUserModified?: boolean
applicableChatTypes?: ('group' | 'private')[]
supportedLocales?: string[]
}
@@ -694,20 +693,35 @@ interface AssistantSummary {
interface AssistantConfigFull {
id: string
name: string
description: string
systemPrompt: string
responseRules?: string
presetQuestions: string[]
allowedBuiltinTools?: string[]
customSkills?: unknown[]
customSqlTools?: unknown[]
version: number
builtinId?: string
isUserModified?: boolean
order?: number
applicableChatTypes?: ('group' | 'private')[]
supportedLocales?: string[]
}
interface BuiltinAssistantInfo {
id: string
name: string
systemPrompt: string
version: number
order?: number
applicableChatTypes?: ('group' | 'private')[]
supportedLocales?: string[]
imported: boolean
hasUpdate: boolean
}
interface BuiltinSqlToolInfo {
name: string
description: string
}
interface AssistantApi {
getAll: () => Promise<AssistantSummary[]>
getConfig: (id: string) => Promise<AssistantConfigFull | null>
@@ -715,6 +729,11 @@ interface AssistantApi {
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 }>
getBuiltinCatalog: () => Promise<BuiltinAssistantInfo[]>
getBuiltinSqlTools: () => Promise<BuiltinSqlToolInfo[]>
getBuiltinTsToolNames: () => Promise<string[]>
importAssistant: (builtinId: string) => Promise<{ success: boolean; error?: string }>
reimportAssistant: (id: string) => Promise<{ success: boolean; error?: string }>
backupOldPresets: (data: {
customPresets?: unknown[]
builtinOverrides?: Record<string, unknown>
@@ -935,6 +954,8 @@ export {
AssistantApi,
AssistantSummary,
AssistantConfigFull,
BuiltinAssistantInfo,
BuiltinSqlToolInfo,
CacheApi,
NetworkApi,
NlpApi,
@@ -31,20 +31,14 @@ const emit = defineEmits<{
</button>
<!-- 名称 -->
<h3 class="mb-1.5 pr-6 text-sm font-semibold text-gray-900 dark:text-gray-100">
<h3 class="mb-1.5 truncate pr-6 text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ assistant.name }}
</h3>
<!-- 描述 -->
<!-- 系统提示词预览 -->
<p class="line-clamp-2 text-xs leading-relaxed text-gray-500 dark:text-gray-400">
{{ assistant.description }}
{{ assistant.systemPrompt }}
</p>
<!-- 用户已修改标记 -->
<div v-if="assistant.isUserModified" class="mt-2">
<span class="inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
已自定义
</span>
</div>
</div>
</template>
@@ -1,16 +1,21 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { ref, watch, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useToast } from '@nuxt/ui/runtime/composables/useToast.js'
import { useAssistantStore, type AssistantConfigFull } from '@/stores/assistant'
const { t } = useI18n()
const props = defineProps<{
open: boolean
assistantId: string | null
readonly?: boolean
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
saved: []
created: [id: string]
}>()
const toast = useToast()
@@ -19,19 +24,28 @@ const assistantStore = useAssistantStore()
const isLoading = ref(false)
const isSaving = ref(false)
const config = ref<AssistantConfigFull | null>(null)
const activeTab = ref<'basic' | 'skills'>('basic')
const isCreateMode = ref(false)
const activeTab = ref<'basic' | 'tools'>('basic')
const chatTypeOptions = [
{ value: 'group', label: '群聊' },
{ value: 'private', label: '私聊' },
]
const chatTypeOptions = computed(() => [
{ value: '', label: t('ai.assistant.config.chatTypeAll') },
{ value: 'group', label: t('ai.assistant.config.chatTypeGroup') },
{ value: 'private', label: t('ai.assistant.config.chatTypePrivate') },
])
const localeOptions = [
{ value: 'zh', label: '中文' },
{ value: 'zh', label: '简体中文' },
{ value: 'en', label: 'English' },
]
interface SkillForm {
const BUILTIN_TS_TOOLS = computed(() =>
assistantStore.builtinTsToolNames.map((name) => ({
name,
description: t(`ai.assistant.builtinToolDesc.${name}`),
}))
)
interface ToolForm {
name: string
description: string
parametersJson: string
@@ -43,64 +57,118 @@ interface SkillForm {
const form = ref({
name: '',
description: '',
systemPrompt: '',
responseRules: '',
presetQuestions: [] as string[],
applicableChatTypes: [] as string[],
applicableChatType: '' as string,
supportedLocales: [] as string[],
allowedBuiltinTools: [] as string[],
})
const skills = ref<SkillForm[]>([])
const customSqlTools = ref<ToolForm[]>([])
const newQuestion = ref('')
const expandedSkillIndex = ref<number | null>(null)
const expandedToolIndex = ref<number | null>(null)
const hasSkills = computed(() => skills.value.length > 0)
const toolGroups = computed(() => [
{ label: t('ai.assistant.config.toolGroupQuery'), tools: BUILTIN_TS_TOOLS.value },
{ label: t('ai.assistant.config.toolGroupSql'), tools: assistantStore.builtinSqlTools },
])
const allBuiltinTools = computed(() => [...BUILTIN_TS_TOOLS.value, ...assistantStore.builtinSqlTools])
const hasCustomTools = computed(() => customSqlTools.value.length > 0)
const toolBadgeCount = computed(() => {
const builtinCount = form.value.allowedBuiltinTools.length
const customCount = customSqlTools.value.filter((t) => t.name.trim()).length
return builtinCount + customCount
})
onMounted(async () => {
if (assistantStore.builtinTsToolNames.length === 0) {
await assistantStore.loadBuiltinTsToolNames()
}
if (assistantStore.builtinSqlTools.length === 0) {
await assistantStore.loadBuiltinSqlTools()
}
})
watch(
() => [props.open, props.assistantId],
async ([visible, id]) => {
if (visible && id) {
activeTab.value = 'basic'
if (!visible) return
activeTab.value = 'basic'
if (id) {
isCreateMode.value = false
await loadConfig(id as string)
} else if (!props.readonly) {
isCreateMode.value = true
initEmptyForm()
}
},
{ immediate: true }
)
function skillDefToForm(skill: any): SkillForm {
function toolDefToForm(tool: any): ToolForm {
return {
name: skill.name || '',
description: skill.description || '',
parametersJson: JSON.stringify(skill.parameters || { type: 'object', properties: {}, required: [] }, null, 2),
query: skill.execution?.query || '',
rowTemplate: skill.execution?.rowTemplate || '',
summaryTemplate: skill.execution?.summaryTemplate || '',
fallback: skill.execution?.fallback || '',
name: tool.name || '',
description: tool.description || '',
parametersJson: JSON.stringify(tool.parameters || { type: 'object', properties: {}, required: [] }, null, 2),
query: tool.execution?.query || '',
rowTemplate: tool.execution?.rowTemplate || '',
summaryTemplate: tool.execution?.summaryTemplate || '',
fallback: tool.execution?.fallback || '',
}
}
function formToSkillDef(sf: SkillForm): any {
function formToToolDef(tf: ToolForm): any {
let parameters: any
try {
parameters = JSON.parse(sf.parametersJson)
parameters = JSON.parse(tf.parametersJson)
} catch {
parameters = { type: 'object', properties: {}, required: [] }
}
return {
name: sf.name,
description: sf.description,
name: tf.name,
description: tf.description,
parameters,
execution: {
type: 'sqlite',
query: sf.query,
rowTemplate: sf.rowTemplate,
summaryTemplate: sf.summaryTemplate || undefined,
fallback: sf.fallback,
query: tf.query,
rowTemplate: tf.rowTemplate,
summaryTemplate: tf.summaryTemplate || undefined,
fallback: tf.fallback,
},
}
}
function initEmptyForm() {
config.value = {
id: '',
name: '',
systemPrompt: '',
responseRules: '',
presetQuestions: [],
allowedBuiltinTools: [],
customSqlTools: [],
version: 1,
order: 100,
applicableChatTypes: [],
supportedLocales: [],
}
form.value = {
name: '',
systemPrompt: '',
responseRules: '',
presetQuestions: [],
applicableChatType: '',
supportedLocales: [],
allowedBuiltinTools: [],
}
customSqlTools.value = []
expandedToolIndex.value = null
isLoading.value = false
}
async function loadConfig(id: string) {
isLoading.value = true
try {
@@ -108,54 +176,73 @@ async function loadConfig(id: string) {
if (config.value) {
form.value = {
name: config.value.name,
description: config.value.description,
systemPrompt: config.value.systemPrompt,
responseRules: config.value.responseRules || '',
presetQuestions: [...config.value.presetQuestions],
applicableChatTypes: [...(config.value.applicableChatTypes || [])],
applicableChatType: config.value.applicableChatTypes?.[0] || '',
supportedLocales: [...(config.value.supportedLocales || [])],
allowedBuiltinTools: [...(config.value.allowedBuiltinTools || [])],
}
skills.value = (config.value.customSkills || []).map(skillDefToForm)
expandedSkillIndex.value = null
customSqlTools.value = (config.value.customSqlTools || []).map(toolDefToForm)
expandedToolIndex.value = null
}
} catch (error) {
console.error('[AssistantConfigModal] Failed to load config:', error)
toast.add({ title: '加载失败', description: String(error), color: 'error' })
toast.add({ title: t('ai.assistant.toast.loadFailed'), description: String(error), color: 'error' })
} finally {
isLoading.value = false
}
}
async function handleSave() {
if (!props.assistantId) return
isSaving.value = true
try {
const customSkills = skills.value
.filter((s) => s.name.trim())
.map(formToSkillDef)
const customTools = customSqlTools.value.filter((t) => t.name.trim()).map(formToToolDef)
const result = await assistantStore.updateAssistant(props.assistantId, {
const payload = {
name: form.value.name,
description: form.value.description,
systemPrompt: form.value.systemPrompt,
responseRules: form.value.responseRules,
presetQuestions: form.value.presetQuestions,
applicableChatTypes: form.value.applicableChatTypes as ('group' | 'private')[],
supportedLocales: form.value.supportedLocales,
customSkills,
})
presetQuestions: [...form.value.presetQuestions],
applicableChatTypes: form.value.applicableChatType
? ([form.value.applicableChatType] as ('group' | 'private')[])
: ([] as ('group' | 'private')[]),
supportedLocales: [...form.value.supportedLocales],
allowedBuiltinTools: [...form.value.allowedBuiltinTools],
customSqlTools: customTools,
}
if (result.success) {
toast.add({ title: '保存成功', color: 'success' })
emit('saved')
closeModal()
if (isCreateMode.value) {
const result = await assistantStore.createAssistant(payload)
if (result.success) {
toast.add({ title: t('ai.assistant.toast.createSuccess'), color: 'success' })
emit('created', result.id!)
closeModal()
} else {
toast.add({
title: t('ai.assistant.toast.createFailed'),
description: result.error || t('ai.assistant.toast.unknownError'),
color: 'error',
})
}
} else {
toast.add({ title: '保存失败', description: result.error || '未知错误', color: 'error' })
if (!props.assistantId) return
const result = await assistantStore.updateAssistant(props.assistantId, payload)
if (result.success) {
toast.add({ title: t('ai.assistant.toast.saveSuccess'), color: 'success' })
emit('saved')
closeModal()
} else {
toast.add({
title: t('ai.assistant.toast.saveFailed'),
description: result.error || t('ai.assistant.toast.unknownError'),
color: 'error',
})
}
}
} catch (error) {
console.error('[AssistantConfigModal] Save failed:', error)
toast.add({ title: '保存失败', description: String(error), color: 'error' })
toast.add({ title: t('ai.assistant.toast.saveFailed'), description: String(error), color: 'error' })
} finally {
isSaving.value = false
}
@@ -168,14 +255,18 @@ async function handleReset() {
try {
const result = await assistantStore.resetAssistant(props.assistantId)
if (result.success) {
toast.add({ title: '已恢复默认', color: 'success' })
toast.add({ title: t('ai.assistant.toast.resetSuccess'), color: 'success' })
await loadConfig(props.assistantId)
emit('saved')
} else {
toast.add({ title: '恢复失败', description: result.error || '未知错误', color: 'error' })
toast.add({
title: t('ai.assistant.toast.resetFailed'),
description: result.error || t('ai.assistant.toast.unknownError'),
color: 'error',
})
}
} catch (error) {
toast.add({ title: '恢复失败', description: String(error), color: 'error' })
toast.add({ title: t('ai.assistant.toast.resetFailed'), description: String(error), color: 'error' })
} finally {
isSaving.value = false
}
@@ -193,39 +284,56 @@ function removeQuestion(index: number) {
form.value.presetQuestions.splice(index, 1)
}
function addSkill() {
skills.value.push({
function toggleBuiltinTool(toolName: string) {
const idx = form.value.allowedBuiltinTools.indexOf(toolName)
if (idx >= 0) {
form.value.allowedBuiltinTools.splice(idx, 1)
} else {
form.value.allowedBuiltinTools.push(toolName)
}
}
function isToolChecked(toolName: string): boolean {
if (form.value.allowedBuiltinTools.length === 0) return true
return form.value.allowedBuiltinTools.includes(toolName)
}
function selectAllTools() {
form.value.allowedBuiltinTools = allBuiltinTools.value.map((t) => t.name)
}
function clearAllTools() {
form.value.allowedBuiltinTools = []
}
function addCustomTool() {
customSqlTools.value.push({
name: '',
description: '',
parametersJson: JSON.stringify({ type: 'object', properties: {}, required: [] }, null, 2),
query: '',
rowTemplate: '',
summaryTemplate: '',
fallback: '未找到相关数据',
fallback: t('ai.assistant.config.toolFallbackDefault'),
})
expandedSkillIndex.value = skills.value.length - 1
expandedToolIndex.value = customSqlTools.value.length - 1
}
function removeSkill(index: number) {
skills.value.splice(index, 1)
if (expandedSkillIndex.value === index) {
expandedSkillIndex.value = null
} else if (expandedSkillIndex.value !== null && expandedSkillIndex.value > index) {
expandedSkillIndex.value--
function removeCustomTool(index: number) {
customSqlTools.value.splice(index, 1)
if (expandedToolIndex.value === index) {
expandedToolIndex.value = null
} else if (expandedToolIndex.value !== null && expandedToolIndex.value > index) {
expandedToolIndex.value--
}
}
function toggleSkill(index: number) {
expandedSkillIndex.value = expandedSkillIndex.value === index ? null : index
function toggleCustomTool(index: number) {
expandedToolIndex.value = expandedToolIndex.value === index ? null : index
}
function toggleChatType(value: string) {
const idx = form.value.applicableChatTypes.indexOf(value)
if (idx >= 0) {
form.value.applicableChatTypes.splice(idx, 1)
} else {
form.value.applicableChatTypes.push(value)
}
function selectChatType(value: string) {
form.value.applicableChatType = value
}
function toggleLocale(value: string) {
@@ -243,12 +351,20 @@ function closeModal() {
</script>
<template>
<UModal :open="open" :ui="{ content: 'sm:max-w-2xl' }" @update:open="emit('update:open', $event)">
<UModal :open="open" :ui="{ content: 'sm:max-w-2xl z-100' }" @update:open="emit('update:open', $event)">
<template #content>
<div class="p-6">
<!-- Header -->
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">助手配置</h2>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{
readonly
? t('ai.assistant.config.viewTitle')
: isCreateMode
? t('ai.assistant.config.createTitle')
: t('ai.assistant.config.editTitle')
}}
</h2>
<UButton icon="i-heroicons-x-mark" variant="ghost" size="sm" @click="closeModal" />
</div>
@@ -263,23 +379,30 @@ function closeModal() {
<div class="mb-4 flex gap-1 rounded-lg bg-gray-100 p-1 dark:bg-gray-800">
<button
class="flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors"
:class="activeTab === 'basic'
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400'"
:class="
activeTab === 'basic'
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400'
"
@click="activeTab = 'basic'"
>
基础配置
{{ t('ai.assistant.config.tabs.basic') }}
</button>
<button
class="flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors"
:class="activeTab === 'skills'
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400'"
@click="activeTab = 'skills'"
:class="
activeTab === 'tools'
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400'
"
@click="activeTab = 'tools'"
>
SQL 技能
<span v-if="hasSkills" class="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full bg-primary-100 text-[10px] text-primary-600 dark:bg-primary-900/30 dark:text-primary-400">
{{ skills.length }}
{{ t('ai.assistant.config.tabs.tools') }}
<span
v-if="toolBadgeCount > 0"
class="ml-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary-100 px-1 text-[10px] text-primary-600 dark:bg-primary-900/30 dark:text-primary-400"
>
{{ toolBadgeCount }}
</span>
</button>
</div>
@@ -287,171 +410,322 @@ function closeModal() {
<div class="max-h-[500px] overflow-y-auto pr-1">
<!-- 基础配置 Tab -->
<div v-show="activeTab === 'basic'" class="space-y-5">
<!-- 名称 -->
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">名称</label>
<UInput v-model="form.name" class="w-full" />
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('ai.assistant.config.name') }}
</label>
<UInput v-model="form.name" class="w-full" :disabled="readonly" />
</div>
<!-- 描述 -->
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">描述</label>
<UInput v-model="form.description" class="w-full" />
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('ai.assistant.config.systemPrompt') }}
</label>
<UTextarea
v-model="form.systemPrompt"
:rows="5"
autoresize
class="w-full font-mono text-sm"
:disabled="readonly"
/>
</div>
<!-- 系统提示词 -->
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">系统提示词</label>
<UTextarea v-model="form.systemPrompt" :rows="5" autoresize class="w-full font-mono text-sm" />
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('ai.assistant.config.responseRules') }}
</label>
<UTextarea
v-model="form.responseRules"
:rows="4"
autoresize
class="w-full font-mono text-sm"
:disabled="readonly"
/>
</div>
<!-- 回答要求 -->
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">回答要求</label>
<UTextarea v-model="form.responseRules" :rows="4" autoresize class="w-full font-mono text-sm" />
</div>
<!-- 适用聊天类型 -->
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">适用聊天类型</label>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('ai.assistant.config.chatType') }}
</label>
<div class="flex flex-wrap gap-2">
<button
v-for="opt in chatTypeOptions"
:key="opt.value"
type="button"
class="rounded-lg border px-3 py-1.5 text-xs transition-colors"
:class="form.applicableChatTypes.includes(opt.value)
? 'border-primary-500 bg-primary-50 text-primary-700 dark:border-primary-400 dark:bg-primary-950/30 dark:text-primary-300'
: 'border-gray-200 text-gray-600 hover:border-gray-300 dark:border-gray-700 dark:text-gray-400'"
@click="toggleChatType(opt.value)"
:class="
form.applicableChatType === opt.value
? 'border-primary-500 bg-primary-50 text-primary-700 dark:border-primary-400 dark:bg-primary-950/30 dark:text-primary-300'
: 'border-gray-200 text-gray-600 hover:border-gray-300 dark:border-gray-700 dark:text-gray-400'
"
:disabled="readonly"
@click="selectChatType(opt.value)"
>
{{ opt.label }}
</button>
</div>
<p class="mt-1 text-[10px] text-gray-400">不选 = 通用</p>
</div>
<!-- 适用语言 -->
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">适用语言</label>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('ai.assistant.config.locale') }}
</label>
<div class="flex flex-wrap gap-2">
<button
v-for="opt in localeOptions"
:key="opt.value"
type="button"
class="rounded-lg border px-3 py-1.5 text-xs transition-colors"
:class="form.supportedLocales.includes(opt.value)
? 'border-primary-500 bg-primary-50 text-primary-700 dark:border-primary-400 dark:bg-primary-950/30 dark:text-primary-300'
: 'border-gray-200 text-gray-600 hover:border-gray-300 dark:border-gray-700 dark:text-gray-400'"
:class="
form.supportedLocales.includes(opt.value)
? 'border-primary-500 bg-primary-50 text-primary-700 dark:border-primary-400 dark:bg-primary-950/30 dark:text-primary-300'
: 'border-gray-200 text-gray-600 hover:border-gray-300 dark:border-gray-700 dark:text-gray-400'
"
:disabled="readonly"
@click="toggleLocale(opt.value)"
>
{{ opt.label }}
</button>
</div>
<p class="mt-1 text-[10px] text-gray-400">不选 = 全语言</p>
<p class="mt-1 text-[10px] text-gray-400">{{ t('ai.assistant.config.localeHint') }}</p>
</div>
<!-- 预设问题 -->
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">预设问题</label>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('ai.assistant.config.presetQuestions') }}
</label>
<div class="space-y-2">
<div v-for="(q, index) in form.presetQuestions" :key="index" class="flex items-center gap-2">
<UInput v-model="form.presetQuestions[index]" class="min-w-0 flex-1" size="sm" />
<UButton color="error" variant="ghost" icon="i-heroicons-trash" size="xs" class="shrink-0" @click="removeQuestion(index)" />
<UInput
v-model="form.presetQuestions[index]"
class="min-w-0 flex-1"
size="sm"
:disabled="readonly"
/>
<UButton
v-if="!readonly"
color="error"
variant="ghost"
icon="i-heroicons-trash"
size="xs"
class="shrink-0"
@click="removeQuestion(index)"
/>
</div>
<div class="flex items-center gap-2">
<div v-if="!readonly" class="flex items-center gap-2">
<UInput
v-model="newQuestion"
placeholder="添加新问题..."
:placeholder="t('ai.assistant.config.addQuestion')"
class="min-w-0 flex-1"
size="sm"
@keyup.enter="addQuestion"
/>
<UButton color="primary" variant="soft" icon="i-heroicons-plus" size="xs" class="shrink-0" @click="addQuestion" />
<UButton
color="primary"
variant="soft"
icon="i-heroicons-plus"
size="xs"
class="shrink-0"
@click="addQuestion"
/>
</div>
</div>
</div>
</div>
<!-- SQL 技能 Tab -->
<div v-show="activeTab === 'skills'" class="space-y-4">
<p class="text-xs text-gray-500 dark:text-gray-400">
SQL 技能会自动注册为 AI 工具助手可通过 Function Calling 调用执行参数化 SQL 查询并格式化结果
</p>
<!-- 技能列表 -->
<div v-for="(skill, index) in skills" :key="index" class="rounded-lg border border-gray-200 dark:border-gray-700">
<!-- 技能标题行 -->
<div
class="flex cursor-pointer items-center justify-between px-3 py-2"
@click="toggleSkill(index)"
>
<span class="text-sm font-medium text-gray-800 dark:text-gray-200">
{{ skill.name || `技能 ${index + 1}(未命名)` }}
</span>
<div class="flex items-center gap-1">
<UButton
color="error"
variant="ghost"
icon="i-heroicons-trash"
size="xs"
@click.stop="removeSkill(index)"
/>
<UIcon
:name="expandedSkillIndex === index ? 'i-heroicons-chevron-up' : 'i-heroicons-chevron-down'"
class="h-4 w-4 text-gray-400"
/>
<!-- 工具管理 Tab -->
<div v-show="activeTab === 'tools'" class="space-y-6">
<!-- 内置工具勾选区 -->
<div>
<div class="mb-2 flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('ai.assistant.config.builtinTools') }}
</h3>
<div v-if="!readonly" class="flex gap-2">
<button class="text-[10px] text-primary-500 hover:text-primary-600" @click="selectAllTools">
{{ t('ai.assistant.config.selectAll') }}
</button>
<span class="text-[10px] text-gray-300 dark:text-gray-600">|</span>
<button class="text-[10px] text-primary-500 hover:text-primary-600" @click="clearAllTools">
{{ t('ai.assistant.config.deselectAll') }}
</button>
</div>
</div>
<!-- 技能编辑区 -->
<div v-if="expandedSkillIndex === index" class="space-y-3 border-t border-gray-200 px-3 py-3 dark:border-gray-700">
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">工具名称</label>
<UInput v-model="skill.name" size="sm" class="w-full" placeholder="如 top_active_members" />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">工具描述</label>
<UInput v-model="skill.description" size="sm" class="w-full" placeholder="查询最活跃成员排行" />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">参数定义JSON Schema</label>
<UTextarea v-model="skill.parametersJson" :rows="4" class="w-full font-mono text-xs" />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">SQL 查询使用 @paramName 引用参数</label>
<UTextarea v-model="skill.query" :rows="4" class="w-full font-mono text-xs" placeholder="SELECT sender_name, COUNT(*) as cnt FROM message WHERE ts > unixepoch('now', '-' || @days || ' days') GROUP BY sender_name ORDER BY cnt DESC LIMIT @limit" />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">行模板{列名} 占位符</label>
<UInput v-model="skill.rowTemplate" size="sm" class="w-full" placeholder="{sender_name}: {cnt} 条消息" />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">空结果提示</label>
<UInput v-model="skill.fallback" size="sm" class="w-full" placeholder="未找到相关数据" />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">汇总模板可选{rowCount} 为行数</label>
<UInput v-model="skill.summaryTemplate" size="sm" class="w-full" placeholder="共找到 {rowCount} 条记录:" />
<p class="mb-3 text-[10px] text-gray-400">
{{ t('ai.assistant.config.builtinToolsHint') }}
</p>
<div v-for="(group, gi) in toolGroups" :key="gi" class="mb-3 last:mb-0">
<h4 class="mb-1.5 text-xs font-medium text-gray-500 dark:text-gray-400">{{ group.label }}</h4>
<div class="grid grid-cols-2 gap-1.5">
<label
v-for="tool in group.tools"
:key="tool.name"
class="flex cursor-pointer items-start gap-2 rounded-md border px-2.5 py-2 transition-colors"
:class="
isToolChecked(tool.name)
? 'border-primary-200 bg-primary-50/50 dark:border-primary-800 dark:bg-primary-950/20'
: 'border-gray-200 dark:border-gray-700'
"
>
<input
type="checkbox"
:checked="form.allowedBuiltinTools.includes(tool.name)"
:disabled="readonly"
class="mt-0.5 h-3.5 w-3.5 shrink-0 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
@change="toggleBuiltinTool(tool.name)"
/>
<div class="min-w-0">
<div class="truncate text-xs font-medium text-gray-800 dark:text-gray-200">{{ tool.name }}</div>
<div class="truncate text-[10px] text-gray-500 dark:text-gray-400">{{ tool.description }}</div>
</div>
</label>
</div>
</div>
</div>
<!-- 添加技能按钮 -->
<button
type="button"
class="flex w-full items-center justify-center gap-1.5 rounded-lg border-2 border-dashed border-gray-300 py-3 text-xs text-gray-500 transition-colors hover:border-primary-400 hover:text-primary-600 dark:border-gray-600 dark:text-gray-400 dark:hover:border-primary-500 dark:hover:text-primary-400"
@click="addSkill"
>
<UIcon name="i-heroicons-plus" class="h-4 w-4" />
添加 SQL 技能
</button>
<!-- 分割线 -->
<div class="border-t border-gray-200 dark:border-gray-700" />
<!-- 自定义 SQL 工具区 -->
<div>
<h3 class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('ai.assistant.config.customSqlTools') }}
<span v-if="hasCustomTools" class="ml-1 text-xs font-normal text-gray-400">
{{ customSqlTools.length }}
</span>
</h3>
<p class="mb-3 text-[10px] text-gray-400">
{{ t('ai.assistant.config.customSqlToolsHint') }}
</p>
<div
v-for="(tool, index) in customSqlTools"
:key="index"
class="mb-2 rounded-lg border border-gray-200 dark:border-gray-700"
>
<div
class="flex cursor-pointer items-center justify-between px-3 py-2"
@click="toggleCustomTool(index)"
>
<span class="text-sm font-medium text-gray-800 dark:text-gray-200">
{{ tool.name || t('ai.assistant.config.toolUntitled', { index: index + 1 }) }}
</span>
<div class="flex items-center gap-1">
<UButton
v-if="!readonly"
color="error"
variant="ghost"
icon="i-heroicons-trash"
size="xs"
@click.stop="removeCustomTool(index)"
/>
<UIcon
:name="expandedToolIndex === index ? 'i-heroicons-chevron-up' : 'i-heroicons-chevron-down'"
class="h-4 w-4 text-gray-400"
/>
</div>
</div>
<div
v-if="expandedToolIndex === index"
class="space-y-3 border-t border-gray-200 px-3 py-3 dark:border-gray-700"
>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('ai.assistant.config.toolName') }}
</label>
<UInput
v-model="tool.name"
size="sm"
class="w-full"
:placeholder="t('ai.assistant.config.toolNamePlaceholder')"
:disabled="readonly"
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('ai.assistant.config.toolDesc') }}
</label>
<UInput
v-model="tool.description"
size="sm"
class="w-full"
:placeholder="t('ai.assistant.config.toolDescPlaceholder')"
:disabled="readonly"
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('ai.assistant.config.toolParams') }}
</label>
<UTextarea
v-model="tool.parametersJson"
:rows="4"
class="w-full font-mono text-xs"
:disabled="readonly"
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('ai.assistant.config.toolQuery') }}
</label>
<UTextarea
v-model="tool.query"
:rows="4"
class="w-full font-mono text-xs"
:disabled="readonly"
:placeholder="t('ai.assistant.config.toolQueryPlaceholder')"
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('ai.assistant.config.toolRowTemplate') }}
</label>
<UInput
v-model="tool.rowTemplate"
size="sm"
class="w-full"
:placeholder="t('ai.assistant.config.toolRowTemplatePlaceholder')"
:disabled="readonly"
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('ai.assistant.config.toolFallback') }}
</label>
<UInput
v-model="tool.fallback"
size="sm"
class="w-full"
:placeholder="t('ai.assistant.config.toolFallbackPlaceholder')"
:disabled="readonly"
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('ai.assistant.config.toolSummary') }}
</label>
<UInput
v-model="tool.summaryTemplate"
size="sm"
class="w-full"
:placeholder="t('ai.assistant.config.toolSummaryPlaceholder')"
:disabled="readonly"
/>
</div>
</div>
</div>
<button
v-if="!readonly"
type="button"
class="flex w-full items-center justify-center gap-1.5 rounded-lg border-2 border-dashed border-gray-300 py-3 text-xs text-gray-500 transition-colors hover:border-primary-400 hover:text-primary-600 dark:border-gray-600 dark:text-gray-400 dark:hover:border-primary-500 dark:hover:text-primary-400"
@click="addCustomTool"
>
<UIcon name="i-heroicons-plus" class="h-4 w-4" />
{{ t('ai.assistant.config.addCustomTool') }}
</button>
</div>
</div>
</div>
@@ -459,18 +733,22 @@ function closeModal() {
<div class="mt-6 flex items-center justify-between">
<div>
<UButton
v-if="config?.builtinId"
v-if="!readonly && !isCreateMode && config?.builtinId"
variant="outline"
color="warning"
:loading="isSaving"
@click="handleReset"
>
恢复默认
{{ t('ai.assistant.config.resetDefault') }}
</UButton>
</div>
<div class="flex gap-2">
<UButton variant="ghost" @click="closeModal">取消</UButton>
<UButton color="primary" :loading="isSaving" @click="handleSave">保存</UButton>
<UButton variant="ghost" @click="closeModal">
{{ readonly ? t('common.close') : t('common.cancel') }}
</UButton>
<UButton v-if="!readonly" color="primary" :loading="isSaving" @click="handleSave">
{{ isCreateMode ? t('ai.assistant.config.create') : t('common.save') }}
</UButton>
</div>
</div>
</template>
@@ -0,0 +1,296 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import { useAssistantStore, type BuiltinAssistantInfo } from '@/stores/assistant'
const { t } = useI18n()
const props = defineProps<{
open: boolean
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
configure: [id: string]
'view-config': [id: string]
create: []
}>()
const assistantStore = useAssistantStore()
const { assistants, builtinCatalog } = storeToRefs(assistantStore)
const activeTab = ref<'local' | 'market'>('local')
const sortedAssistants = computed(() => {
return [...assistants.value].sort((a, b) => {
const orderDiff = (a.order ?? 100) - (b.order ?? 100)
if (orderDiff !== 0) return orderDiff
return a.name.localeCompare(b.name)
})
})
const sortedCatalog = computed(() => {
return [...builtinCatalog.value].sort((a, b) => {
const orderDiff = (a.order ?? 100) - (b.order ?? 100)
if (orderDiff !== 0) return orderDiff
return a.name.localeCompare(b.name)
})
})
const importing = new Set<string>()
onMounted(() => {
assistantStore.loadBuiltinCatalog()
})
function isGeneral(id: string): boolean {
return id === 'general'
}
function handleConfigure(id: string) {
emit('configure', id)
}
async function handleDelete(id: string) {
if (isGeneral(id)) return
await assistantStore.deleteAssistant(id)
}
async function handleDuplicate(id: string) {
await assistantStore.duplicateAssistant(id)
}
function handleCreate() {
emit('create')
}
async function handleImport(builtinId: string) {
if (importing.has(builtinId)) return
importing.add(builtinId)
try {
await assistantStore.importAssistant(builtinId)
} finally {
importing.delete(builtinId)
}
}
async function handleReimport(item: BuiltinAssistantInfo) {
const userAssistant = assistantStore.assistants.find((a) => a.builtinId === item.id)
if (!userAssistant) return
await assistantStore.reimportAssistant(userAssistant.id)
}
function handleViewConfig(builtinId: string) {
const userAssistant = assistantStore.assistants.find((a) => a.builtinId === builtinId)
if (userAssistant) {
emit('view-config', userAssistant.id)
}
}
function getChatTypeLabel(types?: ('group' | 'private')[]): string | null {
if (!types?.length) return null
const labels = types.map((ct) => (ct === 'group' ? t('ai.assistant.config.chatTypeGroup') : t('ai.assistant.config.chatTypePrivate')))
return labels.join(' / ')
}
</script>
<template>
<UModal :open="open" :ui="{ content: 'sm:max-w-xl z-50' }" @update:open="emit('update:open', $event)">
<template #content>
<div class="p-6">
<!-- 标题 -->
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-bold text-gray-900 dark:text-gray-100">{{ t('ai.assistant.market.title') }}</h2>
<button
class="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
@click="emit('update:open', false)"
>
<UIcon name="i-heroicons-x-mark" class="h-5 w-5" />
</button>
</div>
<!-- Tab 切换 -->
<div class="mb-4 flex gap-1 rounded-lg bg-gray-100 p-1 dark:bg-gray-800">
<button
class="flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors"
:class="activeTab === 'local'
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400'"
@click="activeTab = 'local'"
>
{{ t('ai.assistant.market.tabs.local') }}
<span
v-if="assistants.length"
class="ml-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary-100 px-1 text-[10px] text-primary-600 dark:bg-primary-900/30 dark:text-primary-400"
>
{{ assistants.length }}
</span>
</button>
<button
class="flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors"
:class="activeTab === 'market'
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400'"
@click="activeTab = 'market'"
>
{{ t('ai.assistant.market.tabs.market') }}
</button>
</div>
<!-- 本地助手 Tab -->
<div v-show="activeTab === 'local'">
<p class="mb-3 text-xs text-gray-500 dark:text-gray-400">{{ t('ai.assistant.market.localHint') }}</p>
<div class="max-h-[400px] space-y-3 overflow-y-auto pr-1">
<div
v-for="assistant in sortedAssistants"
:key="assistant.id"
class="flex items-center gap-4 rounded-xl border border-gray-200 bg-white p-4 transition-colors dark:border-gray-700 dark:bg-gray-800"
>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ assistant.name }}
</h3>
<span
v-if="getChatTypeLabel(assistant.applicableChatTypes)"
class="inline-flex shrink-0 items-center rounded-full bg-blue-100 px-2 py-0.5 text-[10px] font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
>
{{ getChatTypeLabel(assistant.applicableChatTypes) }}
</span>
<span
v-if="isGeneral(assistant.id)"
class="inline-flex shrink-0 items-center rounded-full bg-green-100 px-2 py-0.5 text-[10px] font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400"
>
{{ t('ai.assistant.market.default') }}
</span>
</div>
<p class="mt-1 line-clamp-1 text-xs text-gray-500 dark:text-gray-400">
{{ assistant.systemPrompt }}
</p>
</div>
<div class="flex shrink-0 items-center gap-1.5">
<button
class="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
:title="t('common.edit')"
@click.stop="handleConfigure(assistant.id)"
>
<UIcon name="i-heroicons-pencil-square" class="h-4 w-4" />
</button>
<button
class="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
:title="t('ai.assistant.market.duplicate')"
@click.stop="handleDuplicate(assistant.id)"
>
<UIcon name="i-heroicons-document-duplicate" class="h-4 w-4" />
</button>
<button
v-if="!isGeneral(assistant.id)"
class="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20 dark:hover:text-red-400"
:title="t('common.delete')"
@click.stop="handleDelete(assistant.id)"
>
<UIcon name="i-heroicons-trash" class="h-4 w-4" />
</button>
</div>
</div>
</div>
<!-- 新增助手按钮 -->
<button
type="button"
class="mt-3 flex w-full items-center justify-center gap-1.5 rounded-xl border-2 border-dashed border-gray-300 py-4 text-sm text-gray-500 transition-colors hover:border-primary-400 hover:text-primary-600 dark:border-gray-600 dark:text-gray-400 dark:hover:border-primary-500 dark:hover:text-primary-400"
@click="handleCreate"
>
<UIcon name="i-heroicons-plus" class="h-4 w-4" />
{{ t('ai.assistant.market.addAssistant') }}
</button>
<div v-if="sortedAssistants.length === 0" class="py-12 text-center text-sm text-gray-400">
{{ t('ai.assistant.market.noLocal') }}
</div>
</div>
<!-- 助手市场 Tab -->
<div v-show="activeTab === 'market'">
<p class="mb-3 text-xs text-gray-500 dark:text-gray-400">{{ t('ai.assistant.market.marketHint') }}</p>
<div class="max-h-[400px] space-y-3 overflow-y-auto pr-1">
<div
v-for="item in sortedCatalog"
:key="item.id"
class="flex items-center gap-4 rounded-xl border border-gray-200 bg-white p-4 transition-colors dark:border-gray-700 dark:bg-gray-800"
>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ item.name }}
</h3>
<span
v-if="getChatTypeLabel(item.applicableChatTypes)"
class="inline-flex shrink-0 items-center rounded-full bg-blue-100 px-2 py-0.5 text-[10px] font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
>
{{ getChatTypeLabel(item.applicableChatTypes) }}
</span>
<span
v-if="isGeneral(item.id)"
class="inline-flex shrink-0 items-center rounded-full bg-green-100 px-2 py-0.5 text-[10px] font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400"
>
{{ t('ai.assistant.market.default') }}
</span>
<span
v-if="item.imported && item.hasUpdate"
class="inline-flex shrink-0 items-center rounded-full bg-orange-100 px-2 py-0.5 text-[10px] font-medium text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"
>
{{ t('ai.assistant.market.updateAvailable') }}
</span>
</div>
<p class="mt-1 line-clamp-1 text-xs text-gray-500 dark:text-gray-400">
{{ item.systemPrompt }}
</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<button
v-if="item.imported && item.hasUpdate"
class="rounded-lg bg-orange-500 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-orange-600"
@click="handleReimport(item)"
>
{{ t('ai.assistant.market.reimport') }}
</button>
<span
v-else-if="item.imported"
class="px-3 py-1.5 text-xs font-medium text-gray-400 dark:text-gray-500"
>
{{ t('ai.assistant.market.imported') }}
</span>
<button
v-else
class="rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-primary-600"
@click="handleImport(item.id)"
>
{{ t('ai.assistant.market.import') }}
</button>
<button
v-if="item.imported"
class="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
:title="t('ai.assistant.market.viewConfig')"
@click.stop="handleViewConfig(item.id)"
>
<UIcon name="i-heroicons-eye" class="h-4 w-4" />
</button>
</div>
</div>
</div>
<div v-if="sortedCatalog.length === 0" class="py-12 text-center text-sm text-gray-400">
{{ t('ai.assistant.market.noCatalog') }}
</div>
</div>
</div>
</template>
</UModal>
</template>
@@ -1,9 +1,12 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import { useAssistantStore } from '@/stores/assistant'
import AssistantCard from './AssistantCard.vue'
const { t } = useI18n()
const props = defineProps<{
chatType: 'group' | 'private'
locale: string
@@ -12,12 +15,11 @@ const props = defineProps<{
const emit = defineEmits<{
select: [id: string]
configure: [id: string]
market: []
}>()
const assistantStore = useAssistantStore()
const { defaultAssistants, moreAssistants, hasMoreAssistants, isLoaded } = storeToRefs(assistantStore)
const showMore = ref(false)
const { filteredAssistants, isLoaded } = storeToRefs(assistantStore)
watch(
() => [props.chatType, props.locale],
@@ -44,70 +46,42 @@ function handleConfigure(id: string) {
</script>
<template>
<div class="flex h-full flex-col items-center justify-center p-8">
<div class="w-full max-w-2xl">
<div class="flex h-full flex-col items-center p-8">
<div class="flex w-full max-w-4xl flex-col" style="max-height: 100%">
<!-- 标题 -->
<div class="mb-8 text-center">
<h2 class="mb-2 text-xl font-bold text-gray-900 dark:text-gray-100">
选择一个助手开始对话
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
每个助手擅长不同类型的分析任务选择最适合你需求的助手
</p>
<div class="mb-8 shrink-0 text-center">
<h2 class="mb-2 text-xl font-bold text-gray-900 dark:text-gray-100">{{ t('ai.assistant.selector.title') }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('ai.assistant.selector.subtitle') }}</p>
</div>
<!-- 无可用助手提示 -->
<div v-if="defaultAssistants.length === 0 && !hasMoreAssistants" class="py-8 text-center text-sm text-gray-400">
当前场景暂无可用助手
<div v-if="filteredAssistants.length === 0" class="py-8 text-center text-sm text-gray-400">
{{ t('ai.assistant.selector.noAssistants') }}
</div>
<!-- 默认助手 4 -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<AssistantCard
v-for="assistant in defaultAssistants"
:key="assistant.id"
:assistant="assistant"
@select="handleSelect"
@configure="handleConfigure"
/>
<!-- 助手卡片可滚动区域 -->
<div class="max-h-[40vh] overflow-y-auto pr-1">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<AssistantCard
v-for="assistant in filteredAssistants"
:key="assistant.id"
:assistant="assistant"
@select="handleSelect"
@configure="handleConfigure"
/>
</div>
</div>
<!-- 更多助手 -->
<div v-if="hasMoreAssistants" class="mt-4">
<!-- 管理助手入口 -->
<div class="mt-6 shrink-0 text-center">
<button
v-if="!showMore"
class="mx-auto flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-300"
@click="showMore = true"
class="inline-flex items-center gap-1.5 text-sm text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
@click="emit('market')"
>
<UIcon name="i-heroicons-chevron-down" class="h-4 w-4" />
<span>更多助手 ({{ moreAssistants.length }})</span>
<UIcon name="i-heroicons-cog-6-tooth" class="h-4 w-4" />
<span>{{ t('ai.assistant.selector.manage') }}</span>
</button>
<Transition name="expand">
<div v-if="showMore" class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<AssistantCard
v-for="assistant in moreAssistants"
:key="assistant.id"
:assistant="assistant"
@select="handleSelect"
@configure="handleConfigure"
/>
</div>
</Transition>
</div>
</div>
</div>
</template>
<style scoped>
.expand-enter-active,
.expand-leave-active {
transition: all 0.3s ease-out;
overflow: hidden;
}
.expand-enter-from,
.expand-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>
@@ -11,6 +11,7 @@ import { useAIChat } from '@/composables/useAIChat'
import CaptureButton from '@/components/common/CaptureButton.vue'
import AssistantSelector from './AssistantSelector.vue'
import AssistantConfigModal from './AssistantConfigModal.vue'
import AssistantMarketModal from './AssistantMarketModal.vue'
import PresetQuestions from './PresetQuestions.vue'
import { usePromptStore } from '@/stores/prompt'
import { useSettingsStore } from '@/stores/settings'
@@ -55,6 +56,8 @@ const promptStore = usePromptStore()
const showAssistantSelector = ref(true)
const configModalVisible = ref(false)
const configModalAssistantId = ref<string | null>(null)
const configModalReadonly = ref(false)
const marketModalVisible = ref(false)
// 当前选中助手的预设问题
const currentPresetQuestions = computed(() => {
@@ -154,12 +157,45 @@ function handleSelectAssistant(id: string) {
startNewConversation(generateWelcomeMessage())
}
// 打开助手配置弹窗
// 打开助手配置弹窗(可编辑)
function handleConfigureAssistant(id: string) {
configModalAssistantId.value = id
configModalReadonly.value = false
configModalVisible.value = true
}
// 打开助手市场
function handleOpenMarket() {
marketModalVisible.value = true
}
// 从市场中打开配置弹窗(可编辑)
function handleMarketConfigure(id: string) {
configModalAssistantId.value = id
configModalReadonly.value = false
configModalVisible.value = true
}
// 从市场中查看配置(只读)
function handleMarketViewConfig(id: string) {
configModalAssistantId.value = id
configModalReadonly.value = true
configModalVisible.value = true
}
// 新建助手(从管理弹窗触发)
function handleCreateAssistant() {
configModalAssistantId.value = null
configModalReadonly.value = false
configModalVisible.value = true
}
// 助手创建完成后刷新列表
async function handleAssistantCreated(_id: string) {
await assistantStore.loadAssistants()
await assistantStore.loadBuiltinCatalog()
}
// 返回助手选择
function handleBackToSelector() {
assistantStore.clearSelection()
@@ -344,6 +380,7 @@ watch(
:locale="settingsStore.locale"
@select="handleSelectAssistant"
@configure="handleConfigureAssistant"
@market="handleOpenMarket"
/>
<!-- 对话区域 -->
@@ -359,7 +396,7 @@ watch(
<UIcon name="i-heroicons-arrow-left" class="h-4 w-4" />
</button>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ assistantStore.selectedAssistant?.name || '助手' }}
{{ assistantStore.selectedAssistant?.name || t('ai.assistant.fallbackName') }}
</span>
</div>
<!-- 消息列表 -->
@@ -486,8 +523,19 @@ watch(
<AssistantConfigModal
:open="configModalVisible"
:assistant-id="configModalAssistantId"
:readonly="configModalReadonly"
@update:open="configModalVisible = $event"
@saved="handleAssistantConfigSaved"
@created="handleAssistantCreated"
/>
<!-- 助手管理弹窗 -->
<AssistantMarketModal
:open="marketModalVisible"
@update:open="marketModalVisible = $event"
@configure="handleMarketConfigure"
@view-config="handleMarketViewConfig"
@create="handleCreateAssistant"
/>
</div>
</template>
+99
View File
@@ -196,5 +196,104 @@
"hoursAgo": "{count} hr ago",
"today": "Today"
}
},
"assistant": {
"selector": {
"title": "Choose an assistant to start",
"subtitle": "Each assistant specializes in different types of analysis tasks. Pick the one that best fits your needs.",
"noAssistants": "No assistants available for this context",
"manage": "Manage Assistants"
},
"config": {
"viewTitle": "View Config",
"createTitle": "New Assistant",
"editTitle": "Assistant Config",
"tabs": {
"basic": "Basic",
"tools": "Tools"
},
"name": "Name",
"systemPrompt": "System Prompt",
"responseRules": "Response Rules",
"chatType": "Chat Type",
"chatTypeAll": "All",
"chatTypeGroup": "Group",
"chatTypePrivate": "Private",
"locale": "Language",
"localeHint": "None selected = all languages",
"presetQuestions": "Preset Questions",
"addQuestion": "Add a question...",
"builtinTools": "Built-in Tools",
"selectAll": "Select All",
"deselectAll": "Deselect All",
"builtinToolsHint": "No selection = all tools available. Only checked tools will be enabled.",
"toolGroupQuery": "Data Query Tools",
"toolGroupSql": "SQL Analysis Tools",
"customSqlTools": "Custom SQL Tools",
"customSqlToolsHint": "Declarative SQL tools are automatically registered as AI function calls. Execute parameterized SQL queries and format results.",
"toolUntitled": "Tool {index} (unnamed)",
"toolName": "Tool Name",
"toolNamePlaceholder": "e.g. top_active_members",
"toolDesc": "Tool Description",
"toolDescPlaceholder": "Query top active members",
"toolParams": "Parameters (JSON Schema)",
"toolQuery": "SQL Query (use @paramName for parameters)",
"toolQueryPlaceholder": "SELECT sender_name, COUNT(*) as cnt FROM message ...",
"toolRowTemplate": "Row Template ({columnName} placeholders)",
"toolRowTemplatePlaceholder": "{sender_name}: {cnt} messages",
"toolFallback": "Empty Result Message",
"toolFallbackPlaceholder": "No data found",
"toolFallbackDefault": "No data found",
"toolSummary": "Summary Template (optional, {rowCount} for row count)",
"toolSummaryPlaceholder": "Found {rowCount} records:",
"addCustomTool": "Add Custom SQL Tool",
"resetDefault": "Reset to Default",
"create": "Create"
},
"market": {
"title": "Manage Assistants",
"tabs": {
"local": "Local",
"market": "Marketplace"
},
"localHint": "Manage imported assistants — configure or delete",
"marketHint": "Browse built-in assistant templates — import to use and freely customize",
"default": "Default",
"updateAvailable": "Update Available",
"reimport": "Re-import",
"imported": "Imported",
"import": "Import",
"viewConfig": "View Config",
"duplicate": "Duplicate",
"addAssistant": "New Assistant",
"noLocal": "No imported assistants yet",
"noCatalog": "No assistant templates available"
},
"fallbackName": "Assistant",
"duplicateSuffix": " (Copy)",
"toast": {
"loadFailed": "Failed to load",
"createSuccess": "Created successfully",
"createFailed": "Failed to create",
"saveSuccess": "Saved successfully",
"saveFailed": "Failed to save",
"resetSuccess": "Reset to default",
"resetFailed": "Failed to reset",
"unknownError": "Unknown error"
},
"builtinToolDesc": {
"search_messages": "Search chat messages",
"get_recent_messages": "Get recent messages",
"get_message_context": "Get message context",
"get_conversation_between": "Get conversation between two members",
"get_group_members": "Get group members",
"get_member_stats": "Get member statistics",
"get_member_name_history": "Get member name history",
"get_time_stats": "Get time statistics",
"search_sessions": "Search sessions",
"get_session_messages": "Get session messages",
"get_session_summaries": "Get session summaries",
"semantic_search_messages": "Semantic search messages"
}
}
}
+99
View File
@@ -196,5 +196,104 @@
"hoursAgo": "{count} 小时前",
"today": "今天"
}
},
"assistant": {
"selector": {
"title": "选择一个助手开始对话",
"subtitle": "每个助手擅长不同类型的分析任务,选择最适合你需求的助手",
"noAssistants": "当前场景暂无可用助手",
"manage": "管理助手"
},
"config": {
"viewTitle": "查看配置",
"createTitle": "新建助手",
"editTitle": "助手配置",
"tabs": {
"basic": "基础配置",
"tools": "工具管理"
},
"name": "名称",
"systemPrompt": "系统提示词",
"responseRules": "回答要求",
"chatType": "适用聊天类型",
"chatTypeAll": "全部",
"chatTypeGroup": "群聊",
"chatTypePrivate": "私聊",
"locale": "适用语言",
"localeHint": "不选 = 全语言",
"presetQuestions": "预设问题",
"addQuestion": "添加新问题...",
"builtinTools": "内置工具",
"selectAll": "全选",
"deselectAll": "全不选",
"builtinToolsHint": "不勾选任何工具 = 全部工具可用。勾选后仅开放已选工具。",
"toolGroupQuery": "数据查询工具",
"toolGroupSql": "SQL 分析工具",
"customSqlTools": "自定义 SQL 工具",
"customSqlToolsHint": "声明式 SQL 工具会自动注册为 AI 函数调用。执行参数化 SQL 查询并格式化结果。",
"toolUntitled": "工具 {index}(未命名)",
"toolName": "工具名称",
"toolNamePlaceholder": "如 top_active_members",
"toolDesc": "工具描述",
"toolDescPlaceholder": "查询最活跃成员排行",
"toolParams": "参数定义(JSON Schema",
"toolQuery": "SQL 查询(使用 @paramName 引用参数)",
"toolQueryPlaceholder": "SELECT sender_name, COUNT(*) as cnt FROM message ...",
"toolRowTemplate": "行模板({columnName} 占位符)",
"toolRowTemplatePlaceholder": "{sender_name}: {cnt} 条消息",
"toolFallback": "空结果提示",
"toolFallbackPlaceholder": "未找到相关数据",
"toolFallbackDefault": "未找到相关数据",
"toolSummary": "汇总模板(可选,{rowCount} 为行数)",
"toolSummaryPlaceholder": "共找到 {rowCount} 条记录:",
"addCustomTool": "添加自定义 SQL 工具",
"resetDefault": "恢复默认",
"create": "创建"
},
"market": {
"title": "助手管理",
"tabs": {
"local": "本地助手",
"market": "助手市场"
},
"localHint": "管理已导入的助手,可配置或删除",
"marketHint": "浏览内置助手模板,导入后即可使用和自由编辑",
"default": "默认",
"updateAvailable": "更新可用",
"reimport": "重新导入",
"imported": "已导入",
"import": "导入",
"viewConfig": "查看配置",
"duplicate": "复制",
"addAssistant": "新增助手",
"noLocal": "暂无已导入的助手",
"noCatalog": "暂无可用助手模板"
},
"fallbackName": "助手",
"duplicateSuffix": "(副本)",
"toast": {
"loadFailed": "加载失败",
"createSuccess": "创建成功",
"createFailed": "创建失败",
"saveSuccess": "保存成功",
"saveFailed": "保存失败",
"resetSuccess": "已恢复默认",
"resetFailed": "恢复失败",
"unknownError": "未知错误"
},
"builtinToolDesc": {
"search_messages": "搜索聊天消息",
"get_recent_messages": "获取最近消息",
"get_message_context": "获取消息上下文",
"get_conversation_between": "获取两人对话",
"get_group_members": "获取群成员列表",
"get_member_stats": "获取成员统计",
"get_member_name_history": "获取成员改名历史",
"get_time_stats": "获取时间统计",
"search_sessions": "搜索会话",
"get_session_messages": "获取会话消息",
"get_session_summaries": "获取会话摘要",
"semantic_search_messages": "语义搜索消息"
}
}
}
+140 -12
View File
@@ -1,19 +1,19 @@
/**
* 助手管理 Store
* 管理助手列表缓存、当前选中助手、配置 CRUD
* 管理助手列表缓存、当前选中助手、配置 CRUD、内置助手目录
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { i18n } from '@/i18n'
export interface AssistantSummary {
id: string
name: string
description: string
systemPrompt: string
presetQuestions: string[]
order?: number
builtinId?: string
isUserModified?: boolean
applicableChatTypes?: ('group' | 'private')[]
supportedLocales?: string[]
}
@@ -21,25 +21,49 @@ export interface AssistantSummary {
export interface AssistantConfigFull {
id: string
name: string
description: string
systemPrompt: string
responseRules?: string
presetQuestions: string[]
allowedBuiltinTools?: string[]
customSkills?: unknown[]
customSqlTools?: unknown[]
version: number
builtinId?: string
isUserModified?: boolean
order?: number
applicableChatTypes?: ('group' | 'private')[]
supportedLocales?: string[]
}
export interface BuiltinAssistantInfo {
id: string
name: string
systemPrompt: string
version: number
order?: number
applicableChatTypes?: ('group' | 'private')[]
supportedLocales?: string[]
imported: boolean
hasUpdate: boolean
}
export interface BuiltinSqlToolInfo {
name: string
description: string
}
export const useAssistantStore = defineStore('assistant', () => {
const assistants = ref<AssistantSummary[]>([])
const selectedAssistantId = ref<string | null>(null)
const isLoaded = ref(false)
/** 内置助手模板目录(助手市场数据源) */
const builtinCatalog = ref<BuiltinAssistantInfo[]>([])
/** 内置 SQL 工具目录 */
const builtinSqlTools = ref<BuiltinSqlToolInfo[]>([])
/** 内置 TS 工具名称列表 */
const builtinTsToolNames = ref<string[]>([])
/** 当前过滤条件 */
const currentChatType = ref<'group' | 'private'>('group')
const currentLocale = ref<string>('zh-CN')
@@ -49,7 +73,7 @@ export const useAssistantStore = defineStore('assistant', () => {
return assistants.value.find((a) => a.id === selectedAssistantId.value) ?? null
})
/** 根据聊天类型和语言过滤后的助手列表 */
/** 根据聊天类型和语言过滤后的助手列表(导入的 = 全部可用的) */
const filteredAssistants = computed(() => {
return assistants.value.filter((a) => {
const typeMatch =
@@ -61,7 +85,6 @@ export const useAssistantStore = defineStore('assistant', () => {
})
})
/** 默认展示的前 N 个助手 */
const defaultVisibleCount = 4
const defaultAssistants = computed(() => filteredAssistants.value.slice(0, defaultVisibleCount))
@@ -84,6 +107,30 @@ export const useAssistantStore = defineStore('assistant', () => {
}
}
async function loadBuiltinCatalog(): Promise<void> {
try {
builtinCatalog.value = await window.assistantApi.getBuiltinCatalog()
} catch (error) {
console.error('[AssistantStore] Failed to load builtin catalog:', error)
}
}
async function loadBuiltinSqlTools(): Promise<void> {
try {
builtinSqlTools.value = await window.assistantApi.getBuiltinSqlTools()
} catch (error) {
console.error('[AssistantStore] Failed to load builtin sql tools:', error)
}
}
async function loadBuiltinTsToolNames(): Promise<void> {
try {
builtinTsToolNames.value = await window.assistantApi.getBuiltinTsToolNames()
} catch (error) {
console.error('[AssistantStore] Failed to load builtin ts tool names:', error)
}
}
function selectAssistant(id: string): void {
selectedAssistantId.value = id
}
@@ -128,12 +175,82 @@ export const useAssistantStore = defineStore('assistant', () => {
}
}
async function importAssistant(builtinId: string): Promise<{ success: boolean; error?: string }> {
try {
const result = await window.assistantApi.importAssistant(builtinId)
if (result.success) {
await loadAssistants()
await loadBuiltinCatalog()
}
return result
} catch (error) {
return { success: false, error: String(error) }
}
}
async function reimportAssistant(id: string): Promise<{ success: boolean; error?: string }> {
try {
const result = await window.assistantApi.reimportAssistant(id)
if (result.success) {
await loadAssistants()
await loadBuiltinCatalog()
}
return result
} catch (error) {
return { success: false, error: String(error) }
}
}
async function createAssistant(
config: Omit<AssistantConfigFull, 'id' | 'version'>
): Promise<{ success: boolean; id?: string; error?: string }> {
try {
const result = await window.assistantApi.create(config)
if (result.success) {
await loadAssistants()
}
return result
} catch (error) {
return { success: false, error: String(error) }
}
}
async function duplicateAssistant(id: string): Promise<{ success: boolean; error?: string }> {
try {
const config = await window.assistantApi.getConfig(id)
if (!config) {
return { success: false, error: 'Assistant not found' }
}
const { id: _id, version: _ver, builtinId: _bid, ...rest } = config
const result = await window.assistantApi.create({
...rest,
name: `${config.name}${i18n.global.t('ai.assistant.duplicateSuffix')}`,
})
if (result.success) {
await loadAssistants()
await loadBuiltinCatalog()
}
return result
} catch (error) {
return { success: false, error: String(error) }
}
}
async function deleteAssistant(id: string): Promise<{ success: boolean; error?: string }> {
try {
const result = await window.assistantApi.delete(id)
if (result.success) {
await loadAssistants()
await loadBuiltinCatalog()
}
return result
} catch (error) {
return { success: false, error: String(error) }
}
}
const promptMigrationDone = ref(false)
/**
* 检查并迁移旧提示词预设数据
* 仅在首次检测到旧数据时执行,备份后标记为已完成
*/
async function migrateOldPromptPresets(): Promise<void> {
if (promptMigrationDone.value) return
@@ -179,6 +296,9 @@ export const useAssistantStore = defineStore('assistant', () => {
selectedAssistantId,
selectedAssistant,
isLoaded,
builtinCatalog,
builtinSqlTools,
builtinTsToolNames,
currentChatType,
currentLocale,
filteredAssistants,
@@ -187,12 +307,20 @@ export const useAssistantStore = defineStore('assistant', () => {
hasMoreAssistants,
promptMigrationDone,
loadAssistants,
loadBuiltinCatalog,
loadBuiltinSqlTools,
loadBuiltinTsToolNames,
selectAssistant,
clearSelection,
setFilterContext,
getAssistantConfig,
updateAssistant,
createAssistant,
duplicateAssistant,
resetAssistant,
importAssistant,
reimportAssistant,
deleteAssistant,
migrateOldPromptPresets,
}
})