Files
qiming/qimingclaw/crates/agent-electron-client/embedded/sgrobot-digital/src/pages/digital/SkillLibrary.tsx
2026-06-12 19:57:38 +08:00

403 lines
16 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.
import { useState, useEffect, useCallback, useRef } from 'react';
import GlassCard from '@/components/digital/GlassCard';
import CardTitleBar from '@/components/digital/CardTitleBar';
import { adaptSkillSummaries, type SkillSummary } from '@/lib/consoleDataAdapter';
import { getSkills, pullSkillPolicies, setSkillEnabled } from '@/lib/api';
import { domSafeId, type DigitalNavigationTarget } from './navigation';
type TaskCadence = 'daily' | 'weekly' | 'monthly' | 'adhoc';
type SkillSection = {
key: string;
title: string;
hint: string;
skills: SkillSummary[];
emptyText: string;
};
const cadenceOptions: Array<{ value: 'all' | TaskCadence; label: string }> = [
{ value: 'all', label: '全部任务' },
{ value: 'daily', label: '日常任务' },
{ value: 'weekly', label: '周任务' },
{ value: 'monthly', label: '月度任务' },
{ value: 'adhoc', label: '临时任务' },
];
const cadenceLabels: Record<TaskCadence, string> = {
daily: '日常',
weekly: '周',
monthly: '月度',
adhoc: '临时',
};
const policyLabels: Record<NonNullable<SkillSummary['governancePolicy']>, string> = {
allow: '白名单',
deny: '黑名单',
open: '开放',
disabled: '停用',
};
function getCadence(skill: SkillSummary): TaskCadence {
const text = `${skill.id} ${skill.name} ${skill.description} ${skill.category}`.toLowerCase();
if (/weekly|week|周/.test(text)) return 'weekly';
if (/monthly|month|月报|月度/.test(text)) return 'monthly';
if (/monitor|crawler|巡检|监控|待办|日程|日报|日终|收件箱|工作台|客户跟进/.test(text)) return 'daily';
return 'adhoc';
}
function isAdvancedSkill(skill: SkillSummary): boolean {
const text = `${skill.id} ${skill.name} ${skill.description} ${skill.category}`.toLowerCase();
return (
skill.capabilityLevel === 'specialized' ||
skill.capabilityLevel === 'experimental' ||
/加密|深度|分析|自动化|部署|代码|审查|调研|批量|converter|crypto|github|deploy|review|research/.test(text)
);
}
function isRecommendedSkill(skill: SkillSummary): boolean {
const text = `${skill.id} ${skill.name} ${skill.description} ${skill.category}`.toLowerCase();
return /日报|周报|报表|待办|日程|巡检|客户|知识库|经营|费用|收件箱|工作台/.test(text);
}
function getOutcome(skill: SkillSummary): string {
const name = skill.name;
const text = `${skill.id} ${skill.name} ${skill.description} ${skill.category}`.toLowerCase();
if (/report|报表|日报|周报|月报|汇报/.test(text)) return `自动整理${name}材料,减少手工汇总和重复填报。`;
if (/monitor|巡检|监控|预警/.test(text)) return `按约定频率完成${name},及时提示异常和遗漏。`;
if (/todo|待办|task|看板|日程/.test(text)) return `帮你梳理${name},把后续处理事项集中呈现。`;
if (/客户|crm|回访|满意/.test(text)) return `汇总客户相关信息,形成可直接跟进的处理建议。`;
if (/知识|文档|制度|模板/.test(text)) return `查找和核对资料内容,减少人工翻找和版本确认。`;
return skill.description;
}
function learningStatus(skill: SkillSummary, recommended: boolean, advanced: boolean): string {
if (skill.enabled) return '已学习';
if (!skill.enabled) return '暂不使用';
if (advanced) return '需确认后学习';
if (recommended) return '建议学习';
return '可学习';
}
function skillMetaBadges(skill: SkillSummary): string[] {
const badges: string[] = [];
if (skill.serverId) badges.push(skill.serverId);
if (skill.running !== null && skill.running !== undefined) badges.push(skill.running ? '运行中' : '未运行');
if (skill.policySource === 'remote') badges.push('远端策略');
if (skill.remotePolicy && skill.remotePolicy.enabled === false) badges.push('管理端禁用');
if (skill.governancePolicy) badges.push(policyLabels[skill.governancePolicy]);
if (typeof skill.toolCount === 'number') badges.push(`${skill.toolCount} 工具`);
if (skill.allowTools?.length) badges.push(`allow ${skill.allowTools.length}`);
if (skill.denyTools?.length) badges.push(`deny ${skill.denyTools.length}`);
return badges.slice(0, 5);
}
function recentCallText(skill: SkillSummary): string | null {
const call = skill.recentCalls?.[0];
if (!call) return typeof skill.callCount === 'number' && skill.callCount > 0
? `${skill.callCount} 次调用`
: null;
const tool = call.toolName ? `${call.toolName} · ` : '';
return `${tool}${call.message || call.status || '最近调用'}`;
}
function SkillCard({
skill,
recommended = false,
advanced = false,
selected = false,
updating = false,
onSelect,
onToggle,
onAudit,
}: {
skill: SkillSummary;
recommended?: boolean;
advanced?: boolean;
selected?: boolean;
updating?: boolean;
onSelect?: (skill: SkillSummary) => void;
onToggle?: (skill: SkillSummary) => void;
onAudit?: (skill: SkillSummary) => void;
}) {
const cadence = getCadence(skill);
const status = learningStatus(skill, recommended, advanced);
const metaBadges = skillMetaBadges(skill);
const recentCall = recentCallText(skill);
return (
<div
id={`skill-card-${domSafeId(skill.id)}`}
className={`de-skill-card ${selected ? 'de-skill-card--selected' : ''}`}
role="button"
tabIndex={0}
onClick={() => onSelect?.(skill)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onSelect?.(skill);
}
}}
>
<div className="de-skill-card-head">
<span className="de-skill-name">{skill.name}</span>
<span className={`de-skill-status ${skill.enabled ? 'enabled' : 'disabled'}`}>
{status}
</span>
</div>
<p className="de-skill-desc">{getOutcome(skill)}</p>
{metaBadges.length > 0 && (
<div className="de-skill-meta-row">
{metaBadges.map((badge) => <span key={badge} className="de-skill-meta-chip">{badge}</span>)}
</div>
)}
{recentCall && <p className="de-skill-recent">{recentCall}</p>}
<div className="de-skill-card-foot">
<span className="de-skill-version">
{cadenceLabels[cadence]}
</span>
<button
className={`de-skill-btn ${skill.enabled ? 'de-skill-btn--danger' : ''}`}
disabled={updating}
title={skill.enabled ? '暂时停用此技能,后续路由不会选择它。' : '恢复使用此技能。'}
onClick={(event) => {
event.stopPropagation();
if (!updating) onToggle?.(skill);
}}
>
{updating ? '处理中...' : skill.enabled ? '暂不使用' : '恢复使用'}
</button>
<button
className="de-skill-btn"
title="查看该技能的调用审计。"
onClick={(event) => {
event.stopPropagation();
onAudit?.(skill);
}}
>
</button>
</div>
</div>
);
}
export default function SkillLibrary({ focusSkillId, onNavigate }: { focusSkillId?: string | null; onNavigate?: (target: DigitalNavigationTarget) => void }) {
const [skills, setSkills] = useState<SkillSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [search, setSearch] = useState('');
const [cadenceFilter, setCadenceFilter] = useState<'all' | TaskCadence>('all');
const [selectedSkillId, setSelectedSkillId] = useState<string | null>(focusSkillId ?? null);
const [updatingSkillId, setUpdatingSkillId] = useState<string | null>(null);
const [pullingPolicies, setPullingPolicies] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const [actionNotice, setActionNotice] = useState<string | null>(null);
const mountedRef = useRef(false);
useEffect(() => {
mountedRef.current = true;
return () => { mountedRef.current = false; };
}, []);
const fetchSkills = useCallback(async () => {
setLoading(true); setError(false);
try {
const data = await getSkills();
if (!mountedRef.current) return;
setSkills(Array.isArray(data) ? adaptSkillSummaries(data as Record<string, unknown>[]) : []);
} catch (err) {
if (!mountedRef.current) return;
console.error('[SkillLibrary] fetch failed:', err);
setSkills([]);
setError(true);
} finally {
if (mountedRef.current) setLoading(false);
}
}, []);
useEffect(() => { fetchSkills(); }, [fetchSkills]);
useEffect(() => {
if (!focusSkillId) return;
setSelectedSkillId(focusSkillId);
setCadenceFilter('all');
setSearch('');
window.setTimeout(() => {
document.getElementById(`skill-card-${domSafeId(focusSkillId)}`)?.scrollIntoView({
block: 'center',
behavior: 'smooth',
});
}, 80);
}, [focusSkillId, skills.length]);
const toggleSkill = useCallback(async (skill: SkillSummary) => {
const nextEnabled = !skill.enabled;
setUpdatingSkillId(skill.id);
setActionError(null);
setActionNotice(null);
try {
const result = await setSkillEnabled(skill.id, nextEnabled);
if (!result.ok) {
throw new Error(result.error || '技能状态变更失败');
}
setSkills((current) => current.map((item) => (
item.id === skill.id ? { ...item, enabled: nextEnabled } : item
)));
setSelectedSkillId(skill.id);
} catch (err) {
setActionError(err instanceof Error ? err.message : '技能状态变更失败');
} finally {
setUpdatingSkillId(null);
}
}, []);
const pullPolicies = useCallback(async () => {
setPullingPolicies(true);
setActionError(null);
setActionNotice(null);
try {
const result = await pullSkillPolicies();
if (!result.ok) throw new Error(result.error || '技能策略拉取失败');
await fetchSkills();
setActionNotice(result.skipped ? '技能策略无需重复拉取。' : '技能策略已更新。');
} catch (err) {
setActionError(err instanceof Error ? err.message : '技能策略拉取失败');
} finally {
setPullingPolicies(false);
}
}, [fetchSkills]);
const filtered = skills
.filter(s => cadenceFilter === 'all' || getCadence(s) === cadenceFilter)
.filter(s => {
if (!search) return true;
const haystack = [
s.name,
s.description,
s.category,
s.serverId,
...(s.tools ?? []),
...(s.allowTools ?? []),
...(s.denyTools ?? []),
].filter(Boolean).join(' ');
return haystack.includes(search);
});
const advancedSkills = filtered.filter(isAdvancedSkill);
const advancedIds = new Set(advancedSkills.map(s => s.id));
const learnedSkills = filtered.filter(s => s.enabled && !advancedIds.has(s.id));
const recommendedPool = filtered.filter(s => !s.enabled && isRecommendedSkill(s) && !isAdvancedSkill(s));
const baseLearnableSkills = filtered.filter(s => !s.enabled && !isAdvancedSkill(s));
const recommendedSkills = (recommendedPool.length > 0 ? recommendedPool : baseLearnableSkills).slice(0, 4);
const recommendedIds = new Set(recommendedSkills.map(s => s.id));
const learnableSkills = baseLearnableSkills.filter(s => !recommendedIds.has(s.id));
const sections: SkillSection[] = [
{
key: 'recommended',
title: '推荐学习',
hint: '优先补齐高频工作能力,适合让数字员工尽快接手重复事务。',
skills: recommendedSkills,
emptyText: '暂无推荐学习项。',
},
{
key: 'learnable',
title: '可学习技能',
hint: '可纳入数字员工日常工作的业务能力,学习入口待接入后启用。',
skills: learnableSkills,
emptyText: '当前没有可学习技能。',
},
{
key: 'learned',
title: '已学技能',
hint: '数字员工已经掌握并可被业务场景调用的能力。',
skills: learnedSkills,
emptyText: '当前还没有已学习技能。',
},
{
key: 'advanced',
title: '高级技能',
hint: '适合有明确边界或需要确认后再交给数字员工处理的能力。',
skills: advancedSkills,
emptyText: '暂无高级技能。',
},
];
const renderSection = (section: SkillSection) => (
<section key={section.key} style={{ display: 'grid', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
<div>
<h3 style={{ margin: 0, fontSize: 16, color: 'var(--de-text-primary)' }}>{section.title}</h3>
<p style={{ margin: '4px 0 0', fontSize: 12, color: 'var(--de-text-muted)' }}>{section.hint}</p>
</div>
<span className="de-capability-badge de-capability-standard">{section.skills.length} </span>
</div>
{section.skills.length === 0 ? (
<p className="de-empty-text" style={{ margin: 0 }}>{section.emptyText}</p>
) : (
<div className="de-skill-grid">
{section.skills.map(s => (
<SkillCard
key={`${section.key}-${s.id}`}
skill={s}
recommended={section.key === 'recommended'}
advanced={section.key === 'advanced'}
selected={selectedSkillId === s.id}
updating={updatingSkillId === s.id}
onSelect={(skill) => setSelectedSkillId(skill.id)}
onToggle={toggleSkill}
onAudit={(skill) => onNavigate?.({ tab: 'skill-audit', auditSkillId: skill.id })}
/>
))}
</div>
)}
</section>
);
return (
<div style={{ padding: '14px 18px' }}>
<GlassCard>
<CardTitleBar title="学习技能">
<button onClick={pullPolicies} disabled={pullingPolicies} className="de-btn-secondary de-btn-sm">
{pullingPolicies ? '拉取中...' : '拉取策略'}
</button>
<button onClick={fetchSkills} className="de-btn-secondary de-btn-sm"></button>
</CardTitleBar>
<div className="de-card-body">
{actionError && (
<div className="de-schedule-result de-schedule-result--error" style={{ marginBottom: 12 }}>
{actionError}
</div>
)}
{actionNotice && (
<div className="de-schedule-result" style={{ marginBottom: 12 }}>
{actionNotice}
</div>
)}
<div className="de-skill-toolbar">
<input type="text" className="de-input" placeholder="搜索业务能力..." value={search} onChange={e => setSearch(e.target.value)} />
<div className="de-filter-row">
{cadenceOptions.map(option => (
<button key={option.value} className={`de-filter-chip ${cadenceFilter === option.value ? 'active' : ''}`} onClick={() => setCadenceFilter(option.value)}>
{option.label}
</button>
))}
</div>
</div>
{loading ? (
<div className="de-loading-skeleton" style={{ height: 300 }} />
) : error ? (
<p className="de-empty-text"> qimingclaw bridge </p>
) : filtered.length === 0 && skills.length > 0 ? (
<p className="de-empty-text"></p>
) : filtered.length === 0 ? (
<p className="de-empty-text"></p>
) : (
<div style={{ display: 'grid', gap: 18 }}>
{sections.map(renderSection)}
</div>
)}
</div>
</GlassCard>
</div>
);
}