@aiyiran/myclaw 1.0.26 → 1.0.28
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/assets/detect-browser.sh +67 -0
- package/assets/myclaw-inject.js +267 -13
- package/index.js +146 -5
- package/package.json +1 -1
- package/patch.js +88 -5
- package/voice-input/index.html +72 -0
- package/voice-input/voice-input.js +440 -0
|
@@ -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!"
|
package/assets/myclaw-inject.js
CHANGED
|
@@ -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,274 @@
|
|
|
40
49
|
"user-select: none",
|
|
41
50
|
"letter-spacing: 0.5px",
|
|
42
51
|
].join(";");
|
|
43
|
-
bar.textContent = "
|
|
52
|
+
bar.textContent = "\uD83D\uDC3E MyClaw v" + MYCLAW_VERSION;
|
|
44
53
|
|
|
45
|
-
|
|
54
|
+
// 测试麦克风按钮
|
|
55
|
+
var testBtn = document.createElement("button");
|
|
56
|
+
testBtn.textContent = "\uD83D\uDD0A \u6D4B\u8BD5\u9EA6\u514B\u98CE";
|
|
57
|
+
testBtn.style.cssText = "background:#e94560;color:white;border:none;padding:2px 8px;border-radius:4px;cursor:pointer;font-size:11px;font-family:monospace;";
|
|
58
|
+
testBtn.onclick = function(e) {
|
|
59
|
+
e.stopPropagation();
|
|
60
|
+
console.log("[myclaw] \u6D4B\u8BD5\u9EA6\u514B\u98CE\u70B9\u51FB");
|
|
61
|
+
testMicrophone();
|
|
62
|
+
};
|
|
63
|
+
bar.appendChild(testBtn);
|
|
46
64
|
|
|
47
|
-
|
|
65
|
+
document.body.prepend(bar);
|
|
48
66
|
document.body.style.paddingTop = "28px";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// \u6D4B\u8BD5\u9EA6\u514B\u98CE\u51FD\u6570
|
|
70
|
+
function testMicrophone() {
|
|
71
|
+
console.log("[myclaw] \u5F00\u59CB\u6D4B\u8BD5\u9EA6\u514B\u98CE...");
|
|
72
|
+
if (typeof window.VoiceInput === "undefined") {
|
|
73
|
+
alert("VoiceInput SDK \u672A\u52A0\u8F7D");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
navigator.mediaDevices.getUserMedia({ audio: true })
|
|
77
|
+
.then(function(stream) {
|
|
78
|
+
console.log("[myclaw] \u9EA6\u514B\u98CE\u6743\u9650\u83B7\u53D6\u6210\u529F!");
|
|
79
|
+
stream.getTracks().forEach(function(track) { track.stop(); });
|
|
80
|
+
alert("\u9EA6\u514B\u98CE\u6B63\u5E38!");
|
|
81
|
+
})
|
|
82
|
+
.catch(function(err) {
|
|
83
|
+
console.error("[myclaw] \u9EA6\u514B\u98CE\u6743\u9650\u83B7\u53D6\u5931\u8D25:", err.message);
|
|
84
|
+
alert("\u9EA6\u514B\u98CE\u6743\u9650\u88AB\u62D2\u7EDD: " + err.message);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ═══ 2. textarea 工具 ═══
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 在 textarea 的指定位置写入文字,并触发 Lit 响应式更新
|
|
92
|
+
* @param {string} fullText - 完整的 textarea 内容
|
|
93
|
+
*/
|
|
94
|
+
function setTextareaValue(fullText) {
|
|
95
|
+
var ta = document.querySelector(".agent-chat__input textarea");
|
|
96
|
+
if (!ta) return;
|
|
97
|
+
|
|
98
|
+
var setter = Object.getOwnPropertyDescriptor(
|
|
99
|
+
HTMLTextAreaElement.prototype, "value"
|
|
100
|
+
).set;
|
|
101
|
+
setter.call(ta, fullText);
|
|
102
|
+
ta.dispatchEvent(new Event("input", { bubbles: true }));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 获取 textarea 当前值
|
|
107
|
+
*/
|
|
108
|
+
function getTextareaValue() {
|
|
109
|
+
var ta = document.querySelector(".agent-chat__input textarea");
|
|
110
|
+
return ta ? ta.value : "";
|
|
111
|
+
}
|
|
49
112
|
|
|
50
|
-
|
|
113
|
+
/**
|
|
114
|
+
* 获取 textarea 当前光标位置
|
|
115
|
+
*/
|
|
116
|
+
function getCursorPosition() {
|
|
117
|
+
var ta = document.querySelector(".agent-chat__input textarea");
|
|
118
|
+
return ta ? ta.selectionStart : 0;
|
|
51
119
|
}
|
|
52
120
|
|
|
53
|
-
|
|
121
|
+
/**
|
|
122
|
+
* 在光标位置插入识别的文字
|
|
123
|
+
* 原理:保留光标前的原文 + 已提交文字 + 当前识别文字 + 光标后的原文
|
|
124
|
+
*/
|
|
125
|
+
function updateTextAtCursor(recognizedText) {
|
|
126
|
+
var ta = document.querySelector(".agent-chat__input textarea");
|
|
127
|
+
if (!ta) return;
|
|
128
|
+
|
|
129
|
+
// 录音开始时的位置前面的文字(不变)
|
|
130
|
+
var before = committedText.substring(0, cursorOffset);
|
|
131
|
+
// 录音开始时位置后面的文字(不变)
|
|
132
|
+
var originalAfter = committedText.substring(cursorOffset);
|
|
133
|
+
|
|
134
|
+
// 在光标位置插入已识别的文字
|
|
135
|
+
var newValue = before + recognizedText + originalAfter;
|
|
136
|
+
setTextareaValue(newValue);
|
|
137
|
+
|
|
138
|
+
// 把光标放到识别文字的末尾
|
|
139
|
+
var newCursorPos = before.length + recognizedText.length;
|
|
140
|
+
try {
|
|
141
|
+
ta.setSelectionRange(newCursorPos, newCursorPos);
|
|
142
|
+
} catch (e) {}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ═══ 3. 语音按钮 ═══
|
|
146
|
+
|
|
147
|
+
function createVoiceButton() {
|
|
148
|
+
var btn = document.createElement("button");
|
|
149
|
+
btn.id = "myclaw-voice-btn";
|
|
150
|
+
btn.className = "agent-chat__input-btn";
|
|
151
|
+
btn.title = "\u8baf\u98de\u8bed\u97f3";
|
|
152
|
+
btn.setAttribute("aria-label", "\u8baf\u98de\u8bed\u97f3\u8f93\u5165");
|
|
153
|
+
btn.innerHTML = [
|
|
154
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18"',
|
|
155
|
+
' viewBox="0 0 24 24" fill="none" stroke="currentColor"',
|
|
156
|
+
' stroke-width="2" stroke-linecap="round" stroke-linejoin="round">',
|
|
157
|
+
' <path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/>',
|
|
158
|
+
' <path d="M19 10v2a7 7 0 0 1-14 0v-2"/>',
|
|
159
|
+
' <line x1="12" x2="12" y1="19" y2="22"/>',
|
|
160
|
+
' <circle cx="12" cy="22" r="1" fill="currentColor" stroke="none"/>',
|
|
161
|
+
'</svg>',
|
|
162
|
+
].join("");
|
|
163
|
+
|
|
164
|
+
btn.addEventListener("click", function () {
|
|
165
|
+
if (recording) {
|
|
166
|
+
stopVoice();
|
|
167
|
+
} else {
|
|
168
|
+
startVoice();
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
return btn;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function updateButtonUI() {
|
|
176
|
+
var btn = document.querySelector("#myclaw-voice-btn");
|
|
177
|
+
if (!btn) return;
|
|
178
|
+
|
|
179
|
+
if (recording) {
|
|
180
|
+
btn.classList.add("agent-chat__input-btn--recording");
|
|
181
|
+
btn.title = "\u505c\u6b62\u8bed\u97f3";
|
|
182
|
+
} else {
|
|
183
|
+
btn.classList.remove("agent-chat__input-btn--recording");
|
|
184
|
+
btn.title = "\u8baf\u98de\u8bed\u97f3";
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ═══ 4. 录音控制 ═══
|
|
189
|
+
|
|
54
190
|
function initVoice() {
|
|
55
|
-
|
|
56
|
-
|
|
191
|
+
if (typeof window.VoiceInput === "undefined") {
|
|
192
|
+
console.error("[myclaw-inject] VoiceInput SDK \u672a\u52a0\u8f7d");
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
voice = new window.VoiceInput({
|
|
197
|
+
onResult: function (text) {
|
|
198
|
+
// 讯飞实时返回识别文字,替换到光标位置
|
|
199
|
+
pendingText = text;
|
|
200
|
+
updateTextAtCursor(pendingText);
|
|
201
|
+
console.log("[myclaw-voice] \u8bc6\u522b\u4e2d:", text);
|
|
202
|
+
},
|
|
203
|
+
onStatusChange: function (oldStatus, newStatus) {
|
|
204
|
+
console.log("[myclaw-voice] \u72b6\u6001:", oldStatus, "->", newStatus);
|
|
205
|
+
|
|
206
|
+
if (newStatus === "idle" && recording) {
|
|
207
|
+
// 讯飞 60 秒断开,但用户没有点停止 → 自动重连
|
|
208
|
+
// 把当前识别的文字提交,并更新光标位置
|
|
209
|
+
committedText = getTextareaValue();
|
|
210
|
+
cursorOffset = getCursorPosition();
|
|
211
|
+
pendingText = "";
|
|
212
|
+
|
|
213
|
+
console.log("[myclaw-voice] \u81ea\u52a8\u91cd\u8fde...");
|
|
214
|
+
setTimeout(function () {
|
|
215
|
+
if (recording && voice) {
|
|
216
|
+
voice.start();
|
|
217
|
+
}
|
|
218
|
+
}, 300);
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
onError: function (err) {
|
|
222
|
+
console.error("[myclaw-voice] \u9519\u8bef:", err);
|
|
223
|
+
// 权限错误是永久性的,不重试,直接停止
|
|
224
|
+
var errStr = String(err).toLowerCase();
|
|
225
|
+
if (errStr.indexOf("permission") !== -1 || errStr.indexOf("not allowed") !== -1) {
|
|
226
|
+
console.error("[myclaw-voice] ⛔ 麦克风权限被拒绝,停止录音");
|
|
227
|
+
recording = false;
|
|
228
|
+
updateButtonUI();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
// 其他错误(如 WebSocket)尝试重连
|
|
232
|
+
if (recording) {
|
|
233
|
+
setTimeout(function () {
|
|
234
|
+
if (recording && voice) {
|
|
235
|
+
console.log("[myclaw-voice] \u9519\u8bef\u540e\u91cd\u8fde...");
|
|
236
|
+
voice.start();
|
|
237
|
+
}
|
|
238
|
+
}, 1000);
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
console.log("[myclaw-inject] \u2705 \u8baf\u98de\u8bed\u97f3 SDK \u5df2\u521d\u59cb\u5316");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function startVoice() {
|
|
247
|
+
if (!voice) {
|
|
248
|
+
initVoice();
|
|
249
|
+
if (!voice) return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// 记录当前 textarea 状态和光标位置
|
|
253
|
+
committedText = getTextareaValue();
|
|
254
|
+
cursorOffset = getCursorPosition();
|
|
255
|
+
pendingText = "";
|
|
256
|
+
|
|
257
|
+
recording = true;
|
|
258
|
+
updateButtonUI();
|
|
259
|
+
voice.start();
|
|
260
|
+
|
|
261
|
+
console.log("[myclaw-voice] \u5f00\u59cb\u5f55\u97f3\uff0c\u5149\u6807\u4f4d\u7f6e:", cursorOffset);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function stopVoice() {
|
|
265
|
+
recording = false;
|
|
266
|
+
updateButtonUI();
|
|
267
|
+
|
|
268
|
+
if (voice) {
|
|
269
|
+
voice.stop();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// 最终提交文字
|
|
273
|
+
committedText = getTextareaValue();
|
|
274
|
+
pendingText = "";
|
|
275
|
+
|
|
276
|
+
console.log("[myclaw-voice] \u505c\u6b62\u5f55\u97f3");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ═══ 5. DOM 注入 ═══
|
|
280
|
+
|
|
281
|
+
function injectButton() {
|
|
282
|
+
var toolbar = document.querySelector(".agent-chat__toolbar-left");
|
|
283
|
+
if (!toolbar || document.querySelector("#myclaw-voice-btn")) return;
|
|
284
|
+
|
|
285
|
+
var btn = createVoiceButton();
|
|
286
|
+
|
|
287
|
+
// 插入到 token 计数之前,或追加到末尾
|
|
288
|
+
var tokenCount = toolbar.querySelector(".agent-chat__token-count");
|
|
289
|
+
if (tokenCount) {
|
|
290
|
+
toolbar.insertBefore(btn, tokenCount);
|
|
291
|
+
} else {
|
|
292
|
+
toolbar.appendChild(btn);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
injected = true;
|
|
296
|
+
console.log("[myclaw-inject] \u2705 \u8bed\u97f3\u6309\u94ae\u5df2\u6ce8\u5165");
|
|
57
297
|
}
|
|
58
298
|
|
|
59
299
|
// ═══ 启动 ═══
|
|
60
300
|
function init() {
|
|
61
301
|
createVersionBar();
|
|
302
|
+
|
|
303
|
+
// 初始化 VoiceInput SDK
|
|
62
304
|
initVoice();
|
|
305
|
+
|
|
306
|
+
// 持续监听 DOM 变化,确保按钮始终在
|
|
307
|
+
new MutationObserver(function () {
|
|
308
|
+
if (!document.querySelector("#myclaw-voice-btn")) {
|
|
309
|
+
injected = false;
|
|
310
|
+
}
|
|
311
|
+
if (!injected) {
|
|
312
|
+
injectButton();
|
|
313
|
+
}
|
|
314
|
+
}).observe(document.documentElement, { childList: true, subtree: true });
|
|
315
|
+
|
|
316
|
+
// 尝试立即注入
|
|
317
|
+
injectButton();
|
|
63
318
|
}
|
|
64
319
|
|
|
65
|
-
// 确保 DOM 就绪
|
|
66
320
|
if (document.readyState === "loading") {
|
|
67
321
|
document.addEventListener("DOMContentLoaded", init);
|
|
68
322
|
} 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
|
|
399
|
-
console.log(' myclaw status
|
|
400
|
-
console.log(' myclaw new helper
|
|
401
|
-
console.log(' myclaw
|
|
402
|
-
console.log(' myclaw
|
|
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
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.
|
|
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
|
|
154
|
+
INJECT_MARKER + '[\\s\\S]*?(?=</body>)',
|
|
143
155
|
'g'
|
|
144
156
|
);
|
|
145
157
|
html = html.replace(markerRegex, '');
|
|
146
158
|
|
|
147
|
-
// 在 </body>
|
|
148
|
-
const injection =
|
|
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);
|