dsh-context-actions 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js ADDED
@@ -0,0 +1,1004 @@
1
+ /**
2
+ * dsh-context-actions · 浏览器半侧(Client half)
3
+ *
4
+ * 在「上下文已用」面板底部注入两个按钮:
5
+ * - 压缩:对当前会话执行宿主命令 /compact(复用 dsh-command-compact)
6
+ * - 交接:让宿主把交接文档写进临时目录,然后新建会话并让新会话引用它
7
+ *
8
+ * 为什么用 DOM 注入而不是 slot:上下文占用面板由 dsh-client-ui-conversation
9
+ * 内部渲染,没有对外开放的 slot。这里通过 MutationObserver 找到面板
10
+ * (触发按钮 aria-haspopup="dialog" 且 aria-label 含百分比),把按钮容器
11
+ * 追加到面板末尾;面板关闭时随面板一起销毁。
12
+ */
13
+ window.__ModuleLoader__.load({
14
+ id: 'dsh-context-actions',
15
+ factory: (require) => {
16
+ var module = { exports: {} };
17
+ var exports = module.exports;
18
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
19
+
20
+ /** React 来自客户端平台模块表(seed):只用于「设置 → 插件」里的配置卡片。 */
21
+ const React = require('react');
22
+
23
+ /** 包名 = Cordis 插件名 = loader 行名。 */
24
+ const NAME = 'dsh-context-actions';
25
+ /** i18n 命名空间。 */
26
+ const NS = 'dsh-context-actions';
27
+ /** 宿主命令名(不含斜杠)。 */
28
+ const COMMAND = 'handover';
29
+
30
+ const DICTS = {
31
+ zh: {
32
+ 'button.compact': '压缩',
33
+ 'button.handover': '交接',
34
+ 'tip.compact': '压缩上下文:对当前会话执行 /compact',
35
+ 'tip.handover': '交接:生成交接文档到临时目录,并新建会话接续',
36
+ 'state.noSession': '当前没有可用会话',
37
+ 'state.compacting': '正在压缩上下文…',
38
+ 'state.compacted': '已请求压缩,结果见对话流',
39
+ 'state.compactMissing': '宿主未注册 /compact 命令',
40
+ 'state.compactUnsupported': '当前 agent 预设未挂载压缩后端(/compact)',
41
+ 'tip.compactPresetHint':
42
+ '当前会话的 agent 预设没有压缩后端。二选一:① 用 standard 预设新建会话;② 在 profile 的 cordis.patch.yml 里加两行:- id: compaction-basic / disabled: false 与 - id: command-compact / disabled: false(注意后者会为该部署开启自动压缩)。',
43
+ 'state.handoverStart': '正在生成交接文档…',
44
+ 'state.handoverSummarizing': '正在调用模型总结交接文档…',
45
+ 'state.handoverWritten': '文档已生成,正在新建会话…',
46
+ 'state.handoverDone': '已新建会话并引用交接文档',
47
+ 'state.handoverNoPrompt': '已新建会话,但首条消息未送达',
48
+ 'state.hostMissing': '宿主未注册 /' + COMMAND + ' 命令,请重启 dsh 后重试',
49
+ 'state.failed': '失败',
50
+ 'opt.followSession': '跟随会话(不固定)',
51
+ 'opt.unavailable': '当前不可用',
52
+ 'opt.savedUnavailable': '已保存但当前不可用',
53
+ 'card.catalogLoading': '正在读取已配置的模型目录…',
54
+ 'card.catalogUnavailable': '模型目录读取失败,改为手动输入',
55
+ 'field.provider.hint': '从已配置的 provider 里选一条固定路由;跟随会话 = 用该会话最近一次请求的路由',
56
+ 'field.model.hint': '先选 provider;跟随会话 = 不固定模型',
57
+ 'card.resetAll': '恢复全部默认',
58
+ 'card.resetAllTitle': '清空本插件的全部用户设置,恢复部署默认值',
59
+ 'card.mechanicalHint': '脚本节选:零模型调用。选「模型总结」后可配置总结模型、提示词与输出上限',
60
+ 'field.prompt': '自定义提示词',
61
+ 'field.prompt.hint': '整段替换发给模型的提示词;留空 = 用内置默认',
62
+ 'card.title': '上下文动作(压缩 / 交接)',
63
+ 'card.desc': '面板按钮的交接文档生成方式:脚本节选或模型总结。保存后立即生效。',
64
+ 'card.open': '展开',
65
+ 'card.close': '收起',
66
+ 'card.loading': '正在读取设置…',
67
+ 'card.unavailable': '本部署未暴露该设置,或当前连接只在本地生效。',
68
+ 'card.readOnly': '当前设置存储只读,修改无法保存。',
69
+ 'card.saving': '保存中…',
70
+ 'card.saved': '已保存',
71
+ 'card.failed': '保存失败',
72
+ 'card.overridden': '已覆盖',
73
+ 'card.reset': '重置',
74
+ 'card.resetTitle': '清除该字段的用户值,恢复为组合配置 / 默认值',
75
+ 'card.invalidNumber': '请输入大于 0 的数字',
76
+ 'card.hint.mode': '压缩/交接的生成方式',
77
+ 'field.mode': '交接文档生成方式',
78
+ 'opt.mechanical': '脚本节选(零模型调用)',
79
+ 'opt.llm': '模型总结(调用一次模型)',
80
+ 'field.provider': '总结模型 provider',
81
+ 'field.provider.hint': '留空 = 跟随该会话最近一次请求的路由',
82
+ 'field.model': '总结模型 model id',
83
+ 'field.model.hint': '留空 = 跟随该会话最近一次请求的路由',
84
+ 'field.reasoningEffort': '推理档位',
85
+ 'field.reasoningEffort.hint': '留空 = 沿用会话的档位',
86
+ 'field.maxOutputTokens': '输出上限(tokens)',
87
+ 'field.timeoutMs': '单次总结超时(ms)',
88
+ 'field.maxInputChars': '喂给模型的纪要字符上限',
89
+ 'field.llmMessageTextLimit': '喂模型时单条消息截断',
90
+ 'field.llmToolResultLimit': '喂模型时单条工具结果截断',
91
+ 'field.includeDigest': '摘要后附脚本节选纪要',
92
+ 'field.fallback': '总结失败时回退脚本节选',
93
+ },
94
+ en: {
95
+ 'button.compact': 'Compact',
96
+ 'button.handover': 'Handover',
97
+ 'tip.compact': 'Compact context: run /compact on this session',
98
+ 'tip.handover': 'Handover: write a handover document to a temp dir, then start a new session',
99
+ 'state.noSession': 'No active session',
100
+ 'state.compacting': 'Compacting context…',
101
+ 'state.compacted': 'Compaction requested; see the transcript',
102
+ 'state.compactMissing': '/compact is not registered on the host',
103
+ 'state.compactUnsupported': 'This agent preset mounts no compaction backend (/compact)',
104
+ 'tip.compactPresetHint':
105
+ 'The current agent preset has no compaction backend. Either start a session with the standard preset, or add "- id: compaction-basic / disabled: false" and "- id: command-compact / disabled: false" to the profile cordis.patch.yml (the latter also turns on automatic compaction for this deployment).',
106
+ 'state.handoverStart': 'Writing the handover document…',
107
+ 'state.handoverSummarizing': 'Summarizing the handover document with the model…',
108
+ 'state.handoverWritten': 'Document written, creating a new session…',
109
+ 'state.handoverDone': 'New session started with the handover document',
110
+ 'state.handoverNoPrompt': 'New session created, but the first message was not delivered',
111
+ 'state.hostMissing': '/' + COMMAND + ' is not registered on the host — restart dsh and retry',
112
+ 'state.failed': 'failed',
113
+ 'opt.followSession': 'Follow the session',
114
+ 'opt.unavailable': 'currently unavailable',
115
+ 'opt.savedUnavailable': 'saved but currently unavailable',
116
+ 'card.catalogLoading': 'Loading the configured model catalog…',
117
+ 'card.catalogUnavailable': 'Could not read the model catalog; falling back to manual input',
118
+ 'field.provider.hint': 'Pick one configured provider to pin the route; Follow the session uses the session last request route',
119
+ 'field.model.hint': 'Pick a provider first; Follow the session leaves the model unpinned',
120
+ 'card.resetAll': 'Restore all defaults',
121
+ 'card.resetAllTitle': 'Clear every user value of this plugin and fall back to the deployment defaults',
122
+ 'card.mechanicalHint': 'Script digest: no model call. Pick "Model summary" to configure the model, prompt and output cap',
123
+ 'field.prompt': 'Custom prompt',
124
+ 'field.prompt.hint': 'Replaces the whole prompt sent to the model; empty = built-in default',
125
+ 'card.title': 'Context actions (Compact / Handover)',
126
+ 'card.desc': 'How the panel button writes its handover document: script digest or model summary. Changes apply immediately.',
127
+ 'card.open': 'Expand',
128
+ 'card.close': 'Collapse',
129
+ 'card.loading': 'Reading settings…',
130
+ 'card.unavailable': 'This deployment does not expose the section, or this connection keeps preferences local.',
131
+ 'card.readOnly': 'The settings store is read-only; changes cannot be saved.',
132
+ 'card.saving': 'Saving…',
133
+ 'card.saved': 'Saved',
134
+ 'card.failed': 'Save failed',
135
+ 'card.overridden': 'overridden',
136
+ 'card.reset': 'Reset',
137
+ 'card.resetTitle': 'Clear the user value and inherit the composition/default again',
138
+ 'card.invalidNumber': 'Enter a number greater than 0',
139
+ 'card.hint.mode': 'How compaction/handover documents are produced',
140
+ 'field.mode': 'Handover document mode',
141
+ 'opt.mechanical': 'Script digest (no model call)',
142
+ 'opt.llm': 'Model summary (one model call)',
143
+ 'field.provider': 'Summary provider',
144
+ 'field.provider.hint': 'Empty = follow the session last request route',
145
+ 'field.model': 'Summary model id',
146
+ 'field.model.hint': 'Empty = follow the session last request route',
147
+ 'field.reasoningEffort': 'Reasoning effort',
148
+ 'field.reasoningEffort.hint': 'Empty = inherit the session effort',
149
+ 'field.maxOutputTokens': 'Output cap (tokens)',
150
+ 'field.timeoutMs': 'Summarization timeout (ms)',
151
+ 'field.maxInputChars': 'Digest input cap (chars)',
152
+ 'field.llmMessageTextLimit': 'Per-message truncation for the model',
153
+ 'field.llmToolResultLimit': 'Per-tool-result truncation for the model',
154
+ 'field.includeDigest': 'Append the script digest after the summary',
155
+ 'field.fallback': 'Fall back to the script digest on failure',
156
+ },
157
+ };
158
+
159
+ /** 默认语言(中文),locale 服务可用时会被替换。 */
160
+ let t = (key) => DICTS.zh[key] ?? key;
161
+
162
+ const CSS = [
163
+ '.dsch-box{display:flex;flex-direction:column;gap:8px;margin-top:10px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.28))}',
164
+ '.dsch-row{display:flex;gap:8px}',
165
+ '.dsch-btn{flex:1 1 0;min-width:0;height:26px;display:inline-flex;align-items:center;justify-content:center;padding:0 8px;font:inherit;font-size:12px;line-height:1;color:var(--dsw-alias-label-primary,inherit);background:var(--dsw-alias-button-tool-bar-fill,rgba(128,128,128,.12));border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.28));border-radius:8px;cursor:pointer}',
166
+ '.dsch-btn:hover:not(:disabled){background:var(--dsw-alias-button-tool-bar-hover,rgba(128,128,128,.2))}',
167
+ '.dsch-btn:disabled{opacity:.5;cursor:default}',
168
+ '.dsch-status{font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary,inherit);word-break:break-all}',
169
+ '.dsch-status:empty{display:none}',
170
+ '.dsch-status[data-kind="ok"]{color:var(--dsw-alias-state-success-primary,inherit)}',
171
+ '.dsch-status[data-kind="error"]{color:var(--dsw-alias-state-error-primary,inherit)}',
172
+ ].join('');
173
+
174
+ /* ---------------------------------------------------------------- *
175
+ * 注入逻辑
176
+ * ---------------------------------------------------------------- */
177
+
178
+ /** 「设置 → 插件」卡片的样式(沿用设计变量,尽量贴近原生卡片)。 */
179
+ /**
180
+ * 「设置 → 插件」卡片的样式。
181
+ * 数值与 token 逐条对齐 dsh-client-ui-settings-plugins 的卡片(PluginCard)与字段(fields)CSS 模块,
182
+ * 这样和「终端 / Agent 循环 / Subagent / 网页搜索」那些原生卡片看起来是同一套。
183
+ */
184
+ const CARD_CSS = [
185
+ '.dsch-set-card{list-style:none;border:.5px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-3);border-radius:16px;transition:border-color .16s,background .16s}',
186
+ '.dsch-set-card:hover{border-color:var(--dsw-alias-label-dimmed)}',
187
+ '.dsch-set-card.dsch-set-open{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}',
188
+ '.dsch-set-head{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;display:flex;align-items:center;gap:12px;padding:14px 16px}',
189
+ '.dsch-set-head:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}',
190
+ '.dsch-set-headtext{display:flex;flex-direction:column;flex:1;gap:4px;min-width:0}',
191
+ '.dsch-set-name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}',
192
+ '.dsch-set-desc{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}',
193
+ '.dsch-set-chevron{color:var(--dsw-alias-label-tertiary);flex:none;font-size:12px;transition:transform .16s}',
194
+ '.dsch-set-open .dsch-set-chevron{transform:rotate(180deg)}',
195
+ '.dsch-set-body{border-top:.5px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}',
196
+ '.dsch-set-field{display:flex;flex-direction:column;gap:6px;padding:12px 0}',
197
+ '.dsch-set-field+.dsch-set-field{border-top:.5px solid var(--dsw-alias-border-l2)}',
198
+ '.dsch-set-row{display:flex;align-items:center;gap:8px}',
199
+ '.dsch-set-label{flex:1;min-width:0;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500;line-height:1.5}',
200
+ '.dsch-set-badges{display:inline-flex;align-items:center;gap:8px}',
201
+ '.dsch-set-badge{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5}',
202
+ '.dsch-set-input,.dsch-set-select{box-sizing:border-box;width:100%;height:34px;border:.5px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font:inherit;font-size:13px;line-height:1.5}',
203
+ '.dsch-set-input:focus-visible,.dsch-set-select:focus-visible,.dsch-set-textarea:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}',
204
+ '.dsch-set-input:disabled,.dsch-set-select:disabled,.dsch-set-textarea:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}',
205
+ '.dsch-set-textarea{box-sizing:border-box;width:100%;min-height:132px;padding:8px 12px;border:.5px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border-radius:8px;font:inherit;font-size:13px;line-height:1.5;resize:vertical}',
206
+ '.dsch-set-hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}',
207
+ '.dsch-set-foot{display:flex;justify-content:flex-end;align-items:center;gap:8px;border-top:.5px solid var(--dsw-alias-border-l2);padding:12px 0 4px}',
208
+ '.dsch-set-status{flex:1;min-width:0;color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}',
209
+ '.dsch-set-status[data-kind="error"]{color:var(--dsw-alias-label-error,var(--dsw-alias-state-error-primary,#e5484d))}',
210
+ '.dsch-set-btn{appearance:none;font:inherit;font-size:13px;line-height:1.5;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:5px 14px;color:var(--dsw-alias-label-secondary);background:0 0}',
211
+ '.dsch-set-btn:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}',
212
+ '.dsch-set-btn:disabled{opacity:.4;cursor:default}',
213
+ '.dsch-set-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}',
214
+ ].join('');
215
+
216
+ /** 全局单例容器状态:同一时刻只会开一个上下文面板。 */
217
+ const ui = {
218
+ box: null,
219
+ panel: null,
220
+ status: null,
221
+ buttons: [],
222
+ busy: false,
223
+ };
224
+
225
+ /**
226
+ * 挂载:等待 sessions / remote 服务就绪后再接 UI。
227
+ * @param ctx - 浏览器 Cordis 上下文。
228
+ */
229
+ function apply(ctx) {
230
+ // locale 可选:拿不到就用内置中文。
231
+ ctx.inject(['locale'], (localeCtx) => {
232
+ try {
233
+ localeCtx.effect(() => localeCtx.locale.register(NS, 'zh', DICTS.zh));
234
+ localeCtx.effect(() => localeCtx.locale.register(NS, 'en', DICTS.en));
235
+ const bound = localeCtx.locale.bind(NS);
236
+ if (typeof bound === 'function') {
237
+ t = (key) => {
238
+ const value = bound(key);
239
+ return typeof value === 'string' && value !== '' ? value : DICTS.zh[key] ?? key;
240
+ };
241
+ }
242
+ } catch (error) {
243
+ console.warn('[dsh-context-actions] locale 注册失败,使用内置中文', error);
244
+ }
245
+ });
246
+
247
+ // 「设置 → 插件」卡片:宿主注册了同名 settings namespace 时才会被派发。
248
+ applySettingsCard(ctx);
249
+
250
+ // 必需服务:会话对象层 + 远程命名空间(命令、模型选择)。
251
+ ctx.inject(['sessions', 'remote', 'remote.commands', 'remote.session'], (scope) => {
252
+ let timer = 0;
253
+ const schedule = () => {
254
+ if (timer !== 0) return;
255
+ timer = setTimeout(() => {
256
+ timer = 0;
257
+ sync();
258
+ }, 60);
259
+ };
260
+ const observer = new MutationObserver(schedule);
261
+ scope.effect(() => {
262
+ ensureStyles();
263
+ observer.observe(document.body, { childList: true, subtree: true });
264
+ schedule();
265
+ return () => {
266
+ observer.disconnect();
267
+ if (timer !== 0) clearTimeout(timer);
268
+ removeBox();
269
+ };
270
+ });
271
+ scope.effect(() => () => {});
272
+
273
+ /** 页面结构变化后把按钮容器对到面板末尾。 */
274
+ function sync() {
275
+ const panel = findPanel();
276
+ if (panel === null) {
277
+ if (ui.box !== null && !document.contains(ui.box)) removeBox();
278
+ return;
279
+ }
280
+ ensureBox(panel);
281
+ // React 可能在展开期间追加图例行,保证按钮始终贴在面板底部。
282
+ if (ui.panel === panel && panel.lastElementChild !== ui.box) panel.appendChild(ui.box);
283
+ }
284
+
285
+ /** 找到「上下文已用」面板。 */
286
+ function findPanel() {
287
+ const triggers = document.querySelectorAll('button[aria-haspopup="dialog"][aria-expanded="true"]');
288
+ for (const trigger of triggers) {
289
+ const label = trigger.getAttribute('aria-label') || '';
290
+ if (!/\d\s*%/.test(label)) continue; // 语义无关的锚点:读数里一定带百分号
291
+ let node = trigger.parentElement;
292
+ for (let depth = 0; depth < 4 && node !== null; depth += 1, node = node.parentElement) {
293
+ for (const child of node.children) {
294
+ if (child.getAttribute('role') === 'dialog') return child;
295
+ }
296
+ }
297
+ }
298
+ // 结构兜底:带三行图例(dt/dd)的 dialog 就是上下文面板。
299
+ for (const dialog of document.querySelectorAll('div[role="dialog"]')) {
300
+ const list = dialog.querySelector('dl');
301
+ if (list !== null && list.querySelectorAll('dt').length >= 3) return dialog;
302
+ }
303
+ return null;
304
+ }
305
+
306
+ /** 在指定面板里创建按钮容器。 */
307
+ function ensureBox(panel) {
308
+ if (ui.box !== null && ui.panel === panel && panel.contains(ui.box)) return;
309
+ removeBox();
310
+
311
+ const box = document.createElement('div');
312
+ box.className = 'dsch-box';
313
+ box.dataset.dshContextActions = '';
314
+ box.addEventListener('pointerdown', (event) => event.stopPropagation());
315
+
316
+ const row = document.createElement('div');
317
+ row.className = 'dsch-row';
318
+
319
+ const compact = makeButton('button.compact', 'tip.compact', () => runCompact());
320
+ const handover = makeButton('button.handover', 'tip.handover', () => runHandover());
321
+ row.append(compact.button, handover.button);
322
+
323
+ const status = document.createElement('div');
324
+ status.className = 'dsch-status';
325
+ status.setAttribute('role', 'status');
326
+
327
+ box.append(row, status);
328
+ panel.appendChild(box);
329
+
330
+ ui.box = box;
331
+ ui.panel = panel;
332
+ ui.status = status;
333
+ ui.buttons = [compact.button, handover.button];
334
+ }
335
+
336
+ /** 移除按钮容器。 */
337
+ function removeBox() {
338
+ if (ui.box !== null) ui.box.remove();
339
+ ui.box = null;
340
+ ui.panel = null;
341
+ ui.status = null;
342
+ ui.buttons = [];
343
+ ui.busy = false;
344
+ }
345
+
346
+ /** 造一个按钮。 */
347
+ function makeButton(labelKey, tipKey, onClick) {
348
+ const button = document.createElement('button');
349
+ button.type = 'button';
350
+ button.className = 'dsch-btn';
351
+ button.textContent = t(labelKey);
352
+ button.title = t(tipKey);
353
+ button.addEventListener('click', (event) => {
354
+ event.preventDefault();
355
+ event.stopPropagation();
356
+ if (ui.busy) return;
357
+ onClick();
358
+ });
359
+ return { button };
360
+ }
361
+
362
+ /** 写状态行(可选提示全文放到 title)。 */
363
+ function setStatus(text, kind, hint) {
364
+ if (ui.status === null) return;
365
+ ui.status.textContent = text ?? '';
366
+ ui.status.dataset.kind = kind ?? '';
367
+ if (typeof hint === 'string' && hint !== '') ui.status.title = hint;
368
+ else ui.status.removeAttribute('title');
369
+ }
370
+
371
+ /** 忙碌时禁用两个按钮。 */
372
+ function setBusy(busy) {
373
+ ui.busy = busy;
374
+ for (const button of ui.buttons) button.disabled = busy;
375
+ }
376
+
377
+ /** 当前会话(id + 列表行)。 */
378
+ function currentSession() {
379
+ const state = scope.sessions.list.getSnapshot();
380
+ const id = state.current;
381
+ if (id === undefined) return undefined;
382
+ return { id: id, summary: state.byId[id] };
383
+ }
384
+
385
+ /**
386
+ * 动作一:压缩上下文。
387
+ * /compact 由 agent 预设提供的压缩后端注册(web 部署默认把 compaction-basic 与
388
+ * command-compact 交给预设),所以先查命令目录再执行,缺后端时给出可操作的提示。
389
+ */
390
+ async function runCompact() {
391
+ const current = currentSession();
392
+ if (current === undefined) {
393
+ setStatus(t('state.noSession'), 'error');
394
+ return;
395
+ }
396
+ setBusy(true);
397
+ setStatus(t('state.compacting'), 'busy');
398
+ try {
399
+ const commands = scope.remote.commands;
400
+ const listed = await commands.list(current.id);
401
+ const available =
402
+ listed.ok === true && Array.isArray(listed.value) && listed.value.some((item) => item !== null && item.name === 'compact');
403
+ if (!available) {
404
+ setStatus(t('state.compactUnsupported'), 'error', t('tip.compactPresetHint'));
405
+ return;
406
+ }
407
+ const executed = await commands.execute(current.id, '/compact', [], new AbortController().signal);
408
+ if (executed.ok !== true) throw new Error(failureText(executed.error));
409
+ const outcome = executed.value === undefined || executed.value === null ? undefined : executed.value.result;
410
+ if (outcome === undefined) throw new Error(t('state.compactMissing'));
411
+ if (outcome.kind !== 'success') {
412
+ setStatus(typeof outcome.text === 'string' && outcome.text !== '' ? outcome.text : t('state.failed'), 'error');
413
+ return;
414
+ }
415
+ const text = typeof outcome.text === 'string' && outcome.text !== '' ? outcome.text : t('state.compacted');
416
+ setStatus(text, 'ok');
417
+ } catch (error) {
418
+ setStatus(t('state.failed') + ':' + messageOf(error), 'error');
419
+ } finally {
420
+ setBusy(false);
421
+ }
422
+ }
423
+
424
+ /** 动作二:交接(写文档 → 新建会话 → 新会话引用文档)。 */
425
+ async function runHandover() {
426
+ const current = currentSession();
427
+ if (current === undefined) {
428
+ setStatus(t('state.noSession'), 'error');
429
+ return;
430
+ }
431
+ setBusy(true);
432
+ setStatus(t('state.handoverStart'), 'busy');
433
+ try {
434
+ const commands = scope.remote.commands;
435
+ const where = await commands.execute(current.id, '/' + COMMAND + ' --where', [], new AbortController().signal);
436
+ if (where.ok !== true) throw new Error(failureText(where.error));
437
+ const described = parseWhere(resultText(where.value));
438
+ if (described.dir === '') throw new Error(t('state.hostMissing'));
439
+
440
+ const target = joinPath(described.dir, 'handover-' + shortId(current.id) + '-' + stamp(new Date()) + '.md');
441
+ const startedAt = Date.now();
442
+ let ticker = 0;
443
+ if (described.mode === 'llm') {
444
+ setStatus(t('state.handoverSummarizing'), 'busy', target);
445
+ ticker = setInterval(() => {
446
+ setStatus(t('state.handoverSummarizing') + ' ' + Math.round((Date.now() - startedAt) / 1000) + 's', 'busy', target);
447
+ }, 2000);
448
+ }
449
+ let written;
450
+ try {
451
+ written = await commands.execute(
452
+ current.id,
453
+ '/' + COMMAND + ' --write ' + target,
454
+ [],
455
+ new AbortController().signal,
456
+ );
457
+ } finally {
458
+ if (ticker !== 0) {
459
+ clearInterval(ticker);
460
+ ticker = 0;
461
+ }
462
+ }
463
+ if (written.ok !== true) throw new Error(failureText(written.error));
464
+ const outcome = written.value !== undefined && written.value !== null ? written.value.result : undefined;
465
+ if (outcome === undefined || outcome.kind !== 'success') {
466
+ throw new Error(outcome !== undefined && typeof outcome.text === 'string' ? outcome.text : t('state.failed'));
467
+ }
468
+ if (typeof outcome.text === 'string' && outcome.text !== '') setStatus(outcome.text, 'ok', target);
469
+ setStatus(t('state.handoverWritten'), 'busy', target);
470
+
471
+ const created = await scope.sessions.create(
472
+ current.summary !== undefined && typeof current.summary.cwd === 'string' && current.summary.cwd !== ''
473
+ ? { cwd: current.summary.cwd }
474
+ : {},
475
+ );
476
+ await inheritModel(current.summary, created);
477
+ scope.sessions.open(created);
478
+ const binding = scope.sessions.binding(created);
479
+ if (binding === undefined) throw new Error(t('state.handoverNoPrompt'));
480
+
481
+ const text = promptText(target);
482
+ let handle;
483
+ try {
484
+ handle = binding.session.beginSubmission({ mode: 'queue', text: text, attachments: [] });
485
+ } catch (error) {
486
+ console.warn('[dsh-context-actions] 提交回显注册失败,改为直接发送', error);
487
+ }
488
+ const result = await binding.session.prompt(
489
+ [{ type: 'text', text: text }],
490
+ 'queue',
491
+ undefined,
492
+ handle === undefined ? undefined : handle.requestId,
493
+ );
494
+ if (result.ok !== true) {
495
+ if (handle !== undefined) handle.abandon();
496
+ throw new Error(failureText(result.error));
497
+ }
498
+ setStatus(t('state.handoverDone'), 'ok', target);
499
+ } catch (error) {
500
+ setStatus(t('state.failed') + ':' + messageOf(error), 'error');
501
+ } finally {
502
+ setBusy(false);
503
+ }
504
+ }
505
+
506
+ /** 新会话沿用当前会话的模型(尽力而为)。 */
507
+ async function inheritModel(summary, sessionId) {
508
+ try {
509
+ const values = summary === undefined ? undefined : summary.projectionValues;
510
+ const selection = values === undefined ? undefined : values.modelSelection;
511
+ const next = selection === undefined || selection === null ? undefined : selection.next ?? selection.lastUsed;
512
+ if (next === undefined || next === null) return;
513
+ await scope.remote.session.selectModel({
514
+ sessionId: sessionId,
515
+ provider: next.provider,
516
+ model: next.model,
517
+ reasoningEffort: next.reasoningEffort,
518
+ });
519
+ } catch (error) {
520
+ console.warn('[dsh-context-actions] 继承模型选择失败', error);
521
+ }
522
+ }
523
+
524
+ /** 发给新会话的首条消息。 */
525
+ function promptText(target) {
526
+ return [
527
+ '你正在接手上一会话的工作。',
528
+ '',
529
+ '交接文档:' + target,
530
+ '',
531
+ '请先用文件读取工具读完整份交接文档——它记录上一会话的任务背景、已完成的工作、涉及的文件与命令、以及未完成的事项。',
532
+ '读完后请:',
533
+ '1. 用 3-5 行复述你对当前任务状态的理解;',
534
+ '2. 给出下一步计划;',
535
+ '3. 直接继续推进,不要等待额外确认。',
536
+ ].join('\n');
537
+ }
538
+ });
539
+ }
540
+
541
+ /* ---------------------------------------------------------------- *
542
+ * 纯函数工具
543
+ * ---------------------------------------------------------------- */
544
+
545
+ /** 注入按钮样式(幂等)。 */
546
+ function ensureStyles() {
547
+ if (document.querySelector('style[data-dsh-context-actions]') !== null) return;
548
+ const style = document.createElement('style');
549
+ style.dataset.dshContextActions = '';
550
+ style.textContent = CSS + CARD_CSS;
551
+ document.head.appendChild(style);
552
+ }
553
+
554
+ /** 命令结果的文本(未匹配到命令时为 undefined)。 */
555
+ function resultText(value) {
556
+ const outcome = value !== undefined && value !== null ? value.result : undefined;
557
+ if (outcome === undefined || outcome.kind !== 'success' || typeof outcome.text !== 'string') return undefined;
558
+ return outcome.text.trim();
559
+ }
560
+
561
+ /** 解析 /handover --where 的返回:JSON { dir, mode };兼容旧的纯路径文本。 */
562
+ function parseWhere(text) {
563
+ const value = typeof text === 'string' ? text.trim() : '';
564
+ if (value.startsWith('{')) {
565
+ try {
566
+ const parsed = JSON.parse(value);
567
+ return {
568
+ dir: typeof parsed.dir === 'string' ? parsed.dir : '',
569
+ mode: typeof parsed.mode === 'string' ? parsed.mode : '',
570
+ };
571
+ } catch (error) {
572
+ console.warn('[dsh-context-actions] --where 返回不是合法 JSON,按纯路径处理', error);
573
+ }
574
+ }
575
+ return { dir: value, mode: '' };
576
+ }
577
+
578
+ /** 远程失败 → 可读文本。 */
579
+ function failureText(error) {
580
+ if (error === undefined || error === null) return 'unknown remote failure';
581
+ if (typeof error.message === 'string' && error.message !== '') return error.message;
582
+ if (typeof error.code === 'string' && error.code !== '') return error.code;
583
+ return String(error);
584
+ }
585
+
586
+ /** 任意异常 → 可读文本。 */
587
+ function messageOf(error) {
588
+ if (error instanceof Error && error.message !== '') return error.message;
589
+ return String(error);
590
+ }
591
+
592
+ /** 拼接目录与文件名,自动识别宿主分隔符。 */
593
+ function joinPath(dir, name) {
594
+ const separator = dir.includes('\\') ? '\\' : '/';
595
+ return dir.endsWith('\\') || dir.endsWith('/') ? dir + name : dir + separator + name;
596
+ }
597
+
598
+ /** 会话 id 的短标识。 */
599
+ function shortId(sessionId) {
600
+ const match = /([0-9a-f]{8})-[0-9a-f]{4}-/i.exec(String(sessionId));
601
+ return match === null ? String(sessionId).slice(-8) : match[1];
602
+ }
603
+
604
+ /** 时间戳:YYYYMMDD-HHmmss。 */
605
+ function stamp(date) {
606
+ const pad = (value) => String(value).padStart(2, '0');
607
+ return (
608
+ String(date.getFullYear()) +
609
+ pad(date.getMonth() + 1) +
610
+ pad(date.getDate()) +
611
+ '-' +
612
+ pad(date.getHours()) +
613
+ pad(date.getMinutes()) +
614
+ pad(date.getSeconds())
615
+ );
616
+ }
617
+
618
+ /* ---------------------------------------------------------------- *
619
+ * 「设置 → 插件」卡片(settings.plugin.item,key = settings namespace)
620
+ * ---------------------------------------------------------------- */
621
+
622
+ /** 卡片字段表;顺序即渲染顺序。 */
623
+ /** 卡片字段表;顺序即渲染顺序。 */
624
+ const CARD_FIELDS = [
625
+ {
626
+ key: 'mode',
627
+ kind: 'select',
628
+ options: [
629
+ { value: 'mechanical', label: 'opt.mechanical' },
630
+ { value: 'llm', label: 'opt.llm' },
631
+ ],
632
+ },
633
+ { key: 'provider', kind: 'provider', hint: 'field.provider.hint' },
634
+ { key: 'model', kind: 'model', hint: 'field.model.hint' },
635
+ { key: 'prompt', kind: 'textarea', hint: 'field.prompt.hint' },
636
+ { key: 'maxOutputTokens', kind: 'number' },
637
+ ];
638
+
639
+ /** 卡片里「一键重置」用的占位 field 名(pseudo field)。 */
640
+ const ALL_FIELDS_KEY = '__all__';
641
+
642
+ /**
643
+ * 注册「设置 → 插件」里的卡片。
644
+ * 宿主未注册同名 settings namespace 时,这一页根本不会派发这个 key,卡片自然不出现。
645
+ * @param ctx - 浏览器 Cordis 上下文。
646
+ */
647
+ function applySettingsCard(ctx) {
648
+ ctx.inject(['slots', 'settingsScope', 'remote', 'remote.session'], (child) => {
649
+ const binder = child.settingsScope;
650
+ const slots = child.slots;
651
+ if (binder === undefined || slots === undefined || typeof binder.bind !== 'function') return;
652
+ let scope;
653
+ try {
654
+ scope = binder.bind({ namespace: NS });
655
+ } catch (error) {
656
+ console.warn('[dsh-context-actions] settingsScope 绑定失败', error);
657
+ return;
658
+ }
659
+ const bindings = {
660
+ hooks: {
661
+ handoverCard: {
662
+ getSnapshot: () => scope.getSnapshot(),
663
+ subscribe: (listener) => scope.subscribe(listener),
664
+ },
665
+ },
666
+ setHandoverField: (field, value) => scope.set(field, value),
667
+ // 部署里已配置的 provider/model 目录(客户端 remote.session)。
668
+ loadModelCatalog: () => child.remote.session.modelCatalog(),
669
+ // 一键恢复全部默认:unset 空 path = 清空本 namespace 的整个用户层。
670
+ resetAllHandoverFields: () => scope.mutate([{ op: 'unset', path: [] }]),
671
+ };
672
+ slots.inject('settings.plugin.item', () => {
673
+ return slots.register(
674
+ {
675
+ name: 'settings.plugin.item',
676
+ key: NS,
677
+ locale: NS,
678
+ inject: () => bindings,
679
+ },
680
+ (props) => React.createElement(HandoverSettingsCard, props),
681
+ );
682
+ });
683
+ });
684
+ }
685
+
686
+ /** 用户层里出现过该字段即为「已覆盖」(值相同也算)。 */
687
+ function isOverridden(user, key) {
688
+ return user !== null && typeof user === 'object' && Object.prototype.hasOwnProperty.call(user, key);
689
+ }
690
+
691
+ /** 一张可展开的配置卡片。 */
692
+ function HandoverSettingsCard(props) {
693
+ const [open, setOpen] = React.useState(false);
694
+ const [pendingField, setPendingField] = React.useState('');
695
+ const [error, setError] = React.useState('');
696
+ const [saved, setSaved] = React.useState(false);
697
+ // 框架把 inject().hooks.<key> 变成 use<Key> 这个 React hook 属性传进来。
698
+ const useHandoverCard = props.useHandoverCard;
699
+ const snapshot = typeof useHandoverCard === 'function' ? useHandoverCard((value) => value) : undefined;
700
+
701
+ // 已配置的 provider/model 目录:卡片第一次展开时拉一次,用于两个下拉框。
702
+ const [catalog, setCatalog] = React.useState({ status: 'idle', groups: [], failures: [], message: '' });
703
+ const loadModelCatalog = props.loadModelCatalog;
704
+ React.useEffect(() => {
705
+ const currentMode =
706
+ snapshot === undefined || snapshot.value === undefined || snapshot.value === null ? undefined : snapshot.value.mode;
707
+ if (open !== true || currentMode !== 'llm' || catalog.status !== 'idle') return;
708
+ setCatalog({ status: 'loading', groups: [], failures: [], message: '' });
709
+ Promise.resolve()
710
+ .then(() => (typeof loadModelCatalog === 'function' ? loadModelCatalog() : undefined))
711
+ .then(
712
+ (result) => {
713
+ if (result === undefined || result.ok !== true) {
714
+ const message =
715
+ result === undefined
716
+ ? 'remote.session unavailable'
717
+ : String((result.error !== null && typeof result.error === 'object' && result.error.message) || result.error);
718
+ setCatalog({ status: 'error', groups: [], failures: [], message: message });
719
+ return;
720
+ }
721
+ const catalogValue = result.value === undefined || result.value === null ? {} : result.value;
722
+ setCatalog({
723
+ status: 'ready',
724
+ groups: Array.isArray(catalogValue.groups) ? catalogValue.groups : [],
725
+ failures: Array.isArray(catalogValue.failures) ? catalogValue.failures : [],
726
+ message: '',
727
+ });
728
+ },
729
+ (reason) => setCatalog({ status: 'error', groups: [], failures: [], message: String(reason) }),
730
+ );
731
+ }, [open, catalog.status, loadModelCatalog, snapshot]);
732
+
733
+ if (snapshot === undefined || snapshot.status === 'unavailable') return null;
734
+ const value = snapshot.value === undefined || snapshot.value === null ? {} : snapshot.value;
735
+ const disabled = snapshot.status !== 'ready' || snapshot.writable !== true;
736
+
737
+ const providerValue = typeof value.provider === 'string' ? value.provider : '';
738
+ const modelValue = typeof value.model === 'string' ? value.model : '';
739
+ const catalogFailed = catalog.status === 'error';
740
+ const providerOptions = [{ value: '', label: t('opt.followSession') }];
741
+ for (const group of catalog.groups) {
742
+ providerOptions.push({ value: group.id, label: group.name + '(' + group.id + ')' });
743
+ }
744
+ for (const failure of catalog.failures) {
745
+ providerOptions.push({ value: failure.id, label: failure.name + '(' + failure.id + ' · ' + t('opt.unavailable') + ')' });
746
+ }
747
+ if (providerValue !== '' && !providerOptions.some((option) => option.value === providerValue)) {
748
+ providerOptions.push({ value: providerValue, label: providerValue + '(' + t('opt.savedUnavailable') + ')' });
749
+ }
750
+ const modelOptions = [{ value: '', label: t('opt.followSession') }];
751
+ const providerGroup = catalog.groups.find((group) => group.id === providerValue);
752
+ if (providerGroup !== undefined) {
753
+ for (const model of providerGroup.models) {
754
+ modelOptions.push({
755
+ value: model.id,
756
+ label: model.name !== undefined && model.name !== model.id ? model.name + '(' + model.id + ')' : model.id,
757
+ });
758
+ }
759
+ }
760
+ if (modelValue !== '' && !modelOptions.some((option) => option.value === modelValue)) {
761
+ modelOptions.push({ value: modelValue, label: modelValue + '(' + t('opt.savedUnavailable') + ')' });
762
+ }
763
+ const catalogHint =
764
+ catalog.status === 'loading' ? t('card.catalogLoading') : catalogFailed ? t('card.catalogUnavailable') : undefined;
765
+
766
+ const settle = (operation, field) => {
767
+ setPendingField(field);
768
+ setError('');
769
+ setSaved(false);
770
+ Promise.resolve()
771
+ .then(operation)
772
+ .then(
773
+ () => {
774
+ setPendingField('');
775
+ setSaved(true);
776
+ },
777
+ (reason) => {
778
+ setPendingField('');
779
+ setError(String(reason !== null && typeof reason === 'object' && 'message' in reason ? reason.message : reason));
780
+ },
781
+ );
782
+ };
783
+ const commit = (field, next) => {
784
+ if (typeof props.setHandoverField !== 'function') return;
785
+ settle(() => props.setHandoverField(field, next), field);
786
+ };
787
+ const resetAll = () => {
788
+ if (typeof props.resetAllHandoverFields !== 'function') return;
789
+ settle(() => props.resetAllHandoverFields(), ALL_FIELDS_KEY);
790
+ };
791
+ const overriddenCount = CARD_FIELDS.filter((spec) => isOverridden(snapshot.user, spec.key)).length;
792
+
793
+ const llmMode = value.mode === 'llm';
794
+ // 只有「模型总结」才显示模型/提示词/输出上限;脚本节选时卡片里只留生成方式。
795
+ const rows = CARD_FIELDS.filter((spec) => spec.key === 'mode' || llmMode).map((spec) => {
796
+ const enriched = Object.assign({}, spec);
797
+ if (spec.key === 'mode' && llmMode !== true) enriched.hintText = t('card.mechanicalHint');
798
+ if (spec.key === 'provider') {
799
+ enriched.options = providerOptions;
800
+ enriched.catalogFailed = catalogFailed;
801
+ enriched.hintText = catalogHint;
802
+ }
803
+ if (spec.key === 'model') {
804
+ enriched.options = modelOptions;
805
+ enriched.catalogFailed = catalogFailed;
806
+ enriched.hintText = catalogHint;
807
+ enriched.locked = providerValue === '';
808
+ }
809
+ return React.createElement(CardField, {
810
+ key: spec.key,
811
+ spec: enriched,
812
+ value: value[spec.key],
813
+ overridden: isOverridden(snapshot.user, spec.key),
814
+ disabled: disabled,
815
+ pending: pendingField === spec.key,
816
+ onCommit: (next) => commit(spec.key, next),
817
+ });
818
+ });
819
+
820
+ const status = error !== '' ? t('card.failed') + ':' + error : pendingField !== '' ? t('card.saving') : saved ? t('card.saved') : '';
821
+
822
+ return React.createElement(
823
+ 'li',
824
+ { className: 'dsch-set-card' + (open ? ' dsch-set-open' : '') },
825
+ React.createElement(
826
+ 'button',
827
+ {
828
+ type: 'button',
829
+ className: 'dsch-set-head',
830
+ 'aria-expanded': open,
831
+ 'aria-label': t(open ? 'card.close' : 'card.open') + ': ' + t('card.title'),
832
+ onClick: () => setOpen(!open),
833
+ },
834
+ React.createElement(
835
+ 'span',
836
+ { className: 'dsch-set-headtext' },
837
+ React.createElement('span', { className: 'dsch-set-name' }, t('card.title')),
838
+ React.createElement('span', { className: 'dsch-set-desc' }, t('card.desc')),
839
+ ),
840
+ React.createElement('span', { className: 'dsch-set-chevron', 'aria-hidden': true }, open ? '\u25be' : '\u25b8'),
841
+ ),
842
+ open
843
+ ? React.createElement(
844
+ 'div',
845
+ { className: 'dsch-set-body' },
846
+ snapshot.status !== 'ready' ? React.createElement('p', { className: 'dsch-set-note' }, t('card.loading')) : null,
847
+ snapshot.status === 'ready' && snapshot.writable !== true
848
+ ? React.createElement('p', { className: 'dsch-set-note' }, t('card.readOnly'))
849
+ : null,
850
+ rows,
851
+ React.createElement(
852
+ 'div',
853
+ { className: 'dsch-set-foot' },
854
+ React.createElement(
855
+ 'span',
856
+ { className: 'dsch-set-status', 'data-kind': error !== '' ? 'error' : undefined, role: 'status' },
857
+ status,
858
+ ),
859
+ React.createElement(
860
+ 'button',
861
+ {
862
+ type: 'button',
863
+ className: 'dsch-set-btn',
864
+ title: t('card.resetAllTitle'),
865
+ disabled: disabled || overriddenCount === 0 || pendingField !== '',
866
+ onClick: resetAll,
867
+ },
868
+ t('card.resetAll') + (overriddenCount > 0 ? '(' + overriddenCount + ')' : ''),
869
+ ),
870
+ ),
871
+ )
872
+ : null,
873
+ );
874
+ }
875
+
876
+ /** 一行:标签(含提示)+ 控件(+ 覆盖标记与重置)。 */
877
+ function CardField(props) {
878
+ const spec = props.spec;
879
+ const label = t('field.' + spec.key);
880
+ const hint = spec.hintText !== undefined ? spec.hintText : spec.hint === undefined ? undefined : t(spec.hint);
881
+ let control;
882
+ if (spec.kind === 'select') {
883
+ control = React.createElement(
884
+ 'select',
885
+ {
886
+ className: 'dsch-set-select',
887
+ value: String(props.value === undefined || props.value === null ? '' : props.value),
888
+ disabled: props.disabled,
889
+ onChange: (event) => props.onCommit(event.target.value),
890
+ },
891
+ spec.options.map((option) => React.createElement('option', { key: option.value, value: option.value }, t(option.label))),
892
+ );
893
+ } else if (spec.kind === 'boolean') {
894
+ control = React.createElement('input', {
895
+ className: 'dsch-set-check',
896
+ type: 'checkbox',
897
+ checked: props.value === true,
898
+ disabled: props.disabled,
899
+ onChange: (event) => props.onCommit(event.target.checked),
900
+ });
901
+ } else if (spec.kind === 'provider' || spec.kind === 'model') {
902
+ if (spec.catalogFailed === true) {
903
+ // 目录读不到时退回手动输入,不至于没法配。
904
+ control = React.createElement(TextControl, {
905
+ value: props.value,
906
+ disabled: props.disabled,
907
+ onCommit: props.onCommit,
908
+ });
909
+ } else {
910
+ control = React.createElement(
911
+ 'select',
912
+ {
913
+ className: 'dsch-set-select',
914
+ value: String(props.value === undefined || props.value === null ? '' : props.value),
915
+ disabled: props.disabled || spec.locked === true,
916
+ onChange: (event) => props.onCommit(event.target.value),
917
+ },
918
+ (spec.options === undefined ? [] : spec.options).map((option) =>
919
+ React.createElement('option', { key: option.value, value: option.value }, option.label),
920
+ ),
921
+ );
922
+ }
923
+ } else {
924
+ control = React.createElement(TextControl, {
925
+ numeric: spec.kind === 'number',
926
+ multiline: spec.kind === 'textarea',
927
+ value: props.value,
928
+ disabled: props.disabled,
929
+ onCommit: props.onCommit,
930
+ });
931
+ }
932
+ return React.createElement(
933
+ 'div',
934
+ { className: 'dsch-set-field' },
935
+ React.createElement(
936
+ 'div',
937
+ { className: 'dsch-set-row' },
938
+ React.createElement('label', { className: 'dsch-set-label' }, label),
939
+ React.createElement(
940
+ 'span',
941
+ { className: 'dsch-set-badges' },
942
+ props.pending ? React.createElement('span', { key: 'saving', className: 'dsch-set-badge' }, t('card.saving')) : null,
943
+ props.overridden ? React.createElement('span', { key: 'overridden', className: 'dsch-set-badge' }, t('card.overridden')) : null,
944
+ ),
945
+ ),
946
+ control,
947
+ hint === undefined ? null : React.createElement('p', { className: 'dsch-set-hint' }, hint),
948
+ );
949
+ }
950
+ /** 文本 / 数字输入:本地草稿,失焦或回车才提交。 */
951
+ function TextControl(props) {
952
+ const text = props.value === undefined || props.value === null ? '' : String(props.value);
953
+ const [draft, setDraft] = React.useState(text);
954
+ const [invalid, setInvalid] = React.useState(false);
955
+ React.useEffect(() => {
956
+ setDraft(text);
957
+ setInvalid(false);
958
+ }, [text]);
959
+ const commit = () => {
960
+ const raw = draft.trim();
961
+ if (props.numeric === true) {
962
+ const number = Number(raw);
963
+ if (raw === '' || !Number.isFinite(number) || number <= 0) {
964
+ setInvalid(true);
965
+ return;
966
+ }
967
+ setInvalid(false);
968
+ if (number !== props.value) props.onCommit(number);
969
+ return;
970
+ }
971
+ setInvalid(false);
972
+ if (raw !== text) props.onCommit(raw);
973
+ };
974
+ const shared = {
975
+ value: draft,
976
+ disabled: props.disabled,
977
+ title: invalid ? t('card.invalidNumber') : undefined,
978
+ style: invalid ? { borderColor: 'var(--dsw-alias-state-error-primary, #e5484d)' } : undefined,
979
+ onChange: (event) => setDraft(event.target.value),
980
+ onBlur: commit,
981
+ };
982
+ if (props.multiline === true) {
983
+ return React.createElement('textarea', Object.assign({ rows: 8, className: 'dsch-set-textarea' }, shared));
984
+ }
985
+ return React.createElement(
986
+ 'input',
987
+ Object.assign(
988
+ {
989
+ className: props.numeric === true ? 'dsch-set-input dsch-set-number' : 'dsch-set-input',
990
+ type: props.numeric === true ? 'number' : 'text',
991
+ onKeyDown: (event) => {
992
+ if (event.key === 'Enter') commit();
993
+ },
994
+ },
995
+ shared,
996
+ ),
997
+ );
998
+ }
999
+
1000
+ exports.name = NAME;
1001
+ exports.apply = apply;
1002
+ return module.exports;
1003
+ },
1004
+ });