diff --git a/frontend-web/src/components/workspace/recent-chat-list.tsx b/frontend-web/src/components/workspace/recent-chat-list.tsx index 9624b7f4..e496da2c 100644 --- a/frontend-web/src/components/workspace/recent-chat-list.tsx +++ b/frontend-web/src/components/workspace/recent-chat-list.tsx @@ -163,7 +163,8 @@ export function RecentChatList() { const visibleThreads = threads.filter( (thread) => thread.metadata?.thread_type !== "scheduler" && - thread.metadata?.thread_type !== "bootstrap", + thread.metadata?.thread_type !== "bootstrap" && + thread.metadata?.thread_type !== "roundtable", ); if (visibleThreads.length === 0) { diff --git a/frontend-web/src/pages/ChatsPage.tsx b/frontend-web/src/pages/ChatsPage.tsx index 9be1f716..a6885c1b 100644 --- a/frontend-web/src/pages/ChatsPage.tsx +++ b/frontend-web/src/pages/ChatsPage.tsx @@ -34,6 +34,7 @@ export default function ChatsPage() { return threads?.filter((thread) => { return ( thread.metadata?.thread_type !== "scheduler" && + thread.metadata?.thread_type !== "roundtable" && titleOfThread(thread).toLowerCase().includes(search.toLowerCase()) ); }); diff --git a/frontend-web/src/roundtable-planning/components/Step2Panel.tsx b/frontend-web/src/roundtable-planning/components/Step2Panel.tsx index 76dc50b5..697dd732 100644 --- a/frontend-web/src/roundtable-planning/components/Step2Panel.tsx +++ b/frontend-web/src/roundtable-planning/components/Step2Panel.tsx @@ -476,8 +476,13 @@ function Step2Message({ } /** - * 扫描所有 `tool_calling:present_files` 步骤里的 `filepaths`,按出现顺序去重。 - * 同一气泡可能多次 present_files(总控分批交付),都聚合到一行卡片里。 + * 收集气泡里所有"写了文件"的产出路径,按出现顺序去重,聚合到一行文件卡片。 + * + * 三类来源都算: + * - `present_files`:总控呈现文件,args.filepaths 是数组(可一次多个); + * - `write_file` / `str_replace`:子智能体被后端 excluded_tools 禁用了 present_files, + * 只能 write_file 写文件,单文件 path 在 args.path —— 也聚合进卡片,保证"凡是写了 + * 文件就出可点的卡片",而不是只有 present_files 才出。 */ function collectPresentedFiles( steps: Step2Dialogue["steps"], @@ -485,14 +490,22 @@ function collectPresentedFiles( if (!Array.isArray(steps) || steps.length === 0) return []; const out: string[] = []; const seen = new Set(); + const add = (fp: unknown) => { + if (typeof fp !== "string" || !fp || seen.has(fp)) return; + seen.add(fp); + out.push(fp); + }; for (const step of steps) { - if (step.key !== "tool_calling:present_files") continue; - const fps = step.args?.filepaths; - if (!Array.isArray(fps)) continue; - for (const fp of fps) { - if (typeof fp !== "string" || !fp || seen.has(fp)) continue; - seen.add(fp); - out.push(fp); + if (step.key === "tool_calling:present_files") { + const fps = step.args?.filepaths; + if (Array.isArray(fps)) fps.forEach(add); + continue; + } + if ( + step.key === "tool_calling:write_file" || + step.key === "tool_calling:str_replace" + ) { + add(step.args?.path); } } return out; diff --git a/offline-backend-20260512/backend/app/gateway/routers/intent.py b/offline-backend-20260512/backend/app/gateway/routers/intent.py index 1b9a5c1f..249c4a1b 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/intent.py +++ b/offline-backend-20260512/backend/app/gateway/routers/intent.py @@ -65,6 +65,7 @@ from fastapi.responses import StreamingResponse # identical in connection handling, payload shape, and error mapping. from app.gateway.routers._roundtable_seed import ensure_roundtable_functional_agents from app.gateway.routers.multi_agent import ( + _ROUNDTABLE_THREAD_METADATA, _auth_headers, _create_thread, _diagnose_exception, @@ -286,7 +287,7 @@ async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str, # 看不到逐 token 增量。 try: async with _make_loopback_client(request) as client: - thread_id = await _create_thread(client, base, headers) + thread_id = await _create_thread(client, base, headers, metadata=_ROUNDTABLE_THREAD_METADATA) except HTTPException: # 上游已用结构化方式失败,原样传给前端不需要二次包装。 raise diff --git a/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py index 5f92a3e6..57114fb8 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py +++ b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py @@ -69,6 +69,12 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/multi-agent", tags=["multi-agent"]) +# 圆桌(intent / recommend / 席位 / 协调)创建的 thread 都是功能型内部线程, +# 不是用户的普通对话。打上这个 metadata,前端「最近的对话」列表(recent-chat-list.tsx) +# 据 `metadata.thread_type` 过滤掉,与 scheduler / bootstrap 系统会话同一套机制。 +_ROUNDTABLE_THREAD_METADATA: dict[str, Any] = {"thread_type": "roundtable", "system": True} + + # --------------------------------------------------------------------------- # 共享:异常诊断器(intent/recommend/multi-agent 共用) # --------------------------------------------------------------------------- @@ -289,9 +295,22 @@ def _auth_headers(request: Request) -> dict[str, str]: return {"Authorization": token, "Content-Type": "application/json"} -async def _create_thread(client: httpx.AsyncClient, base: str, headers: dict[str, str]) -> str: - """``POST /api/threads`` → 新 thread_id(intent/recommend/init 席位均用)。""" - resp = await client.post(f"{base}/api/threads", json={}, headers=headers) +async def _create_thread( + client: httpx.AsyncClient, + base: str, + headers: dict[str, str], + *, + metadata: dict[str, Any] | None = None, +) -> str: + """``POST /api/threads`` → 新 thread_id(intent/recommend/init 席位均用)。 + + ``metadata`` 写入 thread 元数据,圆桌调用方传 ``_ROUNDTABLE_THREAD_METADATA`` + 把这些功能型线程标记成可被前端「最近的对话」过滤掉的系统会话。 + """ + body: dict[str, Any] = {} + if metadata: + body["metadata"] = metadata + resp = await client.post(f"{base}/api/threads", json=body, headers=headers) resp.raise_for_status() return resp.json()["thread_id"] @@ -351,12 +370,15 @@ def _resolve_user_id(request: Request) -> str: return get_effective_user_id() -async def _create_thread_direct(request: Request) -> str: +async def _create_thread_direct(request: Request, *, metadata: dict[str, Any] | None = None) -> str: """同进程版的 ``POST /api/threads``。 完全镜像 ``routers/threads.create_thread`` 的写盘动作(thread_meta + 空 checkpoint),但跳过 TCP + Pydantic 反序列化。仅供 init 阶段批量建席位 线程用,失败时抛 HTTPException 由 SSE 帧上报。 + + ``metadata`` 写入 thread 元数据;init 传 ``_ROUNDTABLE_THREAD_METADATA`` 把 + 席位 / 协调线程标记成系统会话,从前端「最近的对话」里隐藏。 """ from app.gateway.deps import get_checkpointer, get_thread_store @@ -366,7 +388,7 @@ async def _create_thread_direct(request: Request) -> str: now = now_iso() try: - await thread_store.create(thread_id, assistant_id=None, metadata={}) + await thread_store.create(thread_id, assistant_id=None, metadata=metadata or {}) except Exception: logger.exception("[init-direct] thread_meta create failed for %s", thread_id) raise HTTPException(status_code=500, detail="Failed to create thread") @@ -740,7 +762,7 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> Strea # 2) 每个席位 thread 并行创建(直调 thread_store + checkpointer), # 用 asyncio.as_completed 保持"先完先发 seat_ready"的体验。 async def _prepare_sub_agent(sub_id: str) -> tuple[str, str, str, str]: - thread_id = await _create_thread_direct(request) + thread_id = await _create_thread_direct(request, metadata=_ROUNDTABLE_THREAD_METADATA) meta = agent_meta.get(sub_id.lower(), {}) return ( sub_id.lower(), @@ -802,7 +824,9 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> Strea # 3) coordinator thread + agent 并行创建。thread 与 agent 之间无依赖, # asyncio.gather 比串行省一个直调往返的开销。 - coord_thread_task = asyncio.create_task(_create_thread_direct(request)) + coord_thread_task = asyncio.create_task( + _create_thread_direct(request, metadata=_ROUNDTABLE_THREAD_METADATA) + ) coord_agent_task = asyncio.create_task( _create_coordinator_direct( request, diff --git a/offline-backend-20260512/backend/app/gateway/routers/recommend.py b/offline-backend-20260512/backend/app/gateway/routers/recommend.py index f6b50494..7f666e66 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/recommend.py +++ b/offline-backend-20260512/backend/app/gateway/routers/recommend.py @@ -49,6 +49,7 @@ from fastapi.responses import StreamingResponse from app.gateway.routers._roundtable_seed import ensure_roundtable_functional_agents from app.gateway.routers.multi_agent import ( + _ROUNDTABLE_THREAD_METADATA, _auth_headers, _create_thread, _flatten_content, @@ -237,7 +238,7 @@ async def stream_recommend(request: Request, payload: dict = Body(...)) -> Strea async with _make_loopback_client(request) as client: try: # 每次推荐独立 thread;prompt 已包含完整 intent + 候选列表,无需多轮。 - thread_id = await _create_thread(client, base, headers) + thread_id = await _create_thread(client, base, headers, metadata=_ROUNDTABLE_THREAD_METADATA) body = _run_payload( agent_name=RECOMMENDER_AGENT_ID,