dsh-speak 1.7.4 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client/client.js CHANGED
@@ -1,340 +1,357 @@
1
- // client.js — dsh-speak browser half: per-message Speak/Stop/Replay button,
2
- // speech-state WebSocket, and the native dsh-speak settings page.
3
- // ==============================================================================
4
- // Merged bundle (1.7.0): victorwads' PR #2 UI (message action + Settings page,
5
- // styled with @deepseek-ai/dsh-client-ui-primitives) plus the dsh-speak-specific
6
- // options (master switch, optional event announcements, editable fixed prompt,
7
- // queue-all-messages mode). All UI copy is bilingual (zh/en) via the DSH locale
8
- // service; the settings page entry is "dsh-speak 设置 / dsh-speak settings".
9
- //
10
- // Host contract (adapters/dsh/speech-hook.js):
11
- // * /dsh-speak/control — POST { action: 'play'|'stop'|'status', ... }
12
- // * /dsh-speak/ws — WebSocket publishing { type: 'speech-state', ... }
13
- // * settings namespace 'dsh-speak' (installSettingsSection)
14
- //
15
- // The bundle is deliberately hand-written (no build step) and only uses
16
- // platform seed modules + official primitives (bundle-purity gate).
17
- 'use strict'
18
-
19
- window.__ModuleLoader__.load({
20
- id: 'dsh-speak',
21
- factory: require => {
22
- const module = { exports: {} }
23
- const React = require('react')
24
- const { Button, DisclosureRow, IconPauseOutline16, Input } = require('@deepseek-ai/dsh-client-ui-primitives')
25
- const CONTROL_PATH = '/dsh-speak/control'
26
- const SOCKET_PATH = '/dsh-speak/ws'
27
- const SETTINGS_NAMESPACE = 'dsh-speak'
28
-
29
- // ---- locale copy (zh / en) -------------------------------------------
30
- const NS = 'dsh-speak'
31
- const zh = {
32
- nav: 'dsh-speak 设置',
33
- settingsAria: 'dsh-speak 设置',
34
- settingsIntro: '自动播报与逐条重播的语音偏好。',
35
- settingsTitle: 'dsh-speak',
36
- actionSpeakTurn: '播报此回合',
37
- actionStop: '停止播报',
38
- toggleOn: '开',
39
- toggleOff: '关',
40
- masterSwitch: '总开关',
41
- masterSwitchHint: '临时开启或关闭所有播报。',
42
- automaticSpeech: '自动朗读',
43
- automaticSpeechHint: '朗读最终回复。手动重播始终可用。',
44
- replayFullRead: '重播完整朗读',
45
- replayFullReadHint: '手动重播时跳过超长文本的标题截断,完整朗读。',
46
- queueAllMessages: '入队所有消息',
47
- queueAllMessagesHint: '每一条 assistant 消息到达即入队朗读(中间消息也读,FIFO),而不只读最终回复。',
48
- throttleMs: '合并延迟(毫秒)',
49
- throttleMsHint: '回复文本等待多久才播报(合并同一回复的多步消息)。',
50
- cleanMarkdown: '清理 Markdown',
51
- cleanMarkdownHint: '把 Markdown 转成自然的语音文本。',
52
- markdownCleaning: 'Markdown 清理',
53
- readInlineCode: '朗读行内代码',
54
- readInlineCodeHint: '朗读行内代码(去掉反引号标记)。',
55
- codeBlocks: '代码块',
56
- codeBlocksHint: '围栏代码块如何朗读。',
57
- codeBlocksAll: '全部朗读',
58
- codeBlocksSmart: '智能',
59
- codeBlocksReplace: '全部替换',
60
- codeBlockMaxChars: '代码块最大字数',
61
- codeBlockMaxCharsHint: '仅用于智能模式。',
62
- codeBlockReplacementText: '代码块替换文本',
63
- codeBlockReplacementTextHint: '代码块被替换时朗读的文本。',
64
- maxChars: '最大朗读字数',
65
- maxCharsHint: 'macOS 上 0 = 不限;Windows 保留安全默认值。',
66
- volume: '音量',
67
- volumeHint: '仅 Windows(0-100);macOS 音量跟随系统。',
68
- rate: '语速',
69
- rateHintWin: 'Windows SAPI 语速(-10 到 10,0 = 正常;推荐 0,稍快 1-3)。',
70
- rateHintMac: 'words-per-minute(wpm),数值越大语速越快:默认 175,稍快 200。',
71
- engine: '引擎路径',
72
- engineHint: '自定义引擎脚本路径;留空自动解析(包内引擎 → ~/.dsh/hooks)。',
73
- longTextBehavior: '超长文本行为',
74
- longTextBehaviorHint: '超过正数上限时如何处理。',
75
- longTextMessageOption: '朗读替换提示语',
76
- longTextHeadingOption: '朗读最大标题',
77
- fixedPrompt: '固定提示语',
78
- fixedPromptHint: '超长文本(message 模式)时朗读的提示语。',
79
- announceApprovals: '播报审批',
80
- announceApprovalsHint: '朗读审批请求。',
81
- stripApprovalPrefix: '剥离审批前缀',
82
- stripApprovalPrefixHint: '剥离审批原因里的固定英文模板前缀(escalate sandbox to …: )。',
83
- announceQuestions: '播报提问',
84
- announceQuestionsHint: '朗读 ask_user_question 问题。',
85
- questionGap: '问题间隔(毫秒)',
86
- questionGapHint: '多个问题播报之间的停顿。',
87
- optionalEvents: '可选事件播报',
88
- turnEnd: '回合结束',
89
- turnEndHint: '一轮对话结束时播报。',
90
- commandDone: '命令完成',
91
- commandDoneHint: '命令完成或失败时播报。',
92
- goalChange: '目标变更',
93
- goalChangeHint: '目标创建/更新/完成时播报。',
94
- toolErrors: '工具出错',
95
- toolErrorsHint: '工具调用失败时播报错误摘要。',
96
- todoWrite: '待办更新',
97
- todoWriteHint: 'agent 更新待办列表时播报。',
98
- }
99
- const en = {
100
- nav: 'dsh-speak settings',
101
- settingsAria: 'dsh-speak settings',
102
- settingsIntro: 'Speech preferences for automatic announcements and per-message replay.',
103
- settingsTitle: 'dsh-speak',
104
- actionSpeakTurn: 'Speak turn',
105
- actionStop: 'Stop speaking',
106
- toggleOn: 'On',
107
- toggleOff: 'Off',
108
- masterSwitch: 'Master Switch',
109
- masterSwitchHint: 'Temporarily enable or disable all announcements.',
110
- automaticSpeech: 'Automatic Speech',
111
- automaticSpeechHint: 'Speaks final assistant responses. Manual replay always remains available.',
112
- replayFullRead: 'Full Read on Replay',
113
- replayFullReadHint: 'When replaying, read the full text instead of the heading fallback.',
114
- queueAllMessages: 'Queue All Messages',
115
- queueAllMessagesHint: 'Also speaks intermediate assistant messages in a FIFO queue, not only the final reply.',
116
- throttleMs: 'Merge Delay (ms)',
117
- throttleMsHint: 'How long a reply waits before being announced (merges multi-step messages).',
118
- cleanMarkdown: 'Clean Markdown Formatting',
119
- cleanMarkdownHint: 'Converts Markdown into natural speech text.',
120
- markdownCleaning: 'Markdown cleaning',
121
- readInlineCode: 'Read Inline Code',
122
- readInlineCodeHint: 'Reads inline code without backtick markers.',
123
- codeBlocks: 'Code Blocks',
124
- codeBlocksHint: 'Choose how fenced code blocks are spoken.',
125
- codeBlocksAll: 'Read all',
126
- codeBlocksSmart: 'Smart',
127
- codeBlocksReplace: 'Replace all',
128
- codeBlockMaxChars: 'Code Block Max Characters',
129
- codeBlockMaxCharsHint: 'Only used by Smart code blocks.',
130
- codeBlockReplacementText: 'Code Block Replacement Text',
131
- codeBlockReplacementTextHint: 'Used when a code block is replaced.',
132
- maxChars: 'Max Speech Characters',
133
- maxCharsHint: '0 is unlimited on macOS; Windows keeps its safe default.',
134
- volume: 'Volume',
135
- volumeHint: 'Windows only (0-100); macOS volume follows the system.',
136
- rate: 'Speech Rate',
137
- rateHintWin: 'Windows SAPI rate scale (-10 to 10, 0 = normal; try 1-3 for faster).',
138
- rateHintMac: 'words-per-minute (wpm) — higher = faster: default 175, 200 is a bit faster.',
139
- engine: 'Engine Path',
140
- engineHint: 'Custom engine script path; empty = auto-resolve (package engine → ~/.dsh/hooks).',
141
- longTextBehavior: 'Long Text Behavior',
142
- longTextBehaviorHint: 'When a positive maximum is exceeded.',
143
- longTextMessageOption: 'Read replacement message',
144
- longTextHeadingOption: 'Read largest heading',
145
- fixedPrompt: 'Fixed Prompt',
146
- fixedPromptHint: 'The prompt spoken when the text is too long (message mode).',
147
- announceApprovals: 'Announce Approvals',
148
- announceApprovalsHint: 'Announces approval requests.',
149
- stripApprovalPrefix: 'Strip Approval Prefix',
150
- stripApprovalPrefixHint: 'Strip the fixed English template prefix (escalate sandbox to …:) from approval reasons.',
151
- announceQuestions: 'Announce Questions',
152
- announceQuestionsHint: 'Announces ask-user questions.',
153
- questionGap: 'Question Gap (ms)',
154
- questionGapHint: 'Pause between multiple question announcements.',
155
- optionalEvents: 'Optional event announcements',
156
- turnEnd: 'Turn End',
157
- turnEndHint: 'Announces when a round of conversation ends.',
158
- commandDone: 'Command Done',
159
- commandDoneHint: 'Announces when a command finishes or fails.',
160
- goalChange: 'Goal Change',
161
- goalChangeHint: 'Announces goal created/updated/completed.',
162
- toolErrors: 'Tool Errors',
163
- toolErrorsHint: 'Announces an error summary when a tool call fails.',
164
- todoWrite: 'Todo Write',
165
- todoWriteHint: 'Announces when the agent updates its todos.',
166
- }
167
-
168
- module.exports.inject = ['slots', 'timer', 'settingsScope', 'locale']
169
- module.exports.apply = function apply(ctx) {
170
- ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-speak: dictionaries')
171
- const t = ctx.locale.bind(NS)
172
-
173
- let speechState = { speaking: false, sessionId: null, turn: null, messageId: null, source: null, queueLength: 0 }
174
- const listeners = new Set()
175
- const settings = ctx.settingsScope.bind({ namespace: SETTINGS_NAMESPACE })
176
- const e = React.createElement
177
- function IconVolume2({ size = 20, className }) {
178
- return e('svg', { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', className, 'aria-hidden': 'true' },
179
- e('path', { d: 'M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z' }),
180
- e('path', { d: 'M16 9a5 5 0 0 1 0 6' }), e('path', { d: 'M19.364 18.364a9 9 0 0 0 0-12.728' }),
181
- )
182
- }
183
- function publish(next) {
184
- speechState = next && typeof next === 'object' ? next : { speaking: false, sessionId: null, turn: null, messageId: null, source: null, queueLength: 0 }
185
- for (const listener of listeners) listener()
186
- }
187
- async function control(payload) {
188
- const response = await fetch(CONTROL_PATH, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
189
- if (!response.ok) throw new Error(`dsh-speak control failed (${response.status})`)
190
- const state = await response.json()
191
- if (state && state.type === 'speech-state') publish(state)
192
- return state
193
- }
194
- ctx.effect(() => {
195
- let socket = null
196
- let retry = null
197
- let disposed = false
198
- const connect = () => {
199
- if (disposed) return
200
- const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:'
201
- socket = new WebSocket(`${scheme}//${location.host}${SOCKET_PATH}`)
202
- socket.onmessage = event => {
203
- try {
204
- const state = JSON.parse(event.data)
205
- if (state && state.type === 'speech-state') publish(state)
206
- } catch (e) { console.warn('[dsh-speak] ignored invalid speech websocket state') }
207
- }
208
- socket.onclose = () => {
209
- if (!disposed) retry = ctx.timeout(connect, 1000)
210
- }
211
- socket.onerror = () => { try { socket.close() } catch (e) { /* closed */ } }
212
- }
213
- connect()
214
- return () => {
215
- disposed = true
216
- if (retry) retry()
217
- try { if (socket) socket.close() } catch (e) { /* closed */ }
218
- }
219
- }, 'dsh-speak speech state websocket')
220
- function useSpeechState() {
221
- const [snapshot, setSnapshot] = React.useState(speechState)
222
- React.useEffect(() => { const update = () => setSnapshot(speechState); listeners.add(update); return () => listeners.delete(update) }, [])
223
- return snapshot
224
- }
225
- function visibleText(node) {
226
- return Array.isArray(node && node.blocks) ? node.blocks.filter(block => block && block.kind === 'text' && typeof block.text === 'string').map(block => block.text).join('') : ''
227
- }
228
- function SpeakAction(props) {
229
- const messageId = props.messageId == null ? null : String(props.messageId)
230
- const turnData = props.useSession(snapshot => {
231
- // DSH 会话投影:snapshot.chat.nodes 是按 key 索引的 Map,value 为
232
- // { key, kind, data, location }。最终 assistant 消息内容在节点的
233
- // data.finalNode(assistant 节点)或 data.closing.finalNode
234
- // (turn-tail 节点)里,含 messageId / turn / seq / blocks。
235
- const nodes = snapshot && snapshot.chat && snapshot.chat.nodes
236
- if (!nodes || typeof nodes.values !== 'function') return { turn: null, text: '' }
237
- const all = [...nodes.values()]
238
- const finalOf = node => {
239
- const d = node && node.data
240
- if (!d) return null
241
- if (d.finalNode) return d.finalNode
242
- if (d.closing && d.closing.finalNode) return d.closing.finalNode
243
- return d.kind === 'assistant' ? d : null
244
- }
245
- const entries = all.map(node => ({ final: finalOf(node) }))
246
- const addressed = entries.find(entry => entry.final && String(entry.final.messageId) === messageId)
247
- if (!addressed || !Number.isFinite(addressed.final.turn)) return { turn: null, text: '' }
248
- const turn = addressed.final.turn
249
- // 只重播点击的那条消息(assistant-actions 只渲染在回合尾部 = 最终回复),
250
- // 不合并整个回合的所有中间消息
251
- const text = visibleText(addressed.final)
252
- return { turn, text }
253
- })
254
- const active = useSpeechState()
255
- const speaking = active.speaking && String(active.sessionId) === String(props.sessionId) && active.turn === turnData.turn
256
- const [pending, setPending] = React.useState(false)
257
- const label = speaking ? t('actionStop') : t('actionSpeakTurn')
258
- return e('button', {
259
- type: 'button', className: 'dsh-speak-message-action', 'aria-label': label, 'aria-pressed': speaking,
260
- 'data-speaking': speaking || undefined, title: label, disabled: pending || !turnData.text.trim(),
261
- onClick: () => {
262
- if (pending) return
263
- setPending(true)
264
- const action = speaking ? 'stop' : 'play'
265
- const payload = speaking ? { action } : { action, sessionId: props.sessionId, turn: turnData.turn, messageId, text: turnData.text }
266
- void control(payload).catch(console.error).finally(() => setPending(false))
267
- },
268
- }, speaking ? e(IconPauseOutline16) : e(IconVolume2))
269
- }
270
- function useSettings() {
271
- const [snapshot, setSnapshot] = React.useState(settings.getSnapshot())
272
- React.useEffect(() => settings.subscribe(() => setSnapshot(settings.getSnapshot())), [])
273
- return snapshot
274
- }
275
- function Field({ label, hint, children, inline }) {
276
- return e('div', { className: 'dsh-speak-field' }, inline ? e('div', { className: 'dsh-speak-inline-field' }, e('div', { className: 'dsh-speak-field-label' }, label), children) : e('div', { className: 'dsh-speak-field-label' }, label), inline ? null : children, e('p', { className: 'dsh-speak-field-hint' }, hint))
277
- }
278
- function Toggle({ label, value, disabled, onChange, hint }) { return e(Field, { label: `${label}:`, hint, inline: true }, e('div', { className: 'dsh-speak-option-row' }, e(Button, { variant: 'outline', size: 'sm', disabled, 'aria-pressed': value, onClick: () => onChange(!value) }, value ? t('toggleOn') : t('toggleOff')))) }
279
- function SettingInput({ label, value, disabled, numeric, onChange, hint }) {
280
- const id = `dsh-speak-${label.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`
281
- // 缓冲输入:受控 value 来自配置,直接绑定会让"-"这类中间态被弹回
282
- // (Number('-') = NaN 拒绝后 value 不变),负数字段无法输入。这里用
283
- // 局部 state 缓冲,输入过程自由,onChange 里校验合法后才写配置。
284
- const [text, setText] = React.useState(String(value))
285
- React.useEffect(() => { setText(String(value)) }, [value])
286
- return e(Field, { label: `${label}:`, hint }, e('div', { className: 'dsh-speak-input-row' }, e(Input, { id, value: text, disabled, inputMode: numeric ? 'numeric' : undefined, onChange: event => { setText(event.target.value); onChange(event.target.value) } })))
287
- }
288
- function Options({ label, value, disabled, onChange, hint, options }) { return e(Field, { label, hint }, e('div', { className: 'dsh-speak-option-row' }, ...options.map(option => e(Button, { key: option.value, variant: value === option.value ? 'primary' : 'outline', size: 'sm', disabled, 'aria-pressed': value === option.value, onClick: () => onChange(option.value) }, option.label)))) }
289
- function MarkdownCleaning({ value, clean, disabled, set }) {
290
- const [open, setOpen] = React.useState(true); const controlsDisabled = disabled || !clean
291
- return e(DisclosureRow, { icon: null, title: t('markdownCleaning'), open, expandable: true, onToggle: () => setOpen(!open), expandOnRowClick: true }, e('div', { style: { paddingLeft: '22px' } },
292
- e(Toggle, { label: t('readInlineCode'), value: value.readInlineCode !== false, disabled: controlsDisabled, onChange: next => set('readInlineCode', next), hint: t('readInlineCodeHint') }),
293
- e(Options, { label: t('codeBlocks'), value: value.codeBlocks || 'smart', disabled: controlsDisabled, onChange: next => set('codeBlocks', next), hint: t('codeBlocksHint'), options: [{ value: 'all', label: t('codeBlocksAll') }, { value: 'smart', label: t('codeBlocksSmart') }, { value: 'replace', label: t('codeBlocksReplace') }] }),
294
- e(SettingInput, { label: t('codeBlockMaxChars'), value: value.codeBlockMaxChars == null ? 300 : value.codeBlockMaxChars, numeric: true, disabled: controlsDisabled || value.codeBlocks !== 'smart', onChange: next => { if (/^\d+$/.test(next)) set('codeBlockMaxChars', Number(next)) }, hint: t('codeBlockMaxCharsHint') }),
295
- e(SettingInput, { label: t('codeBlockReplacementText'), value: value.codeBlockReplacementText || 'You can see the code in our history.', disabled: controlsDisabled || value.codeBlocks === 'all', onChange: next => set('codeBlockReplacementText', next), hint: t('codeBlockReplacementTextHint') }),
296
- ))
297
- }
298
- function SettingsCard() {
299
- // hooks must run unconditionally (before the ready-guard return)
300
- const [eventsOpen, setEventsOpen] = React.useState(false)
301
- const snapshot = useSettings(); if (snapshot.status !== 'ready' || !snapshot.value) return null
302
- const value = snapshot.value; const disabled = !snapshot.writable; const clean = value.cleanMarkdownFormatting !== false; const set = (field, next) => { void settings.set(field, next).catch(console.error) }
303
- const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent || navigator.platform || '')
304
- return e('section', { 'aria-label': t('settingsAria') }, e('h3', null, t('settingsTitle')), e('p', null, t('settingsIntro')),
305
- e(Toggle, { label: t('masterSwitch'), value: value.enabled !== false, disabled, onChange: next => set('enabled', next), hint: t('masterSwitchHint') }),
306
- e(Toggle, { label: t('automaticSpeech'), value: value.automaticSpeech !== false, disabled, onChange: next => set('automaticSpeech', next), hint: t('automaticSpeechHint') }),
307
- e(Toggle, { label: t('replayFullRead'), value: value.replayFullRead === true, disabled, onChange: next => set('replayFullRead', next), hint: t('replayFullReadHint') }),
308
- e(Toggle, { label: t('queueAllMessages'), value: value.queueAllMessages === true, disabled: disabled || value.automaticSpeech === false, onChange: next => set('queueAllMessages', next), hint: t('queueAllMessagesHint') }),
309
- e(SettingInput, { label: t('throttleMs'), value: value.throttleMs == null ? 1500 : value.throttleMs, numeric: true, disabled, onChange: next => { if (/^\d+$/.test(next)) set('throttleMs', Number(next)) }, hint: t('throttleMsHint') }),
310
- e(Toggle, { label: t('cleanMarkdown'), value: clean, disabled, onChange: next => set('cleanMarkdownFormatting', next), hint: t('cleanMarkdownHint') }), e(MarkdownCleaning, { value, clean, disabled, set }),
311
- e(SettingInput, { label: t('maxChars'), value: value.maxChars == null ? 0 : value.maxChars, numeric: true, disabled, onChange: next => { if (/^\d+$/.test(next)) set('maxChars', Number(next)) }, hint: t('maxCharsHint') }),
312
- e(SettingInput, { label: t('volume'), value: value.volume == null ? 50 : value.volume, numeric: true, disabled, onChange: next => { if (/^\d+$/.test(next)) set('volume', Number(next)) }, hint: t('volumeHint') }),
313
- e(SettingInput, { label: t('rate'), value: value.rate == null ? 0 : value.rate, numeric: true, disabled, onChange: next => { if (/^-?\d*\.?\d*$/.test(next)) { const n = Number(next); if (Number.isFinite(n)) set('rate', n) } }, hint: isMac ? t('rateHintMac') : t('rateHintWin') }),
314
- e(SettingInput, { label: t('engine'), value: value.engine || '', disabled, onChange: next => set('engine', next), hint: t('engineHint') }),
315
- e(Options, { label: t('longTextBehavior'), value: value.longTextMode || 'message', disabled, onChange: next => set('longTextMode', next), hint: t('longTextBehaviorHint'), options: [{ value: 'message', label: t('longTextMessageOption') }, { value: 'heading', label: t('longTextHeadingOption') }] }),
316
- e(SettingInput, { label: t('fixedPrompt'), value: value.longTextMessage || '本次播报内容较长,请自行阅读。', disabled: disabled || value.longTextMode !== 'message', onChange: next => set('longTextMessage', next), hint: t('fixedPromptHint') }),
317
- e(Toggle, { label: t('announceApprovals'), value: value.announceApprovals !== false, disabled, onChange: next => set('announceApprovals', next), hint: t('announceApprovalsHint') }),
318
- e(Toggle, { label: t('stripApprovalPrefix'), value: value.stripApprovalPrefix !== false, disabled: disabled || value.announceApprovals === false, onChange: next => set('stripApprovalPrefix', next), hint: t('stripApprovalPrefixHint') }),
319
- e(Toggle, { label: t('announceQuestions'), value: value.announceQuestions !== false, disabled, onChange: next => set('announceQuestions', next), hint: t('announceQuestionsHint') }),
320
- e(SettingInput, { label: t('questionGap'), value: value.questionGapMs == null ? 2000 : value.questionGapMs, numeric: true, disabled: disabled || value.announceQuestions === false, onChange: next => { if (/^\d+$/.test(next)) set('questionGapMs', Number(next)) }, hint: t('questionGapHint') }),
321
- e(DisclosureRow, { icon: null, title: t('optionalEvents'), open: eventsOpen, expandable: true, onToggle: () => setEventsOpen(!eventsOpen), expandOnRowClick: true }, e('div', { style: { paddingLeft: '22px' } },
322
- e(Toggle, { label: t('turnEnd'), value: value.announceTurnEnd === true, disabled, onChange: next => set('announceTurnEnd', next), hint: t('turnEndHint') }),
323
- e(Toggle, { label: t('commandDone'), value: value.announceCommandDone === true, disabled, onChange: next => set('announceCommandDone', next), hint: t('commandDoneHint') }),
324
- e(Toggle, { label: t('goalChange'), value: value.announceGoalChange === true, disabled, onChange: next => set('announceGoalChange', next), hint: t('goalChangeHint') }),
325
- e(Toggle, { label: t('toolErrors'), value: value.announceToolErrors === true, disabled, onChange: next => set('announceToolErrors', next), hint: t('toolErrorsHint') }),
326
- e(Toggle, { label: t('todoWrite'), value: value.announceTodoWrite === true, disabled, onChange: next => set('announceTodoWrite', next), hint: t('todoWriteHint') }),
327
- )),
328
- )
329
- }
330
- ctx.effect(() => {
331
- const style = document.createElement('style'); style.dataset.plugin = 'dsh-speak'
332
- style.textContent = '.dsh-speak-message-action{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:5px;border:0;border-radius:28px;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;font:inherit;font-size:14px;line-height:1}.dsh-speak-message-action:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}.dsh-speak-message-action[data-speaking]{color:var(--dsw-alias-label-primary)}.dsh-speak-message-action:disabled{cursor:default;opacity:.4}.dsh-speak-field{display:flex;flex-direction:column;gap:8px;margin:0 0 20px}.dsh-speak-field-label{font-weight:600;color:var(--dsw-alias-label-primary)}.dsh-speak-inline-field{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.dsh-speak-inline-field>.dsh-speak-field-label{flex:none}.dsh-speak-field-hint{margin:0;color:var(--dsw-alias-label-tertiary)}.dsh-speak-field+.dsh-speak-field{margin-top:20px}.dsh-speak-option-row{display:inline-flex;align-items:center;gap:8px;width:max-content;max-width:100%}.dsh-speak-input-row{display:flex;max-width:100%}.dsh-speak-input-row>span{max-width:100%}'
333
- document.head.appendChild(style); return () => style.remove()
334
- }, 'dsh-speak message action styles')
335
- ctx.slots.inject('conversation.chat.assistant-actions', () => ctx.slots.register({ name: 'conversation.chat.assistant-actions', id: 'speak', order: 5, label: 'Speak', locale: NS }, SpeakAction))
336
- ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'speak', order: 25, label: () => t('nav'), locale: NS }, SettingsCard))
337
- }
338
- return module.exports
339
- },
340
- })
1
+ // client.js — dsh-speak browser half: per-message Speak/Stop/Replay button,
2
+ // speech-state WebSocket, and the native dsh-speak settings page.
3
+ // ==============================================================================
4
+ // Merged bundle (1.7.0): victorwads' PR #2 UI (message action + Settings page,
5
+ // styled with @deepseek-ai/dsh-client-ui-primitives) plus the dsh-speak-specific
6
+ // options (master switch, optional event announcements, editable fixed prompt,
7
+ // queue-all-messages mode). All UI copy is bilingual (zh/en) via the DSH locale
8
+ // service; the settings page entry is "dsh-speak 设置 / dsh-speak settings".
9
+ //
10
+ // Host contract (adapters/dsh/speech-hook.js):
11
+ // * /dsh-speak/control — POST { action: 'play'|'stop'|'status', ... }
12
+ // * /dsh-speak/ws — WebSocket publishing { type: 'speech-state', ... }
13
+ // * settings namespace 'dsh-speak' (installSettingsSection)
14
+ //
15
+ // The bundle is deliberately hand-written (no build step) and only uses
16
+ // platform seed modules + official primitives (bundle-purity gate).
17
+ 'use strict'
18
+
19
+ window.__ModuleLoader__.load({
20
+ id: 'dsh-speak',
21
+ factory: require => {
22
+ const module = { exports: {} }
23
+ const React = require('react')
24
+ const { Button, DisclosureRow, IconPauseOutline16, Input } = require('@deepseek-ai/dsh-client-ui-primitives')
25
+ const CONTROL_PATH = '/dsh-speak/control'
26
+ const SOCKET_PATH = '/dsh-speak/ws'
27
+ const SETTINGS_NAMESPACE = 'dsh-speak'
28
+
29
+ // ---- locale copy (zh / en) -------------------------------------------
30
+ const NS = 'dsh-speak'
31
+ const zh = {
32
+ nav: 'dsh-speak 设置',
33
+ settingsAria: 'dsh-speak 设置',
34
+ settingsIntro: '自动播报与逐条重播的语音偏好。',
35
+ settingsTitle: 'dsh-speak',
36
+ actionSpeakTurn: '播报此回合',
37
+ actionStop: '停止播报',
38
+ toggleOn: '开',
39
+ toggleOff: '关',
40
+ masterSwitch: '总开关',
41
+ masterSwitchHint: '临时开启或关闭所有播报。',
42
+ automaticSpeech: '自动朗读',
43
+ automaticSpeechHint: '朗读最终回复。手动重播始终可用。',
44
+ replayFullRead: '重播完整朗读',
45
+ replayFullReadHint: '手动重播时跳过超长文本的标题截断,完整朗读。',
46
+ queueAllMessages: '入队所有消息',
47
+ queueAllMessagesHint: '每一条 assistant 消息到达即入队朗读(中间消息也读,FIFO),而不只读最终回复。',
48
+ throttleMs: '合并延迟(毫秒)',
49
+ throttleMsHint: '回复文本等待多久才播报(合并同一回复的多步消息)。',
50
+ cleanMarkdown: '清理 Markdown',
51
+ cleanMarkdownHint: '把 Markdown 转成自然的语音文本。',
52
+ markdownCleaning: 'Markdown 清理',
53
+ readInlineCode: '朗读行内代码',
54
+ readInlineCodeHint: '朗读行内代码(去掉反引号标记)。',
55
+ codeBlocks: '代码块',
56
+ codeBlocksHint: '围栏代码块如何朗读。',
57
+ codeBlocksAll: '全部朗读',
58
+ codeBlocksSmart: '智能',
59
+ codeBlocksReplace: '全部替换',
60
+ codeBlockMaxChars: '代码块最大字数',
61
+ codeBlockMaxCharsHint: '仅用于智能模式。',
62
+ codeBlockReplacementText: '代码块替换文本',
63
+ codeBlockReplacementTextHint: '代码块被替换时朗读的文本。',
64
+ maxChars: '最大朗读字数',
65
+ maxCharsHint: 'macOS 上 0 = 不限;Windows 保留安全默认值。',
66
+ volume: '音量',
67
+ volumeHint: '仅 Windows(0-100);macOS 音量跟随系统。',
68
+ rate: '语速',
69
+ rateHintWin: 'Windows SAPI 语速(-10 到 10,0 = 正常;推荐 0,稍快 1-3)。',
70
+ rateHintMac: 'words-per-minute(wpm),数值越大语速越快:默认 175,稍快 200。',
71
+ engine: '引擎路径',
72
+ engineHint: '自定义引擎脚本路径;留空自动解析(包内引擎 → ~/.dsh/hooks)。',
73
+ longTextBehavior: '超长文本行为',
74
+ longTextBehaviorHint: '超过正数上限时如何处理。',
75
+ longTextMessageOption: '朗读替换提示语',
76
+ longTextHeadingOption: '朗读最大标题',
77
+ fixedPrompt: '固定提示语',
78
+ fixedPromptHint: '超长文本(message 模式)时朗读的提示语。',
79
+ announceApprovals: '播报审批',
80
+ announceApprovalsHint: '朗读审批请求。',
81
+ stripApprovalPrefix: '剥离审批前缀',
82
+ stripApprovalPrefixHint: '剥离审批原因里的固定英文模板前缀(escalate sandbox to …: )。',
83
+ announceQuestions: '播报提问',
84
+ announceQuestionsHint: '朗读 ask_user_question 问题。',
85
+ questionGap: '问题间隔(毫秒)',
86
+ questionGapHint: '多个问题播报之间的停顿。',
87
+ optionalEvents: '可选事件播报',
88
+ turnEnd: '回合结束',
89
+ turnEndHint: '一轮对话结束时播报。',
90
+ commandDone: '命令完成',
91
+ commandDoneHint: '命令完成或失败时播报。',
92
+ goalChange: '目标变更',
93
+ goalChangeHint: '目标创建/更新/完成时播报。',
94
+ toolErrors: '工具出错',
95
+ toolErrorsHint: '工具调用失败时播报错误摘要。',
96
+ todoWrite: '待办更新',
97
+ todoWriteHint: 'agent 更新待办列表时播报。',
98
+ }
99
+ const en = {
100
+ nav: 'dsh-speak settings',
101
+ settingsAria: 'dsh-speak settings',
102
+ settingsIntro: 'Speech preferences for automatic announcements and per-message replay.',
103
+ settingsTitle: 'dsh-speak',
104
+ actionSpeakTurn: 'Speak turn',
105
+ actionStop: 'Stop speaking',
106
+ toggleOn: 'On',
107
+ toggleOff: 'Off',
108
+ masterSwitch: 'Master Switch',
109
+ masterSwitchHint: 'Temporarily enable or disable all announcements.',
110
+ automaticSpeech: 'Automatic Speech',
111
+ automaticSpeechHint: 'Speaks final assistant responses. Manual replay always remains available.',
112
+ replayFullRead: 'Full Read on Replay',
113
+ replayFullReadHint: 'When replaying, read the full text instead of the heading fallback.',
114
+ queueAllMessages: 'Queue All Messages',
115
+ queueAllMessagesHint: 'Also speaks intermediate assistant messages in a FIFO queue, not only the final reply.',
116
+ throttleMs: 'Merge Delay (ms)',
117
+ throttleMsHint: 'How long a reply waits before being announced (merges multi-step messages).',
118
+ cleanMarkdown: 'Clean Markdown Formatting',
119
+ cleanMarkdownHint: 'Converts Markdown into natural speech text.',
120
+ markdownCleaning: 'Markdown cleaning',
121
+ readInlineCode: 'Read Inline Code',
122
+ readInlineCodeHint: 'Reads inline code without backtick markers.',
123
+ codeBlocks: 'Code Blocks',
124
+ codeBlocksHint: 'Choose how fenced code blocks are spoken.',
125
+ codeBlocksAll: 'Read all',
126
+ codeBlocksSmart: 'Smart',
127
+ codeBlocksReplace: 'Replace all',
128
+ codeBlockMaxChars: 'Code Block Max Characters',
129
+ codeBlockMaxCharsHint: 'Only used by Smart code blocks.',
130
+ codeBlockReplacementText: 'Code Block Replacement Text',
131
+ codeBlockReplacementTextHint: 'Used when a code block is replaced.',
132
+ maxChars: 'Max Speech Characters',
133
+ maxCharsHint: '0 is unlimited on macOS; Windows keeps its safe default.',
134
+ volume: 'Volume',
135
+ volumeHint: 'Windows only (0-100); macOS volume follows the system.',
136
+ rate: 'Speech Rate',
137
+ rateHintWin: 'Windows SAPI rate scale (-10 to 10, 0 = normal; try 1-3 for faster).',
138
+ rateHintMac: 'words-per-minute (wpm) — higher = faster: default 175, 200 is a bit faster.',
139
+ engine: 'Engine Path',
140
+ engineHint: 'Custom engine script path; empty = auto-resolve (package engine → ~/.dsh/hooks).',
141
+ longTextBehavior: 'Long Text Behavior',
142
+ longTextBehaviorHint: 'When a positive maximum is exceeded.',
143
+ longTextMessageOption: 'Read replacement message',
144
+ longTextHeadingOption: 'Read largest heading',
145
+ fixedPrompt: 'Fixed Prompt',
146
+ fixedPromptHint: 'The prompt spoken when the text is too long (message mode).',
147
+ announceApprovals: 'Announce Approvals',
148
+ announceApprovalsHint: 'Announces approval requests.',
149
+ stripApprovalPrefix: 'Strip Approval Prefix',
150
+ stripApprovalPrefixHint: 'Strip the fixed English template prefix (escalate sandbox to …:) from approval reasons.',
151
+ announceQuestions: 'Announce Questions',
152
+ announceQuestionsHint: 'Announces ask-user questions.',
153
+ questionGap: 'Question Gap (ms)',
154
+ questionGapHint: 'Pause between multiple question announcements.',
155
+ optionalEvents: 'Optional event announcements',
156
+ turnEnd: 'Turn End',
157
+ turnEndHint: 'Announces when a round of conversation ends.',
158
+ commandDone: 'Command Done',
159
+ commandDoneHint: 'Announces when a command finishes or fails.',
160
+ goalChange: 'Goal Change',
161
+ goalChangeHint: 'Announces goal created/updated/completed.',
162
+ toolErrors: 'Tool Errors',
163
+ toolErrorsHint: 'Announces an error summary when a tool call fails.',
164
+ todoWrite: 'Todo Write',
165
+ todoWriteHint: 'Announces when the agent updates its todos.',
166
+ }
167
+
168
+ module.exports.inject = ['slots', 'timer', 'settingsScope', 'locale']
169
+ module.exports.apply = function apply(ctx) {
170
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-speak: dictionaries')
171
+ const t = ctx.locale.bind(NS)
172
+
173
+ let speechState = { speaking: false, sessionId: null, turn: null, messageId: null, source: null, queueLength: 0 }
174
+ const listeners = new Set()
175
+ const settings = ctx.settingsScope.bind({ namespace: SETTINGS_NAMESPACE })
176
+ const e = React.createElement
177
+ function IconVolume2({ size = 20, className }) {
178
+ return e('svg', { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', className, 'aria-hidden': 'true' },
179
+ e('path', { d: 'M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z' }),
180
+ e('path', { d: 'M16 9a5 5 0 0 1 0 6' }), e('path', { d: 'M19.364 18.364a9 9 0 0 0 0-12.728' }),
181
+ )
182
+ }
183
+ function publish(next) {
184
+ speechState = next && typeof next === 'object' ? next : { speaking: false, sessionId: null, turn: null, messageId: null, source: null, queueLength: 0 }
185
+ for (const listener of listeners) listener()
186
+ }
187
+ async function control(payload) {
188
+ const response = await fetch(CONTROL_PATH, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
189
+ if (!response.ok) throw new Error(`dsh-speak control failed (${response.status})`)
190
+ const state = await response.json()
191
+ if (state && state.type === 'speech-state') publish(state)
192
+ return state
193
+ }
194
+ ctx.effect(() => {
195
+ let socket = null
196
+ let retry = null
197
+ let disposed = false
198
+ const connect = () => {
199
+ if (disposed) return
200
+ const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:'
201
+ socket = new WebSocket(`${scheme}//${location.host}${SOCKET_PATH}`)
202
+ socket.onmessage = event => {
203
+ try {
204
+ const state = JSON.parse(event.data)
205
+ if (state && state.type === 'speech-state') publish(state)
206
+ } catch (e) { console.warn('[dsh-speak] ignored invalid speech websocket state') }
207
+ }
208
+ socket.onclose = () => {
209
+ if (!disposed) retry = ctx.timeout(connect, 1000)
210
+ }
211
+ socket.onerror = () => { try { socket.close() } catch (e) { /* closed */ } }
212
+ }
213
+ connect()
214
+ return () => {
215
+ disposed = true
216
+ if (retry) retry()
217
+ try { if (socket) socket.close() } catch (e) { /* closed */ }
218
+ }
219
+ }, 'dsh-speak speech state websocket')
220
+ function useSpeechState() {
221
+ const [snapshot, setSnapshot] = React.useState(speechState)
222
+ React.useEffect(() => { const update = () => setSnapshot(speechState); listeners.add(update); return () => listeners.delete(update) }, [])
223
+ return snapshot
224
+ }
225
+ function visibleText(node) {
226
+ return Array.isArray(node && node.blocks) ? node.blocks.filter(block => block && block.kind === 'text' && typeof block.text === 'string').map(block => block.text).join('') : ''
227
+ }
228
+ // Constant selector used when the framework does not hand us a Chat target
229
+ // hook: the selectors then see no snapshot and the button renders disabled
230
+ // instead of crashing its row.
231
+ function useAbsentChat(selector) { return selector(null) }
232
+ /**
233
+ * Find the finalized assistant node carrying `messageId` in the Chat target.
234
+ *
235
+ * DSH >= 0.1.2 excludes Conversation target data from the Session snapshot,
236
+ * so the chat nodes are no longer reachable through `useSession`; the Chat
237
+ * target selector hook (`useChat`, declared by
238
+ * `@deepseek-ai/dsh-client-ui-chat` for every session-scoped slot) is the
239
+ * only read path. `snapshot.nodes` is a ChatNodeStore whose `values()` are
240
+ * the `{ key, kind, data, location }` nodes: the finalized assistant
241
+ * content lives in `data.finalNode` (assistant node) or
242
+ * `data.closing.finalNode` (turn-tail node), carrying messageId / turn /
243
+ * seq / blocks.
244
+ */
245
+ function finalNodeFor(snapshot, messageId) {
246
+ const nodes = snapshot && snapshot.nodes
247
+ if (!nodes || typeof nodes.values !== 'function') return null
248
+ for (const node of nodes.values()) {
249
+ const data = node && node.data
250
+ if (!data) continue
251
+ const final = data.finalNode || (data.closing && data.closing.finalNode) || (node.kind === 'assistant' ? data : null)
252
+ if (final && final.messageId != null && String(final.messageId) === messageId) return final
253
+ }
254
+ return null
255
+ }
256
+ function SpeakAction(props) {
257
+ const messageId = props.messageId == null ? null : String(props.messageId)
258
+ // Both selectors return primitives: `useChat` is a snapshot selector
259
+ // hook, and a fresh object per read would make the subscription churn.
260
+ const useChat = typeof props.useChat === 'function' ? props.useChat : useAbsentChat
261
+ const turn = useChat(snapshot => {
262
+ const final = finalNodeFor(snapshot, messageId)
263
+ return final && Number.isFinite(final.turn) ? final.turn : null
264
+ })
265
+ // 只重播点击的那条消息(assistant-actions 只渲染在回合尾部 = 最终回复),
266
+ // 不合并整个回合的所有中间消息
267
+ const text = useChat(snapshot => {
268
+ const final = finalNodeFor(snapshot, messageId)
269
+ return final ? visibleText(final) : ''
270
+ })
271
+ const active = useSpeechState()
272
+ const speaking = active.speaking && String(active.sessionId) === String(props.sessionId) && active.turn === turn
273
+ const [pending, setPending] = React.useState(false)
274
+ const label = speaking ? t('actionStop') : t('actionSpeakTurn')
275
+ return e('button', {
276
+ type: 'button', className: 'dsh-speak-message-action', 'aria-label': label, 'aria-pressed': speaking,
277
+ 'data-speaking': speaking || undefined, title: label, disabled: pending || !text.trim(),
278
+ onClick: () => {
279
+ if (pending) return
280
+ setPending(true)
281
+ const action = speaking ? 'stop' : 'play'
282
+ const payload = speaking ? { action } : { action, sessionId: props.sessionId, turn, messageId, text }
283
+ void control(payload).catch(console.error).finally(() => setPending(false))
284
+ },
285
+ }, speaking ? e(IconPauseOutline16) : e(IconVolume2))
286
+ }
287
+ function useSettings() {
288
+ const [snapshot, setSnapshot] = React.useState(settings.getSnapshot())
289
+ React.useEffect(() => settings.subscribe(() => setSnapshot(settings.getSnapshot())), [])
290
+ return snapshot
291
+ }
292
+ function Field({ label, hint, children, inline }) {
293
+ return e('div', { className: 'dsh-speak-field' }, inline ? e('div', { className: 'dsh-speak-inline-field' }, e('div', { className: 'dsh-speak-field-label' }, label), children) : e('div', { className: 'dsh-speak-field-label' }, label), inline ? null : children, e('p', { className: 'dsh-speak-field-hint' }, hint))
294
+ }
295
+ function Toggle({ label, value, disabled, onChange, hint }) { return e(Field, { label: `${label}:`, hint, inline: true }, e('div', { className: 'dsh-speak-option-row' }, e(Button, { variant: 'outline', size: 'sm', disabled, 'aria-pressed': value, onClick: () => onChange(!value) }, value ? t('toggleOn') : t('toggleOff')))) }
296
+ function SettingInput({ label, value, disabled, numeric, onChange, hint }) {
297
+ const id = `dsh-speak-${label.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`
298
+ // 缓冲输入:受控 value 来自配置,直接绑定会让"-"这类中间态被弹回
299
+ // (Number('-') = NaN 拒绝后 value 不变),负数字段无法输入。这里用
300
+ // 局部 state 缓冲,输入过程自由,onChange 里校验合法后才写配置。
301
+ const [text, setText] = React.useState(String(value))
302
+ React.useEffect(() => { setText(String(value)) }, [value])
303
+ return e(Field, { label: `${label}:`, hint }, e('div', { className: 'dsh-speak-input-row' }, e(Input, { id, value: text, disabled, inputMode: numeric ? 'numeric' : undefined, onChange: event => { setText(event.target.value); onChange(event.target.value) } })))
304
+ }
305
+ function Options({ label, value, disabled, onChange, hint, options }) { return e(Field, { label, hint }, e('div', { className: 'dsh-speak-option-row' }, ...options.map(option => e(Button, { key: option.value, variant: value === option.value ? 'primary' : 'outline', size: 'sm', disabled, 'aria-pressed': value === option.value, onClick: () => onChange(option.value) }, option.label)))) }
306
+ function MarkdownCleaning({ value, clean, disabled, set }) {
307
+ const [open, setOpen] = React.useState(true); const controlsDisabled = disabled || !clean
308
+ return e(DisclosureRow, { icon: null, title: t('markdownCleaning'), open, expandable: true, onToggle: () => setOpen(!open), expandOnRowClick: true }, e('div', { style: { paddingLeft: '22px' } },
309
+ e(Toggle, { label: t('readInlineCode'), value: value.readInlineCode !== false, disabled: controlsDisabled, onChange: next => set('readInlineCode', next), hint: t('readInlineCodeHint') }),
310
+ e(Options, { label: t('codeBlocks'), value: value.codeBlocks || 'smart', disabled: controlsDisabled, onChange: next => set('codeBlocks', next), hint: t('codeBlocksHint'), options: [{ value: 'all', label: t('codeBlocksAll') }, { value: 'smart', label: t('codeBlocksSmart') }, { value: 'replace', label: t('codeBlocksReplace') }] }),
311
+ e(SettingInput, { label: t('codeBlockMaxChars'), value: value.codeBlockMaxChars == null ? 300 : value.codeBlockMaxChars, numeric: true, disabled: controlsDisabled || value.codeBlocks !== 'smart', onChange: next => { if (/^\d+$/.test(next)) set('codeBlockMaxChars', Number(next)) }, hint: t('codeBlockMaxCharsHint') }),
312
+ e(SettingInput, { label: t('codeBlockReplacementText'), value: value.codeBlockReplacementText || 'You can see the code in our history.', disabled: controlsDisabled || value.codeBlocks === 'all', onChange: next => set('codeBlockReplacementText', next), hint: t('codeBlockReplacementTextHint') }),
313
+ ))
314
+ }
315
+ function SettingsCard() {
316
+ // hooks must run unconditionally (before the ready-guard return)
317
+ const [eventsOpen, setEventsOpen] = React.useState(false)
318
+ const snapshot = useSettings(); if (snapshot.status !== 'ready' || !snapshot.value) return null
319
+ const value = snapshot.value; const disabled = !snapshot.writable; const clean = value.cleanMarkdownFormatting !== false; const set = (field, next) => { void settings.set(field, next).catch(console.error) }
320
+ const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent || navigator.platform || '')
321
+ return e('section', { 'aria-label': t('settingsAria') }, e('h3', null, t('settingsTitle')), e('p', null, t('settingsIntro')),
322
+ e(Toggle, { label: t('masterSwitch'), value: value.enabled !== false, disabled, onChange: next => set('enabled', next), hint: t('masterSwitchHint') }),
323
+ e(Toggle, { label: t('automaticSpeech'), value: value.automaticSpeech !== false, disabled, onChange: next => set('automaticSpeech', next), hint: t('automaticSpeechHint') }),
324
+ e(Toggle, { label: t('replayFullRead'), value: value.replayFullRead === true, disabled, onChange: next => set('replayFullRead', next), hint: t('replayFullReadHint') }),
325
+ e(Toggle, { label: t('queueAllMessages'), value: value.queueAllMessages === true, disabled: disabled || value.automaticSpeech === false, onChange: next => set('queueAllMessages', next), hint: t('queueAllMessagesHint') }),
326
+ e(SettingInput, { label: t('throttleMs'), value: value.throttleMs == null ? 1500 : value.throttleMs, numeric: true, disabled, onChange: next => { if (/^\d+$/.test(next)) set('throttleMs', Number(next)) }, hint: t('throttleMsHint') }),
327
+ e(Toggle, { label: t('cleanMarkdown'), value: clean, disabled, onChange: next => set('cleanMarkdownFormatting', next), hint: t('cleanMarkdownHint') }), e(MarkdownCleaning, { value, clean, disabled, set }),
328
+ e(SettingInput, { label: t('maxChars'), value: value.maxChars == null ? 0 : value.maxChars, numeric: true, disabled, onChange: next => { if (/^\d+$/.test(next)) set('maxChars', Number(next)) }, hint: t('maxCharsHint') }),
329
+ e(SettingInput, { label: t('volume'), value: value.volume == null ? 50 : value.volume, numeric: true, disabled, onChange: next => { if (/^\d+$/.test(next)) set('volume', Number(next)) }, hint: t('volumeHint') }),
330
+ e(SettingInput, { label: t('rate'), value: value.rate == null ? 0 : value.rate, numeric: true, disabled, onChange: next => { if (/^-?\d*\.?\d*$/.test(next)) { const n = Number(next); if (Number.isFinite(n)) set('rate', n) } }, hint: isMac ? t('rateHintMac') : t('rateHintWin') }),
331
+ e(SettingInput, { label: t('engine'), value: value.engine || '', disabled, onChange: next => set('engine', next), hint: t('engineHint') }),
332
+ e(Options, { label: t('longTextBehavior'), value: value.longTextMode || 'message', disabled, onChange: next => set('longTextMode', next), hint: t('longTextBehaviorHint'), options: [{ value: 'message', label: t('longTextMessageOption') }, { value: 'heading', label: t('longTextHeadingOption') }] }),
333
+ e(SettingInput, { label: t('fixedPrompt'), value: value.longTextMessage || '本次播报内容较长,请自行阅读。', disabled: disabled || value.longTextMode !== 'message', onChange: next => set('longTextMessage', next), hint: t('fixedPromptHint') }),
334
+ e(Toggle, { label: t('announceApprovals'), value: value.announceApprovals !== false, disabled, onChange: next => set('announceApprovals', next), hint: t('announceApprovalsHint') }),
335
+ e(Toggle, { label: t('stripApprovalPrefix'), value: value.stripApprovalPrefix !== false, disabled: disabled || value.announceApprovals === false, onChange: next => set('stripApprovalPrefix', next), hint: t('stripApprovalPrefixHint') }),
336
+ e(Toggle, { label: t('announceQuestions'), value: value.announceQuestions !== false, disabled, onChange: next => set('announceQuestions', next), hint: t('announceQuestionsHint') }),
337
+ e(SettingInput, { label: t('questionGap'), value: value.questionGapMs == null ? 2000 : value.questionGapMs, numeric: true, disabled: disabled || value.announceQuestions === false, onChange: next => { if (/^\d+$/.test(next)) set('questionGapMs', Number(next)) }, hint: t('questionGapHint') }),
338
+ e(DisclosureRow, { icon: null, title: t('optionalEvents'), open: eventsOpen, expandable: true, onToggle: () => setEventsOpen(!eventsOpen), expandOnRowClick: true }, e('div', { style: { paddingLeft: '22px' } },
339
+ e(Toggle, { label: t('turnEnd'), value: value.announceTurnEnd === true, disabled, onChange: next => set('announceTurnEnd', next), hint: t('turnEndHint') }),
340
+ e(Toggle, { label: t('commandDone'), value: value.announceCommandDone === true, disabled, onChange: next => set('announceCommandDone', next), hint: t('commandDoneHint') }),
341
+ e(Toggle, { label: t('goalChange'), value: value.announceGoalChange === true, disabled, onChange: next => set('announceGoalChange', next), hint: t('goalChangeHint') }),
342
+ e(Toggle, { label: t('toolErrors'), value: value.announceToolErrors === true, disabled, onChange: next => set('announceToolErrors', next), hint: t('toolErrorsHint') }),
343
+ e(Toggle, { label: t('todoWrite'), value: value.announceTodoWrite === true, disabled, onChange: next => set('announceTodoWrite', next), hint: t('todoWriteHint') }),
344
+ )),
345
+ )
346
+ }
347
+ ctx.effect(() => {
348
+ const style = document.createElement('style'); style.dataset.plugin = 'dsh-speak'
349
+ style.textContent = '.dsh-speak-message-action{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:5px;border:0;border-radius:28px;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;font:inherit;font-size:14px;line-height:1}.dsh-speak-message-action:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}.dsh-speak-message-action[data-speaking]{color:var(--dsw-alias-label-primary)}.dsh-speak-message-action:disabled{cursor:default;opacity:.4}.dsh-speak-field{display:flex;flex-direction:column;gap:8px;margin:0 0 20px}.dsh-speak-field-label{font-weight:600;color:var(--dsw-alias-label-primary)}.dsh-speak-inline-field{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.dsh-speak-inline-field>.dsh-speak-field-label{flex:none}.dsh-speak-field-hint{margin:0;color:var(--dsw-alias-label-tertiary)}.dsh-speak-field+.dsh-speak-field{margin-top:20px}.dsh-speak-option-row{display:inline-flex;align-items:center;gap:8px;width:max-content;max-width:100%}.dsh-speak-input-row{display:flex;max-width:100%}.dsh-speak-input-row>span{max-width:100%}'
350
+ document.head.appendChild(style); return () => style.remove()
351
+ }, 'dsh-speak message action styles')
352
+ ctx.slots.inject('conversation.chat.assistant-actions', () => ctx.slots.register({ name: 'conversation.chat.assistant-actions', id: 'speak', order: 5, label: 'Speak', locale: NS }, SpeakAction))
353
+ ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'speak', order: 25, label: () => t('nav'), locale: NS }, SettingsCard))
354
+ }
355
+ return module.exports
356
+ },
357
+ })