page.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. "use client";
  2. import { FormEvent, useEffect, useMemo, useState } from "react";
  3. import { Activity, Archive, BrainCircuit, ChevronRight, Database, History, Lock, RefreshCw, Send, Settings2, ShieldCheck, Sparkles, Trash2, Wrench } from "lucide-react";
  4. function apiUrl(path: string): string {
  5. if (typeof window === "undefined") return path;
  6. const configured = process.env.NEXT_PUBLIC_API_URL?.trim().replace(/\/$/, "");
  7. const runtimeBase = `${window.location.protocol}//${window.location.hostname}:8000`;
  8. return `${configured || runtimeBase}${path}`;
  9. }
  10. async function apiFetch(path: string, init?: RequestInit, timeoutMs = 10000): Promise<Response> {
  11. const controller = new AbortController();
  12. const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
  13. try {
  14. return await fetch(apiUrl(path), { ...init, signal: controller.signal });
  15. } finally {
  16. window.clearTimeout(timeout);
  17. }
  18. }
  19. type SystemId = "text2mem" | "mem0" | "letta" | "reme" | "memu";
  20. type SystemInfo = { id: SystemId; name: string; paradigm: string; description: string; available: boolean; mode: string; status: string; package?: string; setup_hint?: string };
  21. type Memory = { id: string; system: string; content: string; memory_type: string; source: string; confidence: number; created_at: string; updated_at: string; metadata: Record<string, unknown> };
  22. type Audit = { id: string; system: string; operation: string; status: string; target?: string; details: Record<string, unknown>; created_at: string };
  23. type Message = { role: "user" | "assistant" | "system"; content: string; mode?: string; events?: string[] };
  24. const fallbackSystems: SystemInfo[] = [
  25. { id: "text2mem", name: "Text2Mem", paradigm: "IR 操作契约", description: "显式定义记忆操作、授权与生命周期。", available: true, mode: "native", status: "ready" },
  26. { id: "mem0", name: "Mem0", paradigm: "自动提取", description: "自动提取事实,处理更新与冲突。", available: false, mode: "unavailable", status: "not-configured" },
  27. { id: "letta", name: "Letta", paradigm: "有状态 Agent", description: "Core / Archival / Recall 分层工作记忆。", available: false, mode: "unavailable", status: "not-installed" },
  28. { id: "reme", name: "ReMe", paradigm: "文件即记忆", description: "Markdown 文件、日志和混合检索。", available: false, mode: "unavailable", status: "not-configured" },
  29. { id: "memu", name: "memU", paradigm: "主动式管线", description: "后台整理、候选记忆和主动检索。", available: false, mode: "unavailable", status: "not-configured" },
  30. ];
  31. const icons: Record<SystemId, typeof BrainCircuit> = { text2mem: ShieldCheck, mem0: BrainCircuit, letta: Database, reme: Archive, memu: Sparkles };
  32. function initialMessages(): Record<SystemId, Message[]> {
  33. return {
  34. text2mem: [{ role: "assistant", content: "你好,我是 Text2Mem Agent。告诉我你的技术偏好、项目进展或希望我记住的工程规则。" }],
  35. mem0: [{ role: "assistant", content: "Mem0 适配器当前未启用;完成记忆投影、删除、重置和隔离闭环后再开放对话。" }],
  36. letta: [{ role: "assistant", content: "Letta 适配器当前未启用;完成 Agent ID 恢复和分层记忆投影后再开放对话。" }],
  37. reme: [{ role: "assistant", content: "你好,我是 ReMe Agent。当前对话只会使用 ReMe 的文件记忆。" }],
  38. memu: [{ role: "assistant", content: "memU 适配器当前未启用;完成队列、预算、审批和停止开关后再开放对话。" }],
  39. };
  40. }
  41. function setupSummary(system: SystemInfo): string {
  42. if (system.setup_hint) return system.setup_hint;
  43. if (system.id === "text2mem") return "本项目内置教学实现,无需额外框架服务。";
  44. if (system.id === "reme") return "已连接 ReMe 真实 SDK;右侧每一项代表一个 Markdown 记忆文件。";
  45. return "当前为未启用的适配器骨架。";
  46. }
  47. export default function Home() {
  48. const [systems, setSystems] = useState<SystemInfo[]>(fallbackSystems);
  49. const [selected, setSelected] = useState<SystemId>("text2mem");
  50. const [messagesBySystem, setMessagesBySystem] = useState<Record<SystemId, Message[]>>(initialMessages);
  51. const [input, setInput] = useState("");
  52. const [memories, setMemories] = useState<Memory[]>([]);
  53. const [audit, setAudit] = useState<Audit[]>([]);
  54. const [loading, setLoading] = useState(false);
  55. const [resetting, setResetting] = useState(false);
  56. const [deletingId, setDeletingId] = useState<string | null>(null);
  57. const [notice, setNotice] = useState("");
  58. const current = useMemo(() => systems.find((item) => item.id === selected) ?? fallbackSystems[0], [systems, selected]);
  59. const messages = messagesBySystem[selected];
  60. const memoryNoun = selected === "reme" ? "记忆文件" : "记忆";
  61. async function loadSystems() {
  62. try {
  63. const response = await apiFetch("/api/systems");
  64. if (!response.ok) throw new Error("API unavailable");
  65. const data = await response.json();
  66. setSystems(data.systems);
  67. setNotice(`已连接:${apiUrl("")} · 存储:${data.storage}`);
  68. } catch (error) {
  69. const reason = error instanceof Error ? error.message : "未知错误";
  70. setNotice(`连接失败:${apiUrl("")} · ${reason}`);
  71. }
  72. }
  73. async function loadDetails(system: SystemId = selected) {
  74. try {
  75. const [memoryResponse, auditResponse] = await Promise.all([
  76. apiFetch(`/api/memories?system=${system}`),
  77. apiFetch(`/api/audit?system=${system}`),
  78. ]);
  79. if (memoryResponse.ok) setMemories((await memoryResponse.json()).memories);
  80. if (auditResponse.ok) setAudit((await auditResponse.json()).events);
  81. } catch {
  82. setMemories([]);
  83. setAudit([]);
  84. }
  85. }
  86. useEffect(() => { void loadSystems(); void loadDetails(); }, []);
  87. useEffect(() => { void loadDetails(selected); }, [selected]);
  88. async function submit(event: FormEvent) {
  89. event.preventDefault();
  90. const content = input.trim();
  91. if (!content || loading) return;
  92. const system = selected;
  93. setInput("");
  94. setMessagesBySystem((all) => ({ ...all, [system]: [...all[system], { role: "user", content }] }));
  95. setLoading(true);
  96. try {
  97. const response = await apiFetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ system, message: content }) }, 120000);
  98. const data = await response.json();
  99. if (!response.ok) throw new Error(data.detail ?? "执行失败");
  100. setMessagesBySystem((all) => ({ ...all, [system]: [...all[system], { role: "assistant", content: data.answer, mode: data.mode, events: data.memory_events?.map((item: { event?: string }) => item.event).filter(Boolean) }] }));
  101. await loadDetails(system);
  102. } catch (error) {
  103. setMessagesBySystem((all) => ({ ...all, [system]: [...all[system], { role: "system", content: error instanceof Error ? error.message : "执行失败" }] }));
  104. } finally {
  105. setLoading(false);
  106. }
  107. }
  108. async function reset() {
  109. if (!window.confirm(`确定清空 ${current.name} 的本地记忆和审计记录吗?`)) return;
  110. const system = selected;
  111. setResetting(true);
  112. try {
  113. const response = await apiFetch("/api/reset", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ system }) }, 60000);
  114. const data = await response.json();
  115. if (!response.ok) throw new Error(data.detail ?? "清空失败");
  116. setMessagesBySystem((all) => ({ ...all, [system]: [...initialMessages()[system], { role: "system", content: `${current.name} 的记忆已清空。` }] }));
  117. await loadDetails(system);
  118. } catch (error) {
  119. setNotice(error instanceof Error ? error.message : "清空失败");
  120. } finally {
  121. setResetting(false);
  122. }
  123. }
  124. async function deleteMemory(memory: Memory) {
  125. const warning = selected === "reme" ? "该文件可能包含多条事实,删除后会重建 ReMe 索引。\n\n" : "";
  126. if (!window.confirm(`确定删除这个${current.name}${memoryNoun}吗?\n\n${warning}${memory.content.slice(0, 120)}`)) return;
  127. const system = selected;
  128. setDeletingId(memory.id);
  129. try {
  130. const response = await apiFetch(`/api/memories/${encodeURIComponent(memory.id)}?system=${system}`, { method: "DELETE" }, 60000);
  131. const data = await response.json();
  132. if (!response.ok) throw new Error(data.detail ?? "删除失败");
  133. await loadDetails(system);
  134. } catch (error) {
  135. setNotice(error instanceof Error ? error.message : "删除失败");
  136. } finally {
  137. setDeletingId(null);
  138. }
  139. }
  140. const Icon = icons[selected];
  141. return (
  142. <main className="app-shell">
  143. <header className="topbar">
  144. <div className="brand"><div className="brand-mark"><BrainCircuit size={20} /></div><div><div className="brand-title">Memory Agents Lab</div><div className="brand-subtitle">五种记忆范式 · 同一场景 · 独立证据</div></div></div>
  145. <div className="top-actions"><span className="workspace-pill"><Activity size={14} /> local-workspace</span><button className="icon-button" onClick={() => { void loadSystems(); void loadDetails(); }} title="刷新状态"><RefreshCw size={16} /></button></div>
  146. </header>
  147. <section className="hero"><div><p className="eyebrow">AI PROGRAMMING ASSISTANT</p><h1>把记忆从黑箱,<span>变成可观察的系统。</span></h1><p className="hero-copy">五种记忆范式使用同一套工程场景;当前可实测 Text2Mem 与 ReMe,其余适配器保留为后续扩展。</p></div><div className="hero-status"><div className="status-label"><span className="pulse" />系统状态</div><strong>{notice || "正在连接后端…"}</strong><small>模型 API、数据库和框架均可按需配置</small></div></section>
  148. <div className="workspace-grid">
  149. <aside className="system-rail panel"><div className="panel-heading"><div><span className="section-kicker">PARADIGMS</span><h2>记忆系统</h2></div><Settings2 size={17} className="muted" /></div><div className="system-list">{systems.map((system) => { const SystemIcon = icons[system.id]; return <button key={system.id} className={`system-card ${selected === system.id ? "selected" : ""}`} onClick={() => setSelected(system.id)}><div className="system-icon"><SystemIcon size={17} /></div><div className="system-copy"><strong>{system.name}</strong><span>{system.paradigm}</span></div><div className={`availability ${system.available ? "on" : "off"}`} title={system.available ? "可用" : "未配置"} />{selected === system.id && <ChevronRight size={15} className="selected-arrow" />}</button>; })}</div><div className="rail-note"><Lock size={15} /><span>当前是单工作区模式。未来可扩展用户与租户隔离。</span></div></aside>
  150. <section className="chat-panel panel"><div className="chat-header"><div className="chat-system-title"><div className="system-icon large"><Icon size={20} /></div><div><div className="section-kicker">ACTIVE AGENT</div><h2>{current.name}<span className="mode-chip">{current.mode}</span></h2></div></div><button className="reset-button" onClick={reset} disabled={resetting}><Trash2 size={15} />{resetting ? "清空中" : "清空记忆"}</button></div><div className="agent-description">{current.description} <span className="separator">·</span> {setupSummary(current)}</div><div className="messages">{messages.map((message, index) => <div key={`${message.role}-${index}`} className={`message-row ${message.role}`}><div className="message-avatar">{message.role === "user" ? "你" : message.role === "system" ? "!" : "M"}</div><div className="message-bubble"><div className="message-role">{message.role === "user" ? "USER" : message.role === "system" ? "SYSTEM" : current.name.toUpperCase()}{message.mode && <span className="message-mode">{message.mode}</span>}</div><div className="message-content">{message.content}</div>{message.events && message.events.length > 0 && <div className="event-tags">{message.events.map((event) => <span key={event}>{event}</span>)}</div>}</div></div>)}</div><form className="composer" onSubmit={submit}><textarea value={input} onChange={(event) => setInput(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); void submit(event); } }} placeholder={current.available ? "例如:记住我偏好用 Python + FastAPI,项目数据库选 PostgreSQL…" : "该适配器当前未启用,请先查看上方配置说明。"} rows={2} disabled={!current.available} /><button className="send-button" disabled={loading || !input.trim() || !current.available}>{loading ? <RefreshCw size={17} className="spin" /> : <Send size={17} />}<span>{loading ? "处理中" : "发送"}</span></button></form></section>
  151. <aside className="inspector panel"><div className="inspector-tabs"><span className="active"><Archive size={15} />记忆</span><span><History size={15} />审计</span></div><div className="inspector-content"><div className="inspector-summary"><div><span className="section-kicker">MEMORY STORE</span><h2>{memories.length}<small> {selected === "reme" ? "个记忆文件" : "条记忆"}</small></h2></div><span className="storage-badge"><Database size={13} />持久化</span></div>{memories.length === 0 ? <div className="empty-state"><Archive size={25} /><strong>还没有记忆</strong><span>在对话中使用“记住”“偏好”“项目”等表达,观察写入事件。</span></div> : <div className="memory-list">{memories.map((memory) => <article className="memory-item" key={memory.id}><div className="memory-meta"><span>{memory.memory_type}</span><time>{new Date(memory.updated_at).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}</time><button className="memory-delete-button" type="button" aria-label={`删除${memoryNoun}:${memory.content.slice(0, 30)}`} title={`删除这个${memoryNoun}`} disabled={deletingId === memory.id} onClick={() => { void deleteMemory(memory); }}><Trash2 size={12} />{deletingId === memory.id ? "删除中" : "删除"}</button></div><p>{memory.content}</p><div className="memory-source">来源:{memory.source} · 置信度:{Math.round(memory.confidence * 100)}%</div></article>)}</div>}<div className="audit-preview"><div className="subheading"><span>最近审计</span><span className="count-badge">{audit.length}</span></div>{audit.slice(0, 5).map((event) => <div className="audit-row" key={event.id}><span className="audit-dot" /><span>{event.operation}</span><time>{new Date(event.created_at).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}</time></div>)}</div></div></aside>
  152. </div>
  153. <footer><Wrench size={14} /> <span>Text2Mem 和 ReMe 已完成当前阶段闭环;Mem0、Letta 和 memU 仅保留未启用适配器骨架。</span><span className="footer-link">见 README →</span></footer>
  154. </main>
  155. );
  156. }