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.
- package/README.md +166 -72
- package/README.zh-CN.md +160 -71
- package/adapters/dsh/speech-hook.js +498 -102
- package/client/client.js +304 -0
- package/docs/DESIGN.md +122 -30
- package/docs/DESIGN.zh-CN.md +108 -25
- package/engine/speak.ps1 +88 -14
- package/engine/speak.sh +66 -52
- package/package.json +36 -3
package/client/client.js
ADDED
|
@@ -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: **
|
|
6
|
-
|
|
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
|
-
-
|
|
40
|
-
|
|
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
|
-
-
|
|
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
|
|
119
|
-
multi-step messages of one reply;
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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) |
|
|
154
|
-
| text + `ask_user_question` call | ✅
|
|
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` |
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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. `
|
|
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).
|