@modusensus/dsh-mneme 0.14.0-beta.1 → 0.14.0-beta.2
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/LICENSE +21 -21
- package/README.md +830 -830
- package/assets/icon.svg +3 -3
- package/bin/cli.mjs +964 -964
- package/bin/dsh-mneme-choro.mjs +262 -262
- package/bin/dsh-mneme-mcp.mjs +510 -510
- package/bin/dsh-mneme-serve.mjs +151 -151
- package/cordis.patch.yml +21 -21
- package/docs/CONFIGURATION.md +407 -404
- package/docs/CONTINUITY.md +139 -139
- package/docs/DAEMON.md +57 -57
- package/docs/DIRECTORIES.md +114 -114
- package/docs/DISTILL.md +397 -371
- package/docs/DSH-GO.md +97 -97
- package/docs/ENTITIES.md +247 -247
- package/docs/FORGET.md +77 -77
- package/docs/KB.md +110 -110
- package/docs/LOCAL_MODEL.md +222 -222
- package/docs/SEMANTIC.md +256 -256
- package/docs/SHARE.md +552 -552
- package/docs/SLEEP.md +202 -202
- package/docs/STORAGE.md +122 -122
- package/docs/WORKFLOWS.md +366 -366
- package/lib/api-standalone.js +1007 -1007
- package/lib/api.js +3600 -3600
- package/lib/attachment-text.js +122 -122
- package/lib/bootstrap-http.js +105 -105
- package/lib/bootstrap.js +215 -215
- package/lib/candidates.js +454 -454
- package/lib/capabilities.js +819 -819
- package/lib/choro-command.js +111 -111
- package/lib/client.js +14420 -14322
- package/lib/commands.js +95 -95
- package/lib/config-keys.js +144 -144
- package/lib/config.js +1361 -1344
- package/lib/content-hash.js +54 -54
- package/lib/continuity.js +258 -258
- package/lib/date-hint.js +32 -0
- package/lib/diary/auto.js +190 -190
- package/lib/diary/compose.js +440 -440
- package/lib/diary/dir.js +59 -59
- package/lib/diary/export.js +97 -97
- package/lib/diary/index.js +26 -26
- package/lib/diary/library.js +189 -189
- package/lib/diary/qq-inbox.js +142 -142
- package/lib/diary/rollover.js +61 -61
- package/lib/diary/session-provider.js +122 -122
- package/lib/document-index.js +94 -94
- package/lib/document.js +322 -322
- package/lib/dream/clustering.js +123 -123
- package/lib/dream/decisions.js +945 -945
- package/lib/dream/narratives.js +44 -44
- package/lib/dream/sleep.js +2027 -2027
- package/lib/dream/triage.js +139 -139
- package/lib/dream.js +1879 -1879
- package/lib/dsh-go/host.js +162 -162
- package/lib/dsh-go/panel.js +2091 -2091
- package/lib/dsh-go/text.js +342 -342
- package/lib/dsh-go/views/common.js +68 -68
- package/lib/dsh-go/views/entity.js +48 -48
- package/lib/dsh-go/views/memory.js +198 -198
- package/lib/dsh-go/views/root.js +30 -30
- package/lib/dsh-go/views/settings.js +398 -398
- package/lib/dsh-go/views/status.js +51 -51
- package/lib/embedding.js +342 -342
- package/lib/entities/extractor.js +285 -285
- package/lib/fence.js +24 -24
- package/lib/graph/anchoring.js +184 -184
- package/lib/guide.js +74 -74
- package/lib/heat.js +144 -144
- package/lib/hot-memory.js +119 -119
- package/lib/image-describe.js +158 -0
- package/lib/index.js +852 -852
- package/lib/inject.js +656 -651
- package/lib/kb/index.js +309 -309
- package/lib/kb/routes.js +219 -219
- package/lib/kb/tools.js +148 -148
- package/lib/lang.js +607 -595
- package/lib/local-embedder.js +358 -358
- package/lib/maintenance.js +162 -162
- package/lib/memory-source.js +9 -9
- package/lib/mirror.js +330 -330
- package/lib/organize.js +456 -456
- package/lib/peak-hours.js +172 -172
- package/lib/pipeline-activities.js +178 -178
- package/lib/pipeline-attribution.js +165 -165
- package/lib/pipeline-command.js +279 -279
- package/lib/pipeline-confirm.js +142 -142
- package/lib/pipeline-exec.js +174 -174
- package/lib/pipeline-nudge-auto.js +129 -129
- package/lib/pipeline-nudge.js +1623 -1623
- package/lib/pipeline-observations.js +181 -181
- package/lib/pipeline-observe.js +133 -133
- package/lib/pipeline-runner.js +361 -361
- package/lib/pipeline-runs.js +442 -442
- package/lib/pipeline.js +716 -716
- package/lib/placement.js +13 -13
- package/lib/pre-inject-gate.js +354 -354
- package/lib/profile-extract.js +164 -164
- package/lib/profile-facts.js +356 -356
- package/lib/provenance-http.js +251 -251
- package/lib/quality-filter.js +162 -162
- package/lib/recall-stats.js +190 -190
- package/lib/reclaim-http.js +150 -150
- package/lib/reranker.js +269 -269
- package/lib/runtime/adopt.js +176 -176
- package/lib/runtime/closure.js +172 -172
- package/lib/runtime/download.js +247 -247
- package/lib/runtime/layout.js +265 -265
- package/lib/runtime/loader.js +252 -252
- package/lib/runtime/model-fetch.js +361 -361
- package/lib/runtime/provision.js +237 -237
- package/lib/runtime/tarball.js +130 -130
- package/lib/runtime/verify.js +223 -223
- package/lib/safe-slice.js +18 -18
- package/lib/scope-state.js +277 -277
- package/lib/scope.js +187 -187
- package/lib/search/adaptive.js +60 -60
- package/lib/search/bm25.js +96 -96
- package/lib/semantic.js +572 -572
- package/lib/sensitive-scan.js +209 -209
- package/lib/serve.js +228 -228
- package/lib/service.js +3268 -3268
- package/lib/session-gate.js +91 -91
- package/lib/session-usage.js +350 -350
- package/lib/settings.js +827 -817
- package/lib/share-http.js +281 -281
- package/lib/share-import.js +234 -234
- package/lib/share.js +335 -335
- package/lib/store.js +5895 -5895
- package/lib/summarize.js +1388 -1343
- package/lib/tools.js +1370 -1356
- package/lib/user-settings.js +230 -230
- package/lib/vector-index.js +180 -180
- package/lib/version-check.js +122 -122
- package/lib/wiki/claims.js +267 -267
- package/lib/wiki/cli-hint.js +90 -90
- package/lib/wiki/inject.js +164 -164
- package/lib/wiki/partition.js +200 -200
- package/lib/wiki/plan.js +214 -214
- package/lib/wiki/queries.js +640 -640
- package/lib/wiki/render.js +261 -261
- package/lib/wiki/scan.js +371 -371
- package/lib/wiki/t0.js +500 -500
- package/lib/wiki/t1-jobs.js +232 -232
- package/lib/wiki/t1.js +609 -609
- package/lib/write-admission.js +404 -404
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +1 -1
- package/runtime-manifest.json +526 -526
- package/scripts/benchmark-embed.js +201 -201
- package/scripts/benchmark-recall.js +249 -249
- package/scripts/benchmark-rerank.js +166 -166
- package/scripts/build-runtime-manifest.mjs +224 -224
- package/scripts/changelog-prep.mjs +91 -91
- package/scripts/check-sync.js +44 -44
- package/scripts/e2e-dsh.js +220 -220
- package/scripts/mneme-runtime.mjs +277 -277
- package/scripts/publish-gate.mjs +197 -197
- package/scripts/release-docs.mjs +210 -210
- package/scripts/release-notes.mjs +164 -164
- package/scripts/stress-dsh.js +255 -255
- package/scripts/sync-lib.js +47 -47
- package/scripts/sync-test-badge.mjs +69 -69
- package/scripts/test-count-sync.mjs +48 -48
- package/src/api-standalone.js +1007 -1007
- package/src/api.js +3600 -3600
- package/src/attachment-text.js +122 -122
- package/src/bootstrap-http.js +105 -105
- package/src/bootstrap.js +215 -215
- package/src/candidates.js +454 -454
- package/src/capabilities.js +819 -819
- package/src/choro-command.js +111 -111
- package/src/commands.js +95 -95
- package/src/config-keys.js +144 -144
- package/src/config.js +1361 -1344
- package/src/content-hash.js +54 -54
- package/src/continuity.js +258 -258
- package/src/date-hint.js +32 -0
- package/src/diary/auto.js +190 -190
- package/src/diary/compose.js +440 -440
- package/src/diary/dir.js +59 -59
- package/src/diary/export.js +97 -97
- package/src/diary/index.js +26 -26
- package/src/diary/library.js +189 -189
- package/src/diary/qq-inbox.js +142 -142
- package/src/diary/rollover.js +61 -61
- package/src/diary/session-provider.js +122 -122
- package/src/document-index.js +94 -94
- package/src/document.js +322 -322
- package/src/dream/clustering.js +123 -123
- package/src/dream/decisions.js +945 -945
- package/src/dream/narratives.js +44 -44
- package/src/dream/sleep.js +2027 -2027
- package/src/dream/triage.js +139 -139
- package/src/dream.js +1879 -1879
- package/src/dsh-go/host.js +162 -162
- package/src/dsh-go/panel.js +2091 -2091
- package/src/dsh-go/text.js +342 -342
- package/src/dsh-go/views/common.js +68 -68
- package/src/dsh-go/views/entity.js +48 -48
- package/src/dsh-go/views/memory.js +198 -198
- package/src/dsh-go/views/root.js +30 -30
- package/src/dsh-go/views/settings.js +398 -398
- package/src/dsh-go/views/status.js +51 -51
- package/src/embedding.js +342 -342
- package/src/entities/extractor.js +285 -285
- package/src/fence.js +24 -24
- package/src/graph/anchoring.js +184 -184
- package/src/guide.js +74 -74
- package/src/heat.js +144 -144
- package/src/hot-memory.js +119 -119
- package/src/image-describe.js +158 -0
- package/src/index.js +852 -852
- package/src/inject.js +656 -651
- package/src/kb/index.js +309 -309
- package/src/kb/routes.js +219 -219
- package/src/kb/tools.js +148 -148
- package/src/lang.js +607 -595
- package/src/local-embedder.js +358 -358
- package/src/maintenance.js +162 -162
- package/src/memory-source.js +9 -9
- package/src/mirror.js +330 -330
- package/src/organize.js +456 -456
- package/src/peak-hours.js +172 -172
- package/src/pipeline-activities.js +178 -178
- package/src/pipeline-attribution.js +165 -165
- package/src/pipeline-command.js +279 -279
- package/src/pipeline-confirm.js +142 -142
- package/src/pipeline-exec.js +174 -174
- package/src/pipeline-nudge-auto.js +129 -129
- package/src/pipeline-nudge.js +1623 -1623
- package/src/pipeline-observations.js +181 -181
- package/src/pipeline-observe.js +133 -133
- package/src/pipeline-runner.js +361 -361
- package/src/pipeline-runs.js +442 -442
- package/src/pipeline.js +716 -716
- package/src/placement.js +13 -13
- package/src/pre-inject-gate.js +354 -354
- package/src/profile-extract.js +164 -164
- package/src/profile-facts.js +356 -356
- package/src/provenance-http.js +251 -251
- package/src/quality-filter.js +162 -162
- package/src/recall-stats.js +190 -190
- package/src/reclaim-http.js +150 -150
- package/src/reranker.js +269 -269
- package/src/runtime/adopt.js +176 -176
- package/src/runtime/closure.js +172 -172
- package/src/runtime/download.js +247 -247
- package/src/runtime/layout.js +265 -265
- package/src/runtime/loader.js +252 -252
- package/src/runtime/model-fetch.js +361 -361
- package/src/runtime/provision.js +237 -237
- package/src/runtime/tarball.js +130 -130
- package/src/runtime/verify.js +223 -223
- package/src/safe-slice.js +18 -18
- package/src/scope-state.js +277 -277
- package/src/scope.js +187 -187
- package/src/search/adaptive.js +60 -60
- package/src/search/bm25.js +96 -96
- package/src/semantic.js +572 -572
- package/src/sensitive-scan.js +209 -209
- package/src/serve.js +228 -228
- package/src/service.js +3268 -3268
- package/src/session-gate.js +91 -91
- package/src/session-usage.js +350 -350
- package/src/settings.js +827 -817
- package/src/share-http.js +281 -281
- package/src/share-import.js +234 -234
- package/src/share.js +335 -335
- package/src/store.js +5895 -5895
- package/src/summarize.js +1388 -1343
- package/src/tools.js +1370 -1356
- package/src/user-settings.js +230 -230
- package/src/vector-index.js +180 -180
- package/src/version-check.js +122 -122
- package/src/wiki/claims.js +267 -267
- package/src/wiki/cli-hint.js +90 -90
- package/src/wiki/inject.js +164 -164
- package/src/wiki/partition.js +200 -200
- package/src/wiki/plan.js +214 -214
- package/src/wiki/queries.js +640 -640
- package/src/wiki/render.js +261 -261
- package/src/wiki/scan.js +371 -371
- package/src/wiki/t0.js +500 -500
- package/src/wiki/t1-jobs.js +232 -232
- package/src/wiki/t1.js +609 -609
- package/src/write-admission.js +404 -404
- package/test/adaptive-inject-budget.test.js +85 -85
- package/test/api-hardening.test.js +173 -173
- package/test/api-nudge-route.test.js +259 -259
- package/test/api-routes-gaps.test.js +1092 -1092
- package/test/api.test.js +3007 -2998
- package/test/attachment-text.test.js +139 -139
- package/test/audit.test.js +448 -448
- package/test/baseline-persist.test.js +248 -248
- package/test/benchmark.test.js +94 -94
- package/test/bm25-corpus-cache.test.js +145 -145
- package/test/bootstrap.test.js +228 -228
- package/test/candidates.test.js +345 -345
- package/test/capabilities.test.js +577 -577
- package/test/capacity-warning.test.js +262 -262
- package/test/changelog-prep.test.js +125 -125
- package/test/choro-command.test.js +218 -218
- package/test/cli-enums.test.js +37 -37
- package/test/cli-flag-value.test.js +86 -86
- package/test/cli-receive.test.js +390 -390
- package/test/cli-share.test.js +437 -437
- package/test/client-share.test.js +225 -225
- package/test/client.test.js +4595 -4425
- package/test/clustering.test.js +100 -100
- package/test/commands.test.js +106 -106
- package/test/config-keys-routes.test.js +233 -233
- package/test/config.test.js +153 -153
- package/test/conflict-freeze.test.js +344 -344
- package/test/conflict-ledger-enforce.test.js +452 -452
- package/test/conflict-ledger.test.js +242 -242
- package/test/conflict-reviewed.test.js +79 -79
- package/test/content-hash.test.js +204 -204
- package/test/continuity.test.js +452 -452
- package/test/contrast.test.js +432 -432
- package/test/date-hint.test.js +156 -0
- package/test/dedupe-candidate-window.test.js +180 -180
- package/test/dependency-overrides.test.js +78 -78
- package/test/diary-library.test.js +187 -187
- package/test/diary.test.js +576 -576
- package/test/differentiate-feedback.test.js +248 -248
- package/test/distill-tx-embed-gap.test.js +110 -110
- package/test/docs-config-keys.test.js +65 -65
- package/test/document-dir.test.js +233 -233
- package/test/document.test.js +703 -703
- package/test/dream-archive-guardrail.test.js +116 -116
- package/test/dream-baseline-clamp.test.js +174 -174
- package/test/dream-candidate-hybrid.test.js +281 -281
- package/test/dream-coverage-degrade.test.js +86 -86
- package/test/dream-decision-evidence.test.js +314 -314
- package/test/dream-failure-backoff.test.js +173 -173
- package/test/dream-fence.test.js +207 -207
- package/test/dream-maxtokens-fallback.test.js +132 -132
- package/test/dream-merge-guardrail.test.js +219 -219
- package/test/dream-peak-hours.test.js +194 -194
- package/test/dream.test.js +1528 -1528
- package/test/dsh-go-host.test.js +134 -134
- package/test/dsh-go-panel-actions.test.js +854 -854
- package/test/dsh-go-panel.test.js +251 -251
- package/test/embed-ready-poll.test.js +94 -94
- package/test/embed-text-reasons.test.js +112 -112
- package/test/embedding-cache-f32.test.js +276 -276
- package/test/embedding-ready.test.js +226 -226
- package/test/entities.test.js +711 -711
- package/test/entity-recall.test.js +179 -179
- package/test/epistemic.test.js +325 -325
- package/test/failure-query.test.js +147 -147
- package/test/failure-retention.test.js +105 -105
- package/test/fnew-0112.test.js +311 -311
- package/test/fnew-03.test.js +422 -422
- package/test/forget-corrections.test.js +70 -70
- package/test/graph-anchoring.test.js +184 -184
- package/test/graph-api.test.js +333 -333
- package/test/graph-boosted-relations.test.js +189 -189
- package/test/graph-inject-budget.test.js +103 -103
- package/test/graph-multipath.test.js +269 -269
- package/test/graph-passive-confirm.test.js +91 -91
- package/test/graph-weight-evolution.test.js +104 -104
- package/test/heat.test.js +175 -175
- package/test/helpers/dream-mock.js +86 -86
- package/test/helpers/peer-worker.mjs +25 -25
- package/test/helpers/runtime-source.js +60 -60
- package/test/helpers/tar-builder.js +80 -80
- package/test/helpers/write-admission-samples.js +312 -312
- package/test/hot-memory.test.js +259 -259
- package/test/image-description.test.js +271 -0
- package/test/image-memory.test.js +170 -170
- package/test/inject-content-cap.test.js +91 -91
- package/test/inject-first-round-bm25.test.js +89 -89
- package/test/inject-heat.test.js +77 -77
- package/test/inject-parent-gate.test.js +134 -109
- package/test/inject-pin-explicit.test.js +300 -300
- package/test/inject-pin-vector-crowd.test.js +225 -225
- package/test/inject-pools.test.js +195 -195
- package/test/inject-recall.test.js +172 -172
- package/test/inject-rotation.test.js +161 -161
- package/test/inject.test.js +553 -553
- package/test/issue-617-step1-query-and-fusion.test.js +361 -361
- package/test/kb-api-wiring.test.js +128 -128
- package/test/kb-collections.test.js +272 -272
- package/test/kb-routes.test.js +379 -379
- package/test/lang.test.js +98 -98
- package/test/lastrunat-persist.test.js +101 -101
- package/test/lib-smoke.test.js +116 -116
- package/test/llm-audit.test.js +815 -815
- package/test/llm-message-source.test.js +207 -207
- package/test/local-embedder.test.js +292 -292
- package/test/maintenance.test.js +501 -501
- package/test/mcp-stdio.test.js +385 -385
- package/test/mint-source.test.js +311 -311
- package/test/mirror-dirty.test.js +424 -424
- package/test/mirror-edit-digest.test.js +187 -187
- package/test/mirror-generation.test.js +499 -499
- package/test/mirror-types.test.js +208 -208
- package/test/mirror.test.js +249 -249
- package/test/narrative-bars.test.js +162 -162
- package/test/organize-tool.test.js +232 -232
- package/test/organize.test.js +418 -418
- package/test/panel-chart-scale.test.js +90 -90
- package/test/panel-tokens.test.js +245 -245
- package/test/peak-hours-observability.test.js +70 -70
- package/test/peer-blockers.test.js +193 -193
- package/test/persona-addressing.test.js +83 -83
- package/test/persona-api.test.js +172 -172
- package/test/persona-by-session.test.js +568 -568
- package/test/persona-flag.test.js +36 -36
- package/test/persona-inject.test.js +145 -145
- package/test/persona-settings.test.js +198 -198
- package/test/pipeline-activities.test.js +155 -155
- package/test/pipeline-attribution.test.js +175 -175
- package/test/pipeline-command.test.js +243 -243
- package/test/pipeline-confirm.test.js +148 -148
- package/test/pipeline-exec.test.js +172 -172
- package/test/pipeline-nudge-auto.test.js +248 -248
- package/test/pipeline-nudge.test.js +1278 -1278
- package/test/pipeline-observations.test.js +157 -157
- package/test/pipeline-observe.test.js +127 -127
- package/test/pipeline-r1r2.test.js +278 -278
- package/test/pipeline-runner.test.js +468 -468
- package/test/pipeline-runs.test.js +767 -767
- package/test/pipeline.test.js +636 -636
- package/test/placement.test.js +36 -36
- package/test/policy-epoch.test.js +259 -259
- package/test/pre-inject-gate.test.js +444 -444
- package/test/profile-extract.test.js +269 -269
- package/test/profile-facts.test.js +492 -492
- package/test/prompt-stance.test.js +128 -128
- package/test/provenance-http.test.js +367 -367
- package/test/publish-gate.test.js +272 -272
- package/test/quality-backfill.test.js +290 -290
- package/test/quality-filter.test.js +295 -295
- package/test/readme-claims.test.js +113 -113
- package/test/reasoning-effort.test.js +752 -752
- package/test/recall-anti-update.test.js +82 -82
- package/test/recall-evals.test.js +253 -253
- package/test/recall-layer.test.js +326 -326
- package/test/recall-retention.test.js +128 -128
- package/test/recall-stats.test.js +274 -274
- package/test/receipt-chain.test.js +451 -451
- package/test/reclaim-http.test.js +209 -209
- package/test/reflection-update-gate.test.js +129 -129
- package/test/reflection.test.js +226 -226
- package/test/reindex-backfill.test.js +475 -475
- package/test/reindex-scheduler.test.js +369 -369
- package/test/release-docs.test.js +156 -156
- package/test/release-notes.test.js +265 -265
- package/test/release-workflow.test.js +93 -93
- package/test/reranker-single-logit.test.js +73 -73
- package/test/reranker.test.js +197 -197
- package/test/resident-status.test.js +83 -83
- package/test/route-policy.test.js +113 -113
- package/test/runtime-adopt.test.js +206 -206
- package/test/runtime-cli.test.js +89 -89
- package/test/runtime-closure.test.js +193 -193
- package/test/runtime-download.test.js +302 -302
- package/test/runtime-layout.test.js +163 -163
- package/test/runtime-loader.test.js +255 -255
- package/test/runtime-manifest.test.js +159 -159
- package/test/runtime-model-fetch.test.js +348 -348
- package/test/runtime-provision.test.js +101 -101
- package/test/runtime-tarball.test.js +81 -81
- package/test/runtime-verify.test.js +319 -319
- package/test/scope-candidates.test.js +491 -491
- package/test/scope-distill-stamp.test.js +134 -134
- package/test/scope-explicit.test.js +452 -452
- package/test/scope-inject-soft.test.js +106 -106
- package/test/scope-optional-service.test.js +110 -110
- package/test/scope-retrieval.test.js +197 -197
- package/test/scope-state.test.js +610 -610
- package/test/scope-storage.test.js +255 -255
- package/test/scope-strict.test.js +367 -367
- package/test/search-fusion.test.js +90 -90
- package/test/security-doc.test.js +188 -188
- package/test/semantic.test.js +124 -124
- package/test/sensitive-scan.test.js +251 -251
- package/test/serve-bin.test.js +121 -121
- package/test/serve.test.js +258 -258
- package/test/service-search.test.js +174 -174
- package/test/service.test.js +410 -410
- package/test/session-gate.test.js +264 -264
- package/test/session-usage.test.js +317 -317
- package/test/settings.test.js +273 -273
- package/test/share-foreign-rows.test.js +144 -144
- package/test/share-http.test.js +144 -144
- package/test/share-import.test.js +577 -577
- package/test/share-imported-guard.test.js +311 -311
- package/test/share-imported-identity.test.js +111 -111
- package/test/share-pack.test.js +511 -511
- package/test/share-receive-http.test.js +756 -756
- package/test/share-routes.test.js +337 -337
- package/test/share-store.test.js +133 -133
- package/test/share-validate.test.js +101 -101
- package/test/skip-invalid-restore.test.js +262 -262
- package/test/sleep-action-set.test.js +441 -441
- package/test/sleep-catchup.test.js +315 -315
- package/test/sleep-failure-backoff.test.js +156 -156
- package/test/sleep-heat.test.js +174 -174
- package/test/sleep-scheduler-rearm.test.js +89 -89
- package/test/sleep.test.js +1455 -1455
- package/test/standalone-api.test.js +1344 -1344
- package/test/store-vector-cache.test.js +98 -98
- package/test/store-write-path-columns.test.js +191 -191
- package/test/store.test.js +744 -744
- package/test/stress.test.js +209 -209
- package/test/summarize-occurred-at.test.js +127 -127
- package/test/summarize-parse-diagnostics.test.js +75 -75
- package/test/summarize-reasoning-effort.test.js +189 -189
- package/test/summarize.test.js +2251 -2251
- package/test/supersede-state-tracking.test.js +394 -394
- package/test/test-count-sync.test.js +71 -71
- package/test/tool-state-ignore.test.js +65 -65
- package/test/tools-dto-schema-matrix.test.js +398 -398
- package/test/tools-memory-get-schema.test.js +65 -65
- package/test/tools.test.js +585 -585
- package/test/updated-at-semantics.test.js +113 -113
- package/test/user-settings-preview.test.js +127 -127
- package/test/user-settings-segments.test.js +527 -527
- package/test/vector-index.test.js +334 -334
- package/test/version-check.test.js +152 -152
- package/test/webServer-optional.test.js +82 -82
- package/test/wiki-api.test.js +734 -734
- package/test/wiki-claims.test.js +194 -194
- package/test/wiki-cli-hint.test.js +118 -118
- package/test/wiki-inject.test.js +265 -265
- package/test/wiki-partition.test.js +112 -112
- package/test/wiki-plan.test.js +80 -80
- package/test/wiki-provenance.test.js +202 -202
- package/test/wiki-queries.test.js +398 -398
- package/test/wiki-scan.test.js +208 -208
- package/test/wiki-store.test.js +190 -190
- package/test/wiki-t0.test.js +456 -456
- package/test/wiki-t1-jobs.test.js +125 -125
- package/test/wiki-t1.test.js +460 -460
- package/test/wiki-tools.test.js +176 -176
- package/test/workflow-guards.test.js +155 -155
- package/test/write-admission.test.js +668 -668
package/src/dsh-go/panel.js
CHANGED
|
@@ -1,2091 +1,2091 @@
|
|
|
1
|
-
// local-mneme-panel — a dsh-go native panel over the installed dsh-mneme store.
|
|
2
|
-
//
|
|
3
|
-
// The panel never talks HTTP: src/dsh-go/host.js dispatches mneme's own registered
|
|
4
|
-
// route handlers inside this engine process. Reads are mneme's own GET routes;
|
|
5
|
-
// writes go through mneme's own routes (create/update/delete/features/profile/
|
|
6
|
-
// rules/mode/conflicts/commands/vector/external/reclaim), so its apiToken policy
|
|
7
|
-
// still applies if one is ever set.
|
|
8
|
-
import { createHostCall } from "./host.js";
|
|
9
|
-
import { buildView } from "./views/root.js";
|
|
10
|
-
import {
|
|
11
|
-
FLAG_LABELS,
|
|
12
|
-
RANGE_DAYS,
|
|
13
|
-
TYPE_LABELS,
|
|
14
|
-
clip,
|
|
15
|
-
errorText,
|
|
16
|
-
flagAction,
|
|
17
|
-
fmtDay,
|
|
18
|
-
fmtMonth,
|
|
19
|
-
fmtTime,
|
|
20
|
-
joinTags,
|
|
21
|
-
scopeText,
|
|
22
|
-
splitTags,
|
|
23
|
-
entityIcon,
|
|
24
|
-
entityTypeLabel,
|
|
25
|
-
typeIcon,
|
|
26
|
-
typeLabel,
|
|
27
|
-
typeTone,
|
|
28
|
-
} from "./text.js";
|
|
29
|
-
|
|
30
|
-
const PAGE_SIZE = 20;
|
|
31
|
-
const cache = new Map();
|
|
32
|
-
// Live panel feeds, one emit per open panel/locale. dsh-go feeds are push-based:
|
|
33
|
-
// data() runs once and later state changes must be pushed through these emits
|
|
34
|
-
// (a `refresh` effect only re-renders the view; it never re-runs data()).
|
|
35
|
-
const emitters = new Set();
|
|
36
|
-
// 每个面板 scope 打开过哪条记忆。删除确认(confirm 节点)不携带任何参数,
|
|
37
|
-
// 只能靠状态决定删谁;状态又是所有面板共用的,所以按 scope 记一份,删除前
|
|
38
|
-
// 与本面板打开的那条核对,不匹配就拒绝(评审 #5)。
|
|
39
|
-
const openedBy = new Map();
|
|
40
|
-
// 冲突页的裁决同样改数据,而 state.conflictId 是模块级共享的:两个面板
|
|
41
|
-
// 各看一条冲突时,后开的会盖掉前一个的 id。按面板 scope 记一份再核对
|
|
42
|
-
// (评审 #3 的 conflict-win 部分)。
|
|
43
|
-
const conflictBy = new Map();
|
|
44
|
-
// 「Agent 眼中的你」的编辑态 state.factEdit 同样是共享的;按面板 scope 记
|
|
45
|
-
// 一份打开的事实 id,保存与归档前核对(评审 #3 的 facts 部分)。
|
|
46
|
-
const factBy = new Map();
|
|
47
|
-
// 上一次推给 App 的完整数据:未重新读取的块从这里并回去,保证每份 data 都
|
|
48
|
-
// 带着全部绑定键(见 load 的注释)。
|
|
49
|
-
let lastPayload = null;
|
|
50
|
-
const scopeKey = (scope) => [scope?.sessionId ?? "", scope?.turnId ?? "", scope?.callId ?? ""].join("|");
|
|
51
|
-
|
|
52
|
-
const defaultState = () => ({
|
|
53
|
-
page: "list", // list|detail|editor|entity|conflict|settings|profile
|
|
54
|
-
flagGroup: "basic",
|
|
55
|
-
extraRows: [], // 上游新增、面板还没中文名的布尔旗标
|
|
56
|
-
factsCards: [],
|
|
57
|
-
factsAgent: "",
|
|
58
|
-
factsRows: [],
|
|
59
|
-
factsItems: [],
|
|
60
|
-
factEdit: null,
|
|
61
|
-
factsRunText: "",
|
|
62
|
-
choroRepos: [],
|
|
63
|
-
choroRepo: "",
|
|
64
|
-
choroRows: [],
|
|
65
|
-
choroNote: "",
|
|
66
|
-
choroPageText: "",
|
|
67
|
-
settingsView: "settings", // settings|status
|
|
68
|
-
sPanel: "menu", // menu|workbench|commands|vector|external|help|facts|choro
|
|
69
|
-
profileTab: "profile",
|
|
70
|
-
q: "",
|
|
71
|
-
type: "all",
|
|
72
|
-
range: "all",
|
|
73
|
-
minImportance: 0,
|
|
74
|
-
scope: "all",
|
|
75
|
-
semantic: false,
|
|
76
|
-
viewMode: "flat",
|
|
77
|
-
heatSort: false,
|
|
78
|
-
filtersOpen: false,
|
|
79
|
-
offset: 0,
|
|
80
|
-
size: PAGE_SIZE,
|
|
81
|
-
current: null,
|
|
82
|
-
form: {},
|
|
83
|
-
// 编辑页打开时的那份快照:只有和它不同的字段才会写回 /update,
|
|
84
|
-
// 并且用它挡住「别处已经改过这条记忆」(评审 #4)。
|
|
85
|
-
editBase: null,
|
|
86
|
-
entityTotal: -1,
|
|
87
|
-
entityTypeTotal: 0,
|
|
88
|
-
entityQuery: "",
|
|
89
|
-
entityName: "",
|
|
90
|
-
entityDepth: 1,
|
|
91
|
-
entityRelationCount: 0,
|
|
92
|
-
entityMemoryCount: 0,
|
|
93
|
-
flags: {},
|
|
94
|
-
flagOverrides: {},
|
|
95
|
-
pendingRestart: [],
|
|
96
|
-
pendingConflicts: 0,
|
|
97
|
-
pendingIds: new Set(),
|
|
98
|
-
rowsCount: 0,
|
|
99
|
-
total: 0, // 当前筛选下的总条数,翻页时用它算页数
|
|
100
|
-
monthGroups: [],
|
|
101
|
-
detailEntities: [],
|
|
102
|
-
detailEntityCount: 0,
|
|
103
|
-
detailConflict: false,
|
|
104
|
-
provenanceText: "",
|
|
105
|
-
conflictCount: 0,
|
|
106
|
-
conflictId: "",
|
|
107
|
-
conflictPick: "",
|
|
108
|
-
conflictA: null,
|
|
109
|
-
conflictB: null,
|
|
110
|
-
conflictReady: false,
|
|
111
|
-
wbKv: [],
|
|
112
|
-
wbDeposited: [],
|
|
113
|
-
wbArchived: [],
|
|
114
|
-
commands: [],
|
|
115
|
-
cmdCount: 0,
|
|
116
|
-
sCmdPick: "",
|
|
117
|
-
sCmdName: "",
|
|
118
|
-
sCmdDesc: "",
|
|
119
|
-
sCmdIns: "",
|
|
120
|
-
vector: { enabled: false, baseUrl: "", model: "" },
|
|
121
|
-
external: { enabled: false, port: 8790, host: "127.0.0.1" },
|
|
122
|
-
// 读成功才允许整体写回:读失败时上面的 vector/external 会落成默认值,
|
|
123
|
-
// 此时 PUT 会把用户在 Web 面板里配好的地址/端口覆盖掉(评审 #2)。
|
|
124
|
-
vectorLoaded: false,
|
|
125
|
-
externalLoaded: false,
|
|
126
|
-
// 实体搜索要重读的是「实体清单」而不是整页;load() 按这个标记补读
|
|
127
|
-
// (评审 #4)。
|
|
128
|
-
entityDirty: false,
|
|
129
|
-
// 顶层页签由 App 本地切换,state.page 可能还停在 "list";设置项的动作
|
|
130
|
-
// 改完数据后也要重读设置块,否则推到面板上的仍是旧值(评审 #6)。
|
|
131
|
-
settingsDirty: false,
|
|
132
|
-
versionNote: "",
|
|
133
|
-
extractText: "",
|
|
134
|
-
injectPreviewText: "",
|
|
135
|
-
tokenCategories: [],
|
|
136
|
-
tokenSeries: [],
|
|
137
|
-
tokenKv: [],
|
|
138
|
-
heatSeries: [],
|
|
139
|
-
heatNote: "",
|
|
140
|
-
reclaimText: "尚未检查。",
|
|
141
|
-
reclaimConfirm: false,
|
|
142
|
-
notice: "",
|
|
143
|
-
noticeTone: "ok",
|
|
144
|
-
err: "",
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
const state = defaultState();
|
|
148
|
-
|
|
149
|
-
function resetNotice() {
|
|
150
|
-
state.notice = "";
|
|
151
|
-
state.err = "";
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function notice(text, tone = "ok") {
|
|
155
|
-
state.notice = clip(text, 160);
|
|
156
|
-
state.noticeTone = tone;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/** Read the resolved action arguments/values the app sends with an invoke. */
|
|
160
|
-
function valueOf(event, keys, fallback) {
|
|
161
|
-
const value = event?.value;
|
|
162
|
-
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
163
|
-
for (const key of keys) {
|
|
164
|
-
if (value[key] !== undefined && value[key] !== null) return value[key];
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
if (typeof value === "string" && value !== "") return value;
|
|
168
|
-
if (typeof value === "boolean") return value;
|
|
169
|
-
return fallback;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function num(value, fallback) {
|
|
173
|
-
const n = Number(value);
|
|
174
|
-
return Number.isFinite(n) ? n : fallback;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function rowOf(row) {
|
|
178
|
-
const heat = typeof row.heat === "number" ? row.heat : Number.NaN;
|
|
179
|
-
const pending = state.pendingIds.has(row.id);
|
|
180
|
-
const marks = [];
|
|
181
|
-
if (row.archived === true) marks.push("已归档");
|
|
182
|
-
if (pending) marks.push("待确认");
|
|
183
|
-
return {
|
|
184
|
-
title: clip(row.title, 60),
|
|
185
|
-
subtitle: `${typeLabel(row.type)} · ${fmtTime(row.updated_at ?? row.created_at)}${marks.length === 0 ? "" : ` · ${marks.join(" · ")}`}`,
|
|
186
|
-
trailing: Number.isFinite(heat) ? `★${row.importance ?? 1} · 热度 ${heat.toFixed(2)}` : `★${row.importance ?? 1}`,
|
|
187
|
-
...(typeIcon(row.type) === undefined ? {} : { icon: typeIcon(row.type) }),
|
|
188
|
-
tone: typeTone(row.type),
|
|
189
|
-
action: { id: "open", args: { id: row.id } },
|
|
190
|
-
};
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/** Flat dotted flag keys -> nested object so `data.flags.a.b` bindings resolve. */
|
|
194
|
-
function nestFlags(effective) {
|
|
195
|
-
const out = {};
|
|
196
|
-
for (const [key, value] of Object.entries(effective ?? {})) {
|
|
197
|
-
const parts = key.split(".");
|
|
198
|
-
let node = out;
|
|
199
|
-
for (const part of parts.slice(0, -1)) {
|
|
200
|
-
if (typeof node[part] !== "object" || node[part] === null) node[part] = {};
|
|
201
|
-
node = node[part];
|
|
202
|
-
}
|
|
203
|
-
// 原样透传:开关是布尔,枚举型旗标(chorographyInject 的 light/full/off)
|
|
204
|
-
// 是字符串。以前这里一律写成 `value === true`,下拉永远只看到 true/false。
|
|
205
|
-
node[parts[parts.length - 1]] = value;
|
|
206
|
-
}
|
|
207
|
-
return out;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
const enc = (value) => encodeURIComponent(String(value ?? ""));
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* Mount the panel on a context that already provides the dsh-go `dshGo` service.
|
|
214
|
-
* Called by src/index.js from `ctx.inject(['dshGo'], ...)`: without the dsh-go
|
|
215
|
-
* bridge (desktop / headless hosts) this module never runs.
|
|
216
|
-
*/
|
|
217
|
-
export function mountDshGoPanel(ctx, options = {}) {
|
|
218
|
-
// options.apiToken 由 src/index.js 传入:路由注册器给 token / token-write
|
|
219
|
-
// 路由包了鉴权,进程内调用不带这个头就会被自家 API 拦成 401(评审 #2)。
|
|
220
|
-
const call = createHostCall(ctx, options);
|
|
221
|
-
|
|
222
|
-
// ---------------------------------------------------------------- loaders
|
|
223
|
-
function groupByMonth(rows, raw) {
|
|
224
|
-
const buckets = new Map();
|
|
225
|
-
rows.forEach((row, index) => {
|
|
226
|
-
const label = fmtMonth(raw[index]?.updated_at ?? raw[index]?.created_at);
|
|
227
|
-
if (!buckets.has(label)) buckets.set(label, []);
|
|
228
|
-
buckets.get(label).push(row);
|
|
229
|
-
});
|
|
230
|
-
return [...buckets.entries()].map(([label, list]) => ({ label, rows: list }));
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
async function loadMemory() {
|
|
234
|
-
const statsPromise = call("/recall-stats?window=30").catch(() => ({}));
|
|
235
|
-
const conflictsPromise = call("/conflicts").catch(() => ({}));
|
|
236
|
-
const dreamPromise = call("/dream-status").catch(() => ({}));
|
|
237
|
-
const payload = {
|
|
238
|
-
query: state.q,
|
|
239
|
-
filter: state.type,
|
|
240
|
-
fRange: state.range,
|
|
241
|
-
fMin: String(state.minImportance),
|
|
242
|
-
fScope: state.scope,
|
|
243
|
-
fSemantic: state.semantic,
|
|
244
|
-
fView: state.viewMode,
|
|
245
|
-
fHeat: state.heatSort,
|
|
246
|
-
};
|
|
247
|
-
let items = [];
|
|
248
|
-
if (state.q !== "") {
|
|
249
|
-
const mode = state.semantic ? "hybrid" : "auto";
|
|
250
|
-
const res = await call(`/search?q=${enc(state.q)}&topK=20&mode=${mode}`);
|
|
251
|
-
// /search 没有 type 参数(它是全库语义检索),类型筛选只能在拿到结果后
|
|
252
|
-
// 再收一遍,否则搜索会把用户选好的类型丢掉(评审 #8)。
|
|
253
|
-
const hits = res?.items ?? [];
|
|
254
|
-
items = state.type === "all" || state.type === "" ? hits : hits.filter((item) => item?.type === state.type);
|
|
255
|
-
payload.total = String(items.length);
|
|
256
|
-
// 空结果不再靠一块 emptyState 表达:那个结构在首屏就定了,数据晚一步
|
|
257
|
-
// 到达时就会和真实列表同时出现。文案跟着数据走,结构不跟着动。
|
|
258
|
-
payload.pageText =
|
|
259
|
-
items.length === 0
|
|
260
|
-
? "搜索无结果"
|
|
261
|
-
: `搜索 · ${items.length} 条`;
|
|
262
|
-
} else {
|
|
263
|
-
const query = new URLSearchParams({
|
|
264
|
-
limit: String(state.size),
|
|
265
|
-
offset: String(state.offset),
|
|
266
|
-
order: "chrono",
|
|
267
|
-
});
|
|
268
|
-
if (state.type !== "all" && state.type !== "") query.set("type", state.type);
|
|
269
|
-
if (state.minImportance > 0) query.set("minImportance", String(state.minImportance));
|
|
270
|
-
const days = RANGE_DAYS[state.range];
|
|
271
|
-
if (days !== undefined) query.set("updatedFrom", new Date(Date.now() - days * 86400000).toISOString());
|
|
272
|
-
if (state.scope === "deposited") query.set("deposited", "only");
|
|
273
|
-
if (state.scope === "archived") query.set("archived", "only");
|
|
274
|
-
if (state.scope === "foreign") query.set("source", "imported");
|
|
275
|
-
const res = await call(`/list?${query.toString()}`);
|
|
276
|
-
items = res?.items ?? [];
|
|
277
|
-
const total = num(res?.total, items.length);
|
|
278
|
-
const pages = Math.max(1, Math.ceil(total / state.size));
|
|
279
|
-
const pageNo = Math.min(pages, Math.floor(state.offset / state.size) + 1);
|
|
280
|
-
payload.total = String(total);
|
|
281
|
-
// 这行文案同时是左胶囊的标签,列宽只有约 142dp,越长越容易折行。
|
|
282
|
-
payload.pageText = total === 0 ? "没有匹配的记忆" : `${total} 条 · ${pageNo}/${pages} 页`;
|
|
283
|
-
}
|
|
284
|
-
for (const row of items) cache.set(row.id, row);
|
|
285
|
-
const dream = await dreamPromise;
|
|
286
|
-
state.pendingIds = new Set(Array.isArray(dream?.pendingMemoryIds) ? dream.pendingMemoryIds : []);
|
|
287
|
-
const ordered = state.heatSort ? [...items].sort((a, b2) => num(b2.heat, -1) - num(a.heat, -1)) : items;
|
|
288
|
-
payload.rows = ordered.map(rowOf);
|
|
289
|
-
state.monthGroups = groupByMonth(ordered, ordered);
|
|
290
|
-
const [stats, conflicts] = await Promise.all([statsPromise, conflictsPromise]);
|
|
291
|
-
payload.injected = String(stats?.injection?.injectedCount ?? 0);
|
|
292
|
-
payload.slotRate = String(Math.round((stats?.injection?.slotFillRate ?? 0) * 1000) / 10);
|
|
293
|
-
payload.conflicts = String(conflicts?.total ?? 0);
|
|
294
|
-
// metric values render in one style, so units ride along in the value
|
|
295
|
-
payload.totalText = `${payload.total ?? "0"} 条`;
|
|
296
|
-
payload.injectedText = `${payload.injected} 次`;
|
|
297
|
-
payload.slotRateText = `${payload.slotRate}%`;
|
|
298
|
-
payload.conflictsText = `${payload.conflicts} 条`;
|
|
299
|
-
state.pendingConflicts = num(conflicts?.total, 0);
|
|
300
|
-
payload.pendingConflicts = state.pendingConflicts;
|
|
301
|
-
// 翻页用的是 chips(原生最小 48dp,两个一定同行),点完立刻把绑定清空,
|
|
302
|
-
// 免得选中的那枚一直带着勾。
|
|
303
|
-
payload.pagePick = "";
|
|
304
|
-
state.rowsCount = (payload.rows ?? []).length;
|
|
305
|
-
state.total = num(payload.total, state.rowsCount);
|
|
306
|
-
return payload;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
async function loadDetail() {
|
|
310
|
-
const memo = state.current ?? {};
|
|
311
|
-
const entities = await call(`/memories/entities?memoryId=${enc(memo.id ?? "")}`).then((res) => res?.entities ?? []);
|
|
312
|
-
state.detailEntityCount = entities.length;
|
|
313
|
-
state.detailConflict = state.pendingIds.has(memo.id);
|
|
314
|
-
return {
|
|
315
|
-
detailTitle: clip(memo.title ?? "(无标题)", 120),
|
|
316
|
-
detailMeta: [
|
|
317
|
-
{ label: "类型", value: typeLabel(memo.type) },
|
|
318
|
-
{ label: "重要度", value: `★${memo.importance ?? 1}` },
|
|
319
|
-
{ label: "归属", value: scopeText(memo) },
|
|
320
|
-
{ label: "更新", value: fmtTime(memo.updated_at ?? memo.created_at) },
|
|
321
|
-
{ label: "标签", value: clip(joinTags(memo.tags) || "—", 40) },
|
|
322
|
-
{ label: "来源", value: clip(memo.source ?? "—", 40) },
|
|
323
|
-
{ label: "状态", value: memo.archived ? "已归档" : "正常", tone: memo.archived ? "muted" : "good" },
|
|
324
|
-
],
|
|
325
|
-
detailContent: clip(memo.content ?? "", 2000),
|
|
326
|
-
detailType: typeLabel(memo.type),
|
|
327
|
-
detailTypeTone: typeTone(memo.type) ?? "info",
|
|
328
|
-
detailStars: `重要度 ★${memo.importance ?? 1}`,
|
|
329
|
-
detailEntities: entities.slice(0, 8).map((item) => ({
|
|
330
|
-
title: clip(item.name ?? item.title ?? item.id ?? "—", 40),
|
|
331
|
-
subtitle: clip(item.type ?? "实体", 20),
|
|
332
|
-
icon: "user",
|
|
333
|
-
})),
|
|
334
|
-
detailEntityCount: entities.length,
|
|
335
|
-
detailConflict: state.detailConflict,
|
|
336
|
-
provenanceText: state.provenanceText,
|
|
337
|
-
};
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
function loadEditor() {
|
|
341
|
-
return {
|
|
342
|
-
form: {
|
|
343
|
-
id: state.form.id ?? "",
|
|
344
|
-
title: state.form.title ?? "",
|
|
345
|
-
content: state.form.content ?? "",
|
|
346
|
-
type: state.form.type ?? "project",
|
|
347
|
-
importance: num(state.form.importance, 3),
|
|
348
|
-
tags: state.form.tags ?? "",
|
|
349
|
-
agentScope: state.form.agentScope ?? "",
|
|
350
|
-
workspaceScope: state.form.workspaceScope ?? "",
|
|
351
|
-
},
|
|
352
|
-
};
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
async function loadEntity() {
|
|
356
|
-
const needle = state.entityQuery.trim();
|
|
357
|
-
// 名字过滤交给宿主(/entities?q=):本地过滤只能看见 limit 那一条切片,
|
|
358
|
-
// 排在后面的实体搜不到(评审 #9)。类型数只在不搜索时重算——它代表整库
|
|
359
|
-
// 有几种类型,不该随搜索词一起抖动。
|
|
360
|
-
const [directory, matched] = await Promise.all([
|
|
361
|
-
needle === "" ? null : call("/entities?limit=200").catch(() => null),
|
|
362
|
-
call(`/entities?limit=${needle === "" ? 200 : 30}${needle === "" ? "" : `&q=${enc(needle)}`}`),
|
|
363
|
-
]);
|
|
364
|
-
const all = directory?.entities ?? matched?.entities ?? [];
|
|
365
|
-
state.entityTotal = num(matched?.total, all.length);
|
|
366
|
-
if (needle === "") {
|
|
367
|
-
const types = new Set();
|
|
368
|
-
for (const item of all) {
|
|
369
|
-
if (typeof item?.type === "string" && item.type !== "") types.add(item.type);
|
|
370
|
-
}
|
|
371
|
-
state.entityTypeTotal = types.size;
|
|
372
|
-
}
|
|
373
|
-
const shown = (needle === "" ? all : (matched?.entities ?? [])).slice(0, 30);
|
|
374
|
-
return {
|
|
375
|
-
entityTotal: String(state.entityTotal),
|
|
376
|
-
entityTypeTotal: String(state.entityTypeTotal),
|
|
377
|
-
entityTotalText: `${String(state.entityTotal)} 个`,
|
|
378
|
-
entityTypeTotalText: `${String(state.entityTypeTotal)} 类`,
|
|
379
|
-
entityQuery: state.entityQuery,
|
|
380
|
-
// 结构固定,空库的提示走这一行文案(见 views/entity.js 的注释)。
|
|
381
|
-
entityStatus:
|
|
382
|
-
shown.length === 0
|
|
383
|
-
? needle === ""
|
|
384
|
-
? "还没有实体 · 在「设置 › 记忆引擎」的功能开关里打开「抽取人物与项目」"
|
|
385
|
-
: `没有名字含「${clip(needle, 20)}」的实体`
|
|
386
|
-
: needle === ""
|
|
387
|
-
? `共 ${String(state.entityTotal)} 个实体`
|
|
388
|
-
: `搜索「${clip(needle, 20)}」· ${String(shown.length)} 个`,
|
|
389
|
-
entities: shown.map((item) => ({
|
|
390
|
-
title: clip(item.name ?? item.title ?? item.id ?? "—", 40),
|
|
391
|
-
subtitle: clip(entityTypeLabel(item.type ?? "—"), 20),
|
|
392
|
-
trailing: item.mention_count === undefined ? undefined : `${item.mention_count} 次`,
|
|
393
|
-
...(entityIcon(item.type) === undefined ? {} : { icon: entityIcon(item.type) }),
|
|
394
|
-
action: { id: "open-entity", args: { name: String(item.name ?? "") } },
|
|
395
|
-
})),
|
|
396
|
-
};
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
async function loadEntityDetail() {
|
|
400
|
-
const name = state.entityName;
|
|
401
|
-
const ego = await call(`/semantic/graph/ego?entity=${enc(name)}&depth=${state.entityDepth}&limit=40`);
|
|
402
|
-
const attrs = await call(`/semantic/graph/entity-attrs?entity=${enc(name)}`).catch(() => null);
|
|
403
|
-
const nodes = Array.isArray(ego?.nodes) ? ego.nodes : [];
|
|
404
|
-
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
405
|
-
const root = ego?.root ?? {};
|
|
406
|
-
const relations = (Array.isArray(ego?.edges) ? ego.edges : []).slice(0, 30).map((edge) => {
|
|
407
|
-
const from = byId.get(edge.from_entity);
|
|
408
|
-
const to = byId.get(edge.to_entity);
|
|
409
|
-
const other = edge.from_entity === root.id ? edge.to_entity : edge.from_entity;
|
|
410
|
-
const otherNode = byId.get(other);
|
|
411
|
-
return {
|
|
412
|
-
title: `${clip(from?.name ?? "—", 16)} → ${clip(to?.name ?? "—", 16)}`,
|
|
413
|
-
subtitle: clip(edge.relation_type ?? "关系", 24),
|
|
414
|
-
tone: "info",
|
|
415
|
-
...(otherNode === undefined
|
|
416
|
-
? {}
|
|
417
|
-
: { action: { id: "open-entity", args: { name: String(otherNode.name ?? "") } } }),
|
|
418
|
-
};
|
|
419
|
-
});
|
|
420
|
-
const memories = new Map();
|
|
421
|
-
for (const edge of Array.isArray(ego?.edges) ? ego.edges : []) {
|
|
422
|
-
if (typeof edge.memory_id === "string" && edge.memory_id !== "") {
|
|
423
|
-
const memo = cache.get(edge.memory_id);
|
|
424
|
-
memories.set(edge.memory_id, {
|
|
425
|
-
title: clip(memo?.title ?? `记忆 ${edge.memory_id.slice(0, 8)}`, 40),
|
|
426
|
-
subtitle: memo === undefined ? "未在本地缓存" : typeLabel(memo.type),
|
|
427
|
-
action: { id: "open", args: { id: edge.memory_id } },
|
|
428
|
-
});
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
state.entityRelationCount = relations.length;
|
|
432
|
-
state.entityMemoryCount = memories.size;
|
|
433
|
-
return {
|
|
434
|
-
entityName: clip(root.name ?? name, 40),
|
|
435
|
-
entityType: clip(entityTypeLabel(root.type ?? "实体"), 20),
|
|
436
|
-
entityMentionText: `被提到 ${String(root.mention_count ?? 0)} 次 · 关系 ${relations.length} 条`,
|
|
437
|
-
entityDepth: String(state.entityDepth),
|
|
438
|
-
entityAttrs: (Array.isArray(attrs?.attrs) ? attrs.attrs : []).slice(0, 24).map((attr) => ({
|
|
439
|
-
label: clip(attr.key ?? "—", 30),
|
|
440
|
-
value: clip(String(attr.value ?? "—"), 60),
|
|
441
|
-
})),
|
|
442
|
-
entityRelations: relations,
|
|
443
|
-
entityMemories: [...memories.values()].slice(0, 20),
|
|
444
|
-
entityRelationCount: relations.length,
|
|
445
|
-
entityMemoryCount: memories.size,
|
|
446
|
-
entityHint: "点关系可以跳到相邻实体;点记忆可以跳到详情。",
|
|
447
|
-
};
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
async function loadSettings() {
|
|
451
|
-
const [features, mode] = await Promise.all([call("/features"), call("/mode")]);
|
|
452
|
-
const effective = features?.effective ?? {};
|
|
453
|
-
state.flags = effective;
|
|
454
|
-
state.flagOverrides = features?.overrides ?? {};
|
|
455
|
-
state.pendingRestart = Array.isArray(features?.pendingRestart) ? features.pendingRestart : [];
|
|
456
|
-
const changed = Object.keys(state.flagOverrides).length;
|
|
457
|
-
const pending = state.pendingRestart.length;
|
|
458
|
-
// 上游不断加旗标:没给中文名的布尔项仍要出现在面板上,否则新功能在手机上
|
|
459
|
-
// 静默消失。数值/字符串键(模型名、预算)不放这里——它们需要输入框,不是开关。
|
|
460
|
-
state.extraRows = Object.keys(effective)
|
|
461
|
-
.filter((key) => !FLAG_LABELS.has(key) && typeof effective[key] === "boolean")
|
|
462
|
-
.sort()
|
|
463
|
-
.map((key) => ({
|
|
464
|
-
title: key,
|
|
465
|
-
subtitle: effective[key] === true ? "已开启 · 点一下关闭" : "已关闭 · 点一下开启",
|
|
466
|
-
icon: effective[key] === true ? "toggle-on" : "toggle-off",
|
|
467
|
-
action: { id: "flag-set", args: { key } },
|
|
468
|
-
}));
|
|
469
|
-
const payload = {
|
|
470
|
-
extraRows: state.extraRows,
|
|
471
|
-
settingsView: state.settingsView,
|
|
472
|
-
sPanel: state.sPanel,
|
|
473
|
-
flagGroup: state.flagGroup,
|
|
474
|
-
flags: nestFlags(effective),
|
|
475
|
-
mode: String(mode?.mode ?? "standard"),
|
|
476
|
-
flagsMeta: `已改 ${changed} 项 · 待重启生效 ${pending} 项(改动即时写回 mneme,重启引擎后彻底生效)`,
|
|
477
|
-
};
|
|
478
|
-
if (state.sPanel === "menu") return payload;
|
|
479
|
-
if (state.sPanel === "workbench") {
|
|
480
|
-
const [deposited, archived] = await Promise.all([
|
|
481
|
-
call("/list?deposited=only&limit=8&order=chrono").catch(() => ({})),
|
|
482
|
-
call("/list?archived=only&limit=5&order=chrono").catch(() => ({})),
|
|
483
|
-
]);
|
|
484
|
-
const depItems = deposited?.items ?? [];
|
|
485
|
-
const arcItems = archived?.items ?? [];
|
|
486
|
-
for (const row of [...depItems, ...arcItems]) cache.set(row.id, row);
|
|
487
|
-
state.wbDeposited = depItems.map((row) => ({
|
|
488
|
-
title: clip(row.title, 50),
|
|
489
|
-
subtitle: `${typeLabel(row.type)} · ${fmtTime(row.updated_at)}`,
|
|
490
|
-
action: { id: "open", args: { id: row.id } },
|
|
491
|
-
}));
|
|
492
|
-
state.wbArchived = arcItems.map((row) => ({
|
|
493
|
-
title: clip(row.title, 50),
|
|
494
|
-
subtitle: fmtTime(row.updated_at),
|
|
495
|
-
trailing: "点按恢复",
|
|
496
|
-
action: { id: "wb-restore", args: { id: row.id } },
|
|
497
|
-
}));
|
|
498
|
-
Object.assign(payload, {
|
|
499
|
-
wbKv: [
|
|
500
|
-
{ label: "已沉淀", value: `${String(deposited?.total ?? depItems.length)} 条` },
|
|
501
|
-
{ label: "已归档", value: `${String(archived?.total ?? arcItems.length)} 条` },
|
|
502
|
-
],
|
|
503
|
-
wbDeposited: state.wbDeposited,
|
|
504
|
-
wbArchived: state.wbArchived,
|
|
505
|
-
});
|
|
506
|
-
} else if (state.sPanel === "commands") {
|
|
507
|
-
const res = await call("/commands").catch(() => ({ commands: [] }));
|
|
508
|
-
state.commands = Array.isArray(res?.commands) ? res.commands : [];
|
|
509
|
-
state.cmdCount = state.commands.length;
|
|
510
|
-
Object.assign(payload, {
|
|
511
|
-
cmdRows: state.commands.map((cmd) => ({
|
|
512
|
-
title: clip(cmd.name ?? cmd.id ?? "—", 30),
|
|
513
|
-
subtitle: clip(cmd.description ?? "", 60),
|
|
514
|
-
})),
|
|
515
|
-
cmdCount: state.cmdCount,
|
|
516
|
-
sCmdPick: state.sCmdPick,
|
|
517
|
-
sCmdName: state.sCmdName,
|
|
518
|
-
sCmdDesc: state.sCmdDesc,
|
|
519
|
-
sCmdIns: state.sCmdIns,
|
|
520
|
-
});
|
|
521
|
-
} else if (state.sPanel === "vector") {
|
|
522
|
-
const res = await call("/vector-config").catch(() => null);
|
|
523
|
-
// 读到才算 loaded:读失败时下面会落成默认值,不能再拿它整体覆盖
|
|
524
|
-
// 服务端配置(评审 #2)。
|
|
525
|
-
state.vectorLoaded = res !== null && typeof res === "object" && res.config !== null && typeof res.config === "object";
|
|
526
|
-
const config = res?.config ?? {};
|
|
527
|
-
state.vector = {
|
|
528
|
-
enabled: config.enabled === true,
|
|
529
|
-
baseUrl: String(config.baseUrl ?? ""),
|
|
530
|
-
model: String(config.model ?? ""),
|
|
531
|
-
};
|
|
532
|
-
const keySet = typeof config.apiKey === "string" && config.apiKey !== "";
|
|
533
|
-
Object.assign(payload, {
|
|
534
|
-
sVecEnabled: state.vector.enabled,
|
|
535
|
-
sVecUrl: state.vector.baseUrl,
|
|
536
|
-
sVecModel: state.vector.model,
|
|
537
|
-
vectorKv: [
|
|
538
|
-
{
|
|
539
|
-
label: "状态",
|
|
540
|
-
value: state.vector.enabled ? "已启用" : "未启用",
|
|
541
|
-
tone: state.vector.enabled ? "good" : "muted",
|
|
542
|
-
},
|
|
543
|
-
{ label: "接口", value: state.vector.baseUrl === "" ? "未配置" : clip(state.vector.baseUrl, 60) },
|
|
544
|
-
{ label: "模型", value: state.vector.model === "" ? "未配置" : state.vector.model },
|
|
545
|
-
{ label: "密钥", value: keySet ? "已配置(面板不显示)" : "未配置" },
|
|
546
|
-
],
|
|
547
|
-
vectorNote: "搜索不再只认字面:接入任意 OpenAI 兼容的 embedding 接口后,意思相近的记忆也能被召回。",
|
|
548
|
-
});
|
|
549
|
-
} else if (state.sPanel === "external") {
|
|
550
|
-
// 读一次真实配置,否则面板永远拿默认值渲染,保存时又把默认值写回去,
|
|
551
|
-
// 把用户已经在 Web 面板里配好的端口/地址覆盖掉(评审 #6)。
|
|
552
|
-
// 必须带 token=omit:不带的话这条 GET 会顺手生成并落库一个 Bearer。
|
|
553
|
-
const res = await call("/external-api?token=omit").catch(() => null);
|
|
554
|
-
state.externalLoaded = res !== null && typeof res === "object" && res.config !== null && typeof res.config === "object";
|
|
555
|
-
const config = res?.config ?? {};
|
|
556
|
-
state.external = {
|
|
557
|
-
enabled: config.enabled === true,
|
|
558
|
-
port: num(config.port, 8790),
|
|
559
|
-
host: typeof config.host === "string" && config.host !== "" ? config.host : "127.0.0.1",
|
|
560
|
-
};
|
|
561
|
-
Object.assign(payload, {
|
|
562
|
-
sExtEnabled: state.external.enabled,
|
|
563
|
-
sExtPort: String(state.external.port),
|
|
564
|
-
sExtHost: state.external.host,
|
|
565
|
-
});
|
|
566
|
-
} else if (state.sPanel === "facts") {
|
|
567
|
-
// 「Agent 眼中的你」:一次请求喂饱这张卡(facts / journal / runs)。flag 关着
|
|
568
|
-
// 也照读——历史上开过又关掉的库里事实还在,得让人看得见。
|
|
569
|
-
const query = state.factsAgent === "" ? "" : `?agent=${encodeURIComponent(state.factsAgent)}`;
|
|
570
|
-
const facts = await call(`/profile-facts${query}`).catch(() => ({}));
|
|
571
|
-
state.factsCards = Array.isArray(facts?.cards) ? facts.cards.map(String) : [];
|
|
572
|
-
const rows = Array.isArray(facts?.facts) ? facts.facts : [];
|
|
573
|
-
state.factsItems = rows;
|
|
574
|
-
const active = rows.filter((fact) => fact.status !== "archived");
|
|
575
|
-
state.factsRows = rows.slice(0, 20).map((fact) => ({
|
|
576
|
-
title: clip(String(fact.content ?? ""), 60),
|
|
577
|
-
subtitle:
|
|
578
|
-
fact.status === "archived"
|
|
579
|
-
? `已归档 · ${fmtTime(fact.updated_at)}`
|
|
580
|
-
: `更新于 ${fmtTime(fact.updated_at)}`,
|
|
581
|
-
trailing: fact.status === "archived" ? "已归档" : "点一下修改",
|
|
582
|
-
action: { id: "facts-edit", args: { id: String(fact.id ?? "") } },
|
|
583
|
-
}));
|
|
584
|
-
const runs = Array.isArray(facts?.runs) ? facts.runs : [];
|
|
585
|
-
const last = runs[0];
|
|
586
|
-
state.factsRunText =
|
|
587
|
-
last === undefined
|
|
588
|
-
? "还没跑过维护轮。点「立即更新」会让模型读最近的记忆补事实。"
|
|
589
|
-
: `最近一轮:${String(last.status ?? "?")} · 改 ${String(last.ops_applied ?? 0)} 条 · 读 ${String(last.input_count ?? 0)} 条记忆 · ${fmtTime(last.started_at)}`;
|
|
590
|
-
Object.assign(payload, {
|
|
591
|
-
factsAgent: state.factsAgent,
|
|
592
|
-
factsCardOptions: [
|
|
593
|
-
{ label: "默认", value: "" },
|
|
594
|
-
// 宿主给的 cards 里常含空串(默认卡),别再和「默认」重复一次。
|
|
595
|
-
...state.factsCards.filter((key) => key !== "").map((key) => ({ label: key, value: key })),
|
|
596
|
-
],
|
|
597
|
-
factsRows: state.factsRows,
|
|
598
|
-
factsRunText: state.factsRunText,
|
|
599
|
-
sFactNew: state.form.factNew ?? "",
|
|
600
|
-
sFactEdit: state.factEdit?.content ?? "",
|
|
601
|
-
factsEditHint:
|
|
602
|
-
state.factEdit === null
|
|
603
|
-
? ""
|
|
604
|
-
: `正在修改:${clip(String(state.factEdit.content ?? ""), 40)}`,
|
|
605
|
-
});
|
|
606
|
-
if (active.length === 0) state.factsRows.push({ title: "还没有事实", subtitle: "点下面的「立即更新」让模型生成一批。" });
|
|
607
|
-
} else if (state.sPanel === "choro") {
|
|
608
|
-
// 地志是「按仓库」的:先列已登记仓库,再列该仓库的模块骨架页。
|
|
609
|
-
const repos = await call("/choro/repos").catch(() => ({}));
|
|
610
|
-
state.choroRepos = (Array.isArray(repos?.repos) ? repos.repos : []).map((repo) => ({
|
|
611
|
-
id: String(repo.id ?? ""),
|
|
612
|
-
name: clip(String(repo.name ?? repo.root_path ?? repo.id ?? ""), 40),
|
|
613
|
-
}));
|
|
614
|
-
if (state.choroRepo === "" && state.choroRepos.length > 0) state.choroRepo = state.choroRepos[0].id;
|
|
615
|
-
const known = state.choroRepos.some((repo) => repo.id === state.choroRepo);
|
|
616
|
-
if (!known) state.choroRepo = state.choroRepos[0]?.id ?? "";
|
|
617
|
-
let note = "先在地志里登记仓库(命令行 dsh-mneme-choro <repo> --apply)。";
|
|
618
|
-
let rows = [];
|
|
619
|
-
if (state.choroRepo !== "") {
|
|
620
|
-
const map = await call(`/choro/modules?repo=${encodeURIComponent(state.choroRepo)}`).catch(() => ({}));
|
|
621
|
-
const modules = Array.isArray(map?.modules) ? map.modules : [];
|
|
622
|
-
rows = modules.slice(0, 30).map((item) => ({
|
|
623
|
-
title: clip(String(item.title ?? item.slug ?? ""), 40),
|
|
624
|
-
subtitle: `${String(item.file_count ?? 0)} 个文件 · ${String(item.page_status ?? "无页")}`,
|
|
625
|
-
trailing: "看页",
|
|
626
|
-
action: { id: "choro-page", args: { slug: String(item.slug ?? "") } },
|
|
627
|
-
}));
|
|
628
|
-
const repo = state.choroRepos.find((item) => item.id === state.choroRepo);
|
|
629
|
-
const baseline = map?.repo?.gitBaseline;
|
|
630
|
-
note =
|
|
631
|
-
modules.length === 0
|
|
632
|
-
? "这个仓库还没有骨架页:点下面的「重建骨架」生成一次。"
|
|
633
|
-
: `${modules.length} 个模块${baseline ? ` · 基线 ${String(baseline).slice(0, 7)}` : ""} · ${repo?.name ?? ""}`;
|
|
634
|
-
}
|
|
635
|
-
state.choroRows = rows;
|
|
636
|
-
state.choroNote = note;
|
|
637
|
-
Object.assign(payload, {
|
|
638
|
-
choroRepo: state.choroRepo,
|
|
639
|
-
choroRepoOptions:
|
|
640
|
-
state.choroRepos.length === 0
|
|
641
|
-
? [{ label: "还没有登记任何仓库", value: "" }]
|
|
642
|
-
: state.choroRepos.map((repo) => ({ label: repo.name, value: repo.id })),
|
|
643
|
-
choroRows: state.choroRows,
|
|
644
|
-
choroNote: state.choroNote,
|
|
645
|
-
choroPageText: state.choroPageText,
|
|
646
|
-
});
|
|
647
|
-
} else if (state.sPanel === "help") {
|
|
648
|
-
const info = await call("/info").catch(() => ({}));
|
|
649
|
-
Object.assign(payload, {
|
|
650
|
-
helpKv: [
|
|
651
|
-
{ label: "插件", value: `@modusensus/dsh-mneme ${String(info?.version ?? "—")}` },
|
|
652
|
-
{ label: "记忆库", value: "本机 SQLite(与本机 Web 面板同一份,不跨设备同步)" },
|
|
653
|
-
{ label: "面板", value: "dsh-go 原生面板" },
|
|
654
|
-
],
|
|
655
|
-
versionNote: state.versionNote,
|
|
656
|
-
});
|
|
657
|
-
}
|
|
658
|
-
return payload;
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
async function loadProfile() {
|
|
662
|
-
if (state.profileTab === "persona") {
|
|
663
|
-
const res = await call("/persona");
|
|
664
|
-
const persona = res?.persona ?? {};
|
|
665
|
-
const preview = res?.preview ?? {};
|
|
666
|
-
return {
|
|
667
|
-
personaText: clip(
|
|
668
|
-
JSON.stringify({ default: persona.default ?? {}, byAgent: persona.byAgent ?? {} }, null, 2),
|
|
669
|
-
700,
|
|
670
|
-
),
|
|
671
|
-
previewItems: [
|
|
672
|
-
{ label: "生效来源", value: String(preview.source ?? "—") },
|
|
673
|
-
{ label: "注入内容", value: clip(preview.text === "" ? "(空)" : preview.text, 300) },
|
|
674
|
-
],
|
|
675
|
-
};
|
|
676
|
-
}
|
|
677
|
-
if (state.profileTab === "rules") {
|
|
678
|
-
const res = await call("/rules");
|
|
679
|
-
return { editText: clip(JSON.stringify(res?.rules ?? [], null, 2), 2000) };
|
|
680
|
-
}
|
|
681
|
-
const res = await call("/profile");
|
|
682
|
-
return { editText: clip(res?.profile ?? "", 2000), extractText: state.extractText };
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
async function loadConflict() {
|
|
686
|
-
const res = await call("/conflicts");
|
|
687
|
-
const items = Array.isArray(res?.items) ? res.items : [];
|
|
688
|
-
state.conflictCount = num(res?.total, items.length);
|
|
689
|
-
const rows = items.slice(0, 20).map((item) => ({
|
|
690
|
-
title: clip(item.a?.title ?? item.b?.title ?? item.id ?? "—", 40),
|
|
691
|
-
subtitle: `A:${clip(item.a?.title ?? "—", 20)} · B:${clip(item.b?.title ?? "—", 20)}`,
|
|
692
|
-
tone: "warn",
|
|
693
|
-
action: { id: "conflict-pick-row", args: { id: String(item.id ?? "") } },
|
|
694
|
-
}));
|
|
695
|
-
const selected = items.find((item) => item.id === state.conflictId) ?? null;
|
|
696
|
-
state.conflictA = selected?.a ?? null;
|
|
697
|
-
state.conflictB = selected?.b ?? null;
|
|
698
|
-
state.conflictReady = selected !== null;
|
|
699
|
-
const side = (memo) =>
|
|
700
|
-
memo === null
|
|
701
|
-
? []
|
|
702
|
-
: [
|
|
703
|
-
{ label: "类型", value: typeLabel(memo.type) },
|
|
704
|
-
{ label: "重要度", value: `★${memo.importance ?? 1}` },
|
|
705
|
-
{ label: "更新", value: fmtTime(memo.updated_at ?? memo.created_at) },
|
|
706
|
-
];
|
|
707
|
-
return {
|
|
708
|
-
conflictRows: rows,
|
|
709
|
-
conflictCount: state.conflictCount,
|
|
710
|
-
conflictReady: state.conflictReady,
|
|
711
|
-
conflictPick: state.conflictPick,
|
|
712
|
-
conflictATitle: clip(state.conflictA?.title ?? "", 120),
|
|
713
|
-
conflictAMeta: side(state.conflictA),
|
|
714
|
-
conflictAContent: clip(state.conflictA?.content ?? "", 800),
|
|
715
|
-
conflictBTitle: clip(state.conflictB?.title ?? "", 120),
|
|
716
|
-
conflictBMeta: side(state.conflictB),
|
|
717
|
-
conflictBContent: clip(state.conflictB?.content ?? "", 800),
|
|
718
|
-
pendingConflicts: state.conflictCount,
|
|
719
|
-
};
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
async function loadStatus() {
|
|
723
|
-
const [info, health, dream, inject, stats, conflicts, list, entities, mode, preview, audit, features, recent] =
|
|
724
|
-
await Promise.all([
|
|
725
|
-
call("/info").catch(() => ({})),
|
|
726
|
-
call("/health").catch(() => ({})),
|
|
727
|
-
call("/dream-status").catch(() => ({})),
|
|
728
|
-
call("/inject-status").catch(() => ({})),
|
|
729
|
-
call("/recall-stats?window=30").catch(() => ({})),
|
|
730
|
-
call("/conflicts").catch(() => ({})),
|
|
731
|
-
call("/list?limit=1").catch(() => ({})),
|
|
732
|
-
call("/entities?limit=1").catch(() => ({})),
|
|
733
|
-
call("/mode").catch(() => ({})),
|
|
734
|
-
call("/inject-preview").catch(() => ({})),
|
|
735
|
-
call("/semantic/llm-audit/stats?days=28").catch(() => ({})),
|
|
736
|
-
call("/features").catch(() => ({})),
|
|
737
|
-
call("/list?limit=200&order=chrono").catch(() => ({})),
|
|
738
|
-
]);
|
|
739
|
-
const run = dream?.lastRun ?? {};
|
|
740
|
-
const mirror = health?.mirror ?? {};
|
|
741
|
-
const snapshot = preview?.snapshot ?? null;
|
|
742
|
-
state.injectPreviewText =
|
|
743
|
-
snapshot === null
|
|
744
|
-
? "还没有渲染过注入预览:下一轮对话开始时 mneme 会写一份快照。"
|
|
745
|
-
: clip(typeof snapshot === "string" ? snapshot : JSON.stringify(snapshot), 500);
|
|
746
|
-
const byDay = Array.isArray(audit?.by_day) ? audit.by_day : [];
|
|
747
|
-
state.tokenCategories = byDay.map((day) => fmtDay(day.day));
|
|
748
|
-
state.tokenSeries = byDay.length === 0 ? [] : [{ name: "调用", values: byDay.map((day) => num(day.count, 0)) }];
|
|
749
|
-
state.tokenKv = [
|
|
750
|
-
{ label: "调用次数", value: `${String(audit?.total_calls ?? 0)} 次` },
|
|
751
|
-
{ label: "Token", value: String(audit?.total_tokens ?? 0) },
|
|
752
|
-
{ label: "估算成本", value: `$${Number(audit?.total_cost_usd ?? 0).toFixed(4)}` },
|
|
753
|
-
];
|
|
754
|
-
const heatEnabled = (features?.effective ?? {}).heatEnabled === true;
|
|
755
|
-
const heated = (recent?.items ?? []).filter((row) => typeof row.heat === "number");
|
|
756
|
-
if (heatEnabled && heated.length > 0) {
|
|
757
|
-
const hot = heated.filter((row) => row.heat >= 0.7).length;
|
|
758
|
-
const warm = heated.filter((row) => row.heat >= 0.3 && row.heat < 0.7).length;
|
|
759
|
-
state.heatSeries = [{ name: "条数", values: [hot, warm, heated.length - hot - warm] }];
|
|
760
|
-
state.heatNote = `统计最近 ${heated.length} 条记忆的热度。`;
|
|
761
|
-
} else {
|
|
762
|
-
state.heatSeries = [];
|
|
763
|
-
state.heatNote = "mneme 没有开启“热度衰减”,所以看不到热度分布。";
|
|
764
|
-
}
|
|
765
|
-
state.pendingConflicts = num(conflicts?.total, 0);
|
|
766
|
-
return {
|
|
767
|
-
// metric cards live on 状态, mirroring mneme's own Status page
|
|
768
|
-
totalText: `${String(list?.total ?? 0)} 条`,
|
|
769
|
-
injectedText: `${String(stats?.injection?.injectedCount ?? 0)} 次`,
|
|
770
|
-
slotRateText: `${String(Math.round((stats?.injection?.slotFillRate ?? 0) * 1000) / 10)}%`,
|
|
771
|
-
conflictsText: `${String(conflicts?.total ?? 0)} 条`,
|
|
772
|
-
statusKv: [
|
|
773
|
-
{ label: "mneme 版本", value: String(info?.version ?? "—") },
|
|
774
|
-
{ label: "记忆", value: `${String(list?.total ?? 0)} 条` },
|
|
775
|
-
{ label: "实体", value: `${String(entities?.total ?? 0)} 个` },
|
|
776
|
-
{ label: "运行模式", value: String(mode?.mode ?? "—") },
|
|
777
|
-
{ label: "镜像", value: String(mirror.status ?? "—"), tone: mirror.status === "ok" ? "good" : "warn" },
|
|
778
|
-
{ label: "上次成功", value: fmtTime(mirror.success_at) },
|
|
779
|
-
],
|
|
780
|
-
injectKv: [
|
|
781
|
-
{
|
|
782
|
-
label: "自动注入",
|
|
783
|
-
value: inject?.autoInject ? "已开启" : "已关闭",
|
|
784
|
-
tone: inject?.autoInject ? "good" : "muted",
|
|
785
|
-
},
|
|
786
|
-
{ label: "注入抑制", value: inject?.suppressed ? "是" : "否" },
|
|
787
|
-
{ label: "会话预设", value: String(inject?.agentPreset ?? "—") },
|
|
788
|
-
{ label: "近 30 天注入", value: `${String(stats?.injection?.injectedCount ?? 0)} 次` },
|
|
789
|
-
{ label: "命中率", value: `${String(Math.round((stats?.injection?.slotFillRate ?? 0) * 1000) / 10)}%` },
|
|
790
|
-
],
|
|
791
|
-
topRows: (stats?.topRecalled ?? []).slice(0, 5).map((row) => ({
|
|
792
|
-
title: clip(row.title, 40),
|
|
793
|
-
subtitle: typeLabel(row.type),
|
|
794
|
-
trailing: `${row.count} 次`,
|
|
795
|
-
})),
|
|
796
|
-
dreamKv: [
|
|
797
|
-
{ label: "上次巩固", value: fmtTime(dream?.lastConsolidation) },
|
|
798
|
-
{ label: "上次运行", value: `${fmtTime(run.created_at)} ${run.status ?? "—"}` },
|
|
799
|
-
{
|
|
800
|
-
label: "待确认",
|
|
801
|
-
value: `${String(conflicts?.total ?? 0)} 条`,
|
|
802
|
-
tone: num(conflicts?.total, 0) > 0 ? "warn" : "good",
|
|
803
|
-
},
|
|
804
|
-
],
|
|
805
|
-
runRows: (dream?.runs ?? []).slice(0, 5).map((row) => ({
|
|
806
|
-
title: `${fmtTime(row.created_at)} · ${String(row.status ?? "—")}`,
|
|
807
|
-
subtitle: clip(row.summary ?? row.mode ?? "", 40),
|
|
808
|
-
})),
|
|
809
|
-
injectPreviewText: state.injectPreviewText,
|
|
810
|
-
tokenCategories: state.tokenCategories,
|
|
811
|
-
tokenSeries: state.tokenSeries,
|
|
812
|
-
tokenKv: state.tokenKv,
|
|
813
|
-
heatSeries: state.heatSeries,
|
|
814
|
-
heatNote: state.heatNote,
|
|
815
|
-
reclaimText: state.reclaimText,
|
|
816
|
-
reclaimConfirm: state.reclaimConfirm,
|
|
817
|
-
pendingConflicts: state.pendingConflicts,
|
|
818
|
-
};
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
// The app's `tabs` bar switches locally, so the payload always carries every
|
|
822
|
-
// section (a binding whose key is missing renders as 「—」). That does not mean
|
|
823
|
-
// every section must be re-read after every tap: 实体 / 设置 / 状态 only change
|
|
824
|
-
// while their own page is open, so a tap on the list used to cost four reads
|
|
825
|
-
// (评审 #14). Load the active page's section, keep the rest from the previous
|
|
826
|
-
// payload; a full read happens on mount.
|
|
827
|
-
async function load(options = {}) {
|
|
828
|
-
const full = options.full === true;
|
|
829
|
-
const payload = {
|
|
830
|
-
page: state.page,
|
|
831
|
-
profileTab: state.profileTab,
|
|
832
|
-
notice: state.notice,
|
|
833
|
-
err: state.err,
|
|
834
|
-
};
|
|
835
|
-
const jobs = [];
|
|
836
|
-
const merge = (promise) => jobs.push(promise.then((part) => Object.assign(payload, part)));
|
|
837
|
-
const page = state.page;
|
|
838
|
-
const memoryPage = !["entity", "settings", "profile"].includes(page);
|
|
839
|
-
if (full || memoryPage) {
|
|
840
|
-
const memory = page === "detail" ? loadDetail() : page === "editor" ? loadEditor() : loadMemory();
|
|
841
|
-
merge(Promise.resolve(memory));
|
|
842
|
-
}
|
|
843
|
-
const entityPage = page === "entity";
|
|
844
|
-
// 实体搜索要重读实体清单;但搜索后 state.page 仍是 "list"(视图里实体
|
|
845
|
-
// 区就是列表),旧条件只看页面,于是搜索完推的仍是搜索前那份(评审 #4)。
|
|
846
|
-
if (full || entityPage || state.entityDirty) merge(entityPage ? loadEntityDetail() : loadEntity());
|
|
847
|
-
state.entityDirty = false;
|
|
848
|
-
if (full || page === "settings" || page === "profile" || state.settingsDirty) {
|
|
849
|
-
merge(loadSettings());
|
|
850
|
-
merge(loadStatus());
|
|
851
|
-
}
|
|
852
|
-
state.settingsDirty = false;
|
|
853
|
-
if (full || (memoryPage && page === "conflict")) merge(loadConflict());
|
|
854
|
-
if (full || page === "profile") merge(loadProfile());
|
|
855
|
-
const settled = await Promise.allSettled(jobs);
|
|
856
|
-
const failed = settled.find((item) => item.status === "rejected");
|
|
857
|
-
if (failed !== undefined) payload.err = errorText(failed.reason);
|
|
858
|
-
// 未重新读取的块沿用上一次的完整结果;刷新过的那块覆盖上去。
|
|
859
|
-
const merged = lastPayload === null ? payload : { ...lastPayload, ...payload };
|
|
860
|
-
lastPayload = merged;
|
|
861
|
-
return merged;
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
/** Re-run the loaders and push the result into every live panel feed. */
|
|
865
|
-
async function broadcast() {
|
|
866
|
-
if (emitters.size === 0) return;
|
|
867
|
-
const payload = await load();
|
|
868
|
-
for (const emit of [...emitters]) {
|
|
869
|
-
try {
|
|
870
|
-
emit(payload);
|
|
871
|
-
} catch {
|
|
872
|
-
// a closing panel detaches its emitter; nothing to do
|
|
873
|
-
}
|
|
874
|
-
}
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
// ---------------------------------------------------------------- actions
|
|
878
|
-
function goMemory(page) {
|
|
879
|
-
state.page = page;
|
|
880
|
-
state.err = "";
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
function nextValue(list, current, value) {
|
|
884
|
-
const picked = typeof value === "string" && list.includes(value) ? value : null;
|
|
885
|
-
if (picked !== null) return picked;
|
|
886
|
-
const index = list.indexOf(current);
|
|
887
|
-
return list[(index + 1) % list.length];
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
async function applyArchived(archived, event, scope) {
|
|
891
|
-
const memo = state.current;
|
|
892
|
-
if (memo === null) return { toast: "先选一条记忆" };
|
|
893
|
-
// 归档/恢复改的是「正在看」的那一条,参数只能从状态里拿;状态是模块级
|
|
894
|
-
// 共享的,两个面板各开一条时后开的会盖掉前一个的 current。与删除同一条
|
|
895
|
-
// 规矩:只动本面板自己打开的那条,对不上就拒绝(评审 #10)。
|
|
896
|
-
const mine = openedBy.get(scopeKey(scope));
|
|
897
|
-
if (mine === undefined || mine !== memo.id) {
|
|
898
|
-
return { toast: "这条不是当前打开的记忆,已取消" };
|
|
899
|
-
}
|
|
900
|
-
try {
|
|
901
|
-
const res = await call("/update", { method: "POST", body: { id: mine, archived } });
|
|
902
|
-
if (res?.memory !== null && typeof res.memory === "object") {
|
|
903
|
-
state.current = res.memory;
|
|
904
|
-
openedBy.set(scopeKey(scope), String(res.memory.id ?? mine));
|
|
905
|
-
}
|
|
906
|
-
notice(archived ? "已归档。" : "已恢复。");
|
|
907
|
-
} catch (error) {
|
|
908
|
-
state.err = errorText(error);
|
|
909
|
-
}
|
|
910
|
-
return {};
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
// 编辑页的比对基准:点「编辑」时看到的那一份快照。冲突后必须用最新的
|
|
914
|
-
// 记忆重建它,否则下一次保存会因为 base 为 null 而整份提交、直接跳过版本
|
|
915
|
-
// 检查(评审 #11)。
|
|
916
|
-
function editBaseOf(memo) {
|
|
917
|
-
return {
|
|
918
|
-
title: String(memo.title ?? ""),
|
|
919
|
-
content: String(memo.content ?? ""),
|
|
920
|
-
importance: num(memo.importance, 3),
|
|
921
|
-
tags: joinTags(memo.tags),
|
|
922
|
-
agentScope: typeof memo.agent_scope === "string" ? memo.agent_scope : "",
|
|
923
|
-
workspaceScope: typeof memo.workspace_scope === "string" ? memo.workspace_scope : "",
|
|
924
|
-
updatedAt: typeof memo.updated_at === "string" ? memo.updated_at : "",
|
|
925
|
-
};
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
// 每次打开都回宿主重读,而不是先看 cache:cache 是列表加载时写下的,
|
|
929
|
-
// 可能已经几分钟没更新;编辑页会拿它预填表单,随后整份提交回 /update,
|
|
930
|
-
// 用陈旧内容覆盖掉工具或 Web 面板刚写进去的新值(评审 #4)。
|
|
931
|
-
// /list 支持按 id 取单条(归档行也取得到),不再用 limit=500 扫列表再拿缓存
|
|
932
|
-
// 兜底:500 条之外或已归档的条目根本扫不到,兜底等于拿旧值去比对 updated_at
|
|
933
|
-
// (评审 #12)。网络失败就让它抛,调用处会报错,绝不悄悄退回缓存。
|
|
934
|
-
async function openMemoryById(id) {
|
|
935
|
-
const res = await call(`/list?id=${encodeURIComponent(String(id))}`);
|
|
936
|
-
const row = (res?.items ?? [])[0];
|
|
937
|
-
if (row === undefined) {
|
|
938
|
-
cache.delete(id);
|
|
939
|
-
return undefined;
|
|
940
|
-
}
|
|
941
|
-
cache.set(row.id, row);
|
|
942
|
-
return row;
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
const actions = {
|
|
946
|
-
"switch-group"(scope, event) {
|
|
947
|
-
resetNotice();
|
|
948
|
-
const keys = ["basic", "record", "write", "workflow", "retrieval", "scope", "extra"];
|
|
949
|
-
state.flagGroup = nextValue(keys, state.flagGroup, valueOf(event, ["group", "value", "k"], ""));
|
|
950
|
-
state.page = "settings";
|
|
951
|
-
return {};
|
|
952
|
-
},
|
|
953
|
-
|
|
954
|
-
"switch-panel"(scope, event) {
|
|
955
|
-
resetNotice();
|
|
956
|
-
const keys = ["menu", "workbench", "commands", "vector", "external", "help", "facts", "choro"];
|
|
957
|
-
state.sPanel = nextValue(keys, state.sPanel, valueOf(event, ["value", "panel", "k"], ""));
|
|
958
|
-
state.page = "settings";
|
|
959
|
-
return {};
|
|
960
|
-
},
|
|
961
|
-
|
|
962
|
-
"open-panel"(scope, event) {
|
|
963
|
-
resetNotice();
|
|
964
|
-
// 功能开关已经铺在设置页正文里,不再有 flags 子页面(#dsh-go 面板对齐电脑端分组)
|
|
965
|
-
const keys = ["workbench", "commands", "vector", "external", "help", "facts", "choro"];
|
|
966
|
-
const wanted = String(valueOf(event, ["k", "panel", "value"], "") ?? "");
|
|
967
|
-
state.sPanel = keys.includes(wanted) ? wanted : "menu";
|
|
968
|
-
state.page = "settings";
|
|
969
|
-
// 子页面画在「设置」视图里:从「状态」视图点过来时若不把视图切回去,
|
|
970
|
-
// 看到的还是状态页,什么都没有发生(评审 #14)。
|
|
971
|
-
state.settingsView = "settings";
|
|
972
|
-
return {};
|
|
973
|
-
},
|
|
974
|
-
|
|
975
|
-
"switch-settings-view"(scope, event) {
|
|
976
|
-
resetNotice();
|
|
977
|
-
state.settingsView = nextValue(["settings", "status"], state.settingsView, valueOf(event, ["value"], ""));
|
|
978
|
-
if (state.settingsView === "settings") state.page = "settings";
|
|
979
|
-
return {};
|
|
980
|
-
},
|
|
981
|
-
|
|
982
|
-
"open-profile"(scope, event) {
|
|
983
|
-
resetNotice();
|
|
984
|
-
const wanted = String(valueOf(event, ["k", "tab", "value"], state.profileTab) ?? "");
|
|
985
|
-
state.profileTab = ["profile", "rules", "persona"].includes(wanted) ? wanted : "profile";
|
|
986
|
-
state.page = "profile";
|
|
987
|
-
return {};
|
|
988
|
-
},
|
|
989
|
-
|
|
990
|
-
"open-conflict"() {
|
|
991
|
-
resetNotice();
|
|
992
|
-
state.page = "conflict";
|
|
993
|
-
// 冲突页同样属于「设置」视图(入口在设置页的状态区里),视图停在状态
|
|
994
|
-
// 视图时切不到它(评审 #14)。
|
|
995
|
-
state.settingsView = "settings";
|
|
996
|
-
return {};
|
|
997
|
-
},
|
|
998
|
-
|
|
999
|
-
"clear-filter"() {
|
|
1000
|
-
resetNotice();
|
|
1001
|
-
state.q = "";
|
|
1002
|
-
state.type = "all";
|
|
1003
|
-
state.range = "all";
|
|
1004
|
-
state.minImportance = 0;
|
|
1005
|
-
state.scope = "all";
|
|
1006
|
-
state.semantic = false;
|
|
1007
|
-
state.heatSort = false;
|
|
1008
|
-
state.offset = 0;
|
|
1009
|
-
goMemory("list");
|
|
1010
|
-
return {};
|
|
1011
|
-
},
|
|
1012
|
-
|
|
1013
|
-
"set-range"(scope, event) {
|
|
1014
|
-
resetNotice();
|
|
1015
|
-
state.range = nextValue(["all", "7d", "30d", "90d"], state.range, valueOf(event, ["value"], ""));
|
|
1016
|
-
state.offset = 0;
|
|
1017
|
-
goMemory("list");
|
|
1018
|
-
return {};
|
|
1019
|
-
},
|
|
1020
|
-
|
|
1021
|
-
"set-minimp"(scope, event) {
|
|
1022
|
-
resetNotice();
|
|
1023
|
-
const picked = String(valueOf(event, ["value"], "0") ?? "0");
|
|
1024
|
-
state.minImportance = ["0", "3", "4", "5"].includes(picked) ? Number(picked) : 0;
|
|
1025
|
-
state.offset = 0;
|
|
1026
|
-
goMemory("list");
|
|
1027
|
-
return {};
|
|
1028
|
-
},
|
|
1029
|
-
|
|
1030
|
-
"set-scope"(scope, event) {
|
|
1031
|
-
resetNotice();
|
|
1032
|
-
state.scope = nextValue(["all", "deposited", "archived", "foreign"], state.scope, valueOf(event, ["value"], ""));
|
|
1033
|
-
state.offset = 0;
|
|
1034
|
-
goMemory("list");
|
|
1035
|
-
return {};
|
|
1036
|
-
},
|
|
1037
|
-
|
|
1038
|
-
"set-view"(scope, event) {
|
|
1039
|
-
resetNotice();
|
|
1040
|
-
state.viewMode = nextValue(["flat", "month"], state.viewMode, valueOf(event, ["value"], ""));
|
|
1041
|
-
return {};
|
|
1042
|
-
},
|
|
1043
|
-
|
|
1044
|
-
"toggle-semantic"(scope, event) {
|
|
1045
|
-
resetNotice();
|
|
1046
|
-
const value = event?.value;
|
|
1047
|
-
state.semantic = typeof value === "boolean" ? value : !state.semantic;
|
|
1048
|
-
return {};
|
|
1049
|
-
},
|
|
1050
|
-
|
|
1051
|
-
"toggle-heat"(scope, event) {
|
|
1052
|
-
resetNotice();
|
|
1053
|
-
const value = event?.value;
|
|
1054
|
-
state.heatSort = typeof value === "boolean" ? value : !state.heatSort;
|
|
1055
|
-
return {};
|
|
1056
|
-
},
|
|
1057
|
-
|
|
1058
|
-
async provenance() {
|
|
1059
|
-
resetNotice();
|
|
1060
|
-
const memo = state.current;
|
|
1061
|
-
if (memo === null) return { toast: "先选一条记忆" };
|
|
1062
|
-
try {
|
|
1063
|
-
const res = await call(`/memory/provenance?id=${enc(memo.id)}&limit=5`);
|
|
1064
|
-
const windows = Array.isArray(res?.windows) ? res.windows : [];
|
|
1065
|
-
if (windows.length === 0) {
|
|
1066
|
-
state.provenanceText = "mneme 里没有这条记忆的蒸馏来源记录。";
|
|
1067
|
-
} else {
|
|
1068
|
-
const first = windows[0] ?? {};
|
|
1069
|
-
state.provenanceText = clip(
|
|
1070
|
-
`${windows.length} 个来源窗口 · 最近来自会话 ${String(first.session_id ?? "—").slice(0, 12)}(${fmtTime(first.created_at)})`,
|
|
1071
|
-
300,
|
|
1072
|
-
);
|
|
1073
|
-
}
|
|
1074
|
-
} catch (error) {
|
|
1075
|
-
state.provenanceText = errorText(error).includes("distill-records-disabled")
|
|
1076
|
-
? "mneme 没开「保留蒸馏原文」,所以这条记忆没有可查的来源窗口。"
|
|
1077
|
-
: errorText(error);
|
|
1078
|
-
}
|
|
1079
|
-
return {};
|
|
1080
|
-
},
|
|
1081
|
-
|
|
1082
|
-
refresh() {
|
|
1083
|
-
resetNotice();
|
|
1084
|
-
return {};
|
|
1085
|
-
},
|
|
1086
|
-
|
|
1087
|
-
back() {
|
|
1088
|
-
resetNotice();
|
|
1089
|
-
state.err = "";
|
|
1090
|
-
if (state.page === "profile" || state.page === "conflict") {
|
|
1091
|
-
state.page = "settings";
|
|
1092
|
-
state.sPanel = "menu";
|
|
1093
|
-
} else if (state.page === "entity") {
|
|
1094
|
-
state.page = "list";
|
|
1095
|
-
} else if (state.page === "settings") {
|
|
1096
|
-
if (state.sPanel !== "menu") state.sPanel = "menu";
|
|
1097
|
-
else state.page = "list";
|
|
1098
|
-
} else if (state.page === "detail" || state.page === "editor") {
|
|
1099
|
-
state.page = "list";
|
|
1100
|
-
}
|
|
1101
|
-
return {};
|
|
1102
|
-
},
|
|
1103
|
-
|
|
1104
|
-
search(scope, event) {
|
|
1105
|
-
resetNotice();
|
|
1106
|
-
state.q = String(valueOf(event, ["query", "q", "text"], state.q) ?? "").trim();
|
|
1107
|
-
state.offset = 0;
|
|
1108
|
-
goMemory("list");
|
|
1109
|
-
return {};
|
|
1110
|
-
},
|
|
1111
|
-
|
|
1112
|
-
page(scope, event) {
|
|
1113
|
-
resetNotice();
|
|
1114
|
-
// chips 送来的是 "prev" / "next";早期的按钮组送 { d: ±1 },两条都认。
|
|
1115
|
-
const raw = valueOf(event, ["value", "d", "delta", "page"], 0);
|
|
1116
|
-
const delta = raw === "prev" ? -1 : raw === "next" ? 1 : num(raw, 0);
|
|
1117
|
-
const pages = Math.max(1, Math.ceil(num(state.total, 0) / state.size));
|
|
1118
|
-
const next = Math.min(pages, Math.max(1, Math.floor(state.offset / state.size) + 1 + delta));
|
|
1119
|
-
state.offset = (next - 1) * state.size;
|
|
1120
|
-
goMemory("list");
|
|
1121
|
-
return {};
|
|
1122
|
-
},
|
|
1123
|
-
|
|
1124
|
-
async open(scope, event) {
|
|
1125
|
-
resetNotice();
|
|
1126
|
-
const id = String(valueOf(event, ["id"], "") ?? "");
|
|
1127
|
-
if (id === "") return { toast: "这条记录没有 id" };
|
|
1128
|
-
let found;
|
|
1129
|
-
try {
|
|
1130
|
-
found = await openMemoryById(id);
|
|
1131
|
-
} catch (error) {
|
|
1132
|
-
state.err = errorText(error);
|
|
1133
|
-
return {};
|
|
1134
|
-
}
|
|
1135
|
-
if (found === undefined) {
|
|
1136
|
-
state.err = "这条记忆已经不在列表里了。";
|
|
1137
|
-
return {};
|
|
1138
|
-
}
|
|
1139
|
-
state.current = found;
|
|
1140
|
-
openedBy.set(scopeKey(scope), found.id);
|
|
1141
|
-
state.provenanceText = "尚未查看。点上面的按钮可向 mneme 查这条记忆的蒸馏来源。";
|
|
1142
|
-
goMemory("detail");
|
|
1143
|
-
return {};
|
|
1144
|
-
},
|
|
1145
|
-
|
|
1146
|
-
"open-entity"(scope, event) {
|
|
1147
|
-
resetNotice();
|
|
1148
|
-
const name = String(valueOf(event, ["name", "entity", "value"], "") ?? "");
|
|
1149
|
-
if (name === "") return { toast: "没有选中实体" };
|
|
1150
|
-
state.entityName = name;
|
|
1151
|
-
state.entityDepth = 1;
|
|
1152
|
-
state.page = "entity";
|
|
1153
|
-
return {};
|
|
1154
|
-
},
|
|
1155
|
-
|
|
1156
|
-
"entity-search"(scope, event) {
|
|
1157
|
-
resetNotice();
|
|
1158
|
-
state.entityQuery = String(valueOf(event, ["entityQuery", "query", "q"], "") ?? "").trim();
|
|
1159
|
-
state.page = "list";
|
|
1160
|
-
// 让下一次 load() 补读实体清单(页面仍是列表,见 load 里的注释)。
|
|
1161
|
-
state.entityDirty = true;
|
|
1162
|
-
return {};
|
|
1163
|
-
},
|
|
1164
|
-
|
|
1165
|
-
"entity-depth"(scope, event) {
|
|
1166
|
-
resetNotice();
|
|
1167
|
-
const picked = String(valueOf(event, ["value", "entityDepth"], "1") ?? "1");
|
|
1168
|
-
state.entityDepth = picked === "2" ? 2 : 1;
|
|
1169
|
-
return {};
|
|
1170
|
-
},
|
|
1171
|
-
|
|
1172
|
-
edit(scope) {
|
|
1173
|
-
resetNotice();
|
|
1174
|
-
const memo = state.current;
|
|
1175
|
-
if (memo === null) return { toast: "先选一条记忆" };
|
|
1176
|
-
// 记下本面板打开的那条:save 也要按它核对(评审 #3)。
|
|
1177
|
-
openedBy.set(scopeKey(scope), memo.id);
|
|
1178
|
-
state.form = {
|
|
1179
|
-
id: memo.id,
|
|
1180
|
-
title: memo.title ?? "",
|
|
1181
|
-
content: memo.content ?? "",
|
|
1182
|
-
type: memo.type ?? "project",
|
|
1183
|
-
importance: num(memo.importance, 3),
|
|
1184
|
-
tags: joinTags(memo.tags),
|
|
1185
|
-
agentScope: typeof memo.agent_scope === "string" ? memo.agent_scope : "",
|
|
1186
|
-
workspaceScope: typeof memo.workspace_scope === "string" ? memo.workspace_scope : "",
|
|
1187
|
-
};
|
|
1188
|
-
state.editBase = editBaseOf(memo);
|
|
1189
|
-
goMemory("editor");
|
|
1190
|
-
return {};
|
|
1191
|
-
},
|
|
1192
|
-
|
|
1193
|
-
"type-filter"(scope, event) {
|
|
1194
|
-
resetNotice();
|
|
1195
|
-
const next = String(valueOf(event, ["value", "type"], "all") ?? "all");
|
|
1196
|
-
state.type = next === "all" || TYPE_LABELS[next] !== undefined ? next : "all";
|
|
1197
|
-
state.offset = 0;
|
|
1198
|
-
goMemory("list");
|
|
1199
|
-
return {};
|
|
1200
|
-
},
|
|
1201
|
-
|
|
1202
|
-
"form-type"(scope, event) {
|
|
1203
|
-
const next = String(valueOf(event, ["value", "type"], state.form.type ?? "project") ?? "project");
|
|
1204
|
-
state.form.type = TYPE_LABELS[next] === undefined ? "project" : next;
|
|
1205
|
-
return {};
|
|
1206
|
-
},
|
|
1207
|
-
|
|
1208
|
-
new() {
|
|
1209
|
-
resetNotice();
|
|
1210
|
-
state.current = null;
|
|
1211
|
-
state.form = {
|
|
1212
|
-
id: "",
|
|
1213
|
-
title: "",
|
|
1214
|
-
content: "",
|
|
1215
|
-
type: "project",
|
|
1216
|
-
importance: 3,
|
|
1217
|
-
tags: "",
|
|
1218
|
-
agentScope: "",
|
|
1219
|
-
workspaceScope: "",
|
|
1220
|
-
};
|
|
1221
|
-
goMemory("editor");
|
|
1222
|
-
return {};
|
|
1223
|
-
},
|
|
1224
|
-
|
|
1225
|
-
async save(scope, event) {
|
|
1226
|
-
resetNotice();
|
|
1227
|
-
const value = event?.value;
|
|
1228
|
-
const source =
|
|
1229
|
-
value !== null && typeof value === "object" && !Array.isArray(value)
|
|
1230
|
-
? { ...state.form, ...value }
|
|
1231
|
-
: { ...state.form };
|
|
1232
|
-
const title = String(source.title ?? "").trim();
|
|
1233
|
-
const content = String(source.content ?? "").trim();
|
|
1234
|
-
const type = TYPE_LABELS[String(source.type ?? "")] === undefined ? "project" : String(source.type);
|
|
1235
|
-
const importance = Math.min(5, Math.max(1, Math.round(num(source.importance, 3))));
|
|
1236
|
-
const tags = splitTags(String(source.tags ?? ""));
|
|
1237
|
-
if (title === "" || content === "") return { toast: "标题和正文都不能空" };
|
|
1238
|
-
const id = state.form.id === "" || state.form.id === undefined ? undefined : String(state.form.id);
|
|
1239
|
-
// 编辑既有条目也要核对面板 scope:state.form 是模块级共享的,两个面板
|
|
1240
|
-
// 各开一条时,A 面板点保存会把内容写进 B 刚打开的那条(评审 #3)。
|
|
1241
|
-
// 新建(id 为空)不涉及已有行,不核对。
|
|
1242
|
-
if (id !== undefined) {
|
|
1243
|
-
const mine = openedBy.get(scopeKey(scope));
|
|
1244
|
-
if (mine === undefined || mine !== id) {
|
|
1245
|
-
return { toast: "这条不是当前打开的记忆,已取消" };
|
|
1246
|
-
}
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
if (id === undefined) {
|
|
1250
|
-
try {
|
|
1251
|
-
const res = await call("/create", { method: "POST", body: { title, content, importance, tags, type } });
|
|
1252
|
-
state.form = {};
|
|
1253
|
-
state.editBase = null;
|
|
1254
|
-
if (res !== null && typeof res === "object" && typeof res.id === "string") {
|
|
1255
|
-
state.current = res;
|
|
1256
|
-
openedBy.set(scopeKey(scope), res.id);
|
|
1257
|
-
goMemory("detail");
|
|
1258
|
-
notice(res.action === "merged" ? "已并入同标题的既有条目。" : "已新建一条记忆。");
|
|
1259
|
-
} else {
|
|
1260
|
-
goMemory("list");
|
|
1261
|
-
notice("已保存。");
|
|
1262
|
-
}
|
|
1263
|
-
} catch (error) {
|
|
1264
|
-
state.err = errorText(error);
|
|
1265
|
-
}
|
|
1266
|
-
return {};
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
|
-
// 编辑既有条目:先回宿主要一份当前值。盘点窗口够长(跨了几分钟甚至几次
|
|
1270
|
-
// 对话)时,缓存里的那份可能已经被工具或 Web 面板改过,而这里回填的表单
|
|
1271
|
-
// 一旦整份提交就会把对方的写入盖掉;/update 没有版本号校验,所以这层
|
|
1272
|
-
// 比较只能放在面板侧(评审 #4)。
|
|
1273
|
-
let fresh;
|
|
1274
|
-
try {
|
|
1275
|
-
fresh = await openMemoryById(id);
|
|
1276
|
-
} catch (error) {
|
|
1277
|
-
state.err = errorText(error);
|
|
1278
|
-
return {};
|
|
1279
|
-
}
|
|
1280
|
-
if (fresh === undefined) {
|
|
1281
|
-
state.current = null;
|
|
1282
|
-
state.form = {};
|
|
1283
|
-
state.editBase = null;
|
|
1284
|
-
goMemory("list");
|
|
1285
|
-
notice("这条记忆已经不在了。", "warn");
|
|
1286
|
-
return {};
|
|
1287
|
-
}
|
|
1288
|
-
const base = state.editBase;
|
|
1289
|
-
if (base !== null && base.updatedAt !== "" && base.updatedAt !== String(fresh.updated_at ?? "")) {
|
|
1290
|
-
state.current = fresh;
|
|
1291
|
-
state.form = {
|
|
1292
|
-
id: fresh.id,
|
|
1293
|
-
title: fresh.title ?? "",
|
|
1294
|
-
content: fresh.content ?? "",
|
|
1295
|
-
type: fresh.type ?? "project",
|
|
1296
|
-
importance: num(fresh.importance, 3),
|
|
1297
|
-
tags: joinTags(fresh.tags),
|
|
1298
|
-
agentScope: typeof fresh.agent_scope === "string" ? fresh.agent_scope : "",
|
|
1299
|
-
workspaceScope: typeof fresh.workspace_scope === "string" ? fresh.workspace_scope : "",
|
|
1300
|
-
};
|
|
1301
|
-
state.editBase = editBaseOf(fresh);
|
|
1302
|
-
state.err = "这条记忆刚在别处被改过,已换成最新内容,请再确认一次。";
|
|
1303
|
-
return {};
|
|
1304
|
-
}
|
|
1305
|
-
|
|
1306
|
-
// 只提交真正改动的字段:把未改的 title/content 一起发回去,等于用旧值
|
|
1307
|
-
// 覆盖对方的写入(同一问题的一半)。
|
|
1308
|
-
const body = { id };
|
|
1309
|
-
if (base === null || title !== base.title) body.title = title;
|
|
1310
|
-
if (base === null || content !== base.content) body.content = content;
|
|
1311
|
-
if (base === null || importance !== base.importance) body.importance = importance;
|
|
1312
|
-
if (base === null || joinTags(tags) !== base.tags) body.tags = tags;
|
|
1313
|
-
const agentScope = String(source.agentScope ?? "").trim();
|
|
1314
|
-
const workspaceScope = String(source.workspaceScope ?? "").trim();
|
|
1315
|
-
if (base === null || agentScope !== base.agentScope) body.agent_scope = agentScope === "" ? null : agentScope;
|
|
1316
|
-
if (base === null || workspaceScope !== base.workspaceScope) {
|
|
1317
|
-
body.workspace_scope = workspaceScope === "" ? null : workspaceScope;
|
|
1318
|
-
}
|
|
1319
|
-
if (Object.keys(body).length === 1) return { toast: "没有改动" };
|
|
1320
|
-
try {
|
|
1321
|
-
const res = await call("/update", { method: "POST", body });
|
|
1322
|
-
const memo = res?.memory;
|
|
1323
|
-
state.form = {};
|
|
1324
|
-
state.editBase = null;
|
|
1325
|
-
if (memo !== null && typeof memo === "object" && typeof memo.id === "string") {
|
|
1326
|
-
state.current = memo;
|
|
1327
|
-
openedBy.set(scopeKey(scope), memo.id);
|
|
1328
|
-
goMemory("detail");
|
|
1329
|
-
} else {
|
|
1330
|
-
goMemory("list");
|
|
1331
|
-
}
|
|
1332
|
-
notice("已保存修改。");
|
|
1333
|
-
} catch (error) {
|
|
1334
|
-
state.err = errorText(error);
|
|
1335
|
-
}
|
|
1336
|
-
return {};
|
|
1337
|
-
},
|
|
1338
|
-
|
|
1339
|
-
async archive(scope, event) {
|
|
1340
|
-
resetNotice();
|
|
1341
|
-
return applyArchived(true, event, scope);
|
|
1342
|
-
},
|
|
1343
|
-
|
|
1344
|
-
async unarchive(scope, event) {
|
|
1345
|
-
resetNotice();
|
|
1346
|
-
return applyArchived(false, event, scope);
|
|
1347
|
-
},
|
|
1348
|
-
|
|
1349
|
-
async delete(scope, event) {
|
|
1350
|
-
resetNotice();
|
|
1351
|
-
if (valueOf(event, ["confirmed"], false) !== true) return {};
|
|
1352
|
-
const memo = state.current;
|
|
1353
|
-
if (memo === null) return { toast: "先选一条记忆" };
|
|
1354
|
-
// 确认框本身不带参数(confirm 节点只有 id 与两个按钮文案),所以「删哪
|
|
1355
|
-
// 一条」只能靠状态。状态是模块级的、被所有面板共用:两个面板各开一条时
|
|
1356
|
-
// 后开的会盖掉前一个的 current,此时点确认就会删错行(评审 #5)。
|
|
1357
|
-
// 打开记忆时按面板 scope 记一份(见 open)。只显示共享详情、没在本面板
|
|
1358
|
-
// 走过 open 的(mine 为空)也不能删:state.current 可能是另一个面板的
|
|
1359
|
-
// 条目(评审 #13)。
|
|
1360
|
-
const mine = openedBy.get(scopeKey(scope));
|
|
1361
|
-
if (mine === undefined || mine !== memo.id) {
|
|
1362
|
-
return { toast: "这条不是当前打开的记忆,已取消" };
|
|
1363
|
-
}
|
|
1364
|
-
const id = mine;
|
|
1365
|
-
try {
|
|
1366
|
-
await call("/delete", { method: "POST", body: { id } });
|
|
1367
|
-
cache.delete(id);
|
|
1368
|
-
openedBy.delete(scopeKey(scope));
|
|
1369
|
-
state.current = null;
|
|
1370
|
-
goMemory("list");
|
|
1371
|
-
notice("已删除。");
|
|
1372
|
-
} catch (error) {
|
|
1373
|
-
state.err = errorText(error);
|
|
1374
|
-
}
|
|
1375
|
-
return {};
|
|
1376
|
-
},
|
|
1377
|
-
|
|
1378
|
-
copy() {
|
|
1379
|
-
const memo = state.current;
|
|
1380
|
-
if (memo === null) return { toast: "先选一条记忆" };
|
|
1381
|
-
return { copy: clip(`# ${memo.title}\n\n${memo.content}`, 8000) };
|
|
1382
|
-
},
|
|
1383
|
-
|
|
1384
|
-
insert() {
|
|
1385
|
-
const memo = state.current;
|
|
1386
|
-
if (memo === null) return { toast: "先选一条记忆" };
|
|
1387
|
-
return { insert: clip(`【记忆】${memo.title}\n${memo.content}`, 2000) };
|
|
1388
|
-
},
|
|
1389
|
-
|
|
1390
|
-
"profile-tab"(scope, event) {
|
|
1391
|
-
resetNotice();
|
|
1392
|
-
const next = nextValue(
|
|
1393
|
-
["profile", "rules", "persona"],
|
|
1394
|
-
state.profileTab,
|
|
1395
|
-
valueOf(event, ["k", "tab", "value"], ""),
|
|
1396
|
-
);
|
|
1397
|
-
state.profileTab = next;
|
|
1398
|
-
state.page = "profile";
|
|
1399
|
-
return {};
|
|
1400
|
-
},
|
|
1401
|
-
|
|
1402
|
-
async "profile-save"(scope, event) {
|
|
1403
|
-
resetNotice();
|
|
1404
|
-
const body = String(valueOf(event, ["editText", "text", "value"], "") ?? "");
|
|
1405
|
-
try {
|
|
1406
|
-
if (state.profileTab === "rules") {
|
|
1407
|
-
let parsed;
|
|
1408
|
-
try {
|
|
1409
|
-
parsed = JSON.parse(body);
|
|
1410
|
-
} catch {
|
|
1411
|
-
state.err = "规则必须是合法 JSON 数组。";
|
|
1412
|
-
return {};
|
|
1413
|
-
}
|
|
1414
|
-
// 非数组({} / null / "text")不能落成空数组:这条路由是整份覆盖,
|
|
1415
|
-
// 静默写 [] 会把用户已有的规则一次清空(评审 #10)。
|
|
1416
|
-
if (!Array.isArray(parsed)) {
|
|
1417
|
-
state.err = "规则必须是合法 JSON 数组。";
|
|
1418
|
-
return {};
|
|
1419
|
-
}
|
|
1420
|
-
await call("/rules", { method: "PUT", body: { rules: parsed } });
|
|
1421
|
-
} else {
|
|
1422
|
-
await call("/profile", { method: "PUT", body: { profile: body } });
|
|
1423
|
-
}
|
|
1424
|
-
notice("已保存。");
|
|
1425
|
-
} catch (error) {
|
|
1426
|
-
state.err = errorText(error);
|
|
1427
|
-
}
|
|
1428
|
-
return {};
|
|
1429
|
-
},
|
|
1430
|
-
|
|
1431
|
-
async "profile-extract"() {
|
|
1432
|
-
resetNotice();
|
|
1433
|
-
if (state.profileTab !== "profile") return { toast: "先切到画像" };
|
|
1434
|
-
try {
|
|
1435
|
-
const res = await call("/profile/extract", { method: "POST", body: {} });
|
|
1436
|
-
const draft = res?.draft ?? res ?? {};
|
|
1437
|
-
const text =
|
|
1438
|
-
typeof draft.profile === "string"
|
|
1439
|
-
? draft.profile
|
|
1440
|
-
: typeof draft.text === "string"
|
|
1441
|
-
? draft.text
|
|
1442
|
-
: JSON.stringify(draft);
|
|
1443
|
-
state.extractText = clip(`草稿:${text}`, 600);
|
|
1444
|
-
} catch (error) {
|
|
1445
|
-
const raw = errorText(error);
|
|
1446
|
-
state.extractText = raw.includes("disabled")
|
|
1447
|
-
? "mneme 没开「画像提取草稿」,先到「设置 › 功能开关 › 写入」里打开它。"
|
|
1448
|
-
: raw.includes("no-route")
|
|
1449
|
-
? "没有可用的模型路由,提取不了草稿。"
|
|
1450
|
-
: raw.includes("no-input")
|
|
1451
|
-
? "记忆还太少,提取不出画像。"
|
|
1452
|
-
: raw;
|
|
1453
|
-
}
|
|
1454
|
-
return {};
|
|
1455
|
-
},
|
|
1456
|
-
|
|
1457
|
-
async mode(scope, event) {
|
|
1458
|
-
resetNotice();
|
|
1459
|
-
const next = String(valueOf(event, ["mode", "value"], "standard") ?? "standard");
|
|
1460
|
-
if (next !== "standard" && next !== "light") {
|
|
1461
|
-
state.err = "未知的模式。";
|
|
1462
|
-
return {};
|
|
1463
|
-
}
|
|
1464
|
-
try {
|
|
1465
|
-
await call("/mode", { method: "PUT", body: { mode: next } });
|
|
1466
|
-
notice(`已切到${next === "light" ? "轻量" : "标准"}模式。`);
|
|
1467
|
-
} catch (error) {
|
|
1468
|
-
state.err = errorText(error);
|
|
1469
|
-
}
|
|
1470
|
-
return {};
|
|
1471
|
-
},
|
|
1472
|
-
|
|
1473
|
-
async reclaim() {
|
|
1474
|
-
resetNotice();
|
|
1475
|
-
try {
|
|
1476
|
-
const res = await call("/maintenance/reclaim");
|
|
1477
|
-
const report = res?.report ?? res ?? {};
|
|
1478
|
-
const rows = Array.isArray(report.candidates) ? report.candidates.length : undefined;
|
|
1479
|
-
const total = rows ?? report.reclaimable ?? report.total ?? report.rows ?? null;
|
|
1480
|
-
state.reclaimText =
|
|
1481
|
-
total === null ? "已检查(dry-run,未改动)。" : `可回收 ${String(total)} 条(dry-run,未改动)。`;
|
|
1482
|
-
} catch (error) {
|
|
1483
|
-
state.reclaimText = errorText(error);
|
|
1484
|
-
}
|
|
1485
|
-
return {};
|
|
1486
|
-
},
|
|
1487
|
-
|
|
1488
|
-
"reclaim-prepare"() {
|
|
1489
|
-
resetNotice();
|
|
1490
|
-
state.reclaimConfirm = true;
|
|
1491
|
-
state.reclaimText = "回收会丢弃无法再召回的条目,确认后再执行。";
|
|
1492
|
-
return {};
|
|
1493
|
-
},
|
|
1494
|
-
|
|
1495
|
-
async "reclaim-run"(scope, event) {
|
|
1496
|
-
resetNotice();
|
|
1497
|
-
if (valueOf(event, ["confirmed"], false) !== true) return {};
|
|
1498
|
-
try {
|
|
1499
|
-
const res = await call("/maintenance/reclaim", {
|
|
1500
|
-
method: "POST",
|
|
1501
|
-
body: { confirm: true, vacuum: true },
|
|
1502
|
-
});
|
|
1503
|
-
const report = res?.report ?? res ?? {};
|
|
1504
|
-
state.reclaimText = `已回收 ${String(report.reclaimed ?? report.total ?? 0)} 条。`;
|
|
1505
|
-
state.reclaimConfirm = false;
|
|
1506
|
-
} catch (error) {
|
|
1507
|
-
state.reclaimText = errorText(error);
|
|
1508
|
-
}
|
|
1509
|
-
return {};
|
|
1510
|
-
},
|
|
1511
|
-
|
|
1512
|
-
"conflict-pick-row"(scope, event) {
|
|
1513
|
-
resetNotice();
|
|
1514
|
-
state.conflictId = String(valueOf(event, ["id"], "") ?? "");
|
|
1515
|
-
conflictBy.set(scopeKey(scope), state.conflictId);
|
|
1516
|
-
state.conflictPick = "";
|
|
1517
|
-
state.page = "conflict";
|
|
1518
|
-
return {};
|
|
1519
|
-
},
|
|
1520
|
-
|
|
1521
|
-
"conflict-pick"(scope, event) {
|
|
1522
|
-
resetNotice();
|
|
1523
|
-
const picked = String(valueOf(event, ["winner", "value"], "") ?? "");
|
|
1524
|
-
state.conflictPick = picked === "a" || picked === "b" ? picked : "none";
|
|
1525
|
-
state.page = "conflict";
|
|
1526
|
-
return {};
|
|
1527
|
-
},
|
|
1528
|
-
|
|
1529
|
-
async "conflict-win"(scope, event) {
|
|
1530
|
-
resetNotice();
|
|
1531
|
-
if (valueOf(event, ["confirmed"], false) !== true) return {};
|
|
1532
|
-
const id = state.conflictId;
|
|
1533
|
-
if (id === "") return { toast: "先选一条冲突" };
|
|
1534
|
-
// 冲突 id 也是共享状态:只裁决本面板自己选中的那条(评审 #3)。
|
|
1535
|
-
if (conflictBy.get(scopeKey(scope)) !== id) {
|
|
1536
|
-
return { toast: "这条不是当前打开的冲突,已取消" };
|
|
1537
|
-
}
|
|
1538
|
-
const winner = state.conflictPick === "a" ? "a" : state.conflictPick === "b" ? "b" : null;
|
|
1539
|
-
try {
|
|
1540
|
-
await call("/conflicts/resolve", { method: "POST", body: { id, winner, apply: true } });
|
|
1541
|
-
conflictBy.delete(scopeKey(scope));
|
|
1542
|
-
state.conflictId = "";
|
|
1543
|
-
state.conflictPick = "";
|
|
1544
|
-
notice("已按裁决合并。");
|
|
1545
|
-
} catch (error) {
|
|
1546
|
-
state.err = errorText(error);
|
|
1547
|
-
}
|
|
1548
|
-
return {};
|
|
1549
|
-
},
|
|
1550
|
-
|
|
1551
|
-
async "wb-restore"(scope, event) {
|
|
1552
|
-
resetNotice();
|
|
1553
|
-
const id = String(valueOf(event, ["id"], "") ?? "");
|
|
1554
|
-
if (id === "") return {};
|
|
1555
|
-
try {
|
|
1556
|
-
await call("/update", { method: "POST", body: { id, archived: false } });
|
|
1557
|
-
notice("已恢复这条记忆。");
|
|
1558
|
-
} catch (error) {
|
|
1559
|
-
state.err = errorText(error);
|
|
1560
|
-
}
|
|
1561
|
-
return {};
|
|
1562
|
-
},
|
|
1563
|
-
|
|
1564
|
-
"cmd-pick"(scope, event) {
|
|
1565
|
-
resetNotice();
|
|
1566
|
-
state.sCmdPick = String(valueOf(event, ["value"], "") ?? "");
|
|
1567
|
-
state.page = "settings";
|
|
1568
|
-
return {};
|
|
1569
|
-
},
|
|
1570
|
-
|
|
1571
|
-
"cmd-add"(scope, event) {
|
|
1572
|
-
resetNotice();
|
|
1573
|
-
const value = event?.value;
|
|
1574
|
-
const source =
|
|
1575
|
-
value !== null && typeof value === "object" && !Array.isArray(value)
|
|
1576
|
-
? { sCmdName: value.sCmdName, sCmdDesc: value.sCmdDesc, sCmdIns: value.sCmdIns }
|
|
1577
|
-
: {};
|
|
1578
|
-
const name = String(source.sCmdName ?? state.sCmdName ?? "").trim();
|
|
1579
|
-
const description = String(source.sCmdDesc ?? state.sCmdDesc ?? "").trim();
|
|
1580
|
-
const instruction = String(source.sCmdIns ?? state.sCmdIns ?? "").trim();
|
|
1581
|
-
if (name === "" || instruction === "") return { toast: "命令名和指令正文都不能空" };
|
|
1582
|
-
return call("/commands", { method: "POST", body: { name, description, instruction } }).then(
|
|
1583
|
-
() => {
|
|
1584
|
-
state.sCmdName = "";
|
|
1585
|
-
state.sCmdDesc = "";
|
|
1586
|
-
state.sCmdIns = "";
|
|
1587
|
-
notice(`已添加命令 ${name}。`);
|
|
1588
|
-
return {};
|
|
1589
|
-
},
|
|
1590
|
-
(error) => {
|
|
1591
|
-
state.err = errorText(error);
|
|
1592
|
-
return {};
|
|
1593
|
-
},
|
|
1594
|
-
);
|
|
1595
|
-
},
|
|
1596
|
-
|
|
1597
|
-
async "cmd-delete"(scope, event) {
|
|
1598
|
-
resetNotice();
|
|
1599
|
-
if (valueOf(event, ["confirmed"], false) !== true) return {};
|
|
1600
|
-
const id = state.sCmdPick;
|
|
1601
|
-
if (id === "") return { toast: "先选一条命令" };
|
|
1602
|
-
try {
|
|
1603
|
-
await call(`/commands?id=${enc(id)}`, { method: "DELETE" });
|
|
1604
|
-
state.sCmdPick = "";
|
|
1605
|
-
notice("已删除命令。");
|
|
1606
|
-
} catch (error) {
|
|
1607
|
-
state.err = errorText(error);
|
|
1608
|
-
}
|
|
1609
|
-
return {};
|
|
1610
|
-
},
|
|
1611
|
-
|
|
1612
|
-
"vector-toggle"(scope, event) {
|
|
1613
|
-
resetNotice();
|
|
1614
|
-
// 没读到真实配置时 state.vector 只是默认值,整体 PUT 会覆盖服务端(评审 #2)。
|
|
1615
|
-
if (!state.vectorLoaded) return { toast: "还没读到向量配置,请刷新后再改" };
|
|
1616
|
-
const value = event?.value;
|
|
1617
|
-
state.vector.enabled = typeof value === "boolean" ? value : !state.vector.enabled;
|
|
1618
|
-
return call("/vector-config", {
|
|
1619
|
-
method: "PUT",
|
|
1620
|
-
body: { enabled: state.vector.enabled, baseUrl: state.vector.baseUrl, model: state.vector.model },
|
|
1621
|
-
}).then(
|
|
1622
|
-
() => {
|
|
1623
|
-
notice(`向量检索:${state.vector.enabled ? "已开启" : "已关闭"}`);
|
|
1624
|
-
return {};
|
|
1625
|
-
},
|
|
1626
|
-
(error) => {
|
|
1627
|
-
state.err = errorText(error);
|
|
1628
|
-
return {};
|
|
1629
|
-
},
|
|
1630
|
-
);
|
|
1631
|
-
},
|
|
1632
|
-
|
|
1633
|
-
"vector-save"(scope, event) {
|
|
1634
|
-
resetNotice();
|
|
1635
|
-
if (!state.vectorLoaded) return { toast: "还没读到向量配置,请刷新后再改" };
|
|
1636
|
-
const value = event?.value;
|
|
1637
|
-
const source = value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
1638
|
-
const baseUrl = String(source.sVecUrl ?? state.vector.baseUrl ?? "").trim();
|
|
1639
|
-
const model = String(source.sVecModel ?? state.vector.model ?? "").trim();
|
|
1640
|
-
state.vector.baseUrl = baseUrl;
|
|
1641
|
-
state.vector.model = model;
|
|
1642
|
-
return call("/vector-config", {
|
|
1643
|
-
method: "PUT",
|
|
1644
|
-
body: { enabled: state.vector.enabled, baseUrl, model },
|
|
1645
|
-
}).then(
|
|
1646
|
-
() => {
|
|
1647
|
-
notice("已保存向量配置。");
|
|
1648
|
-
return {};
|
|
1649
|
-
},
|
|
1650
|
-
(error) => {
|
|
1651
|
-
state.err = errorText(error);
|
|
1652
|
-
return {};
|
|
1653
|
-
},
|
|
1654
|
-
);
|
|
1655
|
-
},
|
|
1656
|
-
|
|
1657
|
-
"vector-reindex"() {
|
|
1658
|
-
resetNotice();
|
|
1659
|
-
const run = call("/vector-reindex", { timeoutMs: 60000 }).then(
|
|
1660
|
-
(res) => {
|
|
1661
|
-
notice(`索引完成:新增 ${String(res?.indexed ?? 0)} 条,跳过 ${String(res?.skipped ?? 0)} 条。`);
|
|
1662
|
-
return {};
|
|
1663
|
-
},
|
|
1664
|
-
(error) => {
|
|
1665
|
-
state.err = errorText(error);
|
|
1666
|
-
return {};
|
|
1667
|
-
},
|
|
1668
|
-
);
|
|
1669
|
-
return {
|
|
1670
|
-
pending: { label: "正在重建向量索引", timeoutMs: 60000 },
|
|
1671
|
-
result: async () => run,
|
|
1672
|
-
};
|
|
1673
|
-
},
|
|
1674
|
-
|
|
1675
|
-
"external-toggle"(scope, event) {
|
|
1676
|
-
resetNotice();
|
|
1677
|
-
if (!state.externalLoaded) return { toast: "还没读到外部访问配置,请刷新后再改" };
|
|
1678
|
-
const value = event?.value;
|
|
1679
|
-
state.external.enabled = typeof value === "boolean" ? value : !state.external.enabled;
|
|
1680
|
-
return call("/external-api", {
|
|
1681
|
-
method: "PUT",
|
|
1682
|
-
body: { enabled: state.external.enabled, port: state.external.port, host: state.external.host },
|
|
1683
|
-
}).then(
|
|
1684
|
-
() => {
|
|
1685
|
-
notice(`外部访问:${state.external.enabled ? "已开启" : "已关闭"}`);
|
|
1686
|
-
return {};
|
|
1687
|
-
},
|
|
1688
|
-
(error) => {
|
|
1689
|
-
state.err = errorText(error);
|
|
1690
|
-
return {};
|
|
1691
|
-
},
|
|
1692
|
-
);
|
|
1693
|
-
},
|
|
1694
|
-
|
|
1695
|
-
"external-save"(scope, event) {
|
|
1696
|
-
resetNotice();
|
|
1697
|
-
if (!state.externalLoaded) return { toast: "还没读到外部访问配置,请刷新后再改" };
|
|
1698
|
-
const value = event?.value;
|
|
1699
|
-
const source = value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
1700
|
-
const port = Math.round(num(source.sExtPort ?? state.external.port, 8790));
|
|
1701
|
-
const host = String(source.sExtHost ?? state.external.host ?? "").trim();
|
|
1702
|
-
if (port < 1 || port > 65535) return { toast: "端口要在 1–65535 之间" };
|
|
1703
|
-
if (host === "" || host.includes("://")) return { toast: "监听地址不能带协议前缀" };
|
|
1704
|
-
state.external.port = port;
|
|
1705
|
-
state.external.host = host;
|
|
1706
|
-
return call("/external-api", {
|
|
1707
|
-
method: "PUT",
|
|
1708
|
-
body: { enabled: state.external.enabled, port, host },
|
|
1709
|
-
}).then(
|
|
1710
|
-
() => {
|
|
1711
|
-
notice("已保存外部访问配置。");
|
|
1712
|
-
return {};
|
|
1713
|
-
},
|
|
1714
|
-
(error) => {
|
|
1715
|
-
state.err = errorText(error);
|
|
1716
|
-
return {};
|
|
1717
|
-
},
|
|
1718
|
-
);
|
|
1719
|
-
},
|
|
1720
|
-
|
|
1721
|
-
async "check-version"() {
|
|
1722
|
-
resetNotice();
|
|
1723
|
-
try {
|
|
1724
|
-
const res = await call("/version-check");
|
|
1725
|
-
const status = String(res?.status ?? "unknown");
|
|
1726
|
-
state.versionNote =
|
|
1727
|
-
status === "outdated"
|
|
1728
|
-
? `有新版本 ${String(res?.latest ?? "")}(当前 ${String(res?.version ?? "")})`
|
|
1729
|
-
: status === "latest"
|
|
1730
|
-
? `已是最新版本 ${String(res?.version ?? "")}`
|
|
1731
|
-
: "没能连上 npm registry,稍后再试。";
|
|
1732
|
-
if (status === "outdated" && res?.latest) notice(`mneme 有新版本 ${res.latest}`);
|
|
1733
|
-
} catch (error) {
|
|
1734
|
-
state.versionNote = errorText(error);
|
|
1735
|
-
}
|
|
1736
|
-
return {};
|
|
1737
|
-
},
|
|
1738
|
-
};
|
|
1739
|
-
|
|
1740
|
-
// One action per feature switch: the app tells us which action fired, and the
|
|
1741
|
-
// new value arrives as `event.value` (boolean) or is derived by flipping the
|
|
1742
|
-
// current effective value.
|
|
1743
|
-
for (const key of FLAG_LABELS.keys()) {
|
|
1744
|
-
actions[flagAction(key)] = async (scope, event) => {
|
|
1745
|
-
resetNotice();
|
|
1746
|
-
const value = event?.value;
|
|
1747
|
-
// state.flags 是宿主原样的扁平点号键(scopeState.enabled);嵌套形态只
|
|
1748
|
-
// 出现在视图绑定里(payload.flags = nestFlags(effective))。这里若按嵌套
|
|
1749
|
-
// 去取值会恒为 undefined,于是「已经打开的开关」永远翻转不回去(评审 #7)。
|
|
1750
|
-
const current = state.flags?.[key];
|
|
1751
|
-
// 布尔项翻转、枚举项(chorographyInject 的 light/full/off)直接用宿主给的
|
|
1752
|
-
// 字符串值:同一个动作 id 同时服务开关与下拉。
|
|
1753
|
-
const next =
|
|
1754
|
-
typeof value === "boolean" || typeof value === "string"
|
|
1755
|
-
? value
|
|
1756
|
-
: typeof current === "string"
|
|
1757
|
-
? current
|
|
1758
|
-
: !(current === true);
|
|
1759
|
-
try {
|
|
1760
|
-
await call("/features", { method: "PUT", body: { [key]: next } });
|
|
1761
|
-
const features = await call("/features");
|
|
1762
|
-
state.flagOverrides = features?.overrides ?? {};
|
|
1763
|
-
state.pendingRestart = Array.isArray(features?.pendingRestart) ? features.pendingRestart : [];
|
|
1764
|
-
state.flags = features?.effective ?? state.flags;
|
|
1765
|
-
state.settingsDirty = true;
|
|
1766
|
-
notice(
|
|
1767
|
-
`${FLAG_LABELS.get(key) ?? key}:${
|
|
1768
|
-
typeof next === "string" ? next : next ? "已开启" : "已关闭"
|
|
1769
|
-
}`,
|
|
1770
|
-
);
|
|
1771
|
-
} catch (error) {
|
|
1772
|
-
state.err = errorText(error);
|
|
1773
|
-
}
|
|
1774
|
-
// 开关只改一个绑定值:数据已经推过去了,别让 App 重建视图(否则设置页
|
|
1775
|
-
// 会被弹回第一个页签)。
|
|
1776
|
-
return { refresh: false };
|
|
1777
|
-
};
|
|
1778
|
-
}
|
|
1779
|
-
|
|
1780
|
-
// ---- 「Agent 眼中的你」自动事实(0.11.0 的面板卡) ----------------------
|
|
1781
|
-
// 写入只有一个入口:POST /profile-facts/apply,op 形状与自动轮共用(add 必须
|
|
1782
|
-
// 带 content、update/archive 必须带 id)。这里不自己拼 SQL,也不碰 user_settings
|
|
1783
|
-
// ——手填画像仍然优先。
|
|
1784
|
-
const applyFacts = (ops) =>
|
|
1785
|
-
call("/profile-facts/apply", { method: "POST", body: { agent: state.factsAgent, ops } });
|
|
1786
|
-
|
|
1787
|
-
Object.assign(actions, {
|
|
1788
|
-
"facts-agent"(scope, event) {
|
|
1789
|
-
resetNotice();
|
|
1790
|
-
state.factsAgent = String(valueOf(event, ["value", "agent"], "") ?? "");
|
|
1791
|
-
state.factEdit = null;
|
|
1792
|
-
factBy.delete(scopeKey(scope));
|
|
1793
|
-
return {};
|
|
1794
|
-
},
|
|
1795
|
-
|
|
1796
|
-
"facts-edit"(scope, event) {
|
|
1797
|
-
resetNotice();
|
|
1798
|
-
const id = String(valueOf(event, ["id"], "") ?? "");
|
|
1799
|
-
const fact = state.factsItems.find((item) => String(item.id) === id);
|
|
1800
|
-
if (fact === undefined) return { toast: "这条事实已经不在了" };
|
|
1801
|
-
state.factEdit = { id, content: String(fact.content ?? "") };
|
|
1802
|
-
factBy.set(scopeKey(scope), id);
|
|
1803
|
-
return {};
|
|
1804
|
-
},
|
|
1805
|
-
|
|
1806
|
-
"facts-add"(scope, event) {
|
|
1807
|
-
resetNotice();
|
|
1808
|
-
const value = event?.value;
|
|
1809
|
-
const source = value !== null && typeof value === "object" ? value : {};
|
|
1810
|
-
const text = String(source.sFactNew ?? state.form.factNew ?? "").trim();
|
|
1811
|
-
if (text === "") return { toast: "写一句话再添加" };
|
|
1812
|
-
return applyFacts([{ op: "add", content: text }]).then(
|
|
1813
|
-
(result) => {
|
|
1814
|
-
state.form.factNew = "";
|
|
1815
|
-
notice(`已加 ${String(result?.applied ?? 1)} 条事实。`);
|
|
1816
|
-
return {};
|
|
1817
|
-
},
|
|
1818
|
-
(error) => {
|
|
1819
|
-
state.err = errorText(error);
|
|
1820
|
-
return {};
|
|
1821
|
-
},
|
|
1822
|
-
);
|
|
1823
|
-
},
|
|
1824
|
-
|
|
1825
|
-
"facts-save"(scope, event) {
|
|
1826
|
-
resetNotice();
|
|
1827
|
-
const target = state.factEdit;
|
|
1828
|
-
if (target === null) return { toast: "先点一条事实" };
|
|
1829
|
-
if (factBy.get(scopeKey(scope)) !== target.id) {
|
|
1830
|
-
return { toast: "这条不是当前打开的事实,已取消" };
|
|
1831
|
-
}
|
|
1832
|
-
const value = event?.value;
|
|
1833
|
-
const source = value !== null && typeof value === "object" ? value : {};
|
|
1834
|
-
const text = String(source.sFactEdit ?? target.content ?? "").trim();
|
|
1835
|
-
if (text === "") return { toast: "事实正文不能空" };
|
|
1836
|
-
if (text === target.content) return { toast: "没有改动" };
|
|
1837
|
-
return applyFacts([{ op: "update", id: target.id, content: text }]).then(
|
|
1838
|
-
(result) => {
|
|
1839
|
-
const failed = Array.isArray(result?.failures) ? result.failures : [];
|
|
1840
|
-
if (failed.length > 0) {
|
|
1841
|
-
state.err = "这条事实已被改过或删掉了,刷新后再试。";
|
|
1842
|
-
return {};
|
|
1843
|
-
}
|
|
1844
|
-
state.factEdit = null;
|
|
1845
|
-
factBy.delete(scopeKey(scope));
|
|
1846
|
-
notice("事实已更新。");
|
|
1847
|
-
return {};
|
|
1848
|
-
},
|
|
1849
|
-
(error) => {
|
|
1850
|
-
state.err = errorText(error);
|
|
1851
|
-
return {};
|
|
1852
|
-
},
|
|
1853
|
-
);
|
|
1854
|
-
},
|
|
1855
|
-
|
|
1856
|
-
"facts-archive"(scope, event) {
|
|
1857
|
-
resetNotice();
|
|
1858
|
-
if (valueOf(event, ["confirmed"], false) !== true) return {};
|
|
1859
|
-
const target = state.factEdit;
|
|
1860
|
-
if (target === null) return { toast: "先点一条事实" };
|
|
1861
|
-
if (factBy.get(scopeKey(scope)) !== target.id) {
|
|
1862
|
-
return { toast: "这条不是当前打开的事实,已取消" };
|
|
1863
|
-
}
|
|
1864
|
-
return applyFacts([{ op: "archive", id: target.id }]).then(
|
|
1865
|
-
() => {
|
|
1866
|
-
state.factEdit = null;
|
|
1867
|
-
factBy.delete(scopeKey(scope));
|
|
1868
|
-
notice("已归档这条事实。");
|
|
1869
|
-
return {};
|
|
1870
|
-
},
|
|
1871
|
-
(error) => {
|
|
1872
|
-
state.err = errorText(error);
|
|
1873
|
-
return {};
|
|
1874
|
-
},
|
|
1875
|
-
);
|
|
1876
|
-
},
|
|
1877
|
-
|
|
1878
|
-
"facts-run"(scope, event) {
|
|
1879
|
-
resetNotice();
|
|
1880
|
-
// 维护轮要跑一次 LLM(模型读最近记忆、提 op、落库),慢且不可并发,所以
|
|
1881
|
-
// 走 pending:面板先挂一条「正在更新」,最长等 60 秒。
|
|
1882
|
-
const run = call("/profile-facts/run", {
|
|
1883
|
-
method: "POST",
|
|
1884
|
-
body: { agent: state.factsAgent },
|
|
1885
|
-
timeoutMs: 60000,
|
|
1886
|
-
}).then(
|
|
1887
|
-
(receipt) => {
|
|
1888
|
-
const status = String(receipt?.status ?? "ok");
|
|
1889
|
-
state.factsRunText =
|
|
1890
|
-
status === "ok"
|
|
1891
|
-
? `本轮改了 ${String(receipt?.opsApplied ?? 0)} 条事实。`
|
|
1892
|
-
: status === "noop"
|
|
1893
|
-
? "没有新记忆可读,这轮没动。"
|
|
1894
|
-
: `这轮结束了:${status}`;
|
|
1895
|
-
notice("事实维护轮已结束。");
|
|
1896
|
-
return { toast: "事实已更新" };
|
|
1897
|
-
},
|
|
1898
|
-
(error) => {
|
|
1899
|
-
const message = errorText(error);
|
|
1900
|
-
// 409 = 功能关着,503 = 没配模型,502 = 模型答得不成形:三种都得说人话。
|
|
1901
|
-
state.err = /\b409\b/u.test(message)
|
|
1902
|
-
? "「Agent 眼中的你」还关着:先在上面把开关打开。"
|
|
1903
|
-
: /\b503\b/u.test(message)
|
|
1904
|
-
? "还没配后台模型:先在 mneme 设置里选一个 dream 模型。"
|
|
1905
|
-
: message;
|
|
1906
|
-
return { toast: "这轮没成功" };
|
|
1907
|
-
},
|
|
1908
|
-
);
|
|
1909
|
-
return { pending: { label: "正在维护事实", timeoutMs: 60000 }, result: async () => run };
|
|
1910
|
-
},
|
|
1911
|
-
|
|
1912
|
-
// ---- 代码库地志(chorography)-----------------------------------------
|
|
1913
|
-
"choro-repo"(scope, event) {
|
|
1914
|
-
resetNotice();
|
|
1915
|
-
state.choroRepo = String(valueOf(event, ["value"], "") ?? "");
|
|
1916
|
-
state.choroPageText = "";
|
|
1917
|
-
return {};
|
|
1918
|
-
},
|
|
1919
|
-
|
|
1920
|
-
"choro-page"(scope, event) {
|
|
1921
|
-
resetNotice();
|
|
1922
|
-
const slug = String(valueOf(event, ["slug"], "") ?? "");
|
|
1923
|
-
if (slug === "" || state.choroRepo === "") return { toast: "先选一个仓库" };
|
|
1924
|
-
return call(`/choro/page?repo=${encodeURIComponent(state.choroRepo)}&slug=${encodeURIComponent(slug)}`).then(
|
|
1925
|
-
(res) => {
|
|
1926
|
-
const page = res?.page;
|
|
1927
|
-
state.choroPageText =
|
|
1928
|
-
page === undefined
|
|
1929
|
-
? "这一页读不出来,可能刚被重建过。"
|
|
1930
|
-
: clip(String(page?.content ?? page?.relPath ?? ""), 1600);
|
|
1931
|
-
notice(`已读取 ${String(page?.title ?? slug)} 的骨架页。`);
|
|
1932
|
-
return {};
|
|
1933
|
-
},
|
|
1934
|
-
(error) => {
|
|
1935
|
-
const message = errorText(error);
|
|
1936
|
-
state.err = /\b409\b/u.test(message) ? "索引还在、盘上没了:点「重建骨架」重跑一次。" : message;
|
|
1937
|
-
return {};
|
|
1938
|
-
},
|
|
1939
|
-
);
|
|
1940
|
-
},
|
|
1941
|
-
|
|
1942
|
-
"choro-run"(scope, event) {
|
|
1943
|
-
resetNotice();
|
|
1944
|
-
if (valueOf(event, ["confirmed"], false) !== true) return {};
|
|
1945
|
-
if (state.choroRepo === "") return { toast: "还没有登记任何仓库" };
|
|
1946
|
-
const run = call("/choro/run", {
|
|
1947
|
-
method: "POST",
|
|
1948
|
-
body: { repo: state.choroRepo },
|
|
1949
|
-
timeoutMs: 60000,
|
|
1950
|
-
}).then(
|
|
1951
|
-
(res) => {
|
|
1952
|
-
const receipt = res?.receipt ?? {};
|
|
1953
|
-
state.choroPageText = "";
|
|
1954
|
-
notice(
|
|
1955
|
-
`骨架已重建:${String(receipt.pagesWritten ?? receipt.modules ?? 0)} 页` +
|
|
1956
|
-
(receipt.t1Loss === true ? "(已富化的语义段被退回骨架)" : ""),
|
|
1957
|
-
);
|
|
1958
|
-
return { toast: "重建完成" };
|
|
1959
|
-
},
|
|
1960
|
-
(error) => {
|
|
1961
|
-
state.err = errorText(error);
|
|
1962
|
-
return { toast: "重建失败" };
|
|
1963
|
-
},
|
|
1964
|
-
);
|
|
1965
|
-
return { pending: { label: "正在重建骨架", timeoutMs: 60000 }, result: async () => run };
|
|
1966
|
-
},
|
|
1967
|
-
});
|
|
1968
|
-
|
|
1969
|
-
// 未收录的布尔旗标(「其它」组里的数据行):行里带着 key,点一下翻转。
|
|
1970
|
-
actions["flag-set"] = async (scope, event) => {
|
|
1971
|
-
resetNotice();
|
|
1972
|
-
const key = String(valueOf(event, ["key", "value"], "") ?? "");
|
|
1973
|
-
if (key === "") {
|
|
1974
|
-
state.err = "没有拿到要切换的键。";
|
|
1975
|
-
return {};
|
|
1976
|
-
}
|
|
1977
|
-
const next = state.flags?.[key] !== true;
|
|
1978
|
-
try {
|
|
1979
|
-
await call("/features", { method: "PUT", body: { [key]: next } });
|
|
1980
|
-
const features = await call("/features");
|
|
1981
|
-
state.flagOverrides = features?.overrides ?? {};
|
|
1982
|
-
state.pendingRestart = Array.isArray(features?.pendingRestart) ? features.pendingRestart : [];
|
|
1983
|
-
state.flags = features?.effective ?? state.flags;
|
|
1984
|
-
state.settingsDirty = true;
|
|
1985
|
-
notice(`${key}:${next ? "已开启" : "已关闭"}`);
|
|
1986
|
-
} catch (error) {
|
|
1987
|
-
state.err = errorText(error);
|
|
1988
|
-
}
|
|
1989
|
-
return { refresh: false };
|
|
1990
|
-
};
|
|
1991
|
-
|
|
1992
|
-
// 只改绑定值的动作不申请重画视图。App 收到 refresh 会重新拉一次视图树,
|
|
1993
|
-
// 而顶层 tabs 的选中页签是 App 的本地状态:树一重建就回到第一个页签,用户在
|
|
1994
|
-
// 设置页翻开关会被弹回记忆页。这些动作的结果都在 data 里(开关值、列表行、
|
|
1995
|
-
// 文案),推一次数据就够了。
|
|
1996
|
-
const VALUE_ONLY = new Set([
|
|
1997
|
-
"mode",
|
|
1998
|
-
"vector-toggle",
|
|
1999
|
-
"vector-save",
|
|
2000
|
-
"external-toggle",
|
|
2001
|
-
"external-save",
|
|
2002
|
-
"profile-save",
|
|
2003
|
-
"facts-agent",
|
|
2004
|
-
"facts-edit",
|
|
2005
|
-
"facts-add",
|
|
2006
|
-
"facts-save",
|
|
2007
|
-
"facts-archive",
|
|
2008
|
-
"facts-run",
|
|
2009
|
-
"flag-set",
|
|
2010
|
-
"choro-repo",
|
|
2011
|
-
"choro-page",
|
|
2012
|
-
"choro-run",
|
|
2013
|
-
]);
|
|
2014
|
-
|
|
2015
|
-
// Every action may change what the panel shows, so push fresh data before
|
|
2016
|
-
// asking the app to re-render (the refresh effect alone would re-render a
|
|
2017
|
-
// stale feed).
|
|
2018
|
-
const wrapped = {};
|
|
2019
|
-
for (const [id, handler] of Object.entries(actions)) {
|
|
2020
|
-
wrapped[id] = async (scope, event) => {
|
|
2021
|
-
let result;
|
|
2022
|
-
try {
|
|
2023
|
-
result = await handler(scope, event);
|
|
2024
|
-
} catch (error) {
|
|
2025
|
-
state.err = errorText(error);
|
|
2026
|
-
result = undefined;
|
|
2027
|
-
}
|
|
2028
|
-
// 默认重画视图:多数动作会改变「哪些节点存在」(进子页、空态、列表)。
|
|
2029
|
-
// 只改一个绑定值的动作(开关、档位、外部访问的开关)要显式关掉重画:
|
|
2030
|
-
// App 的 refresh 会让它重新拉一次视图树,而页签选择是 App 的本地状态,
|
|
2031
|
-
// 重建后回到第一个页签——用户正在设置页翻开关,会被弹回记忆页。
|
|
2032
|
-
const wanted = result ?? {};
|
|
2033
|
-
const skip = wanted.refresh === false || VALUE_ONLY.has(id) || id.startsWith("flag.");
|
|
2034
|
-
const effect = { ...wanted, refresh: !skip };
|
|
2035
|
-
// pending 动作(维护轮、地志重建)把真正的活儿放在 result 里,handler
|
|
2036
|
-
// 返回时它还没跑:此时立刻 broadcast 推出去的仍是这一轮之前的事实与
|
|
2037
|
-
// 地志列表。改成等 result 跑完再推(评审 #15)。
|
|
2038
|
-
if (typeof wanted.result === "function") {
|
|
2039
|
-
const inner = wanted.result;
|
|
2040
|
-
effect.result = async (...args) => {
|
|
2041
|
-
try {
|
|
2042
|
-
return await inner(...args);
|
|
2043
|
-
} finally {
|
|
2044
|
-
await broadcast();
|
|
2045
|
-
}
|
|
2046
|
-
};
|
|
2047
|
-
return effect;
|
|
2048
|
-
}
|
|
2049
|
-
await broadcast();
|
|
2050
|
-
return effect;
|
|
2051
|
-
};
|
|
2052
|
-
}
|
|
2053
|
-
|
|
2054
|
-
// ----------------------------------------------------------------- panel
|
|
2055
|
-
ctx.dshGo.panel("mneme", {
|
|
2056
|
-
view() {
|
|
2057
|
-
return buildView(state);
|
|
2058
|
-
},
|
|
2059
|
-
data(scope, emit) {
|
|
2060
|
-
emitters.add(emit);
|
|
2061
|
-
// 挂载时读全:之后每个动作只重读当前页那一块(见 load 的注释)。
|
|
2062
|
-
load({ full: true }).then(
|
|
2063
|
-
(payload) => {
|
|
2064
|
-
if (emitters.has(emit)) emit(payload);
|
|
2065
|
-
},
|
|
2066
|
-
(error) => {
|
|
2067
|
-
if (emitters.has(emit)) emit({ notice: state.notice, err: errorText(error) });
|
|
2068
|
-
},
|
|
2069
|
-
);
|
|
2070
|
-
return () => {
|
|
2071
|
-
emitters.delete(emit);
|
|
2072
|
-
};
|
|
2073
|
-
},
|
|
2074
|
-
actions: wrapped,
|
|
2075
|
-
});
|
|
2076
|
-
}
|
|
2077
|
-
|
|
2078
|
-
/**
|
|
2079
|
-
* 清掉模块级状态与缓存。在引擎重新挂载面板、以及测试之间切换场景时用它:
|
|
2080
|
-
* 这些表(列表缓存、按 scope 打开的记忆、提示语)是模块级的,跨 mount 复用会
|
|
2081
|
-
* 把上一个场景的残留带进下一个。
|
|
2082
|
-
*/
|
|
2083
|
-
export function resetPanelState() {
|
|
2084
|
-
cache.clear();
|
|
2085
|
-
emitters.clear();
|
|
2086
|
-
openedBy.clear();
|
|
2087
|
-
conflictBy.clear();
|
|
2088
|
-
factBy.clear();
|
|
2089
|
-
lastPayload = null;
|
|
2090
|
-
Object.assign(state, defaultState());
|
|
2091
|
-
}
|
|
1
|
+
// local-mneme-panel — a dsh-go native panel over the installed dsh-mneme store.
|
|
2
|
+
//
|
|
3
|
+
// The panel never talks HTTP: src/dsh-go/host.js dispatches mneme's own registered
|
|
4
|
+
// route handlers inside this engine process. Reads are mneme's own GET routes;
|
|
5
|
+
// writes go through mneme's own routes (create/update/delete/features/profile/
|
|
6
|
+
// rules/mode/conflicts/commands/vector/external/reclaim), so its apiToken policy
|
|
7
|
+
// still applies if one is ever set.
|
|
8
|
+
import { createHostCall } from "./host.js";
|
|
9
|
+
import { buildView } from "./views/root.js";
|
|
10
|
+
import {
|
|
11
|
+
FLAG_LABELS,
|
|
12
|
+
RANGE_DAYS,
|
|
13
|
+
TYPE_LABELS,
|
|
14
|
+
clip,
|
|
15
|
+
errorText,
|
|
16
|
+
flagAction,
|
|
17
|
+
fmtDay,
|
|
18
|
+
fmtMonth,
|
|
19
|
+
fmtTime,
|
|
20
|
+
joinTags,
|
|
21
|
+
scopeText,
|
|
22
|
+
splitTags,
|
|
23
|
+
entityIcon,
|
|
24
|
+
entityTypeLabel,
|
|
25
|
+
typeIcon,
|
|
26
|
+
typeLabel,
|
|
27
|
+
typeTone,
|
|
28
|
+
} from "./text.js";
|
|
29
|
+
|
|
30
|
+
const PAGE_SIZE = 20;
|
|
31
|
+
const cache = new Map();
|
|
32
|
+
// Live panel feeds, one emit per open panel/locale. dsh-go feeds are push-based:
|
|
33
|
+
// data() runs once and later state changes must be pushed through these emits
|
|
34
|
+
// (a `refresh` effect only re-renders the view; it never re-runs data()).
|
|
35
|
+
const emitters = new Set();
|
|
36
|
+
// 每个面板 scope 打开过哪条记忆。删除确认(confirm 节点)不携带任何参数,
|
|
37
|
+
// 只能靠状态决定删谁;状态又是所有面板共用的,所以按 scope 记一份,删除前
|
|
38
|
+
// 与本面板打开的那条核对,不匹配就拒绝(评审 #5)。
|
|
39
|
+
const openedBy = new Map();
|
|
40
|
+
// 冲突页的裁决同样改数据,而 state.conflictId 是模块级共享的:两个面板
|
|
41
|
+
// 各看一条冲突时,后开的会盖掉前一个的 id。按面板 scope 记一份再核对
|
|
42
|
+
// (评审 #3 的 conflict-win 部分)。
|
|
43
|
+
const conflictBy = new Map();
|
|
44
|
+
// 「Agent 眼中的你」的编辑态 state.factEdit 同样是共享的;按面板 scope 记
|
|
45
|
+
// 一份打开的事实 id,保存与归档前核对(评审 #3 的 facts 部分)。
|
|
46
|
+
const factBy = new Map();
|
|
47
|
+
// 上一次推给 App 的完整数据:未重新读取的块从这里并回去,保证每份 data 都
|
|
48
|
+
// 带着全部绑定键(见 load 的注释)。
|
|
49
|
+
let lastPayload = null;
|
|
50
|
+
const scopeKey = (scope) => [scope?.sessionId ?? "", scope?.turnId ?? "", scope?.callId ?? ""].join("|");
|
|
51
|
+
|
|
52
|
+
const defaultState = () => ({
|
|
53
|
+
page: "list", // list|detail|editor|entity|conflict|settings|profile
|
|
54
|
+
flagGroup: "basic",
|
|
55
|
+
extraRows: [], // 上游新增、面板还没中文名的布尔旗标
|
|
56
|
+
factsCards: [],
|
|
57
|
+
factsAgent: "",
|
|
58
|
+
factsRows: [],
|
|
59
|
+
factsItems: [],
|
|
60
|
+
factEdit: null,
|
|
61
|
+
factsRunText: "",
|
|
62
|
+
choroRepos: [],
|
|
63
|
+
choroRepo: "",
|
|
64
|
+
choroRows: [],
|
|
65
|
+
choroNote: "",
|
|
66
|
+
choroPageText: "",
|
|
67
|
+
settingsView: "settings", // settings|status
|
|
68
|
+
sPanel: "menu", // menu|workbench|commands|vector|external|help|facts|choro
|
|
69
|
+
profileTab: "profile",
|
|
70
|
+
q: "",
|
|
71
|
+
type: "all",
|
|
72
|
+
range: "all",
|
|
73
|
+
minImportance: 0,
|
|
74
|
+
scope: "all",
|
|
75
|
+
semantic: false,
|
|
76
|
+
viewMode: "flat",
|
|
77
|
+
heatSort: false,
|
|
78
|
+
filtersOpen: false,
|
|
79
|
+
offset: 0,
|
|
80
|
+
size: PAGE_SIZE,
|
|
81
|
+
current: null,
|
|
82
|
+
form: {},
|
|
83
|
+
// 编辑页打开时的那份快照:只有和它不同的字段才会写回 /update,
|
|
84
|
+
// 并且用它挡住「别处已经改过这条记忆」(评审 #4)。
|
|
85
|
+
editBase: null,
|
|
86
|
+
entityTotal: -1,
|
|
87
|
+
entityTypeTotal: 0,
|
|
88
|
+
entityQuery: "",
|
|
89
|
+
entityName: "",
|
|
90
|
+
entityDepth: 1,
|
|
91
|
+
entityRelationCount: 0,
|
|
92
|
+
entityMemoryCount: 0,
|
|
93
|
+
flags: {},
|
|
94
|
+
flagOverrides: {},
|
|
95
|
+
pendingRestart: [],
|
|
96
|
+
pendingConflicts: 0,
|
|
97
|
+
pendingIds: new Set(),
|
|
98
|
+
rowsCount: 0,
|
|
99
|
+
total: 0, // 当前筛选下的总条数,翻页时用它算页数
|
|
100
|
+
monthGroups: [],
|
|
101
|
+
detailEntities: [],
|
|
102
|
+
detailEntityCount: 0,
|
|
103
|
+
detailConflict: false,
|
|
104
|
+
provenanceText: "",
|
|
105
|
+
conflictCount: 0,
|
|
106
|
+
conflictId: "",
|
|
107
|
+
conflictPick: "",
|
|
108
|
+
conflictA: null,
|
|
109
|
+
conflictB: null,
|
|
110
|
+
conflictReady: false,
|
|
111
|
+
wbKv: [],
|
|
112
|
+
wbDeposited: [],
|
|
113
|
+
wbArchived: [],
|
|
114
|
+
commands: [],
|
|
115
|
+
cmdCount: 0,
|
|
116
|
+
sCmdPick: "",
|
|
117
|
+
sCmdName: "",
|
|
118
|
+
sCmdDesc: "",
|
|
119
|
+
sCmdIns: "",
|
|
120
|
+
vector: { enabled: false, baseUrl: "", model: "" },
|
|
121
|
+
external: { enabled: false, port: 8790, host: "127.0.0.1" },
|
|
122
|
+
// 读成功才允许整体写回:读失败时上面的 vector/external 会落成默认值,
|
|
123
|
+
// 此时 PUT 会把用户在 Web 面板里配好的地址/端口覆盖掉(评审 #2)。
|
|
124
|
+
vectorLoaded: false,
|
|
125
|
+
externalLoaded: false,
|
|
126
|
+
// 实体搜索要重读的是「实体清单」而不是整页;load() 按这个标记补读
|
|
127
|
+
// (评审 #4)。
|
|
128
|
+
entityDirty: false,
|
|
129
|
+
// 顶层页签由 App 本地切换,state.page 可能还停在 "list";设置项的动作
|
|
130
|
+
// 改完数据后也要重读设置块,否则推到面板上的仍是旧值(评审 #6)。
|
|
131
|
+
settingsDirty: false,
|
|
132
|
+
versionNote: "",
|
|
133
|
+
extractText: "",
|
|
134
|
+
injectPreviewText: "",
|
|
135
|
+
tokenCategories: [],
|
|
136
|
+
tokenSeries: [],
|
|
137
|
+
tokenKv: [],
|
|
138
|
+
heatSeries: [],
|
|
139
|
+
heatNote: "",
|
|
140
|
+
reclaimText: "尚未检查。",
|
|
141
|
+
reclaimConfirm: false,
|
|
142
|
+
notice: "",
|
|
143
|
+
noticeTone: "ok",
|
|
144
|
+
err: "",
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const state = defaultState();
|
|
148
|
+
|
|
149
|
+
function resetNotice() {
|
|
150
|
+
state.notice = "";
|
|
151
|
+
state.err = "";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function notice(text, tone = "ok") {
|
|
155
|
+
state.notice = clip(text, 160);
|
|
156
|
+
state.noticeTone = tone;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Read the resolved action arguments/values the app sends with an invoke. */
|
|
160
|
+
function valueOf(event, keys, fallback) {
|
|
161
|
+
const value = event?.value;
|
|
162
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
163
|
+
for (const key of keys) {
|
|
164
|
+
if (value[key] !== undefined && value[key] !== null) return value[key];
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (typeof value === "string" && value !== "") return value;
|
|
168
|
+
if (typeof value === "boolean") return value;
|
|
169
|
+
return fallback;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function num(value, fallback) {
|
|
173
|
+
const n = Number(value);
|
|
174
|
+
return Number.isFinite(n) ? n : fallback;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function rowOf(row) {
|
|
178
|
+
const heat = typeof row.heat === "number" ? row.heat : Number.NaN;
|
|
179
|
+
const pending = state.pendingIds.has(row.id);
|
|
180
|
+
const marks = [];
|
|
181
|
+
if (row.archived === true) marks.push("已归档");
|
|
182
|
+
if (pending) marks.push("待确认");
|
|
183
|
+
return {
|
|
184
|
+
title: clip(row.title, 60),
|
|
185
|
+
subtitle: `${typeLabel(row.type)} · ${fmtTime(row.updated_at ?? row.created_at)}${marks.length === 0 ? "" : ` · ${marks.join(" · ")}`}`,
|
|
186
|
+
trailing: Number.isFinite(heat) ? `★${row.importance ?? 1} · 热度 ${heat.toFixed(2)}` : `★${row.importance ?? 1}`,
|
|
187
|
+
...(typeIcon(row.type) === undefined ? {} : { icon: typeIcon(row.type) }),
|
|
188
|
+
tone: typeTone(row.type),
|
|
189
|
+
action: { id: "open", args: { id: row.id } },
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Flat dotted flag keys -> nested object so `data.flags.a.b` bindings resolve. */
|
|
194
|
+
function nestFlags(effective) {
|
|
195
|
+
const out = {};
|
|
196
|
+
for (const [key, value] of Object.entries(effective ?? {})) {
|
|
197
|
+
const parts = key.split(".");
|
|
198
|
+
let node = out;
|
|
199
|
+
for (const part of parts.slice(0, -1)) {
|
|
200
|
+
if (typeof node[part] !== "object" || node[part] === null) node[part] = {};
|
|
201
|
+
node = node[part];
|
|
202
|
+
}
|
|
203
|
+
// 原样透传:开关是布尔,枚举型旗标(chorographyInject 的 light/full/off)
|
|
204
|
+
// 是字符串。以前这里一律写成 `value === true`,下拉永远只看到 true/false。
|
|
205
|
+
node[parts[parts.length - 1]] = value;
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const enc = (value) => encodeURIComponent(String(value ?? ""));
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Mount the panel on a context that already provides the dsh-go `dshGo` service.
|
|
214
|
+
* Called by src/index.js from `ctx.inject(['dshGo'], ...)`: without the dsh-go
|
|
215
|
+
* bridge (desktop / headless hosts) this module never runs.
|
|
216
|
+
*/
|
|
217
|
+
export function mountDshGoPanel(ctx, options = {}) {
|
|
218
|
+
// options.apiToken 由 src/index.js 传入:路由注册器给 token / token-write
|
|
219
|
+
// 路由包了鉴权,进程内调用不带这个头就会被自家 API 拦成 401(评审 #2)。
|
|
220
|
+
const call = createHostCall(ctx, options);
|
|
221
|
+
|
|
222
|
+
// ---------------------------------------------------------------- loaders
|
|
223
|
+
function groupByMonth(rows, raw) {
|
|
224
|
+
const buckets = new Map();
|
|
225
|
+
rows.forEach((row, index) => {
|
|
226
|
+
const label = fmtMonth(raw[index]?.updated_at ?? raw[index]?.created_at);
|
|
227
|
+
if (!buckets.has(label)) buckets.set(label, []);
|
|
228
|
+
buckets.get(label).push(row);
|
|
229
|
+
});
|
|
230
|
+
return [...buckets.entries()].map(([label, list]) => ({ label, rows: list }));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function loadMemory() {
|
|
234
|
+
const statsPromise = call("/recall-stats?window=30").catch(() => ({}));
|
|
235
|
+
const conflictsPromise = call("/conflicts").catch(() => ({}));
|
|
236
|
+
const dreamPromise = call("/dream-status").catch(() => ({}));
|
|
237
|
+
const payload = {
|
|
238
|
+
query: state.q,
|
|
239
|
+
filter: state.type,
|
|
240
|
+
fRange: state.range,
|
|
241
|
+
fMin: String(state.minImportance),
|
|
242
|
+
fScope: state.scope,
|
|
243
|
+
fSemantic: state.semantic,
|
|
244
|
+
fView: state.viewMode,
|
|
245
|
+
fHeat: state.heatSort,
|
|
246
|
+
};
|
|
247
|
+
let items = [];
|
|
248
|
+
if (state.q !== "") {
|
|
249
|
+
const mode = state.semantic ? "hybrid" : "auto";
|
|
250
|
+
const res = await call(`/search?q=${enc(state.q)}&topK=20&mode=${mode}`);
|
|
251
|
+
// /search 没有 type 参数(它是全库语义检索),类型筛选只能在拿到结果后
|
|
252
|
+
// 再收一遍,否则搜索会把用户选好的类型丢掉(评审 #8)。
|
|
253
|
+
const hits = res?.items ?? [];
|
|
254
|
+
items = state.type === "all" || state.type === "" ? hits : hits.filter((item) => item?.type === state.type);
|
|
255
|
+
payload.total = String(items.length);
|
|
256
|
+
// 空结果不再靠一块 emptyState 表达:那个结构在首屏就定了,数据晚一步
|
|
257
|
+
// 到达时就会和真实列表同时出现。文案跟着数据走,结构不跟着动。
|
|
258
|
+
payload.pageText =
|
|
259
|
+
items.length === 0
|
|
260
|
+
? "搜索无结果"
|
|
261
|
+
: `搜索 · ${items.length} 条`;
|
|
262
|
+
} else {
|
|
263
|
+
const query = new URLSearchParams({
|
|
264
|
+
limit: String(state.size),
|
|
265
|
+
offset: String(state.offset),
|
|
266
|
+
order: "chrono",
|
|
267
|
+
});
|
|
268
|
+
if (state.type !== "all" && state.type !== "") query.set("type", state.type);
|
|
269
|
+
if (state.minImportance > 0) query.set("minImportance", String(state.minImportance));
|
|
270
|
+
const days = RANGE_DAYS[state.range];
|
|
271
|
+
if (days !== undefined) query.set("updatedFrom", new Date(Date.now() - days * 86400000).toISOString());
|
|
272
|
+
if (state.scope === "deposited") query.set("deposited", "only");
|
|
273
|
+
if (state.scope === "archived") query.set("archived", "only");
|
|
274
|
+
if (state.scope === "foreign") query.set("source", "imported");
|
|
275
|
+
const res = await call(`/list?${query.toString()}`);
|
|
276
|
+
items = res?.items ?? [];
|
|
277
|
+
const total = num(res?.total, items.length);
|
|
278
|
+
const pages = Math.max(1, Math.ceil(total / state.size));
|
|
279
|
+
const pageNo = Math.min(pages, Math.floor(state.offset / state.size) + 1);
|
|
280
|
+
payload.total = String(total);
|
|
281
|
+
// 这行文案同时是左胶囊的标签,列宽只有约 142dp,越长越容易折行。
|
|
282
|
+
payload.pageText = total === 0 ? "没有匹配的记忆" : `${total} 条 · ${pageNo}/${pages} 页`;
|
|
283
|
+
}
|
|
284
|
+
for (const row of items) cache.set(row.id, row);
|
|
285
|
+
const dream = await dreamPromise;
|
|
286
|
+
state.pendingIds = new Set(Array.isArray(dream?.pendingMemoryIds) ? dream.pendingMemoryIds : []);
|
|
287
|
+
const ordered = state.heatSort ? [...items].sort((a, b2) => num(b2.heat, -1) - num(a.heat, -1)) : items;
|
|
288
|
+
payload.rows = ordered.map(rowOf);
|
|
289
|
+
state.monthGroups = groupByMonth(ordered, ordered);
|
|
290
|
+
const [stats, conflicts] = await Promise.all([statsPromise, conflictsPromise]);
|
|
291
|
+
payload.injected = String(stats?.injection?.injectedCount ?? 0);
|
|
292
|
+
payload.slotRate = String(Math.round((stats?.injection?.slotFillRate ?? 0) * 1000) / 10);
|
|
293
|
+
payload.conflicts = String(conflicts?.total ?? 0);
|
|
294
|
+
// metric values render in one style, so units ride along in the value
|
|
295
|
+
payload.totalText = `${payload.total ?? "0"} 条`;
|
|
296
|
+
payload.injectedText = `${payload.injected} 次`;
|
|
297
|
+
payload.slotRateText = `${payload.slotRate}%`;
|
|
298
|
+
payload.conflictsText = `${payload.conflicts} 条`;
|
|
299
|
+
state.pendingConflicts = num(conflicts?.total, 0);
|
|
300
|
+
payload.pendingConflicts = state.pendingConflicts;
|
|
301
|
+
// 翻页用的是 chips(原生最小 48dp,两个一定同行),点完立刻把绑定清空,
|
|
302
|
+
// 免得选中的那枚一直带着勾。
|
|
303
|
+
payload.pagePick = "";
|
|
304
|
+
state.rowsCount = (payload.rows ?? []).length;
|
|
305
|
+
state.total = num(payload.total, state.rowsCount);
|
|
306
|
+
return payload;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function loadDetail() {
|
|
310
|
+
const memo = state.current ?? {};
|
|
311
|
+
const entities = await call(`/memories/entities?memoryId=${enc(memo.id ?? "")}`).then((res) => res?.entities ?? []);
|
|
312
|
+
state.detailEntityCount = entities.length;
|
|
313
|
+
state.detailConflict = state.pendingIds.has(memo.id);
|
|
314
|
+
return {
|
|
315
|
+
detailTitle: clip(memo.title ?? "(无标题)", 120),
|
|
316
|
+
detailMeta: [
|
|
317
|
+
{ label: "类型", value: typeLabel(memo.type) },
|
|
318
|
+
{ label: "重要度", value: `★${memo.importance ?? 1}` },
|
|
319
|
+
{ label: "归属", value: scopeText(memo) },
|
|
320
|
+
{ label: "更新", value: fmtTime(memo.updated_at ?? memo.created_at) },
|
|
321
|
+
{ label: "标签", value: clip(joinTags(memo.tags) || "—", 40) },
|
|
322
|
+
{ label: "来源", value: clip(memo.source ?? "—", 40) },
|
|
323
|
+
{ label: "状态", value: memo.archived ? "已归档" : "正常", tone: memo.archived ? "muted" : "good" },
|
|
324
|
+
],
|
|
325
|
+
detailContent: clip(memo.content ?? "", 2000),
|
|
326
|
+
detailType: typeLabel(memo.type),
|
|
327
|
+
detailTypeTone: typeTone(memo.type) ?? "info",
|
|
328
|
+
detailStars: `重要度 ★${memo.importance ?? 1}`,
|
|
329
|
+
detailEntities: entities.slice(0, 8).map((item) => ({
|
|
330
|
+
title: clip(item.name ?? item.title ?? item.id ?? "—", 40),
|
|
331
|
+
subtitle: clip(item.type ?? "实体", 20),
|
|
332
|
+
icon: "user",
|
|
333
|
+
})),
|
|
334
|
+
detailEntityCount: entities.length,
|
|
335
|
+
detailConflict: state.detailConflict,
|
|
336
|
+
provenanceText: state.provenanceText,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function loadEditor() {
|
|
341
|
+
return {
|
|
342
|
+
form: {
|
|
343
|
+
id: state.form.id ?? "",
|
|
344
|
+
title: state.form.title ?? "",
|
|
345
|
+
content: state.form.content ?? "",
|
|
346
|
+
type: state.form.type ?? "project",
|
|
347
|
+
importance: num(state.form.importance, 3),
|
|
348
|
+
tags: state.form.tags ?? "",
|
|
349
|
+
agentScope: state.form.agentScope ?? "",
|
|
350
|
+
workspaceScope: state.form.workspaceScope ?? "",
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function loadEntity() {
|
|
356
|
+
const needle = state.entityQuery.trim();
|
|
357
|
+
// 名字过滤交给宿主(/entities?q=):本地过滤只能看见 limit 那一条切片,
|
|
358
|
+
// 排在后面的实体搜不到(评审 #9)。类型数只在不搜索时重算——它代表整库
|
|
359
|
+
// 有几种类型,不该随搜索词一起抖动。
|
|
360
|
+
const [directory, matched] = await Promise.all([
|
|
361
|
+
needle === "" ? null : call("/entities?limit=200").catch(() => null),
|
|
362
|
+
call(`/entities?limit=${needle === "" ? 200 : 30}${needle === "" ? "" : `&q=${enc(needle)}`}`),
|
|
363
|
+
]);
|
|
364
|
+
const all = directory?.entities ?? matched?.entities ?? [];
|
|
365
|
+
state.entityTotal = num(matched?.total, all.length);
|
|
366
|
+
if (needle === "") {
|
|
367
|
+
const types = new Set();
|
|
368
|
+
for (const item of all) {
|
|
369
|
+
if (typeof item?.type === "string" && item.type !== "") types.add(item.type);
|
|
370
|
+
}
|
|
371
|
+
state.entityTypeTotal = types.size;
|
|
372
|
+
}
|
|
373
|
+
const shown = (needle === "" ? all : (matched?.entities ?? [])).slice(0, 30);
|
|
374
|
+
return {
|
|
375
|
+
entityTotal: String(state.entityTotal),
|
|
376
|
+
entityTypeTotal: String(state.entityTypeTotal),
|
|
377
|
+
entityTotalText: `${String(state.entityTotal)} 个`,
|
|
378
|
+
entityTypeTotalText: `${String(state.entityTypeTotal)} 类`,
|
|
379
|
+
entityQuery: state.entityQuery,
|
|
380
|
+
// 结构固定,空库的提示走这一行文案(见 views/entity.js 的注释)。
|
|
381
|
+
entityStatus:
|
|
382
|
+
shown.length === 0
|
|
383
|
+
? needle === ""
|
|
384
|
+
? "还没有实体 · 在「设置 › 记忆引擎」的功能开关里打开「抽取人物与项目」"
|
|
385
|
+
: `没有名字含「${clip(needle, 20)}」的实体`
|
|
386
|
+
: needle === ""
|
|
387
|
+
? `共 ${String(state.entityTotal)} 个实体`
|
|
388
|
+
: `搜索「${clip(needle, 20)}」· ${String(shown.length)} 个`,
|
|
389
|
+
entities: shown.map((item) => ({
|
|
390
|
+
title: clip(item.name ?? item.title ?? item.id ?? "—", 40),
|
|
391
|
+
subtitle: clip(entityTypeLabel(item.type ?? "—"), 20),
|
|
392
|
+
trailing: item.mention_count === undefined ? undefined : `${item.mention_count} 次`,
|
|
393
|
+
...(entityIcon(item.type) === undefined ? {} : { icon: entityIcon(item.type) }),
|
|
394
|
+
action: { id: "open-entity", args: { name: String(item.name ?? "") } },
|
|
395
|
+
})),
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function loadEntityDetail() {
|
|
400
|
+
const name = state.entityName;
|
|
401
|
+
const ego = await call(`/semantic/graph/ego?entity=${enc(name)}&depth=${state.entityDepth}&limit=40`);
|
|
402
|
+
const attrs = await call(`/semantic/graph/entity-attrs?entity=${enc(name)}`).catch(() => null);
|
|
403
|
+
const nodes = Array.isArray(ego?.nodes) ? ego.nodes : [];
|
|
404
|
+
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
405
|
+
const root = ego?.root ?? {};
|
|
406
|
+
const relations = (Array.isArray(ego?.edges) ? ego.edges : []).slice(0, 30).map((edge) => {
|
|
407
|
+
const from = byId.get(edge.from_entity);
|
|
408
|
+
const to = byId.get(edge.to_entity);
|
|
409
|
+
const other = edge.from_entity === root.id ? edge.to_entity : edge.from_entity;
|
|
410
|
+
const otherNode = byId.get(other);
|
|
411
|
+
return {
|
|
412
|
+
title: `${clip(from?.name ?? "—", 16)} → ${clip(to?.name ?? "—", 16)}`,
|
|
413
|
+
subtitle: clip(edge.relation_type ?? "关系", 24),
|
|
414
|
+
tone: "info",
|
|
415
|
+
...(otherNode === undefined
|
|
416
|
+
? {}
|
|
417
|
+
: { action: { id: "open-entity", args: { name: String(otherNode.name ?? "") } } }),
|
|
418
|
+
};
|
|
419
|
+
});
|
|
420
|
+
const memories = new Map();
|
|
421
|
+
for (const edge of Array.isArray(ego?.edges) ? ego.edges : []) {
|
|
422
|
+
if (typeof edge.memory_id === "string" && edge.memory_id !== "") {
|
|
423
|
+
const memo = cache.get(edge.memory_id);
|
|
424
|
+
memories.set(edge.memory_id, {
|
|
425
|
+
title: clip(memo?.title ?? `记忆 ${edge.memory_id.slice(0, 8)}`, 40),
|
|
426
|
+
subtitle: memo === undefined ? "未在本地缓存" : typeLabel(memo.type),
|
|
427
|
+
action: { id: "open", args: { id: edge.memory_id } },
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
state.entityRelationCount = relations.length;
|
|
432
|
+
state.entityMemoryCount = memories.size;
|
|
433
|
+
return {
|
|
434
|
+
entityName: clip(root.name ?? name, 40),
|
|
435
|
+
entityType: clip(entityTypeLabel(root.type ?? "实体"), 20),
|
|
436
|
+
entityMentionText: `被提到 ${String(root.mention_count ?? 0)} 次 · 关系 ${relations.length} 条`,
|
|
437
|
+
entityDepth: String(state.entityDepth),
|
|
438
|
+
entityAttrs: (Array.isArray(attrs?.attrs) ? attrs.attrs : []).slice(0, 24).map((attr) => ({
|
|
439
|
+
label: clip(attr.key ?? "—", 30),
|
|
440
|
+
value: clip(String(attr.value ?? "—"), 60),
|
|
441
|
+
})),
|
|
442
|
+
entityRelations: relations,
|
|
443
|
+
entityMemories: [...memories.values()].slice(0, 20),
|
|
444
|
+
entityRelationCount: relations.length,
|
|
445
|
+
entityMemoryCount: memories.size,
|
|
446
|
+
entityHint: "点关系可以跳到相邻实体;点记忆可以跳到详情。",
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async function loadSettings() {
|
|
451
|
+
const [features, mode] = await Promise.all([call("/features"), call("/mode")]);
|
|
452
|
+
const effective = features?.effective ?? {};
|
|
453
|
+
state.flags = effective;
|
|
454
|
+
state.flagOverrides = features?.overrides ?? {};
|
|
455
|
+
state.pendingRestart = Array.isArray(features?.pendingRestart) ? features.pendingRestart : [];
|
|
456
|
+
const changed = Object.keys(state.flagOverrides).length;
|
|
457
|
+
const pending = state.pendingRestart.length;
|
|
458
|
+
// 上游不断加旗标:没给中文名的布尔项仍要出现在面板上,否则新功能在手机上
|
|
459
|
+
// 静默消失。数值/字符串键(模型名、预算)不放这里——它们需要输入框,不是开关。
|
|
460
|
+
state.extraRows = Object.keys(effective)
|
|
461
|
+
.filter((key) => !FLAG_LABELS.has(key) && typeof effective[key] === "boolean")
|
|
462
|
+
.sort()
|
|
463
|
+
.map((key) => ({
|
|
464
|
+
title: key,
|
|
465
|
+
subtitle: effective[key] === true ? "已开启 · 点一下关闭" : "已关闭 · 点一下开启",
|
|
466
|
+
icon: effective[key] === true ? "toggle-on" : "toggle-off",
|
|
467
|
+
action: { id: "flag-set", args: { key } },
|
|
468
|
+
}));
|
|
469
|
+
const payload = {
|
|
470
|
+
extraRows: state.extraRows,
|
|
471
|
+
settingsView: state.settingsView,
|
|
472
|
+
sPanel: state.sPanel,
|
|
473
|
+
flagGroup: state.flagGroup,
|
|
474
|
+
flags: nestFlags(effective),
|
|
475
|
+
mode: String(mode?.mode ?? "standard"),
|
|
476
|
+
flagsMeta: `已改 ${changed} 项 · 待重启生效 ${pending} 项(改动即时写回 mneme,重启引擎后彻底生效)`,
|
|
477
|
+
};
|
|
478
|
+
if (state.sPanel === "menu") return payload;
|
|
479
|
+
if (state.sPanel === "workbench") {
|
|
480
|
+
const [deposited, archived] = await Promise.all([
|
|
481
|
+
call("/list?deposited=only&limit=8&order=chrono").catch(() => ({})),
|
|
482
|
+
call("/list?archived=only&limit=5&order=chrono").catch(() => ({})),
|
|
483
|
+
]);
|
|
484
|
+
const depItems = deposited?.items ?? [];
|
|
485
|
+
const arcItems = archived?.items ?? [];
|
|
486
|
+
for (const row of [...depItems, ...arcItems]) cache.set(row.id, row);
|
|
487
|
+
state.wbDeposited = depItems.map((row) => ({
|
|
488
|
+
title: clip(row.title, 50),
|
|
489
|
+
subtitle: `${typeLabel(row.type)} · ${fmtTime(row.updated_at)}`,
|
|
490
|
+
action: { id: "open", args: { id: row.id } },
|
|
491
|
+
}));
|
|
492
|
+
state.wbArchived = arcItems.map((row) => ({
|
|
493
|
+
title: clip(row.title, 50),
|
|
494
|
+
subtitle: fmtTime(row.updated_at),
|
|
495
|
+
trailing: "点按恢复",
|
|
496
|
+
action: { id: "wb-restore", args: { id: row.id } },
|
|
497
|
+
}));
|
|
498
|
+
Object.assign(payload, {
|
|
499
|
+
wbKv: [
|
|
500
|
+
{ label: "已沉淀", value: `${String(deposited?.total ?? depItems.length)} 条` },
|
|
501
|
+
{ label: "已归档", value: `${String(archived?.total ?? arcItems.length)} 条` },
|
|
502
|
+
],
|
|
503
|
+
wbDeposited: state.wbDeposited,
|
|
504
|
+
wbArchived: state.wbArchived,
|
|
505
|
+
});
|
|
506
|
+
} else if (state.sPanel === "commands") {
|
|
507
|
+
const res = await call("/commands").catch(() => ({ commands: [] }));
|
|
508
|
+
state.commands = Array.isArray(res?.commands) ? res.commands : [];
|
|
509
|
+
state.cmdCount = state.commands.length;
|
|
510
|
+
Object.assign(payload, {
|
|
511
|
+
cmdRows: state.commands.map((cmd) => ({
|
|
512
|
+
title: clip(cmd.name ?? cmd.id ?? "—", 30),
|
|
513
|
+
subtitle: clip(cmd.description ?? "", 60),
|
|
514
|
+
})),
|
|
515
|
+
cmdCount: state.cmdCount,
|
|
516
|
+
sCmdPick: state.sCmdPick,
|
|
517
|
+
sCmdName: state.sCmdName,
|
|
518
|
+
sCmdDesc: state.sCmdDesc,
|
|
519
|
+
sCmdIns: state.sCmdIns,
|
|
520
|
+
});
|
|
521
|
+
} else if (state.sPanel === "vector") {
|
|
522
|
+
const res = await call("/vector-config").catch(() => null);
|
|
523
|
+
// 读到才算 loaded:读失败时下面会落成默认值,不能再拿它整体覆盖
|
|
524
|
+
// 服务端配置(评审 #2)。
|
|
525
|
+
state.vectorLoaded = res !== null && typeof res === "object" && res.config !== null && typeof res.config === "object";
|
|
526
|
+
const config = res?.config ?? {};
|
|
527
|
+
state.vector = {
|
|
528
|
+
enabled: config.enabled === true,
|
|
529
|
+
baseUrl: String(config.baseUrl ?? ""),
|
|
530
|
+
model: String(config.model ?? ""),
|
|
531
|
+
};
|
|
532
|
+
const keySet = typeof config.apiKey === "string" && config.apiKey !== "";
|
|
533
|
+
Object.assign(payload, {
|
|
534
|
+
sVecEnabled: state.vector.enabled,
|
|
535
|
+
sVecUrl: state.vector.baseUrl,
|
|
536
|
+
sVecModel: state.vector.model,
|
|
537
|
+
vectorKv: [
|
|
538
|
+
{
|
|
539
|
+
label: "状态",
|
|
540
|
+
value: state.vector.enabled ? "已启用" : "未启用",
|
|
541
|
+
tone: state.vector.enabled ? "good" : "muted",
|
|
542
|
+
},
|
|
543
|
+
{ label: "接口", value: state.vector.baseUrl === "" ? "未配置" : clip(state.vector.baseUrl, 60) },
|
|
544
|
+
{ label: "模型", value: state.vector.model === "" ? "未配置" : state.vector.model },
|
|
545
|
+
{ label: "密钥", value: keySet ? "已配置(面板不显示)" : "未配置" },
|
|
546
|
+
],
|
|
547
|
+
vectorNote: "搜索不再只认字面:接入任意 OpenAI 兼容的 embedding 接口后,意思相近的记忆也能被召回。",
|
|
548
|
+
});
|
|
549
|
+
} else if (state.sPanel === "external") {
|
|
550
|
+
// 读一次真实配置,否则面板永远拿默认值渲染,保存时又把默认值写回去,
|
|
551
|
+
// 把用户已经在 Web 面板里配好的端口/地址覆盖掉(评审 #6)。
|
|
552
|
+
// 必须带 token=omit:不带的话这条 GET 会顺手生成并落库一个 Bearer。
|
|
553
|
+
const res = await call("/external-api?token=omit").catch(() => null);
|
|
554
|
+
state.externalLoaded = res !== null && typeof res === "object" && res.config !== null && typeof res.config === "object";
|
|
555
|
+
const config = res?.config ?? {};
|
|
556
|
+
state.external = {
|
|
557
|
+
enabled: config.enabled === true,
|
|
558
|
+
port: num(config.port, 8790),
|
|
559
|
+
host: typeof config.host === "string" && config.host !== "" ? config.host : "127.0.0.1",
|
|
560
|
+
};
|
|
561
|
+
Object.assign(payload, {
|
|
562
|
+
sExtEnabled: state.external.enabled,
|
|
563
|
+
sExtPort: String(state.external.port),
|
|
564
|
+
sExtHost: state.external.host,
|
|
565
|
+
});
|
|
566
|
+
} else if (state.sPanel === "facts") {
|
|
567
|
+
// 「Agent 眼中的你」:一次请求喂饱这张卡(facts / journal / runs)。flag 关着
|
|
568
|
+
// 也照读——历史上开过又关掉的库里事实还在,得让人看得见。
|
|
569
|
+
const query = state.factsAgent === "" ? "" : `?agent=${encodeURIComponent(state.factsAgent)}`;
|
|
570
|
+
const facts = await call(`/profile-facts${query}`).catch(() => ({}));
|
|
571
|
+
state.factsCards = Array.isArray(facts?.cards) ? facts.cards.map(String) : [];
|
|
572
|
+
const rows = Array.isArray(facts?.facts) ? facts.facts : [];
|
|
573
|
+
state.factsItems = rows;
|
|
574
|
+
const active = rows.filter((fact) => fact.status !== "archived");
|
|
575
|
+
state.factsRows = rows.slice(0, 20).map((fact) => ({
|
|
576
|
+
title: clip(String(fact.content ?? ""), 60),
|
|
577
|
+
subtitle:
|
|
578
|
+
fact.status === "archived"
|
|
579
|
+
? `已归档 · ${fmtTime(fact.updated_at)}`
|
|
580
|
+
: `更新于 ${fmtTime(fact.updated_at)}`,
|
|
581
|
+
trailing: fact.status === "archived" ? "已归档" : "点一下修改",
|
|
582
|
+
action: { id: "facts-edit", args: { id: String(fact.id ?? "") } },
|
|
583
|
+
}));
|
|
584
|
+
const runs = Array.isArray(facts?.runs) ? facts.runs : [];
|
|
585
|
+
const last = runs[0];
|
|
586
|
+
state.factsRunText =
|
|
587
|
+
last === undefined
|
|
588
|
+
? "还没跑过维护轮。点「立即更新」会让模型读最近的记忆补事实。"
|
|
589
|
+
: `最近一轮:${String(last.status ?? "?")} · 改 ${String(last.ops_applied ?? 0)} 条 · 读 ${String(last.input_count ?? 0)} 条记忆 · ${fmtTime(last.started_at)}`;
|
|
590
|
+
Object.assign(payload, {
|
|
591
|
+
factsAgent: state.factsAgent,
|
|
592
|
+
factsCardOptions: [
|
|
593
|
+
{ label: "默认", value: "" },
|
|
594
|
+
// 宿主给的 cards 里常含空串(默认卡),别再和「默认」重复一次。
|
|
595
|
+
...state.factsCards.filter((key) => key !== "").map((key) => ({ label: key, value: key })),
|
|
596
|
+
],
|
|
597
|
+
factsRows: state.factsRows,
|
|
598
|
+
factsRunText: state.factsRunText,
|
|
599
|
+
sFactNew: state.form.factNew ?? "",
|
|
600
|
+
sFactEdit: state.factEdit?.content ?? "",
|
|
601
|
+
factsEditHint:
|
|
602
|
+
state.factEdit === null
|
|
603
|
+
? ""
|
|
604
|
+
: `正在修改:${clip(String(state.factEdit.content ?? ""), 40)}`,
|
|
605
|
+
});
|
|
606
|
+
if (active.length === 0) state.factsRows.push({ title: "还没有事实", subtitle: "点下面的「立即更新」让模型生成一批。" });
|
|
607
|
+
} else if (state.sPanel === "choro") {
|
|
608
|
+
// 地志是「按仓库」的:先列已登记仓库,再列该仓库的模块骨架页。
|
|
609
|
+
const repos = await call("/choro/repos").catch(() => ({}));
|
|
610
|
+
state.choroRepos = (Array.isArray(repos?.repos) ? repos.repos : []).map((repo) => ({
|
|
611
|
+
id: String(repo.id ?? ""),
|
|
612
|
+
name: clip(String(repo.name ?? repo.root_path ?? repo.id ?? ""), 40),
|
|
613
|
+
}));
|
|
614
|
+
if (state.choroRepo === "" && state.choroRepos.length > 0) state.choroRepo = state.choroRepos[0].id;
|
|
615
|
+
const known = state.choroRepos.some((repo) => repo.id === state.choroRepo);
|
|
616
|
+
if (!known) state.choroRepo = state.choroRepos[0]?.id ?? "";
|
|
617
|
+
let note = "先在地志里登记仓库(命令行 dsh-mneme-choro <repo> --apply)。";
|
|
618
|
+
let rows = [];
|
|
619
|
+
if (state.choroRepo !== "") {
|
|
620
|
+
const map = await call(`/choro/modules?repo=${encodeURIComponent(state.choroRepo)}`).catch(() => ({}));
|
|
621
|
+
const modules = Array.isArray(map?.modules) ? map.modules : [];
|
|
622
|
+
rows = modules.slice(0, 30).map((item) => ({
|
|
623
|
+
title: clip(String(item.title ?? item.slug ?? ""), 40),
|
|
624
|
+
subtitle: `${String(item.file_count ?? 0)} 个文件 · ${String(item.page_status ?? "无页")}`,
|
|
625
|
+
trailing: "看页",
|
|
626
|
+
action: { id: "choro-page", args: { slug: String(item.slug ?? "") } },
|
|
627
|
+
}));
|
|
628
|
+
const repo = state.choroRepos.find((item) => item.id === state.choroRepo);
|
|
629
|
+
const baseline = map?.repo?.gitBaseline;
|
|
630
|
+
note =
|
|
631
|
+
modules.length === 0
|
|
632
|
+
? "这个仓库还没有骨架页:点下面的「重建骨架」生成一次。"
|
|
633
|
+
: `${modules.length} 个模块${baseline ? ` · 基线 ${String(baseline).slice(0, 7)}` : ""} · ${repo?.name ?? ""}`;
|
|
634
|
+
}
|
|
635
|
+
state.choroRows = rows;
|
|
636
|
+
state.choroNote = note;
|
|
637
|
+
Object.assign(payload, {
|
|
638
|
+
choroRepo: state.choroRepo,
|
|
639
|
+
choroRepoOptions:
|
|
640
|
+
state.choroRepos.length === 0
|
|
641
|
+
? [{ label: "还没有登记任何仓库", value: "" }]
|
|
642
|
+
: state.choroRepos.map((repo) => ({ label: repo.name, value: repo.id })),
|
|
643
|
+
choroRows: state.choroRows,
|
|
644
|
+
choroNote: state.choroNote,
|
|
645
|
+
choroPageText: state.choroPageText,
|
|
646
|
+
});
|
|
647
|
+
} else if (state.sPanel === "help") {
|
|
648
|
+
const info = await call("/info").catch(() => ({}));
|
|
649
|
+
Object.assign(payload, {
|
|
650
|
+
helpKv: [
|
|
651
|
+
{ label: "插件", value: `@modusensus/dsh-mneme ${String(info?.version ?? "—")}` },
|
|
652
|
+
{ label: "记忆库", value: "本机 SQLite(与本机 Web 面板同一份,不跨设备同步)" },
|
|
653
|
+
{ label: "面板", value: "dsh-go 原生面板" },
|
|
654
|
+
],
|
|
655
|
+
versionNote: state.versionNote,
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
return payload;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async function loadProfile() {
|
|
662
|
+
if (state.profileTab === "persona") {
|
|
663
|
+
const res = await call("/persona");
|
|
664
|
+
const persona = res?.persona ?? {};
|
|
665
|
+
const preview = res?.preview ?? {};
|
|
666
|
+
return {
|
|
667
|
+
personaText: clip(
|
|
668
|
+
JSON.stringify({ default: persona.default ?? {}, byAgent: persona.byAgent ?? {} }, null, 2),
|
|
669
|
+
700,
|
|
670
|
+
),
|
|
671
|
+
previewItems: [
|
|
672
|
+
{ label: "生效来源", value: String(preview.source ?? "—") },
|
|
673
|
+
{ label: "注入内容", value: clip(preview.text === "" ? "(空)" : preview.text, 300) },
|
|
674
|
+
],
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
if (state.profileTab === "rules") {
|
|
678
|
+
const res = await call("/rules");
|
|
679
|
+
return { editText: clip(JSON.stringify(res?.rules ?? [], null, 2), 2000) };
|
|
680
|
+
}
|
|
681
|
+
const res = await call("/profile");
|
|
682
|
+
return { editText: clip(res?.profile ?? "", 2000), extractText: state.extractText };
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
async function loadConflict() {
|
|
686
|
+
const res = await call("/conflicts");
|
|
687
|
+
const items = Array.isArray(res?.items) ? res.items : [];
|
|
688
|
+
state.conflictCount = num(res?.total, items.length);
|
|
689
|
+
const rows = items.slice(0, 20).map((item) => ({
|
|
690
|
+
title: clip(item.a?.title ?? item.b?.title ?? item.id ?? "—", 40),
|
|
691
|
+
subtitle: `A:${clip(item.a?.title ?? "—", 20)} · B:${clip(item.b?.title ?? "—", 20)}`,
|
|
692
|
+
tone: "warn",
|
|
693
|
+
action: { id: "conflict-pick-row", args: { id: String(item.id ?? "") } },
|
|
694
|
+
}));
|
|
695
|
+
const selected = items.find((item) => item.id === state.conflictId) ?? null;
|
|
696
|
+
state.conflictA = selected?.a ?? null;
|
|
697
|
+
state.conflictB = selected?.b ?? null;
|
|
698
|
+
state.conflictReady = selected !== null;
|
|
699
|
+
const side = (memo) =>
|
|
700
|
+
memo === null
|
|
701
|
+
? []
|
|
702
|
+
: [
|
|
703
|
+
{ label: "类型", value: typeLabel(memo.type) },
|
|
704
|
+
{ label: "重要度", value: `★${memo.importance ?? 1}` },
|
|
705
|
+
{ label: "更新", value: fmtTime(memo.updated_at ?? memo.created_at) },
|
|
706
|
+
];
|
|
707
|
+
return {
|
|
708
|
+
conflictRows: rows,
|
|
709
|
+
conflictCount: state.conflictCount,
|
|
710
|
+
conflictReady: state.conflictReady,
|
|
711
|
+
conflictPick: state.conflictPick,
|
|
712
|
+
conflictATitle: clip(state.conflictA?.title ?? "", 120),
|
|
713
|
+
conflictAMeta: side(state.conflictA),
|
|
714
|
+
conflictAContent: clip(state.conflictA?.content ?? "", 800),
|
|
715
|
+
conflictBTitle: clip(state.conflictB?.title ?? "", 120),
|
|
716
|
+
conflictBMeta: side(state.conflictB),
|
|
717
|
+
conflictBContent: clip(state.conflictB?.content ?? "", 800),
|
|
718
|
+
pendingConflicts: state.conflictCount,
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async function loadStatus() {
|
|
723
|
+
const [info, health, dream, inject, stats, conflicts, list, entities, mode, preview, audit, features, recent] =
|
|
724
|
+
await Promise.all([
|
|
725
|
+
call("/info").catch(() => ({})),
|
|
726
|
+
call("/health").catch(() => ({})),
|
|
727
|
+
call("/dream-status").catch(() => ({})),
|
|
728
|
+
call("/inject-status").catch(() => ({})),
|
|
729
|
+
call("/recall-stats?window=30").catch(() => ({})),
|
|
730
|
+
call("/conflicts").catch(() => ({})),
|
|
731
|
+
call("/list?limit=1").catch(() => ({})),
|
|
732
|
+
call("/entities?limit=1").catch(() => ({})),
|
|
733
|
+
call("/mode").catch(() => ({})),
|
|
734
|
+
call("/inject-preview").catch(() => ({})),
|
|
735
|
+
call("/semantic/llm-audit/stats?days=28").catch(() => ({})),
|
|
736
|
+
call("/features").catch(() => ({})),
|
|
737
|
+
call("/list?limit=200&order=chrono").catch(() => ({})),
|
|
738
|
+
]);
|
|
739
|
+
const run = dream?.lastRun ?? {};
|
|
740
|
+
const mirror = health?.mirror ?? {};
|
|
741
|
+
const snapshot = preview?.snapshot ?? null;
|
|
742
|
+
state.injectPreviewText =
|
|
743
|
+
snapshot === null
|
|
744
|
+
? "还没有渲染过注入预览:下一轮对话开始时 mneme 会写一份快照。"
|
|
745
|
+
: clip(typeof snapshot === "string" ? snapshot : JSON.stringify(snapshot), 500);
|
|
746
|
+
const byDay = Array.isArray(audit?.by_day) ? audit.by_day : [];
|
|
747
|
+
state.tokenCategories = byDay.map((day) => fmtDay(day.day));
|
|
748
|
+
state.tokenSeries = byDay.length === 0 ? [] : [{ name: "调用", values: byDay.map((day) => num(day.count, 0)) }];
|
|
749
|
+
state.tokenKv = [
|
|
750
|
+
{ label: "调用次数", value: `${String(audit?.total_calls ?? 0)} 次` },
|
|
751
|
+
{ label: "Token", value: String(audit?.total_tokens ?? 0) },
|
|
752
|
+
{ label: "估算成本", value: `$${Number(audit?.total_cost_usd ?? 0).toFixed(4)}` },
|
|
753
|
+
];
|
|
754
|
+
const heatEnabled = (features?.effective ?? {}).heatEnabled === true;
|
|
755
|
+
const heated = (recent?.items ?? []).filter((row) => typeof row.heat === "number");
|
|
756
|
+
if (heatEnabled && heated.length > 0) {
|
|
757
|
+
const hot = heated.filter((row) => row.heat >= 0.7).length;
|
|
758
|
+
const warm = heated.filter((row) => row.heat >= 0.3 && row.heat < 0.7).length;
|
|
759
|
+
state.heatSeries = [{ name: "条数", values: [hot, warm, heated.length - hot - warm] }];
|
|
760
|
+
state.heatNote = `统计最近 ${heated.length} 条记忆的热度。`;
|
|
761
|
+
} else {
|
|
762
|
+
state.heatSeries = [];
|
|
763
|
+
state.heatNote = "mneme 没有开启“热度衰减”,所以看不到热度分布。";
|
|
764
|
+
}
|
|
765
|
+
state.pendingConflicts = num(conflicts?.total, 0);
|
|
766
|
+
return {
|
|
767
|
+
// metric cards live on 状态, mirroring mneme's own Status page
|
|
768
|
+
totalText: `${String(list?.total ?? 0)} 条`,
|
|
769
|
+
injectedText: `${String(stats?.injection?.injectedCount ?? 0)} 次`,
|
|
770
|
+
slotRateText: `${String(Math.round((stats?.injection?.slotFillRate ?? 0) * 1000) / 10)}%`,
|
|
771
|
+
conflictsText: `${String(conflicts?.total ?? 0)} 条`,
|
|
772
|
+
statusKv: [
|
|
773
|
+
{ label: "mneme 版本", value: String(info?.version ?? "—") },
|
|
774
|
+
{ label: "记忆", value: `${String(list?.total ?? 0)} 条` },
|
|
775
|
+
{ label: "实体", value: `${String(entities?.total ?? 0)} 个` },
|
|
776
|
+
{ label: "运行模式", value: String(mode?.mode ?? "—") },
|
|
777
|
+
{ label: "镜像", value: String(mirror.status ?? "—"), tone: mirror.status === "ok" ? "good" : "warn" },
|
|
778
|
+
{ label: "上次成功", value: fmtTime(mirror.success_at) },
|
|
779
|
+
],
|
|
780
|
+
injectKv: [
|
|
781
|
+
{
|
|
782
|
+
label: "自动注入",
|
|
783
|
+
value: inject?.autoInject ? "已开启" : "已关闭",
|
|
784
|
+
tone: inject?.autoInject ? "good" : "muted",
|
|
785
|
+
},
|
|
786
|
+
{ label: "注入抑制", value: inject?.suppressed ? "是" : "否" },
|
|
787
|
+
{ label: "会话预设", value: String(inject?.agentPreset ?? "—") },
|
|
788
|
+
{ label: "近 30 天注入", value: `${String(stats?.injection?.injectedCount ?? 0)} 次` },
|
|
789
|
+
{ label: "命中率", value: `${String(Math.round((stats?.injection?.slotFillRate ?? 0) * 1000) / 10)}%` },
|
|
790
|
+
],
|
|
791
|
+
topRows: (stats?.topRecalled ?? []).slice(0, 5).map((row) => ({
|
|
792
|
+
title: clip(row.title, 40),
|
|
793
|
+
subtitle: typeLabel(row.type),
|
|
794
|
+
trailing: `${row.count} 次`,
|
|
795
|
+
})),
|
|
796
|
+
dreamKv: [
|
|
797
|
+
{ label: "上次巩固", value: fmtTime(dream?.lastConsolidation) },
|
|
798
|
+
{ label: "上次运行", value: `${fmtTime(run.created_at)} ${run.status ?? "—"}` },
|
|
799
|
+
{
|
|
800
|
+
label: "待确认",
|
|
801
|
+
value: `${String(conflicts?.total ?? 0)} 条`,
|
|
802
|
+
tone: num(conflicts?.total, 0) > 0 ? "warn" : "good",
|
|
803
|
+
},
|
|
804
|
+
],
|
|
805
|
+
runRows: (dream?.runs ?? []).slice(0, 5).map((row) => ({
|
|
806
|
+
title: `${fmtTime(row.created_at)} · ${String(row.status ?? "—")}`,
|
|
807
|
+
subtitle: clip(row.summary ?? row.mode ?? "", 40),
|
|
808
|
+
})),
|
|
809
|
+
injectPreviewText: state.injectPreviewText,
|
|
810
|
+
tokenCategories: state.tokenCategories,
|
|
811
|
+
tokenSeries: state.tokenSeries,
|
|
812
|
+
tokenKv: state.tokenKv,
|
|
813
|
+
heatSeries: state.heatSeries,
|
|
814
|
+
heatNote: state.heatNote,
|
|
815
|
+
reclaimText: state.reclaimText,
|
|
816
|
+
reclaimConfirm: state.reclaimConfirm,
|
|
817
|
+
pendingConflicts: state.pendingConflicts,
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// The app's `tabs` bar switches locally, so the payload always carries every
|
|
822
|
+
// section (a binding whose key is missing renders as 「—」). That does not mean
|
|
823
|
+
// every section must be re-read after every tap: 实体 / 设置 / 状态 only change
|
|
824
|
+
// while their own page is open, so a tap on the list used to cost four reads
|
|
825
|
+
// (评审 #14). Load the active page's section, keep the rest from the previous
|
|
826
|
+
// payload; a full read happens on mount.
|
|
827
|
+
async function load(options = {}) {
|
|
828
|
+
const full = options.full === true;
|
|
829
|
+
const payload = {
|
|
830
|
+
page: state.page,
|
|
831
|
+
profileTab: state.profileTab,
|
|
832
|
+
notice: state.notice,
|
|
833
|
+
err: state.err,
|
|
834
|
+
};
|
|
835
|
+
const jobs = [];
|
|
836
|
+
const merge = (promise) => jobs.push(promise.then((part) => Object.assign(payload, part)));
|
|
837
|
+
const page = state.page;
|
|
838
|
+
const memoryPage = !["entity", "settings", "profile"].includes(page);
|
|
839
|
+
if (full || memoryPage) {
|
|
840
|
+
const memory = page === "detail" ? loadDetail() : page === "editor" ? loadEditor() : loadMemory();
|
|
841
|
+
merge(Promise.resolve(memory));
|
|
842
|
+
}
|
|
843
|
+
const entityPage = page === "entity";
|
|
844
|
+
// 实体搜索要重读实体清单;但搜索后 state.page 仍是 "list"(视图里实体
|
|
845
|
+
// 区就是列表),旧条件只看页面,于是搜索完推的仍是搜索前那份(评审 #4)。
|
|
846
|
+
if (full || entityPage || state.entityDirty) merge(entityPage ? loadEntityDetail() : loadEntity());
|
|
847
|
+
state.entityDirty = false;
|
|
848
|
+
if (full || page === "settings" || page === "profile" || state.settingsDirty) {
|
|
849
|
+
merge(loadSettings());
|
|
850
|
+
merge(loadStatus());
|
|
851
|
+
}
|
|
852
|
+
state.settingsDirty = false;
|
|
853
|
+
if (full || (memoryPage && page === "conflict")) merge(loadConflict());
|
|
854
|
+
if (full || page === "profile") merge(loadProfile());
|
|
855
|
+
const settled = await Promise.allSettled(jobs);
|
|
856
|
+
const failed = settled.find((item) => item.status === "rejected");
|
|
857
|
+
if (failed !== undefined) payload.err = errorText(failed.reason);
|
|
858
|
+
// 未重新读取的块沿用上一次的完整结果;刷新过的那块覆盖上去。
|
|
859
|
+
const merged = lastPayload === null ? payload : { ...lastPayload, ...payload };
|
|
860
|
+
lastPayload = merged;
|
|
861
|
+
return merged;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/** Re-run the loaders and push the result into every live panel feed. */
|
|
865
|
+
async function broadcast() {
|
|
866
|
+
if (emitters.size === 0) return;
|
|
867
|
+
const payload = await load();
|
|
868
|
+
for (const emit of [...emitters]) {
|
|
869
|
+
try {
|
|
870
|
+
emit(payload);
|
|
871
|
+
} catch {
|
|
872
|
+
// a closing panel detaches its emitter; nothing to do
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// ---------------------------------------------------------------- actions
|
|
878
|
+
function goMemory(page) {
|
|
879
|
+
state.page = page;
|
|
880
|
+
state.err = "";
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function nextValue(list, current, value) {
|
|
884
|
+
const picked = typeof value === "string" && list.includes(value) ? value : null;
|
|
885
|
+
if (picked !== null) return picked;
|
|
886
|
+
const index = list.indexOf(current);
|
|
887
|
+
return list[(index + 1) % list.length];
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
async function applyArchived(archived, event, scope) {
|
|
891
|
+
const memo = state.current;
|
|
892
|
+
if (memo === null) return { toast: "先选一条记忆" };
|
|
893
|
+
// 归档/恢复改的是「正在看」的那一条,参数只能从状态里拿;状态是模块级
|
|
894
|
+
// 共享的,两个面板各开一条时后开的会盖掉前一个的 current。与删除同一条
|
|
895
|
+
// 规矩:只动本面板自己打开的那条,对不上就拒绝(评审 #10)。
|
|
896
|
+
const mine = openedBy.get(scopeKey(scope));
|
|
897
|
+
if (mine === undefined || mine !== memo.id) {
|
|
898
|
+
return { toast: "这条不是当前打开的记忆,已取消" };
|
|
899
|
+
}
|
|
900
|
+
try {
|
|
901
|
+
const res = await call("/update", { method: "POST", body: { id: mine, archived } });
|
|
902
|
+
if (res?.memory !== null && typeof res.memory === "object") {
|
|
903
|
+
state.current = res.memory;
|
|
904
|
+
openedBy.set(scopeKey(scope), String(res.memory.id ?? mine));
|
|
905
|
+
}
|
|
906
|
+
notice(archived ? "已归档。" : "已恢复。");
|
|
907
|
+
} catch (error) {
|
|
908
|
+
state.err = errorText(error);
|
|
909
|
+
}
|
|
910
|
+
return {};
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// 编辑页的比对基准:点「编辑」时看到的那一份快照。冲突后必须用最新的
|
|
914
|
+
// 记忆重建它,否则下一次保存会因为 base 为 null 而整份提交、直接跳过版本
|
|
915
|
+
// 检查(评审 #11)。
|
|
916
|
+
function editBaseOf(memo) {
|
|
917
|
+
return {
|
|
918
|
+
title: String(memo.title ?? ""),
|
|
919
|
+
content: String(memo.content ?? ""),
|
|
920
|
+
importance: num(memo.importance, 3),
|
|
921
|
+
tags: joinTags(memo.tags),
|
|
922
|
+
agentScope: typeof memo.agent_scope === "string" ? memo.agent_scope : "",
|
|
923
|
+
workspaceScope: typeof memo.workspace_scope === "string" ? memo.workspace_scope : "",
|
|
924
|
+
updatedAt: typeof memo.updated_at === "string" ? memo.updated_at : "",
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// 每次打开都回宿主重读,而不是先看 cache:cache 是列表加载时写下的,
|
|
929
|
+
// 可能已经几分钟没更新;编辑页会拿它预填表单,随后整份提交回 /update,
|
|
930
|
+
// 用陈旧内容覆盖掉工具或 Web 面板刚写进去的新值(评审 #4)。
|
|
931
|
+
// /list 支持按 id 取单条(归档行也取得到),不再用 limit=500 扫列表再拿缓存
|
|
932
|
+
// 兜底:500 条之外或已归档的条目根本扫不到,兜底等于拿旧值去比对 updated_at
|
|
933
|
+
// (评审 #12)。网络失败就让它抛,调用处会报错,绝不悄悄退回缓存。
|
|
934
|
+
async function openMemoryById(id) {
|
|
935
|
+
const res = await call(`/list?id=${encodeURIComponent(String(id))}`);
|
|
936
|
+
const row = (res?.items ?? [])[0];
|
|
937
|
+
if (row === undefined) {
|
|
938
|
+
cache.delete(id);
|
|
939
|
+
return undefined;
|
|
940
|
+
}
|
|
941
|
+
cache.set(row.id, row);
|
|
942
|
+
return row;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const actions = {
|
|
946
|
+
"switch-group"(scope, event) {
|
|
947
|
+
resetNotice();
|
|
948
|
+
const keys = ["basic", "record", "write", "workflow", "retrieval", "scope", "extra"];
|
|
949
|
+
state.flagGroup = nextValue(keys, state.flagGroup, valueOf(event, ["group", "value", "k"], ""));
|
|
950
|
+
state.page = "settings";
|
|
951
|
+
return {};
|
|
952
|
+
},
|
|
953
|
+
|
|
954
|
+
"switch-panel"(scope, event) {
|
|
955
|
+
resetNotice();
|
|
956
|
+
const keys = ["menu", "workbench", "commands", "vector", "external", "help", "facts", "choro"];
|
|
957
|
+
state.sPanel = nextValue(keys, state.sPanel, valueOf(event, ["value", "panel", "k"], ""));
|
|
958
|
+
state.page = "settings";
|
|
959
|
+
return {};
|
|
960
|
+
},
|
|
961
|
+
|
|
962
|
+
"open-panel"(scope, event) {
|
|
963
|
+
resetNotice();
|
|
964
|
+
// 功能开关已经铺在设置页正文里,不再有 flags 子页面(#dsh-go 面板对齐电脑端分组)
|
|
965
|
+
const keys = ["workbench", "commands", "vector", "external", "help", "facts", "choro"];
|
|
966
|
+
const wanted = String(valueOf(event, ["k", "panel", "value"], "") ?? "");
|
|
967
|
+
state.sPanel = keys.includes(wanted) ? wanted : "menu";
|
|
968
|
+
state.page = "settings";
|
|
969
|
+
// 子页面画在「设置」视图里:从「状态」视图点过来时若不把视图切回去,
|
|
970
|
+
// 看到的还是状态页,什么都没有发生(评审 #14)。
|
|
971
|
+
state.settingsView = "settings";
|
|
972
|
+
return {};
|
|
973
|
+
},
|
|
974
|
+
|
|
975
|
+
"switch-settings-view"(scope, event) {
|
|
976
|
+
resetNotice();
|
|
977
|
+
state.settingsView = nextValue(["settings", "status"], state.settingsView, valueOf(event, ["value"], ""));
|
|
978
|
+
if (state.settingsView === "settings") state.page = "settings";
|
|
979
|
+
return {};
|
|
980
|
+
},
|
|
981
|
+
|
|
982
|
+
"open-profile"(scope, event) {
|
|
983
|
+
resetNotice();
|
|
984
|
+
const wanted = String(valueOf(event, ["k", "tab", "value"], state.profileTab) ?? "");
|
|
985
|
+
state.profileTab = ["profile", "rules", "persona"].includes(wanted) ? wanted : "profile";
|
|
986
|
+
state.page = "profile";
|
|
987
|
+
return {};
|
|
988
|
+
},
|
|
989
|
+
|
|
990
|
+
"open-conflict"() {
|
|
991
|
+
resetNotice();
|
|
992
|
+
state.page = "conflict";
|
|
993
|
+
// 冲突页同样属于「设置」视图(入口在设置页的状态区里),视图停在状态
|
|
994
|
+
// 视图时切不到它(评审 #14)。
|
|
995
|
+
state.settingsView = "settings";
|
|
996
|
+
return {};
|
|
997
|
+
},
|
|
998
|
+
|
|
999
|
+
"clear-filter"() {
|
|
1000
|
+
resetNotice();
|
|
1001
|
+
state.q = "";
|
|
1002
|
+
state.type = "all";
|
|
1003
|
+
state.range = "all";
|
|
1004
|
+
state.minImportance = 0;
|
|
1005
|
+
state.scope = "all";
|
|
1006
|
+
state.semantic = false;
|
|
1007
|
+
state.heatSort = false;
|
|
1008
|
+
state.offset = 0;
|
|
1009
|
+
goMemory("list");
|
|
1010
|
+
return {};
|
|
1011
|
+
},
|
|
1012
|
+
|
|
1013
|
+
"set-range"(scope, event) {
|
|
1014
|
+
resetNotice();
|
|
1015
|
+
state.range = nextValue(["all", "7d", "30d", "90d"], state.range, valueOf(event, ["value"], ""));
|
|
1016
|
+
state.offset = 0;
|
|
1017
|
+
goMemory("list");
|
|
1018
|
+
return {};
|
|
1019
|
+
},
|
|
1020
|
+
|
|
1021
|
+
"set-minimp"(scope, event) {
|
|
1022
|
+
resetNotice();
|
|
1023
|
+
const picked = String(valueOf(event, ["value"], "0") ?? "0");
|
|
1024
|
+
state.minImportance = ["0", "3", "4", "5"].includes(picked) ? Number(picked) : 0;
|
|
1025
|
+
state.offset = 0;
|
|
1026
|
+
goMemory("list");
|
|
1027
|
+
return {};
|
|
1028
|
+
},
|
|
1029
|
+
|
|
1030
|
+
"set-scope"(scope, event) {
|
|
1031
|
+
resetNotice();
|
|
1032
|
+
state.scope = nextValue(["all", "deposited", "archived", "foreign"], state.scope, valueOf(event, ["value"], ""));
|
|
1033
|
+
state.offset = 0;
|
|
1034
|
+
goMemory("list");
|
|
1035
|
+
return {};
|
|
1036
|
+
},
|
|
1037
|
+
|
|
1038
|
+
"set-view"(scope, event) {
|
|
1039
|
+
resetNotice();
|
|
1040
|
+
state.viewMode = nextValue(["flat", "month"], state.viewMode, valueOf(event, ["value"], ""));
|
|
1041
|
+
return {};
|
|
1042
|
+
},
|
|
1043
|
+
|
|
1044
|
+
"toggle-semantic"(scope, event) {
|
|
1045
|
+
resetNotice();
|
|
1046
|
+
const value = event?.value;
|
|
1047
|
+
state.semantic = typeof value === "boolean" ? value : !state.semantic;
|
|
1048
|
+
return {};
|
|
1049
|
+
},
|
|
1050
|
+
|
|
1051
|
+
"toggle-heat"(scope, event) {
|
|
1052
|
+
resetNotice();
|
|
1053
|
+
const value = event?.value;
|
|
1054
|
+
state.heatSort = typeof value === "boolean" ? value : !state.heatSort;
|
|
1055
|
+
return {};
|
|
1056
|
+
},
|
|
1057
|
+
|
|
1058
|
+
async provenance() {
|
|
1059
|
+
resetNotice();
|
|
1060
|
+
const memo = state.current;
|
|
1061
|
+
if (memo === null) return { toast: "先选一条记忆" };
|
|
1062
|
+
try {
|
|
1063
|
+
const res = await call(`/memory/provenance?id=${enc(memo.id)}&limit=5`);
|
|
1064
|
+
const windows = Array.isArray(res?.windows) ? res.windows : [];
|
|
1065
|
+
if (windows.length === 0) {
|
|
1066
|
+
state.provenanceText = "mneme 里没有这条记忆的蒸馏来源记录。";
|
|
1067
|
+
} else {
|
|
1068
|
+
const first = windows[0] ?? {};
|
|
1069
|
+
state.provenanceText = clip(
|
|
1070
|
+
`${windows.length} 个来源窗口 · 最近来自会话 ${String(first.session_id ?? "—").slice(0, 12)}(${fmtTime(first.created_at)})`,
|
|
1071
|
+
300,
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
} catch (error) {
|
|
1075
|
+
state.provenanceText = errorText(error).includes("distill-records-disabled")
|
|
1076
|
+
? "mneme 没开「保留蒸馏原文」,所以这条记忆没有可查的来源窗口。"
|
|
1077
|
+
: errorText(error);
|
|
1078
|
+
}
|
|
1079
|
+
return {};
|
|
1080
|
+
},
|
|
1081
|
+
|
|
1082
|
+
refresh() {
|
|
1083
|
+
resetNotice();
|
|
1084
|
+
return {};
|
|
1085
|
+
},
|
|
1086
|
+
|
|
1087
|
+
back() {
|
|
1088
|
+
resetNotice();
|
|
1089
|
+
state.err = "";
|
|
1090
|
+
if (state.page === "profile" || state.page === "conflict") {
|
|
1091
|
+
state.page = "settings";
|
|
1092
|
+
state.sPanel = "menu";
|
|
1093
|
+
} else if (state.page === "entity") {
|
|
1094
|
+
state.page = "list";
|
|
1095
|
+
} else if (state.page === "settings") {
|
|
1096
|
+
if (state.sPanel !== "menu") state.sPanel = "menu";
|
|
1097
|
+
else state.page = "list";
|
|
1098
|
+
} else if (state.page === "detail" || state.page === "editor") {
|
|
1099
|
+
state.page = "list";
|
|
1100
|
+
}
|
|
1101
|
+
return {};
|
|
1102
|
+
},
|
|
1103
|
+
|
|
1104
|
+
search(scope, event) {
|
|
1105
|
+
resetNotice();
|
|
1106
|
+
state.q = String(valueOf(event, ["query", "q", "text"], state.q) ?? "").trim();
|
|
1107
|
+
state.offset = 0;
|
|
1108
|
+
goMemory("list");
|
|
1109
|
+
return {};
|
|
1110
|
+
},
|
|
1111
|
+
|
|
1112
|
+
page(scope, event) {
|
|
1113
|
+
resetNotice();
|
|
1114
|
+
// chips 送来的是 "prev" / "next";早期的按钮组送 { d: ±1 },两条都认。
|
|
1115
|
+
const raw = valueOf(event, ["value", "d", "delta", "page"], 0);
|
|
1116
|
+
const delta = raw === "prev" ? -1 : raw === "next" ? 1 : num(raw, 0);
|
|
1117
|
+
const pages = Math.max(1, Math.ceil(num(state.total, 0) / state.size));
|
|
1118
|
+
const next = Math.min(pages, Math.max(1, Math.floor(state.offset / state.size) + 1 + delta));
|
|
1119
|
+
state.offset = (next - 1) * state.size;
|
|
1120
|
+
goMemory("list");
|
|
1121
|
+
return {};
|
|
1122
|
+
},
|
|
1123
|
+
|
|
1124
|
+
async open(scope, event) {
|
|
1125
|
+
resetNotice();
|
|
1126
|
+
const id = String(valueOf(event, ["id"], "") ?? "");
|
|
1127
|
+
if (id === "") return { toast: "这条记录没有 id" };
|
|
1128
|
+
let found;
|
|
1129
|
+
try {
|
|
1130
|
+
found = await openMemoryById(id);
|
|
1131
|
+
} catch (error) {
|
|
1132
|
+
state.err = errorText(error);
|
|
1133
|
+
return {};
|
|
1134
|
+
}
|
|
1135
|
+
if (found === undefined) {
|
|
1136
|
+
state.err = "这条记忆已经不在列表里了。";
|
|
1137
|
+
return {};
|
|
1138
|
+
}
|
|
1139
|
+
state.current = found;
|
|
1140
|
+
openedBy.set(scopeKey(scope), found.id);
|
|
1141
|
+
state.provenanceText = "尚未查看。点上面的按钮可向 mneme 查这条记忆的蒸馏来源。";
|
|
1142
|
+
goMemory("detail");
|
|
1143
|
+
return {};
|
|
1144
|
+
},
|
|
1145
|
+
|
|
1146
|
+
"open-entity"(scope, event) {
|
|
1147
|
+
resetNotice();
|
|
1148
|
+
const name = String(valueOf(event, ["name", "entity", "value"], "") ?? "");
|
|
1149
|
+
if (name === "") return { toast: "没有选中实体" };
|
|
1150
|
+
state.entityName = name;
|
|
1151
|
+
state.entityDepth = 1;
|
|
1152
|
+
state.page = "entity";
|
|
1153
|
+
return {};
|
|
1154
|
+
},
|
|
1155
|
+
|
|
1156
|
+
"entity-search"(scope, event) {
|
|
1157
|
+
resetNotice();
|
|
1158
|
+
state.entityQuery = String(valueOf(event, ["entityQuery", "query", "q"], "") ?? "").trim();
|
|
1159
|
+
state.page = "list";
|
|
1160
|
+
// 让下一次 load() 补读实体清单(页面仍是列表,见 load 里的注释)。
|
|
1161
|
+
state.entityDirty = true;
|
|
1162
|
+
return {};
|
|
1163
|
+
},
|
|
1164
|
+
|
|
1165
|
+
"entity-depth"(scope, event) {
|
|
1166
|
+
resetNotice();
|
|
1167
|
+
const picked = String(valueOf(event, ["value", "entityDepth"], "1") ?? "1");
|
|
1168
|
+
state.entityDepth = picked === "2" ? 2 : 1;
|
|
1169
|
+
return {};
|
|
1170
|
+
},
|
|
1171
|
+
|
|
1172
|
+
edit(scope) {
|
|
1173
|
+
resetNotice();
|
|
1174
|
+
const memo = state.current;
|
|
1175
|
+
if (memo === null) return { toast: "先选一条记忆" };
|
|
1176
|
+
// 记下本面板打开的那条:save 也要按它核对(评审 #3)。
|
|
1177
|
+
openedBy.set(scopeKey(scope), memo.id);
|
|
1178
|
+
state.form = {
|
|
1179
|
+
id: memo.id,
|
|
1180
|
+
title: memo.title ?? "",
|
|
1181
|
+
content: memo.content ?? "",
|
|
1182
|
+
type: memo.type ?? "project",
|
|
1183
|
+
importance: num(memo.importance, 3),
|
|
1184
|
+
tags: joinTags(memo.tags),
|
|
1185
|
+
agentScope: typeof memo.agent_scope === "string" ? memo.agent_scope : "",
|
|
1186
|
+
workspaceScope: typeof memo.workspace_scope === "string" ? memo.workspace_scope : "",
|
|
1187
|
+
};
|
|
1188
|
+
state.editBase = editBaseOf(memo);
|
|
1189
|
+
goMemory("editor");
|
|
1190
|
+
return {};
|
|
1191
|
+
},
|
|
1192
|
+
|
|
1193
|
+
"type-filter"(scope, event) {
|
|
1194
|
+
resetNotice();
|
|
1195
|
+
const next = String(valueOf(event, ["value", "type"], "all") ?? "all");
|
|
1196
|
+
state.type = next === "all" || TYPE_LABELS[next] !== undefined ? next : "all";
|
|
1197
|
+
state.offset = 0;
|
|
1198
|
+
goMemory("list");
|
|
1199
|
+
return {};
|
|
1200
|
+
},
|
|
1201
|
+
|
|
1202
|
+
"form-type"(scope, event) {
|
|
1203
|
+
const next = String(valueOf(event, ["value", "type"], state.form.type ?? "project") ?? "project");
|
|
1204
|
+
state.form.type = TYPE_LABELS[next] === undefined ? "project" : next;
|
|
1205
|
+
return {};
|
|
1206
|
+
},
|
|
1207
|
+
|
|
1208
|
+
new() {
|
|
1209
|
+
resetNotice();
|
|
1210
|
+
state.current = null;
|
|
1211
|
+
state.form = {
|
|
1212
|
+
id: "",
|
|
1213
|
+
title: "",
|
|
1214
|
+
content: "",
|
|
1215
|
+
type: "project",
|
|
1216
|
+
importance: 3,
|
|
1217
|
+
tags: "",
|
|
1218
|
+
agentScope: "",
|
|
1219
|
+
workspaceScope: "",
|
|
1220
|
+
};
|
|
1221
|
+
goMemory("editor");
|
|
1222
|
+
return {};
|
|
1223
|
+
},
|
|
1224
|
+
|
|
1225
|
+
async save(scope, event) {
|
|
1226
|
+
resetNotice();
|
|
1227
|
+
const value = event?.value;
|
|
1228
|
+
const source =
|
|
1229
|
+
value !== null && typeof value === "object" && !Array.isArray(value)
|
|
1230
|
+
? { ...state.form, ...value }
|
|
1231
|
+
: { ...state.form };
|
|
1232
|
+
const title = String(source.title ?? "").trim();
|
|
1233
|
+
const content = String(source.content ?? "").trim();
|
|
1234
|
+
const type = TYPE_LABELS[String(source.type ?? "")] === undefined ? "project" : String(source.type);
|
|
1235
|
+
const importance = Math.min(5, Math.max(1, Math.round(num(source.importance, 3))));
|
|
1236
|
+
const tags = splitTags(String(source.tags ?? ""));
|
|
1237
|
+
if (title === "" || content === "") return { toast: "标题和正文都不能空" };
|
|
1238
|
+
const id = state.form.id === "" || state.form.id === undefined ? undefined : String(state.form.id);
|
|
1239
|
+
// 编辑既有条目也要核对面板 scope:state.form 是模块级共享的,两个面板
|
|
1240
|
+
// 各开一条时,A 面板点保存会把内容写进 B 刚打开的那条(评审 #3)。
|
|
1241
|
+
// 新建(id 为空)不涉及已有行,不核对。
|
|
1242
|
+
if (id !== undefined) {
|
|
1243
|
+
const mine = openedBy.get(scopeKey(scope));
|
|
1244
|
+
if (mine === undefined || mine !== id) {
|
|
1245
|
+
return { toast: "这条不是当前打开的记忆,已取消" };
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
if (id === undefined) {
|
|
1250
|
+
try {
|
|
1251
|
+
const res = await call("/create", { method: "POST", body: { title, content, importance, tags, type } });
|
|
1252
|
+
state.form = {};
|
|
1253
|
+
state.editBase = null;
|
|
1254
|
+
if (res !== null && typeof res === "object" && typeof res.id === "string") {
|
|
1255
|
+
state.current = res;
|
|
1256
|
+
openedBy.set(scopeKey(scope), res.id);
|
|
1257
|
+
goMemory("detail");
|
|
1258
|
+
notice(res.action === "merged" ? "已并入同标题的既有条目。" : "已新建一条记忆。");
|
|
1259
|
+
} else {
|
|
1260
|
+
goMemory("list");
|
|
1261
|
+
notice("已保存。");
|
|
1262
|
+
}
|
|
1263
|
+
} catch (error) {
|
|
1264
|
+
state.err = errorText(error);
|
|
1265
|
+
}
|
|
1266
|
+
return {};
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// 编辑既有条目:先回宿主要一份当前值。盘点窗口够长(跨了几分钟甚至几次
|
|
1270
|
+
// 对话)时,缓存里的那份可能已经被工具或 Web 面板改过,而这里回填的表单
|
|
1271
|
+
// 一旦整份提交就会把对方的写入盖掉;/update 没有版本号校验,所以这层
|
|
1272
|
+
// 比较只能放在面板侧(评审 #4)。
|
|
1273
|
+
let fresh;
|
|
1274
|
+
try {
|
|
1275
|
+
fresh = await openMemoryById(id);
|
|
1276
|
+
} catch (error) {
|
|
1277
|
+
state.err = errorText(error);
|
|
1278
|
+
return {};
|
|
1279
|
+
}
|
|
1280
|
+
if (fresh === undefined) {
|
|
1281
|
+
state.current = null;
|
|
1282
|
+
state.form = {};
|
|
1283
|
+
state.editBase = null;
|
|
1284
|
+
goMemory("list");
|
|
1285
|
+
notice("这条记忆已经不在了。", "warn");
|
|
1286
|
+
return {};
|
|
1287
|
+
}
|
|
1288
|
+
const base = state.editBase;
|
|
1289
|
+
if (base !== null && base.updatedAt !== "" && base.updatedAt !== String(fresh.updated_at ?? "")) {
|
|
1290
|
+
state.current = fresh;
|
|
1291
|
+
state.form = {
|
|
1292
|
+
id: fresh.id,
|
|
1293
|
+
title: fresh.title ?? "",
|
|
1294
|
+
content: fresh.content ?? "",
|
|
1295
|
+
type: fresh.type ?? "project",
|
|
1296
|
+
importance: num(fresh.importance, 3),
|
|
1297
|
+
tags: joinTags(fresh.tags),
|
|
1298
|
+
agentScope: typeof fresh.agent_scope === "string" ? fresh.agent_scope : "",
|
|
1299
|
+
workspaceScope: typeof fresh.workspace_scope === "string" ? fresh.workspace_scope : "",
|
|
1300
|
+
};
|
|
1301
|
+
state.editBase = editBaseOf(fresh);
|
|
1302
|
+
state.err = "这条记忆刚在别处被改过,已换成最新内容,请再确认一次。";
|
|
1303
|
+
return {};
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// 只提交真正改动的字段:把未改的 title/content 一起发回去,等于用旧值
|
|
1307
|
+
// 覆盖对方的写入(同一问题的一半)。
|
|
1308
|
+
const body = { id };
|
|
1309
|
+
if (base === null || title !== base.title) body.title = title;
|
|
1310
|
+
if (base === null || content !== base.content) body.content = content;
|
|
1311
|
+
if (base === null || importance !== base.importance) body.importance = importance;
|
|
1312
|
+
if (base === null || joinTags(tags) !== base.tags) body.tags = tags;
|
|
1313
|
+
const agentScope = String(source.agentScope ?? "").trim();
|
|
1314
|
+
const workspaceScope = String(source.workspaceScope ?? "").trim();
|
|
1315
|
+
if (base === null || agentScope !== base.agentScope) body.agent_scope = agentScope === "" ? null : agentScope;
|
|
1316
|
+
if (base === null || workspaceScope !== base.workspaceScope) {
|
|
1317
|
+
body.workspace_scope = workspaceScope === "" ? null : workspaceScope;
|
|
1318
|
+
}
|
|
1319
|
+
if (Object.keys(body).length === 1) return { toast: "没有改动" };
|
|
1320
|
+
try {
|
|
1321
|
+
const res = await call("/update", { method: "POST", body });
|
|
1322
|
+
const memo = res?.memory;
|
|
1323
|
+
state.form = {};
|
|
1324
|
+
state.editBase = null;
|
|
1325
|
+
if (memo !== null && typeof memo === "object" && typeof memo.id === "string") {
|
|
1326
|
+
state.current = memo;
|
|
1327
|
+
openedBy.set(scopeKey(scope), memo.id);
|
|
1328
|
+
goMemory("detail");
|
|
1329
|
+
} else {
|
|
1330
|
+
goMemory("list");
|
|
1331
|
+
}
|
|
1332
|
+
notice("已保存修改。");
|
|
1333
|
+
} catch (error) {
|
|
1334
|
+
state.err = errorText(error);
|
|
1335
|
+
}
|
|
1336
|
+
return {};
|
|
1337
|
+
},
|
|
1338
|
+
|
|
1339
|
+
async archive(scope, event) {
|
|
1340
|
+
resetNotice();
|
|
1341
|
+
return applyArchived(true, event, scope);
|
|
1342
|
+
},
|
|
1343
|
+
|
|
1344
|
+
async unarchive(scope, event) {
|
|
1345
|
+
resetNotice();
|
|
1346
|
+
return applyArchived(false, event, scope);
|
|
1347
|
+
},
|
|
1348
|
+
|
|
1349
|
+
async delete(scope, event) {
|
|
1350
|
+
resetNotice();
|
|
1351
|
+
if (valueOf(event, ["confirmed"], false) !== true) return {};
|
|
1352
|
+
const memo = state.current;
|
|
1353
|
+
if (memo === null) return { toast: "先选一条记忆" };
|
|
1354
|
+
// 确认框本身不带参数(confirm 节点只有 id 与两个按钮文案),所以「删哪
|
|
1355
|
+
// 一条」只能靠状态。状态是模块级的、被所有面板共用:两个面板各开一条时
|
|
1356
|
+
// 后开的会盖掉前一个的 current,此时点确认就会删错行(评审 #5)。
|
|
1357
|
+
// 打开记忆时按面板 scope 记一份(见 open)。只显示共享详情、没在本面板
|
|
1358
|
+
// 走过 open 的(mine 为空)也不能删:state.current 可能是另一个面板的
|
|
1359
|
+
// 条目(评审 #13)。
|
|
1360
|
+
const mine = openedBy.get(scopeKey(scope));
|
|
1361
|
+
if (mine === undefined || mine !== memo.id) {
|
|
1362
|
+
return { toast: "这条不是当前打开的记忆,已取消" };
|
|
1363
|
+
}
|
|
1364
|
+
const id = mine;
|
|
1365
|
+
try {
|
|
1366
|
+
await call("/delete", { method: "POST", body: { id } });
|
|
1367
|
+
cache.delete(id);
|
|
1368
|
+
openedBy.delete(scopeKey(scope));
|
|
1369
|
+
state.current = null;
|
|
1370
|
+
goMemory("list");
|
|
1371
|
+
notice("已删除。");
|
|
1372
|
+
} catch (error) {
|
|
1373
|
+
state.err = errorText(error);
|
|
1374
|
+
}
|
|
1375
|
+
return {};
|
|
1376
|
+
},
|
|
1377
|
+
|
|
1378
|
+
copy() {
|
|
1379
|
+
const memo = state.current;
|
|
1380
|
+
if (memo === null) return { toast: "先选一条记忆" };
|
|
1381
|
+
return { copy: clip(`# ${memo.title}\n\n${memo.content}`, 8000) };
|
|
1382
|
+
},
|
|
1383
|
+
|
|
1384
|
+
insert() {
|
|
1385
|
+
const memo = state.current;
|
|
1386
|
+
if (memo === null) return { toast: "先选一条记忆" };
|
|
1387
|
+
return { insert: clip(`【记忆】${memo.title}\n${memo.content}`, 2000) };
|
|
1388
|
+
},
|
|
1389
|
+
|
|
1390
|
+
"profile-tab"(scope, event) {
|
|
1391
|
+
resetNotice();
|
|
1392
|
+
const next = nextValue(
|
|
1393
|
+
["profile", "rules", "persona"],
|
|
1394
|
+
state.profileTab,
|
|
1395
|
+
valueOf(event, ["k", "tab", "value"], ""),
|
|
1396
|
+
);
|
|
1397
|
+
state.profileTab = next;
|
|
1398
|
+
state.page = "profile";
|
|
1399
|
+
return {};
|
|
1400
|
+
},
|
|
1401
|
+
|
|
1402
|
+
async "profile-save"(scope, event) {
|
|
1403
|
+
resetNotice();
|
|
1404
|
+
const body = String(valueOf(event, ["editText", "text", "value"], "") ?? "");
|
|
1405
|
+
try {
|
|
1406
|
+
if (state.profileTab === "rules") {
|
|
1407
|
+
let parsed;
|
|
1408
|
+
try {
|
|
1409
|
+
parsed = JSON.parse(body);
|
|
1410
|
+
} catch {
|
|
1411
|
+
state.err = "规则必须是合法 JSON 数组。";
|
|
1412
|
+
return {};
|
|
1413
|
+
}
|
|
1414
|
+
// 非数组({} / null / "text")不能落成空数组:这条路由是整份覆盖,
|
|
1415
|
+
// 静默写 [] 会把用户已有的规则一次清空(评审 #10)。
|
|
1416
|
+
if (!Array.isArray(parsed)) {
|
|
1417
|
+
state.err = "规则必须是合法 JSON 数组。";
|
|
1418
|
+
return {};
|
|
1419
|
+
}
|
|
1420
|
+
await call("/rules", { method: "PUT", body: { rules: parsed } });
|
|
1421
|
+
} else {
|
|
1422
|
+
await call("/profile", { method: "PUT", body: { profile: body } });
|
|
1423
|
+
}
|
|
1424
|
+
notice("已保存。");
|
|
1425
|
+
} catch (error) {
|
|
1426
|
+
state.err = errorText(error);
|
|
1427
|
+
}
|
|
1428
|
+
return {};
|
|
1429
|
+
},
|
|
1430
|
+
|
|
1431
|
+
async "profile-extract"() {
|
|
1432
|
+
resetNotice();
|
|
1433
|
+
if (state.profileTab !== "profile") return { toast: "先切到画像" };
|
|
1434
|
+
try {
|
|
1435
|
+
const res = await call("/profile/extract", { method: "POST", body: {} });
|
|
1436
|
+
const draft = res?.draft ?? res ?? {};
|
|
1437
|
+
const text =
|
|
1438
|
+
typeof draft.profile === "string"
|
|
1439
|
+
? draft.profile
|
|
1440
|
+
: typeof draft.text === "string"
|
|
1441
|
+
? draft.text
|
|
1442
|
+
: JSON.stringify(draft);
|
|
1443
|
+
state.extractText = clip(`草稿:${text}`, 600);
|
|
1444
|
+
} catch (error) {
|
|
1445
|
+
const raw = errorText(error);
|
|
1446
|
+
state.extractText = raw.includes("disabled")
|
|
1447
|
+
? "mneme 没开「画像提取草稿」,先到「设置 › 功能开关 › 写入」里打开它。"
|
|
1448
|
+
: raw.includes("no-route")
|
|
1449
|
+
? "没有可用的模型路由,提取不了草稿。"
|
|
1450
|
+
: raw.includes("no-input")
|
|
1451
|
+
? "记忆还太少,提取不出画像。"
|
|
1452
|
+
: raw;
|
|
1453
|
+
}
|
|
1454
|
+
return {};
|
|
1455
|
+
},
|
|
1456
|
+
|
|
1457
|
+
async mode(scope, event) {
|
|
1458
|
+
resetNotice();
|
|
1459
|
+
const next = String(valueOf(event, ["mode", "value"], "standard") ?? "standard");
|
|
1460
|
+
if (next !== "standard" && next !== "light") {
|
|
1461
|
+
state.err = "未知的模式。";
|
|
1462
|
+
return {};
|
|
1463
|
+
}
|
|
1464
|
+
try {
|
|
1465
|
+
await call("/mode", { method: "PUT", body: { mode: next } });
|
|
1466
|
+
notice(`已切到${next === "light" ? "轻量" : "标准"}模式。`);
|
|
1467
|
+
} catch (error) {
|
|
1468
|
+
state.err = errorText(error);
|
|
1469
|
+
}
|
|
1470
|
+
return {};
|
|
1471
|
+
},
|
|
1472
|
+
|
|
1473
|
+
async reclaim() {
|
|
1474
|
+
resetNotice();
|
|
1475
|
+
try {
|
|
1476
|
+
const res = await call("/maintenance/reclaim");
|
|
1477
|
+
const report = res?.report ?? res ?? {};
|
|
1478
|
+
const rows = Array.isArray(report.candidates) ? report.candidates.length : undefined;
|
|
1479
|
+
const total = rows ?? report.reclaimable ?? report.total ?? report.rows ?? null;
|
|
1480
|
+
state.reclaimText =
|
|
1481
|
+
total === null ? "已检查(dry-run,未改动)。" : `可回收 ${String(total)} 条(dry-run,未改动)。`;
|
|
1482
|
+
} catch (error) {
|
|
1483
|
+
state.reclaimText = errorText(error);
|
|
1484
|
+
}
|
|
1485
|
+
return {};
|
|
1486
|
+
},
|
|
1487
|
+
|
|
1488
|
+
"reclaim-prepare"() {
|
|
1489
|
+
resetNotice();
|
|
1490
|
+
state.reclaimConfirm = true;
|
|
1491
|
+
state.reclaimText = "回收会丢弃无法再召回的条目,确认后再执行。";
|
|
1492
|
+
return {};
|
|
1493
|
+
},
|
|
1494
|
+
|
|
1495
|
+
async "reclaim-run"(scope, event) {
|
|
1496
|
+
resetNotice();
|
|
1497
|
+
if (valueOf(event, ["confirmed"], false) !== true) return {};
|
|
1498
|
+
try {
|
|
1499
|
+
const res = await call("/maintenance/reclaim", {
|
|
1500
|
+
method: "POST",
|
|
1501
|
+
body: { confirm: true, vacuum: true },
|
|
1502
|
+
});
|
|
1503
|
+
const report = res?.report ?? res ?? {};
|
|
1504
|
+
state.reclaimText = `已回收 ${String(report.reclaimed ?? report.total ?? 0)} 条。`;
|
|
1505
|
+
state.reclaimConfirm = false;
|
|
1506
|
+
} catch (error) {
|
|
1507
|
+
state.reclaimText = errorText(error);
|
|
1508
|
+
}
|
|
1509
|
+
return {};
|
|
1510
|
+
},
|
|
1511
|
+
|
|
1512
|
+
"conflict-pick-row"(scope, event) {
|
|
1513
|
+
resetNotice();
|
|
1514
|
+
state.conflictId = String(valueOf(event, ["id"], "") ?? "");
|
|
1515
|
+
conflictBy.set(scopeKey(scope), state.conflictId);
|
|
1516
|
+
state.conflictPick = "";
|
|
1517
|
+
state.page = "conflict";
|
|
1518
|
+
return {};
|
|
1519
|
+
},
|
|
1520
|
+
|
|
1521
|
+
"conflict-pick"(scope, event) {
|
|
1522
|
+
resetNotice();
|
|
1523
|
+
const picked = String(valueOf(event, ["winner", "value"], "") ?? "");
|
|
1524
|
+
state.conflictPick = picked === "a" || picked === "b" ? picked : "none";
|
|
1525
|
+
state.page = "conflict";
|
|
1526
|
+
return {};
|
|
1527
|
+
},
|
|
1528
|
+
|
|
1529
|
+
async "conflict-win"(scope, event) {
|
|
1530
|
+
resetNotice();
|
|
1531
|
+
if (valueOf(event, ["confirmed"], false) !== true) return {};
|
|
1532
|
+
const id = state.conflictId;
|
|
1533
|
+
if (id === "") return { toast: "先选一条冲突" };
|
|
1534
|
+
// 冲突 id 也是共享状态:只裁决本面板自己选中的那条(评审 #3)。
|
|
1535
|
+
if (conflictBy.get(scopeKey(scope)) !== id) {
|
|
1536
|
+
return { toast: "这条不是当前打开的冲突,已取消" };
|
|
1537
|
+
}
|
|
1538
|
+
const winner = state.conflictPick === "a" ? "a" : state.conflictPick === "b" ? "b" : null;
|
|
1539
|
+
try {
|
|
1540
|
+
await call("/conflicts/resolve", { method: "POST", body: { id, winner, apply: true } });
|
|
1541
|
+
conflictBy.delete(scopeKey(scope));
|
|
1542
|
+
state.conflictId = "";
|
|
1543
|
+
state.conflictPick = "";
|
|
1544
|
+
notice("已按裁决合并。");
|
|
1545
|
+
} catch (error) {
|
|
1546
|
+
state.err = errorText(error);
|
|
1547
|
+
}
|
|
1548
|
+
return {};
|
|
1549
|
+
},
|
|
1550
|
+
|
|
1551
|
+
async "wb-restore"(scope, event) {
|
|
1552
|
+
resetNotice();
|
|
1553
|
+
const id = String(valueOf(event, ["id"], "") ?? "");
|
|
1554
|
+
if (id === "") return {};
|
|
1555
|
+
try {
|
|
1556
|
+
await call("/update", { method: "POST", body: { id, archived: false } });
|
|
1557
|
+
notice("已恢复这条记忆。");
|
|
1558
|
+
} catch (error) {
|
|
1559
|
+
state.err = errorText(error);
|
|
1560
|
+
}
|
|
1561
|
+
return {};
|
|
1562
|
+
},
|
|
1563
|
+
|
|
1564
|
+
"cmd-pick"(scope, event) {
|
|
1565
|
+
resetNotice();
|
|
1566
|
+
state.sCmdPick = String(valueOf(event, ["value"], "") ?? "");
|
|
1567
|
+
state.page = "settings";
|
|
1568
|
+
return {};
|
|
1569
|
+
},
|
|
1570
|
+
|
|
1571
|
+
"cmd-add"(scope, event) {
|
|
1572
|
+
resetNotice();
|
|
1573
|
+
const value = event?.value;
|
|
1574
|
+
const source =
|
|
1575
|
+
value !== null && typeof value === "object" && !Array.isArray(value)
|
|
1576
|
+
? { sCmdName: value.sCmdName, sCmdDesc: value.sCmdDesc, sCmdIns: value.sCmdIns }
|
|
1577
|
+
: {};
|
|
1578
|
+
const name = String(source.sCmdName ?? state.sCmdName ?? "").trim();
|
|
1579
|
+
const description = String(source.sCmdDesc ?? state.sCmdDesc ?? "").trim();
|
|
1580
|
+
const instruction = String(source.sCmdIns ?? state.sCmdIns ?? "").trim();
|
|
1581
|
+
if (name === "" || instruction === "") return { toast: "命令名和指令正文都不能空" };
|
|
1582
|
+
return call("/commands", { method: "POST", body: { name, description, instruction } }).then(
|
|
1583
|
+
() => {
|
|
1584
|
+
state.sCmdName = "";
|
|
1585
|
+
state.sCmdDesc = "";
|
|
1586
|
+
state.sCmdIns = "";
|
|
1587
|
+
notice(`已添加命令 ${name}。`);
|
|
1588
|
+
return {};
|
|
1589
|
+
},
|
|
1590
|
+
(error) => {
|
|
1591
|
+
state.err = errorText(error);
|
|
1592
|
+
return {};
|
|
1593
|
+
},
|
|
1594
|
+
);
|
|
1595
|
+
},
|
|
1596
|
+
|
|
1597
|
+
async "cmd-delete"(scope, event) {
|
|
1598
|
+
resetNotice();
|
|
1599
|
+
if (valueOf(event, ["confirmed"], false) !== true) return {};
|
|
1600
|
+
const id = state.sCmdPick;
|
|
1601
|
+
if (id === "") return { toast: "先选一条命令" };
|
|
1602
|
+
try {
|
|
1603
|
+
await call(`/commands?id=${enc(id)}`, { method: "DELETE" });
|
|
1604
|
+
state.sCmdPick = "";
|
|
1605
|
+
notice("已删除命令。");
|
|
1606
|
+
} catch (error) {
|
|
1607
|
+
state.err = errorText(error);
|
|
1608
|
+
}
|
|
1609
|
+
return {};
|
|
1610
|
+
},
|
|
1611
|
+
|
|
1612
|
+
"vector-toggle"(scope, event) {
|
|
1613
|
+
resetNotice();
|
|
1614
|
+
// 没读到真实配置时 state.vector 只是默认值,整体 PUT 会覆盖服务端(评审 #2)。
|
|
1615
|
+
if (!state.vectorLoaded) return { toast: "还没读到向量配置,请刷新后再改" };
|
|
1616
|
+
const value = event?.value;
|
|
1617
|
+
state.vector.enabled = typeof value === "boolean" ? value : !state.vector.enabled;
|
|
1618
|
+
return call("/vector-config", {
|
|
1619
|
+
method: "PUT",
|
|
1620
|
+
body: { enabled: state.vector.enabled, baseUrl: state.vector.baseUrl, model: state.vector.model },
|
|
1621
|
+
}).then(
|
|
1622
|
+
() => {
|
|
1623
|
+
notice(`向量检索:${state.vector.enabled ? "已开启" : "已关闭"}`);
|
|
1624
|
+
return {};
|
|
1625
|
+
},
|
|
1626
|
+
(error) => {
|
|
1627
|
+
state.err = errorText(error);
|
|
1628
|
+
return {};
|
|
1629
|
+
},
|
|
1630
|
+
);
|
|
1631
|
+
},
|
|
1632
|
+
|
|
1633
|
+
"vector-save"(scope, event) {
|
|
1634
|
+
resetNotice();
|
|
1635
|
+
if (!state.vectorLoaded) return { toast: "还没读到向量配置,请刷新后再改" };
|
|
1636
|
+
const value = event?.value;
|
|
1637
|
+
const source = value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
1638
|
+
const baseUrl = String(source.sVecUrl ?? state.vector.baseUrl ?? "").trim();
|
|
1639
|
+
const model = String(source.sVecModel ?? state.vector.model ?? "").trim();
|
|
1640
|
+
state.vector.baseUrl = baseUrl;
|
|
1641
|
+
state.vector.model = model;
|
|
1642
|
+
return call("/vector-config", {
|
|
1643
|
+
method: "PUT",
|
|
1644
|
+
body: { enabled: state.vector.enabled, baseUrl, model },
|
|
1645
|
+
}).then(
|
|
1646
|
+
() => {
|
|
1647
|
+
notice("已保存向量配置。");
|
|
1648
|
+
return {};
|
|
1649
|
+
},
|
|
1650
|
+
(error) => {
|
|
1651
|
+
state.err = errorText(error);
|
|
1652
|
+
return {};
|
|
1653
|
+
},
|
|
1654
|
+
);
|
|
1655
|
+
},
|
|
1656
|
+
|
|
1657
|
+
"vector-reindex"() {
|
|
1658
|
+
resetNotice();
|
|
1659
|
+
const run = call("/vector-reindex", { timeoutMs: 60000 }).then(
|
|
1660
|
+
(res) => {
|
|
1661
|
+
notice(`索引完成:新增 ${String(res?.indexed ?? 0)} 条,跳过 ${String(res?.skipped ?? 0)} 条。`);
|
|
1662
|
+
return {};
|
|
1663
|
+
},
|
|
1664
|
+
(error) => {
|
|
1665
|
+
state.err = errorText(error);
|
|
1666
|
+
return {};
|
|
1667
|
+
},
|
|
1668
|
+
);
|
|
1669
|
+
return {
|
|
1670
|
+
pending: { label: "正在重建向量索引", timeoutMs: 60000 },
|
|
1671
|
+
result: async () => run,
|
|
1672
|
+
};
|
|
1673
|
+
},
|
|
1674
|
+
|
|
1675
|
+
"external-toggle"(scope, event) {
|
|
1676
|
+
resetNotice();
|
|
1677
|
+
if (!state.externalLoaded) return { toast: "还没读到外部访问配置,请刷新后再改" };
|
|
1678
|
+
const value = event?.value;
|
|
1679
|
+
state.external.enabled = typeof value === "boolean" ? value : !state.external.enabled;
|
|
1680
|
+
return call("/external-api", {
|
|
1681
|
+
method: "PUT",
|
|
1682
|
+
body: { enabled: state.external.enabled, port: state.external.port, host: state.external.host },
|
|
1683
|
+
}).then(
|
|
1684
|
+
() => {
|
|
1685
|
+
notice(`外部访问:${state.external.enabled ? "已开启" : "已关闭"}`);
|
|
1686
|
+
return {};
|
|
1687
|
+
},
|
|
1688
|
+
(error) => {
|
|
1689
|
+
state.err = errorText(error);
|
|
1690
|
+
return {};
|
|
1691
|
+
},
|
|
1692
|
+
);
|
|
1693
|
+
},
|
|
1694
|
+
|
|
1695
|
+
"external-save"(scope, event) {
|
|
1696
|
+
resetNotice();
|
|
1697
|
+
if (!state.externalLoaded) return { toast: "还没读到外部访问配置,请刷新后再改" };
|
|
1698
|
+
const value = event?.value;
|
|
1699
|
+
const source = value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
1700
|
+
const port = Math.round(num(source.sExtPort ?? state.external.port, 8790));
|
|
1701
|
+
const host = String(source.sExtHost ?? state.external.host ?? "").trim();
|
|
1702
|
+
if (port < 1 || port > 65535) return { toast: "端口要在 1–65535 之间" };
|
|
1703
|
+
if (host === "" || host.includes("://")) return { toast: "监听地址不能带协议前缀" };
|
|
1704
|
+
state.external.port = port;
|
|
1705
|
+
state.external.host = host;
|
|
1706
|
+
return call("/external-api", {
|
|
1707
|
+
method: "PUT",
|
|
1708
|
+
body: { enabled: state.external.enabled, port, host },
|
|
1709
|
+
}).then(
|
|
1710
|
+
() => {
|
|
1711
|
+
notice("已保存外部访问配置。");
|
|
1712
|
+
return {};
|
|
1713
|
+
},
|
|
1714
|
+
(error) => {
|
|
1715
|
+
state.err = errorText(error);
|
|
1716
|
+
return {};
|
|
1717
|
+
},
|
|
1718
|
+
);
|
|
1719
|
+
},
|
|
1720
|
+
|
|
1721
|
+
async "check-version"() {
|
|
1722
|
+
resetNotice();
|
|
1723
|
+
try {
|
|
1724
|
+
const res = await call("/version-check");
|
|
1725
|
+
const status = String(res?.status ?? "unknown");
|
|
1726
|
+
state.versionNote =
|
|
1727
|
+
status === "outdated"
|
|
1728
|
+
? `有新版本 ${String(res?.latest ?? "")}(当前 ${String(res?.version ?? "")})`
|
|
1729
|
+
: status === "latest"
|
|
1730
|
+
? `已是最新版本 ${String(res?.version ?? "")}`
|
|
1731
|
+
: "没能连上 npm registry,稍后再试。";
|
|
1732
|
+
if (status === "outdated" && res?.latest) notice(`mneme 有新版本 ${res.latest}`);
|
|
1733
|
+
} catch (error) {
|
|
1734
|
+
state.versionNote = errorText(error);
|
|
1735
|
+
}
|
|
1736
|
+
return {};
|
|
1737
|
+
},
|
|
1738
|
+
};
|
|
1739
|
+
|
|
1740
|
+
// One action per feature switch: the app tells us which action fired, and the
|
|
1741
|
+
// new value arrives as `event.value` (boolean) or is derived by flipping the
|
|
1742
|
+
// current effective value.
|
|
1743
|
+
for (const key of FLAG_LABELS.keys()) {
|
|
1744
|
+
actions[flagAction(key)] = async (scope, event) => {
|
|
1745
|
+
resetNotice();
|
|
1746
|
+
const value = event?.value;
|
|
1747
|
+
// state.flags 是宿主原样的扁平点号键(scopeState.enabled);嵌套形态只
|
|
1748
|
+
// 出现在视图绑定里(payload.flags = nestFlags(effective))。这里若按嵌套
|
|
1749
|
+
// 去取值会恒为 undefined,于是「已经打开的开关」永远翻转不回去(评审 #7)。
|
|
1750
|
+
const current = state.flags?.[key];
|
|
1751
|
+
// 布尔项翻转、枚举项(chorographyInject 的 light/full/off)直接用宿主给的
|
|
1752
|
+
// 字符串值:同一个动作 id 同时服务开关与下拉。
|
|
1753
|
+
const next =
|
|
1754
|
+
typeof value === "boolean" || typeof value === "string"
|
|
1755
|
+
? value
|
|
1756
|
+
: typeof current === "string"
|
|
1757
|
+
? current
|
|
1758
|
+
: !(current === true);
|
|
1759
|
+
try {
|
|
1760
|
+
await call("/features", { method: "PUT", body: { [key]: next } });
|
|
1761
|
+
const features = await call("/features");
|
|
1762
|
+
state.flagOverrides = features?.overrides ?? {};
|
|
1763
|
+
state.pendingRestart = Array.isArray(features?.pendingRestart) ? features.pendingRestart : [];
|
|
1764
|
+
state.flags = features?.effective ?? state.flags;
|
|
1765
|
+
state.settingsDirty = true;
|
|
1766
|
+
notice(
|
|
1767
|
+
`${FLAG_LABELS.get(key) ?? key}:${
|
|
1768
|
+
typeof next === "string" ? next : next ? "已开启" : "已关闭"
|
|
1769
|
+
}`,
|
|
1770
|
+
);
|
|
1771
|
+
} catch (error) {
|
|
1772
|
+
state.err = errorText(error);
|
|
1773
|
+
}
|
|
1774
|
+
// 开关只改一个绑定值:数据已经推过去了,别让 App 重建视图(否则设置页
|
|
1775
|
+
// 会被弹回第一个页签)。
|
|
1776
|
+
return { refresh: false };
|
|
1777
|
+
};
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
// ---- 「Agent 眼中的你」自动事实(0.11.0 的面板卡) ----------------------
|
|
1781
|
+
// 写入只有一个入口:POST /profile-facts/apply,op 形状与自动轮共用(add 必须
|
|
1782
|
+
// 带 content、update/archive 必须带 id)。这里不自己拼 SQL,也不碰 user_settings
|
|
1783
|
+
// ——手填画像仍然优先。
|
|
1784
|
+
const applyFacts = (ops) =>
|
|
1785
|
+
call("/profile-facts/apply", { method: "POST", body: { agent: state.factsAgent, ops } });
|
|
1786
|
+
|
|
1787
|
+
Object.assign(actions, {
|
|
1788
|
+
"facts-agent"(scope, event) {
|
|
1789
|
+
resetNotice();
|
|
1790
|
+
state.factsAgent = String(valueOf(event, ["value", "agent"], "") ?? "");
|
|
1791
|
+
state.factEdit = null;
|
|
1792
|
+
factBy.delete(scopeKey(scope));
|
|
1793
|
+
return {};
|
|
1794
|
+
},
|
|
1795
|
+
|
|
1796
|
+
"facts-edit"(scope, event) {
|
|
1797
|
+
resetNotice();
|
|
1798
|
+
const id = String(valueOf(event, ["id"], "") ?? "");
|
|
1799
|
+
const fact = state.factsItems.find((item) => String(item.id) === id);
|
|
1800
|
+
if (fact === undefined) return { toast: "这条事实已经不在了" };
|
|
1801
|
+
state.factEdit = { id, content: String(fact.content ?? "") };
|
|
1802
|
+
factBy.set(scopeKey(scope), id);
|
|
1803
|
+
return {};
|
|
1804
|
+
},
|
|
1805
|
+
|
|
1806
|
+
"facts-add"(scope, event) {
|
|
1807
|
+
resetNotice();
|
|
1808
|
+
const value = event?.value;
|
|
1809
|
+
const source = value !== null && typeof value === "object" ? value : {};
|
|
1810
|
+
const text = String(source.sFactNew ?? state.form.factNew ?? "").trim();
|
|
1811
|
+
if (text === "") return { toast: "写一句话再添加" };
|
|
1812
|
+
return applyFacts([{ op: "add", content: text }]).then(
|
|
1813
|
+
(result) => {
|
|
1814
|
+
state.form.factNew = "";
|
|
1815
|
+
notice(`已加 ${String(result?.applied ?? 1)} 条事实。`);
|
|
1816
|
+
return {};
|
|
1817
|
+
},
|
|
1818
|
+
(error) => {
|
|
1819
|
+
state.err = errorText(error);
|
|
1820
|
+
return {};
|
|
1821
|
+
},
|
|
1822
|
+
);
|
|
1823
|
+
},
|
|
1824
|
+
|
|
1825
|
+
"facts-save"(scope, event) {
|
|
1826
|
+
resetNotice();
|
|
1827
|
+
const target = state.factEdit;
|
|
1828
|
+
if (target === null) return { toast: "先点一条事实" };
|
|
1829
|
+
if (factBy.get(scopeKey(scope)) !== target.id) {
|
|
1830
|
+
return { toast: "这条不是当前打开的事实,已取消" };
|
|
1831
|
+
}
|
|
1832
|
+
const value = event?.value;
|
|
1833
|
+
const source = value !== null && typeof value === "object" ? value : {};
|
|
1834
|
+
const text = String(source.sFactEdit ?? target.content ?? "").trim();
|
|
1835
|
+
if (text === "") return { toast: "事实正文不能空" };
|
|
1836
|
+
if (text === target.content) return { toast: "没有改动" };
|
|
1837
|
+
return applyFacts([{ op: "update", id: target.id, content: text }]).then(
|
|
1838
|
+
(result) => {
|
|
1839
|
+
const failed = Array.isArray(result?.failures) ? result.failures : [];
|
|
1840
|
+
if (failed.length > 0) {
|
|
1841
|
+
state.err = "这条事实已被改过或删掉了,刷新后再试。";
|
|
1842
|
+
return {};
|
|
1843
|
+
}
|
|
1844
|
+
state.factEdit = null;
|
|
1845
|
+
factBy.delete(scopeKey(scope));
|
|
1846
|
+
notice("事实已更新。");
|
|
1847
|
+
return {};
|
|
1848
|
+
},
|
|
1849
|
+
(error) => {
|
|
1850
|
+
state.err = errorText(error);
|
|
1851
|
+
return {};
|
|
1852
|
+
},
|
|
1853
|
+
);
|
|
1854
|
+
},
|
|
1855
|
+
|
|
1856
|
+
"facts-archive"(scope, event) {
|
|
1857
|
+
resetNotice();
|
|
1858
|
+
if (valueOf(event, ["confirmed"], false) !== true) return {};
|
|
1859
|
+
const target = state.factEdit;
|
|
1860
|
+
if (target === null) return { toast: "先点一条事实" };
|
|
1861
|
+
if (factBy.get(scopeKey(scope)) !== target.id) {
|
|
1862
|
+
return { toast: "这条不是当前打开的事实,已取消" };
|
|
1863
|
+
}
|
|
1864
|
+
return applyFacts([{ op: "archive", id: target.id }]).then(
|
|
1865
|
+
() => {
|
|
1866
|
+
state.factEdit = null;
|
|
1867
|
+
factBy.delete(scopeKey(scope));
|
|
1868
|
+
notice("已归档这条事实。");
|
|
1869
|
+
return {};
|
|
1870
|
+
},
|
|
1871
|
+
(error) => {
|
|
1872
|
+
state.err = errorText(error);
|
|
1873
|
+
return {};
|
|
1874
|
+
},
|
|
1875
|
+
);
|
|
1876
|
+
},
|
|
1877
|
+
|
|
1878
|
+
"facts-run"(scope, event) {
|
|
1879
|
+
resetNotice();
|
|
1880
|
+
// 维护轮要跑一次 LLM(模型读最近记忆、提 op、落库),慢且不可并发,所以
|
|
1881
|
+
// 走 pending:面板先挂一条「正在更新」,最长等 60 秒。
|
|
1882
|
+
const run = call("/profile-facts/run", {
|
|
1883
|
+
method: "POST",
|
|
1884
|
+
body: { agent: state.factsAgent },
|
|
1885
|
+
timeoutMs: 60000,
|
|
1886
|
+
}).then(
|
|
1887
|
+
(receipt) => {
|
|
1888
|
+
const status = String(receipt?.status ?? "ok");
|
|
1889
|
+
state.factsRunText =
|
|
1890
|
+
status === "ok"
|
|
1891
|
+
? `本轮改了 ${String(receipt?.opsApplied ?? 0)} 条事实。`
|
|
1892
|
+
: status === "noop"
|
|
1893
|
+
? "没有新记忆可读,这轮没动。"
|
|
1894
|
+
: `这轮结束了:${status}`;
|
|
1895
|
+
notice("事实维护轮已结束。");
|
|
1896
|
+
return { toast: "事实已更新" };
|
|
1897
|
+
},
|
|
1898
|
+
(error) => {
|
|
1899
|
+
const message = errorText(error);
|
|
1900
|
+
// 409 = 功能关着,503 = 没配模型,502 = 模型答得不成形:三种都得说人话。
|
|
1901
|
+
state.err = /\b409\b/u.test(message)
|
|
1902
|
+
? "「Agent 眼中的你」还关着:先在上面把开关打开。"
|
|
1903
|
+
: /\b503\b/u.test(message)
|
|
1904
|
+
? "还没配后台模型:先在 mneme 设置里选一个 dream 模型。"
|
|
1905
|
+
: message;
|
|
1906
|
+
return { toast: "这轮没成功" };
|
|
1907
|
+
},
|
|
1908
|
+
);
|
|
1909
|
+
return { pending: { label: "正在维护事实", timeoutMs: 60000 }, result: async () => run };
|
|
1910
|
+
},
|
|
1911
|
+
|
|
1912
|
+
// ---- 代码库地志(chorography)-----------------------------------------
|
|
1913
|
+
"choro-repo"(scope, event) {
|
|
1914
|
+
resetNotice();
|
|
1915
|
+
state.choroRepo = String(valueOf(event, ["value"], "") ?? "");
|
|
1916
|
+
state.choroPageText = "";
|
|
1917
|
+
return {};
|
|
1918
|
+
},
|
|
1919
|
+
|
|
1920
|
+
"choro-page"(scope, event) {
|
|
1921
|
+
resetNotice();
|
|
1922
|
+
const slug = String(valueOf(event, ["slug"], "") ?? "");
|
|
1923
|
+
if (slug === "" || state.choroRepo === "") return { toast: "先选一个仓库" };
|
|
1924
|
+
return call(`/choro/page?repo=${encodeURIComponent(state.choroRepo)}&slug=${encodeURIComponent(slug)}`).then(
|
|
1925
|
+
(res) => {
|
|
1926
|
+
const page = res?.page;
|
|
1927
|
+
state.choroPageText =
|
|
1928
|
+
page === undefined
|
|
1929
|
+
? "这一页读不出来,可能刚被重建过。"
|
|
1930
|
+
: clip(String(page?.content ?? page?.relPath ?? ""), 1600);
|
|
1931
|
+
notice(`已读取 ${String(page?.title ?? slug)} 的骨架页。`);
|
|
1932
|
+
return {};
|
|
1933
|
+
},
|
|
1934
|
+
(error) => {
|
|
1935
|
+
const message = errorText(error);
|
|
1936
|
+
state.err = /\b409\b/u.test(message) ? "索引还在、盘上没了:点「重建骨架」重跑一次。" : message;
|
|
1937
|
+
return {};
|
|
1938
|
+
},
|
|
1939
|
+
);
|
|
1940
|
+
},
|
|
1941
|
+
|
|
1942
|
+
"choro-run"(scope, event) {
|
|
1943
|
+
resetNotice();
|
|
1944
|
+
if (valueOf(event, ["confirmed"], false) !== true) return {};
|
|
1945
|
+
if (state.choroRepo === "") return { toast: "还没有登记任何仓库" };
|
|
1946
|
+
const run = call("/choro/run", {
|
|
1947
|
+
method: "POST",
|
|
1948
|
+
body: { repo: state.choroRepo },
|
|
1949
|
+
timeoutMs: 60000,
|
|
1950
|
+
}).then(
|
|
1951
|
+
(res) => {
|
|
1952
|
+
const receipt = res?.receipt ?? {};
|
|
1953
|
+
state.choroPageText = "";
|
|
1954
|
+
notice(
|
|
1955
|
+
`骨架已重建:${String(receipt.pagesWritten ?? receipt.modules ?? 0)} 页` +
|
|
1956
|
+
(receipt.t1Loss === true ? "(已富化的语义段被退回骨架)" : ""),
|
|
1957
|
+
);
|
|
1958
|
+
return { toast: "重建完成" };
|
|
1959
|
+
},
|
|
1960
|
+
(error) => {
|
|
1961
|
+
state.err = errorText(error);
|
|
1962
|
+
return { toast: "重建失败" };
|
|
1963
|
+
},
|
|
1964
|
+
);
|
|
1965
|
+
return { pending: { label: "正在重建骨架", timeoutMs: 60000 }, result: async () => run };
|
|
1966
|
+
},
|
|
1967
|
+
});
|
|
1968
|
+
|
|
1969
|
+
// 未收录的布尔旗标(「其它」组里的数据行):行里带着 key,点一下翻转。
|
|
1970
|
+
actions["flag-set"] = async (scope, event) => {
|
|
1971
|
+
resetNotice();
|
|
1972
|
+
const key = String(valueOf(event, ["key", "value"], "") ?? "");
|
|
1973
|
+
if (key === "") {
|
|
1974
|
+
state.err = "没有拿到要切换的键。";
|
|
1975
|
+
return {};
|
|
1976
|
+
}
|
|
1977
|
+
const next = state.flags?.[key] !== true;
|
|
1978
|
+
try {
|
|
1979
|
+
await call("/features", { method: "PUT", body: { [key]: next } });
|
|
1980
|
+
const features = await call("/features");
|
|
1981
|
+
state.flagOverrides = features?.overrides ?? {};
|
|
1982
|
+
state.pendingRestart = Array.isArray(features?.pendingRestart) ? features.pendingRestart : [];
|
|
1983
|
+
state.flags = features?.effective ?? state.flags;
|
|
1984
|
+
state.settingsDirty = true;
|
|
1985
|
+
notice(`${key}:${next ? "已开启" : "已关闭"}`);
|
|
1986
|
+
} catch (error) {
|
|
1987
|
+
state.err = errorText(error);
|
|
1988
|
+
}
|
|
1989
|
+
return { refresh: false };
|
|
1990
|
+
};
|
|
1991
|
+
|
|
1992
|
+
// 只改绑定值的动作不申请重画视图。App 收到 refresh 会重新拉一次视图树,
|
|
1993
|
+
// 而顶层 tabs 的选中页签是 App 的本地状态:树一重建就回到第一个页签,用户在
|
|
1994
|
+
// 设置页翻开关会被弹回记忆页。这些动作的结果都在 data 里(开关值、列表行、
|
|
1995
|
+
// 文案),推一次数据就够了。
|
|
1996
|
+
const VALUE_ONLY = new Set([
|
|
1997
|
+
"mode",
|
|
1998
|
+
"vector-toggle",
|
|
1999
|
+
"vector-save",
|
|
2000
|
+
"external-toggle",
|
|
2001
|
+
"external-save",
|
|
2002
|
+
"profile-save",
|
|
2003
|
+
"facts-agent",
|
|
2004
|
+
"facts-edit",
|
|
2005
|
+
"facts-add",
|
|
2006
|
+
"facts-save",
|
|
2007
|
+
"facts-archive",
|
|
2008
|
+
"facts-run",
|
|
2009
|
+
"flag-set",
|
|
2010
|
+
"choro-repo",
|
|
2011
|
+
"choro-page",
|
|
2012
|
+
"choro-run",
|
|
2013
|
+
]);
|
|
2014
|
+
|
|
2015
|
+
// Every action may change what the panel shows, so push fresh data before
|
|
2016
|
+
// asking the app to re-render (the refresh effect alone would re-render a
|
|
2017
|
+
// stale feed).
|
|
2018
|
+
const wrapped = {};
|
|
2019
|
+
for (const [id, handler] of Object.entries(actions)) {
|
|
2020
|
+
wrapped[id] = async (scope, event) => {
|
|
2021
|
+
let result;
|
|
2022
|
+
try {
|
|
2023
|
+
result = await handler(scope, event);
|
|
2024
|
+
} catch (error) {
|
|
2025
|
+
state.err = errorText(error);
|
|
2026
|
+
result = undefined;
|
|
2027
|
+
}
|
|
2028
|
+
// 默认重画视图:多数动作会改变「哪些节点存在」(进子页、空态、列表)。
|
|
2029
|
+
// 只改一个绑定值的动作(开关、档位、外部访问的开关)要显式关掉重画:
|
|
2030
|
+
// App 的 refresh 会让它重新拉一次视图树,而页签选择是 App 的本地状态,
|
|
2031
|
+
// 重建后回到第一个页签——用户正在设置页翻开关,会被弹回记忆页。
|
|
2032
|
+
const wanted = result ?? {};
|
|
2033
|
+
const skip = wanted.refresh === false || VALUE_ONLY.has(id) || id.startsWith("flag.");
|
|
2034
|
+
const effect = { ...wanted, refresh: !skip };
|
|
2035
|
+
// pending 动作(维护轮、地志重建)把真正的活儿放在 result 里,handler
|
|
2036
|
+
// 返回时它还没跑:此时立刻 broadcast 推出去的仍是这一轮之前的事实与
|
|
2037
|
+
// 地志列表。改成等 result 跑完再推(评审 #15)。
|
|
2038
|
+
if (typeof wanted.result === "function") {
|
|
2039
|
+
const inner = wanted.result;
|
|
2040
|
+
effect.result = async (...args) => {
|
|
2041
|
+
try {
|
|
2042
|
+
return await inner(...args);
|
|
2043
|
+
} finally {
|
|
2044
|
+
await broadcast();
|
|
2045
|
+
}
|
|
2046
|
+
};
|
|
2047
|
+
return effect;
|
|
2048
|
+
}
|
|
2049
|
+
await broadcast();
|
|
2050
|
+
return effect;
|
|
2051
|
+
};
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
// ----------------------------------------------------------------- panel
|
|
2055
|
+
ctx.dshGo.panel("mneme", {
|
|
2056
|
+
view() {
|
|
2057
|
+
return buildView(state);
|
|
2058
|
+
},
|
|
2059
|
+
data(scope, emit) {
|
|
2060
|
+
emitters.add(emit);
|
|
2061
|
+
// 挂载时读全:之后每个动作只重读当前页那一块(见 load 的注释)。
|
|
2062
|
+
load({ full: true }).then(
|
|
2063
|
+
(payload) => {
|
|
2064
|
+
if (emitters.has(emit)) emit(payload);
|
|
2065
|
+
},
|
|
2066
|
+
(error) => {
|
|
2067
|
+
if (emitters.has(emit)) emit({ notice: state.notice, err: errorText(error) });
|
|
2068
|
+
},
|
|
2069
|
+
);
|
|
2070
|
+
return () => {
|
|
2071
|
+
emitters.delete(emit);
|
|
2072
|
+
};
|
|
2073
|
+
},
|
|
2074
|
+
actions: wrapped,
|
|
2075
|
+
});
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
/**
|
|
2079
|
+
* 清掉模块级状态与缓存。在引擎重新挂载面板、以及测试之间切换场景时用它:
|
|
2080
|
+
* 这些表(列表缓存、按 scope 打开的记忆、提示语)是模块级的,跨 mount 复用会
|
|
2081
|
+
* 把上一个场景的残留带进下一个。
|
|
2082
|
+
*/
|
|
2083
|
+
export function resetPanelState() {
|
|
2084
|
+
cache.clear();
|
|
2085
|
+
emitters.clear();
|
|
2086
|
+
openedBy.clear();
|
|
2087
|
+
conflictBy.clear();
|
|
2088
|
+
factBy.clear();
|
|
2089
|
+
lastPayload = null;
|
|
2090
|
+
Object.assign(state, defaultState());
|
|
2091
|
+
}
|