467 lines
15 KiB
Python
467 lines
15 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.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
|