Unstuck
Unstuck
Mic
diff --git a/EF-COACH/live-demo/deploy/docker-compose.vps.yml b/EF-COACH/live-demo/deploy/docker-compose.vps.yml index e3c0fb9..7c842ee 100644 --- a/EF-COACH/live-demo/deploy/docker-compose.vps.yml +++ b/EF-COACH/live-demo/deploy/docker-compose.vps.yml @@ -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" diff --git a/EF-COACH/live-demo/lib/llm-client.mjs b/EF-COACH/live-demo/lib/llm-client.mjs index e57b713..e7936c9 100644 --- a/EF-COACH/live-demo/lib/llm-client.mjs +++ b/EF-COACH/live-demo/lib/llm-client.mjs @@ -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"]); @@ -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`, diff --git a/EF-COACH/live-demo/public/app.js b/EF-COACH/live-demo/public/app.js index bf6e4f3..dea57cf 100644 --- a/EF-COACH/live-demo/public/app.js +++ b/EF-COACH/live-demo/public/app.js @@ -22,6 +22,8 @@ const thread = []; const heldItems = []; let energyLevel = null; let currentRecognition = null; +let voiceBaseDraft = ""; +let lastVoiceTranscript = ""; let loadingTimers = []; let lastTrackedDraftBucket = null; @@ -448,20 +450,46 @@ 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; } @@ -469,33 +497,39 @@ function startVoiceInput() { 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; + 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."; } @@ -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, @@ -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."); } } diff --git a/EF-COACH/live-demo/scripts/build-hostinger-compose.mjs b/EF-COACH/live-demo/scripts/build-hostinger-compose.mjs index c613e8d..ca25dbb 100644 --- a/EF-COACH/live-demo/scripts/build-hostinger-compose.mjs +++ b/EF-COACH/live-demo/scripts/build-hostinger-compose.mjs @@ -8,7 +8,7 @@ const pathPrefix = process.env.UNSTUCK_LIVE_PATH_PREFIX || "/unstuck"; const liveProvider = process.env.UNSTUCK_LIVE_PROVIDER || "vps-local"; const isZaiCodingPlan = liveProvider === "zai-coding-plan"; const isHomeInference = liveProvider === "home-openai" || liveProvider === "nucbox-openai"; -const model = process.env.UNSTUCK_LIVE_MODEL || (isZaiCodingPlan ? "glm-5.1" : "Qwen3.5-0.8B-Q4_K_M"); +const model = process.env.UNSTUCK_LIVE_MODEL || (isZaiCodingPlan ? "glm-4.5-air" : "Qwen3.5-0.8B-Q4_K_M"); const baseUrl = isZaiCodingPlan ? "https://api.z.ai/api/coding/paas/v4" : process.env.UNSTUCK_LIVE_BASE_URL || "http://llama-local:8085/v1"; @@ -18,7 +18,7 @@ const contextBase = process.env.UNSTUCK_CONTEXT_BASE || "https://unstuck.kyanite const outputPath = process.argv[2]; const routeRule = `Host(\`${host}\`) || (Host(\`${fallbackHost}\`) && PathPrefix(\`${pathPrefix}\`))`; -const liveHtml = String.raw`
Unstuck
Mic
Unstuck
Mic