dsh-memento 0.5.2 → 0.5.4
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/ARCHITECTURE.md +2 -0
- package/CHANGELOG.md +17 -0
- package/README.es.md +7 -3
- package/README.hi.md +7 -3
- package/README.md +7 -3
- package/README.pt.md +7 -3
- package/README.zh.md +7 -3
- package/client/client.js +576 -14
- package/index.mjs +287 -80
- package/package.json +5 -3
package/client/client.js
CHANGED
|
@@ -1,30 +1,43 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
// client/client.js — dsh-memento
|
|
2
|
+
// client/client.js — dsh-memento 浏览器半侧(零构建 vanilla,单模块)。
|
|
3
3
|
//
|
|
4
4
|
// host 端 dsh.client 扫描把本文件作为 classic script 注入 __DSH_BOOT__ 图,
|
|
5
|
-
// 执行时经 window.__ModuleLoader__.load
|
|
5
|
+
// 执行时经 window.__ModuleLoader__.load 注册唯一 factory(id = 插件名,与
|
|
6
|
+
// 宿主 graph row 一致;同文件多个 load 会产生永远不被物化的孤儿 factory)。
|
|
7
|
+
// apply 挂载两块表面:浮层抽屉面板 + 宿主设置弹窗的一级设置项。
|
|
6
8
|
// 面板只读:条目浏览/搜索/预算条/审计尾,全部走本插件自注册的
|
|
7
9
|
// /api/memento/* JSON 路由(只走公开 API)。写与审批在 DSH 内置审批 UI 完成,
|
|
8
10
|
// 面板不产生任何模型可见内容、不做任何审批决策。
|
|
9
11
|
// 面板文案随 Config.language(en/zh)切换,语言来自 entries 路由响应。
|
|
12
|
+
// 设置页经 ctx.settingsScope 读/写用户层(settings.yaml),暂存—保存语义
|
|
13
|
+
// 与宿主内置卡片一致;factory 的 require 由宿主模块系统提供(react 为平台
|
|
14
|
+
// 内置模块)。
|
|
10
15
|
|
|
11
|
-
(function () {
|
|
16
|
+
;(function () {
|
|
12
17
|
'use strict'
|
|
13
18
|
if (typeof window === 'undefined' || !window.__ModuleLoader__ || !window.__ModuleLoader__.load) return
|
|
14
19
|
window.__ModuleLoader__.load({
|
|
15
20
|
id: 'dsh-memento',
|
|
16
|
-
factory: function () {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
apply: function () { installPanel() },
|
|
21
|
-
}
|
|
22
|
-
},
|
|
23
|
-
})
|
|
24
|
-
})()
|
|
21
|
+
factory: function (require) {
|
|
22
|
+
const react = require('react')
|
|
23
|
+
/** createElement 简写(宿主平台内置 react,无需构建期 JSX 编译)。 */
|
|
24
|
+
const jsx = react.createElement
|
|
25
25
|
|
|
26
26
|
const PANEL_ID = 'dsh-memento-panel'
|
|
27
27
|
|
|
28
|
+
/** 本页已渲染的悬浮入口按钮引用(设置页 panel.enabled 开关即时切换用)。 */
|
|
29
|
+
let panelOpenButton = null
|
|
30
|
+
|
|
31
|
+
/** 悬浮窗开关的即时生效面:切显隐;开启时按钮缺失(上次探测为关)则重建。 */
|
|
32
|
+
function setPanelButtonVisible(visible) {
|
|
33
|
+
if (panelOpenButton === null) panelOpenButton = document.getElementById('mem-open')
|
|
34
|
+
if (panelOpenButton !== null) {
|
|
35
|
+
panelOpenButton.style.display = visible ? '' : 'none'
|
|
36
|
+
} else if (visible) {
|
|
37
|
+
void bootPanel()
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
28
41
|
/** 面板文案(en 源文 / zh 译文;语言来自 /api/memento/entries 响应的 language 字段,缺省 en)。 */
|
|
29
42
|
const STRINGS = {
|
|
30
43
|
en: {
|
|
@@ -63,9 +76,35 @@ const STRINGS = {
|
|
|
63
76
|
},
|
|
64
77
|
}
|
|
65
78
|
|
|
66
|
-
|
|
79
|
+
/**
|
|
80
|
+
* 启动探测:panel.enabled=false 时入口按钮不渲染(设置面板可随时改回);
|
|
81
|
+
* 探测失败按开启处理,行为与未引入开关前的版本一致。
|
|
82
|
+
* @returns {Promise<{enabled: boolean, language: string}>}。
|
|
83
|
+
*/
|
|
84
|
+
async function probePanelState() {
|
|
85
|
+
try {
|
|
86
|
+
const response = await fetch('/api/memento/entries?limit=1')
|
|
87
|
+
if (response.ok) {
|
|
88
|
+
const data = await response.json()
|
|
89
|
+
if (data.error === undefined) {
|
|
90
|
+
return { enabled: data.panel?.enabled !== false, language: data.language ?? 'en' }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
// 探测失败:保持默认开启。
|
|
95
|
+
}
|
|
96
|
+
return { enabled: true, language: 'en' }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function bootPanel() {
|
|
100
|
+
const state = await probePanelState()
|
|
101
|
+
if (!state.enabled) return
|
|
102
|
+
installPanel(state)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function installPanel(state) {
|
|
67
106
|
if (document.getElementById(PANEL_ID)) return
|
|
68
|
-
let S = STRINGS.en
|
|
107
|
+
let S = STRINGS[state.language] ?? STRINGS.en
|
|
69
108
|
|
|
70
109
|
const style = document.createElement('style')
|
|
71
110
|
style.textContent = `
|
|
@@ -95,6 +134,7 @@ function installPanel() {
|
|
|
95
134
|
const openBtn = document.createElement('button')
|
|
96
135
|
openBtn.id = 'mem-open'
|
|
97
136
|
openBtn.textContent = S.open
|
|
137
|
+
panelOpenButton = openBtn
|
|
98
138
|
|
|
99
139
|
const root = document.createElement('div')
|
|
100
140
|
root.id = PANEL_ID
|
|
@@ -239,3 +279,525 @@ function installPanel() {
|
|
|
239
279
|
function escapeHtml(value) {
|
|
240
280
|
return String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"')
|
|
241
281
|
}
|
|
282
|
+
|
|
283
|
+
// ── 宿主设置页(settings.section 一级项,id = dsh-memento)──────────────
|
|
284
|
+
/** 卡片文案(en 源文 / zh 译文;语言跟随 namespace value.language,保存后即时切换)。 */
|
|
285
|
+
const CARD_STRINGS = {
|
|
286
|
+
en: {
|
|
287
|
+
title: 'dsh-memento memory',
|
|
288
|
+
description: 'Approval-gated cross-session memory. Writes, snapshot wording and the floating panel follow these values.',
|
|
289
|
+
sectionPermissions: 'Write approval policy',
|
|
290
|
+
sectionPanel: 'Floating panel',
|
|
291
|
+
sectionLanguage: 'Language',
|
|
292
|
+
sectionBudgets: 'Character budgets (per track/layer)',
|
|
293
|
+
sectionLimits: 'Query & command limits',
|
|
294
|
+
sectionRecall: 'Recall defaults',
|
|
295
|
+
sectionPanelPage: 'Panel page limits',
|
|
296
|
+
sectionProposals: 'Auto-capture proposals',
|
|
297
|
+
sectionStorage: 'Storage & audit retention',
|
|
298
|
+
sectionAdvanced: 'Advanced (applied after DSH reload)',
|
|
299
|
+
writePolicy: 'Global write policy',
|
|
300
|
+
writePolicies: 'Per-track/scope policies',
|
|
301
|
+
writePoliciesHint: 'One per line: track/scope=policy or source:name=policy. Unknown keys fail validation on save.',
|
|
302
|
+
panelEnabled: 'Show the floating panel button',
|
|
303
|
+
language: 'Language',
|
|
304
|
+
budgetUserGlobal: 'user / user-global',
|
|
305
|
+
budgetUserWorkspace: 'user / workspace',
|
|
306
|
+
budgetAgentGlobal: 'agent / user-global',
|
|
307
|
+
budgetAgentWorkspace: 'agent / workspace',
|
|
308
|
+
maxEntriesPerQuery: 'query default limit',
|
|
309
|
+
commandListLimit: '/memory list|query page limit',
|
|
310
|
+
commandAuditLimit: '/memory audit page limit',
|
|
311
|
+
recallHistoryLimit: 'history sessions per recall',
|
|
312
|
+
recallSnippetCap: 'snippets per session',
|
|
313
|
+
recallSnippetChars: 'snippet characters',
|
|
314
|
+
recallWindowDays: 'history window (days)',
|
|
315
|
+
panelEntriesLimit: 'entries page limit',
|
|
316
|
+
panelAuditLimit: 'audit page limit',
|
|
317
|
+
proposalsEnabled: 'Generate proposals after compaction',
|
|
318
|
+
proposalsMaxChars: 'proposal max characters',
|
|
319
|
+
proposalsMaxPending: 'max pending proposals',
|
|
320
|
+
dbPath: 'Memory database path',
|
|
321
|
+
dbPathHint: 'Empty = default ($DSH_HOME/dsh-memento/memory.db). Saving reopens the store immediately.',
|
|
322
|
+
snapshotOrder: 'Snapshot section order',
|
|
323
|
+
auditRetentionDays: 'Audit retention (days, 0 = unlimited)',
|
|
324
|
+
retrievalVector: 'Vector recall (when an embedding provider exists)',
|
|
325
|
+
reloadHint: 'Applied after DSH reload',
|
|
326
|
+
overridden: 'Overridden',
|
|
327
|
+
reset: 'Reset',
|
|
328
|
+
resetField: 'Reset field',
|
|
329
|
+
save: 'Save',
|
|
330
|
+
saving: 'Saving…',
|
|
331
|
+
discard: 'Discard',
|
|
332
|
+
unsaved: 'Unsaved changes',
|
|
333
|
+
saveFailed: 'Save failed — drafts kept for correction.',
|
|
334
|
+
readOnly: 'Read-only (settings service unavailable).',
|
|
335
|
+
invalidNumber: 'Must be a whole number.',
|
|
336
|
+
invalidPolicy: 'Must be ask, auto or off.',
|
|
337
|
+
},
|
|
338
|
+
zh: {
|
|
339
|
+
title: 'dsh-memento 记忆',
|
|
340
|
+
description: '带审批门的跨会话记忆。写入策略、快照文案与悬浮窗跟随这些值。',
|
|
341
|
+
sectionPermissions: '写审批策略',
|
|
342
|
+
sectionPanel: '悬浮窗',
|
|
343
|
+
sectionLanguage: '语言',
|
|
344
|
+
sectionBudgets: '字符预算(每轨道/层)',
|
|
345
|
+
sectionLimits: '查询与命令上限',
|
|
346
|
+
sectionRecall: '召回默认值',
|
|
347
|
+
sectionPanelPage: '面板页上限',
|
|
348
|
+
sectionProposals: '自动捕捉提案',
|
|
349
|
+
sectionStorage: '存储与审计保留',
|
|
350
|
+
sectionAdvanced: '高级(DSH 重载后生效)',
|
|
351
|
+
writePolicy: '全局写策略',
|
|
352
|
+
writePolicies: '按轨道/层粒度策略',
|
|
353
|
+
writePoliciesHint: '每行一条:track/scope=策略 或 source:name=策略。保存时无法识别的键会被校验拒绝。',
|
|
354
|
+
panelEnabled: '显示悬浮窗入口按钮',
|
|
355
|
+
language: '语言',
|
|
356
|
+
budgetUserGlobal: 'user / user-global',
|
|
357
|
+
budgetUserWorkspace: 'user / workspace',
|
|
358
|
+
budgetAgentGlobal: 'agent / user-global',
|
|
359
|
+
budgetAgentWorkspace: 'agent / workspace',
|
|
360
|
+
maxEntriesPerQuery: 'query 默认上限',
|
|
361
|
+
commandListLimit: '/memory list|query 单页上限',
|
|
362
|
+
commandAuditLimit: '/memory audit 单页上限',
|
|
363
|
+
recallHistoryLimit: '每次召回扫描会话数',
|
|
364
|
+
recallSnippetCap: '每会话片段数',
|
|
365
|
+
recallSnippetChars: '片段字符数',
|
|
366
|
+
recallWindowDays: '历史窗口(天)',
|
|
367
|
+
panelEntriesLimit: '条目单页上限',
|
|
368
|
+
panelAuditLimit: '审计单页上限',
|
|
369
|
+
proposalsEnabled: '压缩结束后生成提案',
|
|
370
|
+
proposalsMaxChars: '提案最大字符数',
|
|
371
|
+
proposalsMaxPending: '待审批提案上限',
|
|
372
|
+
dbPath: '记忆库路径',
|
|
373
|
+
dbPathHint: '留空 = 默认($DSH_HOME/dsh-memento/memory.db)。保存后立即重开记忆库。',
|
|
374
|
+
snapshotOrder: '快照段注入顺序',
|
|
375
|
+
auditRetentionDays: '审计保留天数(0 = 不限)',
|
|
376
|
+
retrievalVector: '向量召回(存在 embedding provider 时)',
|
|
377
|
+
reloadHint: 'DSH 重载后生效',
|
|
378
|
+
overridden: '已覆盖',
|
|
379
|
+
reset: '重置',
|
|
380
|
+
resetField: '重置该字段',
|
|
381
|
+
save: '保存',
|
|
382
|
+
saving: '保存中…',
|
|
383
|
+
discard: '放弃修改',
|
|
384
|
+
unsaved: '有未保存修改',
|
|
385
|
+
saveFailed: '保存失败——草稿已保留,可修正后重试。',
|
|
386
|
+
readOnly: '只读(设置服务不可用)。',
|
|
387
|
+
invalidNumber: '必须是整数。',
|
|
388
|
+
invalidPolicy: '必须是 ask、auto 或 off。',
|
|
389
|
+
},
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** 顶层字段名(保存按顶层聚合:scope.set(topField, 合并值),不依赖点路径写入面)。 */
|
|
393
|
+
const POLICIES = ['ask', 'auto', 'off']
|
|
394
|
+
|
|
395
|
+
/** 简版快照 store(useSyncExternalStore 形状:{getSnapshot, subscribe}+set)。 */
|
|
396
|
+
function createSnapshotStore(initial) {
|
|
397
|
+
let snapshot = initial
|
|
398
|
+
const listeners = new Set()
|
|
399
|
+
return {
|
|
400
|
+
getSnapshot() { return snapshot },
|
|
401
|
+
subscribe(fn) { listeners.add(fn); return () => { listeners.delete(fn) } },
|
|
402
|
+
set(next) { snapshot = next; for (const fn of listeners) fn() },
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** 读点路径('a.b' → obj?.a?.b)。 */
|
|
407
|
+
function pathValue(obj, path) {
|
|
408
|
+
let cursor = obj
|
|
409
|
+
for (const key of path.split('.')) {
|
|
410
|
+
if (cursor === null || typeof cursor !== 'object') return undefined
|
|
411
|
+
cursor = cursor[key]
|
|
412
|
+
}
|
|
413
|
+
return cursor
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** 判定子字段是否被用户层覆盖(user 层子路径 hasOwn)。 */
|
|
417
|
+
function pathStored(user, path) {
|
|
418
|
+
const keys = path.split('.')
|
|
419
|
+
const last = keys.pop()
|
|
420
|
+
let cursor = user
|
|
421
|
+
for (const key of keys) {
|
|
422
|
+
if (cursor === null || typeof cursor !== 'object') return false
|
|
423
|
+
cursor = cursor[key]
|
|
424
|
+
}
|
|
425
|
+
return cursor !== null && typeof cursor === 'object' && Object.hasOwn(cursor, last)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** 深合并(草稿子树 → 当前顶层值;数组与标量直接替换)。 */
|
|
429
|
+
function deepMerge(base, patch) {
|
|
430
|
+
if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) return patch
|
|
431
|
+
const out = { ...(base !== null && typeof base === 'object' && !Array.isArray(base) ? base : {}) }
|
|
432
|
+
for (const [key, value] of Object.entries(patch)) out[key] = deepMerge(out[key], value)
|
|
433
|
+
return out
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** 子字段规格:path + kind(number/text/bool/choice/policies)+ choice 候选 + key(文案键)。 */
|
|
437
|
+
const FIELD_SPECS = [
|
|
438
|
+
{ path: 'writePolicy', kind: 'choice', choices: POLICIES, key: 'writePolicy' },
|
|
439
|
+
{ path: 'writePolicies', kind: 'policies', key: 'writePolicies' },
|
|
440
|
+
{ path: 'panel.enabled', kind: 'bool', key: 'panelEnabled' },
|
|
441
|
+
{ path: 'language', kind: 'choice', choices: ['en', 'zh'], key: 'language' },
|
|
442
|
+
{ path: 'budgets.user.userGlobal', kind: 'number', key: 'budgetUserGlobal' },
|
|
443
|
+
{ path: 'budgets.user.workspace', kind: 'number', key: 'budgetUserWorkspace' },
|
|
444
|
+
{ path: 'budgets.agent.userGlobal', kind: 'number', key: 'budgetAgentGlobal' },
|
|
445
|
+
{ path: 'budgets.agent.workspace', kind: 'number', key: 'budgetAgentWorkspace' },
|
|
446
|
+
{ path: 'maxEntriesPerQuery', kind: 'number', key: 'maxEntriesPerQuery' },
|
|
447
|
+
{ path: 'commandListLimit', kind: 'number', key: 'commandListLimit' },
|
|
448
|
+
{ path: 'commandAuditLimit', kind: 'number', key: 'commandAuditLimit' },
|
|
449
|
+
{ path: 'recall.historyLimitDefault', kind: 'number', key: 'recallHistoryLimit' },
|
|
450
|
+
{ path: 'recall.snippetCap', kind: 'number', key: 'recallSnippetCap' },
|
|
451
|
+
{ path: 'recall.snippetChars', kind: 'number', key: 'recallSnippetChars' },
|
|
452
|
+
{ path: 'recall.windowDays', kind: 'number', key: 'recallWindowDays' },
|
|
453
|
+
{ path: 'panelEntriesLimit', kind: 'number', key: 'panelEntriesLimit' },
|
|
454
|
+
{ path: 'panelAuditLimit', kind: 'number', key: 'panelAuditLimit' },
|
|
455
|
+
{ path: 'proposals.enabled', kind: 'bool', key: 'proposalsEnabled' },
|
|
456
|
+
{ path: 'proposals.maxChars', kind: 'number', key: 'proposalsMaxChars' },
|
|
457
|
+
{ path: 'proposals.maxPending', kind: 'number', key: 'proposalsMaxPending' },
|
|
458
|
+
{ path: 'dbPath', kind: 'text', key: 'dbPath' },
|
|
459
|
+
{ path: 'snapshotOrder', kind: 'number', key: 'snapshotOrder' },
|
|
460
|
+
{ path: 'auditRetentionDays', kind: 'number', key: 'auditRetentionDays' },
|
|
461
|
+
{ path: 'retrieval.vector', kind: 'bool', key: 'retrievalVector' },
|
|
462
|
+
]
|
|
463
|
+
const SPEC_BY_PATH = new Map(FIELD_SPECS.map((spec) => [spec.path, spec]))
|
|
464
|
+
const RELOAD_PATHS = new Set(['snapshotOrder'])
|
|
465
|
+
|
|
466
|
+
/** 草稿文本 → 顶层字段写入计划;无法解析的草稿返回 undefined(阻塞保存)。 */
|
|
467
|
+
function parseDraftText(spec, text, currentValue) {
|
|
468
|
+
if (spec.kind === 'bool') {
|
|
469
|
+
if (text === 'true') return { ok: true, value: true }
|
|
470
|
+
if (text === 'false') return { ok: true, value: false }
|
|
471
|
+
return { ok: false }
|
|
472
|
+
}
|
|
473
|
+
if (spec.kind === 'choice') {
|
|
474
|
+
return spec.choices.includes(text) ? { ok: true, value: text } : { ok: false }
|
|
475
|
+
}
|
|
476
|
+
if (spec.kind === 'number') {
|
|
477
|
+
const trimmed = text.trim()
|
|
478
|
+
const parsed = Number(trimmed)
|
|
479
|
+
return trimmed !== '' && Number.isFinite(parsed) && Number.isInteger(parsed) ? { ok: true, value: parsed } : { ok: false }
|
|
480
|
+
}
|
|
481
|
+
if (spec.kind === 'policies') {
|
|
482
|
+
const next = {}
|
|
483
|
+
for (const rawLine of text.split('\n')) {
|
|
484
|
+
const line = rawLine.trim()
|
|
485
|
+
if (line === '') continue
|
|
486
|
+
const eq = line.indexOf('=')
|
|
487
|
+
if (eq <= 0) return { ok: false }
|
|
488
|
+
const key = line.slice(0, eq).trim()
|
|
489
|
+
const policy = line.slice(eq + 1).trim()
|
|
490
|
+
if (key === '' || !POLICIES.includes(policy)) return { ok: false }
|
|
491
|
+
next[key] = policy
|
|
492
|
+
}
|
|
493
|
+
return { ok: true, value: next }
|
|
494
|
+
}
|
|
495
|
+
// text:空串 = 恢复 base 语义由 clear 处理,这里空串写空字符串(dbPath 空 = 默认路径)。
|
|
496
|
+
void currentValue
|
|
497
|
+
return { ok: true, value: text }
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** 子字段格式化(快照值 → 控件文本)。 */
|
|
501
|
+
function formatValue(value) {
|
|
502
|
+
if (value === undefined || value === null) return ''
|
|
503
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false'
|
|
504
|
+
return String(value)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** policies 顶层对象的控件文本(每行 key=policy)。 */
|
|
508
|
+
function formatPolicies(value) {
|
|
509
|
+
if (value === null || typeof value !== 'object') return ''
|
|
510
|
+
return Object.entries(value).map(([key, policy]) => `${key}=${policy}`).join('\n')
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* 暂存表单(学宿主 CardForm:staged → save 才写;revision 栅防并发覆盖)。
|
|
515
|
+
* 顶层聚合:同顶层字段的多个子字段草稿一次 scope.set(top, 合并值)。
|
|
516
|
+
*/
|
|
517
|
+
class CardForm {
|
|
518
|
+
constructor(scope, onLanded) {
|
|
519
|
+
this.scope = scope
|
|
520
|
+
/** @type {((tops: Map<string, object>) => void) | undefined} 落盘成功回调(panel 显隐同步用)。 */
|
|
521
|
+
this.onLanded = onLanded
|
|
522
|
+
/** @type {Map<string, string>} */
|
|
523
|
+
this.staged = new Map()
|
|
524
|
+
this.saving = false
|
|
525
|
+
this.failed = false
|
|
526
|
+
this.listeners = new Set()
|
|
527
|
+
scope.subscribe(() => this.publish())
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
bind(project) {
|
|
531
|
+
const store = createSnapshotStore(project())
|
|
532
|
+
this.listeners.add(() => store.set(project()))
|
|
533
|
+
return store
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
stage(path, text) {
|
|
537
|
+
this.staged.set(path, text)
|
|
538
|
+
this.failed = false
|
|
539
|
+
this.publish()
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
discard() {
|
|
543
|
+
if (this.staged.size === 0 && !this.failed) return
|
|
544
|
+
this.staged.clear()
|
|
545
|
+
this.failed = false
|
|
546
|
+
this.publish()
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async save() {
|
|
550
|
+
if (this.saving || this.staged.size === 0) return
|
|
551
|
+
const snapshot = this.scope.getSnapshot()
|
|
552
|
+
if (snapshot.status !== 'ready' || !snapshot.writable) return
|
|
553
|
+
/** @type {Map<string, object>} */
|
|
554
|
+
const tops = new Map()
|
|
555
|
+
let valid = true
|
|
556
|
+
for (const [path, text] of this.staged) {
|
|
557
|
+
const spec = SPEC_BY_PATH.get(path)
|
|
558
|
+
const keys = path.split('.')
|
|
559
|
+
const top = keys[0]
|
|
560
|
+
const parsed = parseDraftText(spec, text, snapshot.value)
|
|
561
|
+
if (!parsed.ok) { valid = false; continue }
|
|
562
|
+
const base = tops.get(top) ?? (snapshot.value !== null && typeof snapshot.value === 'object' ? snapshot.value[top] : undefined)
|
|
563
|
+
const merged = keys.length === 1
|
|
564
|
+
? parsed.value
|
|
565
|
+
: deepMerge(base, (() => { /** @type {Record<string, unknown>} */ const out = {}; let cursor = out; for (let i = 1; i < keys.length - 1; i++) { cursor[keys[i]] = {}; cursor = cursor[keys[i]] } cursor[keys[keys.length - 1]] = parsed.value; return out })())
|
|
566
|
+
tops.set(top, merged)
|
|
567
|
+
}
|
|
568
|
+
if (!valid || this.staged.size === 0) { this.publish(); return }
|
|
569
|
+
this.saving = true
|
|
570
|
+
this.failed = false
|
|
571
|
+
this.publish()
|
|
572
|
+
let landed = true
|
|
573
|
+
for (const [top, value] of tops) {
|
|
574
|
+
try {
|
|
575
|
+
await this.scope.set(top, value)
|
|
576
|
+
} catch {
|
|
577
|
+
landed = false
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
if (landed && typeof this.onLanded === 'function') this.onLanded(tops)
|
|
581
|
+
if (landed) this.staged.clear()
|
|
582
|
+
this.saving = false
|
|
583
|
+
this.failed = !landed
|
|
584
|
+
this.publish()
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
fieldState(path) {
|
|
588
|
+
const spec = SPEC_BY_PATH.get(path)
|
|
589
|
+
const snapshot = this.scope.getSnapshot()
|
|
590
|
+
const staged = this.staged.get(path)
|
|
591
|
+
const effective = pathValue(snapshot.value, path)
|
|
592
|
+
const overridden = pathStored(snapshot.user, path)
|
|
593
|
+
if (staged === undefined) {
|
|
594
|
+
const text = spec.kind === 'policies' ? formatPolicies(effective) : formatValue(effective)
|
|
595
|
+
return { text, overridden, invalid: false }
|
|
596
|
+
}
|
|
597
|
+
const parsed = parseDraftText(spec, staged, snapshot.value)
|
|
598
|
+
return { text: staged, overridden, invalid: !parsed.ok }
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
shell() {
|
|
602
|
+
const snapshot = this.scope.getSnapshot()
|
|
603
|
+
let invalid = false
|
|
604
|
+
for (const [path, text] of this.staged) {
|
|
605
|
+
if (!parseDraftText(SPEC_BY_PATH.get(path), text, snapshot.value).ok) invalid = true
|
|
606
|
+
}
|
|
607
|
+
// 界面语言跟随「所选」语言:草稿里的 language 合法即生效,保存与否不影响 UI 语言。
|
|
608
|
+
const stagedLanguage = this.staged.get('language')
|
|
609
|
+
const draftLanguage = stagedLanguage === 'en' || stagedLanguage === 'zh' ? stagedLanguage : undefined
|
|
610
|
+
return {
|
|
611
|
+
available: snapshot.status === 'ready',
|
|
612
|
+
writable: snapshot.writable === true,
|
|
613
|
+
dirty: this.staged.size > 0,
|
|
614
|
+
invalid,
|
|
615
|
+
saving: this.saving,
|
|
616
|
+
failed: this.failed,
|
|
617
|
+
language: draftLanguage ?? snapshot.value?.language,
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
publish() {
|
|
622
|
+
for (const listener of this.listeners) listener()
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** 字段分组(卡片 UI 布局;reload 组标注重载生效)。 */
|
|
627
|
+
const FIELD_GROUPS = [
|
|
628
|
+
{ key: 'sectionPermissions', paths: ['writePolicy', 'writePolicies'] },
|
|
629
|
+
{ key: 'sectionPanel', paths: ['panel.enabled'] },
|
|
630
|
+
{ key: 'sectionLanguage', paths: ['language'] },
|
|
631
|
+
{ key: 'sectionBudgets', paths: ['budgets.user.userGlobal', 'budgets.user.workspace', 'budgets.agent.userGlobal', 'budgets.agent.workspace'] },
|
|
632
|
+
{ key: 'sectionLimits', paths: ['maxEntriesPerQuery', 'commandListLimit', 'commandAuditLimit'] },
|
|
633
|
+
{ key: 'sectionRecall', paths: ['recall.historyLimitDefault', 'recall.snippetCap', 'recall.snippetChars', 'recall.windowDays', 'retrieval.vector'] },
|
|
634
|
+
{ key: 'sectionPanelPage', paths: ['panelEntriesLimit', 'panelAuditLimit'] },
|
|
635
|
+
{ key: 'sectionProposals', paths: ['proposals.enabled', 'proposals.maxChars', 'proposals.maxPending'] },
|
|
636
|
+
{ key: 'sectionStorage', paths: ['dbPath', 'auditRetentionDays'] },
|
|
637
|
+
{ key: 'sectionAdvanced', paths: ['snapshotOrder'] },
|
|
638
|
+
]
|
|
639
|
+
|
|
640
|
+
let styleInstalled = false
|
|
641
|
+
const CARD_CSS = `
|
|
642
|
+
.memsec { max-width: 720px; color: inherit; font-size: 13px; }
|
|
643
|
+
.memsec-title { margin: 0 0 4px; font-size: 16px; font-weight: 600; }
|
|
644
|
+
.memsec-desc { margin: 0 0 8px; font-size: 13px; color: var(--dsw-alias-label-secondary, #8a93a6); }
|
|
645
|
+
.memsec-note { margin: 0 0 8px; font-size: 12px; color: var(--dsw-alias-label-tertiary, #98a2b3); }
|
|
646
|
+
.memsec-group { margin: 20px 0 0; padding-bottom: 4px; font-size: 12px; font-weight: 600; color: var(--dsw-alias-label-secondary, #6f87ad); }
|
|
647
|
+
.memcard-field { display: flex; flex-direction: column; padding: 8px 0; border-top: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.22)); }
|
|
648
|
+
.memcard-row { display: flex; flex: 1; flex-wrap: wrap; min-width: 0; align-items: center; gap: 8px; }
|
|
649
|
+
.memcard-label { flex: 1 1 auto; min-width: 0; font-size: 13px; }
|
|
650
|
+
.memcard-input { border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35)); background: transparent; color: inherit; border-radius: 6px; padding: 4px 8px; font: inherit; min-width: 0; width: 240px; }
|
|
651
|
+
.memcard-input[aria-invalid="true"] { border-color: var(--dsw-alias-label-error, #e5534b); }
|
|
652
|
+
.memcard-textarea { flex-basis: 100%; width: 100%; min-height: 64px; font: 12px/1.5 ui-monospace, monospace; resize: vertical; }
|
|
653
|
+
.memcard-hint, .memcard-invalid { margin: 2px 0 0; font-size: 12px; }
|
|
654
|
+
.memcard-hint { color: var(--dsw-alias-label-tertiary, #98a2b3); }
|
|
655
|
+
.memcard-invalid { color: var(--dsw-alias-label-error, #e5534b); }
|
|
656
|
+
.memcard-override { font-size: 11px; color: var(--dsw-alias-label-secondary, #98a2b3); white-space: nowrap; }
|
|
657
|
+
.memcard-badge { white-space: nowrap; background: var(--dsw-alias-bg-module-platform, rgba(128, 128, 128, 0.15)); color: var(--dsw-alias-label-secondary, #98a2b3); border-radius: 999px; padding: 1px 8px; font-size: 11px; }
|
|
658
|
+
.memcard-reset { font: inherit; font-size: 12px; color: var(--dsw-alias-label-secondary, #98a2b3); cursor: pointer; background: 0 0; border: none; padding: 0; white-space: nowrap; }
|
|
659
|
+
.memsec-footer { display: flex; gap: 8px; align-items: center; margin: 0 0 6px; }
|
|
660
|
+
.memsec-spacer { flex: 1; }
|
|
661
|
+
.memsec-dirty { font-size: 12px; color: var(--dsw-alias-label-secondary, #98a2b3); }
|
|
662
|
+
.memsec-failed { font-size: 12px; color: var(--dsw-alias-label-error, #e5534b); }
|
|
663
|
+
.memsec-btn { font: inherit; font-size: 13px; border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35)); background: transparent; color: inherit; border-radius: 6px; padding: 4px 14px; cursor: pointer; }
|
|
664
|
+
.memsec-btn:disabled { opacity: 0.5; cursor: default; }
|
|
665
|
+
`
|
|
666
|
+
|
|
667
|
+
/** 单字段控件行(label + input/checkbox/select + override 徽标 + reset + hint/invalid)。 */
|
|
668
|
+
function FieldRow(props) {
|
|
669
|
+
const { t, spec, state, disabled, onEdit, onReset } = props
|
|
670
|
+
const label = t(spec.key ?? spec.path)
|
|
671
|
+
const reloadBadge = RELOAD_PATHS.has(spec.path) ? jsx('span', { className: 'memcard-badge', title: t('reloadHint') }, '⟳') : null
|
|
672
|
+
const invalid = spec.kind === 'choice' ? t('invalidPolicy') : t('invalidNumber')
|
|
673
|
+
const control = spec.kind === 'bool'
|
|
674
|
+
? jsx('input', {
|
|
675
|
+
type: 'checkbox', disabled,
|
|
676
|
+
checked: state.text === 'true',
|
|
677
|
+
onChange: (event) => onEdit(event.target.checked ? 'true' : 'false'),
|
|
678
|
+
})
|
|
679
|
+
: spec.kind === 'choice'
|
|
680
|
+
? jsx('select', {
|
|
681
|
+
className: 'memcard-input', disabled, value: state.text,
|
|
682
|
+
onChange: (event) => onEdit(event.target.value),
|
|
683
|
+
}, spec.choices.map((choice) => jsx('option', { key: choice, value: choice }, choice)))
|
|
684
|
+
: spec.kind === 'policies'
|
|
685
|
+
? jsx('textarea', {
|
|
686
|
+
className: 'memcard-input memcard-textarea', disabled,
|
|
687
|
+
value: state.text, 'aria-invalid': state.invalid,
|
|
688
|
+
onChange: (event) => onEdit(event.target.value),
|
|
689
|
+
})
|
|
690
|
+
: jsx('input', {
|
|
691
|
+
type: spec.kind === 'number' ? 'text' : 'text',
|
|
692
|
+
inputMode: spec.kind === 'number' ? 'numeric' : undefined,
|
|
693
|
+
className: 'memcard-input', disabled,
|
|
694
|
+
value: state.text, 'aria-invalid': state.invalid,
|
|
695
|
+
onChange: (event) => onEdit(event.target.value),
|
|
696
|
+
})
|
|
697
|
+
return jsx('div', { className: 'memcard-field' },
|
|
698
|
+
jsx('div', { className: 'memcard-row' },
|
|
699
|
+
jsx('label', { className: 'memcard-label' }, label, state.overridden ? jsx('span', { className: 'memcard-override' }, ` · ${t('overridden')}`) : null, reloadBadge),
|
|
700
|
+
state.overridden ? jsx('button', { type: 'button', className: 'memcard-reset', disabled, title: t('resetField'), onClick: onReset }, t('reset')) : null,
|
|
701
|
+
control,
|
|
702
|
+
),
|
|
703
|
+
spec.path === 'writePolicies' ? jsx('p', { className: 'memcard-hint' }, t('writePoliciesHint'))
|
|
704
|
+
: spec.path === 'dbPath' ? jsx('p', { className: 'memcard-hint' }, t('dbPathHint'))
|
|
705
|
+
: null,
|
|
706
|
+
state.invalid ? jsx('p', { className: 'memcard-invalid' }, invalid) : null,
|
|
707
|
+
)
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** 设置页组件(settings.section 渲染入口;hooks share: mementoCard → useMementoCard)。 */
|
|
711
|
+
function MementoSection(props) {
|
|
712
|
+
const state = props.useMementoCard((snapshot) => snapshot)
|
|
713
|
+
const language = state.language === 'zh' ? 'zh' : 'en'
|
|
714
|
+
const t = (key) => CARD_STRINGS[language][key] ?? key
|
|
715
|
+
const blocked = !state.dirty || state.saving || state.invalid
|
|
716
|
+
return jsx('div', { className: 'memsec' },
|
|
717
|
+
jsx('h2', { className: 'memsec-title' }, t('title')),
|
|
718
|
+
jsx('p', { className: 'memsec-desc' }, t('description')),
|
|
719
|
+
!state.available || !state.writable ? jsx('p', { className: 'memsec-note' }, t('readOnly')) : null,
|
|
720
|
+
jsx('div', { className: 'memsec-footer' },
|
|
721
|
+
state.dirty ? jsx('span', { className: 'memsec-dirty' }, t('unsaved')) : null,
|
|
722
|
+
state.failed ? jsx('span', { className: 'memsec-failed' }, t('saveFailed')) : null,
|
|
723
|
+
jsx('span', { className: 'memsec-spacer' }),
|
|
724
|
+
jsx('button', { type: 'button', className: 'memsec-btn', disabled: !state.dirty || state.saving, onClick: props.discard }, t('discard')),
|
|
725
|
+
jsx('button', { type: 'button', className: 'memsec-btn', disabled: blocked, onClick: props.save }, state.saving ? t('saving') : t('save')),
|
|
726
|
+
),
|
|
727
|
+
state.available
|
|
728
|
+
? FIELD_GROUPS.map((group) => jsx('div', { key: group.key },
|
|
729
|
+
jsx('div', { className: 'memsec-group' }, t(group.key)),
|
|
730
|
+
group.paths.map((path) => {
|
|
731
|
+
const spec = SPEC_BY_PATH.get(path)
|
|
732
|
+
return jsx(FieldRow, {
|
|
733
|
+
key: path, t, spec, disabled: !state.writable,
|
|
734
|
+
state: state.fields[path],
|
|
735
|
+
onEdit: (text) => props.edit(path, text),
|
|
736
|
+
onReset: () => {
|
|
737
|
+
const baseValue = pathValue(props.base, path)
|
|
738
|
+
props.edit(path, spec.kind === 'policies' ? formatPolicies(baseValue) : formatValue(baseValue))
|
|
739
|
+
},
|
|
740
|
+
})
|
|
741
|
+
}),
|
|
742
|
+
))
|
|
743
|
+
: null,
|
|
744
|
+
)
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/** 控制器:scope → 暂存表单 → 设置页快照。保存落盘后同步本页悬浮按钮显隐。 */
|
|
748
|
+
class MementoCardController {
|
|
749
|
+
constructor(scope) {
|
|
750
|
+
this.scope = scope
|
|
751
|
+
this.form = new CardForm(scope, (tops) => {
|
|
752
|
+
const panel = tops instanceof Map ? tops.get('panel') : undefined
|
|
753
|
+
if (panel !== null && typeof panel === 'object' && 'enabled' in panel) {
|
|
754
|
+
setPanelButtonVisible(panel.enabled === true)
|
|
755
|
+
}
|
|
756
|
+
})
|
|
757
|
+
this.store = this.form.bind(() => this.projection())
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
projection() {
|
|
761
|
+
/** @type {Record<string, {text: string, overridden: boolean, invalid: boolean}>} */
|
|
762
|
+
const fields = {}
|
|
763
|
+
for (const spec of FIELD_SPECS) fields[spec.path] = this.form.fieldState(spec.path)
|
|
764
|
+
return { ...this.form.shell(), fields, base: this.scope.getSnapshot().base }
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
inject() {
|
|
768
|
+
return {
|
|
769
|
+
hooks: { mementoCard: this.store },
|
|
770
|
+
edit: (path, text) => this.form.stage(path, text),
|
|
771
|
+
discard: () => this.form.discard(),
|
|
772
|
+
save: () => { void this.form.save() },
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function apply(ctx) {
|
|
778
|
+
void bootPanel()
|
|
779
|
+
if (!styleInstalled && typeof document !== 'undefined') {
|
|
780
|
+
styleInstalled = true
|
|
781
|
+
const tag = document.createElement('style')
|
|
782
|
+
tag.dataset.plugin = 'dsh-memento'
|
|
783
|
+
tag.textContent = CARD_CSS
|
|
784
|
+
document.head.appendChild(tag)
|
|
785
|
+
}
|
|
786
|
+
const controller = new MementoCardController(ctx.settingsScope.bind({ namespace: 'dsh-memento' }))
|
|
787
|
+
ctx.effect(() => ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
788
|
+
name: 'settings.section',
|
|
789
|
+
id: 'dsh-memento',
|
|
790
|
+
order: 16,
|
|
791
|
+
label: 'dsh-memento',
|
|
792
|
+
inject: () => controller.inject(),
|
|
793
|
+
}, MementoSection)), 'dsh-memento: settings section')
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
return {
|
|
797
|
+
name: 'memento-client',
|
|
798
|
+
inject: ['slots', 'settingsScope'],
|
|
799
|
+
apply,
|
|
800
|
+
}
|
|
801
|
+
},
|
|
802
|
+
})
|
|
803
|
+
})()
|