5a26dfde2a
后端变更: - 新增 risk_config.py: 风险配置数据类,支持环境变量驱动 - 新增 risk_control.py: 风险控制控制器,管理并发和预算 - 新增 session_store.py: 匿名会话存储,基于 cookie 的 session ID - 新增 audit_store.py: API 审计日志存储,记录请求和 LLM 调用 - 新增 captcha_api.py: 验证码 API,用于验证用户操作真实性 - 新增 llm_policy.py: LLM 策略配置,管理 completion/pro/vision 模型 - main.py: 集成 middleware、risk/audit/session 模块 (+467/-7) - job_handlers.py: LLM 执行流程重构,新增 risk/audit 集成 (+207/-4) - llm.py: 异步客户端封装,新增 max_output_tokens 参数 (+78/-1) - job_system.py: stream_events 逻辑优化,支持心跳检测 (+12/-4) - pro_completions.py: SSE heartbeat 机制,防止连接超时 (+14/-4) - prompt.py: _normalize_preferences 支持 Mapping 类型 (+13/-0) - tts_asr.py: asyncio loop 初始化,router export (+10/-0) 前端变更: - src/components/CaptchaComponent.vue: 新增验证码组件 (NEW) - src/utils/cookie_policy.js: Cookie 策略工具 (NEW) - SettingsPanel.vue: 集成验证码组件,新增安全设置部分 (+59/-0) - MilkdownEditor.vue: 移除硬编码 API_KEY,新增 credentials (+32/-10) - ProBlockCrepe.vue: 样式简化,移除渐变动画 (+18/-4) - proBlockPlugin.ts: 重构 schema/serializer 引用方式,通过 Ctx 管理 (+40/-10) - api.js: 新增 credentials,重构 headers 条件逻辑 (+50/-14) - config.js: API 基址改为 https://api.imageteach.tech:8002 (+8/-4) - convert.js, docsApi.js, i18n.js: 新增 credentials 和验证码 i18n (+54/-12) - proAccept.js: 重构正则和转义处理,修复捕获组索引 (+14/-4) 配置和基础设施: - docker-compose.yml: 新增端口映射 8001:8001 (+2/-0) - docker/nginx.conf: 改为 307 redirect,优化代理配置 (+8/-6) - vite.config.js: 移除 proxy 配置,直接调用远程 API (+8/-4) - .env.example: 新增 VITE_API_BASE_URL, VITE_API_KEY (+3/-1) - backend/.env.example: 大量 RISK_*, SESSION_*, CORS_* 配置 (+54/-0) - pytest.ini: 扩展 coverage 范围到整个 backend,移除 fail_under (+3/-2) - .coveragerc: 移除 fail_under = 90 (+0/-1) - .gitignore: 新增 docker-data/ (+3/-0) - package.json: 新增 vue3-captcha 依赖 (+3/-1) - AGENTS.md, README.md: 更新 Docker 部署和前端网络约定 (+20/-5) - public/sw.js: Service Worker cache 版本从 v1 升级到 v2 (+0/-1) 测试变更: - test_main_endpoints.py: 新增 session/risk/audit reset,新增测试用例 (+63/-4) - test_main_cancel.py: 新增 reset 调用 (+6/-0) - test_pro_completions.py: 新增 preferences 序列化和测试 (+23/-0) 总计: 45 个文件变更,+1009/-280 行
378 lines
14 KiB
Python
378 lines
14 KiB
Python
import asyncio
|
|
import contextlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
import uuid
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
from typing import Optional
|
|
|
|
from fastapi import FastAPI, HTTPException, Request, Security
|
|
from fastapi.responses import JSONResponse, StreamingResponse
|
|
from pydantic import BaseModel
|
|
|
|
from geoip import get_ip_location_text
|
|
from llm import stream_ollama_events
|
|
from models import UserPreferences
|
|
from prompt import build_pro_completion_prompts
|
|
|
|
logger = logging.getLogger("api.pro")
|
|
|
|
PRO_COMPLETION_TIMEOUT = float(os.getenv("PRO_COMPLETION_TIMEOUT", "3600"))
|
|
PRO_QUEUE_TIMEOUT = float(os.getenv("PRO_QUEUE_TIMEOUT", "600"))
|
|
PRO_MAX_CONCURRENCY = max(1, int(os.getenv("PRO_MAX_CONCURRENCY", "1")))
|
|
PRO_QUEUE_MAX_SIZE = max(0, int(os.getenv("PRO_QUEUE_MAX_SIZE", "5")))
|
|
PRO_STATUS_RETENTION_SECONDS = float(os.getenv("PRO_STATUS_RETENTION_SECONDS", "600"))
|
|
PRO_CANCEL_ACK_TIMEOUT = 5.0
|
|
STREAM_HEARTBEAT_SECONDS = float(os.getenv("STREAM_HEARTBEAT_SECONDS", "2"))
|
|
PUBLIC_PRO_ERROR = "PRO generation failed. Please retry or adjust the instruction."
|
|
|
|
|
|
class ProCompletionRequest(BaseModel):
|
|
prefix: str
|
|
suffix: str
|
|
languageId: str = "markdown"
|
|
instruction: str = ""
|
|
pro_thinking: str = "medium"
|
|
privacy_mode: bool = False
|
|
user_preferences: Optional[UserPreferences] = None
|
|
|
|
|
|
class ProCancelRequest(BaseModel):
|
|
request_id: str
|
|
reason: str = "abort"
|
|
|
|
|
|
@dataclass
|
|
class ProRequestState:
|
|
request_id: str
|
|
status: str = "queued"
|
|
created_at: float = field(default_factory=time.time)
|
|
updated_at: float = field(default_factory=time.time)
|
|
error: str = ""
|
|
task: asyncio.Task | None = None
|
|
cancel_requested: bool = False
|
|
done_event: asyncio.Event = field(default_factory=asyncio.Event)
|
|
|
|
def touch(self, status: str | None = None, error: str = "") -> None:
|
|
if status:
|
|
self.status = status
|
|
if error:
|
|
self.error = error
|
|
self.updated_at = time.time()
|
|
|
|
def request_cancel(self) -> None:
|
|
self.cancel_requested = True
|
|
self.touch("cancelled")
|
|
|
|
|
|
PRO_STATES: dict[str, ProRequestState] = {}
|
|
PRO_STATES_LOCK = asyncio.Lock()
|
|
PRO_SEMAPHORE = asyncio.Semaphore(PRO_MAX_CONCURRENCY)
|
|
|
|
|
|
def _iso_timestamp(value: float) -> str:
|
|
return datetime.fromtimestamp(value, tz=timezone.utc).isoformat()
|
|
|
|
|
|
def _clamp_thinking(value: str | None) -> str | None:
|
|
normalized = (value or "medium").strip().lower()
|
|
if normalized in {"none", "off", "false"}:
|
|
return None
|
|
if normalized in {"low", "medium", "high"}:
|
|
return normalized
|
|
return "medium"
|
|
|
|
|
|
def _queued_states() -> list[ProRequestState]:
|
|
return [state for state in PRO_STATES.values() if state.status == "queued"]
|
|
|
|
|
|
def _queue_position(request_id: str) -> int | None:
|
|
queued = sorted(_queued_states(), key=lambda item: item.created_at)
|
|
for index, state in enumerate(queued, start=1):
|
|
if state.request_id == request_id:
|
|
return index
|
|
return None
|
|
|
|
|
|
async def _cleanup_states() -> None:
|
|
now = time.time()
|
|
expired = [
|
|
request_id
|
|
for request_id, state in PRO_STATES.items()
|
|
if state.status in {"done", "error", "cancelled"}
|
|
and now - state.updated_at > PRO_STATUS_RETENTION_SECONDS
|
|
]
|
|
for request_id in expired:
|
|
PRO_STATES.pop(request_id, None)
|
|
|
|
|
|
def _state_payload(state: ProRequestState) -> dict:
|
|
return {
|
|
"request_id": state.request_id,
|
|
"status": state.status,
|
|
"queue_position": _queue_position(state.request_id),
|
|
"created_at": _iso_timestamp(state.created_at),
|
|
"updated_at": _iso_timestamp(state.updated_at),
|
|
"error": state.error,
|
|
}
|
|
|
|
|
|
def _build_pro_prompts(
|
|
*,
|
|
prefix: str,
|
|
suffix: str,
|
|
language_id: str,
|
|
instruction: str,
|
|
pro_thinking: str = "medium",
|
|
location: str = "",
|
|
preferences: UserPreferences | None = None,
|
|
) -> tuple[str, str]:
|
|
return build_pro_completion_prompts(
|
|
prefix=prefix,
|
|
suffix=suffix,
|
|
instruction=instruction,
|
|
language_id=language_id,
|
|
location=location,
|
|
pro_thinking_level=pro_thinking,
|
|
preferences=preferences,
|
|
)
|
|
|
|
|
|
def _get_client_ip(request: Request) -> str:
|
|
if request.client:
|
|
return request.headers.get("X-Client-IP") or request.client.host
|
|
return request.headers.get("X-Client-IP") or "unknown"
|
|
|
|
|
|
async def _send_sse_event(queue: asyncio.Queue, event_name: str, data: dict) -> None:
|
|
await queue.put((event_name, json.dumps(data, ensure_ascii=False)))
|
|
|
|
|
|
async def _wait_for_cancel_cleanup(state: ProRequestState, request_tag: str, reason: str) -> None:
|
|
if state.done_event.is_set():
|
|
return
|
|
try:
|
|
await asyncio.wait_for(state.done_event.wait(), timeout=PRO_CANCEL_ACK_TIMEOUT)
|
|
except asyncio.TimeoutError:
|
|
logger.warning(
|
|
"[%s] /v1/pro/completions cancel cleanup not confirmed request_id=%s reason=%s",
|
|
request_tag,
|
|
state.request_id,
|
|
reason,
|
|
)
|
|
|
|
|
|
def register_pro_completion_routes(app: FastAPI, get_api_key):
|
|
@app.post("/v1/pro/completions")
|
|
async def create_pro_completion(
|
|
request: Request,
|
|
req: ProCompletionRequest,
|
|
api_key: str = Security(get_api_key),
|
|
):
|
|
request_id = request.headers.get("X-Request-Id") or str(uuid.uuid4())
|
|
request_tag = request_id[:8]
|
|
event_queue: asyncio.Queue[tuple[str, str] | None] = asyncio.Queue()
|
|
previous_state: ProRequestState | None = None
|
|
|
|
async with PRO_STATES_LOCK:
|
|
await _cleanup_states()
|
|
queued_count = len(_queued_states())
|
|
if queued_count >= PRO_QUEUE_MAX_SIZE:
|
|
logger.info("[%s] /v1/pro/completions rejected queue_full request_id=%s", request_tag, request_id)
|
|
return JSONResponse(
|
|
content={"error": "PRO queue is full", "request_id": request_id},
|
|
status_code=429,
|
|
)
|
|
|
|
existing = PRO_STATES.get(request_id)
|
|
if existing and existing.task and not existing.task.done():
|
|
existing.request_cancel()
|
|
existing.task.cancel()
|
|
previous_state = existing
|
|
|
|
state = ProRequestState(request_id=request_id)
|
|
PRO_STATES[request_id] = state
|
|
|
|
if previous_state:
|
|
await _wait_for_cancel_cleanup(previous_state, request_tag, "replace")
|
|
|
|
client_ip = "hidden"
|
|
location = ""
|
|
if not req.privacy_mode: # pragma: no cover
|
|
client_ip = _get_client_ip(request)
|
|
location = get_ip_location_text(client_ip)
|
|
|
|
prefix = req.prefix or ""
|
|
suffix = req.suffix or ""
|
|
|
|
system_prompt, user_prompt = _build_pro_prompts(
|
|
prefix=prefix,
|
|
suffix=suffix,
|
|
language_id=req.languageId,
|
|
instruction=req.instruction,
|
|
pro_thinking=req.pro_thinking,
|
|
location=location,
|
|
preferences=req.user_preferences,
|
|
)
|
|
|
|
logger.info(
|
|
"[%s] /v1/pro/completions request_id=%s client_ip=%s prefix_chars=%d suffix_chars=%d instruction_chars=%d lang=%s thinking=%s",
|
|
request_tag,
|
|
request_id,
|
|
client_ip,
|
|
len(prefix),
|
|
len(suffix),
|
|
len(req.instruction or ""),
|
|
req.languageId,
|
|
req.pro_thinking,
|
|
)
|
|
|
|
async def producer() -> None:
|
|
acquired = False
|
|
chunks: list[str] = []
|
|
try:
|
|
async with PRO_STATES_LOCK:
|
|
if state.cancel_requested:
|
|
raise asyncio.CancelledError()
|
|
state.touch("queued")
|
|
queue_position = _queue_position(request_id)
|
|
await _send_sse_event(event_queue, "queued", {"request_id": request_id, "queue_position": queue_position})
|
|
|
|
await asyncio.wait_for(PRO_SEMAPHORE.acquire(), timeout=PRO_QUEUE_TIMEOUT)
|
|
acquired = True
|
|
|
|
async with PRO_STATES_LOCK:
|
|
if state.cancel_requested:
|
|
raise asyncio.CancelledError()
|
|
state.touch("started")
|
|
await _send_sse_event(event_queue, "started", {"request_id": request_id})
|
|
|
|
async for event_type, payload in stream_ollama_events(
|
|
user_prompt,
|
|
system_prompt=system_prompt,
|
|
tag=f"{request_tag}-pro",
|
|
temperature=0.7,
|
|
thinking=_clamp_thinking(req.pro_thinking),
|
|
use_pro_model=True,
|
|
enable_thinking=True,
|
|
timeout=PRO_COMPLETION_TIMEOUT,
|
|
):
|
|
# Handle 'thinking' event - just update state, don't accumulate
|
|
if event_type == "thinking":
|
|
await _send_sse_event(event_queue, "thinking", {"request_id": request_id})
|
|
continue
|
|
|
|
# Handle 'chunk' event - accumulate content
|
|
if not payload:
|
|
continue
|
|
chunks.append(payload)
|
|
await _send_sse_event(event_queue, "chunk", {"delta": payload, "request_id": request_id})
|
|
|
|
# Handle 'done' event - return full content
|
|
content = "".join(chunks)
|
|
async with PRO_STATES_LOCK:
|
|
if state.cancel_requested:
|
|
raise asyncio.CancelledError()
|
|
if not content:
|
|
raise ValueError("PRO returned empty content")
|
|
|
|
async with PRO_STATES_LOCK:
|
|
state.touch("done")
|
|
logger.info("[%s] /v1/pro/completions done request_id=%s content_chars=%d", request_tag, request_id, len(content))
|
|
await _send_sse_event(event_queue, "done", {"content": content, "request_id": request_id})
|
|
except asyncio.CancelledError:
|
|
async with PRO_STATES_LOCK:
|
|
state.request_cancel()
|
|
logger.info("[%s] /v1/pro/completions cancelled request_id=%s", request_tag, request_id)
|
|
await _send_sse_event(event_queue, "cancelled", {"cancelled": True, "request_id": request_id})
|
|
raise
|
|
except Exception as exc:
|
|
async with PRO_STATES_LOCK:
|
|
state.touch("error", PUBLIC_PRO_ERROR)
|
|
logger.exception("[%s] /v1/pro/completions failed request_id=%s", request_tag, request_id)
|
|
await _send_sse_event(event_queue, "error", {"error": PUBLIC_PRO_ERROR, "request_id": request_id})
|
|
finally:
|
|
if acquired:
|
|
PRO_SEMAPHORE.release()
|
|
state.done_event.set()
|
|
await event_queue.put(None)
|
|
|
|
producer_task = asyncio.create_task(producer())
|
|
async with PRO_STATES_LOCK:
|
|
state.task = producer_task
|
|
|
|
async def event_stream():
|
|
try:
|
|
while True:
|
|
try:
|
|
item = await asyncio.wait_for(event_queue.get(), timeout=STREAM_HEARTBEAT_SECONDS)
|
|
except asyncio.TimeoutError:
|
|
yield ": keepalive\n\n"
|
|
continue
|
|
if item is None:
|
|
break
|
|
event_name, data = item
|
|
yield f"event: {event_name}\ndata: {data}\n\n"
|
|
if event_name in {"done", "error", "cancelled"}:
|
|
break
|
|
except asyncio.CancelledError:
|
|
async with PRO_STATES_LOCK:
|
|
state.request_cancel()
|
|
producer_task.cancel()
|
|
raise
|
|
finally:
|
|
if not producer_task.done() and not state.done_event.is_set():
|
|
async with PRO_STATES_LOCK:
|
|
state.request_cancel()
|
|
producer_task.cancel()
|
|
with contextlib.suppress(asyncio.TimeoutError):
|
|
await asyncio.wait_for(state.done_event.wait(), timeout=PRO_CANCEL_ACK_TIMEOUT)
|
|
|
|
return StreamingResponse(
|
|
event_stream(),
|
|
media_type="text/event-stream; charset=utf-8",
|
|
headers={
|
|
"Cache-Control": "no-cache, no-transform",
|
|
"Connection": "keep-alive",
|
|
"X-Accel-Buffering": "no",
|
|
},
|
|
)
|
|
|
|
@app.post("/v1/pro/completions/cancel")
|
|
async def cancel_pro_completion(req: ProCancelRequest, api_key: str = Security(get_api_key)):
|
|
request_id = req.request_id or ""
|
|
request_tag = request_id[:8]
|
|
state_to_wait: ProRequestState | None = None
|
|
async with PRO_STATES_LOCK:
|
|
await _cleanup_states()
|
|
state = PRO_STATES.get(request_id)
|
|
if not state:
|
|
return {"cancelled": False, "status": "not_found"}
|
|
if state.task and not state.task.done():
|
|
state.request_cancel()
|
|
state.task.cancel()
|
|
state_to_wait = state
|
|
if state.status in {"done", "error", "cancelled"}:
|
|
if not state_to_wait:
|
|
return {"cancelled": False, "status": state.status}
|
|
else:
|
|
state.request_cancel()
|
|
|
|
if state_to_wait:
|
|
await _wait_for_cancel_cleanup(state_to_wait, request_tag, req.reason)
|
|
|
|
return {"cancelled": True, "status": "ok"}
|
|
|
|
@app.get("/v1/pro/completions/status/{request_id}")
|
|
async def get_pro_completion_status(request_id: str, api_key: str = Security(get_api_key)):
|
|
async with PRO_STATES_LOCK:
|
|
await _cleanup_states()
|
|
state = PRO_STATES.get(request_id)
|
|
if not state:
|
|
raise HTTPException(status_code=404, detail="PRO request not found")
|
|
return _state_payload(state)
|