dsh-dock 0.15.0 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -16
- package/client.js +2363 -907
- package/features/animation/host.js +2 -1
- package/features/balance/host.js +2 -1
- package/features/mobile/host.js +9 -0
- package/features/mobile/view.js +20 -0
- package/features/mobile-relay/host.js +2 -1
- package/features/modelconfig/host.js +21 -462
- package/features/modelconfig/view.js +19 -704
- package/features/notify/host.js +2 -1
- package/features/notify/view.jsx +153 -30
- package/features/providerconfig/host.js +235 -0
- package/features/providerconfig/view.js +604 -0
- package/features/runstate/host.js +2 -1
- package/features/tokenlog/host.js +2 -1
- package/features/tts/host.js +164 -0
- package/features/tts/view.jsx +1189 -0
- package/features/visionproxy/host.js +1 -1
- package/index.js +9 -1
- package/package.json +3 -3
- package/screenshots/dock-modelconfig.png +0 -0
- package/src/client.jsx +25 -2
- package/src/host-core.js +84 -0
- package/src/model-directory.js +275 -0
- package/src/model-draft.js +119 -0
|
@@ -0,0 +1,1189 @@
|
|
|
1
|
+
// dsh-dock · 功能模块【语音朗读】· 客户端视图
|
|
2
|
+
//
|
|
3
|
+
// 三部分组成:
|
|
4
|
+
// 1. TurnTail —— 会话每条回复下方(回合结束后的动作区)的「🔊 朗读」按钮:点一下把这条
|
|
5
|
+
// 回复读出来,正在读时变成「■ 停止」。同时是**自动朗读的触发点**——
|
|
6
|
+
// 本条目只在 turn/end 落盘后才挂载,所以「刚挂载 + 时间够新」= 这一回合刚结束。
|
|
7
|
+
// 2. View —— 功能坞面板页:音色/语速/音调/音量、自动朗读、内容清洗与长度上限、试听。
|
|
8
|
+
// 3. HomeStat —— 首页总揽概要(自动朗读开/关 · 语速)。
|
|
9
|
+
//
|
|
10
|
+
// 朗读用的是浏览器自己的 Web Speech API(window.speechSynthesis),声音从**当前这台设备**
|
|
11
|
+
// 的扬声器出来;宿主(Node)侧没有这个能力,所以本功能的宿主半部只管配置持久化。
|
|
12
|
+
//
|
|
13
|
+
// ⚠️ 三条硬约束(都在这份文件里落实):
|
|
14
|
+
// 1. 沙箱兼容:测试用例(scripts/test-client-views.mjs)在 node:vm 里跑构建产物,
|
|
15
|
+
// 那里没有 speechSynthesis / SpeechSynthesisUtterance 这些全局对象,**裸引用会抛
|
|
16
|
+
// ReferenceError**。一律经 synth() 取(属性读 undefined 是安全的)。
|
|
17
|
+
// 2. 不用 useSyncExternalStore:测试用的极简 React 替身没有这个 hook。跨组件共享状态
|
|
18
|
+
// 一律 useState + 订阅(见 configStore / engine)。
|
|
19
|
+
// 3. fetch 用文档相对路径 "dsh-dock/tts/…"(反代子路径挂载契约,见用例 12)。
|
|
20
|
+
import react, { useState, useEffect, useRef } from "react";
|
|
21
|
+
import { stateOf, openPanel } from "../../src/shared.js";
|
|
22
|
+
|
|
23
|
+
// ---------- 语音合成入口(沙箱安全:绝不裸引用全局 speechSynthesis) ----------
|
|
24
|
+
function synth() {
|
|
25
|
+
try {
|
|
26
|
+
const g = typeof globalThis !== "undefined" ? globalThis : null;
|
|
27
|
+
if (g && g.speechSynthesis) return g.speechSynthesis;
|
|
28
|
+
if (typeof window !== "undefined" && window.speechSynthesis) return window.speechSynthesis;
|
|
29
|
+
} catch { /* 取不到就当不支持 */ }
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
function ttsSupported() {
|
|
33
|
+
return synth() !== null && typeof globalThis.SpeechSynthesisUtterance === "function";
|
|
34
|
+
}
|
|
35
|
+
function makeUtterance(text) {
|
|
36
|
+
try {
|
|
37
|
+
const Ctor = typeof globalThis !== "undefined" && globalThis.SpeechSynthesisUtterance
|
|
38
|
+
? globalThis.SpeechSynthesisUtterance
|
|
39
|
+
: (typeof window !== "undefined" ? window.SpeechSynthesisUtterance : null);
|
|
40
|
+
return Ctor ? new Ctor(text) : null;
|
|
41
|
+
} catch { return null; }
|
|
42
|
+
}
|
|
43
|
+
/** 本机音色表(可能为空:Chrome 首次调用是异步加载,靠 voiceschanged 补)。 */
|
|
44
|
+
function localVoices() {
|
|
45
|
+
const s = synth();
|
|
46
|
+
if (!s) return [];
|
|
47
|
+
try { return (typeof s.getVoices === "function" ? s.getVoices() : []) || []; } catch { return []; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------- 配置:宿主持久化 + 本地镜像兜底 ----------
|
|
51
|
+
const CONFIG_STORE_KEY = "dsh-dock/tts/v1";
|
|
52
|
+
const CONFIG_DEFAULTS = {
|
|
53
|
+
autoPlay: false,
|
|
54
|
+
autoPlayOnFinish: true,
|
|
55
|
+
interruptOnNew: true,
|
|
56
|
+
rate: 1,
|
|
57
|
+
pitch: 1,
|
|
58
|
+
volume: 1,
|
|
59
|
+
voiceURI: "",
|
|
60
|
+
lang: "",
|
|
61
|
+
skipCode: true,
|
|
62
|
+
skipTables: true,
|
|
63
|
+
maxChars: 0,
|
|
64
|
+
maxSentences: 0,
|
|
65
|
+
};
|
|
66
|
+
const NUMERIC_RANGE = { rate: [0.1, 10], pitch: [0, 2], volume: [0, 1], maxChars: [0, 200000], maxSentences: [0, 200] };
|
|
67
|
+
const BOOL_KEYS = ["autoPlay", "autoPlayOnFinish", "interruptOnNew", "skipCode", "skipTables"];
|
|
68
|
+
const NUM_KEYS = Object.keys(NUMERIC_RANGE);
|
|
69
|
+
const STR_KEYS = ["voiceURI", "lang"];
|
|
70
|
+
|
|
71
|
+
/** 把任意来源的配置对象规整成完整配置(未知键忽略、类型不符取默认、数值钳进区间)。 */
|
|
72
|
+
function normalizeConfig(raw) {
|
|
73
|
+
const out = Object.assign({}, CONFIG_DEFAULTS);
|
|
74
|
+
const src = raw && typeof raw === "object" ? raw : {};
|
|
75
|
+
for (const k of BOOL_KEYS) if (typeof src[k] === "boolean") out[k] = src[k];
|
|
76
|
+
for (const k of STR_KEYS) if (typeof src[k] === "string") out[k] = src[k].slice(0, 200);
|
|
77
|
+
for (const k of NUM_KEYS) {
|
|
78
|
+
if (typeof src[k] === "number" && Number.isFinite(src[k])) {
|
|
79
|
+
const [lo, hi] = NUMERIC_RANGE[k];
|
|
80
|
+
const step = k === "maxChars" || k === "maxSentences" ? Math.round(src[k]) : src[k];
|
|
81
|
+
out[k] = Math.min(hi, Math.max(lo, step));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
function readLocalConfig() {
|
|
87
|
+
try {
|
|
88
|
+
if (typeof localStorage === "undefined") return null;
|
|
89
|
+
const raw = localStorage.getItem(CONFIG_STORE_KEY);
|
|
90
|
+
return raw ? normalizeConfig(JSON.parse(raw)) : null;
|
|
91
|
+
} catch { return null; }
|
|
92
|
+
}
|
|
93
|
+
function writeLocalConfig(cfg) {
|
|
94
|
+
try {
|
|
95
|
+
if (typeof localStorage !== "undefined") localStorage.setItem(CONFIG_STORE_KEY, JSON.stringify(cfg));
|
|
96
|
+
} catch { /* 隐私模式等写不进去,不影响本次使用 */ }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 配置总线:宿主 tts 段为准,本地镜像兜底。
|
|
101
|
+
* routeGone(宿主没有 /dsh-dock/tts 路由 = 宿主进程是旧版本,或本功能在宿主侧未启用)
|
|
102
|
+
* 时**停止一切 POST**,改用 localStorage:手动朗读、自动朗读照常用,只是不跨设备同步。
|
|
103
|
+
* 与【用量记录】chip 的 routeGone 闩锁同一个道理(405/404 是终态,重试无用)。
|
|
104
|
+
*/
|
|
105
|
+
const configStore = {
|
|
106
|
+
value: readLocalConfig() || normalizeConfig({}),
|
|
107
|
+
loaded: false,
|
|
108
|
+
routeGone: false,
|
|
109
|
+
note: "",
|
|
110
|
+
listeners: new Set(),
|
|
111
|
+
};
|
|
112
|
+
function notifyConfig() {
|
|
113
|
+
for (const fn of Array.from(configStore.listeners)) {
|
|
114
|
+
try { fn(); } catch { /* 单个订阅者异常不影响其他 */ }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function subscribeConfig(fn) {
|
|
118
|
+
configStore.listeners.add(fn);
|
|
119
|
+
return () => { configStore.listeners.delete(fn); };
|
|
120
|
+
}
|
|
121
|
+
function useConfig() {
|
|
122
|
+
const [, force] = useState(0);
|
|
123
|
+
useEffect(() => subscribeConfig(() => force((n) => n + 1)), []);
|
|
124
|
+
return configStore.value;
|
|
125
|
+
}
|
|
126
|
+
// 宿主落盘防抖:滑杆拖动一格一事件,逐格 POST 会打爆宿主(面板与回合尾浮层共用)。
|
|
127
|
+
// 本地值与 localStorage **立即**更新(UI 与朗读即时生效),只有宿主写盘延到停手之后。
|
|
128
|
+
const HOST_SAVE_DEBOUNCE_MS = 400;
|
|
129
|
+
let hostSaveTimer = null;
|
|
130
|
+
let hostSavePending = null;
|
|
131
|
+
// 本地改动序号:写盘响应回来时若本地已有更新的改动,**不拿宿主的旧值覆盖**——
|
|
132
|
+
// 否则拖动中会出现「滑杆跳回上一个值」的闪回(防抖让在途请求与后续改动重叠,更易踩到)。
|
|
133
|
+
let localRev = 0;
|
|
134
|
+
function setConfig(patch, opts) {
|
|
135
|
+
const p = patch && typeof patch === "object" ? patch : {};
|
|
136
|
+
const next = normalizeConfig(Object.assign({}, configStore.value, p));
|
|
137
|
+
configStore.value = next;
|
|
138
|
+
localRev += 1;
|
|
139
|
+
writeLocalConfig(next);
|
|
140
|
+
notifyConfig();
|
|
141
|
+
if (opts && opts.skipHost) return Promise.resolve(next);
|
|
142
|
+
if (configStore.routeGone) return Promise.resolve(next);
|
|
143
|
+
hostSavePending = Object.assign(hostSavePending || {}, p);
|
|
144
|
+
if (hostSaveTimer !== null) clearTimeout(hostSaveTimer);
|
|
145
|
+
return new Promise((resolve) => {
|
|
146
|
+
hostSaveTimer = setTimeout(() => {
|
|
147
|
+
hostSaveTimer = null;
|
|
148
|
+
const body = hostSavePending || {};
|
|
149
|
+
hostSavePending = null;
|
|
150
|
+
pushConfigToHost(body, localRev, resolve);
|
|
151
|
+
}, HOST_SAVE_DEBOUNCE_MS);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
function pushConfigToHost(body, sentRev, resolve) {
|
|
155
|
+
fetch("dsh-dock/tts/config", {
|
|
156
|
+
method: "POST",
|
|
157
|
+
headers: { "content-type": "application/json" },
|
|
158
|
+
body: JSON.stringify(body),
|
|
159
|
+
})
|
|
160
|
+
.then(async (res) => {
|
|
161
|
+
if (res.status === 404 || res.status === 405) {
|
|
162
|
+
configStore.routeGone = true;
|
|
163
|
+
configStore.note = "宿主侧没有语音朗读接口(重启 dsh web,或把本功能开关关掉再打开),配置暂只存在本浏览器";
|
|
164
|
+
notifyConfig();
|
|
165
|
+
resolve(configStore.value);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const data = await res.json().catch(() => ({}));
|
|
169
|
+
if (!res.ok || (data && data.ok === false)) {
|
|
170
|
+
configStore.note = "保存配置失败:" + ((data && data.error && data.error.message) || ("HTTP " + res.status));
|
|
171
|
+
notifyConfig();
|
|
172
|
+
resolve(configStore.value);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
configStore.note = "";
|
|
176
|
+
// 回包只作确认:期间本地若又有改动(sentRev 落后),不覆盖新值
|
|
177
|
+
if (data && data.data && data.data.config && localRev === sentRev) {
|
|
178
|
+
const merged = normalizeConfig(data.data.config);
|
|
179
|
+
configStore.value = merged;
|
|
180
|
+
writeLocalConfig(merged);
|
|
181
|
+
}
|
|
182
|
+
notifyConfig();
|
|
183
|
+
resolve(configStore.value);
|
|
184
|
+
})
|
|
185
|
+
.catch(() => {
|
|
186
|
+
configStore.note = "保存配置失败:网络不可达,配置暂只存在本浏览器";
|
|
187
|
+
notifyConfig();
|
|
188
|
+
resolve(configStore.value);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
let hostConfigRequested = false;
|
|
192
|
+
// 配置探测(/status 一次往返)完成信号:回合尾的自动朗读兜底要等它落定才能
|
|
193
|
+
// 判定 routeGone——首回合尾恰好在页面加载时挂载(探测在途)也不漏读。
|
|
194
|
+
let resolveConfigReady = null;
|
|
195
|
+
const configReady = new Promise((r) => { resolveConfigReady = r; });
|
|
196
|
+
function markConfigReady() {
|
|
197
|
+
if (resolveConfigReady === null) return;
|
|
198
|
+
const r = resolveConfigReady;
|
|
199
|
+
resolveConfigReady = null;
|
|
200
|
+
r();
|
|
201
|
+
}
|
|
202
|
+
/** 首次用到配置时向宿主拉一次(幂等)。宿主不可达则保持本地值。 */
|
|
203
|
+
function ensureConfigLoaded() {
|
|
204
|
+
if (hostConfigRequested || configStore.routeGone) { if (configStore.routeGone) markConfigReady(); return; }
|
|
205
|
+
hostConfigRequested = true;
|
|
206
|
+
fetch("dsh-dock/tts/status", {
|
|
207
|
+
method: "POST",
|
|
208
|
+
headers: { "content-type": "application/json" },
|
|
209
|
+
body: "{}",
|
|
210
|
+
})
|
|
211
|
+
.then(async (res) => {
|
|
212
|
+
if (res.status === 404 || res.status === 405) {
|
|
213
|
+
configStore.routeGone = true;
|
|
214
|
+
configStore.note = "宿主侧没有语音朗读接口(重启 dsh web,或把本功能开关关掉再打开),配置暂只存在本浏览器";
|
|
215
|
+
notifyConfig();
|
|
216
|
+
markConfigReady();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const data = await res.json().catch(() => ({}));
|
|
220
|
+
if (data && data.ok && data.data && data.data.config) {
|
|
221
|
+
// 宿主持久化值优先;但本浏览器已经表态过的键不覆盖(多设备场景沿用 shared.js 的取舍)
|
|
222
|
+
const local = readLocalConfig();
|
|
223
|
+
const fromHost = normalizeConfig(data.data.config);
|
|
224
|
+
configStore.value = local ? normalizeConfig(Object.assign({}, CONFIG_DEFAULTS, fromHost, local)) : fromHost;
|
|
225
|
+
writeLocalConfig(configStore.value);
|
|
226
|
+
}
|
|
227
|
+
configStore.loaded = true;
|
|
228
|
+
notifyConfig();
|
|
229
|
+
markConfigReady();
|
|
230
|
+
})
|
|
231
|
+
.catch(() => { /* 宿主不可达:本地值继续用;就绪信号照放(兜底自己判 routeGone) */
|
|
232
|
+
markConfigReady(); });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---------- Markdown → 可朗读的纯文本 ----------
|
|
236
|
+
/**
|
|
237
|
+
* 会话正文是 Markdown,直接念会念出一堆符号。这里按「听起来像人话」清洗:
|
|
238
|
+
* 代码块/表格按配置整段跳过,链接只留文字,标记符号一律去掉。
|
|
239
|
+
* 返回的文本已经压成单段(换行→空格),分句交给 chunkForSpeech。
|
|
240
|
+
*/
|
|
241
|
+
export function plainForSpeech(md, cfg) {
|
|
242
|
+
let t = String(md == null ? "" : md);
|
|
243
|
+
if (!t.trim()) return "";
|
|
244
|
+
t = t.replace(/\r\n?/g, "\n");
|
|
245
|
+
// 围栏代码块
|
|
246
|
+
t = t.replace(/```[\w+-]*(?:[^\n]*)\n([\s\S]*?)```/g, (_m, body) => {
|
|
247
|
+
if (cfg && cfg.skipCode === false) return " " + String(body).replace(/[#*_`>]/g, " ") + " ";
|
|
248
|
+
return " (此处有代码,略) ";
|
|
249
|
+
});
|
|
250
|
+
// 波浪号围栏与缩进代码(4 空格 / tab 开头且下一行也缩进):保守处理,只去缩进
|
|
251
|
+
t = t.replace(/^(?: {4}|\t)(.*)$/gm, (_m, body) => (cfg && cfg.skipCode === false ? " " + body : " "));
|
|
252
|
+
// 行内代码:保留文字,去掉反引号
|
|
253
|
+
t = t.replace(/`([^`\n]*)`/g, "$1");
|
|
254
|
+
// 图片 → alt 文字;链接 → 锚文字
|
|
255
|
+
t = t.replace(/!\[([^\]]*)\]\([^)]*\)/g, " $1 ");
|
|
256
|
+
t = t.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1");
|
|
257
|
+
t = t.replace(/<((?:https?|ftp|mailto):[^>\s]+)>/gi, " ");
|
|
258
|
+
// 表格:整块跳过或把竖线换成停顿
|
|
259
|
+
t = t.replace(/(^\s*\|.+\|[ \t]*(?:\n|$))+/gm, (block) => {
|
|
260
|
+
if (cfg && cfg.skipTables === false) return block.replace(/\|/g, ",").replace(/[-:,]{2,}/g, ",");
|
|
261
|
+
return " (此处有表格,略) ";
|
|
262
|
+
});
|
|
263
|
+
// 结构符号
|
|
264
|
+
t = t.replace(/^\s{0,3}#{1,6}\s+/gm, " ");
|
|
265
|
+
t = t.replace(/^\s{0,3}>+\s?/gm, " ");
|
|
266
|
+
t = t.replace(/^\s*(?:[-*+]|\d+[.)])\s+/gm, " ");
|
|
267
|
+
t = t.replace(/^\s*(?:={3,}|-{3,}|\*{3,}|_{3,})\s*$/gm, " ");
|
|
268
|
+
t = t.replace(/(\*\*|__|~~|\*|_)/g, " ");
|
|
269
|
+
t = t.replace(/<[^>\n]{1,200}>/g, " ");
|
|
270
|
+
// 压成单段
|
|
271
|
+
t = t.replace(/\s*\n+\s*/g, " ").replace(/[ \t]{2,}/g, " ").trim();
|
|
272
|
+
return t;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** 取前 N 句(0=不限);再按字数上限截断(0=不限),截断处补「(以下略)」。 */
|
|
276
|
+
export function capSpeechText(text, cfg) {
|
|
277
|
+
let t = String(text || "");
|
|
278
|
+
const sentences = cfg && typeof cfg.maxSentences === "number" && cfg.maxSentences > 0
|
|
279
|
+
? Math.round(cfg.maxSentences) : 0;
|
|
280
|
+
if (sentences > 0) {
|
|
281
|
+
const parts = splitSentences(t);
|
|
282
|
+
if (parts.length > sentences) t = parts.slice(0, sentences).join("") + "(后面的内容略)";
|
|
283
|
+
}
|
|
284
|
+
const max = cfg && typeof cfg.maxChars === "number" && cfg.maxChars > 0 ? Math.round(cfg.maxChars) : 0;
|
|
285
|
+
if (max > 0 && t.length > max) t = t.slice(0, max) + "…(以下略)";
|
|
286
|
+
return t.trim();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** 按中英文句末标点分句,保留分隔符(不用后行断言,兼容老内核与构建目标)。 */
|
|
290
|
+
function splitSentences(text) {
|
|
291
|
+
const out = [];
|
|
292
|
+
let cur = "";
|
|
293
|
+
const re = /[^。!?;.!?;\n]+|[。!?;.!?;\n]+/g;
|
|
294
|
+
let m;
|
|
295
|
+
while ((m = re.exec(text)) !== null) {
|
|
296
|
+
if (/[。!?;.!?;\n]/.test(m[0])) {
|
|
297
|
+
cur += m[0];
|
|
298
|
+
if (cur.trim()) out.push(cur);
|
|
299
|
+
cur = "";
|
|
300
|
+
} else {
|
|
301
|
+
cur += m[0];
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (cur.trim()) out.push(cur);
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** 切成 ≤limit 字的片段:Chrome 长句合成会静默停住,分片 + 顺序播是标准解法。 */
|
|
309
|
+
function chunkForSpeech(text, limit) {
|
|
310
|
+
const cap = limit && limit > 0 ? limit : 180;
|
|
311
|
+
const sentences = splitSentences(String(text || ""));
|
|
312
|
+
const chunks = [];
|
|
313
|
+
let buf = "";
|
|
314
|
+
for (const s of sentences) {
|
|
315
|
+
if (s.length > cap) {
|
|
316
|
+
if (buf) { chunks.push(buf); buf = ""; }
|
|
317
|
+
for (let i = 0; i < s.length; i += cap) chunks.push(s.slice(i, i + cap));
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if ((buf + s).length > cap && buf) { chunks.push(buf); buf = s; }
|
|
321
|
+
else buf += s;
|
|
322
|
+
}
|
|
323
|
+
if (buf.trim()) chunks.push(buf);
|
|
324
|
+
return chunks;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** 按配置挑音色:显式选过的音色必须在**本机**音色表里存在,否则按语言回落。 */
|
|
328
|
+
function pickVoice(cfg) {
|
|
329
|
+
const voices = localVoices();
|
|
330
|
+
if (voices.length === 0) return null;
|
|
331
|
+
const want = String((cfg && cfg.voiceURI) || "");
|
|
332
|
+
if (want) {
|
|
333
|
+
const hit = voices.find((v) => v && v.voiceURI === want);
|
|
334
|
+
if (hit) return hit; // 音色是设备相关的:别的机器上存的 voiceURI 在这里可能没有,落下面按语言挑
|
|
335
|
+
}
|
|
336
|
+
const lang = String((cfg && cfg.lang) || "");
|
|
337
|
+
if (lang) {
|
|
338
|
+
const base = lang.split("-")[0].toLowerCase();
|
|
339
|
+
return voices.find((v) => String(v.lang || "").toLowerCase() === lang.toLowerCase())
|
|
340
|
+
|| voices.find((v) => String(v.lang || "").toLowerCase().startsWith(base))
|
|
341
|
+
|| null;
|
|
342
|
+
}
|
|
343
|
+
// 未指定语言:按文本大概率是中文,优先中文音色
|
|
344
|
+
const zh = voices.find((v) => /^zh/i.test(String(v.lang || "")));
|
|
345
|
+
return zh || null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ---------- 朗读引擎(模块级单例:全页面同时只有一个声音) ----------
|
|
349
|
+
// phase:idle(没在读) / playing / paused(暂停后原位待续)。
|
|
350
|
+
// session 留住当前这一篇的分片与游标——暂停/继续/重新朗读都靠它,不重头、不丢进度。
|
|
351
|
+
const engine = {
|
|
352
|
+
state: { phase: "idle", key: "", label: "" },
|
|
353
|
+
session: null,
|
|
354
|
+
listeners: new Set(),
|
|
355
|
+
queue: [],
|
|
356
|
+
epoch: 0,
|
|
357
|
+
};
|
|
358
|
+
function notifyEngine() {
|
|
359
|
+
for (const fn of Array.from(engine.listeners)) {
|
|
360
|
+
try { fn(); } catch { /* 订阅者异常不外溢 */ }
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
function engineSnapshot() { return engine.state; }
|
|
364
|
+
function subscribeEngine(fn) {
|
|
365
|
+
engine.listeners.add(fn);
|
|
366
|
+
return () => { engine.listeners.delete(fn); };
|
|
367
|
+
}
|
|
368
|
+
function useEngineState() {
|
|
369
|
+
const [snap, setSnap] = useState(engineSnapshot());
|
|
370
|
+
useEffect(() => subscribeEngine(() => setSnap(engineSnapshot())), []);
|
|
371
|
+
return snap;
|
|
372
|
+
}
|
|
373
|
+
function setEngineState(next) {
|
|
374
|
+
engine.state = Object.assign({}, engine.state, next);
|
|
375
|
+
notifyEngine();
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** 停止当前朗读(清队列、丢进度)。幂等:不支持 TTS / 没在读也安全。 */
|
|
379
|
+
export function stopSpeech() {
|
|
380
|
+
engine.queue = [];
|
|
381
|
+
engine.session = null;
|
|
382
|
+
engine.epoch += 1;
|
|
383
|
+
const s = synth();
|
|
384
|
+
if (s) { try { s.cancel(); } catch { /* ignore */ } }
|
|
385
|
+
setEngineState({ phase: "idle", key: "", label: "" });
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** 暂停:停在当前这句的当前字上,再 resume 就地继续(浏览器原生 pause/resume)。 */
|
|
389
|
+
export function pauseSpeech() {
|
|
390
|
+
if (engine.state.phase !== "playing") return false;
|
|
391
|
+
const s = synth();
|
|
392
|
+
if (s) { try { s.pause(); } catch { /* ignore */ } }
|
|
393
|
+
setEngineState({ phase: "paused" });
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** 继续:从暂停处接着读。没在暂停态则忽略(幂等)。 */
|
|
398
|
+
export function resumeSpeech() {
|
|
399
|
+
if (engine.state.phase !== "paused") return false;
|
|
400
|
+
const s = synth();
|
|
401
|
+
if (s) { try { s.resume(); } catch { /* ignore */ } }
|
|
402
|
+
setEngineState({ phase: "playing" });
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** 重新朗读当前这一篇(回到第一句)。 */
|
|
407
|
+
export function restartSpeech() {
|
|
408
|
+
const session = engine.session;
|
|
409
|
+
if (!session) return false;
|
|
410
|
+
const { key, raw, label, cfg } = session;
|
|
411
|
+
stopSpeech();
|
|
412
|
+
return startSession({ key, text: raw, label, cfg });
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* 起一篇朗读:分片、逐片顺序播(Chrome 对超长 utterance 会静默停住,分片是标准解法)。
|
|
417
|
+
* 抽成内部函数,speak() 与 restartSpeech() 共用同一套游标与 epoch 约定。
|
|
418
|
+
*/
|
|
419
|
+
function startSession(opts) {
|
|
420
|
+
const o = opts && typeof opts === "object" ? opts : {};
|
|
421
|
+
const text = String(o.text || "").trim();
|
|
422
|
+
if (!text) return false;
|
|
423
|
+
if (!ttsSupported()) return false;
|
|
424
|
+
const cfg = normalizeConfig(o.cfg || configStore.value);
|
|
425
|
+
const key = String(o.key || ("k" + Date.now()));
|
|
426
|
+
const chunks = chunkForSpeech(text, 180);
|
|
427
|
+
if (chunks.length === 0) return false;
|
|
428
|
+
const epoch = ++engine.epoch;
|
|
429
|
+
const voice = pickVoice(cfg);
|
|
430
|
+
const session = { key, raw: text, chunks, index: 0, epoch, cfg, label: String(o.label || "") };
|
|
431
|
+
engine.session = session;
|
|
432
|
+
setEngineState({ phase: "playing", key, label: session.label });
|
|
433
|
+
const s = synth();
|
|
434
|
+
const speakNext = () => {
|
|
435
|
+
if (epoch !== engine.epoch) return; // 已被新的播放/停止取代
|
|
436
|
+
if (session.index >= session.chunks.length) {
|
|
437
|
+
if (engine.session === session) engine.session = null;
|
|
438
|
+
setEngineState({ phase: "idle", key: "", label: "" });
|
|
439
|
+
drainQueue();
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
const piece = session.chunks[session.index];
|
|
443
|
+
session.index += 1; // 先推进:onend/onerror 只负责「继续下一片」,不再各自加一次
|
|
444
|
+
const utt = makeUtterance(piece);
|
|
445
|
+
if (!utt) {
|
|
446
|
+
if (engine.session === session) engine.session = null;
|
|
447
|
+
setEngineState({ phase: "idle", key: "", label: "" });
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
// 语速/音调/音量**逐片现取**:播放中在浮层/面板里拖动滑杆,从下一句起立即生效
|
|
451
|
+
// (当前这句改不了是浏览器限制——utterance 参数在 speak 时定格)。
|
|
452
|
+
const lv = configStore.value;
|
|
453
|
+
utt.rate = typeof lv.rate === "number" ? lv.rate : cfg.rate;
|
|
454
|
+
utt.pitch = typeof lv.pitch === "number" ? lv.pitch : cfg.pitch;
|
|
455
|
+
utt.volume = typeof lv.volume === "number" ? lv.volume : cfg.volume;
|
|
456
|
+
if (voice) { utt.voice = voice; if (voice.lang) utt.lang = voice.lang; }
|
|
457
|
+
else if (cfg.lang) utt.lang = cfg.lang;
|
|
458
|
+
utt.onend = () => { if (epoch !== engine.epoch) return; speakNext(); };
|
|
459
|
+
// 分片之间被浏览器吃掉 onend 是已知问题:onerror 同样推进,坏一片不致整段哑掉
|
|
460
|
+
utt.onerror = () => { if (epoch !== engine.epoch) return; speakNext(); };
|
|
461
|
+
try { s.speak(utt); } catch {
|
|
462
|
+
if (engine.session === session) engine.session = null;
|
|
463
|
+
setEngineState({ phase: "idle", key: "", label: "" });
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
// 上一句可能刚被 cancel():Chrome 异步清队,同一 tick 里 speak 会排进还没清空的
|
|
467
|
+
// 旧队列 → 先把上一条(会话 A)剩下的分片念完才轮到这一条(会话 B)。
|
|
468
|
+
// 所以必须等队列真正空了再开播;队列本来就空时(绝大多数情况)立即播,零延迟。
|
|
469
|
+
whenQueueClear(s, epoch, speakNext);
|
|
470
|
+
return true;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** 队列真空探测(沙箱/旧内核读不到属性时按「空」处理,绝不因此卡住播放)。 */
|
|
474
|
+
function queueBusy(s) {
|
|
475
|
+
try { return s.speaking === true || s.pending === true; } catch { return false; }
|
|
476
|
+
}
|
|
477
|
+
/** cancel 后等队列真空的轮询间隔与上限(上限到点仍未空则再 cancel 一次硬开播)。 */
|
|
478
|
+
const FLUSH_POLL_MS = 15;
|
|
479
|
+
const FLUSH_MAX_MS = 1500;
|
|
480
|
+
function whenQueueClear(s, epoch, run) {
|
|
481
|
+
if (!queueBusy(s)) { run(); return; }
|
|
482
|
+
let waited = 0;
|
|
483
|
+
const poll = () => {
|
|
484
|
+
if (epoch !== engine.epoch) return; // 期间又切了内容/停止:本局作废
|
|
485
|
+
if (!queueBusy(s)) { run(); return; }
|
|
486
|
+
waited += FLUSH_POLL_MS;
|
|
487
|
+
if (waited >= FLUSH_MAX_MS) {
|
|
488
|
+
// cancel 没生效(Chrome 已知毛病):再补一次并硬开播,宁可重叠也不静默不出声
|
|
489
|
+
try { s.cancel(); } catch { /* ignore */ }
|
|
490
|
+
run();
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
setTimeout(poll, FLUSH_POLL_MS);
|
|
494
|
+
};
|
|
495
|
+
setTimeout(poll, FLUSH_POLL_MS);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* 朗读一段文本。
|
|
500
|
+
* @param opts.key - 幂等键(同一 key 正在读/暂停时重复调用被忽略,靠它防重播)。
|
|
501
|
+
* @param opts.label - 显示用短标签(按钮 title)。
|
|
502
|
+
* @param opts.text - 已清洗的纯文本。
|
|
503
|
+
* @param opts.cfg - 朗读配置;interruptOnNew=false 时排队而不是打断。
|
|
504
|
+
* @returns 是否真的开始了播放(浏览器不支持时 false)。
|
|
505
|
+
*/
|
|
506
|
+
export function speak(opts) {
|
|
507
|
+
const o = opts && typeof opts === "object" ? opts : {};
|
|
508
|
+
const text = String(o.text || "").trim();
|
|
509
|
+
if (!text) return false;
|
|
510
|
+
if (!ttsSupported()) return false;
|
|
511
|
+
const cfg = normalizeConfig(o.cfg || configStore.value);
|
|
512
|
+
const key = String(o.key || ("k" + Date.now()));
|
|
513
|
+
// 同一篇已在读/暂停中:不重头(用户点第二下走的是暂停/继续,不是这里)
|
|
514
|
+
if (engine.state.phase !== "idle" && engine.state.key === key) return false;
|
|
515
|
+
if (engine.state.phase !== "idle" && !cfg.interruptOnNew) {
|
|
516
|
+
// 排队:只留最新一条,旧的不读(读两条没意义)
|
|
517
|
+
engine.queue = [{ key, text, label: String(o.label || ""), cfg }];
|
|
518
|
+
return false;
|
|
519
|
+
}
|
|
520
|
+
if (engine.state.phase !== "idle") stopSpeech();
|
|
521
|
+
return startSession({ key, text, label: o.label, cfg });
|
|
522
|
+
}
|
|
523
|
+
function drainQueue() {
|
|
524
|
+
const next = engine.queue.shift();
|
|
525
|
+
engine.queue = [];
|
|
526
|
+
if (next) speak({ key: next.key, text: next.text, label: next.label, cfg: Object.assign({}, next.cfg, { interruptOnNew: true }) });
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ---------- 从 turnTail owner 取本回合最后一条回复正文 ----------
|
|
530
|
+
function closingMarkdown(owner) {
|
|
531
|
+
const turn = owner && owner.turn;
|
|
532
|
+
const store = turn && turn.data;
|
|
533
|
+
if (!store || typeof store.get !== "function") return "";
|
|
534
|
+
let tail = null;
|
|
535
|
+
try { tail = store.get("turn-tail"); } catch { return ""; }
|
|
536
|
+
const closing = tail && tail.closing;
|
|
537
|
+
const blocks = closing && Array.isArray(closing.blocks) ? closing.blocks : null;
|
|
538
|
+
if (!blocks) return "";
|
|
539
|
+
const parts = [];
|
|
540
|
+
for (const b of blocks) if (b && b.kind === "text" && typeof b.text === "string") parts.push(b.text);
|
|
541
|
+
return parts.join("");
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ---------- 图标(与宿主动作行的线性图标同风格:17px 描边) ----------
|
|
545
|
+
const ICON_SPEAKER = react.createElement("svg", {
|
|
546
|
+
viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.8",
|
|
547
|
+
strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true",
|
|
548
|
+
},
|
|
549
|
+
react.createElement("path", { d: "M11 5 6 9H3v6h3l5 4z" }),
|
|
550
|
+
react.createElement("path", { d: "M15.5 8.5a5 5 0 0 1 0 7" }),
|
|
551
|
+
react.createElement("path", { d: "M18.5 5.5a9 9 0 0 1 0 13" }),
|
|
552
|
+
);
|
|
553
|
+
const ICON_PAUSE = react.createElement("svg", {
|
|
554
|
+
viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.2",
|
|
555
|
+
strokeLinecap: "round", "aria-hidden": "true",
|
|
556
|
+
},
|
|
557
|
+
react.createElement("path", { d: "M9 6v12" }),
|
|
558
|
+
react.createElement("path", { d: "M15 6v12" }),
|
|
559
|
+
);
|
|
560
|
+
const ICON_RESUME = react.createElement("svg", {
|
|
561
|
+
viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.8",
|
|
562
|
+
strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true",
|
|
563
|
+
},
|
|
564
|
+
react.createElement("path", { d: "M8 5.5v13l11-6.5z" }),
|
|
565
|
+
);
|
|
566
|
+
const ICON_RESTART = react.createElement("svg", {
|
|
567
|
+
viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.8",
|
|
568
|
+
strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true",
|
|
569
|
+
},
|
|
570
|
+
react.createElement("path", { d: "M3.5 8.5V4.5" }),
|
|
571
|
+
react.createElement("path", { d: "M3.5 8.5h4" }),
|
|
572
|
+
react.createElement("path", { d: "M5.6 5.1a8.5 8.5 0 1 1-2.1 5.4" }),
|
|
573
|
+
);
|
|
574
|
+
const ICON_STOP = react.createElement("svg", {
|
|
575
|
+
viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.8",
|
|
576
|
+
strokeLinejoin: "round", "aria-hidden": "true",
|
|
577
|
+
},
|
|
578
|
+
react.createElement("rect", { x: "6.5", y: "6.5", width: "11", height: "11", rx: "2" }),
|
|
579
|
+
);
|
|
580
|
+
const ICON_GEAR = react.createElement("svg", {
|
|
581
|
+
viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.8",
|
|
582
|
+
strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true",
|
|
583
|
+
},
|
|
584
|
+
react.createElement("circle", { cx: "12", cy: "12", r: "3.2" }),
|
|
585
|
+
react.createElement("path", { d: "M12 2.8v3M12 18.2v3M2.8 12h3M18.2 12h3M5.5 5.5l2.1 2.1M16.4 16.4l2.1 2.1M18.5 5.5l-2.1 2.1M7.6 16.4l-2.1 2.1" }),
|
|
586
|
+
);
|
|
587
|
+
|
|
588
|
+
// ---------- 就地快设浮层:语速 + 音量(听的时候最常改的就这两个) ----------
|
|
589
|
+
/**
|
|
590
|
+
* 从回合尾的 ⚙ 弹出,portal 到 body(会话流祖先可能被 overflow/backdrop-filter 裁剪,
|
|
591
|
+
* 与【用量记录】单价子弹窗同一个理由)。滑杆改的是同一份 configStore:
|
|
592
|
+
* 引擎**逐片现取**语速/音量,播放中拖动从下一句起立即生效(当前这句是浏览器定格,改不动)。
|
|
593
|
+
* 完整设置(音色/清洗规则等)走「更多设置」跳功能坞面板。
|
|
594
|
+
*/
|
|
595
|
+
function TtsQuickPop(props) {
|
|
596
|
+
const cfg = useConfig();
|
|
597
|
+
const anchor = props.anchor || { x: 120, y: 120 };
|
|
598
|
+
const boxRef = useRef(null);
|
|
599
|
+
useEffect(() => {
|
|
600
|
+
if (typeof document === "undefined") return undefined;
|
|
601
|
+
const onDown = (e) => {
|
|
602
|
+
if (boxRef.current && !boxRef.current.contains(e.target)) props.onClose();
|
|
603
|
+
};
|
|
604
|
+
const onKey = (e) => { if (e.key === "Escape") props.onClose(); };
|
|
605
|
+
document.addEventListener("pointerdown", onDown, true);
|
|
606
|
+
document.addEventListener("keydown", onKey);
|
|
607
|
+
return () => {
|
|
608
|
+
document.removeEventListener("pointerdown", onDown, true);
|
|
609
|
+
document.removeEventListener("keydown", onKey);
|
|
610
|
+
};
|
|
611
|
+
}, [props.onClose]);
|
|
612
|
+
// 视口夹取:贴右边/下边的回合不至于把浮层弹出屏外(宽约 240、高约 130)
|
|
613
|
+
const vw = typeof globalThis !== "undefined" && globalThis.innerWidth ? globalThis.innerWidth : 1440;
|
|
614
|
+
const left = Math.max(8, Math.min(anchor.x - 4, vw - 256));
|
|
615
|
+
const portalTarget = typeof document !== "undefined" && document.body ? document.body : null;
|
|
616
|
+
const pop = react.createElement("div", {
|
|
617
|
+
ref: boxRef, className: "dktts-pop",
|
|
618
|
+
style: { left: left + "px", top: (anchor.y - 8) + "px" },
|
|
619
|
+
role: "dialog", "aria-label": "朗读快速设置",
|
|
620
|
+
},
|
|
621
|
+
react.createElement("div", { className: "dktts-pop-h" },
|
|
622
|
+
react.createElement("b", null, "朗读设置"),
|
|
623
|
+
react.createElement("button", {
|
|
624
|
+
type: "button", className: "dktts-pop-x",
|
|
625
|
+
"aria-label": "关闭", title: "关闭", onClick: props.onClose,
|
|
626
|
+
}, "✕")),
|
|
627
|
+
react.createElement("div", { className: "dktts-pop-row" },
|
|
628
|
+
react.createElement("span", null, "语速"),
|
|
629
|
+
react.createElement("input", {
|
|
630
|
+
type: "range", min: "0.5", max: "2", step: "0.05", value: String(cfg.rate),
|
|
631
|
+
"aria-label": "语速",
|
|
632
|
+
onChange: (e) => setConfig({ rate: Number(e.target.value) || 1 }),
|
|
633
|
+
}),
|
|
634
|
+
react.createElement("span", { className: "dktts-pop-val" }, cfg.rate.toFixed(2) + "×")),
|
|
635
|
+
react.createElement("div", { className: "dktts-pop-row" },
|
|
636
|
+
react.createElement("span", null, "音量"),
|
|
637
|
+
react.createElement("input", {
|
|
638
|
+
type: "range", min: "0", max: "1", step: "0.05", value: String(cfg.volume),
|
|
639
|
+
"aria-label": "音量",
|
|
640
|
+
onChange: (e) => setConfig({ volume: Number(e.target.value) }),
|
|
641
|
+
}),
|
|
642
|
+
react.createElement("span", { className: "dktts-pop-val" }, Math.round(cfg.volume * 100) + "%")),
|
|
643
|
+
react.createElement("div", { className: "dktts-pop-foot" },
|
|
644
|
+
react.createElement("span", { className: "dktts-pop-tip" }, "下一句起生效"),
|
|
645
|
+
react.createElement("button", {
|
|
646
|
+
type: "button", className: "dktts-pop-more",
|
|
647
|
+
onClick: () => { props.onClose(); openPanel("tts"); },
|
|
648
|
+
}, "更多设置 →")));
|
|
649
|
+
return portalTarget && typeof react.createPortal === "function"
|
|
650
|
+
? react.createPortal(pop, portalTarget)
|
|
651
|
+
: pop;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// ---------- 自动朗读共用常量(回合尾兜底与 TtsOverlay 主路径共用) ----------
|
|
655
|
+
/** 视为「这一回合刚结束」的窗口:回合尾节点挂载时 turn/end 距今不超过这个毫秒数。 */
|
|
656
|
+
const AUTO_PLAY_FRESH_MS = 8000;
|
|
657
|
+
/** 非正常结束的 reason(跟随 autoPlayOnFinish 开关决定是否朗读)。 */
|
|
658
|
+
const BAD_END = { error: 1, aborted: 1, blocked: 1, "max-tokens": 1, interrupted: 1 };
|
|
659
|
+
/** 本地兜底读过的回合键(模块级:切会话/重挂载都不重播)。 */
|
|
660
|
+
const spokenKeys = new Set();
|
|
661
|
+
function markSpoken(key) {
|
|
662
|
+
spokenKeys.add(key);
|
|
663
|
+
if (spokenKeys.size > 200) {
|
|
664
|
+
// Set 保持插入序,丢最早的即可(只用于「刚结束的回合」判定,窗口很小)
|
|
665
|
+
const first = spokenKeys.values().next();
|
|
666
|
+
if (!first.done) spokenKeys.delete(first.value);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* 自动朗读去重闸(两条路径合流):浮层主路径(宿主追踪)与回合尾本地兜底
|
|
671
|
+
* 共用这份键集——宿主路由中途恢复(面板「重试连接宿主」)时,同一回合只会被
|
|
672
|
+
* 先到的那条路径读一次。键统一用回合尾口径 sessionId:turn:seq;浮层从
|
|
673
|
+
* lastText 反推不出 seq 时退回自己的 sessionId|endTime 口径(两口径互不碰撞,
|
|
674
|
+
* 真撞上也只是少读一次,不会双读)。
|
|
675
|
+
*/
|
|
676
|
+
function claimAutoPlay(key) {
|
|
677
|
+
if (spokenKeys.has(key)) return false;
|
|
678
|
+
markSpoken(key);
|
|
679
|
+
return true;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// ---------- 回合下方的喇叭按钮(手动朗读:播放 / 暂停 / 继续 / 重读 / 停止) ----------
|
|
683
|
+
/**
|
|
684
|
+
* 宿主把本条目渲染在「已完成回合」的动作区之前(conversation.chat.turnTail 是 list 座位)。
|
|
685
|
+
* 三态主按钮:
|
|
686
|
+
* 没在读 → 朗读本条;正在读 → 暂停(停在当前这句的当前字上);暂停中 → 就地继续。
|
|
687
|
+
* 暂停/继续**不重头**;「重新朗读」回到第一句;「停止」彻底收起这条的播放。
|
|
688
|
+
* 行尾 ⚙ 就地快设语速/音量。自动朗读主路径在 TtsOverlay(宿主追踪驱动);
|
|
689
|
+
* 宿主路由不可用时由本组件的兜底 effect 接手(见 autoDoneRef)。
|
|
690
|
+
*/
|
|
691
|
+
function TtsTurnTail(props) {
|
|
692
|
+
const owner = props || {};
|
|
693
|
+
const cfg = useConfig();
|
|
694
|
+
const eng = useEngineState();
|
|
695
|
+
const md = closingMarkdown(owner);
|
|
696
|
+
const turn = owner.turn;
|
|
697
|
+
const turnNo = turn && typeof turn.turn === "number" ? turn.turn : -1;
|
|
698
|
+
const key = String((owner && owner.sessionId) || "") + ":" + turnNo + ":" + (owner.seq || 0);
|
|
699
|
+
const hasText = Boolean(md && md.trim());
|
|
700
|
+
const supported = ttsSupported();
|
|
701
|
+
// 本回合的播放态:active = 这一篇正被引擎持有(playing 或 paused)
|
|
702
|
+
const active = hasText && eng.phase !== "idle" && eng.key === key;
|
|
703
|
+
const paused = active && eng.phase === "paused";
|
|
704
|
+
const playing = active && eng.phase === "playing";
|
|
705
|
+
|
|
706
|
+
useEffect(() => { ensureConfigLoaded(); }, []);
|
|
707
|
+
const startThis = () => {
|
|
708
|
+
ensureConfigLoaded();
|
|
709
|
+
const plain = capSpeechText(plainForSpeech(md, configStore.value), configStore.value);
|
|
710
|
+
speak({ key, text: plain, cfg: configStore.value, label: "第 " + (turnNo + 1) + " 回合" });
|
|
711
|
+
};
|
|
712
|
+
// —— 自动朗读的本地兜底(仅宿主路由不可用时生效)——
|
|
713
|
+
// 主路径在 TtsOverlay:轮宿主任务追踪,后台会话也能读。但宿主进程是旧版本时
|
|
714
|
+
// /dsh-dock/tts 整片 405 → routeGone → 主路径哑火,而**手动喇叭并不依赖宿主**——
|
|
715
|
+
// 于是出现「手动能读、自动不读」的残缺态(2026-10-23 用户实测)。兜底用回合尾
|
|
716
|
+
// 节点自己当信号:该节点只在 turn/end 落盘后挂载,「挂载 + 结束时间 < 8 秒」
|
|
717
|
+
// = 这一回合刚结束;冷读历史会话时间差大 → 不播,claimAutoPlay 去重防重播/双读。
|
|
718
|
+
// ⚠️ 全部 hooks 必须排在 hasText 早退之前(条件 hook 在真实 React 会抛错)。
|
|
719
|
+
const autoDoneRef = useRef(false);
|
|
720
|
+
// ⚙ 就地快设(语速/音量):任何状态下都可开——没在读时调的下次朗读用
|
|
721
|
+
const [quick, setQuick] = useState(null); // null | { x, y }(按钮视口坐标)
|
|
722
|
+
useEffect(() => {
|
|
723
|
+
if (autoDoneRef.current) return;
|
|
724
|
+
autoDoneRef.current = true;
|
|
725
|
+
if (!hasText || !supported) return;
|
|
726
|
+
let cancelled = false;
|
|
727
|
+
// 等配置探测落定再判 routeGone:页面刚加载时首个回合尾可能先于 /status
|
|
728
|
+
// 往返挂载,同步读会漏掉这一回合(8 秒新鲜窗口内补判即可,过期自然放弃)。
|
|
729
|
+
configReady.then(() => {
|
|
730
|
+
if (cancelled || autoDoneRef.current === "played") return;
|
|
731
|
+
autoDoneRef.current = "played";
|
|
732
|
+
if (!configStore.routeGone) return; // 宿主路由活着 → 主路径负责,这里让位
|
|
733
|
+
if (!configStore.value.autoPlay) return;
|
|
734
|
+
const reasonKind = turn && turn.end && turn.end.data && turn.end.data.reason
|
|
735
|
+
? String(turn.end.data.reason.kind || "") : "";
|
|
736
|
+
const bad = Boolean(reasonKind) && BAD_END[reasonKind] === 1;
|
|
737
|
+
if (bad && configStore.value.autoPlayOnFinish === false) return;
|
|
738
|
+
let endedAgo = Number.POSITIVE_INFINITY;
|
|
739
|
+
try {
|
|
740
|
+
const t = turn && turn.end ? turn.end.time : 0;
|
|
741
|
+
if (typeof t === "number" && t > 0) endedAgo = Date.now() - t;
|
|
742
|
+
} catch { endedAgo = Number.POSITIVE_INFINITY; }
|
|
743
|
+
if (!(endedAgo >= 0 && endedAgo < AUTO_PLAY_FRESH_MS)) return;
|
|
744
|
+
if (!claimAutoPlay("local:" + key)) return;
|
|
745
|
+
const plain = capSpeechText(plainForSpeech(md, configStore.value), configStore.value);
|
|
746
|
+
// 与手动同一 key:自动读时本回合的按钮同步进入「暂停」态,点它即暂停/继续
|
|
747
|
+
speak({ key, text: plain, cfg: configStore.value, label: "第 " + (turnNo + 1) + " 回合" });
|
|
748
|
+
});
|
|
749
|
+
return () => { cancelled = true; };
|
|
750
|
+
}, []);
|
|
751
|
+
|
|
752
|
+
if (!hasText) return null;
|
|
753
|
+
const mainTitle = !supported
|
|
754
|
+
? "本浏览器不支持语音合成(Web Speech API)"
|
|
755
|
+
: playing ? "暂停(再点继续,不重头)"
|
|
756
|
+
: paused ? "继续朗读" : "朗读这条回复";
|
|
757
|
+
const onMain = () => {
|
|
758
|
+
if (!supported) return;
|
|
759
|
+
if (playing) { pauseSpeech(); return; }
|
|
760
|
+
if (paused) { resumeSpeech(); return; }
|
|
761
|
+
startThis();
|
|
762
|
+
};
|
|
763
|
+
const openQuick = (e) => {
|
|
764
|
+
const rect = e && e.currentTarget && typeof e.currentTarget.getBoundingClientRect === "function"
|
|
765
|
+
? e.currentTarget.getBoundingClientRect() : null;
|
|
766
|
+
ensureConfigLoaded();
|
|
767
|
+
setQuick(rect ? { x: rect.left, y: rect.bottom } : { x: 120, y: 120 });
|
|
768
|
+
};
|
|
769
|
+
return react.createElement("div", { className: "dktts-tail" },
|
|
770
|
+
react.createElement("button", {
|
|
771
|
+
type: "button",
|
|
772
|
+
className: "dktts-btn" + (active ? " on" : "") + (supported ? "" : " off"),
|
|
773
|
+
"aria-label": mainTitle,
|
|
774
|
+
"aria-pressed": active,
|
|
775
|
+
title: mainTitle,
|
|
776
|
+
onClick: onMain,
|
|
777
|
+
}, playing ? ICON_PAUSE : paused ? ICON_RESUME : ICON_SPEAKER),
|
|
778
|
+
active ? react.createElement("button", {
|
|
779
|
+
type: "button", className: "dktts-btn",
|
|
780
|
+
"aria-label": "重新朗读", title: "重新朗读(从头读)",
|
|
781
|
+
onClick: () => { restartSpeech(); },
|
|
782
|
+
}, ICON_RESTART) : null,
|
|
783
|
+
active ? react.createElement("button", {
|
|
784
|
+
type: "button", className: "dktts-btn",
|
|
785
|
+
"aria-label": "停止", title: "停止朗读",
|
|
786
|
+
onClick: () => { stopSpeech(); },
|
|
787
|
+
}, ICON_STOP) : null,
|
|
788
|
+
react.createElement("button", {
|
|
789
|
+
type: "button",
|
|
790
|
+
className: "dktts-btn" + (quick ? " on" : ""),
|
|
791
|
+
"aria-label": "朗读设置(语速 / 音量)",
|
|
792
|
+
"aria-expanded": quick ? true : false,
|
|
793
|
+
title: "朗读设置(语速 / 音量)",
|
|
794
|
+
onClick: openQuick,
|
|
795
|
+
}, ICON_GEAR),
|
|
796
|
+
react.createElement("span", { className: "dktts-state" + (active ? " on" : "") },
|
|
797
|
+
playing ? "正在朗读本回合" : paused ? "已暂停 · 点 ▶ 继续,不重头" : ""),
|
|
798
|
+
quick ? react.createElement(TtsQuickPop, { anchor: quick, onClose: () => setQuick(null) }) : null);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// ---------- 自动朗读浮层:任务结束时读最后一条回复 ----------
|
|
802
|
+
/**
|
|
803
|
+
* 与【任务通知】同一机制:轮询宿主侧会话级任务追踪(src/task-track.js),
|
|
804
|
+
* 看到「新出现的结束记录」就读它的 lastText。
|
|
805
|
+
*
|
|
806
|
+
* 为什么不在回合尾节点里判断「回合刚结束」:冷读历史会话时那个节点也会挂载,
|
|
807
|
+
* 只能靠时间差猜,既会误播也读不到后台会话;而宿主追踪的 agent idle 是
|
|
808
|
+
* 「这个会话的任务真的跑完了」的权威信号(一个任务多轮也只记一次结束)。
|
|
809
|
+
* 宿主路由不可用时(旧宿主进程未注册 /dsh-dock/tts),本浮层哑火,
|
|
810
|
+
* 由回合尾节点的本地兜底接手(见 TtsTurnTail 的 autoDoneRef effect)。
|
|
811
|
+
*/
|
|
812
|
+
function TtsOverlay() {
|
|
813
|
+
const cfg = useConfig();
|
|
814
|
+
const seenRef = useRef(null);
|
|
815
|
+
const primedRef = useRef(false);
|
|
816
|
+
|
|
817
|
+
// 配置拉取与轮询分开:默认值是关的,若不先拉宿主配置,autoPlay 永远起不来。
|
|
818
|
+
useEffect(() => { ensureConfigLoaded(); }, []);
|
|
819
|
+
|
|
820
|
+
useEffect(() => {
|
|
821
|
+
if (!cfg.autoPlay) return undefined;
|
|
822
|
+
if (!ttsSupported()) return undefined;
|
|
823
|
+
if (seenRef.current === null) seenRef.current = new Set();
|
|
824
|
+
let cancelled = false;
|
|
825
|
+
const tick = () => {
|
|
826
|
+
if (cancelled || configStore.routeGone) return;
|
|
827
|
+
fetch("dsh-dock/tts/status", {
|
|
828
|
+
method: "POST", headers: { "content-type": "application/json" }, body: "{}",
|
|
829
|
+
})
|
|
830
|
+
.then(async (res) => {
|
|
831
|
+
if (res.status === 404 || res.status === 405) {
|
|
832
|
+
configStore.routeGone = true;
|
|
833
|
+
configStore.note = "宿主侧没有语音朗读接口(重启 dsh web 后更好):自动朗读暂只覆盖你正在看的会话,配置也只存在本浏览器";
|
|
834
|
+
notifyConfig();
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
if (!res.ok) return;
|
|
838
|
+
const data = await res.json().catch(() => ({}));
|
|
839
|
+
if (cancelled || !data || !data.ok || !data.data) return;
|
|
840
|
+
const recent = Array.isArray(data.data.recent) ? data.data.recent : [];
|
|
841
|
+
const seen = seenRef.current;
|
|
842
|
+
// 首次拿到快照只登记、不朗读:页面刚打开时「最近完成」是历史,不该补读
|
|
843
|
+
if (!primedRef.current) {
|
|
844
|
+
for (const r of recent) seen.add(String(r.sessionId) + "|" + String(r.endTime));
|
|
845
|
+
primedRef.current = true;
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
// 新记录按时间正序读(一轮里可能同时结束多个任务,读最新的那条就够)
|
|
849
|
+
const fresh = [];
|
|
850
|
+
for (const r of recent) {
|
|
851
|
+
const k = String(r.sessionId) + "|" + String(r.endTime);
|
|
852
|
+
if (seen.has(k)) continue;
|
|
853
|
+
seen.add(k);
|
|
854
|
+
fresh.push(r);
|
|
855
|
+
}
|
|
856
|
+
if (fresh.length === 0) return;
|
|
857
|
+
if (seen.size > 500) seenRef.current = new Set([].concat(Array.from(seen).slice(-200)));
|
|
858
|
+
const r = fresh[fresh.length - 1];
|
|
859
|
+
const bad = BAD_END[String(r.endReason || "")] === 1;
|
|
860
|
+
if (bad && configStore.value.autoPlayOnFinish === false) return;
|
|
861
|
+
const text = String(r.lastText || "");
|
|
862
|
+
if (!text.trim()) return;
|
|
863
|
+
if (!claimAutoPlay("auto:" + String(r.sessionId) + "|" + String(r.endTime))) return;
|
|
864
|
+
const plain = capSpeechText(plainForSpeech(text, configStore.value), configStore.value);
|
|
865
|
+
speak({
|
|
866
|
+
key: "auto:" + String(r.sessionId) + "|" + String(r.endTime),
|
|
867
|
+
text: plain,
|
|
868
|
+
cfg: configStore.value,
|
|
869
|
+
label: (r.title || "任务") + (bad ? "(" + String(r.endReason) + ")" : "(已完成)"),
|
|
870
|
+
});
|
|
871
|
+
})
|
|
872
|
+
.catch(() => { /* 宿主不可达:下一轮再试 */ });
|
|
873
|
+
};
|
|
874
|
+
tick();
|
|
875
|
+
const timer = setInterval(tick, 1000);
|
|
876
|
+
return () => { cancelled = true; clearInterval(timer); };
|
|
877
|
+
}, [cfg.autoPlay]);
|
|
878
|
+
|
|
879
|
+
return null;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// ---------- 面板页 ----------
|
|
883
|
+
// 行布局:左标签列固定宽,中间控件列不换行,右侧说明列占满剩余。
|
|
884
|
+
// (旧版复用外壳 dock-sw + minmax 网格:中文开关被挤成一字一行、说明列错位——见截图返工。)
|
|
885
|
+
function TtsRow(props) {
|
|
886
|
+
return react.createElement("div", { className: "dktts-row" },
|
|
887
|
+
react.createElement("span", { className: "dktts-label" }, props.label),
|
|
888
|
+
react.createElement("span", { className: "dktts-ctl" }, props.children),
|
|
889
|
+
props.note ? react.createElement("span", { className: "dktts-note" }, props.note) : null);
|
|
890
|
+
}
|
|
891
|
+
// 专用开关:药丸形、文字不换行、状态点 + 「启用/停用」二字,宽度稳定。
|
|
892
|
+
function TtsSwitch(props) {
|
|
893
|
+
const on = props.value === true;
|
|
894
|
+
return react.createElement("button", {
|
|
895
|
+
type: "button",
|
|
896
|
+
className: "dktts-sw" + (on ? " on" : ""),
|
|
897
|
+
role: "switch",
|
|
898
|
+
"aria-checked": on,
|
|
899
|
+
"aria-label": (on ? "停用" : "启用") + props.label,
|
|
900
|
+
title: on ? "已启用(点击停用)" : "已停用(点击启用)",
|
|
901
|
+
onClick: () => props.onChange(!on),
|
|
902
|
+
},
|
|
903
|
+
react.createElement("span", { className: "dktts-sw-dot" }),
|
|
904
|
+
on ? "启用" : "停用");
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function TtsView() {
|
|
908
|
+
const cfg = useConfig();
|
|
909
|
+
const eng = useEngineState();
|
|
910
|
+
const [, force] = useState(0);
|
|
911
|
+
const [saved, setSaved] = useState("");
|
|
912
|
+
const voices = useRef(localVoices());
|
|
913
|
+
|
|
914
|
+
// 音色表在 Chrome 里是异步到位的:订阅 voiceschanged 后重渲染一次
|
|
915
|
+
useEffect(() => {
|
|
916
|
+
ensureConfigLoaded();
|
|
917
|
+
const s = synth();
|
|
918
|
+
if (!s || typeof s.addEventListener !== "function") return;
|
|
919
|
+
const onChange = () => { voices.current = localVoices(); force((n) => n + 1); };
|
|
920
|
+
try { s.addEventListener("voiceschanged", onChange); } catch { return; }
|
|
921
|
+
return () => { try { s.removeEventListener("voiceschanged", onChange); } catch { /* ignore */ } };
|
|
922
|
+
}, []);
|
|
923
|
+
// 首次渲染时音色表可能已经在了但没触发事件:补一次读取
|
|
924
|
+
useEffect(() => {
|
|
925
|
+
if (voices.current.length === 0) { voices.current = localVoices(); force((n) => n + 1); }
|
|
926
|
+
}, []);
|
|
927
|
+
|
|
928
|
+
const list = voices.current;
|
|
929
|
+
const supported = ttsSupported();
|
|
930
|
+
const sample = "好的,我现在把这条回复读给你听。语速和音色可以在这里调,点「试听」就能立刻听到效果。";
|
|
931
|
+
|
|
932
|
+
const patch = (p) => {
|
|
933
|
+
setConfig(p);
|
|
934
|
+
setSaved("已保存");
|
|
935
|
+
setTimeout(() => setSaved(""), 1500);
|
|
936
|
+
};
|
|
937
|
+
const preview = () => {
|
|
938
|
+
stopSpeech();
|
|
939
|
+
speak({
|
|
940
|
+
key: "preview:" + Date.now(),
|
|
941
|
+
text: sample,
|
|
942
|
+
cfg: configStore.value,
|
|
943
|
+
label: "试听",
|
|
944
|
+
});
|
|
945
|
+
};
|
|
946
|
+
// 面板里的播放控制:正在读时给「暂停/继续 + 重新朗读 + 停止」,与回合尾按钮同一引擎。
|
|
947
|
+
const active = eng.phase !== "idle";
|
|
948
|
+
const paused = eng.phase === "paused";
|
|
949
|
+
const playing = eng.phase === "playing";
|
|
950
|
+
|
|
951
|
+
// 音色按语言分组:中文在前,其余按语言名排序,长列表也能找
|
|
952
|
+
const grouped = [];
|
|
953
|
+
{
|
|
954
|
+
const zh = [], other = [];
|
|
955
|
+
for (const v of list) {
|
|
956
|
+
if (!v || !v.voiceURI) continue;
|
|
957
|
+
if (/^zh/i.test(String(v.lang || ""))) zh.push(v); else other.push(v);
|
|
958
|
+
}
|
|
959
|
+
if (zh.length) grouped.push(["中文音色", zh]);
|
|
960
|
+
if (other.length) grouped.push(["其他音色", other]);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
return react.createElement("div", { className: "dktts-root" },
|
|
964
|
+
react.createElement("div", { className: "dktts-intro" },
|
|
965
|
+
"眼睛不舒服时让 dsh 把回复读出来。用的是",
|
|
966
|
+
react.createElement("b", null, "浏览器自带的语音合成"),
|
|
967
|
+
"(Web Speech API),声音从你当前这台设备的扬声器出来,不消耗 token、不经过服务器。",
|
|
968
|
+
"每条回复下方会出现一个", react.createElement("b", null, " 🔊 喇叭"),
|
|
969
|
+
",点一下就读这条;也可以打开「自动朗读」,回合结束后直接读最后一条回复。"),
|
|
970
|
+
|
|
971
|
+
!supported
|
|
972
|
+
? react.createElement("div", { className: "dktts-warn" },
|
|
973
|
+
"这个浏览器不提供语音合成能力(speechSynthesis 不可用)。换 Chrome / Edge / Safari 即可;"
|
|
974
|
+
+ "Linux 上的 Chromium 需要装语音包(如 espeak-ng),或在系统设置里添加语音。")
|
|
975
|
+
: null,
|
|
976
|
+
supported && list.length === 0
|
|
977
|
+
? react.createElement("div", { className: "dktts-warn" },
|
|
978
|
+
"本机暂时读不到任何音色。通常是系统还没装/没启用语音:Windows 到「设置 → 时间和语言 → 语音」添加中文语音,"
|
|
979
|
+
+ "macOS 到「系统设置 → 无障碍 → 朗读内容」添加音色,然后刷新页面。")
|
|
980
|
+
: null,
|
|
981
|
+
|
|
982
|
+
react.createElement("div", { className: "dktts-card" },
|
|
983
|
+
react.createElement("div", { className: "dktts-card-h" }, "朗读时机"),
|
|
984
|
+
react.createElement(TtsRow, {
|
|
985
|
+
label: "自动朗读",
|
|
986
|
+
note: "回合结束后自动读最后一条回复;你正在看的这条会话才读,后台会话不读。",
|
|
987
|
+
}, react.createElement(TtsSwitch, {
|
|
988
|
+
label: "自动朗读", value: cfg.autoPlay, onChange: (v) => patch({ autoPlay: v }),
|
|
989
|
+
})),
|
|
990
|
+
react.createElement(TtsRow, {
|
|
991
|
+
label: "出错 / 中止也读",
|
|
992
|
+
note: "关闭后只在正常完成时读。",
|
|
993
|
+
}, react.createElement(TtsSwitch, {
|
|
994
|
+
label: "出错也读", value: cfg.autoPlayOnFinish, onChange: (v) => patch({ autoPlayOnFinish: v }),
|
|
995
|
+
})),
|
|
996
|
+
react.createElement(TtsRow, {
|
|
997
|
+
label: "新内容打断当前朗读",
|
|
998
|
+
note: "关闭则排队:新的那条等当前读完再读(只保留最新一条)。",
|
|
999
|
+
}, react.createElement(TtsSwitch, {
|
|
1000
|
+
label: "打断", value: cfg.interruptOnNew, onChange: (v) => patch({ interruptOnNew: v }),
|
|
1001
|
+
}))),
|
|
1002
|
+
|
|
1003
|
+
react.createElement("div", { className: "dktts-card" },
|
|
1004
|
+
react.createElement("div", { className: "dktts-card-h" }, "声音"),
|
|
1005
|
+
react.createElement(TtsRow, { label: "音色", note: "音色是本机浏览器给的,换设备可能要重选。" },
|
|
1006
|
+
react.createElement("select", {
|
|
1007
|
+
className: "dktts-select",
|
|
1008
|
+
value: cfg.voiceURI,
|
|
1009
|
+
onChange: (e) => patch({ voiceURI: e.target.value }),
|
|
1010
|
+
},
|
|
1011
|
+
react.createElement("option", { value: "" }, "自动(按语言挑)"),
|
|
1012
|
+
grouped.map(([gname, vs]) => react.createElement("optgroup", { key: gname, label: gname },
|
|
1013
|
+
vs.map((v) => react.createElement("option", {
|
|
1014
|
+
key: v.voiceURI, value: v.voiceURI,
|
|
1015
|
+
}, (v.name || v.voiceURI) + "(" + (v.lang || "?") + ")")))))),
|
|
1016
|
+
react.createElement(TtsRow, { label: "强制语言", note: "留空则中文文本优先挑中文音色。" },
|
|
1017
|
+
react.createElement("select", {
|
|
1018
|
+
className: "dktts-select",
|
|
1019
|
+
value: cfg.lang,
|
|
1020
|
+
onChange: (e) => patch({ lang: e.target.value }),
|
|
1021
|
+
},
|
|
1022
|
+
react.createElement("option", { value: "" }, "自动"),
|
|
1023
|
+
["zh-CN", "zh-TW", "en-US", "en-GB", "ja-JP", "ko-KR"].map((l) => react.createElement("option", {
|
|
1024
|
+
key: l, value: l,
|
|
1025
|
+
}, l)))),
|
|
1026
|
+
react.createElement(TtsSlider, {
|
|
1027
|
+
label: "语速", suffix: cfg.rate.toFixed(2) + "×", min: "0.5", max: "2", step: "0.05",
|
|
1028
|
+
value: cfg.rate, aria: "语速", onChange: (v) => patch({ rate: v }),
|
|
1029
|
+
}),
|
|
1030
|
+
react.createElement(TtsSlider, {
|
|
1031
|
+
label: "音调", suffix: cfg.pitch.toFixed(2), min: "0", max: "2", step: "0.1",
|
|
1032
|
+
value: cfg.pitch, aria: "音调", onChange: (v) => patch({ pitch: v }),
|
|
1033
|
+
}),
|
|
1034
|
+
react.createElement(TtsSlider, {
|
|
1035
|
+
label: "音量", suffix: Math.round(cfg.volume * 100) + "%", min: "0", max: "1", step: "0.05",
|
|
1036
|
+
value: cfg.volume, aria: "音量", onChange: (v) => patch({ volume: v }),
|
|
1037
|
+
})),
|
|
1038
|
+
|
|
1039
|
+
react.createElement("div", { className: "dktts-card" },
|
|
1040
|
+
react.createElement("div", { className: "dktts-card-h" }, "念什么(回复正文的清洗)"),
|
|
1041
|
+
react.createElement(TtsRow, { label: "跳过代码块", note: "关闭则连代码一起念(只去符号)。" },
|
|
1042
|
+
react.createElement(TtsSwitch, { label: "跳过代码块", value: cfg.skipCode, onChange: (v) => patch({ skipCode: v }) })),
|
|
1043
|
+
react.createElement(TtsRow, { label: "跳过表格", note: "表格逐格念出来没有意义,默认跳过。" },
|
|
1044
|
+
react.createElement(TtsSwitch, { label: "跳过表格", value: cfg.skipTables, onChange: (v) => patch({ skipTables: v }) })),
|
|
1045
|
+
react.createElement(TtsRow, { label: "只读前 N 句", note: "0 = 不限。想只听结论时设 3~5 句。" },
|
|
1046
|
+
react.createElement("input", {
|
|
1047
|
+
type: "number", className: "dktts-num", min: "0", max: "200", step: "1",
|
|
1048
|
+
"aria-label": "只读前几句", value: String(cfg.maxSentences),
|
|
1049
|
+
onChange: (e) => patch({ maxSentences: Math.max(0, Math.round(Number(e.target.value) || 0)) }),
|
|
1050
|
+
})),
|
|
1051
|
+
react.createElement(TtsRow, { label: "字数上限", note: "0 = 不限;超出部分念「以下略」。" },
|
|
1052
|
+
react.createElement("input", {
|
|
1053
|
+
type: "number", className: "dktts-num", min: "0", max: "200000", step: "100",
|
|
1054
|
+
"aria-label": "字数上限", value: String(cfg.maxChars),
|
|
1055
|
+
onChange: (e) => patch({ maxChars: Math.max(0, Math.round(Number(e.target.value) || 0)) }),
|
|
1056
|
+
}))),
|
|
1057
|
+
|
|
1058
|
+
// —— 播放控制:试听 + 正在读时的 暂停/继续 · 重新朗读 · 停止 ——
|
|
1059
|
+
react.createElement("div", { className: "dktts-card" },
|
|
1060
|
+
react.createElement("div", { className: "dktts-card-h" }, "播放控制"),
|
|
1061
|
+
react.createElement("div", { className: "dktts-transport" },
|
|
1062
|
+
react.createElement("button", {
|
|
1063
|
+
type: "button", className: "dktts-primary", onClick: preview, disabled: !supported,
|
|
1064
|
+
}, "试听"),
|
|
1065
|
+
active ? react.createElement("button", {
|
|
1066
|
+
type: "button", className: "dktts-ghost",
|
|
1067
|
+
onClick: () => { if (playing) pauseSpeech(); else resumeSpeech(); },
|
|
1068
|
+
}, playing ? "暂停" : "继续") : null,
|
|
1069
|
+
active ? react.createElement("button", {
|
|
1070
|
+
type: "button", className: "dktts-ghost", onClick: () => { restartSpeech(); },
|
|
1071
|
+
}, "重新朗读") : null,
|
|
1072
|
+
active ? react.createElement("button", {
|
|
1073
|
+
type: "button", className: "dktts-ghost", onClick: () => { stopSpeech(); },
|
|
1074
|
+
}, "停止") : null,
|
|
1075
|
+
react.createElement("span", { className: "dktts-playing" },
|
|
1076
|
+
playing ? "正在朗读:" + (eng.label || "内容")
|
|
1077
|
+
: paused ? "已暂停:" + (eng.label || "内容") + "(点「继续」不重头)"
|
|
1078
|
+
: saved ? "✓ " + saved : " "))),
|
|
1079
|
+
|
|
1080
|
+
react.createElement("div", { className: "dktts-note-line" },
|
|
1081
|
+
"提示:浏览器要求「先有用户手势才允许出声」。刚打开页面就自动朗读可能被浏览器拦掉——",
|
|
1082
|
+
"点一下页面上任意位置,或直接用回复下方的喇叭按钮。"),
|
|
1083
|
+
configStore.note ? react.createElement("div", { className: "dktts-warn" }, configStore.note) : null,
|
|
1084
|
+
configStore.routeGone ? react.createElement("div", { className: "dktts-links" },
|
|
1085
|
+
react.createElement("button", {
|
|
1086
|
+
type: "button", className: "dktts-ghost",
|
|
1087
|
+
onClick: () => { configStore.routeGone = false; hostConfigRequested = false; ensureConfigLoaded(); force((n) => n + 1); },
|
|
1088
|
+
}, "重试连接宿主")) : null);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// 滑块行:标签 + 滑块 + 数值后缀,三列对齐,避免旧版数值挤在标签里。
|
|
1092
|
+
function TtsSlider(props) {
|
|
1093
|
+
return react.createElement("div", { className: "dktts-row" },
|
|
1094
|
+
react.createElement("span", { className: "dktts-label" }, props.label),
|
|
1095
|
+
react.createElement("span", { className: "dktts-slider" },
|
|
1096
|
+
react.createElement("input", {
|
|
1097
|
+
type: "range", min: props.min, max: props.max, step: props.step,
|
|
1098
|
+
value: String(props.value), "aria-label": props.aria,
|
|
1099
|
+
onChange: (e) => props.onChange(Number(e.target.value)),
|
|
1100
|
+
}),
|
|
1101
|
+
react.createElement("span", { className: "dktts-slider-val" }, props.suffix)),
|
|
1102
|
+
null);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// ---------- 首页总揽 ----------
|
|
1106
|
+
function TtsStat() {
|
|
1107
|
+
const cfg = useConfig();
|
|
1108
|
+
useEffect(() => { ensureConfigLoaded(); }, []);
|
|
1109
|
+
return react.createElement("span", null,
|
|
1110
|
+
"自动朗读:", cfg.autoPlay ? "开" : "关",
|
|
1111
|
+
" · 语速 ", cfg.rate.toFixed(2), "×",
|
|
1112
|
+
cfg.autoPlay ? "" : "(回复下方点喇叭可手动读)");
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
const CSS = [
|
|
1116
|
+
".dktts-root{display:flex;flex-direction:column;gap:12px;color:var(--dsw-alias-label-primary);font-size:13px;}",
|
|
1117
|
+
".dktts-intro{color:var(--dsw-alias-label-secondary);line-height:1.7;}",
|
|
1118
|
+
".dktts-intro b{color:var(--dsw-alias-label-primary);}",
|
|
1119
|
+
".dktts-card{border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);border-radius:10px;padding:10px 12px;display:flex;flex-direction:column;gap:4px;}",
|
|
1120
|
+
".dktts-card-h{font-weight:600;color:var(--dsw-alias-label-primary);padding-bottom:6px;border-bottom:1px solid var(--dsw-alias-border-l1);margin-bottom:4px;}",
|
|
1121
|
+
// 行:标签列固定 116px 不换行 · 控件列不伸缩 · 说明列吃掉剩余(窄屏整块纵排)
|
|
1122
|
+
".dktts-row{display:grid;grid-template-columns:116px 220px minmax(0,1fr);align-items:center;gap:12px;padding:4px 0;min-width:0;}",
|
|
1123
|
+
".dktts-label{color:var(--dsw-alias-label-primary);white-space:nowrap;}",
|
|
1124
|
+
".dktts-ctl{display:flex;align-items:center;gap:8px;min-width:0;}",
|
|
1125
|
+
".dktts-note{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.5;min-width:0;}",
|
|
1126
|
+
".dktts-select{width:100%;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:8px;padding:5px 8px;font-size:12px;min-width:0;}",
|
|
1127
|
+
".dktts-num{width:110px;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:8px;padding:5px 8px;font-size:12px;}",
|
|
1128
|
+
// 开关:药丸形 + 状态点,文字绝不换行(旧版复用外壳 dock-sw 被挤成一字一行)
|
|
1129
|
+
".dktts-sw{display:inline-flex;align-items:center;gap:6px;flex:none;white-space:nowrap;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:4px 12px;cursor:pointer;font-size:12px;line-height:18px;}",
|
|
1130
|
+
".dktts-sw-dot{flex:none;width:7px;height:7px;border-radius:50%;background:var(--dsw-alias-label-tertiary);}",
|
|
1131
|
+
".dktts-sw.on{color:var(--dk-ok);border-color:var(--dk-ok);}",
|
|
1132
|
+
".dktts-sw.on .dktts-sw-dot{background:var(--dk-ok);}",
|
|
1133
|
+
// 滑块行:滑块占满控件列,数值右对齐固定宽
|
|
1134
|
+
".dktts-slider{display:flex;align-items:center;gap:8px;min-width:0;}",
|
|
1135
|
+
".dktts-slider input[type='range']{flex:1;min-width:0;accent-color:var(--dk-accent);}",
|
|
1136
|
+
".dktts-slider-val{flex:none;width:46px;text-align:right;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary);font-size:12px;}",
|
|
1137
|
+
".dktts-transport{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}",
|
|
1138
|
+
".dktts-primary,.dktts-ghost{flex:none;white-space:nowrap;border-radius:999px;padding:5px 14px;font-size:12px;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);}",
|
|
1139
|
+
".dktts-primary{border-color:var(--dk-accent);color:var(--dk-accent);}",
|
|
1140
|
+
".dktts-primary:disabled{opacity:.45;cursor:default;}",
|
|
1141
|
+
".dktts-ghost:hover,.dktts-primary:not(:disabled):hover{background:var(--dk-hover-tint);}",
|
|
1142
|
+
".dktts-playing{color:var(--dk-accent);font-size:12px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
|
|
1143
|
+
".dktts-warn{border:1px solid var(--dk-warn);color:var(--dk-warn);border-radius:8px;padding:8px 10px;font-size:12px;line-height:1.6;}",
|
|
1144
|
+
".dktts-note-line{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.6;}",
|
|
1145
|
+
".dktts-links{display:flex;gap:8px;}",
|
|
1146
|
+
// 窄屏(功能坞面板被压窄时):三列改纵排,说明跟在控件下
|
|
1147
|
+
"@media (max-width:720px){.dktts-row{grid-template-columns:1fr;gap:2px;padding:6px 0;}.dktts-ctl,.dktts-slider{max-width:320px;}}",
|
|
1148
|
+
// —— 回合下方的播放条:尺寸/配色对齐宿主消息动作行(ui-chat MessageIconActions .action)——
|
|
1149
|
+
".dktts-tail{display:flex;align-items:center;gap:2px;margin-top:4px;flex-wrap:wrap;}",
|
|
1150
|
+
".dktts-btn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:6px;border:none;border-radius:var(--dsw-radius-sm,6px);background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;}",
|
|
1151
|
+
".dktts-btn svg{width:17px;height:17px;}",
|
|
1152
|
+
".dktts-btn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary);}",
|
|
1153
|
+
".dktts-btn.on{color:var(--dk-accent);}",
|
|
1154
|
+
".dktts-btn.off{opacity:.4;cursor:default;}",
|
|
1155
|
+
".dktts-btn.off:hover{background:transparent;color:var(--dsw-alias-label-tertiary);}",
|
|
1156
|
+
".dktts-state{color:var(--dsw-alias-label-secondary);font-size:12px;padding-left:6px;}",
|
|
1157
|
+
".dktts-state.on{color:var(--dk-accent);}",
|
|
1158
|
+
// —— ⚙ 就地快设浮层(portal 到 body;z 高于功能坞面板 200,低于常见模态 1000)——
|
|
1159
|
+
".dktts-pop{position:fixed;z-index:300;width:248px;box-sizing:border-box;display:flex;flex-direction:column;gap:6px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);box-shadow:0 10px 32px rgb(0 0 0 / .28);padding:10px 12px;font-size:12px;animation:dktts-pop-in .12s ease-out;}",
|
|
1160
|
+
"@keyframes dktts-pop-in{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}",
|
|
1161
|
+
"@media (prefers-reduced-motion:reduce){.dktts-pop{animation:none}}",
|
|
1162
|
+
".dktts-pop-h{display:flex;align-items:center;gap:8px;}",
|
|
1163
|
+
".dktts-pop-h b{flex:1;font-weight:600;}",
|
|
1164
|
+
".dktts-pop-x{border:none;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;font-size:13px;line-height:1;padding:4px;border-radius:6px;}",
|
|
1165
|
+
".dktts-pop-x:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);}",
|
|
1166
|
+
".dktts-pop-row{display:grid;grid-template-columns:32px 1fr 46px;align-items:center;gap:8px;}",
|
|
1167
|
+
".dktts-pop-row input[type='range']{width:100%;min-width:0;accent-color:var(--dk-accent);}",
|
|
1168
|
+
".dktts-pop-val{text-align:right;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary);}",
|
|
1169
|
+
".dktts-pop-foot{display:flex;align-items:center;justify-content:space-between;gap:8px;border-top:1px solid var(--dsw-alias-border-l1);padding-top:6px;margin-top:2px;}",
|
|
1170
|
+
".dktts-pop-tip{color:var(--dsw-alias-label-secondary);}",
|
|
1171
|
+
".dktts-pop-more{border:none;background:transparent;color:var(--dk-accent);cursor:pointer;font-size:12px;padding:2px 0;}",
|
|
1172
|
+
".dktts-pop-more:hover{text-decoration:underline;}",
|
|
1173
|
+
].join("\n");
|
|
1174
|
+
|
|
1175
|
+
export const feature = {
|
|
1176
|
+
id: "tts",
|
|
1177
|
+
name: "语音朗读",
|
|
1178
|
+
order: 160,
|
|
1179
|
+
accent: "#0ea5e9",
|
|
1180
|
+
description: "把会话回复读出来(浏览器语音合成):回复下方点喇叭播放,或设置回合结束自动朗读",
|
|
1181
|
+
defaultEnabled: false,
|
|
1182
|
+
css: CSS,
|
|
1183
|
+
View: TtsView,
|
|
1184
|
+
HomeStat: TtsStat,
|
|
1185
|
+
// 回合下方的喇叭按钮(宿主 conversation.chat.turnTail 座位,见 src/client.jsx 的装配)
|
|
1186
|
+
TurnTail: TtsTurnTail,
|
|
1187
|
+
// 自动朗读的触发器(无 UI,只在功能启用时常驻挂载)
|
|
1188
|
+
Overlay: TtsOverlay,
|
|
1189
|
+
};
|