PicoBot/web/src/components/Panel/SkillList.tsx
oudecheng 0159227828 perf: 第二批性能修复——blocking 线程池隔离、HTTP 客户端复用、前端 memo 与流式节流
- 同步阻塞操作(附件处理、历史加载、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 缓存分组排序结果
2026-08-18 06:55:21 +08:00

226 lines
7.6 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, 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 ────────────────────────────────────── */
// memoprops 全部稳定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>
);
});