将 desktop/src 中 23 处 console.log 替换为 createLogger() 结构化日志: - 生产构建自动静默 debug/info 级别 - 保留 console.error 用于关键错误可见性 - 新增 dompurify 依赖修复 XSS 防护引入缺失 涉及文件: App.tsx, offlineStore.ts, autonomy-manager.ts, gateway-auth.ts, llm-service.ts, request-helper.ts, security-index.ts, skill-discovery.ts, use-onboarding.ts 等 16 个文件
446 lines
12 KiB
TypeScript
446 lines
12 KiB
TypeScript
/**
|
||
* Skill Discovery - Agent-driven skill search, recommendation, and management
|
||
*
|
||
* Enables ZCLAW agents to:
|
||
* - Search available skills by keyword/capability
|
||
* - Recommend skills based on recent conversation patterns
|
||
* - Manage skill installation lifecycle (with user approval)
|
||
*
|
||
* Dynamically loads skills from the backend Kernel's SkillRegistry.
|
||
*
|
||
* Reference: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md §6.5.2
|
||
*/
|
||
|
||
import { intelligenceClient } from './intelligence-client';
|
||
import { canAutoExecute } from './autonomy-manager';
|
||
import { createLogger } from './logger';
|
||
|
||
const log = createLogger('SkillDiscovery');
|
||
|
||
// === Types ===
|
||
|
||
export interface SkillInfo {
|
||
id: string;
|
||
name: string;
|
||
description: string;
|
||
triggers: string[];
|
||
capabilities: string[];
|
||
toolDeps: string[];
|
||
installed: boolean;
|
||
category?: string;
|
||
path?: string;
|
||
version?: string;
|
||
mode?: string;
|
||
}
|
||
|
||
/** Backend skill response format */
|
||
interface BackendSkillInfo {
|
||
id: string;
|
||
name: string;
|
||
description: string;
|
||
version: string;
|
||
capabilities: string[];
|
||
tags: string[];
|
||
mode: string;
|
||
enabled: boolean;
|
||
}
|
||
|
||
export interface SkillSuggestion {
|
||
skill: SkillInfo;
|
||
reason: string;
|
||
confidence: number; // 0-1
|
||
matchedPatterns: string[];
|
||
}
|
||
|
||
export interface SkillSearchResult {
|
||
query: string;
|
||
results: SkillInfo[];
|
||
totalAvailable: number;
|
||
}
|
||
|
||
export interface ConversationContext {
|
||
role: string;
|
||
content: string;
|
||
}
|
||
|
||
// === Storage ===
|
||
|
||
const SKILL_INDEX_KEY = 'zclaw-skill-index';
|
||
const SKILL_SUGGESTIONS_KEY = 'zclaw-skill-suggestions';
|
||
|
||
// === Skill Discovery Engine ===
|
||
|
||
export class SkillDiscoveryEngine {
|
||
private skills: SkillInfo[] = [];
|
||
private suggestionHistory: SkillSuggestion[] = [];
|
||
private loadedFromBackend: boolean = false;
|
||
|
||
constructor() {
|
||
this.loadIndex();
|
||
this.loadSuggestions();
|
||
// Try to load from backend, fallback to cache
|
||
this.loadFromBackend();
|
||
}
|
||
|
||
/**
|
||
* Load skills from backend Tauri command.
|
||
* Falls back to cached skills if backend is unavailable.
|
||
*/
|
||
private async loadFromBackend(): Promise<void> {
|
||
try {
|
||
// Dynamic import to avoid bundling issues in non-Tauri environments
|
||
const { invoke } = await import('@tauri-apps/api/core');
|
||
const backendSkills = await invoke<BackendSkillInfo[]>('skill_list');
|
||
|
||
// Convert backend format to frontend format
|
||
this.skills = backendSkills.map(this.convertFromBackend);
|
||
this.loadedFromBackend = true;
|
||
this.saveIndex();
|
||
log.debug(`Loaded ${this.skills.length} skills from backend`);
|
||
} catch (error) {
|
||
log.warn('Failed to load skills from backend:', error);
|
||
// Keep using cached skills (loaded in loadIndex)
|
||
this.loadedFromBackend = false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Convert backend skill format to frontend format.
|
||
*/
|
||
private convertFromBackend(backend: BackendSkillInfo): SkillInfo {
|
||
return {
|
||
id: backend.id,
|
||
name: backend.name,
|
||
description: backend.description,
|
||
version: backend.version,
|
||
triggers: backend.tags, // Use tags as triggers
|
||
capabilities: backend.capabilities,
|
||
mode: backend.mode,
|
||
toolDeps: [], // Backend doesn't have this field
|
||
installed: backend.enabled,
|
||
category: backend.tags[0] || 'general',
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Refresh skills from backend.
|
||
* Optionally specify a custom directory to scan.
|
||
*/
|
||
async refresh(skillDir?: string): Promise<number> {
|
||
try {
|
||
const { invoke } = await import('@tauri-apps/api/core');
|
||
const backendSkills = await invoke<BackendSkillInfo[]>('skill_refresh', {
|
||
skillDir
|
||
});
|
||
|
||
this.skills = backendSkills.map(this.convertFromBackend);
|
||
this.loadedFromBackend = true;
|
||
this.saveIndex();
|
||
log.debug(`Refreshed ${this.skills.length} skills`);
|
||
return this.skills.length;
|
||
} catch (error) {
|
||
console.error('[SkillDiscovery] Failed to refresh skills:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Check if skills were loaded from backend.
|
||
*/
|
||
isLoadedFromBackend(): boolean {
|
||
return this.loadedFromBackend;
|
||
}
|
||
|
||
// === Search ===
|
||
|
||
/**
|
||
* Search skills by keyword. Matches against name, description, triggers, capabilities.
|
||
*/
|
||
searchSkills(query: string): SkillSearchResult {
|
||
const q = query.toLowerCase().trim();
|
||
if (!q) {
|
||
return { query, results: [...this.skills], totalAvailable: this.skills.length };
|
||
}
|
||
|
||
const tokens = q.split(/[\s,;.!?。,;!?]+/).filter(t => t.length > 0);
|
||
|
||
const scored = this.skills.map(skill => {
|
||
let score = 0;
|
||
|
||
// Name match (highest weight)
|
||
if (skill.name.toLowerCase().includes(q)) score += 10;
|
||
|
||
// Description match
|
||
if (skill.description.toLowerCase().includes(q)) score += 5;
|
||
|
||
// Trigger match (exact or partial)
|
||
for (const trigger of skill.triggers) {
|
||
const tLower = trigger.toLowerCase();
|
||
if (tLower === q) { score += 15; break; }
|
||
if (tLower.includes(q) || q.includes(tLower)) score += 8;
|
||
}
|
||
|
||
// Capability match
|
||
for (const cap of skill.capabilities) {
|
||
if (cap.toLowerCase().includes(q)) score += 4;
|
||
}
|
||
|
||
// Token-level matching
|
||
for (const token of tokens) {
|
||
if (skill.name.toLowerCase().includes(token)) score += 2;
|
||
if (skill.description.toLowerCase().includes(token)) score += 1;
|
||
for (const trigger of skill.triggers) {
|
||
if (trigger.toLowerCase().includes(token)) score += 3;
|
||
}
|
||
}
|
||
|
||
// Category match
|
||
if (skill.category && skill.category.toLowerCase().includes(q)) score += 3;
|
||
|
||
return { skill, score };
|
||
});
|
||
|
||
const results = scored
|
||
.filter(s => s.score > 0)
|
||
.sort((a, b) => b.score - a.score)
|
||
.map(s => s.skill);
|
||
|
||
return { query, results, totalAvailable: this.skills.length };
|
||
}
|
||
|
||
// === Recommendation ===
|
||
|
||
/**
|
||
* Suggest skills based on recent conversation content and memory patterns.
|
||
*/
|
||
async suggestSkills(
|
||
recentConversations: ConversationContext[],
|
||
agentId: string,
|
||
limit: number = 5
|
||
): Promise<SkillSuggestion[]> {
|
||
const suggestions: SkillSuggestion[] = [];
|
||
|
||
// 1. Extract key topics from conversations
|
||
const topics = this.extractTopics(recentConversations);
|
||
|
||
// 2. Match topics against skill triggers and capabilities
|
||
for (const skill of this.skills) {
|
||
const matchedPatterns: string[] = [];
|
||
let confidence = 0;
|
||
|
||
for (const topic of topics) {
|
||
const topicLower = topic.toLowerCase();
|
||
|
||
// Check triggers
|
||
for (const trigger of skill.triggers) {
|
||
if (trigger.toLowerCase().includes(topicLower) || topicLower.includes(trigger.toLowerCase())) {
|
||
matchedPatterns.push(`话题"${topic}"匹配触发词"${trigger}"`);
|
||
confidence += 0.3;
|
||
}
|
||
}
|
||
|
||
// Check capabilities
|
||
for (const cap of skill.capabilities) {
|
||
if (cap.toLowerCase().includes(topicLower)) {
|
||
matchedPatterns.push(`话题"${topic}"匹配能力"${cap}"`);
|
||
confidence += 0.2;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. Check memory patterns for recurring needs
|
||
try {
|
||
const memories = await intelligenceClient.memory.search({
|
||
agentId,
|
||
query: skill.name,
|
||
limit: 5,
|
||
minImportance: 3,
|
||
});
|
||
if (memories.length > 0) {
|
||
matchedPatterns.push(`记忆中有${memories.length}条相关记录`);
|
||
confidence += memories.length * 0.1;
|
||
}
|
||
} catch { /* non-critical */ }
|
||
|
||
if (matchedPatterns.length > 0 && confidence > 0) {
|
||
suggestions.push({
|
||
skill,
|
||
reason: matchedPatterns.slice(0, 3).join(';'),
|
||
confidence: Math.min(confidence, 1),
|
||
matchedPatterns,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Sort by confidence
|
||
const sorted = suggestions
|
||
.sort((a, b) => b.confidence - a.confidence)
|
||
.slice(0, limit);
|
||
|
||
// Cache suggestions
|
||
this.suggestionHistory = sorted;
|
||
this.saveSuggestions();
|
||
|
||
return sorted;
|
||
}
|
||
|
||
// === Skill Management ===
|
||
|
||
/**
|
||
* Get all available skills.
|
||
*/
|
||
getAllSkills(): SkillInfo[] {
|
||
return [...this.skills];
|
||
}
|
||
|
||
/**
|
||
* Get skills by category.
|
||
*/
|
||
getSkillsByCategory(category: string): SkillInfo[] {
|
||
return this.skills.filter(s => s.category === category);
|
||
}
|
||
|
||
/**
|
||
* Get unique categories.
|
||
*/
|
||
getCategories(): string[] {
|
||
return [...new Set(this.skills.map(s => s.category).filter(Boolean))] as string[];
|
||
}
|
||
|
||
/**
|
||
* Register a new skill (e.g., from a SKILL.md file scan).
|
||
*/
|
||
registerSkill(skill: SkillInfo): void {
|
||
const existing = this.skills.findIndex(s => s.id === skill.id);
|
||
if (existing >= 0) {
|
||
this.skills[existing] = skill;
|
||
} else {
|
||
this.skills.push(skill);
|
||
}
|
||
this.saveIndex();
|
||
}
|
||
|
||
/**
|
||
* Mark a skill as installed/uninstalled.
|
||
* Includes autonomy check for skill_install/skill_uninstall actions.
|
||
*/
|
||
setSkillInstalled(
|
||
skillId: string,
|
||
installed: boolean,
|
||
options?: { skipAutonomyCheck?: boolean }
|
||
): { success: boolean; reason?: string } {
|
||
const skill = this.skills.find(s => s.id === skillId);
|
||
if (!skill) {
|
||
return { success: false, reason: `Skill not found: ${skillId}` };
|
||
}
|
||
|
||
// Autonomy check - verify if skill installation is allowed
|
||
if (!options?.skipAutonomyCheck) {
|
||
const action = installed ? 'skill_install' : 'skill_uninstall';
|
||
const { canProceed, decision } = canAutoExecute(action, 6);
|
||
log.debug(`Autonomy check for ${action}: ${decision.reason}`);
|
||
|
||
if (!canProceed) {
|
||
return { success: false, reason: decision.reason };
|
||
}
|
||
}
|
||
|
||
skill.installed = installed;
|
||
this.saveIndex();
|
||
log.debug(`Skill ${skillId} ${installed ? 'installed' : 'uninstalled'}`);
|
||
return { success: true };
|
||
}
|
||
|
||
/**
|
||
* Get last cached suggestions.
|
||
*/
|
||
getLastSuggestions(): SkillSuggestion[] {
|
||
return [...this.suggestionHistory];
|
||
}
|
||
|
||
// === Topic Extraction ===
|
||
|
||
private extractTopics(conversations: ConversationContext[]): string[] {
|
||
const topics = new Set<string>();
|
||
|
||
const patterns = [
|
||
// Chinese task patterns
|
||
/帮我(.{2,15})/g,
|
||
/我想(.{2,15})/g,
|
||
/如何(.{2,15})/g,
|
||
/怎么(.{2,15})/g,
|
||
/需要(.{2,15})/g,
|
||
/分析(.{2,15})/g,
|
||
/写一个(.{2,15})/g,
|
||
/做一个(.{2,15})/g,
|
||
// English task patterns
|
||
/(?:help me |I need to |how to |please )(.{3,30})/gi,
|
||
// Technical terms
|
||
/(?:React|Vue|Python|Docker|K8s|API|SQL|CSS|TypeScript|Node|Git)/gi,
|
||
// Domain keywords
|
||
/(?:部署|测试|审查|优化|设计|开发|分析|报告|运营|安全)/g,
|
||
];
|
||
|
||
for (const msg of conversations.filter(m => m.role === 'user')) {
|
||
for (const pattern of patterns) {
|
||
const matches = msg.content.matchAll(pattern);
|
||
for (const match of matches) {
|
||
const topic = (match[1] || match[0]).trim();
|
||
if (topic.length >= 2 && topic.length <= 30) {
|
||
topics.add(topic);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return [...topics].slice(0, 20);
|
||
}
|
||
|
||
// === Persistence ===
|
||
|
||
private loadIndex(): void {
|
||
try {
|
||
const raw = localStorage.getItem(SKILL_INDEX_KEY);
|
||
if (raw) this.skills = JSON.parse(raw);
|
||
} catch {
|
||
this.skills = [];
|
||
}
|
||
}
|
||
|
||
private saveIndex(): void {
|
||
try {
|
||
localStorage.setItem(SKILL_INDEX_KEY, JSON.stringify(this.skills));
|
||
} catch { /* silent */ }
|
||
}
|
||
|
||
private loadSuggestions(): void {
|
||
try {
|
||
const raw = localStorage.getItem(SKILL_SUGGESTIONS_KEY);
|
||
if (raw) this.suggestionHistory = JSON.parse(raw);
|
||
} catch {
|
||
this.suggestionHistory = [];
|
||
}
|
||
}
|
||
|
||
private saveSuggestions(): void {
|
||
try {
|
||
localStorage.setItem(SKILL_SUGGESTIONS_KEY, JSON.stringify(this.suggestionHistory));
|
||
} catch { /* silent */ }
|
||
}
|
||
}
|
||
|
||
// === Singleton ===
|
||
|
||
let _instance: SkillDiscoveryEngine | null = null;
|
||
|
||
export function getSkillDiscovery(): SkillDiscoveryEngine {
|
||
if (!_instance) {
|
||
_instance = new SkillDiscoveryEngine();
|
||
}
|
||
return _instance;
|
||
}
|
||
|
||
export function resetSkillDiscovery(): void {
|
||
_instance = null;
|
||
}
|