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

618 lines
22 KiB
Python
Raw Normal View History

"""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>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {{
--bg-primary: #f8fafc;
--bg-secondary: #ffffff;
--bg-tertiary: #f1f5f9;
--text-primary: #0f172a;
--text-secondary: #475569;
--text-muted: #94a3b8;
--accent: #6366f1;
--accent-hover: #4f46e5;
--accent-light: #eef2ff;
--border: #e2e8f0;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.06);
--shadow-lg: 0 8px 32px rgba(0,0,0,0.08);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 24px;
}}
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{
font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: var(--bg-primary);
min-height: 100vh;
display: flex;
flex-direction: column;
color: var(--text-primary);
line-height: 1.6;
}}
.welcome-screen {{
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-primary) 100%);
}}
.welcome-screen.hidden {{ display: none; }}
.welcome-title {{
font-size: 28px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 32px;
letter-spacing: -0.02em;
}}
.welcome-input-wrapper {{
width: 100%;
max-width: 600px;
background: var(--bg-secondary);
border-radius: var(--radius-xl);
padding: 8px;
box-shadow: var(--shadow-lg);
border: 1px solid var(--border);
display: flex;
align-items: flex-end;
gap: 8px;
transition: box-shadow 0.2s ease, border-color 0.2s ease;
}}
.welcome-input-wrapper:focus-within {{
box-shadow: var(--shadow-lg), 0 0 0 3px var(--accent-light);
border-color: var(--accent);
}}
.welcome-textarea, .input-textarea {{
flex: 1;
min-height: 48px;
max-height: 200px;
border: none;
border-radius: var(--radius-lg);
padding: 14px 16px;
resize: none;
outline: none;
font-size: 15px;
line-height: 1.5;
font-family: inherit;
background: transparent;
color: var(--text-primary);
}}
.welcome-textarea::placeholder, .input-textarea::placeholder {{
color: var(--text-muted);
}}
.chat-screen {{ flex: 1; display: none; flex-direction: column; background: var(--bg-primary); }}
.chat-screen.active {{ display: flex; }}
.chat-list {{
flex: 1;
padding: 24px;
max-width: 720px;
width: 100%;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 20px;
overflow-y: auto;
}}
.bubble {{ display: flex; gap: 12px; align-items: flex-start; animation: fadeIn 0.3s ease; }}
@keyframes fadeIn {{
from {{ opacity: 0; transform: translateY(8px); }}
to {{ opacity: 1; transform: translateY(0); }}
}}
.bubble.user {{ flex-direction: row-reverse; }}
.avatar {{
width: 36px; height: 36px; border-radius: 50%;
background: var(--bg-tertiary);
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
overflow: hidden;
}}
.avatar svg {{
width: 22px;
height: 22px;
}}
.bubble.user .avatar {{
background: var(--accent-light);
}}
.bubble.user .avatar svg path {{
fill: var(--accent);
}}
.bubble.bot .avatar svg path {{
fill: var(--accent);
}}
.bubble-content {{ max-width: 80%; min-width: 0; }}
.bubble-text {{
padding: 14px 18px;
border-radius: var(--radius-lg);
white-space: pre-wrap;
word-break: break-word;
font-size: 14px;
line-height: 1.65;
}}
.bubble.user .bubble-text {{
background: var(--accent);
color: white;
border-bottom-right-radius: var(--radius-sm);
}}
.bubble.bot .bubble-text {{
background: var(--bg-secondary);
color: var(--text-primary);
border-bottom-left-radius: var(--radius-sm);
box-shadow: var(--shadow-sm);
}}
.bubble.error .bubble-text {{
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}}
.thought-block {{
background: var(--bg-tertiary);
color: var(--text-secondary);
padding: 14px 18px;
border-radius: var(--radius-md);
margin-bottom: 12px;
font-size: 13px;
line-height: 1.65;
border-left: 3px solid var(--text-muted);
}}
.thought-label {{
font-weight: 600;
color: var(--text-muted);
margin-bottom: 8px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
}}
.final-answer-block {{
background: var(--bg-secondary);
color: var(--text-primary);
padding: 14px 18px;
border-radius: var(--radius-md);
font-size: 14px;
line-height: 1.65;
}}
.final-answer-label {{
font-weight: 600;
color: var(--accent);
margin-bottom: 8px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
}}
.input-area {{
background: var(--bg-secondary);
padding: 16px 24px 24px;
border-top: 1px solid var(--border);
}}
.input-wrapper {{
max-width: 720px;
margin: 0 auto;
display: flex;
gap: 12px;
align-items: flex-end;
background: var(--bg-tertiary);
border-radius: var(--radius-lg);
padding: 6px 6px 6px 16px;
border: 1px solid var(--border);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}}
.input-wrapper:focus-within {{
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-light);
}}
.input-textarea {{
background: transparent;
padding: 10px 0;
}}
.send-btn, .welcome-send {{
width: 44px; height: 44px;
border-radius: 50%;
border: none;
cursor: pointer;
background: var(--accent);
color: white;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease, transform 0.15s ease;
flex-shrink: 0;
}}
.send-btn:hover, .welcome-send:hover {{
background: var(--accent-hover);
transform: scale(1.05);
}}
.send-btn:active, .welcome-send:active {{
transform: scale(0.95);
}}
.send-btn:disabled, .welcome-send:disabled {{
background: var(--text-muted);
cursor: not-allowed;
transform: none;
}}
.status {{
text-align: center;
padding: 12px;
font-size: 13px;
color: var(--text-muted);
font-weight: 500;
}}
.status.error {{ color: #dc2626; }}
@media (max-width: 640px) {{
.welcome-screen {{ padding: 32px 16px; }}
.welcome-title {{ font-size: 22px; margin-bottom: 24px; }}
.chat-list {{ padding: 16px; gap: 16px; }}
.bubble-content {{ max-width: 85%; }}
.input-area {{ padding: 12px 16px 20px; }}
}}
</style>
</head>
<body>
<div class="welcome-screen" id="welcomeScreen">
<h1 class="welcome-title">今天有什么可以帮到你</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 userSvg = '<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M573.9 516.2L512 640l-61.9-123.8C232 546.4 64 733.6 64 960h896c0-226.4-168-413.6-386.1-443.8zM480 384h64c17.7 0 32.1 14.4 32.1 32.1 0 17.7-14.4 32.1-32.1 32.1h-64c-11.9 0-22.3-6.5-27.8-16.1H356c34.9 48.5 91.7 80 156 80 106 0 192-86 192-192s-86-192-192-192-192 86-192 192c0 28.5 6.2 55.6 17.4 80h114.8c5.5-9.6 15.9-16.1 27.8-16.1z"/><path d="M272 432.1h84c-4.2-5.9-8.1-12-11.7-18.4-2.3-4.1-4.4-8.3-6.4-12.5-0.2-0.4-0.4-0.7-0.5-1.1H288c-8.8 0-16-7.2-16-16v-48.4c0-64.1 25-124.3 70.3-169.6S447.9 95.8 512 95.8s124.3 25 169.7 70.3c38.3 38.3 62.1 87.2 68.5 140.2-8.4 4-14.2 12.5-14.2 22.4v78.6c0 13.7 11.1 24.8 24.8 24.8h14.6c13.7 0 24.8-11.1 24.8-24.8v-78.6c0-11.3-7.6-20.9-18-23.8-6.9-60.9-33.9-117.4-78-161.3C652.9 92.1 584.6 63.9 512 63.9s-140.9 28.3-192.3 79.6C268.3 194.8 240 263.1 240 335.7v64.4c0 17.7 14.3 32 32 32z"/></svg>';
const botSvg = '<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M894.1 355.6h-1.7C853 177.6 687.6 51.4 498.1 54.9S148.2 190.5 115.9 369.7c-35.2 5.6-61.1 36-61.1 71.7v143.4c0.9 40.4 34.3 72.5 74.7 71.7 21.7-0.3 42.2-10 56-26.7 33.6 84.5 99.9 152 183.8 187 1.1-2 2.3-3.9 3.7-5.7 0.9-1.5 2.4-2.6 4.1-3 1.3 0 2.5 0.5 3.6 1.2a318.46 318.46 0 0 1-105.3-187.1c-5.1-44.4 24.1-85.4 67.6-95.2 64.3-11.7 128.1-24.7 192.4-35.9 37.9-5.3 70.4-29.8 85.7-64.9 6.8-15.9 11-32.8 12.5-50 0.5-3.1 2.9-5.6 5.9-6.2 3.1-0.7 6.4 0.5 8.2 3l1.7-1.1c25.4 35.9 74.7 114.4 82.7 197.2 8.2 94.8 3.7 160-71.4 226.5-1.1 1.1-1.7 2.6-1.7 4.1 0.1 2 1.1 3.8 2.8 4.8h4.8l3.2-1.8c75.6-40.4 132.8-108.2 159.9-189.5 11.4 16.1 28.5 27.1 47.8 30.8C846 783.9 716.9 871.6 557.2 884.9c-12-28.6-42.5-44.8-72.9-38.6-33.6 5.4-56.6 37-51.2 70.6 4.4 27.6 26.8 48.8 54.5 51.6 30.6 4.6 60.3-13 70.8-42.2 184.9-14.5 333.2-120.8 364.2-286.9 27.8-10.8 46.3-37.4 46.6-67.2V428.7c-0.1-19.5-8.1-38.2-22.3-51.6-14.5-13.8-33.8-21.4-53.8-21.3l1-0.2zM825.9 397c-71.1-176.9-272.1-262.7-449-191.7-86.8 34.9-155.7 103.4-191 190-2.5-2.8-5.2-5.4-8-7.9 25.3-154.6 163.8-268.6 326.8-269.2s302.3 112.6 328.7 267c-2.9 3.8-5.4 7.7-7.5 11.8z"/></svg>';
const errorSvg = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>';
const avatar = role === 'user' ? userSvg : (role === 'bot' ? botSvg : errorSvg);
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