@avantf/dsh-mem 0.3.0 → 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.
- package/README.md +89 -28
- package/cordis.patch.yml +17 -0
- package/lib/client.js +155 -14
- package/lib/dsh-build.json +5 -5
- package/lib/engine/mem/config/config_files.d.ts +26 -0
- package/lib/engine/mem/config/domains.d.ts +20 -0
- package/lib/engine/mem/config/loader.d.ts +35 -0
- package/lib/engine/mem/config/paths.d.ts +53 -0
- package/lib/engine/mem/db/chunk.d.ts +26 -0
- package/lib/engine/mem/db/conn.d.ts +13 -0
- package/lib/engine/mem/db/dao/chunks.d.ts +150 -0
- package/lib/engine/mem/db/dao/contradictions.d.ts +98 -0
- package/lib/engine/mem/db/dao/documents.d.ts +51 -0
- package/lib/engine/mem/db/dao/entities.d.ts +67 -0
- package/lib/engine/mem/db/dao/facts.d.ts +472 -0
- package/lib/engine/mem/db/dao/stats.d.ts +14 -0
- package/lib/engine/mem/db/dao/triples.d.ts +80 -0
- package/lib/engine/mem/db/hash.d.ts +2 -0
- package/lib/engine/mem/db/knowledge.d.ts +17 -0
- package/lib/engine/mem/db/migrations.d.ts +71 -0
- package/lib/engine/mem/db/port.d.ts +57 -0
- package/lib/engine/mem/db/schema.d.ts +26 -0
- package/lib/engine/mem/db/sqlite.d.ts +89 -0
- package/lib/engine/mem/db/store.d.ts +35 -0
- package/lib/engine/mem/db/tokenizer.d.ts +61 -0
- package/lib/engine/mem/db/vectors.d.ts +50 -0
- package/lib/engine/mem/dispatch.d.ts +51 -0
- package/lib/engine/mem/entities/extract.d.ts +84 -0
- package/lib/engine/mem/eval/loader.d.ts +19 -0
- package/lib/engine/mem/eval/metrics.d.ts +25 -0
- package/lib/engine/mem/eval/runner.d.ts +10 -0
- package/lib/engine/mem/eval/write_metrics.d.ts +88 -0
- package/lib/engine/mem/git.d.ts +73 -0
- package/lib/engine/mem/hrr/algebra.d.ts +30 -0
- package/lib/engine/mem/hrr/atoms.d.ts +22 -0
- package/lib/engine/mem/hrr/encode.d.ts +32 -0
- package/lib/engine/mem/hrr/index.d.ts +3 -0
- package/lib/engine/mem/index.d.ts +27 -0
- package/lib/engine/mem/lifecycle/contradiction.d.ts +156 -0
- package/lib/engine/mem/lifecycle/index.d.ts +5 -0
- package/lib/engine/mem/lifecycle/maintenance.d.ts +48 -0
- package/lib/engine/mem/lifecycle/presence.d.ts +29 -0
- package/lib/engine/mem/lifecycle/tick.d.ts +43 -0
- package/lib/engine/mem/lifecycle/trust.d.ts +90 -0
- package/lib/engine/mem/modelBootstrap.d.ts +207 -0
- package/lib/engine/mem/router.d.ts +51 -0
- package/lib/engine/mem/runtime.d.ts +232 -0
- package/lib/engine/mem/store/common.d.ts +82 -0
- package/lib/engine/mem/store/doc_files.d.ts +125 -0
- package/lib/engine/mem/store/document_text.d.ts +47 -0
- package/lib/engine/mem/store/floors.d.ts +191 -0
- package/lib/engine/mem/store/hybrid.d.ts +232 -0
- package/lib/engine/mem/store/ingest_guard.d.ts +25 -0
- package/lib/engine/mem/store/knowledge.d.ts +483 -0
- package/lib/engine/mem/store/lexical.d.ts +67 -0
- package/lib/engine/mem/store/memory.d.ts +543 -0
- package/lib/engine/mem/store/source_picker.d.ts +43 -0
- package/lib/engine/mem-contract/config.d.ts +129 -0
- package/lib/engine/mem-contract/env.d.ts +31 -0
- package/lib/engine/mem-contract/family.d.ts +25 -0
- package/lib/engine/mem-contract/index.d.ts +8 -0
- package/lib/engine/mem-contract/log.d.ts +29 -0
- package/lib/engine/mem-contract/remote.d.ts +80 -0
- package/lib/engine/mem-contract/text.d.ts +92 -0
- package/lib/engine/mem-contract/tools.d.ts +284 -0
- package/lib/engine/mem-contract/types.d.ts +615 -0
- package/lib/engine/mem-retrieval/adapters/auto_vstore.d.ts +47 -0
- package/lib/engine/mem-retrieval/adapters/hnswlib.d.ts +174 -0
- package/lib/engine/mem-retrieval/adapters/local_bge.d.ts +121 -0
- package/lib/engine/mem-retrieval/adapters/local_numpy.d.ts +28 -0
- package/lib/engine/mem-retrieval/adapters/local_reranker.d.ts +52 -0
- package/lib/engine/mem-retrieval/adapters/none_reranker.d.ts +10 -0
- package/lib/engine/mem-retrieval/adapters/warm_gate.d.ts +46 -0
- package/lib/engine/mem-retrieval/budget.d.ts +30 -0
- package/lib/engine/mem-retrieval/fusion.d.ts +53 -0
- package/lib/engine/mem-retrieval/index.d.ts +14 -0
- package/lib/engine/mem-retrieval/interfaces.d.ts +82 -0
- package/lib/engine/mem-retrieval/log.d.ts +11 -0
- package/lib/engine/mem-retrieval/registry.d.ts +23 -0
- package/lib/engine/mem-retrieval/rerank.d.ts +13 -0
- package/lib/engine/mem-retrieval/stats.d.ts +64 -0
- package/lib/engine/mem-retrieval/text_budget.d.ts +52 -0
- package/lib/index.js +1773 -473
- package/lib/interface-version.json +2 -2
- package/lib/types/client/domains.d.ts +5 -4
- package/lib/types/client/domains.d.ts.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/ingest.d.ts +1 -1
- package/lib/types/client/wire.d.ts +36 -0
- package/lib/types/client/wire.d.ts.map +1 -0
- package/lib/types/envinit-bootstrap.d.ts +3 -2
- package/lib/types/envinit.d.ts +2 -19
- package/lib/types/envinit.d.ts.map +1 -1
- package/lib/types/hints.d.ts +1 -1
- package/lib/types/index.d.ts +8 -3
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/open.d.ts +61 -11
- package/lib/types/open.d.ts.map +1 -1
- package/lib/types/prompt.d.ts +19 -1
- package/lib/types/prompt.d.ts.map +1 -1
- package/lib/types/provision.d.ts +1 -1
- package/lib/types/provision.d.ts.map +1 -1
- package/lib/types/reconcile.d.ts +64 -0
- package/lib/types/reconcile.d.ts.map +1 -0
- package/lib/types/remote.d.ts +53 -0
- package/lib/types/remote.d.ts.map +1 -1
- package/lib/types/render.d.ts +35 -0
- package/lib/types/render.d.ts.map +1 -0
- package/lib/types/tool_schema.d.ts +3 -13
- package/lib/types/tool_schema.d.ts.map +1 -1
- package/package.json +27 -18
- package/lib/client.js.map +0 -1
- package/lib/types/client/domains.js +0 -25
- package/lib/types/client/domains.js.map +0 -1
- package/lib/types/client/index.js +0 -1371
- package/lib/types/client/index.js.map +0 -1
- package/lib/types/client/ingest.js +0 -30
- package/lib/types/client/ingest.js.map +0 -1
- package/lib/types/client/paging.js +0 -36
- package/lib/types/client/paging.js.map +0 -1
- package/lib/types/client/summarize.js +0 -79
- package/lib/types/client/summarize.js.map +0 -1
- package/lib/types/data_home.js +0 -26
- package/lib/types/data_home.js.map +0 -1
- package/lib/types/envinit-bootstrap.js +0 -333
- package/lib/types/envinit.js +0 -402
- package/lib/types/envinit.js.map +0 -1
- package/lib/types/hints.js +0 -53
- package/lib/types/hints.js.map +0 -1
- package/lib/types/index.js +0 -755
- package/lib/types/index.js.map +0 -1
- package/lib/types/inject.js +0 -14
- package/lib/types/inject.js.map +0 -1
- package/lib/types/interface_gate.js +0 -84
- package/lib/types/interface_gate.js.map +0 -1
- package/lib/types/open.js +0 -133
- package/lib/types/open.js.map +0 -1
- package/lib/types/prompt.js +0 -148
- package/lib/types/prompt.js.map +0 -1
- package/lib/types/provision.js +0 -177
- package/lib/types/provision.js.map +0 -1
- package/lib/types/remote.js +0 -247
- package/lib/types/remote.js.map +0 -1
- package/lib/types/tool_schema.js +0 -149
- 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
|