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: if not cm or not conv_id:
return return
try: 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): if isinstance(getattr(cm, "conversation_metadata", None), dict):
cm.conversation_metadata.update(updates) cm.conversation_metadata.update(updates)
except Exception: 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) conv_id = conversation_id or getattr(getattr(self, "context_manager", None), "current_conversation_id", None)
if conv_id and getattr(self, "context_manager", None): if conv_id and getattr(self, "context_manager", None):
try: try:
self.context_manager.conversation_manager.update_conversation_metadata( self.context_manager._get_conversation_manager_for_id(conv_id).update_conversation_metadata(conv_id,
conv_id,
{"permission_mode": normalized}, {"permission_mode": normalized},
) )
if self.context_manager.current_conversation_id == conv_id: if self.context_manager.current_conversation_id == conv_id:

View File

@ -206,7 +206,8 @@ class WebTerminal(MainTerminal):
# 把默认模型同步到新对话的 metadata # 把默认模型同步到新对话的 metadata
try: 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, conversation_id=conversation_id,
messages=self.context_manager.conversation_history, messages=self.context_manager.conversation_history,
project_path=str(self.project_path), 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}) 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) 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}) 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 = { snapshot_payload = {
"conversation_id": normalized_id, "conversation_id": normalized_id,
"title": conv_data.get("title"), "title": conv_data.get("title"),
@ -293,7 +295,7 @@ class WebTerminal(MainTerminal):
init_row = init_result.get("row") or {} init_row = init_result.get("row") or {}
if init_row.get("tree_hash"): if init_row.get("tree_hash"):
meta["last_tree_hash"] = 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, normalized_id,
{ {
"versioning": { "versioning": {
@ -324,7 +326,8 @@ class WebTerminal(MainTerminal):
if success: if success:
# 根据对话元数据同步思考模式 # 根据对话元数据同步思考模式
try: 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 {} meta = conv_data.get("metadata", {}) or {}
mode = bool(meta.get("thinking_mode", self.thinking_mode)) mode = bool(meta.get("thinking_mode", self.thinking_mode))
self.thinking_mode = mode self.thinking_mode = mode
@ -371,7 +374,8 @@ class WebTerminal(MainTerminal):
self.current_session_id += 1 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: if not conversation_data:
return { return {
"success": False, "success": False,

View File

@ -19,6 +19,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import re
import uuid import uuid
from asyncio import AbstractEventLoop from asyncio import AbstractEventLoop
from dataclasses import dataclass, field from dataclasses import dataclass, field
@ -51,6 +52,7 @@ def format_multi_agent_message(
target: Optional[str] = None, target: Optional[str] = None,
extra_attrs: Optional[Dict[str, str]] = None, extra_attrs: Optional[Dict[str, str]] = None,
msg_type_text: Optional[str] = None, msg_type_text: Optional[str] = None,
subtype: Optional[str] = None,
) -> str: ) -> str:
"""按统一格式构造 user 消息字符串。 """按统一格式构造 user 消息字符串。
@ -62,6 +64,7 @@ def format_multi_agent_message(
target: 接收方显示名用于子 提问时标明对谁提问 target: 接收方显示名用于子 提问时标明对谁提问
extra_attrs: 额外标签属性 question_id="ask_xxx" extra_attrs: 额外标签属性 question_id="ask_xxx"
msg_type_text: 覆盖默认的中文消息类型文案"任务结束汇报" msg_type_text: 覆盖默认的中文消息类型文案"任务结束汇报"
subtype: 渲染/分类使用的子类型 progress_output / completion_report / ask_master
""" """
if not msg_id: if not msg_id:
msg_id = f"msg_{uuid.uuid4().hex[:10]}" msg_id = f"msg_{uuid.uuid4().hex[:10]}"
@ -80,6 +83,8 @@ def format_multi_agent_message(
attrs = "" attrs = ""
if target: if target:
attrs += f' target="{target}"' attrs += f' target="{target}"'
if subtype:
attrs += f' subtype="{subtype}"'
if extra_attrs: if extra_attrs:
for k, v in extra_attrs.items(): for k, v in extra_attrs.items():
attrs += f' {k}="{v}"' 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: def build_sub_agent_output_text(display_name: str, content: str, msg_id: Optional[str] = None, *, is_final: bool = False) -> str:
"""子智能体输出(进度或完成)插入到主对话的 user 消息文本。""" """子智能体输出(进度或完成)插入到主对话的 user 消息文本。"""
return format_multi_agent_message( 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, content=content,
msg_id=msg_id, msg_id=msg_id,
msg_type_text="任务结束汇报" if is_final else "任务进度输出", 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, msg_type=TYPE_ASK,
content=question, content=question,
msg_id=question_id, msg_id=question_id,
subtype=SUBTYPE_ASK_MASTER,
) )
@ -153,9 +167,45 @@ def build_sub_agent_ask_other_text(
content=question, content=question,
msg_id=question_id, msg_id=question_id,
target=target_display, 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: def build_master_message_to_sub_agent(message: str, msg_id: Optional[str] = None) -> str:
"""主智能体 send_message_to_sub_agent 时插入子对话的 user 消息文本。""" """主智能体 send_message_to_sub_agent 时插入子对话的 user 消息文本。"""
return format_multi_agent_message( 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] safe_title = title[:80]
ok = False ok = False
try: 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: except Exception as exc:
debug_log(f"[TitleGen] 保存标题失败: {exc}") debug_log(f"[TitleGen] 保存标题失败: {exc}")
_title_debug_log("title_save_exception", error=str(exc), conversation_id=conversation_id) _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", "对话加载失败")) raise RuntimeError(load_result.get("message", "对话加载失败"))
# 切换到对话记录的运行模式 # 切换到对话记录的运行模式
try: 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 {} meta = conv_data.get("metadata", {}) or {}
run_mode_meta = meta.get("run_mode") run_mode_meta = meta.get("run_mode")
if run_mode_meta: if run_mode_meta:

View File

@ -132,7 +132,7 @@ def generate_conversation_title_background(
safe_title = title[:80] safe_title = title[:80]
ok = False ok = False
try: 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: except Exception as exc:
debug_logger(f"[TitleGen] 保存标题失败: {exc}") debug_logger(f"[TitleGen] 保存标题失败: {exc}")
_title_debug_log("title_save_exception", error=str(exc), conversation_id=conversation_id) _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.user_manager import UserWorkspace
from modules.usage_tracker import QUOTA_DEFAULTS from modules.usage_tracker import QUOTA_DEFAULTS
from modules.sub_agent import TERMINAL_STATUSES 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.versioning_manager import ConversationVersioningManager, VersioningError
from modules.shallow_versioning import ShallowVersioningManager from modules.shallow_versioning import ShallowVersioningManager
from core.web_terminal import WebTerminal from core.web_terminal import WebTerminal
@ -569,6 +570,12 @@ async def _dispatch_completion_user_notice(
for item in preceding_notices for item in preceding_notices
if str(item.get("message") or "").strip() 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( rec = task_manager.create_chat_task(
username, username,
workspace_id, workspace_id,
@ -580,6 +587,11 @@ async def _dispatch_completion_user_notice(
run_mode=session_data.get("run_mode"), run_mode=session_data.get("run_mode"),
session_data=session_data, session_data=session_data,
) )
ma_debug(
"dispatch_completion_task_created",
conversation_id=conversation_id,
task_id=getattr(rec, "task_id", None),
)
payload = { payload = {
'message': user_message, 'message': user_message,
'conversation_id': conversation_id, '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 t.get("status") in TERMINAL_STATUSES # completed/failed/timeout不含 terminated
and not t.get("notified") and not t.get("notified")
and t.get("task_id") not in web_terminal._announced_sub_agent_tasks 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) candidates.sort(key=lambda t: t.get("updated_at") or t.get("created_at") or 0)
for task_info in candidates: 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) state = sub_manager.get_multi_agent_state(conversation_id)
if state: if state:
ma_messages = state.drain_master_messages() 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: for msg_text in ma_messages:
notices.append({ notices.append({
"kind": "multi_agent", "kind": "multi_agent",
@ -878,6 +897,7 @@ async def poll_completion_notifications(*, web_terminal, workspace, conversation
pass pass
loop_count = 0 loop_count = 0
ma_debug("poll_completion_notifications_start", conversation_id=conversation_id)
while (time.time() - start_wait) < max_wait_time: while (time.time() - start_wait) < max_wait_time:
loop_count += 1 loop_count += 1
# 检查停止标志 # 检查停止标志
@ -885,22 +905,27 @@ async def poll_completion_notifications(*, web_terminal, workspace, conversation
if client_stop_info: if client_stop_info:
stop_requested = client_stop_info.get('stop', False) if isinstance(client_stop_info, dict) else client_stop_info stop_requested = client_stop_info.get('stop', False) if isinstance(client_stop_info, dict) else client_stop_info
if stop_requested: if stop_requested:
ma_debug("poll_completion_notifications_stop_requested", conversation_id=conversation_id)
break break
# 若主对话仍在工具循环中,暂不消费完成事件,避免抢占 system 消息插入 # 若主对话仍在工具循环中,暂不消费完成事件,避免抢占 system 消息插入
if getattr(web_terminal, "_tool_loop_active", False): if getattr(web_terminal, "_tool_loop_active", False):
ma_debug("poll_completion_notifications_wait_tool_loop", conversation_id=conversation_id)
await asyncio.sleep(1) await asyncio.sleep(1)
continue continue
# 多智能体模式:主对话任务仍在运行时,由主循环自己消费 pending 消息 # 多智能体模式:主对话任务仍在运行时,由主循环自己消费 pending 消息
if getattr(web_terminal, "_multi_agent_main_task_active", False): 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) await asyncio.sleep(1)
continue continue
ma_debug("poll_completion_notifications_collect", conversation_id=conversation_id, loop_count=loop_count)
notices = _collect_pending_completion_notices( notices = _collect_pending_completion_notices(
web_terminal=web_terminal, web_terminal=web_terminal,
conversation_id=conversation_id, conversation_id=conversation_id,
) )
ma_debug("poll_completion_notifications_collected", conversation_id=conversation_id, notice_count=len(notices))
if notices: if notices:
# 取出后剩余是否还有未完成/未通知的后台工作(决定前端是否保持等待态) # 取出后剩余是否还有未完成/未通知的后台工作(决定前端是否保持等待态)
@ -974,6 +999,18 @@ async def handle_task_with_sender(
# 多智能体模式:标记主对话任务正在运行,供后台通知池判断是否可以安全消费 # 多智能体模式:标记主对话任务正在运行,供后台通知池判断是否可以安全消费
if getattr(web_terminal, "multi_agent_mode", False): if getattr(web_terminal, "multi_agent_mode", False):
web_terminal._multi_agent_main_task_active = True 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 [] videos = videos or []
raw_sender = sender raw_sender = sender
@ -1695,6 +1732,11 @@ async def handle_task_with_sender(
sender=sender, sender=sender,
debug_log=debug_log, debug_log=debug_log,
) )
ma_debug(
"no_tool_call_turn_process_ma_messages",
conversation_id=conversation_id,
injected_count=injected_count,
)
if injected_count: if injected_count:
debug_log(f"[MultiAgent] no-tool-call turn 注入 {injected_count} 条子智能体消息,继续迭代") debug_log(f"[MultiAgent] no-tool-call turn 注入 {injected_count} 条子智能体消息,继续迭代")
is_first_iteration = False is_first_iteration = False
@ -1907,6 +1949,13 @@ async def handle_task_with_sender(
# 只 spawn 一个轮询器(无论是否同时存在子智能体与后台命令),从根本上消除 # 只 spawn 一个轮询器(无论是否同时存在子智能体与后台命令),从根本上消除
# 两个轮询器同时 create_chat_task 撞「单工作区互斥」的问题。 # 两个轮询器同时 create_chat_task 撞「单工作区互斥」的问题。
if has_running_sub_agents or has_running_background_commands or has_running_multi_agent: 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(): def run_completion_poll():
import asyncio import asyncio
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
@ -1923,6 +1972,12 @@ async def handle_task_with_sender(
loop.close() loop.close()
socketio.start_background_task(run_completion_poll) 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 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 continue
if task.get("notified"): if task.get("notified"):
continue continue
if task.get("multi_agent_mode"):
continue
task_conv_id = task.get("conversation_id") task_conv_id = task.get("conversation_id")
current_conv_id = getattr(getattr(web_terminal, "context_manager", None), "current_conversation_id", None) 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: 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( def inject_multi_agent_master_message(
*, *,
web_terminal, web_terminal,
@ -338,14 +351,26 @@ def inject_multi_agent_master_message(
after_tool_call_id=after_tool_call_id, 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 = { metadata = {
"runtime_injected": True, "runtime_injected": True,
"source": "sub_agent", "source": "sub_agent",
"message_source": "sub_agent", "message_source": "sub_agent",
"inline": inline, "inline": inline,
"is_auto_generated": True, "is_auto_generated": True,
"auto_message_type": "multi_agent_output", "auto_message_type": auto_message_type,
"visibility": "compact", "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, "starts_work": False,
} }
@ -376,8 +401,12 @@ def inject_multi_agent_master_message(
"inline": inline, "inline": inline,
"source": "sub_agent", "source": "sub_agent",
"message_source": "sub_agent", "message_source": "sub_agent",
"visibility": "compact", "visibility": "chat",
"starts_work": False, "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, "metadata": metadata,
"runtime_injected": True, "runtime_injected": True,
} }

View File

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

View File

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

View File

@ -288,7 +288,7 @@ def handle_message(data):
emit('error', {'message': str(exc)}) emit('error', {'message': str(exc)})
return return
try: 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: except Exception:
conv_data = {} conv_data = {}
title = conv_data.get('title', '新对话') 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 current_conv = terminal.context_manager.current_conversation_id
status.setdefault('conversation', {})['current_id'] = current_conv status.setdefault('conversation', {})['current_id'] = current_conv
if current_conv and not current_conv.startswith('temp_'): 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: if current_conv_data:
status['conversation']['title'] = current_conv_data.get('title', '未知对话') status['conversation']['title'] = current_conv_data.get('title', '未知对话')
status['conversation']['created_at'] = current_conv_data.get('created_at') 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--compact-user': getMessageVisibility(msg) === 'compact',
'message-block--sub-agent-notice': isSubAgentNoticeOnlyMessage(msg), 'message-block--sub-agent-notice': isSubAgentNoticeOnlyMessage(msg),
'message-block--before-sub-agent-notice': isFollowedBySubAgentNotice(index), 'message-block--before-sub-agent-notice': isFollowedBySubAgentNotice(index),
'message-block--multi-agent': isMultiAgentMessage(msg),
'message-block--last': index === (filteredMessages || []).length - 1 '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"> <div v-if="isBriefCompactMessage(msg)" class="compact-brief-line" role="separator">
<span class="compact-brief-text">{{ compactBriefLabel(msg) }}</span> <span class="compact-brief-text">{{ compactBriefLabel(msg) }}</span>
</div> </div>
<template v-else> <template v-else>
<div class="message-header icon-label"> <div
<span class="icon icon-sm" :style="iconStyleSafe(userHeaderIconKey(msg))" aria-hidden="true"></span> class="message-header icon-label"
<span>{{ userHeaderLabel(msg) }}</span> :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>
<div <div
class="message-text user-bubble-text" class="message-text user-bubble-text"
@ -38,8 +63,14 @@
class="bubble-text" class="bubble-text"
:class="{ 'is-expanded': isUserBubbleExpanded(msg, index) }" :class="{ 'is-expanded': isUserBubbleExpanded(msg, index) }"
:ref="(el) => registerUserBubbleRef(msg, index, el)" :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 v-if="msg.images && msg.images.length" class="image-inline-row">
<div <div
class="image-thumbnail-wrapper" class="image-thumbnail-wrapper"
@ -79,7 +110,7 @@
></button> ></button>
</div> </div>
</div> </div>
<div class="user-bubble-actions"> <div v-if="!isMultiAgentMessage(msg)" class="user-bubble-actions">
<button <button
class="user-bubble-action-btn copy" class="user-bubble-action-btn copy"
:class="{ copied: isUserBubbleCopied(msg, index) }" :class="{ copied: isUserBubbleCopied(msg, index) }"
@ -905,6 +936,69 @@ const isSystemAutoUserMessage = (message: any) => {
meta.background_command_notice 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 => const escapeUserHtml = (value: string): string =>
String(value || '') String(value || '')
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
@ -1176,6 +1270,7 @@ function attachBounceListener() {
scrollListener = (event: Event) => { scrollListener = (event: Event) => {
const target = event.target as HTMLElement | null; const target = event.target as HTMLElement | null;
if (!target) return; if (!target) return;
const trusted = (event as any).isTrusted === true;
const top = target.scrollTop; const top = target.scrollTop;
const delta = top - lastObservedTop; const delta = top - lastObservedTop;
const height = target.scrollHeight || 0; const height = target.scrollHeight || 0;
@ -1183,6 +1278,18 @@ function attachBounceListener() {
lastObservedTop = top; lastObservedTop = top;
lastObservedHeight = height; 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 / // scrollHeight /
// escapedFromLock=false // escapedFromLock=false
// instant spring // instant spring
@ -1193,6 +1300,15 @@ function attachBounceListener() {
heightDelta > largeGrowthThreshold && heightDelta > largeGrowthThreshold &&
!escapedFromLock.value !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; isHandlingLargeGrowth = true;
markProgrammaticHint('ChatArea.largeGrowth'); markProgrammaticHint('ChatArea.largeGrowth');
suppressUserIntentUntil = Date.now() + 900; suppressUserIntentUntil = Date.now() + 900;
@ -1209,7 +1325,6 @@ function attachBounceListener() {
} }
const now = Date.now(); const now = Date.now();
const trusted = (event as any).isTrusted === true;
const closeToProgrammaticHint = now - lastProgrammaticHintTs <= 140; const closeToProgrammaticHint = now - lastProgrammaticHintTs <= 140;
const likelyLayoutDriven = Math.abs(heightDelta) > 2; const likelyLayoutDriven = Math.abs(heightDelta) > 2;
const manualUpByWheel = delta < -3 && now - lastUserWheelUpTs <= 240; const manualUpByWheel = delta < -3 && now - lastUserWheelUpTs <= 240;
@ -1432,6 +1547,15 @@ async function stickScrollToBottom(
) { ) {
const el = scrollRef.value; const el = scrollRef.value;
if (el) { 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( bounceTraceLog(
'programmatic:scrollToBottom:before', 'programmatic:scrollToBottom:before',
{ {

View File

@ -462,8 +462,6 @@
.message-block { .message-block {
margin-bottom: 24px; margin-bottom: 24px;
min-width: 0; min-width: 0;
content-visibility: auto;
contain-intrinsic-size: auto 300px;
} }
.message-block.message-block--sub-agent-notice { .message-block.message-block--sub-agent-notice {
@ -492,11 +490,39 @@
align-items: flex-end; /* 靠右对齐 */ 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; 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; position: absolute;
right: 0; right: 0;
top: 100%; 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.media_store import MediaStore
from utils.token_usage import normalize_usage_payload 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_PLACEHOLDER = "过早的工具结果已经被自动压缩"
AUTO_SHALLOW_TOOL_WHITELIST = { AUTO_SHALLOW_TOOL_WHITELIST = {
"write_file", "write_file",
@ -101,6 +106,12 @@ class ContextManagerBase:
# 新增:对话持久化管理器 # 新增:对话持久化管理器
self.conversation_manager = ConversationManager(base_dir=self.data_dir, project_path=str(self.project_path)) 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.media_store = MediaStore(self.data_dir)
self.current_conversation_id: Optional[str] = None self.current_conversation_id: Optional[str] = None
self.auto_save_enabled = True self.auto_save_enabled = True
@ -126,7 +137,9 @@ class ContextManagerBase:
self.conversation_metadata[key] = value self.conversation_metadata[key] = value
if save and self.current_conversation_id: if save and self.current_conversation_id:
try: 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, self.current_conversation_id,
{key: value} {key: value}
) )

View File

@ -77,6 +77,42 @@ AUTO_SHALLOW_TOOL_WHITELIST = {
class ConversationMixin: class ConversationMixin:
"""ContextManager conversation mixin 能力 mixin。""" """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( def start_new_conversation(
self, self,
project_path: str = None, project_path: str = None,
@ -117,8 +153,10 @@ class ConversationMixin:
except Exception as exc: except Exception as exc:
print(f"[Skills] 同步失败: {exc}") print(f"[Skills] 同步失败: {exc}")
# 创建新对话 # 创建新对话:多智能体对话使用独立的 conversation_manager
conversation_id = self.conversation_manager.create_conversation( 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, project_path=project_path,
thinking_mode=thinking_mode, thinking_mode=thinking_mode,
run_mode=run_mode or ("thinking" if thinking_mode else "fast"), 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: if self.current_conversation_id and self.conversation_history:
self.save_current_conversation() self.save_current_conversation()
# 加载指定对话 # 加载指定对话先按ID路由到对应 manager
conversation_data = self.conversation_manager.load_conversation(conversation_id) 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: if not conversation_data:
print(f"⌘ 对话 {conversation_id} 不存在") print(f"⌘ 对话 {conversation_id} 不存在")
return False return False
@ -279,7 +329,8 @@ class ConversationMixin:
try: try:
run_mode = getattr(self.main_terminal, "run_mode", None) if hasattr(self, "main_terminal") else None 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, conversation_id=self.current_conversation_id,
messages=self.conversation_history, messages=self.conversation_history,
project_path=str(self.project_path), project_path=str(self.project_path),
@ -310,7 +361,8 @@ class ConversationMixin:
try: try:
run_mode = getattr(self.main_terminal, "run_mode", None) if hasattr(self, "main_terminal") else None 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 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, conversation_id=self.current_conversation_id,
messages=self.conversation_history, messages=self.conversation_history,
project_path=str(self.project_path), project_path=str(self.project_path),
@ -326,8 +378,37 @@ class ConversationMixin:
print(f"⌘ 自动保存异常: {e}") 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: 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: def delete_conversation_by_id(self, conversation_id: str) -> bool:
"""删除指定对话""" """删除指定对话"""
@ -338,7 +419,8 @@ class ConversationMixin:
self.todo_list = None self.todo_list = None
elif self.current_conversation_id and self.conversation_history: elif self.current_conversation_id and self.conversation_history:
try: 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: if not conversation_data:
self.current_conversation_id = None self.current_conversation_id = None
self.conversation_history = [] self.conversation_history = []
@ -350,7 +432,24 @@ class ConversationMixin:
print(f"⌘ 刷新待办列表失败: {exc}") print(f"⌘ 刷新待办列表失败: {exc}")
self.todo_list = None 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]: def search_conversations(self, query: str, limit: int = 20) -> List[Dict]:
"""搜索对话""" """搜索对话"""

View File

@ -174,7 +174,11 @@ class TokenMixin:
if not target_id: if not target_id:
return None 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: def get_current_context_tokens(self, conversation_id: str = None) -> int:
""" """