memoryweave/web-ui/index.html

464 lines
20 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>织忆 — 记忆系统</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script src="https://d3js.org/d3.v7.min.js"></script>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text y='26' font-size='28'>🧠</text></svg>" />
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e5e7eb; margin: 0; }
.nav-link { @apply px-4 py-2 rounded-lg transition-colors cursor-pointer; }
.nav-link:hover { @apply bg-gray-700; }
.nav-link.active { @apply bg-blue-600 text-white; }
.card { @apply bg-gray-800 rounded-xl p-4 border border-gray-700; }
.btn { @apply px-4 py-2 rounded-lg font-medium transition-all cursor-pointer; }
.btn-primary { @apply bg-blue-600 hover:bg-blue-700 text-white; }
.btn-ghost { @apply bg-transparent hover:bg-gray-700 text-gray-300; }
input, select { @apply bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500; }
.badge { @apply inline-block px-2 py-0.5 rounded text-xs font-mono; }
.badge-episodes { @apply bg-purple-900 text-purple-200; }
.badge-distilled { @apply bg-green-900 text-green-200; }
.badge-core { @apply bg-yellow-900 text-yellow-200; }
.badge-system_fact { @apply bg-red-900 text-red-200; }
.badge-default { @apply bg-gray-700 text-gray-300; }
table { @apply w-full text-sm; }
th { @apply text-left text-gray-400 font-normal border-b border-gray-700 pb-2; }
td { @apply py-2 border-b border-gray-800; }
tr:hover td { @apply bg-gray-800/50; }
pre { @apply bg-gray-900 rounded-lg p-3 text-xs overflow-x-auto; }
.graph-node { cursor: pointer; }
.graph-node:hover circle { filter: brightness(1.3); }
.drawer { @apply fixed top-0 right-0 h-full w-80 bg-gray-800 border-l border-gray-700 p-4 overflow-y-auto z-50; }
.overlay { @apply fixed inset-0 bg-black/50 z-40; }
@keyframes spin { to { transform: rotate(360deg); } }
.animate-spin { animation: spin 1s linear infinite; }
</style>
</head>
<body>
<div id="root">加载中…</div>
<script type="text/babel" data-type="module">
// ─── API 客户端 ──────────────────────────────────────────────
const API_BASE = 'http://localhost:7821';
const API_KEY = 'zhiyi-dev-key-2026';
async function apiFetch(path, body = null) {
const opts = {
headers: { 'X-API-Key': API_KEY, 'Content-Type': 'application/json' },
};
if (body) opts.method = 'POST', opts.body = JSON.stringify(body);
const r = await fetch(API_BASE + path, opts);
if (!r.ok) {
const txt = await r.text();
throw new Error(`API ${r.status}: ${txt}`);
}
return r.json();
}
const api = {
stats: () => apiFetch('/api/v1/stats'),
recall: (q, ns = 'hermes-main', topK = 20) =>
apiFetch('/api/v1/recall', { query: q, namespace: ns, top_k: topK }),
navigate: (entity, maxHops = 1, ns = 'hermes-main') =>
apiFetch('/api/v1/graph/navigate', { entity, max_hops: maxHops, namespace: ns }),
export: (ns = 'hermes-main') =>
apiFetch(`/api/v1/graph/export?namespace=${ns}&limit=2000`),
pagerank: () => apiFetch('/api/v1/graph/pagerank'),
distillStatus: () => apiFetch('/api/v1/distill/status'),
};
// ─── 全局状态 ────────────────────────────────────────────────
const AppCtx = React.createContext({});
function useCtx() { return React.useContext(AppCtx); }
// ─── 工具 ────────────────────────────────────────────────────
function truncate(str, len = 120) {
if (!str) return '';
return str.length > len ? str.slice(0, len - 1) + '…' : str;
}
function timeAgo(ts) {
if (!ts) return '—';
const d = new Date(ts);
const now = Date.now();
const diff = Math.floor((now - d) / 1000);
if (diff < 60) return `${diff}s前`;
if (diff < 3600) return `${Math.floor(diff/60)}m前`;
if (diff < 86400) return `${Math.floor(diff/3600)}h前`;
return `${Math.floor(diff/86400)}d前`;
}
function catColor(cat) {
const map = { episodes: 'purple', distilled: 'green', core: 'yellow', system_fact: 'red' };
return map[cat] || 'gray';
}
// ─── 顶栏导航 ────────────────────────────────────────────────
function NavBar({ page, setPage }) {
const links = [
{ id: 'memories', label: '📋 记忆' },
{ id: 'graph', label: '🕸️ 图谱' },
{ id: 'search', label: '🔍 搜索' },
{ id: 'distill', label: '⚙️ 蒸馏' },
];
return (
<nav className="flex items-center gap-1 px-4 py-3 border-b border-gray-700 bg-gray-900">
<span className="text-xl mr-6">🧠 织忆</span>
{links.map(l => (
<button key={l.id} onClick={() => setPage(l.id)}
className={`nav-link ${page === l.id ? 'active' : ''}`}>
{l.label}
</button>
))}
<div className="ml-auto flex items-center gap-2 text-xs text-gray-500">
<span>hermes-main</span>
</div>
</nav>
);
}
// ─── 页面:记忆列表 ──────────────────────────────────────────
function MemoriesPage() {
const [memories, setMemories] = React.useState([]);
const [total, setTotal] = React.useState(0);
const [page, setPage] = React.useState(1);
const [loading, setLoading] = React.useState(true);
const limit = 20;
const load = React.useCallback(async () => {
setLoading(true);
try {
// 通过 recall 获取记忆列表(无 query 时返回所有)
const d = await apiFetch('/api/v1/recall', { query: '的', namespace: 'hermes-main', top_k: 1000 });
setMemories(d.results || []);
setTotal(d.count || 0);
} catch(e) {
console.error(e);
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => { load(); }, [load]);
const paged = memories.slice((page - 1) * limit, page * limit);
const totalPages = Math.max(1, Math.ceil(total / limit));
return (
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">记忆列表 <span className="text-gray-500 text-sm font-normal"> {total} </span></h2>
<button onClick={load} className="btn btn-ghost text-sm">🔄 刷新</button>
</div>
{loading ? (
<div className="flex justify-center py-20"><div className="animate-spin w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full"/></div>
) : (
<>
<div className="card overflow-hidden">
<table>
<thead><tr>
<th className="w-16">类别</th>
<th>内容预览</th>
<th className="w-24">时间</th>
<th className="w-48">ID</th>
</tr></thead>
<tbody>
{paged.map(m => (
<tr key={m.id} title={m.content}>
<td><span className={`badge badge-${catColor(m.category)}`}>{m.category || 'default'}</span></td>
<td className="text-gray-300">{truncate(m.content)}</td>
<td className="text-gray-500 text-xs">{m.created_at ? timeAgo(new Date(m.created_at).getTime()) : '—'}</td>
<td className="text-gray-600 font-mono text-xs">{m.id}</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex justify-center gap-2 mt-4">
<button onClick={() => setPage(p => Math.max(1, p-1))} className="btn btn-ghost" disabled={page <= 1}> 上一页</button>
<span className="px-3 py-2 text-sm text-gray-400"> {page} / {totalPages} </span>
<button onClick={() => setPage(p => Math.min(totalPages, p+1))} className="btn btn-ghost" disabled={page >= totalPages}>下一页 </button>
</div>
)}
</>
)}
</div>
);
}
// ─── 页面:图谱探索 ──────────────────────────────────────────
function GraphPage() {
const [nodes, setNodes] = React.useState([]);
const [links, setLinks] = React.useState([]);
const [selected, setSelected] = React.useState(null);
const [center, setCenter] = React.useState('织忆');
const [loading, setLoading] = React.useState(true);
const svgRef = React.useRef();
const loadGraph = React.useCallback(async () => {
setLoading(true);
try {
const d = await api.export('hermes-main');
setNodes(d.nodes || []);
setLinks(d.edges || []);
} catch(e) { console.error(e); }
finally { setLoading(false); }
}, []);
React.useEffect(() => { loadGraph(); }, [loadGraph]);
// D3 force simulation
React.useEffect(() => {
if (!nodes.length || !svgRef.current) return;
const svg = d3.select(svgRef.current);
svg.selectAll('*').remove();
const w = svgRef.current.clientWidth || 900;
const h = 600;
const sim = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).id(d => d.id).distance(80))
.force('charge', d3.forceManyBody().strength(-200))
.force('center', d3.forceCenter(w/2, h/2))
.force('collision', d3.forceCollide(20));
const g = svg.append('g');
svg.call(d3.zoom().scaleExtent([0.2, 4]).on('zoom', e => g.attr('transform', e.transform)));
const link = g.append('g').selectAll('line').data(links).join('line')
.attr('stroke', '#374151').attr('stroke-width', 1);
const node = g.append('g').selectAll('g').data(nodes).join('g')
.attr('class', 'graph-node')
.call(d3.drag().on('start', e => { if (!e.active) sim.alphaTarget(0.3).restart(); e.subject.fx = e.subject.x; e.subject.fy = e.subject.y; })
.on('drag', e => { e.subject.fx = e.x; e.subject.fy = e.y; })
.on('end', e => { if (!e.active) sim.alphaTarget(0); e.subject.fx = null; e.subject.fy = null; }))
.on('click', (_, d) => setSelected(d));
node.append('circle').attr('r', 6)
.attr('fill', d => d.type === 'episode' ? '#8b5cf6' : '#3b82f6');
node.append('text').text(d => (d.name || d.id || '').slice(0, 12))
.attr('dx', 10).attr('dy', 4).attr('fill', '#9ca3af').style('font-size', '11px');
sim.on('tick', () => {
link.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
.attr('x2', d => d.target.x).attr('y2', d => d.target.y);
node.attr('transform', d => `translate(${d.x},${d.y})`);
});
}, [nodes, links]);
return (
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">🕸 图谱探索 <span className="text-gray-500 text-sm font-normal">{nodes.length} 节点 / {links.length} </span></h2>
<div className="flex gap-2">
<input placeholder="实体名…" value={center} onChange={e => setCenter(e.target.value)} className="text-sm w-48"/>
<button onClick={() => { setCenter(document.querySelector('input').value); }} className="btn btn-primary text-sm">跳转</button>
<button onClick={loadGraph} className="btn btn-ghost text-sm">🔄</button>
</div>
</div>
<div className="card overflow-hidden" style={{ position: 'relative' }}>
{loading ? (
<div className="flex justify-center py-20"><div className="animate-spin w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full"/></div>
) : (
<svg ref={svgRef} width="100%" height={600} style={{ display: 'block' }}/>
)}
</div>
{selected && (
<>
<div className="overlay" onClick={() => setSelected(null)}/>
<div className="drawer">
<div className="flex justify-between items-center mb-4">
<h3 className="font-semibold text-lg">{selected.name || selected.id}</h3>
<button onClick={() => setSelected(null)} className="btn btn-ghost text-sm"></button>
</div>
<dl className="space-y-2 text-sm">
<dt className="text-gray-500">ID</dt><dd className="font-mono text-xs text-gray-300">{selected.id}</dd>
<dt className="text-gray-500">类型</dt><dd className="text-gray-300">{selected.type || 'entity'}</dd>
<dt className="text-gray-500">权重</dt><dd className="text-gray-300">{(selected.weight || 0).toFixed(3)}</dd>
</dl>
</div>
</>
)}
</div>
);
}
// ─── 页面:语义搜索 ──────────────────────────────────────────
function SearchPage() {
const [query, setQuery] = React.useState('');
const [results, setResults] = React.useState([]);
const [count, setCount] = React.useState(0);
const [loading, setLoading] = React.useState(false);
const debRef = React.useRef(null);
const search = React.useCallback(async (q) => {
if (!q.trim()) { setResults([]); setCount(0); return; }
setLoading(true);
try {
const d = await api.recall(q, 'hermes-main', 20);
setResults(d.results || []);
setCount(d.count || 0);
} catch(e) { console.error(e); }
finally { setLoading(false); }
}, []);
const handleQuery = (val) => {
setQuery(val);
clearTimeout(debRef.current);
debRef.current = setTimeout(() => search(val), 300);
};
return (
<div className="p-6">
<h2 className="text-lg font-semibold mb-4">🔍 语义搜索</h2>
<div className="relative mb-4">
<input
autoFocus
value={query}
onChange={e => handleQuery(e.target.value)}
placeholder="输入关键词搜索记忆…"
className="w-full text-lg px-4 py-3 bg-gray-800 border border-gray-700 rounded-xl focus:border-blue-500 outline-none"
/>
{loading && <div className="absolute right-3 top-1/2 -translate-y-1/2 w-5 h-5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"/>}
</div>
{count > 0 && (
<p className="text-sm text-gray-500 mb-3">找到 {count} 条相关记忆</p>
)}
<div className="space-y-3">
{results.map((r, i) => (
<div key={r.id} className="card">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-gray-500">#{i+1}</span>
<span className={`badge badge-${catColor(r.category)}`}>{r.category || 'default'}</span>
<span className="text-xs text-gray-600 ml-auto">{(r.score || 0).toFixed(3)}</span>
</div>
<p className="text-gray-200 text-sm leading-relaxed">{r.content}</p>
<p className="text-gray-700 font-mono text-xs mt-2">{r.id}</p>
</div>
))}
</div>
{!loading && query && results.length === 0 && (
<p className="text-gray-500 text-center py-12">未找到相关记忆</p>
)}
{!query && (
<p className="text-gray-600 text-center py-12 text-sm">输入关键词开始搜索300ms 实时搜索</p>
)}
</div>
);
}
// ─── 页面:蒸馏监控 ──────────────────────────────────────────
function DistillPage() {
const [stats, setStats] = React.useState(null);
const [distill, setDistill] = React.useState(null);
const [loading, setLoading] = React.useState(true);
const load = React.useCallback(async () => {
setLoading(true);
try {
const [s, d] = await Promise.all([api.stats(), api.distillStatus().catch(() => null)]);
setStats(s);
setDistill(d);
} catch(e) { console.error(e); }
finally { setLoading(false); }
}, []);
React.useEffect(() => { load(); }, [load]);
return (
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold"> 蒸馏监控</h2>
<button onClick={load} className="btn btn-ghost text-sm">🔄 刷新</button>
</div>
{loading ? (
<div className="flex justify-center py-20"><div className="animate-spin w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full"/></div>
) : stats ? (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div className="card text-center">
<div className="text-3xl font-bold text-blue-400">{stats.total_memories || 0}</div>
<div className="text-gray-500 text-sm mt-1">记忆总数</div>
</div>
<div className="card text-center">
<div className="text-3xl font-bold text-purple-400">{stats.total_episodes || 0}</div>
<div className="text-gray-500 text-sm mt-1">Episodes</div>
</div>
<div className="card text-center">
<div className="text-3xl font-bold text-red-400">{stats.tombstone_count || 0}</div>
<div className="text-gray-500 text-sm mt-1">墓碑记录</div>
</div>
</div>
{/* 蒸馏配额 */}
{stats.distill_quota && (
<div className="card">
<h3 className="font-medium mb-3">蒸馏配额</h3>
<div className="flex items-center gap-3">
<div className="flex-1 bg-gray-700 rounded-full h-3 overflow-hidden">
<div className="h-full bg-gradient-to-r from-blue-500 to-purple-500 transition-all"
style={{ width: `${Math.min(100, (stats.distill_quota.used / stats.distill_quota.limit) * 100)}%` }}/>
</div>
<span className="text-sm text-gray-400">{stats.distill_quota.used} / {stats.distill_quota.limit}</span>
</div>
<p className="text-xs text-gray-600 mt-1">每日限额 · tier: {stats.distill_quota.tier}</p>
</div>
)}
{/* 队列 */}
{distill && distill.queue && distill.queue.length > 0 && (
<div className="card">
<h3 className="font-medium mb-3">蒸馏队列 <span className="text-gray-500 text-sm">{distill.queue.length} 条待处理</span></h3>
<div className="space-y-2">
{distill.queue.slice(0, 20).map((q, i) => (
<div key={i} className="flex items-center gap-3 text-sm border-b border-gray-700 pb-2">
<span className={`badge badge-${catColor(q.category)}`}>{q.category || 'default'}</span>
<span className="text-gray-300 flex-1 truncate">{truncate(q.content || q.episode_id || '', 80)}</span>
</div>
))}
{distill.queue.length > 20 && (
<p className="text-xs text-gray-600">还有 {distill.queue.length - 20} </p>
)}
</div>
</div>
)}
{/* 后端信息 */}
<div className="card">
<h3 className="font-medium mb-2 text-gray-400 text-sm">后端状态</h3>
<pre className="text-xs text-gray-500">{JSON.stringify({ backend: stats.backend, data_dir: stats.data_dir }, null, 2)}</pre>
</div>
</div>
) : (
<p className="text-gray-500 text-center py-12">加载失败</p>
)}
</div>
);
}
// ─── 主应用 ──────────────────────────────────────────────────
function App() {
const [page, setPage] = React.useState('memories');
const pages = { memories: MemoriesPage, graph: GraphPage, search: SearchPage, distill: DistillPage };
const Page = pages[page] || MemoriesPage;
return (
<AppCtx.Provider value={{}}>
<div className="min-h-screen bg-[#0f1117]">
<NavBar page={page} setPage={setPage} />
<Page key={page} />
</div>
</AppCtx.Provider>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>