diff --git a/src/dashboard/routes/chat_api.py b/src/dashboard/routes/chat_api.py index 604394f..c3f5c21 100644 --- a/src/dashboard/routes/chat_api.py +++ b/src/dashboard/routes/chat_api.py @@ -31,6 +31,93 @@ _UPLOAD_DIR = str(Path(settings.repo_root) / "data" / "chat-uploads") _MAX_UPLOAD_SIZE = 50 * 1024 * 1024 # 50 MB +# ── POST /api/chat — helpers ───────────────────────────────────────────────── + + +async def _parse_chat_body(request: Request) -> tuple[dict | None, JSONResponse | None]: + """Parse and validate the JSON request body. + + Returns (body, None) on success or (None, error_response) on failure. + """ + content_length = request.headers.get("content-length") + if content_length and int(content_length) > settings.chat_api_max_body_bytes: + return None, JSONResponse(status_code=413, content={"error": "Request body too large"}) + + try: + body = await request.json() + except Exception as exc: + logger.warning("Chat API JSON parse error: %s", exc) + return None, JSONResponse(status_code=400, content={"error": "Invalid JSON"}) + + messages = body.get("messages") + if not messages or not isinstance(messages, list): + return None, JSONResponse(status_code=400, content={"error": "messages array is required"}) + + return body, None + + +def _extract_user_message(messages: list[dict]) -> str | None: + """Return the text of the last user message, or *None* if absent.""" + for msg in reversed(messages): + if msg.get("role") == "user": + content = msg.get("content", "") + if isinstance(content, list): + text_parts = [ + p.get("text", "") + for p in content + if isinstance(p, dict) and p.get("type") == "text" + ] + return " ".join(text_parts).strip() or None + text = str(content).strip() + return text or None + return None + + +def _build_context_prefix() -> str: + """Build the system-context preamble injected before the user message.""" + now = datetime.now() + return ( + f"[System: Current date/time is " + f"{now.strftime('%A, %B %d, %Y at %I:%M %p')}]\n" + f"[System: Mobile client]\n\n" + ) + + +def _notify_thinking_engine() -> None: + """Record user activity so the thinking engine knows we're not idle.""" + try: + from timmy.thinking import thinking_engine + + thinking_engine.record_user_input() + except Exception: + logger.debug("Failed to record user input for thinking engine") + + +async def _process_chat(user_msg: str) -> dict | JSONResponse: + """Send *user_msg* to the agent, log the exchange, and return a response.""" + _notify_thinking_engine() + timestamp = datetime.now().strftime("%H:%M:%S") + + try: + response_text = await agent_chat( + _build_context_prefix() + user_msg, + session_id="mobile", + ) + message_log.append(role="user", content=user_msg, timestamp=timestamp, source="api") + message_log.append(role="agent", content=response_text, timestamp=timestamp, source="api") + return {"reply": response_text, "timestamp": timestamp} + + except Exception as exc: + error_msg = f"Agent is offline: {exc}" + logger.error("api_chat error: %s", exc) + message_log.append(role="user", content=user_msg, timestamp=timestamp, source="api") + message_log.append(role="error", content=error_msg, timestamp=timestamp, source="api") + return JSONResponse( + status_code=503, + content={"error": error_msg, "timestamp": timestamp}, + ) + + # ── POST /api/chat ──────────────────────────────────────────────────────────── @@ -44,78 +131,15 @@ async def api_chat(request: Request): Response: {"reply": "...", "timestamp": "HH:MM:SS"} """ - # Enforce request body size limit - content_length = request.headers.get("content-length") - if content_length and int(content_length) > settings.chat_api_max_body_bytes: - return JSONResponse(status_code=413, content={"error": "Request body too large"}) + body, err = await _parse_chat_body(request) + if err: + return err - try: - body = await request.json() - except Exception as exc: - logger.warning("Chat API JSON parse error: %s", exc) - return JSONResponse(status_code=400, content={"error": "Invalid JSON"}) - - messages = body.get("messages") - if not messages or not isinstance(messages, list): - return JSONResponse(status_code=400, content={"error": "messages array is required"}) - - # Extract the latest user message text - last_user_msg = None - for msg in reversed(messages): - if msg.get("role") == "user": - content = msg.get("content", "") - # Handle multimodal content arrays — extract text parts - if isinstance(content, list): - text_parts = [ - p.get("text", "") - for p in content - if isinstance(p, dict) and p.get("type") == "text" - ] - last_user_msg = " ".join(text_parts).strip() - else: - last_user_msg = str(content).strip() - break - - if not last_user_msg: + user_msg = _extract_user_message(body["messages"]) + if not user_msg: return JSONResponse(status_code=400, content={"error": "No user message found"}) - # Record user activity so the thinking engine knows we're not idle - try: - from timmy.thinking import thinking_engine - - thinking_engine.record_user_input() - except Exception: - logger.debug("Failed to record user input for thinking engine") - - timestamp = datetime.now().strftime("%H:%M:%S") - - try: - # Inject context (same pattern as the HTMX chat handler in agents.py) - now = datetime.now() - context_prefix = ( - f"[System: Current date/time is " - f"{now.strftime('%A, %B %d, %Y at %I:%M %p')}]\n" - f"[System: Mobile client]\n\n" - ) - response_text = await agent_chat( - context_prefix + last_user_msg, - session_id="mobile", - ) - - message_log.append(role="user", content=last_user_msg, timestamp=timestamp, source="api") - message_log.append(role="agent", content=response_text, timestamp=timestamp, source="api") - - return {"reply": response_text, "timestamp": timestamp} - - except Exception as exc: - error_msg = f"Agent is offline: {exc}" - logger.error("api_chat error: %s", exc) - message_log.append(role="user", content=last_user_msg, timestamp=timestamp, source="api") - message_log.append(role="error", content=error_msg, timestamp=timestamp, source="api") - return JSONResponse( - status_code=503, - content={"error": error_msg, "timestamp": timestamp}, - ) + return await _process_chat(user_msg) # ── POST /api/upload ──────────────────────────────────────────────────────────