Files
metona-ollama-desktop/src/renderer/services/skill-manager.ts
T
Metona Dev 8ffba1a857 fix: 全面审查修复 - 日志/死代码/工具卡片/技能描述
- sqlite.ts: 移除 console.warn,改用状态变量跟踪 FTS5 可用性
- tool-handlers.ts: download_file/append_file/edit_file 补充 sendLog 执行日志
- chat-area.ts: 工具卡片补齐 29 个工具的图标/名称/参数/结果渲染
- main.ts: 移除未使用的 stopAllMCPServers 导入
- skill-manager.ts: 修复 /** 注释语法错误,补充 browser*/spawn_task 工具描述
2026-04-18 10:47:38 +08:00

271 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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提示:如果当前任务与某个技能匹配,可以参考其工具链步骤,但根据实际情况调整参数。`;
}
// ── 辅助函数 ──
/** 生成工具步骤描述 */
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 `添加记忆`;
case 'spawn_task': return `子代理委派`;
case 'browser_open': return `打开网页 ${args.url || ''}`;
case 'browser_screenshot': return `浏览器截图`;
case 'browser_evaluate': return `执行浏览器 JS`;
case 'browser_extract': return `提取页面内容`;
case 'browser_click': return `点击元素 ${args.selector || ''}`;
case 'browser_type': return `输入文本 ${args.selector || ''}`;
case 'browser_scroll': return `滚动页面`;
case 'browser_close': 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}`;
}