@avantf/dsh-mem 0.3.1 → 0.4.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 (144) hide show
  1. package/README.md +89 -28
  2. package/cordis.patch.yml +17 -0
  3. package/lib/client.js +155 -14
  4. package/lib/engine/mem/config/config_files.d.ts +26 -0
  5. package/lib/engine/mem/config/domains.d.ts +20 -0
  6. package/lib/engine/mem/config/loader.d.ts +35 -0
  7. package/lib/engine/mem/config/paths.d.ts +53 -0
  8. package/lib/engine/mem/db/chunk.d.ts +26 -0
  9. package/lib/engine/mem/db/conn.d.ts +13 -0
  10. package/lib/engine/mem/db/dao/chunks.d.ts +150 -0
  11. package/lib/engine/mem/db/dao/contradictions.d.ts +98 -0
  12. package/lib/engine/mem/db/dao/documents.d.ts +51 -0
  13. package/lib/engine/mem/db/dao/entities.d.ts +67 -0
  14. package/lib/engine/mem/db/dao/facts.d.ts +472 -0
  15. package/lib/engine/mem/db/dao/stats.d.ts +14 -0
  16. package/lib/engine/mem/db/dao/triples.d.ts +80 -0
  17. package/lib/engine/mem/db/hash.d.ts +2 -0
  18. package/lib/engine/mem/db/knowledge.d.ts +17 -0
  19. package/lib/engine/mem/db/migrations.d.ts +71 -0
  20. package/lib/engine/mem/db/port.d.ts +57 -0
  21. package/lib/engine/mem/db/schema.d.ts +26 -0
  22. package/lib/engine/mem/db/sqlite.d.ts +89 -0
  23. package/lib/engine/mem/db/store.d.ts +35 -0
  24. package/lib/engine/mem/db/tokenizer.d.ts +61 -0
  25. package/lib/engine/mem/db/vectors.d.ts +50 -0
  26. package/lib/engine/mem/dispatch.d.ts +51 -0
  27. package/lib/engine/mem/entities/extract.d.ts +84 -0
  28. package/lib/engine/mem/eval/loader.d.ts +19 -0
  29. package/lib/engine/mem/eval/metrics.d.ts +25 -0
  30. package/lib/engine/mem/eval/runner.d.ts +10 -0
  31. package/lib/engine/mem/eval/write_metrics.d.ts +88 -0
  32. package/lib/engine/mem/git.d.ts +73 -0
  33. package/lib/engine/mem/hrr/algebra.d.ts +30 -0
  34. package/lib/engine/mem/hrr/atoms.d.ts +22 -0
  35. package/lib/engine/mem/hrr/encode.d.ts +32 -0
  36. package/lib/engine/mem/hrr/index.d.ts +3 -0
  37. package/lib/engine/mem/index.d.ts +27 -0
  38. package/lib/engine/mem/lifecycle/contradiction.d.ts +156 -0
  39. package/lib/engine/mem/lifecycle/index.d.ts +5 -0
  40. package/lib/engine/mem/lifecycle/maintenance.d.ts +48 -0
  41. package/lib/engine/mem/lifecycle/presence.d.ts +29 -0
  42. package/lib/engine/mem/lifecycle/tick.d.ts +43 -0
  43. package/lib/engine/mem/lifecycle/trust.d.ts +90 -0
  44. package/lib/engine/mem/modelBootstrap.d.ts +207 -0
  45. package/lib/engine/mem/router.d.ts +51 -0
  46. package/lib/engine/mem/runtime.d.ts +232 -0
  47. package/lib/engine/mem/store/common.d.ts +82 -0
  48. package/lib/engine/mem/store/doc_files.d.ts +125 -0
  49. package/lib/engine/mem/store/document_text.d.ts +47 -0
  50. package/lib/engine/mem/store/floors.d.ts +191 -0
  51. package/lib/engine/mem/store/hybrid.d.ts +232 -0
  52. package/lib/engine/mem/store/ingest_guard.d.ts +25 -0
  53. package/lib/engine/mem/store/knowledge.d.ts +483 -0
  54. package/lib/engine/mem/store/lexical.d.ts +67 -0
  55. package/lib/engine/mem/store/memory.d.ts +543 -0
  56. package/lib/engine/mem/store/source_picker.d.ts +43 -0
  57. package/lib/engine/mem-contract/config.d.ts +129 -0
  58. package/lib/engine/mem-contract/env.d.ts +31 -0
  59. package/lib/engine/mem-contract/family.d.ts +25 -0
  60. package/lib/engine/mem-contract/index.d.ts +8 -0
  61. package/lib/engine/mem-contract/log.d.ts +29 -0
  62. package/lib/engine/mem-contract/remote.d.ts +80 -0
  63. package/lib/engine/mem-contract/text.d.ts +92 -0
  64. package/lib/engine/mem-contract/tools.d.ts +284 -0
  65. package/lib/engine/mem-contract/types.d.ts +615 -0
  66. package/lib/engine/mem-retrieval/adapters/auto_vstore.d.ts +47 -0
  67. package/lib/engine/mem-retrieval/adapters/hnswlib.d.ts +174 -0
  68. package/lib/engine/mem-retrieval/adapters/local_bge.d.ts +121 -0
  69. package/lib/engine/mem-retrieval/adapters/local_numpy.d.ts +28 -0
  70. package/lib/engine/mem-retrieval/adapters/local_reranker.d.ts +52 -0
  71. package/lib/engine/mem-retrieval/adapters/none_reranker.d.ts +10 -0
  72. package/lib/engine/mem-retrieval/adapters/warm_gate.d.ts +46 -0
  73. package/lib/engine/mem-retrieval/budget.d.ts +30 -0
  74. package/lib/engine/mem-retrieval/fusion.d.ts +53 -0
  75. package/lib/engine/mem-retrieval/index.d.ts +14 -0
  76. package/lib/engine/mem-retrieval/interfaces.d.ts +82 -0
  77. package/lib/engine/mem-retrieval/log.d.ts +11 -0
  78. package/lib/engine/mem-retrieval/registry.d.ts +23 -0
  79. package/lib/engine/mem-retrieval/rerank.d.ts +13 -0
  80. package/lib/engine/mem-retrieval/stats.d.ts +64 -0
  81. package/lib/engine/mem-retrieval/text_budget.d.ts +52 -0
  82. package/lib/index.js +1773 -473
  83. package/lib/interface-version.json +2 -2
  84. package/lib/types/client/domains.d.ts +5 -4
  85. package/lib/types/client/domains.d.ts.map +1 -1
  86. package/lib/types/client/index.d.ts.map +1 -1
  87. package/lib/types/client/ingest.d.ts +1 -1
  88. package/lib/types/client/wire.d.ts +36 -0
  89. package/lib/types/client/wire.d.ts.map +1 -0
  90. package/lib/types/envinit-bootstrap.d.ts +3 -2
  91. package/lib/types/envinit.d.ts +2 -19
  92. package/lib/types/envinit.d.ts.map +1 -1
  93. package/lib/types/hints.d.ts +1 -1
  94. package/lib/types/index.d.ts +8 -3
  95. package/lib/types/index.d.ts.map +1 -1
  96. package/lib/types/open.d.ts +61 -11
  97. package/lib/types/open.d.ts.map +1 -1
  98. package/lib/types/prompt.d.ts +19 -1
  99. package/lib/types/prompt.d.ts.map +1 -1
  100. package/lib/types/provision.d.ts +1 -1
  101. package/lib/types/provision.d.ts.map +1 -1
  102. package/lib/types/reconcile.d.ts +64 -0
  103. package/lib/types/reconcile.d.ts.map +1 -0
  104. package/lib/types/remote.d.ts +53 -0
  105. package/lib/types/remote.d.ts.map +1 -1
  106. package/lib/types/render.d.ts +35 -0
  107. package/lib/types/render.d.ts.map +1 -0
  108. package/lib/types/tool_schema.d.ts +3 -13
  109. package/lib/types/tool_schema.d.ts.map +1 -1
  110. package/package.json +24 -15
  111. package/lib/client.js.map +0 -1
  112. package/lib/types/client/domains.js +0 -25
  113. package/lib/types/client/domains.js.map +0 -1
  114. package/lib/types/client/index.js +0 -1371
  115. package/lib/types/client/index.js.map +0 -1
  116. package/lib/types/client/ingest.js +0 -30
  117. package/lib/types/client/ingest.js.map +0 -1
  118. package/lib/types/client/paging.js +0 -36
  119. package/lib/types/client/paging.js.map +0 -1
  120. package/lib/types/client/summarize.js +0 -79
  121. package/lib/types/client/summarize.js.map +0 -1
  122. package/lib/types/data_home.js +0 -26
  123. package/lib/types/data_home.js.map +0 -1
  124. package/lib/types/envinit-bootstrap.js +0 -333
  125. package/lib/types/envinit.js +0 -402
  126. package/lib/types/envinit.js.map +0 -1
  127. package/lib/types/hints.js +0 -53
  128. package/lib/types/hints.js.map +0 -1
  129. package/lib/types/index.js +0 -755
  130. package/lib/types/index.js.map +0 -1
  131. package/lib/types/inject.js +0 -14
  132. package/lib/types/inject.js.map +0 -1
  133. package/lib/types/interface_gate.js +0 -84
  134. package/lib/types/interface_gate.js.map +0 -1
  135. package/lib/types/open.js +0 -133
  136. package/lib/types/open.js.map +0 -1
  137. package/lib/types/prompt.js +0 -148
  138. package/lib/types/prompt.js.map +0 -1
  139. package/lib/types/provision.js +0 -177
  140. package/lib/types/provision.js.map +0 -1
  141. package/lib/types/remote.js +0 -247
  142. package/lib/types/remote.js.map +0 -1
  143. package/lib/types/tool_schema.js +0 -149
  144. package/lib/types/tool_schema.js.map +0 -1
@@ -1,1371 +0,0 @@
1
- /**
2
- * @avantf/dsh-mem — DSH client half.
3
- *
4
- * Registers two `conversation.view` tabs (记忆 id=memory, 知识 id=knowledge) that sit
5
- * next to 对话/轨迹 in the session view strip and talk to the host through the
6
- * Typert Remote bridge `avantfMem`.
7
- *
8
- * Design choices worth keeping:
9
- *
10
- * 1. The plugin injects only `slots` + `remote`, never `remote.avantfMem`. Boot
11
- * audits every client entry and THROWS when one is still pending, so depending
12
- * on a namespace an out-of-tree package can only mount at runtime would take
13
- * the whole web tree down. The namespace is mounted in `apply` and read back
14
- * through `ctx.get()` — a dotted `ctx.remote.avantfMem` read is refused unless
15
- * that exact key is in this plugin's own `inject`, which cannot be satisfied.
16
- * 2. Every remote call is normalized and surfaced: the raw response is logged
17
- * (prefixed `[avantf-mem]`) and rendered as an explicit status/error line, so
18
- * an empty result is never indistinguishable from a failed call.
19
- * 3. Full DESIGN §12 surface: tab A does CRUD (edit/archive/restore/feedback) +
20
- * fact detail (entities/triples) + open contradictions; tab B does filtered
21
- * cross-store query (kind/domain/source) with results grouped by
22
- * `记忆 / domain → source`, plus KB management (ingest/import/list/detail/
23
- * remove/reindex). Generation stays external (Plan A) — no answer synthesis.
24
- *
25
- * Built as a DSH client bundle (`tsdown.config.ts` → `clientBundle`): a CJS
26
- * factory self-registering through `window.__ModuleLoader__.load`, with `react`
27
- * resolved from the platform module table.
28
- */
29
- import * as React from 'react';
30
- import css from './pages.module.css';
31
- import { clientContribution } from '../remote.js';
32
- // Subpath import on purpose: the client bundle must not pull in the whole
33
- // contract (tool unions + JSON-Schema derivation); this module has no deps.
34
- import { unwrapRemoteEnvelope, formatViolations } from '@avantf/mem-contract/remote';
35
- import { summarize } from './summarize.js';
36
- import { mergeDocs, mergePage } from './paging.js';
37
- import { checkNewDomain, domainOptions, domainPickerMode } from './domains.js';
38
- import { conflictMessage, ingestConflict } from './ingest.js';
39
- const h = React.createElement;
40
- /**
41
- * The host's answer for one source string, NORMALIZED at the boundary that receives it.
42
- *
43
- * The shape comes from the contract (no hand-mirror to drift), and the three list fields are read
44
- * through {@link normalizeClassification} rather than assumed: this object is dereferenced inside a
45
- * render body (`info.paths.length`, `info.missing.join`), and a field the host stopped sending would
46
- * throw there — which the shell renders as an empty panel with no retry for the session.
47
- */
48
- function normalizeClassification(value) {
49
- if (value === null || value === undefined)
50
- return null;
51
- const list = (candidate) => Array.isArray(candidate) ? candidate.filter((entry) => typeof entry === 'string') : [];
52
- return {
53
- kind: value.kind,
54
- paths: list(value.paths),
55
- missing: list(value.missing),
56
- reasons: list(value.reasons),
57
- files: typeof value.files === 'number' ? value.files : 0,
58
- };
59
- }
60
- const NOT_MOUNTED = 'avantfMem Remote 未挂载:本插件自带的 typert contribution 挂载失败(详见控制台错误),暂时无法读取数据。';
61
- /** One 记忆 tab; the host `admin` list default is the same. */
62
- const MEMORY_PAGE_SIZE = 50;
63
- /** How close (px) the list's end must come to the scrollport before the next page loads. */
64
- const AUTO_LOAD_MARGIN_PX = 300;
65
- /** One page of the 知识 tab's document list; the 记忆 tab pages at 50 for the same reason. */
66
- const DOC_PAGE_SIZE = 50;
67
- /**
68
- * Run one Remote call and normalize every shape it can take: the transport's
69
- * `{ok,value}` envelope wrapping the gateway's own `{ok,value}` /
70
- * `{ok,error,violations}` envelope, a bare value, or a thrown carrier failure.
71
- * Both layers are peeled (see `unwrapRemoteEnvelope`), so `value` is always the
72
- * payload and `error` is always the host's message. Failures are returned, never
73
- * swallowed.
74
- */
75
- async function callRemote(label, pending) {
76
- try {
77
- const result = await pending;
78
- console.log(`[avantf-mem] ${label} ->`, result);
79
- const decoded = unwrapRemoteEnvelope(result);
80
- if (decoded.ok)
81
- return { ok: true, value: decoded.value };
82
- const violations = formatViolations(decoded.violations);
83
- return { ok: false, error: violations ? `${decoded.error}(${violations})` : decoded.error };
84
- }
85
- catch (error) {
86
- console.error(`[avantf-mem] ${label} failed`, error);
87
- return { ok: false, error: error instanceof Error ? error.message : String(error) };
88
- }
89
- }
90
- /**
91
- * Response-ordering guard shared by every click-driven fetch (detail, doc detail,
92
- * docs list, contradictions — each used to hand-roll the same ++seq/compare dance):
93
- * a slow reply must never overwrite a newer one.
94
- */
95
- function useSeqGuard() {
96
- const ref = React.useRef(0);
97
- return React.useMemo(() => ({
98
- begin: () => ++ref.current,
99
- isCurrent: (seq) => seq === ref.current,
100
- }), []);
101
- }
102
- /** The element that actually scrolls this panel, walking up to it (the conversation's scroll body). */
103
- function scrollParentOf(node) {
104
- let current = node?.parentElement ?? null;
105
- while (current !== null) {
106
- const overflowY = getComputedStyle(current).overflowY;
107
- if ((overflowY === 'auto' || overflowY === 'scroll') && current.scrollHeight > current.clientHeight) {
108
- return current;
109
- }
110
- current = current.parentElement;
111
- }
112
- return null;
113
- }
114
- /**
115
- * Call `onLoadMore` when the user scrolls near the end of the panel.
116
- *
117
- * A scroll listener, not an `IntersectionObserver`, for the three reasons this seat makes an
118
- * observer the wrong tool:
119
- *
120
- * 1. **`rootMargin` cannot prefetch here.** The panel does not own a scrollport — the conversation's
121
- * `.scrollBody` does, and an ancestor's clip rect is NOT expanded by `rootMargin` (only the
122
- * intersection root's is). The promised look-ahead simply never happened.
123
- * 2. **An observer only reports threshold CHANGES.** One page that failed to load left the sentinel
124
- * visible, so no new callback arrived and auto-load stayed dead until something else moved it.
125
- * A scroll listener is level-triggered: the next scroll event tries again.
126
- * 3. **The scrollport is found, not assumed.** `scrollParentOf` walks up to whatever element really
127
- * scrolls, so this keeps working if the panel ever gets its own scroller.
128
- *
129
- * `version` re-checks after a page lands: a page that does not fill the scrollport produces no
130
- * scroll event, and without the re-check loading would stop after the first page.
131
- */
132
- function useLoadMoreOnScroll(rootRef, enabled, onLoadMore, version) {
133
- const latest = React.useRef(onLoadMore);
134
- latest.current = onLoadMore;
135
- const enabledRef = React.useRef(enabled);
136
- enabledRef.current = enabled;
137
- const checkRef = React.useRef(() => { });
138
- React.useEffect(() => {
139
- const root = rootRef.current;
140
- if (root === null)
141
- return undefined;
142
- const scroller = scrollParentOf(root);
143
- const target = scroller ?? window;
144
- let queued = false;
145
- const check = () => {
146
- queued = false;
147
- if (!enabledRef.current || scroller === null)
148
- return;
149
- if (scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - AUTO_LOAD_MARGIN_PX) {
150
- latest.current();
151
- }
152
- };
153
- checkRef.current = check;
154
- const onScroll = () => {
155
- if (queued)
156
- return;
157
- queued = true;
158
- if (typeof requestAnimationFrame === 'function')
159
- requestAnimationFrame(check);
160
- else
161
- check();
162
- };
163
- target.addEventListener('scroll', onScroll, { passive: true });
164
- check(); // the first page may leave the scrollport unfilled
165
- return () => {
166
- checkRef.current = () => { };
167
- target.removeEventListener('scroll', onScroll);
168
- };
169
- }, [rootRef]);
170
- React.useEffect(() => { checkRef.current(); }, [version, enabled]);
171
- }
172
- /**
173
- * The one mutation flow both panels share: run a Remote call, optionally refresh
174
- * FIRST (so the mutation's own notice/error survives a list reload that clears
175
- * the error line), then surface `完成`/`失败` with an optional value summary.
176
- */
177
- async function runRemoteAction(hooks, label, pending, opts) {
178
- hooks.setLoading(true);
179
- try {
180
- const outcome = await callRemote(label, pending);
181
- if (opts?.refreshFirst)
182
- await opts.refreshFirst();
183
- if (outcome.ok) {
184
- hooks.setNotice(`${label} 完成${opts?.summarize ? summarize(outcome.value) : ''}`);
185
- hooks.setError(undefined);
186
- opts?.after?.();
187
- }
188
- else {
189
- hooks.setNotice(undefined);
190
- hooks.setError(`${label} 失败:${outcome.error}`);
191
- }
192
- }
193
- finally {
194
- hooks.setLoading(false);
195
- }
196
- }
197
- /** The settings shell's search glyph, inlined so this bundle needs no shared icon package. */
198
- function SearchIcon() {
199
- return h('svg', { width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none', xmlns: 'http://www.w3.org/2000/svg', 'aria-hidden': true }, h('path', {
200
- d: 'M11.894845 6.647401C11.894845 3.725463 9.534486 1.356779 6.623219 1.35657C3.711786 1.35657 1.351635 3.725338 1.351635 6.647401C1.351843 9.569296 3.711911 11.938273 6.623219 11.938273C9.534361 11.938064 11.894637 9.569171 11.894845 6.647401ZM13.245462 6.647401C13.245254 10.317935 10.280401 13.293613 6.623219 13.293821C2.965871 13.293821 0.000204 10.31806 0 6.647401C0 2.976574 2.965746 0 6.623219 0C10.280526 0.000205 13.245462 2.9767 13.245462 6.647401Z',
201
- fill: 'currentColor',
202
- }), h('path', {
203
- d: 'M16.000417 15.041079L15.044449 16.000433L11.530434 12.473588L12.486298 11.514234L16.000417 15.041079Z',
204
- fill: 'currentColor',
205
- }));
206
- }
207
- function Btn(props) {
208
- return h('button', {
209
- type: 'button',
210
- className: props.danger ? `${css.btn} ${css.btnDanger}` : css.btn,
211
- disabled: props.disabled === true,
212
- onClick: props.onClick,
213
- }, props.label);
214
- }
215
- function Field(props) {
216
- return h('label', { className: css.field }, props.label, h('input', {
217
- className: css.input,
218
- type: 'text',
219
- value: props.value,
220
- placeholder: props.placeholder,
221
- autoFocus: props.autoFocus === true,
222
- onKeyDown: props.onKeyDown,
223
- onChange: (e) => { props.onChange(e.target.value); },
224
- }));
225
- }
226
- /**
227
- * The 知识域 control.
228
- *
229
- * With a configured `knowledge.domains` allowlist the host returns `restricted: true` and this is
230
- * a closed `<select>` over the allowlist ∪ the library's existing domains — a new domain is then a
231
- * config change, not a second spelling. An explicitly empty allowlist means "no restriction", so
232
- * the control stays a free input (the host's options still show what is already in use).
233
- *
234
- * The WRITE form passes `onAdd`, which adds a 「+」 on the field's own LABEL row, right-aligned with
235
- * the select: adding a domain is a user action (the agent stays bounded by the configured list), so
236
- * the picker must not force a hand-edit of YAML. It is deliberately absent while the allowlist is
237
- * `[]`: appending to an unfettered list would silently turn "no restriction" into a restricted one.
238
- *
239
- * The control carries NO explanatory sentence: the field sits in a row of sibling filters, and one
240
- * extra line under the select made that row taller than its neighbours. The 「+」 says what it does.
241
- */
242
- function DomainField(props) {
243
- const [adding, setAdding] = React.useState(false);
244
- const [draft, setDraft] = React.useState('');
245
- const [addError, setAddError] = React.useState();
246
- const [addBusy, setAddBusy] = React.useState(false);
247
- if (domainPickerMode(props.catalog) === 'input') {
248
- return h(Field, { label: props.label, value: props.value, onChange: props.onChange, placeholder: props.placeholder });
249
- }
250
- const domains = props.catalog?.domains ?? [];
251
- const closeAdd = () => { setAdding(false); setDraft(''); setAddError(undefined); };
252
- const submitAdd = () => {
253
- const check = checkNewDomain(draft, domains);
254
- if (check.kind === 'empty') {
255
- setAddError('知识域不能为空');
256
- return;
257
- }
258
- if (check.kind === 'invalid') {
259
- setAddError(check.reason);
260
- return;
261
- }
262
- // Already offered by the picker: select it, write nothing (a duplicate entry helps no one).
263
- if (check.kind === 'existing') {
264
- props.onChange(check.name);
265
- closeAdd();
266
- return;
267
- }
268
- const onAdd = props.onAdd;
269
- if (onAdd === undefined)
270
- return;
271
- setAddBusy(true);
272
- void onAdd(check.name).then((addFailure) => {
273
- setAddBusy(false);
274
- if (addFailure !== undefined) {
275
- setAddError(addFailure);
276
- return;
277
- }
278
- props.onChange(check.name);
279
- closeAdd();
280
- });
281
- };
282
- // The 「+」 rides the LABEL row and is right-aligned with the select under it: it acts on this one
283
- // field, whereas as a sibling of the field it read as another toolbar item and wrapped onto a line
284
- // of its own. While the draft is open the row keeps just the name — the 「+」 has become the form
285
- // below it.
286
- const addButton = props.onAdd === undefined || adding
287
- ? null
288
- : h(Btn, { label: '+ 新增领域', onClick: () => { setAdding(true); } });
289
- return h(React.Fragment, null, h('label', { className: css.field, key: 'domain' }, h('span', { className: css.fieldHead }, h('span', null, props.label), addButton), h('select', {
290
- className: css.input,
291
- value: props.value,
292
- onChange: (e) => { props.onChange(e.target.value); },
293
- }, domainOptions(props.catalog, props.emptyLabel).map(option => h('option', { key: option.value, value: option.value }, option.label)))), props.onAdd === undefined || !adding ? null : [
294
- h(Field, {
295
- key: 'add-input',
296
- label: '新知识域(domain)',
297
- value: draft,
298
- onChange: (value) => { setDraft(value); setAddError(undefined); },
299
- placeholder: '如 legal',
300
- autoFocus: true,
301
- onKeyDown: (e) => {
302
- if (e.key === 'Enter') {
303
- e.preventDefault();
304
- submitAdd();
305
- }
306
- else if (e.key === 'Escape') {
307
- e.preventDefault();
308
- closeAdd();
309
- }
310
- },
311
- }),
312
- h(Btn, { key: 'add-ok', label: addBusy ? '新增中…' : '确定', disabled: addBusy, onClick: submitAdd }),
313
- h(Btn, { key: 'add-cancel', label: '取消', onClick: closeAdd }),
314
- addError === undefined ? null : h('span', { key: 'add-error', className: css.error }, addError),
315
- ]);
316
- }
317
- // ─── tab A: 记忆 ──────────────────────────────────────────────────────────
318
- function MemoryPanel(props) {
319
- const [statusFilter, setStatusFilter] = React.useState('active');
320
- const [rows, setRows] = React.useState([]);
321
- const [total, setTotal] = React.useState();
322
- const [truncated, setTruncated] = React.useState(false);
323
- /**
324
- * How many rows the HOST has handed over — the pagination anchor. It is not `rows.length`:
325
- * appends are de-duplicated (a row inserted between two page requests shifts the window and
326
- * repeats one row), and using the de-duplicated length as the next offset would re-request it.
327
- */
328
- const [nextOffset, setNextOffset] = React.useState(0);
329
- const [error, setError] = React.useState();
330
- const [notice, setNotice] = React.useState();
331
- const [loading, setLoading] = React.useState(props.remote !== undefined);
332
- const [editing, setEditing] = React.useState(null);
333
- const [detail, setDetail] = React.useState(null);
334
- const [contradictions, setContradictions] = React.useState(null);
335
- const [health, setHealth] = React.useState(null);
336
- // Response-ordering guards: a slow reply must never overwrite a newer one.
337
- const detailGuard = useSeqGuard();
338
- const contradictionGuard = useSeqGuard();
339
- const healthGuard = useSeqGuard();
340
- // The list needs one too, and more than the others: the scroll observer calls `load` without any
341
- // user action, so a page requested for 活动 can land after the user switched to 归档 — appending
342
- // active rows to the archived list and overwriting its total with the active one's.
343
- const listGuard = useSeqGuard();
344
- /**
345
- * Load one page and append it (offset 0 replaces the list). Resolves once the
346
- * page settled, so callers can order their own state updates after the refresh.
347
- */
348
- const load = React.useCallback(async (offset, status) => {
349
- if (props.remote === undefined)
350
- return;
351
- const seq = listGuard.begin();
352
- setLoading(true);
353
- // Only a REFRESH clears the error line. An append is triggered by scrolling, and clearing it
354
- // there made a mutation's failure message disappear the moment the user scrolled — the
355
- // mutation path documents the opposite intent (see `mutate`'s refreshFirst).
356
- if (offset === 0)
357
- setError(undefined);
358
- const outcome = await callRemote(`admin.list(status=${status}, offset=${String(offset)})`, props.remote.admin({ action: 'list', status, limit: MEMORY_PAGE_SIZE, offset }));
359
- if (!listGuard.isCurrent(seq))
360
- return; // a newer page (or a status switch) already won
361
- if (outcome.ok) {
362
- // `?.` / `?? []` / `=== true` stay defensive on purpose: the contract type describes the
363
- // CURRENT host, while the wire can be an older one (or a gateway that hands back a bare
364
- // payload). Naming the shape must not turn a tolerated payload into a thrown render.
365
- const page = outcome.value?.facts ?? [];
366
- setRows(previous => (offset === 0 ? page : mergePage(previous, page)));
367
- setNextOffset(offset + page.length);
368
- setTotal(outcome.value?.total);
369
- setTruncated(outcome.value?.truncated === true);
370
- }
371
- else {
372
- if (offset === 0) {
373
- setRows([]);
374
- setNextOffset(0);
375
- setTotal(undefined);
376
- setTruncated(false); // never leave "加载更多" over an emptied list
377
- }
378
- setError(outcome.error);
379
- }
380
- setLoading(false);
381
- }, [listGuard, props.remote]);
382
- React.useEffect(() => { void load(0, statusFilter); }, [load, statusFilter]);
383
- /** Switch the status tab: clear the other tab's page immediately, and drop its in-flight page. */
384
- const switchStatus = (next) => {
385
- if (next === statusFilter)
386
- return;
387
- listGuard.begin(); // the page already in flight belongs to the tab being left
388
- setRows([]);
389
- setNextOffset(0);
390
- setTotal(undefined);
391
- setTruncated(false);
392
- setDetail(null);
393
- setEditing(null);
394
- // Closing the conflict table must also drop a reply that is still in flight, or that reply
395
- // re-opens it (see `fetchContradictions`).
396
- contradictionGuard.begin();
397
- setContradictions(null);
398
- setStatusFilter(next);
399
- };
400
- /** Run one mutation, refresh page 0, then surface the mutation's own outcome. */
401
- const mutate = React.useCallback((label, call, opts) => {
402
- const remote = props.remote;
403
- if (remote === undefined)
404
- return;
405
- // refreshFirst: `load` clears the error line at its start, so the mutation's
406
- // own error/notice survives (it used to be erased by the refresh's success path).
407
- void runRemoteAction({ setLoading, setNotice, setError }, label, call(remote), {
408
- summarize: opts?.summarize,
409
- refreshFirst: () => load(0, statusFilter),
410
- // `after` runs once the refresh settled — a second view that the mutation also changed
411
- // (e.g. the conflict list after a verdict) refreshes here instead of racing the first.
412
- after: opts?.after,
413
- });
414
- }, [props.remote, load, statusFilter]);
415
- const showDetail = (factId) => {
416
- if (props.remote === undefined)
417
- return;
418
- if (detail?.id === factId) {
419
- setDetail(null);
420
- return;
421
- }
422
- const seq = detailGuard.begin();
423
- void callRemote(`admin.detail(${String(factId)})`, props.remote.admin({ action: 'detail', fact_id: factId }))
424
- .then((outcome) => {
425
- if (!detailGuard.isCurrent(seq))
426
- return; // a newer click already answered
427
- if (!outcome.ok) {
428
- setError(outcome.error);
429
- return;
430
- }
431
- // `admin.detail` reports a missing fact as `{error}` (a successful call with an error
432
- // body) — render the error, not an empty panel. Narrowing on `fact_id` is what makes the
433
- // cast unnecessary: the error body has no such field, so the payload is typed afterwards.
434
- const value = outcome.value;
435
- if (value === null || typeof value !== 'object' || !('fact_id' in value)) {
436
- const reason = 'error' in value && typeof value.error === 'string' ? value.error : '详情为空';
437
- setError(`详情读取失败:${reason}`);
438
- return;
439
- }
440
- setDetail({ id: factId, data: value });
441
- });
442
- };
443
- /** Load the open conflicts. Separate from the toggle so a verdict can re-read the list. */
444
- const fetchContradictions = React.useCallback(() => {
445
- const remote = props.remote;
446
- if (remote === undefined)
447
- return;
448
- const seq = contradictionGuard.begin();
449
- void callRemote('recall.contradict', remote.recall({ action: 'contradict', limit: 20 }))
450
- .then((outcome) => {
451
- if (!contradictionGuard.isCurrent(seq))
452
- return;
453
- if (outcome.ok) {
454
- setContradictions(Array.isArray(outcome.value) ? outcome.value : []);
455
- setError(undefined);
456
- }
457
- else
458
- setError(outcome.error);
459
- });
460
- }, [props.remote, contradictionGuard]);
461
- const loadContradictions = () => {
462
- if (props.remote === undefined)
463
- return;
464
- if (contradictions !== null) {
465
- // Closing must also invalidate a reply already in flight: `useSeqGuard` only advances on
466
- // `begin()`, so without this a slow `recall.contradict` re-opens the table the user just closed.
467
- contradictionGuard.begin();
468
- setContradictions(null);
469
- return;
470
- }
471
- fetchContradictions();
472
- };
473
- /**
474
- * Adjudicate one conflict from the page.
475
- *
476
- * Naming a loser ARCHIVES that fact (it leaves the live corpus), so those two buttons are
477
- * marked dangerous while `误报` — which changes no fact — is not. `after` re-reads the list:
478
- * the row is gone from it, and without the refresh the table would still show a closed pair.
479
- */
480
- const resolveConflict = (row, resolution, loserFactId) => {
481
- mutate(resolution === 'false_positive'
482
- ? `标记矛盾 #${String(row.contradiction_id)} 为误报`
483
- : `裁决矛盾 #${String(row.contradiction_id)}(归档 #${String(loserFactId)})`, remote => remote.admin({
484
- action: 'contradict_resolve',
485
- contradiction_id: row.contradiction_id,
486
- resolution,
487
- loser_fact_id: loserFactId,
488
- }), { after: fetchContradictions });
489
- };
490
- const loadHealth = () => {
491
- if (props.remote === undefined)
492
- return;
493
- if (health !== null) {
494
- healthGuard.begin(); // same as the conflict table: a late stats reply must not reopen it
495
- setHealth(null);
496
- return;
497
- }
498
- const seq = healthGuard.begin();
499
- void callRemote('admin.stats', props.remote.admin({ action: 'stats' }))
500
- .then((outcome) => {
501
- if (!healthGuard.isCurrent(seq))
502
- return;
503
- if (outcome.ok) {
504
- setHealth(outcome.value);
505
- setError(undefined);
506
- }
507
- else
508
- setError(outcome.error);
509
- });
510
- };
511
- const shown = rows.length;
512
- const summary = total === undefined
513
- ? `已显示 ${String(shown)} 条`
514
- : `共 ${String(total)} 条${statusFilter === 'active' ? '活动' : '归档'}事实,已显示 ${String(shown)} 条`
515
- + (truncated ? '(继续滚动自动加载)' : '');
516
- /** Bring the panel's top into view — used when a block opens below the sticky bar. */
517
- const rootRef = React.useRef(null);
518
- const reveal = () => { rootRef.current?.scrollIntoView({ block: 'start' }); };
519
- // Mounting inside a scrollport that kept its offset (the seat swaps only the view child) would
520
- // drop the panel in at whatever depth the previous view was scrolled to — with the sticky bar's
521
- // controls and the top of the list already above the fold. Start at the panel's own top.
522
- React.useEffect(() => { reveal(); }, []);
523
- /** Load the next page once the list's end is within reach of the scrollport. */
524
- const maybeLoadMore = () => {
525
- if (!truncated || loading || props.remote === undefined)
526
- return;
527
- void load(nextOffset, statusFilter);
528
- };
529
- useLoadMoreOnScroll(rootRef, truncated && !loading, maybeLoadMore, rows.length);
530
- // Mounting inside a scrollport that kept its offset (the seat swaps only the view child) would
531
- // drop the panel in at whatever depth the previous view was scrolled to — with the sticky bar's
532
- return h('div', { className: css.section, ref: rootRef }, props.remote === undefined ? h('div', { className: css.error }, NOT_MOUNTED) : null, props.remote === undefined ? null : h('div', { className: css.headbar }, h('button', {
533
- type: 'button',
534
- className: statusFilter === 'active' ? `${css.btn} ${css.btnActive}` : css.btn,
535
- onClick: () => { switchStatus('active'); },
536
- }, '活动'), h('button', {
537
- type: 'button',
538
- className: statusFilter === 'archived' ? `${css.btn} ${css.btnActive}` : css.btn,
539
- onClick: () => { switchStatus('archived'); },
540
- }, '归档'), h(Btn, {
541
- label: '立即维护',
542
- disabled: loading,
543
- onClick: () => { mutate('生命周期维护', remote => remote.admin({ action: 'maintenance' })); },
544
- }),
545
- // These two used to sit BELOW the list, so with a long list they were only
546
- // reachable after scrolling to the end. They belong in the sticky bar with the
547
- // rest of the controls, and opening one scrolls the panel back to the top so
548
- // the block that just appeared is in view instead of above the scrollport.
549
- h(Btn, {
550
- label: contradictions === null ? '查看未处理矛盾' : '收起矛盾',
551
- disabled: loading,
552
- onClick: () => { const opening = contradictions === null; loadContradictions(); if (opening)
553
- reveal(); },
554
- }), h(Btn, {
555
- label: health === null ? '查看检索健康度' : '收起检索健康度',
556
- disabled: loading,
557
- onClick: () => { const opening = health === null; loadHealth(); if (opening)
558
- reveal(); },
559
- }), h('div', { className: css.status }, loading && shown === 0 ? '读取中…' : summary)), error !== undefined ? h('div', { className: css.error }, `错误:${error}`) : null, notice !== undefined ? h('div', { className: css.status }, notice) : null, props.remote !== undefined && !loading && error === undefined && shown === 0
560
- ? h('div', { className: css.status }, statusFilter === 'active'
561
- ? '当前没有活动的事实。可在对话里让 agent 用 mem_remember 写入。'
562
- : '当前没有归档的事实。')
563
- : null,
564
- // Opened blocks render ABOVE the list, directly under the sticky bar: they are
565
- // read right after the click, not after scrolling past every fact.
566
- health !== null ? h(HealthBlock, { stats: health }) : null, contradictions !== null
567
- ? contradictions.length === 0
568
- ? h('div', { className: css.status }, '没有未处理的矛盾。')
569
- : h('ul', { className: css.list }, contradictions.map(c => h('li', { key: c.contradiction_id, className: css.item }, `#${String(c.fact_a)}「${c.content_a}」 ↔ #${String(c.fact_b)}「${c.content_b}」`, h('span', { className: css.meta }, `score ${c.score.toFixed(2)} · ${c.detected_at ?? ''}`), props.remote !== undefined
570
- ? h('div', { className: css.row }, h(Btn, { label: `判 #${String(c.fact_a)} 错`, danger: true, disabled: loading, onClick: () => resolveConflict(c, 'true_positive', c.fact_a) }), h(Btn, { label: `判 #${String(c.fact_b)} 错`, danger: true, disabled: loading, onClick: () => resolveConflict(c, 'true_positive', c.fact_b) }), h(Btn, { label: '误报(两条都对)', disabled: loading, onClick: () => resolveConflict(c, 'false_positive') }))
571
- : null)))
572
- : null, h('ul', { className: css.list }, rows.map(f => h('li', { key: f.fact_id, className: css.item }, editing?.id === f.fact_id
573
- ? h('textarea', {
574
- className: css.textarea,
575
- value: editing.text,
576
- rows: 3,
577
- onChange: (e) => { setEditing({ id: f.fact_id, text: e.target.value }); },
578
- })
579
- : f.content, h('span', { className: css.meta }, `#${String(f.fact_id)} · ${f.category} · trust ${f.trust_score.toFixed(2)}`
580
- + (f.pinned === true
581
- ? ' · 永久'
582
- : typeof f.remaining_days === 'number' ? ` · 剩余 ≈${String(Math.ceil(f.remaining_days))} 活跃日` : '')
583
- + ` · 有用 ${String(f.helpful_count)} · ${f.created_at}`
584
- + (f.status === 'archived' && f.archive_reason ? ` · 归档原因 ${f.archive_reason}` : '')
585
- // R12 limbo, stated honestly: a permanent fact archived by an explicit TTL is
586
- // never purged, so it leaves the active set but stays in the store forever.
587
- + (f.status === 'archived' && f.pinned === true ? ' · 永久保留(不会被清理)' : '')), h('div', { className: css.row }, editing?.id === f.fact_id
588
- ? [
589
- h(Btn, {
590
- key: 'save',
591
- label: '保存',
592
- disabled: loading || editing.text.trim().length === 0,
593
- onClick: () => {
594
- const text = editing.text.trim();
595
- setEditing(null);
596
- // summarize: an edit is the one page-side write that can be detected as a
597
- // CONFLICT at write time, and the notice is where the page can say so.
598
- mutate(`更新 #${String(f.fact_id)}`, remote => remote.remember({ action: 'update', fact_id: f.fact_id, content: text }), { summarize: true });
599
- },
600
- }),
601
- h(Btn, { key: 'cancel', label: '取消', disabled: loading, onClick: () => { setEditing(null); } }),
602
- ]
603
- : [
604
- h(Btn, { key: 'detail', label: detail?.id === f.fact_id ? '收起详情' : '详情', disabled: loading, onClick: () => { showDetail(f.fact_id); } }),
605
- statusFilter === 'active'
606
- ? h(Btn, { key: 'edit', label: '编辑', disabled: loading, onClick: () => { setEditing({ id: f.fact_id, text: f.content }); setDetail(null); } })
607
- : null,
608
- statusFilter === 'active'
609
- ? h(Btn, { key: 'helpful', label: '有用 +1', disabled: loading, onClick: () => { mutate(`反馈有用 #${String(f.fact_id)}`, remote => remote.remember({ action: 'helpful', fact_id: f.fact_id })); } })
610
- : null,
611
- statusFilter === 'active'
612
- ? h(Btn, { key: 'unhelpful', label: '没用 −1', disabled: loading, onClick: () => { mutate(`反馈没用 #${String(f.fact_id)}`, remote => remote.remember({ action: 'unhelpful', fact_id: f.fact_id })); } })
613
- : null,
614
- statusFilter === 'active'
615
- ? h(Btn, { key: 'archive', label: '归档', disabled: loading, onClick: () => { mutate(`归档 #${String(f.fact_id)}`, remote => remote.admin({ action: 'archive', fact_id: f.fact_id, reason: 'ui' })); } })
616
- : h(Btn, { key: 'restore', label: '恢复', disabled: loading, onClick: () => { mutate(`恢复 #${String(f.fact_id)}`, remote => remote.admin({ action: 'restore', fact_id: f.fact_id })); } }),
617
- statusFilter === 'active'
618
- ? (f.pinned === true
619
- ? h(Btn, { key: 'unpin', label: '解除永久', disabled: loading, onClick: () => { mutate(`解除永久 #${String(f.fact_id)}`, remote => remote.admin({ action: 'unpin', fact_id: f.fact_id })); } })
620
- : h(Btn, { key: 'pin', label: '永久记忆', disabled: loading, onClick: () => { mutate(`固化永久 #${String(f.fact_id)}`, remote => remote.admin({ action: 'pin', fact_id: f.fact_id })); } }))
621
- : null,
622
- ]), detail?.id === f.fact_id
623
- ? h('div', { className: css.meta },
624
- // 列表行只显示 created_at(第一次记录的时间);详情才并列两个时间,避免把
625
- // 「这一行被改过」误读成「内容更新」。
626
- `创建于 ${detail.data.created_at} / 更新于 ${detail.data.updated_at ?? '—'}`, h('br'), `实体:${(detail.data.entities ?? []).join('、') || '(无)'}`, h('br'), `三元组:${(detail.data.triples ?? []).map(t => `${t.subj} —${t.pred}→ ${t.obj}(${t.confidence.toFixed(2)})`).join(';') || '(无)'}`, h('br'), `检索次数:${String(detail.data.retrieval_count ?? 0)}${detail.data.supersedes_id ? ` · 修订自 #${String(detail.data.supersedes_id)}` : ''}`)
627
- : null))), truncated
628
- ? h('button', {
629
- type: 'button',
630
- className: css.more,
631
- disabled: loading,
632
- onClick: () => { load(nextOffset, statusFilter); },
633
- }, loading
634
- ? '加载中…'
635
- : total === undefined
636
- ? '加载更多'
637
- : `加载更多(还有 ${String(Math.max(total - shown, 0))} 条)`)
638
- : null);
639
- }
640
- /**
641
- * Retrieval health (DESIGN §20): the numbers that say whether retrieval is actually working —
642
- * how often it is degraded, how often it returns nothing, and whether a budget has been cutting
643
- * text. They live in memory and are flushed to `avantf_stats`, so they survive a restart.
644
- */
645
- function HealthBlock(props) {
646
- const r = props.stats.retrieval;
647
- if (r === undefined)
648
- return h('div', { className: css.status }, '检索健康度不可用。');
649
- const pct = (v) => `${String(Math.round(v * 100))}%`;
650
- const kinds = Object.entries(r.by_kind)
651
- .map(([kind, v]) => `${kind} ${String(v.queries)} 次(空 ${String(v.zero_results)})`)
652
- .join(' · ');
653
- const truncated = r.embedding_truncated + r.rerank_truncated + r.output_truncated;
654
- return h('div', { className: css.detail }, h('div', { className: css.status }, `检索 ${String(r.queries)} 次 · 空结果 ${pct(r.zero_result_rate)} · 平均 ${r.avg_latency_ms.toFixed(1)}ms · 峰值 ${r.max_latency_ms.toFixed(1)}ms`), h('div', { className: css.meta }, `语义腿在线 ${pct(r.semantic_live_rate)} · 平均命中 ${String(r.avg_results_per_query)} 条${r.rerank_used > 0 ? ` · 重排 ${String(r.rerank_used)} 次` : ''}`), kinds === '' ? null : h('div', { className: css.meta }, kinds), h('div', { className: css.meta }, truncated === 0
655
- ? '未被任何预算截断。'
656
- : `截断:嵌入 ${String(r.embedding_truncated)} · 重排 ${String(r.rerank_truncated)} · 输出 ${String(r.output_truncated)}${r.output_truncated > 0 ? '(调大 retrieval.max_output_tokens 或用 max_tokens 参数)' : ''}`), r.updated_at === null ? null : h('div', { className: css.meta }, `更新于 ${r.updated_at}`));
657
- }
658
- // ─── tab B: 知识 + KB 管理 ─────────────────────────────────────────────────
659
- /**
660
- * Group cross-store hits for display: memory facts vs `domain → source`.
661
- *
662
- * Reads the host's `domain`/`source` fields. Splitting `source_ref` here (the old
663
- * `parts[0] → parts[1]`) mis-grouped any document whose domain or source name
664
- * contains `:` — the host cannot encode the pair unambiguously either way.
665
- */
666
- function groupKey(hit) {
667
- if (hit.kind === 'fact')
668
- return '记忆事实';
669
- const domain = hit.domain ?? '';
670
- const source = hit.source ?? '';
671
- if (domain !== '' && source !== '')
672
- return `${domain} → ${source}`;
673
- return domain || source || '文档切片';
674
- }
675
- function KnowledgePanel(props) {
676
- // The document list is the page body; 查询 is a panel opened on demand, so nothing
677
- // here queries the store until the user asks for it.
678
- const [showQuery, setShowQuery] = React.useState(false);
679
- const [query, setQuery] = React.useState('');
680
- const [kind, setKind] = React.useState('all');
681
- const [domain, setDomain] = React.useState('');
682
- const [source, setSource] = React.useState('');
683
- const [hits, setHits] = React.useState([]);
684
- const [queryStatus, setQueryStatus] = React.useState('输入关键词后按回车检索');
685
- const [error, setError] = React.useState();
686
- const [notice, setNotice] = React.useState();
687
- const [loading, setLoading] = React.useState(false);
688
- /** The 查询 call's own in-flight flag — `loading` belongs to the KB mutations. */
689
- const [queryLoading, setQueryLoading] = React.useState(false);
690
- // KB state — the document list is what the tab shows on open.
691
- const [docs, setDocs] = React.useState(null);
692
- const [docDetail, setDocumentDetail] = React.useState(null);
693
- /**
694
- * The managed files as the host last saw them (`kb sync --dry-run`): which documents' `.md` was
695
- * edited since it was ingested, which have no file at all, and which files no document claims.
696
- * Read together with the list — there is deliberately no file watcher (changes are pulled in on
697
- * demand), so these flags are what tells the user a 「重新摄入」 is due.
698
- */
699
- const [fileState, setFileState] = React.useState(null);
700
- /** Pagination anchor for the document list (rows the host has handed over) + "there may be more". */
701
- const [docsOffset, setDocsOffset] = React.useState(0);
702
- const [docsTruncated, setDocsTruncated] = React.useState(false);
703
- const [showIngest, setShowIngest] = React.useState(false);
704
- const [iDomain, setIDomain] = React.useState('');
705
- const [iSource, setISource] = React.useState('');
706
- const [iTitle, setITitle] = React.useState('');
707
- const [iText, setIText] = React.useState('');
708
- /**
709
- * The 知识域 options the host reported: the configured allowlist ∪ the domains already in the
710
- * library, plus whether the allowlist is active. `null` = not fetched yet (or the call failed),
711
- * which falls back to the free input rather than blocking the form.
712
- */
713
- const [domainCatalog, setDomainCatalog] = React.useState(null);
714
- /**
715
- * ONE source input (URL / local file / local directory / pasted text). What it IS can only be
716
- * answered by the host, so `sourceInfo` holds its classification and the hint line shows what
717
- * 入库 will do before it is pressed.
718
- */
719
- const [iUri, setIUri] = React.useState('');
720
- const [sourceInfo, setSourceInfo] = React.useState(null);
721
- /**
722
- * Which of the two inputs the form is showing. They are alternatives, not a sequence: document
723
- * identity is `(domain, source, title)`, so ingesting a file AND pasted text under one title would
724
- * make the second call REPLACE the first. Rather than pick one silently (or lose a document),
725
- * only one input exists at a time; the other keeps its value and is marked 有内容 on the toggle.
726
- */
727
- const [inputMode, setInputMode] = React.useState('source');
728
- /** The 选择 picker: `null` closed, otherwise the current listing (or an error to show). */
729
- const [picker, setPicker] = React.useState(null);
730
- const docsGuard = useSeqGuard();
731
- const docDetailGuard = useSeqGuard();
732
- // The query had no guard while `docs`/`detail` did: two Enters issued two `remote.query` calls,
733
- // both wrote `hits`, and whichever settled FIRST cleared the shared `loading` — enabling the KB
734
- // buttons while a query was still in flight. Guard + its own flag.
735
- const queryGuard = useSeqGuard();
736
- const run = () => {
737
- const remote = props.remote;
738
- if (remote === undefined)
739
- return;
740
- const text = query.trim();
741
- if (text.length === 0) {
742
- setQueryStatus('请输入查询关键词');
743
- setHits([]);
744
- setError(undefined);
745
- return;
746
- }
747
- const seq = queryGuard.begin();
748
- setQueryLoading(true);
749
- void callRemote('query', remote.query({
750
- query: text,
751
- kind,
752
- domain: domain.trim() || undefined,
753
- source: source.trim() || undefined,
754
- limit: 10,
755
- }))
756
- .then((outcome) => {
757
- if (!queryGuard.isCurrent(seq))
758
- return; // a newer Enter already owns the panel
759
- if (outcome.ok) {
760
- const found = outcome.value?.hits ?? [];
761
- setHits(found);
762
- const degraded = outcome.value?.degraded === true ? '(语义降级,仅 FTS+实体)' : '';
763
- setQueryStatus(`命中 ${String(found.length)} 条${degraded}`);
764
- setError(undefined);
765
- }
766
- else {
767
- setHits([]);
768
- setQueryStatus('查询失败');
769
- setError(outcome.error);
770
- }
771
- })
772
- .finally(() => { if (queryGuard.isCurrent(seq))
773
- setQueryLoading(false); });
774
- };
775
- const kbCall = (label, args, after) => {
776
- const remote = props.remote;
777
- if (remote === undefined)
778
- return;
779
- void runRemoteAction({ setLoading, setNotice, setError }, label, remote.kb(args), { summarize: true, after });
780
- };
781
- const loadDocs = React.useCallback((offset) => {
782
- const remote = props.remote;
783
- if (remote === undefined)
784
- return;
785
- const seq = docsGuard.begin();
786
- // No domain/source here on purpose: the tab's body is the WHOLE knowledge base, while
787
- // those two fields belong to 查询 (and scope 重建索引). Feeding them in would also make
788
- // this effect re-fire on every keystroke of the query panel's filter inputs.
789
- void callRemote('kb.list', remote.kb({ action: 'list', limit: DOC_PAGE_SIZE, offset }))
790
- .then((outcome) => {
791
- if (!docsGuard.isCurrent(seq))
792
- return;
793
- if (outcome.ok) {
794
- const page = Array.isArray(outcome.value) ? outcome.value : [];
795
- setDocs(previous => (offset === 0 || previous === null ? page : mergeDocs(previous, page)));
796
- setDocsOffset(offset + page.length);
797
- // The payload is a bare array, so "there may be more" is "the page was full" — one extra
798
- // request at the end, in exchange for not changing a shape the CLI and MCP surfaces read.
799
- setDocsTruncated(page.length === DOC_PAGE_SIZE);
800
- setError(undefined);
801
- }
802
- else {
803
- setError(outcome.error);
804
- }
805
- });
806
- }, [props.remote]);
807
- // The list IS the page: read it on mount (and whenever the remote mounts). No
808
- // "展开知识库" gate — the button that used to open this list is gone, so switching to
809
- // the tab shows documents immediately. Mutations refresh it explicitly after they land.
810
- React.useEffect(() => { loadDocs(0); }, [loadDocs]);
811
- /** Re-read each document's managed file state (`kb sync --dry-run` — never re-ingests). */
812
- const loadFiles = React.useCallback(() => {
813
- const remote = props.remote;
814
- if (remote === undefined)
815
- return;
816
- void callRemote('kb.sync(dry_run)', remote.kb({ action: 'sync', dry_run: true }))
817
- .then((outcome) => {
818
- if (outcome.ok) {
819
- setFileState({
820
- stale: outcome.value?.stale ?? [],
821
- missing: outcome.value?.missing ?? [],
822
- unclaimed: outcome.value?.unclaimed ?? [],
823
- orphans: outcome.value?.orphans ?? [],
824
- });
825
- }
826
- else {
827
- setError(outcome.error);
828
- }
829
- });
830
- }, [props.remote]);
831
- React.useEffect(() => { loadFiles(); }, [loadFiles]);
832
- /** The domain picker's options: fetched once on mount and again whenever the library changes. */
833
- const loadDomains = React.useCallback(() => {
834
- const remote = props.remote;
835
- if (remote === undefined)
836
- return;
837
- void callRemote('kb.domains', remote.kbDomains({}))
838
- .then((outcome) => {
839
- if (outcome.ok && outcome.value)
840
- setDomainCatalog(outcome.value);
841
- else if (!outcome.ok)
842
- setError(outcome.error);
843
- });
844
- }, [props.remote]);
845
- React.useEffect(() => { loadDomains(); }, [loadDomains]);
846
- /**
847
- * The write form's 「+」: append one domain to the store config through the UI-only remote
848
- * method, then fold the returned catalog into state (the host is the source of truth for the
849
- * order). Returns the error text for the inline field; `undefined` means it landed and the
850
- * control selects it.
851
- */
852
- const addDomain = (domain) => {
853
- const remote = props.remote;
854
- if (remote === undefined)
855
- return Promise.resolve('未挂载 avantfMem Remote');
856
- return callRemote('kb.addDomain', remote.kbAddDomain({ domain })).then((outcome) => {
857
- if (!outcome.ok)
858
- return outcome.error;
859
- if (outcome.value)
860
- setDomainCatalog(outcome.value);
861
- setNotice(`已新增知识域「${domain}」并写入知识库配置`);
862
- setError(undefined);
863
- return undefined;
864
- });
865
- };
866
- /** The list and the file flags always move together — one refresh for both. */
867
- const refresh = React.useCallback(() => { loadDocs(0); loadFiles(); loadDomains(); }, [loadDocs, loadFiles, loadDomains]);
868
- /**
869
- * Open one document's managed file (or the directory holding it) in the user's editor.
870
- * The host resolves the path from `doc_id`; the client only says which of the two.
871
- */
872
- const openDoc = (docId, target) => {
873
- const remote = props.remote;
874
- if (remote === undefined)
875
- return;
876
- void callRemote('kb.openDoc', remote.openDoc({ doc_id: docId, target }))
877
- .then((outcome) => {
878
- if (outcome.ok) {
879
- const opener = outcome.value?.opener ?? '编辑器';
880
- setNotice(target === 'dir'
881
- ? `已用 ${opener} 打开目录:${outcome.value?.path ?? ''}`
882
- : `已用 ${opener} 打开:${outcome.value?.path ?? ''}(改完保存后点「重新摄入」)`);
883
- setError(undefined);
884
- }
885
- else {
886
- setError(outcome.error);
887
- }
888
- });
889
- };
890
- /** Pull edited files back into the index: one document, or every stale one. */
891
- // `adopt` is the explicit second step for a file whose frontmatter was destroyed by a whole-file
892
- // overwrite: the store reports it as `unclaimed` and refuses to guess, and this is the user
893
- // accepting that guess for one document.
894
- const syncDocs = (docId, adopt) => {
895
- const remote = props.remote;
896
- if (remote === undefined)
897
- return;
898
- setLoading(true);
899
- void callRemote('kb.sync', remote.kb(docId === undefined
900
- ? { action: 'sync' }
901
- : { action: 'sync', doc_id: docId, ...(adopt === true ? { adopt: true } : {}) }))
902
- .then((outcome) => {
903
- if (outcome.ok) {
904
- const report = outcome.value;
905
- const missing = report?.missing.length ?? 0;
906
- setNotice(adopt === true
907
- ? `认领完成:${String(report?.adopted ?? 0)} 篇`
908
- : `同步完成:重新摄入 ${String(report?.reingested ?? 0)} 篇`
909
- + (missing > 0 ? `;${String(missing)} 篇的受管文件不存在(重新摄入可重建)` : ''));
910
- setError(undefined);
911
- }
912
- else {
913
- setError(outcome.error);
914
- }
915
- })
916
- .finally(() => { setLoading(false); refresh(); });
917
- };
918
- /** Bring the panel's top into view — used when a block opens below the sticky bar. */
919
- const rootRef = React.useRef(null);
920
- const reveal = () => { rootRef.current?.scrollIntoView({ block: 'start' }); };
921
- React.useEffect(() => { reveal(); }, []); // same reason as the 记忆 tab: the seat keeps scrollTop
922
- // The document list pages like the 记忆 list: the same hook, the same scrollport (this panel does
923
- // not own one), and the same "a full page means there may be more" rule.
924
- const maybeLoadMoreDocs = () => { if (docsTruncated && !loading)
925
- loadDocs(docsOffset); };
926
- useLoadMoreOnScroll(rootRef, docsTruncated && !loading, maybeLoadMoreDocs, docs?.length ?? 0);
927
- const showDocumentDetail = (docId) => {
928
- const remote = props.remote;
929
- if (remote === undefined)
930
- return;
931
- if (docDetail?.id === docId) {
932
- setDocumentDetail(null);
933
- return;
934
- }
935
- const seq = docDetailGuard.begin();
936
- void callRemote(`kb.detail(${String(docId)})`, remote.kb({ action: 'detail', doc_id: docId }))
937
- .then((outcome) => {
938
- if (!docDetailGuard.isCurrent(seq))
939
- return; // a newer click already answered
940
- if (outcome.ok && outcome.value) {
941
- setDocumentDetail({ id: docId, data: outcome.value });
942
- setError(undefined);
943
- }
944
- else
945
- setError(outcome.ok ? '文档详情为空' : outcome.error);
946
- });
947
- };
948
- /**
949
- * Ask before deleting a document — its chunks/vectors AND its managed file. The dialog names
950
- * the file when the last status call knew it, and says outright that `source_uri` is untouched:
951
- * "delete the document" must never read as "delete my file".
952
- */
953
- const confirmRemoveDoc = (doc) => {
954
- const known = [...(fileState?.stale ?? []), ...(fileState?.missing ?? [])]
955
- .find(file => file.doc_id === doc.doc_id);
956
- const message = `删除文档 #${String(doc.doc_id)}「${doc.title}」及其受管文件`
957
- + (known === undefined ? '(knowledge/docs 下的那份副本)' : `:\n${known.path}`)
958
- + '\n\n该文档的切片与向量一并删除,此操作不可恢复;source_uri 指向的原文件不会被改动。';
959
- if (typeof globalThis.confirm === 'function' && !globalThis.confirm(message))
960
- return;
961
- kbCall(`删除文档 #${String(doc.doc_id)}`, { action: 'remove', doc_id: doc.doc_id }, () => { setDocumentDetail(null); refresh(); });
962
- };
963
- // Grouped results (order preserved: first appearance of each group).
964
- const groups = [];
965
- for (const hit of hits) {
966
- const key = groupKey(hit);
967
- const found = groups.find(g => g[0] === key);
968
- if (found)
969
- found[1].push(hit);
970
- else
971
- groups.push([key, [hit]]);
972
- }
973
- const sourceText = iUri.trim();
974
- const bodyText = iText.trim();
975
- /**
976
- * Ask the host what the source input is, debounced so typing a path does not fire a stat per
977
- * keystroke. The answer drives both the hint line and which `kb_manage` action 入库 sends.
978
- */
979
- React.useEffect(() => {
980
- const remote = props.remote;
981
- if (remote === undefined)
982
- return undefined;
983
- if (sourceText === '') {
984
- setSourceInfo(null);
985
- return undefined;
986
- }
987
- // Drop the previous verdict IMMEDIATELY: during the debounce plus the round trip the old
988
- // classification was still rendered and still readable by `canIngest`/`submitIngest`, so a
989
- // click in that window submitted the PREVIOUS path with no visible sign.
990
- setSourceInfo(null);
991
- const timer = setTimeout(() => {
992
- void callRemote('kb.classifySource', remote.classifySource({ text: sourceText }))
993
- .then((outcome) => {
994
- if (outcome.ok)
995
- setSourceInfo(normalizeClassification(outcome.value));
996
- else {
997
- setSourceInfo(null);
998
- setError(outcome.error);
999
- }
1000
- });
1001
- }, 300);
1002
- return () => { clearTimeout(timer); };
1003
- }, [sourceText, props.remote]);
1004
- /** Load one directory into the picker; `path === undefined` starts at the first allowed root. */
1005
- const browseTo = (path) => {
1006
- const remote = props.remote;
1007
- if (remote === undefined)
1008
- return;
1009
- setPicker(previous => ({ listing: previous?.listing ?? null }));
1010
- void callRemote('kb.browseDir', remote.browseDir(path === undefined ? {} : { path }))
1011
- .then((outcome) => {
1012
- if (outcome.ok)
1013
- setPicker({ listing: outcome.value ?? null });
1014
- else
1015
- setPicker({ listing: null, error: outcome.error });
1016
- });
1017
- };
1018
- /** Put a picked path into the source input (which re-classifies it) and close the picker. */
1019
- const choosePath = (path) => { setIUri(path); setPicker(null); };
1020
- /** The other input still holds something — say so, so提交 does not silently leave it behind. */
1021
- const inactiveNote = (inputMode === 'source' && bodyText !== '') || (inputMode === 'text' && sourceText !== '')
1022
- ? `(${inputMode === 'source' ? '粘贴文本' : '来源'}里有内容,本次不会入库)`
1023
- : '';
1024
- /** What 入库 will do with what is currently filled in — shown BEFORE it is pressed. */
1025
- const ingestHint = (() => {
1026
- if (inputMode === 'text') {
1027
- return bodyText !== '' ? `将「粘贴文本」入库${inactiveNote}` : '粘贴文本后即可入库';
1028
- }
1029
- if (sourceText !== '') {
1030
- const info = sourceInfo;
1031
- if (info === null)
1032
- return '正在识别来源…';
1033
- if (info.kind === 'url')
1034
- return '将从该 URL 抓取内容入库';
1035
- if (info.kind === 'file') {
1036
- return info.paths.length > 1
1037
- ? `将导入这 ${String(info.paths.length)} 个本地文件`
1038
- : `将按本地文件入库:${info.paths[0] ?? ''}`;
1039
- }
1040
- if (info.kind === 'directory')
1041
- return `将导入该目录(${String(info.files)} 个可摄入文件,其余跳过)`;
1042
- if (info.kind === 'missing') {
1043
- return `找不到:${info.missing.join('、')}${info.reasons[0] === undefined ? '' : ` —— ${info.reasons[0]}`}`;
1044
- }
1045
- return `将作为文本入库(不是 URL,也不是本地路径)${inactiveNote}`;
1046
- }
1047
- return '填写来源后即可入库';
1048
- })();
1049
- /** True when the hint is a refusal rather than a plan — the row turns warning-coloured. */
1050
- const ingestBlocked = inputMode === 'source'
1051
- ? sourceText !== '' && sourceInfo?.kind === 'missing'
1052
- : false;
1053
- const canIngest = iDomain.trim().length > 0
1054
- && (inputMode === 'text'
1055
- ? bodyText !== ''
1056
- : sourceText !== '' && sourceInfo !== null && sourceInfo.kind !== 'missing');
1057
- /**
1058
- * One 入库 request, with the collision step built in.
1059
- *
1060
- * The host's REPLACE mode answers an unconfirmed collision with a report and writes NOTHING, so
1061
- * this asks the user first and only then re-runs the SAME request with `overwrite: true`.
1062
- * Cancelling returns without sending a second request — nothing is written either way.
1063
- */
1064
- const ingestCall = (label, args, after) => {
1065
- const remote = props.remote;
1066
- if (remote === undefined)
1067
- return;
1068
- setLoading(true);
1069
- void callRemote(label, remote.kb(args))
1070
- .then((outcome) => {
1071
- if (!outcome.ok) {
1072
- setNotice(undefined);
1073
- setError(`${label} 失败:${outcome.error}`);
1074
- return;
1075
- }
1076
- const conflict = ingestConflict(outcome.value);
1077
- if (conflict === null) {
1078
- setNotice(`${label} 完成${summarize(outcome.value)}`);
1079
- setError(undefined);
1080
- after();
1081
- return;
1082
- }
1083
- if (typeof globalThis.confirm !== 'function' || !globalThis.confirm(conflictMessage(conflict)))
1084
- return;
1085
- return callRemote(label, remote.kb({ ...args, overwrite: true }))
1086
- .then((retry) => {
1087
- if (!retry.ok) {
1088
- setNotice(undefined);
1089
- setError(`${label} 失败:${retry.error}`);
1090
- return;
1091
- }
1092
- setNotice(`${label} 完成${summarize(retry.value)}`);
1093
- setError(undefined);
1094
- after();
1095
- });
1096
- })
1097
- .finally(() => { setLoading(false); });
1098
- };
1099
- /**
1100
- * One 入库 button, four destinations: a URL is fetched, an existing file is read, a directory is
1101
- * imported, and anything that is neither a URL nor an existing path is ingested as text. The
1102
- * classification that decides this came from the host — never from a client-side guess.
1103
- */
1104
- const submitIngest = () => {
1105
- const domain = iDomain.trim();
1106
- // Empty = let the contract default it to `default`; sending `''` would land an empty path
1107
- // segment instead.
1108
- const source = iSource.trim() || undefined;
1109
- const title = iTitle.trim() || undefined;
1110
- // Only the visible input is consumed; the other keeps whatever it holds for the next attempt.
1111
- const after = () => {
1112
- if (inputMode === 'text')
1113
- setIText('');
1114
- else
1115
- setIUri('');
1116
- refresh();
1117
- };
1118
- if (inputMode === 'text') {
1119
- ingestCall('入库', { action: 'ingest', text: bodyText, domain, source, title }, after);
1120
- return;
1121
- }
1122
- if (sourceText !== '') {
1123
- const info = sourceInfo;
1124
- if (info === null || info.kind === 'missing')
1125
- return;
1126
- if (info.kind === 'directory' || info.paths.length > 1) {
1127
- ingestCall('导入', { action: 'import', paths: info.paths, domain, source }, after);
1128
- return;
1129
- }
1130
- if (info.kind === 'file') {
1131
- ingestCall('入库', { action: 'ingest', source_uri: info.paths[0], domain, source, title }, after);
1132
- return;
1133
- }
1134
- if (info.kind === 'url') {
1135
- ingestCall('入库', { action: 'ingest', source_uri: sourceText, domain, source, title }, after);
1136
- return;
1137
- }
1138
- }
1139
- ingestCall('入库', { action: 'ingest', text: sourceText, domain, source, title }, after);
1140
- };
1141
- const shown = docs?.length ?? 0;
1142
- const staleIds = new Set((fileState?.stale ?? []).map(file => file.doc_id));
1143
- const missingIds = new Set((fileState?.missing ?? []).map(file => file.doc_id));
1144
- const unclaimedIds = new Set((fileState?.unclaimed ?? []).map(file => file.doc_id));
1145
- const orphans = fileState?.orphans.length ?? 0;
1146
- const summary = docs === null
1147
- ? '读取中…'
1148
- : `已加载 ${String(shown)} 篇文档${docsTruncated ? '(继续滚动自动加载)' : ''}`
1149
- + (staleIds.size > 0 ? ` · ${String(staleIds.size)} 篇待重新摄入` : '')
1150
- + (orphans > 0 ? ` · ${String(orphans)} 个无主文件` : '');
1151
- return h('div', { className: css.section, ref: rootRef }, props.remote === undefined ? h('div', { className: css.error }, NOT_MOUNTED) : null,
1152
- // Sticky bar, the same shape as the 记忆 tab: the page body is a long list, so its
1153
- // controls must not scroll away with it. The button that used to open the list is
1154
- // gone — the list is what this tab shows.
1155
- props.remote === undefined ? null : h('div', { className: css.headbar }, h(Btn, {
1156
- label: showQuery ? '收起查询' : '查询',
1157
- disabled: loading,
1158
- onClick: () => { const opening = !showQuery; setShowQuery(!showQuery); if (opening)
1159
- reveal(); },
1160
- }), h(Btn, {
1161
- label: showIngest ? '收起入库' : '入库',
1162
- disabled: loading,
1163
- // The form mounts ABOVE the document list, so opening it must bring the top into view —
1164
- // otherwise the only visible change is the button's own label.
1165
- onClick: () => { const opening = !showIngest; setShowIngest(opening); if (opening)
1166
- reveal(); },
1167
- }), h(Btn, {
1168
- label: staleIds.size > 0 ? `同步文件(${String(staleIds.size)})` : '同步文件',
1169
- disabled: loading || staleIds.size === 0,
1170
- onClick: () => { syncDocs(); },
1171
- }), h(Btn, {
1172
- label: domain.trim() ? `重建索引(${domain.trim()})` : '重建索引',
1173
- disabled: loading,
1174
- onClick: () => { kbCall('重建索引', { action: 'reindex', domain: domain.trim() || undefined }, refresh); },
1175
- }), h('div', { className: css.status }, loading ? '处理中…' : summary)), error !== undefined ? h('div', { className: css.error }, `错误:${error}`) : null, notice !== undefined ? h('div', { className: css.status }, notice) : null,
1176
- // ─── 查询框:点「查询」才出现(展开即聚焦输入框,回车即检索) ───
1177
- showQuery
1178
- ? h('div', { className: css.item }, React.createElement('label', { className: css.search }, h(SearchIcon, null), h('input', {
1179
- type: 'search',
1180
- value: query,
1181
- disabled: props.remote === undefined,
1182
- placeholder: '搜索记忆与文档(按回车检索)',
1183
- 'aria-label': '搜索记忆与文档',
1184
- autoFocus: true,
1185
- onChange: (event) => { setQuery(event.target.value); },
1186
- onKeyDown: (event) => {
1187
- if (event.key === 'Enter') {
1188
- event.preventDefault();
1189
- run();
1190
- }
1191
- },
1192
- })), props.remote === undefined ? null : h('div', { className: css.toolbar }, h('label', { className: css.field }, '类型', h('select', {
1193
- className: css.input,
1194
- value: kind,
1195
- onChange: (e) => { setKind(e.target.value); },
1196
- }, h('option', { value: 'all' }, '全部'), h('option', { value: 'fact' }, '仅记忆'), h('option', { value: 'doc_chunk' }, '仅文档'))), h(DomainField, {
1197
- label: '知识域(domain)',
1198
- value: domain,
1199
- onChange: setDomain,
1200
- catalog: domainCatalog,
1201
- emptyLabel: '全部',
1202
- placeholder: '如 design',
1203
- }), h(Field, { label: '来源(source)', value: source, onChange: setSource, placeholder: '如 spec' }), h(Btn, { label: '检索', disabled: queryLoading, onClick: run })), props.remote === undefined ? null : h('div', { className: css.status }, queryLoading ? '查询中…' : queryStatus), props.remote !== undefined && !queryLoading && error === undefined && hits.length === 0 && queryStatus.startsWith('命中')
1204
- ? h('div', { className: css.status }, '没有匹配项。知识库为空时先用「入库/导入」添加文档;记忆里也只包含已写入的事实。')
1205
- : null, groups.map(([groupName, groupHits]) => h('div', { key: groupName }, h('div', { className: css.groupHead }, groupName), h('ul', { className: css.list }, groupHits.map(hit => h('li', { key: `${hit.source_ref}:${String(hit.ref_id)}`, className: css.item }, hit.text, h('span', { className: css.meta }, `${hit.kind} · score ${hit.score.toFixed(3)} · ${hit.source_ref}`
1206
- + (hit.entities && hit.entities.length > 0 ? ` · 实体:${hit.entities.join('、')}` : '')
1207
- // 记忆的「何时记下 / 何时改过」:updated_at 只在行被改动时前进,检索不会写它,
1208
- // 所以它与 created_at 不同才是真的改过(而不是刚被查到过)。
1209
- + ` · 创建于 ${hit.created_at}`
1210
- + (hit.updated_at !== null && hit.updated_at !== hit.created_at ? ` · 更新于 ${hit.updated_at}` : ''))))))))
1211
- : null,
1212
- // ─── KB 管理:按钮都在上面的 sticky 头部里 ───
1213
- showIngest
1214
- ? h('div', { className: `${css.item} ${css.form}` }, h('div', { className: css.toolbar }, h(DomainField, {
1215
- label: '知识域(domain)',
1216
- value: iDomain,
1217
- onChange: setIDomain,
1218
- catalog: domainCatalog,
1219
- emptyLabel: '请选择知识域',
1220
- placeholder: '如 design',
1221
- onAdd: addDomain,
1222
- }), h(Field, { label: '来源(source)', value: iSource, onChange: setISource, placeholder: '缺省 default' }), h(Field, { label: '标题(title,可选)', value: iTitle, onChange: setITitle, placeholder: '缺省取正文首个标题或首行' })),
1223
- // 来源 / 粘贴文本 are ALTERNATIVES — one 入库 button with one classification, not two calls —
1224
- // so the form shows exactly one of them. The hidden one keeps its value and says so on the
1225
- // toggle, and `canIngest` only ever reads the visible one. Their meanings differ: a
1226
- // URL/file/directory keeps replace-by-identity, while 粘贴文本 (and anything the host
1227
- // classifies as plain text) only ADDS — the same identity is refused.
1228
- h('div', { className: css.toolbar }, h('button', {
1229
- type: 'button',
1230
- className: inputMode === 'source' ? `${css.btn} ${css.btnActive}` : css.btn,
1231
- onClick: () => { setInputMode('source'); },
1232
- }, sourceText !== '' && inputMode !== 'source' ? '来源 ·有内容' : '来源'), h('button', {
1233
- type: 'button',
1234
- className: inputMode === 'text' ? `${css.btn} ${css.btnActive}` : css.btn,
1235
- onClick: () => { setInputMode('text'); },
1236
- }, bodyText !== '' && inputMode !== 'text' ? '粘贴文本 ·有内容' : '粘贴文本')), inputMode === 'source'
1237
- ? h('div', { className: css.toolbar }, h(Field, {
1238
- label: '来源(http(s) URL / 本地文件 / 本地目录,多条路径用空格或逗号分隔)',
1239
- value: iUri,
1240
- onChange: setIUri,
1241
- placeholder: '如 https://… 或 ~/docs/spec.md 或 ~/docs',
1242
- }), h(Btn, { label: '选择', disabled: loading, onClick: () => { browseTo(); } }))
1243
- : h('label', { className: css.field }, '粘贴文本', h('textarea', {
1244
- className: css.textarea,
1245
- value: iText,
1246
- rows: 4,
1247
- placeholder: '直接粘贴要入库的内容',
1248
- onChange: (e) => { setIText(e.target.value); },
1249
- })), h('div', { className: css.row }, h(Btn, { label: '入库', disabled: loading || !canIngest, onClick: submitIngest }), h('div', { className: ingestBlocked ? `${css.status} ${css.hintWarn}` : css.status }, ingestHint)),
1250
- // ─── 选择器:宿主按摄入边界列举,浏览器里点选 ───
1251
- picker === null
1252
- ? null
1253
- : h('div', { className: css.picker }, h('div', { className: css.pickerHead }, h('code', { className: css.pickerPath }, picker.listing?.path ?? '读取中…'), h('div', { className: css.status }, picker.listing === null
1254
- ? ''
1255
- : picker.listing.unrestricted
1256
- ? '不限制范围(knowledge.ingest.allow_outside_workspace = true),可浏览整个文件系统'
1257
- : `可浏览范围:${picker.listing.roots.join('、')}`)), picker.error === undefined ? null : h('div', { className: css.error }, picker.error), picker.listing === null
1258
- ? null
1259
- : h('ul', { className: css.pickerList }, picker.listing.parent === null
1260
- ? null
1261
- : h('li', { key: '..' }, h('button', {
1262
- type: 'button',
1263
- className: css.pickerEntry,
1264
- onClick: () => { browseTo(picker.listing?.parent ?? undefined); },
1265
- }, '../(上级目录)')), picker.listing.entries.map(entry => h('li', { key: entry.path }, h('button', {
1266
- type: 'button',
1267
- className: entry.kind === 'other' ? `${css.pickerEntry} ${css.pickerDisabled}` : css.pickerEntry,
1268
- disabled: entry.kind === 'other',
1269
- onClick: () => { if (entry.kind === 'dir')
1270
- browseTo(entry.path);
1271
- else
1272
- choosePath(entry.path); },
1273
- }, entry.kind === 'dir' ? `${entry.name}/` : entry.kind === 'ingestable' ? entry.name : `${entry.name}(不可摄入)`)))), h('div', { className: css.row }, picker.listing === null
1274
- ? null
1275
- : h(Btn, { label: '选择此目录', disabled: loading, onClick: () => { choosePath(picker.listing?.path ?? ''); } }), h(Btn, { label: '取消', disabled: loading, onClick: () => { setPicker(null); } }))))
1276
- : null,
1277
- // ─── 页面主体:文档列表(打开「知识」标签页即见;滚到底自动取下一页) ───
1278
- docs === null
1279
- // `docs === null` means "never loaded", which a failure also leaves it at — so the failed
1280
- // state has to say so and offer the retry, or the tab sits on 读取中… forever (the refresh
1281
- // button that used to be here was removed when the list became the page body).
1282
- ? h('div', { className: css.status }, error === undefined ? '读取文档列表…' : '文档列表读取失败。', error === undefined
1283
- ? null
1284
- : h(Btn, { label: '重试', disabled: loading, onClick: () => { refresh(); } }))
1285
- : docs.length === 0
1286
- ? h('div', { className: css.status }, '知识库暂无文档。可用「入库/导入」添加。')
1287
- : h('ul', { className: css.list }, docs.map(doc => h('li', { key: doc.doc_id, className: css.item }, `${doc.title}(${doc.domain} → ${doc.source})`, staleIds.has(doc.doc_id)
1288
- ? h('span', { className: css.warn }, '文件已修改 · 待重新摄入')
1289
- : null, missingIds.has(doc.doc_id)
1290
- ? h('span', { className: css.warn }, unclaimedIds.has(doc.doc_id)
1291
- ? '文件无 frontmatter · 可认领'
1292
- : '受管文件缺失')
1293
- : null, h('span', { className: css.meta }, `#${String(doc.doc_id)}${doc.source_uri ? ` · ${doc.source_uri}` : ''}${doc.updated_at ? ` · 更新于 ${doc.updated_at}` : ''}`), h('div', { className: css.row }, h(Btn, { key: 'detail', label: docDetail?.id === doc.doc_id ? '收起切片' : '查看切片', disabled: loading, onClick: () => { showDocumentDetail(doc.doc_id); } }),
1294
- // 编辑 / 打开目录 act on the MANAGED COPY (`knowledge.docs.dir`), never on whatever
1295
- // `source_uri` points at: the copy is what this KB owns, and deleting or editing a
1296
- // document must not reach into the user's working tree.
1297
- h(Btn, {
1298
- key: 'edit',
1299
- label: '编辑',
1300
- disabled: loading || missingIds.has(doc.doc_id),
1301
- onClick: () => { openDoc(doc.doc_id, 'file'); },
1302
- }), h(Btn, {
1303
- key: 'dir',
1304
- label: '打开目录',
1305
- disabled: loading || missingIds.has(doc.doc_id),
1306
- onClick: () => { openDoc(doc.doc_id, 'dir'); },
1307
- }), staleIds.has(doc.doc_id)
1308
- ? h(Btn, { key: 'resync', label: '重新摄入', disabled: loading, onClick: () => { syncDocs(doc.doc_id); } })
1309
- : null,
1310
- // Only offered for a file the store has ALREADY identified as adoptable (no frontmatter,
1311
- // path names this document, nobody else claims it) — never as a way to force anything.
1312
- unclaimedIds.has(doc.doc_id)
1313
- ? h(Btn, { key: 'adopt', label: '认领文件', disabled: loading, onClick: () => { syncDocs(doc.doc_id, true); } })
1314
- : null, h(Btn, {
1315
- key: 'remove',
1316
- label: '删除',
1317
- danger: true,
1318
- disabled: loading,
1319
- onClick: () => { confirmRemoveDoc(doc); },
1320
- })), docDetail?.id === doc.doc_id
1321
- ? h('div', { className: css.meta }, `共 ${String(docDetail.data.chunks?.length ?? 0)} 个切片`, (docDetail.data.chunks ?? []).slice(0, 20).map(chunk => h('div', { key: chunk.chunk_id, className: css.chunkBox }, h('span', { className: css.badge }, `#${String(chunk.idx)}${chunk.headings_path ? ` · ${chunk.headings_path}` : ''}`), h('div', null, chunk.text.length > 200 ? `${chunk.text.slice(0, 200)}…` : chunk.text))), (docDetail.data.chunks?.length ?? 0) > 20 ? h('div', null, `……其余 ${String((docDetail.data.chunks?.length ?? 0) - 20)} 个切片省略`) : null)
1322
- : null))), docsTruncated
1323
- ? h('button', {
1324
- type: 'button',
1325
- className: css.more,
1326
- disabled: loading,
1327
- onClick: () => { loadDocs(docsOffset); },
1328
- }, loading ? '加载中…' : '加载更多')
1329
- : null);
1330
- }
1331
- /** Client cordis-plugin name shown in boot diagnostics. */
1332
- export const name = 'avantf-mem-client';
1333
- /**
1334
- * Services required to render the two tabs. `remote` (not `remote.avantfMem`)
1335
- * on purpose: a missing namespace must not leave this entry pending, because
1336
- * the client boot rejects the whole tree when any entry is pending.
1337
- */
1338
- export const inject = ['slots', 'remote'];
1339
- /** Contribute the two `conversation.view` tabs, mounting this package's Remote namespace first. */
1340
- export async function apply(ctx) {
1341
- let remote;
1342
- try {
1343
- // The Client only auto-mounts the harness's generated namespace list, so an
1344
- // out-of-tree plugin mounts its own contribution before using it.
1345
- await ctx.remote.$mount(clientContribution);
1346
- // Resolved through `get()`: reading `ctx.remote.avantfMem` directly is
1347
- // refused unless `remote.avantfMem` is in this plugin's own inject list.
1348
- remote = ctx.get('remote.avantfMem');
1349
- if (remote === undefined) {
1350
- console.error('[avantf-mem] client apply: contribution mounted but no remote.avantfMem service');
1351
- }
1352
- else {
1353
- console.log('[avantf-mem] client apply: avantfMem remote mounted');
1354
- }
1355
- }
1356
- catch (error) {
1357
- console.error('[avantf-mem] client apply: mounting the avantfMem contribution failed', error);
1358
- }
1359
- // Two `conversation.view` tabs, ordered after 对话 (0) and 轨迹 (10) — the same
1360
- // strip. The mount is unchanged: the seat hands session-scoped props we ignore,
1361
- // and the panels read everything through the `avantfMem` Remote.
1362
- ctx.slots.inject('conversation.view', () => ctx.slots.register({ name: 'conversation.view', id: 'memory', order: 20, label: () => '记忆' }, () => React.createElement(MemoryPanel, { remote })));
1363
- ctx.slots.inject('conversation.view', () => ctx.slots.register({ name: 'conversation.view', id: 'knowledge', order: 30, label: () => '知识' }, () => React.createElement(KnowledgePanel, { remote })));
1364
- // The panels live ONLY here, in the main window's view strip; the settings page intentionally
1365
- // carries no 记忆/知识 entries (the panels used to be registered into `settings.section` too, as an
1366
- // always-reachable route while a blank session hides the strip — the product decision is that the
1367
- // tabs are the single home, so that duplication is gone and `ui-settings` is no longer a client
1368
- // dependency).
1369
- console.log('[avantf-mem] client apply: tabs registered (记忆, 知识) in the conversation view strip');
1370
- }
1371
- //# sourceMappingURL=index.js.map