@oadank/dsh-input-tools 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -0
- package/cordis.patch.yml +5 -0
- package/lib/client.js +1072 -0
- package/lib/edge-tts.js +117 -0
- package/lib/index.js +1394 -0
- package/package.json +33 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,1072 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-input-tools — 输入框工具条插件 v4.1(2026-08-20 改名自 dsh-client-composer)
|
|
3
|
+
*
|
|
4
|
+
* 功能三合一:图片(官方 draft 链路随文本发)+ 语音(录音/取消)+ 余额。
|
|
5
|
+
*
|
|
6
|
+
* v4 核心设计(对照用户要求逐条):
|
|
7
|
+
* 1) 图片"必须配文本发送":走官方 draft 链路——插件注册 conversation.input.attachments 槽
|
|
8
|
+
* (priority:-1 覆盖官方附件条渲染),该槽 props 自带 onAddImages(=官方 intakeImages):
|
|
9
|
+
* 图片按钮选文件 → onAddImages → 图片进官方 draft → 官方发送按钮发送时自动带图 ✓
|
|
10
|
+
* 2) 预览 = 插件自己的悬浮缩略图墙(absolute 定位在输入框上方,无背景、无边框),
|
|
11
|
+
* 点击缩略图放大 modal,右上角 × 移除(调 onRemoveImage)——不是官方附件条样式;
|
|
12
|
+
* 3) 没有"发送图片"按钮:发送动作完全由官方发送按钮承担,图片必然配文本发送;
|
|
13
|
+
* 4) 语音:插件自实现(点击录音/秒数/×取消/取消不留垃圾);
|
|
14
|
+
* 5) 余额:conversation.input.right(独立 balance 插件已停用并删除);
|
|
15
|
+
* 6) 按钮位置:left 槽(源码 .tools 区,命令按钮之前:[🖼][🎙][+]);
|
|
16
|
+
* 7) 按钮间距:16px(与源码 .tools gap 一致)。
|
|
17
|
+
*
|
|
18
|
+
* 源码零改动:附件槽 props 由官方 ConversationRoot/InputBar 自动传入,无需桥接代码。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
window.__ModuleLoader__.load({
|
|
22
|
+
id: "@oadank/dsh-input-tools",
|
|
23
|
+
factory: (require) => {
|
|
24
|
+
var module = { exports: {} };
|
|
25
|
+
var exports = module.exports;
|
|
26
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
27
|
+
let react = require("react");
|
|
28
|
+
const { useState, useEffect, useRef, useCallback } = react;
|
|
29
|
+
const h = react.createElement;
|
|
30
|
+
|
|
31
|
+
const POLL_MS = 30_000;
|
|
32
|
+
|
|
33
|
+
// ── 附件槽桥:官方 onAddImages 由 attachments 槽组件挂载时存入,left 按钮调用 ──
|
|
34
|
+
let sharedOnAddImages = null;
|
|
35
|
+
|
|
36
|
+
// ── 源码 SVG 图标 ────────────────────────────────────────────
|
|
37
|
+
const svgProps = { viewBox: "0 0 16 16", width: "14", height: "14", "aria-hidden": true };
|
|
38
|
+
const imageIcon = h("svg", svgProps,
|
|
39
|
+
h("rect", { x: "2.5", y: "3.5", width: "11", height: "9", rx: "2", fill: "none", stroke: "currentColor", strokeWidth: "1.4" }),
|
|
40
|
+
h("circle", { cx: "6", cy: "7.5", r: "1.5", fill: "currentColor" }),
|
|
41
|
+
h("path", { d: "M3.5 11.5 L6.5 8.5 L9 10.5 L11.5 8 L13.5 10.5", stroke: "currentColor", strokeWidth: "1.2", fill: "none" }),
|
|
42
|
+
);
|
|
43
|
+
const micIcon = h("svg", svgProps,
|
|
44
|
+
h("path", { d: "M8 1.5C6.895 1.5 6 2.395 6 3.5V8C6 9.105 6.895 10 8 10C9.105 10 10 9.105 10 8V3.5C10 2.395 9.105 1.5 8 1.5Z", fill: "currentColor" }),
|
|
45
|
+
h("path", { d: "M3.5 7.5V8C3.5 10.485 5.515 12.5 8 12.5C10.485 12.5 12.5 10.485 12.5 8V7.5H14V8C14 11.087 11.683 13.615 8.75 13.936V15.5H7.25V13.936C4.317 13.615 2 11.087 2 8V7.5H3.5Z", fill: "currentColor" }),
|
|
46
|
+
);
|
|
47
|
+
const cancelIcon = h("svg", svgProps,
|
|
48
|
+
h("path", { d: "M4 4L12 12M12 4L4 12", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round" }),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
// ── 工具行按钮样式:圆形底,间距 16px(与源码 .tools gap 一致) ─────
|
|
52
|
+
const circleBtn = {
|
|
53
|
+
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
|
54
|
+
width: "32px", height: "32px", padding: "0", border: "none",
|
|
55
|
+
borderRadius: "999px",
|
|
56
|
+
background: "rgba(128,128,128,.16)",
|
|
57
|
+
color: "inherit", cursor: "pointer",
|
|
58
|
+
transition: "background-color .15s",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// ── 左工具行:图片(官方 draft 链路)+ 语音 ─────────────────────
|
|
62
|
+
function ToolbarLeft({ connection, sessionId }) {
|
|
63
|
+
const [recording, setRecording] = useState(false);
|
|
64
|
+
const [seconds, setSeconds] = useState(0);
|
|
65
|
+
const [voiceError, setVoiceError] = useState(null); // 语音发送失败提示
|
|
66
|
+
const voiceErrorTimerRef = useRef(null);
|
|
67
|
+
const recorderRef = useRef(null);
|
|
68
|
+
const chunksRef = useRef([]);
|
|
69
|
+
const timerRef = useRef(null);
|
|
70
|
+
const fileRef = useRef(null);
|
|
71
|
+
const voiceSupported = typeof navigator !== "undefined" && typeof MediaRecorder !== "undefined";
|
|
72
|
+
|
|
73
|
+
const sendVoiceBlob = useCallback(async (blob) => {
|
|
74
|
+
if (connection === undefined) return;
|
|
75
|
+
const mediaType = blob.type.split(";")[0] || "audio/webm";
|
|
76
|
+
const reader = new FileReader();
|
|
77
|
+
const data = await new Promise((resolve, reject) => {
|
|
78
|
+
reader.onload = () => resolve(String(reader.result).split(",")[1] ?? "");
|
|
79
|
+
reader.onerror = reject;
|
|
80
|
+
reader.readAsDataURL(blob);
|
|
81
|
+
});
|
|
82
|
+
try {
|
|
83
|
+
const response = await connection.api.sessions.prompt({
|
|
84
|
+
sessionId, mode: "queue",
|
|
85
|
+
content: [{ type: "voice", mediaType, data }],
|
|
86
|
+
});
|
|
87
|
+
// [本地改造 2026-08-21] 该 RPC 失败不 throw,而是返回 result.ok=false——
|
|
88
|
+
// 必须检查返回值,否则 ASR 识别失败(VOICE_ASR_FAILED)会静默无反馈。
|
|
89
|
+
const result = response?.result;
|
|
90
|
+
if (!result || !result.ok) {
|
|
91
|
+
const err = result?.error;
|
|
92
|
+
const msg = (err && typeof err.message === "string" && err.message !== "")
|
|
93
|
+
? err.message
|
|
94
|
+
: "语音发送失败,请重试";
|
|
95
|
+
setVoiceError(msg);
|
|
96
|
+
if (voiceErrorTimerRef.current !== null) window.clearTimeout(voiceErrorTimerRef.current);
|
|
97
|
+
voiceErrorTimerRef.current = window.setTimeout(() => setVoiceError(null), 6000);
|
|
98
|
+
}
|
|
99
|
+
} catch (e) {
|
|
100
|
+
// 传输层异常兜底
|
|
101
|
+
const msg = (e && typeof e.message === "string" && e.message !== "")
|
|
102
|
+
? e.message
|
|
103
|
+
: "语音发送失败,请重试";
|
|
104
|
+
setVoiceError(msg);
|
|
105
|
+
if (voiceErrorTimerRef.current !== null) window.clearTimeout(voiceErrorTimerRef.current);
|
|
106
|
+
voiceErrorTimerRef.current = window.setTimeout(() => setVoiceError(null), 6000);
|
|
107
|
+
}
|
|
108
|
+
}, [connection, sessionId]);
|
|
109
|
+
|
|
110
|
+
const stopRecording = useCallback((send) => {
|
|
111
|
+
clearInterval(timerRef.current);
|
|
112
|
+
timerRef.current = null;
|
|
113
|
+
const recorder = recorderRef.current;
|
|
114
|
+
recorderRef.current = null;
|
|
115
|
+
if (recorder !== null && recorder.state !== "inactive") {
|
|
116
|
+
if (send) recorder.stop();
|
|
117
|
+
else { recorder.onstop = null; try { recorder.stop(); } catch { /* ignore */ } }
|
|
118
|
+
}
|
|
119
|
+
setRecording(false);
|
|
120
|
+
setSeconds(0);
|
|
121
|
+
}, []);
|
|
122
|
+
|
|
123
|
+
const startRecording = useCallback(async () => {
|
|
124
|
+
if (connection === undefined || sessionId === undefined) return;
|
|
125
|
+
try {
|
|
126
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
127
|
+
const recorder = new MediaRecorder(stream);
|
|
128
|
+
chunksRef.current = [];
|
|
129
|
+
recorder.ondataavailable = (event) => { if (event.data.size > 0) chunksRef.current.push(event.data); };
|
|
130
|
+
recorder.onstop = () => {
|
|
131
|
+
const blob = new Blob(chunksRef.current, { type: recorder.mimeType || "audio/webm" });
|
|
132
|
+
chunksRef.current = [];
|
|
133
|
+
if (blob.size > 0) void sendVoiceBlob(blob);
|
|
134
|
+
stream.getTracks().forEach((t) => t.stop());
|
|
135
|
+
};
|
|
136
|
+
recorder.start();
|
|
137
|
+
recorderRef.current = recorder;
|
|
138
|
+
setRecording(true);
|
|
139
|
+
setSeconds(0);
|
|
140
|
+
timerRef.current = setInterval(() => setSeconds((s) => s + 1), 1000);
|
|
141
|
+
} catch { /* 权限拒绝 */ }
|
|
142
|
+
}, [connection, sessionId, sendVoiceBlob]);
|
|
143
|
+
|
|
144
|
+
// 图片选中 → 官方 onAddImages(intakeImages)→ 官方 draft → 随文本发送
|
|
145
|
+
const onPickImage = useCallback((event) => {
|
|
146
|
+
const files = Array.from(event.target.files ?? []);
|
|
147
|
+
event.target.value = "";
|
|
148
|
+
if (files.length === 0 || typeof sharedOnAddImages !== "function") return;
|
|
149
|
+
sharedOnAddImages(files);
|
|
150
|
+
}, []);
|
|
151
|
+
|
|
152
|
+
return h("div", { style: { position: "relative", display: "inline-flex", alignItems: "center", gap: "16px" } },
|
|
153
|
+
h("button", {
|
|
154
|
+
type: "button", "aria-label": "添加图片", title: "添加图片",
|
|
155
|
+
style: circleBtn, onMouseDown: (e) => e.preventDefault(),
|
|
156
|
+
onClick: () => fileRef.current?.click(),
|
|
157
|
+
}, imageIcon),
|
|
158
|
+
h("input", {
|
|
159
|
+
ref: fileRef, type: "file",
|
|
160
|
+
accept: "image/png,image/jpeg,image/webp,image/gif",
|
|
161
|
+
multiple: false, hidden: true, onChange: onPickImage,
|
|
162
|
+
}),
|
|
163
|
+
voiceSupported && h("button", {
|
|
164
|
+
type: "button",
|
|
165
|
+
"aria-label": recording ? "停止并发送" : "录音",
|
|
166
|
+
title: recording ? "停止并发送" : "录音",
|
|
167
|
+
style: { ...circleBtn, ...(recording ? { background: "#e5484d", color: "#fff" } : {}) },
|
|
168
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
169
|
+
onClick: () => { if (recording) stopRecording(true); else void startRecording(); },
|
|
170
|
+
}, recording
|
|
171
|
+
? h("span", {
|
|
172
|
+
style: { display: "inline-flex", alignItems: "center", gap: "3px", fontSize: "11px", fontWeight: 600 },
|
|
173
|
+
}, h("span", {
|
|
174
|
+
style: { width: "6px", height: "6px", borderRadius: "50%", background: "#fff", display: "inline-block" },
|
|
175
|
+
}), `${seconds}s`)
|
|
176
|
+
: micIcon),
|
|
177
|
+
recording && h("button", {
|
|
178
|
+
type: "button", "aria-label": "取消录音", title: "取消",
|
|
179
|
+
style: circleBtn, onMouseDown: (e) => e.preventDefault(),
|
|
180
|
+
onClick: () => stopRecording(false),
|
|
181
|
+
}, cancelIcon),
|
|
182
|
+
// [本地改造 2026-08-21] 语音发送失败提示(ASR 未配置/识别失败):按钮上方气泡
|
|
183
|
+
voiceError !== null && h("div", {
|
|
184
|
+
style: {
|
|
185
|
+
position: "absolute", bottom: "calc(100% + 8px)", left: "0", zIndex: 30,
|
|
186
|
+
maxWidth: "380px", background: "rgba(229,72,77,.12)", color: "#e5484d",
|
|
187
|
+
border: "1px solid rgba(229,72,77,.35)", borderRadius: "8px",
|
|
188
|
+
padding: "6px 10px", fontSize: "12px", lineHeight: 1.45, whiteSpace: "normal",
|
|
189
|
+
pointerEvents: "none", boxShadow: "0 4px 14px rgba(0,0,0,.25)",
|
|
190
|
+
},
|
|
191
|
+
}, voiceError),
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── 附件槽(覆盖官方):悬浮缩略图墙 + 放大 modal,无背景无边框 ─────
|
|
196
|
+
function ComposerAttachmentsOverlay({ attachments, onAddImages, onRemoveImage }) {
|
|
197
|
+
const [zoom, setZoom] = useState(null); // { id, url, name } | null
|
|
198
|
+
const items = Array.isArray(attachments) ? attachments : [];
|
|
199
|
+
const hasItems = items.length > 0;
|
|
200
|
+
|
|
201
|
+
// 官方 onAddImages 存入模块级,供 left 按钮使用
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
if (typeof onAddImages === "function") sharedOnAddImages = onAddImages;
|
|
204
|
+
}, [onAddImages]);
|
|
205
|
+
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
if (zoom !== null && !items.some((a) => a.id === zoom.id)) setZoom(null);
|
|
208
|
+
}, [items, zoom]);
|
|
209
|
+
|
|
210
|
+
if (!hasItems) return null;
|
|
211
|
+
return h("div", {
|
|
212
|
+
style: {
|
|
213
|
+
position: "absolute", bottom: "calc(100% + 8px)", left: "10px", zIndex: 20,
|
|
214
|
+
display: "flex", flexWrap: "wrap", gap: "6px",
|
|
215
|
+
padding: "0", margin: "0", background: "transparent", border: "none",
|
|
216
|
+
pointerEvents: "none",
|
|
217
|
+
},
|
|
218
|
+
}, items.map((a) => h("div", {
|
|
219
|
+
key: a.id,
|
|
220
|
+
style: {
|
|
221
|
+
position: "relative", width: "60px", height: "60px", borderRadius: "6px",
|
|
222
|
+
overflow: "hidden", cursor: "zoom-in", background: "rgba(128,128,128,.1)",
|
|
223
|
+
pointerEvents: "auto",
|
|
224
|
+
},
|
|
225
|
+
onClick: () => setZoom({ id: a.id, url: a.previewUrl, name: a.file?.name ?? "image" }),
|
|
226
|
+
},
|
|
227
|
+
h("img", {
|
|
228
|
+
src: a.previewUrl, alt: a.file?.name ?? "image",
|
|
229
|
+
style: { width: "100%", height: "100%", objectFit: "cover", display: "block" },
|
|
230
|
+
}),
|
|
231
|
+
h("button", {
|
|
232
|
+
type: "button", "aria-label": "移除", title: "移除",
|
|
233
|
+
style: {
|
|
234
|
+
position: "absolute", top: "2px", right: "2px",
|
|
235
|
+
width: "18px", height: "18px", padding: "0", border: "none", borderRadius: "50%",
|
|
236
|
+
background: "rgba(0,0,0,.6)", color: "#fff", cursor: "pointer",
|
|
237
|
+
display: "flex", alignItems: "center", justifyContent: "center", fontSize: "12px", lineHeight: "1",
|
|
238
|
+
},
|
|
239
|
+
onClick: (e) => { e.stopPropagation(); if (typeof onRemoveImage === "function") onRemoveImage(a.id); },
|
|
240
|
+
}, "×"),
|
|
241
|
+
)),
|
|
242
|
+
// 放大 modal
|
|
243
|
+
zoom !== null ? h("div", {
|
|
244
|
+
role: "dialog", "aria-label": "图片预览",
|
|
245
|
+
style: {
|
|
246
|
+
position: "fixed", inset: "0", zIndex: 9999,
|
|
247
|
+
display: "flex", alignItems: "center", justifyContent: "center",
|
|
248
|
+
background: "rgba(0,0,0,.78)", cursor: "zoom-out",
|
|
249
|
+
// modal 是缩略图墙 div 的子节点,外层 pointerEvents:none 会继承;显式 auto 让按钮能点
|
|
250
|
+
pointerEvents: "auto",
|
|
251
|
+
},
|
|
252
|
+
onClick: () => setZoom(null),
|
|
253
|
+
},
|
|
254
|
+
h("img", {
|
|
255
|
+
src: zoom.url, alt: zoom.name,
|
|
256
|
+
style: { maxWidth: "92vw", maxHeight: "92vh", objectFit: "contain", borderRadius: "8px", boxShadow: "0 12px 48px rgba(0,0,0,.5)" },
|
|
257
|
+
}),
|
|
258
|
+
h("button", {
|
|
259
|
+
type: "button", "aria-label": "关闭",
|
|
260
|
+
style: {
|
|
261
|
+
position: "absolute", top: "12px", right: "16px",
|
|
262
|
+
width: "36px", height: "36px", padding: "0", border: "none", borderRadius: "50%",
|
|
263
|
+
background: "rgba(0,0,0,.5)", color: "#fff", cursor: "pointer",
|
|
264
|
+
fontSize: "18px", lineHeight: "1",
|
|
265
|
+
},
|
|
266
|
+
onClick: () => setZoom(null),
|
|
267
|
+
}, "×"),
|
|
268
|
+
) : null,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ── 右工具行:余额 ─────────────────────────────────────────────
|
|
273
|
+
function BalanceMeter({ connection, sessionId }) {
|
|
274
|
+
const [balance, setBalance] = useState(null);
|
|
275
|
+
const [visible, setVisible] = useState(false);
|
|
276
|
+
const refresh = useCallback(async () => {
|
|
277
|
+
if (connection === undefined) return;
|
|
278
|
+
try {
|
|
279
|
+
const response = await connection.api.balance.get({ sessionId });
|
|
280
|
+
if (!response.result.ok) return;
|
|
281
|
+
const value = response.result.value.balance;
|
|
282
|
+
setBalance(value);
|
|
283
|
+
setVisible(value !== null);
|
|
284
|
+
} catch { /* 静默 */ }
|
|
285
|
+
}, [connection, sessionId]);
|
|
286
|
+
useEffect(() => { void refresh(); }, [refresh]);
|
|
287
|
+
useEffect(() => {
|
|
288
|
+
const timer = setInterval(() => { void refresh(); }, POLL_MS);
|
|
289
|
+
return () => clearInterval(timer);
|
|
290
|
+
}, [refresh]);
|
|
291
|
+
|
|
292
|
+
if (!visible || balance === null) return null;
|
|
293
|
+
const label = `余额: ¥${balance.total}`;
|
|
294
|
+
return h("span", {
|
|
295
|
+
title: `总额 ¥${balance.total} · 赠送 ¥${balance.granted} · 充值 ¥${balance.toppedUp}`,
|
|
296
|
+
style: { display: "inline-flex", alignItems: "center", fontSize: "12px", opacity: 0.85, whiteSpace: "nowrap", cursor: "default" },
|
|
297
|
+
}, h("button", {
|
|
298
|
+
type: "button", "aria-label": label, title: label,
|
|
299
|
+
style: { border: "none", background: "transparent", color: "inherit", cursor: "default", fontSize: "inherit", padding: "0 4px" },
|
|
300
|
+
}, label));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ── 设置页:语音服务分区(settings.section,读写 ~/.dsh/voice-config.json)──
|
|
304
|
+
const VOICE_RULES = [
|
|
305
|
+
"1) 用户本轮发过语音 → 必须语音回复(使用上方选择的默认引擎)",
|
|
306
|
+
"2) 用户文本明确要求发语音 / 指定服务商(小米/微软)→ 自动合成(用指定 provider)",
|
|
307
|
+
"3) 其他情况不自动合成——agent 自主决定,需要时调用 send_voice 工具主动发(仍按默认引擎)",
|
|
308
|
+
];
|
|
309
|
+
// 自然语言风格预设(xiaomi context,强差异)
|
|
310
|
+
const STYLE_PRESETS = [
|
|
311
|
+
{ key: "", label: "自然(默认)", ctx: "" },
|
|
312
|
+
{ key: "joyful", label: "欢快活泼", ctx: "用欢快、活泼的语气,语速轻快,带着笑意,声音明亮有活力" },
|
|
313
|
+
{ key: "gentle", label: "温柔亲切", ctx: "用温柔、亲切的语气,语速平缓,声音柔和,像在关怀对方" },
|
|
314
|
+
{ key: "calm", label: "沉稳严肃", ctx: "用沉稳、严肃的语气,语速适中偏慢,声音厚重,正式播报感" },
|
|
315
|
+
{ key: "broadcast", label: "播音腔", ctx: "用标准播音腔,吐字清晰,节奏分明,抑扬顿挫,专业新闻播报" },
|
|
316
|
+
{ key: "whisper", label: "低语私密", ctx: "用低沉、私密的低语语气,音量放轻,语速缓慢,像耳语般亲近" },
|
|
317
|
+
{ key: "excited", label: "兴奋激动", ctx: "用兴奋、激动的语气,语速快,音调上扬,情绪饱满有感染力" },
|
|
318
|
+
];
|
|
319
|
+
// 常用情绪(voicedesign 试听:叠加在"音色描述"之上的表演指令,不写性别/年龄——那是音色描述的事)
|
|
320
|
+
// 写法参照 MiMo 官方"自然语言控制"示例:语速、气息、停顿、音调、共鸣都要有可感细节
|
|
321
|
+
const EMOTIONS = [
|
|
322
|
+
{ key: "happy", label: "开心", ctx: "用开心、欢快的语气,语速轻快,带着抑制不住的笑意,声音明亮上扬,尾音微微翘起" },
|
|
323
|
+
{ key: "sad", label: "难过", ctx: "用难过、低落的语气,语速缓慢,声音轻柔低沉,气息断断续续,带着淡淡的忧伤和哽咽感" },
|
|
324
|
+
{ key: "angry", label: "愤怒", ctx: "用愤怒、激动的语气,语速急促,声音强硬有力,气息加重,字字用力,带爆发感" },
|
|
325
|
+
{ key: "gentle", label: "温柔", ctx: "用温柔、关切的语气,语速平缓,气息绵软,声音柔和亲切,像在轻声安抚对方" },
|
|
326
|
+
{ key: "calm", label: "平静", ctx: "用平静、沉稳的语气,语速适中,气息平稳,声音波澜不惊,字正腔圆" },
|
|
327
|
+
{ key: "playful", label: "俏皮", ctx: "用俏皮、活泼的语气,语速轻快,声音带点机灵劲,尾音上扬,像在逗趣" },
|
|
328
|
+
{ key: "cold", label: "高冷", ctx: "用高冷、疏离的语气,语速偏慢,声音平淡克制,字字清晰,像隔着一层冰" },
|
|
329
|
+
{ key: "magnetic", label: "磁性", ctx: "用磁性、醇厚的语气,语速稍慢,气息低沉共鸣,声音富有魅力,尾音带拖腔" },
|
|
330
|
+
{ key: "excited", label: "兴奋", ctx: "用兴奋、高昂的语气,语速快,声音高亢明亮,情绪饱满,气息急促上扬" },
|
|
331
|
+
{ key: "grievance", label: "委屈", ctx: "用委屈、哽咽的语气,语速慢,声音发颤带鼻音,像忍着泪说话" },
|
|
332
|
+
{ key: "lazy", label: "慵懒", ctx: "用慵懒、松弛的语气,语速慢悠悠,声音松散,气息不紧不慢,漫不经心" },
|
|
333
|
+
{ key: "deep", label: "深沉", ctx: "用深沉、厚重的语气,若有所思,语速稳中有顿挫,声音偏低,字字有分量" },
|
|
334
|
+
];
|
|
335
|
+
const ENGINES_ORDER = ["edge", "xiaomi", "voiceclone", "local", "ali"];
|
|
336
|
+
const ENGINE_LABELS = {
|
|
337
|
+
edge: "微软 edge(免费)", xiaomi: "小米 MiMo", voiceclone: "小米克隆(VoiceClone)", local: "本地 TTS", ali: "阿里 qwen3-tts",
|
|
338
|
+
};
|
|
339
|
+
const MIMO_DOC_URL = "https://mimo.mi.com/models/zh-CN/mimo-v2.5-tts";
|
|
340
|
+
// VoiceDesign 官方示例(音色设计:Instruct=音色描述/导演指令,Text=要朗读的文本)
|
|
341
|
+
const VOICE_DESIGN_EXAMPLES = [
|
|
342
|
+
{
|
|
343
|
+
title: "ASMR 双耳女声",
|
|
344
|
+
instruct: "年轻的女性声音,近距离的聆听效果,带有双耳刺激的ASMR感。可以听到她的呼吸声、轻微的吞咽声,以及轻柔的自然唇音。她的说话速度非常慢,营造出一种极度放松且沉浸式的体验。",
|
|
345
|
+
text: "[在你耳边低语] 嘘……放松点,再靠近一点吧。我现在就在你身边。慢慢、轻柔地呼吸,让思绪随着水流轻轻流淌,就像沉浸在温暖的水中一样。",
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
title: "纪录片旁白",
|
|
349
|
+
instruct: "一位中年男性,说标准普通话,嗓音低沉有磁性,带有轻微的沙哑质感,像纪录片旁白解说员,沉稳而有感染力。",
|
|
350
|
+
text: "当最后一缕阳光消失在地平线之下,这片沉睡了亿万年的大地开始显露它真正的面貌。在这寂静的荒野中,每一块岩石都记录着时间的流逝,每一阵风都在诉说着古老的故事。",
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
title: "年迈老先生旁白",
|
|
354
|
+
instruct: "一位年迈的老先生,说带北方口音的普通话,语速缓慢而沉稳,嗓音略带沙哑和沧桑感,仿佛一位饱经风霜的老爷爷在讲故事,充满岁月的智慧。",
|
|
355
|
+
text: "我这辈子啊,走南闯北六十多年。见过最热闹的集市,也见过最安静的戈壁。到头来才明白一个道理——这人哪,不在走了多远的路,在于记住了多少风景。年轻人,别光顾着赶路,偶尔也停下来看看天。",
|
|
356
|
+
},
|
|
357
|
+
];
|
|
358
|
+
// VoiceDesign 默认音色描述(用户未填时的兜底,含性别锚点)
|
|
359
|
+
const DEFAULT_VOICE_DESC = "青年女性,声音甜美明亮,普通话标准,语速适中,活泼开朗";
|
|
360
|
+
// [本地改造 2026-08-21] 所有克隆音色的统一试听文本(与每个样本自己的风格指令配合,
|
|
361
|
+
// 试听时能同时听出"音色+个性";如小团团样本的指令让它念这句时自然带沙雕可爱腔)
|
|
362
|
+
const CLONE_PREVIEW_TEXT = "喂喂喂!你怎么才来呀?我都等你老半天啦!我跟你说啊——你今天可不能凶我哦,因为……因为你又不娶我,哼!不过嘛,看在你这么乖的份上,本小姐今天心情好,就大发慈悲原谅你啦!嘿嘿嘿~走吧走吧,出发喽!";
|
|
363
|
+
|
|
364
|
+
const vInput = {
|
|
365
|
+
background: "var(--dsw-specific-input-fill,#1e2128)", color: "var(--dsw-alias-label-primary,#e6e9ef)",
|
|
366
|
+
border: "1px solid var(--dsw-alias-border-l1,#333a45)", borderRadius: "6px",
|
|
367
|
+
padding: "6px 10px", fontSize: "12.5px", fontFamily: "inherit", width: "100%",
|
|
368
|
+
boxSizing: "border-box",
|
|
369
|
+
};
|
|
370
|
+
const vField = (labelText, node) => h("label", {
|
|
371
|
+
style: { display: "flex", flexDirection: "column", gap: "4px", fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)", flex: "1 1 45%", minWidth: "220px" },
|
|
372
|
+
}, labelText, node);
|
|
373
|
+
// 服务商卡片([本地改造 2026-08-21] 去复选框改折叠):标题栏点击展开/收起。
|
|
374
|
+
// 配置填写与启用与否无关——只要填了 AI 就能调用,所以不再用 enabled 开关控制。
|
|
375
|
+
const vCard = (title, open, onToggle, children) => h("div", {
|
|
376
|
+
style: { border: "1px solid var(--dsw-alias-border-l1,#333a45)", borderRadius: "10px", padding: "10px 12px", display: "flex", flexDirection: "column", gap: "8px", background: "rgba(128,128,128,.05)" },
|
|
377
|
+
},
|
|
378
|
+
h("div", {
|
|
379
|
+
style: { display: "flex", alignItems: "center", gap: "8px", cursor: "pointer", userSelect: "none" },
|
|
380
|
+
onClick: onToggle,
|
|
381
|
+
},
|
|
382
|
+
h("span", { style: { display: "inline-flex", width: "22px", height: "22px", borderRadius: "6px", background: "rgba(128,128,128,.12)", alignItems: "center", justifyContent: "center", color: "var(--vk-accent,#4b6fff)", flex: "none" } }, micIcon),
|
|
383
|
+
h("span", { style: { fontSize: "13px", fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)" } }, title),
|
|
384
|
+
h("span", { style: { marginLeft: "auto", fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)", flex: "none" } }, open ? "收起 ▴" : "展开 ▾"),
|
|
385
|
+
),
|
|
386
|
+
open ? (typeof children === "function" ? children(true) : children) : null,
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
function VoiceSettingsSection() {
|
|
390
|
+
const [config, setConfig] = useState(null);
|
|
391
|
+
const [meta, setMeta] = useState(null);
|
|
392
|
+
// [本地改造 2026-08-21] 服务商卡片折叠状态(去复选框后由折叠控制显隐,默认展开)
|
|
393
|
+
const [openCards, setOpenCards] = useState({ edge: true, xiaomi: true, local: true, ali: true });
|
|
394
|
+
const toggleCard = (key) => setOpenCards((s) => ({ ...s, [key]: !s[key] }));
|
|
395
|
+
const [previewing, setPreviewing] = useState(null); // 正在试听的标识:engine / emotion:key / style:key
|
|
396
|
+
const [rulesPinned, setRulesPinned] = useState(false);
|
|
397
|
+
const [rulesHover, setRulesHover] = useState(false);
|
|
398
|
+
const [cloneListTipPinned, setCloneListTipPinned] = useState(false);
|
|
399
|
+
const [cloneListTipHover, setCloneListTipHover] = useState(false);
|
|
400
|
+
const [designTipPinned, setDesignTipPinned] = useState(false);
|
|
401
|
+
const [designTipHover, setDesignTipHover] = useState(false);
|
|
402
|
+
const [asrTipPinned, setAsrTipPinned] = useState(false);
|
|
403
|
+
const [asrTipHover, setAsrTipHover] = useState(false);
|
|
404
|
+
const [xmTipPinned, setXmTipPinned] = useState(false);
|
|
405
|
+
const [xmTipHover, setXmTipHover] = useState(false);
|
|
406
|
+
// 本地 TTS 卡片标题的 ? 提示 state
|
|
407
|
+
const [localTipPinned, setLocalTipPinned] = useState(false);
|
|
408
|
+
const [localTipHover, setLocalTipHover] = useState(false);
|
|
409
|
+
// 3 个 VoiceDesign 官方示例的 Instruct/Text 悬浮提示 state
|
|
410
|
+
const [vdExamplePins, setVdExamplePins] = useState([false, false, false]);
|
|
411
|
+
const [vdExampleHovers, setVdExampleHovers] = useState([false, false, false]);
|
|
412
|
+
const previewRef = useRef(null);
|
|
413
|
+
const previewTagRef = useRef(null); // [本地改造 2026-08-21] 当前播放的试听 tag,用于「再点=停止」
|
|
414
|
+
const newCloneNameRef = useRef(null);
|
|
415
|
+
const newClonePathRef = useRef(null);
|
|
416
|
+
// ASR 语音识别测试状态(示例音频 + 识别)
|
|
417
|
+
const [asrResult, setAsrResult] = useState(null); // { ok, text, busy } | null
|
|
418
|
+
// [本地改造 2026-08-21] 克隆样本添加(选择音频 → 上传 → 命名)
|
|
419
|
+
const [cloneName, setCloneName] = useState("");
|
|
420
|
+
const [addingClone, setAddingClone] = useState(false);
|
|
421
|
+
const [cloneAddMsg, setCloneAddMsg] = useState(null); // { ok, text } | null
|
|
422
|
+
const cloneFileRef = useRef(null);
|
|
423
|
+
const asrAudioRef = useRef(null);
|
|
424
|
+
const asrSampleBase64Ref = useRef(null);
|
|
425
|
+
const [asrInstalling, setAsrInstalling] = useState(false); // 一键安装进行中
|
|
426
|
+
const [asrCmd, setAsrCmd] = useState(null); // 待手动复制的安装命令
|
|
427
|
+
const [vdSamples, setVdSamples] = useState([]); // VoiceDesign 官方示例音频(预生成)
|
|
428
|
+
|
|
429
|
+
useEffect(() => {
|
|
430
|
+
let dead = false;
|
|
431
|
+
fetch("/voice-config").then((r) => r.json()).then((d) => { if (!dead && d?.ok) setConfig(d.config); }).catch(() => {});
|
|
432
|
+
fetch("/voice-config/engines").then((r) => r.json()).then((d) => { if (!dead && d?.ok) setMeta(d.engines); }).catch(() => {});
|
|
433
|
+
// 自动加载 ASR 示例音频(无需手动点"加载")
|
|
434
|
+
fetch("/asr/sample").then((r) => r.json()).then((d) => {
|
|
435
|
+
if (!dead && d?.ok) {
|
|
436
|
+
asrSampleBase64Ref.current = d.data;
|
|
437
|
+
if (asrAudioRef.current !== null) asrAudioRef.current.src = "data:" + d.mediaType + ";base64," + d.data;
|
|
438
|
+
}
|
|
439
|
+
}).catch(() => {});
|
|
440
|
+
// 自动加载 VoiceDesign 官方示例音频(预生成)
|
|
441
|
+
fetch("/asr/voice-design-samples").then((r) => r.json()).then((d) => {
|
|
442
|
+
if (!dead && d?.ok && Array.isArray(d.samples)) setVdSamples(d.samples);
|
|
443
|
+
}).catch(() => {});
|
|
444
|
+
// 自动检测本机 ASR 组件(不覆盖用户已保存的配置,只静默记录)
|
|
445
|
+
fetch("/asr/detect").then((r) => r.json()).then((d) => {
|
|
446
|
+
if (!dead && d?.ok && d.detected?.serviceOk) {
|
|
447
|
+
// 服务可达时静默确保 url 已填
|
|
448
|
+
}
|
|
449
|
+
}).catch(() => {});
|
|
450
|
+
return () => { dead = true; if (previewRef.current !== null) previewRef.current.pause(); };
|
|
451
|
+
}, []);
|
|
452
|
+
|
|
453
|
+
// 自动保存的 setEngine(勾选/输入变化后立即持久化,防止刷新丢失;无保存按钮)
|
|
454
|
+
// [本地改造 2026-08-21] 保存后以服务端返回的 config 为准刷新本地 state——
|
|
455
|
+
// 避免"前端旧 config 全量覆盖服务端新变更"(如服务端新加的克隆样本被清空)
|
|
456
|
+
const saveTimerRef = useRef(null);
|
|
457
|
+
const setEngine = (key, patch, autoSave) => {
|
|
458
|
+
setConfig((c) => {
|
|
459
|
+
if (c === null) return c;
|
|
460
|
+
const next = { ...c, engines: { ...c.engines, [key]: { ...c.engines[key], ...patch } } };
|
|
461
|
+
if (autoSave) {
|
|
462
|
+
if (saveTimerRef.current !== null) window.clearTimeout(saveTimerRef.current);
|
|
463
|
+
saveTimerRef.current = window.setTimeout(() => {
|
|
464
|
+
fetch("/voice-config", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ config: next }) })
|
|
465
|
+
.then((r) => r.json())
|
|
466
|
+
.then((d) => { if (d?.ok && d.config) setConfig(d.config); })
|
|
467
|
+
.catch(() => {});
|
|
468
|
+
}, 400);
|
|
469
|
+
}
|
|
470
|
+
return next;
|
|
471
|
+
});
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
// [本地改造 2026-08-21] 克隆样本添加:选音频文件 → 校验格式/大小 → 上传命名
|
|
475
|
+
const addCloneSample = async (file) => {
|
|
476
|
+
if (file === null || file === undefined) return;
|
|
477
|
+
setCloneAddMsg(null);
|
|
478
|
+
if (!/\.(mp3|wav)$/i.test(file.name) && !/audio\/(mpeg|wav)/.test(file.type)) {
|
|
479
|
+
setCloneAddMsg({ ok: false, text: "仅支持 mp3 / wav 格式" });
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
if (file.size > 10 * 1024 * 1024) {
|
|
483
|
+
setCloneAddMsg({ ok: false, text: "音频需在 10MB 以内(官方限制;参考语音建议 15-60 秒,越长克隆越准)" });
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const reader = new FileReader();
|
|
487
|
+
const data = await new Promise((resolve, reject) => {
|
|
488
|
+
reader.onload = () => resolve(String(reader.result).split(",")[1] ?? "");
|
|
489
|
+
reader.onerror = reject;
|
|
490
|
+
reader.readAsDataURL(file);
|
|
491
|
+
});
|
|
492
|
+
setAddingClone(true);
|
|
493
|
+
try {
|
|
494
|
+
const r = await fetch("/voice-config/voice-clone/add", {
|
|
495
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
496
|
+
body: JSON.stringify({
|
|
497
|
+
name: cloneName.trim() !== "" ? cloneName.trim() : file.name.replace(/\.(mp3|wav)$/i, ""),
|
|
498
|
+
audioBase64: data,
|
|
499
|
+
mediaType: file.type || "audio/wav",
|
|
500
|
+
}),
|
|
501
|
+
});
|
|
502
|
+
const d = await r.json();
|
|
503
|
+
if (d?.ok) {
|
|
504
|
+
setCloneAddMsg({ ok: true, text: "已添加克隆音色「" + d.sample.name + "」,如需默认使用,在「默认语音引擎」选「小米克隆」即可" });
|
|
505
|
+
setCloneName("");
|
|
506
|
+
// [本地改造 2026-08-21] 以服务端返回的 config 为准刷新(含新增样本),避免本地拼装丢字段
|
|
507
|
+
if (d.config) setConfig(d.config);
|
|
508
|
+
} else {
|
|
509
|
+
setCloneAddMsg({ ok: false, text: d?.error ?? "添加失败" });
|
|
510
|
+
}
|
|
511
|
+
} catch (e) {
|
|
512
|
+
setCloneAddMsg({ ok: false, text: String(e?.message ?? e) });
|
|
513
|
+
}
|
|
514
|
+
setAddingClone(false);
|
|
515
|
+
if (cloneFileRef.current !== null) cloneFileRef.current.value = "";
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
// 音色试听:POST /voice-config/preview → 播放返回音频;tag 用于区分多个试听按钮状态;text/cmd/url 可临时指定
|
|
519
|
+
const previewVoice = (engine, voice, context, samplePath, tag, extra) => {
|
|
520
|
+
if (previewRef.current !== null) { previewRef.current.pause(); previewRef.current = null; }
|
|
521
|
+
const curTag = tag ?? engine;
|
|
522
|
+
previewTagRef.current = curTag;
|
|
523
|
+
setPreviewing(curTag);
|
|
524
|
+
fetch("/voice-config/preview", {
|
|
525
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
526
|
+
body: JSON.stringify({
|
|
527
|
+
engine, voice: voice ?? undefined, context: context ?? undefined, samplePath: samplePath ?? undefined,
|
|
528
|
+
text: extra?.text ?? undefined, cmd: extra?.cmd ?? undefined, url: extra?.url ?? undefined,
|
|
529
|
+
}),
|
|
530
|
+
})
|
|
531
|
+
.then((r) => r.json())
|
|
532
|
+
.then((d) => {
|
|
533
|
+
if (!d?.ok) { if (previewTagRef.current === curTag) setPreviewing(null); return; }
|
|
534
|
+
if (previewTagRef.current !== curTag) return; // 已被「再点=停止」或切换,丢弃
|
|
535
|
+
const audio = new Audio("data:" + d.mediaType + ";base64," + d.data);
|
|
536
|
+
previewRef.current = audio;
|
|
537
|
+
audio.onended = () => { if (previewTagRef.current === curTag) setPreviewing(null); };
|
|
538
|
+
audio.onerror = () => { if (previewTagRef.current === curTag) setPreviewing(null); };
|
|
539
|
+
audio.play().catch(() => { if (previewTagRef.current === curTag) setPreviewing(null); });
|
|
540
|
+
})
|
|
541
|
+
.catch(() => { if (previewTagRef.current === curTag) setPreviewing(null); });
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
// [本地改造 2026-08-21] 试听克隆样本的原始音频(用于和克隆合成效果对比还原度)
|
|
545
|
+
const previewSourceVoice = async (path, tag) => {
|
|
546
|
+
if (previewRef.current !== null) { previewRef.current.pause(); previewRef.current = null; }
|
|
547
|
+
previewTagRef.current = tag;
|
|
548
|
+
setPreviewing(tag);
|
|
549
|
+
try {
|
|
550
|
+
const r = await fetch("/voice-config/voice-clone/source", {
|
|
551
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
552
|
+
body: JSON.stringify({ path }),
|
|
553
|
+
});
|
|
554
|
+
const d = await r.json();
|
|
555
|
+
if (!d?.ok) { if (previewTagRef.current === tag) setPreviewing(null); return; }
|
|
556
|
+
if (previewTagRef.current !== tag) return; // 已被「再点=停止」或切换,丢弃
|
|
557
|
+
const audio = new Audio("data:" + d.mediaType + ";base64," + d.data);
|
|
558
|
+
previewRef.current = audio;
|
|
559
|
+
audio.onended = () => { if (previewTagRef.current === tag) setPreviewing(null); };
|
|
560
|
+
audio.onerror = () => { if (previewTagRef.current === tag) setPreviewing(null); };
|
|
561
|
+
audio.play().catch(() => { if (previewTagRef.current === tag) setPreviewing(null); });
|
|
562
|
+
} catch { if (previewTagRef.current === tag) setPreviewing(null); }
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
const previewBtn = (tag, label, onClick, icon) => h("button", {
|
|
566
|
+
type: "button", "aria-label": label, title: label,
|
|
567
|
+
style: {
|
|
568
|
+
border: "none", borderRadius: "6px", width: "30px", height: "30px", flex: "none",
|
|
569
|
+
background: previewing === tag ? "rgba(229,72,77,.25)" : "rgba(128,128,128,.15)",
|
|
570
|
+
color: "inherit", cursor: "pointer", fontSize: "13px",
|
|
571
|
+
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
|
572
|
+
},
|
|
573
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
574
|
+
onClick: () => {
|
|
575
|
+
// [本地改造 2026-08-21] 再点一次正在播放的按钮 = 停止(而不是重播)
|
|
576
|
+
if (previewing === tag) {
|
|
577
|
+
previewTagRef.current = null;
|
|
578
|
+
if (previewRef.current !== null) { previewRef.current.pause(); previewRef.current = null; }
|
|
579
|
+
setPreviewing(null);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
onClick();
|
|
583
|
+
},
|
|
584
|
+
}, previewing === tag ? "⏹" : (icon ?? "🔊"));
|
|
585
|
+
|
|
586
|
+
// 音色下拉 + 试听按钮(showPreview=false 时不显示试听,改由风格处试听)
|
|
587
|
+
const voiceSelect = (engine, current, voices, onChange, showPreview) => h("div", { style: { display: "flex", gap: "6px", alignItems: "center" } },
|
|
588
|
+
h("select", { value: current, onChange: (e) => onChange(e.target.value), style: vInput },
|
|
589
|
+
(voices ?? [current]).map((v) => h("option", { key: v, value: v }, v))),
|
|
590
|
+
showPreview === false ? null : previewBtn(engine, "试听此音色", () => previewVoice(engine, current)),
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
// ASR 示例音频:自动加载 host 提供的测试音频(可播放),识别则把它发给 /asr/transcribe
|
|
594
|
+
const loadAsrSample = () => {
|
|
595
|
+
fetch("/asr/sample").then((r) => r.json()).then((d) => {
|
|
596
|
+
if (!d?.ok) { setAsrResult({ ok: false, text: d?.error ?? "示例音频加载失败" }); return; }
|
|
597
|
+
asrSampleBase64Ref.current = d.data;
|
|
598
|
+
if (asrAudioRef.current !== null) {
|
|
599
|
+
asrAudioRef.current.src = "data:" + d.mediaType + ";base64," + d.data;
|
|
600
|
+
}
|
|
601
|
+
}).catch((e) => setAsrResult({ ok: false, text: String(e) }));
|
|
602
|
+
};
|
|
603
|
+
const recognizeAsrSample = () => {
|
|
604
|
+
const sample = asrSampleBase64Ref.current;
|
|
605
|
+
if (sample === null || sample === undefined) { setAsrResult({ ok: false, text: "示例音频加载中,请稍候" }); return; }
|
|
606
|
+
setAsrResult({ ok: true, text: "识别中…", busy: true });
|
|
607
|
+
fetch("/asr/transcribe", {
|
|
608
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
609
|
+
body: JSON.stringify({ audioBase64: sample }),
|
|
610
|
+
}).then((r) => r.json()).then((d) => {
|
|
611
|
+
setAsrResult(d?.ok ? { ok: true, text: d.text } : { ok: false, text: d?.error ?? "识别失败" });
|
|
612
|
+
}).catch((e) => setAsrResult({ ok: false, text: String(e) }));
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
// 检测本机 ASR(exe/模型/服务/ffmpeg),自动填入可用的地址或命令
|
|
616
|
+
const detectAsr = () => {
|
|
617
|
+
setAsrResult(null);
|
|
618
|
+
fetch("/asr/detect").then((r) => r.json()).then((d) => {
|
|
619
|
+
if (!d?.ok) { setAsrResult({ ok: false, text: d?.error ?? "检测失败" }); return; }
|
|
620
|
+
const det = d.detected;
|
|
621
|
+
const fills = [];
|
|
622
|
+
if (det.serviceOk) {
|
|
623
|
+
setEngine("asr", { mode: "service", url: "http://127.0.0.1:18790" }, true);
|
|
624
|
+
fills.push("检测到本地常驻服务(18790),已自动填入地址");
|
|
625
|
+
}
|
|
626
|
+
if (det.cmd !== "") {
|
|
627
|
+
if (!det.serviceOk) setEngine("asr", { mode: "cmd", cmd: det.cmd }, true);
|
|
628
|
+
else setEngine("asr", { cmd: det.cmd }, true);
|
|
629
|
+
fills.push("已填入本地命令路径");
|
|
630
|
+
}
|
|
631
|
+
if (!det.exe) fills.push("未找到 sherpa-onnx,可点「一键安装」");
|
|
632
|
+
if (!det.ffmpegOk) fills.push("未找到 ffmpeg,安装脚本会自动安装");
|
|
633
|
+
setAsrResult({ ok: true, text: fills.length > 0 ? fills.join(";") : "未检测到本地 ASR 组件,请点「一键安装」" });
|
|
634
|
+
}).catch((e) => setAsrResult({ ok: false, text: String(e) }));
|
|
635
|
+
};
|
|
636
|
+
// 一键安装:获取安装命令并显示(不自动写剪贴板,避免 uBlock 误报 ClickFix;用户手动复制更安全)
|
|
637
|
+
const installAsr = () => {
|
|
638
|
+
setAsrInstalling(true);
|
|
639
|
+
setAsrCmd(null);
|
|
640
|
+
fetch("/asr/install-script").then((r) => r.json()).then((d) => {
|
|
641
|
+
setAsrInstalling(false);
|
|
642
|
+
if (!d?.ok) { setAsrResult({ ok: false, text: d?.error ?? "获取安装命令失败" }); return; }
|
|
643
|
+
setAsrCmd(d.command);
|
|
644
|
+
setAsrResult({
|
|
645
|
+
ok: true,
|
|
646
|
+
text: "请打开「以管理员身份运行」的 PowerShell,手动复制下方命令粘贴执行。\n安装位置会自动放到插件目录:" + d.installDir + "\n脚本会自动下载 sherpa-onnx + SenseVoice 模型 + ffmpeg 并注册开机自启服务(端口 18790)",
|
|
647
|
+
});
|
|
648
|
+
}).catch((e) => { setAsrInstalling(false); setAsrResult({ ok: false, text: String(e) }); });
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
// 提示小问号(hover 浮层显示 / 点击固定);align="right" 时浮层右对齐(向左展开,适合靠左按钮),默认左对齐(向右展开,适合靠右按钮)
|
|
652
|
+
const helpTip = (text, pinned, setPinned, hover, setHover, align, place) => h("span", { style: { position: "relative", display: "inline-flex", alignItems: "center" } },
|
|
653
|
+
h("button", {
|
|
654
|
+
type: "button", "aria-label": "帮助", title: "帮助",
|
|
655
|
+
style: {
|
|
656
|
+
border: "none", borderRadius: "999px", width: "18px", height: "18px", padding: "0",
|
|
657
|
+
background: pinned ? "var(--vk-accent,#4b6fff)" : "rgba(128,128,128,.15)",
|
|
658
|
+
color: "inherit", cursor: "pointer", fontSize: "10px", fontWeight: 700,
|
|
659
|
+
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
|
660
|
+
},
|
|
661
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
662
|
+
onMouseEnter: () => setHover(true),
|
|
663
|
+
onMouseLeave: () => setHover(false),
|
|
664
|
+
onClick: () => { setPinned(!pinned); setHover(false); },
|
|
665
|
+
}, "?"),
|
|
666
|
+
(pinned || hover) ? h("div", {
|
|
667
|
+
style: {
|
|
668
|
+
position: "absolute", ...(place === "top" ? { bottom: "calc(100% + 6px)" } : { top: "calc(100% + 6px)" }), zIndex: 60,
|
|
669
|
+
...(align === "right" ? { right: "0", left: "auto" } : align === "center" ? { left: "50%", transform: "translateX(-50%)" } : { left: "0", right: "auto" }),
|
|
670
|
+
background: "var(--dsw-specific-input-fill,#1e2128)",
|
|
671
|
+
border: "1px solid var(--dsw-alias-border-l1,#333a45)", borderRadius: "8px",
|
|
672
|
+
padding: "10px 12px", boxShadow: "0 8px 24px rgba(0,0,0,.35)",
|
|
673
|
+
fontSize: "12px", lineHeight: "1.7", color: "var(--dsw-alias-label-secondary,#9aa3ad)",
|
|
674
|
+
minWidth: "320px", maxWidth: "460px",
|
|
675
|
+
},
|
|
676
|
+
}, text) : null,
|
|
677
|
+
);
|
|
678
|
+
|
|
679
|
+
if (config === null) {
|
|
680
|
+
return h("div", { style: { padding: "16px", fontSize: "13px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } }, "语音配置加载中…");
|
|
681
|
+
}
|
|
682
|
+
const eng = config.engines;
|
|
683
|
+
const cloneSamples = Array.isArray(eng.voiceclone.samples) ? eng.voiceclone.samples : [];
|
|
684
|
+
const showRules = rulesPinned || rulesHover;
|
|
685
|
+
// [本地改造 2026-08-21] 已移除 VoiceClone/VoiceDesign 勾选:分区始终显示
|
|
686
|
+
const designOn = false;
|
|
687
|
+
const cloneOn = false;
|
|
688
|
+
|
|
689
|
+
return h("div", { style: { display: "flex", flexDirection: "column", gap: "14px", padding: "16px", width: "100%", boxSizing: "border-box" } },
|
|
690
|
+
// 分区标题(语音图标已移到各服务商卡片前)
|
|
691
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: "8px", fontSize: "15px", fontWeight: 700, color: "var(--dsw-alias-label-primary,#e6e9ef)" } },
|
|
692
|
+
"语音服务"),
|
|
693
|
+
// ⑤ ASR 语音识别(必填项,无开关)
|
|
694
|
+
h("div", { style: { border: "1px solid var(--dsw-alias-border-l1,#333a45)", borderRadius: "10px", padding: "10px 12px", display: "flex", flexDirection: "column", gap: "8px", background: "rgba(128,128,128,.05)" } },
|
|
695
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: "8px" } },
|
|
696
|
+
h("span", { style: { display: "inline-flex", width: "22px", height: "22px", borderRadius: "6px", background: "rgba(128,128,128,.12)", alignItems: "center", justifyContent: "center", color: "var(--vk-accent,#4b6fff)", flex: "none" } }, micIcon),
|
|
697
|
+
h("span", { style: { fontSize: "13px", fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)" } }, "ASR 语音识别"),
|
|
698
|
+
helpTip("把语音转成文字(必填配置,选一种模式即可)。本地服务:请求常驻 HTTP 服务(默认 127.0.0.1:18790);本地命令:直接调用 sherpa-onnx exe,无需额外装服务,速度与本地服务基本一致(8 秒音频约 1.4s,其中真正推理只占 0.16s,其余是每次加载模型的固定开销);在线 API:走 OpenAI 兼容接口,不占用本地算力。", asrTipPinned, setAsrTipPinned, asrTipHover, setAsrTipHover, "center"),
|
|
699
|
+
),
|
|
700
|
+
h("div", { style: { display: "flex", flexDirection: "column", gap: "10px" } },
|
|
701
|
+
h("div", { style: { display: "flex", flexWrap: "wrap", gap: "10px" } },
|
|
702
|
+
vField("模式", h("select", {
|
|
703
|
+
value: eng.asr.mode ?? "service",
|
|
704
|
+
onChange: (e) => setEngine("asr", { mode: e.target.value }, true),
|
|
705
|
+
style: vInput,
|
|
706
|
+
},
|
|
707
|
+
h("option", { value: "service" }, "本地常驻服务"),
|
|
708
|
+
h("option", { value: "cmd" }, "本地命令"),
|
|
709
|
+
h("option", { value: "api" }, "在线 API"))),
|
|
710
|
+
(eng.asr.mode ?? "service") === "service" ? vField("本地服务地址", h("input", { value: eng.asr.url ?? "", onChange: (e) => setEngine("asr", { url: e.target.value }, true), placeholder: "http://127.0.0.1:18790", style: vInput })) : null,
|
|
711
|
+
(eng.asr.mode ?? "service") === "cmd" ? vField("本地命令", h("input", { value: eng.asr.cmd ?? "", onChange: (e) => setEngine("asr", { cmd: e.target.value }, true), placeholder: "sherpa-onnx-offline.exe --tokens=... --sense-voice-model=... --num-threads=4", style: vInput })) : null,
|
|
712
|
+
(eng.asr.mode ?? "service") === "api" ? [
|
|
713
|
+
vField("API Key", h("input", { type: "password", value: eng.asr.apiKey ?? "", onChange: (e) => setEngine("asr", { apiKey: e.target.value }, true), placeholder: "sk-...", style: vInput })),
|
|
714
|
+
vField("API 地址", h("input", { value: eng.asr.apiBaseUrl ?? "", onChange: (e) => setEngine("asr", { apiBaseUrl: e.target.value }, true), placeholder: "https://api.openai.com/v1", style: vInput })),
|
|
715
|
+
] : null,
|
|
716
|
+
),
|
|
717
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: "10px", flexWrap: "wrap" } },
|
|
718
|
+
h("span", { style: { fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)", flex: "none" } }, "示例音频:"),
|
|
719
|
+
h("audio", { ref: asrAudioRef, controls: true, preload: "none", style: { maxWidth: "320px", height: "32px", flex: "none" } }),
|
|
720
|
+
h("button", {
|
|
721
|
+
type: "button",
|
|
722
|
+
style: {
|
|
723
|
+
border: "none", borderRadius: "999px", padding: "7px 16px", fontSize: "12.5px", fontWeight: 600,
|
|
724
|
+
background: asrResult?.busy ? "rgba(229,72,77,.85)" : "rgba(128,128,128,.15)",
|
|
725
|
+
color: "inherit", cursor: "pointer",
|
|
726
|
+
},
|
|
727
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
728
|
+
onClick: recognizeAsrSample,
|
|
729
|
+
}, asrResult?.busy ? "识别中…" : "识别这段音频"),
|
|
730
|
+
),
|
|
731
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: "10px", flexWrap: "wrap", borderTop: "1px dashed var(--dsw-alias-border-l1,#333a45)", paddingTop: "8px" } },
|
|
732
|
+
h("button", {
|
|
733
|
+
type: "button",
|
|
734
|
+
style: {
|
|
735
|
+
border: "1px solid var(--vk-accent,#4b6fff)", borderRadius: "999px", padding: "6px 16px",
|
|
736
|
+
fontSize: "12.5px", fontWeight: 600, background: "transparent", color: "var(--vk-accent,#4b6fff)",
|
|
737
|
+
cursor: "pointer",
|
|
738
|
+
},
|
|
739
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
740
|
+
onClick: detectAsr,
|
|
741
|
+
}, "检测已安装"),
|
|
742
|
+
h("button", {
|
|
743
|
+
type: "button",
|
|
744
|
+
style: {
|
|
745
|
+
border: "none", borderRadius: "999px", padding: "6px 16px", fontSize: "12.5px", fontWeight: 600,
|
|
746
|
+
background: asrInstalling ? "rgba(128,128,128,.15)" : "var(--vk-accent,#4b6fff)",
|
|
747
|
+
color: "#fff", cursor: "pointer",
|
|
748
|
+
},
|
|
749
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
750
|
+
onClick: installAsr,
|
|
751
|
+
}, asrInstalling ? "准备命令…" : "复制安装命令"),
|
|
752
|
+
h("span", { style: { fontSize: "11.5px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } },
|
|
753
|
+
"复制命令后,打开「以管理员身份运行」的 PowerShell 粘贴执行。脚本自动下载 sherpa-onnx + SenseVoice 模型 + ffmpeg 并注册开机自启服务,安装到插件目录内统一路径"),
|
|
754
|
+
),
|
|
755
|
+
asrCmd !== null ? h("div", { style: { display: "flex", flexDirection: "column", gap: "4px" } },
|
|
756
|
+
h("div", { style: { fontSize: "11.5px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } }, "安装命令(点击选中全部,Ctrl+C 复制):"),
|
|
757
|
+
h("code", {
|
|
758
|
+
style: {
|
|
759
|
+
display: "block", fontSize: "12px", lineHeight: "1.6", fontFamily: "Consolas, monospace",
|
|
760
|
+
color: "var(--dsw-alias-label-primary,#e6e9ef)",
|
|
761
|
+
border: "1px solid var(--dsw-alias-border-l1,#333a45)", borderRadius: "8px",
|
|
762
|
+
padding: "8px 10px", background: "rgba(128,128,128,.08)",
|
|
763
|
+
wordBreak: "break-all", whiteSpace: "pre-wrap", cursor: "text", userSelect: "all",
|
|
764
|
+
},
|
|
765
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
766
|
+
onClick: (e) => {
|
|
767
|
+
const sel = window.getSelection();
|
|
768
|
+
const range = document.createRange();
|
|
769
|
+
range.selectNodeContents(e.currentTarget);
|
|
770
|
+
sel.removeAllRanges();
|
|
771
|
+
sel.addRange(range);
|
|
772
|
+
},
|
|
773
|
+
}, asrCmd),
|
|
774
|
+
) : null,
|
|
775
|
+
asrResult !== null && asrResult.text !== undefined ? h("div", {
|
|
776
|
+
style: {
|
|
777
|
+
fontSize: "12.5px", lineHeight: "1.6",
|
|
778
|
+
color: asrResult.ok ? "var(--dsw-alias-label-primary,#e6e9ef)" : "#e5484d",
|
|
779
|
+
border: "1px solid var(--dsw-alias-border-l1,#333a45)", borderRadius: "8px", padding: "8px 10px",
|
|
780
|
+
background: "rgba(128,128,128,.06)", whiteSpace: "pre-wrap",
|
|
781
|
+
},
|
|
782
|
+
}, asrResult.text) : null,
|
|
783
|
+
),
|
|
784
|
+
),
|
|
785
|
+
// 默认引擎
|
|
786
|
+
vField("默认语音引擎", h("select", {
|
|
787
|
+
value: config.defaultEngine,
|
|
788
|
+
onChange: (e) => {
|
|
789
|
+
// [本地改造 2026-08-21] 修复:defaultEngine 之前只改本地 state 不持久化,刷新回 auto;
|
|
790
|
+
// 现在与其它字段一致:防抖 POST 立即保存
|
|
791
|
+
const next = { ...config, defaultEngine: e.target.value };
|
|
792
|
+
setConfig(next);
|
|
793
|
+
if (saveTimerRef.current !== null) window.clearTimeout(saveTimerRef.current);
|
|
794
|
+
saveTimerRef.current = window.setTimeout(() => {
|
|
795
|
+
fetch("/voice-config", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ config: next }) }).catch(() => {});
|
|
796
|
+
}, 400);
|
|
797
|
+
},
|
|
798
|
+
style: vInput,
|
|
799
|
+
},
|
|
800
|
+
["auto", ...ENGINES_ORDER].map((k) => h("option", { key: k, value: k },
|
|
801
|
+
k === "auto" ? "auto(按规则自动选择,未启用任何引擎时用微软 edge 免费兜底)"
|
|
802
|
+
: k === "voiceclone"
|
|
803
|
+
? "小米克隆(VoiceClone)" + (cloneSamples.length > 0 ? ":默认用「" + cloneSamples[0].name + "」" : "(未添加样本)")
|
|
804
|
+
: ENGINE_LABELS[k])))),
|
|
805
|
+
// 语音三原则:问号按钮(hover 显示,点击固定/收起)
|
|
806
|
+
h("div", { style: { position: "relative", display: "inline-flex", alignItems: "center", gap: "6px" } },
|
|
807
|
+
h("button", {
|
|
808
|
+
type: "button", "aria-label": "语音自动回复规则", title: "语音自动回复规则",
|
|
809
|
+
style: {
|
|
810
|
+
border: "none", borderRadius: "999px", width: "22px", height: "22px", padding: "0",
|
|
811
|
+
background: rulesPinned ? "var(--vk-accent,#4b6fff)" : "rgba(128,128,128,.15)",
|
|
812
|
+
color: "inherit", cursor: "pointer", fontSize: "12px", fontWeight: 700,
|
|
813
|
+
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
|
814
|
+
},
|
|
815
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
816
|
+
onMouseEnter: () => setRulesHover(true),
|
|
817
|
+
onMouseLeave: () => setRulesHover(false),
|
|
818
|
+
onClick: () => setRulesPinned((v) => !v),
|
|
819
|
+
}, "?"),
|
|
820
|
+
h("span", { style: { fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } }, "语音自动回复规则", rulesPinned ? "(已固定,点击收起)" : "(悬停查看,点击固定)"),
|
|
821
|
+
showRules ? h("div", {
|
|
822
|
+
style: {
|
|
823
|
+
position: "absolute", top: "calc(100% + 6px)", left: "0", zIndex: 30,
|
|
824
|
+
background: "var(--dsw-specific-input-fill,#1e2128)",
|
|
825
|
+
border: "1px solid var(--dsw-alias-border-l1,#333a45)", borderRadius: "8px",
|
|
826
|
+
padding: "10px 12px", boxShadow: "0 8px 24px rgba(0,0,0,.35)",
|
|
827
|
+
fontSize: "12px", lineHeight: "1.8", color: "var(--dsw-alias-label-secondary,#9aa3ad)",
|
|
828
|
+
minWidth: "360px", maxWidth: "480px",
|
|
829
|
+
},
|
|
830
|
+
}, VOICE_RULES.map((r) => h("div", { key: r }, r))) : null,
|
|
831
|
+
),
|
|
832
|
+
// ① edge
|
|
833
|
+
vCard(ENGINE_LABELS.edge, openCards.edge, () => toggleCard("edge"),
|
|
834
|
+
vField("音色", voiceSelect("edge", eng.edge.voice, meta?.edgeVoices, (v) => setEngine("edge", { voice: v }, true)))),
|
|
835
|
+
// ② 小米 MiMo(三模型合一卡片)
|
|
836
|
+
vCard(h("span", { style: { display: "inline-flex", alignItems: "center", gap: "6px", flexWrap: "wrap" } },
|
|
837
|
+
ENGINE_LABELS.xiaomi,
|
|
838
|
+
helpTip("想让 AI 唱歌?直接对 AI 说“唱首歌/用歌声回我”,回复时自动加 (唱歌) 标签。", xmTipPinned, setXmTipPinned, xmTipHover, setXmTipHover),
|
|
839
|
+
h("span", { style: { fontSize: "12px", fontWeight: 400, color: "var(--dsw-alias-label-secondary,#9aa3ad)" } },
|
|
840
|
+
"(限时免费,请以官方为准)",
|
|
841
|
+
h("a", {
|
|
842
|
+
href: MIMO_DOC_URL, target: "_blank", rel: "noreferrer",
|
|
843
|
+
style: { color: "var(--vk-accent,#4b6fff)", textDecoration: "none" },
|
|
844
|
+
}, "MiMo 官方模型页"),
|
|
845
|
+
),
|
|
846
|
+
), openCards.xiaomi, () => toggleCard("xiaomi"),
|
|
847
|
+
() => h("div", { style: { display: "flex", flexDirection: "column", gap: "10px" } },
|
|
848
|
+
// [本地改造 2026-08-21] API Key(卡片最上;不再有模型勾选)
|
|
849
|
+
h("div", { style: { display: "flex", flexWrap: "wrap", gap: "10px" } },
|
|
850
|
+
vField("API Key", h("input", {
|
|
851
|
+
type: "password",
|
|
852
|
+
value: eng.xiaomi.apiKey,
|
|
853
|
+
onChange: (e) => setEngine("xiaomi", { apiKey: e.target.value }, true),
|
|
854
|
+
placeholder: (eng.xiaomi.apiKey !== "" || meta?.envKeys?.xiaomi) ? "已填写——输入新值可替换" : "MIMO_API_KEY",
|
|
855
|
+
style: vInput,
|
|
856
|
+
})),
|
|
857
|
+
),
|
|
858
|
+
// 语音模型:MiMo-V2.5-TTS(基础 TTS,音色 + 语言风格)
|
|
859
|
+
h("div", { style: { display: "flex", flexDirection: "column", gap: "8px", borderTop: "1px dashed var(--dsw-alias-border-l1,#333a45)", paddingTop: "8px" } },
|
|
860
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: "6px" } },
|
|
861
|
+
h("span", { style: { fontSize: "12.5px", fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)" } }, "语音模型:MiMo-V2.5-TTS"),
|
|
862
|
+
),
|
|
863
|
+
h("div", { style: { display: "flex", flexWrap: "wrap", gap: "10px" } },
|
|
864
|
+
vField("音色", voiceSelect("xiaomi", eng.xiaomi.voice, meta?.xiaomiVoices, (v) => setEngine("xiaomi", { voice: v }, true), false)),
|
|
865
|
+
vField("默认语言风格", h("div", { style: { display: "flex", gap: "6px", alignItems: "center" } },
|
|
866
|
+
h("select", {
|
|
867
|
+
value: STYLE_PRESETS.find((sp) => sp.ctx === (eng.xiaomi.context ?? ""))?.key ?? "",
|
|
868
|
+
onChange: (e) => {
|
|
869
|
+
const hit = STYLE_PRESETS.find((sp) => sp.key === e.target.value);
|
|
870
|
+
setEngine("xiaomi", { context: hit ? hit.ctx : "" }, true);
|
|
871
|
+
},
|
|
872
|
+
style: { ...vInput, flex: 1 },
|
|
873
|
+
},
|
|
874
|
+
STYLE_PRESETS.map((sp) => h("option", { key: sp.key || "nat", value: sp.key }, sp.label))),
|
|
875
|
+
previewBtn("style", "试听", () => previewVoice("xiaomi", eng.xiaomi.voice, eng.xiaomi.context ?? "", undefined, "style")),
|
|
876
|
+
)),
|
|
877
|
+
),
|
|
878
|
+
),
|
|
879
|
+
// 克隆模型:MiMo-V2.5-TTS-VoiceDesign(官方示例,始终显示)
|
|
880
|
+
h("div", { style: { display: "flex", flexDirection: "column", gap: "8px", borderTop: "1px dashed var(--dsw-alias-border-l1,#333a45)", paddingTop: "8px" } },
|
|
881
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: "6px" } },
|
|
882
|
+
h("span", { style: { fontSize: "12.5px", fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)" } }, "克隆模型:MiMo-V2.5-TTS-VoiceDesign"),
|
|
883
|
+
helpTip("「音色设计 VoiceDesign」由 AI 根据对话情境自动编写音色描述(无需你填写):比如你说「用低沉的声音念这首诗」,AI 会自己写一段音色描述(年龄段+性别+质感+语速+情绪)再念。开启后 AI 还会自觉用语音表达情绪:任务成功时兴奋道喜、你生气时委屈道歉、你难过时温柔安慰等。下方示例是官方效果,点播放即可试听。", designTipPinned, setDesignTipPinned, designTipHover, setDesignTipHover, "center", "top"),
|
|
884
|
+
),
|
|
885
|
+
h("div", { style: { fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } }, "官方示例"),
|
|
886
|
+
VOICE_DESIGN_EXAMPLES.map((ex, i) => h("div", { key: ex.title, style: { border: "1px solid var(--dsw-alias-border-l1,#333a45)", borderRadius: "8px", padding: "8px 10px", display: "flex", flexDirection: "column", gap: "6px" } },
|
|
887
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: "8px" } },
|
|
888
|
+
h("span", { style: { fontSize: "13px", fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)", flex: "none" } }, ex.title),
|
|
889
|
+
helpTip(
|
|
890
|
+
h("div", { style: { display: "flex", flexDirection: "column", gap: "8px" } },
|
|
891
|
+
h("div", null, h("span", { style: { fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)" } }, "Instruct:"), ex.instruct),
|
|
892
|
+
h("div", null, h("span", { style: { fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)" } }, "Text:"), ex.text),
|
|
893
|
+
),
|
|
894
|
+
vdExamplePins[i], (v) => { const n = [...vdExamplePins]; n[i] = v; setVdExamplePins(n); },
|
|
895
|
+
vdExampleHovers[i], (v) => { const n = [...vdExampleHovers]; n[i] = v; setVdExampleHovers(n); },
|
|
896
|
+
"left", "top",
|
|
897
|
+
),
|
|
898
|
+
),
|
|
899
|
+
h("audio", {
|
|
900
|
+
controls: true, preload: "none",
|
|
901
|
+
src: vdSamples[i] !== undefined ? "data:" + vdSamples[i].mediaType + ";base64," + vdSamples[i].data : undefined,
|
|
902
|
+
style: { width: "100%", height: "32px" },
|
|
903
|
+
}),
|
|
904
|
+
)),
|
|
905
|
+
),
|
|
906
|
+
// 克隆模型:MiMo-V2.5-TTS-VoiceClone(样本管理,始终显示)
|
|
907
|
+
h("div", { style: { display: "flex", flexDirection: "column", gap: "6px", borderTop: "1px dashed var(--dsw-alias-border-l1,#333a45)", paddingTop: "8px" } },
|
|
908
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: "6px" } },
|
|
909
|
+
h("span", { style: { fontSize: "12.5px", fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)" } }, "克隆模型:MiMo-V2.5-TTS-VoiceClone"),
|
|
910
|
+
helpTip("克隆音色与预置音色(冰糖等)互斥:在「默认语音引擎」里选择「小米克隆(VoiceClone)」后,默认回复一律使用下方克隆声音;开启 VoiceDesign 时,AI 会在克隆底嗓上叠加情感指令(如「用委屈撒娇的语气」),克隆声同样带情感。", cloneListTipPinned, setCloneListTipPinned, cloneListTipHover, setCloneListTipHover, "center", "top"),
|
|
911
|
+
),
|
|
912
|
+
cloneSamples.length > 0 ? h("div", { style: { display: "flex", flexDirection: "column", gap: "6px" } },
|
|
913
|
+
h("div", { style: { fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } }, "已保存的克隆音色:"),
|
|
914
|
+
cloneSamples.map((sp) => h("div", { key: sp.id, style: { display: "flex", alignItems: "center", gap: "8px", border: "1px solid var(--dsw-alias-border-l1,#333a45)", borderRadius: "8px", padding: "6px 10px", fontSize: "12.5px" } },
|
|
915
|
+
h("span", { style: { fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)", flex: "none" } }, sp.name ?? "样本"),
|
|
916
|
+
h("span", { style: { color: "var(--dsw-alias-label-secondary,#9aa3ad)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1 } }, sp.path ?? ""),
|
|
917
|
+
previewBtn("clone:" + sp.id, "试听克隆合成效果(统一文本)", () => previewVoice("voiceclone", undefined, undefined, sp.path, "clone:" + sp.id, { text: CLONE_PREVIEW_TEXT })),
|
|
918
|
+
previewBtn("clone-src:" + sp.id, "试听原始音频(对比克隆还原度)", () => previewSourceVoice(sp.path, "clone-src:" + sp.id), "▶"),
|
|
919
|
+
h("button", {
|
|
920
|
+
type: "button", "aria-label": "删除", title: "删除此克隆音色",
|
|
921
|
+
style: { border: "none", borderRadius: "6px", width: "28px", height: "28px", flex: "none", background: "rgba(229,72,77,.15)", color: "#e5484d", cursor: "pointer", fontSize: "14px" },
|
|
922
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
923
|
+
onClick: () => setEngine("voiceclone", { samples: cloneSamples.filter((x) => x.id !== sp.id) }, true),
|
|
924
|
+
}, "✕"),
|
|
925
|
+
)),
|
|
926
|
+
) : h("div", { style: { fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)", lineHeight: 1.7 } },
|
|
927
|
+
"无(尚未添加克隆音色)。",
|
|
928
|
+
),
|
|
929
|
+
// [本地改造 2026-08-21] 添加克隆音色:选音频 → 命名 → 上传
|
|
930
|
+
h("div", { style: { display: "flex", flexDirection: "column", gap: "6px", borderTop: "1px dashed var(--dsw-alias-border-l1,#333a45)", paddingTop: "8px" } },
|
|
931
|
+
h("div", { style: { fontSize: "12.5px", fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)" } }, "添加克隆音色"),
|
|
932
|
+
h("div", { style: { fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } }, "支持 mp3 / wav,Base64 后 ≤10MB(官方限制);参考语音建议 15-60 秒、单人纯人声无背景音乐,越长克隆越准。"),
|
|
933
|
+
h("div", { style: { display: "flex", flexWrap: "wrap", gap: "8px", alignItems: "center" } },
|
|
934
|
+
vField("名称", h("input", { value: cloneName, onChange: (e) => setCloneName(e.target.value), placeholder: "如:我的声音(留空用文件名)", style: { ...vInput, width: "100%" } })),
|
|
935
|
+
h("button", {
|
|
936
|
+
type: "button", onClick: () => cloneFileRef.current?.click(), disabled: addingClone,
|
|
937
|
+
style: { background: "var(--vk-accent,#4b6fff)", color: "#fff", border: "none", borderRadius: "999px", padding: "7px 16px", fontSize: "12.5px", fontWeight: 600, cursor: "pointer", flex: "none" },
|
|
938
|
+
}, addingClone ? "添加中…" : "选择音频文件添加"),
|
|
939
|
+
h("input", { ref: cloneFileRef, type: "file", accept: ".mp3,.wav,audio/mpeg,audio/wav", style: { display: "none" }, onChange: (e) => { const f = e.target.files && e.target.files[0]; if (f !== undefined && f !== null) void addCloneSample(f); } }),
|
|
940
|
+
),
|
|
941
|
+
cloneAddMsg !== null ? h("div", { style: { fontSize: "12px", color: cloneAddMsg.ok ? "#73c991" : "#f14c4c" } }, cloneAddMsg.text) : null,
|
|
942
|
+
),
|
|
943
|
+
),
|
|
944
|
+
)),
|
|
945
|
+
// ③ 本地 TTS(与其他卡片一致:勾选后才显示配置字段)
|
|
946
|
+
vCard(h("span", { style: { display: "inline-flex", alignItems: "center", gap: "6px", flexWrap: "wrap" } },
|
|
947
|
+
ENGINE_LABELS.local,
|
|
948
|
+
helpTip("本地模型常驻内存(CPU 推理)。填本地命令(每次调用启动进程,较慢);或填 HTTP 服务地址(推荐,模型常驻一次加载后快)。两者都填时 HTTP 优先;留空则跳过本地引擎。", localTipPinned, setLocalTipPinned, localTipHover, setLocalTipHover, "center"),
|
|
949
|
+
), openCards.local, () => toggleCard("local"),
|
|
950
|
+
[
|
|
951
|
+
h("div", { style: { display: "flex", flexWrap: "wrap", gap: "10px" } },
|
|
952
|
+
vField("本地命令(每次调用启动进程)", h("input", { value: eng.local.cmd ?? "", onChange: (e) => setEngine("local", { cmd: e.target.value }, true), placeholder: "如 melo-tts.exe --text {text} --out {out}", style: vInput })),
|
|
953
|
+
vField("HTTP 服务地址(常驻模式)", h("input", { value: eng.local.url ?? "", onChange: (e) => setEngine("local", { url: e.target.value }, true), placeholder: "如 http://127.0.0.1:5000/tts(POST {text} 返回音频)", style: vInput })),
|
|
954
|
+
),
|
|
955
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: "8px" } },
|
|
956
|
+
previewBtn("local-preview", "试听本地 TTS", () => previewVoice("local", undefined, undefined, undefined, "local-preview", { cmd: eng.local.cmd ?? "", url: eng.local.url ?? "" })),
|
|
957
|
+
h("span", { style: { fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } }, "点击试听(用上方填的命令/地址合成)"),
|
|
958
|
+
),
|
|
959
|
+
]),
|
|
960
|
+
// ④ 阿里 qwen3-tts
|
|
961
|
+
vCard(ENGINE_LABELS.ali, openCards.ali, () => toggleCard("ali"),
|
|
962
|
+
h("div", { style: { display: "flex", flexWrap: "wrap", gap: "10px" } },
|
|
963
|
+
vField("API Key", h("input", {
|
|
964
|
+
type: "password",
|
|
965
|
+
value: eng.ali.apiKey ?? "",
|
|
966
|
+
onChange: (e) => setEngine("ali", { apiKey: e.target.value }, true),
|
|
967
|
+
placeholder: (eng.ali.apiKey !== "" || meta?.envKeys?.ali) ? "已填写——输入新值可替换" : "dashscope API Key",
|
|
968
|
+
style: vInput,
|
|
969
|
+
})),
|
|
970
|
+
vField("音色", voiceSelect("ali", eng.ali.voice ?? "Cherry", meta?.aliVoices, (v) => setEngine("ali", { voice: v }, true))),
|
|
971
|
+
)),
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// ── [本地改造 2026-08-21] 语音条尾部「复制转写」按钮 ─────────────
|
|
976
|
+
// 挂在 conversation.chat.voice-actions 槽(核心补的挂点):按钮渲染在语音条
|
|
977
|
+
// (VoiceCard)内部、转写文本之后,样式对齐系统复制按钮(28px 圆形透明、
|
|
978
|
+
// hover 变背景;图标 14px)。
|
|
979
|
+
const actionCopySvg = h("svg", { viewBox: "0 0 16 16", width: "14", height: "14", "aria-hidden": true },
|
|
980
|
+
h("rect", { x: "5.5", y: "5.5", width: "7", height: "7", rx: "1.2", fill: "none", stroke: "currentColor", strokeWidth: "1.3" }),
|
|
981
|
+
h("path", { d: "M10.5 5.5V4.5A1 1 0 0 0 9.5 3.5H5A1 1 0 0 0 4 4.5v4.5a1 1 0 0 0 1 1h1", fill: "none", stroke: "currentColor", strokeWidth: "1.3" }),
|
|
982
|
+
);
|
|
983
|
+
const actionCheckSvg = h("svg", { viewBox: "0 0 16 16", width: "14", height: "14", "aria-hidden": true },
|
|
984
|
+
h("path", { d: "M3.5 8.5L6.5 11.5L12.5 4.5", fill: "none", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round", strokeLinejoin: "round" }),
|
|
985
|
+
);
|
|
986
|
+
function VoiceCopyTranscriptAction(props) {
|
|
987
|
+
const transcript = props.transcript;
|
|
988
|
+
const [copied, setCopied] = react.useState(false);
|
|
989
|
+
if (typeof transcript !== "string" || transcript === "") return null;
|
|
990
|
+
const onCopy = () => {
|
|
991
|
+
const done = () => { setCopied(true); setTimeout(() => setCopied(false), 1200); };
|
|
992
|
+
if (typeof navigator !== "undefined" && navigator.clipboard && navigator.clipboard.writeText) {
|
|
993
|
+
navigator.clipboard.writeText(transcript).then(done, done);
|
|
994
|
+
} else { done(); }
|
|
995
|
+
};
|
|
996
|
+
const label = copied ? "已复制转写文本" : "复制转写文本";
|
|
997
|
+
const style = {
|
|
998
|
+
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
|
999
|
+
width: "28px", height: "28px", padding: "6px", border: "none",
|
|
1000
|
+
borderRadius: "28px", background: "transparent",
|
|
1001
|
+
color: "var(--dsw-alias-label-tertiary)", cursor: "pointer",
|
|
1002
|
+
flexShrink: 0,
|
|
1003
|
+
};
|
|
1004
|
+
return h("button", {
|
|
1005
|
+
type: "button",
|
|
1006
|
+
onClick: onCopy,
|
|
1007
|
+
title: label,
|
|
1008
|
+
"aria-label": label,
|
|
1009
|
+
style,
|
|
1010
|
+
onMouseEnter: (e) => {
|
|
1011
|
+
e.currentTarget.style.background = "var(--dsw-alias-interactive-bg-hover)";
|
|
1012
|
+
e.currentTarget.style.color = "var(--dsw-alias-label-secondary)";
|
|
1013
|
+
},
|
|
1014
|
+
onMouseLeave: (e) => {
|
|
1015
|
+
e.currentTarget.style.background = "transparent";
|
|
1016
|
+
e.currentTarget.style.color = "var(--dsw-alias-label-tertiary)";
|
|
1017
|
+
},
|
|
1018
|
+
}, copied ? actionCheckSvg : actionCopySvg);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
const inject = ["slots"];
|
|
1022
|
+
|
|
1023
|
+
function apply(ctx) {
|
|
1024
|
+
const getConnection = () => ctx.get("connection");
|
|
1025
|
+
ctx.effect(() => {
|
|
1026
|
+
const disposers = [
|
|
1027
|
+
// 附件槽:priority:-1 覆盖官方(lowest renders;官方默认 0 不冲突)
|
|
1028
|
+
ctx.slots.inject("conversation.input.attachments", () => ctx.slots.register({
|
|
1029
|
+
name: "conversation.input.attachments",
|
|
1030
|
+
id: "composer-attachments-overlay",
|
|
1031
|
+
priority: -1,
|
|
1032
|
+
locale: "conversation",
|
|
1033
|
+
inject: (sessionId) => ({ connection: getConnection(), sessionId }),
|
|
1034
|
+
}, ComposerAttachmentsOverlay)),
|
|
1035
|
+
ctx.slots.inject("conversation.input.left", () => ctx.slots.register({
|
|
1036
|
+
name: "conversation.input.left",
|
|
1037
|
+
id: "composer-left",
|
|
1038
|
+
order: 10,
|
|
1039
|
+
locale: "conversation",
|
|
1040
|
+
inject: (sessionId) => ({ connection: getConnection(), sessionId }),
|
|
1041
|
+
}, ToolbarLeft)),
|
|
1042
|
+
ctx.slots.inject("conversation.input.right", () => ctx.slots.register({
|
|
1043
|
+
name: "conversation.input.right",
|
|
1044
|
+
id: "composer-balance",
|
|
1045
|
+
order: -10,
|
|
1046
|
+
locale: "conversation",
|
|
1047
|
+
inject: (sessionId) => ({ connection: getConnection(), sessionId }),
|
|
1048
|
+
}, BalanceMeter)),
|
|
1049
|
+
// 设置页「语音服务」分区(settings.section 槽)
|
|
1050
|
+
ctx.slots.inject("settings.section", () => ctx.slots.register({
|
|
1051
|
+
name: "settings.section",
|
|
1052
|
+
id: "voice",
|
|
1053
|
+
order: 4,
|
|
1054
|
+
label: () => "语音服务",
|
|
1055
|
+
}, VoiceSettingsSection)),
|
|
1056
|
+
// [本地改造 2026-08-21] 语音条尾部「复制转写」按钮(voice-actions 槽,
|
|
1057
|
+
// 渲染在语音卡内转写文本之后;样式对齐系统复制按钮)
|
|
1058
|
+
ctx.slots.inject("conversation.chat.voice-actions", () => ctx.slots.register({
|
|
1059
|
+
name: "conversation.chat.voice-actions",
|
|
1060
|
+
id: "voice-copy-transcript",
|
|
1061
|
+
order: 0,
|
|
1062
|
+
}, VoiceCopyTranscriptAction)),
|
|
1063
|
+
];
|
|
1064
|
+
return () => { for (const d of disposers) d(); };
|
|
1065
|
+
}, "dsh-input-tools: toolbar");
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
exports.apply = apply;
|
|
1069
|
+
exports.inject = inject;
|
|
1070
|
+
return module.exports;
|
|
1071
|
+
}
|
|
1072
|
+
});
|