dsh-speak 1.7.4 → 1.8.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 +45 -4
- package/README.zh-CN.md +38 -2
- package/adapters/dsh/speech-hook.js +91 -38
- package/client/client.js +43 -26
- package/docs/DESIGN.md +38 -14
- package/docs/DESIGN.zh-CN.md +30 -12
- package/engine/speak.ps1 +71 -12
- package/engine/speak.sh +36 -6
- package/engine/speech-prompt.ps1 +2 -0
- package/package.json +5 -9
package/README.md
CHANGED
|
@@ -91,6 +91,18 @@ macOS:
|
|
|
91
91
|
- Chinese voices: see the [macOS](#macos) section (incl. the Siri natural-voice
|
|
92
92
|
picker and its pitfalls).
|
|
93
93
|
|
|
94
|
+
DSH web app:
|
|
95
|
+
|
|
96
|
+
- Tested against **DSH 0.1.5-rc.1**. Two host/client APIs changed after 0.1.1, both
|
|
97
|
+
handled here (1.8.0):
|
|
98
|
+
- `@deepseek-ai/dsh-settings` deleted the `installSettingsSection` /
|
|
99
|
+
`settingsNamespace` helpers — the plugin now registers its namespace through
|
|
100
|
+
the `settings` **service**. On those older releases the plugin aborted the
|
|
101
|
+
host boot (`settingsNamespace is not a function`); a missing settings provider
|
|
102
|
+
now just leaves the composed patch `config` in force.
|
|
103
|
+
- the Session snapshot stopped carrying Conversation target data — the 🔊 button
|
|
104
|
+
resolves the clicked message through the Chat target hook `useChat`.
|
|
105
|
+
|
|
94
106
|
## Install & quick start
|
|
95
107
|
|
|
96
108
|
### DSH — Option A: npm plugin (recommended)
|
|
@@ -324,7 +336,7 @@ document):
|
|
|
324
336
|
| `announceTurnEnd` | `false` | announce "第 N 轮对话完成/中断/异常结束" on turn end (`turn/end`) |
|
|
325
337
|
| `announceCommandDone` | `false` | announce when a command finishes or fails (`command/done`) |
|
|
326
338
|
| `announceGoalChange` | `false` | announce goal created/updated/completed/paused/resumed (`goal/change`, objective head) |
|
|
327
|
-
| `announceToolErrors` | `false` | announce "工具调用出错" when a tool call returns an error
|
|
339
|
+
| `announceToolErrors` | `false` | announce "工具调用出错" when a tool call returns an error: `tool/result` carrying `error` (structured failure identity) or a result block with `isError === true`. A **non-zero shell exit does NOT count** — pwsh/bash report `exit code: N` as result data by design, so only infrastructure failures (spawn errors, aborts) and structured tool failures (e.g. fs) set `isError` (English details / technical codes dropped, Chinese details kept) |
|
|
328
340
|
| `announceTodoWrite` | `false` | announce "待办已更新:n/m 完成" when the agent updates its todos (`todo/write`) |
|
|
329
341
|
|
|
330
342
|
#### Long-text modes
|
|
@@ -334,9 +346,17 @@ When cleaned text exceeds `maxChars`:
|
|
|
334
346
|
- **`message`** (default): speak `longTextMessage` (`本次播报内容较长,请自行阅读。`,
|
|
335
347
|
editable in the UI or YAML).
|
|
336
348
|
- **`heading`**: pick the *largest* markdown heading in the raw text — fewest `#`
|
|
337
|
-
wins, tie → first
|
|
338
|
-
|
|
339
|
-
|
|
349
|
+
wins, tie → first. When there is **no heading at all**, speak a coherent opening
|
|
350
|
+
instead of just the first line: the leading `maxChars` window, trimmed back to
|
|
351
|
+
its last sentence end, and kept whole when that would drop more than half the
|
|
352
|
+
window. Sentence ends are recognised bilingually: full-width `。!?;` and `…`
|
|
353
|
+
always count, while half-width `.!?;` only count when followed by whitespace, a
|
|
354
|
+
closing quote/bracket, or (for the very last character) one read past the window —
|
|
355
|
+
so an English `period + space` at the edge still lands, but a decimal point such
|
|
356
|
+
as `Version 0.1.` does not. (Before 1.8.0 this fallback spoke the first non-empty
|
|
357
|
+
line only, which sounded like the narration stopped after line 1.) The chosen
|
|
358
|
+
candidate is still cleaned and subject to the `maxChars` ceiling, falling back to
|
|
359
|
+
the message if it is itself too long.
|
|
340
360
|
|
|
341
361
|
Full architecture and design rationale: [docs/DESIGN.md](docs/DESIGN.md).
|
|
342
362
|
|
|
@@ -356,6 +376,16 @@ You can tune behavior without forking, and your changes **survive `npm update`**
|
|
|
356
376
|
|
|
357
377
|
Then point the plugin at your copy in the `config` block:
|
|
358
378
|
|
|
379
|
+
> **Windows: keep the file's UTF-8 BOM.** `speak.ps1` is a UTF-8 script and Windows
|
|
380
|
+
> PowerShell 5.1 only knows that from the 3-byte BOM (`EF BB BF`) at the start; an
|
|
381
|
+
> editor that saves it without one makes the system ANSI code page decode it instead,
|
|
382
|
+
> and Chinese text inside the script turns to mojibake — the symptom is **silence or
|
|
383
|
+
> wrong trimming, with no error**. The shipped script keeps all of its *logic* ASCII-only
|
|
384
|
+
> for that reason, so a lost BOM only garbles the Chinese comments and the default
|
|
385
|
+
> prompt. After editing, check with
|
|
386
|
+
> `Get-Content -Encoding Byte -TotalCount 3 your-speak.ps1` (expect `239 187 191`), or
|
|
387
|
+
> run `node scripts/test-engine-static.js`.
|
|
388
|
+
|
|
359
389
|
```yaml
|
|
360
390
|
- insert:
|
|
361
391
|
- id: speech-hook
|
|
@@ -377,6 +407,8 @@ You can tune behavior without forking, and your changes **survive `npm update`**
|
|
|
377
407
|
| ------- | ----- | --- |
|
|
378
408
|
| No sound at all, no error | no natural voice enabled/installed | Win11: enable a natural voice in *Settings → Narrator / Speech*; Win10: install NaturalVoiceSAPIAdapter + a voice pack. Test `speak.ps1` directly |
|
|
379
409
|
| Long replies never spoken | adapter per-`Speak` character ceiling | already guarded at 300 chars — lower `-MaxChars` if needed |
|
|
410
|
+
| Narration stops after the first line | with `longTextMode: heading`, text over `maxChars` and no markdown heading made the engine speak only the first non-empty line (pre-1.8.0) | fixed in 1.8.0 (speaks a coherent opening instead); to change the policy use `message` mode or raise `maxChars` |
|
|
411
|
+
| `工具调用出错:Error: cannot read …` spoken | the "is this Chinese?" detail filter only checked for the presence of a CJK character, so a Chinese directory name inside an English error passed it (1.8.0 regression) | fixed in 1.8.0 — the detail now needs more Chinese characters than Latin letters |
|
|
380
412
|
| Emoji-heavy text silent | SAPI fails silently on emoji | already stripped by the engine |
|
|
381
413
|
| Plugin not loading | raw Windows path as plugin name | use the `file:///C:/…` URL form (installer does this) |
|
|
382
414
|
| macOS: voice suddenly became "婷婷" | opening the "Spoken Content / Siri Voice" pane drifted the system voice | re-pick via Settings → Accessibility → Spoken Content → System Voice → ⓘ entry |
|
|
@@ -401,6 +433,15 @@ client/
|
|
|
401
433
|
client.js DSH browser bundle: turn-tail Speak/Stop button + Settings → dsh-speak settings page
|
|
402
434
|
docs/
|
|
403
435
|
DESIGN.md full design rationale, pitfalls, extension guide
|
|
436
|
+
scripts/ tests + manual dev helpers (not shipped in the npm package)
|
|
437
|
+
test-engine-static.js engine invariants: .ps1 BOM + PowerShell parse, .sh LF (also run by prepublishOnly)
|
|
438
|
+
test-engine-longtext.js long-text guard contract for BOTH engines (speak.ps1 -DryRun / speak.sh's perl)
|
|
439
|
+
test-speech-hook.js host plugin: event triggers, queue, tool-error detail filter
|
|
440
|
+
test-client-bundle.js browser bundle: slot registration + component rendering
|
|
441
|
+
test-settings-integration.js settings-service wiring + removed-API guard
|
|
442
|
+
session-log-dump.js read a DSH session log (manual: what text reached the engine)
|
|
443
|
+
settings-ui-check.py Playwright UI check (manual: needs a running, authenticated dsh)
|
|
444
|
+
dsh-events-check.py Playwright disclosure check (manual)
|
|
404
445
|
```
|
|
405
446
|
|
|
406
447
|
## Writing a new adapter
|
package/README.zh-CN.md
CHANGED
|
@@ -80,6 +80,18 @@ harness 事件(DSH 会话事件 / Claude Code Stop hook / 任意方式)
|
|
|
80
80
|
- macOS(Apple Silicon / Intel 均可),系统自带 `say` 命令,**无需安装任何软件**。
|
|
81
81
|
- 中文音色与 Siri 音色的选择入口/坑见 [macOS](#macos) 一节。
|
|
82
82
|
|
|
83
|
+
### DSH 版本
|
|
84
|
+
|
|
85
|
+
- 已在 **DSH 0.1.5-rc.1** 上验证。0.1.1 之后有两处 host/客户端 API 变更,本插件
|
|
86
|
+
1.8.0 均已适配:
|
|
87
|
+
- `@deepseek-ai/dsh-settings` 删除了 `installSettingsSection` /
|
|
88
|
+
`settingsNamespace` 两个辅助导出——插件改为通过 `settings` **服务**注册
|
|
89
|
+
namespace(旧版本上原实现会让宿主启动直接崩掉:
|
|
90
|
+
`settingsNamespace is not a function`)。没有 settings provider 时,插件照旧
|
|
91
|
+
按 patch `config` 工作。
|
|
92
|
+
- Session snapshot 不再携带会话视图(Conversation target)数据——🔊 按钮改为
|
|
93
|
+
通过 Chat 目标的 hook `useChat` 取被点击消息的文本。
|
|
94
|
+
|
|
83
95
|
## 安装与快速开始
|
|
84
96
|
|
|
85
97
|
### DSH — 方式 A:npm 插件(推荐)
|
|
@@ -303,7 +315,7 @@ speak.ps1 -Text "…" -Volume 50 -Rate 1 -MaxChars 300 -LongTextMessage "本次
|
|
|
303
315
|
| `announceTurnEnd` | `false` | 回合结束时播报"第 N 轮对话完成/中断/异常结束"(`turn/end`) |
|
|
304
316
|
| `announceCommandDone` | `false` | 命令执行完成/失败时播报(`command/done`) |
|
|
305
317
|
| `announceGoalChange` | `false` | 目标创建/更新/完成/暂停/恢复时播报(`goal/change`,含目标标题前 40 字) |
|
|
306
|
-
| `announceToolErrors` | `false` | 工具调用返回错误时播报"工具调用出错"(英文错误详情/技术 code
|
|
318
|
+
| `announceToolErrors` | `false` | 工具调用返回错误时播报"工具调用出错"(英文错误详情/技术 code 截掉,只保留中文详情)。触发条件:`tool/result` 带 `error`(结构化失败身份)或结果块 `isError === true`。注意 **shell 命令非零退出不算**——pwsh/bash 把 `exit code: N` 当结果数据上报(dsh 明文如此设计),只有基础设施失败(spawn 错误、abort)和 fs 这类结构化失败才置 `isError` |
|
|
307
319
|
| `announceTodoWrite` | `false` | agent 更新待办列表时播报"待办已更新:n/m 完成"(`todo/write`) |
|
|
308
320
|
|
|
309
321
|
#### 超长文本模式
|
|
@@ -313,7 +325,12 @@ speak.ps1 -Text "…" -Volume 50 -Rate 1 -MaxChars 300 -LongTextMessage "本次
|
|
|
313
325
|
- **`message`**(默认):念 `longTextMessage`(`本次播报内容较长,请自行阅读。`,
|
|
314
326
|
可在 UI 或 YAML 里编辑)。
|
|
315
327
|
- **`heading`**:在原始文本里挑**最大字号**的 markdown 标题——`#` 数量最少者优先,
|
|
316
|
-
|
|
328
|
+
并列取第一个。**整段没有任何标题时**改念"有头有尾的开头":取开头 `maxChars`
|
|
329
|
+
长度的窗口并回退到窗口内最后一个句末标点;若这样会砍掉半个窗口以上则保留整窗。
|
|
330
|
+
句末标点中英双语识别:全角 `。!?;` 与 `…` 无条件算;半角 `.!?;` 只在后面跟
|
|
331
|
+
空白、右引号/右括号时才算,落在窗口最后一位时会**多读一位**判断——所以英文
|
|
332
|
+
「句号+空格」在边缘照样算,而 `Version 0.1.` 这种小数点不算。(1.8.0 之前这里只念
|
|
333
|
+
第一个非空行,听感上就是"从第二行开始不念了"。)选中的候选仍会清洗并受 `maxChars`
|
|
317
334
|
上限约束,若其本身仍超长则回退提示语。
|
|
318
335
|
|
|
319
336
|
完整架构与设计取舍见 [docs/DESIGN.zh-CN.md](docs/DESIGN.zh-CN.md)。
|
|
@@ -333,6 +350,14 @@ speak.ps1 -Text "…" -Volume 50 -Rate 1 -MaxChars 300 -LongTextMessage "本次
|
|
|
333
350
|
|
|
334
351
|
然后在 config 块里指向你的副本:
|
|
335
352
|
|
|
353
|
+
> **Windows:务必保住文件的 UTF-8 BOM。** `speak.ps1` 是 UTF-8 脚本,而 Windows
|
|
354
|
+
> PowerShell 5.1 只能靠开头那三个字节 `EF BB BF` 知道这一点;编辑器保存时若把它
|
|
355
|
+
> 丢掉,系统会改用 ANSI 代码页解码,脚本里的中文会变乱码——症状是**静默无声或
|
|
356
|
+
> 修剪错乱,且不报错**。为此仓库里的脚本已把**逻辑部分全部写成纯 ASCII**,所以
|
|
357
|
+
> 丢 BOM 只会让中文注释和默认提示语变乱码。改完可以用
|
|
358
|
+
> `Get-Content -Encoding Byte -TotalCount 3 你的-speak.ps1` 检查(应为 `239 187 191`),
|
|
359
|
+
> 或跑 `node scripts/test-engine-static.js`。
|
|
360
|
+
|
|
336
361
|
```yaml
|
|
337
362
|
- insert:
|
|
338
363
|
- id: speech-hook
|
|
@@ -354,6 +379,8 @@ speak.ps1 -Text "…" -Volume 50 -Rate 1 -MaxChars 300 -LongTextMessage "本次
|
|
|
354
379
|
| ---- | ---- | ---- |
|
|
355
380
|
| 完全没有声音、无报错 | 未启用/安装自然语音 | Win11:在 设置 → 讲述人/语音 中启用自然语音;Win10:安装 NaturalVoiceSAPIAdapter 并下载语音包。直接测 `speak.ps1` |
|
|
356
381
|
| 长回复从不播报 | 适配器单次 `Speak` 有字数上限 | 已默认在 300 字处守卫——必要时调低 `-MaxChars` |
|
|
382
|
+
| 念到第二行就停/像是被切断 | `longTextMode: heading` 下,文本超过 `maxChars` 且整段没有 markdown 标题时,旧版引擎只念第一个非空行(1.8.0 之前) | 1.8.0 已修(改念"有头有尾的开头");想换策略可用 `message` 模式或调高 `maxChars` |
|
|
383
|
+
| 听到 `工具调用出错:Error: cannot read …` | "是否中文"的详情判据只检查"含有汉字",英文报错里夹着中文目录名就能骗过它(1.8.0 引入的回归) | 1.8.0 已修——详情需满足"汉字数量多于拉丁字母数量" |
|
|
357
384
|
| 含大量 emoji 的文本静默 | SAPI 遇到 emoji 会静默失败 | 引擎已自动剥离 |
|
|
358
385
|
| 插件加载失败 | 插件名用了 Windows 原始路径 | 改用 `file:///C:/…` URL 形式(安装脚本会自动处理) |
|
|
359
386
|
| macOS:音色突然变成"婷婷" | 打开过"朗读内容 / Siri 声音"设置面板导致系统朗读声音漂移 | 系统设置 → 辅助功能 → 阅读与朗读 → 系统声音 → ⓘ 入口重新选择 |
|
|
@@ -378,6 +405,15 @@ client/
|
|
|
378
405
|
client.js DSH 浏览器端 bundle:回合尾部 Speak/Stop 按钮 + 设置 → dsh-speak 设置页
|
|
379
406
|
docs/
|
|
380
407
|
DESIGN.zh-CN.md 完整设计文档:设计取舍、踩坑记录、扩展指南
|
|
408
|
+
scripts/ 测试 + 手动开发辅助脚本(不随 npm 包发布)
|
|
409
|
+
test-engine-static.js 引擎静态不变量:.ps1 的 BOM + PowerShell 语法解析、.sh 的 LF(prepublishOnly 也会跑)
|
|
410
|
+
test-engine-longtext.js 两个引擎的长文守卫契约(speak.ps1 -DryRun / speak.sh 的 perl)
|
|
411
|
+
test-speech-hook.js 宿主插件:事件触发、队列、工具出错详情过滤
|
|
412
|
+
test-client-bundle.js 浏览器 bundle:slot 注册 + 组件渲染
|
|
413
|
+
test-settings-integration.js settings 服务接线 + 已删除 API 的回归守卫
|
|
414
|
+
session-log-dump.js 读取 DSH 会话日志(手动:看引擎究竟收到了什么文本)
|
|
415
|
+
settings-ui-check.py Playwright UI 检查(手动:需要运行中且已鉴权的 dsh)
|
|
416
|
+
dsh-events-check.py Playwright 折叠行检查(手动)
|
|
381
417
|
```
|
|
382
418
|
|
|
383
419
|
## 编写新适配器
|
|
@@ -19,8 +19,9 @@
|
|
|
19
19
|
// - on: every assistant/message is enqueued immediately as it arrives
|
|
20
20
|
// * a `/dsh-speak/control` POST route (play/stop/status) and a
|
|
21
21
|
// `/dsh-speak/ws` WebSocket publish the authoritative speech state
|
|
22
|
-
// * a `dsh-speak` settings namespace
|
|
23
|
-
//
|
|
22
|
+
// * a `dsh-speak` settings namespace registered through the settings SERVICE
|
|
23
|
+
// (`ctx.inject(['settings'])` → `settings.register`); schema defaults →
|
|
24
|
+
// patch config → UI user layer
|
|
24
25
|
// * `enabled` master switch: when off, nothing is ever enqueued (no sound)
|
|
25
26
|
//
|
|
26
27
|
// Trigger semantics:
|
|
@@ -72,7 +73,7 @@ function resolveEngine(override) {
|
|
|
72
73
|
const DEFAULT_MAX_CHARS = process.platform === 'darwin' ? 0 : 300
|
|
73
74
|
|
|
74
75
|
// ---------------------------------------------------------------------------
|
|
75
|
-
// Settings namespace (best-effort;
|
|
76
|
+
// Settings namespace (best-effort; registered through the `settings` service)
|
|
76
77
|
// ---------------------------------------------------------------------------
|
|
77
78
|
// The schema mirrors every config key. Values resolve as:
|
|
78
79
|
// schema default → patch `config` (base) → user settings layer (the UI).
|
|
@@ -141,14 +142,29 @@ function resolveConfig(value) {
|
|
|
141
142
|
}
|
|
142
143
|
|
|
143
144
|
/**
|
|
144
|
-
*
|
|
145
|
+
* Resolve a module specifier from the plugin's own location first, then from
|
|
146
|
+
* the booted profile tree. `@deepseek-ai/schemastery` is a peer of this package
|
|
147
|
+
* and lives beside it after an npm/pnpm install; the profile-tree fallback
|
|
148
|
+
* covers the file:// install used by install.ps1 and repo checkouts.
|
|
149
|
+
* @returns the module, or null when neither base resolves it.
|
|
150
|
+
*/
|
|
151
|
+
function requirePeer(ctx, spec) {
|
|
152
|
+
const bases = [__filename, ctx.baseUrl].filter(Boolean)
|
|
153
|
+
for (const base of bases) {
|
|
154
|
+
try { return createRequire(base)(spec) } catch (e) { /* try the next base */ }
|
|
155
|
+
}
|
|
156
|
+
return null
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Build the settings schema + entry for the settings namespace. Best-effort:
|
|
145
161
|
* any failure (missing peer packages) returns null and the plugin keeps the
|
|
146
|
-
* patch config.
|
|
162
|
+
* patch config.
|
|
147
163
|
*/
|
|
148
164
|
function buildSettingsNamespace(ctx, patch) {
|
|
149
165
|
try {
|
|
150
|
-
const
|
|
151
|
-
|
|
166
|
+
const z = requirePeer(ctx, '@deepseek-ai/schemastery')
|
|
167
|
+
if (!z) throw new Error('@deepseek-ai/schemastery 不可解析')
|
|
152
168
|
const schema = z.object({
|
|
153
169
|
enabled: z.boolean().default(true),
|
|
154
170
|
automaticSpeech: z.boolean().default(true),
|
|
@@ -188,33 +204,49 @@ module.exports = {
|
|
|
188
204
|
config = config || {}
|
|
189
205
|
let cfg = resolveConfig(config)
|
|
190
206
|
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
207
|
+
// ---- settings namespace -------------------------------------------------
|
|
208
|
+
// Wire the namespace through the settings SERVICE.
|
|
209
|
+
//
|
|
210
|
+
// dsh 0.1.2-alpha.1 deleted the `installSettingsSection` / `settingsNamespace`
|
|
211
|
+
// convenience exports from `@deepseek-ai/dsh-settings`; what remains — and
|
|
212
|
+
// has not changed since 0.1.0-rc.7 — is the `settings` service itself
|
|
213
|
+
// (`ctx.settings.register(ns, schema, { base })` → `{ get, watch, update,
|
|
214
|
+
// replace }`). Referencing the removed names is fatal: an ESM named import
|
|
215
|
+
// of a deleted export is a module-evaluation SyntaxError that kills the host
|
|
216
|
+
// boot, and a lazy `settingsModule.installSettingsSection(...)` call — what
|
|
217
|
+
// this plugin used to do inside a timer callback — throws
|
|
218
|
+
// `settingsNamespace is not a function` and crashed dsh before it served.
|
|
219
|
+
//
|
|
220
|
+
// `ctx.inject(['settings'])` is the graceful-degradation boundary: on a host
|
|
221
|
+
// with no settings provider the callback never runs and the composed patch
|
|
222
|
+
// config stands as-is.
|
|
223
|
+
const prepared = buildSettingsNamespace(ctx, config)
|
|
224
|
+
if (prepared) {
|
|
225
|
+
ctx.inject(['settings'], scopedCtx => {
|
|
226
|
+
// `scope.get()` is the live resolved value (schema default → patch
|
|
227
|
+
// config → UI user layer), so re-deriving cfg from it on every change
|
|
228
|
+
// is what makes a settings edit take effect without a restart.
|
|
229
|
+
let settingsSource = () => prepared.entry
|
|
230
|
+
const applySettings = () => {
|
|
231
|
+
try { cfg = resolveConfig(settingsSource()) } catch (e) { log('settings 变更应用失败:', e && e.message) }
|
|
232
|
+
}
|
|
198
233
|
try {
|
|
199
|
-
const
|
|
200
|
-
|
|
234
|
+
const scope = scopedCtx.settings.register(SETTINGS_NS, prepared.schema, { base: prepared.entry })
|
|
235
|
+
settingsSource = () => scope.get()
|
|
236
|
+
// Unload restores the composed entry, so a disabled plugin cannot
|
|
237
|
+
// leave the queue reading a value nobody can see or change any more.
|
|
238
|
+
scopedCtx.effect(() => () => {
|
|
239
|
+
settingsSource = () => prepared.entry
|
|
240
|
+
applySettings()
|
|
241
|
+
})
|
|
242
|
+
scope.watch(applySettings)
|
|
243
|
+
applySettings()
|
|
244
|
+
log('settings namespace 已注册:', SETTINGS_NS)
|
|
201
245
|
} catch (e) {
|
|
202
|
-
log('
|
|
203
|
-
return
|
|
246
|
+
log('settings namespace 注册失败,继续使用 patch config:', e && e.message)
|
|
204
247
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
// (installSettingsSection only calls setSource on attach/detach).
|
|
208
|
-
let settingsSource = () => prepared.entry
|
|
209
|
-
settingsModule.installSettingsSection(ctx, settingsModule.settingsNamespace(SETTINGS_NS), prepared.schema, prepared.entry, {
|
|
210
|
-
setSource: source => { settingsSource = source; cfg = resolveConfig(source()) },
|
|
211
|
-
onChange: () => {
|
|
212
|
-
try { cfg = resolveConfig(settingsSource()) } catch (e) { log('settings 变更应用失败:', e && e.message) }
|
|
213
|
-
log('settings 变更已应用; cfg=', JSON.stringify(cfg))
|
|
214
|
-
},
|
|
215
|
-
})
|
|
216
|
-
}, 0)
|
|
217
|
-
})
|
|
248
|
+
})
|
|
249
|
+
}
|
|
218
250
|
|
|
219
251
|
// ---- host-owned FIFO speech queue + WebSocket state sync (PR #2) ----
|
|
220
252
|
let activeSpeech = null
|
|
@@ -472,7 +504,13 @@ module.exports = {
|
|
|
472
504
|
if (type === 'approval/asked' && cfg.announceApprovals) {
|
|
473
505
|
cancelPending()
|
|
474
506
|
let reason = String((event.data && event.data.reason) || '')
|
|
475
|
-
if (cfg.stripApprovalPrefix)
|
|
507
|
+
if (cfg.stripApprovalPrefix) {
|
|
508
|
+
// 通用剥离行首"动作标签: "前缀(英文动作短语 + 冒号,如
|
|
509
|
+
// "Store decision fact in workspace memory (dsh-speak): <内容>"、
|
|
510
|
+
// "escalate sandbox to danger-full-access: <原因>"),只念冒号后的
|
|
511
|
+
// 具体内容;中文开头或无冒号的 reason 原样保留(如"删除 xxx")。
|
|
512
|
+
reason = reason.replace(/^[A-Za-z][^::\n]*?[::]\s*/, '').trim()
|
|
513
|
+
}
|
|
476
514
|
enqueue(hostItem('approval', session, event, reason || '需要你的审批,请查看界面。', null))
|
|
477
515
|
return
|
|
478
516
|
}
|
|
@@ -510,16 +548,31 @@ module.exports = {
|
|
|
510
548
|
if (type === 'tool/result' && cfg.announceToolErrors) {
|
|
511
549
|
const data = event.data
|
|
512
550
|
const err = data && data.error
|
|
513
|
-
//
|
|
514
|
-
//
|
|
551
|
+
// 真实错误标记有两处:结构化失败身份 data.error(name/code),以及结果块上的
|
|
552
|
+
// isError。0.1.2 起 createToolResultMessage 把结果块包进一个 ToolResultBlock
|
|
553
|
+
// ({ type:'tool-result', toolCallId, content:[…], isError }),文字在它嵌套的
|
|
554
|
+
// content 里;更早的版本把 isError 直接放在 text 块上。两种形状都读。
|
|
555
|
+
//
|
|
556
|
+
// 注意:pwsh / bash 把「命令非零退出」当作结果数据上报(`exit code: N`),
|
|
557
|
+
// 不置 isError —— 只有基础设施失败(spawn 错误、abort)才是 isError 结果,
|
|
558
|
+
// 所以失败的命令本身不会播报工具出错。
|
|
515
559
|
const errText = (Array.isArray(data && data.message && data.message.content) ? data.message.content : [])
|
|
516
560
|
.filter(block => block && block.isError === true)
|
|
517
|
-
.map(block =>
|
|
561
|
+
.map(block => {
|
|
562
|
+
const parts = Array.isArray(block.content) ? block.content : [block]
|
|
563
|
+
return parts.map(part => (part && (part.text || part.code)) || '').filter(Boolean).join(' ')
|
|
564
|
+
})
|
|
565
|
+
.filter(Boolean).join(' ')
|
|
518
566
|
if (err || errText) {
|
|
519
567
|
const detail = (errText || (err && err.code) || (err && err.name) || '').replace(/\s+/g, ' ').trim().slice(0, 60)
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
|
|
568
|
+
// 详情只在"确实是一句中文描述"时才念:英文模板(Error: / ENOENT / 技术
|
|
569
|
+
// code)对中文用户可读性差,应当截掉。判据是**汉字数量多于拉丁字母数量**,
|
|
570
|
+
// 而不是"含有汉字"——后者会被路径里的中文目录名骗过:
|
|
571
|
+
// `Error: cannot read "D:\...\第二轮测试用的不存在文件.txt"` 含 12 个汉字,
|
|
572
|
+
// 却是纯英文报错(1.8.0 修正)。
|
|
573
|
+
const cjkCount = (detail.match(/[\u4e00-\u9fff]/g) || []).length
|
|
574
|
+
const latinCount = (detail.match(/[A-Za-z]/g) || []).length
|
|
575
|
+
const readable = cjkCount > latinCount ? `:${detail}` : ''
|
|
523
576
|
enqueue(hostItem('tool/result', session, event, `工具调用出错${readable}`, null))
|
|
524
577
|
}
|
|
525
578
|
return
|
package/client/client.js
CHANGED
|
@@ -225,44 +225,61 @@ window.__ModuleLoader__.load({
|
|
|
225
225
|
function visibleText(node) {
|
|
226
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
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
|
+
}
|
|
228
256
|
function SpeakAction(props) {
|
|
229
257
|
const messageId = props.messageId == null ? null : String(props.messageId)
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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 }
|
|
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) : ''
|
|
253
270
|
})
|
|
254
271
|
const active = useSpeechState()
|
|
255
|
-
const speaking = active.speaking && String(active.sessionId) === String(props.sessionId) && active.turn ===
|
|
272
|
+
const speaking = active.speaking && String(active.sessionId) === String(props.sessionId) && active.turn === turn
|
|
256
273
|
const [pending, setPending] = React.useState(false)
|
|
257
274
|
const label = speaking ? t('actionStop') : t('actionSpeakTurn')
|
|
258
275
|
return e('button', {
|
|
259
276
|
type: 'button', className: 'dsh-speak-message-action', 'aria-label': label, 'aria-pressed': speaking,
|
|
260
|
-
'data-speaking': speaking || undefined, title: label, disabled: pending || !
|
|
277
|
+
'data-speaking': speaking || undefined, title: label, disabled: pending || !text.trim(),
|
|
261
278
|
onClick: () => {
|
|
262
279
|
if (pending) return
|
|
263
280
|
setPending(true)
|
|
264
281
|
const action = speaking ? 'stop' : 'play'
|
|
265
|
-
const payload = speaking ? { action } : { action, sessionId: props.sessionId, turn
|
|
282
|
+
const payload = speaking ? { action } : { action, sessionId: props.sessionId, turn, messageId, text }
|
|
266
283
|
void control(payload).catch(console.error).finally(() => setPending(false))
|
|
267
284
|
},
|
|
268
285
|
}, speaking ? e(IconPauseOutline16) : e(IconVolume2))
|
package/docs/DESIGN.md
CHANGED
|
@@ -94,8 +94,13 @@ Processing pipeline (in order):
|
|
|
94
94
|
3. **Strip emoji / non-printable** — keep CJK, CJK punctuation, full-width ranges,
|
|
95
95
|
ASCII printable (regex `[^一-龥 -〿- - -~]`).
|
|
96
96
|
4. **Collapse whitespace.**
|
|
97
|
-
5. **Length guard** —
|
|
98
|
-
`
|
|
97
|
+
5. **Length guard** — over `MaxChars` (default 300) the text is handled by
|
|
98
|
+
`LongTextMode`: `message` (the default) replaces it with `LongTextMessage`
|
|
99
|
+
(default: `本次播报内容较长,请自行阅读。`); `heading` speaks the largest
|
|
100
|
+
markdown heading, or — when the text has no heading at all — a coherent
|
|
101
|
+
opening: the leading `MaxChars` window trimmed back to its last sentence end
|
|
102
|
+
(that fallback used to speak only the first line, which sounded like the
|
|
103
|
+
narration was cut off).
|
|
99
104
|
6. **Speak** — `System.Speech.Synthesis.SpeechSynthesizer`, volume/rate applied,
|
|
100
105
|
best zh natural voice selected, then `Speak()`.
|
|
101
106
|
|
|
@@ -135,14 +140,20 @@ no "reply finished" hook, so the plugin observes the session event stream:
|
|
|
135
140
|
- **Optional event announcements** (1.6.0, all off by default): `turn/end`,
|
|
136
141
|
`command/done`, `goal/change`, `tool/result` (on error), and `todo/write` each
|
|
137
142
|
have an independent toggle and announce a fixed phrase on fire (see §5).
|
|
138
|
-
- **Settings namespace registration** (1.6.0):
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
`
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
0.1.
|
|
145
|
-
the
|
|
143
|
+
- **Settings namespace registration** (1.6.0): the plugin wires its namespace
|
|
144
|
+
through the settings *service* — `ctx.inject(['settings'])` →
|
|
145
|
+
`settings.register('dsh-speak', schema, { base: patchConfig })` → re-derive
|
|
146
|
+
`cfg` from `scope.get()` on every `scope.watch` notification, and restore the
|
|
147
|
+
composed patch config when the fiber unloads. Resolution stays schema default
|
|
148
|
+
→ patch `config` → UI user layer.
|
|
149
|
+
The plugin never imports `@deepseek-ai/dsh-settings`: DSH 0.1.2-alpha.1 deleted
|
|
150
|
+
the `installSettingsSection` / `settingsNamespace` helpers, and referencing
|
|
151
|
+
them is fatal — a missing named export is a module-evaluation error, and the
|
|
152
|
+
old lazy call threw `settingsNamespace is not a function` inside a timer
|
|
153
|
+
callback, which crashed the host (dsh exited 1 instead of booting). The service
|
|
154
|
+
itself never changed. On hosts without a settings service the inject callback
|
|
155
|
+
never runs and the plugin works purely from the patch config — graceful
|
|
156
|
+
degradation with no version check.
|
|
146
157
|
|
|
147
158
|
Registration snippet (also automated by `install.ps1`; npm installs use the bare
|
|
148
159
|
package name `'dsh-speak'` — this is the file-install path):
|
|
@@ -168,7 +179,13 @@ that registers two pieces of UI:
|
|
|
168
179
|
Clicking 🔊 POSTs to `/dsh-speak/control` to replay that final message; clicking
|
|
169
180
|
again stops; clicking another switches. The button's speaking/paused state is
|
|
170
181
|
derived from the authoritative host state over the `/dsh-speak/ws` WebSocket
|
|
171
|
-
(matched by session + turn identity).
|
|
182
|
+
(matched by session + turn identity). The replayed text is resolved through the
|
|
183
|
+
Chat target selector hook `useChat` (`@deepseek-ai/dsh-client-ui-chat` declares
|
|
184
|
+
it for every session-scoped slot): DSH 0.1.2 excluded Conversation target data
|
|
185
|
+
from the Session snapshot, so `useSession(s => s.chat.nodes)` no longer yields
|
|
186
|
+
the chat nodes. The two selectors return primitives only, because a fresh
|
|
187
|
+
object per read would churn the subscription, and a missing `useChat` prop
|
|
188
|
+
degrades the button to disabled instead of throwing inside its row.
|
|
172
189
|
- **Settings → dsh-speak settings page** (1.7.0): registered into the
|
|
173
190
|
`settings.section` slot, drawn with `@deepseek-ai/dsh-client-ui-primitives`
|
|
174
191
|
(Button / DisclosureRow / Input; Toggle / Options / SettingInput helpers). Every
|
|
@@ -179,7 +196,12 @@ that registers two pieces of UI:
|
|
|
179
196
|
|
|
180
197
|
- The package declares its browser half via `package.json`
|
|
181
198
|
`dsh.client: { platform: 'web' }` + `exports['./client']`; DSH's client-modules
|
|
182
|
-
scanner picks it up and loads it automatically.
|
|
199
|
+
scanner picks it up and loads it automatically. `dsh.client.inject` names the
|
|
200
|
+
package rows that DECLARE the two slots it occupies
|
|
201
|
+
(`@deepseek-ai/dsh-client-ui-chat`, `@deepseek-ai/dsh-client-ui-settings`) so
|
|
202
|
+
their factories arrive first; `dsh.client.external` lists
|
|
203
|
+
`@deepseek-ai/dsh-client-ui-primitives`, which the shell seeds in its static
|
|
204
|
+
module table.
|
|
183
205
|
- **Deliberately handwritten, zero build**: it only uses platform seed modules
|
|
184
206
|
and official primitives (the bundle-purity gate allows primitives but forbids
|
|
185
207
|
importing official package internals), matching the built bundles' contract.
|
|
@@ -206,7 +228,7 @@ returns immediately. (Async spawning is safe here — the nested-spawn restricti
|
|
|
206
228
|
| `turn/end` | 🟡 off by default; announces "第 N 轮对话完成/中断/异常结束" |
|
|
207
229
|
| `command/done` | 🟡 off by default; announces "命令执行完成/失败" |
|
|
208
230
|
| `goal/change` | 🟡 off by default; announces "已创建目标/目标已完成…" (head) |
|
|
209
|
-
| `tool/result` | 🟡 off by default; announces "工具调用出错" only
|
|
231
|
+
| `tool/result` | 🟡 off by default; announces "工具调用出错" only for a structured failure (`error`, or a result block with `isError === true`). A non-zero shell exit is result data (`exit code: N`), not an error — pwsh/bash deliberately settle it as a completed call, so only infrastructure failures (spawn errors, aborts) and structured tool failures (e.g. fs) announce. Since 0.1.2 the `ToolResultBlock` wrapper nests the text under `content[]`, so the detail is read from there (English details / technical codes dropped, Chinese details kept) |
|
|
210
232
|
| `todo/write` | 🟡 off by default; announces "待办已更新:n/m 完成" |
|
|
211
233
|
| `assistant/message` (queueAllMessages on) | ✅ every message enqueued immediately (intermediate spoken too) |
|
|
212
234
|
| manual replay (per-message 🔊) | ✅ clear queue → stop current → speak that turn |
|
|
@@ -223,7 +245,8 @@ returns immediately. (Async spawning is safe here — the nested-spawn restricti
|
|
|
223
245
|
| `-Rate` | `1` | speech rate (SAPI scale) |
|
|
224
246
|
| `-MaxChars` | platform | beyond this, replaced by `LongTextMessage` (macOS default 0 = unlimited) |
|
|
225
247
|
| `-LongTextMessage`| `本次播报内容较长,请自行阅读。` | spoken instead of over-long text |
|
|
226
|
-
| `-LongTextMode` | `message` | `message` (fixed prompt) \| `heading` (speak the largest markdown heading) |
|
|
248
|
+
| `-LongTextMode` | `message` | `message` (fixed prompt) \| `heading` (speak the largest markdown heading; **with no heading, speak a coherent opening**: the leading `MaxChars` window trimmed back to its last sentence end, kept whole when that would drop more than half the window. Full-width `。!?;…` always end a sentence; half-width `.!?;` only when followed by whitespace/a closing quote or bracket — read one character PAST the window for the last position, so an English `period + space` at the edge counts while `Version 0.1.` does not) |
|
|
249
|
+
| `-DryRun` | `0` | print the text that WOULD be spoken as UTF-8 on stdout and exit without audio (maintainer aid for diffing the cleaning pipeline and the long-text guard) |
|
|
227
250
|
| `-CleanMarkdownFormatting` | `true` | convert Markdown to natural speech (link labels kept, URLs stripped) |
|
|
228
251
|
| `-ReadInlineCode` | `true` | read inline code without backtick markers |
|
|
229
252
|
| `-CodeBlocks` | `smart` | `all` \| `smart` \| `replace` (fenced code blocks) |
|
|
@@ -281,6 +304,7 @@ Full configuration guide: the README's Configuration section.
|
|
|
281
304
|
| 6.6 | Reading/writing speech text as ANSI | mojibake or empty speech | always UTF-8 (`[System.IO.File]::ReadAllText(..., UTF8)`) |
|
|
282
305
|
| 6.7 | A repo `.sh` checked out as CRLF by `core.autocrlf=true`; `npm pack` bundles the **working-tree** file | the published `speak.sh` dies in bash on macOS (`command not found`, `syntax error near {`), silent failure | `.gitattributes` pins `*.sh text eol=lf` (check `file engine/speak.sh` for CRLF before publishing) |
|
|
283
306
|
| 6.8 | Log path hard-coded as `/tmp` | on macOS `os.tmpdir()` is `/var/folders/.../T`, the log is not at `/tmp` | look for the log at `os.tmpdir()` (= `$TMPDIR`) |
|
|
307
|
+
| 6.9 | Engine `.ps1` saved **without the UTF-8 BOM** (any editor or script that rewrites the file drops it — it is byte metadata, not content, and nothing in the file records the requirement) | Windows PowerShell 5.1 decodes the file with the system ANSI code page, so Chinese **literals in code** become mojibake: the emoji/CJK filter then drops real text (silent no audio) or the sentence-end classes stop matching (silent wrong trimming). Comments only look garbled | two rules: (a) the engine's CODE stays ASCII-only — PowerShell punctuation is built from `[char]` code points and ranges are written as `\u` escapes, and `speak.sh`'s Perl guard writes punctuation as `\x{...}` escapes (Perl source is bytes without `use utf8`, so a Chinese literal in a pattern is read as Latin-1 and matches nothing — a macOS-only "trims nothing" bug 1.8.0 shipped and `npm test` caught it on a real machine) — so a lost BOM only garbles comments and the two default prompts; (b) `scripts/test-engine-static.js` asserts the BOM on every `engine/*.ps1` plus a PowerShell parse check (also in `prepublishOnly`), and `scripts/test-engine-longtext.js` asserts the extracted Perl guard is ASCII-only |
|
|
284
308
|
|
|
285
309
|
## 7. Extending
|
|
286
310
|
|
package/docs/DESIGN.zh-CN.md
CHANGED
|
@@ -86,8 +86,11 @@ Agent 工具会跑长任务(构建、测试、迁移、批量修改),而
|
|
|
86
86
|
3. **剥离 emoji / 不可打印字符** — 只保留中文汉字、中文标点、全角区间、
|
|
87
87
|
ASCII 可打印(正则 `[^一-龥 -〿- - -~]`)。
|
|
88
88
|
4. **压缩空白。**
|
|
89
|
-
5. **长度守卫** —
|
|
90
|
-
`LongTextMessage
|
|
89
|
+
5. **长度守卫** — 超过 `MaxChars`(默认 300)的文本由 `LongTextMode` 决定:
|
|
90
|
+
`message`(默认)替换为 `LongTextMessage`(默认:`本次播报内容较长,请自行
|
|
91
|
+
阅读。`);`heading` 念最大字号的 markdown 标题,而**整段没有标题时**改念
|
|
92
|
+
"有头有尾的开头"——取开头 `MaxChars` 窗口并回退到窗口内最后一个句末标点
|
|
93
|
+
(旧实现这里只念第一个非空行,听感上就是"从第二行开始不念了")。
|
|
91
94
|
6. **朗读** — `System.Speech.Synthesis.SpeechSynthesizer`,应用音量/语速,
|
|
92
95
|
选择最佳 zh 自然语音,然后 `Speak()`。
|
|
93
96
|
|
|
@@ -119,13 +122,17 @@ Agent 工具会跑长任务(构建、测试、迁移、批量修改),而
|
|
|
119
122
|
事件;开 = 每条 assistant 消息立即入队朗读(中间消息也读)。
|
|
120
123
|
- **可选事件播报**(1.6.0,默认全关):`turn/end`、`command/done`、
|
|
121
124
|
`goal/change`、`tool/result`(出错时)、`todo/write` 各自独立开关(见 §5)。
|
|
122
|
-
- **settings namespace 注册**(1.6.0
|
|
123
|
-
`
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
125
|
+
- **settings namespace 注册**(1.6.0):插件通过 settings **服务**接线——
|
|
126
|
+
`ctx.inject(['settings'])` → `settings.register('dsh-speak', schema, { base:
|
|
127
|
+
patchConfig })` → 每次 `scope.watch` 通知时从 `scope.get()` 重新解析 cfg,
|
|
128
|
+
fiber 卸载时恢复成组合层的 patch config。配置解析仍是 schema 默认 → patch
|
|
129
|
+
`config` → UI 用户设置三层。
|
|
130
|
+
插件**不再 import** `@deepseek-ai/dsh-settings`:DSH 0.1.2-alpha.1 删除了
|
|
131
|
+
`installSettingsSection` / `settingsNamespace` 两个辅助导出,引用它们是致命
|
|
132
|
+
的——缺失的具名导出会在模块求值期直接报错;旧代码在 timer 回调里懒调用,抛
|
|
133
|
+
`settingsNamespace is not a function` 把宿主打崩(dsh 直接退出 1,起不来)。
|
|
134
|
+
服务本身从未变过。宿主没有 settings 服务时 inject 回调永不执行,插件完全按
|
|
135
|
+
patch `config` 工作——优雅退化,无需版本判断。
|
|
129
136
|
|
|
130
137
|
注册片段(`install.ps1` 也会自动完成;npm 安装用裸包名 `'dsh-speak'` 即可,
|
|
131
138
|
这是文件安装方式用的路径):
|
|
@@ -150,7 +157,12 @@ factory })`),注册两条 UI:
|
|
|
150
157
|
`conversation.chat.assistant-actions` slot(该回合最终回复的操作栏)。点击 🔊
|
|
151
158
|
调 `/dsh-speak/control` 重播该条最终回复,再点停止,点另一条切换;按钮状态
|
|
152
159
|
(播放中/暂停)由 `/dsh-speak/ws` WebSocket 的 host 权威状态推导(session +
|
|
153
|
-
turn
|
|
160
|
+
turn 身份匹配)。重播文本改为通过 Chat 目标的 selector hook `useChat` 取
|
|
161
|
+
(`@deepseek-ai/dsh-client-ui-chat` 为所有 session 作用域 slot 声明了它):
|
|
162
|
+
DSH 0.1.2 起 Session snapshot 不再携带 Conversation 目标数据,
|
|
163
|
+
`useSession(s => s.chat.nodes)` 已经取不到聊天节点。两个 selector 只返回原始值
|
|
164
|
+
(每次读返回新对象会让订阅反复失效),拿不到 `useChat` 时按钮退化为禁用,而不是
|
|
165
|
+
在自己的操作栏里抛错。
|
|
154
166
|
- **设置 → dsh-speak 设置独立设置页**(1.7.0):注册进 `settings.section` slot。
|
|
155
167
|
用 `@deepseek-ai/dsh-client-ui-primitives` 的 Button/DisclosureRow/Input
|
|
156
168
|
绘制(Toggle/Options/SettingInput 组件),所有配置项(总开关、自动朗读、
|
|
@@ -160,6 +172,10 @@ factory })`),注册两条 UI:
|
|
|
160
172
|
|
|
161
173
|
- 包通过 `package.json` 的 `dsh.client: { platform: 'web' }` +
|
|
162
174
|
`exports['./client']` 声明浏览器端;DSH 的 client-modules 扫描到后自动加载。
|
|
175
|
+
`dsh.client.inject` 列出**声明**它占用那两个 slot 的包 row
|
|
176
|
+
(`@deepseek-ai/dsh-client-ui-chat`、`@deepseek-ai/dsh-client-ui-settings`),
|
|
177
|
+
保证它们的 factory 先到达;`dsh.client.external` 列出
|
|
178
|
+
`@deepseek-ai/dsh-client-ui-primitives`(shell 的静态模块表里已 seed)。
|
|
163
179
|
- **刻意手写、零构建**:只用平台 seed 模块 + 官方 primitives(bundle-purity
|
|
164
180
|
gate 允许用 primitives,禁止 import 官方包内部组件),与构建出来的 bundle
|
|
165
181
|
契约一致。
|
|
@@ -184,7 +200,7 @@ Claude Code *确实*有 Stop hook。hook JSON(含 `transcript_path`)从 stdi
|
|
|
184
200
|
| `turn/end`(回合结束) | 🟡 默认关;开则播报"第 N 轮对话完成/中断/异常结束" |
|
|
185
201
|
| `command/done`(命令完成) | 🟡 默认关;开则播报"命令执行完成/失败" |
|
|
186
202
|
| `goal/change`(目标变更) | 🟡 默认关;开则播报"已创建目标/目标已完成…(前 40 字)" |
|
|
187
|
-
| `tool/result`(工具结果) | 🟡
|
|
203
|
+
| `tool/result`(工具结果) | 🟡 默认关;开则仅对**结构化失败**播报"工具调用出错"(带 `error`,或结果块 `isError === true`)。shell 命令非零退出是结果数据(`exit code: N`)而非错误——pwsh/bash 有意按"已完成调用"结算,所以只有基础设施失败(spawn 错误、abort)和 fs 这类结构化失败才播报。0.1.2 起结果块被包进 `ToolResultBlock`,文字在其嵌套的 `content[]` 里(英文详情/技术 code 截掉,只保留中文详情) |
|
|
188
204
|
| `todo/write`(待办更新) | 🟡 默认关;开则播报"待办已更新:n/m 完成" |
|
|
189
205
|
|
|
190
206
|
| `assistant/message`(queueAllMessages 开)| ✅ 每条立即入队(中间消息也读) |
|
|
@@ -202,7 +218,8 @@ Claude Code *确实*有 Stop hook。hook JSON(含 `transcript_path`)从 stdi
|
|
|
202
218
|
| `-Rate` | `1` | 语速(SAPI 刻度) |
|
|
203
219
|
| `-MaxChars` | 平台相关 | 超过此长度时替换为 `LongTextMessage`(macOS 默认 0 = 不限) |
|
|
204
220
|
| `-LongTextMessage` | `本次播报内容较长,请自行阅读。` | 超长文本时改念这句 |
|
|
205
|
-
| `-LongTextMode` | `message` | `message`(固定提示语)\| `heading`(念最大字号 markdown
|
|
221
|
+
| `-LongTextMode` | `message` | `message`(固定提示语)\| `heading`(念最大字号 markdown 标题;**整段没有标题时改念"有头有尾的开头"**:取开头 `MaxChars` 窗口并回退到窗口内最后一个句末标点,若这样会砍掉半个窗口以上则保留整窗。全角 `。!?;…` 无条件算句末;半角 `.!?;` 只在后面跟空白、右引号/右括号时才算——最后一位会**多读一位**判断,所以英文「句号+空格」在边缘照样算,`Version 0.1.` 这种小数点不算) |
|
|
222
|
+
| `-DryRun` | `0` | 把「将要朗读的文本」按 UTF-8 打到 stdout 后直接退出、完全不出声(调试清洗与长文守卫用) |
|
|
206
223
|
| `-CleanMarkdownFormatting` | `true` | Markdown 转自然语音(保留链接文字去 URL) |
|
|
207
224
|
| `-ReadInlineCode` | `true` | 朗读行内代码(去掉反引号) |
|
|
208
225
|
| `-CodeBlocks` | `smart` | `all` \| `smart` \| `replace`(围栏代码块) |
|
|
@@ -259,6 +276,7 @@ config:
|
|
|
259
276
|
| 6.6 | 用 ANSI 读写播报文本 | 乱码或完全无声 | 一律 UTF-8(`[System.IO.File]::ReadAllText(..., UTF8)`) |
|
|
260
277
|
| 6.7 | 仓库内 `.sh` 被 git 按 `core.autocrlf=true` 检出为 CRLF,`npm pack` 打包的是**工作区**文件 | 发布包里的 `speak.sh` 在 macOS 上 bash 语法错误(`command not found`、`syntax error near {`),静默失败 | `.gitattributes` 里 `*.sh text eol=lf` 锁定 LF(发布前 `file engine/speak.sh` 确认无 CRLF) |
|
|
261
278
|
| 6.8 | 日志路径写死 `/tmp` | macOS 上 `os.tmpdir()` 是 `/var/folders/.../T`,`/tmp` 里找不到日志 | 日志路径按 `os.tmpdir()`(= `$TMPDIR`)查找 |
|
|
279
|
+
| 6.9 | 引擎 `.ps1` 被**丢掉 UTF-8 BOM** 保存(任何重写文件的编辑器/脚本都会丢——BOM 是字节级元数据而不是内容,文件里没有任何东西记录"我需要 BOM") | Windows PowerShell 5.1 改用系统 ANSI 代码页解码,**代码里的中文标点**变乱码:emoji/CJK 过滤范围错乱会连中文一起丢掉(**静默无声**),句末标点类失配则修剪静默失效;中文注释只是显示为乱码 | 两条规则:(a) 引擎**代码保持纯 ASCII** ——PowerShell 的中文标点用 `[char]` 码位拼、范围写成 `\u` 转义,`speak.sh` 的 Perl 守卫也是同理用 `\x{...}` 码位转义(Perl 源码没有 `use utf8` 时按字节处理,模式里直接写中文会被当成 Latin-1 而**完全匹配不到**——这是 1.8.0 实际发布出去的 macOS 专属"完全不修剪"bug,由真机 `npm test` 抓出);这样丢 BOM 只影响注释和两个默认提示语;(b) `scripts/test-engine-static.js` 对每个 `engine/*.ps1` 断言 BOM + PowerShell 语法解析(也挂 `prepublishOnly`),`scripts/test-engine-longtext.js` 断言抽出的 Perl 守卫是纯 ASCII |
|
|
262
280
|
|
|
263
281
|
## 7. 扩展
|
|
264
282
|
|
package/engine/speak.ps1
CHANGED
|
@@ -29,6 +29,9 @@ param(
|
|
|
29
29
|
[int]$Volume = 50,
|
|
30
30
|
[int]$Rate = 1,
|
|
31
31
|
[int]$MaxChars = 300,
|
|
32
|
+
# 本脚本唯一的非 ASCII 代码字面量。丢了 UTF-8 BOM 时 PowerShell 5.1 会按 ANSI
|
|
33
|
+
# 代码页把它解码成乱码——DSH 插件总是显式传 -LongTextMessage,所以只影响手动
|
|
34
|
+
# CLI 调用;脚本逻辑(句末判定 / 字符过滤 / 分句)已全部改成纯 ASCII 源码。
|
|
32
35
|
[string]$LongTextMessage = '本次播报内容较长,请自行阅读。',
|
|
33
36
|
[ValidateSet('message', 'heading')]
|
|
34
37
|
[string]$LongTextMode = 'message',
|
|
@@ -41,11 +44,15 @@ param(
|
|
|
41
44
|
[int]$CodeBlockMaxChars = 300,
|
|
42
45
|
[string]$CodeBlockReplacementText = 'You can see the code in our history.',
|
|
43
46
|
# 手动重播完整朗读:跳过超长文本的 heading/message 截断,分段完整朗读
|
|
44
|
-
[string]$FullRead = '0'
|
|
47
|
+
[string]$FullRead = '0',
|
|
48
|
+
# 只把「将要朗读的文本」按 UTF-8 写到 stdout、完全不出声(调试清洗与长文
|
|
49
|
+
# 守卫用;正常调用无需传,插件不会传)
|
|
50
|
+
[string]$DryRun = '0'
|
|
45
51
|
)
|
|
46
52
|
|
|
47
53
|
$cleanMarkdown = $CleanMarkdownFormatting -in @('1', 'true', 'yes', 'on')
|
|
48
54
|
$readInlineCode = $ReadInlineCode -in @('1', 'true', 'yes', 'on')
|
|
55
|
+
$dryRunMode = $DryRun -in @('1', 'true', 'yes', 'on')
|
|
49
56
|
# 注意:PowerShell 变量大小写不敏感,内部变量名不能与参数名仅差大小写
|
|
50
57
|
# (曾用 $fullRead 导致自赋值污染参数 $FullRead,使 -not 判断失效)
|
|
51
58
|
$fullReadMode = $FullRead -in @('1', 'true', 'yes', 'on')
|
|
@@ -61,13 +68,12 @@ if (-not $text -or -not $text.Trim()) { exit 0 }
|
|
|
61
68
|
|
|
62
69
|
# ---------- length guard: adapter per-Speak ceiling ----------
|
|
63
70
|
# 'message': fixed prompt. 'heading': speak the largest markdown heading instead
|
|
64
|
-
# (fewest '#' wins, tie -> first;
|
|
65
|
-
# cleaned candidate is still subject to the ceiling
|
|
66
|
-
#
|
|
67
|
-
if (-not $fullReadMode -and $text.Length -gt $MaxChars -and $LongTextMode -eq 'heading') {
|
|
71
|
+
# (fewest '#' wins, tie -> first; with NO heading, speak a coherent opening of
|
|
72
|
+
# the text — see below; the cleaned candidate is still subject to the ceiling
|
|
73
|
+
# below). FullRead 手动重播跳过该守卫(见文件底部"完整朗读"分支)。
|
|
74
|
+
if (-not $fullReadMode -and $MaxChars -gt 0 -and $text.Length -gt $MaxChars -and $LongTextMode -eq 'heading') {
|
|
68
75
|
$candidate = ''
|
|
69
76
|
$bestLevel = 7
|
|
70
|
-
$firstNonEmpty = ''
|
|
71
77
|
# 代码块 fence 内的行跳过:其中的 "# 注释" 不是 markdown 标题,
|
|
72
78
|
# 否则长回复里的代码注释会被误当成标题只念注释
|
|
73
79
|
$inCodeBlock = $false
|
|
@@ -80,12 +86,50 @@ if (-not $fullReadMode -and $text.Length -gt $MaxChars -and $LongTextMode -eq 'h
|
|
|
80
86
|
$bestLevel = $level
|
|
81
87
|
$candidate = $line -replace '^\s*#+\s*', ''
|
|
82
88
|
}
|
|
83
|
-
} elseif (-not $firstNonEmpty -and $line.Trim()) {
|
|
84
|
-
$firstNonEmpty = $line
|
|
85
89
|
}
|
|
86
90
|
}
|
|
87
|
-
if (
|
|
88
|
-
|
|
91
|
+
if ($candidate) {
|
|
92
|
+
# 有标题:只念最大的标题("长回复只报标题")
|
|
93
|
+
$text = $candidate
|
|
94
|
+
} else {
|
|
95
|
+
# 没有标题:**不能只念第一个非空行** —— 那会念出"……官方文档写明:"这类
|
|
96
|
+
# 断头句然后静默停住,听感上就是"从第二行开始不念了"(1.8.0 修复)。
|
|
97
|
+
# 改为取开头 MaxChars 长度的窗口,并在窗口内最后一个句末标点处收尾。
|
|
98
|
+
# 中英双语判据:
|
|
99
|
+
# * 全角 。!?; 与省略号 … 无条件算句末(中文标点不含歧义);
|
|
100
|
+
# * 半角 .!?; 只在后面跟空白、右引号/右括号时才算,否则 "0.1.2"、
|
|
101
|
+
# "file.txt"、"e.g." 里的小数点/扩展名会被当成句子结尾;
|
|
102
|
+
# * 半角标点不认"到窗口结尾"本身就结束:窗口末尾若是小数点,认了等于没
|
|
103
|
+
# 修剪。但会多读一位来判断(见下),所以"句号+空格"在窗口边缘照样成立。
|
|
104
|
+
# 收尾后若不足半个窗口,就保留整个窗口:一整句超长文本不该被砍成一个词。
|
|
105
|
+
#
|
|
106
|
+
# 标点类一律用 [char] 码位拼出来,让源码里**不出现非 ASCII 代码字面量**:
|
|
107
|
+
# 1) PowerShell 把 ’ ” ‘ “ 也当字符串引号,直接写进单引号串会提前截断
|
|
108
|
+
# (报 Missing ')' in method call);
|
|
109
|
+
# 2) 更要紧的是——脚本一旦丢掉 UTF-8 BOM,Windows PowerShell 5.1 会按
|
|
110
|
+
# ANSI 代码页解码,代码里的中文标点会变乱码,句末判定**静默失效**。
|
|
111
|
+
# 代码保持纯 ASCII 后,丢 BOM 只会让中文注释变乱码,不影响行为。
|
|
112
|
+
# 。 ! ? ; … = 0x3002 0xFF01 0xFF1F 0xFF1B 0x2026
|
|
113
|
+
# ) 】 」 』 = 0xFF09 0x3011 0x300D 0x300F
|
|
114
|
+
$fullWidthEnders = [string]([char]0x3002) + [char]0xFF01 + [char]0xFF1F + [char]0xFF1B + [char]0x2026
|
|
115
|
+
$closers = [string]([char]0x22) + [char]0x201D + [char]0x2019 + [char]0xFF09 + [char]0x3011 + [char]0x300D + [char]0x300F + [char]0x29 + '\' + [char]0x5D + [char]0x7D
|
|
116
|
+
$sentenceEndPattern = '(?:[' + $fullWidthEnders + ']|[.!?;](?=[\s' + $closers + ']))'
|
|
117
|
+
$window = $text.Substring(0, [Math]::Min($text.Length, $MaxChars))
|
|
118
|
+
# 多看一个字符再判定,但只在窗口内收尾:句号落在窗口最后一位时,它后面那个
|
|
119
|
+
# 空格在窗口之外,多看一位才能认出"句号+空格"确实是句末;而"句号+数字"
|
|
120
|
+
# (`Version 0.1.` 的第 300 位)依旧被拒。切点永远不超过窗口长度。
|
|
121
|
+
$scan = $text.Substring(0, [Math]::Min($text.Length, $MaxChars + 1))
|
|
122
|
+
# 取**落在窗口内**的最后一个句末标点:scan 多读的那一位会让最后一个匹配可能
|
|
123
|
+
# 落在窗口之外(窗口外正好是个全角句号时),那种匹配必须忽略——但也不能因此
|
|
124
|
+
# 丢掉窗口内更早的合法边界,所以逐个过滤而不是只看最后一个。
|
|
125
|
+
$cut = 0
|
|
126
|
+
foreach ($match in [regex]::Matches($scan, $sentenceEndPattern)) {
|
|
127
|
+
$end = $match.Index + $match.Length
|
|
128
|
+
if ($end -le $window.Length) { $cut = $end }
|
|
129
|
+
}
|
|
130
|
+
if ($cut -ge [Math]::Floor($window.Length / 2)) { $window = $window.Substring(0, $cut) }
|
|
131
|
+
$text = $window
|
|
132
|
+
}
|
|
89
133
|
}
|
|
90
134
|
|
|
91
135
|
# ---------- clean: Markdown -> natural speech text ----------
|
|
@@ -104,13 +148,27 @@ if ($cleanMarkdown) {
|
|
|
104
148
|
$text = $text -replace '[*_~]+', ''
|
|
105
149
|
}
|
|
106
150
|
# Keep all Unicode letters, including Portuguese accents; remove unsafe symbols.
|
|
107
|
-
|
|
151
|
+
# 范围写成 \u 转义(纯 ASCII 源码,丢 BOM 也不会乱码):
|
|
152
|
+
# \u4e00-\u9fa5 汉字、\u3000-\u303f 中文标点、\uff00-\uffef 全角、
|
|
153
|
+
# \u2000-\u206f 通用标点(含 … 和 —)、\u0020-\u007e ASCII 可打印。
|
|
154
|
+
$text = [regex]::Replace($text, '[^\p{L}\p{N}\u4e00-\u9fa5\u3000-\u303f\uff00-\uffef\u2000-\u206f\u0020-\u007e]', '')
|
|
108
155
|
$text = $text -replace '\s+', ' '
|
|
109
156
|
$text = $text.Trim()
|
|
110
157
|
|
|
111
158
|
# ---------- final ceiling (also catches over-long heading candidates) ----------
|
|
112
159
|
if (-not $fullReadMode -and $text.Length -gt $MaxChars) { $text = $LongTextMessage }
|
|
113
160
|
|
|
161
|
+
# ---------- dry run: expose the text that would be spoken, silently ----------
|
|
162
|
+
# Maintainer aid: makes the cleaning pipeline and the long-text guard observable
|
|
163
|
+
# without a speaker, so a truncated candidate can be diffed against its source.
|
|
164
|
+
# Written as raw UTF-8 bytes so a redirected capture never depends on the
|
|
165
|
+
# console code page.
|
|
166
|
+
if ($dryRunMode) {
|
|
167
|
+
$bytes = [System.Text.Encoding]::UTF8.GetBytes($text)
|
|
168
|
+
[Console]::OpenStandardOutput().Write($bytes, 0, $bytes.Length)
|
|
169
|
+
exit 0
|
|
170
|
+
}
|
|
171
|
+
|
|
114
172
|
# ---------- speak ----------
|
|
115
173
|
Add-Type -AssemblyName System.Speech
|
|
116
174
|
$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
|
|
@@ -132,7 +190,8 @@ $synth.Rate = $Rate
|
|
|
132
190
|
# 标点切成不超过 450 字的段,逐段朗读(自动播报不经过这里,走上面的守卫)。
|
|
133
191
|
$SPEAK_CHUNK = 400
|
|
134
192
|
if ($fullReadMode -and $text.Length -gt $SPEAK_CHUNK) {
|
|
135
|
-
|
|
193
|
+
# 分句标点同样用 \u 转义(纯 ASCII 源码)
|
|
194
|
+
$parts = [regex]::Split($text, '(?<=[\u3002\uff01\uff1f\uff1b.!?;])')
|
|
136
195
|
$chunk = ''
|
|
137
196
|
foreach ($part in $parts) {
|
|
138
197
|
if ($part.Length -eq 0) { continue }
|
package/engine/speak.sh
CHANGED
|
@@ -55,15 +55,45 @@ fi
|
|
|
55
55
|
# `heading` is meaningful only when a positive long-text ceiling is configured.
|
|
56
56
|
# -F (full read, manual replay) skips this guard entirely.
|
|
57
57
|
if [ "$FULL_READ" != "1" ] && [ "$MAX_CHARS" -gt 0 ] && [ "${#TEXT}" -gt "$MAX_CHARS" ] && [ "$LONG_MODE" = "heading" ]; then
|
|
58
|
+
export DSH_SPEAK_MAX_CHARS="$MAX_CHARS"
|
|
58
59
|
TEXT=$(printf '%s' "$TEXT" | /usr/bin/perl -CSD -e '
|
|
59
|
-
my $
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
my $text = do { local $/; <STDIN> };
|
|
61
|
+
my $best = 7; my $cand = ""; my $inCode = 0;
|
|
62
|
+
for my $line (split /\n/, $text, -1) {
|
|
63
|
+
$line =~ s/\r$//;
|
|
64
|
+
if ($line =~ /^\s*```/) { $inCode = !$inCode; next; } # 跳过代码块内的 "# 注释"
|
|
62
65
|
next if $inCode;
|
|
63
|
-
if (/^[ \t]*(\#{1,6})[ \t]+(.*)$/) { my $n = length($1); if ($n < $best) { $best = $n; $cand = $2; } }
|
|
64
|
-
elsif ($first eq "" && /\S/) { $first = $_; }
|
|
66
|
+
if ($line =~ /^[ \t]*(\#{1,6})[ \t]+(.*)$/) { my $n = length($1); if ($n < $best) { $best = $n; $cand = $2; } }
|
|
65
67
|
}
|
|
66
|
-
|
|
68
|
+
if ($cand ne "") { print $cand; exit }
|
|
69
|
+
# 没有标题:**不能只念第一个非空行**(会念出"……写明:"这种断头句然后静默
|
|
70
|
+
# 停住,听感上就是"从第二行开始不念了")。改为取开头 MaxChars 窗口,并在窗口
|
|
71
|
+
# 内最后一个句末标点处收尾。中英双语判据与 speak.ps1 保持一致:
|
|
72
|
+
# * 全角 。!?;… 无条件算句末;
|
|
73
|
+
# * 半角 .!?; 只在后面跟空白、右引号/右括号时才算,否则
|
|
74
|
+
# "0.1.2"、"file.txt"、"e.g." 里的小数点/扩展名会被当成句末;
|
|
75
|
+
# * 半角标点不认"到窗口结尾"本身就结束:窗口末尾若是小数点,认了等于没修剪。
|
|
76
|
+
# 但会多读一位来判断,所以"句号+空格"在窗口边缘照样成立。
|
|
77
|
+
# 收尾后若不足半个窗口,就保留整个窗口,避免一整句超长文本被砍成一个词。
|
|
78
|
+
#
|
|
79
|
+
# 标点一律写成 \x{...} 码位转义,让这段 Perl 源码保持**纯 ASCII**:Perl 源码默认
|
|
80
|
+
# 按字节处理(这里没有 use utf8),直接写中文标点会被当成 Latin-1 单字节字符,
|
|
81
|
+
# 模式就再也匹配不到解码后的正文,表现为**完全不修剪**(1.8.0 实测踩到)。
|
|
82
|
+
# 码位与 speak.ps1 里 [char] 构造的集合一一对应:
|
|
83
|
+
# 。 ! ? ; … = \x{3002} \x{FF01} \x{FF1F} \x{FF1B} \x{2026}
|
|
84
|
+
# ) 】 」 』 = \x{FF09} \x{3011} \x{300D} \x{300F}
|
|
85
|
+
# " ” ’ ) ] } = " \x{201D} \x{2019} ) ] }
|
|
86
|
+
my $max = $ENV{DSH_SPEAK_MAX_CHARS} || 300;
|
|
87
|
+
my $window = length($text) > $max ? substr($text, 0, $max) : $text;
|
|
88
|
+
my $scan = length($text) > $max + 1 ? substr($text, 0, $max + 1) : $text;
|
|
89
|
+
my @ends;
|
|
90
|
+
while ($scan =~ /(?:[\x{3002}\x{FF01}\x{FF1F}\x{FF1B}\x{2026}]|[.!?;](?=[\s"\x{201D}\x{2019}\)\]\}\x{FF09}\x{3011}\x{300D}\x{300F}]))/g) { push @ends, pos($scan); }
|
|
91
|
+
# 只认落在窗口内的最后一个句末标点:多读的那一位可能让最后一个匹配落在窗口
|
|
92
|
+
# 之外,那种匹配要忽略,但不能因此丢掉窗口内更早的合法边界。
|
|
93
|
+
my $cut = 0;
|
|
94
|
+
foreach my $end (@ends) { $cut = $end if $end <= length($window); }
|
|
95
|
+
$window = substr($window, 0, $cut) if $cut >= int(length($window) / 2);
|
|
96
|
+
print $window;
|
|
67
97
|
')
|
|
68
98
|
fi
|
|
69
99
|
|
package/engine/speech-prompt.ps1
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
#
|
|
6
6
|
# powershell.exe -NoProfile -ExecutionPolicy Bypass -File speech-prompt.ps1 -Text "请做出选择"
|
|
7
7
|
|
|
8
|
+
# 唯一的非 ASCII 代码字面量:丢 UTF-8 BOM 时 PowerShell 5.1 会按 ANSI 代码页把它
|
|
9
|
+
# 解码成乱码——调用方通常显式传 -Text,所以只影响不带参数的调用。
|
|
8
10
|
param([string]$Text = '请做出选择')
|
|
9
11
|
|
|
10
12
|
if (-not $Text) { exit 0 }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-speak",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Make your AI harness speak — voice announcements for DSH and other AI coding harnesses (Windows SAPI5 + macOS system voices)",
|
|
5
5
|
"main": "adapters/dsh/speech-hook.js",
|
|
6
6
|
"exports": {
|
|
@@ -27,9 +27,8 @@
|
|
|
27
27
|
"client": {
|
|
28
28
|
"platform": "web",
|
|
29
29
|
"inject": [
|
|
30
|
-
"@deepseek-ai/dsh-client-ui-
|
|
31
|
-
"@deepseek-ai/dsh-client-ui-settings"
|
|
32
|
-
"@deepseek-ai/dsh-client-runtime"
|
|
30
|
+
"@deepseek-ai/dsh-client-ui-chat",
|
|
31
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
33
32
|
],
|
|
34
33
|
"external": [
|
|
35
34
|
"@deepseek-ai/dsh-client-ui-primitives"
|
|
@@ -53,7 +52,8 @@
|
|
|
53
52
|
"say"
|
|
54
53
|
],
|
|
55
54
|
"scripts": {
|
|
56
|
-
"
|
|
55
|
+
"test": "node scripts/test-engine-static.js && node scripts/test-engine-longtext.js && node scripts/test-client-bundle.js && node scripts/test-speech-hook.js && node scripts/test-settings-integration.js",
|
|
56
|
+
"prepublishOnly": "node scripts/test-engine-static.js && node --check adapters/dsh/speech-hook.js && node --check client/client.js"
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"ws": "^8.18.0"
|
|
@@ -68,13 +68,9 @@
|
|
|
68
68
|
"author": "Alan2Z",
|
|
69
69
|
"license": "MIT",
|
|
70
70
|
"peerDependencies": {
|
|
71
|
-
"@deepseek-ai/dsh-settings": "^0.1.0-rc.7",
|
|
72
71
|
"@deepseek-ai/schemastery": "^3.18.1"
|
|
73
72
|
},
|
|
74
73
|
"peerDependenciesMeta": {
|
|
75
|
-
"@deepseek-ai/dsh-settings": {
|
|
76
|
-
"optional": true
|
|
77
|
-
},
|
|
78
74
|
"@deepseek-ai/schemastery": {
|
|
79
75
|
"optional": true
|
|
80
76
|
}
|