dsh-speak 1.4.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,304 @@
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
+ cleanMarkdown: '清理 Markdown',
49
+ cleanMarkdownHint: '把 Markdown 转成自然的语音文本。',
50
+ markdownCleaning: 'Markdown 清理',
51
+ readInlineCode: '朗读行内代码',
52
+ readInlineCodeHint: '朗读行内代码(去掉反引号标记)。',
53
+ codeBlocks: '代码块',
54
+ codeBlocksHint: '围栏代码块如何朗读。',
55
+ codeBlocksAll: '全部朗读',
56
+ codeBlocksSmart: '智能',
57
+ codeBlocksReplace: '全部替换',
58
+ codeBlockMaxChars: '代码块最大字数',
59
+ codeBlockMaxCharsHint: '仅用于智能模式。',
60
+ codeBlockReplacementText: '代码块替换文本',
61
+ codeBlockReplacementTextHint: '代码块被替换时朗读的文本。',
62
+ maxChars: '最大朗读字数',
63
+ maxCharsHint: 'macOS 上 0 = 不限;Windows 保留安全默认值。',
64
+ longTextBehavior: '超长文本行为',
65
+ longTextBehaviorHint: '超过正数上限时如何处理。',
66
+ longTextMessageOption: '朗读替换提示语',
67
+ longTextHeadingOption: '朗读最大标题',
68
+ fixedPrompt: '固定提示语',
69
+ fixedPromptHint: '超长文本(message 模式)时朗读的提示语。',
70
+ announceApprovals: '播报审批',
71
+ announceApprovalsHint: '朗读审批请求。',
72
+ announceQuestions: '播报提问',
73
+ announceQuestionsHint: '朗读 ask_user_question 问题。',
74
+ questionGap: '问题间隔(毫秒)',
75
+ questionGapHint: '多个问题播报之间的停顿。',
76
+ optionalEvents: '可选事件播报',
77
+ turnEnd: '回合结束',
78
+ turnEndHint: '一轮对话结束时播报。',
79
+ commandDone: '命令完成',
80
+ commandDoneHint: '命令完成或失败时播报。',
81
+ goalChange: '目标变更',
82
+ goalChangeHint: '目标创建/更新/完成时播报。',
83
+ toolErrors: '工具出错',
84
+ toolErrorsHint: '工具调用失败时播报错误摘要。',
85
+ todoWrite: '待办更新',
86
+ todoWriteHint: 'agent 更新待办列表时播报。',
87
+ }
88
+ const en = {
89
+ nav: 'dsh-speak settings',
90
+ settingsAria: 'dsh-speak settings',
91
+ settingsIntro: 'Speech preferences for automatic announcements and per-message replay.',
92
+ settingsTitle: 'dsh-speak',
93
+ actionSpeakTurn: 'Speak turn',
94
+ actionStop: 'Stop speaking',
95
+ toggleOn: 'On',
96
+ toggleOff: 'Off',
97
+ masterSwitch: 'Master Switch',
98
+ masterSwitchHint: 'Temporarily enable or disable all announcements.',
99
+ automaticSpeech: 'Automatic Speech',
100
+ automaticSpeechHint: 'Speaks final assistant responses. Manual replay always remains available.',
101
+ replayFullRead: 'Full Read on Replay',
102
+ replayFullReadHint: 'When replaying, read the full text instead of the heading fallback.',
103
+ queueAllMessages: 'Queue All Messages',
104
+ queueAllMessagesHint: 'Also speaks intermediate assistant messages in a FIFO queue, not only the final reply.',
105
+ cleanMarkdown: 'Clean Markdown Formatting',
106
+ cleanMarkdownHint: 'Converts Markdown into natural speech text.',
107
+ markdownCleaning: 'Markdown cleaning',
108
+ readInlineCode: 'Read Inline Code',
109
+ readInlineCodeHint: 'Reads inline code without backtick markers.',
110
+ codeBlocks: 'Code Blocks',
111
+ codeBlocksHint: 'Choose how fenced code blocks are spoken.',
112
+ codeBlocksAll: 'Read all',
113
+ codeBlocksSmart: 'Smart',
114
+ codeBlocksReplace: 'Replace all',
115
+ codeBlockMaxChars: 'Code Block Max Characters',
116
+ codeBlockMaxCharsHint: 'Only used by Smart code blocks.',
117
+ codeBlockReplacementText: 'Code Block Replacement Text',
118
+ codeBlockReplacementTextHint: 'Used when a code block is replaced.',
119
+ maxChars: 'Max Speech Characters',
120
+ maxCharsHint: '0 is unlimited on macOS; Windows keeps its safe default.',
121
+ longTextBehavior: 'Long Text Behavior',
122
+ longTextBehaviorHint: 'When a positive maximum is exceeded.',
123
+ longTextMessageOption: 'Read replacement message',
124
+ longTextHeadingOption: 'Read largest heading',
125
+ fixedPrompt: 'Fixed Prompt',
126
+ fixedPromptHint: 'The prompt spoken when the text is too long (message mode).',
127
+ announceApprovals: 'Announce Approvals',
128
+ announceApprovalsHint: 'Announces approval requests.',
129
+ announceQuestions: 'Announce Questions',
130
+ announceQuestionsHint: 'Announces ask-user questions.',
131
+ questionGap: 'Question Gap (ms)',
132
+ questionGapHint: 'Pause between multiple question announcements.',
133
+ optionalEvents: 'Optional event announcements',
134
+ turnEnd: 'Turn End',
135
+ turnEndHint: 'Announces when a round of conversation ends.',
136
+ commandDone: 'Command Done',
137
+ commandDoneHint: 'Announces when a command finishes or fails.',
138
+ goalChange: 'Goal Change',
139
+ goalChangeHint: 'Announces goal created/updated/completed.',
140
+ toolErrors: 'Tool Errors',
141
+ toolErrorsHint: 'Announces an error summary when a tool call fails.',
142
+ todoWrite: 'Todo Write',
143
+ todoWriteHint: 'Announces when the agent updates its todos.',
144
+ }
145
+
146
+ module.exports.inject = ['slots', 'timer', 'settingsScope', 'locale']
147
+ module.exports.apply = function apply(ctx) {
148
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-speak: dictionaries')
149
+ const t = ctx.locale.bind(NS)
150
+
151
+ let speechState = { speaking: false, sessionId: null, turn: null, messageId: null, source: null, queueLength: 0 }
152
+ const listeners = new Set()
153
+ const settings = ctx.settingsScope.bind({ namespace: SETTINGS_NAMESPACE })
154
+ const e = React.createElement
155
+ function IconVolume2({ size = 20, className }) {
156
+ 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' },
157
+ 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' }),
158
+ 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' }),
159
+ )
160
+ }
161
+ function publish(next) {
162
+ speechState = next && typeof next === 'object' ? next : { speaking: false, sessionId: null, turn: null, messageId: null, source: null, queueLength: 0 }
163
+ for (const listener of listeners) listener()
164
+ }
165
+ async function control(payload) {
166
+ const response = await fetch(CONTROL_PATH, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
167
+ if (!response.ok) throw new Error(`dsh-speak control failed (${response.status})`)
168
+ const state = await response.json()
169
+ if (state && state.type === 'speech-state') publish(state)
170
+ return state
171
+ }
172
+ ctx.effect(() => {
173
+ let socket = null
174
+ let retry = null
175
+ let disposed = false
176
+ const connect = () => {
177
+ if (disposed) return
178
+ const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:'
179
+ socket = new WebSocket(`${scheme}//${location.host}${SOCKET_PATH}`)
180
+ socket.onmessage = event => {
181
+ try {
182
+ const state = JSON.parse(event.data)
183
+ if (state && state.type === 'speech-state') publish(state)
184
+ } catch (e) { console.warn('[dsh-speak] ignored invalid speech websocket state') }
185
+ }
186
+ socket.onclose = () => {
187
+ if (!disposed) retry = ctx.timeout(connect, 1000)
188
+ }
189
+ socket.onerror = () => { try { socket.close() } catch (e) { /* closed */ } }
190
+ }
191
+ connect()
192
+ return () => {
193
+ disposed = true
194
+ if (retry) retry()
195
+ try { if (socket) socket.close() } catch (e) { /* closed */ }
196
+ }
197
+ }, 'dsh-speak speech state websocket')
198
+ function useSpeechState() {
199
+ const [snapshot, setSnapshot] = React.useState(speechState)
200
+ React.useEffect(() => { const update = () => setSnapshot(speechState); listeners.add(update); return () => listeners.delete(update) }, [])
201
+ return snapshot
202
+ }
203
+ function visibleText(node) {
204
+ return Array.isArray(node && node.blocks) ? node.blocks.filter(block => block && block.kind === 'text' && typeof block.text === 'string').map(block => block.text).join('') : ''
205
+ }
206
+ function SpeakAction(props) {
207
+ const messageId = props.messageId == null ? null : String(props.messageId)
208
+ const turnData = props.useSession(snapshot => {
209
+ // DSH 会话投影:snapshot.chat.nodes 是按 key 索引的 Map,value 为
210
+ // { key, kind, data, location }。最终 assistant 消息内容在节点的
211
+ // data.finalNode(assistant 节点)或 data.closing.finalNode
212
+ // (turn-tail 节点)里,含 messageId / turn / seq / blocks。
213
+ const nodes = snapshot && snapshot.chat && snapshot.chat.nodes
214
+ if (!nodes || typeof nodes.values !== 'function') return { turn: null, text: '' }
215
+ const all = [...nodes.values()]
216
+ const finalOf = node => {
217
+ const d = node && node.data
218
+ if (!d) return null
219
+ if (d.finalNode) return d.finalNode
220
+ if (d.closing && d.closing.finalNode) return d.closing.finalNode
221
+ return d.kind === 'assistant' ? d : null
222
+ }
223
+ const entries = all.map(node => ({ final: finalOf(node) }))
224
+ const addressed = entries.find(entry => entry.final && String(entry.final.messageId) === messageId)
225
+ if (!addressed || !Number.isFinite(addressed.final.turn)) return { turn: null, text: '' }
226
+ const turn = addressed.final.turn
227
+ // 只重播点击的那条消息(assistant-actions 只渲染在回合尾部 = 最终回复),
228
+ // 不合并整个回合的所有中间消息
229
+ const text = visibleText(addressed.final)
230
+ return { turn, text }
231
+ })
232
+ const active = useSpeechState()
233
+ const speaking = active.speaking && String(active.sessionId) === String(props.sessionId) && active.turn === turnData.turn
234
+ const [pending, setPending] = React.useState(false)
235
+ const label = speaking ? t('actionStop') : t('actionSpeakTurn')
236
+ return e('button', {
237
+ type: 'button', className: 'dsh-speak-message-action', 'aria-label': label, 'aria-pressed': speaking,
238
+ 'data-speaking': speaking || undefined, title: label, disabled: pending || !turnData.text.trim(),
239
+ onClick: () => {
240
+ if (pending) return
241
+ setPending(true)
242
+ const action = speaking ? 'stop' : 'play'
243
+ const payload = speaking ? { action } : { action, sessionId: props.sessionId, turn: turnData.turn, messageId, text: turnData.text }
244
+ void control(payload).catch(console.error).finally(() => setPending(false))
245
+ },
246
+ }, speaking ? e(IconPauseOutline16) : e(IconVolume2))
247
+ }
248
+ function useSettings() {
249
+ const [snapshot, setSnapshot] = React.useState(settings.getSnapshot())
250
+ React.useEffect(() => settings.subscribe(() => setSnapshot(settings.getSnapshot())), [])
251
+ return snapshot
252
+ }
253
+ function Field({ label, hint, children, inline }) {
254
+ 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))
255
+ }
256
+ 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')))) }
257
+ function SettingInput({ label, value, disabled, numeric, onChange, hint }) { const id = `dsh-speak-${label.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`; return e(Field, { label: `${label}:`, hint }, e('div', { className: 'dsh-speak-input-row' }, e(Input, { id, value: String(value), disabled, inputMode: numeric ? 'numeric' : undefined, onChange: event => onChange(event.target.value) }))) }
258
+ 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)))) }
259
+ function MarkdownCleaning({ value, clean, disabled, set }) {
260
+ const [open, setOpen] = React.useState(true); const controlsDisabled = disabled || !clean
261
+ return e(DisclosureRow, { icon: null, title: t('markdownCleaning'), open, expandable: true, onToggle: () => setOpen(!open), expandOnRowClick: true }, e('div', { style: { paddingLeft: '22px' } },
262
+ e(Toggle, { label: t('readInlineCode'), value: value.readInlineCode !== false, disabled: controlsDisabled, onChange: next => set('readInlineCode', next), hint: t('readInlineCodeHint') }),
263
+ 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') }] }),
264
+ 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') }),
265
+ 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') }),
266
+ ))
267
+ }
268
+ function SettingsCard() {
269
+ // hooks must run unconditionally (before the ready-guard return)
270
+ const [eventsOpen, setEventsOpen] = React.useState(false)
271
+ const snapshot = useSettings(); if (snapshot.status !== 'ready' || !snapshot.value) return null
272
+ 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) }
273
+ return e('section', { 'aria-label': t('settingsAria') }, e('h3', null, t('settingsTitle')), e('p', null, t('settingsIntro')),
274
+ e(Toggle, { label: t('masterSwitch'), value: value.enabled !== false, disabled, onChange: next => set('enabled', next), hint: t('masterSwitchHint') }),
275
+ e(Toggle, { label: t('automaticSpeech'), value: value.automaticSpeech !== false, disabled, onChange: next => set('automaticSpeech', next), hint: t('automaticSpeechHint') }),
276
+ e(Toggle, { label: t('replayFullRead'), value: value.replayFullRead === true, disabled, onChange: next => set('replayFullRead', next), hint: t('replayFullReadHint') }),
277
+ e(Toggle, { label: t('queueAllMessages'), value: value.queueAllMessages === true, disabled: disabled || value.automaticSpeech === false, onChange: next => set('queueAllMessages', next), hint: t('queueAllMessagesHint') }),
278
+ e(Toggle, { label: t('cleanMarkdown'), value: clean, disabled, onChange: next => set('cleanMarkdownFormatting', next), hint: t('cleanMarkdownHint') }), e(MarkdownCleaning, { value, clean, disabled, set }),
279
+ 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') }),
280
+ 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') }] }),
281
+ e(SettingInput, { label: t('fixedPrompt'), value: value.longTextMessage || '本次播报内容较长,请自行阅读。', disabled: disabled || value.longTextMode !== 'message', onChange: next => set('longTextMessage', next), hint: t('fixedPromptHint') }),
282
+ e(Toggle, { label: t('announceApprovals'), value: value.announceApprovals !== false, disabled, onChange: next => set('announceApprovals', next), hint: t('announceApprovalsHint') }),
283
+ e(Toggle, { label: t('announceQuestions'), value: value.announceQuestions !== false, disabled, onChange: next => set('announceQuestions', next), hint: t('announceQuestionsHint') }),
284
+ 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') }),
285
+ e(DisclosureRow, { icon: null, title: t('optionalEvents'), open: eventsOpen, expandable: true, onToggle: () => setEventsOpen(!eventsOpen), expandOnRowClick: true }, e('div', { style: { paddingLeft: '22px' } },
286
+ e(Toggle, { label: t('turnEnd'), value: value.announceTurnEnd === true, disabled, onChange: next => set('announceTurnEnd', next), hint: t('turnEndHint') }),
287
+ e(Toggle, { label: t('commandDone'), value: value.announceCommandDone === true, disabled, onChange: next => set('announceCommandDone', next), hint: t('commandDoneHint') }),
288
+ e(Toggle, { label: t('goalChange'), value: value.announceGoalChange === true, disabled, onChange: next => set('announceGoalChange', next), hint: t('goalChangeHint') }),
289
+ e(Toggle, { label: t('toolErrors'), value: value.announceToolErrors === true, disabled, onChange: next => set('announceToolErrors', next), hint: t('toolErrorsHint') }),
290
+ e(Toggle, { label: t('todoWrite'), value: value.announceTodoWrite === true, disabled, onChange: next => set('announceTodoWrite', next), hint: t('todoWriteHint') }),
291
+ )),
292
+ )
293
+ }
294
+ ctx.effect(() => {
295
+ const style = document.createElement('style'); style.dataset.plugin = 'dsh-speak'
296
+ 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%}'
297
+ document.head.appendChild(style); return () => style.remove()
298
+ }, 'dsh-speak message action styles')
299
+ ctx.slots.inject('conversation.chat.assistant-actions', () => ctx.slots.register({ name: 'conversation.chat.assistant-actions', id: 'speak', order: 5, label: 'Speak', locale: NS }, SpeakAction))
300
+ ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'speak', order: 25, label: () => t('nav'), locale: NS }, SettingsCard))
301
+ }
302
+ return module.exports
303
+ },
304
+ })
package/docs/DESIGN.md CHANGED
@@ -2,9 +2,8 @@
2
2
 
3
3
  English · [中文](DESIGN.zh-CN.md)
4
4
 
5
- Status: **draft** — this document describes the current (proven) local implementation
6
- and the target generic structure of this repository. It is the reference for the
7
- README.
5
+ Status: **maintained** — this document describes the current implementation and
6
+ repository structure. It is the reference for the README.
8
7
 
9
8
  ---
10
9
 
@@ -36,12 +35,12 @@ Goals:
36
35
 
37
36
  Non-goals (for now):
38
37
 
39
- - Both engines — Windows `speak.ps1` and macOS `speak.sh` (built-in `say`) — are
40
- **officially supported** (macOS ships in the npm package since 1.2.0).
41
- Linux/headless TTS is not supported.
38
+ - Linux/headless TTS is not supported (Windows uses `speak.ps1` + SAPI5; macOS
39
+ uses `speak.sh` + the built-in `say`, shipped in the npm package since 1.2.0).
42
40
  - In-repo packaging of NaturalVoiceSAPIAdapter (Windows 10 only) or voice data —
43
41
  they are prerequisites, not bundled.
44
- - Streaming/queued playback, per-voice audio files, non-Chinese voice curation.
42
+ - Per-voice audio files, non-Chinese voice curation (the speech **playback
43
+ queue** is already implemented in 1.7.0 — see §3.2's host FIFO queue).
45
44
 
46
45
  ## 3. Architecture
47
46
 
@@ -115,15 +114,38 @@ no "reply finished" hook, so the plugin observes the session event stream:
115
114
  - listens to `session/event`;
116
115
  - filters `assistant/message` events with `surfaceOp == 'append'`;
117
116
  - extracts only `text` content blocks (reasoning / tool_use blocks are skipped);
118
- - buffers the text and starts a throttle timer (default 1500 ms) to merge
119
- multi-step messages of one reply;
120
- - a `tool/call` event **cancels** the pending announcement — that round's assistant
121
- text is process narration, not the final reply;
122
- - on fire: writes the text to a temp file and `spawn`s
123
- `powershell.exe -File <engine> -File <tmp>` with `windowsHide` + `stdio: 'ignore'`
124
- so the harness is never blocked; the temp file is deleted on exit.
125
-
126
- Registration snippet (also automated by `install.ps1`):
117
+ - default mode: buffers the text and starts a throttle timer (default 1500 ms) to
118
+ merge multi-step messages of one reply; a `tool/call` event **cancels** the
119
+ pending announcement (that round's assistant text is process narration), but
120
+ **`turn/end` fallback-announces the final reply** (tool-calling replies are
121
+ still heard);
122
+ - **host speech queue** (1.7.0, from PR #2): every announcement (final reply,
123
+ approvals, questions, optional events, manual replay) goes through a FIFO
124
+ queue — only one native speech process runs at a time, queued items continue
125
+ automatically. A `/dsh-speak/control` POST route (play/stop/status) and a
126
+ `/dsh-speak/ws` WebSocket broadcast the authoritative speech state (which
127
+ message is speaking, queue length).
128
+ - **Final-reply replay** (1.7.0): the 🔊 button in the turn-tail (final reply)
129
+ action bar calls the control route to replay **that final message**; speech
130
+ execution stays fully owned by the host (keeps speaking even with the browser
131
+ closed).
132
+ - **`queueAllMessages` switch** (1.7.0, default off): off = throttled final
133
+ reply + optional events (as before); on = every assistant message is enqueued
134
+ immediately (intermediate messages spoken too).
135
+ - **Optional event announcements** (1.6.0, all off by default): `turn/end`,
136
+ `command/done`, `goal/change`, `tool/result` (on error), and `todo/write` each
137
+ have an independent toggle and announce a fixed phrase on fire (see §5).
138
+ - **Settings namespace registration** (1.6.0): one timer tick after apply the
139
+ plugin calls `installSettingsSection(ctx, 'dsh-speak', schema, patchConfig,
140
+ hooks)`, resolving config as schema default → patch `config` → UI user layer.
141
+ `onChange` re-derives `cfg` from a saved `settingsSource()` thunk (note:
142
+ `installSettingsSection` only calls `setSource` on attach/detach, so changes
143
+ must be re-read in `onChange`). On hosts without a settings service (dsh <
144
+ 0.1.0-rc.7 or no provider mounted) the registration is skipped silently and
145
+ the plugin works purely from the patch config — backward compatible.
146
+
147
+ Registration snippet (also automated by `install.ps1`; npm installs use the bare
148
+ package name `'dsh-speak'` — this is the file-install path):
127
149
 
128
150
  ```yaml
129
151
  # ~/.dsh/profiles/web/cordis.patch.yml
@@ -136,6 +158,32 @@ Registration snippet (also automated by `install.ps1`):
136
158
  > Node's ESM loader does not accept Windows absolute paths as plugin names — the
137
159
  > `file:///C:/...` URL form is required.
138
160
 
161
+ ### 3.4 DSH browser half — `client/client.js`
162
+
163
+ A DSH client bundle (`window.__ModuleLoader__.load({ id: 'dsh-speak', factory })`)
164
+ that registers two pieces of UI:
165
+
166
+ - **Turn-tail Speak button** (1.7.0, from PR #2): registered into the
167
+ `conversation.chat.assistant-actions` slot (the turn's final-reply action bar).
168
+ Clicking 🔊 POSTs to `/dsh-speak/control` to replay that final message; clicking
169
+ again stops; clicking another switches. The button's speaking/paused state is
170
+ derived from the authoritative host state over the `/dsh-speak/ws` WebSocket
171
+ (matched by session + turn identity).
172
+ - **Settings → dsh-speak settings page** (1.7.0): registered into the
173
+ `settings.section` slot, drawn with `@deepseek-ai/dsh-client-ui-primitives`
174
+ (Button / DisclosureRow / Input; Toggle / Options / SettingInput helpers). Every
175
+ option (master switch, automatic speech, queueAllMessages, Markdown cleaning,
176
+ code blocks, maxChars, longTextMode, fixed prompt, approvals/questions, the five
177
+ optional events) is read/written through `settingsScope.bind({ namespace:
178
+ 'dsh-speak' })`.
179
+
180
+ - The package declares its browser half via `package.json`
181
+ `dsh.client: { platform: 'web' }` + `exports['./client']`; DSH's client-modules
182
+ scanner picks it up and loads it automatically.
183
+ - **Deliberately handwritten, zero build**: it only uses platform seed modules
184
+ and official primitives (the bundle-purity gate allows primitives but forbids
185
+ importing official package internals), matching the built bundles' contract.
186
+
139
187
  ### 3.3 Claude Code adapter — `adapters/claude-code/stop-hook.ps1`
140
188
 
141
189
  Claude Code *does* have a Stop hook. The hook JSON (with `transcript_path`) arrives
@@ -150,11 +198,18 @@ returns immediately. (Async spawning is safe here — the nested-spawn restricti
150
198
  | assistant round / event | announced? |
151
199
  | -------------------------------- | ----------- |
152
200
  | final text reply, no tool call | ✅ after throttle |
153
- | text + tool/call(s) | ❌ (cancelled — narration) |
154
- | text + `ask_user_question` call | ✅ the question text is kept and announced |
201
+ | text + tool/call(s) | 🟡 throttle cancelled (intermediate); **fallback-announced at turn end** |
202
+ | text + `ask_user_question` call | ✅ each question announced separately: "问题N" prefix (when several) + "选项N" numbering, `questionGapMs` pause between questions |
155
203
  | `approval/asked` | ✅ immediately (reason, else a fixed prompt) |
156
204
  | reasoning only, no text | ❌ (no text block) |
157
205
  | streaming chunks | ❌ (filtered) |
206
+ | `turn/end` | 🟡 off by default; announces "第 N 轮对话完成/中断/异常结束" |
207
+ | `command/done` | 🟡 off by default; announces "命令执行完成/失败" |
208
+ | `goal/change` | 🟡 off by default; announces "已创建目标/目标已完成…" (head) |
209
+ | `tool/result` | 🟡 off by default; announces "工具调用出错" only when `error` or an `isError` content block is present (English details / technical codes are dropped, Chinese details kept) |
210
+ | `todo/write` | 🟡 off by default; announces "待办已更新:n/m 完成" |
211
+ | `assistant/message` (queueAllMessages on) | ✅ every message enqueued immediately (intermediate spoken too) |
212
+ | manual replay (per-message 🔊) | ✅ clear queue → stop current → speak that turn |
158
213
 
159
214
  ## 5. Configuration reference
160
215
 
@@ -166,15 +221,53 @@ returns immediately. (Async spawning is safe here — the nested-spawn restricti
166
221
  | `-File` | `''` | UTF-8 file to read |
167
222
  | `-Volume` | `50` | 0–100 |
168
223
  | `-Rate` | `1` | speech rate (SAPI scale) |
169
- | `-MaxChars` | `300` | beyond this, replaced by `LongTextMessage` |
224
+ | `-MaxChars` | platform | beyond this, replaced by `LongTextMessage` (macOS default 0 = unlimited) |
170
225
  | `-LongTextMessage`| `本次播报内容较长,请自行阅读。` | spoken instead of over-long text |
226
+ | `-LongTextMode` | `message` | `message` (fixed prompt) \| `heading` (speak the largest markdown heading) |
227
+ | `-CleanMarkdownFormatting` | `true` | convert Markdown to natural speech (link labels kept, URLs stripped) |
228
+ | `-ReadInlineCode` | `true` | read inline code without backtick markers |
229
+ | `-CodeBlocks` | `smart` | `all` \| `smart` \| `replace` (fenced code blocks) |
230
+ | `-CodeBlockMaxChars` | `300` | smart-mode code block character limit |
231
+ | `-CodeBlockReplacementText` | `You can see the code in our history.` | spoken in replace mode |
232
+
233
+ ### DSH plugin (profile `config`; since 1.7.0 also editable in the Settings → dsh-speak settings page)
234
+
235
+ ```yaml
236
+ config:
237
+ enabled: true # master switch
238
+ automaticSpeech: true # auto-speak final replies
239
+ queueAllMessages: false # true = enqueue every assistant message
240
+ replayFullRead: false # true = manual replay skips the long-text truncation
241
+ cleanMarkdownFormatting: true
242
+ readInlineCode: true
243
+ codeBlocks: smart # all | smart | replace
244
+ codeBlockMaxChars: 300
245
+ codeBlockReplacementText: 'You can see the code in our history.'
246
+ throttleMs: 1500
247
+ engine: '' # '' = auto-resolve
248
+ announceApprovals: true
249
+ announceQuestions: true
250
+ stripApprovalPrefix: true
251
+ questionGapMs: 2000 # pause between multiple question announcements (ms)
252
+ longTextMode: message # message | heading
253
+ longTextMessage: '本次播报内容较长,请自行阅读。'
254
+ maxChars: 300 # macOS default 0 = unlimited
255
+ volume: 50 # Windows only
256
+ rate: 0 # 0 = engine default
257
+ # —— optional event announcements (off by default) ——
258
+ announceTurnEnd: false # turn/end
259
+ announceCommandDone: false # command/done
260
+ announceGoalChange: false # goal/change
261
+ announceToolErrors: false # tool/result with error or isError block
262
+ announceTodoWrite: false # todo/write
263
+ ```
171
264
 
172
- ### DSH plugin (environment variables)
265
+ Resolution order: schema default → patch `config` (base) → UI user layer. The
266
+ browser dsh-speak settings page (`client/client.js`) and the patch YAML read/write
267
+ the same settings document. Platform note: `maxChars` defaults to 0 on macOS
268
+ (`say` has no ceiling) and 300 on Windows (SAPI safe limit).
173
269
 
174
- | var | default | meaning |
175
- | -------------------- | ---------------------------------------- | ------------------------------ |
176
- | `DSH_SPEAK_ENGINE` | empty (auto-resolved) | engine path override; otherwise resolved as `<package>/engine/<platform script>` → `~/.dsh/hooks/<platform script>` (Windows: `speak.ps1`, macOS: `speak.sh`) |
177
- | `DSH_SPEAK_THROTTLE_MS` | `1500` | merge delay before announcing |
270
+ Full configuration guide: the README's Configuration section.
178
271
 
179
272
  ## 6. Pitfalls (hard-won; do not "fix" casually)
180
273
 
@@ -203,11 +296,10 @@ by the agent) are the three reference patterns.
203
296
 
204
297
  ## 8. Scope
205
298
 
206
- This project is intentionally **not** a living product. It documents one proven way
207
- to give a harness a voice: a small engine + the two adapter patterns (event-stream
208
- and stop-hook) that worked. If you need more (voice management UI, more backends,
209
- cross-platform), treat the engine as the seam and build on top — this repository
210
- stays as a minimal, self-contained reference implementation.
299
+ This repository stays **small and self-contained**: a small engine plus the two
300
+ adapter patterns (event-stream and stop-hook), and it is **actively maintained**.
301
+ If you need more (voice management UI, more backends, cross-platform), treat the
302
+ engine as the seam and build on top.
211
303
 
212
304
  ## 9. Publishing as an npm plugin (appendix)
213
305
 
@@ -227,7 +319,7 @@ dependencies in the profile). This repository is prepared for that path:
227
319
 
228
320
  `speech-hook.js` locates `engine/speak.ps1` in this order:
229
321
 
230
- 1. `DSH_SPEAK_ENGINE` environment override;
322
+ 1. `config.engine` override;
231
323
  2. `<package>/engine/speak.ps1` resolved relative to the plugin file — covers
232
324
  both a repo checkout and `node_modules/dsh-speak/` after `npm install`;
233
325
  3. legacy `%USERPROFILE%\.dsh\hooks\speak.ps1` (the file-install location).