diff --git a/AGENTS.md b/AGENTS.md index ea67dba9..4c367e8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ - `server/app.py`: 推荐的 Web 服务入口(封装并转发到 `server/app_legacy.py`) - `web_server.py`: 兼容入口,已标记 deprecated,但仍可启动 - **后端核心目录** - - `server/`: Flask 业务主线(chat/task/status/context 已拆分为子包:`server/chat/`、`server/status/`、`server/tasks/`、`server/context/`(用户资源/身份/广播/个性化,原 `server/context.py` 已拆包并保留兼容 re-export);REST 任务轮询为主,Socket.IO 主要用于兼容与实时辅助通道) + - `server/`: Flask 业务主线(chat/task/status/context 已拆分为子包:`server/chat/`、`server/status/`、`server/tasks/`、`server/context/`(用户资源/身份/个性化,原 `server/context.py` 已拆包并保留兼容 re-export);**REST 任务轮询为唯一实时通道**——Socket.IO 已于 2026-09-11 整体移除,详见 §12.5) - `server/runtime/`: 公共任务入口(2026-09 Gateway 化阶段二新增):`context.py` 定义 RuntimeContext 三层模型(TrustedPrincipal/TaskParams/InternalDirectives),`service.py` 提供 RuntimeService(create_task/cancel/guidance/queue/get_task_events);契约见 `docs/runtime_contract.md` - `core/`: 终端与工具编排(`main_terminal.py`、`web_terminal.py`、`main_terminal_parts/*`;其中 `main_terminal_parts/context/` 和 `main_terminal_parts/tools_definition` 已拆分为 base + mixin 子包) - `modules/`: 可复用能力模块(terminal/file/memory/sub_agent/upload_security/user 等;`file_manager`、`persistent_terminal`、`terminal_ops`、`mcp_client_manager` 已拆分为子包) @@ -31,7 +31,7 @@ - `utils/`: API client、日志、上下文与对话工具等公共函数(`api_client`、`tool_result_formatter`、`context_manager`、`conversation_manager` 已拆分为子包;原入口文件保留为兼容入口) - **前端目录** - `static/src/`: Vue 3 + TS 前端 - - `cli/src/`: React 19 + Ink 6 + TypeScript CLI 前端(正在重写中) + - `cli/src/`: opentui(Zig 内核 + Yoga)+ React 19 CLI 前端(2026-09-11 起正式替代 Ink 版;旧 Ink 实现存档于 `cli/legacy-ink-src/` 仅作字段参考) - 监控动画相关核心文件: - `static/src/components/chat/monitor/MonitorDirector.ts` - `static/src/stores/monitor.ts` @@ -117,12 +117,12 @@ - 开发监听(当前脚本是 build watch):`npm run dev` - Lint:`npm run lint` -### CLI(React / Ink) -- 安装依赖:`npm --prefix cli install` -- 开发启动:`npm run cli` 或 `npm --prefix cli run dev` -- 构建:`npm run cli:build` -- 类型检查:`npm run cli:typecheck` -- 可执行命令名(构建后):`agents` / `agents-cli` +### CLI(opentui / bun) +- 运行环境:bun ≥1.3(opentui FFI;node ≥26.4 亦可,本机 node v24 不可用) +- 开发启动:`bun cli/src/main.tsx`(或 `npm --prefix cli run dev`) +- 类型检查:`cd cli && ./node_modules/.bin/tsc --noEmit` +- 可执行命令名:`astrion`(已装到 `/opt/homebrew/bin/astrion` → `cli/bin/astrion` symlink,bun wrapper 支持 symlink 解析;`--version`/`--help` 快速退出不启 TUI;后续 `bun build --compile` 单文件分发) +- 依赖安装:沙箱网络受限时不跑 npm/bun install,从 `cli-redesign-demo/node_modules` 铺平(版本以 `cli/package.json` 为准) ## 3) 测试现状(不要再写过时命令) @@ -133,8 +133,8 @@ - `test_system_message.py` 依赖外部 `MOONSHOT_API_KEY` 与网络,不属于离线稳定 CI 用例。 - 当前仓库未发现 `pytest.ini`/`pyproject.toml`/`tox.ini`;不要默认要求 `pytest` 作为唯一入口(仅作为冒烟测试的便捷运行器)。 - CLI 当前最小可复现验证: - - `npm --prefix cli run typecheck` - - `npm --prefix cli run build` + - `cd cli && ./node_modules/.bin/tsc --noEmit` + - 无头冒烟:`testRender` 抓帧脚本(写完即用即删,参考 cli-redesign-demo 的验证模式; gateway 指向 127.0.0.1:9 不碰真实服务) - 若改动 `server/chat/`、`server/status/`、`server/tasks/` 等后端接口适配,补充: - `python3 -m py_compile server/chat/*.py server/status/*.py server/tasks/*.py` - `python -m pytest test/test_server_refactor_smoke.py -q` @@ -146,7 +146,7 @@ - **文件编辑方式**:修改文件时优先使用 `apply_patch` 或其他原生文件编辑工具;尽量不要用 `bash`/`python` 脚本批量改文件,除非原生工具明显不适合。 - **后端改动优先级**:先改 `modules/`、`server/` 内对应模块,最后才动入口。 - **前端改动优先级**:按 `static/src` 现有分层改(`app/`、`stores/`、`components/`、`composables/`)。 -- **CLI 改动优先级**:优先在 `cli/src/App.tsx`、`cli/src/components.tsx`、`cli/src/eventMapper.ts`、`cli/src/api.ts` 内做最小闭环修改。 +- **CLI 改动优先级**:`cli/src/` 分层——入口编排 `main.tsx`/`boot.tsx`;主界面 `app.tsx`;Gateway 客户端 `gateway.ts`(Bearer);会话运行时 `runtime.ts`(发消息/事件轮询/审批弹出);菜单状态机 `menuState.ts`;渲染分发 `menu.tsx`;通用件 `components.tsx`/`selector.tsx`;数据层 `data.ts`;文案 `i18n/`。新交互先定归属层再动手。 - 涉及 monitor 动画/事件联动时,至少同步检查: - `MonitorDirector.ts`(动画与场景执行) - `stores/monitor.ts`(事件队列与状态机) @@ -173,12 +173,14 @@ - 运行根目录前端构建时,默认使用 `npm run build --silent 2>&1 | tail -n 5`。 - 构建/安装/Lint 类命令(如 `npm run build`、`npm install`、`npm run lint`)因沙箱权限失败时(如报 `EPERM` / `Operation not permitted` / 沙箱拒绝写入),直接向用户说明失败原因并申请权限或调整路径授权,禁止尝试任何绕过沙箱的做法(如换执行方式规避限制、向其他路径写入等)。 -### CLI 当前交互约束(2026-05-15) +### CLI 当前交互约束(2026-09-11 重写) -- CLI 连接的是现有本地 Web API(默认 `127.0.0.1:8091`),不是独立 agent runtime。 -- 启动 CLI 时应清屏、连接本地服务、创建新会话,并将输入区固定在底部。 -- 当前目录若不在工作区中,应先弹出“是否添加到工作区”的选择。 -- 思考内容当前默认隐藏,只显示“思考中 / 思考完成”标题;相关折叠代码保留,后续可继续修。 +- **CLI 全部走 Gateway host Bearer 通道**(`docs/runtime_protocol.md` §6):token 读 `~/.astrion/astrion/host/data/host_api_token`,请求带 `Authorization: Bearer`;**禁止** Web 会话通道(Cookie/CSRF/host-login)。工作区经 `X-Astrion-Workspace-Id` 头绑定(server/gateway_auth.py 解析)。 +- **启动指令 `astrion`**:cwd = 工作区,运行内不可切换;cwd 未注册为工作区时先提示并询问创建(确认面板 Esc=取消退出)。 +- **服务自启动**:CLI 不与 Web 端启动绑定——探测无服务时自动 spawn `python3 -m server.app --port 8091 --thinking-mode`(detached+unref,显式端口);服务在运行但无 host token = 旧版本服务,提示重启(token 由服务端启动时生成,`initialize_system` 内 host 分支);请求 401 自动重读 token 重试一次(自愈)。**边界:CLI 只在探测无服务时启动新实例,绝不 kill/重启已在运行的进程。** +- **审批面板自动弹出**:收到 `tool_approval_required` 事件即弹出(不依赖手动 /approvals);←→ 选 运行/拒绝/切无限制,Enter 裁决(decision 端点)。 +- **多语言读 OS 语言设置**(`cli/src/i18n/`,LC_ALL/LANG 检测 zh/en,启动时定死);CLI 内所有文案禁按字符长度硬编码布局(选中反色只落文字节点,项间固定间距)。 +- 输入语义:Enter=发送,Shift+Enter=换行(opentui textarea 默认相反,Composer 已用 keyBindings 覆盖)。 - 不要在未获得用户要求的情况下运行交互式 TUI 压测或长时间模拟输入,以免刷屏占满上下文。 ## 5.5) 前端设计风格统一规范(强制) @@ -238,7 +240,7 @@ - **key 奇偶强校验**:en-US 聚合器用 `DeepString` 约束,en 缺/多 key 直接 tsc 报错;新增命名空间须在 `zh-CN.ts` / `en-US.ts` 同步注册。 - **防回退栏杆**:`npm run lint` 先跑 `scripts/i18n_audit.mjs`(剥离注释后查裸中文,独立命令 `lint:text`);存量文件列在 `scripts/i18n_baseline.txt`,**迁移完一个文件就删一行**,删除后该文件永久受栏杆保护。 - **语言切换**:个人空间 → 外观 → 界面语言;默认 zh-CN(不跟随浏览器),持久化 key `agents_ui_locale`。 -- **边界**:后端下发文字(API error、通知、工具结果摘要)不做多语言,前端原样显示;CLI 暂不纳入。 +- **边界**:后端下发文字(API error、通知、工具结果摘要)不做多语言,前端原样显示;CLI 独立成体系(`cli/src/i18n/`,读 OS 语言设置,不跟随 web 个人空间)。 ## 6) Git 工作流(开发 + Review) @@ -460,7 +462,7 @@ AI 执行以下流程时,每一步都要向用户说明在做什么: ### 11.2 子智能体执行机制 -- 子智能体在主进程内 `asyncio.Task`,跑在独立后台事件循环线程里(避开 Flask-SocketIO threading 冲突)。工具调用复用主进程沙箱/容器链路,网络调用走 `utils.api_client.APIClient`。 +- 子智能体在主进程内 `asyncio.Task`,跑在独立后台事件循环线程里(历史原因是避开 Flask-SocketIO threading 冲突;Socket.IO 已移除,该线程模型仍保留)。工具调用复用主进程沙箱/容器链路,网络调用走 `utils.api_client.APIClient`。 - **模型请求重试(2026-08-26 起)**:`_run_loop` 对 `_call_model` 包重试循环,与主智能体 `run_streaming_attempts` 同构——最多 5 次尝试(`_SUB_AGENT_MAX_API_RETRIES=4`)、间隔 10s(`_SUB_AGENT_RETRY_DELAY_SECONDS`,用 `asyncio.sleep` 分段等待并响应软停止/取消)。重试条件:**仅当零接收**(未收到任何文本/思考/工具调用)才重试;已开始收到内容后断流(`SubAgentModelCallError.received_any=True`)直接失败。失败终态分模式:多智能体模式下 5 次全失败 → 转为 idle + `_forward_output_to_master` 报错(等 Team Leader 重新下达指令),输出期间断开 → 直接 failed 并同步向主智能体报错;传统模式一律 `_write_failure`。 - **工具「正在调用」进度事件(2026-08-26 起)**:`_call_model` 在工具名+id 首个流式 chunk 到达时即 emit `status="calling"` 进度事件(与后续 running/completed 共用同一 tool_call id),前端按 id 原地更新条目;`RunnerDetailPanel.vue` / `SubAgentActivityDialog.vue` 的 normalizeStatus 识别 `calling`(显示 spinner + 「调用中」),并支持同一 id 历史条目跨组原地更新(多工具 calling 事件交错场景)。 - 子智能体在多智能体模式下: @@ -570,7 +572,7 @@ AI 执行以下流程时,每一步都要向用户说明在做什么: ## 12) 对话级主任务门闸与单写者不变量(2026-08-12) -> 事故背景:一个对话并发运行了两个主聊天任务(socketio 用户任务 + 完成通知轮询器派发的通知任务),交叉写入共享 `conversation_history`,产生 `assistant→assistant→tool→tool` 乱序段,最终 API 400 `tool_call_id is not found`、通知永久丢失。本节机制即为修复该事故引入。 +> 事故背景:一个对话并发运行了两个主聊天任务(socketio 入口的用户任务 + 完成通知轮询器派发的通知任务),交叉写入共享 `conversation_history`,产生 `assistant→assistant→tool→tool` 乱序段,最终 API 400 `tool_call_id is not found`、通知永久丢失。本节机制即为修复该事故引入。 ### 12.1 单写者不变量(核心约束) @@ -594,6 +596,25 @@ AI 执行以下流程时,每一步都要向用户说明在做什么: 2. **不要在 return 分支手写 `_tool_loop_active` 恢复**:`execute_tool_calls`(`server/chat_flow_tool_loop.py`)已改为守护包装(try/finally 复位,内层 `_execute_tool_calls_impl`),新增提前返回路径无需也不应手动操作该标志——并发交错「存旧值→置True→恢复旧值」正是此前标志卡死的原因。 3. **不要依赖 build_messages 防御层掩盖并发问题**:`core/main_terminal_parts/context/messages.py` 的孤儿 tool 消息剥离只是「坏数据不再 400」的止血层,乱序段本身意味着历史已被污染;发现剥离 warning 日志应按事故排查,而不是视为正常。 +### 12.5 Socket.IO 已整体移除(2026-09-11) + +Web 端实时通道曾长期双轨(REST 任务轮询为主 + Socket.IO 辅助推送),本次清理后**轮询成为唯一通道**,Socket.IO 及其依赖(flask-socketio / socket.io-client / websockets)已全量移除。 + +**事件出口唯一权威 = 任务事件流**(`TaskRecord.events`,`_append_event`):运行期一切事件(text/thinking/tool/token/todo/edited_files/edit_summary/context_warning/quota_exceeded/conversation_changed 标题更新等)都在其中,前端任务轮询与 CLI 共用。历史遗留的 socket 推送只是同一事件的镜像,删除不丢数据。 + +**各原 socket 功能的替代机制**(改代码前必查): +- `status_update`(原 13 处推送)→ 前端 `socketMethods.fetchStatusSnapshot` 空闲 5s 轮询 `/api/status`(运行期跳过,由任务事件流驱动);发起方操作后的即时状态走 REST 响应载荷。 +- `quota_update/notice/exceeded` → `quota_exceeded` 本就在事件流;配额快照走 resource store 既有的 UsageQuotaPolling。 +- `conversation_list_update / conversation_changed / conversation_loaded` → 发起方操作后主动刷新(既有主路径);**多标签页被动同步退化为 status 轮询间接感知**(不再即时,属刻意取舍)。 +- `system_ready` → `loadInitialData` 的 `/api/status` 一次性初始化已覆盖。 +- 终端面板实时输出 → `GET /api/terminals//output` REST 轮询(TerminalPanel 面板打开时输出 1.5s、列表 5s),快照前缀匹配增量写入 xterm。 +- 连接状态指示 → 既有 REST 心跳(`/api/health`,连续失败阈值置灰),与 socket 断开语义等价。 + +**硬性约束**: +1. 新增任何「实时通知」一律写任务事件流(`_append_event`)或扩 `/api/status` 载荷,**禁止**重新引入 socket 推送。 +2. `server/extensions.py` 只剩 `run_background`(daemon 线程);不要再往里加推送类函数。 +3. `WebTerminal.broadcast()` / `terminal_manager.broadcast` 调用点保留但回调恒为 None(判空安全),新代码不要依赖它们产生用户可见效果。 + ## 13) 工具动态加载(tool_loading,2026-09 新增) > 设计文档:`docs/dynamic_tool_loading_plan.md`。注册表与状态辅助唯一权威:`core/tool_loading.py`。 diff --git a/core/web_terminal.py b/core/web_terminal.py index 6899b858..b185a6e6 100644 --- a/core/web_terminal.py +++ b/core/web_terminal.py @@ -687,7 +687,7 @@ class WebTerminal(MainTerminal): return "思考模式" if self.thinking_mode else "快速模式" def broadcast(self, event_type: str, data: Dict): - """广播事件到WebSocket""" + """经 message_callback 推送事件(Socket.IO 已移除,回调恒为 None 时 no-op)""" if self.message_callback: payload = dict(data or {}) payload.setdefault('conversation_id', self.context_manager.current_conversation_id) diff --git a/package.json b/package.json index 82d99dc1..ce86f56f 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,6 @@ "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "sherpa-onnx": "^1.13.2", - "socket.io-client": "^4.7.5", "unified": "^11.0.5", "virtua": "^0.49.3", "vue": "^3.4.15", diff --git a/requirements.txt b/requirements.txt index fd2fa577..3fe979da 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,10 @@ # 运行时直接依赖(人读、宽松,不锁版本,便于开发期升级)。 # 可复现安装 / release 打包请用 requirements.lock.txt(精确版本)。 flask -flask-socketio flask-cors werkzeug httpx[http2] openai cryptography pillow -websockets pyyaml>=6.0 diff --git a/server/__init__.py b/server/__init__.py index 29cbed72..36504353 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -5,7 +5,7 @@ """ import importlib -__all__ = ["app", "socketio", "run_server", "parse_arguments", "initialize_system", "resource_busy_page"] +__all__ = ["app", "run_server", "parse_arguments", "initialize_system", "resource_busy_page"] def __getattr__(name): diff --git a/server/app.py b/server/app.py index 71e68a53..be0e1fdf 100644 --- a/server/app.py +++ b/server/app.py @@ -3,7 +3,6 @@ import argparse from .app_legacy import ( app, - socketio, run_server as _run_server, parse_arguments as _parse_arguments, initialize_system, @@ -34,7 +33,6 @@ def main(): __all__ = [ "app", - "socketio", "run_server", "parse_arguments", "initialize_system", diff --git a/server/app_legacy.py b/server/app_legacy.py index 691fc51a..d7798120 100644 --- a/server/app_legacy.py +++ b/server/app_legacy.py @@ -11,7 +11,6 @@ import re import threading from typing import Dict, List, Optional, Callable, Any, Tuple from flask import Flask, request, jsonify, send_from_directory, session, redirect, send_file, abort -from flask_socketio import SocketIO, emit, join_room, leave_room, disconnect from flask_cors import CORS from werkzeug.exceptions import RequestEntityTooLarge from pathlib import Path @@ -41,7 +40,6 @@ from server.workflow_page import workflow_page_bp from server.workflow_runtime_api import workflow_runtime_bp from server.conversation_bootstrap import conversation_bootstrap_bp from server.gateway_api import gateway_bp -from server.socket_handlers import socketio from server.security import attach_security_hooks from werkzeug.utils import secure_filename from werkzeug.routing import BaseConverter @@ -201,9 +199,6 @@ if not ENABLE_VERBOSE_CONSOLE: # 抑制 Flask/Werkzeug 访问日志,只保留 brief_log 输出 logging.getLogger('werkzeug').setLevel(logging.ERROR) logging.getLogger('werkzeug').disabled = True -for noisy_logger in ('engineio.server', 'socketio.server'): - logging.getLogger(noisy_logger).setLevel(logging.ERROR) - logging.getLogger(noisy_logger).disabled = True # 静音子智能体模块错误日志(交由 brief_log 或前端提示处理) sub_agent_logger = logging.getLogger('modules.sub_agent.manager') sub_agent_logger.setLevel(logging.CRITICAL) @@ -271,8 +266,6 @@ app.config['SESSION_COOKIE_SECURE'] = _cookie_secure_env in {"1", "true", "yes"} app.config['SESSION_COOKIE_HTTPONLY'] = True CORS(app) -socketio.init_app(app, cors_allowed_origins='*', async_mode='threading', logger=False, engineio_logger=False) - class EndpointFilter(logging.Filter): """过滤掉噪声请求日志。""" @@ -329,7 +322,6 @@ MONITOR_SNAPSHOT_CHAR_LIMIT = state.MONITOR_SNAPSHOT_CHAR_LIMIT MONITOR_MEMORY_ENTRY_LIMIT = state.MONITOR_MEMORY_ENTRY_LIMIT RATE_LIMIT_BUCKETS = state.RATE_LIMIT_BUCKETS FAILURE_TRACKERS = state.FAILURE_TRACKERS -pending_socket_tokens = state.pending_socket_tokens usage_trackers = state.usage_trackers MONITOR_SNAPSHOT_CACHE = state.MONITOR_SNAPSHOT_CACHE @@ -350,7 +342,6 @@ CSRF_PROTECTED_PREFIXES = state.CSRF_PROTECTED_PREFIXES CSRF_EXEMPT_PATHS = state.CSRF_EXEMPT_PATHS FAILED_LOGIN_LIMIT = state.FAILED_LOGIN_LIMIT FAILED_LOGIN_LOCK_SECONDS = state.FAILED_LOGIN_LOCK_SECONDS -SOCKET_TOKEN_TTL_SECONDS = state.SOCKET_TOKEN_TTL_SECONDS PROJECT_STORAGE_CACHE = state.PROJECT_STORAGE_CACHE PROJECT_STORAGE_CACHE_TTL_SECONDS = state.PROJECT_STORAGE_CACHE_TTL_SECONDS USER_IDLE_TIMEOUT_SECONDS = state.USER_IDLE_TIMEOUT_SECONDS @@ -457,7 +448,7 @@ def start_background_jobs(): return _idle_reaper_started = True _load_last_active_cache() - socketio.start_background_task(idle_reaper_loop) + threading.Thread(target=idle_reaper_loop, daemon=True).start() try: from .context import start_conversation_terminal_reaper start_conversation_terminal_reaper() @@ -564,18 +555,8 @@ def generate_conversation_title_background(web_terminal: WebTerminal, conversati _title_debug_log("title_save_failed", conversation_id=conversation_id, safe_title=safe_title) return _title_debug_log("title_save_success", conversation_id=conversation_id, safe_title=safe_title) - try: - socketio.emit('conversation_changed', { - 'conversation_id': conversation_id, - 'title': safe_title - }, room=f"user_{username}") - socketio.emit('conversation_list_update', { - 'action': 'updated', - 'conversation_id': conversation_id - }, room=f"user_{username}") - except Exception as exc: - debug_log(f"[TitleGen] 推送标题更新失败: {exc}") - _title_debug_log("title_emit_exception", error=str(exc), conversation_id=conversation_id, username=username) + # 标题更新推送已随 WebSocket 移除:标题变更已写入任务事件流 + # (chat_flow_helpers 的 conversation_changed 事件),前端轮询可见。 try: asyncio.run(_runner()) @@ -744,31 +725,6 @@ def validate_csrf_request() -> bool: 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 = tr("legacy.tool_result_header", tool=tool_name) @@ -1047,27 +1003,6 @@ def detect_tool_failure(result_data: Any) -> bool: return True return False -# 终端广播回调函数 -def terminal_broadcast(event_type, data): - """广播终端事件到所有订阅者""" - try: - # 对于全局事件,发送给所有连接的客户端 - if event_type in ('token_update', 'todo_updated', 'edited_files_updated'): - socketio.emit(event_type, data) # 全局广播,不限制房间 - debug_log(f"全局广播{event_type}: {data}") - else: - # 其他终端事件发送到终端订阅者房间 - socketio.emit(event_type, data, room='terminal_subscribers') - - # 如果是特定会话的事件,也发送到该会话的专属房间 - if 'session' in data: - session_room = f"terminal_{data['session']}" - socketio.emit(event_type, data, room=session_room) - - debug_log(f"终端广播: {event_type} - {data}") - except Exception as e: - debug_log(f"终端广播错误: {e}") - # Routes removed; now provided by Blueprints in server/auth.py and server/files.py @@ -1103,6 +1038,15 @@ def initialize_system(path: str, thinking_mode: bool = False): print(f"{OUTPUT_FORMATS['success']} 预设子智能体角色同步完成") except Exception as _e: print(f"{OUTPUT_FORMATS['warning']} 预设子智能体角色同步失败: {_e}") + # host 模式启动即生成 CLI Bearer token(原惰性生成依赖首个 Bearer 请求触发, + # CLI 无 token 不发请求会形成鸡生蛋死锁;启动时生成根治) + if TERMINAL_SANDBOX_MODE == "host": + try: + from server.gateway_auth import get_or_create_host_api_token + if get_or_create_host_api_token(): + print(f"{OUTPUT_FORMATS['success']} Host API token 已就绪(CLI Bearer 通道)") + except Exception as _e: + print(f"{OUTPUT_FORMATS['warning']} Host API token 生成失败: {_e}") _mode_label = "宿主机模式(单用户)" if TERMINAL_SANDBOX_MODE == "host" else "多用户模式(Web)" print(f"{OUTPUT_FORMATS['success']} Web 系统初始化完成({_mode_label})") @@ -1116,13 +1060,13 @@ def run_server(path: str, thinking_mode: bool = False, port: int = DEFAULT_PORT, app.config['SESSION_COOKIE_NAME'] = f"agents_session_{port}" initialize_system(path, thinking_mode) start_background_jobs() - socketio.run( - app, + # Socket.IO 已移除,直接使用 Werkzeug 内建服务器(threaded 以支持并发轮询) + app.run( host=WEB_SERVER_HOST, port=port, debug=debug, use_reloader=debug, - allow_unsafe_werkzeug=True + threaded=True, ) diff --git a/server/auth.py b/server/auth.py index d591899c..1d2c79b9 100644 --- a/server/auth.py +++ b/server/auth.py @@ -351,9 +351,6 @@ def logout(): state.container_manager.release_container(key, reason="logout") except Exception: pass - for token_value, meta in list(state.pending_socket_tokens.items()): - if meta.get("username") == username: - state.pending_socket_tokens.pop(token_value, None) auth_debug_log(f"[auth_debug] {request.method} /logout after_clear session={_session_debug_snapshot()}") if request.method == 'GET': resp = make_response(redirect('/login?logged_out=1')) diff --git a/server/chat/approval.py b/server/chat/approval.py index 4f9d9f13..0ef6e017 100644 --- a/server/chat/approval.py +++ b/server/chat/approval.py @@ -36,11 +36,10 @@ from config.model_profiles import get_model_context_window from server.auth_helpers import resolve_admin_policy, get_current_user_record, get_current_username from server.gateway_auth import api_login_or_host_token_required from server.context import with_terminal, get_gui_manager, get_upload_guard, build_upload_error_response, ensure_conversation_loaded, get_or_create_usage_tracker -from server.security import rate_limited, prune_socket_tokens +from server.security import rate_limited from server.utils_common import debug_log -from server.state import PROJECT_MAX_STORAGE_MB, pending_socket_tokens, SOCKET_TOKEN_TTL_SECONDS +from server.state import PROJECT_MAX_STORAGE_MB from server.runtime import runtime_service -from server.extensions import socketio from server.monitor import get_cached_monitor_snapshot from modules.i18n import tr diff --git a/server/chat/files.py b/server/chat/files.py index 33a86736..2d6ffcea 100644 --- a/server/chat/files.py +++ b/server/chat/files.py @@ -34,11 +34,10 @@ from config.model_profiles import get_model_context_window from server.auth_helpers import api_login_required, resolve_admin_policy, get_current_user_record, get_current_username from server.context import with_terminal, get_gui_manager, get_upload_guard, build_upload_error_response, ensure_conversation_loaded, get_or_create_usage_tracker -from server.security import rate_limited, prune_socket_tokens +from server.security import rate_limited from server.utils_common import debug_log -from server.state import PROJECT_MAX_STORAGE_MB, pending_socket_tokens, SOCKET_TOKEN_TTL_SECONDS +from server.state import PROJECT_MAX_STORAGE_MB from server.state import tool_approval_manager, user_question_manager -from server.extensions import socketio from server.monitor import get_cached_monitor_snapshot from server.utils_common import sanitize_filename_preserve_unicode diff --git a/server/chat/misc.py b/server/chat/misc.py index 4b57cbca..a31936e7 100644 --- a/server/chat/misc.py +++ b/server/chat/misc.py @@ -35,11 +35,10 @@ from config.model_profiles import get_model_context_window from server.auth_helpers import api_login_required, resolve_admin_policy, get_current_user_record, get_current_username from server.context import with_terminal, get_gui_manager, get_upload_guard, build_upload_error_response, ensure_conversation_loaded, get_or_create_usage_tracker -from server.security import rate_limited, prune_socket_tokens +from server.security import rate_limited from server.utils_common import debug_log -from server.state import PROJECT_MAX_STORAGE_MB, pending_socket_tokens, SOCKET_TOKEN_TTL_SECONDS +from server.state import PROJECT_MAX_STORAGE_MB from server.state import tool_approval_manager, user_question_manager -from server.extensions import socketio from server.monitor import get_cached_monitor_snapshot from modules.i18n import tr @@ -147,9 +146,8 @@ def tool_settings(terminal: WebTerminal, workspace: UserWorkspace, username: str }), 403 terminal.set_tool_category_enabled(category, enabled) snapshot = terminal.get_tool_settings_snapshot() - socketio.emit('tool_settings_updated', { - 'categories': snapshot - }, room=f"user_{username}") + # tool_settings 变更不再经 WebSocket 广播:发起方直接使用本响应中的 + # categories,其他标签页在下次操作或 status 轮询时同步。 return jsonify({ "success": True, "categories": snapshot diff --git a/server/chat/permission.py b/server/chat/permission.py index 6f71ae6a..50abf4e9 100644 --- a/server/chat/permission.py +++ b/server/chat/permission.py @@ -46,11 +46,10 @@ from config.model_profiles import get_model_context_window from server.auth_helpers import api_login_required, resolve_admin_policy, get_current_user_record, get_current_username from server.context import with_terminal, get_gui_manager, get_upload_guard, build_upload_error_response, ensure_conversation_loaded, get_or_create_usage_tracker, get_user_resources -from server.security import rate_limited, prune_socket_tokens +from server.security import rate_limited from server.utils_common import debug_log -from server.state import PROJECT_MAX_STORAGE_MB, pending_socket_tokens, SOCKET_TOKEN_TTL_SECONDS +from server.state import PROJECT_MAX_STORAGE_MB from server.state import tool_approval_manager, user_question_manager -from server.extensions import socketio from server.monitor import get_cached_monitor_snapshot import os import re @@ -189,8 +188,6 @@ def update_permission_mode(terminal: WebTerminal, workspace: UserWorkspace, user "error": str(exc), "message": tr("chat_permission.update_failed") }), 500 - status = terminal.get_status() - socketio.emit('status_update', status, room=f"user_{username}") return jsonify({ "success": True, "mode": target_mode, @@ -221,8 +218,6 @@ def update_permission_mode(terminal: WebTerminal, workspace: UserWorkspace, user }), 500 session["permission_mode"] = applied_mode - status = terminal.get_status() - socketio.emit('status_update', status, room=f"user_{username}") return jsonify({ "success": True, "mode": applied_mode, @@ -284,8 +279,6 @@ def update_execution_mode(terminal: WebTerminal, workspace: UserWorkspace, usern _sync_workspace_terminal_mode(username, workspace, "execution_mode", target_mode) except Exception as exc: return jsonify({"success": False, "error": str(exc), "message": tr("chat_permission.execution_update_failed")}), 500 - status = terminal.get_status() - socketio.emit('status_update', status, room=f"user_{username}") return jsonify({ "success": True, "state": { @@ -310,8 +303,6 @@ def update_execution_mode(terminal: WebTerminal, workspace: UserWorkspace, usern _sync_workspace_terminal_mode(username, workspace, "execution_mode", state.get("mode", target_mode)) except Exception as exc: return jsonify({"success": False, "error": str(exc), "message": tr("chat_permission.execution_update_failed")}), 500 - status = terminal.get_status() - socketio.emit('status_update', status, room=f"user_{username}") return jsonify({ "success": True, "state": state, @@ -368,8 +359,6 @@ def update_network_permission(terminal: WebTerminal, workspace: UserWorkspace, u _sync_workspace_terminal_mode(username, workspace, "network_permission", target_mode) except Exception as exc: return jsonify({"success": False, "error": str(exc), "message": tr("chat_permission.network_update_failed")}), 500 - status = terminal.get_status() - socketio.emit('status_update', status, room=f"user_{username}") return jsonify({ "success": True, "mode": target_mode, @@ -391,8 +380,6 @@ def update_network_permission(terminal: WebTerminal, workspace: UserWorkspace, u _sync_workspace_terminal_mode(username, workspace, "network_permission", applied) except Exception as exc: return jsonify({"success": False, "error": str(exc), "message": tr("chat_permission.network_update_failed")}), 500 - status = terminal.get_status() - socketio.emit('status_update', status, room=f"user_{username}") return jsonify({ "success": True, "mode": applied, @@ -490,8 +477,6 @@ def update_work_mode(terminal: WebTerminal, workspace: UserWorkspace, username: except Exception: pass - status = terminal.get_status() - socketio.emit('status_update', status, room=f"user_{username}") return jsonify({ "success": True, "mode": result.get("mode") or target_mode, diff --git a/server/chat/settings.py b/server/chat/settings.py index 5e1eb41d..86289568 100644 --- a/server/chat/settings.py +++ b/server/chat/settings.py @@ -39,11 +39,10 @@ from config.model_profiles import get_model_context_window from server.auth_helpers import api_login_required, resolve_admin_policy, get_current_user_record, get_current_username from server.context import with_terminal, get_gui_manager, get_upload_guard, build_upload_error_response, ensure_conversation_loaded, get_or_create_usage_tracker -from server.security import rate_limited, prune_socket_tokens +from server.security import rate_limited from server.utils_common import debug_log -from server.state import PROJECT_MAX_STORAGE_MB, pending_socket_tokens, SOCKET_TOKEN_TTL_SECONDS +from server.state import PROJECT_MAX_STORAGE_MB from server.state import tool_approval_manager, user_question_manager -from server.extensions import socketio from server.monitor import get_cached_monitor_snapshot from modules.i18n import tr @@ -104,9 +103,6 @@ def update_thinking_mode(terminal: WebTerminal, workspace: UserWorkspace, userna except Exception as exc: logger.error(f"[API] 保存思考模式到对话失败: {exc}") - status = terminal.get_status() - socketio.emit('status_update', status, room=f"user_{username}") - return jsonify({ "success": True, "data": { @@ -170,9 +166,6 @@ def update_reasoning_effort(terminal: WebTerminal, workspace: UserWorkspace, use except Exception as exc: logger.error(f"[API] 保存推理强度到对话失败: {exc}") - status = terminal.get_status() - socketio.emit('status_update', status, room=f"user_{username}") - return jsonify({ "success": True, "data": { @@ -261,9 +254,6 @@ def update_model(terminal: WebTerminal, workspace: UserWorkspace, username: str) f"与 terminal 当前对话 {current_cid} 不一致" ) - status = terminal.get_status() - socketio.emit('status_update', status, room=f"user_{username}") - return jsonify({ "success": True, "data": { @@ -377,11 +367,6 @@ def update_personalization_settings(terminal: WebTerminal, workspace: UserWorksp ) except Exception as meta_exc: debug_log(f"应用个性化偏好失败: 同步对话元数据异常 {meta_exc}") - try: - status = terminal.get_status() - socketio.emit('status_update', status, room=f"user_{username}") - except Exception as status_exc: - debug_log(f"广播个性化状态失败: {status_exc}") except Exception as exc: debug_log(f"应用个性化偏好失败: {exc}") config_out = dict(config) diff --git a/server/chat/terminal.py b/server/chat/terminal.py index 4b17698d..e4c5881e 100644 --- a/server/chat/terminal.py +++ b/server/chat/terminal.py @@ -35,11 +35,10 @@ from config.model_profiles import get_model_context_window from server.auth_helpers import api_login_required, resolve_admin_policy, get_current_user_record, get_current_username from server.context import with_terminal, get_gui_manager, get_upload_guard, build_upload_error_response, ensure_conversation_loaded, get_or_create_usage_tracker -from server.security import rate_limited, prune_socket_tokens +from server.security import rate_limited from server.utils_common import debug_log -from server.state import PROJECT_MAX_STORAGE_MB, pending_socket_tokens, SOCKET_TOKEN_TTL_SECONDS +from server.state import PROJECT_MAX_STORAGE_MB from server.state import tool_approval_manager, user_question_manager -from server.extensions import socketio from server.monitor import get_cached_monitor_snapshot from modules.i18n import tr @@ -59,24 +58,29 @@ def get_terminals(terminal: WebTerminal, workspace: UserWorkspace, username: str else: return jsonify({"sessions": [], "active": None, "total": 0}) -@chat_bp.route('/api/socket-token', methods=['GET']) +@chat_bp.route('/api/terminals//output') @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 - }) +@with_terminal +def get_terminal_output_rest(terminal: WebTerminal, workspace: UserWorkspace, username: str, session_name: str): + """获取终端输出历史(TerminalPanel 轮询端点,替代原 WebSocket get_terminal_output)。""" + policy = resolve_admin_policy(get_current_user_record()) + if policy.get("ui_blocks", {}).get("block_realtime_terminal"): + return jsonify({"success": False, "error": tr("chat_terminal.realtime_terminal_admin_disabled")}), 403 + if not terminal.terminal_manager: + return jsonify({"success": False, "error": "Terminal system not initialized"}), 400 + try: + lines = int(request.args.get("lines", 100)) + except (TypeError, ValueError): + lines = 100 + lines = max(1, min(lines, 2000)) + result = terminal.terminal_manager.get_terminal_output(session_name, lines) + if result.get("success"): + return jsonify({ + "success": True, + "session": session_name, + "output": result.get("output", ""), + "is_interactive": result.get("is_interactive", False), + "last_command": result.get("last_command", ""), + "last_event_time": result.get("last_event_time"), + }) + return jsonify({"success": False, "error": result.get("error", "unknown error")}), 404 diff --git a/server/chat_flow.py b/server/chat_flow.py index be26cf63..f0658c7c 100644 --- a/server/chat_flow.py +++ b/server/chat_flow.py @@ -66,10 +66,10 @@ from .utils_common import ( CHUNK_FRONTEND_LOG_FILE, STREAMING_DEBUG_LOG_FILE, ) -from .security import rate_limited, format_tool_result_notice, compact_web_search_result, consume_socket_token, prune_socket_tokens, validate_csrf_request, requires_csrf_protection, get_csrf_token +from .security import rate_limited, format_tool_result_notice, compact_web_search_result, validate_csrf_request, requires_csrf_protection, get_csrf_token from .main_task_gate import acquire_adopted_main_task_gate, release_main_task_gate from .monitor import cache_monitor_snapshot, get_cached_monitor_snapshot -from .extensions import socketio, run_background +from .extensions import run_background from .state import ( MONITOR_FILE_TOOLS, MONITOR_MEMORY_TOOLS, @@ -77,7 +77,6 @@ from .state import ( MONITOR_MEMORY_ENTRY_LIMIT, RATE_LIMIT_BUCKETS, FAILURE_TRACKERS, - pending_socket_tokens, usage_trackers, MONITOR_SNAPSHOT_CACHE, MONITOR_SNAPSHOT_CACHE_LIMIT, @@ -118,7 +117,6 @@ def generate_conversation_title_background(web_terminal: WebTerminal, conversati conversation_id=conversation_id, user_message=user_message, username=username, - socketio_instance=socketio, title_prompt_path=TITLE_PROMPT_PATH, debug_logger=debug_log, title_model=title_model, diff --git a/server/chat_flow_helpers.py b/server/chat_flow_helpers.py index 8f86e4af..e9c7f1f9 100644 --- a/server/chat_flow_helpers.py +++ b/server/chat_flow_helpers.py @@ -119,7 +119,6 @@ def generate_conversation_title_background( conversation_id: str, user_message: str, username: str, - socketio_instance, title_prompt_path, debug_logger, title_model: str = "", @@ -186,21 +185,6 @@ def generate_conversation_title_background( debug_logger(f"[TitleGen] 添加任务事件失败: {exc}") _title_debug_log("title_task_event_exception", error=str(exc), conversation_id=conversation_id, username=username) - try: - socketio_instance.emit( - 'conversation_changed', - {'conversation_id': conversation_id, 'title': safe_title}, - room=f"user_{username}", - ) - socketio_instance.emit( - 'conversation_list_update', - {'action': 'updated', 'conversation_id': conversation_id}, - room=f"user_{username}", - ) - except Exception as exc: - debug_logger(f"[TitleGen] 推送标题更新失败: {exc}") - _title_debug_log("title_emit_exception", error=str(exc), conversation_id=conversation_id, username=username) - try: asyncio.run(_runner()) except Exception as exc: diff --git a/server/chat_flow_runtime.py b/server/chat_flow_runtime.py index 5e8c1e9a..630394c8 100644 --- a/server/chat_flow_runtime.py +++ b/server/chat_flow_runtime.py @@ -58,9 +58,8 @@ from .utils_common import ( CHUNK_FRONTEND_LOG_FILE, STREAMING_DEBUG_LOG_FILE, ) -from .security import rate_limited, format_tool_result_notice, compact_web_search_result, consume_socket_token, prune_socket_tokens, validate_csrf_request, requires_csrf_protection, get_csrf_token +from .security import rate_limited, format_tool_result_notice, compact_web_search_result, validate_csrf_request, requires_csrf_protection, get_csrf_token from .monitor import cache_monitor_snapshot, get_cached_monitor_snapshot -from .extensions import socketio from .state import ( MONITOR_FILE_TOOLS, MONITOR_MEMORY_TOOLS, @@ -68,7 +67,6 @@ from .state import ( MONITOR_MEMORY_ENTRY_LIMIT, RATE_LIMIT_BUCKETS, FAILURE_TRACKERS, - pending_socket_tokens, usage_trackers, MONITOR_SNAPSHOT_CACHE, MONITOR_SNAPSHOT_CACHE_LIMIT, @@ -110,7 +108,6 @@ def generate_conversation_title_background(web_terminal: WebTerminal, conversati conversation_id=conversation_id, user_message=user_message, username=username, - socketio_instance=socketio, title_prompt_path=TITLE_PROMPT_PATH, debug_logger=debug_log, title_model=title_model, diff --git a/server/chat_flow_task_main.py b/server/chat_flow_task_main.py index 8ee19c20..b37f61cf 100644 --- a/server/chat_flow_task_main.py +++ b/server/chat_flow_task_main.py @@ -67,10 +67,10 @@ from .utils_common import ( CHUNK_FRONTEND_LOG_FILE, STREAMING_DEBUG_LOG_FILE, ) -from .security import rate_limited, compact_web_search_result, consume_socket_token, prune_socket_tokens, validate_csrf_request, requires_csrf_protection, get_csrf_token +from .security import rate_limited, compact_web_search_result, validate_csrf_request, requires_csrf_protection, get_csrf_token from .main_task_gate import try_acquire_main_task_gate, release_main_task_gate from .monitor import cache_monitor_snapshot, get_cached_monitor_snapshot -from .extensions import emit_event, run_background +from .extensions import run_background from .state import ( MONITOR_FILE_TOOLS, MONITOR_MEMORY_TOOLS, @@ -78,7 +78,6 @@ from .state import ( MONITOR_MEMORY_ENTRY_LIMIT, RATE_LIMIT_BUCKETS, FAILURE_TRACKERS, - pending_socket_tokens, usage_trackers, MONITOR_SNAPSHOT_CACHE, MONITOR_SNAPSHOT_CACHE_LIMIT, @@ -477,7 +476,7 @@ def _persist_and_echo_preceding_notice( message: str, payload: Dict[str, Any], ) -> None: - """预写一条「前置完成通知」:写入对话历史 + socketio 回显(不触发新一轮工作)。 + """预写一条「前置完成通知」:写入对话历史 + 任务事件流回显(不触发新一轮工作)。 用于「通知池」一次取出多条时,前 N-1 条随同一后续任务一起呈现。 这些通知必须落历史,否则模型看不到、刷新后也会丢失。 @@ -503,8 +502,8 @@ def _persist_and_echo_preceding_notice( cm.add_conversation("user", message, metadata=metadata) except Exception as exc: debug_log(f"[CompletionNotice] 前置通知写入历史失败: {exc}") - # socketio 回显(在线客户端即时可见);轮询客户端由后续任务事件流回放, - # 前端按消息内容 dedup,两条通道不会双显。 + # 回显经 sender 通道(任务内=任务事件流;poll 轮询器=no-op); + # 轮询客户端统一由后续任务事件流回放,前端按消息内容 dedup 不会双显。 try: echo_payload = { "message": message, @@ -553,8 +552,8 @@ async def _dispatch_completion_user_notice( if message_source not in _VALID_USER_MESSAGE_SOURCES: message_source = "user" - # 先把前置通知写入历史并 socketio 回显(在线客户端即时可见)。 - # 轮询客户端则通过后续任务事件流回放(见 _run_chat_task 的 preceding_user_notices 注入)。 + # 先把前置通知写入历史并经 sender 回显(Socket.IO 移除后无即时推送, + # 统一通过后续任务事件流回放,见 _run_chat_task 的 preceding_user_notices 注入)。 for item in preceding_notices: _persist_and_echo_preceding_notice( web_terminal=web_terminal, @@ -576,7 +575,7 @@ async def _dispatch_completion_user_notice( **ui_defaults, "timestamp": datetime.now().isoformat(), } - # 轮询客户端通过后续任务事件流回放前置通知(在线客户端已由上面的 socketio 回显覆盖)。 + # 轮询客户端通过后续任务事件流回放前置通知。 preceding_list = None if preceding_notices: preceding_list = [ @@ -965,14 +964,13 @@ async def poll_completion_notifications(*, web_terminal, workspace, conversation 把子智能体完成通知与后台 run_command 完成通知合并到同一条链路: - 每轮把两路所有待通知项一次性取出(池化),按时间排序; - - 这一批里前 N-1 条作为「预写通知」随同一个后续任务一起注入(写历史 + 事件流 + socketio), + - 这一批里前 N-1 条作为「预写通知」随同一个后续任务一起注入(写历史 + 事件流), 不各自触发新一轮工作; - 仅最后 1 条作为后续 chat task 的触发消息,启动一次「工作 → 停止」循环; - 该后续任务结束后回到 handle_task_with_sender 结尾重新 spawn 本轮询器,继续消费剩余通知。 这样多个后台任务同时完成时,会被合并成一次(而非每条都触发一轮停止-再工作)。 """ - from .extensions import emit_event sub_manager = getattr(web_terminal, "sub_agent_manager", None) bg_manager = getattr(web_terminal, "background_command_manager", None) @@ -985,10 +983,9 @@ async def poll_completion_notifications(*, web_terminal, workspace, conversation start_wait = time.time() def sender(event_type, data): - try: - emit_event(event_type, data, room=f"user_{username}") - except Exception: - pass + # WebSocket 推送已移除:事件均经任务事件流(_append_event)由轮询消费; + # 此 sender 仅保留签名以兼容既有调用点,不再做任何实时推送。 + return loop_count = 0 ma_debug("poll_completion_notifications_start", conversation_id=conversation_id) @@ -1115,16 +1112,13 @@ async def poll_multi_agent_notifications(*, web_terminal, workspace, conversatio 与 poll_completion_notifications 完全分离,避免多智能体消息和传统后台 通知竞争 task_manager 的单工作区互斥。 """ - from .extensions import emit_event max_wait_time = 3600 start_wait = time.time() def sender(event_type, data): - try: - emit_event(event_type, data, room=f"user_{username}") - except Exception: - pass + # WebSocket 推送已移除:事件均经任务事件流(_append_event)由轮询消费。 + return import threading as _threading ma_debug( @@ -1258,7 +1252,7 @@ async def _dispatch_multi_agent_idle_messages( ): """主智能体空闲时,把多智能体 pending 消息作为新一轮用户消息分发。 - 所有消息先持久化到对话历史;前 N-1 条作为前置通知通过 socketio 推送; + 所有消息先持久化到对话历史;前 N-1 条作为前置通知写入任务事件流; 最后一条创建 task_type="notice" 任务触发 Team Leader 新一轮工作。 """ ma_debug( @@ -1341,8 +1335,8 @@ async def _dispatch_multi_agent_idle_messages( error=str(exc), ) - # 2) 前置通知的 socketio emit 已在上述注入 inject_multi_agent_master_message 内 - # 同时完成,避免在多智能体消息场景重复发送相同事件。 + # 2) 前置通知的回显已在上述注入 inject_multi_agent_master_message 内 + # 同时完成(经 sender/事件流),避免在多智能体消息场景重复发送相同事件。 # 3) 最后一条触发新一轮工作 ui_defaults = dict(_user_message_ui_defaults(message_source, auto_user_message_event=True)) @@ -2170,12 +2164,6 @@ async def handle_task_with_sender( quota_allowed, quota_info = web_terminal.record_model_call(bool(thinking_expected)) if not quota_allowed: quota_type = 'thinking' if thinking_expected else 'fast' - emit_event('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') diff --git a/server/chat_flow_tool_loop.py b/server/chat_flow_tool_loop.py index e74b0664..f154d926 100644 --- a/server/chat_flow_tool_loop.py +++ b/server/chat_flow_tool_loop.py @@ -533,11 +533,6 @@ async def _handle_submit_plan(*, web_terminal, arguments: Dict[str, Any], sender }) except Exception: pass - try: - from .extensions import emit_event - emit_event('status_update', web_terminal.get_status(), room=f"user_{username}") - except Exception: - pass switch_note = tr("tool_loop.plan_switch_note_ok") except Exception as exc: switch_note = tr("tool_loop.plan_switch_note_failed", error=exc) diff --git a/server/context/__init__.py b/server/context/__init__.py index 5216cb1c..04d4e64d 100644 --- a/server/context/__init__.py +++ b/server/context/__init__.py @@ -12,14 +12,10 @@ _SYMBOL_MODULE = { "RuntimeIdentity": "server.context.identity", "_resolve_user_role": "server.context.identity", # broadcast - "make_terminal_callback": "server.context.broadcast", - "attach_user_broadcast": "server.context.broadcast", - "_wrap_callback_with_conversation_id": "server.context.broadcast", # personalization "_apply_workspace_personalization_preferences": "server.context.personalization", # usage "get_or_create_usage_tracker": "server.context.usage", - "emit_user_quota_update": "server.context.usage", # upload "get_gui_manager": "server.context.upload", "get_upload_guard": "server.context.upload", @@ -55,9 +51,6 @@ __all__ = [ "apply_conversation_overrides", "reset_system_state", "get_or_create_usage_tracker", - "emit_user_quota_update", - "make_terminal_callback", - "attach_user_broadcast", "reap_idle_conversation_terminals", "start_conversation_terminal_reaper", ] diff --git a/server/context/broadcast.py b/server/context/broadcast.py deleted file mode 100644 index a549c549..00000000 --- a/server/context/broadcast.py +++ /dev/null @@ -1,47 +0,0 @@ -"""终端事件广播与回调包装(用户房间广播 + conversation_id 注入)。""" -from __future__ import annotations - -from typing import Optional - -from core.web_terminal import WebTerminal -from server.utils_common import debug_log - - -def make_terminal_callback(username: str): - """生成面向指定用户的广播函数(独立 Gateway 进程下 socket 未初始化时静默跳过)""" - from server.extensions import emit_event - def _callback(event_type, data): - emit_event(event_type, data, room=f"user_{username}") - return _callback - - -def attach_user_broadcast(terminal: WebTerminal, username: str): - """确保终端的广播函数指向当前用户的房间。 - - 对话级 terminal 的回调会额外包装注入 conversation_id(见 _wrap_callback_with_conversation_id)。 - """ - callback = make_terminal_callback(username) - callback = _wrap_callback_with_conversation_id( - callback, getattr(terminal, "_bound_conversation_id", None) - ) - terminal.message_callback = callback - if terminal.terminal_manager: - terminal.terminal_manager.broadcast = callback - - -def _wrap_callback_with_conversation_id(callback, conversation_id: Optional[str]): - """包装广播回调,为 dict 类型的事件数据注入 conversation_id(setdefault,不覆盖已有值)。 - - 对话级 terminal 的广播(shell 输出、terminal 列表等)仍发到用户房间, - 前端按 conversation_id 过滤,避免同工作区多个对话的终端事件互相串扰。 - """ - if not callback or not conversation_id: - return callback - - def _wrapped(event_type, data): - if isinstance(data, dict): - data = dict(data) - data.setdefault("conversation_id", conversation_id) - return callback(event_type, data) - - return _wrapped diff --git a/server/context/reaper.py b/server/context/reaper.py index 1d8f5056..7faf119b 100644 --- a/server/context/reaper.py +++ b/server/context/reaper.py @@ -157,5 +157,4 @@ def start_conversation_terminal_reaper(): if _conversation_terminal_reaper_started: return _conversation_terminal_reaper_started = True - from server.extensions import socketio - socketio.start_background_task(_conversation_terminal_reaper_loop) + threading.Thread(target=_conversation_terminal_reaper_loop, daemon=True).start() diff --git a/server/context/resources.py b/server/context/resources.py index 36aa3d2a..151f5ad8 100644 --- a/server/context/resources.py +++ b/server/context/resources.py @@ -55,9 +55,8 @@ def _get_current_user_role(record=None) -> str: return get_current_user_role(record) from server.context.identity import NoWorkspaceError, RuntimeIdentity, _resolve_user_role -from server.context.broadcast import make_terminal_callback, attach_user_broadcast from server.context.personalization import _apply_workspace_personalization_preferences -from server.context.usage import get_or_create_usage_tracker, emit_user_quota_update +from server.context.usage import get_or_create_usage_tracker @@ -316,14 +315,12 @@ def get_user_resources( project_path=str(project_path), thinking_mode=thinking_mode, run_mode=run_mode, - message_callback=make_terminal_callback("host"), + message_callback=None, data_dir=str(data_dir), container_session=container_handle, usage_tracker=usage_tracker, conversation_id=conversation_id, ) - if terminal.terminal_manager: - terminal.terminal_manager.broadcast = terminal.message_callback state.user_terminals[term_key] = terminal terminal.username = "host" terminal.user_role = "admin" @@ -335,7 +332,6 @@ def get_user_resources( session_set('host_workspace_id', getattr(workspace, "workspace_id", None)) else: terminal.update_container_session(container_handle) - attach_user_broadcast(terminal, "host") terminal.username = "host" terminal.user_role = "admin" if can_write_session: @@ -476,12 +472,10 @@ def get_user_resources( usage_tracker=usage_tracker, conversation_id=conversation_id, ) - if terminal.terminal_manager: - terminal.terminal_manager.broadcast = terminal.message_callback state.user_terminals[term_key] = terminal terminal.username = username terminal.user_role = "api" if is_api_user else _resolve_user_role(identity, record) - terminal.quota_update_callback = (lambda metric=None: emit_user_quota_update(username)) if not is_api_user else None + terminal.quota_update_callback = None if can_write_session: session_set('run_mode', terminal.run_mode) session_set('thinking_mode', terminal.thinking_mode) @@ -489,10 +483,9 @@ def get_user_resources( session_set('workspace_id', getattr(workspace, "workspace_id", None)) else: terminal.update_container_session(container_handle) - attach_user_broadcast(terminal, username) terminal.username = username terminal.user_role = "api" if is_api_user else _resolve_user_role(identity, record) - terminal.quota_update_callback = (lambda metric=None: emit_user_quota_update(username)) if not is_api_user else None + terminal.quota_update_callback = None if can_write_session: session_set('workspace_id', getattr(workspace, "workspace_id", None)) diff --git a/server/context/usage.py b/server/context/usage.py index 9c6e1c04..6e87b47f 100644 --- a/server/context/usage.py +++ b/server/context/usage.py @@ -1,4 +1,4 @@ -"""配额追踪器(UsageTracker)的获取与广播。""" +"""配额追踪器(UsageTracker)的获取。""" from __future__ import annotations from typing import Optional, TYPE_CHECKING @@ -24,17 +24,3 @@ def get_or_create_usage_tracker(username: Optional[str], workspace: Optional['mo tracker = UsageTracker(str(workspace.data_dir), role=role or "user") state.usage_trackers[username] = tracker return tracker - - -def emit_user_quota_update(username: Optional[str]): - from server.extensions import emit_event - if not username: - return - tracker = get_or_create_usage_tracker(username) - if not tracker: - return - try: - snapshot = tracker.get_quota_snapshot() - emit_event('quota_update', {'quotas': snapshot}, room=f"user_{username}") - except Exception: - pass diff --git a/server/conversation.py b/server/conversation.py index bc74bf17..730a53b0 100644 --- a/server/conversation.py +++ b/server/conversation.py @@ -26,7 +26,6 @@ from io import BytesIO from typing import Dict, Any, Optional, List, Tuple from flask import Blueprint, request, jsonify, session, send_file -from flask_socketio import emit from werkzeug.utils import secure_filename import zipfile @@ -72,7 +71,7 @@ from utils.conversation_manager import ConversationManager from utils.api_client import APIClient from .auth_helpers import api_login_required, resolve_admin_policy, get_current_user_record, get_current_username -from .context import with_terminal, get_terminal_for_sid, get_gui_manager, get_upload_guard, build_upload_error_response, ensure_conversation_loaded, reset_system_state, get_user_resources, get_or_create_usage_tracker +from .context import with_terminal, get_gui_manager, get_upload_guard, build_upload_error_response, ensure_conversation_loaded, reset_system_state, get_user_resources, get_or_create_usage_tracker from .utils_common import ( build_review_lines, debug_log, @@ -86,7 +85,6 @@ from .utils_common import ( CHUNK_FRONTEND_LOG_FILE, STREAMING_DEBUG_LOG_FILE, ) -from .extensions import socketio from .state import ( RECENT_UPLOAD_EVENT_LIMIT, RECENT_UPLOAD_FEED_LIMIT, @@ -807,18 +805,7 @@ def create_conversation(terminal: WebTerminal, workspace: UserWorkspace, usernam except Exception as exc: debug_log(f"[Versioning] create_conversation apply default failed: {exc}") perf_log("create_conversation after default versioning", elapsed_ms=(time.perf_counter() - t0) * 1000, extra={"conv_id": result.get("conversation_id")}) - # 广播对话列表更新事件 - socketio.emit('conversation_list_update', { - 'action': 'created', - 'conversation_id': result["conversation_id"] - }, room=f"user_{username}") - - if not result.get("safe_navigation"): - # 安全导航创建不代表后端 terminal 当前对话已切换,因此不广播 conversation_changed。 - socketio.emit('conversation_changed', { - 'conversation_id': result["conversation_id"], - 'title': tr("conversation.default_title") - }, room=f"user_{username}") + # 对话列表/切换推送已随 WebSocket 移除:发起方依据响应刷新,其他标签页由轮询同步。 perf_log("create_conversation route done", elapsed_ms=(time.perf_counter() - t0) * 1000, extra={"conv_id": result.get("conversation_id")}) return jsonify(result), 201 @@ -941,22 +928,9 @@ def load_conversation(conversation_id, terminal: WebTerminal, workspace: UserWor } if not result.get("safe_navigation"): - # 安全导航只改变前端查看对象,不代表后端 terminal 当前上下文已切换。 - socketio.emit('conversation_changed', { - 'conversation_id': conversation_id, - 'title': result.get("title", tr("conversation.unknown_title")), - 'messages_count': result.get("messages_count", 0) - }, room=f"user_{username}") - - # 广播系统状态更新(因为当前对话改变了) - status = terminal.get_status() - socketio.emit('status_update', status, room=f"user_{username}") - - # 清理和重置相关UI状态 - socketio.emit('conversation_loaded', { - 'conversation_id': conversation_id, - 'clear_ui': True # 提示前端清理当前UI状态 - }, room=f"user_{username}") + # 安全导航只改变前端查看对象;对话切换/加载推送已随 WebSocket 移除, + # 发起方依据响应刷新,其他标签页由轮询同步。 + pass return jsonify(result) else: @@ -1007,23 +981,9 @@ def delete_conversation(conversation_id, terminal: WebTerminal, workspace: UserW result = terminal.delete_conversation(conversation_id) if result["success"]: - # 广播对话列表更新事件 - socketio.emit('conversation_list_update', { - 'action': 'deleted', - 'conversation_id': conversation_id - }, room=f"user_{username}") - - # 如果删除的是当前对话,广播对话清空事件 - if is_current: - socketio.emit('conversation_changed', { - 'conversation_id': None, - 'title': None, - 'cleared': True - }, room=f"user_{username}") - - # 更新系统状态 - status = terminal.get_status() - socketio.emit('status_update', status, room=f"user_{username}") + # 对话删除/清空推送已随 WebSocket 移除:发起方依据响应刷新, + # 其他标签页由对话列表轮询同步。is_current 变量保留供下方响应使用。 + pass return jsonify(result) else: @@ -1705,14 +1665,7 @@ def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTer session['run_mode'] = terminal.run_mode session['thinking_mode'] = terminal.thinking_mode - socketio.emit('conversation_list_update', { - 'action': 'version_restored', - 'conversation_id': target_conversation_id - }, room=f"user_{username}") - socketio.emit('conversation_changed', { - 'conversation_id': target_conversation_id, - 'title': (cm.load_conversation(target_conversation_id) or {}).get("title", tr("conversation.version_restored_title")), - }, room=f"user_{username}") + # 版本恢复后的列表/切换推送已随 WebSocket 移除:发起方依据响应刷新。 return jsonify({ "success": True, @@ -1766,17 +1719,9 @@ def compress_conversation(conversation_id, terminal: WebTerminal, workspace: Use status_code = 404 if _is_not_found_message(result.get("error", "")) else (409 if result.get("in_progress") else 400) return jsonify(result), status_code - # in-place 压缩:对话 id 不变。通知前端当前对话内容已变化(历史前缀被标记),刷新展示。 + # in-place 压缩:对话 id 不变。对话内容变化推送已随 WebSocket 移除, + # 发起方依据响应(guide_inserted 等字段)刷新历史。 load_result = terminal.load_conversation(normalized_id) - if load_result.get("success"): - socketio.emit('conversation_list_update', { - 'action': 'compressed', - 'conversation_id': normalized_id - }, room=f"user_{username}") - socketio.emit('conversation_loaded', { - 'conversation_id': normalized_id, - 'clear_ui': True - }, room=f"user_{username}") response_payload = { "success": True, @@ -1802,29 +1747,7 @@ def compress_conversation(conversation_id, terminal: WebTerminal, workspace: Use ) response_payload["auto_task_started"] = False response_payload["guide_inserted"] = True - # 通知前端实时显示这条 compact 消息(覆盖 socket 与 in-place 未刷新场景) - try: - emit( - "user_message", - { - "message": guide_message, - "images": [], - "videos": [], - "media_refs": [], - "message_source": "compression_handoff", - "visibility": "compact", - "starts_work": False, - "metadata": { - "message_source": "compression_handoff", - "visibility": "compact", - "starts_work": False, - }, - "conversation_id": normalized_id, - }, - room=f"user_{username}", - ) - except Exception as emit_exc: - debug_log(f"[Compression] 发送 user_message 事件失败: {emit_exc}") + # 引导语已写入历史,前端依据响应 guide_inserted 刷新历史即可看到。 except Exception as exc: debug_log(f"[Compression] 追加引导语消息失败: {exc}") response_payload["auto_task_started"] = False @@ -2267,21 +2190,6 @@ def duplicate_conversation(conversation_id, terminal: WebTerminal, workspace: Us new_conversation_id = result["duplicate_conversation_id"] load_result = terminal.load_conversation(new_conversation_id) - if load_result.get("success"): - socketio.emit('conversation_list_update', { - 'action': 'duplicated', - 'conversation_id': new_conversation_id - }, room=f"user_{username}") - socketio.emit('conversation_changed', { - 'conversation_id': new_conversation_id, - 'title': load_result.get('title', tr('conversation.duplicated_title')), - 'messages_count': load_result.get('messages_count', 0) - }, room=f"user_{username}") - socketio.emit('conversation_loaded', { - 'conversation_id': new_conversation_id, - 'clear_ui': True - }, room=f"user_{username}") - response_payload = { "success": True, "duplicate_conversation_id": new_conversation_id, @@ -2466,23 +2374,8 @@ def get_current_conversation(terminal: WebTerminal, workspace: UserWorkspace, us "error": str(e) }), 500 -@socketio.on('send_command') -def handle_command(data): - """处理系统命令""" - command = data.get('command', '') - - username, terminal, _ = get_terminal_for_sid(request.sid) - if not terminal: - emit('error', {'message': 'System not initialized'}) - return - record_user_activity(username) - - result = _execute_system_command(terminal, command) - emit('command_result', result) - - def _execute_system_command(terminal: WebTerminal, command: str) -> Dict[str, Any]: - """执行系统命令,供 WebSocket 与 REST API 复用。""" + """执行系统命令(REST API 专用;原 WebSocket send_command 通道已移除)。""" command = (command or '').strip() if command.startswith('/'): command = command[1:] diff --git a/server/extensions.py b/server/extensions.py index 7f07849b..9c9ed4cb 100644 --- a/server/extensions.py +++ b/server/extensions.py @@ -1,35 +1,16 @@ -"""Flask/SocketIO 扩展实例。""" +"""后台任务辅助(WebSocket 移除后仅剩后台线程启动器)。""" import threading -from flask_socketio import SocketIO - -# 统一的 SocketIO 实例,使用线程模式以兼容现有逻辑 -socketio = SocketIO(cors_allowed_origins="*", async_mode='threading', logger=False, engineio_logger=False) - - -def emit_event(event, data, room=None, **kwargs): - """Web 模式下经 socketio 实时推送;独立 Gateway 进程(socketio 未绑定 app)静默跳过。 - - 事件的权威记录是任务事件流(TaskRecord.events,_append_event 落盘在前); - socket 推送只是在线客户端的实时增量通道——独立进程没有 socket 客户端, - 跳过不丢数据。Web 模式下推送失败也静默(对齐原各调用点的 try/except 语义)。 - """ - if socketio.server is None: - return - try: - socketio.emit(event, data, room=room, **kwargs) - except Exception: - pass - def run_background(fn, *args, **kwargs): - """启动后台任务:Web 模式走 socketio.start_background_task(兼容其线程模型); - 独立 Gateway 进程(socketio 未初始化)降级为 daemon 线程。""" - if socketio.server is not None: - return socketio.start_background_task(fn, *args, **kwargs) + """以 daemon 线程启动后台任务。 + + 历史上该函数会在 Web 模式优先走 socketio.start_background_task; + Socket.IO 移除后统一为 daemon 线程(与原降级路径行为一致)。 + """ t = threading.Thread(target=fn, args=args, kwargs=kwargs, daemon=True) t.start() return t -__all__ = ["socketio", "emit_event", "run_background"] +__all__ = ["run_background"] diff --git a/server/main_task_gate.py b/server/main_task_gate.py index fb8cfe54..81193ae0 100644 --- a/server/main_task_gate.py +++ b/server/main_task_gate.py @@ -1,6 +1,6 @@ """对话级主任务门闸(单写者防护)。 -背景(2026-08-12「平行时空」事故):socketio 入口的主聊天任务不在 +背景(2026-08-12「平行时空」事故):socketio 入口(已移除)的主聊天任务不在 task_manager 注册,`create_chat_task` 的单对话互斥对它们不可见;完成通知 轮询器又只凭 `_tool_loop_active`(仅覆盖工具执行窗口)判断对话是否空闲, 于是在主任务两次工具循环的间隙里派发了通知任务——两个主任务并发交叉写入 diff --git a/server/multi_agent.py b/server/multi_agent.py index 49e0cfa6..7c5ae794 100644 --- a/server/multi_agent.py +++ b/server/multi_agent.py @@ -428,15 +428,8 @@ def create_multi_agent_conversation(): except Exception: pass - # 触发对话列表更新事件 - try: - from server.app_legacy import socketio - socketio.emit('conversation_list_update', { - 'action': 'created', - 'conversation_id': conversation_id, - }, room=f"user_{username}") - except Exception: - pass + # 对话列表更新不再经 WebSocket 广播:发起方依据本响应刷新列表, + # 其他标签页由对话列表轮询同步。 return jsonify({ "success": True, diff --git a/server/security.py b/server/security.py index 3da4c18d..ef77c417 100644 --- a/server/security.py +++ b/server/security.py @@ -188,31 +188,6 @@ def validate_csrf_request() -> bool: return False -def prune_socket_tokens(now: Optional[float] = None): - current = now or time.time() - for token, meta in list(state.pending_socket_tokens.items()): - if meta.get("expires_at", 0) <= current: - state.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 = state.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}" @@ -301,8 +276,6 @@ __all__ = [ "get_csrf_token", "requires_csrf_protection", "validate_csrf_request", - "prune_socket_tokens", - "consume_socket_token", "format_tool_result_notice", "compact_web_search_result", "attach_security_hooks", @@ -318,8 +291,6 @@ __all__ = [ "get_csrf_token", "requires_csrf_protection", "validate_csrf_request", - "prune_socket_tokens", - "consume_socket_token", "format_tool_result_notice", "compact_web_search_result", ] diff --git a/server/socket_handlers.py b/server/socket_handlers.py deleted file mode 100644 index 214752a3..00000000 --- a/server/socket_handlers.py +++ /dev/null @@ -1,406 +0,0 @@ -from __future__ import annotations -import asyncio, time, json, re -from typing import Dict, Any -from flask import request -from flask_socketio import emit, join_room, leave_room, disconnect - -from .extensions import socketio -from modules.i18n import tr -from .auth_helpers import get_current_username, resolve_admin_policy -from .context import ( - get_terminal_for_sid, - ensure_conversation_loaded, - reset_system_state, - get_user_resources, -) -from .utils_common import debug_log, log_frontend_chunk, log_streaming_debug_entry -from .state import connection_users, stop_flags, terminal_rooms, pending_socket_tokens, user_manager, get_stop_flag, set_stop_flag, clear_stop_flag -from .usage import record_user_activity -from .chat_flow import start_chat_task -from .security import consume_socket_token, prune_socket_tokens -from config import OUTPUT_FORMATS, AGENT_VERSION -from config.model_profiles import model_supports_image, model_supports_video - -@socketio.on('connect') -def handle_connect(auth): - """客户端连接""" - debug_log(f"[WebSocket] 客户端连接: {request.sid}") - username = get_current_username() - 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': tr("socket.not_logged_in")}) - disconnect() - return - - emit('connected', {'status': 'Connected to server'}) - connection_users[request.sid] = username - - # 清理可能存在的停止标志和状态 - stop_flags.pop(request.sid, None) - # 将旧的 username 级别任务映射到新的 sid,便于重新停止 - user_entry = get_stop_flag(None, username) - if user_entry: - set_stop_flag(request.sid, username, user_entry) - - join_room(f"user_{username}") - join_room(f"user_{username}_terminal") - if request.sid not in terminal_rooms: - terminal_rooms[request.sid] = set() - terminal_rooms[request.sid].update({f"user_{username}", f"user_{username}_terminal"}) - - terminal, workspace = get_user_resources(username) - if terminal: - reset_system_state(terminal) - emit('system_ready', { - 'project_path': str(workspace.project_path), - 'thinking_mode': bool(getattr(terminal, "thinking_mode", False)), - 'version': AGENT_VERSION - }, room=request.sid) - - if terminal.terminal_manager: - terminals = terminal.terminal_manager.get_terminal_list() - emit('terminal_list_update', { - 'terminals': terminals, - 'active': terminal.terminal_manager.active_terminal - }, room=request.sid) - - if terminal.terminal_manager.active_terminal: - for name, term in terminal.terminal_manager.terminals.items(): - emit('terminal_started', { - 'session': name, - 'working_dir': str(term.working_dir), - 'shell': term.shell_command, - 'time': term.start_time.isoformat() if term.start_time else None - }, room=request.sid) - -@socketio.on('disconnect') -def handle_disconnect(): - """客户端断开""" - debug_log(f"[WebSocket] 客户端断开: {request.sid}") - username = connection_users.pop(request.sid, None) - # 若同一用户仍有其他活跃连接,不因断开而停止任务 - has_other_connection = False - if username: - for sid, user in connection_users.items(): - if user == username: - has_other_connection = True - break - - # 检查是否有通过 REST API 创建的运行中任务 - # 如果有,说明使用轮询模式,不应该停止任务 - has_rest_api_task = False - if username and not has_other_connection: - try: - from .tasks import task_manager - running_tasks = [t for t in task_manager.list_tasks(username) if t.status == "running"] - if running_tasks: - has_rest_api_task = True - debug_log(f"[WebSocket] 用户 {username} 有运行中的 REST API 任务,不停止") - except Exception as e: - debug_log(f"[WebSocket] 检查 REST API 任务失败: {e}") - - task_info = get_stop_flag(request.sid, username) - # 只有在没有其他连接且没有 REST API 任务时才停止 - if isinstance(task_info, dict) and not has_other_connection and not has_rest_api_task: - task_info['stop'] = True - pending_task = task_info.get('task') - if pending_task and not pending_task.done(): - debug_log(f"disconnect: cancel task for {request.sid}") - pending_task.cancel() - terminal = task_info.get('terminal') - if terminal: - reset_system_state(terminal) - - # 清理停止标志(只清理 sid 级别的,不清理 user 级别的) - if request.sid in stop_flags: - stop_flags.pop(request.sid, None) - - # 从所有房间移除 - for room in list(terminal_rooms.get(request.sid, [])): - leave_room(room) - if request.sid in terminal_rooms: - del terminal_rooms[request.sid] - - if username: - leave_room(f"user_{username}") - leave_room(f"user_{username}_terminal") - -@socketio.on('stop_task') -def handle_stop_task(): - """处理停止任务请求""" - debug_log(f"[停止] 收到停止请求: {request.sid}") - username = connection_users.get(request.sid) - task_info = get_stop_flag(request.sid, username) - if not isinstance(task_info, dict): - task_info = {'stop': False, 'task': None, 'terminal': None} - # 标记停止标志,让任务内部检测并优雅停止 - task_info['stop'] = True - # 注释掉直接取消任务,改为通过停止标志让任务内部处理 - # pending_task = task_info.get('task') - # if pending_task and not pending_task.done(): - # debug_log(f"正在取消任务: {request.sid}") - # pending_task.cancel() - debug_log(f"设置停止标志: {request.sid}") - if task_info.get('terminal'): - reset_system_state(task_info['terminal']) - set_stop_flag(request.sid, username, task_info) - - emit('stop_requested', { - 'message': tr("socket.stop_received") - }) - -@socketio.on('terminal_subscribe') -def handle_terminal_subscribe(data): - """订阅终端事件""" - session_name = data.get('session') - subscribe_all = data.get('all', False) - - conv_id = (data.get('conversation_id') or '').strip() or None - username, terminal, _ = get_terminal_for_sid(request.sid, conversation_id=conv_id) - if not username or not terminal or not terminal.terminal_manager: - emit('error', {'message': 'Terminal system not initialized'}) - return - policy = resolve_admin_policy(user_manager.get_user(username)) - if policy.get("ui_blocks", {}).get("block_realtime_terminal"): - emit('error', {'message': tr("socket.terminal_disabled")}) - return - - if request.sid not in terminal_rooms: - terminal_rooms[request.sid] = set() - - if subscribe_all: - # 订阅所有终端事件 - room_name = f"user_{username}_terminal" - join_room(room_name) - terminal_rooms[request.sid].add(room_name) - debug_log(f"[Terminal] {request.sid} 订阅所有终端事件") - - # 发送当前终端状态 - emit('terminal_subscribed', { - 'type': 'all', - 'terminals': terminal.terminal_manager.get_terminal_list() - }) - elif session_name: - # 订阅特定终端会话 - room_name = f'user_{username}_terminal_{session_name}' - join_room(room_name) - terminal_rooms[request.sid].add(room_name) - debug_log(f"[Terminal] {request.sid} 订阅终端: {session_name}") - - # 发送该终端的当前输出 - output_result = terminal.terminal_manager.get_terminal_output(session_name, 100) - if output_result['success']: - emit('terminal_history', { - 'session': session_name, - 'output': output_result['output'] - }) - -@socketio.on('terminal_unsubscribe') -def handle_terminal_unsubscribe(data): - """取消订阅终端事件""" - session_name = data.get('session') - username = connection_users.get(request.sid) - - if session_name: - room_name = f'user_{username}_terminal_{session_name}' if username else f'terminal_{session_name}' - leave_room(room_name) - if request.sid in terminal_rooms: - terminal_rooms[request.sid].discard(room_name) - debug_log(f"[Terminal] {request.sid} 取消订阅终端: {session_name}") - -@socketio.on('get_terminal_output') -def handle_get_terminal_output(data): - """获取终端输出历史""" - session_name = data.get('session') - lines = data.get('lines', 50) - - conv_id = (data.get('conversation_id') or '').strip() or None - username, terminal, _ = get_terminal_for_sid(request.sid, conversation_id=conv_id) - if not terminal or not terminal.terminal_manager: - emit('error', {'message': 'Terminal system not initialized'}) - return - policy = resolve_admin_policy(user_manager.get_user(username)) - if policy.get("ui_blocks", {}).get("block_realtime_terminal"): - emit('error', {'message': tr("socket.terminal_disabled")}) - return - - result = terminal.terminal_manager.get_terminal_output(session_name, lines) - - if result['success']: - emit('terminal_output_history', { - 'session': session_name, - 'output': result['output'], - 'is_interactive': result.get('is_interactive', False), - 'last_command': result.get('last_command', ''), - 'last_event_time': result.get('last_event_time') - }) - else: - emit('error', {'message': result['error']}) - -@socketio.on('send_message') -def handle_message(data): - """ - 【已废弃】WebSocket 聊天入口,已由 REST API (/api/tasks) 替代 - - 保留此处理器用于向后兼容,但建议前端使用 REST API 轮询模式。 - 新的架构优势: - - 支持分布式部署(任务存储可扩展为 Redis) - - 更好的错误恢复(页面刷新后可恢复任务状态) - - 减少 WebSocket 连接压力 - """ - username, terminal, workspace = get_terminal_for_sid(request.sid) - if not terminal: - emit('error', {'message': 'System not initialized'}) - return - - # 返回废弃提示 - emit('error', { - 'message': tr("socket.ws_chat_deprecated"), - 'code': 'DEPRECATED', - 'migration_guide': tr("socket.ws_migration_guide") - }) - return - - # 以下代码保留用于紧急回退 - # ======================================== - message = (data.get('message') or '').strip() - images = data.get('images') or [] - videos = data.get('videos') or [] - if not message and not images and not videos: - emit('error', {'message': tr("socket.empty_message")}) - return - current_model = getattr(terminal, "model_key", None) or "" - if images and not model_supports_image(current_model): - emit('error', {'message': tr("socket.image_not_supported")}) - return - if videos and not model_supports_video(current_model): - emit('error', {'message': tr("socket.video_not_supported")}) - return - if images and videos: - emit('error', {'message': tr("socket.image_video_separate")}) - return - - debug_log(f"[WebSocket] 收到消息: {message}") - debug_log(f"\n{'='*80}\n新任务开始: {message}\n{'='*80}") - record_user_activity(username) - - requested_conversation_id = data.get('conversation_id') - try: - conversation_id, created_new = ensure_conversation_loaded(terminal, requested_conversation_id) - except RuntimeError as exc: - emit('error', {'message': str(exc)}) - return - try: - conv_data = terminal.context_manager._get_conversation_manager_for_id(conversation_id).load_conversation(conversation_id) or {} - except Exception: - conv_data = {} - title = conv_data.get('title', tr("conversation.default_title")) - - socketio.emit('conversation_resolved', { - 'conversation_id': conversation_id, - 'title': title, - 'created': created_new - }, room=request.sid) - - if created_new: - socketio.emit('conversation_list_update', { - 'action': 'created', - 'conversation_id': conversation_id - }, room=f"user_{username}") - socketio.emit('conversation_changed', { - 'conversation_id': conversation_id, - 'title': title - }, room=request.sid) - - client_sid = request.sid - - def send_to_client(event_type, data): - """发送消息到客户端""" - socketio.emit(event_type, data, room=client_sid) - - # 模型活动事件:用于刷新"在线"心跳(回复/工具调用都算活动) - activity_events = { - 'ai_message_start', 'thinking_start', 'thinking_chunk', 'thinking_end', - 'text_start', 'text_chunk', 'text_end', - 'tool_preparing', 'tool_start', 'update_action', - 'append_payload', 'modify_payload', 'system_message', - 'task_complete' - } - last_model_activity = 0.0 - - def send_with_activity(event_type, data): - """模型产生输出或调用工具时刷新活跃时间,防止长回复被误判下线。""" - nonlocal last_model_activity - if event_type in activity_events: - now = time.time() - # 轻量节流:1 秒内多次事件只记一次 - if now - last_model_activity >= 1.0: - record_user_activity(username) - last_model_activity = now - send_to_client(event_type, data) - - # 传递客户端ID - images = data.get('images') or [] - videos = data.get('videos') or [] - start_chat_task(terminal, message, images, send_with_activity, client_sid, workspace, username, videos) - - -# WS 客户端日志事件的轻量限流(防已认证用户洪泛写盘;username -> 时间戳滑窗) -_WS_CLIENT_LOG_LIMIT = 30 # 每 60 秒最多 30 条 -_WS_CLIENT_LOG_WINDOW = 60.0 -_ws_client_log_buckets: Dict[str, list] = {} -_WS_CLIENT_LOG_MAX_KEYS = 10000 - - -def _ws_client_log_allowed(username: str) -> bool: - now = time.time() - if len(_ws_client_log_buckets) > _WS_CLIENT_LOG_MAX_KEYS: - # 全局回收:清空过老桶(简单策略,防止桶表无限膨胀) - for key in [k for k, v in _ws_client_log_buckets.items() if not v or now - v[-1] > _WS_CLIENT_LOG_WINDOW]: - _ws_client_log_buckets.pop(key, None) - if len(_ws_client_log_buckets) > _WS_CLIENT_LOG_MAX_KEYS: - _ws_client_log_buckets.clear() - bucket = _ws_client_log_buckets.setdefault(username, []) - while bucket and now - bucket[0] > _WS_CLIENT_LOG_WINDOW: - bucket.pop(0) - if len(bucket) >= _WS_CLIENT_LOG_LIMIT: - return False - bucket.append(now) - return True - - -@socketio.on('client_chunk_log') -def handle_client_chunk_log(data): - """前端chunk日志上报""" - username = connection_users.get(request.sid) - if not username or not _ws_client_log_allowed(username): - return - conversation_id = data.get('conversation_id') - chunk_index = int(data.get('index') or data.get('chunk_index') or 0) - elapsed = float(data.get('elapsed') or 0.0) - length = int(data.get('length') or len(data.get('content') or "")) - client_ts = float(data.get('ts') or 0.0) - log_frontend_chunk(conversation_id, chunk_index, elapsed, length, client_ts) - - -@socketio.on('client_stream_debug_log') -def handle_client_stream_debug_log(data): - """前端流式调试日志""" - username = connection_users.get(request.sid) - if not username or not _ws_client_log_allowed(username): - return - if not isinstance(data, dict): - return - entry = dict(data) - entry.setdefault('server_ts', time.time()) - log_streaming_debug_entry(entry) - -# 在 web_server.py 中添加以下对话管理API接口 -# 添加在现有路由之后,@socketio 事件处理之前 - -# ========================================== -# 对话管理API接口 -# ========================================== - - -# conversation routes moved to server/conversation.py diff --git a/server/state.py b/server/state.py index 535ebd4a..5b2031ef 100644 --- a/server/state.py +++ b/server/state.py @@ -48,7 +48,6 @@ MONITOR_SNAPSHOT_CHAR_LIMIT = 60000 MONITOR_MEMORY_ENTRY_LIMIT = 256 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] = {} active_login_nonces: Dict[str, set] = defaultdict(set) @@ -71,7 +70,6 @@ CSRF_PROTECTED_PREFIXES = ("/api/",) CSRF_EXEMPT_PATHS = {"/api/csrf-token"} FAILED_LOGIN_LIMIT = 5 FAILED_LOGIN_LOCK_SECONDS = 300 -SOCKET_TOKEN_TTL_SECONDS = 45 USER_IDLE_TIMEOUT_SECONDS = int(os.environ.get("USER_IDLE_TIMEOUT_SECONDS", "900")) LAST_ACTIVE_FILE = Path(LOGS_DIR).expanduser().resolve() / "last_active.json" _last_active_lock = threading.Lock() @@ -104,7 +102,6 @@ __all__ = [ "MONITOR_MEMORY_ENTRY_LIMIT", "RATE_LIMIT_BUCKETS", "FAILURE_TRACKERS", - "pending_socket_tokens", "usage_trackers", "active_login_nonces", "tool_approval_manager", @@ -123,7 +120,6 @@ __all__ = [ "CSRF_EXEMPT_PATHS", "FAILED_LOGIN_LIMIT", "FAILED_LOGIN_LOCK_SECONDS", - "SOCKET_TOKEN_TTL_SECONDS", "USER_IDLE_TIMEOUT_SECONDS", "LAST_ACTIVE_FILE", "_last_active_lock", diff --git a/server/status/app.py b/server/status/app.py index 34392735..b6d0f7e7 100644 --- a/server/status/app.py +++ b/server/status/app.py @@ -18,7 +18,7 @@ from pathlib import Path from flask import Blueprint, jsonify, request, send_file, session from server.auth_helpers import api_login_required, resolve_admin_policy -from server.context import with_terminal, attach_user_broadcast +from server.context import with_terminal from server.state import ( PROJECT_STORAGE_CACHE, PROJECT_STORAGE_CACHE_TTL_SECONDS, diff --git a/server/status/base.py b/server/status/base.py index b91edcd1..f8fe6901 100644 --- a/server/status/base.py +++ b/server/status/base.py @@ -13,7 +13,7 @@ from pathlib import Path from flask import Blueprint, jsonify, request, send_file, session from server.auth_helpers import api_login_required, resolve_admin_policy -from server.context import with_terminal, attach_user_broadcast +from server.context import with_terminal from server.state import ( PROJECT_STORAGE_CACHE, PROJECT_STORAGE_CACHE_TTL_SECONDS, diff --git a/server/status/docker.py b/server/status/docker.py index c05a88a4..3c9fd3dc 100644 --- a/server/status/docker.py +++ b/server/status/docker.py @@ -18,7 +18,7 @@ from pathlib import Path from flask import Blueprint, jsonify, request, send_file, session from server.auth_helpers import api_login_required, resolve_admin_policy -from server.context import with_terminal, attach_user_broadcast +from server.context import with_terminal from server.state import ( PROJECT_STORAGE_CACHE, PROJECT_STORAGE_CACHE_TTL_SECONDS, @@ -141,12 +141,6 @@ def select_docker_project(): except RuntimeError as exc: session["workspace_id"] = previous_workspace_id return jsonify({"success": False, "error": str(exc)}), 503 - terminal = state.user_terminals.get(f"{username}::{session['workspace_id']}") - if terminal: - try: - attach_user_broadcast(terminal, username) - except Exception: - pass default_workspace_id = user_manager._get_user_default_workspace_id(username) return jsonify({ "success": True, diff --git a/server/status/file_open.py b/server/status/file_open.py index ed434daa..3f270ab8 100644 --- a/server/status/file_open.py +++ b/server/status/file_open.py @@ -15,7 +15,7 @@ from pathlib import Path from flask import Blueprint, jsonify, request, send_file, session from server.auth_helpers import api_login_required, resolve_admin_policy -from server.context import with_terminal, attach_user_broadcast +from server.context import with_terminal from server.state import ( PROJECT_STORAGE_CACHE, PROJECT_STORAGE_CACHE_TTL_SECONDS, diff --git a/server/status/git.py b/server/status/git.py index 58730c66..a20302ff 100644 --- a/server/status/git.py +++ b/server/status/git.py @@ -13,7 +13,7 @@ from pathlib import Path from flask import Blueprint, jsonify, request, send_file, session from server.auth_helpers import api_login_required, resolve_admin_policy -from server.context import with_terminal, attach_user_broadcast +from server.context import with_terminal from server.state import ( PROJECT_STORAGE_CACHE, PROJECT_STORAGE_CACHE_TTL_SECONDS, diff --git a/server/tasks/models.py b/server/tasks/models.py index a06e2c31..5d6061ad 100644 --- a/server/tasks/models.py +++ b/server/tasks/models.py @@ -912,15 +912,8 @@ class TaskManager: data.setdefault("conversation_id", rec.conversation_id) if rec.workspace_id: data.setdefault("workspace_id", rec.workspace_id) - # 记录事件 + # 记录事件到任务事件流(轮询通道,唯一出口) self._append_event(rec, event_type, data) - # 在线用户仍然收到实时推送(房间 user_{username}); - # 安全包装:socketio 未绑定(独立 Gateway 进程)时静默跳过 - try: - from server.extensions import emit_event - emit_event(event_type, data, room=f"user_{username}") - except Exception: - pass # 轮询模式需要把 context_manager 的回调切到当前任务 sender, # 否则 token_update 等事件只走 websocket,前端任务轮询拿不到实时更新。 @@ -1043,7 +1036,6 @@ class TaskManager: ) # 统一发送 task_stopped,携带后台任务状态 try: - from server.extensions import emit_event stopped_payload = { 'message': tr("task_main.task_stopped"), 'reason': 'user_requested', @@ -1052,10 +1044,8 @@ class TaskManager: 'has_running_sub_agents': bg_state["has_running_sub_agents"], 'has_running_background_commands': bg_state["has_running_background_commands"], } - # 先写权威事件流(轮询客户端可见),再做实时推送—— - # 推送失败(含 socketio 未绑定)不得影响事件流记录 + # 事件流(轮询通道)为唯一权威出口,不再做 WebSocket 实时推送。 self._append_event(rec, "task_stopped", stopped_payload) - emit_event('task_stopped', stopped_payload, room=f"user_{rec.username}") debug_log( f"[TaskRun] 已发送 task_stopped: task_id={rec.task_id}, " f"has_bg={has_bg}, room=user_{rec.username}" diff --git a/static/src/app/computed.ts b/static/src/app/computed.ts index 1e597fe3..160f7821 100644 --- a/static/src/app/computed.ts +++ b/static/src/app/computed.ts @@ -58,7 +58,6 @@ const AVATAR_TERMINAL_STATUS = new Set([ export const computed = { ...mapWritableState(useConnectionStore, [ 'isConnected', - 'socket', 'stopRequested', 'projectPath', 'agentVersion', diff --git a/static/src/app/lifecycle.ts b/static/src/app/lifecycle.ts index f21dd72d..d09523a6 100644 --- a/static/src/app/lifecycle.ts +++ b/static/src/app/lifecycle.ts @@ -47,6 +47,7 @@ export async function mounted() { const initialDataPromise = this.loadInitialData(); this.startProjectGitSummaryIdleRefresh?.(); this.startConnectionHeartbeat(); + this.startStatusIdleRefresh?.(); this.fetchTerminalCount(); this.startTerminalCountIdleRefresh(); this.checkTutorialPrompt(); @@ -123,6 +124,7 @@ export function beforeUnmount() { this.teardownMobileViewportWatcher(); this.stopProjectGitSummaryIdleRefresh?.(); this.stopTerminalCountIdleRefresh?.(); + this.stopStatusIdleRefresh?.(); this.resourceStopContainerStatsPolling(); this.resourceStopProjectStoragePolling(); this.resourceStopUsageQuotaPolling(); diff --git a/static/src/app/methods/ui/panel.ts b/static/src/app/methods/ui/panel.ts index effa574d..f1ff3977 100644 --- a/static/src/app/methods/ui/panel.ts +++ b/static/src/app/methods/ui/panel.ts @@ -49,9 +49,6 @@ export const panelMethods = { }, toggleTerminalPanel() { this.terminalPanelOpen = !this.terminalPanelOpen; - if (this.terminalPanelOpen) { - this.subscribeTerminalEvents(); - } }, closeTerminalPanel() { this.terminalPanelOpen = false; diff --git a/static/src/app/methods/ui/socket.ts b/static/src/app/methods/ui/socket.ts index 4de17809..9d7ec95f 100644 --- a/static/src/app/methods/ui/socket.ts +++ b/static/src/app/methods/ui/socket.ts @@ -30,6 +30,51 @@ export const socketMethods = { // 主网页端已切换为 REST + 轮询模式,这里保留空实现用于兼容旧调用 this.socket = null; }, + // 状态快照轮询:替代原 WebSocket status_update 推送。 + // 任务运行期跳过(运行期状态由任务事件流驱动),空闲期每 5s 一次。 + // 配额轮询已由 resource store 的 UsageQuotaPolling 独立承担,这里不重复。 + async fetchStatusSnapshot() { + try { + const res = await fetch('/api/status', { cache: 'no-store' }); + if (!res.ok) return; + const status = await res.json(); + this.applyStatusSnapshot(status); + // 显式新建路由(/new 等)与独立全屏路由不接管当前对话与运行模式 + const onExplicitNewRoute = + typeof this.isExplicitNewConversationRoute === 'function' && + this.isExplicitNewConversationRoute(); + const onIndependentRoute = + typeof this.isConversationIndependentRoute === 'function' && + this.isConversationIndependentRoute(); + if (status.conversation && status.conversation.current_id) { + if (this.initialRouteResolved && !this.currentConversationId && !onExplicitNewRoute && !onIndependentRoute) { + this.currentConversationId = status.conversation.current_id; + } + } + if (!onExplicitNewRoute) { + if (typeof status.run_mode === 'string') { + this.runMode = status.run_mode; + } else if (typeof status.thinking_mode !== 'undefined') { + this.runMode = status.thinking_mode ? 'thinking' : 'fast'; + } + } + } catch { + // 断线判定由连接心跳负责,这里静默失败 + } + }, + startStatusIdleRefresh() { + if (this.statusRefreshTimer) return; + this.statusRefreshTimer = window.setInterval(() => { + if (!this.isConnected) return; + if (typeof this.isOutputActive === 'function' && this.isOutputActive()) return; + this.fetchStatusSnapshot(); + }, 5000); + }, + stopStatusIdleRefresh() { + if (!this.statusRefreshTimer) return; + window.clearInterval(this.statusRefreshTimer); + this.statusRefreshTimer = null; + }, async checkConnectionHealth() { if (this.connectionHeartbeatInFlight) { connectionDiag('log', 'health-skip-inflight', { diff --git a/static/src/app/methods/ui/terminal.ts b/static/src/app/methods/ui/terminal.ts index 2abf7161..a6aae7ac 100644 --- a/static/src/app/methods/ui/terminal.ts +++ b/static/src/app/methods/ui/terminal.ts @@ -34,11 +34,6 @@ export const terminalMethods = { } this.toggleTerminalPanel(); }, - subscribeTerminalEvents() { - const socket = this.socket; - if (!socket) return; - socket.emit('terminal_subscribe', { all: true, conversation_id: this.currentConversationId || undefined }); - }, setTerminalSessions(sessions: Record) { this.terminalSessions = sessions; }, @@ -47,9 +42,7 @@ export const terminalMethods = { }, switchTerminalSession(name: string) { this.terminalActiveSession = name; - if (this.socket) { - this.socket.emit('get_terminal_output', { session: name, lines: 0, conversation_id: this.currentConversationId || undefined }); - } + // 终端输出由 TerminalPanel 的 REST 轮询获取,无需主动拉取 }, async fetchTerminalCount() { try { diff --git a/static/src/components/panels/TerminalPanel.vue b/static/src/components/panels/TerminalPanel.vue index 55f979b7..7f59ba30 100644 --- a/static/src/components/panels/TerminalPanel.vue +++ b/static/src/components/panels/TerminalPanel.vue @@ -37,7 +37,6 @@ diff --git a/static/src/composables/useLegacySocket.ts b/static/src/composables/useLegacySocket.ts deleted file mode 100644 index ee69c2e0..00000000 --- a/static/src/composables/useLegacySocket.ts +++ /dev/null @@ -1,2103 +0,0 @@ -// @ts-nocheck -/** - * WebSocket 连接管理(遗留代码) - * - * 注意:聊天流式事件(ai_message_start, thinking_*, text_*, tool_*)已废弃 - * 现在使用 REST API 轮询模式(/api/tasks)处理聊天消息 - * - * 保留的功能: - * - 系统事件:connect, disconnect, error - * - 配额事件:quota_update, quota_notice, quota_exceeded - * - 对话事件:conversation_changed, conversation_resolved, conversation_loaded - * - 子智能体事件:sub_agent_waiting - * - Token统计:token_update - * - * 待清理: - * - 流式文本处理相关代码(streamingState, pendingToolEvents等) - * - 聊天事件处理器中的复杂逻辑 - */ -import { io as createSocketClient } from 'socket.io-client'; -import { renderLatexInRealtime } from './useMarkdownRenderer'; -import { getMessageVisibility, messageStartsWork } from '../utils/messageVisibility'; -import { goalModeDebugLog } from '../app/methods/common'; -import { useQuickDockStore } from '../stores/quickDock'; -import { useChatStore } from '../stores/chat'; -import { t } from '@/locales'; - -export async function initializeLegacySocket(ctx: any) { - try { - const SOCKET_DEBUG_LOGS_ENABLED = false; - const socketLog = (...args: any[]) => { - if (!SOCKET_DEBUG_LOGS_ENABLED) { - return; - } - console.log(...args); - }; - - socketLog('初始化WebSocket连接...'); - - const socketOptions = { - transports: ['websocket', 'polling'], - autoConnect: false - }; - - ctx.socket = createSocketClient('/', socketOptions); - - // 可开关的逐字打印功能,默认关闭 - const STREAMING_ENABLED = false; - const STREAMING_CHAR_DELAY = 22; - const STREAMING_FINALIZE_DELAY = 1000; - const STREAMING_DEBUG = false; - const STREAMING_DEBUG_HISTORY_LIMIT = 2000; - const streamingState = { - buffer: [] as string[], - timer: null as number | null, - completionTimer: null as number | null, - apiCompleted: false, - pendingCompleteContent: '' as string, - renderedText: '' as string, - activeMessageIndex: null as number | null, - activeTextAction: null as any, - ignoreThinking: false - }; - - const pendingToolEvents: Array<{ event: string; data: any; handler: () => void }> = []; - - const startIntentTyping = (action: any, intentText: any) => { - if (!action || !action.tool) { - return; - } - if (typeof intentText !== 'string') { - return; - } - const text = intentText.trim(); - if (!text) { - return; - } - if (action.tool.intent_full === text) { - if (console && console.debug) { - } - return; - } - if (typeof console !== 'undefined' && console.debug) { - } - if (action.tool.intent_full === text && action.tool.intent_rendered === text) { - return; - } - // 清理旧定时器 - if (action.tool.intent_typing_timer) { - clearInterval(action.tool.intent_typing_timer); - action.tool.intent_typing_timer = null; - } - action.tool.intent_full = text; - action.tool.intent_rendered = ''; - const duration = 1000; - const step = Math.max(10, Math.floor(duration / Math.max(1, text.length))); - let index = 0; - action.tool.intent_typing_timer = window.setInterval(() => { - action.tool.intent_rendered = text.slice(0, index + 1); - if (index === 0 && console && console.debug) { - } - index += 1; - if (index >= text.length) { - clearInterval(action.tool.intent_typing_timer); - action.tool.intent_typing_timer = null; - action.tool.intent_rendered = text; - if (console && console.debug) { - } - } - if (typeof ctx?.$forceUpdate === 'function') { - ctx.$forceUpdate(); - } - }, step); - }; - - const resetPendingToolEvents = () => { - pendingToolEvents.length = 0; - }; - - const flushPendingToolEvents = () => { - if (!pendingToolEvents.length) { - return; - } - const queue = pendingToolEvents.splice(0); - queue.forEach((item) => { - try { - item.handler(); - } catch (error) { - console.warn('延后工具事件处理失败:', item.event, error); - } - }); - }; - - const snapshotStreamingState = () => ({ - bufferLength: streamingState.buffer.length, - timerActive: streamingState.timer !== null, - completionTimerActive: streamingState.completionTimer !== null, - apiCompleted: streamingState.apiCompleted, - pendingLength: (streamingState.pendingCompleteContent || '').length, - renderedLength: streamingState.renderedText.length, - currentMessageIndex: - typeof ctx?.currentMessageIndex === 'number' ? ctx.currentMessageIndex : null, - messagesLength: Array.isArray(ctx?.messages) ? ctx.messages.length : null, - streamingMessage: !!ctx?.streamingMessage - }); - - const streamingDebugHistory: Array = []; - - const sanitizeBubbleText = (text: unknown) => { - if (typeof text !== 'string') { - return ''; - } - return text.replace(/\r/g, ''); - }; - - const getActiveMessage = () => { - if (streamingState.activeMessageIndex === null) { - return null; - } - const messages = ctx?.messages; - if (!Array.isArray(messages)) { - return null; - } - return messages[streamingState.activeMessageIndex] || null; - }; - - const ensureActiveMessageBinding = () => { - if ( - streamingState.activeMessageIndex === null && - typeof ctx?.currentMessageIndex === 'number' && - ctx.currentMessageIndex >= 0 - ) { - streamingState.activeMessageIndex = ctx.currentMessageIndex; - } - if (typeof ctx?.currentMessageIndex !== 'number' || ctx.currentMessageIndex < 0) { - if (streamingState.activeMessageIndex !== null) { - ctx.currentMessageIndex = streamingState.activeMessageIndex; - } - } - if (!ctx.streamingMessage) { - ctx.streamingMessage = true; - } - const msg = getActiveMessage(); - if (!msg && Array.isArray(ctx?.messages) && ctx.messages.length) { - streamingState.activeMessageIndex = ctx.messages.length - 1; - } - return getActiveMessage(); - }; - - const ensureActiveTextAction = () => { - const msg = getActiveMessage(); - if (!msg || !Array.isArray(msg.actions) || !msg.actions.length) { - return null; - } - const known = streamingState.activeTextAction; - if (known && msg.actions.includes(known)) { - return known; - } - for (let i = msg.actions.length - 1; i >= 0; i--) { - const action = msg.actions[i]; - if (action && action.type === 'text') { - streamingState.activeTextAction = action; - return action; - } - } - return null; - }; - - const fallbackAppendToActiveMessage = (text: string) => { - const msg = ensureActiveMessageBinding(); - if (!msg) { - return null; - } - if (typeof msg.streamingText !== 'string') { - msg.streamingText = ''; - } - msg.streamingText += text; - const action = ensureActiveTextAction(); - if (!action) { - return null; - } - if (typeof action.content !== 'string') { - action.content = ''; - } - action.content += text; - action.streaming = action.streaming !== false; - streamingState.activeTextAction = action; - ctx.$forceUpdate(); - ctx.conditionalScrollToBottom(); - renderLatexInRealtime(); - return action; - }; - - const completeActiveTextAction = (fullContent: string) => { - const msg = ensureActiveMessageBinding(); - if (!msg) { - return false; - } - const action = ensureActiveTextAction(); - if (action) { - action.streaming = false; - const fallback = - (typeof fullContent === 'string' && fullContent.length ? fullContent : action.content) || - ''; - action.content = fallback; - } - msg.streamingText = ''; - msg.currentStreamingType = null; - return !!action; - }; - - const logStreamingDebug = (event: string, detail?: any) => { - if (!STREAMING_DEBUG) { - return; - } - const payload = typeof detail === 'undefined' ? snapshotStreamingState() : detail; - const entry = { - event, - detail: payload, - conversationId: ctx.currentConversationId || null, - timestamp: Date.now() - }; - streamingDebugHistory.push(entry); - if (streamingDebugHistory.length > STREAMING_DEBUG_HISTORY_LIMIT) { - streamingDebugHistory.shift(); - } - try { - window.__streamingDebugLogs = streamingDebugHistory.slice(); - } catch (error) { - // ignore - } - console.log('[streaming-debug]', event, payload); - try { - if (ctx.socket && typeof ctx.socket.emit === 'function') { - ctx.socket.emit('client_stream_debug_log', entry); - } - } catch (error) { - console.warn('上报 streaming debug 日志失败:', error); - } - }; - - const markStreamingIdleIfPossible = (source: string) => { - try { - if (!ctx) { - return; - } - if (typeof ctx.maybeResetStreamingState === 'function') { - const reset = ctx.maybeResetStreamingState(source); - if (reset) { - logStreamingDebug('streaming_idle_reset', { source }); - } - return; - } - if (!ctx.streamingMessage) { - return; - } - const hasPending = - typeof ctx.hasPendingToolActions === 'function' ? ctx.hasPendingToolActions() : false; - if (!hasPending) { - ctx.streamingMessage = false; - ctx.stopRequested = false; - logStreamingDebug('streaming_idle_reset:fallback', { source }); - } - } catch (error) { - console.warn('自动结束流式状态失败:', error); - } - }; - - const stopStreamingTimer = () => { - if (streamingState.timer !== null) { - clearTimeout(streamingState.timer); - streamingState.timer = null; - logStreamingDebug('stopStreamingTimer', snapshotStreamingState()); - } - }; - - const stopCompletionTimer = () => { - if (streamingState.completionTimer !== null) { - clearTimeout(streamingState.completionTimer); - streamingState.completionTimer = null; - logStreamingDebug('stopCompletionTimer', snapshotStreamingState()); - } - }; - - const finalizeStreamingText = (options?: { force?: boolean; allowIncomplete?: boolean }) => { - const forceFlush = !!options?.force; - const allowIncomplete = !!options?.allowIncomplete; - logStreamingDebug('finalizeStreamingText:start', { - forceFlush, - allowIncomplete, - snapshot: snapshotStreamingState() - }); - if (!forceFlush) { - if (streamingState.buffer.length) { - logStreamingDebug('finalizeStreamingText:blocked-buffer', snapshotStreamingState()); - return false; - } - if (!allowIncomplete && !streamingState.apiCompleted) { - logStreamingDebug( - 'finalizeStreamingText:blocked-api-incomplete', - snapshotStreamingState() - ); - return false; - } - } - stopStreamingTimer(); - stopCompletionTimer(); - if (forceFlush && streamingState.buffer.length) { - const remainder = streamingState.buffer.join(''); - streamingState.buffer.length = 0; - if (remainder) { - applyTextChunk(remainder); - logStreamingDebug('finalizeStreamingText:force-flush-buffer', { - flushedChars: remainder.length - }); - } - } - const pendingText = streamingState.pendingCompleteContent || ''; - const renderedText = streamingState.renderedText || ''; - let finalText = pendingText || renderedText || ''; - let remainderToAppend = ''; - if (!pendingText) { - finalText = renderedText; - } else if (!renderedText) { - finalText = pendingText; - remainderToAppend = pendingText; - } else if (pendingText.length < renderedText.length) { - // 后端返回的最终内容比已渲染的还短,避免覆盖已展示的字符 - finalText = renderedText; - } else if (pendingText.startsWith(renderedText)) { - finalText = pendingText; - remainderToAppend = pendingText.slice(renderedText.length); - } else { - finalText = pendingText; - } - logStreamingDebug('finalizeStreamingText:resolved-final-text', { - pendingLength: pendingText.length, - renderedLength: renderedText.length, - remainderToAppendLength: remainderToAppend.length, - finalLength: finalText.length - }); - if (remainderToAppend) { - applyTextChunk(remainderToAppend); - } - streamingState.pendingCompleteContent = ''; - streamingState.apiCompleted = false; - streamingState.renderedText = ''; - ctx.chatCompleteTextAction(finalText || ''); - completeActiveTextAction(finalText || ''); - ctx.$forceUpdate(); - // 注意:不在这里重置 streamingMessage,因为可能还有工具调用在进行 - // streamingMessage 只在 task_complete 事件时重置 - logStreamingDebug('finalizeStreamingText:complete', snapshotStreamingState()); - streamingState.activeMessageIndex = null; - streamingState.activeTextAction = null; - markStreamingIdleIfPossible('finalizeStreamingText'); - flushPendingToolEvents(); - return true; - }; - - const scheduleFinalizationAfterDrain = () => { - if (streamingState.buffer.length) { - logStreamingDebug( - 'scheduleFinalizationAfterDrain:buffer-not-empty', - snapshotStreamingState() - ); - return; - } - if (streamingState.completionTimer !== null) { - logStreamingDebug( - 'scheduleFinalizationAfterDrain:already-scheduled', - snapshotStreamingState() - ); - return; - } - logStreamingDebug('scheduleFinalizationAfterDrain:scheduled', snapshotStreamingState()); - streamingState.completionTimer = window.setTimeout(() => { - streamingState.completionTimer = null; - logStreamingDebug('scheduleFinalizationAfterDrain:timer-fired', snapshotStreamingState()); - finalizeStreamingText({ allowIncomplete: true }); - }, STREAMING_FINALIZE_DELAY); - }; - - const resetStreamingBuffer = () => { - stopStreamingTimer(); - stopCompletionTimer(); - streamingState.buffer.length = 0; - streamingState.apiCompleted = false; - streamingState.pendingCompleteContent = ''; - streamingState.renderedText = ''; - streamingState.activeMessageIndex = null; - streamingState.activeTextAction = null; - resetPendingToolEvents(); - logStreamingDebug('resetStreamingBuffer', snapshotStreamingState()); - }; - - const applyTextChunk = (text: string) => { - if (!text) { - return null; - } - ensureActiveMessageBinding(); - let action = ctx.chatAppendTextChunk(text); - if (action) { - ctx.$forceUpdate(); - ctx.conditionalScrollToBottom(); - renderLatexInRealtime(); - } else { - action = fallbackAppendToActiveMessage(text); - } - streamingState.renderedText += text; - const appended = !!action; - logStreamingDebug('applyTextChunk', { - chunkLength: text.length, - appended, - snapshot: snapshotStreamingState() - }); - if (!appended) { - logStreamingDebug('applyTextChunk:missing-target', { - chunkLength: text.length, - currentMessageIndex: - typeof ctx?.currentMessageIndex === 'number' ? ctx.currentMessageIndex : null, - messagesLength: Array.isArray(ctx?.messages) ? ctx.messages.length : null - }); - } - return action; - }; - - const drainStreamingBufferImmediately = () => { - if (!streamingState.buffer.length) { - return; - } - const chunk = streamingState.buffer.join(''); - streamingState.buffer.length = 0; - applyTextChunk(chunk); - }; - - const scheduleStreamingFlush = () => { - const shouldFlushImmediately = typeof document !== 'undefined' && document.hidden === true; - if (shouldFlushImmediately) { - stopStreamingTimer(); - drainStreamingBufferImmediately(); - if (streamingState.apiCompleted) { - finalizeStreamingText({ force: true }); - } else { - scheduleFinalizationAfterDrain(); - } - return; - } - if (streamingState.timer !== null) { - logStreamingDebug('scheduleStreamingFlush:timer-exists', snapshotStreamingState()); - return; - } - if (!streamingState.buffer.length) { - logStreamingDebug('scheduleStreamingFlush:no-buffer', snapshotStreamingState()); - return; - } - logStreamingDebug('scheduleStreamingFlush:start', snapshotStreamingState()); - const process = () => { - streamingState.timer = null; - logStreamingDebug('scheduleStreamingFlush:tick', snapshotStreamingState()); - if (!streamingState.buffer.length) { - logStreamingDebug('scheduleStreamingFlush:buffer-empty', snapshotStreamingState()); - return; - } - const piece = streamingState.buffer.shift(); - if (piece) { - applyTextChunk(piece); - } - if (streamingState.buffer.length) { - scheduleStreamingFlush(); - } else { - logStreamingDebug('scheduleStreamingFlush:buffer-drained', snapshotStreamingState()); - scheduleFinalizationAfterDrain(); - } - }; - streamingState.timer = window.setTimeout(process, STREAMING_CHAR_DELAY); - }; - - const enqueueStreamingContent = (text: string) => { - if (!text) { - return; - } - stopCompletionTimer(); - if (!STREAMING_ENABLED) { - // 关闭逐字模式:整块入缓冲并立即刷新,保持与 chunk 顺序一致 - streamingState.pendingCompleteContent = ''; - streamingState.buffer.length = 0; - streamingState.apiCompleted = false; - applyTextChunk(text); - return; - } - logStreamingDebug('enqueueStreamingContent', { incomingLength: text.length }); - for (const ch of Array.from(text)) { - streamingState.buffer.push(ch); - } - logStreamingDebug('enqueueStreamingContent:buffered', snapshotStreamingState()); - scheduleStreamingFlush(); - }; - - const scheduleHistoryReload = (delay = 0, options: { force?: boolean } = {}) => { - const { force = false } = options; - if (!ctx || typeof ctx.fetchAndDisplayHistory !== 'function') { - return; - } - setTimeout(() => { - const targetConversationId = ctx.currentConversationId; - if (!targetConversationId || targetConversationId.startsWith('temp_')) { - return; - } - try { - if (!force) { - const loadingSame = - !!ctx.historyLoading && ctx.historyLoadingFor === targetConversationId; - const historyReady = - ctx.lastHistoryLoadedConversationId === targetConversationId && - Array.isArray(ctx.messages) && - ctx.messages.length > 0; - if (loadingSame || historyReady) { - socketLog('跳过重复历史加载(scheduleHistoryReload)'); - return; - } - } - ctx.fetchAndDisplayHistory({ force }); - if (typeof ctx.fetchConversationTokenStatistics === 'function') { - ctx.fetchConversationTokenStatistics(); - } - if (typeof ctx.updateCurrentContextTokens === 'function') { - ctx.updateCurrentContextTokens(); - } - } catch (error) { - console.warn('重新加载对话历史失败:', error); - } - }, delay); - }; - - const assignSocketToken = async () => { - if (typeof window.requestSocketToken !== 'function') { - console.warn('缺少 requestSocketToken(),无法获取实时连接凭证'); - return false; - } - try { - const freshToken = await window.requestSocketToken(); - ctx.socket.auth = { socket_token: freshToken }; - return true; - } catch (error) { - console.error('获取 WebSocket token 失败:', error); - return false; - } - }; - - if (ctx.socket.io && typeof ctx.socket.io.on === 'function') { - ctx.socket.io.on('reconnect_attempt', async () => { - await assignSocketToken(); - }); - } - - let hasConnectedOnce = false; - - // 连接事件 - ctx.socket.on('connect', () => { - const isReconnect = hasConnectedOnce; - const historyLoadingSame = - !!ctx.historyLoading && ctx.historyLoadingFor === ctx.currentConversationId; - const historyReady = - ctx.lastHistoryLoadedConversationId === ctx.currentConversationId && - Array.isArray(ctx.messages) && - ctx.messages.length > 0; - - ctx.isConnected = true; - socketLog('WebSocket已连接'); - - // 自动订阅终端事件,确保终端面板数据始终就绪(对话级:带当前 conversation_id) - ctx.socket.emit('terminal_subscribe', { all: true, conversation_id: ctx.currentConversationId || undefined }); - - // 首次连接且历史已在加载/已就绪时,避免重复清空与重复动画 - if (!isReconnect && (historyLoadingSame || historyReady)) { - socketLog('初次连接已存在历史,跳过重复重置与加载'); - hasConnectedOnce = true; - return; - } - - hasConnectedOnce = true; - ctx.resetAllStates(isReconnect ? 'socket:reconnect' : 'socket:connect'); - scheduleHistoryReload(200, { force: isReconnect }); - }); - - ctx.socket.on('disconnect', () => { - ctx.isConnected = false; - socketLog('WebSocket已断开'); - // 断线时也重置状态,防止状态混乱 - ctx.resetAllStates('socket:disconnect'); - }); - - ctx.socket.on('connect_error', (error) => { - console.error('WebSocket连接错误:', error.message); - }); - - ctx.socket.on('quota_update', (data) => { - if (data && data.quotas) { - ctx.resourceSetUsageQuota({ quotas: data.quotas }); - } else { - ctx.fetchUsageQuota(); - } - }); - - ctx.socket.on('quota_notice', (data) => { - ctx.showQuotaToast(data || {}); - ctx.fetchUsageQuota(); - }); - - ctx.socket.on('quota_exceeded', (data) => { - ctx.showQuotaToast(data || {}); - ctx.fetchUsageQuota(); - }); - - ctx.socket.on('reconnect_attempt', async () => { - await assignSocketToken(); - }); - - const ready = await assignSocketToken(); - if (!ready) { - console.error('无法获取实时连接凭证,WebSocket 初始化中止。'); - return; - } - ctx.socket.connect(); - - // ========================================== - // Token统计WebSocket事件处理(修复版) - // ========================================== - - ctx.socket.on('token_update', (data) => { - socketLog('收到token更新事件:', data); - - // 只处理当前对话的token更新 - if (data.conversation_id === ctx.currentConversationId) { - // 更新累计统计(使用后端提供的准确字段名) - ctx.currentConversationTokens.cumulative_input_tokens = data.cumulative_input_tokens || 0; - ctx.currentConversationTokens.cumulative_output_tokens = data.cumulative_output_tokens || 0; - ctx.currentConversationTokens.cumulative_total_tokens = data.cumulative_total_tokens || 0; - ctx.currentConversationTokens.cumulative_cached_input_tokens = data.cumulative_cached_input_tokens || 0; - ctx.currentConversationTokens.cache_exempt_input_tokens = data.cache_exempt_input_tokens || 0; - - socketLog( - `Cumulative token stats updated: input=${data.cumulative_input_tokens}, output=${data.cumulative_output_tokens}, total=${data.cumulative_total_tokens}` - ); - - const hasContextTokens = typeof data.current_context_tokens === 'number'; - if (hasContextTokens && typeof ctx.resourceSetCurrentContextTokens === 'function') { - ctx.resourceSetCurrentContextTokens(data.current_context_tokens); - } else { - // 同时更新当前上下文Token(关键修复) - ctx.updateCurrentContextTokens(); - } - - ctx.$forceUpdate(); - } - }); - - // 上下文过长提示 - ctx.socket.on('context_warning', (data) => { - const title = data?.title || t('utils.contextTooLong'); - const message = data?.message || t('utils.contextNearLimit'); - if (typeof ctx.uiPushToast === 'function') { - ctx.uiPushToast({ - title, - message, - type: data?.type || 'warning', - duration: data?.duration || 6000 - }); - } - }); - - ctx.socket.on('todo_updated', (data) => { - socketLog('收到todo更新事件:', data); - // todo_updated 是全局广播:子智能体/其他对话的待办也会推到这里, - // 必须只响应当前对话,否则快捷窗口会串出子智能体的待办。 - // 也不能用事件里的 conversation_id 覆盖当前对话 id —— 那会把 - // 子智能体的对话 id 安到当前会话上(对话 id 同步走 conversation_resolved)。 - if (!data || !data.conversation_id) { - return; - } - if (data.conversation_id !== ctx.currentConversationId) { - return; - } - // 实时事件必须带 live=true:socket 先于任务轮询到达, - // 若静态消费(live=false)会抢先把状态写掉,等轮询的 live - // 事件再到时已无差异,快捷窗口的进入/打勾动画永远播不出来。 - ctx.fileSetTodoList(data.todo_list || null, true); - }); - - // 快捷窗口:本次对话编辑/创建文件记录更新 - ctx.socket.on('edited_files_updated', (data) => { - socketLog('收到编辑文件记录更新事件:', data); - // 全局广播:与 todo_updated 同款过滤——事件必须属于当前对话; - // /new 等无对话场景直接丢弃(文件记录一定归属某个对话)。 - if (!data || !data.conversation_id) { - return; - } - if (data.conversation_id !== ctx.currentConversationId) { - return; - } - // 同 todo_updated:socket 是实时通道,带 live=true 让新增文件 - // 播进入动画并滚动露出(静态插入在 5 行可视区外用户看不见)。 - useQuickDockStore().setEditedFiles(data.edited_files || [], true); - }); - - // 编辑摘要卡片:write/edit 成功后后端实时广播合并 diff, - // 写入发起工作的 user 消息 metadata,卡片随工作推进实时更新。 - ctx.socket.on('edit_summary_updated', (data) => { - if (!data || !data.conversation_id) { - return; - } - if (data.conversation_id !== ctx.currentConversationId) { - return; - } - useChatStore().updateEditSummaryByMessageId( - typeof data.message_id === 'string' ? data.message_id : '', - data.edit_summary || null - ); - }); - - // 系统就绪 - ctx.socket.on('system_ready', (data) => { - ctx.projectPath = data.project_path || ''; - ctx.agentVersion = data.version || ctx.agentVersion; - ctx.thinkingMode = !!data.thinking_mode; - if (data.run_mode) { - ctx.runMode = data.run_mode; - } else { - ctx.runMode = ctx.thinkingMode ? 'thinking' : 'fast'; - } - socketLog('系统就绪:', data); - - // 系统就绪后立即加载对话列表 - ctx.loadConversationsList(); - }); - - ctx.socket.on('tool_settings_updated', (data) => { - socketLog('收到工具设置更新:', data); - if (data && Array.isArray(data.categories)) { - ctx.applyToolSettingsSnapshot(data.categories); - } - }); - - // ========================================== - // 对话管理相关Socket事件 - // ========================================== - - // 监听对话变更事件 - ctx.socket.on('conversation_changed', (data) => { - socketLog('对话已切换:', data); - - // 初始化期间不修改 currentConversationId,避免与 bootstrapRoute 冲突 - if (ctx.initialRouteResolved) { - // 独立全屏路由(工作流编辑器等)不归对话体系:不接管 id、不清消息、不改 URL, - // 仅同步下方列表数据(标题更新/置顶/列表刷新)。 - const onIndependentRoute = - typeof ctx.isConversationIndependentRoute === 'function' && - ctx.isConversationIndependentRoute(); - // 用户停留在显式新建对话页(/new、/multiagent/new)时,不被其他对话的 - // 广播事件(如运行中任务的标题生成)拽走视图,仅同步列表数据。 - const stayOnNewRoute = - !data.cleared && - !onIndependentRoute && - typeof ctx.isExplicitNewConversationRoute === 'function' && - ctx.isExplicitNewConversationRoute(); - if (!stayOnNewRoute && !onIndependentRoute) { - ctx.currentConversationId = data.conversation_id; - ctx.currentConversationTitle = data.title || ''; - } - - // 更新对话列表中的标题 - if (data.title && Array.isArray(ctx.conversations)) { - const conv = ctx.conversations.find((c) => c && c.id === data.conversation_id); - if (conv) { - conv.title = data.title; - } - } - - ctx.promoteConversationToTop(data.conversation_id); - - if (data.cleared && !onIndependentRoute) { - // 对话被清空(独立全屏路由下不动对话状态与 URL) - ctx.logMessageState?.('socket:conversation_changed-clearing', { event: data }); - ctx.messages = []; - ctx.logMessageState?.('socket:conversation_changed-cleared', { event: data }); - ctx.currentConversationId = null; - ctx.currentConversationTitle = ''; - // 重置Token统计 - ctx.resetTokenStatistics(); - history.replaceState({}, '', '/new'); - } - - // 刷新对话列表 - ctx.loadConversationsList(); - ctx.fetchTodoList(); - ctx.fetchSubAgents(); - } - }); - - ctx.socket.on('conversation_resolved', (data) => { - if (!data || !data.conversation_id) { - return; - } - const convId = data.conversation_id; - - // 初始化期间不修改 currentConversationId,避免与 bootstrapRoute 冲突; - // 独立全屏路由(工作流编辑器等)下不恢复对话、不改写 URL - const onIndependentRoute = - typeof ctx.isConversationIndependentRoute === 'function' && - ctx.isConversationIndependentRoute(); - if (ctx.initialRouteResolved && !onIndependentRoute) { - ctx.currentConversationId = convId; - if (data.title) { - ctx.currentConversationTitle = data.title; - } - ctx.promoteConversationToTop(convId); - const pathFragment = ctx.stripConversationPrefix(convId); - const currentPath = window.location.pathname.replace(/^\/+/, ''); - // 对话类型不再是路由概念,统一裸路径 / - const targetPath = `/${pathFragment}`; - if (data.created) { - history.pushState({ conversationId: convId }, '', targetPath); - } else if (currentPath !== pathFragment) { - history.replaceState({ conversationId: convId }, '', targetPath); - } - } - }); - - // 监听对话加载事件 - ctx.socket.on('conversation_loaded', (data) => { - socketLog('对话已加载:', data); - if (ctx.skipConversationLoadedEvent) { - ctx.skipConversationLoadedEvent = false; - socketLog('跳过重复的 conversation_loaded 处理'); - return; - } - const targetConversationId = (data && data.conversation_id) || ctx.currentConversationId; - const historyLoadingSame = - !!targetConversationId && - !!ctx.historyLoading && - ctx.historyLoadingFor === targetConversationId; - const historyReady = - !!targetConversationId && - ctx.lastHistoryLoadedConversationId === targetConversationId && - Array.isArray(ctx.messages) && - ctx.messages.length > 0; - const forceReload = !!(data && data.force_reload); - - if (!forceReload && (historyLoadingSame || historyReady)) { - socketLog('conversation_loaded: 历史已加载/加载中,跳过重复刷新'); - } else if (data.clear_ui && targetConversationId === ctx.currentConversationId) { - // in-place 压缩等同一对话内的刷新:不 resetAllStates(避免重置滚动状态 - // 导致锁死),不重新 fetchAndDisplayHistory(避免消息闪烁)。 - socketLog('conversation_loaded: 同对话 clear_ui,跳过重置以保持滚动状态'); - } else { - if (data.clear_ui) { - // 清理当前UI状态,准备显示历史内容 - ctx.resetAllStates('socket:conversation_loaded'); - } - // 延迟获取并显示历史对话内容 - setTimeout(() => { - ctx.fetchAndDisplayHistory({ force: forceReload }); - }, 300); - } - - // 延迟获取Token统计(累计+当前上下文) - setTimeout(() => { - ctx.fetchConversationTokenStatistics(); - ctx.updateCurrentContextTokens(); - ctx.fetchTodoList(); - }, 500); - }); - - // 监听对话列表更新事件 - ctx.socket.on('conversation_list_update', (data) => { - socketLog('对话列表已更新:', data); - // 刷新对话列表 - ctx.conversationsOffset = 0; - ctx.hasMoreConversations = false; - ctx.loadingMoreConversations = false; - ctx.loadConversationsList(); - // 同时刷新分组侧边栏中已加载的工作区 - try { - const { useConversationStore } = require('../stores/conversation'); - const conversationStore = useConversationStore(); - conversationStore.workspaceGroups.forEach((group: any) => { - conversationStore.loadWorkspaceConversations(group.workspaceId, { refresh: true }); - }); - } catch (_err) { - // ignore - } - }); - - // 监听状态更新事件 - ctx.socket.on('status_update', (status) => { - ctx.applyStatusSnapshot(status); - // 显式新建路由(/new、/multiagent/new):status 快照反映的是后端 terminal - // 残留的旧对话上下文,既不能把它设成当前对话(防跳回),也不能让它 - // 覆盖刚应用的个性化默认运行模式。 - const onExplicitNewRoute = - typeof ctx.isExplicitNewConversationRoute === 'function' && - ctx.isExplicitNewConversationRoute(); - // 独立全屏路由(工作流编辑器等)同样不归对话体系,快照不得接管当前对话 - const onIndependentRoute = - typeof ctx.isConversationIndependentRoute === 'function' && - ctx.isConversationIndependentRoute(); - // 只有在初始化完成后,才允许 status_update 修改 currentConversationId - // 避免与 bootstrapRoute 冲突 - if (status.conversation && status.conversation.current_id) { - if (ctx.initialRouteResolved && !ctx.currentConversationId && !onExplicitNewRoute && !onIndependentRoute) { - // 初始化完成且当前没有对话ID,才设置 - ctx.currentConversationId = status.conversation.current_id; - } - } - if (!onExplicitNewRoute) { - if (typeof status.run_mode === 'string') { - ctx.runMode = status.run_mode; - } else if (typeof status.thinking_mode !== 'undefined') { - ctx.runMode = status.thinking_mode ? 'thinking' : 'fast'; - } - } - }); - - // 用户消息(后台子智能体完成后自动触发) - ctx.socket.on('user_message', (data) => { - const isMultiAgentMessage = !!( - data?.multi_agent_message || - data?.metadata?.multi_agent_message || - data?.multi_agent_output || - data?.metadata?.multi_agent_output - ); - goalModeDebugLog('legacy_socket_user_message_entry', { - usePollingMode: ctx.usePollingMode, - waitingForSubAgent: ctx.waitingForSubAgent, - isMultiAgentMessage, - currentConversationId: ctx.currentConversationId, - dataConversationId: data?.conversation_id, - message_source: data?.message_source || data?.metadata?.message_source, - starts_work: data?.starts_work, - auto_message_type: data?.auto_message_type || data?.metadata?.auto_message_type, - taskInProgress: ctx.taskInProgress - }); - // 多智能体消息在轮询模式下也需要处理,否则主智能体空闲时子智能体 - // 推送的消息无法触发前端恢复轮询,必须刷新页面才能看到。 - if (ctx.usePollingMode && !ctx.waitingForSubAgent && !isMultiAgentMessage) { - goalModeDebugLog('legacy_socket_user_message_early_return', { reason: 'polling_mode_no_waiting' }); - return; - } - if (!data) { - goalModeDebugLog('legacy_socket_user_message_early_return', { reason: 'no_data' }); - return; - } - if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { - socketLog('跳过user_message(对话不匹配)', data.conversation_id); - return; - } - const message = (data.message || data.content || '').trim(); - if (!message) { - return; - } - const incomingImages = Array.isArray(data.images) ? data.images : []; - const incomingVideos = Array.isArray(data.videos) ? data.videos : []; - const incomingMediaRefs = Array.isArray(data.media_refs) - ? data.media_refs - : Array.isArray(data.mediaRefs) - ? data.mediaRefs - : []; - const source = String(data?.message_source || data?.metadata?.message_source || 'user') - .trim() - .toLowerCase(); - const eventMetadata = { - ...(data?.metadata || {}), - message_source: source - }; - if (Object.prototype.hasOwnProperty.call(data || {}, 'visibility')) { - eventMetadata.visibility = data.visibility; - } - if (Object.prototype.hasOwnProperty.call(data || {}, 'starts_work')) { - eventMetadata.starts_work = data.starts_work; - } - eventMetadata.visibility = getMessageVisibility({ role: 'user', content: message, metadata: eventMetadata, ...data }); - eventMetadata.starts_work = messageStartsWork({ role: 'user', content: message, metadata: eventMetadata, ...data }); - const isAutoUserMessage = !!( - data?.is_auto_generated || - data?.metadata?.is_auto_generated || - data?.auto_message_type || - data?.metadata?.auto_message_type || - data?.sub_agent_notice || - data?.background_command_notice - ); - if ( - isAutoUserMessage && - eventMetadata.starts_work === true && - typeof ctx.markLatestUserWorkCompleted === 'function' - ) { - ctx.markLatestUserWorkCompleted(); - } - const last = - Array.isArray(ctx.messages) && ctx.messages.length - ? ctx.messages[ctx.messages.length - 1] - : null; - const isLikelyOptimisticEcho = !!( - last && - last.role === 'user' && - String(last.content || '').trim() === message && - JSON.stringify(last.images || []) === JSON.stringify(incomingImages || []) && - JSON.stringify(last.videos || []) === JSON.stringify(incomingVideos || []) - ); - if (isLikelyOptimisticEcho) { - last.media_refs = incomingMediaRefs; - last.metadata = { - ...(last.metadata || {}), - ...eventMetadata, - media_refs: incomingMediaRefs - }; - const backendTimestamp = data?.timestamp ?? data?.created_at ?? data?.createdAt ?? null; - if (backendTimestamp) { - last.created_at = backendTimestamp; - } - } else { - ctx.chatAddUserMessage(message, incomingImages, incomingVideos, incomingMediaRefs, source, eventMetadata); - } - ctx.taskInProgress = true; - ctx.streamingMessage = false; - ctx.stopRequested = false; - if (data?.sub_agent_notice && data?.task_id && ctx.usePollingMode) { - if (typeof data?.has_running_sub_agents === 'boolean') { - ctx.waitingForSubAgent = data.has_running_sub_agents; - } else if (typeof data?.remaining_count === 'number') { - ctx.waitingForSubAgent = data.remaining_count > 0; - } - (async () => { - try { - const { useTaskStore } = await import('../stores/task'); - const taskStore = useTaskStore(); - taskStore.resumeTask(data.task_id); - } catch (error) { - console.warn('恢复任务轮询失败', error); - } - })(); - } - // 多智能体消息没有 task_id,需要主动查找当前对话的运行中任务并恢复轮询。 - if (isMultiAgentMessage && ctx.usePollingMode && !data?.task_id) { - goalModeDebugLog('legacy_socket_user_message_ma_polling_start', { - currentConversationId: ctx.currentConversationId - }); - (async () => { - try { - const { useTaskStore } = await import('../stores/task'); - const taskStore = useTaskStore(); - const runningTask = await taskStore.loadRunningTask(ctx.currentConversationId); - goalModeDebugLog('legacy_socket_user_message_ma_polling_loaded', { - found: !!runningTask, - taskId: runningTask?.task_id, - taskStatus: runningTask?.status - }); - if (runningTask?.task_id) { - taskStore.resumeTask(runningTask.task_id); - goalModeDebugLog('legacy_socket_user_message_ma_polling_resumed', { - taskId: runningTask.task_id - }); - } - } catch (error) { - console.warn('恢复多智能体任务轮询失败', error); - goalModeDebugLog('legacy_socket_user_message_ma_polling_error', { - error: String(error) - }); - } - })(); - } - goalModeDebugLog('legacy_socket_user_message_end', { - taskInProgress: ctx.taskInProgress, - streamingMessage: ctx.streamingMessage - }); - ctx.$forceUpdate(); - ctx.conditionalScrollToBottom(); - }); - - // AI消息开始(已废弃,改用 REST API 轮询) - ctx.socket.on('ai_message_start', (data) => { - // 用户级广播:只响应当前对话的 AI 消息开始事件。 - if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { - socketLog('跳过ai_message_start(对话不匹配)', data.conversation_id); - return; - } - // 只处理子智能体模式 - if (ctx.waitingForSubAgent) { - ctx.waitingForSubAgent = false; - ctx.taskInProgress = false; - } - }); - - // API 请求已发出、尚未收到首个响应:状态头像显示「等待 API 响应…」 - ctx.socket.on('api_request_start', (data) => { - if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { - return; - } - // 轮询模式下该状态由任务事件流维护 - if (ctx.usePollingMode && !ctx.waitingForSubAgent) { - return; - } - ctx.apiRequestPending = true; - }); - - // 断流重试「清除重来」:后端与 API 的流式连接在输出中途断开、即将自动重试, - // 清除本轮 attempt 已渲染的半截内容(思考块含已闭合的 / 文本 / preparing 工具条目), - // 重试的新内容会由新一轮 thinking_start/text_start/tool_preparing 重新推送。 - ctx.socket.on('stream_reset', (data) => { - if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { - return; - } - // 轮询模式下由任务事件流重放处理(lifecycle.ts case 'stream_reset') - if (ctx.usePollingMode && !ctx.waitingForSubAgent) { - return; - } - socketLog('断流重试,清理本轮半截内容', data); - // 清逐字流缓冲,防止半截缓冲在重置后继续渲染 - resetStreamingBuffer(); - const retryAttempt = Number(data?.attempt) || 0; - const retryMax = Number(data?.max_attempts) || 0; - const retryLabel = retryAttempt && retryMax - ? t('appTasks.streamRetrying', { attempt: retryAttempt, max: retryMax }) - : t('appTasks.streamRetryingGeneric'); - const removedActions = ctx.chatResetStreamingAttemptActions?.(retryLabel) || []; - for (const action of removedActions) { - if (action?.id) { - ctx.preparingTools?.delete(action.id); - } - if (action?.type === 'tool') { - ctx.toolUnregisterAction?.(action); - } - } - if (typeof ctx.chatClearThinkingLocks === 'function') { - ctx.chatClearThinkingLocks(); - } - ctx.monitorEndModelOutput?.(); - ctx.$forceUpdate(); - }); - - // 思考流开始 - ctx.socket.on('thinking_start', (data) => { - // 对话定向事件:只响应当前对话,避免运行中对话的流式输出渲染到 /new 等空白页。 - if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { - socketLog('跳过thinking_start(对话不匹配)', data.conversation_id); - return; - } - // 轮询模式下跳过 WebSocket 事件 - if (ctx.usePollingMode && !ctx.waitingForSubAgent) { - socketLog('思考开始 (轮询模式,跳过)'); - return; - } - socketLog('思考开始'); - // 响应已开始,清除「等待 API 响应」状态(含 ignoreThinking 分支,故放最前) - ctx.apiRequestPending = false; - const ignoreThinking = ctx.runMode === 'fast' || ctx.thinkingMode === false; - streamingState.ignoreThinking = ignoreThinking; - if (ignoreThinking) { - ctx.monitorEndModelOutput(); - return; - } - ctx.monitorShowThinking(); - const result = ctx.chatStartThinkingAction(); - if (result && result.blockId) { - const blockId = result.blockId; - ctx.chatExpandBlock(blockId); - // 开始思考时尝试滚动到底部,但不改变用户锁定选择 - ctx.scrollToBottom(); - ctx.chatSetThinkingLock(blockId, true); - ctx.$forceUpdate(); - } - }); - - // 思考内容块 - ctx.socket.on('thinking_chunk', (data) => { - // 对话定向事件:只响应当前对话。 - if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { - return; - } - // 轮询模式下跳过 WebSocket 事件 - if (ctx.usePollingMode && !ctx.waitingForSubAgent) { - return; - } - if (streamingState.ignoreThinking) { - return; - } - const thinkingAction = ctx.chatAppendThinkingChunk(data.content); - if (thinkingAction) { - ctx.$forceUpdate(); - ctx.$nextTick(() => { - if (thinkingAction && thinkingAction.blockId) { - ctx.scrollThinkingToBottom(thinkingAction.blockId); - } - ctx.conditionalScrollToBottom(); - }); - } - ctx.monitorShowThinking(); - }); - - // 思考结束 - ctx.socket.on('thinking_end', (data) => { - // 对话定向事件:只响应当前对话。 - if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { - socketLog('跳过thinking_end(对话不匹配)', data.conversation_id); - return; - } - // 轮询模式下跳过 WebSocket 事件 - if (ctx.usePollingMode && !ctx.waitingForSubAgent) { - socketLog('思考结束 (轮询模式,跳过)'); - return; - } - socketLog('思考结束'); - if (streamingState.ignoreThinking) { - streamingState.ignoreThinking = false; - return; - } - const blockId = ctx.chatCompleteThinkingAction(data.full_content); - if (blockId) { - setTimeout(() => { - ctx.chatCollapseBlock(blockId); - ctx.chatSetThinkingLock(blockId, false); - ctx.$forceUpdate(); - }, 1000); - ctx.$nextTick(() => ctx.scrollThinkingToBottom(blockId)); - } - ctx.$forceUpdate(); - ctx.monitorEndModelOutput(); - }); - - // 文本流开始 - ctx.socket.on('text_start', (data) => { - // 对话定向事件:只响应当前对话。 - if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { - socketLog('跳过text_start(对话不匹配)', data.conversation_id); - return; - } - // 轮询模式下跳过 WebSocket 事件 - if (ctx.usePollingMode && !ctx.waitingForSubAgent) { - socketLog('文本开始 (轮询模式,跳过)'); - return; - } - socketLog('文本开始'); - ctx.apiRequestPending = false; - logStreamingDebug('socket:text_start'); - finalizeStreamingText({ force: true }); - resetStreamingBuffer(); - const action = ctx.chatStartTextAction(); - streamingState.activeMessageIndex = - typeof ctx.currentMessageIndex === 'number' ? ctx.currentMessageIndex : null; - streamingState.activeTextAction = action || ensureActiveTextAction(); - ensureActiveMessageBinding(); - ctx.$forceUpdate(); - }); - - // 文本内容块 - ctx.socket.on('text_chunk', (data) => { - // 对话定向事件:只响应当前对话。 - if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { - return; - } - // 轮询模式下跳过 WebSocket 事件 - if (ctx.usePollingMode && !ctx.waitingForSubAgent) { - return; - } - logStreamingDebug('socket:text_chunk', { - index: data?.index ?? null, - elapsed: data?.elapsed ?? null, - chunkLength: (data?.content || '').length, - snapshot: snapshotStreamingState() - }); - try { - ctx.socket.emit('client_chunk_log', { - conversation_id: ctx.currentConversationId, - index: data?.index ?? null, - elapsed: data?.elapsed ?? null, - length: (data?.content || '').length, - ts: Date.now() - }); - } catch (error) { - console.warn('上报chunk日志失败:', error); - } - if (data && typeof data.content === 'string' && data.content.length) { - if (STREAMING_ENABLED) { - enqueueStreamingContent(data.content); - } else { - // 关闭逐字模式时,直接追加当前chunk - applyTextChunk(data.content); - } - const speech = sanitizeBubbleText(data.content); - if (speech) { - ctx.monitorShowSpeech(speech); - } - } - }); - - // 文本结束 - ctx.socket.on('text_end', (data) => { - // 对话定向事件:只响应当前对话。 - if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { - socketLog('跳过text_end(对话不匹配)', data.conversation_id); - return; - } - // 轮询模式下跳过 WebSocket 事件 - if (ctx.usePollingMode && !ctx.waitingForSubAgent) { - socketLog('文本结束 (轮询模式,跳过)'); - return; - } - socketLog('文本结束'); - logStreamingDebug('socket:text_end', { - finalLength: (data?.full_content || '').length, - snapshot: snapshotStreamingState() - }); - if (!STREAMING_ENABLED) { - // 已逐块追加,这里保证动作收尾 - const full = data?.full_content || ''; - if (full) { - ctx.chatCompleteTextAction(full); - } else { - ctx.chatCompleteTextAction(''); - } - streamingState.buffer.length = 0; - streamingState.pendingCompleteContent = ''; - streamingState.renderedText = ''; - streamingState.apiCompleted = false; - ctx.monitorEndModelOutput(); - flushPendingToolEvents(); - return; - } - streamingState.apiCompleted = true; - streamingState.pendingCompleteContent = data?.full_content || ''; - const hidden = typeof document !== 'undefined' && document.hidden === true; - if (hidden) { - stopStreamingTimer(); - drainStreamingBufferImmediately(); - finalizeStreamingText({ force: true }); - } else if (!streamingState.buffer.length) { - scheduleFinalizationAfterDrain(); - } else { - scheduleStreamingFlush(); - } - ctx.monitorEndModelOutput(); - flushPendingToolEvents(); - }); - - // 工具提示事件(可选) - ctx.socket.on('tool_hint', (data) => { - socketLog('工具提示:', data.name); - if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { - socketLog('跳过tool_hint(对话不匹配)', data.conversation_id); - return; - } - // 可以在这里添加提示UI - }); - - // 工具准备中事件 - 实时显示 - ctx.socket.on('tool_preparing', (data) => { - // 轮询模式下跳过 WebSocket 事件 - if (ctx.usePollingMode && !ctx.waitingForSubAgent) { - return; - } - if (ctx.dropToolEvents) { - return; - } - socketLog('工具准备中:', data.name); - if (typeof console !== 'undefined' && console.debug) { - } - if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { - socketLog('跳过tool_preparing(对话不匹配)', data.conversation_id); - return; - } - // 工具流式收集即 API 响应已开始,清除「等待 API 响应」状态 - ctx.apiRequestPending = false; - const msg = ctx.chatEnsureAssistantMessage(); - if (!msg) { - return; - } - if (msg.awaitingFirstContent) { - msg.awaitingFirstContent = false; - msg.generatingLabel = ''; - } - const action = { - id: data.id, - type: 'tool', - tool: { - id: data.id, - name: data.name, - arguments: {}, - argumentSnapshot: null, - argumentLabel: '', - status: 'preparing', - result: null, - message: data.message || t('appTasks.preparingTool', { name: data.name }), - intent_full: data.intent || '', - intent_rendered: data.intent || '' - }, - timestamp: Date.now() - }; - msg.actions.push(action); - ctx.preparingTools.set(data.id, action); - ctx.toolRegisterAction(action, data.id); - ctx.toolTrackAction(data.name, action); - if (data.intent) { - startIntentTyping(action, data.intent); - } - ctx.$forceUpdate(); - ctx.conditionalScrollToBottom(); - if (ctx.monitorPreviewTool) { - ctx.monitorPreviewTool(data); - } - }); - - // 工具意图(流式增量)事件 - ctx.socket.on('tool_intent', (data) => { - // 轮询模式下跳过 WebSocket 事件 - if (ctx.usePollingMode && !ctx.waitingForSubAgent) { - return; - } - if (ctx.dropToolEvents) { - return; - } - if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { - return; - } - if (typeof console !== 'undefined' && console.debug) { - } - const target = - ctx.toolFindAction(data.id, data.preparing_id, data.execution_id) || - ctx.toolGetLatestAction(data.name); - if (target) { - startIntentTyping(target, data.intent); - } - ctx.$forceUpdate(); - }); - - // 工具状态更新事件 - 实时显示详细状态 - ctx.socket.on('tool_status', (data) => { - // 轮询模式下跳过 WebSocket 事件 - if (ctx.usePollingMode && !ctx.waitingForSubAgent) { - return; - } - if (ctx.dropToolEvents) { - return; - } - socketLog('工具状态:', data); - if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { - socketLog('跳过tool_status(对话不匹配)', data.conversation_id); - return; - } - const target = ctx.toolFindAction(data.id, data.preparing_id, data.execution_id); - if (target) { - target.tool.statusDetail = data.detail; - target.tool.statusType = data.status; - if (data.intent) { - startIntentTyping(target, data.intent); - } - ctx.$forceUpdate(); - return; - } - const fallbackAction = ctx.toolGetLatestAction(data.tool); - if (fallbackAction) { - fallbackAction.tool.statusDetail = data.detail; - fallbackAction.tool.statusType = data.status; - ctx.toolRegisterAction(fallbackAction, data.execution_id || data.id || data.preparing_id); - if (data.intent) { - startIntentTyping(fallbackAction, data.intent); - } - ctx.$forceUpdate(); - } - }); - - // 工具开始(从准备转为执行) - ctx.socket.on('tool_start', (data) => { - // 轮询模式下跳过 WebSocket 事件 - if (ctx.usePollingMode && !ctx.waitingForSubAgent) { - return; - } - if (ctx.dropToolEvents) { - return; - } - socketLog('工具开始执行:', data.name); - if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { - socketLog('跳过tool_start(对话不匹配)', data.conversation_id); - return; - } - const handler = () => { - let action = null; - if (data.preparing_id && ctx.preparingTools.has(data.preparing_id)) { - action = ctx.preparingTools.get(data.preparing_id); - ctx.preparingTools.delete(data.preparing_id); - } else { - action = ctx.toolFindAction(data.id, data.preparing_id, data.execution_id); - } - if (!action) { - const msg = ctx.chatEnsureAssistantMessage(); - if (!msg) { - return; - } - action = { - id: data.id, - type: 'tool', - tool: { - id: data.id, - name: data.name, - arguments: {}, - argumentSnapshot: null, - argumentLabel: '', - status: 'running', - result: null - }, - timestamp: Date.now() - }; - msg.actions.push(action); - } - action.tool.status = 'running'; - action.tool.arguments = data.arguments; - action.tool.argumentSnapshot = ctx.cloneToolArguments(data.arguments); - action.tool.argumentLabel = ctx.buildToolLabel(action.tool.argumentSnapshot); - action.tool.message = null; - action.tool.id = data.id; - action.tool.executionId = data.id; - if (data.arguments && data.arguments.intent) { - startIntentTyping(action, data.arguments.intent); - } - ctx.toolRegisterAction(action, data.id); - ctx.toolTrackAction(data.name, action); - ctx.$forceUpdate(); - ctx.conditionalScrollToBottom(); - ctx.monitorQueueTool(data); - - if (data.name === 'view_video' && typeof ctx.uiPushToast === 'function') { - ctx.uiPushToast({ - title: t('utils.videoReading'), - message: t('utils.videoReadingSlow'), - type: 'info', - duration: 5000 - }); - } - }; - - handler(); - }); - - // 更新action(工具完成) - ctx.socket.on('update_action', (data) => { - // 轮询模式下跳过 WebSocket 事件 - if (ctx.usePollingMode && !ctx.waitingForSubAgent) { - return; - } - if (ctx.dropToolEvents) { - return; - } - socketLog('更新action:', data.id, 'status:', data.status); - if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { - socketLog('跳过update_action(对话不匹配)', data.conversation_id); - return; - } - const handler = () => { - let targetAction = ctx.toolFindAction(data.id, data.preparing_id, data.execution_id); - if (!targetAction && data.preparing_id && ctx.preparingTools.has(data.preparing_id)) { - targetAction = ctx.preparingTools.get(data.preparing_id); - } - if (!targetAction) { - outer: for (const message of ctx.messages) { - if (!message.actions) continue; - for (const action of message.actions) { - if (action.type !== 'tool') continue; - const matchByExecution = - action.tool.executionId && action.tool.executionId === data.id; - const matchByToolId = action.tool.id === data.id; - const matchByPreparingId = action.id === data.preparing_id; - if (matchByExecution || matchByToolId || matchByPreparingId) { - targetAction = action; - break outer; - } - } - } - } - if (targetAction) { - if (data.status) { - targetAction.tool.status = data.status; - } - if (data.result !== undefined) { - targetAction.tool.result = data.result; - } - if ( - targetAction.tool && - targetAction.tool.name === 'trigger_easter_egg' && - data.result !== undefined - ) { - const eggPromise = ctx.handleEasterEggPayload(data.result); - if (eggPromise && typeof eggPromise.catch === 'function') { - eggPromise.catch((error) => { - console.warn('彩蛋处理异常:', error); - }); - } - } - // 个性化设置工具主题切换处理在 taskPolling.ts 中(当前使用轮询模式) - if (data.message !== undefined) { - targetAction.tool.message = data.message; - } - if (data.arguments && data.arguments.intent) { - startIntentTyping(targetAction, data.arguments.intent); - } - if (data.awaiting_content) { - targetAction.tool.awaiting_content = true; - } else if (data.status === 'completed') { - targetAction.tool.awaiting_content = false; - } - if (!targetAction.tool.executionId && (data.execution_id || data.id)) { - targetAction.tool.executionId = data.execution_id || data.id; - } - if (data.arguments) { - targetAction.tool.arguments = data.arguments; - targetAction.tool.argumentSnapshot = ctx.cloneToolArguments(data.arguments); - targetAction.tool.argumentLabel = ctx.buildToolLabel( - targetAction.tool.argumentSnapshot - ); - if (data.arguments.intent) { - startIntentTyping(targetAction, data.arguments.intent); - } - } - ctx.toolRegisterAction(targetAction, data.execution_id || data.id); - if ( - data.status && - ['completed', 'failed', 'error'].includes(data.status) && - !data.awaiting_content - ) { - ctx.toolUnregisterAction(targetAction); - if (data.id) { - ctx.preparingTools.delete(data.id); - } - if (data.preparing_id) { - ctx.preparingTools.delete(data.preparing_id); - } - } - ctx.$forceUpdate(); - ctx.conditionalScrollToBottom(); - } - - if (data.status === 'completed') { - setTimeout(() => { - ctx.updateCurrentContextTokens(); - }, 500); - try { - ctx.fileFetchTree(); - } catch (error) { - console.warn('刷新文件树失败', error); - } - } - ctx.monitorResolveTool(data); - }; - - handler(); - }); - - ctx.socket.on('append_payload', (data) => { - if (ctx.dropToolEvents) { - return; - } - socketLog('收到append_payload事件:', data); - if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { - socketLog('跳过append_payload(对话不匹配)', data.conversation_id); - return; - } - ctx.chatAddAppendPayloadAction({ - path: data.path || t('utils.unknownFile'), - forced: !!data.forced, - success: data.success === undefined ? true : !!data.success, - lines: data.lines ?? null, - bytes: data.bytes ?? null - }); - ctx.$forceUpdate(); - ctx.conditionalScrollToBottom(); - }); - - ctx.socket.on('modify_payload', (data) => { - if (ctx.dropToolEvents) { - return; - } - socketLog('收到modify_payload事件:', data); - if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { - socketLog('跳过modify_payload(对话不匹配)', data.conversation_id); - return; - } - ctx.chatAddModifyPayloadAction({ - path: data.path || t('utils.unknownFile'), - total: data.total ?? null, - completed: data.completed || [], - failed: data.failed || [], - forced: !!data.forced - }); - ctx.$forceUpdate(); - ctx.conditionalScrollToBottom(); - }); - - // 停止请求确认 - ctx.socket.on('stop_requested', (data) => { - // 用户级广播:只清理当前对话的 pending 工具状态。 - if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { - socketLog('跳过stop_requested(对话不匹配)', data.conversation_id); - return; - } - socketLog('停止请求已接收:', data.message); - // 可以显示提示信息 - try { - if (typeof ctx.clearPendingTools === 'function') { - ctx.clearPendingTools('socket:stop_requested'); - } - } catch (error) { - console.warn('清理未完成工具失败', error); - } - }); - - // 任务停止 - ctx.socket.on('task_stopped', (data) => { - // 多对话并行运行:任务生命周期事件是用户级广播,只响应当前对话的, - // 否则其他对话的停止会误清本对话的运行态(侧边栏标识由 - // refreshRunningWorkspaceTasks 独立轮询维护,不受影响)。 - if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { - socketLog('跳过task_stopped(对话不匹配)', data.conversation_id); - return; - } - socketLog('任务已停止:', data.message); - const hasRunningSubAgents = !!data?.has_running_sub_agents; - const hasRunningBackgroundCommands = !!data?.has_running_background_commands; - const hasRunningBackground = hasRunningSubAgents || hasRunningBackgroundCommands; - const hasRunningMultiAgent = !!data?.has_running_multi_agent; - // 主对话已被停止。streaming、stopRequested 都重置。 - // 如果仍有后台任务在跑,保留 taskInProgress=true 让对话在列表里仍显示“运行中” - // (仅影响 UI 标识,不影响输入栏可用性——输入栏只看 streamingMessage)。 - const stillHasBackground = hasRunningBackground || hasRunningMultiAgent; - ctx.streamingMessage = false; - ctx.stopRequested = false; - ctx.waitingForSubAgent = hasRunningSubAgents; - ctx.waitingForBackgroundCommand = hasRunningBackgroundCommands; - ctx.taskInProgress = stillHasBackground; - if (typeof ctx.scheduleResetAfterTask === 'function') { - ctx.scheduleResetAfterTask('socket:task_stopped', { preserveMonitorWindows: true }); - } - }); - - // 任务完成(重点:更新Token统计) - ctx.socket.on('task_complete', (data) => { - // 同 task_stopped:用户级广播,只响应当前对话的任务完成事件。 - if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { - socketLog('跳过task_complete(对话不匹配)', data.conversation_id); - return; - } - socketLog('任务完成', data); - - const hasRunningMultiAgent = !!data?.has_running_multi_agent; - // 多智能体模式下,若仍有运行中的多智能体实例,保持对话运行态,继续接收后续消息; - // 但此时主智能体已空闲,不应阻塞输入区(waitingForSubAgent=false)。 - if (hasRunningMultiAgent) { - } else if (ctx.currentConversationType === 'multi_agent' || !data.has_running_sub_agents) { - if (ctx.waitingForSubAgent) { - ctx.waitingForSubAgent = false; - } - ctx.taskInProgress = false; - ctx.scheduleResetAfterTask('socket:task_complete', { preserveMonitorWindows: true }); - } else { - } - - resetPendingToolEvents(); - - // 行内引用:任务完成时后端带回已用来源,挂到最后一条 assistant 消息的 metadata, - // MarkdownRenderer 的 citations watcher 会触发 chip 增强(流式期间的占位胶囊在此转正) - if (Array.isArray(data?.citations) && Array.isArray(ctx.messages) && ctx.messages.length) { - const lastMsg = ctx.messages[ctx.messages.length - 1]; - if (lastMsg && lastMsg.role === 'assistant') { - lastMsg.metadata = { - ...(lastMsg.metadata || {}), - citations: data.citations - }; - } - } - - // 任务完成后立即更新Token统计(关键修复) - if (ctx.currentConversationId) { - ctx.updateCurrentContextTokens(); - ctx.fetchConversationTokenStatistics(); - } - }); - - // 子智能体等待状态 - ctx.socket.on('sub_agent_waiting', (data) => { - // 用户级广播:只响应当前对话的等待事件,避免把其他对话的 - // waitingForSubAgent 标志安到本对话上(会阻塞 resetAllStates)。 - if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { - socketLog('跳过sub_agent_waiting(对话不匹配)', data.conversation_id); - return; - } - socketLog('等待子智能体完成:', data); - - // 多智能体对话下,子智能体 idle/running 是常态,不阻塞主对话输入区 - if (ctx.currentConversationType !== 'multi_agent') { - // 设置标志:有子智能体在运行,阻止状态重置 - ctx.waitingForSubAgent = true; - - // 保持任务进行中状态 - ctx.taskInProgress = true; - ctx.streamingMessage = false; - ctx.stopRequested = false; - } - - // 显示等待提示 - if (typeof ctx.appendSystemAction === 'function') { - const taskList = data.tasks - .map((agent: any) => - t('utils.subAgentItem', { - id: agent.agent_id, - summary: agent.summary || t('utils.noDescription') - }) - ) - .join(t('utils.listSeparator')); - ctx.appendSystemAction(t('utils.waitingForSubAgents', { n: data.count, list: taskList })); - } - - ctx.$forceUpdate(); - }); - - // 聚焦文件更新 - ctx.socket.on('focused_files_update', (data) => { - ctx.focusSetFiles(data || {}); - // 聚焦文件变化时更新当前上下文Token(关键修复) - if (ctx.currentConversationId) { - setTimeout(() => { - ctx.updateCurrentContextTokens(); - }, 500); - } - }); - - // 文件树更新 - ctx.socket.on('file_tree_update', (data) => { - ctx.fileSetTreeFromResponse(data); - // 文件树变化时也可能影响上下文 - if (ctx.currentConversationId) { - setTimeout(() => { - ctx.updateCurrentContextTokens(); - }, 500); - } - }); - - // 系统消息 - ctx.socket.on('system_message', (data) => { - if (!data || !data.content) { - return; - } - // 用户级广播:其他对话的系统提示不应串进当前对话视图。 - if (data.conversation_id && data.conversation_id !== ctx.currentConversationId) { - socketLog('跳过system_message(对话不匹配)', data.conversation_id); - return; - } - ctx.appendSystemAction(data.content); - }); - - // 错误处理 - ctx.socket.on('error', (data) => { - const msg = data?.message || t('utils.unknownErrorOccurred'); - const code = data?.status_code; - const errType = data?.error_type; - const errCode = data?.error_code; - const dumpPath = data?.request_dump; - const baseUrl = data?.base_url; - const modelId = data?.model_id; - const conversationId = data?.conversation_id; - const taskId = data?.task_id; - const shouldRetry = Boolean(data?.retry); - const retryIn = Number(data?.retry_in) || 5; - const retryAttempt = Number(data?.attempt) || 1; - const retryMax = Number(data?.max_attempts) || retryAttempt; - const detailParts = [ - dumpPath ? t('utils.requestRecord', { path: dumpPath }) : '', - baseUrl ? t('utils.endpoint', { endpoint: baseUrl }) : '', - modelId ? t('utils.model', { model: modelId }) : '', - conversationId ? t('utils.conversationIdLabel', { id: conversationId }) : '', - taskId ? t('utils.taskIdLabel', { id: taskId }) : '' - ].filter(Boolean); - const detailText = detailParts.length ? `\n${detailParts.join('\n')}` : ''; - if (typeof ctx.uiPushToast === 'function') { - ctx.uiPushToast({ - title: code ? t('utils.apiErrorWithCode', { code }) : t('utils.apiError'), - message: `${errType ? `${errType}${errCode ? `(${errCode})` : ''}: ${msg}` : msg}${detailText}`, - type: 'error', - duration: 6000 - }); - if (shouldRetry) { - ctx.uiPushToast({ - title: t('appTasks.retrySoonTitle'), - message: t('appTasks.retryInSeconds', { - n: retryIn, - attempt: retryAttempt, - max: retryMax, - error: msg - }), - type: 'info', - duration: Math.max(retryIn, 1) * 1000 - }); - } - } - if (shouldRetry) { - // 错误后保持停止按钮态,用户可手动停止或等待自动重试; - // 重试间隔不算「等待 API 响应」,重试发起时后端会重新发 api_request_start - ctx.apiRequestPending = false; - ctx.stopRequested = false; - ctx.taskInProgress = true; - ctx.streamingMessage = true; - return; - } - - if (typeof ctx.appendSystemAction === 'function') { - ctx.appendSystemAction( - `${code ? `[API ${code}] ` : '[API] '}${errType ? `${errType}${errCode ? `(${errCode})` : ''}: ` : ''}${msg}${detailText}` - ); - } - - // 最后一次报错:恢复输入状态并清理提示动画/流式残留 - // (统一清理 currentStreamingType/activeThinkingId 等字段, - // 否则 avatarStatus 的 isThinking 永久为真,状态头像卡在「思考中」) - if (typeof ctx.chatClearStreamingResidualState === 'function') { - ctx.chatClearStreamingResidualState(); - } - ctx.apiRequestPending = false; - if (typeof ctx.chatClearThinkingLocks === 'function') { - ctx.chatClearThinkingLocks(); - } - ctx.streamingMessage = false; - ctx.stopRequested = false; - ctx.taskInProgress = false; - }); - - // 命令结果 - ctx.socket.on('command_result', (data) => { - if (data.command === 'clear' && data.success) { - ctx.logMessageState?.('socket:command_result-clear', { data }); - ctx.messages = []; - ctx.logMessageState?.('socket:command_result-cleared', { data }); - ctx.currentMessageIndex = -1; - ctx.chatClearExpandedBlocks(); - // 清除对话时重置Token统计 - ctx.resetTokenStatistics(); - } else if (data.command === 'status' && data.success) { - ctx.addSystemMessage( - t('utils.systemStatus', { data: JSON.stringify(data.data, null, 2) }) - ); - } else if (!data.success) { - ctx.addSystemMessage(t('utils.commandFailed', { message: data.message })); - } - }); - - // === 实时终端事件 === - ctx.socket.on('terminal_subscribed', (data) => { - if (data?.terminals) { - const sessions: Record = {}; - for (const t of data.terminals) { - sessions[t.name] = { - working_dir: t.working_dir, - shell: 'bash' - }; - } - ctx.setTerminalSessions(sessions); - if (data.terminals.length > 0 && !ctx.terminalActiveSession) { - ctx.setTerminalActiveSession(data.terminals[0].name); - } - } - }); - - ctx.socket.on('terminal_started', (data) => { - if (data?.session) { - const sessions = { ...ctx.terminalSessions }; - sessions[data.session] = { - working_dir: data.working_dir, - shell: 'bash' - }; - ctx.setTerminalSessions(sessions); - if (!ctx.terminalActiveSession) { - ctx.setTerminalActiveSession(data.session); - } - } - }); - - ctx.socket.on('terminal_list_update', (data) => { - if (data?.terminals) { - const sessions: Record = {}; - for (const t of data.terminals) { - sessions[t.name] = { - working_dir: t.working_dir, - shell: 'bash' - }; - } - ctx.setTerminalSessions(sessions); - if (!ctx.terminalActiveSession && data.terminals.length > 0) { - ctx.setTerminalActiveSession(data.terminals[0].name); - } - } - }); - - ctx.socket.on('terminal_closed', (data) => { - if (data?.session) { - const sessions = { ...ctx.terminalSessions }; - delete sessions[data.session]; - ctx.setTerminalSessions(sessions); - if (ctx.terminalActiveSession === data.session) { - const names = Object.keys(sessions); - ctx.setTerminalActiveSession(names.length > 0 ? names[0] : ''); - } - } - }); - - ctx.socket.on('terminal_reset', (data) => { - if (data?.session) { - const logs = { ...ctx.terminalSessionLogs }; - logs[data.session] = ''; - ctx.terminalSessionLogs = logs; - } - }); - - ctx.socket.on('terminal_output_history', (data) => { - if (data?.session && data?.output) { - const logs = { ...ctx.terminalSessionLogs }; - logs[data.session] = (logs[data.session] || '') + data.output; - ctx.terminalSessionLogs = logs; - } - }); - - ctx.socket.on('terminal_history', (data) => { - if (data?.session && data?.output) { - const logs = { ...ctx.terminalSessionLogs }; - // 历史数据前置(完整替换),因为这是完整的历史记录 - logs[data.session] = data.output; - ctx.terminalSessionLogs = logs; - // 标记已水合 - const hydrated = { ...ctx.terminalHydrated }; - hydrated[data.session] = true; - ctx.terminalHydrated = hydrated; - } - }); - - ctx.socket.on('terminal_output', (data) => { - if (data?.session && data?.data) { - const logs = { ...ctx.terminalSessionLogs }; - logs[data.session] = (logs[data.session] || '') + data.data; - if (logs[data.session] && logs[data.session].length > 400000) { - logs[data.session] = logs[data.session].slice(-400000); - } - ctx.terminalSessionLogs = logs; - } - }); - - ctx.socket.on('terminal_input', (data) => { - if (data?.session && data?.data) { - const formatted = `\x1b[1;32m➤ ${data.data}\x1b[0m`; - const logs = { ...ctx.terminalSessionLogs }; - logs[data.session] = (logs[data.session] || '') + formatted; - if (logs[data.session] && logs[data.session].length > 400000) { - logs[data.session] = logs[data.session].slice(-400000); - } - ctx.terminalSessionLogs = logs; - } - }); - } catch (error) { - console.error('Socket初始化失败:', error); - } -} diff --git a/static/src/stores/connection.ts b/static/src/stores/connection.ts index 52be0615..87b4112b 100644 --- a/static/src/stores/connection.ts +++ b/static/src/stores/connection.ts @@ -1,10 +1,8 @@ import { defineStore } from 'pinia'; -import type { Socket } from 'socket.io-client'; import type { ReasoningEffort } from './personalization'; interface ConnectionState { isConnected: boolean; - socket: Socket | null; stopRequested: boolean; projectPath: string; agentVersion: string; @@ -16,7 +14,6 @@ interface ConnectionState { export const useConnectionStore = defineStore('connection', { state: (): ConnectionState => ({ isConnected: false, - socket: null, stopRequested: false, projectPath: '', agentVersion: '', @@ -25,9 +22,6 @@ export const useConnectionStore = defineStore('connection', { reasoningEffort: null }), actions: { - setSocket(socket: Socket | null) { - this.socket = socket; - }, setConnected(value: boolean) { this.isConnected = value; },