2026-03-05 17:06:19 +00:00
|
|
|
|
"""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,
|
|
|
|
|
|
)
|
2026-03-05 17:14:05 +00:00
|
|
|
|
from app.core.config import get_settings
|
2026-03-05 17:06:19 +00:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-05 17:14:05 +00:00
|
|
|
|
# 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("/")
|
2026-03-05 17:06:19 +00:00
|
|
|
|
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>
|
2026-03-11 11:07:03 +00:00
|
|
|
|
<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">
|
2026-03-05 17:06:19 +00:00
|
|
|
|
<style>
|
2026-03-11 11:07:03 +00:00
|
|
|
|
: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;
|
|
|
|
|
|
}}
|
2026-03-05 17:06:19 +00:00
|
|
|
|
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
|
|
|
|
|
body {{
|
2026-03-11 11:07:03 +00:00
|
|
|
|
font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
|
|
|
|
|
background: var(--bg-primary);
|
2026-03-05 17:06:19 +00:00
|
|
|
|
min-height: 100vh;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
2026-03-11 11:07:03 +00:00
|
|
|
|
color: var(--text-primary);
|
|
|
|
|
|
line-height: 1.6;
|
2026-03-05 17:06:19 +00:00
|
|
|
|
}}
|
|
|
|
|
|
.welcome-screen {{
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
2026-03-11 11:07:03 +00:00
|
|
|
|
padding: 48px 24px;
|
|
|
|
|
|
background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-primary) 100%);
|
2026-03-05 17:06:19 +00:00
|
|
|
|
}}
|
|
|
|
|
|
.welcome-screen.hidden {{ display: none; }}
|
2026-03-11 11:07:03 +00:00
|
|
|
|
.welcome-title {{
|
|
|
|
|
|
font-size: 28px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
color: var(--text-primary);
|
|
|
|
|
|
margin-bottom: 32px;
|
|
|
|
|
|
letter-spacing: -0.02em;
|
|
|
|
|
|
}}
|
2026-03-05 17:06:19 +00:00
|
|
|
|
.welcome-input-wrapper {{
|
|
|
|
|
|
width: 100%;
|
2026-03-11 11:07:03 +00:00
|
|
|
|
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);
|
2026-03-05 17:06:19 +00:00
|
|
|
|
}}
|
|
|
|
|
|
.welcome-textarea, .input-textarea {{
|
2026-03-11 11:07:03 +00:00
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-height: 48px;
|
|
|
|
|
|
max-height: 200px;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
border-radius: var(--radius-lg);
|
|
|
|
|
|
padding: 14px 16px;
|
2026-03-05 17:06:19 +00:00
|
|
|
|
resize: none;
|
|
|
|
|
|
outline: none;
|
|
|
|
|
|
font-size: 15px;
|
2026-03-11 11:07:03 +00:00
|
|
|
|
line-height: 1.5;
|
2026-03-05 17:06:19 +00:00
|
|
|
|
font-family: inherit;
|
2026-03-11 11:07:03 +00:00
|
|
|
|
background: transparent;
|
|
|
|
|
|
color: var(--text-primary);
|
2026-03-05 17:06:19 +00:00
|
|
|
|
}}
|
2026-03-11 11:07:03 +00:00
|
|
|
|
.welcome-textarea::placeholder, .input-textarea::placeholder {{
|
|
|
|
|
|
color: var(--text-muted);
|
2026-03-05 17:06:19 +00:00
|
|
|
|
}}
|
2026-03-11 11:07:03 +00:00
|
|
|
|
.chat-screen {{ flex: 1; display: none; flex-direction: column; background: var(--bg-primary); }}
|
2026-03-05 17:06:19 +00:00
|
|
|
|
.chat-screen.active {{ display: flex; }}
|
|
|
|
|
|
.chat-list {{
|
|
|
|
|
|
flex: 1;
|
2026-03-11 11:07:03 +00:00
|
|
|
|
padding: 24px;
|
|
|
|
|
|
max-width: 720px;
|
2026-03-05 17:06:19 +00:00
|
|
|
|
width: 100%;
|
|
|
|
|
|
margin: 0 auto;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
2026-03-11 11:07:03 +00:00
|
|
|
|
gap: 20px;
|
2026-03-05 17:06:19 +00:00
|
|
|
|
overflow-y: auto;
|
|
|
|
|
|
}}
|
2026-03-11 11:07:03 +00:00
|
|
|
|
.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); }}
|
|
|
|
|
|
}}
|
2026-03-05 17:06:19 +00:00
|
|
|
|
.bubble.user {{ flex-direction: row-reverse; }}
|
|
|
|
|
|
.avatar {{
|
2026-03-11 11:07:03 +00:00
|
|
|
|
width: 36px; height: 36px; border-radius: 50%;
|
|
|
|
|
|
background: var(--bg-tertiary);
|
|
|
|
|
|
display: flex; align-items: center; justify-content: center;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
overflow: hidden;
|
2026-03-05 17:06:19 +00:00
|
|
|
|
}}
|
2026-03-11 11:07:03 +00:00
|
|
|
|
.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; }}
|
2026-03-05 17:06:19 +00:00
|
|
|
|
.bubble-text {{
|
2026-03-11 11:07:03 +00:00
|
|
|
|
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;
|
2026-03-05 17:06:19 +00:00
|
|
|
|
}}
|
|
|
|
|
|
.thought-block {{
|
2026-03-11 11:07:03 +00:00
|
|
|
|
background: var(--bg-tertiary);
|
|
|
|
|
|
color: var(--text-secondary);
|
|
|
|
|
|
padding: 14px 18px;
|
|
|
|
|
|
border-radius: var(--radius-md);
|
2026-03-05 17:06:19 +00:00
|
|
|
|
margin-bottom: 12px;
|
|
|
|
|
|
font-size: 13px;
|
2026-03-11 11:07:03 +00:00
|
|
|
|
line-height: 1.65;
|
|
|
|
|
|
border-left: 3px solid var(--text-muted);
|
2026-03-05 17:06:19 +00:00
|
|
|
|
}}
|
|
|
|
|
|
.thought-label {{
|
|
|
|
|
|
font-weight: 600;
|
2026-03-11 11:07:03 +00:00
|
|
|
|
color: var(--text-muted);
|
|
|
|
|
|
margin-bottom: 8px;
|
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
|
letter-spacing: 0.05em;
|
2026-03-05 17:06:19 +00:00
|
|
|
|
}}
|
|
|
|
|
|
.final-answer-block {{
|
2026-03-11 11:07:03 +00:00
|
|
|
|
background: var(--bg-secondary);
|
|
|
|
|
|
color: var(--text-primary);
|
|
|
|
|
|
padding: 14px 18px;
|
|
|
|
|
|
border-radius: var(--radius-md);
|
2026-03-05 17:06:19 +00:00
|
|
|
|
font-size: 14px;
|
2026-03-11 11:07:03 +00:00
|
|
|
|
line-height: 1.65;
|
2026-03-05 17:06:19 +00:00
|
|
|
|
}}
|
|
|
|
|
|
.final-answer-label {{
|
|
|
|
|
|
font-weight: 600;
|
2026-03-11 11:07:03 +00:00
|
|
|
|
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;
|
2026-03-05 17:06:19 +00:00
|
|
|
|
}}
|
|
|
|
|
|
.send-btn, .welcome-send {{
|
2026-03-11 11:07:03 +00:00
|
|
|
|
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; }}
|
2026-03-05 17:06:19 +00:00
|
|
|
|
}}
|
|
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
|
|
|
|
|
<div class="welcome-screen" id="welcomeScreen">
|
2026-03-11 11:07:03 +00:00
|
|
|
|
<h1 class="welcome-title">今天有什么可以帮到你?</h1>
|
2026-03-05 17:06:19 +00:00
|
|
|
|
<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;
|
2026-03-11 11:07:03 +00:00
|
|
|
|
|
|
|
|
|
|
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);
|
2026-03-05 17:06:19 +00:00
|
|
|
|
|
|
|
|
|
|
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
|