mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-13 01:30:57 +08:00
feat: 完善助手功能,新增分析tools
This commit is contained in:
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -12,5 +12,9 @@ export {
|
||||
createAssistant,
|
||||
deleteAssistant,
|
||||
resetAssistant,
|
||||
getBuiltinCatalog,
|
||||
importAssistant,
|
||||
reimportAssistant,
|
||||
backupOldPromptPresets,
|
||||
} from './manager'
|
||||
export { getBuiltinSqlToolCatalog, getBuiltinTsToolNames } from './builtinSqlTools'
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
+37
-31
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 =====
|
||||
|
||||
@@ -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 系统提示词 =====
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
Vendored
+26
-5
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user