From 8c8b2d3a204e4615aeeb01eb8e6cb172cf427a2b Mon Sep 17 00:00:00 2001
From: JOJO <1498581755@qq.com>
Date: Mon, 24 Nov 2025 00:47:17 +0800
Subject: [PATCH] chore: snapshot before usage menu update
---
core/main_terminal.py | 42 ++++
core/web_terminal.py | 3 +-
doc/phase3_summary.md | 34 +++
doc/security_review.md | 4 +-
modules/usage_tracker.py | 193 +++++++++++++++++
static/app.js | 215 ++++++++++++++++++-
static/file_manager/editor.html | 1 +
static/file_manager/index.html | 1 +
static/index.html | 96 ++++++---
static/login.html | 7 +
static/register.html | 7 +
static/security.js | 109 ++++++++++
static/style.css | 121 ++++++++++-
web_server.py | 366 +++++++++++++++++++++++++++++++-
14 files changed, 1145 insertions(+), 54 deletions(-)
create mode 100644 doc/phase3_summary.md
create mode 100644 modules/usage_tracker.py
create mode 100644 static/security.js
diff --git a/core/main_terminal.py b/core/main_terminal.py
index dfa1792..a1865f0 100644
--- a/core/main_terminal.py
+++ b/core/main_terminal.py
@@ -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"),
diff --git a/core/web_terminal.py b/core/web_terminal.py
index 7269ca5..ffbfa6e 100644
--- a/core/web_terminal.py
+++ b/core/web_terminal.py
@@ -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
diff --git a/doc/phase3_summary.md b/doc/phase3_summary.md
new file mode 100644
index 0000000..59512e4
--- /dev/null
+++ b/doc/phase3_summary.md
@@ -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 小时后自动恢复。
diff --git a/doc/security_review.md b/doc/security_review.md
index dd19ac1..8770fa7 100644
--- a/doc/security_review.md
+++ b/doc/security_review.md
@@ -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` 未检查当前用户权限。 | 误配置可能导致数据写入到未知磁盘或权限不足引发异常。 | 在启动阶段校验运行环境(磁盘权限、必需目录、环境变量),并提供友好报错与文档。 |
diff --git a/modules/usage_tracker.py b/modules/usage_tracker.py
new file mode 100644
index 0000000..14a2770
--- /dev/null
+++ b/modules/usage_tracker.py
@@ -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"]
diff --git a/static/app.js b/static/app.js
index 0bd257b..a5822c9 100644
--- a/static/app.js
+++ b/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 '未知';
diff --git a/static/file_manager/editor.html b/static/file_manager/editor.html
index e391e22..c29dd7f 100644
--- a/static/file_manager/editor.html
+++ b/static/file_manager/editor.html
@@ -25,6 +25,7 @@
+