feat: add OpenAPI share page with device-bound tokens and thought/answer separation [AC-IDMP-SHARE]

This commit is contained in:
MerCry 2026-03-06 01:06:19 +08:00
parent 9c40509225
commit 382f91ce83
6 changed files with 900 additions and 194 deletions

View File

@ -112,7 +112,7 @@
<el-card shadow="never" class="shares-card" style="margin-top: 16px">
<template #header>
<div class="header-row">
<span>分享链接</span>
<span>历史分享</span>
<el-button size="small" text @click="loadShares">刷新</el-button>
</div>
</template>
@ -139,34 +139,25 @@
</el-col>
</el-row>
<el-dialog v-model="shareDialogVisible" title="分享对话" width="500px">
<el-dialog v-model="shareDialogVisible" title="分享对话(安全链接)" width="500px">
<el-form :model="shareForm" label-width="120px">
<el-form-item label="标题">
<el-input v-model="shareForm.title" placeholder="可选,分享标题" maxlength="255" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="shareForm.description" type="textarea" :rows="2" placeholder="可选,分享描述" maxlength="1000" />
</el-form-item>
<el-form-item label="有效期">
<el-select v-model="shareForm.expires_in_days" style="width: 100%">
<el-option label="1 天" :value="1" />
<el-option label="7 天" :value="7" />
<el-option label="30 天" :value="30" />
<el-option label="90 天" :value="90" />
<el-select v-model="shareForm.expires_in_minutes" style="width: 100%">
<el-option label="15 分钟" :value="15" />
<el-option label="1 小时" :value="60" />
<el-option label="6 小时" :value="360" />
<el-option label="24 小时" :value="1440" />
</el-select>
</el-form-item>
<el-form-item label="最大在线人数">
<el-input-number v-model="shareForm.max_concurrent_users" :min="1" :max="100" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="shareDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="creatingShare" @click="handleCreateShare">创建分享</el-button>
<el-button type="primary" :loading="creatingShare" @click="handleCreateShare">创建安全分享</el-button>
</template>
</el-dialog>
<el-dialog v-model="shareResultVisible" title="分享成功" width="500px">
<el-result icon="success" title="分享链接已创建" :sub-title="shareResult?.share_url">
<el-result icon="success" title="安全分享链接已创建" :sub-title="shareResult?.share_url">
<template #extra>
<el-input :model-value="shareResult?.share_url" readonly>
<template #append>
@ -186,13 +177,13 @@ import {
respondDialogue,
switchSessionMode,
reportMessages,
createShare,
createPublicShareToken,
listShares,
deleteShare,
type DialogueMessage,
type DialogueResponse,
type SessionMode,
type ShareResponse,
type CreatePublicShareTokenResponse,
type ShareListItem
} from '@/api/mid-platform'
@ -223,12 +214,9 @@ const shares = ref<ShareListItem[]>([])
const shareDialogVisible = ref(false)
const shareResultVisible = ref(false)
const creatingShare = ref(false)
const shareResult = ref<ShareResponse | null>(null)
const shareResult = ref<CreatePublicShareTokenResponse | null>(null)
const shareForm = ref({
title: '',
description: '',
expires_in_days: 7,
max_concurrent_users: 10
expires_in_minutes: 60
})
const now = () => new Date().toLocaleTimeString()
@ -334,10 +322,7 @@ const clearConversation = () => {
const showShareDialog = () => {
shareForm.value = {
title: '',
description: '',
expires_in_days: 7,
max_concurrent_users: 10
expires_in_minutes: 60
}
shareDialogVisible.value = true
}
@ -345,14 +330,17 @@ const showShareDialog = () => {
const handleCreateShare = async () => {
creatingShare.value = true
try {
const result = await createShare(sessionId.value, shareForm.value)
const result = await createPublicShareToken({
session_id: sessionId.value,
user_id: userId.value,
expires_in_minutes: shareForm.value.expires_in_minutes
})
shareResult.value = result
shareDialogVisible.value = false
shareResultVisible.value = true
await loadShares()
ElMessage.success('分享链接创建成功')
ElMessage.success('安全分享链接创建成功')
} catch {
ElMessage.error('创建分享链接失败')
ElMessage.error('创建安全分享链接失败')
} finally {
creatingShare.value = false
}

View File

@ -1,66 +1,44 @@
<template>
<div class="share-page">
<div v-if="loading" class="loading-container">
<el-icon class="is-loading" :size="48">
<Loading />
</el-icon>
<p>加载中...</p>
<div class="loading-spinner"></div>
<p class="loading-text">正在加载...</p>
</div>
<div v-else-if="error" class="error-container">
<el-result :icon="errorIcon" :title="errorTitle" :sub-title="errorMessage">
<template #extra>
<el-button type="primary" @click="goHome">返回首页</el-button>
</template>
</el-result>
<div class="error-icon">{{ errorEmoji }}</div>
<h2 class="error-title">{{ errorTitle }}</h2>
<p class="error-message">{{ errorMessage }}</p>
<button class="back-btn" @click="goHome">返回首页</button>
</div>
<template v-else-if="sessionInfo">
<el-card shadow="never" class="header-card">
<div class="session-header">
<div class="session-info">
<h2>{{ sessionInfo.title || '共享对话' }}</h2>
<p v-if="sessionInfo.description" class="description">{{ sessionInfo.description }}</p>
<div class="meta-info">
<el-tag size="small" type="info">
<el-icon><User /></el-icon>
{{ sessionInfo.current_users }} / {{ sessionInfo.max_concurrent_users }} 在线
</el-tag>
<el-tag size="small" type="warning">
<el-icon><Clock /></el-icon>
{{ formatExpiresAt(sessionInfo.expires_at) }}
</el-tag>
<div class="page-header">
<div class="header-icon">💬</div>
<h1 class="page-title">{{ sessionInfo.title || '对话记录' }}</h1>
<p v-if="sessionInfo.description" class="page-desc">{{ sessionInfo.description }}</p>
</div>
</div>
</div>
</el-card>
<el-card shadow="never" class="chat-card">
<template #header>
<div class="chat-header">
<span>对话记录</span>
<el-tag size="small" type="info">{{ sessionInfo.history.length }} 条消息</el-tag>
<div class="chat-container">
<div class="chat-list" ref="chatListRef">
<template v-for="(msg, idx) in sessionInfo.history" :key="idx">
<div class="chat-bubble" :class="`bubble-${msg.role}`">
<div class="bubble-avatar">
<span v-if="msg.role === 'user' || msg.role === 'human'">👤</span>
<span v-else>🤖</span>
</div>
<div class="bubble-content">
<div class="bubble-text">{{ msg.content }}</div>
</div>
</div>
</template>
<div class="chat-list" ref="chatListRef">
<div
v-for="(msg, idx) in sessionInfo.history"
:key="idx"
class="chat-item"
:class="`role-${msg.role}`"
>
<div class="meta">
<el-tag size="small" :type="getTagType(msg.role)">
{{ getRoleLabel(msg.role) }}
</el-tag>
<div v-if="!sessionInfo.history.length" class="empty-state">
<div class="empty-icon">📭</div>
<p>暂无对话内容</p>
</div>
<div class="content">{{ msg.content }}</div>
</div>
<el-empty v-if="!sessionInfo.history.length" description="暂无对话记录" :image-size="80" />
</div>
</el-card>
</template>
</div>
</template>
@ -68,10 +46,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Loading, User, Clock } from '@element-plus/icons-vue'
import {
getSharedSession,
joinSharedSession,
leaveSharedSession,
type SharedSessionInfo
@ -87,43 +62,13 @@ const errorMessage = ref('')
const sessionInfo = ref<SharedSessionInfo | null>(null)
const chatListRef = ref<HTMLElement | null>(null)
const errorIcon = computed(() => {
if (error.value === 'expired') return 'error'
if (error.value === 'not_found') return 'error'
if (error.value === 'too_many_users') return 'warning'
return 'error'
const errorEmoji = computed(() => {
if (error.value === 'expired') return ''
if (error.value === 'not_found') return '🔍'
if (error.value === 'too_many_users') return '👥'
return '😕'
})
const getTagType = (role: string) => {
if (role === 'user') return 'info'
if (role === 'assistant') return 'success'
if (role === 'human') return 'warning'
return ''
}
const getRoleLabel = (role: string) => {
if (role === 'user') return '用户'
if (role === 'assistant') return '助手'
if (role === 'human') return '人工'
return role
}
const formatExpiresAt = (expiresAt: string) => {
const expires = new Date(expiresAt)
const now = new Date()
const diffMs = expires.getTime() - now.getTime()
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
const diffHours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
if (diffDays > 0) {
return `${diffDays}${diffHours} 小时后过期`
} else if (diffHours > 0) {
return `${diffHours} 小时后过期`
} else {
return '即将过期'
}
}
const goHome = () => {
router.push('/')
}
@ -133,8 +78,8 @@ const loadSession = async () => {
if (!shareToken) {
error.value = 'not_found'
errorTitle.value = '无效链接'
errorMessage.value = '分享链接无效,请检查链接是否正确'
errorTitle.value = '链接无效'
errorMessage.value = '这个链接好像不对哦,请检查一下'
loading.value = false
return
}
@ -146,20 +91,20 @@ const loadSession = async () => {
sessionInfo.value = await joinSharedSession(shareToken)
} catch (err: any) {
const status = err?.response?.status
const detail = err?.response?.data?.detail || '未知错误'
const detail = err?.response?.data?.detail || '出了点问题'
if (status === 404) {
error.value = 'not_found'
errorTitle.value = '分享不存在'
errorMessage.value = '分享链接不存在或已被删除'
errorTitle.value = '找不到内容'
errorMessage.value = '这个分享链接不存在或已被删除'
} else if (status === 410) {
error.value = 'expired'
errorTitle.value = '分享已过期'
errorMessage.value = detail
errorTitle.value = '链接已失效'
errorMessage.value = '这个分享链接已经过期了'
} else if (status === 429) {
error.value = 'too_many_users'
errorTitle.value = '访问人数多'
errorMessage.value = '当前在线人数已达上限,请稍后再试'
errorTitle.value = '访问人数多'
errorMessage.value = '当前查看的人太多了,请稍后再试'
} else {
error.value = 'error'
errorTitle.value = '加载失败'
@ -176,14 +121,12 @@ const handleLeave = async () => {
try {
await leaveSharedSession(shareToken)
} catch {
// ignore leave errors
}
}
}
onMounted(() => {
loadSession()
window.addEventListener('beforeunload', handleLeave)
})
@ -196,9 +139,9 @@ onUnmounted(() => {
<style scoped>
.share-page {
min-height: 100vh;
background: var(--el-bg-color-page);
padding: 20px;
max-width: 900px;
background: #f5f5f5;
padding: 0;
max-width: 100%;
margin: 0 auto;
}
@ -208,91 +151,199 @@ onUnmounted(() => {
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
min-height: 100vh;
padding: 24px;
background: white;
}
.loading-container p {
margin-top: 16px;
color: var(--el-text-color-secondary);
.loading-spinner {
width: 36px;
height: 36px;
border: 3px solid #f0f0f0;
border-top-color: #1677ff;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
.header-card,
.chat-card {
margin-bottom: 16px;
@keyframes spin {
to { transform: rotate(360deg); }
}
.session-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.session-info h2 {
margin: 0 0 8px 0;
font-size: 20px;
font-weight: 600;
}
.session-info .description {
margin: 0 0 12px 0;
color: var(--el-text-color-secondary);
.loading-text {
margin-top: 14px;
color: #999;
font-size: 14px;
}
.meta-info {
display: flex;
gap: 8px;
.error-icon {
font-size: 48px;
margin-bottom: 10px;
}
.meta-info .el-tag {
display: flex;
align-items: center;
gap: 4px;
.error-title {
font-size: 18px;
font-weight: 500;
color: #333;
margin: 0 0 6px 0;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
.error-message {
font-size: 14px;
color: #999;
margin: 0 0 18px 0;
text-align: center;
}
.chat-list {
max-height: 60vh;
overflow-y: auto;
padding: 8px;
background: var(--el-fill-color-lighter);
.back-btn {
padding: 10px 24px;
background: #1677ff;
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
}
.chat-item {
margin-bottom: 12px;
padding: 12px;
border-radius: 8px;
background: #fff;
.back-btn:hover {
background: #4096ff;
}
.chat-item.role-user {
border-left: 4px solid var(--el-color-info);
.page-header {
background: white;
padding: 24px 20px;
text-align: center;
border-bottom: 1px solid #eee;
}
.chat-item.role-assistant {
border-left: 4px solid var(--el-color-success);
}
.chat-item.role-human {
border-left: 4px solid var(--el-color-warning);
}
.meta {
display: flex;
align-items: center;
gap: 8px;
.header-icon {
font-size: 36px;
margin-bottom: 8px;
}
.content {
white-space: pre-wrap;
line-height: 1.6;
.page-title {
font-size: 18px;
font-weight: 600;
color: #222;
margin: 0 0 4px 0;
}
.page-desc {
font-size: 13px;
color: #999;
margin: 0;
}
.chat-container {
padding: 16px;
max-width: 800px;
margin: 0 auto;
}
.chat-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.chat-bubble {
display: flex;
gap: 8px;
align-items: flex-start;
}
.bubble-user,
.bubble-human {
flex-direction: row-reverse;
}
.bubble-avatar {
flex-shrink: 0;
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
background: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.bubble-content {
max-width: 80%;
}
.bubble-text {
padding: 10px 14px;
border-radius: 14px;
font-size: 14px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.bubble-user .bubble-text {
background: #1677ff;
color: white;
border-bottom-right-radius: 4px;
}
.bubble-human .bubble-text {
background: #fa8c16;
color: white;
border-bottom-right-radius: 4px;
}
.bubble-assistant .bubble-text {
background: white;
color: #333;
border-bottom-left-radius: 4px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
color: #bbb;
}
.empty-icon {
font-size: 48px;
margin-bottom: 12px;
}
.empty-state p {
font-size: 14px;
margin: 0;
}
@media (max-width: 640px) {
.page-header {
padding: 20px 16px;
}
.header-icon {
font-size: 32px;
}
.page-title {
font-size: 16px;
}
.chat-container {
padding: 12px;
}
.bubble-content {
max-width: 85%;
}
.bubble-text {
font-size: 14px;
padding: 9px 12px;
}
}
</style>

View File

@ -0,0 +1,12 @@
"""OpenAPI-facing routers for third-party integrations."""
from fastapi import APIRouter
from .dialogue import router as dialogue_router
from .share_page import router as share_page_router
router = APIRouter()
router.include_router(dialogue_router)
router.include_router(share_page_router)
__all__ = ["router", "dialogue_router", "share_page_router"]

View File

@ -0,0 +1,7 @@
"""OpenAPI dialogue router placeholder."""
from fastapi import APIRouter
router = APIRouter(prefix="/openapi/v1/dialogue", tags=["OpenAPI Dialogue"])
__all__ = ["router"]

View File

@ -0,0 +1,466 @@
"""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.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,
)
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

View File

@ -0,0 +1,182 @@
"""Redis-backed share token service for secure share links with device binding."""
from __future__ import annotations
import json
import secrets
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Literal
import redis.asyncio as redis
from app.core.config import get_settings
@dataclass
class ChatGrantRecord:
chat_token: str
tenant_id: str
api_key: str
session_id: str
user_id: str | None
bound_device_id: str
expires_at: datetime
created_at: datetime
last_seen_at: datetime
@dataclass
class ClaimResult:
ok: bool
status: Literal["claimed", "reused", "invalid", "expired", "forbidden"]
grant: ChatGrantRecord | None = None
class ShareTokenService:
"""Manage temporary share tokens and device-bound chat grants in Redis."""
def __init__(self, redis_client: redis.Redis | None = None) -> None:
self._settings = get_settings()
self._redis = redis_client
async def _get_client(self) -> redis.Redis:
if self._redis is None:
self._redis = redis.from_url(
self._settings.redis_url,
encoding="utf-8",
decode_responses=True,
)
return self._redis
@staticmethod
def _token_key(token: str) -> str:
return f"share:token:{token}"
@staticmethod
def _grant_key(chat_token: str) -> str:
return f"share:grant:{chat_token}"
async def create_token(
self,
tenant_id: str,
api_key: str,
session_id: str,
user_id: str | None,
expires_in_minutes: int,
) -> tuple[str, datetime]:
client = await self._get_client()
now = datetime.utcnow()
expires_at = now + timedelta(minutes=expires_in_minutes)
ttl_seconds = max(1, int((expires_at - now).total_seconds()))
token = secrets.token_urlsafe(24)
payload = {
"tenant_id": tenant_id,
"api_key": api_key,
"session_id": session_id,
"user_id": user_id,
"expires_at": expires_at.isoformat(),
"bound_device_id": None,
"chat_token": None,
}
await client.setex(self._token_key(token), ttl_seconds, json.dumps(payload))
return token, expires_at
async def claim_or_reuse(self, token: str, device_id: str) -> ClaimResult:
client = await self._get_client()
raw = await client.get(self._token_key(token))
if not raw:
return ClaimResult(ok=False, status="invalid")
data = json.loads(raw)
expires_at = datetime.fromisoformat(data["expires_at"])
if datetime.utcnow() > expires_at:
await client.delete(self._token_key(token))
return ClaimResult(ok=False, status="expired")
if not data.get("chat_token"):
chat_token = secrets.token_urlsafe(24)
now = datetime.utcnow()
ttl_seconds = max(1, int((expires_at - now).total_seconds()))
grant_data = {
"chat_token": chat_token,
"tenant_id": data["tenant_id"],
"api_key": data["api_key"],
"session_id": data["session_id"],
"user_id": data.get("user_id"),
"bound_device_id": device_id,
"expires_at": data["expires_at"],
"created_at": now.isoformat(),
"last_seen_at": now.isoformat(),
}
data["bound_device_id"] = device_id
data["chat_token"] = chat_token
pipe = client.pipeline()
pipe.setex(self._grant_key(chat_token), ttl_seconds, json.dumps(grant_data))
pipe.setex(self._token_key(token), ttl_seconds, json.dumps(data))
await pipe.execute()
return ClaimResult(ok=True, status="claimed", grant=self._to_grant(grant_data))
if data.get("bound_device_id") != device_id:
return ClaimResult(ok=False, status="forbidden")
grant_raw = await client.get(self._grant_key(data["chat_token"]))
if not grant_raw:
return ClaimResult(ok=False, status="invalid")
grant_data = json.loads(grant_raw)
grant_data["last_seen_at"] = datetime.utcnow().isoformat()
ttl = await client.ttl(self._grant_key(data["chat_token"]))
if ttl and ttl > 0:
await client.setex(self._grant_key(data["chat_token"]), ttl, json.dumps(grant_data))
return ClaimResult(ok=True, status="reused", grant=self._to_grant(grant_data))
async def get_chat_grant_for_device(self, chat_token: str, device_id: str) -> ChatGrantRecord | None:
client = await self._get_client()
raw = await client.get(self._grant_key(chat_token))
if not raw:
return None
data = json.loads(raw)
if data.get("bound_device_id") != device_id:
return None
expires_at = datetime.fromisoformat(data["expires_at"])
if datetime.utcnow() > expires_at:
await client.delete(self._grant_key(chat_token))
return None
data["last_seen_at"] = datetime.utcnow().isoformat()
ttl = await client.ttl(self._grant_key(chat_token))
if ttl and ttl > 0:
await client.setex(self._grant_key(chat_token), ttl, json.dumps(data))
return self._to_grant(data)
@staticmethod
def _to_grant(data: dict) -> ChatGrantRecord:
return ChatGrantRecord(
chat_token=data["chat_token"],
tenant_id=data["tenant_id"],
api_key=data["api_key"],
session_id=data["session_id"],
user_id=data.get("user_id"),
bound_device_id=data["bound_device_id"],
expires_at=datetime.fromisoformat(data["expires_at"]),
created_at=datetime.fromisoformat(data["created_at"]),
last_seen_at=datetime.fromisoformat(data["last_seen_at"]),
)
_share_token_service: ShareTokenService | None = None
def get_share_token_service() -> ShareTokenService:
global _share_token_service
if _share_token_service is None:
_share_token_service = ShareTokenService()
return _share_token_service