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 %}