@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.
Files changed (39) hide show
  1. package/lib/client.js +1088 -303
  2. package/lib/engine/graph-recall/index.d.ts +2 -2
  3. package/lib/engine/graph-recall/traverse.d.ts +2 -2
  4. package/lib/engine/graph-recall/types.d.ts +1 -1
  5. package/lib/engine/mem/config/config_files.d.ts +1 -1
  6. package/lib/engine/mem/defaults.d.ts +4 -1
  7. package/lib/engine/mem/entities/extract.d.ts +34 -14
  8. package/lib/engine/mem/index.d.ts +1 -0
  9. package/lib/engine/mem/modelBootstrap.d.ts +29 -0
  10. package/lib/engine/mem/store/graph_reader.d.ts +2 -2
  11. package/lib/engine/mem/store/memory.d.ts +53 -6
  12. package/lib/engine/mem/store/recall_policy.d.ts +10 -0
  13. package/lib/engine/mem-contract/config.d.ts +135 -0
  14. package/lib/engine/mem-contract/types.d.ts +11 -0
  15. package/lib/engine/mem-retrieval/adapters/auto_vstore.d.ts +10 -0
  16. package/lib/engine/mem-retrieval/adapters/hnswlib.d.ts +28 -0
  17. package/lib/engine/mem-retrieval/adapters/local_bge.d.ts +48 -6
  18. package/lib/engine/mem-retrieval/interfaces.d.ts +22 -2
  19. package/lib/engine/mem-retrieval/text_budget.d.ts +31 -0
  20. package/lib/index.js +1617 -114
  21. package/lib/interface-version.json +2 -2
  22. package/lib/types/client/index.d.ts.map +1 -1
  23. package/lib/types/client/paging.d.ts +58 -8
  24. package/lib/types/client/paging.d.ts.map +1 -1
  25. package/lib/types/client/time_filter.d.ts +54 -0
  26. package/lib/types/client/time_filter.d.ts.map +1 -0
  27. package/lib/types/deps_audit.d.ts +88 -0
  28. package/lib/types/deps_audit.d.ts.map +1 -0
  29. package/lib/types/envinit-bootstrap.d.ts +1 -1
  30. package/lib/types/envinit.d.ts +224 -4
  31. package/lib/types/envinit.d.ts.map +1 -1
  32. package/lib/types/hints.d.ts +58 -0
  33. package/lib/types/hints.d.ts.map +1 -1
  34. package/lib/types/index.d.ts.map +1 -1
  35. package/lib/types/render.d.ts.map +1 -1
  36. package/lib/types/runtime_deps_resource.d.ts +29 -0
  37. package/lib/types/runtime_deps_resource.d.ts.map +1 -0
  38. package/package.json +5 -4
  39. 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}.HvfIsq_headbar{z-index:4;border-bottom:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base);flex-wrap:wrap;align-items:flex-end;gap:8px;padding-bottom:8px;display:flex;position:sticky;top:0}.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_searchPanel{border:.5px solid var(--dsw-alias-state-business-primary);background:color-mix(in srgb, var(--dsw-alias-state-business-primary) 7%, transparent);border-radius:10px;flex-direction:column;gap:8px;padding:10px;display:flex}.HvfIsq_searchTitle{color:var(--dsw-alias-label-primary);flex-wrap:wrap;align-items:baseline;gap:6px;font-size:13px;font-weight:600;display:flex}.HvfIsq_searchQuery{color:var(--dsw-alias-label-tertiary);font-size:12px;font-weight:400}.HvfIsq_select{border:.5px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-1);height:28px;color:var(--dsw-alias-label-primary);font:inherit;border-radius:8px;outline:none;padding:0 8px;font-size:12px}.HvfIsq_select:focus-visible{border-color:var(--dsw-alias-state-business-primary);box-shadow:0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 18%, transparent)}.HvfIsq_search>svg{pointer-events:none;position:absolute;left:12px}.HvfIsq_search input{border:.5px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-1);width:100%;height:36px;color:var(--dsw-alias-label-primary);font:inherit;border-radius:10px;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-alias-state-business-primary);box-shadow:0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 18%, transparent)}.HvfIsq_clear{width:20px;height:20px;color:var(--dsw-alias-label-tertiary);font:inherit;cursor:pointer;background:0 0;border:0;border-radius:50%;justify-content:center;align-items:center;padding:0;font-size:16px;line-height:1;display:inline-flex;position:absolute;top:50%;right:8px;transform:translateY(-50%)}.HvfIsq_clear:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}.HvfIsq_clear:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:1px}.HvfIsq_status{color:var(--dsw-alias-label-tertiary);font-size:12px}.HvfIsq_error{color:var(--dsw-alias-label-error);white-space:pre-wrap;font-size:12px}.HvfIsq_list{flex-direction:column;gap:8px;margin:0;padding:0;list-style:none;display:flex}.HvfIsq_item{border:.5px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-1);border-radius:10px;padding:8px 10px;font-size:13px;line-height:1.5}.HvfIsq_meta{color:var(--dsw-alias-label-tertiary);margin-top:2px;font-size:12px;display:block}.HvfIsq_detail{flex-direction:column;gap:4px;display:flex}.HvfIsq_more{border:.5px solid var(--dsw-alias-border-l3);height:28px;color:var(--dsw-alias-label-primary);font:inherit;cursor:pointer;background:0 0;border-radius:14px;justify-content:center;align-self:flex-start;align-items:center;gap:4px;padding:0 12px;font-size:12px;line-height:18px;display:inline-flex}.HvfIsq_more:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.HvfIsq_more:disabled{cursor:not-allowed;opacity:.4}.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_btn{border:.5px solid var(--dsw-alias-border-l3);height:24px;color:var(--dsw-alias-label-primary);font:inherit;cursor:pointer;background:0 0;border-radius:12px;justify-content:center;align-items:center;gap:4px;padding:0 10px;font-size:12px;line-height:16px;display:inline-flex}.HvfIsq_btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.HvfIsq_btn:disabled{cursor:not-allowed;opacity:.4}.HvfIsq_btnActive{border-color:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-state-business-primary)}.HvfIsq_btnDanger{color:var(--dsw-alias-label-error)}.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_input{border:.5px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-1);height:28px;color:var(--dsw-alias-label-primary);font:inherit;border-radius:8px;outline:none;min-width:0;padding:0 8px;font-size:12px}.HvfIsq_input:focus-visible{border-color:var(--dsw-alias-state-business-primary);box-shadow:0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 18%, transparent)}.HvfIsq_textarea{border:.5px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-1);width:100%;color:var(--dsw-alias-label-primary);font:inherit;resize:vertical;box-sizing:border-box;border-radius:8px;outline:none;margin-top:6px;padding:6px 8px;font-size:12px;line-height:1.5}.HvfIsq_textarea:focus-visible{border-color:var(--dsw-alias-state-business-primary);box-shadow:0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 18%, transparent)}.HvfIsq_groupHead{color:var(--dsw-alias-label-secondary);margin:4px 0 2px;font-size:12px;font-weight:600}.HvfIsq_badge{color:var(--dsw-alias-label-tertiary);margin-bottom:2px;font-size:11px;display:inline-block}.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 = "@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
- "error": "HvfIsq_error",
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
- "select": "HvfIsq_select",
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 fact list.
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 facts, dropping ids already present.
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
- * `admin.list` pages by `OFFSET` over `created_at DESC`, and `created_at` has second precision: a
4398
- * fact written (or archived) between two requests shifts every later row by one, so the next page
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 mergePage(previous, page) {
4403
- return mergeById(previous, page, (row) => row.fact_id);
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
- /** The exact page default; fuzzy is fixed at five by the engine. */
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
- * A scroll listener, not an `IntersectionObserver`, for the three reasons this seat makes an
4604
- * observer the wrong tool:
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
- * 1. **`rootMargin` cannot prefetch here.** The panel does not own a scrollport — the conversation's
4607
- * `.scrollBody` does, and an ancestor's clip rect is NOT expanded by `rootMargin` (only the
4608
- * intersection root's is). The promised look-ahead simply never happened.
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
- * `version` re-checks after a page lands: a page that does not fill the scrollport produces no
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 checkRef = react.useRef(() => {});
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 scroller = scrollParentOf(root);
4628
- const target = scroller ?? window;
4629
- let queued = false;
4630
- const check = () => {
4631
- queued = false;
4632
- if (!enabledRef.current || scroller === null) return;
4633
- if (scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - AUTO_LOAD_MARGIN_PX) latest.current();
4634
- };
4635
- checkRef.current = check;
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
- checkRef.current = () => {};
4646
- target.removeEventListener("scroll", onScroll);
4935
+ controllerRef.current = null;
4936
+ controller.dispose();
4647
4937
  };
4648
4938
  }, [rootRef]);
4649
4939
  react.useEffect(() => {
4650
- checkRef.current();
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 ? `${pages_module_css_default.btn} ${pages_module_css_default.btnDanger}` : pages_module_css_default.btn,
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: pages_module_css_default.input,
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: pages_module_css_default.meta }, "声明的显式关系(仅 note;图上的 source=explicit):"), relations.length === 0 ? h("div", { className: pages_module_css_default.status }, "当前没有显式关系。") : h("ul", { className: pages_module_css_default.list }, relations.map((relation, index) => h("li", {
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: pages_module_css_default.item
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: pages_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, {
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
- * How many rows the HOST has handed over — the pagination anchor. It is not `rows.length`:
4770
- * appends are de-duplicated (a row inserted between two page requests shifts the window and
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 [nextOffset, setNextOffset] = react.useState(0);
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
- const [loading, setLoading] = react.useState(props.remote !== void 0);
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 [searchHits, setSearchHits] = react.useState(null);
4784
- const [searchRelation, setSearchRelation] = react.useState(null);
4785
- const [searchFactPage, setSearchFactPage] = react.useState(null);
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 the CURRENT search results need so their action row can match an admin list row:
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
- const searchGuard = useSeqGuard();
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
- * Load one page and append it (offset 0 replaces the list). Resolves once the
4801
- * page settled, so callers can order their own state updates after the refresh.
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 load = react.useCallback(async (offset, status) => {
5272
+ const loadListPage = react.useCallback(async (offset) => {
4804
5273
  if (props.remote === void 0) return;
4805
5274
  const seq = listGuard.begin();
4806
- setLoading(true);
4807
- if (offset === 0) setError(void 0);
4808
- const outcome = await callRemote(`admin.list(status=${status}, offset=${String(offset)})`, props.remote.admin({
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
- if (outcome.ok) {
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
- setRows((previous) => offset === 0 ? page : mergePage(previous, page));
4818
- setNextOffset(offset + page.length);
4819
- setTotal(outcome.value?.total);
4820
- setTruncated(outcome.value?.truncated === true);
4821
- } else {
4822
- if (offset === 0) {
4823
- setRows([]);
4824
- setNextOffset(0);
4825
- setTotal(void 0);
4826
- setTruncated(false);
4827
- }
4828
- setError(outcome.error);
4829
- }
4830
- setLoading(false);
4831
- }, [listGuard, props.remote]);
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
- load(0, statusFilter);
4834
- }, [load, statusFilter]);
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 (!searchGuard.isCurrent(seq)) return;
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(next);
5364
+ setSearchFacts((previous) => ({
5365
+ ...previous,
5366
+ ...next
5367
+ }));
4871
5368
  });
4872
5369
  };
4873
- /** Search memory through the same `recall` remote the model tool uses. */
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
- if (props.remote === void 0) return;
5432
+ const remote = props.remote;
5433
+ if (remote === void 0) return;
4876
5434
  const query = searchText.trim();
4877
- if (query === "") {
4878
- searchGuard.begin();
4879
- setSearchHits(null);
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 = searchGuard.begin();
5440
+ const seq = listGuard.begin();
5441
+ const factsSeq = searchFactsGuard.begin();
5442
+ searchFactsSeq.current = factsSeq;
4888
5443
  setSearching(true);
4889
5444
  setError(void 0);
4890
- const outcome = await callRemote(`recall.search(${query}${searchKind === "all" ? "" : `, kind=${searchKind}`})`, props.remote.recall({
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 (!searchGuard.isCurrent(seq)) return;
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
- setSearchRelation({
4900
- paths: value.paths,
4901
- evidence: value.evidence
5482
+ setApplied((current) => current === null ? null : {
5483
+ ...current,
5484
+ mode: "relation"
4902
5485
  });
4903
- setSearchHits(null);
4904
- setSearchFactPage(null);
4905
- loadSearchFacts(seq, value.evidence.map((entry) => entry.fact_id));
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
- setSearchFactPage(value.facts);
4908
- setSearchHits(null);
4909
- setSearchRelation(null);
4910
- loadSearchFacts(seq, value.facts.map((entry) => entry.fact_id));
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
- setSearchHits(value.hits);
4913
- setSearchRelation(null);
4914
- setSearchFactPage(null);
4915
- loadSearchFacts(seq, value.hits.map((hit) => hit.ref_id));
4916
- } else {
4917
- setSearchHits([]);
4918
- setSearchRelation(null);
4919
- setSearchFactPage(null);
4920
- setSearchFacts({});
4921
- if (!outcome.ok) setError(outcome.error);
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
- /** Clear the query and the search result view (never the memory rows themselves). */
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
- searchGuard.begin();
5624
+ listGuard.begin();
5625
+ searchFactsGuard.begin();
4928
5626
  setSearchText("");
4929
- setSearchHits(null);
4930
- setSearchRelation(null);
4931
- setSearchFactPage(null);
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: clear the other tab's page immediately, and drop its in-flight page. */
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
- setRows([]);
4941
- setNextOffset(0);
4942
- setTotal(void 0);
4943
- setTruncated(false);
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
- /** Run one mutation, refresh page 0, then surface the mutation's own outcome. */
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: () => load(0, statusFilter),
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 the search results.
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
- * The regression this replaced: the actions were written inline in the list row, so when
5027
- * `searchActive` hid the list, a hit's 「详情」 had nowhere to render and there was no way to
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: loading || editing.text.trim().length === 0,
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: loading,
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: loading,
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: loading,
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: loading,
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: loading,
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: loading,
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: loading,
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: loading,
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: loading,
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
- const summary = total === void 0 ? `已显示 ${String(shown)} 条` : `共 ${String(total)} 条${statusFilter === "active" ? "活动" : "归档"}事实,已显示 ${String(shown)} 条` + (truncated ? "(继续滚动自动加载)" : "");
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
- /** Whether the search view owns the panel (the admin list must not compete with it). */
5237
- const searchActive = searching || searchHits !== null || searchRelation !== null || searchFactPage !== null;
5238
- /** Load the next page once the list's end is within reach of the scrollport. */
5239
- const maybeLoadMore = () => {
5240
- if (searchActive || !truncated || loading || props.remote === void 0) return;
5241
- load(nextOffset, statusFilter);
5242
- };
5243
- useLoadMoreOnScroll(rootRef, truncated && !loading, maybeLoadMore, rows.length);
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: pages_module_css_default.error }, NOT_MOUNTED) : null, props.remote === void 0 ? null : h("div", { className: pages_module_css_default.headbar }, h("button", {
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" ? `${pages_module_css_default.btn} ${pages_module_css_default.btnActive}` : pages_module_css_default.btn,
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" ? `${pages_module_css_default.btn} ${pages_module_css_default.btnActive}` : pages_module_css_default.btn,
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: loading,
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: loading,
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: loading,
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: pages_module_css_default.status }, loading && shown === 0 ? "读取中…" : summary)), 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", {
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 !== "" || searchHits !== null || searchFactPage !== null || searchRelation !== null ? h("button", {
6084
+ }), searchText !== "" || timeCondition !== void 0 || applied !== null ? h("button", {
5295
6085
  type: "button",
5296
- className: pages_module_css_default.clear,
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: pages_module_css_default.select,
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"))), searchActive ? h("div", { className: pages_module_css_default.searchPanel }, h("div", { className: pages_module_css_default.searchTitle }, searching ? "搜索中…" : searchFactPage !== null ? `多实体事实:${String(searchFactPage.length)} 条${searchKind === "all" ? "" : `(${searchKind})`}` : searchRelation !== null ? `关系结果:${String(searchRelation.paths.length)} 条路径${searchKind === "all" ? "" : `(${searchKind})`}` : `搜索结果:${String(searchHits?.length ?? 0)} 条${searchKind === "all" ? "" : `(${searchKind})`}`, searchText.trim() === "" ? null : h("span", { className: pages_module_css_default.searchQuery }, `“${searchText.trim()}”`)), searchFactPage !== null ? h("div", null, searchFactPage.length === 0 ? h("div", { className: pages_module_css_default.status }, "没有同时提到这些实体的事实。") : h("ul", { className: pages_module_css_default.list }, searchFactPage.map((entry) => h("li", {
5308
- key: `facts-${String(entry.fact_id)}`,
5309
- className: pages_module_css_default.item
5310
- }, entry.title === void 0 ? null : h("div", { className: pages_module_css_default.meta }, `标题:${entry.title}`), editing?.id === entry.fact_id ? h("textarea", {
5311
- className: pages_module_css_default.textarea,
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
- setEditing({
5316
- id: entry.fact_id,
5317
- text: e.target.value
5318
- });
6103
+ setTimeField(e.target.value);
5319
6104
  }
5320
- }) : entry.content, entry.truncated === true ? h("span", { className: pages_module_css_default.meta }, "(已截断)") : null, h("span", { className: pages_module_css_default.meta }, `#${String(entry.fact_id)} · ${entry.memory_kind} · ${entry.status} · ${entry.event_date ?? entry.created_at}` + (entry.source_ref === void 0 ? "" : ` · ${entry.source_ref}`)), h("div", { className: pages_module_css_default.row }, factButtons(actionFact(entry.fact_id, entry.content, entry.truncated === true), true)), detail?.id === entry.fact_id ? h(FactDetailView, {
5321
- fact: detail.data,
5322
- disabled: loading,
5323
- onSaveRelations: saveRelations
5324
- }) : null)))) : searchRelation !== null ? h("div", null, searchRelation.paths.length === 0 ? h("div", { className: pages_module_css_default.status }, "没有关系路径。") : h("ul", { className: pages_module_css_default.list }, searchRelation.paths.map((path, index) => h("li", {
5325
- key: `relation-path-${String(index)}`,
5326
- className: pages_module_css_default.item
5327
- }, [path.start.name, ...path.edges.flatMap((edge) => [edge.predicate, edge.object])].join(" → "), h("span", { className: pages_module_css_default.meta }, `${String(path.hops)} 跳 · #${path.edges.map((edge) => String(edge.fact_id)).join(" / ")} · 置信度 ${path.confidence.toFixed(2)}`)))), searchRelation.evidence.length === 0 ? null : h("div", null, h("div", { className: pages_module_css_default.meta }, "证据事实:"), h("ul", { className: pages_module_css_default.list }, searchRelation.evidence.map((entry) => h("li", {
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
- setEditing({
5336
- id: entry.fact_id,
5337
- text: e.target.value
5338
- });
6114
+ setTimeMode(e.target.value);
5339
6115
  }
5340
- }) : entry.text, entry.truncated === true ? h("span", { className: pages_module_css_default.meta }, "(已截断)") : null, h("span", { className: pages_module_css_default.meta }, `#${String(entry.fact_id)} · ${entry.memory_kind} · ${entry.created_at}`), h("div", { className: pages_module_css_default.row }, factButtons(actionFact(entry.fact_id, entry.text, entry.truncated === true), true)), detail?.id === entry.fact_id ? h(FactDetailView, {
5341
- fact: detail.data,
5342
- disabled: loading,
5343
- onSaveRelations: saveRelations
5344
- }) : null))))) : searchHits === null || searchHits.length === 0 ? h("div", { className: pages_module_css_default.status }, searching ? "正在检索记忆…" : "没有命中。") : h("ul", { className: pages_module_css_default.list }, searchHits.map((hit) => h("li", {
5345
- key: hit.ref_id,
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
- setEditing({
5353
- id: hit.ref_id,
5354
- text: e.target.value
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
- }) : hit.text, hit.truncated === true ? h("span", { className: pages_module_css_default.meta }, "(已截断)") : null, h("span", { className: pages_module_css_default.meta }, `#${String(hit.ref_id)} · score ${hit.score.toFixed(2)} · ${hit.source_ref}` + (hit.valid_from === void 0 ? "" : ` · ${hit.valid_from}`)), h("div", { className: pages_module_css_default.row }, factButtons(actionFact(hit.ref_id, hit.text, hit.truncated === true), true)), detail?.id === hit.ref_id ? h(FactDetailView, {
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: pages_module_css_default.item
5364
- }, `#${String(c.fact_a)}「${c.content_a}」 ↔ #${String(c.fact_b)}「${c.content_b}」`, h("span", { className: pages_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, {
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: loading,
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: loading,
6169
+ disabled: busy,
5373
6170
  onClick: () => resolveConflict(c, "true_positive", c.fact_b)
5374
6171
  }), h(Btn, {
5375
6172
  label: "误报(两条都对)",
5376
- disabled: loading,
6173
+ disabled: busy,
5377
6174
  onClick: () => resolveConflict(c, "false_positive")
5378
- })) : null))) : null, searchActive ? null : h("ul", { className: pages_module_css_default.list }, rows.map((f) => h("li", {
5379
- key: f.fact_id,
5380
- className: pages_module_css_default.item
5381
- }, f.title === null || f.title === void 0 ? f.kind === "journal" ? h("div", { className: pages_module_css_default.meta }, "标题:(无标题)") : null : h("div", { className: pages_module_css_default.meta }, `标题:${f.title}`), editing?.id === f.fact_id ? h("textarea", {
5382
- className: pages_module_css_default.textarea,
5383
- value: editing.text,
5384
- rows: 3,
5385
- onChange: (e) => {
5386
- setEditing({
5387
- id: f.fact_id,
5388
- text: e.target.value
5389
- });
5390
- }
5391
- }) : f.content, h("span", { className: pages_module_css_default.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 ? " · 永久保留(不会被清理)" : "")), h("div", { className: pages_module_css_default.row }, factButtons({
5392
- fact_id: f.fact_id,
5393
- status: f.status,
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
- }, loading ? "加载中…" : total === void 0 ? "加载更多" : `加载更多(还有 ${String(Math.max(total - shown, 0))} 条)`));
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: pages_module_css_default.status }, "检索健康度不可用。");
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: pages_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: pages_module_css_default.status }, `⚠ ${wal.warning}(当前 ${String(wal.bytes)} 字节)`), h("div", { className: pages_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: pages_module_css_default.meta }, `语义腿在线 ${pct(r.semantic_live_rate)} · 平均命中 ${String(r.avg_results_per_query)} 条`), kinds === "" ? null : h("div", { className: pages_module_css_default.meta }, kinds), props.stats.sources === void 0 && props.stats.validity === void 0 ? null : h("div", { className: pages_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: pages_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: pages_module_css_default.meta }, `更新于 ${r.updated_at}`));
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";