forked from Rockachopa/Timmy-time-dashboard
161 lines
5.6 KiB
Python
161 lines
5.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 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 = os.path.join("data", "chat-uploads")
|
|
_MAX_UPLOAD_SIZE = 50 * 1024 * 1024 # 50 MB
|
|
|
|
|
|
# ── 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"}
|
|
"""
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
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:
|
|
return JSONResponse(status_code=400, content={"error": "No user message found"})
|
|
|
|
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 = 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},
|
|
)
|
|
|
|
|
|
# ── 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)
|
|
|
|
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}
|