feat(server,static): 对话运行状态 REST 对账与 conversation 级 WebTerminal 隔离

议题1 运行状态对账:
- 后端新增 GET /api/conversations/<id>/running-status 聚合主任务/子智能体/
  后台命令/多智能体四类运行状态
- 前端 3 个 1s probe 收敛为单个 2.5s 对账循环,事件为主、对账纠偏,
  清理方向连续 2 次确认,恢复方向立即接管,修复轮询 404 卡死

议题4 conversation 级隔离:
- terminal 缓存键改为 username::workspace::conversation 三段,
  每个对话独立 WebTerminal/文件管理/终端/子智能体
- 同工作区多对话可并行运行,互斥维度从工作区改为同对话
- socket 终端面板按对话过滤广播,terminal 工具每对话最多 3 个
- 常驻内存 + 24h TTL 回收器(无活动且无运行任务才回收)
- 子智能体 restore 按 owner_conversation_id 过滤,避免多 manager 重复恢复
This commit is contained in:
JOJO 2026-07-20 01:05:17 +08:00
parent c414d268b3
commit a4eb688814
19 changed files with 742 additions and 468 deletions

View File

@ -143,6 +143,8 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
project_path=self.project_path,
data_dir=str(self.data_dir),
container_session=container_session,
# 对话级隔离(仅 WebTerminal 设置该属性CLI MainTerminal 无此属性getattr 回退 None
owner_conversation_id=getattr(self, "_bound_conversation_id", None),
)
self.sub_agent_manager.set_terminal(self)
self.background_command_manager = BackgroundCommandManager(

View File

@ -30,10 +30,27 @@ class WebTerminal(MainTerminal):
"""Web版本的终端继承自MainTerminal包含对话持久化功能"""
def _ensure_conversation(self):
"""确保Web端在首次进入时自动加载或创建对话"""
"""确保Web端在首次进入时自动加载或创建对话。
对话级 terminal_bound_conversation_id 非空加载绑定的对话
加载失败不 fallback 最近对话避免串对话保持无对话状态
由任务执行链路的 ensure_conversation_loaded 显式处理
工作区级 terminal未绑定保持原有最近对话行为
"""
if self.context_manager.current_conversation_id:
return
bound_id = getattr(self, "_bound_conversation_id", None)
if bound_id:
if not str(bound_id).startswith("conv_"):
bound_id = f"conv_{bound_id}"
result = self.load_conversation(bound_id, restore_model=False)
if result.get("success"):
print(f"[WebTerminal] 已加载绑定对话: {bound_id}")
else:
print(f"[WebTerminal] 警告:绑定对话 {bound_id} 加载失败: {result.get('message') or result.get('error')}")
return
latest_list = self.context_manager.get_conversation_list(limit=1, offset=0)
conversations = latest_list.get("conversations", []) if latest_list else []
@ -53,15 +70,29 @@ class WebTerminal(MainTerminal):
print(f"[WebTerminal] 自动创建新对话: {conversation_id}")
def __init__(
self,
project_path: str,
self,
project_path: str,
thinking_mode: bool = False,
run_mode: Optional[str] = None,
message_callback: Optional[Callable] = None,
data_dir: Optional[str] = None,
container_session: Optional["ContainerHandle"] = None,
usage_tracker: Optional[object] = None,
conversation_id: Optional[str] = None,
):
# 对话级隔离:绑定指定对话(必须在 super().__init__ 之前设置,
# 因为父类初始化会调用 self._ensure_conversation()
self._bound_conversation_id = conversation_id
# 24h TTL 回收器判定用:最近活动时间
self.last_activity_at = time.time()
# 对话级 terminal广播回调注入 conversation_id前端按对话过滤终端事件
if message_callback is not None and conversation_id:
_raw_callback = message_callback
def message_callback(event_type, data, _cb=_raw_callback, _cid=conversation_id):
if isinstance(data, dict):
data = dict(data)
data.setdefault("conversation_id", _cid)
return _cb(event_type, data)
# 调用父类初始化(包含对话持久化功能)
super().__init__(
project_path,

View File

@ -0,0 +1,160 @@
# Conversation 级 WebTerminal 隔离改造——交接文档(压缩后必读)
> 写于 2026-07-19供上下文压缩后的对话继续工作。
> **读完本文档即可直接开工,无需重新调研代码**。行号基于 2026-07-19 的代码,若有漂移以搜索符号名为准。
> 相关项目记忆:`runtime_state_loading_concurrency_redesign`4 议题讨论总索引)。
---
## 0. 当前状态一句话
议题 1运行状态 REST 对账)✅ 已完成并端到端验证;**议题 4conversation 级 WebTerminal 隔离)✅ 已于 2026-07-20 完成并端到端验证**(见 §7。议题 2子智能体按需加载、议题 3worktree用户明确暂缓是未来可做项。
## 1. 用户已拍板的设计决策(不要重新讨论)
1. **单对话单 WebTerminal**:每个对话有自己的 context_manager、file_manager、TerminalManagershell、SubAgentManager、BackgroundCommandManager。
2. **生命周期:常驻 + 24 小时 TTL**。实例随对话首次运行创建并常驻;仅当「超过 24 小时无活动 且 无运行中任务」才回收。不追求精确释放(原因见 §4 不可序列化清单)。
3. **worktree 后置**:先做 terminal 隔离。用户观点worktree 只能硬隔离文件编辑工具路径锚定run_command 只能软隔离cwd 锚定+agent 自觉Claude Code/Codex 也一样,工作量不大,后面再做。
4. 对话互斥维度:**同工作区互斥 → 同对话互斥**(同一对话仍禁止两个主 chat 任务并行,防止串写对话历史)。
## 2. 议题 1 已完成内容(文件 + 行号,勿重复调研)
### 后端
- `server/tasks/models.py`
- `TaskManager.get_conversation_running_status(terminal, conversation_id)`(约 604-665 行):新增。聚合 3 类后台状态,返回 `{has_running_sub_agents, has_running_background_commands, has_running_multi_agent}`。sub_agents 排除 multi_agent_mode 任务multi_agent 判定 = 非终态多智能体任务 或 `MultiAgentState` 有 running 实例 或 `has_pending_master_messages()`
- `_has_running_background(rec, terminal)`(约 667-682 行):改为委托上面的方法,保持旧语义(多智能体计入 sub_agents
- `create_chat_task` 互斥逻辑在 **147-155 行**(⚠️ 议题 4 要改这里)。
- `list_tasks(username, workspace_id)` 在约 206 行。
- `server/tasks/api.py`
- `GET /api/conversations/<conversation_id>/running-status?workspace_id=`(约 38-90 行,插在 `create_task_api` 之前)。返回 `{is_main_running, main_task_id, main_task_type, has_running_sub_agents, has_running_background_commands, has_running_multi_agent, is_truly_active}`
### 前端
- `static/src/app/methods/taskPolling/probe.ts`(已整体重写,@ts-nocheck现约 180 行)
- `startRunningStateReconcile()`:幂等启动 2.5s 对账循环 + 立即对账一次。
- `reconcileRunningStateOnce()`核心逻辑。恢复方向立即执行server active 本地空闲 → `taskStore.resumeTask(main_task_id)` + 设 waiting 标志;多智能体只设 taskInProgress 不设 waitingForSubAgent清理方向保守`taskStore.isPolling` 活跃则不干预;否则需连续 2 次 server idle 确认才清 streamingMessage/taskInProgress/waiting 标志 + `clearTaskState()`)。网络失败一律不变更状态。
- `startWaitingTaskProbe/stopWaitingTaskProbe/startMultiAgentTaskProbe/stopMultiAgentTaskProbe`:保留为委托/no-op调用点未动lifecycle.ts 400/407/418/419/502/531、messaging.ts 163/165、scroll.ts 31-32
- `restoreSubAgentWaitingState(retry)`:简化为启动对账循环(被 compression.ts 调用)。
- `static/src/app/state.ts` ~29 行:`runningStateReconcileTimer: null` + `_runningStateIdleStreak: 0`
- `static/src/app/methods/taskPolling/compression.ts` ~96-103 行:`restoreTaskState()` 入口启动对账。
- `static/src/app/methods/message/send.ts` ~312-316 行:`taskInProgress = true` 后启动对账。
### 验证资产
- 验证脚本:`_experiments/verify_running_status_api.py`test_client 4 项断言401/空闲/活动/终态,全过)。
- 已验证:冒烟 6/6、前端构建、真实服务 playwright 端到端2.5s 精确间隔、外部建任务前端自动接管轮询、完成后正常收尾)。
- 测试副作用:`conv_20260718_005231_752`(张雪峰对话)多了一条"对账测试通过"消息,可删。
- **既有 bug与议题 1 无关,未修)**assistant 消息 `reasoning_content` 前端渲染两遍(对话文件数据正常,刷新仍复现,历史加载渲染路径问题)。
## 3. 议题 4 改动地图(已普查,直接照此施工)
### 3.1 核心缓存与资源链路
- `server/state.py:28``user_terminals: Dict[str, WebTerminal] = {}` —— 全局缓存,当前 key = `username::workspace_id`
- `server/context.py`
- `_make_terminal_key(username, workspace_id)` **49 行**:返回 `f"{username}::{workspace_id}"`。→ 议题 4 改为加 conversation_id 段。
- `get_user_resources(username, workspace_id, update_session)` **131 行**:所有资源获取的统一入口。
- host 模式分支 ~143-280 行term_key 在 **205 行**生成、**280 行**写入缓存含「project_path 变化时重建 terminal」逻辑~210-260 行)。
- web 模式分支:**377 行**生成 term_key、**413 行**写入缓存。
- ⚠️ 调用点普查:**34 处调用 / 14 个文件**。分布:`server/api_v1.py` 14 处、`server/multi_agent.py` 6 处、`server/tasks/api.py` 4 处、`server/tasks/models.py` 3 处、`server/context.py` 2 处、`server/app_legacy.py` 2 处、`server/tasks/skills.py` 1 处、`server/socket_handlers.py` 1 处。
- 改造策略建议:不改 `get_user_resources` 签名,新增 `get_conversation_resources(username, workspace_id, conversation_id)`逐调用点迁移chat 任务链路tasks/models.py、tasks/api.py、chat_flow*)优先。
### 3.2 WebTerminal 本体
- `core/web_terminal.py`
- `class WebTerminal(MainTerminal)` ~30 行。
- `_ensure_conversation()` **33-52 行**:当前自动加载「最近对话」→ 议题 4 改为加载**指定 conversation_id**(或新对话则 start_new
- `__init__` **55-100 行**:调用父类 init → 创建 TerminalManager83 行)→ attach。
- `create_new_conversation()` ~110 行起。
- `close()` ~809 行:`terminal_manager.close_all()`。
- `core/main_terminal.py:142`SubAgentManager 每 WebTerminal 一个(非全局单例)——对话级化天然兼容。
- `modules/terminal_manager.py:65`TerminalManagermax_terminals 限制是 per-manager 的,对话级后每对话一个 manager互不干扰
- `modules/persistent_terminal/start.py`shell 是**惰性启动**Popen 在 167/208/269/346 行,首次用才起进程)——这是内存可控的关键,保持不变。
### 3.3 互斥与前端
- `server/tasks/models.py:147-155`chat 任务互斥(`task_type="chat"` 时同用户同工作区禁止并发409 "当前工作区已有运行中的任务")。→ 改为同 conversation_id 互斥。
- `static/src/app/methods/message/send.ts:25`:前端发送拦截。
- `static/src/app/computed.ts:220``currentWorkspaceHasRunningTask`(仅 host/docker 模式生效)→ 改为按对话判定,**可直接复用议题 1 的 running-status 接口**。
- 限制 B运行中禁切传统/多智能体模式,纯前端):`static/src/app/methods/ui/mode.ts:73` + QuickMenu.vue disabled。用户尚未拍板何时放开默认暂不动。
### 3.4 回收器(新建)
- 定期扫描 `state.user_terminals`,回收条件:当前时间 - 最后活动时间 > 24h 且该对话无运行任务(可用 `TaskManager.get_conversation_running_status` 判定)。
- 回收动作:`terminal.close()`(关 shell→ 从 user_terminals 移除。对话历史本来就有磁盘持久化,无损。
- 需要给 WebTerminal 加 `last_activity_at` 字段(任务创建/工具调用时刷新)。
### 3.5 需要注意的关联点
- `restore_running_tasks()``modules/sub_agent/manager.py:279`):启动时恢复多智能体子智能体,当前按 terminal 恢复 → 对话级后恢复逻辑要按对话 terminal 走。
- `stop_flags``server/state.py`):按 task_id 全局 dicttask 本就属于具体对话,理论上不用大改,但要检查清理时机。
- 多智能体模式:`multi_agent_mode` 是对话级 metadata对话级 terminal 后 `web_terminal.multi_agent_mode` 属性语义要重新对齐(当前是 terminal 级属性chat_flow_task_main.py 多处 `getattr(web_terminal, "multi_agent_mode", False)`)。
- 后台通知轮询(`poll_completion_notifications` / `poll_multi_agent_notifications`server/chat_flow_task_main.py持有 terminal 引用,对话级后注意引用来源。
## 4. 为什么选「常驻+TTL」而不是「空闲释放」勿重新争论
不可序列化的状态(释放即丢,无法 100% 恢复):
1. **shell 进程状态**cwd、`export` 变量、激活的 venv、后台 jobOS 进程态)。快照重放只能覆盖 90%且错误是静默的AI 在错误目录执行命令)。
2. **子智能体 asyncio 状态**:运行中/idle 的 Task、等待唤醒的 Event、ask_master 的 Future。现有 restore 也只能恢复到「强制 idle」。
3. **后台命令进程句柄**
内存估算(已给用户确认):单对话常驻 ~10-25MBshell 惰性30 活跃对话 ~300-750MB本机单用户可控。
## 5. 服务端当前状态
- 服务当前**未在运行**(用户关过,我起的 8091 后台进程也已结束)。重启命令:`python3 -m server.app --port 8091`host 模式,`/host-login` 免密登录POST 需先 GET `/api/csrf-token``token` 字段放 `X-CSRF-Token` 头)。
- 前端构建命令:`npm run build --silent 2>&1 | tail -n 5`。
- 验证命令:`python3 -m py_compile <files>` + `python3 -m unittest test.test_server_refactor_smoke`
## 6. 下一步(压缩后从这里继续)
议题 4 已全部完成。未来可做:议题 2子智能体按需加载、议题 3worktree、`currentWorkspaceHasRunningTask` 更名为 `currentConversationHasRunningTask`(语义已变、名字未改)。
---
## 7. 议题 4 实现记录2026-07-20 完成)
### 实际改动(与原计划偏差:未新增 `get_conversation_resources`,改为 `get_user_resources``conversation_id=None` 可选参数——向后兼容、更 DRY迁移即传参
**后端**
- `server/context.py`
- `_make_terminal_key(username, workspace_id, conversation_id=None)`:三段键 `u::w::cid`
- `_wrap_callback_with_conversation_id(callback, cid)`:广播 data 注入 cidsetdefault
- `_touch_terminal_activity(terminal, cid)`:刷新 `last_activity_at`
- `attach_user_broadcast`:读 `terminal._bound_conversation_id` 自动包装
- `get_user_resources(..., conversation_id=None)`host/web 两分支 term_key 带对话段;**container_key 保持工作区级**docker 一工作区一容器用户确认WebTerminal 创建传 cidreturn 前 touch
- `with_terminal`:从 query/JSON body 读 conversation_id/api/terminals 等自动支持)
- `get_terminal_for_sid(sid, conversation_id=None)`
- 回收器(文件末尾):`CONVERSATION_TERMINAL_TTL_SECONDS`(默认 24henv 可调)/ `reap_idle_conversation_terminals(now)`(可测试)/ `_conversation_terminal_reaper_loop` / `start_conversation_terminal_reaper`(幂等);回收=保存对话+close_all shell+close MCP+pop仅三段键、过期、无运行工作主任务+3类后台判定失败保守不回收
- `core/web_terminal.py``__init__` 加 `conversation_id=None`super 前设 `_bound_conversation_id`、`last_activity_at`message_callback 包装注入 cid`_ensure_conversation` 绑定对话优先加载conv_ 前缀规范化,失败不 fallback
- `core/main_terminal.py`:创建 SubAgentManager 透传 `owner_conversation_id=getattr(self,'_bound_conversation_id',None)`
- `modules/sub_agent/manager.py``owner_conversation_id` 属性;`restore_running_tasks` 过滤——仅恢复本对话任务,**工作区级 manager 不再恢复(行为变化:重启后多智能体任务延迟到对话激活时恢复)**
- `server/tasks/models.py`chat 互斥改同对话(`_norm_cid` 规范化比较文案“当前对话已有运行中的任务”235/264/739 三处传 `conversation_id=rec.conversation_id`
- `server/tasks/api.py`running-status、create_task(×2)、cancel 四处传 cid
- `server/multi_agent.py``list_active_sub_agents_api` 传 cid其余 5 处保持工作区级——无对话上下文/仅 workspace
- `server/socket_handlers.py``terminal_subscribe`/`get_terminal_output` 从 data 读 cid
- `server/app_legacy.py``start_background_jobs` 接入回收器启动
- 保持工作区级不动:`server/conversation.py`(对话列表/标题/创建)、`server/api_v1.py` 14 处、`server/tasks/skills.py`、socket connect轻量服务实例shell 惰性)
**前端**
- `static/src/app/computed.ts` `currentWorkspaceHasRunningTask``!==` → `===`当前对话判定displayLockEngaged 随之按对话锁)
- `static/src/app/methods/message/send.ts`:拦截文案“当前对话正在运行”
- `static/src/components/panels/TerminalPanel.vue`+`conversationId` prop`acceptTerminalBroadcast` 过滤 7 个广播事件started/list_update/output/input/closed/reset/switched响应类 subscribed/history 不过滤watch conversationId 清空重订阅emit 带 cid
- `static/src/App.vue`TerminalPanel 传 `:conversation-id="currentConversationId"`
- `static/src/app/methods/ui/terminal.ts`fetchTerminalCount/subscribeTerminalEvents/switchTerminalSession 带 cid
- `static/src/composables/useLegacySocket.ts`connect 时 terminal_subscribe 带 cid
### 验证结果(全部通过)
- py_compile + 冒烟 6/6 + 前端构建
- `_experiments/verify_conversation_level_terminal.py`可复用回归9 项断言:键隔离/容器共享/绑定/Manager 独立/同对话互斥/异对话并行/广播注入/回收器 3 态
- 真实服务 playwright 原子脚本:**PARALLEL_PROOF {a_running:true, b_running:true}**A 运行中 B 页面零 is-disabledB 发送不被拦;同对话 409
- 终端隔离实测A 创建 `test-shell-a` 后 A 列表 1 个 / B 列表 0 个;`/api/terminals` 返回 `max_allowed:3`(每对话 3 终端)
- 议题 1 回归:双任务完成后双方 is_truly_active=false对账循环正常收尾
### 遗留与注意
- **行为变化**重启后多智能体任务不再立即恢复延迟到对话激活restore 过滤的必然结果)
- 测试副作用:语音工作区的 conv_20260720_001402_595/857 两个测试对话A 内有运行中的 test-shell-a张雪峰对话conv_20260718_005231_752有议题 1 时的测试消息
- 24h 回收器真实时长未实测单元覆盖三态env `CONVERSATION_TERMINAL_TTL_SECONDS` 可调短测)
- TerminalPanel 广播过滤前端 UI 未单独截图(逻辑随构建通过;后端 API 层隔离已实测)
- 既有 bug 仍未修assistant reasoning_content 前端渲染两遍(与议题 1/4 无关)

View File

@ -47,9 +47,15 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
project_path: str,
data_dir: str,
container_session: Optional["ContainerHandle"] = None,
owner_conversation_id: Optional[str] = None,
):
self.project_path = Path(project_path).resolve()
self.data_dir = Path(data_dir).resolve()
# 对话级隔离:本 manager 所属 terminal 绑定的对话。
# 非空时 restore_running_tasks 只恢复该对话的多智能体任务;
# 为空(工作区级服务 terminal时不恢复避免同工作区多个
# 对话级 manager 重复恢复同一批任务(任务延迟到对话激活时恢复)。
self.owner_conversation_id = owner_conversation_id
# 子智能体任务和状态按 data_dir 隔离web 模式下按用户/工作区自动隔离)
self.base_dir = self.data_dir / "sub_agent_tasks"
self.state_file = self.data_dir / "sub_agents.json"
@ -718,12 +724,17 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
restored = 0
terminal_statuses = TERMINAL_STATUSES.union({"terminated"})
# 对话级隔离:仅恢复本 manager 绑定对话的任务;未绑定(工作区级
# 服务 terminal不恢复任务延迟到对应对话激活创建对话级 terminal时恢复。
owner_cid = getattr(self, "owner_conversation_id", None)
for task_id, task in list(self.tasks.items()):
if not isinstance(task, dict):
continue
# 仅恢复多智能体模式任务;传统子智能体保持原有清理逻辑
if not task.get("multi_agent_mode"):
continue
if not owner_cid or task.get("conversation_id") != owner_cid:
continue
status = task.get("status", "running")
if status in terminal_statuses:
continue

View File

@ -440,13 +440,18 @@ def idle_reaper_loop():
def start_background_jobs():
"""启动一次性的后台任务(容器空闲回收)。"""
"""启动一次性的后台任务(容器空闲回收 + 对话级 terminal TTL 回收)。"""
global _idle_reaper_started
if _idle_reaper_started:
return
_idle_reaper_started = True
_load_last_active_cache()
socketio.start_background_task(idle_reaper_loop)
try:
from .context import start_conversation_terminal_reaper
start_conversation_terminal_reaper()
except Exception as exc:
debug_log(f"[ConvTerminalReaper] 启动失败: {exc}")
TITLE_DEBUG_DIR = Path(LOGS_DIR).expanduser().resolve() / "title_debug"
TITLE_DEBUG_FILE = TITLE_DEBUG_DIR / "title_generation.log"

View File

@ -1,8 +1,10 @@
"""用户终端与工作区相关的共享辅助函数。"""
from __future__ import annotations
import os
import time
from functools import wraps
from typing import Optional, Tuple, Dict, Any
from flask import session, jsonify, has_request_context
from flask import session, jsonify, has_request_context, request
from core.web_terminal import WebTerminal
from modules.gui_file_manager import GuiFileManager
@ -39,15 +41,62 @@ def make_terminal_callback(username: str):
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 _make_terminal_key(username: str, workspace_id: Optional[str] = None) -> str:
return f"{username}::{workspace_id}" if workspace_id else username
def _make_terminal_key(
username: str,
workspace_id: Optional[str] = None,
conversation_id: Optional[str] = None,
) -> str:
"""终端缓存键。
对话级隔离传入 conversation_id 时键为 ``username::workspace_id::conversation_id``
每个对话拥有独立的 WebTerminalcontext/file/terminal manager子智能体等
不传 conversation_id 时保持旧的两段键工作区级服务实例供对话列表等无对话上下文的 API 使用
"""
base = f"{username}::{workspace_id}" if workspace_id else username
if conversation_id:
base = f"{base}::{conversation_id}"
return base
def _wrap_callback_with_conversation_id(callback, conversation_id: Optional[str]):
"""包装广播回调,为 dict 类型的事件数据注入 conversation_idsetdefault不覆盖已有值
对话级 terminal 的广播shell 输出terminal 列表等仍发到用户房间
前端按 conversation_id 过滤避免同工作区多个对话的终端事件互相串扰
"""
if not callback or not conversation_id:
return callback
def _wrapped(event_type, data):
if isinstance(data, dict):
data = dict(data)
data.setdefault("conversation_id", conversation_id)
return callback(event_type, data)
return _wrapped
def _touch_terminal_activity(terminal: Optional[WebTerminal], conversation_id: Optional[str]) -> None:
"""对话级 terminal刷新最近活动时间供 24h TTL 回收器判定)。"""
if not terminal or not conversation_id:
return
try:
terminal.last_activity_at = time.time()
except Exception:
pass
def _set_terminal_workspace_label(terminal: WebTerminal, label: Optional[str]) -> None:
@ -132,7 +181,14 @@ def get_user_resources(
username: Optional[str] = None,
workspace_id: Optional[str] = None,
update_session: bool = True,
conversation_id: Optional[str] = None,
) -> Tuple[Optional[WebTerminal], Optional['modules.user_manager.UserWorkspace']]:
"""获取用户终端与工作区资源。
conversation_id 非空时返回对话级 terminal每对话独立的 shell/文件/子智能体状态
常驻内存 + 24h 无活动回收为空时返回工作区级服务 terminal对话列表等无对话
上下文的 API 使用容器句柄始终按工作区级共享
"""
from modules.user_manager import UserWorkspace
username = (username or get_current_username())
if not username:
@ -202,8 +258,10 @@ def get_user_resources(
workspace.workspace_id = host_workspace.get("workspace_id") or "default"
workspace_id_value = getattr(workspace, "workspace_id", None) or host_workspace.get("workspace_id") or "default"
term_key = _make_terminal_key("host", workspace_id_value)
container_handle = state.container_manager.ensure_container("host", str(project_path), container_key=term_key, preferred_mode="host")
term_key = _make_terminal_key("host", workspace_id_value, conversation_id)
# 容器句柄始终按工作区级共享:对话级 terminal 在同一容器内起独立 shell 进程
container_key = _make_terminal_key("host", workspace_id_value)
container_handle = state.container_manager.ensure_container("host", str(project_path), container_key=container_key, preferred_mode="host")
usage_tracker = None # 宿主机模式不计配额
terminal = state.user_terminals.get(term_key)
target_project_path = str(project_path)
@ -273,7 +331,8 @@ def get_user_resources(
message_callback=make_terminal_callback("host"),
data_dir=str(data_dir),
container_session=container_handle,
usage_tracker=usage_tracker
usage_tracker=usage_tracker,
conversation_id=conversation_id,
)
if terminal.terminal_manager:
terminal.terminal_manager.broadcast = terminal.message_callback
@ -341,6 +400,7 @@ def get_user_resources(
_apply_workspace_personalization_preferences(terminal, workspace)
_ensure_workspace_skills_synced(terminal, workspace)
_touch_terminal_activity(terminal, conversation_id)
write_host_workspace_debug(
"context.get_user_resources.host.return",
terminal_id=id(terminal),
@ -374,8 +434,10 @@ def get_user_resources(
except Exception:
pass
workspace_id_value = getattr(workspace, "workspace_id", None) or "default"
term_key = _make_terminal_key(username, workspace_id_value)
container_handle = state.container_manager.ensure_container(username, str(workspace.project_path), container_key=term_key, preferred_mode="docker")
term_key = _make_terminal_key(username, workspace_id_value, conversation_id)
# 容器句柄始终按工作区级共享docker 模式:一个工作区/项目一个容器)
container_key = _make_terminal_key(username, workspace_id_value)
container_handle = state.container_manager.ensure_container(username, str(workspace.project_path), container_key=container_key, preferred_mode="docker")
usage_tracker = None if is_api_user else get_or_create_usage_tracker(username, workspace)
terminal = state.user_terminals.get(term_key)
if not terminal:
@ -406,7 +468,8 @@ def get_user_resources(
message_callback=make_terminal_callback(username),
data_dir=str(workspace.data_dir),
container_session=container_handle,
usage_tracker=usage_tracker
usage_tracker=usage_tracker,
conversation_id=conversation_id,
)
if terminal.terminal_manager:
terminal.terminal_manager.broadcast = terminal.message_callback
@ -480,6 +543,7 @@ def get_user_resources(
_apply_workspace_personalization_preferences(terminal, workspace, update_session=update_session)
_ensure_workspace_skills_synced(terminal, workspace)
_touch_terminal_activity(terminal, conversation_id)
return terminal, workspace
@ -557,12 +621,25 @@ def apply_conversation_overrides(terminal: WebTerminal, workspace, conversation_
def with_terminal(func):
"""注入用户专属终端和工作区"""
"""注入用户专属终端和工作区。
请求携带 conversation_idquery 参数或 JSON body时返回该对话的对话级 terminal
否则返回工作区级服务 terminal
"""
@wraps(func)
def wrapper(*args, **kwargs):
username = get_current_username()
conversation_id = None
try:
terminal, workspace = get_user_resources(username)
conversation_id = (request.args.get("conversation_id") or "").strip() or None
if not conversation_id and request.is_json:
body = request.get_json(silent=True) or {}
if isinstance(body, dict):
conversation_id = (body.get("conversation_id") or "").strip() or None
except Exception:
conversation_id = None
try:
terminal, workspace = get_user_resources(username, conversation_id=conversation_id)
except RuntimeError as exc:
return jsonify({"error": str(exc), "code": "resource_busy"}), 503
if not terminal or not workspace:
@ -576,12 +653,12 @@ def with_terminal(func):
return wrapper
def get_terminal_for_sid(sid: str):
def get_terminal_for_sid(sid: str, conversation_id: Optional[str] = None):
username = state.connection_users.get(sid)
if not username:
return None, None, None
try:
terminal, workspace = get_user_resources(username)
terminal, workspace = get_user_resources(username, conversation_id=conversation_id)
except RuntimeError:
return username, None, None
return username, terminal, workspace
@ -715,6 +792,109 @@ def reset_system_state(terminal: Optional[WebTerminal]):
debug_log(f"错误详情: {traceback.format_exc()}")
# ====== 对话级 terminal 24h TTL 回收器 ======
# 对话级 terminalkey 为 username::workspace_id::conversation_id 三段)常驻内存,
# 仅当「超过 TTL 无活动 且 该对话无运行中工作」时回收;工作区级服务 terminal 不回收。
CONVERSATION_TERMINAL_TTL_SECONDS = float(os.environ.get("CONVERSATION_TERMINAL_TTL_SECONDS", str(24 * 3600)))
CONVERSATION_TERMINAL_REAP_INTERVAL_SECONDS = float(os.environ.get("CONVERSATION_TERMINAL_REAP_INTERVAL_SECONDS", "600"))
_conversation_terminal_reaper_started = False
def _conversation_terminal_has_running_work(
username: str,
workspace_id: str,
conversation_id: str,
terminal: WebTerminal,
) -> bool:
"""判定对话是否仍有运行中的工作(主任务/子智能体/后台命令/多智能体)。
判定失败时保守返回 True不回收
"""
try:
from .tasks import task_manager
active_statuses = {"pending", "running", "cancel_requested"}
for rec in task_manager.list_tasks(username, workspace_id):
if rec.conversation_id == conversation_id and rec.status in active_statuses:
return True
status = task_manager.get_conversation_running_status(terminal, conversation_id)
if any(bool(v) for v in (status or {}).values()):
return True
except Exception as exc:
debug_log(f"[ConvTerminalReaper] 运行状态判定失败 {conversation_id}: {exc}")
return True
return False
def reap_idle_conversation_terminals(now: Optional[float] = None) -> int:
"""回收超过 TTL 且无运行任务的对话级 terminal返回回收数量可测试"""
now = now or time.time()
reaped = 0
for term_key, terminal in list(state.user_terminals.items()):
parts = term_key.split("::")
if len(parts) < 3:
continue # 工作区级服务 terminal 不回收
username, workspace_id = parts[0], parts[1]
conversation_id = "::".join(parts[2:])
try:
last_active = float(getattr(terminal, "last_activity_at", None))
except (TypeError, ValueError):
last_active = None
if last_active is None:
# 无时间戳实例(旧版本创建):补上当前时间,下轮再判定
try:
terminal.last_activity_at = now
except Exception:
pass
continue
if now - last_active < CONVERSATION_TERMINAL_TTL_SECONDS:
continue
if _conversation_terminal_has_running_work(username, workspace_id, conversation_id, terminal):
continue
try:
cm = getattr(terminal, "context_manager", None)
if cm and getattr(cm, "current_conversation_id", None):
cm.save_current_conversation()
except Exception as exc:
debug_log(f"[ConvTerminalReaper] 保存对话失败 {conversation_id}: {exc}")
try:
tm = getattr(terminal, "terminal_manager", None)
if tm:
tm.close_all()
except Exception as exc:
debug_log(f"[ConvTerminalReaper] 关闭 shell 失败 {conversation_id}: {exc}")
try:
mcp = getattr(terminal, "mcp_client_manager", None)
if mcp:
mcp.close_all_clients()
except Exception as exc:
debug_log(f"[ConvTerminalReaper] 关闭 MCP 失败 {conversation_id}: {exc}")
state.user_terminals.pop(term_key, None)
reaped += 1
debug_log(f"[ConvTerminalReaper] 已回收对话级 terminal: {term_key} (idle {int(now - last_active)}s)")
return reaped
def _conversation_terminal_reaper_loop():
"""后台循环:定期扫描回收空闲对话级 terminal。"""
while True:
try:
reap_idle_conversation_terminals()
time.sleep(CONVERSATION_TERMINAL_REAP_INTERVAL_SECONDS)
except Exception as exc:
debug_log(f"[ConvTerminalReaper] 后台循环异常: {exc}")
time.sleep(CONVERSATION_TERMINAL_REAP_INTERVAL_SECONDS)
def start_conversation_terminal_reaper():
"""幂等启动对话级 terminal TTL 回收后台线程。"""
global _conversation_terminal_reaper_started
if _conversation_terminal_reaper_started:
return
_conversation_terminal_reaper_started = True
from .extensions import socketio
socketio.start_background_task(_conversation_terminal_reaper_loop)
__all__ = [
"get_user_resources",
"with_terminal",
@ -727,4 +907,6 @@ __all__ = [
"get_or_create_usage_tracker",
"emit_user_quota_update",
"attach_user_broadcast",
"reap_idle_conversation_terminals",
"start_conversation_terminal_reaper",
]

View File

@ -399,7 +399,7 @@ def list_active_sub_agents_api():
conversation_id = (request.args.get("conversation_id") or "").strip()
if not conversation_id:
return jsonify({"success": False, "error": "缺少 conversation_id 参数"}), 400
terminal, _ = get_user_resources(username)
terminal, _ = get_user_resources(username, conversation_id=conversation_id)
if not terminal:
return jsonify({"success": False, "error": "工作区未就绪"}), 503
sub_agent_manager = getattr(terminal, "sub_agent_manager", None)

View File

@ -154,7 +154,8 @@ def handle_terminal_subscribe(data):
session_name = data.get('session')
subscribe_all = data.get('all', False)
username, terminal, _ = get_terminal_for_sid(request.sid)
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
@ -211,8 +212,9 @@ def handle_get_terminal_output(data):
"""获取终端输出历史"""
session_name = data.get('session')
lines = data.get('lines', 50)
username, terminal, _ = get_terminal_for_sid(request.sid)
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

View File

@ -51,6 +51,61 @@ def list_tasks_api():
]
})
@tasks_bp.route("/api/conversations/<conversation_id>/running-status", methods=["GET"])
@api_login_required
def get_conversation_running_status_api(conversation_id: str):
"""REST 对账接口:聚合某对话的完整运行状态。
前端架构事件流250ms 任务轮询 + socket负责即时性本接口周期对账负责
正确性冲突以对账为准聚合四类状态 task / 传统后台子智能体 / 后台命令 /
多智能体实例与待消费消息
"""
username = get_current_username()
conversation_id = (conversation_id or "").strip()
if not conversation_id:
return jsonify({"success": False, "error": "缺少 conversation_id"}), 400
# ① 主 task内存中该对话是否有活动任务取最新一条
active_statuses = {"pending", "running", "cancel_requested"}
main_rec = None
for rec in task_manager.list_tasks(username):
if rec.conversation_id != conversation_id or rec.status not in active_statuses:
continue
if main_rec is None or rec.created_at > main_rec.created_at:
main_rec = rec
# ②③④ 需要 terminalsub_agent_manager / background_command_manager 挂在 terminal 上)
workspace_id = (
(request.args.get("workspace_id") or "").strip()
or (main_rec.workspace_id if main_rec else "")
or (session.get("workspace_id") or "")
) or None
bg_status = {
"has_running_sub_agents": False,
"has_running_background_commands": False,
"has_running_multi_agent": False,
}
try:
terminal, _workspace = get_user_resources(username, workspace_id=workspace_id, conversation_id=conversation_id)
except Exception as exc:
debug_log(f"[TaskAPI] running-status 获取终端失败: {exc}")
terminal = None
if terminal:
bg_status = task_manager.get_conversation_running_status(terminal, conversation_id)
is_main_running = main_rec is not None
return jsonify({
"success": True,
"data": {
"conversation_id": conversation_id,
"is_main_running": is_main_running,
"main_task_id": main_rec.task_id if main_rec else None,
"main_task_type": getattr(main_rec, "task_type", "chat") if main_rec else None,
**bg_status,
"is_truly_active": is_main_running or any(bg_status.values()),
}
})
@tasks_bp.route("/api/tasks", methods=["POST"])
@api_login_required
def create_task_api():
@ -72,7 +127,7 @@ def create_task_api():
# 避免新对话错误继承旧目标状态。
if not goal_mode:
try:
_terminal, workspace = get_user_resources(username, workspace_id)
_terminal, workspace = get_user_resources(username, workspace_id, conversation_id=conversation_id)
if workspace:
gsm = GoalStateManager(workspace.data_dir)
if gsm.is_active():
@ -89,7 +144,7 @@ def create_task_api():
f"count={len(raw_skill_refs) if isinstance(raw_skill_refs, list) else 'N/A'} "
f"refs={raw_skill_refs!r}"
)
_terminal, workspace = get_user_resources(username, workspace_id)
_terminal, workspace = get_user_resources(username, workspace_id, conversation_id=conversation_id)
if not workspace:
debug_log(f"[SkillsAPI] create_task workspace unavailable user={username} ws={workspace_id}")
return jsonify({"success": False, "error": "工作区不可用"}), 400
@ -209,7 +264,7 @@ def cancel_task_api(task_id: str):
# 用户取消任务时,一并停止该工作区的目标模式,避免后续新对话继承旧目标。
try:
if ok and rec.workspace_id:
_, workspace = get_user_resources(username, rec.workspace_id)
_, workspace = get_user_resources(username, rec.workspace_id, conversation_id=rec.conversation_id)
if workspace:
gsm = GoalStateManager(workspace.data_dir)
if gsm.is_active():

View File

@ -144,12 +144,22 @@ class TaskManager:
raise ValueError("run_mode 只支持 fast/thinking/deep")
run_mode = normalized
normalized_task_type = str(task_type or "chat").strip().lower() or "chat"
# 单工作区互斥:普通 chat 任务禁止同一用户同一工作区并发;
# 单对话互斥:普通 chat 任务禁止同一对话并发(防串写对话历史);
# 同工作区不同对话允许并行(对话级 terminal 隔离)。
# notice通知触发任务允许与已完成的 chat 任务共存,用于后台通知重入。
if normalized_task_type == "chat":
existing = [t for t in self.list_tasks(username, workspace_id) if t.status in {"pending", "running"} and getattr(t, "task_type", "chat") == "chat"]
def _norm_cid(cid):
cid = str(cid or "").strip()
return cid[5:] if cid.startswith("conv_") else cid
target_cid = _norm_cid(conversation_id)
existing = [
t for t in self.list_tasks(username, workspace_id)
if t.status in {"pending", "running"}
and getattr(t, "task_type", "chat") == "chat"
and _norm_cid(getattr(t, "conversation_id", None)) == target_cid
]
if existing:
raise RuntimeError("当前工作区已有运行中的任务,请稍后再试。")
raise RuntimeError("当前对话已有运行中的任务,请稍后再试。")
task_id = str(uuid.uuid4())
record = TaskRecord(task_id, username, workspace_id, message, conversation_id, model_key, thinking_mode, run_mode, max_iterations, task_type=normalized_task_type)
# 记录当前 session 快照,便于后台线程内使用
@ -232,7 +242,7 @@ class TaskManager:
terminal = entry.get('terminal')
if not terminal and rec.workspace_id:
try:
terminal, _ = get_user_resources(username, rec.workspace_id)
terminal, _ = get_user_resources(username, rec.workspace_id, conversation_id=rec.conversation_id)
except Exception:
pass
@ -261,7 +271,7 @@ class TaskManager:
# 2. 停止目标模式
if rec.workspace_id:
try:
_, workspace = get_user_resources(username, rec.workspace_id)
_, workspace = get_user_resources(username, rec.workspace_id, conversation_id=rec.conversation_id)
if workspace:
gsm = GoalStateManager(workspace.data_dir)
if gsm.is_active():
@ -617,38 +627,77 @@ class TaskManager:
return has_running_background
@staticmethod
def _has_running_background(rec: TaskRecord, terminal: Optional[Any]) -> Dict[str, bool]:
"""检测指定任务/对话是否还有运行中的后台子智能体或后台命令。"""
result = {"has_running_sub_agents": False, "has_running_background_commands": False}
if not terminal or not rec.conversation_id:
def get_conversation_running_status(terminal: Optional[Any], conversation_id: Optional[str]) -> Dict[str, bool]:
"""按对话聚合后台运行状态REST 对账接口用)。
返回三类后台工作语义互不重叠
- has_running_sub_agents: 传统后台子智能体排除多智能体实例任务
- has_running_background_commands: 后台命令含终态未通知
- has_running_multi_agent: 多智能体实例在跑非终态多智能体任务
或有待消费的 pending master 消息
"""
result = {
"has_running_sub_agents": False,
"has_running_background_commands": False,
"has_running_multi_agent": False,
}
if not terminal or not conversation_id:
return result
sub_agent_manager = getattr(terminal, 'sub_agent_manager', None)
if sub_agent_manager:
try:
sub_agent_manager.reconcile_task_states(conversation_id=rec.conversation_id)
sub_agent_manager.reconcile_task_states(conversation_id=conversation_id)
terminal_statuses = SUB_AGENT_TERMINAL_STATUSES.union({"terminated"})
for task_info in sub_agent_manager.tasks.values():
if task_info.get('conversation_id') != rec.conversation_id:
if task_info.get('conversation_id') != conversation_id:
continue
status = task_info.get('status')
if status not in SUB_AGENT_TERMINAL_STATUSES.union({"terminated"}):
if status in terminal_statuses:
continue
if task_info.get('multi_agent_mode'):
result["has_running_multi_agent"] = True
else:
result["has_running_sub_agents"] = True
break
except Exception as exc:
debug_log(f"[Task] 检查后台子智能体失败: {exc}")
debug_log(f"[Task] 对账检查子智能体失败: {exc}")
try:
state = sub_agent_manager.get_multi_agent_state(conversation_id)
if state:
has_running_instance = any(a.status == "running" for a in state.list_all())
if has_running_instance or state.has_pending_master_messages():
result["has_running_multi_agent"] = True
except Exception as exc:
debug_log(f"[Task] 对账检查多智能体状态失败: {exc}")
bg_manager = getattr(terminal, 'background_command_manager', None)
if bg_manager:
try:
bg_manager.reconcile_stale_records(conversation_id=rec.conversation_id)
waiting_items = bg_manager.list_waiting_items(rec.conversation_id)
bg_manager.reconcile_stale_records(conversation_id=conversation_id)
waiting_items = bg_manager.list_waiting_items(conversation_id)
if waiting_items:
result["has_running_background_commands"] = True
except Exception as exc:
debug_log(f"[Task] 检查后台命令失败: {exc}")
debug_log(f"[Task] 对账检查后台命令失败: {exc}")
return result
@staticmethod
def _has_running_background(rec: TaskRecord, terminal: Optional[Any]) -> Dict[str, bool]:
"""检测指定任务/对话是否还有运行中的后台子智能体或后台命令。
兼容旧语义多智能体实例任务也计入 sub_agents
"""
result = {"has_running_sub_agents": False, "has_running_background_commands": False}
if not rec:
return result
status = TaskManager.get_conversation_running_status(terminal, rec.conversation_id)
result["has_running_sub_agents"] = (
status["has_running_sub_agents"] or status["has_running_multi_agent"]
)
result["has_running_background_commands"] = status["has_running_background_commands"]
return result
def _append_event(self, rec: TaskRecord, event_type: str, data: Dict[str, Any]):
if isinstance(data, dict):
data = dict(data)
@ -697,7 +746,7 @@ class TaskManager:
)
except Exception:
pass
terminal, workspace = get_user_resources(username, workspace_id=workspace_id)
terminal, workspace = get_user_resources(username, workspace_id=workspace_id, conversation_id=rec.conversation_id)
if not terminal or not workspace:
raise RuntimeError("系统未初始化")
stop_hint = bool(stop_flags.get(rec.task_id, {}).get("stop"))

View File

@ -394,6 +394,7 @@
<TerminalPanel
:width="Math.min(rightWidth || 420, 600)"
:workspace-id="currentHostWorkspaceId"
:conversation-id="currentConversationId"
@close="closeTerminalPanel"
/>
</div>

View File

@ -218,6 +218,8 @@ export const computed = {
return !!this.compressionInProgress || !!this.compressing || !!this.currentWorkspaceHasRunningTask;
},
currentWorkspaceHasRunningTask() {
// 对话级隔离后:仅当「当前对话」有运行中任务时才拦截发送;
// 同工作区其他对话的任务不再阻塞本对话(多对话并行)。
if (!(this.versioningHostMode || this.dockerProjectMode) || !this.currentHostWorkspaceId) {
return false;
}
@ -226,7 +228,7 @@ export const computed = {
const status = String(task?.status || '');
return (
task?.workspace_id === this.currentHostWorkspaceId &&
task?.conversation_id !== this.currentConversationId &&
task?.conversation_id === this.currentConversationId &&
['pending', 'running', 'cancel_requested'].includes(status)
);
});

View File

@ -24,8 +24,8 @@ export const sendMethods = {
(Array.isArray(this.selectedVideos) && this.selectedVideos.length > 0);
if (this.currentWorkspaceHasRunningTask) {
this.uiPushToast({
title: '当前工作区正在运行',
message: '请先在“运行中的任务”中查看或等待任务完成后再发送新消息。',
title: '当前对话正在运行',
message: '请等待当前对话任务完成后再发送新消息;同工作区的其他对话可正常并行。',
type: 'warning'
});
return;
@ -309,6 +309,10 @@ export const sendMethods = {
// 标记任务进行中,直到任务完成或用户手动停止
this.taskInProgress = true;
// 启动运行状态对账循环(幂等):发送消息后确保对账兜底在运行
if (typeof this.startRunningStateReconcile === 'function') {
this.startRunningStateReconcile();
}
const localMessageSource = usePresetText ? (options?.source === 'runtime_queue_manual_guide' ? 'guidance' : 'presend') : 'user';
this.chatAddUserMessage(message, images, videos, [], localMessageSource);
// 关键体验修复:用户发送后立刻显示 assistant 头部 + 工作中计时 + 等待提示,

View File

@ -96,6 +96,10 @@ export const compressionMethods = {
async restoreTaskState() {
// 清理已处理的事件索引
this.clearProcessedEvents();
// 启动运行状态对账循环(幂等):事件负责即时性,对账负责正确性
if (typeof this.startRunningStateReconcile === 'function') {
this.startRunningStateReconcile();
}
restoreDebugLog('restore:start', {
currentConversationId: this.currentConversationId,
streamingMessage: this.streamingMessage,

View File

@ -1,22 +1,7 @@
// @ts-nocheck
import { debugLog, goalModeDebugLog } from '../common';
import { debugLog } from '../common';
import { useTaskStore } from '../../../stores/task';
import { getMessageVisibility, messageStartsWork } from '../../../utils/messageVisibility';
import {
debugNotifyLog,
keyNotifyLog,
jsonDebug,
userMDebug,
isRestoreDebugEnabled,
restoreDebugLog,
isSystemAutoUserMessagePayload,
isRuntimeModeNoticePayload,
resolveUserMessageSource,
resolveUserMessageMetadata,
isEmptyAssistantPlaceholderMessage,
getOptimisticUserEchoTarget,
findRecentMatchingUserMessage,
} from './shared';
import { debugNotifyLog } from './shared';
export const probeMethods = {
scheduleTodoListRefresh(delayMs = 120) {
@ -32,414 +17,158 @@ export const probeMethods = {
}
}, delay);
},
stopWaitingTaskProbe() {
if (this.waitingTaskProbeTimer) {
debugNotifyLog('[DEBUG_NOTIFY][ui] stopWaitingTaskProbe');
clearInterval(this.waitingTaskProbeTimer);
this.waitingTaskProbeTimer = null;
// ---------- 运行状态对账(事件为主,对账纠偏) ----------
// 架构事件流250ms 任务轮询 + socket负责即时性本对账循环每 2.5s 调用
// GET /api/conversations/<id>/running-status 获取服务端权威聚合状态,负责正确性。
// 冲突以对账为准:恢复方向立即执行,清理方向需连续 2 次服务端空闲确认
// (防止 notice 任务创建间隙、多智能体 idle dispatch 间隙造成误清抖动)。
startRunningStateReconcile() {
if (this.runningStateReconcileTimer) {
return;
}
this._waitingProbeStableEmptyCount = 0;
this._runningStateIdleStreak = 0;
this.runningStateReconcileTimer = setInterval(() => {
void this.reconcileRunningStateOnce();
}, 2500);
void this.reconcileRunningStateOnce();
},
stopRunningStateReconcile() {
if (this.runningStateReconcileTimer) {
clearInterval(this.runningStateReconcileTimer);
this.runningStateReconcileTimer = null;
}
this._runningStateIdleStreak = 0;
},
async reconcileRunningStateOnce() {
const conversationId = this.currentConversationId;
if (!conversationId) {
return;
}
let status: any = null;
try {
const wsParam =
(this.versioningHostMode || this.dockerProjectMode) && this.currentHostWorkspaceId
? `?workspace_id=${encodeURIComponent(this.currentHostWorkspaceId)}`
: '';
const resp = await fetch(
`/api/conversations/${encodeURIComponent(conversationId)}/running-status${wsParam}`
);
if (!resp.ok) {
return; // 网络/鉴权异常不做任何状态变更,避免误清
}
const result = await resp.json().catch(() => ({}));
if (!result?.success || !result?.data) {
return;
}
status = result.data;
} catch (error) {
return; // 请求失败不做任何状态变更
}
if (this.currentConversationId !== conversationId) {
return; // 请求期间切换了对话,丢弃过期结果
}
const taskStore = useTaskStore();
if (status.is_truly_active) {
this._runningStateIdleStreak = 0;
// 恢复方向(立即执行,宁多勿漏):服务端活跃但本地缺轮询/缺标志位
if (status.is_main_running && status.main_task_id && !taskStore.hasActiveTask) {
debugNotifyLog('[DEBUG_NOTIFY][ui] reconcile:resume-main-task', {
taskId: status.main_task_id,
conversationId
});
if (typeof this.clearProcessedEvents === 'function') {
this.clearProcessedEvents();
}
taskStore.resumeTask(status.main_task_id, {
status: 'running',
resetOffset: true,
eventHandler: (event: any) => this.handleTaskEvent(event)
});
this.taskInProgress = true;
}
if (status.has_running_sub_agents) {
this.waitingForSubAgent = true;
this.taskInProgress = true;
}
if (status.has_running_background_commands) {
this.waitingForBackgroundCommand = true;
this.taskInProgress = true;
}
if (status.has_running_multi_agent) {
// 多智能体活跃不阻塞输入区(不设置 waitingForSubAgent
this.taskInProgress = true;
}
return;
}
// 清理方向(保守,需连续确认):服务端确认空闲
const localUiActive =
this.streamingMessage ||
this.taskInProgress ||
this.waitingForSubAgent ||
this.waitingForBackgroundCommand;
if (!localUiActive && !taskStore.hasActiveTask) {
this._runningStateIdleStreak = 0;
return; // 双端一致空闲
}
if (taskStore.isPolling) {
// 事件轮询仍活跃:终态事件会正常收尾,对账不干预(避免截断尾部事件)
return;
}
this._runningStateIdleStreak = (this._runningStateIdleStreak || 0) + 1;
if (this._runningStateIdleStreak < 2) {
return;
}
this._runningStateIdleStreak = 0;
debugLog('[TaskPolling] 对账清理:服务端确认对话已空闲,清除本地卡住的运行状态', {
conversationId,
streamingMessage: this.streamingMessage,
taskInProgress: this.taskInProgress,
waitingForSubAgent: this.waitingForSubAgent,
waitingForBackgroundCommand: this.waitingForBackgroundCommand,
taskStatus: taskStore.taskStatus
});
this.streamingMessage = false;
this.taskInProgress = false;
this.stopRequested = false;
this.waitingForSubAgent = false;
this.waitingForBackgroundCommand = false;
if (typeof this.clearPendingTools === 'function') {
this.clearPendingTools('reconcile_auto_clear');
}
this.clearTaskState();
this.$forceUpdate();
},
// 兼容旧调用点:原"等待子智能体探测"与"多智能体任务探测"已收敛为单一对账循环,
// 对账循环按会话常驻,不再随单个探测启动/停止。
stopWaitingTaskProbe() {
// no-op保留方法签名避免改动所有调用点
},
startWaitingTaskProbe() {
if (this.waitingTaskProbeTimer) {
debugNotifyLog('[DEBUG_NOTIFY][ui] startWaitingTaskProbe:already-started');
return;
}
debugNotifyLog('[DEBUG_NOTIFY][ui] startWaitingTaskProbe');
keyNotifyLog('[DEBUG_NOTIFY_KEY][ui] startWaitingTaskProbe', {
conversationId: this.currentConversationId
});
this.waitingTaskProbeTimer = setInterval(async () => {
if (!this.waitingForSubAgent) {
this.stopWaitingTaskProbe();
return;
}
const resumed = await this.tryResumeWaitingNoticeTask();
if (resumed) {
debugNotifyLog('[DEBUG_NOTIFY][ui] waitingTaskProbe:resumed-and-stop');
keyNotifyLog('[DEBUG_NOTIFY_KEY][ui] waitingTaskProbe:resumed-and-stop');
this.stopWaitingTaskProbe();
return;
}
const state = await this.inspectWaitingCompletionState();
if (state.hasPending) {
this._waitingProbeStableEmptyCount = 0;
this.waitingForBackgroundCommand = !!state.hasBackgroundPending;
return;
}
this._waitingProbeStableEmptyCount = (this._waitingProbeStableEmptyCount || 0) + 1;
if (this._waitingProbeStableEmptyCount >= 2) {
debugLog('[TaskPolling] 等待态兜底清理:未发现运行中的后台子智能体/后台指令');
this.streamingMessage = false;
this.taskInProgress = false;
this.stopRequested = false;
this.waitingForSubAgent = false;
this.waitingForBackgroundCommand = false;
if (typeof this.clearPendingTools === 'function') {
this.clearPendingTools('waiting_probe_auto_clear');
}
this.clearTaskState();
this.stopWaitingTaskProbe();
this.$forceUpdate();
}
}, 1000);
this.startRunningStateReconcile();
},
// ---------- 多智能体任务探测 ----------
// ---------- 多智能体任务探测(已收敛为对账循环的兼容入口) ----------
stopMultiAgentTaskProbe() {
if (this.multiAgentTaskProbeTimer) {
debugNotifyLog('[DEBUG_NOTIFY][ui] stopMultiAgentTaskProbe');
goalModeDebugLog('probe.stopMultiAgentTaskProbe', {
conversationId: this.currentConversationId,
taskInProgress: this.taskInProgress
});
clearInterval(this.multiAgentTaskProbeTimer);
this.multiAgentTaskProbeTimer = null;
}
// no-op对账循环常驻保留方法签名避免改动所有调用点
},
startMultiAgentTaskProbe() {
if (this.multiAgentTaskProbeTimer) {
debugNotifyLog('[DEBUG_NOTIFY][ui] startMultiAgentTaskProbe:already-started');
return;
}
debugNotifyLog('[DEBUG_NOTIFY][ui] startMultiAgentTaskProbe');
keyNotifyLog('[DEBUG_NOTIFY_KEY][ui] startMultiAgentTaskProbe', {
conversationId: this.currentConversationId
});
goalModeDebugLog('probe.startMultiAgentTaskProbe', {
conversationId: this.currentConversationId,
taskInProgress: this.taskInProgress
});
this.multiAgentTaskProbeTimer = setInterval(async () => {
if (!this.taskInProgress || !this.currentConversationId) {
debugNotifyLog('[DEBUG_NOTIFY][ui] multiAgentTaskProbe:stop-no-task-in-progress');
goalModeDebugLog('probe.multiAgentTaskProbe_stop', {
reason: 'no_task_in_progress',
taskInProgress: this.taskInProgress,
currentConversationId: this.currentConversationId
});
this.stopMultiAgentTaskProbe();
return;
}
goalModeDebugLog('probe.multiAgentTaskProbe_tick', {
currentConversationId: this.currentConversationId
});
const resumed = await this.tryResumeRunningTask();
if (resumed) {
debugNotifyLog('[DEBUG_NOTIFY][ui] multiAgentTaskProbe:resumed-and-stop');
keyNotifyLog('[DEBUG_NOTIFY_KEY][ui] multiAgentTaskProbe:resumed-and-stop');
this.stopMultiAgentTaskProbe();
}
}, 1000);
},
async tryResumeRunningTask() {
if (!this.currentConversationId) {
return false;
}
try {
const { useTaskStore } = await import('../../../stores/task');
const taskStore = useTaskStore();
if (taskStore.hasActiveTask) {
debugNotifyLog('[DEBUG_NOTIFY][ui] tryResumeRunningTask:already-active', {
taskId: taskStore.currentTaskId,
status: taskStore.taskStatus
});
return true;
}
const resp = await fetch('/api/tasks');
if (!resp.ok) {
debugNotifyLog('[DEBUG_NOTIFY][ui] tryResumeRunningTask:tasks-api-not-ok', {
status: resp.status
});
return false;
}
const result = await resp.json().catch(() => ({}));
const tasks = Array.isArray(result?.data) ? result.data : [];
debugNotifyLog('[DEBUG_NOTIFY][ui] tryResumeRunningTask:tasks', {
currentConversationId: this.currentConversationId,
count: tasks.length,
running: tasks
.filter((t: any) => t?.status === 'running')
.map((t: any) => ({
task_id: t?.task_id,
conversation_id: t?.conversation_id,
status: t?.status
}))
});
const runningTask = tasks.find(
(task: any) =>
(task?.status === 'running' || task?.status === 'pending') &&
task?.conversation_id === this.currentConversationId &&
(!(this.versioningHostMode || this.dockerProjectMode) ||
!this.currentHostWorkspaceId ||
task?.workspace_id === this.currentHostWorkspaceId)
);
if (!runningTask?.task_id) {
debugNotifyLog('[DEBUG_NOTIFY][ui] tryResumeRunningTask:no-matched-task');
goalModeDebugLog('probe.tryResumeRunningTask_no_match', {
currentConversationId: this.currentConversationId,
tasksCount: tasks.length
});
return false;
}
debugNotifyLog('[DEBUG_NOTIFY][ui] tryResumeRunningTask:resume', {
task_id: runningTask.task_id
});
goalModeDebugLog('probe.tryResumeRunningTask_resume', {
taskId: runningTask.task_id,
conversationId: this.currentConversationId
});
keyNotifyLog('[DEBUG_NOTIFY_KEY][ui] tryResumeRunningTask:resume', {
task_id: runningTask.task_id,
conversationId: this.currentConversationId
});
if (typeof this.clearProcessedEvents === 'function') {
this.clearProcessedEvents();
}
taskStore.resumeTask(runningTask.task_id, {
status: 'running',
resetOffset: true,
eventHandler: (event: any) => this.handleTaskEvent(event)
});
return true;
} catch (error) {
console.warn('[TaskPolling] 接管运行中任务失败:', error);
return false;
}
},
async inspectWaitingCompletionState() {
const terminalStatuses = new Set(['completed', 'failed', 'timeout', 'terminated', 'cancelled']);
let hasSubAgentPending = false;
let hasBackgroundPending = false;
let resolved = true;
try {
const [subResp, bgResp] = await Promise.all([
fetch('/api/sub_agents'),
fetch('/api/background_commands?limit=200')
]);
if (subResp.ok) {
const subResult = await subResp.json().catch(() => ({}));
const tasks = Array.isArray(subResult?.data) ? subResult.data : [];
const relevantTasks = tasks.filter((task: any) => {
if (!task) return false;
if (task.conversation_id && task.conversation_id !== this.currentConversationId)
return false;
return true;
});
hasSubAgentPending = relevantTasks.some((task: any) => {
if (!task?.run_in_background) return false;
const status = String(task?.status || '').toLowerCase();
return !terminalStatuses.has(status) || !!task?.notice_pending;
});
} else {
resolved = false;
}
if (bgResp.ok) {
const bgResult = await bgResp.json().catch(() => ({}));
const commands = Array.isArray(bgResult?.data) ? bgResult.data : [];
hasBackgroundPending = commands.some((command: any) => {
if (!command) return false;
const status = String(command?.status || '').toLowerCase();
return !terminalStatuses.has(status) || !!command?.notice_pending;
});
} else {
resolved = false;
}
} catch (error) {
debugNotifyLog('[DEBUG_NOTIFY][ui] inspectWaitingCompletionState:error', {
error: error?.message || String(error)
});
return {
resolved: false,
hasPending: true,
hasBackgroundPending: this.waitingForBackgroundCommand
};
}
return {
resolved,
hasPending: !resolved || hasSubAgentPending || hasBackgroundPending,
hasBackgroundPending
};
},
async tryResumeWaitingNoticeTask() {
if (!this.waitingForSubAgent || !this.currentConversationId) {
debugNotifyLog('[DEBUG_NOTIFY][ui] tryResumeWaitingNoticeTask:skip', {
waitingForSubAgent: this.waitingForSubAgent,
currentConversationId: this.currentConversationId
});
return false;
}
try {
const { useTaskStore } = await import('../../../stores/task');
const taskStore = useTaskStore();
if (taskStore.hasActiveTask) {
debugNotifyLog('[DEBUG_NOTIFY][ui] tryResumeWaitingNoticeTask:already-active', {
taskId: taskStore.currentTaskId,
status: taskStore.taskStatus
});
return true;
}
const resp = await fetch('/api/tasks');
if (!resp.ok) {
debugNotifyLog('[DEBUG_NOTIFY][ui] tryResumeWaitingNoticeTask:tasks-api-not-ok', {
status: resp.status
});
return false;
}
const result = await resp.json().catch(() => ({}));
const tasks = Array.isArray(result?.data) ? result.data : [];
debugNotifyLog('[DEBUG_NOTIFY][ui] tryResumeWaitingNoticeTask:tasks', {
currentConversationId: this.currentConversationId,
count: tasks.length,
running: tasks
.filter((t: any) => t?.status === 'running')
.map((t: any) => ({
task_id: t?.task_id,
conversation_id: t?.conversation_id,
status: t?.status
}))
});
const runningTask = tasks.find(
(task: any) =>
task?.status === 'running' &&
task?.conversation_id === this.currentConversationId &&
(!(this.versioningHostMode || this.dockerProjectMode) ||
!this.currentHostWorkspaceId ||
task?.workspace_id === this.currentHostWorkspaceId)
);
if (!runningTask?.task_id) {
debugNotifyLog('[DEBUG_NOTIFY][ui] tryResumeWaitingNoticeTask:no-matched-task');
return false;
}
debugNotifyLog('[DEBUG_NOTIFY][ui] tryResumeWaitingNoticeTask:resume', {
task_id: runningTask.task_id
});
keyNotifyLog('[DEBUG_NOTIFY_KEY][ui] tryResumeWaitingNoticeTask:resume', {
task_id: runningTask.task_id,
conversationId: this.currentConversationId
});
// 关键修复:切到新的通知任务前,清空旧任务事件去重索引。
// 否则新任务 event idx 与旧任务重叠时会被误判为“重复事件”而丢失 user_message。
if (typeof this.clearProcessedEvents === 'function') {
this.clearProcessedEvents();
}
taskStore.resumeTask(runningTask.task_id, {
status: 'running',
resetOffset: true,
eventHandler: (event: any) => this.handleTaskEvent(event)
});
return true;
} catch (error) {
console.warn('[TaskPolling] 接管等待任务失败:', error);
return false;
}
this.startRunningStateReconcile();
},
async restoreSubAgentWaitingState(retry = 0) {
try {
if (!this.currentConversationId) {
if (retry < 5) {
setTimeout(() => {
this.restoreSubAgentWaitingState(retry + 1);
}, 300);
}
return;
// 初始化恢复与周期对账共用同一接口:启动对账循环即会立即对账一次,
// 服务端活跃则恢复等待态/事件轮询,服务端空闲则不设置任何运行标志。
if (!this.currentConversationId) {
if (retry < 5) {
setTimeout(() => {
this.restoreSubAgentWaitingState(retry + 1);
}, 300);
}
const response = await fetch('/api/sub_agents');
if (!response.ok) {
debugLog('[TaskPolling] 获取子智能体状态失败');
return;
}
const result = await response.json();
if (!result.success) {
debugLog('[TaskPolling] 子智能体状态响应无效');
return;
}
const tasks = Array.isArray(result.data) ? result.data : [];
debugLog('[TaskPolling] 子智能体状态响应', {
total: tasks.length,
currentConversationId: this.currentConversationId,
tasks: tasks.map((task: any) => ({
task_id: task?.task_id,
status: task?.status,
run_in_background: task?.run_in_background,
notice_pending: task?.notice_pending,
conversation_id: task?.conversation_id
}))
});
const terminalStatuses = new Set(['completed', 'failed', 'timeout', 'terminated']);
const relevant = tasks.filter((task: any) => {
if (task && task.conversation_id && task.conversation_id !== this.currentConversationId) {
return false;
}
return true;
});
const running = relevant.filter(
(task: any) => task?.run_in_background && !terminalStatuses.has(task?.status)
);
const pendingNotice = relevant.filter((task: any) => task?.notice_pending);
let backgroundPending = false;
try {
const bgResp = await fetch('/api/background_commands?limit=200');
if (bgResp.ok) {
const bgResult = await bgResp.json().catch(() => ({}));
const bgCommands = Array.isArray(bgResult?.data) ? bgResult.data : [];
backgroundPending = bgCommands.some((command: any) => {
if (!command) return false;
const status = String(command?.status || '').toLowerCase();
return !terminalStatuses.has(status) || !!command?.notice_pending;
});
}
} catch (bgErr) {
console.warn('[TaskPolling] 获取后台指令状态失败:', bgErr);
}
if (running.length || pendingNotice.length || backgroundPending) {
debugLog('[TaskPolling] 恢复子智能体等待状态', {
running: running.length,
pendingNotice: pendingNotice.length,
backgroundPending,
runningTasks: running.map((task: any) => ({
task_id: task?.task_id,
status: task?.status
})),
pendingTasks: pendingNotice.map((task: any) => ({
task_id: task?.task_id,
status: task?.status
}))
});
this.waitingForSubAgent = true;
this.waitingForBackgroundCommand = backgroundPending;
this.taskInProgress = true;
this.streamingMessage = false;
this.stopRequested = false;
this.startWaitingTaskProbe();
this.$forceUpdate();
} else {
this.waitingForSubAgent = false;
this.waitingForBackgroundCommand = false;
this.stopWaitingTaskProbe();
if (!this.streamingMessage) {
this.taskInProgress = false;
}
this.$forceUpdate();
}
} catch (error) {
console.error('[TaskPolling] 恢复子智能体等待状态失败:', error);
return;
}
this.startRunningStateReconcile();
},
clearProcessedEvents() {
if (this._processedEventIndices) {

View File

@ -36,7 +36,7 @@ export const terminalMethods = {
subscribeTerminalEvents() {
const socket = this.socket;
if (!socket) return;
socket.emit('terminal_subscribe', { all: true });
socket.emit('terminal_subscribe', { all: true, conversation_id: this.currentConversationId || undefined });
},
setTerminalSessions(sessions: Record<string, { working_dir?: string; shell?: string }>) {
this.terminalSessions = sessions;
@ -47,12 +47,13 @@ export const terminalMethods = {
switchTerminalSession(name: string) {
this.terminalActiveSession = name;
if (this.socket) {
this.socket.emit('get_terminal_output', { session: name, lines: 0 });
this.socket.emit('get_terminal_output', { session: name, lines: 0, conversation_id: this.currentConversationId || undefined });
}
},
async fetchTerminalCount() {
try {
const res = await fetch('/api/terminals');
const cid = encodeURIComponent(this.currentConversationId || '');
const res = await fetch(`/api/terminals?conversation_id=${cid}`);
if (!res.ok) return;
const data = await res.json();
if (data?.sessions) {

View File

@ -25,8 +25,10 @@ export function dataState() {
toolStacks: new Map(),
// 当前任务是否仍在进行中(用于保持输入区的"停止"状态)
taskInProgress: false,
// 等待后台通知期间,用于发现并接管新建任务的轮询定时器
waitingTaskProbeTimer: null,
// 对话运行状态对账定时器事件为主、2.5s 对账纠偏,冲突以对账为准)
runningStateReconcileTimer: null,
// 对账清理方向的连续空闲确认计数(防 notice/idle dispatch 间隙误清)
_runningStateIdleStreak: 0,
// 宿主机多工作区任务列表后台刷新定时器(用于当前未查看运行对话时同步完成态)
runningWorkspaceTasksRefreshTimer: null,
// 运行期消息堆积(提前发送 / 引导对话)

View File

@ -50,6 +50,7 @@ defineOptions({ name: 'TerminalPanel' });
const props = defineProps<{
width: number;
workspaceId?: string;
conversationId?: string;
}>();
const emit = defineEmits<{
@ -74,6 +75,15 @@ const _historyLastEventTime = ref<Record<string, number>>({});
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' {
const theme = document.documentElement.getAttribute('data-theme');
@ -218,7 +228,7 @@ function switchToSession(name: string) {
_historyLastEventTime.value = updatedTimes;
if (socket?.connected) {
console.log('[TerminalPanel] requesting history for:', name);
socket.emit('get_terminal_output', { session: name, lines: 0 });
socket.emit('get_terminal_output', { session: name, lines: 0, conversation_id: props.conversationId });
}
}
// renderSessionLog watch(activeSession)
@ -257,6 +267,23 @@ watch(() => props.workspaceId, (newId, oldId) => {
}
});
// terminal shell
watch(() => props.conversationId, (newId, oldId) => {
if (newId !== oldId) {
console.log('[TerminalPanel] conversation changed:', oldId, '->', newId);
sessions.value = {};
activeSession.value = '';
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 ----
async function initSocket() {
console.log('[TerminalPanel] initSocket start');
@ -293,7 +320,7 @@ async function initSocket() {
socket.on('connect', () => {
console.log('[TerminalPanel] socket connected');
socket!.emit('terminal_subscribe', { all: true });
socket!.emit('terminal_subscribe', { all: true, conversation_id: props.conversationId });
});
socket.on('disconnect', () => {
@ -315,6 +342,7 @@ async function initSocket() {
});
socket.on('terminal_started', (data: any) => {
if (!acceptTerminalBroadcast(data)) return;
console.log('[TerminalPanel] terminal_started:', data);
sessions.value = {
...sessions.value,
@ -324,6 +352,7 @@ async function initSocket() {
});
socket.on('terminal_list_update', (data: any) => {
if (!acceptTerminalBroadcast(data)) return;
console.log('[TerminalPanel] terminal_list_update:', data);
if (data?.terminals) {
const map: Record<string, { working_dir?: string; shell?: string }> = {};
@ -336,6 +365,7 @@ async function initSocket() {
});
socket.on('terminal_output', (data: any) => {
if (!acceptTerminalBroadcast(data)) return;
const s = data.session;
if (!s) return;
const delta = (data.data || '') as string;
@ -363,6 +393,7 @@ async function initSocket() {
});
socket.on('terminal_input', (data: any) => {
if (!acceptTerminalBroadcast(data)) return;
console.log('[TerminalPanel] terminal_input:', data.session);
//
//
@ -370,6 +401,7 @@ async function initSocket() {
});
socket.on('terminal_closed', (data: any) => {
if (!acceptTerminalBroadcast(data)) return;
console.log('[TerminalPanel] terminal_closed:', data);
const updated = { ...sessions.value };
delete updated[data.session];
@ -381,6 +413,7 @@ async function initSocket() {
});
socket.on('terminal_reset', (data: any) => {
if (!acceptTerminalBroadcast(data)) return;
console.log('[TerminalPanel] terminal_reset:', data);
const target = data.session || activeSession.value;
if (target) {
@ -394,6 +427,7 @@ async function initSocket() {
});
socket.on('terminal_switched', (data: any) => {
if (!acceptTerminalBroadcast(data)) return;
console.log('[TerminalPanel] terminal_switched:', data);
if (data.current) switchToSession(data.current);
});

View File

@ -625,8 +625,8 @@ export async function initializeLegacySocket(ctx: any) {
ctx.isConnected = true;
socketLog('WebSocket已连接');
// 自动订阅终端事件,确保终端面板数据始终就绪
ctx.socket.emit('terminal_subscribe', { all: true });
// 自动订阅终端事件,确保终端面板数据始终就绪(对话级:带当前 conversation_id
ctx.socket.emit('terminal_subscribe', { all: true, conversation_id: ctx.currentConversationId || undefined });
// 首次连接且历史已在加载/已就绪时,避免重复清空与重复动画
if (!isReconnect && (historyLoadingSame || historyReady)) {