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:
parent
d97c1423b1
commit
e5808ea5be
59
AGENTS.md
59
AGENTS.md
@ -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-export);REST 任务轮询为主,Socket.IO 主要用于兼容与实时辅助通道)
|
- `server/`: Flask 业务主线(chat/task/status/context 已拆分为子包:`server/chat/`、`server/status/`、`server/tasks/`、`server/context/`(用户资源/身份/个性化,原 `server/context.py` 已拆包并保留兼容 re-export);**REST 任务轮询为唯一实时通道**——Socket.IO 已于 2026-09-11 整体移除,详见 §12.5)
|
||||||
- `server/runtime/`: 公共任务入口(2026-09 Gateway 化阶段二新增):`context.py` 定义 RuntimeContext 三层模型(TrustedPrincipal/TaskParams/InternalDirectives),`service.py` 提供 RuntimeService(create_task/cancel/guidance/queue/get_task_events);契约见 `docs/runtime_contract.md`
|
- `server/runtime/`: 公共任务入口(2026-09 Gateway 化阶段二新增):`context.py` 定义 RuntimeContext 三层模型(TrustedPrincipal/TaskParams/InternalDirectives),`service.py` 提供 RuntimeService(create_task/cancel/guidance/queue/get_task_events);契约见 `docs/runtime_contract.md`
|
||||||
- `core/`: 终端与工具编排(`main_terminal.py`、`web_terminal.py`、`main_terminal_parts/*`;其中 `main_terminal_parts/context/` 和 `main_terminal_parts/tools_definition` 已拆分为 base + mixin 子包)
|
- `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/`: opentui(Zig 内核 + 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`
|
||||||
|
|
||||||
### CLI(React / Ink)
|
### CLI(opentui / bun)
|
||||||
- 安装依赖:`npm --prefix cli install`
|
- 运行环境:bun ≥1.3(opentui FFI;node ≥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` symlink,bun 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` §6):token 读 `~/.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_loading,2026-09 新增)
|
## 13) 工具动态加载(tool_loading,2026-09 新增)
|
||||||
|
|
||||||
> 设计文档:`docs/dynamic_tool_loading_plan.md`。注册表与状态辅助唯一权威:`core/tool_loading.py`。
|
> 设计文档:`docs/dynamic_tool_loading_plan.md`。注册表与状态辅助唯一权威:`core/tool_loading.py`。
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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'))
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
if result.get("success"):
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"success": True,
|
"success": True,
|
||||||
"token": token_value,
|
"session": session_name,
|
||||||
"expires_in": SOCKET_TOKEN_TTL_SECONDS
|
"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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,47 +0,0 @@
|
|||||||
"""终端事件广播与回调包装(用户房间广播 + conversation_id 注入)。"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from core.web_terminal import WebTerminal
|
|
||||||
from server.utils_common import debug_log
|
|
||||||
|
|
||||||
|
|
||||||
def make_terminal_callback(username: str):
|
|
||||||
"""生成面向指定用户的广播函数(独立 Gateway 进程下 socket 未初始化时静默跳过)"""
|
|
||||||
from server.extensions import emit_event
|
|
||||||
def _callback(event_type, data):
|
|
||||||
emit_event(event_type, data, room=f"user_{username}")
|
|
||||||
return _callback
|
|
||||||
|
|
||||||
|
|
||||||
def attach_user_broadcast(terminal: WebTerminal, username: str):
|
|
||||||
"""确保终端的广播函数指向当前用户的房间。
|
|
||||||
|
|
||||||
对话级 terminal 的回调会额外包装注入 conversation_id(见 _wrap_callback_with_conversation_id)。
|
|
||||||
"""
|
|
||||||
callback = make_terminal_callback(username)
|
|
||||||
callback = _wrap_callback_with_conversation_id(
|
|
||||||
callback, getattr(terminal, "_bound_conversation_id", None)
|
|
||||||
)
|
|
||||||
terminal.message_callback = callback
|
|
||||||
if terminal.terminal_manager:
|
|
||||||
terminal.terminal_manager.broadcast = callback
|
|
||||||
|
|
||||||
|
|
||||||
def _wrap_callback_with_conversation_id(callback, conversation_id: Optional[str]):
|
|
||||||
"""包装广播回调,为 dict 类型的事件数据注入 conversation_id(setdefault,不覆盖已有值)。
|
|
||||||
|
|
||||||
对话级 terminal 的广播(shell 输出、terminal 列表等)仍发到用户房间,
|
|
||||||
前端按 conversation_id 过滤,避免同工作区多个对话的终端事件互相串扰。
|
|
||||||
"""
|
|
||||||
if not callback or not conversation_id:
|
|
||||||
return callback
|
|
||||||
|
|
||||||
def _wrapped(event_type, data):
|
|
||||||
if isinstance(data, dict):
|
|
||||||
data = dict(data)
|
|
||||||
data.setdefault("conversation_id", conversation_id)
|
|
||||||
return callback(event_type, data)
|
|
||||||
|
|
||||||
return _wrapped
|
|
||||||
@ -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)
|
|
||||||
|
|||||||
@ -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))
|
||||||
|
|
||||||
|
|||||||
@ -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
|
|
||||||
|
|||||||
@ -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:]
|
||||||
|
|||||||
@ -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"]
|
||||||
|
|||||||
@ -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`(仅覆盖工具执行窗口)判断对话是否空闲,
|
||||||
于是在主任务两次工具循环的间隙里派发了通知任务——两个主任务并发交叉写入
|
于是在主任务两次工具循环的间隙里派发了通知任务——两个主任务并发交叉写入
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -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",
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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}"
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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', {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|
||||||
socket = createSocketClient('/', {
|
|
||||||
transports: ['websocket', 'polling'],
|
|
||||||
autoConnect: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const assignToken = async (): Promise<boolean> => {
|
|
||||||
const w = window as any;
|
|
||||||
if (typeof w.requestSocketToken !== 'function') {
|
|
||||||
console.warn('[TerminalPanel] requestSocketToken 不可用');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const token = await w.requestSocketToken();
|
const cid = encodeURIComponent(props.conversationId || '');
|
||||||
(socket as any).auth = { socket_token: token };
|
const res = await fetch(`/api/terminals?conversation_id=${cid}`, { cache: 'no-store' });
|
||||||
return true;
|
if (!res.ok) return;
|
||||||
} catch (e) {
|
const data = await res.json();
|
||||||
console.error('[TerminalPanel] 获取 token 失败:', e);
|
const list = Array.isArray(data?.sessions) ? data.sessions : [];
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.io.on('reconnect_attempt', () => assignToken());
|
|
||||||
|
|
||||||
const ready = await assignToken();
|
|
||||||
if (!ready) {
|
|
||||||
console.error('[TerminalPanel] token 获取失败,放弃 socket');
|
|
||||||
socket = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.on('connect', () => {
|
|
||||||
socket!.emit('terminal_subscribe', { all: true, conversation_id: props.conversationId });
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('disconnect', () => {
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('terminal_subscribed', (data: any) => {
|
|
||||||
if (data?.terminals) {
|
|
||||||
const map: Record<string, { working_dir?: string; shell?: string }> = {};
|
const map: Record<string, { working_dir?: string; shell?: string }> = {};
|
||||||
for (const t of data.terminals) {
|
for (const t of list) {
|
||||||
map[t.name] = { working_dir: t.working_dir, shell: 'bash' };
|
const name = t.session_name || t.name || t.session || t.id;
|
||||||
|
if (name) map[name] = { working_dir: t.working_dir, shell: t.shell || 'bash' };
|
||||||
}
|
}
|
||||||
sessions.value = map;
|
sessions.value = map;
|
||||||
if (data.terminals.length > 0 && !activeSession.value) {
|
const keys = Object.keys(map);
|
||||||
switchToSession(data.terminals[0].name);
|
if (keys.length === 0) {
|
||||||
|
activeSession.value = '';
|
||||||
|
} else if (!activeSession.value || !map[activeSession.value]) {
|
||||||
|
// 新终端出现 / 当前终端被关闭:自动切换
|
||||||
|
switchToSession(keys[0]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 断线判定由全局连接心跳负责,轮询静默失败
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('terminal_started', (data: any) => {
|
async function fetchActiveOutput() {
|
||||||
if (!acceptTerminalBroadcast(data)) return;
|
const s = activeSession.value;
|
||||||
sessions.value = {
|
|
||||||
...sessions.value,
|
|
||||||
[data.session]: { working_dir: data.working_dir, shell: 'bash' }
|
|
||||||
};
|
|
||||||
if (!activeSession.value) switchToSession(data.session);
|
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
if (!s) return;
|
||||||
const delta = (data.data || '') as string;
|
try {
|
||||||
|
const cid = encodeURIComponent(props.conversationId || '');
|
||||||
// 去重:刷新页面后后端会回放历史快照(terminal_output_history)
|
const res = await fetch(
|
||||||
// 同时可能回放最近的 terminal_output 事件,导致已有内容被重复写入。
|
`/api/terminals/${encodeURIComponent(s)}/output?lines=1000&conversation_id=${cid}`,
|
||||||
// 用历史快照的 last_event_time 作为截止线,跳过更早的事件。
|
{ cache: 'no-store' }
|
||||||
const historyTime = _historyLastEventTime.value[s] || 0;
|
);
|
||||||
const eventTime = typeof data.timestamp === 'number' ? data.timestamp : 0;
|
if (!res.ok) return;
|
||||||
if (historyTime > 0 && eventTime > 0 && eventTime <= historyTime) {
|
const data = await res.json();
|
||||||
return;
|
if (!data?.success) return;
|
||||||
|
const output = typeof data.output === 'string' ? data.output : '';
|
||||||
|
applyOutputSnapshot(s, output);
|
||||||
|
} catch {
|
||||||
|
// 静默失败,下一轮重试
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 先把输出暂存到 sessionLogs;历史加载完成后再写入终端,
|
// 快照驱动渲染:输出是纯追加时只写增量(避免闪烁);
|
||||||
// 避免新终端历史为空时 _historyReady 始终为 false 导致永远写不进去。
|
// reset/截断/窗口滚动导致前缀不匹配时全量重绘。
|
||||||
sessionLogs.value[s] = (sessionLogs.value[s] || '') + delta;
|
function applyOutputSnapshot(session: string, output: string) {
|
||||||
if (s === activeSession.value && term && _historyReady.value[s]) {
|
const prev = sessionLogs.value[session] || '';
|
||||||
term.write(delta);
|
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 {
|
} else {
|
||||||
|
sessionLogs.value[session] = output;
|
||||||
|
if (session === activeSession.value) renderSessionLog();
|
||||||
}
|
}
|
||||||
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) => {
|
function startPolling() {
|
||||||
if (!acceptTerminalBroadcast(data)) return;
|
stopPolling();
|
||||||
const target = data.session || activeSession.value;
|
void fetchTerminalList();
|
||||||
if (target) {
|
listPollTimer = window.setInterval(() => void fetchTerminalList(), 5000);
|
||||||
sessionLogs.value = { ...sessionLogs.value, [target]: '' };
|
outputPollTimer = window.setInterval(() => void fetchActiveOutput(), 1500);
|
||||||
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) => {
|
function stopPolling() {
|
||||||
if (!acceptTerminalBroadcast(data)) return;
|
if (listPollTimer !== null) {
|
||||||
if (data.current) switchToSession(data.current);
|
window.clearInterval(listPollTimer);
|
||||||
});
|
listPollTimer = null;
|
||||||
|
|
||||||
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) {
|
if (outputPollTimer !== null) {
|
||||||
sessionLogs.value[s] = payload;
|
window.clearInterval(outputPollTimer);
|
||||||
|
outputPollTimer = null;
|
||||||
}
|
}
|
||||||
// 无论历史是否为空,都要标记加载完成;否则新建终端在收到第一条输出前
|
|
||||||
// _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
@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user