diff --git a/scripts/patch-nginx-llm-proxy.sh b/scripts/patch-nginx-llm-proxy.sh new file mode 100755 index 0000000..d39461d --- /dev/null +++ b/scripts/patch-nginx-llm-proxy.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +NGINX_CONF="${NGINX_CONF:-/etc/nginx/nginx.conf}" +LLM_PROXY_PATH="${LLM_PROXY_PATH:-/llm/}" +LLM_UPSTREAM="${LLM_UPSTREAM:-http://127.0.0.1:11435/}" + +if [[ ! -f "$NGINX_CONF" ]]; then + echo "nginx config not found: $NGINX_CONF" >&2 + exit 1 +fi + +if [[ $EUID -ne 0 ]]; then + echo "run as root: sudo $0" >&2 + exit 1 +fi + +backup_path="${NGINX_CONF}.webterm-llm-$(date +%Y%m%d-%H%M%S).bak" +cp "$NGINX_CONF" "$backup_path" +echo "backup created: $backup_path" + +python3 - "$NGINX_CONF" "$LLM_PROXY_PATH" "$LLM_UPSTREAM" <<'PY' +from pathlib import Path +import sys + +config_path = Path(sys.argv[1]) +location_path = sys.argv[2] +upstream = sys.argv[3] +text = config_path.read_text() + +if f"location {location_path}" in text: + print(f"proxy location already present: {location_path}") + raise SystemExit(0) + +target = """ location / {\n if ($valid_origin = "0") { return 403; }\n proxy_pass http://127.0.0.1:8080;\n proxy_http_version 1.1;\n proxy_set_header Upgrade $http_upgrade;\n proxy_set_header Connection "upgrade";\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n }\n""" + +replacement = f""" location {location_path} {{\n if ($valid_origin = "0") {{ return 403; }}\n proxy_pass {upstream};\n proxy_http_version 1.1;\n proxy_connect_timeout 30s;\n proxy_send_timeout 300s;\n proxy_read_timeout 300s;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n }}\n\n{target}""" + +if target not in text: + print("target webterm location block not found in nginx.conf", file=sys.stderr) + raise SystemExit(1) + +config_path.write_text(text.replace(target, replacement, 1)) +print(f"inserted proxy location {location_path} -> {upstream}") +PY + +nginx -t +echo "nginx config valid" +echo "reload when ready: sudo systemctl reload nginx" diff --git a/webterm/constants.go b/webterm/constants.go index 74a460e..662db9c 100644 --- a/webterm/constants.go +++ b/webterm/constants.go @@ -13,9 +13,12 @@ const ( DefaultFontSize = 16 DefaultTerminalWidth = 132 DefaultTerminalHeight = 45 + DefaultVoiceLLMBaseURL = "/llm" ScreenshotForceRedrawEnv = "WEBTERM_SCREENSHOT_FORCE_REDRAW" ScreenshotModeEnv = "WEBTERM_SCREENSHOT_MODE" + VoiceLLMBaseURLEnv = "WEBTERM_VOICE_LLM_BASE_URL" + VoiceLLMModelEnv = "WEBTERM_VOICE_LLM_MODEL" AuthUsernameEnv = "WEBTERM_AUTH_USERNAME" AuthPasswordEnv = "WEBTERM_AUTH_PASSWORD" AuthCookieSecretEnv = "WEBTERM_AUTH_COOKIE_SECRET" diff --git a/webterm/server.go b/webterm/server.go index 2576764..f0bfeee 100644 --- a/webterm/server.go +++ b/webterm/server.go @@ -1888,7 +1888,19 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) { fontFamily = "var(--webterm-mono)" } escapedFont := strings.ReplaceAll(fontFamily, `"`, """) - dataAttrs := fmt.Sprintf(`data-session-websocket-url="%s" data-session-route-key="%s" data-session-name="%s" data-font-size="%d" data-scrollback="1000" data-theme="%s" data-font-family="%s"`, htmlAttrEscape(wsURL), htmlAttrEscape(routeKey), htmlAttrEscape(app.Name), s.fontSize, htmlAttrEscape(theme), escapedFont) + voiceLLMBaseURL := strings.TrimSpace(os.Getenv(VoiceLLMBaseURLEnv)) + voiceLLMModel := strings.TrimSpace(os.Getenv(VoiceLLMModelEnv)) + dataAttrs := fmt.Sprintf( + `data-session-websocket-url="%s" data-session-route-key="%s" data-session-name="%s" data-font-size="%d" data-scrollback="1000" data-theme="%s" data-font-family="%s" data-voice-llm-base-url="%s" data-voice-llm-model="%s"`, + htmlAttrEscape(wsURL), + htmlAttrEscape(routeKey), + htmlAttrEscape(app.Name), + s.fontSize, + htmlAttrEscape(theme), + escapedFont, + htmlAttrEscape(voiceLLMBaseURL), + htmlAttrEscape(voiceLLMModel), + ) cacheBust := "?v=" + s.staticAssetCacheBust page := fmt.Sprintf(`
0?"down":"up",G=Math.min(Math.abs(Math.round(J.deltaY/33)),5);for(let U=0;U 0?"down":"up",G=Math.min(Math.abs(Math.round(J.deltaY/33)),5);for(let V=0;V0)if(W
=0?U=U.substring(0,G):U="",K+=U,W
=0;z--)if(K[z]&&K[z].codepoint!==0&&K[z].codepoint!==32){W=z;break}}if(W>=0){this.selectionStart={col:0,absoluteRow:X},this.selectionEnd={col:W,absoluteRow:X},this.requestRender();let z=this.getSelection();z&&(this.copyToClipboard(z),this.selectionChangedEmitter.fire())}}}),this.boundContextMenuHandler=(J)=>{if(this.terminal.hasMouseTracking()){J.preventDefault();return}if(this.renderer.getCanvas().getBoundingClientRect(),this.textarea.style.position="fixed",this.textarea.style.left=`${J.clientX}px`,this.textarea.style.top=`${J.clientY}px`,this.textarea.style.width="1px",this.textarea.style.height="1px",this.textarea.style.zIndex="1000",this.textarea.style.opacity="0",this.textarea.style.pointerEvents="auto",this.hasSelection()){let Z=this.getSelection();this.textarea.value=Z,this.textarea.select(),this.textarea.setSelectionRange(0,Z.length)}else this.textarea.value="";this.textarea.focus(),setTimeout(()=>{let Z=()=>{this.textarea.style.pointerEvents="none",this.textarea.style.zIndex="-10",this.textarea.style.width="0",this.textarea.style.height="0",this.textarea.style.left="0",this.textarea.style.top="0",this.textarea.value="",document.removeEventListener("click",Z),document.removeEventListener("contextmenu",Z),this.textarea.removeEventListener("blur",Z)};document.addEventListener("click",Z,{once:!0}),document.addEventListener("contextmenu",Z,{once:!0}),this.textarea.addEventListener("blur",Z,{once:!0})},10)},j.addEventListener("contextmenu",this.boundContextMenuHandler),this.boundClickHandler=(J)=>{if(this.isSelecting||this.mouseDownTarget&&j.contains(this.mouseDownTarget))return;let Z=J.target;j.contains(Z)||this.hasSelection()&&this.clearSelection()},document.addEventListener("click",this.boundClickHandler)}markCurrentSelectionDirty(){let j=this.normalizeSelection();if(j)for(let J=j.startRow;J<=j.endRow;J++)this.dirtySelectionRows.add(J)}updateAutoScroll(j,J){let Z=$.AUTO_SCROLL_EDGE_SIZE;j
1)U=j.h*0.24;if(j.label.length>5)U=j.h*0.2;U=Math.max(U,14/X),$.fillStyle=G,$.textAlign="center",$.textBaseline="middle",$.font=`500 ${U}px Inter, system-ui, sans-serif`,$.fillText(j.label,j.x+j.w/2,j.y+j.h/2+H)}drawMobileVirtualKeyboard(){let $=this.mobileVirtualKeyboard;if(!$||!this.mobileKeyboardVisible)return;let j=$.getContext("2d",{alpha:!1});if(!j)return;let J=$.getBoundingClientRect();if(!J.width||!J.height)return;let Z=window.devicePixelRatio||1,X=Math.round(J.width*Z),q=Math.round(J.height*Z);if($.width!==X||$.height!==q)$.width=X,$.height=q;j.setTransform(Z,0,0,Z,0,0),j.fillStyle="#0f172a",j.fillRect(0,0,J.width,J.height),this.mobileVirtualKeyboardBounds=this.calculateVirtualKeyboardLayout();let K=this.getVirtualKeyboardScale(J.width);this.mobileVirtualKeyboardBounds.forEach((W)=>{let z=!1;for(let G of this.mobileVirtualKeyboardActivePresses.values()){let V=W.rowIndex*100+W.colIndex;if(G.keyIndex===V){z=!0;break}}this.drawVirtualKeyboardKey(j,W,z,this.isVirtualKeyboardModifierActive(W),K)})}requestMobileVirtualKeyboardDraw(){if(this.mobileVirtualKeyboardDrawFrame!==null)return;this.mobileVirtualKeyboardDrawFrame=window.requestAnimationFrame(()=>{this.mobileVirtualKeyboardDrawFrame=null,this.drawMobileVirtualKeyboard()})}clearVirtualKeyboardRepeat($){if($?.repeatTimeout)window.clearTimeout($.repeatTimeout),$.repeatTimeout=void 0}findVirtualKeyboardKey($,j){let J=this.getVirtualKeyboardRect();if(!J)return null;let Z=$-J.left,X=j-J.top;return this.mobileVirtualKeyboardBounds.find((q)=>Z>=q.x&&Z<=q.x+q.w&&X>=q.y&&X<=q.y+q.h)??null}startVirtualKeyboardRepeat($){let j=$.key?.value??"";if(!($.key?.key.kind==="char"||$.key?.key.kind==="space"||j==="Backspace"||j==="Delete"||j==="ArrowLeft"||j==="ArrowRight"))return;let Z=()=>{let X=this.mobileVirtualKeyboardActivePresses.get($.pointerId);if(!X?.key)return;this.dispatchVirtualKeyboardKey(X.key),X.repeatTimeout=window.setTimeout(Z,60),this.mobileVirtualKeyboardActivePresses.set($.pointerId,X)};$.repeatTimeout=window.setTimeout(Z,500),this.mobileVirtualKeyboardActivePresses.set($.pointerId,$)}async dispatchVirtualKeyboardKey($){let j=$.key.actionId;if(j==="mode-alpha"){this.mobileKeyboardMode="alpha",this.syncVirtualKeyboardState();return}if(j==="mode-symbol"){this.mobileKeyboardMode=this.mobileKeyboardMode==="symbol"?"alpha":"symbol",this.syncVirtualKeyboardState();return}if(j==="toggle-voice"){await this.toggleVoiceInput();return}if($.key.modifier){this.toggleModifierState($.key.modifier);return}if($.key.seq){this.sendMobileSequence($.key.seq);return}this.sendMobileText($.value)}handleVirtualKeyboardPointerDown=($)=>{if($.preventDefault(),$.stopPropagation(),!this.mobileVirtualKeyboard)return;if(this.mobileVirtualKeyboard.setPointerCapture($.pointerId),!this.mobileVirtualKeyboardBounds.length)this.mobileVirtualKeyboardBounds=this.calculateVirtualKeyboardLayout();this.mobileVirtualKeyboardActivePresses.forEach((J)=>{this.clearVirtualKeyboardRepeat(J)});let j=this.findVirtualKeyboardKey($.clientX,$.clientY);if(j){let J={pointerId:$.pointerId,keyIndex:j.rowIndex*100+j.colIndex,key:j};this.dispatchVirtualKeyboardKey(j),this.mobileVirtualKeyboardActivePresses.set($.pointerId,J),this.startVirtualKeyboardRepeat(J)}else this.mobileVirtualKeyboardActivePresses.set($.pointerId,{pointerId:$.pointerId,keyIndex:-1,key:null});this.requestMobileVirtualKeyboardDraw()};handleVirtualKeyboardPointerMove=($)=>{$.preventDefault();let j=this.mobileVirtualKeyboardActivePresses.get($.pointerId);if(!j)return;let J=this.findVirtualKeyboardKey($.clientX,$.clientY);if(J){let Z=J.rowIndex*100+J.colIndex;if(Z!==j.keyIndex){this.clearVirtualKeyboardRepeat(j);let X={pointerId:$.pointerId,keyIndex:Z,key:J};this.dispatchVirtualKeyboardKey(J),this.mobileVirtualKeyboardActivePresses.set($.pointerId,X),this.startVirtualKeyboardRepeat(X)}}else if(j.keyIndex!==-1)this.clearVirtualKeyboardRepeat(j),this.mobileVirtualKeyboardActivePresses.set($.pointerId,{pointerId:$.pointerId,keyIndex:-1,key:null});this.requestMobileVirtualKeyboardDraw()};handleVirtualKeyboardPointerUp=($)=>{$.preventDefault();let j=this.mobileVirtualKeyboardActivePresses.get($.pointerId);if(j)this.clearVirtualKeyboardRepeat(j),this.mobileVirtualKeyboardActivePresses.delete($.pointerId);if(this.mobileVirtualKeyboard?.hasPointerCapture($.pointerId))this.mobileVirtualKeyboard.releasePointerCapture($.pointerId);this.requestMobileVirtualKeyboardDraw()};setupVirtualKeyboard(){if(this.mobileVirtualKeyboard||this.mobileVirtualKeyboardHost)return;let $=document.createElement("div");$.className="mobile-virtual-keyboard",$.style.cssText=`
position: absolute;
left: 0;
right: 0;
@@ -74,6 +80,8 @@ For tests, pass a Ghostty instance directly:
+
+
`,$.appendChild(j),$.appendChild(J);let Z=document.createElement("style");Z.textContent=`
.mobile-keybar {
position: absolute;
@@ -141,7 +149,7 @@ For tests, pass a Ghostty instance directly:
.keybar-label-grow {
flex: 1 1 auto;
}
- `,document.head.appendChild(Z),this.mobileKeybarStyle=Z,this.element.appendChild($),this.mobileKeybar=$,this.updateMobileKeyboardDockLayout(),this.fit(),this.setMobileKeyboardVisible(!1);let X=(V)=>{j.style.display=V==="keys"?"":"none",J.style.display=V==="settings"?"":"none",this.updateMobileKeyboardDockLayout(),this.fit()};j.querySelector(".keybar-settings").addEventListener("touchstart",(V)=>{V.preventDefault(),X("settings")}),j.querySelector(".keybar-hide").addEventListener("touchstart",(V)=>{V.preventDefault(),this.setMobileKeyboardVisible(!1)}),j.querySelector(".keybar-hide").addEventListener("click",(V)=>{V.preventDefault(),this.setMobileKeyboardVisible(!1)}),J.querySelector(".keybar-back").addEventListener("touchstart",(V)=>{V.preventDefault(),X("keys")});let z=(V)=>{this.keybarButtonHeight=Math.max(28,Math.min(72,this.keybarButtonHeight+V)),$.querySelectorAll("button").forEach((K)=>{K.style.height=`${this.keybarButtonHeight}px`}),this.updateMobileKeyboardDockLayout(),this.requestMobileVirtualKeyboardDraw(),this.fit()};J.querySelector(".keybar-bar-shrink").addEventListener("touchstart",(V)=>{V.preventDefault(),z(-4)}),J.querySelector(".keybar-bar-grow").addEventListener("touchstart",(V)=>{V.preventDefault(),z(4)}),J.querySelector(".keybar-font-shrink").addEventListener("touchstart",(V)=>{V.preventDefault(),this.fontSize=Math.max(8,this.fontSize-1),this.terminal.options.fontSize=this.fontSize,this.fit()}),J.querySelector(".keybar-font-grow").addEventListener("touchstart",(V)=>{V.preventDefault(),this.fontSize=Math.max(8,this.fontSize+1),this.terminal.options.fontSize=this.fontSize,this.fit()}),j.querySelectorAll("button[data-key]").forEach((V)=>{V.addEventListener("touchstart",(K)=>{K.preventDefault();let q=V.dataset.key||"";if(q=q.replace(/\\x([0-9a-fA-F]{2})/g,(G,U)=>String.fromCharCode(parseInt(U,16))),q=q.replace(/\\x1b/g,"\x1B"),q.length===1)this.sendMobileText(q);else this.sendMobileSequence(q)})}),j.querySelectorAll("button[data-modifier]").forEach((V)=>{V.addEventListener("touchstart",(K)=>{K.preventDefault();let q=V.dataset.modifier;if(q==="ctrl")this.toggleModifierState("ctrl");else if(q==="alt")this.toggleModifierState("alt");else if(q==="shift")this.toggleModifierState("shift");else if(q==="fn")this.toggleModifierState("fn");this.focusMobileInput()})})}deactivateModifiers(){this.ctrlActive=!1,this.altActive=!1,this.shiftActive=!1,this.fnActive=!1,this.pendingCtrl=!1,this.pendingAlt=!1,this.pendingShift=!1,this.pendingFn=!1,this.syncModifierButtons(),this.syncVirtualKeyboardState()}focusMobileInput(){if(this.usesVirtualKeyboard())return;this.mobileInput?.focus()}async waitForFonts(){if(!("fonts"in document))return;try{await document.fonts.ready}catch{}}fit(){let j=this.terminal.renderer,J,Z;if(j?.getMetrics){let N=j.getMetrics();if(N&&N.width>0&&N.height>0)J=N.width,Z=N.height;else{let R=this.measureCellSize();if(!R){this.fitAddon.fit();return}J=R.width,Z=R.height}}else{let N=this.measureCellSize();if(!N){this.fitAddon.fit();return}J=N.width,Z=N.height}let X=window.getComputedStyle(this.element),z=parseInt(X.paddingTop)||0,V=parseInt(X.paddingBottom)||0,K=parseInt(X.paddingLeft)||0,q=parseInt(X.paddingRight)||0,G=this.element.clientWidth-K-q,U=this.element.clientHeight-z-V;if(G<=0||U<=0)return;let Y=Math.max(2,Math.floor(G/J)),H=Math.max(1,Math.floor(U/Z));if(Y!==this.terminal.cols||H!==this.terminal.rows)this.terminal.resize(Y,H)}measureCellSize(){let $=document.createElement("span");$.style.visibility="hidden",$.style.position="absolute",$.style.fontFamily=this.fontFamily,$.style.fontSize=`${this.fontSize}px`,$.style.lineHeight="normal",$.textContent="W",document.body.appendChild($);let{offsetWidth:j,offsetHeight:J}=$;if(document.body.removeChild($),j>0&&J>0)return{width:j,height:J};return null}setupResizeObserver(){this.resizeObserver=new ResizeObserver(()=>{if(this.resizeDebounceTimer)clearTimeout(this.resizeDebounceTimer);this.resizeDebounceTimer=window.setTimeout(()=>{this.fit()},100)}),this.resizeObserver.observe(this.element)}resizeDebounceTimer;isValidSize($,j){return $>=2&&$<=500&&j>=1&&j<=500}connect(){if(this.socket?.readyState===WebSocket.OPEN)return;let $=++this.socketGeneration;this.socket=new WebSocket(this.wsUrl),this.socket.binaryType="arraybuffer",this.socket.addEventListener("open",()=>{if($!==this.socketGeneration)return;if(this.reconnectAttempts=0,this.clearUserError(),!this.isTabHidden)this.startHeartbeatWatchdog();this.element.classList.add("-connected"),this.element.classList.remove("-disconnected"),this.flushStdin(),this.processMessageQueue();let j=this.terminal.cols,J=this.terminal.rows;if(this.isValidSize(j,J))this.lastValidSize={cols:j,rows:J},this.send(["resize",{width:j,height:J}]);if(!this.usesVirtualKeyboard())this.terminal.focus()}),this.socket.addEventListener("close",(j)=>{if($!==this.socketGeneration)return;if(this.stopHeartbeatWatchdog(),this.element.classList.remove("-connected"),this.element.classList.add("-disconnected"),this.reconnectAttempts>=this.maxReconnectAttempts)this.showUserError("Disconnected. Max reconnection attempts reached.");else if(!j.wasClean)this.showUserError("Connection lost. Reconnecting...");this.scheduleReconnect()}),this.socket.addEventListener("error",()=>{this.showUserError("WebSocket error. Reconnecting...")}),this.socket.addEventListener("message",(j)=>{if($!==this.socketGeneration)return;this.handleMessage(j.data)})}handleTextMessage($){try{let j=JSON.parse($),[J,Z]=j;switch(J){case"stdout":if(this.isTabHidden)this.bufferWhileHidden(Z);else this.terminal.write(Z);break;case"pong":this.lastPongAt=Date.now();break;default:console.debug("Unknown message type:",J)}}catch{if(this.isTabHidden)this.bufferWhileHidden($);else this.terminal.write($)}}handleMessage($){if(this.lastMessageAt=Date.now(),$ instanceof ArrayBuffer){let j=new Uint8Array($);if(this.isTabHidden)this.bufferWhileHidden(j);else this.terminal.write(j);return}if($ instanceof Blob){$.text().then((j)=>{this.lastMessageAt=Date.now(),this.handleTextMessage(j)}).catch(()=>{});return}this.handleTextMessage($)}startHeartbeatWatchdog(){this.stopHeartbeatWatchdog();let $=Date.now();this.lastMessageAt=$,this.lastPongAt=$,this.heartbeatTimer=window.setInterval(()=>{if(this.socket?.readyState!==WebSocket.OPEN)return;let j=Date.now(),J=Math.max(this.lastMessageAt,this.lastPongAt);if(j-J>this.stallTimeoutMs){console.warn("WebSocket inbound stream stalled; reconnecting"),this.socket.close();return}this.send(["ping",String(j)])},this.heartbeatIntervalMs)}stopHeartbeatWatchdog(){if(this.heartbeatTimer)clearInterval(this.heartbeatTimer),this.heartbeatTimer=void 0}startResourceCleanup(){this.cleanupTimer=window.setInterval(()=>{this.trimMessageQueue()},b0)}stopResourceCleanup(){if(this.cleanupTimer)clearInterval(this.cleanupTimer),this.cleanupTimer=void 0}trimMessageQueue(){if(this.messageQueue.length>I){let $=this.messageQueue.length-I;this.messageQueue=this.messageQueue.slice(-I),console.warn(`[webterm] Trimmed ${$} stale messages from queue`)}}bufferWhileHidden($){let j=typeof $==="string"?l.sharedTextEncoder.encode($):$;while(this.hiddenBufferBytes+j.byteLength>l0&&this.hiddenBuffer.length>0){let J=this.hiddenBuffer.shift();this.hiddenBufferBytes-=J.byteLength}this.hiddenBuffer.push(j),this.hiddenBufferBytes+=j.byteLength}refreshConnection(){if(this.hiddenBuffer.length=0,this.hiddenBufferBytes=0,this.reconnectAttempts=0,this.stopHeartbeatWatchdog(),this.socket)this.socket.close(),this.socket=null;this.connect()}sendStdin($){if(!$)return;if(this.pendingStdin+=$,this.pendingStdin.length>=B0){this.flushStdin();return}if(this.pendingStdinTimer)return;this.pendingStdinTimer=window.setTimeout(()=>{this.pendingStdinTimer=void 0,this.flushStdin()},E0)}flushStdin(){if(this.pendingStdinTimer)clearTimeout(this.pendingStdinTimer),this.pendingStdinTimer=void 0;if(!this.pendingStdin)return;let $=this.pendingStdin;this.pendingStdin="",this.send(["stdin",$])}send($){if($[0]!=="stdin"&&this.pendingStdin)this.flushStdin();if(this.messageQueue.length>=I)this.messageQueue=this.messageQueue.slice(-Math.floor(I/2)),console.warn("[webterm] Message queue overflow; trimmed old messages");this.messageQueue.push($),this.processMessageQueue()}processMessageQueue(){if(this.socket?.readyState!==WebSocket.OPEN)return;while(this.messageQueue.length>0){let $=this.messageQueue.shift();try{if($)this.socket.send(JSON.stringify($))}catch(j){if(console.error("Failed to send message:",j,$),$)this.messageQueue.unshift($);break}}}scheduleReconnect(){if(this.reconnectAttempts>=this.maxReconnectAttempts){console.error("Max reconnection attempts reached"),this.showUserError("Disconnected. Max reconnection attempts reached.");return}this.reconnectAttempts++;let $=this.reconnectDelay*Math.pow(2,this.reconnectAttempts-1);setTimeout(()=>{console.log(`[webterm] Reconnecting (attempt ${this.reconnectAttempts})...`),this.connect()},$)}dispose(){if(this.stopVoiceInput(),this.stopResourceCleanup(),this.stopHeartbeatWatchdog(),this.pendingStdinTimer)clearTimeout(this.pendingStdinTimer),this.pendingStdinTimer=void 0;if(this.mobileVirtualKeyboardDrawFrame!==null)cancelAnimationFrame(this.mobileVirtualKeyboardDrawFrame),this.mobileVirtualKeyboardDrawFrame=null;if(this.mobileVirtualKeyboardActivePresses.forEach(($)=>{this.clearVirtualKeyboardRepeat($)}),this.mobileVirtualKeyboardActivePresses.clear(),this.pendingStdin="",this.resizeDebounceTimer)clearTimeout(this.resizeDebounceTimer),this.resizeDebounceTimer=void 0;this.socket?.close(),this.socket=null,this.messageQueue.length=0,this.hiddenBuffer.length=0,this.hiddenBufferBytes=0;for(let{target:$,type:j,handler:J,options:Z}of this.boundHandlers)$.removeEventListener(j,J,Z);if(this.boundHandlers.length=0,this.resizeObserver)this.resizeObserver.disconnect(),this.resizeObserver=null;if(this.mobileInput)this.mobileInput.remove(),this.mobileInput=null;if(this.mobileKeybar)this.mobileKeybar.remove(),this.mobileKeybar=null;if(this.mobileVirtualKeyboard)this.mobileVirtualKeyboard.remove(),this.mobileVirtualKeyboard=null;if(this.mobileVirtualKeyboardHost)this.mobileVirtualKeyboardHost.remove(),this.mobileVirtualKeyboardHost=null;if(this.mobileVirtualKeyboardBounds=[],this.mobileKeybarStyle)this.mobileKeybarStyle.remove(),this.mobileKeybarStyle=null;if(this.errorOverlay)this.errorOverlay.remove(),this.errorOverlay=null;if(this.voiceControls)this.voiceControls.remove(),this.voiceControls=null;this.voiceStatus=null,this.destroyVoiceEngine(),this.fitAddon.dispose(),this.terminal.dispose()}setTheme($){this.terminal.options.theme=$}static getTheme($){return y[$.toLowerCase()]}}var j0=new Map;function P0($,j){if(window.getComputedStyle($).position==="static")$.style.position="relative";let J=$.querySelector(".webterm-startup-error");if(!J)J=document.createElement("div"),J.className="webterm-startup-error",J.style.cssText=`
+ `,document.head.appendChild(Z),this.mobileKeybarStyle=Z,this.element.appendChild($),this.mobileKeybar=$,this.updateMobileKeyboardDockLayout(),this.fit(),this.setMobileKeyboardVisible(!1);let X=(z)=>{j.style.display=z==="keys"?"":"none",J.style.display=z==="settings"?"":"none",this.updateMobileKeyboardDockLayout(),this.fit()};j.querySelector(".keybar-settings").addEventListener("touchstart",(z)=>{z.preventDefault(),X("settings")}),j.querySelector(".keybar-hide").addEventListener("touchstart",(z)=>{z.preventDefault(),this.setMobileKeyboardVisible(!1)}),j.querySelector(".keybar-hide").addEventListener("click",(z)=>{z.preventDefault(),this.setMobileKeyboardVisible(!1)}),J.querySelector(".keybar-back").addEventListener("touchstart",(z)=>{z.preventDefault(),X("keys")});let q=(z)=>{this.keybarButtonHeight=Math.max(28,Math.min(72,this.keybarButtonHeight+z)),$.querySelectorAll("button").forEach((G)=>{G.style.height=`${this.keybarButtonHeight}px`}),this.updateMobileKeyboardDockLayout(),this.requestMobileVirtualKeyboardDraw(),this.fit()};J.querySelector(".keybar-bar-shrink").addEventListener("touchstart",(z)=>{z.preventDefault(),q(-4)}),J.querySelector(".keybar-bar-grow").addEventListener("touchstart",(z)=>{z.preventDefault(),q(4)}),J.querySelector(".keybar-font-shrink").addEventListener("touchstart",(z)=>{z.preventDefault(),this.fontSize=Math.max(8,this.fontSize-1),this.terminal.options.fontSize=this.fontSize,this.fit()}),J.querySelector(".keybar-font-grow").addEventListener("touchstart",(z)=>{z.preventDefault(),this.fontSize=Math.max(8,this.fontSize+1),this.terminal.options.fontSize=this.fontSize,this.fit()});let K=J.querySelector(".keybar-voice-mode"),W=()=>{if(!K)return;K.textContent=this.voiceMode==="cleanup"?"Clean":"Live",K.classList.toggle("active",this.voiceMode==="cleanup")};W(),K?.addEventListener("touchstart",(z)=>{z.preventDefault(),this.setVoiceMode(this.voiceMode==="cleanup"?"live":"cleanup"),W()}),j.querySelectorAll("button[data-key]").forEach((z)=>{z.addEventListener("touchstart",(G)=>{G.preventDefault();let V=z.dataset.key||"";if(V=V.replace(/\\x([0-9a-fA-F]{2})/g,(H,U)=>String.fromCharCode(parseInt(U,16))),V=V.replace(/\\x1b/g,"\x1B"),V.length===1)this.sendMobileText(V);else this.sendMobileSequence(V)})}),j.querySelectorAll("button[data-modifier]").forEach((z)=>{z.addEventListener("touchstart",(G)=>{G.preventDefault();let V=z.dataset.modifier;if(V==="ctrl")this.toggleModifierState("ctrl");else if(V==="alt")this.toggleModifierState("alt");else if(V==="shift")this.toggleModifierState("shift");else if(V==="fn")this.toggleModifierState("fn");this.focusMobileInput()})})}deactivateModifiers(){this.ctrlActive=!1,this.altActive=!1,this.shiftActive=!1,this.fnActive=!1,this.pendingCtrl=!1,this.pendingAlt=!1,this.pendingShift=!1,this.pendingFn=!1,this.syncModifierButtons(),this.syncVirtualKeyboardState()}focusMobileInput(){if(this.usesVirtualKeyboard())return;this.mobileInput?.focus()}async waitForFonts(){if(!("fonts"in document))return;try{await document.fonts.ready}catch{}}fit(){let j=this.terminal.renderer,J,Z;if(j?.getMetrics){let R=j.getMetrics();if(R&&R.width>0&&R.height>0)J=R.width,Z=R.height;else{let N=this.measureCellSize();if(!N){this.fitAddon.fit();return}J=N.width,Z=N.height}}else{let R=this.measureCellSize();if(!R){this.fitAddon.fit();return}J=R.width,Z=R.height}let X=window.getComputedStyle(this.element),q=parseInt(X.paddingTop)||0,K=parseInt(X.paddingBottom)||0,W=parseInt(X.paddingLeft)||0,z=parseInt(X.paddingRight)||0,G=this.element.clientWidth-W-z,V=this.element.clientHeight-q-K;if(G<=0||V<=0)return;let H=Math.max(2,Math.floor(G/J)),U=Math.max(1,Math.floor(V/Z));if(H!==this.terminal.cols||U!==this.terminal.rows)this.terminal.resize(H,U)}measureCellSize(){let $=document.createElement("span");$.style.visibility="hidden",$.style.position="absolute",$.style.fontFamily=this.fontFamily,$.style.fontSize=`${this.fontSize}px`,$.style.lineHeight="normal",$.textContent="W",document.body.appendChild($);let{offsetWidth:j,offsetHeight:J}=$;if(document.body.removeChild($),j>0&&J>0)return{width:j,height:J};return null}setupResizeObserver(){this.resizeObserver=new ResizeObserver(()=>{if(this.resizeDebounceTimer)clearTimeout(this.resizeDebounceTimer);this.resizeDebounceTimer=window.setTimeout(()=>{this.fit()},100)}),this.resizeObserver.observe(this.element)}resizeDebounceTimer;isValidSize($,j){return $>=2&&$<=500&&j>=1&&j<=500}connect(){if(this.socket?.readyState===WebSocket.OPEN)return;let $=++this.socketGeneration;this.socket=new WebSocket(this.wsUrl),this.socket.binaryType="arraybuffer",this.socket.addEventListener("open",()=>{if($!==this.socketGeneration)return;if(this.reconnectAttempts=0,this.clearUserError(),!this.isTabHidden)this.startHeartbeatWatchdog();this.element.classList.add("-connected"),this.element.classList.remove("-disconnected"),this.flushStdin(),this.processMessageQueue();let j=this.terminal.cols,J=this.terminal.rows;if(this.isValidSize(j,J))this.lastValidSize={cols:j,rows:J},this.send(["resize",{width:j,height:J}]);if(!this.usesVirtualKeyboard())this.terminal.focus()}),this.socket.addEventListener("close",(j)=>{if($!==this.socketGeneration)return;if(this.stopHeartbeatWatchdog(),this.element.classList.remove("-connected"),this.element.classList.add("-disconnected"),this.reconnectAttempts>=this.maxReconnectAttempts)this.showUserError("Disconnected. Max reconnection attempts reached.");else if(!j.wasClean)this.showUserError("Connection lost. Reconnecting...");this.scheduleReconnect()}),this.socket.addEventListener("error",()=>{this.showUserError("WebSocket error. Reconnecting...")}),this.socket.addEventListener("message",(j)=>{if($!==this.socketGeneration)return;this.handleMessage(j.data)})}handleTextMessage($){try{let j=JSON.parse($),[J,Z]=j;switch(J){case"stdout":if(this.isTabHidden)this.bufferWhileHidden(Z);else this.terminal.write(Z);break;case"pong":this.lastPongAt=Date.now();break;default:console.debug("Unknown message type:",J)}}catch{if(this.isTabHidden)this.bufferWhileHidden($);else this.terminal.write($)}}handleMessage($){if(this.lastMessageAt=Date.now(),$ instanceof ArrayBuffer){let j=new Uint8Array($);if(this.isTabHidden)this.bufferWhileHidden(j);else this.terminal.write(j);return}if($ instanceof Blob){$.text().then((j)=>{this.lastMessageAt=Date.now(),this.handleTextMessage(j)}).catch(()=>{});return}this.handleTextMessage($)}startHeartbeatWatchdog(){this.stopHeartbeatWatchdog();let $=Date.now();this.lastMessageAt=$,this.lastPongAt=$,this.heartbeatTimer=window.setInterval(()=>{if(this.socket?.readyState!==WebSocket.OPEN)return;let j=Date.now(),J=Math.max(this.lastMessageAt,this.lastPongAt);if(j-J>this.stallTimeoutMs){console.warn("WebSocket inbound stream stalled; reconnecting"),this.socket.close();return}this.send(["ping",String(j)])},this.heartbeatIntervalMs)}stopHeartbeatWatchdog(){if(this.heartbeatTimer)clearInterval(this.heartbeatTimer),this.heartbeatTimer=void 0}startResourceCleanup(){this.cleanupTimer=window.setInterval(()=>{this.trimMessageQueue()},f0)}stopResourceCleanup(){if(this.cleanupTimer)clearInterval(this.cleanupTimer),this.cleanupTimer=void 0}trimMessageQueue(){if(this.messageQueue.length>I){let $=this.messageQueue.length-I;this.messageQueue=this.messageQueue.slice(-I),console.warn(`[webterm] Trimmed ${$} stale messages from queue`)}}bufferWhileHidden($){let j=typeof $==="string"?l.sharedTextEncoder.encode($):$;while(this.hiddenBufferBytes+j.byteLength>o0&&this.hiddenBuffer.length>0){let J=this.hiddenBuffer.shift();this.hiddenBufferBytes-=J.byteLength}this.hiddenBuffer.push(j),this.hiddenBufferBytes+=j.byteLength}refreshConnection(){if(this.hiddenBuffer.length=0,this.hiddenBufferBytes=0,this.reconnectAttempts=0,this.stopHeartbeatWatchdog(),this.socket)this.socket.close(),this.socket=null;this.connect()}sendStdin($){if(!$)return;if(this.pendingStdin+=$,this.pendingStdin.length>=i0){this.flushStdin();return}if(this.pendingStdinTimer)return;this.pendingStdinTimer=window.setTimeout(()=>{this.pendingStdinTimer=void 0,this.flushStdin()},a0)}flushStdin(){if(this.pendingStdinTimer)clearTimeout(this.pendingStdinTimer),this.pendingStdinTimer=void 0;if(!this.pendingStdin)return;let $=this.pendingStdin;this.pendingStdin="",this.send(["stdin",$])}send($){if($[0]!=="stdin"&&this.pendingStdin)this.flushStdin();if(this.messageQueue.length>=I)this.messageQueue=this.messageQueue.slice(-Math.floor(I/2)),console.warn("[webterm] Message queue overflow; trimmed old messages");this.messageQueue.push($),this.processMessageQueue()}processMessageQueue(){if(this.socket?.readyState!==WebSocket.OPEN)return;while(this.messageQueue.length>0){let $=this.messageQueue.shift();try{if($)this.socket.send(JSON.stringify($))}catch(j){if(console.error("Failed to send message:",j,$),$)this.messageQueue.unshift($);break}}}scheduleReconnect(){if(this.reconnectAttempts>=this.maxReconnectAttempts){console.error("Max reconnection attempts reached"),this.showUserError("Disconnected. Max reconnection attempts reached.");return}this.reconnectAttempts++;let $=this.reconnectDelay*Math.pow(2,this.reconnectAttempts-1);setTimeout(()=>{console.log(`[webterm] Reconnecting (attempt ${this.reconnectAttempts})...`),this.connect()},$)}dispose(){if(this.stopVoiceInput(),this.stopResourceCleanup(),this.stopHeartbeatWatchdog(),this.pendingStdinTimer)clearTimeout(this.pendingStdinTimer),this.pendingStdinTimer=void 0;if(this.mobileVirtualKeyboardDrawFrame!==null)cancelAnimationFrame(this.mobileVirtualKeyboardDrawFrame),this.mobileVirtualKeyboardDrawFrame=null;if(this.mobileVirtualKeyboardActivePresses.forEach(($)=>{this.clearVirtualKeyboardRepeat($)}),this.mobileVirtualKeyboardActivePresses.clear(),this.pendingStdin="",this.resizeDebounceTimer)clearTimeout(this.resizeDebounceTimer),this.resizeDebounceTimer=void 0;this.socket?.close(),this.socket=null,this.messageQueue.length=0,this.hiddenBuffer.length=0,this.hiddenBufferBytes=0;for(let{target:$,type:j,handler:J,options:Z}of this.boundHandlers)$.removeEventListener(j,J,Z);if(this.boundHandlers.length=0,this.resizeObserver)this.resizeObserver.disconnect(),this.resizeObserver=null;if(this.mobileInput)this.mobileInput.remove(),this.mobileInput=null;if(this.mobileKeybar)this.mobileKeybar.remove(),this.mobileKeybar=null;if(this.mobileVirtualKeyboard)this.mobileVirtualKeyboard.remove(),this.mobileVirtualKeyboard=null;if(this.mobileVirtualKeyboardHost)this.mobileVirtualKeyboardHost.remove(),this.mobileVirtualKeyboardHost=null;if(this.mobileVirtualKeyboardBounds=[],this.mobileKeybarStyle)this.mobileKeybarStyle.remove(),this.mobileKeybarStyle=null;if(this.errorOverlay)this.errorOverlay.remove(),this.errorOverlay=null;if(this.voiceControls)this.voiceControls.remove(),this.voiceControls=null;this.voiceStatus=null,this.destroyVoiceEngine(),this.fitAddon.dispose(),this.terminal.dispose()}setTheme($){this.terminal.options.theme=$}static getTheme($){return c[$.toLowerCase()]}}var q0=new Map;function _0($,j){if(window.getComputedStyle($).position==="static")$.style.position="relative";let J=$.querySelector(".webterm-startup-error");if(!J)J=document.createElement("div"),J.className="webterm-startup-error",J.style.cssText=`
position: absolute;
inset: 12px 12px auto 12px;
z-index: 40;
@@ -154,4 +162,4 @@ For tests, pass a Ghostty instance directly:
white-space: pre-wrap;
word-break: break-word;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
- `,$.appendChild(J);J.textContent=j}setInterval(()=>{for(let[$,j]of j0)if(!$.isConnected)j.dispose(),j0.delete($),console.log("[webterm] Cleaned up stale terminal instance")},b0);async function F0(){let $=document.querySelectorAll(".webterm-terminal");for(let j of $){let J=j.dataset.sessionWebsocketUrl;if(!J){console.error("[webterm] Missing data-session-websocket-url on terminal container"),P0(j,"Terminal startup failed: missing websocket URL.");continue}let Z=r0(j);try{let X=await l.create(j,J,Z);j0.set(j,X)}catch(X){console.error("[webterm] Failed to create terminal:",X);let z=X instanceof Error&&X.message?X.message:"Unknown startup error";P0(j,`Terminal startup failed: ${z}`)}}}if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>F0());else F0();export{j0 as instances,F0 as initTerminals,l as WebTerminal,y as THEMES};
+ `,$.appendChild(J);J.textContent=j}setInterval(()=>{for(let[$,j]of q0)if(!$.isConnected)j.dispose(),q0.delete($),console.log("[webterm] Cleaned up stale terminal instance")},f0);async function x0(){let $=document.querySelectorAll(".webterm-terminal");for(let j of $){let J=j.dataset.sessionWebsocketUrl;if(!J){console.error("[webterm] Missing data-session-websocket-url on terminal container"),_0(j,"Terminal startup failed: missing websocket URL.");continue}let Z=j5(j);try{let X=await l.create(j,J,Z);q0.set(j,X)}catch(X){console.error("[webterm] Failed to create terminal:",X);let q=X instanceof Error&&X.message?X.message:"Unknown startup error";_0(j,`Terminal startup failed: ${q}`)}}}if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>x0());else x0();export{q0 as instances,x0 as initTerminals,l as WebTerminal,c as THEMES};
diff --git a/webterm/static/js/terminal.ts b/webterm/static/js/terminal.ts
index 59dc929..9614935 100644
--- a/webterm/static/js/terminal.ts
+++ b/webterm/static/js/terminal.ts
@@ -27,6 +27,21 @@ const VOICE_PROCESSOR_BUFFER_SIZE = 4096;
const VOICE_VAD_WINDOW_SIZE = 512;
const VOICE_BUFFER_SIZE_SECONDS = 30;
const VOICE_STATUS_MAX_LENGTH = 48;
+const DEFAULT_VOICE_LLM_BASE_URL = "/llm";
+const VOICE_LLM_TIMEOUT_MS = 180_000;
+const VOICE_MODE_STORAGE_KEY = "webterm:voice-mode";
+
+const VOICE_INSERT_COMMAND = "insert text";
+const VOICE_SUBMIT_COMMAND = "submit text";
+const VOICE_CANCEL_COMMAND = "cancel text";
+
+type VoiceMode = "live" | "cleanup";
+type VoiceFinalizeAction = "insert" | "submit";
+type VoiceCommandAction = VoiceFinalizeAction | "cancel";
+
+const VOICE_THINKING_SYSTEM_PROMPT = `You are a helpful voice-to-task translator. You will recieve a raw speech-to-text transcript that may contain filler words, false starts, and rambling. Then you will receive some instructions after which you'll analyze the text in the best way to help clean it up. Do not ask any questions. Just think outloud.`;
+const VOICE_THINKING_ANALYSIS_PROMPT = `Analyze the user's Intent and the Functional meaning of each sentence. Evaluate Correctness — are these genuine instructions or speech mistakes? Consider Efficiency of each phrase and whether there are better Alternatives or unnecessary filler words to remove.`;
+const VOICE_CLEANUP_SYSTEM_PROMPT = `You clean up raw speech-to-text transcripts into concise terminal-ready text. Remove filler words, false starts, repetitions, and obvious recognition mistakes while preserving user intent. Do not ask questions. Return only cleaned transcript text with no explanation, labels, or quotes.`;
type SherpaModule = {
HEAPF32: Float32Array;
@@ -81,6 +96,15 @@ type SherpaRuntime = {
createVad: (module: SherpaModule, config?: unknown) => SherpaVad;
};
+type OpenAIChatMessage = {
+ role: "system" | "user" | "assistant";
+ content: string;
+};
+
+type OpenAIModelsResponse = {
+ data?: Array<{ id?: string }>;
+};
+
declare global {
interface Window {
CircularBuffer?: new (capacity: number, module: SherpaModule) => SherpaCircularBuffer;
@@ -1031,10 +1055,17 @@ class WebTerminal {
private voiceSilentGain: GainNode | null = null;
private voiceInputStream: MediaStream | null = null;
private voiceAssetBase = `${getStaticJSBasePath()}${DEFAULT_SHERPA_ASSET_DIR}/`;
+ private voiceLlmBaseUrl = "";
+ private voiceLlmModelOverride = "";
+ private voiceLlmDetectedModel: string | null = null;
+ private voiceMode: VoiceMode = "live";
private voiceSpeechDetected = false;
private voiceReceivedAudio = false;
+ private voicePendingSeparator = false;
+ private voiceDraftTranscript = "";
+ private voiceFinalizeToken = 0;
private isVoiceStarting = false;
- private voiceState: "idle" | "loading" | "listening" | "error" | "unsupported" = "idle";
+ private voiceState: "idle" | "loading" | "listening" | "processing" | "error" | "unsupported" = "idle";
private voiceStartupErrorCleanup: (() => void) | null = null;
private static sharedTextEncoder = new TextEncoder();
@@ -1151,6 +1182,223 @@ class WebTerminal {
document.title = this.baseTitle;
}
+ private loadVoiceModePreference(): VoiceMode {
+ try {
+ const stored = localStorage.getItem(VOICE_MODE_STORAGE_KEY);
+ return stored === "cleanup" ? "cleanup" : "live";
+ } catch {
+ return "live";
+ }
+ }
+
+ private persistVoiceModePreference(): void {
+ try {
+ localStorage.setItem(VOICE_MODE_STORAGE_KEY, this.voiceMode);
+ } catch {
+ // Ignore storage failures in private browsing or restricted contexts.
+ }
+ }
+
+ private setVoiceMode(mode: VoiceMode): void {
+ this.voiceMode = mode;
+ this.persistVoiceModePreference();
+ this.syncModifierButtons();
+ const configError = this.getVoiceLlmConfigError();
+ if (configError) {
+ this.setVoiceState("error", configError);
+ return;
+ }
+ if (this.voiceState === "idle" || this.voiceState === "error") {
+ this.setVoiceState("idle", mode === "cleanup" ? "Ready: Cleanup" : "Ready: Live");
+ }
+ }
+
+ private normalizeVoiceCommandText(value: string): string {
+ return value
+ .toLowerCase()
+ .replace(/[\s]+/g, " ")
+ .replace(/[.,!?;:]+$/g, "")
+ .trim();
+ }
+
+ private getVoiceCommandAction(transcript: string): VoiceCommandAction | null {
+ const normalized = this.normalizeVoiceCommandText(transcript);
+ if (!normalized) {
+ return null;
+ }
+ if (normalized === VOICE_INSERT_COMMAND || normalized.endsWith(` ${VOICE_INSERT_COMMAND}`)) {
+ return "insert";
+ }
+ if (normalized === VOICE_SUBMIT_COMMAND || normalized.endsWith(` ${VOICE_SUBMIT_COMMAND}`)) {
+ return "submit";
+ }
+ if (normalized === VOICE_CANCEL_COMMAND || normalized.endsWith(` ${VOICE_CANCEL_COMMAND}`)) {
+ return "cancel";
+ }
+ return null;
+ }
+
+ private stripVoiceCommandSuffix(transcript: string): string {
+ let value = transcript.trim();
+ const patterns = [
+ new RegExp(`(?:^|\\s)${VOICE_INSERT_COMMAND}[\\s,.!?;:]*$`, "i"),
+ new RegExp(`(?:^|\\s)${VOICE_SUBMIT_COMMAND}[\\s,.!?;:]*$`, "i"),
+ new RegExp(`(?:^|\\s)${VOICE_CANCEL_COMMAND}[\\s,.!?;:]*$`, "i"),
+ ];
+ for (const pattern of patterns) {
+ value = value.replace(pattern, "").trim();
+ }
+ return value;
+ }
+
+ private appendVoiceDraftSegment(transcript: string): string {
+ const next = this.formatVoiceTranscriptForInsert(transcript);
+ this.voiceDraftTranscript += next;
+ return this.voiceDraftTranscript;
+ }
+
+ private voiceDraftPreview(limit = 40): string {
+ const text = this.voiceDraftTranscript.trim();
+ if (!text) {
+ return "Draft empty";
+ }
+ return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
+ }
+
+ private getVoiceLlmConfigError(): string | null {
+ if (this.voiceMode !== "cleanup") {
+ return null;
+ }
+ if (!this.voiceLlmBaseUrl) {
+ return "Cleanup mode missing LLM URL";
+ }
+ return null;
+ }
+
+ private async resolveVoiceLlmModel(): Promise