forked from Rockachopa/Timmy-time-dashboard
Compare commits
3 Commits
chore/migr
...
feature/ip
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36d1bdb521 | ||
|
|
964f28a86f | ||
|
|
55dda093c8 |
@@ -28,6 +28,7 @@ from dashboard.routes.agents import router as agents_router
|
||||
from dashboard.routes.briefing import router as briefing_router
|
||||
from dashboard.routes.calm import router as calm_router
|
||||
from dashboard.routes.chat_api import router as chat_api_router
|
||||
from dashboard.routes.chat_api_v1 import router as chat_api_v1_router
|
||||
from dashboard.routes.db_explorer import router as db_explorer_router
|
||||
from dashboard.routes.discord import router as discord_router
|
||||
from dashboard.routes.experiments import router as experiments_router
|
||||
@@ -483,6 +484,7 @@ app.include_router(grok_router)
|
||||
app.include_router(models_router)
|
||||
app.include_router(models_api_router)
|
||||
app.include_router(chat_api_router)
|
||||
app.include_router(chat_api_v1_router)
|
||||
app.include_router(thinking_router)
|
||||
app.include_router(calm_router)
|
||||
app.include_router(tasks_router)
|
||||
|
||||
206
src/dashboard/routes/chat_api_v1.py
Normal file
206
src/dashboard/routes/chat_api_v1.py
Normal file
@@ -0,0 +1,206 @@
|
||||
"""Version 1 (v1) JSON REST API for the Timmy Time iPad app.
|
||||
|
||||
This module implements the specific endpoints required by the native
|
||||
iPad app as defined in the project specification.
|
||||
|
||||
Endpoints:
|
||||
POST /api/v1/chat — Streaming SSE chat response
|
||||
GET /api/v1/chat/history — Retrieve chat history with limit
|
||||
POST /api/v1/upload — Multipart file upload with auto-detection
|
||||
GET /api/v1/status — Detailed system and model status
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, File, HTTPException, Request, UploadFile, Query
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from config import APP_START_TIME, settings
|
||||
from dashboard.store import message_log
|
||||
from timmy.session import _get_agent
|
||||
from dashboard.routes.health import _check_ollama
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["chat-api-v1"])
|
||||
|
||||
_UPLOAD_DIR = str(Path(settings.repo_root) / "data" / "chat-uploads")
|
||||
_MAX_UPLOAD_SIZE = 50 * 1024 * 1024 # 50 MB
|
||||
|
||||
|
||||
# ── POST /api/v1/chat ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/chat")
|
||||
async def api_v1_chat(request: Request):
|
||||
"""Accept a JSON chat payload and return a streaming SSE response.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"message": "string",
|
||||
"session_id": "string",
|
||||
"attachments": ["id1", "id2"]
|
||||
}
|
||||
|
||||
Response:
|
||||
text/event-stream (SSE)
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception as exc:
|
||||
logger.warning("Chat v1 API JSON parse error: %s", exc)
|
||||
return JSONResponse(status_code=400, content={"error": "Invalid JSON"})
|
||||
|
||||
message = body.get("message")
|
||||
session_id = body.get("session_id", "ipad-app")
|
||||
attachments = body.get("attachments", [])
|
||||
|
||||
if not message:
|
||||
return JSONResponse(status_code=400, content={"error": "message is required"})
|
||||
|
||||
# Prepare context for the agent
|
||||
now = datetime.now()
|
||||
timestamp = now.strftime("%H:%M:%S")
|
||||
context_prefix = (
|
||||
f"[System: Current date/time is "
|
||||
f"{now.strftime('%A, %B %d, %Y at %I:%M %p')}]\n"
|
||||
f"[System: iPad App client]\n"
|
||||
)
|
||||
|
||||
if attachments:
|
||||
context_prefix += f"[System: Attachments: {', '.join(attachments)}]\n"
|
||||
|
||||
context_prefix += "\n"
|
||||
full_prompt = context_prefix + message
|
||||
|
||||
# Log user message
|
||||
message_log.append(role="user", content=message, timestamp=timestamp, source="api-v1")
|
||||
|
||||
async def event_generator():
|
||||
full_response = ""
|
||||
try:
|
||||
agent = _get_agent()
|
||||
# Using streaming mode for SSE
|
||||
async for chunk in agent.arun(full_prompt, stream=True, session_id=session_id):
|
||||
# Agno chunks can be strings or RunOutput
|
||||
content = chunk.content if hasattr(chunk, "content") else str(chunk)
|
||||
if content:
|
||||
full_response += content
|
||||
yield f"data: {json.dumps({'text': content})}\n\n"
|
||||
|
||||
# Log agent response once complete
|
||||
message_log.append(
|
||||
role="agent", content=full_response, timestamp=timestamp, source="api-v1"
|
||||
)
|
||||
yield "data: [DONE]\n\n"
|
||||
except Exception as exc:
|
||||
logger.error("SSE stream error: %s", exc)
|
||||
yield f"data: {json.dumps({'error': str(exc)})}\n\n"
|
||||
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||
|
||||
|
||||
# ── GET /api/v1/chat/history ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/chat/history")
|
||||
async def api_v1_chat_history(
|
||||
session_id: str = Query("ipad-app"), limit: int = Query(50, ge=1, le=100)
|
||||
):
|
||||
"""Return recent chat history for a specific session."""
|
||||
# Using the optimized .recent() method from infrastructure.chat_store
|
||||
all_msgs = message_log.recent(limit=limit)
|
||||
|
||||
history = [
|
||||
{
|
||||
"role": msg.role,
|
||||
"content": msg.content,
|
||||
"timestamp": msg.timestamp,
|
||||
"source": msg.source,
|
||||
}
|
||||
for msg in all_msgs
|
||||
]
|
||||
|
||||
return {"messages": history}
|
||||
|
||||
|
||||
# ── POST /api/v1/upload ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def api_v1_upload(file: UploadFile = File(...)):
|
||||
"""Accept a file upload, auto-detect type, and return metadata.
|
||||
|
||||
Response:
|
||||
{
|
||||
"id": "string",
|
||||
"type": "image|audio|document|url",
|
||||
"summary": "string",
|
||||
"metadata": {...}
|
||||
}
|
||||
"""
|
||||
os.makedirs(_UPLOAD_DIR, exist_ok=True)
|
||||
|
||||
file_id = uuid.uuid4().hex[:12]
|
||||
safe_name = os.path.basename(file.filename or "upload")
|
||||
stored_name = f"{file_id}-{safe_name}"
|
||||
file_path = os.path.join(_UPLOAD_DIR, stored_name)
|
||||
|
||||
# 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")
|
||||
|
||||
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)
|
||||
|
||||
# Auto-detect type based on extension/mime
|
||||
mime_type = file.content_type or "application/octet-stream"
|
||||
ext = os.path.splitext(safe_name)[1].lower()
|
||||
|
||||
media_type = "document"
|
||||
if mime_type.startswith("image/") or ext in [".jpg", ".jpeg", ".png", ".heic"]:
|
||||
media_type = "image"
|
||||
elif mime_type.startswith("audio/") or ext in [".m4a", ".mp3", ".wav", ".caf"]:
|
||||
media_type = "audio"
|
||||
elif ext in [".pdf", ".txt", ".md"]:
|
||||
media_type = "document"
|
||||
|
||||
# Placeholder for actual processing (OCR, Whisper, etc.)
|
||||
summary = f"Uploaded {media_type}: {safe_name}"
|
||||
|
||||
return {
|
||||
"id": file_id,
|
||||
"type": media_type,
|
||||
"summary": summary,
|
||||
"url": f"/uploads/{stored_name}",
|
||||
"metadata": {"fileName": safe_name, "mimeType": mime_type, "size": len(contents)},
|
||||
}
|
||||
|
||||
|
||||
# ── GET /api/v1/status ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def api_v1_status():
|
||||
"""Detailed system and model status."""
|
||||
ollama_status = await _check_ollama()
|
||||
uptime = (datetime.now(UTC) - APP_START_TIME).total_seconds()
|
||||
|
||||
return {
|
||||
"timmy": "online" if ollama_status.status == "healthy" else "offline",
|
||||
"model": settings.ollama_model,
|
||||
"ollama": "running" if ollama_status.status == "healthy" else "stopped",
|
||||
"uptime": f"{int(uptime // 3600)}h {int((uptime % 3600) // 60)}m",
|
||||
"version": "2.0.0-v1-api",
|
||||
}
|
||||
62
tests/test_api_v1.py
Normal file
62
tests/test_api_v1.py
Normal file
@@ -0,0 +1,62 @@
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Add project root to path
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
src_path = project_root / "src"
|
||||
if str(src_path) not in sys.path:
|
||||
sys.path.insert(0, str(src_path))
|
||||
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
try:
|
||||
from dashboard.app import app # noqa: E402
|
||||
print("✓ Successfully imported dashboard.app")
|
||||
except ImportError as e:
|
||||
print(f"✗ Failed to import dashboard.app: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
def test_v1_status():
|
||||
response = client.get("/api/v1/status")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "timmy" in data
|
||||
assert "model" in data
|
||||
assert "uptime" in data
|
||||
|
||||
def test_v1_chat_history():
|
||||
# Append a message first to ensure history is not empty
|
||||
from dashboard.store import message_log
|
||||
message_log.append(role="user", content="test message", timestamp="12:00:00", source="api-v1")
|
||||
|
||||
response = client.get("/api/v1/chat/history")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "messages" in data
|
||||
assert len(data["messages"]) > 0
|
||||
# The message_log.recent() returns reversed(rows) so the last one should be our test message
|
||||
assert data["messages"][-1]["content"] == "test message"
|
||||
|
||||
def test_v1_upload_fail():
|
||||
# Test without file
|
||||
response = client.post("/api/v1/upload")
|
||||
assert response.status_code == 422 # Unprocessable Entity (missing file)
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Running API v1 tests...")
|
||||
try:
|
||||
test_v1_status()
|
||||
print("✓ Status test passed")
|
||||
test_v1_chat_history()
|
||||
print("✓ History test passed")
|
||||
test_v1_upload_fail()
|
||||
print("✓ Upload failure test passed")
|
||||
print("All tests passed!")
|
||||
except Exception as e:
|
||||
print(f"Test failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
Reference in New Issue
Block a user