dsh-plugin-xujie-memory 0.6.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.
- package/ACCEPTANCE.md +37 -0
- package/BUILD-PROVENANCE.json +53 -0
- package/CHANGELOG.md +12 -0
- package/README.md +48 -23
- package/README_EN.md +32 -9
- package/lib/client.js +17219 -0
- package/lib/controller.js +307 -0
- package/lib/index.js +87 -22
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +15104 -0
- package/lib/typert.remote-client.d.ts +40 -0
- package/lib/typert.remote-client.d.ts.map +1 -0
- package/lib/typert.remote-client.js +15093 -0
- package/lib/types.d.ts +13 -0
- package/lib/types.js +1 -0
- package/package.json +49 -5
- package/python/xujie_memory/__init__.py +123 -123
- package/python/xujie_memory/engine.py +1333 -1312
- package/python/xujie_memory/management.py +203 -0
- package/python/xujie_memory/memory.py +27 -21
- package/python/xujie_memory/privacy.py +105 -0
- package/python/xujie_memory/schema.sql +492 -480
- package/python/xujie_memory/server.py +52 -0
- package/scripts/build.mjs +19 -0
- package/scripts/prepack.mjs +9 -0
- package/scripts/test-all.mjs +9 -0
- package/scripts/test-node.mjs +10 -0
- package/src/client/MemoryPanel.tsx +536 -0
- package/src/client/NeuralField.tsx +88 -0
- package/src/client/css.d.ts +4 -0
- package/src/client/icons.tsx +28 -0
- package/src/client/index.tsx +59 -0
- package/src/client/memory-adapter.ts +67 -0
- package/src/client/model.ts +79 -0
- package/src/client/neural/engine.ts +999 -0
- package/src/client/neural/morphology.ts +219 -0
- package/src/client/panel.css +337 -0
- package/src/client/types.ts +6 -0
- package/src/controller.ts +88 -0
- package/src/generated/typert.host.js +449 -0
- package/src/generated/typert.remote-client.js +438 -0
- package/src/index.ts +2 -0
- package/src/types.ts +13 -0
- package/test/cordis-fixture.mjs +5 -0
- package/test/management_regression.py +187 -0
- package/test/node-regression.entry.mjs +52 -0
- package/vendor/DSH-LICENSE.txt +21 -0
- package/vendor/THIRD-PARTY.md +6 -0
- package/vendor/dsh-protocol/index.ts +484 -0
- package/vendor/dsh-protocol/json-value.ts +53 -0
- package/vendor/dsh-protocol/owned-value.ts +39 -0
- package/vendor/dsh-protocol/principal.ts +17 -0
- package/vendor/dsh-protocol/remote-access.ts +38 -0
- package/vendor/dsh-protocol/remote-error.ts +49 -0
- package/vendor/dsh-protocol/remote-info.ts +161 -0
- package/vendor/dsh-protocol/types.ts +939 -0
- package/vendor/licenses/jridgewell-gen-mapping-MIT.txt +19 -0
- package/vendor/licenses/jridgewell-resolve-uri-MIT.txt +19 -0
- package/vendor/licenses/jridgewell-sourcemap-codec-MIT.txt +19 -0
- package/vendor/licenses/jridgewell-trace-mapping-MIT.txt +19 -0
- 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
|
+
})
|