feat(parallel-agents): 子智能体禁派活/脚本(防模仿总控) + 面板能力完善

后端 parallel_agents.py:每个并行席位 excluded_tools 增加 agent_orchestration
(派活) 与 bash(脚本逃逸),并在任务消息里加「角色边界」护栏——根治弱模型拿到
带「总控/业务链条」语气的任务后模仿总控输出「总控调度指令…」、绕道 glob/bash
翻找派活脚本的问题。新增 /api/parallel-agents/auth/token 取 token 端点
(auth/csrf 豁免)。

parallel-agents-vue 面板:协作澄清卡片、步骤图标/步骤展示、API 与类型完善等。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
董宏凯andClaude Opus 4.8 committed 2026-06-09 13:41:39 +08:00
1 parent 3405795371
commit c230f7a6c6
18 files changed
+1276 -128

No files matched your search

@@ -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",
}
)
@@ -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",
}
)
@@ -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(
@@ -14,6 +14,7 @@
| [05-嵌入与深色样式.md](./05-嵌入与深色样式.md) | 配置文件接口地址、抽屉/弹窗嵌入、深色自适应、不污染宿主 |
| [06-可用智能体清单.md](./06-可用智能体清单.md) | **测试用真实 agent_id 清单(可直接复制粘贴)** + 示例任务 |
| [07-使用说明.md](./07-使用说明.md) | **在其它 Vue 项目中引入哪些组件、如何使用**(props/事件/嵌入) |
| [08-开发文档.md](./08-开发文档.md) | **二次开发**:架构/数据流/事件协议/目录职责/后端契约/扩展指南 |
## 测试环境
+190 -8
View File
@@ -102,8 +102,11 @@ app.use(ParallelAgentsVue); // 之后任意模板可直接用 <ParallelAgentsPan
- **接口地址**:默认读 `src/config.ts` 的 `DEFAULT_BASE_URL`(可改源码,或 dev 设 `VITE_PA_BASE_URL`);
也可用 `:base-url` prop 临时覆盖。
- **登录**:把宿主的**用户名**用 `:username` 传进来即可——组件自动走 DeerFlow 免密登录端点换 token、
缓存、401 自动重登。**宿主不用管 token**。若已有 token,可改传 `:token` / `:get-token`(优先级更高)。
- **登录**:把宿主的**用户名**用 `:username` 传进来即可——组件自动走本面板**专用**的「绕过常规登录」
公共端点 `POST /api/parallel-agents/auth/token` 换 token、缓存、401 自动重登。**宿主不用管 token**。
若已有 token,可改传 `:token` / `:get-token`(优先级更高)。
- 为什么专用端点:内网部署对标准 `/api/v1/auth/login/username` 有额外限制(口令门/网关裁剪等),拿不到
token;该端点传用户名直接签发(账号不存在自动注册),已在 auth/csrf 中间件白名单里放行。
- token 缓存按 `baseUrl::username` 维度,多个实例/多用户互不串。
---
@@ -122,14 +125,121 @@ app.use(ParallelAgentsVue); // 之后任意模板可直接用 <ParallelAgentsPan
| `model` | `string` | | 可选模型名(不传走默认 / 后端回退) |
| `dark` | `boolean`(默认 `true`) | | 深色面板(自带成色,不依赖宿主主题) |
`AgentInput`/`SavedTask` 等类型也从包里导出:`import type { AgentInput, SavedTask } from "parallel-agents-vue"`。
`AgentInput`/`SavedTask`/`AgentResult` 等类型也从包里导出:
`import type { AgentInput, SavedTask, AgentResult } from "parallel-agents-vue"`。
---
## 六·一、每个参数怎么传(逐个举例)
### `agents`(必填)——「智能体参数」怎么传
一个数组,每项 `{ agent_id, name?, avatarType? }`:
```ts
const agents = [
{ agent_id: "roundtable-solution-design", name: "方案设计" }, // 最常用:id + 显示名
{ agent_id: "roundtable-risk-review", name: "风险审查", avatarType: "amber" }, // 顺带指定头像配色
{ agent_id: "demo-expert" }, // 只给 id(显示名回退成 id)
];
```
```vue
<ParallelAgentsPanel :agents="agents" :username="'demo'" />
```
- `agent_id`**必须是后端真实注册的 id**(只写名字无效);可用 id 见 [06-可用智能体清单.md](./06-可用智能体清单.md)。
- `name`:席位/气泡显示名,可省(省了用 id)。
- `avatarType`:可选 `blue|purple|emerald|amber|pink|human`;不传按顺序自动轮转配色。
### `username`——免密登录(推荐)
```vue
<ParallelAgentsPanel :username="currentUser.name" :agents="agents" />
```
### `baseUrl`——后端地址(不传用 config 默认)
```vue
<ParallelAgentsPanel :base-url="'http://10.0.0.5:8001'" :username="'demo'" :agents="agents" />
```
### `token` / `getToken`——已有登录态时(优先于 username)
```vue
<!-- 直接给字符串 token -->
<ParallelAgentsPanel :token="hostToken" :agents="agents" />
<!-- 或给一个(异步)取 token 的函数 -->
<ParallelAgentsPanel :get-token="() => authStore.getAccessToken()" :agents="agents" />
```
### `model`——指定模型(不传走默认/后端回退;也可在面板右上角下拉切)
```vue
<ParallelAgentsPanel :model="'deepseek-chat'" :username="'demo'" :agents="agents" />
```
### `dark`——深色(默认 true,自带成色)
```vue
<ParallelAgentsPanel :dark="true" :username="'demo'" :agents="agents" />
```
### `taskId`——存储主键 + 回显(见 §七·二)
```vue
<ParallelAgentsPanel
:task-id="taskId"
:username="'demo'"
:agents="agents"
@update:taskId="(id) => (taskId = id)"
/>
```
### 全量示例(所有参数一起)
```vue
<script setup lang="ts">
import { ref } from "vue";
import { ParallelAgentsPanel } from "parallel-agents-vue";
import "parallel-agents-vue/style.css";
import type { AgentInput, AgentResult, SavedTask } from "parallel-agents-vue";
const agents: AgentInput[] = [
{ agent_id: "roundtable-solution-design", name: "方案设计" },
{ agent_id: "roundtable-risk-review", name: "风险审查" },
];
const taskId = ref("task-001");
function onSaved(t: SavedTask) {
console.log("已保存", t.id);
}
function onCompleted(results: AgentResult[]) {
console.log("各智能体返回内容", results);
}
</script>
<template>
<div style="height: 86vh">
<ParallelAgentsPanel
:base-url="'http://localhost:8001'"
:username="'demo'"
:agents="agents"
:task-id="taskId"
:model="'deepseek-chat'"
:dark="true"
@saved="onSaved"
@update:taskId="(id) => (taskId = id)"
@completed="onCompleted"
/>
</div>
</template>
```
## 七、事件
| 事件 | 载荷 | 触发时机 |
| ----------------- | ------------ | --------------------------------------- |
| `saved` | `SavedTask` | 保存成功(新建或更新)后 |
| `update:taskId` | `string` | 当前任务 id 变化(保存 / 加载历史时) |
| 事件 | 载荷 | 触发时机 |
| ----------------- | ------------------ | --------------------------------------- |
| `saved` | `SavedTask` | 保存成功(新建或更新)后 |
| `update:taskId` | `string` | 当前任务 id 变化(保存 / 加载历史时) |
| `completed` | `AgentResult[]` | 一轮并行分析结束时,回传各智能体返回内容 |
```vue
<ParallelAgentsPanel
@@ -138,14 +248,86 @@ app.use(ParallelAgentsVue); // 之后任意模板可直接用 <ParallelAgentsPan
:task-id="taskId"
@saved="(t) => console.log('saved', t.id)"
@update:taskId="(id) => (taskId = id)"
@completed="(results) => console.log('各智能体返回:', results)"
/>
```
## 七·一、如何获取模型返回的内容
每个智能体(模型)返回的正文是 `AgentResult.content`。两种方式,任选其一:
**A. 事件**:监听 `@completed`,一轮分析结束时自动回传 `AgentResult[]`:
```vue
<ParallelAgentsPanel
:username="'demo'"
:agents="agents"
@completed="(results) => {
// results: [{ agent_id, name, content, status }]
for (const r of results) console.log(r.name, '→', r.content);
}"
/>
```
**B. 方法(模板 ref)**:随时主动取(不必等事件):
```vue
<script setup lang="ts">
import { ref } from "vue";
import { ParallelAgentsPanel } from "parallel-agents-vue";
import type { AgentResult } from "parallel-agents-vue";
const panelRef = ref<InstanceType<typeof ParallelAgentsPanel> | null>(null);
function readAll() {
const all: AgentResult[] = panelRef.value!.getResults();
// [{ agent_id, name, content, status }]
}
function readOne(id: string) {
const one = panelRef.value!.getResult(id); // AgentResult | null
}
</script>
<template>
<ParallelAgentsPanel ref="panelRef" :username="user" :agents="agents" />
</template>
```
`AgentResult = { agent_id, name, content, status }`,`content` 即该智能体模型返回的正文(Markdown)。
`defineExpose` 还暴露了 `start()` / `cancel()`,可由宿主代替按钮触发开始 / 停止。
## 七·二、如何保存 + 回显聊天记录
**保存**:面板里点「保存」即把当前整段聊天记录(各智能体气泡 + 步骤条等)存到后端,按用户隔离。
传 `:task-id` 时**以它为存储主键**做 upsert(已存在→更新;不存在→用该 id 新建);不传则首次保存由后端
生成 id(通过 `@update:taskId` 回传,下次再保存即更新这条)。
**回显(两种方式)**:
```vue
<!-- 方式 1:挂载时传一个已存在的 taskId → 自动加载并回显该任务的聊天记录 -->
<ParallelAgentsPanel :task-id="'task-001'" :username="'demo'" :agents="agents" />
```
```ts
// 方式 1 变体:运行中改 taskId(双向绑定),面板会自动切到并回显那条任务
const taskId = ref<string>(""); // 留空=新任务
// <ParallelAgentsPanel :task-id="taskId" @update:taskId="id => 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 / 配置 / 后端回退。
+226
View File
@@ -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(`<script setup>` + Composition API) |
| 构建 | Vite 6(dev app + `build:lib` 组件库双产物) |
| 语言 | TypeScript(`strict` + `noUnusedLocals/Parameters`) |
| 样式 | TailwindCSS v4(**仅 utilities,不引 preflight**)+ 作用域 `.pa-*` token |
| Markdown | `markdown-it`(打进库,宿主无需安装) |
| peer dep | `vue ^3.4`(宿主提供,库不打包 vue) |
---
## 3. 目录结构与各文件职责
```
src/
├─ index.ts 库入口:导出 ParallelAgentsPanel + install 插件 + 类型;import 样式
├─ main.ts / App.vue dev demo(仅本地调试用,不进库)
├─ config.ts 配置:DEFAULT_BASE_URL / DEFAULT_MODEL / TOKEN_PATH
├─ types.ts 共享类型:Dialogue / SeatView / DialogueStep / ClarificationPayload
│ / AgentInput / AgentResult / SavedTask / StreamEvent(事件协议)
├─ api.ts 后端访问层:鉴权(取 token+缓存+401 重登) / SSE 流 / 任务 CRUD / 模型列表
├─ composables/
│ └─ useParallelRun.ts 运行编排(前端态核心):消费合并 SSE → 维护 dialogues/seats;
│ start / cancel / hydrate(回显) / reset / rerunAgent(单席位重跑)
├─ lib/
│ ├─ avatar-constants.ts 头像配色 + getAvatarTypeFor(轮转)
│ └─ step-display.ts 步骤条:getStepDisplay(图标+中文文案) / getStepArgs(参数子节点)
├─ styles/
│ └─ parallel-agents.css 作用域深色 token(--pa-*) + .pa-* 语义类 + Markdown 样式(无 preflight)
└─ components/
├─ ParallelAgentsPanel.vue 主组件:输入区/操作区/模型切换/历史/席位条/会话区 + 全部状态编排
├─ ParallelSeatStrip.vue 席位状态条(头像 + running/done/error 角标,点击切换单显)
├─ MessageBubble.vue 单条气泡:头像/发言人/徽标/时间/步骤条/正文/光标/协作卡片
├─ MessageStepsCard.vue 步骤条(ChainOfThought 结构,1:1 对齐 Step2)
├─ StepIcon.vue 步骤图标(lucide 风格 inline SVG,按 IconKey)
├─ ClarificationCard.vue 协作 / 澄清卡片(1:1 对齐圆桌第一步 IntentClarificationCard)
├─ MarkdownContent.vue markdown-it 渲染
└─ Avatar.vue 机器人 / 真人 SVG 头像
```
---
## 4. 架构与数据流
一次「开始分析」的端到端链路:
```
用户输入(任务名+简介) ──onStart──> useParallelRun.start(cfg, {taskName, brief, agents, model})
│ 为每个 agent 建一个 Dialogue(流式气泡) + SeatView(席位)
▼
api.streamParallelAgents(cfg, body, handlers, signal)
│ POST {baseUrl}/api/parallel-agents/run/stream (Authorization: Bearer <token>)
▼
后端扇出:每个 agent 各跑一轮 LangGraph,合并成「一条带 agent_id 的 SSE」
▼ 逐条 StreamEvent(见 §5)
useParallelRun.handleEvent(ev) 按 agent_id 路由:
delta→气泡正文(rAF 节流) · step→步骤条 · agent_clarification→协作卡片
agent_done→收口 · agent_error→错误 · all_done→收尾
▼ 响应式 dialogues / seats 变化
组件渲染:ParallelSeatStrip + 当前选中席位的 MessageBubble(单显,点头像切换)
▼ run 结束
panel emit "completed"(AgentResult[]);可点「保存」(按 taskId upsert) / 「重新分析」(重跑)
```
**关键不变量**
- 主区**单选**显示一个席位的消息(`selected=[agentId]`,点头像切换);后台 N 个智能体仍并行执行。
- delta 用 `requestAnimationFrame` 合并刷新,避免逐 token reflow。
- `bubbleByAgent: Map<agentId, bubbleId>` 把事件路由到对应气泡;hydrate/start 时重建。
---
## 5. 统一事件协议(前后端唯一契约)
后端 `/run/stream` 每条 `data:` 是一个 JSON(`StreamEvent`,定义在 `types.ts`):
| type | 字段 | 前端处理 |
| --------------------- | --------------------------------------------------------------------------------------------- | --------------------------------- |
| `agent_start` | `agent_id` | 该席位置 running |
| `delta` | `agent_id, text` | 追加正文(节流) |
| `step` | `agent_id, key(=`tool_calling:<tool>`), label, args` | 步骤条加一步(含完整 args) |
| `agent_clarification` | `agent_id, content, question, clarification_type, clarification_context, options, allow_*` | 设 `dialogue.clarification` → 卡片 |
| `agent_done` | `agent_id, content` | 收口,正文以终态为准 |
| `agent_error` | `agent_id, message` | 该席位置 error |
| `all_done` | — | 全部结束 |
**核心数据模型**(`types.ts`):
- `Dialogue { id, agentId, sender, avatarType, content, time, confidence, streaming?, steps?, clarification? }`
- `DialogueStep { id, key, label, ts, args? }`(`key` 形如 `tool_calling:web_search`)
- `SeatView { agentId, name, avatarType, status: pending|running|done|error, bubbleId }`
- `ClarificationPayload { question, clarificationType, context, options, allowCustom, allowMultiple }`
- `AgentResult { agent_id, name, content, status }`(宿主读取用)
> 改协议时:`types.ts` 的 `StreamEvent` 与后端 `parallel_agents.py` 的事件 dict **必须同步**。
---
## 6. 组件树
```
ParallelAgentsPanel
├─ ParallelSeatStrip ── Avatar (席位条,点头像切换单显)
└─ MessageBubble (当前选中席位)
├─ Avatar
├─ MessageStepsCard ── StepIcon (步骤条;getStepDisplay/getStepArgs 翻译 key+args)
├─ MarkdownContent (markdown-it)
└─ ClarificationCard (msg.clarification 存在时;提交→panel.onClarify→rerunAgent)
```
`ParallelAgentsPanel` 的对外接口(详见 [07-使用说明.md](./07-使用说明.md)):
- props:`agents`(必填) / `username` / `baseUrl` / `token` / `getToken` / `taskId` / `model` / `dark`
- emits:`saved(SavedTask)` / `update:taskId(string)` / `completed(AgentResult[])`
- `defineExpose`:`getResults()` / `getResult(id)` / `start()` / `cancel()`
---
## 7. 鉴权
`api.ts` 的 token 解析优先级:`token` > `getToken()` > 缓存 > 用 `username` 取 token。
- **取 token**:`POST {baseUrl}{TOKEN_PATH}`(`TOKEN_PATH=/api/parallel-agents/auth/token`)—— 本面板
**专用的「绕过常规登录」公共端点**(内网部署限制了标准 `/login/username`)。传 `{username}` 直接签发。
- 取到的 token 按 `baseUrl::username` **缓存**;业务请求带 `Authorization: Bearer`(顺带绕过 CSRF)。
- `authedFetch` 在 **401** 时清缓存、用 username 重登并**重试一次**。
- 后端该端点在 `auth_middleware._PUBLIC_EXACT_PATHS` + `csrf_middleware._AUTH_EXEMPT_PATHS` 白名单里。
---
## 8. 对接的后端契约
后端实现:`offline-backend-20260512/backend/app/gateway/routers/parallel_agents.py`
(任务存储:`parallel_agent_tasks.py` + `deerflow/persistence/parallel_agent_tasks/`)。
| 用途 | 接口 |
| ---------- | -------------------------------------------------------------------- |
| 取 token | `POST /api/parallel-agents/auth/token` `{username}` |
| 并行流式 | `POST /api/parallel-agents/run/stream` `{task_name, brief, agent_ids[], model?}` → SSE |
| 任务 CRUD | `GET/POST/GET{id}/PUT{id}/DELETE{id} /api/parallel-agent-tasks` |
| 模型列表 | `GET /api/models` → `{models:[{name, display_name}]}` |
**后端运行策略(重要)**:每个并行席位是「执行席位」,不是总控。
- **禁用工具** `_EXCLUDED_TOOLS = [write_file, str_replace, agent_orchestration, bash]`:①禁写文件(并行
不互相打架);②禁 `agent_orchestration`/`bash`(防弱模型模仿总控**派活**或绕道脚本逃逸)。
- **保留** 只读 / 检索工具 + `ask_clarification` → 步骤条、协作卡片正常。
- 任务消息带**角色边界护栏**(「执行席位、不要模仿总控调度 / 派活」)。
- 步骤 `args` 通过累积 `tool_call_chunks` + 终态全量 `tool_calls` 补全(流式期间空 args 不发,防钉死)。
- `ask_clarification` 从终态 messages 抽成结构化 `agent_clarification` 事件。
---
## 9. 样式系统(嵌入安全)
`styles/parallel-agents.css`:
- **不引 Tailwind preflight**(`@import "tailwindcss/theme.css"` + `utilities.css`),避免重置宿主全局样式。
- 颜色全用作用域内的 `--pa-*` 深蓝 token(取自《深蓝 UI 规范》,与圆桌 Step2 深色一致),全部包在
根类 `.parallel-agents` 下,**不污染宿主**。
- 语义类 `.pa-card / .pa-surface / .pa-border / .pa-text(-muted/-primary) / .pa-input / .pa-btn-* /
.pa-badge / .pa-md(Markdown)` —— 组件**上色用这些类**,布局用 Tailwind 工具类。
- 面板**自带深色成色**,不随宿主明暗变化;根元素 `h-full w-full flex flex-col min-h-0` 自适应容器。
---
## 10. 步骤条 / 协作卡片(与圆桌的对应)
- **步骤条** `MessageStepsCard.vue` ← 圆桌 `MessageStepsCard.tsx` + `ChainOfThought`:图标列(带竖向连接线)
+ 中文文案 + 时间 + 参数子节点。文案/图标映射在 `lib/step-display.ts`(`getStepDisplay`),参数子节点
在 `getStepArgs`(chip / code / text / kv)。加新工具图标:在 `step-display.ts` 的 switch + `StepIcon.vue`
各加一条。
- **协作卡片** `ClarificationCard.vue` ← 圆桌第一步 `IntentClarificationCard.tsx`:标题/类型标签/背景/选项
chip(单选点击即发、多选可勾)/自定义输入/发送。提交回传格式化回答 → 面板 `onClarify` → `rerunAgent`
带「【用户补充】…」**重跑该单个智能体**。
---
## 11. 构建与产物
```bash
pnpm install
pnpm dev # dev demo(连本地后端,App.vue 里填 baseUrl/username/agents)
pnpm typecheck # vue-tsc --noEmit
pnpm build:lib # 组件库:dist/parallel-agents-vue.{js,umd.cjs,css}
```
- `vite.config.ts`:`--mode lib` 走 `build.lib`,`external:['vue']`,`output.exports:'named'`,CSS 不拆分。
- `package.json` `exports`:`.`→ESM/UMD + types;`./style.css`→样式。宿主 `import "parallel-agents-vue/style.css"`。
---
## 12. 常见扩展怎么改
| 需求 | 改哪里 |
| ---------------------------- | --------------------------------------------------------------------------------------- |
| 新增一个 SSE 事件类型 | `types.ts` 的 `StreamEvent` + `useParallelRun.handleEvent` + 后端 `parallel_agents.py` |
| 步骤条支持新工具的图标/文案 | `lib/step-display.ts`(`getStepDisplay`/`getStepArgs`)+ `StepIcon.vue` |
| 改深色配色 / 加浅色主题 | `styles/parallel-agents.css` 的 `--pa-*`(浅色可在 `.parallel-agents--light` 下覆盖) |
| 改默认后端地址 / 模型 | `config.ts`(`DEFAULT_BASE_URL` / `DEFAULT_MODEL`) |
| 多席位并排显示(取消单选) | `ParallelAgentsPanel.vue` 的 `selected` / `visibleSeats` / `toggleSeat` 逻辑 |
| 宿主自定义存储(不走后端) | 用 `@completed` + `getResults()` 取数据,自行存;不调用 `createTask/updateTask` |
| 改鉴权方式 | `api.ts` 的 `resolveToken` / `loginByUsername` / `authedFetch` + `config.ts` 的 `TOKEN_PATH` |
---
## 13. 注意事项 / 已知约束
- **agent_id 必须真实**(后端注册过的 id),只填名字不行;可用 id 见 [06-可用智能体清单.md](./06-可用智能体清单.md)。
- 并行席位**禁派活/脚本/写文件**:本面板里每个智能体只做自己那份分析,产出走正文。
- 协作卡片回答是**重跑单个智能体**(新线程 + 把回答拼进任务),不是 LangGraph 原地 resume。
- token 取自公共端点(无门槛旁路)—— 适合内网;若网关要暴露公网,建议给取 token 端点加共享密钥校验。
- 跨域:宿主与后端不同源时,后端需放开该来源 CORS + `credentials`。
+19 -2
View File
@@ -2,19 +2,29 @@
/** Dev demo 调试页:填后端地址 / token / 智能体 id,把面板塞进一个弹窗,验证嵌入与流式。 */
import { reactive, ref } from "vue";
import ParallelAgentsPanel from "./components/ParallelAgentsPanel.vue";
import type { AgentInput } from "./types";
import type { AgentInput, AgentResult } from "./types";
import { DEFAULT_BASE_URL } from "./config";
const cfg = reactive({
baseUrl: localStorage.getItem("pa.baseUrl") || DEFAULT_BASE_URL,
username: localStorage.getItem("pa.username") || "demo",
taskId: localStorage.getItem("pa.taskId") || "",
agentsRaw: localStorage.getItem("pa.agents") || "",
});
const open = ref(false);
const agents = ref<AgentInput[]>([]);
// 面板保存 / 生成 id 时回填,方便复制下来下次填进去测「按 taskId 回显」。
function onTaskId(id: string) {
cfg.taskId = id;
localStorage.setItem("pa.taskId", id);
}
function onCompleted(results: AgentResult[]) {
console.log("[demo] completed → 各智能体返回内容:", results);
}
function parseAgents(): AgentInput[] {
// 每行一个:`agent_id, 显示名` 或仅 `agent_id`。
return cfg.agentsRaw
@@ -30,6 +40,7 @@ function parseAgents(): AgentInput[] {
function launch() {
localStorage.setItem("pa.baseUrl", cfg.baseUrl);
localStorage.setItem("pa.username", cfg.username);
localStorage.setItem("pa.taskId", cfg.taskId);
localStorage.setItem("pa.agents", cfg.agentsRaw);
agents.value = parseAgents();
open.value = true;
@@ -46,8 +57,11 @@ function launch() {
<label>用户名(免密自动登录,由你自己传)
<input v-model="cfg.username" style="width: 100%; padding: 6px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px" />
</label>
<label>taskId(存储主键,留空则首次保存自动生成;填已存在的 id 会自动回显)
<input v-model="cfg.taskId" placeholder="例如 my-task-001,留空也行" style="width: 100%; padding: 6px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px" />
</label>
<label>智能体(每行一个:agent_id, 显示名)
<textarea v-model="cfg.agentsRaw" rows="4" placeholder="roundtable-intelligence, 情报收集&#10;roundtable-risk-review, 风险审查" style="width: 100%; padding: 6px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px" />
<textarea v-model="cfg.agentsRaw" rows="4" placeholder="roundtable-solution-design, 方案设计&#10;roundtable-risk-review, 风险审查" style="width: 100%; padding: 6px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px" />
</label>
<button @click="launch" style="align-self: flex-start; padding: 8px 16px; background: #1677ff; color: #fff; border: 0; border-radius: 6px; cursor: pointer">
打开面板(弹窗内)
@@ -64,7 +78,10 @@ function launch() {
<ParallelAgentsPanel
:base-url="cfg.baseUrl"
:username="cfg.username || undefined"
:task-id="cfg.taskId || undefined"
:agents="agents"
@update:taskId="onTaskId"
@completed="onCompleted"
/>
</div>
</div>
+4 -6
View File
@@ -6,7 +6,7 @@
* - `{baseUrl}/api/parallel-agent-tasks` —— 任务保存/回显 CRUD。
*/
import type { AgentInput, Dialogue, SavedTask, StreamEvent } from "./types";
import { USERNAME_LOGIN_PATH } from "./config";
import { TOKEN_PATH } from "./config";
export interface ApiConfig {
baseUrl: string;
@@ -30,12 +30,10 @@ function base(cfg: ApiConfig): string {
const _tokenCache = new Map<string, string>();
const cacheKey = (cfg: ApiConfig) => `${base(cfg)}::${cfg.username ?? ""}`;
/** 用 username 走公共「用户名登录」端点换 access_token(CSRF 豁免)。 */
/** 用 username 走本面板**专用**的「绕过常规登录」公共端点换 access_token(CSRF 豁免)。 */
async function loginByUsername(cfg: ApiConfig): Promise<string> {
if (!cfg.username) throw new Error("缺少 token 且未提供 username,无法登录");
let url = `${base(cfg)}${USERNAME_LOGIN_PATH}`;
if (cfg.loginPassword) url += `?password=${encodeURIComponent(cfg.loginPassword)}`;
const res = await fetch(url, {
const res = await fetch(`${base(cfg)}${TOKEN_PATH}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: cfg.username }),
@@ -43,7 +41,7 @@ async function loginByUsername(cfg: ApiConfig): Promise<string> {
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`username login failed (${res.status}): ${text}`);
throw new Error(`get token failed (${res.status}): ${text}`);
}
const data = (await res.json()) as { access_token: string; token_type?: string };
const token = data.access_token;
@@ -0,0 +1,152 @@
<script setup lang="ts">
/**
* 协作 / 澄清卡片 —— 对齐圆桌第一步的 IntentClarificationCard 样式与交互:
* 「需要你的确认」标题 + 类型标签 + 背景说明 + 选项 chip(单选点击即发 / 多选可勾)+
* 自定义输入 + 发送按钮。提交时回传格式化好的回答文本。
*/
import { computed, reactive } from "vue";
import type { ClarificationPayload, ClarOption } from "@/types";
const props = defineProps<{ payload: ClarificationPayload; submitting?: boolean }>();
const emit = defineEmits<{ (e: "submit", answer: string): void }>();
interface NormOption {
id: string;
label: string;
description?: string;
}
function norm(opts: ClarOption[]): NormOption[] {
return (opts || []).map((o, i) =>
typeof o === "string"
? { id: o, label: o }
: { id: o.id || o.label || String(i), label: o.label || o.id || "", description: o.description },
);
}
const options = computed(() => norm(props.payload.options));
const allowMultiple = computed(() => !!props.payload.allowMultiple);
const allowCustom = computed(() => props.payload.allowCustom !== false);
const deferSubmit = computed(() => allowCustom.value); // 允许自定义时不在单击即发,给机会补充
const showSubmit = computed(() => allowMultiple.value || allowCustom.value || options.value.length === 0);
const sel = reactive({ ids: [] as string[], custom: "" });
function labelOf(id: string): string {
return options.value.find((o) => o.id === id)?.label || id;
}
function buildAnswer(ids: string[]): string | null {
const labels = ids.map(labelOf);
const custom = sel.custom.trim();
if (labels.length && custom) return `我选择:${labels.join("、")};补充:${custom}`;
if (labels.length) return `我选择:${labels.join("、")}`;
if (custom) return custom;
return null;
}
function clickOption(id: string) {
if (props.submitting) return;
if (!allowMultiple.value) {
if (deferSubmit.value) {
sel.ids = [id];
return;
}
emit("submit", buildAnswer([id]) ?? labelOf(id));
return;
}
sel.ids = sel.ids.includes(id) ? sel.ids.filter((x) => x !== id) : [...sel.ids, id];
}
const canSubmit = computed(() => sel.ids.length > 0 || sel.custom.trim().length > 0);
function submit() {
if (props.submitting || !canSubmit.value) return;
const ans = buildAnswer(sel.ids);
if (ans) emit("submit", ans);
}
</script>
<template>
<div class="pa-card pa-border mt-2 rounded-xl border p-3 shadow-sm">
<!-- 头部 -->
<div class="space-y-1.5">
<div class="flex items-center gap-2">
<span class="text-[10px] font-bold uppercase tracking-wide pa-text-primary">需要你的确认</span>
<span
v-if="payload.clarificationType"
class="pa-badge rounded-full"
style="text-transform: none"
>
{{ payload.clarificationType }}
</span>
</div>
<p v-if="payload.context" class="text-[11px] italic leading-relaxed pa-text-muted">
{{ payload.context }}
</p>
<div class="text-xs font-medium leading-relaxed pa-text">{{ payload.question }}</div>
</div>
<!-- 选项 -->
<div v-if="options.length" class="mt-3 grid gap-2">
<div class="text-[10px] pa-text-muted">
{{ allowMultiple ? "可多选(再次点击取消选择)" : "请选择一项(点击即发送)" }}
</div>
<button
v-for="opt in options"
:key="opt.id"
type="button"
:disabled="submitting"
class="flex w-full items-start justify-between gap-2 rounded-xl border px-3 py-2 text-left transition"
:class="
sel.ids.includes(opt.id)
? 'pa-opt-selected'
: 'pa-border pa-surface-soft pa-text hover:opacity-90'
"
@click="clickOption(opt.id)"
>
<span class="flex min-w-0 flex-1 flex-col items-start gap-1">
<span class="text-[11px] font-bold">{{ opt.label }}</span>
<span v-if="opt.description" class="text-[10px] leading-snug" :class="sel.ids.includes(opt.id) ? '' : 'pa-text-muted'">
{{ opt.description }}
</span>
</span>
<span v-if="sel.ids.includes(opt.id)" class="mt-0.5 shrink-0">✓</span>
</button>
</div>
<!-- 自定义输入 -->
<div v-if="allowCustom" class="mt-3 flex flex-col gap-2">
<textarea
v-model="sel.custom"
:disabled="submitting"
rows="2"
class="pa-input min-h-16 resize-none px-2.5 py-1.5 text-xs"
:placeholder="
options.length
? allowMultiple
? '可选:补充说明(与所选项一起发送),或填写自定义回答'
: '如果以上选项都不合适,可在这里输入自定义回答'
: '请输入你的回答'
"
/>
</div>
<!-- 发送 -->
<div v-if="showSubmit" class="mt-3 flex justify-end">
<button
type="button"
class="pa-btn-primary h-8 rounded-xl px-4 text-xs font-bold"
:disabled="submitting || !canSubmit"
@click="submit"
>
{{ submitting ? "发送中…" : "发送回答" }}
</button>
</div>
</div>
</template>
<style scoped>
.pa-opt-selected {
background-color: var(--pa-primary);
color: #fff;
border: 1px solid var(--pa-primary);
}
</style>
@@ -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);
</script>
@@ -45,6 +47,14 @@ const hasContent = computed(() => (props.msg.content ?? "").trim().length > 0);
<MarkdownContent v-if="hasContent" :source="msg.content" />
<span v-if="msg.streaming && hasContent" class="pa-cursor" />
</div>
<!-- 协作 / 澄清卡片(对齐圆桌第一步) -->
<ClarificationCard
v-if="msg.clarification"
:payload="msg.clarification"
:submitting="submitting"
@submit="(answer) => emit('clarify', msg.agentId, answer)"
/>
</div>
</div>
</template>
@@ -1,52 +1,95 @@
<script setup lang="ts">
/** 步骤条 / 思维链卡:折叠展示该气泡运行期间的工具调用步骤(对齐 Step2 MessageStepsCard)。 */
import { ref } from "vue";
/**
* 步骤条 / 思维链卡 —— 1:1 复刻圆桌 Step2 的 MessageStepsCard + ChainOfThought:
* slate 边框卡 + 每步「图标列(带竖向连接线) + 中文文案 + 时间 + 参数子节点」。
*/
import { computed, ref } from "vue";
import type { DialogueStep } from "@/types";
import { getStepArgs, getStepDisplay } from "@/lib/step-display";
import StepIcon from "./StepIcon.vue";
defineProps<{ steps: DialogueStep[]; streaming?: boolean }>();
const props = defineProps<{ steps: DialogueStep[]; streaming?: boolean }>();
const open = ref(true);
function argSummary(args?: Record<string, unknown> | null): string {
if (!args) return "";
const path = (args as { path?: unknown }).path;
if (typeof path === "string") return path;
try {
const s = JSON.stringify(args);
return s.length > 60 ? s.slice(0, 60) + "…" : s;
} catch {
return "";
}
}
const rows = computed(() =>
props.steps.map((s, i) => {
const display = getStepDisplay(s.key, s.label, s.args);
return {
step: s,
display,
args: getStepArgs(s.key, s.args),
// 最后一步在 streaming 时为「进行中」(前景色),其余为已完成(次级色)。
active: props.streaming && i === props.steps.length - 1,
isLast: i === props.steps.length - 1,
};
}),
);
</script>
<template>
<div class="pa-surface pa-border mb-2 rounded-lg border p-0.5">
<div class="pa-surface pa-border mb-2 w-full gap-1 rounded-lg border p-0.5">
<button
type="button"
class="flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left text-[11px] font-semibold pa-text-muted"
@click="open = !open"
>
<span
class="inline-block transition-transform"
:class="open ? 'rotate-90' : ''"
>▶</span
>
<span class="inline-block transition-transform" :class="open ? 'rotate-90' : ''">▶</span>
<span>执行步骤({{ steps.length }})</span>
<span v-if="streaming" class="pa-spin inline-block">◜</span>
</button>
<ol v-show="open" class="space-y-0.5 px-2 pb-1.5 pt-0.5">
<li
v-for="s in steps"
:key="s.id"
class="flex items-start gap-2 text-[11px] pa-text"
<div v-show="open" class="space-y-2 px-3 pb-2 pt-0.5">
<div
v-for="row in rows"
:key="row.step.id"
class="flex gap-2 text-sm"
:class="row.active ? 'pa-text' : 'pa-text-muted'"
>
<span class="mt-1 inline-block size-1.5 shrink-0 rounded-full" style="background: var(--pa-primary)" />
<span class="min-w-0">
<span class="font-semibold">{{ s.label }}</span>
<span v-if="argSummary(s.args)" class="pa-text-muted"> · {{ argSummary(s.args) }}</span>
<span class="pa-text-muted"> · {{ s.ts }}</span>
</span>
</li>
</ol>
<!-- 图标列 + 竖向连接线 -->
<div class="relative mt-0.5">
<StepIcon :name="row.display.icon" :size="16" />
<div
v-if="!row.isLast"
class="absolute left-1/2 -mx-px w-px"
style="top: 26px; bottom: -8px; background: var(--pa-border)"
/>
</div>
<!-- 文案 + 时间 + 参数 -->
<div class="min-w-0 flex-1 space-y-1 overflow-hidden">
<div class="leading-snug">
<span class="font-medium pa-text">{{ row.display.label }}</span>
<span class="ml-2 font-mono text-[11px] pa-text-muted">{{ row.step.ts }}</span>
</div>
<!-- 参数子节点(按 kind 渲染,对齐 renderStepArgs) -->
<template v-if="row.args">
<span
v-if="row.args.kind === 'chip'"
class="pa-surface pa-border inline-block max-w-full truncate rounded border px-1.5 py-0.5 text-[11px] pa-text-muted"
>
{{ row.args.text }}
</span>
<pre
v-else-if="row.args.kind === 'code'"
class="pa-md max-h-60 overflow-auto rounded-md p-2 text-[11px]"
><code>{{ row.args.text }}</code></pre>
<span
v-else-if="row.args.kind === 'text'"
class="line-clamp-2 block text-[11px] leading-snug pa-text-muted"
>
{{ row.args.text }}
</span>
<span v-else-if="row.args.kind === 'kv'" class="flex flex-wrap gap-1">
<span
v-for="p in row.args.pairs"
:key="p.k"
class="pa-surface pa-border inline-block rounded border px-1.5 py-0.5 text-[11px] pa-text-muted"
>
<span class="opacity-60">{{ p.k }}:</span> {{ p.v }}
</span>
</span>
</template>
</div>
</div>
</div>
</div>
</template>
@@ -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<ApiConfig>(() => ({
@@ -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 用:`<ParallelAgentsPanel ref="panelRef" />` → 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(() => {
<MessageBubble
v-if="dialogueById.get(seat.bubbleId)"
:msg="dialogueById.get(seat.bubbleId)!"
:submitting="running"
@clarify="onClarify"
/>
<div v-else class="py-6 text-center text-xs pa-text-muted">
{{ seat.name }} · 等待输出…
@@ -0,0 +1,56 @@
<script setup lang="ts">
import type { IconKey } from "@/lib/step-display";
defineProps<{ name: IconKey; size?: number }>();
</script>
<template>
<svg
:width="size || 16"
:height="size || 16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<template v-if="name === 'search'">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
</template>
<template v-else-if="name === 'globe'">
<circle cx="12" cy="12" r="10" /><path d="M2 12h20" />
<path d="M12 2a15 15 0 0 1 4 10 15 15 0 0 1-4 10 15 15 0 0 1-4-10 15 15 0 0 1 4-10Z" />
</template>
<template v-else-if="name === 'folder'">
<path
d="M6 14l1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.55 6A2 2 0 0 1 18.45 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"
/>
</template>
<template v-else-if="name === 'book'">
<path d="M12 7v14" /><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" />
</template>
<template v-else-if="name === 'pen'">
<path d="M12 20h9" /><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
</template>
<template v-else-if="name === 'terminal'">
<rect x="3" y="4" width="18" height="16" rx="2" /><path d="m7 11 2-2-2-2" /><path d="M11 13h4" />
</template>
<template v-else-if="name === 'todo'">
<rect x="3" y="5" width="6" height="6" rx="1" /><path d="m3 17 2 2 4-4" />
<path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" />
</template>
<template v-else-if="name === 'question'">
<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" /><path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3" /><path d="M12 17h.01" />
</template>
<template v-else-if="name === 'lightbulb'">
<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5" />
<path d="M9 18h6" /><path d="M10 22h4" />
</template>
<template v-else-if="name === 'dot'">
<circle cx="12" cy="12" r="2" fill="currentColor" stroke="none" />
</template>
<template v-else>
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</template>
</svg>
</template>
@@ -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<void> {
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 };
}
+5 -2
View File
@@ -12,5 +12,8 @@ export const DEFAULT_BASE_URL: string =
export const DEFAULT_MODEL: string | undefined =
(import.meta as unknown as { env?: Record<string, string> }).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";
+1 -1
View File
@@ -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) {
+120
View File
@@ -0,0 +1,120 @@
/**
* 步骤条渲染辅助(Vue 端,1:1 对齐圆桌 `roundtable-planning/lib/step-display.tsx`)。
* 把 step.key(`tool_calling:<toolName>`)+ 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:<toolName>` + args → 图标 + 中文文案(对齐主聊天 toolCalls 文案)。 */
export function getStepDisplay(key: string, fallbackLabel: string, args?: Record<string, unknown> | 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<string, unknown> | 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;
}
}
}
+36 -1
View File
@@ -5,7 +5,7 @@
* 归到对应席位列)。`StreamEvent` 是后端 `/api/parallel-agents/run/stream` 的统一事件协议。
*/
/** 一条工具调用步骤(喂步骤条 / 思维链卡)。 */
/** 一条工具调用步骤(喂步骤条 / 思维链卡)。`key` 形如 `tool_calling:<toolName>`。 */
export interface DialogueStep {
id: string;
key: string;
@@ -14,6 +14,19 @@ export interface DialogueStep {
args?: Record<string, unknown> | 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<string, unknown> | 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;