464 lines
20 KiB
HTML
464 lines
20 KiB
HTML
<!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> |