A guest sees the conversations they started or were shared (list, detail, summary, search index, subagents, raw meta, diffs), the forms, asks and pushes of those, and the projects they were granted. Their ui-state lives under u.<id>.<key>; the SSE hub filters per subscriber. Forms record answeredBy. Pushes from a guest's session skip the owner's webhooks and go to the guest's channels (in-app, email). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
118 lines
4.7 KiB
Python
118 lines
4.7 KiB
Python
"""Forms: rich structured questions answered in the PWA."""
|
|
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
from pydantic import BaseModel
|
|
|
|
import schemas
|
|
from conversations.catalog import _conv_ids_by_session
|
|
from core.http import _json_in_worker, _r
|
|
from core.state import State
|
|
from forms.service import _create_form
|
|
from users import access
|
|
from users.caller import Caller, CallerDep
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# ── forms: rich structured questions answered in the PWA (forms.py) ──────────
|
|
class FormCreateBody(BaseModel):
|
|
title: str
|
|
fields: list[dict]
|
|
description: str | None = None
|
|
type: str | None = None
|
|
url: str | None = None
|
|
sessionId: str | None = None
|
|
id: str | None = None
|
|
# True: the hub pushes the "📋 <title>" notification itself, deep-linked
|
|
# to the conversation with ?form=<id> (the notify CLI's way; the legacy
|
|
# skill sent its own push after creating the form).
|
|
notify: bool | None = None
|
|
# Files the spec references as ``{{asset:<name>}}`` (an html block's image,
|
|
# video…), inlined base64 by the CLI so the same payload works through a
|
|
# worker's outbox: ``[{name, contentType?, data}]``. Stored like composer
|
|
# uploads and substituted by their serve URL before validation.
|
|
assets: list[dict] | None = None
|
|
|
|
|
|
class FormSubmitBody(BaseModel):
|
|
answers: dict
|
|
|
|
|
|
@router.post("/api/forms", responses=_r(schemas.FormRecord))
|
|
def form_create(deps: State, body: FormCreateBody):
|
|
"""Create a form (the ask-form skill's entry point) and return it.
|
|
|
|
The caller then sends its own push notification linking to the
|
|
conversation with ``?form=<id>`` and long-polls GET /api/forms/{id}."""
|
|
return _create_form(deps, body.model_dump())
|
|
|
|
|
|
@router.get("/api/forms", responses=_r(schemas.FormsResponse))
|
|
@_json_in_worker
|
|
def forms_list(deps: State, caller: CallerDep, session: str = "", limit: int = 100):
|
|
"""Forms, newest first; ``session`` filters to one conversation. Each form
|
|
with a known transcript carries ``conversationId`` — its way back to the
|
|
conversation it belongs to."""
|
|
items = deps.form_store.list(session_id=session or None, limit=limit)
|
|
if not caller.is_owner:
|
|
items = [f for f in items if f.get("sessionId")
|
|
and access.visible(deps, caller, f["sessionId"])]
|
|
if any(f.get("sessionId") for f in items):
|
|
by_sid = _conv_ids_by_session(deps, )
|
|
for f in items:
|
|
cid = by_sid.get(f.get("sessionId") or "")
|
|
if cid:
|
|
f["conversationId"] = cid
|
|
return {"forms": items, "count": len(items)}
|
|
|
|
|
|
def _form_visible(deps, caller: Caller, form: dict | None) -> dict:
|
|
"""The form, if ``caller`` may see it — a guest only their conversations'."""
|
|
if not form or not (caller.is_owner or (
|
|
form.get("sessionId")
|
|
and access.visible(deps, caller, form["sessionId"]))):
|
|
raise HTTPException(404, "unknown form")
|
|
return form
|
|
|
|
|
|
@router.get("/api/forms/{form_id}", responses=_r(schemas.FormRecord))
|
|
def form_get(deps: State, caller: CallerDep, form_id: str, waitSecs: float = 0):
|
|
"""The form's current state; ``waitSecs`` long-polls for a submit."""
|
|
if not caller.is_owner:
|
|
_form_visible(deps, caller, deps.form_store.get(form_id))
|
|
form = (deps.form_store.wait(form_id, waitSecs) if waitSecs > 0
|
|
else deps.form_store.get(form_id))
|
|
if not form:
|
|
raise HTTPException(404, "unknown form")
|
|
return form
|
|
|
|
|
|
@router.post("/api/forms/{form_id}/submit", responses=_r(schemas.FormRecord))
|
|
def form_submit(deps: State, caller: CallerDep, form_id: str, body: FormSubmitBody):
|
|
"""Record the user's answers (from the PWA's form card / modal)."""
|
|
_form_visible(deps, caller, deps.form_store.get(form_id))
|
|
try:
|
|
form = deps.form_store.submit(form_id, body.answers,
|
|
by=None if caller.is_owner else caller.id)
|
|
except ValueError as e:
|
|
raise HTTPException(400, str(e))
|
|
if not form:
|
|
raise HTTPException(404, "unknown form")
|
|
deps.hub.publish({"type": "form", "id": form_id,
|
|
"sessionId": form.get("sessionId") or ""})
|
|
return form
|
|
|
|
|
|
@router.post("/api/forms/{form_id}/cancel", responses=_r(schemas.FormRecord))
|
|
def form_cancel(deps: State, caller: CallerDep, form_id: str):
|
|
"""Dismiss a form unanswered (the card's Cancel button, or the agent
|
|
giving up on the wait)."""
|
|
_form_visible(deps, caller, deps.form_store.get(form_id))
|
|
form = deps.form_store.cancel(form_id, by=None if caller.is_owner else caller.id)
|
|
if not form:
|
|
raise HTTPException(404, "unknown form")
|
|
deps.hub.publish({"type": "form", "id": form_id,
|
|
"sessionId": form.get("sessionId") or ""})
|
|
return form
|