From 2aca0e5d42233b05330b7c325e71eb5a9eb9e1be Mon Sep 17 00:00:00 2001 From: Jacob Hinkle Date: Mon, 29 Jun 2026 11:53:08 -0400 Subject: [PATCH] Fix form submit cancelability, improve opencode error handling --- app/routers/chat.py | 18 ++++++++++++++---- app/services/opencode_proxy.py | 15 +++++++++++++-- app/static/chat-sidebar.js | 11 +++++++++-- app/templates/chat.html | 15 ++++++++++++--- 4 files changed, 48 insertions(+), 11 deletions(-) diff --git a/app/routers/chat.py b/app/routers/chat.py index b7436ee..79e61e1 100644 --- a/app/routers/chat.py +++ b/app/routers/chat.py @@ -1,3 +1,4 @@ +import logging from datetime import datetime, timezone import uuid from fastapi import APIRouter, Request, Depends, Form @@ -12,7 +13,7 @@ from app.models.workout import Workout from app.models.checkin import Checkin from app.models.measurement import Measurement, MeasurementType from app.auth import get_current_user -from app.services.opencode_proxy import query_opencode +from app.services.opencode_proxy import query_opencode, OpenCodeUnavailableError router = APIRouter() templates = Jinja2Templates(directory="app/templates") @@ -26,6 +27,9 @@ ONBOARDING_PROMPT = ( ) +logger = logging.getLogger("chat") + + @router.get("/api/chat/messages") async def get_chat_messages(request: Request, user: User = Depends(get_current_user)): session_id = request.cookies.get("chat_session_id") @@ -159,9 +163,15 @@ async def chat_send( await session.commit() assistant_content = "" - async for chunk in query_opencode(message, session_id, user_context): - assistant_content += chunk - yield f"data: {chunk}\n\n" + try: + async for chunk in query_opencode(message, session_id, user_context): + assistant_content += chunk + yield f"data: {chunk}\n\n" + except OpenCodeUnavailableError as e: + logger.error("Chat failed for user %s: %s", user.username, e) + yield f"data: [error] {e}\n\n" + # Don't save an error assistant message + return async with async_session() as session: assistant_msg = ChatMessage( diff --git a/app/services/opencode_proxy.py b/app/services/opencode_proxy.py index c800985..be60913 100644 --- a/app/services/opencode_proxy.py +++ b/app/services/opencode_proxy.py @@ -1,8 +1,15 @@ import asyncio import json +import logging from typing import AsyncGenerator from app.config import OPENCODE_SERVE_URL +logger = logging.getLogger("opencode_proxy") + + +class OpenCodeUnavailableError(Exception): + pass + async def query_opencode( message: str, @@ -37,6 +44,10 @@ async def query_opencode( await proc.wait() except FileNotFoundError: - yield "AI coach is not available. Install opencode and run `opencode serve` to enable." + logger.error("opencode binary not found in PATH") + raise OpenCodeUnavailableError( + "opencode binary not found. Install opencode or run `opencode serve`." + ) except Exception as e: - yield f"Error connecting to AI coach: {e}" + logger.error("opencode proxy error: %s", e) + raise OpenCodeUnavailableError(f"AI coach unavailable: {e}") diff --git a/app/static/chat-sidebar.js b/app/static/chat-sidebar.js index f3606f2..267c63b 100644 --- a/app/static/chat-sidebar.js +++ b/app/static/chat-sidebar.js @@ -79,7 +79,12 @@ buffer = lines.pop() || ''; for (const line of lines) { if (line.startsWith('data: ')) { - p.innerHTML += line.slice(6); + const chunk = line.slice(6); + if (chunk.startsWith('[error] ')) { + p.innerHTML = '' + chunk.slice(8) + ''; + break; + } + p.innerHTML += chunk; } } messagesEl.scrollTop = messagesEl.scrollHeight; @@ -104,7 +109,9 @@ input.addEventListener('keydown', function(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); - form.dispatchEvent(new Event('submit')); + const event = new Event('submit', {cancelable: true}); + form.dispatchEvent(event); + if (!event.defaultPrevented) sendMessage(event); } }); } diff --git a/app/templates/chat.html b/app/templates/chat.html index 0e343e9..a6ee7b3 100644 --- a/app/templates/chat.html +++ b/app/templates/chat.html @@ -33,7 +33,9 @@ const ONBOARDING_PROMPT = chatForm.dataset.onboarding; chatInput.addEventListener('keydown', function(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); - chatForm.dispatchEvent(new Event('submit')); + const event = new Event('submit', {cancelable: true}); + chatForm.dispatchEvent(event); + if (!event.defaultPrevented) sendMessage(event); } }); @@ -90,7 +92,12 @@ async function sendMessage(event) { buffer = lines.pop() || ''; for (const line of lines) { if (line.startsWith('data: ')) { - p.innerHTML += line.slice(6); + const chunk = line.slice(6); + if (chunk.startsWith('[error] ')) { + p.innerHTML = '' + chunk.slice(8) + ''; + break; + } + p.innerHTML += chunk; } } chatMessages.scrollTop = chatMessages.scrollHeight; @@ -114,7 +121,9 @@ async function sendMessage(event) { if (IS_FIRST && ONBOARDING_PROMPT) { chatInput.value = ONBOARDING_PROMPT; - chatForm.dispatchEvent(new Event('submit')); + const event = new Event('submit', {cancelable: true}); + chatForm.dispatchEvent(event); + if (!event.defaultPrevented) sendMessage(event); } {% endblock %}