refactor: 全栈架构升级 - 风险控制、会话管理、审计日志和验证码功能
后端变更: - 新增 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 行
This commit is contained in:
+68
-10
@@ -2,6 +2,7 @@ import os
|
||||
import time
|
||||
import logging
|
||||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
import base64
|
||||
from datetime import datetime
|
||||
@@ -43,6 +44,51 @@ LLM_BASE_URL = LLM_BASE_URL.rstrip('/') + '/'
|
||||
COMPLETION_TIMEOUT = int(os.getenv("LLM_COMPLETION_TIMEOUT", "600"))
|
||||
OCR_TIMEOUT = int(os.getenv("LLM_OCR_TIMEOUT", "600"))
|
||||
|
||||
|
||||
async def _maybe_await(value):
|
||||
if inspect.isawaitable(value):
|
||||
return await value
|
||||
return value
|
||||
|
||||
|
||||
class _AsyncClientContext:
|
||||
def __init__(self, client):
|
||||
self.client = client
|
||||
|
||||
async def __aenter__(self):
|
||||
return self.client
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
close = getattr(self.client, "aclose", None) or getattr(self.client, "close", None)
|
||||
if close:
|
||||
await _maybe_await(close())
|
||||
|
||||
|
||||
async def _create_async_client(timeout: httpx.Timeout):
|
||||
client = await _maybe_await(
|
||||
httpx.AsyncClient(base_url=LLM_BASE_URL, headers=LLM_HEADERS, timeout=timeout)
|
||||
)
|
||||
if hasattr(client, "__aenter__"):
|
||||
return client
|
||||
return _AsyncClientContext(client)
|
||||
|
||||
|
||||
async def _client_post(client, url: str, payload: dict):
|
||||
try:
|
||||
return await client.post(url, json=payload)
|
||||
except TypeError as exc:
|
||||
raw_post = getattr(type(client), "__dict__", {}).get("post")
|
||||
if raw_post is None or "multiple values for argument" not in str(exc):
|
||||
raise
|
||||
return await raw_post(url, json=payload)
|
||||
|
||||
|
||||
async def _stream_line_iterator(response):
|
||||
lines = await _maybe_await(response.aiter_lines())
|
||||
if hasattr(lines, "__aiter__"):
|
||||
return lines.__aiter__()
|
||||
return lines
|
||||
|
||||
logger = logging.getLogger('llm')
|
||||
|
||||
|
||||
@@ -77,6 +123,7 @@ def _build_chat_payload(
|
||||
model: str | None = None,
|
||||
use_pro_model: bool = False,
|
||||
prefill: str | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
) -> dict:
|
||||
messages = []
|
||||
sys_prompt = _resolve_system_prompt(system_prompt)
|
||||
@@ -97,6 +144,8 @@ def _build_chat_payload(
|
||||
'stream': False,
|
||||
'options': options,
|
||||
}
|
||||
if max_output_tokens and max_output_tokens > 0:
|
||||
payload['max_tokens'] = int(max_output_tokens)
|
||||
|
||||
return payload
|
||||
|
||||
@@ -110,6 +159,7 @@ def _build_chat_stream_payload(
|
||||
model: str | None = None,
|
||||
use_pro_model: bool = False,
|
||||
prefill: str | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
) -> dict:
|
||||
messages = []
|
||||
sys_prompt = _resolve_system_prompt(system_prompt)
|
||||
@@ -130,6 +180,8 @@ def _build_chat_stream_payload(
|
||||
'stream': True,
|
||||
'options': options,
|
||||
}
|
||||
if max_output_tokens and max_output_tokens > 0:
|
||||
payload['max_tokens'] = int(max_output_tokens)
|
||||
|
||||
return payload
|
||||
|
||||
@@ -159,6 +211,7 @@ async def call_ollama(
|
||||
model: str | None = None,
|
||||
use_pro_model: bool = False,
|
||||
prefill: str | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
) -> dict:
|
||||
"""Call OpenAI-compatible chat completions (non-streaming) and return content/thinking."""
|
||||
start = time.perf_counter()
|
||||
@@ -176,14 +229,15 @@ async def call_ollama(
|
||||
payload = _build_chat_payload(
|
||||
prompt=prompt, system_prompt=system_prompt, temperature=temperature,
|
||||
thinking=thinking, model=model, use_pro_model=use_pro_model, prefill=prefill,
|
||||
max_output_tokens=max_output_tokens,
|
||||
)
|
||||
|
||||
http_timeout = httpx.Timeout(connect=10.0, read=None, write=30.0, pool=30.0)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=LLM_BASE_URL, headers=LLM_HEADERS, timeout=http_timeout) as client:
|
||||
async with await _create_async_client(http_timeout) as client:
|
||||
resp = await asyncio.wait_for(
|
||||
client.post('/chat/completions', json=payload), timeout=COMPLETION_TIMEOUT,
|
||||
_client_post(client, '/chat/completions', payload), timeout=COMPLETION_TIMEOUT,
|
||||
)
|
||||
|
||||
resp.raise_for_status()
|
||||
@@ -244,6 +298,7 @@ async def stream_ollama(
|
||||
model: str | None = None,
|
||||
use_pro_model: bool = False,
|
||||
prefill: str | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Stream text deltas from OpenAI-compatible chat completions."""
|
||||
start = time.perf_counter()
|
||||
@@ -262,18 +317,19 @@ async def stream_ollama(
|
||||
payload = _build_chat_stream_payload(
|
||||
prompt=prompt, system_prompt=system_prompt, temperature=temperature,
|
||||
thinking=thinking, model=model, use_pro_model=use_pro_model, prefill=prefill,
|
||||
max_output_tokens=max_output_tokens,
|
||||
)
|
||||
|
||||
http_timeout = httpx.Timeout(connect=10.0, read=None, write=30.0, pool=30.0)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=LLM_BASE_URL, headers=LLM_HEADERS, timeout=http_timeout) as client:
|
||||
async with await _create_async_client(http_timeout) as client:
|
||||
try:
|
||||
async with client.stream('POST', '/chat/completions', json=payload) as response:
|
||||
response.raise_for_status()
|
||||
await _maybe_await(response.raise_for_status())
|
||||
|
||||
deadline = time.perf_counter() + COMPLETION_TIMEOUT
|
||||
line_iterator = response.aiter_lines().__aiter__()
|
||||
line_iterator = await _stream_line_iterator(response)
|
||||
|
||||
while True:
|
||||
remaining = deadline - time.perf_counter()
|
||||
@@ -369,6 +425,7 @@ async def stream_ollama_events(
|
||||
enable_thinking: bool = True,
|
||||
prefill: str | None = None,
|
||||
timeout: float | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
) -> AsyncIterator[tuple[Literal['thinking', 'content'], str]]:
|
||||
"""Stream (event_type, payload) tuples from OpenAI-compatible chat completions."""
|
||||
start = time.perf_counter()
|
||||
@@ -387,6 +444,7 @@ async def stream_ollama_events(
|
||||
payload = _build_chat_stream_payload(
|
||||
prompt=prompt, system_prompt=system_prompt, temperature=temperature,
|
||||
thinking=thinking if enable_thinking else None, model=model, use_pro_model=use_pro_model, prefill=prefill,
|
||||
max_output_tokens=max_output_tokens,
|
||||
)
|
||||
|
||||
effective_timeout = timeout if timeout is not None else COMPLETION_TIMEOUT
|
||||
@@ -394,13 +452,13 @@ async def stream_ollama_events(
|
||||
sent_thinking = False
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=LLM_BASE_URL, headers=LLM_HEADERS, timeout=http_timeout) as client:
|
||||
async with await _create_async_client(http_timeout) as client:
|
||||
try:
|
||||
async with client.stream('POST', '/chat/completions', json=payload) as response:
|
||||
response.raise_for_status()
|
||||
await _maybe_await(response.raise_for_status())
|
||||
|
||||
deadline = time.perf_counter() + effective_timeout
|
||||
line_iterator = response.aiter_lines().__aiter__()
|
||||
line_iterator = await _stream_line_iterator(response)
|
||||
|
||||
while True:
|
||||
remaining = deadline - time.perf_counter()
|
||||
@@ -523,9 +581,9 @@ async def call_vlm_ocr(image_bytes: bytes, language: str = 'auto') -> str:
|
||||
http_timeout = httpx.Timeout(connect=10.0, read=None, write=30.0, pool=30.0)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=LLM_BASE_URL, headers=LLM_HEADERS, timeout=http_timeout) as client:
|
||||
async with await _create_async_client(http_timeout) as client:
|
||||
resp = await asyncio.wait_for(
|
||||
client.post('/chat/completions', json=payload), timeout=OCR_TIMEOUT,
|
||||
_client_post(client, '/chat/completions', payload), timeout=OCR_TIMEOUT,
|
||||
)
|
||||
|
||||
resp.raise_for_status()
|
||||
|
||||
Reference in New Issue
Block a user