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 @@ + diff --git a/static/file_manager/index.html b/static/file_manager/index.html index 89e6923..7704108 100644 --- a/static/file_manager/index.html +++ b/static/file_manager/index.html @@ -55,6 +55,7 @@ + diff --git a/static/index.html b/static/index.html index 7d5461a..40139e7 100644 --- a/static/index.html +++ b/static/index.html @@ -32,6 +32,11 @@
+ +
+ {{ quotaToast.message }} +
+

正在连接服务器...

@@ -328,30 +333,31 @@
-
-
-
-
- 当前上下文 - {{ formatTokenCount(currentContextTokens || 0) }} -
-
- 累计输入 - {{ formatTokenCount(currentConversationTokens.cumulative_input_tokens || 0) }} -
-
- 累计输出 - {{ formatTokenCount(currentConversationTokens.cumulative_output_tokens || 0) }} +
+
+
+
Token 统计
+
+
+ 当前上下文 + {{ formatTokenCount(currentContextTokens || 0) }} +
+
+ 累计输入 + {{ formatTokenCount(currentConversationTokens.cumulative_input_tokens || 0) }} +
+
+ 累计输出 + {{ formatTokenCount(currentConversationTokens.cumulative_output_tokens || 0) }} +
-
-
-
- 容器资源 - {{ containerStatusText() }} -
- - - {{ formatPercentage(projectStorage.usage_percent) }} + + {{ projectStorage.usage_percent.toFixed(1) }}%
- - +
@@ -1089,6 +1124,7 @@ + diff --git a/static/login.html b/static/login.html index bcb49fc..020093f 100644 --- a/static/login.html +++ b/static/login.html @@ -105,10 +105,17 @@ 还没有账号?点击注册
+