@oadank/dsh-input-tools 0.3.19 → 0.3.21
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 +28 -4
- package/assets/vision-test.jpg +0 -0
- package/lib/client.js +2008 -1553
- package/lib/index.js +1932 -1738
- package/package.json +7 -3
- package/scripts/install-asr.ps1 +71 -18
- package/scripts/install-local-tts.ps1 +52 -18
package/lib/client.js
CHANGED
|
@@ -1,1554 +1,2009 @@
|
|
|
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
|
-
// [2026-08-21] draft 图片共享:attachments 槽挂载时把当前 draft 图(ComposerAttachment[])
|
|
36
|
-
// 与移除回调存入模块级,语音发送时可一起带上、发完清掉(解决"选了图发语音图被留下")。
|
|
37
|
-
let sharedDraftImages = [];
|
|
38
|
-
let sharedRemoveImage = null;
|
|
39
|
-
|
|
40
|
-
// ── [2026-08-21] 语音气泡(聊天界面 DOM 注入,安装即用,不依赖 dsh 源码支持)────
|
|
41
|
-
// 录音 → 存服务器(/voice/outbox/save)→ ASR 转文本 → 发【用户语音】标记文本;
|
|
42
|
-
// observer 发现带标记的消息 → 注入语音条(可播放)。dsh 原生支持 voice 的版本
|
|
43
|
-
// (rc.8 本地改造)走多模态直发,消息本身没有该标记,不会触发注入(官方渲染语音条)。
|
|
44
|
-
let voiceBubbleStarted = false;
|
|
45
|
-
const pendingVoiceQueue = []; // [{ voiceId, ext }] 待消费的录音(FIFO)
|
|
46
|
-
const injectedVoiceEls = new WeakSet(); // 已注入的元素
|
|
47
|
-
const VOICE_MSG_MARK = "【用户语音】";
|
|
48
|
-
|
|
49
|
-
function startVoiceBubbleObserver() {
|
|
50
|
-
if (voiceBubbleStarted || typeof MutationObserver === "undefined") return;
|
|
51
|
-
voiceBubbleStarted = true;
|
|
52
|
-
const tryInject = () => {
|
|
53
|
-
const els = Array.from(document.querySelectorAll("div,span,p,li"));
|
|
54
|
-
for (const el of els) {
|
|
55
|
-
if (injectedVoiceEls.has(el)) continue;
|
|
56
|
-
if (el.querySelector("audio[data-voice-bubble]")) { injectedVoiceEls.add(el); continue; }
|
|
57
|
-
const text = el.textContent ?? "";
|
|
58
|
-
// [2026-08-21] AI 语音回复:**已禁用**。DOM 注入在 React 重渲染下会随 Tool call 展开/折叠
|
|
59
|
-
// 重复注入、位置漂移、无限累积(用户实测图1-4),修不干净。AI 语音条走源码版(voice/reply
|
|
60
|
-
// 事件原生渲染);rc.7 上 AI 语音音频已生成但界面不显示,属 rc.7 硬伤,引导用户使用源码版。
|
|
61
|
-
// if (text.includes("语音已发送") && text.includes("voiceId") && text.includes("sha256:")) { ... 注入 ... }
|
|
62
|
-
if (!text.includes(VOICE_MSG_MARK)) continue;
|
|
63
|
-
// 只处理"叶子级"文本块:若子元素已含标记(父容器),跳过避免重复注入
|
|
64
|
-
let childHasMark = false;
|
|
65
|
-
for (const c of el.children) {
|
|
66
|
-
if ((c.textContent ?? "").includes(VOICE_MSG_MARK)) { childHasMark = true; break; }
|
|
67
|
-
}
|
|
68
|
-
if (childHasMark) continue;
|
|
69
|
-
const meta = pendingVoiceQueue.shift();
|
|
70
|
-
if (meta) injectVoiceCard(el, `/voice/outbox/${meta.voiceId}.${meta.ext || "webm"}`, 1); /* 用户消息气泡内 */
|
|
71
|
-
injectedVoiceEls.add(el);
|
|
72
|
-
}
|
|
73
|
-
};
|
|
74
|
-
const obs = new MutationObserver(() => tryInject());
|
|
75
|
-
obs.observe(document.body, { childList: true, subtree: true, characterData: true });
|
|
76
|
-
tryInject();
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function injectVoiceCard(anchorEl, audioSrc, hop = 1) {
|
|
80
|
-
try {
|
|
81
|
-
// [2026-08-21 修] 不再硬编码爬 7 层(之前导致 AI 语音条藏到 Tool call 折叠块里)——
|
|
82
|
-
// 改为可指定爬层数:
|
|
83
|
-
// 1 = 用户消息:爬 1 层到消息气泡内(气泡可能就在文本的父级)
|
|
84
|
-
// 6 = AI 语音回复:爬 6 层穿透 Tool call 折叠卡到主 assistant message 行
|
|
85
|
-
let host = anchorEl;
|
|
86
|
-
for (let i = 0; i < hop && host.parentElement; i++) host = host.parentElement;
|
|
87
|
-
if (!host || host.querySelector("audio[data-voice-bubble]")) return;
|
|
88
|
-
const audio = document.createElement("audio");
|
|
89
|
-
audio.src = audioSrc;
|
|
90
|
-
audio.preload = "metadata";
|
|
91
|
-
audio.dataset.voiceBubble = "1";
|
|
92
|
-
const btn = document.createElement("button");
|
|
93
|
-
btn.type = "button";
|
|
94
|
-
btn.style.cssText = "display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;border:none;border-radius:50%;background:rgba(229,72,77,.18);color:#e5484d;cursor:pointer;font-size:14px;flex:none;line-height:1;";
|
|
95
|
-
btn.textContent = "▶";
|
|
96
|
-
btn.onclick = () => { if (audio.paused) { void audio.play(); btn.textContent = "⏸"; } else { audio.pause(); btn.textContent = "▶"; } };
|
|
97
|
-
audio.onended = () => { btn.textContent = "▶"; };
|
|
98
|
-
audio.onerror = () => { btn.textContent = "⚠"; btn.title = "音频加载失败"; };
|
|
99
|
-
const dur = document.createElement("span");
|
|
100
|
-
dur.style.cssText = "font-size:11px;opacity:.75;min-width:26px;";
|
|
101
|
-
audio.onloadedmetadata = () => {
|
|
102
|
-
const s = Math.round(audio.duration || 0);
|
|
103
|
-
dur.textContent = s ? `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}` : "";
|
|
104
|
-
};
|
|
105
|
-
const card = document.createElement("div");
|
|
106
|
-
card.style.cssText = "display:inline-flex;align-items:center;gap:8px;background:rgba(229,72,77,.1);border:1px solid rgba(229,72,77,.28);border-radius:999px;padding:4px 12px;margin-top:6px;width:fit-content;max-width:260px;align-self:flex-start;";
|
|
107
|
-
card.append(btn, dur);
|
|
108
|
-
host.appendChild(card);
|
|
109
|
-
} catch { /* 注入失败不影响消息 */ }
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// ── 源码 SVG 图标 ────────────────────────────────────────────
|
|
113
|
-
const svgProps = { viewBox: "0 0 16 16", width: "14", height: "14", "aria-hidden": true };
|
|
114
|
-
const imageIcon = h("svg", svgProps,
|
|
115
|
-
h("rect", { x: "2.5", y: "3.5", width: "11", height: "9", rx: "2", fill: "none", stroke: "currentColor", strokeWidth: "1.4" }),
|
|
116
|
-
h("circle", { cx: "6", cy: "7.5", r: "1.5", fill: "currentColor" }),
|
|
117
|
-
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" }),
|
|
118
|
-
);
|
|
119
|
-
const micIcon = h("svg", svgProps,
|
|
120
|
-
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" }),
|
|
121
|
-
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" }),
|
|
122
|
-
);
|
|
123
|
-
const cancelIcon = h("svg", svgProps,
|
|
124
|
-
h("path", { d: "M4 4L12 12M12 4L4 12", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round" }),
|
|
125
|
-
);
|
|
126
|
-
|
|
127
|
-
// ── 工具行按钮样式:圆形底,间距 16px(与源码 .tools gap 一致) ─────
|
|
128
|
-
const circleBtn = {
|
|
129
|
-
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
|
130
|
-
width: "32px", height: "32px", padding: "0", border: "none",
|
|
131
|
-
borderRadius: "999px",
|
|
132
|
-
background: "rgba(128,128,128,.16)",
|
|
133
|
-
color: "inherit", cursor: "pointer",
|
|
134
|
-
transition: "background-color .15s",
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
// ── 左工具行:图片(官方 draft 链路)+ 语音 ─────────────────────
|
|
138
|
-
function ToolbarLeft({ connection, sessionId }) {
|
|
139
|
-
const [recording, setRecording] = useState(false);
|
|
140
|
-
const [seconds, setSeconds] = useState(0);
|
|
141
|
-
const [voiceError, setVoiceError] = useState(null); // 语音发送失败提示
|
|
142
|
-
const voiceErrorTimerRef = useRef(null);
|
|
143
|
-
const recorderRef = useRef(null);
|
|
144
|
-
const chunksRef = useRef([]);
|
|
145
|
-
const timerRef = useRef(null);
|
|
146
|
-
const fileRef = useRef(null);
|
|
147
|
-
const voiceSupported = typeof navigator !== "undefined" && typeof MediaRecorder !== "undefined";
|
|
148
|
-
// [2026-08-21] 语音气泡注入:页面一挂载就监听消息流,给【用户语音】消息贴语音条
|
|
149
|
-
useEffect(() => { startVoiceBubbleObserver(); }, []);
|
|
150
|
-
|
|
151
|
-
const sendVoiceBlob = useCallback(async (blob) => {
|
|
152
|
-
if (connection === undefined) return;
|
|
153
|
-
const mediaType = blob.type.split(";")[0] || "audio/webm";
|
|
154
|
-
const reader = new FileReader();
|
|
155
|
-
const data = await new Promise((resolve, reject) => {
|
|
156
|
-
reader.onload = () => resolve(String(reader.result).split(",")[1] ?? "");
|
|
157
|
-
reader.onerror = reject;
|
|
158
|
-
reader.readAsDataURL(blob);
|
|
159
|
-
});
|
|
160
|
-
const fail = (msg) => {
|
|
161
|
-
setVoiceError(msg);
|
|
162
|
-
if (voiceErrorTimerRef.current !== null) window.clearTimeout(voiceErrorTimerRef.current);
|
|
163
|
-
voiceErrorTimerRef.current = window.setTimeout(() => setVoiceError(null), 6000);
|
|
164
|
-
};
|
|
165
|
-
// [2026-08-21] draft 图片转 image content(File→base64),语音可与图片一起发送
|
|
166
|
-
const draftImageContents = async () => {
|
|
167
|
-
const imgs = Array.isArray(sharedDraftImages) ? sharedDraftImages : [];
|
|
168
|
-
const out = [];
|
|
169
|
-
for (const a of imgs) {
|
|
170
|
-
const file = a?.file;
|
|
171
|
-
if (!file) continue;
|
|
172
|
-
const b64 = await new Promise((resolve) => {
|
|
173
|
-
const r = new FileReader();
|
|
174
|
-
r.onload = () => resolve(String(r.result).split(",")[1] ?? "");
|
|
175
|
-
r.onerror = () => resolve("");
|
|
176
|
-
r.readAsDataURL(file);
|
|
177
|
-
});
|
|
178
|
-
if (b64 !== "") out.push({ type: "image", mediaType: file.type || "image/jpeg", data: b64, name: file.name });
|
|
179
|
-
}
|
|
180
|
-
return out;
|
|
181
|
-
};
|
|
182
|
-
// [2026-08-21] 语音发送成功后清掉 draft 图片(否则图还留在输入框上)
|
|
183
|
-
const clearDraftImages = () => {
|
|
184
|
-
const imgs = Array.isArray(sharedDraftImages) ? sharedDraftImages : [];
|
|
185
|
-
if (typeof sharedRemoveImage === "function") {
|
|
186
|
-
for (const a of imgs) { try { sharedRemoveImage(a.id); } catch { /* ignore */ } }
|
|
187
|
-
}
|
|
188
|
-
sharedDraftImages = [];
|
|
189
|
-
};
|
|
190
|
-
const sendAsText = async (text, images) => {
|
|
191
|
-
// [2026-08-21] 降级路径:XDN(npm rc.7) 不支持 voice content。带【用户语音】标记让 AI
|
|
192
|
-
// 知道这是语音转的文本,可以按规则(自动 TTS)回复。
|
|
193
|
-
const marked = "【用户语音】" + text;
|
|
194
|
-
const response = await connection.api.sessions.prompt({
|
|
195
|
-
sessionId, mode: "queue",
|
|
196
|
-
content: [{ type: "text", text: marked }, ...images],
|
|
197
|
-
});
|
|
198
|
-
const result = response?.result;
|
|
199
|
-
if (!result || !result.ok) {
|
|
200
|
-
fail((result?.error && typeof result.error.message === "string" && result.error.message !== "")
|
|
201
|
-
? result.error.message : "语音发送失败,请重试");
|
|
202
|
-
}
|
|
203
|
-
};
|
|
204
|
-
const sendAsVoice = async (images) => {
|
|
205
|
-
// 首选:多模态直发(AI 能听原音,消息渲染为语音气泡)——本机 lecoo / dev rc.8 支持
|
|
206
|
-
const response = await connection.api.sessions.prompt({
|
|
207
|
-
sessionId, mode: "queue",
|
|
208
|
-
content: [{ type: "voice", mediaType, data }, ...images],
|
|
209
|
-
});
|
|
210
|
-
return response?.result;
|
|
211
|
-
};
|
|
212
|
-
try {
|
|
213
|
-
const images = await draftImageContents();
|
|
214
|
-
// [2026-08-21 修] 先直发 voice,失败时降级 ASR 转文本(rc.7 兼容)。
|
|
215
|
-
// 这样本机/rc.8 享受多模态(AI 听到原音 + 语音消息气泡),XDN/rc.7 自动降级不报错。
|
|
216
|
-
let result;
|
|
217
|
-
try {
|
|
218
|
-
result = await sendAsVoice(images);
|
|
219
|
-
} catch (voiceErr) {
|
|
220
|
-
result = null;
|
|
221
|
-
}
|
|
222
|
-
// 直发成功(rc.8/dev):result.ok true
|
|
223
|
-
if (result && result.ok) { clearDraftImages(); return; }
|
|
224
|
-
// 失败或不支持:尝试降级
|
|
225
|
-
const errMsg = result?.error?.message ?? "";
|
|
226
|
-
// 只有"contract/payload"类错误才降级;其他业务错误直接提示
|
|
227
|
-
const isContractError = /invalid payload|schema|contract|not supported|unsupported/i.test(errMsg);
|
|
228
|
-
if (!isContractError && result && !result.ok) {
|
|
229
|
-
fail(errMsg || "语音发送失败,请重试");
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
|
-
// 走 ASR 转文本(降级路径:dsh 契约不支持 voice content)
|
|
233
|
-
// 先把录音存到服务器(语音气泡数据源),再识别再发【用户语音】标记文本
|
|
234
|
-
try {
|
|
235
|
-
const sv = await (await fetch("/voice/outbox/save", {
|
|
236
|
-
method: "POST", headers: { "content-type": "application/json" },
|
|
237
|
-
body: JSON.stringify({ audioBase64: data, mediaType }),
|
|
238
|
-
})).json().catch(() => ({}));
|
|
239
|
-
if (sv?.ok) pendingVoiceQueue.push({ voiceId: sv.voiceId, ext: sv.ext ?? "webm" });
|
|
240
|
-
} catch { /* 存档失败不阻塞发送 */ }
|
|
241
|
-
const tr = await fetch("/asr/transcribe", {
|
|
242
|
-
method: "POST", headers: { "content-type": "application/json" },
|
|
243
|
-
body: JSON.stringify({ audioBase64: data, mediaType }),
|
|
244
|
-
});
|
|
245
|
-
const td = await tr.json().catch(() => ({}));
|
|
246
|
-
if (!td?.ok) { fail(td?.error ?? "语音识别失败,请检查 ASR 配置"); return; }
|
|
247
|
-
const text = typeof td?.text === "string" ? td.text.trim() : "";
|
|
248
|
-
if (text === "") { fail("没听清,请再说一次"); return; }
|
|
249
|
-
await sendAsText(text, images);
|
|
250
|
-
clearDraftImages();
|
|
251
|
-
} catch (e) {
|
|
252
|
-
fail(String(e && typeof e.message === "string" && e.message !== "" ? e.message : e));
|
|
253
|
-
}
|
|
254
|
-
}, [connection, sessionId]);
|
|
255
|
-
|
|
256
|
-
const stopRecording = useCallback((send) => {
|
|
257
|
-
clearInterval(timerRef.current);
|
|
258
|
-
timerRef.current = null;
|
|
259
|
-
const recorder = recorderRef.current;
|
|
260
|
-
recorderRef.current = null;
|
|
261
|
-
if (recorder !== null && recorder.state !== "inactive") {
|
|
262
|
-
if (send) recorder.stop();
|
|
263
|
-
else { recorder.onstop = null; try { recorder.stop(); } catch { /* ignore */ } }
|
|
264
|
-
}
|
|
265
|
-
setRecording(false);
|
|
266
|
-
setSeconds(0);
|
|
267
|
-
}, []);
|
|
268
|
-
|
|
269
|
-
const startRecording = useCallback(async () => {
|
|
270
|
-
if (connection === undefined || sessionId === undefined) return;
|
|
271
|
-
try {
|
|
272
|
-
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
273
|
-
const recorder = new MediaRecorder(stream);
|
|
274
|
-
chunksRef.current = [];
|
|
275
|
-
recorder.ondataavailable = (event) => { if (event.data.size > 0) chunksRef.current.push(event.data); };
|
|
276
|
-
recorder.onstop = () => {
|
|
277
|
-
const blob = new Blob(chunksRef.current, { type: recorder.mimeType || "audio/webm" });
|
|
278
|
-
chunksRef.current = [];
|
|
279
|
-
if (blob.size > 0) void sendVoiceBlob(blob);
|
|
280
|
-
stream.getTracks().forEach((t) => t.stop());
|
|
281
|
-
};
|
|
282
|
-
recorder.start();
|
|
283
|
-
recorderRef.current = recorder;
|
|
284
|
-
setRecording(true);
|
|
285
|
-
setSeconds(0);
|
|
286
|
-
timerRef.current = setInterval(() => setSeconds((s) => s + 1), 1000);
|
|
287
|
-
} catch { /* 权限拒绝 */ }
|
|
288
|
-
}, [connection, sessionId, sendVoiceBlob]);
|
|
289
|
-
|
|
290
|
-
//
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
style:
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
},
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
},
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
const
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
{
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
},
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
},
|
|
504
|
-
{
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
},
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
const
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
const
|
|
605
|
-
//
|
|
606
|
-
const [
|
|
607
|
-
const [
|
|
608
|
-
|
|
609
|
-
const
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
//
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
return;
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
})
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
})
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
style: {
|
|
928
|
-
position: "
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
}
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
const
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
);
|
|
1031
|
-
// [本地改造 2026-08-
|
|
1032
|
-
const
|
|
1033
|
-
const
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
),
|
|
1090
|
-
h("
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
)
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
}
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
),
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
),
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
const
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
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
|
+
// [2026-08-21] draft 图片共享:attachments 槽挂载时把当前 draft 图(ComposerAttachment[])
|
|
36
|
+
// 与移除回调存入模块级,语音发送时可一起带上、发完清掉(解决"选了图发语音图被留下")。
|
|
37
|
+
let sharedDraftImages = [];
|
|
38
|
+
let sharedRemoveImage = null;
|
|
39
|
+
|
|
40
|
+
// ── [2026-08-21] 语音气泡(聊天界面 DOM 注入,安装即用,不依赖 dsh 源码支持)────
|
|
41
|
+
// 录音 → 存服务器(/voice/outbox/save)→ ASR 转文本 → 发【用户语音】标记文本;
|
|
42
|
+
// observer 发现带标记的消息 → 注入语音条(可播放)。dsh 原生支持 voice 的版本
|
|
43
|
+
// (rc.8 本地改造)走多模态直发,消息本身没有该标记,不会触发注入(官方渲染语音条)。
|
|
44
|
+
let voiceBubbleStarted = false;
|
|
45
|
+
const pendingVoiceQueue = []; // [{ voiceId, ext }] 待消费的录音(FIFO)
|
|
46
|
+
const injectedVoiceEls = new WeakSet(); // 已注入的元素
|
|
47
|
+
const VOICE_MSG_MARK = "【用户语音】";
|
|
48
|
+
|
|
49
|
+
function startVoiceBubbleObserver() {
|
|
50
|
+
if (voiceBubbleStarted || typeof MutationObserver === "undefined") return;
|
|
51
|
+
voiceBubbleStarted = true;
|
|
52
|
+
const tryInject = () => {
|
|
53
|
+
const els = Array.from(document.querySelectorAll("div,span,p,li"));
|
|
54
|
+
for (const el of els) {
|
|
55
|
+
if (injectedVoiceEls.has(el)) continue;
|
|
56
|
+
if (el.querySelector("audio[data-voice-bubble]")) { injectedVoiceEls.add(el); continue; }
|
|
57
|
+
const text = el.textContent ?? "";
|
|
58
|
+
// [2026-08-21] AI 语音回复:**已禁用**。DOM 注入在 React 重渲染下会随 Tool call 展开/折叠
|
|
59
|
+
// 重复注入、位置漂移、无限累积(用户实测图1-4),修不干净。AI 语音条走源码版(voice/reply
|
|
60
|
+
// 事件原生渲染);rc.7 上 AI 语音音频已生成但界面不显示,属 rc.7 硬伤,引导用户使用源码版。
|
|
61
|
+
// if (text.includes("语音已发送") && text.includes("voiceId") && text.includes("sha256:")) { ... 注入 ... }
|
|
62
|
+
if (!text.includes(VOICE_MSG_MARK)) continue;
|
|
63
|
+
// 只处理"叶子级"文本块:若子元素已含标记(父容器),跳过避免重复注入
|
|
64
|
+
let childHasMark = false;
|
|
65
|
+
for (const c of el.children) {
|
|
66
|
+
if ((c.textContent ?? "").includes(VOICE_MSG_MARK)) { childHasMark = true; break; }
|
|
67
|
+
}
|
|
68
|
+
if (childHasMark) continue;
|
|
69
|
+
const meta = pendingVoiceQueue.shift();
|
|
70
|
+
if (meta) injectVoiceCard(el, `/voice/outbox/${meta.voiceId}.${meta.ext || "webm"}`, 1); /* 用户消息气泡内 */
|
|
71
|
+
injectedVoiceEls.add(el);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
const obs = new MutationObserver(() => tryInject());
|
|
75
|
+
obs.observe(document.body, { childList: true, subtree: true, characterData: true });
|
|
76
|
+
tryInject();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function injectVoiceCard(anchorEl, audioSrc, hop = 1) {
|
|
80
|
+
try {
|
|
81
|
+
// [2026-08-21 修] 不再硬编码爬 7 层(之前导致 AI 语音条藏到 Tool call 折叠块里)——
|
|
82
|
+
// 改为可指定爬层数:
|
|
83
|
+
// 1 = 用户消息:爬 1 层到消息气泡内(气泡可能就在文本的父级)
|
|
84
|
+
// 6 = AI 语音回复:爬 6 层穿透 Tool call 折叠卡到主 assistant message 行
|
|
85
|
+
let host = anchorEl;
|
|
86
|
+
for (let i = 0; i < hop && host.parentElement; i++) host = host.parentElement;
|
|
87
|
+
if (!host || host.querySelector("audio[data-voice-bubble]")) return;
|
|
88
|
+
const audio = document.createElement("audio");
|
|
89
|
+
audio.src = audioSrc;
|
|
90
|
+
audio.preload = "metadata";
|
|
91
|
+
audio.dataset.voiceBubble = "1";
|
|
92
|
+
const btn = document.createElement("button");
|
|
93
|
+
btn.type = "button";
|
|
94
|
+
btn.style.cssText = "display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;border:none;border-radius:50%;background:rgba(229,72,77,.18);color:#e5484d;cursor:pointer;font-size:14px;flex:none;line-height:1;";
|
|
95
|
+
btn.textContent = "▶";
|
|
96
|
+
btn.onclick = () => { if (audio.paused) { void audio.play(); btn.textContent = "⏸"; } else { audio.pause(); btn.textContent = "▶"; } };
|
|
97
|
+
audio.onended = () => { btn.textContent = "▶"; };
|
|
98
|
+
audio.onerror = () => { btn.textContent = "⚠"; btn.title = "音频加载失败"; };
|
|
99
|
+
const dur = document.createElement("span");
|
|
100
|
+
dur.style.cssText = "font-size:11px;opacity:.75;min-width:26px;";
|
|
101
|
+
audio.onloadedmetadata = () => {
|
|
102
|
+
const s = Math.round(audio.duration || 0);
|
|
103
|
+
dur.textContent = s ? `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}` : "";
|
|
104
|
+
};
|
|
105
|
+
const card = document.createElement("div");
|
|
106
|
+
card.style.cssText = "display:inline-flex;align-items:center;gap:8px;background:rgba(229,72,77,.1);border:1px solid rgba(229,72,77,.28);border-radius:999px;padding:4px 12px;margin-top:6px;width:fit-content;max-width:260px;align-self:flex-start;";
|
|
107
|
+
card.append(btn, dur);
|
|
108
|
+
host.appendChild(card);
|
|
109
|
+
} catch { /* 注入失败不影响消息 */ }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── 源码 SVG 图标 ────────────────────────────────────────────
|
|
113
|
+
const svgProps = { viewBox: "0 0 16 16", width: "14", height: "14", "aria-hidden": true };
|
|
114
|
+
const imageIcon = h("svg", svgProps,
|
|
115
|
+
h("rect", { x: "2.5", y: "3.5", width: "11", height: "9", rx: "2", fill: "none", stroke: "currentColor", strokeWidth: "1.4" }),
|
|
116
|
+
h("circle", { cx: "6", cy: "7.5", r: "1.5", fill: "currentColor" }),
|
|
117
|
+
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" }),
|
|
118
|
+
);
|
|
119
|
+
const micIcon = h("svg", svgProps,
|
|
120
|
+
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" }),
|
|
121
|
+
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" }),
|
|
122
|
+
);
|
|
123
|
+
const cancelIcon = h("svg", svgProps,
|
|
124
|
+
h("path", { d: "M4 4L12 12M12 4L4 12", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round" }),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// ── 工具行按钮样式:圆形底,间距 16px(与源码 .tools gap 一致) ─────
|
|
128
|
+
const circleBtn = {
|
|
129
|
+
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
|
130
|
+
width: "32px", height: "32px", padding: "0", border: "none",
|
|
131
|
+
borderRadius: "999px",
|
|
132
|
+
background: "rgba(128,128,128,.16)",
|
|
133
|
+
color: "inherit", cursor: "pointer",
|
|
134
|
+
transition: "background-color .15s",
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// ── 左工具行:图片(官方 draft 链路)+ 语音 ─────────────────────
|
|
138
|
+
function ToolbarLeft({ connection, sessionId }) {
|
|
139
|
+
const [recording, setRecording] = useState(false);
|
|
140
|
+
const [seconds, setSeconds] = useState(0);
|
|
141
|
+
const [voiceError, setVoiceError] = useState(null); // 语音发送失败提示
|
|
142
|
+
const voiceErrorTimerRef = useRef(null);
|
|
143
|
+
const recorderRef = useRef(null);
|
|
144
|
+
const chunksRef = useRef([]);
|
|
145
|
+
const timerRef = useRef(null);
|
|
146
|
+
const fileRef = useRef(null);
|
|
147
|
+
const voiceSupported = typeof navigator !== "undefined" && typeof MediaRecorder !== "undefined";
|
|
148
|
+
// [2026-08-21] 语音气泡注入:页面一挂载就监听消息流,给【用户语音】消息贴语音条
|
|
149
|
+
useEffect(() => { startVoiceBubbleObserver(); }, []);
|
|
150
|
+
|
|
151
|
+
const sendVoiceBlob = useCallback(async (blob) => {
|
|
152
|
+
if (connection === undefined) return;
|
|
153
|
+
const mediaType = blob.type.split(";")[0] || "audio/webm";
|
|
154
|
+
const reader = new FileReader();
|
|
155
|
+
const data = await new Promise((resolve, reject) => {
|
|
156
|
+
reader.onload = () => resolve(String(reader.result).split(",")[1] ?? "");
|
|
157
|
+
reader.onerror = reject;
|
|
158
|
+
reader.readAsDataURL(blob);
|
|
159
|
+
});
|
|
160
|
+
const fail = (msg) => {
|
|
161
|
+
setVoiceError(msg);
|
|
162
|
+
if (voiceErrorTimerRef.current !== null) window.clearTimeout(voiceErrorTimerRef.current);
|
|
163
|
+
voiceErrorTimerRef.current = window.setTimeout(() => setVoiceError(null), 6000);
|
|
164
|
+
};
|
|
165
|
+
// [2026-08-21] draft 图片转 image content(File→base64),语音可与图片一起发送
|
|
166
|
+
const draftImageContents = async () => {
|
|
167
|
+
const imgs = Array.isArray(sharedDraftImages) ? sharedDraftImages : [];
|
|
168
|
+
const out = [];
|
|
169
|
+
for (const a of imgs) {
|
|
170
|
+
const file = a?.file;
|
|
171
|
+
if (!file) continue;
|
|
172
|
+
const b64 = await new Promise((resolve) => {
|
|
173
|
+
const r = new FileReader();
|
|
174
|
+
r.onload = () => resolve(String(r.result).split(",")[1] ?? "");
|
|
175
|
+
r.onerror = () => resolve("");
|
|
176
|
+
r.readAsDataURL(file);
|
|
177
|
+
});
|
|
178
|
+
if (b64 !== "") out.push({ type: "image", mediaType: file.type || "image/jpeg", data: b64, name: file.name });
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
};
|
|
182
|
+
// [2026-08-21] 语音发送成功后清掉 draft 图片(否则图还留在输入框上)
|
|
183
|
+
const clearDraftImages = () => {
|
|
184
|
+
const imgs = Array.isArray(sharedDraftImages) ? sharedDraftImages : [];
|
|
185
|
+
if (typeof sharedRemoveImage === "function") {
|
|
186
|
+
for (const a of imgs) { try { sharedRemoveImage(a.id); } catch { /* ignore */ } }
|
|
187
|
+
}
|
|
188
|
+
sharedDraftImages = [];
|
|
189
|
+
};
|
|
190
|
+
const sendAsText = async (text, images) => {
|
|
191
|
+
// [2026-08-21] 降级路径:XDN(npm rc.7) 不支持 voice content。带【用户语音】标记让 AI
|
|
192
|
+
// 知道这是语音转的文本,可以按规则(自动 TTS)回复。
|
|
193
|
+
const marked = "【用户语音】" + text;
|
|
194
|
+
const response = await connection.api.sessions.prompt({
|
|
195
|
+
sessionId, mode: "queue",
|
|
196
|
+
content: [{ type: "text", text: marked }, ...images],
|
|
197
|
+
});
|
|
198
|
+
const result = response?.result;
|
|
199
|
+
if (!result || !result.ok) {
|
|
200
|
+
fail((result?.error && typeof result.error.message === "string" && result.error.message !== "")
|
|
201
|
+
? result.error.message : "语音发送失败,请重试");
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
const sendAsVoice = async (images) => {
|
|
205
|
+
// 首选:多模态直发(AI 能听原音,消息渲染为语音气泡)——本机 lecoo / dev rc.8 支持
|
|
206
|
+
const response = await connection.api.sessions.prompt({
|
|
207
|
+
sessionId, mode: "queue",
|
|
208
|
+
content: [{ type: "voice", mediaType, data }, ...images],
|
|
209
|
+
});
|
|
210
|
+
return response?.result;
|
|
211
|
+
};
|
|
212
|
+
try {
|
|
213
|
+
const images = await draftImageContents();
|
|
214
|
+
// [2026-08-21 修] 先直发 voice,失败时降级 ASR 转文本(rc.7 兼容)。
|
|
215
|
+
// 这样本机/rc.8 享受多模态(AI 听到原音 + 语音消息气泡),XDN/rc.7 自动降级不报错。
|
|
216
|
+
let result;
|
|
217
|
+
try {
|
|
218
|
+
result = await sendAsVoice(images);
|
|
219
|
+
} catch (voiceErr) {
|
|
220
|
+
result = null;
|
|
221
|
+
}
|
|
222
|
+
// 直发成功(rc.8/dev):result.ok true
|
|
223
|
+
if (result && result.ok) { clearDraftImages(); return; }
|
|
224
|
+
// 失败或不支持:尝试降级
|
|
225
|
+
const errMsg = result?.error?.message ?? "";
|
|
226
|
+
// 只有"contract/payload"类错误才降级;其他业务错误直接提示
|
|
227
|
+
const isContractError = /invalid payload|schema|contract|not supported|unsupported/i.test(errMsg);
|
|
228
|
+
if (!isContractError && result && !result.ok) {
|
|
229
|
+
fail(errMsg || "语音发送失败,请重试");
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
// 走 ASR 转文本(降级路径:dsh 契约不支持 voice content)
|
|
233
|
+
// 先把录音存到服务器(语音气泡数据源),再识别再发【用户语音】标记文本
|
|
234
|
+
try {
|
|
235
|
+
const sv = await (await fetch("/voice/outbox/save", {
|
|
236
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
237
|
+
body: JSON.stringify({ audioBase64: data, mediaType }),
|
|
238
|
+
})).json().catch(() => ({}));
|
|
239
|
+
if (sv?.ok) pendingVoiceQueue.push({ voiceId: sv.voiceId, ext: sv.ext ?? "webm" });
|
|
240
|
+
} catch { /* 存档失败不阻塞发送 */ }
|
|
241
|
+
const tr = await fetch("/asr/transcribe", {
|
|
242
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
243
|
+
body: JSON.stringify({ audioBase64: data, mediaType }),
|
|
244
|
+
});
|
|
245
|
+
const td = await tr.json().catch(() => ({}));
|
|
246
|
+
if (!td?.ok) { fail(td?.error ?? "语音识别失败,请检查 ASR 配置"); return; }
|
|
247
|
+
const text = typeof td?.text === "string" ? td.text.trim() : "";
|
|
248
|
+
if (text === "") { fail("没听清,请再说一次"); return; }
|
|
249
|
+
await sendAsText(text, images);
|
|
250
|
+
clearDraftImages();
|
|
251
|
+
} catch (e) {
|
|
252
|
+
fail(String(e && typeof e.message === "string" && e.message !== "" ? e.message : e));
|
|
253
|
+
}
|
|
254
|
+
}, [connection, sessionId]);
|
|
255
|
+
|
|
256
|
+
const stopRecording = useCallback((send) => {
|
|
257
|
+
clearInterval(timerRef.current);
|
|
258
|
+
timerRef.current = null;
|
|
259
|
+
const recorder = recorderRef.current;
|
|
260
|
+
recorderRef.current = null;
|
|
261
|
+
if (recorder !== null && recorder.state !== "inactive") {
|
|
262
|
+
if (send) recorder.stop();
|
|
263
|
+
else { recorder.onstop = null; try { recorder.stop(); } catch { /* ignore */ } }
|
|
264
|
+
}
|
|
265
|
+
setRecording(false);
|
|
266
|
+
setSeconds(0);
|
|
267
|
+
}, []);
|
|
268
|
+
|
|
269
|
+
const startRecording = useCallback(async () => {
|
|
270
|
+
if (connection === undefined || sessionId === undefined) return;
|
|
271
|
+
try {
|
|
272
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
273
|
+
const recorder = new MediaRecorder(stream);
|
|
274
|
+
chunksRef.current = [];
|
|
275
|
+
recorder.ondataavailable = (event) => { if (event.data.size > 0) chunksRef.current.push(event.data); };
|
|
276
|
+
recorder.onstop = () => {
|
|
277
|
+
const blob = new Blob(chunksRef.current, { type: recorder.mimeType || "audio/webm" });
|
|
278
|
+
chunksRef.current = [];
|
|
279
|
+
if (blob.size > 0) void sendVoiceBlob(blob);
|
|
280
|
+
stream.getTracks().forEach((t) => t.stop());
|
|
281
|
+
};
|
|
282
|
+
recorder.start();
|
|
283
|
+
recorderRef.current = recorder;
|
|
284
|
+
setRecording(true);
|
|
285
|
+
setSeconds(0);
|
|
286
|
+
timerRef.current = setInterval(() => setSeconds((s) => s + 1), 1000);
|
|
287
|
+
} catch { /* 权限拒绝 */ }
|
|
288
|
+
}, [connection, sessionId, sendVoiceBlob]);
|
|
289
|
+
|
|
290
|
+
// [2026-08-22] 大图自动缩放:官方限制图片宽高 ≤2000px,超出则 canvas 缩小(最长边对齐 2000px,转 jpeg)再上传
|
|
291
|
+
// [2026-08-22 修] 尺寸无效(0/NaN)或输出异常一律回退原图, 绝不缩成像素
|
|
292
|
+
const scaleImageToFit = (file, maxDim = 2000) => new Promise((resolve) => {
|
|
293
|
+
if (typeof Image === "undefined" || typeof document === "undefined") { resolve(file); return; }
|
|
294
|
+
const url = URL.createObjectURL(file);
|
|
295
|
+
const img = new Image();
|
|
296
|
+
img.onload = () => {
|
|
297
|
+
URL.revokeObjectURL(url);
|
|
298
|
+
const w = img.naturalWidth, h = img.naturalHeight;
|
|
299
|
+
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) { resolve(file); return; }
|
|
300
|
+
const scale = Math.min(1, maxDim / Math.max(w, h));
|
|
301
|
+
if (scale >= 1) { resolve(file); return; }
|
|
302
|
+
try {
|
|
303
|
+
const canvas = document.createElement("canvas");
|
|
304
|
+
canvas.width = Math.max(1, Math.round(w * scale));
|
|
305
|
+
canvas.height = Math.max(1, Math.round(h * scale));
|
|
306
|
+
const ctx = canvas.getContext("2d");
|
|
307
|
+
if (!ctx) { resolve(file); return; }
|
|
308
|
+
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
309
|
+
canvas.toBlob((blob) => {
|
|
310
|
+
if (!blob || blob.size < 1024) { resolve(file); return; }
|
|
311
|
+
resolve(new File([blob], file.name, { type: file.type === "image/gif" ? "image/jpeg" : (file.type || "image/jpeg") }));
|
|
312
|
+
}, "image/jpeg", 0.92);
|
|
313
|
+
} catch { resolve(file); }
|
|
314
|
+
};
|
|
315
|
+
img.onerror = () => { URL.revokeObjectURL(url); resolve(file); };
|
|
316
|
+
img.src = url;
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// 图片选中 → 自动缩放(防官方 2000px 限制) → 官方 onAddImages(intakeImages)→ 官方 draft → 随文本发送
|
|
320
|
+
const onPickImage = useCallback((event) => {
|
|
321
|
+
const files = Array.from(event.target.files ?? []);
|
|
322
|
+
event.target.value = "";
|
|
323
|
+
if (files.length === 0 || typeof sharedOnAddImages !== "function") return;
|
|
324
|
+
Promise.all(files.map(scaleImageToFit)).then((scaled) => sharedOnAddImages(scaled));
|
|
325
|
+
}, []);
|
|
326
|
+
|
|
327
|
+
return h("div", { style: { position: "relative", display: "inline-flex", alignItems: "center", gap: "16px" } },
|
|
328
|
+
h("button", {
|
|
329
|
+
type: "button", "aria-label": "添加图片", title: "添加图片",
|
|
330
|
+
style: circleBtn, onMouseDown: (e) => e.preventDefault(),
|
|
331
|
+
onClick: () => fileRef.current?.click(),
|
|
332
|
+
}, imageIcon),
|
|
333
|
+
h("input", {
|
|
334
|
+
ref: fileRef, type: "file",
|
|
335
|
+
accept: "image/png,image/jpeg,image/webp,image/gif",
|
|
336
|
+
multiple: false, hidden: true, onChange: onPickImage,
|
|
337
|
+
}),
|
|
338
|
+
voiceSupported && h("button", {
|
|
339
|
+
type: "button",
|
|
340
|
+
"aria-label": recording ? "停止并发送" : "录音",
|
|
341
|
+
title: recording ? "停止并发送" : "录音",
|
|
342
|
+
style: { ...circleBtn, ...(recording ? { background: "#e5484d", color: "#fff" } : {}) },
|
|
343
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
344
|
+
onClick: () => { if (recording) stopRecording(true); else void startRecording(); },
|
|
345
|
+
}, recording
|
|
346
|
+
? h("span", {
|
|
347
|
+
style: { display: "inline-flex", alignItems: "center", gap: "3px", fontSize: "11px", fontWeight: 600 },
|
|
348
|
+
}, h("span", {
|
|
349
|
+
style: { width: "6px", height: "6px", borderRadius: "50%", background: "#fff", display: "inline-block" },
|
|
350
|
+
}), `${seconds}s`)
|
|
351
|
+
: micIcon),
|
|
352
|
+
recording && h("button", {
|
|
353
|
+
type: "button", "aria-label": "取消录音", title: "取消",
|
|
354
|
+
style: circleBtn, onMouseDown: (e) => e.preventDefault(),
|
|
355
|
+
onClick: () => stopRecording(false),
|
|
356
|
+
}, cancelIcon),
|
|
357
|
+
// [本地改造 2026-08-21] 语音发送失败提示(ASR 未配置/识别失败):按钮上方气泡
|
|
358
|
+
voiceError !== null && h("div", {
|
|
359
|
+
style: {
|
|
360
|
+
position: "absolute", bottom: "calc(100% + 8px)", left: "0", zIndex: 30,
|
|
361
|
+
maxWidth: "380px", background: "rgba(229,72,77,.12)", color: "#e5484d",
|
|
362
|
+
border: "1px solid rgba(229,72,77,.35)", borderRadius: "8px",
|
|
363
|
+
padding: "6px 10px", fontSize: "12px", lineHeight: 1.45, whiteSpace: "normal",
|
|
364
|
+
pointerEvents: "none", boxShadow: "0 4px 14px rgba(0,0,0,.25)",
|
|
365
|
+
},
|
|
366
|
+
}, voiceError),
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ── 附件槽(覆盖官方):悬浮缩略图墙 + 放大 modal,无背景无边框 ─────
|
|
371
|
+
function ComposerAttachmentsOverlay({ attachments, onAddImages, onRemoveImage }) {
|
|
372
|
+
const [zoom, setZoom] = useState(null); // { id, url, name } | null
|
|
373
|
+
const items = Array.isArray(attachments) ? attachments : [];
|
|
374
|
+
const hasItems = items.length > 0;
|
|
375
|
+
|
|
376
|
+
// 官方 onAddImages 存入模块级,供 left 按钮使用
|
|
377
|
+
useEffect(() => {
|
|
378
|
+
if (typeof onAddImages === "function") sharedOnAddImages = onAddImages;
|
|
379
|
+
}, [onAddImages]);
|
|
380
|
+
|
|
381
|
+
// [2026-08-21] draft 图同步到模块级(语音发送一起带 + 发完清掉)
|
|
382
|
+
useEffect(() => {
|
|
383
|
+
sharedDraftImages = Array.isArray(attachments) ? attachments : [];
|
|
384
|
+
if (typeof onRemoveImage === "function") sharedRemoveImage = onRemoveImage;
|
|
385
|
+
}, [attachments, onRemoveImage]);
|
|
386
|
+
|
|
387
|
+
useEffect(() => {
|
|
388
|
+
if (zoom !== null && !items.some((a) => a.id === zoom.id)) setZoom(null);
|
|
389
|
+
}, [items, zoom]);
|
|
390
|
+
|
|
391
|
+
if (!hasItems) return null;
|
|
392
|
+
return h("div", {
|
|
393
|
+
style: {
|
|
394
|
+
position: "absolute", bottom: "calc(100% + 8px)", left: "10px", zIndex: 20,
|
|
395
|
+
display: "flex", flexWrap: "wrap", gap: "6px",
|
|
396
|
+
padding: "0", margin: "0", background: "transparent", border: "none",
|
|
397
|
+
pointerEvents: "none",
|
|
398
|
+
},
|
|
399
|
+
}, items.map((a) => h("div", {
|
|
400
|
+
key: a.id,
|
|
401
|
+
style: {
|
|
402
|
+
position: "relative", width: "60px", height: "60px", borderRadius: "6px",
|
|
403
|
+
overflow: "hidden", cursor: "zoom-in", background: "rgba(128,128,128,.1)",
|
|
404
|
+
pointerEvents: "auto",
|
|
405
|
+
},
|
|
406
|
+
onClick: () => setZoom({ id: a.id, url: a.previewUrl, name: a.file?.name ?? "image" }),
|
|
407
|
+
},
|
|
408
|
+
h("img", {
|
|
409
|
+
src: a.previewUrl, alt: a.file?.name ?? "image",
|
|
410
|
+
style: { width: "100%", height: "100%", objectFit: "cover", display: "block" },
|
|
411
|
+
}),
|
|
412
|
+
h("button", {
|
|
413
|
+
type: "button", "aria-label": "移除", title: "移除",
|
|
414
|
+
style: {
|
|
415
|
+
position: "absolute", top: "2px", right: "2px",
|
|
416
|
+
width: "18px", height: "18px", padding: "0", border: "none", borderRadius: "50%",
|
|
417
|
+
background: "rgba(0,0,0,.6)", color: "#fff", cursor: "pointer",
|
|
418
|
+
display: "flex", alignItems: "center", justifyContent: "center", fontSize: "12px", lineHeight: "1",
|
|
419
|
+
},
|
|
420
|
+
onClick: (e) => { e.stopPropagation(); if (typeof onRemoveImage === "function") onRemoveImage(a.id); },
|
|
421
|
+
}, "×"),
|
|
422
|
+
)),
|
|
423
|
+
// 放大 modal
|
|
424
|
+
zoom !== null ? h("div", {
|
|
425
|
+
role: "dialog", "aria-label": "图片预览",
|
|
426
|
+
style: {
|
|
427
|
+
position: "fixed", inset: "0", zIndex: 9999,
|
|
428
|
+
display: "flex", alignItems: "center", justifyContent: "center",
|
|
429
|
+
background: "rgba(0,0,0,.78)", cursor: "zoom-out",
|
|
430
|
+
// modal 是缩略图墙 div 的子节点,外层 pointerEvents:none 会继承;显式 auto 让按钮能点
|
|
431
|
+
pointerEvents: "auto",
|
|
432
|
+
},
|
|
433
|
+
onClick: () => setZoom(null),
|
|
434
|
+
},
|
|
435
|
+
h("img", {
|
|
436
|
+
src: zoom.url, alt: zoom.name,
|
|
437
|
+
style: { maxWidth: "92vw", maxHeight: "92vh", objectFit: "contain", borderRadius: "8px", boxShadow: "0 12px 48px rgba(0,0,0,.5)" },
|
|
438
|
+
}),
|
|
439
|
+
h("button", {
|
|
440
|
+
type: "button", "aria-label": "关闭",
|
|
441
|
+
style: {
|
|
442
|
+
position: "absolute", top: "12px", right: "16px",
|
|
443
|
+
width: "36px", height: "36px", padding: "0", border: "none", borderRadius: "50%",
|
|
444
|
+
background: "rgba(0,0,0,.5)", color: "#fff", cursor: "pointer",
|
|
445
|
+
fontSize: "18px", lineHeight: "1",
|
|
446
|
+
},
|
|
447
|
+
onClick: () => setZoom(null),
|
|
448
|
+
}, "×"),
|
|
449
|
+
) : null,
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ── 右工具行:余额 ─────────────────────────────────────────────
|
|
454
|
+
function BalanceMeter({ connection, sessionId }) {
|
|
455
|
+
const [balance, setBalance] = useState(null);
|
|
456
|
+
const [visible, setVisible] = useState(false);
|
|
457
|
+
const refresh = useCallback(async () => {
|
|
458
|
+
if (connection === undefined) return;
|
|
459
|
+
try {
|
|
460
|
+
const response = await connection.api.balance.get({ sessionId });
|
|
461
|
+
if (!response.result.ok) return;
|
|
462
|
+
const value = response.result.value.balance;
|
|
463
|
+
setBalance(value);
|
|
464
|
+
setVisible(value !== null);
|
|
465
|
+
} catch { /* 静默 */ }
|
|
466
|
+
}, [connection, sessionId]);
|
|
467
|
+
useEffect(() => { void refresh(); }, [refresh]);
|
|
468
|
+
useEffect(() => {
|
|
469
|
+
const timer = setInterval(() => { void refresh(); }, POLL_MS);
|
|
470
|
+
return () => clearInterval(timer);
|
|
471
|
+
}, [refresh]);
|
|
472
|
+
|
|
473
|
+
if (!visible || balance === null) return null;
|
|
474
|
+
const label = `余额: ¥${balance.total}`;
|
|
475
|
+
return h("span", {
|
|
476
|
+
title: `总额 ¥${balance.total} · 赠送 ¥${balance.granted} · 充值 ¥${balance.toppedUp}`,
|
|
477
|
+
style: { display: "inline-flex", alignItems: "center", fontSize: "12px", opacity: 0.85, whiteSpace: "nowrap", cursor: "default" },
|
|
478
|
+
}, h("button", {
|
|
479
|
+
type: "button", "aria-label": label, title: label,
|
|
480
|
+
style: { border: "none", background: "transparent", color: "inherit", cursor: "default", fontSize: "inherit", padding: "0 4px" },
|
|
481
|
+
}, label));
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// ── 设置页:语音服务分区(settings.section,读写 ~/.dsh/voice-config.json)──
|
|
485
|
+
const VOICE_RULES = [
|
|
486
|
+
"1) 用户本轮发过语音 → 必须语音回复(使用上方选择的默认引擎)",
|
|
487
|
+
"2) 用户文本明确要求发语音 / 指定服务商(小米/微软)→ 自动合成(用指定 provider)",
|
|
488
|
+
"3) 其他情况不自动合成——agent 自主决定,需要时调用 send_voice 工具主动发(仍按默认引擎)",
|
|
489
|
+
];
|
|
490
|
+
// 自然语言风格预设(xiaomi context,强差异)
|
|
491
|
+
const STYLE_PRESETS = [
|
|
492
|
+
{ key: "", label: "自然(默认)", ctx: "" },
|
|
493
|
+
{ key: "joyful", label: "欢快活泼", ctx: "用欢快、活泼的语气,语速轻快,带着笑意,声音明亮有活力" },
|
|
494
|
+
{ key: "gentle", label: "温柔亲切", ctx: "用温柔、亲切的语气,语速平缓,声音柔和,像在关怀对方" },
|
|
495
|
+
{ key: "calm", label: "沉稳严肃", ctx: "用沉稳、严肃的语气,语速适中偏慢,声音厚重,正式播报感" },
|
|
496
|
+
{ key: "broadcast", label: "播音腔", ctx: "用标准播音腔,吐字清晰,节奏分明,抑扬顿挫,专业新闻播报" },
|
|
497
|
+
{ key: "whisper", label: "低语私密", ctx: "用低沉、私密的低语语气,音量放轻,语速缓慢,像耳语般亲近" },
|
|
498
|
+
{ key: "excited", label: "兴奋激动", ctx: "用兴奋、激动的语气,语速快,音调上扬,情绪饱满有感染力" },
|
|
499
|
+
];
|
|
500
|
+
// 常用情绪(voicedesign 试听:叠加在"音色描述"之上的表演指令,不写性别/年龄——那是音色描述的事)
|
|
501
|
+
// 写法参照 MiMo 官方"自然语言控制"示例:语速、气息、停顿、音调、共鸣都要有可感细节
|
|
502
|
+
const EMOTIONS = [
|
|
503
|
+
{ key: "happy", label: "开心", ctx: "用开心、欢快的语气,语速轻快,带着抑制不住的笑意,声音明亮上扬,尾音微微翘起" },
|
|
504
|
+
{ key: "sad", label: "难过", ctx: "用难过、低落的语气,语速缓慢,声音轻柔低沉,气息断断续续,带着淡淡的忧伤和哽咽感" },
|
|
505
|
+
{ key: "angry", label: "愤怒", ctx: "用愤怒、激动的语气,语速急促,声音强硬有力,气息加重,字字用力,带爆发感" },
|
|
506
|
+
{ key: "gentle", label: "温柔", ctx: "用温柔、关切的语气,语速平缓,气息绵软,声音柔和亲切,像在轻声安抚对方" },
|
|
507
|
+
{ key: "calm", label: "平静", ctx: "用平静、沉稳的语气,语速适中,气息平稳,声音波澜不惊,字正腔圆" },
|
|
508
|
+
{ key: "playful", label: "俏皮", ctx: "用俏皮、活泼的语气,语速轻快,声音带点机灵劲,尾音上扬,像在逗趣" },
|
|
509
|
+
{ key: "cold", label: "高冷", ctx: "用高冷、疏离的语气,语速偏慢,声音平淡克制,字字清晰,像隔着一层冰" },
|
|
510
|
+
{ key: "magnetic", label: "磁性", ctx: "用磁性、醇厚的语气,语速稍慢,气息低沉共鸣,声音富有魅力,尾音带拖腔" },
|
|
511
|
+
{ key: "excited", label: "兴奋", ctx: "用兴奋、高昂的语气,语速快,声音高亢明亮,情绪饱满,气息急促上扬" },
|
|
512
|
+
{ key: "grievance", label: "委屈", ctx: "用委屈、哽咽的语气,语速慢,声音发颤带鼻音,像忍着泪说话" },
|
|
513
|
+
{ key: "lazy", label: "慵懒", ctx: "用慵懒、松弛的语气,语速慢悠悠,声音松散,气息不紧不慢,漫不经心" },
|
|
514
|
+
{ key: "deep", label: "深沉", ctx: "用深沉、厚重的语气,若有所思,语速稳中有顿挫,声音偏低,字字有分量" },
|
|
515
|
+
];
|
|
516
|
+
const ENGINES_ORDER = ["edge", "xiaomi", "voicedesign", "voiceclone", "local", "ali"];
|
|
517
|
+
const ENGINE_LABELS = {
|
|
518
|
+
edge: "微软 edge(免费)", xiaomi: "小米 MiMo", voicedesign: "小米语音设计(VoiceDesign)", voiceclone: "小米克隆(VoiceClone)", local: "本地 TTS", ali: "阿里 qwen3-tts",
|
|
519
|
+
};
|
|
520
|
+
const MIMO_DOC_URL = "https://mimo.mi.com/models/zh-CN/mimo-v2.5-tts";
|
|
521
|
+
// VoiceDesign 官方示例(音色设计:Instruct=音色描述/导演指令,Text=要朗读的文本)
|
|
522
|
+
const VOICE_DESIGN_EXAMPLES = [
|
|
523
|
+
{
|
|
524
|
+
title: "ASMR 双耳女声",
|
|
525
|
+
instruct: "年轻的女性声音,近距离的聆听效果,带有双耳刺激的ASMR感。可以听到她的呼吸声、轻微的吞咽声,以及轻柔的自然唇音。她的说话速度非常慢,营造出一种极度放松且沉浸式的体验。",
|
|
526
|
+
text: "[在你耳边低语] 嘘……放松点,再靠近一点吧。我现在就在你身边。慢慢、轻柔地呼吸,让思绪随着水流轻轻流淌,就像沉浸在温暖的水中一样。",
|
|
527
|
+
},
|
|
528
|
+
{
|
|
529
|
+
title: "纪录片旁白",
|
|
530
|
+
instruct: "一位中年男性,说标准普通话,嗓音低沉有磁性,带有轻微的沙哑质感,像纪录片旁白解说员,沉稳而有感染力。",
|
|
531
|
+
text: "当最后一缕阳光消失在地平线之下,这片沉睡了亿万年的大地开始显露它真正的面貌。在这寂静的荒野中,每一块岩石都记录着时间的流逝,每一阵风都在诉说着古老的故事。",
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
title: "年迈老先生旁白",
|
|
535
|
+
instruct: "一位年迈的老先生,说带北方口音的普通话,语速缓慢而沉稳,嗓音略带沙哑和沧桑感,仿佛一位饱经风霜的老爷爷在讲故事,充满岁月的智慧。",
|
|
536
|
+
text: "我这辈子啊,走南闯北六十多年。见过最热闹的集市,也见过最安静的戈壁。到头来才明白一个道理——这人哪,不在走了多远的路,在于记住了多少风景。年轻人,别光顾着赶路,偶尔也停下来看看天。",
|
|
537
|
+
},
|
|
538
|
+
];
|
|
539
|
+
// VoiceDesign 默认音色描述(用户未填时的兜底,含性别锚点)
|
|
540
|
+
const DEFAULT_VOICE_DESC = "青年女性,声音甜美明亮,普通话标准,语速适中,活泼开朗";
|
|
541
|
+
// [本地改造 2026-08-21] 所有克隆音色的统一试听文本(与每个样本自己的风格指令配合,
|
|
542
|
+
// 试听时能同时听出"音色+个性";如小团团样本的指令让它念这句时自然带沙雕可爱腔)
|
|
543
|
+
const CLONE_PREVIEW_TEXT = "喂喂喂!你怎么才来呀?我都等你老半天啦!我跟你说啊——你今天可不能凶我哦,因为……因为你又不娶我,哼!不过嘛,看在你这么乖的份上,本小姐今天心情好,就大发慈悲原谅你啦!嘿嘿嘿~走吧走吧,出发喽!";
|
|
544
|
+
// [本地改造 2026-08-22] 自带默认样本 id(小团团):禁止删除、有预生成合成试听录音
|
|
545
|
+
const BUNDLED_CLONE_ID = "8da38fcc-b041-4f5b-86b9-901956016f89";
|
|
546
|
+
|
|
547
|
+
const vInput = {
|
|
548
|
+
background: "var(--dsw-specific-input-major,#ffffff)", color: "var(--dsw-alias-label-primary,#e6e9ef)",
|
|
549
|
+
border: "1px solid var(--dsw-alias-border-l1,#333a45)", borderRadius: "6px",
|
|
550
|
+
padding: "6px 10px", fontSize: "12.5px", fontFamily: "inherit", width: "100%",
|
|
551
|
+
boxSizing: "border-box",
|
|
552
|
+
};
|
|
553
|
+
const vField = (labelText, node) => h("label", {
|
|
554
|
+
style: { display: "flex", flexDirection: "column", gap: "4px", fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)", flex: "1 1 45%", minWidth: "220px" },
|
|
555
|
+
}, labelText, node);
|
|
556
|
+
// 服务商卡片([本地改造 2026-08-21] 去复选框改折叠):标题栏点击展开/收起。
|
|
557
|
+
// 配置填写与启用与否无关——只要填了 AI 就能调用,所以不再用 enabled 开关控制。
|
|
558
|
+
const vCard = (title, open, onToggle, children) => h("div", {
|
|
559
|
+
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)" },
|
|
560
|
+
},
|
|
561
|
+
h("div", {
|
|
562
|
+
style: { display: "flex", alignItems: "center", gap: "8px", cursor: "pointer", userSelect: "none" },
|
|
563
|
+
onClick: onToggle,
|
|
564
|
+
},
|
|
565
|
+
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),
|
|
566
|
+
h("span", { style: { fontSize: "13px", fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)" } }, title),
|
|
567
|
+
h("span", { style: { marginLeft: "auto", fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)", flex: "none" } }, open ? "收起 ▴" : "展开 ▾"),
|
|
568
|
+
),
|
|
569
|
+
open ? (typeof children === "function" ? children(true) : children) : null,
|
|
570
|
+
);
|
|
571
|
+
|
|
572
|
+
// 提示小问号(hover 浮层显示 / 点击固定);align=right 右对齐/center 居中/默认左对齐;place=top 上方展开
|
|
573
|
+
const helpTip = (text, pinned, setPinned, hover, setHover, align, place) => h("span", { style: { position: "relative", display: "inline-flex", alignItems: "center" } },
|
|
574
|
+
h("button", {
|
|
575
|
+
type: "button", "aria-label": "帮助", title: "帮助",
|
|
576
|
+
style: {
|
|
577
|
+
border: "none", borderRadius: "999px", width: "18px", height: "18px", padding: "0",
|
|
578
|
+
background: pinned ? "var(--vk-accent,#4b6fff)" : "rgba(128,128,128,.15)",
|
|
579
|
+
color: "inherit", cursor: "pointer", fontSize: "10px", fontWeight: 700,
|
|
580
|
+
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
|
581
|
+
},
|
|
582
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
583
|
+
onMouseEnter: () => setHover(true),
|
|
584
|
+
onMouseLeave: () => setHover(false),
|
|
585
|
+
onClick: () => { setPinned(!pinned); setHover(false); },
|
|
586
|
+
}, "?"),
|
|
587
|
+
(pinned || hover) ? h("div", {
|
|
588
|
+
style: {
|
|
589
|
+
position: "absolute", ...(place === "top" ? { bottom: "calc(100% + 6px)" } : { top: "calc(100% + 6px)" }), zIndex: 60,
|
|
590
|
+
...(align === "right" ? { right: "0", left: "auto" } : align === "center" ? { left: "50%", transform: "translateX(-50%)" } : { left: "0", right: "auto" }),
|
|
591
|
+
background: "var(--dsw-specific-input-major,#ffffff)",
|
|
592
|
+
border: "1px solid var(--dsw-alias-border-l1,#333a45)", borderRadius: "8px",
|
|
593
|
+
padding: "10px 12px", boxShadow: "0 8px 24px rgba(0,0,0,.35)",
|
|
594
|
+
fontSize: "12px", lineHeight: "1.7", color: "var(--dsw-alias-label-secondary,#9aa3ad)",
|
|
595
|
+
minWidth: "320px", maxWidth: "460px",
|
|
596
|
+
},
|
|
597
|
+
}, text) : null,
|
|
598
|
+
);
|
|
599
|
+
|
|
600
|
+
// ── [2026-08-22] 设置页「图片识别」独立分区(settings.section,从语音服务拆出)──
|
|
601
|
+
// 自包含:config 加载 / 部署位置 / 测试 / 提示词查看-编辑弹窗 / 测试图放大
|
|
602
|
+
function VisionSettingsSection() {
|
|
603
|
+
const [config, setConfig] = useState(null);
|
|
604
|
+
const saveTimerRef = useRef(null);
|
|
605
|
+
// 密钥显示开关(secretField 用)
|
|
606
|
+
const [showKeys, setShowKeys] = useState({});
|
|
607
|
+
const [visionTestTask, setVisionTestTask] = useState("describe");
|
|
608
|
+
const [visionTestResult, setVisionTestResult] = useState(null); // { ok, text, model, durationMs, busy } | null
|
|
609
|
+
const [visionZoom, setVisionZoom] = useState(null); // 放大查看的文本(null=关闭)
|
|
610
|
+
const [visionDefaults, setVisionDefaults] = useState(null); // 内置默认提示词 {describe,text,reverse}
|
|
611
|
+
const [visionEditKey, setVisionEditKey] = useState(null); // key=describe|text|reverse|null
|
|
612
|
+
const [visionEditMode, setVisionEditMode] = useState("view"); // [2026-08-22] view=只读 / edit=编辑 / saved=已保存
|
|
613
|
+
const [visionCopyState, setVisionCopyState] = useState(null); // [2026-08-22] 复制反馈: copied|fail|null
|
|
614
|
+
const [visionEditDraft, setVisionEditDraft] = useState(""); // 编辑草稿(点保存才写配置)
|
|
615
|
+
const [visionImgZoom, setVisionImgZoom] = useState(false);
|
|
616
|
+
const [visionModeTipPinned, setVisionModeTipPinned] = useState(false);
|
|
617
|
+
const [visionModeTipHover, setVisionModeTipHover] = useState(false);
|
|
618
|
+
const btnSmall = { border: "none", borderRadius: "6px", padding: "5px 14px", fontSize: "12px", fontWeight: 600, background: "rgba(128,128,128,.15)", color: "inherit", cursor: "pointer" };
|
|
619
|
+
const VISION_TASK_LABELS = { describe: "describe 看图描述", reverse: "reverse 反推提示词", text: "text 提取文字" };
|
|
620
|
+
// 各模式介绍(测试模式下拉后的「?」显示,随切换变化)
|
|
621
|
+
const VISION_MODE_INTRO = {
|
|
622
|
+
describe: "看图描述:让 AI 用一两句话简要描述图片内容。",
|
|
623
|
+
reverse: "像素级反推:把图反推成可直接用于 AI 生图(即梦/可灵/SD/Midjourney 等)的完整中文提示词,输出较长。",
|
|
624
|
+
text: "提取文字:逐字提取图中所有文字,按画面位置分行。",
|
|
625
|
+
};
|
|
626
|
+
const openPromptEditor = (key) => {
|
|
627
|
+
setVisionEditKey(key);
|
|
628
|
+
setVisionEditMode("view");
|
|
629
|
+
setVisionEditDraft(((config?.vision?.prompts ?? {})[key] ?? "").trim() !== ""
|
|
630
|
+
? (config?.vision?.prompts ?? {})[key]
|
|
631
|
+
: (visionDefaults ?? {})[key] ?? "");
|
|
632
|
+
};
|
|
633
|
+
// 当前某工具的有效提示词(配置值优先,空=内置默认)
|
|
634
|
+
const effectivePrompt = (key) => {
|
|
635
|
+
const cfg = (config?.vision?.prompts ?? {})[key];
|
|
636
|
+
if (typeof cfg === "string" && cfg.trim() !== "") return cfg;
|
|
637
|
+
return (visionDefaults ?? {})[key] ?? "";
|
|
638
|
+
};
|
|
639
|
+
const savePromptEdit = (key, value) => {
|
|
640
|
+
const prompts = { ...(config?.vision?.prompts ?? {}), [key]: value };
|
|
641
|
+
setVision({ prompts }, true);
|
|
642
|
+
};
|
|
643
|
+
const resetPromptEdit = (key) => {
|
|
644
|
+
const prompts = { ...(config?.vision?.prompts ?? {}), [key]: "" };
|
|
645
|
+
setVision({ prompts }, true);
|
|
646
|
+
setVisionEditDraft((visionDefaults ?? {})[key] ?? "");
|
|
647
|
+
};
|
|
648
|
+
useEffect(() => {
|
|
649
|
+
let dead = false;
|
|
650
|
+
fetch("/voice-config").then((r) => r.json()).then((d) => { if (!dead && d?.ok) setConfig(d.config); }).catch(() => {});
|
|
651
|
+
fetch("/voice-config/vision-prompts").then((r) => r.json()).then((d) => { if (!dead && d?.ok) setVisionDefaults(d.defaults); }).catch(() => {});
|
|
652
|
+
return () => { dead = true; };
|
|
653
|
+
}, []);
|
|
654
|
+
if (config === null) {
|
|
655
|
+
return h("div", { style: { padding: "16px", fontSize: "13px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } }, "图片识别配置加载中…");
|
|
656
|
+
}
|
|
657
|
+
// secretField(本组件副本:依赖 showKeys)
|
|
658
|
+
const secretField = (labelText, keyName, value, onChange, placeholder) => h("label", {
|
|
659
|
+
style: { display: "flex", flexDirection: "column", gap: "4px", fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)", flex: "1 1 45%", minWidth: "220px" },
|
|
660
|
+
}, labelText,
|
|
661
|
+
h("div", { style: { display: "flex", gap: "6px", alignItems: "center" } },
|
|
662
|
+
h("input", {
|
|
663
|
+
type: showKeys[keyName] ? "text" : "password",
|
|
664
|
+
value: value,
|
|
665
|
+
onChange: onChange,
|
|
666
|
+
placeholder: placeholder,
|
|
667
|
+
style: { ...vInput, flex: 1 },
|
|
668
|
+
}),
|
|
669
|
+
h("button", {
|
|
670
|
+
type: "button", "aria-label": showKeys[keyName] ? "隐藏密钥" : "显示密钥", title: showKeys[keyName] ? "隐藏密钥" : "显示密钥",
|
|
671
|
+
style: {
|
|
672
|
+
border: "none", borderRadius: "6px", width: "32px", height: "32px", flex: "none",
|
|
673
|
+
background: "rgba(128,128,128,.12)", color: "inherit", cursor: "pointer", fontSize: "14px",
|
|
674
|
+
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
|
675
|
+
},
|
|
676
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
677
|
+
onClick: () => setShowKeys((s) => ({ ...s, [keyName]: !s[keyName] })),
|
|
678
|
+
}, showKeys[keyName] ? "🙈" : "👁"),
|
|
679
|
+
),
|
|
680
|
+
);
|
|
681
|
+
// 图片识别配置(顶层 vision 段):读写同 /voice-config
|
|
682
|
+
const setVision = (patch, autoSave) => {
|
|
683
|
+
setConfig((c) => {
|
|
684
|
+
if (c === null) return c;
|
|
685
|
+
const next = { ...c, vision: { ...(c.vision ?? {}), ...patch } };
|
|
686
|
+
if (autoSave) {
|
|
687
|
+
if (saveTimerRef.current !== null) window.clearTimeout(saveTimerRef.current);
|
|
688
|
+
saveTimerRef.current = window.setTimeout(() => {
|
|
689
|
+
fetch("/voice-config", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ config: next }) })
|
|
690
|
+
.then((r) => r.json())
|
|
691
|
+
.then((d) => { if (d?.ok && d.config) setConfig(d.config); })
|
|
692
|
+
.catch(() => {});
|
|
693
|
+
}, 400);
|
|
694
|
+
}
|
|
695
|
+
return next;
|
|
696
|
+
});
|
|
697
|
+
};
|
|
698
|
+
// 识图配置测试:调 host /voice-config/vision-test(内置测试图 + 所选模式)
|
|
699
|
+
const testVision = () => {
|
|
700
|
+
setVisionTestResult({ ok: true, text: "识别中…(首次调用可能需要 1-2 分钟)", busy: true });
|
|
701
|
+
fetch("/voice-config/vision-test", {
|
|
702
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
703
|
+
body: JSON.stringify({ task: visionTestTask }),
|
|
704
|
+
}).then((r) => r.json()).then((d) => {
|
|
705
|
+
setVisionTestResult(d?.ok
|
|
706
|
+
? { ok: true, text: d.text, model: d.model, durationMs: d.durationMs }
|
|
707
|
+
: { ok: false, text: d?.error ?? "识图测试失败" });
|
|
708
|
+
}).catch((e) => setVisionTestResult({ ok: false, text: String(e) }));
|
|
709
|
+
};
|
|
710
|
+
// [2026-08-22] 复制(参考 comfyui):clipboard 不可用(非安全上下文)时 execCommand 兜底
|
|
711
|
+
const copyText = async (text) => {
|
|
712
|
+
try {
|
|
713
|
+
if (typeof navigator !== "undefined" && navigator.clipboard && window.isSecureContext) {
|
|
714
|
+
await navigator.clipboard.writeText(text);
|
|
715
|
+
} else {
|
|
716
|
+
const ta = document.createElement("textarea");
|
|
717
|
+
ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0";
|
|
718
|
+
document.body.appendChild(ta); ta.focus(); ta.select(); ta.setSelectionRange(0, text.length);
|
|
719
|
+
const ok = document.execCommand("copy"); document.body.removeChild(ta);
|
|
720
|
+
if (!ok) throw new Error("execCommand copy 失败");
|
|
721
|
+
}
|
|
722
|
+
return true;
|
|
723
|
+
} catch { return false; }
|
|
724
|
+
};
|
|
725
|
+
const copyWithFeedback = (text) => {
|
|
726
|
+
copyText(text).then((ok) => {
|
|
727
|
+
setVisionCopyState(ok ? "copied" : "fail");
|
|
728
|
+
window.setTimeout(() => setVisionCopyState(null), 1500);
|
|
729
|
+
});
|
|
730
|
+
};
|
|
731
|
+
// 编辑模式保存:写配置 → "已保存" → 1.2s 后回只读(弹窗不关,复制按钮常驻)
|
|
732
|
+
const doSavePromptEdit = (key) => {
|
|
733
|
+
savePromptEdit(key, visionEditDraft);
|
|
734
|
+
setVisionEditMode("saved");
|
|
735
|
+
window.setTimeout(() => setVisionEditMode("view"), 1200);
|
|
736
|
+
};
|
|
737
|
+
const visionIsOnline = ["online", "openai"].includes(config.vision?.provider ?? "local");
|
|
738
|
+
return h("div", { style: { display: "flex", flexDirection: "column", gap: "14px", padding: "16px", width: "100%", boxSizing: "border-box" } },
|
|
739
|
+
// 分区标题(含仓库链接,同语音分区样式)
|
|
740
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap", fontSize: "15px", fontWeight: 700, color: "var(--dsw-alias-label-primary,#e6e9ef)" } },
|
|
741
|
+
"🖼️ 图片识别",
|
|
742
|
+
h("span", { style: { display: "inline-flex", alignItems: "center", gap: "6px", fontSize: "12px", fontWeight: 400, color: "var(--dsw-alias-label-secondary,#9aa3ad)" } },
|
|
743
|
+
"让文本模型也能看图的识图工具(look_image)",
|
|
744
|
+
h("span", { style: { color: "var(--dsw-alias-label-tertiary,#6b7384)" } }, "·"),
|
|
745
|
+
h("a", {
|
|
746
|
+
href: "https://github.com/oadank/dsh-input-tools",
|
|
747
|
+
target: "_blank", rel: "noopener",
|
|
748
|
+
title: "语音插件源码仓库(dsh-input-tools)",
|
|
749
|
+
style: { color: "var(--dsw-alias-link,#5b9cff)", textDecoration: "none" },
|
|
750
|
+
}, "插件仓库 ↗"),
|
|
751
|
+
h("span", { style: { color: "var(--dsw-alias-label-tertiary,#6b7384)" } }, "·"),
|
|
752
|
+
h("a", {
|
|
753
|
+
href: "https://github.com/oadank/deepseek-harness",
|
|
754
|
+
target: "_blank", rel: "noopener",
|
|
755
|
+
title: "整合版:插件已内置,一键安装,推荐大多数用户",
|
|
756
|
+
style: { color: "var(--dsw-alias-link,#5b9cff)", textDecoration: "none" },
|
|
757
|
+
}, "整合版(推荐)↗"),
|
|
758
|
+
),
|
|
759
|
+
),
|
|
760
|
+
// [2026-08-22] 直接可见的说明(分块条目排版)
|
|
761
|
+
h("div", { style: { fontSize: "12px", lineHeight: 1.9, color: "var(--dsw-alias-label-secondary,#9aa3ad)", background: "rgba(128,128,128,.05)", border: "1px solid var(--dsw-alias-border-l1,#333a45)", borderRadius: "8px", padding: "10px 12px" } },
|
|
762
|
+
h("div", { style: { fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)" } }, "识图工具 look_image —— 三种模式(AI 收到图片后按提问自动选择):"),
|
|
763
|
+
h("div", { style: { paddingLeft: "10px" } }, "· describe:看图描述(默认,一两句简要)"),
|
|
764
|
+
h("div", { style: { paddingLeft: "10px" } }, "· reverse:像素级反推生图提示词"),
|
|
765
|
+
h("div", { style: { paddingLeft: "10px" } }, "· text:逐字提取图中文字"),
|
|
766
|
+
h("div", { style: { fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)", marginTop: "6px" } }, "配置:"),
|
|
767
|
+
h("div", { style: { paddingLeft: "10px" } }, "· 「本地」= 本机起的 OpenAI 兼容 /v1 端点(如 ollama 11434/v1,无需 Key)"),
|
|
768
|
+
h("div", { style: { paddingLeft: "10px" } }, "· 「在线」= 云端 API(填地址 + API Key)"),
|
|
769
|
+
h("div", { style: { paddingLeft: "10px", fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)", marginTop: "2px" } }, "首次使用?一键装本地识图(ollama + qwen3-vl:4b-instruct):"),
|
|
770
|
+
h("div", { style: { paddingLeft: "10px" } }, "· 要求:显卡驱动最新,显存 ≥ 3GB(N 卡/A 卡核显均可,跑不满会退回 CPU 慢速)"),
|
|
771
|
+
h("div", { style: { paddingLeft: "10px" } },
|
|
772
|
+
h("span", { style: { opacity: .8 } }, "Windows:"),
|
|
773
|
+
h("code", { style: { background: "rgba(91,156,255,.12)", padding: "1px 6px", borderRadius: "4px", fontFamily: "monospace", fontSize: "11px" } }, "winget install Ollama.Ollama && ollama pull qwen3-vl:4b-instruct"),
|
|
774
|
+
),
|
|
775
|
+
h("div", { style: { paddingLeft: "10px" } },
|
|
776
|
+
h("span", { style: { opacity: .8 } }, "macOS:"),
|
|
777
|
+
h("code", { style: { background: "rgba(91,156,255,.12)", padding: "1px 6px", borderRadius: "4px", fontFamily: "monospace", fontSize: "11px" } }, "brew install ollama && ollama pull qwen3-vl:4b-instruct"),
|
|
778
|
+
),
|
|
779
|
+
h("div", { style: { paddingLeft: "10px" } },
|
|
780
|
+
h("span", { style: { opacity: .8 } }, "Linux:"),
|
|
781
|
+
h("code", { style: { background: "rgba(91,156,255,.12)", padding: "1px 6px", borderRadius: "4px", fontFamily: "monospace", fontSize: "11px" } }, "curl -fsSL https://ollama.com/install.sh | sh && ollama pull qwen3-vl:4b-instruct"),
|
|
782
|
+
),
|
|
783
|
+
h("div", { style: { paddingLeft: "10px" } }, "· 装完在下方「API 地址」填 http://127.0.0.1:11434/v1,模型填 qwen3-vl:4b-instruct,点「测试配置」即可"),
|
|
784
|
+
h("div", { style: { paddingLeft: "10px" } }, "· 「在线」= 云端 API(填地址 + API Key)"),
|
|
785
|
+
h("div", { style: { paddingLeft: "10px", opacity: .85 } }, "下方可测试配置连通、查看/编辑各模式提示词。"),
|
|
786
|
+
),
|
|
787
|
+
// 配置卡片
|
|
788
|
+
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)" } },
|
|
789
|
+
h("div", { style: { display: "flex", flexWrap: "wrap", gap: "10px" } },
|
|
790
|
+
vField("部署位置", h("select", {
|
|
791
|
+
value: visionIsOnline ? "online" : "local",
|
|
792
|
+
onChange: (e) => {
|
|
793
|
+
const p = e.target.value;
|
|
794
|
+
const cur = String(config.vision?.baseUrl ?? "");
|
|
795
|
+
if (p === "online") {
|
|
796
|
+
setVision({ provider: "online", baseUrl: cur && cur !== "http://127.0.0.1:11434/v1" ? cur : "https://api.siliconflow.cn/v1" }, true);
|
|
797
|
+
} else {
|
|
798
|
+
setVision({ provider: "local", baseUrl: cur.startsWith("http://127.0.0.1") ? cur : "http://127.0.0.1:11434/v1" }, true);
|
|
799
|
+
}
|
|
800
|
+
},
|
|
801
|
+
style: vInput,
|
|
802
|
+
},
|
|
803
|
+
h("option", { value: "local" }, "本地"),
|
|
804
|
+
h("option", { value: "online" }, "在线"))),
|
|
805
|
+
vField("API 地址(填到 /v1)", h("input", {
|
|
806
|
+
value: config.vision?.baseUrl ?? "",
|
|
807
|
+
onChange: (e) => setVision({ baseUrl: e.target.value }, true),
|
|
808
|
+
placeholder: visionIsOnline ? "https://api.siliconflow.cn/v1" : "http://127.0.0.1:11434/v1",
|
|
809
|
+
style: vInput,
|
|
810
|
+
})),
|
|
811
|
+
vField("模型", h("input", {
|
|
812
|
+
value: config.vision?.model ?? "",
|
|
813
|
+
onChange: (e) => setVision({ model: e.target.value }, true),
|
|
814
|
+
placeholder: "qwen3-vl:4b-instruct",
|
|
815
|
+
style: vInput,
|
|
816
|
+
})),
|
|
817
|
+
visionIsOnline
|
|
818
|
+
? secretField("API Key(在线服务必填)", "vision", config.vision?.apiKey ?? "", (e) => setVision({ apiKey: e.target.value }, true), "sk-...")
|
|
819
|
+
: null,
|
|
820
|
+
),
|
|
821
|
+
// 测试区:缩略图(点击放大) + 模式(带?介绍与✎编辑) + 测试按钮
|
|
822
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: "12px", flexWrap: "wrap", borderTop: "1px solid var(--dsw-alias-border-l1,#333a45)", paddingTop: "10px" } },
|
|
823
|
+
h("img", {
|
|
824
|
+
src: "/voice-config/vision-test-image",
|
|
825
|
+
alt: "测试图(点击放大)",
|
|
826
|
+
title: "点击放大查看测试图",
|
|
827
|
+
onClick: () => setVisionImgZoom(true),
|
|
828
|
+
style: { width: "64px", height: "64px", objectFit: "cover", borderRadius: "8px", border: "1px solid var(--dsw-alias-border-l1,#333a45)", flex: "none", background: "rgba(128,128,128,.1)", cursor: "zoom-in" },
|
|
829
|
+
}),
|
|
830
|
+
h("div", { style: { display: "flex", flexDirection: "column", gap: "6px", flex: "1", minWidth: "260px" } },
|
|
831
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" } },
|
|
832
|
+
h("span", { style: { fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)", flex: "none" } }, "测试模式:"),
|
|
833
|
+
h("select", {
|
|
834
|
+
value: visionTestTask,
|
|
835
|
+
onChange: (e) => setVisionTestTask(e.target.value),
|
|
836
|
+
style: { ...vInput, width: "auto", padding: "3px 8px", fontSize: "12px" },
|
|
837
|
+
},
|
|
838
|
+
h("option", { value: "describe" }, "describe 看图描述"),
|
|
839
|
+
h("option", { value: "reverse" }, "reverse 反推提示词"),
|
|
840
|
+
h("option", { value: "text" }, "text 提取文字")),
|
|
841
|
+
helpTip(VISION_MODE_INTRO[visionTestTask] ?? VISION_MODE_INTRO.describe,
|
|
842
|
+
visionModeTipPinned, setVisionModeTipPinned, visionModeTipHover, setVisionModeTipHover, "center", "top"),
|
|
843
|
+
h("button", {
|
|
844
|
+
type: "button",
|
|
845
|
+
title: "查看/编辑「" + (VISION_TASK_LABELS[visionTestTask] ?? visionTestTask) + "」的提示词",
|
|
846
|
+
"aria-label": "编辑工具提示词",
|
|
847
|
+
style: {
|
|
848
|
+
border: "none", background: "none", cursor: "pointer", padding: "2px 6px",
|
|
849
|
+
color: "var(--dsw-alias-link,#5b9cff)", fontSize: "12px", lineHeight: "1.4",
|
|
850
|
+
display: "inline-flex", alignItems: "center", gap: "3px", borderRadius: "6px",
|
|
851
|
+
},
|
|
852
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
853
|
+
onClick: () => openPromptEditor(visionTestTask),
|
|
854
|
+
}, "✎ 编辑工具提示词"),
|
|
855
|
+
h("button", {
|
|
856
|
+
type: "button",
|
|
857
|
+
style: {
|
|
858
|
+
border: "none", borderRadius: "999px", padding: "7px 16px", fontSize: "12.5px", fontWeight: 600,
|
|
859
|
+
background: visionTestResult?.busy ? "rgba(229,72,77,.85)" : "rgba(128,128,128,.15)",
|
|
860
|
+
color: "inherit", cursor: "pointer",
|
|
861
|
+
},
|
|
862
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
863
|
+
onClick: testVision,
|
|
864
|
+
}, visionTestResult?.busy ? "测试中…" : "测试识图")),
|
|
865
|
+
),
|
|
866
|
+
),
|
|
867
|
+
// 测试结果:状态行在框外,文本框只放识别内容
|
|
868
|
+
visionTestResult !== null ? h("div", { style: { display: "flex", flexDirection: "column", gap: "4px", borderTop: "1px solid var(--dsw-alias-border-l1,#333a45)", paddingTop: "8px" } },
|
|
869
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: "8px", fontSize: "11px", opacity: .8 } },
|
|
870
|
+
h("span", { style: { color: visionTestResult.ok ? "#3ecf8e" : "#e5484d" } }, visionTestResult.ok ? "✅ 识图成功" : "❌ 识别失败"),
|
|
871
|
+
visionTestResult.ok && visionTestResult.durationMs !== undefined
|
|
872
|
+
? h("span", {}, "耗时 " + (visionTestResult.durationMs / 1000).toFixed(1) + "s" + (visionTestResult.model ? " · " + visionTestResult.model : ""))
|
|
873
|
+
: null,
|
|
874
|
+
h("span", { style: { marginLeft: "auto", flex: "none" } },
|
|
875
|
+
(visionTestResult.text ?? "").length > 120 ? h("button", {
|
|
876
|
+
type: "button",
|
|
877
|
+
style: { border: "none", borderRadius: "6px", padding: "3px 10px", fontSize: "11px", fontWeight: 600, background: "rgba(91,156,255,.18)", color: "var(--dsw-alias-link,#5b9cff)", cursor: "pointer" },
|
|
878
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
879
|
+
onClick: () => setVisionZoom(visionTestResult.text),
|
|
880
|
+
}, "🔍 放大查看") : null)),
|
|
881
|
+
h("div", { style: {
|
|
882
|
+
border: "1px solid " + (visionTestResult.ok ? "rgba(62,207,142,.4)" : "rgba(229,72,77,.4)"),
|
|
883
|
+
borderRadius: "8px", padding: "8px 10px", fontSize: "12px", lineHeight: "1.6",
|
|
884
|
+
background: "rgba(128,128,128,.06)", maxHeight: "140px", overflowY: "auto", whiteSpace: "pre-wrap", wordBreak: "break-word",
|
|
885
|
+
} },
|
|
886
|
+
visionTestResult.busy ? visionTestResult.text : String(visionTestResult.text).slice(0, 120) + (String(visionTestResult.text).length > 120 ? "…" : "")),
|
|
887
|
+
) : null,
|
|
888
|
+
),
|
|
889
|
+
// [2026-08-22] 放大查看识图结果(只读,AI 输出不可编辑)
|
|
890
|
+
visionZoom !== null ? h("div", {
|
|
891
|
+
style: {
|
|
892
|
+
position: "fixed", inset: "0", zIndex: 9998, background: "rgba(0,0,0,.6)",
|
|
893
|
+
display: "flex", alignItems: "center", justifyContent: "center", padding: "24px",
|
|
894
|
+
},
|
|
895
|
+
onMouseDown: (e) => { if (e.target === e.currentTarget) setVisionZoom(null); },
|
|
896
|
+
},
|
|
897
|
+
h("div", { style: {
|
|
898
|
+
background: "var(--dsw-alias-bg-primary,#1e222a)", border: "1px solid var(--dsw-alias-border-l1,#333a45)",
|
|
899
|
+
borderRadius: "12px", width: "min(760px, 92vw)", maxHeight: "82vh", display: "flex", flexDirection: "column",
|
|
900
|
+
boxShadow: "0 12px 48px rgba(0,0,0,.5)",
|
|
901
|
+
} },
|
|
902
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: "8px", padding: "10px 14px", borderBottom: "1px solid var(--dsw-alias-border-l1,#333a45)", fontSize: "13px", fontWeight: 600 } },
|
|
903
|
+
"🔍 识图结果",
|
|
904
|
+
h("span", { style: { marginLeft: "auto", display: "flex", gap: "6px" } },
|
|
905
|
+
h("button", { type: "button", style: btnSmall, onMouseDown: (e) => e.preventDefault(), onClick: () => copyText(visionZoom) }, "一键复制"),
|
|
906
|
+
h("button", {
|
|
907
|
+
type: "button",
|
|
908
|
+
style: { ...btnSmall, background: "rgba(229,72,77,.2)", color: "#e5484d" },
|
|
909
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
910
|
+
onClick: () => setVisionZoom(null),
|
|
911
|
+
}, "关闭"))),
|
|
912
|
+
h("pre", {
|
|
913
|
+
spellCheck: false,
|
|
914
|
+
style: {
|
|
915
|
+
flex: "1", minHeight: "320px", margin: "12px 14px", padding: "10px 12px",
|
|
916
|
+
background: "rgba(128,128,128,.06)", color: "inherit", border: "1px solid var(--dsw-alias-border-l1,#333a45)",
|
|
917
|
+
borderRadius: "8px", fontSize: "12.5px", lineHeight: "1.7", fontFamily: "inherit",
|
|
918
|
+
whiteSpace: "pre-wrap", wordBreak: "break-word", overflowY: "auto", userSelect: "text",
|
|
919
|
+
},
|
|
920
|
+
}, visionZoom),
|
|
921
|
+
),
|
|
922
|
+
) : null,
|
|
923
|
+
// [2026-08-22] 提示词弹窗(参考 comfyui promptModal):
|
|
924
|
+
// textarea + readonly 只读(无光标);复制按钮常驻标题栏(带兜底+反馈);
|
|
925
|
+
// 编辑/保存 toggle;恢复默认右下角常驻;保存后回只读且弹窗不关
|
|
926
|
+
visionEditKey !== null ? h("div", {
|
|
927
|
+
style: {
|
|
928
|
+
position: "fixed", inset: "0", zIndex: 9998, background: "rgba(0,0,0,.6)",
|
|
929
|
+
display: "flex", alignItems: "center", justifyContent: "center", padding: "24px",
|
|
930
|
+
},
|
|
931
|
+
onMouseDown: (e) => { if (e.target === e.currentTarget) setVisionEditKey(null); },
|
|
932
|
+
},
|
|
933
|
+
h("div", { style: {
|
|
934
|
+
background: "var(--dsw-alias-bg-primary,#1e222a)", border: "1px solid var(--dsw-alias-border-l1,#333a45)",
|
|
935
|
+
borderRadius: "12px", width: "min(760px, 92vw)", maxHeight: "82vh", display: "flex", flexDirection: "column",
|
|
936
|
+
boxShadow: "0 12px 48px rgba(0,0,0,.5)",
|
|
937
|
+
} },
|
|
938
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: "8px", padding: "10px 14px", borderBottom: "1px solid var(--dsw-alias-border-l1,#333a45)", fontSize: "13px", fontWeight: 600 } },
|
|
939
|
+
(VISION_TASK_LABELS[visionEditKey] ?? visionEditKey) + " 提示词",
|
|
940
|
+
h("span", { style: { marginLeft: "auto", display: "flex", gap: "6px" } },
|
|
941
|
+
h("button", {
|
|
942
|
+
type: "button",
|
|
943
|
+
style: { ...btnSmall, ...(visionCopyState === "copied" ? { background: "rgba(62,207,142,.25)", color: "#3ecf8e" } : visionCopyState === "fail" ? { background: "rgba(229,72,77,.2)", color: "#e5484d" } : {}) },
|
|
944
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
945
|
+
onClick: () => copyWithFeedback(visionEditDraft),
|
|
946
|
+
}, visionCopyState === "copied" ? "✅ 已复制" : visionCopyState === "fail" ? "❌ 复制失败" : "✂️ 一键复制"),
|
|
947
|
+
visionEditMode === "view"
|
|
948
|
+
? h("button", { type: "button", style: { ...btnSmall, background: "rgba(91,156,255,.2)", color: "var(--dsw-alias-link,#5b9cff)" }, onMouseDown: (e) => e.preventDefault(), onClick: () => setVisionEditMode("edit") }, "✏️ 编辑")
|
|
949
|
+
: visionEditMode === "saved"
|
|
950
|
+
? h("button", { type: "button", style: { ...btnSmall, background: "rgba(62,207,142,.2)", color: "#3ecf8e" }, onMouseDown: (e) => e.preventDefault() }, "✅ 已保存")
|
|
951
|
+
: h("button", { type: "button", style: { ...btnSmall, background: "rgba(62,207,142,.25)", color: "#3ecf8e" }, onMouseDown: (e) => e.preventDefault(), onClick: () => doSavePromptEdit(visionEditKey) }, "💾 保存"),
|
|
952
|
+
h("button", {
|
|
953
|
+
type: "button",
|
|
954
|
+
style: { ...btnSmall, background: "rgba(229,72,77,.2)", color: "#e5484d" },
|
|
955
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
956
|
+
onClick: () => setVisionEditKey(null),
|
|
957
|
+
}, "✕ 关闭"))),
|
|
958
|
+
h("textarea", {
|
|
959
|
+
value: visionEditDraft,
|
|
960
|
+
onChange: (e) => setVisionEditDraft(e.target.value),
|
|
961
|
+
readOnly: visionEditMode !== "edit",
|
|
962
|
+
spellCheck: false,
|
|
963
|
+
style: {
|
|
964
|
+
flex: "1", minHeight: "320px", margin: "12px 14px", padding: "10px 12px",
|
|
965
|
+
background: "rgba(128,128,128,.06)", color: "inherit", border: "1px solid var(--dsw-alias-border-l1,#333a45)",
|
|
966
|
+
borderRadius: "8px", fontSize: "12.5px", lineHeight: "1.7", fontFamily: "inherit", whiteSpace: "pre-wrap", resize: "vertical",
|
|
967
|
+
...(visionEditMode === "edit" ? { outline: "2px solid var(--vk-accent,#4b6fff)" } : { outline: "none" }),
|
|
968
|
+
},
|
|
969
|
+
}),
|
|
970
|
+
h("div", { style: { padding: "0 14px 10px", display: "flex", alignItems: "center", justifyContent: "flex-end", gap: "8px", fontSize: "11px", opacity: .75 } },
|
|
971
|
+
h("button", {
|
|
972
|
+
type: "button",
|
|
973
|
+
style: { border: "none", background: "none", cursor: "pointer", color: "#e5a53a", fontSize: "11.5px", textDecoration: "underline" },
|
|
974
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
975
|
+
onClick: () => resetPromptEdit(visionEditKey),
|
|
976
|
+
}, "↺ 恢复默认"),
|
|
977
|
+
),
|
|
978
|
+
),
|
|
979
|
+
) : null,
|
|
980
|
+
// [2026-08-22] 测试图点击放大(大图 modal)
|
|
981
|
+
visionImgZoom ? h("div", {
|
|
982
|
+
style: {
|
|
983
|
+
position: "fixed", inset: "0", zIndex: 9998, background: "rgba(0,0,0,.72)",
|
|
984
|
+
display: "flex", alignItems: "center", justifyContent: "center", padding: "24px",
|
|
985
|
+
},
|
|
986
|
+
onMouseDown: (e) => { if (e.target === e.currentTarget) setVisionImgZoom(false); },
|
|
987
|
+
},
|
|
988
|
+
h("div", { style: { position: "relative", maxWidth: "92vw", maxHeight: "88vh" } },
|
|
989
|
+
h("img", {
|
|
990
|
+
src: "/voice-config/vision-test-image",
|
|
991
|
+
alt: "测试图大图",
|
|
992
|
+
title: "点击缩小",
|
|
993
|
+
onClick: () => setVisionImgZoom(false),
|
|
994
|
+
style: { maxWidth: "92vw", maxHeight: "88vh", objectFit: "contain", borderRadius: "10px", boxShadow: "0 12px 48px rgba(0,0,0,.6)", display: "block", background: "rgba(255,255,255,.04)", cursor: "zoom-out" },
|
|
995
|
+
}),
|
|
996
|
+
h("button", {
|
|
997
|
+
type: "button",
|
|
998
|
+
title: "关闭",
|
|
999
|
+
style: {
|
|
1000
|
+
position: "absolute", top: "-12px", right: "-12px",
|
|
1001
|
+
border: "none", borderRadius: "999px", width: "30px", height: "30px",
|
|
1002
|
+
background: "rgba(229,72,77,.9)", color: "#fff", fontSize: "16px", cursor: "pointer",
|
|
1003
|
+
display: "flex", alignItems: "center", justifyContent: "center",
|
|
1004
|
+
},
|
|
1005
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
1006
|
+
onClick: () => setVisionImgZoom(false),
|
|
1007
|
+
}, "✕"),
|
|
1008
|
+
),
|
|
1009
|
+
) : null,
|
|
1010
|
+
);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function VoiceSettingsSection() {
|
|
1014
|
+
const [config, setConfig] = useState(null);
|
|
1015
|
+
const [meta, setMeta] = useState(null);
|
|
1016
|
+
// [2026-08-21] 语音能力状态面板(安装即用能力 vs dsh 原生契约支持)
|
|
1017
|
+
const [caps, setCaps] = useState(null);
|
|
1018
|
+
useEffect(() => {
|
|
1019
|
+
fetch("/voice/capabilities").then((r) => r.json()).then((d) => {
|
|
1020
|
+
if (d?.ok) setCaps(d.capabilities);
|
|
1021
|
+
}).catch(() => { /* 检测失败不阻塞设置页 */ });
|
|
1022
|
+
}, []);
|
|
1023
|
+
// [本地改造 2026-08-21] 服务商卡片折叠状态(去复选框后由折叠控制显隐,默认展开)
|
|
1024
|
+
const [openCards, setOpenCards] = useState({ edge: true, xiaomi: true, local: true, ali: true });
|
|
1025
|
+
const toggleCard = (key) => setOpenCards((s) => ({ ...s, [key]: !s[key] }));
|
|
1026
|
+
const [previewing, setPreviewing] = useState(null); // 正在试听的标识:engine / emotion:key / style:key
|
|
1027
|
+
const [rulesPinned, setRulesPinned] = useState(false);
|
|
1028
|
+
const [rulesHover, setRulesHover] = useState(false);
|
|
1029
|
+
const [cloneListTipPinned, setCloneListTipPinned] = useState(false);
|
|
1030
|
+
const [cloneListTipHover, setCloneListTipHover] = useState(false);
|
|
1031
|
+
// [本地改造 2026-08-22] 克隆音色「?」弹层:显示该音色默认沟通指令 + 试听文本(用户想看到,之前是隐藏的)
|
|
1032
|
+
const [cloneInfoId, setCloneInfoId] = useState(null); // 当前展开信息的样本 id(hover 或 pinned)
|
|
1033
|
+
const [cloneInfoPinned, setCloneInfoPinned] = useState(false);
|
|
1034
|
+
const [designTipPinned, setDesignTipPinned] = useState(false);
|
|
1035
|
+
const [designTipHover, setDesignTipHover] = useState(false);
|
|
1036
|
+
const [asrTipPinned, setAsrTipPinned] = useState(false);
|
|
1037
|
+
const [asrTipHover, setAsrTipHover] = useState(false);
|
|
1038
|
+
const [xmTipPinned, setXmTipPinned] = useState(false);
|
|
1039
|
+
const [xmTipHover, setXmTipHover] = useState(false);
|
|
1040
|
+
// 本地 TTS 卡片标题的 ? 提示 state
|
|
1041
|
+
const [localTipPinned, setLocalTipPinned] = useState(false);
|
|
1042
|
+
const [localTipHover, setLocalTipHover] = useState(false);
|
|
1043
|
+
// 3 个 VoiceDesign 官方示例的 Instruct/Text 悬浮提示 state
|
|
1044
|
+
const [vdExamplePins, setVdExamplePins] = useState([false, false, false]);
|
|
1045
|
+
const [vdExampleHovers, setVdExampleHovers] = useState([false, false, false]);
|
|
1046
|
+
const previewRef = useRef(null);
|
|
1047
|
+
const previewTagRef = useRef(null); // [本地改造 2026-08-21] 当前播放的试听 tag,用于「再点=停止」
|
|
1048
|
+
const newCloneNameRef = useRef(null);
|
|
1049
|
+
const newClonePathRef = useRef(null);
|
|
1050
|
+
// ASR 语音识别测试状态(示例音频 + 识别)
|
|
1051
|
+
const [asrResult, setAsrResult] = useState(null); // { ok, text, busy } | null
|
|
1052
|
+
// [本地改造 2026-08-21] 克隆样本添加(选择音频 → 上传 → 命名)
|
|
1053
|
+
const [cloneName, setCloneName] = useState("");
|
|
1054
|
+
// [本地改造 2026-08-22] 添加克隆音色还需提供:指令(默认沟通语气)+ 文本(试听念的内容)
|
|
1055
|
+
const [cloneContext, setCloneContext] = useState("");
|
|
1056
|
+
const [clonePreviewText, setClonePreviewText] = useState("");
|
|
1057
|
+
const [addingClone, setAddingClone] = useState(false);
|
|
1058
|
+
const [cloneAddMsg, setCloneAddMsg] = useState(null); // { ok, text } | null
|
|
1059
|
+
const cloneFileRef = useRef(null);
|
|
1060
|
+
const asrAudioRef = useRef(null);
|
|
1061
|
+
const asrSampleBase64Ref = useRef(null);
|
|
1062
|
+
const [asrInstalling, setAsrInstalling] = useState(false); // 一键安装进行中
|
|
1063
|
+
const [asrCmd, setAsrCmd] = useState(null); // 待手动复制的安装命令
|
|
1064
|
+
const [vdSamples, setVdSamples] = useState([]); // VoiceDesign 官方示例音频(预生成)
|
|
1065
|
+
// [2026-08-21] 试听失败的错误提示(之前失败静默无反馈)
|
|
1066
|
+
const [previewErr, setPreviewErr] = useState(null);
|
|
1067
|
+
// [2026-08-22] 试听失败浮动 Toast(fixed 顶部居中,醒目弹窗式,6 秒自动消失)
|
|
1068
|
+
useEffect(() => {
|
|
1069
|
+
if (previewErr === null) return;
|
|
1070
|
+
const t = window.setTimeout(() => setPreviewErr(null), 6000);
|
|
1071
|
+
return () => window.clearTimeout(t);
|
|
1072
|
+
}, [previewErr]);
|
|
1073
|
+
// [2026-08-21] API Key 明文/密文切换(眼睛图标)
|
|
1074
|
+
const [showKeys, setShowKeys] = useState({});
|
|
1075
|
+
// [2026-08-21] 本地 TTS 一键安装命令
|
|
1076
|
+
const [ttsInstalling, setTtsInstalling] = useState(false);
|
|
1077
|
+
const [ttsCmd, setTtsCmd] = useState(null);
|
|
1078
|
+
// [2026-08-21] 密钥输入框 + 眼睛切换(明文/密文),keyName 作 state map 键
|
|
1079
|
+
const secretField = (labelText, keyName, value, onChange, placeholder) => h("label", {
|
|
1080
|
+
style: { display: "flex", flexDirection: "column", gap: "4px", fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)", flex: "1 1 45%", minWidth: "220px" },
|
|
1081
|
+
}, labelText,
|
|
1082
|
+
h("div", { style: { display: "flex", gap: "6px", alignItems: "center" } },
|
|
1083
|
+
h("input", {
|
|
1084
|
+
type: showKeys[keyName] ? "text" : "password",
|
|
1085
|
+
value: value,
|
|
1086
|
+
onChange: onChange,
|
|
1087
|
+
placeholder: placeholder,
|
|
1088
|
+
style: { ...vInput, flex: 1 },
|
|
1089
|
+
}),
|
|
1090
|
+
h("button", {
|
|
1091
|
+
type: "button", "aria-label": showKeys[keyName] ? "隐藏密钥" : "显示密钥", title: showKeys[keyName] ? "隐藏密钥" : "显示密钥",
|
|
1092
|
+
style: {
|
|
1093
|
+
border: "none", borderRadius: "6px", width: "32px", height: "32px", flex: "none",
|
|
1094
|
+
background: "rgba(128,128,128,.12)", color: "inherit", cursor: "pointer", fontSize: "14px",
|
|
1095
|
+
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
|
1096
|
+
},
|
|
1097
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
1098
|
+
onClick: () => setShowKeys((s) => ({ ...s, [keyName]: !s[keyName] })),
|
|
1099
|
+
}, showKeys[keyName] ? "🙈" : "👁"),
|
|
1100
|
+
),
|
|
1101
|
+
);
|
|
1102
|
+
|
|
1103
|
+
useEffect(() => {
|
|
1104
|
+
let dead = false;
|
|
1105
|
+
fetch("/voice-config").then((r) => r.json()).then((d) => { if (!dead && d?.ok) setConfig(d.config); }).catch(() => {});
|
|
1106
|
+
fetch("/voice-config/engines").then((r) => r.json()).then((d) => { if (!dead && d?.ok) setMeta(d.engines); }).catch(() => {});
|
|
1107
|
+
// [2026-08-22] 识图内置默认提示词(编辑弹窗预填用)
|
|
1108
|
+
fetch("/voice-config/vision-prompts").then((r) => r.json()).then((d) => { if (!dead && d?.ok) setVisionDefaults(d.defaults); }).catch(() => {});
|
|
1109
|
+
// 自动加载 ASR 示例音频(无需手动点"加载")
|
|
1110
|
+
fetch("/asr/sample").then((r) => r.json()).then((d) => {
|
|
1111
|
+
if (!dead && d?.ok) {
|
|
1112
|
+
asrSampleBase64Ref.current = d.data;
|
|
1113
|
+
if (asrAudioRef.current !== null) asrAudioRef.current.src = "data:" + d.mediaType + ";base64," + d.data;
|
|
1114
|
+
}
|
|
1115
|
+
}).catch(() => {});
|
|
1116
|
+
// 自动加载 VoiceDesign 官方示例音频(预生成)
|
|
1117
|
+
fetch("/asr/voice-design-samples").then((r) => r.json()).then((d) => {
|
|
1118
|
+
if (!dead && d?.ok && Array.isArray(d.samples)) setVdSamples(d.samples);
|
|
1119
|
+
}).catch(() => {});
|
|
1120
|
+
// 自动检测本机 ASR 组件(不覆盖用户已保存的配置,只静默记录)
|
|
1121
|
+
fetch("/asr/detect").then((r) => r.json()).then((d) => {
|
|
1122
|
+
if (!dead && d?.ok && d.detected?.serviceOk) {
|
|
1123
|
+
// 服务可达时静默确保 url 已填
|
|
1124
|
+
}
|
|
1125
|
+
}).catch(() => {});
|
|
1126
|
+
return () => { dead = true; if (previewRef.current !== null) previewRef.current.pause(); };
|
|
1127
|
+
}, []);
|
|
1128
|
+
|
|
1129
|
+
// 自动保存的 setEngine(勾选/输入变化后立即持久化,防止刷新丢失;无保存按钮)
|
|
1130
|
+
// [本地改造 2026-08-21] 保存后以服务端返回的 config 为准刷新本地 state——
|
|
1131
|
+
// 避免"前端旧 config 全量覆盖服务端新变更"(如服务端新加的克隆样本被清空)
|
|
1132
|
+
const saveTimerRef = useRef(null);
|
|
1133
|
+
const setEngine = (key, patch, autoSave) => {
|
|
1134
|
+
setConfig((c) => {
|
|
1135
|
+
if (c === null) return c;
|
|
1136
|
+
const next = { ...c, engines: { ...c.engines, [key]: { ...c.engines[key], ...patch } } };
|
|
1137
|
+
if (autoSave) {
|
|
1138
|
+
if (saveTimerRef.current !== null) window.clearTimeout(saveTimerRef.current);
|
|
1139
|
+
saveTimerRef.current = window.setTimeout(() => {
|
|
1140
|
+
fetch("/voice-config", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ config: next }) })
|
|
1141
|
+
.then((r) => r.json())
|
|
1142
|
+
.then((d) => { if (d?.ok && d.config) setConfig(d.config); })
|
|
1143
|
+
.catch(() => {});
|
|
1144
|
+
}, 400);
|
|
1145
|
+
}
|
|
1146
|
+
return next;
|
|
1147
|
+
});
|
|
1148
|
+
};
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
// [本地改造 2026-08-21] 克隆样本添加:选音频文件 → 校验格式/大小 → 上传命名
|
|
1152
|
+
const addCloneSample = async (file) => {
|
|
1153
|
+
if (file === null || file === undefined) return;
|
|
1154
|
+
setCloneAddMsg(null);
|
|
1155
|
+
if (!/\.(mp3|wav)$/i.test(file.name) && !/audio\/(mpeg|wav)/.test(file.type)) {
|
|
1156
|
+
setCloneAddMsg({ ok: false, text: "仅支持 mp3 / wav 格式" });
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
if (file.size > 10 * 1024 * 1024) {
|
|
1160
|
+
setCloneAddMsg({ ok: false, text: "音频需在 10MB 以内(官方限制;参考语音建议 15-60 秒,越长克隆越准)" });
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
const reader = new FileReader();
|
|
1164
|
+
const data = await new Promise((resolve, reject) => {
|
|
1165
|
+
reader.onload = () => resolve(String(reader.result).split(",")[1] ?? "");
|
|
1166
|
+
reader.onerror = reject;
|
|
1167
|
+
reader.readAsDataURL(file);
|
|
1168
|
+
});
|
|
1169
|
+
setAddingClone(true);
|
|
1170
|
+
try {
|
|
1171
|
+
const r = await fetch("/voice-config/voice-clone/add", {
|
|
1172
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
1173
|
+
body: JSON.stringify({
|
|
1174
|
+
name: cloneName.trim() !== "" ? cloneName.trim() : file.name.replace(/\.(mp3|wav)$/i, ""),
|
|
1175
|
+
audioBase64: data,
|
|
1176
|
+
mediaType: file.type || "audio/wav",
|
|
1177
|
+
// [本地改造 2026-08-22] 提供 3 样:指令(默认沟通语气)+ 文本(试听内容)+ 样本音频
|
|
1178
|
+
context: cloneContext,
|
|
1179
|
+
previewText: clonePreviewText,
|
|
1180
|
+
}),
|
|
1181
|
+
});
|
|
1182
|
+
const d = await r.json();
|
|
1183
|
+
if (d?.ok) {
|
|
1184
|
+
setCloneAddMsg({ ok: true, text: "已添加克隆音色「" + d.sample.name + "」,如需默认使用,在「默认语音引擎」选「小米克隆」即可" });
|
|
1185
|
+
setCloneName("");
|
|
1186
|
+
setCloneContext("");
|
|
1187
|
+
setClonePreviewText("");
|
|
1188
|
+
// [本地改造 2026-08-21] 以服务端返回的 config 为准刷新(含新增样本),避免本地拼装丢字段
|
|
1189
|
+
if (d.config) setConfig(d.config);
|
|
1190
|
+
} else {
|
|
1191
|
+
setCloneAddMsg({ ok: false, text: d?.error ?? "添加失败" });
|
|
1192
|
+
}
|
|
1193
|
+
} catch (e) {
|
|
1194
|
+
setCloneAddMsg({ ok: false, text: String(e?.message ?? e) });
|
|
1195
|
+
}
|
|
1196
|
+
setAddingClone(false);
|
|
1197
|
+
if (cloneFileRef.current !== null) cloneFileRef.current.value = "";
|
|
1198
|
+
};
|
|
1199
|
+
|
|
1200
|
+
// 音色试听:POST /voice-config/preview → 播放返回音频;tag 用于区分多个试听按钮状态;text/cmd/url 可临时指定
|
|
1201
|
+
// [2026-08-21] 失败时显示错误(之前静默无提示,用户填错 API Key 毫无反馈)
|
|
1202
|
+
const previewVoice = (engine, voice, context, samplePath, tag, extra) => {
|
|
1203
|
+
if (previewRef.current !== null) { previewRef.current.pause(); previewRef.current = null; }
|
|
1204
|
+
const curTag = tag ?? engine;
|
|
1205
|
+
previewTagRef.current = curTag;
|
|
1206
|
+
setPreviewing(curTag);
|
|
1207
|
+
setPreviewErr(null);
|
|
1208
|
+
fetch("/voice-config/preview", {
|
|
1209
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
1210
|
+
body: JSON.stringify({
|
|
1211
|
+
engine, voice: voice ?? undefined, context: context ?? undefined, samplePath: samplePath ?? undefined,
|
|
1212
|
+
text: extra?.text ?? undefined, cmd: extra?.cmd ?? undefined, url: extra?.url ?? undefined,
|
|
1213
|
+
cloneContext: extra?.cloneContext ?? undefined, // [2026-08-22] 克隆试听可带样本自带指令
|
|
1214
|
+
}),
|
|
1215
|
+
})
|
|
1216
|
+
.then((r) => r.json())
|
|
1217
|
+
.then((d) => {
|
|
1218
|
+
if (!d?.ok) { if (previewTagRef.current === curTag) setPreviewing(null); setPreviewErr(d?.error ?? "试听失败"); return; }
|
|
1219
|
+
if (previewTagRef.current !== curTag) return; // 已被「再点=停止」或切换,丢弃
|
|
1220
|
+
const audio = new Audio("data:" + d.mediaType + ";base64," + d.data);
|
|
1221
|
+
previewRef.current = audio;
|
|
1222
|
+
audio.onended = () => { if (previewTagRef.current === curTag) setPreviewing(null); };
|
|
1223
|
+
audio.onerror = () => {
|
|
1224
|
+
if (previewTagRef.current === curTag) {
|
|
1225
|
+
setPreviewing(null);
|
|
1226
|
+
setPreviewErr("音频加载/播放失败(服务可能返回了无效音频)");
|
|
1227
|
+
}
|
|
1228
|
+
};
|
|
1229
|
+
audio.play().catch(() => {
|
|
1230
|
+
if (previewTagRef.current === curTag) {
|
|
1231
|
+
setPreviewing(null);
|
|
1232
|
+
setPreviewErr("音频加载/播放失败(服务可能返回了无效音频)");
|
|
1233
|
+
}
|
|
1234
|
+
});
|
|
1235
|
+
})
|
|
1236
|
+
.catch((e) => { if (previewTagRef.current === curTag) setPreviewing(null); setPreviewErr(String(e?.message ?? e)); });
|
|
1237
|
+
};
|
|
1238
|
+
|
|
1239
|
+
// [本地改造 2026-08-21] 试听克隆样本的原始音频(用于和克隆合成效果对比还原度)
|
|
1240
|
+
const previewSourceVoice = async (path, tag) => {
|
|
1241
|
+
if (previewRef.current !== null) { previewRef.current.pause(); previewRef.current = null; }
|
|
1242
|
+
previewTagRef.current = tag;
|
|
1243
|
+
setPreviewing(tag);
|
|
1244
|
+
try {
|
|
1245
|
+
const r = await fetch("/voice-config/voice-clone/source", {
|
|
1246
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
1247
|
+
body: JSON.stringify({ path }),
|
|
1248
|
+
});
|
|
1249
|
+
const d = await r.json();
|
|
1250
|
+
if (!d?.ok) { if (previewTagRef.current === tag) setPreviewing(null); return; }
|
|
1251
|
+
if (previewTagRef.current !== tag) return; // 已被「再点=停止」或切换,丢弃
|
|
1252
|
+
const audio = new Audio("data:" + d.mediaType + ";base64," + d.data);
|
|
1253
|
+
previewRef.current = audio;
|
|
1254
|
+
audio.onended = () => { if (previewTagRef.current === tag) setPreviewing(null); };
|
|
1255
|
+
audio.onerror = () => { if (previewTagRef.current === tag) setPreviewing(null); };
|
|
1256
|
+
audio.play().catch(() => { if (previewTagRef.current === tag) setPreviewing(null); });
|
|
1257
|
+
} catch { if (previewTagRef.current === tag) setPreviewing(null); }
|
|
1258
|
+
};
|
|
1259
|
+
|
|
1260
|
+
// [本地改造 2026-08-22] 播放合成试听录音:默认样本=预生成静态文件(免联网,和 VoiceDesign 官方示例同类);
|
|
1261
|
+
// 没有预生成录音(自建样本)→ 回退在线合成,并带上该样本自己的指令/文本
|
|
1262
|
+
const playBakedPreview = async (sp, tag) => {
|
|
1263
|
+
if (previewRef.current !== null) { previewRef.current.pause(); previewRef.current = null; }
|
|
1264
|
+
previewTagRef.current = tag;
|
|
1265
|
+
setPreviewing(tag);
|
|
1266
|
+
setPreviewErr(null);
|
|
1267
|
+
try {
|
|
1268
|
+
const r = await fetch("/voice-config/voice-clone/preview-sample?id=" + encodeURIComponent(sp.id));
|
|
1269
|
+
const d = await r.json();
|
|
1270
|
+
if (!d?.ok) {
|
|
1271
|
+
previewTagRef.current = null;
|
|
1272
|
+
setPreviewing(null);
|
|
1273
|
+
previewVoice("voiceclone", undefined, undefined, sp.path, tag, {
|
|
1274
|
+
text: (sp.previewText && sp.previewText.trim() !== "") ? sp.previewText : CLONE_PREVIEW_TEXT,
|
|
1275
|
+
cloneContext: (sp.context && sp.context.trim() !== "") ? sp.context : "",
|
|
1276
|
+
});
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
const audio = new Audio("data:" + d.mediaType + ";base64," + d.data);
|
|
1280
|
+
previewRef.current = audio;
|
|
1281
|
+
audio.onended = () => { if (previewTagRef.current === tag) setPreviewing(null); };
|
|
1282
|
+
audio.onerror = () => { if (previewTagRef.current === tag) setPreviewing(null); setPreviewErr("音频加载失败(试听录音可能已损坏)"); };
|
|
1283
|
+
audio.play().catch(() => { if (previewTagRef.current === tag) setPreviewing(null); });
|
|
1284
|
+
} catch (e) { if (previewTagRef.current === tag) setPreviewing(null); setPreviewErr(String(e?.message ?? e)); }
|
|
1285
|
+
};
|
|
1286
|
+
|
|
1287
|
+
const previewBtn = (tag, label, onClick, icon) => h("button", {
|
|
1288
|
+
type: "button", "aria-label": label, title: label,
|
|
1289
|
+
style: {
|
|
1290
|
+
border: "none", borderRadius: "6px", width: "30px", height: "30px", flex: "none",
|
|
1291
|
+
background: previewing === tag ? "rgba(229,72,77,.25)" : "rgba(128,128,128,.15)",
|
|
1292
|
+
color: "inherit", cursor: "pointer", fontSize: "13px",
|
|
1293
|
+
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
|
1294
|
+
},
|
|
1295
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
1296
|
+
onClick: () => {
|
|
1297
|
+
// [本地改造 2026-08-21] 再点一次正在播放的按钮 = 停止(而不是重播)
|
|
1298
|
+
if (previewing === tag) {
|
|
1299
|
+
previewTagRef.current = null;
|
|
1300
|
+
if (previewRef.current !== null) { previewRef.current.pause(); previewRef.current = null; }
|
|
1301
|
+
setPreviewing(null);
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
onClick();
|
|
1305
|
+
},
|
|
1306
|
+
}, previewing === tag ? "⏹" : (icon ?? "🔊"));
|
|
1307
|
+
|
|
1308
|
+
// 音色下拉 + 试听按钮(showPreview=false 时不显示试听,改由风格处试听)
|
|
1309
|
+
const voiceSelect = (engine, current, voices, onChange, showPreview) => h("div", { style: { display: "flex", gap: "6px", alignItems: "center" } },
|
|
1310
|
+
h("select", { value: current, onChange: (e) => onChange(e.target.value), style: vInput },
|
|
1311
|
+
(voices ?? [current]).map((v) => h("option", { key: v, value: v }, v))),
|
|
1312
|
+
showPreview === false ? null : previewBtn(engine, "试听此音色", () => previewVoice(engine, current)),
|
|
1313
|
+
);
|
|
1314
|
+
|
|
1315
|
+
// ASR 示例音频:自动加载 host 提供的测试音频(可播放),识别则把它发给 /asr/transcribe
|
|
1316
|
+
const loadAsrSample = () => {
|
|
1317
|
+
fetch("/asr/sample").then((r) => r.json()).then((d) => {
|
|
1318
|
+
if (!d?.ok) { setAsrResult({ ok: false, text: d?.error ?? "示例音频加载失败" }); return; }
|
|
1319
|
+
asrSampleBase64Ref.current = d.data;
|
|
1320
|
+
if (asrAudioRef.current !== null) {
|
|
1321
|
+
asrAudioRef.current.src = "data:" + d.mediaType + ";base64," + d.data;
|
|
1322
|
+
}
|
|
1323
|
+
}).catch((e) => setAsrResult({ ok: false, text: String(e) }));
|
|
1324
|
+
};
|
|
1325
|
+
const recognizeAsrSample = () => {
|
|
1326
|
+
const sample = asrSampleBase64Ref.current;
|
|
1327
|
+
if (sample === null || sample === undefined) { setAsrResult({ ok: false, text: "示例音频加载中,请稍候" }); return; }
|
|
1328
|
+
setAsrResult({ ok: true, text: "识别中…", busy: true });
|
|
1329
|
+
fetch("/asr/transcribe", {
|
|
1330
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
1331
|
+
body: JSON.stringify({ audioBase64: sample }),
|
|
1332
|
+
}).then((r) => r.json()).then((d) => {
|
|
1333
|
+
setAsrResult(d?.ok ? { ok: true, text: d.text } : { ok: false, text: d?.error ?? "识别失败" });
|
|
1334
|
+
}).catch((e) => setAsrResult({ ok: false, text: String(e) }));
|
|
1335
|
+
};
|
|
1336
|
+
|
|
1337
|
+
// 检测本机 ASR(exe/模型/服务/ffmpeg),自动填入可用的地址或命令
|
|
1338
|
+
const detectAsr = () => {
|
|
1339
|
+
setAsrResult(null);
|
|
1340
|
+
fetch("/asr/detect").then((r) => r.json()).then((d) => {
|
|
1341
|
+
if (!d?.ok) { setAsrResult({ ok: false, text: d?.error ?? "检测失败" }); return; }
|
|
1342
|
+
const det = d.detected;
|
|
1343
|
+
const fills = [];
|
|
1344
|
+
if (det.serviceOk) {
|
|
1345
|
+
setEngine("asr", { mode: "service", url: "http://127.0.0.1:18790" }, true);
|
|
1346
|
+
fills.push("检测到本地常驻服务(18790),已自动填入地址");
|
|
1347
|
+
}
|
|
1348
|
+
if (det.cmd !== "") {
|
|
1349
|
+
if (!det.serviceOk) setEngine("asr", { mode: "cmd", cmd: det.cmd }, true);
|
|
1350
|
+
else setEngine("asr", { cmd: det.cmd }, true);
|
|
1351
|
+
fills.push("已填入本地命令路径");
|
|
1352
|
+
}
|
|
1353
|
+
if (!det.exe) fills.push("未找到 sherpa-onnx,可点「一键安装」");
|
|
1354
|
+
if (!det.ffmpegOk) fills.push("未找到 ffmpeg,安装脚本会自动安装");
|
|
1355
|
+
setAsrResult({ ok: true, text: fills.length > 0 ? fills.join(";") : "未检测到本地 ASR 组件,请点「一键安装」" });
|
|
1356
|
+
}).catch((e) => setAsrResult({ ok: false, text: String(e) }));
|
|
1357
|
+
};
|
|
1358
|
+
// 一键安装:获取安装命令并显示(不自动写剪贴板,避免 uBlock 误报 ClickFix;用户手动复制更安全)
|
|
1359
|
+
const installAsr = () => {
|
|
1360
|
+
setAsrInstalling(true);
|
|
1361
|
+
setAsrCmd(null);
|
|
1362
|
+
fetch("/asr/install-script").then((r) => r.json()).then((d) => {
|
|
1363
|
+
setAsrInstalling(false);
|
|
1364
|
+
if (!d?.ok) { setAsrResult({ ok: false, text: d?.error ?? "获取安装命令失败" }); return; }
|
|
1365
|
+
setAsrCmd(d.command);
|
|
1366
|
+
setAsrResult({
|
|
1367
|
+
ok: true,
|
|
1368
|
+
text: "请打开「以管理员身份运行」的 PowerShell,手动复制下方命令粘贴执行。\n安装位置会自动放到插件目录:" + d.installDir + "\n脚本会自动下载 sherpa-onnx + SenseVoice 模型 + ffmpeg 并注册开机自启服务(端口 18790)",
|
|
1369
|
+
});
|
|
1370
|
+
}).catch((e) => { setAsrInstalling(false); setAsrResult({ ok: false, text: String(e) }); });
|
|
1371
|
+
};
|
|
1372
|
+
// [2026-08-21] 本地 TTS 一键安装:获取安装命令并显示(与 ASR 同款交互)
|
|
1373
|
+
const installLocalTts = () => {
|
|
1374
|
+
setTtsInstalling(true);
|
|
1375
|
+
setTtsCmd(null);
|
|
1376
|
+
fetch("/tts/install-script").then((r) => r.json()).then((d) => {
|
|
1377
|
+
setTtsInstalling(false);
|
|
1378
|
+
if (!d?.ok) { setTtsCmd(null); setPreviewErr(d?.error ?? "获取安装命令失败"); return; }
|
|
1379
|
+
setTtsCmd(d.command);
|
|
1380
|
+
setPreviewErr(null);
|
|
1381
|
+
}).catch((e) => { setTtsInstalling(false); setPreviewErr(String(e)); });
|
|
1382
|
+
};
|
|
1383
|
+
|
|
1384
|
+
// 提示小问号(hover 浮层显示 / 点击固定);align="right" 时浮层右对齐(向左展开,适合靠左按钮),默认左对齐(向右展开,适合靠右按钮)
|
|
1385
|
+
// [本地改造 2026-08-22] 克隆音色「?」:上方弹出、向右展开,展示“默认沟通指令”与“试听文本”,让用户直观看到该克隆音默认用什么语气沟通、试听念的是哪句
|
|
1386
|
+
const cloneInfoTip = (sp) => {
|
|
1387
|
+
const active = cloneInfoId === sp.id;
|
|
1388
|
+
const instruct = (sp.context && sp.context.trim() !== "") ? sp.context.trim() : "(该样本未单独设置指令,使用全局默认指令)";
|
|
1389
|
+
return h("span", { style: { position: "relative", display: "inline-flex", alignItems: "center", flex: "none" } },
|
|
1390
|
+
h("button", {
|
|
1391
|
+
type: "button", "aria-label": "查看语音指令与试听文本", title: "查看语音指令与试听文本",
|
|
1392
|
+
style: {
|
|
1393
|
+
border: "none", borderRadius: "999px", width: "18px", height: "18px", padding: "0",
|
|
1394
|
+
background: (active && cloneInfoPinned) ? "var(--vk-accent,#4b6fff)" : "rgba(128,128,128,.15)",
|
|
1395
|
+
color: "inherit", cursor: "pointer", fontSize: "10px", fontWeight: 700,
|
|
1396
|
+
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
|
1397
|
+
},
|
|
1398
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
1399
|
+
onMouseEnter: () => setCloneInfoId(sp.id),
|
|
1400
|
+
onMouseLeave: () => { if (!cloneInfoPinned) setCloneInfoId(null); },
|
|
1401
|
+
onClick: () => { const willPin = !(cloneInfoId === sp.id && cloneInfoPinned); setCloneInfoPinned(willPin); setCloneInfoId(willPin ? sp.id : null); },
|
|
1402
|
+
}, "?"),
|
|
1403
|
+
active ? h("div", {
|
|
1404
|
+
style: {
|
|
1405
|
+
position: "absolute", bottom: "calc(100% + 6px)", left: "0", right: "auto", zIndex: 70,
|
|
1406
|
+
background: "var(--dsw-specific-input-major,#ffffff)",
|
|
1407
|
+
border: "1px solid var(--dsw-alias-border-l1,#333a45)", borderRadius: "8px",
|
|
1408
|
+
padding: "10px 12px", boxShadow: "0 8px 24px rgba(0,0,0,.35)",
|
|
1409
|
+
fontSize: "12px", lineHeight: "1.7", color: "var(--dsw-alias-label-secondary,#9aa3ad)",
|
|
1410
|
+
minWidth: "340px", maxWidth: "460px", textAlign: "left",
|
|
1411
|
+
},
|
|
1412
|
+
},
|
|
1413
|
+
h("div", { style: { fontSize: "12px", fontWeight: 700, color: "var(--dsw-alias-label-primary,#e6e9ef)", marginBottom: "3px" } }, "默认沟通指令(" + (sp.name ?? "样本") + ")"),
|
|
1414
|
+
h("div", { style: { marginBottom: "8px" } }, instruct),
|
|
1415
|
+
h("div", { style: { fontSize: "12px", fontWeight: 700, color: "var(--dsw-alias-label-primary,#e6e9ef)", marginBottom: "3px" } }, "试听文本"),
|
|
1416
|
+
h("div", { style: {} }, (sp.previewText && sp.previewText.trim() !== "") ? sp.previewText : CLONE_PREVIEW_TEXT),
|
|
1417
|
+
) : null,
|
|
1418
|
+
);
|
|
1419
|
+
};
|
|
1420
|
+
|
|
1421
|
+
if (config === null) {
|
|
1422
|
+
return h("div", { style: { padding: "16px", fontSize: "13px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } }, "语音配置加载中…");
|
|
1423
|
+
}
|
|
1424
|
+
const eng = config.engines;
|
|
1425
|
+
const cloneSamples = Array.isArray(eng.voiceclone.samples) ? eng.voiceclone.samples : [];
|
|
1426
|
+
const showRules = rulesPinned || rulesHover;
|
|
1427
|
+
// [本地改造 2026-08-22] 语音设计单选模型:官方示例(asmr/docu/elder) / 自定义(custom) / 交给 AI(ai)
|
|
1428
|
+
const VD_KEYS = ["asmr", "docu", "elder"];
|
|
1429
|
+
const vdMode = (eng.voicedesign?.mode && ["asmr", "docu", "elder", "custom", "ai"].includes(eng.voicedesign.mode))
|
|
1430
|
+
? eng.voicedesign.mode
|
|
1431
|
+
: (() => {
|
|
1432
|
+
const ctx = eng.voicedesign?.context ?? "";
|
|
1433
|
+
const i = VOICE_DESIGN_EXAMPLES.findIndex((ex) => ex.instruct === ctx);
|
|
1434
|
+
return i >= 0 ? VD_KEYS[i] : (ctx.trim() !== "" ? "custom" : "ai");
|
|
1435
|
+
})();
|
|
1436
|
+
const pickVdMode = (m) => {
|
|
1437
|
+
// [2026-08-22] 单选切换:示例=写死指令+关 AI 情绪;custom=保留文本+关 AI 情绪;ai=开 AI 情绪
|
|
1438
|
+
if (m === "ai") setEngine("voicedesign", { mode: "ai", emotion: true }, true);
|
|
1439
|
+
else if (VD_KEYS.includes(m)) {
|
|
1440
|
+
const idx = VD_KEYS.indexOf(m);
|
|
1441
|
+
setEngine("voicedesign", { mode: m, context: VOICE_DESIGN_EXAMPLES[idx].instruct, emotion: false }, true);
|
|
1442
|
+
} else {
|
|
1443
|
+
setEngine("voicedesign", { mode: "custom", emotion: false }, true);
|
|
1444
|
+
}
|
|
1445
|
+
};
|
|
1446
|
+
// [2026-08-22] 年龄感 6 档(婴儿感~老年感),锚点实时可改,禁止自由文本
|
|
1447
|
+
const AI_AGE_LABELS = { infant: "婴儿感", child: "幼儿感", teen: "少年感", young: "青年感", middle: "中年感", old: "老年感" };
|
|
1448
|
+
const normalizeAiAge = (v) => {
|
|
1449
|
+
if (!v) return "young";
|
|
1450
|
+
if (AI_AGE_LABELS[v] !== undefined) return v;
|
|
1451
|
+
const s = String(v);
|
|
1452
|
+
if (/婴/.test(s)) return "infant";
|
|
1453
|
+
if (/幼|小|岁\s*[0-6]|[0-6]\s*岁/.test(s)) return "child";
|
|
1454
|
+
if (/老/.test(s)) return "old";
|
|
1455
|
+
if (/中/.test(s)) return "middle";
|
|
1456
|
+
if (/少|[1][0-9]\s*岁|岁\s*[7-9]/.test(s)) return "teen";
|
|
1457
|
+
return "young";
|
|
1458
|
+
};
|
|
1459
|
+
// [2026-08-22] AI 自动模式的稳定锚点行:checkbox + 值控件。
|
|
1460
|
+
// optionsOrPlaceholder: null=无具体值可选(如音色质感);数组=[v,l][] 渲染 select;字符串=自由文本输入(placeholder)
|
|
1461
|
+
const vdLockRow = (label, keyName, value, onValue, optionsOrPlaceholder) => h("div", { style: { display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap", fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } },
|
|
1462
|
+
h("label", { style: { display: "inline-flex", alignItems: "center", gap: "5px", cursor: "pointer" } },
|
|
1463
|
+
h("input", { type: "checkbox", checked: eng.voicedesign?.[keyName] === true, onChange: (e) => setEngine("voicedesign", { [keyName]: e.target.checked }, true), style: { accentColor: "var(--vk-accent,#4b6fff)", cursor: "pointer", width: "13px", height: "13px" } }),
|
|
1464
|
+
label),
|
|
1465
|
+
Array.isArray(optionsOrPlaceholder) && eng.voicedesign?.[keyName] === true ? h("select", {
|
|
1466
|
+
value: value,
|
|
1467
|
+
onChange: (e) => onValue(e.target.value),
|
|
1468
|
+
// [2026-08-22] 修复: 之前 onMouseDown preventDefault 会禁掉原生下拉弹出, 导致固定性别选不了
|
|
1469
|
+
style: { ...vInput, width: "auto", padding: "3px 8px", fontSize: "12px" },
|
|
1470
|
+
}, optionsOrPlaceholder.map(([v, l]) => h("option", { key: v, value: v }, l))) : null,
|
|
1471
|
+
typeof optionsOrPlaceholder === "string" && eng.voicedesign?.[keyName] === true ? h("input", {
|
|
1472
|
+
type: "text", value: value, placeholder: optionsOrPlaceholder,
|
|
1473
|
+
onChange: (e) => onValue(e.target.value),
|
|
1474
|
+
style: { ...vInput, width: "120px", padding: "3px 8px", fontSize: "12px" },
|
|
1475
|
+
}) : null,
|
|
1476
|
+
optionsOrPlaceholder === null ? h("span", { style: { fontSize: "11px", opacity: .8 } }, "(保持同一质感)") : null,
|
|
1477
|
+
);
|
|
1478
|
+
// [本地改造 2026-08-21] 已移除 VoiceClone/VoiceDesign 勾选:分区始终显示
|
|
1479
|
+
const designOn = false;
|
|
1480
|
+
const cloneOn = false;
|
|
1481
|
+
|
|
1482
|
+
return h("div", { style: { display: "flex", flexDirection: "column", gap: "14px", padding: "16px", width: "100%", boxSizing: "border-box" } },
|
|
1483
|
+
// 分区标题(语音图标已移到各服务商卡片前)+ 仓库链接(内联,不换行)
|
|
1484
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap", fontSize: "15px", fontWeight: 700, color: "var(--dsw-alias-label-primary,#e6e9ef)" } },
|
|
1485
|
+
"语音服务",
|
|
1486
|
+
h("span", { style: { display: "inline-flex", alignItems: "center", gap: "6px", fontSize: "12px", fontWeight: 400, color: "var(--dsw-alias-label-secondary,#9aa3b2)" } },
|
|
1487
|
+
h("a", {
|
|
1488
|
+
href: "https://github.com/oadank/dsh-input-tools",
|
|
1489
|
+
target: "_blank", rel: "noopener",
|
|
1490
|
+
title: "语音插件源码仓库(dsh-input-tools)",
|
|
1491
|
+
style: { color: "var(--dsw-alias-link,#5b9cff)", textDecoration: "none" },
|
|
1492
|
+
}, "语音插件仓库 ↗"),
|
|
1493
|
+
h("span", { style: { color: "var(--dsw-alias-label-tertiary,#6b7384)" } }, "·"),
|
|
1494
|
+
h("a", {
|
|
1495
|
+
href: "https://github.com/oadank/deepseek-harness",
|
|
1496
|
+
target: "_blank", rel: "noopener",
|
|
1497
|
+
title: "整合版:插件已内置,一键安装,推荐大多数用户",
|
|
1498
|
+
style: { color: "var(--dsw-alias-link,#5b9cff)", textDecoration: "none" },
|
|
1499
|
+
}, "整合版(推荐)↗"),
|
|
1500
|
+
),
|
|
1501
|
+
),
|
|
1502
|
+
// [2026-08-21] 试听失败错误提示;[2026-08-22] fixed 顶部弹窗 Toast + 限高滚动(错误堆栈超长不撑爆)
|
|
1503
|
+
previewErr !== null ? h("div", {
|
|
1504
|
+
style: {
|
|
1505
|
+
position: "fixed", top: "24px", left: "50%", transform: "translateX(-50%)", zIndex: 9999,
|
|
1506
|
+
background: "rgba(229,72,77,.95)", color: "#fff", borderRadius: "10px",
|
|
1507
|
+
padding: "10px 18px", fontSize: "13px", lineHeight: "1.5",
|
|
1508
|
+
boxShadow: "0 6px 24px rgba(0,0,0,.45)",
|
|
1509
|
+
maxWidth: "520px", maxHeight: "45vh", overflowY: "auto",
|
|
1510
|
+
whiteSpace: "pre-wrap", wordBreak: "break-word", pointerEvents: "none",
|
|
1511
|
+
},
|
|
1512
|
+
}, "试听失败:" + previewErr) : null,
|
|
1513
|
+
// [2026-08-21] 语音能力状态面板(安装即用 vs dsh 原生契约支持)
|
|
1514
|
+
caps !== null ? h("div", { style: { border: "1px solid var(--dsw-alias-border-l1,#333a45)", borderRadius: "10px", padding: "10px 12px", display: "flex", flexDirection: "column", gap: "6px", background: "rgba(128,128,128,.05)", fontSize: "12.5px", lineHeight: "1.5" } },
|
|
1515
|
+
h("div", { style: { fontSize: "12px", fontWeight: 600, opacity: .8 } }, "语音能力"),
|
|
1516
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: "6px" } },
|
|
1517
|
+
h("span", { style: { color: "#3ecf8e" } }, "✅"), " 语音输入(录音+识别+发送)", h("span", { style: { marginLeft: "auto", opacity: .7 } }, "插件自带")),
|
|
1518
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: "6px" } },
|
|
1519
|
+
h("span", { style: { color: "#3ecf8e" } }, "✅"), " 聊天语音气泡(可点播放)", h("span", { style: { marginLeft: "auto", opacity: .7 } }, "插件内置")),
|
|
1520
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: "6px" } },
|
|
1521
|
+
caps.voiceContentContract === true
|
|
1522
|
+
? h("span", { style: { color: "#3ecf8e" } }, "✅")
|
|
1523
|
+
: h("span", { style: { color: "#e5a53a" } }, "⚠️"),
|
|
1524
|
+
" dsh 原生语音消息(多模态直发)",
|
|
1525
|
+
h("span", { style: { marginLeft: "auto", opacity: .7 } },
|
|
1526
|
+
caps.voiceContentContract === true ? "当前 dsh 支持" : "当前 dsh 不支持,自动转文字发送")),
|
|
1527
|
+
caps.voiceContentContract === true ? null : h("div", { style: { fontSize: "12px", opacity: .75, marginTop: "2px" } },
|
|
1528
|
+
"说明:当前 dsh(npm 安装版)契约不支持原生语音消息,语音自动转文字发送,AI 通过【用户语音】标记识别。原生语音消息需使用「含语音改造的 dsh」——注意:官方源码/官方发布版均无此功能,语音能力是语音插件配套的 dsh 本地改造(本机 lecoo 的 dev 仓库即为改造版),正整理提交官方。"),
|
|
1529
|
+
) : null,
|
|
1530
|
+
// ⑤ ASR 语音识别(必填项,无开关)
|
|
1531
|
+
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)" } },
|
|
1532
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: "8px" } },
|
|
1533
|
+
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),
|
|
1534
|
+
h("span", { style: { fontSize: "13px", fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)" } }, "ASR 语音识别"),
|
|
1535
|
+
helpTip("把语音转成文字(必填配置,选一种模式即可)。本地服务:请求常驻 HTTP 服务(默认 127.0.0.1:18790);本地命令:直接调用 sherpa-onnx exe,无需额外装服务,速度与本地服务基本一致(8 秒音频约 1.4s,其中真正推理只占 0.16s,其余是每次加载模型的固定开销);在线 API:走 OpenAI 兼容接口,不占用本地算力。", asrTipPinned, setAsrTipPinned, asrTipHover, setAsrTipHover, "center"),
|
|
1536
|
+
),
|
|
1537
|
+
h("div", { style: { display: "flex", flexDirection: "column", gap: "10px" } },
|
|
1538
|
+
h("div", { style: { display: "flex", flexWrap: "wrap", gap: "10px" } },
|
|
1539
|
+
vField("模式", h("select", {
|
|
1540
|
+
value: eng.asr.mode ?? "service",
|
|
1541
|
+
onChange: (e) => setEngine("asr", { mode: e.target.value }, true),
|
|
1542
|
+
style: vInput,
|
|
1543
|
+
},
|
|
1544
|
+
h("option", { value: "service" }, "本地常驻服务"),
|
|
1545
|
+
h("option", { value: "cmd" }, "本地命令"),
|
|
1546
|
+
h("option", { value: "api" }, "在线 API"))),
|
|
1547
|
+
(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,
|
|
1548
|
+
(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,
|
|
1549
|
+
(eng.asr.mode ?? "service") === "api" ? [
|
|
1550
|
+
secretField("API Key", "asr", eng.asr.apiKey ?? "", (e) => setEngine("asr", { apiKey: e.target.value }, true), "sk-..."),
|
|
1551
|
+
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 })),
|
|
1552
|
+
] : null,
|
|
1553
|
+
),
|
|
1554
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: "10px", flexWrap: "wrap" } },
|
|
1555
|
+
h("span", { style: { fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)", flex: "none" } }, "示例音频:"),
|
|
1556
|
+
h("audio", { ref: asrAudioRef, controls: true, preload: "none", style: { maxWidth: "320px", height: "32px", flex: "none" } }),
|
|
1557
|
+
h("button", {
|
|
1558
|
+
type: "button",
|
|
1559
|
+
style: {
|
|
1560
|
+
border: "none", borderRadius: "999px", padding: "7px 16px", fontSize: "12.5px", fontWeight: 600,
|
|
1561
|
+
background: asrResult?.busy ? "rgba(229,72,77,.85)" : "rgba(128,128,128,.15)",
|
|
1562
|
+
color: "inherit", cursor: "pointer",
|
|
1563
|
+
},
|
|
1564
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
1565
|
+
onClick: recognizeAsrSample,
|
|
1566
|
+
}, asrResult?.busy ? "识别中…" : "识别这段音频"),
|
|
1567
|
+
),
|
|
1568
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: "10px", flexWrap: "wrap", borderTop: "1px dashed var(--dsw-alias-border-l1,#333a45)", paddingTop: "8px" } },
|
|
1569
|
+
h("button", {
|
|
1570
|
+
type: "button",
|
|
1571
|
+
style: {
|
|
1572
|
+
border: "1px solid var(--vk-accent,#4b6fff)", borderRadius: "999px", padding: "6px 16px",
|
|
1573
|
+
fontSize: "12.5px", fontWeight: 600, background: "transparent", color: "var(--vk-accent,#4b6fff)",
|
|
1574
|
+
cursor: "pointer",
|
|
1575
|
+
},
|
|
1576
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
1577
|
+
onClick: detectAsr,
|
|
1578
|
+
}, "检测已安装"),
|
|
1579
|
+
h("button", {
|
|
1580
|
+
type: "button",
|
|
1581
|
+
style: {
|
|
1582
|
+
border: "none", borderRadius: "999px", padding: "6px 16px", fontSize: "12.5px", fontWeight: 600,
|
|
1583
|
+
background: asrInstalling ? "rgba(128,128,128,.15)" : "var(--vk-accent,#4b6fff)",
|
|
1584
|
+
color: "#fff", cursor: "pointer",
|
|
1585
|
+
},
|
|
1586
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
1587
|
+
onClick: installAsr,
|
|
1588
|
+
}, asrInstalling ? "准备命令…" : "复制安装命令"),
|
|
1589
|
+
h("span", { style: { fontSize: "11.5px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } },
|
|
1590
|
+
"复制命令后,打开「以管理员身份运行」的 PowerShell 粘贴执行。脚本自动下载 sherpa-onnx + SenseVoice 模型 + ffmpeg 并注册开机自启服务,安装到插件目录内统一路径"),
|
|
1591
|
+
),
|
|
1592
|
+
asrCmd !== null ? h("div", { style: { display: "flex", flexDirection: "column", gap: "4px" } },
|
|
1593
|
+
h("div", { style: { fontSize: "11.5px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } }, "安装命令(点击选中全部,Ctrl+C 复制):"),
|
|
1594
|
+
h("code", {
|
|
1595
|
+
style: {
|
|
1596
|
+
display: "block", fontSize: "12px", lineHeight: "1.6", fontFamily: "Consolas, monospace",
|
|
1597
|
+
color: "var(--dsw-alias-label-primary,#e6e9ef)",
|
|
1598
|
+
border: "1px solid var(--dsw-alias-border-l1,#333a45)", borderRadius: "8px",
|
|
1599
|
+
padding: "8px 10px", background: "rgba(128,128,128,.08)",
|
|
1600
|
+
wordBreak: "break-all", whiteSpace: "pre-wrap", cursor: "text", userSelect: "all",
|
|
1601
|
+
},
|
|
1602
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
1603
|
+
onClick: (e) => {
|
|
1604
|
+
const sel = window.getSelection();
|
|
1605
|
+
const range = document.createRange();
|
|
1606
|
+
range.selectNodeContents(e.currentTarget);
|
|
1607
|
+
sel.removeAllRanges();
|
|
1608
|
+
sel.addRange(range);
|
|
1609
|
+
},
|
|
1610
|
+
}, asrCmd),
|
|
1611
|
+
) : null,
|
|
1612
|
+
asrResult !== null && asrResult.text !== undefined ? h("div", {
|
|
1613
|
+
style: {
|
|
1614
|
+
fontSize: "12.5px", lineHeight: "1.6",
|
|
1615
|
+
color: asrResult.ok ? "var(--dsw-alias-label-primary,#e6e9ef)" : "#e5484d",
|
|
1616
|
+
border: "1px solid var(--dsw-alias-border-l1,#333a45)", borderRadius: "8px", padding: "8px 10px",
|
|
1617
|
+
background: "rgba(128,128,128,.06)", whiteSpace: "pre-wrap",
|
|
1618
|
+
},
|
|
1619
|
+
}, asrResult.text) : null,
|
|
1620
|
+
),
|
|
1621
|
+
),
|
|
1622
|
+
// 默认引擎
|
|
1623
|
+
vField("默认语音引擎", h("select", {
|
|
1624
|
+
value: config.defaultEngine,
|
|
1625
|
+
onChange: (e) => {
|
|
1626
|
+
// [本地改造 2026-08-21] 修复:defaultEngine 之前只改本地 state 不持久化,刷新回 auto;
|
|
1627
|
+
// 现在与其它字段一致:防抖 POST 立即保存
|
|
1628
|
+
const next = { ...config, defaultEngine: e.target.value };
|
|
1629
|
+
// [本地改造 2026-08-22] 选「语音设计」时若还没选过模式,默认「纪录片旁白」;用户自己切过就保留原设计
|
|
1630
|
+
if (e.target.value === "voicedesign" && !(config.engines?.voicedesign?.mode)) {
|
|
1631
|
+
next.engines = { ...(config.engines ?? {}), voicedesign: { ...(config.engines?.voicedesign ?? {}), mode: "docu", context: VOICE_DESIGN_EXAMPLES[1].instruct, emotion: false } };
|
|
1632
|
+
}
|
|
1633
|
+
setConfig(next);
|
|
1634
|
+
if (saveTimerRef.current !== null) window.clearTimeout(saveTimerRef.current);
|
|
1635
|
+
saveTimerRef.current = window.setTimeout(() => {
|
|
1636
|
+
fetch("/voice-config", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ config: next }) }).catch(() => {});
|
|
1637
|
+
}, 400);
|
|
1638
|
+
},
|
|
1639
|
+
style: vInput,
|
|
1640
|
+
},
|
|
1641
|
+
["auto", ...ENGINES_ORDER].map((k) => h("option", { key: k, value: k },
|
|
1642
|
+
k === "auto" ? "auto(按规则自动选择,未启用任何引擎时用微软 edge 免费兜底)"
|
|
1643
|
+
: k === "voicedesign"
|
|
1644
|
+
? "小米语音设计(VoiceDesign):默认用「纪录片旁白」指令"
|
|
1645
|
+
: k === "voiceclone"
|
|
1646
|
+
? "小米克隆(VoiceClone)" + (cloneSamples.length > 0 ? ":默认用「" + cloneSamples[0].name + "」" : "(未添加样本)")
|
|
1647
|
+
: ENGINE_LABELS[k])))),
|
|
1648
|
+
// 语音三原则:问号按钮(hover 显示,点击固定/收起)
|
|
1649
|
+
h("div", { style: { position: "relative", display: "inline-flex", alignItems: "center", gap: "6px" } },
|
|
1650
|
+
h("button", {
|
|
1651
|
+
type: "button", "aria-label": "语音自动回复规则", title: "语音自动回复规则",
|
|
1652
|
+
style: {
|
|
1653
|
+
border: "none", borderRadius: "999px", width: "22px", height: "22px", padding: "0",
|
|
1654
|
+
background: rulesPinned ? "var(--vk-accent,#4b6fff)" : "rgba(128,128,128,.15)",
|
|
1655
|
+
color: "inherit", cursor: "pointer", fontSize: "12px", fontWeight: 700,
|
|
1656
|
+
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
|
1657
|
+
},
|
|
1658
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
1659
|
+
onMouseEnter: () => setRulesHover(true),
|
|
1660
|
+
onMouseLeave: () => setRulesHover(false),
|
|
1661
|
+
onClick: () => setRulesPinned((v) => !v),
|
|
1662
|
+
}, "?"),
|
|
1663
|
+
h("span", { style: { fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } }, "语音自动回复规则", rulesPinned ? "(已固定,点击收起)" : "(悬停查看,点击固定)"),
|
|
1664
|
+
showRules ? h("div", {
|
|
1665
|
+
style: {
|
|
1666
|
+
position: "absolute", top: "calc(100% + 6px)", left: "0", zIndex: 30,
|
|
1667
|
+
background: "var(--dsw-specific-input-major,#ffffff)",
|
|
1668
|
+
border: "1px solid var(--dsw-alias-border-l1,#333a45)", borderRadius: "8px",
|
|
1669
|
+
padding: "10px 12px", boxShadow: "0 8px 24px rgba(0,0,0,.35)",
|
|
1670
|
+
fontSize: "12px", lineHeight: "1.8", color: "var(--dsw-alias-label-secondary,#9aa3ad)",
|
|
1671
|
+
minWidth: "360px", maxWidth: "480px",
|
|
1672
|
+
},
|
|
1673
|
+
}, VOICE_RULES.map((r) => h("div", { key: r }, r))) : null,
|
|
1674
|
+
),
|
|
1675
|
+
// ① edge
|
|
1676
|
+
vCard(ENGINE_LABELS.edge, openCards.edge, () => toggleCard("edge"),
|
|
1677
|
+
vField("音色", voiceSelect("edge", eng.edge.voice, meta?.edgeVoices, (v) => setEngine("edge", { voice: v }, true)))),
|
|
1678
|
+
// ② 小米 MiMo(三模型合一卡片)
|
|
1679
|
+
vCard(h("span", { style: { display: "inline-flex", alignItems: "center", gap: "6px", flexWrap: "wrap" } },
|
|
1680
|
+
ENGINE_LABELS.xiaomi,
|
|
1681
|
+
helpTip("想让 AI 唱歌?直接对 AI 说“唱首歌/用歌声回我”,回复时自动加 (唱歌) 标签。", xmTipPinned, setXmTipPinned, xmTipHover, setXmTipHover),
|
|
1682
|
+
h("span", { style: { fontSize: "12px", fontWeight: 400, color: "var(--dsw-alias-label-secondary,#9aa3ad)" } },
|
|
1683
|
+
"(限时免费,请以官方为准)",
|
|
1684
|
+
h("a", {
|
|
1685
|
+
href: MIMO_DOC_URL, target: "_blank", rel: "noreferrer",
|
|
1686
|
+
style: { color: "var(--vk-accent,#4b6fff)", textDecoration: "none" },
|
|
1687
|
+
}, "MiMo 官方模型页"),
|
|
1688
|
+
),
|
|
1689
|
+
), openCards.xiaomi, () => toggleCard("xiaomi"),
|
|
1690
|
+
() => h("div", { style: { display: "flex", flexDirection: "column", gap: "10px" } },
|
|
1691
|
+
// [本地改造 2026-08-21] API Key(卡片最上;不再有模型勾选)
|
|
1692
|
+
h("div", { style: { display: "flex", flexWrap: "wrap", gap: "10px" } },
|
|
1693
|
+
secretField("API Key", "xiaomi", eng.xiaomi.apiKey, (e) => setEngine("xiaomi", { apiKey: e.target.value }, true),
|
|
1694
|
+
(eng.xiaomi.apiKey !== "" || meta?.envKeys?.xiaomi) ? "已填写——输入新值可替换" : "MIMO_API_KEY"),
|
|
1695
|
+
),
|
|
1696
|
+
// 语音模型:MiMo-V2.5-TTS(基础 TTS,音色 + 语言风格)
|
|
1697
|
+
h("div", { style: { display: "flex", flexDirection: "column", gap: "8px", borderTop: "1px dashed var(--dsw-alias-border-l1,#333a45)", paddingTop: "8px" } },
|
|
1698
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: "6px" } },
|
|
1699
|
+
h("span", { style: { fontSize: "12.5px", fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)" } }, "语音模型:MiMo-V2.5-TTS"),
|
|
1700
|
+
),
|
|
1701
|
+
h("div", { style: { display: "flex", flexWrap: "wrap", gap: "10px" } },
|
|
1702
|
+
vField("音色", voiceSelect("xiaomi", eng.xiaomi.voice, meta?.xiaomiVoices, (v) => setEngine("xiaomi", { voice: v }, true), false)),
|
|
1703
|
+
vField("默认语言风格", h("div", { style: { display: "flex", gap: "6px", alignItems: "center" } },
|
|
1704
|
+
h("select", {
|
|
1705
|
+
value: STYLE_PRESETS.find((sp) => sp.ctx === (eng.xiaomi.context ?? ""))?.key ?? "",
|
|
1706
|
+
onChange: (e) => {
|
|
1707
|
+
const hit = STYLE_PRESETS.find((sp) => sp.key === e.target.value);
|
|
1708
|
+
setEngine("xiaomi", { context: hit ? hit.ctx : "" }, true);
|
|
1709
|
+
},
|
|
1710
|
+
style: { ...vInput, flex: 1 },
|
|
1711
|
+
},
|
|
1712
|
+
STYLE_PRESETS.map((sp) => h("option", { key: sp.key || "nat", value: sp.key }, sp.label))),
|
|
1713
|
+
previewBtn("style", "试听", () => previewVoice("xiaomi", eng.xiaomi.voice, eng.xiaomi.context ?? "", undefined, "style")),
|
|
1714
|
+
)),
|
|
1715
|
+
),
|
|
1716
|
+
),
|
|
1717
|
+
// [2026-08-22] 语音设计:MiMo-V2.5-TTS-VoiceDesign(单选:官方示例 / 自定义 / 交给 AI,始终显示)
|
|
1718
|
+
h("div", { style: { display: "flex", flexDirection: "column", gap: "8px", borderTop: "1px dashed var(--dsw-alias-border-l1,#333a45)", paddingTop: "8px" } },
|
|
1719
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: "6px" } },
|
|
1720
|
+
h("span", { style: { fontSize: "12.5px", fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)" } }, "语音设计:MiMo-V2.5-TTS-VoiceDesign"),
|
|
1721
|
+
helpTip("「音色设计 VoiceDesign」用一段文字描述你想要的声音(性别/年龄/质感/语速/情绪),AI 照着念。单选:选官方示例(ASMR / 纪录片旁白 / 年迈老先生),或自定义填写,或「交给 AI 自动发挥」(AI 按对话情境写音色描述,可勾选固定性别/音色/年龄保持声音稳定——尚未充分测试)。选为默认语音引擎后默认用「纪录片旁白」;切换过就保留你的选择。", designTipPinned, setDesignTipPinned, designTipHover, setDesignTipHover, "center", "top"),
|
|
1722
|
+
),
|
|
1723
|
+
h("div", { style: { fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } }, "单选:当前使用的高亮(点播放可试听)"),
|
|
1724
|
+
VOICE_DESIGN_EXAMPLES.map((ex, i) => {
|
|
1725
|
+
const key = VD_KEYS[i];
|
|
1726
|
+
const active = vdMode === key;
|
|
1727
|
+
return h("div", { key: ex.title, style: { border: "1px solid " + (active ? "var(--vk-accent,#4b6fff)" : "var(--dsw-alias-border-l1,#333a45)"), borderRadius: "8px", padding: "6px 10px", display: "flex", flexDirection: "column", gap: "6px", background: active ? "rgba(75,111,255,.08)" : "transparent" } },
|
|
1728
|
+
h("label", { style: { display: "flex", alignItems: "center", gap: "8px", cursor: "pointer" } },
|
|
1729
|
+
h("input", { type: "radio", name: "vd-mode", checked: active, onChange: () => pickVdMode(key), style: { accentColor: "var(--vk-accent,#4b6fff)", cursor: "pointer", flex: "none", width: "14px", height: "14px" } }),
|
|
1730
|
+
h("span", { style: { fontSize: "13px", fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)", flex: "none" } }, ex.title),
|
|
1731
|
+
active ? h("span", { style: { fontSize: "11px", color: "var(--vk-accent,#4b6fff)", flex: "none" } }, "使用中") : null,
|
|
1732
|
+
h("span", { style: { flex: 1 } }),
|
|
1733
|
+
helpTip(
|
|
1734
|
+
h("div", { style: { display: "flex", flexDirection: "column", gap: "8px" } },
|
|
1735
|
+
h("div", null, h("span", { style: { fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)" } }, "Instruct:"), ex.instruct),
|
|
1736
|
+
h("div", null, h("span", { style: { fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)" } }, "Text:"), ex.text),
|
|
1737
|
+
),
|
|
1738
|
+
vdExamplePins[i], (v) => { const n = [...vdExamplePins]; n[i] = v; setVdExamplePins(n); },
|
|
1739
|
+
vdExampleHovers[i], (v) => { const n = [...vdExampleHovers]; n[i] = v; setVdExampleHovers(n); },
|
|
1740
|
+
"left", "top",
|
|
1741
|
+
),
|
|
1742
|
+
),
|
|
1743
|
+
h("audio", {
|
|
1744
|
+
controls: true, preload: "none",
|
|
1745
|
+
src: vdSamples[i] !== undefined ? "data:" + vdSamples[i].mediaType + ";base64," + vdSamples[i].data : undefined,
|
|
1746
|
+
style: { width: "100%", height: "32px" },
|
|
1747
|
+
}),
|
|
1748
|
+
);
|
|
1749
|
+
}),
|
|
1750
|
+
// 自定义音色描述(单选)
|
|
1751
|
+
h("div", { style: { border: "1px solid " + (vdMode === "custom" ? "var(--vk-accent,#4b6fff)" : "var(--dsw-alias-border-l1,#333a45)"), borderRadius: "8px", padding: "6px 10px", display: "flex", flexDirection: "column", gap: "6px", background: vdMode === "custom" ? "rgba(75,111,255,.08)" : "transparent" } },
|
|
1752
|
+
h("label", { style: { display: "flex", alignItems: "center", gap: "8px", cursor: "pointer" } },
|
|
1753
|
+
h("input", { type: "radio", name: "vd-mode", checked: vdMode === "custom", onChange: () => pickVdMode("custom"), style: { accentColor: "var(--vk-accent,#4b6fff)", cursor: "pointer", flex: "none", width: "14px", height: "14px" } }),
|
|
1754
|
+
h("span", { style: { fontSize: "13px", fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)" } }, "自定义音色描述"),
|
|
1755
|
+
vdMode === "custom" ? h("span", { style: { fontSize: "11px", color: "var(--vk-accent,#4b6fff)" } }, "使用中") : null,
|
|
1756
|
+
),
|
|
1757
|
+
vdMode === "custom" ? h("div", { style: { display: "flex", flexDirection: "column", gap: "6px" } },
|
|
1758
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: "8px" } },
|
|
1759
|
+
previewBtn("vd-custom", "试听当前指令", () => previewVoice("voicedesign", undefined, eng.voicedesign?.context ?? "", undefined, "vd-custom", { text: "这是一段使用你设计的音色朗读的语音,用来检查当前音色描述的效果。" })),
|
|
1760
|
+
h("span", { style: { fontSize: "11.5px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } }, "写好后点试听;切换到其它选项会保留这段文本"),
|
|
1761
|
+
),
|
|
1762
|
+
h("textarea", {
|
|
1763
|
+
value: eng.voicedesign?.context ?? "",
|
|
1764
|
+
onChange: (e) => setEngine("voicedesign", { context: e.target.value }, true),
|
|
1765
|
+
placeholder: "如:一位温柔的年轻女性,说标准普通话,语速缓慢,声音甜美,像在耳边轻声细语…",
|
|
1766
|
+
style: { ...vInput, minHeight: "64px", resize: "vertical", lineHeight: "1.6" },
|
|
1767
|
+
}),
|
|
1768
|
+
) : null,
|
|
1769
|
+
),
|
|
1770
|
+
// 交给 AI 自动发挥(单选)+ 稳定锚点锁定
|
|
1771
|
+
h("div", { style: { border: "1px solid " + (vdMode === "ai" ? "var(--vk-accent,#4b6fff)" : "var(--dsw-alias-border-l1,#333a45)"), borderRadius: "8px", padding: "6px 10px", display: "flex", flexDirection: "column", gap: "6px", background: vdMode === "ai" ? "rgba(75,111,255,.08)" : "transparent" } },
|
|
1772
|
+
h("label", { style: { display: "flex", alignItems: "center", gap: "8px", cursor: "pointer" } },
|
|
1773
|
+
h("input", { type: "radio", name: "vd-mode", checked: vdMode === "ai", onChange: () => pickVdMode("ai"), style: { accentColor: "var(--vk-accent,#4b6fff)", cursor: "pointer", flex: "none", width: "14px", height: "14px" } }),
|
|
1774
|
+
h("span", { style: { fontSize: "13px", fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)" } }, "交给 AI 自动发挥"),
|
|
1775
|
+
vdMode === "ai" ? h("span", { style: { fontSize: "11px", color: "var(--vk-accent,#4b6fff)" } }, "使用中") : null,
|
|
1776
|
+
),
|
|
1777
|
+
vdMode === "ai" ? h("div", { style: { display: "flex", flexDirection: "column", gap: "6px", fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)", lineHeight: "1.6" } },
|
|
1778
|
+
h("div", null, "音色描述由 AI 根据对话情境自动编写(任务成功兴奋道喜 / 生气委屈道歉 / 难过温柔安慰)。下面的锁定项让 AI 每次都是同一个人:性别/年龄选好值,音色质感保持同一质感,只允许情绪/语速/语气波动(尚未充分测试):"),
|
|
1779
|
+
h("div", { style: { display: "flex", flexDirection: "column", gap: "4px" } },
|
|
1780
|
+
vdLockRow("固定性别", "lockGender", eng.voicedesign?.aiGender ?? "female", (v) => setEngine("voicedesign", { aiGender: v }, true), [["female", "女"], ["male", "男"]]),
|
|
1781
|
+
vdLockRow("固定音色质感", "lockTimbre", null, null, null),
|
|
1782
|
+
vdLockRow("固定年龄感", "lockAge", normalizeAiAge(eng.voicedesign?.aiAge), (v) => setEngine("voicedesign", { aiAge: v }, true),
|
|
1783
|
+
[["infant", "婴儿感"], ["child", "幼儿感"], ["teen", "少年感"], ["young", "青年感"], ["middle", "中年感"], ["old", "老年感"]]),
|
|
1784
|
+
),
|
|
1785
|
+
) : null,
|
|
1786
|
+
),
|
|
1787
|
+
),
|
|
1788
|
+
// 克隆模型:MiMo-V2.5-TTS-VoiceClone(样本管理,始终显示)
|
|
1789
|
+
h("div", { style: { display: "flex", flexDirection: "column", gap: "6px", borderTop: "1px dashed var(--dsw-alias-border-l1,#333a45)", paddingTop: "8px" } },
|
|
1790
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: "6px" } },
|
|
1791
|
+
h("span", { style: { fontSize: "12.5px", fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)" } }, "克隆模型:MiMo-V2.5-TTS-VoiceClone"),
|
|
1792
|
+
helpTip("克隆音色与预置音色(冰糖等)互斥:在「默认语音引擎」里选择「小米克隆(VoiceClone)」后,默认回复一律使用下方克隆声音;开启 VoiceDesign 时,AI 会在克隆底嗓上叠加情感指令(如「用委屈撒娇的语气」),克隆声同样带情感。", cloneListTipPinned, setCloneListTipPinned, cloneListTipHover, setCloneListTipHover, "center", "top"),
|
|
1793
|
+
),
|
|
1794
|
+
cloneSamples.length > 0 ? h("div", { style: { display: "flex", flexDirection: "column", gap: "6px" } },
|
|
1795
|
+
h("div", { style: { fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } }, "已保存的克隆音色(默认语音引擎选「小米克隆」后用第一个音色):"),
|
|
1796
|
+
cloneSamples.map((sp) => {
|
|
1797
|
+
// [本地改造 2026-08-22] 自带小团团样本:禁止删除;两行展示(第一行 ?+名称+完整路径+试听原音,第二行 合成试听录音+删除)
|
|
1798
|
+
const isDefault = sp.id === BUNDLED_CLONE_ID;
|
|
1799
|
+
return h("div", { key: sp.id, style: { border: "1px solid var(--dsw-alias-border-l1,#333a45)", borderRadius: "8px", padding: "6px 10px", display: "flex", flexDirection: "column", gap: "6px", fontSize: "12.5px" } },
|
|
1800
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: "8px" } },
|
|
1801
|
+
cloneInfoTip(sp),
|
|
1802
|
+
h("span", { style: { fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)", flex: "none", whiteSpace: "nowrap" } }, sp.name ?? "样本"),
|
|
1803
|
+
h("span", { style: { color: "var(--dsw-alias-label-secondary,#9aa3ad)", fontSize: "11px", flex: 1, minWidth: 0, wordBreak: "break-all", lineHeight: "1.4" } }, sp.path ?? ""),
|
|
1804
|
+
previewBtn("clone-src:" + sp.id, "试听原音(样本原始音频,对比还原度)", () => previewSourceVoice(sp.path, "clone-src:" + sp.id), "▶"),
|
|
1805
|
+
),
|
|
1806
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: "8px" } },
|
|
1807
|
+
previewBtn("clone-baked:" + sp.id, "播放合成音(克隆效果试听)", () => playBakedPreview(sp, "clone-baked:" + sp.id)),
|
|
1808
|
+
h("span", { style: { fontSize: "11.5px", color: "var(--dsw-alias-label-secondary,#9aa3ad)", flex: 1 } },
|
|
1809
|
+
"合成效果试听" + (isDefault ? "(预生成录音,免联网)" : "(按该音色指令/文本合成)")),
|
|
1810
|
+
isDefault ? null : h("button", {
|
|
1811
|
+
type: "button", "aria-label": "删除", title: "删除此克隆音色",
|
|
1812
|
+
style: { border: "none", borderRadius: "6px", width: "28px", height: "28px", flex: "none", background: "rgba(229,72,77,.15)", color: "#e5484d", cursor: "pointer", fontSize: "14px" },
|
|
1813
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
1814
|
+
onClick: () => setEngine("voiceclone", { samples: cloneSamples.filter((x) => x.id !== sp.id) }, true),
|
|
1815
|
+
}, "✕"),
|
|
1816
|
+
),
|
|
1817
|
+
);
|
|
1818
|
+
}),
|
|
1819
|
+
) : h("div", { style: { fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)", lineHeight: 1.7 } },
|
|
1820
|
+
"无(尚未添加克隆音色)。",
|
|
1821
|
+
),
|
|
1822
|
+
// [本地改造 2026-08-21] 添加克隆音色:选音频 → 命名 → 上传
|
|
1823
|
+
// [本地改造 2026-08-22] 与自带小团团样本对齐:需要提供 3 样 —— 指令(默认沟通语气)+ 文本(试听内容)+ 样本音频
|
|
1824
|
+
h("div", { style: { display: "flex", flexDirection: "column", gap: "6px", borderTop: "1px dashed var(--dsw-alias-border-l1,#333a45)", paddingTop: "8px" } },
|
|
1825
|
+
h("div", { style: { fontSize: "12.5px", fontWeight: 600, color: "var(--dsw-alias-label-primary,#e6e9ef)" } }, "添加克隆音色"),
|
|
1826
|
+
h("div", { style: { fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } }, "需要提供 3 样:①沟通指令(这个声音默认用什么语气跟客户沟通)②试听文本(点播放念哪句)③样本音频(克隆的原始声音)。音频支持 mp3 / wav,Base64 后 ≤10MB(官方限制);参考语音建议 15-60 秒、单人纯人声无背景音乐,越长克隆越准。"),
|
|
1827
|
+
h("div", { style: { display: "flex", flexDirection: "column", gap: "8px" } },
|
|
1828
|
+
vField("沟通指令(默认语气)", h("textarea", { value: cloneContext, onChange: (e) => setCloneContext(e.target.value), placeholder: "如:一个魔性的少女萝莉音,说话自带沙雕搞怪气质,爱撒娇爱耍宝…", style: { ...vInput, minHeight: "56px", resize: "vertical", lineHeight: "1.5" } })),
|
|
1829
|
+
vField("试听文本", h("textarea", { value: clonePreviewText, onChange: (e) => setClonePreviewText(e.target.value), placeholder: "如:喂喂喂!你怎么才来呀?我都等你老半天啦!……", style: { ...vInput, minHeight: "56px", resize: "vertical", lineHeight: "1.5" } })),
|
|
1830
|
+
vField("名称", h("input", { value: cloneName, onChange: (e) => setCloneName(e.target.value), placeholder: "如:我的声音(留空用文件名)", style: { ...vInput, width: "100%" } })),
|
|
1831
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" } },
|
|
1832
|
+
h("button", {
|
|
1833
|
+
type: "button", onClick: () => cloneFileRef.current?.click(), disabled: addingClone,
|
|
1834
|
+
style: { background: "var(--vk-accent,#4b6fff)", color: "#fff", border: "none", borderRadius: "999px", padding: "7px 16px", fontSize: "12.5px", fontWeight: 600, cursor: "pointer", flex: "none" },
|
|
1835
|
+
}, addingClone ? "添加中…" : "选择音频文件添加"),
|
|
1836
|
+
h("span", { style: { fontSize: "11.5px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } }, "选完音频即自动上传添加"),
|
|
1837
|
+
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); } }),
|
|
1838
|
+
),
|
|
1839
|
+
),
|
|
1840
|
+
cloneAddMsg !== null ? h("div", { style: { fontSize: "12px", color: cloneAddMsg.ok ? "#73c991" : "#f14c4c" } }, cloneAddMsg.text) : null,
|
|
1841
|
+
),
|
|
1842
|
+
),
|
|
1843
|
+
)),
|
|
1844
|
+
// ③ 本地 TTS(与其他卡片一致:勾选后才显示配置字段)
|
|
1845
|
+
vCard(h("span", { style: { display: "inline-flex", alignItems: "center", gap: "6px", flexWrap: "wrap" } },
|
|
1846
|
+
ENGINE_LABELS.local,
|
|
1847
|
+
helpTip("本地模型常驻内存(CPU 推理)。填本地命令(每次调用启动进程,较慢);或填 HTTP 服务地址(推荐,模型常驻一次加载后快)。两者都填时 HTTP 优先;留空则跳过本地引擎。点「复制安装命令」可一键下载 sherpa-onnx + 中文 MeloTTS 模型 + ffmpeg,并自动生成可用的启动脚本。", localTipPinned, setLocalTipPinned, localTipHover, setLocalTipHover, "center"),
|
|
1848
|
+
), openCards.local, () => toggleCard("local"),
|
|
1849
|
+
[
|
|
1850
|
+
h("div", { style: { display: "flex", flexWrap: "wrap", gap: "10px" } },
|
|
1851
|
+
vField("本地命令(每次调用启动进程)", h("input", { value: eng.local.cmd ?? "", onChange: (e) => setEngine("local", { cmd: e.target.value }, true), placeholder: "如 node <插件目录>\\local-tts.mjs(安装脚本会自动填好)", style: vInput })),
|
|
1852
|
+
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 })),
|
|
1853
|
+
),
|
|
1854
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" } },
|
|
1855
|
+
previewBtn("local-preview", "试听本地 TTS", () => previewVoice("local", undefined, undefined, undefined, "local-preview", { cmd: eng.local.cmd ?? "", url: eng.local.url ?? "" })),
|
|
1856
|
+
h("span", { style: { fontSize: "12px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } }, "点击试听(用上方填的命令/地址合成)"),
|
|
1857
|
+
),
|
|
1858
|
+
// [2026-08-21] 本地 TTS 一键安装(与 ASR 同款交互)
|
|
1859
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: "10px", flexWrap: "wrap", borderTop: "1px dashed var(--dsw-alias-border-l1,#333a45)", paddingTop: "8px" } },
|
|
1860
|
+
h("button", {
|
|
1861
|
+
type: "button",
|
|
1862
|
+
style: {
|
|
1863
|
+
border: "none", borderRadius: "999px", padding: "6px 16px", fontSize: "12.5px", fontWeight: 600,
|
|
1864
|
+
background: ttsInstalling ? "rgba(128,128,128,.15)" : "var(--vk-accent,#4b6fff)",
|
|
1865
|
+
color: "#fff", cursor: "pointer",
|
|
1866
|
+
},
|
|
1867
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
1868
|
+
onClick: installLocalTts,
|
|
1869
|
+
}, ttsInstalling ? "准备命令…" : "复制安装命令"),
|
|
1870
|
+
h("span", { style: { fontSize: "11.5px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } },
|
|
1871
|
+
"复制命令后,打开「以管理员身份运行」的 PowerShell 粘贴执行。脚本自动下载 sherpa-onnx(含离线 TTS)+ 中文 MeloTTS 模型 + ffmpeg,并生成 local-tts.mjs 启动脚本"),
|
|
1872
|
+
),
|
|
1873
|
+
ttsCmd !== null ? h("div", { style: { display: "flex", flexDirection: "column", gap: "4px" } },
|
|
1874
|
+
h("div", { style: { fontSize: "11.5px", color: "var(--dsw-alias-label-secondary,#9aa3ad)" } }, "安装命令(点击选中全部,Ctrl+C 复制):"),
|
|
1875
|
+
h("code", {
|
|
1876
|
+
style: {
|
|
1877
|
+
display: "block", fontSize: "12px", lineHeight: "1.6", fontFamily: "Consolas, monospace",
|
|
1878
|
+
color: "var(--dsw-alias-label-primary,#e6e9ef)",
|
|
1879
|
+
border: "1px solid var(--dsw-alias-border-l1,#333a45)", borderRadius: "8px",
|
|
1880
|
+
padding: "8px 10px", background: "rgba(128,128,128,.08)",
|
|
1881
|
+
wordBreak: "break-all", whiteSpace: "pre-wrap", cursor: "text", userSelect: "all",
|
|
1882
|
+
},
|
|
1883
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
1884
|
+
onClick: (e) => {
|
|
1885
|
+
const sel = window.getSelection();
|
|
1886
|
+
const range = document.createRange();
|
|
1887
|
+
range.selectNodeContents(e.currentTarget);
|
|
1888
|
+
sel.removeAllRanges();
|
|
1889
|
+
sel.addRange(range);
|
|
1890
|
+
},
|
|
1891
|
+
}, ttsCmd),
|
|
1892
|
+
) : null,
|
|
1893
|
+
]),
|
|
1894
|
+
// ④ 阿里 qwen3-tts
|
|
1895
|
+
vCard(ENGINE_LABELS.ali, openCards.ali, () => toggleCard("ali"),
|
|
1896
|
+
h("div", { style: { display: "flex", flexWrap: "wrap", gap: "10px" } },
|
|
1897
|
+
secretField("API Key", "ali", eng.ali.apiKey ?? "", (e) => setEngine("ali", { apiKey: e.target.value }, true),
|
|
1898
|
+
(eng.ali.apiKey !== "" || meta?.envKeys?.ali) ? "已填写——输入新值可替换" : "dashscope API Key"),
|
|
1899
|
+
vField("音色", voiceSelect("ali", eng.ali.voice ?? "Cherry", meta?.aliVoices, (v) => setEngine("ali", { voice: v }, true))),
|
|
1900
|
+
)),
|
|
1901
|
+
|
|
1902
|
+
);
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
// ── [本地改造 2026-08-21] 语音条尾部「复制转写」按钮 ─────────────
|
|
1906
|
+
// 挂在 conversation.chat.voice-actions 槽(核心补的挂点):按钮渲染在语音条
|
|
1907
|
+
// (VoiceCard)内部、转写文本之后,样式对齐系统复制按钮(28px 圆形透明、
|
|
1908
|
+
// hover 变背景;图标 14px)。
|
|
1909
|
+
const actionCopySvg = h("svg", { viewBox: "0 0 16 16", width: "14", height: "14", "aria-hidden": true },
|
|
1910
|
+
h("rect", { x: "5.5", y: "5.5", width: "7", height: "7", rx: "1.2", fill: "none", stroke: "currentColor", strokeWidth: "1.3" }),
|
|
1911
|
+
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" }),
|
|
1912
|
+
);
|
|
1913
|
+
const actionCheckSvg = h("svg", { viewBox: "0 0 16 16", width: "14", height: "14", "aria-hidden": true },
|
|
1914
|
+
h("path", { d: "M3.5 8.5L6.5 11.5L12.5 4.5", fill: "none", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round", strokeLinejoin: "round" }),
|
|
1915
|
+
);
|
|
1916
|
+
function VoiceCopyTranscriptAction(props) {
|
|
1917
|
+
const transcript = props.transcript;
|
|
1918
|
+
const [copied, setCopied] = react.useState(false);
|
|
1919
|
+
if (typeof transcript !== "string" || transcript === "") return null;
|
|
1920
|
+
const onCopy = () => {
|
|
1921
|
+
const done = () => { setCopied(true); setTimeout(() => setCopied(false), 1200); };
|
|
1922
|
+
if (typeof navigator !== "undefined" && navigator.clipboard && navigator.clipboard.writeText) {
|
|
1923
|
+
navigator.clipboard.writeText(transcript).then(done, done);
|
|
1924
|
+
} else { done(); }
|
|
1925
|
+
};
|
|
1926
|
+
const label = copied ? "已复制转写文本" : "复制转写文本";
|
|
1927
|
+
const style = {
|
|
1928
|
+
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
|
1929
|
+
width: "28px", height: "28px", padding: "6px", border: "none",
|
|
1930
|
+
borderRadius: "28px", background: "transparent",
|
|
1931
|
+
color: "var(--dsw-alias-label-tertiary)", cursor: "pointer",
|
|
1932
|
+
flexShrink: 0,
|
|
1933
|
+
};
|
|
1934
|
+
return h("button", {
|
|
1935
|
+
type: "button",
|
|
1936
|
+
onClick: onCopy,
|
|
1937
|
+
title: label,
|
|
1938
|
+
"aria-label": label,
|
|
1939
|
+
style,
|
|
1940
|
+
onMouseEnter: (e) => {
|
|
1941
|
+
e.currentTarget.style.background = "var(--dsw-alias-interactive-bg-hover)";
|
|
1942
|
+
e.currentTarget.style.color = "var(--dsw-alias-label-secondary)";
|
|
1943
|
+
},
|
|
1944
|
+
onMouseLeave: (e) => {
|
|
1945
|
+
e.currentTarget.style.background = "transparent";
|
|
1946
|
+
e.currentTarget.style.color = "var(--dsw-alias-label-tertiary)";
|
|
1947
|
+
},
|
|
1948
|
+
}, copied ? actionCheckSvg : actionCopySvg);
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
const inject = ["slots"];
|
|
1952
|
+
|
|
1953
|
+
function apply(ctx) {
|
|
1954
|
+
const getConnection = () => ctx.get("connection");
|
|
1955
|
+
ctx.effect(() => {
|
|
1956
|
+
const disposers = [
|
|
1957
|
+
// 附件槽:priority:-1 覆盖官方(lowest renders;官方默认 0 不冲突)
|
|
1958
|
+
ctx.slots.inject("conversation.input.attachments", () => ctx.slots.register({
|
|
1959
|
+
name: "conversation.input.attachments",
|
|
1960
|
+
id: "composer-attachments-overlay",
|
|
1961
|
+
priority: -1,
|
|
1962
|
+
locale: "conversation",
|
|
1963
|
+
inject: (sessionId) => ({ connection: getConnection(), sessionId }),
|
|
1964
|
+
}, ComposerAttachmentsOverlay)),
|
|
1965
|
+
ctx.slots.inject("conversation.input.left", () => ctx.slots.register({
|
|
1966
|
+
name: "conversation.input.left",
|
|
1967
|
+
id: "composer-left",
|
|
1968
|
+
order: 10,
|
|
1969
|
+
locale: "conversation",
|
|
1970
|
+
inject: (sessionId) => ({ connection: getConnection(), sessionId }),
|
|
1971
|
+
}, ToolbarLeft)),
|
|
1972
|
+
ctx.slots.inject("conversation.input.right", () => ctx.slots.register({
|
|
1973
|
+
name: "conversation.input.right",
|
|
1974
|
+
id: "composer-balance",
|
|
1975
|
+
order: -10,
|
|
1976
|
+
locale: "conversation",
|
|
1977
|
+
inject: (sessionId) => ({ connection: getConnection(), sessionId }),
|
|
1978
|
+
}, BalanceMeter)),
|
|
1979
|
+
// 设置页「语音服务」分区(settings.section 槽)
|
|
1980
|
+
ctx.slots.inject("settings.section", () => ctx.slots.register({
|
|
1981
|
+
name: "settings.section",
|
|
1982
|
+
id: "voice",
|
|
1983
|
+
order: 4,
|
|
1984
|
+
label: () => "语音服务",
|
|
1985
|
+
}, VoiceSettingsSection)),
|
|
1986
|
+
// [2026-08-22] 设置页「图片识别」独立分区(从语音服务拆出)
|
|
1987
|
+
ctx.slots.inject("settings.section", () => ctx.slots.register({
|
|
1988
|
+
name: "settings.section",
|
|
1989
|
+
id: "vision",
|
|
1990
|
+
order: 5,
|
|
1991
|
+
label: () => "图片识别",
|
|
1992
|
+
}, VisionSettingsSection)),
|
|
1993
|
+
// [本地改造 2026-08-21] 语音条尾部「复制转写」按钮(voice-actions 槽,
|
|
1994
|
+
// 渲染在语音卡内转写文本之后;样式对齐系统复制按钮)
|
|
1995
|
+
ctx.slots.inject("conversation.chat.voice-actions", () => ctx.slots.register({
|
|
1996
|
+
name: "conversation.chat.voice-actions",
|
|
1997
|
+
id: "voice-copy-transcript",
|
|
1998
|
+
order: 0,
|
|
1999
|
+
}, VoiceCopyTranscriptAction)),
|
|
2000
|
+
];
|
|
2001
|
+
return () => { for (const d of disposers) d(); };
|
|
2002
|
+
}, "dsh-input-tools: toolbar");
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
exports.apply = apply;
|
|
2006
|
+
exports.inject = inject;
|
|
2007
|
+
return module.exports;
|
|
2008
|
+
}
|
|
1554
2009
|
});
|