Compare commits

...

3 Commits

Author SHA1 Message Date
Manus
36d1bdb521 feat: enhance v1 API with streaming and improved history
Some checks failed
Tests / lint (pull_request) Failing after 3s
Tests / test (pull_request) Has been skipped
2026-03-19 20:23:37 -04:00
Manus
964f28a86f fix: address linting and formatting issues for v1 API
All checks were successful
Tests / lint (pull_request) Successful in 5s
Tests / test (pull_request) Successful in 52s
2026-03-18 17:51:10 -04:00
Manus
55dda093c8 feat: implement v1 API endpoints for iPad app
Some checks failed
Tests / lint (pull_request) Failing after 5s
Tests / test (pull_request) Has been skipped
2026-03-18 17:41:00 -04:00
3 changed files with 270 additions and 0 deletions

View File

@@ -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)

View 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
View 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)