fix(multi-agent): 修复消息渲染、分类、存储与滚动抖动

- 子智能体消息渲染为左侧对话气泡
- 多智能体通知细分为进度/完成/提问三类
- 修复多智能体会话存储到 mutiagents 目录并迁移数据
- 修复 token-statistics API 支持多智能体会话
- 修复 ma_debug 未定义报错
- 移除 .message-block content-visibility 修复滚动抖动
This commit is contained in:
JOJO 2026-07-12 23:09:56 +08:00
parent 7f2ad9144d
commit 692748d567
19 changed files with 618 additions and 64 deletions

View File

@ -374,7 +374,8 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
if not cm or not conv_id:
return
try:
cm.conversation_manager.update_conversation_metadata(conv_id, updates)
target_manager = cm._get_conversation_manager_for_id(conv_id) if hasattr(cm, "_get_conversation_manager_for_id") else cm.conversation_manager
target_manager.update_conversation_metadata(conv_id, updates)
if isinstance(getattr(cm, "conversation_metadata", None), dict):
cm.conversation_metadata.update(updates)
except Exception:

View File

@ -218,8 +218,7 @@ class MainTerminalToolsPolicyMixin:
conv_id = conversation_id or getattr(getattr(self, "context_manager", None), "current_conversation_id", None)
if conv_id and getattr(self, "context_manager", None):
try:
self.context_manager.conversation_manager.update_conversation_metadata(
conv_id,
self.context_manager._get_conversation_manager_for_id(conv_id).update_conversation_metadata(conv_id,
{"permission_mode": normalized},
)
if self.context_manager.current_conversation_id == conv_id:

View File

@ -206,7 +206,8 @@ class WebTerminal(MainTerminal):
# 把默认模型同步到新对话的 metadata
try:
self.context_manager.conversation_manager.save_conversation(
target_manager = self.context_manager._get_conversation_manager_for_id(conversation_id)
target_manager.save_conversation(
conversation_id=conversation_id,
messages=self.context_manager.conversation_history,
project_path=str(self.project_path),
@ -275,7 +276,8 @@ class WebTerminal(MainTerminal):
perf_log("_ensure_conversation_versioning_enabled manager created", elapsed_ms=(time.perf_counter() - t0) * 1000, extra={"conv_id": normalized_id})
meta = manager.set_enabled(enabled=True, mode="overwrite", tracking_mode=tracking_mode)
perf_log("_ensure_conversation_versioning_enabled set_enabled done", elapsed_ms=(time.perf_counter() - t0) * 1000, extra={"conv_id": normalized_id})
conv_data = self.context_manager.conversation_manager.load_conversation(normalized_id) or {}
target_manager = self.context_manager._get_conversation_manager_for_id(normalized_id)
conv_data = target_manager.load_conversation(normalized_id) or {}
snapshot_payload = {
"conversation_id": normalized_id,
"title": conv_data.get("title"),
@ -293,7 +295,7 @@ class WebTerminal(MainTerminal):
init_row = init_result.get("row") or {}
if init_row.get("tree_hash"):
meta["last_tree_hash"] = init_row.get("tree_hash")
self.context_manager.conversation_manager.update_conversation_metadata(
target_manager.update_conversation_metadata(
normalized_id,
{
"versioning": {
@ -324,7 +326,8 @@ class WebTerminal(MainTerminal):
if success:
# 根据对话元数据同步思考模式
try:
conv_data = self.context_manager.conversation_manager.load_conversation(conversation_id) or {}
target_manager = self.context_manager._get_conversation_manager_for_id(conversation_id)
conv_data = target_manager.load_conversation(conversation_id) or {}
meta = conv_data.get("metadata", {}) or {}
mode = bool(meta.get("thinking_mode", self.thinking_mode))
self.thinking_mode = mode
@ -371,7 +374,8 @@ class WebTerminal(MainTerminal):
self.current_session_id += 1
# 获取对话信息
conversation_data = self.context_manager.conversation_manager.load_conversation(conversation_id)
target_manager = self.context_manager._get_conversation_manager_for_id(conversation_id)
conversation_data = target_manager.load_conversation(conversation_id)
if not conversation_data:
return {
"success": False,

View File

@ -19,6 +19,7 @@ from __future__ import annotations
import asyncio
import json
import re
import uuid
from asyncio import AbstractEventLoop
from dataclasses import dataclass, field
@ -51,6 +52,7 @@ def format_multi_agent_message(
target: Optional[str] = None,
extra_attrs: Optional[Dict[str, str]] = None,
msg_type_text: Optional[str] = None,
subtype: Optional[str] = None,
) -> str:
"""按统一格式构造 user 消息字符串。
@ -62,6 +64,7 @@ def format_multi_agent_message(
target: 接收方显示名用于子 提问时标明对谁提问
extra_attrs: 额外标签属性 question_id="ask_xxx"
msg_type_text: 覆盖默认的中文消息类型文案"任务结束汇报"
subtype: 渲染/分类使用的子类型 progress_output / completion_report / ask_master
"""
if not msg_id:
msg_id = f"msg_{uuid.uuid4().hex[:10]}"
@ -80,6 +83,8 @@ def format_multi_agent_message(
attrs = ""
if target:
attrs += f' target="{target}"'
if subtype:
attrs += f' subtype="{subtype}"'
if extra_attrs:
for k, v in extra_attrs.items():
attrs += f' {k}="{v}"'
@ -119,6 +124,13 @@ def build_master_dispatch_text(task: str, msg_id: Optional[str] = None) -> str:
)
# 子类型常量(用于前端渲染与后端分类)
SUBTYPE_PROGRESS_OUTPUT = "progress_output"
SUBTYPE_COMPLETION_REPORT = "completion_report"
SUBTYPE_ASK_MASTER = "ask_master"
SUBTYPE_ASK_OTHER = "ask_other"
def build_sub_agent_output_text(display_name: str, content: str, msg_id: Optional[str] = None, *, is_final: bool = False) -> str:
"""子智能体输出(进度或完成)插入到主对话的 user 消息文本。"""
return format_multi_agent_message(
@ -127,6 +139,7 @@ def build_sub_agent_output_text(display_name: str, content: str, msg_id: Optiona
content=content,
msg_id=msg_id,
msg_type_text="任务结束汇报" if is_final else "任务进度输出",
subtype=SUBTYPE_COMPLETION_REPORT if is_final else SUBTYPE_PROGRESS_OUTPUT,
)
@ -137,6 +150,7 @@ def build_sub_agent_ask_master_text(display_name: str, question: str, question_i
msg_type=TYPE_ASK,
content=question,
msg_id=question_id,
subtype=SUBTYPE_ASK_MASTER,
)
@ -153,9 +167,45 @@ def build_sub_agent_ask_other_text(
content=question,
msg_id=question_id,
target=target_display,
subtype=SUBTYPE_ASK_OTHER,
)
_MULTI_AGENT_MESSAGE_RE = re.compile(
r"^来自\s+(?P<display_name>.+?)\s+的(?P<type_text>.+?)\n"
r"id:\s*(?P<msg_id>\S+)\n\n"
r"<(?P=display_name)>\n"
r"<(?P<tag>\w+)(?P<attrs>[^>]*)>\n"
r"(?P<content>.*?)\n"
r"</(?P=tag)>\n"
r"</(?P=display_name)>$",
re.DOTALL,
)
def parse_multi_agent_message(text: str) -> Optional[Dict[str, str]]:
"""解析标准多智能体消息格式。
返回字段display_name, type_text, msg_id, tag, subtype, content
若不是标准格式则返回 None
"""
if not text:
return None
m = _MULTI_AGENT_MESSAGE_RE.search(text)
if not m:
return None
attrs = m.group("attrs") or ""
subtype_match = re.search(r'subtype="([^"]+)"', attrs)
return {
"display_name": m.group("display_name").strip(),
"type_text": m.group("type_text").strip(),
"msg_id": m.group("msg_id").strip(),
"tag": m.group("tag").strip(),
"subtype": subtype_match.group(1) if subtype_match else "",
"content": m.group("content"),
}
def build_master_message_to_sub_agent(message: str, msg_id: Optional[str] = None) -> str:
"""主智能体 send_message_to_sub_agent 时插入子对话的 user 消息文本。"""
return format_multi_agent_message(

View File

@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""把误存到常规对话目录的多智能体会话迁移到 mutiagents/conversations/。
用法:
python3 scripts/migrate_multi_agent_conversations.py [--dry-run]
"""
from __future__ import annotations
import argparse
import json
import shutil
import sys
import time
from datetime import datetime
from pathlib import Path
# 加载项目配置以确定数据目录
project_root = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(project_root))
from config import DATA_DIR, IS_HOST_MODE
from modules.multi_agent.role_store import DEFAULT_MUTIAGENTS_DIR
def load_json(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def save_json(path: Path, data: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(f".tmp.{int(time.time() * 1000)}")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
shutil.move(str(tmp), str(path))
def migrate(dry_run: bool = False) -> dict:
regular_dir = Path(DATA_DIR) / "conversations" / "workspace"
ma_dir = Path(DEFAULT_MUTIAGENTS_DIR) / "conversations" / "workspace"
regular_index_path = regular_dir / "index.json"
ma_index_path = ma_dir / "index.json"
regular_index = load_json(regular_index_path) if regular_index_path.exists() else {}
ma_index = load_json(ma_index_path) if ma_index_path.exists() else {}
moved = []
skipped = []
errors = []
for f in sorted(regular_dir.glob("conv_*.json")):
if f.name == "index.json":
continue
try:
data = load_json(f)
except Exception as exc:
errors.append({"file": str(f), "error": f"读取失败: {exc}"})
continue
meta = data.get("metadata", {}) or {}
if not meta.get("multi_agent_mode"):
skipped.append(f.name)
continue
conv_id = data.get("id") or f.stem
target_file = ma_dir / f.name
# 目标已存在时跳过,避免覆盖
if target_file.exists():
errors.append({"file": str(f), "error": f"目标已存在: {target_file}"})
continue
if dry_run:
moved.append({"id": conv_id, "file": f.name, "target": str(target_file)})
continue
try:
# 移动文件
target_file.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(f), str(target_file))
# 更新索引
if conv_id in regular_index:
ma_index[conv_id] = regular_index.pop(conv_id)
else:
# 从文件重建索引条目
ma_index[conv_id] = {
"title": data.get("title") or "未命名对话",
"created_at": data.get("created_at"),
"updated_at": data.get("updated_at"),
"project_path": meta.get("project_path"),
"project_relative_path": meta.get("project_relative_path"),
"thinking_mode": meta.get("thinking_mode", False),
"run_mode": meta.get("run_mode") or ("thinking" if meta.get("thinking_mode") else "fast"),
"model_key": meta.get("model_key"),
"has_images": meta.get("has_images", False),
"has_videos": meta.get("has_videos", False),
"total_messages": meta.get("total_messages", 0),
"total_tools": meta.get("total_tools", 0),
"status": meta.get("status", "active"),
"multi_agent_mode": True,
}
moved.append({"id": conv_id, "file": f.name, "target": str(target_file)})
except Exception as exc:
errors.append({"file": str(f), "error": f"迁移失败: {exc}"})
if not dry_run:
# 保存索引
save_json(regular_index_path, regular_index)
save_json(ma_index_path, ma_index)
return {
"dry_run": dry_run,
"regular_dir": str(regular_dir),
"ma_dir": str(ma_dir),
"moved": moved,
"skipped_count": len(skipped),
"errors": errors,
}
def main():
parser = argparse.ArgumentParser(description="迁移多智能体会话到 mutiagents 目录")
parser.add_argument("--dry-run", action="store_true", help="只预览,不实际移动")
args = parser.parse_args()
result = migrate(dry_run=args.dry_run)
print(f"常规目录: {result['regular_dir']}")
print(f"目标目录: {result['ma_dir']}")
print(f"模式: {'预览' if args.dry_run else '实际迁移'}")
print(f"将迁移: {len(result['moved'])} 个对话")
print(f"跳过(非多智能体): {result['skipped_count']}")
print(f"错误: {len(result['errors'])}")
for item in result["moved"]:
print(f"{item['id']} -> {item.get('target', item['file'])}")
for err in result["errors"]:
print(f"{err['file']}: {err['error']}")
if __name__ == "__main__":
main()

View File

@ -525,7 +525,7 @@ def generate_conversation_title_background(web_terminal: WebTerminal, conversati
safe_title = title[:80]
ok = False
try:
ok = web_terminal.context_manager.conversation_manager.update_conversation_title(conversation_id, safe_title)
ok = web_terminal.context_manager._get_conversation_manager_for_id(conversation_id).update_conversation_title(conversation_id, safe_title)
except Exception as exc:
debug_log(f"[TitleGen] 保存标题失败: {exc}")
_title_debug_log("title_save_exception", error=str(exc), conversation_id=conversation_id)
@ -1082,7 +1082,7 @@ def ensure_conversation_loaded(terminal: WebTerminal, conversation_id: Optional[
raise RuntimeError(load_result.get("message", "对话加载失败"))
# 切换到对话记录的运行模式
try:
conv_data = terminal.context_manager.conversation_manager.load_conversation(conversation_id) or {}
conv_data = terminal.context_manager._get_conversation_manager_for_id(conversation_id).load_conversation(conversation_id) or {}
meta = conv_data.get("metadata", {}) or {}
run_mode_meta = meta.get("run_mode")
if run_mode_meta:

View File

@ -132,7 +132,7 @@ def generate_conversation_title_background(
safe_title = title[:80]
ok = False
try:
ok = web_terminal.context_manager.conversation_manager.update_conversation_title(conversation_id, safe_title)
ok = web_terminal.context_manager._get_conversation_manager_for_id(conversation_id).update_conversation_title(conversation_id, safe_title)
except Exception as exc:
debug_logger(f"[TitleGen] 保存标题失败: {exc}")
_title_debug_log("title_save_exception", error=str(exc), conversation_id=conversation_id)

View File

@ -45,6 +45,7 @@ from modules.upload_security import UploadSecurityError
from modules.user_manager import UserWorkspace
from modules.usage_tracker import QUOTA_DEFAULTS
from modules.sub_agent import TERMINAL_STATUSES
from modules.multi_agent.debug_logger import ma_debug
from modules.versioning_manager import ConversationVersioningManager, VersioningError
from modules.shallow_versioning import ShallowVersioningManager
from core.web_terminal import WebTerminal
@ -569,6 +570,12 @@ async def _dispatch_completion_user_notice(
for item in preceding_notices
if str(item.get("message") or "").strip()
]
ma_debug(
"dispatch_completion_create_chat_task",
conversation_id=conversation_id,
user_message_preview=user_message[:300],
preceding_count=len(preceding_notices),
)
rec = task_manager.create_chat_task(
username,
workspace_id,
@ -580,6 +587,11 @@ async def _dispatch_completion_user_notice(
run_mode=session_data.get("run_mode"),
session_data=session_data,
)
ma_debug(
"dispatch_completion_task_created",
conversation_id=conversation_id,
task_id=getattr(rec, "task_id", None),
)
payload = {
'message': user_message,
'conversation_id': conversation_id,
@ -701,6 +713,7 @@ def _collect_pending_completion_notices(*, web_terminal, conversation_id: str) -
and t.get("status") in TERMINAL_STATUSES # completed/failed/timeout不含 terminated
and not t.get("notified")
and t.get("task_id") not in web_terminal._announced_sub_agent_tasks
and not t.get("multi_agent_mode") # 多智能体任务走独立注入路径,不在这里发传统通知
]
candidates.sort(key=lambda t: t.get("updated_at") or t.get("created_at") or 0)
for task_info in candidates:
@ -785,6 +798,12 @@ def _collect_pending_completion_notices(*, web_terminal, conversation_id: str) -
state = sub_manager.get_multi_agent_state(conversation_id)
if state:
ma_messages = state.drain_master_messages()
ma_debug(
"collect_notices_drain_ma_messages",
conversation_id=conversation_id,
count=len(ma_messages),
previews=[str(m)[:200] for m in ma_messages],
)
for msg_text in ma_messages:
notices.append({
"kind": "multi_agent",
@ -878,6 +897,7 @@ async def poll_completion_notifications(*, web_terminal, workspace, conversation
pass
loop_count = 0
ma_debug("poll_completion_notifications_start", conversation_id=conversation_id)
while (time.time() - start_wait) < max_wait_time:
loop_count += 1
# 检查停止标志
@ -885,22 +905,27 @@ async def poll_completion_notifications(*, web_terminal, workspace, conversation
if client_stop_info:
stop_requested = client_stop_info.get('stop', False) if isinstance(client_stop_info, dict) else client_stop_info
if stop_requested:
ma_debug("poll_completion_notifications_stop_requested", conversation_id=conversation_id)
break
# 若主对话仍在工具循环中,暂不消费完成事件,避免抢占 system 消息插入
if getattr(web_terminal, "_tool_loop_active", False):
ma_debug("poll_completion_notifications_wait_tool_loop", conversation_id=conversation_id)
await asyncio.sleep(1)
continue
# 多智能体模式:主对话任务仍在运行时,由主循环自己消费 pending 消息
if getattr(web_terminal, "_multi_agent_main_task_active", False):
ma_debug("poll_completion_notifications_wait_main_task", conversation_id=conversation_id)
await asyncio.sleep(1)
continue
ma_debug("poll_completion_notifications_collect", conversation_id=conversation_id, loop_count=loop_count)
notices = _collect_pending_completion_notices(
web_terminal=web_terminal,
conversation_id=conversation_id,
)
ma_debug("poll_completion_notifications_collected", conversation_id=conversation_id, notice_count=len(notices))
if notices:
# 取出后剩余是否还有未完成/未通知的后台工作(决定前端是否保持等待态)
@ -974,6 +999,18 @@ async def handle_task_with_sender(
# 多智能体模式:标记主对话任务正在运行,供后台通知池判断是否可以安全消费
if getattr(web_terminal, "multi_agent_mode", False):
web_terminal._multi_agent_main_task_active = True
manager = getattr(web_terminal, "sub_agent_manager", None)
pending_count = 0
if manager:
state = manager.get_multi_agent_state(conversation_id)
if state:
pending_count = len(state.pending_master_messages)
ma_debug(
"handle_task_with_sender_start",
conversation_id=conversation_id,
message_preview=str(message)[:300] if message else None,
pending_master_messages_count=pending_count,
)
videos = videos or []
raw_sender = sender
@ -1695,6 +1732,11 @@ async def handle_task_with_sender(
sender=sender,
debug_log=debug_log,
)
ma_debug(
"no_tool_call_turn_process_ma_messages",
conversation_id=conversation_id,
injected_count=injected_count,
)
if injected_count:
debug_log(f"[MultiAgent] no-tool-call turn 注入 {injected_count} 条子智能体消息,继续迭代")
is_first_iteration = False
@ -1907,6 +1949,13 @@ async def handle_task_with_sender(
# 只 spawn 一个轮询器(无论是否同时存在子智能体与后台命令),从根本上消除
# 两个轮询器同时 create_chat_task 撞「单工作区互斥」的问题。
if has_running_sub_agents or has_running_background_commands or has_running_multi_agent:
ma_debug(
"completion_poll_start",
conversation_id=conversation_id,
has_running_sub_agents=has_running_sub_agents,
has_running_background_commands=has_running_background_commands,
has_running_multi_agent=has_running_multi_agent,
)
def run_completion_poll():
import asyncio
loop = asyncio.new_event_loop()
@ -1923,6 +1972,12 @@ async def handle_task_with_sender(
loop.close()
socketio.start_background_task(run_completion_poll)
else:
ma_debug(
"completion_poll_skip",
conversation_id=conversation_id,
reason="no_running_background_or_multi_agent",
)
has_running_completion_jobs = has_running_sub_agents or has_running_background_commands

View File

@ -179,6 +179,8 @@ async def process_sub_agent_updates(*, messages: List[Dict], inline: bool = Fals
continue
if task.get("notified"):
continue
if task.get("multi_agent_mode"):
continue
task_conv_id = task.get("conversation_id")
current_conv_id = getattr(getattr(web_terminal, "context_manager", None), "current_conversation_id", None)
if task_conv_id and current_conv_id and task_conv_id != current_conv_id:
@ -315,6 +317,17 @@ async def process_background_command_updates(*, messages: List[Dict], inline: bo
def _auto_message_type_for_multi_agent_subtype(subtype: Optional[str]) -> str:
"""把多智能体消息 subtype 映射为统一的 auto_message_type。"""
if subtype == "progress_output":
return "multi_agent_progress_output"
if subtype == "completion_report":
return "multi_agent_completion_report"
if subtype == "ask_master":
return "multi_agent_ask_master"
return "multi_agent_output"
def inject_multi_agent_master_message(
*,
web_terminal,
@ -338,14 +351,26 @@ def inject_multi_agent_master_message(
after_tool_call_id=after_tool_call_id,
)
# 解析标准格式以获取 subtype解析失败时回退到通用类型
try:
from modules.multi_agent.state import parse_multi_agent_message
parsed = parse_multi_agent_message(raw)
except Exception:
parsed = None
subtype = parsed.get("subtype") if parsed else None
auto_message_type = _auto_message_type_for_multi_agent_subtype(subtype)
metadata = {
"runtime_injected": True,
"source": "sub_agent",
"message_source": "sub_agent",
"inline": inline,
"is_auto_generated": True,
"auto_message_type": "multi_agent_output",
"visibility": "compact",
"auto_message_type": auto_message_type,
"multi_agent_message": True,
"multi_agent_display_name": parsed.get("display_name") if parsed else None,
"multi_agent_subtype": subtype,
"visibility": "chat",
"starts_work": False,
}
@ -376,8 +401,12 @@ def inject_multi_agent_master_message(
"inline": inline,
"source": "sub_agent",
"message_source": "sub_agent",
"visibility": "compact",
"visibility": "chat",
"starts_work": False,
"auto_message_type": auto_message_type,
"multi_agent_message": True,
"multi_agent_display_name": parsed.get("display_name") if parsed else None,
"multi_agent_subtype": subtype,
"metadata": metadata,
"runtime_injected": True,
}

View File

@ -639,7 +639,7 @@ def ensure_conversation_loaded(
).get("project_path"),
)
try:
conv_data = terminal.context_manager.conversation_manager.load_conversation(conversation_id) or {}
conv_data = terminal.context_manager._get_conversation_manager_for_id(conversation_id).load_conversation(conversation_id) or {}
meta = conv_data.get("metadata", {}) or {}
run_mode_meta = meta.get("run_mode")
if run_mode_meta:

View File

@ -275,7 +275,7 @@ def _atomic_write_input_draft(path: Path, payload: Dict[str, Any]) -> None:
def _get_conversation_versioning_meta(terminal: WebTerminal, conversation_id: str) -> Dict[str, Any]:
normalized = _normalize_conv_id(conversation_id)
data = terminal.context_manager.conversation_manager.load_conversation(normalized) or {}
data = terminal.context_manager._get_conversation_manager_for_id(normalized).load_conversation(normalized) or {}
meta = data.get("metadata") or {}
versioning = meta.get("versioning") or {}
if not isinstance(versioning, dict):
@ -316,7 +316,7 @@ def _ensure_conversation_versioning_enabled(
manager = _get_conv_versioning_manager(workspace, normalized_id)
normalized_tracking_mode = _normalize_versioning_tracking_mode(tracking_mode or default_mode)
meta = manager.set_enabled(enabled=True, mode="overwrite", tracking_mode=normalized_tracking_mode)
conv_data = terminal.context_manager.conversation_manager.load_conversation(normalized_id) or {}
conv_data = terminal.context_manager._get_conversation_manager_for_id(normalized_id).load_conversation(normalized_id) or {}
snapshot_payload = {
"conversation_id": normalized_id,
"title": conv_data.get("title"),
@ -372,7 +372,7 @@ def _update_conversation_versioning_meta(
payload["versioning"]["last_commit"] = last_commit
if last_input_seq is not None:
payload["versioning"]["last_input_seq"] = int(last_input_seq)
return terminal.context_manager.conversation_manager.update_conversation_metadata(normalized, payload)
return terminal.context_manager._get_conversation_manager_for_id(normalized).update_conversation_metadata(normalized, payload)
@conversation_bp.route('/api/input-draft', methods=['GET'])
@ -710,7 +710,7 @@ def get_conversation_info(terminal: WebTerminal, workspace: UserWorkspace, usern
"""获取特定对话信息"""
try:
# 通过ConversationManager直接获取对话数据
conversation_data = terminal.context_manager.conversation_manager.load_conversation(conversation_id)
conversation_data = terminal.context_manager._get_conversation_manager_for_id(conversation_id).load_conversation(conversation_id)
if conversation_data:
# 提取关键信息,不返回完整消息内容(避免数据量过大)
@ -950,7 +950,7 @@ def get_conversation_messages(conversation_id, terminal: WebTerminal, workspace:
"""获取对话的消息历史(可选功能,用于调试或详细查看)"""
try:
# 获取完整对话数据
conversation_data = terminal.context_manager.conversation_manager.load_conversation(conversation_id)
conversation_data = terminal.context_manager._get_conversation_manager_for_id(conversation_id).load_conversation(conversation_id)
if conversation_data:
messages = conversation_data.get("messages", [])
@ -1072,7 +1072,7 @@ def update_conversation_versioning(conversation_id, terminal: WebTerminal, works
manager = _get_conv_versioning_manager(workspace, normalized_id)
meta = manager.set_enabled(enabled=enabled, mode=mode, tracking_mode=tracking_mode)
if enabled:
conv_data = terminal.context_manager.conversation_manager.load_conversation(normalized_id) or {}
conv_data = terminal.context_manager._get_conversation_manager_for_id(normalized_id).load_conversation(normalized_id) or {}
snapshot_payload = {
"conversation_id": normalized_id,
"title": conv_data.get("title"),
@ -1615,7 +1615,7 @@ def compress_conversation(conversation_id, terminal: WebTerminal, workspace: Use
def get_conversation_compression_status(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
try:
normalized_id = conversation_id if conversation_id.startswith('conv_') else f"conv_{conversation_id}"
data = terminal.context_manager.conversation_manager.load_conversation(normalized_id) or {}
data = terminal.context_manager._get_conversation_manager_for_id(normalized_id).load_conversation(normalized_id) or {}
meta = data.get("metadata", {}) or {}
return jsonify({
"success": True,
@ -1639,9 +1639,9 @@ def get_conversation_compression_status(conversation_id, terminal: WebTerminal,
def cancel_conversation_compression(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
try:
normalized_id = conversation_id if conversation_id.startswith('conv_') else f"conv_{conversation_id}"
ok = terminal.context_manager.conversation_manager.update_conversation_metadata(
normalized_id,
{
ok = terminal.context_manager._get_conversation_manager_for_id(
normalized_id).update_conversation_metadata(
normalized_id, {
"compression_in_progress": False,
"compression_mode": None,
"compression_stage": None,
@ -2007,7 +2007,7 @@ def review_conversation_preview(conversation_id, terminal: WebTerminal, workspac
"message": "无法引用当前对话"
}), 400
conversation_data = terminal.context_manager.conversation_manager.load_conversation(conversation_id)
conversation_data = terminal.context_manager._get_conversation_manager_for_id(conversation_id).load_conversation(conversation_id)
if not conversation_data:
return jsonify({
"success": False,
@ -2050,7 +2050,7 @@ def review_conversation(conversation_id, terminal: WebTerminal, workspace: UserW
"message": "无法引用当前对话"
}), 400
conversation_data = terminal.context_manager.conversation_manager.load_conversation(conversation_id)
conversation_data = terminal.context_manager._get_conversation_manager_for_id(conversation_id).load_conversation(conversation_id)
if not conversation_data:
return jsonify({
"success": False,
@ -2132,7 +2132,7 @@ def get_current_conversation(terminal: WebTerminal, workspace: UserWorkspace, us
# 如果是真实的对话ID查找对话数据
try:
conversation_data = terminal.context_manager.conversation_manager.load_conversation(current_id)
conversation_data = terminal.context_manager._get_conversation_manager_for_id(current_id).load_conversation(current_id)
if conversation_data:
metadata = conversation_data.get("metadata", {}) or {}
return jsonify({

View File

@ -44,11 +44,14 @@ def rebuild_conversation_index_api():
terminal, _ = get_user_resources(username)
if not terminal:
return jsonify({"success": False, "error": "工作区未就绪"}), 503
cm = getattr(getattr(terminal, "context_manager", None), "conversation_manager", None)
if not cm:
return jsonify({"success": False, "error": "对话管理器未初始化"}), 503
rebuilt = cm._rebuild_index_from_files()
cm._save_index(rebuilt)
ctx_manager = getattr(terminal, "context_manager", None)
if not ctx_manager:
return jsonify({"success": False, "error": "上下文管理器未初始化"}), 503
ma_manager = getattr(ctx_manager, "multi_agent_conversation_manager", None)
if not ma_manager:
return jsonify({"success": False, "error": "多智能体对话管理器未初始化"}), 503
rebuilt = ma_manager._rebuild_index_from_files()
ma_manager._save_index(rebuilt)
return jsonify({"success": True, "index_size": len(rebuilt)})
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 500
@ -150,7 +153,10 @@ def create_multi_agent_conversation():
except Exception:
prefs = {}
cm = getattr(getattr(terminal, "context_manager", None), "conversation_manager", None)
ctx_manager = getattr(terminal, "context_manager", None)
if not ctx_manager:
return jsonify({"success": False, "error": "上下文管理器未初始化"}), 500
cm = getattr(ctx_manager, "multi_agent_conversation_manager", None) or getattr(ctx_manager, "conversation_manager", None)
if not cm:
return jsonify({"success": False, "error": "对话管理器未初始化"}), 500
@ -163,7 +169,7 @@ def create_multi_agent_conversation():
if default_permission_mode not in ("readonly", "approval", "auto_approval", "unrestricted"):
default_permission_mode = None
previous_cm_current = getattr(cm, "current_conversation_id", None)
previous_cm_current = getattr(ctx_manager, "current_conversation_id", None)
conversation_id = cm.create_conversation(
project_path=str(workspace.project_path),
@ -177,8 +183,9 @@ def create_multi_agent_conversation():
"multi_agent_mode": True,
},
)
# 恢复 context_manager 的当前对话(不要影响普通对话的 current_conversation_id
try:
cm.current_conversation_id = previous_cm_current
ctx_manager.current_conversation_id = previous_cm_current
except Exception:
pass

View File

@ -288,7 +288,7 @@ def handle_message(data):
emit('error', {'message': str(exc)})
return
try:
conv_data = terminal.context_manager.conversation_manager.load_conversation(conversation_id) or {}
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', '新对话')

View File

@ -122,7 +122,7 @@ def get_status(terminal, workspace, username):
current_conv = terminal.context_manager.current_conversation_id
status.setdefault('conversation', {})['current_id'] = current_conv
if current_conv and not current_conv.startswith('temp_'):
current_conv_data = terminal.context_manager.conversation_manager.load_conversation(current_conv)
current_conv_data = terminal.context_manager._get_conversation_manager_for_id(current_conv).load_conversation(current_conv)
if current_conv_data:
status['conversation']['title'] = current_conv_data.get('title', '未知对话')
status['conversation']['created_at'] = current_conv_data.get('created_at')

View File

@ -13,17 +13,42 @@
'message-block--compact-user': getMessageVisibility(msg) === 'compact',
'message-block--sub-agent-notice': isSubAgentNoticeOnlyMessage(msg),
'message-block--before-sub-agent-notice': isFollowedBySubAgentNotice(index),
'message-block--multi-agent': isMultiAgentMessage(msg),
'message-block--last': index === (filteredMessages || []).length - 1
}"
>
<div v-if="msg.role === 'user'" class="user-message" :class="{ 'user-message--compact': getMessageVisibility(msg) === 'compact', 'user-message--brief': isBriefCompactMessage(msg) }">
<div
v-if="msg.role === 'user'"
class="user-message"
:class="{
'user-message--multi-agent': isMultiAgentMessage(msg),
'user-message--compact': getMessageVisibility(msg) === 'compact',
'user-message--brief': isBriefCompactMessage(msg)
}"
>
<div v-if="isBriefCompactMessage(msg)" class="compact-brief-line" role="separator">
<span class="compact-brief-text">{{ compactBriefLabel(msg) }}</span>
</div>
<template v-else>
<div class="message-header icon-label">
<span class="icon icon-sm" :style="iconStyleSafe(userHeaderIconKey(msg))" aria-hidden="true"></span>
<span>{{ userHeaderLabel(msg) }}</span>
<div
class="message-header icon-label"
:class="{
'message-header--multi-agent': isMultiAgentMessage(msg),
'message-header--hidden':
isMultiAgentMessage(msg) && isContinuousMultiAgentMessage(msg, index)
}"
>
<template v-if="isMultiAgentMessage(msg)">
<span>{{ multiAgentHeaderLabel(msg) }}</span>
</template>
<template v-else>
<span
class="icon icon-sm"
:style="iconStyleSafe(userHeaderIconKey(msg))"
aria-hidden="true"
></span>
<span>{{ userHeaderLabel(msg) }}</span>
</template>
</div>
<div
class="message-text user-bubble-text"
@ -38,8 +63,14 @@
class="bubble-text"
:class="{ 'is-expanded': isUserBubbleExpanded(msg, index) }"
:ref="(el) => registerUserBubbleRef(msg, index, el)"
v-html="renderUserMessageContent(msg.content)"
></div>
>
<template v-if="isMultiAgentMessage(msg)">
{{ multiAgentBubbleContent(msg) }}
</template>
<template v-else>
<span v-html="renderUserMessageContent(msg.content)"></span>
</template>
</div>
<div v-if="msg.images && msg.images.length" class="image-inline-row">
<div
class="image-thumbnail-wrapper"
@ -79,7 +110,7 @@
></button>
</div>
</div>
<div class="user-bubble-actions">
<div v-if="!isMultiAgentMessage(msg)" class="user-bubble-actions">
<button
class="user-bubble-action-btn copy"
:class="{ copied: isUserBubbleCopied(msg, index) }"
@ -905,6 +936,69 @@ const isSystemAutoUserMessage = (message: any) => {
meta.background_command_notice
);
};
// ---------- ----------
interface MultiAgentMessageInfo {
displayName: string;
subtype: string;
content: string;
}
const MULTI_AGENT_MESSAGE_RE =
/^来自\s+(.+?)\s+的(.+?)\nid:\s*(\S+)\n\n<(.+?)>\n<(\w+)([^>]*)>\n([\s\S]*?)\n<\/\5>\n<\/\4>$/;
function parseMultiAgentMessage(content: string): MultiAgentMessageInfo | null {
const text = String(content || '');
const m = text.match(MULTI_AGENT_MESSAGE_RE);
if (!m) {
return null;
}
const attrs = m[6] || '';
const subtypeMatch = attrs.match(/subtype="([^"]+)"/);
return {
displayName: m[1].trim(),
subtype: subtypeMatch ? subtypeMatch[1] : '',
content: m[7]
};
}
function isMultiAgentMessage(message: any): boolean {
if (!message || message.role !== 'user') {
return false;
}
const meta = message.metadata || {};
const autoType = String(meta.auto_message_type || message.auto_message_type || '');
return autoType.startsWith('multi_agent_');
}
function multiAgentHeaderLabel(message: any): string {
const displayName =
message?.metadata?.multi_agent_display_name || message?.multi_agent_display_name;
if (displayName) {
return displayName;
}
const parsed = parseMultiAgentMessage(message?.content || '');
return parsed?.displayName || '子智能体';
}
function isContinuousMultiAgentMessage(message: any, index: number): boolean {
if (!isMultiAgentMessage(message)) {
return false;
}
const messages = getFilteredMessagesSafe();
const prev = messages[index - 1];
if (!prev || !isMultiAgentMessage(prev)) {
return false;
}
return multiAgentHeaderLabel(message) === multiAgentHeaderLabel(prev);
}
function multiAgentBubbleContent(message: any): string {
const parsed = parseMultiAgentMessage(message?.content || '');
return parsed?.content || message?.content || '';
}
const escapeUserHtml = (value: string): string =>
String(value || '')
.replace(/&/g, '&amp;')
@ -1176,6 +1270,7 @@ function attachBounceListener() {
scrollListener = (event: Event) => {
const target = event.target as HTMLElement | null;
if (!target) return;
const trusted = (event as any).isTrusted === true;
const top = target.scrollTop;
const delta = top - lastObservedTop;
const height = target.scrollHeight || 0;
@ -1183,6 +1278,18 @@ function attachBounceListener() {
lastObservedTop = top;
lastObservedHeight = height;
if (heightDelta !== 0) {
console.log('[ChatAreaScrollDebug] height-change', {
heightDelta,
scrollHeight: height,
top,
delta,
escapedFromLock: escapedFromLock.value,
isAtBottom: isAtBottom.value,
isNearBottom: isNearBottom.value,
});
}
// scrollHeight /
// escapedFromLock=false
// instant spring
@ -1193,6 +1300,15 @@ function attachBounceListener() {
heightDelta > largeGrowthThreshold &&
!escapedFromLock.value
) {
console.log('[ChatAreaScrollDebug] TRIGGER:large-growth-scroll', {
heightDelta,
threshold: largeGrowthThreshold,
top,
delta,
escapedFromLock: escapedFromLock.value,
isAtBottom: isAtBottom.value,
isNearBottom: isNearBottom.value,
});
isHandlingLargeGrowth = true;
markProgrammaticHint('ChatArea.largeGrowth');
suppressUserIntentUntil = Date.now() + 900;
@ -1209,7 +1325,6 @@ function attachBounceListener() {
}
const now = Date.now();
const trusted = (event as any).isTrusted === true;
const closeToProgrammaticHint = now - lastProgrammaticHintTs <= 140;
const likelyLayoutDriven = Math.abs(heightDelta) > 2;
const manualUpByWheel = delta < -3 && now - lastUserWheelUpTs <= 240;
@ -1432,6 +1547,15 @@ async function stickScrollToBottom(
) {
const el = scrollRef.value;
if (el) {
console.log('[ChatAreaScrollDebug] CALL:stickScrollToBottom', {
behavior: options.behavior || 'auto',
force: !!options.force,
preserveScrollPosition: !!options.preserveScrollPosition,
isAtBottom: isAtBottom.value,
isNearBottom: isNearBottom.value,
escapedFromLock: escapedFromLock.value,
...getScrollMetrics(el),
});
bounceTraceLog(
'programmatic:scrollToBottom:before',
{

View File

@ -462,8 +462,6 @@
.message-block {
margin-bottom: 24px;
min-width: 0;
content-visibility: auto;
contain-intrinsic-size: auto 300px;
}
.message-block.message-block--sub-agent-notice {
@ -492,11 +490,39 @@
align-items: flex-end; /* 靠右对齐 */
}
.message-block--last .user-message:not(.user-message--compact):not(.user-message--brief) {
/* 多智能体消息:渲染为左侧对话气泡,复用用户消息气泡样式 */
.user-message.user-message--multi-agent {
align-items: flex-start;
}
.message-block.message-block--multi-agent {
margin-bottom: 12px;
}
.user-message.user-message--multi-agent .message-header {
align-self: flex-start;
}
.user-message.user-message--multi-agent .message-header.message-header--hidden {
display: none;
}
.user-message.user-message--multi-agent .message-text {
align-self: flex-start;
}
.message-block--last
.user-message:not(.user-message--compact):not(.user-message--brief):not(
.user-message--multi-agent
) {
position: relative;
}
.message-block--last .user-message:not(.user-message--compact):not(.user-message--brief) .user-bubble-actions {
.message-block--last
.user-message:not(.user-message--compact):not(.user-message--brief):not(
.user-message--multi-agent
)
.user-bubble-actions {
position: absolute;
right: 0;
top: 100%;

View File

@ -59,6 +59,11 @@ from utils.host_workspace_debug import write_host_workspace_debug
from utils.media_store import MediaStore
from utils.token_usage import normalize_usage_payload
try:
from modules.multi_agent.role_store import DEFAULT_MUTIAGENTS_DIR
except Exception:
DEFAULT_MUTIAGENTS_DIR = None
AUTO_SHALLOW_PLACEHOLDER = "过早的工具结果已经被自动压缩"
AUTO_SHALLOW_TOOL_WHITELIST = {
"write_file",
@ -101,6 +106,12 @@ class ContextManagerBase:
# 新增:对话持久化管理器
self.conversation_manager = ConversationManager(base_dir=self.data_dir, project_path=str(self.project_path))
# 多智能体对话独立存储到 mutiagents/conversations
ma_base_dir = DEFAULT_MUTIAGENTS_DIR if DEFAULT_MUTIAGENTS_DIR else (self.data_dir.parent / "mutiagents")
self.multi_agent_conversation_manager = ConversationManager(
base_dir=str(ma_base_dir),
project_path=str(self.project_path),
)
self.media_store = MediaStore(self.data_dir)
self.current_conversation_id: Optional[str] = None
self.auto_save_enabled = True
@ -126,7 +137,9 @@ class ContextManagerBase:
self.conversation_metadata[key] = value
if save and self.current_conversation_id:
try:
self.conversation_manager.update_conversation_metadata(
# 路由到正确的 conversation_manager
target_manager = getattr(self, "_get_conversation_manager_for_id", lambda _: self.conversation_manager)(self.current_conversation_id)
target_manager.update_conversation_metadata(
self.current_conversation_id,
{key: value}
)

View File

@ -77,6 +77,42 @@ AUTO_SHALLOW_TOOL_WHITELIST = {
class ConversationMixin:
"""ContextManager conversation mixin 能力 mixin。"""
def _get_conversation_manager_for_multi_agent_mode(self, multi_agent_mode: bool = False):
"""根据是否为多智能体会话返回对应的 ConversationManager。"""
if multi_agent_mode:
return getattr(self, "multi_agent_conversation_manager", None) or self.conversation_manager
return self.conversation_manager
def _get_conversation_manager_for_id(self, conversation_id: str):
"""根据对话ID判断它属于哪个 ConversationManager。
优先检查多智能体管理器如果存在再回退到普通管理器
"""
ma_manager = getattr(self, "multi_agent_conversation_manager", None)
if not ma_manager:
return self.conversation_manager
# 先查多智能体索引/文件
try:
ma_path = ma_manager._get_conversation_file_path(conversation_id)
if ma_path.exists():
return ma_manager
except Exception:
pass
try:
regular_path = self.conversation_manager._get_conversation_file_path(conversation_id)
if regular_path.exists():
return self.conversation_manager
except Exception:
pass
# 都不存在时若ID在常规索引中则走常规否则默认走多智能体兼容新建对话后立刻保存
try:
regular_index = self.conversation_manager._load_index()
if conversation_id in regular_index:
return self.conversation_manager
except Exception:
pass
return ma_manager
def start_new_conversation(
self,
project_path: str = None,
@ -117,8 +153,10 @@ class ConversationMixin:
except Exception as exc:
print(f"[Skills] 同步失败: {exc}")
# 创建新对话
conversation_id = self.conversation_manager.create_conversation(
# 创建新对话:多智能体对话使用独立的 conversation_manager
is_multi_agent = bool((metadata_overrides or {}).get("multi_agent_mode"))
target_manager = self._get_conversation_manager_for_multi_agent_mode(is_multi_agent)
conversation_id = target_manager.create_conversation(
project_path=project_path,
thinking_mode=thinking_mode,
run_mode=run_mode or ("thinking" if thinking_mode else "fast"),
@ -155,8 +193,20 @@ class ConversationMixin:
if self.current_conversation_id and self.conversation_history:
self.save_current_conversation()
# 加载指定对话
conversation_data = self.conversation_manager.load_conversation(conversation_id)
# 加载指定对话先按ID路由到对应 manager
target_manager = self._get_conversation_manager_for_id(conversation_id)
conversation_data = target_manager.load_conversation(conversation_id)
if not conversation_data:
# 兜底:如果目标 manager 没有,再尝试另一个
fallback_manager = (
self.conversation_manager
if target_manager is self.multi_agent_conversation_manager
else self.multi_agent_conversation_manager
)
try:
conversation_data = fallback_manager.load_conversation(conversation_id)
except Exception:
conversation_data = None
if not conversation_data:
print(f"⌘ 对话 {conversation_id} 不存在")
return False
@ -279,7 +329,8 @@ class ConversationMixin:
try:
run_mode = getattr(self.main_terminal, "run_mode", None) if hasattr(self, "main_terminal") else None
success = self.conversation_manager.save_conversation(
target_manager = self._get_conversation_manager_for_id(self.current_conversation_id)
success = target_manager.save_conversation(
conversation_id=self.current_conversation_id,
messages=self.conversation_history,
project_path=str(self.project_path),
@ -310,7 +361,8 @@ class ConversationMixin:
try:
run_mode = getattr(self.main_terminal, "run_mode", None) if hasattr(self, "main_terminal") else None
model_key = getattr(self.main_terminal, "model_key", None) if hasattr(self, "main_terminal") else None
self.conversation_manager.save_conversation(
target_manager = self._get_conversation_manager_for_id(self.current_conversation_id)
target_manager.save_conversation(
conversation_id=self.current_conversation_id,
messages=self.conversation_history,
project_path=str(self.project_path),
@ -326,8 +378,37 @@ class ConversationMixin:
print(f"⌘ 自动保存异常: {e}")
def get_conversation_list(self, limit: int = 50, offset: int = 0, non_empty: bool = False, multi_agent_mode: Optional[bool] = None) -> Dict:
"""获取对话列表"""
return self.conversation_manager.get_conversation_list(limit=limit, offset=offset, non_empty=non_empty, multi_agent_mode=multi_agent_mode)
"""获取对话列表。
多智能体对话存储在独立目录需要合并普通管理器和多智能体管理器的结果
"""
# 多智能体模式只查多智能体管理器常规模式只查普通管理器None 时合并
if multi_agent_mode is True:
ma_manager = getattr(self, "multi_agent_conversation_manager", None)
if ma_manager:
return ma_manager.get_conversation_list(limit=limit, offset=offset, non_empty=non_empty, multi_agent_mode=multi_agent_mode)
return self.conversation_manager.get_conversation_list(limit=limit, offset=offset, non_empty=non_empty, multi_agent_mode=multi_agent_mode)
if multi_agent_mode is False:
return self.conversation_manager.get_conversation_list(limit=limit, offset=offset, non_empty=non_empty, multi_agent_mode=multi_agent_mode)
# None合并两个管理器的结果
regular = self.conversation_manager.get_conversation_list(limit=limit, offset=offset, non_empty=non_empty, multi_agent_mode=False)
ma_manager = getattr(self, "multi_agent_conversation_manager", None)
if not ma_manager:
return regular
ma = ma_manager.get_conversation_list(limit=limit, offset=offset, non_empty=non_empty, multi_agent_mode=True)
merged_conversations = list(regular.get("conversations", [])) + list(ma.get("conversations", []))
merged_conversations.sort(key=lambda x: x.get("updated_at") or "", reverse=True)
total = (regular.get("total") or 0) + (ma.get("total") or 0)
result = merged_conversations[offset:offset + limit]
return {
"conversations": result,
"total": total,
"limit": limit,
"offset": offset,
"has_more": offset + limit < total
}
def delete_conversation_by_id(self, conversation_id: str) -> bool:
"""删除指定对话"""
@ -338,7 +419,8 @@ class ConversationMixin:
self.todo_list = None
elif self.current_conversation_id and self.conversation_history:
try:
conversation_data = self.conversation_manager.load_conversation(self.current_conversation_id)
target_manager = self._get_conversation_manager_for_id(self.current_conversation_id)
conversation_data = target_manager.load_conversation(self.current_conversation_id)
if not conversation_data:
self.current_conversation_id = None
self.conversation_history = []
@ -349,8 +431,25 @@ class ConversationMixin:
except Exception as exc:
print(f"⌘ 刷新待办列表失败: {exc}")
self.todo_list = None
return self.conversation_manager.delete_conversation(conversation_id)
# 先定位对话所在 manager再删除两个目录都尝试兜底
target_manager = self._get_conversation_manager_for_id(conversation_id)
try:
if target_manager.load_conversation(conversation_id):
return target_manager.delete_conversation(conversation_id)
except Exception:
pass
fallback_manager = (
self.conversation_manager
if target_manager is self.multi_agent_conversation_manager
else self.multi_agent_conversation_manager
)
try:
if fallback_manager.load_conversation(conversation_id):
return fallback_manager.delete_conversation(conversation_id)
except Exception:
pass
return False
def search_conversations(self, query: str, limit: int = 20) -> List[Dict]:
"""搜索对话"""

View File

@ -173,8 +173,12 @@ class TokenMixin:
target_id = conversation_id or self.current_conversation_id
if not target_id:
return None
return self.conversation_manager.get_token_statistics(target_id)
# 路由到正确的 conversation_manager支持多智能体会话独立存储
target_manager = getattr(
self, "_get_conversation_manager_for_id", lambda _: self.conversation_manager
)(target_id)
return target_manager.get_token_statistics(target_id)
def get_current_context_tokens(self, conversation_id: str = None) -> int:
"""