feat: unify card runtime and event-driven web ui
This commit is contained in:
parent
0edf8c3fef
commit
4dfb7ca3cc
105 changed files with 17382 additions and 8505 deletions
19
routes/__init__.py
Normal file
19
routes/__init__.py
Normal 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
108
routes/cards.py
Normal 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
169
routes/inbox.py
Normal 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
114
routes/messages.py
Normal 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
29
routes/rtc.py
Normal 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
132
routes/sessions.py
Normal 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
92
routes/templates.py
Normal 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
138
routes/tools.py
Normal 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
103
routes/workbench.py
Normal 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})
|
||||
Loading…
Add table
Add a link
Reference in a new issue