dsh-plugin-xujie-memory 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/ACCEPTANCE.md +37 -0
  2. package/BUILD-PROVENANCE.json +53 -0
  3. package/CHANGELOG.md +40 -1
  4. package/LICENSE +21 -21
  5. package/README.md +67 -17
  6. package/README_EN.md +54 -8
  7. package/cordis.patch.yml +49 -49
  8. package/lib/client.js +17219 -0
  9. package/lib/controller.js +307 -0
  10. package/lib/index.js +200 -14
  11. package/lib/typert.host.d.ts +3 -0
  12. package/lib/typert.host.js +15104 -0
  13. package/lib/typert.remote-client.d.ts +40 -0
  14. package/lib/typert.remote-client.d.ts.map +1 -0
  15. package/lib/typert.remote-client.js +15093 -0
  16. package/lib/types.d.ts +13 -0
  17. package/lib/types.js +1 -0
  18. package/package.json +49 -5
  19. package/python/specs/characters/aloof.json +14 -14
  20. package/python/specs/characters/gentle.json +14 -14
  21. package/python/specs/characters/tsundere.json +14 -14
  22. package/python/specs/debt.v1.json +31 -31
  23. package/python/specs/nickname.v1.json +16 -16
  24. package/python/specs/preference.v1.json +16 -16
  25. package/python/xujie_memory/__init__.py +123 -123
  26. package/python/xujie_memory/admission.py +211 -211
  27. package/python/xujie_memory/audit.py +377 -377
  28. package/python/xujie_memory/benchmark.py +281 -281
  29. package/python/xujie_memory/character.py +199 -199
  30. package/python/xujie_memory/console.py +41 -41
  31. package/python/xujie_memory/consolidate.py +356 -356
  32. package/python/xujie_memory/due.py +121 -121
  33. package/python/xujie_memory/emotion.py +573 -573
  34. package/python/xujie_memory/engine.py +1333 -1312
  35. package/python/xujie_memory/errors.py +98 -98
  36. package/python/xujie_memory/importance.py +104 -104
  37. package/python/xujie_memory/judge.py +350 -350
  38. package/python/xujie_memory/management.py +203 -0
  39. package/python/xujie_memory/memory.py +71 -10
  40. package/python/xujie_memory/orchestrator.py +690 -666
  41. package/python/xujie_memory/phrasing.py +292 -292
  42. package/python/xujie_memory/privacy.py +105 -0
  43. package/python/xujie_memory/schema.sql +492 -480
  44. package/python/xujie_memory/server.py +76 -0
  45. package/python/xujie_memory/specs.py +170 -170
  46. package/python/xujie_memory/summary.py +238 -238
  47. package/scripts/build.mjs +19 -0
  48. package/scripts/prepack.mjs +9 -0
  49. package/scripts/test-all.mjs +9 -0
  50. package/scripts/test-node.mjs +10 -0
  51. package/src/client/MemoryPanel.tsx +536 -0
  52. package/src/client/NeuralField.tsx +88 -0
  53. package/src/client/css.d.ts +4 -0
  54. package/src/client/icons.tsx +28 -0
  55. package/src/client/index.tsx +59 -0
  56. package/src/client/memory-adapter.ts +67 -0
  57. package/src/client/model.ts +79 -0
  58. package/src/client/neural/engine.ts +999 -0
  59. package/src/client/neural/morphology.ts +219 -0
  60. package/src/client/panel.css +337 -0
  61. package/src/client/types.ts +6 -0
  62. package/src/controller.ts +88 -0
  63. package/src/generated/typert.host.js +449 -0
  64. package/src/generated/typert.remote-client.js +438 -0
  65. package/src/index.ts +2 -0
  66. package/src/types.ts +13 -0
  67. package/test/cordis-fixture.mjs +5 -0
  68. package/test/management_regression.py +187 -0
  69. package/test/node-regression.entry.mjs +52 -0
  70. package/vendor/DSH-LICENSE.txt +21 -0
  71. package/vendor/THIRD-PARTY.md +6 -0
  72. package/vendor/dsh-protocol/index.ts +484 -0
  73. package/vendor/dsh-protocol/json-value.ts +53 -0
  74. package/vendor/dsh-protocol/owned-value.ts +39 -0
  75. package/vendor/dsh-protocol/principal.ts +17 -0
  76. package/vendor/dsh-protocol/remote-access.ts +38 -0
  77. package/vendor/dsh-protocol/remote-error.ts +49 -0
  78. package/vendor/dsh-protocol/remote-info.ts +161 -0
  79. package/vendor/dsh-protocol/types.ts +939 -0
  80. package/vendor/licenses/jridgewell-gen-mapping-MIT.txt +19 -0
  81. package/vendor/licenses/jridgewell-resolve-uri-MIT.txt +19 -0
  82. package/vendor/licenses/jridgewell-sourcemap-codec-MIT.txt +19 -0
  83. package/vendor/licenses/jridgewell-trace-mapping-MIT.txt +19 -0
  84. package/vendor/licenses/zod-MIT.txt +21 -0
@@ -0,0 +1,536 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
2
+ import type { Catalog, Node, PersonCard, SearchHit, Snapshot, Trace } from './types.ts'
3
+ import { MemoryAdapter, asFailure, type MemoryFailure, type XujieMemoryRemote } from './memory-adapter.ts'
4
+ import { FAILURE_TEXT, KIND_LABEL, LAYER_META, STATUS_LABEL, formatTime, isTraceBacked, layerCounts, layerIndex, shortId, sourceLabel, toField } from './model.ts'
5
+ import { NeuralField, type NeuralFieldHandle } from './NeuralField.tsx'
6
+ import { IconAlert, IconArrow, IconBack, IconClose, IconEdit, IconForget, IconLock, IconMinus, IconPause, IconPlay, IconPlus, IconReset, IconSearch } from './icons.tsx'
7
+
8
+ export interface MemoryPanelProps { remote: XujieMemoryRemote }
9
+
10
+ type Target = { nodeId: string } | { traceId: string }
11
+ type Tab = 'overview' | 'persona'
12
+
13
+ const TAG_KIND: Record<string, string> = { preference: '偏好', nickname: '称呼', debt: '往来', identity: '身份', relation: '关系' }
14
+ const MAX_CONTENT = 2000
15
+
16
+ function usePrefersReducedMotion(): boolean {
17
+ const query = '(prefers-reduced-motion: reduce)'
18
+ const [reduced, setReduced] = useState(() => typeof matchMedia === 'function' && matchMedia(query).matches)
19
+ useEffect(() => {
20
+ if (typeof matchMedia !== 'function') return
21
+ const mq = matchMedia(query)
22
+ const on = () => setReduced(mq.matches)
23
+ mq.addEventListener('change', on)
24
+ return () => mq.removeEventListener('change', on)
25
+ }, [])
26
+ return reduced
27
+ }
28
+
29
+ function Highlight({ text, query }: { text: string; query: string }) {
30
+ const q = query.trim()
31
+ if (!q) return <>{text}</>
32
+ const i = text.toLowerCase().indexOf(q.toLowerCase())
33
+ if (i < 0) return <>{text}</>
34
+ return <>{text.slice(0, i)}<mark className="xjm-mark">{text.slice(i, i + q.length)}</mark>{text.slice(i + q.length)}</>
35
+ }
36
+
37
+ function LayerChip({ layer }: { layer: string }) {
38
+ const li = layerIndex(layer)
39
+ return <span className="xjm-chip" data-layer={li}><i className="xjm-dot" aria-hidden="true" />{LAYER_META[li].name}</span>
40
+ }
41
+
42
+ function StatusBlock({ icon, title, body, action }: { icon?: ReactNode; title: string; body: string; action?: ReactNode }) {
43
+ return <div className="xjm-status" role="status">
44
+ {icon && <span className="xjm-status-icon">{icon}</span>}
45
+ <p className="xjm-status-title">{title}</p>
46
+ <p className="xjm-status-body">{body}</p>
47
+ {action}
48
+ </div>
49
+ }
50
+
51
+ export function MemoryPanel({ remote }: MemoryPanelProps) {
52
+ const adapter = useMemo(() => new MemoryAdapter(remote), [remote])
53
+ const reducedMotion = usePrefersReducedMotion()
54
+ const fieldRef = useRef<NeuralFieldHandle>(null)
55
+ const epoch = useRef(0)
56
+ const catalogEpoch = useRef(0)
57
+
58
+ const [catalog, setCatalog] = useState<Catalog | null>(null)
59
+ const [scopeId, setScopeId] = useState<string | null>(null)
60
+ const [snapshot, setSnapshot] = useState<Snapshot | null>(null)
61
+ const [loading, setLoading] = useState(true)
62
+ const [failure, setFailure] = useState<MemoryFailure | null>(null)
63
+ const [reloadKey, setReloadKey] = useState(0)
64
+
65
+ const [query, setQuery] = useState('')
66
+ const [hits, setHits] = useState<SearchHit[] | null>(null)
67
+ const [searching, setSearching] = useState(false)
68
+ const [searchFailure, setSearchFailure] = useState<MemoryFailure | null>(null)
69
+ const [searchNonce, setSearchNonce] = useState(0)
70
+
71
+ const [tab, setTab] = useState<Tab>('overview')
72
+ const [layerFocus, setLayerFocus] = useState<number | null>(null)
73
+ const [autoRotate, setAutoRotate] = useState(true)
74
+
75
+ const [target, setTarget] = useState<Target | null>(null)
76
+ const [trace, setTrace] = useState<Trace | null>(null)
77
+ const [detailLoading, setDetailLoading] = useState(false)
78
+ const [detailFailure, setDetailFailure] = useState<MemoryFailure | null>(null)
79
+ const [editing, setEditing] = useState(false)
80
+ const [draft, setDraft] = useState('')
81
+ const [busy, setBusy] = useState<'save' | 'forget' | null>(null)
82
+ const busyRef = useRef<typeof busy>(null)
83
+ busyRef.current = busy
84
+ const [actionFailure, setActionFailure] = useState<MemoryFailure | null>(null)
85
+ const [confirmForget, setConfirmForget] = useState(false)
86
+ const [toast, setToast] = useState<string | null>(null)
87
+ const [detailNonce, setDetailNonce] = useState(0)
88
+
89
+ // ---- catalog ----
90
+ useEffect(() => {
91
+ const my = ++catalogEpoch.current
92
+ setLoading(true); setFailure(null)
93
+ adapter.catalog().then(cat => {
94
+ if (my !== catalogEpoch.current) return
95
+ setCatalog(cat)
96
+ setScopeId(prev => {
97
+ if (prev && cat.scopes.some(s => s.scope_id === prev)) return prev
98
+ const best = [...cat.scopes].sort((a, b) => b.traces - a.traces)[0]
99
+ return best?.scope_id ?? null
100
+ })
101
+ if (!cat.scopes.length) { setSnapshot(null); setLoading(false) }
102
+ }, err => { if (my === catalogEpoch.current) { setFailure(asFailure(err)); setLoading(false) } })
103
+ }, [adapter, reloadKey])
104
+
105
+ // quiet catalog refresh after mutations (keeps per-scope counts in the selector honest)
106
+ const refreshCatalog = useCallback(() => {
107
+ const my = catalogEpoch.current
108
+ adapter.catalog().then(cat => { if (my === catalogEpoch.current) setCatalog(cat) }, () => { /* non-fatal */ })
109
+ }, [adapter])
110
+
111
+ // ---- snapshot ----
112
+ const loadSnapshot = useCallback(async (scope: string, quiet = false) => {
113
+ const my = ++epoch.current
114
+ if (!quiet) { setLoading(true); setFailure(null) }
115
+ try {
116
+ const snap = await adapter.snapshot(scope)
117
+ if (my !== epoch.current) return null
118
+ setSnapshot(snap)
119
+ setFailure(null)
120
+ return snap
121
+ } catch (err) {
122
+ if (my === epoch.current) setFailure(asFailure(err))
123
+ return null
124
+ } finally {
125
+ if (my === epoch.current) setLoading(false)
126
+ }
127
+ }, [adapter])
128
+
129
+ useEffect(() => {
130
+ if (!scopeId) return
131
+ setTarget(null); setHits(null); setLayerFocus(null)
132
+ void loadSnapshot(scopeId)
133
+ }, [scopeId, loadSnapshot, reloadKey])
134
+
135
+ // ---- search ----
136
+ useEffect(() => {
137
+ const q = query.trim()
138
+ if (!scopeId || !q) { setHits(null); setSearching(false); setSearchFailure(null); return }
139
+ let alive = true
140
+ setSearching(true)
141
+ const t = setTimeout(() => {
142
+ adapter.search(scopeId, q).then(
143
+ list => { if (alive) { setHits(list); setSearchFailure(null) } },
144
+ err => { if (alive) { setHits(null); setSearchFailure(asFailure(err)) } },
145
+ ).finally(() => { if (alive) setSearching(false) })
146
+ }, 260)
147
+ return () => { alive = false; clearTimeout(t) }
148
+ }, [adapter, scopeId, query, searchNonce])
149
+
150
+ // ---- derived ----
151
+ const nodeById = useMemo(() => new Map((snapshot?.nodes ?? []).map(n => [n.id, n])), [snapshot])
152
+ const field = useMemo(() => snapshot ? toField(snapshot) : { nodes: [], edges: [] }, [snapshot])
153
+ const counts = useMemo(() => layerCounts(snapshot), [snapshot])
154
+ const captions = useMemo(() => LAYER_META.map((m, i) => `${m.name} ${m.en} · ${counts[i]}`) as unknown as readonly [string, string, string], [counts])
155
+ const targetNode: Node | null = target && 'nodeId' in target ? nodeById.get(target.nodeId) ?? null : null
156
+ const traceId: string | null = target ? ('traceId' in target ? target.traceId : (targetNode && isTraceBacked(targetNode) ? targetNode.trace_id! : null)) : null
157
+ const selectedGraphId = target ? ('nodeId' in target ? target.nodeId : (nodeById.has(target.traceId) ? target.traceId : null)) : null
158
+ const highlightIds = useMemo(() => {
159
+ if (hits) return hits.map(h => h.trace_id)
160
+ if (layerFocus !== null && snapshot) return snapshot.nodes.filter(n => layerIndex(n.layer) === layerFocus).map(n => n.id)
161
+ return null
162
+ }, [hits, layerFocus, snapshot])
163
+
164
+ // ---- detail ----
165
+ useEffect(() => {
166
+ setEditing(false); setConfirmForget(false); setActionFailure(null); setDetailFailure(null)
167
+ // Never let the previous record stay visible/editable while another one loads.
168
+ setTrace(prev => (prev && prev.trace_id === traceId ? prev : null))
169
+ if (!scopeId || !traceId) { setDetailLoading(false); return }
170
+ let alive = true
171
+ setDetailLoading(true)
172
+ adapter.detail(scopeId, traceId).then(
173
+ t => { if (alive && t.trace_id === traceId) { setTrace(t); setDraft(t.content) } },
174
+ err => { if (alive) { setTrace(null); setDetailFailure(asFailure(err)) } },
175
+ ).finally(() => { if (alive) setDetailLoading(false) })
176
+ return () => { alive = false }
177
+ }, [adapter, scopeId, traceId, detailNonce, target])
178
+
179
+ useEffect(() => {
180
+ if (!toast) return
181
+ const t = setTimeout(() => setToast(null), 3600)
182
+ return () => clearTimeout(t)
183
+ }, [toast])
184
+
185
+ // Every user-initiated selection goes through here: blocked while a save/forget is in flight
186
+ // (so an async result cannot land on a different record), and it drops the previous record in the
187
+ // same render so no frame shows the old trace's actions under the new node's header.
188
+ const selectTarget = useCallback((next: Target | null) => {
189
+ if (busyRef.current) return
190
+ setTrace(null); setEditing(false); setConfirmForget(false); setActionFailure(null)
191
+ setDetailLoading(next !== null)
192
+ setTarget(next)
193
+ }, [])
194
+
195
+ const openTrace = useCallback((id: string) => {
196
+ selectTarget(nodeById.has(id) ? { nodeId: id } : { traceId: id })
197
+ }, [nodeById, selectTarget])
198
+
199
+ const onFieldSelect = useCallback((id: string | null) => {
200
+ if (id === null) { if (!editing) selectTarget(null); return }
201
+ if (target && 'nodeId' in target && target.nodeId === id) return
202
+ selectTarget({ nodeId: id })
203
+ }, [editing, target, selectTarget])
204
+
205
+ const save = async () => {
206
+ if (!scopeId || !trace || trace.trace_id !== traceId || busyRef.current) return
207
+ const content = draft.trim()
208
+ if (!content || content.length > MAX_CONTENT) return
209
+ setBusy('save'); setActionFailure(null)
210
+ try {
211
+ const result = await adapter.edit(scopeId, trace.trace_id, content, trace.content_hash)
212
+ if (!result.changed) { setEditing(false); setToast('内容未变化,没有生成新版本'); return }
213
+ setEditing(false)
214
+ setTrace(result.trace)
215
+ const pending = result.pending ?? 0
216
+ setToast(pending > 0 ? `已保存为新版本,产生 ${pending} 条待澄清` : '已保存为新版本,旧版本已替换')
217
+ const snap = await loadSnapshot(scopeId, true)
218
+ refreshCatalog()
219
+ const newId = result.trace.trace_id
220
+ setTarget(snap?.nodes.some(n => n.id === newId) ? { nodeId: newId } : { traceId: newId })
221
+ if (query.trim()) setSearchNonce(n => n + 1)
222
+ } catch (err) {
223
+ setActionFailure(asFailure(err))
224
+ } finally { setBusy(null) }
225
+ }
226
+
227
+ const forget = async () => {
228
+ if (!scopeId || !trace || trace.trace_id !== traceId || busyRef.current) return
229
+ setBusy('forget'); setActionFailure(null)
230
+ try {
231
+ await adapter.forget(scopeId, trace.trace_id, trace.content_hash)
232
+ setTarget(null); setTrace(null); setConfirmForget(false)
233
+ setToast('已忘记这条记忆,相关摘要与人设已重新推导')
234
+ await loadSnapshot(scopeId, true)
235
+ refreshCatalog()
236
+ if (query.trim()) setSearchNonce(n => n + 1)
237
+ } catch (err) {
238
+ setActionFailure(asFailure(err))
239
+ } finally { setBusy(null) }
240
+ }
241
+
242
+ const reloadLatest = () => { setActionFailure(null); setDetailNonce(n => n + 1); if (scopeId) void loadSnapshot(scopeId, true) }
243
+
244
+ // ---- render helpers ----
245
+ const scopes = catalog?.scopes ?? []
246
+ const stats = snapshot?.stats
247
+ const pageFailure = failure
248
+ const isEmpty = !loading && !pageFailure && (scopes.length === 0 || (snapshot !== null && snapshot.nodes.length === 0))
249
+ const showDetail = target !== null
250
+
251
+ const relationsOf = (node: Node): Array<{ node: Node; kind: string }> => {
252
+ if (!snapshot) return []
253
+ const out: Array<{ node: Node; kind: string }> = []
254
+ const seen = new Set<string>()
255
+ for (const e of snapshot.edges) {
256
+ const other = e.source === node.id ? e.target : e.target === node.id ? e.source : null
257
+ if (!other || seen.has(other)) continue
258
+ const n = nodeById.get(other)
259
+ if (n) { seen.add(other); out.push({ node: n, kind: e.kind }) }
260
+ }
261
+ return out
262
+ }
263
+
264
+ const stageOverlay = (() => {
265
+ if (pageFailure) {
266
+ const t = FAILURE_TEXT[pageFailure.kind]
267
+ return <StatusBlock icon={pageFailure.kind === 'owner' ? <IconLock /> : <IconAlert />} title={t.title} body={t.body}
268
+ action={pageFailure.kind !== 'owner' ? <button type="button" className="xjm-btn xjm-btn-ghost-dark" onClick={() => setReloadKey(k => k + 1)}>重试</button> : undefined} />
269
+ }
270
+ if (loading && !snapshot) return <div className="xjm-status" role="status"><span className="xjm-spinner" aria-hidden="true" /><p className="xjm-status-title">正在读取记忆</p></div>
271
+ if (isEmpty) return <StatusBlock title="还没有记忆" body={scopes.length ? '这个范围里还没有原话。与智能体对话后,原话、状态与摘要会在这里生长出来。' : '还没有任何记忆范围。与智能体对话后,这里会出现第一批记忆。'} />
272
+ return null
273
+ })()
274
+
275
+ const renderOverview = () => <>
276
+ <section className="xjm-section" aria-label="三层记忆">
277
+ <h2 className="xjm-h2">三层结构</h2>
278
+ <div className="xjm-layers">
279
+ {LAYER_META.map((m, i) => <button key={m.key} type="button" className="xjm-layer" data-layer={i} aria-pressed={layerFocus === i}
280
+ disabled={!snapshot || counts[i] === 0} onClick={() => setLayerFocus(layerFocus === i ? null : i)}>
281
+ <i className="xjm-dot" aria-hidden="true" />
282
+ <span className="xjm-layer-text"><span className="xjm-layer-name">{m.name}</span><span className="xjm-layer-hint">{m.hint}</span></span>
283
+ <span className="xjm-layer-count">{counts[i]}</span>
284
+ </button>)}
285
+ </div>
286
+ </section>
287
+ {stats && <section className="xjm-section" aria-label="统计">
288
+ <dl className="xjm-stats">
289
+ <div><dt>原话</dt><dd>{stats.traces}</dd></div>
290
+ <div><dt>状态</dt><dd>{stats.states}</dd></div>
291
+ <div><dt>锚点</dt><dd>{stats.anchors}</dd></div>
292
+ <div><dt>摘要</dt><dd>{stats.summaries}</dd></div>
293
+ <div><dt>待澄清</dt><dd>{stats.clarifications_open}</dd></div>
294
+ <div><dt>已忘记</dt><dd>{stats.traces_erased}</dd></div>
295
+ </dl>
296
+ {snapshot?.truncated && <p className="xjm-note">图中显示最近 {snapshot.limit} 条原话,共 {stats.traces} 条。更早的记录可通过搜索查看与编辑。</p>}
297
+ </section>}
298
+ {snapshot && snapshot.nodes.some(n => n.kind === 'trace') && <section className="xjm-section" aria-label="最近原话">
299
+ <h2 className="xjm-h2">最近原话</h2>
300
+ <ul className="xjm-list">
301
+ {snapshot.nodes.filter(n => n.kind === 'trace').slice(0, 6).map(n => <li key={n.id}>
302
+ <button type="button" className="xjm-row" onClick={() => selectTarget({ nodeId: n.id })}>
303
+ <span className="xjm-row-text">{n.content}</span>
304
+ <span className="xjm-row-meta">{formatTime(n.recorded_at)}{n.anchor ? ' · 锚点' : ''}</span>
305
+ </button>
306
+ </li>)}
307
+ </ul>
308
+ </section>}
309
+ </>
310
+
311
+ const renderPersona = (cards: PersonCard[]) => cards.length === 0
312
+ ? <p className="xjm-empty-line">还没有人设卡。对话中出现称呼、偏好等信息后会自动推导。</p>
313
+ : <>
314
+ <p className="xjm-note">人设卡由原话推导,只读。编辑或忘记原话后会重新推导。</p>
315
+ {cards.map(card => {
316
+ const groups = new Map<string, typeof card.tags>()
317
+ for (const t of card.tags) groups.set(t.kind, [...(groups.get(t.kind) ?? []), t])
318
+ return <article key={card.account_id} className="xjm-card">
319
+ <header className="xjm-card-head">
320
+ <span className="xjm-avatar" aria-hidden="true">{(card.display_name || card.account_id).slice(0, 1)}</span>
321
+ <span className="xjm-card-name">{card.display_name}</span>
322
+ <span className="xjm-card-status">{STATUS_LABEL[card.status] ?? card.status}</span>
323
+ </header>
324
+ {card.tags.length === 0 ? <p className="xjm-empty-line">暂无标签</p> : [...groups].map(([kind, tags]) => <div key={kind} className="xjm-tag-group">
325
+ <span className="xjm-tag-kind">{TAG_KIND[kind] ?? kind}</span>
326
+ <span className="xjm-tags">{tags.map(t => <span key={t.tag_id} className="xjm-tag" title={`来源 ${t.source} · 置信 ${Math.round(t.confidence * 100)}%`}>{t.value}</span>)}</span>
327
+ </div>)}
328
+ </article>
329
+ })}
330
+ </>
331
+
332
+ const renderHits = () => {
333
+ if (searchFailure) { const t = FAILURE_TEXT[searchFailure.kind]; return <p className="xjm-inline-error" role="alert">{t.title}。{t.body}</p> }
334
+ if (!hits) return <p className="xjm-empty-line">正在检索…</p>
335
+ if (!hits.length) return <p className="xjm-empty-line">没有找到与「{query.trim()}」相关的原话。</p>
336
+ const inGraph = hits.filter(h => nodeById.has(h.trace_id)).length
337
+ return <>
338
+ <p className="xjm-result-count">{hits.length} 条结果{inGraph < hits.length ? ` · ${hits.length - inGraph} 条在图外` : ''}</p>
339
+ <ul className="xjm-list">
340
+ {hits.map(h => <li key={h.trace_id}>
341
+ <button type="button" className="xjm-row" aria-current={traceId === h.trace_id ? 'true' : undefined} onClick={() => openTrace(h.trace_id)}>
342
+ <span className="xjm-row-text"><Highlight text={h.content} query={query} /></span>
343
+ <span className="xjm-row-meta">
344
+ {formatTime(h.recorded_at)}
345
+ {h.anchor && <span className="xjm-badge">锚点</span>}
346
+ {!nodeById.has(h.trace_id) && <span className="xjm-badge xjm-badge-muted">图外</span>}
347
+ </span>
348
+ </button>
349
+ </li>)}
350
+ </ul>
351
+ </>
352
+ }
353
+
354
+ const renderDetail = () => {
355
+ const node = targetNode
356
+ const derived = node !== null && !isTraceBacked(node)
357
+ const layer = node?.layer ?? 'episodic'
358
+ const kind = node ? (KIND_LABEL[node.kind] ?? node.kind) : '原话'
359
+ const head = <div className="xjm-detail-head">
360
+ <button type="button" className="xjm-icon-btn" aria-label="返回" disabled={busy !== null} onClick={() => selectTarget(null)}><IconBack /></button>
361
+ <LayerChip layer={layer} />
362
+ <span className="xjm-kind">{kind}</span>
363
+ <span className="xjm-spacer" />
364
+ {derived ? <span className="xjm-badge xjm-badge-muted">只读 · 派生</span> : !node && traceId ? <span className="xjm-badge xjm-badge-muted">图外</span> : null}
365
+ </div>
366
+
367
+ if (derived && node) {
368
+ const evidence = node.evidence ?? []
369
+ return <div className="xjm-detail">
370
+ {head}
371
+ <p className="xjm-content">{node.content}</p>
372
+ <dl className="xjm-meta">
373
+ {node.status && <div><dt>状态</dt><dd>{STATUS_LABEL[node.status] ?? node.status}</dd></div>}
374
+ {node.recorded_at && <div><dt>生成时间</dt><dd>{formatTime(node.recorded_at)}</dd></div>}
375
+ <div><dt>标识</dt><dd className="xjm-mono" title={node.id}>{shortId(node.id)}</dd></div>
376
+ </dl>
377
+ <p className="xjm-note">派生记忆不能直接编辑。要更正它,请编辑或忘记下面的证据原话。</p>
378
+ {node.kind === 'clarification'
379
+ ? <p className="xjm-empty-line">待澄清问题会在后续对话中由智能体向你确认。</p>
380
+ : <section className="xjm-section" aria-label="证据原话">
381
+ <h2 className="xjm-h2">证据原话 · {evidence.length}</h2>
382
+ {evidence.length === 0 ? <p className="xjm-empty-line">没有仍然有效的证据。</p> : <ul className="xjm-list">
383
+ {evidence.map(id => { const ev = nodeById.get(id); return <li key={id}>
384
+ <button type="button" className="xjm-row" onClick={() => openTrace(id)}>
385
+ <span className="xjm-row-text">{ev ? ev.content : `图外原话 ${shortId(id)}`}</span>
386
+ <span className="xjm-row-meta">{ev ? formatTime(ev.recorded_at) : '点击载入'}<IconArrow /></span>
387
+ </button>
388
+ </li> })}
389
+ </ul>}
390
+ </section>}
391
+ </div>
392
+ }
393
+
394
+ if (detailFailure) {
395
+ const t = FAILURE_TEXT[detailFailure.kind]
396
+ return <div className="xjm-detail">{head}<StatusBlock icon={<IconAlert />} title={t.title} body={t.body} action={<button type="button" className="xjm-btn" onClick={() => setDetailNonce(n => n + 1)}>重试</button>} /></div>
397
+ }
398
+ // Only a record that matches the current selection may be shown or mutated; while loading or on
399
+ // any mismatch, no edit/forget controls are rendered at all.
400
+ const live = trace && traceId && trace.trace_id === traceId ? trace : null
401
+ if (!live) return <div className="xjm-detail">{head}{traceId ? <div className="xjm-skeleton" aria-busy="true" aria-label="正在载入记录"><i /><i /><i /></div> : null}</div>
402
+ const len = draft.trim().length
403
+ const dirty = draft.trim() !== live.content
404
+ const conflict = actionFailure?.kind === 'conflict'
405
+ const relations = node ? relationsOf(node) : []
406
+ return <div className="xjm-detail">
407
+ {head}
408
+ {actionFailure && <div className={conflict ? 'xjm-banner xjm-banner-warn' : 'xjm-banner xjm-banner-error'} role="alert">
409
+ <p><b>{FAILURE_TEXT[actionFailure.kind].title}</b>{FAILURE_TEXT[actionFailure.kind].body}</p>
410
+ {(conflict || actionFailure.kind === 'not_found') && <button type="button" className="xjm-btn" onClick={reloadLatest}>载入最新</button>}
411
+ </div>}
412
+ {editing
413
+ ? <div className="xjm-editor">
414
+ <label className="xjm-visually-hidden" htmlFor="xjm-draft">记忆内容</label>
415
+ <textarea id="xjm-draft" className="xjm-textarea" value={draft} maxLength={MAX_CONTENT} autoFocus
416
+ onChange={e => setDraft(e.target.value)} disabled={busy !== null}
417
+ onKeyDown={e => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); void save() } if (e.key === 'Escape') { setEditing(false); setDraft(live.content) } }} />
418
+ <div className="xjm-editor-foot">
419
+ <span className={len === 0 || len > MAX_CONTENT ? 'xjm-count xjm-count-bad' : 'xjm-count'}>{len} / {MAX_CONTENT}</span>
420
+ <span className="xjm-spacer" />
421
+ <button type="button" className="xjm-btn" disabled={busy !== null} onClick={() => { setEditing(false); setDraft(live.content); setActionFailure(null) }}>取消</button>
422
+ <button type="button" className="xjm-btn xjm-btn-primary" disabled={busy !== null || !dirty || len === 0 || len > MAX_CONTENT} onClick={() => void save()}>{busy === 'save' ? '保存中…' : '保存'}</button>
423
+ </div>
424
+ <p className="xjm-note">保存会以新版本替换这条原话(生成新的记录 ID),重要度与锚点随之保留;依赖它的状态、摘要和人设会重新推导。</p>
425
+ </div>
426
+ : <p className="xjm-content">{live.content}</p>}
427
+ {!editing && !confirmForget && <div className="xjm-actions">
428
+ <button type="button" className="xjm-btn" onClick={() => { setDraft(live.content); setEditing(true); setActionFailure(null) }}><IconEdit />编辑</button>
429
+ <button type="button" className="xjm-btn xjm-btn-danger-quiet" onClick={() => { setConfirmForget(true); setActionFailure(null) }}><IconForget />忘记</button>
430
+ </div>}
431
+ {confirmForget && <div className="xjm-confirm" role="alertdialog" aria-labelledby="xjm-forget-title">
432
+ <p id="xjm-forget-title" className="xjm-confirm-title">忘记这条原话?</p>
433
+ <p className="xjm-confirm-body">它和由它派生的摘要、状态证据会被清除,人设卡将重新推导。此操作无法撤销。</p>
434
+ <div className="xjm-actions">
435
+ <button type="button" className="xjm-btn" disabled={busy !== null} onClick={() => setConfirmForget(false)}>取消</button>
436
+ <button type="button" className="xjm-btn xjm-btn-danger" disabled={busy !== null} onClick={() => void forget()}>{busy === 'forget' ? '正在忘记…' : '确认忘记'}</button>
437
+ </div>
438
+ </div>}
439
+ <dl className="xjm-meta">
440
+ <div><dt>记录时间</dt><dd>{formatTime(live.recorded_at)}</dd></div>
441
+ <div><dt>来源</dt><dd>{sourceLabel(live.source_kind)}</dd></div>
442
+ <div><dt>重要度</dt><dd>{Number(live.importance).toFixed(1)}</dd></div>
443
+ <div><dt>优先级</dt><dd>{Number(live.priority).toFixed(2)}</dd></div>
444
+ <div><dt>锚点</dt><dd>{live.anchor ? '是' : '否'}</dd></div>
445
+ <div><dt>标识</dt><dd className="xjm-mono" title={live.trace_id}>{shortId(live.trace_id)}</dd></div>
446
+ </dl>
447
+ {relations.length > 0 && <section className="xjm-section" aria-label="关联">
448
+ <h2 className="xjm-h2">关联 · {relations.length}</h2>
449
+ <ul className="xjm-list">
450
+ {relations.map(({ node: other }) => <li key={other.id}>
451
+ <button type="button" className="xjm-row xjm-row-compact" disabled={busy !== null} onClick={() => selectTarget({ nodeId: other.id })}>
452
+ <span className="xjm-row-lead"><LayerChip layer={other.layer} /><span className="xjm-kind">{KIND_LABEL[other.kind] ?? other.kind}</span></span>
453
+ <span className="xjm-row-text xjm-row-text-1">{other.content}</span>
454
+ </button>
455
+ </li>)}
456
+ </ul>
457
+ </section>}
458
+ </div>
459
+ }
460
+
461
+ const onSearchKey = (e: KeyboardEvent<HTMLInputElement>) => {
462
+ if (e.key === 'Escape') setQuery('')
463
+ if (e.key === 'Enter') setSearchNonce(n => n + 1)
464
+ }
465
+
466
+ const canSearch = Boolean(scopeId) && !pageFailure
467
+ const nodesN = snapshot?.nodes.length ?? 0, edgesN = snapshot?.edges.length ?? 0
468
+
469
+ return <div className="xjm-root">
470
+ <div className="xjm-shell">
471
+ <section className="xjm-stage" aria-label="三层神经记忆图">
472
+ <NeuralField ref={fieldRef} nodes={field.nodes} edges={field.edges} selectedId={selectedGraphId} highlightIds={highlightIds}
473
+ dimmed={Boolean(stageOverlay)} reducedMotion={reducedMotion} captions={captions} onSelect={onFieldSelect}
474
+ label={`三层记忆神经图:语义 ${counts[0]}、情节 ${counts[1]}、工作 ${counts[2]} 个节点,${edgesN} 条关联。拖动旋转,滚轮缩放,方向键也可旋转。`} />
475
+ <div className="xjm-stage-top">
476
+ <header className="xjm-stage-head">
477
+ <p className="xjm-eyebrow">NEURAL MEMORY</p>
478
+ <h1 className="xjm-title">记忆</h1>
479
+ <p className="xjm-sub">{snapshot ? `${nodesN} 个节点 · ${edgesN} 条关联` : '\u00a0'}</p>
480
+ </header>
481
+ <div className="xjm-controls" role="toolbar" aria-label="视图">
482
+ <button type="button" className="xjm-tool" aria-label={autoRotate && !reducedMotion ? '暂停自转' : '开始自转'} aria-pressed={autoRotate && !reducedMotion}
483
+ disabled={reducedMotion} title={reducedMotion ? '已遵循系统“减少动态效果”设置' : undefined}
484
+ onClick={() => { const next = !autoRotate; setAutoRotate(next); fieldRef.current?.setAutoRotate(next) }}>{autoRotate && !reducedMotion ? <IconPause /> : <IconPlay />}</button>
485
+ <span className="xjm-tool-sep" aria-hidden="true" />
486
+ <button type="button" className="xjm-tool" aria-label="缩小" onClick={() => fieldRef.current?.zoomOut()}><IconMinus /></button>
487
+ <button type="button" className="xjm-tool" aria-label="放大" onClick={() => fieldRef.current?.zoomIn()}><IconPlus /></button>
488
+ <button type="button" className="xjm-tool" aria-label="重置视角" onClick={() => fieldRef.current?.reset()}><IconReset /></button>
489
+ </div>
490
+ </div>
491
+ <div className="xjm-legend" aria-hidden={Boolean(stageOverlay)}>
492
+ <span><i className="xjm-g-dot" />原话 · 可编辑</span>
493
+ <span><i className="xjm-g-diamond" />派生 · 只读</span>
494
+ <span><i className="xjm-g-link" />真实关联</span>
495
+ <span className="xjm-legend-quiet">纤维与脉冲为神经结构,不代表记录</span>
496
+ </div>
497
+ {stageOverlay && <div className="xjm-overlay">{stageOverlay}</div>}
498
+ </section>
499
+
500
+ <aside className="xjm-inspector" aria-label="记忆检查器">
501
+ <div className="xjm-insp-head">
502
+ <div className="xjm-scope">
503
+ <label className="xjm-scope-label" htmlFor="xjm-scope">记忆库</label>
504
+ <select id="xjm-scope" className="xjm-select" value={scopeId ?? ''} disabled={!scopes.length || busy !== null}
505
+ onChange={e => setScopeId(e.target.value || null)}>
506
+ {!scopes.length && <option value="">{catalog === null && !failure ? "读取中…" : "无可用范围"}</option>}
507
+ {scopes.map(s => <option key={s.scope_id} value={s.scope_id}>{s.agent_id || shortId(s.scope_id)} · {s.traces} 条</option>)}
508
+ </select>
509
+ </div>
510
+ <div className="xjm-search">
511
+ <IconSearch />
512
+ <input type="search" className="xjm-search-input" placeholder="搜索原话,包括图外记录" aria-label="搜索记忆"
513
+ value={query} disabled={!canSearch} onChange={e => setQuery(e.target.value)} onKeyDown={onSearchKey} />
514
+ {searching && <span className="xjm-spinner xjm-spinner-sm" aria-hidden="true" />}
515
+ {query && <button type="button" className="xjm-icon-btn xjm-icon-btn-sm" aria-label="清除搜索" onClick={() => setQuery('')}><IconClose /></button>}
516
+ </div>
517
+ {!showDetail && !query.trim() && <div className="xjm-tabs" role="tablist" aria-label="检查器视图">
518
+ <button type="button" role="tab" aria-selected={tab === 'overview'} className="xjm-tab" onClick={() => setTab('overview')}>概览</button>
519
+ <button type="button" role="tab" aria-selected={tab === 'persona'} className="xjm-tab" onClick={() => setTab('persona')}>人设卡{snapshot?.cards.length ? <span className="xjm-tab-count">{snapshot.cards.length}</span> : null}</button>
520
+ </div>}
521
+ </div>
522
+ <div className="xjm-insp-body">
523
+ {pageFailure ? <StatusBlock icon={pageFailure.kind === 'owner' ? <IconLock /> : <IconAlert />} title={FAILURE_TEXT[pageFailure.kind].title} body={FAILURE_TEXT[pageFailure.kind].body}
524
+ action={pageFailure.kind !== 'owner' ? <button type="button" className="xjm-btn" onClick={() => setReloadKey(k => k + 1)}>重试</button> : undefined} />
525
+ : loading && !snapshot ? <div className="xjm-skeleton" aria-hidden="true"><i /><i /><i /><i /></div>
526
+ : showDetail ? renderDetail()
527
+ : query.trim() ? renderHits()
528
+ : isEmpty ? <p className="xjm-empty-line">暂无可管理的记忆。</p>
529
+ : tab === 'persona' ? renderPersona(snapshot?.cards ?? [])
530
+ : renderOverview()}
531
+ </div>
532
+ <div className="xjm-toast-slot" aria-live="polite">{toast && <div className="xjm-toast">{toast}</div>}</div>
533
+ </aside>
534
+ </div>
535
+ </div>
536
+ }
@@ -0,0 +1,88 @@
1
+ import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'
2
+ import { NeuralEngine, type FieldEdge, type FieldNode } from './neural/engine.ts'
3
+
4
+ export interface NeuralFieldHandle {
5
+ zoomIn(): void
6
+ zoomOut(): void
7
+ reset(): void
8
+ setAutoRotate(on: boolean): void
9
+ }
10
+
11
+ export interface NeuralFieldProps {
12
+ nodes: FieldNode[]
13
+ edges: FieldEdge[]
14
+ selectedId: string | null
15
+ highlightIds: readonly string[] | null
16
+ dimmed: boolean
17
+ reducedMotion: boolean
18
+ captions: readonly [string, string, string]
19
+ onSelect: (id: string | null) => void
20
+ label: string
21
+ }
22
+
23
+ /** React shell for the canvas engine. The engine owns its RAF loop and listeners and is disposed on unmount. */
24
+ export const NeuralField = forwardRef<NeuralFieldHandle, NeuralFieldProps>(function NeuralField(props, ref) {
25
+ const canvasRef = useRef<HTMLCanvasElement>(null)
26
+ const tipRef = useRef<HTMLDivElement>(null)
27
+ const wrapRef = useRef<HTMLDivElement>(null)
28
+ const engineRef = useRef<NeuralEngine | null>(null)
29
+ const onSelectRef = useRef(props.onSelect)
30
+ onSelectRef.current = props.onSelect
31
+ const [failed, setFailed] = useState(false)
32
+
33
+ useEffect(() => {
34
+ const canvas = canvasRef.current, tip = tipRef.current, wrap = wrapRef.current
35
+ if (!canvas || !tip || !wrap) return
36
+ let engine: NeuralEngine
37
+ try {
38
+ engine = new NeuralEngine(canvas, tip, { reducedMotion: props.reducedMotion, layerCaptions: props.captions, onSelect: id => onSelectRef.current(id) })
39
+ } catch {
40
+ setFailed(true)
41
+ return
42
+ }
43
+ engineRef.current = engine
44
+ const qa = (globalThis as { __XJM_QA__?: { engine?: unknown } }).__XJM_QA__
45
+ if (qa) qa.engine = engine
46
+ const ro = new ResizeObserver(entries => {
47
+ const r = entries[0]?.contentRect
48
+ if (r) engine.resize(r.width, r.height)
49
+ })
50
+ ro.observe(wrap)
51
+ const rect = wrap.getBoundingClientRect()
52
+ engine.resize(rect.width, rect.height)
53
+ const io = typeof IntersectionObserver === 'function'
54
+ ? new IntersectionObserver(entries => engine.setVisible(entries.some(e => e.isIntersecting)))
55
+ : null
56
+ io?.observe(wrap)
57
+ engine.start()
58
+ return () => {
59
+ ro.disconnect()
60
+ io?.disconnect()
61
+ engine.dispose()
62
+ engineRef.current = null
63
+ if (qa && qa.engine === engine) qa.engine = undefined
64
+ }
65
+ // The engine is created once per mount; later prop changes are pushed below.
66
+ // eslint-disable-next-line react-hooks/exhaustive-deps
67
+ }, [])
68
+
69
+ useEffect(() => { engineRef.current?.setOptions({ reducedMotion: props.reducedMotion, layerCaptions: props.captions, onSelect: id => onSelectRef.current(id) }) }, [props.reducedMotion, props.captions])
70
+ useEffect(() => { engineRef.current?.setData(props.nodes, props.edges) }, [props.nodes, props.edges])
71
+ useEffect(() => { engineRef.current?.setSelected(props.selectedId) }, [props.selectedId, props.nodes])
72
+ useEffect(() => { engineRef.current?.setHighlight(props.highlightIds) }, [props.highlightIds, props.nodes])
73
+ useEffect(() => { engineRef.current?.setDimmed(props.dimmed) }, [props.dimmed])
74
+
75
+ useImperativeHandle(ref, () => ({
76
+ zoomIn: () => engineRef.current?.zoomBy(1.2),
77
+ zoomOut: () => engineRef.current?.zoomBy(1 / 1.2),
78
+ reset: () => engineRef.current?.resetView(),
79
+ setAutoRotate: on => engineRef.current?.setAutoRotate(on),
80
+ }), [])
81
+
82
+ return <div className="xjm-field" ref={wrapRef}>
83
+ {failed
84
+ ? <p className="xjm-field-fallback">此环境无法绘制神经图。右侧列表仍可检索与编辑全部记忆。</p>
85
+ : <canvas ref={canvasRef} className="xjm-canvas" tabIndex={0} role="img" aria-label={props.label} />}
86
+ <div ref={tipRef} className="xjm-tip" hidden aria-hidden="true" />
87
+ </div>
88
+ })
@@ -0,0 +1,4 @@
1
+ declare module '*.css' {
2
+ const text: string
3
+ export default text
4
+ }