@avantf/dsh-mem 0.8.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -43
- package/lib/client.js +1181 -327
- package/lib/engine/graph-recall/index.d.ts +2 -2
- package/lib/engine/graph-recall/traverse.d.ts +2 -2
- package/lib/engine/graph-recall/types.d.ts +1 -1
- package/lib/engine/mem/config/config_files.d.ts +1 -1
- package/lib/engine/mem/db/dao/facts.d.ts +80 -82
- package/lib/engine/mem/dbdrivers/sqlite/facts.d.ts +6 -13
- package/lib/engine/mem/defaults.d.ts +4 -1
- package/lib/engine/mem/entities/extract.d.ts +34 -14
- package/lib/engine/mem/facts_io.d.ts +4 -0
- package/lib/engine/mem/index.d.ts +1 -0
- package/lib/engine/mem/interfaces/repositories.d.ts +5 -19
- package/lib/engine/mem/lifecycle/maintenance.d.ts +8 -4
- package/lib/engine/mem/lifecycle/tick.d.ts +11 -10
- package/lib/engine/mem/lifecycle/trust.d.ts +24 -37
- package/lib/engine/mem/modelBootstrap.d.ts +29 -0
- package/lib/engine/mem/runtime.d.ts +4 -8
- package/lib/engine/mem/store/facts_restore.d.ts +8 -6
- package/lib/engine/mem/store/graph_reader.d.ts +2 -2
- package/lib/engine/mem/store/memory.d.ts +98 -37
- package/lib/engine/mem/store/recall_policy.d.ts +10 -0
- package/lib/engine/mem/store/time_filter.d.ts +1 -1
- package/lib/engine/mem/trustHeartbeat.d.ts +1 -1
- package/lib/engine/mem-contract/config.d.ts +144 -9
- package/lib/engine/mem-contract/index.d.ts +2 -0
- package/lib/engine/mem-contract/time.d.ts +75 -0
- package/lib/engine/mem-contract/tools.d.ts +9 -15
- package/lib/engine/mem-contract/trust_curve.d.ts +119 -0
- package/lib/engine/mem-contract/types.d.ts +73 -12
- package/lib/engine/mem-retrieval/adapters/auto_vstore.d.ts +10 -0
- package/lib/engine/mem-retrieval/adapters/hnswlib.d.ts +28 -0
- package/lib/engine/mem-retrieval/adapters/local_bge.d.ts +48 -6
- package/lib/engine/mem-retrieval/interfaces.d.ts +22 -2
- package/lib/engine/mem-retrieval/text_budget.d.ts +31 -0
- package/lib/index.js +2349 -665
- package/lib/interface-version.json +2 -2
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/paging.d.ts +58 -8
- package/lib/types/client/paging.d.ts.map +1 -1
- package/lib/types/client/time_filter.d.ts +54 -0
- package/lib/types/client/time_filter.d.ts.map +1 -0
- package/lib/types/command.d.ts.map +1 -1
- package/lib/types/deps_audit.d.ts +88 -0
- package/lib/types/deps_audit.d.ts.map +1 -0
- package/lib/types/envinit-bootstrap.d.ts +1 -1
- package/lib/types/envinit.d.ts +224 -4
- package/lib/types/envinit.d.ts.map +1 -1
- package/lib/types/hints.d.ts +58 -0
- package/lib/types/hints.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/remote.d.ts.map +1 -1
- package/lib/types/render.d.ts.map +1 -1
- package/lib/types/runtime_deps_resource.d.ts +29 -0
- package/lib/types/runtime_deps_resource.d.ts.map +1 -0
- package/package.json +26 -24
- package/res/runtime-deps.yaml +99 -0
package/lib/client.js
CHANGED
|
@@ -29,34 +29,25 @@ window.__ModuleLoader__.load({
|
|
|
29
29
|
let react = require("react");
|
|
30
30
|
react = __toESM(react, 1);
|
|
31
31
|
//#region \0dsh-css:src/client/pages.module.css.mjs
|
|
32
|
-
const css = ".HvfIsq_section{color:var(--dsw-alias-label-primary);width:100%;max-width:var(--dsh-chat-content-width);flex-direction:column;gap:12px;margin:0 auto;display:flex}.
|
|
33
|
-
const tagId = "@avantf/dsh-mem/pages.module.css";
|
|
34
|
-
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
32
|
+
const css$1 = ".HvfIsq_section{color:var(--dsw-alias-label-primary);width:100%;max-width:var(--dsh-chat-content-width);flex-direction:column;gap:12px;margin:0 auto;display:flex}.HvfIsq_stickyTop{z-index:4;border-bottom:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base);flex-direction:column;gap:8px;padding-bottom:8px;display:flex;position:sticky;top:0}.HvfIsq_headbar{flex-wrap:wrap;align-items:flex-end;gap:8px;display:flex}.HvfIsq_warn{color:var(--dsw-alias-state-warn-label);background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 16%, transparent);border-radius:9px;margin-left:6px;padding:0 6px;font-size:11px;line-height:16px;display:inline-block}.HvfIsq_search{width:100%;color:var(--dsw-alias-label-tertiary);align-items:center;display:flex;position:relative}.HvfIsq_searchField{flex:260px;width:auto}.HvfIsq_edit{margin-top:6px}.HvfIsq_search>svg{pointer-events:none;position:absolute;left:12px}.HvfIsq_search input{border:.5px solid var(--dsw-alias-border-l4);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-1);width:100%;height:36px;color:var(--dsw-alias-label-primary);font:inherit;outline:none;padding:0 34px 0 36px;font-size:13px}.HvfIsq_search input::placeholder{color:var(--dsw-alias-label-tertiary)}.HvfIsq_search input:focus-visible{border-color:var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));box-shadow:0 0 0 2px color-mix(in srgb, var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary)) 18%, transparent)}.HvfIsq_search>button{position:absolute;top:50%;right:8px;transform:translateY(-50%)}.HvfIsq_detail{flex-direction:column;gap:4px;display:flex}.HvfIsq_more{align-self:flex-start}.HvfIsq_toolbar{flex-wrap:wrap;align-items:flex-end;gap:8px;display:flex}.HvfIsq_form{flex-direction:column;gap:8px;display:flex}.HvfIsq_row{flex-wrap:wrap;align-items:center;gap:6px;margin-top:6px;display:flex}.HvfIsq_fieldHead{justify-content:space-between;align-items:center;gap:8px;display:flex}.HvfIsq_fieldHead>*+*{flex:none}.HvfIsq_field{color:var(--dsw-alias-label-tertiary);flex-direction:column;flex:auto;gap:4px;min-width:120px;font-size:12px;display:flex}.HvfIsq_groupHead{color:var(--dsw-alias-label-secondary);margin:4px 0 2px;font-size:12px;font-weight:600}.HvfIsq_chunkBox{border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;margin-top:6px;padding:6px 8px;font-size:12px;line-height:1.5}.HvfIsq_picker{border:.5px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-1);border-radius:10px;flex-direction:column;gap:8px;padding:8px 10px;display:flex}.HvfIsq_pickerHead{flex-direction:column;gap:2px;display:flex}.HvfIsq_pickerPath{overflow-wrap:anywhere;font-family:ui-monospace,monospace;font-size:12px}.HvfIsq_pickerList{border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;flex-direction:column;gap:1px;max-height:260px;margin:0;padding:0;list-style:none;display:flex;overflow:auto}.HvfIsq_pickerEntry{width:100%;color:inherit;font:inherit;text-align:left;cursor:pointer;background:0 0;border:0;padding:4px 8px;font-size:12px;display:block}.HvfIsq_pickerEntry:hover:not(:disabled){background:color-mix(in srgb, currentColor 8%, transparent)}.HvfIsq_pickerDisabled{opacity:.45;cursor:default}.HvfIsq_hintWarn{color:var(--dsw-alias-state-warn-label)}";
|
|
33
|
+
const tagId$1 = "@avantf/dsh-mem/pages.module.css";
|
|
34
|
+
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$1) + "]") === null) {
|
|
35
35
|
const tag = document.createElement("style");
|
|
36
36
|
tag.dataset.plugin = "@avantf/dsh-mem";
|
|
37
|
-
tag.dataset.pluginCss = tagId;
|
|
38
|
-
tag.textContent = css;
|
|
37
|
+
tag.dataset.pluginCss = tagId$1;
|
|
38
|
+
tag.textContent = css$1;
|
|
39
39
|
document.head.appendChild(tag);
|
|
40
40
|
}
|
|
41
41
|
var pages_module_css_default = {
|
|
42
|
-
"badge": "HvfIsq_badge",
|
|
43
|
-
"btn": "HvfIsq_btn",
|
|
44
|
-
"btnActive": "HvfIsq_btnActive",
|
|
45
|
-
"btnDanger": "HvfIsq_btnDanger",
|
|
46
42
|
"chunkBox": "HvfIsq_chunkBox",
|
|
47
|
-
"clear": "HvfIsq_clear",
|
|
48
43
|
"detail": "HvfIsq_detail",
|
|
49
|
-
"
|
|
44
|
+
"edit": "HvfIsq_edit",
|
|
50
45
|
"field": "HvfIsq_field",
|
|
51
46
|
"fieldHead": "HvfIsq_fieldHead",
|
|
52
47
|
"form": "HvfIsq_form",
|
|
53
48
|
"groupHead": "HvfIsq_groupHead",
|
|
54
49
|
"headbar": "HvfIsq_headbar",
|
|
55
50
|
"hintWarn": "HvfIsq_hintWarn",
|
|
56
|
-
"input": "HvfIsq_input",
|
|
57
|
-
"item": "HvfIsq_item",
|
|
58
|
-
"list": "HvfIsq_list",
|
|
59
|
-
"meta": "HvfIsq_meta",
|
|
60
51
|
"more": "HvfIsq_more",
|
|
61
52
|
"picker": "HvfIsq_picker",
|
|
62
53
|
"pickerDisabled": "HvfIsq_pickerDisabled",
|
|
@@ -67,13 +58,8 @@ window.__ModuleLoader__.load({
|
|
|
67
58
|
"row": "HvfIsq_row",
|
|
68
59
|
"search": "HvfIsq_search",
|
|
69
60
|
"searchField": "HvfIsq_searchField",
|
|
70
|
-
"searchPanel": "HvfIsq_searchPanel",
|
|
71
|
-
"searchQuery": "HvfIsq_searchQuery",
|
|
72
|
-
"searchTitle": "HvfIsq_searchTitle",
|
|
73
61
|
"section": "HvfIsq_section",
|
|
74
|
-
"
|
|
75
|
-
"status": "HvfIsq_status",
|
|
76
|
-
"textarea": "HvfIsq_textarea",
|
|
62
|
+
"stickyTop": "HvfIsq_stickyTop",
|
|
77
63
|
"toolbar": "HvfIsq_toolbar",
|
|
78
64
|
"warn": "HvfIsq_warn"
|
|
79
65
|
};
|
|
@@ -4174,11 +4160,9 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4174
4160
|
content: optionalText.optional(),
|
|
4175
4161
|
fact_id: count.optional(),
|
|
4176
4162
|
kind: optionalText.optional(),
|
|
4177
|
-
ttl_days: count.optional(),
|
|
4178
4163
|
reason: optionalText.optional(),
|
|
4179
4164
|
source_ref: optionalText.optional(),
|
|
4180
4165
|
event_date: optionalText.optional(),
|
|
4181
|
-
valid_until: optionalText.optional(),
|
|
4182
4166
|
title: optionalText.optional(),
|
|
4183
4167
|
retitle: bool.optional(),
|
|
4184
4168
|
relations: array(object({
|
|
@@ -4328,6 +4312,86 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4328
4312
|
return violations && violations.length > 0 ? violations.join(";") : "";
|
|
4329
4313
|
}
|
|
4330
4314
|
//#endregion
|
|
4315
|
+
//#region ../contract/lib/time.js
|
|
4316
|
+
/**
|
|
4317
|
+
* The ONE place a stored timestamp turns into a string a human (or a model) reads.
|
|
4318
|
+
*
|
|
4319
|
+
* ── THE BUG THIS MODULE EXISTS FOR ───────────────────────────────────────────────────────────────
|
|
4320
|
+
*
|
|
4321
|
+
* `facts.created_at` / `updated_at` / `archived_at` (and `contradiction_log.detected_at`, and the
|
|
4322
|
+
* retrieval-health stamp) are written by SQLite's `DEFAULT CURRENT_TIMESTAMP`, which is **UTC**, and
|
|
4323
|
+
* `store/memory.ts` used to hand the raw text up to every surface. Two consequences, both reported as
|
|
4324
|
+
* "记忆时间显示不对":
|
|
4325
|
+
*
|
|
4326
|
+
* 1. the UI printed `2026-10-28 11:58:48` as if it were a local wall clock (Asia/Shanghai really
|
|
4327
|
+
* meant `19:58:48`), because a zone-less string cannot say otherwise;
|
|
4328
|
+
* 2. the MODEL received the same zone-less string next to `event_date`, which is a calendar DATE
|
|
4329
|
+
* with no time and no zone. Two different kinds of value, one shape — the model had to guess
|
|
4330
|
+
* which one it was looking at, and it cannot.
|
|
4331
|
+
*
|
|
4332
|
+
* ── THE SPLIT THIS MODULE ENFORCES ───────────────────────────────────────────────────────────────
|
|
4333
|
+
*
|
|
4334
|
+
* Storage stays UTC and stays byte-identical (`facts_io` export/import/snapshot is a compatibility
|
|
4335
|
+
* surface and is deliberately NOT routed through here). The fix is in the two DISPLAY faces:
|
|
4336
|
+
*
|
|
4337
|
+
* - {@link formatOffsetIso} — the MODEL face (`mem_recall` hits, `mem_admin list/detail/
|
|
4338
|
+
* contradictions`, `mem_admin stats`, the CLI and MCP that print those payloads): an ISO-8601
|
|
4339
|
+
* instant **with an explicit offset**, e.g. `2026-10-28T19:58:48+08:00`, rendered in the host's
|
|
4340
|
+
* local zone. The offset is part of the string, so the model needs no conversion and the value is
|
|
4341
|
+
* never ambiguous again — while `event_date` stays `2026-10-28`, a calendar date with no zone.
|
|
4342
|
+
* - {@link formatLocalHuman} — the HUMAN face (the 记忆 tab): `2026-10-28 19:58`, the same instant,
|
|
4343
|
+
* local, to the minute.
|
|
4344
|
+
*
|
|
4345
|
+
* Both read the input through the SAME parser ({@link parseUtcTs}) and the same instant, so the two
|
|
4346
|
+
* faces cannot disagree. The UI deliberately formats the model face's OWN string rather than
|
|
4347
|
+
* re-deriving one from the raw column: the input it gets already carries an offset, `parseUtcTs`
|
|
4348
|
+
* leaves an explicit offset alone, and there is therefore exactly ONE parse rule and ONE pair of
|
|
4349
|
+
* renderers in the repository — not a second opinion in the browser.
|
|
4350
|
+
*
|
|
4351
|
+
* Dependency-free on purpose: this module is reachable from the DSH client bundle through the
|
|
4352
|
+
* `@avantf/mem-contract/time` subpath (the bundle must not pull in the tool schemas / zod). It has no
|
|
4353
|
+
* DSH host and no DB, so the engine, the CLI and the MCP face can all use it.
|
|
4354
|
+
*
|
|
4355
|
+
* @module contract/time
|
|
4356
|
+
*/
|
|
4357
|
+
/** Two-digit, zero-padded — the only formatting primitive the two renderers share. */
|
|
4358
|
+
function pad2(value) {
|
|
4359
|
+
return String(value).padStart(2, "0");
|
|
4360
|
+
}
|
|
4361
|
+
/**
|
|
4362
|
+
* `'YYYY-MM-DD HH:MM:SS'` (SQLite UTC) or ISO → epoch ms; null/garbage → null.
|
|
4363
|
+
*
|
|
4364
|
+
* A stamp with NO zone designator is read as UTC, not as local time. `Date.parse` treats a zone-less
|
|
4365
|
+
* ISO string as LOCAL, and everything in this store is written in UTC (`CURRENT_TIMESTAMP`, and
|
|
4366
|
+
* {@link formatUtcTs}), so handing one over as-is would shift every quota window and every decay
|
|
4367
|
+
* measurement by the host's UTC offset — a fact reinforced at 23:00 UTC+8 would look reinforced 8
|
|
4368
|
+
* hours earlier, and the 24h cap would open or close at the wrong moment. An explicit `Z` or
|
|
4369
|
+
* `±HH:MM` is left alone (that is what makes a `formatOffsetIso` output safe to feed back in).
|
|
4370
|
+
*/
|
|
4371
|
+
function parseUtcTs(ts) {
|
|
4372
|
+
if (ts === null || ts === void 0 || ts === "") return null;
|
|
4373
|
+
const iso = ts.includes("T") ? ts : ts.replace(" ", "T");
|
|
4374
|
+
const zoned = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(iso) ? iso : `${iso}Z`;
|
|
4375
|
+
const ms = Date.parse(zoned);
|
|
4376
|
+
return Number.isFinite(ms) ? ms : null;
|
|
4377
|
+
}
|
|
4378
|
+
/**
|
|
4379
|
+
* The HUMAN face: the same instant as local wall-clock text to the minute (`2026-10-28 19:58`).
|
|
4380
|
+
*
|
|
4381
|
+
* Seconds are deliberately dropped: the list/detail lines and the health line are read at a glance,
|
|
4382
|
+
* and `HH:MM` is the whole useful resolution there. `null` for an absent/unreadable value (same
|
|
4383
|
+
* contract as {@link formatOffsetIso}), so a caller prints its own 「未知」/`—` placeholder.
|
|
4384
|
+
*
|
|
4385
|
+
* NOT for `event_date` / `valid_from`: those are calendar dates the CALLER wrote (`2026-10-28`), with
|
|
4386
|
+
* no time and no zone. Running one through here would invent a midnight in the host's zone.
|
|
4387
|
+
*/
|
|
4388
|
+
function formatLocalHuman(ts) {
|
|
4389
|
+
const ms = parseUtcTs(ts);
|
|
4390
|
+
if (ms === null) return null;
|
|
4391
|
+
const at = new Date(ms);
|
|
4392
|
+
return `${String(at.getFullYear())}-${pad2(at.getMonth() + 1)}-${pad2(at.getDate())} ${pad2(at.getHours())}:${pad2(at.getMinutes())}`;
|
|
4393
|
+
}
|
|
4394
|
+
//#endregion
|
|
4331
4395
|
//#region src/client/summarize.ts
|
|
4332
4396
|
/**
|
|
4333
4397
|
* Short, page-friendly rendering of a tool result — never a JSON wall.
|
|
@@ -4383,31 +4447,135 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4383
4447
|
//#endregion
|
|
4384
4448
|
//#region src/client/paging.ts
|
|
4385
4449
|
/**
|
|
4386
|
-
* Page merging for the
|
|
4450
|
+
* Page merging for the tab's ONE list.
|
|
4387
4451
|
*
|
|
4388
4452
|
* Kept out of `client/index.ts` so it can be tested without a DOM (the same reason `summarize.ts`
|
|
4389
4453
|
* lives on its own): this is where page drift is handled, and page drift is the kind of bug that
|
|
4390
4454
|
* only shows up as a React key warning plus a row nobody can scroll to.
|
|
4391
4455
|
*
|
|
4456
|
+
* There used to be TWO helpers here — one keyed by `fact_id` for the admin list and one by `ref_id` for
|
|
4457
|
+
* a time window's results — because the tab kept two lists. It keeps one now (`ListState`), so there is
|
|
4458
|
+
* one merge: the row's React key is the identity, and both sources are appended through it.
|
|
4459
|
+
*
|
|
4392
4460
|
* @module client/paging
|
|
4393
4461
|
*/
|
|
4394
4462
|
/**
|
|
4395
|
-
* Append one page of
|
|
4463
|
+
* Append one page of list rows, dropping keys already present.
|
|
4464
|
+
*
|
|
4465
|
+
* Both sources page by `OFFSET` over `created_at DESC`, and `created_at` has second precision: a fact
|
|
4466
|
+
* written (or archived) between two requests shifts every later row by one, so the next page repeats a
|
|
4467
|
+
* row that is already on screen. The duplicate is not cosmetic — it is a React key collision
|
|
4468
|
+
* (`key={row.key}`) and a row that can never be scrolled to.
|
|
4396
4469
|
*
|
|
4397
|
-
*
|
|
4398
|
-
*
|
|
4399
|
-
* repeats a row that is already on screen. The duplicate is not cosmetic — it is a React key
|
|
4400
|
-
* collision (`key={fact_id}`) and a row that can never be scrolled to.
|
|
4470
|
+
* A fact-shaped row's `key` is `fact:<id>`, so this stays exactly as strict as the two helpers it
|
|
4471
|
+
* replaced; a relation path row is keyed by its position and cannot collide with one.
|
|
4401
4472
|
*/
|
|
4402
|
-
function
|
|
4403
|
-
|
|
4404
|
-
|
|
4405
|
-
/** Append the rows whose id is not already loaded, preserving order; identity when nothing is new. */
|
|
4406
|
-
function mergeById(previous, page, idOf) {
|
|
4407
|
-
const seen = new Set(previous.map(idOf));
|
|
4408
|
-
const added = page.filter((row) => !seen.has(idOf(row)));
|
|
4473
|
+
function mergeRows(previous, page) {
|
|
4474
|
+
const seen = new Set(previous.map((row) => row.key));
|
|
4475
|
+
const added = page.filter((row) => !seen.has(row.key));
|
|
4409
4476
|
return added.length === 0 ? previous : [...previous, ...added];
|
|
4410
4477
|
}
|
|
4478
|
+
/**
|
|
4479
|
+
* Fold the pages of a WALKED window into the list's rows (REVIEW-2026-10-27 P1-1).
|
|
4480
|
+
*
|
|
4481
|
+
* The walk in `runTimeWindow` requests page after page from `offset` 0, and the drift the merge above
|
|
4482
|
+
* guards against happens BETWEEN those requests just as it does between two scroll-triggered pages: a
|
|
4483
|
+
* fact written while page 2 is in flight shifts the window, so page 3 repeats a row of page 2. Collecting
|
|
4484
|
+
* the raw hits and mapping them once (what the walk did) therefore duplicated a row inside the FIRST
|
|
4485
|
+
* render — a duplicate React key, a card that cannot be scrolled to, and a `total - rows.length` that
|
|
4486
|
+
* under-reports. Folding through {@link mergeRows} makes the walk and the continuation the same rule.
|
|
4487
|
+
*
|
|
4488
|
+
* Note what this does NOT touch: the host-handed offsets (`nextOffset`) still advance by the page's own
|
|
4489
|
+
* length, never by the de-duplicated row count — the cursor follows the HOST's paging, the rows follow
|
|
4490
|
+
* what is actually on screen.
|
|
4491
|
+
*/
|
|
4492
|
+
function mergeRowPages(pages) {
|
|
4493
|
+
return pages.reduce((rows, page) => mergeRows(rows, [...page]), []);
|
|
4494
|
+
}
|
|
4495
|
+
/**
|
|
4496
|
+
* Append ONE continuation page of a walked time window (`loadSearchPage`), and decide whether the window
|
|
4497
|
+
* still has a page to fetch (REVIEW-2026-10-27 P1-2).
|
|
4498
|
+
*
|
|
4499
|
+
* `truncated` is the host's own rule — `offset + hits.length < total` — and NOT "did the row count grow".
|
|
4500
|
+
* A page whose rows are all already on screen (extreme, but real: many facts inserted at the top between
|
|
4501
|
+
* two requests shift every later row) adds nothing to `rows`, yet the anchor has moved, so claiming "no
|
|
4502
|
+
* more rows" there stops the window for good while half of it is still unread.
|
|
4503
|
+
*
|
|
4504
|
+
* The one case that DOES stop is a page with no rows at all: the anchor cannot advance
|
|
4505
|
+
* (`offset + 0 === offset`), so keeping `truncated` true would re-ask the same offset on every scroll.
|
|
4506
|
+
* That is the empty page the host answers with when it has nothing more, not a repeated page.
|
|
4507
|
+
*/
|
|
4508
|
+
function appendWindowPage(previous, page) {
|
|
4509
|
+
const nextOffset = page.offset + page.rows.length;
|
|
4510
|
+
return {
|
|
4511
|
+
rows: mergeRows(previous.rows, page.rows),
|
|
4512
|
+
nextOffset,
|
|
4513
|
+
truncated: page.rows.length > 0 && nextOffset < page.total
|
|
4514
|
+
};
|
|
4515
|
+
}
|
|
4516
|
+
//#endregion
|
|
4517
|
+
//#region src/client/time_filter.ts
|
|
4518
|
+
/**
|
|
4519
|
+
* The three time axes, with the labels the toolbar shows. The axis is REQUIRED by the contract (D1)
|
|
4520
|
+
* and the three do not substitute for one another, so the control never defaults to "any time".
|
|
4521
|
+
*/
|
|
4522
|
+
const TIME_AXES = [
|
|
4523
|
+
{
|
|
4524
|
+
value: "created",
|
|
4525
|
+
label: "入库时间"
|
|
4526
|
+
},
|
|
4527
|
+
{
|
|
4528
|
+
value: "updated",
|
|
4529
|
+
label: "修改时间"
|
|
4530
|
+
},
|
|
4531
|
+
{
|
|
4532
|
+
value: "event",
|
|
4533
|
+
label: "事件时间"
|
|
4534
|
+
}
|
|
4535
|
+
];
|
|
4536
|
+
/** The panel-facing name of one axis (same list as {@link TIME_AXES}). */
|
|
4537
|
+
function axisLabel(field) {
|
|
4538
|
+
const axis = TIME_AXES.find((entry) => entry.value === field);
|
|
4539
|
+
return axis === void 0 ? String(field) : axis.label;
|
|
4540
|
+
}
|
|
4541
|
+
/**
|
|
4542
|
+
* Build the `time` argument from the controls. `undefined` = no time condition at all.
|
|
4543
|
+
*
|
|
4544
|
+
* In `on` mode the range inputs are IGNORED (the contract refuses `on` together with `from`/`to`,
|
|
4545
|
+
* so a stale value in the hidden inputs must never leak into a request). Values are the native
|
|
4546
|
+
* `YYYY-MM-DD` a date input produces; they are passed through untouched — the engine owns the
|
|
4547
|
+
* grammar, including the timezone reading of a date without an offset.
|
|
4548
|
+
*/
|
|
4549
|
+
function buildTimeFilter(field, mode, on, from, to) {
|
|
4550
|
+
if (mode === "on") {
|
|
4551
|
+
const day = on.trim();
|
|
4552
|
+
return day === "" ? void 0 : {
|
|
4553
|
+
field,
|
|
4554
|
+
on: day
|
|
4555
|
+
};
|
|
4556
|
+
}
|
|
4557
|
+
const lower = from.trim();
|
|
4558
|
+
const upper = to.trim();
|
|
4559
|
+
if (lower === "" && upper === "") return void 0;
|
|
4560
|
+
return {
|
|
4561
|
+
field,
|
|
4562
|
+
...lower === "" ? {} : { from: lower },
|
|
4563
|
+
...upper === "" ? {} : { to: upper }
|
|
4564
|
+
};
|
|
4565
|
+
}
|
|
4566
|
+
/**
|
|
4567
|
+
* The panel's one-line statement of the window the visible results were narrowed to.
|
|
4568
|
+
*
|
|
4569
|
+
* WHY THE PANEL NEEDS IT: a time-filtered answer is shorter than the list, and without a line naming
|
|
4570
|
+
* the window, "fewer rows than expected" is indistinguishable from a broken query. It is rendered
|
|
4571
|
+
* from the filter the RESULTS were fetched with (not from the live controls), so editing a date
|
|
4572
|
+
* input cannot relabel results that were fetched with the previous window.
|
|
4573
|
+
*/
|
|
4574
|
+
function timeFilterLabel(time) {
|
|
4575
|
+
const axis = axisLabel(time.field);
|
|
4576
|
+
if (time.on !== void 0) return `${axis} ${time.on}`;
|
|
4577
|
+
return `${axis} ${time.from ?? "最早"} ~ ${time.to ?? "现在"}`;
|
|
4578
|
+
}
|
|
4411
4579
|
//#endregion
|
|
4412
4580
|
//#region src/client/wire.ts
|
|
4413
4581
|
/**
|
|
@@ -4473,6 +4641,199 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4473
4641
|
if (!/404|not found/iu.test(text)) return text;
|
|
4474
4642
|
return `宿主可能没有注册这个接口(旧版本 / 未重启):客户端会随页面刷新,宿主只在 dsh web 启动时加载一次,所以 dsh web 很可能还在跑旧的宿主代码。重启 dsh web 后再试。(原始错误:${text})`;
|
|
4475
4643
|
}
|
|
4644
|
+
/**
|
|
4645
|
+
* The nearest element that actually scrolls `node`, at or above it.
|
|
4646
|
+
*
|
|
4647
|
+
* The criterion is the computed `overflow-y` (`auto`/`scroll`) AND "it really overflows"
|
|
4648
|
+
* (`scrollHeight > clientHeight`): a host's wrapper elements are `overflow-y:auto` long before their
|
|
4649
|
+
* content is taller than they are, and binding to one of those would listen to an element that never
|
|
4650
|
+
* emits a scroll. A `null` result is therefore honest — nothing here scrolls — and the listener falls
|
|
4651
|
+
* back to `window` (see {@link createAutoLoadMore}).
|
|
4652
|
+
*
|
|
4653
|
+
* `node` ITSELF is considered first. A panel normally passes its root (the scrollport is an ancestor:
|
|
4654
|
+
* the host's conversation scroll body), but a list that owns its scroller — a virtualized list whose
|
|
4655
|
+
* `getScrollElement()` is its own container — passes that container, which is the very element
|
|
4656
|
+
* scroll events fire on. Scroll events do not bubble between scrollers, so getting THIS element (not
|
|
4657
|
+
* some ancestor) is what makes such a list work.
|
|
4658
|
+
*/
|
|
4659
|
+
function scrollParentOf(node) {
|
|
4660
|
+
let current = node;
|
|
4661
|
+
while (current !== null) {
|
|
4662
|
+
const overflowY = computedOverflowY(current);
|
|
4663
|
+
if ((overflowY === "auto" || overflowY === "scroll") && current.scrollHeight > current.clientHeight) return current;
|
|
4664
|
+
current = current.parentElement;
|
|
4665
|
+
}
|
|
4666
|
+
return null;
|
|
4667
|
+
}
|
|
4668
|
+
/**
|
|
4669
|
+
* The computed `overflow-y` of `element`, read off ITS OWN document's view.
|
|
4670
|
+
*
|
|
4671
|
+
* Not the bare `getComputedStyle` global: this module also runs under a test double or inside a
|
|
4672
|
+
* detached document, and both need the style to come from somewhere explicit. An element with no
|
|
4673
|
+
* view (no document, or a document without a window) reports `''` — "not a scrollport", the safe
|
|
4674
|
+
* answer.
|
|
4675
|
+
*/
|
|
4676
|
+
function computedOverflowY(element) {
|
|
4677
|
+
const view = element.ownerDocument?.defaultView;
|
|
4678
|
+
if (view === null || view === void 0) return "";
|
|
4679
|
+
return view.getComputedStyle(element).overflowY;
|
|
4680
|
+
}
|
|
4681
|
+
/**
|
|
4682
|
+
* Where to listen: the scrollport when one was found, else `window`.
|
|
4683
|
+
*
|
|
4684
|
+
* The `window` fallback is the case the seat's own history taught: a panel can be mounted in a
|
|
4685
|
+
* document whose scrollport is the page itself. Listening there costs nothing, but the POSITION check
|
|
4686
|
+
* still needs a scrollport, so nothing fires until an ancestor with its own scrollbar appears (or the
|
|
4687
|
+
* caller drives {@link AutoLoadMore.recheck}). `null` only in a host without a `window` at all.
|
|
4688
|
+
*/
|
|
4689
|
+
function scrollEventSource(scroller) {
|
|
4690
|
+
if (scroller !== null) return scroller;
|
|
4691
|
+
const view = globalThis["window"];
|
|
4692
|
+
if (view === null || view === void 0 || typeof view !== "object") return null;
|
|
4693
|
+
const candidate = view;
|
|
4694
|
+
if (typeof candidate.addEventListener !== "function") return null;
|
|
4695
|
+
if (typeof candidate.removeEventListener !== "function") return null;
|
|
4696
|
+
return view;
|
|
4697
|
+
}
|
|
4698
|
+
/**
|
|
4699
|
+
* The host's animation-frame pair, read off `globalThis` (`lib.dom` is not in this package's types, and
|
|
4700
|
+
* a node host has neither function — the controller then checks synchronously, its pre-rAF behaviour).
|
|
4701
|
+
*/
|
|
4702
|
+
function animationFrames() {
|
|
4703
|
+
const host = globalThis;
|
|
4704
|
+
const candidate = host;
|
|
4705
|
+
if (typeof candidate.requestAnimationFrame !== "function") return null;
|
|
4706
|
+
if (typeof candidate.cancelAnimationFrame !== "function") return null;
|
|
4707
|
+
return host;
|
|
4708
|
+
}
|
|
4709
|
+
/**
|
|
4710
|
+
* Build the controller: listen for scroll (passive + one rAF per burst), check once immediately, and
|
|
4711
|
+
* stay silent while `isEnabled()` is false.
|
|
4712
|
+
*
|
|
4713
|
+
* The immediate first check is not an optimisation: the first page of a short list does not fill the
|
|
4714
|
+
* scrollport, so waiting for a scroll event means waiting forever.
|
|
4715
|
+
*
|
|
4716
|
+
* @param options - see {@link AutoLoadMoreOptions}. Read at creation: `scrollParentOf` runs once, so a
|
|
4717
|
+
* panel that later gets its own scroller must dispose and recreate the controller.
|
|
4718
|
+
*/
|
|
4719
|
+
function createAutoLoadMore(options) {
|
|
4720
|
+
const marginPx = options.marginPx ?? 300;
|
|
4721
|
+
let scroller = scrollParentOf(options.target);
|
|
4722
|
+
let source = scrollEventSource(scroller);
|
|
4723
|
+
const frames = animationFrames();
|
|
4724
|
+
let onLoadMore = options.onLoadMore;
|
|
4725
|
+
let version = options.version ?? 0;
|
|
4726
|
+
let disposed = false;
|
|
4727
|
+
let queued = false;
|
|
4728
|
+
let frame = null;
|
|
4729
|
+
/** Bumped whenever a queued frame is superseded, so a callback the host still runs is a no-op. */
|
|
4730
|
+
let generation = 0;
|
|
4731
|
+
/** Move the listener onto `next`: the window fallback may be holding it, and a stale element listener
|
|
4732
|
+
* would keep listening on a node that no longer scrolls. */
|
|
4733
|
+
const bind = (next) => {
|
|
4734
|
+
if (next === scroller) return;
|
|
4735
|
+
source?.removeEventListener("scroll", onScroll);
|
|
4736
|
+
scroller = next;
|
|
4737
|
+
source = scrollEventSource(next);
|
|
4738
|
+
source?.addEventListener("scroll", onScroll, { passive: true });
|
|
4739
|
+
};
|
|
4740
|
+
const check = () => {
|
|
4741
|
+
frame = null;
|
|
4742
|
+
queued = false;
|
|
4743
|
+
if (disposed || onLoadMore === null) return;
|
|
4744
|
+
if (scroller === null) {
|
|
4745
|
+
const found = scrollParentOf(options.target);
|
|
4746
|
+
if (found === null) return;
|
|
4747
|
+
bind(found);
|
|
4748
|
+
}
|
|
4749
|
+
if (scroller === null) return;
|
|
4750
|
+
if (!options.isEnabled()) return;
|
|
4751
|
+
if (scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - marginPx) onLoadMore();
|
|
4752
|
+
};
|
|
4753
|
+
/**
|
|
4754
|
+
* Drop the queued frame, if there is one, and make any callback already handed to the host inert.
|
|
4755
|
+
*
|
|
4756
|
+
* Called before a SYNC check (`recheck`/`setVersion`): the frame a scroll just scheduled reads the
|
|
4757
|
+
* same position, so leaving it scheduled asks for the same page twice — the second `onLoadMore`
|
|
4758
|
+
* arrives after the caller's own guard may already have cleared. The generation bump is belt and
|
|
4759
|
+
* braces: a host (or a test) that runs a cancelled callback anyway must not ask again.
|
|
4760
|
+
*/
|
|
4761
|
+
const cancelQueued = () => {
|
|
4762
|
+
if (frame !== null && frames !== null) frames.cancelAnimationFrame(frame);
|
|
4763
|
+
frame = null;
|
|
4764
|
+
queued = false;
|
|
4765
|
+
generation += 1;
|
|
4766
|
+
};
|
|
4767
|
+
const onScroll = () => {
|
|
4768
|
+
if (disposed || queued) return;
|
|
4769
|
+
queued = true;
|
|
4770
|
+
if (frames === null) check();
|
|
4771
|
+
else {
|
|
4772
|
+
const scheduled = generation;
|
|
4773
|
+
frame = frames.requestAnimationFrame(() => {
|
|
4774
|
+
if (scheduled !== generation) return;
|
|
4775
|
+
check();
|
|
4776
|
+
});
|
|
4777
|
+
}
|
|
4778
|
+
};
|
|
4779
|
+
source?.addEventListener("scroll", onScroll, { passive: true });
|
|
4780
|
+
check();
|
|
4781
|
+
return {
|
|
4782
|
+
recheck() {
|
|
4783
|
+
if (disposed) return;
|
|
4784
|
+
cancelQueued();
|
|
4785
|
+
check();
|
|
4786
|
+
},
|
|
4787
|
+
setVersion(next) {
|
|
4788
|
+
if (disposed || next === version) return false;
|
|
4789
|
+
version = next;
|
|
4790
|
+
cancelQueued();
|
|
4791
|
+
check();
|
|
4792
|
+
return true;
|
|
4793
|
+
},
|
|
4794
|
+
dispose() {
|
|
4795
|
+
if (disposed) return;
|
|
4796
|
+
disposed = true;
|
|
4797
|
+
onLoadMore = null;
|
|
4798
|
+
source?.removeEventListener("scroll", onScroll);
|
|
4799
|
+
if (frame !== null && frames !== null) frames.cancelAnimationFrame(frame);
|
|
4800
|
+
frame = null;
|
|
4801
|
+
queued = false;
|
|
4802
|
+
}
|
|
4803
|
+
};
|
|
4804
|
+
}
|
|
4805
|
+
//#endregion
|
|
4806
|
+
//#region \0dsh-css:../../../base/src/client/controls.module.css.mjs
|
|
4807
|
+
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)}}";
|
|
4808
|
+
const tagId = "@avantf/dsh-mem/controls.module.css";
|
|
4809
|
+
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
4810
|
+
const tag = document.createElement("style");
|
|
4811
|
+
tag.dataset.plugin = "@avantf/dsh-mem";
|
|
4812
|
+
tag.dataset.pluginCss = tagId;
|
|
4813
|
+
tag.textContent = css;
|
|
4814
|
+
document.head.appendChild(tag);
|
|
4815
|
+
}
|
|
4816
|
+
var controls_module_css_default = {
|
|
4817
|
+
"active": "-GlBVa_active",
|
|
4818
|
+
"badge": "-GlBVa_badge",
|
|
4819
|
+
"button": "-GlBVa_button",
|
|
4820
|
+
"clear": "-GlBVa_clear",
|
|
4821
|
+
"danger": "-GlBVa_danger",
|
|
4822
|
+
"dateInput": "-GlBVa_dateInput",
|
|
4823
|
+
"error": "-GlBVa_error",
|
|
4824
|
+
"ghost": "-GlBVa_ghost",
|
|
4825
|
+
"input": "-GlBVa_input",
|
|
4826
|
+
"item": "-GlBVa_item",
|
|
4827
|
+
"list": "-GlBVa_list",
|
|
4828
|
+
"md": "-GlBVa_md",
|
|
4829
|
+
"meta": "-GlBVa_meta",
|
|
4830
|
+
"outline": "-GlBVa_outline",
|
|
4831
|
+
"primary": "-GlBVa_primary",
|
|
4832
|
+
"select": "-GlBVa_select",
|
|
4833
|
+
"sm": "-GlBVa_sm",
|
|
4834
|
+
"status": "-GlBVa_status",
|
|
4835
|
+
"textarea": "-GlBVa_textarea"
|
|
4836
|
+
};
|
|
4476
4837
|
//#endregion
|
|
4477
4838
|
//#region src/client/index.ts
|
|
4478
4839
|
/**
|
|
@@ -4492,7 +4853,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4492
4853
|
* 2. Every remote call is normalized and surfaced: the raw response is logged
|
|
4493
4854
|
* (prefixed `[avantf-mem]`) and rendered as an explicit status/error line, so
|
|
4494
4855
|
* an empty result is never indistinguishable from a failed call.
|
|
4495
|
-
* 3. Full DESIGN §12 surface: the tab does CRUD (edit/archive/restore
|
|
4856
|
+
* 3. Full DESIGN §12 surface: the tab does CRUD (edit/archive/restore) + fact detail
|
|
4496
4857
|
* (entities/triples) + open contradictions. Generation stays external (Plan A) — no answer
|
|
4497
4858
|
* synthesis.
|
|
4498
4859
|
*
|
|
@@ -4501,20 +4862,52 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4501
4862
|
* resolved from the platform module table.
|
|
4502
4863
|
*/
|
|
4503
4864
|
const h = react.createElement;
|
|
4865
|
+
/**
|
|
4866
|
+
* The panel's compact button, composed ONCE: the shared control geometry from the family base
|
|
4867
|
+
* (`controls` is `@avantf/dsh-plugin-base/internal/controls.module.css`, hashed into this bundle at
|
|
4868
|
+
* build time) + the outline variant. Every 按钮 in the panel used to carry its own copy of these
|
|
4869
|
+
* numbers; the sizes/radius/hover now come from the base, so the 记忆 tab and the other family
|
|
4870
|
+
* panels cannot drift apart.
|
|
4871
|
+
*/
|
|
4872
|
+
const BTN = `${controls_module_css_default.button} ${controls_module_css_default.sm} ${controls_module_css_default.outline}`;
|
|
4873
|
+
/**
|
|
4874
|
+
* Every timestamp the 记忆 tab prints goes through here: local wall-clock text to the minute
|
|
4875
|
+
* (`2026-10-28 19:58`), or the caller's placeholder when the host sent nothing readable.
|
|
4876
|
+
*
|
|
4877
|
+
* This is a PLACEHOLDER policy, not a formatter — the formatting itself is
|
|
4878
|
+
* `formatLocalHuman` from `@avantf/mem-contract/time`, the same module the host renders the model
|
|
4879
|
+
* face with. The value arriving here is an instant WITH an offset (the host's `created_at` etc.), so
|
|
4880
|
+
* `Date.parse` inside that one renderer is unambiguous; nothing in this file parses a time itself.
|
|
4881
|
+
*
|
|
4882
|
+
* A calendar date (`event_date` / `valid_from`) must NOT come through here: it has no time and no
|
|
4883
|
+
* zone, and rendering it would invent a midnight in the viewer's zone.
|
|
4884
|
+
*/
|
|
4885
|
+
const localTime = (ts, fallback = "—") => formatLocalHuman(ts) ?? fallback;
|
|
4504
4886
|
const NOT_MOUNTED = "avantfMem Remote 未挂载:本插件自带的 typert contribution 挂载失败(详见控制台错误),暂时无法读取数据。";
|
|
4505
4887
|
/** One 记忆 tab; the host `admin` list default is the same. */
|
|
4506
4888
|
const MEMORY_PAGE_SIZE = 50;
|
|
4507
|
-
/**
|
|
4889
|
+
/**
|
|
4890
|
+
* The page size of a KEYWORD search; fuzzy is fixed at five by the engine.
|
|
4891
|
+
*
|
|
4892
|
+
* A pure time window sends no `limit` at all: it is walked by `offset` instead (`runTimeWindow`), so
|
|
4893
|
+
* the host's own window page applies and no client constant can cut a day's history short.
|
|
4894
|
+
*/
|
|
4508
4895
|
const SEARCH_LIMIT = 10;
|
|
4509
4896
|
/**
|
|
4897
|
+
* How many pages one time-window search may walk before the panel stops.
|
|
4898
|
+
*
|
|
4899
|
+
* A wall against a host that never reports `remaining: 0` (or never returns rows): 200 pages of 50 is
|
|
4900
|
+
* 10 000 facts, far beyond any hand-readable window. Stopping early is visible — the panel prints how
|
|
4901
|
+
* many rows are still unseen.
|
|
4902
|
+
*/
|
|
4903
|
+
const SEARCH_PAGES_MAX = 200;
|
|
4904
|
+
/**
|
|
4510
4905
|
* How many `admin.detail` calls one search answer may trigger, to give its results the list's action
|
|
4511
4906
|
* row (status / pin state / full text). A hard cap so a large exact page or a wide relation answer
|
|
4512
4907
|
* cannot turn one keystroke into an unbounded fan-out; anything past the cap keeps the id-only
|
|
4513
|
-
* actions (详情 / 编辑 when the excerpt is complete / 归档
|
|
4908
|
+
* actions (详情 / 编辑 when the excerpt is complete / 归档), which still work.
|
|
4514
4909
|
*/
|
|
4515
4910
|
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
4911
|
/**
|
|
4519
4912
|
* The wire revision the host last reported, and whether its absence/mismatch was logged.
|
|
4520
4913
|
*
|
|
@@ -4587,67 +4980,57 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4587
4980
|
isCurrent: (seq) => seq === ref.current
|
|
4588
4981
|
}), []);
|
|
4589
4982
|
}
|
|
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
4983
|
/**
|
|
4601
4984
|
* Call `onLoadMore` when the user scrolls near the end of the panel.
|
|
4602
4985
|
*
|
|
4603
|
-
*
|
|
4604
|
-
*
|
|
4986
|
+
* The behaviour itself now lives in the base — `createAutoLoadMore` on
|
|
4987
|
+
* `@avantf/dsh-plugin-base/internal` — and this function is only its React half: refs for the two
|
|
4988
|
+
* values the controller reads live (`onLoadMore`, `enabled`), one effect that owns the controller's
|
|
4989
|
+
* lifetime, and the two effects that drive the checks React knows about. Why the kit is in the base:
|
|
4990
|
+
* mission pages its lists the same way, and the DOM detail (walking up to the element that really
|
|
4991
|
+
* scrolls, the passive listener with its rAF throttle, the window fallback) must exist ONCE. The base
|
|
4992
|
+
* has no React peer, so the kit is plain DOM and each plugin keeps this thin wrapper.
|
|
4605
4993
|
*
|
|
4606
|
-
*
|
|
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.
|
|
4994
|
+
* The two facts this wrapper is responsible for keeping (the kit cannot see them by itself):
|
|
4614
4995
|
*
|
|
4615
|
-
*
|
|
4616
|
-
*
|
|
4996
|
+
* - **`version` re-checks after a page lands.** A page that does not fill the scrollport produces no
|
|
4997
|
+
* scroll event; without the re-check loading would stop after the first page.
|
|
4998
|
+
* - **`enabled` flipping back on retries once.** That is how a page that landed (or a list that
|
|
4999
|
+
* gained a continuation) keeps loading while the user sits still.
|
|
5000
|
+
*
|
|
5001
|
+
* Both are one effect on purpose: a page landing changes `rows.length` AND clears `loading`, so the
|
|
5002
|
+
* two reasons arrive in the SAME commit — and two checks would send the next page twice. The kit's
|
|
5003
|
+
* `setVersion` reports whether it already checked, which is what keeps it at one.
|
|
5004
|
+
*
|
|
5005
|
+
* The observer-vs-listener reasoning and the margin live in the kit's own doc (one copy).
|
|
4617
5006
|
*/
|
|
4618
5007
|
function useLoadMoreOnScroll(rootRef, enabled, onLoadMore, version) {
|
|
4619
5008
|
const latest = react.useRef(onLoadMore);
|
|
4620
5009
|
latest.current = onLoadMore;
|
|
4621
5010
|
const enabledRef = react.useRef(enabled);
|
|
4622
5011
|
enabledRef.current = enabled;
|
|
4623
|
-
const
|
|
5012
|
+
const controllerRef = react.useRef(null);
|
|
4624
5013
|
react.useEffect(() => {
|
|
4625
5014
|
const root = rootRef.current;
|
|
4626
5015
|
if (root === null) return void 0;
|
|
4627
|
-
const
|
|
4628
|
-
|
|
4629
|
-
|
|
4630
|
-
|
|
4631
|
-
|
|
4632
|
-
|
|
4633
|
-
|
|
4634
|
-
};
|
|
4635
|
-
|
|
4636
|
-
const onScroll = () => {
|
|
4637
|
-
if (queued) return;
|
|
4638
|
-
queued = true;
|
|
4639
|
-
if (typeof requestAnimationFrame === "function") requestAnimationFrame(check);
|
|
4640
|
-
else check();
|
|
4641
|
-
};
|
|
4642
|
-
target.addEventListener("scroll", onScroll, { passive: true });
|
|
4643
|
-
check();
|
|
5016
|
+
const controller = createAutoLoadMore({
|
|
5017
|
+
target: root,
|
|
5018
|
+
isEnabled: () => enabledRef.current,
|
|
5019
|
+
onLoadMore: () => {
|
|
5020
|
+
latest.current();
|
|
5021
|
+
},
|
|
5022
|
+
version
|
|
5023
|
+
});
|
|
5024
|
+
controllerRef.current = controller;
|
|
4644
5025
|
return () => {
|
|
4645
|
-
|
|
4646
|
-
|
|
5026
|
+
controllerRef.current = null;
|
|
5027
|
+
controller.dispose();
|
|
4647
5028
|
};
|
|
4648
5029
|
}, [rootRef]);
|
|
4649
5030
|
react.useEffect(() => {
|
|
4650
|
-
|
|
5031
|
+
const controller = controllerRef.current;
|
|
5032
|
+
if (controller === null) return;
|
|
5033
|
+
if (!controller.setVersion(version)) controller.recheck();
|
|
4651
5034
|
}, [version, enabled]);
|
|
4652
5035
|
}
|
|
4653
5036
|
/**
|
|
@@ -4675,7 +5058,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4675
5058
|
function Btn(props) {
|
|
4676
5059
|
return h("button", {
|
|
4677
5060
|
type: "button",
|
|
4678
|
-
className: props.danger ? `${
|
|
5061
|
+
className: props.danger ? `${BTN} ${controls_module_css_default.danger}` : BTN,
|
|
4679
5062
|
disabled: props.disabled === true,
|
|
4680
5063
|
onClick: props.onClick
|
|
4681
5064
|
}, props.label);
|
|
@@ -4700,7 +5083,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4700
5083
|
});
|
|
4701
5084
|
const complete = draft.subject.trim() !== "" && draft.predicate.trim() !== "" && draft.object.trim() !== "";
|
|
4702
5085
|
const field = (slot, label) => h("input", {
|
|
4703
|
-
className:
|
|
5086
|
+
className: controls_module_css_default.input,
|
|
4704
5087
|
value: draft[slot],
|
|
4705
5088
|
placeholder: label,
|
|
4706
5089
|
"aria-label": `关系${label}`,
|
|
@@ -4712,9 +5095,9 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4712
5095
|
}));
|
|
4713
5096
|
}
|
|
4714
5097
|
});
|
|
4715
|
-
return h("div", { className: pages_module_css_default.detail }, h("div", { className:
|
|
5098
|
+
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
5099
|
key: `relation-${String(index)}`,
|
|
4717
|
-
className:
|
|
5100
|
+
className: controls_module_css_default.item
|
|
4718
5101
|
}, `${relation.subject} —${relation.predicate}→ ${relation.object}`, h("div", { className: pages_module_css_default.row }, h(Btn, {
|
|
4719
5102
|
label: "删除",
|
|
4720
5103
|
danger: true,
|
|
@@ -4743,6 +5126,15 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4743
5126
|
}
|
|
4744
5127
|
})));
|
|
4745
5128
|
}
|
|
5129
|
+
/** A list with nothing in it: the list view before its first page, and a search that was cleared. */
|
|
5130
|
+
const EMPTY_LIST = {
|
|
5131
|
+
rows: [],
|
|
5132
|
+
total: void 0,
|
|
5133
|
+
truncated: false,
|
|
5134
|
+
nextOffset: 0,
|
|
5135
|
+
loading: false,
|
|
5136
|
+
error: void 0
|
|
5137
|
+
};
|
|
4746
5138
|
/**
|
|
4747
5139
|
* The detail body of one fact — the SAME view under an admin list row and under the search results
|
|
4748
5140
|
* (the search view used to hide the list, which is why its 「详情」 button appeared to do nothing).
|
|
@@ -4752,7 +5144,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4752
5144
|
*/
|
|
4753
5145
|
function FactDetailView(props) {
|
|
4754
5146
|
const fact = props.fact;
|
|
4755
|
-
return h("div", { className:
|
|
5147
|
+
return h("div", { className: controls_module_css_default.meta }, `标题:${fact.title ?? "(无标题)"}`, h("br"), `创建于 ${localTime(fact.created_at)} / 更新于 ${localTime(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.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
5148
|
fact,
|
|
4757
5149
|
disabled: props.disabled,
|
|
4758
5150
|
onSave: (relations) => {
|
|
@@ -4760,78 +5152,265 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4760
5152
|
}
|
|
4761
5153
|
}) : null);
|
|
4762
5154
|
}
|
|
5155
|
+
/**
|
|
5156
|
+
* An `admin.list` row, in the unified shape.
|
|
5157
|
+
*
|
|
5158
|
+
* The meta line is the list's own (trust, retention, counts); a search answer's row states what THAT
|
|
5159
|
+
* answer can honestly say instead (see the four builders below), which is why this text belongs to the
|
|
5160
|
+
* row and not to the renderer.
|
|
5161
|
+
*/
|
|
5162
|
+
function listRow(f) {
|
|
5163
|
+
return {
|
|
5164
|
+
kind: "fact",
|
|
5165
|
+
key: `fact:${String(f.fact_id)}`,
|
|
5166
|
+
fact_id: f.fact_id,
|
|
5167
|
+
title: f.title ?? (f.kind === "journal" ? "(无标题)" : void 0),
|
|
5168
|
+
text: f.content,
|
|
5169
|
+
truncated: false,
|
|
5170
|
+
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))} 活跃日` : "") + ` · ${localTime(f.created_at)}` + (f.status === "archived" && f.archive_reason ? ` · 归档原因 ${f.archive_reason}` : "") + (f.status === "archived" && f.pinned === true ? " · 永久保留(不会被清理)" : ""),
|
|
5171
|
+
action: {
|
|
5172
|
+
fact_id: f.fact_id,
|
|
5173
|
+
status: f.status,
|
|
5174
|
+
content: f.content,
|
|
5175
|
+
pinned: f.pinned === true
|
|
5176
|
+
},
|
|
5177
|
+
fromSearch: false
|
|
5178
|
+
};
|
|
5179
|
+
}
|
|
5180
|
+
/** A keyword (`exact`/`fuzzy`) hit: the one result shape that carries a relevance score. */
|
|
5181
|
+
function hitRow(hit, action) {
|
|
5182
|
+
return {
|
|
5183
|
+
kind: "fact",
|
|
5184
|
+
key: `fact:${String(hit.ref_id)}`,
|
|
5185
|
+
fact_id: hit.ref_id,
|
|
5186
|
+
title: hit.title,
|
|
5187
|
+
text: hit.text,
|
|
5188
|
+
truncated: hit.truncated === true,
|
|
5189
|
+
meta: `#${String(hit.ref_id)} · score ${hit.score.toFixed(2)} · ${hit.source_ref}` + (hit.valid_from === void 0 ? "" : ` · ${hit.valid_from}`),
|
|
5190
|
+
action,
|
|
5191
|
+
fromSearch: true
|
|
5192
|
+
};
|
|
5193
|
+
}
|
|
5194
|
+
/** One row of a fact-level AND page (`mode:"facts"`) — a fact, not a ranked hit: no score. */
|
|
5195
|
+
function factPageRow(entry, action) {
|
|
5196
|
+
return {
|
|
5197
|
+
kind: "fact",
|
|
5198
|
+
key: `fact:${String(entry.fact_id)}`,
|
|
5199
|
+
fact_id: entry.fact_id,
|
|
5200
|
+
title: entry.title,
|
|
5201
|
+
text: entry.content,
|
|
5202
|
+
truncated: entry.truncated === true,
|
|
5203
|
+
meta: `#${String(entry.fact_id)} · ${entry.memory_kind} · ${entry.status} · ${entry.event_date ?? localTime(entry.created_at)}` + (entry.source_ref === void 0 ? "" : ` · ${entry.source_ref}`),
|
|
5204
|
+
action,
|
|
5205
|
+
fromSearch: true
|
|
5206
|
+
};
|
|
5207
|
+
}
|
|
5208
|
+
/** One evidence fact of a relation answer — again a fact, not a ranked hit: no score. */
|
|
5209
|
+
function evidenceRow(entry, action) {
|
|
5210
|
+
return {
|
|
5211
|
+
kind: "fact",
|
|
5212
|
+
key: `fact:${String(entry.fact_id)}`,
|
|
5213
|
+
fact_id: entry.fact_id,
|
|
5214
|
+
title: entry.title,
|
|
5215
|
+
text: entry.text,
|
|
5216
|
+
truncated: entry.truncated === true,
|
|
5217
|
+
meta: `#${String(entry.fact_id)} · ${entry.memory_kind} · ${localTime(entry.created_at)}`,
|
|
5218
|
+
action,
|
|
5219
|
+
fromSearch: true
|
|
5220
|
+
};
|
|
5221
|
+
}
|
|
5222
|
+
/** A relation answer's path: the ONE row body that is not a fact, still a row of the same list. */
|
|
5223
|
+
function pathRow(path, index) {
|
|
5224
|
+
return {
|
|
5225
|
+
kind: "path",
|
|
5226
|
+
key: `path:${String(index)}`,
|
|
5227
|
+
path
|
|
5228
|
+
};
|
|
5229
|
+
}
|
|
5230
|
+
/**
|
|
5231
|
+
* The ONE list renderer of the 记忆 tab: the admin list and every search answer draw through it.
|
|
5232
|
+
*
|
|
5233
|
+
* It owns the parts that used to be written twice — the row body, the 「加载更多(还有 N 条)」 control at
|
|
5234
|
+
* the end, the empty state and the error line — and it reads ONE state ({@link ListState}), so the list
|
|
5235
|
+
* and a result set cannot disagree about whether more rows exist, why the last page failed, or whether
|
|
5236
|
+
* scrolling should load.
|
|
5237
|
+
*
|
|
5238
|
+
* The rows arrive already normalized (`listRow` / `hitRow` / `factPageRow` / `evidenceRow` / `pathRow`):
|
|
5239
|
+
* a fact row carries its text, meta line and resolved action set, so this component never needs to know
|
|
5240
|
+
* which face it came from. The row actions themselves come from the panel (`actionRow`), which is where
|
|
5241
|
+
* the ONE action builder lives (`factButtons`).
|
|
5242
|
+
*/
|
|
5243
|
+
function FactList(props) {
|
|
5244
|
+
const state = props.state;
|
|
5245
|
+
const remaining = state.total === void 0 ? 0 : Math.max(state.total - state.rows.length, 0);
|
|
5246
|
+
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", {
|
|
5247
|
+
key: row.key,
|
|
5248
|
+
className: controls_module_css_default.item
|
|
5249
|
+
}, row.kind === "path" ? [h("div", { key: "route" }, [row.path.start.name, ...row.path.edges.flatMap((edge) => [edge.predicate, edge.object])].join(" → ")), h("span", {
|
|
5250
|
+
key: "meta",
|
|
5251
|
+
className: controls_module_css_default.meta
|
|
5252
|
+
}, `${String(row.path.hops)} 跳 · #${row.path.edges.map((edge) => String(edge.fact_id)).join(" / ")} · 置信度 ${row.path.confidence.toFixed(2)}`)] : [
|
|
5253
|
+
row.title === void 0 ? null : h("div", {
|
|
5254
|
+
key: "title",
|
|
5255
|
+
className: controls_module_css_default.meta
|
|
5256
|
+
}, `标题:${row.title}`),
|
|
5257
|
+
props.editing?.id === row.fact_id ? h("textarea", {
|
|
5258
|
+
key: "edit",
|
|
5259
|
+
className: `${controls_module_css_default.textarea} ${pages_module_css_default.edit}`,
|
|
5260
|
+
value: props.editing.text,
|
|
5261
|
+
rows: 3,
|
|
5262
|
+
onChange: (e) => {
|
|
5263
|
+
props.onEdit({
|
|
5264
|
+
id: row.fact_id,
|
|
5265
|
+
text: e.target.value
|
|
5266
|
+
});
|
|
5267
|
+
}
|
|
5268
|
+
}) : row.text,
|
|
5269
|
+
row.truncated ? h("span", {
|
|
5270
|
+
key: "cut",
|
|
5271
|
+
className: controls_module_css_default.meta
|
|
5272
|
+
}, "(已截断)") : null,
|
|
5273
|
+
h("span", {
|
|
5274
|
+
key: "meta",
|
|
5275
|
+
className: controls_module_css_default.meta
|
|
5276
|
+
}, row.meta),
|
|
5277
|
+
h("div", {
|
|
5278
|
+
key: "actions",
|
|
5279
|
+
className: pages_module_css_default.row
|
|
5280
|
+
}, props.actionRow(row.action, row.fromSearch)),
|
|
5281
|
+
props.detail?.id === row.fact_id ? h(FactDetailView, {
|
|
5282
|
+
key: "detail",
|
|
5283
|
+
fact: props.detail.data,
|
|
5284
|
+
disabled: props.busy,
|
|
5285
|
+
onSaveRelations: props.onSaveRelations
|
|
5286
|
+
}) : null
|
|
5287
|
+
]))), state.truncated ? h("button", {
|
|
5288
|
+
type: "button",
|
|
5289
|
+
className: `${BTN} ${pages_module_css_default.more}`,
|
|
5290
|
+
disabled: state.loading,
|
|
5291
|
+
onClick: props.onLoadMore
|
|
5292
|
+
}, state.loading ? "加载中…" : state.total === void 0 ? "加载更多" : `加载更多(还有 ${String(remaining)} 条)`) : null);
|
|
5293
|
+
}
|
|
4763
5294
|
function MemoryPanel(props) {
|
|
4764
5295
|
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
5296
|
/**
|
|
4769
|
-
*
|
|
4770
|
-
*
|
|
4771
|
-
* repeats one row), and using the de-duplicated length as the next offset would re-request it.
|
|
5297
|
+
* The list the panel renders — the ONE list state (see {@link ListState}). `admin.list` fills it
|
|
5298
|
+
* while no search is applied; `recall.search` fills it while one is.
|
|
4772
5299
|
*/
|
|
4773
|
-
const [
|
|
5300
|
+
const [list, setList] = react.useState({
|
|
5301
|
+
...EMPTY_LIST,
|
|
5302
|
+
loading: props.remote !== void 0
|
|
5303
|
+
});
|
|
5304
|
+
/** The search the visible rows came from (`null` = the list view). See {@link AppliedSearch}. */
|
|
5305
|
+
const [applied, setApplied] = react.useState(null);
|
|
5306
|
+
/** Everything that is NOT the list: a mutation's message, a detail/health/conflict failure. */
|
|
4774
5307
|
const [error, setError] = react.useState();
|
|
4775
5308
|
const [notice, setNotice] = react.useState();
|
|
4776
|
-
|
|
5309
|
+
/** A mutation in flight: the panel's buttons and the row actions obey it. */
|
|
5310
|
+
const [busy, setBusy] = react.useState(false);
|
|
4777
5311
|
const [editing, setEditing] = react.useState(null);
|
|
4778
5312
|
const [detail, setDetail] = react.useState(null);
|
|
4779
5313
|
const [contradictions, setContradictions] = react.useState(null);
|
|
4780
5314
|
const [health, setHealth] = react.useState(null);
|
|
4781
5315
|
const [searchText, setSearchText] = react.useState("");
|
|
4782
5316
|
const [searchKind, setSearchKind] = react.useState("all");
|
|
4783
|
-
const [
|
|
4784
|
-
const [
|
|
4785
|
-
const [
|
|
5317
|
+
const [timeField, setTimeField] = react.useState("created");
|
|
5318
|
+
const [timeMode, setTimeMode] = react.useState("on");
|
|
5319
|
+
const [timeOn, setTimeOn] = react.useState("");
|
|
5320
|
+
const [timeFrom, setTimeFrom] = react.useState("");
|
|
5321
|
+
const [timeTo, setTimeTo] = react.useState("");
|
|
4786
5322
|
/**
|
|
4787
|
-
* What
|
|
5323
|
+
* What a search result needs so its action row can match an admin list row:
|
|
4788
5324
|
* status (归档 vs 恢复), pin state (永久 vs 解除) and the FULL content (a hit's `text` may be
|
|
4789
5325
|
* truncated by the output budget, and editing a truncated excerpt would corrupt the fact).
|
|
4790
5326
|
* Read through `admin.detail` — one call per result, bounded by {@link SEARCH_FACT_LOOKUPS_MAX}.
|
|
4791
5327
|
*/
|
|
4792
5328
|
const [searchFacts, setSearchFacts] = react.useState({});
|
|
5329
|
+
/** The query is on its way: the title line and the 搜索 button say so. */
|
|
4793
5330
|
const [searching, setSearching] = react.useState(false);
|
|
4794
5331
|
const detailGuard = useSeqGuard();
|
|
4795
5332
|
const contradictionGuard = useSeqGuard();
|
|
4796
5333
|
const healthGuard = useSeqGuard();
|
|
4797
|
-
|
|
5334
|
+
/**
|
|
5335
|
+
* The list's guard — and the search's: both write the SAME state now, so one guard is what keeps a
|
|
5336
|
+
* late page from overwriting a newer one. It matters most on the scroll path, which calls `loadMore()`
|
|
5337
|
+
* with no user action: a page requested for 活动 can land after the user switched to 归档 (appending
|
|
5338
|
+
* active rows to the archived list) or after a search replaced the rows.
|
|
5339
|
+
*/
|
|
4798
5340
|
const listGuard = useSeqGuard();
|
|
4799
5341
|
/**
|
|
4800
|
-
*
|
|
4801
|
-
* page
|
|
5342
|
+
* The action-state guard: a `loadSearchFacts` reply belongs to the SEARCH that asked for it, not to the
|
|
5343
|
+
* page request that happened to carry it. `listGuard` cannot serve here — every page begins a new one,
|
|
5344
|
+
* so a continuation page would drop the lookups the previous page had in flight (their rows are on
|
|
5345
|
+
* screen, so their actions must land). ONE generation per search; the generation is kept in a ref
|
|
5346
|
+
* because `loadSearchPage` asks for it long after `runSearch` began it.
|
|
4802
5347
|
*/
|
|
4803
|
-
const
|
|
5348
|
+
const searchFactsGuard = useSeqGuard();
|
|
5349
|
+
const searchFactsSeq = react.useRef(0);
|
|
5350
|
+
/**
|
|
5351
|
+
* The re-entrancy wall of `loadMore`, as a REF (REVIEW-2026-10-27 P2-1).
|
|
5352
|
+
*
|
|
5353
|
+
* `list.loading` is React state: two checks in the same tick (the scroll controller's queued frame plus
|
|
5354
|
+
* a re-check, or the end-of-list button and a scroll) both read the value from the render that is
|
|
5355
|
+
* already committed and would send the same page twice. `listGuard` then throws away the older reply,
|
|
5356
|
+
* but the RPC has been made. Set on entry, cleared in `finally`, so the wall is visible synchronously.
|
|
5357
|
+
*/
|
|
5358
|
+
const loadingMoreRef = react.useRef(false);
|
|
5359
|
+
/**
|
|
5360
|
+
* Load one page of the ADMIN list into the unified state (offset 0 replaces it), resolving once it
|
|
5361
|
+
* settled so callers can order their own updates after the refresh.
|
|
5362
|
+
*/
|
|
5363
|
+
const loadListPage = react.useCallback(async (offset) => {
|
|
4804
5364
|
if (props.remote === void 0) return;
|
|
4805
5365
|
const seq = listGuard.begin();
|
|
4806
|
-
|
|
4807
|
-
|
|
4808
|
-
|
|
5366
|
+
setList((previous) => ({
|
|
5367
|
+
...previous,
|
|
5368
|
+
loading: true
|
|
5369
|
+
}));
|
|
5370
|
+
const outcome = await callRemote(`admin.list(status=${statusFilter}, offset=${String(offset)})`, props.remote.admin({
|
|
4809
5371
|
action: "list",
|
|
4810
|
-
status,
|
|
5372
|
+
status: statusFilter,
|
|
4811
5373
|
limit: MEMORY_PAGE_SIZE,
|
|
4812
5374
|
offset
|
|
4813
5375
|
}));
|
|
4814
5376
|
if (!listGuard.isCurrent(seq)) return;
|
|
4815
|
-
|
|
5377
|
+
setList((previous) => {
|
|
5378
|
+
if (!outcome.ok) return offset === 0 ? {
|
|
5379
|
+
...EMPTY_LIST,
|
|
5380
|
+
error: outcome.error
|
|
5381
|
+
} : {
|
|
5382
|
+
...previous,
|
|
5383
|
+
loading: false,
|
|
5384
|
+
error: outcome.error
|
|
5385
|
+
};
|
|
4816
5386
|
const page = outcome.value?.facts ?? [];
|
|
4817
|
-
|
|
4818
|
-
|
|
4819
|
-
|
|
4820
|
-
|
|
4821
|
-
|
|
4822
|
-
|
|
4823
|
-
|
|
4824
|
-
|
|
4825
|
-
|
|
4826
|
-
|
|
4827
|
-
|
|
4828
|
-
|
|
4829
|
-
|
|
4830
|
-
|
|
4831
|
-
|
|
5387
|
+
const rows = page.map(listRow);
|
|
5388
|
+
return {
|
|
5389
|
+
rows: offset === 0 ? rows : mergeRows(previous.rows, rows),
|
|
5390
|
+
total: outcome.value?.total,
|
|
5391
|
+
truncated: outcome.value?.truncated === true,
|
|
5392
|
+
nextOffset: offset + page.length,
|
|
5393
|
+
loading: false,
|
|
5394
|
+
error: void 0
|
|
5395
|
+
};
|
|
5396
|
+
});
|
|
5397
|
+
}, [
|
|
5398
|
+
listGuard,
|
|
5399
|
+
props.remote,
|
|
5400
|
+
statusFilter
|
|
5401
|
+
]);
|
|
5402
|
+
/**
|
|
5403
|
+
* The list view's source: `admin.list`, page 0.
|
|
5404
|
+
*
|
|
5405
|
+
* It runs on mount, on a status switch and when a search is cleared — and NEVER while a search owns
|
|
5406
|
+
* the list, or an admin page would overwrite the rows the search put on screen. `applied` decides the
|
|
5407
|
+
* source here exactly as it does for the next page.
|
|
5408
|
+
*/
|
|
5409
|
+
const listView = applied === null;
|
|
4832
5410
|
react.useEffect(() => {
|
|
4833
|
-
|
|
4834
|
-
|
|
5411
|
+
if (!listView) return;
|
|
5412
|
+
loadListPage(0);
|
|
5413
|
+
}, [listView, loadListPage]);
|
|
4835
5414
|
/**
|
|
4836
5415
|
* Resolve the state of the facts a search answer points at (`admin.detail`, one call each).
|
|
4837
5416
|
*
|
|
@@ -4839,15 +5418,21 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4839
5418
|
* know the id and an excerpt, and would have to guess 「归档/恢复」 and 「永久/解除」 — two labels
|
|
4840
5419
|
* whose opposite is a different operation. Results are bounded (search is a fixed shortlist, and
|
|
4841
5420
|
* relation evidence is the paths' facts), deduplicated, and dropped if a newer search started.
|
|
5421
|
+
*
|
|
5422
|
+
* A CONTINUATION page calls it too (REVIEW-2026-10-27 P2-2): without that, every row a scroll brought
|
|
5423
|
+
* in kept the id-only action set until the query was run again. The lookup is per page, so the bound
|
|
5424
|
+
* above still holds — one page can resolve at most {@link SEARCH_FACT_LOOKUPS_MAX} of its rows and the
|
|
5425
|
+
* rest keep the id-only actions (详情 / 编辑 / 归档), exactly like the first page's overflow.
|
|
5426
|
+
* The record is MERGED, so an append never drops what the first page resolved.
|
|
5427
|
+
*
|
|
5428
|
+
* The guard is the SEARCH's, not the page request's (`searchFactsGuard`): a page landing while the
|
|
5429
|
+
* previous page's lookups are still in flight must not throw them away — those facts are on screen.
|
|
4842
5430
|
*/
|
|
4843
5431
|
const loadSearchFacts = (seq, ids) => {
|
|
4844
5432
|
const remote = props.remote;
|
|
4845
5433
|
if (remote === void 0) return;
|
|
4846
5434
|
const unique = [...new Set(ids)].slice(0, SEARCH_FACT_LOOKUPS_MAX);
|
|
4847
|
-
if (unique.length === 0)
|
|
4848
|
-
setSearchFacts({});
|
|
4849
|
-
return;
|
|
4850
|
-
}
|
|
5435
|
+
if (unique.length === 0) return;
|
|
4851
5436
|
Promise.all(unique.map(async (id) => ({
|
|
4852
5437
|
id,
|
|
4853
5438
|
outcome: await callRemote(`admin.detail(${String(id)})`, remote.admin({
|
|
@@ -4855,7 +5440,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4855
5440
|
fact_id: id
|
|
4856
5441
|
}))
|
|
4857
5442
|
}))).then((results) => {
|
|
4858
|
-
if (!
|
|
5443
|
+
if (!searchFactsGuard.isCurrent(seq)) return;
|
|
4859
5444
|
const next = {};
|
|
4860
5445
|
for (const { id, outcome } of results) {
|
|
4861
5446
|
if (!outcome.ok) continue;
|
|
@@ -4867,104 +5452,319 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4867
5452
|
content: value.content
|
|
4868
5453
|
};
|
|
4869
5454
|
}
|
|
4870
|
-
setSearchFacts(
|
|
5455
|
+
setSearchFacts((previous) => ({
|
|
5456
|
+
...previous,
|
|
5457
|
+
...next
|
|
5458
|
+
}));
|
|
4871
5459
|
});
|
|
4872
5460
|
};
|
|
4873
|
-
/**
|
|
5461
|
+
/**
|
|
5462
|
+
* Walk a pure time window page by page and hand back every row in it.
|
|
5463
|
+
*
|
|
5464
|
+
* The request sends NO `limit`: the host pages a window at its own default (`mode:"time"`, 50 rows)
|
|
5465
|
+
* and says how much is left in `remaining`. The panel shows the WHOLE window — "what happened that
|
|
5466
|
+
* day" is the question, and answering it with the first 50 of 286 rows would hide most of the day.
|
|
5467
|
+
* Walking `offset` (never a client-side `limit`) is what reconciles the two: 286 facts arrive as six
|
|
5468
|
+
* pages, which is exactly what `offset` is for, and the host's cap stays untouched.
|
|
5469
|
+
*
|
|
5470
|
+
* `null` rows means a newer search took over (`listGuard`); the caller must then stop without touching
|
|
5471
|
+
* the list. `error` is reported with whatever rows were already collected, so a failure on page 3
|
|
5472
|
+
* still shows pages 1-2 rather than nothing.
|
|
5473
|
+
*
|
|
5474
|
+
* The rows are de-duplicated ACROSS pages (see the loop's note): a walk is one render of the whole
|
|
5475
|
+
* window, so a row repeated because the offset drifted would be a duplicate React key on the very
|
|
5476
|
+
* first paint, not just on a later scroll.
|
|
5477
|
+
*
|
|
5478
|
+
* A walk that stops early is RESUMABLE: it hands back the host-handed `nextOffset` it reached and the
|
|
5479
|
+
* window's `total`, and the unified list state keeps `truncated = nextOffset < total` so `loadMore()`
|
|
5480
|
+
* continues from there on the next scroll instead of leaving 「还有 K 条未显示」 as a dead end.
|
|
5481
|
+
*/
|
|
5482
|
+
const runTimeWindow = async (remote, seq, time) => {
|
|
5483
|
+
const pages = [];
|
|
5484
|
+
let total = null;
|
|
5485
|
+
let offset = 0;
|
|
5486
|
+
for (let page = 0; page < SEARCH_PAGES_MAX; page++) {
|
|
5487
|
+
const outcome = await callRemote(`recall.search(time, offset=${String(offset)}${searchKind === "all" ? "" : `, kind=${searchKind}`})`, remote.recall({
|
|
5488
|
+
action: "search",
|
|
5489
|
+
...searchKind === "all" ? {} : { kind: searchKind },
|
|
5490
|
+
time,
|
|
5491
|
+
offset
|
|
5492
|
+
}));
|
|
5493
|
+
if (!listGuard.isCurrent(seq)) return {
|
|
5494
|
+
rows: null,
|
|
5495
|
+
total: null,
|
|
5496
|
+
nextOffset: offset
|
|
5497
|
+
};
|
|
5498
|
+
if (!outcome.ok) return {
|
|
5499
|
+
rows: mergeRowPages(pages),
|
|
5500
|
+
total,
|
|
5501
|
+
nextOffset: offset,
|
|
5502
|
+
error: outcome.error
|
|
5503
|
+
};
|
|
5504
|
+
const value = outcome.value;
|
|
5505
|
+
if (value.mode !== "time") break;
|
|
5506
|
+
pages.push(value.hits.map((hit) => hitRow(hit, actionFact(hit.ref_id, hit.text, hit.truncated === true))));
|
|
5507
|
+
total = value.total;
|
|
5508
|
+
offset = value.offset + value.hits.length;
|
|
5509
|
+
const remaining = typeof value.remaining === "number" ? value.remaining : Math.max(0, value.total - offset);
|
|
5510
|
+
if (value.hits.length === 0 || remaining <= 0) break;
|
|
5511
|
+
}
|
|
5512
|
+
return {
|
|
5513
|
+
rows: mergeRowPages(pages),
|
|
5514
|
+
total,
|
|
5515
|
+
nextOffset: offset
|
|
5516
|
+
};
|
|
5517
|
+
};
|
|
5518
|
+
/**
|
|
5519
|
+
* Search memory through the same `recall` remote the model tool uses, and put the answer into the
|
|
5520
|
+
* ONE list state (the result set REPLACES the list's rows; `clearSearch` puts the list back).
|
|
5521
|
+
*/
|
|
4874
5522
|
const runSearch = async () => {
|
|
4875
|
-
|
|
5523
|
+
const remote = props.remote;
|
|
5524
|
+
if (remote === void 0) return;
|
|
4876
5525
|
const query = searchText.trim();
|
|
4877
|
-
|
|
4878
|
-
|
|
4879
|
-
|
|
4880
|
-
setSearchRelation(null);
|
|
4881
|
-
setSearchFactPage(null);
|
|
4882
|
-
setSearchFacts({});
|
|
4883
|
-
setSearching(false);
|
|
4884
|
-
setError(void 0);
|
|
5526
|
+
const time = buildTimeFilter(timeField, timeMode, timeOn, timeFrom, timeTo);
|
|
5527
|
+
if (query === "" && time === void 0) {
|
|
5528
|
+
if (applied !== null || searchText !== "") clearSearch();
|
|
4885
5529
|
return;
|
|
4886
5530
|
}
|
|
4887
|
-
const seq =
|
|
5531
|
+
const seq = listGuard.begin();
|
|
5532
|
+
const factsSeq = searchFactsGuard.begin();
|
|
5533
|
+
searchFactsSeq.current = factsSeq;
|
|
4888
5534
|
setSearching(true);
|
|
4889
5535
|
setError(void 0);
|
|
4890
|
-
|
|
5536
|
+
setSearchFacts({});
|
|
5537
|
+
setApplied({
|
|
5538
|
+
query,
|
|
5539
|
+
time: time ?? null,
|
|
5540
|
+
kind: searchKind,
|
|
5541
|
+
mode: query === "" ? "time" : "hits"
|
|
5542
|
+
});
|
|
5543
|
+
setList({
|
|
5544
|
+
...EMPTY_LIST,
|
|
5545
|
+
loading: true
|
|
5546
|
+
});
|
|
5547
|
+
if (query === "" && time !== void 0) {
|
|
5548
|
+
const walked = await runTimeWindow(remote, seq, time);
|
|
5549
|
+
if (!listGuard.isCurrent(seq)) return;
|
|
5550
|
+
const rows = walked.rows ?? [];
|
|
5551
|
+
setList({
|
|
5552
|
+
rows,
|
|
5553
|
+
total: walked.total ?? void 0,
|
|
5554
|
+
truncated: walked.total !== null && walked.nextOffset < walked.total,
|
|
5555
|
+
nextOffset: walked.nextOffset,
|
|
5556
|
+
loading: false,
|
|
5557
|
+
error: walked.error
|
|
5558
|
+
});
|
|
5559
|
+
loadSearchFacts(factsSeq, rows.flatMap((row) => row.kind === "fact" ? [row.fact_id] : []));
|
|
5560
|
+
setSearching(false);
|
|
5561
|
+
return;
|
|
5562
|
+
}
|
|
5563
|
+
const outcome = await callRemote(`recall.search(${query}${searchKind === "all" ? "" : `, kind=${searchKind}`})`, remote.recall({
|
|
4891
5564
|
action: "search",
|
|
4892
5565
|
query,
|
|
4893
5566
|
limit: SEARCH_LIMIT,
|
|
4894
|
-
...searchKind === "all" ? {} : { kind: searchKind }
|
|
5567
|
+
...searchKind === "all" ? {} : { kind: searchKind },
|
|
5568
|
+
...time === void 0 ? {} : { time }
|
|
4895
5569
|
}));
|
|
4896
|
-
if (!
|
|
5570
|
+
if (!listGuard.isCurrent(seq)) return;
|
|
4897
5571
|
const value = outcome.ok ? outcome.value : void 0;
|
|
4898
5572
|
if (value !== void 0 && value.mode === "relation") {
|
|
4899
|
-
|
|
4900
|
-
|
|
4901
|
-
|
|
5573
|
+
setApplied((current) => current === null ? null : {
|
|
5574
|
+
...current,
|
|
5575
|
+
mode: "relation"
|
|
5576
|
+
});
|
|
5577
|
+
const rows = [...value.paths.map(pathRow), ...value.evidence.map((entry) => evidenceRow(entry, actionFact(entry.fact_id, entry.text, entry.truncated === true)))];
|
|
5578
|
+
setList({
|
|
5579
|
+
rows,
|
|
5580
|
+
total: void 0,
|
|
5581
|
+
truncated: false,
|
|
5582
|
+
nextOffset: rows.length,
|
|
5583
|
+
loading: false,
|
|
5584
|
+
error: void 0
|
|
4902
5585
|
});
|
|
4903
|
-
|
|
4904
|
-
setSearchFactPage(null);
|
|
4905
|
-
loadSearchFacts(seq, value.evidence.map((entry) => entry.fact_id));
|
|
5586
|
+
loadSearchFacts(factsSeq, value.evidence.map((entry) => entry.fact_id));
|
|
4906
5587
|
} else if (value !== void 0 && value.mode === "facts") {
|
|
4907
|
-
|
|
4908
|
-
|
|
4909
|
-
|
|
4910
|
-
|
|
5588
|
+
setApplied((current) => current === null ? null : {
|
|
5589
|
+
...current,
|
|
5590
|
+
mode: "facts"
|
|
5591
|
+
});
|
|
5592
|
+
const rows = value.facts.map((entry) => factPageRow(entry, actionFact(entry.fact_id, entry.content, entry.truncated === true)));
|
|
5593
|
+
setList({
|
|
5594
|
+
rows,
|
|
5595
|
+
total: void 0,
|
|
5596
|
+
truncated: false,
|
|
5597
|
+
nextOffset: rows.length,
|
|
5598
|
+
loading: false,
|
|
5599
|
+
error: void 0
|
|
5600
|
+
});
|
|
5601
|
+
loadSearchFacts(factsSeq, value.facts.map((entry) => entry.fact_id));
|
|
4911
5602
|
} else if (value !== void 0) {
|
|
4912
|
-
|
|
4913
|
-
|
|
4914
|
-
|
|
4915
|
-
|
|
4916
|
-
|
|
4917
|
-
|
|
4918
|
-
|
|
4919
|
-
|
|
4920
|
-
|
|
4921
|
-
|
|
4922
|
-
|
|
5603
|
+
setApplied((current) => current === null ? null : {
|
|
5604
|
+
...current,
|
|
5605
|
+
mode: "hits"
|
|
5606
|
+
});
|
|
5607
|
+
const rows = value.hits.map((hit) => hitRow(hit, actionFact(hit.ref_id, hit.text, hit.truncated === true)));
|
|
5608
|
+
setList({
|
|
5609
|
+
rows,
|
|
5610
|
+
total: void 0,
|
|
5611
|
+
truncated: false,
|
|
5612
|
+
nextOffset: rows.length,
|
|
5613
|
+
loading: false,
|
|
5614
|
+
error: void 0
|
|
5615
|
+
});
|
|
5616
|
+
loadSearchFacts(factsSeq, value.hits.map((hit) => hit.ref_id));
|
|
5617
|
+
} else setList({
|
|
5618
|
+
...EMPTY_LIST,
|
|
5619
|
+
error: outcome.ok ? void 0 : outcome.error
|
|
5620
|
+
});
|
|
4923
5621
|
setSearching(false);
|
|
4924
5622
|
};
|
|
4925
|
-
/**
|
|
5623
|
+
/**
|
|
5624
|
+
* Fetch the NEXT page of the window whose rows are on screen, and APPEND it to the same state.
|
|
5625
|
+
*
|
|
5626
|
+
* `runTimeWindow` normally walks a window to its end in one user action, so this is the path for the
|
|
5627
|
+
* case it could not finish: a page that failed (the rows before it stay, and so does the shortfall
|
|
5628
|
+
* line) or the `SEARCH_PAGES_MAX` wall. Without it the panel would print 「还有 K 条未显示」 and stop
|
|
5629
|
+
* there — the rows would be named but unreachable.
|
|
5630
|
+
*
|
|
5631
|
+
* The question is NOT rebuilt from the live controls: it is the one the visible rows were fetched with
|
|
5632
|
+
* (`applied.time` + `applied.kind` + the empty query). A date edited after the search must not change
|
|
5633
|
+
* what page 2 means, or the list would concatenate two different windows. `offset` is the host-handed
|
|
5634
|
+
* count from the list state, so a page that repeated a row (page drift) cannot shift what comes next.
|
|
5635
|
+
*
|
|
5636
|
+
* `listGuard` is the re-entry wall: this request becomes the newest one, and a search or a clear that
|
|
5637
|
+
* starts while it is in flight drops its reply. `loadingMoreRef` (in `loadMore`) is the burst wall —
|
|
5638
|
+
* one page in flight.
|
|
5639
|
+
*/
|
|
5640
|
+
const loadSearchPage = async (offset) => {
|
|
5641
|
+
const remote = props.remote;
|
|
5642
|
+
const search = applied;
|
|
5643
|
+
if (remote === void 0 || search === null || search.time === null) return;
|
|
5644
|
+
const seq = listGuard.begin();
|
|
5645
|
+
setList((previous) => ({
|
|
5646
|
+
...previous,
|
|
5647
|
+
loading: true
|
|
5648
|
+
}));
|
|
5649
|
+
const outcome = await callRemote(`recall.search(time, offset=${String(offset)}${search.kind === "all" ? "" : `, kind=${search.kind}`})`, remote.recall({
|
|
5650
|
+
action: "search",
|
|
5651
|
+
...search.kind === "all" ? {} : { kind: search.kind },
|
|
5652
|
+
time: search.time,
|
|
5653
|
+
offset
|
|
5654
|
+
}));
|
|
5655
|
+
if (!listGuard.isCurrent(seq)) return;
|
|
5656
|
+
if (!outcome.ok) {
|
|
5657
|
+
setList((previous) => ({
|
|
5658
|
+
...previous,
|
|
5659
|
+
loading: false,
|
|
5660
|
+
error: outcome.error
|
|
5661
|
+
}));
|
|
5662
|
+
return;
|
|
5663
|
+
}
|
|
5664
|
+
const value = outcome.value;
|
|
5665
|
+
if (value.mode !== "time") {
|
|
5666
|
+
setList((previous) => ({
|
|
5667
|
+
...previous,
|
|
5668
|
+
loading: false,
|
|
5669
|
+
truncated: false
|
|
5670
|
+
}));
|
|
5671
|
+
return;
|
|
5672
|
+
}
|
|
5673
|
+
const pageRows = value.hits.map((hit) => hitRow(hit, actionFact(hit.ref_id, hit.text, hit.truncated === true)));
|
|
5674
|
+
setList((previous) => ({
|
|
5675
|
+
...previous,
|
|
5676
|
+
...appendWindowPage(previous, {
|
|
5677
|
+
rows: pageRows,
|
|
5678
|
+
offset: value.offset,
|
|
5679
|
+
total: value.total
|
|
5680
|
+
}),
|
|
5681
|
+
total: value.total,
|
|
5682
|
+
loading: false,
|
|
5683
|
+
error: void 0
|
|
5684
|
+
}));
|
|
5685
|
+
loadSearchFacts(searchFactsSeq.current, value.hits.map((hit) => hit.ref_id));
|
|
5686
|
+
};
|
|
5687
|
+
/**
|
|
5688
|
+
* Load the next page of WHATEVER owns the list.
|
|
5689
|
+
*
|
|
5690
|
+
* The single paging entry: the end-of-list button and the scroll controller both call it, so there is
|
|
5691
|
+
* no "button version" and "scroll version" to diverge. The source is decided by `applied` — the search
|
|
5692
|
+
* the visible rows came from — and only a time window has a next page, because a keyword / fact-level /
|
|
5693
|
+
* relation answer is a shortlist whose offset the host does not page.
|
|
5694
|
+
*
|
|
5695
|
+
* Two walls, and both are needed: `loadingMoreRef` is synchronous (two checks in ONE tick — a queued
|
|
5696
|
+
* scroll frame and a re-check — must not send the same page twice), `list.loading` is the committed
|
|
5697
|
+
* state (a page started elsewhere, e.g. the refresh after a mutation, is already in flight).
|
|
5698
|
+
*/
|
|
5699
|
+
const loadMore = async () => {
|
|
5700
|
+
if (props.remote === void 0 || loadingMoreRef.current || list.loading || !list.truncated) return;
|
|
5701
|
+
loadingMoreRef.current = true;
|
|
5702
|
+
try {
|
|
5703
|
+
if (applied === null) {
|
|
5704
|
+
await loadListPage(list.nextOffset);
|
|
5705
|
+
return;
|
|
5706
|
+
}
|
|
5707
|
+
if (applied.mode !== "time") return;
|
|
5708
|
+
await loadSearchPage(list.nextOffset);
|
|
5709
|
+
} finally {
|
|
5710
|
+
loadingMoreRef.current = false;
|
|
5711
|
+
}
|
|
5712
|
+
};
|
|
5713
|
+
/** Clear the query, the time window and the result view; the list view comes back on its own. */
|
|
4926
5714
|
const clearSearch = () => {
|
|
4927
|
-
|
|
5715
|
+
listGuard.begin();
|
|
5716
|
+
searchFactsGuard.begin();
|
|
4928
5717
|
setSearchText("");
|
|
4929
|
-
|
|
4930
|
-
|
|
4931
|
-
|
|
5718
|
+
setTimeOn("");
|
|
5719
|
+
setTimeFrom("");
|
|
5720
|
+
setTimeTo("");
|
|
5721
|
+
setApplied(null);
|
|
4932
5722
|
setSearchFacts({});
|
|
4933
5723
|
setSearching(false);
|
|
5724
|
+
setEditing(null);
|
|
5725
|
+
setDetail(null);
|
|
4934
5726
|
setError(void 0);
|
|
5727
|
+
setList({
|
|
5728
|
+
...EMPTY_LIST,
|
|
5729
|
+
loading: props.remote !== void 0
|
|
5730
|
+
});
|
|
4935
5731
|
};
|
|
4936
|
-
/** Switch the status tab:
|
|
5732
|
+
/** Switch the status tab: drop the other tab's page immediately, and its in-flight page too. */
|
|
4937
5733
|
const switchStatus = (next) => {
|
|
4938
5734
|
if (next === statusFilter) return;
|
|
4939
5735
|
listGuard.begin();
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
|
|
4943
|
-
|
|
5736
|
+
if (listView) setList({
|
|
5737
|
+
...EMPTY_LIST,
|
|
5738
|
+
loading: props.remote !== void 0
|
|
5739
|
+
});
|
|
4944
5740
|
setDetail(null);
|
|
4945
5741
|
setEditing(null);
|
|
4946
5742
|
contradictionGuard.begin();
|
|
4947
5743
|
setContradictions(null);
|
|
4948
5744
|
setStatusFilter(next);
|
|
4949
5745
|
};
|
|
4950
|
-
/**
|
|
5746
|
+
/**
|
|
5747
|
+
* Re-read whatever the list is SHOWING: the admin page, or nothing while a search owns it.
|
|
5748
|
+
*
|
|
5749
|
+
* A search result set is re-read by the query that produced it (`factButtons`' `fromSearch`), so
|
|
5750
|
+
* refreshing the admin list underneath it would be a page nobody is looking at — and, now that both
|
|
5751
|
+
* sources write ONE state, it would overwrite the rows on screen.
|
|
5752
|
+
*/
|
|
5753
|
+
const refreshVisible = react.useCallback(() => listView ? loadListPage(0) : Promise.resolve(), [listView, loadListPage]);
|
|
5754
|
+
/** Run one mutation, re-read the visible list, then surface the mutation's own outcome. */
|
|
4951
5755
|
const mutate = react.useCallback((label, call, opts) => {
|
|
4952
5756
|
const remote = props.remote;
|
|
4953
5757
|
if (remote === void 0) return;
|
|
4954
5758
|
runRemoteAction({
|
|
4955
|
-
setLoading,
|
|
5759
|
+
setLoading: setBusy,
|
|
4956
5760
|
setNotice,
|
|
4957
5761
|
setError
|
|
4958
5762
|
}, label, call(remote), {
|
|
4959
5763
|
summarize: opts?.summarize,
|
|
4960
|
-
refreshFirst:
|
|
5764
|
+
refreshFirst: refreshVisible,
|
|
4961
5765
|
after: opts?.after
|
|
4962
5766
|
});
|
|
4963
|
-
}, [
|
|
4964
|
-
props.remote,
|
|
4965
|
-
load,
|
|
4966
|
-
statusFilter
|
|
4967
|
-
]);
|
|
5767
|
+
}, [props.remote, refreshVisible]);
|
|
4968
5768
|
/**
|
|
4969
5769
|
* Read one fact's detail. Kept separate from the toggle because a relation mutation changes the
|
|
4970
5770
|
* detail it was performed in: the panel stays open and re-reads it, instead of leaving a stale
|
|
@@ -5021,13 +5821,14 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
5021
5821
|
} });
|
|
5022
5822
|
};
|
|
5023
5823
|
/**
|
|
5024
|
-
* The per-fact action buttons — ONE implementation for the admin list and
|
|
5824
|
+
* The per-fact action buttons — ONE implementation for the admin list and every result shape.
|
|
5025
5825
|
*
|
|
5026
|
-
* The regression this replaced: the actions were written inline in the list row, so when
|
|
5027
|
-
*
|
|
5028
|
-
*
|
|
5029
|
-
*
|
|
5030
|
-
*
|
|
5826
|
+
* The regression this replaced: the actions were written inline in the list row, so when a search
|
|
5827
|
+
* owned the view, a hit's 「详情」 had nowhere to render and there was no way to edit/archive
|
|
5828
|
+
* a fact found by search.
|
|
5829
|
+
*
|
|
5830
|
+
* `fromSearch` only decides what happens AFTER the write: a result set is what is on screen, so it
|
|
5831
|
+
* re-runs the query, while a list row is re-read by `mutate`'s `refreshVisible`.
|
|
5031
5832
|
*
|
|
5032
5833
|
* A field the caller does not know is `undefined` and its button is OMITTED — never shown with a
|
|
5033
5834
|
* guessed label (「恢复」 on an active fact or 「永久记忆」 on a pinned one is a different write).
|
|
@@ -5044,7 +5845,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
5044
5845
|
if (editing?.id === fact.fact_id) return [h(Btn, {
|
|
5045
5846
|
key: "save",
|
|
5046
5847
|
label: "保存",
|
|
5047
|
-
disabled:
|
|
5848
|
+
disabled: busy || editing.text.trim().length === 0,
|
|
5048
5849
|
onClick: () => {
|
|
5049
5850
|
const text = editing.text.trim();
|
|
5050
5851
|
setEditing(null);
|
|
@@ -5057,7 +5858,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
5057
5858
|
}), h(Btn, {
|
|
5058
5859
|
key: "cancel",
|
|
5059
5860
|
label: "取消",
|
|
5060
|
-
disabled:
|
|
5861
|
+
disabled: busy,
|
|
5061
5862
|
onClick: () => {
|
|
5062
5863
|
setEditing(null);
|
|
5063
5864
|
}
|
|
@@ -5067,7 +5868,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
5067
5868
|
h(Btn, {
|
|
5068
5869
|
key: "detail",
|
|
5069
5870
|
label: detail?.id === fact.fact_id ? "收起详情" : "详情",
|
|
5070
|
-
disabled:
|
|
5871
|
+
disabled: busy,
|
|
5071
5872
|
onClick: () => {
|
|
5072
5873
|
showDetail(fact.fact_id);
|
|
5073
5874
|
}
|
|
@@ -5075,7 +5876,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
5075
5876
|
fact.status === "active" && content !== void 0 ? h(Btn, {
|
|
5076
5877
|
key: "edit",
|
|
5077
5878
|
label: "编辑",
|
|
5078
|
-
disabled:
|
|
5879
|
+
disabled: busy,
|
|
5079
5880
|
onClick: () => {
|
|
5080
5881
|
setEditing({
|
|
5081
5882
|
id: fact.fact_id,
|
|
@@ -5084,32 +5885,10 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
5084
5885
|
setDetail(null);
|
|
5085
5886
|
}
|
|
5086
5887
|
}) : null,
|
|
5087
|
-
fact.status === "active" ? h(Btn, {
|
|
5088
|
-
key: "helpful",
|
|
5089
|
-
label: "有用 +1",
|
|
5090
|
-
disabled: loading,
|
|
5091
|
-
onClick: () => {
|
|
5092
|
-
run(`反馈有用 #${String(fact.fact_id)}`, (remote) => remote.remember({
|
|
5093
|
-
action: "helpful",
|
|
5094
|
-
fact_id: fact.fact_id
|
|
5095
|
-
}));
|
|
5096
|
-
}
|
|
5097
|
-
}) : null,
|
|
5098
|
-
fact.status === "active" ? h(Btn, {
|
|
5099
|
-
key: "unhelpful",
|
|
5100
|
-
label: "没用 −1",
|
|
5101
|
-
disabled: loading,
|
|
5102
|
-
onClick: () => {
|
|
5103
|
-
run(`反馈没用 #${String(fact.fact_id)}`, (remote) => remote.remember({
|
|
5104
|
-
action: "unhelpful",
|
|
5105
|
-
fact_id: fact.fact_id
|
|
5106
|
-
}));
|
|
5107
|
-
}
|
|
5108
|
-
}) : null,
|
|
5109
5888
|
fact.status === "active" ? h(Btn, {
|
|
5110
5889
|
key: "archive",
|
|
5111
5890
|
label: "归档",
|
|
5112
|
-
disabled:
|
|
5891
|
+
disabled: busy,
|
|
5113
5892
|
onClick: () => {
|
|
5114
5893
|
run(`归档 #${String(fact.fact_id)}`, (remote) => remote.admin({
|
|
5115
5894
|
action: "archive",
|
|
@@ -5120,7 +5899,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
5120
5899
|
}) : h(Btn, {
|
|
5121
5900
|
key: "restore",
|
|
5122
5901
|
label: "恢复",
|
|
5123
|
-
disabled:
|
|
5902
|
+
disabled: busy,
|
|
5124
5903
|
onClick: () => {
|
|
5125
5904
|
run(`恢复 #${String(fact.fact_id)}`, (remote) => remote.admin({
|
|
5126
5905
|
action: "restore",
|
|
@@ -5131,7 +5910,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
5131
5910
|
fact.status === "active" && fact.pinned !== void 0 ? fact.pinned ? h(Btn, {
|
|
5132
5911
|
key: "unpin",
|
|
5133
5912
|
label: "解除永久",
|
|
5134
|
-
disabled:
|
|
5913
|
+
disabled: busy,
|
|
5135
5914
|
onClick: () => {
|
|
5136
5915
|
run(`解除永久 #${String(fact.fact_id)}`, (remote) => remote.admin({
|
|
5137
5916
|
action: "unpin",
|
|
@@ -5141,7 +5920,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
5141
5920
|
}) : h(Btn, {
|
|
5142
5921
|
key: "pin",
|
|
5143
5922
|
label: "永久记忆",
|
|
5144
|
-
disabled:
|
|
5923
|
+
disabled: busy,
|
|
5145
5924
|
onClick: () => {
|
|
5146
5925
|
run(`固化永久 #${String(fact.fact_id)}`, (remote) => remote.admin({
|
|
5147
5926
|
action: "pin",
|
|
@@ -5223,8 +6002,59 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
5223
6002
|
} else setError(outcome.error);
|
|
5224
6003
|
});
|
|
5225
6004
|
};
|
|
5226
|
-
const shown = rows.length;
|
|
5227
|
-
|
|
6005
|
+
const shown = list.rows.length;
|
|
6006
|
+
/**
|
|
6007
|
+
* The NAME of the answer on screen, for the unified info line (see {@link metaLine}).
|
|
6008
|
+
*
|
|
6009
|
+
* It says which KIND of answer the rows are (a fact-level AND page is not a hit list, and a
|
|
6010
|
+
* relation answer is structure), plus the memory layer it was fetched with — `applied`, never the
|
|
6011
|
+
* live selector. `null` for a pure time window: there the WINDOW is the question, so naming it
|
|
6012
|
+
* twice would only repeat the line's first piece.
|
|
6013
|
+
*/
|
|
6014
|
+
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}”`}`;
|
|
6015
|
+
/**
|
|
6016
|
+
* How many rows the SOURCE holds and how many are on screen — the count piece of the info line.
|
|
6017
|
+
*
|
|
6018
|
+
* `list.total` is only set when the source counts its rows (`admin.list`'s own `total`, or a walked
|
|
6019
|
+
* time window's), so a RANKED keyword page / fact-level page / relation answer prints no count here:
|
|
6020
|
+
* labelling a ten-row shortlist with a corpus-wide number would claim a completeness it lacks.
|
|
6021
|
+
*
|
|
6022
|
+
* The 「继续滚动自动加载」 hint appears only when scrolling really loads (the same `truncated` the
|
|
6023
|
+
* pager and the scroll hook read), and 「正在加载…」 says when a page is on its way, so a shortfall
|
|
6024
|
+
* never reads as a dead end.
|
|
6025
|
+
*/
|
|
6026
|
+
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 ? "(继续滚动自动加载)" : "");
|
|
6027
|
+
/**
|
|
6028
|
+
* THE top information line of the 记忆 tab — ONE line, in ONE place, for the list and for every
|
|
6029
|
+
* search answer.
|
|
6030
|
+
*
|
|
6031
|
+
* It used to be TWO interfaces' worth: the list had this sentence in the sticky bar, while a search
|
|
6032
|
+
* wrapped the SAME rows in `css.searchPanel` with three blocks of its own (`css.searchTitle`,
|
|
6033
|
+
* `css.searchWindow` and a second count line). Two renderings of one fact is what made a search look
|
|
6034
|
+
* like a separate screen. The composition below is the fix: the pieces are joined in a fixed order,
|
|
6035
|
+
* and a source that cannot state a piece omits it.
|
|
6036
|
+
*
|
|
6037
|
+
* 时间窗口:… · <answer name> · 共 N 条,已显示 M 条[,还有 K 条未显示] (继续滚动自动加载)
|
|
6038
|
+
*
|
|
6039
|
+
* The window is the one the VISIBLE rows were narrowed to (`applied.time`, never the live draft).
|
|
6040
|
+
*/
|
|
6041
|
+
const metaLine = [
|
|
6042
|
+
applied === null || applied.time === null ? null : `时间窗口:${timeFilterLabel(applied.time)}`,
|
|
6043
|
+
answerTitle,
|
|
6044
|
+
countText
|
|
6045
|
+
].filter((part) => part !== null).join(" · ");
|
|
6046
|
+
/** The list view before its first page has nothing to count yet: say what is happening instead. */
|
|
6047
|
+
const statusLine = applied === null && list.loading && shown === 0 ? "读取中…" : metaLine;
|
|
6048
|
+
/** What the list says when its source returned nothing at all. */
|
|
6049
|
+
const emptyText = listView ? statusFilter === "active" ? "当前没有活动的事实。可在对话里让 agent 用 mem_remember 写入。" : "当前没有归档的事实。" : applied?.mode === "facts" ? "没有同时提到这些实体的事实。" : applied?.mode === "relation" ? "没有关系路径。" : "没有命中。";
|
|
6050
|
+
/** What it says while the source's first page is on its way. */
|
|
6051
|
+
const loadingText = listView ? "读取中…" : "正在检索记忆…";
|
|
6052
|
+
/**
|
|
6053
|
+
* A relation answer's paths and its evidence are two groups of the SAME list. When the graph found no
|
|
6054
|
+
* path but the evidence facts are still there, the sentence is printed above the rows instead of
|
|
6055
|
+
* being dropped along with the (empty) path group.
|
|
6056
|
+
*/
|
|
6057
|
+
const listNote = applied?.mode === "relation" && shown > 0 && list.rows.every((row) => row.kind === "fact") ? "没有关系路径。" : void 0;
|
|
5228
6058
|
/** Bring the panel's top into view — used when a block opens below the sticky bar. */
|
|
5229
6059
|
const rootRef = react.useRef(null);
|
|
5230
6060
|
const reveal = () => {
|
|
@@ -5233,38 +6063,67 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
5233
6063
|
react.useEffect(() => {
|
|
5234
6064
|
reveal();
|
|
5235
6065
|
}, []);
|
|
5236
|
-
/**
|
|
5237
|
-
const
|
|
5238
|
-
/**
|
|
5239
|
-
|
|
5240
|
-
|
|
5241
|
-
|
|
5242
|
-
|
|
5243
|
-
|
|
6066
|
+
/** The window the CONTROLS currently describe (`undefined` = no time condition). */
|
|
6067
|
+
const timeCondition = buildTimeFilter(timeField, timeMode, timeOn, timeFrom, timeTo);
|
|
6068
|
+
/**
|
|
6069
|
+
* The ONE scroll loader.
|
|
6070
|
+
*
|
|
6071
|
+
* Its `enabled` and its `version` come from the unified list state, and its callback is the same
|
|
6072
|
+
* `loadMore()` the end-of-list button calls — there is no second hook, no second enabled rule and no
|
|
6073
|
+
* separate "continue the results" path, which is where the two views used to diverge. `version` is the
|
|
6074
|
+
* row count, so a page that lands re-checks (a short page may not fill the scrollport, and then no
|
|
6075
|
+
* scroll event would ever come).
|
|
6076
|
+
*
|
|
6077
|
+
* `list.error === undefined` is part of the gate on purpose: the hook retries once whenever `enabled`
|
|
6078
|
+
* flips back on, so an unreported page failure would re-fire the same request on every round trip.
|
|
6079
|
+
* A failure is a STATE, not a retry trigger — the end-of-list button (still rendered, because
|
|
6080
|
+
* `truncated` is unchanged) is how the user asks again, and the next successful refresh re-arms the
|
|
6081
|
+
* loader.
|
|
6082
|
+
*/
|
|
6083
|
+
useLoadMoreOnScroll(rootRef, list.truncated && !list.loading && list.error === void 0, () => {
|
|
6084
|
+
loadMore();
|
|
6085
|
+
}, shown);
|
|
6086
|
+
/**
|
|
6087
|
+
* The rows the renderer gets: a SEARCH row re-reads its action state HERE.
|
|
6088
|
+
*
|
|
6089
|
+
* `hitRow` / `evidenceRow` / `factPageRow` take an {@link ActionFact} as an argument, so a row carries
|
|
6090
|
+
* a SNAPSHOT of `searchFacts` from the moment it was built — and the lookups (`loadSearchFacts`) answer
|
|
6091
|
+
* AFTER the rows exist. Without this the panel would render every result with the fallback set
|
|
6092
|
+
* (`status:'active'`, pin state unknown) for the life of the result set: 「归档」 on an archived fact,
|
|
6093
|
+
* and no 「永久记忆」 at all — the exact guess the lookups exist to avoid. The pre-unification view read
|
|
6094
|
+
* `searchFacts` at render for the same reason; the unified row shape moved that read into the fetch
|
|
6095
|
+
* handler, so it is restored once, here, for every search row at once.
|
|
6096
|
+
*
|
|
6097
|
+
* A list row needs no derivation: `admin.list` already hands over every action field.
|
|
6098
|
+
*/
|
|
6099
|
+
const rows = list.rows.map((row) => row.kind === "fact" && row.fromSearch ? {
|
|
6100
|
+
...row,
|
|
6101
|
+
action: actionFact(row.fact_id, row.text, row.truncated)
|
|
6102
|
+
} : row);
|
|
5244
6103
|
return h("div", {
|
|
5245
6104
|
className: pages_module_css_default.section,
|
|
5246
6105
|
ref: rootRef
|
|
5247
|
-
}, props.remote === void 0 ? h("div", { className:
|
|
6106
|
+
}, 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
6107
|
type: "button",
|
|
5249
|
-
className: statusFilter === "active" ? `${
|
|
6108
|
+
className: statusFilter === "active" ? `${BTN} ${controls_module_css_default.active}` : BTN,
|
|
5250
6109
|
onClick: () => {
|
|
5251
6110
|
switchStatus("active");
|
|
5252
6111
|
}
|
|
5253
6112
|
}, "活动"), h("button", {
|
|
5254
6113
|
type: "button",
|
|
5255
|
-
className: statusFilter === "archived" ? `${
|
|
6114
|
+
className: statusFilter === "archived" ? `${BTN} ${controls_module_css_default.active}` : BTN,
|
|
5256
6115
|
onClick: () => {
|
|
5257
6116
|
switchStatus("archived");
|
|
5258
6117
|
}
|
|
5259
6118
|
}, "归档"), h(Btn, {
|
|
5260
6119
|
label: "立即维护",
|
|
5261
|
-
disabled:
|
|
6120
|
+
disabled: busy,
|
|
5262
6121
|
onClick: () => {
|
|
5263
6122
|
mutate("生命周期维护", (remote) => remote.admin({ action: "maintenance" }));
|
|
5264
6123
|
}
|
|
5265
6124
|
}), h(Btn, {
|
|
5266
6125
|
label: contradictions === null ? "查看未处理矛盾" : "收起矛盾",
|
|
5267
|
-
disabled:
|
|
6126
|
+
disabled: busy,
|
|
5268
6127
|
onClick: () => {
|
|
5269
6128
|
const opening = contradictions === null;
|
|
5270
6129
|
loadContradictions();
|
|
@@ -5272,13 +6131,13 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
5272
6131
|
}
|
|
5273
6132
|
}), h(Btn, {
|
|
5274
6133
|
label: health === null ? "查看检索健康度" : "收起检索健康度",
|
|
5275
|
-
disabled:
|
|
6134
|
+
disabled: busy,
|
|
5276
6135
|
onClick: () => {
|
|
5277
6136
|
const opening = health === null;
|
|
5278
6137
|
loadHealth();
|
|
5279
6138
|
if (opening) reveal();
|
|
5280
6139
|
}
|
|
5281
|
-
}), h("div", { className:
|
|
6140
|
+
}), 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
6141
|
value: searchText,
|
|
5283
6142
|
placeholder: "搜索记忆…(Enter)",
|
|
5284
6143
|
"aria-label": "搜索记忆",
|
|
@@ -5291,120 +6150,115 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
5291
6150
|
runSearch();
|
|
5292
6151
|
}
|
|
5293
6152
|
}
|
|
5294
|
-
}), searchText !== "" ||
|
|
6153
|
+
}), searchText !== "" || timeCondition !== void 0 || applied !== null ? h("button", {
|
|
5295
6154
|
type: "button",
|
|
5296
|
-
className:
|
|
6155
|
+
className: controls_module_css_default.clear,
|
|
5297
6156
|
"aria-label": "清除搜索",
|
|
5298
6157
|
title: "清除搜索",
|
|
5299
6158
|
onClick: clearSearch
|
|
5300
6159
|
}, "×") : null), h("select", {
|
|
5301
|
-
className:
|
|
6160
|
+
className: controls_module_css_default.select,
|
|
5302
6161
|
value: searchKind,
|
|
5303
6162
|
"aria-label": "记忆层",
|
|
5304
6163
|
onChange: (e) => {
|
|
5305
6164
|
setSearchKind(e.target.value);
|
|
5306
6165
|
}
|
|
5307
|
-
}, h("option", { value: "all" }, "全部"), h("option", { value: "journal" }, "journal"), h("option", { value: "note" }, "note"))
|
|
5308
|
-
|
|
5309
|
-
|
|
5310
|
-
|
|
5311
|
-
|
|
5312
|
-
value: editing.text,
|
|
5313
|
-
rows: 3,
|
|
6166
|
+
}, h("option", { value: "all" }, "全部"), h("option", { value: "journal" }, "journal"), h("option", { value: "note" }, "note")), h("select", {
|
|
6167
|
+
className: controls_module_css_default.select,
|
|
6168
|
+
value: timeField,
|
|
6169
|
+
"aria-label": "时间轴",
|
|
6170
|
+
title: "时间轴:created=入库时间 · updated=修改时间 · event=事件发生时间",
|
|
5314
6171
|
onChange: (e) => {
|
|
5315
|
-
|
|
5316
|
-
id: entry.fact_id,
|
|
5317
|
-
text: e.target.value
|
|
5318
|
-
});
|
|
6172
|
+
setTimeField(e.target.value);
|
|
5319
6173
|
}
|
|
5320
|
-
}
|
|
5321
|
-
|
|
5322
|
-
|
|
5323
|
-
|
|
5324
|
-
|
|
5325
|
-
|
|
5326
|
-
|
|
5327
|
-
|
|
5328
|
-
key: `relation-evidence-${String(entry.fact_id)}`,
|
|
5329
|
-
className: pages_module_css_default.item
|
|
5330
|
-
}, entry.title === void 0 ? null : h("div", { className: pages_module_css_default.meta }, `标题:${entry.title}`), editing?.id === entry.fact_id ? h("textarea", {
|
|
5331
|
-
className: pages_module_css_default.textarea,
|
|
5332
|
-
value: editing.text,
|
|
5333
|
-
rows: 3,
|
|
6174
|
+
}, TIME_AXES.map((axis) => h("option", {
|
|
6175
|
+
key: axis.value,
|
|
6176
|
+
value: axis.value
|
|
6177
|
+
}, axis.label))), h("select", {
|
|
6178
|
+
className: controls_module_css_default.select,
|
|
6179
|
+
value: timeMode,
|
|
6180
|
+
"aria-label": "时间范围形态",
|
|
6181
|
+
title: "单日 = 这一整天;区间 = from~to(to 当天也包含)",
|
|
5334
6182
|
onChange: (e) => {
|
|
5335
|
-
|
|
5336
|
-
id: entry.fact_id,
|
|
5337
|
-
text: e.target.value
|
|
5338
|
-
});
|
|
6183
|
+
setTimeMode(e.target.value);
|
|
5339
6184
|
}
|
|
5340
|
-
}
|
|
5341
|
-
|
|
5342
|
-
|
|
5343
|
-
|
|
5344
|
-
|
|
5345
|
-
|
|
5346
|
-
className: pages_module_css_default.item
|
|
5347
|
-
}, hit.title === void 0 ? null : h("div", { className: pages_module_css_default.meta }, `标题:${hit.title}`), editing?.id === hit.ref_id ? h("textarea", {
|
|
5348
|
-
className: pages_module_css_default.textarea,
|
|
5349
|
-
value: editing.text,
|
|
5350
|
-
rows: 3,
|
|
6185
|
+
}, h("option", { value: "on" }, "单日"), h("option", { value: "range" }, "区间")), timeMode === "on" ? h("input", {
|
|
6186
|
+
type: "date",
|
|
6187
|
+
className: controls_module_css_default.dateInput,
|
|
6188
|
+
value: timeOn,
|
|
6189
|
+
"aria-label": "单日日期",
|
|
6190
|
+
title: "这一整天(本地时区)",
|
|
5351
6191
|
onChange: (e) => {
|
|
5352
|
-
|
|
5353
|
-
|
|
5354
|
-
|
|
5355
|
-
|
|
6192
|
+
setTimeOn(e.target.value);
|
|
6193
|
+
}
|
|
6194
|
+
}) : [
|
|
6195
|
+
h("input", {
|
|
6196
|
+
key: "from",
|
|
6197
|
+
type: "date",
|
|
6198
|
+
className: controls_module_css_default.dateInput,
|
|
6199
|
+
value: timeFrom,
|
|
6200
|
+
"aria-label": "起始日期",
|
|
6201
|
+
title: "起始日(包含;留空 = 不限起点)",
|
|
6202
|
+
onChange: (e) => {
|
|
6203
|
+
setTimeFrom(e.target.value);
|
|
6204
|
+
}
|
|
6205
|
+
}),
|
|
6206
|
+
h("span", {
|
|
6207
|
+
key: "sep",
|
|
6208
|
+
className: controls_module_css_default.status
|
|
6209
|
+
}, "~"),
|
|
6210
|
+
h("input", {
|
|
6211
|
+
key: "to",
|
|
6212
|
+
type: "date",
|
|
6213
|
+
className: controls_module_css_default.dateInput,
|
|
6214
|
+
value: timeTo,
|
|
6215
|
+
"aria-label": "结束日期",
|
|
6216
|
+
title: "结束日(包含当天;留空 = 不限终点)",
|
|
6217
|
+
onChange: (e) => {
|
|
6218
|
+
setTimeTo(e.target.value);
|
|
6219
|
+
}
|
|
6220
|
+
})
|
|
6221
|
+
], h(Btn, {
|
|
6222
|
+
label: searching ? "搜索中…" : "搜索",
|
|
6223
|
+
disabled: searching,
|
|
6224
|
+
onClick: () => {
|
|
6225
|
+
runSearch();
|
|
5356
6226
|
}
|
|
5357
|
-
})
|
|
5358
|
-
fact: detail.data,
|
|
5359
|
-
disabled: loading,
|
|
5360
|
-
onSaveRelations: saveRelations
|
|
5361
|
-
}) : null)))) : null, error !== void 0 ? h("div", { className: pages_module_css_default.error }, `错误:${error}`) : null, notice !== void 0 ? h("div", { className: pages_module_css_default.status }, notice) : null, props.remote !== void 0 && !searchActive && !loading && error === void 0 && shown === 0 ? h("div", { className: pages_module_css_default.status }, statusFilter === "active" ? "当前没有活动的事实。可在对话里让 agent 用 mem_remember 写入。" : "当前没有归档的事实。") : null, health !== null ? h(HealthBlock, { stats: health }) : null, contradictions !== null ? contradictions.length === 0 ? h("div", { className: pages_module_css_default.status }, "没有未处理的矛盾。") : h("ul", { className: pages_module_css_default.list }, contradictions.map((c) => h("li", {
|
|
6227
|
+
}))), 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
6228
|
key: c.contradiction_id,
|
|
5363
|
-
className:
|
|
5364
|
-
}, `#${String(c.fact_a)}「${c.content_a}」 ↔ #${String(c.fact_b)}「${c.content_b}」`, h("span", { className:
|
|
6229
|
+
className: controls_module_css_default.item
|
|
6230
|
+
}, `#${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)} · ${localTime(c.detected_at, "")}`), props.remote !== void 0 ? h("div", { className: pages_module_css_default.row }, h(Btn, {
|
|
5365
6231
|
label: `判 #${String(c.fact_a)} 错`,
|
|
5366
6232
|
danger: true,
|
|
5367
|
-
disabled:
|
|
6233
|
+
disabled: busy,
|
|
5368
6234
|
onClick: () => resolveConflict(c, "true_positive", c.fact_a)
|
|
5369
6235
|
}), h(Btn, {
|
|
5370
6236
|
label: `判 #${String(c.fact_b)} 错`,
|
|
5371
6237
|
danger: true,
|
|
5372
|
-
disabled:
|
|
6238
|
+
disabled: busy,
|
|
5373
6239
|
onClick: () => resolveConflict(c, "true_positive", c.fact_b)
|
|
5374
6240
|
}), h(Btn, {
|
|
5375
6241
|
label: "误报(两条都对)",
|
|
5376
|
-
disabled:
|
|
6242
|
+
disabled: busy,
|
|
5377
6243
|
onClick: () => resolveConflict(c, "false_positive")
|
|
5378
|
-
})) : null))) : null,
|
|
5379
|
-
|
|
5380
|
-
|
|
5381
|
-
|
|
5382
|
-
|
|
5383
|
-
|
|
5384
|
-
|
|
5385
|
-
|
|
5386
|
-
|
|
5387
|
-
|
|
5388
|
-
|
|
5389
|
-
|
|
5390
|
-
|
|
5391
|
-
|
|
5392
|
-
|
|
5393
|
-
|
|
5394
|
-
content: f.content,
|
|
5395
|
-
pinned: f.pinned === true
|
|
5396
|
-
}, false)), detail?.id === f.fact_id ? h(FactDetailView, {
|
|
5397
|
-
fact: detail.data,
|
|
5398
|
-
disabled: loading,
|
|
5399
|
-
onSaveRelations: saveRelations
|
|
5400
|
-
}) : null))), searchActive || !truncated ? null : h("button", {
|
|
5401
|
-
type: "button",
|
|
5402
|
-
className: pages_module_css_default.more,
|
|
5403
|
-
disabled: loading,
|
|
5404
|
-
onClick: () => {
|
|
5405
|
-
load(nextOffset, statusFilter);
|
|
6244
|
+
})) : null))) : null, h(FactList, {
|
|
6245
|
+
state: {
|
|
6246
|
+
...list,
|
|
6247
|
+
rows
|
|
6248
|
+
},
|
|
6249
|
+
note: listNote,
|
|
6250
|
+
emptyText,
|
|
6251
|
+
loadingText,
|
|
6252
|
+
busy,
|
|
6253
|
+
editing,
|
|
6254
|
+
onEdit: setEditing,
|
|
6255
|
+
detail,
|
|
6256
|
+
actionRow: factButtons,
|
|
6257
|
+
onSaveRelations: saveRelations,
|
|
6258
|
+
onLoadMore: () => {
|
|
6259
|
+
loadMore();
|
|
5406
6260
|
}
|
|
5407
|
-
}
|
|
6261
|
+
}));
|
|
5408
6262
|
}
|
|
5409
6263
|
/**
|
|
5410
6264
|
* Retrieval health (DESIGN §20): the numbers that say whether retrieval is actually working —
|
|
@@ -5413,14 +6267,14 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
5413
6267
|
*/
|
|
5414
6268
|
function HealthBlock(props) {
|
|
5415
6269
|
const r = props.stats.retrieval;
|
|
5416
|
-
if (r === void 0) return h("div", { className:
|
|
6270
|
+
if (r === void 0) return h("div", { className: controls_module_css_default.status }, "检索健康度不可用。");
|
|
5417
6271
|
const vectors = props.stats.vectors;
|
|
5418
6272
|
const pendingVectors = vectors === void 0 ? 0 : vectors.stale + vectors.space_stale;
|
|
5419
6273
|
const wal = props.stats.wal;
|
|
5420
6274
|
const pct = (v) => `${String(Math.round(v * 100))}%`;
|
|
5421
6275
|
const kinds = Object.entries(r.by_kind).map(([kind, v]) => `${kind} ${String(v.queries)} 次(空 ${String(v.zero_results)})`).join(" · ");
|
|
5422
6276
|
const truncated = r.embedding_truncated + r.output_truncated;
|
|
5423
|
-
return h("div", { className: pages_module_css_default.detail }, pendingVectors === 0 ? null : h("div", { className:
|
|
6277
|
+
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 }, `更新于 ${localTime(r.updated_at)}`));
|
|
5424
6278
|
}
|
|
5425
6279
|
/** Client cordis-plugin name shown in boot diagnostics. */
|
|
5426
6280
|
const name = "avantf-mem-client";
|