refactor(server,web): remove Socket.IO entirely, unify on REST polling

- task event stream (_append_event) is now the sole realtime channel for web and CLI
- status snapshots: idle 5s polling of /api/status (was status_update push); operation initiator gets state from REST responses
- terminal panel: REST polling (list 5s, output 1.5s, prefix-matched incremental xterm writes)
- multi-tab passive sync deliberately degrades to polling-based perception
- delete socket_handlers/broadcast/useLegacySocket (2103-line dead file); extensions.py keeps only run_background
- remove socket token chain (/api/socket-token, prune/consume_socket_token, pending_socket_tokens)
- app.run(threaded=True) replaces socketio.run; reapers switched to plain threading
- drop flask-socketio/socket.io-client/websockets dependencies
- docs: AGENTS.md section 12.5 (replacement map + hard constraints), plus CLI rewrite companion doc updates

Co-authored-by: Astrion powered by Kimi-K3 <astrion-agent@users.noreply.github.com>
This commit is contained in:
JOJO 2026-09-11 17:28:47 +08:00
parent d97c1423b1
commit e5808ea5be
45 changed files with 292 additions and 3256 deletions

View File

@ -23,7 +23,7 @@
- `server/app.py`: 推荐的 Web 服务入口(封装并转发到 `server/app_legacy.py` - `server/app.py`: 推荐的 Web 服务入口(封装并转发到 `server/app_legacy.py`
- `web_server.py`: 兼容入口,已标记 deprecated但仍可启动 - `web_server.py`: 兼容入口,已标记 deprecated但仍可启动
- **后端核心目录** - **后端核心目录**
- `server/`: Flask 业务主线chat/task/status/context 已拆分为子包:`server/chat/`、`server/status/`、`server/tasks/`、`server/context/`(用户资源/身份/广播/个性化,原 `server/context.py` 已拆包并保留兼容 re-exportREST 任务轮询为主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` 提供 RuntimeServicecreate_task/cancel/guidance/queue/get_task_events契约见 `docs/runtime_contract.md` - `server/runtime/`: 公共任务入口2026-09 Gateway 化阶段二新增):`context.py` 定义 RuntimeContext 三层模型TrustedPrincipal/TaskParams/InternalDirectives`service.py` 提供 RuntimeServicecreate_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 子包) - `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` 已拆分为子包) - `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` 已拆分为子包;原入口文件保留为兼容入口) - `utils/`: API client、日志、上下文与对话工具等公共函数`api_client`、`tool_result_formatter`、`context_manager`、`conversation_manager` 已拆分为子包;原入口文件保留为兼容入口)
- **前端目录** - **前端目录**
- `static/src/`: Vue 3 + TS 前端 - `static/src/`: Vue 3 + TS 前端
- `cli/src/`: React 19 + Ink 6 + TypeScript CLI 前端(正在重写中 - `cli/src/`: opentuiZig 内核 + Yoga+ React 19 CLI 前端2026-09-11 起正式替代 Ink 版;旧 Ink 实现存档于 `cli/legacy-ink-src/` 仅作字段参考
- 监控动画相关核心文件: - 监控动画相关核心文件:
- `static/src/components/chat/monitor/MonitorDirector.ts` - `static/src/components/chat/monitor/MonitorDirector.ts`
- `static/src/stores/monitor.ts` - `static/src/stores/monitor.ts`
@ -117,12 +117,12 @@
- 开发监听(当前脚本是 build watch`npm run dev` - 开发监听(当前脚本是 build watch`npm run dev`
- Lint`npm run lint` - Lint`npm run lint`
### CLIReact / Ink ### CLIopentui / bun
- 安装依赖:`npm --prefix cli install` - 运行环境bun ≥1.3opentui FFInode ≥26.4 亦可,本机 node v24 不可用)
- 开发启动:`npm run cli` 或 `npm --prefix cli run dev` - 开发启动:`bun cli/src/main.tsx`(或 `npm --prefix cli run dev`
- 构建:`npm run cli:build` - 类型检查:`cd cli && ./node_modules/.bin/tsc --noEmit`
- 类型检查:`npm run cli:typecheck` - 可执行命令名:`astrion`(已装到 `/opt/homebrew/bin/astrion``cli/bin/astrion` symlinkbun wrapper 支持 symlink 解析;`--version`/`--help` 快速退出不启 TUI后续 `bun build --compile` 单文件分发)
- 可执行命令名(构建后):`agents` / `agents-cli` - 依赖安装:沙箱网络受限时不跑 npm/bun install`cli-redesign-demo/node_modules` 铺平(版本以 `cli/package.json` 为准)
## 3) 测试现状(不要再写过时命令) ## 3) 测试现状(不要再写过时命令)
@ -133,8 +133,8 @@
- `test_system_message.py` 依赖外部 `MOONSHOT_API_KEY` 与网络,不属于离线稳定 CI 用例。 - `test_system_message.py` 依赖外部 `MOONSHOT_API_KEY` 与网络,不属于离线稳定 CI 用例。
- 当前仓库未发现 `pytest.ini`/`pyproject.toml`/`tox.ini`;不要默认要求 `pytest` 作为唯一入口(仅作为冒烟测试的便捷运行器)。 - 当前仓库未发现 `pytest.ini`/`pyproject.toml`/`tox.ini`;不要默认要求 `pytest` 作为唯一入口(仅作为冒烟测试的便捷运行器)。
- CLI 当前最小可复现验证: - CLI 当前最小可复现验证:
- `npm --prefix cli run typecheck` - `cd cli && ./node_modules/.bin/tsc --noEmit`
- `npm --prefix cli run build` - 无头冒烟:`testRender` 抓帧脚本(写完即用即删,参考 cli-redesign-demo 的验证模式; gateway 指向 127.0.0.1:9 不碰真实服务)
- 若改动 `server/chat/`、`server/status/`、`server/tasks/` 等后端接口适配,补充: - 若改动 `server/chat/`、`server/status/`、`server/tasks/` 等后端接口适配,补充:
- `python3 -m py_compile server/chat/*.py server/status/*.py server/tasks/*.py` - `python3 -m py_compile server/chat/*.py server/status/*.py server/tasks/*.py`
- `python -m pytest test/test_server_refactor_smoke.py -q` - `python -m pytest test/test_server_refactor_smoke.py -q`
@ -146,7 +146,7 @@
- **文件编辑方式**:修改文件时优先使用 `apply_patch` 或其他原生文件编辑工具;尽量不要用 `bash`/`python` 脚本批量改文件,除非原生工具明显不适合。 - **文件编辑方式**:修改文件时优先使用 `apply_patch` 或其他原生文件编辑工具;尽量不要用 `bash`/`python` 脚本批量改文件,除非原生工具明显不适合。
- **后端改动优先级**:先改 `modules/`、`server/` 内对应模块,最后才动入口。 - **后端改动优先级**:先改 `modules/`、`server/` 内对应模块,最后才动入口。
- **前端改动优先级**:按 `static/src` 现有分层改(`app/`、`stores/`、`components/`、`composables/`)。 - **前端改动优先级**:按 `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 动画/事件联动时,至少同步检查: - 涉及 monitor 动画/事件联动时,至少同步检查:
- `MonitorDirector.ts`(动画与场景执行) - `MonitorDirector.ts`(动画与场景执行)
- `stores/monitor.ts`(事件队列与状态机) - `stores/monitor.ts`(事件队列与状态机)
@ -173,12 +173,14 @@
- 运行根目录前端构建时,默认使用 `npm run build --silent 2>&1 | tail -n 5` - 运行根目录前端构建时,默认使用 `npm run build --silent 2>&1 | tail -n 5`
- 构建/安装/Lint 类命令(如 `npm run build`、`npm install`、`npm run lint`)因沙箱权限失败时(如报 `EPERM` / `Operation not permitted` / 沙箱拒绝写入),直接向用户说明失败原因并申请权限或调整路径授权,禁止尝试任何绕过沙箱的做法(如换执行方式规避限制、向其他路径写入等)。 - 构建/安装/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 全部走 Gateway host Bearer 通道**`docs/runtime_protocol.md` §6token 读 `~/.astrion/astrion/host/data/host_api_token`,请求带 `Authorization: Bearer`**禁止** Web 会话通道Cookie/CSRF/host-login。工作区经 `X-Astrion-Workspace-Id` 头绑定server/gateway_auth.py 解析)。
- 启动 CLI 时应清屏、连接本地服务、创建新会话,并将输入区固定在底部。 - **启动指令 `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 压测或长时间模拟输入,以免刷屏占满上下文。 - 不要在未获得用户要求的情况下运行交互式 TUI 压测或长时间模拟输入,以免刷屏占满上下文。
## 5.5) 前端设计风格统一规范(强制) ## 5.5) 前端设计风格统一规范(强制)
@ -238,7 +240,7 @@
- **key 奇偶强校验**en-US 聚合器用 `DeepString<typeof zhCN>` 约束en 缺/多 key 直接 tsc 报错;新增命名空间须在 `zh-CN.ts` / `en-US.ts` 同步注册。 - **key 奇偶强校验**en-US 聚合器用 `DeepString<typeof zhCN>` 约束en 缺/多 key 直接 tsc 报错;新增命名空间须在 `zh-CN.ts` / `en-US.ts` 同步注册。
- **防回退栏杆**`npm run lint` 先跑 `scripts/i18n_audit.mjs`(剥离注释后查裸中文,独立命令 `lint:text`);存量文件列在 `scripts/i18n_baseline.txt`**迁移完一个文件就删一行**,删除后该文件永久受栏杆保护。 - **防回退栏杆**`npm run lint` 先跑 `scripts/i18n_audit.mjs`(剥离注释后查裸中文,独立命令 `lint:text`);存量文件列在 `scripts/i18n_baseline.txt`**迁移完一个文件就删一行**,删除后该文件永久受栏杆保护。
- **语言切换**:个人空间 → 外观 → 界面语言;默认 zh-CN不跟随浏览器持久化 key `agents_ui_locale` - **语言切换**:个人空间 → 外观 → 界面语言;默认 zh-CN不跟随浏览器持久化 key `agents_ui_locale`
- **边界**后端下发文字API error、通知、工具结果摘要不做多语言前端原样显示CLI 暂不纳入 - **边界**后端下发文字API error、通知、工具结果摘要不做多语言前端原样显示CLI 独立成体系(`cli/src/i18n/`,读 OS 语言设置,不跟随 web 个人空间)
## 6) Git 工作流(开发 + Review ## 6) Git 工作流(开发 + Review
@ -460,7 +462,7 @@ AI 执行以下流程时,每一步都要向用户说明在做什么:
### 11.2 子智能体执行机制 ### 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 起)**`_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 事件交错场景)。 - **工具「正在调用」进度事件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 ## 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 单写者不变量(核心约束) ### 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→恢复旧值」正是此前标志卡死的原因。 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 日志应按事故排查,而不是视为正常。 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/<name>/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_loading2026-09 新增) ## 13) 工具动态加载tool_loading2026-09 新增)
> 设计文档:`docs/dynamic_tool_loading_plan.md`。注册表与状态辅助唯一权威:`core/tool_loading.py`。 > 设计文档:`docs/dynamic_tool_loading_plan.md`。注册表与状态辅助唯一权威:`core/tool_loading.py`。

View File

@ -687,7 +687,7 @@ class WebTerminal(MainTerminal):
return "思考模式" if self.thinking_mode else "快速模式" return "思考模式" if self.thinking_mode else "快速模式"
def broadcast(self, event_type: str, data: Dict): def broadcast(self, event_type: str, data: Dict):
"""广播事件到WebSocket""" """经 message_callback 推送事件Socket.IO 已移除,回调恒为 None 时 no-op"""
if self.message_callback: if self.message_callback:
payload = dict(data or {}) payload = dict(data or {})
payload.setdefault('conversation_id', self.context_manager.current_conversation_id) payload.setdefault('conversation_id', self.context_manager.current_conversation_id)

View File

@ -46,7 +46,6 @@
"remark-parse": "^11.0.0", "remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2", "remark-rehype": "^11.1.2",
"sherpa-onnx": "^1.13.2", "sherpa-onnx": "^1.13.2",
"socket.io-client": "^4.7.5",
"unified": "^11.0.5", "unified": "^11.0.5",
"virtua": "^0.49.3", "virtua": "^0.49.3",
"vue": "^3.4.15", "vue": "^3.4.15",

View File

@ -1,12 +1,10 @@
# 运行时直接依赖(人读、宽松,不锁版本,便于开发期升级)。 # 运行时直接依赖(人读、宽松,不锁版本,便于开发期升级)。
# 可复现安装 / release 打包请用 requirements.lock.txt精确版本 # 可复现安装 / release 打包请用 requirements.lock.txt精确版本
flask flask
flask-socketio
flask-cors flask-cors
werkzeug werkzeug
httpx[http2] httpx[http2]
openai openai
cryptography cryptography
pillow pillow
websockets
pyyaml>=6.0 pyyaml>=6.0

View File

@ -5,7 +5,7 @@
""" """
import importlib 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): def __getattr__(name):

View File

@ -3,7 +3,6 @@ import argparse
from .app_legacy import ( from .app_legacy import (
app, app,
socketio,
run_server as _run_server, run_server as _run_server,
parse_arguments as _parse_arguments, parse_arguments as _parse_arguments,
initialize_system, initialize_system,
@ -34,7 +33,6 @@ def main():
__all__ = [ __all__ = [
"app", "app",
"socketio",
"run_server", "run_server",
"parse_arguments", "parse_arguments",
"initialize_system", "initialize_system",

View File

@ -11,7 +11,6 @@ import re
import threading import threading
from typing import Dict, List, Optional, Callable, Any, Tuple 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 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 flask_cors import CORS
from werkzeug.exceptions import RequestEntityTooLarge from werkzeug.exceptions import RequestEntityTooLarge
from pathlib import Path 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.workflow_runtime_api import workflow_runtime_bp
from server.conversation_bootstrap import conversation_bootstrap_bp from server.conversation_bootstrap import conversation_bootstrap_bp
from server.gateway_api import gateway_bp from server.gateway_api import gateway_bp
from server.socket_handlers import socketio
from server.security import attach_security_hooks from server.security import attach_security_hooks
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from werkzeug.routing import BaseConverter from werkzeug.routing import BaseConverter
@ -201,9 +199,6 @@ if not ENABLE_VERBOSE_CONSOLE:
# 抑制 Flask/Werkzeug 访问日志,只保留 brief_log 输出 # 抑制 Flask/Werkzeug 访问日志,只保留 brief_log 输出
logging.getLogger('werkzeug').setLevel(logging.ERROR) logging.getLogger('werkzeug').setLevel(logging.ERROR)
logging.getLogger('werkzeug').disabled = True 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 或前端提示处理) # 静音子智能体模块错误日志(交由 brief_log 或前端提示处理)
sub_agent_logger = logging.getLogger('modules.sub_agent.manager') sub_agent_logger = logging.getLogger('modules.sub_agent.manager')
sub_agent_logger.setLevel(logging.CRITICAL) 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 app.config['SESSION_COOKIE_HTTPONLY'] = True
CORS(app) CORS(app)
socketio.init_app(app, cors_allowed_origins='*', async_mode='threading', logger=False, engineio_logger=False)
class EndpointFilter(logging.Filter): 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 MONITOR_MEMORY_ENTRY_LIMIT = state.MONITOR_MEMORY_ENTRY_LIMIT
RATE_LIMIT_BUCKETS = state.RATE_LIMIT_BUCKETS RATE_LIMIT_BUCKETS = state.RATE_LIMIT_BUCKETS
FAILURE_TRACKERS = state.FAILURE_TRACKERS FAILURE_TRACKERS = state.FAILURE_TRACKERS
pending_socket_tokens = state.pending_socket_tokens
usage_trackers = state.usage_trackers usage_trackers = state.usage_trackers
MONITOR_SNAPSHOT_CACHE = state.MONITOR_SNAPSHOT_CACHE 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 CSRF_EXEMPT_PATHS = state.CSRF_EXEMPT_PATHS
FAILED_LOGIN_LIMIT = state.FAILED_LOGIN_LIMIT FAILED_LOGIN_LIMIT = state.FAILED_LOGIN_LIMIT
FAILED_LOGIN_LOCK_SECONDS = state.FAILED_LOGIN_LOCK_SECONDS 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 = state.PROJECT_STORAGE_CACHE
PROJECT_STORAGE_CACHE_TTL_SECONDS = state.PROJECT_STORAGE_CACHE_TTL_SECONDS PROJECT_STORAGE_CACHE_TTL_SECONDS = state.PROJECT_STORAGE_CACHE_TTL_SECONDS
USER_IDLE_TIMEOUT_SECONDS = state.USER_IDLE_TIMEOUT_SECONDS USER_IDLE_TIMEOUT_SECONDS = state.USER_IDLE_TIMEOUT_SECONDS
@ -457,7 +448,7 @@ def start_background_jobs():
return return
_idle_reaper_started = True _idle_reaper_started = True
_load_last_active_cache() _load_last_active_cache()
socketio.start_background_task(idle_reaper_loop) threading.Thread(target=idle_reaper_loop, daemon=True).start()
try: try:
from .context import start_conversation_terminal_reaper from .context import start_conversation_terminal_reaper
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) _title_debug_log("title_save_failed", conversation_id=conversation_id, safe_title=safe_title)
return return
_title_debug_log("title_save_success", conversation_id=conversation_id, safe_title=safe_title) _title_debug_log("title_save_success", conversation_id=conversation_id, safe_title=safe_title)
try: # 标题更新推送已随 WebSocket 移除:标题变更已写入任务事件流
socketio.emit('conversation_changed', { # chat_flow_helpers 的 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)
try: try:
asyncio.run(_runner()) asyncio.run(_runner())
@ -744,31 +725,6 @@ def validate_csrf_request() -> bool:
return False 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: 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) header = tr("legacy.tool_result_header", tool=tool_name)
@ -1047,27 +1003,6 @@ def detect_tool_failure(result_data: Any) -> bool:
return True return True
return False 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 # 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']} 预设子智能体角色同步完成") print(f"{OUTPUT_FORMATS['success']} 预设子智能体角色同步完成")
except Exception as _e: except Exception as _e:
print(f"{OUTPUT_FORMATS['warning']} 预设子智能体角色同步失败: {_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" _mode_label = "宿主机模式(单用户)" if TERMINAL_SANDBOX_MODE == "host" else "多用户模式Web"
print(f"{OUTPUT_FORMATS['success']} Web 系统初始化完成({_mode_label}") 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}" app.config['SESSION_COOKIE_NAME'] = f"agents_session_{port}"
initialize_system(path, thinking_mode) initialize_system(path, thinking_mode)
start_background_jobs() start_background_jobs()
socketio.run( # Socket.IO 已移除,直接使用 Werkzeug 内建服务器threaded 以支持并发轮询)
app, app.run(
host=WEB_SERVER_HOST, host=WEB_SERVER_HOST,
port=port, port=port,
debug=debug, debug=debug,
use_reloader=debug, use_reloader=debug,
allow_unsafe_werkzeug=True threaded=True,
) )

View File

@ -351,9 +351,6 @@ def logout():
state.container_manager.release_container(key, reason="logout") state.container_manager.release_container(key, reason="logout")
except Exception: except Exception:
pass 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()}") auth_debug_log(f"[auth_debug] {request.method} /logout after_clear session={_session_debug_snapshot()}")
if request.method == 'GET': if request.method == 'GET':
resp = make_response(redirect('/login?logged_out=1')) resp = make_response(redirect('/login?logged_out=1'))

View File

@ -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.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.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.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.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.runtime import runtime_service
from server.extensions import socketio
from server.monitor import get_cached_monitor_snapshot from server.monitor import get_cached_monitor_snapshot
from modules.i18n import tr from modules.i18n import tr

View File

@ -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.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.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.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.state import tool_approval_manager, user_question_manager
from server.extensions import socketio
from server.monitor import get_cached_monitor_snapshot from server.monitor import get_cached_monitor_snapshot
from server.utils_common import sanitize_filename_preserve_unicode from server.utils_common import sanitize_filename_preserve_unicode

View File

@ -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.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.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.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.state import tool_approval_manager, user_question_manager
from server.extensions import socketio
from server.monitor import get_cached_monitor_snapshot from server.monitor import get_cached_monitor_snapshot
from modules.i18n import tr from modules.i18n import tr
@ -147,9 +146,8 @@ def tool_settings(terminal: WebTerminal, workspace: UserWorkspace, username: str
}), 403 }), 403
terminal.set_tool_category_enabled(category, enabled) terminal.set_tool_category_enabled(category, enabled)
snapshot = terminal.get_tool_settings_snapshot() snapshot = terminal.get_tool_settings_snapshot()
socketio.emit('tool_settings_updated', { # tool_settings 变更不再经 WebSocket 广播:发起方直接使用本响应中的
'categories': snapshot # categories其他标签页在下次操作或 status 轮询时同步。
}, room=f"user_{username}")
return jsonify({ return jsonify({
"success": True, "success": True,
"categories": snapshot "categories": snapshot

View File

@ -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.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.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.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.state import tool_approval_manager, user_question_manager
from server.extensions import socketio
from server.monitor import get_cached_monitor_snapshot from server.monitor import get_cached_monitor_snapshot
import os import os
import re import re
@ -189,8 +188,6 @@ def update_permission_mode(terminal: WebTerminal, workspace: UserWorkspace, user
"error": str(exc), "error": str(exc),
"message": tr("chat_permission.update_failed") "message": tr("chat_permission.update_failed")
}), 500 }), 500
status = terminal.get_status()
socketio.emit('status_update', status, room=f"user_{username}")
return jsonify({ return jsonify({
"success": True, "success": True,
"mode": target_mode, "mode": target_mode,
@ -221,8 +218,6 @@ def update_permission_mode(terminal: WebTerminal, workspace: UserWorkspace, user
}), 500 }), 500
session["permission_mode"] = applied_mode session["permission_mode"] = applied_mode
status = terminal.get_status()
socketio.emit('status_update', status, room=f"user_{username}")
return jsonify({ return jsonify({
"success": True, "success": True,
"mode": applied_mode, "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) _sync_workspace_terminal_mode(username, workspace, "execution_mode", target_mode)
except Exception as exc: except Exception as exc:
return jsonify({"success": False, "error": str(exc), "message": tr("chat_permission.execution_update_failed")}), 500 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({ return jsonify({
"success": True, "success": True,
"state": { "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)) _sync_workspace_terminal_mode(username, workspace, "execution_mode", state.get("mode", target_mode))
except Exception as exc: except Exception as exc:
return jsonify({"success": False, "error": str(exc), "message": tr("chat_permission.execution_update_failed")}), 500 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({ return jsonify({
"success": True, "success": True,
"state": state, "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) _sync_workspace_terminal_mode(username, workspace, "network_permission", target_mode)
except Exception as exc: except Exception as exc:
return jsonify({"success": False, "error": str(exc), "message": tr("chat_permission.network_update_failed")}), 500 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({ return jsonify({
"success": True, "success": True,
"mode": target_mode, "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) _sync_workspace_terminal_mode(username, workspace, "network_permission", applied)
except Exception as exc: except Exception as exc:
return jsonify({"success": False, "error": str(exc), "message": tr("chat_permission.network_update_failed")}), 500 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({ return jsonify({
"success": True, "success": True,
"mode": applied, "mode": applied,
@ -490,8 +477,6 @@ def update_work_mode(terminal: WebTerminal, workspace: UserWorkspace, username:
except Exception: except Exception:
pass pass
status = terminal.get_status()
socketio.emit('status_update', status, room=f"user_{username}")
return jsonify({ return jsonify({
"success": True, "success": True,
"mode": result.get("mode") or target_mode, "mode": result.get("mode") or target_mode,

View File

@ -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.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.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.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.state import tool_approval_manager, user_question_manager
from server.extensions import socketio
from server.monitor import get_cached_monitor_snapshot from server.monitor import get_cached_monitor_snapshot
from modules.i18n import tr from modules.i18n import tr
@ -104,9 +103,6 @@ def update_thinking_mode(terminal: WebTerminal, workspace: UserWorkspace, userna
except Exception as exc: except Exception as exc:
logger.error(f"[API] 保存思考模式到对话失败: {exc}") logger.error(f"[API] 保存思考模式到对话失败: {exc}")
status = terminal.get_status()
socketio.emit('status_update', status, room=f"user_{username}")
return jsonify({ return jsonify({
"success": True, "success": True,
"data": { "data": {
@ -170,9 +166,6 @@ def update_reasoning_effort(terminal: WebTerminal, workspace: UserWorkspace, use
except Exception as exc: except Exception as exc:
logger.error(f"[API] 保存推理强度到对话失败: {exc}") logger.error(f"[API] 保存推理强度到对话失败: {exc}")
status = terminal.get_status()
socketio.emit('status_update', status, room=f"user_{username}")
return jsonify({ return jsonify({
"success": True, "success": True,
"data": { "data": {
@ -261,9 +254,6 @@ def update_model(terminal: WebTerminal, workspace: UserWorkspace, username: str)
f"与 terminal 当前对话 {current_cid} 不一致" f"与 terminal 当前对话 {current_cid} 不一致"
) )
status = terminal.get_status()
socketio.emit('status_update', status, room=f"user_{username}")
return jsonify({ return jsonify({
"success": True, "success": True,
"data": { "data": {
@ -377,11 +367,6 @@ def update_personalization_settings(terminal: WebTerminal, workspace: UserWorksp
) )
except Exception as meta_exc: except Exception as meta_exc:
debug_log(f"应用个性化偏好失败: 同步对话元数据异常 {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: except Exception as exc:
debug_log(f"应用个性化偏好失败: {exc}") debug_log(f"应用个性化偏好失败: {exc}")
config_out = dict(config) config_out = dict(config)

View File

@ -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.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.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.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.state import tool_approval_manager, user_question_manager
from server.extensions import socketio
from server.monitor import get_cached_monitor_snapshot from server.monitor import get_cached_monitor_snapshot
from modules.i18n import tr from modules.i18n import tr
@ -59,24 +58,29 @@ def get_terminals(terminal: WebTerminal, workspace: UserWorkspace, username: str
else: else:
return jsonify({"sessions": [], "active": None, "total": 0}) return jsonify({"sessions": [], "active": None, "total": 0})
@chat_bp.route('/api/socket-token', methods=['GET']) @chat_bp.route('/api/terminals/<session_name>/output')
@api_login_required @api_login_required
def issue_socket_token(): @with_terminal
"""生成一次性 WebSocket token供握手阶段使用。""" def get_terminal_output_rest(terminal: WebTerminal, workspace: UserWorkspace, username: str, session_name: str):
username = get_current_username() """获取终端输出历史TerminalPanel 轮询端点,替代原 WebSocket get_terminal_output"""
prune_socket_tokens() policy = resolve_admin_policy(get_current_user_record())
now = time.time() if policy.get("ui_blocks", {}).get("block_realtime_terminal"):
for token_value, meta in list(pending_socket_tokens.items()): return jsonify({"success": False, "error": tr("chat_terminal.realtime_terminal_admin_disabled")}), 403
if meta.get("username") == username: if not terminal.terminal_manager:
pending_socket_tokens.pop(token_value, None) return jsonify({"success": False, "error": "Terminal system not initialized"}), 400
token_value = secrets.token_urlsafe(32) try:
pending_socket_tokens[token_value] = { lines = int(request.args.get("lines", 100))
"username": username, except (TypeError, ValueError):
"expires_at": now + SOCKET_TOKEN_TTL_SECONDS, lines = 100
"fingerprint": (request.headers.get('User-Agent') or '')[:128], lines = max(1, min(lines, 2000))
} result = terminal.terminal_manager.get_terminal_output(session_name, lines)
return jsonify({ if result.get("success"):
"success": True, return jsonify({
"token": token_value, "success": True,
"expires_in": SOCKET_TOKEN_TTL_SECONDS "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

View File

@ -66,10 +66,10 @@ from .utils_common import (
CHUNK_FRONTEND_LOG_FILE, CHUNK_FRONTEND_LOG_FILE,
STREAMING_DEBUG_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 .main_task_gate import acquire_adopted_main_task_gate, release_main_task_gate
from .monitor import cache_monitor_snapshot, get_cached_monitor_snapshot from .monitor import cache_monitor_snapshot, get_cached_monitor_snapshot
from .extensions import socketio, run_background from .extensions import run_background
from .state import ( from .state import (
MONITOR_FILE_TOOLS, MONITOR_FILE_TOOLS,
MONITOR_MEMORY_TOOLS, MONITOR_MEMORY_TOOLS,
@ -77,7 +77,6 @@ from .state import (
MONITOR_MEMORY_ENTRY_LIMIT, MONITOR_MEMORY_ENTRY_LIMIT,
RATE_LIMIT_BUCKETS, RATE_LIMIT_BUCKETS,
FAILURE_TRACKERS, FAILURE_TRACKERS,
pending_socket_tokens,
usage_trackers, usage_trackers,
MONITOR_SNAPSHOT_CACHE, MONITOR_SNAPSHOT_CACHE,
MONITOR_SNAPSHOT_CACHE_LIMIT, MONITOR_SNAPSHOT_CACHE_LIMIT,
@ -118,7 +117,6 @@ def generate_conversation_title_background(web_terminal: WebTerminal, conversati
conversation_id=conversation_id, conversation_id=conversation_id,
user_message=user_message, user_message=user_message,
username=username, username=username,
socketio_instance=socketio,
title_prompt_path=TITLE_PROMPT_PATH, title_prompt_path=TITLE_PROMPT_PATH,
debug_logger=debug_log, debug_logger=debug_log,
title_model=title_model, title_model=title_model,

View File

@ -119,7 +119,6 @@ def generate_conversation_title_background(
conversation_id: str, conversation_id: str,
user_message: str, user_message: str,
username: str, username: str,
socketio_instance,
title_prompt_path, title_prompt_path,
debug_logger, debug_logger,
title_model: str = "", title_model: str = "",
@ -186,21 +185,6 @@ def generate_conversation_title_background(
debug_logger(f"[TitleGen] 添加任务事件失败: {exc}") debug_logger(f"[TitleGen] 添加任务事件失败: {exc}")
_title_debug_log("title_task_event_exception", error=str(exc), conversation_id=conversation_id, username=username) _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: try:
asyncio.run(_runner()) asyncio.run(_runner())
except Exception as exc: except Exception as exc:

View File

@ -58,9 +58,8 @@ from .utils_common import (
CHUNK_FRONTEND_LOG_FILE, CHUNK_FRONTEND_LOG_FILE,
STREAMING_DEBUG_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 .monitor import cache_monitor_snapshot, get_cached_monitor_snapshot
from .extensions import socketio
from .state import ( from .state import (
MONITOR_FILE_TOOLS, MONITOR_FILE_TOOLS,
MONITOR_MEMORY_TOOLS, MONITOR_MEMORY_TOOLS,
@ -68,7 +67,6 @@ from .state import (
MONITOR_MEMORY_ENTRY_LIMIT, MONITOR_MEMORY_ENTRY_LIMIT,
RATE_LIMIT_BUCKETS, RATE_LIMIT_BUCKETS,
FAILURE_TRACKERS, FAILURE_TRACKERS,
pending_socket_tokens,
usage_trackers, usage_trackers,
MONITOR_SNAPSHOT_CACHE, MONITOR_SNAPSHOT_CACHE,
MONITOR_SNAPSHOT_CACHE_LIMIT, MONITOR_SNAPSHOT_CACHE_LIMIT,
@ -110,7 +108,6 @@ def generate_conversation_title_background(web_terminal: WebTerminal, conversati
conversation_id=conversation_id, conversation_id=conversation_id,
user_message=user_message, user_message=user_message,
username=username, username=username,
socketio_instance=socketio,
title_prompt_path=TITLE_PROMPT_PATH, title_prompt_path=TITLE_PROMPT_PATH,
debug_logger=debug_log, debug_logger=debug_log,
title_model=title_model, title_model=title_model,

View File

@ -67,10 +67,10 @@ from .utils_common import (
CHUNK_FRONTEND_LOG_FILE, CHUNK_FRONTEND_LOG_FILE,
STREAMING_DEBUG_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 .main_task_gate import try_acquire_main_task_gate, release_main_task_gate
from .monitor import cache_monitor_snapshot, get_cached_monitor_snapshot 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 ( from .state import (
MONITOR_FILE_TOOLS, MONITOR_FILE_TOOLS,
MONITOR_MEMORY_TOOLS, MONITOR_MEMORY_TOOLS,
@ -78,7 +78,6 @@ from .state import (
MONITOR_MEMORY_ENTRY_LIMIT, MONITOR_MEMORY_ENTRY_LIMIT,
RATE_LIMIT_BUCKETS, RATE_LIMIT_BUCKETS,
FAILURE_TRACKERS, FAILURE_TRACKERS,
pending_socket_tokens,
usage_trackers, usage_trackers,
MONITOR_SNAPSHOT_CACHE, MONITOR_SNAPSHOT_CACHE,
MONITOR_SNAPSHOT_CACHE_LIMIT, MONITOR_SNAPSHOT_CACHE_LIMIT,
@ -477,7 +476,7 @@ def _persist_and_echo_preceding_notice(
message: str, message: str,
payload: Dict[str, Any], payload: Dict[str, Any],
) -> None: ) -> None:
"""预写一条「前置完成通知」:写入对话历史 + socketio 回显(不触发新一轮工作)。 """预写一条「前置完成通知」:写入对话历史 + 任务事件流回显(不触发新一轮工作)。
用于通知池一次取出多条时 N-1 条随同一后续任务一起呈现 用于通知池一次取出多条时 N-1 条随同一后续任务一起呈现
这些通知必须落历史否则模型看不到刷新后也会丢失 这些通知必须落历史否则模型看不到刷新后也会丢失
@ -503,8 +502,8 @@ def _persist_and_echo_preceding_notice(
cm.add_conversation("user", message, metadata=metadata) cm.add_conversation("user", message, metadata=metadata)
except Exception as exc: except Exception as exc:
debug_log(f"[CompletionNotice] 前置通知写入历史失败: {exc}") debug_log(f"[CompletionNotice] 前置通知写入历史失败: {exc}")
# socketio 回显(在线客户端即时可见);轮询客户端由后续任务事件流回放, # 回显经 sender 通道(任务内=任务事件流poll 轮询器=no-op
# 前端按消息内容 dedup两条通道不会双显。 # 轮询客户端统一由后续任务事件流回放,前端按消息内容 dedup 不会双显。
try: try:
echo_payload = { echo_payload = {
"message": message, "message": message,
@ -553,8 +552,8 @@ async def _dispatch_completion_user_notice(
if message_source not in _VALID_USER_MESSAGE_SOURCES: if message_source not in _VALID_USER_MESSAGE_SOURCES:
message_source = "user" message_source = "user"
# 先把前置通知写入历史并 socketio 回显(在线客户端即时可见)。 # 先把前置通知写入历史并经 sender 回显Socket.IO 移除后无即时推送,
# 轮询客户端则通过后续任务事件流回放(见 _run_chat_task 的 preceding_user_notices 注入)。 # 统一通过后续任务事件流回放,见 _run_chat_task 的 preceding_user_notices 注入)。
for item in preceding_notices: for item in preceding_notices:
_persist_and_echo_preceding_notice( _persist_and_echo_preceding_notice(
web_terminal=web_terminal, web_terminal=web_terminal,
@ -576,7 +575,7 @@ async def _dispatch_completion_user_notice(
**ui_defaults, **ui_defaults,
"timestamp": datetime.now().isoformat(), "timestamp": datetime.now().isoformat(),
} }
# 轮询客户端通过后续任务事件流回放前置通知(在线客户端已由上面的 socketio 回显覆盖) # 轮询客户端通过后续任务事件流回放前置通知
preceding_list = None preceding_list = None
if preceding_notices: if preceding_notices:
preceding_list = [ preceding_list = [
@ -965,14 +964,13 @@ async def poll_completion_notifications(*, web_terminal, workspace, conversation
把子智能体完成通知与后台 run_command 完成通知合并到同一条链路 把子智能体完成通知与后台 run_command 完成通知合并到同一条链路
- 每轮把两路所有待通知项一次性取出池化按时间排序 - 每轮把两路所有待通知项一次性取出池化按时间排序
- 这一批里前 N-1 条作为预写通知随同一个后续任务一起注入写历史 + 事件流 + socketio - 这一批里前 N-1 条作为预写通知随同一个后续任务一起注入写历史 + 事件流
不各自触发新一轮工作 不各自触发新一轮工作
- 仅最后 1 条作为后续 chat task 的触发消息启动一次工作 停止循环 - 仅最后 1 条作为后续 chat task 的触发消息启动一次工作 停止循环
- 该后续任务结束后回到 handle_task_with_sender 结尾重新 spawn 本轮询器继续消费剩余通知 - 该后续任务结束后回到 handle_task_with_sender 结尾重新 spawn 本轮询器继续消费剩余通知
这样多个后台任务同时完成时会被合并成一次而非每条都触发一轮停止-再工作 这样多个后台任务同时完成时会被合并成一次而非每条都触发一轮停止-再工作
""" """
from .extensions import emit_event
sub_manager = getattr(web_terminal, "sub_agent_manager", None) sub_manager = getattr(web_terminal, "sub_agent_manager", None)
bg_manager = getattr(web_terminal, "background_command_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() start_wait = time.time()
def sender(event_type, data): def sender(event_type, data):
try: # WebSocket 推送已移除事件均经任务事件流_append_event由轮询消费
emit_event(event_type, data, room=f"user_{username}") # 此 sender 仅保留签名以兼容既有调用点,不再做任何实时推送。
except Exception: return
pass
loop_count = 0 loop_count = 0
ma_debug("poll_completion_notifications_start", conversation_id=conversation_id) 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 完全分离避免多智能体消息和传统后台 poll_completion_notifications 完全分离避免多智能体消息和传统后台
通知竞争 task_manager 的单工作区互斥 通知竞争 task_manager 的单工作区互斥
""" """
from .extensions import emit_event
max_wait_time = 3600 max_wait_time = 3600
start_wait = time.time() start_wait = time.time()
def sender(event_type, data): def sender(event_type, data):
try: # WebSocket 推送已移除事件均经任务事件流_append_event由轮询消费。
emit_event(event_type, data, room=f"user_{username}") return
except Exception:
pass
import threading as _threading import threading as _threading
ma_debug( ma_debug(
@ -1258,7 +1252,7 @@ async def _dispatch_multi_agent_idle_messages(
): ):
"""主智能体空闲时,把多智能体 pending 消息作为新一轮用户消息分发。 """主智能体空闲时,把多智能体 pending 消息作为新一轮用户消息分发。
所有消息先持久化到对话历史 N-1 条作为前置通知通过 socketio 推送 所有消息先持久化到对话历史 N-1 条作为前置通知写入任务事件流
最后一条创建 task_type="notice" 任务触发 Team Leader 新一轮工作 最后一条创建 task_type="notice" 任务触发 Team Leader 新一轮工作
""" """
ma_debug( ma_debug(
@ -1341,8 +1335,8 @@ async def _dispatch_multi_agent_idle_messages(
error=str(exc), error=str(exc),
) )
# 2) 前置通知的 socketio emit 已在上述注入 inject_multi_agent_master_message 内 # 2) 前置通知的回显已在上述注入 inject_multi_agent_master_message 内
# 同时完成,避免在多智能体消息场景重复发送相同事件。 # 同时完成(经 sender/事件流),避免在多智能体消息场景重复发送相同事件。
# 3) 最后一条触发新一轮工作 # 3) 最后一条触发新一轮工作
ui_defaults = dict(_user_message_ui_defaults(message_source, auto_user_message_event=True)) 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)) quota_allowed, quota_info = web_terminal.record_model_call(bool(thinking_expected))
if not quota_allowed: if not quota_allowed:
quota_type = 'thinking' if thinking_expected else 'fast' 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', { sender('quota_exceeded', {
'type': quota_type, 'type': quota_type,
'reset_at': quota_info.get('reset_at') 'reset_at': quota_info.get('reset_at')

View File

@ -533,11 +533,6 @@ async def _handle_submit_plan(*, web_terminal, arguments: Dict[str, Any], sender
}) })
except Exception: except Exception:
pass 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") switch_note = tr("tool_loop.plan_switch_note_ok")
except Exception as exc: except Exception as exc:
switch_note = tr("tool_loop.plan_switch_note_failed", error=exc) switch_note = tr("tool_loop.plan_switch_note_failed", error=exc)

View File

@ -12,14 +12,10 @@ _SYMBOL_MODULE = {
"RuntimeIdentity": "server.context.identity", "RuntimeIdentity": "server.context.identity",
"_resolve_user_role": "server.context.identity", "_resolve_user_role": "server.context.identity",
# broadcast # broadcast
"make_terminal_callback": "server.context.broadcast",
"attach_user_broadcast": "server.context.broadcast",
"_wrap_callback_with_conversation_id": "server.context.broadcast",
# personalization # personalization
"_apply_workspace_personalization_preferences": "server.context.personalization", "_apply_workspace_personalization_preferences": "server.context.personalization",
# usage # usage
"get_or_create_usage_tracker": "server.context.usage", "get_or_create_usage_tracker": "server.context.usage",
"emit_user_quota_update": "server.context.usage",
# upload # upload
"get_gui_manager": "server.context.upload", "get_gui_manager": "server.context.upload",
"get_upload_guard": "server.context.upload", "get_upload_guard": "server.context.upload",
@ -55,9 +51,6 @@ __all__ = [
"apply_conversation_overrides", "apply_conversation_overrides",
"reset_system_state", "reset_system_state",
"get_or_create_usage_tracker", "get_or_create_usage_tracker",
"emit_user_quota_update",
"make_terminal_callback",
"attach_user_broadcast",
"reap_idle_conversation_terminals", "reap_idle_conversation_terminals",
"start_conversation_terminal_reaper", "start_conversation_terminal_reaper",
] ]

View File

@ -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_idsetdefault不覆盖已有值
对话级 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

View File

@ -157,5 +157,4 @@ def start_conversation_terminal_reaper():
if _conversation_terminal_reaper_started: if _conversation_terminal_reaper_started:
return return
_conversation_terminal_reaper_started = True _conversation_terminal_reaper_started = True
from server.extensions import socketio threading.Thread(target=_conversation_terminal_reaper_loop, daemon=True).start()
socketio.start_background_task(_conversation_terminal_reaper_loop)

View File

@ -55,9 +55,8 @@ def _get_current_user_role(record=None) -> str:
return get_current_user_role(record) return get_current_user_role(record)
from server.context.identity import NoWorkspaceError, RuntimeIdentity, _resolve_user_role 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.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), project_path=str(project_path),
thinking_mode=thinking_mode, thinking_mode=thinking_mode,
run_mode=run_mode, run_mode=run_mode,
message_callback=make_terminal_callback("host"), message_callback=None,
data_dir=str(data_dir), data_dir=str(data_dir),
container_session=container_handle, container_session=container_handle,
usage_tracker=usage_tracker, usage_tracker=usage_tracker,
conversation_id=conversation_id, conversation_id=conversation_id,
) )
if terminal.terminal_manager:
terminal.terminal_manager.broadcast = terminal.message_callback
state.user_terminals[term_key] = terminal state.user_terminals[term_key] = terminal
terminal.username = "host" terminal.username = "host"
terminal.user_role = "admin" terminal.user_role = "admin"
@ -335,7 +332,6 @@ def get_user_resources(
session_set('host_workspace_id', getattr(workspace, "workspace_id", None)) session_set('host_workspace_id', getattr(workspace, "workspace_id", None))
else: else:
terminal.update_container_session(container_handle) terminal.update_container_session(container_handle)
attach_user_broadcast(terminal, "host")
terminal.username = "host" terminal.username = "host"
terminal.user_role = "admin" terminal.user_role = "admin"
if can_write_session: if can_write_session:
@ -476,12 +472,10 @@ def get_user_resources(
usage_tracker=usage_tracker, usage_tracker=usage_tracker,
conversation_id=conversation_id, conversation_id=conversation_id,
) )
if terminal.terminal_manager:
terminal.terminal_manager.broadcast = terminal.message_callback
state.user_terminals[term_key] = terminal state.user_terminals[term_key] = terminal
terminal.username = username terminal.username = username
terminal.user_role = "api" if is_api_user else _resolve_user_role(identity, record) 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: if can_write_session:
session_set('run_mode', terminal.run_mode) session_set('run_mode', terminal.run_mode)
session_set('thinking_mode', terminal.thinking_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)) session_set('workspace_id', getattr(workspace, "workspace_id", None))
else: else:
terminal.update_container_session(container_handle) terminal.update_container_session(container_handle)
attach_user_broadcast(terminal, username)
terminal.username = username terminal.username = username
terminal.user_role = "api" if is_api_user else _resolve_user_role(identity, record) 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: if can_write_session:
session_set('workspace_id', getattr(workspace, "workspace_id", None)) session_set('workspace_id', getattr(workspace, "workspace_id", None))

View File

@ -1,4 +1,4 @@
"""配额追踪器UsageTracker的获取与广播""" """配额追踪器UsageTracker的获取"""
from __future__ import annotations from __future__ import annotations
from typing import Optional, TYPE_CHECKING 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") tracker = UsageTracker(str(workspace.data_dir), role=role or "user")
state.usage_trackers[username] = tracker state.usage_trackers[username] = tracker
return 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

View File

@ -26,7 +26,6 @@ from io import BytesIO
from typing import Dict, Any, Optional, List, Tuple from typing import Dict, Any, Optional, List, Tuple
from flask import Blueprint, request, jsonify, session, send_file from flask import Blueprint, request, jsonify, session, send_file
from flask_socketio import emit
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
import zipfile import zipfile
@ -72,7 +71,7 @@ from utils.conversation_manager import ConversationManager
from utils.api_client import APIClient from utils.api_client import APIClient
from .auth_helpers import api_login_required, resolve_admin_policy, get_current_user_record, get_current_username 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 ( from .utils_common import (
build_review_lines, build_review_lines,
debug_log, debug_log,
@ -86,7 +85,6 @@ from .utils_common import (
CHUNK_FRONTEND_LOG_FILE, CHUNK_FRONTEND_LOG_FILE,
STREAMING_DEBUG_LOG_FILE, STREAMING_DEBUG_LOG_FILE,
) )
from .extensions import socketio
from .state import ( from .state import (
RECENT_UPLOAD_EVENT_LIMIT, RECENT_UPLOAD_EVENT_LIMIT,
RECENT_UPLOAD_FEED_LIMIT, RECENT_UPLOAD_FEED_LIMIT,
@ -807,18 +805,7 @@ def create_conversation(terminal: WebTerminal, workspace: UserWorkspace, usernam
except Exception as exc: except Exception as exc:
debug_log(f"[Versioning] create_conversation apply default failed: {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")}) perf_log("create_conversation after default versioning", elapsed_ms=(time.perf_counter() - t0) * 1000, extra={"conv_id": result.get("conversation_id")})
# 广播对话列表更新事件 # 对话列表/切换推送已随 WebSocket 移除:发起方依据响应刷新,其他标签页由轮询同步。
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}")
perf_log("create_conversation route done", elapsed_ms=(time.perf_counter() - t0) * 1000, extra={"conv_id": result.get("conversation_id")}) perf_log("create_conversation route done", elapsed_ms=(time.perf_counter() - t0) * 1000, extra={"conv_id": result.get("conversation_id")})
return jsonify(result), 201 return jsonify(result), 201
@ -941,22 +928,9 @@ def load_conversation(conversation_id, terminal: WebTerminal, workspace: UserWor
} }
if not result.get("safe_navigation"): if not result.get("safe_navigation"):
# 安全导航只改变前端查看对象,不代表后端 terminal 当前上下文已切换。 # 安全导航只改变前端查看对象;对话切换/加载推送已随 WebSocket 移除,
socketio.emit('conversation_changed', { # 发起方依据响应刷新,其他标签页由轮询同步。
'conversation_id': conversation_id, pass
'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}")
return jsonify(result) return jsonify(result)
else: else:
@ -1007,23 +981,9 @@ def delete_conversation(conversation_id, terminal: WebTerminal, workspace: UserW
result = terminal.delete_conversation(conversation_id) result = terminal.delete_conversation(conversation_id)
if result["success"]: if result["success"]:
# 广播对话列表更新事件 # 对话删除/清空推送已随 WebSocket 移除:发起方依据响应刷新,
socketio.emit('conversation_list_update', { # 其他标签页由对话列表轮询同步。is_current 变量保留供下方响应使用。
'action': 'deleted', pass
'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}")
return jsonify(result) return jsonify(result)
else: else:
@ -1705,14 +1665,7 @@ def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTer
session['run_mode'] = terminal.run_mode session['run_mode'] = terminal.run_mode
session['thinking_mode'] = terminal.thinking_mode session['thinking_mode'] = terminal.thinking_mode
socketio.emit('conversation_list_update', { # 版本恢复后的列表/切换推送已随 WebSocket 移除:发起方依据响应刷新。
'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}")
return jsonify({ return jsonify({
"success": True, "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) 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 return jsonify(result), status_code
# in-place 压缩:对话 id 不变。通知前端当前对话内容已变化(历史前缀被标记),刷新展示。 # in-place 压缩:对话 id 不变。对话内容变化推送已随 WebSocket 移除,
# 发起方依据响应guide_inserted 等字段)刷新历史。
load_result = terminal.load_conversation(normalized_id) 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 = { response_payload = {
"success": True, "success": True,
@ -1802,29 +1747,7 @@ def compress_conversation(conversation_id, terminal: WebTerminal, workspace: Use
) )
response_payload["auto_task_started"] = False response_payload["auto_task_started"] = False
response_payload["guide_inserted"] = True response_payload["guide_inserted"] = True
# 通知前端实时显示这条 compact 消息(覆盖 socket 与 in-place 未刷新场景) # 引导语已写入历史,前端依据响应 guide_inserted 刷新历史即可看到。
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}")
except Exception as exc: except Exception as exc:
debug_log(f"[Compression] 追加引导语消息失败: {exc}") debug_log(f"[Compression] 追加引导语消息失败: {exc}")
response_payload["auto_task_started"] = False 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"] new_conversation_id = result["duplicate_conversation_id"]
load_result = terminal.load_conversation(new_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 = { response_payload = {
"success": True, "success": True,
"duplicate_conversation_id": new_conversation_id, "duplicate_conversation_id": new_conversation_id,
@ -2466,23 +2374,8 @@ def get_current_conversation(terminal: WebTerminal, workspace: UserWorkspace, us
"error": str(e) "error": str(e)
}), 500 }), 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]: def _execute_system_command(terminal: WebTerminal, command: str) -> Dict[str, Any]:
"""执行系统命令,供 WebSocket 与 REST API 复用""" """执行系统命令REST API 专用;原 WebSocket send_command 通道已移除)。"""
command = (command or '').strip() command = (command or '').strip()
if command.startswith('/'): if command.startswith('/'):
command = command[1:] command = command[1:]

View File

@ -1,35 +1,16 @@
"""Flask/SocketIO 扩展实例""" """后台任务辅助WebSocket 移除后仅剩后台线程启动器)"""
import threading 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): def run_background(fn, *args, **kwargs):
"""启动后台任务Web 模式走 socketio.start_background_task兼容其线程模型 """以 daemon 线程启动后台任务。
独立 Gateway 进程socketio 未初始化降级为 daemon 线程"""
if socketio.server is not None: 历史上该函数会在 Web 模式优先走 socketio.start_background_task
return socketio.start_background_task(fn, *args, **kwargs) Socket.IO 移除后统一为 daemon 线程与原降级路径行为一致
"""
t = threading.Thread(target=fn, args=args, kwargs=kwargs, daemon=True) t = threading.Thread(target=fn, args=args, kwargs=kwargs, daemon=True)
t.start() t.start()
return t return t
__all__ = ["socketio", "emit_event", "run_background"] __all__ = ["run_background"]

View File

@ -1,6 +1,6 @@
"""对话级主任务门闸(单写者防护)。 """对话级主任务门闸(单写者防护)。
背景2026-08-12平行时空事故socketio 入口的主聊天任务不在 背景2026-08-12平行时空事故socketio 入口已移除的主聊天任务不在
task_manager 注册`create_chat_task` 的单对话互斥对它们不可见完成通知 task_manager 注册`create_chat_task` 的单对话互斥对它们不可见完成通知
轮询器又只凭 `_tool_loop_active`仅覆盖工具执行窗口判断对话是否空闲 轮询器又只凭 `_tool_loop_active`仅覆盖工具执行窗口判断对话是否空闲
于是在主任务两次工具循环的间隙里派发了通知任务两个主任务并发交叉写入 于是在主任务两次工具循环的间隙里派发了通知任务两个主任务并发交叉写入

View File

@ -428,15 +428,8 @@ def create_multi_agent_conversation():
except Exception: except Exception:
pass pass
# 触发对话列表更新事件 # 对话列表更新不再经 WebSocket 广播:发起方依据本响应刷新列表,
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
return jsonify({ return jsonify({
"success": True, "success": True,

View File

@ -188,31 +188,6 @@ def validate_csrf_request() -> bool:
return False 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: def format_tool_result_notice(tool_name: str, tool_call_id: Optional[str], content: str) -> str:
"""将工具执行结果转为系统消息文本,方便在对话中回传。""" """将工具执行结果转为系统消息文本,方便在对话中回传。"""
header = f"[工具结果] {tool_name}" header = f"[工具结果] {tool_name}"
@ -301,8 +276,6 @@ __all__ = [
"get_csrf_token", "get_csrf_token",
"requires_csrf_protection", "requires_csrf_protection",
"validate_csrf_request", "validate_csrf_request",
"prune_socket_tokens",
"consume_socket_token",
"format_tool_result_notice", "format_tool_result_notice",
"compact_web_search_result", "compact_web_search_result",
"attach_security_hooks", "attach_security_hooks",
@ -318,8 +291,6 @@ __all__ = [
"get_csrf_token", "get_csrf_token",
"requires_csrf_protection", "requires_csrf_protection",
"validate_csrf_request", "validate_csrf_request",
"prune_socket_tokens",
"consume_socket_token",
"format_tool_result_notice", "format_tool_result_notice",
"compact_web_search_result", "compact_web_search_result",
] ]

View File

@ -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

View File

@ -48,7 +48,6 @@ MONITOR_SNAPSHOT_CHAR_LIMIT = 60000
MONITOR_MEMORY_ENTRY_LIMIT = 256 MONITOR_MEMORY_ENTRY_LIMIT = 256
RATE_LIMIT_BUCKETS: Dict[str, deque] = defaultdict(deque) RATE_LIMIT_BUCKETS: Dict[str, deque] = defaultdict(deque)
FAILURE_TRACKERS: Dict[str, Dict[str, float]] = {} FAILURE_TRACKERS: Dict[str, Dict[str, float]] = {}
pending_socket_tokens: Dict[str, Dict[str, Any]] = {}
usage_trackers: Dict[str, UsageTracker] = {} usage_trackers: Dict[str, UsageTracker] = {}
active_login_nonces: Dict[str, set] = defaultdict(set) active_login_nonces: Dict[str, set] = defaultdict(set)
@ -71,7 +70,6 @@ CSRF_PROTECTED_PREFIXES = ("/api/",)
CSRF_EXEMPT_PATHS = {"/api/csrf-token"} CSRF_EXEMPT_PATHS = {"/api/csrf-token"}
FAILED_LOGIN_LIMIT = 5 FAILED_LOGIN_LIMIT = 5
FAILED_LOGIN_LOCK_SECONDS = 300 FAILED_LOGIN_LOCK_SECONDS = 300
SOCKET_TOKEN_TTL_SECONDS = 45
USER_IDLE_TIMEOUT_SECONDS = int(os.environ.get("USER_IDLE_TIMEOUT_SECONDS", "900")) 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_FILE = Path(LOGS_DIR).expanduser().resolve() / "last_active.json"
_last_active_lock = threading.Lock() _last_active_lock = threading.Lock()
@ -104,7 +102,6 @@ __all__ = [
"MONITOR_MEMORY_ENTRY_LIMIT", "MONITOR_MEMORY_ENTRY_LIMIT",
"RATE_LIMIT_BUCKETS", "RATE_LIMIT_BUCKETS",
"FAILURE_TRACKERS", "FAILURE_TRACKERS",
"pending_socket_tokens",
"usage_trackers", "usage_trackers",
"active_login_nonces", "active_login_nonces",
"tool_approval_manager", "tool_approval_manager",
@ -123,7 +120,6 @@ __all__ = [
"CSRF_EXEMPT_PATHS", "CSRF_EXEMPT_PATHS",
"FAILED_LOGIN_LIMIT", "FAILED_LOGIN_LIMIT",
"FAILED_LOGIN_LOCK_SECONDS", "FAILED_LOGIN_LOCK_SECONDS",
"SOCKET_TOKEN_TTL_SECONDS",
"USER_IDLE_TIMEOUT_SECONDS", "USER_IDLE_TIMEOUT_SECONDS",
"LAST_ACTIVE_FILE", "LAST_ACTIVE_FILE",
"_last_active_lock", "_last_active_lock",

View File

@ -18,7 +18,7 @@ from pathlib import Path
from flask import Blueprint, jsonify, request, send_file, session from flask import Blueprint, jsonify, request, send_file, session
from server.auth_helpers import api_login_required, resolve_admin_policy 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 ( from server.state import (
PROJECT_STORAGE_CACHE, PROJECT_STORAGE_CACHE,
PROJECT_STORAGE_CACHE_TTL_SECONDS, PROJECT_STORAGE_CACHE_TTL_SECONDS,

View File

@ -13,7 +13,7 @@ from pathlib import Path
from flask import Blueprint, jsonify, request, send_file, session from flask import Blueprint, jsonify, request, send_file, session
from server.auth_helpers import api_login_required, resolve_admin_policy 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 ( from server.state import (
PROJECT_STORAGE_CACHE, PROJECT_STORAGE_CACHE,
PROJECT_STORAGE_CACHE_TTL_SECONDS, PROJECT_STORAGE_CACHE_TTL_SECONDS,

View File

@ -18,7 +18,7 @@ from pathlib import Path
from flask import Blueprint, jsonify, request, send_file, session from flask import Blueprint, jsonify, request, send_file, session
from server.auth_helpers import api_login_required, resolve_admin_policy 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 ( from server.state import (
PROJECT_STORAGE_CACHE, PROJECT_STORAGE_CACHE,
PROJECT_STORAGE_CACHE_TTL_SECONDS, PROJECT_STORAGE_CACHE_TTL_SECONDS,
@ -141,12 +141,6 @@ def select_docker_project():
except RuntimeError as exc: except RuntimeError as exc:
session["workspace_id"] = previous_workspace_id session["workspace_id"] = previous_workspace_id
return jsonify({"success": False, "error": str(exc)}), 503 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) default_workspace_id = user_manager._get_user_default_workspace_id(username)
return jsonify({ return jsonify({
"success": True, "success": True,

View File

@ -15,7 +15,7 @@ from pathlib import Path
from flask import Blueprint, jsonify, request, send_file, session from flask import Blueprint, jsonify, request, send_file, session
from server.auth_helpers import api_login_required, resolve_admin_policy 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 ( from server.state import (
PROJECT_STORAGE_CACHE, PROJECT_STORAGE_CACHE,
PROJECT_STORAGE_CACHE_TTL_SECONDS, PROJECT_STORAGE_CACHE_TTL_SECONDS,

View File

@ -13,7 +13,7 @@ from pathlib import Path
from flask import Blueprint, jsonify, request, send_file, session from flask import Blueprint, jsonify, request, send_file, session
from server.auth_helpers import api_login_required, resolve_admin_policy 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 ( from server.state import (
PROJECT_STORAGE_CACHE, PROJECT_STORAGE_CACHE,
PROJECT_STORAGE_CACHE_TTL_SECONDS, PROJECT_STORAGE_CACHE_TTL_SECONDS,

View File

@ -912,15 +912,8 @@ class TaskManager:
data.setdefault("conversation_id", rec.conversation_id) data.setdefault("conversation_id", rec.conversation_id)
if rec.workspace_id: if rec.workspace_id:
data.setdefault("workspace_id", rec.workspace_id) data.setdefault("workspace_id", rec.workspace_id)
# 记录事件 # 记录事件到任务事件流(轮询通道,唯一出口)
self._append_event(rec, event_type, data) 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 # 轮询模式需要把 context_manager 的回调切到当前任务 sender
# 否则 token_update 等事件只走 websocket前端任务轮询拿不到实时更新。 # 否则 token_update 等事件只走 websocket前端任务轮询拿不到实时更新。
@ -1043,7 +1036,6 @@ class TaskManager:
) )
# 统一发送 task_stopped携带后台任务状态 # 统一发送 task_stopped携带后台任务状态
try: try:
from server.extensions import emit_event
stopped_payload = { stopped_payload = {
'message': tr("task_main.task_stopped"), 'message': tr("task_main.task_stopped"),
'reason': 'user_requested', 'reason': 'user_requested',
@ -1052,10 +1044,8 @@ class TaskManager:
'has_running_sub_agents': bg_state["has_running_sub_agents"], 'has_running_sub_agents': bg_state["has_running_sub_agents"],
'has_running_background_commands': bg_state["has_running_background_commands"], 'has_running_background_commands': bg_state["has_running_background_commands"],
} }
# 先写权威事件流(轮询客户端可见),再做实时推送—— # 事件流(轮询通道)为唯一权威出口,不再做 WebSocket 实时推送。
# 推送失败(含 socketio 未绑定)不得影响事件流记录
self._append_event(rec, "task_stopped", stopped_payload) self._append_event(rec, "task_stopped", stopped_payload)
emit_event('task_stopped', stopped_payload, room=f"user_{rec.username}")
debug_log( debug_log(
f"[TaskRun] 已发送 task_stopped: task_id={rec.task_id}, " f"[TaskRun] 已发送 task_stopped: task_id={rec.task_id}, "
f"has_bg={has_bg}, room=user_{rec.username}" f"has_bg={has_bg}, room=user_{rec.username}"

View File

@ -58,7 +58,6 @@ const AVATAR_TERMINAL_STATUS = new Set([
export const computed = { export const computed = {
...mapWritableState(useConnectionStore, [ ...mapWritableState(useConnectionStore, [
'isConnected', 'isConnected',
'socket',
'stopRequested', 'stopRequested',
'projectPath', 'projectPath',
'agentVersion', 'agentVersion',

View File

@ -47,6 +47,7 @@ export async function mounted() {
const initialDataPromise = this.loadInitialData(); const initialDataPromise = this.loadInitialData();
this.startProjectGitSummaryIdleRefresh?.(); this.startProjectGitSummaryIdleRefresh?.();
this.startConnectionHeartbeat(); this.startConnectionHeartbeat();
this.startStatusIdleRefresh?.();
this.fetchTerminalCount(); this.fetchTerminalCount();
this.startTerminalCountIdleRefresh(); this.startTerminalCountIdleRefresh();
this.checkTutorialPrompt(); this.checkTutorialPrompt();
@ -123,6 +124,7 @@ export function beforeUnmount() {
this.teardownMobileViewportWatcher(); this.teardownMobileViewportWatcher();
this.stopProjectGitSummaryIdleRefresh?.(); this.stopProjectGitSummaryIdleRefresh?.();
this.stopTerminalCountIdleRefresh?.(); this.stopTerminalCountIdleRefresh?.();
this.stopStatusIdleRefresh?.();
this.resourceStopContainerStatsPolling(); this.resourceStopContainerStatsPolling();
this.resourceStopProjectStoragePolling(); this.resourceStopProjectStoragePolling();
this.resourceStopUsageQuotaPolling(); this.resourceStopUsageQuotaPolling();

View File

@ -49,9 +49,6 @@ export const panelMethods = {
}, },
toggleTerminalPanel() { toggleTerminalPanel() {
this.terminalPanelOpen = !this.terminalPanelOpen; this.terminalPanelOpen = !this.terminalPanelOpen;
if (this.terminalPanelOpen) {
this.subscribeTerminalEvents();
}
}, },
closeTerminalPanel() { closeTerminalPanel() {
this.terminalPanelOpen = false; this.terminalPanelOpen = false;

View File

@ -30,6 +30,51 @@ export const socketMethods = {
// 主网页端已切换为 REST + 轮询模式,这里保留空实现用于兼容旧调用 // 主网页端已切换为 REST + 轮询模式,这里保留空实现用于兼容旧调用
this.socket = null; 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() { async checkConnectionHealth() {
if (this.connectionHeartbeatInFlight) { if (this.connectionHeartbeatInFlight) {
connectionDiag('log', 'health-skip-inflight', { connectionDiag('log', 'health-skip-inflight', {

View File

@ -34,11 +34,6 @@ export const terminalMethods = {
} }
this.toggleTerminalPanel(); this.toggleTerminalPanel();
}, },
subscribeTerminalEvents() {
const socket = this.socket;
if (!socket) return;
socket.emit('terminal_subscribe', { all: true, conversation_id: this.currentConversationId || undefined });
},
setTerminalSessions(sessions: Record<string, { working_dir?: string; shell?: string }>) { setTerminalSessions(sessions: Record<string, { working_dir?: string; shell?: string }>) {
this.terminalSessions = sessions; this.terminalSessions = sessions;
}, },
@ -47,9 +42,7 @@ export const terminalMethods = {
}, },
switchTerminalSession(name: string) { switchTerminalSession(name: string) {
this.terminalActiveSession = name; this.terminalActiveSession = name;
if (this.socket) { // 终端输出由 TerminalPanel 的 REST 轮询获取,无需主动拉取
this.socket.emit('get_terminal_output', { session: name, lines: 0, conversation_id: this.currentConversationId || undefined });
}
}, },
async fetchTerminalCount() { async fetchTerminalCount() {
try { try {

View File

@ -37,7 +37,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'; import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
import { Terminal } from 'xterm'; import { Terminal } from 'xterm';
import { io as createSocketClient } from 'socket.io-client';
import 'xterm/css/xterm.css'; import 'xterm/css/xterm.css';
import CloseButton from '@/components/common/CloseButton.vue'; import CloseButton from '@/components/common/CloseButton.vue';
@ -53,33 +52,22 @@ const emit = defineEmits<{
(event: 'close'): void; (event: 'close'): void;
}>(); }>();
// ---- socket ---- // ---- REST ----
const sessions = ref<Record<string, { working_dir?: string; shell?: string }>>({}); const sessions = ref<Record<string, { working_dir?: string; shell?: string }>>({});
const activeSession = ref(''); const activeSession = ref('');
const sessionLogs = ref<Record<string, string>>({}); const sessionLogs = ref<Record<string, string>>({});
const sessionHydrated = ref<Record<string, boolean>>({}); const sessionHydrated = ref<Record<string, boolean>>({});
let socket: ReturnType<typeof createSocketClient> | null = null;
const terminalContainer = ref<HTMLElement | null>(null); const terminalContainer = ref<HTMLElement | null>(null);
let term: Terminal | null = null; let term: Terminal | null = null;
let themeObserver: MutationObserver | null = null; let themeObserver: MutationObserver | null = null;
let resizeObserver: ResizeObserver | null = null; let resizeObserver: ResizeObserver | null = null;
const _historyReady = ref<Record<string, boolean>>({}); const _historyReady = ref<Record<string, boolean>>({});
// terminal_output let listPollTimer: number | null = null;
const _historyLastEventTime = ref<Record<string, number>>({}); let outputPollTimer: number | null = null;
const sessionKeys = computed(() => Object.keys(sessions.value)); const sessionKeys = computed(() => Object.keys(sessions.value));
// 广started/list_update/output/input/closed/reset/switched
// conversation_id
// terminal_subscribed / *_history
function acceptTerminalBroadcast(data: any): boolean {
const cid = data?.conversation_id;
if (!cid || !props.conversationId) return false;
return cid === props.conversationId;
}
// ---- ---- // ---- ----
function getCurrentTheme(): 'light' | 'dark' { function getCurrentTheme(): 'light' | 'dark' {
const theme = document.documentElement.getAttribute('data-theme'); const theme = document.documentElement.getAttribute('data-theme');
@ -217,13 +205,9 @@ function switchToSession(name: string) {
activeSession.value = name; activeSession.value = name;
if (!sessionHydrated.value[name]) { if (!sessionHydrated.value[name]) {
_historyReady.value = { ..._historyReady.value, [name]: false }; _historyReady.value = { ..._historyReady.value, [name]: false };
const updatedTimes = { ..._historyLastEventTime.value };
delete updatedTimes[name];
_historyLastEventTime.value = updatedTimes;
if (socket?.connected) {
socket.emit('get_terminal_output', { session: name, lines: 0, conversation_id: props.conversationId });
}
} }
//
void fetchActiveOutput();
// renderSessionLog watch(activeSession) // renderSessionLog watch(activeSession)
} }
@ -238,221 +222,121 @@ watch(sessionKeys, (keys) => {
} }
}); });
// // / terminal shell
function resetPanelState() {
sessions.value = {};
activeSession.value = '';
sessionLogs.value = {};
sessionHydrated.value = {};
_historyReady.value = {};
if (term) term.clear();
}
watch(() => props.workspaceId, (newId, oldId) => { watch(() => props.workspaceId, (newId, oldId) => {
if (newId && oldId && newId !== oldId) { if (newId && oldId && newId !== oldId) {
// resetPanelState();
if (socket) { void fetchTerminalList();
socket.disconnect();
socket = null;
}
sessions.value = {};
activeSession.value = '';
sessionLogs.value = {};
sessionHydrated.value = {};
_historyReady.value = {};
_historyLastEventTime.value = {};
if (term) term.clear();
initSocket();
} }
}); });
// terminal shell
watch(() => props.conversationId, (newId, oldId) => { watch(() => props.conversationId, (newId, oldId) => {
if (newId !== oldId) { if (newId !== oldId) {
sessions.value = {}; resetPanelState();
activeSession.value = ''; void fetchTerminalList();
sessionLogs.value = {};
sessionHydrated.value = {};
_historyReady.value = {};
_historyLastEventTime.value = {};
if (term) term.clear();
if (socket?.connected) {
socket.emit('terminal_subscribe', { all: true, conversation_id: newId });
}
} }
}); });
// ---- socket ---- // ---- REST WebSocket ----
async function initSocket() { async function fetchTerminalList() {
try {
socket = createSocketClient('/', { const cid = encodeURIComponent(props.conversationId || '');
transports: ['websocket', 'polling'], const res = await fetch(`/api/terminals?conversation_id=${cid}`, { cache: 'no-store' });
autoConnect: false if (!res.ok) return;
}); const data = await res.json();
const list = Array.isArray(data?.sessions) ? data.sessions : [];
const assignToken = async (): Promise<boolean> => { const map: Record<string, { working_dir?: string; shell?: string }> = {};
const w = window as any; for (const t of list) {
if (typeof w.requestSocketToken !== 'function') { const name = t.session_name || t.name || t.session || t.id;
console.warn('[TerminalPanel] requestSocketToken 不可用'); if (name) map[name] = { working_dir: t.working_dir, shell: t.shell || 'bash' };
return false;
} }
try { sessions.value = map;
const token = await w.requestSocketToken(); const keys = Object.keys(map);
(socket as any).auth = { socket_token: token }; if (keys.length === 0) {
return true; activeSession.value = '';
} catch (e) { } else if (!activeSession.value || !map[activeSession.value]) {
console.error('[TerminalPanel] 获取 token 失败:', e); // /
return false; switchToSession(keys[0]);
} }
}; } catch {
// 线
socket.io.on('reconnect_attempt', () => assignToken());
const ready = await assignToken();
if (!ready) {
console.error('[TerminalPanel] token 获取失败,放弃 socket');
socket = null;
return;
} }
}
socket.on('connect', () => { async function fetchActiveOutput() {
socket!.emit('terminal_subscribe', { all: true, conversation_id: props.conversationId }); const s = activeSession.value;
}); if (!s) return;
try {
const cid = encodeURIComponent(props.conversationId || '');
const res = await fetch(
`/api/terminals/${encodeURIComponent(s)}/output?lines=1000&conversation_id=${cid}`,
{ cache: 'no-store' }
);
if (!res.ok) return;
const data = await res.json();
if (!data?.success) return;
const output = typeof data.output === 'string' ? data.output : '';
applyOutputSnapshot(s, output);
} catch {
//
}
}
socket.on('disconnect', () => { //
}); // reset//
function applyOutputSnapshot(session: string, output: string) {
const prev = sessionLogs.value[session] || '';
sessionHydrated.value = { ...sessionHydrated.value, [session]: true };
_historyReady.value = { ..._historyReady.value, [session]: true };
if (output === prev) return;
if (prev && output.startsWith(prev)) {
const delta = output.slice(prev.length);
sessionLogs.value[session] = output;
if (session === activeSession.value) appendToTerm(delta);
} else {
sessionLogs.value[session] = output;
if (session === activeSession.value) renderSessionLog();
}
}
socket.on('terminal_subscribed', (data: any) => { function startPolling() {
if (data?.terminals) { stopPolling();
const map: Record<string, { working_dir?: string; shell?: string }> = {}; void fetchTerminalList();
for (const t of data.terminals) { listPollTimer = window.setInterval(() => void fetchTerminalList(), 5000);
map[t.name] = { working_dir: t.working_dir, shell: 'bash' }; outputPollTimer = window.setInterval(() => void fetchActiveOutput(), 1500);
} }
sessions.value = map;
if (data.terminals.length > 0 && !activeSession.value) {
switchToSession(data.terminals[0].name);
}
}
});
socket.on('terminal_started', (data: any) => { function stopPolling() {
if (!acceptTerminalBroadcast(data)) return; if (listPollTimer !== null) {
sessions.value = { window.clearInterval(listPollTimer);
...sessions.value, listPollTimer = null;
[data.session]: { working_dir: data.working_dir, shell: 'bash' } }
}; if (outputPollTimer !== null) {
if (!activeSession.value) switchToSession(data.session); window.clearInterval(outputPollTimer);
}); outputPollTimer = null;
}
socket.on('terminal_list_update', (data: any) => {
if (!acceptTerminalBroadcast(data)) return;
if (data?.terminals) {
const map: Record<string, { working_dir?: string; shell?: string }> = {};
for (const t of data.terminals) {
map[t.name] = { working_dir: t.working_dir, shell: 'bash' };
}
sessions.value = map;
if (data.active && !activeSession.value) switchToSession(data.active);
}
});
socket.on('terminal_output', (data: any) => {
if (!acceptTerminalBroadcast(data)) return;
const s = data.session;
if (!s) return;
const delta = (data.data || '') as string;
// terminal_output_history
// terminal_output
// last_event_time 线
const historyTime = _historyLastEventTime.value[s] || 0;
const eventTime = typeof data.timestamp === 'number' ? data.timestamp : 0;
if (historyTime > 0 && eventTime > 0 && eventTime <= historyTime) {
return;
}
// sessionLogs
// _historyReady false
sessionLogs.value[s] = (sessionLogs.value[s] || '') + delta;
if (s === activeSession.value && term && _historyReady.value[s]) {
term.write(delta);
} else {
}
if (!activeSession.value) switchToSession(s);
});
socket.on('terminal_input', (data: any) => {
if (!acceptTerminalBroadcast(data)) return;
//
//
if (!activeSession.value && data.session) switchToSession(data.session);
});
socket.on('terminal_closed', (data: any) => {
if (!acceptTerminalBroadcast(data)) return;
const updated = { ...sessions.value };
delete updated[data.session];
sessions.value = updated;
const remaining = Object.keys(updated);
if (activeSession.value === data.session) {
activeSession.value = remaining.length > 0 ? remaining[0] : '';
}
});
socket.on('terminal_reset', (data: any) => {
if (!acceptTerminalBroadcast(data)) return;
const target = data.session || activeSession.value;
if (target) {
sessionLogs.value = { ...sessionLogs.value, [target]: '' };
sessionHydrated.value = { ...sessionHydrated.value, [target]: true };
const updatedTimes = { ..._historyLastEventTime.value };
delete updatedTimes[target];
_historyLastEventTime.value = updatedTimes;
if (target === activeSession.value) renderSessionLog();
}
});
socket.on('terminal_switched', (data: any) => {
if (!acceptTerminalBroadcast(data)) return;
if (data.current) switchToSession(data.current);
});
const handleHistory = (data: any) => {
const s = data.session || data.active;
if (!s) return;
const raw = data.output || data.data || '';
let payload = '';
if (typeof raw === 'string') {
payload = raw;
} else if (Array.isArray(raw)) {
payload = raw.join('\n');
}
if (payload) {
sessionLogs.value[s] = payload;
}
//
// _historyReady false seemingly
sessionHydrated.value = { ...sessionHydrated.value, [s]: true };
_historyReady.value = { ..._historyReady.value, [s]: true };
// terminal_output
const lastEventTime = typeof data.last_event_time === 'number' ? data.last_event_time : 0;
_historyLastEventTime.value = { ..._historyLastEventTime.value, [s]: lastEventTime };
if (s === activeSession.value) {
renderSessionLog();
}
};
socket.on('terminal_output_history', handleHistory);
socket.on('terminal_history', handleHistory);
socket.connect();
} }
// ---- ---- // ---- ----
onMounted(() => { onMounted(() => {
nextTick(() => { nextTick(() => {
initTerminal(); initTerminal();
initSocket(); startPolling();
}); });
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
stopPolling();
disposeTerminal(); disposeTerminal();
if (socket) {
socket.disconnect();
socket = null;
}
}); });
</script> </script>

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,8 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import type { Socket } from 'socket.io-client';
import type { ReasoningEffort } from './personalization'; import type { ReasoningEffort } from './personalization';
interface ConnectionState { interface ConnectionState {
isConnected: boolean; isConnected: boolean;
socket: Socket | null;
stopRequested: boolean; stopRequested: boolean;
projectPath: string; projectPath: string;
agentVersion: string; agentVersion: string;
@ -16,7 +14,6 @@ interface ConnectionState {
export const useConnectionStore = defineStore('connection', { export const useConnectionStore = defineStore('connection', {
state: (): ConnectionState => ({ state: (): ConnectionState => ({
isConnected: false, isConnected: false,
socket: null,
stopRequested: false, stopRequested: false,
projectPath: '', projectPath: '',
agentVersion: '', agentVersion: '',
@ -25,9 +22,6 @@ export const useConnectionStore = defineStore('connection', {
reasoningEffort: null reasoningEffort: null
}), }),
actions: { actions: {
setSocket(socket: Socket | null) {
this.socket = socket;
},
setConnected(value: boolean) { setConnected(value: boolean) {
this.isConnected = value; this.isConnected = value;
}, },