403 lines
16 KiB
TypeScript
403 lines
16 KiB
TypeScript
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>
|
||
);
|
||
}
|