- 同步阻塞操作(附件处理、历史加载、scheduler/memory_search 的 SQLite 调用) 移入 spawn_blocking,避免占用 async worker - LLM Provider reqwest::Client 按超时配置缓存复用,减少 TLS/连接开销 - agent loop:图片过滤加廉价预判避免全量深拷贝;请求克隆改借用;工具定义 Arc 化 - 定向 COUNT/LIMIT 1 查询替代全量加载计数(wait_coordinator、task session 重建) - 前端:面板/侧栏/聊天组件 memo 化;merged_tool 对象按值复用缓存; 流式 delta rAF 节流批量 flush;useMemo 缓存分组排序结果
226 lines
7.6 KiB
TypeScript
226 lines
7.6 KiB
TypeScript
import { useState, useMemo, memo } from 'react';
|
||
import {
|
||
Package,
|
||
User,
|
||
Folder,
|
||
RefreshCw,
|
||
ChevronDown,
|
||
ChevronRight,
|
||
BookOpen,
|
||
} from 'lucide-react';
|
||
import type { SkillSummary } from '../../types/protocol';
|
||
|
||
/* ── types ────────────────────────────────────────────── */
|
||
|
||
interface SkillListProps {
|
||
skills: SkillSummary[];
|
||
onRefresh: () => void;
|
||
}
|
||
|
||
interface SourceConfig {
|
||
label: string;
|
||
icon: typeof Package;
|
||
accent: string;
|
||
}
|
||
|
||
const SOURCE_CONFIG: Record<string, SourceConfig> = {
|
||
user: { label: '用户技能', icon: User, accent: 'text-[var(--accent-cyan)]' },
|
||
useragent: { label: '用户 Agent', icon: User, accent: 'text-[var(--accent-cyan)]' },
|
||
useropenclaw: { label: '用户 OpenClaw', icon: User, accent: 'text-[var(--accent-cyan)]' },
|
||
project: { label: '项目技能', icon: Folder, accent: 'text-[var(--accent-amber)]' },
|
||
projectagent: { label: '项目 Agent', icon: Folder, accent: 'text-[var(--accent-amber)]' },
|
||
projectopenclaw: { label: '项目 OpenClaw', icon: Folder, accent: 'text-[var(--accent-amber)]' },
|
||
};
|
||
|
||
function sourceConfig(source: string): SourceConfig {
|
||
return (
|
||
SOURCE_CONFIG[source] ?? {
|
||
label: source,
|
||
icon: Package,
|
||
accent: 'text-[var(--text-secondary)]',
|
||
}
|
||
);
|
||
}
|
||
|
||
/* ── Section header ────────────────────────────────────── */
|
||
|
||
function SectionHeader({
|
||
config,
|
||
count,
|
||
isCollapsed,
|
||
onClick,
|
||
}: {
|
||
config: SourceConfig;
|
||
count: number;
|
||
isCollapsed: boolean;
|
||
onClick: () => void;
|
||
}) {
|
||
const Icon = config.icon;
|
||
return (
|
||
<button
|
||
onClick={onClick}
|
||
className="sticky top-0 z-10 group flex items-center gap-2 w-full py-1.5 rounded-lg transition-colors hover:bg-[var(--overlay-hover)] bg-[var(--bg-secondary)]/90 backdrop-blur-sm -mx-3 px-3"
|
||
>
|
||
{isCollapsed ? (
|
||
<ChevronRight className="h-3 w-3 text-[var(--text-muted)]" />
|
||
) : (
|
||
<ChevronDown className="h-3 w-3 text-[var(--text-muted)]" />
|
||
)}
|
||
<div
|
||
className={`flex items-center justify-center w-5 h-5 rounded-md bg-[var(--overlay-hover)] ${config.accent}`}
|
||
>
|
||
<Icon className="h-3 w-3" />
|
||
</div>
|
||
<span className="text-xs font-semibold text-[var(--text-primary)] tracking-tight">
|
||
{config.label}
|
||
</span>
|
||
<span className="text-[10px] text-[var(--text-muted)] font-mono tabular-nums ml-auto">
|
||
{count}
|
||
</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
/* ── Skill card ────────────────────────────────────────── */
|
||
|
||
function SkillCard({ skill, config }: { skill: SkillSummary; config: SourceConfig }) {
|
||
return (
|
||
<div className="group rounded-lg bg-[var(--overlay-hover)] border border-[var(--border-color)] overflow-hidden transition-all duration-200 hover:border-[var(--border-accent)]">
|
||
<div className="px-3 py-2.5">
|
||
<span className={`text-[10px] font-mono uppercase tracking-wider ${config.accent}`}>
|
||
{skill.name}
|
||
</span>
|
||
<p className="text-sm text-[var(--text-secondary)] leading-relaxed mt-1">
|
||
{skill.description}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ── main component ────────────────────────────────────── */
|
||
|
||
// memo:props 全部稳定(skills 仅在刷新时换引用、onRefresh 为 useCallback),
|
||
// 主视图流式期间 App 每帧重渲染时跳过面板重渲染与分组/排序重算。
|
||
export const SkillList = memo(function SkillList({ skills, onRefresh }: SkillListProps) {
|
||
const [collapsed, setCollapsed] = useState<Set<string>>(() => {
|
||
try {
|
||
const s = localStorage.getItem('picobot-skill-collapsed');
|
||
return s ? new Set(JSON.parse(s)) : new Set();
|
||
} catch (_) {
|
||
return new Set();
|
||
}
|
||
});
|
||
|
||
const toggle = (source: string) => {
|
||
setCollapsed((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(source)) {
|
||
next.delete(source);
|
||
} else {
|
||
next.add(source);
|
||
}
|
||
localStorage.setItem('picobot-skill-collapsed', JSON.stringify([...next]));
|
||
return next;
|
||
});
|
||
};
|
||
|
||
// skills 引用未变时跳过分组/排序重算(流式期间 App 每帧重渲染)
|
||
const { grouped, sorted } = useMemo(() => {
|
||
const grouped = new Map<string, SkillSummary[]>();
|
||
for (const s of skills) {
|
||
const l = grouped.get(s.source) || [];
|
||
l.push(s);
|
||
grouped.set(s.source, l);
|
||
}
|
||
|
||
const order = [
|
||
'user',
|
||
'useragent',
|
||
'useropenclaw',
|
||
'project',
|
||
'projectagent',
|
||
'projectopenclaw',
|
||
];
|
||
const sorted = Array.from(grouped.keys()).sort((a, b) => {
|
||
const ai = order.indexOf(a);
|
||
const bi = order.indexOf(b);
|
||
if (ai !== -1 && bi !== -1) return ai - bi;
|
||
if (ai !== -1) return -1;
|
||
if (bi !== -1) return 1;
|
||
return a.localeCompare(b);
|
||
});
|
||
return { grouped, sorted };
|
||
}, [skills]);
|
||
|
||
return (
|
||
<div className="flex h-full flex-col">
|
||
{/* title bar */}
|
||
<div className="shrink-0 border-b border-[var(--border-color)] px-4 py-3 flex items-center gap-2.5">
|
||
<div className="flex items-center justify-center w-6 h-6 rounded-lg bg-[var(--accent-cyan)]/10">
|
||
<BookOpen className="h-3.5 w-3.5 text-[var(--accent-cyan)]" />
|
||
</div>
|
||
<span className="text-sm font-bold text-[var(--text-primary)] tracking-tight">技能</span>
|
||
{skills.length > 0 && (
|
||
<span className="text-[11px] font-mono text-[var(--text-muted)] tabular-nums ml-0.5">
|
||
{skills.length}
|
||
</span>
|
||
)}
|
||
<div className="ml-auto flex items-center gap-0.5">
|
||
<button
|
||
onClick={onRefresh}
|
||
className="p-1.5 rounded-lg hover:bg-[var(--overlay-hover)] text-[var(--text-muted)] hover:text-[var(--accent-cyan)] transition-colors"
|
||
title="刷新"
|
||
>
|
||
<RefreshCw className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* empty */}
|
||
{skills.length === 0 && (
|
||
<div className="flex flex-1 items-center justify-center p-8">
|
||
<div className="text-center select-none">
|
||
<div className="relative inline-block mb-5">
|
||
<div className="absolute inset-0 rounded-full bg-[var(--accent-cyan)]/10 blur-xl animate-pulse" />
|
||
<BookOpen className="relative h-12 w-12 text-[var(--accent-cyan)]/25" />
|
||
</div>
|
||
<p className="text-sm text-[var(--text-muted)] leading-relaxed">
|
||
暂无可用技能
|
||
<br />在 .picobot/skills/ 目录下添加 SKILL.md 文件
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* list */}
|
||
{skills.length > 0 && (
|
||
<div className="flex-1 overflow-y-auto px-3 pt-0 pb-2 space-y-3">
|
||
{sorted.map((source) => {
|
||
const cfg = sourceConfig(source);
|
||
const items = grouped.get(source)!;
|
||
const closed = collapsed.has(source);
|
||
return (
|
||
<div key={source}>
|
||
<SectionHeader
|
||
config={cfg}
|
||
count={items.length}
|
||
isCollapsed={closed}
|
||
onClick={() => toggle(source)}
|
||
/>
|
||
{!closed && (
|
||
<div className="mt-1.5 space-y-1.5">
|
||
{items.map((s) => (
|
||
<SkillCard key={s.name} skill={s} config={cfg} />
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
});
|