diff --git a/offline-backend-20260512/backend/app/gateway/auth_middleware.py b/offline-backend-20260512/backend/app/gateway/auth_middleware.py index e5394c70..ff3bb86c 100644 --- a/offline-backend-20260512/backend/app/gateway/auth_middleware.py +++ b/offline-backend-20260512/backend/app/gateway/auth_middleware.py @@ -47,6 +47,8 @@ _PUBLIC_EXACT_PATHS: frozenset[str] = frozenset( "/api/v1/auth/logout", "/api/v1/auth/setup-status", "/api/v1/auth/initialize", + # 并行多智能体面板专用的「绕过常规登录」取 token 端点(内网部署限制了标准登录时用)。 + "/api/parallel-agents/auth/token", } ) diff --git a/offline-backend-20260512/backend/app/gateway/csrf_middleware.py b/offline-backend-20260512/backend/app/gateway/csrf_middleware.py index b471e207..07b46047 100644 --- a/offline-backend-20260512/backend/app/gateway/csrf_middleware.py +++ b/offline-backend-20260512/backend/app/gateway/csrf_middleware.py @@ -59,6 +59,8 @@ _AUTH_EXEMPT_PATHS: frozenset[str] = frozenset( "/api/v1/auth/logout", "/api/v1/auth/register", "/api/v1/auth/initialize", + # 并行多智能体面板专用取 token 端点(公共 + CSRF 豁免)。 + "/api/parallel-agents/auth/token", } ) diff --git a/offline-backend-20260512/backend/app/gateway/routers/parallel_agents.py b/offline-backend-20260512/backend/app/gateway/routers/parallel_agents.py index f16791c1..be57f4cd 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/parallel_agents.py +++ b/offline-backend-20260512/backend/app/gateway/routers/parallel_agents.py @@ -1,14 +1,16 @@ """Server-side parallel fan-out for the standalone parallel multi-agent panel. The Vue embeddable panel sends a single request — ``{task_name, brief, agent_ids}`` -— and this endpoint **fans out** to every agent in parallel (file writes disabled), -then **merges** their LangGraph streams into ONE SSE tagged per ``agent_id``, so the -client only consumes one stream and never parses LangGraph internals. +— and this endpoint **fans out** to every agent in parallel, then **merges** their +LangGraph streams into ONE SSE tagged per ``agent_id``, so the client only consumes +one stream and never parses LangGraph internals. -It reuses the roundtable single-agent machinery from ``multi_agent``: -``_special_run(..., no_file=True)`` runs one seat under the ``seat_parallel`` run -policy (which excludes ``write_file`` / ``str_replace`` / ``bash`` / ``web_search`` -/ ``agent_orchestration`` …), so parallel agents can't write files or dispatch. +Each fan-out agent runs as a pure **executing seat**: ``excluded_tools`` = +``_EXCLUDED_TOOLS`` (``write_file`` / ``str_replace`` / ``agent_orchestration`` / +``bash``) so a seat can neither write files (parallel writes would clash) nor +dispatch / run scripts (a weak model fed "总控 / 业务链条" framing would otherwise +mimic the controller and "派活"). Read-only tools + ``ask_clarification`` stay on, +so the step bar and clarification cards still work. Unified event protocol (one JSON object per ``data:`` frame):: @@ -31,18 +33,24 @@ import json import logging from typing import Any, AsyncIterator +import httpx from fastapi import APIRouter, Body, HTTPException, Request from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field from app.gateway.routers.multi_agent import ( _ROUNDTABLE_THREAD_METADATA, _auth_headers, _create_thread, + _flatten_content, _loopback_base, _make_loopback_client, - _special_run, + _parse_last_messages, + _run_payload, + _stream_upstream, _validate_agent_id, ) +from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/parallel-agents", tags=["parallel-agents"]) @@ -50,6 +58,49 @@ router = APIRouter(prefix="/api/parallel-agents", tags=["parallel-agents"]) # Soft cap on parallel degree — protects remote model concurrency + sandbox. MAX_PARALLEL_AGENTS = 8 +# 禁两类工具:①**写文件**(write_file / str_replace)—— 并行席位互相写文件会打架; +# ②**派活 / 脚本逃逸**(agent_orchestration / bash)—— 本面板里每个智能体只做自己那 +# 份分析交付,绝不该扮演「总控」去调度别人。弱模型(deepseek 等)拿到带「总控 / 业务 +# 链条」语气的任务后,会概率性地用 agent_orchestration 真去派活,或绕道 bash 跑脚本 +# 调内部接口模仿总控调度(bug:单步单智能体链条里子智能体输出「总控调度指令…」并用 +# glob / bash 翻找派活脚本)。物理禁掉这两类即可根治。其余只读工具(ls / read_file / +# 检索)与 ask_clarification(协作卡片)保留:既不互相打架,又能正常出步骤条 / 要澄清。 +_EXCLUDED_TOOLS: list[str] = [ + "write_file", # 并行不互相写文件打架 + "str_replace", + "agent_orchestration", # 子智能体不派活 / 不调度(防模仿总控) + "bash", # 禁跑脚本(防绕道脚本调内部接口模仿派活) +] + + +# ── 专用「绕过常规登录」取 token ────────────────────────────────────────────── +# 内网部署对标准 /api/v1/auth/login/username 有额外操作限制(口令门 / 网关裁剪等), +# 嵌入方拿不到 token。这里给并行多智能体面板单开一个**公共**端点:传用户名直接签发 token +# (账号不存在则自动注册,与 username 登录同一套逻辑)。已在 auth_middleware / csrf_middleware +# 的白名单里放行(公共 + CSRF 豁免)。⚠️ 这是有意的鉴权旁路,仅供内网部署的本功能使用。 + + +class _TokenRequest(BaseModel): + username: str = Field(min_length=1, max_length=128) + + +class _TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + user_id: str + + +@router.post("/auth/token", response_model=_TokenResponse) +async def issue_token(body: _TokenRequest) -> _TokenResponse: + """传用户名 → 直接签发 access_token(绕过常规登录,公共端点)。""" + # 懒加载,避免与 auth 路由的模块级循环依赖。 + from app.gateway.auth import create_access_token + from app.gateway.routers.auth import _get_or_create_user_by_username + + user, _created = await _get_or_create_user_by_username(body.username.strip()) + token = create_access_token(str(user.id), token_version=user.token_version) + return _TokenResponse(access_token=token, token_type="bearer", user_id=str(user.id)) + # ── frame normalisation (mirrors multi-agent.ts) ───────────────────────────── @@ -75,33 +126,136 @@ def _extract_ai_text(chunk: dict[str, Any]) -> str: return "" -def _extract_tool_steps(chunk: dict[str, Any]) -> list[dict[str, Any]]: - """Tool-call steps from a chunk obj — both complete ``tool_calls`` and - incremental ``tool_call_chunks`` (we only need the name to surface a step).""" - steps: list[dict[str, Any]] = [] - tool_calls = chunk.get("tool_calls") - if isinstance(tool_calls, list): - for tc in tool_calls: - if not isinstance(tc, dict): - continue - name = tc.get("name") - if not isinstance(name, str) or not name: - continue - ident = tc.get("id") or name - args = tc.get("args") if isinstance(tc.get("args"), dict) else None - steps.append({"key": f"tool:{ident}", "label": f"调用工具 {name}", "args": args}) - tcc = chunk.get("tool_call_chunks") - if isinstance(tcc, list): - for c in tcc: - if not isinstance(c, dict): - continue - name = c.get("name") - if not isinstance(name, str) or not name: - continue - idx = c.get("index") if isinstance(c.get("index"), int) else 0 - ident = c.get("id") or f"{name}:{idx}" - steps.append({"key": f"tool:{ident}", "label": f"调用工具 {name}", "args": None}) - return steps +def _ingest_tool_chunks(chunks: Any, acc: dict[int, dict[str, Any]]) -> None: + """累积 ``tool_call_chunks`` 的增量(name + args 字符串),按 index 归并 + (mirror multi-agent.ts ``ingestToolCallChunks``)。""" + if not isinstance(chunks, list): + return + for c in chunks: + if not isinstance(c, dict): + continue + idx = c.get("index") if isinstance(c.get("index"), int) else 0 + entry = acc.setdefault(idx, {"id": None, "name": "", "argsStr": ""}) + cid = c.get("id") + if isinstance(cid, str) and cid: + if entry["id"] and entry["id"] != cid: # 同 index 换了新调用 → 重置 + entry["name"] = "" + entry["argsStr"] = "" + entry["id"] = cid + name = c.get("name") + if isinstance(name, str) and name: + entry["name"] = name + a = c.get("args") + if isinstance(a, str): + entry["argsStr"] += a + + +def _synthetic_tool_calls(acc: dict[int, dict[str, Any]]) -> list[dict[str, Any]]: + """把累积的 argsStr 尽力 parse 成完整 args,产出 ``{id,name,args}``(未成完整 JSON 则跳过)。""" + out: list[dict[str, Any]] = [] + for entry in acc.values(): + if not entry["name"]: + continue + s = str(entry["argsStr"]).strip() + if not s: + continue + try: + args = json.loads(s) + except Exception: + continue + if isinstance(args, dict): + out.append({"id": entry["id"], "name": entry["name"], "args": args}) + return out + + +def _clarification_from_messages(messages: list[dict[str, Any]]) -> dict[str, Any] | None: + """从最终 messages 里找最后一个 ``ask_clarification`` 工具调用,抽出结构化澄清载荷 + (字段对齐 multi_agent leader 的 clarification 帧)。""" + for msg in reversed(messages): + if not isinstance(msg, dict): + continue + if (msg.get("type") or "").lower() not in ("ai", "aimessage", "aimessagechunk"): + continue + for tc in msg.get("tool_calls") or []: + if isinstance(tc, dict) and (tc.get("name") or "").lower() == "ask_clarification": + cargs = tc.get("args") if isinstance(tc.get("args"), dict) else {} + return { + "question": str(cargs.get("question") or ""), + "clarification_type": str(cargs.get("clarification_type") or ""), + "clarification_context": str(cargs.get("context") or ""), + "options": cargs.get("options") or [], + "allow_custom": bool(cargs.get("allow_custom", True)), + "allow_multiple": resolve_allow_multiple(cargs), + } + return None + + +async def _run_one_agent( + client: httpx.AsyncClient, + base: str, + headers: dict[str, str], + agent_id: str, + thread_id: str, + new_message: str, + model_name: str, +) -> AsyncIterator[str | dict[str, Any]]: + """跑一个智能体一轮(禁写文件 + 禁派活 / 脚本,其余只读工具放开)。 + + 与 ``multi_agent._special_run`` 同形(yield 原始上游 SSE 行 + 末尾一条终态 dict), + 但 ``excluded_tools`` = ``_EXCLUDED_TOOLS``(write_file / str_replace / + agent_orchestration / bash),且不做 post-broadcast(独立面板各智能体互不广播)。 + """ + body = _run_payload( + agent_name=agent_id, + model_name=model_name, + thread_id=thread_id, + new_message=new_message, + excluded_tools=list(_EXCLUDED_TOOLS), + skill_stop_names=[], + thinking_enabled=True, + reasoning_effort="medium", + multitask_strategy="rollback", + ) + captured: list[str] = [] + async for line, done in _stream_upstream(client, base, headers, thread_id, body): + if line is not None: + yield line + elif done is not None: + captured = done + messages = _parse_last_messages(captured) + if messages is None: + # 流被抢占/截断,没有终态 values 帧 —— 静默收尾(调用方据无终态帧兜底)。 + return + last_ai: dict[str, Any] = {} + for msg in reversed(messages): + if isinstance(msg, dict) and (msg.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"): + last_ai = msg + break + content = _flatten_content(last_ai.get("content", "")) + # 收集**所有** AI 消息的 tool_calls(全量 args,按顺序)—— 用于在收口时补发所有步骤 + # (含 ls / skill_list 这类无参/中间步骤),保证步骤条不丢步、且 args 完整。 + final_tool_calls: list[dict[str, Any]] = [] + for msg in messages: + if isinstance(msg, dict) and (msg.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"): + for tc in msg.get("tool_calls") or []: + if isinstance(tc, dict): + final_tool_calls.append(tc) + clarification = _clarification_from_messages(messages) + if clarification is not None: + # 智能体在向用户要澄清 —— 交给前端渲染「协作卡片」(对齐圆桌第一步)。 + yield { + "type": "clarification", + "content": content, + "clarification": clarification, + "tool_calls": final_tool_calls, + } + else: + yield { + "status": f"子智能体 {agent_id} 完成", + "content": content, + "agent_name": agent_id, + "tool_calls": final_tool_calls, + } def _build_task_message(task_name: str, brief: str) -> str: @@ -112,8 +266,15 @@ def _build_task_message(task_name: str, brief: str) -> str: parts.append(f"任务简介:\n{brief.strip()}") parts.append( "请你从你的专业职责出发,针对以上任务给出你的分析与交付。" - "本轮为**并行**研讨:请把你的产出**直接写在回复正文里**," - "不要写文件、不要执行脚本/命令。" + "本轮为**并行**研讨:请把你的产出**直接写在回复正文里**(本轮禁止写文件)。" + ) + # 角色边界护栏:本面板里每个智能体都是**执行席位**,不是总控 / 指挥。即便任务里带有 + # 「总控 / 业务链条 / 调度 / 派活」字样,也只完成属于自己的那份交付,严禁模仿总控派活。 + parts.append( + "【角色边界】你是本轮的**执行席位**,不是总控 / 指挥。即使任务里出现「总控 / 业务链条 / 调度 / " + "派活」等字样,你也只需完成**属于你自己**的那份分析与交付;严禁模仿总控口吻输出「总控调度指令 / " + "调度对象 / 分配给『…』的任务 / agent_name」之类调度内容,严禁罗列或转派其他席位的任务,严禁调用" + "任何派活 / 调度类工具,也不要用脚本(bash / python)去调用内部接口或翻找派活脚本。" ) return "\n\n".join(parts) @@ -156,28 +317,60 @@ async def run_parallel_agents_stream(request: Request, payload: dict = Body(...) ) -> None: await queue.put({"type": "agent_start", "agent_id": agent_id}) last_event = "" - seen_steps: set[str] = set() + emitted_tools: set[str] = set() # 已发过 step 的 tool_call id(去重) + tool_acc: dict[int, dict[str, Any]] = {} # tool_call_chunks 累积 buffer: list[str] = [] done_sent = False + + async def _emit_tool(tc: Any, allow_empty: bool = False) -> None: + """一个 tool_call(含完整 args)→ 一条 step 事件;按 id 去重。 + + ``allow_empty=False``(流式期间):args 为空先不发 —— 流式帧里的 tool_calls 常先给 + 空 ``{}`` 占位、真实 args 走 tool_call_chunks 后到,过早发会把空 args 钉死。等累积出 + 完整 args(synthetic)或终态全量 tool_calls(``allow_empty=True``)再发。 + """ + if not isinstance(tc, dict): + return + name = tc.get("name") + if not isinstance(name, str) or not name: + return + args = tc.get("args") if isinstance(tc.get("args"), dict) else None + if args is None: + return + if not args and not allow_empty: # 空 args 先不发,等 chunks / 终态补全 + return + key_id = str(tc.get("id") or name) + if key_id in emitted_tools: + return + emitted_tools.add(key_id) + await queue.put( + {"type": "step", "agent_id": agent_id, "key": f"tool_calling:{name}", "label": name, "args": args} + ) + try: thread_id = await _create_thread(client, base, headers, metadata=_ROUNDTABLE_THREAD_METADATA) - gen = _special_run( - client, - base, - headers, - {agent_id: thread_id}, - agent_id, - new_message, - [], - model_name, - display_name="", - no_file=True, # seat_parallel policy → 禁 write_file/bash/str_replace… + gen = _run_one_agent( # 只禁文件写入,其余工具放开(保留步骤条) + client, base, headers, agent_id, thread_id, new_message, model_name ) async for chunk in gen: if isinstance(chunk, dict): - # 终态帧 {status, content, agent_name} - content = str(chunk.get("content") or "") - await queue.put({"type": "agent_done", "agent_id": agent_id, "content": content}) + # 终态:先用最终 tool_calls(全量 args)补发漏掉的 step,再发收口/澄清。 + for tc in chunk.get("tool_calls") or []: + await _emit_tool(tc, allow_empty=True) + if chunk.get("type") == "clarification": + clar = chunk.get("clarification") or {} + await queue.put( + { + "type": "agent_clarification", + "agent_id": agent_id, + "content": str(chunk.get("content") or ""), + **clar, + } + ) + else: + await queue.put( + {"type": "agent_done", "agent_id": agent_id, "content": str(chunk.get("content") or "")} + ) done_sent = True continue line = chunk.strip() if isinstance(chunk, str) else "" @@ -200,12 +393,12 @@ async def run_parallel_agents_stream(request: Request, payload: dict = Body(...) if text: buffer.append(text) await queue.put({"type": "delta", "agent_id": agent_id, "text": text}) - for step in _extract_tool_steps(obj): - key = step["key"] - if key in seen_steps: - continue - seen_steps.add(key) - await queue.put({"type": "step", "agent_id": agent_id, **step}) + # 完整 tool_calls(部分模型直接给)+ 累积 chunks 后的合成调用,都尝试发 step。 + for tc in obj.get("tool_calls") or []: + await _emit_tool(tc) + _ingest_tool_chunks(obj.get("tool_call_chunks"), tool_acc) + for tc in _synthetic_tool_calls(tool_acc): + await _emit_tool(tc) if not done_sent: # 流被抢占/截断,没收到终态帧 —— 用已累积正文兜底收口本席位。 await queue.put( diff --git a/parallel-agents-vue/docs/00-测试说明.md b/parallel-agents-vue/docs/00-测试说明.md index d762c1e4..426c5ef1 100644 --- a/parallel-agents-vue/docs/00-测试说明.md +++ b/parallel-agents-vue/docs/00-测试说明.md @@ -14,6 +14,7 @@ | [05-嵌入与深色样式.md](./05-嵌入与深色样式.md) | 配置文件接口地址、抽屉/弹窗嵌入、深色自适应、不污染宿主 | | [06-可用智能体清单.md](./06-可用智能体清单.md) | **测试用真实 agent_id 清单(可直接复制粘贴)** + 示例任务 | | [07-使用说明.md](./07-使用说明.md) | **在其它 Vue 项目中引入哪些组件、如何使用**(props/事件/嵌入) | +| [08-开发文档.md](./08-开发文档.md) | **二次开发**:架构/数据流/事件协议/目录职责/后端契约/扩展指南 | ## 测试环境 diff --git a/parallel-agents-vue/docs/07-使用说明.md b/parallel-agents-vue/docs/07-使用说明.md index 3d11b05e..6a5c2e1d 100644 --- a/parallel-agents-vue/docs/07-使用说明.md +++ b/parallel-agents-vue/docs/07-使用说明.md @@ -102,8 +102,11 @@ app.use(ParallelAgentsVue); // 之后任意模板可直接用 +``` +- `agent_id`**必须是后端真实注册的 id**(只写名字无效);可用 id 见 [06-可用智能体清单.md](./06-可用智能体清单.md)。 +- `name`:席位/气泡显示名,可省(省了用 id)。 +- `avatarType`:可选 `blue|purple|emerald|amber|pink|human`;不传按顺序自动轮转配色。 + +### `username`——免密登录(推荐) + +```vue + +``` + +### `baseUrl`——后端地址(不传用 config 默认) + +```vue + +``` + +### `token` / `getToken`——已有登录态时(优先于 username) + +```vue + + + + +``` + +### `model`——指定模型(不传走默认/后端回退;也可在面板右上角下拉切) + +```vue + +``` + +### `dark`——深色(默认 true,自带成色) + +```vue + +``` + +### `taskId`——存储主键 + 回显(见 §七·二) + +```vue + (taskId = id)" +/> +``` + +### 全量示例(所有参数一起) + +```vue + + + + + (taskId = id)" + @completed="onCompleted" + /> + + +``` ## 七、事件 -| 事件 | 载荷 | 触发时机 | -| ----------------- | ------------ | --------------------------------------- | -| `saved` | `SavedTask` | 保存成功(新建或更新)后 | -| `update:taskId` | `string` | 当前任务 id 变化(保存 / 加载历史时) | +| 事件 | 载荷 | 触发时机 | +| ----------------- | ------------------ | --------------------------------------- | +| `saved` | `SavedTask` | 保存成功(新建或更新)后 | +| `update:taskId` | `string` | 当前任务 id 变化(保存 / 加载历史时) | +| `completed` | `AgentResult[]` | 一轮并行分析结束时,回传各智能体返回内容 | ```vue console.log('saved', t.id)" @update:taskId="(id) => (taskId = id)" + @completed="(results) => console.log('各智能体返回:', results)" /> ``` +## 七·一、如何获取模型返回的内容 + +每个智能体(模型)返回的正文是 `AgentResult.content`。两种方式,任选其一: + +**A. 事件**:监听 `@completed`,一轮分析结束时自动回传 `AgentResult[]`: + +```vue + { + // results: [{ agent_id, name, content, status }] + for (const r of results) console.log(r.name, '→', r.content); + }" +/> +``` + +**B. 方法(模板 ref)**:随时主动取(不必等事件): + +```vue + + + + + +``` + +`AgentResult = { agent_id, name, content, status }`,`content` 即该智能体模型返回的正文(Markdown)。 +`defineExpose` 还暴露了 `start()` / `cancel()`,可由宿主代替按钮触发开始 / 停止。 + +## 七·二、如何保存 + 回显聊天记录 + +**保存**:面板里点「保存」即把当前整段聊天记录(各智能体气泡 + 步骤条等)存到后端,按用户隔离。 +传 `:task-id` 时**以它为存储主键**做 upsert(已存在→更新;不存在→用该 id 新建);不传则首次保存由后端 +生成 id(通过 `@update:taskId` 回传,下次再保存即更新这条)。 + +**回显(两种方式)**: + +```vue + + +``` + +```ts +// 方式 1 变体:运行中改 taskId(双向绑定),面板会自动切到并回显那条任务 +const taskId = ref(""); // 留空=新任务 +// taskId = id" ... /> +taskId.value = "task-001"; // 改了之后面板自动回显 task-001 +``` + +> 方式 2:面板**右上角内置「历史」下拉**——点开选一条历史任务即回显,**无需写任何代码**。 + +要点:先**保存过**、再用**同一个 taskId**(或从历史里选)才会有内容回显;没存过的新 id 第一次进去是空白 +(后台会探测一次 `GET /…/{id}` 返回 404,属正常,面板静默处理)。 + --- ## 八、交互要点(与圆桌 Step2 一致) -- 输入「任务名称 + 简介」→「开始分析」→ 各智能体**并行**输出,运行期间**禁止写文件**(产出走正文)。 +- 输入「任务名称 + 简介」→「开始分析」→ 各智能体**并行**输出。运行期间禁用**写文件**(`write_file`/ + `str_replace`)与**派活/脚本**(`agent_orchestration`/`bash`,防子智能体模仿总控调度);检索、查看、 + `ask_clarification` 等照常 —— 所以**步骤条 / 协作卡片正常显示**,每个智能体只做自己那份分析。 - 主区每次只显示**一个**席位的消息列表;**点席位头像切换**要看哪个(单选)。 - 顶部席位状态条显示每个智能体的进行中 / 完成 / 出错。 - 操作区右侧有**模型切换**下拉(拉取后端 `/api/models`);选「默认」则用 `model` prop / 配置 / 后端回退。 diff --git a/parallel-agents-vue/docs/08-开发文档.md b/parallel-agents-vue/docs/08-开发文档.md new file mode 100644 index 00000000..0cf3cd45 --- /dev/null +++ b/parallel-agents-vue/docs/08-开发文档.md @@ -0,0 +1,226 @@ +# 开发文档 · parallel-agents-vue + +面向**维护 / 二次开发**这个前端的同学。讲清楚:它是什么、架构与数据流、目录与各文件职责、 +对接的后端契约、样式系统、以及常见扩展怎么改。使用 / 嵌入请看 [07-使用说明.md](./07-使用说明.md)。 + +--- + +## 1. 它是什么 + +一个**独立、可嵌入**的 Vue3 组件:输入「任务名称 + 简介」→ **并行**调用宿主指定的多个智能体 → +深色消息列表(**1:1 复刻圆桌 Step2**:步骤条 / 协作卡片 / 席位状态条 / 点头像切换单显)→ +支持**保存 / 回显 / 重新分析**。只对外暴露一个组件 `ParallelAgentsPanel`。 + +- 后端是同仓库的 DeerFlow 网关(`offline-backend-20260512/backend`),本前端只消费它的几个 HTTP 接口。 +- 并行**编排在后端**:前端发一条请求,后端扇出 N 个智能体、合并成**一条 SSE**回来;前端只解析这一条流。 + +--- + +## 2. 技术栈 + +| 方面 | 选型 | +| -------- | ----------------------------------------------------------------- | +| 框架 | Vue 3(` + + + + + + + 需要你的确认 + + {{ payload.clarificationType }} + + + + {{ payload.context }} + + {{ payload.question }} + + + + + + {{ allowMultiple ? "可多选(再次点击取消选择)" : "请选择一项(点击即发送)" }} + + + + {{ opt.label }} + + {{ opt.description }} + + + ✓ + + + + + + + + + + + + {{ submitting ? "发送中…" : "发送回答" }} + + + + + + diff --git a/parallel-agents-vue/src/components/MessageBubble.vue b/parallel-agents-vue/src/components/MessageBubble.vue index a5e5c9e4..8e3dd79c 100644 --- a/parallel-agents-vue/src/components/MessageBubble.vue +++ b/parallel-agents-vue/src/components/MessageBubble.vue @@ -6,8 +6,10 @@ import type { Dialogue } from "@/types"; import Avatar from "./Avatar.vue"; import MessageStepsCard from "./MessageStepsCard.vue"; import MarkdownContent from "./MarkdownContent.vue"; +import ClarificationCard from "./ClarificationCard.vue"; -const props = defineProps<{ msg: Dialogue }>(); +const props = defineProps<{ msg: Dialogue; submitting?: boolean }>(); +const emit = defineEmits<{ (e: "clarify", agentId: string, answer: string): void }>(); const hasContent = computed(() => (props.msg.content ?? "").trim().length > 0); @@ -45,6 +47,14 @@ const hasContent = computed(() => (props.msg.content ?? "").trim().length > 0); + + + emit('clarify', msg.agentId, answer)" + /> diff --git a/parallel-agents-vue/src/components/MessageStepsCard.vue b/parallel-agents-vue/src/components/MessageStepsCard.vue index 9cb8ecef..8fcaffbb 100644 --- a/parallel-agents-vue/src/components/MessageStepsCard.vue +++ b/parallel-agents-vue/src/components/MessageStepsCard.vue @@ -1,52 +1,95 @@ - + - ▶ + ▶ 执行步骤({{ steps.length }}) - ◜ - - + - - - {{ s.label }} - · {{ argSummary(s.args) }} - · {{ s.ts }} - - - + + + + + + + + + {{ row.display.label }} + {{ row.step.ts }} + + + + + + {{ row.args.text }} + + {{ row.args.text }} + + {{ row.args.text }} + + + + {{ p.k }}: {{ p.v }} + + + + + + diff --git a/parallel-agents-vue/src/components/ParallelAgentsPanel.vue b/parallel-agents-vue/src/components/ParallelAgentsPanel.vue index 00cc2928..0e4cfe3c 100644 --- a/parallel-agents-vue/src/components/ParallelAgentsPanel.vue +++ b/parallel-agents-vue/src/components/ParallelAgentsPanel.vue @@ -9,7 +9,7 @@ * 自适应容器:根元素 `h-full w-full flex flex-col min-h-0`,宿主直接塞进抽屉 / 弹窗即可。 */ import { computed, onMounted, reactive, ref, watch } from "vue"; -import type { AgentInput, Dialogue, SavedTask } from "@/types"; +import type { AgentInput, AgentResult, Dialogue, SavedTask, SeatView } from "@/types"; import { useParallelRun } from "@/composables/useParallelRun"; import { createTask, getTask, listModels, listTasks, updateTask, type ApiConfig, type ModelItem } from "@/api"; import { DEFAULT_BASE_URL, DEFAULT_MODEL } from "@/config"; @@ -38,6 +38,8 @@ const props = withDefaults( const emit = defineEmits<{ (e: "saved", task: SavedTask): void; (e: "update:taskId", id: string): void; + /** 一轮并行分析结束时回传各智能体的返回内容。 */ + (e: "completed", results: AgentResult[]): void; }>(); const cfg = computed(() => ({ @@ -90,6 +92,15 @@ watch( { immediate: true }, ); +// 宿主传入 / 改变 taskId:作为存储主键,并尝试加载回显该任务(不存在则只设 id)。 +watch( + () => props.taskId, + (id) => { + currentTaskId.value = id ?? null; + if (id) void loadTask(id); + }, +); + /** 点头像切换显示:单选,始终切到点击的那个席位。 */ function toggleSeat(agentId: string) { selected.value = [agentId]; @@ -101,6 +112,43 @@ const visibleSeats = computed(() => { return seat ? [seat] : []; }); +// ── 给宿主读取「每个智能体模型返回的内容」的简单方法 ────────────────────────── +function _statusOf(agentId: string, streaming?: boolean): SeatView["status"] { + return seats.value.find((s) => s.agentId === agentId)?.status ?? (streaming ? "running" : "done"); +} +/** 取所有智能体的返回内容(按席位顺序)。宿主:`panelRef.value.getResults()`。 */ +function getResults(): AgentResult[] { + return dialogues.value.map((d) => ({ + agent_id: d.agentId, + name: d.sender, + content: d.content, + status: _statusOf(d.agentId, d.streaming), + })); +} +/** 取某个智能体的返回内容;不存在返回 null。 */ +function getResult(agentId: string): AgentResult | null { + return getResults().find((r) => r.agent_id === agentId) ?? null; +} +// 模板 ref 用:`` → panelRef.value.getResults() +defineExpose({ getResults, getResult, start: () => onStart(), cancel: () => run.cancel() }); + +/** 用户在某智能体的协作卡片里提交了回答 → 带上回答重跑该智能体。 */ +async function onClarify(agentId: string, answer: string) { + errorMsg.value = null; + const brief = `${form.brief}\n\n【用户补充】${answer}`.trim(); + try { + await run.rerunAgent(cfg.value, { + taskName: form.taskName, + brief, + agentId, + model: selectedModel.value || undefined, + }); + emit("completed", getResults()); + } catch (err) { + errorMsg.value = err instanceof Error ? err.message : String(err); + } +} + async function onStart() { if (!canStart.value) return; errorMsg.value = null; @@ -111,6 +159,8 @@ async function onStart() { agents: props.agents, model: selectedModel.value || undefined, }); + // 一轮结束:回传各智能体返回内容(宿主也可用 ref 调 getResults())。 + emit("completed", getResults()); } catch (err) { errorMsg.value = err instanceof Error ? err.message : String(err); } @@ -123,21 +173,15 @@ async function onSave() { try { const title = form.taskName.trim() || "未命名任务"; const agentsSnapshot: AgentInput[] = props.agents.map((a) => ({ ...a })); + const payload = { title, brief: form.brief, agents: agentsSnapshot, messages: dialogues.value }; + // 按宿主传入的 taskId **upsert**:有 id 先更新,不存在(404)再用该 id 创建;无 id 则新建。 let saved: SavedTask | null; - if (currentTaskId.value) { - saved = await updateTask(cfg.value, currentTaskId.value, { - title, - brief: form.brief, - agents: agentsSnapshot, - messages: dialogues.value, - }); + const id = currentTaskId.value; + if (id) { + saved = await updateTask(cfg.value, id, payload); + if (!saved) saved = await createTask(cfg.value, { id, ...payload }); } else { - saved = await createTask(cfg.value, { - title, - brief: form.brief, - agents: agentsSnapshot, - messages: dialogues.value, - }); + saved = await createTask(cfg.value, payload); } if (saved) { currentTaskId.value = saved.id; @@ -311,6 +355,8 @@ onMounted(() => { {{ seat.name }} · 等待输出… diff --git a/parallel-agents-vue/src/components/StepIcon.vue b/parallel-agents-vue/src/components/StepIcon.vue new file mode 100644 index 00000000..78027228 --- /dev/null +++ b/parallel-agents-vue/src/components/StepIcon.vue @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/parallel-agents-vue/src/composables/useParallelRun.ts b/parallel-agents-vue/src/composables/useParallelRun.ts index 3d238c95..0167af05 100644 --- a/parallel-agents-vue/src/composables/useParallelRun.ts +++ b/parallel-agents-vue/src/composables/useParallelRun.ts @@ -95,6 +95,26 @@ export function useParallelRun() { dialogues.value = [...dialogues.value]; break; } + case "agent_clarification": { + flushPending(); + const d = findDialogue(ev.agent_id); + if (d) { + if (ev.content) d.content = ev.content; + d.clarification = { + question: ev.question, + clarificationType: ev.clarification_type, + context: ev.clarification_context, + options: ev.options, + allowCustom: ev.allow_custom, + allowMultiple: ev.allow_multiple, + }; + d.streaming = false; + d.confidence = "待确认"; + } + setSeatStatus(ev.agent_id, "done"); + dialogues.value = [...dialogues.value]; + break; + } case "agent_error": { flushPending(); const d = findDialogue(ev.agent_id); @@ -178,6 +198,48 @@ export function useParallelRun() { running.value = false; } + /** + * 重跑**单个**智能体(用于回答澄清后继续):清掉它的旧气泡/步骤/澄清,带上新消息重新 + * 流式,只更新该席位列,其它席位不动。 + */ + async function rerunAgent( + cfg: ApiConfig, + args: { taskName: string; brief: string; agentId: string; model?: string }, + ): Promise { + if (running.value) return; + const d = dialogues.value.find((x) => x.agentId === args.agentId); + if (!d) return; + d.content = ""; + d.steps = []; + d.clarification = undefined; + d.streaming = true; + d.confidence = "并行执行中"; + d.time = nowTime(); + setSeatStatus(args.agentId, "running"); + dialogues.value = [...dialogues.value]; + + running.value = true; + const ac = new AbortController(); + abortRef.value = ac; + try { + await streamParallelAgents( + cfg, + { task_name: args.taskName, brief: args.brief, agent_ids: [args.agentId], model: args.model }, + { onEvent: handleEvent, onError: (err) => console.error("[parallel-run]", err) }, + ac.signal, + ); + } finally { + const dd = dialogues.value.find((x) => x.agentId === args.agentId); + if (dd && dd.streaming) { + dd.streaming = false; + if (dd.confidence === "并行执行中") dd.confidence = "已完成"; + } + dialogues.value = [...dialogues.value]; + running.value = false; + abortRef.value = null; + } + } + /** 回显:从保存的对话列表重建(并据此还原席位条为已完成)。 */ function hydrate(saved: Dialogue[]) { cancel(); @@ -208,5 +270,5 @@ export function useParallelRun() { seats.value = []; } - return { dialogues, seats, running, start, cancel, hydrate, reset }; + return { dialogues, seats, running, start, cancel, hydrate, reset, rerunAgent }; } diff --git a/parallel-agents-vue/src/config.ts b/parallel-agents-vue/src/config.ts index c3430a51..7f5eec18 100644 --- a/parallel-agents-vue/src/config.ts +++ b/parallel-agents-vue/src/config.ts @@ -12,5 +12,8 @@ export const DEFAULT_BASE_URL: string = export const DEFAULT_MODEL: string | undefined = (import.meta as unknown as { env?: Record }).env?.VITE_PA_MODEL || undefined; -/** 免密「用户名登录」端点(公共 + CSRF 豁免)。 */ -export const USERNAME_LOGIN_PATH = "/api/v1/auth/login/username"; +/** + * 取 token 端点:本面板**专用**的「绕过常规登录」公共端点(传用户名直接签发 token)。 + * 内网部署对标准 /api/v1/auth/login/username 有额外限制,故走这个专用端点。 + */ +export const TOKEN_PATH = "/api/parallel-agents/auth/token"; diff --git a/parallel-agents-vue/src/index.ts b/parallel-agents-vue/src/index.ts index a42640d7..010d65c5 100644 --- a/parallel-agents-vue/src/index.ts +++ b/parallel-agents-vue/src/index.ts @@ -9,7 +9,7 @@ import type { App } from "vue"; import ParallelAgentsPanel from "./components/ParallelAgentsPanel.vue"; export { ParallelAgentsPanel }; -export type { AgentInput, Dialogue, SavedTask, SeatView, StreamEvent } from "./types"; +export type { AgentInput, AgentResult, Dialogue, SavedTask, SeatView, StreamEvent } from "./types"; export default { install(app: App) { diff --git a/parallel-agents-vue/src/lib/step-display.ts b/parallel-agents-vue/src/lib/step-display.ts new file mode 100644 index 00000000..15abff28 --- /dev/null +++ b/parallel-agents-vue/src/lib/step-display.ts @@ -0,0 +1,120 @@ +/** + * 步骤条渲染辅助(Vue 端,1:1 对齐圆桌 `roundtable-planning/lib/step-display.tsx`)。 + * 把 step.key(`tool_calling:`)+ args 翻译成「图标 key + 中文文案」, + * 以及参数子节点的结构化描述(chip / code / text / kv),供 MessageStepsCard 渲染。 + */ + +export type IconKey = + | "search" + | "globe" + | "folder" + | "book" + | "pen" + | "terminal" + | "todo" + | "question" + | "wrench" + | "lightbulb" + | "dot"; + +export interface StepDisplay { + icon: IconKey; + label: string; +} + +function str(v: unknown): string { + return typeof v === "string" ? v.trim() : ""; +} + +/** key=`tool_calling:` + args → 图标 + 中文文案(对齐主聊天 toolCalls 文案)。 */ +export function getStepDisplay(key: string, fallbackLabel: string, args?: Record | null): StepDisplay { + const [phase, toolName] = key.split(":"); + if (phase === "tool_calling" && toolName) { + switch (toolName) { + case "web_search": { + const q = str(args?.query); + return { icon: "search", label: q ? `在网络上搜索 “${q}”` : "搜索相关信息" }; + } + case "image_search": { + const q = str(args?.query); + return { icon: "search", label: q ? `搜索相关图片 “${q}”` : "搜索相关图片" }; + } + case "web_fetch": + return { icon: "globe", label: "查看网页" }; + case "ls": + return { icon: "folder", label: str(args?.description) || "列出文件夹" }; + case "read_file": + return { icon: "book", label: str(args?.description) || "读取文件" }; + case "write_file": + case "str_replace": + return { icon: "pen", label: str(args?.description) || "写入文件" }; + case "bash": + return { icon: "terminal", label: str(args?.description) || "执行命令" }; + case "write_todos": + return { icon: "todo", label: "更新 To-do 列表" }; + case "ask_clarification": + return { icon: "question", label: "需要你的协助" }; + case "present_files": + return { icon: "folder", label: "展示文件" }; + case "agent_orchestration": + return { icon: "wrench", label: "派活给子智能体" }; + default: + return { icon: "wrench", label: `使用 “${toolName}” 工具` }; + } + } + if (phase === "streaming") return { icon: "lightbulb", label: fallbackLabel }; + return { icon: "dot", label: fallbackLabel }; +} + +/** 参数子节点的结构化描述(供模板按 kind 渲染)。null = 不渲染。 */ +export type StepArgs = + | { kind: "chip"; text: string } + | { kind: "code"; text: string } + | { kind: "text"; text: string } + | { kind: "kv"; pairs: Array<{ k: string; v: string }> } + | null; + +export function getStepArgs(key: string, args?: Record | null): StepArgs { + if (!args) return null; + const [, toolName] = key.split(":"); + if (!toolName) return null; + switch (toolName) { + case "web_search": + case "image_search": { + const q = str(args.query); + return q ? { kind: "chip", text: q } : null; + } + case "web_fetch": { + const u = str(args.url); + return u ? { kind: "chip", text: u } : null; + } + case "read_file": + case "ls": + case "write_file": + case "str_replace": { + const p = str(args.path); + return p ? { kind: "chip", text: p } : null; + } + case "bash": { + const c = str(args.command); + return c ? { kind: "code", text: c } : null; + } + case "write_todos": + return null; + case "ask_clarification": { + const q = str(args.question); + return q ? { kind: "text", text: q } : null; + } + case "agent_orchestration": { + const task = str(args.task); + return task ? { kind: "text", text: task } : null; + } + default: { + const pairs = Object.entries(args) + .filter(([, v]) => v !== null && v !== undefined && v !== "") + .slice(0, 4) + .map(([k, v]) => ({ k, v: typeof v === "string" ? v : JSON.stringify(v) })); + return pairs.length ? { kind: "kv", pairs } : null; + } + } +} diff --git a/parallel-agents-vue/src/types.ts b/parallel-agents-vue/src/types.ts index a7c1a5a1..baa86396 100644 --- a/parallel-agents-vue/src/types.ts +++ b/parallel-agents-vue/src/types.ts @@ -5,7 +5,7 @@ * 归到对应席位列)。`StreamEvent` 是后端 `/api/parallel-agents/run/stream` 的统一事件协议。 */ -/** 一条工具调用步骤(喂步骤条 / 思维链卡)。 */ +/** 一条工具调用步骤(喂步骤条 / 思维链卡)。`key` 形如 `tool_calling:`。 */ export interface DialogueStep { id: string; key: string; @@ -14,6 +14,19 @@ export interface DialogueStep { args?: Record | null; } +/** 澄清/协作卡片的一个选项(字符串或带描述的对象)。 */ +export type ClarOption = string | { id?: string; label?: string; description?: string }; + +/** 智能体向用户要澄清时的结构化载荷(对齐圆桌第一步协作卡片)。 */ +export interface ClarificationPayload { + question: string; + clarificationType: string; + context: string; + options: ClarOption[]; + allowCustom: boolean; + allowMultiple: boolean; +} + /** 一条消息气泡(对齐 Step2Dialogue + agentId)。 */ export interface Dialogue { id: number; @@ -27,6 +40,8 @@ export interface Dialogue { confidence: string; streaming?: boolean; steps?: DialogueStep[]; + /** 该智能体在要澄清时的协作卡片载荷(有则渲染卡片)。 */ + clarification?: ClarificationPayload; } /** 并行席位的视图状态(席位状态条用)。 */ @@ -51,10 +66,30 @@ export type StreamEvent = | { type: "agent_start"; agent_id: string } | { type: "delta"; agent_id: string; text: string } | { type: "step"; agent_id: string; key: string; label: string; args?: Record | null } + | { + type: "agent_clarification"; + agent_id: string; + content: string; + question: string; + clarification_type: string; + clarification_context: string; + options: ClarOption[]; + allow_custom: boolean; + allow_multiple: boolean; + } | { type: "agent_done"; agent_id: string; content: string } | { type: "agent_error"; agent_id: string; message: string } | { type: "all_done" }; +/** 单个智能体的返回结果(供宿主通过 getResults() / completed 事件读取)。 */ +export interface AgentResult { + agent_id: string; + name: string; + /** 该智能体模型返回的正文(Markdown)。 */ + content: string; + status: "pending" | "running" | "done" | "error"; +} + /** 持久化的任务记录(与后端 /api/parallel-agent-tasks 对应)。 */ export interface SavedTask { id: string;
+ {{ payload.context }} +
{{ row.args.text }}