@aiyiran/myclaw 1.0.101 → 1.0.102

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.
@@ -2,15 +2,18 @@
2
2
  * ============================================================================
3
3
  * MyClaw UI Inject — 浏览器端注入脚本
4
4
  * ============================================================================
5
- *
5
+ *
6
6
  * 功能:
7
7
  * 1. 页面顶部 fixed 显示 myclaw 版本号
8
8
  * 2. 在聊天工具栏新增讯飞语音输入按钮
9
9
  * - 光标处插入文字
10
10
  * - 持续录入,手动停止
11
11
  * - 讯飞 60 秒断开后自动重连
12
- *
13
- * 依赖:voice-input.js(讯飞 VoiceInput SDK,需先于本脚本加载)
12
+ * 3. 拦截 chat-tts-btn 按钮,点击后使用讯飞 TTS 播放消息
13
+ *
14
+ * 依赖:
15
+ * - voice-input.js(讯飞 VoiceInput SDK)
16
+ * - voice-output.js(讯飞 VoiceOutput SDK)
14
17
  * ============================================================================
15
18
  */
16
19
  (function () {
@@ -20,11 +23,13 @@
20
23
 
21
24
  // ═══ 状态 ═══
22
25
  var voice = null; // VoiceInput 实例
26
+ var tts = null; // VoiceOutput 实例
23
27
  var recording = false; // 用户层面的录音状态(独立于 SDK 的 status)
24
28
  var pendingText = ""; // 当前这轮识别的文字(实时更新)
25
29
  var committedText = ""; // 已经提交到 textarea 的文字(上一轮累积)
26
30
  var cursorOffset = 0; // 录音开始时光标在 textarea 中的位置
27
31
  var injected = false;
32
+ var ttsBtnHooked = false; // TTS 按钮是否已绑定
28
33
 
29
34
  // ═══ 1. 右下角版本标签(点击测试麦克风) ═══
30
35
  function createVersionBar() {
@@ -396,6 +401,92 @@
396
401
  console.log("[myclaw-inject] ✅ 发送按钮拦截器已挂载");
397
402
  }
398
403
 
404
+ // ═══ 7. TTS 按钮绑定(讯飞语音合成) ═══
405
+
406
+ function initTts() {
407
+ if (typeof window.VoiceOutput === "undefined") {
408
+ console.error("[myclaw-tts] VoiceOutput SDK 未加载");
409
+ return;
410
+ }
411
+ if (tts) return;
412
+
413
+ tts = new window.VoiceOutput({
414
+ APPID: 'de6ec59e',
415
+ APISecret: 'ZTA3ZDU3YjAyNDUyZGVkNjQwMGM5MWNi',
416
+ APIKey: '37104a0463a5460d7571869324b667d5',
417
+ vcn: 'aisbabyxu',
418
+ onAudio: function (audioBuffer) {
419
+ console.log('[myclaw-tts] audio chunk:', audioBuffer.byteLength, 'bytes');
420
+ },
421
+ onStatusChange: function (oldStatus, newStatus) {
422
+ console.log('[myclaw-tts] status:', oldStatus, '->', newStatus);
423
+ },
424
+ onError: function (err) {
425
+ console.error('[myclaw-tts] error:', err);
426
+ }
427
+ });
428
+
429
+ console.log("[myclaw-tts] ✅ 讯飞 TTS SDK 已初始化");
430
+ }
431
+
432
+ /**
433
+ * 从 .chat-tts-btn 按钮获取对应的消息文字
434
+ * 按钮通常在消息气泡下方,需要向上查找
435
+ */
436
+ function extractMessageText(ttsBtn) {
437
+ // 尝试找到包含此按钮的消息气泡
438
+ var bubble = ttsBtn.closest('.chat-message__bubble');
439
+ if (!bubble) {
440
+ // 尝试上一级元素的兄弟元素
441
+ var parent = ttsBtn.parentElement;
442
+ if (parent) {
443
+ var prevSibling = parent.previousElementSibling;
444
+ if (prevSibling) {
445
+ bubble = prevSibling.querySelector('.chat-message__bubble');
446
+ }
447
+ }
448
+ }
449
+
450
+ if (bubble) {
451
+ // 获取气泡内的文字,移除多余空白
452
+ var text = bubble.textContent || '';
453
+ return text.replace(/\s+/g, ' ').trim();
454
+ }
455
+
456
+ return '';
457
+ }
458
+
459
+ function hookTtsButton() {
460
+ if (ttsBtnHooked) return;
461
+ ttsBtnHooked = true;
462
+
463
+ // 使用捕获阶段拦截点击
464
+ document.addEventListener('click', function (e) {
465
+ var btn = e.target.closest('.chat-tts-btn');
466
+ if (!btn) return;
467
+
468
+ e.preventDefault();
469
+ e.stopPropagation();
470
+
471
+ var text = extractMessageText(btn);
472
+ if (!text) {
473
+ console.warn('[myclaw-tts] 未找到消息文字');
474
+ return;
475
+ }
476
+
477
+ console.log('[myclaw-tts] 播放:', text.substring(0, 50) + (text.length > 50 ? '...' : ''));
478
+
479
+ if (!tts) {
480
+ initTts();
481
+ }
482
+ if (tts) {
483
+ tts.speak(text);
484
+ }
485
+ }, true);
486
+
487
+ console.log("[myclaw-inject] ✅ TTS 按钮拦截器已挂载");
488
+ }
489
+
399
490
  function fallbackCopy(text) {
400
491
  var ta = document.createElement("textarea");
401
492
  ta.value = text;
@@ -415,6 +506,9 @@
415
506
  // 初始化 VoiceInput SDK
416
507
  initVoice();
417
508
 
509
+ // 初始化 TTS SDK
510
+ initTts();
511
+
418
512
  // 持续监听 DOM 变化,确保按钮始终在
419
513
  new MutationObserver(function () {
420
514
  if (!document.querySelector("#myclaw-voice-btn")) {
@@ -430,6 +524,9 @@
430
524
 
431
525
  // 挂载发送拦截
432
526
  hookSendButton();
527
+
528
+ // 挂载 TTS 按钮拦截
529
+ hookTtsButton();
433
530
  }
434
531
 
435
532
  if (document.readyState === "loading") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiyiran/myclaw",
3
- "version": "1.0.101",
3
+ "version": "1.0.102",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "_doc": "MyClaw 注入清单 (auto-generated)。strategy: auto | on | off",
3
- "_generated": "2026-04-01T17:04:53.758Z",
3
+ "_generated": "2026-04-01T18:02:28.628Z",
4
4
  "agents": [
5
5
  {
6
6
  "id": "danci",
@@ -0,0 +1,121 @@
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: 120px; padding: 12px; font-size: 15px;
14
+ border: 1px solid #ddd; border-radius: 8px; resize: vertical;
15
+ line-height: 1.6;
16
+ }
17
+ .controls { margin-top: 12px; display: flex; gap: 10px; flex-wrap: wrap; }
18
+ .btn {
19
+ padding: 12px 24px; font-size: 15px;
20
+ border: none; border-radius: 8px; cursor: pointer;
21
+ color: white; transition: all 0.2s;
22
+ }
23
+ .btn-speak { background: #28a745; }
24
+ .btn-speak:hover { background: #218838; }
25
+ .btn-stop { background: #dc3545; }
26
+ .btn-stop:hover { background: #c82333; }
27
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
28
+ .status { margin-top: 10px; font-size: 13px; color: #888; }
29
+ .voice-select { margin-top: 12px; }
30
+ .voice-select label { font-size: 13px; color: #666; margin-right: 8px; }
31
+ .voice-select select { padding: 6px 10px; font-size: 14px; border-radius: 6px; border: 1px solid #ddd; }
32
+ </style>
33
+ </head>
34
+ <body>
35
+ <div class="container">
36
+ <h1>🔊 讯飞语音合成演示</h1>
37
+ <textarea id="text" placeholder="输入要合成的文字...">你好世界,这是语音合成测试</textarea>
38
+
39
+ <div class="voice-select">
40
+ <label>音色:</label>
41
+ <select id="vcn">
42
+ <option value="x4_xiaoyan">小燕 - 普通话女声</option>
43
+ <option value="x4_yezi">小露 - 普通话女声</option>
44
+ <option value="aisjiuxu">许久 - 普通话男声</option>
45
+ <option value="aisjinger">小婧 - 普通话女声</option>
46
+ <option value="aisbabyxu">许小宝 - 童声</option>
47
+ </select>
48
+ </div>
49
+
50
+ <div class="controls">
51
+ <button class="btn btn-speak" id="speakBtn">🔊 开始合成</button>
52
+ <button class="btn btn-stop" id="stopBtn" disabled>⏹ 停止</button>
53
+ </div>
54
+
55
+ <div class="status" id="status">就绪</div>
56
+ </div>
57
+
58
+ <script src="voice-output.js"></script>
59
+ <script>
60
+ var textEl = document.getElementById('text');
61
+ var vcnEl = document.getElementById('vcn');
62
+ var speakBtn = document.getElementById('speakBtn');
63
+ var stopBtn = document.getElementById('stopBtn');
64
+ var statusEl = document.getElementById('status');
65
+
66
+ var tts = null;
67
+
68
+ function updateStatus(text) {
69
+ statusEl.textContent = text;
70
+ }
71
+
72
+ speakBtn.onclick = function () {
73
+ var text = textEl.value.trim();
74
+ if (!text) {
75
+ alert('请输入要合成的文字');
76
+ return;
77
+ }
78
+
79
+ if (!tts) {
80
+ // 使用 OpenClaw 配置的讯飞账号
81
+ tts = new VoiceOutput({
82
+ APPID: 'de6ec59e',
83
+ APISecret: 'ZTA3ZDU3YjAyNDUyZGVkNjQwMGM5MWNi',
84
+ APIKey: '37104a0463a5460d7571869324b667d5',
85
+ vcn: vcnEl.value,
86
+ onAudio: function (audioBuffer) {
87
+ console.log('[demo] received audio chunk:', audioBuffer.byteLength, 'bytes');
88
+ },
89
+ onStatusChange: function (oldStatus, newStatus) {
90
+ console.log('[tts] status:', oldStatus, '->', newStatus);
91
+ var labels = {
92
+ idle: '就绪',
93
+ connecting: '连接中...',
94
+ synthesizing: '🔊 合成中...',
95
+ stopping: '停止中...',
96
+ stopped: '已停止'
97
+ };
98
+ statusEl.textContent = labels[newStatus] || newStatus;
99
+
100
+ speakBtn.disabled = (newStatus !== 'idle');
101
+ stopBtn.disabled = (newStatus === 'idle' || newStatus === 'stopping');
102
+ },
103
+ onError: function (err) {
104
+ console.error('[tts] error:', err);
105
+ statusEl.textContent = '❌ ' + err;
106
+ }
107
+ });
108
+ }
109
+
110
+ tts.vcn = vcnEl.value;
111
+ tts.speak(text);
112
+ };
113
+
114
+ stopBtn.onclick = function () {
115
+ if (tts) {
116
+ tts.stop();
117
+ }
118
+ };
119
+ </script>
120
+ </body>
121
+ </html>
@@ -0,0 +1,299 @@
1
+ /**
2
+ * VoiceOutput - 独立语音合成 SDK
3
+ * 基于讯飞语音合成 WebAPI(流式版)
4
+ *
5
+ * 零依赖,纯原生 JS,任何静态 HTML 页面都能用
6
+ *
7
+ * 用法:
8
+ * const tts = new VoiceOutput({
9
+ * onAudio: (audioData) => { ... }
10
+ * });
11
+ * tts.speak("你好世界"); // 开始合成
12
+ * tts.stop(); // 停止
13
+ */
14
+ (function (global) {
15
+ 'use strict';
16
+
17
+ // ============ 默认配置 ============
18
+ var DEFAULT_CONFIG = {
19
+ APPID: '',
20
+ APISecret: '',
21
+ APIKey: '',
22
+ url: 'wss://tts-api.xfyun.cn/v2/tts',
23
+ host: 'tts-api.xfyun.cn',
24
+ vcn: 'x4_xiaoyan', // 默认语音
25
+ speed: 50, // 语速 0-100
26
+ pitch: 50, // 音调 0-100
27
+ volume: 50, // 音量 0-100
28
+ aue: 'lame', // 音频格式: lame=mp3, raw=pcm
29
+ };
30
+
31
+ // ============ 工具函数 ============
32
+
33
+ function hmacSHA256(key, message) {
34
+ var enc = new TextEncoder();
35
+ return crypto.subtle.importKey(
36
+ 'raw', enc.encode(key), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
37
+ ).then(function (cryptoKey) {
38
+ return crypto.subtle.sign('HMAC', cryptoKey, enc.encode(message));
39
+ }).then(function (sig) {
40
+ return arrayBufferToBase64(sig);
41
+ });
42
+ }
43
+
44
+ function arrayBufferToBase64(buffer) {
45
+ var binary = '';
46
+ var bytes = new Uint8Array(buffer);
47
+ for (var i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
48
+ return btoa(binary);
49
+ }
50
+
51
+ function base64ToArrayBuffer(base64) {
52
+ var binaryStr = atob(base64);
53
+ var bytes = new Uint8Array(binaryStr.length);
54
+ for (var i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i);
55
+ return bytes.buffer;
56
+ }
57
+
58
+ // ============ VoiceOutput 类 ============
59
+
60
+ function VoiceOutput(opts) {
61
+ opts = opts || {};
62
+
63
+ this.APPID = opts.APPID || DEFAULT_CONFIG.APPID;
64
+ this.APISecret = opts.APISecret || DEFAULT_CONFIG.APISecret;
65
+ this.APIKey = opts.APIKey || DEFAULT_CONFIG.APIKey;
66
+ this.url = opts.url || DEFAULT_CONFIG.url;
67
+ this.host = opts.host || DEFAULT_CONFIG.host;
68
+ this.vcn = opts.vcn || DEFAULT_CONFIG.vcn;
69
+ this.speed = opts.speed || DEFAULT_CONFIG.speed;
70
+ this.pitch = opts.pitch || DEFAULT_CONFIG.pitch;
71
+ this.volume = opts.volume || DEFAULT_CONFIG.volume;
72
+ this.aue = opts.aue || DEFAULT_CONFIG.aue;
73
+
74
+ // 回调
75
+ this.onAudio = opts.onAudio || function (audioBuffer) {}; // 音频数据回调
76
+ this.onStatusChange = opts.onStatusChange || function () {};
77
+ this.onError = opts.onError || function () {};
78
+
79
+ // 内部状态
80
+ this.status = 'idle'; // idle | connecting | synthesizing | stopping | stopped
81
+ this.webSocket = null;
82
+ this.audioContext = null;
83
+ this.audioChunks = [];
84
+ this.audioSource = null;
85
+ this.onAudioEnd = null;
86
+ }
87
+
88
+ VoiceOutput.prototype._getWebSocketUrl = function () {
89
+ var self = this;
90
+ var date = new Date().toUTCString();
91
+ var signatureOrigin = 'host: ' + self.host + '\ndate: ' + date + '\nGET /v2/tts HTTP/1.1';
92
+
93
+ return hmacSHA256(self.APISecret, signatureOrigin).then(function (signature) {
94
+ var authOrigin = 'api_key="' + self.APIKey + '", algorithm="hmac-sha256", headers="host date request-line", signature="' + signature + '"';
95
+ var authorization = btoa(authOrigin);
96
+ return self.url + '?authorization=' + authorization + '&date=' + encodeURIComponent(date) + '&host=' + self.host;
97
+ });
98
+ };
99
+
100
+ VoiceOutput.prototype._initAudioContext = function () {
101
+ if (!this.audioContext) {
102
+ this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
103
+ }
104
+ return this.audioContext;
105
+ };
106
+
107
+ VoiceOutput.prototype._playAudio = function (audioBuffer) {
108
+ var self = this;
109
+
110
+ // 创建 Blob URL 用于 <audio> 元素播放
111
+ var mimeType = this.aue === 'lame' ? 'audio/mpeg' : 'audio/wav';
112
+ var blob = new Blob([audioBuffer], { type: mimeType });
113
+ var blobUrl = URL.createObjectURL(blob);
114
+
115
+ // 停止之前的播放
116
+ if (this.currentAudio) {
117
+ this.currentAudio.pause();
118
+ URL.revokeObjectURL(this.currentAudioSrc);
119
+ }
120
+
121
+ // 创建 audio 元素播放
122
+ var audio = new Audio(blobUrl);
123
+ this.currentAudio = audio;
124
+ this.currentAudioSrc = blobUrl;
125
+
126
+ audio.play().then(function () {
127
+ console.log('[tts] playing...');
128
+ }).catch(function (err) {
129
+ console.error('[tts] play error:', err);
130
+ self.onError('播放失败: ' + err.message);
131
+ });
132
+
133
+ audio.onended = function () {
134
+ URL.revokeObjectURL(blobUrl);
135
+ if (self.onAudioEnd) {
136
+ self.onAudioEnd();
137
+ }
138
+ };
139
+ };
140
+
141
+ VoiceOutput.prototype._playBuffer = function (audioBuffer) {
142
+ var source = this.audioContext.createBufferSource();
143
+ source.buffer = audioBuffer;
144
+ source.connect(this.audioContext.destination);
145
+ source.start(0);
146
+ this.audioSource = source;
147
+
148
+ source.onended = function () {
149
+ if (self.onAudioEnd) {
150
+ self.onAudioEnd();
151
+ }
152
+ };
153
+ };
154
+
155
+ VoiceOutput.prototype.speak = function (text, opts) {
156
+ if (this.status === 'synthesizing') {
157
+ console.warn('[tts] already synthesizing');
158
+ return;
159
+ }
160
+
161
+ opts = opts || {};
162
+ this.audioChunks = [];
163
+ this._setStatus('connecting');
164
+
165
+ var self = this;
166
+ this._getWebSocketUrl().then(function (url) {
167
+ var ws = new WebSocket(url);
168
+ self.webSocket = ws;
169
+
170
+ var timeout = setTimeout(function () {
171
+ if (ws.readyState === WebSocket.CONNECTING) {
172
+ ws.close();
173
+ self.onError('WebSocket 连接超时');
174
+ self._setStatus('idle');
175
+ }
176
+ }, 5000);
177
+
178
+ ws.onopen = function () {
179
+ clearTimeout(timeout);
180
+ self._setStatus('synthesizing');
181
+
182
+ // 构造 TTS 请求
183
+ var textBase64 = btoa(unescape(encodeURIComponent(text)));
184
+
185
+ var payload = {
186
+ common: { app_id: self.APPID },
187
+ business: {
188
+ aue: self.aue,
189
+ auf: 'audio/L16;rate=16000',
190
+ vcn: opts.vcn || self.vcn,
191
+ speed: opts.speed || self.speed,
192
+ pitch: opts.pitch || self.pitch,
193
+ volume: opts.volume || self.volume,
194
+ tte: 'UTF8',
195
+ },
196
+ data: {
197
+ status: 2,
198
+ text: textBase64,
199
+ },
200
+ };
201
+
202
+ ws.send(JSON.stringify(payload));
203
+ console.log('[tts] sent text:', text.substring(0, 50) + (text.length > 50 ? '...' : ''));
204
+ };
205
+
206
+ ws.onmessage = function (event) {
207
+ try {
208
+ var msg = JSON.parse(event.data);
209
+
210
+ if (msg.code !== 0) {
211
+ self.onError('TTS error ' + msg.code + ': ' + msg.message);
212
+ self._cleanup();
213
+ return;
214
+ }
215
+
216
+ // 音频数据
217
+ if (msg.data && msg.data.audio) {
218
+ var audioData = base64ToArrayBuffer(msg.data.audio);
219
+ self.audioChunks.push(audioData);
220
+ self.onAudio(audioData);
221
+
222
+ // 全部接收完成后播放
223
+ if (msg.data.status === 2) {
224
+ console.log('[tts] synthesis complete, chunks:', self.audioChunks.length);
225
+ // 合并所有 chunks
226
+ var totalLen = 0;
227
+ for (var i = 0; i < self.audioChunks.length; i++) {
228
+ totalLen += self.audioChunks[i].byteLength;
229
+ }
230
+ var merged = new Uint8Array(totalLen);
231
+ var offset = 0;
232
+ for (var j = 0; j < self.audioChunks.length; j++) {
233
+ merged.set(new Uint8Array(self.audioChunks[j]), offset);
234
+ offset += self.audioChunks[j].byteLength;
235
+ }
236
+ self._playAudio(merged.buffer);
237
+ self._setStatus('idle');
238
+ }
239
+ }
240
+ } catch (err) {
241
+ console.error('[tts] parse error:', err);
242
+ }
243
+ };
244
+
245
+ ws.onerror = function (err) {
246
+ console.error('[tts] WebSocket error:', err);
247
+ self.onError('WebSocket 连接错误');
248
+ self._cleanup();
249
+ };
250
+
251
+ ws.onclose = function () {
252
+ clearTimeout(timeout);
253
+ if (self.status !== 'idle') {
254
+ self._setStatus('idle');
255
+ }
256
+ };
257
+ }).catch(function (err) {
258
+ self.onError('签名失败: ' + err);
259
+ self._setStatus('idle');
260
+ });
261
+ };
262
+
263
+ VoiceOutput.prototype.stop = function () {
264
+ this._setStatus('stopping');
265
+ this._cleanup();
266
+ this._setStatus('idle');
267
+ };
268
+
269
+ VoiceOutput.prototype._cleanup = function () {
270
+ if (this.webSocket) {
271
+ this.webSocket.close();
272
+ this.webSocket = null;
273
+ }
274
+ if (this.audioSource) {
275
+ try { this.audioSource.stop(); } catch (e) {}
276
+ this.audioSource = null;
277
+ }
278
+ };
279
+
280
+ VoiceOutput.prototype._setStatus = function (status) {
281
+ if (this.status !== status) {
282
+ var old = this.status;
283
+ this.status = status;
284
+ this.onStatusChange(old, status);
285
+ }
286
+ };
287
+
288
+ VoiceOutput.prototype.destroy = function () {
289
+ this.stop();
290
+ if (this.audioContext) {
291
+ this.audioContext.close();
292
+ this.audioContext = null;
293
+ }
294
+ };
295
+
296
+ // 暴露到全局
297
+ global.VoiceOutput = VoiceOutput;
298
+
299
+ })(typeof window !== 'undefined' ? window : this);