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:
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) | **二次开发**:架构/数据流/事件协议/目录职责/后端契约/扩展指南 |
|
||||
|
||||
## 测试环境
|
||||
|
||||
|
||||
@@ -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 / 配置 / 后端回退。
|
||||
|
||||
@@ -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`。
|
||||
@@ -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, 情报收集 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, 方案设计 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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user