@alanzhao/dsh-memory-lite 0.1.1
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 -0
- package/README.md +112 -0
- package/cordis.patch.yml +9 -0
- package/lib/catalog.d.ts +35 -0
- package/lib/catalog.js +145 -0
- package/lib/catalog.js.map +1 -0
- package/lib/client.js +631 -0
- package/lib/config.d.ts +133 -0
- package/lib/config.js +173 -0
- package/lib/config.js.map +1 -0
- package/lib/extract/checkpoint.d.ts +60 -0
- package/lib/extract/checkpoint.js +42 -0
- package/lib/extract/checkpoint.js.map +1 -0
- package/lib/extract/decision.d.ts +36 -0
- package/lib/extract/decision.js +118 -0
- package/lib/extract/decision.js.map +1 -0
- package/lib/extract/digest.d.ts +12 -0
- package/lib/extract/digest.js +25 -0
- package/lib/extract/digest.js.map +1 -0
- package/lib/extract/index.d.ts +52 -0
- package/lib/extract/index.js +489 -0
- package/lib/extract/index.js.map +1 -0
- package/lib/extract/prompt.d.ts +20 -0
- package/lib/extract/prompt.js +63 -0
- package/lib/extract/prompt.js.map +1 -0
- package/lib/extract/triggers.d.ts +15 -0
- package/lib/extract/triggers.js +26 -0
- package/lib/extract/triggers.js.map +1 -0
- package/lib/extract/window.d.ts +48 -0
- package/lib/extract/window.js +110 -0
- package/lib/extract/window.js.map +1 -0
- package/lib/index.d.ts +29 -0
- package/lib/index.js +92 -0
- package/lib/index.js.map +1 -0
- package/lib/inject.d.ts +45 -0
- package/lib/inject.js +102 -0
- package/lib/inject.js.map +1 -0
- package/lib/memory-store.d.ts +147 -0
- package/lib/memory-store.js +494 -0
- package/lib/memory-store.js.map +1 -0
- package/lib/path.d.ts +24 -0
- package/lib/path.js +54 -0
- package/lib/path.js.map +1 -0
- package/lib/peer.d.ts +16 -0
- package/lib/peer.js +38 -0
- package/lib/peer.js.map +1 -0
- package/lib/status.d.ts +44 -0
- package/lib/status.js +42 -0
- package/lib/status.js.map +1 -0
- package/lib/tool-utils.d.ts +25 -0
- package/lib/tool-utils.js +16 -0
- package/lib/tool-utils.js.map +1 -0
- package/lib/tools/forget-memory.d.ts +9 -0
- package/lib/tools/forget-memory.js +39 -0
- package/lib/tools/forget-memory.js.map +1 -0
- package/lib/tools/read-memory.d.ts +8 -0
- package/lib/tools/read-memory.js +41 -0
- package/lib/tools/read-memory.js.map +1 -0
- package/lib/tools/remember.d.ts +9 -0
- package/lib/tools/remember.js +65 -0
- package/lib/tools/remember.js.map +1 -0
- package/lib/tools/search-memory.d.ts +8 -0
- package/lib/tools/search-memory.js +49 -0
- package/lib/tools/search-memory.js.map +1 -0
- package/lib/tools/update-memory.d.ts +9 -0
- package/lib/tools/update-memory.js +46 -0
- package/lib/tools/update-memory.js.map +1 -0
- package/lib/types.d.ts +35 -0
- package/lib/types.js +7 -0
- package/lib/types.js.map +1 -0
- package/package.json +81 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
// dsh-memory-lite — browser half.
|
|
2
|
+
//
|
|
3
|
+
// Two surfaces:
|
|
4
|
+
// 1. A compact session-header indicator (conversation.session.header.utilities)
|
|
5
|
+
// beside the built-in "Session log" capsule: label + 3-state dot
|
|
6
|
+
// (green ok / red error / gray disabled), polling /memory-status every
|
|
7
|
+
// 10s; a failed poll renders red (plugin unreachable).
|
|
8
|
+
// 2. A settings page (settings.section) editing the dsh-memory-lite
|
|
9
|
+
// namespace through ctx.settingsScope: staged drafts + Save/Discard,
|
|
10
|
+
// like the official plugin cards.
|
|
11
|
+
//
|
|
12
|
+
// Hand-written classic-script bundle: the module table answers require() for
|
|
13
|
+
// react / react/jsx-runtime only; everything else is inlined. No build step,
|
|
14
|
+
// no CSS files — inline styles with design-system variables.
|
|
15
|
+
|
|
16
|
+
window.__ModuleLoader__.load({
|
|
17
|
+
id: '@alanzhao/dsh-memory-lite',
|
|
18
|
+
factory: (require) => {
|
|
19
|
+
var module = { exports: {} };
|
|
20
|
+
var exports = module.exports;
|
|
21
|
+
|
|
22
|
+
const { jsx, jsxs } = require('react/jsx-runtime');
|
|
23
|
+
const { useCallback, useEffect, useState } = require('react');
|
|
24
|
+
|
|
25
|
+
const POLL_MS = 10000;
|
|
26
|
+
const LABEL = 'memory-lite';
|
|
27
|
+
const NS = 'dsh-memory-lite';
|
|
28
|
+
|
|
29
|
+
const dotColor = {
|
|
30
|
+
ok: 'var(--dsw-alias-state-success-primary)',
|
|
31
|
+
error: 'var(--dsw-alias-state-error-primary)',
|
|
32
|
+
disabled: 'var(--dsw-alias-label-tertiary)',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const fmtTime = (at) => {
|
|
36
|
+
if (!at) return '';
|
|
37
|
+
const d = new Date(at);
|
|
38
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
39
|
+
return p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const tooltipOf = (down, snap) => {
|
|
43
|
+
if (down) return 'memory-lite: 插件不可达(red)';
|
|
44
|
+
if (!snap) return LABEL;
|
|
45
|
+
if (snap.status === 'disabled') return 'memory-lite: 提取未启用(mode: ' + snap.mode + ')';
|
|
46
|
+
if (snap.status === 'error') {
|
|
47
|
+
const last = snap.last || {};
|
|
48
|
+
return 'memory-lite: 异常(' + (last.note || last.outcome || '未知错误') + ')@ ' + fmtTime(last.at);
|
|
49
|
+
}
|
|
50
|
+
if (snap.last) return 'memory-lite: 正常 · 上次 ' + (snap.last.outcome || '') + ' @ ' + fmtTime(snap.last.at);
|
|
51
|
+
return 'memory-lite: 正常(尚无运行)';
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// ---- header status dot (order reads the settings namespace) ----
|
|
55
|
+
|
|
56
|
+
function StatusDot({ connection }) {
|
|
57
|
+
const [snap, setSnap] = useState(null);
|
|
58
|
+
const [down, setDown] = useState(false);
|
|
59
|
+
|
|
60
|
+
const tick = useCallback(async () => {
|
|
61
|
+
try {
|
|
62
|
+
const result = await connection.rpc.call('/memory-status', 'snapshot', {});
|
|
63
|
+
if (result && result.ok && result.value) {
|
|
64
|
+
setSnap(result.value);
|
|
65
|
+
setDown(false);
|
|
66
|
+
} else {
|
|
67
|
+
setDown(true);
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
setDown(true);
|
|
71
|
+
}
|
|
72
|
+
}, [connection]);
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
tick();
|
|
76
|
+
const timer = window.setInterval(tick, POLL_MS);
|
|
77
|
+
const onVisible = () => {
|
|
78
|
+
if (document.visibilityState === 'visible') tick();
|
|
79
|
+
};
|
|
80
|
+
document.addEventListener('visibilitychange', onVisible);
|
|
81
|
+
return () => {
|
|
82
|
+
window.clearInterval(timer);
|
|
83
|
+
document.removeEventListener('visibilitychange', onVisible);
|
|
84
|
+
};
|
|
85
|
+
}, [tick]);
|
|
86
|
+
|
|
87
|
+
const status = down ? 'error' : snap ? snap.status : 'ok';
|
|
88
|
+
const color = dotColor[status] || dotColor.ok;
|
|
89
|
+
const title = tooltipOf(down, snap);
|
|
90
|
+
|
|
91
|
+
const dot = jsx('span', {
|
|
92
|
+
style: { width: 8, height: 8, borderRadius: '50%', background: color, display: 'inline-block', flexShrink: 0 },
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// The header utilities row is horizontal; match the 32px capsule chrome
|
|
96
|
+
// of the built-in "Session log" button so the pair reads as one cluster.
|
|
97
|
+
return jsx('div', {
|
|
98
|
+
style: {
|
|
99
|
+
display: 'inline-flex', alignItems: 'center', gap: 6,
|
|
100
|
+
height: 32, padding: '0 12px', boxSizing: 'border-box',
|
|
101
|
+
borderRadius: 18, border: '1px solid var(--dsw-alias-border-l2)',
|
|
102
|
+
background: 'transparent', cursor: 'default', flexShrink: 0,
|
|
103
|
+
whiteSpace: 'nowrap',
|
|
104
|
+
},
|
|
105
|
+
title,
|
|
106
|
+
children: [jsx('span', {
|
|
107
|
+
style: { fontSize: 12, lineHeight: '20px', color: 'var(--dsw-alias-label-tertiary)' },
|
|
108
|
+
children: LABEL,
|
|
109
|
+
}), dot],
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---- settings page (方案 A §16) ----
|
|
114
|
+
|
|
115
|
+
// Every config field editable in the settings panel. Fields with
|
|
116
|
+
// live=false are restart-applies (root/sharing): the host keeps the store
|
|
117
|
+
// snapshot until the next boot, so the UI labels them and persists anyway.
|
|
118
|
+
const GROUPS = [
|
|
119
|
+
{ id: 'extraction', label: '记忆提取' },
|
|
120
|
+
{ id: 'index', label: '目录注入(index)' },
|
|
121
|
+
{ id: 'ui', label: '界面(ui)' },
|
|
122
|
+
{ id: 'general', label: '存储与共享' },
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
// sub groups the extraction fields into related clusters so the panel
|
|
126
|
+
// reads as features rather than a flat dump; undefined sub = rendered
|
|
127
|
+
// directly under the group heading.
|
|
128
|
+
const FIELDS = [
|
|
129
|
+
{ path: ['extraction', 'mode'], type: 'select', group: 'extraction', label: '提取模式', options: [['incremental', 'incremental(增量)'], ['explicit_only', 'explicit_only(仅显式)'], ['off', 'off(关闭)']], hint: 'incremental = 隐式提取开启;explicit_only/off 时指示器置灰' },
|
|
130
|
+
// ---- 触发方式 ----
|
|
131
|
+
{ path: ['extraction', 'windowTurns'], type: 'number', group: 'extraction', sub: '触发方式', label: '窗口触发(消息数)', hint: '新增 surface 消息数 ≥ 此值触发一次提取;0 = 每条消息立即触发(非禁用)' },
|
|
132
|
+
{ path: ['extraction', 'idleTimeoutMin'], type: 'number', group: 'extraction', sub: '触发方式', label: '空闲兜底(分钟)', hint: '空闲 N 分钟后提取剩余窗口;0 = 空闲立即提取(非禁用)' },
|
|
133
|
+
{ path: ['extraction', 'turnStoppingTrigger'], type: 'toggle', group: 'extraction', sub: '触发方式', label: '回合边界触发', hint: 'agent/turn-stopping 时触发提取(成本较高);依赖下面的最小消息数' },
|
|
134
|
+
{ path: ['extraction', 'flushTrigger'], type: 'toggle', group: 'extraction', sub: '触发方式', label: '会话落盘触发', hint: 'session/flush 时触发提取' },
|
|
135
|
+
{ path: ['extraction', 'minTurnExtract'], type: 'number', group: 'extraction', sub: '触发方式', label: '回合边界最小消息数', hint: '回合/落盘触发的未提取消息下限;0 = 无条件触发' },
|
|
136
|
+
{ path: ['extraction', 'turnDebounceMs'], type: 'number', group: 'extraction', sub: '触发方式', label: '回合防抖(毫秒)', hint: '回合边界提取的防抖窗口;0 = 无防抖,每次都触发' },
|
|
137
|
+
// ---- 提取内容 ----
|
|
138
|
+
{ path: ['extraction', 'maxMessages'], type: 'number', group: 'extraction', sub: '提取内容', label: '单次消息上限', hint: '单次提取喂给 LLM 的最大消息数;0 = 窗口为空,不提取(等效禁用)' },
|
|
139
|
+
{ path: ['extraction', 'toolResultMaxBytes'], type: 'number', group: 'extraction', sub: '提取内容', label: '工具结果截断(字节)', hint: '每条 tool_result 喂给 LLM 前的截断;0 = 工具内容全部丢弃' },
|
|
140
|
+
{ path: ['extraction', 'includeDigest'], type: 'toggle', group: 'extraction', sub: '提取内容', label: '携带会话摘要', hint: '提取时携带滚动会话 digest' },
|
|
141
|
+
{ path: ['extraction', 'dedup'], type: 'toggle', group: 'extraction', sub: '提取内容', label: '去重检索', hint: '提取前 grep 已有记忆做去重/矛盾判断' },
|
|
142
|
+
// ---- 提取模型 ----
|
|
143
|
+
{ path: ['extraction', 'llm', 'route'], type: 'dynamic-select', dynamic: 'route', group: 'extraction', sub: '提取模型', label: '提取模型', hint: '提取运行的模型;跟随全局默认 = 用 agent-default-model 当前值(活)' },
|
|
144
|
+
{ path: ['extraction', 'llm', 'reasoningEffort'], type: 'dynamic-select', dynamic: 'effort', group: 'extraction', sub: '提取模型', label: '推理强度', hint: '跟随全局默认 = 用默认模型的推理强度;选项按所选模型的配置读取' },
|
|
145
|
+
// ---- 可靠性 ----
|
|
146
|
+
{ path: ['extraction', 'parseRetry'], type: 'toggle', group: 'extraction', sub: '可靠性', label: '解析失败重试', hint: 'JSON 解析失败时一次廉价 repair 调用' },
|
|
147
|
+
{ path: ['extraction', 'auditLog'], type: 'toggle', group: 'extraction', sub: '可靠性', label: '审计日志', hint: '写 peers/{peer}/sessions/{id}.json' },
|
|
148
|
+
{ path: ['extraction', 'maxConcurrentRequests'], type: 'number', group: 'extraction', sub: '可靠性', label: '并发提取上限', hint: '预留字段,当前未接线(改动不生效)' },
|
|
149
|
+
{ path: ['index', 'maxTokens'], type: 'number', group: 'index', label: '目录 token 上限', hint: 'L0 目录近似 token 上限,超出截断;最小 50,低于会被拒绝' },
|
|
150
|
+
{ path: ['ui', 'headerOrder'], type: 'number', group: 'ui', label: '指示器位置', hint: '会话标题栏 utilities 槽顺序(越小越靠左);保存后刷新页面生效(槽注册期快照)' },
|
|
151
|
+
{ path: ['root'], type: 'text', group: 'general', label: '记忆根目录', hint: '重启生效;MemoryStore 构造时固定' },
|
|
152
|
+
{ path: ['defaultPeer'], type: 'text', group: 'general', label: '默认 peer', hint: '无 cwd 时的 peer 名(live)' },
|
|
153
|
+
{ path: ['sharing', 'enabled'], type: 'toggle', group: 'general', label: '跨 peer 共享', hint: '总开关,重启生效;具体共享哪些 peer 由 sharing.mounts 决定(见下方列表)' },
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
const pathKey = (path) => path.join('.');
|
|
157
|
+
|
|
158
|
+
// Schema/resolve defaults mirrored from src/config.ts. The settings service
|
|
159
|
+
// resolves the namespace with schemastery (which fills nothing for absent
|
|
160
|
+
// keys), so the panel shows these when the stored document has no value —
|
|
161
|
+
// the same defaults the host's resolveConfig applies.
|
|
162
|
+
const DEFAULTS = {
|
|
163
|
+
'root': '~/.agent-memory',
|
|
164
|
+
'defaultPeer': 'dsh-web',
|
|
165
|
+
'workspacePeers.enabled': true,
|
|
166
|
+
'workspacePeers.excludeSubagents': true,
|
|
167
|
+
'workspacePeers.cwdFallback': 'default_peer',
|
|
168
|
+
'index.maxTokens': 1200,
|
|
169
|
+
'tools.schemaMinimal': true,
|
|
170
|
+
'extraction.mode': 'incremental',
|
|
171
|
+
'extraction.windowTurns': 20,
|
|
172
|
+
'extraction.idleTimeoutMin': 30,
|
|
173
|
+
'extraction.maxMessages': 20,
|
|
174
|
+
'extraction.toolResultMaxBytes': 2048,
|
|
175
|
+
'extraction.includeDigest': true,
|
|
176
|
+
'extraction.dedup': true,
|
|
177
|
+
'extraction.turnStoppingTrigger': true,
|
|
178
|
+
'extraction.flushTrigger': true,
|
|
179
|
+
'extraction.minTurnExtract': 5,
|
|
180
|
+
'extraction.turnDebounceMs': 30000,
|
|
181
|
+
'extraction.auditLog': true,
|
|
182
|
+
'extraction.maxConcurrentRequests': 1,
|
|
183
|
+
'extraction.parseRetry': true,
|
|
184
|
+
'extraction.llm.route': '',
|
|
185
|
+
'extraction.llm.reasoningEffort': '',
|
|
186
|
+
'sharing.enabled': false,
|
|
187
|
+
'ui.headerOrder': -1,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// Read a nested value by path (undefined when absent).
|
|
191
|
+
const readPath = (obj, path) => {
|
|
192
|
+
let cur = obj;
|
|
193
|
+
for (const part of path) {
|
|
194
|
+
if (cur === null || typeof cur !== 'object') return undefined;
|
|
195
|
+
cur = cur[part];
|
|
196
|
+
}
|
|
197
|
+
return cur;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const rowStyle = { marginBottom: 12, maxWidth: 420 };
|
|
201
|
+
const labelStyle = { display: 'block', fontSize: 13, lineHeight: '20px', color: 'var(--dsw-alias-label-primary)', marginBottom: 4 };
|
|
202
|
+
const hintStyle = { fontSize: 11, lineHeight: '16px', color: 'var(--dsw-alias-label-tertiary)', marginTop: 3 };
|
|
203
|
+
const inputStyle = {
|
|
204
|
+
width: '100%', boxSizing: 'border-box', height: 28, padding: '0 8px',
|
|
205
|
+
borderRadius: 6, border: '1px solid var(--dsw-alias-border-l2)',
|
|
206
|
+
background: 'transparent', color: 'var(--dsw-alias-label-primary)',
|
|
207
|
+
fontSize: 13, lineHeight: '20px', outline: 'none',
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
// ---- 提取模型下拉数据源 (§17) ----
|
|
211
|
+
// Pulls the registered provider/model catalog (api.llm.models) and the live
|
|
212
|
+
// global default selection (agent-default-model settings scope) once per
|
|
213
|
+
// mount. Builds the two dynamic dropdown option sets:
|
|
214
|
+
// - routeOptions: [['', '跟随全局默认(当前:<provider> / <model>)'], ...all '<provider> / <model>']
|
|
215
|
+
// - effortOptionsFor(route): efforts of the selected model, prefixed with the
|
|
216
|
+
// '跟随全局默认' empty option (the model's defaultEffort when absent).
|
|
217
|
+
function useModelOptions(connection, agentDefaultScope) {
|
|
218
|
+
const [catalog, setCatalog] = useState(null); // { groups: [{id,name,models:[{id,name,reasoning}]}] }
|
|
219
|
+
const [failed, setFailed] = useState(false);
|
|
220
|
+
const [, force] = useState(0);
|
|
221
|
+
|
|
222
|
+
useEffect(() => {
|
|
223
|
+
let alive = true;
|
|
224
|
+
const load = async () => {
|
|
225
|
+
try {
|
|
226
|
+
const res = await connection.api.llm.models({});
|
|
227
|
+
if (!alive) return;
|
|
228
|
+
if (res && res.result && res.result.ok) {
|
|
229
|
+
setCatalog(res.result.value);
|
|
230
|
+
setFailed(false);
|
|
231
|
+
if (!res.result.value || !res.result.value.groups || res.result.value.groups.length === 0) {
|
|
232
|
+
console.warn('[memory-lite] llm.models returned empty groups', res.result.value);
|
|
233
|
+
}
|
|
234
|
+
} else {
|
|
235
|
+
console.warn('[memory-lite] llm.models non-ok:', res && res.result ? res.result : res);
|
|
236
|
+
setFailed(true);
|
|
237
|
+
}
|
|
238
|
+
} catch (err) {
|
|
239
|
+
if (alive) {
|
|
240
|
+
console.error('[memory-lite] llm.models failed:', err);
|
|
241
|
+
setFailed(true);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
void load();
|
|
246
|
+
return () => { alive = false; };
|
|
247
|
+
}, [connection]);
|
|
248
|
+
|
|
249
|
+
useEffect(() => {
|
|
250
|
+
if (!agentDefaultScope) return undefined;
|
|
251
|
+
return agentDefaultScope.subscribe(() => { force((n) => n + 1); });
|
|
252
|
+
}, [agentDefaultScope]);
|
|
253
|
+
|
|
254
|
+
const defSnap = agentDefaultScope ? agentDefaultScope.getSnapshot() : null;
|
|
255
|
+
const defValue = defSnap && defSnap.status === 'ready' ? defSnap.value : undefined;
|
|
256
|
+
const defProvider = defValue ? defValue.provider : undefined;
|
|
257
|
+
const defModel = defValue ? defValue.model : undefined;
|
|
258
|
+
const defRoute = (defProvider && defModel) ? defProvider + '/' + defModel : '';
|
|
259
|
+
|
|
260
|
+
const routeOptions = [];
|
|
261
|
+
if (defRoute !== '') {
|
|
262
|
+
routeOptions.push(['', '跟随全局默认(当前:' + defRoute + ')']);
|
|
263
|
+
} else {
|
|
264
|
+
routeOptions.push(['', '跟随全局默认']);
|
|
265
|
+
}
|
|
266
|
+
if (catalog && !failed) {
|
|
267
|
+
for (const group of catalog.groups || []) {
|
|
268
|
+
for (const model of group.models || []) {
|
|
269
|
+
routeOptions.push([group.id + '/' + model.id, group.id + ' / ' + model.id]);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// For one selected route, find its model's reasoning efforts.
|
|
275
|
+
const effortOptionsFor = (route) => {
|
|
276
|
+
const opts = [['', '跟随全局默认']];
|
|
277
|
+
if (!catalog || failed || !route || route === '') return opts;
|
|
278
|
+
const slash = route.indexOf('/');
|
|
279
|
+
if (slash <= 0) return opts;
|
|
280
|
+
const gid = route.slice(0, slash);
|
|
281
|
+
const mid = route.slice(slash + 1);
|
|
282
|
+
const group = (catalog.groups || []).find((g) => g.id === gid);
|
|
283
|
+
const model = group && (group.models || []).find((m) => m.id === mid);
|
|
284
|
+
const reasoning = model && model.reasoning;
|
|
285
|
+
if (reasoning && reasoning.efforts && reasoning.efforts.length > 0) {
|
|
286
|
+
for (const eff of reasoning.efforts) {
|
|
287
|
+
opts.push([eff.id, eff.name + (eff.description ? '(' + eff.description + ')' : '')]);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return opts;
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
return { routeOptions, effortOptionsFor, failed };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/* ---- 共享挂载 peer 列表 (16.9 补: 勾选式编辑) ---- */
|
|
297
|
+
/* Pulls the existing peer directory names (rpc /memory-peers) once per */
|
|
298
|
+
/* mount, plus the currently configured mounts. The settings page renders */
|
|
299
|
+
/* one checkbox per peer; toggling changes the mounts list saved on Save. */
|
|
300
|
+
function usePeerList(connection) {
|
|
301
|
+
const [peers, setPeers] = useState([]); // string[]
|
|
302
|
+
const [mounts, setMounts] = useState([]); // {name, peer, subpath, readonly}[]
|
|
303
|
+
const [failed, setFailed] = useState(false);
|
|
304
|
+
useEffect(() => {
|
|
305
|
+
let alive = true;
|
|
306
|
+
const load = async () => {
|
|
307
|
+
try {
|
|
308
|
+
const res = await connection.rpc.call('/memory-peers', 'snapshot', {});
|
|
309
|
+
if (!alive) return;
|
|
310
|
+
// rpc.call resolves to { ok, value } directly (StatusDot pattern),
|
|
311
|
+
// not a { result: ... } wrapper.
|
|
312
|
+
if (res && res.ok && res.value) {
|
|
313
|
+
setPeers(res.value.peers || []);
|
|
314
|
+
setMounts(res.value.mounts || []);
|
|
315
|
+
setFailed(false);
|
|
316
|
+
} else {
|
|
317
|
+
console.warn('[memory-lite] /memory-peers non-ok:', res);
|
|
318
|
+
setFailed(true);
|
|
319
|
+
}
|
|
320
|
+
} catch (err) {
|
|
321
|
+
if (alive) {
|
|
322
|
+
console.error('[memory-lite] /memory-peers failed:', err);
|
|
323
|
+
setFailed(true);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
void load();
|
|
328
|
+
return () => { alive = false; };
|
|
329
|
+
}, [connection]);
|
|
330
|
+
return { peers, mounts, failed };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function FieldRow({ spec, value, draft, onChange, modelOptions }) {
|
|
334
|
+
const key = pathKey(spec.path);
|
|
335
|
+
const staged = draft[key];
|
|
336
|
+
const shown = staged !== undefined ? staged : value;
|
|
337
|
+
|
|
338
|
+
let control;
|
|
339
|
+
if (spec.type === 'toggle') {
|
|
340
|
+
control = jsx('input', {
|
|
341
|
+
type: 'checkbox',
|
|
342
|
+
checked: shown === true,
|
|
343
|
+
onChange: (e) => { onChange(key, e.target.checked); },
|
|
344
|
+
style: { width: 16, height: 16, accentColor: 'var(--dsw-alias-state-business-primary)' },
|
|
345
|
+
});
|
|
346
|
+
} else if (spec.type === 'select') {
|
|
347
|
+
control = jsx('select', {
|
|
348
|
+
value: String(shown ?? ''),
|
|
349
|
+
onChange: (e) => { onChange(key, e.target.value); },
|
|
350
|
+
style: inputStyle,
|
|
351
|
+
children: (spec.options || []).map(([optValue, optLabel]) =>
|
|
352
|
+
jsx('option', { value: optValue, children: optLabel, key: optValue })),
|
|
353
|
+
});
|
|
354
|
+
} else if (spec.type === 'dynamic-select') {
|
|
355
|
+
// Route dropdown: 跟随全局默认 + all registered provider/model pairs.
|
|
356
|
+
// Effort dropdown: options for the currently-selected route (draft or
|
|
357
|
+
// stored), falling back to the global default's effort when route unset.
|
|
358
|
+
let options;
|
|
359
|
+
if (spec.dynamic === 'route') {
|
|
360
|
+
options = modelOptions ? modelOptions.routeOptions : [];
|
|
361
|
+
} else {
|
|
362
|
+
const routeKey = 'extraction.llm.route';
|
|
363
|
+
const routeVal = draft[routeKey] !== undefined ? draft[routeKey] : value;
|
|
364
|
+
options = modelOptions && modelOptions.effortOptionsFor ? modelOptions.effortOptionsFor(routeVal) : [];
|
|
365
|
+
}
|
|
366
|
+
control = jsx('select', {
|
|
367
|
+
value: String(shown ?? ''),
|
|
368
|
+
onChange: (e) => { onChange(key, e.target.value); },
|
|
369
|
+
style: inputStyle,
|
|
370
|
+
children: (options || []).map(([optValue, optLabel]) =>
|
|
371
|
+
jsx('option', { value: optValue, children: optLabel, key: optValue })),
|
|
372
|
+
});
|
|
373
|
+
} else {
|
|
374
|
+
control = jsx('input', {
|
|
375
|
+
type: 'text',
|
|
376
|
+
value: shown === undefined || shown === null ? '' : String(shown),
|
|
377
|
+
inputMode: spec.type === 'number' ? 'numeric' : undefined,
|
|
378
|
+
placeholder: spec.type === 'number' ? '0' : '',
|
|
379
|
+
onChange: (e) => { onChange(key, e.target.value); },
|
|
380
|
+
style: inputStyle,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return jsx('div', { style: rowStyle, children: [
|
|
385
|
+
jsx('label', { style: labelStyle, children: spec.label }),
|
|
386
|
+
control,
|
|
387
|
+
jsx('div', { style: hintStyle, children: spec.hint || '' }),
|
|
388
|
+
]});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function SettingsPage({ scope, connection, agentDefaultScope }) {
|
|
392
|
+
const [draft, setDraft] = useState({});
|
|
393
|
+
const [saving, setSaving] = useState(false);
|
|
394
|
+
const [saved, setSaved] = useState(false);
|
|
395
|
+
const [failed, setFailed] = useState(false);
|
|
396
|
+
const [, force] = useState(0);
|
|
397
|
+
// Sharing-mount checkbox state: a Set of peer names checked for sharing.
|
|
398
|
+
const [mountDraft, setMountDraft] = useState(null); // null = untouched
|
|
399
|
+
|
|
400
|
+
const modelOptions = useModelOptions(connection, agentDefaultScope);
|
|
401
|
+
const peerList = usePeerList(connection);
|
|
402
|
+
|
|
403
|
+
useEffect(() => {
|
|
404
|
+
if (!scope) return undefined;
|
|
405
|
+
return scope.subscribe(() => { force((n) => n + 1); });
|
|
406
|
+
}, [scope]);
|
|
407
|
+
|
|
408
|
+
const snapshot = scope ? scope.getSnapshot() : null;
|
|
409
|
+
const value = snapshot && snapshot.status === 'ready' ? snapshot.value : undefined;
|
|
410
|
+
const writable = snapshot ? snapshot.writable : false;
|
|
411
|
+
|
|
412
|
+
const setField = (key, val) => {
|
|
413
|
+
setDraft((d) => ({ ...d, [key]: val }));
|
|
414
|
+
setSaved(false);
|
|
415
|
+
setFailed(false);
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const fieldValue = (spec) => {
|
|
419
|
+
const key = pathKey(spec.path);
|
|
420
|
+
if (draft[key] !== undefined) return draft[key];
|
|
421
|
+
const stored = readPath(value, spec.path);
|
|
422
|
+
return stored !== undefined ? stored : DEFAULTS[key];
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
const save = async () => {
|
|
426
|
+
// Early-out only when there is nothing to save: empty field draft AND
|
|
427
|
+
// untouched mount checkboxes (mount-only edits must still persist).
|
|
428
|
+
if (!scope || (Object.keys(draft).length === 0 && mountDraft === null)) return;
|
|
429
|
+
setSaving(true);
|
|
430
|
+
setFailed(false);
|
|
431
|
+
try {
|
|
432
|
+
const ops = Object.entries(draft).map(([key, val]) => {
|
|
433
|
+
const path = key.split('.');
|
|
434
|
+
const spec = FIELDS.find((f) => pathKey(f.path) === key);
|
|
435
|
+
if (spec && spec.type === 'number') {
|
|
436
|
+
if (val === '' || val === null || val === undefined) {
|
|
437
|
+
return { op: 'unset', path };
|
|
438
|
+
}
|
|
439
|
+
const n = Number(val);
|
|
440
|
+
if (!Number.isFinite(n)) return null;
|
|
441
|
+
return { op: 'set', path, value: n };
|
|
442
|
+
}
|
|
443
|
+
if (spec && (spec.type === 'text' || spec.type === 'dynamic-select') && (val === '' || val === null || val === undefined)) {
|
|
444
|
+
return { op: 'unset', path };
|
|
445
|
+
}
|
|
446
|
+
return { op: 'set', path, value: val };
|
|
447
|
+
}).filter(Boolean);
|
|
448
|
+
// Sharing mounts: write the whole array when the checkbox set changed.
|
|
449
|
+
if (mountDraft !== null) {
|
|
450
|
+
const peers = Array.from(mountDraft);
|
|
451
|
+
ops.push({ op: 'set', path: ['sharing', 'mounts'], value: peers.map(peer => ({ name: peer, peer, subpath: '', readonly: true })) });
|
|
452
|
+
}
|
|
453
|
+
if (ops.length === 0) { setSaving(false); return; }
|
|
454
|
+
const revision = scope.getSnapshot().revision;
|
|
455
|
+
const response = await connection.api.settings.mutate({
|
|
456
|
+
ns: NS,
|
|
457
|
+
ops,
|
|
458
|
+
...(revision === undefined ? {} : { expectedRevision: revision }),
|
|
459
|
+
});
|
|
460
|
+
if (!response.result.ok) {
|
|
461
|
+
setFailed(true);
|
|
462
|
+
} else {
|
|
463
|
+
setDraft({});
|
|
464
|
+
setMountDraft(null);
|
|
465
|
+
setSaved(true);
|
|
466
|
+
}
|
|
467
|
+
} catch {
|
|
468
|
+
setFailed(true);
|
|
469
|
+
} finally {
|
|
470
|
+
setSaving(false);
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
const discard = () => {
|
|
475
|
+
setDraft({});
|
|
476
|
+
setMountDraft(null);
|
|
477
|
+
setSaved(false);
|
|
478
|
+
setFailed(false);
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
// Render one group; fields cluster under optional `sub` sub-headings.
|
|
482
|
+
const renderGroup = (group) => {
|
|
483
|
+
const fields = FIELDS.filter((f) => f.group === group.id);
|
|
484
|
+
if (fields.length === 0) return null;
|
|
485
|
+
const subHeading = (title) => jsx('div', {
|
|
486
|
+
style: { fontSize: 12, fontWeight: 600, lineHeight: '18px', color: 'var(--dsw-alias-label-secondary)', margin: '14px 0 6px' },
|
|
487
|
+
children: title,
|
|
488
|
+
});
|
|
489
|
+
const children = [];
|
|
490
|
+
let lastSub;
|
|
491
|
+
for (const spec of fields) {
|
|
492
|
+
if (spec.sub !== lastSub) {
|
|
493
|
+
if (spec.sub !== undefined) children.push(subHeading(spec.sub));
|
|
494
|
+
lastSub = spec.sub;
|
|
495
|
+
}
|
|
496
|
+
children.push(jsx(FieldRow, {
|
|
497
|
+
key: pathKey(spec.path),
|
|
498
|
+
spec,
|
|
499
|
+
value: fieldValue(spec),
|
|
500
|
+
draft,
|
|
501
|
+
onChange: setField,
|
|
502
|
+
modelOptions,
|
|
503
|
+
}));
|
|
504
|
+
}
|
|
505
|
+
return jsx('div', { key: group.id, style: { marginBottom: 20 }, children: [
|
|
506
|
+
jsx('div', { style: { fontSize: 13, fontWeight: 600, lineHeight: '20px', color: 'var(--dsw-alias-label-primary)', margin: '0 0 10px' }, children: group.label }),
|
|
507
|
+
...children,
|
|
508
|
+
]});
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
const groups = GROUPS.map(renderGroup);
|
|
512
|
+
|
|
513
|
+
const dirty = Object.keys(draft).length > 0 || mountDraft !== null;
|
|
514
|
+
|
|
515
|
+
const footer = jsx('div', { style: { display: 'flex', alignItems: 'center', gap: 10, marginTop: 8 }, children: [
|
|
516
|
+
jsx('button', {
|
|
517
|
+
type: 'button',
|
|
518
|
+
onClick: () => { void save(); },
|
|
519
|
+
disabled: !dirty || saving || !writable,
|
|
520
|
+
style: {
|
|
521
|
+
height: 30, padding: '0 16px', borderRadius: 8, cursor: dirty && !saving && writable ? 'pointer' : 'default',
|
|
522
|
+
border: '1px solid var(--dsw-alias-state-business-primary)',
|
|
523
|
+
background: 'var(--dsw-alias-state-business-primary)',
|
|
524
|
+
color: 'var(--dsw-alias-label-primary-inverted)',
|
|
525
|
+
fontSize: 13, fontWeight: 500,
|
|
526
|
+
},
|
|
527
|
+
children: saving ? '保存中…' : '保存',
|
|
528
|
+
}),
|
|
529
|
+
jsx('button', {
|
|
530
|
+
type: 'button',
|
|
531
|
+
onClick: discard,
|
|
532
|
+
disabled: !dirty || saving || !writable,
|
|
533
|
+
style: {
|
|
534
|
+
height: 30, padding: '0 16px', borderRadius: 8, cursor: dirty && !saving && writable ? 'pointer' : 'default',
|
|
535
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
536
|
+
background: 'transparent',
|
|
537
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
538
|
+
fontSize: 13,
|
|
539
|
+
},
|
|
540
|
+
children: '放弃修改',
|
|
541
|
+
}),
|
|
542
|
+
saved ? jsx('span', { style: { fontSize: 12, color: 'var(--dsw-alias-state-success-primary)' }, children: '已保存(部分参数重启后生效)' }) : null,
|
|
543
|
+
failed ? jsx('span', { style: { fontSize: 12, color: 'var(--dsw-alias-state-error-primary)' }, children: '保存失败' }) : null,
|
|
544
|
+
]});
|
|
545
|
+
|
|
546
|
+
if (!writable) {
|
|
547
|
+
return jsx('div', { style: { fontSize: 13, color: 'var(--dsw-alias-label-tertiary)' }, children: '设置文档不可写(只读环境)。' });
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Sharing mounts: checkbox per peer. Checked peers are shared (readonly).
|
|
551
|
+
const configuredMounts = (value && value.sharing && value.sharing.mounts) || [];
|
|
552
|
+
const checkedPeers = new Set(configuredMounts.map(m => m.peer));
|
|
553
|
+
const shownPeers = (peerList.peers || []).length > 0 ? peerList.peers : Object.keys(checkedPeers);
|
|
554
|
+
const mountCheckboxValue = (peer) => mountDraft !== null ? mountDraft.has(peer) : checkedPeers.has(peer);
|
|
555
|
+
const toggleMount = (peer, on) => {
|
|
556
|
+
setDraft((d) => {
|
|
557
|
+
const next = mountDraft === null ? new Set(checkedPeers) : new Set(mountDraft);
|
|
558
|
+
if (on) next.add(peer); else next.delete(peer);
|
|
559
|
+
setMountDraft(next);
|
|
560
|
+
return d; // keep other fields' draft untouched
|
|
561
|
+
});
|
|
562
|
+
setSaved(false);
|
|
563
|
+
setFailed(false);
|
|
564
|
+
};
|
|
565
|
+
const mountsBlock = jsx('div', { style: { marginBottom: 20 }, children: [
|
|
566
|
+
jsx('div', { style: { fontSize: 13, fontWeight: 600, lineHeight: '20px', color: 'var(--dsw-alias-label-primary)', margin: '0 0 10px' }, children: '跨 peer 共享(勾选要共享的 peer,只读)' }),
|
|
567
|
+
peerList.failed
|
|
568
|
+
? jsx('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary)' }, children: '无法读取 peer 列表(/memory-peers 失败)。' })
|
|
569
|
+
: shownPeers.length === 0
|
|
570
|
+
? jsx('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary)' }, children: '尚未创建任何工作区记忆(peers 为空)。' })
|
|
571
|
+
: jsx('div', { children: shownPeers.map((peer) => {
|
|
572
|
+
const on = mountCheckboxValue(peer);
|
|
573
|
+
return jsx('label', {
|
|
574
|
+
key: peer,
|
|
575
|
+
style: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8, fontSize: 13, cursor: 'pointer' },
|
|
576
|
+
children: [
|
|
577
|
+
jsx('input', {
|
|
578
|
+
type: 'checkbox',
|
|
579
|
+
checked: on,
|
|
580
|
+
onChange: (e) => { toggleMount(peer, e.target.checked); },
|
|
581
|
+
style: { width: 16, height: 16, accentColor: 'var(--dsw-alias-state-business-primary)' },
|
|
582
|
+
}),
|
|
583
|
+
jsx('span', { style: { fontFamily: 'var(--ds-font-family-code)', color: 'var(--dsw-alias-label-primary)' }, children: peer }),
|
|
584
|
+
on ? jsx('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary)' }, children: '(已共享)' }) : null,
|
|
585
|
+
],
|
|
586
|
+
});
|
|
587
|
+
}) }),
|
|
588
|
+
jsx('div', { style: { fontSize: 11, lineHeight: '16px', color: 'var(--dsw-alias-label-tertiary)', marginTop: 4 }, children: '勾选的 peer 记忆对工作区会话只读可见;保存后重启生效。' }),
|
|
589
|
+
]});
|
|
590
|
+
|
|
591
|
+
return jsx('div', { children: [
|
|
592
|
+
...groups.filter(Boolean),
|
|
593
|
+
mountsBlock,
|
|
594
|
+
footer,
|
|
595
|
+
]});
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const inject = ['connection', 'slots', 'settingsScope'];
|
|
599
|
+
|
|
600
|
+
function apply(ctx, config) {
|
|
601
|
+
const connection = ctx.get('connection');
|
|
602
|
+
const scope = ctx.get('settingsScope').bind({ namespace: NS });
|
|
603
|
+
|
|
604
|
+
ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register(
|
|
605
|
+
{
|
|
606
|
+
name: 'conversation.session.header.utilities',
|
|
607
|
+
id: 'memory-lite-status',
|
|
608
|
+
order: -1,
|
|
609
|
+
inject: () => ({ connection, scope }),
|
|
610
|
+
},
|
|
611
|
+
StatusDot,
|
|
612
|
+
));
|
|
613
|
+
|
|
614
|
+
const agentDefaultScope = ctx.get('settingsScope').bind({ namespace: 'agent-default-model' });
|
|
615
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register(
|
|
616
|
+
{
|
|
617
|
+
name: 'settings.section',
|
|
618
|
+
id: 'memory-lite',
|
|
619
|
+
order: 100,
|
|
620
|
+
label: () => 'memory-lite',
|
|
621
|
+
inject: () => ({ connection, scope, agentDefaultScope }),
|
|
622
|
+
},
|
|
623
|
+
SettingsPage,
|
|
624
|
+
));
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
exports.apply = apply;
|
|
628
|
+
exports.inject = inject;
|
|
629
|
+
return module.exports;
|
|
630
|
+
},
|
|
631
|
+
});
|