216 lines
7.6 KiB
Python
216 lines
7.6 KiB
Python
"""JSON REST API for mobile / external chat clients.
|
|
|
|
Provides the same chat experience as the HTMX dashboard but over
|
|
a JSON interface that React Native (or any HTTP client) can consume.
|
|
|
|
Endpoints:
|
|
POST /api/chat — send a message, get the agent's reply
|
|
POST /api/upload — upload a file attachment
|
|
GET /api/chat/history — retrieve recent chat history
|
|
DELETE /api/chat/history — clear chat history
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import uuid
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, File, HTTPException, Request, UploadFile
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from config import settings
|
|
from dashboard.store import message_log
|
|
from timmy.session import chat as agent_chat
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api", tags=["chat-api"])
|
|
|
|
_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=body.get("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 ────────────────────────────────────────────────────────────
|
|
|
|
|
|
@router.post("/chat")
|
|
async def api_chat(request: Request):
|
|
"""Accept a JSON chat payload and return the agent's reply.
|
|
|
|
Request body:
|
|
{"messages": [{"role": "user"|"assistant", "content": "..."}]}
|
|
|
|
Response:
|
|
{"reply": "...", "timestamp": "HH:MM:SS"}
|
|
"""
|
|
body, err = await _parse_chat_body(request)
|
|
if err:
|
|
return err
|
|
|
|
user_msg = _extract_user_message(body["messages"])
|
|
if not user_msg:
|
|
return JSONResponse(status_code=400, content={"error": "No user message found"})
|
|
|
|
return await _process_chat(user_msg)
|
|
|
|
|
|
# ── POST /api/upload ──────────────────────────────────────────────────────────
|
|
|
|
|
|
@router.post("/upload")
|
|
async def api_upload(file: UploadFile = File(...)):
|
|
"""Accept a file upload and return its URL.
|
|
|
|
Response:
|
|
{"url": "/static/chat-uploads/...", "fileName": "...", "mimeType": "..."}
|
|
"""
|
|
os.makedirs(_UPLOAD_DIR, exist_ok=True)
|
|
|
|
suffix = uuid.uuid4().hex[:12]
|
|
safe_name = os.path.basename(file.filename or "upload")
|
|
stored_name = f"{suffix}-{safe_name}"
|
|
file_path = os.path.join(_UPLOAD_DIR, stored_name)
|
|
|
|
# Defense-in-depth: verify resolved path stays within upload directory
|
|
resolved = Path(file_path).resolve()
|
|
upload_root = Path(_UPLOAD_DIR).resolve()
|
|
if not str(resolved).startswith(str(upload_root)):
|
|
raise HTTPException(status_code=400, detail="Invalid file name")
|
|
|
|
# Validate MIME type
|
|
allowed_types = ["image/png", "image/jpeg", "image/gif", "application/pdf", "text/plain"]
|
|
if file.content_type not in allowed_types:
|
|
raise HTTPException(status_code=400, detail=f"File type {file.content_type} not allowed")
|
|
|
|
contents = await file.read()
|
|
if len(contents) > _MAX_UPLOAD_SIZE:
|
|
raise HTTPException(status_code=413, detail="File too large (max 50 MB)")
|
|
with open(file_path, "wb") as f:
|
|
f.write(contents)
|
|
|
|
# Return a URL the mobile app can reference
|
|
url = f"/uploads/{stored_name}"
|
|
|
|
return {
|
|
"url": url,
|
|
"fileName": file.filename or "upload",
|
|
"mimeType": file.content_type or "application/octet-stream",
|
|
}
|
|
|
|
|
|
# ── GET /api/chat/history ────────────────────────────────────────────────────
|
|
|
|
|
|
@router.get("/chat/history")
|
|
async def api_chat_history():
|
|
"""Return the in-memory chat history as JSON."""
|
|
return {
|
|
"messages": [
|
|
{
|
|
"role": msg.role,
|
|
"content": msg.content,
|
|
"timestamp": msg.timestamp,
|
|
"source": msg.source,
|
|
}
|
|
for msg in message_log.all()
|
|
]
|
|
}
|
|
|
|
|
|
# ── DELETE /api/chat/history ──────────────────────────────────────────────────
|
|
|
|
|
|
@router.delete("/chat/history")
|
|
async def api_clear_history():
|
|
"""Clear the in-memory chat history."""
|
|
message_log.clear()
|
|
return {"success": True}
|