chore: snapshot before usage menu update
This commit is contained in:
parent
7348eab83a
commit
8c8b2d3a20
@ -73,10 +73,12 @@ class MainTerminal:
|
||||
thinking_mode: bool = False,
|
||||
data_dir: Optional[str] = None,
|
||||
container_session: Optional["ContainerHandle"] = None,
|
||||
usage_tracker: Optional[object] = None,
|
||||
):
|
||||
self.project_path = project_path
|
||||
self.thinking_mode = thinking_mode # False=快速模式, True=思考模式
|
||||
self.data_dir = Path(data_dir).expanduser().resolve() if data_dir else Path(DATA_DIR).resolve()
|
||||
self.usage_tracker = usage_tracker
|
||||
|
||||
# 初始化组件
|
||||
self.api_client = DeepSeekClient(thinking_mode=thinking_mode)
|
||||
@ -163,6 +165,39 @@ class MainTerminal:
|
||||
else:
|
||||
self.container_mount_path = TERMINAL_SANDBOX_MOUNT_PATH or "/workspace"
|
||||
|
||||
def record_model_call(self, is_thinking: bool):
|
||||
tracker = getattr(self, "usage_tracker", None)
|
||||
if not tracker:
|
||||
return True, {}
|
||||
mode = "thinking" if is_thinking else "fast"
|
||||
try:
|
||||
allowed, info = tracker.check_and_increment(mode)
|
||||
if allowed:
|
||||
self._notify_quota_update(mode)
|
||||
return allowed, info
|
||||
except Exception:
|
||||
return True, {}
|
||||
|
||||
def record_search_call(self):
|
||||
tracker = getattr(self, "usage_tracker", None)
|
||||
if not tracker:
|
||||
return True, {}
|
||||
try:
|
||||
allowed, info = tracker.check_and_increment("search")
|
||||
if allowed:
|
||||
self._notify_quota_update("search")
|
||||
return allowed, info
|
||||
except Exception:
|
||||
return True, {}
|
||||
|
||||
def _notify_quota_update(self, metric: str):
|
||||
callback = getattr(self, "quota_update_callback", None)
|
||||
if callable(callback):
|
||||
try:
|
||||
callback(metric)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def update_container_session(self, session: Optional["ContainerHandle"]):
|
||||
self._apply_container_session(session)
|
||||
if getattr(self, "terminal_manager", None):
|
||||
@ -1914,6 +1949,13 @@ class MainTerminal:
|
||||
result = {"success": False, "error": f"文件未处于聚焦状态: {path}"}
|
||||
|
||||
elif tool_name == "web_search":
|
||||
allowed, quota_info = self.record_search_call()
|
||||
if not allowed:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"搜索配额已用尽,将在 {quota_info.get('reset_at')} 重置。",
|
||||
"quota": quota_info
|
||||
}
|
||||
search_response = await self.search_engine.search_with_summary(
|
||||
query=arguments["query"],
|
||||
max_results=arguments.get("max_results"),
|
||||
|
||||
@ -51,9 +51,10 @@ class WebTerminal(MainTerminal):
|
||||
message_callback: Optional[Callable] = None,
|
||||
data_dir: Optional[str] = None,
|
||||
container_session: Optional["ContainerHandle"] = None,
|
||||
usage_tracker: Optional[object] = None,
|
||||
):
|
||||
# 调用父类初始化(包含对话持久化功能)
|
||||
super().__init__(project_path, thinking_mode, data_dir=data_dir, container_session=container_session)
|
||||
super().__init__(project_path, thinking_mode, data_dir=data_dir, container_session=container_session, usage_tracker=usage_tracker)
|
||||
|
||||
# Web特有属性
|
||||
self.message_callback = message_callback
|
||||
|
||||
34
doc/phase3_summary.md
Normal file
34
doc/phase3_summary.md
Normal file
@ -0,0 +1,34 @@
|
||||
# Phase 3 Summary – 鉴权与配额基线
|
||||
|
||||
## 交付成果
|
||||
|
||||
1. **HTTP / WebSocket 鉴权加固**
|
||||
- 引入统一速率限制器与失败计数:登录/注册/文件写操作等接口按 IP/用户多维限流,同时重试上限触发临时封禁。
|
||||
- 自定义 CSRF 体系:后端提供 `/api/csrf-token`,前端全局 `fetch` 包装自动附带 `X-CSRF-Token`,所有写操作在 `before_request` 校验;会话 Cookie 统一启用 `SameSite=Strict`、`Secure/HttpOnly` 并增加基础安全头。
|
||||
- Socket.IO 握手新增一次性 token(含 UA 指纹),连接、重连都会验证 token 并立即销毁,阻断单凭 session cookie 劫持的可能。
|
||||
|
||||
2. **模型 / 搜索用量配额**
|
||||
- 新增 `modules/usage_tracker.py`,按用户与角色记录滑动窗口用量(普通用户:常规 50/5h、思考 20/5h、搜索 20/24h;管理员 9999),数据持久化在 `usage_stats.json`。
|
||||
- `core/main_terminal.py` 在每次模型/搜索调用前检查并递增配额,超限时直接拒绝并返回重置时间;`web_server.py` 同步向前端广播 `quota_notice`,防止静默失败。
|
||||
- UI 用量面板展示实时配额、重置时间,并在输入框侧阻止超限请求;新增右上角 toast 通知提醒配额状态。
|
||||
|
||||
3. **界面与体验**
|
||||
- Web 用量组件、新增安全 toast、自动刷新配额数据(轮询 + Socket 事件),并隐藏 0 次额度的重置提示,避免噪音。
|
||||
|
||||
## 影响评估
|
||||
|
||||
- **攻击面收敛**:CSRF、防爆破、Socket 凭证等基础鉴权补齐后,未授权调用需要突破多重校验,WebSocket 劫持和暴力破解风险显著下降。
|
||||
- **可观测性/运维**:配额快照和 `/api/usage` 接口为后续差异化用户组与审计提供依据,可直观查看每类用量与重置时间。
|
||||
- **用户体验**:前端能够即时获知配额状态,避免“点击无响应”,同时 toast 提醒让限额策略更透明。
|
||||
|
||||
## 仍待处理
|
||||
|
||||
- 尚未引入 CAPTCHA / MFA、异常告警、CLI API Token 等进一步的身份防护手段。
|
||||
- Upload/MIME 检查、CSP/内容安全策略与更细粒度的 ACL 仍未落地。
|
||||
- 配额目前只区分普通/管理员,可在下一阶段扩展“用户组 + 自定义窗口/额度”的配置中心,并联动告警系统。
|
||||
|
||||
## 验证
|
||||
|
||||
- `python3 -m py_compile web_server.py core/main_terminal.py core/web_terminal.py modules/usage_tracker.py`。
|
||||
- 手动验证:登录界面输错多次触发 429,WebSocket 无 token 无法建立连接;浏览器抓包可见所有写请求均带 `X-CSRF-Token`。
|
||||
- 配额体验:普通用户 5 小时窗口内超限时前端弹出提示、API 返回重置时间,5 小时后自动恢复。
|
||||
@ -53,10 +53,10 @@
|
||||
| 1 | Critical | ✅ **执行环境隔离就绪**:实时终端、`run_command` 与 FileManager 统统绑定用户专属容器,`modules/container_file_proxy.py` 保证写入只发生在 `/workspace`,宿主机仅承载挂载目录。 | Web 用户仅能访问自己容器内的文件/进程;若容器崩溃可在宿主机安全回收。 | 持续关注容器逃逸与 runtime 补丁,下一步考虑 rootless Docker / gVisor 进一步降低宿主暴露面。 |
|
||||
| 2 | Critical | **明文 API Key / Secret**:`config/api.py:3-25` 存在硬编码模型 key,`config/auth.py:1-7` 暴露管理员用户名 + 哈希。 | 仓库一旦共享即泄漏密钥;攻击者可伪造管理员账户或重放 API 请求。 | 将所有 secrets 挪到环境变量 / Secret Manager,删除仓库中的明文;在启动时校验缺省值并阻止运行。 |
|
||||
| 3 | High | **Flask SECRET_KEY 固定且公开**:`web_server.py:54-58` 将 `SECRET_KEY='your-secret-key-here'` 写死,且默认启用 `CORS(*)`。 | 攻击者可伪造 session cookie、冒充任意用户、解密/篡改会话。 | 将 SECRET_KEY 存储于环境变量,启用 `SESSION_COOKIE_SECURE/HTTPONLY/SAMESITE`,并限制 CORS 源。 |
|
||||
| 4 | High | **鉴权与速率限制缺失**:登录接口没有 CAPTCHA/速率限制;`api_login_required` 仅检查 session;Socket.IO 连接未绑定 IP/指纹。 | 暴力破解、会话劫持、重放攻击均无防护;一旦 cookie 泄漏即可接管终端。 | 引入 Flask-Limiter 等中间件,记录失败次数,必要时强制多因子或设备锁定;WebSocket 握手附带一次性 token。 |
|
||||
| 4 | High | ✅ **鉴权与速率限制补强**:登录/注册/文件等敏感接口统一接入速率限制与失败计数(按 IP/用户维度),Socket.IO 握手改为一次性 token + Session。 | 暴力破解和凭证滥用的窗口被显著压缩,WebSocket 不再能单靠 Cookie 劫持。 | 下一步考虑加入 CAPTCHA/MFA、失败告警以及对 CLI API 的 token 鉴权。 |
|
||||
| 5 | High | ✅ **多租户容器化 + 读写代理**:UserManager 登录即创建独立容器,FileManager 通过容器内脚本执行 create/read/write/modify,宿主机不再直接接触用户代码。 | 横向越权面大幅收窄:除非容器逃逸,否则无法读写他人目录。 | 下一步需在 API 层加上 workspace ACL 与审计日志,防止管理员 session 滥用。 |
|
||||
| 6 | Medium | **用户与会话数据存储在本地 JSON**:`modules/user_manager.py:167-195` 将账号、密码哈希、邀请码写入 `data/users.json`,没有备份策略、并发安全或加密。 | 易被本地用户读取/篡改;当并发写入时有数据损坏风险,也无法满足审计/恢复。 | 引入关系型数据库或托管身份服务;对敏感字段做透明加密,提供备份与迁移策略。 |
|
||||
| 7 | Medium | **缺乏 CSRF、防重放与安全头部**:所有 POST/PUT 接口(如 `/api/gui/files/*`, `/api/upload`)均未校验 CSRF token。 | 登陆态用户可被恶意网站诱导发起操作(上传/删除/运行命令)。 | 在 Flask 层加入 CSRF 保护(WTF-CSRF 或自定义 token),并添加 Strict-Transport-Security、Content-Security-Policy 等响应头。 |
|
||||
| 7 | Medium | ✅ **CSRF + 安全头**:所有写操作统一校验 `X-CSRF-Token`(匿名令牌 API + fetch 包装),并启用 `SameSite/HttpOnly/Secure` Cookie 与基础安全响应头。 | 浏览器无法再被第三方站点诱导执行写操作,Cookie 也具备最小暴露。 | 仍需结合 CSP/Referer 限制和子域隔离,防止潜在的 XSS 复合攻击。 |
|
||||
| 8 | Medium | **文件上传仅做文件名校验**:`web_server.py:841-907, 985-1069` 只调用 `sanitize_filename_preserve_unicode`,但未检测 MIME、内容或执行权限。 | 可上传脚本并经终端执行;针对 Windows/Unix 的路径和符号链接处理也未覆盖。 | 引入内容扫描(ClamAV/自建规则)、限制文件类型/数量,并将上传目录与执行目录隔离。 |
|
||||
| 9 | Medium | **日志/终端信息缺乏审计与脱敏**:`logs/`、`data/conversations/` 中保留所有指令和模型输出,没有访问控制。 | 可能泄漏用户代码、密钥;出现安全事件时也很难追踪。 | 将日志写入集中式系统(ELK/ClickHouse),对敏感字段脱敏,建立查询与保留策略。 |
|
||||
| 10 | Low | **配置默认值缺乏环境检测**:如 `DEFAULT_PROJECT_PATH="./project"`、`MAX_UPLOAD_SIZE=50MB` 固定写入;`main.py` 未检查当前用户权限。 | 误配置可能导致数据写入到未知磁盘或权限不足引发异常。 | 在启动阶段校验运行环境(磁盘权限、必需目录、环境变量),并提供友好报错与文档。 |
|
||||
|
||||
193
modules/usage_tracker.py
Normal file
193
modules/usage_tracker.py
Normal file
@ -0,0 +1,193 @@
|
||||
"""Per-user usage tracking utilities with rolling quota windows."""
|
||||
|
||||
import json
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Literal, Tuple
|
||||
|
||||
|
||||
QuotaKey = Literal["fast", "thinking", "search"]
|
||||
|
||||
|
||||
QUOTA_DEFAULTS = {
|
||||
"default": {
|
||||
"fast": {"limit": 50, "window_hours": 5},
|
||||
"thinking": {"limit": 20, "window_hours": 5},
|
||||
"search": {"limit": 20, "window_hours": 24},
|
||||
},
|
||||
"search_daily": {"limit": 20, "window_hours": 24},
|
||||
"admin": {
|
||||
"fast": {"limit": 9999, "window_hours": 5},
|
||||
"thinking": {"limit": 9999, "window_hours": 5},
|
||||
"search": {"limit": 9999, "window_hours": 24},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class UsageTracker:
|
||||
"""Record per-user model/search usage statistics and enforce quotas."""
|
||||
|
||||
def __init__(self, data_dir: str, role: str = "user"):
|
||||
self.data_dir = Path(data_dir).expanduser().resolve()
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.stats_file = self.data_dir / "usage_stats.json"
|
||||
self.role = role or "user"
|
||||
self._lock = threading.Lock()
|
||||
self._state = {
|
||||
"started_at": self._now_iso(),
|
||||
"updated_at": self._now_iso(),
|
||||
"windows": {
|
||||
"fast": {"count": 0, "window_start": None},
|
||||
"thinking": {"count": 0, "window_start": None},
|
||||
"search": {"count": 0, "window_start": None},
|
||||
},
|
||||
}
|
||||
self._load()
|
||||
|
||||
def _now_iso(self) -> str:
|
||||
return datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
|
||||
|
||||
def _load(self):
|
||||
if not self.stats_file.exists():
|
||||
return
|
||||
try:
|
||||
data = json.loads(self.stats_file.read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict):
|
||||
self._merge_state(data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _merge_state(self, data: Dict):
|
||||
windows = data.get("windows")
|
||||
if windows:
|
||||
for key in ("fast", "thinking", "search"):
|
||||
win = windows.get(key) or {}
|
||||
self._state["windows"][key] = {
|
||||
"count": int(win.get("count", 0)),
|
||||
"window_start": win.get("window_start"),
|
||||
}
|
||||
else:
|
||||
legacy_model = data.get("model_calls") or {}
|
||||
legacy_search = data.get("search_calls") or {}
|
||||
self._state["windows"]["fast"]["count"] = int(legacy_model.get("fast", 0))
|
||||
self._state["windows"]["thinking"]["count"] = int(legacy_model.get("thinking", 0))
|
||||
self._state["windows"]["search"]["count"] = int(legacy_search.get("total", 0))
|
||||
for key in ("fast", "thinking", "search"):
|
||||
if not self._state["windows"][key].get("window_start"):
|
||||
self._state["windows"][key]["window_start"] = None
|
||||
self._state["started_at"] = data.get("started_at") or self._state["started_at"]
|
||||
self._state["updated_at"] = data.get("updated_at") or self._state["updated_at"]
|
||||
|
||||
def _save(self):
|
||||
with self.stats_file.open("w", encoding="utf-8") as f:
|
||||
json.dump(self._state, f, ensure_ascii=False, indent=2)
|
||||
|
||||
def _get_quota_config(self, metric: QuotaKey) -> Dict[str, int]:
|
||||
if self.role == "admin":
|
||||
return QUOTA_DEFAULTS["admin"][metric]
|
||||
if metric == "search":
|
||||
return QUOTA_DEFAULTS["search_daily"]
|
||||
return QUOTA_DEFAULTS["default"][metric]
|
||||
|
||||
def _ensure_window(self, metric: QuotaKey) -> Tuple[int, Optional[str], datetime, datetime]:
|
||||
"""Returns tuple(count, window_start_iso, window_start_dt, reset_at_dt)."""
|
||||
config = self._get_quota_config(metric)
|
||||
window_hours = config["window_hours"]
|
||||
window_delta = timedelta(hours=window_hours)
|
||||
window_data = self._state["windows"].setdefault(metric, {"count": 0, "window_start": None})
|
||||
window_start_iso = window_data.get("window_start")
|
||||
now = datetime.utcnow()
|
||||
|
||||
if window_start_iso:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(window_start_iso.replace("Z", ""))
|
||||
window_start_dt = floor_to_hour(parsed)
|
||||
except ValueError:
|
||||
window_start_dt = floor_to_hour(now)
|
||||
else:
|
||||
window_start_dt = floor_to_hour(now)
|
||||
window_data["window_start"] = frame_iso(window_start_dt)
|
||||
|
||||
reset_at_dt = window_start_dt + window_delta
|
||||
if now >= reset_at_dt and window_data.get("window_start"):
|
||||
window_data["count"] = 0
|
||||
window_start_dt = floor_to_hour(now)
|
||||
reset_at_dt = window_start_dt + window_delta
|
||||
window_data["window_start"] = frame_iso(window_start_dt)
|
||||
|
||||
return (
|
||||
int(window_data.get("count", 0)),
|
||||
window_data["window_start"],
|
||||
window_start_dt,
|
||||
reset_at_dt,
|
||||
)
|
||||
|
||||
def check_and_increment(self, metric: QuotaKey) -> Tuple[bool, Dict[str, str]]:
|
||||
"""Check quota and increment if allowed. Returns (allowed, info)."""
|
||||
with self._lock:
|
||||
count, window_start_iso, window_start_dt, reset_at_dt = self._ensure_window(metric)
|
||||
quota = self._get_quota_config(metric)["limit"]
|
||||
if count >= quota:
|
||||
reset_at_iso = frame_iso(reset_at_dt)
|
||||
return False, {
|
||||
"limit": quota,
|
||||
"count": count,
|
||||
"reset_at": reset_at_iso,
|
||||
"window_start": window_start_iso or "",
|
||||
}
|
||||
|
||||
new_count = count + 1
|
||||
self._state["windows"][metric]["count"] = new_count
|
||||
if not window_start_iso:
|
||||
self._state["windows"][metric]["window_start"] = frame_iso(window_start_dt)
|
||||
self._state["updated_at"] = self._now_iso()
|
||||
self._save()
|
||||
|
||||
return True, {
|
||||
"limit": quota,
|
||||
"count": new_count,
|
||||
"reset_at": frame_iso(reset_at_dt),
|
||||
"window_start": self._state["windows"][metric]["window_start"],
|
||||
}
|
||||
|
||||
def get_quota_snapshot(self) -> Dict[str, Dict[str, str]]:
|
||||
snapshot = {}
|
||||
for key in ("fast", "thinking", "search"):
|
||||
count, window_start_iso, _, reset_dt = self._ensure_window(key)
|
||||
limit = self._get_quota_config(key)["limit"]
|
||||
if not count:
|
||||
window_start_iso = None
|
||||
reset_value = None
|
||||
else:
|
||||
reset_value = frame_iso(reset_dt)
|
||||
snapshot[key] = {
|
||||
"count": count,
|
||||
"limit": limit,
|
||||
"window_start": window_start_iso,
|
||||
"reset_at": reset_value,
|
||||
}
|
||||
snapshot["role"] = self.role
|
||||
return snapshot
|
||||
|
||||
def get_stats(self) -> Dict:
|
||||
with self._lock:
|
||||
data = json.loads(json.dumps(self._state))
|
||||
quotas = self.get_quota_snapshot()
|
||||
for key in ("fast", "thinking", "search"):
|
||||
data["windows"][key]["reset_at"] = quotas[key]["reset_at"]
|
||||
data["role"] = self.role
|
||||
data["quotas"] = quotas
|
||||
self._save()
|
||||
return data
|
||||
|
||||
|
||||
def frame_iso(value: datetime) -> str:
|
||||
return value.replace(microsecond=0).isoformat() + "Z"
|
||||
|
||||
|
||||
def floor_to_hour(value: datetime) -> datetime:
|
||||
return value.replace(minute=0, second=0, microsecond=0)
|
||||
|
||||
|
||||
__all__ = ["UsageTracker", "QUOTA_DEFAULTS"]
|
||||
215
static/app.js
215
static/app.js
@ -313,6 +313,15 @@ async function bootstrapApp() {
|
||||
down_bps: null,
|
||||
up_bps: null
|
||||
},
|
||||
usageQuota: {
|
||||
fast: { count: 0, limit: 0, reset_at: null, window_start: null },
|
||||
thinking: { count: 0, limit: 0, reset_at: null, window_start: null },
|
||||
search: { count: 0, limit: 0, reset_at: null, window_start: null },
|
||||
role: 'user'
|
||||
},
|
||||
usageQuotaTimer: null,
|
||||
quotaToast: null,
|
||||
quotaToastTimer: null,
|
||||
|
||||
// 对话压缩状态
|
||||
compressing: false,
|
||||
@ -363,8 +372,13 @@ async function bootstrapApp() {
|
||||
|
||||
async mounted() {
|
||||
console.log('Vue应用已挂载');
|
||||
if (window.ensureCsrfToken) {
|
||||
window.ensureCsrfToken().catch((err) => {
|
||||
console.warn('CSRF token 初始化失败:', err);
|
||||
});
|
||||
}
|
||||
await this.bootstrapRoute();
|
||||
this.initSocket();
|
||||
await this.initSocket();
|
||||
this.initScrollListener();
|
||||
|
||||
// 延迟加载初始数据
|
||||
@ -414,6 +428,7 @@ async function bootstrapApp() {
|
||||
});
|
||||
this.startContainerStatsPolling();
|
||||
this.startProjectStoragePolling();
|
||||
this.startUsageQuotaPolling();
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
@ -438,6 +453,11 @@ async function bootstrapApp() {
|
||||
}
|
||||
this.stopContainerStatsPolling();
|
||||
this.stopProjectStoragePolling();
|
||||
this.stopUsageQuotaPolling();
|
||||
if (this.quotaToastTimer) {
|
||||
clearTimeout(this.quotaToastTimer);
|
||||
this.quotaToastTimer = null;
|
||||
}
|
||||
const cleanup = this.destroyEasterEggEffect(true);
|
||||
if (cleanup && typeof cleanup.catch === 'function') {
|
||||
cleanup.catch(() => {});
|
||||
@ -676,19 +696,44 @@ async function bootstrapApp() {
|
||||
});
|
||||
},
|
||||
|
||||
initSocket() {
|
||||
async initSocket() {
|
||||
try {
|
||||
console.log('初始化WebSocket连接...');
|
||||
|
||||
const usePollingOnly = window.location.hostname !== 'localhost' &&
|
||||
window.location.hostname !== '127.0.0.1';
|
||||
|
||||
this.socket = io('/', usePollingOnly ? {
|
||||
const socketOptions = usePollingOnly ? {
|
||||
transports: ['polling'],
|
||||
upgrade: false
|
||||
upgrade: false,
|
||||
autoConnect: false
|
||||
} : {
|
||||
transports: ['websocket', 'polling']
|
||||
});
|
||||
transports: ['websocket', 'polling'],
|
||||
autoConnect: false
|
||||
};
|
||||
|
||||
this.socket = io('/', socketOptions);
|
||||
|
||||
const assignSocketToken = async () => {
|
||||
if (typeof window.requestSocketToken !== 'function') {
|
||||
console.warn('缺少 requestSocketToken(),无法获取实时连接凭证');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const freshToken = await window.requestSocketToken();
|
||||
this.socket.auth = { socket_token: freshToken };
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('获取 WebSocket token 失败:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if (this.socket.io && typeof this.socket.io.on === 'function') {
|
||||
this.socket.io.on('reconnect_attempt', async () => {
|
||||
await assignSocketToken();
|
||||
});
|
||||
}
|
||||
|
||||
// 连接事件
|
||||
this.socket.on('connect', () => {
|
||||
@ -708,6 +753,35 @@ async function bootstrapApp() {
|
||||
this.socket.on('connect_error', (error) => {
|
||||
console.error('WebSocket连接错误:', error.message);
|
||||
});
|
||||
|
||||
this.socket.on('quota_update', (data) => {
|
||||
if (data && data.quotas) {
|
||||
this.usageQuota = this.normalizeUsageQuota({ quotas: data.quotas });
|
||||
} else {
|
||||
this.fetchUsageQuota();
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on('quota_notice', (data) => {
|
||||
this.showQuotaToast(data || {});
|
||||
this.fetchUsageQuota();
|
||||
});
|
||||
|
||||
this.socket.on('quota_exceeded', (data) => {
|
||||
this.showQuotaToast(data || {});
|
||||
this.fetchUsageQuota();
|
||||
});
|
||||
|
||||
this.socket.on('reconnect_attempt', async () => {
|
||||
await assignSocketToken();
|
||||
});
|
||||
|
||||
const ready = await assignSocketToken();
|
||||
if (!ready) {
|
||||
console.error('无法获取实时连接凭证,WebSocket 初始化中止。');
|
||||
return;
|
||||
}
|
||||
this.socket.connect();
|
||||
|
||||
// ==========================================
|
||||
// Token统计WebSocket事件处理(修复版)
|
||||
@ -1526,6 +1600,7 @@ async function bootstrapApp() {
|
||||
this.agentVersion = statusData.version || this.agentVersion;
|
||||
this.thinkingMode = !!statusData.thinking_mode;
|
||||
this.applyStatusSnapshot(statusData);
|
||||
await this.fetchUsageQuota();
|
||||
|
||||
// 获取当前对话信息
|
||||
const statusConversationId = statusData.conversation && statusData.conversation.current_id;
|
||||
@ -1765,6 +1840,50 @@ async function bootstrapApp() {
|
||||
this.projectStorageTimer = null;
|
||||
}
|
||||
},
|
||||
|
||||
async fetchUsageQuota() {
|
||||
try {
|
||||
const response = await fetch('/api/usage');
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText || '请求失败');
|
||||
}
|
||||
const data = await response.json();
|
||||
if (data && data.success && data.data) {
|
||||
this.usageQuota = this.normalizeUsageQuota(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('获取用量配额失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
normalizeUsageQuota(raw) {
|
||||
const tpl = { count: 0, limit: 0, reset_at: null, window_start: null };
|
||||
const quotas = (raw && raw.quotas) || {};
|
||||
const role = quotas.role || (raw && raw.role) || 'user';
|
||||
return {
|
||||
fast: { ...tpl, ...(quotas.fast || {}) },
|
||||
thinking: { ...tpl, ...(quotas.thinking || {}) },
|
||||
search: { ...tpl, ...(quotas.search || {}) },
|
||||
role
|
||||
};
|
||||
},
|
||||
|
||||
startUsageQuotaPolling() {
|
||||
if (this.usageQuotaTimer) {
|
||||
return;
|
||||
}
|
||||
this.fetchUsageQuota();
|
||||
this.usageQuotaTimer = setInterval(() => {
|
||||
this.fetchUsageQuota();
|
||||
}, 60000);
|
||||
},
|
||||
|
||||
stopUsageQuotaPolling() {
|
||||
if (this.usageQuotaTimer) {
|
||||
clearInterval(this.usageQuotaTimer);
|
||||
this.usageQuotaTimer = null;
|
||||
}
|
||||
},
|
||||
|
||||
// ==========================================
|
||||
// 对话管理核心功能
|
||||
@ -2831,6 +2950,12 @@ async function bootstrapApp() {
|
||||
if (!this.inputMessage.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const quotaType = this.thinkingMode ? 'thinking' : 'fast';
|
||||
if (this.isQuotaExceeded(quotaType)) {
|
||||
this.showQuotaToast({ type: quotaType });
|
||||
return;
|
||||
}
|
||||
|
||||
const message = this.inputMessage;
|
||||
|
||||
@ -3897,6 +4022,84 @@ async function bootstrapApp() {
|
||||
return !!(this.containerStatus && this.containerStatus.mode === 'docker' && this.containerStatus.stats);
|
||||
},
|
||||
|
||||
quotaTypeLabel(type) {
|
||||
switch (type) {
|
||||
case 'thinking':
|
||||
return '思考模型';
|
||||
case 'search':
|
||||
return '搜索';
|
||||
default:
|
||||
return '常规模型';
|
||||
}
|
||||
},
|
||||
|
||||
formatResetTime(iso) {
|
||||
if (!iso) {
|
||||
return '--:--';
|
||||
}
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '--:--';
|
||||
}
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
},
|
||||
|
||||
formatQuotaValue(entry) {
|
||||
if (!entry) {
|
||||
return '--';
|
||||
}
|
||||
const count = Number(entry.count) || 0;
|
||||
const limit = Number(entry.limit) || 0;
|
||||
if (!limit) {
|
||||
return `${count}`;
|
||||
}
|
||||
return `${count} / ${limit}`;
|
||||
},
|
||||
|
||||
quotaResetSummary() {
|
||||
const parts = [];
|
||||
['fast', 'thinking', 'search'].forEach((type) => {
|
||||
const entry = this.usageQuota && this.usageQuota[type];
|
||||
if (entry && entry.reset_at && Number(entry.count || 0) > 0) {
|
||||
parts.push(`${this.quotaTypeLabel(type)} ${this.formatResetTime(entry.reset_at)}`);
|
||||
}
|
||||
});
|
||||
return parts.join(' · ');
|
||||
},
|
||||
|
||||
isQuotaExceeded(type) {
|
||||
const entry = this.usageQuota && this.usageQuota[type];
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
const limit = Number(entry.limit) || 0;
|
||||
if (!limit) {
|
||||
return false;
|
||||
}
|
||||
return Number(entry.count || 0) >= limit;
|
||||
},
|
||||
|
||||
showQuotaToast(payload) {
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
const type = payload.type || 'fast';
|
||||
const resetAt = payload.reset_at || (this.usageQuota[type] && this.usageQuota[type].reset_at);
|
||||
const label = this.quotaTypeLabel(type);
|
||||
const timeText = this.formatResetTime(resetAt);
|
||||
this.quotaToast = {
|
||||
message: `${label} 配额已用完,将在 ${timeText} 重置`,
|
||||
type
|
||||
};
|
||||
if (this.quotaToastTimer) {
|
||||
clearTimeout(this.quotaToastTimer);
|
||||
}
|
||||
this.quotaToastTimer = setTimeout(() => {
|
||||
this.quotaToast = null;
|
||||
this.quotaToastTimer = null;
|
||||
}, 5000);
|
||||
},
|
||||
|
||||
containerStatusText() {
|
||||
if (!this.containerStatus) {
|
||||
return '未知';
|
||||
|
||||
@ -25,6 +25,7 @@
|
||||
<textarea id="editorArea" spellcheck="false"></textarea>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/static/security.js"></script>
|
||||
<script src="/static/file_manager/editor.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -55,6 +55,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/security.js"></script>
|
||||
<script src="/static/file_manager/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -32,6 +32,11 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<transition name="quota-toast-fade">
|
||||
<div class="quota-toast" v-if="quotaToast">
|
||||
<span class="quota-toast-label">{{ quotaToast.message }}</span>
|
||||
</div>
|
||||
</transition>
|
||||
<!-- Loading indicator -->
|
||||
<div v-if="!isConnected && messages.length === 0" style="text-align: center; padding: 50px;">
|
||||
<h2>正在连接服务器...</h2>
|
||||
@ -328,30 +333,31 @@
|
||||
<div class="token-drawer" v-if="currentConversationId" :class="{ collapsed: tokenPanelCollapsed }">
|
||||
<div class="token-display-panel">
|
||||
<div class="token-panel-content">
|
||||
<div class="token-panel-layout">
|
||||
<div class="token-card">
|
||||
<div class="token-stats">
|
||||
<div class="token-item">
|
||||
<span class="token-label">当前上下文</span>
|
||||
<span class="token-value current">{{ formatTokenCount(currentContextTokens || 0) }}</span>
|
||||
</div>
|
||||
<div class="token-item">
|
||||
<span class="token-label">累计输入</span>
|
||||
<span class="token-value input">{{ formatTokenCount(currentConversationTokens.cumulative_input_tokens || 0) }}</span>
|
||||
</div>
|
||||
<div class="token-item">
|
||||
<span class="token-label">累计输出</span>
|
||||
<span class="token-value output">{{ formatTokenCount(currentConversationTokens.cumulative_output_tokens || 0) }}</span>
|
||||
<div class="token-panel-grid">
|
||||
<div class="panel-row">
|
||||
<div class="token-card compact panel-card">
|
||||
<div class="panel-row-title">Token 统计</div>
|
||||
<div class="token-stats inline-row">
|
||||
<div class="token-item">
|
||||
<span class="token-label">当前上下文</span>
|
||||
<span class="token-value current">{{ formatTokenCount(currentContextTokens || 0) }}</span>
|
||||
</div>
|
||||
<div class="token-item">
|
||||
<span class="token-label">累计输入</span>
|
||||
<span class="token-value input">{{ formatTokenCount(currentConversationTokens.cumulative_input_tokens || 0) }}</span>
|
||||
</div>
|
||||
<div class="token-item">
|
||||
<span class="token-label">累计输出</span>
|
||||
<span class="token-value output">{{ formatTokenCount(currentConversationTokens.cumulative_output_tokens || 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container-stats-card" v-if="containerStatus">
|
||||
<div class="container-stats-header">
|
||||
<span class="token-label">容器资源</span>
|
||||
<span class="status-pill" :class="containerStatusClass()">{{ containerStatusText() }}</span>
|
||||
</div>
|
||||
<template v-if="hasContainerStats()">
|
||||
<div class="container-metric-grid">
|
||||
<div class="container-card compact panel-card" v-if="containerStatus">
|
||||
<div class="panel-row-title">
|
||||
<span class="token-label">CPU / 内存</span>
|
||||
<span class="status-pill" :class="containerStatusClass()">{{ containerStatusText() }}</span>
|
||||
</div>
|
||||
<div class="double-metric">
|
||||
<div class="container-metric">
|
||||
<span class="metric-label">CPU</span>
|
||||
<span class="metric-value">{{ formatPercentage(containerStatus.stats?.cpu_percent) }}</span>
|
||||
@ -368,6 +374,40 @@
|
||||
{{ formatPercentage(containerStatus.stats.memory.percent) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container-card compact panel-card empty" v-else>
|
||||
<div class="container-empty">
|
||||
当前运行在宿主机模式,暂无容器指标。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-row">
|
||||
<div class="token-card compact panel-card">
|
||||
<div class="panel-row-title">模型 / 搜索 用量</div>
|
||||
<div class="quota-inline">
|
||||
<div class="quota-inline-item">
|
||||
<div class="quota-label">常规模型</div>
|
||||
<div class="quota-value">{{ formatQuotaValue(usageQuota.fast) }}</div>
|
||||
<div class="quota-reset" v-if="(usageQuota.fast.count || 0) > 0">重置 {{ formatResetTime(usageQuota.fast.reset_at) }}</div>
|
||||
</div>
|
||||
<div class="quota-inline-item">
|
||||
<div class="quota-label">思考模型</div>
|
||||
<div class="quota-value">{{ formatQuotaValue(usageQuota.thinking) }}</div>
|
||||
<div class="quota-reset" v-if="(usageQuota.thinking.count || 0) > 0">重置 {{ formatResetTime(usageQuota.thinking.reset_at) }}</div>
|
||||
</div>
|
||||
<div class="quota-inline-item">
|
||||
<div class="quota-label">搜索</div>
|
||||
<div class="quota-value">{{ formatQuotaValue(usageQuota.search) }}</div>
|
||||
<div class="quota-reset" v-if="(usageQuota.search.count || 0) > 0">重置 {{ formatResetTime(usageQuota.search.reset_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container-card compact panel-card">
|
||||
<div class="panel-row-title">
|
||||
<span class="token-label">网络 / 存储</span>
|
||||
</div>
|
||||
<div class="double-metric">
|
||||
<div class="container-metric">
|
||||
<span class="metric-label">网络</span>
|
||||
<span class="metric-value">
|
||||
@ -383,17 +423,12 @@
|
||||
/ {{ formatBytes(projectStorage.limit_bytes) }}
|
||||
</template>
|
||||
</span>
|
||||
<span class="metric-subtext" v-if="projectStorage.limit_bytes">
|
||||
{{ formatPercentage(projectStorage.usage_percent) }}
|
||||
<span class="metric-subtext" v-if="typeof projectStorage.usage_percent === 'number'">
|
||||
{{ projectStorage.usage_percent.toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="container-empty">
|
||||
当前运行在宿主机模式,暂无容器指标。
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1089,6 +1124,7 @@
|
||||
<script src="/static/easter-eggs/registry.js"></script>
|
||||
<script src="/static/easter-eggs/flood.js"></script>
|
||||
<script src="/static/easter-eggs/snake.js"></script>
|
||||
<script src="/static/security.js"></script>
|
||||
<!-- 加载应用脚本 -->
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
|
||||
@ -105,10 +105,17 @@
|
||||
还没有账号?<a href="/register">点击注册</a>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/security.js"></script>
|
||||
<script>
|
||||
const btn = document.getElementById('login-btn');
|
||||
const errorEl = document.getElementById('error');
|
||||
|
||||
if (window.ensureCsrfToken) {
|
||||
window.ensureCsrfToken().catch((err) => {
|
||||
console.warn('获取 CSRF token 失败:', err);
|
||||
});
|
||||
}
|
||||
|
||||
btn.addEventListener('click', async () => {
|
||||
const email = document.getElementById('email').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
@ -102,10 +102,17 @@
|
||||
已有账号?<a href="/login">返回登录</a>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/security.js"></script>
|
||||
<script>
|
||||
const btn = document.getElementById('register-btn');
|
||||
const errorEl = document.getElementById('error');
|
||||
|
||||
if (window.ensureCsrfToken) {
|
||||
window.ensureCsrfToken().catch((err) => {
|
||||
console.warn('获取 CSRF token 失败:', err);
|
||||
});
|
||||
}
|
||||
|
||||
async function register() {
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const email = document.getElementById('email').value.trim();
|
||||
|
||||
109
static/security.js
Normal file
109
static/security.js
Normal file
@ -0,0 +1,109 @@
|
||||
(function () {
|
||||
if (window.__secureFetchWrapped) {
|
||||
return;
|
||||
}
|
||||
if (typeof window.fetch !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.__secureFetchWrapped = true;
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
const csrfState = {
|
||||
token: null,
|
||||
inflight: null
|
||||
};
|
||||
|
||||
async function fetchCsrfToken(forceRefresh = false) {
|
||||
if (!forceRefresh && csrfState.token) {
|
||||
return csrfState.token;
|
||||
}
|
||||
if (csrfState.inflight && !forceRefresh) {
|
||||
return csrfState.inflight;
|
||||
}
|
||||
csrfState.inflight = originalFetch('/api/csrf-token', { credentials: 'same-origin' })
|
||||
.then((resp) => {
|
||||
if (!resp.ok) {
|
||||
throw new Error('无法获取 CSRF token');
|
||||
}
|
||||
return resp.json();
|
||||
})
|
||||
.then((data) => {
|
||||
csrfState.token = data && data.token ? data.token : '';
|
||||
csrfState.inflight = null;
|
||||
return csrfState.token;
|
||||
})
|
||||
.catch((err) => {
|
||||
csrfState.inflight = null;
|
||||
csrfState.token = null;
|
||||
throw err;
|
||||
});
|
||||
return csrfState.inflight;
|
||||
}
|
||||
|
||||
function extractMethod(resource, options) {
|
||||
if (options && options.method) {
|
||||
return options.method;
|
||||
}
|
||||
const RequestCtor = typeof Request !== 'undefined' ? Request : null;
|
||||
if (RequestCtor && resource instanceof RequestCtor && resource.method) {
|
||||
return resource.method;
|
||||
}
|
||||
return 'GET';
|
||||
}
|
||||
|
||||
function mergeHeaders(baseHeaders, extraHeaders) {
|
||||
const merged = new Headers(baseHeaders || {});
|
||||
if (extraHeaders) {
|
||||
new Headers(extraHeaders).forEach((value, key) => merged.set(key, value));
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
async function secureFetch(resource, init) {
|
||||
const RequestCtor = typeof Request !== 'undefined' ? Request : null;
|
||||
let requestInfo = resource;
|
||||
let options = init ? { ...init } : {};
|
||||
const method = (extractMethod(resource, options) || 'GET').toUpperCase();
|
||||
const needsProtection = !SAFE_METHODS.has(method);
|
||||
|
||||
if (needsProtection) {
|
||||
const token = await fetchCsrfToken();
|
||||
if (RequestCtor && resource instanceof RequestCtor) {
|
||||
const merged = mergeHeaders(resource.headers, options.headers);
|
||||
merged.set('X-CSRF-Token', token);
|
||||
requestInfo = new RequestCtor(resource, { headers: merged });
|
||||
if (options.headers) {
|
||||
options = { ...options };
|
||||
delete options.headers;
|
||||
}
|
||||
} else {
|
||||
const headers = mergeHeaders(null, options.headers);
|
||||
headers.set('X-CSRF-Token', token);
|
||||
options.headers = headers;
|
||||
}
|
||||
}
|
||||
return originalFetch(requestInfo, options);
|
||||
}
|
||||
|
||||
async function requestSocketToken() {
|
||||
const resp = await originalFetch('/api/socket-token', { credentials: 'same-origin' });
|
||||
if (!resp.ok) {
|
||||
throw new Error('无法获取实时连接凭证');
|
||||
}
|
||||
const data = await resp.json();
|
||||
if (!data || !data.success || !data.token) {
|
||||
throw new Error((data && data.error) || '实时连接凭证无效');
|
||||
}
|
||||
return data.token;
|
||||
}
|
||||
|
||||
window.fetch = function (resource, init) {
|
||||
return secureFetch(resource, init);
|
||||
};
|
||||
|
||||
window.ensureCsrfToken = fetchCsrfToken;
|
||||
window.refreshCsrfToken = () => fetchCsrfToken(true);
|
||||
window.requestSocketToken = requestSocketToken;
|
||||
window.secureFetch = secureFetch;
|
||||
})();
|
||||
121
static/style.css
121
static/style.css
@ -2425,10 +2425,15 @@ o-files {
|
||||
padding: 16px 36px;
|
||||
}
|
||||
|
||||
.token-panel-layout {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 32px;
|
||||
.token-panel-grid {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.panel-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
||||
gap: 24px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
@ -2436,10 +2441,12 @@ o-files {
|
||||
flex: 1 1 320px;
|
||||
}
|
||||
|
||||
.token-card.compact,
|
||||
.container-stats-card {
|
||||
flex: 1 1 320px;
|
||||
padding-left: 24px;
|
||||
border-left: 1px solid var(--claude-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.token-stats {
|
||||
@ -2449,6 +2456,57 @@ o-files {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.token-stats.inline-row {
|
||||
flex-wrap: nowrap;
|
||||
gap: 24px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.token-stats.inline-row > .token-item {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.quota-inline {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.quota-inline-item {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.usage-quota-card {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.quota-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.quota-label {
|
||||
font-size: 12px;
|
||||
color: var(--claude-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.quota-value {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: var(--claude-text);
|
||||
}
|
||||
|
||||
.quota-reset {
|
||||
font-size: 12px;
|
||||
color: var(--claude-text-secondary);
|
||||
}
|
||||
|
||||
.token-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -2479,11 +2537,20 @@ o-files {
|
||||
.token-value.input { color: var(--claude-success); }
|
||||
.token-value.output { color: var(--claude-warning); }
|
||||
|
||||
.container-stats-header {
|
||||
.panel-row-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--claude-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.double-metric {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
@ -2495,6 +2562,36 @@ o-files {
|
||||
color: var(--claude-text-secondary);
|
||||
}
|
||||
|
||||
.quota-toast {
|
||||
position: fixed;
|
||||
top: 24px;
|
||||
right: 32px;
|
||||
padding: 12px 18px;
|
||||
border-radius: 12px;
|
||||
background: var(--claude-bg);
|
||||
border: 1px solid var(--claude-border-strong);
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.18);
|
||||
z-index: 2000;
|
||||
max-width: 320px;
|
||||
color: var(--claude-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.quota-toast-label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.quota-toast-fade-enter-active,
|
||||
.quota-toast-fade-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.quota-toast-fade-enter-from,
|
||||
.quota-toast-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.status-pill--running {
|
||||
background: rgba(118, 176, 134, 0.18);
|
||||
color: var(--claude-success);
|
||||
@ -2512,8 +2609,8 @@ o-files {
|
||||
|
||||
.container-metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 14px 20px;
|
||||
grid-template-columns: repeat(2, minmax(150px, 1fr));
|
||||
gap: 16px 24px;
|
||||
}
|
||||
|
||||
.container-metric {
|
||||
@ -3113,3 +3210,7 @@ o-files {
|
||||
.easter-egg-overlay.active {
|
||||
opacity: 1;
|
||||
}
|
||||
.panel-card {
|
||||
border-left: 1px solid var(--claude-border);
|
||||
padding: 0 0 0 0;
|
||||
}
|
||||
|
||||
366
web_server.py
366
web_server.py
@ -22,6 +22,8 @@ from collections import defaultdict, deque
|
||||
from werkzeug.utils import secure_filename
|
||||
from werkzeug.routing import BaseConverter
|
||||
import secrets
|
||||
import logging
|
||||
import hmac
|
||||
|
||||
# 添加项目根目录到Python路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
@ -54,6 +56,7 @@ from modules.personalization_manager import (
|
||||
save_personalization_config,
|
||||
)
|
||||
from modules.user_container_manager import UserContainerManager
|
||||
from modules.usage_tracker import UsageTracker
|
||||
|
||||
app = Flask(__name__, static_folder='static')
|
||||
app.config['MAX_CONTENT_LENGTH'] = MAX_UPLOAD_SIZE
|
||||
@ -63,11 +66,30 @@ if not _secret_key:
|
||||
print("[security] WEB_SECRET_KEY 未设置,已生成临时密钥(重启后失效)。")
|
||||
app.config['SECRET_KEY'] = _secret_key
|
||||
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=12)
|
||||
_cookie_secure_env = (os.environ.get("WEB_COOKIE_SECURE") or "").strip().lower()
|
||||
app.config['SESSION_COOKIE_SAMESITE'] = os.environ.get("WEB_COOKIE_SAMESITE", "Strict")
|
||||
app.config['SESSION_COOKIE_SECURE'] = _cookie_secure_env in {"1", "true", "yes"}
|
||||
app.config['SESSION_COOKIE_HTTPONLY'] = True
|
||||
CORS(app)
|
||||
|
||||
socketio = SocketIO(app, cors_allowed_origins="*", async_mode='threading')
|
||||
|
||||
|
||||
class EndpointFilter(logging.Filter):
|
||||
"""过滤掉噪声请求日志。"""
|
||||
BLOCK_PATTERNS = (
|
||||
"GET /api/project-storage",
|
||||
"GET /api/container-status",
|
||||
)
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
message = record.getMessage()
|
||||
return not any(pattern in message for pattern in self.BLOCK_PATTERNS)
|
||||
|
||||
|
||||
logging.getLogger('werkzeug').addFilter(EndpointFilter())
|
||||
|
||||
|
||||
class ConversationIdConverter(BaseConverter):
|
||||
regex = r'(?:conv_)?\d{8}_\d{6}_\d{3}'
|
||||
|
||||
@ -80,9 +102,22 @@ user_terminals: Dict[str, WebTerminal] = {}
|
||||
terminal_rooms: Dict[str, set] = {}
|
||||
connection_users: Dict[str, str] = {}
|
||||
stop_flags: Dict[str, Dict[str, Any]] = {}
|
||||
RATE_LIMIT_BUCKETS: Dict[str, deque] = defaultdict(deque)
|
||||
FAILURE_TRACKERS: Dict[str, Dict[str, float]] = {}
|
||||
pending_socket_tokens: Dict[str, Dict[str, Any]] = {}
|
||||
usage_trackers: Dict[str, UsageTracker] = {}
|
||||
|
||||
DEFAULT_PORT = 8091
|
||||
THINKING_FAILURE_KEYWORDS = ["⚠️", "🛑", "失败", "错误", "异常", "终止", "error", "failed", "未完成", "超时", "强制"]
|
||||
CSRF_HEADER_NAME = "X-CSRF-Token"
|
||||
CSRF_SESSION_KEY = "_csrf_token"
|
||||
CSRF_SAFE_METHODS = {"GET", "HEAD", "OPTIONS", "TRACE"}
|
||||
CSRF_PROTECTED_PATHS = {"/login", "/register", "/logout"}
|
||||
CSRF_PROTECTED_PREFIXES = ("/api/",)
|
||||
CSRF_EXEMPT_PATHS = {"/api/csrf-token"}
|
||||
FAILED_LOGIN_LIMIT = 5
|
||||
FAILED_LOGIN_LOCK_SECONDS = 300
|
||||
SOCKET_TOKEN_TTL_SECONDS = 45
|
||||
|
||||
|
||||
def format_read_file_result(result_data: Dict) -> str:
|
||||
@ -167,6 +202,149 @@ def sanitize_filename_preserve_unicode(filename: str) -> str:
|
||||
return cleaned[:255]
|
||||
|
||||
|
||||
def get_client_ip() -> str:
|
||||
"""获取客户端IP,支持 X-Forwarded-For."""
|
||||
forwarded = request.headers.get("X-Forwarded-For")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.remote_addr or "unknown"
|
||||
|
||||
|
||||
def resolve_identifier(scope: str = "ip", identifier: Optional[str] = None, kwargs: Optional[Dict[str, Any]] = None) -> str:
|
||||
if identifier:
|
||||
return identifier
|
||||
if scope == "user":
|
||||
if kwargs:
|
||||
username = kwargs.get('username')
|
||||
if username:
|
||||
return username
|
||||
username = get_current_username()
|
||||
if username:
|
||||
return username
|
||||
return get_client_ip()
|
||||
|
||||
|
||||
def check_rate_limit(action: str, limit: int, window_seconds: int, identifier: Optional[str]) -> Tuple[bool, int]:
|
||||
"""针对指定动作进行简单的滑动窗口限频。"""
|
||||
bucket_key = f"{action}:{identifier or 'anonymous'}"
|
||||
bucket = RATE_LIMIT_BUCKETS[bucket_key]
|
||||
now = time.time()
|
||||
while bucket and now - bucket[0] > window_seconds:
|
||||
bucket.popleft()
|
||||
if len(bucket) >= limit:
|
||||
retry_after = window_seconds - int(now - bucket[0])
|
||||
return True, max(retry_after, 1)
|
||||
bucket.append(now)
|
||||
return False, 0
|
||||
|
||||
|
||||
def rate_limited(action: str, limit: int, window_seconds: int, scope: str = "ip", error_message: Optional[str] = None):
|
||||
"""装饰器:为路由增加速率限制。"""
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
def wrapped(*args, **kwargs):
|
||||
identifier = resolve_identifier(scope, kwargs=kwargs)
|
||||
limited, retry_after = check_rate_limit(action, limit, window_seconds, identifier)
|
||||
if limited:
|
||||
message = error_message or "请求过于频繁,请稍后再试。"
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": message,
|
||||
"retry_after": retry_after
|
||||
}), 429
|
||||
return func(*args, **kwargs)
|
||||
return wrapped
|
||||
return decorator
|
||||
|
||||
|
||||
def register_failure(action: str, limit: int, lock_seconds: int, scope: str = "ip", identifier: Optional[str] = None, kwargs: Optional[Dict[str, Any]] = None) -> int:
|
||||
"""记录失败次数,超过阈值后触发锁定。"""
|
||||
ident = resolve_identifier(scope, identifier, kwargs)
|
||||
key = f"{action}:{ident}"
|
||||
now = time.time()
|
||||
entry = FAILURE_TRACKERS.setdefault(key, {"count": 0, "blocked_until": 0})
|
||||
blocked_until = entry.get("blocked_until", 0)
|
||||
if blocked_until and blocked_until > now:
|
||||
return int(blocked_until - now)
|
||||
entry["count"] = entry.get("count", 0) + 1
|
||||
if entry["count"] >= limit:
|
||||
entry["count"] = 0
|
||||
entry["blocked_until"] = now + lock_seconds
|
||||
return lock_seconds
|
||||
return 0
|
||||
|
||||
|
||||
def is_action_blocked(action: str, scope: str = "ip", identifier: Optional[str] = None, kwargs: Optional[Dict[str, Any]] = None) -> Tuple[bool, int]:
|
||||
ident = resolve_identifier(scope, identifier, kwargs)
|
||||
key = f"{action}:{ident}"
|
||||
entry = FAILURE_TRACKERS.get(key)
|
||||
if not entry:
|
||||
return False, 0
|
||||
now = time.time()
|
||||
blocked_until = entry.get("blocked_until", 0)
|
||||
if blocked_until and blocked_until > now:
|
||||
return True, int(blocked_until - now)
|
||||
return False, 0
|
||||
|
||||
|
||||
def clear_failures(action: str, scope: str = "ip", identifier: Optional[str] = None, kwargs: Optional[Dict[str, Any]] = None):
|
||||
ident = resolve_identifier(scope, identifier, kwargs)
|
||||
key = f"{action}:{ident}"
|
||||
FAILURE_TRACKERS.pop(key, None)
|
||||
|
||||
|
||||
def get_csrf_token(force_new: bool = False) -> str:
|
||||
token = session.get(CSRF_SESSION_KEY)
|
||||
if force_new or not token:
|
||||
token = secrets.token_urlsafe(32)
|
||||
session[CSRF_SESSION_KEY] = token
|
||||
return token
|
||||
|
||||
|
||||
def requires_csrf_protection(path: str) -> bool:
|
||||
if path in CSRF_EXEMPT_PATHS:
|
||||
return False
|
||||
if path in CSRF_PROTECTED_PATHS:
|
||||
return True
|
||||
return any(path.startswith(prefix) for prefix in CSRF_PROTECTED_PREFIXES)
|
||||
|
||||
|
||||
def validate_csrf_request() -> bool:
|
||||
expected = session.get(CSRF_SESSION_KEY)
|
||||
provided = request.headers.get(CSRF_HEADER_NAME) or request.form.get("csrf_token")
|
||||
if not expected or not provided:
|
||||
return False
|
||||
try:
|
||||
return hmac.compare_digest(str(provided), str(expected))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def prune_socket_tokens(now: Optional[float] = None):
|
||||
current = now or time.time()
|
||||
for token, meta in list(pending_socket_tokens.items()):
|
||||
if meta.get("expires_at", 0) <= current:
|
||||
pending_socket_tokens.pop(token, None)
|
||||
|
||||
|
||||
def consume_socket_token(token_value: Optional[str], username: Optional[str]) -> bool:
|
||||
if not token_value or not username:
|
||||
return False
|
||||
prune_socket_tokens()
|
||||
token_meta = pending_socket_tokens.pop(token_value, None)
|
||||
if not token_meta:
|
||||
return False
|
||||
if token_meta.get("username") != username:
|
||||
return False
|
||||
if token_meta.get("expires_at", 0) <= time.time():
|
||||
return False
|
||||
fingerprint = token_meta.get("fingerprint") or ""
|
||||
request_fp = (request.headers.get("User-Agent") or "")[:128]
|
||||
if fingerprint and request_fp and not hmac.compare_digest(fingerprint, request_fp):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def format_tool_result_notice(tool_name: str, tool_call_id: Optional[str], content: str) -> str:
|
||||
"""将工具执行结果转为系统消息文本,方便在对话中回传。"""
|
||||
header = f"[工具结果] {tool_name}"
|
||||
@ -234,6 +412,7 @@ def get_user_resources(username: Optional[str] = None) -> Tuple[Optional[WebTerm
|
||||
return None, None
|
||||
workspace = user_manager.ensure_user_workspace(username)
|
||||
container_handle = container_manager.ensure_container(username, str(workspace.project_path))
|
||||
usage_tracker = get_or_create_usage_tracker(username, workspace)
|
||||
terminal = user_terminals.get(username)
|
||||
if not terminal:
|
||||
thinking_mode = session.get('thinking_mode', False)
|
||||
@ -242,17 +421,52 @@ def get_user_resources(username: Optional[str] = None) -> Tuple[Optional[WebTerm
|
||||
thinking_mode=thinking_mode,
|
||||
message_callback=make_terminal_callback(username),
|
||||
data_dir=str(workspace.data_dir),
|
||||
container_session=container_handle
|
||||
container_session=container_handle,
|
||||
usage_tracker=usage_tracker
|
||||
)
|
||||
if terminal.terminal_manager:
|
||||
terminal.terminal_manager.broadcast = terminal.message_callback
|
||||
user_terminals[username] = terminal
|
||||
terminal.username = username
|
||||
terminal.quota_update_callback = lambda metric=None: emit_user_quota_update(username)
|
||||
else:
|
||||
terminal.update_container_session(container_handle)
|
||||
attach_user_broadcast(terminal, username)
|
||||
terminal.username = username
|
||||
terminal.quota_update_callback = lambda metric=None: emit_user_quota_update(username)
|
||||
return terminal, workspace
|
||||
|
||||
|
||||
def get_or_create_usage_tracker(username: Optional[str], workspace: Optional[UserWorkspace] = None) -> Optional[UsageTracker]:
|
||||
if not username:
|
||||
return None
|
||||
tracker = usage_trackers.get(username)
|
||||
if tracker:
|
||||
return tracker
|
||||
if workspace is None:
|
||||
workspace = user_manager.ensure_user_workspace(username)
|
||||
record = user_manager.get_user(username)
|
||||
role = getattr(record, "role", "user") if record else "user"
|
||||
tracker = UsageTracker(str(workspace.data_dir), role=role or "user")
|
||||
usage_trackers[username] = tracker
|
||||
return tracker
|
||||
|
||||
|
||||
def emit_user_quota_update(username: Optional[str]):
|
||||
if not username:
|
||||
return
|
||||
tracker = get_or_create_usage_tracker(username)
|
||||
if not tracker:
|
||||
return
|
||||
try:
|
||||
snapshot = tracker.get_quota_snapshot()
|
||||
socketio.emit('quota_update', {
|
||||
'quotas': snapshot
|
||||
}, room=f"user_{username}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def with_terminal(func):
|
||||
"""注入用户专属终端和工作区"""
|
||||
@wraps(func)
|
||||
@ -481,6 +695,39 @@ def terminal_broadcast(event_type, data):
|
||||
except Exception as e:
|
||||
debug_log(f"终端广播错误: {e}")
|
||||
|
||||
|
||||
@app.route('/api/csrf-token', methods=['GET'])
|
||||
def issue_csrf_token():
|
||||
"""提供 CSRF token,供前端写操作附带。"""
|
||||
token = get_csrf_token()
|
||||
response = jsonify({"success": True, "token": token})
|
||||
response.headers['Cache-Control'] = 'no-store'
|
||||
return response
|
||||
|
||||
|
||||
@app.before_request
|
||||
def enforce_csrf_token():
|
||||
method = (request.method or "GET").upper()
|
||||
if method in CSRF_SAFE_METHODS:
|
||||
return
|
||||
if not requires_csrf_protection(request.path):
|
||||
return
|
||||
if validate_csrf_request():
|
||||
return
|
||||
return jsonify({"success": False, "error": "CSRF validation failed"}), 403
|
||||
|
||||
|
||||
@app.after_request
|
||||
def apply_security_headers(response):
|
||||
response.headers.setdefault("X-Frame-Options", "SAMEORIGIN")
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
if response.mimetype == "application/json":
|
||||
response.headers.setdefault("Cache-Control", "no-store")
|
||||
if app.config.get("SESSION_COOKIE_SECURE"):
|
||||
response.headers.setdefault("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||
return response
|
||||
|
||||
@app.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
"""登录页面与认证"""
|
||||
@ -494,21 +741,51 @@ def login():
|
||||
data = request.get_json() or {}
|
||||
email = (data.get('email') or '').strip()
|
||||
password = data.get('password') or ''
|
||||
client_ip = get_client_ip()
|
||||
limited, retry_after = check_rate_limit("login", 10, 60, client_ip)
|
||||
if limited:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "登录请求过于频繁,请稍后再试。",
|
||||
"retry_after": retry_after
|
||||
}), 429
|
||||
|
||||
blocked, block_for = is_action_blocked("login", identifier=client_ip)
|
||||
if blocked:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": f"尝试次数过多,请 {block_for} 秒后重试。",
|
||||
"retry_after": block_for
|
||||
}), 429
|
||||
|
||||
record = user_manager.authenticate(email, password)
|
||||
if not record:
|
||||
return jsonify({"success": False, "error": "账号或密码错误"}), 401
|
||||
wait_seconds = register_failure("login", FAILED_LOGIN_LIMIT, FAILED_LOGIN_LOCK_SECONDS, identifier=client_ip)
|
||||
error_payload = {
|
||||
"success": False,
|
||||
"error": "账号或密码错误"
|
||||
}
|
||||
status_code = 401
|
||||
if wait_seconds:
|
||||
error_payload.update({
|
||||
"error": f"尝试次数过多,请 {wait_seconds} 秒后重试。",
|
||||
"retry_after": wait_seconds
|
||||
})
|
||||
status_code = 429
|
||||
return jsonify(error_payload), status_code
|
||||
|
||||
session['logged_in'] = True
|
||||
session['username'] = record.username
|
||||
session['thinking_mode'] = app.config.get('DEFAULT_THINKING_MODE', False)
|
||||
session.permanent = True
|
||||
clear_failures("login", identifier=client_ip)
|
||||
workspace = user_manager.ensure_user_workspace(record.username)
|
||||
try:
|
||||
container_manager.ensure_container(record.username, str(workspace.project_path))
|
||||
except RuntimeError as exc:
|
||||
session.clear()
|
||||
return jsonify({"success": False, "error": str(exc), "code": "resource_busy"}), 503
|
||||
get_csrf_token(force_new=True)
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@ -526,6 +803,14 @@ def register():
|
||||
password = data.get('password') or ''
|
||||
invite_code = (data.get('invite_code') or '').strip()
|
||||
|
||||
limited, retry_after = check_rate_limit("register", 5, 300, get_client_ip())
|
||||
if limited:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "注册请求过于频繁,请稍后再试。",
|
||||
"retry_after": retry_after
|
||||
}), 429
|
||||
|
||||
try:
|
||||
user_manager.register_user(username, email, password, invite_code)
|
||||
return jsonify({"success": True})
|
||||
@ -544,6 +829,9 @@ def logout():
|
||||
user_terminals.pop(username, None)
|
||||
if username:
|
||||
container_manager.release_container(username, reason="logout")
|
||||
for token_value, meta in list(pending_socket_tokens.items()):
|
||||
if meta.get("username") == username:
|
||||
pending_socket_tokens.pop(token_value, None)
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@ -676,9 +964,24 @@ def get_project_storage(terminal: WebTerminal, workspace: UserWorkspace, usernam
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
|
||||
@app.route('/api/usage', methods=['GET'])
|
||||
@api_login_required
|
||||
def get_usage_stats():
|
||||
"""返回当前用户的模型/搜索调用统计。"""
|
||||
username = get_current_username()
|
||||
tracker = get_or_create_usage_tracker(username)
|
||||
if not tracker:
|
||||
return jsonify({"success": False, "error": "未找到用户"}), 404
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": tracker.get_stats()
|
||||
})
|
||||
|
||||
@app.route('/api/thinking-mode', methods=['POST'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
@rate_limited("thinking_mode_toggle", 15, 60, scope="user")
|
||||
def update_thinking_mode(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""切换思考模式"""
|
||||
try:
|
||||
@ -733,6 +1036,7 @@ def get_personalization_settings(terminal: WebTerminal, workspace: UserWorkspace
|
||||
@app.route('/api/personalization', methods=['POST'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
@rate_limited("personalization_update", 20, 300, scope="user")
|
||||
def update_personalization_settings(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""更新个性化配置"""
|
||||
payload = request.get_json() or {}
|
||||
@ -798,6 +1102,7 @@ def gui_list_entries(terminal: WebTerminal, workspace: UserWorkspace, username:
|
||||
@app.route('/api/gui/files/create', methods=['POST'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
@rate_limited("gui_file_create", 30, 60, scope="user")
|
||||
def gui_create_entry(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
payload = request.get_json() or {}
|
||||
parent = payload.get('path') or ""
|
||||
@ -820,6 +1125,7 @@ def gui_create_entry(terminal: WebTerminal, workspace: UserWorkspace, username:
|
||||
@app.route('/api/gui/files/delete', methods=['POST'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
@rate_limited("gui_file_delete", 30, 60, scope="user")
|
||||
def gui_delete_entries(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
payload = request.get_json() or {}
|
||||
paths = payload.get('paths') or []
|
||||
@ -840,6 +1146,7 @@ def gui_delete_entries(terminal: WebTerminal, workspace: UserWorkspace, username
|
||||
@app.route('/api/gui/files/rename', methods=['POST'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
@rate_limited("gui_file_rename", 30, 60, scope="user")
|
||||
def gui_rename_entry(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
payload = request.get_json() or {}
|
||||
path = payload.get('path')
|
||||
@ -863,6 +1170,7 @@ def gui_rename_entry(terminal: WebTerminal, workspace: UserWorkspace, username:
|
||||
@app.route('/api/gui/files/copy', methods=['POST'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
@rate_limited("gui_file_copy", 40, 120, scope="user")
|
||||
def gui_copy_entries(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
payload = request.get_json() or {}
|
||||
paths = payload.get('paths') or []
|
||||
@ -884,6 +1192,7 @@ def gui_copy_entries(terminal: WebTerminal, workspace: UserWorkspace, username:
|
||||
@app.route('/api/gui/files/move', methods=['POST'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
@rate_limited("gui_file_move", 40, 120, scope="user")
|
||||
def gui_move_entries(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
payload = request.get_json() or {}
|
||||
paths = payload.get('paths') or []
|
||||
@ -905,6 +1214,7 @@ def gui_move_entries(terminal: WebTerminal, workspace: UserWorkspace, username:
|
||||
@app.route('/api/gui/files/upload', methods=['POST'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
@rate_limited("gui_file_upload", 10, 300, scope="user")
|
||||
def gui_upload_entry(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
if 'file' not in request.files:
|
||||
return jsonify({"success": False, "error": "未找到文件"}), 400
|
||||
@ -1049,6 +1359,7 @@ def get_todo_list(terminal: WebTerminal, workspace: UserWorkspace, username: str
|
||||
@app.route('/api/upload', methods=['POST'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
@rate_limited("legacy_upload", 20, 300, scope="user")
|
||||
def upload_file(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""处理前端文件上传请求"""
|
||||
if 'file' not in request.files:
|
||||
@ -1273,13 +1584,37 @@ def get_terminals(terminal: WebTerminal, workspace: UserWorkspace, username: str
|
||||
else:
|
||||
return jsonify({"sessions": [], "active": None, "total": 0})
|
||||
|
||||
|
||||
@app.route('/api/socket-token', methods=['GET'])
|
||||
@api_login_required
|
||||
def issue_socket_token():
|
||||
"""生成一次性 WebSocket token,供握手阶段使用。"""
|
||||
username = get_current_username()
|
||||
prune_socket_tokens()
|
||||
now = time.time()
|
||||
for token_value, meta in list(pending_socket_tokens.items()):
|
||||
if meta.get("username") == username:
|
||||
pending_socket_tokens.pop(token_value, None)
|
||||
token_value = secrets.token_urlsafe(32)
|
||||
pending_socket_tokens[token_value] = {
|
||||
"username": username,
|
||||
"expires_at": now + SOCKET_TOKEN_TTL_SECONDS,
|
||||
"fingerprint": (request.headers.get('User-Agent') or '')[:128],
|
||||
}
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"token": token_value,
|
||||
"expires_in": SOCKET_TOKEN_TTL_SECONDS
|
||||
})
|
||||
|
||||
@socketio.on('connect')
|
||||
def handle_connect():
|
||||
def handle_connect(auth):
|
||||
"""客户端连接"""
|
||||
print(f"[WebSocket] 客户端连接: {request.sid}")
|
||||
username = get_current_username()
|
||||
if not username:
|
||||
emit('error', {'message': '未登录或会话已失效'})
|
||||
token_value = (auth or {}).get('socket_token') if isinstance(auth, dict) else None
|
||||
if not username or not consume_socket_token(token_value, username):
|
||||
emit('error', {'message': '未登录或连接凭证无效'})
|
||||
disconnect()
|
||||
return
|
||||
|
||||
@ -2778,6 +3113,27 @@ async def handle_task_with_sender(terminal: WebTerminal, message, sender, client
|
||||
|
||||
thinking_expected = web_terminal.api_client.get_current_thinking_mode()
|
||||
debug_log(f"思考模式: {thinking_expected}")
|
||||
quota_allowed = True
|
||||
quota_info = {}
|
||||
if hasattr(web_terminal, "record_model_call"):
|
||||
quota_allowed, quota_info = web_terminal.record_model_call(bool(thinking_expected))
|
||||
if not quota_allowed:
|
||||
quota_type = 'thinking' if thinking_expected else 'fast'
|
||||
socketio.emit('quota_notice', {
|
||||
'type': quota_type,
|
||||
'reset_at': quota_info.get('reset_at'),
|
||||
'limit': quota_info.get('limit'),
|
||||
'count': quota_info.get('count')
|
||||
}, room=f"user_{getattr(web_terminal, 'username', '')}")
|
||||
sender('quota_exceeded', {
|
||||
'type': quota_type,
|
||||
'reset_at': quota_info.get('reset_at')
|
||||
})
|
||||
sender('error', {
|
||||
'message': "配额已达到上限,暂时无法继续调用模型。",
|
||||
'quota': quota_info
|
||||
})
|
||||
return
|
||||
|
||||
print(f"[API] 第{iteration + 1}次调用 (总工具调用: {total_tool_calls}/{MAX_TOTAL_TOOL_CALLS})")
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user