@oneciel-ai/ciel-runtime 0.2.9 → 0.2.10
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/ciel_runtime_support/channel_mcp_tools.py +54 -4
- package/ciel_runtime_support/channel_message_prompt.py +17 -7
- package/ciel_runtime_support/runtime_constants.py +1 -1
- package/ciel_runtime_support/web_ui.py +119 -14
- package/docs/COLAB_SPEECH.md +5 -1
- package/package.json +1 -1
- package/scripts/colab/__pycache__/bootstrap_moss_tts.cpython-311.pyc +0 -0
- package/scripts/colab/__pycache__/bootstrap_qwen_asr.cpython-311.pyc +0 -0
|
@@ -46,7 +46,25 @@ def channel_mcp_tool_schemas() -> list[dict[str, Any]]:
|
|
|
46
46
|
"type": "object",
|
|
47
47
|
"properties": {
|
|
48
48
|
"channel": {"type": "string", "description": "Destination channel id from the incoming message."},
|
|
49
|
-
"message": {"type": "string", "description": "
|
|
49
|
+
"message": {"type": "string", "description": "Legacy unstructured message body."},
|
|
50
|
+
"response": {
|
|
51
|
+
"type": "object",
|
|
52
|
+
"description": "Structured Web Chat response. Prefer this for browser acknowledgements and replies.",
|
|
53
|
+
"properties": {
|
|
54
|
+
"spoken": {
|
|
55
|
+
"type": "string",
|
|
56
|
+
"description": "Short conversational text intended for speech synthesis.",
|
|
57
|
+
},
|
|
58
|
+
"overview": {
|
|
59
|
+
"type": "string",
|
|
60
|
+
"description": "Concise on-screen summary.",
|
|
61
|
+
},
|
|
62
|
+
"details": {
|
|
63
|
+
"type": "string",
|
|
64
|
+
"description": "Optional detailed supporting Markdown.",
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
50
68
|
"recipients": {
|
|
51
69
|
"description": "Recipient id, 'all', or an array of recipients. Use 'web' for /ca/web/chat replies."
|
|
52
70
|
},
|
|
@@ -59,7 +77,11 @@ def channel_mcp_tool_schemas() -> list[dict[str, Any]]:
|
|
|
59
77
|
},
|
|
60
78
|
"kind": {"type": "string", "description": "Optional message kind, for example 'reply' or 'status'."},
|
|
61
79
|
},
|
|
62
|
-
"required": ["channel"
|
|
80
|
+
"required": ["channel"],
|
|
81
|
+
"anyOf": [
|
|
82
|
+
{"required": ["message"]},
|
|
83
|
+
{"required": ["response"]},
|
|
84
|
+
],
|
|
63
85
|
},
|
|
64
86
|
},
|
|
65
87
|
{
|
|
@@ -169,13 +191,41 @@ def _send_message(
|
|
|
169
191
|
services: ChannelMcpToolServices,
|
|
170
192
|
) -> dict[str, Any]:
|
|
171
193
|
channel = str(args.get("channel") or "").strip()
|
|
194
|
+
structured = _structured_response(args.get("response"))
|
|
172
195
|
message = str(args.get("message") or args.get("text") or "").strip()
|
|
196
|
+
if structured and not message:
|
|
197
|
+
message = _structured_message_text(structured)
|
|
173
198
|
if not channel or not message:
|
|
174
|
-
return channel_mcp_tool_response(
|
|
175
|
-
|
|
199
|
+
return channel_mcp_tool_response(
|
|
200
|
+
request_id,
|
|
201
|
+
"send_message requires channel and either message or a non-empty response object.",
|
|
202
|
+
True,
|
|
203
|
+
)
|
|
204
|
+
payload = _message_payload(args, channel, message, "reply")
|
|
205
|
+
if structured:
|
|
206
|
+
payload["meta"]["web_response"] = structured
|
|
207
|
+
saved = services.append_message(payload)
|
|
176
208
|
return _json_response(request_id, {"ok": True, "message": saved})
|
|
177
209
|
|
|
178
210
|
|
|
211
|
+
def _structured_response(value: Any) -> dict[str, str]:
|
|
212
|
+
if not isinstance(value, dict):
|
|
213
|
+
return {}
|
|
214
|
+
response = {
|
|
215
|
+
key: str(value.get(key) or "").strip()
|
|
216
|
+
for key in ("spoken", "overview", "details")
|
|
217
|
+
}
|
|
218
|
+
return response if any(response.values()) else {}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _structured_message_text(response: dict[str, str]) -> str:
|
|
222
|
+
overview = response.get("overview", "")
|
|
223
|
+
details = response.get("details", "")
|
|
224
|
+
if overview and details:
|
|
225
|
+
return f"{overview}\n\n{details}"
|
|
226
|
+
return overview or details or response.get("spoken", "")
|
|
227
|
+
|
|
228
|
+
|
|
179
229
|
def _send_file(
|
|
180
230
|
request_id: Any,
|
|
181
231
|
args: dict[str, Any],
|
|
@@ -156,7 +156,8 @@ def _web_chat_reply_routes(messages: list[dict[str, Any]]) -> list[dict[str, str
|
|
|
156
156
|
if route in seen:
|
|
157
157
|
continue
|
|
158
158
|
seen.add(route)
|
|
159
|
-
|
|
159
|
+
input_mode = str(meta.get("input_mode") or "text").strip().lower()
|
|
160
|
+
routes.append({"channel": channel, "thread_id": thread, "input_mode": input_mode})
|
|
160
161
|
return routes
|
|
161
162
|
|
|
162
163
|
|
|
@@ -166,12 +167,21 @@ def _web_chat_reply_instruction(messages: list[dict[str, Any]]) -> str:
|
|
|
166
167
|
return ""
|
|
167
168
|
encoded_routes = json.dumps(routes, ensure_ascii=False, separators=(",", ":"))
|
|
168
169
|
return (
|
|
169
|
-
"[ciel-runtime web reply required] Do not leave
|
|
170
|
-
"
|
|
171
|
-
"
|
|
172
|
-
|
|
173
|
-
"delivery=[\"web\"], kind=\"
|
|
174
|
-
"
|
|
170
|
+
"[ciel-runtime web reply required] Do not leave responses only in the terminal. "
|
|
171
|
+
"For each route in "
|
|
172
|
+
f"{encoded_routes}: (1) immediately acknowledge the request with one short, honest sentence by calling "
|
|
173
|
+
"MCP server `ciel-runtime-router` tool `send_message` using that channel and thread_id, "
|
|
174
|
+
"recipients=[\"web\"], delivery=[\"web\"], kind=\"ack\", and "
|
|
175
|
+
"response={\"spoken\":\"brief conversational acknowledgement\","
|
|
176
|
+
"\"overview\":\"brief acknowledgement\",\"details\":\"\"}. "
|
|
177
|
+
"Do not claim completion in the acknowledgement. (2) Perform the requested work and then call "
|
|
178
|
+
"`send_message` again with kind=\"reply\" and a structured response object: "
|
|
179
|
+
"response={\"spoken\":\"one to three short conversational sentences suitable for TTS\","
|
|
180
|
+
"\"overview\":\"concise screen summary\","
|
|
181
|
+
"\"details\":\"supporting Markdown only when useful, otherwise empty\"}. "
|
|
182
|
+
"For input_mode=voice, spoken is required and must avoid Markdown, URLs, code, tables, and long lists; "
|
|
183
|
+
"the browser speaks only this field. Keep overview compact and put evidence, commands, links, and "
|
|
184
|
+
"technical detail in details. Use `send_file` on the same route for files."
|
|
175
185
|
)
|
|
176
186
|
|
|
177
187
|
|
|
@@ -115,6 +115,13 @@ def render_web_chat_page(
|
|
|
115
115
|
.recording {{ border-color: #ef4444 !important; color: #fecaca !important; }}
|
|
116
116
|
.message-actions {{ display: flex; align-items: flex-start; padding: 4px; }}
|
|
117
117
|
.message-actions button {{ border: 1px solid var(--line); border-radius: 999px; background: #0b111b; color: var(--muted); cursor: pointer; padding: 4px 8px; }}
|
|
118
|
+
.structured-response {{ display: grid; gap: 10px; }}
|
|
119
|
+
.response-section {{ display: grid; gap: 4px; }}
|
|
120
|
+
.response-section + .response-section {{ border-top: 1px solid rgba(255,255,255,.1); padding-top: 9px; }}
|
|
121
|
+
.response-label {{ color: #9fb1c8; font-size: 10px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }}
|
|
122
|
+
.response-spoken {{ color: #f0fdfa; }}
|
|
123
|
+
.live-transcript {{ display: none; width: 100%; border: 1px solid #315b66; border-radius: 6px; background: #0d1d25; color: #bcecf3; padding: 7px 9px; font-size: 13px; }}
|
|
124
|
+
.live-transcript.active {{ display: block; }}
|
|
118
125
|
dialog {{ width: min(720px, calc(100vw - 28px)); max-height: calc(100vh - 28px); overflow: auto; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); color: var(--text); padding: 0; }}
|
|
119
126
|
dialog::backdrop {{ background: rgba(0,0,0,.72); }}
|
|
120
127
|
.settings-head {{ position: sticky; top: 0; display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--line); background: var(--panel); }}
|
|
@@ -197,6 +204,7 @@ def render_web_chat_page(
|
|
|
197
204
|
<input id="fileInput" type="file" multiple>
|
|
198
205
|
<div class="attachment-tray" id="attachmentTray" aria-live="polite"></div>
|
|
199
206
|
</div>
|
|
207
|
+
<div class="live-transcript" id="liveTranscript" aria-live="polite"></div>
|
|
200
208
|
<div class="hint">Enter sends. Shift+Enter inserts a new line. Live voice detects the end of each utterance, transcribes and sends it automatically, and supports interruption while TTS is speaking. The active coding-agent session handles the message, so its configured tools and MCP servers remain available.</div>
|
|
201
209
|
</form>
|
|
202
210
|
</main>
|
|
@@ -269,6 +277,7 @@ def render_web_chat_page(
|
|
|
269
277
|
const micButton = document.getElementById('micButton');
|
|
270
278
|
const fileInput = document.getElementById('fileInput');
|
|
271
279
|
const attachmentTray = document.getElementById('attachmentTray');
|
|
280
|
+
const liveTranscript = document.getElementById('liveTranscript');
|
|
272
281
|
const shareButton = document.getElementById('shareButton');
|
|
273
282
|
const clearButton = document.getElementById('clearButton');
|
|
274
283
|
const speechSettingsButton = document.getElementById('speechSettingsButton');
|
|
@@ -316,6 +325,9 @@ def render_web_chat_page(
|
|
|
316
325
|
let vadVoicedSamples = 0;
|
|
317
326
|
let vadNoiseFloor = 0.006;
|
|
318
327
|
let liveTranscriptionQueue = Promise.resolve();
|
|
328
|
+
let livePartialInFlight = false;
|
|
329
|
+
let livePartialLastAt = 0;
|
|
330
|
+
let liveUtteranceSerial = 0;
|
|
319
331
|
let activeSpeechAudio = null;
|
|
320
332
|
let activeSpeechUrl = '';
|
|
321
333
|
let speechGenerationController = null;
|
|
@@ -521,6 +533,58 @@ def render_web_chat_page(
|
|
|
521
533
|
}}
|
|
522
534
|
return bubble;
|
|
523
535
|
}}
|
|
536
|
+
function structuredWebResponse(message) {{
|
|
537
|
+
const value = message && message.meta && message.meta.web_response;
|
|
538
|
+
if (!value || typeof value !== 'object') return null;
|
|
539
|
+
const response = {{
|
|
540
|
+
spoken: String(value.spoken || '').trim(),
|
|
541
|
+
overview: String(value.overview || '').trim(),
|
|
542
|
+
details: String(value.details || '').trim(),
|
|
543
|
+
}};
|
|
544
|
+
return response.spoken || response.overview || response.details ? response : null;
|
|
545
|
+
}}
|
|
546
|
+
function addStructuredBubble(response, mode = 'append', id = null) {{
|
|
547
|
+
if (id !== null && id !== undefined) {{
|
|
548
|
+
const key = String(id);
|
|
549
|
+
if (renderedIds.has(key)) return null;
|
|
550
|
+
renderedIds.add(key);
|
|
551
|
+
}}
|
|
552
|
+
const row = document.createElement('div');
|
|
553
|
+
row.className = 'row assistant';
|
|
554
|
+
const bubble = document.createElement('div');
|
|
555
|
+
bubble.className = 'bubble structured-response';
|
|
556
|
+
const sections = [
|
|
557
|
+
['Voice', response.spoken, 'response-spoken'],
|
|
558
|
+
['Overview', response.overview, 'markdown'],
|
|
559
|
+
['Details', response.details, 'markdown'],
|
|
560
|
+
];
|
|
561
|
+
sections.forEach(([labelText, value, className]) => {{
|
|
562
|
+
if (!value) return;
|
|
563
|
+
const section = document.createElement('section');
|
|
564
|
+
section.className = 'response-section ' + className;
|
|
565
|
+
const label = document.createElement('div');
|
|
566
|
+
label.className = 'response-label';
|
|
567
|
+
label.textContent = labelText;
|
|
568
|
+
const content = document.createElement('div');
|
|
569
|
+
if (className === 'markdown') content.innerHTML = renderMarkdown(value);
|
|
570
|
+
else content.textContent = value;
|
|
571
|
+
section.appendChild(label);
|
|
572
|
+
section.appendChild(content);
|
|
573
|
+
bubble.appendChild(section);
|
|
574
|
+
}});
|
|
575
|
+
row.appendChild(bubble);
|
|
576
|
+
const actions = document.createElement('div');
|
|
577
|
+
actions.className = 'message-actions';
|
|
578
|
+
const speak = document.createElement('button');
|
|
579
|
+
speak.type = 'button';
|
|
580
|
+
speak.textContent = 'Speak';
|
|
581
|
+
speak.addEventListener('click', () => speakText(response.spoken || response.overview));
|
|
582
|
+
actions.appendChild(speak);
|
|
583
|
+
row.appendChild(actions);
|
|
584
|
+
if (mode === 'prepend') transcript.insertBefore(row, transcript.firstChild);
|
|
585
|
+
else {{ transcript.appendChild(row); transcript.scrollTop = transcript.scrollHeight; }}
|
|
586
|
+
return bubble;
|
|
587
|
+
}}
|
|
524
588
|
function rememberLastId(id) {{
|
|
525
589
|
const numeric = Number(id || 0) || 0;
|
|
526
590
|
if (numeric > lastId) {{
|
|
@@ -534,11 +598,14 @@ def render_web_chat_page(
|
|
|
534
598
|
function renderIncomingMessage(message, mode = 'append') {{
|
|
535
599
|
if (mode !== 'prepend') rememberLastId(message.id);
|
|
536
600
|
const text = message.message || '';
|
|
537
|
-
|
|
538
|
-
|
|
601
|
+
const structured = structuredWebResponse(message);
|
|
602
|
+
if (!text.trim() && !structured) return;
|
|
603
|
+
if (structured && roleForMessage(message) === 'assistant') addStructuredBubble(structured, mode, message.id);
|
|
604
|
+
else addBubble(roleForMessage(message), text, mode, message.id);
|
|
539
605
|
if (mode !== 'prepend' && message.sender_id !== 'web-user') {{
|
|
540
606
|
setState('reply received', 'ok');
|
|
541
|
-
|
|
607
|
+
const speechText = structured ? (structured.spoken || structured.overview) : text;
|
|
608
|
+
if (speechConfig.tts && speechConfig.tts.enabled && speechText && (speechConfig.tts.auto_speak || liveVoiceEnabled)) speakText(speechText);
|
|
542
609
|
}}
|
|
543
610
|
}}
|
|
544
611
|
function formatBytes(bytes) {{
|
|
@@ -712,17 +779,21 @@ def render_web_chat_page(
|
|
|
712
779
|
setState('generating speech');
|
|
713
780
|
const response = await fetch('/v1/audio/speech', {{
|
|
714
781
|
method: 'POST',
|
|
715
|
-
headers: {{'content-type': 'application/json'}},
|
|
782
|
+
headers: {{'content-type': 'application/json', 'accept': 'audio/*'}},
|
|
716
783
|
body: JSON.stringify({{input: String(text || ''), model: speechConfig.tts.model, voice: speechConfig.tts.voice, language: speechConfig.tts.language, response_format: speechConfig.tts.response_format || 'wav'}}),
|
|
717
784
|
signal: controller.signal,
|
|
718
785
|
}});
|
|
719
786
|
if (!response.ok) throw new Error(await response.text() || `HTTP ${{response.status}}`);
|
|
720
787
|
const blob = await response.blob();
|
|
788
|
+
if (!blob.size) throw new Error('TTS returned empty audio');
|
|
789
|
+
if (blob.type && !blob.type.startsWith('audio/')) throw new Error(`TTS returned ${{blob.type}} instead of audio`);
|
|
721
790
|
if (controller.signal.aborted) return;
|
|
722
791
|
speechGenerationController = null;
|
|
723
792
|
activeSpeechUrl = URL.createObjectURL(blob);
|
|
724
793
|
activeSpeechAudio = new Audio(activeSpeechUrl);
|
|
725
794
|
const audio = activeSpeechAudio;
|
|
795
|
+
audio.preload = 'auto';
|
|
796
|
+
audio.playsInline = true;
|
|
726
797
|
const cleanup = () => {{
|
|
727
798
|
if (activeSpeechAudio === audio) {{
|
|
728
799
|
activeSpeechAudio = null;
|
|
@@ -742,13 +813,14 @@ def render_web_chat_page(
|
|
|
742
813
|
addBubble('system', 'TTS failed: ' + String(err && err.message ? err.message : err));
|
|
743
814
|
}}
|
|
744
815
|
}}
|
|
745
|
-
async function transcribeRecording(blob, populatePrompt = true) {{
|
|
746
|
-
setState('transcribing');
|
|
816
|
+
async function transcribeRecording(blob, populatePrompt = true, options = {{}}) {{
|
|
817
|
+
if (!options.quiet) setState('transcribing');
|
|
747
818
|
const audio_base64 = await fileToBase64(blob);
|
|
748
819
|
const response = await fetch('/v1/audio/transcriptions', {{
|
|
749
820
|
method: 'POST',
|
|
750
821
|
headers: {{'content-type': 'application/json', 'accept': 'application/json'}},
|
|
751
|
-
body: JSON.stringify({{audio_base64, filename: 'web-chat-recording.wav', content_type: blob.type || 'audio/wav', model: speechConfig.asr.model, language: speechConfig.asr.language}})
|
|
822
|
+
body: JSON.stringify({{audio_base64, filename: 'web-chat-recording.wav', content_type: blob.type || 'audio/wav', model: speechConfig.asr.model, language: speechConfig.asr.language}}),
|
|
823
|
+
signal: options.signal,
|
|
752
824
|
}});
|
|
753
825
|
const text = await response.text();
|
|
754
826
|
let data = {{}};
|
|
@@ -760,7 +832,7 @@ def render_web_chat_page(
|
|
|
760
832
|
prompt.value = prompt.value ? prompt.value + ' ' + transcriptText : transcriptText;
|
|
761
833
|
prompt.focus();
|
|
762
834
|
}}
|
|
763
|
-
setState('transcribed', 'ok');
|
|
835
|
+
if (!options.quiet) setState('transcribed', 'ok');
|
|
764
836
|
return transcriptText;
|
|
765
837
|
}}
|
|
766
838
|
function encodePcmWav(chunks, sampleRate) {{
|
|
@@ -798,13 +870,35 @@ def render_web_chat_page(
|
|
|
798
870
|
vadLastVoiceAt = 0;
|
|
799
871
|
vadVoicedSamples = 0;
|
|
800
872
|
}}
|
|
801
|
-
function
|
|
873
|
+
function setLiveTranscript(text, active = true) {{
|
|
874
|
+
liveTranscript.textContent = text;
|
|
875
|
+
liveTranscript.classList.toggle('active', Boolean(active && text));
|
|
876
|
+
}}
|
|
877
|
+
function requestLivePartial(now) {{
|
|
878
|
+
if (!vadSpeechActive || livePartialInFlight || !audioContext) return;
|
|
879
|
+
if (now - livePartialLastAt < 1200 || now - vadSpeechStartedAt < 900) return;
|
|
880
|
+
const serial = liveUtteranceSerial;
|
|
881
|
+
const blob = encodePcmWav(vadSpeechChunks.slice(), audioContext.sampleRate);
|
|
882
|
+
livePartialInFlight = true;
|
|
883
|
+
livePartialLastAt = now;
|
|
884
|
+
transcribeRecording(blob, false, {{quiet: true}}).then(text => {{
|
|
885
|
+
if (liveVoiceEnabled && vadSpeechActive && serial === liveUtteranceSerial) {{
|
|
886
|
+
setLiveTranscript('Live: ' + text);
|
|
887
|
+
}}
|
|
888
|
+
}}).catch(() => {{
|
|
889
|
+
// Partial transcription is best-effort; final transcription reports actionable errors.
|
|
890
|
+
}}).finally(() => {{
|
|
891
|
+
livePartialInFlight = false;
|
|
892
|
+
}});
|
|
893
|
+
}}
|
|
894
|
+
function queueLiveUtterance(chunks, sampleRate, serial) {{
|
|
802
895
|
if (!chunks.length) return;
|
|
803
896
|
const blob = encodePcmWav(chunks, sampleRate);
|
|
804
897
|
liveTranscriptionQueue = liveTranscriptionQueue.then(async () => {{
|
|
805
898
|
const transcriptText = await transcribeRecording(blob, false);
|
|
899
|
+
if (serial === liveUtteranceSerial) setLiveTranscript('Heard: ' + transcriptText);
|
|
806
900
|
setState('sending voice');
|
|
807
|
-
await sendMessage(transcriptText, []);
|
|
901
|
+
await sendMessage(transcriptText, [], {{inputMode: 'voice'}});
|
|
808
902
|
if (liveVoiceEnabled) setState('listening', 'ok');
|
|
809
903
|
}}).catch(err => {{
|
|
810
904
|
setState('STT error', 'error');
|
|
@@ -814,9 +908,12 @@ def render_web_chat_page(
|
|
|
814
908
|
function finishVadUtterance() {{
|
|
815
909
|
const chunks = vadSpeechChunks.slice();
|
|
816
910
|
const sampleRate = audioContext ? audioContext.sampleRate : 48000;
|
|
911
|
+
const serial = liveUtteranceSerial;
|
|
912
|
+
const partialText = liveTranscript.textContent.replace(/^Live:\\s*/, '');
|
|
913
|
+
setLiveTranscript(partialText ? 'Finalizing: ' + partialText : 'Finalizing speech...');
|
|
817
914
|
resetVadUtterance();
|
|
818
915
|
vadPreRollChunks = [];
|
|
819
|
-
queueLiveUtterance(chunks, sampleRate);
|
|
916
|
+
queueLiveUtterance(chunks, sampleRate, serial);
|
|
820
917
|
}}
|
|
821
918
|
function processVadFrame(event) {{
|
|
822
919
|
if (!liveVoiceEnabled || !audioContext) return;
|
|
@@ -838,12 +935,15 @@ def render_web_chat_page(
|
|
|
838
935
|
return;
|
|
839
936
|
}}
|
|
840
937
|
vadSpeechActive = true;
|
|
938
|
+
liveUtteranceSerial += 1;
|
|
939
|
+
livePartialLastAt = now;
|
|
841
940
|
vadSpeechStartedAt = now;
|
|
842
941
|
vadLastVoiceAt = now;
|
|
843
942
|
vadVoicedSamples = chunk.length;
|
|
844
943
|
vadSpeechChunks = vadPreRollChunks.concat([chunk]);
|
|
845
944
|
vadPreRollChunks = [];
|
|
846
945
|
stopActiveSpeech();
|
|
946
|
+
setLiveTranscript('Listening to speech...');
|
|
847
947
|
setState('hearing speech', 'ok');
|
|
848
948
|
return;
|
|
849
949
|
}}
|
|
@@ -856,9 +956,10 @@ def render_web_chat_page(
|
|
|
856
956
|
const minSpeechMs = Number((speechConfig.asr && speechConfig.asr.min_speech_ms) || 300);
|
|
857
957
|
const voicedMs = vadVoicedSamples * 1000 / audioContext.sampleRate;
|
|
858
958
|
const utteranceMs = now - vadSpeechStartedAt;
|
|
959
|
+
requestLivePartial(now);
|
|
859
960
|
if (now - vadLastVoiceAt >= silenceMs) {{
|
|
860
961
|
if (voicedMs >= minSpeechMs) finishVadUtterance();
|
|
861
|
-
else resetVadUtterance();
|
|
962
|
+
else {{ resetVadUtterance(); setLiveTranscript('Listening...', liveVoiceEnabled); }}
|
|
862
963
|
}} else if (utteranceMs >= 30000) {{
|
|
863
964
|
finishVadUtterance();
|
|
864
965
|
}}
|
|
@@ -881,6 +982,7 @@ def render_web_chat_page(
|
|
|
881
982
|
audioProcessor.connect(audioContext.destination);
|
|
882
983
|
micButton.textContent = 'Stop live voice';
|
|
883
984
|
micButton.classList.add('recording');
|
|
985
|
+
setLiveTranscript('Listening...');
|
|
884
986
|
setState('listening', 'ok');
|
|
885
987
|
}}
|
|
886
988
|
async function stopVoiceInput() {{
|
|
@@ -901,6 +1003,7 @@ def render_web_chat_page(
|
|
|
901
1003
|
audioContext = null;
|
|
902
1004
|
mediaStream = null;
|
|
903
1005
|
stopActiveSpeech();
|
|
1006
|
+
setLiveTranscript('', false);
|
|
904
1007
|
micButton.textContent = 'Start live voice';
|
|
905
1008
|
micButton.classList.remove('recording');
|
|
906
1009
|
setState('ready');
|
|
@@ -1033,7 +1136,7 @@ def render_web_chat_page(
|
|
|
1033
1136
|
setTimeout(startChannelStream, 1200);
|
|
1034
1137
|
}};
|
|
1035
1138
|
}}
|
|
1036
|
-
async function sendMessage(text, files = []) {{
|
|
1139
|
+
async function sendMessage(text, files = [], options = {{}}) {{
|
|
1037
1140
|
setState('queued');
|
|
1038
1141
|
sendButton.disabled = true;
|
|
1039
1142
|
attachButton.disabled = true;
|
|
@@ -1054,9 +1157,11 @@ def render_web_chat_page(
|
|
|
1054
1157
|
meta: {{
|
|
1055
1158
|
source: 'ciel-runtime-web-chat',
|
|
1056
1159
|
web_chat_session: sessionId,
|
|
1160
|
+
input_mode: options.inputMode || 'text',
|
|
1057
1161
|
reply_channel: channel,
|
|
1058
1162
|
reply_recipient: 'web',
|
|
1059
|
-
|
|
1163
|
+
response_contract: {{version: 1, fields: ['spoken', 'overview', 'details'], tts_field: 'spoken'}},
|
|
1164
|
+
reply_instruction: 'Acknowledge briefly first, then use the ciel-runtime-router send_message tool with response.spoken, response.overview, and optional response.details. The browser speaks only response.spoken. Use send_file when returning a file attachment.',
|
|
1060
1165
|
attachments: uploads
|
|
1061
1166
|
}}
|
|
1062
1167
|
}})
|
package/docs/COLAB_SPEECH.md
CHANGED
|
@@ -26,7 +26,11 @@ Colab sessions are ephemeral. Re-run the bootstrap after a runtime reset. The wo
|
|
|
26
26
|
|
|
27
27
|
## Live voice
|
|
28
28
|
|
|
29
|
-
Web Chat's **Start live voice** button keeps the microphone open and uses browser-side voice activity detection (VAD). A completed utterance is encoded as PCM WAV, transcribed, and sent to the active coding-agent session automatically. While MOSS TTS is generating or playing a reply, new speech stops it immediately and starts a new utterance (barge-in). Tune end-of-speech silence, minimum speech duration, and the VAD threshold in Speech Settings.
|
|
29
|
+
Web Chat's **Start live voice** button keeps the microphone open and uses browser-side voice activity detection (VAD). While the user is speaking, the browser sends rate-limited snapshots of the growing utterance to the Qwen worker and displays the latest best-effort partial transcript. A completed utterance is encoded as PCM WAV, transcribed once more for the final text, and sent to the active coding-agent session automatically. While MOSS TTS is generating or playing a reply, new speech stops it immediately and starts a new utterance (barge-in). Tune end-of-speech silence, minimum speech duration, and the VAD threshold in Speech Settings.
|
|
30
|
+
|
|
31
|
+
The Colab Qwen endpoint remains a batch HTTP API, so the live caption is progressive re-transcription rather than token-level server streaming. A future WebSocket or streaming HTTP worker can replace this transport without changing the final-turn behavior.
|
|
32
|
+
|
|
33
|
+
Web Chat requests carry an input mode and a structured response contract. The active agent first sends a short acknowledgement and then a final response containing `spoken`, `overview`, and optional `details` fields. The browser renders the fields separately and sends only `spoken` to TTS, avoiding long Markdown, URLs, code, and tables in synthesized speech. Legacy plain `message` replies remain supported.
|
|
30
34
|
|
|
31
35
|
## API surface
|
|
32
36
|
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|