"""Simple shareable chat page and tokenized public chat APIs.""" from __future__ import annotations import secrets from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi.responses import HTMLResponse from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession from app.api.mid.dialogue import ( get_default_kb_tool_runner, get_feature_flag_service, get_high_risk_handler, get_interrupt_context_enricher, get_metrics_collector, get_output_guardrail_executor, get_policy_router, get_runtime_observer, get_segment_humanizer, get_timeout_governor, get_trace_logger, respond_dialogue, ) from app.core.config import get_settings from app.core.database import get_session from app.core.tenant import clear_tenant_context, set_tenant_context from app.models.mid.schemas import DialogueRequest, HistoryMessage from app.services.openapi.share_token_service import get_share_token_service router = APIRouter(prefix="/openapi/v1/share", tags=["OpenAPI Share Page"]) SHARE_DEVICE_COOKIE = "share_device_id" class CreatePublicShareTokenRequest(BaseModel): tenant_id: str | None = Field(default=None, description="Tenant id (optional, fallback to X-Tenant-Id)") api_key: str | None = Field(default=None, description="API key (optional, fallback to X-API-Key)") session_id: str = Field(..., description="Shared session id") user_id: str | None = Field(default=None, description="Optional default user id") expires_in_minutes: int = Field(default=60, ge=1, le=1440, description="Token ttl in minutes") class CreatePublicShareTokenResponse(BaseModel): share_token: str share_url: str expires_at: str class PublicShareChatRequest(BaseModel): message: str = Field(..., min_length=1, max_length=2000) history: list[HistoryMessage] = Field(default_factory=list) user_id: str | None = None def _get_or_create_device_id(request: Request) -> tuple[str, bool]: existing = request.cookies.get(SHARE_DEVICE_COOKIE) if existing: return existing, False return secrets.token_urlsafe(16), True @router.post("/token", response_model=CreatePublicShareTokenResponse, summary="Create a public one-time share token") async def create_public_share_token(request: Request, body: CreatePublicShareTokenRequest) -> CreatePublicShareTokenResponse: tenant_id = body.tenant_id or request.headers.get("X-Tenant-Id") api_key = body.api_key or request.headers.get("X-API-Key") if not tenant_id or not api_key: raise HTTPException(status_code=400, detail="tenant_id/api_key missing") service = get_share_token_service() token, expires_at = await service.create_token( tenant_id=tenant_id, api_key=api_key, session_id=body.session_id, user_id=body.user_id, expires_in_minutes=body.expires_in_minutes, ) # Use configured base URL if available, otherwise fallback to request base_url settings = get_settings() if settings.share_link_base_url: base_url = settings.share_link_base_url.rstrip("/") else: base_url = str(request.base_url).rstrip("/") share_url = f"{base_url}/openapi/v1/share/chat?token={token}" return CreatePublicShareTokenResponse( share_token=token, share_url=share_url, expires_at=expires_at.isoformat(), ) @router.post("/chat/{chat_token}", summary="Public chat via consumed share token") async def public_chat_via_share_token( chat_token: str, body: PublicShareChatRequest, request: Request, session: Annotated[AsyncSession, Depends(get_session)], ): service = get_share_token_service() device_id, _ = _get_or_create_device_id(request) grant = await service.get_chat_grant_for_device(chat_token, device_id) if not grant: raise HTTPException(status_code=403, detail="This share link is bound to another device or expired") set_tenant_context(grant.tenant_id) request.state.tenant_id = grant.tenant_id try: mid_request = DialogueRequest( session_id=grant.session_id, user_id=body.user_id or grant.user_id, user_message=body.message, history=body.history, ) result = await respond_dialogue( request=request, dialogue_request=mid_request, session=session, policy_router=get_policy_router(), high_risk_handler=get_high_risk_handler(), timeout_governor=get_timeout_governor(), feature_flag_service=get_feature_flag_service(), trace_logger=get_trace_logger(), metrics_collector=get_metrics_collector(), output_guardrail_executor=get_output_guardrail_executor(), interrupt_context_enricher=get_interrupt_context_enricher(), default_kb_tool_runner=get_default_kb_tool_runner(), segment_humanizer=get_segment_humanizer(), runtime_observer=get_runtime_observer(), ) finally: clear_tenant_context() merged_reply = "\n\n".join([s.text for s in result.segments if s.text]) return { "request_id": result.trace.request_id, "reply": merged_reply, "segments": [s.model_dump() for s in result.segments], "trace": result.trace.model_dump(), } @router.get("/chat", response_class=HTMLResponse, summary="Shareable chat page") async def share_chat_page( request: Request, token: Annotated[str | None, Query(description="One-time share token")] = None, ) -> HTMLResponse: service = get_share_token_service() device_id, is_new_cookie = _get_or_create_device_id(request) chat_token = "" token_error = "" if token: claim = await service.claim_or_reuse(token, device_id) if claim.ok and claim.grant: chat_token = claim.grant.chat_token elif claim.status in {"invalid", "expired"}: token_error = "分享链接已失效" elif claim.status == "forbidden": token_error = "该链接已绑定到其他设备,无法访问" else: token_error = "分享链接不可用" else: token_error = "缺少分享 token" html = f""" 对话分享

今天有什么可以帮到你?

""" response = HTMLResponse( content=html, headers={ "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0", "Pragma": "no-cache", "Expires": "0", }, ) if is_new_cookie: response.set_cookie( key=SHARE_DEVICE_COOKIE, value=device_id, httponly=True, samesite="lax", secure=False, max_age=30 * 24 * 3600, ) return response