PicoBot/web/src/components/Panel/MemoryPanel.tsx
oudecheng eaee29841d feat(web): 对齐 deepseek-harness 设计语言,重构前端视觉与三栏布局
- 设计 token 重映射为 DeepSeek 调色板(深浅双主题),正文改无衬线字体栈,全面去霓虹化
- 移除顶部 Header,控件迁入左栏(Logo/连接/通道/会话/tabs/底部操作区),左右栏支持拖拽调宽并持久化,折叠为图标 rail
- 对话区对齐 DeepSeek 聊天风:hero/docked 状态机、736px 居中列、用户右对齐柔和气泡、助手无气泡平铺、composer 卡片与反差发送键;子智能体点击导航完整保留
- 全功能区同步换肤:侧栏列表、右栏面板、ConfigPage、弹窗、选择器等
- fix: 修复 virtual-core 3.17.x 陈旧行高导致消息行重叠(宽度变化/滚动停止后强制 resizeItem 重测)
- fix: 修复 ConfigPage/Modal 引用不存在的 fadeIn/scaleIn keyframes 致入场动画失效
2026-08-14 19:15:45 +08:00

473 lines
15 KiB
TypeScript

import { useState } 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 ────────────────────────────────────── */
export 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;
});
};
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);
});
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>
);
}