- 同步阻塞操作(附件处理、历史加载、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 缓存分组排序结果
479 lines
16 KiB
TypeScript
479 lines
16 KiB
TypeScript
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 ────────────────────────────────────── */
|
||
|
||
// memo:props 全部稳定(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>
|
||
);
|
||
});
|