feat: unify card runtime and event-driven web ui
Some checks failed
CI / Backend Checks (push) Failing after 36s
CI / Frontend Checks (push) Failing after 40s

This commit is contained in:
kacper 2026-04-06 15:42:53 -04:00
parent 0edf8c3fef
commit 4dfb7ca3cc
105 changed files with 17382 additions and 8505 deletions

19
routes/__init__.py Normal file
View file

@ -0,0 +1,19 @@
from .cards import router as cards_router
from .inbox import router as inbox_router
from .messages import router as messages_router
from .rtc import router as rtc_router
from .sessions import router as sessions_router
from .templates import router as templates_router
from .tools import router as tools_router
from .workbench import router as workbench_router
__all__ = [
"cards_router",
"inbox_router",
"messages_router",
"rtc_router",
"sessions_router",
"templates_router",
"tools_router",
"workbench_router",
]

108
routes/cards.py Normal file
View file

@ -0,0 +1,108 @@
from __future__ import annotations
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from app_dependencies import get_runtime
from card_store import (
delete_card,
load_card,
load_cards,
normalize_card_id,
parse_iso_datetime,
write_card,
)
from route_helpers import read_json_request
from session_store import normalize_session_chat_id
from web_runtime import WebAppRuntime
router = APIRouter()
@router.get("/cards")
async def get_cards(request: Request) -> JSONResponse:
chat_id = normalize_session_chat_id(str(request.query_params.get("chat_id", "web") or "web"))
if not chat_id:
return JSONResponse({"error": "invalid chat id"}, status_code=400)
return JSONResponse(load_cards(chat_id))
@router.delete("/cards/{card_id}")
async def delete_card_route(
card_id: str,
runtime: WebAppRuntime = Depends(get_runtime),
) -> JSONResponse:
if not normalize_card_id(card_id):
return JSONResponse({"error": "invalid card id"}, status_code=400)
card = load_card(card_id)
delete_card(card_id)
await runtime.publish_cards_changed(card.get("chat_id") if isinstance(card, dict) else None)
return JSONResponse({"status": "ok"})
@router.post("/cards/{card_id}/snooze")
async def snooze_card(
card_id: str,
request: Request,
runtime: WebAppRuntime = Depends(get_runtime),
) -> JSONResponse:
if not normalize_card_id(card_id):
return JSONResponse({"error": "invalid card id"}, status_code=400)
try:
payload = await read_json_request(request)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
until_raw = str(payload.get("until", "")).strip()
until_dt = parse_iso_datetime(until_raw)
if until_dt is None:
return JSONResponse({"error": "until must be a valid ISO datetime"}, status_code=400)
card = load_card(card_id)
if card is None:
return JSONResponse({"error": "card not found"}, status_code=404)
card["snooze_until"] = until_dt.isoformat()
card["updated_at"] = datetime.now(timezone.utc).isoformat()
persisted = write_card(card)
if persisted is None:
return JSONResponse({"error": "failed to snooze card"}, status_code=500)
await runtime.publish_cards_changed(persisted.get("chat_id"))
return JSONResponse({"status": "ok", "card": persisted})
@router.post("/cards/{card_id}/state")
async def update_card_state(
card_id: str,
request: Request,
runtime: WebAppRuntime = Depends(get_runtime),
) -> JSONResponse:
if not normalize_card_id(card_id):
return JSONResponse({"error": "invalid card id"}, status_code=400)
try:
payload = await read_json_request(request)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
template_state = payload.get("template_state")
if not isinstance(template_state, dict):
return JSONResponse({"error": "template_state must be an object"}, status_code=400)
card = load_card(card_id)
if card is None:
return JSONResponse({"error": "card not found"}, status_code=404)
if str(card.get("kind", "")) != "text":
return JSONResponse({"error": "only text cards support template_state"}, status_code=400)
card["template_state"] = template_state
card["updated_at"] = datetime.now(timezone.utc).isoformat()
persisted = write_card(card)
if persisted is None:
return JSONResponse({"error": "failed to update card state"}, status_code=500)
await runtime.publish_cards_changed(persisted.get("chat_id"))
return JSONResponse({"status": "ok", "card": persisted})

169
routes/inbox.py Normal file
View file

@ -0,0 +1,169 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from app_dependencies import get_runtime
from message_pipeline import typed_message_from_api_notification
from nanobot_api_client import run_nanobot_agent_turn
from route_helpers import read_json_request
from web_runtime import WebAppRuntime
router = APIRouter()
@router.get("/inbox")
async def get_inbox(
request: Request, runtime: WebAppRuntime = Depends(get_runtime)
) -> JSONResponse:
status_filter = str(request.query_params.get("status", "") or "").strip() or None
kind_filter = str(request.query_params.get("kind", "") or "").strip() or None
include_closed = str(request.query_params.get("include_closed", "") or "").strip().lower() in {
"1",
"true",
"yes",
"on",
}
limit_raw = str(request.query_params.get("limit", "") or "").strip()
try:
limit = max(1, min(int(limit_raw), 50)) if limit_raw else None
except ValueError:
return JSONResponse({"error": "limit must be an integer"}, status_code=400)
tags_raw = request.query_params.getlist("tag")
tags: list[str] = []
for raw in tags_raw:
for part in str(raw).split(","):
part_clean = part.strip()
if part_clean:
tags.append(part_clean)
try:
items = runtime.inbox_service.list_items(
status_filter=status_filter,
kind_filter=kind_filter,
include_closed=include_closed,
limit=limit,
tags=tags,
)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
except Exception as exc:
return JSONResponse({"error": f"failed to load inbox: {exc}"}, status_code=500)
return JSONResponse({"items": items})
@router.post("/inbox/capture")
async def capture_inbox_item(
request: Request,
runtime: WebAppRuntime = Depends(get_runtime),
) -> JSONResponse:
try:
payload = await read_json_request(request)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
title = str(payload.get("title", "") or "")
text = str(payload.get("text", "") or "")
kind = str(payload.get("kind", "unknown") or "unknown")
source = str(payload.get("source", "web-ui") or "web-ui")
due = str(payload.get("due", "") or "")
body = str(payload.get("body", "") or "")
session_id = str(payload.get("session_id", "") or "")
raw_tags = payload.get("tags", [])
tags = [str(tag) for tag in raw_tags] if isinstance(raw_tags, list) else []
confidence_value = payload.get("confidence")
confidence = confidence_value if isinstance(confidence_value, (int, float)) else None
try:
item = runtime.inbox_service.capture_item(
title=title,
text=text,
kind=kind,
source=source,
due=due,
body=body,
session_id=session_id,
tags=tags,
confidence=confidence,
)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
except Exception as exc:
return JSONResponse({"error": f"failed to capture inbox item: {exc}"}, status_code=500)
await runtime.publish_inbox_changed()
return JSONResponse({"status": "ok", "item": item}, status_code=201)
@router.post("/inbox/accept-task")
async def accept_inbox_item_as_task(
request: Request,
runtime: WebAppRuntime = Depends(get_runtime),
) -> JSONResponse:
try:
payload = await read_json_request(request)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
item = str(payload.get("item", "") or "").strip()
lane = str(payload.get("lane", "backlog") or "backlog").strip()
title = str(payload.get("title", "") or "")
due = str(payload.get("due", "") or "")
body = payload.get("body")
body_text = str(body) if isinstance(body, str) else None
raw_tags = payload.get("tags")
tags = [str(tag) for tag in raw_tags] if isinstance(raw_tags, list) else None
if not item:
return JSONResponse({"error": "item is required"}, status_code=400)
try:
result = await runtime.inbox_service.accept_item_as_task(
item=item,
lane=lane,
title=title,
due=due,
body_text=body_text,
tags=tags,
run_nanobot_turn=run_nanobot_agent_turn,
notification_to_event=lambda obj: typed_message_from_api_notification(
obj,
default_chat_id="inbox-groomer",
),
)
except (FileNotFoundError, ValueError) as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
except RuntimeError as exc:
return JSONResponse({"error": str(exc)}, status_code=503)
except Exception as exc:
return JSONResponse({"error": f"failed to accept inbox item: {exc}"}, status_code=500)
await runtime.publish_inbox_changed()
await runtime.publish_cards_changed()
return JSONResponse(result)
@router.post("/inbox/dismiss")
async def dismiss_inbox_item(
request: Request,
runtime: WebAppRuntime = Depends(get_runtime),
) -> JSONResponse:
try:
payload = await read_json_request(request)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
item = str(payload.get("item", "") or "").strip()
if not item:
return JSONResponse({"error": "item is required"}, status_code=400)
try:
item_payload = runtime.inbox_service.dismiss_item(item)
except (FileNotFoundError, ValueError) as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
except Exception as exc:
return JSONResponse({"error": f"failed to dismiss inbox item: {exc}"}, status_code=500)
await runtime.publish_inbox_changed()
return JSONResponse({"status": "ok", "item": item_payload})

114
routes/messages.py Normal file
View file

@ -0,0 +1,114 @@
from __future__ import annotations
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse, StreamingResponse
from app_dependencies import get_runtime
from message_pipeline import (
context_label_from_message_metadata,
encode_sse_data,
typed_message_from_api_notification,
)
from nanobot_api_client import stream_nanobot_agent_turn
from route_helpers import read_json_request
from session_store import normalize_session_chat_id
from web_runtime import WebAppRuntime
router = APIRouter()
@router.post("/message")
async def post_message(
request: Request,
runtime: WebAppRuntime = Depends(get_runtime),
) -> JSONResponse:
try:
payload = await read_json_request(request)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
text = str(payload.get("text", "")).strip()
metadata = payload.get("metadata", {})
chat_id = normalize_session_chat_id(str(payload.get("chat_id", "web") or "web"))
if not text:
return JSONResponse({"error": "empty message"}, status_code=400)
if not chat_id:
return JSONResponse({"error": "invalid chat id"}, status_code=400)
if not isinstance(metadata, dict):
metadata = {}
try:
await runtime.gateway.send_user_message(text, metadata=metadata, chat_id=chat_id)
except RuntimeError as exc:
return JSONResponse({"error": str(exc)}, status_code=503)
return JSONResponse({"status": "ok"})
@router.post("/message/stream")
async def stream_message(
request: Request,
runtime: WebAppRuntime = Depends(get_runtime),
):
try:
payload = await read_json_request(request)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
text = str(payload.get("text", "")).strip()
metadata = payload.get("metadata", {})
chat_id = normalize_session_chat_id(str(payload.get("chat_id", "web") or "web"))
if not text:
return JSONResponse({"error": "empty message"}, status_code=400)
if not chat_id:
return JSONResponse({"error": "invalid chat id"}, status_code=400)
if not isinstance(metadata, dict):
metadata = {}
context_label = context_label_from_message_metadata(metadata)
async def stream_turn():
try:
yield ": stream-open\n\n"
yield encode_sse_data(
{
"type": "message",
"role": "user",
"content": text,
"is_progress": False,
"is_tool_hint": False,
"timestamp": datetime.now(timezone.utc).isoformat(),
"context_label": context_label or None,
}
)
async for typed_event in stream_nanobot_agent_turn(
text,
chat_id=chat_id,
metadata=metadata,
timeout_seconds=60.0,
notification_to_event=lambda obj: typed_message_from_api_notification(
obj,
default_chat_id=chat_id,
),
):
if typed_event.get("type") == "card":
await runtime.publish_cards_changed(
typed_event.get("chat_id") if isinstance(typed_event, dict) else chat_id
)
elif typed_event.get("type") == "workbench":
await runtime.publish_workbench_changed(
typed_event.get("chat_id") if isinstance(typed_event, dict) else chat_id
)
yield encode_sse_data(typed_event)
await runtime.publish_sessions_changed()
except RuntimeError as exc:
yield encode_sse_data({"type": "error", "error": str(exc)})
return StreamingResponse(
stream_turn(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)

29
routes/rtc.py Normal file
View file

@ -0,0 +1,29 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from app_dependencies import get_runtime
from route_helpers import read_json_request
from web_runtime import WebAppRuntime
router = APIRouter()
@router.post("/rtc/offer")
async def rtc_offer(
request: Request,
runtime: WebAppRuntime = Depends(get_runtime),
) -> JSONResponse:
try:
payload = await read_json_request(request)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
answer = await runtime.rtc_manager.handle_offer(payload)
if answer is None:
return JSONResponse(
{"error": "WebRTC backend unavailable on host (aiortc is not installed)."},
status_code=503,
)
return JSONResponse(answer)

132
routes/sessions.py Normal file
View file

@ -0,0 +1,132 @@
from __future__ import annotations
import asyncio
import uuid
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse, StreamingResponse
from app_dependencies import get_runtime
from message_pipeline import encode_sse_data
from route_helpers import read_json_request
from session_store import (
create_session,
delete_session,
is_web_session_chat_id,
list_web_sessions,
load_session_payload,
normalize_session_chat_id,
rename_session,
)
from web_runtime import WebAppRuntime
router = APIRouter()
@router.get("/events")
async def stream_ui_events(request: Request, runtime: WebAppRuntime = Depends(get_runtime)):
chat_id = normalize_session_chat_id(str(request.query_params.get("chat_id", "web") or "web"))
if not chat_id:
return JSONResponse({"error": "invalid chat id"}, status_code=400)
subscription_id, queue = await runtime.event_bus.subscribe(chat_id)
async def stream_events():
try:
yield ": stream-open\n\n"
yield encode_sse_data({"type": "events.ready", "chat_id": chat_id})
while True:
if await request.is_disconnected():
break
try:
payload = await asyncio.wait_for(queue.get(), timeout=20.0)
except asyncio.TimeoutError:
yield ": keepalive\n\n"
continue
yield encode_sse_data(payload)
finally:
await runtime.event_bus.unsubscribe(subscription_id)
return StreamingResponse(
stream_events(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.get("/sessions")
async def get_sessions() -> JSONResponse:
return JSONResponse({"sessions": list_web_sessions()})
@router.get("/sessions/{chat_id}")
async def get_session(chat_id: str) -> JSONResponse:
session_chat_id = normalize_session_chat_id(chat_id)
if not session_chat_id or not is_web_session_chat_id(session_chat_id):
return JSONResponse({"error": "invalid session id"}, status_code=400)
payload = load_session_payload(session_chat_id)
if payload is None:
return JSONResponse({"error": "session not found"}, status_code=404)
return JSONResponse(payload)
@router.post("/sessions")
async def create_session_route(
request: Request,
runtime: WebAppRuntime = Depends(get_runtime),
) -> JSONResponse:
try:
payload = await read_json_request(request)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
title = str(payload.get("title", "")).strip()
chat_id = f"web-{uuid.uuid4().hex[:8]}"
summary = create_session(chat_id, title=title)
await runtime.publish_sessions_changed()
return JSONResponse({"session": summary}, status_code=201)
@router.patch("/sessions/{chat_id}")
async def rename_session_route(
chat_id: str,
request: Request,
runtime: WebAppRuntime = Depends(get_runtime),
) -> JSONResponse:
session_chat_id = normalize_session_chat_id(chat_id)
if not session_chat_id or not is_web_session_chat_id(session_chat_id):
return JSONResponse({"error": "invalid session id"}, status_code=400)
try:
payload = await read_json_request(request)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
title = str(payload.get("title", "")).strip()
summary = rename_session(session_chat_id, title)
if summary is None:
return JSONResponse({"error": "session not found"}, status_code=404)
await runtime.publish_sessions_changed()
return JSONResponse({"session": summary})
@router.delete("/sessions/{chat_id}")
async def delete_session_route(
chat_id: str,
runtime: WebAppRuntime = Depends(get_runtime),
) -> JSONResponse:
session_chat_id = normalize_session_chat_id(chat_id)
if not session_chat_id or not is_web_session_chat_id(session_chat_id):
return JSONResponse({"error": "invalid session id"}, status_code=400)
if not delete_session(session_chat_id, runtime.delete_cards_for_chat):
return JSONResponse({"error": "session not found"}, status_code=404)
await runtime.publish_sessions_changed()
await runtime.publish_cards_changed()
return JSONResponse({"status": "ok", "chat_id": session_chat_id})

92
routes/templates.py Normal file
View file

@ -0,0 +1,92 @@
from __future__ import annotations
import json
import shutil
from datetime import datetime, timezone
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from card_store import (
list_templates,
normalize_template_key,
read_template_meta,
sync_templates_context_file,
template_dir,
template_html_path,
template_meta_path,
)
from route_helpers import read_json_request
router = APIRouter()
@router.get("/templates")
async def get_templates() -> JSONResponse:
return JSONResponse(list_templates())
@router.post("/templates")
async def save_template(request: Request) -> JSONResponse:
try:
payload = await read_json_request(request)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
key = normalize_template_key(str(payload.get("key", "")))
title = str(payload.get("title", "")).strip()
content = str(payload.get("content", "")).strip()
notes = str(payload.get("notes", "")).strip()
example_state = payload.get("example_state", {})
if not isinstance(example_state, dict):
example_state = {}
if not key:
return JSONResponse({"error": "template key is required"}, status_code=400)
if not content:
return JSONResponse({"error": "template content is required"}, status_code=400)
current_template_dir = template_dir(key)
current_template_dir.mkdir(parents=True, exist_ok=True)
now = datetime.now(timezone.utc).isoformat()
existing_meta = read_template_meta(key)
created_at = str(existing_meta.get("created_at") or now)
template_html_path(key).write_text(content, encoding="utf-8")
template_meta_path(key).write_text(
json.dumps(
{
"key": key,
"title": title,
"notes": notes,
"example_state": example_state,
"created_at": created_at,
"updated_at": now,
},
indent=2,
ensure_ascii=False,
)
+ "\n",
encoding="utf-8",
)
sync_templates_context_file()
return JSONResponse(
{
"status": "ok",
"id": key,
"key": key,
"example_state": example_state,
"file_url": f"/card-templates/{key}/template.html",
}
)
@router.delete("/templates/{template_key}")
async def delete_template(template_key: str) -> JSONResponse:
key = normalize_template_key(template_key)
if not key:
return JSONResponse({"error": "invalid template key"}, status_code=400)
shutil.rmtree(template_dir(key), ignore_errors=True)
sync_templates_context_file()
return JSONResponse({"status": "ok", "key": key})

138
routes/tools.py Normal file
View file

@ -0,0 +1,138 @@
from __future__ import annotations
import asyncio
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse, StreamingResponse
from app_dependencies import get_runtime
from message_pipeline import encode_sse_data
from nanobot_api_client import NanobotApiError, send_nanobot_api_request
from route_helpers import read_json_request
from web_runtime import WebAppRuntime
router = APIRouter()
@router.get("/tools")
async def list_tools() -> JSONResponse:
try:
result = await send_nanobot_api_request("tool.list", {}, timeout_seconds=20.0)
except NanobotApiError as exc:
status_code = 503 if exc.code == -32000 else 502
return JSONResponse({"error": str(exc)}, status_code=status_code)
except RuntimeError as exc:
return JSONResponse({"error": str(exc)}, status_code=503)
if not isinstance(result, dict):
return JSONResponse({"error": "Nanobot API returned an invalid tool list"}, status_code=502)
tools = result.get("tools", [])
if not isinstance(tools, list):
return JSONResponse({"error": "Nanobot API returned an invalid tool list"}, status_code=502)
return JSONResponse({"tools": tools})
@router.post("/tools/call")
async def call_tool(
request: Request, runtime: WebAppRuntime = Depends(get_runtime)
) -> JSONResponse:
try:
payload = await read_json_request(request)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
tool_name = str(payload.get("tool_name", payload.get("name", ""))).strip()
if not tool_name:
return JSONResponse({"error": "tool_name is required"}, status_code=400)
arguments = payload.get("arguments", payload.get("params", {}))
if arguments is None:
arguments = {}
if not isinstance(arguments, dict):
return JSONResponse({"error": "arguments must be a JSON object"}, status_code=400)
async_requested = payload.get("async") is True
if async_requested:
job_payload = await runtime.tool_job_service.start_job(tool_name, arguments)
return JSONResponse(job_payload, status_code=202)
try:
result = await send_nanobot_api_request(
"tool.call",
{"name": tool_name, "arguments": arguments},
timeout_seconds=60.0,
)
except NanobotApiError as exc:
status_code = 400 if exc.code == -32602 else 503 if exc.code == -32000 else 502
return JSONResponse({"error": str(exc)}, status_code=status_code)
except RuntimeError as exc:
return JSONResponse({"error": str(exc)}, status_code=503)
if not isinstance(result, dict):
return JSONResponse(
{"error": "Nanobot API returned an invalid tool response"}, status_code=502
)
return JSONResponse(result)
@router.get("/tools/jobs/{job_id}")
async def get_tool_job(job_id: str, runtime: WebAppRuntime = Depends(get_runtime)) -> JSONResponse:
safe_job_id = job_id.strip()
if not safe_job_id:
return JSONResponse({"error": "job id is required"}, status_code=400)
payload = await runtime.tool_job_service.get_job(safe_job_id)
if payload is None:
return JSONResponse({"error": "tool job not found"}, status_code=404)
return JSONResponse(payload)
@router.get("/tools/jobs/{job_id}/stream")
async def stream_tool_job(
job_id: str,
request: Request,
runtime: WebAppRuntime = Depends(get_runtime),
):
safe_job_id = job_id.strip()
if not safe_job_id:
return JSONResponse({"error": "job id is required"}, status_code=400)
subscription = await runtime.tool_job_service.subscribe_job(safe_job_id)
if subscription is None:
return JSONResponse({"error": "tool job not found"}, status_code=404)
subscription_id, queue = subscription
current = await runtime.tool_job_service.get_job(safe_job_id)
if current is None:
await runtime.tool_job_service.unsubscribe_job(safe_job_id, subscription_id)
return JSONResponse({"error": "tool job not found"}, status_code=404)
async def stream_events():
try:
yield ": stream-open\n\n"
yield encode_sse_data({"type": "tool.job", "job": current})
if current.get("status") in {"completed", "failed"}:
return
while True:
if await request.is_disconnected():
break
try:
payload = await asyncio.wait_for(queue.get(), timeout=20.0)
except asyncio.TimeoutError:
yield ": keepalive\n\n"
continue
yield encode_sse_data({"type": "tool.job", "job": payload})
if payload.get("status") in {"completed", "failed"}:
break
finally:
await runtime.tool_job_service.unsubscribe_job(safe_job_id, subscription_id)
return StreamingResponse(
stream_events(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)

103
routes/workbench.py Normal file
View file

@ -0,0 +1,103 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from app_dependencies import get_runtime
from card_store import normalize_card_id
from route_helpers import read_json_request
from session_store import normalize_session_chat_id
from web_runtime import WebAppRuntime
from workbench_store import (
delete_workbench_item,
load_workbench_items,
persist_workbench_item,
promote_workbench_item,
)
router = APIRouter()
@router.get("/workbench")
async def get_workbench(request: Request) -> JSONResponse:
chat_id = normalize_session_chat_id(str(request.query_params.get("chat_id", "web") or "web"))
if not chat_id:
return JSONResponse({"error": "invalid chat id"}, status_code=400)
try:
items = load_workbench_items(chat_id)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
except Exception as exc:
return JSONResponse({"error": f"failed to load workbench: {exc}"}, status_code=500)
return JSONResponse({"items": items})
@router.post("/workbench")
async def upsert_workbench(
request: Request,
runtime: WebAppRuntime = Depends(get_runtime),
) -> JSONResponse:
try:
payload = await read_json_request(request)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
item = persist_workbench_item(payload)
if item is None:
return JSONResponse({"error": "invalid workbench payload"}, status_code=400)
await runtime.publish_workbench_changed(item.get("chat_id"))
return JSONResponse({"status": "ok", "item": item}, status_code=201)
@router.delete("/workbench/{item_id}")
async def delete_workbench(
item_id: str,
request: Request,
runtime: WebAppRuntime = Depends(get_runtime),
) -> JSONResponse:
chat_id = normalize_session_chat_id(str(request.query_params.get("chat_id", "web") or "web"))
if not chat_id:
return JSONResponse({"error": "invalid chat id"}, status_code=400)
if not normalize_card_id(item_id):
return JSONResponse({"error": "invalid workbench item id"}, status_code=400)
try:
removed = delete_workbench_item(chat_id, item_id)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
except Exception as exc:
return JSONResponse({"error": f"failed to delete workbench item: {exc}"}, status_code=500)
if not removed:
return JSONResponse({"error": "workbench item not found"}, status_code=404)
await runtime.publish_workbench_changed(chat_id)
return JSONResponse({"status": "ok", "item_id": item_id})
@router.post("/workbench/{item_id}/promote")
async def promote_workbench(
item_id: str,
request: Request,
runtime: WebAppRuntime = Depends(get_runtime),
) -> JSONResponse:
try:
payload = await read_json_request(request)
except ValueError:
payload = {}
chat_id = normalize_session_chat_id(str(payload.get("chat_id", "web") or "web"))
if not chat_id:
return JSONResponse({"error": "invalid chat id"}, status_code=400)
if not normalize_card_id(item_id):
return JSONResponse({"error": "invalid workbench item id"}, status_code=400)
try:
card = promote_workbench_item(chat_id, item_id)
except FileNotFoundError:
return JSONResponse({"error": "workbench item not found"}, status_code=404)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
except RuntimeError as exc:
return JSONResponse({"error": str(exc)}, status_code=500)
except Exception as exc:
return JSONResponse({"error": f"failed to promote workbench item: {exc}"}, status_code=500)
await runtime.publish_workbench_changed(chat_id)
await runtime.publish_cards_changed(card.get("chat_id"))
return JSONResponse({"status": "ok", "card": card, "item_id": item_id})