Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion EF-COACH/live-demo/deploy/docker-compose.vps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ services:
PORT: "3000"
LLM_PROVIDER: zai-coding-plan
ZAI_API_KEY: ${ZAI_API_KEY:?ZAI_API_KEY required}
OPENAI_MODEL: glm-5.1
OPENAI_MODEL: glm-4.5-air
COACH_RATE_LIMIT_MAX: "12"
COACH_RATE_LIMIT_WINDOW_MS: "600000"
COACH_MAX_CONCURRENT_MODEL_CALLS: "2"
Expand Down
6 changes: 3 additions & 3 deletions EF-COACH/live-demo/lib/llm-client.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const DEFAULT_RESPONSES_MODEL = "gpt-5.5";
const DEFAULT_COMPATIBLE_MODEL = "Qwen3.5-0.8B-Q4_K_M";
const ZAI_CODING_PLAN_BASE_URL = "https://api.z.ai/api/coding/paas/v4";
const ZAI_GLM_51_MODEL = "glm-5.1";
const ZAI_GLM_AIR_MODEL = "glm-4.5-air";
const DEFAULT_LLM_TIMEOUT_MS = 25_000;
const GLM_THINKING_MAX_TOKENS = 1200;
const HOME_OPENAI_PROVIDERS = new Set(["home-openai", "nucbox-openai"]);
Expand All @@ -26,13 +26,13 @@ export function resolveLlmConfig(env = process.env) {

if (provider === "zai-coding-plan") {
const apiKey = env.ZAI_API_KEY || env.OPENAI_API_KEY || env.LLM_API_KEY;
const model = env.OPENAI_MODEL || env.LLM_MODEL || ZAI_GLM_51_MODEL;
const model = env.OPENAI_MODEL || env.LLM_MODEL || ZAI_GLM_AIR_MODEL;
if (!apiKey) {
throw new Error("ZAI_API_KEY is required for Z.AI GLM Coding Plan mode.");
}
return {
provider,
providerLabel: "Z.AI GLM-5.1 (medium reasoning)",
providerLabel: "Z.AI GLM-4.5-Air",
model,
apiKey,
chatCompletionsUrl: `${ZAI_CODING_PLAN_BASE_URL}/chat/completions`,
Expand Down
79 changes: 57 additions & 22 deletions EF-COACH/live-demo/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ const thread = [];
const heldItems = [];
let energyLevel = null;
let currentRecognition = null;
let voiceBaseDraft = "";
let lastVoiceTranscript = "";
let loadingTimers = [];
let lastTrackedDraftBucket = null;

Expand Down Expand Up @@ -448,54 +450,86 @@ function getSpeechRecognition() {

function getVoiceErrorMessage(error) {
if (error === "not-allowed" || error === "service-not-allowed") {
return "Microphone permission was blocked. You can still type or use a chip.";
return "Microphone permission was blocked. I focused the box for keyboard dictation or fragments.";
}
if (error === "no-speech") {
return "No speech caught. Try Mic again and say the messy version.";
return "No speech caught. Try Mic again, or use keyboard dictation in the box.";
}
if (error === "audio-capture") {
return "No microphone was found. The chips still work.";
return "No microphone was found. I focused the box for keyboard dictation or fragments.";
}
return "Voice typing did not start. The chips still work.";
return "Voice typing did not start. I focused the box for keyboard dictation or fragments.";
}

function focusManualDictationFallback(status, starterText = "") {
if (starterText && !message.value.trim()) {
insertText(starterText);
} else {
message.focus();
message.setSelectionRange?.(message.value.length, message.value.length);
}
setVoiceState(status, false);
}

function extractRecognitionTranscript(event) {
return Array.from(event.results || [])
.map((result) => result[0]?.transcript || "")
.join(" ")
.replace(/\s+/g, " ")
.trim();
}

function applyVoiceTranscript(transcript) {
lastVoiceTranscript = transcript;
message.value = [voiceBaseDraft, transcript].filter(Boolean).join(" ").trim();
resizeComposer();
updateDraftStatus();
saveDraft();
}

function startVoiceInput() {
if (currentRecognition) {
stopVoiceInput();
stopVoiceInput("Stopped listening. Captured text stays in the box.");
return;
}

const SpeechRecognition = getSpeechRecognition();

if (!SpeechRecognition) {
trackChat("voice failed", { reason: "unsupported" });
setVoiceState("Voice typing is not available here. I put a no-typing prompt in the box.");
insertText("I'm too overloaded to type. Help me start.");
focusManualDictationFallback(
"Voice typing is not available in this browser. I focused the box for keyboard dictation.",
"I'm too overloaded to type. Help me start.",
);
return;
}

const recognition = new SpeechRecognition();
let recognition;
try {
recognition = new SpeechRecognition();
} catch {
trackChat("voice failed", { reason: "constructor" });
focusManualDictationFallback("Voice typing did not initialize. I focused the box for keyboard dictation.");
return;
}
let endedWithError = false;
recognition.lang = "en-US";
voiceBaseDraft = message.value.trim();
lastVoiceTranscript = "";
recognition.lang = navigator.language || "en-US";
recognition.interimResults = true;
recognition.continuous = false;
recognition.continuous = true;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop continuous recognition when submitting voice text

With continuous = true, the recognizer can keep emitting result events after the user has already pressed Send while the Mic is still active. The submit handler clears message.value but never stops currentRecognition, so a later interim/final result can call applyVoiceTranscript() and put the already-submitted transcript back into the composer (or append more dictated text to the old voiceBaseDraft), making it easy to resend duplicate/stale input. This only affects the voice flow when the user sends before manually tapping Mic again to stop.

Useful? React with 👍 / 👎.

recognition.maxAlternatives = 1;
currentRecognition = recognition;

recognition.addEventListener("start", () => {
trackChat("voice started");
setVoiceState("Listening. Say the messy version.", true);
setVoiceState("Listening. Say the messy version. Tap Mic again to stop.", true);
});

recognition.addEventListener("result", (event) => {
const transcript = Array.from(event.results)
.map((result) => result[0]?.transcript || "")
.join(" ")
.trim();
const transcript = extractRecognitionTranscript(event);
if (transcript) {
message.value = transcript;
resizeComposer();
updateDraftStatus();
saveDraft();
applyVoiceTranscript(transcript);
trackChat("voice transcript received", { input_length_bucket: getLengthBucket(transcript) });
voiceStatus.textContent = "Captured speech. Send when ready, or keep talking.";
}
Expand All @@ -510,7 +544,7 @@ function startVoiceInput() {
setVoiceState(
endedWithError
? voiceStatus.textContent
: message.value.trim()
: lastVoiceTranscript || message.value.trim()
? "Captured. Press Enter or Send."
: "No speech captured. Try Mic again or use a chip.",
false,
Expand All @@ -521,14 +555,15 @@ function startVoiceInput() {
recognition.addEventListener("error", (event) => {
endedWithError = true;
trackChat("voice failed", { reason: event.error || "unknown" });
setVoiceState(getVoiceErrorMessage(event.error), false);
focusManualDictationFallback(getVoiceErrorMessage(event.error));
});

try {
recognition.start();
} catch {
currentRecognition = null;
setVoiceState("Voice typing did not start. Try a chip or type fragments.", false);
trackChat("voice failed", { reason: "start" });
focusManualDictationFallback("Voice typing did not start. I focused the box for keyboard dictation.");
}
}

Expand Down
Loading