@followbin/dsh-selection 0.0.0-stage → 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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -2
  3. package/README.zh-CN.md +141 -0
  4. package/THIRD_PARTY_NOTICES.md +22 -0
  5. package/client.js +1055 -0
  6. package/cordis.patch.yml +4 -0
  7. package/icon.svg +1 -0
  8. package/index.js +41 -0
  9. package/locale/en.json +6 -0
  10. package/locale/zh.json +6 -0
  11. package/package.json +90 -4
  12. package/src/host/files.mjs +29 -0
  13. package/src/host/http.mjs +153 -0
  14. package/src/host/llm.mjs +194 -0
  15. package/src/host/log.mjs +40 -0
  16. package/src/host/service.mjs +324 -0
  17. package/src/host/settings.mjs +279 -0
  18. package/src/host/skills.mjs +88 -0
  19. package/src/host/supervisor.mjs +126 -0
  20. package/src/host/usage.mjs +128 -0
  21. package/src/shared/icons.mjs +43 -0
  22. package/src/shared/open-with-system.mjs +15 -0
  23. package/src/shared/paths.mjs +7 -0
  24. package/src/shared/protocol.mjs +4 -0
  25. package/src/worker/controller.mjs +261 -0
  26. package/src/worker/main.mjs +147 -0
  27. package/src/worker/native-window.mjs +319 -0
  28. package/src/worker/open-url.mjs +20 -0
  29. package/src/worker/overlay.mjs +263 -0
  30. package/src/worker/page.mjs +14 -0
  31. package/src/worker/placement.mjs +58 -0
  32. package/src/worker/speech.mjs +136 -0
  33. package/ui/markdown.js +122 -0
  34. package/ui/toolbar.css +152 -0
  35. package/ui/toolbar.html +52 -0
  36. package/ui/toolbar.js +492 -0
  37. package/vendor/selection-hook/LICENSE +21 -0
  38. package/vendor/selection-hook/index.d.ts +517 -0
  39. package/vendor/selection-hook/index.js +662 -0
  40. package/vendor/selection-hook/package.json +10 -0
  41. package/vendor/selection-hook/prebuilds/darwin-arm64/selection-hook.node +0 -0
  42. package/vendor/selection-hook/prebuilds/darwin-x64/selection-hook.node +0 -0
  43. package/vendor/selection-hook/prebuilds/win32-arm64/selection-hook.node +0 -0
  44. package/vendor/selection-hook/prebuilds/win32-x64/selection-hook.node +0 -0
package/client.js ADDED
@@ -0,0 +1,1055 @@
1
+ // Client half: the settings page inside DSH, plus the sidebar entry that opens it.
2
+ // A plain module for DSH's module loader (no build step); talks to the Host over /dsh-selection/api.
3
+ // DSH finds this module by the npm package name, so `id` must be that name.
4
+ window.__ModuleLoader__.load({
5
+ id: '@followbin/dsh-selection',
6
+ factory(require) {
7
+ const React = require('react')
8
+ const h = React.createElement
9
+ const { useCallback, useEffect, useMemo, useRef, useState } = React
10
+
11
+ const NS = 'dshSelection'
12
+ const PANEL_ID = 'dsh-selection'
13
+ const API = '/dsh-selection/api'
14
+ const SAVE_DELAY_MS = 350
15
+ // How long "Saved" replaces the worker status in the header.
16
+ const SAVED_NOTICE_MS = 2000
17
+
18
+ const zh = {
19
+ 'title': '划词助手',
20
+ 'subtitle': '在任意应用里选中文字,就能用 DSH 翻译、总结、解释、追问或朗读。',
21
+ 'status.running': '运行中',
22
+ 'status.starting': '启动中',
23
+ 'status.restarting': '正在重启',
24
+ 'status.stopped': '未运行',
25
+ 'status.failed': '启动失败,可点「重启划词进程」再试',
26
+ 'status.unsupported': '仅支持 DSH 桌面版(macOS、Windows)',
27
+ 'status.saving': '保存中…',
28
+ 'status.saved': '已保存',
29
+ 'status.saveFailed': '保存失败:',
30
+ 'status.actionFailed': '操作失败:',
31
+ 'status.offline': '连不上 DSH Host:',
32
+ 'permission.title': '需要辅助功能权限',
33
+ 'permission.body': '在「系统设置 → 隐私与安全性 → 辅助功能」里打开 DeepSeek Harness,划词助手才能读到选中的文字。授权后会自动生效。',
34
+ 'permission.open': '打开系统设置',
35
+ 'enabled.label': '选中文字时显示工具栏',
36
+ 'enabled.hint': '关掉后划词助手不会再弹出,设置保留。',
37
+ 'skills.title': '技能',
38
+ 'skills.hint': '前 {count} 个启用的技能显示在工具栏上,其余收进 ⋯ 菜单。拖动或用方向键调整顺序。',
39
+ 'skills.overflow': '以下收进 ⋯ 菜单',
40
+ 'skills.custom': '自定义',
41
+ 'skills.add': '新建技能',
42
+ 'skills.edit': '编辑',
43
+ 'skills.delete': '删除',
44
+ 'skills.deleteConfirm': '删除技能「{name}」?',
45
+ 'skills.move': '拖动或按 ↑ ↓ 调整顺序',
46
+ 'editor.titleNew': '新建技能',
47
+ 'editor.titleEdit': '编辑技能',
48
+ 'editor.name': '名称',
49
+ 'editor.namePlaceholder': '例如:整理成表格',
50
+ 'editor.icon': '图标',
51
+ 'editor.prompt': '指令',
52
+ 'editor.promptPlaceholder': '例如:把这段内容整理成 Markdown 表格,列出要点和对应说明。',
53
+ 'editor.promptHint': '选中的文字会单独发给模型,不需要写进指令里;需要指定位置时可以写 {{text}}。',
54
+ 'editor.try': '试一试',
55
+ 'editor.sample': '示例文字',
56
+ 'editor.samplePlaceholder': '粘贴一段文字,看看这个技能的效果',
57
+ 'editor.run': '运行',
58
+ 'editor.running': '运行中…',
59
+ 'editor.save': '保存',
60
+ 'editor.cancel': '取消',
61
+ 'toolbar.title': '工具栏',
62
+ 'toolbar.trigger': '弹出方式',
63
+ 'toolbar.trigger.auto': '选中文字后自动弹出',
64
+ 'toolbar.trigger.modifier': '按住 ⌥ / Alt 选中时才弹出',
65
+ 'toolbar.size': '工具栏上的技能数',
66
+ 'language.title': '语言',
67
+ 'language.native': '我的母语',
68
+ 'language.translate': '翻译成',
69
+ 'language.translate.auto': '自动(母语和英文互译)',
70
+ 'language.answer': '总结、解释和回答使用',
71
+ 'language.answer.native': '我的母语',
72
+ 'language.answer.source': '和原文相同的语言',
73
+ 'model.title': '模型',
74
+ 'model.route': '使用的模型',
75
+ 'model.auto': '自动(优先 DeepSeek Flash)',
76
+ 'model.current': '当前使用:{route}',
77
+ 'model.none': 'DSH 里还没有可用的模型,请先在 DSH 设置中登录或填写 API Key。',
78
+ 'model.test': '测试',
79
+ 'model.testing': '测试中…',
80
+ 'model.testOk': '{route} 可用,用时 {ms} 毫秒:{output}',
81
+ 'model.hint': '模型请求走 DSH 自己的账号或 API Key,关闭思考以加快速度;追问会带上同一个面板里之前的问答。',
82
+ 'model.error': '读不到模型列表:{error}',
83
+ 'capture.title': '取词',
84
+ 'capture.clipboard': '读不到选区时用复制兜底',
85
+ 'capture.clipboardHint': '有些应用不提供选中文字,这时会模拟一次复制,之后恢复你原来的剪贴板。',
86
+ 'capture.max': '最多发送的字数',
87
+ 'search.title': '搜索',
88
+ 'search.engine': '搜索引擎',
89
+ 'search.custom': '自定义',
90
+ 'search.customUrl': '搜索地址',
91
+ 'search.customHint': '用 %s 表示要搜索的文字,例如 https://example.com/search?q=%s',
92
+ 'search.invalid': '地址要以 http:// 或 https:// 开头,并包含 %s。',
93
+ 'apps.title': '不在这些应用里弹出',
94
+ 'apps.empty': '还没有禁用的应用。也可以在工具栏的 ⋯ 菜单里选「在此应用中禁用」。',
95
+ 'apps.add': '添加',
96
+ 'apps.placeholder': '应用名称,和工具栏显示的「来自 …」一致',
97
+ 'apps.recent': '最近划词的应用:',
98
+ 'apps.remove': '移除',
99
+ 'footer.reset': '恢复默认设置',
100
+ 'footer.resetConfirm': '恢复全部默认设置?自定义技能会被删除。',
101
+ 'footer.restart': '重启划词进程',
102
+ 'footer.data': '设置保存在 DSH 数据目录下的 selection/settings.json',
103
+ 'tab.general': '常规',
104
+ 'tab.skills': '技能',
105
+ 'tab.model': '模型',
106
+ 'tab.capture': '取词与搜索',
107
+ 'tab.apps': '应用',
108
+ 'tab.usage': '用量',
109
+ 'failure.NO_LLM': 'DSH 的模型服务还没就绪',
110
+ 'failure.NO_MODEL': '还没有可用的模型',
111
+ 'failure.MODEL_FAILED': '模型调用失败',
112
+ 'failure.EMPTY': '模型没有返回内容',
113
+ 'failure.TIMEOUT': '模型太久没有回应',
114
+ 'failure.detail': '{message}:{detail}',
115
+ 'usage.loading': '正在读取用量…',
116
+ 'usage.period': '统计范围',
117
+ 'usage.period.today': '今天',
118
+ 'usage.period.week': '近 7 天',
119
+ 'usage.period.month': '近 30 天',
120
+ 'usage.period.all': '全部',
121
+ 'usage.requests': '请求',
122
+ 'usage.input': '输入 tokens',
123
+ 'usage.output': '输出 tokens',
124
+ 'usage.cacheRead': '缓存命中 tokens',
125
+ 'usage.reasoning': '推理 tokens',
126
+ 'usage.byModel': '按模型',
127
+ 'usage.bySkill': '按技能',
128
+ 'usage.model': '模型',
129
+ 'usage.skill': '技能',
130
+ 'usage.none': '这段时间还没有用量',
131
+ 'usage.modelTest': '模型测试',
132
+ 'usage.skillTest': '技能试运行',
133
+ 'usage.removedSkill': '已删除的技能',
134
+ 'usage.since': '从 {day} 开始统计,保留最近 90 天;只记数字,不记文字。',
135
+ 'usage.privacy': '用量只记数字,不记文字,保留最近 90 天。',
136
+ 'usage.reset': '清空用量',
137
+ 'usage.resetConfirm': '清空所有用量记录?',
138
+ 'error.render': '设置页渲染失败:',
139
+ }
140
+ const en = {
141
+ 'title': 'Selection assistant',
142
+ 'subtitle': 'Select text in any app to translate, summarize, explain, ask about or read it with DSH.',
143
+ 'status.running': 'Running',
144
+ 'status.starting': 'Starting',
145
+ 'status.restarting': 'Restarting',
146
+ 'status.stopped': 'Not running',
147
+ 'status.failed': 'Failed to start; use Restart the assistant to try again',
148
+ 'status.unsupported': 'Only available in DSH Desktop (macOS, Windows)',
149
+ 'status.saving': 'Saving…',
150
+ 'status.saved': 'Saved',
151
+ 'status.saveFailed': 'Save failed: ',
152
+ 'status.actionFailed': 'Action failed: ',
153
+ 'status.offline': 'Cannot reach the DSH Host: ',
154
+ 'permission.title': 'Accessibility permission needed',
155
+ 'permission.body': 'Turn on DeepSeek Harness in System Settings → Privacy & Security → Accessibility so the assistant can read selected text. It starts working as soon as you allow it.',
156
+ 'permission.open': 'Open System Settings',
157
+ 'enabled.label': 'Show the toolbar when text is selected',
158
+ 'enabled.hint': 'Turning this off hides the assistant and keeps your settings.',
159
+ 'skills.title': 'Skills',
160
+ 'skills.hint': 'The first {count} enabled skills sit on the toolbar; the rest go into the ⋯ menu. Drag or use the arrow keys to reorder.',
161
+ 'skills.overflow': 'In the ⋯ menu',
162
+ 'skills.custom': 'Custom',
163
+ 'skills.add': 'New skill',
164
+ 'skills.edit': 'Edit',
165
+ 'skills.delete': 'Delete',
166
+ 'skills.deleteConfirm': 'Delete the skill “{name}”?',
167
+ 'skills.move': 'Drag, or press ↑ ↓ to reorder',
168
+ 'editor.titleNew': 'New skill',
169
+ 'editor.titleEdit': 'Edit skill',
170
+ 'editor.name': 'Name',
171
+ 'editor.namePlaceholder': 'e.g. Make a table',
172
+ 'editor.icon': 'Icon',
173
+ 'editor.prompt': 'Instructions',
174
+ 'editor.promptPlaceholder': 'e.g. Turn this into a Markdown table of key points and explanations.',
175
+ 'editor.promptHint': 'The selected text is sent to the model separately, so you do not need to include it. Write {{text}} to place it yourself.',
176
+ 'editor.try': 'Try it',
177
+ 'editor.sample': 'Sample text',
178
+ 'editor.samplePlaceholder': 'Paste some text to see what the skill does',
179
+ 'editor.run': 'Run',
180
+ 'editor.running': 'Running…',
181
+ 'editor.save': 'Save',
182
+ 'editor.cancel': 'Cancel',
183
+ 'toolbar.title': 'Toolbar',
184
+ 'toolbar.trigger': 'Show the toolbar',
185
+ 'toolbar.trigger.auto': 'Automatically after selecting text',
186
+ 'toolbar.trigger.modifier': 'Only while holding ⌥ / Alt during selection',
187
+ 'toolbar.size': 'Skills on the toolbar',
188
+ 'language.title': 'Language',
189
+ 'language.native': 'My language',
190
+ 'language.translate': 'Translate into',
191
+ 'language.translate.auto': 'Auto (between my language and English)',
192
+ 'language.answer': 'Summaries, explanations and answers in',
193
+ 'language.answer.native': 'My language',
194
+ 'language.answer.source': 'The language of the selected text',
195
+ 'model.title': 'Model',
196
+ 'model.route': 'Model',
197
+ 'model.auto': 'Auto (prefers DeepSeek Flash)',
198
+ 'model.current': 'Currently using {route}',
199
+ 'model.none': 'No model is set up in DSH yet. Sign in or add an API key in DSH settings.',
200
+ 'model.test': 'Test',
201
+ 'model.testing': 'Testing…',
202
+ 'model.testOk': '{route} works, {ms} ms: {output}',
203
+ 'model.hint': 'Requests use DSH’s own account or API key, with thinking off for speed. Follow-up questions carry the earlier turns of the same panel.',
204
+ 'model.error': 'Could not list the models: {error}',
205
+ 'capture.title': 'Capture',
206
+ 'capture.clipboard': 'Fall back to copying when the selection cannot be read',
207
+ 'capture.clipboardHint': 'Some apps do not expose selected text; the assistant then simulates a copy and restores your clipboard afterwards.',
208
+ 'capture.max': 'Maximum characters sent',
209
+ 'search.title': 'Search',
210
+ 'search.engine': 'Search engine',
211
+ 'search.custom': 'Custom',
212
+ 'search.customUrl': 'Search URL',
213
+ 'search.customHint': 'Use %s for the search text, e.g. https://example.com/search?q=%s',
214
+ 'search.invalid': 'Use an http:// or https:// address that contains %s.',
215
+ 'apps.title': 'Never show in these apps',
216
+ 'apps.empty': 'No apps disabled. You can also choose “Disable in this app” from the toolbar’s ⋯ menu.',
217
+ 'apps.add': 'Add',
218
+ 'apps.placeholder': 'App name, as shown after “from” on the toolbar',
219
+ 'apps.recent': 'Recently used:',
220
+ 'apps.remove': 'Remove',
221
+ 'footer.reset': 'Restore defaults',
222
+ 'footer.resetConfirm': 'Restore all default settings? Custom skills will be deleted.',
223
+ 'footer.restart': 'Restart the assistant',
224
+ 'footer.data': 'Settings are stored in selection/settings.json under the DSH data directory',
225
+ 'tab.general': 'General',
226
+ 'tab.skills': 'Skills',
227
+ 'tab.model': 'Model',
228
+ 'tab.capture': 'Capture & search',
229
+ 'tab.apps': 'Apps',
230
+ 'tab.usage': 'Usage',
231
+ 'failure.NO_LLM': 'DSH’s model service is not ready yet',
232
+ 'failure.NO_MODEL': 'No model is available',
233
+ 'failure.MODEL_FAILED': 'The model call failed',
234
+ 'failure.EMPTY': 'The model returned nothing',
235
+ 'failure.TIMEOUT': 'The model took too long to answer',
236
+ 'failure.detail': '{message}: {detail}',
237
+ 'usage.loading': 'Reading usage…',
238
+ 'usage.period': 'Period',
239
+ 'usage.period.today': 'Today',
240
+ 'usage.period.week': 'Last 7 days',
241
+ 'usage.period.month': 'Last 30 days',
242
+ 'usage.period.all': 'All',
243
+ 'usage.requests': 'Requests',
244
+ 'usage.input': 'Input tokens',
245
+ 'usage.output': 'Output tokens',
246
+ 'usage.cacheRead': 'Cached input tokens',
247
+ 'usage.reasoning': 'Reasoning tokens',
248
+ 'usage.byModel': 'By model',
249
+ 'usage.bySkill': 'By skill',
250
+ 'usage.model': 'Model',
251
+ 'usage.skill': 'Skill',
252
+ 'usage.none': 'No usage in this period yet',
253
+ 'usage.modelTest': 'Model test',
254
+ 'usage.skillTest': 'Skill trial run',
255
+ 'usage.removedSkill': 'Deleted skill',
256
+ 'usage.since': 'Counted since {day}, last 90 days kept; numbers only, never text.',
257
+ 'usage.privacy': 'Usage keeps numbers only, never text, for the last 90 days.',
258
+ 'usage.reset': 'Clear usage',
259
+ 'usage.resetConfirm': 'Clear all usage records?',
260
+ 'error.render': 'The settings page failed to render: ',
261
+ }
262
+ const fill = (text, values) => text.replace(/\{(\w+)\}/g, (match, key) => (key in values ? String(values[key]) : match))
263
+
264
+ const css = `
265
+ .dss-root { flex: 1; min-height: 0; height: 100%; overflow-y: auto; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-base); }
266
+ .dss-page { max-width: 760px; margin: 0 auto; padding: calc(24px + var(--dsh-frame-top-clearance, 0px)) 24px 48px; }
267
+ .dss-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
268
+ .dss-title { font-size: 20px; font-weight: 500; line-height: 28px; }
269
+ .dss-sub { margin-top: 2px; font-size: 13px; line-height: 20px; color: var(--dsw-alias-label-secondary); }
270
+ .dss-state { display: inline-flex; align-items: center; gap: 6px; flex: none; margin-top: 4px; font-size: 12px; color: var(--dsw-alias-label-secondary); white-space: nowrap; }
271
+ .dss-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--dsw-alias-state-idle-primary, #999); }
272
+ .dss-dot[data-state="running"] { background: var(--dsw-alias-state-success-primary, #1f9d55); }
273
+ .dss-dot[data-state="error"] { background: var(--dsw-alias-state-error-primary, #d4302b); }
274
+ .dss-section { margin-top: 28px; }
275
+ .dss-section-title { font-size: 15px; font-weight: 600; line-height: 22px; }
276
+ .dss-section-hint { margin-top: 2px; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); }
277
+ .dss-card { margin-top: 10px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 12px; background: var(--dsw-alias-bg-layer-1); }
278
+ .dss-row { display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 10px 16px; }
279
+ .dss-row + .dss-row { border-top: 1px solid var(--dsw-alias-border-l1); }
280
+ .dss-row-main { flex: 1; min-width: 0; }
281
+ .dss-label { font-size: 13.5px; line-height: 20px; }
282
+ .dss-hint { margin-top: 2px; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); }
283
+ .dss-banner { display: flex; gap: 12px; align-items: flex-start; margin-top: 16px; padding: 12px 16px; border-radius: 12px; border: 1px solid var(--dsw-alias-state-warn-primary, #d98e04); background: var(--dsw-alias-bg-layer-1); }
284
+ .dss-banner strong { display: block; font-size: 13.5px; font-weight: 600; }
285
+ .dss-banner p { margin: 2px 0 0; font-size: 12.5px; line-height: 19px; color: var(--dsw-alias-label-secondary); }
286
+ .dss-error { margin-top: 8px; font-size: 12px; color: var(--dsw-alias-state-error-primary, #d4302b); }
287
+ .dss-invalid { color: var(--dsw-alias-state-error-primary, #d4302b); }
288
+ .dss-tabs { position: sticky; top: var(--dsh-frame-top-clearance, 0px); z-index: 1; display: flex; gap: 4px; margin: 20px 0 0; padding: 8px 0; overflow-x: auto; background: var(--dsw-alias-bg-base); border-bottom: 1px solid var(--dsw-alias-border-l1); }
289
+ .dss-tab { flex: none; height: 32px; padding: 0 14px; border: 0; border-radius: 8px; background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; font-size: 13px; cursor: pointer; }
290
+ .dss-tab:hover { background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); }
291
+ .dss-tab[aria-selected="true"] { background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); font-weight: 600; }
292
+ .dss-tab:focus-visible, .dss-segmented button:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; }
293
+ .dss-tabpanel[hidden] { display: none; }
294
+ .dss-segmented { display: inline-flex; gap: 2px; margin-top: 20px; padding: 2px; border-radius: 8px; background: var(--dsw-alias-bg-layer-2); }
295
+ .dss-segmented button { height: 28px; padding: 0 12px; border: 0; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; font-size: 12.5px; cursor: pointer; }
296
+ .dss-segmented button[aria-pressed="true"] { background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); }
297
+ .dss-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; margin-top: 12px; }
298
+ .dss-tile { padding: 12px 14px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 12px; background: var(--dsw-alias-bg-layer-1); }
299
+ .dss-tile-value { font-size: 20px; font-weight: 600; font-variant-numeric: tabular-nums; }
300
+ .dss-tile-label { margin-top: 2px; font-size: 12px; color: var(--dsw-alias-label-secondary); }
301
+ .dss-table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 13px; }
302
+ .dss-table th:first-child { width: 46%; }
303
+ .dss-table th, .dss-table td { padding: 9px 16px; text-align: right; border-top: 1px solid var(--dsw-alias-border-l1); font-variant-numeric: tabular-nums; }
304
+ .dss-table th:first-child, .dss-table td:first-child { text-align: left; }
305
+ .dss-table thead th { border-top: 0; font-size: 12px; font-weight: 500; color: var(--dsw-alias-label-secondary); }
306
+ .dss-table td.dss-table-empty { text-align: left; color: var(--dsw-alias-label-secondary); }
307
+ .dss-button { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 12px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); font: inherit; font-size: 13px; cursor: pointer; white-space: nowrap; }
308
+ .dss-button:hover { background: var(--dsw-alias-bg-layer-2); }
309
+ .dss-button:disabled { opacity: 0.5; cursor: default; }
310
+ .dss-button.primary { border-color: transparent; background: var(--dsw-alias-brand-primary); color: #fff; }
311
+ .dss-button.primary:hover { filter: brightness(1.06); }
312
+ .dss-button.quiet { border-color: transparent; background: none; color: var(--dsw-alias-label-secondary); }
313
+ .dss-button.quiet:hover { background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); }
314
+ .dss-icon-button { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; border: 0; border-radius: 8px; background: none; color: var(--dsw-alias-label-secondary); cursor: pointer; }
315
+ .dss-icon-button:hover { background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); }
316
+ .dss-icon-button:focus-visible, .dss-button:focus-visible, .dss-switch:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; }
317
+ .dss-svg { width: 16px; height: 16px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; }
318
+ .dss-switch { position: relative; flex: none; width: 40px; height: 24px; border: 0; border-radius: 12px; background: var(--dsw-alias-border-l2); cursor: pointer; transition: background 0.15s; }
319
+ .dss-switch::after { content: ""; position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,0.2); transition: transform 0.15s; }
320
+ .dss-switch[aria-checked="true"] { background: var(--dsw-alias-brand-primary); }
321
+ .dss-switch[aria-checked="true"]::after { transform: translateX(16px); }
322
+ .dss-select, .dss-input, .dss-textarea { height: 32px; padding: 0 10px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); font: inherit; font-size: 13px; }
323
+ .dss-select { max-width: 280px; }
324
+ .dss-input { width: 100%; }
325
+ .dss-number { width: 88px; }
326
+ .dss-textarea { width: 100%; height: auto; min-height: 84px; padding: 8px 10px; line-height: 1.55; resize: vertical; }
327
+ .dss-select:focus, .dss-input:focus, .dss-textarea:focus { outline: none; border-color: var(--dsw-alias-brand-primary); }
328
+ .dss-skill { display: flex; align-items: center; gap: 10px; min-height: 48px; padding: 6px 12px 6px 6px; background: var(--dsw-alias-bg-layer-1); }
329
+ .dss-skill + .dss-skill, .dss-overflow + .dss-skill { border-top: 1px solid var(--dsw-alias-border-l1); }
330
+ .dss-skill[data-dragging="true"] { opacity: 0.4; }
331
+ .dss-skill[data-drop="before"] { box-shadow: inset 0 2px 0 var(--dsw-alias-brand-primary); }
332
+ .dss-skill[data-drop="after"] { box-shadow: inset 0 -2px 0 var(--dsw-alias-brand-primary); }
333
+ .dss-skill[data-enabled="false"] .dss-skill-name, .dss-skill[data-enabled="false"] .dss-skill-icon { opacity: 0.45; }
334
+ .dss-grip { cursor: grab; }
335
+ .dss-skill-icon { display: inline-flex; color: var(--dsw-alias-label-primary); }
336
+ .dss-skill-name { flex: 1; min-width: 0; font-size: 13.5px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
337
+ .dss-tag { margin-left: 8px; padding: 1px 6px; border-radius: 4px; font-size: 11px; color: var(--dsw-alias-label-secondary); background: var(--dsw-alias-bg-layer-2); }
338
+ .dss-overflow { padding: 6px 16px; font-size: 11.5px; color: var(--dsw-alias-label-secondary); background: var(--dsw-alias-bg-layer-2); border-top: 1px solid var(--dsw-alias-border-l1); }
339
+ .dss-actions { display: flex; gap: 8px; margin-top: 10px; }
340
+ .dss-editor { margin-top: 10px; padding: 16px; border: 1px solid var(--dsw-alias-brand-primary); border-radius: 12px; background: var(--dsw-alias-bg-layer-1); }
341
+ .dss-editor h3 { margin: 0 0 12px; font-size: 14px; font-weight: 600; }
342
+ .dss-field + .dss-field { margin-top: 14px; }
343
+ .dss-field-label { display: block; margin-bottom: 6px; font-size: 12.5px; color: var(--dsw-alias-label-secondary); }
344
+ .dss-icons { display: flex; flex-wrap: wrap; gap: 6px; }
345
+ .dss-icon-choice { display: inline-flex; align-items: center; justify-content: center; width: 34px; height: 34px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); cursor: pointer; }
346
+ .dss-icon-choice[aria-pressed="true"] { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); box-shadow: 0 0 0 1px var(--dsw-alias-brand-primary); }
347
+ .dss-output { margin-top: 8px; padding: 10px 12px; border-radius: 8px; background: var(--dsw-alias-bg-layer-2); font-size: 12.5px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; max-height: 220px; overflow: auto; }
348
+ .dss-output.error { color: var(--dsw-alias-state-error-primary, #d4302b); }
349
+ .dss-chips { display: flex; flex-wrap: wrap; gap: 8px; padding: 12px 16px; }
350
+ .dss-chip { display: inline-flex; align-items: center; gap: 4px; height: 30px; padding: 0 4px 0 12px; border-radius: 15px; background: var(--dsw-alias-bg-layer-2); font-size: 12.5px; }
351
+ .dss-chip .dss-icon-button { width: 24px; height: 24px; border-radius: 12px; }
352
+ .dss-chip.suggest { padding: 0 12px; border: 1px dashed var(--dsw-alias-border-l2); background: none; color: var(--dsw-alias-label-secondary); cursor: pointer; font: inherit; font-size: 12.5px; }
353
+ .dss-chip.suggest:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-brand-primary); }
354
+ .dss-empty { padding: 14px 16px; font-size: 12.5px; color: var(--dsw-alias-label-secondary); }
355
+ .dss-inline { display: flex; gap: 8px; padding: 0 16px 14px; }
356
+ .dss-recent { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 0 16px 14px; font-size: 12px; color: var(--dsw-alias-label-secondary); }
357
+ .dss-footer { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 28px; }
358
+ .dss-footer .dss-hint { flex-basis: 100%; }
359
+ `
360
+
361
+ // ---------- Host connection ----------
362
+
363
+ async function request(path, init) {
364
+ const response = await fetch(API + path, {
365
+ cache: 'no-store',
366
+ ...init,
367
+ headers: { accept: 'application/json', ...(init && init.body ? { 'content-type': 'application/json' } : {}) },
368
+ })
369
+ let payload = null
370
+ try {
371
+ payload = await response.json()
372
+ } catch {
373
+ payload = null
374
+ }
375
+ if (!response.ok) throw new Error((payload && payload.error) || `HTTP ${response.status}`)
376
+ return payload
377
+ }
378
+
379
+ // One server-sent-events connection per client, shared by the page and the navigation listener.
380
+ // Read through fetch rather than EventSource, so it works through Desktop's request bridge.
381
+ const events = (() => {
382
+ const listeners = new Set()
383
+ let controller = null
384
+ let retry = 1000
385
+ async function connect() {
386
+ // Each connection checks its own controller: after a stop and start the shared one is a newer one.
387
+ const own = new AbortController()
388
+ controller = own
389
+ try {
390
+ const response = await fetch(`${API}/events`, { cache: 'no-store', headers: { accept: 'text/event-stream' }, signal: own.signal })
391
+ if (!response.ok || !response.body) throw new Error(`HTTP ${response.status}`)
392
+ retry = 1000
393
+ const reader = response.body.getReader()
394
+ const decoder = new TextDecoder()
395
+ let buffer = ''
396
+ for (;;) {
397
+ const { done, value } = await reader.read()
398
+ if (done) break
399
+ buffer += decoder.decode(value, { stream: true })
400
+ let boundary
401
+ while ((boundary = buffer.indexOf('\n\n')) !== -1) {
402
+ const frame = buffer.slice(0, boundary)
403
+ buffer = buffer.slice(boundary + 2)
404
+ let event = 'message'
405
+ let data = ''
406
+ for (const line of frame.split('\n')) {
407
+ if (line.startsWith('event:')) event = line.slice(6).trim()
408
+ else if (line.startsWith('data:')) data += line.slice(5).trim()
409
+ }
410
+ if (!data) continue
411
+ let parsed = null
412
+ try {
413
+ parsed = JSON.parse(data)
414
+ } catch {
415
+ continue
416
+ }
417
+ for (const listener of listeners) listener(event, parsed)
418
+ }
419
+ }
420
+ } catch (error) {
421
+ if (own.signal.aborted) return
422
+ }
423
+ if (own.signal.aborted) return
424
+ setTimeout(() => { if (!own.signal.aborted) void connect() }, retry)
425
+ retry = Math.min(retry * 2, 30000)
426
+ }
427
+ return {
428
+ start() {
429
+ void connect()
430
+ return () => controller && controller.abort()
431
+ },
432
+ subscribe(listener) {
433
+ listeners.add(listener)
434
+ return () => listeners.delete(listener)
435
+ },
436
+ }
437
+ })()
438
+
439
+ // ---------- Small components ----------
440
+
441
+ function Svg({ markup, size }) {
442
+ return h('svg', { className: 'dss-svg', viewBox: '0 0 24 24', 'aria-hidden': true, style: size ? { width: size, height: size } : undefined, dangerouslySetInnerHTML: { __html: markup || '' } })
443
+ }
444
+
445
+ function Switch({ checked, onChange, label }) {
446
+ return h('button', { type: 'button', role: 'switch', className: 'dss-switch', 'aria-checked': String(Boolean(checked)), 'aria-label': label, onClick: () => onChange(!checked) })
447
+ }
448
+
449
+ function Row({ label, hint, children }) {
450
+ return h('div', { className: 'dss-row' },
451
+ h('div', { className: 'dss-row-main' }, h('div', { className: 'dss-label' }, label), hint ? h('div', { className: 'dss-hint' }, hint) : null),
452
+ children)
453
+ }
454
+
455
+ function Section({ title, hint, children }) {
456
+ return h('section', { className: 'dss-section' },
457
+ h('div', { className: 'dss-section-title' }, title),
458
+ hint ? h('div', { className: 'dss-section-hint' }, hint) : null,
459
+ children)
460
+ }
461
+
462
+ function NumberInput({ value, min, max, onChange, label }) {
463
+ const [draft, setDraft] = useState(String(value))
464
+ useEffect(() => setDraft(String(value)), [value])
465
+ const commit = () => {
466
+ // A cleared or non-numeric field reads as '', which Number() would turn into 0.
467
+ const number = draft.trim() === '' ? Number.NaN : Number(draft)
468
+ const next = Number.isFinite(number) ? Math.min(max, Math.max(min, Math.round(number))) : value
469
+ // The saved value may not change (a revert, or a clamp back to it), so the field resets here too.
470
+ setDraft(String(next))
471
+ if (next !== value) onChange(next)
472
+ }
473
+ return h('input', {
474
+ className: 'dss-input dss-number', type: 'number', min, max, value: draft, 'aria-label': label,
475
+ onChange: (event) => setDraft(event.target.value), onBlur: commit,
476
+ onKeyDown: (event) => { if (event.key === 'Enter') commit() },
477
+ })
478
+ }
479
+
480
+ // Same rule as isSearchTemplate in src/host/settings.mjs, which drops any other value.
481
+ function isSearchTemplate(value) {
482
+ if (value.length > 500 || !value.includes('%s')) return false
483
+ try {
484
+ const url = new URL(value.replace('%s', 'x'))
485
+ return url.protocol === 'https:' || url.protocol === 'http:'
486
+ } catch {
487
+ return false
488
+ }
489
+ }
490
+
491
+ function SearchUrlRow({ t, settings, update }) {
492
+ const [invalid, setInvalid] = useState(false)
493
+ const commit = (event) => {
494
+ const value = event.target.value.trim()
495
+ const valid = !value || isSearchTemplate(value)
496
+ setInvalid(!valid)
497
+ // Tabbing through an unchanged field must not switch the engine.
498
+ if (!valid || value === settings.customSearchUrl) return
499
+ update(value
500
+ ? { customSearchUrl: value, searchEngine: 'custom' }
501
+ : { customSearchUrl: '', searchEngine: settings.searchEngine === 'custom' ? 'google' : settings.searchEngine })
502
+ }
503
+ const hint = invalid ? h('span', { className: 'dss-invalid', role: 'alert' }, t('search.invalid')) : t('search.customHint')
504
+ return h(Row, { label: t('search.customUrl'), hint },
505
+ h('input', {
506
+ key: `search-url-${settings.customSearchUrl}`,
507
+ className: 'dss-input', style: { maxWidth: 320 }, defaultValue: settings.customSearchUrl, placeholder: 'https://…%s',
508
+ 'aria-label': t('search.customUrl'), 'aria-invalid': invalid,
509
+ onBlur: commit,
510
+ onKeyDown: (event) => { if (event.key === 'Enter') event.currentTarget.blur() },
511
+ }))
512
+ }
513
+
514
+ // ---------- Skills ----------
515
+
516
+ function SkillList({ t, settings, catalog, update, onEdit }) {
517
+ const [drag, setDrag] = useState(null)
518
+ const builtins = useMemo(() => new Map(catalog.builtins.map((skill) => [skill.id, skill])), [catalog])
519
+ // Built-in names follow DSH's interface language, which the bound dictionary already reflects.
520
+ const locale = t('title') === zh.title ? 'zh' : 'en'
521
+ const nameOf = (entry) => (entry.custom ? entry.name : builtins.get(entry.id).name[locale])
522
+ const iconOf = (entry) => catalog.icons[entry.custom ? entry.icon : builtins.get(entry.id).icon]
523
+
524
+ const move = (from, to) => {
525
+ if (from === to || to < 0 || to >= settings.skills.length) return
526
+ const skills = [...settings.skills]
527
+ const [item] = skills.splice(from, 1)
528
+ skills.splice(to, 0, item)
529
+ update({ skills })
530
+ }
531
+
532
+ let enabledSeen = 0
533
+ const rows = []
534
+ settings.skills.forEach((entry, index) => {
535
+ if (entry.enabled) enabledSeen += 1
536
+ rows.push(h('div', {
537
+ key: entry.id,
538
+ className: 'dss-skill',
539
+ 'data-enabled': String(entry.enabled),
540
+ 'data-dragging': String(drag && drag.from === index),
541
+ 'data-drop': drag && drag.over === index && drag.from !== index ? (drag.from < index ? 'after' : 'before') : undefined,
542
+ draggable: true,
543
+ onDragStart: (event) => {
544
+ event.dataTransfer.effectAllowed = 'move'
545
+ event.dataTransfer.setData('text/plain', entry.id)
546
+ setDrag({ from: index, over: index })
547
+ },
548
+ onDragOver: (event) => {
549
+ event.preventDefault()
550
+ if (drag && drag.over !== index) setDrag({ ...drag, over: index })
551
+ },
552
+ onDrop: (event) => {
553
+ event.preventDefault()
554
+ if (drag) move(drag.from, index)
555
+ setDrag(null)
556
+ },
557
+ onDragEnd: () => setDrag(null),
558
+ },
559
+ h('button', {
560
+ type: 'button', className: 'dss-icon-button dss-grip', title: t('skills.move'), 'aria-label': t('skills.move'),
561
+ onKeyDown: (event) => {
562
+ if (event.key === 'ArrowUp') { event.preventDefault(); move(index, index - 1) }
563
+ if (event.key === 'ArrowDown') { event.preventDefault(); move(index, index + 1) }
564
+ },
565
+ }, h(Svg, { markup: catalog.icons.grip })),
566
+ h('span', { className: 'dss-skill-icon' }, h(Svg, { markup: iconOf(entry) })),
567
+ h('span', { className: 'dss-skill-name' }, nameOf(entry), entry.custom ? h('span', { className: 'dss-tag' }, t('skills.custom')) : null),
568
+ entry.custom ? h('button', { type: 'button', className: 'dss-icon-button', title: t('skills.edit'), 'aria-label': t('skills.edit'), onClick: () => onEdit(entry) }, h(Svg, { markup: catalog.icons.edit })) : null,
569
+ entry.custom ? h('button', {
570
+ type: 'button', className: 'dss-icon-button', title: t('skills.delete'), 'aria-label': t('skills.delete'),
571
+ onClick: () => {
572
+ if (window.confirm(fill(t('skills.deleteConfirm'), { name: entry.name }))) update({ skills: settings.skills.filter((skill) => skill.id !== entry.id) })
573
+ },
574
+ }, h(Svg, { markup: catalog.icons.trash })) : null,
575
+ h(Switch, {
576
+ checked: entry.enabled, label: nameOf(entry),
577
+ onChange: (enabled) => update({ skills: settings.skills.map((skill) => (skill.id === entry.id ? { ...skill, enabled } : skill)) }),
578
+ })))
579
+ if (entry.enabled && enabledSeen === settings.toolbarSize && index < settings.skills.length - 1) {
580
+ rows.push(h('div', { key: '__overflow', className: 'dss-overflow' }, t('skills.overflow')))
581
+ }
582
+ })
583
+ return h('div', { className: 'dss-card', style: { overflow: 'hidden' } }, rows)
584
+ }
585
+
586
+ function SkillEditor({ t, catalog, limits, initial, onSave, onCancel, prepare }) {
587
+ const [name, setName] = useState(initial ? initial.name : '')
588
+ const [icon, setIcon] = useState(initial ? initial.icon : 'sparkles')
589
+ const [prompt, setPrompt] = useState(initial ? initial.prompt : '')
590
+ const [sample, setSample] = useState('')
591
+ const [trial, setTrial] = useState(null)
592
+ const [running, setRunning] = useState(false)
593
+ const valid = name.trim() && prompt.trim()
594
+
595
+ const run = async () => {
596
+ setRunning(true)
597
+ setTrial(null)
598
+ try {
599
+ await prepare?.()
600
+ setTrial(await request('/skills/test', { method: 'POST', body: JSON.stringify({ prompt, text: sample }) }))
601
+ } catch (error) {
602
+ setTrial({ ok: false, error: { code: 'MODEL_FAILED', detail: error.message } })
603
+ } finally {
604
+ setRunning(false)
605
+ }
606
+ }
607
+
608
+ return h('div', { className: 'dss-editor' },
609
+ h('h3', null, initial ? t('editor.titleEdit') : t('editor.titleNew')),
610
+ h('div', { className: 'dss-field' },
611
+ h('label', { className: 'dss-field-label' }, t('editor.name')),
612
+ h('input', { className: 'dss-input', value: name, maxLength: limits.customName, placeholder: t('editor.namePlaceholder'), onChange: (event) => setName(event.target.value) })),
613
+ h('div', { className: 'dss-field' },
614
+ h('span', { className: 'dss-field-label' }, t('editor.icon')),
615
+ h('div', { className: 'dss-icons' }, catalog.customIcons.map((key) => h('button', {
616
+ key, type: 'button', className: 'dss-icon-choice', 'aria-pressed': String(icon === key), 'aria-label': key, onClick: () => setIcon(key),
617
+ }, h(Svg, { markup: catalog.icons[key] }))))),
618
+ h('div', { className: 'dss-field' },
619
+ h('label', { className: 'dss-field-label' }, t('editor.prompt')),
620
+ h('textarea', { className: 'dss-textarea', value: prompt, maxLength: limits.customPrompt, placeholder: t('editor.promptPlaceholder'), onChange: (event) => setPrompt(event.target.value) }),
621
+ h('div', { className: 'dss-hint' }, t('editor.promptHint'))),
622
+ h('div', { className: 'dss-field' },
623
+ h('label', { className: 'dss-field-label' }, `${t('editor.try')} · ${t('editor.sample')}`),
624
+ h('textarea', { className: 'dss-textarea', style: { minHeight: 60 }, value: sample, placeholder: t('editor.samplePlaceholder'), onChange: (event) => setSample(event.target.value) }),
625
+ h('div', { className: 'dss-actions' },
626
+ h('button', { type: 'button', className: 'dss-button', disabled: !prompt.trim() || !sample.trim() || running, onClick: run }, running ? t('editor.running') : t('editor.run'))),
627
+ trial ? h('div', { className: `dss-output${trial.ok ? '' : ' error'}` }, trial.ok ? trial.output : failureText(t, trial.error)) : null),
628
+ h('div', { className: 'dss-actions', style: { marginTop: 16 } },
629
+ h('button', { type: 'button', className: 'dss-button primary', disabled: !valid, onClick: () => onSave({ name: name.trim(), icon, prompt: prompt.trim() }) }, t('editor.save')),
630
+ h('button', { type: 'button', className: 'dss-button', onClick: onCancel }, t('editor.cancel'))))
631
+ }
632
+
633
+ // ---------- Failures and usage ----------
634
+
635
+ const FAILURE_CODES = new Set(['NO_LLM', 'NO_MODEL', 'MODEL_FAILED', 'EMPTY', 'TIMEOUT'])
636
+
637
+ /** The Host reports stable codes; people read the matching sentence, with the provider's words after it. */
638
+ function failureText(t, failure) {
639
+ const message = FAILURE_CODES.has(failure.code) ? t(`failure.${failure.code}`) : failure.code
640
+ return failure.detail ? fill(t('failure.detail'), { message, detail: failure.detail }) : message
641
+ }
642
+
643
+ const PERIODS = ['today', 'week', 'month', 'all']
644
+
645
+ function UsagePanel({ t, usage, settings, catalog, onReset }) {
646
+ const [period, setPeriod] = useState('week')
647
+ if (!usage) return h('div', { className: 'dss-empty' }, t('usage.loading'))
648
+ const locale = t('title') === zh.title ? 'zh' : 'en'
649
+ const number = (value) => value.toLocaleString(locale === 'zh' ? 'zh-CN' : 'en-US')
650
+ const builtins = new Map(catalog.builtins.map((skill) => [skill.id, skill]))
651
+ const skillName = (id) => {
652
+ if (id === 'model-test') return t('usage.modelTest')
653
+ if (id === 'skill-test') return t('usage.skillTest')
654
+ if (builtins.has(id)) return builtins.get(id).name[locale]
655
+ return settings.skills.find((skill) => skill.id === id)?.name ?? t('usage.removedSkill')
656
+ }
657
+ const { totals, models, skills } = usage.periods[period]
658
+ const tiles = [
659
+ ['usage.requests', totals.requests],
660
+ ['usage.input', totals.input],
661
+ ['usage.output', totals.output],
662
+ ...(totals.cacheRead ? [['usage.cacheRead', totals.cacheRead]] : []),
663
+ ...(totals.reasoning ? [['usage.reasoning', totals.reasoning]] : []),
664
+ ]
665
+ const table = (title, rows, labelOf, keyOf) => h('div', { className: 'dss-card' },
666
+ h('table', { className: 'dss-table' },
667
+ h('thead', null, h('tr', null,
668
+ h('th', null, title), h('th', null, t('usage.requests')), h('th', null, t('usage.input')), h('th', null, t('usage.output')))),
669
+ h('tbody', null, rows.length
670
+ ? rows.map((row) => h('tr', { key: keyOf(row) },
671
+ h('td', null, labelOf(row)), h('td', null, number(row.requests)), h('td', null, number(row.input)), h('td', null, number(row.output))))
672
+ : h('tr', null, h('td', { colSpan: 4, className: 'dss-table-empty' }, t('usage.none'))))))
673
+ return h('div', null,
674
+ h('div', { className: 'dss-segmented', role: 'group', 'aria-label': t('usage.period') },
675
+ PERIODS.map((id) => h('button', { key: id, type: 'button', 'aria-pressed': String(period === id), onClick: () => setPeriod(id) }, t(`usage.period.${id}`)))),
676
+ h('div', { className: 'dss-tiles' }, tiles.map(([key, value]) => h('div', { key, className: 'dss-tile' },
677
+ h('div', { className: 'dss-tile-value' }, number(value)), h('div', { className: 'dss-tile-label' }, t(key))))),
678
+ h(Section, { title: t('usage.byModel') },
679
+ table(t('usage.model'), models, (row) => `${row.providerName} · ${row.modelName}`, (row) => `${row.provider}/${row.model}`)),
680
+ h(Section, { title: t('usage.bySkill') },
681
+ table(t('usage.skill'), skills, (row) => skillName(row.skill), (row) => row.skill)),
682
+ h('div', { className: 'dss-actions' },
683
+ h('button', { type: 'button', className: 'dss-button', onClick: onReset }, t('usage.reset')),
684
+ h('div', { className: 'dss-hint' }, usage.since ? fill(t('usage.since'), { day: usage.since }) : t('usage.privacy'))))
685
+ }
686
+
687
+ // Grouped by what people come to change; the most used group first.
688
+ const TABS = ['general', 'skills', 'model', 'capture', 'apps', 'usage']
689
+
690
+ function Tabs({ t, tab, onSelect }) {
691
+ // Arrow keys move between tabs, as screen readers expect of a tab list.
692
+ const onKeyDown = (event) => {
693
+ const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0
694
+ if (!step) return
695
+ event.preventDefault()
696
+ const next = TABS[(TABS.indexOf(tab) + step + TABS.length) % TABS.length]
697
+ onSelect(next)
698
+ event.currentTarget.querySelector(`#dss-tab-${next}`)?.focus()
699
+ }
700
+ return h('div', { className: 'dss-tabs', role: 'tablist', 'aria-label': t('title'), onKeyDown },
701
+ TABS.map((id) => h('button', {
702
+ key: id, id: `dss-tab-${id}`, type: 'button', role: 'tab', className: 'dss-tab',
703
+ 'aria-selected': String(tab === id), 'aria-controls': `dss-panel-${id}`, tabIndex: tab === id ? 0 : -1,
704
+ onClick: () => onSelect(id),
705
+ }, t(`tab.${id}`))))
706
+ }
707
+
708
+ // ---------- Page ----------
709
+
710
+ function SettingsPage(props) {
711
+ const t = typeof props.t === 'function' ? props.t : (key) => zh[key] || key
712
+ const [state, setState] = useState(null)
713
+ const [loadError, setLoadError] = useState(null)
714
+ const [save, setSave] = useState({ phase: 'idle', error: null })
715
+ const [editing, setEditing] = useState(null)
716
+ const [models, setModels] = useState(null)
717
+ const [modelTest, setModelTest] = useState(null)
718
+ const [appDraft, setAppDraft] = useState('')
719
+ const [tab, setTab] = useState('general')
720
+ const [usage, setUsage] = useState(null)
721
+ const pending = useRef(null)
722
+ const timer = useRef(null)
723
+ const savedTimer = useRef(null)
724
+ const loaded = useRef(false)
725
+
726
+ const load = useCallback(async () => {
727
+ try {
728
+ setState(await request('/state'))
729
+ setLoadError(null)
730
+ loaded.current = true
731
+ } catch (error) {
732
+ setLoadError(error.message)
733
+ }
734
+ }, [])
735
+
736
+ const loadModels = useCallback(async () => {
737
+ try {
738
+ setModels(await request('/models'))
739
+ } catch (error) {
740
+ setModels({ routes: [], selected: null, error: { code: 'MODEL_FAILED', detail: error.message } })
741
+ }
742
+ }, [])
743
+
744
+ const loadUsage = useCallback(async () => {
745
+ try {
746
+ setUsage(await request('/usage'))
747
+ } catch {
748
+ // The usage tab keeps showing "reading" and the next usage event fills it.
749
+ }
750
+ }, [])
751
+
752
+ useEffect(() => { void load(); void loadModels(); void loadUsage() }, [load, loadModels, loadUsage])
753
+
754
+ useEffect(() => events.subscribe((event, data) => {
755
+ // The first load failed (Host not up yet); an event means it is reachable now.
756
+ if (!loaded.current) {
757
+ void load()
758
+ void loadModels()
759
+ void loadUsage()
760
+ return
761
+ }
762
+ if (event === 'status') {
763
+ setState((current) => (current ? { ...current, status: data } : current))
764
+ // A new toolbar state is news; "Saved" gives way to it at once.
765
+ setSave((current) => (current.phase === 'saved' ? { phase: 'idle', error: null } : current))
766
+ }
767
+ // Changes made elsewhere (e.g. "disable in this app") arrive here; fields being edited locally win.
768
+ if (event === 'settings') setState((current) => (current ? { ...current, settings: { ...data, ...pending.current } } : current))
769
+ if (event === 'usage') setUsage(data)
770
+ }), [load, loadModels, loadUsage])
771
+
772
+ // Only the changed fields go to the Host, which merges them into its current settings, so a
773
+ // concurrent change from the toolbar is never overwritten by a stale copy of the whole object.
774
+ const flush = useCallback(async () => {
775
+ const patch = pending.current
776
+ if (!patch) return
777
+ pending.current = null
778
+ setSave({ phase: 'saving', error: null })
779
+ try {
780
+ const { settings } = await request('/settings', { method: 'PUT', body: JSON.stringify(patch) })
781
+ setState((current) => ({ ...current, settings: { ...settings, ...pending.current } }))
782
+ setSave({ phase: 'saved', error: null })
783
+ clearTimeout(savedTimer.current)
784
+ savedTimer.current = setTimeout(() => setSave((current) => (current.phase === 'saved' ? { phase: 'idle', error: null } : current)), SAVED_NOTICE_MS)
785
+ } catch (error) {
786
+ setSave({ phase: 'error', error: error.message })
787
+ void load()
788
+ }
789
+ }, [load])
790
+
791
+ const update = useCallback((patch) => {
792
+ pending.current = { ...pending.current, ...patch }
793
+ setState((current) => ({ ...current, settings: { ...current.settings, ...patch } }))
794
+ clearTimeout(timer.current)
795
+ timer.current = setTimeout(() => { void flush() }, SAVE_DELAY_MS)
796
+ }, [flush])
797
+
798
+ // Tests run against the saved settings, so a change still waiting for the debounce goes first.
799
+ const saveNow = useCallback(async () => {
800
+ clearTimeout(timer.current)
801
+ await flush()
802
+ }, [flush])
803
+
804
+ // Buttons that only fire a request: failures surface next to the save status.
805
+ const act = useCallback((path, then) => {
806
+ request(path, { method: 'POST', body: '{}' })
807
+ .then((payload) => then?.(payload))
808
+ .catch((error) => setSave({ phase: 'error', error: error.message, action: true }))
809
+ }, [])
810
+
811
+ useEffect(() => () => {
812
+ clearTimeout(timer.current)
813
+ clearTimeout(savedTimer.current)
814
+ void flush()
815
+ }, [flush])
816
+
817
+ if (!state) {
818
+ return h('div', { className: 'dss-root' }, h('style', null, css),
819
+ h('div', { className: 'dss-page' }, h('div', { className: 'dss-title' }, t('title')),
820
+ loadError ? h('div', { className: 'dss-error' }, t('status.offline') + loadError) : null))
821
+ }
822
+
823
+ const { settings, status, catalog, recentApps } = state
824
+ const limits = catalog.limits
825
+ const statusKey = !status.supported ? 'status.unsupported' : `status.${status.worker}`
826
+ const saveText = save.phase === 'saving' ? t('status.saving') : save.phase === 'saved' ? t('status.saved') : save.phase === 'error' ? t(save.action ? 'status.actionFailed' : 'status.saveFailed') + save.error : null
827
+ const routeLabel = (route) => `${route.providerName} · ${route.modelName}`
828
+ const selectedRoute = settings.model.provider && settings.model.model ? `${settings.model.provider}\u0000${settings.model.model}` : ''
829
+
830
+ const saveSkill = (draft) => {
831
+ if (editing && editing.id) {
832
+ update({ skills: settings.skills.map((skill) => (skill.id === editing.id ? { ...skill, ...draft } : skill)) })
833
+ } else {
834
+ update({ skills: [...settings.skills, { id: `custom-${Math.random().toString(36).slice(2, 10)}`, custom: true, enabled: true, ...draft }] })
835
+ }
836
+ setEditing(null)
837
+ }
838
+
839
+ const addApp = (app) => {
840
+ const name = app.trim()
841
+ if (!name || settings.disabledApps.includes(name)) return
842
+ update({ disabledApps: [...settings.disabledApps, name] })
843
+ setAppDraft('')
844
+ }
845
+
846
+ const panel = (id, ...children) => h('div', {
847
+ key: id, id: `dss-panel-${id}`, role: 'tabpanel', className: 'dss-tabpanel', 'aria-labelledby': `dss-tab-${id}`, hidden: tab !== id,
848
+ }, ...children)
849
+
850
+ return h('div', { className: 'dss-root' }, h('style', null, css),
851
+ h('div', { className: 'dss-page' },
852
+ h('div', { className: 'dss-head' },
853
+ h('div', null, h('div', { className: 'dss-title' }, t('title')), h('div', { className: 'dss-sub' }, t('subtitle'))),
854
+ h('div', { className: 'dss-state' },
855
+ h('span', { className: 'dss-dot', 'data-state': !status.supported ? 'idle' : status.worker === 'running' ? 'running' : status.worker === 'restarting' || status.worker === 'failed' ? 'error' : 'idle' }),
856
+ saveText || t(statusKey))),
857
+
858
+ status.supported && status.permission === 'denied'
859
+ ? h('div', { className: 'dss-banner' },
860
+ h('div', { style: { flex: 1 } }, h('strong', null, t('permission.title')), h('p', null, t('permission.body'))),
861
+ h('button', { type: 'button', className: 'dss-button primary', onClick: () => act('/permission/open') }, t('permission.open')))
862
+ : null,
863
+
864
+ h(Tabs, { t, tab, onSelect: setTab }),
865
+
866
+ panel('general',
867
+ h('div', { className: 'dss-card', style: { marginTop: 20 } },
868
+ h(Row, { label: t('enabled.label'), hint: t('enabled.hint') }, h(Switch, { checked: settings.enabled, label: t('enabled.label'), onChange: (enabled) => update({ enabled }) }))),
869
+ h(Section, { title: t('toolbar.title') },
870
+ h('div', { className: 'dss-card' },
871
+ h(Row, { label: t('toolbar.trigger') },
872
+ h('select', { className: 'dss-select', value: settings.trigger, onChange: (event) => update({ trigger: event.target.value }) },
873
+ h('option', { value: 'auto' }, t('toolbar.trigger.auto')),
874
+ h('option', { value: 'modifier' }, t('toolbar.trigger.modifier')))),
875
+ h(Row, { label: t('toolbar.size') },
876
+ h(NumberInput, { value: settings.toolbarSize, min: limits.toolbarSize.min, max: limits.toolbarSize.max, label: t('toolbar.size'), onChange: (toolbarSize) => update({ toolbarSize }) })))),
877
+ h(Section, { title: t('language.title') },
878
+ h('div', { className: 'dss-card' },
879
+ h(Row, { label: t('language.native') },
880
+ h('select', { className: 'dss-select', value: settings.nativeLanguage, onChange: (event) => update({ nativeLanguage: event.target.value }) },
881
+ catalog.languages.map((language) => h('option', { key: language.code, value: language.code }, language.name)))),
882
+ h(Row, { label: t('language.translate') },
883
+ h('select', { className: 'dss-select', value: settings.translateTarget, onChange: (event) => update({ translateTarget: event.target.value }) },
884
+ h('option', { value: 'auto' }, t('language.translate.auto')),
885
+ catalog.languages.map((language) => h('option', { key: language.code, value: language.code }, language.name)))),
886
+ h(Row, { label: t('language.answer') },
887
+ h('select', { className: 'dss-select', value: settings.answerLanguage, onChange: (event) => update({ answerLanguage: event.target.value }) },
888
+ h('option', { value: 'native' }, t('language.answer.native')),
889
+ h('option', { value: 'source' }, t('language.answer.source')),
890
+ catalog.languages.map((language) => h('option', { key: language.code, value: language.code }, language.name))))))),
891
+
892
+ panel('skills',
893
+ h(Section, { title: t('skills.title'), hint: fill(t('skills.hint'), { count: settings.toolbarSize }) },
894
+ h(SkillList, { t, settings, catalog, update, onEdit: (entry) => setEditing(entry) }),
895
+ editing
896
+ ? h(SkillEditor, { key: editing.id || 'new', t, catalog, limits, initial: editing.id ? editing : null, onSave: saveSkill, onCancel: () => setEditing(null), prepare: saveNow })
897
+ : h('div', { className: 'dss-actions' },
898
+ h('button', { type: 'button', className: 'dss-button', onClick: () => setEditing({}) }, h(Svg, { markup: catalog.icons.plus }), t('skills.add'))))),
899
+
900
+ panel('model',
901
+ h(Section, { title: t('model.title'), hint: t('model.hint') },
902
+ h('div', { className: 'dss-card' },
903
+ h(Row, {
904
+ label: t('model.route'),
905
+ hint: models && models.error ? fill(t('model.error'), { error: failureText(t, models.error) })
906
+ : models && models.routes.length === 0 ? t('model.none')
907
+ : models && models.selected ? fill(t('model.current'), { route: routeLabel(models.selected) }) : null,
908
+ },
909
+ h('select', {
910
+ className: 'dss-select', value: selectedRoute,
911
+ onChange: (event) => {
912
+ const [provider, model] = event.target.value ? event.target.value.split('\u0000') : ['', '']
913
+ update({ model: { provider, model } })
914
+ setModelTest(null)
915
+ setTimeout(() => void loadModels(), SAVE_DELAY_MS + 200)
916
+ },
917
+ },
918
+ h('option', { value: '' }, t('model.auto')),
919
+ (models ? models.routes : []).map((route) => h('option', { key: `${route.provider}/${route.model}`, value: `${route.provider}\u0000${route.model}` }, routeLabel(route)))),
920
+ h('button', {
921
+ type: 'button', className: 'dss-button', disabled: modelTest && modelTest.running,
922
+ onClick: async () => {
923
+ setModelTest({ running: true })
924
+ try {
925
+ await saveNow()
926
+ setModelTest(await request('/models/test', { method: 'POST', body: '{}' }))
927
+ } catch (error) {
928
+ setModelTest({ ok: false, error: { code: 'MODEL_FAILED', detail: error.message } })
929
+ }
930
+ },
931
+ }, modelTest && modelTest.running ? t('model.testing') : t('model.test'))),
932
+ modelTest && !modelTest.running
933
+ ? h('div', { style: { padding: '0 16px 14px' } }, h('div', { className: `dss-output${modelTest.ok ? '' : ' error'}` },
934
+ modelTest.ok
935
+ ? fill(t('model.testOk'), { route: modelTest.route ? routeLabel(modelTest.route) : '', ms: modelTest.ms, output: modelTest.output.trim() })
936
+ : failureText(t, modelTest.error)))
937
+ : null))),
938
+
939
+ panel('capture',
940
+ h(Section, { title: t('capture.title') },
941
+ h('div', { className: 'dss-card' },
942
+ h(Row, { label: t('capture.clipboard'), hint: t('capture.clipboardHint') },
943
+ h(Switch, { checked: settings.clipboardFallback, label: t('capture.clipboard'), onChange: (clipboardFallback) => update({ clipboardFallback }) })),
944
+ h(Row, { label: t('capture.max') },
945
+ h(NumberInput, { value: settings.maxTextLength, min: limits.maxTextLength.min, max: limits.maxTextLength.max, label: t('capture.max'), onChange: (maxTextLength) => update({ maxTextLength }) })))),
946
+ h(Section, { title: t('search.title') },
947
+ h('div', { className: 'dss-card' },
948
+ h(Row, { label: t('search.engine') },
949
+ h('select', { className: 'dss-select', value: settings.searchEngine, onChange: (event) => update({ searchEngine: event.target.value }) },
950
+ Object.keys(catalog.searchEngines).map((key) => h('option', { key, value: key }, ({ google: 'Google', bing: 'Bing', baidu: '百度 Baidu', duckduckgo: 'DuckDuckGo' })[key] || key)),
951
+ h('option', { value: 'custom', disabled: !settings.customSearchUrl && settings.searchEngine !== 'custom' }, t('search.custom')))),
952
+ h(SearchUrlRow, { t, settings, update })))),
953
+
954
+ panel('apps',
955
+ h(Section, { title: t('apps.title') },
956
+ h('div', { className: 'dss-card' },
957
+ settings.disabledApps.length
958
+ ? h('div', { className: 'dss-chips' }, settings.disabledApps.map((app) => h('span', { key: app, className: 'dss-chip' }, app,
959
+ h('button', { type: 'button', className: 'dss-icon-button', 'aria-label': `${t('apps.remove')} ${app}`, onClick: () => update({ disabledApps: settings.disabledApps.filter((item) => item !== app) }) },
960
+ h(Svg, { markup: catalog.icons.close, size: 14 })))))
961
+ : h('div', { className: 'dss-empty' }, t('apps.empty')),
962
+ h('div', { className: 'dss-inline' },
963
+ h('input', { className: 'dss-input', value: appDraft, placeholder: t('apps.placeholder'), onChange: (event) => setAppDraft(event.target.value), onKeyDown: (event) => { if (event.key === 'Enter') addApp(appDraft) } }),
964
+ h('button', { type: 'button', className: 'dss-button', disabled: !appDraft.trim(), onClick: () => addApp(appDraft) }, t('apps.add'))),
965
+ recentApps.filter((app) => !settings.disabledApps.includes(app)).length
966
+ ? h('div', { className: 'dss-recent' }, t('apps.recent'), recentApps.filter((app) => !settings.disabledApps.includes(app)).map((app) => h('button', {
967
+ key: app, type: 'button', className: 'dss-chip suggest', onClick: () => addApp(app),
968
+ }, `+ ${app}`)))
969
+ : null))),
970
+
971
+ panel('usage',
972
+ h(UsagePanel, {
973
+ t, usage, settings, catalog,
974
+ onReset: () => {
975
+ if (window.confirm(t('usage.resetConfirm'))) act('/usage/reset', (summary) => setUsage(summary))
976
+ },
977
+ })),
978
+
979
+ h('div', { className: 'dss-footer' },
980
+ h('button', {
981
+ type: 'button', className: 'dss-button',
982
+ onClick: () => {
983
+ if (!window.confirm(t('footer.resetConfirm'))) return
984
+ clearTimeout(timer.current)
985
+ pending.current = null
986
+ act('/settings/reset', ({ settings: restored }) => setState((current) => ({ ...current, settings: restored })))
987
+ },
988
+ }, t('footer.reset')),
989
+ status.supported ? h('button', { type: 'button', className: 'dss-button quiet', onClick: () => act('/worker/restart') }, t('footer.restart')) : null,
990
+ h('div', { className: 'dss-hint' }, t('footer.data')))))
991
+ }
992
+
993
+ class PageBoundary extends React.Component {
994
+ constructor(props) {
995
+ super(props)
996
+ this.state = { error: null }
997
+ }
998
+ static getDerivedStateFromError(error) {
999
+ return { error }
1000
+ }
1001
+ render() {
1002
+ if (this.state.error) {
1003
+ const t = typeof this.props.t === 'function' ? this.props.t : (key) => zh[key] || key
1004
+ return h('div', { className: 'dss-root' }, h('style', null, css),
1005
+ h('div', { className: 'dss-page' }, h('div', { className: 'dss-error' }, t('error.render') + String(this.state.error.message || this.state.error))))
1006
+ }
1007
+ return this.props.children
1008
+ }
1009
+ }
1010
+
1011
+ function SettingsEntry(props) {
1012
+ return h(PageBoundary, props, h(SettingsPage, props))
1013
+ }
1014
+
1015
+ // Dashed selection frame around lines of text (Lucide "text-select", ISC).
1016
+ const SIDEBAR_ICON = 'M5 3a2 2 0 0 0-2 2 M19 3a2 2 0 0 1 2 2 M21 19a2 2 0 0 1-2 2 M5 21a2 2 0 0 1-2-2 M9 3h1 M9 21h1 M14 3h1 M14 21h1 M3 9v1 M21 9v1 M3 14v1 M21 14v1 M7 8h10 M7 12h10 M7 16h6'
1017
+
1018
+ function SidebarIcon({ size = 18, active }) {
1019
+ return h('svg', {
1020
+ viewBox: '0 0 24 24', width: size, height: size, fill: 'none', 'aria-hidden': true,
1021
+ style: { display: 'block', color: active ? 'var(--dsw-alias-brand-primary)' : 'currentColor' },
1022
+ }, h('path', { d: SIDEBAR_ICON, stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round' }))
1023
+ }
1024
+
1025
+ return {
1026
+ inject: ['slots', 'locale'],
1027
+ apply(ctx) {
1028
+ const t = ctx.locale.bind(NS)
1029
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-selection: dictionaries')
1030
+ ctx.effect(
1031
+ () => ctx.slots.inject('sidebar.panellist', () => ctx.slots.register(
1032
+ { name: 'sidebar.panellist', id: PANEL_ID, order: 30, label: () => t('title') },
1033
+ SidebarIcon,
1034
+ )),
1035
+ 'dsh-selection: sidebar entry',
1036
+ )
1037
+ ctx.effect(
1038
+ () => ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: PANEL_ID, locale: NS }, SettingsEntry)),
1039
+ 'dsh-selection: settings page',
1040
+ )
1041
+ ctx.effect(() => events.start(), 'dsh-selection: event stream')
1042
+ // "More settings…" in the toolbar menu asks the Host to bring DSH forward on this page.
1043
+ ctx.effect(() => events.subscribe((event) => {
1044
+ if (event !== 'navigate') return
1045
+ try {
1046
+ const layout = ctx.get ? ctx.get('layout') : ctx.layout
1047
+ if (layout && typeof layout.selectPanel === 'function') layout.selectPanel(PANEL_ID)
1048
+ } catch {
1049
+ // The layout service may not be ready yet; the window still comes forward.
1050
+ }
1051
+ }), 'dsh-selection: navigation')
1052
+ },
1053
+ }
1054
+ },
1055
+ })