dsh-voice 0.3.6 → 0.4.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/CHANGELOG.md CHANGED
@@ -4,6 +4,21 @@
4
4
 
5
5
  [历史英文记录](docs/CHANGELOG.en.md)
6
6
 
7
+ ## 0.4.0 (2026-10-26)
8
+
9
+ - Add this plugin’s own update controls and independent preferences. Automatic installation stays off until explicitly enabled.
10
+ - Confirm manual installs, preserve local sources and disabled state, pause for tasks or unsaved changes, and use native DSH recovery without forced restarts.
11
+ - Require the public DSH 0.2.1-rc.3 update APIs; Suite components remain managed through Suite.
12
+
13
+ ## 0.3.7 (2026-10-20)
14
+
15
+ - 修复 voice_stt 的引擎/地址错配:openai、groq 分支不再写死厂商地址,一律使用配置 `asrBaseUrl`;模型传的 `engine` 与配置 `asrEngine` 不一致时直接报错,避免把 groq 的密钥发给 OpenAI 或把自建服务的密钥发给厂商
16
+ - 修复读写越界:`audio` 与 `output`/`outputDir` 解析后必须落在会话工作区内(含符号链接解析),工作区外的文件不再被整份上传给 ASR,也不再在工作区外创建文件
17
+ - 拒绝跟随符号链接写入(含悬空链接),新建文件用 `O_EXCL`;转写文本改为 0600
18
+ - `voice_preview` 复用 `resolveOutputPath`:`overwrite=false` 时同名样例加序号,不再直接覆盖
19
+ - 音频大小按实际读到的字节数复查,`stat` 报 0 字节的伪文件不再绕过 25MB 预检
20
+ - 新增 8 项回归测试(工作区越界、私钥不被上传、符号链接、engine 错配、自定义网关、preview 不覆盖)
21
+
7
22
  ## 0.3.6 (2026-10-05)
8
23
 
9
24
  - ASR 网络与响应体读取均受超时和取消控制;保留用户取消原因,空白或缺失识别结果明确报错,不伪造成功文本文件。
package/README.en.md CHANGED
@@ -8,6 +8,8 @@ Generate speech from text or transcribe audio through a compatible API.
8
8
 
9
9
  [![npm](https://img.shields.io/npm/v/dsh-voice)](https://www.npmjs.com/package/dsh-voice) [![downloads](https://raw.githubusercontent.com/STARDUSTLC666/dsh-suite/npm-downloads/assets/dsh-voice-downloads.svg)](https://www.npmjs.com/package/dsh-voice)
10
10
 
11
+ Feedback and contributions are welcome: report [issues](https://github.com/STARDUSTLC666/dsh-voice/issues) or submit [pull requests](https://github.com/STARDUSTLC666/dsh-voice/pulls).
12
+
11
13
  ## What it does
12
14
 
13
15
  - Generate speech using the Edge online read-aloud service.
@@ -41,6 +43,12 @@ Detailed configuration, tool arguments and troubleshooting are in the [usage gui
41
43
  - [Validation scope and history](docs/VALIDATION.md)
42
44
  - [Report a problem or suggest a feature](https://github.com/STARDUSTLC666/dsh-voice/issues)
43
45
 
46
+ ## Plugin updates
47
+
48
+ Requires DSH 0.2.1-rc.3 or newer. Each plugin provides its own check, release-note and confirmed-install controls on its page or in settings. Preferences are independent. Automatic checks are on by default; **automatic installation is off by default**. Explicitly enabled automatic updates only install compatible stable patches while idle with no unsaved changes. Disable, defer or ignore reminders per plugin. DSH is never restarted or upgraded automatically, and local-source installs are preserved. Suite components clearly direct updates through Suite.
49
+
50
+ Older releases need one manual update in native plugin management to acquire these controls. Publishing to npm does not replace an existing user installation.
51
+
44
52
  ## License
45
53
 
46
54
  [MIT](LICENSE)
package/README.md CHANGED
@@ -8,6 +8,8 @@
8
8
 
9
9
  [![npm](https://img.shields.io/npm/v/dsh-voice)](https://www.npmjs.com/package/dsh-voice) [![downloads](https://raw.githubusercontent.com/STARDUSTLC666/dsh-suite/npm-downloads/assets/dsh-voice-downloads.svg)](https://www.npmjs.com/package/dsh-voice)
10
10
 
11
+ 欢迎使用,遇到问题或有改进建议,请提交 [issues](https://github.com/STARDUSTLC666/dsh-voice/issues) 和 [PR](https://github.com/STARDUSTLC666/dsh-voice/pulls)。
12
+
11
13
  ## 功能
12
14
 
13
15
  - 使用 Edge 在线朗读服务生成语音。
@@ -41,6 +43,12 @@ dsh plugin --profile desktop add dsh-voice
41
43
  - [验证范围与历史记录](docs/VALIDATION.md)
42
44
  - [问题反馈与功能建议](https://github.com/STARDUSTLC666/dsh-voice/issues)
43
45
 
46
+ ## 插件更新
47
+
48
+ 需要 DSH 0.2.1-rc.3 或更新版本。每个插件在自己的页面或设置项提供检查、更新记录与确认安装入口,开关独立保存。自动检查默认开启;**自动安装默认关闭**,只有明确授权后,兼容的稳定补丁才会在空闲且没有未保存修改时安装。可单独关闭、推迟或忽略提醒;不会强制重启 DSH、升级 DSH 本体或替换本地源码安装。Suite 安装的组件会明确提示通过 Suite 整包更新。
49
+
50
+ 旧版用户先在官方插件管理中手动更新一次,之后才会获得此入口。发布到 npm 不会直接替换用户已经安装的版本。
51
+
44
52
  ## License
45
53
 
46
54
  [MIT](LICENSE)
package/docs/USAGE.en.md CHANGED
@@ -40,7 +40,7 @@ Then restart the web service. To clean up fully, also remove the plugin entry fr
40
40
  | Tool | Purpose | Key parameters |
41
41
  | :-- | :-- | :-- |
42
42
  | `voice_tts` | Synthesize MP3 from text (Edge online service) | `text` required; `voice`/`rate`/`pitch`/`output` optional |
43
- | `voice_stt` | Transcribe audio to text | `audio` required; `engine`/`model`/`language`/`prompt`/`output` optional |
43
+ | `voice_stt` | Transcribe audio to text | `audio` required; `model`/`language`/`prompt`/`output` optional; `engine` may only repeat the configured `asrEngine` |
44
44
  | `voice_list` | Curated voice catalog | none |
45
45
  | `voice_preview` | Batch-generate short preview MP3s | optional `voices` (≤8) / `text` / `outputDir` |
46
46
  | `voice_health` | Offline config self-check | none |
@@ -50,7 +50,7 @@ Then restart the web service. To clean up fully, also remove the plugin entry fr
50
50
  ```text
51
51
  voice_tts { text: hello world } # outputs voice_output.mp3
52
52
  voice_tts { text: hello, voice: en-US-AriaNeural } # English female voice
53
- voice_stt { audio: E:\audio\meeting.mp3, language: zh } # transcribe a recording
53
+ voice_stt { audio: meeting.mp3, language: zh } # transcribe a recording inside the session workspace
54
54
  voice_list {}
55
55
  voice_preview { voices: [zh-CN-XiaoxiaoNeural, en-US-AriaNeural] } # generate two preview samples
56
56
  voice_health {} # self-check TTS / ASR / proxy config
@@ -60,7 +60,11 @@ voice_health {} # self-check TTS /
60
60
 
61
61
  - **Direct edge-tts protocol**: the Sec-MS-GEC token is **generated locally** with the official DRM algorithm (SHA256 of Windows file time + trusted client token, 5-minute windows); transport uses the `ws` library with permessage-deflate and an optional HTTP CONNECT proxy tunnel
62
62
  - **API configuration**: TTS needs no separate API key. STT uses your configured ASR service, whose pricing and limits apply.
63
- - Up-front validation: text ≤ 5000 chars, audio ≤ 25MB; same-name outputs auto-suffixed
63
+ - Up-front validation: text ≤ 5000 chars, audio ≤ 25MB (re-checked against the bytes actually read); same-name outputs auto-suffixed
64
+ - **Reads and writes stay inside the session workspace**: `audio`, `output` and `outputDir` must resolve (symlinks included) under `session.header.cwd`; anything outside is rejected. Copy the file in first.
65
+ - **Symlinks are never written through**: a target that is a symlink (including a dangling one) is refused, and new files are created with `O_EXCL`.
66
+ - **Transcripts are 0600**: the `.txt` written by `voice_stt` is owner-only (Windows does not enforce the mode bits).
67
+ - **The key only goes to the configured endpoint**: ASR always uses `asrBaseUrl` from config (the groq/openai presets are defaults only); an `engine` argument that differs from config is rejected instead of shipping the key to another vendor.
64
68
  - Protocol aligned with current open-source edge-tts (7.x) — no reliance on the outdated token endpoint
65
69
 
66
70
  ## Development
package/docs/USAGE.md CHANGED
@@ -40,7 +40,7 @@ dsh plugin --profile web remove dsh-voice
40
40
  | 工具 | 作用 | 关键参数 |
41
41
  | :-- | :-- | :-- |
42
42
  | `voice_tts` | 文字合成 MP3(Edge 在线服务) | `text` 必填;`voice`/`rate`/`pitch`/`output` 可选 |
43
- | `voice_stt` | 音频转文字 | `audio` 必填;`engine`/`model`/`language`/`prompt`/`output` 可选 |
43
+ | `voice_stt` | 音频转文字 | `audio` 必填;`model`/`language`/`prompt`/`output` 可选;`engine` 只能与配置 `asrEngine` 相同 |
44
44
  | `voice_list` | 常用音色清单 | 无 |
45
45
  | `voice_preview` | 音色试听:批量生成短样例 MP3 | `voices`(≤8 个)/ `text` / `outputDir` 可选 |
46
46
  | `voice_health` | 配置自检(不联网) | 无 |
@@ -50,7 +50,7 @@ dsh plugin --profile web remove dsh-voice
50
50
  ```text
51
51
  voice_tts { text: 今天的 AI 早报来了 } # 晓晓女声,输出 voice_output.mp3
52
52
  voice_tts { text: hello, voice: en-US-AriaNeural } # 英文女声
53
- voice_stt { audio: E:\audio\meeting.mp3, language: zh } # 转写会议录音
53
+ voice_stt { audio: meeting.mp3, language: zh } # 转写会话工作区里的录音
54
54
  voice_list {}
55
55
  voice_preview { voices: [zh-CN-XiaoxiaoNeural, en-US-AriaNeural] } # 生成两个试听样例
56
56
  voice_health {} # 自检 TTS / ASR / 代理配置
@@ -60,7 +60,11 @@ voice_health {} # 自检 TTS / ASR /
60
60
 
61
61
  - **edge-tts 协议直连**:Sec-MS-GEC 令牌按官方 DRM 算法**本地生成**(SHA256(Windows 文件时间 + TrustedClientToken),5 分钟窗口),WS 传输用 `ws` 库 + permessage-deflate 压缩 + 可选 HTTP CONNECT 代理隧道
62
62
  - **接口配置**:TTS 无需另填 API Key;STT 使用你配置的 ASR 服务,其计费与额度由该服务决定。
63
- - 文本 ≤5000 字符、音频 ≤25MB 前置校验;输出同名自动加序号
63
+ - 文本 ≤5000 字符、音频 ≤25MB 前置校验(读入后再按实际字节数复查一次);输出同名自动加序号
64
+ - **读写都在会话工作区内**:`audio` 与 `output` / `outputDir` 解析后(含符号链接)必须落在 `session.header.cwd` 之下,越界直接报错。想转写工作区外的录音,先复制进来;输出同理
65
+ - **不跟随符号链接写文件**:目标是符号链接(含悬空链接)时拒绝写入,新建文件用 `O_EXCL`,避免检查完再被换成链接
66
+ - **转写文本 0600**:`voice_stt` 写出的 `.txt` 只给当前用户读写(Windows 不检查权限位)
67
+ - **密钥只发给配置的地址**:ASR 请求地址一律取配置 `asrBaseUrl`(groq/openai 的预设只是缺省值),模型传的 `engine` 与配置不一致会直接报错,避免把密钥发到另一家的主机
64
68
  - Edge TTS 协议适配保留在插件实现中,不使用旧令牌端点。
65
69
 
66
70
  ## 开发
package/lib/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { registerUpdates } from '@stardustlc/dsh-updates';
1
2
  /**
2
3
  * dsh-voice —— 语音双件套工具插件(node 半身,配置走 cordis.patch.yml)。
3
4
  *
@@ -16,6 +17,7 @@ export const inject = ['tools'];
16
17
  * 插件入口:解析配置并注册五个语音工具。
17
18
  */
18
19
  export function apply(ctx, config) {
20
+ registerUpdates(ctx, { name: "dsh-voice" });
19
21
  let cfg;
20
22
  try {
21
23
  cfg = resolveConfig(config);
package/lib/paths.d.ts CHANGED
@@ -1,4 +1,23 @@
1
- /** 校验音频文件存在且是文件;相对路径按 cwd(会话工作区)解析,返回绝对路径。 */
1
+ /**
2
+ * 校验音频文件:必须存在于会话工作区内,且是普通文件。
3
+ *
4
+ * 相对路径按 cwd(会话工作区)解析,返回解析后的绝对路径;符号链接会先
5
+ * 解析真实路径再判断边界,避免把工作区外的隐私文件(如 id_ed25519)当成
6
+ * 音频整份上传给 ASR 服务。
7
+ */
2
8
  export declare function assertAudioFile(input: string, cwd?: string): string;
3
- /** 决定输出路径:缺省放在会话工作目录(缺省回退宿主 cwd),同名自动加 _1/_2 序号。 */
9
+ /**
10
+ * 决定输出路径:缺省放在会话工作目录(缺省回退宿主 cwd),同名自动加 _1/_2 序号。
11
+ *
12
+ * 目标必须落在会话工作区内;已存在的符号链接直接拒绝,避免顺着悬空链接
13
+ * 在工作区外创建文件。overwrite=false 时跳过已存在的路径(含符号链接)。
14
+ */
4
15
  export declare function resolveOutputPath(explicit: string | undefined, defaultName: string, overwrite: boolean, cwd?: string): string;
16
+ /** 确保输出目录存在且落在工作区内:不存在则创建,越界或符号链接报错。 */
17
+ export declare function ensureWorkspaceDirectory(input: string, cwd?: string): string;
18
+ /**
19
+ * 写工作区内的文件:先建父目录,再按「新建用 wx / 覆盖用 w」落盘。
20
+ *
21
+ * 新建时用 O_EXCL,避免检查完成到写入之间被人塞进一个符号链接。
22
+ */
23
+ export declare function writeWorkspaceFile(target: string, data: string | Buffer, cwd?: string, mode?: number): void;
package/lib/paths.js CHANGED
@@ -1,30 +1,152 @@
1
1
  /**
2
- * 输出路径与音频文件校验。
2
+ * 输出路径、音频文件与工作区边界校验。
3
3
  *
4
4
  * @module dsh-voice/paths
5
5
  */
6
- import { existsSync, statSync } from 'node:fs';
7
- import { basename, dirname, extname, join, resolve } from 'node:path';
8
- /** 校验音频文件存在且是文件;相对路径按 cwd(会话工作区)解析,返回绝对路径。 */
6
+ import { lstatSync, mkdirSync, openSync, closeSync, realpathSync, statSync, writeFileSync } from 'node:fs';
7
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
8
+ /** 会话工作区的真实路径(解析符号链接);取不到就退回解析后的绝对路径。 */
9
+ function workspaceRoot(cwd) {
10
+ const absolute = resolve(cwd);
11
+ try {
12
+ return realpathSync(absolute);
13
+ }
14
+ catch {
15
+ return absolute;
16
+ }
17
+ }
18
+ /** candidate 是否等于 root 或位于 root 之下。 */
19
+ function isInside(root, candidate) {
20
+ const rel = relative(root, candidate);
21
+ return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith('..' + sep));
22
+ }
23
+ function pathState(target) {
24
+ try {
25
+ return lstatSync(target).isSymbolicLink() ? 'symlink' : 'present';
26
+ }
27
+ catch {
28
+ return 'missing';
29
+ }
30
+ }
31
+ /**
32
+ * 沿着最近的已存在祖先解析真实路径,再接回尚不存在的后缀。
33
+ *
34
+ * 用于输出路径:目标文件通常还不存在,但仍要判断它落盘时会不会被
35
+ * 中间某一层符号链接带到工作区之外。
36
+ */
37
+ function resolveThroughExistingAncestor(target) {
38
+ let current = target;
39
+ const missing = [];
40
+ for (;;) {
41
+ try {
42
+ const real = realpathSync(current);
43
+ return missing.length === 0 ? real : join(real, ...missing.slice().reverse());
44
+ }
45
+ catch {
46
+ const parent = dirname(current);
47
+ if (parent === current)
48
+ return target;
49
+ missing.push(basename(current));
50
+ current = parent;
51
+ }
52
+ }
53
+ }
54
+ /** 输出路径必须落在工作区内,且不能是符号链接(含悬空链接)。 */
55
+ function assertWritableTarget(target, root, label) {
56
+ if (pathState(target) === 'symlink') {
57
+ throw new Error(label + ' 是符号链接,拒绝写入:' + target + '(请改用工作区内的普通文件路径)');
58
+ }
59
+ const real = resolveThroughExistingAncestor(target);
60
+ if (!isInside(root, real)) {
61
+ throw new Error(label + ' 超出会话工作区:' + target + '(实际解析到 ' + real + ')。请把输出放在会话工作目录内。');
62
+ }
63
+ }
64
+ /**
65
+ * 校验音频文件:必须存在于会话工作区内,且是普通文件。
66
+ *
67
+ * 相对路径按 cwd(会话工作区)解析,返回解析后的绝对路径;符号链接会先
68
+ * 解析真实路径再判断边界,避免把工作区外的隐私文件(如 id_ed25519)当成
69
+ * 音频整份上传给 ASR 服务。
70
+ */
9
71
  export function assertAudioFile(input, cwd = process.cwd()) {
10
- const absolute = resolve(cwd, input);
11
- if (!existsSync(absolute) || !statSync(absolute).isFile()) {
72
+ const root = workspaceRoot(cwd);
73
+ const requested = resolve(root, input);
74
+ let real;
75
+ try {
76
+ real = realpathSync(requested);
77
+ }
78
+ catch {
12
79
  throw new Error('音频文件不存在:' + input);
13
80
  }
14
- return absolute;
81
+ if (!isInside(root, real)) {
82
+ throw new Error('音频文件必须位于会话工作区内:' + input + '(实际解析到 ' + real + ')。请先复制到工作目录再转写。');
83
+ }
84
+ if (!statSync(real).isFile())
85
+ throw new Error('音频文件不是普通文件:' + input);
86
+ return requested;
15
87
  }
16
- /** 决定输出路径:缺省放在会话工作目录(缺省回退宿主 cwd),同名自动加 _1/_2 序号。 */
88
+ /**
89
+ * 决定输出路径:缺省放在会话工作目录(缺省回退宿主 cwd),同名自动加 _1/_2 序号。
90
+ *
91
+ * 目标必须落在会话工作区内;已存在的符号链接直接拒绝,避免顺着悬空链接
92
+ * 在工作区外创建文件。overwrite=false 时跳过已存在的路径(含符号链接)。
93
+ */
17
94
  export function resolveOutputPath(explicit, defaultName, overwrite, cwd = process.cwd()) {
18
- let target = explicit !== undefined && explicit.trim() !== '' ? resolve(cwd, explicit.trim()) : resolve(cwd, defaultName);
19
- if (overwrite || !existsSync(target))
95
+ const root = workspaceRoot(cwd);
96
+ const requested = explicit !== undefined && explicit.trim() !== '' ? explicit.trim() : defaultName;
97
+ const target = resolve(root, requested);
98
+ assertWritableTarget(target, root, '输出路径');
99
+ if (overwrite || pathState(target) === 'missing')
20
100
  return target;
21
101
  const directory = dirname(target);
22
102
  const base = basename(target, extname(target));
23
103
  const extension = extname(target);
24
104
  for (let index = 1; index < 1000; index++) {
25
105
  const candidate = join(directory, base + '_' + index + extension);
26
- if (!existsSync(candidate))
27
- return candidate;
106
+ if (pathState(candidate) !== 'missing')
107
+ continue;
108
+ assertWritableTarget(candidate, root, '输出路径');
109
+ return candidate;
28
110
  }
29
111
  throw new Error('找不到可用的输出文件名(同名文件超过 999 个),请显式指定 output。');
30
112
  }
113
+ /** 确保输出目录存在且落在工作区内:不存在则创建,越界或符号链接报错。 */
114
+ export function ensureWorkspaceDirectory(input, cwd = process.cwd()) {
115
+ const root = workspaceRoot(cwd);
116
+ const target = resolve(root, input);
117
+ if (pathState(target) === 'symlink') {
118
+ throw new Error('输出目录是符号链接,拒绝写入:' + target);
119
+ }
120
+ const real = resolveThroughExistingAncestor(target);
121
+ if (!isInside(root, real)) {
122
+ throw new Error('输出目录超出会话工作区:' + target + '(实际解析到 ' + real + ')。请把输出放在会话工作目录内。');
123
+ }
124
+ mkdirSync(target, { recursive: true });
125
+ const after = realpathSync(target);
126
+ if (!isInside(root, after)) {
127
+ throw new Error('输出目录超出会话工作区:' + target + '(实际解析到 ' + after + ')');
128
+ }
129
+ return target;
130
+ }
131
+ /**
132
+ * 写工作区内的文件:先建父目录,再按「新建用 wx / 覆盖用 w」落盘。
133
+ *
134
+ * 新建时用 O_EXCL,避免检查完成到写入之间被人塞进一个符号链接。
135
+ */
136
+ export function writeWorkspaceFile(target, data, cwd = process.cwd(), mode) {
137
+ const root = workspaceRoot(cwd);
138
+ assertWritableTarget(target, root, '输出路径');
139
+ mkdirSync(dirname(target), { recursive: true });
140
+ assertWritableTarget(target, root, '输出路径');
141
+ if (pathState(target) === 'missing') {
142
+ const fd = openSync(target, 'wx', mode);
143
+ try {
144
+ writeFileSync(fd, data);
145
+ }
146
+ finally {
147
+ closeSync(fd);
148
+ }
149
+ return;
150
+ }
151
+ writeFileSync(target, data, mode === undefined ? undefined : { mode });
152
+ }
package/lib/tools.js CHANGED
@@ -3,10 +3,10 @@
3
3
  *
4
4
  * @module dsh-voice/tools
5
5
  */
6
- import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
7
- import { basename, dirname, join, resolve } from 'node:path';
6
+ import { readFileSync, statSync } from 'node:fs';
7
+ import { basename, join } from 'node:path';
8
8
  import { synthesizeSpeech } from './edge-tts.js';
9
- import { assertAudioFile, resolveOutputPath } from './paths.js';
9
+ import { assertAudioFile, ensureWorkspaceDirectory, resolveOutputPath, writeWorkspaceFile } from './paths.js';
10
10
  import { createProxyFetch } from './proxy-fetch.js';
11
11
  import { assertAudioSize, transcribe } from './stt.js';
12
12
  import { isValidVoiceId, VOICES } from './voices.js';
@@ -87,11 +87,6 @@ function sessionCwd(exec) {
87
87
  const cwd = exec?.agent?.session?.header?.cwd;
88
88
  return typeof cwd === 'string' && cwd.trim() !== '' ? cwd : process.cwd();
89
89
  }
90
- /** 写文件前先建父目录,避免合成/转写完成后才因 ENOENT 白烧一次接口调用。 */
91
- function writeFileWithDir(target, data) {
92
- mkdirSync(dirname(target), { recursive: true });
93
- writeFileSync(target, data);
94
- }
95
90
  const voiceItemSchema = {
96
91
  type: 'object',
97
92
  properties: { id: { type: 'string' }, gender: { type: 'string' }, locale: { type: 'string' } },
@@ -135,6 +130,8 @@ const previewSchema = {
135
130
  },
136
131
  additionalProperties: true,
137
132
  };
133
+ /** 自定义端点没配 asrModel 时的兜底模型名。 */
134
+ const FALLBACK_ASR_MODEL = 'whisper-1';
138
135
  /** 默认试听文本(中英混合,便于感知发音差异)。 */
139
136
  export const DEFAULT_PREVIEW_TEXT = '你好,这是音色试听。Hello, this is a voice preview.';
140
137
  /** voice_preview 同时合成的音色数上限:有界并发,避免 8 个音色串行把最坏等待拖成 8 倍超时。 */
@@ -203,7 +200,7 @@ export function buildVoiceTools(config, deps = {}) {
203
200
  const output = resolveOutputPath(optionalString(args, 'output'), 'voice_output.mp3', cfg.overwrite, sessionCwd(exec));
204
201
  const audio = await raceAbort((deps.tts ?? synthesizeSpeech)({ text, voice, rate, pitch }, { proxyUrl: cfg.proxyUrl }, timeout, signal), signal);
205
202
  throwIfAborted(signal);
206
- writeFileWithDir(output, audio);
203
+ writeWorkspaceFile(output, audio, sessionCwd(exec));
207
204
  return { output, bytes: audio.length, voice, rate, pitch, textLength: text.length };
208
205
  },
209
206
  timeoutMs: timeout + 10000,
@@ -213,7 +210,7 @@ export function buildVoiceTools(config, deps = {}) {
213
210
  description: '语音转文字:调用 OpenAI 兼容 ASR 接口(默认 Groq whisper-large-v3-turbo;可切 openai/custom)。audio 为音频文件路径(mp3/wav/m4a/ogg/flac,≤25MB);language/prompt 可选;output 可把转写文本写成 .txt。密钥用环境变量 DSH_VOICE_ASR_KEY 或配置 asrApiKey。',
214
211
  parameters: compileParameters({
215
212
  audio: { type: 'string', required: true, description: '音频文件路径(必填)。' },
216
- engine: { type: 'string', enum: ['groq', 'openai', 'custom'], description: '引擎:groq / openai / custom(可选,默认配置值)。' },
213
+ engine: { type: 'string', enum: ['groq', 'openai', 'custom'], description: '引擎(可选):只接受与配置 asrEngine 相同的值,用于显式确认;接口地址与密钥始终取配置。' },
217
214
  model: { type: 'string', description: '模型名(可选,覆盖配置)。' },
218
215
  language: { type: 'string', description: '语言提示,如 zh(可选)。' },
219
216
  prompt: { type: 'string', description: '提示词(专有名词/术语纠偏,可选)。' },
@@ -230,27 +227,26 @@ export function buildVoiceTools(config, deps = {}) {
230
227
  const signal = execSignal(exec);
231
228
  throwIfAborted(signal);
232
229
  const args = asRecord(rawArgs);
233
- const audioPath = assertAudioFile(requiredString(args, 'audio', '音频文件'), sessionCwd(exec));
234
- assertAudioSize(statSync(audioPath).size);
235
- const engine = optionalString(args, 'engine') ?? cfg.asrEngine;
236
- let baseUrl;
237
- let model;
238
- if (engine === 'openai') {
239
- baseUrl = 'https://api.openai.com/v1';
240
- model = optionalString(args, 'model') ?? 'whisper-1';
230
+ const cwd = sessionCwd(exec);
231
+ const audioPath = assertAudioFile(requiredString(args, 'audio', '音频文件'), cwd);
232
+ const requestedEngine = optionalString(args, 'engine');
233
+ if (requestedEngine !== undefined && !['groq', 'openai', 'custom'].includes(requestedEngine)) {
234
+ throw new Error('engine 必须是 groq / openai / custom 之一(当前:' + requestedEngine + ')。');
241
235
  }
242
- else if (engine === 'custom') {
243
- baseUrl = cfg.asrBaseUrl;
244
- model = optionalString(args, 'model') ?? cfg.asrModel;
236
+ if (requestedEngine !== undefined && requestedEngine !== cfg.asrEngine) {
237
+ throw new Error('voice_stt 不支持用 engine 参数临时换引擎(配置 asrEngine=' + cfg.asrEngine + ',本次传入 ' + requestedEngine + ')。接口地址与密钥是一组配置,混着用会把密钥发到非预期的服务;如需切换请改配置里的 asrEngine / asrBaseUrl。');
245
238
  }
246
- else if (engine === 'groq') {
247
- baseUrl = 'https://api.groq.com/openai/v1';
248
- model = optionalString(args, 'model') ?? (cfg.asrModel !== '' && cfg.asrEngine === 'groq' ? cfg.asrModel : 'whisper-large-v3-turbo');
249
- }
250
- else
251
- throw new Error('engine 必须是 groq / openai / custom 之一(当前:' + engine + ')。');
239
+ // 接口地址一律取配置:openai/groq 预设已由 resolveConfig 回填,自定义端点原样使用。
240
+ const baseUrl = cfg.asrBaseUrl;
241
+ if (baseUrl === '')
242
+ throw new Error('未配置 ASR 接口地址:asrEngine=custom 时必须在配置里提供 asrBaseUrl。');
243
+ assertAudioSize(statSync(audioPath).size);
244
+ const audio = readFileSync(audioPath);
245
+ // 伪文件(如 procfs)stat 报 0 字节,按实际读到的长度再判一次。
246
+ assertAudioSize(audio.length);
247
+ const model = optionalString(args, 'model') ?? (cfg.asrModel !== '' ? cfg.asrModel : FALLBACK_ASR_MODEL);
252
248
  const { text } = await raceAbort((deps.stt ?? transcribe)(baseUrl, cfg.asrApiKey, {
253
- audio: readFileSync(audioPath),
249
+ audio,
254
250
  filename: basename(audioPath),
255
251
  model,
256
252
  language: optionalString(args, 'language'),
@@ -260,8 +256,9 @@ export function buildVoiceTools(config, deps = {}) {
260
256
  let transcriptFile = '';
261
257
  const output = optionalString(args, 'output');
262
258
  if (output !== undefined) {
263
- const target = resolveOutputPath(output, '', cfg.overwrite, sessionCwd(exec));
264
- writeFileWithDir(target, text);
259
+ const target = resolveOutputPath(output, '', cfg.overwrite, cwd);
260
+ // 转写文本可能含隐私内容,只用 0600 写入。
261
+ writeWorkspaceFile(target, text, cwd, 0o600);
265
262
  transcriptFile = target;
266
263
  }
267
264
  // The output schema declares these as strings — never return null, or
@@ -309,8 +306,8 @@ export function buildVoiceTools(config, deps = {}) {
309
306
  const text = optionalString(args, 'text') ?? DEFAULT_PREVIEW_TEXT;
310
307
  if (text.length > 200)
311
308
  throw new Error('试听文本请控制在 200 字以内(试听要短平快)。');
312
- const outDir = resolve(sessionCwd(exec), optionalString(args, 'outputDir') ?? 'voice_previews');
313
- mkdirSync(outDir, { recursive: true });
309
+ const cwd = sessionCwd(exec);
310
+ const outDir = ensureWorkspaceDirectory(optionalString(args, 'outputDir') ?? 'voice_previews', cwd);
314
311
  const outcomes = await mapLimit(targets, PREVIEW_CONCURRENCY, signal, async (voice) => {
315
312
  throwIfAborted(signal);
316
313
  if (!isValidVoiceId(voice)) {
@@ -319,8 +316,10 @@ export function buildVoiceTools(config, deps = {}) {
319
316
  try {
320
317
  const audio = await raceAbort((deps.tts ?? synthesizeSpeech)({ text, voice, rate: cfg.ttsRate, pitch: cfg.ttsPitch }, { proxyUrl: cfg.proxyUrl }, timeout, signal), signal);
321
318
  throwIfAborted(signal);
322
- const file = join(outDir, 'voice-preview-' + voice.replace(/[^a-zA-Z0-9-]/g, '_') + '.mp3');
323
- writeFileSync(file, audio);
319
+ const name = 'voice-preview-' + voice.replace(/[^a-zA-Z0-9-]/g, '_') + '.mp3';
320
+ // 复用 resolveOutputPath:overwrite=false 时改名加序号,不再直接覆盖同名样例。
321
+ const file = resolveOutputPath(join(outDir, name), '', cfg.overwrite, cwd);
322
+ writeWorkspaceFile(file, audio, cwd);
324
323
  return { voice, output: file, bytes: audio.length };
325
324
  }
326
325
  catch (error) {
@@ -0,0 +1,408 @@
1
+ // Generated: shared optional update controls; edit the original client templates.
2
+ (function(){
3
+ /* DSH workbench UI v1. MIT. Canonical source: dsh-suite/ui/workbench-ui.js.
4
+ * Vendored into each plugin: no Suite or extra runtime dependency is required.
5
+ * Styles are scoped to .dshw and never replace the host chrome or theme.
6
+ */
7
+ function createWorkbenchUI(React) {
8
+ const h = React.createElement;
9
+ const { useEffect, useId, useRef } = React;
10
+ const cx = (...values) => values.filter(Boolean).join(' ');
11
+ const paths = {
12
+ book: ['M4 4h6a3 3 0 0 1 2 2v15a4 4 0 0 0-4-2H4z', 'M20 4h-6a3 3 0 0 0-2 2v15a4 4 0 0 1 4-2h4z'],
13
+ calendar: ['M5 5h14a2 2 0 0 1 2 2v13H3V7a2 2 0 0 1 2-2Z', 'M7 3v4M17 3v4M3 10h18'],
14
+ mail: ['M3 5h18v14H3z', 'm3 6 9 7 9-7'],
15
+ rss: ['M4 11a9 9 0 0 1 9 9M4 4a16 16 0 0 1 16 16', 'M4 19h.01'],
16
+ slides: ['M3 4h18v13H3z', 'M12 17v4M8 21h8'],
17
+ shield: ['m12 3 8 3v6c0 4-4 7-8 9-4-2-8-5-8-9V6z', 'm8 12 3 3 5-6'],
18
+ layers: ['M5 3h16v16H5z', 'M2 7v15h15'],
19
+ play: ['m9 6 9 6-9 6Z'],
20
+ video: ['M3 5h12v14H3z', 'm15 10 6-4v12l-6-4'],
21
+ search: ['M10.5 18a7.5 7.5 0 1 0 0-15 7.5 7.5 0 0 0 0 15Z', 'm16 16 5 5'],
22
+ plus: ['M12 5v14M5 12h14'],
23
+ refresh: ['M20 7a9 9 0 1 0 1 8', 'M20 2v5h-5'],
24
+ settings: ['m9 3-.7 2.4-2 .9L4 5.8 2.5 8.4l1.7 1.8v2.3L2.5 14 4 17l2.5-.4 1.9 1L9 20h3l.8-2.4 2-1 2.5.4 1.5-2.7-1.7-1.9v-2.2l1.7-1.8-1.5-2.6-2.5.4-2-1L12 3Z', 'M14 11.5a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0Z'],
25
+ close: ['m6 6 12 12M18 6 6 18'],
26
+ left: ['m14 5-7 7 7 7'],
27
+ right: ['m10 5 7 7-7 7'],
28
+ down: ['m5 9 7 7 7-7'],
29
+ filter: ['M4 5h16M7 12h10M10 19h4'],
30
+ download: ['M12 3v12m-5-5 5 5 5-5', 'M4 16v5h16v-5'],
31
+ upload: ['M12 16V4m-5 5 5-5 5 5', 'M4 16v5h16v-5'],
32
+ copy: ['M8 8h13v13H8z', 'M16 8V3H3v13h5'],
33
+ external: ['M14 3h7v7m0-7L10 14', 'M10 3H3v18h18v-7'],
34
+ file: ['M4 3h10l6 6v12H4z', 'M14 3v6h6M8 13h8M8 17h6'],
35
+ folder: ['M3 5h7l2 3h9v12H3z'],
36
+ edit: ['m15 4 5 5-10 10-6 1 1-6Z', 'm13 6 5 5'],
37
+ trash: ['M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7M14 10v7'],
38
+ more: ['M5 12h.01M12 12h.01M19 12h.01'],
39
+ info: ['M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Z', 'M12 11v6M12 7h.01'],
40
+ check: ['m5 12 4 4L19 6'],
41
+ quote: ['M4 12h6v7H3v-7c0-4 2-6 5-7M15 12h6v7h-7v-7c0-4 2-6 5-7'],
42
+ star: ['m12 3 2.8 5.7 6.2.9-4.5 4.4 1.1 6.2-5.6-3-5.6 3 1.1-6.2L3 9.6l6.2-.9Z'],
43
+ reply: ['m9 5-7 7 7 7v-5h6c3 0 5 2 6 5 0-8-4-11-12-11Z'],
44
+ link: ['m10 13 4-4M8 16l-2 2a4 4 0 0 1-6-6l4-4a4 4 0 0 1 6 0', 'm14 8 2-2a4 4 0 0 1 6 6l-4 4a4 4 0 0 1-6 0'],
45
+ };
46
+ function Icon({ name = 'file', size = 20, ...props }) {
47
+ return h('svg', { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.75, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true, focusable: false, ...props },
48
+ (paths[name] || paths.file).map((d, index) => h('path', { key: index, d })));
49
+ }
50
+ function Button({ children, icon, variant, className, ...props }) {
51
+ return h('button', { type: 'button', ...props, className: cx('w-button', variant && 'w-' + variant, className) }, icon ? h(Icon, { name: icon, size: 18 }) : null, children);
52
+ }
53
+ function IconButton({ label, icon, ...props }) {
54
+ return h(Button, { ...props, icon, className: cx('w-icon-button', props.className), 'aria-label': label, title: label });
55
+ }
56
+ function Page({ title, subtitle, actions, children, workbench = false, className, rootRef, ...props }) {
57
+ return h('section', { ...props, ref: rootRef, className: cx('dshw', 'w-page', className), 'data-main': workbench ? 'true' : 'false' },
58
+ h('style', null, styles),
59
+ h('header', { className: 'w-page-header' }, h('div', { className: 'w-heading' }, h('h1', null, title), subtitle ? h('p', { className: 'w-subtitle' }, subtitle) : null), actions ? h('div', { className: 'w-header-actions' }, actions) : null),
60
+ h('div', { className: 'w-body' }, children));
61
+ }
62
+ function Tabs({ items, value, onChange, label, children }) {
63
+ const id = useId();
64
+ const active = items.some(item => item.id === value) ? value : items[0]?.id;
65
+ function keys(event, index) {
66
+ if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
67
+ event.preventDefault();
68
+ const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : (index + (event.key === 'ArrowRight' ? 1 : -1) + items.length) % items.length;
69
+ const node = event.currentTarget.parentElement?.querySelectorAll('[role=tab]')[next];
70
+ if (node) { onChange(items[next].id); node.focus(); }
71
+ }
72
+ return h('div', { className: 'w-tabs-layout' },
73
+ h('div', { className: 'w-tabs', role: 'tablist', 'aria-label': label }, items.map((item, index) => h('button', {
74
+ type: 'button', role: 'tab', key: item.id, id: id + '-' + item.id, 'aria-controls': id + '-panel', 'aria-selected': active === item.id, tabIndex: active === item.id ? 0 : -1,
75
+ onClick: () => onChange(item.id), onKeyDown: event => keys(event, index),
76
+ }, item.label, item.count !== undefined ? h('span', { className: 'w-count' }, item.count) : null))),
77
+ h('div', { className: 'w-tab-panel', id: id + '-panel', role: 'tabpanel', 'aria-labelledby': id + '-' + active }, children));
78
+ }
79
+ function Status({ children, tone = 'info', className }) {
80
+ if (!children) return null;
81
+ return h('div', { className: cx('w-status', 'w-status-' + tone, className), role: tone === 'error' ? 'alert' : 'status', 'aria-live': tone === 'error' ? 'assertive' : 'polite' }, h(Icon, { name: tone === 'success' ? 'check' : 'info', size: 18 }), h('div', null, children));
82
+ }
83
+ function Empty({ title, children, action, icon = 'file' }) {
84
+ return h('div', { className: 'w-empty' }, h(Icon, { name: icon, size: 32 }), h('h2', null, title), children ? h('div', { className: 'w-muted' }, children) : null, action ? h('div', { className: 'w-toolbar' }, action) : null);
85
+ }
86
+ function Split({ list, detail, selected, onBack, backLabel, className, listWidth = 360 }) {
87
+ const ref = useRef(null);
88
+ useEffect(() => {
89
+ if (!selected || !ref.current || ref.current.clientWidth > 760) return;
90
+ const heading = ref.current.querySelector('.w-detail h2');
91
+ if (heading) { heading.tabIndex = -1; heading.focus({ preventScroll: true }); }
92
+ }, [selected]);
93
+ return h('div', { ref, className: cx('w-split', className), 'data-selected': selected ? 'true' : 'false', style: { '--w-list-width': listWidth + 'px' } },
94
+ h('div', { className: 'w-master' }, list),
95
+ h('div', { className: 'w-detail' }, selected ? h(Button, { className: 'w-back', icon: 'left', variant: 'ghost', onClick: onBack }, backLabel) : null, detail));
96
+ }
97
+ function Row({ title, meta, description, active, onClick, children, status, className, disabled }) {
98
+ return h('div', { className: cx('w-row', active && 'w-selected', className) },
99
+ children ? h('div', { className: 'w-row-select' }, children) : null,
100
+ h('button', { type: 'button', className: 'w-row-button', onClick, disabled, 'aria-pressed': !!active },
101
+ h('span', { className: 'w-row-head' }, h('strong', null, title), status ? h('span', { className: 'w-tag' }, status) : null),
102
+ meta ? h('span', { className: 'w-row-meta' }, meta) : null,
103
+ description ? h('span', { className: 'w-row-description' }, description) : null));
104
+ }
105
+ function Search({ label, value, onChange, ...props }) {
106
+ const id = useId();
107
+ return h('div', { className: 'w-search' }, h(Icon, { name: 'search', size: 18 }), h('input', { type: 'search', id, 'aria-label': label, placeholder: label, value, onChange, ...props }));
108
+ }
109
+ function Switch({ label, checked, onChange, disabled }) {
110
+ return h('label', { className: 'w-switch-label' }, h('span', null, label), h('input', { type: 'checkbox', role: 'switch', checked, disabled, onChange, 'aria-label': label }), h('span', { className: 'w-switch-track', 'aria-hidden': true }));
111
+ }
112
+ function Dialog({ title, children, onClose, closeLabel, className, busy = false }) {
113
+ const id = useId(), panel = useRef(null);
114
+ useEffect(() => {
115
+ const previous = document.activeElement;
116
+ panel.current?.querySelector('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex="0"]')?.focus();
117
+ return () => { if (previous?.isConnected) previous.focus({ preventScroll: true }); };
118
+ }, []);
119
+ function keydown(event) {
120
+ if (event.key === 'Escape' && !event.isComposing) { event.preventDefault(); event.stopPropagation(); if (!busy) onClose(); return; }
121
+ if (event.key !== 'Tab') return;
122
+ const controls = [...panel.current.querySelectorAll('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), a[href], [tabindex="0"]')].filter(node => node.getClientRects().length);
123
+ const first = controls[0], last = controls.at(-1);
124
+ if (!controls.length) { event.preventDefault(); panel.current.focus(); }
125
+ else if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
126
+ else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
127
+ }
128
+ return h('div', { className: 'w-overlay', onClick: event => { if (!busy && event.target === event.currentTarget) onClose(); } },
129
+ h('section', { ref: panel, className: cx('w-dialog', className), role: 'dialog', 'aria-modal': true, 'aria-labelledby': id, tabIndex: -1, onKeyDown: keydown },
130
+ h('header', { className: 'w-dialog-header' }, h('h2', { id }, title), h(IconButton, { icon: 'close', label: closeLabel, disabled: busy, onClick: onClose })), h('div', { className: 'w-dialog-body' }, children)));
131
+ }
132
+ function register(ctx, { id, label, icon, component, inject, order = 50 }) {
133
+ // The two slots are optional. Existing settings and manual tools still load
134
+ // in hosts without a workbench, while RC.3 owns navigation/pinning itself.
135
+ ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: id, inject: () => ({ ...(inject?.() || {}), workbench: true }) }, component));
136
+ ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ name: 'sidebar.panellist', id, order, label }, function WorkbenchIcon({ size }) { return h(Icon, { name: icon, size }); }));
137
+ }
138
+ const styles = `
139
+ .dshw{--w-bg:var(--dsw-alias-bg-layer-1,#fff);--w-soft:var(--dsw-alias-bg-layer-2,#f7f8fa);--w-text:var(--dsw-alias-label-primary,#18202c);--w-muted:var(--dsw-alias-label-secondary,#667085);--w-border:var(--dsw-alias-border-l3,#e7e9ef);--w-accent:var(--dsh-plugin-workbench-accent,#4f6bdb);--w-selected:color-mix(in srgb,var(--w-accent) 10%,var(--w-bg));--w-primary:var(--w-text);--w-on-primary:var(--w-bg);container-type:inline-size;container-name:dsh-workbench;color:var(--w-text);background:var(--w-bg);font:14px/1.65 var(--dsh-font-family-content,system-ui,-apple-system,"Segoe UI","Microsoft YaHei",sans-serif);min-width:0;max-width:100%;box-sizing:border-box;text-align:start;color-scheme:inherit}
140
+ .dshw *,.dshw *:before,.dshw *:after{box-sizing:border-box}.dshw h1,.dshw h2,.dshw h3,.dshw h4,.dshw p{margin:0}.dshw h1{font-size:30px;line-height:1.25;letter-spacing:-.025em;font-weight:650}.dshw h2{font-size:24px;line-height:1.35;font-weight:650;letter-spacing:-.015em;overflow-wrap:anywhere}.dshw h3,.dshw h4{font-size:16px;line-height:1.4;font-weight:600}.dshw a{color:var(--w-accent);text-underline-offset:3px;overflow-wrap:anywhere}.dshw button,.dshw input,.dshw select,.dshw textarea{font:inherit;color:inherit;max-width:100%}.dshw input:not([type=checkbox]):not([type=radio]):not([type=file]),.dshw select,.dshw textarea{background:var(--w-bg);border:1px solid var(--w-border);border-radius:8px;padding:8px 11px;min-width:0;line-height:1.5}.dshw input:not([type=checkbox]):not([type=radio]):not([type=file]),.dshw select{min-height:38px}.dshw textarea{resize:vertical}.dshw input[type=checkbox],.dshw input[type=radio]{width:16px;height:16px;flex-shrink:0;accent-color:var(--w-accent)}.dshw :focus-visible{outline:2px solid var(--w-accent);outline-offset:3px}.dshw button:disabled,.dshw input:disabled,.dshw select:disabled,.dshw textarea:disabled{opacity:.5;cursor:not-allowed}.dshw label{font-size:13px}.dshw fieldset{min-width:0;border:1px solid var(--w-border);border-radius:8px}.dshw summary{cursor:pointer;font-weight:550;list-style:none;display:flex;align-items:center;gap:8px}.dshw summary::-webkit-details-marker{display:none}.dshw summary:after{content:'';display:block;margin-inline-start:auto;width:7px;height:7px;border-inline-end:1.75px solid currentColor;border-bottom:1.75px solid currentColor;transform:rotate(45deg);flex-shrink:0}.dshw details[open]>summary:after{transform:rotate(225deg)}.dshw details>summary+*{margin-top:12px}.dshw pre{white-space:pre-wrap;overflow-wrap:anywhere;font:12px/1.7 ui-monospace,SFMono-Regular,Consolas,monospace;max-width:100%;background:var(--w-soft);border-radius:6px;padding:14px}.dshw img,.dshw video,.dshw iframe{max-width:100%}
141
+ .w-page{display:flex;flex-direction:column;position:relative;padding:0;min-height:540px;gap:0}.w-page[data-main=true]{height:100%;min-height:0;padding:calc(24px + var(--dsh-frame-top-clearance,0px)) 28px 0 max(28px,var(--dsh-frame-leading-clearance,0px));overflow:auto}.w-page-header{display:flex;justify-content:space-between;gap:24px;align-items:flex-start;padding-bottom:22px;flex-shrink:0}.w-heading{min-width:0}.w-subtitle{margin-top:8px!important;font-size:14px;color:var(--w-muted);line-height:1.6}.w-header-actions,.w-toolbar,.w-actions{display:flex;align-items:center;flex-wrap:wrap;gap:8px;min-width:0}.w-header-actions{justify-content:flex-end}.w-body,.w-tabs-layout{display:flex;flex-direction:column;flex:1;min-height:0;min-width:0}.w-body{overflow:auto}.w-tab-panel{flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;overflow:auto}.w-tabs{display:flex;align-items:stretch;gap:20px;border-bottom:1px solid var(--w-border);flex-shrink:0;overflow-x:auto;scrollbar-width:thin}.dshw .w-tabs>button{border:0;border-bottom:2px solid transparent;background:transparent;padding:11px 8px 13px;white-space:nowrap;font-size:14px;font-weight:550;cursor:pointer;color:var(--w-muted);display:flex;align-items:center;gap:7px}.dshw .w-tabs>button[aria-selected=true]{border-bottom-color:var(--w-accent);color:var(--w-accent)}.w-count,.w-tag{font-size:12px;line-height:1.5;display:inline-flex;align-items:center;flex-shrink:0;background:var(--w-soft);border-radius:5px;padding:1px 6px;color:var(--w-muted)}.w-tabs [aria-selected=true] .w-count,.w-selected .w-tag{color:var(--w-accent);background:var(--w-selected)}
142
+ .dshw .w-button{display:inline-flex;justify-content:center;align-items:center;gap:7px;background:var(--w-bg);border:1px solid var(--w-border);border-radius:8px;padding:8px 12px;min-height:36px;line-height:1.4;font-size:14px;font-weight:500;cursor:pointer;text-decoration:none;white-space:normal}.dshw .w-button:not(:disabled):hover{background:var(--w-soft)}.dshw .w-primary{background:var(--w-primary);border-color:var(--w-primary);color:var(--w-on-primary)}.dshw .w-primary:not(:disabled):hover{background:var(--w-primary);opacity:.9}.dshw .w-ghost{background:transparent;border-color:transparent}.dshw .w-danger{color:var(--dsw-alias-state-error-primary,#b43c3c)}.dshw .w-icon-button{width:36px;min-width:36px;padding:8px}.dshw .w-button svg{flex-shrink:0}.w-muted{color:var(--w-muted);font-size:12px;line-height:1.6}.w-section{padding:24px 0;border-bottom:1px solid var(--w-border);min-width:0}.w-section>h3{margin-bottom:12px}.w-section>p+p{margin-top:8px}.w-fields{display:grid;gap:16px;min-width:0}.w-field{display:grid;gap:6px;min-width:0}.w-field>input,.w-field>select,.w-field>textarea{width:100%}.w-status{display:flex;align-items:flex-start;gap:8px;padding:11px 13px;margin:12px 0;border:1px solid var(--w-border);border-radius:8px;background:var(--w-soft);font-size:13px;overflow-wrap:anywhere;flex-shrink:0}.w-status>svg{flex-shrink:0;margin-top:2px}.w-status-error{color:var(--dsw-alias-state-error-primary,#ad3636)}.w-status-success{color:var(--dsw-alias-state-success-primary,#24794a)}.w-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:14px;max-width:580px;padding:48px 24px;margin:auto;min-height:280px;overflow-wrap:anywhere}.w-empty>svg{color:var(--w-muted)}.w-empty h2{font-size:20px}.w-empty .w-muted{font-size:14px}.w-empty .w-toolbar{justify-content:center}
143
+ .w-split{display:grid;grid-template-columns:minmax(240px,var(--w-list-width)) minmax(0,1fr);min-height:460px;flex:1;min-width:0}.w-master{min-width:0;border-inline-end:1px solid var(--w-border);overflow:auto;padding-bottom:24px}.w-detail{min-width:0;padding:26px 28px;overflow:auto;overflow-wrap:anywhere;display:flex;flex-direction:column}.w-master-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:20px 16px}.w-search{display:flex;align-items:center;gap:8px;border:1px solid var(--w-border);border-radius:8px;padding:0 10px;min-width:0;flex:1;background:var(--w-bg)}.w-search>svg{color:var(--w-muted);flex-shrink:0}.dshw .w-search>input{border:0!important;background:transparent!important;padding:8px 0!important;width:100%;outline-offset:0}.w-row{display:flex;gap:0;min-width:0;position:relative;border-bottom:1px solid var(--w-border);border-inline-start:3px solid transparent}.w-row-select{display:flex;align-items:flex-start;padding:21px 0 0 14px}.dshw .w-row-button{display:flex;flex-direction:column;align-items:stretch;gap:7px;text-align:start;width:100%;padding:18px 18px 18px 16px;border:0;background:transparent;color:inherit;min-width:0;cursor:pointer;line-height:1.5}.dshw .w-row-button:hover{background:var(--w-soft)}.w-row-head{display:flex;align-items:flex-start;gap:10px;justify-content:space-between;min-width:0}.w-row-head>strong{font-size:14px;line-height:1.5;font-weight:600;overflow-wrap:anywhere}.w-row-meta,.w-row-description{color:var(--w-muted);font-size:12px;line-height:1.6;overflow-wrap:anywhere}.w-row-description{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.w-selected{background:var(--w-selected);border-inline-start-color:var(--w-accent)}.w-back{display:none!important}.w-detail-title{margin-bottom:12px!important}.w-detail-meta{color:var(--w-muted);font-size:13px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}.w-detail-footer{padding:24px 0;display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:auto;border-top:1px solid var(--w-border)}.w-detail-footer .w-muted{flex:1;min-width:160px}.w-definition{display:grid;grid-template-columns:minmax(80px,120px) minmax(0,1fr);gap:8px 16px;font-size:14px;margin:20px 0}.w-definition dt{color:var(--w-muted)}.w-definition dd{margin:0;min-width:0;overflow-wrap:anywhere}
144
+ .w-switch-label{display:inline-flex;align-items:center;gap:8px;font-size:13px;position:relative;cursor:pointer}.w-switch-label>input{position:absolute;opacity:0;inset-inline-end:0;width:36px!important;height:22px!important;margin:0;cursor:pointer}.w-switch-track{width:36px;height:22px;border-radius:12px;display:block;background:var(--w-border);border:1px solid var(--w-border);pointer-events:none;padding:2px;transition:background .15s}.w-switch-track:before{content:'';display:block;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 2px #0002;transition:transform .15s}.w-switch-label>input:checked+.w-switch-track{background:var(--w-accent);border-color:var(--w-accent)}.w-switch-label>input:checked+.w-switch-track:before{transform:translateX(14px)}.w-switch-label>input:focus-visible+.w-switch-track{outline:2px solid var(--w-accent);outline-offset:3px}.w-switch-label:has(>input:disabled){opacity:.5;cursor:not-allowed}
145
+ .w-overlay{position:fixed;inset:0;background:#0005;display:flex;justify-content:flex-end;z-index:1000;padding:12px;overflow:auto}.w-dialog{width:min(620px,100%);max-height:100%;display:flex;flex-direction:column;min-height:0;background:var(--w-bg);color:var(--w-text);border:1px solid var(--w-border);border-radius:12px;box-shadow:0 18px 70px #0003}.w-dialog-header{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:20px 24px;border-bottom:1px solid var(--w-border);flex-shrink:0}.w-dialog-header>h2{font-size:20px}.w-dialog-body{overflow:auto;padding:20px 24px;min-height:0}.w-editor-grid{display:grid;grid-template-columns:210px minmax(0,1fr) 300px;min-height:500px;flex:1}.w-editor-list{padding:20px 12px;border-inline-end:1px solid var(--w-border);min-width:0}.w-editor-canvas{background:var(--w-soft);padding:20px;min-width:0;display:flex;flex-direction:column;gap:14px}.w-editor-inspector{padding:24px;border-inline-start:1px solid var(--w-border);min-width:0;overflow:auto}.w-editor-inspector h3{margin-bottom:18px}.w-preview-empty{display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;gap:12px;aspect-ratio:16/9;min-height:180px;background:var(--w-soft);border:1px solid var(--w-border);border-radius:8px;padding:24px;color:var(--w-muted)}.w-content-pad{padding:24px 0}.w-stack{display:flex;flex-direction:column;gap:16px;min-width:0}.w-scroll-x{overflow:auto;max-width:100%}
146
+ @container dsh-workbench (max-width:1000px){.w-editor-grid{grid-template-columns:160px minmax(0,1fr)}.w-editor-inspector{grid-column:1/-1;border-inline-start:0;border-top:1px solid var(--w-border)}.w-header-actions{max-width:60%}.w-split{grid-template-columns:minmax(240px,32%) minmax(0,1fr)}}
147
+ @container dsh-workbench (max-width:760px){.w-page-header{gap:14px;flex-wrap:wrap;padding-bottom:18px}.w-heading{flex:1;min-width:170px}.dshw h1{font-size:24px}.w-header-actions{max-width:100%;justify-content:flex-start}.w-subtitle{font-size:13px}.w-tabs{gap:12px}.dshw .w-tabs>button{padding:10px 4px 12px}.w-split{display:block;min-height:360px}.w-split[data-selected=false]>.w-detail{display:none}.w-split[data-selected=true]>.w-master{display:none}.w-master{border-inline-end:0}.w-detail{padding:20px 0}.w-back{display:inline-flex!important;align-self:flex-start;margin:0 0 16px!important}.w-detail h2{font-size:22px}.w-master-toolbar{padding:16px 0}.dshw .w-row-button{padding:16px 10px}.w-detail-footer{position:static}.w-detail-footer .w-muted{flex-basis:100%}.w-editor-grid{display:flex;flex-direction:column}.w-editor-list{border-inline-end:0;border-bottom:1px solid var(--w-border);padding:16px 0}.w-editor-canvas{padding:16px 0;background:var(--w-bg)}.w-editor-inspector{padding:20px 0}.w-dialog-header,.w-dialog-body{padding:16px}.w-button{min-height:40px}.w-definition{grid-template-columns:80px minmax(0,1fr)}}
148
+ @media(max-width:700px){.w-page[data-main=true]{padding:calc(16px + var(--dsh-frame-top-clearance,0px)) 16px 0 max(16px,var(--dsh-frame-leading-clearance,0px))}.w-overlay{padding:8px}.w-dialog{width:100%}}
149
+ @media(prefers-reduced-motion:reduce){.dshw *,.dshw *:before{scroll-behavior:auto!important;transition:none!important;animation:none!important}}
150
+ `;
151
+ return { Icon, Button, IconButton, Page, Tabs, Status, Empty, Split, Row, Search, Switch, Dialog, register, styles };
152
+ }
153
+
154
+ /* One update control per plugin. Only transport, idle guards and installation
155
+ * coordination are shared; no global update page or update-all action exists. */
156
+ function __stardustUpdatesGuards() {
157
+ const key = Symbol.for('stardustlc.plugin-updates.guards.v1');
158
+ return globalThis[key] ||= { values: new Map(), listeners: new Set(), viewListeners: new Set(), clients: new Set() };
159
+ }
160
+ function __stardustUpdatesGuard(React, name, { dirty = false, busy = false, retainOnClose = false } = {}) {
161
+ const token = React.useRef(null);
162
+ if (!token.current) token.current = {};
163
+ React.useLayoutEffect(() => {
164
+ const pool = __stardustUpdatesGuards();
165
+ if (!dirty && !busy) for (const [key, value] of pool.values) if (value.name === name && value.orphan) pool.values.delete(key);
166
+ pool.values.set(token.current, { name, dirty: !!dirty, busy: !!busy, retainOnClose, orphan: false });
167
+ for (const listener of pool.listeners) listener();
168
+ }, [name, !!dirty, !!busy, retainOnClose]);
169
+ React.useEffect(() => () => {
170
+ const pool = __stardustUpdatesGuards(), value = pool.values.get(token.current);
171
+ if (value?.retainOnClose && (value.dirty || value.busy)) value.orphan = true;
172
+ else pool.values.delete(token.current);
173
+ for (const listener of pool.listeners) listener();
174
+ }, []);
175
+ }
176
+ function __StardustPluginUpdateButton({ React, name }) {
177
+ const [, render] = React.useState(0), pool = __stardustUpdatesGuards();
178
+ React.useEffect(() => { const fn = () => render(value => value + 1); pool.viewListeners.add(fn); return () => pool.viewListeners.delete(fn); }, []);
179
+ const client = [...pool.clients].find(client => client.has(name));
180
+ if (!client) return null;
181
+ const available = client.row(name)?.available;
182
+ return React.createElement(client.Button, { icon: 'refresh', variant: available ? undefined : 'ghost', onClick: () => client.open(name), 'data-plugin-update': name }, available ? client.t('有更新', 'Update available') : client.t('插件更新', 'Update plugin'));
183
+ }
184
+ function __stardustUpdatesPage(React, props, name) {
185
+ return { ...props, actions: React.createElement(React.Fragment, null, props.actions, React.createElement(__StardustPluginUpdateButton, { React, name })) };
186
+ }
187
+ function __stardustUpdatesDisplayRows(next, known) {
188
+ for (const row of next.rows) known.set(row.name, row);
189
+ if (!['verifying', 'installing', 'cancelling', 'unknown', 'restore-failed'].includes(next.job?.state)) return next;
190
+ const rows = [...next.rows];
191
+ for (const row of known.values()) if ((row.name === next.job.name || row.bundledBy === next.job.name) && !rows.some(item => item.name === row.name)) rows.push({ ...row, updating: true });
192
+ return { ...next, rows };
193
+ }
194
+ function __stardustUpdatesWrap(definition, participant) {
195
+ return { ...definition, factory(require) {
196
+ const original = definition.factory(require);
197
+ if (!original || typeof original !== 'object') return original;
198
+ const React = require('react');
199
+ return { ...original, apply(ctx, ...args) {
200
+ const result = original.apply?.call(this, ctx, ...args);
201
+ if (typeof ctx.inject === 'function') ctx.inject(['slots', 'locale', 'remote', 'remote.pluginManager'], host => __stardustUpdatesJoin(host, React, require, participant));
202
+ return result;
203
+ } };
204
+ } };
205
+ }
206
+ function __stardustUpdatesJoin(host, React, require, participant) {
207
+ const key = Symbol.for('stardustlc.plugin-updates.client.v1'), pools = globalThis[key] ||= new WeakMap(), root = host.root || host;
208
+ if (typeof root.plugin !== 'function') return;
209
+ let record = pools.get(root);
210
+ if (!record) {
211
+ record = { refs: new Map(), timer: null, version: participant.version, alive: true, hold: false };
212
+ pools.set(root, record);
213
+ const shared = record;
214
+ record.fiber = root.plugin({ name: 'stardustlc-plugin-updates-client', inject: ['slots', 'locale', 'remote', 'remote.pluginManager'], apply(ctx) {
215
+ const client = __stardustUpdatesClient(ctx, React, require, shared);
216
+ ctx.effect(() => () => { shared.alive = false; client.dispose(); if (pools.get(root) === shared) pools.delete(root); });
217
+ } });
218
+ Promise.resolve(record.fiber).catch(() => { shared.alive = false; if (pools.get(root) === shared) pools.delete(root); });
219
+ }
220
+ clearTimeout(record.timer);
221
+ const ref = {}; record.refs.set(ref, participant);
222
+ host.effect(() => () => {
223
+ record.refs.delete(ref);
224
+ const stop = () => {
225
+ if (record.refs.size || !record.alive) return;
226
+ if (record.hold) { record.timer = setTimeout(stop, 1000); return; }
227
+ record.alive = false; Promise.resolve(record.fiber?.dispose?.()).catch(() => {});
228
+ };
229
+ if (!record.refs.size) record.timer = setTimeout(stop, 1000);
230
+ });
231
+ }
232
+ function __stardustUpdatesClient(ctx, React, require, shared) {
233
+ const h = React.createElement, W = createWorkbenchUI(React), route = new URL('api/stardustlc/plugin-updates', document.baseURI);
234
+ const { useState, useEffect } = React, id = crypto.randomUUID(), listeners = new Set(), pool = __stardustUpdatesGuards(), requests = new Set(), sections = new Map(), notified = new Set(), knownRows = new Map();
235
+ let alive = true, registered = false, state = null, lastInteraction = Date.now(), inPoll = false, activeName = null, manualOperation = false;
236
+ const language = () => String(ctx.locale?.getSnapshot?.().active || '').startsWith('en') ? 'en' : 'zh';
237
+ const t = (zh, en) => language() === 'en' ? en : zh;
238
+ const preferences = name => state?.preferences.packages[name] || { checkEnabled: true, autoInstall: false, deferredUntil: 0, ignoredVersion: null };
239
+ const originalRow = name => state?.rows.find(row => row.name === name);
240
+ const rowFor = name => { const row = originalRow(name); return row?.bundledBy ? originalRow(row.bundledBy) : row; };
241
+ const activeJob = () => ['verifying', 'installing', 'cancelling'].includes(state?.job?.state);
242
+ function notify() { shared.hold = manualOperation || activeJob(); for (const listener of listeners) listener(); for (const listener of pool.viewListeners) listener(); }
243
+ async function api(body = { action: 'state' }) {
244
+ const controller = new AbortController(); requests.add(controller);
245
+ const timeout = setTimeout(() => controller.abort(), body.action === 'check' ? 95000 : 45000);
246
+ try {
247
+ const response = await fetch(route, { method: 'POST', credentials: 'same-origin', cache: 'no-store', signal: controller.signal, headers: { 'content-type': 'application/json', 'x-stardustlc-updates': '1' }, body: JSON.stringify(body) });
248
+ let data; try { data = await response.json(); } catch { throw new Error('service-unavailable'); }
249
+ if (!response.ok || !data.ok) throw new Error(response.status === 403 ? 'owner-required' : data.code || 'service-unavailable');
250
+ return data.value;
251
+ } catch (error) { if (error?.name === 'AbortError') throw new Error('service-unavailable'); throw error; }
252
+ finally { clearTimeout(timeout); requests.delete(controller); }
253
+ }
254
+ function pageState(manual = manualOperation) {
255
+ const values = [...pool.values.values()];
256
+ const dialog = [...document.querySelectorAll('[role="dialog"],dialog[open]')].some(node => node.getClientRects().length && !(manual && (node.closest('.plugin-updates-page') || node.querySelector('.plugin-updates-page'))));
257
+ return { action: 'heartbeat', id, dirty: values.some(value => value.dirty), busy: values.some(value => value.busy) || dialog, idleForMs: Math.min(86400000, Math.max(0, Date.now() - lastInteraction)) };
258
+ }
259
+ const heartbeat = () => api(pageState());
260
+ function interaction() { lastInteraction = Date.now(); }
261
+ const guardChanged = () => { if (registered && alive) void heartbeat().catch(() => {}); };
262
+ for (const event of ['pointerdown', 'keydown', 'input']) document.addEventListener(event, interaction, { capture: true, passive: true });
263
+ pool.listeners.add(guardChanged);
264
+ const close = () => { void fetch(route, { method: 'POST', credentials: 'same-origin', keepalive: true, headers: { 'content-type': 'application/json', 'x-stardustlc-updates': '1' }, body: JSON.stringify({ action: 'client-close', id }) }).catch(() => {}); };
265
+ window.addEventListener('pagehide', close);
266
+ async function refresh() { const next = await api(); if (alive) { state = __stardustUpdatesDisplayRows(next, knownRows); notify(); } return next; }
267
+ function useSnapshot() {
268
+ const [, render] = useState(0);
269
+ useEffect(() => { const fn = () => render(value => value + 1); listeners.add(fn); const stop = ctx.locale?.subscribe?.(fn); return () => { listeners.delete(fn); stop?.(); }; }, []);
270
+ return state;
271
+ }
272
+ function message(code) {
273
+ return ({
274
+ 'page-dirty': t('有未保存的修改。请先保存或放弃修改,再更新。', 'Save or discard unsaved changes before updating.'),
275
+ 'page-busy': t('有页面操作或对话框尚未结束,请完成后再试。', 'Finish the active page operation or close its dialog, then retry.'),
276
+ 'page-not-ready': t('还有窗口状态未确认,或旧版插件尚未提供状态保护。请刷新相关窗口,必要时先手动更新旧版插件。', 'A window state is unknown or an older plugin lacks state protection. Refresh the affected windows and manually update legacy plugins if needed.'),
277
+ busy: t('DSH 正在执行任务或安装,请等操作结束。', 'DSH is running a task or installation. Wait for it to finish.'),
278
+ 'activity-unknown': t('无法确认任务是否空闲,已暂停安装。', 'Installation paused because task activity is unknown.'),
279
+ 'restart-required': t('更新服务需要重启后继续。请在方便时手动重启 DSH。', 'The updater needs a restart. Restart DSH manually when convenient.'),
280
+ 'operation-unconfirmed': t('上次安装未确认或恢复失败。请在官方插件管理中检查并修复,之后手动重启 DSH。', 'The last installation is unconfirmed or recovery failed. Check and repair it in native plugin management, then restart DSH manually.'),
281
+ 'check-first': t('检查结果已过期,请重新检查。', 'This check expired. Check again.'),
282
+ 'release-changed': t('发布信息已变化,本次未安装。请重新检查。', 'Release metadata changed. Nothing was installed; check again.'),
283
+ 'preferences-conflict': t('另一个窗口修改了偏好,已刷新,请重试。', 'Preferences changed in another window. They have been refreshed; retry.'),
284
+ 'preferences-unreadable': t('更新偏好文件无法读取,自动安装已关闭。请检查当前 profile 的 stardustlc-plugin-updates.json。', 'Preferences could not be read. Automatic installation is off. Check stardustlc-plugin-updates.json in this profile.'),
285
+ 'no-update': t('版本已变化或暂不兼容,请重新检查。', 'The version changed or is incompatible. Check again.'),
286
+ 'source-not-registry': t('本地源码、Git 等安装,请通过原安装方式更新。', 'Update local, Git and other external sources through their original installation method.'),
287
+ 'managed-by-suite': t('这个插件随 Suite 安装,通过 Suite 更新。', 'This plugin was installed through Suite and is updated through Suite.'),
288
+ 'owner-required': t('只有 profile 拥有者能管理更新。', 'Only the profile owner can manage updates.'),
289
+ })[code] || t('操作未完成,请刷新后重试。DSH 不会因此自动重启。', 'The operation could not be completed. Refresh and retry. DSH will not restart automatically.');
290
+ }
291
+ function jobLabel(job) {
292
+ return ({ verifying: t('正在核对版本', 'Verifying release'), installing: t('正在安装', 'Installing'), cancelling: t('正在请求取消', 'Requesting cancellation'), complete: t('已更新', 'Updated'), 'restart-required': t('已安装,等待你手动重启', 'Installed; manual restart needed'), restored: t('更新未成功,已恢复原版本', 'Update failed; previous version restored'), 'restore-failed': t('恢复未成功,请检查官方插件管理', 'Recovery failed; check native plugin management'), unknown: t('结果未确认,请检查官方插件管理', 'Result unconfirmed; check native plugin management'), cancelled: t('已取消', 'Cancelled'), failed: t('更新未成功', 'Update failed') })[job.state] || job.state;
293
+ }
294
+ function Panel({ name, compact = false }) {
295
+ const snapshot = useSnapshot(), [error, setError] = useState(''), [working, setWorking] = useState(false), [checking, setChecking] = useState(false), [dialog, setDialog] = useState(null), [notes, setNotes] = useState(null), [loadingNotes, setLoadingNotes] = useState(false);
296
+ const row = rowFor(name), original = originalRow(name), target = row?.name || name, prefs = preferences(target), job = snapshot?.job?.name === target ? snapshot.job : null;
297
+ async function perform(callback) { setError(''); setWorking(true); try { await callback(); await refresh(); } catch (error) { setError(error.message); await refresh().catch(() => {}); } finally { setWorking(false); } }
298
+ async function save(changes, consent) { await api({ action: 'preferences', name: target, revision: state.preferences.revision, changes, consent }); }
299
+ async function check() { setError(''); setChecking(true); try { await api({ action: 'check', name: target }); await refresh(); } catch (error) { setError(error.message); } finally { setChecking(false); } }
300
+ async function install() {
301
+ setDialog(null); manualOperation = true; notify();
302
+ await perform(async () => {
303
+ try {
304
+ await heartbeat();
305
+ const started = await api({ action: 'install', name: row.name, version: row.release.version, confirmation: 'update-installed-plugin' });
306
+ while (alive) {
307
+ await new Promise(resolve => setTimeout(resolve, 800)); await heartbeat(); await refresh();
308
+ if (state.job?.id !== started.id) throw new Error('operation-unconfirmed');
309
+ if (!activeJob()) break;
310
+ }
311
+ } finally { manualOperation = false; notify(); }
312
+ });
313
+ }
314
+ async function showNotes() {
315
+ setDialog('notes'); setNotes(null); setLoadingNotes(true); setError('');
316
+ try { setNotes(await api({ action: 'notes', name: target, version: row.release.version })); } catch (error) { setError(error.message); } finally { setLoadingNotes(false); }
317
+ }
318
+ if (!snapshot || !row) return h(W.Status, null, t('此插件尚未安装或已从当前 profile 移除。', 'This plugin is not installed in the current profile.'));
319
+ const title = language() === 'en' ? original.labelEn : original.label;
320
+ if (compact) return h('div', { className: 'dshw', style: { padding: 16 } }, h('style', null, W.styles), h('div', { className: 'w-stack' }, h('h3', null, title), h('p', { className: 'w-muted' }, original.name + ' · ' + original.version), h('p', null, t('这个插件的更新设置与安装入口。自动安装默认关闭。', 'Update settings and installation for this plugin. Automatic installation is off by default.')), h(W.Button, { icon: 'refresh', onClick: () => { activeName = name; notify(); } }, row.available ? t('查看可用更新', 'Review available update') : t('检查与更新', 'Check and update'))));
321
+ const unconfirmed = ['unknown', 'restore-failed'].includes(snapshot.job?.state), pageBusy = pageState(true).busy || (snapshot.pages.clients || []).some(client => client.id !== id && client.busy), installLocked = working || activeJob() || unconfirmed || snapshot.updaterRestart || snapshot.preferenceError || !snapshot.activity.known || snapshot.activity.busy || snapshot.pages.dirty || pageBusy || !snapshot.pages.known;
322
+ const checkingThis = checking || snapshot.checkingNames?.includes(target);
323
+ const status = row.sourceKind !== 'registry' ? t('本地 / 外部来源,保留原安装方式', 'Local / external source; keep original install method') : row.loadError ? t('加载异常,请先检查官方插件管理', 'Loading error; check native plugin management') : row.error ? t('检查失败,可以重试', 'Check failed; retry available') : row.release && !row.release.compatible ? t('新版本暂不兼容当前环境', 'Latest release is incompatible with this environment') : row.available ? (prefs.ignoredVersion === row.release.version ? t('已忽略提醒,仍可手动更新', 'Reminder ignored; manual update available') : t('有新版本', 'Update available')) : row.checkedAt ? t('当前已是最新稳定版', 'Latest stable version') : t('尚未检查', 'Not checked yet');
324
+ return h(W.Page, { title: title + t(' · 更新', ' · Updates'), subtitle: t('只管理这个插件。检查不会安装,也不会重启 DSH。', 'Manage this plugin. Checking does not install anything or restart DSH.'), className: 'plugin-updates-page', actions: h(W.Button, { icon: 'refresh', disabled: checkingThis || row.sourceKind !== 'registry', onClick: check }, checkingThis ? t('正在检查…', 'Checking…') : t('检查更新', 'Check for updates')) },
325
+ h('style', null, '.plugin-updates-page{min-height:0;padding:4px}.plugin-updates-page .updates-settings{display:grid;gap:24px;padding:20px 0}.plugin-updates-page .updates-note{margin-top:8px}.plugin-updates-page .updates-version{font:13px/1.7 ui-monospace,monospace;overflow-wrap:anywhere}.plugin-updates-page .w-overlay{z-index:1002}.plugin-updates-page .w-switch-label{max-width:100%}.plugin-updates-page .w-switch-track{flex-shrink:0}@media(max-width:700px){[data-shortcut-modal="settings"]:has(.plugin-updates-page){flex-direction:column!important;width:calc(100vw - 16px)!important;max-width:calc(100vw - 16px)!important}[data-shortcut-modal="settings"]:has(.plugin-updates-page)>nav{width:100%!important;flex-basis:auto!important;flex-shrink:0;max-height:180px;overflow:auto;border-right:0!important}[data-shortcut-modal="settings"]:has(.plugin-updates-page)>nav>div:has(>button){display:flex;flex-wrap:wrap;gap:4px}[data-shortcut-modal="settings"]:has(.plugin-updates-page)>div{min-width:0!important;width:100%!important}.plugin-updates-page .w-switch-label{display:flex;justify-content:space-between;align-items:flex-start;gap:16px}.plugin-updates-page .w-switch-label>span:first-child{min-width:0}.plugin-updates-page .w-switch-label>input{inset-inline-end:0}}'),
326
+ original.bundledBy && h(W.Status, null, t('此插件随 Suite 安装。以下设置与安装作用于 Suite 整包,以保持组件版本一致。单独安装的插件可以独立更新。', 'This plugin was installed through Suite. These controls update Suite as a package so its components stay consistent. Independently installed plugins update individually.')),
327
+ h('section', { className: 'w-section' }, h('p', { className: 'updates-version' }, target + ' · ' + row.version, row.release?.version && row.release.version !== row.version ? ' → ' + row.release.version : ''), h('p', { className: 'w-muted updates-note' }, status), !row.enabled && h('p', { className: 'w-muted' }, t('当前已停用,更新后仍保持停用。', 'Currently disabled; it will remain disabled after updating.')), row.checkedAt && h('p', { className: 'w-muted' }, t('上次检查:', 'Last checked: ') + new Date(row.checkedAt).toLocaleString(language() === 'en' ? 'en-US' : 'zh-CN')),
328
+ h('div', { className: 'w-toolbar', style: { marginTop: 16 } }, row.available && row.sourceKind === 'registry' && h(W.Button, { variant: 'primary', icon: 'download', disabled: installLocked || row.loadError, onClick: () => setDialog('install') }, t('更新此插件', 'Update this plugin')), row.release && h(W.Button, { variant: 'ghost', onClick: showNotes }, t('更新记录', 'Release notes')), row.available && h(W.Button, { variant: 'ghost', disabled: working, onClick: () => perform(() => api({ action: 'later', name: target, revision: state.preferences.revision })) }, t('明天再提醒', 'Remind me tomorrow')), row.available && prefs.ignoredVersion !== row.release.version && h(W.Button, { variant: 'ghost', disabled: working, onClick: () => perform(() => api({ action: 'ignore', name: target, revision: state.preferences.revision, version: row.release.version })) }, t('忽略此版本提醒', 'Ignore this reminder')))),
329
+ h('section', { className: 'updates-settings', 'aria-label': t('这个插件的更新偏好', 'Update preferences for this plugin') },
330
+ h('div', null, h(W.Switch, { label: t('自动检查这个插件的更新', 'Automatically check this plugin'), checked: prefs.checkEnabled, disabled: working || !!snapshot.preferenceError || row.sourceKind !== 'registry', onChange: event => perform(() => save({ checkEnabled: event.target.checked })) }), h('p', { className: 'w-muted updates-note' }, t('默认开启,每天最多一次。只影响这个插件,关闭后仍可手动检查。', 'On by default, at most once daily. This affects only this plugin; manual checks remain available.'))),
331
+ h('div', null, h(W.Switch, { label: t('空闲时自动安装这个插件的补丁', 'Install patches for this plugin while idle'), checked: prefs.autoInstall, disabled: working || !!snapshot.preferenceError || row.sourceKind !== 'registry', onChange: event => event.target.checked ? setDialog('automatic') : perform(() => save({ autoInstall: false })) }), h('p', { className: 'w-muted updates-note' }, t('默认关闭。明确开启后,仅空闲且没有未保存修改时安装兼容补丁。其他插件的开关不变。', 'Off by default. Explicitly enable compatible patches while idle with no unsaved changes. Other plugins’ settings stay unchanged.')))),
332
+ error && h(W.Status, { tone: 'error' }, message(error)), snapshot.preferenceError && h(W.Status, { tone: 'error' }, message('preferences-unreadable')),
333
+ snapshot.pendingRestart && h(W.Status, null, snapshot.updaterRestart ? message('restart-required') : t('已有更新等待手动重启后生效。你可以继续工作,自动安装已暂停。', 'Some updates need a manual restart. Continue your work; automatic installation is paused.')),
334
+ prefs.autoInstall && snapshot.autoReady === false && h(W.Status, null, message('page-not-ready')),
335
+ snapshot.pages.clients.some(client => !client.fresh) && h(W.Status, null, h('p', null, t('有窗口已断开,无法确认是否还有未保存修改。仍在使用的窗口请先保存并刷新;已关闭的窗口可以手动重新确认。', 'A disconnected window may contain unsaved changes. Save and refresh windows still in use. If they are closed, you can confirm that manually.')), h(W.Button, { onClick: () => setDialog('windows') }, t('重新确认已关闭的窗口', 'Confirm closed windows'))),
336
+ snapshot.pages.dirty && h(W.Status, null, message('page-dirty')), (snapshot.activity.busy || pageBusy) && h(W.Status, null, message(snapshot.activity.busy ? 'busy' : 'page-busy')),
337
+ prefs.deferredUntil > Date.now() && h(W.Status, null, t('此插件已推迟提醒和自动安装 24 小时,仍可手动更新。', 'Reminders and automatic installation for this plugin are deferred for 24 hours. Manual updates remain available.')),
338
+ unconfirmed && h(W.Status, { tone: 'error' }, message('operation-unconfirmed')),
339
+ job && h('section', { className: 'w-section', 'aria-label': t('这个插件的更新进度', 'Update progress for this plugin') }, h('h3', null, jobLabel(job)), h('p', { className: 'w-muted' }, job.from + ' → ' + job.to + (job.mode === 'automatic' ? t(' · 自动补丁更新', ' · Automatic patch') : '')), (job.reason || job.outcome?.result?.error?.code) && h(W.Status, { tone: 'error' }, message(job.reason || job.outcome.result.error.code)), activeJob() && h(W.Button, { disabled: job.state === 'cancelling', onClick: () => perform(() => api({ action: 'cancel', id: job.id })) }, t('取消更新', 'Cancel update')), h('p', { className: 'w-muted updates-note' }, t('安装开始后,取消由官方包管理器处理;已落盘的写入以实际结果或恢复状态为准。', 'After installation starts, the native package manager handles cancellation. Completed writes and recovery are reported as they occur.'))),
340
+ dialog && h(W.Dialog, { title: dialog === 'windows' ? t('断开的窗口都已关闭?', 'Are the disconnected windows closed?') : dialog === 'automatic' ? t('允许这个插件自动安装补丁?', 'Allow automatic patches for this plugin?') : dialog === 'notes' ? t('更新记录', 'Release notes') : t('确认更新此插件', 'Confirm this plugin update'), closeLabel: t('关闭', 'Close'), onClose: () => setDialog(null) },
341
+ dialog === 'windows' ? h('div', { className: 'w-stack' }, h('p', null, t('只清除超过 45 秒未回应的其他窗口记录。请先确认它们已经关闭;仍在使用的窗口请保存修改并刷新。当前或仍在回应的窗口保护会保留。', 'Only other windows silent for over 45 seconds are cleared. Confirm that they are closed; save and refresh windows still in use. Current and responsive windows remain protected.')), h('div', { className: 'w-toolbar' }, h(W.Button, { onClick: () => setDialog(null) }, t('取消', 'Cancel')), h(W.Button, { onClick: () => { setDialog(null); void perform(() => api({ ...pageState(true), action: 'reconfirm-windows', confirmation: 'other-stale-windows-are-closed' })); } }, t('已关闭,重新确认状态', 'They are closed; reconfirm')))) :
342
+ dialog === 'automatic' ? h('div', { className: 'w-stack' }, h('p', null, (language() === 'en' ? row.labelEn : row.label) + ' · ' + target), h('p', null, t('你正在主动启用这个插件的自动安装。只处理兼容的稳定补丁;有任务、未保存修改、未知窗口或等待重启时暂停。', 'You are explicitly enabling automatic installation for this plugin. Only compatible stable patches qualify; running tasks, unsaved changes, unknown windows or a pending restart pause it.')), h('p', null, t('不会自动重启 DSH,其他插件不会跟着开启。可随时关闭。', 'DSH will not restart automatically, and other plugins will not be enabled for automatic updates. Turn it off anytime.')), h('div', { className: 'w-toolbar' }, h(W.Button, { onClick: () => setDialog(null) }, t('保持关闭', 'Keep off')), h(W.Button, { variant: 'primary', onClick: () => { setDialog(null); void perform(() => save({ checkEnabled: true, autoInstall: true }, 'enable-idle-patch-updates')); } }, t('允许此插件自动安装补丁', 'Allow patches for this plugin')))) :
343
+ dialog === 'notes' ? h('div', { className: 'w-stack' }, h('p', null, target + ' · ' + row.release.version), loadingNotes ? h('p', null, t('正在读取…', 'Loading…')) : notes?.text ? h('pre', null, notes.text) : h('p', null, t('未找到此版本的独立更新记录。', 'No separate notes were found for this version.')), notes?.url && h('a', { href: notes.url, target: '_blank', rel: 'noreferrer noopener' }, t('查看仓库更新记录', 'View repository changelog'))) :
344
+ h('div', { className: 'w-stack' }, h('p', { className: 'updates-version' }, target + ': ' + row.version + ' → ' + row.release.version), h('p', null, t('通过 DSH 官方插件管理器安装。需要重启时,由你手动决定时间;停用状态会保留。', 'Install through DSH’s native plugin manager. Choose when to restart if needed; disabled status is preserved.')), h('p', { className: 'w-muted' }, t('来源:npm 官方 registry,网络不可达时回退 npmmirror。安装前再次核对版本与完整性。', 'Source: official npm registry, with npmmirror fallback if unreachable. Version and integrity are checked again before installation.')), h('div', { className: 'w-toolbar' }, h(W.Button, { onClick: () => setDialog(null) }, t('取消', 'Cancel')), h(W.Button, { variant: 'primary', onClick: install }, t('确认安装此版本', 'Install this version')))))
345
+ );
346
+ }
347
+ function Overlay() {
348
+ useSnapshot();
349
+ const row = activeName && originalRow(activeName);
350
+ if (!row) return null;
351
+ const content = h('div', { className: 'dshw' }, h('style', null, W.styles), h(W.Dialog, { title: (language() === 'en' ? row.labelEn : row.label) + t(' · 更新设置', ' · Update settings'), closeLabel: t('关闭更新设置', 'Close update settings'), onClose: () => { activeName = null; notify(); } }, h(Panel, { key: activeName, name: activeName })));
352
+ const settings = document.querySelector('[data-shortcut-modal="settings"]');
353
+ // Keep the dialog inside the native settings focus boundary when its
354
+ // per-plugin entry was opened there. The shared fiber survives HMR.
355
+ return settings ? require('react-dom').createPortal(content, settings) : content;
356
+ }
357
+ function Reminder() {
358
+ useSnapshot();
359
+ const row = state?.rows.find(row => { const prefs = preferences(row.name); return row.available && !row.updating && !row.bundledBy && prefs.checkEnabled && prefs.deferredUntil <= Date.now() && prefs.ignoredVersion !== row.release.version && !notified.has(row.name + '@' + row.release.version); });
360
+ if (!row) return null;
361
+ let Toast; try { Toast = require('@deepseek-ai/dsh-client-ui-primitives').Toast; } catch { return null; }
362
+ if (!Toast) return null;
363
+ const key = row.name + '@' + row.release.version;
364
+ return h(Toast, { key, text: (language() === 'en' ? row.labelEn : row.label) + t('有新版本,可在它的页面或设置中查看。', ' has an update. Review it on its page or in settings.'), icon: h(W.Icon, { name: 'refresh', size: 18 }), holdMs: 7000, onDone: () => { notified.add(key); notify(); } });
365
+ }
366
+ function register() {
367
+ if (!alive) return;
368
+ if (!registered) {
369
+ registered = true; pool.clients.add(client);
370
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'stardustlc.plugin-update-dialog' }, Overlay));
371
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'stardustlc.plugin-update-reminder' }, Reminder));
372
+ notify();
373
+ }
374
+ for (const row of state.rows) {
375
+ const hasWorkbench = [...shared.refs.values()].some(participant => participant.name === row.name && participant.hasWorkbench);
376
+ if (hasWorkbench) { if (sections.has(row.name)) { void sections.get(row.name).dispose(); sections.delete(row.name); } continue; }
377
+ if (sections.has(row.name)) continue;
378
+ const label = () => (language() === 'en' ? row.labelEn : row.label) + t(' · 更新', ' · Updates');
379
+ const fiber = ctx.plugin({ name: 'plugin-update-settings-' + row.name, inject: ['slots', 'locale'], apply(section) {
380
+ section.slots.inject('settings.section', () => section.slots.register({ name: 'settings.section', id: row.name + '.updates', label, order: 89 }, () => h(Panel, { name: row.name })));
381
+ } });
382
+ sections.set(row.name, fiber);
383
+ }
384
+ }
385
+ async function poll() {
386
+ if (inPoll || !alive || !shared.refs.size) return;
387
+ inPoll = true;
388
+ try { await refresh(); register(); await heartbeat(); }
389
+ catch { /* Owner-only controls stay absent for guests and unsupported hosts. */ }
390
+ finally { inPoll = false; }
391
+ }
392
+ const timer = setInterval(() => { void poll(); }, 15000), first = setTimeout(() => { void poll(); }, 100);
393
+ const client = { t, Button: W.Button, has: name => registered && !!originalRow(name), row: rowFor, open(name) { activeName = name; notify(); }, dispose() {
394
+ close(); alive = false; clearTimeout(first); clearInterval(timer); pool.clients.delete(client);
395
+ pool.listeners.delete(guardChanged); listeners.clear();
396
+ for (const event of ['pointerdown', 'keydown', 'input']) document.removeEventListener(event, interaction, true);
397
+ window.removeEventListener('pagehide', close);
398
+ for (const controller of requests) controller.abort();
399
+ for (const listener of pool.viewListeners) listener();
400
+ } };
401
+ return client;
402
+ }
403
+
404
+ /* STARDUSTLC_UPDATE_CLIENT_BODY_START */
405
+ window.__ModuleLoader__.load(__stardustUpdatesWrap({id:"dsh-voice",factory:require=>({apply(){}})},{"name":"dsh-voice","version":"0.1.0","hasWorkbench":false}));
406
+
407
+ /* STARDUSTLC_UPDATE_CLIENT_BODY_END */
408
+ })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-voice",
3
- "version": "0.3.6",
3
+ "version": "0.4.0",
4
4
  "description": "把文字生成语音,或通过兼容接口把音频转为文字。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -11,7 +11,8 @@
11
11
  "default": "./lib/index.js"
12
12
  },
13
13
  "./cordis.patch.yml": "./cordis.patch.yml",
14
- "./package.json": "./package.json"
14
+ "./package.json": "./package.json",
15
+ "./client": "./lib/update-client.js"
15
16
  },
16
17
  "files": [
17
18
  "lib",
@@ -22,7 +23,7 @@
22
23
  "CHANGELOG.md"
23
24
  ],
24
25
  "scripts": {
25
- "build": "tsc -p tsconfig.json",
26
+ "build": "tsc -p tsconfig.json && node scripts/build-updates.mjs",
26
27
  "typecheck": "tsc -p tsconfig.json --noEmit",
27
28
  "test": "pnpm run build && node --test \"test/*.test.mjs\"",
28
29
  "test:integration": "pnpm run build && node --test \"integration/*.test.mjs\"",
@@ -31,6 +32,14 @@
31
32
  "dsh": {
32
33
  "bundle": {
33
34
  "patch": "./cordis.patch.yml"
35
+ },
36
+ "client": {
37
+ "platform": "web",
38
+ "inject": [
39
+ "@deepseek-ai/dsh-client-ui-settings",
40
+ "@deepseek-ai/dsh-client-ui-layout",
41
+ "@deepseek-ai/dsh-client-ui-sidebar"
42
+ ]
34
43
  }
35
44
  },
36
45
  "keywords": [
@@ -45,12 +54,14 @@
45
54
  ],
46
55
  "license": "MIT",
47
56
  "engines": {
48
- "node": "^22.19.0 || >=24.0.0"
57
+ "node": "^22.19.0 || >=24.0.0",
58
+ "dsh": ">=0.2.1-rc.3"
49
59
  },
50
60
  "dependencies": {
51
61
  "https-proxy-agent": "^9.1.0",
52
62
  "undici": "^8.10.0",
53
- "ws": "^8.21.3"
63
+ "ws": "^8.21.3",
64
+ "@stardustlc/dsh-updates": "0.1.0"
54
65
  },
55
66
  "devDependencies": {
56
67
  "@types/node": "^24.0.0",