@avantf/dsh-mem 0.8.0 → 0.9.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/lib/client.js +1088 -303
- package/lib/engine/graph-recall/index.d.ts +2 -2
- package/lib/engine/graph-recall/traverse.d.ts +2 -2
- package/lib/engine/graph-recall/types.d.ts +1 -1
- package/lib/engine/mem/config/config_files.d.ts +1 -1
- package/lib/engine/mem/defaults.d.ts +4 -1
- package/lib/engine/mem/entities/extract.d.ts +34 -14
- package/lib/engine/mem/index.d.ts +1 -0
- package/lib/engine/mem/modelBootstrap.d.ts +29 -0
- package/lib/engine/mem/store/graph_reader.d.ts +2 -2
- package/lib/engine/mem/store/memory.d.ts +53 -6
- package/lib/engine/mem/store/recall_policy.d.ts +10 -0
- package/lib/engine/mem-contract/config.d.ts +135 -0
- package/lib/engine/mem-contract/types.d.ts +11 -0
- package/lib/engine/mem-retrieval/adapters/auto_vstore.d.ts +10 -0
- package/lib/engine/mem-retrieval/adapters/hnswlib.d.ts +28 -0
- package/lib/engine/mem-retrieval/adapters/local_bge.d.ts +48 -6
- package/lib/engine/mem-retrieval/interfaces.d.ts +22 -2
- package/lib/engine/mem-retrieval/text_budget.d.ts +31 -0
- package/lib/index.js +1617 -114
- package/lib/interface-version.json +2 -2
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/paging.d.ts +58 -8
- package/lib/types/client/paging.d.ts.map +1 -1
- package/lib/types/client/time_filter.d.ts +54 -0
- package/lib/types/client/time_filter.d.ts.map +1 -0
- package/lib/types/deps_audit.d.ts +88 -0
- package/lib/types/deps_audit.d.ts.map +1 -0
- package/lib/types/envinit-bootstrap.d.ts +1 -1
- package/lib/types/envinit.d.ts +224 -4
- package/lib/types/envinit.d.ts.map +1 -1
- package/lib/types/hints.d.ts +58 -0
- package/lib/types/hints.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/render.d.ts.map +1 -1
- package/lib/types/runtime_deps_resource.d.ts +29 -0
- package/lib/types/runtime_deps_resource.d.ts.map +1 -0
- package/package.json +5 -4
- package/res/runtime-deps.yaml +99 -0
package/lib/client.js
CHANGED
|
@@ -29,34 +29,25 @@ window.__ModuleLoader__.load({
|
|
|
29
29
|
let react = require("react");
|
|
30
30
|
react = __toESM(react, 1);
|
|
31
31
|
//#region \0dsh-css:src/client/pages.module.css.mjs
|
|
32
|
-
const css = ".HvfIsq_section{color:var(--dsw-alias-label-primary);width:100%;max-width:var(--dsh-chat-content-width);flex-direction:column;gap:12px;margin:0 auto;display:flex}.
|
|
33
|
-
const tagId = "@avantf/dsh-mem/pages.module.css";
|
|
34
|
-
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
32
|
+
const css$1 = ".HvfIsq_section{color:var(--dsw-alias-label-primary);width:100%;max-width:var(--dsh-chat-content-width);flex-direction:column;gap:12px;margin:0 auto;display:flex}.HvfIsq_stickyTop{z-index:4;border-bottom:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base);flex-direction:column;gap:8px;padding-bottom:8px;display:flex;position:sticky;top:0}.HvfIsq_headbar{flex-wrap:wrap;align-items:flex-end;gap:8px;display:flex}.HvfIsq_warn{color:var(--dsw-alias-state-warn-label);background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 16%, transparent);border-radius:9px;margin-left:6px;padding:0 6px;font-size:11px;line-height:16px;display:inline-block}.HvfIsq_search{width:100%;color:var(--dsw-alias-label-tertiary);align-items:center;display:flex;position:relative}.HvfIsq_searchField{flex:260px;width:auto}.HvfIsq_edit{margin-top:6px}.HvfIsq_search>svg{pointer-events:none;position:absolute;left:12px}.HvfIsq_search input{border:.5px solid var(--dsw-alias-border-l4);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-1);width:100%;height:36px;color:var(--dsw-alias-label-primary);font:inherit;outline:none;padding:0 34px 0 36px;font-size:13px}.HvfIsq_search input::placeholder{color:var(--dsw-alias-label-tertiary)}.HvfIsq_search input:focus-visible{border-color:var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));box-shadow:0 0 0 2px color-mix(in srgb, var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary)) 18%, transparent)}.HvfIsq_search>button{position:absolute;top:50%;right:8px;transform:translateY(-50%)}.HvfIsq_detail{flex-direction:column;gap:4px;display:flex}.HvfIsq_more{align-self:flex-start}.HvfIsq_toolbar{flex-wrap:wrap;align-items:flex-end;gap:8px;display:flex}.HvfIsq_form{flex-direction:column;gap:8px;display:flex}.HvfIsq_row{flex-wrap:wrap;align-items:center;gap:6px;margin-top:6px;display:flex}.HvfIsq_fieldHead{justify-content:space-between;align-items:center;gap:8px;display:flex}.HvfIsq_fieldHead>*+*{flex:none}.HvfIsq_field{color:var(--dsw-alias-label-tertiary);flex-direction:column;flex:auto;gap:4px;min-width:120px;font-size:12px;display:flex}.HvfIsq_groupHead{color:var(--dsw-alias-label-secondary);margin:4px 0 2px;font-size:12px;font-weight:600}.HvfIsq_chunkBox{border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;margin-top:6px;padding:6px 8px;font-size:12px;line-height:1.5}.HvfIsq_picker{border:.5px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-1);border-radius:10px;flex-direction:column;gap:8px;padding:8px 10px;display:flex}.HvfIsq_pickerHead{flex-direction:column;gap:2px;display:flex}.HvfIsq_pickerPath{overflow-wrap:anywhere;font-family:ui-monospace,monospace;font-size:12px}.HvfIsq_pickerList{border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;flex-direction:column;gap:1px;max-height:260px;margin:0;padding:0;list-style:none;display:flex;overflow:auto}.HvfIsq_pickerEntry{width:100%;color:inherit;font:inherit;text-align:left;cursor:pointer;background:0 0;border:0;padding:4px 8px;font-size:12px;display:block}.HvfIsq_pickerEntry:hover:not(:disabled){background:color-mix(in srgb, currentColor 8%, transparent)}.HvfIsq_pickerDisabled{opacity:.45;cursor:default}.HvfIsq_hintWarn{color:var(--dsw-alias-state-warn-label)}";
|
|
33
|
+
const tagId$1 = "@avantf/dsh-mem/pages.module.css";
|
|
34
|
+
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$1) + "]") === null) {
|
|
35
35
|
const tag = document.createElement("style");
|
|
36
36
|
tag.dataset.plugin = "@avantf/dsh-mem";
|
|
37
|
-
tag.dataset.pluginCss = tagId;
|
|
38
|
-
tag.textContent = css;
|
|
37
|
+
tag.dataset.pluginCss = tagId$1;
|
|
38
|
+
tag.textContent = css$1;
|
|
39
39
|
document.head.appendChild(tag);
|
|
40
40
|
}
|
|
41
41
|
var pages_module_css_default = {
|
|
42
|
-
"badge": "HvfIsq_badge",
|
|
43
|
-
"btn": "HvfIsq_btn",
|
|
44
|
-
"btnActive": "HvfIsq_btnActive",
|
|
45
|
-
"btnDanger": "HvfIsq_btnDanger",
|
|
46
42
|
"chunkBox": "HvfIsq_chunkBox",
|
|
47
|
-
"clear": "HvfIsq_clear",
|
|
48
43
|
"detail": "HvfIsq_detail",
|
|
49
|
-
"
|
|
44
|
+
"edit": "HvfIsq_edit",
|
|
50
45
|
"field": "HvfIsq_field",
|
|
51
46
|
"fieldHead": "HvfIsq_fieldHead",
|
|
52
47
|
"form": "HvfIsq_form",
|
|
53
48
|
"groupHead": "HvfIsq_groupHead",
|
|
54
49
|
"headbar": "HvfIsq_headbar",
|
|
55
50
|
"hintWarn": "HvfIsq_hintWarn",
|
|
56
|
-
"input": "HvfIsq_input",
|
|
57
|
-
"item": "HvfIsq_item",
|
|
58
|
-
"list": "HvfIsq_list",
|
|
59
|
-
"meta": "HvfIsq_meta",
|
|
60
51
|
"more": "HvfIsq_more",
|
|
61
52
|
"picker": "HvfIsq_picker",
|
|
62
53
|
"pickerDisabled": "HvfIsq_pickerDisabled",
|
|
@@ -67,13 +58,8 @@ window.__ModuleLoader__.load({
|
|
|
67
58
|
"row": "HvfIsq_row",
|
|
68
59
|
"search": "HvfIsq_search",
|
|
69
60
|
"searchField": "HvfIsq_searchField",
|
|
70
|
-
"searchPanel": "HvfIsq_searchPanel",
|
|
71
|
-
"searchQuery": "HvfIsq_searchQuery",
|
|
72
|
-
"searchTitle": "HvfIsq_searchTitle",
|
|
73
61
|
"section": "HvfIsq_section",
|
|
74
|
-
"
|
|
75
|
-
"status": "HvfIsq_status",
|
|
76
|
-
"textarea": "HvfIsq_textarea",
|
|
62
|
+
"stickyTop": "HvfIsq_stickyTop",
|
|
77
63
|
"toolbar": "HvfIsq_toolbar",
|
|
78
64
|
"warn": "HvfIsq_warn"
|
|
79
65
|
};
|
|
@@ -4383,31 +4369,135 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4383
4369
|
//#endregion
|
|
4384
4370
|
//#region src/client/paging.ts
|
|
4385
4371
|
/**
|
|
4386
|
-
* Page merging for the
|
|
4372
|
+
* Page merging for the tab's ONE list.
|
|
4387
4373
|
*
|
|
4388
4374
|
* Kept out of `client/index.ts` so it can be tested without a DOM (the same reason `summarize.ts`
|
|
4389
4375
|
* lives on its own): this is where page drift is handled, and page drift is the kind of bug that
|
|
4390
4376
|
* only shows up as a React key warning plus a row nobody can scroll to.
|
|
4391
4377
|
*
|
|
4378
|
+
* There used to be TWO helpers here — one keyed by `fact_id` for the admin list and one by `ref_id` for
|
|
4379
|
+
* a time window's results — because the tab kept two lists. It keeps one now (`ListState`), so there is
|
|
4380
|
+
* one merge: the row's React key is the identity, and both sources are appended through it.
|
|
4381
|
+
*
|
|
4392
4382
|
* @module client/paging
|
|
4393
4383
|
*/
|
|
4394
4384
|
/**
|
|
4395
|
-
* Append one page of
|
|
4385
|
+
* Append one page of list rows, dropping keys already present.
|
|
4386
|
+
*
|
|
4387
|
+
* Both sources page by `OFFSET` over `created_at DESC`, and `created_at` has second precision: a fact
|
|
4388
|
+
* written (or archived) between two requests shifts every later row by one, so the next page repeats a
|
|
4389
|
+
* row that is already on screen. The duplicate is not cosmetic — it is a React key collision
|
|
4390
|
+
* (`key={row.key}`) and a row that can never be scrolled to.
|
|
4396
4391
|
*
|
|
4397
|
-
*
|
|
4398
|
-
*
|
|
4399
|
-
* repeats a row that is already on screen. The duplicate is not cosmetic — it is a React key
|
|
4400
|
-
* collision (`key={fact_id}`) and a row that can never be scrolled to.
|
|
4392
|
+
* A fact-shaped row's `key` is `fact:<id>`, so this stays exactly as strict as the two helpers it
|
|
4393
|
+
* replaced; a relation path row is keyed by its position and cannot collide with one.
|
|
4401
4394
|
*/
|
|
4402
|
-
function
|
|
4403
|
-
|
|
4404
|
-
|
|
4405
|
-
/** Append the rows whose id is not already loaded, preserving order; identity when nothing is new. */
|
|
4406
|
-
function mergeById(previous, page, idOf) {
|
|
4407
|
-
const seen = new Set(previous.map(idOf));
|
|
4408
|
-
const added = page.filter((row) => !seen.has(idOf(row)));
|
|
4395
|
+
function mergeRows(previous, page) {
|
|
4396
|
+
const seen = new Set(previous.map((row) => row.key));
|
|
4397
|
+
const added = page.filter((row) => !seen.has(row.key));
|
|
4409
4398
|
return added.length === 0 ? previous : [...previous, ...added];
|
|
4410
4399
|
}
|
|
4400
|
+
/**
|
|
4401
|
+
* Fold the pages of a WALKED window into the list's rows (REVIEW-2026-10-27 P1-1).
|
|
4402
|
+
*
|
|
4403
|
+
* The walk in `runTimeWindow` requests page after page from `offset` 0, and the drift the merge above
|
|
4404
|
+
* guards against happens BETWEEN those requests just as it does between two scroll-triggered pages: a
|
|
4405
|
+
* fact written while page 2 is in flight shifts the window, so page 3 repeats a row of page 2. Collecting
|
|
4406
|
+
* the raw hits and mapping them once (what the walk did) therefore duplicated a row inside the FIRST
|
|
4407
|
+
* render — a duplicate React key, a card that cannot be scrolled to, and a `total - rows.length` that
|
|
4408
|
+
* under-reports. Folding through {@link mergeRows} makes the walk and the continuation the same rule.
|
|
4409
|
+
*
|
|
4410
|
+
* Note what this does NOT touch: the host-handed offsets (`nextOffset`) still advance by the page's own
|
|
4411
|
+
* length, never by the de-duplicated row count — the cursor follows the HOST's paging, the rows follow
|
|
4412
|
+
* what is actually on screen.
|
|
4413
|
+
*/
|
|
4414
|
+
function mergeRowPages(pages) {
|
|
4415
|
+
return pages.reduce((rows, page) => mergeRows(rows, [...page]), []);
|
|
4416
|
+
}
|
|
4417
|
+
/**
|
|
4418
|
+
* Append ONE continuation page of a walked time window (`loadSearchPage`), and decide whether the window
|
|
4419
|
+
* still has a page to fetch (REVIEW-2026-10-27 P1-2).
|
|
4420
|
+
*
|
|
4421
|
+
* `truncated` is the host's own rule — `offset + hits.length < total` — and NOT "did the row count grow".
|
|
4422
|
+
* A page whose rows are all already on screen (extreme, but real: many facts inserted at the top between
|
|
4423
|
+
* two requests shift every later row) adds nothing to `rows`, yet the anchor has moved, so claiming "no
|
|
4424
|
+
* more rows" there stops the window for good while half of it is still unread.
|
|
4425
|
+
*
|
|
4426
|
+
* The one case that DOES stop is a page with no rows at all: the anchor cannot advance
|
|
4427
|
+
* (`offset + 0 === offset`), so keeping `truncated` true would re-ask the same offset on every scroll.
|
|
4428
|
+
* That is the empty page the host answers with when it has nothing more, not a repeated page.
|
|
4429
|
+
*/
|
|
4430
|
+
function appendWindowPage(previous, page) {
|
|
4431
|
+
const nextOffset = page.offset + page.rows.length;
|
|
4432
|
+
return {
|
|
4433
|
+
rows: mergeRows(previous.rows, page.rows),
|
|
4434
|
+
nextOffset,
|
|
4435
|
+
truncated: page.rows.length > 0 && nextOffset < page.total
|
|
4436
|
+
};
|
|
4437
|
+
}
|
|
4438
|
+
//#endregion
|
|
4439
|
+
//#region src/client/time_filter.ts
|
|
4440
|
+
/**
|
|
4441
|
+
* The three time axes, with the labels the toolbar shows. The axis is REQUIRED by the contract (D1)
|
|
4442
|
+
* and the three do not substitute for one another, so the control never defaults to "any time".
|
|
4443
|
+
*/
|
|
4444
|
+
const TIME_AXES = [
|
|
4445
|
+
{
|
|
4446
|
+
value: "created",
|
|
4447
|
+
label: "入库时间"
|
|
4448
|
+
},
|
|
4449
|
+
{
|
|
4450
|
+
value: "updated",
|
|
4451
|
+
label: "修改时间"
|
|
4452
|
+
},
|
|
4453
|
+
{
|
|
4454
|
+
value: "event",
|
|
4455
|
+
label: "事件时间"
|
|
4456
|
+
}
|
|
4457
|
+
];
|
|
4458
|
+
/** The panel-facing name of one axis (same list as {@link TIME_AXES}). */
|
|
4459
|
+
function axisLabel(field) {
|
|
4460
|
+
const axis = TIME_AXES.find((entry) => entry.value === field);
|
|
4461
|
+
return axis === void 0 ? String(field) : axis.label;
|
|
4462
|
+
}
|
|
4463
|
+
/**
|
|
4464
|
+
* Build the `time` argument from the controls. `undefined` = no time condition at all.
|
|
4465
|
+
*
|
|
4466
|
+
* In `on` mode the range inputs are IGNORED (the contract refuses `on` together with `from`/`to`,
|
|
4467
|
+
* so a stale value in the hidden inputs must never leak into a request). Values are the native
|
|
4468
|
+
* `YYYY-MM-DD` a date input produces; they are passed through untouched — the engine owns the
|
|
4469
|
+
* grammar, including the timezone reading of a date without an offset.
|
|
4470
|
+
*/
|
|
4471
|
+
function buildTimeFilter(field, mode, on, from, to) {
|
|
4472
|
+
if (mode === "on") {
|
|
4473
|
+
const day = on.trim();
|
|
4474
|
+
return day === "" ? void 0 : {
|
|
4475
|
+
field,
|
|
4476
|
+
on: day
|
|
4477
|
+
};
|
|
4478
|
+
}
|
|
4479
|
+
const lower = from.trim();
|
|
4480
|
+
const upper = to.trim();
|
|
4481
|
+
if (lower === "" && upper === "") return void 0;
|
|
4482
|
+
return {
|
|
4483
|
+
field,
|
|
4484
|
+
...lower === "" ? {} : { from: lower },
|
|
4485
|
+
...upper === "" ? {} : { to: upper }
|
|
4486
|
+
};
|
|
4487
|
+
}
|
|
4488
|
+
/**
|
|
4489
|
+
* The panel's one-line statement of the window the visible results were narrowed to.
|
|
4490
|
+
*
|
|
4491
|
+
* WHY THE PANEL NEEDS IT: a time-filtered answer is shorter than the list, and without a line naming
|
|
4492
|
+
* the window, "fewer rows than expected" is indistinguishable from a broken query. It is rendered
|
|
4493
|
+
* from the filter the RESULTS were fetched with (not from the live controls), so editing a date
|
|
4494
|
+
* input cannot relabel results that were fetched with the previous window.
|
|
4495
|
+
*/
|
|
4496
|
+
function timeFilterLabel(time) {
|
|
4497
|
+
const axis = axisLabel(time.field);
|
|
4498
|
+
if (time.on !== void 0) return `${axis} ${time.on}`;
|
|
4499
|
+
return `${axis} ${time.from ?? "最早"} ~ ${time.to ?? "现在"}`;
|
|
4500
|
+
}
|
|
4411
4501
|
//#endregion
|
|
4412
4502
|
//#region src/client/wire.ts
|
|
4413
4503
|
/**
|
|
@@ -4473,6 +4563,199 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4473
4563
|
if (!/404|not found/iu.test(text)) return text;
|
|
4474
4564
|
return `宿主可能没有注册这个接口(旧版本 / 未重启):客户端会随页面刷新,宿主只在 dsh web 启动时加载一次,所以 dsh web 很可能还在跑旧的宿主代码。重启 dsh web 后再试。(原始错误:${text})`;
|
|
4475
4565
|
}
|
|
4566
|
+
/**
|
|
4567
|
+
* The nearest element that actually scrolls `node`, at or above it.
|
|
4568
|
+
*
|
|
4569
|
+
* The criterion is the computed `overflow-y` (`auto`/`scroll`) AND "it really overflows"
|
|
4570
|
+
* (`scrollHeight > clientHeight`): a host's wrapper elements are `overflow-y:auto` long before their
|
|
4571
|
+
* content is taller than they are, and binding to one of those would listen to an element that never
|
|
4572
|
+
* emits a scroll. A `null` result is therefore honest — nothing here scrolls — and the listener falls
|
|
4573
|
+
* back to `window` (see {@link createAutoLoadMore}).
|
|
4574
|
+
*
|
|
4575
|
+
* `node` ITSELF is considered first. A panel normally passes its root (the scrollport is an ancestor:
|
|
4576
|
+
* the host's conversation scroll body), but a list that owns its scroller — a virtualized list whose
|
|
4577
|
+
* `getScrollElement()` is its own container — passes that container, which is the very element
|
|
4578
|
+
* scroll events fire on. Scroll events do not bubble between scrollers, so getting THIS element (not
|
|
4579
|
+
* some ancestor) is what makes such a list work.
|
|
4580
|
+
*/
|
|
4581
|
+
function scrollParentOf(node) {
|
|
4582
|
+
let current = node;
|
|
4583
|
+
while (current !== null) {
|
|
4584
|
+
const overflowY = computedOverflowY(current);
|
|
4585
|
+
if ((overflowY === "auto" || overflowY === "scroll") && current.scrollHeight > current.clientHeight) return current;
|
|
4586
|
+
current = current.parentElement;
|
|
4587
|
+
}
|
|
4588
|
+
return null;
|
|
4589
|
+
}
|
|
4590
|
+
/**
|
|
4591
|
+
* The computed `overflow-y` of `element`, read off ITS OWN document's view.
|
|
4592
|
+
*
|
|
4593
|
+
* Not the bare `getComputedStyle` global: this module also runs under a test double or inside a
|
|
4594
|
+
* detached document, and both need the style to come from somewhere explicit. An element with no
|
|
4595
|
+
* view (no document, or a document without a window) reports `''` — "not a scrollport", the safe
|
|
4596
|
+
* answer.
|
|
4597
|
+
*/
|
|
4598
|
+
function computedOverflowY(element) {
|
|
4599
|
+
const view = element.ownerDocument?.defaultView;
|
|
4600
|
+
if (view === null || view === void 0) return "";
|
|
4601
|
+
return view.getComputedStyle(element).overflowY;
|
|
4602
|
+
}
|
|
4603
|
+
/**
|
|
4604
|
+
* Where to listen: the scrollport when one was found, else `window`.
|
|
4605
|
+
*
|
|
4606
|
+
* The `window` fallback is the case the seat's own history taught: a panel can be mounted in a
|
|
4607
|
+
* document whose scrollport is the page itself. Listening there costs nothing, but the POSITION check
|
|
4608
|
+
* still needs a scrollport, so nothing fires until an ancestor with its own scrollbar appears (or the
|
|
4609
|
+
* caller drives {@link AutoLoadMore.recheck}). `null` only in a host without a `window` at all.
|
|
4610
|
+
*/
|
|
4611
|
+
function scrollEventSource(scroller) {
|
|
4612
|
+
if (scroller !== null) return scroller;
|
|
4613
|
+
const view = globalThis["window"];
|
|
4614
|
+
if (view === null || view === void 0 || typeof view !== "object") return null;
|
|
4615
|
+
const candidate = view;
|
|
4616
|
+
if (typeof candidate.addEventListener !== "function") return null;
|
|
4617
|
+
if (typeof candidate.removeEventListener !== "function") return null;
|
|
4618
|
+
return view;
|
|
4619
|
+
}
|
|
4620
|
+
/**
|
|
4621
|
+
* The host's animation-frame pair, read off `globalThis` (`lib.dom` is not in this package's types, and
|
|
4622
|
+
* a node host has neither function — the controller then checks synchronously, its pre-rAF behaviour).
|
|
4623
|
+
*/
|
|
4624
|
+
function animationFrames() {
|
|
4625
|
+
const host = globalThis;
|
|
4626
|
+
const candidate = host;
|
|
4627
|
+
if (typeof candidate.requestAnimationFrame !== "function") return null;
|
|
4628
|
+
if (typeof candidate.cancelAnimationFrame !== "function") return null;
|
|
4629
|
+
return host;
|
|
4630
|
+
}
|
|
4631
|
+
/**
|
|
4632
|
+
* Build the controller: listen for scroll (passive + one rAF per burst), check once immediately, and
|
|
4633
|
+
* stay silent while `isEnabled()` is false.
|
|
4634
|
+
*
|
|
4635
|
+
* The immediate first check is not an optimisation: the first page of a short list does not fill the
|
|
4636
|
+
* scrollport, so waiting for a scroll event means waiting forever.
|
|
4637
|
+
*
|
|
4638
|
+
* @param options - see {@link AutoLoadMoreOptions}. Read at creation: `scrollParentOf` runs once, so a
|
|
4639
|
+
* panel that later gets its own scroller must dispose and recreate the controller.
|
|
4640
|
+
*/
|
|
4641
|
+
function createAutoLoadMore(options) {
|
|
4642
|
+
const marginPx = options.marginPx ?? 300;
|
|
4643
|
+
let scroller = scrollParentOf(options.target);
|
|
4644
|
+
let source = scrollEventSource(scroller);
|
|
4645
|
+
const frames = animationFrames();
|
|
4646
|
+
let onLoadMore = options.onLoadMore;
|
|
4647
|
+
let version = options.version ?? 0;
|
|
4648
|
+
let disposed = false;
|
|
4649
|
+
let queued = false;
|
|
4650
|
+
let frame = null;
|
|
4651
|
+
/** Bumped whenever a queued frame is superseded, so a callback the host still runs is a no-op. */
|
|
4652
|
+
let generation = 0;
|
|
4653
|
+
/** Move the listener onto `next`: the window fallback may be holding it, and a stale element listener
|
|
4654
|
+
* would keep listening on a node that no longer scrolls. */
|
|
4655
|
+
const bind = (next) => {
|
|
4656
|
+
if (next === scroller) return;
|
|
4657
|
+
source?.removeEventListener("scroll", onScroll);
|
|
4658
|
+
scroller = next;
|
|
4659
|
+
source = scrollEventSource(next);
|
|
4660
|
+
source?.addEventListener("scroll", onScroll, { passive: true });
|
|
4661
|
+
};
|
|
4662
|
+
const check = () => {
|
|
4663
|
+
frame = null;
|
|
4664
|
+
queued = false;
|
|
4665
|
+
if (disposed || onLoadMore === null) return;
|
|
4666
|
+
if (scroller === null) {
|
|
4667
|
+
const found = scrollParentOf(options.target);
|
|
4668
|
+
if (found === null) return;
|
|
4669
|
+
bind(found);
|
|
4670
|
+
}
|
|
4671
|
+
if (scroller === null) return;
|
|
4672
|
+
if (!options.isEnabled()) return;
|
|
4673
|
+
if (scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - marginPx) onLoadMore();
|
|
4674
|
+
};
|
|
4675
|
+
/**
|
|
4676
|
+
* Drop the queued frame, if there is one, and make any callback already handed to the host inert.
|
|
4677
|
+
*
|
|
4678
|
+
* Called before a SYNC check (`recheck`/`setVersion`): the frame a scroll just scheduled reads the
|
|
4679
|
+
* same position, so leaving it scheduled asks for the same page twice — the second `onLoadMore`
|
|
4680
|
+
* arrives after the caller's own guard may already have cleared. The generation bump is belt and
|
|
4681
|
+
* braces: a host (or a test) that runs a cancelled callback anyway must not ask again.
|
|
4682
|
+
*/
|
|
4683
|
+
const cancelQueued = () => {
|
|
4684
|
+
if (frame !== null && frames !== null) frames.cancelAnimationFrame(frame);
|
|
4685
|
+
frame = null;
|
|
4686
|
+
queued = false;
|
|
4687
|
+
generation += 1;
|
|
4688
|
+
};
|
|
4689
|
+
const onScroll = () => {
|
|
4690
|
+
if (disposed || queued) return;
|
|
4691
|
+
queued = true;
|
|
4692
|
+
if (frames === null) check();
|
|
4693
|
+
else {
|
|
4694
|
+
const scheduled = generation;
|
|
4695
|
+
frame = frames.requestAnimationFrame(() => {
|
|
4696
|
+
if (scheduled !== generation) return;
|
|
4697
|
+
check();
|
|
4698
|
+
});
|
|
4699
|
+
}
|
|
4700
|
+
};
|
|
4701
|
+
source?.addEventListener("scroll", onScroll, { passive: true });
|
|
4702
|
+
check();
|
|
4703
|
+
return {
|
|
4704
|
+
recheck() {
|
|
4705
|
+
if (disposed) return;
|
|
4706
|
+
cancelQueued();
|
|
4707
|
+
check();
|
|
4708
|
+
},
|
|
4709
|
+
setVersion(next) {
|
|
4710
|
+
if (disposed || next === version) return false;
|
|
4711
|
+
version = next;
|
|
4712
|
+
cancelQueued();
|
|
4713
|
+
check();
|
|
4714
|
+
return true;
|
|
4715
|
+
},
|
|
4716
|
+
dispose() {
|
|
4717
|
+
if (disposed) return;
|
|
4718
|
+
disposed = true;
|
|
4719
|
+
onLoadMore = null;
|
|
4720
|
+
source?.removeEventListener("scroll", onScroll);
|
|
4721
|
+
if (frame !== null && frames !== null) frames.cancelAnimationFrame(frame);
|
|
4722
|
+
frame = null;
|
|
4723
|
+
queued = false;
|
|
4724
|
+
}
|
|
4725
|
+
};
|
|
4726
|
+
}
|
|
4727
|
+
//#endregion
|
|
4728
|
+
//#region \0dsh-css:../../../base/src/client/controls.module.css.mjs
|
|
4729
|
+
const css = ".-GlBVa_button{box-sizing:border-box;border-radius:var(--dsw-radius-md);color:var(--dsw-alias-label-primary);font:inherit;cursor:pointer;background:0 0;border:none;justify-content:center;align-items:center;gap:4px;padding:0 14px;font-size:14px;line-height:22px;display:inline-flex}.-GlBVa_button:disabled{cursor:not-allowed;opacity:.4}.-GlBVa_md{height:36px}.-GlBVa_sm{border-radius:var(--dsw-radius-sm);height:28px;padding:0 10px;font-size:12px;line-height:18px}.-GlBVa_primary{background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground)}.-GlBVa_outline{box-shadow:inset 0 0 0 var(--dsh-hairline-width,.5px) var(--dsw-alias-border-l3);background:0 0;border:.5px solid #0000}.-GlBVa_danger{color:var(--dsw-alias-state-error-primary)}.-GlBVa_active{background:var(--dsw-alias-button-ghost-active-fill);box-shadow:inset 0 0 0 var(--dsh-hairline-width,.5px) var(--dsw-alias-button-ghost-active-border);color:var(--dsw-alias-label-primary)}@media (hover:hover){.-GlBVa_primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover)}.-GlBVa_ghost:hover:not(:disabled),.-GlBVa_outline:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.-GlBVa_danger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger)}.-GlBVa_active:hover:not(:disabled){background:var(--dsw-alias-button-ghost-active-hover)}}.-GlBVa_ghost:active:not(:disabled){background:var(--dsw-alias-interactive-bg-active)}.-GlBVa_badge{border-radius:var(--dsw-radius-xs);background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary);align-items:center;gap:4px;padding:0 6px;font-size:11px;line-height:18px;display:inline-flex}.-GlBVa_list{flex-direction:column;gap:8px;margin:0;padding:0;list-style:none;display:flex}.-GlBVa_item{border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-settings-card-fill);color:var(--dsw-alias-label-primary);padding:10px 12px;font-size:13px;line-height:20px}.-GlBVa_meta{color:var(--dsw-alias-label-tertiary);margin-top:2px;font-size:12px;line-height:18px;display:block}.-GlBVa_status{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}.-GlBVa_error{color:var(--dsw-alias-state-error-primary);white-space:pre-wrap;font-size:12px;line-height:18px}.-GlBVa_input,.-GlBVa_textarea,.-GlBVa_select,.-GlBVa_dateInput{box-sizing:border-box;border:.5px solid var(--dsw-alias-border-l4);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-1);min-width:0;color:var(--dsw-alias-label-primary);font:inherit;padding:0 10px;font-size:14px;line-height:22px}.-GlBVa_input,.-GlBVa_select,.-GlBVa_dateInput{height:32px}.-GlBVa_input:focus-visible,.-GlBVa_textarea:focus-visible,.-GlBVa_select:focus-visible,.-GlBVa_dateInput:focus-visible{border-color:var(--dsw-alias-state-business-primary)}.-GlBVa_input::placeholder{color:var(--dsw-alias-label-dimmed)}.-GlBVa_input:disabled,.-GlBVa_textarea:disabled,.-GlBVa_select:disabled,.-GlBVa_dateInput:disabled{opacity:.6;cursor:default}.-GlBVa_textarea{resize:vertical;width:100%;padding:6px 10px}.-GlBVa_select{cursor:pointer}.-GlBVa_dateInput{font-variant-numeric:tabular-nums;padding:0 8px}.-GlBVa_clear{width:20px;height:20px;color:var(--dsw-alias-label-tertiary);font:inherit;cursor:pointer;background:0 0;border:none;border-radius:999px;justify-content:center;align-items:center;padding:0;font-size:14px;line-height:1;display:inline-flex}@media (hover:hover){.-GlBVa_clear:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}}";
|
|
4730
|
+
const tagId = "@avantf/dsh-mem/controls.module.css";
|
|
4731
|
+
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
4732
|
+
const tag = document.createElement("style");
|
|
4733
|
+
tag.dataset.plugin = "@avantf/dsh-mem";
|
|
4734
|
+
tag.dataset.pluginCss = tagId;
|
|
4735
|
+
tag.textContent = css;
|
|
4736
|
+
document.head.appendChild(tag);
|
|
4737
|
+
}
|
|
4738
|
+
var controls_module_css_default = {
|
|
4739
|
+
"active": "-GlBVa_active",
|
|
4740
|
+
"badge": "-GlBVa_badge",
|
|
4741
|
+
"button": "-GlBVa_button",
|
|
4742
|
+
"clear": "-GlBVa_clear",
|
|
4743
|
+
"danger": "-GlBVa_danger",
|
|
4744
|
+
"dateInput": "-GlBVa_dateInput",
|
|
4745
|
+
"error": "-GlBVa_error",
|
|
4746
|
+
"ghost": "-GlBVa_ghost",
|
|
4747
|
+
"input": "-GlBVa_input",
|
|
4748
|
+
"item": "-GlBVa_item",
|
|
4749
|
+
"list": "-GlBVa_list",
|
|
4750
|
+
"md": "-GlBVa_md",
|
|
4751
|
+
"meta": "-GlBVa_meta",
|
|
4752
|
+
"outline": "-GlBVa_outline",
|
|
4753
|
+
"primary": "-GlBVa_primary",
|
|
4754
|
+
"select": "-GlBVa_select",
|
|
4755
|
+
"sm": "-GlBVa_sm",
|
|
4756
|
+
"status": "-GlBVa_status",
|
|
4757
|
+
"textarea": "-GlBVa_textarea"
|
|
4758
|
+
};
|
|
4476
4759
|
//#endregion
|
|
4477
4760
|
//#region src/client/index.ts
|
|
4478
4761
|
/**
|
|
@@ -4501,20 +4784,39 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4501
4784
|
* resolved from the platform module table.
|
|
4502
4785
|
*/
|
|
4503
4786
|
const h = react.createElement;
|
|
4787
|
+
/**
|
|
4788
|
+
* The panel's compact button, composed ONCE: the shared control geometry from the family base
|
|
4789
|
+
* (`controls` is `@avantf/dsh-plugin-base/internal/controls.module.css`, hashed into this bundle at
|
|
4790
|
+
* build time) + the outline variant. Every 按钮 in the panel used to carry its own copy of these
|
|
4791
|
+
* numbers; the sizes/radius/hover now come from the base, so the 记忆 tab and the other family
|
|
4792
|
+
* panels cannot drift apart.
|
|
4793
|
+
*/
|
|
4794
|
+
const BTN = `${controls_module_css_default.button} ${controls_module_css_default.sm} ${controls_module_css_default.outline}`;
|
|
4504
4795
|
const NOT_MOUNTED = "avantfMem Remote 未挂载:本插件自带的 typert contribution 挂载失败(详见控制台错误),暂时无法读取数据。";
|
|
4505
4796
|
/** One 记忆 tab; the host `admin` list default is the same. */
|
|
4506
4797
|
const MEMORY_PAGE_SIZE = 50;
|
|
4507
|
-
/**
|
|
4798
|
+
/**
|
|
4799
|
+
* The page size of a KEYWORD search; fuzzy is fixed at five by the engine.
|
|
4800
|
+
*
|
|
4801
|
+
* A pure time window sends no `limit` at all: it is walked by `offset` instead (`runTimeWindow`), so
|
|
4802
|
+
* the host's own window page applies and no client constant can cut a day's history short.
|
|
4803
|
+
*/
|
|
4508
4804
|
const SEARCH_LIMIT = 10;
|
|
4509
4805
|
/**
|
|
4806
|
+
* How many pages one time-window search may walk before the panel stops.
|
|
4807
|
+
*
|
|
4808
|
+
* A wall against a host that never reports `remaining: 0` (or never returns rows): 200 pages of 50 is
|
|
4809
|
+
* 10 000 facts, far beyond any hand-readable window. Stopping early is visible — the panel prints how
|
|
4810
|
+
* many rows are still unseen.
|
|
4811
|
+
*/
|
|
4812
|
+
const SEARCH_PAGES_MAX = 200;
|
|
4813
|
+
/**
|
|
4510
4814
|
* How many `admin.detail` calls one search answer may trigger, to give its results the list's action
|
|
4511
4815
|
* row (status / pin state / full text). A hard cap so a large exact page or a wide relation answer
|
|
4512
4816
|
* cannot turn one keystroke into an unbounded fan-out; anything past the cap keeps the id-only
|
|
4513
4817
|
* actions (详情 / 编辑 when the excerpt is complete / 归档 / 反馈), which still work.
|
|
4514
4818
|
*/
|
|
4515
4819
|
const SEARCH_FACT_LOOKUPS_MAX = 20;
|
|
4516
|
-
/** How close (px) the list's end must come to the scrollport before the next page loads. */
|
|
4517
|
-
const AUTO_LOAD_MARGIN_PX = 300;
|
|
4518
4820
|
/**
|
|
4519
4821
|
* The wire revision the host last reported, and whether its absence/mismatch was logged.
|
|
4520
4822
|
*
|
|
@@ -4587,67 +4889,57 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4587
4889
|
isCurrent: (seq) => seq === ref.current
|
|
4588
4890
|
}), []);
|
|
4589
4891
|
}
|
|
4590
|
-
/** The element that actually scrolls this panel, walking up to it (the conversation's scroll body). */
|
|
4591
|
-
function scrollParentOf(node) {
|
|
4592
|
-
let current = node?.parentElement ?? null;
|
|
4593
|
-
while (current !== null) {
|
|
4594
|
-
const overflowY = getComputedStyle(current).overflowY;
|
|
4595
|
-
if ((overflowY === "auto" || overflowY === "scroll") && current.scrollHeight > current.clientHeight) return current;
|
|
4596
|
-
current = current.parentElement;
|
|
4597
|
-
}
|
|
4598
|
-
return null;
|
|
4599
|
-
}
|
|
4600
4892
|
/**
|
|
4601
4893
|
* Call `onLoadMore` when the user scrolls near the end of the panel.
|
|
4602
4894
|
*
|
|
4603
|
-
*
|
|
4604
|
-
*
|
|
4895
|
+
* The behaviour itself now lives in the base — `createAutoLoadMore` on
|
|
4896
|
+
* `@avantf/dsh-plugin-base/internal` — and this function is only its React half: refs for the two
|
|
4897
|
+
* values the controller reads live (`onLoadMore`, `enabled`), one effect that owns the controller's
|
|
4898
|
+
* lifetime, and the two effects that drive the checks React knows about. Why the kit is in the base:
|
|
4899
|
+
* mission pages its lists the same way, and the DOM detail (walking up to the element that really
|
|
4900
|
+
* scrolls, the passive listener with its rAF throttle, the window fallback) must exist ONCE. The base
|
|
4901
|
+
* has no React peer, so the kit is plain DOM and each plugin keeps this thin wrapper.
|
|
4902
|
+
*
|
|
4903
|
+
* The two facts this wrapper is responsible for keeping (the kit cannot see them by itself):
|
|
4904
|
+
*
|
|
4905
|
+
* - **`version` re-checks after a page lands.** A page that does not fill the scrollport produces no
|
|
4906
|
+
* scroll event; without the re-check loading would stop after the first page.
|
|
4907
|
+
* - **`enabled` flipping back on retries once.** That is how a page that landed (or a list that
|
|
4908
|
+
* gained a continuation) keeps loading while the user sits still.
|
|
4605
4909
|
*
|
|
4606
|
-
*
|
|
4607
|
-
*
|
|
4608
|
-
*
|
|
4609
|
-
* 2. **An observer only reports threshold CHANGES.** One page that failed to load left the sentinel
|
|
4610
|
-
* visible, so no new callback arrived and auto-load stayed dead until something else moved it.
|
|
4611
|
-
* A scroll listener is level-triggered: the next scroll event tries again.
|
|
4612
|
-
* 3. **The scrollport is found, not assumed.** `scrollParentOf` walks up to whatever element really
|
|
4613
|
-
* scrolls, so this keeps working if the panel ever gets its own scroller.
|
|
4910
|
+
* Both are one effect on purpose: a page landing changes `rows.length` AND clears `loading`, so the
|
|
4911
|
+
* two reasons arrive in the SAME commit — and two checks would send the next page twice. The kit's
|
|
4912
|
+
* `setVersion` reports whether it already checked, which is what keeps it at one.
|
|
4614
4913
|
*
|
|
4615
|
-
*
|
|
4616
|
-
* scroll event, and without the re-check loading would stop after the first page.
|
|
4914
|
+
* The observer-vs-listener reasoning and the margin live in the kit's own doc (one copy).
|
|
4617
4915
|
*/
|
|
4618
4916
|
function useLoadMoreOnScroll(rootRef, enabled, onLoadMore, version) {
|
|
4619
4917
|
const latest = react.useRef(onLoadMore);
|
|
4620
4918
|
latest.current = onLoadMore;
|
|
4621
4919
|
const enabledRef = react.useRef(enabled);
|
|
4622
4920
|
enabledRef.current = enabled;
|
|
4623
|
-
const
|
|
4921
|
+
const controllerRef = react.useRef(null);
|
|
4624
4922
|
react.useEffect(() => {
|
|
4625
4923
|
const root = rootRef.current;
|
|
4626
4924
|
if (root === null) return void 0;
|
|
4627
|
-
const
|
|
4628
|
-
|
|
4629
|
-
|
|
4630
|
-
|
|
4631
|
-
|
|
4632
|
-
|
|
4633
|
-
|
|
4634
|
-
};
|
|
4635
|
-
|
|
4636
|
-
const onScroll = () => {
|
|
4637
|
-
if (queued) return;
|
|
4638
|
-
queued = true;
|
|
4639
|
-
if (typeof requestAnimationFrame === "function") requestAnimationFrame(check);
|
|
4640
|
-
else check();
|
|
4641
|
-
};
|
|
4642
|
-
target.addEventListener("scroll", onScroll, { passive: true });
|
|
4643
|
-
check();
|
|
4925
|
+
const controller = createAutoLoadMore({
|
|
4926
|
+
target: root,
|
|
4927
|
+
isEnabled: () => enabledRef.current,
|
|
4928
|
+
onLoadMore: () => {
|
|
4929
|
+
latest.current();
|
|
4930
|
+
},
|
|
4931
|
+
version
|
|
4932
|
+
});
|
|
4933
|
+
controllerRef.current = controller;
|
|
4644
4934
|
return () => {
|
|
4645
|
-
|
|
4646
|
-
|
|
4935
|
+
controllerRef.current = null;
|
|
4936
|
+
controller.dispose();
|
|
4647
4937
|
};
|
|
4648
4938
|
}, [rootRef]);
|
|
4649
4939
|
react.useEffect(() => {
|
|
4650
|
-
|
|
4940
|
+
const controller = controllerRef.current;
|
|
4941
|
+
if (controller === null) return;
|
|
4942
|
+
if (!controller.setVersion(version)) controller.recheck();
|
|
4651
4943
|
}, [version, enabled]);
|
|
4652
4944
|
}
|
|
4653
4945
|
/**
|
|
@@ -4675,7 +4967,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4675
4967
|
function Btn(props) {
|
|
4676
4968
|
return h("button", {
|
|
4677
4969
|
type: "button",
|
|
4678
|
-
className: props.danger ? `${
|
|
4970
|
+
className: props.danger ? `${BTN} ${controls_module_css_default.danger}` : BTN,
|
|
4679
4971
|
disabled: props.disabled === true,
|
|
4680
4972
|
onClick: props.onClick
|
|
4681
4973
|
}, props.label);
|
|
@@ -4700,7 +4992,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4700
4992
|
});
|
|
4701
4993
|
const complete = draft.subject.trim() !== "" && draft.predicate.trim() !== "" && draft.object.trim() !== "";
|
|
4702
4994
|
const field = (slot, label) => h("input", {
|
|
4703
|
-
className:
|
|
4995
|
+
className: controls_module_css_default.input,
|
|
4704
4996
|
value: draft[slot],
|
|
4705
4997
|
placeholder: label,
|
|
4706
4998
|
"aria-label": `关系${label}`,
|
|
@@ -4712,9 +5004,9 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4712
5004
|
}));
|
|
4713
5005
|
}
|
|
4714
5006
|
});
|
|
4715
|
-
return h("div", { className: pages_module_css_default.detail }, h("div", { className:
|
|
5007
|
+
return h("div", { className: pages_module_css_default.detail }, h("div", { className: controls_module_css_default.meta }, "声明的显式关系(仅 note;图上的 source=explicit):"), relations.length === 0 ? h("div", { className: controls_module_css_default.status }, "当前没有显式关系。") : h("ul", { className: controls_module_css_default.list }, relations.map((relation, index) => h("li", {
|
|
4716
5008
|
key: `relation-${String(index)}`,
|
|
4717
|
-
className:
|
|
5009
|
+
className: controls_module_css_default.item
|
|
4718
5010
|
}, `${relation.subject} —${relation.predicate}→ ${relation.object}`, h("div", { className: pages_module_css_default.row }, h(Btn, {
|
|
4719
5011
|
label: "删除",
|
|
4720
5012
|
danger: true,
|
|
@@ -4743,6 +5035,15 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4743
5035
|
}
|
|
4744
5036
|
})));
|
|
4745
5037
|
}
|
|
5038
|
+
/** A list with nothing in it: the list view before its first page, and a search that was cleared. */
|
|
5039
|
+
const EMPTY_LIST = {
|
|
5040
|
+
rows: [],
|
|
5041
|
+
total: void 0,
|
|
5042
|
+
truncated: false,
|
|
5043
|
+
nextOffset: 0,
|
|
5044
|
+
loading: false,
|
|
5045
|
+
error: void 0
|
|
5046
|
+
};
|
|
4746
5047
|
/**
|
|
4747
5048
|
* The detail body of one fact — the SAME view under an admin list row and under the search results
|
|
4748
5049
|
* (the search view used to hide the list, which is why its 「详情」 button appeared to do nothing).
|
|
@@ -4752,7 +5053,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4752
5053
|
*/
|
|
4753
5054
|
function FactDetailView(props) {
|
|
4754
5055
|
const fact = props.fact;
|
|
4755
|
-
return h("div", { className:
|
|
5056
|
+
return h("div", { className: controls_module_css_default.meta }, `标题:${fact.title ?? "(无标题)"}`, h("br"), `创建于 ${fact.created_at} / 更新于 ${fact.updated_at ?? "—"}`, h("br"), `实体:${(fact.entities ?? []).join("、") || "(无)"}`, h("br"), `三元组:${(fact.triples ?? []).map((t) => `${t.subj} —${t.pred}→ ${t.obj}(${t.confidence.toFixed(2)})`).join(";") || "(无)"}`, h("br"), `检索次数:${String(fact.retrieval_count ?? 0)}${fact.supersedes_id ? ` · 修订自 #${String(fact.supersedes_id)}` : ""} · 被断言 ${String(fact.assert_count ?? 1)} 次`, h("br"), `有效期:${fact.valid_from ?? "未知"} → ${fact.valid_to ?? (fact.status === "active" ? "仍在有效" : "未知")}` + (fact.superseded_by === null || fact.superseded_by === void 0 ? "" : ` · 被 #${String(fact.superseded_by)} 取代`), h("br"), `来源:${(fact.sources ?? []).length === 0 ? "(未记录)" : (fact.sources ?? []).map((s) => `${s.kind}:${s.ref}`).join(";")}`, h("br"), `关系:${(fact.relations ?? []).length === 0 ? "(无)" : (fact.relations ?? []).map((r) => `${r.subject} —${r.predicate}→ ${r.object}`).join(";")}`, fact.kind === "note" && fact.status === "active" ? h(RelationEditor, {
|
|
4756
5057
|
fact,
|
|
4757
5058
|
disabled: props.disabled,
|
|
4758
5059
|
onSave: (relations) => {
|
|
@@ -4760,78 +5061,265 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4760
5061
|
}
|
|
4761
5062
|
}) : null);
|
|
4762
5063
|
}
|
|
5064
|
+
/**
|
|
5065
|
+
* An `admin.list` row, in the unified shape.
|
|
5066
|
+
*
|
|
5067
|
+
* The meta line is the list's own (trust, retention, counts); a search answer's row states what THAT
|
|
5068
|
+
* answer can honestly say instead (see the four builders below), which is why this text belongs to the
|
|
5069
|
+
* row and not to the renderer.
|
|
5070
|
+
*/
|
|
5071
|
+
function listRow(f) {
|
|
5072
|
+
return {
|
|
5073
|
+
kind: "fact",
|
|
5074
|
+
key: `fact:${String(f.fact_id)}`,
|
|
5075
|
+
fact_id: f.fact_id,
|
|
5076
|
+
title: f.title ?? (f.kind === "journal" ? "(无标题)" : void 0),
|
|
5077
|
+
text: f.content,
|
|
5078
|
+
truncated: false,
|
|
5079
|
+
meta: `#${String(f.fact_id)} · ${f.kind} · trust ${f.trust_score.toFixed(2)}` + (f.pinned === true ? " · 永久" : typeof f.remaining_days === "number" ? ` · 剩余 ≈${String(Math.ceil(f.remaining_days))} 活跃日` : "") + ` · 有用 ${String(f.helpful_count)} · ${f.created_at}` + (f.status === "archived" && f.archive_reason ? ` · 归档原因 ${f.archive_reason}` : "") + (f.status === "archived" && f.pinned === true ? " · 永久保留(不会被清理)" : ""),
|
|
5080
|
+
action: {
|
|
5081
|
+
fact_id: f.fact_id,
|
|
5082
|
+
status: f.status,
|
|
5083
|
+
content: f.content,
|
|
5084
|
+
pinned: f.pinned === true
|
|
5085
|
+
},
|
|
5086
|
+
fromSearch: false
|
|
5087
|
+
};
|
|
5088
|
+
}
|
|
5089
|
+
/** A keyword (`exact`/`fuzzy`) hit: the one result shape that carries a relevance score. */
|
|
5090
|
+
function hitRow(hit, action) {
|
|
5091
|
+
return {
|
|
5092
|
+
kind: "fact",
|
|
5093
|
+
key: `fact:${String(hit.ref_id)}`,
|
|
5094
|
+
fact_id: hit.ref_id,
|
|
5095
|
+
title: hit.title,
|
|
5096
|
+
text: hit.text,
|
|
5097
|
+
truncated: hit.truncated === true,
|
|
5098
|
+
meta: `#${String(hit.ref_id)} · score ${hit.score.toFixed(2)} · ${hit.source_ref}` + (hit.valid_from === void 0 ? "" : ` · ${hit.valid_from}`),
|
|
5099
|
+
action,
|
|
5100
|
+
fromSearch: true
|
|
5101
|
+
};
|
|
5102
|
+
}
|
|
5103
|
+
/** One row of a fact-level AND page (`mode:"facts"`) — a fact, not a ranked hit: no score. */
|
|
5104
|
+
function factPageRow(entry, action) {
|
|
5105
|
+
return {
|
|
5106
|
+
kind: "fact",
|
|
5107
|
+
key: `fact:${String(entry.fact_id)}`,
|
|
5108
|
+
fact_id: entry.fact_id,
|
|
5109
|
+
title: entry.title,
|
|
5110
|
+
text: entry.content,
|
|
5111
|
+
truncated: entry.truncated === true,
|
|
5112
|
+
meta: `#${String(entry.fact_id)} · ${entry.memory_kind} · ${entry.status} · ${entry.event_date ?? entry.created_at}` + (entry.source_ref === void 0 ? "" : ` · ${entry.source_ref}`),
|
|
5113
|
+
action,
|
|
5114
|
+
fromSearch: true
|
|
5115
|
+
};
|
|
5116
|
+
}
|
|
5117
|
+
/** One evidence fact of a relation answer — again a fact, not a ranked hit: no score. */
|
|
5118
|
+
function evidenceRow(entry, action) {
|
|
5119
|
+
return {
|
|
5120
|
+
kind: "fact",
|
|
5121
|
+
key: `fact:${String(entry.fact_id)}`,
|
|
5122
|
+
fact_id: entry.fact_id,
|
|
5123
|
+
title: entry.title,
|
|
5124
|
+
text: entry.text,
|
|
5125
|
+
truncated: entry.truncated === true,
|
|
5126
|
+
meta: `#${String(entry.fact_id)} · ${entry.memory_kind} · ${entry.created_at}`,
|
|
5127
|
+
action,
|
|
5128
|
+
fromSearch: true
|
|
5129
|
+
};
|
|
5130
|
+
}
|
|
5131
|
+
/** A relation answer's path: the ONE row body that is not a fact, still a row of the same list. */
|
|
5132
|
+
function pathRow(path, index) {
|
|
5133
|
+
return {
|
|
5134
|
+
kind: "path",
|
|
5135
|
+
key: `path:${String(index)}`,
|
|
5136
|
+
path
|
|
5137
|
+
};
|
|
5138
|
+
}
|
|
5139
|
+
/**
|
|
5140
|
+
* The ONE list renderer of the 记忆 tab: the admin list and every search answer draw through it.
|
|
5141
|
+
*
|
|
5142
|
+
* It owns the parts that used to be written twice — the row body, the 「加载更多(还有 N 条)」 control at
|
|
5143
|
+
* the end, the empty state and the error line — and it reads ONE state ({@link ListState}), so the list
|
|
5144
|
+
* and a result set cannot disagree about whether more rows exist, why the last page failed, or whether
|
|
5145
|
+
* scrolling should load.
|
|
5146
|
+
*
|
|
5147
|
+
* The rows arrive already normalized (`listRow` / `hitRow` / `factPageRow` / `evidenceRow` / `pathRow`):
|
|
5148
|
+
* a fact row carries its text, meta line and resolved action set, so this component never needs to know
|
|
5149
|
+
* which face it came from. The row actions themselves come from the panel (`actionRow`), which is where
|
|
5150
|
+
* the ONE action builder lives (`factButtons`).
|
|
5151
|
+
*/
|
|
5152
|
+
function FactList(props) {
|
|
5153
|
+
const state = props.state;
|
|
5154
|
+
const remaining = state.total === void 0 ? 0 : Math.max(state.total - state.rows.length, 0);
|
|
5155
|
+
return h("div", null, props.note === void 0 ? null : h("div", { className: controls_module_css_default.status }, props.note), state.error === void 0 ? null : h("div", { className: controls_module_css_default.error }, `错误:${state.error}`), state.rows.length === 0 ? state.error === void 0 ? h("div", { className: controls_module_css_default.status }, state.loading ? props.loadingText : props.emptyText) : null : h("ul", { className: controls_module_css_default.list }, state.rows.map((row) => h("li", {
|
|
5156
|
+
key: row.key,
|
|
5157
|
+
className: controls_module_css_default.item
|
|
5158
|
+
}, row.kind === "path" ? [h("div", { key: "route" }, [row.path.start.name, ...row.path.edges.flatMap((edge) => [edge.predicate, edge.object])].join(" → ")), h("span", {
|
|
5159
|
+
key: "meta",
|
|
5160
|
+
className: controls_module_css_default.meta
|
|
5161
|
+
}, `${String(row.path.hops)} 跳 · #${row.path.edges.map((edge) => String(edge.fact_id)).join(" / ")} · 置信度 ${row.path.confidence.toFixed(2)}`)] : [
|
|
5162
|
+
row.title === void 0 ? null : h("div", {
|
|
5163
|
+
key: "title",
|
|
5164
|
+
className: controls_module_css_default.meta
|
|
5165
|
+
}, `标题:${row.title}`),
|
|
5166
|
+
props.editing?.id === row.fact_id ? h("textarea", {
|
|
5167
|
+
key: "edit",
|
|
5168
|
+
className: `${controls_module_css_default.textarea} ${pages_module_css_default.edit}`,
|
|
5169
|
+
value: props.editing.text,
|
|
5170
|
+
rows: 3,
|
|
5171
|
+
onChange: (e) => {
|
|
5172
|
+
props.onEdit({
|
|
5173
|
+
id: row.fact_id,
|
|
5174
|
+
text: e.target.value
|
|
5175
|
+
});
|
|
5176
|
+
}
|
|
5177
|
+
}) : row.text,
|
|
5178
|
+
row.truncated ? h("span", {
|
|
5179
|
+
key: "cut",
|
|
5180
|
+
className: controls_module_css_default.meta
|
|
5181
|
+
}, "(已截断)") : null,
|
|
5182
|
+
h("span", {
|
|
5183
|
+
key: "meta",
|
|
5184
|
+
className: controls_module_css_default.meta
|
|
5185
|
+
}, row.meta),
|
|
5186
|
+
h("div", {
|
|
5187
|
+
key: "actions",
|
|
5188
|
+
className: pages_module_css_default.row
|
|
5189
|
+
}, props.actionRow(row.action, row.fromSearch)),
|
|
5190
|
+
props.detail?.id === row.fact_id ? h(FactDetailView, {
|
|
5191
|
+
key: "detail",
|
|
5192
|
+
fact: props.detail.data,
|
|
5193
|
+
disabled: props.busy,
|
|
5194
|
+
onSaveRelations: props.onSaveRelations
|
|
5195
|
+
}) : null
|
|
5196
|
+
]))), state.truncated ? h("button", {
|
|
5197
|
+
type: "button",
|
|
5198
|
+
className: `${BTN} ${pages_module_css_default.more}`,
|
|
5199
|
+
disabled: state.loading,
|
|
5200
|
+
onClick: props.onLoadMore
|
|
5201
|
+
}, state.loading ? "加载中…" : state.total === void 0 ? "加载更多" : `加载更多(还有 ${String(remaining)} 条)`) : null);
|
|
5202
|
+
}
|
|
4763
5203
|
function MemoryPanel(props) {
|
|
4764
5204
|
const [statusFilter, setStatusFilter] = react.useState("active");
|
|
4765
|
-
const [rows, setRows] = react.useState([]);
|
|
4766
|
-
const [total, setTotal] = react.useState();
|
|
4767
|
-
const [truncated, setTruncated] = react.useState(false);
|
|
4768
5205
|
/**
|
|
4769
|
-
*
|
|
4770
|
-
*
|
|
4771
|
-
* repeats one row), and using the de-duplicated length as the next offset would re-request it.
|
|
5206
|
+
* The list the panel renders — the ONE list state (see {@link ListState}). `admin.list` fills it
|
|
5207
|
+
* while no search is applied; `recall.search` fills it while one is.
|
|
4772
5208
|
*/
|
|
4773
|
-
const [
|
|
5209
|
+
const [list, setList] = react.useState({
|
|
5210
|
+
...EMPTY_LIST,
|
|
5211
|
+
loading: props.remote !== void 0
|
|
5212
|
+
});
|
|
5213
|
+
/** The search the visible rows came from (`null` = the list view). See {@link AppliedSearch}. */
|
|
5214
|
+
const [applied, setApplied] = react.useState(null);
|
|
5215
|
+
/** Everything that is NOT the list: a mutation's message, a detail/health/conflict failure. */
|
|
4774
5216
|
const [error, setError] = react.useState();
|
|
4775
5217
|
const [notice, setNotice] = react.useState();
|
|
4776
|
-
|
|
5218
|
+
/** A mutation in flight: the panel's buttons and the row actions obey it. */
|
|
5219
|
+
const [busy, setBusy] = react.useState(false);
|
|
4777
5220
|
const [editing, setEditing] = react.useState(null);
|
|
4778
5221
|
const [detail, setDetail] = react.useState(null);
|
|
4779
5222
|
const [contradictions, setContradictions] = react.useState(null);
|
|
4780
5223
|
const [health, setHealth] = react.useState(null);
|
|
4781
5224
|
const [searchText, setSearchText] = react.useState("");
|
|
4782
5225
|
const [searchKind, setSearchKind] = react.useState("all");
|
|
4783
|
-
const [
|
|
4784
|
-
const [
|
|
4785
|
-
const [
|
|
5226
|
+
const [timeField, setTimeField] = react.useState("created");
|
|
5227
|
+
const [timeMode, setTimeMode] = react.useState("on");
|
|
5228
|
+
const [timeOn, setTimeOn] = react.useState("");
|
|
5229
|
+
const [timeFrom, setTimeFrom] = react.useState("");
|
|
5230
|
+
const [timeTo, setTimeTo] = react.useState("");
|
|
4786
5231
|
/**
|
|
4787
|
-
* What
|
|
5232
|
+
* What a search result needs so its action row can match an admin list row:
|
|
4788
5233
|
* status (归档 vs 恢复), pin state (永久 vs 解除) and the FULL content (a hit's `text` may be
|
|
4789
5234
|
* truncated by the output budget, and editing a truncated excerpt would corrupt the fact).
|
|
4790
5235
|
* Read through `admin.detail` — one call per result, bounded by {@link SEARCH_FACT_LOOKUPS_MAX}.
|
|
4791
5236
|
*/
|
|
4792
5237
|
const [searchFacts, setSearchFacts] = react.useState({});
|
|
5238
|
+
/** The query is on its way: the title line and the 搜索 button say so. */
|
|
4793
5239
|
const [searching, setSearching] = react.useState(false);
|
|
4794
5240
|
const detailGuard = useSeqGuard();
|
|
4795
5241
|
const contradictionGuard = useSeqGuard();
|
|
4796
5242
|
const healthGuard = useSeqGuard();
|
|
4797
|
-
|
|
5243
|
+
/**
|
|
5244
|
+
* The list's guard — and the search's: both write the SAME state now, so one guard is what keeps a
|
|
5245
|
+
* late page from overwriting a newer one. It matters most on the scroll path, which calls `loadMore()`
|
|
5246
|
+
* with no user action: a page requested for 活动 can land after the user switched to 归档 (appending
|
|
5247
|
+
* active rows to the archived list) or after a search replaced the rows.
|
|
5248
|
+
*/
|
|
4798
5249
|
const listGuard = useSeqGuard();
|
|
4799
5250
|
/**
|
|
4800
|
-
*
|
|
4801
|
-
* page
|
|
5251
|
+
* The action-state guard: a `loadSearchFacts` reply belongs to the SEARCH that asked for it, not to the
|
|
5252
|
+
* page request that happened to carry it. `listGuard` cannot serve here — every page begins a new one,
|
|
5253
|
+
* so a continuation page would drop the lookups the previous page had in flight (their rows are on
|
|
5254
|
+
* screen, so their actions must land). ONE generation per search; the generation is kept in a ref
|
|
5255
|
+
* because `loadSearchPage` asks for it long after `runSearch` began it.
|
|
5256
|
+
*/
|
|
5257
|
+
const searchFactsGuard = useSeqGuard();
|
|
5258
|
+
const searchFactsSeq = react.useRef(0);
|
|
5259
|
+
/**
|
|
5260
|
+
* The re-entrancy wall of `loadMore`, as a REF (REVIEW-2026-10-27 P2-1).
|
|
5261
|
+
*
|
|
5262
|
+
* `list.loading` is React state: two checks in the same tick (the scroll controller's queued frame plus
|
|
5263
|
+
* a re-check, or the end-of-list button and a scroll) both read the value from the render that is
|
|
5264
|
+
* already committed and would send the same page twice. `listGuard` then throws away the older reply,
|
|
5265
|
+
* but the RPC has been made. Set on entry, cleared in `finally`, so the wall is visible synchronously.
|
|
5266
|
+
*/
|
|
5267
|
+
const loadingMoreRef = react.useRef(false);
|
|
5268
|
+
/**
|
|
5269
|
+
* Load one page of the ADMIN list into the unified state (offset 0 replaces it), resolving once it
|
|
5270
|
+
* settled so callers can order their own updates after the refresh.
|
|
4802
5271
|
*/
|
|
4803
|
-
const
|
|
5272
|
+
const loadListPage = react.useCallback(async (offset) => {
|
|
4804
5273
|
if (props.remote === void 0) return;
|
|
4805
5274
|
const seq = listGuard.begin();
|
|
4806
|
-
|
|
4807
|
-
|
|
4808
|
-
|
|
5275
|
+
setList((previous) => ({
|
|
5276
|
+
...previous,
|
|
5277
|
+
loading: true
|
|
5278
|
+
}));
|
|
5279
|
+
const outcome = await callRemote(`admin.list(status=${statusFilter}, offset=${String(offset)})`, props.remote.admin({
|
|
4809
5280
|
action: "list",
|
|
4810
|
-
status,
|
|
5281
|
+
status: statusFilter,
|
|
4811
5282
|
limit: MEMORY_PAGE_SIZE,
|
|
4812
5283
|
offset
|
|
4813
5284
|
}));
|
|
4814
5285
|
if (!listGuard.isCurrent(seq)) return;
|
|
4815
|
-
|
|
5286
|
+
setList((previous) => {
|
|
5287
|
+
if (!outcome.ok) return offset === 0 ? {
|
|
5288
|
+
...EMPTY_LIST,
|
|
5289
|
+
error: outcome.error
|
|
5290
|
+
} : {
|
|
5291
|
+
...previous,
|
|
5292
|
+
loading: false,
|
|
5293
|
+
error: outcome.error
|
|
5294
|
+
};
|
|
4816
5295
|
const page = outcome.value?.facts ?? [];
|
|
4817
|
-
|
|
4818
|
-
|
|
4819
|
-
|
|
4820
|
-
|
|
4821
|
-
|
|
4822
|
-
|
|
4823
|
-
|
|
4824
|
-
|
|
4825
|
-
|
|
4826
|
-
|
|
4827
|
-
|
|
4828
|
-
|
|
4829
|
-
|
|
4830
|
-
|
|
4831
|
-
|
|
5296
|
+
const rows = page.map(listRow);
|
|
5297
|
+
return {
|
|
5298
|
+
rows: offset === 0 ? rows : mergeRows(previous.rows, rows),
|
|
5299
|
+
total: outcome.value?.total,
|
|
5300
|
+
truncated: outcome.value?.truncated === true,
|
|
5301
|
+
nextOffset: offset + page.length,
|
|
5302
|
+
loading: false,
|
|
5303
|
+
error: void 0
|
|
5304
|
+
};
|
|
5305
|
+
});
|
|
5306
|
+
}, [
|
|
5307
|
+
listGuard,
|
|
5308
|
+
props.remote,
|
|
5309
|
+
statusFilter
|
|
5310
|
+
]);
|
|
5311
|
+
/**
|
|
5312
|
+
* The list view's source: `admin.list`, page 0.
|
|
5313
|
+
*
|
|
5314
|
+
* It runs on mount, on a status switch and when a search is cleared — and NEVER while a search owns
|
|
5315
|
+
* the list, or an admin page would overwrite the rows the search put on screen. `applied` decides the
|
|
5316
|
+
* source here exactly as it does for the next page.
|
|
5317
|
+
*/
|
|
5318
|
+
const listView = applied === null;
|
|
4832
5319
|
react.useEffect(() => {
|
|
4833
|
-
|
|
4834
|
-
|
|
5320
|
+
if (!listView) return;
|
|
5321
|
+
loadListPage(0);
|
|
5322
|
+
}, [listView, loadListPage]);
|
|
4835
5323
|
/**
|
|
4836
5324
|
* Resolve the state of the facts a search answer points at (`admin.detail`, one call each).
|
|
4837
5325
|
*
|
|
@@ -4839,15 +5327,21 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4839
5327
|
* know the id and an excerpt, and would have to guess 「归档/恢复」 and 「永久/解除」 — two labels
|
|
4840
5328
|
* whose opposite is a different operation. Results are bounded (search is a fixed shortlist, and
|
|
4841
5329
|
* relation evidence is the paths' facts), deduplicated, and dropped if a newer search started.
|
|
5330
|
+
*
|
|
5331
|
+
* A CONTINUATION page calls it too (REVIEW-2026-10-27 P2-2): without that, every row a scroll brought
|
|
5332
|
+
* in kept the id-only action set until the query was run again. The lookup is per page, so the bound
|
|
5333
|
+
* above still holds — one page can resolve at most {@link SEARCH_FACT_LOOKUPS_MAX} of its rows and the
|
|
5334
|
+
* rest keep the id-only actions (详情 / 编辑 / 归档 / 反馈), exactly like the first page's overflow.
|
|
5335
|
+
* The record is MERGED, so an append never drops what the first page resolved.
|
|
5336
|
+
*
|
|
5337
|
+
* The guard is the SEARCH's, not the page request's (`searchFactsGuard`): a page landing while the
|
|
5338
|
+
* previous page's lookups are still in flight must not throw them away — those facts are on screen.
|
|
4842
5339
|
*/
|
|
4843
5340
|
const loadSearchFacts = (seq, ids) => {
|
|
4844
5341
|
const remote = props.remote;
|
|
4845
5342
|
if (remote === void 0) return;
|
|
4846
5343
|
const unique = [...new Set(ids)].slice(0, SEARCH_FACT_LOOKUPS_MAX);
|
|
4847
|
-
if (unique.length === 0)
|
|
4848
|
-
setSearchFacts({});
|
|
4849
|
-
return;
|
|
4850
|
-
}
|
|
5344
|
+
if (unique.length === 0) return;
|
|
4851
5345
|
Promise.all(unique.map(async (id) => ({
|
|
4852
5346
|
id,
|
|
4853
5347
|
outcome: await callRemote(`admin.detail(${String(id)})`, remote.admin({
|
|
@@ -4855,7 +5349,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4855
5349
|
fact_id: id
|
|
4856
5350
|
}))
|
|
4857
5351
|
}))).then((results) => {
|
|
4858
|
-
if (!
|
|
5352
|
+
if (!searchFactsGuard.isCurrent(seq)) return;
|
|
4859
5353
|
const next = {};
|
|
4860
5354
|
for (const { id, outcome } of results) {
|
|
4861
5355
|
if (!outcome.ok) continue;
|
|
@@ -4867,104 +5361,319 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4867
5361
|
content: value.content
|
|
4868
5362
|
};
|
|
4869
5363
|
}
|
|
4870
|
-
setSearchFacts(
|
|
5364
|
+
setSearchFacts((previous) => ({
|
|
5365
|
+
...previous,
|
|
5366
|
+
...next
|
|
5367
|
+
}));
|
|
4871
5368
|
});
|
|
4872
5369
|
};
|
|
4873
|
-
/**
|
|
5370
|
+
/**
|
|
5371
|
+
* Walk a pure time window page by page and hand back every row in it.
|
|
5372
|
+
*
|
|
5373
|
+
* The request sends NO `limit`: the host pages a window at its own default (`mode:"time"`, 50 rows)
|
|
5374
|
+
* and says how much is left in `remaining`. The panel shows the WHOLE window — "what happened that
|
|
5375
|
+
* day" is the question, and answering it with the first 50 of 286 rows would hide most of the day.
|
|
5376
|
+
* Walking `offset` (never a client-side `limit`) is what reconciles the two: 286 facts arrive as six
|
|
5377
|
+
* pages, which is exactly what `offset` is for, and the host's cap stays untouched.
|
|
5378
|
+
*
|
|
5379
|
+
* `null` rows means a newer search took over (`listGuard`); the caller must then stop without touching
|
|
5380
|
+
* the list. `error` is reported with whatever rows were already collected, so a failure on page 3
|
|
5381
|
+
* still shows pages 1-2 rather than nothing.
|
|
5382
|
+
*
|
|
5383
|
+
* The rows are de-duplicated ACROSS pages (see the loop's note): a walk is one render of the whole
|
|
5384
|
+
* window, so a row repeated because the offset drifted would be a duplicate React key on the very
|
|
5385
|
+
* first paint, not just on a later scroll.
|
|
5386
|
+
*
|
|
5387
|
+
* A walk that stops early is RESUMABLE: it hands back the host-handed `nextOffset` it reached and the
|
|
5388
|
+
* window's `total`, and the unified list state keeps `truncated = nextOffset < total` so `loadMore()`
|
|
5389
|
+
* continues from there on the next scroll instead of leaving 「还有 K 条未显示」 as a dead end.
|
|
5390
|
+
*/
|
|
5391
|
+
const runTimeWindow = async (remote, seq, time) => {
|
|
5392
|
+
const pages = [];
|
|
5393
|
+
let total = null;
|
|
5394
|
+
let offset = 0;
|
|
5395
|
+
for (let page = 0; page < SEARCH_PAGES_MAX; page++) {
|
|
5396
|
+
const outcome = await callRemote(`recall.search(time, offset=${String(offset)}${searchKind === "all" ? "" : `, kind=${searchKind}`})`, remote.recall({
|
|
5397
|
+
action: "search",
|
|
5398
|
+
...searchKind === "all" ? {} : { kind: searchKind },
|
|
5399
|
+
time,
|
|
5400
|
+
offset
|
|
5401
|
+
}));
|
|
5402
|
+
if (!listGuard.isCurrent(seq)) return {
|
|
5403
|
+
rows: null,
|
|
5404
|
+
total: null,
|
|
5405
|
+
nextOffset: offset
|
|
5406
|
+
};
|
|
5407
|
+
if (!outcome.ok) return {
|
|
5408
|
+
rows: mergeRowPages(pages),
|
|
5409
|
+
total,
|
|
5410
|
+
nextOffset: offset,
|
|
5411
|
+
error: outcome.error
|
|
5412
|
+
};
|
|
5413
|
+
const value = outcome.value;
|
|
5414
|
+
if (value.mode !== "time") break;
|
|
5415
|
+
pages.push(value.hits.map((hit) => hitRow(hit, actionFact(hit.ref_id, hit.text, hit.truncated === true))));
|
|
5416
|
+
total = value.total;
|
|
5417
|
+
offset = value.offset + value.hits.length;
|
|
5418
|
+
const remaining = typeof value.remaining === "number" ? value.remaining : Math.max(0, value.total - offset);
|
|
5419
|
+
if (value.hits.length === 0 || remaining <= 0) break;
|
|
5420
|
+
}
|
|
5421
|
+
return {
|
|
5422
|
+
rows: mergeRowPages(pages),
|
|
5423
|
+
total,
|
|
5424
|
+
nextOffset: offset
|
|
5425
|
+
};
|
|
5426
|
+
};
|
|
5427
|
+
/**
|
|
5428
|
+
* Search memory through the same `recall` remote the model tool uses, and put the answer into the
|
|
5429
|
+
* ONE list state (the result set REPLACES the list's rows; `clearSearch` puts the list back).
|
|
5430
|
+
*/
|
|
4874
5431
|
const runSearch = async () => {
|
|
4875
|
-
|
|
5432
|
+
const remote = props.remote;
|
|
5433
|
+
if (remote === void 0) return;
|
|
4876
5434
|
const query = searchText.trim();
|
|
4877
|
-
|
|
4878
|
-
|
|
4879
|
-
|
|
4880
|
-
setSearchRelation(null);
|
|
4881
|
-
setSearchFactPage(null);
|
|
4882
|
-
setSearchFacts({});
|
|
4883
|
-
setSearching(false);
|
|
4884
|
-
setError(void 0);
|
|
5435
|
+
const time = buildTimeFilter(timeField, timeMode, timeOn, timeFrom, timeTo);
|
|
5436
|
+
if (query === "" && time === void 0) {
|
|
5437
|
+
if (applied !== null || searchText !== "") clearSearch();
|
|
4885
5438
|
return;
|
|
4886
5439
|
}
|
|
4887
|
-
const seq =
|
|
5440
|
+
const seq = listGuard.begin();
|
|
5441
|
+
const factsSeq = searchFactsGuard.begin();
|
|
5442
|
+
searchFactsSeq.current = factsSeq;
|
|
4888
5443
|
setSearching(true);
|
|
4889
5444
|
setError(void 0);
|
|
4890
|
-
|
|
5445
|
+
setSearchFacts({});
|
|
5446
|
+
setApplied({
|
|
5447
|
+
query,
|
|
5448
|
+
time: time ?? null,
|
|
5449
|
+
kind: searchKind,
|
|
5450
|
+
mode: query === "" ? "time" : "hits"
|
|
5451
|
+
});
|
|
5452
|
+
setList({
|
|
5453
|
+
...EMPTY_LIST,
|
|
5454
|
+
loading: true
|
|
5455
|
+
});
|
|
5456
|
+
if (query === "" && time !== void 0) {
|
|
5457
|
+
const walked = await runTimeWindow(remote, seq, time);
|
|
5458
|
+
if (!listGuard.isCurrent(seq)) return;
|
|
5459
|
+
const rows = walked.rows ?? [];
|
|
5460
|
+
setList({
|
|
5461
|
+
rows,
|
|
5462
|
+
total: walked.total ?? void 0,
|
|
5463
|
+
truncated: walked.total !== null && walked.nextOffset < walked.total,
|
|
5464
|
+
nextOffset: walked.nextOffset,
|
|
5465
|
+
loading: false,
|
|
5466
|
+
error: walked.error
|
|
5467
|
+
});
|
|
5468
|
+
loadSearchFacts(factsSeq, rows.flatMap((row) => row.kind === "fact" ? [row.fact_id] : []));
|
|
5469
|
+
setSearching(false);
|
|
5470
|
+
return;
|
|
5471
|
+
}
|
|
5472
|
+
const outcome = await callRemote(`recall.search(${query}${searchKind === "all" ? "" : `, kind=${searchKind}`})`, remote.recall({
|
|
4891
5473
|
action: "search",
|
|
4892
5474
|
query,
|
|
4893
5475
|
limit: SEARCH_LIMIT,
|
|
4894
|
-
...searchKind === "all" ? {} : { kind: searchKind }
|
|
5476
|
+
...searchKind === "all" ? {} : { kind: searchKind },
|
|
5477
|
+
...time === void 0 ? {} : { time }
|
|
4895
5478
|
}));
|
|
4896
|
-
if (!
|
|
5479
|
+
if (!listGuard.isCurrent(seq)) return;
|
|
4897
5480
|
const value = outcome.ok ? outcome.value : void 0;
|
|
4898
5481
|
if (value !== void 0 && value.mode === "relation") {
|
|
4899
|
-
|
|
4900
|
-
|
|
4901
|
-
|
|
5482
|
+
setApplied((current) => current === null ? null : {
|
|
5483
|
+
...current,
|
|
5484
|
+
mode: "relation"
|
|
4902
5485
|
});
|
|
4903
|
-
|
|
4904
|
-
|
|
4905
|
-
|
|
5486
|
+
const rows = [...value.paths.map(pathRow), ...value.evidence.map((entry) => evidenceRow(entry, actionFact(entry.fact_id, entry.text, entry.truncated === true)))];
|
|
5487
|
+
setList({
|
|
5488
|
+
rows,
|
|
5489
|
+
total: void 0,
|
|
5490
|
+
truncated: false,
|
|
5491
|
+
nextOffset: rows.length,
|
|
5492
|
+
loading: false,
|
|
5493
|
+
error: void 0
|
|
5494
|
+
});
|
|
5495
|
+
loadSearchFacts(factsSeq, value.evidence.map((entry) => entry.fact_id));
|
|
4906
5496
|
} else if (value !== void 0 && value.mode === "facts") {
|
|
4907
|
-
|
|
4908
|
-
|
|
4909
|
-
|
|
4910
|
-
|
|
5497
|
+
setApplied((current) => current === null ? null : {
|
|
5498
|
+
...current,
|
|
5499
|
+
mode: "facts"
|
|
5500
|
+
});
|
|
5501
|
+
const rows = value.facts.map((entry) => factPageRow(entry, actionFact(entry.fact_id, entry.content, entry.truncated === true)));
|
|
5502
|
+
setList({
|
|
5503
|
+
rows,
|
|
5504
|
+
total: void 0,
|
|
5505
|
+
truncated: false,
|
|
5506
|
+
nextOffset: rows.length,
|
|
5507
|
+
loading: false,
|
|
5508
|
+
error: void 0
|
|
5509
|
+
});
|
|
5510
|
+
loadSearchFacts(factsSeq, value.facts.map((entry) => entry.fact_id));
|
|
4911
5511
|
} else if (value !== void 0) {
|
|
4912
|
-
|
|
4913
|
-
|
|
4914
|
-
|
|
4915
|
-
|
|
4916
|
-
|
|
4917
|
-
|
|
4918
|
-
|
|
4919
|
-
|
|
4920
|
-
|
|
4921
|
-
|
|
4922
|
-
|
|
5512
|
+
setApplied((current) => current === null ? null : {
|
|
5513
|
+
...current,
|
|
5514
|
+
mode: "hits"
|
|
5515
|
+
});
|
|
5516
|
+
const rows = value.hits.map((hit) => hitRow(hit, actionFact(hit.ref_id, hit.text, hit.truncated === true)));
|
|
5517
|
+
setList({
|
|
5518
|
+
rows,
|
|
5519
|
+
total: void 0,
|
|
5520
|
+
truncated: false,
|
|
5521
|
+
nextOffset: rows.length,
|
|
5522
|
+
loading: false,
|
|
5523
|
+
error: void 0
|
|
5524
|
+
});
|
|
5525
|
+
loadSearchFacts(factsSeq, value.hits.map((hit) => hit.ref_id));
|
|
5526
|
+
} else setList({
|
|
5527
|
+
...EMPTY_LIST,
|
|
5528
|
+
error: outcome.ok ? void 0 : outcome.error
|
|
5529
|
+
});
|
|
4923
5530
|
setSearching(false);
|
|
4924
5531
|
};
|
|
4925
|
-
/**
|
|
5532
|
+
/**
|
|
5533
|
+
* Fetch the NEXT page of the window whose rows are on screen, and APPEND it to the same state.
|
|
5534
|
+
*
|
|
5535
|
+
* `runTimeWindow` normally walks a window to its end in one user action, so this is the path for the
|
|
5536
|
+
* case it could not finish: a page that failed (the rows before it stay, and so does the shortfall
|
|
5537
|
+
* line) or the `SEARCH_PAGES_MAX` wall. Without it the panel would print 「还有 K 条未显示」 and stop
|
|
5538
|
+
* there — the rows would be named but unreachable.
|
|
5539
|
+
*
|
|
5540
|
+
* The question is NOT rebuilt from the live controls: it is the one the visible rows were fetched with
|
|
5541
|
+
* (`applied.time` + `applied.kind` + the empty query). A date edited after the search must not change
|
|
5542
|
+
* what page 2 means, or the list would concatenate two different windows. `offset` is the host-handed
|
|
5543
|
+
* count from the list state, so a page that repeated a row (page drift) cannot shift what comes next.
|
|
5544
|
+
*
|
|
5545
|
+
* `listGuard` is the re-entry wall: this request becomes the newest one, and a search or a clear that
|
|
5546
|
+
* starts while it is in flight drops its reply. `loadingMoreRef` (in `loadMore`) is the burst wall —
|
|
5547
|
+
* one page in flight.
|
|
5548
|
+
*/
|
|
5549
|
+
const loadSearchPage = async (offset) => {
|
|
5550
|
+
const remote = props.remote;
|
|
5551
|
+
const search = applied;
|
|
5552
|
+
if (remote === void 0 || search === null || search.time === null) return;
|
|
5553
|
+
const seq = listGuard.begin();
|
|
5554
|
+
setList((previous) => ({
|
|
5555
|
+
...previous,
|
|
5556
|
+
loading: true
|
|
5557
|
+
}));
|
|
5558
|
+
const outcome = await callRemote(`recall.search(time, offset=${String(offset)}${search.kind === "all" ? "" : `, kind=${search.kind}`})`, remote.recall({
|
|
5559
|
+
action: "search",
|
|
5560
|
+
...search.kind === "all" ? {} : { kind: search.kind },
|
|
5561
|
+
time: search.time,
|
|
5562
|
+
offset
|
|
5563
|
+
}));
|
|
5564
|
+
if (!listGuard.isCurrent(seq)) return;
|
|
5565
|
+
if (!outcome.ok) {
|
|
5566
|
+
setList((previous) => ({
|
|
5567
|
+
...previous,
|
|
5568
|
+
loading: false,
|
|
5569
|
+
error: outcome.error
|
|
5570
|
+
}));
|
|
5571
|
+
return;
|
|
5572
|
+
}
|
|
5573
|
+
const value = outcome.value;
|
|
5574
|
+
if (value.mode !== "time") {
|
|
5575
|
+
setList((previous) => ({
|
|
5576
|
+
...previous,
|
|
5577
|
+
loading: false,
|
|
5578
|
+
truncated: false
|
|
5579
|
+
}));
|
|
5580
|
+
return;
|
|
5581
|
+
}
|
|
5582
|
+
const pageRows = value.hits.map((hit) => hitRow(hit, actionFact(hit.ref_id, hit.text, hit.truncated === true)));
|
|
5583
|
+
setList((previous) => ({
|
|
5584
|
+
...previous,
|
|
5585
|
+
...appendWindowPage(previous, {
|
|
5586
|
+
rows: pageRows,
|
|
5587
|
+
offset: value.offset,
|
|
5588
|
+
total: value.total
|
|
5589
|
+
}),
|
|
5590
|
+
total: value.total,
|
|
5591
|
+
loading: false,
|
|
5592
|
+
error: void 0
|
|
5593
|
+
}));
|
|
5594
|
+
loadSearchFacts(searchFactsSeq.current, value.hits.map((hit) => hit.ref_id));
|
|
5595
|
+
};
|
|
5596
|
+
/**
|
|
5597
|
+
* Load the next page of WHATEVER owns the list.
|
|
5598
|
+
*
|
|
5599
|
+
* The single paging entry: the end-of-list button and the scroll controller both call it, so there is
|
|
5600
|
+
* no "button version" and "scroll version" to diverge. The source is decided by `applied` — the search
|
|
5601
|
+
* the visible rows came from — and only a time window has a next page, because a keyword / fact-level /
|
|
5602
|
+
* relation answer is a shortlist whose offset the host does not page.
|
|
5603
|
+
*
|
|
5604
|
+
* Two walls, and both are needed: `loadingMoreRef` is synchronous (two checks in ONE tick — a queued
|
|
5605
|
+
* scroll frame and a re-check — must not send the same page twice), `list.loading` is the committed
|
|
5606
|
+
* state (a page started elsewhere, e.g. the refresh after a mutation, is already in flight).
|
|
5607
|
+
*/
|
|
5608
|
+
const loadMore = async () => {
|
|
5609
|
+
if (props.remote === void 0 || loadingMoreRef.current || list.loading || !list.truncated) return;
|
|
5610
|
+
loadingMoreRef.current = true;
|
|
5611
|
+
try {
|
|
5612
|
+
if (applied === null) {
|
|
5613
|
+
await loadListPage(list.nextOffset);
|
|
5614
|
+
return;
|
|
5615
|
+
}
|
|
5616
|
+
if (applied.mode !== "time") return;
|
|
5617
|
+
await loadSearchPage(list.nextOffset);
|
|
5618
|
+
} finally {
|
|
5619
|
+
loadingMoreRef.current = false;
|
|
5620
|
+
}
|
|
5621
|
+
};
|
|
5622
|
+
/** Clear the query, the time window and the result view; the list view comes back on its own. */
|
|
4926
5623
|
const clearSearch = () => {
|
|
4927
|
-
|
|
5624
|
+
listGuard.begin();
|
|
5625
|
+
searchFactsGuard.begin();
|
|
4928
5626
|
setSearchText("");
|
|
4929
|
-
|
|
4930
|
-
|
|
4931
|
-
|
|
5627
|
+
setTimeOn("");
|
|
5628
|
+
setTimeFrom("");
|
|
5629
|
+
setTimeTo("");
|
|
5630
|
+
setApplied(null);
|
|
4932
5631
|
setSearchFacts({});
|
|
4933
5632
|
setSearching(false);
|
|
5633
|
+
setEditing(null);
|
|
5634
|
+
setDetail(null);
|
|
4934
5635
|
setError(void 0);
|
|
5636
|
+
setList({
|
|
5637
|
+
...EMPTY_LIST,
|
|
5638
|
+
loading: props.remote !== void 0
|
|
5639
|
+
});
|
|
4935
5640
|
};
|
|
4936
|
-
/** Switch the status tab:
|
|
5641
|
+
/** Switch the status tab: drop the other tab's page immediately, and its in-flight page too. */
|
|
4937
5642
|
const switchStatus = (next) => {
|
|
4938
5643
|
if (next === statusFilter) return;
|
|
4939
5644
|
listGuard.begin();
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
|
|
4943
|
-
|
|
5645
|
+
if (listView) setList({
|
|
5646
|
+
...EMPTY_LIST,
|
|
5647
|
+
loading: props.remote !== void 0
|
|
5648
|
+
});
|
|
4944
5649
|
setDetail(null);
|
|
4945
5650
|
setEditing(null);
|
|
4946
5651
|
contradictionGuard.begin();
|
|
4947
5652
|
setContradictions(null);
|
|
4948
5653
|
setStatusFilter(next);
|
|
4949
5654
|
};
|
|
4950
|
-
/**
|
|
5655
|
+
/**
|
|
5656
|
+
* Re-read whatever the list is SHOWING: the admin page, or nothing while a search owns it.
|
|
5657
|
+
*
|
|
5658
|
+
* A search result set is re-read by the query that produced it (`factButtons`' `fromSearch`), so
|
|
5659
|
+
* refreshing the admin list underneath it would be a page nobody is looking at — and, now that both
|
|
5660
|
+
* sources write ONE state, it would overwrite the rows on screen.
|
|
5661
|
+
*/
|
|
5662
|
+
const refreshVisible = react.useCallback(() => listView ? loadListPage(0) : Promise.resolve(), [listView, loadListPage]);
|
|
5663
|
+
/** Run one mutation, re-read the visible list, then surface the mutation's own outcome. */
|
|
4951
5664
|
const mutate = react.useCallback((label, call, opts) => {
|
|
4952
5665
|
const remote = props.remote;
|
|
4953
5666
|
if (remote === void 0) return;
|
|
4954
5667
|
runRemoteAction({
|
|
4955
|
-
setLoading,
|
|
5668
|
+
setLoading: setBusy,
|
|
4956
5669
|
setNotice,
|
|
4957
5670
|
setError
|
|
4958
5671
|
}, label, call(remote), {
|
|
4959
5672
|
summarize: opts?.summarize,
|
|
4960
|
-
refreshFirst:
|
|
5673
|
+
refreshFirst: refreshVisible,
|
|
4961
5674
|
after: opts?.after
|
|
4962
5675
|
});
|
|
4963
|
-
}, [
|
|
4964
|
-
props.remote,
|
|
4965
|
-
load,
|
|
4966
|
-
statusFilter
|
|
4967
|
-
]);
|
|
5676
|
+
}, [props.remote, refreshVisible]);
|
|
4968
5677
|
/**
|
|
4969
5678
|
* Read one fact's detail. Kept separate from the toggle because a relation mutation changes the
|
|
4970
5679
|
* detail it was performed in: the panel stays open and re-reads it, instead of leaving a stale
|
|
@@ -5021,13 +5730,14 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
5021
5730
|
} });
|
|
5022
5731
|
};
|
|
5023
5732
|
/**
|
|
5024
|
-
* The per-fact action buttons — ONE implementation for the admin list and
|
|
5733
|
+
* The per-fact action buttons — ONE implementation for the admin list and every result shape.
|
|
5734
|
+
*
|
|
5735
|
+
* The regression this replaced: the actions were written inline in the list row, so when a search
|
|
5736
|
+
* owned the view, a hit's 「详情」 had nowhere to render and there was no way to edit/archive/feedback
|
|
5737
|
+
* a fact found by search.
|
|
5025
5738
|
*
|
|
5026
|
-
*
|
|
5027
|
-
*
|
|
5028
|
-
* edit/archive/feedback a fact found by search. `fromSearch` only decides what happens AFTER the
|
|
5029
|
-
* write: a search view must re-run the query (its results are the thing on screen), while the
|
|
5030
|
-
* list refresh (`mutate`'s `refreshFirst`) stays the same in both cases.
|
|
5739
|
+
* `fromSearch` only decides what happens AFTER the write: a result set is what is on screen, so it
|
|
5740
|
+
* re-runs the query, while a list row is re-read by `mutate`'s `refreshVisible`.
|
|
5031
5741
|
*
|
|
5032
5742
|
* A field the caller does not know is `undefined` and its button is OMITTED — never shown with a
|
|
5033
5743
|
* guessed label (「恢复」 on an active fact or 「永久记忆」 on a pinned one is a different write).
|
|
@@ -5044,7 +5754,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
5044
5754
|
if (editing?.id === fact.fact_id) return [h(Btn, {
|
|
5045
5755
|
key: "save",
|
|
5046
5756
|
label: "保存",
|
|
5047
|
-
disabled:
|
|
5757
|
+
disabled: busy || editing.text.trim().length === 0,
|
|
5048
5758
|
onClick: () => {
|
|
5049
5759
|
const text = editing.text.trim();
|
|
5050
5760
|
setEditing(null);
|
|
@@ -5057,7 +5767,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
5057
5767
|
}), h(Btn, {
|
|
5058
5768
|
key: "cancel",
|
|
5059
5769
|
label: "取消",
|
|
5060
|
-
disabled:
|
|
5770
|
+
disabled: busy,
|
|
5061
5771
|
onClick: () => {
|
|
5062
5772
|
setEditing(null);
|
|
5063
5773
|
}
|
|
@@ -5067,7 +5777,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
5067
5777
|
h(Btn, {
|
|
5068
5778
|
key: "detail",
|
|
5069
5779
|
label: detail?.id === fact.fact_id ? "收起详情" : "详情",
|
|
5070
|
-
disabled:
|
|
5780
|
+
disabled: busy,
|
|
5071
5781
|
onClick: () => {
|
|
5072
5782
|
showDetail(fact.fact_id);
|
|
5073
5783
|
}
|
|
@@ -5075,7 +5785,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
5075
5785
|
fact.status === "active" && content !== void 0 ? h(Btn, {
|
|
5076
5786
|
key: "edit",
|
|
5077
5787
|
label: "编辑",
|
|
5078
|
-
disabled:
|
|
5788
|
+
disabled: busy,
|
|
5079
5789
|
onClick: () => {
|
|
5080
5790
|
setEditing({
|
|
5081
5791
|
id: fact.fact_id,
|
|
@@ -5087,7 +5797,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
5087
5797
|
fact.status === "active" ? h(Btn, {
|
|
5088
5798
|
key: "helpful",
|
|
5089
5799
|
label: "有用 +1",
|
|
5090
|
-
disabled:
|
|
5800
|
+
disabled: busy,
|
|
5091
5801
|
onClick: () => {
|
|
5092
5802
|
run(`反馈有用 #${String(fact.fact_id)}`, (remote) => remote.remember({
|
|
5093
5803
|
action: "helpful",
|
|
@@ -5098,7 +5808,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
5098
5808
|
fact.status === "active" ? h(Btn, {
|
|
5099
5809
|
key: "unhelpful",
|
|
5100
5810
|
label: "没用 −1",
|
|
5101
|
-
disabled:
|
|
5811
|
+
disabled: busy,
|
|
5102
5812
|
onClick: () => {
|
|
5103
5813
|
run(`反馈没用 #${String(fact.fact_id)}`, (remote) => remote.remember({
|
|
5104
5814
|
action: "unhelpful",
|
|
@@ -5109,7 +5819,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
5109
5819
|
fact.status === "active" ? h(Btn, {
|
|
5110
5820
|
key: "archive",
|
|
5111
5821
|
label: "归档",
|
|
5112
|
-
disabled:
|
|
5822
|
+
disabled: busy,
|
|
5113
5823
|
onClick: () => {
|
|
5114
5824
|
run(`归档 #${String(fact.fact_id)}`, (remote) => remote.admin({
|
|
5115
5825
|
action: "archive",
|
|
@@ -5120,7 +5830,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
5120
5830
|
}) : h(Btn, {
|
|
5121
5831
|
key: "restore",
|
|
5122
5832
|
label: "恢复",
|
|
5123
|
-
disabled:
|
|
5833
|
+
disabled: busy,
|
|
5124
5834
|
onClick: () => {
|
|
5125
5835
|
run(`恢复 #${String(fact.fact_id)}`, (remote) => remote.admin({
|
|
5126
5836
|
action: "restore",
|
|
@@ -5131,7 +5841,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
5131
5841
|
fact.status === "active" && fact.pinned !== void 0 ? fact.pinned ? h(Btn, {
|
|
5132
5842
|
key: "unpin",
|
|
5133
5843
|
label: "解除永久",
|
|
5134
|
-
disabled:
|
|
5844
|
+
disabled: busy,
|
|
5135
5845
|
onClick: () => {
|
|
5136
5846
|
run(`解除永久 #${String(fact.fact_id)}`, (remote) => remote.admin({
|
|
5137
5847
|
action: "unpin",
|
|
@@ -5141,7 +5851,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
5141
5851
|
}) : h(Btn, {
|
|
5142
5852
|
key: "pin",
|
|
5143
5853
|
label: "永久记忆",
|
|
5144
|
-
disabled:
|
|
5854
|
+
disabled: busy,
|
|
5145
5855
|
onClick: () => {
|
|
5146
5856
|
run(`固化永久 #${String(fact.fact_id)}`, (remote) => remote.admin({
|
|
5147
5857
|
action: "pin",
|
|
@@ -5223,8 +5933,59 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
5223
5933
|
} else setError(outcome.error);
|
|
5224
5934
|
});
|
|
5225
5935
|
};
|
|
5226
|
-
const shown = rows.length;
|
|
5227
|
-
|
|
5936
|
+
const shown = list.rows.length;
|
|
5937
|
+
/**
|
|
5938
|
+
* The NAME of the answer on screen, for the unified info line (see {@link metaLine}).
|
|
5939
|
+
*
|
|
5940
|
+
* It says which KIND of answer the rows are (a fact-level AND page is not a hit list, and a
|
|
5941
|
+
* relation answer is structure), plus the memory layer it was fetched with — `applied`, never the
|
|
5942
|
+
* live selector. `null` for a pure time window: there the WINDOW is the question, so naming it
|
|
5943
|
+
* twice would only repeat the line's first piece.
|
|
5944
|
+
*/
|
|
5945
|
+
const answerTitle = applied === null ? null : searching ? "搜索中…" : applied.mode === "time" ? null : applied.mode === "facts" ? `多实体事实:${String(shown)} 条${applied.kind === "all" ? "" : `(${applied.kind})`}` : applied.mode === "relation" ? `关系结果:${String(list.rows.filter((row) => row.kind === "path").length)} 条路径 · 证据 ${String(list.rows.filter((row) => row.kind === "fact").length)} 条${applied.kind === "all" ? "" : `(${applied.kind})`}` : `搜索结果:${String(shown)} 条${applied.kind === "all" ? "" : `(${applied.kind})`}${applied.query === "" ? "" : `“${applied.query}”`}`;
|
|
5946
|
+
/**
|
|
5947
|
+
* How many rows the SOURCE holds and how many are on screen — the count piece of the info line.
|
|
5948
|
+
*
|
|
5949
|
+
* `list.total` is only set when the source counts its rows (`admin.list`'s own `total`, or a walked
|
|
5950
|
+
* time window's), so a RANKED keyword page / fact-level page / relation answer prints no count here:
|
|
5951
|
+
* labelling a ten-row shortlist with a corpus-wide number would claim a completeness it lacks.
|
|
5952
|
+
*
|
|
5953
|
+
* The 「继续滚动自动加载」 hint appears only when scrolling really loads (the same `truncated` the
|
|
5954
|
+
* pager and the scroll hook read), and 「正在加载…」 says when a page is on its way, so a shortfall
|
|
5955
|
+
* never reads as a dead end.
|
|
5956
|
+
*/
|
|
5957
|
+
const countText = list.total === void 0 ? applied === null ? `已显示 ${String(shown)} 条` : null : applied === null ? `共 ${String(list.total)} 条${statusFilter === "active" ? "活动" : "归档"}事实,已显示 ${String(shown)} 条` + (list.truncated ? "(继续滚动自动加载)" : "") : `共 ${String(list.total)} 条,已显示 ${String(shown)} 条` + (list.total > shown ? `,还有 ${String(list.total - shown)} 条未显示` : "") + (list.loading ? "(正在加载…)" : list.truncated ? "(继续滚动自动加载)" : "");
|
|
5958
|
+
/**
|
|
5959
|
+
* THE top information line of the 记忆 tab — ONE line, in ONE place, for the list and for every
|
|
5960
|
+
* search answer.
|
|
5961
|
+
*
|
|
5962
|
+
* It used to be TWO interfaces' worth: the list had this sentence in the sticky bar, while a search
|
|
5963
|
+
* wrapped the SAME rows in `css.searchPanel` with three blocks of its own (`css.searchTitle`,
|
|
5964
|
+
* `css.searchWindow` and a second count line). Two renderings of one fact is what made a search look
|
|
5965
|
+
* like a separate screen. The composition below is the fix: the pieces are joined in a fixed order,
|
|
5966
|
+
* and a source that cannot state a piece omits it.
|
|
5967
|
+
*
|
|
5968
|
+
* 时间窗口:… · <answer name> · 共 N 条,已显示 M 条[,还有 K 条未显示] (继续滚动自动加载)
|
|
5969
|
+
*
|
|
5970
|
+
* The window is the one the VISIBLE rows were narrowed to (`applied.time`, never the live draft).
|
|
5971
|
+
*/
|
|
5972
|
+
const metaLine = [
|
|
5973
|
+
applied === null || applied.time === null ? null : `时间窗口:${timeFilterLabel(applied.time)}`,
|
|
5974
|
+
answerTitle,
|
|
5975
|
+
countText
|
|
5976
|
+
].filter((part) => part !== null).join(" · ");
|
|
5977
|
+
/** The list view before its first page has nothing to count yet: say what is happening instead. */
|
|
5978
|
+
const statusLine = applied === null && list.loading && shown === 0 ? "读取中…" : metaLine;
|
|
5979
|
+
/** What the list says when its source returned nothing at all. */
|
|
5980
|
+
const emptyText = listView ? statusFilter === "active" ? "当前没有活动的事实。可在对话里让 agent 用 mem_remember 写入。" : "当前没有归档的事实。" : applied?.mode === "facts" ? "没有同时提到这些实体的事实。" : applied?.mode === "relation" ? "没有关系路径。" : "没有命中。";
|
|
5981
|
+
/** What it says while the source's first page is on its way. */
|
|
5982
|
+
const loadingText = listView ? "读取中…" : "正在检索记忆…";
|
|
5983
|
+
/**
|
|
5984
|
+
* A relation answer's paths and its evidence are two groups of the SAME list. When the graph found no
|
|
5985
|
+
* path but the evidence facts are still there, the sentence is printed above the rows instead of
|
|
5986
|
+
* being dropped along with the (empty) path group.
|
|
5987
|
+
*/
|
|
5988
|
+
const listNote = applied?.mode === "relation" && shown > 0 && list.rows.every((row) => row.kind === "fact") ? "没有关系路径。" : void 0;
|
|
5228
5989
|
/** Bring the panel's top into view — used when a block opens below the sticky bar. */
|
|
5229
5990
|
const rootRef = react.useRef(null);
|
|
5230
5991
|
const reveal = () => {
|
|
@@ -5233,38 +5994,67 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
5233
5994
|
react.useEffect(() => {
|
|
5234
5995
|
reveal();
|
|
5235
5996
|
}, []);
|
|
5236
|
-
/**
|
|
5237
|
-
const
|
|
5238
|
-
/**
|
|
5239
|
-
|
|
5240
|
-
|
|
5241
|
-
|
|
5242
|
-
|
|
5243
|
-
|
|
5997
|
+
/** The window the CONTROLS currently describe (`undefined` = no time condition). */
|
|
5998
|
+
const timeCondition = buildTimeFilter(timeField, timeMode, timeOn, timeFrom, timeTo);
|
|
5999
|
+
/**
|
|
6000
|
+
* The ONE scroll loader.
|
|
6001
|
+
*
|
|
6002
|
+
* Its `enabled` and its `version` come from the unified list state, and its callback is the same
|
|
6003
|
+
* `loadMore()` the end-of-list button calls — there is no second hook, no second enabled rule and no
|
|
6004
|
+
* separate "continue the results" path, which is where the two views used to diverge. `version` is the
|
|
6005
|
+
* row count, so a page that lands re-checks (a short page may not fill the scrollport, and then no
|
|
6006
|
+
* scroll event would ever come).
|
|
6007
|
+
*
|
|
6008
|
+
* `list.error === undefined` is part of the gate on purpose: the hook retries once whenever `enabled`
|
|
6009
|
+
* flips back on, so an unreported page failure would re-fire the same request on every round trip.
|
|
6010
|
+
* A failure is a STATE, not a retry trigger — the end-of-list button (still rendered, because
|
|
6011
|
+
* `truncated` is unchanged) is how the user asks again, and the next successful refresh re-arms the
|
|
6012
|
+
* loader.
|
|
6013
|
+
*/
|
|
6014
|
+
useLoadMoreOnScroll(rootRef, list.truncated && !list.loading && list.error === void 0, () => {
|
|
6015
|
+
loadMore();
|
|
6016
|
+
}, shown);
|
|
6017
|
+
/**
|
|
6018
|
+
* The rows the renderer gets: a SEARCH row re-reads its action state HERE.
|
|
6019
|
+
*
|
|
6020
|
+
* `hitRow` / `evidenceRow` / `factPageRow` take an {@link ActionFact} as an argument, so a row carries
|
|
6021
|
+
* a SNAPSHOT of `searchFacts` from the moment it was built — and the lookups (`loadSearchFacts`) answer
|
|
6022
|
+
* AFTER the rows exist. Without this the panel would render every result with the fallback set
|
|
6023
|
+
* (`status:'active'`, pin state unknown) for the life of the result set: 「归档」 on an archived fact,
|
|
6024
|
+
* and no 「永久记忆」 at all — the exact guess the lookups exist to avoid. The pre-unification view read
|
|
6025
|
+
* `searchFacts` at render for the same reason; the unified row shape moved that read into the fetch
|
|
6026
|
+
* handler, so it is restored once, here, for every search row at once.
|
|
6027
|
+
*
|
|
6028
|
+
* A list row needs no derivation: `admin.list` already hands over every action field.
|
|
6029
|
+
*/
|
|
6030
|
+
const rows = list.rows.map((row) => row.kind === "fact" && row.fromSearch ? {
|
|
6031
|
+
...row,
|
|
6032
|
+
action: actionFact(row.fact_id, row.text, row.truncated)
|
|
6033
|
+
} : row);
|
|
5244
6034
|
return h("div", {
|
|
5245
6035
|
className: pages_module_css_default.section,
|
|
5246
6036
|
ref: rootRef
|
|
5247
|
-
}, props.remote === void 0 ? h("div", { className:
|
|
6037
|
+
}, props.remote === void 0 ? h("div", { className: controls_module_css_default.error }, NOT_MOUNTED) : null, props.remote === void 0 ? null : h("div", { className: pages_module_css_default.stickyTop }, h("div", { className: pages_module_css_default.headbar }, h("button", {
|
|
5248
6038
|
type: "button",
|
|
5249
|
-
className: statusFilter === "active" ? `${
|
|
6039
|
+
className: statusFilter === "active" ? `${BTN} ${controls_module_css_default.active}` : BTN,
|
|
5250
6040
|
onClick: () => {
|
|
5251
6041
|
switchStatus("active");
|
|
5252
6042
|
}
|
|
5253
6043
|
}, "活动"), h("button", {
|
|
5254
6044
|
type: "button",
|
|
5255
|
-
className: statusFilter === "archived" ? `${
|
|
6045
|
+
className: statusFilter === "archived" ? `${BTN} ${controls_module_css_default.active}` : BTN,
|
|
5256
6046
|
onClick: () => {
|
|
5257
6047
|
switchStatus("archived");
|
|
5258
6048
|
}
|
|
5259
6049
|
}, "归档"), h(Btn, {
|
|
5260
6050
|
label: "立即维护",
|
|
5261
|
-
disabled:
|
|
6051
|
+
disabled: busy,
|
|
5262
6052
|
onClick: () => {
|
|
5263
6053
|
mutate("生命周期维护", (remote) => remote.admin({ action: "maintenance" }));
|
|
5264
6054
|
}
|
|
5265
6055
|
}), h(Btn, {
|
|
5266
6056
|
label: contradictions === null ? "查看未处理矛盾" : "收起矛盾",
|
|
5267
|
-
disabled:
|
|
6057
|
+
disabled: busy,
|
|
5268
6058
|
onClick: () => {
|
|
5269
6059
|
const opening = contradictions === null;
|
|
5270
6060
|
loadContradictions();
|
|
@@ -5272,13 +6062,13 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
5272
6062
|
}
|
|
5273
6063
|
}), h(Btn, {
|
|
5274
6064
|
label: health === null ? "查看检索健康度" : "收起检索健康度",
|
|
5275
|
-
disabled:
|
|
6065
|
+
disabled: busy,
|
|
5276
6066
|
onClick: () => {
|
|
5277
6067
|
const opening = health === null;
|
|
5278
6068
|
loadHealth();
|
|
5279
6069
|
if (opening) reveal();
|
|
5280
6070
|
}
|
|
5281
|
-
}), h("div", { className:
|
|
6071
|
+
}), h("div", { className: controls_module_css_default.meta }, statusLine)), props.remote === void 0 ? null : h("div", { className: pages_module_css_default.toolbar }, h("div", { className: `${pages_module_css_default.search} ${pages_module_css_default.searchField}` }, h("input", {
|
|
5282
6072
|
value: searchText,
|
|
5283
6073
|
placeholder: "搜索记忆…(Enter)",
|
|
5284
6074
|
"aria-label": "搜索记忆",
|
|
@@ -5291,120 +6081,115 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
5291
6081
|
runSearch();
|
|
5292
6082
|
}
|
|
5293
6083
|
}
|
|
5294
|
-
}), searchText !== "" ||
|
|
6084
|
+
}), searchText !== "" || timeCondition !== void 0 || applied !== null ? h("button", {
|
|
5295
6085
|
type: "button",
|
|
5296
|
-
className:
|
|
6086
|
+
className: controls_module_css_default.clear,
|
|
5297
6087
|
"aria-label": "清除搜索",
|
|
5298
6088
|
title: "清除搜索",
|
|
5299
6089
|
onClick: clearSearch
|
|
5300
6090
|
}, "×") : null), h("select", {
|
|
5301
|
-
className:
|
|
6091
|
+
className: controls_module_css_default.select,
|
|
5302
6092
|
value: searchKind,
|
|
5303
6093
|
"aria-label": "记忆层",
|
|
5304
6094
|
onChange: (e) => {
|
|
5305
6095
|
setSearchKind(e.target.value);
|
|
5306
6096
|
}
|
|
5307
|
-
}, h("option", { value: "all" }, "全部"), h("option", { value: "journal" }, "journal"), h("option", { value: "note" }, "note"))
|
|
5308
|
-
|
|
5309
|
-
|
|
5310
|
-
|
|
5311
|
-
|
|
5312
|
-
value: editing.text,
|
|
5313
|
-
rows: 3,
|
|
6097
|
+
}, h("option", { value: "all" }, "全部"), h("option", { value: "journal" }, "journal"), h("option", { value: "note" }, "note")), h("select", {
|
|
6098
|
+
className: controls_module_css_default.select,
|
|
6099
|
+
value: timeField,
|
|
6100
|
+
"aria-label": "时间轴",
|
|
6101
|
+
title: "时间轴:created=入库时间 · updated=修改时间 · event=事件发生时间",
|
|
5314
6102
|
onChange: (e) => {
|
|
5315
|
-
|
|
5316
|
-
id: entry.fact_id,
|
|
5317
|
-
text: e.target.value
|
|
5318
|
-
});
|
|
6103
|
+
setTimeField(e.target.value);
|
|
5319
6104
|
}
|
|
5320
|
-
}
|
|
5321
|
-
|
|
5322
|
-
|
|
5323
|
-
|
|
5324
|
-
|
|
5325
|
-
|
|
5326
|
-
|
|
5327
|
-
|
|
5328
|
-
key: `relation-evidence-${String(entry.fact_id)}`,
|
|
5329
|
-
className: pages_module_css_default.item
|
|
5330
|
-
}, entry.title === void 0 ? null : h("div", { className: pages_module_css_default.meta }, `标题:${entry.title}`), editing?.id === entry.fact_id ? h("textarea", {
|
|
5331
|
-
className: pages_module_css_default.textarea,
|
|
5332
|
-
value: editing.text,
|
|
5333
|
-
rows: 3,
|
|
6105
|
+
}, TIME_AXES.map((axis) => h("option", {
|
|
6106
|
+
key: axis.value,
|
|
6107
|
+
value: axis.value
|
|
6108
|
+
}, axis.label))), h("select", {
|
|
6109
|
+
className: controls_module_css_default.select,
|
|
6110
|
+
value: timeMode,
|
|
6111
|
+
"aria-label": "时间范围形态",
|
|
6112
|
+
title: "单日 = 这一整天;区间 = from~to(to 当天也包含)",
|
|
5334
6113
|
onChange: (e) => {
|
|
5335
|
-
|
|
5336
|
-
id: entry.fact_id,
|
|
5337
|
-
text: e.target.value
|
|
5338
|
-
});
|
|
6114
|
+
setTimeMode(e.target.value);
|
|
5339
6115
|
}
|
|
5340
|
-
}
|
|
5341
|
-
|
|
5342
|
-
|
|
5343
|
-
|
|
5344
|
-
|
|
5345
|
-
|
|
5346
|
-
className: pages_module_css_default.item
|
|
5347
|
-
}, hit.title === void 0 ? null : h("div", { className: pages_module_css_default.meta }, `标题:${hit.title}`), editing?.id === hit.ref_id ? h("textarea", {
|
|
5348
|
-
className: pages_module_css_default.textarea,
|
|
5349
|
-
value: editing.text,
|
|
5350
|
-
rows: 3,
|
|
6116
|
+
}, h("option", { value: "on" }, "单日"), h("option", { value: "range" }, "区间")), timeMode === "on" ? h("input", {
|
|
6117
|
+
type: "date",
|
|
6118
|
+
className: controls_module_css_default.dateInput,
|
|
6119
|
+
value: timeOn,
|
|
6120
|
+
"aria-label": "单日日期",
|
|
6121
|
+
title: "这一整天(本地时区)",
|
|
5351
6122
|
onChange: (e) => {
|
|
5352
|
-
|
|
5353
|
-
|
|
5354
|
-
|
|
5355
|
-
|
|
6123
|
+
setTimeOn(e.target.value);
|
|
6124
|
+
}
|
|
6125
|
+
}) : [
|
|
6126
|
+
h("input", {
|
|
6127
|
+
key: "from",
|
|
6128
|
+
type: "date",
|
|
6129
|
+
className: controls_module_css_default.dateInput,
|
|
6130
|
+
value: timeFrom,
|
|
6131
|
+
"aria-label": "起始日期",
|
|
6132
|
+
title: "起始日(包含;留空 = 不限起点)",
|
|
6133
|
+
onChange: (e) => {
|
|
6134
|
+
setTimeFrom(e.target.value);
|
|
6135
|
+
}
|
|
6136
|
+
}),
|
|
6137
|
+
h("span", {
|
|
6138
|
+
key: "sep",
|
|
6139
|
+
className: controls_module_css_default.status
|
|
6140
|
+
}, "~"),
|
|
6141
|
+
h("input", {
|
|
6142
|
+
key: "to",
|
|
6143
|
+
type: "date",
|
|
6144
|
+
className: controls_module_css_default.dateInput,
|
|
6145
|
+
value: timeTo,
|
|
6146
|
+
"aria-label": "结束日期",
|
|
6147
|
+
title: "结束日(包含当天;留空 = 不限终点)",
|
|
6148
|
+
onChange: (e) => {
|
|
6149
|
+
setTimeTo(e.target.value);
|
|
6150
|
+
}
|
|
6151
|
+
})
|
|
6152
|
+
], h(Btn, {
|
|
6153
|
+
label: searching ? "搜索中…" : "搜索",
|
|
6154
|
+
disabled: searching,
|
|
6155
|
+
onClick: () => {
|
|
6156
|
+
runSearch();
|
|
5356
6157
|
}
|
|
5357
|
-
})
|
|
5358
|
-
fact: detail.data,
|
|
5359
|
-
disabled: loading,
|
|
5360
|
-
onSaveRelations: saveRelations
|
|
5361
|
-
}) : null)))) : null, error !== void 0 ? h("div", { className: pages_module_css_default.error }, `错误:${error}`) : null, notice !== void 0 ? h("div", { className: pages_module_css_default.status }, notice) : null, props.remote !== void 0 && !searchActive && !loading && error === void 0 && shown === 0 ? h("div", { className: pages_module_css_default.status }, statusFilter === "active" ? "当前没有活动的事实。可在对话里让 agent 用 mem_remember 写入。" : "当前没有归档的事实。") : null, health !== null ? h(HealthBlock, { stats: health }) : null, contradictions !== null ? contradictions.length === 0 ? h("div", { className: pages_module_css_default.status }, "没有未处理的矛盾。") : h("ul", { className: pages_module_css_default.list }, contradictions.map((c) => h("li", {
|
|
6158
|
+
}))), error !== void 0 ? h("div", { className: controls_module_css_default.error }, `错误:${error}`) : null, notice !== void 0 ? h("div", { className: controls_module_css_default.status }, notice) : null, health !== null ? h(HealthBlock, { stats: health }) : null, contradictions !== null ? contradictions.length === 0 ? h("div", { className: controls_module_css_default.status }, "没有未处理的矛盾。") : h("ul", { className: controls_module_css_default.list }, contradictions.map((c) => h("li", {
|
|
5362
6159
|
key: c.contradiction_id,
|
|
5363
|
-
className:
|
|
5364
|
-
}, `#${String(c.fact_a)}「${c.content_a}」 ↔ #${String(c.fact_b)}「${c.content_b}」`, h("span", { className:
|
|
6160
|
+
className: controls_module_css_default.item
|
|
6161
|
+
}, `#${String(c.fact_a)}「${c.content_a}」 ↔ #${String(c.fact_b)}「${c.content_b}」`, h("span", { className: controls_module_css_default.meta }, `score ${c.score.toFixed(2)} · ${c.detected_at ?? ""}`), props.remote !== void 0 ? h("div", { className: pages_module_css_default.row }, h(Btn, {
|
|
5365
6162
|
label: `判 #${String(c.fact_a)} 错`,
|
|
5366
6163
|
danger: true,
|
|
5367
|
-
disabled:
|
|
6164
|
+
disabled: busy,
|
|
5368
6165
|
onClick: () => resolveConflict(c, "true_positive", c.fact_a)
|
|
5369
6166
|
}), h(Btn, {
|
|
5370
6167
|
label: `判 #${String(c.fact_b)} 错`,
|
|
5371
6168
|
danger: true,
|
|
5372
|
-
disabled:
|
|
6169
|
+
disabled: busy,
|
|
5373
6170
|
onClick: () => resolveConflict(c, "true_positive", c.fact_b)
|
|
5374
6171
|
}), h(Btn, {
|
|
5375
6172
|
label: "误报(两条都对)",
|
|
5376
|
-
disabled:
|
|
6173
|
+
disabled: busy,
|
|
5377
6174
|
onClick: () => resolveConflict(c, "false_positive")
|
|
5378
|
-
})) : null))) : null,
|
|
5379
|
-
|
|
5380
|
-
|
|
5381
|
-
|
|
5382
|
-
|
|
5383
|
-
|
|
5384
|
-
|
|
5385
|
-
|
|
5386
|
-
|
|
5387
|
-
|
|
5388
|
-
|
|
5389
|
-
|
|
5390
|
-
|
|
5391
|
-
|
|
5392
|
-
|
|
5393
|
-
|
|
5394
|
-
content: f.content,
|
|
5395
|
-
pinned: f.pinned === true
|
|
5396
|
-
}, false)), detail?.id === f.fact_id ? h(FactDetailView, {
|
|
5397
|
-
fact: detail.data,
|
|
5398
|
-
disabled: loading,
|
|
5399
|
-
onSaveRelations: saveRelations
|
|
5400
|
-
}) : null))), searchActive || !truncated ? null : h("button", {
|
|
5401
|
-
type: "button",
|
|
5402
|
-
className: pages_module_css_default.more,
|
|
5403
|
-
disabled: loading,
|
|
5404
|
-
onClick: () => {
|
|
5405
|
-
load(nextOffset, statusFilter);
|
|
6175
|
+
})) : null))) : null, h(FactList, {
|
|
6176
|
+
state: {
|
|
6177
|
+
...list,
|
|
6178
|
+
rows
|
|
6179
|
+
},
|
|
6180
|
+
note: listNote,
|
|
6181
|
+
emptyText,
|
|
6182
|
+
loadingText,
|
|
6183
|
+
busy,
|
|
6184
|
+
editing,
|
|
6185
|
+
onEdit: setEditing,
|
|
6186
|
+
detail,
|
|
6187
|
+
actionRow: factButtons,
|
|
6188
|
+
onSaveRelations: saveRelations,
|
|
6189
|
+
onLoadMore: () => {
|
|
6190
|
+
loadMore();
|
|
5406
6191
|
}
|
|
5407
|
-
}
|
|
6192
|
+
}));
|
|
5408
6193
|
}
|
|
5409
6194
|
/**
|
|
5410
6195
|
* Retrieval health (DESIGN §20): the numbers that say whether retrieval is actually working —
|
|
@@ -5413,14 +6198,14 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
5413
6198
|
*/
|
|
5414
6199
|
function HealthBlock(props) {
|
|
5415
6200
|
const r = props.stats.retrieval;
|
|
5416
|
-
if (r === void 0) return h("div", { className:
|
|
6201
|
+
if (r === void 0) return h("div", { className: controls_module_css_default.status }, "检索健康度不可用。");
|
|
5417
6202
|
const vectors = props.stats.vectors;
|
|
5418
6203
|
const pendingVectors = vectors === void 0 ? 0 : vectors.stale + vectors.space_stale;
|
|
5419
6204
|
const wal = props.stats.wal;
|
|
5420
6205
|
const pct = (v) => `${String(Math.round(v * 100))}%`;
|
|
5421
6206
|
const kinds = Object.entries(r.by_kind).map(([kind, v]) => `${kind} ${String(v.queries)} 次(空 ${String(v.zero_results)})`).join(" · ");
|
|
5422
6207
|
const truncated = r.embedding_truncated + r.output_truncated;
|
|
5423
|
-
return h("div", { className: pages_module_css_default.detail }, pendingVectors === 0 ? null : h("div", { className:
|
|
6208
|
+
return h("div", { className: pages_module_css_default.detail }, pendingVectors === 0 ? null : h("div", { className: controls_module_css_default.status }, `⚠ 向量空间待迁移:${String(pendingVectors)} 条 ACTIVE 向量来自旧嵌入空间(宽度不符 ${String(vectors?.stale ?? 0)} · 其他表示 ${String(vectors?.space_stale ?? 0)})——语义腿对它们失效,正在后台分批重算;手动入口 \`avantf-mem vectors --fix\``), wal?.warning === null || wal?.warning === void 0 ? null : h("div", { className: controls_module_css_default.status }, `⚠ ${wal.warning}(当前 ${String(wal.bytes)} 字节)`), h("div", { className: controls_module_css_default.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: controls_module_css_default.meta }, `语义腿在线 ${pct(r.semantic_live_rate)} · 平均命中 ${String(r.avg_results_per_query)} 条`), kinds === "" ? null : h("div", { className: controls_module_css_default.meta }, kinds), props.stats.sources === void 0 && props.stats.validity === void 0 ? null : h("div", { className: controls_module_css_default.meta }, `来源覆盖 ${props.stats.sources === void 0 ? "—" : pct(props.stats.sources.coverage)}(${String(props.stats.sources?.facts_with_source ?? 0)}/${String(props.stats.sources?.active ?? 0)}) · 事件时间覆盖 ${props.stats.validity === void 0 ? "—" : pct(props.stats.validity.coverage)}(${String(props.stats.validity?.facts_with_valid_from ?? 0)}/${String(props.stats.validity?.active ?? 0)})`), h("div", { className: controls_module_css_default.meta }, truncated === 0 ? "未被任何预算截断。" : `截断:嵌入 ${String(r.embedding_truncated)} · 输出 ${String(r.output_truncated)}${r.output_truncated > 0 ? "(调大 retrieval.max_output_tokens 或用 max_tokens 参数)" : ""}`), r.updated_at === null ? null : h("div", { className: controls_module_css_default.meta }, `更新于 ${r.updated_at}`));
|
|
5424
6209
|
}
|
|
5425
6210
|
/** Client cordis-plugin name shown in boot diagnostics. */
|
|
5426
6211
|
const name = "avantf-mem-client";
|