PicoBot/web/src/components/Panel/MemoryPanel.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

479 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, useMemo, memo } from 'react';
import {
Brain,
User,
Library,
History,
Cpu,
Globe,
Star,
Package,
RefreshCw,
X,
ChevronDown,
ChevronRight,
Plus,
Pencil,
Trash2,
Check,
} from 'lucide-react';
import type { MemorySummary, Command } from '../../types/protocol';
/* ── types ────────────────────────────────────────────── */
interface MemoryPanelProps {
memories: MemorySummary[];
onRefresh: () => void;
onClose?: () => void;
onCreateMemory: (ns: string, key: string, content: string) => Command;
onUpdateMemory: (id: string, content: string) => Command;
onDeleteMemory: (id: string) => Command;
sendCommand: (cmd: Command) => void;
}
interface NamespaceConfig {
label: string;
icon: typeof Brain;
accent: string;
accentBorder: string;
}
const NS: Record<string, NamespaceConfig> = {
user: {
label: '用户记忆',
icon: User,
accent: 'text-[var(--accent-cyan)]',
accentBorder: 'border-[var(--accent-cyan)]/40',
},
semantic: {
label: '语义记忆',
icon: Library,
accent: 'text-[var(--accent-amber)]',
accentBorder: 'border-[var(--accent-amber)]/40',
},
episodic: {
label: '情景记忆',
icon: History,
accent: 'text-[var(--accent-purple)]',
accentBorder: 'border-[var(--accent-purple)]/40',
},
skill: {
label: '技能记忆',
icon: Cpu,
accent: 'text-[var(--accent-green)]',
accentBorder: 'border-[var(--accent-green)]/40',
},
environment: {
label: '环境记忆',
icon: Globe,
accent: 'text-[var(--accent-blue)]',
accentBorder: 'border-[var(--accent-blue)]/40',
},
reflection: {
label: '反思记忆',
icon: Star,
accent: 'text-[rgb(242,90,90)]',
accentBorder: 'border-[rgb(242,90,90)]/40',
},
other: {
label: '其他',
icon: Package,
accent: 'text-[var(--text-muted)]',
accentBorder: 'border-[var(--border-color)]',
},
};
function cfg(ns: string): NamespaceConfig {
return (
NS[ns] ?? {
label: ns,
icon: Package,
accent: 'text-[var(--text-secondary)]',
accentBorder: 'border-[var(--border-color)]',
}
);
}
function fmtKey(k: string) {
return k.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
const NS_OPTIONS = Object.entries(NS);
/* ── Memory card with edit/delete ──────────────────────── */
function MemoryCard({
memory,
config,
onUpdate,
onDelete,
}: {
memory: MemorySummary;
config: NamespaceConfig;
onUpdate: (id: string, content: string) => void;
onDelete: (id: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [editContent, setEditContent] = useState(memory.content);
const [confirmDelete, setConfirmDelete] = useState(false);
const handleSave = () => {
if (editContent.trim() && editContent !== memory.content) {
onUpdate(memory.id, editContent.trim());
}
setEditing(false);
};
const handleDelete = () => {
if (confirmDelete) {
onDelete(memory.id);
} else {
setConfirmDelete(true);
setTimeout(() => setConfirmDelete(false), 3000);
}
};
return (
<div
className={`group rounded-lg bg-[var(--overlay-hover)] border-l-2 ${config.accentBorder} border border-[var(--border-color)] overflow-hidden transition-all duration-200 hover:border-[var(--border-accent)]`}
>
<div className="px-3 py-2.5">
{/* header row */}
<div className="flex items-center justify-between mb-0.5">
<span
className={`text-[10px] font-mono uppercase tracking-wider ${config.accent} opacity-60`}
>
{fmtKey(memory.memory_key)}
</span>
{/* action buttons — visible on hover */}
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => {
setEditing(!editing);
setEditContent(memory.content);
}}
className={`p-1 rounded hover:bg-[var(--overlay-subtle)] ${editing ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)]'} hover:text-[var(--accent-cyan)] transition-colors`}
title="编辑"
>
<Pencil className="h-3 w-3" />
</button>
<button
onClick={handleDelete}
className={`p-1 rounded hover:bg-[rgb(242,90,90)]/10 ${confirmDelete ? 'text-[rgb(242,90,90)]' : 'text-[var(--text-muted)]'} hover:text-[rgb(242,90,90)] transition-colors`}
title={confirmDelete ? '再次点击确认删除' : '删除'}
>
<Trash2 className="h-3 w-3" />
</button>
</div>
</div>
{/* content */}
{editing ? (
<div className="flex gap-1.5 mt-1">
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
className="flex-1 text-sm bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg px-2 py-1.5 text-[var(--text-primary)] resize-none focus:outline-none focus:border-[var(--accent-cyan)] min-h-[120px]"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSave();
}
}}
/>
<button
onClick={handleSave}
className="shrink-0 p-1.5 rounded-lg bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 transition-colors"
title="保存"
>
<Check className="h-3.5 w-3.5" />
</button>
</div>
) : (
<p className="text-sm text-[var(--text-secondary)] leading-relaxed">{memory.content}</p>
)}
</div>
</div>
);
}
/* ── Add memory form ───────────────────────────────────── */
function AddMemoryForm({
onAdd,
onCancel,
}: {
onAdd: (ns: string, key: string, content: string) => void;
onCancel: () => void;
}) {
const [ns, setNs] = useState('user');
const [key, setKey] = useState('');
const [content, setContent] = useState('');
const handleSubmit = () => {
if (!key.trim() || !content.trim()) return;
onAdd(ns, key.trim(), content.trim());
};
return (
<div className="rounded-xl border border-[var(--border-accent)] bg-[var(--bg-tertiary)]/80 p-3 space-y-2.5 animate-fade-in">
<div className="flex gap-2">
<select
value={ns}
onChange={(e) => setNs(e.target.value)}
className="text-xs bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg px-2 py-1.5 text-[var(--text-primary)]"
>
{NS_OPTIONS.map(([k, v]) => (
<option key={k} value={k}>
{v.label}
</option>
))}
</select>
<input
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="键名 (如 work_preference)"
className="flex-1 text-xs bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg px-2 py-1.5 text-[var(--text-primary)] placeholder:text-[var(--text-muted)]"
/>
</div>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="内容..."
className="w-full text-sm bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg px-2.5 py-2 text-[var(--text-primary)] placeholder:text-[var(--text-muted)] resize-none min-h-[120px]"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}}
/>
<div className="flex justify-end gap-1.5">
<button
onClick={onCancel}
className="px-3 py-1 rounded-lg text-xs text-[var(--text-muted)] hover:bg-[var(--overlay-hover)] transition-colors"
>
</button>
<button
onClick={handleSubmit}
disabled={!key.trim() || !content.trim()}
className="px-3 py-1 rounded-lg text-xs bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
</button>
</div>
</div>
);
}
/* ── Section header ────────────────────────────────────── */
function SectionHeader({
config,
count,
isCollapsed,
onClick,
}: {
config: NamespaceConfig;
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>
);
}
/* ── main component ────────────────────────────────────── */
// memoprops 全部稳定memories 仅在刷新时换引用、回调均 useCallback
// 主视图流式期间 App 每帧重渲染时跳过面板重渲染与分组/排序重算。
export const MemoryPanel = memo(function MemoryPanel({
memories,
onRefresh,
onClose,
onCreateMemory,
onUpdateMemory,
onDeleteMemory,
sendCommand,
}: MemoryPanelProps) {
const [collapsed, setCollapsed] = useState<Set<string>>(() => {
try {
const s = localStorage.getItem('picobot-memory-collapsed');
return s ? new Set(JSON.parse(s)) : new Set();
} catch (_) {
return new Set();
}
});
const [showAddForm, setShowAddForm] = useState(false);
const toggle = (ns: string) => {
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(ns)) {
next.delete(ns);
} else {
next.add(ns);
}
localStorage.setItem('picobot-memory-collapsed', JSON.stringify([...next]));
return next;
});
};
// memories 引用未变时跳过分组/排序重算(流式期间 App 每帧重渲染)
const { grouped, sorted } = useMemo(() => {
const grouped = new Map<string, MemorySummary[]>();
for (const m of memories) {
const l = grouped.get(m.namespace) || [];
l.push(m);
grouped.set(m.namespace, l);
}
const order = ['user', 'semantic', 'episodic', 'skill', 'environment', 'reflection', 'other'];
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 };
}, [memories]);
const handleCreate = (ns: string, key: string, content: string) => {
sendCommand(onCreateMemory(ns, key, content));
setShowAddForm(false);
};
const handleUpdate = (id: string, content: string) => {
sendCommand(onUpdateMemory(id, content));
};
const handleDelete = (id: string) => {
sendCommand(onDeleteMemory(id));
};
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">
<Brain 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>
{memories.length > 0 && (
<span className="text-[11px] font-mono text-[var(--text-muted)] tabular-nums ml-0.5">
{memories.length}
</span>
)}
<div className="ml-auto flex items-center gap-0.5">
<button
onClick={() => setShowAddForm(!showAddForm)}
className={`p-1.5 rounded-lg transition-colors ${showAddForm ? 'bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)]' : 'text-[var(--text-muted)] hover:bg-[var(--overlay-hover)] hover:text-[var(--accent-cyan)]'}`}
title="新增记忆"
>
<Plus className="h-3.5 w-3.5" />
</button>
<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>
{onClose && (
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-[var(--overlay-hover)] text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors"
title="收起"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
{/* add form */}
{showAddForm && (
<div className="px-3 pt-2">
<AddMemoryForm onAdd={handleCreate} onCancel={() => setShowAddForm(false)} />
</div>
)}
{/* empty */}
{memories.length === 0 && !showAddForm && (
<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" />
<Brain className="relative h-12 w-12 text-[var(--accent-cyan)]/25" />
</div>
<p className="text-sm text-[var(--text-muted)] leading-relaxed">
PicoBot
<br />
</p>
</div>
</div>
)}
{/* list */}
{memories.length > 0 && (
<div className="flex-1 overflow-y-auto px-3 pt-0 pb-2 space-y-3">
{sorted.map((ns) => {
const c = cfg(ns);
const items = grouped.get(ns)!;
const closed = collapsed.has(ns);
return (
<div key={ns}>
<SectionHeader
config={c}
count={items.length}
isCollapsed={closed}
onClick={() => toggle(ns)}
/>
{!closed && (
<div className="mt-1.5 space-y-1.5">
{items.map((m) => (
<MemoryCard
key={m.id}
memory={m}
config={c}
onUpdate={handleUpdate}
onDelete={handleDelete}
/>
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
});