feat: v4.2 智能进化 — 技能自动生成 + 用户画像 + 人格模板

新增功能:

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)
This commit is contained in:
thzxx
2026-04-18 09:22:25 +08:00
parent d8f89ad097
commit f1625b4353
10 changed files with 623 additions and 4 deletions
+90
View File
@@ -203,6 +203,22 @@ export async function initDatabase(): Promise<SQL.Database> {
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
); );
CREATE INDEX IF NOT EXISTS idx_traces_session ON traces(session_id, created_at); 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 // 尝试创建 FTS5 全文搜索(可选,sql.js 默认 WASM 可能不包含 FTS5
@@ -286,6 +302,21 @@ export interface ToolCallRow {
created_at: number; 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 { export interface TraceRow {
id: string; id: string;
session_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[]; 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/Import ───
export interface ExportData { export interface ExportData {
+27
View File
@@ -14,6 +14,7 @@ import {
saveSetting, getSetting, saveSetting, getSetting,
saveToolCall, getToolCallsBySession, saveToolCall, getToolCallsBySession,
saveTrace, getTracesBySession, saveTrace, getTracesBySession,
saveSkill, getAllSkills, deleteSkill, clearAllSkills, searchSkills, incrementSkillUsage,
exportAllSessions, importSessions exportAllSessions, importSessions
} from './db/sqlite.js'; } from './db/sqlite.js';
import type { ExportData } from './db/sqlite.js'; import type { ExportData } from './db/sqlite.js';
@@ -371,4 +372,30 @@ export async function setupIPC(): Promise<void> {
try { return importSessions(data); } try { return importSessions(data); }
catch (err) { sendLog('error', '导入失败', (err as Error).message); return { imported: 0, skipped: 0 }; } 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 }; }
});
} }
+7
View File
@@ -66,6 +66,13 @@ contextBridge.exposeInMainWorld('metonaDesktop', {
getTraces: (sessionId: string) => ipcRenderer.invoke('db:getTraces', sessionId), getTraces: (sessionId: string) => ipcRenderer.invoke('db:getTraces', sessionId),
exportSessions: () => ipcRenderer.invoke('db:exportSessions'), exportSessions: () => ipcRenderer.invoke('db:exportSessions'),
importSessions: (data: unknown) => ipcRenderer.invoke('db:importSessions', data), 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: { workspace: {
getDir: () => ipcRenderer.invoke('workspace:getDir'), getDir: () => ipcRenderer.invoke('workspace:getDir'),
+54
View File
@@ -15,6 +15,16 @@ import { OllamaAPI } from '../api/ollama.js';
import { ChatDB } from '../db/chat-db.js'; import { ChatDB } from '../db/chat-db.js';
import type { ChatSession } from '../types.js'; import type { ChatSession } from '../types.js';
/** v4.2 人格模式 system prompt 映射 */
export const PERSONALITY_SYSTEM_PROMPTS: Record<string, string> = {
default: '',
coder: '你是一个专业的编程助手。你注重代码质量、可读性和最佳实践。回答时优先给出可运行的代码,其次才是解释。遇到问题先分析根因再给方案。使用 type hints、合理的命名和注释。代码风格偏向简洁紧凑。',
writer: '你是一个专业的写作助手。你的文字流畅、有文采,注重结构和节奏。擅长技术文档、文章、文案、邮件等各种文体。回答时先理解写作目的和受众,再给出合适的风格和内容。',
analyst: '你是一个精确的数据分析助手。你注重数据的准确性和逻辑的严密性。回答时先明确数据来源和假设,再给出分析过程和结论。偏好用数字和事实说话,避免模糊表述。',
translator: '你是一个专业的翻译助手。你精通多语言互译,注重信达雅。翻译时保留原文的语气和风格,专业术语保持一致。遇到歧义时给出多种翻译并标注区别。',
reviewer: '你是一个严格的代码审查助手。你关注:正确性(逻辑错误、边界情况)、安全性(注入、泄露)、性能(时间/空间复杂度)、可维护性(命名、结构、注释)。审查时先给出总体评价,再逐项列出问题并给出修复建议。',
};
let settingsModalEl: HTMLElement; let settingsModalEl: HTMLElement;
export function initSettingsModal(): void { export function initSettingsModal(): void {
@@ -63,6 +73,50 @@ export function initSettingsModal(): void {
}, 300); }, 300);
tempSlider.addEventListener('change', saveTemperature); tempSlider.addEventListener('change', saveTemperature);
// ── v4.2 人格模式切换 ──
const PERSONALITY_TEMPERATURES: Record<string, number> = {
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<ChatDB | null>(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<string, string> = {
default: '通用助手', coder: '编程助手', writer: '写作助手',
analyst: '数据分析', translator: '翻译助手', reviewer: '代码审查'
};
showToast(`已切换到 ${names[personality] || personality}`, 'success');
logSetting('人格模式', names[personality] || personality);
});
});
document.querySelector('#btnReleaseVRAM')!.addEventListener('click', async () => { document.querySelector('#btnReleaseVRAM')!.addEventListener('click', async () => {
const api = state.get<OllamaAPI>(KEYS.API); const api = state.get<OllamaAPI>(KEYS.API);
const model = (document.querySelector('#modelSelect') as HTMLSelectElement).value; const model = (document.querySelector('#modelSelect') as HTMLSelectElement).value;
+12
View File
@@ -260,6 +260,18 @@
<input type="range" id="inputTemperature" class="temp-slider" min="0" max="2" step="0.1" value="0.7"> <input type="range" id="inputTemperature" class="temp-slider" min="0" max="2" step="0.1" value="0.7">
<p class="text-muted" style="margin-top:4px;font-size:11px;">低值更精确稳定,高值更有创意多样(0 = 确定性,2 = 最大随机)</p> <p class="text-muted" style="margin-top:4px;font-size:11px;">低值更精确稳定,高值更有创意多样(0 = 确定性,2 = 最大随机)</p>
</div> </div>
<div class="setting-group">
<label class="setting-label">🎭 人格模式</label>
<div class="personality-grid" id="personalityGrid" style="display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-top:4px;">
<button class="btn btn-sm personality-btn active" data-personality="default" style="padding:6px 8px;font-size:12px;border:2px solid var(--primary);border-radius:var(--radius-control);background:var(--bg-layer);cursor:pointer;">🤖 通用助手</button>
<button class="btn btn-sm personality-btn" data-personality="coder" style="padding:6px 8px;font-size:12px;border:2px solid var(--border-subtle);border-radius:var(--radius-control);background:var(--bg-layer);cursor:pointer;">💻 编程助手</button>
<button class="btn btn-sm personality-btn" data-personality="writer" style="padding:6px 8px;font-size:12px;border:2px solid var(--border-subtle);border-radius:var(--radius-control);background:var(--bg-layer);cursor:pointer;">✍️ 写作助手</button>
<button class="btn btn-sm personality-btn" data-personality="analyst" style="padding:6px 8px;font-size:12px;border:2px solid var(--border-subtle);border-radius:var(--radius-control);background:var(--bg-layer);cursor:pointer;">📊 数据分析</button>
<button class="btn btn-sm personality-btn" data-personality="translator" style="padding:6px 8px;font-size:12px;border:2px solid var(--border-subtle);border-radius:var(--radius-control);background:var(--bg-layer);cursor:pointer;">🌐 翻译助手</button>
<button class="btn btn-sm personality-btn" data-personality="reviewer" style="padding:6px 8px;font-size:12px;border:2px solid var(--border-subtle);border-radius:var(--radius-control);background:var(--bg-layer);cursor:pointer;">🔍 代码审查</button>
</div>
<p class="text-muted" style="margin-top:4px;font-size:11px;">不同人格有不同的行为风格和温度偏好。通用助手不做额外约束。</p>
</div>
<div class="setting-group"> <div class="setting-group">
<label class="setting-label">运行中的模型</label> <label class="setting-label">运行中的模型</label>
<div class="running-models" id="runningModels"> <div class="running-models" id="runningModels">
+15
View File
@@ -363,6 +363,21 @@ async function init(): Promise<void> {
const memoryEnabled = await db.getSetting('memoryEnabled', true); const memoryEnabled = await db.getSetting('memoryEnabled', true);
state.set('memoryEnabled', memoryEnabled); 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(); (document.querySelector('#chatInput') as HTMLElement)?.focus();
updateTotalTokens(); updateTotalTokens();
+99 -4
View File
@@ -12,6 +12,8 @@ import {
needsConfirmation needsConfirmation
} from './tool-registry.js'; } from './tool-registry.js';
import { searchMemories, buildMemoryContext, markMemoryUsed, isMemoryEnabled } from './memory-manager.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 { showToast } from '../components/toast.js';
import { logInfo, logWarn, logError, logToolStart, logToolResult, logThink, logAgentLoop, logModelResponse } from './log-service.js'; import { logInfo, logWarn, logError, logToolStart, logToolResult, logThink, logAgentLoop, logModelResponse } from './log-service.js';
import { getWorkspaceDirPath } from '../components/workspace-panel.js'; import { getWorkspaceDirPath } from '../components/workspace-panel.js';
@@ -136,7 +138,53 @@ function parseToolCallsFromText(content: string): ToolCall[] {
return calls; return calls;
} }
/** 工具调用缓存:key = toolName + JSON(args),用于避免重复调用 */ /** v4.2: 从对话中自动推断用户画像 */
async function inferUserProfile(userMsg: string, assistantMsg: string): Promise<void> {
const bridge = (window as any).metonaDesktop;
if (!bridge?.db?.getSetting) return;
const profile: Record<string, unknown> = await bridge.db.getSetting('user_profile', {}) || {};
// 技术栈检测
const techPatterns: Record<string, RegExp> = {
'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<string, ToolResult>(); const toolResultCache = new Map<string, ToolResult>();
/** 生成工具调用缓存 key */ /** 生成工具调用缓存 key */
@@ -342,10 +390,45 @@ export async function runAgentLoop(
文件操作工具(read_file、write_file 等)的相对路径基于此目录解析。`); 文件操作工具(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<any>(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 // 组合 system prompt
const fullSystemPrompt = systemPromptParts.length > 0 const personality = state.get<string>('personality', 'default');
? systemPromptParts.join('\n\n') + '\n\n' + AGENT_SYSTEM_PROMPT + '\n\n' + TOOL_USAGE_GUIDE const personalityPrompt = PERSONALITY_SYSTEM_PROMPTS[personality] || '';
: AGENT_SYSTEM_PROMPT + '\n\n' + TOOL_USAGE_GUIDE;
const fullSystemPrompt = [
...systemPromptParts,
AGENT_SYSTEM_PROMPT,
...(personalityPrompt ? [personalityPrompt] : []),
TOOL_USAGE_GUIDE
].join('\n\n');
messages.push({ role: 'system', content: fullSystemPrompt }); messages.push({ role: 'system', content: fullSystemPrompt });
@@ -538,6 +621,18 @@ export async function runAgentLoop(
} catch { /* 不阻塞 */ } } 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()); callbacks.onDone(content || '(模型未返回内容)', allToolRecords.length > 0 ? allToolRecords : undefined, makeStats());
return; return;
} }
+1
View File
@@ -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 logModel(name: string, action: string): void { addLog('info', `模型: ${action}`, name); }
export function logSession(action: string, detail?: string): void { addLog('info', `会话: ${action}`, detail); } 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 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 logRAG(action: string, detail?: string): void { addLog('info', `向量: ${action}`, detail); }
export function logSetting(key: string, value: string): void { addLog('debug', `设置: ${key}`, value); } export function logSetting(key: string, value: string): void { addLog('debug', `设置: ${key}`, value); }
export function logInit(msg: string): void { addLog('info', `初始化: ${msg}`); } export function logInit(msg: string): void { addLog('info', `初始化: ${msg}`); }
+302
View File
@@ -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<number> {
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<Skill[]> {
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<Skill[]> {
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<boolean> {
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<boolean> {
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<string, string> = {
'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}`;
}
+16
View File
@@ -1332,6 +1332,22 @@ html, body {
margin-bottom: 0; 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 { .setting-label {
display: flex; display: flex;
align-items: center; align-items: center;