From f1625b4353dd46c4450664516459c1fc8f0602da Mon Sep 17 00:00:00 2001 From: thzxx Date: Sat, 18 Apr 2026 09:22:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v4.2=20=E6=99=BA=E8=83=BD=E8=BF=9B?= =?UTF-8?q?=E5=8C=96=20=E2=80=94=20=E6=8A=80=E8=83=BD=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E7=94=9F=E6=88=90=20+=20=E7=94=A8=E6=88=B7=E7=94=BB=E5=83=8F?= =?UTF-8?q?=20+=20=E4=BA=BA=E6=A0=BC=E6=A8=A1=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增功能: 1. 技能自动生成系统 (Skill Loop) - 新增 skills 表:id/name/description/trigger_keywords/tool_chain/success_count/fail_count - skill-manager.ts: 从成功工具调用链自动提取可复用技能 - 关键词匹配 + 成功率加权的技能检索 - 匹配到的技能自动注入 system prompt(含工具链步骤和成功率) - Agent Loop 结束后自动提取,开始前自动匹配注入 - DB/IPC/Preload 全链路支持 2. 用户画像模型 - 自动从对话中推断技术栈(14 种技术关键词检测) - 画像存储在 settings.user_profile - 自动注入 system prompt(技术栈/角色/风格) - 随使用不断积累完善 3. 人格模板切换(6 种预设) - 🤖 通用助手(默认,温度跟随设置) - 💻 编程助手(温度 0.3,注重代码质量) - ✍️ 写作助手(温度 0.8,注重文采流畅) - 📊 数据分析(温度 0.2,注重精确逻辑) - 🌐 翻译助手(温度 0.4,注重信达雅) - 🔍 代码审查(温度 0.3,注重安全性能) - 设置面板 3×2 网格按钮,点击切换 + 自动调整温度 修改文件:9 个已有 + 1 个新建(skill-manager.ts) --- src/main/db/sqlite.ts | 90 +++++++ src/main/ipc.ts | 27 ++ src/main/preload.ts | 7 + src/renderer/components/settings-modal.ts | 54 ++++ src/renderer/index.html | 12 + src/renderer/main.ts | 15 ++ src/renderer/services/agent-engine.ts | 103 +++++++- src/renderer/services/log-service.ts | 1 + src/renderer/services/skill-manager.ts | 302 ++++++++++++++++++++++ src/renderer/styles/style.css | 16 ++ 10 files changed, 623 insertions(+), 4 deletions(-) create mode 100644 src/renderer/services/skill-manager.ts diff --git a/src/main/db/sqlite.ts b/src/main/db/sqlite.ts index ca82924..2dfb948 100644 --- a/src/main/db/sqlite.ts +++ b/src/main/db/sqlite.ts @@ -203,6 +203,22 @@ export async function initDatabase(): Promise { FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_traces_session ON traces(session_id, created_at); + + -- v4.2 技能表(自动从成功工具调用链提取) + CREATE TABLE IF NOT EXISTS skills ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL, + trigger_keywords TEXT, + tool_chain TEXT NOT NULL, + success_count INTEGER DEFAULT 0, + fail_count INTEGER DEFAULT 0, + avg_duration_ms INTEGER DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_used_at INTEGER + ); + CREATE INDEX IF NOT EXISTS idx_skills_updated ON skills(updated_at DESC); `); // 尝试创建 FTS5 全文搜索(可选,sql.js 默认 WASM 可能不包含 FTS5) @@ -286,6 +302,21 @@ export interface ToolCallRow { created_at: number; } +// v4.2 +export interface SkillRow { + id: string; + name: string; + description: string; + trigger_keywords: string | null; + tool_chain: string; + success_count: number; + fail_count: number; + avg_duration_ms: number; + created_at: number; + updated_at: number; + last_used_at: number | null; +} + export interface TraceRow { id: string; session_id: string; @@ -487,6 +518,65 @@ export function getTracesBySession(sessionId: string): TraceRow[] { return queryAll(getDb(), 'SELECT * FROM traces WHERE session_id = ? ORDER BY step_index ASC', [sessionId]) as unknown as TraceRow[]; } +// ─── Skills CRUD (v4.2) ─── + +export function saveSkill(skill: SkillRow): string { + runExec(getDb(), `INSERT OR REPLACE INTO skills (id, name, description, trigger_keywords, tool_chain, success_count, fail_count, avg_duration_ms, created_at, updated_at, last_used_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [skill.id, skill.name, skill.description, skill.trigger_keywords, skill.tool_chain, + skill.success_count, skill.fail_count, skill.avg_duration_ms, + skill.created_at, skill.updated_at, skill.last_used_at] + ); + persist(); + return skill.id; +} + +export function getSkill(id: string): SkillRow | null { + return queryOne(getDb(), 'SELECT * FROM skills WHERE id = ?', [id]) as unknown as SkillRow | null; +} + +export function getAllSkills(): SkillRow[] { + return queryAll(getDb(), 'SELECT * FROM skills ORDER BY success_count DESC, updated_at DESC') as unknown as SkillRow[]; +} + +export function deleteSkill(id: string): void { + runExec(getDb(), 'DELETE FROM skills WHERE id = ?', [id]); + persist(); +} + +export function clearAllSkills(): void { + getDb().run('DELETE FROM skills'); + persist(); +} + +export function searchSkills(keywords: string, limit = 5): SkillRow[] { + const likeQ = `%${keywords.replace(/[^\w\u4e00-\u9fff]/g, '%')}%`; + return queryAll(getDb(), ` + SELECT * FROM skills + WHERE trigger_keywords LIKE ? OR name LIKE ? OR description LIKE ? + ORDER BY success_count DESC, updated_at DESC + LIMIT ? + `, [likeQ, likeQ, likeQ, limit]) as unknown as SkillRow[]; +} + +export function incrementSkillUsage(id: string, success: boolean, durationMs: number): void { + const d = getDb(); + const skill = queryOne(d, 'SELECT success_count, fail_count, avg_duration_ms FROM skills WHERE id = ?', [id]) as any; + if (!skill) return; + + const newSuccess = success ? skill.success_count + 1 : skill.success_count; + const newFail = success ? skill.fail_count : skill.fail_count + 1; + const totalUses = newSuccess + newFail; + // 指数移动平均 + const alpha = 0.3; + const newAvg = Math.round(alpha * durationMs + (1 - alpha) * (skill.avg_duration_ms || durationMs)); + + runExec(d, `UPDATE skills SET success_count = ?, fail_count = ?, avg_duration_ms = ?, updated_at = ?, last_used_at = ? WHERE id = ?`, + [newSuccess, newFail, newAvg, Date.now(), Date.now(), id] + ); + persist(); +} + // ─── Export/Import ─── export interface ExportData { diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 6d6d8af..42cc578 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -14,6 +14,7 @@ import { saveSetting, getSetting, saveToolCall, getToolCallsBySession, saveTrace, getTracesBySession, + saveSkill, getAllSkills, deleteSkill, clearAllSkills, searchSkills, incrementSkillUsage, exportAllSessions, importSessions } from './db/sqlite.js'; import type { ExportData } from './db/sqlite.js'; @@ -371,4 +372,30 @@ export async function setupIPC(): Promise { try { return importSessions(data); } catch (err) { sendLog('error', '导入失败', (err as Error).message); return { imported: 0, skipped: 0 }; } }); + + // ── Skills (v4.2) ── + ipcMain.handle('db:saveSkill', (_, skill) => { + try { return { success: true, id: saveSkill(skill) }; } + catch (err) { return { success: false, error: (err as Error).message }; } + }); + ipcMain.handle('db:getAllSkills', () => { + try { return getAllSkills(); } + catch { return []; } + }); + ipcMain.handle('db:deleteSkill', (_, id) => { + try { deleteSkill(id); return { success: true }; } + catch (err) { return { success: false, error: (err as Error).message }; } + }); + ipcMain.handle('db:clearAllSkills', () => { + try { clearAllSkills(); return { success: true }; } + catch (err) { return { success: false, error: (err as Error).message }; } + }); + ipcMain.handle('db:searchSkills', (_, query: string, limit?: number) => { + try { return searchSkills(query, limit ?? 5); } + catch { return []; } + }); + ipcMain.handle('db:incrementSkillUsage', (_, id: string, success: boolean, durationMs: number) => { + try { incrementSkillUsage(id, success, durationMs); return { success: true }; } + catch (err) { return { success: false, error: (err as Error).message }; } + }); } diff --git a/src/main/preload.ts b/src/main/preload.ts index 6c21a93..6f2c5dd 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -66,6 +66,13 @@ contextBridge.exposeInMainWorld('metonaDesktop', { getTraces: (sessionId: string) => ipcRenderer.invoke('db:getTraces', sessionId), exportSessions: () => ipcRenderer.invoke('db:exportSessions'), importSessions: (data: unknown) => ipcRenderer.invoke('db:importSessions', data), + // v4.2 Skills + saveSkill: (skill: unknown) => ipcRenderer.invoke('db:saveSkill', skill), + getAllSkills: () => ipcRenderer.invoke('db:getAllSkills'), + deleteSkill: (id: string) => ipcRenderer.invoke('db:deleteSkill', id), + clearAllSkills: () => ipcRenderer.invoke('db:clearAllSkills'), + searchSkills: (query: string, limit?: number) => ipcRenderer.invoke('db:searchSkills', query, limit), + incrementSkillUsage: (id: string, success: boolean, durationMs: number) => ipcRenderer.invoke('db:incrementSkillUsage', id, success, durationMs), }, workspace: { getDir: () => ipcRenderer.invoke('workspace:getDir'), diff --git a/src/renderer/components/settings-modal.ts b/src/renderer/components/settings-modal.ts index 5d043aa..b0e7e1e 100644 --- a/src/renderer/components/settings-modal.ts +++ b/src/renderer/components/settings-modal.ts @@ -15,6 +15,16 @@ import { OllamaAPI } from '../api/ollama.js'; import { ChatDB } from '../db/chat-db.js'; import type { ChatSession } from '../types.js'; +/** v4.2 人格模式 system prompt 映射 */ +export const PERSONALITY_SYSTEM_PROMPTS: Record = { + default: '', + coder: '你是一个专业的编程助手。你注重代码质量、可读性和最佳实践。回答时优先给出可运行的代码,其次才是解释。遇到问题先分析根因再给方案。使用 type hints、合理的命名和注释。代码风格偏向简洁紧凑。', + writer: '你是一个专业的写作助手。你的文字流畅、有文采,注重结构和节奏。擅长技术文档、文章、文案、邮件等各种文体。回答时先理解写作目的和受众,再给出合适的风格和内容。', + analyst: '你是一个精确的数据分析助手。你注重数据的准确性和逻辑的严密性。回答时先明确数据来源和假设,再给出分析过程和结论。偏好用数字和事实说话,避免模糊表述。', + translator: '你是一个专业的翻译助手。你精通多语言互译,注重信达雅。翻译时保留原文的语气和风格,专业术语保持一致。遇到歧义时给出多种翻译并标注区别。', + reviewer: '你是一个严格的代码审查助手。你关注:正确性(逻辑错误、边界情况)、安全性(注入、泄露)、性能(时间/空间复杂度)、可维护性(命名、结构、注释)。审查时先给出总体评价,再逐项列出问题并给出修复建议。', +}; + let settingsModalEl: HTMLElement; export function initSettingsModal(): void { @@ -63,6 +73,50 @@ export function initSettingsModal(): void { }, 300); tempSlider.addEventListener('change', saveTemperature); + // ── v4.2 人格模式切换 ── + const PERSONALITY_TEMPERATURES: Record = { + default: -1, // 使用用户设置 + coder: 0.3, + writer: 0.8, + analyst: 0.2, + translator: 0.4, + reviewer: 0.3, + }; + + document.querySelectorAll('.personality-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const personality = (btn as HTMLElement).dataset.personality || 'default'; + state.set('personality', personality); + const db = state.get(KEYS.DB); + if (db) await db.saveSetting('personality', personality); + + // 更新按钮样式 + document.querySelectorAll('.personality-btn').forEach(b => { + (b as HTMLElement).style.border = '2px solid var(--border-subtle)'; + b.classList.remove('active'); + }); + (btn as HTMLElement).style.border = '2px solid var(--primary)'; + btn.classList.add('active'); + + // 如果人格有推荐温度,自动调整 + const recommendedTemp = PERSONALITY_TEMPERATURES[personality]; + if (recommendedTemp >= 0) { + const tempSlider = document.querySelector('#inputTemperature') as HTMLInputElement; + tempSlider.value = String(recommendedTemp); + document.querySelector('#tempValue')!.textContent = recommendedTemp.toFixed(1); + state.set('temperature', recommendedTemp); + if (db) await db.saveSetting('temperature', recommendedTemp); + } + + const names: Record = { + default: '通用助手', coder: '编程助手', writer: '写作助手', + analyst: '数据分析', translator: '翻译助手', reviewer: '代码审查' + }; + showToast(`已切换到 ${names[personality] || personality}`, 'success'); + logSetting('人格模式', names[personality] || personality); + }); + }); + document.querySelector('#btnReleaseVRAM')!.addEventListener('click', async () => { const api = state.get(KEYS.API); const model = (document.querySelector('#modelSelect') as HTMLSelectElement).value; diff --git a/src/renderer/index.html b/src/renderer/index.html index b55a27a..b660eb8 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -260,6 +260,18 @@

低值更精确稳定,高值更有创意多样(0 = 确定性,2 = 最大随机)

+
+ +
+ + + + + + +
+

不同人格有不同的行为风格和温度偏好。通用助手不做额外约束。

+
diff --git a/src/renderer/main.ts b/src/renderer/main.ts index 7371594..eff2700 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -363,6 +363,21 @@ async function init(): Promise { const memoryEnabled = await db.getSetting('memoryEnabled', true); state.set('memoryEnabled', memoryEnabled); + // ── v4.2 人格模式 ── + const personality = await db.getSetting('personality', 'default'); + state.set('personality', personality); + // 激活对应的人格按钮 + document.querySelectorAll('.personality-btn').forEach(b => { + const p = (b as HTMLElement).dataset.personality; + if (p === personality) { + (b as HTMLElement).style.border = '2px solid var(--primary)'; + b.classList.add('active'); + } else { + (b as HTMLElement).style.border = '2px solid var(--border-subtle)'; + b.classList.remove('active'); + } + }); + (document.querySelector('#chatInput') as HTMLElement)?.focus(); updateTotalTokens(); diff --git a/src/renderer/services/agent-engine.ts b/src/renderer/services/agent-engine.ts index 4b70bf3..70f9c62 100644 --- a/src/renderer/services/agent-engine.ts +++ b/src/renderer/services/agent-engine.ts @@ -12,6 +12,8 @@ import { needsConfirmation } from './tool-registry.js'; import { searchMemories, buildMemoryContext, markMemoryUsed, isMemoryEnabled } from './memory-manager.js'; +import { extractSkillsFromToolRecords, matchSkills, buildSkillContext } from './skill-manager.js'; +import { PERSONALITY_SYSTEM_PROMPTS } from '../components/settings-modal.js'; import { showToast } from '../components/toast.js'; import { logInfo, logWarn, logError, logToolStart, logToolResult, logThink, logAgentLoop, logModelResponse } from './log-service.js'; import { getWorkspaceDirPath } from '../components/workspace-panel.js'; @@ -136,7 +138,53 @@ function parseToolCallsFromText(content: string): ToolCall[] { return calls; } -/** 工具调用缓存:key = toolName + JSON(args),用于避免重复调用 */ +/** v4.2: 从对话中自动推断用户画像 */ +async function inferUserProfile(userMsg: string, assistantMsg: string): Promise { + const bridge = (window as any).metonaDesktop; + if (!bridge?.db?.getSetting) return; + + const profile: Record = await bridge.db.getSetting('user_profile', {}) || {}; + + // 技术栈检测 + const techPatterns: Record = { + 'Python': /\b(python|pip|py|django|flask|fastapi|pandas|numpy)\b/i, + 'TypeScript': /\b(typescript|ts|tsx|deno)\b/i, + 'JavaScript': /\b(javascript|js|jsx|node\.?js|npm|yarn|pnpm)\b/i, + 'Go': /\b(golang|go\s+(run|build|mod)|\.go\b)/i, + 'Rust': /\b(rust|cargo|rustc|\.rs\b)/i, + 'Java': /\b(java|maven|gradle|spring|\.java\b)/i, + 'React': /\b(react|next\.?js|jsx|tsx|vite)\b/i, + 'Vue': /\b(vue|nuxt|vuex|pinia)\b/i, + 'Docker': /\b(docker|dockerfile|docker-compose|container)\b/i, + 'Kubernetes': /\b(k8s|kubernetes|kubectl|helm)\b/i, + 'Linux': /\b(linux|ubuntu|debian|centos|bash|shell|terminal)\b/i, + 'Git': /\b(git|github|gitee|gitlab|commit|push|pull|branch|merge)\b/i, + 'SQL': /\b(sql|sqlite|mysql|postgres|database|查询)\b/i, + 'AI/ML': /\b(machine learning|深度学习|神经网络|模型训练|ollama|llm|transformer)\b/i, + }; + + const combinedText = userMsg + ' ' + assistantMsg; + const currentStack: string[] = (profile.tech_stack as string[]) || []; + let changed = false; + + for (const [tech, pattern] of Object.entries(techPatterns)) { + if (pattern.test(combinedText) && !currentStack.includes(tech)) { + currentStack.push(tech); + changed = true; + } + } + + // 限制技术栈数量 + if (currentStack.length > 15) { + currentStack.splice(0, currentStack.length - 15); + changed = true; + } + + if (changed) { + profile.tech_stack = currentStack; + await bridge.db.saveSetting('user_profile', profile); + } +} const toolResultCache = new Map(); /** 生成工具调用缓存 key */ @@ -342,10 +390,45 @@ export async function runAgentLoop( 文件操作工具(read_file、write_file 等)的相对路径基于此目录解析。`); } + // v4.2 注入匹配的技能上下文 + if (useTools && userContent) { + const matchedSkills = await matchSkills(userContent, 3); + if (matchedSkills.length > 0) { + const skillContext = buildSkillContext(matchedSkills); + if (skillContext) { + systemPromptParts.push(skillContext); + logInfo(`技能匹配: ${matchedSkills.length} 个技能已注入`, matchedSkills.map(s => s.name).join(', ')); + } + } + } + + // v4.2 注入用户画像 + const db = state.get(KEYS.DB); + if (db) { + try { + const userProfile = await db.getSetting('user_profile', null); + if (userProfile && typeof userProfile === 'object') { + const parts: string[] = []; + if (userProfile.tech_stack?.length) parts.push(`技术栈: ${userProfile.tech_stack.join(', ')}`); + if (userProfile.role) parts.push(`角色: ${userProfile.role}`); + if (userProfile.style) parts.push(`代码风格: ${userProfile.style}`); + if (parts.length > 0) { + systemPromptParts.push(`【用户画像】\n${parts.join('\n')}`); + } + } + } catch { /* 不阻塞 */ } + } + // 组合 system prompt - const fullSystemPrompt = systemPromptParts.length > 0 - ? systemPromptParts.join('\n\n') + '\n\n' + AGENT_SYSTEM_PROMPT + '\n\n' + TOOL_USAGE_GUIDE - : AGENT_SYSTEM_PROMPT + '\n\n' + TOOL_USAGE_GUIDE; + const personality = state.get('personality', 'default'); + const personalityPrompt = PERSONALITY_SYSTEM_PROMPTS[personality] || ''; + + const fullSystemPrompt = [ + ...systemPromptParts, + AGENT_SYSTEM_PROMPT, + ...(personalityPrompt ? [personalityPrompt] : []), + TOOL_USAGE_GUIDE + ].join('\n\n'); messages.push({ role: 'system', content: fullSystemPrompt }); @@ -538,6 +621,18 @@ export async function runAgentLoop( } catch { /* 不阻塞 */ } } + // v4.2 自动提取技能 + if (allToolRecords.length >= 2) { + try { + await extractSkillsFromToolRecords(allToolRecords, userContent, currentSession?.title || ''); + } catch { /* 不阻塞 */ } + } + + // v4.2 自动推断用户画像 + try { + await inferUserProfile(userContent, content); + } catch { /* 不阻塞 */ } + callbacks.onDone(content || '(模型未返回内容)', allToolRecords.length > 0 ? allToolRecords : undefined, makeStats()); return; } diff --git a/src/renderer/services/log-service.ts b/src/renderer/services/log-service.ts index 3f0ae88..a0f83c4 100644 --- a/src/renderer/services/log-service.ts +++ b/src/renderer/services/log-service.ts @@ -181,6 +181,7 @@ export function logConnection(status: string, detail?: string): void { addLog(st export function logModel(name: string, action: string): void { addLog('info', `模型: ${action}`, name); } export function logSession(action: string, detail?: string): void { addLog('info', `会话: ${action}`, detail); } export function logMemory(action: string, detail?: string): void { addLog('debug', `记忆: ${action}`, detail); } +export function logSkill(action: string, detail?: string): void { addLog('debug', `技能: ${action}`, detail); } export function logRAG(action: string, detail?: string): void { addLog('info', `向量: ${action}`, detail); } export function logSetting(key: string, value: string): void { addLog('debug', `设置: ${key}`, value); } export function logInit(msg: string): void { addLog('info', `初始化: ${msg}`); } diff --git a/src/renderer/services/skill-manager.ts b/src/renderer/services/skill-manager.ts new file mode 100644 index 0000000..499bf9e --- /dev/null +++ b/src/renderer/services/skill-manager.ts @@ -0,0 +1,302 @@ +/** + * SkillManager - 技能自动生成系统 (v4.2) + * 从成功的工具调用链中提取可复用技能,匹配用户意图后注入上下文 + */ + +import { state, KEYS } from '../state/state.js'; +import { generateId } from '../utils/utils.js'; +import { logInfo, logWarn, logSkill } from './log-service.js'; +import type { ToolCallRecord, ChatSession } from '../types.js'; + +/** 技能数据结构 */ +export interface Skill { + id: string; + name: string; + description: string; + trigger_keywords: string | null; + tool_chain: string; // JSON: ToolChainStep[] + success_count: number; + fail_count: number; + avg_duration_ms: number; + created_at: number; + updated_at: number; + last_used_at: number | null; +} + +/** 工具链步骤 */ +interface ToolChainStep { + name: string; + description: string; // 该步骤的作用描述 + args_hint: string; // 参数提示(哪些参数是关键的) +} + +/** 最小工具链长度,太短的不算技能 */ +const MIN_CHAIN_LENGTH = 2; + +/** 技能匹配最低阈值 */ +const MATCH_THRESHOLD = 0.3; + +/** + * 从 Agent Loop 完成后的工具调用记录中提取技能 + * 只提取成功的、链长 >= 2 的工具调用序列 + */ +export async function extractSkillsFromToolRecords( + toolRecords: ToolCallRecord[], + userMessage: string, + sessionTitle: string +): Promise { + const bridge = (window as any).metonaDesktop; + if (!bridge?.db?.saveSkill) return 0; + + // 只看成功的工具调用 + const successfulTools = toolRecords.filter(t => t.status === 'success'); + if (successfulTools.length < MIN_CHAIN_LENGTH) return 0; + + // 提取工具链 + const chain: ToolChainStep[] = successfulTools.map(t => ({ + name: t.name, + description: describeToolStep(t), + args_hint: extractKeyArgs(t) + })); + + const chainNames = chain.map(s => s.name).join(' → '); + + // 检查是否已有完全相同的技能链 + const existingSkills: Skill[] = await bridge.db.getAllSkills(); + const duplicate = existingSkills.find(s => { + try { + const existingChain: ToolChainStep[] = JSON.parse(s.tool_chain); + return existingChain.map((c: ToolChainStep) => c.name).join(' → ') === chainNames; + } catch { return false; } + }); + + if (duplicate) { + // 已有相同技能,不重复创建 + logInfo(`技能已存在,跳过提取: ${duplicate.name}`); + return 0; + } + + // 从用户消息中提取触发关键词 + const keywords = extractKeywords(userMessage); + + // 生成技能名称和描述 + const name = generateSkillName(chain, userMessage); + const description = generateSkillDescription(chain, sessionTitle); + + const skill: Skill = { + id: `skill_${generateId()}`, + name, + description, + trigger_keywords: keywords, + tool_chain: JSON.stringify(chain), + success_count: 1, + fail_count: 0, + avg_duration_ms: toolRecords.reduce((sum, t) => sum + (t.timestamp - (toolRecords[0]?.timestamp || t.timestamp)), 0), + created_at: Date.now(), + updated_at: Date.now(), + last_used_at: null + }; + + try { + await bridge.db.saveSkill(skill); + logSkill(`新技能提取: ${name}`, `链: ${chainNames}`); + return 1; + } catch (err) { + logWarn('技能保存失败', (err as Error).message); + return 0; + } +} + +/** + * 匹配用户消息与已有技能 + * 返回匹配度最高的技能列表 + */ +export async function matchSkills(userMessage: string, limit = 3): Promise { + const bridge = (window as any).metonaDesktop; + if (!bridge?.db?.getAllSkills) return []; + + try { + const allSkills: Skill[] = await bridge.db.getAllSkills(); + if (allSkills.length === 0) return []; + + const messageLower = userMessage.toLowerCase(); + const messageWords = new Set(messageLower.split(/[\s,,。!?、;:""''()\[\]【】]+/).filter(w => w.length > 1)); + + const scored = allSkills.map(skill => { + let score = 0; + + // 关键词匹配 + if (skill.trigger_keywords) { + const kwSet = new Set(skill.trigger_keywords.toLowerCase().split(',').map(k => k.trim())); + for (const kw of kwSet) { + if (kw && messageLower.includes(kw)) score += 0.4; + } + } + + // 名称和描述匹配 + const nameWords = skill.name.toLowerCase().split(/[\s→\-]+/); + for (const w of nameWords) { + if (w.length > 1 && messageWords.has(w)) score += 0.2; + } + + // 成功率加成 + const total = skill.success_count + skill.fail_count; + if (total > 0) score *= (0.5 + 0.5 * (skill.success_count / total)); + + return { skill, score }; + }); + + return scored + .filter(s => s.score >= MATCH_THRESHOLD) + .sort((a, b) => b.score - a.score) + .slice(0, limit) + .map(s => s.skill); + } catch { + return []; + } +} + +/** + * 从匹配的技能构建 system prompt 上下文 + */ +export function buildSkillContext(skills: Skill[]): string { + if (skills.length === 0) return ''; + + const parts = skills.map(skill => { + let chain: ToolChainStep[] = []; + try { chain = JSON.parse(skill.tool_chain); } catch { return ''; } + + const steps = chain.map((s, i) => + ` ${i + 1}. ${s.name}(${s.description})${s.args_hint ? ` — 参数: ${s.args_hint}` : ''}` + ).join('\n'); + + const total = skill.success_count + skill.fail_count; + const rate = total > 0 ? Math.round(skill.success_count / total * 100) : 100; + + return `【技能】${skill.name}\n${skill.description}\n成功执行 ${skill.success_count} 次(成功率 ${rate}%)\n工具链:\n${steps}`; + }).filter(Boolean); + + if (parts.length === 0) return ''; + + return `【可用技能提示 — 以下技能曾成功完成过类似任务,可作为参考】\n\n${parts.join('\n\n')}\n\n提示:如果当前任务与某个技能匹配,可以参考其工具链步骤,但根据实际情况调整参数。`; +} + +/** + * 获取所有技能(用于 UI 展示) + */ +export async function getAllSkillsForUI(): Promise { + const bridge = (window as any).metonaDesktop; + if (!bridge?.db?.getAllSkills) return []; + try { + return await bridge.db.getAllSkills(); + } catch { + return []; + } +} + +/** + * 删除技能 + */ +export async function deleteSkillById(id: string): Promise { + const bridge = (window as any).metonaDesktop; + if (!bridge?.db?.deleteSkill) return false; + try { + await bridge.db.deleteSkill(id); + return true; + } catch { + return false; + } +} + +/** + * 清空所有技能 + */ +export async function clearAllSkills(): Promise { + const bridge = (window as any).metonaDesktop; + if (!bridge?.db?.clearAllSkills) return false; + try { + await bridge.db.clearAllSkills(); + return true; + } catch { + return false; + } +} + +// ── 辅助函数 ── + +/** 生成工具步骤描述 */ +function describeToolStep(t: ToolCallRecord): string { + const args = t.arguments || {}; + switch (t.name) { + case 'read_file': return `读取文件 ${args.path || ''}`; + case 'write_file': return `写入文件 ${args.path || ''}`; + case 'list_directory': return `列出目录 ${args.path || ''}`; + case 'search_files': return `搜索文件 "${args.query || ''}"`; + case 'web_search': return `联网搜索 "${args.query || ''}"`; + case 'web_fetch': return `抓取网页 ${args.url || ''}`; + case 'run_command': return `执行命令 ${String(args.command || '').slice(0, 60)}`; + case 'git': return `Git ${args.action || ''}`; + case 'edit_file': return `编辑文件 ${args.path || ''}`; + case 'delete_file': return `删除 ${args.path || ''}`; + case 'create_directory': return `创建目录 ${args.path || ''}`; + case 'memory_search': return `搜索记忆 "${args.query || ''}"`; + case 'memory_add': return `添加记忆`; + default: return t.name; + } +} + +/** 提取关键参数名 */ +function extractKeyArgs(t: ToolCallRecord): string { + const args = t.arguments || {}; + const keys: string[] = []; + if (args.path) keys.push('path'); + if (args.query) keys.push('query'); + if (args.url) keys.push('url'); + if (args.command) keys.push('command'); + if (args.action) keys.push('action'); + if (args.branch) keys.push('branch'); + return keys.join(', '); +} + +/** 从用户消息提取关键词(简单分词) */ +function extractKeywords(text: string): string { + // 移除常见停用词 + const stopWords = new Set(['我', '你', '的', '了', '是', '在', '有', '和', '就', '不', '人', '都', '一', '一个', '上', '也', '很', '到', '说', '要', '去', '你', '会', '着', '没有', '看', '好', '自己', '这', '请', '帮', '帮我', '可以', '怎么', '什么', '如何', 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'to', 'of', 'and', 'in', 'that', 'have', 'for', 'it', 'with', 'as', 'this', 'from', 'by', 'on', 'not', 'but', 'what', 'all', 'can', 'had', 'how', 'do']); + + const words = text.toLowerCase() + .split(/[\s,,。!?、;:""''()\[\]【】<>《》]+/) + .filter(w => w.length > 1 && !stopWords.has(w)); + + // 取前 8 个关键词 + return [...new Set(words)].slice(0, 8).join(','); +} + +/** 生成技能名称 */ +function generateSkillName(chain: ToolChainStep[], userMessage: string): string { + const firstTool = chain[0].name; + const lastTool = chain[chain.length - 1].name; + + // 基于工具链模式命名 + const patterns: Record = { + 'web_search → web_fetch': '联网搜索并抓取详情', + 'list_directory → read_file': '浏览目录并读取文件', + 'search_files → read_file': '搜索并读取文件', + 'search_files → edit_file': '搜索并编辑文件', + 'search_files → write_file': '搜索并写入文件', + 'run_command → read_file': '执行命令并读取输出', + 'web_search → web_fetch → write_file': '搜索、抓取并保存', + }; + + const chainKey = chain.map(s => s.name).join(' → '); + if (patterns[chainKey]) return patterns[chainKey]; + + // 基于用户消息前 20 字 + const msgPreview = userMessage.slice(0, 20).replace(/[\r\n]/g, ' '); + return `${firstTool} → ... → ${lastTool}(${msgPreview})`; +} + +/** 生成技能描述 */ +function generateSkillDescription(chain: ToolChainStep[], sessionTitle: string): string { + const steps = chain.map((s, i) => `${i + 1}. ${s.description}`).join(';'); + return `从会话「${sessionTitle}」中提取。步骤:${steps}`; +} diff --git a/src/renderer/styles/style.css b/src/renderer/styles/style.css index bcdf2cc..e80a861 100644 --- a/src/renderer/styles/style.css +++ b/src/renderer/styles/style.css @@ -1332,6 +1332,22 @@ html, body { margin-bottom: 0; } +/* v4.2 人格按钮 */ +.personality-btn { + transition: all 0.15s ease; + color: var(--text-primary); +} +.personality-btn:hover { + background: var(--bg-hover) !important; + border-color: var(--primary) !important; +} +.personality-btn.active { + background: var(--primary-bg) !important; + border-color: var(--primary) !important; + color: var(--primary); + font-weight: 600; +} + .setting-label { display: flex; align-items: center;