@agentunion/kite 1.0.7 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/README.md +48 -0
  3. package/cli.js +1 -1
  4. package/extensions/agents/__init__.py +1 -0
  5. package/extensions/agents/assistant/__init__.py +1 -0
  6. package/extensions/agents/assistant/entry.py +329 -0
  7. package/extensions/agents/assistant/module.md +22 -0
  8. package/extensions/agents/assistant/server.py +197 -0
  9. package/extensions/channels/__init__.py +1 -0
  10. package/extensions/channels/acp_channel/__init__.py +1 -0
  11. package/extensions/channels/acp_channel/entry.py +329 -0
  12. package/extensions/channels/acp_channel/module.md +22 -0
  13. package/extensions/channels/acp_channel/server.py +197 -0
  14. package/extensions/event_hub_bench/entry.py +624 -379
  15. package/extensions/event_hub_bench/module.md +2 -1
  16. package/extensions/services/backup/__init__.py +1 -0
  17. package/extensions/services/backup/entry.py +508 -0
  18. package/extensions/services/backup/module.md +22 -0
  19. package/extensions/services/model_service/__init__.py +1 -0
  20. package/extensions/services/model_service/entry.py +508 -0
  21. package/extensions/services/model_service/module.md +22 -0
  22. package/extensions/services/watchdog/entry.py +468 -102
  23. package/extensions/services/watchdog/module.md +3 -0
  24. package/extensions/services/watchdog/monitor.py +170 -69
  25. package/extensions/services/web/__init__.py +1 -0
  26. package/extensions/services/web/config.yaml +149 -0
  27. package/extensions/services/web/entry.py +390 -0
  28. package/extensions/services/web/module.md +24 -0
  29. package/extensions/services/web/routes/__init__.py +1 -0
  30. package/extensions/services/web/routes/routes_call.py +189 -0
  31. package/extensions/services/web/routes/routes_config.py +512 -0
  32. package/extensions/services/web/routes/routes_contacts.py +98 -0
  33. package/extensions/services/web/routes/routes_devlog.py +99 -0
  34. package/extensions/services/web/routes/routes_phone.py +81 -0
  35. package/extensions/services/web/routes/routes_sms.py +48 -0
  36. package/extensions/services/web/routes/routes_stats.py +17 -0
  37. package/extensions/services/web/routes/routes_voicechat.py +554 -0
  38. package/extensions/services/web/routes/schemas.py +216 -0
  39. package/extensions/services/web/server.py +375 -0
  40. package/extensions/services/web/static/css/style.css +1064 -0
  41. package/extensions/services/web/static/index.html +1445 -0
  42. package/extensions/services/web/static/js/app.js +4671 -0
  43. package/extensions/services/web/vendor/__init__.py +1 -0
  44. package/extensions/services/web/vendor/bluetooth/audio.py +348 -0
  45. package/extensions/services/web/vendor/bluetooth/contacts.py +251 -0
  46. package/extensions/services/web/vendor/bluetooth/manager.py +395 -0
  47. package/extensions/services/web/vendor/bluetooth/sms.py +290 -0
  48. package/extensions/services/web/vendor/bluetooth/telephony.py +274 -0
  49. package/extensions/services/web/vendor/config.py +139 -0
  50. package/extensions/services/web/vendor/conversation/asr.py +936 -0
  51. package/extensions/services/web/vendor/conversation/engine.py +548 -0
  52. package/extensions/services/web/vendor/conversation/llm.py +534 -0
  53. package/extensions/services/web/vendor/conversation/mcp_tools.py +190 -0
  54. package/extensions/services/web/vendor/conversation/tts.py +322 -0
  55. package/extensions/services/web/vendor/conversation/vad.py +138 -0
  56. package/extensions/services/web/vendor/storage/__init__.py +1 -0
  57. package/extensions/services/web/vendor/storage/identity.py +312 -0
  58. package/extensions/services/web/vendor/storage/store.py +507 -0
  59. package/extensions/services/web/vendor/task/manager.py +864 -0
  60. package/extensions/services/web/vendor/task/models.py +45 -0
  61. package/extensions/services/web/vendor/task/webhook.py +263 -0
  62. package/extensions/services/web/vendor/tools/registry.py +321 -0
  63. package/kernel/__init__.py +0 -0
  64. package/kernel/entry.py +407 -0
  65. package/{core/event_hub/hub.py → kernel/event_hub.py} +62 -74
  66. package/kernel/module.md +33 -0
  67. package/{core/registry/store.py → kernel/registry_store.py} +23 -8
  68. package/kernel/rpc_router.py +388 -0
  69. package/kernel/server.py +267 -0
  70. package/launcher/__init__.py +10 -0
  71. package/launcher/__main__.py +6 -0
  72. package/launcher/count_lines.py +258 -0
  73. package/launcher/entry.py +1778 -0
  74. package/launcher/logging_setup.py +289 -0
  75. package/{core/launcher → launcher}/module_scanner.py +11 -6
  76. package/launcher/process_manager.py +880 -0
  77. package/main.py +11 -210
  78. package/package.json +6 -9
  79. package/__init__.py +0 -1
  80. package/__main__.py +0 -15
  81. package/core/event_hub/BENCHMARK.md +0 -94
  82. package/core/event_hub/bench.py +0 -459
  83. package/core/event_hub/bench_extreme.py +0 -308
  84. package/core/event_hub/bench_perf.py +0 -350
  85. package/core/event_hub/entry.py +0 -157
  86. package/core/event_hub/module.md +0 -20
  87. package/core/event_hub/server.py +0 -206
  88. package/core/launcher/entry.py +0 -1158
  89. package/core/launcher/process_manager.py +0 -470
  90. package/core/registry/entry.py +0 -110
  91. package/core/registry/module.md +0 -30
  92. package/core/registry/server.py +0 -289
  93. package/extensions/services/watchdog/server.py +0 -167
  94. /package/{core → extensions/services/web/vendor/bluetooth}/__init__.py +0 -0
  95. /package/{core/event_hub → extensions/services/web/vendor/conversation}/__init__.py +0 -0
  96. /package/{core/launcher → extensions/services/web/vendor/task}/__init__.py +0 -0
  97. /package/{core/registry → extensions/services/web/vendor/tools}/__init__.py +0 -0
  98. /package/{core/event_hub → kernel}/dedup.py +0 -0
  99. /package/{core/event_hub → kernel}/router.py +0 -0
  100. /package/{core/launcher → launcher}/module.md +0 -0
@@ -0,0 +1,4671 @@
1
+ // ============================================================
2
+ // API Client
3
+ // ============================================================
4
+ const API = {
5
+ async get(url) {
6
+ const resp = await fetch(url);
7
+ if (!resp.ok) {
8
+ const err = await resp.json().catch(() => ({ detail: resp.statusText }));
9
+ throw new Error(err.detail || resp.statusText);
10
+ }
11
+ return resp.json();
12
+ },
13
+
14
+ async post(url, body) {
15
+ const resp = await fetch(url, {
16
+ method: 'POST',
17
+ headers: { 'Content-Type': 'application/json' },
18
+ body: body != null ? JSON.stringify(body) : undefined,
19
+ });
20
+ if (!resp.ok) {
21
+ const err = await resp.json().catch(() => ({ detail: resp.statusText }));
22
+ throw new Error(err.detail || resp.statusText);
23
+ }
24
+ return resp.json();
25
+ },
26
+
27
+ async put(url, body) {
28
+ const resp = await fetch(url, {
29
+ method: 'PUT',
30
+ headers: { 'Content-Type': 'application/json' },
31
+ body: JSON.stringify(body),
32
+ });
33
+ if (!resp.ok) {
34
+ const err = await resp.json().catch(() => ({ detail: resp.statusText }));
35
+ throw new Error(err.detail || resp.statusText);
36
+ }
37
+ return resp.json();
38
+ },
39
+
40
+ async del(url) {
41
+ const resp = await fetch(url, { method: 'DELETE' });
42
+ if (!resp.ok) {
43
+ const err = await resp.json().catch(() => ({ detail: resp.statusText }));
44
+ throw new Error(err.detail || resp.statusText);
45
+ }
46
+ return resp.json();
47
+ },
48
+ };
49
+
50
+ // ============================================================
51
+ // Toast Notifications
52
+ // ============================================================
53
+ let _toastContainer = null;
54
+
55
+ function _ensureToastContainer() {
56
+ if (!_toastContainer) {
57
+ _toastContainer = document.getElementById('toast-container');
58
+ }
59
+ if (!_toastContainer) {
60
+ _toastContainer = document.createElement('div');
61
+ _toastContainer.id = 'toast-container';
62
+ _toastContainer.style.cssText =
63
+ 'position:fixed;top:20px;right:20px;z-index:10000;display:flex;flex-direction:column;gap:8px;';
64
+ document.body.appendChild(_toastContainer);
65
+ }
66
+ return _toastContainer;
67
+ }
68
+
69
+ function showToast(message, type = 'info') {
70
+ const container = _ensureToastContainer();
71
+ const toast = document.createElement('div');
72
+ toast.className = `toast toast-${type}`;
73
+ toast.textContent = message;
74
+
75
+ const colors = { info: '#3b82f6', success: '#10b981', error: '#ef4444' };
76
+ toast.style.cssText =
77
+ `padding:12px 20px;border-radius:8px;color:#fff;font-size:14px;` +
78
+ `background:${colors[type] || colors.info};box-shadow:0 4px 12px rgba(0,0,0,.15);` +
79
+ `opacity:0;transform:translateX(40px);transition:all .3s ease;max-width:360px;word-break:break-word;`;
80
+
81
+ container.appendChild(toast);
82
+
83
+ // Animate in
84
+ requestAnimationFrame(() => {
85
+ toast.style.opacity = '1';
86
+ toast.style.transform = 'translateX(0)';
87
+ });
88
+
89
+ // Auto dismiss
90
+ setTimeout(() => {
91
+ toast.style.opacity = '0';
92
+ toast.style.transform = 'translateX(40px)';
93
+ setTimeout(() => toast.remove(), 300);
94
+ }, 3500);
95
+ }
96
+
97
+ // ============================================================
98
+ // Navigation / Router
99
+ // ============================================================
100
+ const pages = ['dashboard', 'calls', 'sms', 'contacts', 'config', 'bluetooth', 'voicechat', 'devlog'];
101
+ let currentPage = '';
102
+
103
+ function navigate(page) {
104
+ if (!pages.includes(page)) page = 'dashboard';
105
+
106
+ // Toggle .active class on page sections (CSS: .page { display:none } .page.active { display:block })
107
+ pages.forEach((p) => {
108
+ const el = document.getElementById(`page-${p}`);
109
+ if (el) {
110
+ el.style.display = ''; // clear any inline override
111
+ el.classList.toggle('active', p === page);
112
+ }
113
+ });
114
+
115
+ // Update nav active state
116
+ document.querySelectorAll('.nav-item').forEach((item) => {
117
+ item.classList.toggle('active', item.dataset.page === page);
118
+ });
119
+
120
+ currentPage = page;
121
+ localStorage.setItem('activePage', page);
122
+
123
+ // Load data for the page
124
+ switch (page) {
125
+ case 'dashboard': loadDashboard(); break;
126
+ case 'calls': loadCalls(); break;
127
+ case 'sms': loadSMS(); break;
128
+ case 'contacts': loadContacts(); break;
129
+ case 'config': loadConfig(); break;
130
+ case 'bluetooth': loadBluetooth(); break;
131
+ case 'voicechat': loadVoiceChatConfig(); break;
132
+ case 'devlog': loadDevLog(); break;
133
+ }
134
+ }
135
+
136
+ // ============================================================
137
+ // Dashboard Page
138
+ // ============================================================
139
+ async function loadDashboard() {
140
+ try {
141
+ const [stats, callsData] = await Promise.all([
142
+ API.get('/api/stats'),
143
+ API.get('/api/calls?page=1&page_size=5'),
144
+ ]);
145
+
146
+ // Stat cards
147
+ _setText('stat-total-calls', stats.total_calls);
148
+ _setText('stat-calls-today', stats.calls_today);
149
+ _setText('stat-avg-duration', formatDuration(stats.avg_duration_seconds || 0));
150
+ _setText('stat-total-contacts', stats.total_contacts);
151
+
152
+ // Calls by result breakdown
153
+ renderCallsByResult(stats.calls_by_result || {});
154
+
155
+ // Calls by direction breakdown
156
+ renderCallsByDirection(stats.calls_by_direction || {});
157
+
158
+ // Recent calls table
159
+ renderRecentCallsTable(callsData.items || []);
160
+ } catch (err) {
161
+ showToast('加载仪表盘失败: ' + err.message, 'error');
162
+ }
163
+ }
164
+
165
+ function renderCallsByResult(data) {
166
+ const container = document.getElementById('calls-by-result');
167
+ if (!container) return;
168
+
169
+ const labels = {
170
+ success: '成功', no_answer: '未接', busy: '忙碌',
171
+ rejected: '拒接', error: '错误', timeout: '超时', unknown: '未知',
172
+ };
173
+ const colors = {
174
+ success: '#10b981', no_answer: '#f59e0b', busy: '#8b5cf6',
175
+ rejected: '#ef4444', error: '#dc2626', timeout: '#6b7280', unknown: '#9ca3af',
176
+ };
177
+
178
+ const entries = Object.entries(data);
179
+ if (entries.length === 0) {
180
+ container.innerHTML = '<span class="text-muted">暂无数据</span>';
181
+ return;
182
+ }
183
+
184
+ const total = entries.reduce((s, [, v]) => s + v, 0);
185
+ container.innerHTML = entries
186
+ .map(([key, val]) => {
187
+ const pct = total > 0 ? Math.round((val / total) * 100) : 0;
188
+ const color = colors[key] || '#9ca3af';
189
+ const label = labels[key] || key;
190
+ return `<div class="result-bar">
191
+ <span class="result-label">${escapeHtml(label)}</span>
192
+ <div class="result-track"><div class="result-fill" style="width:${pct}%;background:${color}"></div></div>
193
+ <span class="result-value">${val} (${pct}%)</span>
194
+ </div>`;
195
+ })
196
+ .join('');
197
+ }
198
+
199
+ function renderCallsByDirection(data) {
200
+ const container = document.getElementById('calls-by-direction');
201
+ if (!container) return;
202
+
203
+ const labels = { outgoing: '拨出', incoming: '接入' };
204
+ const entries = Object.entries(data);
205
+ if (entries.length === 0) {
206
+ container.innerHTML = '<span class="text-muted">暂无数据</span>';
207
+ return;
208
+ }
209
+ container.innerHTML = entries
210
+ .map(([key, val]) => `<span class="badge badge-${key}">${labels[key] || key}: ${val}</span>`)
211
+ .join(' ');
212
+ }
213
+
214
+ function renderRecentCallsTable(items) {
215
+ const tbody = document.getElementById('recent-calls-body');
216
+ if (!tbody) return;
217
+
218
+ if (items.length === 0) {
219
+ tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted">暂无通话记录</td></tr>';
220
+ return;
221
+ }
222
+
223
+ tbody.innerHTML = items
224
+ .map(
225
+ (c) => `<tr>
226
+ <td>${escapeHtml(formatTime(c.started_at))}</td>
227
+ <td>${escapeHtml(c.contact_name || c.phone_number || '-')}</td>
228
+ <td><span class="badge badge-${c.direction || 'unknown'}">${c.direction === 'outgoing' ? '拨出' : '接入'}</span></td>
229
+ <td>${formatDuration(c.duration_seconds || 0)}</td>
230
+ <td><span class="badge badge-result-${c.result || 'unknown'}">${escapeHtml(resultLabel(c.result))}</span></td>
231
+ <td><button class="btn btn-sm btn-link" onclick="viewCallDetail('${escapeHtml(c.task_id)}')">详情</button></td>
232
+ </tr>`
233
+ )
234
+ .join('');
235
+ }
236
+
237
+ // ============================================================
238
+ // Calls Page
239
+ // ============================================================
240
+ let callsPage = 1;
241
+ const CALLS_PAGE_SIZE = 15;
242
+
243
+ async function loadCalls() {
244
+ const tbody = document.getElementById('calls-table-body');
245
+ if (tbody) tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted">加载中...</td></tr>';
246
+
247
+ try {
248
+ const data = await API.get(`/api/calls?page=${callsPage}&page_size=${CALLS_PAGE_SIZE}`);
249
+ const items = data.items || [];
250
+ const total = data.total || 0;
251
+
252
+ if (!tbody) return;
253
+
254
+ if (items.length === 0) {
255
+ tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted">暂无通话记录</td></tr>';
256
+ } else {
257
+ tbody.innerHTML = items
258
+ .map(
259
+ (c) => `<tr>
260
+ <td>${escapeHtml(formatTime(c.started_at))}</td>
261
+ <td>${escapeHtml(c.contact_name || '-')}</td>
262
+ <td>${escapeHtml(c.phone_number || '-')}</td>
263
+ <td><span class="badge badge-${c.direction || 'unknown'}">${c.direction === 'outgoing' ? '拨出' : '接入'}</span></td>
264
+ <td>${formatDuration(c.duration_seconds || 0)}</td>
265
+ <td><span class="badge badge-result-${c.result || 'unknown'}">${escapeHtml(resultLabel(c.result))}</span></td>
266
+ <td>
267
+ <button class="btn btn-sm btn-link" onclick="viewCallDetail('${escapeHtml(c.task_id)}')">详情</button>
268
+ ${c.status === 'active' ? `<button class="btn btn-sm btn-danger" onclick="hangupCall('${escapeHtml(c.task_id)}')">挂断</button>` : ''}
269
+ </td>
270
+ </tr>`
271
+ )
272
+ .join('');
273
+ }
274
+
275
+ renderPagination('calls-pagination', total, callsPage, CALLS_PAGE_SIZE, (p) => {
276
+ callsPage = p;
277
+ loadCalls();
278
+ });
279
+ } catch (err) {
280
+ showToast('加载通话记录失败: ' + err.message, 'error');
281
+ }
282
+ }
283
+
284
+ function showNewCallForm() {
285
+ openModal('new-call-modal');
286
+ }
287
+
288
+ async function submitNewCall() {
289
+ const phone = _val('call-phone');
290
+ const purpose = _val('call-purpose');
291
+ const systemPrompt = _val('call-system-prompt');
292
+ const playText = _val('call-play-text');
293
+ const maxDuration = parseInt(_val('call-max-duration')) || 300;
294
+ const language = _val('call-language') || 'zh';
295
+
296
+ if (!purpose) {
297
+ showToast('请填写通话目的', 'error');
298
+ return;
299
+ }
300
+
301
+ const body = { purpose, max_duration_seconds: maxDuration, language };
302
+ if (phone) body.phone_number = phone;
303
+ if (systemPrompt) body.system_prompt = systemPrompt;
304
+ if (playText) body.play_text = playText;
305
+
306
+ try {
307
+ const res = await API.post('/api/call', body);
308
+ showToast(`通话任务已创建: ${res.task_id}`, 'success');
309
+ closeModal('new-call-modal');
310
+ _clearForm('new-call-form');
311
+ callsPage = 1;
312
+ loadCalls();
313
+ } catch (err) {
314
+ showToast('创建通话失败: ' + err.message, 'error');
315
+ }
316
+ }
317
+
318
+ async function viewCallDetail(taskId) {
319
+ // Show the detail view
320
+ const listView = document.getElementById('calls-list-view');
321
+ const detailView = document.getElementById('calls-detail-view');
322
+ if (listView) listView.style.display = 'none';
323
+ if (detailView) detailView.style.display = '';
324
+
325
+ // Load call info, transcript, and summary in parallel
326
+ try {
327
+ const [callInfo, transcriptData, summaryData] = await Promise.allSettled([
328
+ API.get(`/api/call/${taskId}`),
329
+ API.get(`/api/calls/${taskId}/transcript`),
330
+ API.get(`/api/calls/${taskId}/summary`),
331
+ ]);
332
+
333
+ const info = callInfo.status === 'fulfilled' ? callInfo.value : {};
334
+ const transcript = transcriptData.status === 'fulfilled' ? (transcriptData.value.transcript || []) : [];
335
+ const summary = summaryData.status === 'fulfilled' ? (summaryData.value.summary || '') : '';
336
+
337
+ // Header info
338
+ _setText('detail-task-id', info.task_id || taskId);
339
+ _setText('detail-phone', info.phone_number || '-');
340
+ _setText('detail-contact', info.contact_name || '-');
341
+ _setText('detail-direction', info.direction === 'outgoing' ? '拨出' : '接入');
342
+ _setText('detail-status', statusLabel(info.status));
343
+ _setText('detail-result', resultLabel(info.result));
344
+ _setText('detail-duration', formatDuration(info.duration_seconds || 0));
345
+ _setText('detail-started', formatTime(info.started_at));
346
+ _setText('detail-ended', formatTime(info.ended_at));
347
+
348
+ // Hangup button visibility
349
+ const hangupBtn = document.getElementById('detail-hangup-btn');
350
+ if (hangupBtn) {
351
+ hangupBtn.style.display = (info.status === 'active' || info.status === 'dialing') ? '' : 'none';
352
+ hangupBtn.onclick = () => hangupCall(taskId);
353
+ }
354
+
355
+ // Transcript (chat-style)
356
+ renderTranscript(transcript);
357
+
358
+ // Summary
359
+ const summaryEl = document.getElementById('detail-summary');
360
+ if (summaryEl) {
361
+ summaryEl.textContent = summary || '暂无摘要';
362
+ }
363
+
364
+ // Audio player
365
+ const audioEl = document.getElementById('detail-audio');
366
+ if (audioEl) {
367
+ if (info.has_recording) {
368
+ audioEl.src = `/api/calls/${taskId}/recording`;
369
+ audioEl.style.display = '';
370
+ } else {
371
+ audioEl.style.display = 'none';
372
+ audioEl.src = '';
373
+ }
374
+ }
375
+ } catch (err) {
376
+ showToast('加载通话详情失败: ' + err.message, 'error');
377
+ }
378
+ }
379
+
380
+ function renderTranscript(transcript) {
381
+ const container = document.getElementById('detail-transcript');
382
+ if (!container) return;
383
+
384
+ if (!transcript || transcript.length === 0) {
385
+ container.innerHTML = '<div class="text-muted text-center" style="padding:20px">暂无对话记录</div>';
386
+ return;
387
+ }
388
+
389
+ container.innerHTML = transcript
390
+ .map((entry) => {
391
+ const role = entry.role || 'system';
392
+ const elapsed = entry.elapsed != null ? formatDuration(entry.elapsed) : '';
393
+
394
+ if (role === 'ai') {
395
+ return `<div class="chat-bubble chat-ai">
396
+ <div class="chat-role">AI <span class="chat-time">${escapeHtml(elapsed)}</span></div>
397
+ <div class="chat-text">${escapeHtml(entry.text || '')}</div>
398
+ </div>`;
399
+ } else if (role === 'human') {
400
+ return `<div class="chat-bubble chat-human">
401
+ <div class="chat-role">对方 <span class="chat-time">${escapeHtml(elapsed)}</span></div>
402
+ <div class="chat-text">${escapeHtml(entry.text || '')}</div>
403
+ </div>`;
404
+ } else if (role === 'mcp_request') {
405
+ const tool = entry.tool || '';
406
+ const args = entry.args ? JSON.stringify(entry.args, null, 2) : '';
407
+ return `<div class="chat-bubble chat-mcp-request">
408
+ <div class="chat-role">MCP 工具调用 <span class="chat-time">${escapeHtml(elapsed)}</span></div>
409
+ <div class="chat-tool">${escapeHtml(tool)}</div>
410
+ <pre class="chat-args">${escapeHtml(args)}</pre>
411
+ </div>`;
412
+ } else if (role === 'mcp_response') {
413
+ const result = entry.result ? JSON.stringify(entry.result, null, 2) : '';
414
+ return `<div class="chat-bubble chat-mcp-response">
415
+ <div class="chat-role">MCP 响应 <span class="chat-time">${escapeHtml(elapsed)}</span></div>
416
+ <pre class="chat-args">${escapeHtml(result)}</pre>
417
+ </div>`;
418
+ } else {
419
+ // system or unknown
420
+ return `<div class="chat-bubble chat-system">
421
+ <div class="chat-text">${escapeHtml(entry.text || '')}</div>
422
+ </div>`;
423
+ }
424
+ })
425
+ .join('');
426
+
427
+ // Scroll to bottom
428
+ container.scrollTop = container.scrollHeight;
429
+ }
430
+
431
+ function closeCallDetail() {
432
+ const listView = document.getElementById('calls-list-view');
433
+ const detailView = document.getElementById('calls-detail-view');
434
+ if (listView) listView.style.display = '';
435
+ if (detailView) detailView.style.display = 'none';
436
+ }
437
+
438
+ async function hangupCall(taskId) {
439
+ try {
440
+ await API.post(`/api/call/${taskId}/hangup`);
441
+ showToast('挂断请求已发送', 'success');
442
+ if (document.getElementById('calls-detail-view')?.style.display !== 'none') {
443
+ // Refresh detail view
444
+ viewCallDetail(taskId);
445
+ }
446
+ loadCalls();
447
+ } catch (err) {
448
+ showToast('挂断失败: ' + err.message, 'error');
449
+ }
450
+ }
451
+
452
+ // ============================================================
453
+ // SMS Page
454
+ // ============================================================
455
+ let smsPage = 1;
456
+ const SMS_PAGE_SIZE = 20;
457
+
458
+ async function loadSMS() {
459
+ const tbody = document.getElementById('sms-table-body');
460
+ if (tbody) tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">加载中...</td></tr>';
461
+
462
+ try {
463
+ const data = await API.get(`/api/sms?page=${smsPage}&page_size=${SMS_PAGE_SIZE}`);
464
+ const items = data.items || [];
465
+ const total = data.total || 0;
466
+
467
+ if (!tbody) return;
468
+
469
+ if (items.length === 0) {
470
+ tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">暂无短信记录</td></tr>';
471
+ } else {
472
+ tbody.innerHTML = items
473
+ .map(
474
+ (s) => `<tr>
475
+ <td>${escapeHtml(formatTime(s.timestamp))}</td>
476
+ <td>${escapeHtml(s.contact_name || s.phone_number || '-')}</td>
477
+ <td><span class="badge badge-${s.direction || 'unknown'}">${s.direction === 'outgoing' ? '发送' : '接收'}</span></td>
478
+ <td class="sms-content-cell">${escapeHtml(s.content || '')}</td>
479
+ <td><span class="badge badge-sms-${s.status || 'unknown'}">${escapeHtml(s.status || '-')}</span></td>
480
+ </tr>`
481
+ )
482
+ .join('');
483
+ }
484
+
485
+ renderPagination('sms-pagination', total, smsPage, SMS_PAGE_SIZE, (p) => {
486
+ smsPage = p;
487
+ loadSMS();
488
+ });
489
+ } catch (err) {
490
+ showToast('加载短信记录失败: ' + err.message, 'error');
491
+ }
492
+ }
493
+
494
+ function showNewSMSForm() {
495
+ openModal('new-sms-modal');
496
+ }
497
+
498
+ async function submitNewSMS() {
499
+ const phone = _val('sms-phone');
500
+ const content = _val('sms-content');
501
+
502
+ if (!phone || !content) {
503
+ showToast('请填写手机号和短信内容', 'error');
504
+ return;
505
+ }
506
+
507
+ try {
508
+ await API.post('/api/sms', { phone_number: phone, content });
509
+ showToast('短信发送成功', 'success');
510
+ closeModal('new-sms-modal');
511
+ _clearForm('new-sms-form');
512
+ smsPage = 1;
513
+ loadSMS();
514
+ } catch (err) {
515
+ showToast('发送短信失败: ' + err.message, 'error');
516
+ }
517
+ }
518
+
519
+ // ============================================================
520
+ // Contacts Page
521
+ // ============================================================
522
+ let contactsPage = 1;
523
+ let contactsQuery = '';
524
+ const CONTACTS_PAGE_SIZE = 20;
525
+ let _contactSearchTimer = null;
526
+
527
+ async function loadContacts() {
528
+ const tbody = document.getElementById('contacts-table-body');
529
+ if (tbody) tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted">加载中...</td></tr>';
530
+
531
+ try {
532
+ let url = `/api/contacts?page=${contactsPage}&page_size=${CONTACTS_PAGE_SIZE}`;
533
+ if (contactsQuery) url += `&query=${encodeURIComponent(contactsQuery)}`;
534
+
535
+ const data = await API.get(url);
536
+ const items = data.items || [];
537
+ const total = data.total || 0;
538
+
539
+ if (!tbody) return;
540
+
541
+ if (items.length === 0) {
542
+ tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted">暂无联系人</td></tr>';
543
+ } else {
544
+ tbody.innerHTML = items
545
+ .map(
546
+ (c) => `<tr>
547
+ <td>${escapeHtml(c.name || '-')}</td>
548
+ <td>${escapeHtml(c.phone || '-')}</td>
549
+ <td>${escapeHtml(c.company || '-')}</td>
550
+ <td>${escapeHtml(c.title || '-')}</td>
551
+ <td>${(c.tags || []).map((t) => `<span class="tag">${escapeHtml(t)}</span>`).join(' ')}</td>
552
+ <td>
553
+ <button class="btn btn-sm btn-link" onclick="editContact('${escapeHtml(c.id)}')">编辑</button>
554
+ <button class="btn btn-sm btn-danger-link" onclick="deleteContact('${escapeHtml(c.id)}')">删除</button>
555
+ </td>
556
+ </tr>`
557
+ )
558
+ .join('');
559
+ }
560
+
561
+ renderPagination('contacts-pagination', total, contactsPage, CONTACTS_PAGE_SIZE, (p) => {
562
+ contactsPage = p;
563
+ loadContacts();
564
+ });
565
+ } catch (err) {
566
+ showToast('加载联系人失败: ' + err.message, 'error');
567
+ }
568
+ }
569
+
570
+ function onContactSearch(value) {
571
+ clearTimeout(_contactSearchTimer);
572
+ _contactSearchTimer = setTimeout(() => {
573
+ contactsQuery = value.trim();
574
+ contactsPage = 1;
575
+ loadContacts();
576
+ }, 300);
577
+ }
578
+
579
+ function showNewContactForm() {
580
+ openModal('new-contact-modal');
581
+ }
582
+
583
+ async function submitNewContact() {
584
+ const name = _val('contact-name');
585
+ const phone = _val('contact-phone');
586
+ const company = _val('contact-company');
587
+ const title = _val('contact-title');
588
+ const notes = _val('contact-notes');
589
+ const tagsStr = _val('contact-tags');
590
+
591
+ if (!name || !phone) {
592
+ showToast('请填写姓名和手机号', 'error');
593
+ return;
594
+ }
595
+
596
+ const body = { name, phone };
597
+ if (company) body.company = company;
598
+ if (title) body.title = title;
599
+ if (notes) body.notes = notes;
600
+ if (tagsStr) body.tags = tagsStr.split(',').map((t) => t.trim()).filter(Boolean);
601
+
602
+ try {
603
+ await API.post('/api/contacts', body);
604
+ showToast('联系人已添加', 'success');
605
+ closeModal('new-contact-modal');
606
+ _clearForm('new-contact-form');
607
+ loadContacts();
608
+ } catch (err) {
609
+ showToast('添加联系人失败: ' + err.message, 'error');
610
+ }
611
+ }
612
+
613
+ async function editContact(contactId) {
614
+ try {
615
+ const c = await API.get(`/api/contacts/${contactId}`);
616
+
617
+ _setVal('edit-contact-id', c.id);
618
+ _setVal('edit-contact-name', c.name || '');
619
+ _setVal('edit-contact-phone', c.phone || '');
620
+ _setVal('edit-contact-company', c.company || '');
621
+ _setVal('edit-contact-title', c.title || '');
622
+ _setVal('edit-contact-notes', c.notes || '');
623
+ _setVal('edit-contact-tags', (c.tags || []).join(', '));
624
+
625
+ openModal('edit-contact-modal');
626
+ } catch (err) {
627
+ showToast('加载联系人信息失败: ' + err.message, 'error');
628
+ }
629
+ }
630
+
631
+ async function updateContact() {
632
+ const contactId = _val('edit-contact-id');
633
+ if (!contactId) return;
634
+
635
+ const name = _val('edit-contact-name');
636
+ const phone = _val('edit-contact-phone');
637
+ const company = _val('edit-contact-company');
638
+ const title = _val('edit-contact-title');
639
+ const notes = _val('edit-contact-notes');
640
+ const tagsStr = _val('edit-contact-tags');
641
+
642
+ const body = {};
643
+ if (name) body.name = name;
644
+ if (phone) body.phone = phone;
645
+ if (company !== undefined) body.company = company;
646
+ if (title !== undefined) body.title = title;
647
+ if (notes !== undefined) body.notes = notes;
648
+ if (tagsStr !== undefined) body.tags = tagsStr.split(',').map((t) => t.trim()).filter(Boolean);
649
+
650
+ try {
651
+ await API.put(`/api/contacts/${contactId}`, body);
652
+ showToast('联系人已更新', 'success');
653
+ closeModal('edit-contact-modal');
654
+ loadContacts();
655
+ } catch (err) {
656
+ showToast('更新联系人失败: ' + err.message, 'error');
657
+ }
658
+ }
659
+
660
+ async function deleteContact(contactId) {
661
+ if (!confirm('确定删除此联系人?')) return;
662
+
663
+ try {
664
+ await API.del(`/api/contacts/${contactId}`);
665
+ showToast('联系人已删除', 'success');
666
+ loadContacts();
667
+ } catch (err) {
668
+ showToast('删除联系人失败: ' + err.message, 'error');
669
+ }
670
+ }
671
+
672
+ async function syncContacts() {
673
+ const btn = document.getElementById('btn-sync-contacts');
674
+ if (btn) {
675
+ btn.disabled = true;
676
+ btn.textContent = '同步中...';
677
+ }
678
+
679
+ try {
680
+ const res = await API.post('/api/contacts/sync');
681
+ showToast(`同步完成: 新增 ${res.added}, 更新 ${res.updated}, 共 ${res.total}`, 'success');
682
+ loadContacts();
683
+ } catch (err) {
684
+ showToast('同步联系人失败: ' + err.message, 'error');
685
+ } finally {
686
+ if (btn) {
687
+ btn.disabled = false;
688
+ btn.textContent = '从手机同步';
689
+ }
690
+ }
691
+ }
692
+
693
+ // ============================================================
694
+ // Config Page
695
+ // ============================================================
696
+ let currentConfig = null;
697
+ let _configSaveTimer = null;
698
+ const CONFIG_SAVE_DEBOUNCE_MS = 1000;
699
+
700
+ // --- Basic Info: AI phones & Owners ---
701
+ let _aiPhones = []; // ["13800138000", ...]
702
+ let _currentAiPhone = ""; // the active phone
703
+ let _owners = []; // [{phone, name, note}, ...]
704
+
705
+ function _renderAiPhones() {
706
+ const container = document.getElementById('ai-phone-list');
707
+ if (!container) return;
708
+ container.innerHTML = '';
709
+ _aiPhones.forEach((phone) => {
710
+ const tag = document.createElement('span');
711
+ tag.className = 'tag-item' + (phone === _currentAiPhone ? ' active' : '');
712
+ tag.innerHTML = phone +
713
+ (phone === _currentAiPhone ? ' <span class="badge badge-primary">当前</span>' : '') +
714
+ ' <button type="button" class="tag-remove" data-phone="' + phone + '">&times;</button>';
715
+ tag.addEventListener('click', (e) => {
716
+ if (e.target.classList.contains('tag-remove')) return;
717
+ _currentAiPhone = phone;
718
+ _renderAiPhones();
719
+ _debouncedSaveConfig();
720
+ });
721
+ tag.querySelector('.tag-remove').addEventListener('click', (e) => {
722
+ e.stopPropagation();
723
+ _aiPhones = _aiPhones.filter(p => p !== phone);
724
+ if (_currentAiPhone === phone) _currentAiPhone = _aiPhones[0] || '';
725
+ _renderAiPhones();
726
+ _debouncedSaveConfig();
727
+ });
728
+ container.appendChild(tag);
729
+ });
730
+ }
731
+
732
+ function _addAiPhone() {
733
+ const input = document.getElementById('ai-phone-input');
734
+ const phone = (input.value || '').trim();
735
+ if (!phone) return;
736
+ if (_aiPhones.includes(phone)) { showToast('号码已存在', 'warning'); return; }
737
+ _aiPhones.push(phone);
738
+ if (!_currentAiPhone) _currentAiPhone = phone;
739
+ input.value = '';
740
+ _renderAiPhones();
741
+ _debouncedSaveConfig();
742
+ }
743
+
744
+ function _renderOwners() {
745
+ const container = document.getElementById('owner-list');
746
+ if (!container) return;
747
+ container.innerHTML = '';
748
+ _owners.forEach((owner, idx) => {
749
+ const row = document.createElement('div');
750
+ row.className = 'owner-item';
751
+ row.innerHTML =
752
+ '<span class="owner-phone">' + (owner.phone || '') + '</span>' +
753
+ '<span class="owner-name">' + (owner.name || '') + '</span>' +
754
+ '<span class="owner-note">' + (owner.note || '') + '</span>' +
755
+ '<button type="button" class="tag-remove" data-idx="' + idx + '">&times;</button>';
756
+ row.querySelector('.tag-remove').addEventListener('click', () => {
757
+ _owners.splice(idx, 1);
758
+ _renderOwners();
759
+ _debouncedSaveConfig();
760
+ });
761
+ container.appendChild(row);
762
+ });
763
+ }
764
+
765
+ function _addOwner() {
766
+ const phoneEl = document.getElementById('owner-phone-input');
767
+ const nameEl = document.getElementById('owner-name-input');
768
+ const noteEl = document.getElementById('owner-note-input');
769
+ const phone = (phoneEl.value || '').trim();
770
+ const name = (nameEl.value || '').trim();
771
+ const note = (noteEl.value || '').trim();
772
+ if (!phone) { showToast('请输入号码', 'warning'); return; }
773
+ if (_owners.some(o => o.phone === phone)) { showToast('号码已存在', 'warning'); return; }
774
+ const entry = { phone };
775
+ if (name) entry.name = name;
776
+ if (note) entry.note = note;
777
+ _owners.push(entry);
778
+ phoneEl.value = ''; nameEl.value = ''; noteEl.value = '';
779
+ _renderOwners();
780
+ _debouncedSaveConfig();
781
+ }
782
+
783
+ function _fillBasicInfo(c) {
784
+ // AI phones: user.phone_numbers (list) + user.phone_number (current)
785
+ _aiPhones = (c.user && c.user.phone_numbers) || [];
786
+ _currentAiPhone = (c.user && c.user.phone_number) || '';
787
+ // Backwards compat: if phone_numbers is empty but phone_number is set, seed it
788
+ if (_aiPhones.length === 0 && _currentAiPhone) {
789
+ _aiPhones = [_currentAiPhone];
790
+ }
791
+ _renderAiPhones();
792
+
793
+ // Owners
794
+ const rawOwners = (c.user && c.user.owners) || [];
795
+ _owners = rawOwners.map(o => typeof o === 'string' ? { phone: o } : { ...o });
796
+ _renderOwners();
797
+ }
798
+
799
+ function _buildBasicInfoConfig() {
800
+ return {
801
+ user: {
802
+ phone_number: _currentAiPhone,
803
+ phone_numbers: _aiPhones,
804
+ owners: _owners,
805
+ }
806
+ };
807
+ }
808
+
809
+ async function loadConfig() {
810
+ try {
811
+ const data = await API.get('/api/config');
812
+ currentConfig = data;
813
+ fillConfigForm(data);
814
+ } catch (err) {
815
+ showToast('加载配置失败: ' + err.message, 'error');
816
+ }
817
+ }
818
+
819
+ function fillConfigForm(c) {
820
+ // Basic info (AI phones & owners)
821
+ _fillBasicInfo(c);
822
+
823
+ // LLM provider tabs
824
+ const activeProvider = _getNestedVal(c, 'llm.active_provider') || 'openai';
825
+ switchProviderTab(activeProvider);
826
+
827
+ // Restore cached model lists before setting values
828
+ _restoreCachedModels();
829
+
830
+ // LLM provider configs — HTML ids: openai-base-url, openai-api-key, openai-model, etc.
831
+ const providers = ['openai', 'claude', 'gemini'];
832
+ providers.forEach((p) => {
833
+ _setVal(`${p}-base-url`, _getNestedVal(c, `llm.providers.${p}.base_url`) || '');
834
+ _setVal(`${p}-api-key`, _getNestedVal(c, `llm.providers.${p}.api_key`) || '');
835
+ _setSelectVal(`${p}-model`, _getNestedVal(c, `llm.providers.${p}.model`) || '');
836
+ });
837
+
838
+ // Common LLM settings (use first active provider's values as defaults)
839
+ _setVal('llm-temperature', _getNestedVal(c, `llm.providers.${activeProvider}.temperature`) || '0.7');
840
+ _setVal('llm-max-tokens', _getNestedVal(c, `llm.providers.${activeProvider}.max_tokens`) || '1024');
841
+ const tempSpan = document.getElementById('llm-temperature-value');
842
+ if (tempSpan) tempSpan.textContent = _getNestedVal(c, `llm.providers.${activeProvider}.temperature`) || '0.7';
843
+
844
+ // ASR
845
+ const asrProvider = _getNestedVal(c, 'asr.provider') || 'whisper';
846
+ _setVal('asr-provider', asrProvider);
847
+ _setVal('asr-language', _getNestedVal(c, 'asr.whisper.language') || 'zh');
848
+ // Whisper fields
849
+ _setVal('asr-whisper-base-url', _getNestedVal(c, 'asr.whisper.base_url') || '');
850
+ _setVal('asr-whisper-api-key', _getNestedVal(c, 'asr.whisper.api_key') || '');
851
+ _setVal('asr-whisper-model', _getNestedVal(c, 'asr.whisper.model') || 'whisper-1');
852
+ // Volcengine fields
853
+ _setVal('asr-volc-app-id', _getNestedVal(c, 'asr.volcengine.app_id') || '');
854
+ _setVal('asr-volc-access-token', _getNestedVal(c, 'asr.volcengine.access_token') || '');
855
+ _setVal('asr-volc-resource-id', _getNestedVal(c, 'asr.volcengine.resource_id') || 'volc.bigasr.sauc.duration');
856
+ // Tencent fields
857
+ _setVal('asr-tencent-app-id', _getNestedVal(c, 'asr.tencent.app_id') || '');
858
+ _setVal('asr-tencent-secret-id', _getNestedVal(c, 'asr.tencent.secret_id') || '');
859
+ _setVal('asr-tencent-secret-key', _getNestedVal(c, 'asr.tencent.secret_key') || '');
860
+ _setVal('asr-tencent-engine', _getNestedVal(c, 'asr.tencent.engine_model_type') || '16k_zh_large');
861
+ // Xunfei fields
862
+ _setVal('asr-xunfei-app-id', _getNestedVal(c, 'asr.xunfei.app_id') || '');
863
+ _setVal('asr-xunfei-api-key', _getNestedVal(c, 'asr.xunfei.api_key') || '');
864
+ _setVal('asr-xunfei-api-secret', _getNestedVal(c, 'asr.xunfei.api_secret') || '');
865
+ switchAsrProvider(asrProvider);
866
+
867
+ // TTS
868
+ const ttsProvider = _getNestedVal(c, 'tts.provider') || 'edge-tts';
869
+ _setVal('tts-provider', ttsProvider);
870
+ switchTtsProvider(ttsProvider);
871
+ // Edge TTS
872
+ _setVal('tts-edge-voice', _getNestedVal(c, 'tts.edge_tts.voice') || 'zh-CN-XiaoxiaoNeural');
873
+ // Volcengine TTS
874
+ _setVal('tts-volc-voice', _getNestedVal(c, 'tts.volcengine.voice_type') || 'BV001_streaming');
875
+ _setVal('tts-volc-speed', _getNestedVal(c, 'tts.volcengine.speed_ratio') || '1.0');
876
+ _setVal('tts-volc-volume', _getNestedVal(c, 'tts.volcengine.volume_ratio') || '1.0');
877
+ const volcSpeedEl = document.getElementById('tts-volc-speed');
878
+ if (volcSpeedEl) document.getElementById('tts-volc-speed-val').textContent = volcSpeedEl.value;
879
+ const volcVolEl = document.getElementById('tts-volc-volume');
880
+ if (volcVolEl) document.getElementById('tts-volc-volume-val').textContent = volcVolEl.value;
881
+ // Tencent TTS
882
+ _setVal('tts-tc-voice', String(_getNestedVal(c, 'tts.tencent.voice_type') || '101001'));
883
+ _setVal('tts-tc-speed', String(_getNestedVal(c, 'tts.tencent.speed') || '0'));
884
+ _setVal('tts-tc-volume', String(_getNestedVal(c, 'tts.tencent.volume') || '0'));
885
+ const tcSpeedEl = document.getElementById('tts-tc-speed');
886
+ if (tcSpeedEl) document.getElementById('tts-tc-speed-val').textContent = tcSpeedEl.value;
887
+ const tcVolEl = document.getElementById('tts-tc-volume');
888
+ if (tcVolEl) document.getElementById('tts-tc-volume-val').textContent = tcVolEl.value;
889
+ // Azure TTS
890
+ _setVal('tts-azure-key', _getNestedVal(c, 'tts.azure.api_key') || '');
891
+ _setVal('tts-azure-region', _getNestedVal(c, 'tts.azure.region') || 'eastasia');
892
+ _setVal('tts-azure-voice', _getNestedVal(c, 'tts.azure.voice') || 'zh-CN-XiaoxiaoNeural');
893
+ // OpenAI TTS
894
+ _setVal('tts-openai-key', _getNestedVal(c, 'tts.openai.api_key') || '');
895
+ _setVal('tts-openai-voice', _getNestedVal(c, 'tts.openai.voice') || 'alloy');
896
+ // Aliyun TTS
897
+ _setVal('tts-ali-akid', _getNestedVal(c, 'tts.aliyun.access_key_id') || '');
898
+ _setVal('tts-ali-aksecret', _getNestedVal(c, 'tts.aliyun.access_key_secret') || '');
899
+ _setVal('tts-ali-appkey', _getNestedVal(c, 'tts.aliyun.app_key') || '');
900
+ // Local TTS
901
+ _setVal('tts-local-engine', _getNestedVal(c, 'tts.local.engine') || 'sherpa-onnx');
902
+ _setVal('tts-local-model', _getNestedVal(c, 'tts.local.model_path') || '');
903
+
904
+ // VAD
905
+ _setVal('vad-energy-threshold', _getNestedVal(c, 'vad.energy_threshold') || '300');
906
+ _setVal('vad-silence-ms', _getNestedVal(c, 'vad.silence_threshold_ms') || '800');
907
+ _setVal('vad-min-speech-ms', _getNestedVal(c, 'vad.min_speech_ms') || '250');
908
+
909
+ // Call
910
+ _setVal('default-max-duration', _getNestedVal(c, 'call.max_duration_seconds') || '300');
911
+ _setVal('default-timeout', _getNestedVal(c, 'call.no_response_timeout') || '15');
912
+ _setVal('incoming-action', _getNestedVal(c, 'call.incoming_default_action') || 'reject');
913
+
914
+ // Webhook
915
+ _setVal('webhook-default-url', _getNestedVal(c, 'webhook.default_url') || '');
916
+ _setVal('webhook-retry-count', _getNestedVal(c, 'webhook.retry_count') || '3');
917
+ _setVal('webhook-retry-interval', _getNestedVal(c, 'webhook.timeout') || '10');
918
+ }
919
+
920
+ function _buildConfigObject() {
921
+ const activeProvider = document.querySelector('.provider-tab.active')?.dataset?.provider || 'openai';
922
+ const temp = parseFloat(_val('llm-temperature')) || 0.7;
923
+ const maxTokens = parseInt(_val('llm-max-tokens')) || 1024;
924
+
925
+ const config = {
926
+ llm: {
927
+ active_provider: activeProvider,
928
+ providers: {},
929
+ },
930
+ asr: {
931
+ provider: _val('asr-provider') || 'whisper',
932
+ whisper: {
933
+ base_url: _val('asr-whisper-base-url'),
934
+ api_key: _val('asr-whisper-api-key'),
935
+ model: _val('asr-whisper-model') || 'whisper-1',
936
+ language: _val('asr-language') || 'zh',
937
+ },
938
+ volcengine: {
939
+ app_id: _val('asr-volc-app-id'),
940
+ access_token: _val('asr-volc-access-token'),
941
+ resource_id: _val('asr-volc-resource-id') || 'volc.bigasr.sauc.duration',
942
+ },
943
+ tencent: {
944
+ app_id: _val('asr-tencent-app-id'),
945
+ secret_id: _val('asr-tencent-secret-id'),
946
+ secret_key: _val('asr-tencent-secret-key'),
947
+ engine_model_type: _val('asr-tencent-engine') || '16k_zh_large',
948
+ },
949
+ xunfei: {
950
+ app_id: _val('asr-xunfei-app-id'),
951
+ api_key: _val('asr-xunfei-api-key'),
952
+ api_secret: _val('asr-xunfei-api-secret'),
953
+ },
954
+ },
955
+ tts: {
956
+ provider: _val('tts-provider') || 'edge-tts',
957
+ edge_tts: {
958
+ voice: _val('tts-edge-voice') || 'zh-CN-XiaoxiaoNeural',
959
+ },
960
+ volcengine: {
961
+ voice_type: _val('tts-volc-voice') || 'BV001_streaming',
962
+ speed_ratio: parseFloat(_val('tts-volc-speed')) || 1.0,
963
+ volume_ratio: parseFloat(_val('tts-volc-volume')) || 1.0,
964
+ },
965
+ tencent: {
966
+ voice_type: parseInt(_val('tts-tc-voice')) || 101001,
967
+ speed: parseFloat(_val('tts-tc-speed')) || 0,
968
+ volume: parseFloat(_val('tts-tc-volume')) || 0,
969
+ },
970
+ azure: {
971
+ api_key: _val('tts-azure-key') || '',
972
+ region: _val('tts-azure-region') || 'eastasia',
973
+ voice: _val('tts-azure-voice') || 'zh-CN-XiaoxiaoNeural',
974
+ },
975
+ openai: {
976
+ api_key: _val('tts-openai-key') || '',
977
+ voice: _val('tts-openai-voice') || 'alloy',
978
+ },
979
+ aliyun: {
980
+ access_key_id: _val('tts-ali-akid') || '',
981
+ access_key_secret: _val('tts-ali-aksecret') || '',
982
+ app_key: _val('tts-ali-appkey') || '',
983
+ },
984
+ local: {
985
+ engine: _val('tts-local-engine') || 'sherpa-onnx',
986
+ model_path: _val('tts-local-model') || '',
987
+ },
988
+ },
989
+ vad: {
990
+ energy_threshold: parseInt(_val('vad-energy-threshold')) || 300,
991
+ silence_threshold_ms: parseInt(_val('vad-silence-ms')) || 800,
992
+ min_speech_ms: parseInt(_val('vad-min-speech-ms')) || 250,
993
+ },
994
+ call: {
995
+ max_duration_seconds: parseInt(_val('default-max-duration')) || 300,
996
+ no_response_timeout: parseInt(_val('default-timeout')) || 15,
997
+ incoming_default_action: _val('incoming-action') || 'reject',
998
+ },
999
+ webhook: {
1000
+ default_url: _val('webhook-default-url'),
1001
+ retry_count: parseInt(_val('webhook-retry-count')) || 3,
1002
+ timeout: parseInt(_val('webhook-retry-interval')) || 10,
1003
+ },
1004
+ };
1005
+
1006
+ const providers = ['openai', 'claude', 'gemini'];
1007
+ providers.forEach((p) => {
1008
+ config.llm.providers[p] = {
1009
+ base_url: _val(`${p}-base-url`),
1010
+ api_key: _val(`${p}-api-key`),
1011
+ model: _val(`${p}-model`),
1012
+ temperature: temp,
1013
+ max_tokens: maxTokens,
1014
+ };
1015
+ });
1016
+
1017
+ // Merge basic info
1018
+ Object.assign(config, _buildBasicInfoConfig());
1019
+
1020
+ return config;
1021
+ }
1022
+
1023
+ function _showConfigSaveStatus(status) {
1024
+ const el = document.getElementById('config-save-status');
1025
+ if (!el) return;
1026
+ el.classList.remove('saving', 'saved', 'error', 'fade-out');
1027
+ if (status === 'saving') {
1028
+ el.textContent = '保存中...';
1029
+ el.classList.add('saving');
1030
+ } else if (status === 'saved') {
1031
+ el.textContent = '已保存';
1032
+ el.classList.add('saved');
1033
+ setTimeout(() => {
1034
+ el.classList.add('fade-out');
1035
+ setTimeout(() => { el.textContent = ''; el.classList.remove('saved', 'fade-out'); }, 300);
1036
+ }, 2000);
1037
+ } else if (status === 'error') {
1038
+ el.textContent = '保存失败';
1039
+ el.classList.add('error');
1040
+ setTimeout(() => {
1041
+ el.classList.add('fade-out');
1042
+ setTimeout(() => { el.textContent = ''; el.classList.remove('error', 'fade-out'); }, 300);
1043
+ }, 3000);
1044
+ }
1045
+ }
1046
+
1047
+ function _debouncedSaveConfig() {
1048
+ clearTimeout(_configSaveTimer);
1049
+ _configSaveTimer = setTimeout(async () => {
1050
+ _showConfigSaveStatus('saving');
1051
+ try {
1052
+ const config = _buildConfigObject();
1053
+ const updated = await API.put('/api/config', config);
1054
+ currentConfig = updated;
1055
+ _showConfigSaveStatus('saved');
1056
+ } catch (err) {
1057
+ _showConfigSaveStatus('error');
1058
+ showToast('保存配置失败: ' + err.message, 'error');
1059
+ }
1060
+ }, CONFIG_SAVE_DEBOUNCE_MS);
1061
+ }
1062
+
1063
+ async function refreshModels(provider) {
1064
+ const selectId = `${provider}-model`;
1065
+ const selectEl = document.getElementById(selectId);
1066
+
1067
+ try {
1068
+ showToast(`正在获取 ${provider} 模型列表...`, 'info');
1069
+ const models = await API.get(`/api/models?provider=${provider}`);
1070
+
1071
+ if (selectEl && Array.isArray(models)) {
1072
+ const currentVal = selectEl.value;
1073
+ _populateModelSelect(selectEl, models, currentVal);
1074
+ // Persist model list to localStorage
1075
+ try { localStorage.setItem(`models-${provider}`, JSON.stringify(models)); } catch (_) {}
1076
+ showToast(`已获取 ${models.length} 个模型`, 'success');
1077
+ }
1078
+ } catch (err) {
1079
+ showToast(`获取模型列表失败: ${err.message}`, 'error');
1080
+ }
1081
+ }
1082
+
1083
+ /** Populate a model <select> with a list of {id, name} and restore selection. */
1084
+ function _populateModelSelect(selectEl, models, selectedValue) {
1085
+ if (selectEl.tagName === 'SELECT') {
1086
+ selectEl.innerHTML = models
1087
+ .map((m) => `<option value="${escapeHtml(m.id)}">${escapeHtml(m.name || m.id)}</option>`)
1088
+ .join('');
1089
+ if (selectedValue) {
1090
+ if (models.some((m) => m.id === selectedValue)) {
1091
+ selectEl.value = selectedValue;
1092
+ } else {
1093
+ // Saved model not in list — add it so the value is preserved
1094
+ const opt = document.createElement('option');
1095
+ opt.value = selectedValue;
1096
+ opt.textContent = selectedValue;
1097
+ selectEl.appendChild(opt);
1098
+ selectEl.value = selectedValue;
1099
+ }
1100
+ }
1101
+ }
1102
+ }
1103
+
1104
+ /** Restore cached model lists from localStorage for all LLM providers. */
1105
+ function _restoreCachedModels() {
1106
+ const providers = ['openai', 'claude', 'gemini'];
1107
+ providers.forEach((p) => {
1108
+ const selectEl = document.getElementById(`${p}-model`);
1109
+ if (!selectEl) return;
1110
+ try {
1111
+ const raw = localStorage.getItem(`models-${p}`);
1112
+ if (raw) {
1113
+ const models = JSON.parse(raw);
1114
+ if (Array.isArray(models) && models.length > 0) {
1115
+ const currentVal = selectEl.value;
1116
+ _populateModelSelect(selectEl, models, currentVal);
1117
+ }
1118
+ }
1119
+ } catch (_) {}
1120
+ });
1121
+ }
1122
+
1123
+ function switchAsrProvider(provider) {
1124
+ const configs = ['whisper', 'volcengine', 'tencent', 'xunfei'];
1125
+ configs.forEach((p) => {
1126
+ const el = document.getElementById(`asr-config-${p}`);
1127
+ if (el) el.classList.toggle('hidden', p !== provider);
1128
+ });
1129
+ }
1130
+
1131
+ function switchTtsProvider(provider) {
1132
+ const cfgs = ['edge-tts', 'volcengine', 'tencent', 'azure', 'openai', 'aliyun', 'local'];
1133
+ cfgs.forEach((p) => {
1134
+ const el = document.getElementById(`tts-cfg-${p}`);
1135
+ if (el) el.style.display = (p === provider) ? '' : 'none';
1136
+ });
1137
+ // Show common sliders only for edge-tts
1138
+ const common = document.getElementById('tts-common-sliders');
1139
+ if (common) common.style.display = (provider === 'edge-tts') ? '' : 'none';
1140
+ }
1141
+
1142
+ // ============================================================
1143
+ // TTS Test Dialog
1144
+ // ============================================================
1145
+ const TTS_VOICES = {
1146
+ 'edge-tts': [
1147
+ { id: 'zh-CN-XiaoxiaoNeural', name: '晓晓 (女声)' },
1148
+ { id: 'zh-CN-YunxiNeural', name: '云希 (男声)' },
1149
+ { id: 'zh-CN-XiaoyiNeural', name: '晓伊 (女声)' },
1150
+ { id: 'zh-CN-YunjianNeural', name: '云健 (男声)' },
1151
+ ],
1152
+ volcengine: [
1153
+ { id: 'BV001_streaming', name: '通用女声' },
1154
+ { id: 'BV002_streaming', name: '通用男声' },
1155
+ { id: 'BV007_streaming', name: '亲切女声' },
1156
+ { id: 'BV700_streaming', name: '灿灿' },
1157
+ { id: 'BV701_streaming', name: '擎苍' },
1158
+ { id: 'BV705_streaming', name: '炀炀' },
1159
+ { id: 'BV113_streaming', name: '甜宠少御' },
1160
+ { id: 'BV056_streaming', name: '阳光男声' },
1161
+ { id: 'BV033_streaming', name: '温柔小哥' },
1162
+ ],
1163
+ tencent: [
1164
+ { id: '101001', name: '智瑜 (情感女声)' },
1165
+ { id: '101002', name: '智聆 (通用女声)' },
1166
+ { id: '101004', name: '智云 (通用男声)' },
1167
+ { id: '101005', name: '智莉 (通用女声)' },
1168
+ { id: '101010', name: '智华 (通用男声)' },
1169
+ { id: '101018', name: '智靖 (情感男声)' },
1170
+ { id: '501001', name: '智兰 (新闻女声)' },
1171
+ { id: '501004', name: '月华 (聊天女声)' },
1172
+ { id: '501005', name: '飞镜 (聊天男声)' },
1173
+ ],
1174
+ azure: [
1175
+ { id: 'zh-CN-XiaoxiaoNeural', name: 'Xiaoxiao (女声)' },
1176
+ { id: 'zh-CN-YunxiNeural', name: 'Yunxi (男声)' },
1177
+ ],
1178
+ openai: [
1179
+ { id: 'alloy', name: 'Alloy' },
1180
+ { id: 'echo', name: 'Echo' },
1181
+ { id: 'fable', name: 'Fable' },
1182
+ { id: 'onyx', name: 'Onyx' },
1183
+ { id: 'nova', name: 'Nova' },
1184
+ { id: 'shimmer', name: 'Shimmer' },
1185
+ ],
1186
+ aliyun: [
1187
+ { id: 'zhixiaobai', name: '知小白' },
1188
+ { id: 'zhiyan', name: '知燕' },
1189
+ ],
1190
+ local: [
1191
+ { id: 'default', name: '默认' },
1192
+ ],
1193
+ };
1194
+
1195
+ let ttsTestAudio = null;
1196
+ let ttsTestGeneration = 0; // prevent stale responses from playing
1197
+
1198
+ function _ttsLog(msg) {
1199
+ const el = document.getElementById('tts-test-log');
1200
+ if (!el) return;
1201
+ if (msg === 'clear') {
1202
+ el.textContent = '';
1203
+ el.style.display = 'none';
1204
+ return;
1205
+ }
1206
+ el.style.display = '';
1207
+ const ts = new Date().toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
1208
+ el.textContent += `[${ts}] ${msg}\n`;
1209
+ el.scrollTop = el.scrollHeight;
1210
+ }
1211
+
1212
+ function _ttsReportDiag(provider, error) {
1213
+ const el = document.getElementById('tts-test-log');
1214
+ const log = el ? el.textContent : '';
1215
+ fetch('/api/tts-diag', {
1216
+ method: 'POST',
1217
+ headers: { 'Content-Type': 'application/json' },
1218
+ body: JSON.stringify({
1219
+ log,
1220
+ provider,
1221
+ error: error || '',
1222
+ timestamp: new Date().toISOString(),
1223
+ }),
1224
+ }).catch(() => {}); // fire-and-forget
1225
+ }
1226
+
1227
+ function openTtsTest() {
1228
+ const overlay = document.getElementById('tts-test-overlay');
1229
+ if (!overlay) return;
1230
+ overlay.classList.remove('hidden');
1231
+
1232
+ // Sync provider from config page
1233
+ const cfgProvider = document.getElementById('tts-provider');
1234
+ const testProvider = document.getElementById('tts-test-provider');
1235
+ if (cfgProvider && testProvider) {
1236
+ testProvider.value = cfgProvider.value;
1237
+ }
1238
+ _updateTtsTestVoices();
1239
+ _ttsLog('clear');
1240
+
1241
+ document.getElementById('tts-test-status').textContent = '点击"合成并播放"开始测试';
1242
+ }
1243
+
1244
+ function closeTtsTest() {
1245
+ const overlay = document.getElementById('tts-test-overlay');
1246
+ if (overlay) overlay.classList.add('hidden');
1247
+ _stopTtsTestAudio();
1248
+ }
1249
+
1250
+ function _stopTtsTestAudio() {
1251
+ if (ttsTestAudio) {
1252
+ ttsTestAudio.pause();
1253
+ ttsTestAudio.src = '';
1254
+ ttsTestAudio = null;
1255
+ }
1256
+ const playBtn = document.getElementById('tts-test-play');
1257
+ const stopBtn = document.getElementById('tts-test-stop');
1258
+ if (playBtn) playBtn.style.display = '';
1259
+ if (stopBtn) stopBtn.style.display = 'none';
1260
+ }
1261
+
1262
+ function _updateTtsTestVoices() {
1263
+ const providerSel = document.getElementById('tts-test-provider');
1264
+ const voiceSel = document.getElementById('tts-test-voice');
1265
+ if (!providerSel || !voiceSel) return;
1266
+
1267
+ const provider = providerSel.value;
1268
+ const voices = TTS_VOICES[provider] || [];
1269
+ voiceSel.innerHTML = voices.map(v =>
1270
+ `<option value="${v.id}">${v.name}</option>`
1271
+ ).join('');
1272
+
1273
+ // Sync voice from config page if same provider
1274
+ const cfgProvider = document.getElementById('tts-provider');
1275
+ if (cfgProvider && cfgProvider.value === provider) {
1276
+ _syncTtsTestVoiceFromConfig(provider);
1277
+ }
1278
+ }
1279
+
1280
+ function _syncTtsTestVoiceFromConfig(provider) {
1281
+ const voiceSel = document.getElementById('tts-test-voice');
1282
+ if (!voiceSel) return;
1283
+ let cfgVoice = '';
1284
+ if (provider === 'edge-tts') cfgVoice = _val('tts-edge-voice');
1285
+ else if (provider === 'volcengine') cfgVoice = _val('tts-volc-voice');
1286
+ else if (provider === 'tencent') cfgVoice = _val('tts-tc-voice');
1287
+ else if (provider === 'azure') cfgVoice = _val('tts-azure-voice');
1288
+ else if (provider === 'openai') cfgVoice = _val('tts-openai-voice');
1289
+ if (cfgVoice) voiceSel.value = cfgVoice;
1290
+ }
1291
+
1292
+ async function runTtsTest() {
1293
+ const text = _val('tts-test-text');
1294
+ if (!text || !text.trim()) {
1295
+ showToast('请输入要合成的文本', 'error');
1296
+ return;
1297
+ }
1298
+
1299
+ const provider = _val('tts-test-provider');
1300
+ const voice = _val('tts-test-voice');
1301
+ const speed = parseFloat(document.getElementById('tts-test-speed')?.value || '1.0');
1302
+
1303
+ const statusEl = document.getElementById('tts-test-status');
1304
+ const playBtn = document.getElementById('tts-test-play');
1305
+ const stopBtn = document.getElementById('tts-test-stop');
1306
+
1307
+ // Bump generation to invalidate any in-flight request
1308
+ const gen = ++ttsTestGeneration;
1309
+ _stopTtsTestAudio();
1310
+ _ttsLog('clear');
1311
+ _ttsLog('引擎: ' + provider + ', 音色: ' + voice + ', 语速: ' + speed);
1312
+ _ttsLog('文本: ' + text.substring(0, 50) + (text.length > 50 ? '...' : ''));
1313
+ if (statusEl) statusEl.textContent = '正在合成...';
1314
+ if (playBtn) playBtn.disabled = true;
1315
+
1316
+ const MAX_RETRIES = 2;
1317
+ let lastErr = null;
1318
+
1319
+ for (let attempt = 1; attempt <= MAX_RETRIES + 1; attempt++) {
1320
+ if (gen !== ttsTestGeneration) return;
1321
+
1322
+ if (attempt > 1) {
1323
+ _ttsLog('第' + (attempt - 1) + '次重试...');
1324
+ if (statusEl) statusEl.textContent = `第${attempt - 1}次重试中...`;
1325
+ await new Promise(r => setTimeout(r, 800 * attempt));
1326
+ if (gen !== ttsTestGeneration) return;
1327
+ }
1328
+
1329
+ try {
1330
+ _ttsLog('发送合成请求 (attempt ' + attempt + ')...');
1331
+ const t0 = performance.now();
1332
+
1333
+ const resp = await fetch('/api/tts-test', {
1334
+ method: 'POST',
1335
+ headers: { 'Content-Type': 'application/json' },
1336
+ body: JSON.stringify({ provider, voice, speed, volume: 1.0, text }),
1337
+ });
1338
+
1339
+ const elapsed = ((performance.now() - t0) / 1000).toFixed(2);
1340
+ const body = await resp.json();
1341
+
1342
+ if (!resp.ok) {
1343
+ const detail = body.detail || resp.statusText;
1344
+ _ttsLog('HTTP ' + resp.status + ' (' + elapsed + 's): ' + detail);
1345
+ throw new Error(detail);
1346
+ }
1347
+
1348
+ if (gen !== ttsTestGeneration) return;
1349
+
1350
+ if (!body.audio) {
1351
+ _ttsLog('错误: 服务器返回空音频');
1352
+ throw new Error('服务器返回空音频');
1353
+ }
1354
+
1355
+ _ttsLog('合成成功 (' + elapsed + 's), 大小: ' + (body.size / 1024).toFixed(1) + ' KB, 格式: ' + body.content_type);
1356
+ if (statusEl) statusEl.textContent = `合成完成 (${(body.size / 1024).toFixed(1)} KB),正在播放...`;
1357
+
1358
+ _stopTtsTestAudio();
1359
+
1360
+ if (body.content_type === 'audio/pcm') {
1361
+ const pcmBytes = Uint8Array.from(atob(body.audio), c => c.charCodeAt(0));
1362
+ const wavBlob = _pcmToWavBlob(pcmBytes, 16000, 1, 16);
1363
+ ttsTestAudio = new Audio(URL.createObjectURL(wavBlob));
1364
+ } else {
1365
+ ttsTestAudio = new Audio('data:audio/mpeg;base64,' + body.audio);
1366
+ }
1367
+
1368
+ if (playBtn) playBtn.style.display = 'none';
1369
+ if (stopBtn) stopBtn.style.display = '';
1370
+
1371
+ ttsTestAudio.onended = () => {
1372
+ _ttsLog('播放完成');
1373
+ if (statusEl) statusEl.textContent = '播放完成';
1374
+ if (playBtn) { playBtn.style.display = ''; playBtn.disabled = false; }
1375
+ if (stopBtn) stopBtn.style.display = 'none';
1376
+ };
1377
+ ttsTestAudio.onerror = (e) => {
1378
+ _ttsLog('播放失败: ' + (e.message || 'Audio element error'));
1379
+ if (statusEl) statusEl.textContent = '播放失败';
1380
+ if (playBtn) { playBtn.style.display = ''; playBtn.disabled = false; }
1381
+ if (stopBtn) stopBtn.style.display = 'none';
1382
+ };
1383
+ ttsTestAudio.play();
1384
+ return;
1385
+
1386
+ } catch (err) {
1387
+ lastErr = err;
1388
+ if (attempt <= MAX_RETRIES) {
1389
+ _ttsLog('失败: ' + err.message + ', 准备重试...');
1390
+ if (statusEl) statusEl.textContent = `合成失败,准备重试 (${attempt}/${MAX_RETRIES})...`;
1391
+ }
1392
+ }
1393
+ }
1394
+
1395
+ // All retries exhausted
1396
+ if (gen === ttsTestGeneration) {
1397
+ _ttsLog('全部重试失败: ' + (lastErr?.message || '未知错误'));
1398
+ _ttsReportDiag(provider, lastErr?.message || '未知错误');
1399
+ if (statusEl) statusEl.textContent = '合成失败: ' + (lastErr?.message || '未知错误');
1400
+ showToast('TTS 测试失败: ' + (lastErr?.message || '未知错误'), 'error');
1401
+ if (playBtn) playBtn.disabled = false;
1402
+ }
1403
+ }
1404
+
1405
+ function _pcmToWavBlob(pcmData, sampleRate, channels, bitsPerSample) {
1406
+ const byteRate = sampleRate * channels * (bitsPerSample / 8);
1407
+ const blockAlign = channels * (bitsPerSample / 8);
1408
+ const dataSize = pcmData.length;
1409
+ const buffer = new ArrayBuffer(44 + dataSize);
1410
+ const view = new DataView(buffer);
1411
+
1412
+ // RIFF header
1413
+ _writeString(view, 0, 'RIFF');
1414
+ view.setUint32(4, 36 + dataSize, true);
1415
+ _writeString(view, 8, 'WAVE');
1416
+ // fmt chunk
1417
+ _writeString(view, 12, 'fmt ');
1418
+ view.setUint32(16, 16, true);
1419
+ view.setUint16(20, 1, true);
1420
+ view.setUint16(22, channels, true);
1421
+ view.setUint32(24, sampleRate, true);
1422
+ view.setUint32(28, byteRate, true);
1423
+ view.setUint16(32, blockAlign, true);
1424
+ view.setUint16(34, bitsPerSample, true);
1425
+ // data chunk
1426
+ _writeString(view, 36, 'data');
1427
+ view.setUint32(40, dataSize, true);
1428
+ new Uint8Array(buffer, 44).set(pcmData);
1429
+
1430
+ return new Blob([buffer], { type: 'audio/wav' });
1431
+ }
1432
+
1433
+ function _writeString(view, offset, str) {
1434
+ for (let i = 0; i < str.length; i++) {
1435
+ view.setUint8(offset + i, str.charCodeAt(i));
1436
+ }
1437
+ }
1438
+
1439
+ function switchProviderTab(provider) {
1440
+ // Update tab active state
1441
+ document.querySelectorAll('.provider-tab').forEach((tab) => {
1442
+ tab.classList.toggle('active', tab.dataset.provider === provider);
1443
+ });
1444
+
1445
+ // Show/hide provider sections — HTML ids: provider-openai, provider-claude, provider-gemini
1446
+ const providers = ['openai', 'claude', 'gemini'];
1447
+ providers.forEach((p) => {
1448
+ const section = document.getElementById(`provider-${p}`);
1449
+ if (section) {
1450
+ section.classList.toggle('active', p === provider);
1451
+ }
1452
+ });
1453
+ }
1454
+
1455
+ // ============================================================
1456
+ // Chat Test Dialog
1457
+ // ============================================================
1458
+ let chatTestMessages = [];
1459
+ let chatTestBusy = false;
1460
+
1461
+ function openChatTest() {
1462
+ const overlay = document.getElementById('chat-test-overlay');
1463
+ if (!overlay) return;
1464
+ overlay.classList.remove('hidden');
1465
+
1466
+ // Sync provider from config page
1467
+ const activeProvider = document.querySelector('.provider-tab.active')?.dataset?.provider || 'openai';
1468
+ const providerSelect = document.getElementById('chat-test-provider');
1469
+ if (providerSelect) providerSelect.value = activeProvider;
1470
+
1471
+ // Load models for the selected provider
1472
+ refreshChatTestModels();
1473
+ }
1474
+
1475
+ function closeChatTest() {
1476
+ const overlay = document.getElementById('chat-test-overlay');
1477
+ if (overlay) overlay.classList.add('hidden');
1478
+ chatTestMessages = [];
1479
+ chatTestBusy = false;
1480
+ const container = document.getElementById('chat-test-messages');
1481
+ if (container) container.innerHTML = '<div class="chat-test-empty">选择模型后开始对话</div>';
1482
+ }
1483
+
1484
+ function _getChatTestConfig() {
1485
+ const provider = _val('chat-test-provider') || 'openai';
1486
+ return {
1487
+ provider,
1488
+ base_url: _val(`${provider}-base-url`) || '',
1489
+ api_key: _val(`${provider}-api-key`) || '',
1490
+ model: _val('chat-test-model') || '',
1491
+ temperature: parseFloat(_val('llm-temperature')) || 0.7,
1492
+ max_tokens: parseInt(_val('llm-max-tokens')) || 1024,
1493
+ };
1494
+ }
1495
+
1496
+ async function refreshChatTestModels() {
1497
+ const cfg = _getChatTestConfig();
1498
+ const select = document.getElementById('chat-test-model');
1499
+ if (!select) return;
1500
+
1501
+ if (!cfg.api_key) {
1502
+ select.innerHTML = '<option value="">请先在配置中填入 API Key</option>';
1503
+ return;
1504
+ }
1505
+
1506
+ select.innerHTML = '<option value="">加载中...</option>';
1507
+ try {
1508
+ const models = await API.get(`/api/models?provider=${cfg.provider}`);
1509
+ if (Array.isArray(models) && models.length > 0) {
1510
+ select.innerHTML = models
1511
+ .map((m) => `<option value="${escapeHtml(m.id)}">${escapeHtml(m.name)}</option>`)
1512
+ .join('');
1513
+ // Try to select the model configured on the config page
1514
+ const configModel = _val(`${cfg.provider}-model`);
1515
+ if (configModel && models.some((m) => m.id === configModel)) {
1516
+ select.value = configModel;
1517
+ }
1518
+ } else {
1519
+ select.innerHTML = '<option value="">无可用模型</option>';
1520
+ }
1521
+ } catch (err) {
1522
+ select.innerHTML = '<option value="">获取失败</option>';
1523
+ showToast('获取模型列表失败: ' + err.message, 'error');
1524
+ }
1525
+ }
1526
+
1527
+ function appendChatTestBubble(role, text) {
1528
+ const container = document.getElementById('chat-test-messages');
1529
+ if (!container) return;
1530
+ // Remove empty placeholder
1531
+ const empty = container.querySelector('.chat-test-empty');
1532
+ if (empty) empty.remove();
1533
+
1534
+ const bubble = document.createElement('div');
1535
+ bubble.className = `chat-test-bubble ${role}`;
1536
+ bubble.textContent = text;
1537
+ container.appendChild(bubble);
1538
+ container.scrollTop = container.scrollHeight;
1539
+ return bubble;
1540
+ }
1541
+
1542
+ async function sendChatTestMessage() {
1543
+ if (chatTestBusy) return;
1544
+
1545
+ const input = document.getElementById('chat-test-input');
1546
+ if (!input) return;
1547
+ const text = input.value.trim();
1548
+ if (!text) return;
1549
+
1550
+ const cfg = _getChatTestConfig();
1551
+ if (!cfg.api_key) {
1552
+ showToast('请先在配置页填入 API Key', 'error');
1553
+ return;
1554
+ }
1555
+ if (!cfg.model) {
1556
+ showToast('请选择模型', 'error');
1557
+ return;
1558
+ }
1559
+
1560
+ // Clear input & show user bubble
1561
+ input.value = '';
1562
+ input.style.height = 'auto';
1563
+ appendChatTestBubble('user', text);
1564
+
1565
+ // Add to message history
1566
+ chatTestMessages.push({ role: 'user', content: text });
1567
+
1568
+ // Show thinking indicator
1569
+ const thinking = appendChatTestBubble('thinking', '思考中...');
1570
+ chatTestBusy = true;
1571
+ const sendBtn = document.getElementById('chat-test-send');
1572
+ if (sendBtn) sendBtn.disabled = true;
1573
+
1574
+ try {
1575
+ const result = await API.post('/api/chat', {
1576
+ provider: cfg.provider,
1577
+ base_url: cfg.base_url,
1578
+ api_key: cfg.api_key,
1579
+ model: cfg.model,
1580
+ temperature: cfg.temperature,
1581
+ max_tokens: cfg.max_tokens,
1582
+ messages: chatTestMessages,
1583
+ });
1584
+
1585
+ // Remove thinking bubble
1586
+ if (thinking) thinking.remove();
1587
+
1588
+ const content = result.content || '(空回复)';
1589
+ chatTestMessages.push({ role: 'assistant', content });
1590
+ appendChatTestBubble('assistant', content);
1591
+ } catch (err) {
1592
+ if (thinking) thinking.remove();
1593
+ appendChatTestBubble('error', '错误: ' + err.message);
1594
+ } finally {
1595
+ chatTestBusy = false;
1596
+ if (sendBtn) sendBtn.disabled = false;
1597
+ input.focus();
1598
+ }
1599
+ }
1600
+
1601
+ // ============================================================
1602
+ // Shared Audio Pipeline (used by both ASR Test and Voice Chat)
1603
+ // ============================================================
1604
+
1605
+ // Shared mic constraints — single source of truth for getUserMedia
1606
+ function _getMicConstraints(deviceId) {
1607
+ const c = { channelCount: 1, echoCancellation: true, noiseSuppression: true };
1608
+ if (deviceId && deviceId !== 'default') c.deviceId = { exact: deviceId };
1609
+ return c;
1610
+ }
1611
+
1612
+ // Shared audio processing pipeline: native-SR capture → resample → gain → AGC → Int16 PCM
1613
+ // Both ASR test and voicechat call this — same code path, same parameters.
1614
+ //
1615
+ // options:
1616
+ // stream — MediaStream from getUserMedia
1617
+ // targetSR — target sample rate (default 16000)
1618
+ // onPcmData(Int16Array) — callback per processed frame
1619
+ // agcEnabled() — function returning bool (default: check #asr-agc-toggle, fallback true)
1620
+ // log(msg) — log function (default: no-op)
1621
+ //
1622
+ // returns: { audioContext, source, gainNode, analyser, processor, nativeSR, targetSR, getAgcGain }
1623
+ function _createAudioPipeline(options) {
1624
+ const stream = options.stream;
1625
+ const targetSR = options.targetSR || 16000;
1626
+ const onPcmData = options.onPcmData;
1627
+ const log = options.log || (() => {});
1628
+ const agcEnabledFn = options.agcEnabled || (() => {
1629
+ const toggle = document.getElementById('asr-agc-toggle');
1630
+ return toggle ? toggle.checked : true;
1631
+ });
1632
+
1633
+ const audioContext = new (window.AudioContext || window.webkitAudioContext)();
1634
+ const nativeSR = audioContext.sampleRate;
1635
+ log('AudioContext: ' + nativeSR + ' Hz → ' + targetSR + ' Hz');
1636
+
1637
+ const source = audioContext.createMediaStreamSource(stream);
1638
+
1639
+ // Gain — reads from the shared asr-mic-gain control (same slider controls both ASR test & voicechat)
1640
+ const gainNode = audioContext.createGain();
1641
+ const gainSlider = document.getElementById('asr-mic-gain');
1642
+ const savedGain = localStorage.getItem('asr-mic-gain');
1643
+ gainNode.gain.value = gainSlider ? parseInt(gainSlider.value) / 100 : (savedGain ? parseInt(savedGain) / 100 : 1.0);
1644
+
1645
+ // Analyser for waveform visualization
1646
+ const analyser = audioContext.createAnalyser();
1647
+ analyser.fftSize = 2048;
1648
+ analyser.smoothingTimeConstant = 0.5;
1649
+
1650
+ // ScriptProcessor
1651
+ const bufferSize = 4096;
1652
+ const processor = audioContext.createScriptProcessor(bufferSize, 1, 1);
1653
+
1654
+ // Chain: source → gainNode → analyser → processor → destination
1655
+ source.connect(gainNode);
1656
+ gainNode.connect(analyser);
1657
+ analyser.connect(processor);
1658
+ processor.connect(audioContext.destination);
1659
+
1660
+ // Resample state
1661
+ let resampleOffset = 0;
1662
+ // AGC state — same parameters everywhere
1663
+ let agcGain = 1.0;
1664
+ const AGC_TARGET = 0.5;
1665
+ const AGC_ATTACK = 0.1;
1666
+ const AGC_RELEASE = 0.05;
1667
+ const AGC_MAX_GAIN = 30;
1668
+
1669
+ processor.onaudioprocess = (e) => {
1670
+ const inputData = e.inputBuffer.getChannelData(0);
1671
+
1672
+ // Resample from nativeSR to targetSR using linear interpolation
1673
+ let pcmFloat;
1674
+ if (nativeSR === targetSR) {
1675
+ pcmFloat = new Float32Array(inputData);
1676
+ } else {
1677
+ const outSamples = [];
1678
+ let srcPos = resampleOffset;
1679
+ const step = nativeSR / targetSR;
1680
+ while (srcPos < inputData.length) {
1681
+ const idx = Math.floor(srcPos);
1682
+ const frac = srcPos - idx;
1683
+ const s0 = inputData[idx];
1684
+ const s1 = idx + 1 < inputData.length ? inputData[idx + 1] : s0;
1685
+ outSamples.push(s0 + (s1 - s0) * frac);
1686
+ srcPos += step;
1687
+ }
1688
+ resampleOffset = srcPos - inputData.length;
1689
+ pcmFloat = new Float32Array(outSamples);
1690
+ }
1691
+
1692
+ // Compute pre-AGC RMS on the resampled (but un-amplified) signal.
1693
+ // This is the "true" energy of the audio, unaffected by AGC.
1694
+ // VoiceChat sends this to the backend so VAD can use it instead of
1695
+ // computing RMS on the AGC-amplified PCM (which inflates silence).
1696
+ let preAgcRms = 0;
1697
+ for (let i = 0; i < pcmFloat.length; i++) {
1698
+ preAgcRms += pcmFloat[i] * pcmFloat[i];
1699
+ }
1700
+ preAgcRms = Math.sqrt(preAgcRms / (pcmFloat.length || 1)) * 0x7FFF;
1701
+
1702
+ // AGC
1703
+ if (agcEnabledFn()) {
1704
+ let peak = 0;
1705
+ for (let i = 0; i < pcmFloat.length; i++) {
1706
+ const abs = Math.abs(pcmFloat[i]);
1707
+ if (abs > peak) peak = abs;
1708
+ }
1709
+ if (peak * agcGain > AGC_TARGET) {
1710
+ agcGain = Math.max(1.0, agcGain * (1 - AGC_RELEASE));
1711
+ } else if (peak > 0.0001 && peak * agcGain < AGC_TARGET) {
1712
+ const desiredGain = AGC_TARGET / peak;
1713
+ agcGain += (Math.min(desiredGain, AGC_MAX_GAIN) - agcGain) * AGC_ATTACK;
1714
+ }
1715
+ } else {
1716
+ agcGain = 1.0;
1717
+ }
1718
+
1719
+ // Apply AGC and convert Float32 [-1, 1] → Int16 S16LE
1720
+ const pcm16 = new Int16Array(pcmFloat.length);
1721
+ for (let i = 0; i < pcmFloat.length; i++) {
1722
+ const s = Math.max(-1, Math.min(1, pcmFloat[i] * agcGain));
1723
+ pcm16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
1724
+ }
1725
+
1726
+ onPcmData(pcm16, preAgcRms);
1727
+ };
1728
+
1729
+ log('增益: ' + (gainNode.gain.value * 100).toFixed(0) + '%, AGC 最大 ' + AGC_MAX_GAIN + 'x');
1730
+
1731
+ return { audioContext, source, gainNode, analyser, processor, nativeSR, targetSR, getAgcGain: () => agcGain };
1732
+ }
1733
+
1734
+ // ============================================================
1735
+ // ASR Test Dialog
1736
+ // ============================================================
1737
+ let asrTestWs = null;
1738
+ let asrTestMediaStream = null;
1739
+ let asrTestAudioContext = null;
1740
+ let asrTestRecording = false;
1741
+ let asrTestPcmChunks = [];
1742
+ let asrTestPlaybackCtx = null;
1743
+ let asrTestAnimId = null;
1744
+ let asrTestTimerId = null;
1745
+ let asrTestStartTime = 0;
1746
+ let asrTestAnalyser = null;
1747
+ let asrTestWaveHistory = [];
1748
+ let asrTestMuted = false;
1749
+ let asrTestUserGainNode = null;
1750
+ let asrTestTargetSR = 16000; // target sample rate for current recording
1751
+
1752
+ function openAsrTest() {
1753
+ const overlay = document.getElementById('asr-test-overlay');
1754
+ if (!overlay) return;
1755
+ overlay.classList.remove('hidden');
1756
+ _setAsrTestText('');
1757
+ _setAsrTestStatus('idle');
1758
+ _asrLog('clear');
1759
+ _asrStatsShow(false);
1760
+ _asrPlaybackShow(false);
1761
+ _stopWaveform();
1762
+ _stopRecordingTimer();
1763
+ asrTestWaveHistory = [];
1764
+ const timerEl = document.getElementById('asr-test-timer');
1765
+ if (timerEl) timerEl.textContent = '';
1766
+ // Restore gain slider
1767
+ const gainSlider = document.getElementById('asr-mic-gain');
1768
+ const gainVal = document.getElementById('asr-mic-gain-val');
1769
+ const savedGain = localStorage.getItem('asr-mic-gain') || '100';
1770
+ if (gainSlider) gainSlider.value = savedGain;
1771
+ if (gainVal) gainVal.textContent = savedGain + '%';
1772
+ // Restore mute state
1773
+ asrTestMuted = false;
1774
+ const muteBtn = document.getElementById('asr-mute-btn');
1775
+ if (muteBtn) { muteBtn.classList.remove('muted'); }
1776
+ // Restore resource_id from config
1777
+ _loadAsrProvider();
1778
+ // Enumerate microphones
1779
+ _enumMicrophones();
1780
+ }
1781
+
1782
+ // Engine options per ASR provider
1783
+ const ASR_ENGINE_OPTIONS = {
1784
+ volcengine: [
1785
+ { value: 'volc.seedasr.sauc.duration', label: 'seedasr (推荐)' },
1786
+ { value: 'volc.bigasr.sauc.duration', label: 'bigasr' },
1787
+ { value: 'volc.asr.sauc.duration', label: '标准 asr' },
1788
+ ],
1789
+ tencent: [
1790
+ { value: '16k_zh_large', label: '普方英大模型 (推荐)' },
1791
+ { value: '16k_zh_en', label: '中英粤+方言大模型' },
1792
+ { value: '16k_zh', label: '中文通用' },
1793
+ { value: '16k_en', label: '英文通用' },
1794
+ { value: '8k_zh_large', label: '电话大模型 (8k)' },
1795
+ { value: '16k_multi_lang', label: '多语种大模型' },
1796
+ ],
1797
+ xunfei: [
1798
+ { value: 'iat', label: '讯飞听写' },
1799
+ ],
1800
+ whisper: [
1801
+ { value: 'whisper-1', label: 'Whisper' },
1802
+ ],
1803
+ };
1804
+
1805
+ function _updateEngineOptions(provider) {
1806
+ const sel = document.getElementById('asr-test-resid');
1807
+ if (!sel) return;
1808
+ const options = ASR_ENGINE_OPTIONS[provider] || [];
1809
+ sel.innerHTML = '';
1810
+ for (const opt of options) {
1811
+ const el = document.createElement('option');
1812
+ el.value = opt.value;
1813
+ el.textContent = opt.label;
1814
+ sel.appendChild(el);
1815
+ }
1816
+ }
1817
+
1818
+ async function _loadAsrProvider() {
1819
+ const provSel = document.getElementById('asr-test-provider');
1820
+ const resSel = document.getElementById('asr-test-resid');
1821
+ if (!provSel || !resSel) return;
1822
+
1823
+ try {
1824
+ const cfgData = await API.get('/api/config');
1825
+ const provider = cfgData?.asr?.provider || 'whisper';
1826
+
1827
+ // Select current provider
1828
+ for (const opt of provSel.options) {
1829
+ if (opt.value === provider) { opt.selected = true; break; }
1830
+ }
1831
+
1832
+ // Populate engine list for this provider
1833
+ _updateEngineOptions(provider);
1834
+
1835
+ // Select the configured engine value
1836
+ let currentVal = '';
1837
+ if (provider === 'volcengine') {
1838
+ currentVal = cfgData?.asr?.volcengine?.resource_id || '';
1839
+ } else if (provider === 'tencent') {
1840
+ currentVal = cfgData?.asr?.tencent?.engine_model_type || '';
1841
+ }
1842
+ if (currentVal) {
1843
+ for (const opt of resSel.options) {
1844
+ if (opt.value === currentVal) { opt.selected = true; break; }
1845
+ }
1846
+ }
1847
+ } catch (e) { /* ignore */ }
1848
+ }
1849
+
1850
+ async function _enumMicrophones() {
1851
+ const select = document.getElementById('asr-test-mic');
1852
+ if (!select) return;
1853
+ select.innerHTML = '<option value="">检测麦克风中...</option>';
1854
+ try {
1855
+ // Need a temporary getUserMedia to get device labels (browser requires permission first)
1856
+ let tempStream = null;
1857
+ try {
1858
+ tempStream = await navigator.mediaDevices.getUserMedia({ audio: true });
1859
+ } catch (e) {
1860
+ // Permission denied - try enumerating anyway (labels may be empty)
1861
+ }
1862
+ const devices = await navigator.mediaDevices.enumerateDevices();
1863
+ let mics = devices.filter(d => d.kind === 'audioinput');
1864
+ if (tempStream) tempStream.getTracks().forEach(t => t.stop());
1865
+
1866
+ // Filter out Windows aliases
1867
+ mics = mics.filter(d => d.deviceId !== 'default' && d.deviceId !== 'communications');
1868
+
1869
+ select.innerHTML = '';
1870
+ if (mics.length === 0) {
1871
+ select.innerHTML = '<option value="">未找到麦克风</option>';
1872
+ return;
1873
+ }
1874
+ const savedMic = localStorage.getItem('asr-mic-deviceId') || '';
1875
+ let realMicIdx = -1;
1876
+ let savedIdx = -1;
1877
+ mics.forEach((d, i) => {
1878
+ const opt = document.createElement('option');
1879
+ opt.value = d.deviceId;
1880
+ const label = d.label || ('麦克风 ' + (i + 1));
1881
+ const isVirtual = /virtual|远程|remote|todesk/i.test(label);
1882
+ opt.textContent = label + (isVirtual ? ' (虚拟)' : '');
1883
+ if (d.deviceId === savedMic) savedIdx = i;
1884
+ if (!isVirtual && realMicIdx === -1) realMicIdx = i;
1885
+ select.appendChild(opt);
1886
+ });
1887
+ // Priority: saved > first real > first
1888
+ select.selectedIndex = savedIdx >= 0 ? savedIdx : (realMicIdx >= 0 ? realMicIdx : 0);
1889
+ } catch (err) {
1890
+ select.innerHTML = '<option value="">枚举设备失败</option>';
1891
+ }
1892
+ }
1893
+
1894
+ function closeAsrTest() {
1895
+ stopAsrRecording();
1896
+ const overlay = document.getElementById('asr-test-overlay');
1897
+ if (overlay) overlay.classList.add('hidden');
1898
+ }
1899
+
1900
+ function _setAsrTestStatus(status) {
1901
+ const startBtn = document.getElementById('asr-test-start');
1902
+ const stopBtn = document.getElementById('asr-test-stop');
1903
+ const statusEl = document.getElementById('asr-test-status');
1904
+
1905
+ if (status === 'idle') {
1906
+ if (startBtn) { startBtn.disabled = false; startBtn.style.display = ''; }
1907
+ if (stopBtn) stopBtn.style.display = 'none';
1908
+ if (statusEl) { statusEl.textContent = '点击"开始录音"后对麦克风说话'; statusEl.classList.remove('recording'); }
1909
+ } else if (status === 'connecting') {
1910
+ if (startBtn) startBtn.disabled = true;
1911
+ if (statusEl) statusEl.textContent = '正在连接...';
1912
+ } else if (status === 'recording') {
1913
+ if (startBtn) startBtn.style.display = 'none';
1914
+ if (stopBtn) { stopBtn.style.display = ''; stopBtn.disabled = false; }
1915
+ if (statusEl) { statusEl.textContent = '录音中...'; statusEl.classList.add('recording'); }
1916
+ } else if (status === 'processing') {
1917
+ if (stopBtn) stopBtn.disabled = true;
1918
+ if (statusEl) { statusEl.textContent = '等待识别结果...'; statusEl.classList.remove('recording'); }
1919
+ } else if (status === 'error') {
1920
+ if (startBtn) { startBtn.disabled = false; startBtn.style.display = ''; }
1921
+ if (stopBtn) stopBtn.style.display = 'none';
1922
+ if (statusEl) statusEl.classList.remove('recording');
1923
+ }
1924
+ }
1925
+
1926
+ function _setAsrTestText(text) {
1927
+ const el = document.getElementById('asr-test-result');
1928
+ if (el) {
1929
+ el.textContent = text || '';
1930
+ // Auto-scroll the result area to show latest content
1931
+ const area = el.closest('.asr-test-result-area');
1932
+ if (area) area.scrollTop = area.scrollHeight;
1933
+ }
1934
+ }
1935
+
1936
+ // --- Stats display ---
1937
+ function _asrStatsShow(show) {
1938
+ const el = document.getElementById('asr-test-stats');
1939
+ if (el) el.style.display = show ? 'flex' : 'none';
1940
+ }
1941
+ function _asrStatsUpdate(data) {
1942
+ const durEl = document.getElementById('asr-stat-duration');
1943
+ const bytesEl = document.getElementById('asr-stat-bytes');
1944
+ const pktsEl = document.getElementById('asr-stat-packets');
1945
+ const bwEl = document.getElementById('asr-stat-bw');
1946
+ const dur = data.audio_duration || 0;
1947
+ const bytes = data.audio_bytes || 0;
1948
+ if (durEl) durEl.textContent = dur + 's';
1949
+ if (bytesEl) bytesEl.textContent = (bytes / 1024).toFixed(0) + ' KB';
1950
+ if (pktsEl) pktsEl.textContent = (data.asr_packets_sent || 0) + ' 包';
1951
+ if (bwEl) bwEl.textContent = dur > 0 ? ((bytes * 8 / dur / 1000).toFixed(0) + ' kbps') : '0 kbps';
1952
+ }
1953
+
1954
+ // --- Log area ---
1955
+ function _asrLog(msg) {
1956
+ const el = document.getElementById('asr-test-log');
1957
+ if (!el) return;
1958
+ if (msg === 'clear') {
1959
+ el.textContent = '';
1960
+ el.style.display = 'none';
1961
+ return;
1962
+ }
1963
+ el.style.display = '';
1964
+ const ts = new Date().toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
1965
+ el.textContent += `[${ts}] ${msg}\n`;
1966
+ el.scrollTop = el.scrollHeight;
1967
+ }
1968
+
1969
+ // --- Playback ---
1970
+ function _asrPlaybackShow(show) {
1971
+ const el = document.getElementById('asr-test-playback');
1972
+ if (el) el.style.display = show ? 'flex' : 'none';
1973
+ }
1974
+
1975
+ function _playPcmChunks(chunks, btn, label) {
1976
+ if (!chunks.length) {
1977
+ showToast('没有录音数据', 'error');
1978
+ return;
1979
+ }
1980
+ let totalLen = 0;
1981
+ for (const c of chunks) totalLen += c.length;
1982
+ const merged = new Int16Array(totalLen);
1983
+ let off = 0;
1984
+ for (const c of chunks) { merged.set(c, off); off += c.length; }
1985
+ const float32 = new Float32Array(merged.length);
1986
+ for (let i = 0; i < merged.length; i++) float32[i] = merged[i] / 32768.0;
1987
+ let maxAbs = 0;
1988
+ for (let i = 0; i < float32.length; i++) {
1989
+ const abs = Math.abs(float32[i]);
1990
+ if (abs > maxAbs) maxAbs = abs;
1991
+ }
1992
+ if (maxAbs > 0.0001 && maxAbs < 0.5) {
1993
+ const gain = 0.9 / maxAbs;
1994
+ for (let i = 0; i < float32.length; i++) {
1995
+ float32[i] = Math.max(-1, Math.min(1, float32[i] * gain));
1996
+ }
1997
+ }
1998
+ try {
1999
+ if (asrTestPlaybackCtx) { try { asrTestPlaybackCtx.close(); } catch(e){} }
2000
+ const ctx = new (window.AudioContext || window.webkitAudioContext)();
2001
+ asrTestPlaybackCtx = ctx;
2002
+ const buf = ctx.createBuffer(1, float32.length, asrTestTargetSR);
2003
+ buf.copyToChannel(float32, 0);
2004
+ const src = ctx.createBufferSource();
2005
+ src.buffer = buf;
2006
+ src.connect(ctx.destination);
2007
+ src.start();
2008
+ const durSec = (float32.length / asrTestTargetSR).toFixed(1);
2009
+ if (btn) { btn.textContent = '播放中...'; btn.disabled = true; }
2010
+ src.onended = () => {
2011
+ if (btn) { btn.textContent = label; btn.disabled = false; }
2012
+ };
2013
+ } catch (err) {
2014
+ showToast('回放失败: ' + err.message, 'error');
2015
+ if (btn) { btn.textContent = label; btn.disabled = false; }
2016
+ }
2017
+ }
2018
+
2019
+ function playAsrRecording() {
2020
+ _playPcmChunks(asrTestPcmChunks, document.getElementById('asr-test-play'), '回放录音');
2021
+ }
2022
+
2023
+ // --- Waveform visualization (scrolling timeline) ---
2024
+ function _drawWaveform() {
2025
+ if (!asrTestAnalyser) return;
2026
+ asrTestAnimId = requestAnimationFrame(_drawWaveform);
2027
+
2028
+ const canvas = document.getElementById('asr-test-canvas');
2029
+ if (!canvas) return;
2030
+ const ctx = canvas.getContext('2d');
2031
+ const W = canvas.width;
2032
+ const H = canvas.height;
2033
+
2034
+ // Sample current RMS from time-domain data
2035
+ const bufLen = asrTestAnalyser.frequencyBinCount;
2036
+ const timeData = new Uint8Array(bufLen);
2037
+ asrTestAnalyser.getByteTimeDomainData(timeData);
2038
+ let sumSq = 0;
2039
+ for (let i = 0; i < timeData.length; i++) {
2040
+ const v = (timeData[i] - 128) / 128.0;
2041
+ sumSq += v * v;
2042
+ }
2043
+ const rms = Math.sqrt(sumSq / timeData.length);
2044
+
2045
+ // Push to history (keep enough columns to fill canvas width)
2046
+ const barW = 4;
2047
+ const gap = 1;
2048
+ const maxBars = Math.ceil(W / (barW + gap));
2049
+ asrTestWaveHistory.push(rms);
2050
+ if (asrTestWaveHistory.length > maxBars) {
2051
+ asrTestWaveHistory.shift();
2052
+ }
2053
+
2054
+ // Clear
2055
+ ctx.fillStyle = '#0f172a';
2056
+ ctx.fillRect(0, 0, W, H);
2057
+
2058
+ // Draw scrolling bars from right to left (newest on the right)
2059
+ const len = asrTestWaveHistory.length;
2060
+ for (let i = 0; i < len; i++) {
2061
+ const val = asrTestWaveHistory[i];
2062
+ // Map RMS 0..0.5 → 0..H (clamp)
2063
+ const norm = Math.min(1.0, val / 0.35);
2064
+ const barH = Math.max(2, norm * H);
2065
+ const x = W - (len - i) * (barW + gap);
2066
+ if (x < -barW) continue;
2067
+
2068
+ // Color: green when quiet, yellow mid, red loud
2069
+ let r, g, b;
2070
+ if (norm < 0.4) {
2071
+ r = Math.floor(norm / 0.4 * 180);
2072
+ g = 200;
2073
+ b = 80;
2074
+ } else if (norm < 0.75) {
2075
+ const t = (norm - 0.4) / 0.35;
2076
+ r = 180 + Math.floor(t * 75);
2077
+ g = 200 - Math.floor(t * 80);
2078
+ b = 80 - Math.floor(t * 40);
2079
+ } else {
2080
+ r = 255;
2081
+ g = Math.floor((1 - (norm - 0.75) / 0.25) * 120);
2082
+ b = 40;
2083
+ }
2084
+ ctx.fillStyle = `rgb(${r},${g},${b})`;
2085
+ ctx.fillRect(x, (H - barH) / 2, barW, barH);
2086
+ }
2087
+
2088
+ // Center line (subtle)
2089
+ ctx.strokeStyle = 'rgba(100,116,139,0.3)';
2090
+ ctx.lineWidth = 1;
2091
+ ctx.beginPath();
2092
+ ctx.moveTo(0, H / 2);
2093
+ ctx.lineTo(W, H / 2);
2094
+ ctx.stroke();
2095
+
2096
+ // Update volume bar + dB
2097
+ const dB = rms > 0 ? 20 * Math.log10(rms) : -100;
2098
+ const pct = Math.min(100, Math.max(0, (dB + 60) / 60 * 100));
2099
+
2100
+ const volBar = document.getElementById('asr-test-vol-bar');
2101
+ const volDb = document.getElementById('asr-test-vol-db');
2102
+ if (volBar) volBar.style.width = pct + '%';
2103
+ if (volDb) volDb.textContent = (dB > -100 ? dB.toFixed(0) : '--') + ' dB';
2104
+ }
2105
+
2106
+ function _stopWaveform() {
2107
+ if (asrTestAnimId) {
2108
+ cancelAnimationFrame(asrTestAnimId);
2109
+ asrTestAnimId = null;
2110
+ }
2111
+ const canvas = document.getElementById('asr-test-canvas');
2112
+ if (canvas) {
2113
+ const ctx = canvas.getContext('2d');
2114
+ ctx.fillStyle = '#0f172a';
2115
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
2116
+ ctx.strokeStyle = '#334155';
2117
+ ctx.lineWidth = 1;
2118
+ ctx.beginPath();
2119
+ ctx.moveTo(0, canvas.height / 2);
2120
+ ctx.lineTo(canvas.width, canvas.height / 2);
2121
+ ctx.stroke();
2122
+ }
2123
+ const volBar = document.getElementById('asr-test-vol-bar');
2124
+ const volDb = document.getElementById('asr-test-vol-db');
2125
+ if (volBar) volBar.style.width = '0%';
2126
+ if (volDb) volDb.textContent = '-- dB';
2127
+ asrTestAnalyser = null;
2128
+ }
2129
+
2130
+ // --- Recording timer ---
2131
+ function _startRecordingTimer() {
2132
+ asrTestStartTime = Date.now();
2133
+ const timerEl = document.getElementById('asr-test-timer');
2134
+ if (timerEl) timerEl.textContent = '00:00';
2135
+ asrTestTimerId = setInterval(() => {
2136
+ const elapsed = Math.floor((Date.now() - asrTestStartTime) / 1000);
2137
+ const mins = String(Math.floor(elapsed / 60)).padStart(2, '0');
2138
+ const secs = String(elapsed % 60).padStart(2, '0');
2139
+ if (timerEl) timerEl.textContent = mins + ':' + secs;
2140
+ }, 500);
2141
+ }
2142
+
2143
+ function _stopRecordingTimer() {
2144
+ if (asrTestTimerId) {
2145
+ clearInterval(asrTestTimerId);
2146
+ asrTestTimerId = null;
2147
+ }
2148
+ }
2149
+
2150
+ async function startAsrRecording() {
2151
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
2152
+ showToast('浏览器不支持麦克风访问,请使用 localhost 或 HTTPS 访问', 'error');
2153
+ _setAsrTestStatus('error');
2154
+ _setAsrTestText('麦克风 API 不可用。\n请用 localhost 或 HTTPS 访问。');
2155
+ return;
2156
+ }
2157
+
2158
+ // Reset state
2159
+ asrTestPcmChunks = [];
2160
+ _asrLog('clear');
2161
+ _asrStatsShow(false);
2162
+ _asrPlaybackShow(false);
2163
+ _setAsrTestText('');
2164
+
2165
+ // Read controls
2166
+ const micSelect = document.getElementById('asr-test-mic');
2167
+ const selectedDeviceId = micSelect ? micSelect.value : '';
2168
+ // Save mic selection
2169
+ if (selectedDeviceId) localStorage.setItem('asr-mic-deviceId', selectedDeviceId);
2170
+
2171
+ // Read ASR test params
2172
+ const providerSel = document.getElementById('asr-test-provider');
2173
+ const resIdSel = document.getElementById('asr-test-resid');
2174
+ const srSel = document.getElementById('asr-test-samplerate');
2175
+ const testProvider = providerSel ? providerSel.value : '';
2176
+ const testResId = resIdSel ? resIdSel.value : '';
2177
+ const testSampleRate = srSel ? parseInt(srSel.value) : 16000;
2178
+
2179
+ try {
2180
+ asrTestMediaStream = await navigator.mediaDevices.getUserMedia({ audio: _getMicConstraints(selectedDeviceId) });
2181
+ const track = asrTestMediaStream.getAudioTracks()[0];
2182
+ _asrLog('麦克风: ' + (track.label || '未知设备'));
2183
+ } catch (err) {
2184
+ showToast('无法访问麦克风: ' + err.message, 'error');
2185
+ _setAsrTestStatus('error');
2186
+ _setAsrTestText('麦克风权限被拒绝或不可用');
2187
+ return;
2188
+ }
2189
+
2190
+ _setAsrTestStatus('connecting');
2191
+
2192
+ // Open WebSocket
2193
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
2194
+ const wsUrl = `${protocol}//${location.host}/api/ws/asr-test`;
2195
+ const providerNames = { volcengine: '火山引擎', tencent: '腾讯云', xunfei: '讯飞', whisper: 'Whisper' };
2196
+ _asrLog('引擎: ' + (providerNames[testProvider] || testProvider) + ' / ' + testResId + ', 采样率: ' + testSampleRate + ' Hz');
2197
+
2198
+ try {
2199
+ asrTestWs = new WebSocket(wsUrl);
2200
+ } catch (err) {
2201
+ showToast('WebSocket 连接失败: ' + err.message, 'error');
2202
+ _stopAsrMediaStream();
2203
+ _setAsrTestStatus('error');
2204
+ return;
2205
+ }
2206
+
2207
+ // Send config right after WebSocket opens
2208
+ asrTestWs.onopen = () => {
2209
+ asrTestWs.send(JSON.stringify({
2210
+ type: 'config',
2211
+ provider: testProvider,
2212
+ resource_id: testResId,
2213
+ sample_rate: testSampleRate,
2214
+ }));
2215
+ };
2216
+
2217
+ asrTestWs.onmessage = (event) => {
2218
+ try {
2219
+ const data = JSON.parse(event.data);
2220
+ if (data.type === 'ready') {
2221
+ _asrLog('服务端就绪, ASR: ' + (data.provider || '?') + ', sr: ' + (data.sample_rate || '?'));
2222
+ _asrStatsShow(true);
2223
+ _setAsrTestStatus('recording');
2224
+ _startAudioCapture(data.sample_rate || 16000);
2225
+ } else if (data.type === 'stats') {
2226
+ _asrStatsUpdate(data);
2227
+ if (data.getting_final) {
2228
+ _asrLog('正在获取最终识别结果...');
2229
+ }
2230
+ } else if (data.type === 'interim') {
2231
+ _setAsrTestText(data.text);
2232
+ } else if (data.type === 'final') {
2233
+ _setAsrTestText(data.text || '(未识别到内容)');
2234
+ _setAsrTestStatus('idle');
2235
+ _cleanupAsrTest(true); // keep PCM for playback
2236
+ } else if (data.type === 'error') {
2237
+ _setAsrTestText('错误: ' + (data.message || '未知错误'));
2238
+ _asrLog('错误: ' + (data.message || '未知错误'));
2239
+ _setAsrTestStatus('error');
2240
+ _cleanupAsrTest(true);
2241
+ }
2242
+ } catch (e) { /* ignore parse errors */ }
2243
+ };
2244
+
2245
+ asrTestWs.onerror = () => {
2246
+ showToast('ASR WebSocket 连接错误', 'error');
2247
+ _asrLog('WebSocket 连接错误');
2248
+ _setAsrTestStatus('error');
2249
+ _cleanupAsrTest(false);
2250
+ };
2251
+
2252
+ asrTestWs.onclose = () => {
2253
+ if (asrTestRecording) {
2254
+ _asrLog('WebSocket 连接关闭');
2255
+ _setAsrTestStatus('idle');
2256
+ _cleanupAsrTest(true);
2257
+ }
2258
+ };
2259
+ }
2260
+
2261
+ function _startAudioCapture(targetSR) {
2262
+ if (!asrTestMediaStream) return;
2263
+ targetSR = targetSR || 16000;
2264
+ asrTestTargetSR = targetSR;
2265
+
2266
+ _setAsrTestStatus('recording');
2267
+ asrTestRecording = true;
2268
+ asrTestWaveHistory = [];
2269
+ _startRecordingTimer();
2270
+
2271
+ let sendCount = 0;
2272
+ let totalSamples = 0;
2273
+
2274
+ const pipeline = _createAudioPipeline({
2275
+ stream: asrTestMediaStream,
2276
+ targetSR,
2277
+ log: _asrLog,
2278
+ onPcmData: (pcm16) => {
2279
+ if (!asrTestRecording || !asrTestWs || asrTestWs.readyState !== WebSocket.OPEN) return;
2280
+ asrTestPcmChunks.push(new Int16Array(pcm16));
2281
+ asrTestWs.send(pcm16.buffer);
2282
+ sendCount++;
2283
+ totalSamples += pcm16.length;
2284
+ },
2285
+ });
2286
+
2287
+ asrTestAudioContext = pipeline.audioContext;
2288
+ asrTestUserGainNode = pipeline.gainNode;
2289
+ asrTestAnalyser = pipeline.analyser;
2290
+ asrTestAudioContext._processor = pipeline.processor;
2291
+ asrTestAudioContext._source = pipeline.source;
2292
+ asrTestAudioContext._agcGain = pipeline.getAgcGain;
2293
+ asrTestAudioContext._targetSR = targetSR;
2294
+ _drawWaveform();
2295
+ }
2296
+
2297
+ async function stopAsrRecording() {
2298
+ if (!asrTestRecording) return;
2299
+ asrTestRecording = false;
2300
+ _setAsrTestStatus('processing');
2301
+ _stopRecordingTimer();
2302
+ _stopWaveform();
2303
+
2304
+ const targetSR = (asrTestAudioContext && asrTestAudioContext._targetSR) || 16000;
2305
+ const totalSamples = asrTestPcmChunks.reduce((sum, c) => sum + c.length, 0);
2306
+ const durSec = (totalSamples / targetSR).toFixed(1);
2307
+ const finalGain = asrTestAudioContext && asrTestAudioContext._agcGain ? asrTestAudioContext._agcGain().toFixed(1) : '?';
2308
+ _asrLog('停止录音, 共 ' + durSec + 's (' + (totalSamples * 2 / 1024).toFixed(0) + ' KB), AGC: ' + finalGain + 'x');
2309
+
2310
+ if (asrTestWs && asrTestWs.readyState === WebSocket.OPEN) {
2311
+ asrTestWs.send(JSON.stringify({ type: 'stop' }));
2312
+ } else {
2313
+ _setAsrTestStatus('idle');
2314
+ _cleanupAsrTest(true);
2315
+ }
2316
+ }
2317
+
2318
+ function _stopAsrMediaStream() {
2319
+ if (asrTestMediaStream) {
2320
+ asrTestMediaStream.getTracks().forEach((t) => t.stop());
2321
+ asrTestMediaStream = null;
2322
+ }
2323
+ }
2324
+
2325
+ function _cleanupAsrTest(keepPcm) {
2326
+ asrTestRecording = false;
2327
+ _stopRecordingTimer();
2328
+ _stopWaveform();
2329
+ if (asrTestAudioContext) {
2330
+ try {
2331
+ if (asrTestAudioContext._processor) asrTestAudioContext._processor.disconnect();
2332
+ if (asrTestAudioContext._source) asrTestAudioContext._source.disconnect();
2333
+ asrTestAudioContext.close();
2334
+ } catch (e) { /* ignore */ }
2335
+ asrTestAudioContext = null;
2336
+ }
2337
+ _stopAsrMediaStream();
2338
+ if (asrTestWs) {
2339
+ try { asrTestWs.close(); } catch (e) { /* ignore */ }
2340
+ asrTestWs = null;
2341
+ }
2342
+ // Show playback button if we have recorded data
2343
+ if (keepPcm && asrTestPcmChunks.length > 0) {
2344
+ _asrPlaybackShow(true);
2345
+ const totalSamples = asrTestPcmChunks.reduce((s, c) => s + c.length, 0);
2346
+ const durEl = document.getElementById('asr-test-play-dur');
2347
+ if (durEl) durEl.textContent = '(' + (totalSamples / asrTestTargetSR).toFixed(1) + '秒)';
2348
+ }
2349
+ }
2350
+
2351
+ async function _restartAsrSession() {
2352
+ if (!asrTestRecording) return;
2353
+ // Silently tear down current session (don't keep PCM, don't show playback)
2354
+ asrTestRecording = false;
2355
+ _stopRecordingTimer();
2356
+ _stopWaveform();
2357
+ if (asrTestAudioContext) {
2358
+ try {
2359
+ if (asrTestAudioContext._processor) asrTestAudioContext._processor.disconnect();
2360
+ if (asrTestAudioContext._source) asrTestAudioContext._source.disconnect();
2361
+ asrTestAudioContext.close();
2362
+ } catch (e) { /* ignore */ }
2363
+ asrTestAudioContext = null;
2364
+ }
2365
+ _stopAsrMediaStream();
2366
+ if (asrTestWs) {
2367
+ try { asrTestWs.close(); } catch (e) { /* ignore */ }
2368
+ asrTestWs = null;
2369
+ }
2370
+ // Immediately start a new session with current UI settings
2371
+ _asrLog('--- 切换参数,重新连接 ---');
2372
+ await startAsrRecording();
2373
+ }
2374
+
2375
+
2376
+
2377
+ // ============================================================
2378
+ // Audio Diagnostic Tool
2379
+ // ============================================================
2380
+ let _diagState = { running: false, stream: null, audioCtx: null, mediaRec: null, spChunks: [], mrBlob: null };
2381
+
2382
+ function openAudioDiag() {
2383
+ const overlay = document.getElementById('audio-diag-overlay');
2384
+ if (overlay) overlay.classList.remove('hidden');
2385
+ _diagLog('clear');
2386
+ _diagLog('=== 音频采集诊断工具 ===');
2387
+ _diagLog('点击"开始诊断"自动测试各个音频 API');
2388
+ }
2389
+
2390
+ function closeAudioDiag() {
2391
+ stopAudioDiag();
2392
+ const overlay = document.getElementById('audio-diag-overlay');
2393
+ if (overlay) overlay.classList.add('hidden');
2394
+ }
2395
+
2396
+ function _diagLog(msg) {
2397
+ const el = document.getElementById('audio-diag-log');
2398
+ if (!el) return;
2399
+ if (msg === 'clear') { el.textContent = ''; return; }
2400
+ const ts = new Date().toLocaleTimeString('zh-CN', { hour12: false });
2401
+ el.textContent += '[' + ts + '] ' + msg + '\n';
2402
+ el.scrollTop = el.scrollHeight;
2403
+ }
2404
+
2405
+ function _diagShowBtn(id, show) {
2406
+ const el = document.getElementById(id);
2407
+ if (el) el.style.display = show ? '' : 'none';
2408
+ }
2409
+
2410
+ async function runAudioDiag() {
2411
+ if (_diagState.running) return;
2412
+ _diagState.running = true;
2413
+ _diagState.spChunks = [];
2414
+ _diagState.mrBlob = null;
2415
+ _diagShowBtn('audio-diag-run', false);
2416
+ _diagShowBtn('audio-diag-stop', true);
2417
+ _diagShowBtn('audio-diag-play-sp', false);
2418
+ _diagShowBtn('audio-diag-play-mr', false);
2419
+ _diagLog('clear');
2420
+ _diagLog('=== 开始音频诊断 ===');
2421
+ _diagLog('UserAgent: ' + navigator.userAgent);
2422
+
2423
+ // ---- Step 0: Enumerate devices ----
2424
+ _diagLog('');
2425
+ _diagLog('【步骤0】枚举音频设备...');
2426
+ try {
2427
+ const devices = await navigator.mediaDevices.enumerateDevices();
2428
+ const audioInputs = devices.filter(d => d.kind === 'audioinput');
2429
+ const audioOutputs = devices.filter(d => d.kind === 'audiooutput');
2430
+ _diagLog(' 音频输入设备 (' + audioInputs.length + '):');
2431
+ audioInputs.forEach((d, i) => {
2432
+ _diagLog(' [' + i + '] ' + (d.label || '(无名称-需先授权)') + ' id=' + d.deviceId.substring(0, 12) + '...');
2433
+ });
2434
+ _diagLog(' 音频输出设备 (' + audioOutputs.length + '):');
2435
+ audioOutputs.forEach((d, i) => {
2436
+ _diagLog(' [' + i + '] ' + (d.label || '(无名称)') + ' id=' + d.deviceId.substring(0, 12) + '...');
2437
+ });
2438
+ if (audioInputs.length === 0) {
2439
+ _diagLog(' ✗ 未找到任何音频输入设备!');
2440
+ _diagLog(' 请检查系统是否有麦克风, 或远程桌面是否已启用音频重定向');
2441
+ }
2442
+ } catch (err) {
2443
+ _diagLog(' 枚举设备失败: ' + err.message);
2444
+ }
2445
+
2446
+ // ---- Step 1: Play test tone (verify speaker works) ----
2447
+ _diagLog('');
2448
+ _diagLog('【步骤1】播放测试音 (验证音频输出)...');
2449
+ try {
2450
+ const toneCtx = new (window.AudioContext || window.webkitAudioContext)();
2451
+ if (toneCtx.state === 'suspended') await toneCtx.resume();
2452
+ const osc = toneCtx.createOscillator();
2453
+ const gain = toneCtx.createGain();
2454
+ osc.frequency.value = 440;
2455
+ gain.gain.value = 0.3;
2456
+ osc.connect(gain);
2457
+ gain.connect(toneCtx.destination);
2458
+ osc.start();
2459
+ _diagLog(' 正在播放 440Hz 测试音 1 秒... 你应该能听到"嘟"的一声');
2460
+ await new Promise(r => setTimeout(r, 1000));
2461
+ osc.stop();
2462
+ toneCtx.close();
2463
+ _diagLog(' 测试音播放完毕');
2464
+ } catch (err) {
2465
+ _diagLog(' 播放测试音失败: ' + err.message);
2466
+ }
2467
+
2468
+ if (!_diagState.running) return;
2469
+
2470
+ // ---- Step 2: getUserMedia ----
2471
+ _diagLog('');
2472
+ _diagLog('【步骤2】获取麦克风 (getUserMedia)...');
2473
+ _diagLog(' isSecureContext: ' + window.isSecureContext);
2474
+ _diagLog(' protocol: ' + location.protocol);
2475
+
2476
+ let stream;
2477
+ try {
2478
+ // Try to use the non-virtual mic if available
2479
+ const allDevices = await navigator.mediaDevices.enumerateDevices();
2480
+ const mics = allDevices.filter(d => d.kind === 'audioinput');
2481
+ let preferredId = '';
2482
+ for (const m of mics) {
2483
+ if (!/virtual|todesk|远程|remote/i.test(m.label) && m.deviceId !== 'default' && m.deviceId !== 'communications') {
2484
+ preferredId = m.deviceId;
2485
+ _diagLog(' 优先使用真实麦克风: ' + m.label);
2486
+ break;
2487
+ }
2488
+ }
2489
+ const audioOpts = { channelCount: 1, echoCancellation: true, noiseSuppression: true };
2490
+ if (preferredId) audioOpts.deviceId = { exact: preferredId };
2491
+ stream = await navigator.mediaDevices.getUserMedia({ audio: audioOpts });
2492
+ _diagState.stream = stream;
2493
+ const track = stream.getAudioTracks()[0];
2494
+ const settings = track.getSettings();
2495
+ const caps = track.getCapabilities ? track.getCapabilities() : {};
2496
+ _diagLog(' ✓ 成功! Track: ' + track.label);
2497
+ _diagLog(' readyState: ' + track.readyState + ', enabled: ' + track.enabled + ', muted: ' + track.muted);
2498
+ _diagLog(' Settings: sampleRate=' + (settings.sampleRate || '?') + ', channelCount=' + (settings.channelCount || '?'));
2499
+ _diagLog(' deviceId: ' + (settings.deviceId || '?'));
2500
+ if (caps.sampleRate) _diagLog(' Capabilities sampleRate: ' + caps.sampleRate.min + '-' + caps.sampleRate.max);
2501
+ } catch (err) {
2502
+ _diagLog(' ✗ 失败: ' + err.name + ': ' + err.message);
2503
+ if (err.name === 'NotAllowedError') {
2504
+ _diagLog(' 用户拒绝了麦克风权限, 或浏览器策略阻止');
2505
+ } else if (err.name === 'NotFoundError') {
2506
+ _diagLog(' 找不到麦克风设备');
2507
+ } else if (err.name === 'NotReadableError') {
2508
+ _diagLog(' 麦克风设备被占用或无法读取');
2509
+ }
2510
+ _diagState.running = false;
2511
+ _diagShowBtn('audio-diag-run', true);
2512
+ _diagShowBtn('audio-diag-stop', false);
2513
+ return;
2514
+ }
2515
+
2516
+ // ---- Step 3: AudioContext + AnalyserNode ----
2517
+ _diagLog('');
2518
+ _diagLog('【步骤3】创建 AudioContext + AnalyserNode...');
2519
+ let audioCtx;
2520
+ try {
2521
+ audioCtx = new (window.AudioContext || window.webkitAudioContext)();
2522
+ _diagState.audioCtx = audioCtx;
2523
+ _diagLog(' AudioContext 状态: ' + audioCtx.state + ', 采样率: ' + audioCtx.sampleRate + ' Hz');
2524
+ _diagLog(' baseLatency: ' + (audioCtx.baseLatency || '?') + 's, outputLatency: ' + (audioCtx.outputLatency || '?') + 's');
2525
+ if (audioCtx.state === 'suspended') {
2526
+ _diagLog(' AudioContext 被挂起,尝试 resume...');
2527
+ await audioCtx.resume();
2528
+ _diagLog(' resume 后状态: ' + audioCtx.state);
2529
+ }
2530
+ } catch (err) {
2531
+ _diagLog(' ✗ 创建 AudioContext 失败: ' + err.message);
2532
+ _diagState.running = false;
2533
+ return;
2534
+ }
2535
+
2536
+ const source = audioCtx.createMediaStreamSource(stream);
2537
+ const analyser = audioCtx.createAnalyser();
2538
+ analyser.fftSize = 2048;
2539
+ source.connect(analyser);
2540
+
2541
+ // Read analyser for 2 seconds, print raw data samples
2542
+ _diagLog(' 对着麦克风说话! 读取 AnalyserNode 2秒...');
2543
+ let analyserMaxRms = 0;
2544
+ let analyserSamples = 0;
2545
+ const analyserStart = Date.now();
2546
+ while (Date.now() - analyserStart < 2000 && _diagState.running) {
2547
+ const buf = new Uint8Array(analyser.frequencyBinCount);
2548
+ analyser.getByteTimeDomainData(buf);
2549
+ let sumSq = 0;
2550
+ let minVal = 255, maxVal = 0;
2551
+ for (let i = 0; i < buf.length; i++) {
2552
+ if (buf[i] < minVal) minVal = buf[i];
2553
+ if (buf[i] > maxVal) maxVal = buf[i];
2554
+ const v = (buf[i] - 128) / 128.0;
2555
+ sumSq += v * v;
2556
+ }
2557
+ const rms = Math.sqrt(sumSq / buf.length);
2558
+ if (rms > analyserMaxRms) analyserMaxRms = rms;
2559
+ analyserSamples++;
2560
+ // Print every ~500ms
2561
+ if (analyserSamples % 10 === 1) {
2562
+ _diagLog(' 采样#' + analyserSamples + ': min=' + minVal + ' max=' + maxVal + ' RMS=' + rms.toFixed(6) + ' 前8字节=[' + Array.from(buf.slice(0, 8)).join(',') + ']');
2563
+ }
2564
+ await new Promise(r => setTimeout(r, 50));
2565
+ }
2566
+ // Check track state after reading
2567
+ const trackAfter = stream.getAudioTracks()[0];
2568
+ _diagLog(' Track 采集后状态: readyState=' + trackAfter.readyState + ', enabled=' + trackAfter.enabled + ', muted=' + trackAfter.muted);
2569
+ _diagLog(' AnalyserNode 采样 ' + analyserSamples + ' 次, 最大 RMS: ' + analyserMaxRms.toFixed(6));
2570
+ if (analyserMaxRms > 0.01) {
2571
+ _diagLog(' ✓ AnalyserNode 可以读到音频数据');
2572
+ } else if (analyserMaxRms > 0.001) {
2573
+ _diagLog(' △ AnalyserNode 读到非常微弱的信号 (可能是底噪)');
2574
+ } else {
2575
+ _diagLog(' ✗ AnalyserNode 读到的数据接近静音!');
2576
+ }
2577
+
2578
+ if (!_diagState.running) return;
2579
+
2580
+ // ---- Step 4: ScriptProcessorNode ----
2581
+ _diagLog('');
2582
+ _diagLog('【步骤4】ScriptProcessorNode 采集测试 (3秒)...');
2583
+ _diagLog(' 连接方式: source → analyser → processor → destination');
2584
+ _diagLog(' 对着麦克风说话!');
2585
+
2586
+ const processor = audioCtx.createScriptProcessor(4096, 1, 1);
2587
+ let spFrames = 0;
2588
+ let spNonZeroFrames = 0;
2589
+ let spMaxAbs = 0;
2590
+ let spTotalSamples = 0;
2591
+ const spChunks = [];
2592
+
2593
+ processor.onaudioprocess = (e) => {
2594
+ const data = e.inputBuffer.getChannelData(0);
2595
+ spFrames++;
2596
+ spTotalSamples += data.length;
2597
+ let maxInFrame = 0;
2598
+ let hasNonZero = false;
2599
+ for (let i = 0; i < data.length; i++) {
2600
+ const abs = Math.abs(data[i]);
2601
+ if (abs > maxInFrame) maxInFrame = abs;
2602
+ if (abs > 0.0001) hasNonZero = true;
2603
+ }
2604
+ if (hasNonZero) spNonZeroFrames++;
2605
+ if (maxInFrame > spMaxAbs) spMaxAbs = maxInFrame;
2606
+ // Print first few frames
2607
+ if (spFrames <= 3 || spFrames % 10 === 0) {
2608
+ _diagLog(' 帧#' + spFrames + ': len=' + data.length + ' max=' + maxInFrame.toFixed(6) + ' 前4值=[' + data[0].toFixed(6) + ',' + data[1].toFixed(6) + ',' + data[2].toFixed(6) + ',' + data[3].toFixed(6) + ']');
2609
+ }
2610
+ // Save raw float for playback (at native sample rate)
2611
+ const pcm16 = new Int16Array(data.length);
2612
+ for (let i = 0; i < data.length; i++) {
2613
+ const s = Math.max(-1, Math.min(1, data[i]));
2614
+ pcm16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
2615
+ }
2616
+ spChunks.push(pcm16);
2617
+ };
2618
+
2619
+ analyser.connect(processor);
2620
+ processor.connect(audioCtx.destination);
2621
+
2622
+ // Wait 3 seconds
2623
+ const spStart = Date.now();
2624
+ while (Date.now() - spStart < 3000 && _diagState.running) {
2625
+ await new Promise(r => setTimeout(r, 200));
2626
+ }
2627
+
2628
+ processor.disconnect();
2629
+ _diagLog(' ScriptProcessor 总帧数: ' + spFrames + ', 非零帧: ' + spNonZeroFrames + ', 总样本: ' + spTotalSamples);
2630
+ _diagLog(' 最大绝对值: ' + spMaxAbs.toFixed(6));
2631
+ if (spMaxAbs > 0.01) {
2632
+ _diagLog(' ✓ ScriptProcessorNode 采集到音频!');
2633
+ } else if (spFrames === 0) {
2634
+ _diagLog(' ✗ onaudioprocess 从未触发!');
2635
+ } else {
2636
+ _diagLog(' ✗ 采集到的数据全部为零/近零!');
2637
+ }
2638
+ _diagState.spChunks = spChunks;
2639
+ _diagState.spSampleRate = audioCtx.sampleRate;
2640
+ if (spChunks.length > 0) _diagShowBtn('audio-diag-play-sp', true);
2641
+
2642
+ if (!_diagState.running) return;
2643
+
2644
+ // ---- Step 5: MediaRecorder (baseline) ----
2645
+ _diagLog('');
2646
+ _diagLog('【步骤5】MediaRecorder 采集测试 (3秒)...');
2647
+ _diagLog(' 对着麦克风说话!');
2648
+ let mrBlob = null;
2649
+ try {
2650
+ const mrChunks = [];
2651
+ const mimeTypes = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', 'audio/mp4', ''];
2652
+ let selectedMime = '';
2653
+ for (const mt of mimeTypes) {
2654
+ if (!mt || MediaRecorder.isTypeSupported(mt)) {
2655
+ selectedMime = mt;
2656
+ break;
2657
+ }
2658
+ }
2659
+ _diagLog(' MIME: ' + (selectedMime || '(默认)'));
2660
+ const mrOptions = selectedMime ? { mimeType: selectedMime } : {};
2661
+ const mr = new MediaRecorder(stream, mrOptions);
2662
+ _diagState.mediaRec = mr;
2663
+ mr.ondataavailable = (e) => {
2664
+ if (e.data && e.data.size > 0) {
2665
+ mrChunks.push(e.data);
2666
+ _diagLog(' chunk: ' + e.data.size + ' bytes');
2667
+ }
2668
+ };
2669
+ const mrDone = new Promise(resolve => { mr.onstop = () => resolve(); });
2670
+ mr.start(500);
2671
+ _diagLog(' 录音中...');
2672
+ await new Promise(r => setTimeout(r, 3000));
2673
+ if (mr.state === 'recording') mr.stop();
2674
+ await mrDone;
2675
+ mrBlob = new Blob(mrChunks, { type: mrChunks[0]?.type || selectedMime });
2676
+ _diagState.mrBlob = mrBlob;
2677
+ _diagLog(' MediaRecorder: ' + mrChunks.length + ' chunks, 总大小: ' + mrBlob.size + ' bytes');
2678
+ if (mrBlob.size > 1000) {
2679
+ _diagLog(' ✓ MediaRecorder 录制成功!');
2680
+ _diagShowBtn('audio-diag-play-mr', true);
2681
+ } else {
2682
+ _diagLog(' ✗ 录制数据过小');
2683
+ }
2684
+ } catch (err) {
2685
+ _diagLog(' MediaRecorder 失败: ' + err.message);
2686
+ }
2687
+
2688
+ // ---- Summary ----
2689
+ _diagLog('');
2690
+ _diagLog('=== 诊断总结 ===');
2691
+ _diagLog(' getUserMedia: ✓');
2692
+ _diagLog(' AnalyserNode 有信号: ' + (analyserMaxRms > 0.01 ? '✓' : '✗') + ' (RMS=' + analyserMaxRms.toFixed(6) + ')');
2693
+ _diagLog(' ScriptProcessor 有数据: ' + (spMaxAbs > 0.01 ? '✓' : '✗') + ' (max=' + spMaxAbs.toFixed(6) + ', frames=' + spFrames + ')');
2694
+ _diagLog(' MediaRecorder 有数据: ' + (mrBlob && mrBlob.size > 1000 ? '✓ (' + mrBlob.size + 'B)' : '✗'));
2695
+ _diagLog('');
2696
+
2697
+ if (analyserMaxRms > 0.01 && spMaxAbs < 0.001) {
2698
+ _diagLog('⚠ AnalyserNode 有信号但 ScriptProcessor 全零');
2699
+ _diagLog(' → Chrome 的 ScriptProcessorNode bug');
2700
+ _diagLog(' → 建议改用 MediaRecorder 方案采集');
2701
+ } else if (analyserMaxRms < 0.001 && mrBlob && mrBlob.size > 1000) {
2702
+ _diagLog('⚠ Web Audio API 无信号但 MediaRecorder 正常');
2703
+ _diagLog(' → Web Audio API 可能有问题, 建议用 MediaRecorder 方案');
2704
+ } else if (analyserMaxRms < 0.001 && (!mrBlob || mrBlob.size < 1000)) {
2705
+ _diagLog('⚠ 所有方式均无有效音频!');
2706
+ _diagLog(' → 麦克风可能被系统静音、未正确连接、或远程桌面未启用音频重定向');
2707
+ _diagLog(' → 请检查: 系统声音设置 > 输入设备 > 确认麦克风音量非零');
2708
+ _diagLog(' → 如果是远程桌面(RDP/VNC), 请确认音频重定向已启用');
2709
+ }
2710
+
2711
+ _diagLog('');
2712
+ _diagLog('诊断完成。点击回放按钮试听。');
2713
+
2714
+ source.disconnect();
2715
+ _diagState.running = false;
2716
+ _diagShowBtn('audio-diag-run', true);
2717
+ _diagShowBtn('audio-diag-stop', false);
2718
+
2719
+ // Auto-upload diagnostic log to server
2720
+ try {
2721
+ const logEl = document.getElementById('audio-diag-log');
2722
+ const logText = logEl ? logEl.textContent : '';
2723
+ await fetch('/api/audio-diag', {
2724
+ method: 'POST',
2725
+ headers: { 'Content-Type': 'application/json' },
2726
+ body: JSON.stringify({ log: logText, timestamp: new Date().toISOString() })
2727
+ });
2728
+ _diagLog('(诊断日志已自动上传到服务端)');
2729
+ } catch (e) {
2730
+ // silently ignore
2731
+ }
2732
+ }
2733
+
2734
+ function stopAudioDiag() {
2735
+ _diagState.running = false;
2736
+ if (_diagState.mediaRec && _diagState.mediaRec.state === 'recording') {
2737
+ try { _diagState.mediaRec.stop(); } catch (e) {}
2738
+ }
2739
+ if (_diagState.audioCtx) {
2740
+ try { _diagState.audioCtx.close(); } catch (e) {}
2741
+ _diagState.audioCtx = null;
2742
+ }
2743
+ if (_diagState.stream) {
2744
+ _diagState.stream.getTracks().forEach(t => t.stop());
2745
+ _diagState.stream = null;
2746
+ }
2747
+ _diagShowBtn('audio-diag-run', true);
2748
+ _diagShowBtn('audio-diag-stop', false);
2749
+ }
2750
+
2751
+ function playDiagSP() {
2752
+ const chunks = _diagState.spChunks;
2753
+ if (!chunks.length) { showToast('无 ScriptProcessor 数据', 'error'); return; }
2754
+ let totalLen = 0;
2755
+ for (const c of chunks) totalLen += c.length;
2756
+ const merged = new Int16Array(totalLen);
2757
+ let off = 0;
2758
+ for (const c of chunks) { merged.set(c, off); off += c.length; }
2759
+ const float32 = new Float32Array(merged.length);
2760
+ for (let i = 0; i < merged.length; i++) float32[i] = merged[i] / 32768.0;
2761
+ let maxAbs = 0;
2762
+ for (let i = 0; i < float32.length; i++) { if (Math.abs(float32[i]) > maxAbs) maxAbs = Math.abs(float32[i]); }
2763
+ const sampleRate = _diagState.spSampleRate || 48000;
2764
+ _diagLog('回放 ScriptProcessor: ' + totalLen + ' 样本, SR=' + sampleRate + ', max=' + maxAbs.toFixed(6));
2765
+ const ctx = new (window.AudioContext || window.webkitAudioContext)();
2766
+ const buf = ctx.createBuffer(1, float32.length, sampleRate);
2767
+ buf.copyToChannel(float32, 0);
2768
+ const src = ctx.createBufferSource();
2769
+ src.buffer = buf;
2770
+ src.connect(ctx.destination);
2771
+ src.start();
2772
+ src.onended = () => { _diagLog('ScriptProcessor 回放结束'); ctx.close(); };
2773
+ }
2774
+
2775
+ function playDiagMR() {
2776
+ if (!_diagState.mrBlob) { showToast('无 MediaRecorder 数据', 'error'); return; }
2777
+ _diagLog('回放 MediaRecorder: ' + _diagState.mrBlob.size + ' bytes');
2778
+ const url = URL.createObjectURL(_diagState.mrBlob);
2779
+ const audio = new Audio(url);
2780
+ audio.onended = () => { URL.revokeObjectURL(url); _diagLog('MediaRecorder 回放结束'); };
2781
+ audio.onerror = (e) => { _diagLog('MediaRecorder 回放错误: ' + (e.message || '未知')); };
2782
+ audio.play().catch(err => _diagLog('播放失败: ' + err.message));
2783
+ }
2784
+
2785
+
2786
+ // ============================================================
2787
+ // Bluetooth Page
2788
+ // ============================================================
2789
+ async function loadBluetooth() {
2790
+ try {
2791
+ const status = await API.get('/api/phone/status');
2792
+ renderBluetoothStatus(status);
2793
+ } catch (err) {
2794
+ showToast('加载蓝牙状态失败: ' + err.message, 'error');
2795
+ }
2796
+ }
2797
+
2798
+ function renderBluetoothStatus(status) {
2799
+ const connected = status.bluetooth_connected;
2800
+ _setText('bt-connection-status', connected ? '已连接' : '未连接');
2801
+
2802
+ const indicator = document.getElementById('bt-connection-indicator');
2803
+ if (indicator) {
2804
+ indicator.className = 'status-dot ' + (connected ? 'status-connected' : 'status-disconnected');
2805
+ }
2806
+
2807
+ _setText('bt-device-name', status.device_name || '-');
2808
+ _setText('bt-device-address', status.device_address || '-');
2809
+ _setText('bt-battery', status.battery_level != null ? `${status.battery_level}%` : '-');
2810
+ _setText('bt-signal', status.signal_strength != null ? `${status.signal_strength}/5` : '-');
2811
+ _setText('bt-operator', status.operator || '-');
2812
+ _setText('bt-in-call', status.in_call ? '通话中' : '空闲');
2813
+
2814
+ // Show/hide connect/disconnect buttons
2815
+ const connectBtn = document.getElementById('btn-bt-connect');
2816
+ const disconnectBtn = document.getElementById('btn-bt-disconnect');
2817
+ if (connectBtn) connectBtn.style.display = connected ? 'none' : '';
2818
+ if (disconnectBtn) disconnectBtn.style.display = connected ? '' : 'none';
2819
+ }
2820
+
2821
+ async function scanDevices() {
2822
+ const container = document.getElementById('bt-devices-list');
2823
+ const btn = document.getElementById('btn-bt-scan');
2824
+
2825
+ if (btn) {
2826
+ btn.disabled = true;
2827
+ btn.textContent = '扫描中...';
2828
+ }
2829
+ if (container) {
2830
+ container.innerHTML = '<div class="text-muted">正在扫描附近蓝牙设备...</div>';
2831
+ }
2832
+
2833
+ try {
2834
+ const data = await API.get('/api/phone/devices');
2835
+ const devices = data.devices || [];
2836
+
2837
+ if (!container) return;
2838
+
2839
+ if (devices.length === 0) {
2840
+ container.innerHTML = '<div class="text-muted">未发现蓝牙设备</div>';
2841
+ } else {
2842
+ container.innerHTML = devices
2843
+ .map(
2844
+ (d) => `<div class="device-item">
2845
+ <div class="device-info">
2846
+ <span class="device-name">${escapeHtml(d.name || '未知设备')}</span>
2847
+ <span class="device-address">${escapeHtml(d.address || '')}</span>
2848
+ </div>
2849
+ <div class="device-actions">
2850
+ ${d.paired
2851
+ ? `<button class="btn btn-sm btn-primary" onclick="connectDevice('${escapeHtml(d.address)}')">连接</button>`
2852
+ : `<button class="btn btn-sm btn-outline" onclick="pairDevice('${escapeHtml(d.address)}')">配对</button>`
2853
+ }
2854
+ </div>
2855
+ </div>`
2856
+ )
2857
+ .join('');
2858
+ }
2859
+ } catch (err) {
2860
+ showToast('扫描设备失败: ' + err.message, 'error');
2861
+ if (container) container.innerHTML = '<div class="text-muted">扫描失败</div>';
2862
+ } finally {
2863
+ if (btn) {
2864
+ btn.disabled = false;
2865
+ btn.textContent = '扫描设备';
2866
+ }
2867
+ }
2868
+ }
2869
+
2870
+ async function pairDevice(address) {
2871
+ try {
2872
+ await API.post(`/api/phone/pair?address=${encodeURIComponent(address)}`);
2873
+ showToast('配对成功', 'success');
2874
+ loadBluetooth();
2875
+ scanDevices();
2876
+ } catch (err) {
2877
+ showToast('配对失败: ' + err.message, 'error');
2878
+ }
2879
+ }
2880
+
2881
+ async function connectDevice(address) {
2882
+ try {
2883
+ await API.post(`/api/phone/connect?address=${encodeURIComponent(address)}`);
2884
+ showToast('连接成功', 'success');
2885
+ loadBluetooth();
2886
+ } catch (err) {
2887
+ showToast('连接失败: ' + err.message, 'error');
2888
+ }
2889
+ }
2890
+
2891
+ async function disconnectDevice() {
2892
+ try {
2893
+ await API.post('/api/phone/disconnect');
2894
+ showToast('已断开连接', 'success');
2895
+ loadBluetooth();
2896
+ } catch (err) {
2897
+ showToast('断开失败: ' + err.message, 'error');
2898
+ }
2899
+ }
2900
+
2901
+ // ============================================================
2902
+ // Status Bar Polling
2903
+ // ============================================================
2904
+ async function updateStatusBar() {
2905
+ try {
2906
+ const status = await API.get('/api/phone/status');
2907
+
2908
+ // Bottom status bar
2909
+ _setText('statusbar-connection', status.bluetooth_connected ? '蓝牙已连接' : '蓝牙未连接');
2910
+ _setText('statusbar-battery', status.battery_level != null ? `电量 ${status.battery_level}%` : '电量 -');
2911
+ _setText('statusbar-signal', status.signal_strength != null ? `信号 ${status.signal_strength}/5` : '信号 -');
2912
+ _setText('statusbar-operator', status.operator || '-');
2913
+
2914
+ // Header connection indicator
2915
+ const headerDot = document.getElementById('header-connection-dot');
2916
+ if (headerDot) {
2917
+ headerDot.className = 'status-dot ' + (status.bluetooth_connected ? 'status-connected' : 'status-disconnected');
2918
+ }
2919
+ _setText('header-connection-text', status.bluetooth_connected ? '已连接' : '未连接');
2920
+ } catch {
2921
+ // Silent fail for status polling — avoid spamming the user
2922
+ _setText('statusbar-connection', '蓝牙状态未知');
2923
+ }
2924
+ }
2925
+
2926
+ // Poll every 10 seconds
2927
+ setInterval(updateStatusBar, 10000);
2928
+
2929
+ // ============================================================
2930
+ // Pagination Renderer
2931
+ // ============================================================
2932
+ function renderPagination(containerId, total, currentPage, pageSize, onPageChange) {
2933
+ const container = document.getElementById(containerId);
2934
+ if (!container) return;
2935
+
2936
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
2937
+
2938
+ if (totalPages <= 1) {
2939
+ container.innerHTML = '';
2940
+ return;
2941
+ }
2942
+
2943
+ let html = '<div class="pagination">';
2944
+
2945
+ // Previous
2946
+ html += `<button class="btn btn-sm btn-outline page-btn" ${currentPage <= 1 ? 'disabled' : ''} data-page="${currentPage - 1}">&laquo; 上一页</button>`;
2947
+
2948
+ // Page numbers — show a window of up to 7 pages around the current page
2949
+ const maxVisible = 7;
2950
+ let startPage = Math.max(1, currentPage - Math.floor(maxVisible / 2));
2951
+ let endPage = Math.min(totalPages, startPage + maxVisible - 1);
2952
+ if (endPage - startPage < maxVisible - 1) {
2953
+ startPage = Math.max(1, endPage - maxVisible + 1);
2954
+ }
2955
+
2956
+ if (startPage > 1) {
2957
+ html += `<button class="btn btn-sm btn-outline page-btn" data-page="1">1</button>`;
2958
+ if (startPage > 2) html += '<span class="page-ellipsis">...</span>';
2959
+ }
2960
+
2961
+ for (let i = startPage; i <= endPage; i++) {
2962
+ html += `<button class="btn btn-sm ${i === currentPage ? 'btn-primary' : 'btn-outline'} page-btn" data-page="${i}">${i}</button>`;
2963
+ }
2964
+
2965
+ if (endPage < totalPages) {
2966
+ if (endPage < totalPages - 1) html += '<span class="page-ellipsis">...</span>';
2967
+ html += `<button class="btn btn-sm btn-outline page-btn" data-page="${totalPages}">${totalPages}</button>`;
2968
+ }
2969
+
2970
+ // Next
2971
+ html += `<button class="btn btn-sm btn-outline page-btn" ${currentPage >= totalPages ? 'disabled' : ''} data-page="${currentPage + 1}">下一页 &raquo;</button>`;
2972
+
2973
+ html += `<span class="page-info">共 ${total} 条</span>`;
2974
+ html += '</div>';
2975
+
2976
+ container.innerHTML = html;
2977
+
2978
+ // Attach click handlers
2979
+ container.querySelectorAll('.page-btn:not([disabled])').forEach((btn) => {
2980
+ btn.addEventListener('click', () => {
2981
+ const page = parseInt(btn.dataset.page);
2982
+ if (page && page !== currentPage) {
2983
+ onPageChange(page);
2984
+ }
2985
+ });
2986
+ });
2987
+ }
2988
+
2989
+ // ============================================================
2990
+ // Modal Helpers
2991
+ // ============================================================
2992
+ function openModal(modalId) {
2993
+ const modal = document.getElementById(modalId);
2994
+ if (modal) {
2995
+ modal.style.display = 'flex';
2996
+ modal.classList.add('modal-open');
2997
+ }
2998
+ }
2999
+
3000
+ function closeModal(modalId) {
3001
+ const modal = document.getElementById(modalId);
3002
+ if (modal) {
3003
+ modal.style.display = 'none';
3004
+ modal.classList.remove('modal-open');
3005
+ }
3006
+ }
3007
+
3008
+ // ============================================================
3009
+ // Utility Functions
3010
+ // ============================================================
3011
+ function formatDuration(seconds) {
3012
+ if (seconds == null || isNaN(seconds)) return '-';
3013
+ seconds = Math.round(seconds);
3014
+ if (seconds < 0) seconds = 0;
3015
+
3016
+ const h = Math.floor(seconds / 3600);
3017
+ const m = Math.floor((seconds % 3600) / 60);
3018
+ const s = seconds % 60;
3019
+
3020
+ if (h > 0) {
3021
+ return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
3022
+ }
3023
+ return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
3024
+ }
3025
+
3026
+ function formatTime(isoString) {
3027
+ if (!isoString) return '-';
3028
+ try {
3029
+ const d = new Date(isoString);
3030
+ if (isNaN(d.getTime())) return isoString;
3031
+ return d.toLocaleString('zh-CN', {
3032
+ year: 'numeric', month: '2-digit', day: '2-digit',
3033
+ hour: '2-digit', minute: '2-digit', second: '2-digit',
3034
+ });
3035
+ } catch {
3036
+ return isoString;
3037
+ }
3038
+ }
3039
+
3040
+ function escapeHtml(text) {
3041
+ if (text == null) return '';
3042
+ const str = String(text);
3043
+ const map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
3044
+ return str.replace(/[&<>"']/g, (c) => map[c]);
3045
+ }
3046
+
3047
+ function resultLabel(result) {
3048
+ const labels = {
3049
+ success: '成功', no_answer: '未接', busy: '忙碌',
3050
+ rejected: '拒接', error: '错误', timeout: '超时',
3051
+ };
3052
+ return labels[result] || result || '-';
3053
+ }
3054
+
3055
+ function statusLabel(status) {
3056
+ const labels = {
3057
+ created: '已创建', queued: '排队中', dialing: '拨号中',
3058
+ ringing: '响铃中', active: '通话中', completed: '已完成',
3059
+ failed: '失败', cancelled: '已取消', incoming: '来电',
3060
+ };
3061
+ return labels[status] || status || '-';
3062
+ }
3063
+
3064
+ /** Get value from a nested object using dot notation, e.g. 'llm.active_provider'. */
3065
+ function _getNestedVal(obj, path) {
3066
+ return path.split('.').reduce((o, k) => (o && o[k] !== undefined ? o[k] : undefined), obj);
3067
+ }
3068
+
3069
+ /** Shortcut: set .textContent of element by id. */
3070
+ function _setText(id, text) {
3071
+ const el = document.getElementById(id);
3072
+ if (el) el.textContent = text != null ? String(text) : '';
3073
+ }
3074
+
3075
+ /** Shortcut: get .value of element by id. */
3076
+ function _val(id) {
3077
+ const el = document.getElementById(id);
3078
+ return el ? el.value : '';
3079
+ }
3080
+
3081
+ /** Shortcut: set .value of element by id. */
3082
+ function _setVal(id, value) {
3083
+ const el = document.getElementById(id);
3084
+ if (el) el.value = value != null ? String(value) : '';
3085
+ }
3086
+
3087
+ /** Set a <select> value, adding the option dynamically if it doesn't exist. */
3088
+ function _setSelectVal(id, value) {
3089
+ const el = document.getElementById(id);
3090
+ if (!el || !value) return;
3091
+ const str = String(value);
3092
+ // Check if the option already exists
3093
+ let found = false;
3094
+ for (const opt of el.options) {
3095
+ if (opt.value === str) { found = true; break; }
3096
+ }
3097
+ if (!found) {
3098
+ const opt = document.createElement('option');
3099
+ opt.value = str;
3100
+ opt.textContent = str;
3101
+ el.appendChild(opt);
3102
+ }
3103
+ el.value = str;
3104
+ }
3105
+
3106
+ /** Shortcut: reset a form by id. */
3107
+ function _clearForm(formId) {
3108
+ const form = document.getElementById(formId);
3109
+ if (form) form.reset();
3110
+ }
3111
+
3112
+ // ============================================================
3113
+ // Voice Chat Module
3114
+ // ============================================================
3115
+
3116
+ const TTS_PROVIDER_NAMES = {
3117
+ 'edge-tts': 'Edge TTS', volcengine: '火山引擎', tencent: '腾讯云',
3118
+ azure: 'Azure', openai: 'OpenAI', aliyun: '阿里云', local: '本地',
3119
+ };
3120
+ const ASR_PROVIDER_NAMES = {
3121
+ whisper: 'Whisper', volcengine: '火山引擎', tencent: '腾讯云', xunfei: '讯飞',
3122
+ };
3123
+
3124
+ async function loadVoiceChatConfig() {
3125
+ try {
3126
+ const c = currentConfig || await API.get('/api/config');
3127
+ // LLM
3128
+ const llmProvider = _getNestedVal(c, 'llm.active_provider') || 'openai';
3129
+ const llmModel = _getNestedVal(c, `llm.providers.${llmProvider}.model`) || '--';
3130
+ const llmEl = document.getElementById('vc-cfg-llm');
3131
+ if (llmEl) llmEl.textContent = `${llmProvider} / ${llmModel}`;
3132
+
3133
+ // ASR
3134
+ const asrProvider = _getNestedVal(c, 'asr.provider') || 'whisper';
3135
+ const asrName = ASR_PROVIDER_NAMES[asrProvider] || asrProvider;
3136
+ let asrDetail = '';
3137
+ if (asrProvider === 'volcengine') {
3138
+ asrDetail = _getNestedVal(c, 'asr.volcengine.resource_id') || '';
3139
+ } else if (asrProvider === 'tencent') {
3140
+ asrDetail = _getNestedVal(c, 'asr.tencent.engine_model_type') || '';
3141
+ } else if (asrProvider === 'whisper') {
3142
+ asrDetail = _getNestedVal(c, 'asr.whisper.model') || 'whisper-1';
3143
+ }
3144
+ const asrEl = document.getElementById('vc-cfg-asr');
3145
+ if (asrEl) asrEl.textContent = asrDetail ? `${asrName} (${asrDetail})` : asrName;
3146
+
3147
+ // TTS
3148
+ const ttsProvider = _getNestedVal(c, 'tts.provider') || 'edge-tts';
3149
+ const ttsName = TTS_PROVIDER_NAMES[ttsProvider] || ttsProvider;
3150
+ let ttsVoice = '';
3151
+ if (ttsProvider === 'edge-tts') {
3152
+ ttsVoice = _getNestedVal(c, 'tts.edge_tts.voice') || '';
3153
+ } else if (ttsProvider === 'volcengine') {
3154
+ ttsVoice = _getNestedVal(c, 'tts.volcengine.voice_type') || '';
3155
+ } else if (ttsProvider === 'tencent') {
3156
+ ttsVoice = String(_getNestedVal(c, 'tts.tencent.voice_type') || '');
3157
+ }
3158
+ const ttsEl = document.getElementById('vc-cfg-tts');
3159
+ if (ttsEl) ttsEl.textContent = ttsVoice ? `${ttsName} / ${ttsVoice}` : ttsName;
3160
+
3161
+ // VAD — populate inline inputs (localStorage overrides config)
3162
+ const vadFields = [
3163
+ { id: 'vc-vad-threshold', key: 'vad.energy_threshold', def: 300 },
3164
+ { id: 'vc-vad-silence', key: 'vad.silence_threshold_ms', def: 800 },
3165
+ { id: 'vc-vad-min-speech', key: 'vad.min_speech_ms', def: 250 },
3166
+ ];
3167
+ vadFields.forEach(f => {
3168
+ const el = document.getElementById(f.id);
3169
+ if (!el) return;
3170
+ const saved = localStorage.getItem(f.id);
3171
+ el.value = saved !== null ? saved : (_getNestedVal(c, f.key) || f.def);
3172
+ });
3173
+ } catch (_) {}
3174
+ }
3175
+
3176
+ let vcState = 'idle'; // idle | listening | recognizing | thinking | speaking
3177
+ let vcWs = null;
3178
+ let vcAudioCtx = null;
3179
+ let vcStream = null;
3180
+ let vcProcessor = null;
3181
+ let vcGainNode = null;
3182
+ let vcAnalyser = null;
3183
+ let vcWaveHistory = [];
3184
+ let vcAnimId = null;
3185
+ let vcAudioChunks = [];
3186
+ let vcInterimBubble = null;
3187
+ let vcCurrentAudio = null; // currently playing TTS Audio element
3188
+ let vcCallStartTime = 0; // Date.now() when call started
3189
+ let vcCallTimerId = null; // setInterval id for call timer
3190
+ let vcDebugTurnCount = 0; // debug turn counter
3191
+ let vcAudioQueue = []; // queued audio blobs for sequential playback
3192
+ let vcIsPlaying = false; // whether audio is currently playing from queue
3193
+ let vcAllSegmentsDone = false; // flag: backend finished all TTS segments
3194
+
3195
+ function vcSetState(state) {
3196
+ vcState = state;
3197
+ const indicator = document.getElementById('vc-status-indicator');
3198
+ const text = document.getElementById('vc-status-text');
3199
+ if (!indicator || !text) return;
3200
+
3201
+ indicator.className = 'vc-status-indicator';
3202
+ const labels = {
3203
+ idle: '等待开始',
3204
+ listening: '正在听...',
3205
+ recognizing: '正在识别...',
3206
+ thinking: '正在思考...',
3207
+ speaking: '正在说...',
3208
+ };
3209
+ text.textContent = labels[state] || state;
3210
+ if (state !== 'idle') indicator.classList.add(state === 'listening' || state === 'recognizing' ? 'listening' : state);
3211
+ }
3212
+
3213
+ function _vcFormatTime(date) {
3214
+ return date.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
3215
+ }
3216
+
3217
+ function _vcElapsedStr() {
3218
+ if (!vcCallStartTime) return '';
3219
+ const sec = Math.floor((Date.now() - vcCallStartTime) / 1000);
3220
+ return String(Math.floor(sec / 60)).padStart(2, '0') + ':' + String(sec % 60).padStart(2, '0');
3221
+ }
3222
+
3223
+ function _vcStartTimer() {
3224
+ vcCallStartTime = Date.now();
3225
+ const el = document.getElementById('vc-call-timer');
3226
+ if (el) el.textContent = '00:00';
3227
+ vcCallTimerId = setInterval(() => {
3228
+ if (el) el.textContent = _vcElapsedStr();
3229
+ }, 500);
3230
+ }
3231
+
3232
+ function _vcStopTimer() {
3233
+ if (vcCallTimerId) { clearInterval(vcCallTimerId); vcCallTimerId = null; }
3234
+ const el = document.getElementById('vc-call-timer');
3235
+ if (el) el.textContent = '';
3236
+ }
3237
+
3238
+ // --- Status Log ---
3239
+ function vcLog(msg, cls) {
3240
+ const el = document.getElementById('vc-log');
3241
+ if (!el) return;
3242
+ const ts = new Date().toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
3243
+ const entry = document.createElement('div');
3244
+ entry.className = 'vc-log-entry' + (cls ? ' ' + cls : '');
3245
+ entry.innerHTML = `<span class="vc-log-time">[${ts}]</span> ${msg}`;
3246
+ el.appendChild(entry);
3247
+ el.scrollTop = el.scrollHeight;
3248
+ }
3249
+
3250
+ function vcLogClear() {
3251
+ const el = document.getElementById('vc-log');
3252
+ if (el) el.innerHTML = '';
3253
+ }
3254
+
3255
+ function _vcDumpLog() {
3256
+ const el = document.getElementById('vc-log');
3257
+ if (!el) return;
3258
+ const lines = [];
3259
+ el.querySelectorAll('.vc-log-entry').forEach(e => lines.push(e.textContent));
3260
+ if (!lines.length) return;
3261
+ fetch('/api/voicechat/dump-log', {
3262
+ method: 'POST',
3263
+ headers: { 'Content-Type': 'application/json' },
3264
+ body: JSON.stringify({ log: lines.join('\n') }),
3265
+ }).catch(() => {});
3266
+ }
3267
+
3268
+ function vcDebugClear() {
3269
+ const el = document.getElementById('vc-debug');
3270
+ if (el) el.innerHTML = '';
3271
+ vcDebugTurnCount = 0;
3272
+ const cnt = document.getElementById('vc-debug-count');
3273
+ if (cnt) cnt.textContent = '';
3274
+ }
3275
+
3276
+ function vcAddDebugRecord(messages) {
3277
+ vcDebugTurnCount++;
3278
+ const el = document.getElementById('vc-debug');
3279
+ if (!el) return;
3280
+ const ts = new Date().toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
3281
+ const detail = document.createElement('details');
3282
+ detail.className = 'vc-debug-item';
3283
+ const summary = document.createElement('summary');
3284
+ summary.innerHTML = `<span class="vc-dbg-turn">#${vcDebugTurnCount}</span> ${messages.length} 条消息 <span class="vc-dbg-time">${ts}</span>`;
3285
+ detail.appendChild(summary);
3286
+ const pre = document.createElement('pre');
3287
+ pre.textContent = JSON.stringify(messages, null, 2);
3288
+ detail.appendChild(pre);
3289
+ el.appendChild(detail);
3290
+ el.scrollTop = el.scrollHeight;
3291
+ const cnt = document.getElementById('vc-debug-count');
3292
+ if (cnt) cnt.textContent = `(${vcDebugTurnCount})`;
3293
+ }
3294
+
3295
+ // --- Waveform (reuses ASR test drawing pattern) ---
3296
+ function _vcDrawWaveform() {
3297
+ if (!vcAnalyser) return;
3298
+ vcAnimId = requestAnimationFrame(_vcDrawWaveform);
3299
+
3300
+ const canvas = document.getElementById('vc-canvas');
3301
+ if (!canvas) return;
3302
+ const ctx = canvas.getContext('2d');
3303
+ const W = canvas.width;
3304
+ const H = canvas.height;
3305
+
3306
+ const bufLen = vcAnalyser.frequencyBinCount;
3307
+ const timeData = new Uint8Array(bufLen);
3308
+ vcAnalyser.getByteTimeDomainData(timeData);
3309
+ let sumSq = 0;
3310
+ for (let i = 0; i < timeData.length; i++) {
3311
+ const v = (timeData[i] - 128) / 128.0;
3312
+ sumSq += v * v;
3313
+ }
3314
+ const rms = Math.sqrt(sumSq / timeData.length);
3315
+
3316
+ const barW = 3, gap = 1;
3317
+ const maxBars = Math.ceil(W / (barW + gap));
3318
+ vcWaveHistory.push(rms);
3319
+ if (vcWaveHistory.length > maxBars) vcWaveHistory.shift();
3320
+
3321
+ ctx.fillStyle = '#0f172a';
3322
+ ctx.fillRect(0, 0, W, H);
3323
+
3324
+ const len = vcWaveHistory.length;
3325
+ for (let i = 0; i < len; i++) {
3326
+ const val = vcWaveHistory[i];
3327
+ const norm = Math.min(1.0, val / 0.35);
3328
+ const barH = Math.max(2, norm * H);
3329
+ const x = W - (len - i) * (barW + gap);
3330
+ if (x < -barW) continue;
3331
+ let r, g, b;
3332
+ if (norm < 0.4) { r = Math.floor(norm / 0.4 * 180); g = 200; b = 80; }
3333
+ else if (norm < 0.75) { const t = (norm - 0.4) / 0.35; r = 180 + Math.floor(t * 75); g = 200 - Math.floor(t * 80); b = 80 - Math.floor(t * 40); }
3334
+ else { r = 255; g = Math.floor((1 - (norm - 0.75) / 0.25) * 120); b = 40; }
3335
+ ctx.fillStyle = `rgb(${r},${g},${b})`;
3336
+ ctx.fillRect(x, (H - barH) / 2, barW, barH);
3337
+ }
3338
+
3339
+ ctx.strokeStyle = 'rgba(100,116,139,0.3)';
3340
+ ctx.lineWidth = 1;
3341
+ ctx.beginPath(); ctx.moveTo(0, H / 2); ctx.lineTo(W, H / 2); ctx.stroke();
3342
+
3343
+ const dB = rms > 0 ? 20 * Math.log10(rms) : -100;
3344
+ const pct = Math.min(100, Math.max(0, (dB + 60) / 60 * 100));
3345
+ const volBar = document.getElementById('vc-vol-bar');
3346
+ const volDb = document.getElementById('vc-vol-db');
3347
+ if (volBar) volBar.style.width = pct + '%';
3348
+ if (volDb) volDb.textContent = (dB > -100 ? dB.toFixed(0) : '--') + ' dB';
3349
+ }
3350
+
3351
+ function _vcStopWaveform() {
3352
+ if (vcAnimId) { cancelAnimationFrame(vcAnimId); vcAnimId = null; }
3353
+ vcAnalyser = null;
3354
+ vcWaveHistory = [];
3355
+ }
3356
+
3357
+ function vcAddBubble(role, text) {
3358
+ const container = document.getElementById('vc-messages');
3359
+ const hint = document.getElementById('vc-empty-hint');
3360
+ if (hint) hint.style.display = 'none';
3361
+
3362
+ const bubble = document.createElement('div');
3363
+ bubble.className = `vc-bubble ${role}`;
3364
+
3365
+ const label = document.createElement('div');
3366
+ label.className = 'vc-bubble-label';
3367
+ const roleName = role === 'user' ? '你' : 'AI';
3368
+ const timeStr = _vcFormatTime(new Date());
3369
+ const elapsed = _vcElapsedStr();
3370
+ label.innerHTML = `<span>${roleName}</span><span class="vc-bubble-time">${timeStr}${elapsed ? ' (' + elapsed + ')' : ''}</span>`;
3371
+ bubble.appendChild(label);
3372
+
3373
+ const content = document.createElement('div');
3374
+ content.textContent = text;
3375
+ bubble.appendChild(content);
3376
+
3377
+ container.appendChild(bubble);
3378
+ container.scrollTop = container.scrollHeight;
3379
+ return bubble;
3380
+ }
3381
+
3382
+ // Tool call cards: maps tool_call_id → { card DOM element, startTime, name, args }
3383
+ const _vcToolCards = {};
3384
+
3385
+ function _vcToolCardSvgIcon() {
3386
+ return '<svg class="vc-tool-card-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>';
3387
+ }
3388
+
3389
+ function _vcFormatDateTime(date) {
3390
+ const y = date.getFullYear();
3391
+ const mo = String(date.getMonth() + 1).padStart(2, '0');
3392
+ const d = String(date.getDate()).padStart(2, '0');
3393
+ const h = String(date.getHours()).padStart(2, '0');
3394
+ const mi = String(date.getMinutes()).padStart(2, '0');
3395
+ const s = String(date.getSeconds()).padStart(2, '0');
3396
+ return `${y}/${mo}/${d} ${h}:${mi}:${s}`;
3397
+ }
3398
+
3399
+ function vcAddToolCallCard(id, name, args) {
3400
+ const container = document.getElementById('vc-messages');
3401
+ const hint = document.getElementById('vc-empty-hint');
3402
+ if (hint) hint.style.display = 'none';
3403
+
3404
+ const argsStr = typeof args === 'object' ? JSON.stringify(args, null, 2) : String(args || '');
3405
+ const inputSize = new Blob([argsStr]).size;
3406
+ const startTime = new Date();
3407
+
3408
+ // Outer wrapper
3409
+ const card = document.createElement('div');
3410
+ card.className = 'vc-tool-card';
3411
+ card.dataset.toolId = id || name;
3412
+
3413
+ // Collapsed header (clickable)
3414
+ const header = document.createElement('div');
3415
+ header.className = 'vc-tool-card-header';
3416
+ header.innerHTML = `${_vcToolCardSvgIcon()}
3417
+ <span class="vc-tool-card-name">${escapeHtml(name)}</span>
3418
+ <span class="vc-tool-card-metrics">
3419
+ <span class="up">&uarr; ${inputSize}</span>
3420
+ <span class="down">&darr; 0</span>
3421
+ </span>
3422
+ <span class="vc-tool-card-status"><span class="spinner"></span></span>`;
3423
+
3424
+ header.addEventListener('click', function () {
3425
+ card.classList.toggle('expanded');
3426
+ const msgEl = document.getElementById('vc-messages');
3427
+ if (msgEl) msgEl.scrollTop = msgEl.scrollHeight;
3428
+ });
3429
+ card.appendChild(header);
3430
+
3431
+ // Expanded detail panel
3432
+ const detail = document.createElement('div');
3433
+ detail.className = 'vc-tool-detail';
3434
+ detail.innerHTML = `
3435
+ <div class="vc-tool-detail-header">
3436
+ <span class="title">
3437
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
3438
+ 工具调用详情
3439
+ </span>
3440
+ <button class="vc-tool-detail-copy" title="复制全部">
3441
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
3442
+ </button>
3443
+ </div>
3444
+ <div class="vc-tool-detail-info">
3445
+ <div><span class="label">工具名称:</span>${escapeHtml(name)}</div>
3446
+ <div><span class="label">开始时间:</span>${_vcFormatDateTime(startTime)}</div>
3447
+ <div class="detail-end-time"><span class="label">结束时间:</span>--</div>
3448
+ <div class="detail-duration"><span class="label">耗时:</span>--</div>
3449
+ </div>
3450
+ <div class="vc-tool-detail-sep"></div>
3451
+ <div class="vc-tool-detail-section">
3452
+ <div class="section-title"><span class="arrow-up">&uarr;</span> 参数</div>
3453
+ <pre>${escapeHtml(argsStr)}</pre>
3454
+ </div>
3455
+ <div class="vc-tool-detail-sep"></div>
3456
+ <div class="vc-tool-detail-section detail-response-section">
3457
+ <div class="section-title"><span class="arrow-down">&darr;</span> 响应数据</div>
3458
+ <pre class="detail-response-pre">等待响应...</pre>
3459
+ </div>`;
3460
+
3461
+ // Copy button handler
3462
+ detail.querySelector('.vc-tool-detail-copy').addEventListener('click', function (e) {
3463
+ e.stopPropagation();
3464
+ const info = `工具: ${name}\n参数: ${argsStr}`;
3465
+ const respPre = detail.querySelector('.detail-response-pre');
3466
+ const full = info + '\n响应: ' + (respPre ? respPre.textContent : '');
3467
+ navigator.clipboard.writeText(full).catch(() => {});
3468
+ });
3469
+
3470
+ card.appendChild(detail);
3471
+ container.appendChild(card);
3472
+ container.scrollTop = container.scrollHeight;
3473
+
3474
+ _vcToolCards[id || name] = { card, startTime, name, argsStr, inputSize };
3475
+ return card;
3476
+ }
3477
+
3478
+ function vcUpdateToolResult(id, name, result) {
3479
+ const key = id || name;
3480
+ const entry = _vcToolCards[key];
3481
+ if (!entry) return;
3482
+
3483
+ const { card, startTime, inputSize } = entry;
3484
+ const endTime = new Date();
3485
+ const duration = endTime - startTime;
3486
+ const resultStr = String(result || '');
3487
+ const outputSize = new Blob([resultStr]).size;
3488
+ const isError = resultStr.startsWith('Error:');
3489
+
3490
+ // Update metrics in header
3491
+ const metricsEl = card.querySelector('.vc-tool-card-metrics .down');
3492
+ if (metricsEl) metricsEl.innerHTML = `&darr; ${outputSize}`;
3493
+
3494
+ // Replace spinner with checkmark or error mark
3495
+ const statusEl = card.querySelector('.vc-tool-card-status');
3496
+ if (statusEl) {
3497
+ statusEl.innerHTML = isError
3498
+ ? '<span class="error-mark">&#10060;</span>'
3499
+ : '<span class="checkmark">&#10004;</span>';
3500
+ }
3501
+
3502
+ // Update detail panel
3503
+ const detail = card.querySelector('.vc-tool-detail');
3504
+ if (detail) {
3505
+ const endEl = detail.querySelector('.detail-end-time');
3506
+ if (endEl) endEl.innerHTML = `<span class="label">结束时间:</span>${_vcFormatDateTime(endTime)}`;
3507
+ const durEl = detail.querySelector('.detail-duration');
3508
+ if (durEl) durEl.innerHTML = `<span class="label">耗时:</span>${duration}ms`;
3509
+ const respPre = detail.querySelector('.detail-response-pre');
3510
+ if (respPre) respPre.textContent = resultStr;
3511
+ }
3512
+
3513
+ const container = document.getElementById('vc-messages');
3514
+ if (container) container.scrollTop = container.scrollHeight;
3515
+ }
3516
+
3517
+ function vcSetInterim(text) {
3518
+ if (!text) {
3519
+ if (vcInterimBubble) { vcInterimBubble.remove(); vcInterimBubble = null; }
3520
+ return;
3521
+ }
3522
+ const container = document.getElementById('vc-messages');
3523
+ if (!vcInterimBubble) {
3524
+ const hint = document.getElementById('vc-empty-hint');
3525
+ if (hint) hint.style.display = 'none';
3526
+ vcInterimBubble = document.createElement('div');
3527
+ vcInterimBubble.className = 'vc-bubble interim';
3528
+ container.appendChild(vcInterimBubble);
3529
+ }
3530
+ vcInterimBubble.textContent = text;
3531
+ if (container) container.scrollTop = container.scrollHeight;
3532
+ }
3533
+
3534
+ function _vcStopCurrentAudio() {
3535
+ if (vcCurrentAudio) {
3536
+ vcCurrentAudio.pause();
3537
+ if (vcCurrentAudio._blobUrl) {
3538
+ URL.revokeObjectURL(vcCurrentAudio._blobUrl);
3539
+ }
3540
+ vcCurrentAudio = null;
3541
+ }
3542
+ }
3543
+
3544
+ async function startVoiceChat() {
3545
+ if (vcState !== 'idle') return;
3546
+
3547
+ const startBtn = document.getElementById('vc-btn-start');
3548
+ const stopBtn = document.getElementById('vc-btn-stop');
3549
+ if (startBtn) startBtn.style.display = 'none';
3550
+ if (stopBtn) stopBtn.style.display = '';
3551
+
3552
+ vcSetState('listening');
3553
+ vcAudioChunks = [];
3554
+ _vcStartTimer();
3555
+ vcLogClear();
3556
+ vcDebugClear();
3557
+
3558
+ // Clear previous conversation messages
3559
+ const msgContainer = document.getElementById('vc-messages');
3560
+ if (msgContainer) {
3561
+ msgContainer.innerHTML = '<div class="vc-empty-hint" id="vc-empty-hint">通话中...</div>';
3562
+ }
3563
+ vcInterimBubble = null;
3564
+
3565
+ vcLog('正在初始化通话...', 'info');
3566
+
3567
+ // Read system prompt & determine ai_first
3568
+ const promptEl = document.getElementById('vc-system-prompt');
3569
+ const userPrompt = promptEl ? promptEl.value.trim() : '';
3570
+
3571
+ // Read phone simulation fields
3572
+ const phoneDir = document.getElementById('vc-phone-direction')?.value || 'incoming';
3573
+ const phoneNum = document.getElementById('vc-phone-number')?.value.trim() || '';
3574
+ const phoneName = document.getElementById('vc-phone-name')?.value.trim() || '';
3575
+
3576
+ // Build full system prompt with phone context prepended
3577
+ const phoneParts = [];
3578
+ phoneParts.push('通话方向: ' + (phoneDir === 'outgoing' ? '拨出' : '接入'));
3579
+ if (phoneNum) phoneParts.push('对方号码: ' + phoneNum);
3580
+ if (phoneName) phoneParts.push('对方姓名: ' + phoneName);
3581
+ const phoneContext = phoneParts.join('\n');
3582
+ const systemPrompt = userPrompt
3583
+ ? phoneContext + '\n\n' + userPrompt
3584
+ : phoneContext;
3585
+ const aiFirst = !!userPrompt;
3586
+
3587
+ if (phoneNum) vcLog(`模拟${phoneDir === 'outgoing' ? '拨出' : '来电'}: ${phoneNum}${phoneName ? ' (' + phoneName + ')' : ''}`);
3588
+ if (userPrompt) vcLog('已设置系统提示词,AI 将主动发起对话', 'info');
3589
+
3590
+ try {
3591
+ // Get microphone (shared constraints with ASR test)
3592
+ const savedMic = localStorage.getItem('asr-mic-deviceId') || '';
3593
+ vcStream = await navigator.mediaDevices.getUserMedia({ audio: _getMicConstraints(savedMic) });
3594
+ vcLog('麦克风已连接');
3595
+
3596
+ // Shared audio pipeline (same code path as ASR test)
3597
+ const pipeline = _createAudioPipeline({
3598
+ stream: vcStream,
3599
+ targetSR: 16000,
3600
+ log: vcLog,
3601
+ onPcmData: (pcm16, preAgcRms) => {
3602
+ if (!vcWs || vcWs.readyState !== WebSocket.OPEN) return;
3603
+ // Prepend 4-byte float32 LE pre-AGC RMS so backend VAD can use
3604
+ // the original (un-amplified) energy level.
3605
+ const header = new Float32Array([preAgcRms]);
3606
+ const frame = new Uint8Array(4 + pcm16.byteLength);
3607
+ frame.set(new Uint8Array(header.buffer), 0);
3608
+ frame.set(new Uint8Array(pcm16.buffer), 4);
3609
+ vcWs.send(frame.buffer);
3610
+ },
3611
+ });
3612
+
3613
+ vcAudioCtx = pipeline.audioContext;
3614
+ vcGainNode = pipeline.gainNode;
3615
+ vcAnalyser = pipeline.analyser;
3616
+ vcProcessor = pipeline.processor;
3617
+
3618
+ // Show waveform visualization
3619
+ const vizEl = document.getElementById('vc-viz');
3620
+ if (vizEl) vizEl.style.display = '';
3621
+ vcWaveHistory = [];
3622
+ _vcDrawWaveform();
3623
+
3624
+ // Open WebSocket
3625
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
3626
+ vcWs = new WebSocket(`${proto}//${location.host}/ws/voice-chat`);
3627
+ vcWs.binaryType = 'arraybuffer';
3628
+ vcLog('正在连接 WebSocket...');
3629
+
3630
+ vcWs.onopen = () => {
3631
+ vcLog('WebSocket 已连接', 'info');
3632
+ vcWs.send(JSON.stringify({ type: 'start', system_prompt: systemPrompt, ai_first: aiFirst, phone_number: phoneNum }));
3633
+ };
3634
+
3635
+ vcWs.onmessage = vcHandleMessage;
3636
+
3637
+ vcWs.onclose = () => {
3638
+ vcLog('WebSocket 已断开', 'warn');
3639
+ if (vcState !== 'idle') stopVoiceChat();
3640
+ };
3641
+
3642
+ vcWs.onerror = () => {
3643
+ vcLog('WebSocket 连接失败', 'error');
3644
+ showToast('语音连接失败', 'error');
3645
+ stopVoiceChat();
3646
+ };
3647
+
3648
+ } catch (err) {
3649
+ vcLog('麦克风访问失败: ' + err.message, 'error');
3650
+ showToast('麦克风访问失败: ' + err.message, 'error');
3651
+ stopVoiceChat();
3652
+ }
3653
+ }
3654
+
3655
+ function stopVoiceChat() {
3656
+ vcLog('正在结束通话...');
3657
+
3658
+ // Send stop command
3659
+ if (vcWs && vcWs.readyState === WebSocket.OPEN) {
3660
+ try { vcWs.send(JSON.stringify({ type: 'stop' })); } catch (_) {}
3661
+ try { vcWs.close(); } catch (_) {}
3662
+ }
3663
+ vcWs = null;
3664
+
3665
+ // Stop any playing audio
3666
+ _vcStopCurrentAudio();
3667
+
3668
+ // Stop waveform visualization
3669
+ _vcStopWaveform();
3670
+ const vizEl = document.getElementById('vc-viz');
3671
+ if (vizEl) vizEl.style.display = 'none';
3672
+
3673
+ // Stop audio capture
3674
+ if (vcProcessor) {
3675
+ vcProcessor.onaudioprocess = null;
3676
+ try { vcProcessor.disconnect(); } catch (_) {}
3677
+ vcProcessor = null;
3678
+ }
3679
+ if (vcGainNode) {
3680
+ try { vcGainNode.disconnect(); } catch (_) {}
3681
+ vcGainNode = null;
3682
+ }
3683
+ if (vcAudioCtx) {
3684
+ try { vcAudioCtx.close(); } catch (_) {}
3685
+ vcAudioCtx = null;
3686
+ }
3687
+ if (vcStream) {
3688
+ vcStream.getTracks().forEach(t => t.stop());
3689
+ vcStream = null;
3690
+ }
3691
+
3692
+ vcAudioChunks = [];
3693
+ vcAudioQueue = [];
3694
+ vcIsPlaying = false;
3695
+ vcAllSegmentsDone = false;
3696
+ vcInterimBubble = null;
3697
+ _vcStopTimer();
3698
+ vcSetState('idle');
3699
+ vcLog('通话已结束', 'info');
3700
+
3701
+ // Auto-dump status log to server for diagnostics
3702
+ _vcDumpLog();
3703
+
3704
+ const startBtn = document.getElementById('vc-btn-start');
3705
+ const stopBtn = document.getElementById('vc-btn-stop');
3706
+ if (startBtn) startBtn.style.display = '';
3707
+ if (stopBtn) stopBtn.style.display = 'none';
3708
+ }
3709
+
3710
+ function vcHandleMessage(event) {
3711
+ // Binary frame = TTS audio data
3712
+ if (event.data instanceof ArrayBuffer) {
3713
+ vcAudioChunks.push(new Uint8Array(event.data));
3714
+ return;
3715
+ }
3716
+
3717
+ let msg;
3718
+ try { msg = JSON.parse(event.data); } catch (_) { return; }
3719
+
3720
+ switch (msg.type) {
3721
+ case 'ready':
3722
+ vcLog('服务端已就绪', 'info');
3723
+ vcSetState('listening');
3724
+ break;
3725
+
3726
+ case 'listening':
3727
+ vcLog('等待语音输入...');
3728
+ vcSetState('listening');
3729
+ break;
3730
+
3731
+ case 'speech_start':
3732
+ vcLog('检测到语音');
3733
+ vcSetState('recognizing');
3734
+ vcSetInterim('正在识别...');
3735
+ break;
3736
+
3737
+ case 'speech_end':
3738
+ vcLog('语音结束,正在识别...');
3739
+ vcSetInterim('正在识别...');
3740
+ break;
3741
+
3742
+ case 'interim':
3743
+ vcSetInterim(msg.text || '正在识别...');
3744
+ vcLog('识别中: ' + msg.text, 'debug');
3745
+ break;
3746
+
3747
+ case 'user_text':
3748
+ vcLog('识别结果: ' + msg.text);
3749
+ vcSetInterim(null);
3750
+ vcAddBubble('user', msg.text);
3751
+ break;
3752
+
3753
+ case 'thinking':
3754
+ vcLog('AI 正在思考...');
3755
+ vcSetState('thinking');
3756
+ break;
3757
+
3758
+ case 'ai_text':
3759
+ vcLog('AI 回复: ' + (msg.text.length > 30 ? msg.text.substring(0, 30) + '...' : msg.text));
3760
+ vcAddBubble('ai', msg.text);
3761
+ break;
3762
+
3763
+ case 'tool_call':
3764
+ vcLog('🔧 调用工具: ' + msg.name);
3765
+ vcAddToolCallCard(msg.id || '', msg.name, msg.arguments || {});
3766
+ break;
3767
+
3768
+ case 'tool_result':
3769
+ vcLog('📋 工具结果: ' + msg.name + ' → ' + (msg.result || '').substring(0, 50));
3770
+ vcUpdateToolResult(msg.id || '', msg.name, msg.result || '');
3771
+ break;
3772
+
3773
+ case 'debug_messages':
3774
+ vcAddDebugRecord(msg.messages || []);
3775
+ vcLog(`调试: 第${vcDebugTurnCount}次 LLM 调用 (${(msg.messages || []).length} 条消息)`);
3776
+ break;
3777
+
3778
+ case 'speaking':
3779
+ vcLog('正在合成语音...');
3780
+ vcSetState('speaking');
3781
+ _vcStopCurrentAudio();
3782
+ vcAudioQueue = [];
3783
+ vcAudioChunks = [];
3784
+ vcIsPlaying = false;
3785
+ vcAllSegmentsDone = false;
3786
+ break;
3787
+
3788
+ case 'segment_end': {
3789
+ // One sentence's TTS audio is ready — queue it
3790
+ if (vcAudioChunks.length > 0) {
3791
+ const totalLen = vcAudioChunks.reduce((s, c) => s + c.length, 0);
3792
+ const merged = new Uint8Array(totalLen);
3793
+ let off = 0;
3794
+ for (const chunk of vcAudioChunks) { merged.set(chunk, off); off += chunk.length; }
3795
+ vcAudioQueue.push(merged);
3796
+ vcAudioChunks = [];
3797
+ vcLog(`语音片段 ${vcAudioQueue.length} 就绪`);
3798
+ _vcPlayNextSegment();
3799
+ }
3800
+ break;
3801
+ }
3802
+
3803
+ case 'audio_done':
3804
+ // All TTS segments have been sent
3805
+ vcAllSegmentsDone = true;
3806
+ if (vcAudioChunks.length > 0) {
3807
+ // Flush remaining chunks as last segment
3808
+ const totalLen = vcAudioChunks.reduce((s, c) => s + c.length, 0);
3809
+ const merged = new Uint8Array(totalLen);
3810
+ let off = 0;
3811
+ for (const chunk of vcAudioChunks) { merged.set(chunk, off); off += chunk.length; }
3812
+ vcAudioQueue.push(merged);
3813
+ vcAudioChunks = [];
3814
+ _vcPlayNextSegment();
3815
+ }
3816
+ vcLog('所有语音片段已接收');
3817
+ // If nothing to play, transition directly
3818
+ if (vcAudioQueue.length === 0 && !vcIsPlaying) {
3819
+ vcSetState('listening');
3820
+ }
3821
+ break;
3822
+
3823
+ case 'vad_info': {
3824
+ const rms = msg.rms || 0;
3825
+ const thr = msg.threshold || 0;
3826
+ const nf = msg.noise_floor || 0;
3827
+ const sc = msg.silence_count || 0;
3828
+ const st = msg.silence_target || 0;
3829
+ const isSpeech = msg.in_speech ? '🔊' : '🔇';
3830
+ vcLog(`VAD ${isSpeech} rms=${rms} thr=${thr} noise=${nf} silence=${sc}/${st}`);
3831
+ break;
3832
+ }
3833
+
3834
+ case 'error':
3835
+ vcLog('错误: ' + (msg.message || ''), 'error');
3836
+ showToast('语音错误: ' + (msg.message || ''), 'error');
3837
+ break;
3838
+ }
3839
+ }
3840
+
3841
+ function _vcPlayNextSegment() {
3842
+ if (vcIsPlaying || vcAudioQueue.length === 0) return;
3843
+ vcIsPlaying = true;
3844
+
3845
+ _vcStopCurrentAudio();
3846
+
3847
+ const data = vcAudioQueue.shift();
3848
+ const blob = new Blob([data], { type: 'audio/mp3' });
3849
+ const url = URL.createObjectURL(blob);
3850
+ const audio = new Audio(url);
3851
+ audio._blobUrl = url;
3852
+ vcCurrentAudio = audio;
3853
+
3854
+ const onFinish = () => {
3855
+ URL.revokeObjectURL(url);
3856
+ if (vcCurrentAudio === audio) vcCurrentAudio = null;
3857
+ vcIsPlaying = false;
3858
+ if (vcAudioQueue.length > 0) {
3859
+ _vcPlayNextSegment();
3860
+ } else if (vcAllSegmentsDone) {
3861
+ if (vcState === 'speaking') vcSetState('listening');
3862
+ }
3863
+ };
3864
+
3865
+ audio.onended = onFinish;
3866
+ audio.onerror = onFinish;
3867
+ audio.play().catch(onFinish);
3868
+ }
3869
+
3870
+ // ============================================================
3871
+ // Dev Log Page
3872
+ // ============================================================
3873
+ let devlogPage = 1;
3874
+ let devlogCurrentId = null;
3875
+ let _devlogEditingId = null; // ID of record being edited (null = new)
3876
+
3877
+ async function loadDevLog() {
3878
+ const status = document.getElementById('devlog-filter-status')?.value || '';
3879
+ const params = new URLSearchParams({ page: devlogPage, page_size: 20 });
3880
+ if (status) params.set('status', status);
3881
+
3882
+ try {
3883
+ const data = await API.get('/api/devlog?' + params);
3884
+ renderDevLogTable(data.items || [], data.total, data.page, data.page_size);
3885
+ } catch (err) {
3886
+ showToast('加载开发记录失败: ' + err.message, 'error');
3887
+ }
3888
+ }
3889
+
3890
+ function renderDevLogTable(items, total, page, pageSize) {
3891
+ const tbody = document.getElementById('devlog-tbody');
3892
+ if (!tbody) return;
3893
+
3894
+ const statusLabels = { pending: '待处理', in_progress: '进行中', done: '已完成' };
3895
+ const statusBadges = { pending: 'badge-gray', in_progress: 'badge-info', done: 'badge-success' };
3896
+ const typeBadgeColors = { '需求': '#3b82f6', 'BUG': '#ef4444', '优化': '#10b981', '重构': '#8b5cf6', '澄清': '#f59e0b', '文档': '#6b7280' };
3897
+
3898
+ if (items.length === 0) {
3899
+ tbody.innerHTML = '<tr><td colspan="5" class="text-muted" style="text-align:center;padding:24px;">暂无开发记录</td></tr>';
3900
+ } else {
3901
+ tbody.innerHTML = items.map(r => {
3902
+ const preview = (r.content || '').length > 60 ? r.content.substring(0, 60) + '...' : (r.content || '');
3903
+ const tags = [];
3904
+ if (r.important) tags.push('<span class="badge badge-error">重要</span>');
3905
+ if (r.urgent) tags.push('<span class="badge badge-warning">优先</span>');
3906
+ const typeLabel = r.type || '需求';
3907
+ const typeColor = typeBadgeColors[typeLabel] || '#6b7280';
3908
+ const typeTag = `<span style="display:inline-block;padding:1px 5px;border-radius:3px;font-size:11px;font-weight:500;color:#fff;background:${typeColor};margin-right:4px;vertical-align:baseline;">${escapeHtml(typeLabel)}</span>`;
3909
+ return `<tr>
3910
+ <td><a href="#" class="devlog-view-link" data-id="${escapeHtml(r.id)}" style="color:var(--primary);text-decoration:none;font-weight:500;">${typeTag}${escapeHtml(preview)}</a></td>
3911
+ <td>${tags.length ? tags.join(' ') : '<span class="text-muted">-</span>'}</td>
3912
+ <td><span class="badge ${statusBadges[r.status] || 'badge-gray'}">${escapeHtml(statusLabels[r.status] || r.status)}</span></td>
3913
+ <td>${formatTime(r.created_at)}</td>
3914
+ <td><a href="#" class="devlog-edit-link" data-id="${escapeHtml(r.id)}" data-content="${escapeHtml(r.content)}" data-important="${r.important ? '1' : ''}" data-urgent="${r.urgent ? '1' : ''}" data-type="${escapeHtml(r.type || '需求')}" style="color:var(--primary);font-size:13px;">编辑</a></td>
3915
+ </tr>`;
3916
+ }).join('');
3917
+ }
3918
+
3919
+ // Pagination
3920
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
3921
+ const pagEl = document.getElementById('devlog-pagination');
3922
+ if (pagEl) {
3923
+ pagEl.innerHTML = `
3924
+ <button class="btn btn-secondary btn-sm" id="devlog-prev" ${page <= 1 ? 'disabled' : ''}>上一页</button>
3925
+ <span class="pagination-info">${page} / ${totalPages}(共 ${total} 条)</span>
3926
+ <button class="btn btn-secondary btn-sm" id="devlog-next" ${page >= totalPages ? 'disabled' : ''}>下一页</button>
3927
+ `;
3928
+ const prev = document.getElementById('devlog-prev');
3929
+ const next = document.getElementById('devlog-next');
3930
+ if (prev) prev.onclick = () => { devlogPage = Math.max(1, devlogPage - 1); loadDevLog(); };
3931
+ if (next) next.onclick = () => { devlogPage = Math.min(totalPages, devlogPage + 1); loadDevLog(); };
3932
+ }
3933
+ }
3934
+
3935
+ async function submitDevLog(e) {
3936
+ e.preventDefault();
3937
+ const content = document.getElementById('devlog-content')?.value?.trim();
3938
+ const important = document.getElementById('devlog-important')?.checked || false;
3939
+ const urgent = document.getElementById('devlog-urgent')?.checked || false;
3940
+ const type = document.getElementById('devlog-type')?.value || '需求';
3941
+
3942
+ if (!content) {
3943
+ showToast('请填写开发需求', 'error');
3944
+ return;
3945
+ }
3946
+
3947
+ try {
3948
+ // If editing, delete old record first
3949
+ if (_devlogEditingId) {
3950
+ await API.del('/api/devlog/' + _devlogEditingId);
3951
+ _devlogEditingId = null;
3952
+ _devlogClearEditHint();
3953
+ }
3954
+ await API.post('/api/devlog', { content, important, urgent, type });
3955
+ showToast('已提交', 'success');
3956
+ document.getElementById('form-new-devlog')?.reset();
3957
+ devlogPage = 1;
3958
+ loadDevLog();
3959
+ } catch (err) {
3960
+ showToast('提交失败: ' + err.message, 'error');
3961
+ }
3962
+ }
3963
+
3964
+ function _devlogStartEdit(id, content, important, urgent, type) {
3965
+ _devlogEditingId = id;
3966
+ const el = document.getElementById('devlog-content');
3967
+ if (el) { el.value = content; el.focus(); }
3968
+ const impEl = document.getElementById('devlog-important');
3969
+ if (impEl) impEl.checked = !!important;
3970
+ const urgEl = document.getElementById('devlog-urgent');
3971
+ if (urgEl) urgEl.checked = !!urgent;
3972
+ const typeEl = document.getElementById('devlog-type');
3973
+ if (typeEl) typeEl.value = type || '需求';
3974
+ // Show edit hint
3975
+ let hint = document.getElementById('devlog-edit-hint');
3976
+ if (!hint) {
3977
+ hint = document.createElement('div');
3978
+ hint.id = 'devlog-edit-hint';
3979
+ hint.style.cssText = 'margin-top:6px;font-size:12px;color:var(--primary);display:flex;align-items:center;gap:8px;';
3980
+ const form = document.getElementById('form-new-devlog');
3981
+ if (form) form.appendChild(hint);
3982
+ }
3983
+ hint.innerHTML = '正在编辑需求,提交后将替换原记录 <a href="#" id="devlog-cancel-edit" style="color:var(--gray-400);">取消编辑</a>';
3984
+ document.getElementById('devlog-cancel-edit')?.addEventListener('click', (e) => {
3985
+ e.preventDefault();
3986
+ _devlogEditingId = null;
3987
+ document.getElementById('form-new-devlog')?.reset();
3988
+ _devlogClearEditHint();
3989
+ });
3990
+ // Scroll to form
3991
+ document.getElementById('form-new-devlog')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
3992
+ }
3993
+
3994
+ function _devlogClearEditHint() {
3995
+ const hint = document.getElementById('devlog-edit-hint');
3996
+ if (hint) hint.remove();
3997
+ }
3998
+
3999
+ async function openDevLogDetail(id) {
4000
+ try {
4001
+ const r = await API.get('/api/devlog/' + id);
4002
+ devlogCurrentId = r.id;
4003
+
4004
+ document.getElementById('devlog-detail-content').textContent = r.content;
4005
+ document.getElementById('devlog-detail-type').value = r.type || '需求';
4006
+ document.getElementById('devlog-detail-important').checked = !!r.important;
4007
+ document.getElementById('devlog-detail-urgent').checked = !!r.urgent;
4008
+ document.getElementById('devlog-detail-status').value = r.status || 'pending';
4009
+ _setText('devlog-detail-created', formatTime(r.created_at));
4010
+
4011
+ // Completed time
4012
+ const completedGroup = document.getElementById('devlog-detail-completed-group');
4013
+ if (r.completed_at) {
4014
+ _setText('devlog-detail-completed', formatTime(r.completed_at));
4015
+ if (completedGroup) completedGroup.style.display = '';
4016
+ } else {
4017
+ if (completedGroup) completedGroup.style.display = 'none';
4018
+ }
4019
+
4020
+ document.getElementById('devlog-detail-modal')?.classList.remove('hidden');
4021
+ } catch (err) {
4022
+ showToast('加载详情失败: ' + err.message, 'error');
4023
+ }
4024
+ }
4025
+
4026
+ function closeDevLogDetail() {
4027
+ document.getElementById('devlog-detail-modal')?.classList.add('hidden');
4028
+ devlogCurrentId = null;
4029
+ }
4030
+
4031
+ async function saveDevLogStatus() {
4032
+ if (!devlogCurrentId) return;
4033
+ const status = document.getElementById('devlog-detail-status')?.value;
4034
+ const important = document.getElementById('devlog-detail-important')?.checked || false;
4035
+ const urgent = document.getElementById('devlog-detail-urgent')?.checked || false;
4036
+ const type = document.getElementById('devlog-detail-type')?.value || '需求';
4037
+ try {
4038
+ await API.put('/api/devlog/' + devlogCurrentId, { status, important, urgent, type });
4039
+ showToast('已保存', 'success');
4040
+ closeDevLogDetail();
4041
+ loadDevLog();
4042
+ } catch (err) {
4043
+ showToast('更新失败: ' + err.message, 'error');
4044
+ }
4045
+ }
4046
+
4047
+ async function deleteDevLog(id) {
4048
+ if (!confirm('确定要删除这条开发记录吗?')) return;
4049
+ try {
4050
+ await API.del('/api/devlog/' + id);
4051
+ showToast('已删除', 'success');
4052
+ closeDevLogDetail();
4053
+ loadDevLog();
4054
+ } catch (err) {
4055
+ showToast('删除失败: ' + err.message, 'error');
4056
+ }
4057
+ }
4058
+
4059
+ // --- Dev Log Archive ---
4060
+
4061
+ async function openArchiveCutoffModal() {
4062
+ const modal = document.getElementById('devlog-archive-cutoff-modal');
4063
+ const input = document.getElementById('devlog-archive-cutoff-input');
4064
+ const hint = document.getElementById('devlog-archive-cutoff-hint');
4065
+ if (!modal || !input) return;
4066
+
4067
+ // Fetch last cutoff, fallback to end of day before yesterday
4068
+ let defaultDate = null;
4069
+ let hintText = '';
4070
+ try {
4071
+ const data = await API.get('/api/devlog/archive/last-cutoff');
4072
+ if (data.last_cutoff) {
4073
+ defaultDate = new Date(data.last_cutoff);
4074
+ if (!isNaN(defaultDate.getTime())) {
4075
+ hintText = '默认为上次归档截止时间';
4076
+ } else {
4077
+ defaultDate = null;
4078
+ }
4079
+ }
4080
+ } catch (_) { /* ignore */ }
4081
+
4082
+ if (!defaultDate) {
4083
+ defaultDate = new Date();
4084
+ defaultDate.setDate(defaultDate.getDate() - 2);
4085
+ defaultDate.setHours(23, 59, 0, 0);
4086
+ hintText = '默认为前天 23:59(首次归档)';
4087
+ }
4088
+
4089
+ if (hint) hint.textContent = hintText;
4090
+
4091
+ // Format for datetime-local input: YYYY-MM-DDTHH:MM
4092
+ const pad = n => String(n).padStart(2, '0');
4093
+ input.value = `${defaultDate.getFullYear()}-${pad(defaultDate.getMonth()+1)}-${pad(defaultDate.getDate())}T${pad(defaultDate.getHours())}:${pad(defaultDate.getMinutes())}`;
4094
+ modal.classList.remove('hidden');
4095
+ }
4096
+
4097
+ function closeArchiveCutoffModal() {
4098
+ document.getElementById('devlog-archive-cutoff-modal')?.classList.add('hidden');
4099
+ }
4100
+
4101
+ async function confirmArchiveDevLog() {
4102
+ const input = document.getElementById('devlog-archive-cutoff-input');
4103
+ if (!input || !input.value) {
4104
+ showToast('请选择截止时间', 'error');
4105
+ return;
4106
+ }
4107
+ // Convert local datetime-local value to ISO string with timezone
4108
+ const localDate = new Date(input.value);
4109
+ const cutoff = localDate.toISOString();
4110
+
4111
+ closeArchiveCutoffModal();
4112
+ try {
4113
+ const result = await API.post('/api/devlog/archive', { cutoff });
4114
+ if (result.archived_count > 0) {
4115
+ showToast(`已归档 ${result.archived_count} 条记录`, 'success');
4116
+ loadDevLog();
4117
+ } else {
4118
+ // Format cutoff as local time for display
4119
+ let msg = result.message || '没有需要归档的记录';
4120
+ if (result.cutoff) {
4121
+ const localCutoff = new Date(result.cutoff);
4122
+ const cutoffStr = localCutoff.toLocaleString('zh-CN', { year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' });
4123
+ msg = `没有符合条件的记录(共 ${result.total_done || 0} 条已完成,截止时间 ${cutoffStr})`;
4124
+ }
4125
+ showToast(msg, 'info');
4126
+ }
4127
+ } catch (err) {
4128
+ showToast('归档失败: ' + err.message, 'error');
4129
+ }
4130
+ }
4131
+
4132
+ async function loadDevLogArchives() {
4133
+ const modal = document.getElementById('devlog-archive-modal');
4134
+ const body = document.getElementById('devlog-archive-body');
4135
+ if (!modal || !body) return;
4136
+
4137
+ body.innerHTML = '<p class="text-muted" style="text-align:center;padding:24px;">加载中...</p>';
4138
+ modal.classList.remove('hidden');
4139
+
4140
+ try {
4141
+ const data = await API.get('/api/devlog/archives');
4142
+ const dates = data.dates || [];
4143
+ if (dates.length === 0) {
4144
+ body.innerHTML = '<p class="text-muted" style="text-align:center;padding:24px;">暂无归档记录</p>';
4145
+ return;
4146
+ }
4147
+ body.innerHTML = dates.map(d => {
4148
+ const formatted = d.length === 8
4149
+ ? `${d.substring(0,4)}-${d.substring(4,6)}-${d.substring(6,8)}`
4150
+ : d;
4151
+ return `<div class="devlog-archive-date" data-date="${escapeHtml(d)}" style="padding:10px 12px;border-bottom:1px solid var(--gray-200);cursor:pointer;display:flex;align-items:center;gap:8px;">
4152
+ <span style="font-size:14px;color:var(--gray-500);">&#9654;</span>
4153
+ <span style="font-weight:500;">${escapeHtml(formatted)}</span>
4154
+ </div>
4155
+ <div class="devlog-archive-records hidden" id="devlog-archive-records-${escapeHtml(d)}" style="padding:0 12px;background:var(--gray-50);"></div>`;
4156
+ }).join('');
4157
+ } catch (err) {
4158
+ body.innerHTML = `<p class="text-muted" style="text-align:center;padding:24px;">加载失败: ${escapeHtml(err.message)}</p>`;
4159
+ }
4160
+ }
4161
+
4162
+ async function loadDevLogArchiveDetail(dateStr) {
4163
+ const container = document.getElementById('devlog-archive-records-' + dateStr);
4164
+ if (!container) return;
4165
+
4166
+ // Toggle: if already visible, hide it
4167
+ if (!container.classList.contains('hidden')) {
4168
+ container.classList.add('hidden');
4169
+ return;
4170
+ }
4171
+
4172
+ container.innerHTML = '<p class="text-muted" style="padding:12px;">加载中...</p>';
4173
+ container.classList.remove('hidden');
4174
+
4175
+ try {
4176
+ const data = await API.get('/api/devlog/archives/' + dateStr);
4177
+ const records = data.records || [];
4178
+ if (records.length === 0) {
4179
+ container.innerHTML = '<p class="text-muted" style="padding:12px;">无记录</p>';
4180
+ return;
4181
+ }
4182
+ const statusLabels = { pending: '待处理', in_progress: '进行中', done: '已完成' };
4183
+ container.innerHTML = records.map(r => {
4184
+ const tags = [];
4185
+ if (r.important) tags.push('<span class="badge badge-error">重要</span>');
4186
+ if (r.urgent) tags.push('<span class="badge badge-warning">优先</span>');
4187
+ const tagStr = tags.length ? ' ' + tags.join(' ') : '';
4188
+ return `<div style="padding:8px 0;border-bottom:1px solid var(--gray-200);font-size:13px;line-height:1.5;">
4189
+ <div style="white-space:pre-wrap;color:var(--gray-700);">${escapeHtml(r.content)}</div>
4190
+ <div style="margin-top:4px;color:var(--gray-400);font-size:12px;">
4191
+ ${escapeHtml(statusLabels[r.status] || r.status)}${tagStr}
4192
+ &nbsp;&middot;&nbsp;完成于 ${formatTime(r.completed_at)}
4193
+ </div>
4194
+ </div>`;
4195
+ }).join('');
4196
+ } catch (err) {
4197
+ container.innerHTML = `<p class="text-muted" style="padding:12px;">加载失败: ${escapeHtml(err.message)}</p>`;
4198
+ }
4199
+ }
4200
+
4201
+ function closeDevLogArchiveModal() {
4202
+ document.getElementById('devlog-archive-modal')?.classList.add('hidden');
4203
+ }
4204
+
4205
+ // ============================================================
4206
+ // Initialization
4207
+ // ============================================================
4208
+ document.addEventListener('DOMContentLoaded', () => {
4209
+ // Navigation click handlers
4210
+ document.querySelectorAll('.nav-item').forEach((item) => {
4211
+ item.addEventListener('click', (e) => {
4212
+ e.preventDefault();
4213
+ const page = item.dataset.page;
4214
+ if (page) navigate(page);
4215
+ });
4216
+ });
4217
+
4218
+ // New call form
4219
+ const newCallForm = document.getElementById('new-call-form');
4220
+ if (newCallForm) {
4221
+ newCallForm.addEventListener('submit', (e) => {
4222
+ e.preventDefault();
4223
+ submitNewCall();
4224
+ });
4225
+ }
4226
+
4227
+ // New call button
4228
+ const newCallBtn = document.getElementById('btn-new-call');
4229
+ if (newCallBtn) newCallBtn.addEventListener('click', showNewCallForm);
4230
+
4231
+ // New SMS form
4232
+ const newSMSForm = document.getElementById('new-sms-form');
4233
+ if (newSMSForm) {
4234
+ newSMSForm.addEventListener('submit', (e) => {
4235
+ e.preventDefault();
4236
+ submitNewSMS();
4237
+ });
4238
+ }
4239
+
4240
+ // New SMS button
4241
+ const newSMSBtn = document.getElementById('btn-new-sms');
4242
+ if (newSMSBtn) newSMSBtn.addEventListener('click', showNewSMSForm);
4243
+
4244
+ // New contact form
4245
+ const newContactForm = document.getElementById('new-contact-form');
4246
+ if (newContactForm) {
4247
+ newContactForm.addEventListener('submit', (e) => {
4248
+ e.preventDefault();
4249
+ submitNewContact();
4250
+ });
4251
+ }
4252
+
4253
+ // Edit contact form
4254
+ const editContactForm = document.getElementById('edit-contact-form');
4255
+ if (editContactForm) {
4256
+ editContactForm.addEventListener('submit', (e) => {
4257
+ e.preventDefault();
4258
+ updateContact();
4259
+ });
4260
+ }
4261
+
4262
+ // New contact button
4263
+ const newContactBtn = document.getElementById('btn-new-contact');
4264
+ if (newContactBtn) newContactBtn.addEventListener('click', showNewContactForm);
4265
+
4266
+ // Sync contacts button
4267
+ const syncBtn = document.getElementById('btn-sync-contacts');
4268
+ if (syncBtn) syncBtn.addEventListener('click', syncContacts);
4269
+
4270
+ // Contact search input
4271
+ const contactSearch = document.getElementById('contact-search-input');
4272
+ if (contactSearch) {
4273
+ contactSearch.addEventListener('input', (e) => {
4274
+ onContactSearch(e.target.value);
4275
+ });
4276
+ }
4277
+
4278
+ // Config auto-save: listen for changes on all form fields
4279
+ const configForm = document.getElementById('form-config');
4280
+ if (configForm) {
4281
+ configForm.addEventListener('submit', (e) => e.preventDefault());
4282
+ configForm.querySelectorAll('input, select, textarea').forEach((field) => {
4283
+ if (field.type === 'range') {
4284
+ field.addEventListener('change', _debouncedSaveConfig);
4285
+ } else if (field.tagName === 'SELECT') {
4286
+ field.addEventListener('change', _debouncedSaveConfig);
4287
+ } else {
4288
+ field.addEventListener('input', _debouncedSaveConfig);
4289
+ }
4290
+ });
4291
+ }
4292
+
4293
+ // Provider tabs — also trigger auto-save on tab switch
4294
+ document.querySelectorAll('.provider-tab').forEach((tab) => {
4295
+ tab.addEventListener('click', () => {
4296
+ switchProviderTab(tab.dataset.provider);
4297
+ _debouncedSaveConfig();
4298
+ });
4299
+ });
4300
+
4301
+ // Basic Info: AI phone & owner add buttons
4302
+ const btnAddAiPhone = document.getElementById('btn-add-ai-phone');
4303
+ if (btnAddAiPhone) btnAddAiPhone.addEventListener('click', _addAiPhone);
4304
+ const aiPhoneInput = document.getElementById('ai-phone-input');
4305
+ if (aiPhoneInput) aiPhoneInput.addEventListener('keydown', (e) => {
4306
+ if (e.key === 'Enter') { e.preventDefault(); _addAiPhone(); }
4307
+ });
4308
+ const btnAddOwner = document.getElementById('btn-add-owner');
4309
+ if (btnAddOwner) btnAddOwner.addEventListener('click', _addOwner);
4310
+ const ownerPhoneInput = document.getElementById('owner-phone-input');
4311
+ if (ownerPhoneInput) ownerPhoneInput.addEventListener('keydown', (e) => {
4312
+ if (e.key === 'Enter') { e.preventDefault(); _addOwner(); }
4313
+ });
4314
+
4315
+ // Refresh models buttons (HTML uses data-action="refresh-models")
4316
+ document.querySelectorAll('[data-action="refresh-models"]').forEach((btn) => {
4317
+ btn.addEventListener('click', () => {
4318
+ const provider = btn.dataset.provider;
4319
+ if (provider) refreshModels(provider);
4320
+ });
4321
+ });
4322
+
4323
+ // Temperature slider live display
4324
+ const tempSlider = document.getElementById('llm-temperature');
4325
+ if (tempSlider) {
4326
+ tempSlider.addEventListener('input', () => {
4327
+ const span = document.getElementById('llm-temperature-value');
4328
+ if (span) span.textContent = tempSlider.value;
4329
+ });
4330
+ }
4331
+
4332
+ // Chat test dialog
4333
+ const testModelBtn = document.getElementById('btn-test-model');
4334
+ if (testModelBtn) testModelBtn.addEventListener('click', openChatTest);
4335
+
4336
+ const chatTestCloseBtn = document.getElementById('chat-test-close');
4337
+ if (chatTestCloseBtn) chatTestCloseBtn.addEventListener('click', closeChatTest);
4338
+
4339
+ const chatTestOverlay = document.getElementById('chat-test-overlay');
4340
+ if (chatTestOverlay) {
4341
+ chatTestOverlay.addEventListener('click', (e) => {
4342
+ if (e.target === chatTestOverlay) closeChatTest();
4343
+ });
4344
+ }
4345
+
4346
+ const chatTestSendBtn = document.getElementById('chat-test-send');
4347
+ if (chatTestSendBtn) chatTestSendBtn.addEventListener('click', sendChatTestMessage);
4348
+
4349
+ const chatTestInput = document.getElementById('chat-test-input');
4350
+ if (chatTestInput) {
4351
+ chatTestInput.addEventListener('keydown', (e) => {
4352
+ if (e.key === 'Enter' && !e.shiftKey) {
4353
+ e.preventDefault();
4354
+ sendChatTestMessage();
4355
+ }
4356
+ });
4357
+ // Auto-resize textarea
4358
+ chatTestInput.addEventListener('input', () => {
4359
+ chatTestInput.style.height = 'auto';
4360
+ chatTestInput.style.height = Math.min(chatTestInput.scrollHeight, 120) + 'px';
4361
+ });
4362
+ }
4363
+
4364
+ const chatTestRefreshBtn = document.getElementById('chat-test-refresh');
4365
+ if (chatTestRefreshBtn) chatTestRefreshBtn.addEventListener('click', refreshChatTestModels);
4366
+
4367
+ const chatTestClearBtn = document.getElementById('chat-test-clear');
4368
+ if (chatTestClearBtn) chatTestClearBtn.addEventListener('click', () => {
4369
+ chatTestMessages = [];
4370
+ const container = document.getElementById('chat-test-messages');
4371
+ if (container) container.innerHTML = '<div class="chat-test-empty">选择模型后开始对话</div>';
4372
+ });
4373
+
4374
+ const chatTestProviderSelect = document.getElementById('chat-test-provider');
4375
+ if (chatTestProviderSelect) {
4376
+ chatTestProviderSelect.addEventListener('change', refreshChatTestModels);
4377
+ }
4378
+
4379
+ // ASR provider switching
4380
+ const asrProviderSelect = document.getElementById('asr-provider');
4381
+ if (asrProviderSelect) {
4382
+ asrProviderSelect.addEventListener('change', () => switchAsrProvider(asrProviderSelect.value));
4383
+ }
4384
+
4385
+ // TTS provider switching
4386
+ const ttsProviderSelect = document.getElementById('tts-provider');
4387
+ if (ttsProviderSelect) {
4388
+ ttsProviderSelect.addEventListener('change', () => switchTtsProvider(ttsProviderSelect.value));
4389
+ }
4390
+ // TTS slider value displays
4391
+ ['tts-volc-speed', 'tts-volc-volume'].forEach(id => {
4392
+ const el = document.getElementById(id);
4393
+ if (el) el.addEventListener('input', () => {
4394
+ const valEl = document.getElementById(id + '-val');
4395
+ if (valEl) valEl.textContent = el.value;
4396
+ });
4397
+ });
4398
+ ['tts-tc-speed', 'tts-tc-volume'].forEach(id => {
4399
+ const el = document.getElementById(id);
4400
+ if (el) el.addEventListener('input', () => {
4401
+ const valEl = document.getElementById(id + '-val');
4402
+ if (valEl) valEl.textContent = el.value;
4403
+ });
4404
+ });
4405
+
4406
+ // TTS Test dialog
4407
+ const btnTestTts = document.getElementById('btn-test-tts');
4408
+ if (btnTestTts) btnTestTts.addEventListener('click', openTtsTest);
4409
+
4410
+ const ttsTestCloseBtn = document.getElementById('tts-test-close');
4411
+ if (ttsTestCloseBtn) ttsTestCloseBtn.addEventListener('click', closeTtsTest);
4412
+
4413
+ const ttsTestOverlay = document.getElementById('tts-test-overlay');
4414
+ if (ttsTestOverlay) {
4415
+ ttsTestOverlay.addEventListener('click', (e) => {
4416
+ if (e.target === ttsTestOverlay) closeTtsTest();
4417
+ });
4418
+ }
4419
+
4420
+ const ttsTestPlayBtn = document.getElementById('tts-test-play');
4421
+ if (ttsTestPlayBtn) ttsTestPlayBtn.addEventListener('click', runTtsTest);
4422
+
4423
+ const ttsTestStopBtn = document.getElementById('tts-test-stop');
4424
+ if (ttsTestStopBtn) ttsTestStopBtn.addEventListener('click', _stopTtsTestAudio);
4425
+
4426
+ const ttsTestProviderSel = document.getElementById('tts-test-provider');
4427
+ if (ttsTestProviderSel) {
4428
+ ttsTestProviderSel.addEventListener('change', _updateTtsTestVoices);
4429
+ }
4430
+
4431
+ const ttsTestSpeedSlider = document.getElementById('tts-test-speed');
4432
+ if (ttsTestSpeedSlider) {
4433
+ ttsTestSpeedSlider.addEventListener('input', () => {
4434
+ const valEl = document.getElementById('tts-test-speed-val');
4435
+ if (valEl) valEl.textContent = ttsTestSpeedSlider.value + 'x';
4436
+ });
4437
+ }
4438
+
4439
+ // ASR Test dialog
4440
+ const btnTestAsr = document.getElementById('btn-test-asr');
4441
+ if (btnTestAsr) btnTestAsr.addEventListener('click', openAsrTest);
4442
+
4443
+ const asrTestCloseBtn = document.getElementById('asr-test-close');
4444
+ if (asrTestCloseBtn) asrTestCloseBtn.addEventListener('click', closeAsrTest);
4445
+
4446
+ const asrTestOverlay = document.getElementById('asr-test-overlay');
4447
+ if (asrTestOverlay) {
4448
+ asrTestOverlay.addEventListener('click', (e) => {
4449
+ if (e.target === asrTestOverlay) closeAsrTest();
4450
+ });
4451
+ }
4452
+
4453
+ const asrTestStartBtn = document.getElementById('asr-test-start');
4454
+ if (asrTestStartBtn) asrTestStartBtn.addEventListener('click', startAsrRecording);
4455
+
4456
+ const asrTestStopBtn = document.getElementById('asr-test-stop');
4457
+ if (asrTestStopBtn) asrTestStopBtn.addEventListener('click', stopAsrRecording);
4458
+
4459
+ const asrTestPlayBtn = document.getElementById('asr-test-play');
4460
+ if (asrTestPlayBtn) asrTestPlayBtn.addEventListener('click', playAsrRecording);
4461
+
4462
+ // Real-time restart when ASR test params change during recording
4463
+ const asrProviderSel = document.getElementById('asr-test-provider');
4464
+ if (asrProviderSel) {
4465
+ asrProviderSel.addEventListener('change', () => {
4466
+ _updateEngineOptions(asrProviderSel.value);
4467
+ _restartAsrSession();
4468
+ });
4469
+ }
4470
+ ['asr-test-resid', 'asr-test-samplerate', 'asr-test-mic'].forEach(id => {
4471
+ const el = document.getElementById(id);
4472
+ if (el) el.addEventListener('change', () => _restartAsrSession());
4473
+ });
4474
+
4475
+ // Audio diagnostic dialog
4476
+ const btnAudioDiag = document.getElementById('btn-audio-diag');
4477
+ if (btnAudioDiag) btnAudioDiag.addEventListener('click', openAudioDiag);
4478
+ const audioDiagClose = document.getElementById('audio-diag-close');
4479
+ if (audioDiagClose) audioDiagClose.addEventListener('click', closeAudioDiag);
4480
+ const audioDiagOverlay = document.getElementById('audio-diag-overlay');
4481
+ if (audioDiagOverlay) audioDiagOverlay.addEventListener('click', (e) => { if (e.target === audioDiagOverlay) closeAudioDiag(); });
4482
+ const audioDiagRun = document.getElementById('audio-diag-run');
4483
+ if (audioDiagRun) audioDiagRun.addEventListener('click', runAudioDiag);
4484
+ const audioDiagStop = document.getElementById('audio-diag-stop');
4485
+ if (audioDiagStop) audioDiagStop.addEventListener('click', stopAudioDiag);
4486
+ const audioDiagPlaySP = document.getElementById('audio-diag-play-sp');
4487
+ if (audioDiagPlaySP) audioDiagPlaySP.addEventListener('click', playDiagSP);
4488
+ const audioDiagPlayMR = document.getElementById('audio-diag-play-mr');
4489
+ if (audioDiagPlayMR) audioDiagPlayMR.addEventListener('click', playDiagMR);
4490
+
4491
+ // Mic gain slider
4492
+ const micGainSlider = document.getElementById('asr-mic-gain');
4493
+ if (micGainSlider) {
4494
+ micGainSlider.addEventListener('input', () => {
4495
+ const val = parseInt(micGainSlider.value);
4496
+ const label = document.getElementById('asr-mic-gain-val');
4497
+ if (label) label.textContent = val + '%';
4498
+ localStorage.setItem('asr-mic-gain', val);
4499
+ // Update whichever pipeline is active (shared gain control)
4500
+ if (asrTestUserGainNode) asrTestUserGainNode.gain.value = val / 100;
4501
+ if (vcGainNode) vcGainNode.gain.value = val / 100;
4502
+ });
4503
+ }
4504
+ // Mute toggle
4505
+ const muteBtn = document.getElementById('asr-mute-btn');
4506
+ if (muteBtn) {
4507
+ muteBtn.addEventListener('click', () => {
4508
+ asrTestMuted = !asrTestMuted;
4509
+ muteBtn.classList.toggle('muted', asrTestMuted);
4510
+ // Mute/unmute the media stream track
4511
+ if (asrTestMediaStream) {
4512
+ asrTestMediaStream.getAudioTracks().forEach(t => { t.enabled = !asrTestMuted; });
4513
+ }
4514
+ });
4515
+ }
4516
+
4517
+ // Bluetooth buttons
4518
+ const scanBtn = document.getElementById('btn-bt-scan');
4519
+ if (scanBtn) scanBtn.addEventListener('click', scanDevices);
4520
+
4521
+ const btConnectBtn = document.getElementById('btn-bt-connect');
4522
+ if (btConnectBtn) {
4523
+ btConnectBtn.addEventListener('click', () => {
4524
+ const address = _val('cfg-bt-device-address') || prompt('请输入设备地址 (MAC)');
4525
+ if (address) connectDevice(address);
4526
+ });
4527
+ }
4528
+
4529
+ const btDisconnectBtn = document.getElementById('btn-bt-disconnect');
4530
+ if (btDisconnectBtn) btDisconnectBtn.addEventListener('click', disconnectDevice);
4531
+
4532
+ // Call detail back button
4533
+ const backBtn = document.getElementById('btn-call-detail-back');
4534
+ if (backBtn) backBtn.addEventListener('click', closeCallDetail);
4535
+
4536
+ // Modal close handlers — close when clicking backdrop or close button
4537
+ document.querySelectorAll('.modal-backdrop, .modal-close').forEach((el) => {
4538
+ el.addEventListener('click', (e) => {
4539
+ const modal = e.target.closest('.modal');
4540
+ if (modal) closeModal(modal.id);
4541
+ });
4542
+ });
4543
+
4544
+ // Close modals with Escape key
4545
+ document.addEventListener('keydown', (e) => {
4546
+ if (e.key === 'Escape') {
4547
+ document.querySelectorAll('.modal.modal-open').forEach((modal) => {
4548
+ closeModal(modal.id);
4549
+ });
4550
+ }
4551
+ });
4552
+
4553
+ // Voice chat buttons
4554
+ const vcStartBtn = document.getElementById('vc-btn-start');
4555
+ if (vcStartBtn) vcStartBtn.addEventListener('click', startVoiceChat);
4556
+
4557
+ const vcStopBtn = document.getElementById('vc-btn-stop');
4558
+ if (vcStopBtn) vcStopBtn.addEventListener('click', stopVoiceChat);
4559
+
4560
+ // Voice chat system prompt auto-save
4561
+ const vcPromptEl = document.getElementById('vc-system-prompt');
4562
+ if (vcPromptEl) {
4563
+ const saved = localStorage.getItem('vc-system-prompt');
4564
+ if (saved) vcPromptEl.value = saved;
4565
+ vcPromptEl.addEventListener('input', () => {
4566
+ localStorage.setItem('vc-system-prompt', vcPromptEl.value);
4567
+ // Send live update to backend if connected
4568
+ if (vcWs && vcWs.readyState === WebSocket.OPEN) {
4569
+ vcWs.send(JSON.stringify({ type: 'update_system_prompt', system_prompt: vcPromptEl.value.trim() }));
4570
+ vcLog('系统提示词已更新(下次 LLM 调用生效)');
4571
+ }
4572
+ });
4573
+ }
4574
+
4575
+ // Voice chat VAD params — live update + auto-save
4576
+ function _vcSendVadUpdate() {
4577
+ const threshold = parseInt(document.getElementById('vc-vad-threshold')?.value) || 300;
4578
+ const silence = parseInt(document.getElementById('vc-vad-silence')?.value) || 800;
4579
+ const minSpeech = parseInt(document.getElementById('vc-vad-min-speech')?.value) || 250;
4580
+ // Save to localStorage
4581
+ localStorage.setItem('vc-vad-threshold', threshold);
4582
+ localStorage.setItem('vc-vad-silence', silence);
4583
+ localStorage.setItem('vc-vad-min-speech', minSpeech);
4584
+ // Save to backend config
4585
+ API.post('/api/config', {
4586
+ vad: { energy_threshold: threshold, silence_threshold_ms: silence, min_speech_ms: minSpeech }
4587
+ }).catch(() => {});
4588
+ // Send live update via WebSocket if connected
4589
+ if (vcWs && vcWs.readyState === WebSocket.OPEN) {
4590
+ vcWs.send(JSON.stringify({
4591
+ type: 'update_vad',
4592
+ energy_threshold: threshold,
4593
+ silence_threshold_ms: silence,
4594
+ min_speech_ms: minSpeech,
4595
+ }));
4596
+ vcLog(`VAD 参数已更新: 阈值=${threshold} 静音=${silence}ms 最短=${minSpeech}ms`, 'info');
4597
+ }
4598
+ }
4599
+ ['vc-vad-threshold', 'vc-vad-silence', 'vc-vad-min-speech'].forEach(id => {
4600
+ const el = document.getElementById(id);
4601
+ if (el) el.addEventListener('change', _vcSendVadUpdate);
4602
+ });
4603
+
4604
+ // Voice chat phone simulation — auto-save
4605
+ ['vc-phone-direction', 'vc-phone-number', 'vc-phone-name'].forEach(id => {
4606
+ const el = document.getElementById(id);
4607
+ if (!el) return;
4608
+ const saved = localStorage.getItem(id);
4609
+ if (saved !== null) el.value = saved;
4610
+ el.addEventListener('change', () => localStorage.setItem(id, el.value));
4611
+ if (el.tagName === 'INPUT') el.addEventListener('input', () => localStorage.setItem(id, el.value));
4612
+ });
4613
+
4614
+ // Dev Log
4615
+ const devlogForm = document.getElementById('form-new-devlog');
4616
+ if (devlogForm) devlogForm.addEventListener('submit', submitDevLog);
4617
+
4618
+ const devlogFilterStatus = document.getElementById('devlog-filter-status');
4619
+ if (devlogFilterStatus) devlogFilterStatus.addEventListener('change', () => { devlogPage = 1; loadDevLog(); });
4620
+
4621
+ document.getElementById('devlog-detail-close')?.addEventListener('click', closeDevLogDetail);
4622
+ document.getElementById('devlog-detail-save')?.addEventListener('click', saveDevLogStatus);
4623
+ document.getElementById('devlog-detail-delete')?.addEventListener('click', () => deleteDevLog(devlogCurrentId));
4624
+
4625
+ // Delegated click handlers for devlog table
4626
+ document.getElementById('devlog-tbody')?.addEventListener('click', (e) => {
4627
+ const viewLink = e.target.closest('.devlog-view-link');
4628
+ if (viewLink) { e.preventDefault(); openDevLogDetail(viewLink.dataset.id); return; }
4629
+ const editLink = e.target.closest('.devlog-edit-link');
4630
+ if (editLink) {
4631
+ e.preventDefault();
4632
+ _devlogStartEdit(
4633
+ editLink.dataset.id,
4634
+ editLink.dataset.content,
4635
+ editLink.dataset.important === '1',
4636
+ editLink.dataset.urgent === '1',
4637
+ editLink.dataset.type,
4638
+ );
4639
+ }
4640
+ });
4641
+
4642
+ // Close devlog modal on overlay click
4643
+ document.getElementById('devlog-detail-modal')?.addEventListener('click', (e) => {
4644
+ if (e.target.id === 'devlog-detail-modal') closeDevLogDetail();
4645
+ });
4646
+
4647
+ // Dev Log Archive
4648
+ document.getElementById('devlog-btn-archive')?.addEventListener('click', openArchiveCutoffModal);
4649
+ document.getElementById('devlog-btn-view-archives')?.addEventListener('click', loadDevLogArchives);
4650
+ document.getElementById('devlog-archive-close')?.addEventListener('click', closeDevLogArchiveModal);
4651
+ document.getElementById('devlog-archive-modal')?.addEventListener('click', (e) => {
4652
+ if (e.target.id === 'devlog-archive-modal') closeDevLogArchiveModal();
4653
+ });
4654
+ document.getElementById('devlog-archive-cutoff-close')?.addEventListener('click', closeArchiveCutoffModal);
4655
+ document.getElementById('devlog-archive-cutoff-cancel')?.addEventListener('click', closeArchiveCutoffModal);
4656
+ document.getElementById('devlog-archive-cutoff-confirm')?.addEventListener('click', confirmArchiveDevLog);
4657
+ document.getElementById('devlog-archive-cutoff-modal')?.addEventListener('click', (e) => {
4658
+ if (e.target.id === 'devlog-archive-cutoff-modal') closeArchiveCutoffModal();
4659
+ });
4660
+ // Delegated click for archive date items
4661
+ document.getElementById('devlog-archive-body')?.addEventListener('click', (e) => {
4662
+ const dateEl = e.target.closest('.devlog-archive-date');
4663
+ if (dateEl) loadDevLogArchiveDetail(dateEl.dataset.date);
4664
+ });
4665
+
4666
+ // Navigate to last active page (or dashboard)
4667
+ navigate(localStorage.getItem('activePage') || 'dashboard');
4668
+
4669
+ // Initial status bar update
4670
+ updateStatusBar();
4671
+ });