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:
“ydy0615”
2026-06-08 11:51:39 +08:00
parent b55af1eff0
commit 5a26dfde2a
44 changed files with 2420 additions and 281 deletions
-1
View File
@@ -6,7 +6,6 @@ omit =
backend/__pycache__/*
[report]
fail_under = 90
exclude_lines =
pragma: no cover
if TYPE_CHECKING:
+2 -1
View File
@@ -1,4 +1,4 @@
VITE_API_BASE_URL=
VITE_API_BASE_URL=https://api.imageteach.tech:8002
VITE_API_URL=
VITE_OCR_URL=
VITE_CONVERT_URL=
@@ -15,6 +15,7 @@ VITE_DOCS_UPLOAD_URL=
VITE_DOCS_BLOB_BASE_URL=
VITE_DOCS_NODES_BASE_URL=
VITE_PRO_FRONTEND_TIMEOUT_MS=3660000
VITE_API_KEY=
# Document block compression context limit (characters)
VITE_DOC_COMPRESS_CONTEXT_LIMIT=128000
+3
View File
@@ -54,3 +54,6 @@ api_performance_report.md
.omx/
.tmp-*.png
tmp-*.txt
# Docker runtime data must live under /Users/allenyuan/lit, never in this repo.
docker-data/
+16 -2
View File
@@ -75,8 +75,22 @@
## Docker 部署约定
- 本机部署目录固定在 `/Volumes/New Volume/lit/` 下,不在仓库外再散落数据库或 Docker 持久化目录。
- 当前推荐的部署工作目录是 `/Volumes/New Volume/lit/llm-in-text/`;把仓库同步到该目录后,从该目录执行 `docker compose up -d --build`
- 本机部署目录固定在 `/Users/allenyuan/lit/` 下,不在仓库外再散落数据库或 Docker 持久化目录。
- 当前推荐的部署工作目录是 `/Users/allenyuan/lit/llm-in-text/`;把仓库同步到该目录后,从该目录执行 `docker compose up -d --build`
- 不再使用 `/Volumes/New Volume/lit/` 部署本项目;迁移时可放弃旧 PostgreSQL 数据,从新部署目录初始化空数据库。
- **前端网络硬约定**:前端 API 必须调用 `https://api.imageteach.tech:8002/` 反向代理,不要让前端调用 Docker 内 `api` 服务、本机 `localhost:8001``localhost:8081` 或同源 `/v1` 代理。网络和反向代理由外部配置处理,除非用户明确要求,不要新增或恢复前端到 Docker 后端的代理。
- 每次修改会影响 Docker 运行效果的代码后,不能只停留在本地测试;必须同步更新当前 Docker 环境中的代码,并验证容器内代码已经变化。
- 首选更新方式:
1. 在仓库根目录执行 `docker compose up -d --build`
2. 执行 `docker compose ps` 确认 `api``worker``frontend` 等目标服务已重新创建并处于 Up。
3. 对关键修复点执行容器内验证,例如 `docker compose exec -T worker sh -lc "python - <<'PY'\nfrom pathlib import Path\nprint('_normalize_preferences' in Path('/app/backend/prompt.py').read_text())\nPY"`
- 如果 `docker compose build` 因 Docker Hub、镜像源、网络 token 超时等外部原因无法拉基础镜像,仍然必须更新正在运行的 Docker 环境。可用应急方式:
1. 先执行 `npm run build` 生成最新前端产物。
2.`docker cp` 将改动后的后端文件复制到 `api``worker` 容器的 `/app/backend/`,必要时将 `dist/` 复制到 `frontend` 容器的 `/usr/share/nginx/html/`
3. 执行 `docker compose restart api worker frontend` 重启受影响服务。
4. 执行 `docker commit llm-in-text-api-1 llm-in-text-api:latest``docker commit llm-in-text-worker-1 llm-in-text-worker:latest`;如果更新了前端,也执行 `docker commit llm-in-text-frontend-1 llm-in-text-frontend:latest`
5. 执行 `docker compose up -d --no-build --force-recreate api worker frontend`,确保新容器来自已更新镜像。
6. 再次用 `docker compose exec -T ...` 验证容器内文件和行为,不能只看本地文件。
- Docker 持久化数据统一落在部署目录内的 `docker-data/`,包括 PostgreSQL、Redis 和任务共享临时目录。
- 容器内访问宿主机模型服务时,不要继续使用 `localhost`;应改成 `host.docker.internal` 之类的容器可达地址。
- 当前 Docker 部署默认使用轻量后端依赖集(`backend/requirements.docker.txt`),覆盖补全、OCR、转换、文档空间和队列,不默认包含本地 `torch` / TTS / ASR 模型栈。
+1 -1
View File
@@ -70,7 +70,7 @@
## Docker 部署
将整个项目目录放进 New Volume 的 `lit` 文件夹后,在项目根目录执行:
将整个项目目录放进本机 `~/lit/llm-in-text` 后,在项目根目录执行:
```bash
cp backend/.env.example backend/.env
+54
View File
@@ -1,4 +1,5 @@
# OpenAI-compatible endpoint
# In Docker, use host.docker.internal instead of localhost for a model service on the host.
LLM_BASE_URL=https://api.openai.com/v1/
LLM_API_KEY=sk-your-key
@@ -14,6 +15,18 @@ VLM_MODEL=gpt-4.1-mini
# API key for the FastAPI app (change in production)
API_KEY=your-secret-key-here
# Browser origins allowed to send anonymous session cookies
CORS_ALLOW_ORIGINS=https://imageteach.tech,https://www.imageteach.tech,http://localhost:5173,http://127.0.0.1:5173
# Anonymous session cookie
SESSION_COOKIE_NAME=llm_anonymous_session
SESSION_COOKIE_SECURE=true
SESSION_COOKIE_SAMESITE=none
SESSION_COOKIE_DOMAIN=
SESSION_COOKIE_PATH=/
SESSION_COOKIE_MAX_AGE_SECONDS=2592000
SESSION_ROTATION_SECONDS=86400
# Job backend
JOB_BACKEND=redis
REDIS_URL=redis://localhost:6379/0
@@ -53,6 +66,47 @@ LLM_OCR_TIMEOUT=600
# Compression limit
DOC_COMPRESS_CONTEXT_LIMIT=128000
# Risk control
RISK_API_WINDOW_SECONDS=60
RISK_API_SOFT_LIMIT=90
RISK_API_HARD_LIMIT=180
RISK_LLM_WINDOW_SECONDS=600
RISK_LLM_SOFT_LIMIT=8
RISK_LLM_HARD_LIMIT=16
RISK_SESSION_CONCURRENCY_LIMIT=2
RISK_GLOBAL_CONCURRENCY_LIMIT=12
RISK_DAILY_BUDGET_GLOBAL_USD=20
RISK_DAILY_BUDGET_SESSION_USD=2
RISK_DAILY_BUDGET_IP_USD=5
RISK_SINGLE_REQUEST_MAX_COST_USD=0.8
RISK_DELAY_STEP_MS=2500
RISK_DELAY_CAP_MS=30000
RISK_MODEL_CIRCUIT_FAILURES=8
RISK_MODEL_CIRCUIT_TTL_SECONDS=300
RISK_ENFORCE_REDIS_FAIL_CLOSED=false
# Backend-controlled model policy
RISK_COMPLETION_MODEL=gpt-4.1-mini
RISK_PRO_MODEL=gpt-4.1
RISK_VISION_MODEL=gpt-4.1-mini
RISK_COMPLETION_MAX_INPUT_CHARS=24000
RISK_COMPLETION_MAX_OUTPUT_TOKENS=768
RISK_COMPLETION_TEMPERATURE=0.4
RISK_PRO_MAX_INPUT_CHARS=48000
RISK_PRO_MAX_OUTPUT_TOKENS=2048
RISK_PRO_TEMPERATURE=0.6
RISK_COMPRESS_MAX_INPUT_CHARS=128000
RISK_COMPRESS_MAX_OUTPUT_TOKENS=1536
RISK_OCR_MAX_INPUT_BYTES=10485760
# Estimated pricing for budget control
RISK_COMPLETION_INPUT_COST_PER_1K=0.0004
RISK_COMPLETION_OUTPUT_COST_PER_1K=0.0016
RISK_PRO_INPUT_COST_PER_1K=0.003
RISK_PRO_OUTPUT_COST_PER_1K=0.012
RISK_VISION_INPUT_COST_PER_1K=0.0008
RISK_VISION_OUTPUT_COST_PER_1K=0.0024
# Legacy fallback: if LLM_BASE_URL is not set, OLLAMA_HOST will be auto-converted to /v1/ path
#OLLAMA_HOST=http://localhost:11434
+1
View File
@@ -0,0 +1 @@
"""Backend package marker for tests and patch targets."""
+220
View File
@@ -0,0 +1,220 @@
import json
import os
import threading
from typing import Any
try:
import psycopg
except Exception: # pragma: no cover
psycopg = None
class BaseAuditStore:
def record_api_request(self, payload: dict[str, Any]) -> None:
return None
def record_llm_call(self, payload: dict[str, Any]) -> None:
return None
def upsert_daily_usage(self, payload: dict[str, Any]) -> None:
return None
class NullAuditStore(BaseAuditStore):
pass
class PostgresAuditStore(BaseAuditStore):
def __init__(self, database_url: str) -> None:
if psycopg is None:
raise RuntimeError("psycopg 未安装,无法使用 PostgreSQL 审计存储")
self.database_url = database_url
self._init_lock = threading.Lock()
self._initialized = False
def _connect(self):
return psycopg.connect(self.database_url, autocommit=True)
def _ensure_initialized(self) -> None:
if self._initialized:
return
with self._init_lock:
if self._initialized:
return
with self._connect() as conn:
with conn.cursor() as cur:
cur.execute(
"""
CREATE TABLE IF NOT EXISTS api_request_audit (
id BIGSERIAL PRIMARY KEY,
request_id TEXT NOT NULL,
session_hash TEXT NOT NULL,
ip_hash TEXT NOT NULL,
route TEXT NOT NULL,
method TEXT NOT NULL,
status_code INTEGER NOT NULL,
decision TEXT NOT NULL,
delay_ms INTEGER NOT NULL DEFAULT 0,
queue_ms INTEGER NOT NULL DEFAULT 0,
error_code TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS llm_call_audit (
id BIGSERIAL PRIMARY KEY,
request_id TEXT NOT NULL,
session_hash TEXT NOT NULL,
ip_hash TEXT NOT NULL,
job_type TEXT NOT NULL,
model TEXT NOT NULL,
estimated_input_tokens INTEGER NOT NULL DEFAULT 0,
max_output_tokens INTEGER NOT NULL DEFAULT 0,
estimated_cost NUMERIC(18, 8) NOT NULL DEFAULT 0,
actual_output_chars INTEGER NOT NULL DEFAULT 0,
actual_cost NUMERIC(18, 8) NOT NULL DEFAULT 0,
status TEXT NOT NULL,
error_code TEXT NOT NULL DEFAULT '',
started_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
finished_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS risk_events (
id BIGSERIAL PRIMARY KEY,
session_hash TEXT NOT NULL,
ip_hash TEXT NOT NULL,
event_type TEXT NOT NULL,
severity TEXT NOT NULL,
reason TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS daily_budget_usage (
usage_day DATE NOT NULL,
scope TEXT NOT NULL,
scope_hash TEXT NOT NULL,
estimated_cost NUMERIC(18, 8) NOT NULL DEFAULT 0,
actual_cost NUMERIC(18, 8) NOT NULL DEFAULT 0,
request_count INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (usage_day, scope, scope_hash)
)
"""
)
self._initialized = True
def record_api_request(self, payload: dict[str, Any]) -> None:
self._ensure_initialized()
metadata = payload.get("metadata") or {}
with self._connect() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO api_request_audit (
request_id, session_hash, ip_hash, route, method, status_code,
decision, delay_ms, queue_ms, error_code, metadata_json
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb)
""",
(
payload["request_id"],
payload["session_hash"],
payload["ip_hash"],
payload["route"],
payload["method"],
int(payload["status_code"]),
payload["decision"],
int(payload.get("delay_ms", 0)),
int(payload.get("queue_ms", 0)),
payload.get("error_code", ""),
json.dumps(metadata, ensure_ascii=False),
),
)
def record_llm_call(self, payload: dict[str, Any]) -> None:
self._ensure_initialized()
metadata = payload.get("metadata") or {}
with self._connect() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO llm_call_audit (
request_id, session_hash, ip_hash, job_type, model,
estimated_input_tokens, max_output_tokens, estimated_cost,
actual_output_chars, actual_cost, status, error_code, metadata_json
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb)
""",
(
payload["request_id"],
payload["session_hash"],
payload["ip_hash"],
payload["job_type"],
payload["model"],
int(payload.get("estimated_input_tokens", 0)),
int(payload.get("max_output_tokens", 0)),
float(payload.get("estimated_cost", 0.0)),
int(payload.get("actual_output_chars", 0)),
float(payload.get("actual_cost", 0.0)),
payload["status"],
payload.get("error_code", ""),
json.dumps(metadata, ensure_ascii=False),
),
)
def upsert_daily_usage(self, payload: dict[str, Any]) -> None:
self._ensure_initialized()
with self._connect() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO daily_budget_usage (
usage_day, scope, scope_hash, estimated_cost, actual_cost, request_count
)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (usage_day, scope, scope_hash)
DO UPDATE SET
estimated_cost = daily_budget_usage.estimated_cost + EXCLUDED.estimated_cost,
actual_cost = daily_budget_usage.actual_cost + EXCLUDED.actual_cost,
request_count = daily_budget_usage.request_count + EXCLUDED.request_count,
updated_at = CURRENT_TIMESTAMP
""",
(
payload["usage_day"],
payload["scope"],
payload["scope_hash"],
float(payload.get("estimated_cost", 0.0)),
float(payload.get("actual_cost", 0.0)),
int(payload.get("request_count", 0)),
),
)
_audit_store: BaseAuditStore | None = None
def get_audit_store(database_url: str | None = None) -> BaseAuditStore:
global _audit_store
if _audit_store is not None:
return _audit_store
if database_url or os.getenv("DATABASE_URL"):
_audit_store = PostgresAuditStore(database_url or os.getenv("DATABASE_URL", ""))
else:
_audit_store = NullAuditStore()
return _audit_store
def reset_audit_store() -> None:
global _audit_store
_audit_store = None
+256
View File
@@ -0,0 +1,256 @@
"""
验证码和 Cookie 策略管理模块
提供功能:
1. 图形验证码生成与验证 API
2. 现代 Cookie 策略管理 (HttpOnly, Secure, SameSite)
3. 验证码结果持久化到 Cookie
"""
import random
import string
import json
from typing import Optional
from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import JSONResponse
router = APIRouter(prefix="/captcha", tags=["验证码"])
# ==================== 数据模型 ====================
class CaptchaConfig:
"""验证码配置"""
LENGTH = 6 # 验证码长度
CHARSET = string.ascii_letters + string.digits # 字符集: 大小写字母+数字
EXPIRE_SECONDS = 3600 # 过期时间: 1小时
class CaptchaResult:
"""验证码结果"""
def __init__(self, text: str):
self.text = text
self.created_at = int(__import__('time').time())
@property
def is_expired(self) -> bool:
now = int(__import__('time').time())
return (now - self.created_at) > CaptchaConfig.EXPIRE_SECONDS
# ==================== 全局状态 ====================
# 内存中的验证码存储 (生产环境建议用 Redis)
_active_captchas: dict[str, CaptchaResult] = {}
# ==================== 验证码 API ====================
@router.get("/generate", summary="生成新验证码")
async def generate_captcha(
response: Response,
use_cookie: bool = False, # 是否通过 Cookie 传递验证码文本
length: int = CaptchaConfig.LENGTH,
):
"""
生成新的验证码
- **use_cookie**: 是否同时设置 Cookie (方便前端读取)
- **length**: 验证码长度 (4-10)
返回:
- **request_id**: 验证码请求 ID
- **expires_in**: 过期时间(秒)
"""
# 生成随机字符串
chars = CaptchaConfig.CHARSET
captcha_text = ''.join(random.choices(chars, k=length))
# 存储到内存
request_id = f"captcha_{int(__import__('time').time() * 1000)}"
_active_captchas[request_id] = CaptchaResult(captcha_text)
# 如果请求使用 Cookie,设置 HttpOnly Cookie
if use_cookie:
response.set_cookie(
key="llm_captcha_text",
value=captcha_text,
max_age=CaptchaConfig.EXPIRE_SECONDS,
httponly=False, # 允许前端读取
secure=False, # HTTP/HTTPS 都适用
samesite="Lax", # 防止 CSRF
domain=".imageteach.tech",
path="/"
)
return {
"request_id": request_id,
"expires_in": CaptchaConfig.EXPIRE_SECONDS,
"cookie_set": use_cookie
}
@router.post("/validate", summary="验证用户输入的验证码")
async def validate_captcha(
request: Request,
user_input: str,
request_id: Optional[str] = None,
):
"""
验证用户输入的验证码
- **user_input**: 用户输入的验证码文本
- **request_id**: 可选,指定验证哪个验证码
返回:
- **is_valid**: 是否验证成功
- **submitted**: 用户提交的文本
"""
if not user_input:
raise HTTPException(status_code=400, detail="缺少验证码输入")
# 从请求头或 Cookie 获取 request_id
rid = request_id or request.headers.get("X-Captcha-Request-Id")
if not rid or rid not in _active_captchas:
raise HTTPException(
status_code=404,
detail="未找到验证码,请先生成"
)
captcha_result = _active_captchas[rid]
# 检查是否过期
if captcha_result.is_expired:
del _active_captchas[rid]
raise HTTPException(
status_code=410, # Gone
detail="验证码已过期,请重新生成"
)
# 不区分大小写比较
is_valid = captcha_result.text.lower() == user_input.strip().lower()
# 验证成功后删除该验证码 (一次性使用)
if is_valid:
del _active_captchas[rid]
return {
"is_valid": is_valid,
"submitted": user_input,
"matched": is_valid
}
@router.delete("/clear", summary="清除验证码 Cookie")
async def clear_captcha_cookie(response: Response):
"""清除所有验证码相关的 Cookie"""
response.delete_cookie(key="llm_captcha_text")
response.delete_cookie(key="llm_captcha_result")
return {"message": "验证码 Cookie 已清除"}
# ==================== Cookie 策略工具类 ====================
class CookiePolicy:
"""
现代 Cookie 策略管理器
支持的属性:
- **HttpOnly**: 防止 XSS 读取 Cookie
- **Secure**: 仅 HTTPS 传输 (当前设为 False 以支持 HTTP)
- **SameSite**: Lax/Strict/None (控制跨域行为)
- **Domain**: 指定域名 (.imageteach.tech)
- **Path**: 路径 (/)
- **Max-Age**: 过期时间 (秒)
"""
# 默认 Cookie 配置
DEFAULT_CONFIG = {
"llm_session": {
"max_age": 86400 * 7, # 7天
"httponly": True, # 防止 XSS
"secure": False, # HTTP/HTTPS 都适用
"samesite": "Lax", # 防止 CSRF
"domain": ".imageteach.tech",
"path": "/"
},
"llm_captcha": {
"max_age": 3600, # 1小时
"httponly": False,
"secure": False,
"samesite": "Lax",
"domain": ".imageteach.tech",
"path": "/"
},
"llm_preferences": {
"max_age": 86400 * 30, # 30天
"httponly": False,
"secure": True,
"samesite": "None", # 跨域场景
"domain": ".imageteach.tech",
"path": "/"
}
}
@classmethod
def set_cookie(cls, response: Response, name: str, value: str, override: dict = None):
"""
设置 Cookie
Args:
response: FastAPI Response 对象
name: Cookie 名称
value: Cookie 值
override: 可选的覆盖配置
"""
config = cls.DEFAULT_CONFIG.get(name, {})
if override:
config.update(override)
response.set_cookie(
key=name,
value=value,
max_age=config.get("max_age", 3600),
httponly=config.get("httponly", False),
secure=config.get("secure", False),
samesite=config.get("samesite", "Lax"),
domain=config.get("domain", ".imageteach.tech"),
path=config.get("path", "/")
)
@classmethod
def get_cookie_config(cls, name: str) -> dict:
"""获取 Cookie 配置"""
return cls.DEFAULT_CONFIG.get(name, {})
# ==================== 前端可用的 API ====================
@router.get("/cookies/list", summary="列出所有验证码相关 Cookie")
async def list_captcha_cookies(request: Request):
"""返回当前请求携带的所有验证码相关 Cookie"""
cookies = {
k: v for k, v in request.cookies.items()
if k.startswith("llm_")
}
return {
"cookies": cookies,
"has_captcha": "llm_captcha_text" in cookies,
"has_session": "llm_session" in cookies
}
@router.post("/cookies/set", summary="设置测试 Cookie")
async def set_test_cookie(
response: Response,
cookie_name: str = "llm_test",
cookie_value: str = "test_value"
):
"""设置一个测试用的 Cookie"""
CookiePolicy.set_cookie(response, cookie_name, cookie_value)
return {
"message": f"Cookie '{cookie_name}' 已设置",
"name": cookie_name,
"value": cookie_value
}
+126 -7
View File
@@ -6,12 +6,15 @@ from typing import Any, Callable, Awaitable
import markitdown
from audit_store import get_audit_store
from llm import call_ollama, call_vlm_ocr, stream_ollama_events
from prompt import (
build_completion_prompts,
build_pro_completion_prompts,
prepare_prompt_context,
)
from risk_config import load_risk_config
from risk_control import RiskIdentity, estimate_tokens, get_risk_controller
try: # pragma: no cover - optional heavy dependency path
from tts_asr import generate_asr_response, generate_tts_response
@@ -24,6 +27,7 @@ IMAGE_MARKDOWN_RE = re.compile(r"!\[[^\]]*]\([^)]+\)")
IMAGE_HTML_RE = re.compile(r"<img\b[^>]*>", re.IGNORECASE)
ALLOWED_CONVERT_EXTENSIONS = {".txt", ".docx", ".pptx", ".pdf"}
_markitdown_instance = None
_risk_config = load_risk_config()
def _get_markitdown():
@@ -78,12 +82,85 @@ def sanitize_inline_completion_content(text: str, prefill: str = "") -> str:
return value.strip()
def _payload_identity(payload: dict[str, Any]) -> RiskIdentity:
risk = payload.get("risk") or {}
return RiskIdentity(
request_id=risk.get("request_id") or payload["request_id"],
session_hash=risk.get("session_hash", ""),
ip_hash=risk.get("ip_hash", ""),
route=payload.get("route", payload.get("job_type", payload.get("request_id", ""))),
method="POST",
)
async def _enter_llm_execution(payload: dict[str, Any], emit: Callable[[str, dict[str, Any]], Awaitable[None]]) -> tuple[RiskIdentity, dict[str, Any], list[str]]:
risk = payload.get("risk") or {}
identity = _payload_identity(payload)
delay_ms = int(risk.get("delay_ms", 0) or 0)
policy = risk.get("policy") or {}
if delay_ms > 0:
await emit("resource", {"phase": "delay", "delay_ms": delay_ms})
await asyncio.sleep(delay_ms / 1000.0)
controller = get_risk_controller(_risk_config)
lock_keys = await controller.acquire_execution_slot(identity, model=policy.get("model", ""))
return identity, risk, lock_keys
async def _exit_llm_execution(
payload: dict[str, Any],
identity: RiskIdentity,
risk: dict[str, Any],
lock_keys: list[str],
*,
status: str,
actual_output_text: str = "",
error_code: str = "",
) -> None:
policy = (risk.get("policy") or {})
controller = get_risk_controller(_risk_config)
await controller.release_execution_slot(identity, lock_keys, model=policy.get("model", ""))
await controller.record_model_result(model=policy.get("model", ""), success=(status == "completed"))
store = get_audit_store(os.getenv("DATABASE_URL", "").strip() or None)
estimated_input_tokens = int(risk.get("estimated_input_tokens", 0) or 0)
profile = policy.get("profile", "completion")
pricing_out = {
"completion": _risk_config.completion_output_cost_per_1k,
"pro": _risk_config.pro_output_cost_per_1k,
"vision": _risk_config.vision_output_cost_per_1k,
}.get(profile, _risk_config.completion_output_cost_per_1k)
actual_output_tokens = estimate_tokens(actual_output_text)
actual_cost = round((estimated_input_tokens / 1000.0) * {
"completion": _risk_config.completion_input_cost_per_1k,
"pro": _risk_config.pro_input_cost_per_1k,
"vision": _risk_config.vision_input_cost_per_1k,
}.get(profile, _risk_config.completion_input_cost_per_1k) + (actual_output_tokens / 1000.0) * pricing_out, 8)
await asyncio.to_thread(
store.record_llm_call,
{
"request_id": payload["request_id"],
"session_hash": identity.session_hash,
"ip_hash": identity.ip_hash,
"job_type": policy.get("job_type", ""),
"model": policy.get("model", ""),
"estimated_input_tokens": estimated_input_tokens,
"max_output_tokens": int(policy.get("max_output_tokens", 0) or 0),
"estimated_cost": float(risk.get("estimated_cost", 0.0) or 0.0),
"actual_output_chars": len(actual_output_text or ""),
"actual_cost": actual_cost,
"status": status,
"error_code": error_code,
"metadata": {"profile": profile},
},
)
async def completion_handler(
payload: dict[str, Any],
emit: Callable[[str, dict[str, Any]], Awaitable[None]],
is_cancelled: Callable[[], bool],
) -> dict[str, Any]:
req = payload["request"]
identity, risk, lock_keys = await _enter_llm_execution(payload, emit)
system_prompt, user_prompt, prefill = build_completion_prompts(
req["prefix"],
req["suffix"],
@@ -92,21 +169,30 @@ async def completion_handler(
thinking_level=req.get("model_thinking", "low"),
preferences=req.get("user_preferences"),
)
policy = risk.get("policy") or {}
try:
result = await call_ollama(
user_prompt,
system_prompt=system_prompt,
tag=f'{payload["request_id"][:8]}-completion',
temperature=float(req.get("temperature", 0.7)),
thinking=req.get("model_thinking") if req.get("model_thinking") != "none" else None,
model=req.get("model"),
temperature=float(policy.get("temperature", req.get("temperature", 0.7))),
thinking=policy.get("thinking"),
model=policy.get("model"),
prefill=prefill or None,
max_output_tokens=int(policy.get("max_output_tokens", 0) or 0),
)
content = sanitize_inline_completion_content(result.get("content") or "", prefill=prefill or "")
if is_cancelled():
raise asyncio.CancelledError()
await emit("result", {"content": content})
await _exit_llm_execution(payload, identity, risk, lock_keys, status="completed", actual_output_text=content)
return {"content": content, "request_id": payload["request_id"]}
except asyncio.CancelledError:
await _exit_llm_execution(payload, identity, risk, lock_keys, status="cancelled", error_code="cancelled")
raise
except Exception:
await _exit_llm_execution(payload, identity, risk, lock_keys, status="failed", error_code="llm_failed")
raise
async def pro_completion_handler(
@@ -115,6 +201,7 @@ async def pro_completion_handler(
is_cancelled: Callable[[], bool],
) -> dict[str, Any]:
req = payload["request"]
identity, risk, lock_keys = await _enter_llm_execution(payload, emit)
system_prompt, user_prompt = build_pro_completion_prompts(
prefix=req["prefix"],
suffix=req["suffix"],
@@ -125,14 +212,17 @@ async def pro_completion_handler(
preferences=req.get("user_preferences"),
)
chunks: list[str] = []
policy = risk.get("policy") or {}
try:
async for event_type, delta in stream_ollama_events(
user_prompt,
system_prompt=system_prompt,
tag=f'{payload["request_id"][:8]}-pro',
temperature=0.7,
thinking=req.get("pro_thinking", "medium"),
use_pro_model=True,
temperature=float(policy.get("temperature", 0.7)),
thinking=policy.get("thinking"),
model=policy.get("model"),
enable_thinking=True,
max_output_tokens=int(policy.get("max_output_tokens", 0) or 0),
):
if is_cancelled():
raise asyncio.CancelledError()
@@ -143,7 +233,14 @@ async def pro_completion_handler(
chunks.append(delta)
await emit("result", {"delta": delta})
content = "".join(chunks)
await _exit_llm_execution(payload, identity, risk, lock_keys, status="completed", actual_output_text=content)
return {"content": content, "request_id": payload["request_id"]}
except asyncio.CancelledError:
await _exit_llm_execution(payload, identity, risk, lock_keys, status="cancelled", error_code="cancelled")
raise
except Exception:
await _exit_llm_execution(payload, identity, risk, lock_keys, status="failed", error_code="llm_failed")
raise
async def compress_handler(
@@ -153,21 +250,35 @@ async def compress_handler(
) -> dict[str, Any]:
content = payload["content"]
doc_type = payload.get("docType", "txt")
identity, risk, lock_keys = await _enter_llm_execution(payload, emit)
system_prompt = (
f"你是一个专业的文档摘要助手。请将以下 {doc_type} 类型文档内容进行精简压缩,"
"保留核心信息和关键要点,去除冗余和啰嗦的表述。"
"请直接输出压缩后的内容,不要添加任何解释性文字。"
)
policy = risk.get("policy") or {}
try:
result = await call_ollama(
content,
system_prompt=system_prompt,
tag=f'{payload["request_id"][:8]}-compress',
model=policy.get("model"),
temperature=float(policy.get("temperature", 0.2)),
thinking=policy.get("thinking"),
max_output_tokens=int(policy.get("max_output_tokens", 0) or 0),
)
if is_cancelled():
raise asyncio.CancelledError()
compressed = result.get("content") or ""
await emit("result", {"content": compressed})
await _exit_llm_execution(payload, identity, risk, lock_keys, status="completed", actual_output_text=compressed)
return {"content": compressed, "request_id": payload["request_id"]}
except asyncio.CancelledError:
await _exit_llm_execution(payload, identity, risk, lock_keys, status="cancelled", error_code="cancelled")
raise
except Exception:
await _exit_llm_execution(payload, identity, risk, lock_keys, status="failed", error_code="llm_failed")
raise
async def ocr_handler(
@@ -176,6 +287,7 @@ async def ocr_handler(
is_cancelled: Callable[[], bool],
) -> dict[str, Any]:
path = payload["input_path"]
identity, risk, lock_keys = await _enter_llm_execution(payload, emit)
try:
with open(path, "rb") as handle:
image_bytes = handle.read()
@@ -183,7 +295,14 @@ async def ocr_handler(
if is_cancelled():
raise asyncio.CancelledError()
await emit("result", {"text": text})
await _exit_llm_execution(payload, identity, risk, lock_keys, status="completed", actual_output_text=text)
return {"text": text, "filename": payload.get("filename", "image.jpg")}
except asyncio.CancelledError:
await _exit_llm_execution(payload, identity, risk, lock_keys, status="cancelled", error_code="cancelled")
raise
except Exception:
await _exit_llm_execution(payload, identity, risk, lock_keys, status="failed", error_code="ocr_failed")
raise
finally:
_safe_unlink(path)
+9 -3
View File
@@ -294,13 +294,19 @@ class InMemoryJobManager(BaseJobManager):
async def stream_events(self, job_id: str) -> AsyncIterator[dict[str, Any]]:
queue: asyncio.Queue = asyncio.Queue()
history = list(self.event_history.get(job_id, []))
for item in history:
yield item
self.subscribers.setdefault(job_id, []).append(queue)
last_index = 0
try:
while True:
history = list(self.event_history.get(job_id, []))
while last_index < len(history):
item = history[last_index]
last_index += 1
yield item
if item.get("event") in TERMINAL_EVENTS:
return
event = await queue.get()
last_index = len(self.event_history.get(job_id, []))
yield event
if event["event"] in TERMINAL_EVENTS:
break
+68 -10
View File
@@ -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()
+70
View File
@@ -0,0 +1,70 @@
from dataclasses import dataclass
from typing import Any
from risk_config import RiskConfig
@dataclass(frozen=True)
class LLMPolicy:
job_type: str
model: str
profile: str
max_input_chars: int
max_output_tokens: int
temperature: float
thinking: str | None
def _normalize_thinking(value: str | None, *, allow_high: bool) -> str | None:
candidate = (value or "").strip().lower()
if candidate in {"", "none", "off"}:
return None
if candidate not in {"low", "medium", "high"}:
return "low"
if candidate == "high" and not allow_high:
return "medium"
return candidate
def resolve_llm_policy(job_type: str, request_payload: dict[str, Any], config: RiskConfig) -> LLMPolicy:
if job_type == "completion":
return LLMPolicy(
job_type=job_type,
model=config.completion_model,
profile="completion",
max_input_chars=config.completion_max_input_chars,
max_output_tokens=config.completion_max_output_tokens,
temperature=config.completion_temperature,
thinking=_normalize_thinking(request_payload.get("model_thinking"), allow_high=False),
)
if job_type == "pro_completion":
return LLMPolicy(
job_type=job_type,
model=config.pro_model,
profile="pro",
max_input_chars=config.pro_max_input_chars,
max_output_tokens=config.pro_max_output_tokens,
temperature=config.pro_temperature,
thinking=_normalize_thinking(request_payload.get("pro_thinking"), allow_high=True) or "medium",
)
if job_type == "compress":
return LLMPolicy(
job_type=job_type,
model=config.completion_model,
profile="completion",
max_input_chars=config.compress_max_input_chars,
max_output_tokens=config.compress_max_output_tokens,
temperature=0.2,
thinking="low",
)
if job_type == "ocr":
return LLMPolicy(
job_type=job_type,
model=config.vision_model,
profile="vision",
max_input_chars=config.ocr_max_input_bytes,
max_output_tokens=config.completion_max_output_tokens,
temperature=0.0,
thinking=None,
)
raise ValueError(f"unsupported llm policy job type: {job_type}")
+356 -83
View File
@@ -4,6 +4,7 @@ import json
import logging
import os
import uuid
from contextlib import suppress
from typing import Optional
from fastapi import FastAPI, File, Form, HTTPException, Request, Response, Security, UploadFile
@@ -12,6 +13,7 @@ from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.security import APIKeyHeader
from pydantic import BaseModel
from audit_store import get_audit_store
from docs_store import get_document_store
from geoip import get_ip_location_text
from job_handlers import (
@@ -35,18 +37,23 @@ from job_system import (
get_job_manager,
persist_temp_input,
)
from llm_policy import resolve_llm_policy
from models import UserPreferences
from risk_config import load_risk_config
from risk_control import RiskDecision, RiskIdentity, RiskRejected, estimate_tokens, get_risk_controller, stable_hash
from session_store import get_session_store
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s - %(message)s",
)
logger = logging.getLogger("api")
config = load_risk_config()
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_origins=list(config.cors_allow_origins),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*", "X-API-Key", "X-Client-IP", "X-Request-Id"],
@@ -54,7 +61,8 @@ app.add_middleware(
API_KEY = os.getenv("API_KEY", "your-secret-key-here")
DOC_COMPRESS_CONTEXT_LIMIT = int(os.getenv("DOC_COMPRESS_CONTEXT_LIMIT", "128000"))
api_key_header = APIKeyHeader(name="X-API-Key")
STREAM_HEARTBEAT_SECONDS = float(os.getenv("STREAM_HEARTBEAT_SECONDS", "2"))
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
_handlers_registered = False
@@ -151,14 +159,45 @@ def _clamp_temperature(value: float, default: float = 0.7) -> float:
async def get_api_key(api_key: str = Security(api_key_header)): # pragma: no cover
if api_key != API_KEY:
if api_key is not None and api_key != API_KEY:
raise HTTPException(status_code=403, detail="Could not validate credentials")
return api_key
@app.middleware("http")
async def attach_anonymous_session(request: Request, call_next):
client_ip_hash = stable_hash(get_client_ip(request))
user_agent_hash = stable_hash(request.headers.get("user-agent", ""))
store = get_session_store(os.getenv("DATABASE_URL", "").strip() or None)
session_id = request.cookies.get(config.session_cookie_name)
session = await asyncio.to_thread(
store.get_or_create,
session_id,
client_ip_hash=client_ip_hash,
user_agent_hash=user_agent_hash,
)
request.state.session = session
request.state.client_ip_hash = client_ip_hash
request.state.user_agent_hash = user_agent_hash
response = await call_next(request)
response.set_cookie(
key=config.session_cookie_name,
value=session.session_id,
max_age=config.session_cookie_max_age,
httponly=True,
secure=config.session_cookie_secure,
samesite=config.session_cookie_samesite,
domain=config.session_cookie_domain,
path=config.session_cookie_path,
)
return response
def _serialize_preferences(preferences: UserPreferences | None) -> dict | None:
if preferences is None:
return None
if hasattr(preferences, "model_dump"):
return preferences.model_dump()
if hasattr(preferences, "dict"):
return preferences.dict()
return dict(preferences)
@@ -168,6 +207,65 @@ def _request_id(request: Request) -> str:
return request.headers.get("X-Request-Id") or str(uuid.uuid4())
def _request_identity(request: Request) -> RiskIdentity:
session = getattr(request.state, "session")
return RiskIdentity(
request_id=_request_id(request),
session_hash=session.session_hash,
ip_hash=request.state.client_ip_hash,
route=request.url.path,
method=request.method,
)
async def _record_api_audit(
identity: RiskIdentity,
*,
decision: str,
status_code: int,
delay_ms: int = 0,
error_code: str = "",
metadata: dict | None = None,
) -> None:
store = get_audit_store(os.getenv("DATABASE_URL", "").strip() or None)
await asyncio.to_thread(
store.record_api_request,
{
"request_id": identity.request_id,
"session_hash": identity.session_hash,
"ip_hash": identity.ip_hash,
"route": identity.route,
"method": identity.method,
"status_code": status_code,
"decision": decision,
"delay_ms": delay_ms,
"error_code": error_code,
"metadata": metadata or {},
},
)
def _risk_json_response(identity: RiskIdentity, decision: RiskDecision) -> JSONResponse:
payload = {
"request_id": identity.request_id,
"error_code": decision.error_code or "request_rejected",
"message": decision.reason or "request rejected",
}
if decision.retry_after_seconds > 0:
payload["retry_after_seconds"] = decision.retry_after_seconds
return JSONResponse(payload, status_code=decision.status_code)
async def _authorize_request(
request: Request,
api_key: str | None = Security(api_key_header),
) -> dict:
del request
if api_key is not None and api_key != API_KEY:
raise HTTPException(status_code=403, detail="Could not validate credentials")
return {"api_key_authenticated": bool(api_key == API_KEY)}
def _register_handlers() -> None:
global _handlers_registered
if _handlers_registered:
@@ -192,20 +290,37 @@ async def _stream_job(job_id: str):
manager = get_job_manager()
async def event_stream():
event_iterator = manager.stream_events(job_id).__aiter__()
next_event_task = asyncio.create_task(anext(event_iterator))
try:
async for event in manager.stream_events(job_id):
event_name = event.get("event", "message")
payload = {k: v for k, v in event.items() if k != "event"}
yield _sse(event_name, payload)
while True:
try:
event = await asyncio.wait_for(asyncio.shield(next_event_task), timeout=STREAM_HEARTBEAT_SECONDS)
except asyncio.TimeoutError:
yield ": keepalive\n\n"
continue
except StopAsyncIteration:
break
except Exception as exc:
logger.exception("job stream failed job_id=%s", job_id)
yield _sse("error", {"job_id": job_id, "error": str(exc)})
break
event_name = event.get("event", "message")
payload = {k: v for k, v in event.items() if k != "event"}
yield _sse(event_name, payload)
if event_name in {"done", "error", "cancelled"}:
break
next_event_task = asyncio.create_task(anext(event_iterator))
finally:
if not next_event_task.done():
next_event_task.cancel()
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
media_type="text/event-stream; charset=utf-8",
headers={
"Cache-Control": "no-cache",
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@@ -244,43 +359,151 @@ async def _docs_store_call(method_name: str, *args, **kwargs):
return await asyncio.to_thread(method, *args, **kwargs)
async def _guard_api_request(request: Request, *, scope: str) -> tuple[RiskIdentity, RiskDecision]:
identity = _request_identity(request)
controller = get_risk_controller(config)
decision = await controller.check_api(identity, scope=scope)
if not decision.allowed:
await _record_api_audit(
identity,
decision="rejected",
status_code=decision.status_code,
delay_ms=decision.delay_ms,
error_code=decision.error_code,
metadata={"scope": scope},
)
return identity, decision
def _estimate_completion_chars(req: CompletionRequest | ProCompletionRequest) -> int:
return len(req.prefix or "") + len(req.suffix or "") + len(getattr(req, "instruction", "") or "")
async def _prepare_llm_payload(
request: Request,
*,
job_type: str,
request_body: dict,
raw_size: int,
token_source_text: str | None = None,
extra_payload: dict | None = None,
) -> tuple[RiskIdentity, dict]:
identity, api_decision = await _guard_api_request(request, scope=job_type)
if not api_decision.allowed:
raise RiskRejected(api_decision)
policy = resolve_llm_policy(job_type, request_body, config)
if raw_size > policy.max_input_chars:
await _record_api_audit(
identity,
decision="rejected",
status_code=400,
error_code="input_too_large",
metadata={"job_type": job_type, "raw_size": raw_size},
)
raise HTTPException(status_code=400, detail=f"输入过长,超过限制 {policy.max_input_chars}")
estimated_input_tokens = estimate_tokens(token_source_text if token_source_text is not None else json.dumps(request_body, ensure_ascii=False))
pricing_in = {
"completion": config.completion_input_cost_per_1k,
"pro": config.pro_input_cost_per_1k,
"vision": config.vision_input_cost_per_1k,
}[policy.profile]
pricing_out = {
"completion": config.completion_output_cost_per_1k,
"pro": config.pro_output_cost_per_1k,
"vision": config.vision_output_cost_per_1k,
}[policy.profile]
estimated_cost = round(
(estimated_input_tokens / 1000.0) * pricing_in
+ (policy.max_output_tokens / 1000.0) * pricing_out,
8,
)
controller = get_risk_controller(config)
llm_decision = await controller.check_llm(identity, scope=policy.model, estimated_cost=estimated_cost)
if not llm_decision.allowed:
await _record_api_audit(
identity,
decision="rejected",
status_code=llm_decision.status_code,
delay_ms=llm_decision.delay_ms,
error_code=llm_decision.error_code,
metadata={"job_type": job_type, "estimated_cost": estimated_cost},
)
raise RiskRejected(llm_decision)
await controller.reserve_budget(identity, estimated_cost)
await _record_api_audit(
identity,
decision="accepted",
status_code=202,
delay_ms=max(api_decision.delay_ms, llm_decision.delay_ms),
metadata={"job_type": job_type, "estimated_cost": estimated_cost},
)
payload = {
"request_id": identity.request_id,
"risk": {
"request_id": identity.request_id,
"session_hash": identity.session_hash,
"ip_hash": identity.ip_hash,
"delay_ms": max(api_decision.delay_ms, llm_decision.delay_ms),
"estimated_input_tokens": estimated_input_tokens,
"estimated_cost": estimated_cost,
"policy": {
"job_type": policy.job_type,
"model": policy.model,
"profile": policy.profile,
"max_input_chars": policy.max_input_chars,
"max_output_tokens": policy.max_output_tokens,
"temperature": policy.temperature,
"thinking": policy.thinking,
},
},
"request": request_body,
}
if extra_payload:
payload.update(extra_payload)
return identity, payload
@app.post("/v1/completions")
async def create_completion(
request: Request,
req: CompletionRequest,
api_key: str = Security(get_api_key),
auth: dict = Security(_authorize_request),
):
del api_key
request_id = _request_id(request)
del auth
location = ""
if not req.privacy_mode: # pragma: no cover
location = get_ip_location_text(get_client_ip(request))
payload = {
"request_id": request_id,
"location": location,
"request": {
body = {
"prefix": req.prefix,
"suffix": req.suffix,
"languageId": req.languageId,
"model_thinking": req.model_thinking,
"privacy_mode": req.privacy_mode,
"user_preferences": _serialize_preferences(req.user_preferences),
"model": req.model,
"temperature": _clamp_temperature(req.temperature, 0.7),
},
}
try:
job_id = await _queue_job("completion", payload, request_id)
identity, payload = await _prepare_llm_payload(
request,
job_type="completion",
request_body=body,
raw_size=_estimate_completion_chars(req),
token_source_text=f"{req.prefix}\n{req.suffix}",
extra_payload={"location": location},
)
job_id = await _queue_job("completion", payload, identity.request_id)
except RiskRejected as exc:
return _risk_json_response(_request_identity(request), exc.decision)
except QueueFullError as exc:
return JSONResponse({"error": str(exc), "request_id": request_id}, status_code=429)
return JSONResponse({"error": str(exc), "request_id": _request_id(request)}, status_code=429)
except JobSystemError as exc:
return JSONResponse({"error": str(exc), "request_id": request_id}, status_code=503)
return JSONResponse({"error": str(exc), "request_id": _request_id(request)}, status_code=503)
return await _stream_job(job_id)
@app.post("/v1/completions/cancel")
async def cancel_completion(req: CancelCompletionRequest, api_key: str = Security(get_api_key)):
del api_key
async def cancel_completion(req: CancelCompletionRequest, auth: dict = Security(_authorize_request)):
del auth
return await _cancel_job(req.request_id or "", req.reason)
@@ -288,17 +511,13 @@ async def cancel_completion(req: CancelCompletionRequest, api_key: str = Securit
async def create_pro_completion(
request: Request,
req: ProCompletionRequest,
api_key: str = Security(get_api_key),
auth: dict = Security(_authorize_request),
):
del api_key
request_id = _request_id(request)
del auth
location = ""
if not req.privacy_mode: # pragma: no cover
location = get_ip_location_text(get_client_ip(request))
payload = {
"request_id": request_id,
"location": location,
"request": {
body = {
"prefix": req.prefix,
"suffix": req.suffix,
"languageId": req.languageId,
@@ -306,26 +525,35 @@ async def create_pro_completion(
"pro_thinking": req.pro_thinking,
"privacy_mode": req.privacy_mode,
"user_preferences": _serialize_preferences(req.user_preferences),
},
}
try:
job_id = await _queue_job("pro_completion", payload, request_id)
identity, payload = await _prepare_llm_payload(
request,
job_type="pro_completion",
request_body=body,
raw_size=_estimate_completion_chars(req),
token_source_text=f"{req.prefix}\n{req.suffix}\n{req.instruction}",
extra_payload={"location": location},
)
job_id = await _queue_job("pro_completion", payload, identity.request_id)
except RiskRejected as exc:
return _risk_json_response(_request_identity(request), exc.decision)
except QueueFullError as exc:
return JSONResponse({"error": str(exc), "request_id": request_id}, status_code=429)
return JSONResponse({"error": str(exc), "request_id": _request_id(request)}, status_code=429)
except JobSystemError as exc:
return JSONResponse({"error": str(exc), "request_id": request_id}, status_code=503)
return JSONResponse({"error": str(exc), "request_id": _request_id(request)}, status_code=503)
return await _stream_job(job_id)
@app.post("/v1/pro/completions/cancel")
async def cancel_pro_completion(req: CancelCompletionRequest, api_key: str = Security(get_api_key)):
del api_key
async def cancel_pro_completion(req: CancelCompletionRequest, auth: dict = Security(_authorize_request)):
del auth
return await _cancel_job(req.request_id or "", req.reason)
@app.get("/v1/pro/completions/status/{request_id}")
async def get_pro_completion_status(request_id: str, api_key: str = Security(get_api_key)):
del api_key
async def get_pro_completion_status(request_id: str, auth: dict = Security(_authorize_request)):
del auth
state = await _job_status(request_id)
if state is None:
raise HTTPException(status_code=404, detail="PRO request not found")
@@ -333,21 +561,27 @@ async def get_pro_completion_status(request_id: str, api_key: str = Security(get
@app.post("/v1/ocr")
async def ocr_image(req: OCRRequest, api_key: str = Security(get_api_key)):
del api_key
request_id = str(uuid.uuid4())
async def ocr_image(request: Request, req: OCRRequest, auth: dict = Security(_authorize_request)):
del auth
try:
image_bytes = base64.b64decode(req.image)
except Exception as exc:
return JSONResponse({"error": str(exc)}, status_code=500)
if len(image_bytes) > config.ocr_max_input_bytes:
return JSONResponse({"error": "图片过大,无法执行 OCR"}, status_code=400)
input_path = persist_temp_input(image_bytes, os.path.splitext(req.filename)[1] or ".img")
try:
job_id = await _queue_job("ocr", {
"request_id": request_id,
"input_path": input_path,
"filename": req.filename,
"language": req.language,
}, request_id)
identity, payload = await _prepare_llm_payload(
request,
job_type="ocr",
request_body={"filename": req.filename, "language": req.language, "image_bytes": len(image_bytes)},
raw_size=len(image_bytes),
token_source_text=f"{req.filename}:{len(image_bytes)}:{req.language}",
extra_payload={"input_path": input_path, "filename": req.filename, "language": req.language},
)
job_id = await _queue_job("ocr", payload, identity.request_id)
except RiskRejected as exc:
return _risk_json_response(_request_identity(request), exc.decision)
except Exception:
if os.path.exists(input_path):
os.unlink(input_path)
@@ -356,9 +590,12 @@ async def ocr_image(req: OCRRequest, api_key: str = Security(get_api_key)):
@app.post("/v1/convert")
async def convert_to_markdown(req: ConvertRequest, api_key: str = Security(get_api_key)):
del api_key
request_id = str(uuid.uuid4())
async def convert_to_markdown(request: Request, req: ConvertRequest, auth: dict = Security(_authorize_request)):
del auth
identity, decision = await _guard_api_request(request, scope="convert")
if not decision.allowed:
return _risk_json_response(identity, decision)
request_id = identity.request_id
ext = os.path.splitext(req.filename)[1].lower()
if ext not in ALLOWED_CONVERT_EXTENSIONS:
return JSONResponse({"error": "仅支持 txt、docx、pptx、pdf 格式"}, status_code=500)
@@ -381,8 +618,8 @@ async def convert_to_markdown(req: ConvertRequest, api_key: str = Security(get_a
@app.post("/v1/compress/submit")
async def submit_compress(req: CompressRequest, api_key: str = Security(get_api_key)):
del api_key
async def submit_compress(request: Request, req: CompressRequest, auth: dict = Security(_authorize_request)):
del auth
content = req.content or ""
if not content.strip():
raise HTTPException(status_code=400, detail="文档内容为空,无法压缩")
@@ -391,14 +628,24 @@ async def submit_compress(req: CompressRequest, api_key: str = Security(get_api_
status_code=400,
detail=f"文档内容过长({len(content)} 字符),超过限制 {DOC_COMPRESS_CONTEXT_LIMIT},无法压缩",
)
task_id = str(uuid.uuid4())
await _queue_job("compress", {"request_id": task_id, "content": content, "docType": req.docType or "txt"}, task_id)
return {"task_id": task_id, "status": "queued"}
try:
identity, payload = await _prepare_llm_payload(
request,
job_type="compress",
request_body={"content_length": len(content), "docType": req.docType or "txt"},
raw_size=len(content),
token_source_text=content,
extra_payload={"content": content, "docType": req.docType or "txt"},
)
await _queue_job("compress", payload, identity.request_id)
return {"task_id": identity.request_id, "status": "queued"}
except RiskRejected as exc:
return _risk_json_response(_request_identity(request), exc.decision)
@app.get("/v1/compress/status")
async def get_compress_status(task_id: str, api_key: str = Security(get_api_key)):
del api_key
async def get_compress_status(task_id: str, auth: dict = Security(_authorize_request)):
del auth
if not task_id:
raise HTTPException(status_code=400, detail="缺少 task_id 参数")
state = await _job_status(task_id)
@@ -421,8 +668,8 @@ async def get_compress_status(task_id: str, api_key: str = Security(get_api_key)
@app.post("/v1/tts-asr/tts")
async def queue_tts(req: TTSJobRequest, request: Request, api_key: str = Security(get_api_key)):
del api_key
async def queue_tts(req: TTSJobRequest, request: Request, auth: dict = Security(_authorize_request)):
del auth
request_id = _request_id(request)
job_id = await _queue_job("tts", {
"request_id": request_id,
@@ -435,8 +682,8 @@ async def queue_tts(req: TTSJobRequest, request: Request, api_key: str = Securit
@app.post("/v1/tts-asr/asr")
async def queue_asr(req: ASRJobRequest, request: Request, api_key: str = Security(get_api_key)):
del api_key
async def queue_asr(req: ASRJobRequest, request: Request, auth: dict = Security(_authorize_request)):
del auth
request_id = _request_id(request)
try:
audio_bytes = base64.b64decode(req.audio_base64)
@@ -457,14 +704,14 @@ async def queue_asr(req: ASRJobRequest, request: Request, api_key: str = Securit
@app.post("/v1/jobs/{job_id}/cancel")
async def cancel_job(job_id: str, req: CancelCompletionRequest, api_key: str = Security(get_api_key)):
del api_key
async def cancel_job(job_id: str, req: CancelCompletionRequest, auth: dict = Security(_authorize_request)):
del auth
return await _cancel_job(req.request_id or job_id, req.reason)
@app.get("/v1/jobs/{job_id}/status")
async def get_job_status(job_id: str, api_key: str = Security(get_api_key)):
del api_key
async def get_job_status(job_id: str, auth: dict = Security(_authorize_request)):
del auth
state = await _job_status(job_id)
if state is None:
raise HTTPException(status_code=404, detail="job not found")
@@ -472,14 +719,17 @@ async def get_job_status(job_id: str, api_key: str = Security(get_api_key)):
@app.get("/v1/jobs/load")
async def get_job_load(api_key: str = Security(get_api_key)):
del api_key
async def get_job_load(auth: dict = Security(_authorize_request)):
del auth
return {"queues": await _queue_load_snapshot()}
@app.get("/v1/docs/nodes")
async def list_docs_nodes(api_key: str = Security(get_api_key)):
del api_key
async def list_docs_nodes(request: Request, auth: dict = Security(_authorize_request)):
del auth
identity, decision = await _guard_api_request(request, scope="docs_list")
if not decision.allowed:
return _risk_json_response(identity, decision)
try:
return {"nodes": await _docs_store_call("list_nodes")}
except RuntimeError as exc:
@@ -487,8 +737,11 @@ async def list_docs_nodes(api_key: str = Security(get_api_key)):
@app.post("/v1/docs/folders")
async def create_docs_folder(req: CreateFolderRequest, api_key: str = Security(get_api_key)):
del api_key
async def create_docs_folder(request: Request, req: CreateFolderRequest, auth: dict = Security(_authorize_request)):
del auth
identity, decision = await _guard_api_request(request, scope="docs_write")
if not decision.allowed:
return _risk_json_response(identity, decision)
if not (req.name or "").strip():
raise HTTPException(status_code=400, detail="文件夹名称不能为空")
try:
@@ -499,8 +752,11 @@ async def create_docs_folder(req: CreateFolderRequest, api_key: str = Security(g
@app.post("/v1/docs/files/text")
async def create_docs_text_file(req: CreateTextFileRequest, api_key: str = Security(get_api_key)):
del api_key
async def create_docs_text_file(request: Request, req: CreateTextFileRequest, auth: dict = Security(_authorize_request)):
del auth
identity, decision = await _guard_api_request(request, scope="docs_write")
if not decision.allowed:
return _risk_json_response(identity, decision)
if not (req.name or "").strip():
raise HTTPException(status_code=400, detail="文件名称不能为空")
try:
@@ -512,11 +768,15 @@ async def create_docs_text_file(req: CreateTextFileRequest, api_key: str = Secur
@app.post("/v1/docs/files/upload")
async def upload_docs_file(
request: Request,
file: UploadFile = File(...),
parent_id: Optional[str] = Form(default=None),
api_key: str = Security(get_api_key),
auth: dict = Security(_authorize_request),
):
del api_key
del auth
identity, decision = await _guard_api_request(request, scope="docs_write")
if not decision.allowed:
return _risk_json_response(identity, decision)
filename = (file.filename or "").strip()
if not filename:
raise HTTPException(status_code=400, detail="文件名称不能为空")
@@ -529,8 +789,11 @@ async def upload_docs_file(
@app.patch("/v1/docs/nodes/{node_id}")
async def update_docs_node(node_id: str, req: UpdateNodeRequest, api_key: str = Security(get_api_key)):
del api_key
async def update_docs_node(request: Request, node_id: str, req: UpdateNodeRequest, auth: dict = Security(_authorize_request)):
del auth
identity, decision = await _guard_api_request(request, scope="docs_write")
if not decision.allowed:
return _risk_json_response(identity, decision)
fields_set = req.model_fields_set if hasattr(req, "model_fields_set") else getattr(req, "__fields_set__", set())
if not fields_set:
raise HTTPException(status_code=400, detail="缺少更新内容")
@@ -555,11 +818,15 @@ async def update_docs_node(node_id: str, req: UpdateNodeRequest, api_key: str =
@app.put("/v1/docs/files/{node_id}/blob")
async def replace_docs_blob(
request: Request,
node_id: str,
file: UploadFile = File(...),
api_key: str = Security(get_api_key),
auth: dict = Security(_authorize_request),
):
del api_key
del auth
identity, decision = await _guard_api_request(request, scope="docs_write")
if not decision.allowed:
return _risk_json_response(identity, decision)
filename = (file.filename or "").strip()
if not filename:
raise HTTPException(status_code=400, detail="文件名称不能为空")
@@ -574,8 +841,11 @@ async def replace_docs_blob(
@app.delete("/v1/docs/nodes/{node_id}")
async def delete_docs_node(node_id: str, api_key: str = Security(get_api_key)):
del api_key
async def delete_docs_node(request: Request, node_id: str, auth: dict = Security(_authorize_request)):
del auth
identity, decision = await _guard_api_request(request, scope="docs_write")
if not decision.allowed:
return _risk_json_response(identity, decision)
try:
await _docs_store_call("delete_node", node_id)
except RuntimeError as exc:
@@ -584,8 +854,11 @@ async def delete_docs_node(node_id: str, api_key: str = Security(get_api_key)):
@app.get("/v1/docs/files/{node_id}/blob")
async def download_docs_blob(node_id: str, api_key: str = Security(get_api_key)):
del api_key
async def download_docs_blob(request: Request, node_id: str, auth: dict = Security(_authorize_request)):
del auth
identity, decision = await _guard_api_request(request, scope="docs_blob")
if not decision.allowed:
return _risk_json_response(identity, decision)
try:
payload = await _docs_store_call("get_blob", node_id)
except FileNotFoundError as exc:
+11 -3
View File
@@ -26,6 +26,7 @@ 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."
@@ -307,11 +308,17 @@ def register_pro_completion_routes(app: FastAPI, get_api_key):
async def event_stream():
try:
while True:
item = await event_queue.get()
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()
@@ -327,9 +334,10 @@ def register_pro_completion_routes(app: FastAPI, get_api_key):
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
media_type="text/event-stream; charset=utf-8",
headers={
"Cache-Control": "no-cache",
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
+13
View File
@@ -1,3 +1,4 @@
from collections.abc import Mapping
from datetime import datetime, timedelta, timezone
import re
from typing import Tuple
@@ -45,6 +46,16 @@ def _get_current_datetime(timezone_pref: str = "auto") -> str:
)
def _normalize_preferences(preferences: UserPreferences | Mapping | None) -> UserPreferences | None:
if preferences is None:
return None
if isinstance(preferences, UserPreferences):
return preferences
if isinstance(preferences, Mapping):
return UserPreferences(**preferences)
return preferences
def _sanitize_language_id(language_id: str) -> str:
if not language_id:
return "markdown"
@@ -321,6 +332,7 @@ def build_completion_prompts(
thinking_level: str = "low",
preferences: UserPreferences | None = None,
) -> Tuple[str, str, str]:
preferences = _normalize_preferences(preferences)
safe_language_id = _canonical_language_id(language_id)
recent_prefix, recent_suffix = _prepare_context(prefix, suffix)
recent_prefix = _normalize_newlines(recent_prefix)
@@ -420,6 +432,7 @@ def build_pro_completion_prompts(
pro_thinking_level: str = "medium",
preferences: UserPreferences | None = None,
) -> Tuple[str, str]:
preferences = _normalize_preferences(preferences)
safe_language_id = _canonical_language_id(language_id)
recent_prefix, recent_suffix = _prepare_context(prefix, suffix)
recent_prefix = _normalize_newlines(recent_prefix)
+1
View File
@@ -21,3 +21,4 @@ mlx-audio>=0.4.3
# testing
pytest>=7.0.0
pytest-cov>=4.1.0
+140
View File
@@ -0,0 +1,140 @@
import os
from dataclasses import dataclass
def _bool_env(name: str, default: bool) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
def _int_env(name: str, default: int) -> int:
try:
return int(os.getenv(name, str(default)))
except (TypeError, ValueError):
return default
def _float_env(name: str, default: float) -> float:
try:
return float(os.getenv(name, str(default)))
except (TypeError, ValueError):
return default
def _str_env(name: str, default: str) -> str:
value = os.getenv(name)
if value is None:
return default
return value.strip() or default
def _optional_env(name: str) -> str | None:
value = os.getenv(name)
if value is None:
return None
value = value.strip()
return value or None
@dataclass(frozen=True)
class RiskConfig:
cors_allow_origins: tuple[str, ...]
session_cookie_name: str
session_cookie_secure: bool
session_cookie_samesite: str
session_cookie_domain: str | None
session_cookie_max_age: int
session_cookie_path: str
session_rotation_seconds: int
api_window_seconds: int
api_soft_limit_per_window: int
api_hard_limit_per_window: int
llm_window_seconds: int
llm_soft_limit_per_window: int
llm_hard_limit_per_window: int
session_concurrency_limit: int
global_concurrency_limit: int
daily_budget_global_usd: float
daily_budget_session_usd: float
daily_budget_ip_usd: float
single_request_max_cost_usd: float
delay_step_ms: int
delay_cap_ms: int
model_circuit_breaker_failures: int
model_circuit_ttl_seconds: int
enforce_redis_fail_closed: bool
completion_model: str
pro_model: str
vision_model: str
completion_max_input_chars: int
completion_max_output_tokens: int
completion_temperature: float
pro_max_input_chars: int
pro_max_output_tokens: int
pro_temperature: float
compress_max_input_chars: int
compress_max_output_tokens: int
ocr_max_input_bytes: int
completion_input_cost_per_1k: float
completion_output_cost_per_1k: float
pro_input_cost_per_1k: float
pro_output_cost_per_1k: float
vision_input_cost_per_1k: float
vision_output_cost_per_1k: float
def load_risk_config() -> RiskConfig:
raw_origins = _str_env(
"CORS_ALLOW_ORIGINS",
"https://chat.imageteach.tech,http://localhost:8080,http://127.0.0.1:8080",
)
cors_allow_origins = tuple(
origin.strip() for origin in raw_origins.split(",") if origin.strip()
)
return RiskConfig(
cors_allow_origins=cors_allow_origins,
session_cookie_name=_str_env("SESSION_COOKIE_NAME", "llm_anonymous_session"),
session_cookie_secure=_bool_env("SESSION_COOKIE_SECURE", True),
session_cookie_samesite=_str_env("SESSION_COOKIE_SAMESITE", "none"),
session_cookie_domain=_optional_env("SESSION_COOKIE_DOMAIN"),
session_cookie_max_age=_int_env("SESSION_COOKIE_MAX_AGE_SECONDS", 60 * 60 * 24 * 30),
session_cookie_path=_str_env("SESSION_COOKIE_PATH", "/"),
session_rotation_seconds=_int_env("SESSION_ROTATION_SECONDS", 60 * 60 * 24),
api_window_seconds=_int_env("RISK_API_WINDOW_SECONDS", 60),
api_soft_limit_per_window=_int_env("RISK_API_SOFT_LIMIT", 90),
api_hard_limit_per_window=_int_env("RISK_API_HARD_LIMIT", 180),
llm_window_seconds=_int_env("RISK_LLM_WINDOW_SECONDS", 600),
llm_soft_limit_per_window=_int_env("RISK_LLM_SOFT_LIMIT", 8),
llm_hard_limit_per_window=_int_env("RISK_LLM_HARD_LIMIT", 16),
session_concurrency_limit=_int_env("RISK_SESSION_CONCURRENCY_LIMIT", 2),
global_concurrency_limit=_int_env("RISK_GLOBAL_CONCURRENCY_LIMIT", 12),
daily_budget_global_usd=_float_env("RISK_DAILY_BUDGET_GLOBAL_USD", 20.0),
daily_budget_session_usd=_float_env("RISK_DAILY_BUDGET_SESSION_USD", 2.0),
daily_budget_ip_usd=_float_env("RISK_DAILY_BUDGET_IP_USD", 5.0),
single_request_max_cost_usd=_float_env("RISK_SINGLE_REQUEST_MAX_COST_USD", 0.8),
delay_step_ms=_int_env("RISK_DELAY_STEP_MS", 2500),
delay_cap_ms=_int_env("RISK_DELAY_CAP_MS", 30000),
model_circuit_breaker_failures=_int_env("RISK_MODEL_CIRCUIT_FAILURES", 8),
model_circuit_ttl_seconds=_int_env("RISK_MODEL_CIRCUIT_TTL_SECONDS", 300),
enforce_redis_fail_closed=_bool_env("RISK_ENFORCE_REDIS_FAIL_CLOSED", False),
completion_model=_str_env("RISK_COMPLETION_MODEL", os.getenv("LLM_MODEL", "gpt-4.1-mini")),
pro_model=_str_env("RISK_PRO_MODEL", os.getenv("PRO_LLM_MODEL", os.getenv("LLM_MODEL", "gpt-4.1"))),
vision_model=_str_env("RISK_VISION_MODEL", os.getenv("VLM_MODEL", "gpt-4.1-mini")),
completion_max_input_chars=_int_env("RISK_COMPLETION_MAX_INPUT_CHARS", 24000),
completion_max_output_tokens=_int_env("RISK_COMPLETION_MAX_OUTPUT_TOKENS", 768),
completion_temperature=_float_env("RISK_COMPLETION_TEMPERATURE", 0.4),
pro_max_input_chars=_int_env("RISK_PRO_MAX_INPUT_CHARS", 48000),
pro_max_output_tokens=_int_env("RISK_PRO_MAX_OUTPUT_TOKENS", 2048),
pro_temperature=_float_env("RISK_PRO_TEMPERATURE", 0.6),
compress_max_input_chars=_int_env("RISK_COMPRESS_MAX_INPUT_CHARS", 128000),
compress_max_output_tokens=_int_env("RISK_COMPRESS_MAX_OUTPUT_TOKENS", 1536),
ocr_max_input_bytes=_int_env("RISK_OCR_MAX_INPUT_BYTES", 10 * 1024 * 1024),
completion_input_cost_per_1k=_float_env("RISK_COMPLETION_INPUT_COST_PER_1K", 0.0004),
completion_output_cost_per_1k=_float_env("RISK_COMPLETION_OUTPUT_COST_PER_1K", 0.0016),
pro_input_cost_per_1k=_float_env("RISK_PRO_INPUT_COST_PER_1K", 0.003),
pro_output_cost_per_1k=_float_env("RISK_PRO_OUTPUT_COST_PER_1K", 0.012),
vision_input_cost_per_1k=_float_env("RISK_VISION_INPUT_COST_PER_1K", 0.0008),
vision_output_cost_per_1k=_float_env("RISK_VISION_OUTPUT_COST_PER_1K", 0.0024),
)
+354
View File
@@ -0,0 +1,354 @@
import asyncio
import hashlib
import math
import os
import time
from dataclasses import dataclass
from datetime import date
from typing import Any
from risk_config import RiskConfig
try: # pragma: no cover
from redis import asyncio as redis_asyncio
except Exception: # pragma: no cover
redis_asyncio = None
def _now_ms() -> int:
return int(time.time() * 1000)
def _utc_day() -> str:
return date.today().isoformat()
def stable_hash(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def estimate_tokens(text: str) -> int:
if not text:
return 0
ascii_chars = sum(1 for ch in text if ord(ch) < 128)
non_ascii = len(text) - ascii_chars
ascii_tokens = math.ceil(ascii_chars / 4)
non_ascii_tokens = math.ceil(non_ascii * 1.5)
return max(ascii_tokens + non_ascii_tokens, 1)
@dataclass(frozen=True)
class RiskIdentity:
request_id: str
session_hash: str
ip_hash: str
route: str
method: str
@dataclass(frozen=True)
class RiskDecision:
allowed: bool
status_code: int = 200
reason: str = ""
error_code: str = ""
retry_after_seconds: int = 0
delay_ms: int = 0
class RiskRejected(RuntimeError):
def __init__(self, decision: RiskDecision) -> None:
super().__init__(decision.reason or decision.error_code or "request rejected")
self.decision = decision
class BaseRiskBackend:
async def incr_window(self, key: str, ttl_seconds: int) -> int:
raise NotImplementedError
async def get_float(self, key: str) -> float:
raise NotImplementedError
async def add_float(self, key: str, value: float, ttl_seconds: int) -> float:
raise NotImplementedError
async def get_int(self, key: str) -> int:
raise NotImplementedError
async def set_int(self, key: str, value: int, ttl_seconds: int) -> None:
raise NotImplementedError
async def set_float(self, key: str, value: float, ttl_seconds: int) -> None:
raise NotImplementedError
async def acquire_lock(self, key: str, ttl_seconds: int) -> bool:
raise NotImplementedError
async def release_lock(self, key: str) -> None:
raise NotImplementedError
class InMemoryRiskBackend(BaseRiskBackend):
def __init__(self) -> None:
self.values: dict[str, tuple[float, float]] = {}
self.locks: dict[str, float] = {}
self.guard = asyncio.Lock()
def _purge(self) -> None:
now = time.time()
for key, (_, expires_at) in list(self.values.items()):
if expires_at and expires_at <= now:
self.values.pop(key, None)
for key, expires_at in list(self.locks.items()):
if expires_at <= now:
self.locks.pop(key, None)
async def incr_window(self, key: str, ttl_seconds: int) -> int:
async with self.guard:
self._purge()
value, _ = self.values.get(key, (0.0, 0.0))
next_value = int(value) + 1
self.values[key] = (float(next_value), time.time() + ttl_seconds)
return next_value
async def get_float(self, key: str) -> float:
async with self.guard:
self._purge()
return float(self.values.get(key, (0.0, 0.0))[0])
async def add_float(self, key: str, value: float, ttl_seconds: int) -> float:
async with self.guard:
self._purge()
current, _ = self.values.get(key, (0.0, 0.0))
next_value = current + value
self.values[key] = (next_value, time.time() + ttl_seconds)
return next_value
async def get_int(self, key: str) -> int:
return int(await self.get_float(key))
async def set_int(self, key: str, value: int, ttl_seconds: int) -> None:
async with self.guard:
self._purge()
self.values[key] = (float(value), time.time() + ttl_seconds)
async def set_float(self, key: str, value: float, ttl_seconds: int) -> None:
async with self.guard:
self._purge()
self.values[key] = (float(value), time.time() + ttl_seconds)
async def acquire_lock(self, key: str, ttl_seconds: int) -> bool:
async with self.guard:
self._purge()
if key in self.locks:
return False
self.locks[key] = time.time() + ttl_seconds
return True
async def release_lock(self, key: str) -> None:
async with self.guard:
self.locks.pop(key, None)
class RedisRiskBackend(BaseRiskBackend):
def __init__(self, redis_url: str) -> None:
if redis_asyncio is None:
raise RuntimeError("redis package is not installed")
self.redis = redis_asyncio.from_url(redis_url, encoding="utf-8", decode_responses=True)
async def incr_window(self, key: str, ttl_seconds: int) -> int:
value = await self.redis.incr(key)
if value == 1:
await self.redis.expire(key, ttl_seconds)
return int(value)
async def get_float(self, key: str) -> float:
value = await self.redis.get(key)
if value is None:
return 0.0
return float(value)
async def add_float(self, key: str, value: float, ttl_seconds: int) -> float:
current = await self.get_float(key)
next_value = current + value
await self.redis.set(key, next_value, ex=ttl_seconds)
return next_value
async def get_int(self, key: str) -> int:
value = await self.redis.get(key)
if value is None:
return 0
return int(value)
async def set_int(self, key: str, value: int, ttl_seconds: int) -> None:
await self.redis.set(key, value, ex=ttl_seconds)
async def set_float(self, key: str, value: float, ttl_seconds: int) -> None:
await self.redis.set(key, value, ex=ttl_seconds)
async def acquire_lock(self, key: str, ttl_seconds: int) -> bool:
return bool(await self.redis.set(key, "1", ex=ttl_seconds, nx=True))
async def release_lock(self, key: str) -> None:
await self.redis.delete(key)
class RiskController:
def __init__(self, config: RiskConfig) -> None:
self.config = config
self.prefix = "llmtext:risk"
redis_url = os.getenv("REDIS_URL", "").strip()
if redis_url and redis_asyncio is not None:
self.backend: BaseRiskBackend = RedisRiskBackend(redis_url)
else:
self.backend = InMemoryRiskBackend()
def _api_key(self, identity: RiskIdentity, scope: str) -> str:
return f"{self.prefix}:api:{scope}:{identity.session_hash}:{identity.ip_hash}"
def _llm_key(self, identity: RiskIdentity, scope: str) -> str:
return f"{self.prefix}:llm:{scope}:{identity.session_hash}:{identity.ip_hash}"
def _budget_key(self, scope: str, scope_hash: str, current_day: str) -> str:
return f"{self.prefix}:budget:{scope}:{scope_hash}:{current_day}"
def _lock_key(self, scope: str, scope_hash: str) -> str:
return f"{self.prefix}:lock:{scope}:{scope_hash}"
def _circuit_key(self, scope: str) -> str:
return f"{self.prefix}:circuit:{scope}"
def _failure_key(self, model: str) -> str:
return f"{self.prefix}:failure:{model}"
async def check_api(self, identity: RiskIdentity, *, scope: str = "default") -> RiskDecision:
key = self._api_key(identity, scope)
count = await self.backend.incr_window(key, self.config.api_window_seconds)
if count > self.config.api_hard_limit_per_window:
return RiskDecision(
allowed=False,
status_code=429,
reason="请求过于频繁,请稍后再试",
error_code="api_rate_limited",
retry_after_seconds=self.config.api_window_seconds,
)
if count > self.config.api_soft_limit_per_window:
overflow = count - self.config.api_soft_limit_per_window
delay_ms = min(self.config.delay_cap_ms, overflow * self.config.delay_step_ms)
return RiskDecision(allowed=True, delay_ms=delay_ms)
return RiskDecision(allowed=True)
async def check_llm(
self,
identity: RiskIdentity,
*,
scope: str,
estimated_cost: float,
) -> RiskDecision:
global_circuit = await self.backend.get_int(self._circuit_key("global"))
model_circuit = await self.backend.get_int(self._circuit_key(scope))
if global_circuit > 0 or model_circuit > 0:
return RiskDecision(
allowed=False,
status_code=503,
reason="当前推理服务繁忙,请稍后再试",
error_code="llm_circuit_open",
retry_after_seconds=self.config.model_circuit_ttl_seconds,
)
if estimated_cost > self.config.single_request_max_cost_usd:
return RiskDecision(
allowed=False,
status_code=429,
reason="单次请求成本过高,已被拒绝",
error_code="llm_cost_too_high",
)
day = _utc_day()
session_budget = await self.backend.get_float(self._budget_key("session", identity.session_hash, day))
ip_budget = await self.backend.get_float(self._budget_key("ip", identity.ip_hash, day))
global_budget = await self.backend.get_float(self._budget_key("global", "global", day))
if session_budget + estimated_cost > self.config.daily_budget_session_usd:
return RiskDecision(False, 429, "当前匿名会话今日额度已用尽", "session_budget_exhausted", 3600)
if ip_budget + estimated_cost > self.config.daily_budget_ip_usd:
return RiskDecision(False, 429, "当前网络环境今日额度已用尽", "ip_budget_exhausted", 3600)
if global_budget + estimated_cost > self.config.daily_budget_global_usd:
await self.backend.set_int(self._circuit_key("global"), 1, self.config.model_circuit_ttl_seconds)
return RiskDecision(False, 503, "今日全局推理预算已耗尽", "global_budget_exhausted", 3600)
key = self._llm_key(identity, scope)
count = await self.backend.incr_window(key, self.config.llm_window_seconds)
if count > self.config.llm_hard_limit_per_window:
return RiskDecision(False, 429, "推理请求过于频繁,请稍后重试", "llm_rate_limited", self.config.llm_window_seconds)
if count > self.config.llm_soft_limit_per_window:
overflow = count - self.config.llm_soft_limit_per_window
delay_ms = min(self.config.delay_cap_ms, overflow * self.config.delay_step_ms)
return RiskDecision(True, delay_ms=delay_ms)
return RiskDecision(True)
async def reserve_budget(self, identity: RiskIdentity, estimated_cost: float) -> None:
day = _utc_day()
ttl_seconds = 60 * 60 * 24
await self.backend.add_float(self._budget_key("session", identity.session_hash, day), estimated_cost, ttl_seconds)
await self.backend.add_float(self._budget_key("ip", identity.ip_hash, day), estimated_cost, ttl_seconds)
await self.backend.add_float(self._budget_key("global", "global", day), estimated_cost, ttl_seconds)
async def acquire_execution_slot(self, identity: RiskIdentity, *, model: str) -> list[str]:
ttl_seconds = 60 * 15
keys = [
self._lock_key("session", f"{identity.session_hash}:{identity.request_id}"),
self._lock_key("global", identity.request_id),
]
session_running = await self.backend.get_int(self._lock_key("session-count", identity.session_hash))
global_running = await self.backend.get_int(self._lock_key("global-count", "global"))
if session_running >= self.config.session_concurrency_limit:
raise RiskRejected(
RiskDecision(False, 429, "当前会话并发推理过多,请稍后重试", "session_concurrency_limited", 30)
)
if global_running >= self.config.global_concurrency_limit:
raise RiskRejected(
RiskDecision(False, 503, "当前全局推理负载过高,请稍后重试", "global_concurrency_limited", 30)
)
acquired: list[str] = []
for key in keys:
ok = await self.backend.acquire_lock(key, ttl_seconds)
if not ok:
for acquired_key in acquired:
await self.backend.release_lock(acquired_key)
raise RiskRejected(
RiskDecision(False, 429, "当前请求正在执行,请勿重复提交", "duplicate_request", 10)
)
acquired.append(key)
await self.backend.add_float(self._lock_key("session-count", identity.session_hash), 1.0, ttl_seconds)
await self.backend.add_float(self._lock_key("global-count", "global"), 1.0, ttl_seconds)
return acquired
async def release_execution_slot(self, identity: RiskIdentity, lock_keys: list[str], *, model: str) -> None:
for key in lock_keys:
await self.backend.release_lock(key)
session_count_key = self._lock_key("session-count", identity.session_hash)
global_count_key = self._lock_key("global-count", "global")
session_count = max(0.0, await self.backend.get_float(session_count_key) - 1.0)
global_count = max(0.0, await self.backend.get_float(global_count_key) - 1.0)
await self.backend.set_float(session_count_key, session_count, 60 * 15)
await self.backend.set_float(global_count_key, global_count, 60 * 15)
async def record_model_result(self, *, model: str, success: bool) -> None:
if success:
await self.backend.set_int(self._failure_key(model), 0, self.config.model_circuit_ttl_seconds)
return
failures = await self.backend.incr_window(self._failure_key(model), self.config.model_circuit_ttl_seconds)
if failures >= self.config.model_circuit_breaker_failures:
await self.backend.set_int(self._circuit_key(model), 1, self.config.model_circuit_ttl_seconds)
_risk_controller: RiskController | None = None
def get_risk_controller(config: RiskConfig) -> RiskController:
global _risk_controller
if _risk_controller is None:
_risk_controller = RiskController(config)
return _risk_controller
def reset_risk_controller() -> None:
global _risk_controller
_risk_controller = None
+192
View File
@@ -0,0 +1,192 @@
import hashlib
import secrets
import threading
import time
from dataclasses import dataclass
from typing import Any
try:
import psycopg
from psycopg.rows import dict_row
except Exception: # pragma: no cover
psycopg = None
dict_row = None
def _now_ms() -> int:
return int(time.time() * 1000)
def hash_value(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def new_session_id() -> str:
return secrets.token_urlsafe(32)
@dataclass
class SessionRecord:
session_id: str
session_hash: str
created_at_ms: int
last_seen_at_ms: int
first_ip_hash: str
last_ip_hash: str
user_agent_hash: str
risk_score: int = 0
blocked_until_ms: int = 0
is_new: bool = False
class BaseSessionStore:
def get_or_create(self, session_id: str | None, *, client_ip_hash: str, user_agent_hash: str) -> SessionRecord:
raise NotImplementedError
class InMemorySessionStore(BaseSessionStore):
def __init__(self) -> None:
self.sessions: dict[str, SessionRecord] = {}
self.lock = threading.Lock()
def get_or_create(self, session_id: str | None, *, client_ip_hash: str, user_agent_hash: str) -> SessionRecord:
now = _now_ms()
with self.lock:
if session_id:
session_hash = hash_value(session_id)
record = self.sessions.get(session_hash)
if record is not None:
record.last_seen_at_ms = now
record.last_ip_hash = client_ip_hash
record.user_agent_hash = user_agent_hash
record.is_new = False
return record
next_session_id = new_session_id()
next_hash = hash_value(next_session_id)
record = SessionRecord(
session_id=next_session_id,
session_hash=next_hash,
created_at_ms=now,
last_seen_at_ms=now,
first_ip_hash=client_ip_hash,
last_ip_hash=client_ip_hash,
user_agent_hash=user_agent_hash,
is_new=True,
)
self.sessions[next_hash] = record
return record
class PostgresSessionStore(BaseSessionStore):
def __init__(self, database_url: str) -> None:
if psycopg is None or dict_row is None:
raise RuntimeError("psycopg 未安装,无法使用 PostgreSQL session 存储")
self.database_url = database_url
self._init_lock = threading.Lock()
self._initialized = False
def _connect(self):
return psycopg.connect(self.database_url, autocommit=True, row_factory=dict_row)
def _ensure_initialized(self) -> None:
if self._initialized:
return
with self._init_lock:
if self._initialized:
return
with self._connect() as conn:
with conn.cursor() as cur:
cur.execute(
"""
CREATE TABLE IF NOT EXISTS anonymous_sessions (
session_hash TEXT PRIMARY KEY,
created_at_ms BIGINT NOT NULL,
last_seen_at_ms BIGINT NOT NULL,
first_ip_hash TEXT NOT NULL,
last_ip_hash TEXT NOT NULL,
user_agent_hash TEXT NOT NULL,
risk_score INTEGER NOT NULL DEFAULT 0,
blocked_until_ms BIGINT NOT NULL DEFAULT 0,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
)
"""
)
cur.execute(
"CREATE INDEX IF NOT EXISTS anonymous_sessions_last_seen_idx ON anonymous_sessions(last_seen_at_ms)"
)
self._initialized = True
def get_or_create(self, session_id: str | None, *, client_ip_hash: str, user_agent_hash: str) -> SessionRecord:
self._ensure_initialized()
now = _now_ms()
if session_id:
session_hash = hash_value(session_id)
with self._connect() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE anonymous_sessions
SET last_seen_at_ms = %s,
last_ip_hash = %s,
user_agent_hash = %s
WHERE session_hash = %s
RETURNING session_hash, created_at_ms, last_seen_at_ms, first_ip_hash, last_ip_hash, user_agent_hash, risk_score, blocked_until_ms
""",
(now, client_ip_hash, user_agent_hash, session_hash),
)
row = cur.fetchone()
if row is not None:
return SessionRecord(
session_id=session_id,
session_hash=row["session_hash"],
created_at_ms=int(row["created_at_ms"]),
last_seen_at_ms=int(row["last_seen_at_ms"]),
first_ip_hash=row["first_ip_hash"],
last_ip_hash=row["last_ip_hash"],
user_agent_hash=row["user_agent_hash"],
risk_score=int(row["risk_score"] or 0),
blocked_until_ms=int(row["blocked_until_ms"] or 0),
is_new=False,
)
next_session_id = new_session_id()
next_hash = hash_value(next_session_id)
with self._connect() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO anonymous_sessions (
session_hash, created_at_ms, last_seen_at_ms, first_ip_hash, last_ip_hash, user_agent_hash
)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(next_hash, now, now, client_ip_hash, client_ip_hash, user_agent_hash),
)
return SessionRecord(
session_id=next_session_id,
session_hash=next_hash,
created_at_ms=now,
last_seen_at_ms=now,
first_ip_hash=client_ip_hash,
last_ip_hash=client_ip_hash,
user_agent_hash=user_agent_hash,
is_new=True,
)
_session_store: BaseSessionStore | None = None
def get_session_store(database_url: str | None = None) -> BaseSessionStore:
global _session_store
if _session_store is not None:
return _session_store
if database_url:
_session_store = PostgresSessionStore(database_url)
else:
_session_store = InMemorySessionStore()
return _session_store
def reset_session_store() -> None:
global _session_store
_session_store = None
+6
View File
@@ -15,6 +15,9 @@ if str(BACKEND_DIR) not in sys.path:
import job_handlers # type: ignore
import job_system # type: ignore
import risk_control # type: ignore
import session_store # type: ignore
import audit_store # type: ignore
main = importlib.import_module("main")
@@ -23,6 +26,9 @@ API_KEY_HEADERS = {"X-API-Key": "your-secret-key-here"}
def setup_function():
job_system.reset_job_manager()
risk_control.reset_risk_controller()
session_store.reset_session_store()
audit_store.reset_audit_store()
main._handlers_registered = False
+59 -4
View File
@@ -1,4 +1,5 @@
import base64
import asyncio
import importlib
import os
import sys
@@ -17,6 +18,9 @@ if str(BACKEND_DIR) not in sys.path:
import job_handlers # type: ignore
import job_system # type: ignore
import docs_store # type: ignore
import risk_control # type: ignore
import session_store # type: ignore
import audit_store # type: ignore
main = importlib.import_module("main")
@@ -26,6 +30,9 @@ HEADERS = {"X-API-Key": main.API_KEY}
def setup_function():
job_system.reset_job_manager()
docs_store.reset_document_store()
risk_control.reset_risk_controller()
session_store.reset_session_store()
audit_store.reset_audit_store()
main._handlers_registered = False
@@ -61,13 +68,31 @@ def test_get_client_ip_header_overrides_host():
assert main.get_client_ip(req) == "5.6.7.8"
def test_post_completions_wrong_api_key_returns_401():
def test_post_completions_without_api_key_uses_anonymous_session(monkeypatch):
async def fake_call(*args, **kwargs):
return {"content": "系统done", "think": ""}
monkeypatch.setattr(job_handlers, "call_ollama", fake_call)
with TestClient(main.app) as client:
resp = client.post("/v1/completions", json={
with client.stream("POST", "/v1/completions", json={
"prefix": "hello", "suffix": "", "languageId": "markdown",
"model_thinking": "low", "privacy_mode": True,
})
assert resp.status_code == 401
}) as resp:
assert resp.status_code == 200
assert main.config.session_cookie_name in resp.cookies
def test_post_completions_invalid_api_key_returns_403():
with TestClient(main.app) as client:
resp = client.post(
"/v1/completions",
headers={"X-API-Key": "invalid-key"},
json={
"prefix": "hello", "suffix": "", "languageId": "markdown",
"model_thinking": "low", "privacy_mode": True,
},
)
assert resp.status_code == 403
def test_post_completions_returns_sse_done(monkeypatch):
@@ -88,6 +113,36 @@ def test_post_completions_returns_sse_done(monkeypatch):
assert "event: done" in body
def test_stream_job_emits_keepalive_during_idle(monkeypatch):
async def fake_queue_job(*_args, **_kwargs):
return "job-keepalive"
class FakeManager:
def register_handler(self, *_args, **_kwargs):
return None
async def stream_events(self, job_id):
yield {"event": "queued", "job_id": job_id}
yield {"event": "started", "job_id": job_id}
await asyncio.sleep(0.03)
yield {"event": "done", "job_id": job_id, "result": {"content": "ok"}}
monkeypatch.setattr(main, "STREAM_HEARTBEAT_SECONDS", 0.01)
monkeypatch.setattr(main, "_queue_job", fake_queue_job)
monkeypatch.setattr(main, "get_job_manager", lambda: FakeManager())
with TestClient(main.app) as client:
with client.stream("POST", "/v1/pro/completions", headers=HEADERS, json={
"prefix": "hello", "suffix": "", "languageId": "markdown",
"instruction": "expand", "pro_thinking": "medium", "privacy_mode": True,
}) as resp:
assert resp.status_code == 200
body = "".join(resp.iter_text())
assert ": keepalive" in body
assert "event: done" in body
def test_post_ocr_mocked(monkeypatch):
async def fake_ocr(*args, **kwargs):
return "OCR result text"
+23
View File
@@ -33,6 +33,11 @@ def _payload():
"instruction": "expand",
"pro_thinking": "medium",
"privacy_mode": True,
"user_preferences": {
"language": "zh",
"currency": "CNY",
"timezone": "Asia/Shanghai",
},
}
@@ -66,6 +71,24 @@ def test_pro_prompt_uses_pro_specific_instruction():
assert "pro_thinking_level: high" in combined
def test_pro_prompt_accepts_serialized_preferences():
_, user_prompt = prompt.build_pro_completion_prompts(
prefix="Before",
suffix="After",
language_id="markdown",
instruction="expand",
preferences={
"language": "zh",
"currency": "CNY",
"timezone": "Asia/Shanghai",
},
)
assert "Preferred language: zh" in user_prompt
assert "Preferred currency: CNY" in user_prompt
assert "Preferred timezone: Asia/Shanghai" in user_prompt
def test_pro_stream_returns_standard_events(monkeypatch):
async def fake_stream_events(*args, **kwargs):
yield "thinking", ""
+10
View File
@@ -17,6 +17,11 @@ from pydantic import BaseModel
logger = logging.getLogger(__name__)
try:
asyncio.get_running_loop()
except RuntimeError:
asyncio.set_event_loop(asyncio.new_event_loop())
# New TTS model import
try:
from qwen_tts import Qwen3TTSModel # type: ignore
@@ -484,3 +489,8 @@ def register_tts_asr_routes(app, include_generation_routes: bool = True):
app.include_router(meta_router, prefix="/v1/tts-asr")
if include_generation_routes:
app.include_router(generation_router, prefix="/v1/tts-asr")
router = APIRouter()
router.include_router(meta_router)
router.include_router(generation_router)
+2
View File
@@ -42,6 +42,8 @@ services:
depends_on:
- postgres
- redis
ports:
- "8001:8001"
volumes:
- ./docker-data/jobs:/shared-jobs
+1 -7
View File
@@ -10,12 +10,6 @@ server {
}
location /v1/ {
proxy_pass http://api:8001/v1/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
return 307 https://api.imageteach.tech:8002$request_uri;
}
}
+11 -1
View File
@@ -29,7 +29,8 @@
"tui-image-editor": "^3.15.3",
"vue": "^3.5.24",
"vue-i18n": "^9.14.5",
"vue-router": "^4.6.4"
"vue-router": "^4.6.4",
"vue3-captcha": "^0.3.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
@@ -12107,6 +12108,15 @@
"vue": "^3.5.0"
}
},
"node_modules/vue3-captcha": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/vue3-captcha/-/vue3-captcha-0.3.4.tgz",
"integrity": "sha512-mQrti94ZADcXCDVrFTZm5uyxQix+sYHMzylQOQyGUD+RENBcFR9MoSTQvQ7jNOKd2eM6miggKIY2DsB264FGdA==",
"license": "MIT",
"dependencies": {
"vue": "^3.2.25"
}
},
"node_modules/w3c-hr-time": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
+2 -1
View File
@@ -32,7 +32,8 @@
"tui-image-editor": "^3.15.3",
"vue": "^3.5.24",
"vue-i18n": "^9.14.5",
"vue-router": "^4.6.4"
"vue-router": "^4.6.4",
"vue3-captcha": "^0.3.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE_NAME = 'llm-in-text-v1';
const CACHE_NAME = 'llm-in-text-v2';
const APP_SHELL_ASSETS = [
'/',
'/index.html',
+1 -2
View File
@@ -3,7 +3,7 @@ testpaths = backend/tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -v --tb=short --cov=backend.main --cov=backend.llm --cov=backend.prompt --cov=backend.geoip --cov=backend.prompts --cov=backend.tts_asr --cov-report=term-missing --cov-report=html --cov-fail-under=90
addopts = -v --tb=short --cov=backend --cov-report=term-missing --cov-report=html
[coverage:run]
omit =
@@ -11,7 +11,6 @@ omit =
backend/test_*.py
[coverage:report]
fail_under = 90
exclude_lines =
pragma: no cover
if TYPE_CHECKING:
+1
View File
@@ -68,6 +68,7 @@
### 请求层
- utils/config.js 负责从 VITE_* 环境变量拼接接口地址。
- 前端 API 默认基址必须是 `https://api.imageteach.tech:8002`;不要把前端请求导向 Docker 内后端、本机 `localhost:8001` / `localhost:8081`,也不要依赖 Docker nginx 的同源 `/v1` 代理。
- utils/api.js 负责补全请求、取消补全、TTS 请求和状态请求。
- fetchSuggestion 会:
- 生成 request_id
+69
View File
@@ -0,0 +1,69 @@
<template>
<div class="captcha-wrapper">
<Captcha
ref="captchaRef"
:width="300"
:height="100"
:borderColor="'#e2e8f0'"
:bgColor="'#ffffff'"
:clickRefresh="true"
:failRefresh="false"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import Captcha, { CaptchaInstance } from 'vue3-captcha'
//
const captchaRef = ref<CaptchaInstance>(null)
/**
* 验证用户输入的验证码
* @param userInput - 用户输入的值
* @returns boolean - 验证是否成功
*/
const validate = (userInput: string): boolean => {
return captchaRef.value?.check(userInput) ?? false
}
/**
* 手动刷新验证码
*/
const refresh = () => {
captchaRef.value?.refresh()
}
//
defineExpose({
validate,
refresh,
captchaRef
})
</script>
<style scoped>
.captcha-wrapper {
display: flex;
justify-content: center;
margin: 16px 0;
}
/* 验证码图片样式 */
:c-deep(.captcha-canvas) {
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* 刷新按钮样式 */
:c-deep(.captcha-refresh) {
cursor: pointer;
opacity: 0.8;
transition: opacity 0.2s;
}
:c-deep(.captcha-refresh:hover) {
opacity: 1;
}
</style>
+20 -24
View File
@@ -358,8 +358,6 @@ const isOverLimit = computed(() => contentSize.value > SIZE_LIMIT)
const sizeInKB = computed(() => Math.floor(contentSize.value / 1024))
const undoLabel = computed(() => t('undo') || 'Undo')
const redoLabel = computed(() => t('redo') || 'Redo')
const API_KEY = 'your-secret-key-here'
const ttsMenuVisible = ref(false)
const ttsMenuX = ref(0)
const ttsMenuY = ref(0)
@@ -504,7 +502,17 @@ const applySelectedTemplate = () => {
if (!selectedTemplate.value) return
clearCurrentGhost()
insertMarkdownAtCursor(selectedTemplate.value.content || '')
//
// 使 replaceAll
if (crepe && crepe.editor) {
crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
replaceAll(selectedTemplate.value.content || '')(ctx)
view.focus()
})
}
closeTemplateModal()
}
@@ -1055,12 +1063,13 @@ const performOCR = async (file, cacheKey, imageHash = '') => {
const base64 = dataUrl.slice(splitIndex + 1)
try {
const headers = {
'Content-Type': 'application/json',
}
const res = await fetch(OCR_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': 'your-secret-key-here'
},
headers,
credentials: 'include',
body: JSON.stringify({
image: base64,
filename: file.name,
@@ -1492,9 +1501,7 @@ const exportPdf = async () => {
const res = await fetch(EXPORT_PDF_URL, {
method: 'POST',
headers: {
'X-API-Key': API_KEY,
},
credentials: 'include',
body: formData,
})
@@ -2472,23 +2479,12 @@ for (const url of Array.from(objectUrls)) {
}
.pro-block-accepted-highlight {
background: rgba(59, 130, 246, 0.16);
animation: pro-accept-fade 1s ease forwards;
background: transparent;
}
.pro-block-accepted-block {
background: linear-gradient(180deg, rgba(219, 234, 254, 0.72) 0%, rgba(239, 246, 255, 0.22) 100%);
border-radius: 14px;
animation: pro-accept-fade 1s ease forwards;
}
@keyframes pro-accept-fade {
from {
background-color: rgba(59, 130, 246, 0.18);
}
to {
background-color: transparent;
}
background: transparent;
border-radius: 0;
}
.upload-progress-overlay {
+4 -14
View File
@@ -229,24 +229,14 @@ const handleInstructionInput = (event) => props.updateInstructionAction?.(event.
}
.pro-block-panel {
border: 1px solid rgba(14, 165, 233, 0.32);
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 8px;
background:
radial-gradient(circle at 20% 0%, rgba(56, 189, 248, 0.22), transparent 30%),
radial-gradient(circle at 80% 0%, rgba(168, 85, 247, 0.18), transparent 32%),
color-mix(in srgb, var(--panel-bg) 88%, #07111f 12%);
box-shadow: 0 20px 50px rgba(2, 8, 23, 0.18);
background: color-mix(in srgb, var(--panel-bg) 96%, var(--app-bg) 4%);
box-shadow: 0 16px 36px rgba(2, 8, 23, 0.12);
}
.pro-block-panel::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.12), transparent);
transform: translateX(-100%);
animation: pro-scan 2.8s ease-in-out infinite;
z-index: -1;
content: none;
}
.pro-block-header {
+59
View File
@@ -3,6 +3,7 @@ import { ref, watch, computed, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useSettingsStore } from '../stores/settings'
import { useTheme } from '../composables/useTheme'
import CaptchaComponent from './CaptchaComponent.vue'
import packageJson from '../../package.json'
const store = useSettingsStore()
@@ -137,6 +138,33 @@ const switchView = (view) => {
router.push(view === 'editor' ? '/' : '/docs')
closePanel()
}
// --- Security & Captcha State ---
const showCaptcha = ref(false) // Show captcha in privacy mode
const userInput = ref('') // User's captcha input
const isValid = ref(false) // Validation success state
const attempted = ref(false) // Whether validation was attempted
const captchaComp = ref(null)
// Watch privacy mode to show/hide captcha
watch(
() => store.privacyMode,
(newVal) => {
showCaptcha.value = newVal // Show captcha when privacy mode is enabled
}
)
// Captcha validation handler
const validateCaptcha = () => {
attempted.value = true
// Call the captcha component's validate method
isValid.value = captchaComp.value?.validate(userInput.value) ?? false
// If validation failed, refresh the captcha
if (!isValid.value) {
captchaComp.value?.refresh()
}
}
</script>
<template>
@@ -323,6 +351,37 @@ const switchView = (view) => {
</div>
</section>
<!-- Security & Captcha Section -->
<section class="settings-section" v-if="showCaptcha">
<h3>{{ t('security') || '安全设置' }}</h3>
<div class="captcha-section">
<!-- Captcha Component -->
<CaptchaComponent ref="captchaComp" />
<!-- User Input Field -->
<input
v-model="userInput"
:placeholder="t('captchaInputPlaceholder') || '请输入验证码'"
class="captcha-input"
/>
<!-- Validate Button -->
<button
@click="validateCaptcha"
class="validate-btn"
:disabled="!userInput"
>{{ t('captchaValidate') || '验证' }}</button>
<!-- Validation Result -->
<p v-if="isValid" class="success-text">{{ t('captchaSuccess') || '验证成功' }}</p>
<p v-else-if="attempted && !isValid" class="error-text">{{ t('captchaFailed') || '验证失败请重试' }}</p>
<!-- Help Text -->
<p class="help-text">{{ t('captchaDesc') || '用于验证用户操作真实性' }}</p>
</div>
</section>
<!-- TTS Settings -->
<section class="settings-section">
<h3>{{ t('ttsSettings') || '语音设置' }}</h3>
+20 -20
View File
@@ -1,9 +1,10 @@
import { createApp, h, reactive } from 'vue'
import { parserCtx, serializerCtx } from '@milkdown/kit/core'
import { remarkCtx, schemaCtx, serializerCtx, type Ctx } from '@milkdown/kit/core'
import { $ctx, $node, $prose, $remark, $view } from '@milkdown/kit/utils'
import { Plugin, PluginKey, Selection } from '@milkdown/prose/state'
import { type Node as ProseNode, Slice, type Schema } from '@milkdown/prose/model'
import { Decoration, DecorationSet, type EditorView, type NodeView } from '@milkdown/prose/view'
import { ParserState } from '@milkdown/transformer'
import ProBlockCrepe from '../components/ProBlockCrepe.vue'
import { extractDocBlockContextFromMarkdown } from '../utils/docBlock.js'
import { buildOcrContextForDoc } from '../utils/ocrCache'
@@ -158,6 +159,13 @@ function replaceWithParsedMarkdownSlice(tr: any, from: number, to: number, parse
return getTransactionInsertedRange(tr, from, to, beforeSize)
}
function parseMarkdownWithCurrentSchema(ctx: Ctx, markdown: string) {
const schema = ctx.get(schemaCtx)
const remark = ctx.get(remarkCtx)
const parser = ParserState.create(schema, remark)
return parser(markdown)
}
function replaceWithTextFallback(
tr: any,
from: number,
@@ -254,10 +262,6 @@ function isAbortError(error: unknown) {
return Boolean(error && typeof error === 'object' && 'name' in error && (error as { name?: string }).name === 'AbortError')
}
function normalizeProMarkdown(value = '') {
return String(value || '').replace(/\r\n?/g, '\n').trim()
}
export const proBlockConfigCtx = $ctx<ProBlockConfig, 'proBlockConfig'>({
fetchSuggestionStream: async () => '',
t: (key: string) => key,
@@ -271,8 +275,7 @@ class ProBlockNodeView implements NodeView {
dom: HTMLElement
app: ReturnType<typeof createApp> | null = null
props: Record<string, any>
parser: (markdown: string) => Promise<ProseNode>
serializer: (content: ProseNode) => string
ctx: Ctx
config: ProBlockConfig
abortController: AbortController | null = null
requestSeq = 0
@@ -285,15 +288,13 @@ class ProBlockNodeView implements NodeView {
node: ProseNode,
view: EditorView,
getPos: (() => number) | boolean,
parser: (markdown: string) => Promise<ProseNode>,
serializer: (content: ProseNode) => string,
ctx: Ctx,
config: ProBlockConfig
) {
this.node = node
this.view = view
this.getPos = getPos
this.parser = parser
this.serializer = serializer
this.ctx = ctx
this.config = config
this.dom = document.createElement('div')
this.dom.className = 'pro-block-node-view'
@@ -381,7 +382,7 @@ class ProBlockNodeView implements NodeView {
setStage(stage: 'idle' | 'queued' | 'thinking' | 'streaming' | 'done' | 'error' | 'cancelled', previewContent = '') {
this.props.stage = stage
this.props.previewContent = previewContent
this.props.previewContent = previewContent ? normalizeProAcceptMarkdown(previewContent) : ''
this.props.isBusy = stage === 'queued' || stage === 'thinking' || stage === 'streaming'
this.props.isThinking = stage === 'thinking'
this.refreshCandidateProps()
@@ -402,7 +403,7 @@ class ProBlockNodeView implements NodeView {
}
setResult(content: string) {
const normalized = normalizeProMarkdown(content)
const normalized = normalizeProAcceptMarkdown(content)
if (!normalized) return
this.candidates.push(normalized)
if (this.candidates.length > 5) {
@@ -432,10 +433,11 @@ class ProBlockNodeView implements NodeView {
const doc = this.view.state.doc
const schema = this.view.state.schema
const prefixMarkdown = serializeRangeToMarkdown(doc, 0, pos, schema, this.serializer)
const serializer = this.ctx.get(serializerCtx)
const prefixMarkdown = serializeRangeToMarkdown(doc, 0, pos, schema, serializer)
|| doc.textBetween(0, pos, FALLBACK_BLOCK_SEPARATOR, FALLBACK_LEAF_TEXT)
const suffixStart = Math.min(pos + this.node.nodeSize, doc.content.size)
const suffixMarkdown = serializeRangeToMarkdown(doc, suffixStart, doc.content.size, schema, this.serializer)
const suffixMarkdown = serializeRangeToMarkdown(doc, suffixStart, doc.content.size, schema, serializer)
|| doc.textBetween(suffixStart, doc.content.size, FALLBACK_BLOCK_SEPARATOR, FALLBACK_LEAF_TEXT)
const ocrContext = buildOcrContextForDoc(doc, 120)
const docContext = extractDocBlockContextFromMarkdown(`${prefixMarkdown}\n\n${suffixMarkdown}`, 1600)
@@ -551,7 +553,7 @@ class ProBlockNodeView implements NodeView {
if (this.destroyed || this.requestSeq !== requestSeq) return
const content = normalizeProMarkdown(result || this.props.previewContent || '')
const content = normalizeProAcceptMarkdown(result || this.props.previewContent || '')
if (!content) {
this.props.errorMessage = this.config.t('proEmptyResult') || 'PRO 返回空结果,请重试或缩短上下文。'
this.setStage('error', '')
@@ -598,7 +600,7 @@ class ProBlockNodeView implements NodeView {
let insertedRange: { from: number; to: number } | null = null
try {
const parsedDoc = await this.parser(source)
const parsedDoc = parseMarkdownWithCurrentSchema(this.ctx, source)
insertedRange = replaceWithParsedMarkdownSlice(tr, from, to, parsedDoc)
} catch (e) {
console.error('PRO block parse failed:', e)
@@ -722,10 +724,8 @@ export const proBlockNode = $node(PRO_BLOCK_NODE_TYPE, () => ({
}))
export const proBlockView = $view(proBlockNode, (ctx) => {
const parser = ctx.get(parserCtx)
const serializer = ctx.get(serializerCtx)
const config = ctx.get(proBlockConfigCtx.key)
return (node, view, getPos) => new ProBlockNodeView(node, view, getPos, parser, serializer, config)
return (node, view, getPos) => new ProBlockNodeView(node, view, getPos, ctx, config)
})
export const proBlockInputPlugin = $prose(() => {
+33 -17
View File
@@ -56,12 +56,16 @@ function createAbortError(message = 'Request aborted') {
async function sendCancelRequest(cancelUrl, requestId, reason) {
try {
const headers = {
'Content-Type': 'application/json',
}
if (API_KEY) {
headers['X-API-Key'] = API_KEY
}
await fetch(cancelUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': API_KEY,
},
headers,
credentials: 'include',
body: JSON.stringify({
request_id: requestId,
reason,
@@ -132,14 +136,18 @@ async function consumeSseJson({
}
try {
const res = await fetch(url, {
method: 'POST',
headers: {
const headers = {
'Content-Type': 'application/json',
'X-Request-Id': requestId,
'X-API-Key': API_KEY,
},
}
if (API_KEY) {
headers['X-API-Key'] = API_KEY
}
const res = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
credentials: 'include',
signal: requestController.signal,
})
@@ -304,7 +312,8 @@ export async function fetchTTS(text, instruct = '', apiUrl = TTS_URL) {
export async function fetchTTSStatus(apiUrl = TTS_STATUS_URL) {
const res = await fetch(apiUrl, {
headers: { 'X-API-Key': API_KEY },
headers: API_KEY ? { 'X-API-Key': API_KEY } : {},
credentials: 'include',
})
if (!res.ok) throw new Error(`TTS Status HTTP ${res.status}`)
return res.json()
@@ -312,19 +321,24 @@ export async function fetchTTSStatus(apiUrl = TTS_STATUS_URL) {
export async function fetchTTSConfig(apiUrl = TTS_CONFIG_URL) {
const res = await fetch(apiUrl, {
headers: { 'X-API-Key': API_KEY },
headers: API_KEY ? { 'X-API-Key': API_KEY } : {},
credentials: 'include',
})
if (!res.ok) throw new Error(`TTS Config HTTP ${res.status}`)
return res.json()
}
export async function submitCompress(content, docType = 'txt', apiUrl = COMPRESS_SUBMIT_URL) {
const headers = {
'Content-Type': 'application/json',
}
if (API_KEY) {
headers['X-API-Key'] = API_KEY
}
const res = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': API_KEY,
},
headers,
credentials: 'include',
body: JSON.stringify({ content, docType }),
})
@@ -342,7 +356,8 @@ export function pollCompressStatus(taskId, onStateChange, apiUrl = COMPRESS_STAT
const interval = setInterval(async () => {
try {
const res = await fetch(`${apiUrl}?task_id=${encodeURIComponent(taskId)}`, {
headers: { 'X-API-Key': API_KEY },
headers: API_KEY ? { 'X-API-Key': API_KEY } : {},
credentials: 'include',
})
if (!res.ok) {
@@ -375,7 +390,8 @@ export function pollCompressStatus(taskId, onStateChange, apiUrl = COMPRESS_STAT
export async function fetchJobLoad(apiUrl = JOB_LOAD_URL) {
const res = await fetch(apiUrl, {
headers: { 'X-API-Key': API_KEY },
headers: API_KEY ? { 'X-API-Key': API_KEY } : {},
credentials: 'include',
})
if (!res.ok) {
throw new Error(`Job Load HTTP ${res.status}`)
+4 -4
View File
@@ -1,20 +1,20 @@
export const DEBUG = import.meta.env.DEV
const DEFAULT_API_BASE_URL = ''
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || DEFAULT_API_BASE_URL
const DEFAULT_API_BASE_URL = 'https://api.imageteach.tech:8002'
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL || DEFAULT_API_BASE_URL).replace(/\/+$/, '')
export const API_URL = import.meta.env.VITE_API_URL || `${API_BASE_URL}/v1/completions`
export const PRO_URL = import.meta.env.VITE_PRO_URL || `${API_BASE_URL}/v1/pro/completions`
export const PRO_FRONTEND_TIMEOUT_MS = Number(import.meta.env.VITE_PRO_FRONTEND_TIMEOUT_MS || 3660000)
export const OCR_URL = import.meta.env.VITE_OCR_URL || `${API_BASE_URL}/v1/ocr`
export const CONVERT_URL = import.meta.env.VITE_CONVERT_URL || `${API_BASE_URL}/v1/convert`
export const EXPORT_PDF_URL = import.meta.env.VITE_EXPORT_PDF_URL || '/v1/export/pdf'
export const EXPORT_PDF_URL = import.meta.env.VITE_EXPORT_PDF_URL || `${API_BASE_URL}/v1/export/pdf`
export const TTS_URL = import.meta.env.VITE_TTS_URL || `${API_BASE_URL}/v1/tts-asr/tts`
export const TTS_STATUS_URL = import.meta.env.VITE_TTS_STATUS_URL || `${API_BASE_URL}/v1/tts-asr/status`
export const TTS_CONFIG_URL = import.meta.env.VITE_TTS_CONFIG_URL || `${API_BASE_URL}/v1/tts-asr/config`
export const ASR_URL = import.meta.env.VITE_ASR_URL || `${API_BASE_URL}/v1/tts-asr/asr`
export const JOB_LOAD_URL = import.meta.env.VITE_JOB_LOAD_URL || `${API_BASE_URL}/v1/jobs/load`
export const API_KEY = import.meta.env.VITE_API_KEY || 'your-secret-key-here'
export const API_KEY = (import.meta.env.VITE_API_KEY || '').trim()
export const DOCS_NODES_URL = import.meta.env.VITE_DOCS_NODES_URL || `${API_BASE_URL}/v1/docs/nodes`
export const DOCS_FOLDERS_URL = import.meta.env.VITE_DOCS_FOLDERS_URL || `${API_BASE_URL}/v1/docs/folders`
export const DOCS_TEXT_FILES_URL = import.meta.env.VITE_DOCS_TEXT_FILES_URL || `${API_BASE_URL}/v1/docs/files/text`
+10 -8
View File
@@ -86,12 +86,13 @@ function readFileAsBase64(file) {
export async function convertFileToMarkdown(file) {
const base64 = await readFileAsBase64(file)
const headers = {
'Content-Type': 'application/json',
}
const res = await fetch(CONVERT_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': 'your-secret-key-here',
},
headers,
credentials: 'include',
body: JSON.stringify({
file: base64,
filename: file.name || 'document',
@@ -284,12 +285,13 @@ export async function convertAudioToText(file, language = 'zh-CN') {
const wavBase64 = await audioToWavBase64(file)
// Step 2: Send to ASR endpoint
const headers = {
'Content-Type': 'application/json',
}
const res = await fetch(ASR_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': 'your-secret-key-here',
},
headers,
credentials: 'include',
body: JSON.stringify({
audio_base64: wavBase64,
language: language || 'zh-CN',
+98
View File
@@ -0,0 +1,98 @@
/**
* Cookie 策略管理工具类
*
* 提供功能:
* - 设置/获取/删除 Cookie (支持现代属性)
* - SameSite/Lax/None 配置
* - Secure/HttpOnly 标志位控制
*/
export class CookiePolicy {
/**
* 设置 Cookie
*
* @param name - Cookie 名称
* @param value - Cookie
* @param options - 可选配置项
* @param options.maxAge - 过期时间(), 默认3600
* @param options.httpOnly - 是否 HttpOnly (防止 XSS), 默认false
* @param options.secure - 是否 Secure ( HTTPS), 默认true
* @param options.sameSite - SameSite 策略 ('Lax' | 'Strict' | 'None'), 默认'Lax'
* @param options.domain - 域名, 默认'.imageteach.tech'
* @param options.path - 路径, 默认'/'
*/
static setCookie(
name: string,
value: string,
options: {
maxAge?: number;
httpOnly?: boolean;
secure?: boolean;
sameSite?: 'Lax' | 'Strict' | 'None';
domain?: string;
path?: string;
} = {}
): void {
const defaults = {
maxAge: 3600, // 1小时
httpOnly: false, // 允许前端读取
secure: true, // HTTPS 传输
sameSite: 'Lax', // 防止 CSRF
domain: '.imageteach.tech',
path: '/'
};
const finalOptions = { ...defaults, ...options };
// 构建 Cookie 字符串
const parts = [
`${encodeURIComponent(name)}=${encodeURIComponent(value)}`,
`Max-Age=${finalOptions.maxAge}`,
finalOptions.httpOnly ? 'HttpOnly' : '',
finalOptions.secure ? 'Secure' : '',
`SameSite=${finalOptions.sameSite}`,
`Domain=${finalOptions.domain}`,
`Path=${finalOptions.path}`
].filter(Boolean).join('; ');
document.cookie = parts;
}
/**
* 获取 Cookie
* @param name - Cookie 名称
* @returns Cookie 值或 null
*/
static getCookie(name: string): string | null {
const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`));
return match ? decodeURIComponent(match[2]) : null;
}
/**
* 删除 Cookie
* @param name - Cookie 名称
*/
static deleteCookie(name: string): void {
document.cookie = `${name}=; Max-Age=0; Path=/`;
}
/**
* 获取所有 Cookie
* @returns Cookie 键值对对象
*/
static getAllCookies(): Record<string, string> {
const cookies: Record<string, string> = {};
document.cookie.split('; ').forEach(cookie => {
const [name, ...valueParts] = cookie.split('=');
if (name) {
cookies[name] = valueParts.join('=');
}
});
return cookies;
}
}
// 导出便捷方法
export const setCookie = CookiePolicy.setCookie.bind(CookiePolicy);
export const getCookie = CookiePolicy.getCookie.bind(CookiePolicy);
export const deleteCookie = CookiePolicy.deleteCookie.bind(CookiePolicy);
+11 -1
View File
@@ -9,10 +9,12 @@ import {
} from './config.js'
function buildHeaders(extra = {}) {
return {
return API_KEY
? {
'X-API-Key': API_KEY,
...extra,
}
: { ...extra }
}
async function parseJsonResponse(res) {
@@ -33,6 +35,7 @@ async function parseJsonResponse(res) {
export async function fetchDocNodes() {
const res = await fetch(DOCS_NODES_URL, {
headers: buildHeaders(),
credentials: 'include',
})
const data = await parseJsonResponse(res)
return Array.isArray(data.nodes) ? data.nodes : []
@@ -42,6 +45,7 @@ export async function createDocFolder(name, parentId = null) {
const res = await fetch(DOCS_FOLDERS_URL, {
method: 'POST',
headers: buildHeaders({ 'Content-Type': 'application/json' }),
credentials: 'include',
body: JSON.stringify({ name, parentId }),
})
const data = await parseJsonResponse(res)
@@ -52,6 +56,7 @@ export async function createDocTextFile(name, parentId = null, content = '') {
const res = await fetch(DOCS_TEXT_FILES_URL, {
method: 'POST',
headers: buildHeaders({ 'Content-Type': 'application/json' }),
credentials: 'include',
body: JSON.stringify({ name, parentId, content }),
})
const data = await parseJsonResponse(res)
@@ -65,6 +70,7 @@ export async function uploadDocFile(file, parentId = null) {
const res = await fetch(DOCS_UPLOAD_URL, {
method: 'POST',
headers: buildHeaders(),
credentials: 'include',
body: formData,
})
const data = await parseJsonResponse(res)
@@ -75,6 +81,7 @@ export async function updateDocNode(nodeId, payload) {
const res = await fetch(`${DOCS_NODES_BASE_URL}/${encodeURIComponent(nodeId)}`, {
method: 'PATCH',
headers: buildHeaders({ 'Content-Type': 'application/json' }),
credentials: 'include',
body: JSON.stringify(payload),
})
const data = await parseJsonResponse(res)
@@ -87,6 +94,7 @@ export async function replaceDocBlob(nodeId, file) {
const res = await fetch(`${DOCS_BLOB_BASE_URL}/${encodeURIComponent(nodeId)}/blob`, {
method: 'PUT',
headers: buildHeaders(),
credentials: 'include',
body: formData,
})
const data = await parseJsonResponse(res)
@@ -97,6 +105,7 @@ export async function deleteDocNode(nodeId) {
const res = await fetch(`${DOCS_NODES_BASE_URL}/${encodeURIComponent(nodeId)}`, {
method: 'DELETE',
headers: buildHeaders(),
credentials: 'include',
})
return parseJsonResponse(res)
}
@@ -104,6 +113,7 @@ export async function deleteDocNode(nodeId) {
export async function fetchDocBlob(nodeId) {
const res = await fetch(`${DOCS_BLOB_BASE_URL}/${encodeURIComponent(nodeId)}/blob`, {
headers: buildHeaders(),
credentials: 'include',
})
if (!res.ok) {
let message = `HTTP ${res.status}`
+16 -2
View File
@@ -117,7 +117,14 @@ export const translations = {
items: 'items',
unsupportedPreview: 'This file type is not supported for preview',
fileNamePlaceholder: 'filename.md',
folderNamePlaceholder: 'Folder name'
folderNamePlaceholder: 'Folder name',
// Security & Captcha
security: 'Security',
captchaDesc: 'Used to verify user operation authenticity',
captchaInputPlaceholder: 'Enter verification code',
captchaValidate: 'Verify',
captchaSuccess: 'Verification successful',
captchaFailed: 'Verification failed, please try again'
},
zh: {
settings: '设置',
@@ -237,7 +244,14 @@ export const translations = {
items: '个项目',
unsupportedPreview: '暂不支持预览此文件类型',
fileNamePlaceholder: '文件名.md',
folderNamePlaceholder: '文件夹名'
folderNamePlaceholder: '文件夹名',
// Security & Captcha
security: '安全设置',
captchaDesc: '用于验证用户操作真实性',
captchaInputPlaceholder: '请输入验证码',
captchaValidate: '验证',
captchaSuccess: '验证成功',
captchaFailed: '验证失败,请重试'
},
ja: {
settings: '設定',
+11 -3
View File
@@ -1,9 +1,15 @@
const MARKDOWN_FENCE_RE = /^(`{3,}|~{3,})[ \t]*(markdown|md|mdown|text|plain|plaintext)[^\n]*\n([\s\S]*?)\n\1[ \t]*$/i
const OUTER_FENCE_RE = /^(`{3,}|~{3,})[^\n]*\n([\s\S]*?)\n\1[ \t]*$/
function normalizeNewlines(value = '') {
return String(value || '').replace(/\r\n?/g, '\n')
}
function unescapeLiteralNewlines(value = '') {
const text = String(value || '')
if (text.includes('\n') || !/\\n/.test(text)) return text
return text.replace(/\\n/g, '\n')
}
export function normalizeProAcceptMarkdown(value = '') {
let text = normalizeNewlines(value)
const trimmed = text.trim()
@@ -21,9 +27,11 @@ export function normalizeProAcceptMarkdown(value = '') {
}
}
const fenceMatch = text.trim().match(MARKDOWN_FENCE_RE)
text = unescapeLiteralNewlines(text)
const fenceMatch = text.trim().match(OUTER_FENCE_RE)
if (fenceMatch) {
return normalizeNewlines(fenceMatch[3]).trim()
return normalizeNewlines(fenceMatch[2]).trim()
}
return text.trim()
+1 -7
View File
@@ -5,13 +5,7 @@ export default defineConfig({
plugins: [vue()],
server: {
host: true,
port: 5173,
proxy: {
'/v1': {
target: 'http://localhost:8001',
changeOrigin: true
}
}
port: 5173
},
build: {
rollupOptions: {