Files
zclaw_openfang/desktop/src/lib/skill-discovery.ts
iven ecd7f2e928 fix(desktop): console.log 清理 — 替换为结构化 logger
将 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 个文件
2026-03-30 16:22:16 +08:00

446 lines
12 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.

/**
* 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;
}