dsh-sidecard-ask 1.6.0 → 1.7.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/CHANGELOG.md +35 -0
- package/README.md +12 -7
- package/client.js +564 -14
- package/index.js +110 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.7.0
|
|
4
|
+
|
|
5
|
+
**主题:P0 交互提效——一键快捷动作、自定义动作模板、改问重试、代码块复制。**
|
|
6
|
+
|
|
7
|
+
- **一键快捷动作条(划词主路径 4 步 → 1 步)**:选中文字后,触发按钮旁直接出现
|
|
8
|
+
「解释 / 翻译 / 总结 / 润色 / 纠错」五个动作芯片,点击即跳过提问框、以预置问题
|
|
9
|
+
直接在侧边卡片发起作答;提问框顶部也有同一排芯片(`Alt+Q` 与"仅快捷键"用户同样可达)。
|
|
10
|
+
芯片 `title` 悬停展示完整问题指令与快捷键。
|
|
11
|
+
- **自定义动作模板**:新增配置项 `quickActions`(三层合并、随用户层持久化)。
|
|
12
|
+
设置页「快捷动作」区可启用/停用内置动作、为每个动作配独立快捷键、新增/删除自定义动作
|
|
13
|
+
(名称 + 问题指令 + 快捷键),问题指令支持 `{selection}` 占位符(内嵌选中正文;
|
|
14
|
+
不写占位符时原文按既有方式随问题附上)。内置 5 个动作的文案在客户端按界面语言解析,
|
|
15
|
+
配置与线协议保持语言中立。
|
|
16
|
+
- 宿主端新增**有界结构校验** `sanitizeQuickActions`:≤12 条、id 唯一且 ≤40 字符、
|
|
17
|
+
自定义动作名称 1–20 字 / 问题 1–500 字、快捷键 ≤40 字符;任何一条非法即**整表拒绝**
|
|
18
|
+
(`/config` 返回 `invalid-config`),不做半表保存。客户端保存前同样预检,
|
|
19
|
+
并检查动作快捷键之间、动作与主快捷键之间的冲突(大小写不敏感)。
|
|
20
|
+
- 每个动作的快捷键在页面 keydown 捕获阶段分发:主快捷键优先;在插件自己的输入框/编辑器中
|
|
21
|
+
不拦截任何快捷键;无选区时回退到最近一次触发候选(与主快捷键同语义)。
|
|
22
|
+
- **编辑问题后重新提问(改问重试)**:终态侧边卡片(已完成 / 失败 / 已停止)新增「改问」按钮,
|
|
23
|
+
就地编辑原问题后**重新生成当前这一轮**(不把旧问答压入 history,区别于"继续追问"的追加一轮);
|
|
24
|
+
原「重试」保留(原样重发,仅失败/停止态显示);空问题不触发。
|
|
25
|
+
- **代码块单独复制**:答案中的围栏代码块右上角新增复制按钮,复用既有剪贴板 API →
|
|
26
|
+
选区降级链与 toast 提示;`pre` 改为 `pre-wrap` 容器承载,代码文本与样式不变。
|
|
27
|
+
- 浮层/弹层样式新增 `.dsa-quickbar` / `.dsa-chip` / `.dsa-pre-wrap` / `.dsa-code-copy` /
|
|
28
|
+
`.dsa-qa-*`,并同步加入 Electron 窗口拖拽守卫(`-webkit-app-region:no-drag`)。
|
|
29
|
+
|
|
30
|
+
本机实测:verify 53 / contract 589 / float-placement 38 / smoke 181 = **861 项全部通过**
|
|
31
|
+
(较 1.6.0 的 577 项新增 284 项;含动作解析/占位符截断/快捷键冲突/宿主整表校验/
|
|
32
|
+
芯片与触发条渲染/quickAsk 与 retryEdit 端到端/代码块复制按钮/中英词典整表奇偶等用例)。
|
|
33
|
+
|
|
34
|
+
> 未验证(如实记录):浏览器与桌面 Electron 内的视觉观感与真实点按——本工作区无 GUI 自动化
|
|
35
|
+
> 接入运行实例,动作条换行/定位、芯片点击、代码块复制按钮、设置页编辑器需在真实页面按
|
|
36
|
+
> README 第九节路径人工确认;单测覆盖组件树与端到端数据流,替代不了观感验证。
|
|
37
|
+
|
|
3
38
|
## 1.6.0
|
|
4
39
|
|
|
5
40
|
**主题:最新版 DSH(0.2.1-alpha.2)适配 + 新旧包名共载时的单例护栏。**
|
package/README.md
CHANGED
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
|
|
43
43
|
| 路径 | 操作 | 期望结果 |
|
|
44
44
|
|---|---|---|
|
|
45
|
-
| **A. 就地追问(侧边卡片)** | 在聊天区选中一句话 →
|
|
45
|
+
| **A. 就地追问(侧边卡片)** | 在聊天区选中一句话 → 点浮出的按钮(或点旁边的「解释/翻译/总结/润色/纠错」芯片一键发起)→ 输入问题 → Enter | 右下角浮出卡片,答案逐字流出;卡片底部有「复制 / 继续追问 / 改问 / 关闭」,代码块右上角可单独复制 |
|
|
46
46
|
| **B. 主对话追问** | 选中 → 提问框里把「作答位置」切到**主对话** → Enter | 追问以引用块形式出现在主对话,答案由当前会话正常流式输出 |
|
|
47
47
|
| **C. 快捷键 + 降级** | 选中 → 按 `Alt+Q`(默认) | 直接弹出提问框;若侧边作答引擎不可用,卡片给出错误提示与「改到主对话」按钮 |
|
|
48
48
|
|
|
@@ -159,6 +159,7 @@ plugin_manager(action="install_bundle", target="D:\\Users\\34332\\AI\\dsh-sideca
|
|
|
159
159
|
| `sideTools` | `readonly` \| `inherit` | `readonly` | 侧边作答者的工具权限:只读白名单 / 继承当前会话 | 下一次提问 |
|
|
160
160
|
| `sideTimeoutMs` | 5000–3600000 | `180000` | 单次侧边作答超时 | 下一次提问 |
|
|
161
161
|
| `sideProvider` | 字符串 \| `auto` | `auto` | 指定子代理 provider(`auto` 优先 `spawn`) | 下一次提问 |
|
|
162
|
+
| `quickActions` | 动作数组(≤12 条) | 5 个内置动作 | **一键快捷动作**:选中后按钮旁的动作芯片。条目形如 `{id, enabled, shortcut}`;内置 id 为 `builtin:explain/translate/summarize/polish/fix`,自定义动作(非 `builtin:` 前缀)还需 `label`(≤20 字)与 `question`(≤500 字,可用 `{selection}` 内嵌选中正文)。设置页可视化编辑,用户层整表覆盖默认值 | 立即(快捷键下次按键生效) |
|
|
162
163
|
|
|
163
164
|
非法值会被拒绝(设置页报错、接口返回 `invalid-config`),并把被忽略的项列在 `/state` 的 `problems` 里——不会静默吞掉。
|
|
164
165
|
|
|
@@ -456,12 +457,16 @@ node tools/compat-probe.mjs --json tools/.cache/matrix.json
|
|
|
456
457
|
### A. 路径一:就地追问 → 侧边卡片流式作答
|
|
457
458
|
|
|
458
459
|
1. 在聊天区选中一句**非输入框内**的文本(例如助手消息里的半句话);
|
|
459
|
-
2.
|
|
460
|
-
|
|
460
|
+
2. 选区末端应浮出动作条:「💬 追问选中内容」按钮 + 「解释 / 翻译 / 总结 / 润色 / 纠错」芯片,
|
|
461
|
+
按钮带区域徽标(聊天区 / 任务区 / 跨区选择);**点任一芯片即跳过提问框直接在侧边卡片作答**;
|
|
462
|
+
3. 点主按钮后提问框出现(顶部同样有一排芯片),焦点在输入框,引文预览显示选中文本;
|
|
461
463
|
4. 输入问题按 Enter → 提问框关闭,右下角出现卡片,状态先为「作答中…」并**逐字增长**;
|
|
462
|
-
5.
|
|
463
|
-
6. 点「复制」→ 出现「已复制」提示;点「继续追问」→
|
|
464
|
+
5. 结束后状态变「已完成」;底部出现「改问 / 复制 / 继续追问 / 关闭」;答案里代码块的右上角有「⧉ 复制代码」;
|
|
465
|
+
6. 点「复制」→ 出现「已复制」提示;点「继续追问」→ 输入第二条问题(追加一轮),卡片内容**重置换行**后继续流式;
|
|
466
|
+
点「改问」→ 修改当前问题后**重新生成这一轮**(不追加历史);
|
|
464
467
|
7. 点「关闭」→ 卡片消失;若承载面是 better-sidebar/原生右侧栏,对应 tab 也应关闭。
|
|
468
|
+
7b. 设置页「快捷动作」区:可停用内置动作、给动作配快捷键(如 `Alt+T`)、添加自定义动作
|
|
469
|
+
(名称 + 问题指令,指令里可写 `{selection}`);配好后选中文字直接按快捷键等价于点芯片。
|
|
465
470
|
8. **浮层形态与位置(1.5.0)**:默认应是一个**一行高的胶囊**(状态 + 问题首句),点它才展开成完整卡片;
|
|
466
471
|
展开后标题栏右侧有「收起为胶囊」(⇥)与「隐藏浮层」(×)——隐藏后只剩一个带计数的圆点,点它恢复。
|
|
467
472
|
用 F12 选中浮层根节点(`[data-dsa-root]`)读两个值自检:
|
|
@@ -510,8 +515,8 @@ node test/smoke-test.mjs # 端到端:流式/截断/取消/持久化/失
|
|
|
510
515
|
```
|
|
511
516
|
|
|
512
517
|
四个脚本都以 `process.exitCode` 反映结果,失败会列出具体条目;测试会把 `DSH_HOME` 指向临时目录,不会污染真实配置。
|
|
513
|
-
当前规模(1.
|
|
514
|
-
smoke **
|
|
518
|
+
当前规模(1.7.0 本机实测,四项均 exit 0):verify **53** 项 + contract **589** 项 + float-placement **38** 项 +
|
|
519
|
+
smoke **181** 项 = **861 项全部通过**。
|
|
515
520
|
|
|
516
521
|
> 1.5.0 之前这里还放过 `tools/balance-check.mjs` 与 `tools/i18n-check.mjs` 两个「替代检查」。
|
|
517
522
|
> 它们已被**删除**:前者用「正则字面量 vs 除号」的启发式判断,把 8 个源文件全部误报为残损;
|
package/client.js
CHANGED
|
@@ -47,7 +47,7 @@ window.__ModuleLoader__.load({
|
|
|
47
47
|
* the Host and the module's `api.version` both read it, so a report can
|
|
48
48
|
* never claim a generation the browser is not actually running.
|
|
49
49
|
*/
|
|
50
|
-
const CLIENT_VERSION = '1.
|
|
50
|
+
const CLIENT_VERSION = '1.7.0'
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
53
|
* Tab kind served by the DSH native right rail (also its implementation id).
|
|
@@ -98,6 +98,42 @@ window.__ModuleLoader__.load({
|
|
|
98
98
|
sideProvider: 'auto',
|
|
99
99
|
floatMode: 'capsule',
|
|
100
100
|
floatMaxWidth: 340,
|
|
101
|
+
// Mirrors the Host's DEFAULT_CONFIG.quickActions (contract-asserted):
|
|
102
|
+
// `builtin:*` entries are resolved through QUICK_ACTION_BUILTINS, while
|
|
103
|
+
// custom entries (no builtin prefix) carry their own label/question.
|
|
104
|
+
quickActions: [
|
|
105
|
+
{ id: 'builtin:explain', enabled: true, shortcut: '' },
|
|
106
|
+
{ id: 'builtin:translate', enabled: true, shortcut: '' },
|
|
107
|
+
{ id: 'builtin:summarize', enabled: true, shortcut: '' },
|
|
108
|
+
{ id: 'builtin:polish', enabled: true, shortcut: '' },
|
|
109
|
+
{ id: 'builtin:fix', enabled: true, shortcut: '' },
|
|
110
|
+
],
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Bounds for quick-action editing, mirrored by the Host validator. The
|
|
115
|
+
* settings page refuses anything outside these before it ever posts.
|
|
116
|
+
*/
|
|
117
|
+
const QUICK_ACTION_LIMITS = {
|
|
118
|
+
maxEntries: 12,
|
|
119
|
+
idLength: 40,
|
|
120
|
+
labelLength: 20,
|
|
121
|
+
questionLength: 500,
|
|
122
|
+
shortcutLength: 40,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Built-in quick actions. The config list only carries `builtin:<id>`;
|
|
127
|
+
* the localized label and question live HERE so the wire/config layers
|
|
128
|
+
* stay language-neutral. Questions are instructions only — the Host
|
|
129
|
+
* fences the selection separately, so none of them needs to quote text.
|
|
130
|
+
*/
|
|
131
|
+
const QUICK_ACTION_BUILTINS = {
|
|
132
|
+
'builtin:explain': { labelKey: 'qaExplain', questionKey: 'qaExplainQ' },
|
|
133
|
+
'builtin:translate': { labelKey: 'qaTranslate', questionKey: 'qaTranslateQ' },
|
|
134
|
+
'builtin:summarize': { labelKey: 'qaSummarize', questionKey: 'qaSummarizeQ' },
|
|
135
|
+
'builtin:polish': { labelKey: 'qaPolish', questionKey: 'qaPolishQ' },
|
|
136
|
+
'builtin:fix': { labelKey: 'qaFix', questionKey: 'qaFixQ' },
|
|
101
137
|
}
|
|
102
138
|
|
|
103
139
|
/** Zone classification: slot-key patterns the harness itself renders. */
|
|
@@ -298,6 +334,39 @@ window.__ModuleLoader__.load({
|
|
|
298
334
|
familyLegacyToast: '检测到旧版 dsh-selection-followup 同时启用,已由本版本接管;建议卸载旧包',
|
|
299
335
|
familyLegacyDiag: '旧版共载',
|
|
300
336
|
familyPeers: '同页实例',
|
|
337
|
+
retryEdit: '改问',
|
|
338
|
+
editQuestionPlaceholder: '修改问题后重新提问…',
|
|
339
|
+
retryAskSubmit: '重新提问',
|
|
340
|
+
copyCode: '复制代码',
|
|
341
|
+
qaExplain: '解释',
|
|
342
|
+
qaExplainQ: '请用简洁的语言解释这段内容的含义。',
|
|
343
|
+
qaTranslate: '翻译',
|
|
344
|
+
qaTranslateQ: '请把这段内容翻译成中文;如果原文已经是中文,则翻译成英文。只输出译文,不要加解释。',
|
|
345
|
+
qaSummarize: '总结',
|
|
346
|
+
qaSummarizeQ: '请用简短的要点总结这段内容。',
|
|
347
|
+
qaPolish: '润色',
|
|
348
|
+
qaPolishQ: '请润色这段文字:保持原意、修正语病与不通顺之处,只输出润色后的内容。',
|
|
349
|
+
qaFix: '纠错',
|
|
350
|
+
qaFixQ: '请检查这段内容中的事实性、逻辑性与文字错误,逐条列出问题并给出修正建议。',
|
|
351
|
+
setQuickActions: '快捷动作',
|
|
352
|
+
setQuickActionsHint: '选中文字后显示的一键动作。自定义问题中的 {selection} 会替换为选中内容;不写占位符时,原文会随问题自动附上。',
|
|
353
|
+
qaEnabled: '启用',
|
|
354
|
+
qaAdd: '+ 添加动作',
|
|
355
|
+
qaDelete: '删除',
|
|
356
|
+
qaBuiltinBadge: '内置',
|
|
357
|
+
qaLabelPlaceholder: '动作名称(如:英译中)',
|
|
358
|
+
qaQuestionPlaceholder: '问题指令,可用 {selection} 引用选中内容',
|
|
359
|
+
qaShortcutPlaceholder: '如 Alt+T,留空表示无快捷键',
|
|
360
|
+
qaTooMany: '快捷动作最多 {n} 条',
|
|
361
|
+
qaMalformed: '存在格式不正确的动作',
|
|
362
|
+
qaBadId: '动作 id 缺失或超长',
|
|
363
|
+
qaDuplicateId: '存在重复的动作 id',
|
|
364
|
+
qaBadLabel: '动作名称必填,且不超过 {n} 个字符',
|
|
365
|
+
qaBadQuestion: '问题指令必填,且不超过 {n} 个字符',
|
|
366
|
+
qaBadShortcut: '“{label}”的快捷键格式不正确,请用 Alt+Q 这样的写法',
|
|
367
|
+
qaDuplicateShortcut: '“{label}”的快捷键与“{other}”冲突',
|
|
368
|
+
familyLegacyDiag: '旧版共载',
|
|
369
|
+
familyPeers: '同页实例',
|
|
301
370
|
},
|
|
302
371
|
en: {
|
|
303
372
|
triggerLabel: 'Ask about selection',
|
|
@@ -438,6 +507,37 @@ window.__ModuleLoader__.load({
|
|
|
438
507
|
familyLegacyToast: 'Legacy dsh-selection-followup detected alongside this plugin; this build took over. Uninstall the old package.',
|
|
439
508
|
familyLegacyDiag: 'Legacy co-load',
|
|
440
509
|
familyPeers: 'Copies on page',
|
|
510
|
+
retryEdit: 'Edit & retry',
|
|
511
|
+
editQuestionPlaceholder: 'Edit the question, then ask again…',
|
|
512
|
+
retryAskSubmit: 'Ask again',
|
|
513
|
+
copyCode: 'Copy code',
|
|
514
|
+
qaExplain: 'Explain',
|
|
515
|
+
qaExplainQ: 'Explain what this passage means, concisely.',
|
|
516
|
+
qaTranslate: 'Translate',
|
|
517
|
+
qaTranslateQ: 'Translate this passage into English; if it is already in English, translate it into Chinese. Output the translation only, with no explanation.',
|
|
518
|
+
qaSummarize: 'Summarize',
|
|
519
|
+
qaSummarizeQ: 'Summarize the key points of this passage briefly.',
|
|
520
|
+
qaPolish: 'Polish',
|
|
521
|
+
qaPolishQ: 'Polish this text: keep the original meaning, fix grammar and awkward phrasing, and output only the polished version.',
|
|
522
|
+
qaFix: 'Proofread',
|
|
523
|
+
qaFixQ: 'Check this passage for factual, logical, and writing errors. List each issue with a suggested fix.',
|
|
524
|
+
setQuickActions: 'Quick actions',
|
|
525
|
+
setQuickActionsHint: 'One-click actions shown after selecting text. In a custom question, {selection} is replaced by the selected text; without it, the source text is attached automatically.',
|
|
526
|
+
qaEnabled: 'On',
|
|
527
|
+
qaAdd: '+ Add action',
|
|
528
|
+
qaDelete: 'Delete',
|
|
529
|
+
qaBuiltinBadge: 'built-in',
|
|
530
|
+
qaLabelPlaceholder: 'Action name (e.g. EN→ZH)',
|
|
531
|
+
qaQuestionPlaceholder: 'Question; use {selection} for the selected text',
|
|
532
|
+
qaShortcutPlaceholder: 'e.g. Alt+T; blank for none',
|
|
533
|
+
qaTooMany: 'At most {n} quick actions',
|
|
534
|
+
qaMalformed: 'A malformed action exists',
|
|
535
|
+
qaBadId: 'An action id is missing or too long',
|
|
536
|
+
qaDuplicateId: 'Duplicate action ids exist',
|
|
537
|
+
qaBadLabel: 'Action names are required and at most {n} characters',
|
|
538
|
+
qaBadQuestion: 'Questions are required and at most {n} characters',
|
|
539
|
+
qaBadShortcut: 'The shortcut for "{label}" is invalid; use a form like Alt+Q',
|
|
540
|
+
qaDuplicateShortcut: 'The shortcut for "{label}" conflicts with "{other}"',
|
|
441
541
|
},
|
|
442
542
|
}
|
|
443
543
|
|
|
@@ -1028,6 +1128,134 @@ window.__ModuleLoader__.load({
|
|
|
1028
1128
|
&& event.metaKey === facts.meta
|
|
1029
1129
|
}
|
|
1030
1130
|
|
|
1131
|
+
/**
|
|
1132
|
+
* Resolve the configured quick actions into renderable, ENABLED entries.
|
|
1133
|
+
* The Host validator keeps a saved list well-formed; this is the
|
|
1134
|
+
* render-time guard for an in-memory/edited draft: unknown `builtin:*`
|
|
1135
|
+
* ids and custom entries missing label/question are skipped.
|
|
1136
|
+
* @param {object|Array} configOrList - the effective config or a raw list.
|
|
1137
|
+
* @returns {Array<{id:string,label:string,question:string,shortcut:string,builtin:boolean}>}
|
|
1138
|
+
*/
|
|
1139
|
+
function quickActionsOf(configOrList) {
|
|
1140
|
+
const list = Array.isArray(configOrList) ? configOrList : configOrList?.quickActions
|
|
1141
|
+
if (!Array.isArray(list)) return []
|
|
1142
|
+
const out = []
|
|
1143
|
+
for (const entry of list) {
|
|
1144
|
+
if (entry === null || typeof entry !== 'object') continue
|
|
1145
|
+
if (entry.enabled === false) continue
|
|
1146
|
+
const id = String(entry.id ?? '')
|
|
1147
|
+
const meta = QUICK_ACTION_BUILTINS[id]
|
|
1148
|
+
if (meta !== undefined) {
|
|
1149
|
+
out.push({
|
|
1150
|
+
id,
|
|
1151
|
+
label: t(meta.labelKey),
|
|
1152
|
+
question: t(meta.questionKey),
|
|
1153
|
+
shortcut: typeof entry.shortcut === 'string' ? entry.shortcut : '',
|
|
1154
|
+
builtin: true,
|
|
1155
|
+
})
|
|
1156
|
+
continue
|
|
1157
|
+
}
|
|
1158
|
+
// An unknown builtin id resolves to nothing the client knows.
|
|
1159
|
+
if (id.startsWith('builtin:')) continue
|
|
1160
|
+
if (typeof entry.label === 'string' && entry.label.trim() !== ''
|
|
1161
|
+
&& typeof entry.question === 'string' && entry.question.trim() !== '') {
|
|
1162
|
+
out.push({
|
|
1163
|
+
id,
|
|
1164
|
+
label: entry.label,
|
|
1165
|
+
question: entry.question,
|
|
1166
|
+
shortcut: typeof entry.shortcut === 'string' ? entry.shortcut : '',
|
|
1167
|
+
builtin: false,
|
|
1168
|
+
})
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
return out
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* The question actually sent for one quick action. A template containing
|
|
1176
|
+
* `{selection}` embeds the (truncated) selection inline; a template
|
|
1177
|
+
* without it is sent as-is, because the Host fences the selection in
|
|
1178
|
+
* every prompt regardless.
|
|
1179
|
+
* @param {{question?: string}} action - a resolved quick action.
|
|
1180
|
+
* @param {string} selection - the raw selected text.
|
|
1181
|
+
* @param {number} maxChars - the selection truncation budget.
|
|
1182
|
+
* @returns {string}
|
|
1183
|
+
*/
|
|
1184
|
+
function renderActionQuestion(action, selection, maxChars) {
|
|
1185
|
+
const template = String(action?.question ?? '')
|
|
1186
|
+
if (!template.includes('{selection}')) return template
|
|
1187
|
+
const cut = truncateSelection(String(selection ?? ''), maxChars)
|
|
1188
|
+
return template.split('{selection}').join(cut.text)
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/** Canonical fingerprint of an accelerator, for duplicate detection. */
|
|
1192
|
+
function shortcutFingerprint(accelerator) {
|
|
1193
|
+
const facts = parseShortcut(accelerator)
|
|
1194
|
+
if (facts === null) return null
|
|
1195
|
+
return `${facts.ctrl ? 1 : 0}|${facts.alt ? 1 : 0}|${facts.shift ? 1 : 0}|${facts.meta ? 1 : 0}|${facts.key}`
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/**
|
|
1199
|
+
* Validate a settings-page quick-action draft BEFORE posting it, so the
|
|
1200
|
+
* Host's all-or-nothing refusal never comes back as a surprise. Also
|
|
1201
|
+
* checks that no action shortcut collides with another action or with the
|
|
1202
|
+
* main popover shortcut.
|
|
1203
|
+
* @param {unknown} list - the draft `quickActions` array.
|
|
1204
|
+
* @param {string} mainShortcut - the configured popover shortcut.
|
|
1205
|
+
* @returns {{ok: true}|{ok: false, key: string, vars?: object}}
|
|
1206
|
+
*/
|
|
1207
|
+
function validateQuickActionsDraft(list, mainShortcut) {
|
|
1208
|
+
if (!Array.isArray(list) || list.length > QUICK_ACTION_LIMITS.maxEntries) {
|
|
1209
|
+
return { ok: false, key: 'qaTooMany' }
|
|
1210
|
+
}
|
|
1211
|
+
const ids = new Set()
|
|
1212
|
+
const taken = new Map()
|
|
1213
|
+
const mainFingerprint = shortcutFingerprint(mainShortcut)
|
|
1214
|
+
if (mainFingerprint !== null) taken.set(mainFingerprint, '')
|
|
1215
|
+
const labelOf = (entry) => {
|
|
1216
|
+
const meta = QUICK_ACTION_BUILTINS[String(entry.id ?? '')]
|
|
1217
|
+
return meta !== undefined ? t(meta.labelKey) : String(entry.label ?? '')
|
|
1218
|
+
}
|
|
1219
|
+
for (const entry of list) {
|
|
1220
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
1221
|
+
return { ok: false, key: 'qaMalformed' }
|
|
1222
|
+
}
|
|
1223
|
+
const id = String(entry.id ?? '')
|
|
1224
|
+
if (id === '' || id.length > QUICK_ACTION_LIMITS.idLength) return { ok: false, key: 'qaBadId' }
|
|
1225
|
+
if (ids.has(id)) return { ok: false, key: 'qaDuplicateId' }
|
|
1226
|
+
ids.add(id)
|
|
1227
|
+
const meta = QUICK_ACTION_BUILTINS[id]
|
|
1228
|
+
const label = labelOf(entry)
|
|
1229
|
+
if (meta === undefined && !id.startsWith('builtin:')) {
|
|
1230
|
+
const customLabel = String(entry.label ?? '')
|
|
1231
|
+
const customQuestion = String(entry.question ?? '')
|
|
1232
|
+
if (customLabel.trim() === '' || customLabel.length > QUICK_ACTION_LIMITS.labelLength) {
|
|
1233
|
+
return { ok: false, key: 'qaBadLabel', vars: { n: QUICK_ACTION_LIMITS.labelLength } }
|
|
1234
|
+
}
|
|
1235
|
+
if (customQuestion.trim() === '' || customQuestion.length > QUICK_ACTION_LIMITS.questionLength) {
|
|
1236
|
+
return { ok: false, key: 'qaBadQuestion', vars: { n: QUICK_ACTION_LIMITS.questionLength } }
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
const shortcut = String(entry.shortcut ?? '').trim()
|
|
1240
|
+
if (shortcut !== '') {
|
|
1241
|
+
if (parseShortcut(shortcut) === null) {
|
|
1242
|
+
return { ok: false, key: 'qaBadShortcut', vars: { label } }
|
|
1243
|
+
}
|
|
1244
|
+
const fingerprint = shortcutFingerprint(shortcut)
|
|
1245
|
+
if (taken.has(fingerprint)) {
|
|
1246
|
+
const owner = taken.get(fingerprint)
|
|
1247
|
+
return {
|
|
1248
|
+
ok: false,
|
|
1249
|
+
key: 'qaDuplicateShortcut',
|
|
1250
|
+
vars: { label, other: owner === '' ? t('setShortcut') : owner },
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
taken.set(fingerprint, label)
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
return { ok: true }
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1031
1259
|
// ─────────────────────────────────────────────────────────────────────
|
|
1032
1260
|
// §6 carriers
|
|
1033
1261
|
// ─────────────────────────────────────────────────────────────────────
|
|
@@ -1363,7 +1591,7 @@ window.__ModuleLoader__.load({
|
|
|
1363
1591
|
(\`html[data-platform] body>[data-dsh-better-sidebar]{-webkit-app-region:initial}\`
|
|
1364
1592
|
plus \`[data-dsh-panel-host]>*{-webkit-app-region:no-drag}\`).
|
|
1365
1593
|
Every surface we draw declares it. The property is inert in a browser. */
|
|
1366
|
-
.dsa-layer,.dsa-trigger,.dsa-pop,.dsa-float,.dsa-stack,.dsa-card,.dsa-toast,.dsa-settings{-webkit-app-region:no-drag}
|
|
1594
|
+
.dsa-layer,.dsa-trigger,.dsa-quickbar,.dsa-pop,.dsa-float,.dsa-stack,.dsa-card,.dsa-toast,.dsa-settings{-webkit-app-region:no-drag}
|
|
1367
1595
|
.dsa-layer{pointer-events:none;position:relative;z-index:60}
|
|
1368
1596
|
.dsa-trigger{pointer-events:auto;position:fixed;display:flex;align-items:center;gap:4px;
|
|
1369
1597
|
padding:4px 8px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
|
|
@@ -1372,6 +1600,26 @@ window.__ModuleLoader__.load({
|
|
|
1372
1600
|
font-family:inherit;white-space:nowrap}
|
|
1373
1601
|
.dsa-trigger:hover{border-color:var(--dsw-alias-brand-primary, #4c8dff)}
|
|
1374
1602
|
.dsa-trigger:focus-visible{outline:2px solid var(--dsw-alias-brand-primary, #4c8dff);outline-offset:1px}
|
|
1603
|
+
/* The one-click action bar: the trigger button plus compact chips, laid out
|
|
1604
|
+
in one wrapping row under the selection. The container is positioned by the
|
|
1605
|
+
component; chips keep their own pointer-events because .dsa-layer is a
|
|
1606
|
+
click-through layer. */
|
|
1607
|
+
.dsa-quickbar{pointer-events:auto;position:fixed;display:flex;align-items:center;gap:6px;
|
|
1608
|
+
max-width:min(620px, 92vw);flex-wrap:wrap;z-index:60}
|
|
1609
|
+
.dsa-chip{pointer-events:auto;padding:3px 10px;border-radius:999px;
|
|
1610
|
+
border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
|
|
1611
|
+
background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-primary, #111);
|
|
1612
|
+
font:inherit;font-size:12px;line-height:16px;cursor:pointer;white-space:nowrap;
|
|
1613
|
+
box-shadow:0 2px 8px rgba(0,0,0,.14)}
|
|
1614
|
+
.dsa-chip:hover{border-color:var(--dsw-alias-brand-primary, #4c8dff)}
|
|
1615
|
+
.dsa-chip:focus-visible{outline:2px solid var(--dsw-alias-brand-primary, #4c8dff);outline-offset:1px}
|
|
1616
|
+
/* Chips rendered INSIDE the popover need no floating shadow. */
|
|
1617
|
+
.dsa-pop-chips{display:flex;gap:6px;flex-wrap:wrap}
|
|
1618
|
+
.dsa-pop-chips .dsa-chip{box-shadow:none}
|
|
1619
|
+
/* Inside the trigger bar the main button and chips are one flex row: the
|
|
1620
|
+
trigger drops its own fixed positioning, chips join the row directly. */
|
|
1621
|
+
.dsa-trigger-embedded{position:static;box-shadow:0 2px 10px rgba(0,0,0,.18)}
|
|
1622
|
+
.dsa-quickbar-chips{display:contents}
|
|
1375
1623
|
.dsa-pop{pointer-events:auto;position:fixed;width:min(420px, 92vw);display:flex;flex-direction:column;gap:8px;
|
|
1376
1624
|
padding:12px;border-radius:12px;border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
|
|
1377
1625
|
background:var(--dsw-alias-bg-overlay, #fff);color:var(--dsw-alias-label-primary, #111);
|
|
@@ -1456,6 +1704,11 @@ window.__ModuleLoader__.load({
|
|
|
1456
1704
|
.dsa-table th,.dsa-table td{border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
|
|
1457
1705
|
padding:4px 8px;text-align:left;word-break:break-word;vertical-align:top}
|
|
1458
1706
|
.dsa-table th{background:var(--dsw-alias-bg-layer-2, rgba(127,127,127,.12));font-weight:600}
|
|
1707
|
+
.dsa-pre-wrap{position:relative;margin:0 0 6px}
|
|
1708
|
+
.dsa-pre-wrap .dsa-pre{margin:0}
|
|
1709
|
+
.dsa-code-copy{position:absolute;top:4px;right:4px;padding:1px 7px;line-height:16px;
|
|
1710
|
+
font-size:12px;opacity:.72;background:var(--dsw-alias-bg-overlay, #fff)}
|
|
1711
|
+
.dsa-code-copy:hover,.dsa-code-copy:focus-visible{opacity:1}
|
|
1459
1712
|
.dsa-pre{margin:0 0 6px;padding:8px;border-radius:8px;overflow:auto;
|
|
1460
1713
|
background:var(--dsw-alias-bg-layer-2, rgba(127,127,127,.12));font-size:12px;line-height:17px;
|
|
1461
1714
|
font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
|
|
@@ -1474,6 +1727,14 @@ window.__ModuleLoader__.load({
|
|
|
1474
1727
|
background:var(--dsw-alias-bg-layer-1, transparent);color:inherit}
|
|
1475
1728
|
.dsa-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px}
|
|
1476
1729
|
.dsa-check{display:flex;align-items:center;gap:6px;font-size:12px}
|
|
1730
|
+
/* Quick-action editor (settings page): one wrapped row per action. */
|
|
1731
|
+
.dsa-qa{display:flex;flex-direction:column;gap:8px}
|
|
1732
|
+
.dsa-qa-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;
|
|
1733
|
+
padding:8px;border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.3));border-radius:8px}
|
|
1734
|
+
.dsa-qa-row .dsa-input{flex:1;min-width:120px}
|
|
1735
|
+
.dsa-qa-question{flex-basis:100%!important;min-width:0!important}
|
|
1736
|
+
.dsa-qa-head{display:flex;align-items:center;gap:8px;font-size:12px;
|
|
1737
|
+
color:var(--dsw-alias-label-secondary, #555)}
|
|
1477
1738
|
.dsa-diag{display:flex;flex-direction:column;gap:2px;font-size:12px;
|
|
1478
1739
|
color:var(--dsw-alias-label-secondary, #555)}
|
|
1479
1740
|
/* §4.5 legacy coexistence: an unmarked dsa-layer can only be the old
|
|
@@ -1559,7 +1820,33 @@ window.__ModuleLoader__.load({
|
|
|
1559
1820
|
i += 1
|
|
1560
1821
|
}
|
|
1561
1822
|
i += 1
|
|
1562
|
-
|
|
1823
|
+
const code = body.join('\n')
|
|
1824
|
+
// A per-block copy button: answers are code-heavy, and dragging a
|
|
1825
|
+
// selection across a scrollable <pre> used to miss lines. The
|
|
1826
|
+
// click closes over the code text (never re-read from the DOM), and
|
|
1827
|
+
// reuses the same clipboard API → selection fallback as card copy.
|
|
1828
|
+
nodes.push(h('div', { key: `f${key++}`, className: 'dsa-pre-wrap' },
|
|
1829
|
+
h('pre', { className: 'dsa-pre' }, h('code', null, code)),
|
|
1830
|
+
h('button', {
|
|
1831
|
+
type: 'button',
|
|
1832
|
+
className: 'dsa-btn dsa-btn-icon dsa-code-copy',
|
|
1833
|
+
title: t('copyCode'),
|
|
1834
|
+
'aria-label': t('copyCode'),
|
|
1835
|
+
onClick: (event) => {
|
|
1836
|
+
try {
|
|
1837
|
+
const host = event?.currentTarget ?? event?.target
|
|
1838
|
+
const wrap = typeof host?.closest === 'function' ? host.closest('.dsa-pre-wrap') : null
|
|
1839
|
+
const codeNode = wrap !== null && typeof wrap?.querySelector === 'function'
|
|
1840
|
+
? wrap.querySelector('code')
|
|
1841
|
+
: null
|
|
1842
|
+
void copyText(code, codeNode).then((result) => {
|
|
1843
|
+
toast(result === 'copied'
|
|
1844
|
+
? t('copied')
|
|
1845
|
+
: result === 'selected' ? t('copySelectedHint') : t('copyFailed'))
|
|
1846
|
+
})
|
|
1847
|
+
} catch { /* a clipboard failure must not break the card */ }
|
|
1848
|
+
},
|
|
1849
|
+
}, '⧉')))
|
|
1563
1850
|
continue
|
|
1564
1851
|
}
|
|
1565
1852
|
if (line.includes('|') && i + 1 < lines.length && isTableSplit(lines[i + 1])) {
|
|
@@ -1706,15 +1993,15 @@ window.__ModuleLoader__.load({
|
|
|
1706
1993
|
// ─────────────────────────────────────────────────────────────────────
|
|
1707
1994
|
|
|
1708
1995
|
/** The in-place trigger button floating at the selection. */
|
|
1709
|
-
function TriggerButton({ candidate, onOpen }) {
|
|
1996
|
+
function TriggerButton({ candidate, onOpen, embedded = false }) {
|
|
1710
1997
|
// The clamp width follows the live label: icon + gap + padding + border
|
|
1711
1998
|
// (~38px of chrome) plus a safety margin, over the estimated label width.
|
|
1712
1999
|
const width = Math.ceil(estimateLabelWidth(t('triggerLabel')) + 62)
|
|
1713
|
-
const box = clampBox(candidate.rect, width, 30, 'below')
|
|
2000
|
+
const box = embedded ? null : clampBox(candidate.rect, width, 30, 'below')
|
|
1714
2001
|
return h('button', {
|
|
1715
2002
|
type: 'button',
|
|
1716
|
-
className: 'dsa-trigger',
|
|
1717
|
-
style: { left: `${box.left}px`, top: `${box.top}px` },
|
|
2003
|
+
className: embedded ? 'dsa-trigger dsa-trigger-embedded' : 'dsa-trigger',
|
|
2004
|
+
style: embedded ? undefined : { left: `${box.left}px`, top: `${box.top}px` },
|
|
1718
2005
|
title: t('triggerHint'),
|
|
1719
2006
|
'aria-label': `${t('triggerLabel')}:${zoneLabel(candidate)}`,
|
|
1720
2007
|
onMouseDown: (event) => { event.preventDefault() },
|
|
@@ -1726,14 +2013,64 @@ window.__ModuleLoader__.load({
|
|
|
1726
2013
|
}, h('span', { 'aria-hidden': true }, '💬'), h('span', null, t('triggerLabel')))
|
|
1727
2014
|
}
|
|
1728
2015
|
|
|
2016
|
+
/**
|
|
2017
|
+
* Compact quick-action chips, shared by the in-place trigger bar and the
|
|
2018
|
+
* popover. The trigger bar lays chips out directly (`display:contents`);
|
|
2019
|
+
* the popover wraps them in its own borderless chip row.
|
|
2020
|
+
*/
|
|
2021
|
+
function QuickChips({ actions, onPick, inline = false }) {
|
|
2022
|
+
if (!Array.isArray(actions) || actions.length === 0) return null
|
|
2023
|
+
return h('div', { className: inline ? 'dsa-quickbar-chips' : 'dsa-pop-chips' },
|
|
2024
|
+
actions.map(action => h('button', {
|
|
2025
|
+
key: action.id,
|
|
2026
|
+
type: 'button',
|
|
2027
|
+
className: 'dsa-chip',
|
|
2028
|
+
title: typeof action.shortcut === 'string' && action.shortcut.trim() !== ''
|
|
2029
|
+
? `${action.question}\n${action.shortcut}`
|
|
2030
|
+
: action.question,
|
|
2031
|
+
'aria-label': `${action.label}:${action.question}`,
|
|
2032
|
+
onMouseDown: (event) => { event.preventDefault() },
|
|
2033
|
+
onClick: (event) => {
|
|
2034
|
+
event.preventDefault()
|
|
2035
|
+
event.stopPropagation()
|
|
2036
|
+
onPick(action)
|
|
2037
|
+
},
|
|
2038
|
+
}, action.label)))
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
/**
|
|
2042
|
+
* The in-place trigger bar: the main "ask" button followed by every
|
|
2043
|
+
* enabled quick action. A chip click skips the popover entirely — the
|
|
2044
|
+
* question is fixed and the answer goes straight to the side card.
|
|
2045
|
+
*/
|
|
2046
|
+
function TriggerBar({ candidate, config, onOpen, onQuick }) {
|
|
2047
|
+
const actions = quickActionsOf(config)
|
|
2048
|
+
const mainWidth = Math.ceil(estimateLabelWidth(t('triggerLabel')) + 62)
|
|
2049
|
+
// Rough row width for viewport clamping: main button + 6px gap + an
|
|
2050
|
+
// estimate per chip (label + ~24px padding/chrome). Wrapped rows cost
|
|
2051
|
+
// height, not width, so the clamp stays usable even when it wraps.
|
|
2052
|
+
const chipsWidth = actions.reduce(
|
|
2053
|
+
(sum, action) => sum + Math.ceil(estimateLabelWidth(action.label) + 24), 0)
|
|
2054
|
+
const rowWidth = Math.min(620, mainWidth + (actions.length > 0 ? 6 + chipsWidth : 0))
|
|
2055
|
+
const box = clampBox(candidate.rect, rowWidth, 34, 'below')
|
|
2056
|
+
return h('div', {
|
|
2057
|
+
className: 'dsa-quickbar',
|
|
2058
|
+
style: { left: `${box.left}px`, top: `${box.top}px` },
|
|
2059
|
+
onMouseDown: event => event.stopPropagation(),
|
|
2060
|
+
},
|
|
2061
|
+
h(TriggerButton, { candidate, onOpen, embedded: true }),
|
|
2062
|
+
h(QuickChips, { actions, onPick: onQuick, inline: true }))
|
|
2063
|
+
}
|
|
2064
|
+
|
|
1729
2065
|
/** The question popover. */
|
|
1730
|
-
function AskPopover({ candidate, config, onClose, onSubmit }) {
|
|
2066
|
+
function AskPopover({ candidate, config, onClose, onSubmit, onQuick }) {
|
|
1731
2067
|
const [question, setQuestion] = React.useState('')
|
|
1732
2068
|
const [carrier, setCarrier] = React.useState(config.defaultCarrier)
|
|
1733
2069
|
const [busy, setBusy] = React.useState(false)
|
|
1734
2070
|
const areaRef = React.useRef(null)
|
|
1735
2071
|
const cut = truncateSelection(candidate.text, config.maxChars)
|
|
1736
|
-
const box = clampBox(candidate.rect, 420,
|
|
2072
|
+
const box = clampBox(candidate.rect, 420, 300, 'below')
|
|
2073
|
+
const quickActions = quickActionsOf(config)
|
|
1737
2074
|
|
|
1738
2075
|
React.useEffect(() => {
|
|
1739
2076
|
const area = areaRef.current
|
|
@@ -1780,6 +2117,10 @@ window.__ModuleLoader__.load({
|
|
|
1780
2117
|
codePointLength(candidate.text) > 400
|
|
1781
2118
|
? `${Array.from(candidate.text).slice(0, 400).join('')}…`
|
|
1782
2119
|
: candidate.text),
|
|
2120
|
+
// Same chips as the in-place bar: shortcut-only users reach them via
|
|
2121
|
+
// Alt+Q, and a click here submits directly (the popover closes because
|
|
2122
|
+
// submit/quickAsk both clear `popover`).
|
|
2123
|
+
h(QuickChips, { actions: quickActions, onPick: action => onQuick(action) }),
|
|
1783
2124
|
h('textarea', {
|
|
1784
2125
|
ref: areaRef,
|
|
1785
2126
|
className: 'dsa-textarea',
|
|
@@ -1854,8 +2195,8 @@ window.__ModuleLoader__.load({
|
|
|
1854
2195
|
reasoning, renderRichText(card.text))
|
|
1855
2196
|
}
|
|
1856
2197
|
|
|
1857
|
-
/** Card footer: status, copy, follow-up, retry, close. */
|
|
1858
|
-
function CardActions({ card, onCopy, onFollowUp, onRetry, onClose, onCancel, onSendToMain }) {
|
|
2198
|
+
/** Card footer: status, copy, follow-up, retry/edit-retry, close. */
|
|
2199
|
+
function CardActions({ card, onCopy, onFollowUp, onRetry, onRetryEdit, onClose, onCancel, onSendToMain }) {
|
|
1859
2200
|
const terminal = card.status === 'done' || card.status === 'error' || card.status === 'stopped'
|
|
1860
2201
|
return h('div', { className: 'dsa-row' },
|
|
1861
2202
|
h('span', { className: 'dsa-muted' },
|
|
@@ -1865,9 +2206,15 @@ window.__ModuleLoader__.load({
|
|
|
1865
2206
|
card.status === 'streaming'
|
|
1866
2207
|
? h('button', { type: 'button', className: 'dsa-btn', onClick: onCancel }, t('cancelAnswer'))
|
|
1867
2208
|
: null,
|
|
1868
|
-
terminal && card.status !== 'done'
|
|
2209
|
+
terminal && card.status !== 'done' && card.carrier === 'side'
|
|
1869
2210
|
? h('button', { type: 'button', className: 'dsa-btn', onClick: onRetry }, t('retry'))
|
|
1870
2211
|
: null,
|
|
2212
|
+
// Edit & retry is available for EVERY terminal side card: a typo in
|
|
2213
|
+
// the question used to invalidate the whole card (plain retry only
|
|
2214
|
+
// resends verbatim), and an error card can be corrected the same way.
|
|
2215
|
+
terminal && card.carrier === 'side'
|
|
2216
|
+
? h('button', { type: 'button', className: 'dsa-btn', onClick: onRetryEdit }, t('retryEdit'))
|
|
2217
|
+
: null,
|
|
1871
2218
|
card.status === 'error' && card.carrier === 'side'
|
|
1872
2219
|
? h('button', { type: 'button', className: 'dsa-btn', onClick: onSendToMain }, t('sendToMain'))
|
|
1873
2220
|
: null,
|
|
@@ -1893,9 +2240,14 @@ window.__ModuleLoader__.load({
|
|
|
1893
2240
|
* (the layer is the only surface that can cover the conversation). A host
|
|
1894
2241
|
* tab that embeds the same card passes no callback and gets no buttons.
|
|
1895
2242
|
*/
|
|
1896
|
-
function AnswerCard({ card, surfaceLabel, onCopy, onFollowUp, onRetry, onClose, onCancel, onSendToMain, onFloatMode }) {
|
|
2243
|
+
function AnswerCard({ card, surfaceLabel, onCopy, onFollowUp, onRetry, onRetryEdit, onClose, onCancel, onSendToMain, onFloatMode }) {
|
|
1897
2244
|
const [followOpen, setFollowOpen] = React.useState(false)
|
|
1898
2245
|
const [draft, setDraft] = React.useState('')
|
|
2246
|
+
// Edit-&-retry editor: prefilled with the card's CURRENT question and
|
|
2247
|
+
// kept separate from the follow-up draft (follow-up appends a turn;
|
|
2248
|
+
// edit-retry regenerates THIS turn, keeping the card's earlier turns).
|
|
2249
|
+
const [editOpen, setEditOpen] = React.useState(false)
|
|
2250
|
+
const [editDraft, setEditDraft] = React.useState('')
|
|
1899
2251
|
const answerRef = React.useRef(null)
|
|
1900
2252
|
const config = store.state.config
|
|
1901
2253
|
// Icon-only, so the header row keeps one line: the full labels
|
|
@@ -1947,12 +2299,60 @@ window.__ModuleLoader__.load({
|
|
|
1947
2299
|
},
|
|
1948
2300
|
}))
|
|
1949
2301
|
: null,
|
|
2302
|
+
editOpen
|
|
2303
|
+
? h('div', { className: 'dsa-field' },
|
|
2304
|
+
h('textarea', {
|
|
2305
|
+
className: 'dsa-textarea',
|
|
2306
|
+
value: editDraft,
|
|
2307
|
+
placeholder: t('editQuestionPlaceholder'),
|
|
2308
|
+
autoFocus: true,
|
|
2309
|
+
onChange: event => setEditDraft(event.target.value),
|
|
2310
|
+
onKeyDown: (event) => {
|
|
2311
|
+
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
|
|
2312
|
+
event.preventDefault()
|
|
2313
|
+
const text = editDraft.trim()
|
|
2314
|
+
if (text === '') return
|
|
2315
|
+
setEditDraft('')
|
|
2316
|
+
setEditOpen(false)
|
|
2317
|
+
onRetryEdit(text)
|
|
2318
|
+
}
|
|
2319
|
+
if (event.key === 'Escape') {
|
|
2320
|
+
event.stopPropagation()
|
|
2321
|
+
setEditOpen(false)
|
|
2322
|
+
}
|
|
2323
|
+
},
|
|
2324
|
+
}),
|
|
2325
|
+
h('div', { className: 'dsa-row', style: { justifyContent: 'flex-end' } },
|
|
2326
|
+
h('button', {
|
|
2327
|
+
type: 'button',
|
|
2328
|
+
className: 'dsa-btn',
|
|
2329
|
+
onClick: () => setEditOpen(false),
|
|
2330
|
+
}, t('askCancel')),
|
|
2331
|
+
h('button', {
|
|
2332
|
+
type: 'button',
|
|
2333
|
+
className: 'dsa-btn dsa-btn-primary',
|
|
2334
|
+
disabled: editDraft.trim() === '',
|
|
2335
|
+
onClick: () => {
|
|
2336
|
+
const text = editDraft.trim()
|
|
2337
|
+
if (text === '') return
|
|
2338
|
+
setEditDraft('')
|
|
2339
|
+
setEditOpen(false)
|
|
2340
|
+
onRetryEdit(text)
|
|
2341
|
+
},
|
|
2342
|
+
}, t('retryAskSubmit'))))
|
|
2343
|
+
: null,
|
|
1950
2344
|
h(CardActions, {
|
|
1951
2345
|
card,
|
|
1952
2346
|
onCopy: () => onCopy(answerRef.current),
|
|
1953
2347
|
onRetry,
|
|
1954
2348
|
onCancel,
|
|
1955
2349
|
onSendToMain,
|
|
2350
|
+
onRetryEdit: () => {
|
|
2351
|
+
// Open with the live question, so reopening after an earlier edit
|
|
2352
|
+
// re-edits the question actually asked, not the card's first one.
|
|
2353
|
+
setEditDraft(card.question)
|
|
2354
|
+
setEditOpen(open => !open)
|
|
2355
|
+
},
|
|
1956
2356
|
onFollowUp: () => setFollowOpen(open => !open),
|
|
1957
2357
|
onClose,
|
|
1958
2358
|
}),
|
|
@@ -2084,6 +2484,7 @@ window.__ModuleLoader__.load({
|
|
|
2084
2484
|
onCopy: element => actions.copy(card, element),
|
|
2085
2485
|
onFollowUp: text => actions.followUp(card, text),
|
|
2086
2486
|
onRetry: () => actions.retry(card),
|
|
2487
|
+
onRetryEdit: text => actions.retryEdit(card, text),
|
|
2087
2488
|
onCancel: () => actions.cancel(card),
|
|
2088
2489
|
onSendToMain: () => actions.sendToMain(card),
|
|
2089
2490
|
onClose: () => actions.close(card),
|
|
@@ -2109,7 +2510,12 @@ window.__ModuleLoader__.load({
|
|
|
2109
2510
|
},
|
|
2110
2511
|
h(Styles, null),
|
|
2111
2512
|
candidate !== null && popover === null && config.trigger !== 'shortcut'
|
|
2112
|
-
? h(
|
|
2513
|
+
? h(TriggerBar, {
|
|
2514
|
+
candidate,
|
|
2515
|
+
config,
|
|
2516
|
+
onOpen: () => actions.openPopover(candidate),
|
|
2517
|
+
onQuick: action => actions.quickAsk(candidate, action),
|
|
2518
|
+
})
|
|
2113
2519
|
: null,
|
|
2114
2520
|
popover !== null
|
|
2115
2521
|
? h(AskPopover, {
|
|
@@ -2117,6 +2523,7 @@ window.__ModuleLoader__.load({
|
|
|
2117
2523
|
config,
|
|
2118
2524
|
onClose: () => actions.closePopover(),
|
|
2119
2525
|
onSubmit: payload => actions.submit(popover.candidate, payload),
|
|
2526
|
+
onQuick: action => actions.quickAsk(popover.candidate, action),
|
|
2120
2527
|
})
|
|
2121
2528
|
: null,
|
|
2122
2529
|
h(FloatingLayer, { cards: state.cards, actions }),
|
|
@@ -2172,11 +2579,95 @@ window.__ModuleLoader__.load({
|
|
|
2172
2579
|
}, options.map(name => h('option', { key: name, value: name }, name)))
|
|
2173
2580
|
}
|
|
2174
2581
|
|
|
2582
|
+
// ── quick-action list editor ───────────────────────────────────────
|
|
2583
|
+
// The draft always carries the FULL list (the user layer replaces
|
|
2584
|
+
// defaults wholesale): built-in rows are editable only in
|
|
2585
|
+
// enabled/shortcut, custom rows also in label/question.
|
|
2586
|
+
const qaList = () => (Array.isArray(draft.quickActions) ? draft.quickActions : [])
|
|
2587
|
+
const patchAction = (index, patch) => {
|
|
2588
|
+
const quickActions = qaList().map((entry, i) => (i === index ? { ...entry, ...patch } : entry))
|
|
2589
|
+
setDraft({ ...draft, quickActions })
|
|
2590
|
+
}
|
|
2591
|
+
const addAction = () => {
|
|
2592
|
+
const list = qaList()
|
|
2593
|
+
// Host cap is enforced on save; do not even append beyond it.
|
|
2594
|
+
if (list.length >= QUICK_ACTION_LIMITS.maxEntries) return
|
|
2595
|
+
const id = `custom:${Date.now().toString(36)}-${list.length + 1}`
|
|
2596
|
+
setDraft({ ...draft, quickActions: [...list, { id, label: '', question: '', enabled: true, shortcut: '' }] })
|
|
2597
|
+
}
|
|
2598
|
+
const removeAction = (index) => {
|
|
2599
|
+
setDraft({ ...draft, quickActions: qaList().filter((_, i) => i !== index) })
|
|
2600
|
+
}
|
|
2601
|
+
const quickActionsEditor = h('div', { className: 'dsa-field' },
|
|
2602
|
+
h('label', null, t('setQuickActions')),
|
|
2603
|
+
h('div', { className: 'dsa-qa' },
|
|
2604
|
+
qaList().map((entry, index) => {
|
|
2605
|
+
const meta = QUICK_ACTION_BUILTINS[String(entry.id)]
|
|
2606
|
+
const isBuiltin = meta !== undefined
|
|
2607
|
+
return h('div', { key: `${entry.id}-${index}`, className: 'dsa-qa-row' },
|
|
2608
|
+
h('label', { className: 'dsa-check', title: t('qaEnabled') },
|
|
2609
|
+
h('input', {
|
|
2610
|
+
type: 'checkbox',
|
|
2611
|
+
checked: entry.enabled !== false,
|
|
2612
|
+
onChange: event => patchAction(index, { enabled: event.target.checked }),
|
|
2613
|
+
}),
|
|
2614
|
+
t('qaEnabled')),
|
|
2615
|
+
isBuiltin ? h('span', { className: 'dsa-badge' }, t('qaBuiltinBadge')) : null,
|
|
2616
|
+
isBuiltin
|
|
2617
|
+
? h('span', { className: 'dsa-title' }, t(meta.labelKey))
|
|
2618
|
+
: h('input', {
|
|
2619
|
+
className: 'dsa-input',
|
|
2620
|
+
value: String(entry.label ?? ''),
|
|
2621
|
+
maxLength: QUICK_ACTION_LIMITS.labelLength,
|
|
2622
|
+
placeholder: t('qaLabelPlaceholder'),
|
|
2623
|
+
onChange: event => patchAction(index, { label: event.target.value }),
|
|
2624
|
+
}),
|
|
2625
|
+
h('input', {
|
|
2626
|
+
className: 'dsa-input',
|
|
2627
|
+
style: { maxWidth: '150px' },
|
|
2628
|
+
value: String(entry.shortcut ?? ''),
|
|
2629
|
+
maxLength: QUICK_ACTION_LIMITS.shortcutLength,
|
|
2630
|
+
placeholder: t('qaShortcutPlaceholder'),
|
|
2631
|
+
onChange: event => patchAction(index, { shortcut: event.target.value }),
|
|
2632
|
+
}),
|
|
2633
|
+
isBuiltin
|
|
2634
|
+
? null
|
|
2635
|
+
: h('button', {
|
|
2636
|
+
type: 'button',
|
|
2637
|
+
className: 'dsa-btn',
|
|
2638
|
+
onClick: () => removeAction(index),
|
|
2639
|
+
}, t('qaDelete')),
|
|
2640
|
+
isBuiltin
|
|
2641
|
+
? h('div', { className: 'dsa-muted dsa-qa-question' }, t(meta.questionKey))
|
|
2642
|
+
: h('input', {
|
|
2643
|
+
className: 'dsa-input dsa-qa-question',
|
|
2644
|
+
value: String(entry.question ?? ''),
|
|
2645
|
+
maxLength: QUICK_ACTION_LIMITS.questionLength,
|
|
2646
|
+
placeholder: t('qaQuestionPlaceholder'),
|
|
2647
|
+
onChange: event => patchAction(index, { question: event.target.value }),
|
|
2648
|
+
}))
|
|
2649
|
+
}),
|
|
2650
|
+
h('div', null, h('button', {
|
|
2651
|
+
type: 'button',
|
|
2652
|
+
className: 'dsa-btn',
|
|
2653
|
+
disabled: qaList().length >= QUICK_ACTION_LIMITS.maxEntries,
|
|
2654
|
+
onClick: addAction,
|
|
2655
|
+
}, t('qaAdd')))),
|
|
2656
|
+
h('span', { className: 'dsa-muted' }, t('setQuickActionsHint')))
|
|
2657
|
+
|
|
2175
2658
|
const save = async () => {
|
|
2176
2659
|
if (parseShortcut(draft.shortcut) === null) {
|
|
2177
2660
|
setStatus(`error:${t('unsupportedTrigger')}`)
|
|
2178
2661
|
return
|
|
2179
2662
|
}
|
|
2663
|
+
// Pre-flight the quick-action list: the Host would all-or-nothing
|
|
2664
|
+
// refuse it anyway, but a local check names the offending row instead
|
|
2665
|
+
// of surfacing a wire error after the save.
|
|
2666
|
+
const qaResult = validateQuickActionsDraft(draft.quickActions, draft.shortcut)
|
|
2667
|
+
if (qaResult.ok === false) {
|
|
2668
|
+
setStatus(`error:${t(qaResult.key, qaResult.vars)}`)
|
|
2669
|
+
return
|
|
2670
|
+
}
|
|
2180
2671
|
setStatus('saving')
|
|
2181
2672
|
try {
|
|
2182
2673
|
await actions.saveConfig(draft)
|
|
@@ -2242,6 +2733,7 @@ window.__ModuleLoader__.load({
|
|
|
2242
2733
|
])),
|
|
2243
2734
|
field(t('setSideTimeout'), number('sideTimeoutMs', 5000, 3600000), t('setSideTimeoutHint')),
|
|
2244
2735
|
field(t('setSideProvider'), providerSelect(), t('setSideProviderHint'))),
|
|
2736
|
+
quickActionsEditor,
|
|
2245
2737
|
h('label', { className: 'dsa-check' },
|
|
2246
2738
|
h('input', {
|
|
2247
2739
|
type: 'checkbox',
|
|
@@ -2582,6 +3074,18 @@ window.__ModuleLoader__.load({
|
|
|
2582
3074
|
}
|
|
2583
3075
|
void runSideCard(card.id)
|
|
2584
3076
|
},
|
|
3077
|
+
/**
|
|
3078
|
+
* One quick-action chip: resolve the template ({selection} is the
|
|
3079
|
+
* only placeholder), then take the SAME path as a popover submit
|
|
3080
|
+
* fixed to the side card — surface picking, render proof and the
|
|
3081
|
+
* stream loop all live in submit(), so this stays a thin adapter.
|
|
3082
|
+
*/
|
|
3083
|
+
quickAsk(candidate, action) {
|
|
3084
|
+
const config = store.state.config
|
|
3085
|
+
const question = renderActionQuestion(action, candidate.text, config.maxChars)
|
|
3086
|
+
if (question.trim() === '') return
|
|
3087
|
+
void actions.submit(candidate, { question, carrier: 'side' })
|
|
3088
|
+
},
|
|
2585
3089
|
copy(card, element) {
|
|
2586
3090
|
void copyText(card.text, element).then((result) => {
|
|
2587
3091
|
toast(result === 'copied' ? t('copied') : result === 'selected' ? t('copySelectedHint') : t('copyFailed'))
|
|
@@ -2622,6 +3126,25 @@ window.__ModuleLoader__.load({
|
|
|
2622
3126
|
patchCard(card.id, { status: 'pending', text: '', reasoning: '', error: null, streaming: null })
|
|
2623
3127
|
if (card.carrier === 'side') void runSideCard(card.id)
|
|
2624
3128
|
},
|
|
3129
|
+
/**
|
|
3130
|
+
* Regenerate the CURRENT turn with an edited question. Unlike
|
|
3131
|
+
* followUp, this does NOT push the old Q/A into `history`: the
|
|
3132
|
+
* earlier turns on the card stay, the question text is replaced,
|
|
3133
|
+
* and the answer streams again from scratch.
|
|
3134
|
+
*/
|
|
3135
|
+
retryEdit(card, question) {
|
|
3136
|
+
const trimmed = String(question ?? '').trim()
|
|
3137
|
+
if (trimmed === '' || card.carrier !== 'side') return
|
|
3138
|
+
patchCard(card.id, {
|
|
3139
|
+
question: trimmed,
|
|
3140
|
+
status: 'pending',
|
|
3141
|
+
text: '',
|
|
3142
|
+
reasoning: '',
|
|
3143
|
+
error: null,
|
|
3144
|
+
streaming: null,
|
|
3145
|
+
})
|
|
3146
|
+
void runSideCard(card.id)
|
|
3147
|
+
},
|
|
2625
3148
|
cancel(card) {
|
|
2626
3149
|
const controller = controllers.get(card.id)
|
|
2627
3150
|
if (controller !== undefined) {
|
|
@@ -2863,6 +3386,14 @@ window.__ModuleLoader__.load({
|
|
|
2863
3386
|
// §4.5 only the leader answers the shortcut; otherwise two copies
|
|
2864
3387
|
// would each preventDefault and each open their own popover.
|
|
2865
3388
|
if (store.state.familyLeader === false) return
|
|
3389
|
+
// Never steal keys while the user is typing in our own popover or
|
|
3390
|
+
// card editors (Enter/Escape there are handled by the components).
|
|
3391
|
+
const target = elementOf(event.target)
|
|
3392
|
+
if (target !== null && target.closest('[data-dsa-root]') !== null
|
|
3393
|
+
&& (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA'
|
|
3394
|
+
|| target.closest('[contenteditable=""],[contenteditable="true"]') !== null)) {
|
|
3395
|
+
return
|
|
3396
|
+
}
|
|
2866
3397
|
const config = store.state.config
|
|
2867
3398
|
const facts = parseShortcut(config.shortcut)
|
|
2868
3399
|
if (matchesShortcut(event, facts)) {
|
|
@@ -2873,6 +3404,18 @@ window.__ModuleLoader__.load({
|
|
|
2873
3404
|
return
|
|
2874
3405
|
}
|
|
2875
3406
|
}
|
|
3407
|
+
// Per-action shortcuts: the main shortcut wins on a collision, and
|
|
3408
|
+
// the candidate falls back to the last raised trigger, exactly like
|
|
3409
|
+
// the main shortcut. A chip then submits straight to a side card.
|
|
3410
|
+
for (const action of quickActionsOf(config)) {
|
|
3411
|
+
if (action.shortcut === '' || !matchesShortcut(event, parseShortcut(action.shortcut))) continue
|
|
3412
|
+
const candidate = readSelection() ?? store.state.trigger
|
|
3413
|
+
if (candidate !== null) {
|
|
3414
|
+
event.preventDefault()
|
|
3415
|
+
actions.quickAsk(candidate, action)
|
|
3416
|
+
return
|
|
3417
|
+
}
|
|
3418
|
+
}
|
|
2876
3419
|
if (event.key === 'Escape') {
|
|
2877
3420
|
if (store.state.popover !== null) store.set({ popover: null })
|
|
2878
3421
|
}
|
|
@@ -3288,6 +3831,7 @@ window.__ModuleLoader__.load({
|
|
|
3288
3831
|
onCopy: element => actions.copy(card, element),
|
|
3289
3832
|
onFollowUp: text => actions.followUp(card, text),
|
|
3290
3833
|
onRetry: () => actions.retry(card),
|
|
3834
|
+
onRetryEdit: text => actions.retryEdit(card, text),
|
|
3291
3835
|
onCancel: () => actions.cancel(card),
|
|
3292
3836
|
onSendToMain: () => { void actions.sendToMain(card) },
|
|
3293
3837
|
onClose: () => actions.close(card),
|
|
@@ -3423,6 +3967,12 @@ window.__ModuleLoader__.load({
|
|
|
3423
3967
|
truncateSelection,
|
|
3424
3968
|
parseShortcut,
|
|
3425
3969
|
matchesShortcut,
|
|
3970
|
+
quickActionsOf,
|
|
3971
|
+
renderActionQuestion,
|
|
3972
|
+
validateQuickActionsDraft,
|
|
3973
|
+
shortcutFingerprint,
|
|
3974
|
+
QUICK_ACTION_BUILTINS,
|
|
3975
|
+
QUICK_ACTION_LIMITS,
|
|
3426
3976
|
classifyZone,
|
|
3427
3977
|
slotPathOf,
|
|
3428
3978
|
shouldOffer,
|
package/index.js
CHANGED
|
@@ -42,7 +42,7 @@ export const name = 'dsh-sidecard-ask'
|
|
|
42
42
|
export const inject = ['webServer']
|
|
43
43
|
|
|
44
44
|
/** Version of this plugin (kept in step with package.json by test/verify.mjs). */
|
|
45
|
-
export const PLUGIN_VERSION = '1.
|
|
45
|
+
export const PLUGIN_VERSION = '1.7.0'
|
|
46
46
|
|
|
47
47
|
/** Route prefix of the plugin's own API. */
|
|
48
48
|
export const ROUTE_PREFIX = '/sidecard-ask/api'
|
|
@@ -83,6 +83,32 @@ export const DEFAULT_CONFIG = {
|
|
|
83
83
|
floatMode: 'capsule',
|
|
84
84
|
/** Hard cap, in CSS px, for the floating layer's width. */
|
|
85
85
|
floatMaxWidth: 340,
|
|
86
|
+
/**
|
|
87
|
+
* One-click quick actions rendered beside the selection trigger.
|
|
88
|
+
*
|
|
89
|
+
* Each entry is `{id, enabled, shortcut}`: the five `builtin:*` ids resolve
|
|
90
|
+
* their localized label/question on the Client (the Host stays language
|
|
91
|
+
* neutral); custom entries additionally carry `label` and `question`, and
|
|
92
|
+
* the question may embed a `{selection}` placeholder. The user layer
|
|
93
|
+
* REPLACES this list wholesale (the settings page edits the full list),
|
|
94
|
+
* which is why it can stay a plain JSON array with no merge semantics.
|
|
95
|
+
*/
|
|
96
|
+
quickActions: [
|
|
97
|
+
{ id: 'builtin:explain', enabled: true, shortcut: '' },
|
|
98
|
+
{ id: 'builtin:translate', enabled: true, shortcut: '' },
|
|
99
|
+
{ id: 'builtin:summarize', enabled: true, shortcut: '' },
|
|
100
|
+
{ id: 'builtin:polish', enabled: true, shortcut: '' },
|
|
101
|
+
{ id: 'builtin:fix', enabled: true, shortcut: '' },
|
|
102
|
+
],
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Bounds for the `quickActions` user layer (defense in depth). */
|
|
106
|
+
const QUICK_ACTION_LIMITS = {
|
|
107
|
+
maxEntries: 12,
|
|
108
|
+
idLength: 40,
|
|
109
|
+
labelLength: 20,
|
|
110
|
+
questionLength: 500,
|
|
111
|
+
shortcutLength: 40,
|
|
86
112
|
}
|
|
87
113
|
|
|
88
114
|
/**
|
|
@@ -164,6 +190,85 @@ function clampNumber(value, min, max) {
|
|
|
164
190
|
return Math.min(max, Math.max(min, Math.round(value)))
|
|
165
191
|
}
|
|
166
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Coerce one raw `quickActions` layer into a known-good list.
|
|
195
|
+
*
|
|
196
|
+
* The list is validated ALL-OR-NOTHING: a single malformed entry rejects the
|
|
197
|
+
* whole key (and `/config` answers 400), so the settings page can never
|
|
198
|
+
* silently save a half-edited list. Bounds mirror the Client's own
|
|
199
|
+
* `QUICK_ACTION_LIMITS`; structural validity only — the Host neither knows
|
|
200
|
+
* the builtin catalog nor interprets `question` text.
|
|
201
|
+
* @param {unknown} raw - the candidate array.
|
|
202
|
+
* @param {string[]} problems - collector for human-readable notes.
|
|
203
|
+
* @returns {Array<object>|undefined} the sanitized list, or undefined when
|
|
204
|
+
* the key is absent/unusable (undefined = "do not override", a present but
|
|
205
|
+
* bad array is reported and dropped).
|
|
206
|
+
*/
|
|
207
|
+
export function sanitizeQuickActions(raw, problems = []) {
|
|
208
|
+
if (raw === undefined) return undefined
|
|
209
|
+
if (!Array.isArray(raw)) {
|
|
210
|
+
problems.push('quickActions: 必须是数组,已忽略')
|
|
211
|
+
return undefined
|
|
212
|
+
}
|
|
213
|
+
if (raw.length > QUICK_ACTION_LIMITS.maxEntries) {
|
|
214
|
+
problems.push(`quickActions: 最多 ${QUICK_ACTION_LIMITS.maxEntries} 条,已忽略`)
|
|
215
|
+
return undefined
|
|
216
|
+
}
|
|
217
|
+
const ids = new Set()
|
|
218
|
+
const list = []
|
|
219
|
+
for (const [index, entry] of raw.entries()) {
|
|
220
|
+
const where = `quickActions[${index + 1}]`
|
|
221
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
222
|
+
problems.push(`${where}: 必须是对象,已忽略整份快捷动作配置`)
|
|
223
|
+
return undefined
|
|
224
|
+
}
|
|
225
|
+
const id = entry.id
|
|
226
|
+
if (typeof id !== 'string' || id.trim() === '' || id.length > QUICK_ACTION_LIMITS.idLength) {
|
|
227
|
+
problems.push(`${where}: id 缺失或超长(≤${QUICK_ACTION_LIMITS.idLength} 字符),已忽略整份快捷动作配置`)
|
|
228
|
+
return undefined
|
|
229
|
+
}
|
|
230
|
+
if (ids.has(id)) {
|
|
231
|
+
problems.push(`${where}: id "${id}" 重复,已忽略整份快捷动作配置`)
|
|
232
|
+
return undefined
|
|
233
|
+
}
|
|
234
|
+
ids.add(id)
|
|
235
|
+
const shortcut = entry.shortcut
|
|
236
|
+
if (shortcut !== undefined && (typeof shortcut !== 'string' || shortcut.length > QUICK_ACTION_LIMITS.shortcutLength)) {
|
|
237
|
+
problems.push(`${where}: shortcut 必须是 ≤${QUICK_ACTION_LIMITS.shortcutLength} 字符的字符串,已忽略整份快捷动作配置`)
|
|
238
|
+
return undefined
|
|
239
|
+
}
|
|
240
|
+
const safe = {
|
|
241
|
+
id,
|
|
242
|
+
enabled: entry.enabled === undefined ? true : Boolean(entry.enabled),
|
|
243
|
+
shortcut: typeof shortcut === 'string' ? shortcut : '',
|
|
244
|
+
}
|
|
245
|
+
// `builtin:*` ids resolve on the Client; anything else is a custom action
|
|
246
|
+
// and MUST carry its own label/question. The prefix is reserved so a user
|
|
247
|
+
// layer can never shadow a builtin with a malicious look-alike entry.
|
|
248
|
+
const isBuiltin = id.startsWith('builtin:')
|
|
249
|
+
if (isBuiltin && id === 'builtin:') {
|
|
250
|
+
problems.push(`${where}: 内置动作 id 不能为空,已忽略整份快捷动作配置`)
|
|
251
|
+
return undefined
|
|
252
|
+
}
|
|
253
|
+
if (!isBuiltin) {
|
|
254
|
+
const label = entry.label
|
|
255
|
+
const question = entry.question
|
|
256
|
+
if (typeof label !== 'string' || label.trim() === '' || label.length > QUICK_ACTION_LIMITS.labelLength) {
|
|
257
|
+
problems.push(`${where}: 自定义动作 label 缺失或超长(1–${QUICK_ACTION_LIMITS.labelLength} 字符),已忽略整份快捷动作配置`)
|
|
258
|
+
return undefined
|
|
259
|
+
}
|
|
260
|
+
if (typeof question !== 'string' || question.trim() === '' || question.length > QUICK_ACTION_LIMITS.questionLength) {
|
|
261
|
+
problems.push(`${where}: 自定义动作 question 缺失或超长(1–${QUICK_ACTION_LIMITS.questionLength} 字符),已忽略整份快捷动作配置`)
|
|
262
|
+
return undefined
|
|
263
|
+
}
|
|
264
|
+
safe.label = label
|
|
265
|
+
safe.question = question
|
|
266
|
+
}
|
|
267
|
+
list.push(safe)
|
|
268
|
+
}
|
|
269
|
+
return list
|
|
270
|
+
}
|
|
271
|
+
|
|
167
272
|
/**
|
|
168
273
|
* Coerce one raw config layer into a known-good shape.
|
|
169
274
|
* Unknown keys are dropped; out-of-range values fall back, and every
|
|
@@ -200,6 +305,10 @@ export function sanitizeLayer(raw, problems = []) {
|
|
|
200
305
|
if (typeof raw.showInUnclassified === 'boolean') out.showInUnclassified = raw.showInUnclassified
|
|
201
306
|
else problems.push('showInUnclassified: 必须是布尔值,已忽略')
|
|
202
307
|
}
|
|
308
|
+
if (raw.quickActions !== undefined) {
|
|
309
|
+
const list = sanitizeQuickActions(raw.quickActions, problems)
|
|
310
|
+
if (list !== undefined) out.quickActions = list
|
|
311
|
+
}
|
|
203
312
|
return out
|
|
204
313
|
}
|
|
205
314
|
|