ai-robot-core/ai-service/app/api/openapi/share_page.py

473 lines
16 KiB
Python

"""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"""
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>对话分享</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: #f8f9fa;
min-height: 100vh;
display: flex;
flex-direction: column;
}}
.welcome-screen {{
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
}}
.welcome-screen.hidden {{ display: none; }}
.welcome-input-wrapper {{
width: 100%;
max-width: 680px;
background: white;
border-radius: 16px;
padding: 16px 20px;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
}}
.welcome-textarea, .input-textarea {{
width: 100%;
min-height: 56px;
border: 1px solid #e5e5e5;
border-radius: 12px;
padding: 12px 16px;
resize: none;
outline: none;
font-size: 15px;
line-height: 1.6;
font-family: inherit;
background: #fafafa;
transition: all 0.2s;
}}
.welcome-textarea:focus, .input-textarea:focus {{
border-color: #1677ff;
background: white;
}}
.chat-screen {{ flex: 1; display: none; flex-direction: column; }}
.chat-screen.active {{ display: flex; }}
.chat-list {{
flex: 1;
padding: 20px;
max-width: 800px;
width: 100%;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
}}
.bubble {{ display: flex; gap: 10px; align-items: flex-start; }}
.bubble.user {{ flex-direction: row-reverse; }}
.avatar {{
width: 32px; height: 32px; border-radius: 50%;
background: white; display: flex; align-items: center; justify-content: center;
}}
.bubble-content {{ max-width: 75%; }}
.bubble-text {{
padding: 12px 16px; border-radius: 16px; white-space: pre-wrap; word-break: break-word;
font-size: 14px; line-height: 1.6;
}}
.bubble.user .bubble-text {{ background: #1677ff; color: white; }}
.bubble.bot .bubble-text {{ background: white; color: #333; }}
.bubble.error .bubble-text {{ background: #fff2f0; color: #ff4d4f; border: 1px solid #ffccc7; }}
.thought-block {{
background: #f5f5f5;
color: #888;
padding: 12px 16px;
border-radius: 12px;
margin-bottom: 12px;
font-size: 13px;
line-height: 1.6;
border-left: 3px solid #ddd;
}}
.thought-label {{
font-weight: 600;
color: #999;
margin-bottom: 6px;
font-size: 12px;
}}
.final-answer-block {{
background: white;
color: #333;
padding: 12px 16px;
border-radius: 12px;
font-size: 14px;
line-height: 1.6;
}}
.final-answer-label {{
font-weight: 600;
color: #1677ff;
margin-bottom: 6px;
font-size: 12px;
}}
.input-area {{ background: white; padding: 16px 20px 20px; border-top: 1px solid #eee; }}
.input-wrapper {{ max-width: 800px; margin: 0 auto; display: flex; gap: 12px; align-items: flex-end; }}
.send-btn, .welcome-send {{
width: 40px; height: 40px; border-radius: 50%; border: none; cursor: pointer;
background: #1677ff; color: white;
}}
.status {{ text-align: center; padding: 8px; font-size: 12px; color: #999; }}
.status.error {{ color: #ff4d4f; }}
</style>
</head>
<body>
<div class="welcome-screen" id="welcomeScreen">
<h1>今天有什么可以帮到你?</h1>
<div class="welcome-input-wrapper">
<textarea class="welcome-textarea" id="welcomeInput" placeholder="输入消息,按 Enter 发送" rows="1"></textarea>
<button class="welcome-send" id="welcomeSendBtn">➤</button>
</div>
</div>
<div class="chat-screen" id="chatScreen">
<div class="chat-list" id="chatList"></div>
<div class="input-area">
<div class="input-wrapper">
<textarea class="input-textarea" id="chatInput" placeholder="输入消息..." rows="1"></textarea>
<button class="send-btn" id="chatSendBtn">➤</button>
</div>
<div class="status" id="status"></div>
</div>
</div>
<script>
if (window.location.search.includes('token=')) {{
window.history.replaceState(null, '', '/openapi/v1/share/chat');
}}
const CHAT_TOKEN = {chat_token!r};
const TOKEN_ERROR = {token_error!r};
const welcomeScreen = document.getElementById('welcomeScreen');
const chatScreen = document.getElementById('chatScreen');
const welcomeInput = document.getElementById('welcomeInput');
const welcomeSendBtn = document.getElementById('welcomeSendBtn');
const chatInput = document.getElementById('chatInput');
const chatSendBtn = document.getElementById('chatSendBtn');
const chatList = document.getElementById('chatList');
const statusEl = document.getElementById('status');
const chatHistory = [];
let sending = false;
let started = false;
function setStatus(text, type) {{
statusEl.textContent = text || '';
statusEl.className = 'status ' + (type || '');
}}
function formatBotMessage(text) {{
const thoughtKey = 'Thought:';
const finalKey = 'Final Answer:';
const thoughtIdx = text.indexOf(thoughtKey);
const finalIdx = text.indexOf(finalKey);
if (thoughtIdx === -1 && finalIdx === -1) {{
return '<div class="bubble-text">' + text + '</div>';
}}
let html = '';
if (thoughtIdx !== -1) {{
const thoughtStart = thoughtIdx + thoughtKey.length;
const thoughtEnd = finalIdx !== -1 ? finalIdx : text.length;
const thoughtContent = text.slice(thoughtStart, thoughtEnd).trim().split('\\n').join('<br>');
if (thoughtContent) {{
html += '<div class="thought-block"><div class="thought-label">💭 思考过程</div><div>' + thoughtContent + '</div></div>';
}}
}}
if (finalIdx !== -1) {{
const answerStart = finalIdx + finalKey.length;
const answerContent = text.slice(answerStart).trim().split('\\n').join('<br>');
if (answerContent) {{
html += '<div class="final-answer-block"><div class="final-answer-label">✨ 回答</div><div>' + answerContent + '</div></div>';
}}
}}
return html || '<div class="bubble-text">' + text + '</div>';
}}
function addMessage(role, text) {{
const div = document.createElement('div');
div.className = 'bubble ' + role;
const avatar = role === 'user' ? '👤' : (role === 'bot' ? '🤖' : '⚠️');
let contentHtml;
if (role === 'bot') {{
contentHtml = formatBotMessage(text);
}} else {{
contentHtml = '<div class="bubble-text">' + text + '</div>';
}}
div.innerHTML = '<div class="avatar">' + avatar + '</div><div class="bubble-content">' + contentHtml + '</div>';
chatList.appendChild(div);
chatList.scrollTop = chatList.scrollHeight;
}}
function switchToChat() {{
if (!started) {{
welcomeScreen.classList.add('hidden');
chatScreen.classList.add('active');
started = true;
}}
}}
async function sendMessage(fromWelcome) {{
if (sending) return;
const input = fromWelcome ? welcomeInput : chatInput;
const message = (input.value || '').trim();
if (!message) return;
if (!CHAT_TOKEN) {{
setStatus(TOKEN_ERROR || '链接无效', 'error');
return;
}}
switchToChat();
addMessage('user', message);
chatHistory.push({{ role: 'user', content: message }});
input.value = '';
sending = true;
chatSendBtn.disabled = true;
welcomeSendBtn.disabled = true;
setStatus('发送中...');
try {{
const resp = await fetch('/openapi/v1/share/chat/' + encodeURIComponent(CHAT_TOKEN), {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{ message, history: chatHistory }})
}});
const data = await resp.json().catch(() => ({{}}));
if (!resp.ok) {{
const err = data?.detail || data?.message || '请求失败';
addMessage('error', '发送失败:' + err);
setStatus('发送失败', 'error');
return;
}}
const reply = data.reply || '(无回复)';
addMessage('bot', reply);
chatHistory.push({{ role: 'assistant', content: reply }});
setStatus('');
}} catch (e) {{
addMessage('error', '网络异常,请稍后重试');
setStatus('网络异常', 'error');
}} finally {{
sending = false;
chatSendBtn.disabled = false;
welcomeSendBtn.disabled = false;
}}
}}
welcomeSendBtn.addEventListener('click', () => sendMessage(true));
chatSendBtn.addEventListener('click', () => sendMessage(false));
welcomeInput.addEventListener('keydown', (e) => {{ if (e.key === 'Enter' && !e.shiftKey) {{ e.preventDefault(); sendMessage(true); }} }});
chatInput.addEventListener('keydown', (e) => {{ if (e.key === 'Enter' && !e.shiftKey) {{ e.preventDefault(); sendMessage(false); }} }});
if (!CHAT_TOKEN) {{
welcomeInput.disabled = true;
welcomeSendBtn.disabled = true;
setStatus(TOKEN_ERROR || '链接无效', 'error');
}}
</script>
</body>
</html>
"""
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