openclaw-speech-input 2026.3.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +166 -0
- package/index.ts +206 -0
- package/openclaw.plugin.json +15 -0
- package/package.json +37 -0
- package/tsconfig.json +16 -0
- package/web.html +1571 -0
package/web.html
ADDED
|
@@ -0,0 +1,1571 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>OpenClaw Speech To Text</title>
|
|
7
|
+
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;700;800&family=DM+Mono:wght@300;400&display=swap" rel="stylesheet" />
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
--bg: #0a0a0f;
|
|
11
|
+
--surface: #13131a;
|
|
12
|
+
--surface2: #1c1c28;
|
|
13
|
+
--border: rgba(255, 255, 255, 0.07);
|
|
14
|
+
--border2: rgba(255, 255, 255, 0.14);
|
|
15
|
+
--accent: #7c6dff;
|
|
16
|
+
--accent2: #ff6b9d;
|
|
17
|
+
--accent3: #4de8c2;
|
|
18
|
+
--text: #f0f0f8;
|
|
19
|
+
--muted: #6e6e8a;
|
|
20
|
+
--error: #ff5555;
|
|
21
|
+
--success: #4de8c2;
|
|
22
|
+
--font: "Syne", sans-serif;
|
|
23
|
+
--mono: "DM Mono", monospace;
|
|
24
|
+
}
|
|
25
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
26
|
+
body {
|
|
27
|
+
font-family: var(--font);
|
|
28
|
+
background: var(--bg);
|
|
29
|
+
color: var(--text);
|
|
30
|
+
min-height: 100vh;
|
|
31
|
+
overflow-x: hidden;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* ── Background ── */
|
|
35
|
+
.bg-canvas { position: fixed; inset: 0; z-index: 0; overflow: hidden; }
|
|
36
|
+
.orb {
|
|
37
|
+
position: absolute; border-radius: 50%;
|
|
38
|
+
filter: blur(80px); opacity: 0.15;
|
|
39
|
+
animation: drift 20s infinite alternate ease-in-out;
|
|
40
|
+
}
|
|
41
|
+
.orb1 { width: 600px; height: 600px; background: var(--accent); top: -200px; left: -100px; }
|
|
42
|
+
.orb2 { width: 500px; height: 500px; background: var(--accent2); bottom: -150px; right: -100px; animation-delay: -7s; }
|
|
43
|
+
.orb3 { width: 400px; height: 400px; background: var(--accent3); top: 40%; left: 40%; animation-delay: -14s; }
|
|
44
|
+
@keyframes drift {
|
|
45
|
+
0% { transform: translate(0, 0) scale(1); }
|
|
46
|
+
33% { transform: translate(40px, -60px) scale(1.05); }
|
|
47
|
+
66% { transform: translate(-30px, 40px) scale(0.95); }
|
|
48
|
+
100% { transform: translate(20px, -20px) scale(1.02); }
|
|
49
|
+
}
|
|
50
|
+
.grid-lines {
|
|
51
|
+
position: fixed; inset: 0;
|
|
52
|
+
background-image:
|
|
53
|
+
linear-gradient(rgba(255,255,255,0.015) 1px, transparent 1px),
|
|
54
|
+
linear-gradient(90deg, rgba(255,255,255,0.015) 1px, transparent 1px);
|
|
55
|
+
background-size: 48px 48px; z-index: 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* ── Scrollbar ── */
|
|
59
|
+
::-webkit-scrollbar { width: 4px; height: 4px; }
|
|
60
|
+
::-webkit-scrollbar-track { background: transparent; }
|
|
61
|
+
::-webkit-scrollbar-thumb {
|
|
62
|
+
background: linear-gradient(180deg, rgba(124,109,255,.35), rgba(255,107,157,.25));
|
|
63
|
+
border-radius: 99px;
|
|
64
|
+
}
|
|
65
|
+
::-webkit-scrollbar-thumb:hover {
|
|
66
|
+
background: linear-gradient(180deg, rgba(124,109,255,.7), rgba(255,107,157,.5));
|
|
67
|
+
}
|
|
68
|
+
::-webkit-scrollbar-corner { background: transparent; }
|
|
69
|
+
* { scrollbar-width: thin; scrollbar-color: rgba(124,109,255,.35) transparent; }
|
|
70
|
+
|
|
71
|
+
/* ── Layout ── */
|
|
72
|
+
.app {
|
|
73
|
+
position: relative; z-index: 1;
|
|
74
|
+
display: grid;
|
|
75
|
+
grid-template-columns: 1fr;
|
|
76
|
+
grid-template-rows: 60px 1fr;
|
|
77
|
+
height: 100vh;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* ── Header ── */
|
|
81
|
+
.header {
|
|
82
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
83
|
+
padding: 0 24px;
|
|
84
|
+
border-bottom: 1px solid var(--border);
|
|
85
|
+
background: rgba(10,10,15,.85);
|
|
86
|
+
backdrop-filter: blur(20px);
|
|
87
|
+
}
|
|
88
|
+
.logo {
|
|
89
|
+
display: flex; align-items: center; gap: 10px;
|
|
90
|
+
font-size: 18px; font-weight: 800; letter-spacing: -0.02em;
|
|
91
|
+
}
|
|
92
|
+
.logo-icon {
|
|
93
|
+
width: 28px; height: 28px;
|
|
94
|
+
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
|
95
|
+
border-radius: 8px;
|
|
96
|
+
display: flex; align-items: center; justify-content: center;
|
|
97
|
+
font-size: 14px;
|
|
98
|
+
}
|
|
99
|
+
.header-right {
|
|
100
|
+
display: flex; align-items: center; gap: 14px;
|
|
101
|
+
font-size: 13px; color: var(--muted); font-family: var(--mono);
|
|
102
|
+
}
|
|
103
|
+
.status-dot {
|
|
104
|
+
width: 8px; height: 8px; border-radius: 50%;
|
|
105
|
+
background: var(--muted);
|
|
106
|
+
transition: background .3s, box-shadow .3s;
|
|
107
|
+
}
|
|
108
|
+
.status-dot.live { background: var(--success); box-shadow: 0 0 8px var(--success); }
|
|
109
|
+
.status-dot.error { background: var(--error); }
|
|
110
|
+
|
|
111
|
+
/* ── Panel overlay ── */
|
|
112
|
+
.panel-overlay {
|
|
113
|
+
position: fixed; inset: 0; z-index: 50;
|
|
114
|
+
background: rgba(0,0,0,.5); backdrop-filter: blur(4px);
|
|
115
|
+
opacity: 0; pointer-events: none; transition: opacity .25s;
|
|
116
|
+
}
|
|
117
|
+
.panel-overlay.visible { opacity: 1; pointer-events: auto; }
|
|
118
|
+
|
|
119
|
+
/* ── Slide panels ── */
|
|
120
|
+
.slide-panel {
|
|
121
|
+
position: fixed; top: 0; left: 0; bottom: 0;
|
|
122
|
+
width: 320px; z-index: 60;
|
|
123
|
+
background: rgba(19,19,26,.97); backdrop-filter: blur(24px);
|
|
124
|
+
border-right: 1px solid var(--border2);
|
|
125
|
+
display: flex; flex-direction: column;
|
|
126
|
+
transform: translateX(-100%);
|
|
127
|
+
transition: transform .3s cubic-bezier(.4,0,.2,1);
|
|
128
|
+
overflow: hidden;
|
|
129
|
+
}
|
|
130
|
+
.slide-panel.open { transform: translateX(0); }
|
|
131
|
+
.panel-header {
|
|
132
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
133
|
+
padding: 0 18px; height: 60px;
|
|
134
|
+
border-bottom: 1px solid var(--border); flex-shrink: 0;
|
|
135
|
+
}
|
|
136
|
+
.panel-title { font-size: 13px; font-weight: 700; letter-spacing: .04em; }
|
|
137
|
+
.panel-close {
|
|
138
|
+
background: none; border: none; color: var(--muted);
|
|
139
|
+
font-size: 18px; cursor: pointer; padding: 4px; line-height: 1;
|
|
140
|
+
transition: color .2s;
|
|
141
|
+
}
|
|
142
|
+
.panel-close:hover { color: var(--text); }
|
|
143
|
+
|
|
144
|
+
/* ── History panel ── */
|
|
145
|
+
.history-toolbar {
|
|
146
|
+
display: flex; align-items: center; justify-content: flex-end;
|
|
147
|
+
padding: 10px 18px; border-bottom: 1px solid var(--border); flex-shrink: 0;
|
|
148
|
+
}
|
|
149
|
+
.clear-link { background: none; border: none; color: var(--muted); font-family: var(--mono); font-size: 11px; cursor: pointer; }
|
|
150
|
+
.clear-link:hover { color: var(--error); }
|
|
151
|
+
.history-list { flex: 1; overflow-y: auto; padding: 8px; }
|
|
152
|
+
.history-item {
|
|
153
|
+
padding: 9px 11px; border-radius: 8px; margin-bottom: 4px;
|
|
154
|
+
border: 1px solid transparent; transition: background .15s; cursor: pointer;
|
|
155
|
+
}
|
|
156
|
+
.history-item:hover { background: var(--surface2); border-color: var(--border); }
|
|
157
|
+
.history-item .role {
|
|
158
|
+
font-size: 9px; font-family: var(--mono); text-transform: uppercase;
|
|
159
|
+
letter-spacing: .1em; margin-bottom: 3px;
|
|
160
|
+
}
|
|
161
|
+
.history-item .role.user { color: var(--accent); }
|
|
162
|
+
.history-item .role.assistant { color: var(--accent3); }
|
|
163
|
+
.history-item .text { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
164
|
+
|
|
165
|
+
/* ── Keyboard shortcut panel ── */
|
|
166
|
+
.kbd-panel { position: fixed; bottom: 76px; right: 16px; z-index: 10; }
|
|
167
|
+
.kbd-panel-toggle {
|
|
168
|
+
display: flex; align-items: center; gap: 6px;
|
|
169
|
+
padding: 7px 12px; border-radius: 8px;
|
|
170
|
+
background: rgba(19,19,26,.85); border: 1px solid var(--border2);
|
|
171
|
+
backdrop-filter: blur(12px); color: var(--muted);
|
|
172
|
+
font-family: var(--mono); font-size: 10px; letter-spacing: .08em;
|
|
173
|
+
cursor: pointer; transition: all .2s;
|
|
174
|
+
}
|
|
175
|
+
.kbd-panel-toggle:hover { color: var(--text); border-color: rgba(255,255,255,.25); }
|
|
176
|
+
.kbd-popup {
|
|
177
|
+
position: absolute; bottom: calc(100% + 8px); right: 0; width: 260px;
|
|
178
|
+
padding: 14px; background: rgba(19,19,26,.97);
|
|
179
|
+
border: 1px solid var(--border2); border-radius: 12px;
|
|
180
|
+
backdrop-filter: blur(24px);
|
|
181
|
+
opacity: 0; transform: translateY(6px) scale(.97);
|
|
182
|
+
pointer-events: none; transition: opacity .2s, transform .2s;
|
|
183
|
+
}
|
|
184
|
+
.kbd-popup.open { opacity: 1; transform: translateY(0) scale(1); pointer-events: auto; }
|
|
185
|
+
.kbd-popup-title { font-size: 9px; font-family: var(--mono); color: var(--muted); letter-spacing: .14em; text-transform: uppercase; margin-bottom: 10px; }
|
|
186
|
+
.kbd-row { display: flex; align-items: center; justify-content: space-between; padding: 5px 0; border-bottom: 1px solid var(--border); }
|
|
187
|
+
.kbd-row:last-child { border-bottom: none; }
|
|
188
|
+
.kbd-desc { font-size: 11px; color: var(--muted); font-family: var(--mono); }
|
|
189
|
+
.kbd-keys { display: flex; gap: 4px; }
|
|
190
|
+
.kbd-keys kbd { background: var(--surface2); border: 1px solid var(--border2); border-radius: 4px; padding: 2px 6px; font-family: var(--mono); font-size: 10px; color: var(--text); }
|
|
191
|
+
|
|
192
|
+
/* ── Floating panel buttons ── */
|
|
193
|
+
.panel-btns {
|
|
194
|
+
position: fixed; left: 16px; top: 50%; transform: translateY(-50%);
|
|
195
|
+
z-index: 10; display: flex; flex-direction: column; gap: 10px;
|
|
196
|
+
}
|
|
197
|
+
.panel-btn {
|
|
198
|
+
display: flex; align-items: center; gap: 8px;
|
|
199
|
+
padding: 9px 14px; border-radius: 10px;
|
|
200
|
+
background: rgba(19,19,26,.85); border: 1px solid var(--border2);
|
|
201
|
+
backdrop-filter: blur(12px); color: var(--muted);
|
|
202
|
+
font-family: var(--mono); font-size: 11px; letter-spacing: .08em;
|
|
203
|
+
cursor: pointer; transition: all .2s; white-space: nowrap;
|
|
204
|
+
}
|
|
205
|
+
.panel-btn:hover { color: var(--text); border-color: rgba(255,255,255,.25); background: rgba(28,28,40,.95); }
|
|
206
|
+
.panel-btn .badge {
|
|
207
|
+
min-width: 16px; height: 16px; border-radius: 8px;
|
|
208
|
+
background: var(--accent); color: #fff; font-size: 9px;
|
|
209
|
+
display: flex; align-items: center; justify-content: center; padding: 0 4px;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/* ── Main / Speech To Text area ── */
|
|
213
|
+
.main { display: flex; flex-direction: column; overflow: hidden; }
|
|
214
|
+
.speech-area { flex: 1; overflow-y: auto; padding: 24px; display: flex; flex-direction: column; align-items: center; }
|
|
215
|
+
.speech-inner {
|
|
216
|
+
width: 100%; max-width: 640px;
|
|
217
|
+
display: flex; flex-direction: column; align-items: center;
|
|
218
|
+
gap: 24px; position: relative; min-height: 100%;
|
|
219
|
+
justify-content: center;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/* ── Visualizer ── */
|
|
223
|
+
.visualizer-container {
|
|
224
|
+
position: relative; width: 290px; height: 290px;
|
|
225
|
+
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
|
226
|
+
background: radial-gradient(circle at center, rgba(124,109,255,0.03) 0%, transparent 70%);
|
|
227
|
+
border-radius: 50%;
|
|
228
|
+
}
|
|
229
|
+
.ring {
|
|
230
|
+
position: absolute; border-radius: 50%;
|
|
231
|
+
border: 1px solid rgba(255,255,255,0.06);
|
|
232
|
+
transition: border-color .4s, box-shadow .4s;
|
|
233
|
+
top: 50%; left: 50%;
|
|
234
|
+
transform: translate(-50%, -50%);
|
|
235
|
+
}
|
|
236
|
+
.ring1 { width: 180px; height: 180px; }
|
|
237
|
+
.ring2 { width: 234px; height: 234px; border-color: rgba(255,107,157,0.15); }
|
|
238
|
+
.ring3 { width: 290px; height: 290px; border-color: rgba(77,232,194,0.12); }
|
|
239
|
+
|
|
240
|
+
.ring.pulse1 { animation: ripple 2.2s ease-out infinite; }
|
|
241
|
+
.ring.pulse2 { animation: ripple 2.2s ease-out infinite .4s; }
|
|
242
|
+
.ring.pulse3 { animation: ripple 2.2s ease-out infinite .8s; }
|
|
243
|
+
|
|
244
|
+
@keyframes ripple {
|
|
245
|
+
0% { opacity: 0.4; transform: translate(-50%, -50%) scale(0.8); border-width: 1px; }
|
|
246
|
+
50% { opacity: 0.15; border-width: 2px; }
|
|
247
|
+
100% { opacity: 0; transform: translate(-50%, -50%) scale(1.4); border-width: 0px; }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.mic-orb {
|
|
251
|
+
width: 100px; height: 100px; border-radius: 50%;
|
|
252
|
+
background: linear-gradient(145deg, #1a1a24, #13131a);
|
|
253
|
+
border: 1px solid rgba(255,255,255,0.08);
|
|
254
|
+
display: flex; align-items: center; justify-content: center;
|
|
255
|
+
cursor: pointer; position: relative; z-index: 10;
|
|
256
|
+
transition: all .2s cubic-bezier(.4,0,.2,1);
|
|
257
|
+
overflow: hidden;
|
|
258
|
+
box-shadow: inset 0 2px 10px rgba(0,0,0,0.5), 0 10px 20px rgba(0,0,0,0.3);
|
|
259
|
+
}
|
|
260
|
+
.mic-orb::before {
|
|
261
|
+
content: ""; position: absolute; inset: 0; border-radius: 50%;
|
|
262
|
+
background: radial-gradient(circle at 30% 30%, rgba(124,109,255,0.15), transparent 60%);
|
|
263
|
+
opacity: 0.6; transition: opacity .3s;
|
|
264
|
+
pointer-events: none;
|
|
265
|
+
}
|
|
266
|
+
.mic-orb::after {
|
|
267
|
+
content: ""; position: absolute; inset: -2px; border-radius: 50%;
|
|
268
|
+
background: conic-gradient(from 180deg, transparent, rgba(255,255,255,0.03), transparent);
|
|
269
|
+
opacity: 0; transition: opacity .3s;
|
|
270
|
+
}
|
|
271
|
+
.mic-orb:hover::before { opacity: 1; }
|
|
272
|
+
.mic-orb:hover::after { opacity: 1; }
|
|
273
|
+
.mic-orb:hover {
|
|
274
|
+
transform: scale(1.05);
|
|
275
|
+
border-color: rgba(124,109,255,0.4);
|
|
276
|
+
box-shadow: 0 0 25px rgba(124,109,255,0.15), inset 0 2px 10px rgba(0,0,0,0.5);
|
|
277
|
+
}
|
|
278
|
+
.mic-orb:active { transform: scale(0.96); }
|
|
279
|
+
|
|
280
|
+
.mic-orb.recording {
|
|
281
|
+
border-color: rgba(255,107,157,0.6);
|
|
282
|
+
animation: orb-pulse 1.5s ease-in-out infinite;
|
|
283
|
+
box-shadow: 0 0 30px rgba(255,107,157,0.2), inset 0 0 15px rgba(255,107,157,0.1);
|
|
284
|
+
}
|
|
285
|
+
.mic-orb.recording::before {
|
|
286
|
+
opacity: 1;
|
|
287
|
+
background: radial-gradient(circle at 30% 30%, rgba(255,107,157,0.25), transparent 60%);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
@keyframes orb-pulse {
|
|
291
|
+
0%, 100% { box-shadow: 0 0 0 0 rgba(255,107,157,0.4), inset 0 2px 10px rgba(0,0,0,0.5); }
|
|
292
|
+
50% { box-shadow: 0 0 0 12px rgba(255,107,157,0), inset 0 2px 10px rgba(0,0,0,0.5); }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
.mic-icon { font-size: 30px; user-select: none; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3)); }
|
|
296
|
+
.mic-orb.recording .mic-icon { animation: mic-bounce .8s ease-in-out infinite; }
|
|
297
|
+
|
|
298
|
+
@keyframes mic-bounce {
|
|
299
|
+
0%, 100% { transform: scale(1); }
|
|
300
|
+
50% { transform: scale(1.1); }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/* ── Waveform ── */
|
|
304
|
+
.waveform { display: flex; align-items: center; gap: 4px; height: 40px; opacity: 0; transition: opacity .3s, transform .3s; margin-top: 10px; }
|
|
305
|
+
.waveform.active { opacity: 1; }
|
|
306
|
+
.bar { width: 3px; border-radius: 2px; background: var(--accent); height: 4px; transition: height .08s; }
|
|
307
|
+
|
|
308
|
+
/* ── State label ── */
|
|
309
|
+
.state-label {
|
|
310
|
+
font-family: var(--mono); font-size: 11px; color: var(--muted);
|
|
311
|
+
letter-spacing: .1em; text-transform: uppercase; text-align: center;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/* ── Compose panel ── */
|
|
315
|
+
.compose-panel {
|
|
316
|
+
width: 100%; background: var(--surface); border: 1px solid var(--border2);
|
|
317
|
+
border-radius: 16px; overflow: hidden;
|
|
318
|
+
opacity: 0; transform: translateY(18px);
|
|
319
|
+
transition: opacity .32s, transform .32s;
|
|
320
|
+
pointer-events: none; flex-shrink: 0;
|
|
321
|
+
}
|
|
322
|
+
.compose-panel.visible { opacity: 1; transform: translateY(0); pointer-events: auto; }
|
|
323
|
+
.compose-panel.confirm-mode .compose-textarea { opacity: .6; pointer-events: none; }
|
|
324
|
+
.compose-header {
|
|
325
|
+
padding: 10px 16px 6px;
|
|
326
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
327
|
+
}
|
|
328
|
+
.compose-header-label {
|
|
329
|
+
font-size: 10px; font-family: var(--mono); color: var(--muted);
|
|
330
|
+
letter-spacing: .1em; text-transform: uppercase;
|
|
331
|
+
display: flex; align-items: center; gap: 8px;
|
|
332
|
+
}
|
|
333
|
+
.interim-badge {
|
|
334
|
+
background: rgba(255,107,157,.15); color: var(--accent2);
|
|
335
|
+
border-radius: 4px; padding: 1px 7px; font-size: 9px; letter-spacing: .08em;
|
|
336
|
+
display: none;
|
|
337
|
+
}
|
|
338
|
+
.interim-badge.show { display: inline; }
|
|
339
|
+
.compose-textarea {
|
|
340
|
+
width: 100%; min-height: 72px; max-height: 180px;
|
|
341
|
+
background: transparent; border: none; outline: none;
|
|
342
|
+
color: var(--text); font-family: var(--font);
|
|
343
|
+
font-size: 17px; font-weight: 700; line-height: 1.5; letter-spacing: -0.01em;
|
|
344
|
+
padding: 2px 16px 12px; resize: none; overflow-y: auto; transition: opacity .2s;
|
|
345
|
+
}
|
|
346
|
+
.compose-textarea.interim { opacity: .55; }
|
|
347
|
+
.compose-textarea::placeholder { color: var(--muted); font-weight: 400; font-size: 15px; }
|
|
348
|
+
|
|
349
|
+
/* ── Confirm bar ── */
|
|
350
|
+
.confirm-bar {
|
|
351
|
+
display: none; align-items: center; justify-content: space-between;
|
|
352
|
+
padding: 10px 12px; border-top: 1px solid rgba(77,232,194,.25);
|
|
353
|
+
background: rgba(77,232,194,.05); gap: 8px;
|
|
354
|
+
}
|
|
355
|
+
.confirm-bar.visible { display: flex; }
|
|
356
|
+
.confirm-hint { font-size: 11px; font-family: var(--mono); color: var(--accent3); letter-spacing: .06em; }
|
|
357
|
+
.confirm-actions { display: flex; gap: 8px; }
|
|
358
|
+
.keep-btn {
|
|
359
|
+
height: 34px; padding: 0 18px; border-radius: 9px; border: none;
|
|
360
|
+
background: linear-gradient(135deg, var(--accent3), #26c6a0);
|
|
361
|
+
color: #0a0a0f; font-family: var(--font); font-size: 13px; font-weight: 700;
|
|
362
|
+
cursor: pointer; transition: all .18s; display: flex; align-items: center; gap: 6px;
|
|
363
|
+
}
|
|
364
|
+
.keep-btn:hover { filter: brightness(1.1); transform: scale(1.02); }
|
|
365
|
+
.edit-btn {
|
|
366
|
+
height: 34px; padding: 0 14px; border-radius: 9px;
|
|
367
|
+
border: 1px solid var(--border2); background: var(--surface2);
|
|
368
|
+
color: var(--muted); font-family: var(--mono); font-size: 12px;
|
|
369
|
+
cursor: pointer; transition: all .18s;
|
|
370
|
+
}
|
|
371
|
+
.edit-btn:hover { color: var(--text); border-color: rgba(255,255,255,.22); }
|
|
372
|
+
|
|
373
|
+
/* ── Compose actions ── */
|
|
374
|
+
.compose-actions {
|
|
375
|
+
display: none; align-items: center; justify-content: space-between;
|
|
376
|
+
padding: 10px 12px; border-top: 1px solid var(--border); gap: 8px;
|
|
377
|
+
}
|
|
378
|
+
.compose-actions.visible { display: flex; }
|
|
379
|
+
.compose-left { display: flex; gap: 6px; }
|
|
380
|
+
.action-btn {
|
|
381
|
+
height: 36px; padding: 0 14px; border-radius: 10px;
|
|
382
|
+
border: 1px solid var(--border2); background: var(--surface2);
|
|
383
|
+
color: var(--muted); font-family: var(--mono); font-size: 12px; letter-spacing: .04em;
|
|
384
|
+
cursor: pointer; display: flex; align-items: center; gap: 6px;
|
|
385
|
+
transition: all .18s; white-space: nowrap;
|
|
386
|
+
}
|
|
387
|
+
.action-btn:hover { color: var(--text); border-color: rgba(255,255,255,.22); }
|
|
388
|
+
.action-btn.danger:hover { color: var(--error); border-color: var(--error); }
|
|
389
|
+
.action-btn.re-record:hover{ color: var(--accent2); border-color: var(--accent2); }
|
|
390
|
+
.action-btn:disabled { opacity: .4; pointer-events: none; }
|
|
391
|
+
.send-btn {
|
|
392
|
+
height: 36px; padding: 0 20px; border-radius: 10px; border: none;
|
|
393
|
+
background: linear-gradient(135deg, var(--accent), #9b85ff);
|
|
394
|
+
color: #fff; font-family: var(--font); font-size: 14px; font-weight: 700;
|
|
395
|
+
cursor: pointer; display: flex; align-items: center; gap: 7px;
|
|
396
|
+
transition: all .18s; letter-spacing: .01em;
|
|
397
|
+
}
|
|
398
|
+
.send-btn:hover { filter: brightness(1.12); transform: scale(1.02); }
|
|
399
|
+
.send-btn:active { transform: scale(.98); }
|
|
400
|
+
.send-btn:disabled{ opacity: .4; pointer-events: none; filter: none; transform: none; }
|
|
401
|
+
.send-btn.loading { background: linear-gradient(135deg, var(--accent3), #26c6a0); }
|
|
402
|
+
|
|
403
|
+
/* ── Response panel ── */
|
|
404
|
+
.response-panel {
|
|
405
|
+
width: 100%;
|
|
406
|
+
background: rgba(77,232,194,.055); border: 1px solid rgba(77,232,194,.2);
|
|
407
|
+
border-radius: 14px; padding: 14px 18px;
|
|
408
|
+
opacity: 0; transform: translateY(12px);
|
|
409
|
+
transition: opacity .4s .1s, transform .4s .1s;
|
|
410
|
+
pointer-events: none; flex-shrink: 0;
|
|
411
|
+
}
|
|
412
|
+
.response-panel.visible { opacity: 1; transform: translateY(0); pointer-events: auto; }
|
|
413
|
+
.response-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
|
414
|
+
.response-role { font-size: 10px; font-family: var(--mono); color: var(--accent3); letter-spacing: .1em; text-transform: uppercase; }
|
|
415
|
+
.response-copy {
|
|
416
|
+
background: none; border: none; color: var(--muted); font-family: var(--mono);
|
|
417
|
+
font-size: 11px; cursor: pointer; padding: 2px 6px; border-radius: 4px;
|
|
418
|
+
transition: all .18s; letter-spacing: .04em;
|
|
419
|
+
}
|
|
420
|
+
.response-copy:hover { color: var(--accent3); background: rgba(77,232,194,.1); }
|
|
421
|
+
/* Markdown-rendered response */
|
|
422
|
+
.response-content {
|
|
423
|
+
font-size: 15px; line-height: 1.75; color: var(--text);
|
|
424
|
+
word-break: break-word; overflow-wrap: break-word;
|
|
425
|
+
}
|
|
426
|
+
.response-content p { margin-bottom: .8em; }
|
|
427
|
+
.response-content p:last-child { margin-bottom: 0; }
|
|
428
|
+
.response-content code {
|
|
429
|
+
background: var(--surface2); border: 1px solid var(--border2);
|
|
430
|
+
border-radius: 4px; padding: 1px 5px; font-family: var(--mono); font-size: 13px;
|
|
431
|
+
}
|
|
432
|
+
.response-content pre {
|
|
433
|
+
background: var(--surface2); border: 1px solid var(--border2); border-radius: 8px;
|
|
434
|
+
padding: 12px 14px; margin: .8em 0; overflow-x: auto;
|
|
435
|
+
}
|
|
436
|
+
.response-content pre code { background: none; border: none; padding: 0; font-size: 13px; }
|
|
437
|
+
.response-content ul, .response-content ol { padding-left: 1.5em; margin-bottom: .8em; }
|
|
438
|
+
.response-content li { margin-bottom: .3em; }
|
|
439
|
+
.response-content strong { color: var(--accent3); font-weight: 700; }
|
|
440
|
+
.response-content em { color: var(--accent2); }
|
|
441
|
+
.response-content a { color: var(--accent); text-decoration: underline; }
|
|
442
|
+
.response-content blockquote {
|
|
443
|
+
border-left: 3px solid var(--accent); padding-left: 12px;
|
|
444
|
+
margin: .8em 0; color: var(--muted); font-style: italic;
|
|
445
|
+
}
|
|
446
|
+
/* Streaming cursor */
|
|
447
|
+
.stream-cursor { display: inline-block; width: 2px; height: 1em; background: var(--accent3); animation: blink .8s step-end infinite; vertical-align: text-bottom; margin-left: 2px; }
|
|
448
|
+
@keyframes blink { 0%,100% { opacity: 1; } 50% { opacity: 0; } }
|
|
449
|
+
|
|
450
|
+
/* ── Progress bar (replaces overlay) ── */
|
|
451
|
+
.progress-bar {
|
|
452
|
+
position: fixed; top: 60px; left: 0; right: 0; height: 2px; z-index: 20;
|
|
453
|
+
background: transparent; overflow: hidden; pointer-events: none;
|
|
454
|
+
}
|
|
455
|
+
.progress-bar::after {
|
|
456
|
+
content: ""; position: absolute; top: 0; left: -60%;
|
|
457
|
+
width: 60%; height: 100%;
|
|
458
|
+
background: linear-gradient(90deg, transparent, var(--accent), var(--accent2), transparent);
|
|
459
|
+
animation: progress-slide 1.4s ease-in-out infinite;
|
|
460
|
+
opacity: 0; transition: opacity .25s;
|
|
461
|
+
}
|
|
462
|
+
.progress-bar.active::after { opacity: 1; }
|
|
463
|
+
@keyframes progress-slide {
|
|
464
|
+
0% { left: -60%; }
|
|
465
|
+
100% { left: 110%; }
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/* ── Response skeleton (loading placeholder) ── */
|
|
469
|
+
.response-skeleton {
|
|
470
|
+
display: none; flex-direction: column; gap: 10px; padding: 4px 0;
|
|
471
|
+
}
|
|
472
|
+
.response-skeleton.visible { display: flex; }
|
|
473
|
+
.skel-line {
|
|
474
|
+
height: 13px; border-radius: 6px;
|
|
475
|
+
background: linear-gradient(90deg, var(--surface2) 25%, rgba(124,109,255,.12) 50%, var(--surface2) 75%);
|
|
476
|
+
background-size: 200% 100%;
|
|
477
|
+
animation: shimmer 1.6s infinite;
|
|
478
|
+
}
|
|
479
|
+
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
|
|
480
|
+
|
|
481
|
+
/* ── Response error state ── */
|
|
482
|
+
.response-panel.error-state {
|
|
483
|
+
background: rgba(255,85,85,.06);
|
|
484
|
+
border-color: rgba(255,85,85,.25);
|
|
485
|
+
}
|
|
486
|
+
.response-panel.error-state .response-role { color: var(--error); }
|
|
487
|
+
.error-body { display: flex; flex-direction: column; gap: 12px; }
|
|
488
|
+
.error-message {
|
|
489
|
+
font-family: var(--mono); font-size: 12px; color: rgba(255,85,85,.85);
|
|
490
|
+
line-height: 1.6; word-break: break-word;
|
|
491
|
+
}
|
|
492
|
+
.error-actions { display: flex; gap: 8px; }
|
|
493
|
+
.retry-btn {
|
|
494
|
+
height: 32px; padding: 0 16px; border-radius: 8px; border: none;
|
|
495
|
+
background: rgba(255,85,85,.15); color: var(--error);
|
|
496
|
+
font-family: var(--mono); font-size: 12px; letter-spacing: .04em;
|
|
497
|
+
cursor: pointer; transition: all .18s;
|
|
498
|
+
}
|
|
499
|
+
.retry-btn:hover { background: rgba(255,85,85,.28); }
|
|
500
|
+
.dismiss-btn {
|
|
501
|
+
height: 32px; padding: 0 16px; border-radius: 8px;
|
|
502
|
+
border: 1px solid var(--border2); background: transparent; color: var(--muted);
|
|
503
|
+
font-family: var(--mono); font-size: 12px; letter-spacing: .04em;
|
|
504
|
+
cursor: pointer; transition: all .18s;
|
|
505
|
+
}
|
|
506
|
+
.dismiss-btn:hover { color: var(--text); border-color: rgba(255,255,255,.22); }
|
|
507
|
+
|
|
508
|
+
/* ── Bottom bar ── */
|
|
509
|
+
.bottom-bar {
|
|
510
|
+
padding: 14px 24px; border-top: 1px solid var(--border);
|
|
511
|
+
background: rgba(10,10,15,.8); backdrop-filter: blur(20px);
|
|
512
|
+
display: flex; align-items: center; justify-content: center; gap: 20px;
|
|
513
|
+
}
|
|
514
|
+
.kbd-hint { font-family: var(--mono); font-size: 11px; color: var(--muted); white-space: nowrap; flex-shrink: 0; }
|
|
515
|
+
kbd { background: var(--surface2); border: 1px solid var(--border2); border-radius: 4px; padding: 2px 7px; font-family: var(--mono); font-size: 11px; }
|
|
516
|
+
|
|
517
|
+
.record-btn-wrap { display: flex; align-items: center; }
|
|
518
|
+
.record-main-btn {
|
|
519
|
+
height: 46px; padding: 0 32px; border-radius: 13px; border: none;
|
|
520
|
+
background: linear-gradient(135deg, var(--accent), #9b85ff);
|
|
521
|
+
color: #fff; font-family: var(--font); font-size: 15px; font-weight: 700;
|
|
522
|
+
cursor: pointer; transition: all .2s; letter-spacing: .01em;
|
|
523
|
+
position: relative; overflow: hidden;
|
|
524
|
+
}
|
|
525
|
+
.record-main-btn::after { content: ""; position: absolute; inset: 0; background: rgba(255,255,255,0); transition: background .2s; }
|
|
526
|
+
.record-main-btn:hover::after { background: rgba(255,255,255,.08); }
|
|
527
|
+
.record-main-btn:active { transform: scale(.98); }
|
|
528
|
+
.record-main-btn.recording { background: linear-gradient(135deg, var(--accent2), #ff8fab); animation: btn-glow 1.5s ease-in-out infinite; }
|
|
529
|
+
@keyframes btn-glow { 0%,100% { box-shadow: 0 0 0 0 rgba(255,107,157,.4); } 50% { box-shadow: 0 0 20px 4px rgba(255,107,157,.15); } }
|
|
530
|
+
.record-main-btn.processing { background: linear-gradient(135deg, rgba(255,85,85,.8), #cc3344); animation: btn-glow-cancel 1.5s ease-in-out infinite; }
|
|
531
|
+
@keyframes btn-glow-cancel { 0%,100% { box-shadow: 0 0 0 0 rgba(255,85,85,.4); } 50% { box-shadow: 0 0 20px 6px rgba(255,85,85,.15); } }
|
|
532
|
+
.record-main-btn.processing:hover::after { background: rgba(255,255,255,.1); }
|
|
533
|
+
|
|
534
|
+
/* ── Toast ── */
|
|
535
|
+
.toast {
|
|
536
|
+
position: fixed; bottom: 78px; left: 50%;
|
|
537
|
+
transform: translateX(-50%) translateY(14px);
|
|
538
|
+
background: var(--surface2); border: 1px solid var(--border2);
|
|
539
|
+
border-radius: 10px; padding: 9px 18px;
|
|
540
|
+
font-family: var(--mono); font-size: 12px; color: var(--text);
|
|
541
|
+
opacity: 0; pointer-events: none;
|
|
542
|
+
transition: opacity .25s, transform .25s;
|
|
543
|
+
z-index: 99; white-space: nowrap; max-width: 90vw;
|
|
544
|
+
overflow: hidden; text-overflow: ellipsis;
|
|
545
|
+
}
|
|
546
|
+
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
547
|
+
.toast.error { background: rgba(255,85,85,.12); border-color: rgba(255,85,85,.35); color: var(--error); }
|
|
548
|
+
|
|
549
|
+
/* ── No-speech API fallback notice ── */
|
|
550
|
+
.no-api-banner {
|
|
551
|
+
display: none; width: 100%;
|
|
552
|
+
background: rgba(255,85,85,.07); border: 1px solid rgba(255,85,85,.25);
|
|
553
|
+
border-radius: 12px; padding: 14px 18px;
|
|
554
|
+
font-family: var(--mono); font-size: 12px; color: rgba(255,85,85,.85);
|
|
555
|
+
line-height: 1.6;
|
|
556
|
+
}
|
|
557
|
+
.no-api-banner.visible { display: block; }
|
|
558
|
+
|
|
559
|
+
/* ── Responsive ── */
|
|
560
|
+
@media (max-width: 600px) {
|
|
561
|
+
.panel-btns { left: 8px; }
|
|
562
|
+
.kbd-hint { display: none; }
|
|
563
|
+
.slide-panel { width: 290px; }
|
|
564
|
+
.visualizer-container { width: 220px; height: 220px; }
|
|
565
|
+
.ring1 { width: 140px; height: 140px; }
|
|
566
|
+
.ring2 { width: 178px; height: 178px; }
|
|
567
|
+
.ring3 { width: 220px; height: 220px; }
|
|
568
|
+
.mic-orb { width: 80px; height: 80px; }
|
|
569
|
+
.mic-icon { font-size: 24px; }
|
|
570
|
+
}
|
|
571
|
+
</style>
|
|
572
|
+
</head>
|
|
573
|
+
<body>
|
|
574
|
+
<div class="bg-canvas">
|
|
575
|
+
<div class="orb orb1"></div>
|
|
576
|
+
<div class="orb orb2"></div>
|
|
577
|
+
<div class="orb orb3"></div>
|
|
578
|
+
</div>
|
|
579
|
+
<div class="grid-lines"></div>
|
|
580
|
+
|
|
581
|
+
<!-- Panel overlay -->
|
|
582
|
+
<div class="panel-overlay" id="panelOverlay" onclick="closeAllPanels()"></div>
|
|
583
|
+
|
|
584
|
+
<!-- History slide panel -->
|
|
585
|
+
<div class="slide-panel" id="historyPanel" role="dialog" aria-label="对话记录">
|
|
586
|
+
<div class="panel-header">
|
|
587
|
+
<span class="panel-title">📋 对话记录</span>
|
|
588
|
+
<button class="panel-close" onclick="closeAllPanels()" aria-label="关闭">✕</button>
|
|
589
|
+
</div>
|
|
590
|
+
<div class="history-toolbar">
|
|
591
|
+
<button class="clear-link" onclick="clearHistory()">清空记录</button>
|
|
592
|
+
</div>
|
|
593
|
+
<div class="history-list" id="historyList"></div>
|
|
594
|
+
</div>
|
|
595
|
+
|
|
596
|
+
<!-- Floating panel trigger buttons -->
|
|
597
|
+
<div class="panel-btns" id="panelBtns">
|
|
598
|
+
<button class="panel-btn" id="historyPanelBtn" onclick="openPanel('history')" title="历史记录 (H)">
|
|
599
|
+
📋 历史 <span class="badge" id="historyBadge" style="display:none">0</span>
|
|
600
|
+
</button>
|
|
601
|
+
</div>
|
|
602
|
+
|
|
603
|
+
<!-- Keyboard shortcut panel -->
|
|
604
|
+
<div class="kbd-panel">
|
|
605
|
+
<div class="kbd-popup" id="kbdPopup" role="tooltip">
|
|
606
|
+
<div class="kbd-popup-title">快捷键</div>
|
|
607
|
+
<div class="kbd-row"><span class="kbd-desc">长按录音 / 松开停止,或点击切换</span><span class="kbd-keys"><kbd>Space</kbd></span></div>
|
|
608
|
+
<div class="kbd-row"><span class="kbd-desc">确认发送</span><span class="kbd-keys"><kbd>Enter</kbd></span></div>
|
|
609
|
+
<div class="kbd-row"><span class="kbd-desc">进入编辑模式</span><span class="kbd-keys"><kbd>E</kbd></span></div>
|
|
610
|
+
<div class="kbd-row"><span class="kbd-desc">取消 / 关闭</span><span class="kbd-keys"><kbd>Esc</kbd></span></div>
|
|
611
|
+
<div class="kbd-row"><span class="kbd-desc">打开历史记录</span><span class="kbd-keys"><kbd>H</kbd></span></div>
|
|
612
|
+
</div>
|
|
613
|
+
<button class="kbd-panel-toggle" id="kbdToggle" onclick="toggleKbdPopup()" aria-label="快捷键">⌨ 快捷键</button>
|
|
614
|
+
</div>
|
|
615
|
+
|
|
616
|
+
<!-- Progress bar (top of page, non-blocking) -->
|
|
617
|
+
<div class="progress-bar" id="progressBar"></div>
|
|
618
|
+
|
|
619
|
+
<div class="app">
|
|
620
|
+
<header class="header">
|
|
621
|
+
<div class="logo">
|
|
622
|
+
<div class="logo-icon">🦞</div>
|
|
623
|
+
<span>OpenClaw</span>
|
|
624
|
+
<span style="color:var(--muted);font-weight:400;font-size:14px">/ Speech To Text</span>
|
|
625
|
+
</div>
|
|
626
|
+
<div class="header-right">
|
|
627
|
+
<select id="langSelect" style="background:var(--surface2);border:1px solid var(--border2);color:var(--text);font-family:var(--mono);font-size:11px;border-radius:6px;padding:4px 8px;outline:none;margin-right:12px;">
|
|
628
|
+
<option value="zh-CN">中文</option>
|
|
629
|
+
<option value="en-US">English</option>
|
|
630
|
+
<option value="ja-JP">日本語</option>
|
|
631
|
+
<option value="ko-KR">한국어</option>
|
|
632
|
+
</select>
|
|
633
|
+
<div class="status-dot" id="statusDot" role="status" aria-label="连接状态"></div>
|
|
634
|
+
<span id="statusText">离线</span>
|
|
635
|
+
</div>
|
|
636
|
+
</header>
|
|
637
|
+
|
|
638
|
+
<main class="main">
|
|
639
|
+
<div class="speech-area" id="speechArea">
|
|
640
|
+
<div class="speech-inner">
|
|
641
|
+
<!-- Browser compatibility notice -->
|
|
642
|
+
<div class="no-api-banner" id="noApiBanner">
|
|
643
|
+
⚠ 您的浏览器不支持 Web Speech API。请使用 Chrome、Edge 或 Safari。
|
|
644
|
+
</div>
|
|
645
|
+
|
|
646
|
+
<div class="visualizer-container">
|
|
647
|
+
<div class="ring ring1" id="ring1"></div>
|
|
648
|
+
<div class="ring ring2" id="ring2"></div>
|
|
649
|
+
<div class="ring ring3" id="ring3"></div>
|
|
650
|
+
<div class="mic-orb" id="micOrb" role="button" tabindex="0" aria-label="点击开始录音">
|
|
651
|
+
<div class="mic-icon" id="micIcon">🎙️</div>
|
|
652
|
+
</div>
|
|
653
|
+
</div>
|
|
654
|
+
|
|
655
|
+
<div class="waveform" id="waveform" aria-hidden="true">
|
|
656
|
+
<div class="bar" id="b0"></div><div class="bar" id="b1"></div><div class="bar" id="b2"></div>
|
|
657
|
+
<div class="bar" id="b3"></div><div class="bar" id="b4"></div><div class="bar" id="b5"></div>
|
|
658
|
+
<div class="bar" id="b6"></div><div class="bar" id="b7"></div><div class="bar" id="b8"></div>
|
|
659
|
+
<div class="bar" id="b9"></div><div class="bar" id="b10"></div><div class="bar" id="b11"></div>
|
|
660
|
+
</div>
|
|
661
|
+
|
|
662
|
+
<div class="state-label" id="stateLabel" aria-live="polite">点击麦克风 或 按住空格 开始录音</div>
|
|
663
|
+
|
|
664
|
+
<!-- Compose panel -->
|
|
665
|
+
<div class="compose-panel" id="composePanel">
|
|
666
|
+
<div class="compose-header">
|
|
667
|
+
<div class="compose-header-label">
|
|
668
|
+
<span id="composePanelTitle">识别结果</span>
|
|
669
|
+
<span class="interim-badge" id="interimBadge">识别中…</span>
|
|
670
|
+
</div>
|
|
671
|
+
<span id="charCount" style="font-size:11px;font-family:var(--mono);color:var(--muted)">0 字</span>
|
|
672
|
+
</div>
|
|
673
|
+
<textarea
|
|
674
|
+
class="compose-textarea" id="composeText"
|
|
675
|
+
placeholder="语音识别结果将在这里显示,可直接编辑后发送…"
|
|
676
|
+
oninput="onComposeInput()"
|
|
677
|
+
aria-label="识别结果文本"
|
|
678
|
+
></textarea>
|
|
679
|
+
|
|
680
|
+
<!-- Confirm bar -->
|
|
681
|
+
<div class="confirm-bar" id="confirmBar">
|
|
682
|
+
<span class="confirm-hint">✦ 识别完成 — 确认发送或编辑</span>
|
|
683
|
+
<div class="confirm-actions">
|
|
684
|
+
<button class="edit-btn" onclick="enterEditMode()">✏ 编辑</button>
|
|
685
|
+
<button class="edit-btn re-record" style="color:var(--muted)" onclick="reRecord()">🎙 重录</button>
|
|
686
|
+
<button class="keep-btn" id="keepBtn" onclick="sendMessage()">Keep & 发送 ↗</button>
|
|
687
|
+
</div>
|
|
688
|
+
</div>
|
|
689
|
+
|
|
690
|
+
<!-- Normal edit actions -->
|
|
691
|
+
<div class="compose-actions" id="composeActions">
|
|
692
|
+
<div class="compose-left">
|
|
693
|
+
<button class="action-btn re-record" id="reRecordBtn" onclick="reRecord()">🎙 重新录音</button>
|
|
694
|
+
<button class="action-btn danger" onclick="clearCompose()">✕ 清除</button>
|
|
695
|
+
</div>
|
|
696
|
+
<button class="send-btn" id="sendBtn" onclick="sendMessage()" disabled aria-label="发送消息">发送 ↗</button>
|
|
697
|
+
</div>
|
|
698
|
+
</div>
|
|
699
|
+
|
|
700
|
+
<!-- Response panel -->
|
|
701
|
+
<div class="response-panel" id="responsePanel" aria-live="polite">
|
|
702
|
+
<div class="response-header">
|
|
703
|
+
<div class="response-role" id="responseRole">助手回复</div>
|
|
704
|
+
<button class="response-copy" id="copyBtn" onclick="copyResponse()" title="复制回复">复制</button>
|
|
705
|
+
</div>
|
|
706
|
+
<!-- Skeleton shown while loading -->
|
|
707
|
+
<div class="response-skeleton" id="responseSkeleton">
|
|
708
|
+
<div class="skel-line" style="width:85%"></div>
|
|
709
|
+
<div class="skel-line" style="width:65%"></div>
|
|
710
|
+
<div class="skel-line" style="width:75%"></div>
|
|
711
|
+
</div>
|
|
712
|
+
<!-- Normal content -->
|
|
713
|
+
<div class="response-content" id="responseContent"></div>
|
|
714
|
+
<!-- Error state -->
|
|
715
|
+
<div class="error-body" id="errorBody" style="display:none">
|
|
716
|
+
<div class="error-message" id="errorMessage"></div>
|
|
717
|
+
<div class="error-actions">
|
|
718
|
+
<button class="retry-btn" onclick="retryMessage()">↺ 重试</button>
|
|
719
|
+
<button class="dismiss-btn" onclick="hideResponsePanel()">忽略</button>
|
|
720
|
+
</div>
|
|
721
|
+
</div>
|
|
722
|
+
</div>
|
|
723
|
+
</div>
|
|
724
|
+
</div>
|
|
725
|
+
|
|
726
|
+
<div class="bottom-bar">
|
|
727
|
+
<div class="kbd-hint"><kbd>Space</kbd> 长按说话</div>
|
|
728
|
+
<div class="record-btn-wrap">
|
|
729
|
+
<button class="record-main-btn" id="recordBtn">按住说话</button>
|
|
730
|
+
</div>
|
|
731
|
+
<div class="kbd-hint"><kbd>Enter</kbd> 发送</div>
|
|
732
|
+
</div>
|
|
733
|
+
</main>
|
|
734
|
+
</div>
|
|
735
|
+
|
|
736
|
+
<div class="toast" id="toast" role="alert" aria-live="assertive"></div>
|
|
737
|
+
|
|
738
|
+
<script>
|
|
739
|
+
const LANG_CACHE_KEY = "openclaw_speech_lang";
|
|
740
|
+
|
|
741
|
+
/** Returns the actual BCP-47 language tag to pass to SpeechRecognition. */
|
|
742
|
+
function resolvedLang(cfg) {
|
|
743
|
+
const cachedLang = localStorage.getItem(LANG_CACHE_KEY);
|
|
744
|
+
if (cachedLang) return cachedLang;
|
|
745
|
+
if (!cfg.lang || cfg.lang === "auto") return navigator.language || "zh-CN";
|
|
746
|
+
return cfg.lang;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// ═══════════════════ KEYBOARD POPUP ═══════════════════
|
|
750
|
+
function toggleKbdPopup() {
|
|
751
|
+
document.getElementById("kbdPopup").classList.toggle("open");
|
|
752
|
+
}
|
|
753
|
+
function closeKbdPopup() {
|
|
754
|
+
document.getElementById("kbdPopup").classList.remove("open");
|
|
755
|
+
}
|
|
756
|
+
document.addEventListener("click", (e) => {
|
|
757
|
+
const panel = document.getElementById("kbdPopup");
|
|
758
|
+
const toggle = document.getElementById("kbdToggle");
|
|
759
|
+
if (panel.classList.contains("open") && !panel.contains(e.target) && !toggle.contains(e.target)) {
|
|
760
|
+
closeKbdPopup();
|
|
761
|
+
}
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
// ═══════════════════ SLIDE PANELS ═══════════════════
|
|
765
|
+
function openPanel(name) {
|
|
766
|
+
closeAllPanels(false);
|
|
767
|
+
document.getElementById(name + "Panel").classList.add("open");
|
|
768
|
+
document.getElementById("panelOverlay").classList.add("visible");
|
|
769
|
+
}
|
|
770
|
+
function closeAllPanels(hideOverlay = true) {
|
|
771
|
+
document.getElementById("historyPanel").classList.remove("open");
|
|
772
|
+
if (hideOverlay) document.getElementById("panelOverlay").classList.remove("visible");
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// ═══════════════════ API ═══════════════════
|
|
776
|
+
/**
|
|
777
|
+
* Sends text to the OpenClaw gateway.
|
|
778
|
+
* Supports both streaming (SSE / text-stream) and non-streaming responses.
|
|
779
|
+
* Falls back to non-streaming if the Content-Type is not a stream type.
|
|
780
|
+
*
|
|
781
|
+
* @param {string} text - User message
|
|
782
|
+
* @param {AbortSignal} signal - AbortController signal
|
|
783
|
+
* @param {(chunk: string) => void} onChunk - Called incrementally with streamed text
|
|
784
|
+
* @returns {Promise<string>} - Full reply text
|
|
785
|
+
*/
|
|
786
|
+
async function sendToGateway(text, signal, onChunk) {
|
|
787
|
+
const headers = { "Content-Type": "application/json" };
|
|
788
|
+
|
|
789
|
+
const messages = [];
|
|
790
|
+
messages.push({ role: "user", content: text });
|
|
791
|
+
|
|
792
|
+
const body = {
|
|
793
|
+
messages,
|
|
794
|
+
stream: true,
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
const res = await fetch('chat', {
|
|
798
|
+
method: "POST",
|
|
799
|
+
headers,
|
|
800
|
+
signal,
|
|
801
|
+
body: JSON.stringify(body),
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
if (!res.ok) {
|
|
805
|
+
let errText;
|
|
806
|
+
try { errText = await res.text(); } catch { errText = "(无法读取错误响应)"; }
|
|
807
|
+
throw new Error(`HTTP ${res.status}: ${errText}`);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const contentType = res.headers.get("content-type") || "";
|
|
811
|
+
const isStream = contentType.includes("text/event-stream") || contentType.includes("text/plain");
|
|
812
|
+
|
|
813
|
+
if (isStream) {
|
|
814
|
+
return await readStream(res.body, onChunk, signal);
|
|
815
|
+
} else {
|
|
816
|
+
// Non-streaming fallback
|
|
817
|
+
const data = await res.json();
|
|
818
|
+
const reply = data.choices?.[0]?.message?.content || "";
|
|
819
|
+
onChunk(reply);
|
|
820
|
+
return reply;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Reads an SSE / line-delimited stream from a ReadableStream.
|
|
826
|
+
* Compatible with OpenAI-format SSE (data: {...}\n\n).
|
|
827
|
+
*/
|
|
828
|
+
async function readStream(body, onChunk, signal) {
|
|
829
|
+
const reader = body.getReader();
|
|
830
|
+
const decoder = new TextDecoder("utf-8");
|
|
831
|
+
let fullText = "";
|
|
832
|
+
let buf = "";
|
|
833
|
+
|
|
834
|
+
try {
|
|
835
|
+
while (true) {
|
|
836
|
+
const { done, value } = await reader.read();
|
|
837
|
+
if (done || signal?.aborted) break;
|
|
838
|
+
|
|
839
|
+
buf += decoder.decode(value, { stream: true });
|
|
840
|
+
const lines = buf.split("\n");
|
|
841
|
+
buf = lines.pop(); // keep incomplete line in buffer
|
|
842
|
+
|
|
843
|
+
for (const line of lines) {
|
|
844
|
+
const trimmed = line.trim();
|
|
845
|
+
if (!trimmed || trimmed === "data: [DONE]") continue;
|
|
846
|
+
|
|
847
|
+
let payload = trimmed;
|
|
848
|
+
if (trimmed.startsWith("data:")) payload = trimmed.slice(5).trim();
|
|
849
|
+
|
|
850
|
+
try {
|
|
851
|
+
const json = JSON.parse(payload);
|
|
852
|
+
const delta = json.choices?.[0]?.delta?.content;
|
|
853
|
+
if (delta) {
|
|
854
|
+
fullText += delta;
|
|
855
|
+
onChunk(fullText);
|
|
856
|
+
}
|
|
857
|
+
} catch {
|
|
858
|
+
// Non-JSON line (e.g. raw text stream): treat as raw delta
|
|
859
|
+
if (payload) { fullText += payload + "\n"; onChunk(fullText); }
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
} finally {
|
|
864
|
+
try { reader.cancel(); } catch {}
|
|
865
|
+
}
|
|
866
|
+
return fullText;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// ═══════════════════ HISTORY ═══════════════════
|
|
870
|
+
let chatHistory = [];
|
|
871
|
+
|
|
872
|
+
function addHistory(role, content) {
|
|
873
|
+
chatHistory.push({ role, content, ts: Date.now() });
|
|
874
|
+
renderHistory();
|
|
875
|
+
updateHistoryBadge();
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function renderHistory() {
|
|
879
|
+
const el = document.getElementById("historyList");
|
|
880
|
+
if (!chatHistory.length) {
|
|
881
|
+
el.innerHTML = '<div style="padding:18px 8px;text-align:center;color:var(--muted);font-size:12px;font-family:var(--mono);">暂无记录</div>';
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
el.innerHTML = chatHistory
|
|
885
|
+
.slice()
|
|
886
|
+
.reverse()
|
|
887
|
+
.map(
|
|
888
|
+
(m) => `
|
|
889
|
+
<div class="history-item" onclick="reuseHistory(${chatHistory.indexOf(m) === -1 ? chatHistory.length - 1 : chatHistory.findLastIndex(h => h === m)})">
|
|
890
|
+
<div class="role ${m.role}">${m.role === "user" ? "用户" : "助手"}</div>
|
|
891
|
+
<div class="text">${esc(m.content)}</div>
|
|
892
|
+
</div>`
|
|
893
|
+
)
|
|
894
|
+
.join("");
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function reuseHistory(idx) {
|
|
898
|
+
const m = chatHistory[idx];
|
|
899
|
+
if (!m || m.role !== "user") return;
|
|
900
|
+
setComposeText(m.content, false);
|
|
901
|
+
showComposePanel();
|
|
902
|
+
enterEditMode();
|
|
903
|
+
closeAllPanels();
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function clearHistory() {
|
|
907
|
+
if (!chatHistory.length) return;
|
|
908
|
+
if (!confirm("确认清空对话记录?")) return;
|
|
909
|
+
chatHistory = [];
|
|
910
|
+
renderHistory();
|
|
911
|
+
updateHistoryBadge();
|
|
912
|
+
showToast("已清空对话记录");
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function updateHistoryBadge() {
|
|
916
|
+
const badge = document.getElementById("historyBadge");
|
|
917
|
+
if (chatHistory.length) {
|
|
918
|
+
badge.textContent = chatHistory.length;
|
|
919
|
+
badge.style.display = "flex";
|
|
920
|
+
} else {
|
|
921
|
+
badge.style.display = "none";
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/** XSS-safe HTML escape */
|
|
926
|
+
function esc(s) {
|
|
927
|
+
return String(s)
|
|
928
|
+
.replace(/&/g, "&")
|
|
929
|
+
.replace(/</g, "<")
|
|
930
|
+
.replace(/>/g, ">")
|
|
931
|
+
.replace(/"/g, """)
|
|
932
|
+
.replace(/'/g, "'");
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// ═══════════════════ MARKDOWN RENDERER ═══════════════════
|
|
936
|
+
/**
|
|
937
|
+
* Minimal, safe markdown renderer.
|
|
938
|
+
* Handles: headings, bold, italic, inline-code, code blocks,
|
|
939
|
+
* unordered/ordered lists, blockquotes, links, paragraphs.
|
|
940
|
+
* No external dependencies; XSS-safe via escaping raw text first.
|
|
941
|
+
*/
|
|
942
|
+
function renderMarkdown(text) {
|
|
943
|
+
// Escape first, then selectively un-escape for markdown constructs
|
|
944
|
+
// Work line-by-line for block elements, then inline
|
|
945
|
+
const lines = text.split("\n");
|
|
946
|
+
const out = [];
|
|
947
|
+
let i = 0;
|
|
948
|
+
|
|
949
|
+
while (i < lines.length) {
|
|
950
|
+
const line = lines[i];
|
|
951
|
+
|
|
952
|
+
// Code block
|
|
953
|
+
if (line.startsWith("```")) {
|
|
954
|
+
const lang = esc(line.slice(3).trim());
|
|
955
|
+
const codeLines = [];
|
|
956
|
+
i++;
|
|
957
|
+
while (i < lines.length && !lines[i].startsWith("```")) {
|
|
958
|
+
codeLines.push(esc(lines[i]));
|
|
959
|
+
i++;
|
|
960
|
+
}
|
|
961
|
+
out.push(`<pre><code${lang ? ` class="language-${lang}"` : ""}>${codeLines.join("\n")}</code></pre>`);
|
|
962
|
+
i++;
|
|
963
|
+
continue;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// Headings
|
|
967
|
+
const hMatch = line.match(/^(#{1,6})\s+(.+)/);
|
|
968
|
+
if (hMatch) {
|
|
969
|
+
const level = hMatch[1].length;
|
|
970
|
+
out.push(`<h${level}>${inlineMarkdown(hMatch[2])}</h${level}>`);
|
|
971
|
+
i++; continue;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// Blockquote
|
|
975
|
+
if (line.startsWith("> ")) {
|
|
976
|
+
const quoteLines = [];
|
|
977
|
+
while (i < lines.length && lines[i].startsWith("> ")) {
|
|
978
|
+
quoteLines.push(lines[i].slice(2));
|
|
979
|
+
i++;
|
|
980
|
+
}
|
|
981
|
+
out.push(`<blockquote>${inlineMarkdown(quoteLines.join("\n"))}</blockquote>`);
|
|
982
|
+
continue;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// Unordered list
|
|
986
|
+
if (/^[-*+] /.test(line)) {
|
|
987
|
+
const listItems = [];
|
|
988
|
+
while (i < lines.length && /^[-*+] /.test(lines[i])) {
|
|
989
|
+
listItems.push(`<li>${inlineMarkdown(lines[i].slice(2))}</li>`);
|
|
990
|
+
i++;
|
|
991
|
+
}
|
|
992
|
+
out.push(`<ul>${listItems.join("")}</ul>`);
|
|
993
|
+
continue;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// Ordered list
|
|
997
|
+
if (/^\d+\.\s/.test(line)) {
|
|
998
|
+
const listItems = [];
|
|
999
|
+
while (i < lines.length && /^\d+\.\s/.test(lines[i])) {
|
|
1000
|
+
listItems.push(`<li>${inlineMarkdown(lines[i].replace(/^\d+\.\s/, ""))}</li>`);
|
|
1001
|
+
i++;
|
|
1002
|
+
}
|
|
1003
|
+
out.push(`<ol>${listItems.join("")}</ol>`);
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// Horizontal rule
|
|
1008
|
+
if (/^---+$/.test(line.trim())) {
|
|
1009
|
+
out.push("<hr>");
|
|
1010
|
+
i++; continue;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// Empty line → paragraph break
|
|
1014
|
+
if (!line.trim()) {
|
|
1015
|
+
i++; continue;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// Paragraph: collect consecutive non-empty, non-special lines
|
|
1019
|
+
const paraLines = [];
|
|
1020
|
+
while (
|
|
1021
|
+
i < lines.length &&
|
|
1022
|
+
lines[i].trim() &&
|
|
1023
|
+
!lines[i].startsWith("#") &&
|
|
1024
|
+
!lines[i].startsWith("```") &&
|
|
1025
|
+
!lines[i].startsWith("> ") &&
|
|
1026
|
+
!/^[-*+] /.test(lines[i]) &&
|
|
1027
|
+
!/^\d+\.\s/.test(lines[i]) &&
|
|
1028
|
+
!/^---+$/.test(lines[i].trim())
|
|
1029
|
+
) {
|
|
1030
|
+
paraLines.push(lines[i]);
|
|
1031
|
+
i++;
|
|
1032
|
+
}
|
|
1033
|
+
if (paraLines.length) {
|
|
1034
|
+
out.push(`<p>${inlineMarkdown(paraLines.join(" "))}</p>`);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
return out.join("\n");
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
function inlineMarkdown(text) {
|
|
1042
|
+
return esc(text)
|
|
1043
|
+
// Bold+italic
|
|
1044
|
+
.replace(/\*\*\*(.+?)\*\*\*/g, "<strong><em>$1</em></strong>")
|
|
1045
|
+
// Bold
|
|
1046
|
+
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
|
1047
|
+
.replace(/__(.+?)__/g, "<strong>$1</strong>")
|
|
1048
|
+
// Italic
|
|
1049
|
+
.replace(/\*(.+?)\*/g, "<em>$1</em>")
|
|
1050
|
+
.replace(/_(.+?)_/g, "<em>$1</em>")
|
|
1051
|
+
// Inline code
|
|
1052
|
+
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
|
1053
|
+
// Links [text](url)
|
|
1054
|
+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// ═══════════════════ STATE MACHINE ═══════════════════
|
|
1058
|
+
// States: idle | recording | stopping | confirm | edit | processing
|
|
1059
|
+
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
1060
|
+
let STATE = "idle";
|
|
1061
|
+
let recognition = null;
|
|
1062
|
+
let audioCtx = null, analyser = null, micStream = null, animFrame = null;
|
|
1063
|
+
let finalTranscript = "";
|
|
1064
|
+
let recognitionEpoch = 0;
|
|
1065
|
+
let abortController = null;
|
|
1066
|
+
|
|
1067
|
+
function transitionTo(newState) {
|
|
1068
|
+
STATE = newState;
|
|
1069
|
+
const recordBtn = document.getElementById("recordBtn");
|
|
1070
|
+
const micOrb = document.getElementById("micOrb");
|
|
1071
|
+
const progressBar = document.getElementById("progressBar");
|
|
1072
|
+
|
|
1073
|
+
micOrb.classList.toggle("recording", newState === "recording");
|
|
1074
|
+
document.getElementById("waveform").classList.toggle("active", newState === "recording");
|
|
1075
|
+
["ring1", "ring2", "ring3"].forEach((id, i) =>
|
|
1076
|
+
document.getElementById(id).classList.toggle("pulse" + (i + 1), newState === "recording")
|
|
1077
|
+
);
|
|
1078
|
+
|
|
1079
|
+
recordBtn.classList.remove("recording", "processing");
|
|
1080
|
+
progressBar.classList.toggle("active", newState === "processing");
|
|
1081
|
+
|
|
1082
|
+
if (newState === "processing") {
|
|
1083
|
+
recordBtn.classList.add("processing");
|
|
1084
|
+
recordBtn.textContent = "✕ 取消";
|
|
1085
|
+
recordBtn.disabled = false;
|
|
1086
|
+
setStatus("live", "执行中");
|
|
1087
|
+
} else {
|
|
1088
|
+
recordBtn.disabled = false;
|
|
1089
|
+
if (newState === "recording") {
|
|
1090
|
+
recordBtn.classList.add("recording");
|
|
1091
|
+
recordBtn.textContent = "松开停止";
|
|
1092
|
+
setStatus("live", "录音中");
|
|
1093
|
+
} else if (newState === "confirm") {
|
|
1094
|
+
recordBtn.textContent = "重新录入";
|
|
1095
|
+
setStatus("live", "待确认");
|
|
1096
|
+
} else if (newState === "edit") {
|
|
1097
|
+
recordBtn.textContent = "按住说话";
|
|
1098
|
+
setStatus("live", "就绪");
|
|
1099
|
+
} else {
|
|
1100
|
+
recordBtn.textContent = "按住说话";
|
|
1101
|
+
if (newState === "idle") setStatus("live", "就绪");
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// ═══════════════════ RECOGNITION ═══════════════════
|
|
1107
|
+
function createRecognition(epoch) {
|
|
1108
|
+
if (!SpeechRecognition) return null;
|
|
1109
|
+
const rec = new SpeechRecognition();
|
|
1110
|
+
rec.continuous = true;
|
|
1111
|
+
rec.interimResults = true;
|
|
1112
|
+
const selectedLang = document.getElementById("langSelect").value;
|
|
1113
|
+
rec.lang = selectedLang;
|
|
1114
|
+
|
|
1115
|
+
rec.onresult = (e) => {
|
|
1116
|
+
if (epoch !== recognitionEpoch) return;
|
|
1117
|
+
let interim = "";
|
|
1118
|
+
for (let i = e.resultIndex; i < e.results.length; i++) {
|
|
1119
|
+
const t = e.results[i][0].transcript;
|
|
1120
|
+
if (e.results[i].isFinal) finalTranscript += t;
|
|
1121
|
+
else interim += t;
|
|
1122
|
+
}
|
|
1123
|
+
setComposeText(finalTranscript + interim, interim.length > 0);
|
|
1124
|
+
showComposePanel();
|
|
1125
|
+
};
|
|
1126
|
+
|
|
1127
|
+
rec.onend = () => {
|
|
1128
|
+
if (epoch !== recognitionEpoch) return;
|
|
1129
|
+
if (STATE === "recording") {
|
|
1130
|
+
// Browser silence timeout → restart
|
|
1131
|
+
try { rec.start(); } catch (_) {}
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
// User-initiated stop (STATE === "stopping")
|
|
1135
|
+
stopAudioAnalysis();
|
|
1136
|
+
const text = document.getElementById("composeText").value.trim();
|
|
1137
|
+
if (text) {
|
|
1138
|
+
setComposeText(text, false);
|
|
1139
|
+
transitionTo("confirm");
|
|
1140
|
+
showConfirmMode();
|
|
1141
|
+
setState("识别完成 — 确认发送或编辑");
|
|
1142
|
+
} else {
|
|
1143
|
+
hideComposePanel();
|
|
1144
|
+
transitionTo("idle");
|
|
1145
|
+
setState("未检测到语音,请重试");
|
|
1146
|
+
}
|
|
1147
|
+
};
|
|
1148
|
+
|
|
1149
|
+
rec.onerror = (e) => {
|
|
1150
|
+
if (epoch !== recognitionEpoch) return;
|
|
1151
|
+
if (e.error === "no-speech" || e.error === "aborted") return;
|
|
1152
|
+
stopAudioAnalysis();
|
|
1153
|
+
transitionTo("idle");
|
|
1154
|
+
const MSG = {
|
|
1155
|
+
"not-allowed": "麦克风权限被拒绝,请在浏览器设置中允许访问麦克风",
|
|
1156
|
+
"service-not-allowed": "语音识别服务不可用(可能需要 HTTPS 或网络连接)",
|
|
1157
|
+
"audio-capture": "未检测到麦克风设备",
|
|
1158
|
+
"network": "语音识别网络连接失败",
|
|
1159
|
+
};
|
|
1160
|
+
showToast(MSG[e.error] || ("识别错误:" + e.error), true);
|
|
1161
|
+
};
|
|
1162
|
+
|
|
1163
|
+
return rec;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// ═══════════════════ RECORDING ACTIONS ═══════════════════
|
|
1167
|
+
async function startRecording() {
|
|
1168
|
+
if (STATE !== "idle") return;
|
|
1169
|
+
if (!SpeechRecognition) {
|
|
1170
|
+
showToast("浏览器不支持 Web Speech API — 请使用 Chrome 或 Edge", true);
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
transitionTo("recording");
|
|
1175
|
+
|
|
1176
|
+
try {
|
|
1177
|
+
micStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
|
|
1178
|
+
startAudioAnalysis(micStream);
|
|
1179
|
+
} catch (e) {
|
|
1180
|
+
transitionTo("idle");
|
|
1181
|
+
const msg = e.name === "NotAllowedError"
|
|
1182
|
+
? "麦克风权限被拒绝,请在浏览器设置中允许访问"
|
|
1183
|
+
: "无法访问麦克风: " + e.message;
|
|
1184
|
+
showToast(msg, true);
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
recognitionEpoch++;
|
|
1189
|
+
recognition = createRecognition(recognitionEpoch);
|
|
1190
|
+
finalTranscript = "";
|
|
1191
|
+
setComposeText("", true);
|
|
1192
|
+
hideResponsePanel();
|
|
1193
|
+
hideConfirmMode();
|
|
1194
|
+
showComposePanel();
|
|
1195
|
+
|
|
1196
|
+
try {
|
|
1197
|
+
recognition.start();
|
|
1198
|
+
} catch (e) {
|
|
1199
|
+
stopAudioAnalysis();
|
|
1200
|
+
transitionTo("idle");
|
|
1201
|
+
showToast("无法启动识别: " + e.message, true);
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
setState("正在聆听…");
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
function stopRecording() {
|
|
1208
|
+
if (STATE !== "recording") return;
|
|
1209
|
+
STATE = "stopping";
|
|
1210
|
+
try { recognition && recognition.stop(); } catch (_) {}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
function reRecord() {
|
|
1214
|
+
setComposeText("", false);
|
|
1215
|
+
finalTranscript = "";
|
|
1216
|
+
hideResponsePanel();
|
|
1217
|
+
hideConfirmMode();
|
|
1218
|
+
document.getElementById("composeActions").classList.remove("visible");
|
|
1219
|
+
transitionTo("idle");
|
|
1220
|
+
startRecording();
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
function clearCompose() {
|
|
1224
|
+
setComposeText("", false);
|
|
1225
|
+
finalTranscript = "";
|
|
1226
|
+
hideComposePanel();
|
|
1227
|
+
hideResponsePanel();
|
|
1228
|
+
hideConfirmMode();
|
|
1229
|
+
document.getElementById("composeActions").classList.remove("visible");
|
|
1230
|
+
transitionTo("idle");
|
|
1231
|
+
setState("点击麦克风 或 按住空格 开始录音");
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// ═══════════════════ CONFIRM / EDIT MODES ═══════════════════
|
|
1235
|
+
function showConfirmMode() {
|
|
1236
|
+
document.getElementById("composePanel").classList.add("confirm-mode");
|
|
1237
|
+
document.getElementById("confirmBar").classList.add("visible");
|
|
1238
|
+
document.getElementById("composeActions").classList.remove("visible");
|
|
1239
|
+
setState("待确认");
|
|
1240
|
+
}
|
|
1241
|
+
function hideConfirmMode() {
|
|
1242
|
+
document.getElementById("composePanel").classList.remove("confirm-mode");
|
|
1243
|
+
document.getElementById("confirmBar").classList.remove("visible");
|
|
1244
|
+
}
|
|
1245
|
+
function enterEditMode() {
|
|
1246
|
+
hideConfirmMode();
|
|
1247
|
+
transitionTo("edit");
|
|
1248
|
+
document.getElementById("composeActions").classList.add("visible");
|
|
1249
|
+
const ta = document.getElementById("composeText");
|
|
1250
|
+
ta.removeAttribute("disabled");
|
|
1251
|
+
ta.focus();
|
|
1252
|
+
// Move cursor to end
|
|
1253
|
+
ta.selectionStart = ta.selectionEnd = ta.value.length;
|
|
1254
|
+
onComposeInput();
|
|
1255
|
+
setState("编辑模式 — 修改后点击发送");
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// ═══════════════════ AUDIO ANALYSIS ═══════════════════
|
|
1259
|
+
function startAudioAnalysis(stream) {
|
|
1260
|
+
audioCtx = new AudioContext();
|
|
1261
|
+
analyser = audioCtx.createAnalyser();
|
|
1262
|
+
analyser.fftSize = 256;
|
|
1263
|
+
audioCtx.createMediaStreamSource(stream).connect(analyser);
|
|
1264
|
+
const data = new Uint8Array(analyser.frequencyBinCount);
|
|
1265
|
+
(function draw() {
|
|
1266
|
+
animFrame = requestAnimationFrame(draw);
|
|
1267
|
+
analyser.getByteFrequencyData(data);
|
|
1268
|
+
for (let i = 0; i < 12; i++) {
|
|
1269
|
+
const val = data[Math.floor((i * data.length) / 12 / 4)];
|
|
1270
|
+
const el = document.getElementById("b" + i);
|
|
1271
|
+
if (el) el.style.height = Math.max(4, (val / 255) * 32) + "px";
|
|
1272
|
+
}
|
|
1273
|
+
})();
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function stopAudioAnalysis() {
|
|
1277
|
+
cancelAnimationFrame(animFrame);
|
|
1278
|
+
if (micStream) {
|
|
1279
|
+
micStream.getTracks().forEach((t) => t.stop());
|
|
1280
|
+
micStream = null;
|
|
1281
|
+
}
|
|
1282
|
+
if (audioCtx) {
|
|
1283
|
+
audioCtx.close();
|
|
1284
|
+
audioCtx = null;
|
|
1285
|
+
}
|
|
1286
|
+
for (let i = 0; i < 12; i++) {
|
|
1287
|
+
const el = document.getElementById("b" + i);
|
|
1288
|
+
if (el) el.style.height = "4px";
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
// ═══════════════════ COMPOSE PANEL ═══════════════════
|
|
1293
|
+
function showComposePanel() {
|
|
1294
|
+
document.getElementById("composePanel").classList.add("visible");
|
|
1295
|
+
}
|
|
1296
|
+
function hideComposePanel() {
|
|
1297
|
+
document.getElementById("composePanel").classList.remove("visible");
|
|
1298
|
+
document.getElementById("composeActions").classList.remove("visible");
|
|
1299
|
+
}
|
|
1300
|
+
function setComposeText(text, isInterim) {
|
|
1301
|
+
const ta = document.getElementById("composeText");
|
|
1302
|
+
const badge = document.getElementById("interimBadge");
|
|
1303
|
+
ta.value = text;
|
|
1304
|
+
ta.classList.toggle("interim", isInterim);
|
|
1305
|
+
badge.classList.toggle("show", isInterim);
|
|
1306
|
+
onComposeInput();
|
|
1307
|
+
}
|
|
1308
|
+
function onComposeInput() {
|
|
1309
|
+
const val = document.getElementById("composeText").value;
|
|
1310
|
+
document.getElementById("charCount").textContent = [...val].length + " 字";
|
|
1311
|
+
const canSend = !!val.trim() && (STATE === "edit" || STATE === "confirm");
|
|
1312
|
+
document.getElementById("sendBtn").disabled = !canSend;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// ═══════════════════ API CALL ═══════════════════
|
|
1316
|
+
let _pendingText = ""; // retained for retry
|
|
1317
|
+
|
|
1318
|
+
async function sendMessage() {
|
|
1319
|
+
if (STATE === "processing") return;
|
|
1320
|
+
const text = document.getElementById("composeText").value.trim();
|
|
1321
|
+
if (!text) return;
|
|
1322
|
+
|
|
1323
|
+
_pendingText = text;
|
|
1324
|
+
transitionTo("processing");
|
|
1325
|
+
hideConfirmMode();
|
|
1326
|
+
|
|
1327
|
+
// Show skeleton immediately — no overlay
|
|
1328
|
+
showResponseSkeleton();
|
|
1329
|
+
|
|
1330
|
+
abortController = new AbortController();
|
|
1331
|
+
setState("OpenClaw 执行中…");
|
|
1332
|
+
|
|
1333
|
+
try {
|
|
1334
|
+
const reply = await sendToGateway(text, abortController.signal, (chunk) => {
|
|
1335
|
+
showResponsePanel(chunk, true);
|
|
1336
|
+
});
|
|
1337
|
+
|
|
1338
|
+
addHistory("user", text);
|
|
1339
|
+
addHistory("assistant", reply);
|
|
1340
|
+
hideComposePanel();
|
|
1341
|
+
showResponsePanel(reply, false);
|
|
1342
|
+
setState("已收到回复");
|
|
1343
|
+
transitionTo("idle");
|
|
1344
|
+
} catch (e) {
|
|
1345
|
+
if (e.name === "AbortError") {
|
|
1346
|
+
hideResponsePanel();
|
|
1347
|
+
setState("已取消");
|
|
1348
|
+
transitionTo("edit");
|
|
1349
|
+
showComposePanel();
|
|
1350
|
+
document.getElementById("composeActions").classList.add("visible");
|
|
1351
|
+
} else {
|
|
1352
|
+
// Show error inline in the response panel — preserve compose text
|
|
1353
|
+
showResponseError(e.message);
|
|
1354
|
+
setState("请求失败");
|
|
1355
|
+
setStatus("error", "失败");
|
|
1356
|
+
transitionTo("edit");
|
|
1357
|
+
showComposePanel();
|
|
1358
|
+
document.getElementById("composeActions").classList.add("visible");
|
|
1359
|
+
}
|
|
1360
|
+
} finally {
|
|
1361
|
+
abortController = null;
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
function retryMessage() {
|
|
1366
|
+
if (!_pendingText) return;
|
|
1367
|
+
// Restore text into compose and re-send
|
|
1368
|
+
setComposeText(_pendingText, false);
|
|
1369
|
+
showComposePanel();
|
|
1370
|
+
hideResponsePanel();
|
|
1371
|
+
sendMessage();
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
function cancelRequest() {
|
|
1375
|
+
if (abortController) abortController.abort();
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
// ═══════════════════ RESPONSE PANEL ═══════════════════
|
|
1379
|
+
let _lastResponseText = "";
|
|
1380
|
+
|
|
1381
|
+
function _setResponseMode(mode) {
|
|
1382
|
+
// mode: "skeleton" | "content" | "error" | "hidden"
|
|
1383
|
+
const panel = document.getElementById("responsePanel");
|
|
1384
|
+
const skeleton = document.getElementById("responseSkeleton");
|
|
1385
|
+
const content = document.getElementById("responseContent");
|
|
1386
|
+
const errBody = document.getElementById("errorBody");
|
|
1387
|
+
const copyBtn = document.getElementById("copyBtn");
|
|
1388
|
+
const role = document.getElementById("responseRole");
|
|
1389
|
+
|
|
1390
|
+
skeleton.classList.toggle("visible", mode === "skeleton");
|
|
1391
|
+
content.style.display = (mode === "content") ? "" : "none";
|
|
1392
|
+
errBody.style.display = (mode === "error") ? "" : "none";
|
|
1393
|
+
copyBtn.style.display = (mode === "content") ? "" : "none";
|
|
1394
|
+
|
|
1395
|
+
panel.classList.remove("error-state");
|
|
1396
|
+
if (mode === "error") {
|
|
1397
|
+
panel.classList.add("error-state");
|
|
1398
|
+
role.textContent = "请求失败";
|
|
1399
|
+
} else {
|
|
1400
|
+
role.textContent = "助手回复";
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
if (mode === "hidden") {
|
|
1404
|
+
panel.classList.remove("visible");
|
|
1405
|
+
} else {
|
|
1406
|
+
panel.classList.add("visible");
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
function showResponseSkeleton() {
|
|
1411
|
+
document.getElementById("responseContent").innerHTML = "";
|
|
1412
|
+
document.getElementById("errorMessage").textContent = "";
|
|
1413
|
+
_lastResponseText = "";
|
|
1414
|
+
_setResponseMode("skeleton");
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
function showResponsePanel(text, isStreaming = false) {
|
|
1418
|
+
_lastResponseText = text;
|
|
1419
|
+
const content = document.getElementById("responseContent");
|
|
1420
|
+
|
|
1421
|
+
_setResponseMode("content");
|
|
1422
|
+
content.innerHTML = renderMarkdown(text) + (isStreaming ? '<span class="stream-cursor"></span>' : "");
|
|
1423
|
+
|
|
1424
|
+
if (!isStreaming) {
|
|
1425
|
+
requestAnimationFrame(() => {
|
|
1426
|
+
document.getElementById("speechArea").scrollTo({ top: 0, behavior: "smooth" });
|
|
1427
|
+
});
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
function showResponseError(message) {
|
|
1432
|
+
_setResponseMode("error");
|
|
1433
|
+
document.getElementById("errorMessage").textContent = message;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
function hideResponsePanel() {
|
|
1437
|
+
_setResponseMode("hidden");
|
|
1438
|
+
document.getElementById("responseContent").innerHTML = "";
|
|
1439
|
+
document.getElementById("errorMessage").textContent = "";
|
|
1440
|
+
_lastResponseText = "";
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
function copyResponse() {
|
|
1444
|
+
if (!_lastResponseText) return;
|
|
1445
|
+
navigator.clipboard.writeText(_lastResponseText)
|
|
1446
|
+
.then(() => showToast("✓ 已复制到剪贴板"))
|
|
1447
|
+
.catch(() => showToast("复制失败,请手动选择文本", true));
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
// ═══════════════════ UI HELPERS ═══════════════════
|
|
1451
|
+
function setState(txt) {
|
|
1452
|
+
document.getElementById("stateLabel").textContent = txt;
|
|
1453
|
+
}
|
|
1454
|
+
function setStatus(cls, txt) {
|
|
1455
|
+
document.getElementById("statusDot").className = "status-dot" + (cls ? " " + cls : "");
|
|
1456
|
+
document.getElementById("statusText").textContent = txt;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
let toastTimer;
|
|
1460
|
+
function showToast(msg, isErr = false) {
|
|
1461
|
+
clearTimeout(toastTimer);
|
|
1462
|
+
const t = document.getElementById("toast");
|
|
1463
|
+
t.textContent = msg;
|
|
1464
|
+
t.className = "toast" + (isErr ? " error" : "");
|
|
1465
|
+
t.classList.add("show");
|
|
1466
|
+
toastTimer = setTimeout(() => t.classList.remove("show"), isErr ? 5000 : 3000);
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
// ═══════════════════ KEYBOARD ═══════════════════
|
|
1470
|
+
document.addEventListener("keydown", (e) => {
|
|
1471
|
+
const inInput = ["INPUT", "TEXTAREA", "SELECT"].includes(e.target.tagName);
|
|
1472
|
+
const confirmVisible = document.getElementById("confirmBar").classList.contains("visible");
|
|
1473
|
+
const composeVisible = document.getElementById("composePanel").classList.contains("visible");
|
|
1474
|
+
|
|
1475
|
+
if (e.code === "Space" && !e.repeat && !inInput) {
|
|
1476
|
+
e.preventDefault();
|
|
1477
|
+
if (STATE === "idle") {
|
|
1478
|
+
startRecording();
|
|
1479
|
+
} else if (STATE === "confirm") {
|
|
1480
|
+
reRecord();
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
if ((e.code === "Enter" && !e.shiftKey) && !inInput) {
|
|
1484
|
+
if (confirmVisible || STATE === "confirm") { e.preventDefault(); sendMessage(); }
|
|
1485
|
+
}
|
|
1486
|
+
if (e.code === "KeyE" && !inInput && STATE === "confirm") { enterEditMode(); }
|
|
1487
|
+
if (e.code === "Escape") {
|
|
1488
|
+
if (document.getElementById("kbdPopup").classList.contains("open")) {
|
|
1489
|
+
closeKbdPopup();
|
|
1490
|
+
} else if (
|
|
1491
|
+
document.getElementById("historyPanel").classList.contains("open")
|
|
1492
|
+
) {
|
|
1493
|
+
closeAllPanels();
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
if (e.code === "KeyH" && !inInput) {
|
|
1497
|
+
const open = document.getElementById("historyPanel").classList.contains("open");
|
|
1498
|
+
open ? closeAllPanels() : openPanel("history");
|
|
1499
|
+
}
|
|
1500
|
+
});
|
|
1501
|
+
|
|
1502
|
+
document.addEventListener("keyup", (e) => {
|
|
1503
|
+
const inInput = ["INPUT", "TEXTAREA", "SELECT"].includes(e.target.tagName);
|
|
1504
|
+
if (e.code === "Space" && !inInput && STATE === "recording") stopRecording();
|
|
1505
|
+
});
|
|
1506
|
+
|
|
1507
|
+
// Orb: keyboard accessibility
|
|
1508
|
+
document.getElementById("micOrb").addEventListener("keydown", (e) => {
|
|
1509
|
+
if (e.code === "Enter" || e.code === "Space") {
|
|
1510
|
+
e.preventDefault();
|
|
1511
|
+
if (STATE === "idle") startRecording();
|
|
1512
|
+
else if (STATE === "recording") stopRecording();
|
|
1513
|
+
else if (STATE === "confirm") reRecord();
|
|
1514
|
+
}
|
|
1515
|
+
});
|
|
1516
|
+
|
|
1517
|
+
document.getElementById("recordBtn").addEventListener("click", () => {
|
|
1518
|
+
if (STATE === "processing") { cancelRequest(); return; }
|
|
1519
|
+
if (STATE === "idle") {
|
|
1520
|
+
startRecording();
|
|
1521
|
+
} else if (STATE === "recording") {
|
|
1522
|
+
stopRecording();
|
|
1523
|
+
} else if (STATE === "confirm") {
|
|
1524
|
+
reRecord();
|
|
1525
|
+
}
|
|
1526
|
+
});
|
|
1527
|
+
|
|
1528
|
+
document.getElementById("micOrb").addEventListener("click", () => {
|
|
1529
|
+
if (STATE === "idle") {
|
|
1530
|
+
startRecording();
|
|
1531
|
+
} else if (STATE === "recording") {
|
|
1532
|
+
stopRecording();
|
|
1533
|
+
} else if (STATE === "confirm") {
|
|
1534
|
+
reRecord();
|
|
1535
|
+
}
|
|
1536
|
+
});
|
|
1537
|
+
|
|
1538
|
+
document.getElementById("composeText").addEventListener("keydown", (e) => {
|
|
1539
|
+
if (e.code === "Enter" && !e.shiftKey) {
|
|
1540
|
+
e.preventDefault();
|
|
1541
|
+
const btn = document.getElementById("sendBtn");
|
|
1542
|
+
if (!btn.disabled) sendMessage();
|
|
1543
|
+
}
|
|
1544
|
+
});
|
|
1545
|
+
|
|
1546
|
+
// ═══════════════════ INIT ═══════════════════
|
|
1547
|
+
// Show no-API warning if browser lacks Speech Recognition
|
|
1548
|
+
if (!SpeechRecognition) {
|
|
1549
|
+
document.getElementById("noApiBanner").classList.add("visible");
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
// Restore language preference
|
|
1553
|
+
const savedLang = localStorage.getItem(LANG_CACHE_KEY) || "zh-CN";
|
|
1554
|
+
document.getElementById("langSelect").value = savedLang;
|
|
1555
|
+
document.getElementById("langSelect").addEventListener("change", (e) => {
|
|
1556
|
+
localStorage.setItem(LANG_CACHE_KEY, e.target.value);
|
|
1557
|
+
});
|
|
1558
|
+
|
|
1559
|
+
setStatus("", "就绪");
|
|
1560
|
+
renderHistory();
|
|
1561
|
+
|
|
1562
|
+
// Connectivity check
|
|
1563
|
+
fetch('chat', { method: "HEAD" })
|
|
1564
|
+
.then(() => setStatus("live", "已连接"))
|
|
1565
|
+
.catch(() => {
|
|
1566
|
+
setStatus("error", "无法连接");
|
|
1567
|
+
showToast("⚠ 无法连接到服务器,请检查配置", true);
|
|
1568
|
+
});
|
|
1569
|
+
</script>
|
|
1570
|
+
</body>
|
|
1571
|
+
</html>
|