@aiyiran/myclaw 1.0.26 → 1.0.27

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.
@@ -0,0 +1,67 @@
1
+ #!/bin/bash
2
+
3
+ # ============================================
4
+ # 脚本配置
5
+ # ============================================
6
+ URL="${1:-http://127.0.0.1:18789}" # 要导航的网页(默认本地控制台)
7
+ CHECK_INTERVAL=5 # 每隔 5 秒检查一次 Chrome
8
+ MAX_WAIT=600 # 最多等待 10 分钟(可调整)
9
+
10
+ # ============================================
11
+ # 检测操作系统
12
+ # ============================================
13
+ OS=$(uname -s)
14
+ case "$OS" in
15
+ Darwin*) OS_TYPE="Mac" ;;
16
+ Linux*) OS_TYPE="Linux" ;;
17
+ CYGWIN*|MINGW*|MSYS*) OS_TYPE="Windows" ;;
18
+ *) echo "Unsupported OS: $OS"; exit 1 ;;
19
+ esac
20
+
21
+ # ============================================
22
+ # 设置 Chrome 默认路径
23
+ # ============================================
24
+ case "$OS_TYPE" in
25
+ Mac) CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" ;;
26
+ Linux) CHROME="$(which google-chrome || which chromium-browser || echo "")" ;;
27
+ Windows) CHROME="/mnt/c/Program Files/Google/Chrome/Application/chrome.exe" ;;
28
+ esac
29
+
30
+ # ============================================
31
+ # 检测 Chrome 是否存在
32
+ # ============================================
33
+ if [ -z "$CHROME" ] || [ ! -f "$CHROME" ]; then
34
+ echo "Chrome not found on your system!"
35
+
36
+ # 打开官方下载页面
37
+ case "$OS_TYPE" in
38
+ Mac) open "https://www.google.com/chrome/" ;;
39
+ Linux) xdg-open "https://www.google.com/linux/chrome/" >/dev/null 2>&1 || echo "Please download Chrome from https://www.google.com/linux/chrome/" ;;
40
+ Windows) powershell.exe -Command "Start-Process 'https://www.google.com/chrome/'" ;;
41
+ esac
42
+
43
+ echo "Please install Chrome. Waiting for installation..."
44
+
45
+ # 开始轮询等待用户安装
46
+ ELAPSED=0
47
+ while [ ! -f "$CHROME" ]; do
48
+ sleep $CHECK_INTERVAL
49
+ ELAPSED=$((ELAPSED + CHECK_INTERVAL))
50
+ if [ $ELAPSED -ge $MAX_WAIT ]; then
51
+ echo "Timed out waiting for Chrome. Please install manually and rerun this script."
52
+ exit 1
53
+ fi
54
+ done
55
+ echo "Chrome installation detected!"
56
+ fi
57
+
58
+ # ============================================
59
+ # 打开目标 URL
60
+ # ============================================
61
+ echo "Opening URL: $URL ..."
62
+ case "$OS_TYPE" in
63
+ Mac|Linux) "$CHROME" "$URL" ;;
64
+ Windows) powershell.exe -Command "Start-Process '$CHROME' '$URL'" ;;
65
+ esac
66
+
67
+ echo "Done!"
@@ -3,20 +3,29 @@
3
3
  * MyClaw UI Inject — 浏览器端注入脚本
4
4
  * ============================================================================
5
5
  *
6
- * 该脚本由 myclaw postinstall 自动注入到 OpenClaw Control UI 的 index.html 中。
7
- * 每次 npm update @aiyiran/myclaw 时会自动更新。
8
- *
9
6
  * 功能:
10
7
  * 1. 页面顶部 fixed 显示 myclaw 版本号
11
- * 2. 语音输入按钮占位(后续接入)
8
+ * 2. 在聊天工具栏新增讯飞语音输入按钮
9
+ * - 光标处插入文字
10
+ * - 持续录入,手动停止
11
+ * - 讯飞 60 秒断开后自动重连
12
+ *
13
+ * 依赖:voice-input.js(讯飞 VoiceInput SDK,需先于本脚本加载)
12
14
  * ============================================================================
13
15
  */
14
16
  (function () {
15
17
  "use strict";
16
18
 
17
- // ═══ 版本号(由 patch.js 在注入时替换) ═══
18
19
  var MYCLAW_VERSION = "__MYCLAW_VERSION__";
19
20
 
21
+ // ═══ 状态 ═══
22
+ var voice = null; // VoiceInput 实例
23
+ var recording = false; // 用户层面的录音状态(独立于 SDK 的 status)
24
+ var pendingText = ""; // 当前这轮识别的文字(实时更新)
25
+ var committedText = ""; // 已经提交到 textarea 的文字(上一轮累积)
26
+ var cursorOffset = 0; // 录音开始时光标在 textarea 中的位置
27
+ var injected = false;
28
+
20
29
  // ═══ 1. 顶部版本号横条 ═══
21
30
  function createVersionBar() {
22
31
  if (document.querySelector("#myclaw-version-bar")) return;
@@ -40,29 +49,244 @@
40
49
  "user-select: none",
41
50
  "letter-spacing: 0.5px",
42
51
  ].join(";");
43
- bar.textContent = "🐾 MyClaw v" + MYCLAW_VERSION;
52
+ bar.textContent = "\uD83D\uDC3E MyClaw v" + MYCLAW_VERSION;
44
53
 
45
54
  document.body.prepend(bar);
46
-
47
- // 把页面整体往下推 28px,避免遮挡
48
55
  document.body.style.paddingTop = "28px";
56
+ }
57
+
58
+ // ═══ 2. textarea 工具 ═══
59
+
60
+ /**
61
+ * 在 textarea 的指定位置写入文字,并触发 Lit 响应式更新
62
+ * @param {string} fullText - 完整的 textarea 内容
63
+ */
64
+ function setTextareaValue(fullText) {
65
+ var ta = document.querySelector(".agent-chat__input textarea");
66
+ if (!ta) return;
67
+
68
+ var setter = Object.getOwnPropertyDescriptor(
69
+ HTMLTextAreaElement.prototype, "value"
70
+ ).set;
71
+ setter.call(ta, fullText);
72
+ ta.dispatchEvent(new Event("input", { bubbles: true }));
73
+ }
74
+
75
+ /**
76
+ * 获取 textarea 当前值
77
+ */
78
+ function getTextareaValue() {
79
+ var ta = document.querySelector(".agent-chat__input textarea");
80
+ return ta ? ta.value : "";
81
+ }
82
+
83
+ /**
84
+ * 获取 textarea 当前光标位置
85
+ */
86
+ function getCursorPosition() {
87
+ var ta = document.querySelector(".agent-chat__input textarea");
88
+ return ta ? ta.selectionStart : 0;
89
+ }
90
+
91
+ /**
92
+ * 在光标位置插入识别的文字
93
+ * 原理:保留光标前的原文 + 已提交文字 + 当前识别文字 + 光标后的原文
94
+ */
95
+ function updateTextAtCursor(recognizedText) {
96
+ var ta = document.querySelector(".agent-chat__input textarea");
97
+ if (!ta) return;
98
+
99
+ // 录音开始时的位置前面的文字(不变)
100
+ var before = committedText.substring(0, cursorOffset);
101
+ // 录音开始时位置后面的文字(不变)
102
+ var originalAfter = committedText.substring(cursorOffset);
103
+
104
+ // 在光标位置插入已识别的文字
105
+ var newValue = before + recognizedText + originalAfter;
106
+ setTextareaValue(newValue);
107
+
108
+ // 把光标放到识别文字的末尾
109
+ var newCursorPos = before.length + recognizedText.length;
110
+ try {
111
+ ta.setSelectionRange(newCursorPos, newCursorPos);
112
+ } catch (e) {}
113
+ }
114
+
115
+ // ═══ 3. 语音按钮 ═══
116
+
117
+ function createVoiceButton() {
118
+ var btn = document.createElement("button");
119
+ btn.id = "myclaw-voice-btn";
120
+ btn.className = "agent-chat__input-btn";
121
+ btn.title = "\u8baf\u98de\u8bed\u97f3";
122
+ btn.setAttribute("aria-label", "\u8baf\u98de\u8bed\u97f3\u8f93\u5165");
123
+ btn.innerHTML = [
124
+ '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18"',
125
+ ' viewBox="0 0 24 24" fill="none" stroke="currentColor"',
126
+ ' stroke-width="2" stroke-linecap="round" stroke-linejoin="round">',
127
+ ' <path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/>',
128
+ ' <path d="M19 10v2a7 7 0 0 1-14 0v-2"/>',
129
+ ' <line x1="12" x2="12" y1="19" y2="22"/>',
130
+ ' <circle cx="12" cy="22" r="1" fill="currentColor" stroke="none"/>',
131
+ '</svg>',
132
+ ].join("");
133
+
134
+ btn.addEventListener("click", function () {
135
+ if (recording) {
136
+ stopVoice();
137
+ } else {
138
+ startVoice();
139
+ }
140
+ });
141
+
142
+ return btn;
143
+ }
144
+
145
+ function updateButtonUI() {
146
+ var btn = document.querySelector("#myclaw-voice-btn");
147
+ if (!btn) return;
49
148
 
50
- console.log("[myclaw-inject] ✅ 版本号横条已注入: v" + MYCLAW_VERSION);
149
+ if (recording) {
150
+ btn.classList.add("agent-chat__input-btn--recording");
151
+ btn.title = "\u505c\u6b62\u8bed\u97f3";
152
+ } else {
153
+ btn.classList.remove("agent-chat__input-btn--recording");
154
+ btn.title = "\u8baf\u98de\u8bed\u97f3";
155
+ }
51
156
  }
52
157
 
53
- // ═══ 2. 语音输入按钮占位(TODO: 后续接入) ═══
158
+ // ═══ 4. 录音控制 ═══
159
+
54
160
  function initVoice() {
55
- console.log("[myclaw-inject] 🎙️ 语音模块已加载(占位),等待后续接入...");
56
- console.log("[myclaw-inject] 语音注入点就绪,后续在此处添加按钮和录音逻辑");
161
+ if (typeof window.VoiceInput === "undefined") {
162
+ console.error("[myclaw-inject] VoiceInput SDK \u672a\u52a0\u8f7d");
163
+ return;
164
+ }
165
+
166
+ voice = new window.VoiceInput({
167
+ onResult: function (text) {
168
+ // 讯飞实时返回识别文字,替换到光标位置
169
+ pendingText = text;
170
+ updateTextAtCursor(pendingText);
171
+ console.log("[myclaw-voice] \u8bc6\u522b\u4e2d:", text);
172
+ },
173
+ onStatusChange: function (oldStatus, newStatus) {
174
+ console.log("[myclaw-voice] \u72b6\u6001:", oldStatus, "->", newStatus);
175
+
176
+ if (newStatus === "idle" && recording) {
177
+ // 讯飞 60 秒断开,但用户没有点停止 → 自动重连
178
+ // 把当前识别的文字提交,并更新光标位置
179
+ committedText = getTextareaValue();
180
+ cursorOffset = getCursorPosition();
181
+ pendingText = "";
182
+
183
+ console.log("[myclaw-voice] \u81ea\u52a8\u91cd\u8fde...");
184
+ setTimeout(function () {
185
+ if (recording && voice) {
186
+ voice.start();
187
+ }
188
+ }, 300);
189
+ }
190
+ },
191
+ onError: function (err) {
192
+ console.error("[myclaw-voice] \u9519\u8bef:", err);
193
+ // 权限错误是永久性的,不重试,直接停止
194
+ var errStr = String(err).toLowerCase();
195
+ if (errStr.indexOf("permission") !== -1 || errStr.indexOf("not allowed") !== -1) {
196
+ console.error("[myclaw-voice] ⛔ 麦克风权限被拒绝,停止录音");
197
+ recording = false;
198
+ updateButtonUI();
199
+ return;
200
+ }
201
+ // 其他错误(如 WebSocket)尝试重连
202
+ if (recording) {
203
+ setTimeout(function () {
204
+ if (recording && voice) {
205
+ console.log("[myclaw-voice] \u9519\u8bef\u540e\u91cd\u8fde...");
206
+ voice.start();
207
+ }
208
+ }, 1000);
209
+ }
210
+ },
211
+ });
212
+
213
+ console.log("[myclaw-inject] \u2705 \u8baf\u98de\u8bed\u97f3 SDK \u5df2\u521d\u59cb\u5316");
214
+ }
215
+
216
+ function startVoice() {
217
+ if (!voice) {
218
+ initVoice();
219
+ if (!voice) return;
220
+ }
221
+
222
+ // 记录当前 textarea 状态和光标位置
223
+ committedText = getTextareaValue();
224
+ cursorOffset = getCursorPosition();
225
+ pendingText = "";
226
+
227
+ recording = true;
228
+ updateButtonUI();
229
+ voice.start();
230
+
231
+ console.log("[myclaw-voice] \u5f00\u59cb\u5f55\u97f3\uff0c\u5149\u6807\u4f4d\u7f6e:", cursorOffset);
232
+ }
233
+
234
+ function stopVoice() {
235
+ recording = false;
236
+ updateButtonUI();
237
+
238
+ if (voice) {
239
+ voice.stop();
240
+ }
241
+
242
+ // 最终提交文字
243
+ committedText = getTextareaValue();
244
+ pendingText = "";
245
+
246
+ console.log("[myclaw-voice] \u505c\u6b62\u5f55\u97f3");
247
+ }
248
+
249
+ // ═══ 5. DOM 注入 ═══
250
+
251
+ function injectButton() {
252
+ var toolbar = document.querySelector(".agent-chat__toolbar-left");
253
+ if (!toolbar || document.querySelector("#myclaw-voice-btn")) return;
254
+
255
+ var btn = createVoiceButton();
256
+
257
+ // 插入到 token 计数之前,或追加到末尾
258
+ var tokenCount = toolbar.querySelector(".agent-chat__token-count");
259
+ if (tokenCount) {
260
+ toolbar.insertBefore(btn, tokenCount);
261
+ } else {
262
+ toolbar.appendChild(btn);
263
+ }
264
+
265
+ injected = true;
266
+ console.log("[myclaw-inject] \u2705 \u8bed\u97f3\u6309\u94ae\u5df2\u6ce8\u5165");
57
267
  }
58
268
 
59
269
  // ═══ 启动 ═══
60
270
  function init() {
61
271
  createVersionBar();
272
+
273
+ // 初始化 VoiceInput SDK
62
274
  initVoice();
275
+
276
+ // 持续监听 DOM 变化,确保按钮始终在
277
+ new MutationObserver(function () {
278
+ if (!document.querySelector("#myclaw-voice-btn")) {
279
+ injected = false;
280
+ }
281
+ if (!injected) {
282
+ injectButton();
283
+ }
284
+ }).observe(document.documentElement, { childList: true, subtree: true });
285
+
286
+ // 尝试立即注入
287
+ injectButton();
63
288
  }
64
289
 
65
- // 确保 DOM 就绪
66
290
  if (document.readyState === "loading") {
67
291
  document.addEventListener("DOMContentLoaded", init);
68
292
  } else {
package/index.js CHANGED
@@ -284,6 +284,142 @@ pause
284
284
  }
285
285
  }
286
286
 
287
+ // ============================================================================
288
+ // Open 命令 - 打开浏览器
289
+ // ============================================================================
290
+
291
+ function runOpen() {
292
+ const fs = require('fs');
293
+ const { exec } = require('child_process');
294
+ const url = args[1] || 'http://127.0.0.1:18789';
295
+ const platform = os.platform();
296
+ const bar = '----------------------------------------';
297
+
298
+ console.log('');
299
+ console.log('[' + colors.blue + 'MyClaw' + colors.nc + '] ' + colors.blue + '打开浏览器' + colors.nc);
300
+ console.log(bar);
301
+ console.log('');
302
+
303
+ // 定义各平台 Chrome 路径
304
+ let chromePaths = [];
305
+ if (platform === 'darwin') {
306
+ chromePaths = [
307
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
308
+ ];
309
+ } else if (platform === 'linux') {
310
+ // 检测是否在 WSL2 环境下
311
+ let isWSL = false;
312
+ try {
313
+ const releaseInfo = fs.readFileSync('/proc/version', 'utf8');
314
+ if (/microsoft|wsl/i.test(releaseInfo)) isWSL = true;
315
+ } catch {}
316
+
317
+ if (isWSL) {
318
+ // WSL2 环境下优先使用 Windows 端的 Chrome
319
+ chromePaths = [
320
+ '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
321
+ '/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe',
322
+ ];
323
+ } else {
324
+ chromePaths = [
325
+ '/usr/bin/google-chrome',
326
+ '/usr/bin/google-chrome-stable',
327
+ '/usr/bin/chromium-browser',
328
+ '/usr/bin/chromium',
329
+ ];
330
+ }
331
+ } else if (platform === 'win32') {
332
+ chromePaths = [
333
+ path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'),
334
+ path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'),
335
+ path.join(os.homedir(), 'AppData', 'Local', 'Google', 'Chrome', 'Application', 'chrome.exe'),
336
+ ];
337
+ }
338
+
339
+ // 查找 Chrome
340
+ let chromePath = null;
341
+ for (const p of chromePaths) {
342
+ try {
343
+ if (fs.existsSync(p)) {
344
+ chromePath = p;
345
+ break;
346
+ }
347
+ } catch {}
348
+ }
349
+
350
+ // 如果没有找到 Chrome
351
+ if (!chromePath) {
352
+ console.log('[' + colors.yellow + '提示' + colors.nc + '] 未检测到 Chrome 浏览器');
353
+ console.log('');
354
+ console.log('正在打开 Chrome 下载页面...');
355
+ console.log('');
356
+
357
+ const downloadUrl = 'https://www.google.com/chrome/';
358
+ try {
359
+ if (platform === 'darwin') {
360
+ execSync('open "' + downloadUrl + '"', { stdio: 'ignore' });
361
+ } else if (platform === 'win32') {
362
+ execSync('start "" "' + downloadUrl + '"', { stdio: 'ignore', shell: true });
363
+ } else {
364
+ // Linux / WSL2
365
+ try {
366
+ execSync('xdg-open "' + downloadUrl + '"', { stdio: 'ignore' });
367
+ } catch {
368
+ // WSL2 fallback
369
+ try {
370
+ execSync('powershell.exe -Command "Start-Process \'' + downloadUrl + '\'"', { stdio: 'ignore' });
371
+ } catch {
372
+ console.log('请手动下载 Chrome: ' + colors.yellow + downloadUrl + colors.nc);
373
+ }
374
+ }
375
+ }
376
+ } catch {}
377
+
378
+ console.log('安装 Chrome 后重新运行: ' + colors.yellow + 'myclaw open' + colors.nc);
379
+ console.log('');
380
+ console.log(bar);
381
+ console.log('');
382
+ return;
383
+ }
384
+
385
+ // 打开 URL
386
+ console.log('[Chrome] ' + colors.green + '已检测到' + colors.nc);
387
+ console.log(' 路径: ' + chromePath);
388
+ console.log('');
389
+ console.log('[打开] ' + url);
390
+
391
+ try {
392
+ if (platform === 'darwin') {
393
+ // macOS: 使用 open 命令打开 Chrome
394
+ exec('open -a "Google Chrome" "' + url + '"');
395
+ } else if (platform === 'win32') {
396
+ exec('"' + chromePath + '" "' + url + '"');
397
+ } else {
398
+ // Linux / WSL2
399
+ let isWSL = false;
400
+ try {
401
+ const releaseInfo = fs.readFileSync('/proc/version', 'utf8');
402
+ if (/microsoft|wsl/i.test(releaseInfo)) isWSL = true;
403
+ } catch {}
404
+
405
+ if (isWSL) {
406
+ // WSL2: Chrome 路径中有空格,需要特殊处理
407
+ exec('"' + chromePath + '" "' + url + '"');
408
+ } else {
409
+ exec('"' + chromePath + '" "' + url + '"');
410
+ }
411
+ }
412
+ console.log('');
413
+ console.log('[' + colors.green + '成功' + colors.nc + '] 浏览器已启动');
414
+ } catch (err) {
415
+ console.error('[' + colors.red + '错误' + colors.nc + '] 打开浏览器失败: ' + err.message);
416
+ }
417
+
418
+ console.log('');
419
+ console.log(bar);
420
+ console.log('');
421
+ }
422
+
287
423
  // ============================================================================
288
424
  // Patch / Unpatch / Restart
289
425
  // ============================================================================
@@ -387,6 +523,7 @@ function showHelp() {
387
523
  console.log(' install 安装 OpenClaw 服务');
388
524
  console.log(' status 简化版状态查看(学生友好)');
389
525
  console.log(' new 创建新的 Agent(学生练习用)');
526
+ console.log(' open 打开浏览器控制台(默认 http://127.0.0.1:18789)');
390
527
  console.log(' wsl2 WSL2 一键安装/修复 (仅限 Windows)');
391
528
  console.log(' bat 在桌面生成一键启动脚本 (仅限 Windows)');
392
529
  console.log(' patch 注入 MyClaw UI 扩展到 WebChat');
@@ -395,11 +532,13 @@ function showHelp() {
395
532
  console.log(' help 显示帮助信息');
396
533
  console.log('');
397
534
  console.log('示例:');
398
- console.log(' myclaw install # 安装 OpenClaw');
399
- console.log(' myclaw status # 查看状态');
400
- console.log(' myclaw new helper # 创建名为 helper 的 Agent');
401
- console.log(' myclaw patch # 注入 UI 扩展');
402
- console.log(' myclaw restart # 重启 Gateway');
535
+ console.log(' myclaw install # 安装 OpenClaw');
536
+ console.log(' myclaw status # 查看状态');
537
+ console.log(' myclaw new helper # 创建名为 helper 的 Agent');
538
+ console.log(' myclaw open # 打开默认控制台');
539
+ console.log(' myclaw open http://... # 打开指定 URL');
540
+ console.log(' myclaw patch # 注入 UI 扩展');
541
+ console.log(' myclaw restart # 重启 Gateway');
403
542
  console.log('');
404
543
  console.log('跨平台: macOS / Linux / Windows (PowerShell/CMD)');
405
544
  console.log('');
@@ -419,6 +558,8 @@ if (!command || command === 'help' || command === '--help' || command === '-h')
419
558
  runStatus();
420
559
  } else if (command === 'new') {
421
560
  runNew();
561
+ } else if (command === 'open') {
562
+ runOpen();
422
563
  } else if (command === 'wsl2') {
423
564
  runWsl2();
424
565
  } else if (command === 'bat') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiyiran/myclaw",
3
- "version": "1.0.26",
3
+ "version": "1.0.27",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "bin": {
package/patch.js CHANGED
@@ -23,6 +23,7 @@ const path = require('path');
23
23
 
24
24
  const INJECT_MARKER = '<!-- myclaw-inject -->';
25
25
  const INJECT_FILENAME = 'myclaw-inject.js';
26
+ const VOICE_SDK_FILENAME = 'voice-input.js';
26
27
  const BACKUP_SUFFIX = '.myclaw-backup';
27
28
 
28
29
  /**
@@ -106,6 +107,8 @@ function patch() {
106
107
  const backupPath = indexPath + BACKUP_SUFFIX;
107
108
  const injectSrc = path.join(__dirname, 'assets', INJECT_FILENAME);
108
109
  const injectDest = path.join(uiDir, INJECT_FILENAME);
110
+ const voiceSdkSrc = path.join(__dirname, 'voice-input', VOICE_SDK_FILENAME);
111
+ const voiceSdkDest = path.join(uiDir, VOICE_SDK_FILENAME);
109
112
  const version = getMyclawVersion();
110
113
 
111
114
  console.log('[myclaw-patch] 📍 找到 control-ui: ' + uiDir);
@@ -133,19 +136,34 @@ function patch() {
133
136
  return { success: false, reason: 'copy-failed' };
134
137
  }
135
138
 
136
- // 3. Patch index.html(幂等)
139
+ // 3. 复制 VoiceInput SDK
140
+ try {
141
+ fs.copyFileSync(voiceSdkSrc, voiceSdkDest);
142
+ console.log('[myclaw-patch] 📄 语音 SDK 已复制: ' + voiceSdkDest);
143
+ } catch (err) {
144
+ console.error('[myclaw-patch] ⚠️ 语音 SDK 复制失败 (非致命): ' + err.message);
145
+ // 非致命错误,继续执行
146
+ }
147
+
148
+ // 4. Patch index.html(幂等)
137
149
  try {
138
150
  let html = fs.readFileSync(indexPath, 'utf8');
139
151
 
140
152
  // 先移除旧的注入(如果有)
141
153
  const markerRegex = new RegExp(
142
- INJECT_MARKER + '\\s*<script[^>]*' + INJECT_FILENAME + '[^>]*><\\/script>\\s*',
154
+ INJECT_MARKER + '[\\s\\S]*?(?=</body>)',
143
155
  'g'
144
156
  );
145
157
  html = html.replace(markerRegex, '');
146
158
 
147
- // 在 </body> 前注入
148
- const injection = INJECT_MARKER + '\n<script src="./' + INJECT_FILENAME + '"></script>\n';
159
+ // 在 </body> 前注入(SDK 先加载,inject 后加载)
160
+ const injection = [
161
+ INJECT_MARKER,
162
+ '<script src="./' + VOICE_SDK_FILENAME + '"></script>',
163
+ '<script src="./' + INJECT_FILENAME + '"></script>',
164
+ '',
165
+ ].join('\n');
166
+
149
167
  if (html.includes('</body>')) {
150
168
  html = html.replace('</body>', injection + '</body>');
151
169
  } else {
@@ -153,13 +171,54 @@ function patch() {
153
171
  }
154
172
 
155
173
  fs.writeFileSync(indexPath, html, 'utf8');
156
- console.log('[myclaw-patch] ✅ index.html 已注入');
174
+ console.log('[myclaw-patch] ✅ index.html 已注入 (SDK + inject)');
157
175
  } catch (err) {
158
176
  console.error('[myclaw-patch] ❌ 注入 index.html 失败: ' + err.message);
159
177
  return { success: false, reason: 'inject-failed' };
160
178
  }
161
179
 
162
180
  console.log('[myclaw-patch] 🎉 注入完成! 重启 Gateway 后生效');
181
+
182
+ // 5. Patch Permissions-Policy 头(允许麦克风)
183
+ // OpenClaw 在 gateway-cli-*.js 中硬编码了 microphone=(),需要改为 microphone=(self)
184
+ try {
185
+ const distDir = path.join(uiDir, '..');
186
+ const files = fs.readdirSync(path.join(distDir, '..'));
187
+ // 找到 dist 目录下的 gateway-cli-*.js
188
+ const distParent = path.resolve(uiDir, '..'); // dist/
189
+ const distFiles = fs.readdirSync(distParent);
190
+ let patched = false;
191
+
192
+ for (const f of distFiles) {
193
+ if (f.startsWith('gateway-cli-') && f.endsWith('.js')) {
194
+ const filePath = path.join(distParent, f);
195
+ let content = fs.readFileSync(filePath, 'utf8');
196
+
197
+ if (content.includes('microphone=()')) {
198
+ // 备份
199
+ const backupFile = filePath + BACKUP_SUFFIX;
200
+ if (!fs.existsSync(backupFile)) {
201
+ fs.copyFileSync(filePath, backupFile);
202
+ }
203
+
204
+ content = content.replace(
205
+ 'microphone=()',
206
+ 'microphone=(self)'
207
+ );
208
+ fs.writeFileSync(filePath, content, 'utf8');
209
+ console.log('[myclaw-patch] 🎤 已修复 Permissions-Policy (microphone): ' + f);
210
+ patched = true;
211
+ }
212
+ }
213
+ }
214
+
215
+ if (!patched) {
216
+ console.log('[myclaw-patch] ⚠️ 未找到 Permissions-Policy 配置,麦克风可能受限');
217
+ }
218
+ } catch (err) {
219
+ console.error('[myclaw-patch] ⚠️ Permissions-Policy 修复失败 (非致命): ' + err.message);
220
+ }
221
+
163
222
  return { success: true, uiDir: uiDir, version: version };
164
223
  }
165
224
 
@@ -176,6 +235,7 @@ function unpatch() {
176
235
  const indexPath = path.join(uiDir, 'index.html');
177
236
  const backupPath = indexPath + BACKUP_SUFFIX;
178
237
  const injectDest = path.join(uiDir, INJECT_FILENAME);
238
+ const voiceSdkDest = path.join(uiDir, VOICE_SDK_FILENAME);
179
239
 
180
240
  // 恢复备份
181
241
  if (fs.existsSync(backupPath)) {
@@ -190,6 +250,29 @@ function unpatch() {
190
250
  console.log('[myclaw-patch] ✅ 注入脚本已删除');
191
251
  }
192
252
 
253
+ // 删除语音 SDK
254
+ if (fs.existsSync(voiceSdkDest)) {
255
+ fs.unlinkSync(voiceSdkDest);
256
+ console.log('[myclaw-patch] ✅ 语音 SDK 已删除');
257
+ }
258
+
259
+ // 恢复 gateway-cli-*.js 的 Permissions-Policy
260
+ try {
261
+ const distParent = path.resolve(uiDir, '..');
262
+ const distFiles = fs.readdirSync(distParent);
263
+ for (const f of distFiles) {
264
+ if (f.startsWith('gateway-cli-') && f.endsWith('.js' + BACKUP_SUFFIX)) {
265
+ const originalFile = path.join(distParent, f.replace(BACKUP_SUFFIX, ''));
266
+ const backupFile = path.join(distParent, f);
267
+ fs.copyFileSync(backupFile, originalFile);
268
+ fs.unlinkSync(backupFile);
269
+ console.log('[myclaw-patch] ✅ Permissions-Policy 已恢复: ' + f.replace(BACKUP_SUFFIX, ''));
270
+ }
271
+ }
272
+ } catch (err) {
273
+ // 非致命
274
+ }
275
+
193
276
  console.log('[myclaw-patch] 🔄 回滚完成! 重启 Gateway 后生效');
194
277
  return { success: true };
195
278
  }
@@ -0,0 +1,72 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>语音输入演示</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ body { font-family: -apple-system, sans-serif; background: #f5f5f5; padding: 40px 20px; }
10
+ .container { max-width: 500px; margin: 0 auto; }
11
+ h1 { font-size: 20px; color: #333; margin-bottom: 20px; }
12
+ textarea {
13
+ width: 100%; height: 150px; padding: 12px; font-size: 15px;
14
+ border: 1px solid #ddd; border-radius: 8px; resize: vertical;
15
+ line-height: 1.6;
16
+ }
17
+ .btn {
18
+ margin-top: 12px; padding: 12px 28px; font-size: 15px;
19
+ border: none; border-radius: 8px; cursor: pointer;
20
+ color: white; transition: all 0.2s;
21
+ }
22
+ .btn-start { background: #007bff; }
23
+ .btn-start:hover { background: #0056b3; }
24
+ .btn-stop { background: #ff4444; }
25
+ .btn-stop:hover { background: #cc0000; }
26
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
27
+ .status { margin-top: 10px; font-size: 13px; color: #888; }
28
+ </style>
29
+ </head>
30
+ <body>
31
+ <div class="container">
32
+ <h1>语音输入演示</h1>
33
+ <textarea id="result" placeholder="识别结果将显示在这里..."></textarea>
34
+ <div>
35
+ <button class="btn btn-start" id="startBtn">🎤 开始录音</button>
36
+ <button class="btn btn-stop" id="stopBtn" disabled>⏹ 停止录音</button>
37
+ </div>
38
+ <div class="status" id="status">就绪</div>
39
+ </div>
40
+
41
+ <script src="voice-input.js"></script>
42
+ <script>
43
+ var textarea = document.getElementById('result');
44
+ var startBtn = document.getElementById('startBtn');
45
+ var stopBtn = document.getElementById('stopBtn');
46
+ var statusEl = document.getElementById('status');
47
+
48
+ var voice = new VoiceInput({
49
+ onResult: function (text) {
50
+ textarea.value = text;
51
+ },
52
+ onStatusChange: function (oldStatus, newStatus) {
53
+ var labels = {
54
+ idle: '就绪',
55
+ connecting: '连接中...',
56
+ recording: '🔴 正在录音',
57
+ stopping: '处理中...'
58
+ };
59
+ statusEl.textContent = labels[newStatus] || newStatus;
60
+ startBtn.disabled = (newStatus !== 'idle');
61
+ stopBtn.disabled = (newStatus !== 'recording');
62
+ },
63
+ onError: function (err) {
64
+ statusEl.textContent = '❌ ' + err;
65
+ }
66
+ });
67
+
68
+ startBtn.onclick = function () { voice.start(); };
69
+ stopBtn.onclick = function () { voice.stop(); };
70
+ </script>
71
+ </body>
72
+ </html>
@@ -0,0 +1,440 @@
1
+ /**
2
+ * VoiceInput - 独立语音转文字 SDK
3
+ * 基于讯飞语音听写 WebAPI(流式版)
4
+ *
5
+ * 零依赖,纯原生 JS,任何静态 HTML 页面都能用
6
+ *
7
+ * 用法:
8
+ * const voice = new VoiceInput({
9
+ * onResult: (text) => { textarea.value = text; }
10
+ * });
11
+ * voice.start(); // 开始录音
12
+ * voice.stop(); // 停止录音
13
+ */
14
+ (function (global) {
15
+ 'use strict';
16
+
17
+ // ============ 默认配置 ============
18
+ var DEFAULT_CONFIG = {
19
+ APPID: '029f22a0',
20
+ APISecret: 'Njk1Y2VlYjhhODhkNTYxM2Y3YTE4YzRl',
21
+ APIKey: '60f2b651b26629030c861e5b2c96102d',
22
+ url: 'wss://iat.xf-yun.com/v1',
23
+ host: 'iat.xf-yun.com',
24
+ language: 'zh_cn',
25
+ accent: 'mandarin'
26
+ };
27
+
28
+ // ============ 主线程音频处理(避免 CSP 限制 blob Worker)============
29
+ var _processorSampleRate = 44100;
30
+
31
+ function to16kHz(data) {
32
+ var fitCount = Math.round(data.length * (16000 / _processorSampleRate));
33
+ if (fitCount <= 1) return new Float32Array(0);
34
+ var newData = new Float32Array(fitCount);
35
+ var springFactor = (data.length - 1) / (fitCount - 1);
36
+ newData[0] = data[0];
37
+ for (var i = 1; i < fitCount - 1; i++) {
38
+ var tmp = i * springFactor;
39
+ var before = Math.floor(tmp);
40
+ var after = Math.ceil(tmp);
41
+ newData[i] = data[before] + (data[after] - data[before]) * (tmp - before);
42
+ }
43
+ newData[fitCount - 1] = data[data.length - 1];
44
+ return newData;
45
+ }
46
+
47
+ function to16BitPCM(input) {
48
+ var buf = new ArrayBuffer(input.length * 2);
49
+ var view = new DataView(buf);
50
+ for (var i = 0; i < input.length; i++) {
51
+ var s = Math.max(-1, Math.min(1, input[i]));
52
+ view.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
53
+ }
54
+ return buf;
55
+ }
56
+
57
+ function processAudioData(float32Array) {
58
+ var output = to16kHz(float32Array);
59
+ if (output.length === 0) return null;
60
+ var pcm = to16BitPCM(output);
61
+ var arr = [];
62
+ var bytes = new Uint8Array(pcm);
63
+ for (var i = 0; i < bytes.length; i++) arr.push(bytes[i]);
64
+ return arr;
65
+ }
66
+
67
+ // ============ 工具函数 ============
68
+
69
+ // 使用浏览器原生 SubtleCrypto 做 HMAC-SHA256(无需 CryptoJS)
70
+ function hmacSHA256(key, message) {
71
+ var enc = new TextEncoder();
72
+ return crypto.subtle.importKey(
73
+ 'raw', enc.encode(key), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
74
+ ).then(function (cryptoKey) {
75
+ return crypto.subtle.sign('HMAC', cryptoKey, enc.encode(message));
76
+ }).then(function (sig) {
77
+ return arrayBufferToBase64(sig);
78
+ });
79
+ }
80
+
81
+ function arrayBufferToBase64(buffer) {
82
+ var binary = '';
83
+ var bytes = new Uint8Array(buffer);
84
+ for (var i = 0; i < bytes.length; i++) {
85
+ binary += String.fromCharCode(bytes[i]);
86
+ }
87
+ return btoa(binary);
88
+ }
89
+
90
+ function toBase64(buffer) {
91
+ var binary = '';
92
+ var bytes = new Uint8Array(buffer);
93
+ for (var i = 0; i < bytes.length; i++) {
94
+ binary += String.fromCharCode(bytes[i]);
95
+ }
96
+ return btoa(binary);
97
+ }
98
+
99
+ function base64ToUtf8(base64Str) {
100
+ try {
101
+ var binaryStr = atob(base64Str);
102
+ var bytes = new Uint8Array(binaryStr.length);
103
+ for (var i = 0; i < binaryStr.length; i++) {
104
+ bytes[i] = binaryStr.charCodeAt(i);
105
+ }
106
+ return new TextDecoder('utf-8').decode(bytes);
107
+ } catch (e) {
108
+ return '';
109
+ }
110
+ }
111
+
112
+ // ============ VoiceInput 类 ============
113
+
114
+ function VoiceInput(opts) {
115
+ opts = opts || {};
116
+
117
+ // 配置
118
+ this.APPID = opts.APPID || DEFAULT_CONFIG.APPID;
119
+ this.APISecret = opts.APISecret || DEFAULT_CONFIG.APISecret;
120
+ this.APIKey = opts.APIKey || DEFAULT_CONFIG.APIKey;
121
+ this.url = opts.url || DEFAULT_CONFIG.url;
122
+ this.host = opts.host || DEFAULT_CONFIG.host;
123
+ this.language = opts.language || DEFAULT_CONFIG.language;
124
+ this.accent = opts.accent || DEFAULT_CONFIG.accent;
125
+
126
+ // 回调
127
+ this.onResult = opts.onResult || function () {};
128
+ this.onStatusChange = opts.onStatusChange || function () {};
129
+ this.onError = opts.onError || function () {};
130
+
131
+ // 内部状态
132
+ this.status = 'idle'; // idle | connecting | recording | stopping | stopped
133
+ this.audioData = [];
134
+ this.textSegments = [];
135
+ this.webSocket = null;
136
+ this.audioContext = null;
137
+ this.scriptProcessor = null;
138
+ this.mediaSource = null;
139
+ this.streamRef = null;
140
+ this.sendInterval = null;
141
+ this.resultTimeout = null;
142
+ this.worker = null;
143
+ }
144
+
145
+ VoiceInput.prototype._initProcessor = function () {
146
+ // 主线程处理,无需 Worker
147
+ this._processorReady = true;
148
+ };
149
+
150
+ VoiceInput.prototype._processAudio = function (float32Array) {
151
+ var result = processAudioData(float32Array);
152
+ if (result) {
153
+ this.audioData.push.apply(this.audioData, result);
154
+ }
155
+ };
156
+
157
+ VoiceInput.prototype._setStatus = function (status) {
158
+ if (this.status !== status) {
159
+ var old = this.status;
160
+ this.status = status;
161
+ this.onStatusChange(old, status);
162
+ }
163
+ };
164
+
165
+ VoiceInput.prototype._getWebSocketUrl = function () {
166
+ var self = this;
167
+ var date = new Date().toUTCString();
168
+ var signatureOrigin = 'host: ' + self.host + '\ndate: ' + date + '\nGET /v1 HTTP/1.1';
169
+
170
+ return hmacSHA256(self.APISecret, signatureOrigin).then(function (signature) {
171
+ var authOrigin = 'api_key="' + self.APIKey + '", algorithm="hmac-sha256", headers="host date request-line", signature="' + signature + '"';
172
+ var authorization = btoa(authOrigin);
173
+ return self.url + '?authorization=' + authorization + '&date=' + encodeURIComponent(date) + '&host=' + self.host;
174
+ });
175
+ };
176
+
177
+ VoiceInput.prototype.start = function () {
178
+ if (this.status === 'recording' || this.status === 'connecting') return;
179
+
180
+ var self = this;
181
+ this.audioData = [];
182
+ this.textSegments = [];
183
+ this._setStatus('connecting');
184
+
185
+ // 获取麦克风
186
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
187
+ this.onError('浏览器不支持录音');
188
+ this._setStatus('idle');
189
+ return;
190
+ }
191
+
192
+ navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) {
193
+ self.streamRef = stream;
194
+
195
+ // 初始化音频上下文
196
+ var AudioCtx = window.AudioContext || window.webkitAudioContext;
197
+ self.audioContext = new AudioCtx();
198
+ self.audioContext.resume();
199
+
200
+ // 告诉处理器实际采样率
201
+ _processorSampleRate = self.audioContext.sampleRate;
202
+
203
+ // 设置音频处理
204
+ self.scriptProcessor = self.audioContext.createScriptProcessor(0, 1, 1);
205
+ self.scriptProcessor.onaudioprocess = function (event) {
206
+ if (self.status === 'recording') {
207
+ self._processAudio(event.inputBuffer.getChannelData(0));
208
+ }
209
+ };
210
+ self.mediaSource = self.audioContext.createMediaStreamSource(stream);
211
+ self.mediaSource.connect(self.scriptProcessor);
212
+ self.scriptProcessor.connect(self.audioContext.destination);
213
+
214
+ // 连接 WebSocket
215
+ self._connectWebSocket();
216
+ }).catch(function (err) {
217
+ self.onError('麦克风权限获取失败: ' + err.message);
218
+ self._setStatus('idle');
219
+ });
220
+ };
221
+
222
+ VoiceInput.prototype._connectWebSocket = function () {
223
+ var self = this;
224
+
225
+ this._getWebSocketUrl().then(function (url) {
226
+ var ws = new WebSocket(url);
227
+ self.webSocket = ws;
228
+
229
+ var timeout = setTimeout(function () {
230
+ if (ws.readyState === WebSocket.CONNECTING) {
231
+ ws.close();
232
+ self.onError('WebSocket 连接超时');
233
+ self._cleanup();
234
+ }
235
+ }, 5000);
236
+
237
+ ws.onopen = function () {
238
+ clearTimeout(timeout);
239
+ self._setStatus('recording');
240
+ setTimeout(function () { self._startSending(); }, 300);
241
+ };
242
+
243
+ ws.onmessage = function (event) {
244
+ self._handleResult(event.data);
245
+ };
246
+
247
+ ws.onerror = function () {
248
+ clearTimeout(timeout);
249
+ self.onError('WebSocket 连接错误');
250
+ self._cleanup();
251
+ };
252
+
253
+ ws.onclose = function () {
254
+ clearTimeout(timeout);
255
+ if (self.status !== 'idle') {
256
+ self._setStatus('idle');
257
+ }
258
+ };
259
+ }).catch(function (err) {
260
+ self.onError('签名失败: ' + err);
261
+ self._cleanup();
262
+ });
263
+ };
264
+
265
+ VoiceInput.prototype._startSending = function () {
266
+ var self = this;
267
+ if (!this.webSocket || this.webSocket.readyState !== 1) return;
268
+
269
+ // 发送首帧(带参数)
270
+ var firstAudio = this.audioData.splice(0, 1280);
271
+ if (firstAudio.length === 0) firstAudio = [0]; // 保底
272
+
273
+ this.webSocket.send(JSON.stringify({
274
+ header: { app_id: this.APPID, status: 0 },
275
+ parameter: {
276
+ iat: {
277
+ domain: 'slm',
278
+ language: this.language,
279
+ accent: this.accent,
280
+ eos: 6000,
281
+ vinfo: 1,
282
+ dwa: 'wpgs',
283
+ result: { encoding: 'utf8', compress: 'raw', format: 'json' }
284
+ }
285
+ },
286
+ payload: {
287
+ audio: {
288
+ encoding: 'raw', sample_rate: 16000, channels: 1, bit_depth: 16,
289
+ seq: 1, status: 0, audio: toBase64(firstAudio)
290
+ }
291
+ }
292
+ }));
293
+
294
+ // 每 40ms 发送后续帧
295
+ this.sendInterval = setInterval(function () {
296
+ if (!self.webSocket || self.webSocket.readyState !== 1 || self.status === 'idle') {
297
+ clearInterval(self.sendInterval);
298
+ self.sendInterval = null;
299
+ return;
300
+ }
301
+ // 防数据堆积
302
+ if (self.audioData.length > 10000) {
303
+ self.audioData = self.audioData.slice(-5000);
304
+ }
305
+ var chunk = self.audioData.splice(0, 1280);
306
+ if (chunk.length === 0) return;
307
+
308
+ self.webSocket.send(JSON.stringify({
309
+ header: { app_id: self.APPID, status: 1 },
310
+ payload: {
311
+ audio: { status: 1, audio: toBase64(chunk) }
312
+ }
313
+ }));
314
+ }, 40);
315
+ };
316
+
317
+ VoiceInput.prototype._handleResult = function (data) {
318
+ try {
319
+ var json = JSON.parse(data);
320
+ if (json.payload && json.payload.result) {
321
+ var text = base64ToUtf8(json.payload.result.text);
322
+ var result = JSON.parse(text);
323
+ if (result.ws) {
324
+ var current = result.ws.map(function (ws) {
325
+ return ws.cw.map(function (cw) { return cw.w; }).join('');
326
+ }).join('');
327
+ this.textSegments.push({ pgs: result.pgs, text: current });
328
+ var finalText = this._concatText(this.textSegments);
329
+ this.onResult(finalText);
330
+ }
331
+ }
332
+ // status=2 表示服务端已返回全部结果
333
+ if (json.header && json.header.status === 2) {
334
+ this._cleanupWebSocket();
335
+ }
336
+ } catch (e) {
337
+ // 解析失败忽略
338
+ }
339
+ };
340
+
341
+ VoiceInput.prototype._concatText = function (data) {
342
+ var result = '', currentSentence = '', lastRpl = '';
343
+ data.forEach(function (item) {
344
+ if (item.pgs === 'apd') {
345
+ result += lastRpl || currentSentence;
346
+ currentSentence = item.text;
347
+ lastRpl = '';
348
+ } else if (item.pgs === 'rpl') {
349
+ lastRpl = item.text;
350
+ }
351
+ });
352
+ return result + (lastRpl || currentSentence);
353
+ };
354
+
355
+ VoiceInput.prototype.stop = function () {
356
+ if (this.status !== 'recording') return;
357
+
358
+ this._setStatus('stopping');
359
+
360
+ // 停止定时发送
361
+ if (this.sendInterval) {
362
+ clearInterval(this.sendInterval);
363
+ this.sendInterval = null;
364
+ }
365
+
366
+ // 暂停音频采集
367
+ if (this.audioContext) {
368
+ this.audioContext.suspend();
369
+ }
370
+
371
+ // 发送尾帧
372
+ if (this.webSocket && this.webSocket.readyState === WebSocket.OPEN) {
373
+ this.webSocket.send(JSON.stringify({
374
+ header: { app_id: this.APPID, status: 2 },
375
+ payload: {
376
+ audio: {
377
+ encoding: 'raw', sample_rate: 16000, channels: 1, bit_depth: 16,
378
+ seq: 999, status: 2, audio: ''
379
+ }
380
+ }
381
+ }));
382
+
383
+ // 3秒超时强制关闭
384
+ var self = this;
385
+ this.resultTimeout = setTimeout(function () {
386
+ self._cleanupWebSocket();
387
+ }, 3000);
388
+ } else {
389
+ this._cleanup();
390
+ }
391
+ };
392
+
393
+ VoiceInput.prototype._cleanupWebSocket = function () {
394
+ if (this.resultTimeout) {
395
+ clearTimeout(this.resultTimeout);
396
+ this.resultTimeout = null;
397
+ }
398
+ if (this.webSocket) {
399
+ this.webSocket.close();
400
+ this.webSocket = null;
401
+ }
402
+ this._stopAudioStream();
403
+ this._setStatus('idle');
404
+ };
405
+
406
+ VoiceInput.prototype._stopAudioStream = function () {
407
+ if (this.streamRef) {
408
+ this.streamRef.getTracks().forEach(function (t) { t.stop(); });
409
+ this.streamRef = null;
410
+ }
411
+ if (this.scriptProcessor) {
412
+ this.scriptProcessor.disconnect();
413
+ this.scriptProcessor = null;
414
+ }
415
+ if (this.mediaSource) {
416
+ this.mediaSource.disconnect();
417
+ this.mediaSource = null;
418
+ }
419
+ if (this.audioContext) {
420
+ this.audioContext.close();
421
+ this.audioContext = null;
422
+ }
423
+ };
424
+
425
+ VoiceInput.prototype._cleanup = function () {
426
+ if (this.sendInterval) {
427
+ clearInterval(this.sendInterval);
428
+ this.sendInterval = null;
429
+ }
430
+ this._cleanupWebSocket();
431
+ };
432
+
433
+ VoiceInput.prototype.destroy = function () {
434
+ this._cleanup();
435
+ };
436
+
437
+ // 暴露到全局
438
+ global.VoiceInput = VoiceInput;
439
+
440
+ })(typeof window !== 'undefined' ? window : this);