代码提交
This commit is contained in:
1 parent
57e286b8ad
commit
559340143e
72 files changed
-9686
No files matched your search
@@ -1,2 +0,0 @@
|
|||||||
node-linker=hoisted
|
|
||||||
package-import-method=copy
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# 内网离线开发包说明
|
|
||||||
|
|
||||||
本压缩包已包含 **完整 hoisted 模式 node_modules**(物理文件复制,无 pnpm 符号链接),解压后可直接开发,无需联网安装依赖。
|
|
||||||
|
|
||||||
## 环境要求
|
|
||||||
|
|
||||||
- Node.js 18+(建议 20 LTS)
|
|
||||||
- 全局安装 pnpm:`npm install -g pnpm`(内网可离线安装 pnpm 的 tgz 包)
|
|
||||||
|
|
||||||
## 使用步骤
|
|
||||||
|
|
||||||
1. 解压到任意目录(路径尽量短,避免 Windows 路径过长问题)
|
|
||||||
2. 进入项目目录
|
|
||||||
3. 直接运行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm dev # 开发调试 http://localhost:5183
|
|
||||||
pnpm build:lib # 构建组件库
|
|
||||||
pnpm typecheck # 类型检查
|
|
||||||
```
|
|
||||||
|
|
||||||
## 若 node_modules 损坏,可离线重装
|
|
||||||
|
|
||||||
本包已附带 `.npmrc`(hoisted + copy 模式)。若需重建依赖:
|
|
||||||
|
|
||||||
1. 删除 `node_modules`
|
|
||||||
2. 在有 pnpm 本地 store 的机器上执行:`pnpm install --offline --frozen-lockfile`
|
|
||||||
|
|
||||||
> 若内网机器从未装过依赖,请直接使用本包自带的 node_modules,不要删除。
|
|
||||||
|
|
||||||
## 与直接压缩 node_modules 的区别
|
|
||||||
|
|
||||||
pnpm 默认 node_modules 含大量符号链接/硬链接,WinRAR 等工具压缩后解压会断链导致缺依赖。
|
|
||||||
本包使用 `node-linker=hoisted` + `package-import-method=copy` 重新安装,所有文件均为真实拷贝。
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# parallel-agents-vue
|
|
||||||
|
|
||||||
并行多智能体面板 —— Vue3 可嵌入组件。输入「任务名称 + 简介」,**并行调用多个智能体**
|
|
||||||
(智能体清单由宿主传入,运行期间后端固定**禁止写文件**),消息列表照搬 DeerFlow 圆桌
|
|
||||||
研讨第二步并行多席位的**深色样式与完整结构**(步骤条 / 并行双列 / 席位状态条),支持
|
|
||||||
**保存聊天记录 + 回显**与**重新分析**。
|
|
||||||
|
|
||||||
## 开发调试
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm install
|
|
||||||
pnpm dev # http://localhost:5183 ,填后端 baseUrl / token / 智能体 id 即可联调
|
|
||||||
```
|
|
||||||
|
|
||||||
后端需要一个跑着的 DeerFlow 网关(默认 `http://localhost:8001`),它提供:
|
|
||||||
|
|
||||||
- `POST /api/parallel-agents/run/stream` —— 并行扇出,合并 SSE(事件带 `agent_id`);
|
|
||||||
- `/api/parallel-agent-tasks` —— 任务保存 / 回显 CRUD。
|
|
||||||
|
|
||||||
## 构建为组件库
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm build:lib # 产出 dist/parallel-agents-vue.{js,umd.cjs} + dist/parallel-agents-vue.css
|
|
||||||
```
|
|
||||||
|
|
||||||
## 嵌入其它 Vue 项目
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { ParallelAgentsPanel } from "parallel-agents-vue";
|
|
||||||
import "parallel-agents-vue/style.css";
|
|
||||||
```
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<!-- 放进你自己的抽屉 / 弹窗里,面板会自适应容器(h-full w-full)。
|
|
||||||
接口地址走配置文件默认(src/config.ts),登录只需把用户名传进来(免密自动登录)。 -->
|
|
||||||
<YourDrawer v-model="open">
|
|
||||||
<div style="height: 80vh">
|
|
||||||
<ParallelAgentsPanel
|
|
||||||
:username="currentUsername"
|
|
||||||
:agents="[
|
|
||||||
{ agent_id: 'roundtable-intelligence', name: '情报收集' },
|
|
||||||
{ agent_id: 'roundtable-risk-review', name: '风险审查' },
|
|
||||||
]"
|
|
||||||
@saved="onSaved"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</YourDrawer>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 接口地址 & 登录
|
|
||||||
|
|
||||||
- **接口地址**写在配置文件 `src/config.ts` 的 `DEFAULT_BASE_URL`(dev 可用 `VITE_PA_BASE_URL` 覆盖);
|
|
||||||
也可用 `:base-url` prop 覆盖。
|
|
||||||
- **登录**:把宿主侧的**用户名**通过 `:username` 传进来即可,组件会自动走 DeerFlow 的
|
|
||||||
**免密「用户名登录」**端点(`POST /api/v1/auth/login/username`,公共 + CSRF 豁免)换取 token
|
|
||||||
并缓存、自动续期(401 重登)。**不需要宿主自己管 token**。
|
|
||||||
- 若你已有 token,也可直接传 `:token` / `:get-token`(优先级高于 username)。
|
|
||||||
- 若后端开了口令门(`auth_login.username_login.require_password=true`),再传 `:login-password`。
|
|
||||||
|
|
||||||
### Props
|
|
||||||
|
|
||||||
| prop | 类型 | 说明 |
|
|
||||||
| --------------- | -------------------------------------- | ----------------------------------------------------- |
|
|
||||||
| `agents` | `{agent_id,name,avatarType?}[]`(必填)| 要并行调用的智能体 |
|
|
||||||
| `username` | `string` | 免密自动登录用户名(推荐方式,宿主自己传) |
|
|
||||||
| `baseUrl` | `string` | 后端地址;不传用 `config.ts` 默认 |
|
|
||||||
| `token` | `string` | 已有 Bearer token(与 `username` 二选一,优先) |
|
|
||||||
| `getToken` | `() => string \| Promise<string>` | 动态取 token |
|
|
||||||
| `loginPassword` | `string` | 仅当后端开启口令门时需要 |
|
|
||||||
| `taskId` | `string` | 传入则挂载时自动加载并回显该已保存任务 |
|
|
||||||
| `model` | `string` | 可选模型名(不传用 `config.ts` 默认 / 后端回退) |
|
|
||||||
| `dark` | `boolean`(默认 `true`) | 深色面板(自带成色,不依赖宿主主题) |
|
|
||||||
|
|
||||||
### 事件
|
|
||||||
|
|
||||||
- `@saved="(task) => ..."` —— 保存成功(含新建/更新)后回传 `SavedTask`。
|
|
||||||
- `@update:taskId="(id) => ..."` —— 当前任务 id 变化(保存 / 加载历史时)。
|
|
||||||
|
|
||||||
> 样式作用域全部包在根类 `.parallel-agents` 下,且**不引入 Tailwind preflight**,不会污染宿主全局样式。
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# 并行多智能体面板 · 测试用例总览
|
|
||||||
|
|
||||||
本目录是 `parallel-agents-vue` 的**手工测试用例**(Markdown)。覆盖:并行调用与流式、文件写入禁用、
|
|
||||||
保存与回显、重新分析、免密登录与鉴权、嵌入与深色样式。
|
|
||||||
|
|
||||||
## 用例清单
|
|
||||||
|
|
||||||
| 文件 | 主题 |
|
|
||||||
| -------------------------------------------- | ------------------------------------- |
|
|
||||||
| [01-并行调用与流式.md](./01-并行调用与流式.md) | 并行扇出、流式输出、步骤条、席位状态条、禁写文件 |
|
|
||||||
| [02-保存与回显.md](./02-保存与回显.md) | 保存聊天记录、历史列表、加载回显、更新保存 |
|
|
||||||
| [03-重新分析.md](./03-重新分析.md) | 重新分析(覆盖重跑)、停止、空输入校验 |
|
|
||||||
| [04-免密登录与鉴权.md](./04-免密登录与鉴权.md) | username 免密登录、token 优先级、401 重登、口令门 |
|
|
||||||
| [05-嵌入与深色样式.md](./05-嵌入与深色样式.md) | 配置文件接口地址、抽屉/弹窗嵌入、深色自适应、不污染宿主 |
|
|
||||||
| [06-可用智能体清单.md](./06-可用智能体清单.md) | **测试用真实 agent_id 清单(可直接复制粘贴)** + 示例任务 |
|
|
||||||
| [07-使用说明.md](./07-使用说明.md) | **在其它 Vue 项目中引入哪些组件、如何使用**(props/事件/嵌入) |
|
|
||||||
| [08-开发文档.md](./08-开发文档.md) | **二次开发**:架构/数据流/事件协议/目录职责/后端契约/扩展指南 |
|
|
||||||
|
|
||||||
## 测试环境
|
|
||||||
|
|
||||||
| 组件 | 地址 / 命令 |
|
|
||||||
| --------------- | ------------------------------------------------------ |
|
|
||||||
| DeerFlow 后端 | `http://localhost:8001`(`make dev`,需含本功能的新路由)|
|
|
||||||
| Vue dev demo | `http://localhost:5183`(`pnpm dev`) |
|
|
||||||
| 接口地址配置 | `src/config.ts` 的 `DEFAULT_BASE_URL` |
|
|
||||||
|
|
||||||
## 前置准备(所有用例通用)
|
|
||||||
|
|
||||||
1. 后端已启动,且包含本功能:
|
|
||||||
- `POST /api/parallel-agents/run/stream`(并行扇出,合并 SSE)
|
|
||||||
- `GET/POST/PUT/DELETE /api/parallel-agent-tasks`(任务保存/回显)
|
|
||||||
- `POST /api/v1/auth/login/username`(免密登录,公共)
|
|
||||||
- 数据表 `parallel_agent_tasks` 已建(`alembic upgrade head` 或 `create_all`)。
|
|
||||||
2. 至少准备 **2 个真实存在的 agent_id**(必须是后端注册过的真实 id,不能只写名字)。
|
|
||||||
本环境可直接用:`roundtable-solution-design`(方案设计)、`roundtable-risk-review`(风险审查)等
|
|
||||||
—— 完整可复制清单见 [06-可用智能体清单.md](./06-可用智能体清单.md)。
|
|
||||||
3. 打开 dev demo `http://localhost:5183`,填:
|
|
||||||
- 后端 baseUrl:`http://localhost:8001`
|
|
||||||
- 用户名:`demo`(免密自动登录)
|
|
||||||
- 智能体:每行 `agent_id, 显示名`
|
|
||||||
点「打开面板(弹窗内)」。
|
|
||||||
|
|
||||||
## 用例字段约定
|
|
||||||
|
|
||||||
- **用例ID**:`PA-<模块>-<序号>`
|
|
||||||
- **优先级**:P0(核心,必过)/ P1(重要)/ P2(边界)
|
|
||||||
- **前置条件 → 操作步骤 → 预期结果**:逐条核对,全部命中才算通过。
|
|
||||||
- **结果记录**:建议在「实际结果 / 通过?」列里回填。
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
# 测试用例 · 并行调用与流式
|
|
||||||
|
|
||||||
覆盖:并行扇出多个智能体、流式逐字输出、步骤条(工具调用)、点头像切换显示(单选)、席位状态条、
|
|
||||||
运行期间**禁止写文件**。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-RUN-01 · 并行调用多个智能体(P0)
|
|
||||||
|
|
||||||
**前置条件**
|
|
||||||
- 面板已打开,填入任务名「竞品调研」、简介「对比 A、B 两款产品的核心功能与定价」。
|
|
||||||
- 传入 2 个智能体(情报收集、风险审查)。
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 点击「开始分析」。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 顶部出现**席位状态条**,显示「并行 2 席」,两个席位头像角标先后转为「进行中」(转圈)。
|
|
||||||
- 两个智能体**同时**开始执行(并行,而非一个跑完再跑下一个)——可在状态条上看到都在「进行中」。
|
|
||||||
- 会话区每次显示**一个**席位的消息列表(默认第 1 个),点头像可切换查看另一个(见 PA-RUN-06)。
|
|
||||||
- 气泡头部显示:头像、智能体名、状态徽标「并行执行中」、时间。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-RUN-02 · 流式逐字输出 + 光标(P0)
|
|
||||||
|
|
||||||
**前置条件**:PA-RUN-01 正在运行中。
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 观察当前显示席位的气泡正文(点头像可切换看其它席位)。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 正文**逐步增量**出现(流式),不是一次性整段刷出。
|
|
||||||
- 正在输出的气泡末尾有**闪烁光标**(`.pa-cursor`)。
|
|
||||||
- 内容还没来时,显示 loading 占位(转圈 + 跳动圆点 + 「并行执行中」文案)。
|
|
||||||
- Markdown 能正确渲染(标题/列表/代码块/表格按 `.pa-md` 样式)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-RUN-03 · 步骤条(思维链 / 工具调用)(P1)
|
|
||||||
|
|
||||||
**前置条件**:智能体会调用工具(如检索类技能)。
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 运行一轮,观察气泡顶部的「执行步骤」折叠卡。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 出现「执行步骤(N)」折叠卡(`MessageStepsCard`),点击可展开/收起。
|
|
||||||
- 每条步骤显示:工具标签(如「调用工具 xxx」)、参数摘要(如有 `path` 优先显示)、时间。
|
|
||||||
- 同一工具调用**不重复**出条目(按 key 去重)。
|
|
||||||
- 若智能体本轮不调工具,则不出现步骤卡(属正常)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-RUN-04 · 席位状态流转(P1)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 运行一轮,全程观察席位状态条各头像角标。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 角标状态依次:灰(pending)→ 蓝色转圈(running)→ 绿色对勾(done)。
|
|
||||||
- 某智能体失败时其角标变**红色叉**(error),且对应气泡显示「该智能体执行出错:…」、状态徽标「出错」。
|
|
||||||
- 单个智能体失败**不影响**其它智能体继续完成(互不拖累)。
|
|
||||||
- 全部结束后,运行按钮从「停止」恢复为「重新分析」。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-RUN-05 · 运行期间禁止写文件(P0)
|
|
||||||
|
|
||||||
**目的**:核对后端 `seat_parallel` 策略生效——并行席位被禁用 `write_file` / `str_replace` / `bash` 等。
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 简介里**故意要求写文件**,例如:「请把结果保存成一个 .md 文件」。
|
|
||||||
2. 点击「开始分析」,等待完成。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 智能体**不会**真正落地文件,产出**直接呈现在气泡正文**里。
|
|
||||||
- 步骤条里**不出现** `write_file` / `bash` / `str_replace` 工具调用(被策略排除)。
|
|
||||||
- 可旁证:后端日志中该席位 `role=seat_parallel`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-RUN-06 · 点头像切换显示(单选)(P1)
|
|
||||||
|
|
||||||
**前置条件**:传入 ≥2 个智能体并运行。
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 点击席位状态条上的某个头像。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 主区**切换为只显示**该智能体的消息列表(**单选**,每次显示一个)。
|
|
||||||
- 被选中的头像高亮(外发光描边);再点别的头像即切到那个。
|
|
||||||
- 切换不影响后台并行执行:未显示的智能体仍在跑/已完成,其增量在切回时能看到。
|
|
||||||
- 默认显示第 1 个席位。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-RUN-07 · 后端不可达 / 智能体 id 非法(P2)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 把 baseUrl 改成一个打不通的地址,或传入不存在的 agent_id,点「开始分析」。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 顶部出现红色错误提示(请求失败信息),不白屏、不卡死。
|
|
||||||
- 非法 agent_id 时,对应席位以 error 角标 + 出错气泡呈现,其它正常席位不受影响。
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
# 测试用例 · 保存与回显
|
|
||||||
|
|
||||||
覆盖:保存聊天记录(新建/更新)、历史列表、加载已存任务并回显、`taskId` 自动加载、跨刷新持久化。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-SAVE-01 · 首次保存(新建)(P0)
|
|
||||||
|
|
||||||
**前置条件**:已跑完一轮、会话区有结果。任务名「竞品调研」。
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 点击「保存」。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 按钮短暂显示「保存中…」,成功后变为「更新保存」(说明已绑定 taskId)。
|
|
||||||
- 触发 `@saved` 事件,回传 `SavedTask`(含 id、title、agents、messages)。
|
|
||||||
- 「历史」下拉里出现这条记录(标题 + 更新时间)。
|
|
||||||
- 后端 `parallel_agent_tasks` 表新增一行(owner = 当前登录用户)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-SAVE-02 · 再次保存(更新覆盖)(P0)
|
|
||||||
|
|
||||||
**前置条件**:PA-SAVE-01 已保存、当前仍是同一任务。
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 修改任务名 / 简介,或重新分析得到新结果。
|
|
||||||
2. 点击「更新保存」。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- **不新增**记录,而是更新原 taskId 那条(title / brief / agents / messages 被覆盖为最新)。
|
|
||||||
- 历史列表里该条的更新时间刷新、排到最前。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-SAVE-03 · 历史加载并回显(P0)
|
|
||||||
|
|
||||||
**前置条件**:历史里至少有 1 条已保存任务。
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 点击「历史 ▾」展开列表。
|
|
||||||
2. 选择一条历史任务。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 任务名、简介**回填**为该任务保存时的值。
|
|
||||||
- 会话区**完整回显**保存时的消息列表(各智能体气泡、正文、步骤条),且**均为非流式终态**(无光标、无 loading)。
|
|
||||||
- 席位状态条据消息重建:正常席位显示 done(绿勾),出错的显示 error(红叉)。
|
|
||||||
- 当前 taskId 切换为该任务(再点「保存」是「更新保存」该条)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-SAVE-04 · taskId 进入即自动回显(P1)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 以 `:task-id="<已存在任务id>"` 方式挂载面板(嵌入场景)。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 面板挂载后**自动加载**该任务并回显(等同于 PA-SAVE-03 的结果),无需手动点历史。
|
|
||||||
- `@update:taskId` 随之触发。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-SAVE-05 · 跨刷新持久化(P1)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 保存一条任务后,刷新浏览器(或重开宿主页面)。
|
|
||||||
2. 用相同用户名进入,打开历史。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 历史里仍能看到之前保存的任务(数据存在后端、按用户隔离,非浏览器 localStorage)。
|
|
||||||
- 加载后能正常回显。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-SAVE-06 · 无结果时禁止保存(P2)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 新开面板、尚未运行任何分析。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 「保存」按钮**禁用**(无可保存内容)。
|
|
||||||
- 运行中也不应因保存导致状态错乱(保存按钮在有结果后才可用)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-SAVE-07 · 用户隔离(P1)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 用户名 A 保存一条任务。
|
|
||||||
2. 切换用户名为 B(重新进入面板),打开历史。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- B 的历史里**看不到** A 的任务。
|
|
||||||
- B 直接用 A 的 taskId 加载应失败(404 / 无内容),不报错崩溃。
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
# 测试用例 · 重新分析与运行控制
|
|
||||||
|
|
||||||
覆盖:重新分析(覆盖重跑)、运行中停止、空输入校验、运行态按钮切换。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-RE-01 · 重新分析(覆盖重跑)(P0)
|
|
||||||
|
|
||||||
**前置条件**:已跑完一轮、会话区有结果(按钮显示「重新分析」)。
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. (可选)修改任务名 / 简介。
|
|
||||||
2. 点击「重新分析」。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- **清空**上一轮的所有气泡与席位状态,按当前输入**重新并行**调用各智能体。
|
|
||||||
- 不会和上一轮结果混在一起(旧消息被替换,不是追加)。
|
|
||||||
- 重跑过程与首跑一致(流式、单列消息 + 点头像切换、步骤条、席位状态流转)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-RE-02 · 运行中按钮为「停止」(P1)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 开始分析,运行未结束时观察操作区。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 运行期间,主按钮显示「停止」(不再是「开始/重新分析」)。
|
|
||||||
- 「保存」按钮在运行中可点(保存当前已产出的内容)或按实现禁用——以不报错为准。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-RE-03 · 停止当前运行(P0)
|
|
||||||
|
|
||||||
**前置条件**:正在运行中。
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 点击「停止」。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 进行中的请求被取消(AbortController),不再有新增量进来。
|
|
||||||
- 仍在 streaming 的气泡被收尾为终态(去掉光标,徽标置「已完成」)。
|
|
||||||
- 按钮恢复为「重新分析」,可立即再次发起。
|
|
||||||
- 控制台不应出现未捕获的 AbortError 报错(已被吞掉)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-RE-04 · 空输入校验(P1)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 任务名与简介**都留空**,点击「开始分析」。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 按钮处于**禁用**态,点击无效(`canStart` 为假需任务名或简介至少其一 + 至少一个智能体)。
|
|
||||||
- 只填任务名或只填简介,按钮可用。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-RE-05 · 未传智能体(P2)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. `:agents="[]"`(空数组)挂载面板。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 操作区显示提示「⚠️ 未传入任何智能体(agents 为空)」。
|
|
||||||
- 「开始分析」禁用,不会发起空请求。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-RE-06 · 连续多次重新分析(P2)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 连续点击「重新分析」若干次(每次等上一轮结束或中途停止再点)。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 每次都干净重置 + 重跑,无残留旧气泡、无席位状态错乱。
|
|
||||||
- 不出现多份 SSE 叠加导致的内容串列 / 重复。
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
# 测试用例 · 免密登录与鉴权
|
|
||||||
|
|
||||||
覆盖:username 免密自动登录、token 优先级、token 缓存与 401 重登、口令门、CSRF 绕过。
|
|
||||||
|
|
||||||
> 背景:面板优先用宿主传入的 token;没有 token 时,用宿主传入的 **username** 走 DeerFlow 公共免密端点
|
|
||||||
> `POST /api/v1/auth/login/username` 换取 `access_token`,以 `Authorization: Bearer` 发起后续请求
|
|
||||||
> (带 Bearer 会绕过 CSRF)。token 解析优先级:`token` > `getToken` > 缓存 > `username` 登录。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-AUTH-01 · username 免密自动登录(P0)
|
|
||||||
|
|
||||||
**前置条件**:后端 `auth_login.username_login.require_password = false`(默认)。
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 只传 `:username="demo"`(不传 token),打开面板。
|
|
||||||
2. 开始分析 / 打开历史。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 面板能正常拉历史、能发起并行流式——说明已用 username 换到 token 并附带在请求头。
|
|
||||||
- 旁证(手工 curl):
|
|
||||||
```bash
|
|
||||||
curl -s -X POST -H "Content-Type: application/json" \
|
|
||||||
-d '{"username":"demo"}' http://localhost:8001/api/v1/auth/login/username
|
|
||||||
# → 返回 {"access_token":"...","token_type":"bearer",...}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-AUTH-02 · token 优先级高于 username(P1)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 同时传 `:token="<有效token>"` 和 `:username="demo"`。
|
|
||||||
2. 触发请求(开始分析 / 历史)。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 直接用传入的 `token`(不会再去调登录端点)。
|
|
||||||
- `:get-token` 同理优先于 username(动态取 token)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-AUTH-03 · token 缓存(不重复登录)(P1)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 只传 username,先后触发多次请求(拉历史 → 开始分析 → 保存)。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 登录端点**只调用一次**(首个请求),后续请求复用缓存 token(按 `baseUrl::username` 缓存)。
|
|
||||||
- 可在浏览器 Network 面板核对 `login/username` 只出现一次。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-AUTH-04 · 401 自动重登重试(P1)
|
|
||||||
|
|
||||||
**前置条件**:能制造 token 失效(例如等待过期,或后端清理)。
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 用 username 登录并缓存 token 后,使其失效,再触发一次 CRUD 请求。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 首次请求收到 401 → 面板**清缓存并用 username 重登一次**,再自动重试该请求。
|
|
||||||
- 用户无感知(不需手动重新登录),请求最终成功。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-AUTH-05 · 口令门(require_password=true)(P2)
|
|
||||||
|
|
||||||
**前置条件**:后端把 `auth_login.username_login.require_password` 设为 `true`、配置 `password`。
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 只传 username、不传 `loginPassword` → 触发请求。
|
|
||||||
2. 再传上正确的 `:login-password="<口令>"` → 触发请求。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 不传口令时:登录返回 401「口令错误」,面板顶部红色错误提示,不崩溃。
|
|
||||||
- 传对口令时:登录成功,后续请求正常。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-AUTH-06 · Bearer 绕过 CSRF(P1)
|
|
||||||
|
|
||||||
**目的**:核对带 Bearer 的 POST 不被 CSRF 拦截。
|
|
||||||
|
|
||||||
**操作步骤(手工 curl 旁证)**
|
|
||||||
```bash
|
|
||||||
TOKEN=... # 上面登录拿到的 access_token
|
|
||||||
# 带 Bearer 的 POST:参数非法应为 400/422,而非 403(403=CSRF 拦截)
|
|
||||||
curl -s -o /dev/null -w "%{http_code}\n" -X POST \
|
|
||||||
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
|
||||||
-d '{}' http://localhost:8001/api/parallel-agents/run/stream
|
|
||||||
```
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 返回 **400/422**(业务参数校验),**不是 403**——证明 Bearer 已绕过 CSRF。
|
|
||||||
- 不带任何鉴权时该 POST 返回 401/403(被拦截),属正常。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-AUTH-07 · 未传 token 且未传 username(P2)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 既不传 token / getToken,也不传 username,触发需要鉴权的请求。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 请求因缺少鉴权失败(401/403),面板给出可读的错误提示(不白屏)。
|
|
||||||
- 控制台/提示能反映「缺少 token 且未提供 username,无法登录」之类信息。
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
# 测试用例 · 嵌入与深色样式
|
|
||||||
|
|
||||||
覆盖:接口地址走配置文件、组件库构建产物、嵌入其它 Vue 项目、放进抽屉/弹窗自适应、深色成色、
|
|
||||||
不污染宿主全局样式。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-EMBED-01 · 接口地址走配置文件(P0)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 不传 `:base-url`,只改 `src/config.ts` 的 `DEFAULT_BASE_URL` 为目标后端地址。
|
|
||||||
2. 运行面板触发请求。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 请求打到配置文件里的地址。
|
|
||||||
- 传 `:base-url` prop 时**优先**用 prop(覆盖配置默认)。
|
|
||||||
- dev 下设 `VITE_PA_BASE_URL` 环境变量也能覆盖默认。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-EMBED-02 · 组件库构建产物(P0)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
```bash
|
|
||||||
pnpm build:lib
|
|
||||||
```
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- `dist/` 产出:`parallel-agents-vue.js`(ESM)、`parallel-agents-vue.umd.cjs`(UMD)、`parallel-agents-vue.css`。
|
|
||||||
- `vue` 被 external(不打进库,由宿主提供)。
|
|
||||||
- 构建无报错、无「named + default exports」警告。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-EMBED-03 · 嵌入其它 Vue 项目(P0)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 在另一个 Vue3 项目里:
|
|
||||||
```ts
|
|
||||||
import { ParallelAgentsPanel } from "parallel-agents-vue";
|
|
||||||
import "parallel-agents-vue/style.css";
|
|
||||||
```
|
|
||||||
2. 模板中放置:
|
|
||||||
```vue
|
|
||||||
<ParallelAgentsPanel :username="user" :agents="agents" />
|
|
||||||
```
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 组件正常渲染、可交互(深色面板,与 demo 一致)。
|
|
||||||
- 只需 `:username` + `:agents` 两个 prop 即可跑起来(接口地址走配置)。
|
|
||||||
- 也支持 `app.use(ParallelAgentsVue)` 全局注册后用 `<ParallelAgentsPanel/>`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-EMBED-04 · 放进抽屉 / 弹窗自适应(P0)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 把面板放进宿主的抽屉 / 对话框,外层给一个固定高度容器:
|
|
||||||
```vue
|
|
||||||
<YourDrawer v-model="open">
|
|
||||||
<div style="height: 80vh"><ParallelAgentsPanel :username="user" :agents="agents" /></div>
|
|
||||||
</YourDrawer>
|
|
||||||
```
|
|
||||||
2. 改变容器尺寸 / 视口大小。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 面板**充满容器**(根元素 `h-full w-full flex flex-col min-h-0`),不溢出、不塌缩。
|
|
||||||
- 会话区内部独立滚动,输入区与席位条固定不被挤掉。
|
|
||||||
- 窄容器下消息列表正常显示、内部滚动,不溢出。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-EMBED-05 · 深色成色自带、与宿主主题无关(P1)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 分别在**浅色**宿主页和**深色**宿主页里嵌入面板。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 面板始终是深蓝深色(自带 `--pa-*` token,作用域在 `.parallel-agents`),不随宿主明暗变化。
|
|
||||||
- 文案、徽标、步骤卡、并行列底、滚动条等观感与圆桌 Step2 深色一致。
|
|
||||||
- 传 `:dark="false"` 时按实现切换(如未实现浅色则保持深色,至少不报错)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-EMBED-06 · 不污染宿主全局样式(P0)
|
|
||||||
|
|
||||||
**目的**:核对**未引入 Tailwind preflight**(不重置宿主全局标签样式)。
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 在一个有自己样式的宿主页里嵌入面板(页面有 `h1/p/button/ul` 等默认样式)。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 嵌入后,宿主页面**自身**的 `h1/p/button/列表/边距` 等样式**不被改变**(没有被 Tailwind 的 reset 抹平)。
|
|
||||||
- 面板内部样式正常(不依赖宿主提供 Tailwind)。
|
|
||||||
- 类名都在 `.parallel-agents` 作用域下,不与宿主类名冲突。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-EMBED-07 · 多实例并存(P2)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 同一页面放置 2 个面板实例(不同 agents / 不同 username)。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 两个实例各自独立运行、各自保存/回显,互不串流、互不串 token 缓存(缓存按 `baseUrl::username` 区分)。
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
# 测试用 · 可用智能体清单(可直接复制粘贴)
|
|
||||||
|
|
||||||
测试时面板需要传入**真实存在的 `agent_id`**(后端真正拿去跑的标识,不能只写名字)。
|
|
||||||
下面是当前后端里**可直接用于测试**的内置智能体(已排除 intent / recommender / coordinator 这些系统角色)。
|
|
||||||
|
|
||||||
> 注:`name` 只是显示用、可省略;`agent_id` 必须照抄。下列为本环境实测可用的内置智能体;
|
|
||||||
> 你部署里的智能体可能不同,用文末方法自查。
|
|
||||||
|
|
||||||
## 可用智能体
|
|
||||||
|
|
||||||
| agent_id | 显示名 |
|
|
||||||
| --------------------------- | -------- |
|
|
||||||
| `roundtable-solution-design`| 方案设计 |
|
|
||||||
| `roundtable-risk-review` | 风险审查 |
|
|
||||||
| `roundtable-execution-plan` | 执行规划 |
|
|
||||||
| `roundtable-report` | 方案总结 |
|
|
||||||
| `demo-expert` | 演示专家 |
|
|
||||||
|
|
||||||
## A. 粘贴进 dev demo 页「智能体」输入框(每行 `agent_id, 显示名`)
|
|
||||||
|
|
||||||
推荐至少 2 个以便点头像切换查看不同智能体:
|
|
||||||
|
|
||||||
```
|
|
||||||
roundtable-solution-design, 方案设计
|
|
||||||
roundtable-risk-review, 风险审查
|
|
||||||
```
|
|
||||||
|
|
||||||
想多看几列就多贴几行:
|
|
||||||
|
|
||||||
```
|
|
||||||
roundtable-solution-design, 方案设计
|
|
||||||
roundtable-risk-review, 风险审查
|
|
||||||
roundtable-execution-plan, 执行规划
|
|
||||||
```
|
|
||||||
|
|
||||||
## B. 粘贴进组件 `:agents` prop(嵌入场景)
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const agents = [
|
|
||||||
{ agent_id: "roundtable-solution-design", name: "方案设计" },
|
|
||||||
{ agent_id: "roundtable-risk-review", name: "风险审查" },
|
|
||||||
{ agent_id: "roundtable-execution-plan", name: "执行规划" },
|
|
||||||
];
|
|
||||||
```
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<ParallelAgentsPanel :username="'demo'" :agents="agents" />
|
|
||||||
```
|
|
||||||
|
|
||||||
## C. 配一个示例任务(直接粘贴到面板)
|
|
||||||
|
|
||||||
- **任务名称**:`新功能上线方案评估`
|
|
||||||
- **任务简介**:
|
|
||||||
```
|
|
||||||
我们要给 App 上线「扫码点餐」功能。请从你的专业角度,评估方案可行性、列出关键风险、
|
|
||||||
并给出落地步骤与里程碑。
|
|
||||||
```
|
|
||||||
|
|
||||||
## 自查:列出你部署里所有可用的 agent_id
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1) 免密登录拿 token(用户名换成你的)
|
|
||||||
TOKEN=$(curl -s -X POST -H "Content-Type: application/json" \
|
|
||||||
-d '{"username":"demo"}' http://localhost:8001/api/v1/auth/login/username \
|
|
||||||
| python -c "import sys,json;print(json.load(sys.stdin)['access_token'])")
|
|
||||||
|
|
||||||
# 2) 列出 agent_id <TAB> name
|
|
||||||
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8001/api/agents -o agents.json
|
|
||||||
python -c "import json,io;[print(a['id'],'\t',a['name']) for a in json.load(io.open('agents.json',encoding='utf-8')).get('agents',[])]"
|
|
||||||
```
|
|
||||||
|
|
||||||
第一列 `id` 才是要填的 `agent_id`。挑你需要的智能体即可(避开 `roundtable-intent` /
|
|
||||||
`roundtable-recommender` / `roundtable-coordinator*` 这些系统角色——它们不是用来当并行席位的)。
|
|
||||||
@@ -1,345 +0,0 @@
|
|||||||
# 使用说明 · 在其它 Vue 项目中引入
|
|
||||||
|
|
||||||
本组件库只对外暴露**一个**组件 `ParallelAgentsPanel`(其余 `MessageBubble` / `MessageStepsCard` /
|
|
||||||
`ParallelSeatStrip` / `Avatar` / `MarkdownContent` 都是它的内部子组件,**无需也不要单独引入**)。
|
|
||||||
宿主只管把它放进自己的页面 / 抽屉 / 弹窗,并传几个 prop。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、要引入的东西(就这两样)
|
|
||||||
|
|
||||||
1. **组件**:`ParallelAgentsPanel`
|
|
||||||
2. **样式**:`parallel-agents-vue/style.css`(一次性引入即可,样式作用域全在 `.parallel-agents` 下)
|
|
||||||
|
|
||||||
> 依赖说明:`vue ^3.4`(宿主提供,库不打包 vue);`markdown-it` 已打进库内,无需宿主安装。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、两种引入方式
|
|
||||||
|
|
||||||
### 方式 A:构建成库后按包引入(推荐)
|
|
||||||
|
|
||||||
1. 在本项目构建:
|
|
||||||
```bash
|
|
||||||
pnpm install
|
|
||||||
pnpm build:lib # 产出 dist/parallel-agents-vue.{js,umd.cjs,css}
|
|
||||||
```
|
|
||||||
2. 宿主项目里安装(任选其一):
|
|
||||||
- 把本目录作为本地依赖:`"parallel-agents-vue": "file:../parallel-agents-vue"`;
|
|
||||||
- 或发到你们的私有 npm 后正常 `pnpm add parallel-agents-vue`。
|
|
||||||
3. 代码里引入:
|
|
||||||
```ts
|
|
||||||
import { ParallelAgentsPanel } from "parallel-agents-vue";
|
|
||||||
import "parallel-agents-vue/style.css";
|
|
||||||
```
|
|
||||||
|
|
||||||
### 方式 B:直接拷贝源码(无需发包)
|
|
||||||
|
|
||||||
把本项目 `src/` 下这些拷进宿主项目(保持相对结构),按相对路径引入 `ParallelAgentsPanel.vue` +
|
|
||||||
`styles/parallel-agents.css` 即可:
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
components/ ParallelAgentsPanel.vue · MessageBubble.vue · MessageStepsCard.vue
|
|
||||||
ParallelSeatStrip.vue · Avatar.vue · MarkdownContent.vue
|
|
||||||
composables/ useParallelRun.ts
|
|
||||||
lib/ avatar-constants.ts
|
|
||||||
styles/ parallel-agents.css
|
|
||||||
api.ts · config.ts · types.ts
|
|
||||||
```
|
|
||||||
宿主需有 `markdown-it` 依赖(`pnpm add markdown-it`),并保证构建支持 `.vue`(Vite/@vitejs/plugin-vue)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、注册与使用
|
|
||||||
|
|
||||||
### 局部引入(推荐)
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ParallelAgentsPanel } from "parallel-agents-vue";
|
|
||||||
import "parallel-agents-vue/style.css";
|
|
||||||
|
|
||||||
const agents = [
|
|
||||||
{ agent_id: "roundtable-solution-design", name: "方案设计" },
|
|
||||||
{ agent_id: "roundtable-risk-review", name: "风险审查" },
|
|
||||||
];
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div style="height: 80vh">
|
|
||||||
<ParallelAgentsPanel :username="'demo'" :agents="agents" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 全局注册(可选)
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import ParallelAgentsVue from "parallel-agents-vue";
|
|
||||||
import "parallel-agents-vue/style.css";
|
|
||||||
app.use(ParallelAgentsVue); // 之后任意模板可直接用 <ParallelAgentsPanel/>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、放进抽屉 / 弹窗
|
|
||||||
|
|
||||||
组件根元素是 `h-full w-full flex flex-col min-h-0`,会**充满父容器**。所以**父容器要给一个高度**
|
|
||||||
(弹窗/抽屉本身有高度即可):
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<YourDrawer v-model="open" size="large">
|
|
||||||
<div style="height: 86vh">
|
|
||||||
<ParallelAgentsPanel :username="currentUsername" :agents="agents" @saved="onSaved" />
|
|
||||||
</div>
|
|
||||||
</YourDrawer>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、接口地址与登录
|
|
||||||
|
|
||||||
- **接口地址**:默认读 `src/config.ts` 的 `DEFAULT_BASE_URL`(可改源码,或 dev 设 `VITE_PA_BASE_URL`);
|
|
||||||
也可用 `:base-url` prop 临时覆盖。
|
|
||||||
- **登录**:把宿主的**用户名**用 `: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` 维度,多个实例/多用户互不串。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 六、Props
|
|
||||||
|
|
||||||
| prop | 类型 | 必填 | 说明 |
|
|
||||||
| --------------- | --------------------------------------- | :--: | ----------------------------------------------- |
|
|
||||||
| `agents` | `{ agent_id, name, avatarType? }[]` | ✔ | 要并行调用的智能体(`agent_id` 必须是真实 id) |
|
|
||||||
| `username` | `string` | | 免密自动登录用户名(推荐方式) |
|
|
||||||
| `baseUrl` | `string` | | 后端地址;不传用 `config.ts` 默认 |
|
|
||||||
| `token` | `string` | | 已有 Bearer token(优先于 username) |
|
|
||||||
| `getToken` | `() => string \| Promise<string>` | | 动态取 token |
|
|
||||||
| `loginPassword` | `string` | | 仅当后端开启口令门时需要 |
|
|
||||||
| `taskId` | `string` | | 传入则挂载时自动加载并回显该任务 |
|
|
||||||
| `model` | `string` | | 可选模型名(不传走默认 / 后端回退) |
|
|
||||||
| `dark` | `boolean`(默认 `true`) | | 深色面板(自带成色,不依赖宿主主题) |
|
|
||||||
|
|
||||||
`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 变化(保存 / 加载历史时) |
|
|
||||||
| `completed` | `AgentResult[]` | 一轮并行分析结束时,回传各智能体返回内容 |
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<ParallelAgentsPanel
|
|
||||||
:username="user"
|
|
||||||
:agents="agents"
|
|
||||||
: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 / 配置 / 后端回退。
|
|
||||||
切换作用于**下一次**「开始分析 / 重新分析」,运行中禁用。
|
|
||||||
- 「保存」存档;「历史」里选回**回显**;「重新分析」清空重跑。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 九、注意事项
|
|
||||||
|
|
||||||
- **不会污染宿主全局样式**:库未引入 Tailwind preflight,类名都在 `.parallel-agents` 作用域内。
|
|
||||||
- **深色自带成色**:面板始终深蓝深色,不随宿主明暗切换(与圆桌 Step2 观感一致)。
|
|
||||||
- **跨域**:若宿主与后端不同源,后端需允许该来源的 CORS + 带 `credentials`;登录端点公共且 CSRF 豁免,
|
|
||||||
业务请求用 Bearer 头已绕过 CSRF。
|
|
||||||
- **agent_id 必须真实**:见 [06-可用智能体清单.md](./06-可用智能体清单.md) 获取可用 id 与自查方法。
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
# 开发文档 · 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`。
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>并行多智能体面板 · Dev</title>
|
|
||||||
</head>
|
|
||||||
<body style="margin: 0">
|
|
||||||
<div id="app"></div>
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "parallel-agents-vue",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"description": "并行多智能体面板 —— Vue3 可嵌入组件(输入任务名+简介,并行调用多个智能体,深色消息列表 + 步骤条 + 保存回显 + 重新分析)",
|
|
||||||
"type": "module",
|
|
||||||
"main": "./dist/parallel-agents-vue.umd.cjs",
|
|
||||||
"module": "./dist/parallel-agents-vue.js",
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./dist/parallel-agents-vue.js",
|
|
||||||
"require": "./dist/parallel-agents-vue.umd.cjs"
|
|
||||||
},
|
|
||||||
"./style.css": "./dist/parallel-agents-vue.css"
|
|
||||||
},
|
|
||||||
"files": ["dist"],
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vue-tsc --noEmit && vite build",
|
|
||||||
"build:lib": "vite build --mode lib",
|
|
||||||
"typecheck": "vue-tsc --noEmit",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"vue": "^3.4.0"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"markdown-it": "^14.1.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@tailwindcss/vite": "^4.1.0",
|
|
||||||
"@types/markdown-it": "^14.1.2",
|
|
||||||
"@vitejs/plugin-vue": "^5.2.0",
|
|
||||||
"tailwindcss": "^4.1.0",
|
|
||||||
"typescript": "^5.6.0",
|
|
||||||
"vite": "^6.0.0",
|
|
||||||
"vue": "^3.5.0",
|
|
||||||
"vue-tsc": "^2.1.10"
|
|
||||||
},
|
|
||||||
"pnpm": {
|
|
||||||
"onlyBuiltDependencies": [
|
|
||||||
"esbuild",
|
|
||||||
"@tailwindcss/oxide"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-1389
File diff suppressed because it is too large.
Load diff
@@ -1,89 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/** Dev demo 调试页:填后端地址 / token / 智能体 id,把面板塞进一个弹窗,验证嵌入与流式。 */
|
|
||||||
import { reactive, ref } from "vue";
|
|
||||||
import ParallelAgentsPanel from "./components/ParallelAgentsPanel.vue";
|
|
||||||
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
|
|
||||||
.split("\n")
|
|
||||||
.map((l) => l.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((l) => {
|
|
||||||
const [id, name] = l.split(",").map((x) => x.trim());
|
|
||||||
return { agent_id: id, name: name || id };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div style="min-height: 100vh; background: #0a1020; color: #cbd5e1; padding: 24px; font-family: system-ui">
|
|
||||||
<h1 style="font-size: 18px; font-weight: 700; margin-bottom: 12px">并行多智能体面板 · Dev Demo</h1>
|
|
||||||
<div style="display: flex; flex-direction: column; gap: 8px; max-width: 640px">
|
|
||||||
<label>后端 baseUrl
|
|
||||||
<input v-model="cfg.baseUrl" style="width: 100%; padding: 6px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px" />
|
|
||||||
</label>
|
|
||||||
<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-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">
|
|
||||||
打开面板(弹窗内)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 模拟宿主弹窗:面板自适应容器 -->
|
|
||||||
<div
|
|
||||||
v-if="open"
|
|
||||||
style="position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 50"
|
|
||||||
@click.self="open = false"
|
|
||||||
>
|
|
||||||
<div style="width: 92vw; max-width: 1080px; height: 86vh; border-radius: 12px; overflow: hidden; box-shadow: 0 20px 60px rgba(0,0,0,0.5)">
|
|
||||||
<ParallelAgentsPanel
|
|
||||||
:base-url="cfg.baseUrl"
|
|
||||||
:username="cfg.username || undefined"
|
|
||||||
:task-id="cfg.taskId || undefined"
|
|
||||||
:agents="agents"
|
|
||||||
@update:taskId="onTaskId"
|
|
||||||
@completed="onCompleted"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
/**
|
|
||||||
* 后端访问层:鉴权(宿主传入 token)+ 并行扇出 SSE + 任务 CRUD。
|
|
||||||
*
|
|
||||||
* 全部打到 DeerFlow 网关(宿主传入 `baseUrl`,如 http://localhost:8001):
|
|
||||||
* - `POST {baseUrl}/api/parallel-agents/run/stream` —— 并行扇出,合并 SSE;
|
|
||||||
* - `{baseUrl}/api/parallel-agent-tasks` —— 任务保存/回显 CRUD。
|
|
||||||
*/
|
|
||||||
import type { AgentInput, Dialogue, SavedTask, StreamEvent } from "./types";
|
|
||||||
import { TOKEN_PATH } from "./config";
|
|
||||||
|
|
||||||
export interface ApiConfig {
|
|
||||||
baseUrl: string;
|
|
||||||
/** 已有登录态 token(Bearer)。可传字符串或一个(异步)取 token 的函数。 */
|
|
||||||
token?: string;
|
|
||||||
getToken?: () => string | Promise<string>;
|
|
||||||
/**
|
|
||||||
* 免密自动登录用户名:不传 token 时,组件用它走「用户名登录」(公共端点)换取 token
|
|
||||||
* 并缓存。用户名由宿主自己传。
|
|
||||||
*/
|
|
||||||
username?: string;
|
|
||||||
/** 仅当后端 `auth_login.username_login.require_password=true` 时需要的口令。 */
|
|
||||||
loginPassword?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function base(cfg: ApiConfig): string {
|
|
||||||
return cfg.baseUrl.replace(/\/$/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 免密登录拿到的 token 按 baseUrl::username 缓存,避免每个请求都登一次。
|
|
||||||
const _tokenCache = new Map<string, string>();
|
|
||||||
const cacheKey = (cfg: ApiConfig) => `${base(cfg)}::${cfg.username ?? ""}`;
|
|
||||||
|
|
||||||
/** 用 username 走本面板**专用**的「绕过常规登录」公共端点换 access_token(CSRF 豁免)。 */
|
|
||||||
async function loginByUsername(cfg: ApiConfig): Promise<string> {
|
|
||||||
if (!cfg.username) throw new Error("缺少 token 且未提供 username,无法登录");
|
|
||||||
const res = await fetch(`${base(cfg)}${TOKEN_PATH}`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ username: cfg.username }),
|
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text().catch(() => "");
|
|
||||||
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;
|
|
||||||
_tokenCache.set(cacheKey(cfg), token);
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** token 解析优先级:显式 token > getToken > 缓存 > 用 username 免密登录。 */
|
|
||||||
async function resolveToken(cfg: ApiConfig): Promise<string | undefined> {
|
|
||||||
if (cfg.token) return cfg.token;
|
|
||||||
if (cfg.getToken) return await cfg.getToken();
|
|
||||||
const cached = _tokenCache.get(cacheKey(cfg));
|
|
||||||
if (cached) return cached;
|
|
||||||
if (cfg.username) return await loginByUsername(cfg);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 清掉缓存 token(401 时强制下次重登)。 */
|
|
||||||
function invalidateToken(cfg: ApiConfig): void {
|
|
||||||
_tokenCache.delete(cacheKey(cfg));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function authHeaders(cfg: ApiConfig, json = true): Promise<Record<string, string>> {
|
|
||||||
const h: Record<string, string> = {};
|
|
||||||
if (json) h["Content-Type"] = "application/json";
|
|
||||||
const token = await resolveToken(cfg);
|
|
||||||
if (token) h["Authorization"] = token.startsWith("Bearer ") ? token : `Bearer ${token}`;
|
|
||||||
return h;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 带鉴权的 fetch;若 401 且用 username 免密登录,则清缓存重登并重试一次。 */
|
|
||||||
async function authedFetch(
|
|
||||||
cfg: ApiConfig,
|
|
||||||
url: string,
|
|
||||||
init: RequestInit & { json?: boolean } = {},
|
|
||||||
): Promise<Response> {
|
|
||||||
const { json = true, headers, ...rest } = init;
|
|
||||||
const run = async () =>
|
|
||||||
fetch(url, {
|
|
||||||
...rest,
|
|
||||||
headers: { ...(await authHeaders(cfg, json)), ...(headers as Record<string, string> | undefined) },
|
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
let res = await run();
|
|
||||||
if (res.status === 401 && !cfg.token && !cfg.getToken && cfg.username) {
|
|
||||||
invalidateToken(cfg);
|
|
||||||
res = await run();
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 并行扇出 SSE ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface StreamHandlers {
|
|
||||||
onEvent: (ev: StreamEvent) => void;
|
|
||||||
onError?: (err: unknown) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 打开 `/api/parallel-agents/run/stream` 一条合并 SSE,逐条把统一事件回调出去。
|
|
||||||
* 返回的 Promise 在流结束(all_done / 连接关闭)后 resolve。用 `signal` 取消。
|
|
||||||
*/
|
|
||||||
export async function streamParallelAgents(
|
|
||||||
cfg: ApiConfig,
|
|
||||||
body: { task_name: string; brief: string; agent_ids: string[]; model?: string },
|
|
||||||
handlers: StreamHandlers,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<void> {
|
|
||||||
const res = await authedFetch(cfg, `${base(cfg)}/api/parallel-agents/run/stream`, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
signal,
|
|
||||||
});
|
|
||||||
if (!res.ok || !res.body) {
|
|
||||||
const text = await res.text().catch(() => "");
|
|
||||||
throw new Error(`run/stream failed (${res.status}): ${text}`);
|
|
||||||
}
|
|
||||||
const reader = res.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = "";
|
|
||||||
try {
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
const lines = buffer.split("\n");
|
|
||||||
buffer = lines.pop() ?? "";
|
|
||||||
for (const raw of lines) {
|
|
||||||
const line = raw.trim();
|
|
||||||
if (!line.startsWith("data:")) continue;
|
|
||||||
const payload = line.slice(5).trim();
|
|
||||||
if (!payload) continue;
|
|
||||||
try {
|
|
||||||
handlers.onEvent(JSON.parse(payload) as StreamEvent);
|
|
||||||
} catch {
|
|
||||||
/* 忽略坏帧 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if ((err as { name?: string })?.name !== "AbortError") handlers.onError?.(err);
|
|
||||||
} finally {
|
|
||||||
reader.releaseLock();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 任务 CRUD(保存 / 回显)─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface ServerTask {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
brief: string | null;
|
|
||||||
agents: AgentInput[];
|
|
||||||
messages?: Dialogue[];
|
|
||||||
created_at: string | null;
|
|
||||||
updated_at: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TASKS = (cfg: ApiConfig) => `${base(cfg)}/api/parallel-agent-tasks`;
|
|
||||||
|
|
||||||
async function readJson<T>(res: Response, ctx: string): Promise<T> {
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text().catch(() => "");
|
|
||||||
throw new Error(`${ctx} failed (${res.status}): ${text}`);
|
|
||||||
}
|
|
||||||
return (await res.json()) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listTasks(cfg: ApiConfig): Promise<SavedTask[]> {
|
|
||||||
const res = await authedFetch(cfg, TASKS(cfg), { json: false });
|
|
||||||
const data = await readJson<{ tasks: ServerTask[] }>(res, "list tasks");
|
|
||||||
return data.tasks ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getTask(cfg: ApiConfig, id: string): Promise<SavedTask | null> {
|
|
||||||
const res = await authedFetch(cfg, `${TASKS(cfg)}/${encodeURIComponent(id)}`, { json: false });
|
|
||||||
if (res.status === 404) return null;
|
|
||||||
return readJson<SavedTask>(res, "get task");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createTask(
|
|
||||||
cfg: ApiConfig,
|
|
||||||
payload: { id?: string; title: string; brief?: string; agents: AgentInput[]; messages: Dialogue[] },
|
|
||||||
): Promise<SavedTask> {
|
|
||||||
const res = await authedFetch(cfg, TASKS(cfg), { method: "POST", body: JSON.stringify(payload) });
|
|
||||||
return readJson<SavedTask>(res, "create task");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateTask(
|
|
||||||
cfg: ApiConfig,
|
|
||||||
id: string,
|
|
||||||
patch: { title?: string; brief?: string; agents?: AgentInput[]; messages?: Dialogue[] },
|
|
||||||
): Promise<SavedTask | null> {
|
|
||||||
const res = await authedFetch(cfg, `${TASKS(cfg)}/${encodeURIComponent(id)}`, {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify(patch),
|
|
||||||
});
|
|
||||||
if (res.status === 404) return null;
|
|
||||||
return readJson<SavedTask>(res, "update task");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 模型列表 ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface ModelItem {
|
|
||||||
name: string;
|
|
||||||
display_name?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 列出后端可用模型(GET /api/models)。失败返回空数组(不阻断主流程)。 */
|
|
||||||
export async function listModels(cfg: ApiConfig): Promise<ModelItem[]> {
|
|
||||||
try {
|
|
||||||
const res = await authedFetch(cfg, `${base(cfg)}/api/models`, { json: false });
|
|
||||||
if (!res.ok) return [];
|
|
||||||
const data = (await res.json()) as { models?: ModelItem[] };
|
|
||||||
return data.models ?? [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteTask(cfg: ApiConfig, id: string): Promise<void> {
|
|
||||||
const res = await authedFetch(cfg, `${TASKS(cfg)}/${encodeURIComponent(id)}`, {
|
|
||||||
method: "DELETE",
|
|
||||||
json: false,
|
|
||||||
});
|
|
||||||
if (!res.ok && res.status !== 404) {
|
|
||||||
const text = await res.text().catch(() => "");
|
|
||||||
throw new Error(`delete task failed (${res.status}): ${text}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from "vue";
|
|
||||||
import { avatarColor, isHuman } from "@/lib/avatar-constants";
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{ type: string; size?: number }>(), { size: 36 });
|
|
||||||
const color = computed(() => avatarColor(props.type));
|
|
||||||
const human = computed(() => isHuman(props.type));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<span
|
|
||||||
class="inline-flex shrink-0 items-center justify-center rounded-xl"
|
|
||||||
:style="{ width: `${size}px`, height: `${size}px`, backgroundColor: color.bg }"
|
|
||||||
>
|
|
||||||
<!-- 真人 -->
|
|
||||||
<svg
|
|
||||||
v-if="human"
|
|
||||||
:width="size * 0.58"
|
|
||||||
:height="size * 0.58"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
:stroke="color.fg"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="8" r="4" />
|
|
||||||
<path d="M4 21c0-4 4-6 8-6s8 2 8 6" />
|
|
||||||
</svg>
|
|
||||||
<!-- 机器人 -->
|
|
||||||
<svg
|
|
||||||
v-else
|
|
||||||
:width="size * 0.6"
|
|
||||||
:height="size * 0.6"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
:stroke="color.fg"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<rect x="4" y="8" width="16" height="11" rx="3" />
|
|
||||||
<path d="M12 8V4" />
|
|
||||||
<circle cx="12" cy="3" r="1" :fill="color.fg" />
|
|
||||||
<circle cx="9" cy="13" r="1.2" :fill="color.fg" stroke="none" />
|
|
||||||
<circle cx="15" cy="13" r="1.2" :fill="color.fg" stroke="none" />
|
|
||||||
<path d="M9 16.5h6" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from "vue";
|
|
||||||
import MarkdownIt from "markdown-it";
|
|
||||||
|
|
||||||
const props = defineProps<{ source: string }>();
|
|
||||||
|
|
||||||
const md = new MarkdownIt({ html: false, linkify: true, breaks: true });
|
|
||||||
const html = computed(() => md.render(props.source ?? ""));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="pa-md" v-html="html" />
|
|
||||||
</template>
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/** 单条消息气泡(对齐 Step2 MessageBubble + Step2Message 结构):头像 / 发言人 / 状态徽标 /
|
|
||||||
* 时间 / 步骤条 / Markdown 正文 / 流式光标 / loading 占位。 */
|
|
||||||
import { computed } from "vue";
|
|
||||||
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; submitting?: boolean }>();
|
|
||||||
const emit = defineEmits<{ (e: "clarify", agentId: string, answer: string): void }>();
|
|
||||||
const hasContent = computed(() => (props.msg.content ?? "").trim().length > 0);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<Avatar :type="msg.avatarType" :size="36" />
|
|
||||||
<div class="min-w-0 flex-1 space-y-1">
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-sm font-extrabold pa-text">{{ msg.sender }}</span>
|
|
||||||
<span v-if="msg.confidence" class="pa-badge">{{ msg.confidence }}</span>
|
|
||||||
</div>
|
|
||||||
<span class="shrink-0 font-mono text-[11px] pa-text-muted">{{ msg.time }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MessageStepsCard
|
|
||||||
v-if="msg.steps && msg.steps.length"
|
|
||||||
:steps="msg.steps"
|
|
||||||
:streaming="msg.streaming"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="pa-text break-words">
|
|
||||||
<div
|
|
||||||
v-if="msg.streaming && !hasContent"
|
|
||||||
class="flex items-center gap-2 py-1 text-sm pa-text-muted"
|
|
||||||
>
|
|
||||||
<span class="pa-spin inline-block">◜</span>
|
|
||||||
<span>{{ msg.confidence || "正在生成…" }}</span>
|
|
||||||
<span class="inline-flex items-center gap-0.5">
|
|
||||||
<span class="pa-bounce inline-block size-1 rounded-full" style="background: var(--pa-primary)" />
|
|
||||||
<span class="pa-bounce inline-block size-1 rounded-full" style="background: var(--pa-primary); animation-delay: 0.15s" />
|
|
||||||
<span class="pa-bounce inline-block size-1 rounded-full" style="background: var(--pa-primary); animation-delay: 0.3s" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<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,95 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* 步骤条 / 思维链卡 —— 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";
|
|
||||||
|
|
||||||
const props = defineProps<{ steps: DialogueStep[]; streaming?: boolean }>();
|
|
||||||
const open = ref(true);
|
|
||||||
|
|
||||||
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 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>执行步骤({{ steps.length }})</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<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'"
|
|
||||||
>
|
|
||||||
<!-- 图标列 + 竖向连接线 -->
|
|
||||||
<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>
|
|
||||||
@@ -1,375 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* 并行多智能体面板(主组件,可嵌入)。
|
|
||||||
*
|
|
||||||
* 输入「任务名称 + 简介」,并行调用宿主传入的多个智能体(后端固定禁写文件),消息列表
|
|
||||||
* 照搬圆桌 Step2 并行多席位的深色样式与结构(步骤条 / 并行双列 / 席位状态条),支持保存
|
|
||||||
* 聊天记录 + 回显 + 重新分析。
|
|
||||||
*
|
|
||||||
* 自适应容器:根元素 `h-full w-full flex flex-col min-h-0`,宿主直接塞进抽屉 / 弹窗即可。
|
|
||||||
*/
|
|
||||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
|
||||||
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";
|
|
||||||
import ParallelSeatStrip from "./ParallelSeatStrip.vue";
|
|
||||||
import MessageBubble from "./MessageBubble.vue";
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
/** 后端地址;不传用配置文件默认(config.ts 的 DEFAULT_BASE_URL)。 */
|
|
||||||
baseUrl?: string;
|
|
||||||
/** 已有 token(Bearer),与 username 二选一;都不传走配置默认地址 + 免密登录失败提示。 */
|
|
||||||
token?: string;
|
|
||||||
getToken?: () => string | Promise<string>;
|
|
||||||
/** 免密自动登录用户名(由宿主自己传)。不传 token 时用它登录换 token。 */
|
|
||||||
username?: string;
|
|
||||||
/** 仅当后端开启口令门时需要。 */
|
|
||||||
loginPassword?: string;
|
|
||||||
agents: AgentInput[];
|
|
||||||
taskId?: string;
|
|
||||||
model?: string;
|
|
||||||
dark?: boolean;
|
|
||||||
}>(),
|
|
||||||
{ dark: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: "saved", task: SavedTask): void;
|
|
||||||
(e: "update:taskId", id: string): void;
|
|
||||||
/** 一轮并行分析结束时回传各智能体的返回内容。 */
|
|
||||||
(e: "completed", results: AgentResult[]): void;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const cfg = computed<ApiConfig>(() => ({
|
|
||||||
baseUrl: props.baseUrl || DEFAULT_BASE_URL,
|
|
||||||
token: props.token,
|
|
||||||
getToken: props.getToken,
|
|
||||||
username: props.username,
|
|
||||||
loginPassword: props.loginPassword,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const run = useParallelRun();
|
|
||||||
const { dialogues, seats, running } = run;
|
|
||||||
|
|
||||||
const form = reactive({ taskName: "", brief: "" });
|
|
||||||
const currentTaskId = ref<string | null>(props.taskId ?? null);
|
|
||||||
const selected = ref<string[]>([]);
|
|
||||||
const saving = ref(false);
|
|
||||||
const history = ref<SavedTask[]>([]);
|
|
||||||
const showHistory = ref(false);
|
|
||||||
const errorMsg = ref<string | null>(null);
|
|
||||||
|
|
||||||
// 模型切换:拉后端模型列表,空字符串 = 用默认(prop/config/后端回退)。
|
|
||||||
const models = ref<ModelItem[]>([]);
|
|
||||||
const selectedModel = ref<string>(props.model ?? DEFAULT_MODEL ?? "");
|
|
||||||
|
|
||||||
const dialogueById = computed(() => {
|
|
||||||
const m = new Map<number, Dialogue>();
|
|
||||||
for (const d of dialogues.value) m.set(d.id, d);
|
|
||||||
return m;
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasResults = computed(() => dialogues.value.length > 0);
|
|
||||||
const canStart = computed(
|
|
||||||
() => !running.value && props.agents.length > 0 && (form.taskName.trim() || form.brief.trim()),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 单选:主区每次只显示**一个**席位的消息列表,点头像切换。
|
|
||||||
watch(
|
|
||||||
seats,
|
|
||||||
(s) => {
|
|
||||||
if (!s.length) {
|
|
||||||
selected.value = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 保留仍存在的当前选中;首次 / 失效时默认显示第 1 个席位。
|
|
||||||
const cur = selected.value[0];
|
|
||||||
const keep = cur && s.some((x) => x.agentId === cur) ? cur : s[0].agentId;
|
|
||||||
selected.value = [keep];
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
// 宿主传入 / 改变 taskId:作为存储主键,并尝试加载回显该任务(不存在则只设 id)。
|
|
||||||
watch(
|
|
||||||
() => props.taskId,
|
|
||||||
(id) => {
|
|
||||||
currentTaskId.value = id ?? null;
|
|
||||||
if (id) void loadTask(id);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/** 点头像切换显示:单选,始终切到点击的那个席位。 */
|
|
||||||
function toggleSeat(agentId: string) {
|
|
||||||
selected.value = [agentId];
|
|
||||||
}
|
|
||||||
|
|
||||||
const visibleSeats = computed(() => {
|
|
||||||
const id = selected.value[0] ?? seats.value[0]?.agentId;
|
|
||||||
const seat = seats.value.find((s) => s.agentId === id);
|
|
||||||
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;
|
|
||||||
try {
|
|
||||||
await run.start(cfg.value, {
|
|
||||||
taskName: form.taskName,
|
|
||||||
brief: form.brief,
|
|
||||||
agents: props.agents,
|
|
||||||
model: selectedModel.value || undefined,
|
|
||||||
});
|
|
||||||
// 一轮结束:回传各智能体返回内容(宿主也可用 ref 调 getResults())。
|
|
||||||
emit("completed", getResults());
|
|
||||||
} catch (err) {
|
|
||||||
errorMsg.value = err instanceof Error ? err.message : String(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSave() {
|
|
||||||
if (saving.value || !hasResults.value) return;
|
|
||||||
saving.value = true;
|
|
||||||
errorMsg.value = null;
|
|
||||||
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;
|
|
||||||
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, payload);
|
|
||||||
}
|
|
||||||
if (saved) {
|
|
||||||
currentTaskId.value = saved.id;
|
|
||||||
emit("update:taskId", saved.id);
|
|
||||||
emit("saved", saved);
|
|
||||||
await refreshHistory();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
errorMsg.value = err instanceof Error ? err.message : String(err);
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshHistory() {
|
|
||||||
try {
|
|
||||||
history.value = await listTasks(cfg.value);
|
|
||||||
} catch {
|
|
||||||
/* 历史拉取失败不阻断主流程 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadTask(id: string) {
|
|
||||||
showHistory.value = false;
|
|
||||||
errorMsg.value = null;
|
|
||||||
try {
|
|
||||||
const task = await getTask(cfg.value, id);
|
|
||||||
if (!task) return;
|
|
||||||
currentTaskId.value = task.id;
|
|
||||||
form.taskName = task.title ?? "";
|
|
||||||
form.brief = task.brief ?? "";
|
|
||||||
selected.value = [];
|
|
||||||
run.hydrate(task.messages ?? []);
|
|
||||||
emit("update:taskId", task.id);
|
|
||||||
} catch (err) {
|
|
||||||
errorMsg.value = err instanceof Error ? err.message : String(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshModels() {
|
|
||||||
const list = await listModels(cfg.value);
|
|
||||||
models.value = list;
|
|
||||||
// 当前选择不在列表里(且非空)时,回退为默认(空 = 后端回退)。
|
|
||||||
if (selectedModel.value && !list.some((m) => m.name === selectedModel.value)) {
|
|
||||||
selectedModel.value = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
void refreshHistory();
|
|
||||||
void refreshModels();
|
|
||||||
if (props.taskId) void loadTask(props.taskId);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="parallel-agents pa-bg flex h-full w-full min-h-0 flex-col"
|
|
||||||
:class="dark ? 'parallel-agents--dark' : ''"
|
|
||||||
>
|
|
||||||
<!-- 顶部:任务名 + 操作 -->
|
|
||||||
<div class="pa-border flex shrink-0 flex-col gap-2 border-b px-4 py-3">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
v-model="form.taskName"
|
|
||||||
class="pa-input min-w-0 flex-1 px-3 py-1.5 text-sm"
|
|
||||||
placeholder="任务名称"
|
|
||||||
/>
|
|
||||||
<div class="relative shrink-0">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="pa-btn-ghost px-2.5 py-1.5 text-xs"
|
|
||||||
@click="showHistory = !showHistory"
|
|
||||||
>
|
|
||||||
历史 ▾
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
v-if="showHistory"
|
|
||||||
class="pa-card pa-border absolute right-0 z-20 mt-1 max-h-72 w-72 overflow-auto rounded-lg border p-1 shadow-lg"
|
|
||||||
>
|
|
||||||
<div v-if="!history.length" class="px-3 py-4 text-center text-xs pa-text-muted">
|
|
||||||
暂无保存的任务
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
v-for="t in history"
|
|
||||||
:key="t.id"
|
|
||||||
type="button"
|
|
||||||
class="pa-btn-ghost block w-full truncate px-2.5 py-1.5 text-left text-xs"
|
|
||||||
style="border: 0"
|
|
||||||
@click="loadTask(t.id)"
|
|
||||||
>
|
|
||||||
<span class="font-semibold">{{ t.title || "未命名任务" }}</span>
|
|
||||||
<span class="pa-text-muted"> · {{ (t.updated_at || "").slice(0, 16).replace("T", " ") }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
v-model="form.brief"
|
|
||||||
rows="2"
|
|
||||||
class="pa-input w-full resize-y px-3 py-1.5 text-sm"
|
|
||||||
placeholder="任务简介:把背景、目标、约束写清楚,会并行下发给各智能体"
|
|
||||||
/>
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<button
|
|
||||||
v-if="!running"
|
|
||||||
type="button"
|
|
||||||
class="pa-btn-primary px-3.5 py-1.5 text-sm font-semibold"
|
|
||||||
:disabled="!canStart"
|
|
||||||
@click="onStart"
|
|
||||||
>
|
|
||||||
{{ hasResults ? "重新分析" : "开始分析" }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-else
|
|
||||||
type="button"
|
|
||||||
class="pa-btn-ghost px-3.5 py-1.5 text-sm font-semibold"
|
|
||||||
@click="run.cancel()"
|
|
||||||
>
|
|
||||||
停止
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="pa-btn-ghost px-3.5 py-1.5 text-sm font-semibold"
|
|
||||||
:disabled="saving || !hasResults"
|
|
||||||
@click="onSave"
|
|
||||||
>
|
|
||||||
{{ saving ? "保存中…" : currentTaskId ? "更新保存" : "保存" }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- 模型切换:作用于下一次「开始分析 / 重新分析」 -->
|
|
||||||
<label class="ml-auto flex items-center gap-1.5 text-xs pa-text-muted">
|
|
||||||
模型
|
|
||||||
<select
|
|
||||||
v-model="selectedModel"
|
|
||||||
:disabled="running"
|
|
||||||
class="pa-input px-2 py-1 text-xs"
|
|
||||||
title="选择本次并行分析使用的模型"
|
|
||||||
>
|
|
||||||
<option value="">默认</option>
|
|
||||||
<option v-for="m in models" :key="m.name" :value="m.name">
|
|
||||||
{{ m.display_name || m.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<span v-if="props.agents.length === 0" class="text-xs pa-text-muted">
|
|
||||||
⚠️ 未传入任何智能体(agents 为空)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p v-if="errorMsg" class="text-xs" style="color: var(--pa-rose)">{{ errorMsg }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 席位状态条 -->
|
|
||||||
<div v-if="seats.length" class="pa-border shrink-0 border-b px-4">
|
|
||||||
<ParallelSeatStrip :seats="seats" :selected="selected" @toggle="toggleSeat" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 会话区:并行双列 -->
|
|
||||||
<div class="min-h-0 flex-1 overflow-y-auto p-4">
|
|
||||||
<div
|
|
||||||
v-if="hasResults"
|
|
||||||
class="grid gap-4"
|
|
||||||
:class="visibleSeats.length > 1 ? 'grid-cols-1 lg:grid-cols-2' : 'grid-cols-1'"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="seat in visibleSeats"
|
|
||||||
:key="seat.agentId"
|
|
||||||
class="pa-surface-soft pa-border flex min-w-0 flex-col rounded-xl border p-3"
|
|
||||||
>
|
|
||||||
<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 }} · 等待输出…
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="flex h-full flex-col items-center justify-center gap-2 text-center"
|
|
||||||
>
|
|
||||||
<p class="text-sm pa-text-muted">填写任务名称与简介,点「开始分析」并行调用各智能体。</p>
|
|
||||||
<p class="text-xs pa-text-muted">并行运行期间禁止写文件,产出直接呈现在正文。</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/** 并行席位状态条(对齐 Step2 ParallelSeatStrip):席位头像 + running/done/error 角标,
|
|
||||||
* 点击可切换主区显示哪两列。 */
|
|
||||||
import { computed } from "vue";
|
|
||||||
import type { SeatView } from "@/types";
|
|
||||||
import Avatar from "./Avatar.vue";
|
|
||||||
|
|
||||||
const props = defineProps<{ seats: SeatView[]; selected: string[] }>();
|
|
||||||
const emit = defineEmits<{ (e: "toggle", agentId: string): void }>();
|
|
||||||
|
|
||||||
const runningCount = computed(() => props.seats.filter((s) => s.status === "running").length);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex items-center gap-2 overflow-x-auto py-1">
|
|
||||||
<span class="shrink-0 text-[11px] font-bold pa-text-muted">
|
|
||||||
并行 {{ seats.length }} 席
|
|
||||||
<span v-if="runningCount > 0" class="pa-text-primary">({{ runningCount }} 进行中)</span>
|
|
||||||
<span v-if="seats.length > 1" class="font-normal pa-text-muted">· 点头像切换显示</span>
|
|
||||||
</span>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
v-for="seat in seats"
|
|
||||||
:key="seat.agentId"
|
|
||||||
type="button"
|
|
||||||
class="relative shrink-0 rounded-full transition"
|
|
||||||
:style="
|
|
||||||
selected.includes(seat.agentId)
|
|
||||||
? 'box-shadow: 0 0 0 2px var(--pa-primary)'
|
|
||||||
: ''
|
|
||||||
"
|
|
||||||
:title="seat.name"
|
|
||||||
@click="emit('toggle', seat.agentId)"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
v-if="seat.status === 'running'"
|
|
||||||
class="pa-ping absolute inset-0 rounded-full"
|
|
||||||
style="background: rgba(22, 119, 255, 0.3)"
|
|
||||||
/>
|
|
||||||
<span class="relative block" :style="!selected.includes(seat.agentId) ? 'opacity:0.8' : ''">
|
|
||||||
<Avatar :type="seat.avatarType" :size="28" />
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="absolute -bottom-0.5 -right-0.5 flex size-3.5 items-center justify-center rounded-full text-[8px] font-black"
|
|
||||||
style="border: 1px solid var(--pa-bg)"
|
|
||||||
:class="{
|
|
||||||
'pa-dot-running': seat.status === 'running',
|
|
||||||
'pa-dot-done': seat.status === 'done',
|
|
||||||
'pa-dot-error': seat.status === 'error',
|
|
||||||
}"
|
|
||||||
:style="seat.status === 'pending' ? 'background: var(--pa-border)' : ''"
|
|
||||||
>
|
|
||||||
<span v-if="seat.status === 'running'" class="pa-spin">◜</span>
|
|
||||||
<span v-else-if="seat.status === 'done'">✓</span>
|
|
||||||
<span v-else-if="seat.status === 'error'">✕</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
/**
|
|
||||||
* 并行运行编排(前端态)。消费后端合并 SSE,按 agent_id 把增量/步骤/收口路由到对应席位
|
|
||||||
* 气泡,维护并行席位状态条;带轻量节流(rAF 合并增量),支持取消、回显 hydrate、重置。
|
|
||||||
*/
|
|
||||||
import { ref, shallowRef } from "vue";
|
|
||||||
import type { AgentInput, Dialogue, SeatView, StreamEvent } from "@/types";
|
|
||||||
import { getAvatarTypeFor } from "@/lib/avatar-constants";
|
|
||||||
import { streamParallelAgents, type ApiConfig } from "@/api";
|
|
||||||
|
|
||||||
function nowTime(): string {
|
|
||||||
const d = new Date();
|
|
||||||
const p = (n: number) => String(n).padStart(2, "0");
|
|
||||||
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let _seq = 1;
|
|
||||||
function nextId(): number {
|
|
||||||
return _seq++;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useParallelRun() {
|
|
||||||
const dialogues = ref<Dialogue[]>([]);
|
|
||||||
const seats = ref<SeatView[]>([]);
|
|
||||||
const running = ref(false);
|
|
||||||
const abortRef = shallowRef<AbortController | null>(null);
|
|
||||||
|
|
||||||
// 增量节流:累积到 pending,rAF 合并刷新一次,避免逐 token reflow。
|
|
||||||
const pending = new Map<string, string>(); // agentId -> 待追加文本
|
|
||||||
let rafScheduled = false;
|
|
||||||
const bubbleByAgent = new Map<string, number>(); // agentId -> dialogue.id
|
|
||||||
|
|
||||||
function flushPending() {
|
|
||||||
rafScheduled = false;
|
|
||||||
if (pending.size === 0) return;
|
|
||||||
for (const [agentId, text] of pending) {
|
|
||||||
const bid = bubbleByAgent.get(agentId);
|
|
||||||
const d = dialogues.value.find((x) => x.id === bid);
|
|
||||||
if (d) d.content += text;
|
|
||||||
}
|
|
||||||
pending.clear();
|
|
||||||
// 触发数组响应(对象内字段已变更,这里强制一次浅刷新以更新双列视图)。
|
|
||||||
dialogues.value = [...dialogues.value];
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleFlush() {
|
|
||||||
if (rafScheduled) return;
|
|
||||||
rafScheduled = true;
|
|
||||||
requestAnimationFrame(flushPending);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSeatStatus(agentId: string, status: SeatView["status"]) {
|
|
||||||
const s = seats.value.find((x) => x.agentId === agentId);
|
|
||||||
if (s) s.status = status;
|
|
||||||
seats.value = [...seats.value];
|
|
||||||
}
|
|
||||||
|
|
||||||
function findDialogue(agentId: string): Dialogue | undefined {
|
|
||||||
const bid = bubbleByAgent.get(agentId);
|
|
||||||
return dialogues.value.find((x) => x.id === bid);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEvent(ev: StreamEvent) {
|
|
||||||
switch (ev.type) {
|
|
||||||
case "agent_start":
|
|
||||||
setSeatStatus(ev.agent_id, "running");
|
|
||||||
break;
|
|
||||||
case "delta":
|
|
||||||
pending.set(ev.agent_id, (pending.get(ev.agent_id) ?? "") + ev.text);
|
|
||||||
scheduleFlush();
|
|
||||||
break;
|
|
||||||
case "step": {
|
|
||||||
const d = findDialogue(ev.agent_id);
|
|
||||||
if (d) {
|
|
||||||
d.steps = d.steps ?? [];
|
|
||||||
d.steps.push({
|
|
||||||
id: `${d.id}-${d.steps.length}`,
|
|
||||||
key: ev.key,
|
|
||||||
label: ev.label,
|
|
||||||
ts: nowTime(),
|
|
||||||
args: ev.args ?? null,
|
|
||||||
});
|
|
||||||
dialogues.value = [...dialogues.value];
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "agent_done": {
|
|
||||||
flushPending();
|
|
||||||
const d = findDialogue(ev.agent_id);
|
|
||||||
if (d) {
|
|
||||||
if (ev.content) d.content = ev.content; // 终态正文以后端为准
|
|
||||||
d.streaming = false;
|
|
||||||
d.confidence = "已完成";
|
|
||||||
}
|
|
||||||
setSeatStatus(ev.agent_id, "done");
|
|
||||||
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);
|
|
||||||
if (d) {
|
|
||||||
d.content = d.content || `⚠️ 该智能体执行出错:${ev.message}`;
|
|
||||||
d.streaming = false;
|
|
||||||
d.confidence = "出错";
|
|
||||||
}
|
|
||||||
setSeatStatus(ev.agent_id, "error");
|
|
||||||
dialogues.value = [...dialogues.value];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "all_done":
|
|
||||||
flushPending();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 启动一轮并行运行。会清空旧对话、为每个 agent 建一个流式气泡。 */
|
|
||||||
async function start(
|
|
||||||
cfg: ApiConfig,
|
|
||||||
args: { taskName: string; brief: string; agents: AgentInput[]; model?: string },
|
|
||||||
): Promise<void> {
|
|
||||||
if (running.value) return;
|
|
||||||
reset();
|
|
||||||
running.value = true;
|
|
||||||
|
|
||||||
bubbleByAgent.clear();
|
|
||||||
const newSeats: SeatView[] = [];
|
|
||||||
const newDialogues: Dialogue[] = [];
|
|
||||||
args.agents.forEach((a, i) => {
|
|
||||||
const avatarType = a.avatarType || getAvatarTypeFor(a.agent_id, i);
|
|
||||||
const bubbleId = nextId();
|
|
||||||
bubbleByAgent.set(a.agent_id, bubbleId);
|
|
||||||
newDialogues.push({
|
|
||||||
id: bubbleId,
|
|
||||||
agentId: a.agent_id,
|
|
||||||
sender: a.name || a.agent_id,
|
|
||||||
avatarType,
|
|
||||||
content: "",
|
|
||||||
time: nowTime(),
|
|
||||||
confidence: "并行执行中",
|
|
||||||
streaming: true,
|
|
||||||
steps: [],
|
|
||||||
});
|
|
||||||
newSeats.push({ agentId: a.agent_id, name: a.name || a.agent_id, avatarType, status: "pending", bubbleId });
|
|
||||||
});
|
|
||||||
dialogues.value = newDialogues;
|
|
||||||
seats.value = newSeats;
|
|
||||||
|
|
||||||
const ac = new AbortController();
|
|
||||||
abortRef.value = ac;
|
|
||||||
try {
|
|
||||||
await streamParallelAgents(
|
|
||||||
cfg,
|
|
||||||
{
|
|
||||||
task_name: args.taskName,
|
|
||||||
brief: args.brief,
|
|
||||||
agent_ids: args.agents.map((a) => a.agent_id),
|
|
||||||
model: args.model,
|
|
||||||
},
|
|
||||||
{ onEvent: handleEvent, onError: (err) => console.error("[parallel-run]", err) },
|
|
||||||
ac.signal,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
// 收尾:任何仍在 streaming 的气泡都置完成态。
|
|
||||||
for (const d of dialogues.value) {
|
|
||||||
if (d.streaming) {
|
|
||||||
d.streaming = false;
|
|
||||||
if (d.confidence === "并行执行中") d.confidence = "已完成";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dialogues.value = [...dialogues.value];
|
|
||||||
running.value = false;
|
|
||||||
abortRef.value = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancel() {
|
|
||||||
abortRef.value?.abort();
|
|
||||||
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();
|
|
||||||
bubbleByAgent.clear();
|
|
||||||
const restored = saved.map((d) => ({ ...d, streaming: false }));
|
|
||||||
dialogues.value = restored;
|
|
||||||
const seatMap = new Map<string, SeatView>();
|
|
||||||
restored.forEach((d) => {
|
|
||||||
bubbleByAgent.set(d.agentId, d.id);
|
|
||||||
if (!seatMap.has(d.agentId)) {
|
|
||||||
seatMap.set(d.agentId, {
|
|
||||||
agentId: d.agentId,
|
|
||||||
name: d.sender,
|
|
||||||
avatarType: d.avatarType,
|
|
||||||
status: d.confidence === "出错" ? "error" : "done",
|
|
||||||
bubbleId: d.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
seats.value = [...seatMap.values()];
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
cancel();
|
|
||||||
pending.clear();
|
|
||||||
bubbleByAgent.clear();
|
|
||||||
dialogues.value = [];
|
|
||||||
seats.value = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return { dialogues, seats, running, start, cancel, hydrate, reset, rerunAgent };
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
/**
|
|
||||||
* 前端配置:后端接口地址等。嵌入方一般直接改这里的默认值即可;也可在 dev 用
|
|
||||||
* Vite 环境变量覆盖(VITE_PA_BASE_URL)。组件 props 传入时优先级最高。
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** DeerFlow 网关默认地址(接口都打到这里)。 */
|
|
||||||
export const DEFAULT_BASE_URL: string =
|
|
||||||
(import.meta as unknown as { env?: Record<string, string> }).env?.VITE_PA_BASE_URL ||
|
|
||||||
"http://localhost:8001";
|
|
||||||
|
|
||||||
/** 可选默认模型名(不填由后端回退到 config.yaml models[0])。 */
|
|
||||||
export const DEFAULT_MODEL: string | undefined =
|
|
||||||
(import.meta as unknown as { env?: Record<string, string> }).env?.VITE_PA_MODEL || undefined;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 取 token 端点:本面板**专用**的「绕过常规登录」公共端点(传用户名直接签发 token)。
|
|
||||||
* 内网部署对标准 /api/v1/auth/login/username 有额外限制,故走这个专用端点。
|
|
||||||
*/
|
|
||||||
export const TOKEN_PATH = "/api/parallel-agents/auth/token";
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
/**
|
|
||||||
* 组件库入口。其它 Vue 项目:
|
|
||||||
* import { ParallelAgentsPanel } from "parallel-agents-vue";
|
|
||||||
* import "parallel-agents-vue/style.css";
|
|
||||||
* 或全局注册:app.use(ParallelAgentsVue)。
|
|
||||||
*/
|
|
||||||
import "./styles/parallel-agents.css";
|
|
||||||
import type { App } from "vue";
|
|
||||||
import ParallelAgentsPanel from "./components/ParallelAgentsPanel.vue";
|
|
||||||
|
|
||||||
export { ParallelAgentsPanel };
|
|
||||||
export type { AgentInput, AgentResult, Dialogue, SavedTask, SeatView, StreamEvent } from "./types";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
install(app: App) {
|
|
||||||
app.component("ParallelAgentsPanel", ParallelAgentsPanel);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
/** 头像配色(对齐圆桌 Step2 的 avatarType:blue/purple/emerald/amber/pink/human)。 */
|
|
||||||
|
|
||||||
export const FALLBACK_AVATAR_TYPES = ["blue", "purple", "emerald", "amber", "pink"] as const;
|
|
||||||
|
|
||||||
/** 无显式 avatarType 时按下标轮转一个配色(与 Step2 getAvatarTypeFor 行为一致)。 */
|
|
||||||
export function getAvatarTypeFor(_agentId: string, index: number): string {
|
|
||||||
return FALLBACK_AVATAR_TYPES[index % FALLBACK_AVATAR_TYPES.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 每种 avatarType 在深色面板里的底色 + 主体色(机器人头 / 真人)。 */
|
|
||||||
export const AVATAR_COLORS: Record<string, { bg: string; fg: string }> = {
|
|
||||||
blue: { bg: "rgba(22,119,255,0.18)", fg: "#4096ff" },
|
|
||||||
purple: { bg: "rgba(124,58,237,0.20)", fg: "#a78bfa" },
|
|
||||||
emerald: { bg: "rgba(16,185,129,0.20)", fg: "#34d399" },
|
|
||||||
amber: { bg: "rgba(245,158,11,0.20)", fg: "#fbbf24" },
|
|
||||||
pink: { bg: "rgba(219,39,119,0.20)", fg: "#f472b6" },
|
|
||||||
human: { bg: "rgba(148,163,184,0.20)", fg: "#cbd5e1" },
|
|
||||||
man: { bg: "rgba(148,163,184,0.20)", fg: "#cbd5e1" },
|
|
||||||
};
|
|
||||||
|
|
||||||
export function avatarColor(type: string): { bg: string; fg: string } {
|
|
||||||
return AVATAR_COLORS[type] ?? AVATAR_COLORS.blue;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isHuman(type: string): boolean {
|
|
||||||
return type === "human" || type === "man";
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
/**
|
|
||||||
* 步骤条渲染辅助(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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { createApp } from "vue";
|
|
||||||
import App from "./App.vue";
|
|
||||||
import "./styles/parallel-agents.css";
|
|
||||||
|
|
||||||
createApp(App).mount("#app");
|
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
/* 并行多智能体面板样式。
|
|
||||||
*
|
|
||||||
* 嵌入安全:只引 Tailwind 的 theme + utilities(**不引 preflight**,避免重置宿主全局样式)。
|
|
||||||
* 颜色用作用域内的 `--pa-*` 深蓝 token(取自《深蓝 UI 规范》ZS-004 / ZS-002,与圆桌 Step2
|
|
||||||
* 深色一致),全部包在根类 `.parallel-agents` 下,绝不污染宿主。布局用 Tailwind 工具类,
|
|
||||||
* 主题色用下面的 `.pa-*` 语义类。 */
|
|
||||||
|
|
||||||
@layer theme, base, components, utilities;
|
|
||||||
@import "tailwindcss/theme.css" layer(theme);
|
|
||||||
@import "tailwindcss/utilities.css" layer(utilities);
|
|
||||||
|
|
||||||
/* ── 作用域 token(深色深蓝,自带成色,不依赖宿主主题)─────────────────────── */
|
|
||||||
.parallel-agents {
|
|
||||||
--pa-bg: #0c1836; /* 页面背景 zs-004-09 */
|
|
||||||
--pa-card: #122347; /* 卡片 / 气泡容器 zs-004-08 */
|
|
||||||
--pa-muted: #16294d; /* 次级表面(步骤卡 / 并行列底) */
|
|
||||||
--pa-accent: #1a2f5a; /* hover / 软高亮 */
|
|
||||||
--pa-fg: #c5cfe0; /* 正文 zs-004-02 */
|
|
||||||
--pa-fg-muted: #6e88b0; /* 次级文字 zs-004-04 */
|
|
||||||
--pa-border: #2d4473; /* 容器边框 zs-004-06 */
|
|
||||||
--pa-input: #4a6491; /* 控件描边 */
|
|
||||||
--pa-primary: #1677ff; /* 主操作 zs-002-06 */
|
|
||||||
--pa-primary-fg: #ffffff;
|
|
||||||
--pa-chip-bg: #16335f; /* badge 底 */
|
|
||||||
--pa-chip-fg: #4096ff; /* badge 文字 */
|
|
||||||
--pa-amber: #f59e0b;
|
|
||||||
--pa-emerald: #10b981;
|
|
||||||
--pa-rose: #ef4444;
|
|
||||||
|
|
||||||
color: var(--pa-fg);
|
|
||||||
background-color: var(--pa-bg);
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.6;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.parallel-agents *,
|
|
||||||
.parallel-agents *::before,
|
|
||||||
.parallel-agents *::after {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 滚动条 */
|
|
||||||
.parallel-agents ::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
}
|
|
||||||
.parallel-agents ::-webkit-scrollbar-thumb {
|
|
||||||
background: color-mix(in oklch, var(--pa-primary) 40%, var(--pa-border));
|
|
||||||
border-radius: 999px;
|
|
||||||
}
|
|
||||||
.parallel-agents ::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: color-mix(in oklch, var(--pa-primary) 60%, var(--pa-border));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 语义颜色类(组件用这些上色,布局仍用 Tailwind 工具类)──────────────────── */
|
|
||||||
.parallel-agents .pa-bg {
|
|
||||||
background-color: var(--pa-bg);
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-card {
|
|
||||||
background-color: var(--pa-card);
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-surface {
|
|
||||||
background-color: var(--pa-muted);
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-surface-soft {
|
|
||||||
background-color: color-mix(in oklch, var(--pa-muted) 55%, transparent);
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-border {
|
|
||||||
border-color: var(--pa-border);
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-text {
|
|
||||||
color: var(--pa-fg);
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-text-muted {
|
|
||||||
color: var(--pa-fg-muted);
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-text-primary {
|
|
||||||
color: var(--pa-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 输入框 / 文本域 */
|
|
||||||
.parallel-agents .pa-input {
|
|
||||||
background-color: var(--pa-card);
|
|
||||||
border: 1px solid var(--pa-border);
|
|
||||||
color: var(--pa-fg);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-input::placeholder {
|
|
||||||
color: color-mix(in oklch, var(--pa-fg-muted) 80%, transparent);
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: color-mix(in oklch, var(--pa-primary) 55%, var(--pa-border));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 主按钮 / 次按钮 */
|
|
||||||
.parallel-agents .pa-btn-primary {
|
|
||||||
background-color: var(--pa-primary);
|
|
||||||
color: var(--pa-primary-fg);
|
|
||||||
border-radius: 8px;
|
|
||||||
transition: filter 0.15s ease;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-btn-primary:hover {
|
|
||||||
filter: brightness(1.08);
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-btn-primary:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-btn-ghost {
|
|
||||||
border: 1px solid var(--pa-border);
|
|
||||||
color: var(--pa-fg);
|
|
||||||
background-color: transparent;
|
|
||||||
border-radius: 8px;
|
|
||||||
transition: background-color 0.15s ease;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-btn-ghost:hover {
|
|
||||||
background-color: var(--pa-accent);
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-btn-ghost:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 状态徽标("实时输出" / "已完成" …)—— 对齐 Step2 confidence chip */
|
|
||||||
.parallel-agents .pa-badge {
|
|
||||||
background-color: var(--pa-chip-bg);
|
|
||||||
color: var(--pa-chip-fg);
|
|
||||||
border: 1px solid var(--pa-border);
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
||||||
font-weight: 800;
|
|
||||||
text-transform: uppercase;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 1px 6px;
|
|
||||||
font-size: 11px;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 席位状态角标色 */
|
|
||||||
.parallel-agents .pa-dot-running {
|
|
||||||
background-color: var(--pa-primary);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-dot-done {
|
|
||||||
background-color: var(--pa-emerald);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-dot-error {
|
|
||||||
background-color: var(--pa-rose);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 流式光标 + loading 点 */
|
|
||||||
.parallel-agents .pa-cursor {
|
|
||||||
display: inline-block;
|
|
||||||
width: 4px;
|
|
||||||
height: 13px;
|
|
||||||
margin-left: 2px;
|
|
||||||
vertical-align: middle;
|
|
||||||
background: var(--pa-primary);
|
|
||||||
animation: pa-pulse 1s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
@keyframes pa-pulse {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 0.25;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-bounce {
|
|
||||||
animation: pa-bounce 0.9s infinite;
|
|
||||||
}
|
|
||||||
@keyframes pa-bounce {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: translateY(-3px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@keyframes pa-spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-spin {
|
|
||||||
animation: pa-spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
@keyframes pa-ping {
|
|
||||||
75%,
|
|
||||||
100% {
|
|
||||||
transform: scale(1.8);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-ping {
|
|
||||||
animation: pa-ping 1.2s cubic-bezier(0, 0, 0.2, 1) infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Markdown 正文(对齐 Step2 sharedMarkdownComponents 的深色观感)──────────── */
|
|
||||||
.parallel-agents .pa-md {
|
|
||||||
color: var(--pa-fg);
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.7;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-md p {
|
|
||||||
margin: 0.35em 0;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-md h1,
|
|
||||||
.parallel-agents .pa-md h2,
|
|
||||||
.parallel-agents .pa-md h3,
|
|
||||||
.parallel-agents .pa-md h4 {
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0.7em 0 0.35em;
|
|
||||||
color: var(--pa-fg);
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-md h1 {
|
|
||||||
font-size: 1.3em;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-md h2 {
|
|
||||||
font-size: 1.18em;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-md h3 {
|
|
||||||
font-size: 1.06em;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-md ul,
|
|
||||||
.parallel-agents .pa-md ol {
|
|
||||||
margin: 0.35em 0;
|
|
||||||
padding-left: 1.4em;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-md li {
|
|
||||||
margin: 0.2em 0;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-md a {
|
|
||||||
color: var(--pa-chip-fg);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-md code {
|
|
||||||
background: var(--pa-muted);
|
|
||||||
border: 1px solid var(--pa-border);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.1em 0.35em;
|
|
||||||
font-size: 0.9em;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-md pre {
|
|
||||||
background: var(--pa-muted);
|
|
||||||
border: 1px solid var(--pa-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 0.75em;
|
|
||||||
overflow-x: auto;
|
|
||||||
margin: 0.5em 0;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-md pre code {
|
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-md blockquote {
|
|
||||||
border-left: 3px solid var(--pa-border);
|
|
||||||
padding-left: 0.75em;
|
|
||||||
color: var(--pa-fg-muted);
|
|
||||||
margin: 0.5em 0;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-md table {
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin: 0.5em 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-md th,
|
|
||||||
.parallel-agents .pa-md td {
|
|
||||||
border: 1px solid var(--pa-border);
|
|
||||||
padding: 0.35em 0.6em;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-md th {
|
|
||||||
background: var(--pa-muted);
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
/**
|
|
||||||
* 并行多智能体面板的共享类型。
|
|
||||||
*
|
|
||||||
* `Dialogue` 镜像圆桌 Step2 的 `Step2Dialogue`,外加 `agentId`(并行场景下用来把消息
|
|
||||||
* 归到对应席位列)。`StreamEvent` 是后端 `/api/parallel-agents/run/stream` 的统一事件协议。
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** 一条工具调用步骤(喂步骤条 / 思维链卡)。`key` 形如 `tool_calling:<toolName>`。 */
|
|
||||||
export interface DialogueStep {
|
|
||||||
id: string;
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
ts: string;
|
|
||||||
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;
|
|
||||||
/** 归属席位的 agent_id(并行列定位用)。 */
|
|
||||||
agentId: string;
|
|
||||||
sender: string;
|
|
||||||
avatarType: string;
|
|
||||||
content: string;
|
|
||||||
time: string;
|
|
||||||
/** 状态标签("实时输出" / "已完成" / "出错" 等)。 */
|
|
||||||
confidence: string;
|
|
||||||
streaming?: boolean;
|
|
||||||
steps?: DialogueStep[];
|
|
||||||
/** 该智能体在要澄清时的协作卡片载荷(有则渲染卡片)。 */
|
|
||||||
clarification?: ClarificationPayload;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 并行席位的视图状态(席位状态条用)。 */
|
|
||||||
export interface SeatView {
|
|
||||||
agentId: string;
|
|
||||||
name: string;
|
|
||||||
avatarType: string;
|
|
||||||
status: "pending" | "running" | "done" | "error";
|
|
||||||
/** 关联的 Dialogue.id。 */
|
|
||||||
bubbleId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 宿主传入的一个待并行调用的智能体。 */
|
|
||||||
export interface AgentInput {
|
|
||||||
agent_id: string;
|
|
||||||
name: string;
|
|
||||||
avatarType?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 后端合并 SSE 的统一事件。 */
|
|
||||||
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;
|
|
||||||
title: string;
|
|
||||||
brief?: string | null;
|
|
||||||
agents: AgentInput[];
|
|
||||||
messages?: Dialogue[];
|
|
||||||
created_at?: string | null;
|
|
||||||
updated_at?: string | null;
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
/// <reference types="vite/client" />
|
|
||||||
|
|
||||||
declare module "*.vue" {
|
|
||||||
import type { DefineComponent } from "vue";
|
|
||||||
const component: DefineComponent<{}, {}, any>;
|
|
||||||
export default component;
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2020",
|
|
||||||
"useDefineForClassFields": true,
|
|
||||||
"module": "ESNext",
|
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"jsx": "preserve",
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"types": ["vite/client"],
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": { "@/*": ["./src/*"] }
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { fileURLToPath, URL } from "node:url";
|
|
||||||
import { defineConfig } from "vite";
|
|
||||||
import vue from "@vitejs/plugin-vue";
|
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
|
||||||
|
|
||||||
// 两种构建:
|
|
||||||
// - 默认(`vite build` / `vite dev`):独立 demo 应用(index.html)。
|
|
||||||
// - `--mode lib`(`pnpm build:lib`):组件库,产出 ESM + UMD + 一个 style.css,
|
|
||||||
// 供其它 Vue 项目 `import { ParallelAgentsPanel } from "parallel-agents-vue"`。
|
|
||||||
export default defineConfig(({ mode }) => {
|
|
||||||
const isLib = mode === "lib";
|
|
||||||
return {
|
|
||||||
plugins: [vue(), tailwindcss()],
|
|
||||||
resolve: {
|
|
||||||
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
|
|
||||||
},
|
|
||||||
server: { port: 5183 },
|
|
||||||
build: isLib
|
|
||||||
? {
|
|
||||||
lib: {
|
|
||||||
entry: fileURLToPath(new URL("./src/index.ts", import.meta.url)),
|
|
||||||
name: "ParallelAgentsVue",
|
|
||||||
fileName: "parallel-agents-vue",
|
|
||||||
},
|
|
||||||
rollupOptions: {
|
|
||||||
// vue 由宿主提供,不打进库。
|
|
||||||
external: ["vue"],
|
|
||||||
output: {
|
|
||||||
exports: "named",
|
|
||||||
globals: { vue: "Vue" },
|
|
||||||
assetFileNames: "parallel-agents-vue.[ext]",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
cssCodeSplit: false,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
outDir: "dist-app",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
node-linker=hoisted
|
|
||||||
package-import-method=copy
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# 内网离线开发包说明
|
|
||||||
|
|
||||||
本压缩包已包含 **完整 hoisted 模式 node_modules**(物理文件复制,无 pnpm 符号链接),解压后可直接开发,无需联网安装依赖。
|
|
||||||
|
|
||||||
## 环境要求
|
|
||||||
|
|
||||||
- Node.js 18+(建议 20 LTS)
|
|
||||||
- 全局安装 pnpm:`npm install -g pnpm`(内网可离线安装 pnpm 的 tgz 包)
|
|
||||||
|
|
||||||
## 使用步骤
|
|
||||||
|
|
||||||
1. 解压到任意目录(路径尽量短,避免 Windows 路径过长问题)
|
|
||||||
2. 进入项目目录
|
|
||||||
3. 直接运行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm dev # 开发调试 http://localhost:5183
|
|
||||||
pnpm build:lib # 构建组件库
|
|
||||||
pnpm typecheck # 类型检查
|
|
||||||
```
|
|
||||||
|
|
||||||
## 若 node_modules 损坏,可离线重装
|
|
||||||
|
|
||||||
本包已附带 `.npmrc`(hoisted + copy 模式)。若需重建依赖:
|
|
||||||
|
|
||||||
1. 删除 `node_modules`
|
|
||||||
2. 在有 pnpm 本地 store 的机器上执行:`pnpm install --offline --frozen-lockfile`
|
|
||||||
|
|
||||||
> 若内网机器从未装过依赖,请直接使用本包自带的 node_modules,不要删除。
|
|
||||||
|
|
||||||
## 与直接压缩 node_modules 的区别
|
|
||||||
|
|
||||||
pnpm 默认 node_modules 含大量符号链接/硬链接,WinRAR 等工具压缩后解压会断链导致缺依赖。
|
|
||||||
本包使用 `node-linker=hoisted` + `package-import-method=copy` 重新安装,所有文件均为真实拷贝。
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# parallel-agents-vue
|
|
||||||
|
|
||||||
并行多智能体面板 —— Vue3 可嵌入组件。输入「任务名称 + 简介」,**并行调用多个智能体**
|
|
||||||
(智能体清单由宿主传入,运行期间后端固定**禁止写文件**),消息列表照搬 DeerFlow 圆桌
|
|
||||||
研讨第二步并行多席位的**深色样式与完整结构**(步骤条 / 并行双列 / 席位状态条),支持
|
|
||||||
**保存聊天记录 + 回显**与**重新分析**。
|
|
||||||
|
|
||||||
## 开发调试
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm install
|
|
||||||
pnpm dev # http://localhost:5183 ,填后端 baseUrl / token / 智能体 id 即可联调
|
|
||||||
```
|
|
||||||
|
|
||||||
后端需要一个跑着的 DeerFlow 网关(默认 `http://localhost:8001`),它提供:
|
|
||||||
|
|
||||||
- `POST /api/parallel-agents/run/stream` —— 并行扇出,合并 SSE(事件带 `agent_id`);
|
|
||||||
- `/api/parallel-agent-tasks` —— 任务保存 / 回显 CRUD。
|
|
||||||
|
|
||||||
## 构建为组件库
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm build:lib # 产出 dist/parallel-agents-vue.{js,umd.cjs} + dist/parallel-agents-vue.css
|
|
||||||
```
|
|
||||||
|
|
||||||
## 嵌入其它 Vue 项目
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { ParallelAgentsPanel } from "parallel-agents-vue";
|
|
||||||
import "parallel-agents-vue/style.css";
|
|
||||||
```
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<!-- 放进你自己的抽屉 / 弹窗里,面板会自适应容器(h-full w-full)。
|
|
||||||
接口地址走配置文件默认(src/config.ts),登录只需把用户名传进来(免密自动登录)。 -->
|
|
||||||
<YourDrawer v-model="open">
|
|
||||||
<div style="height: 80vh">
|
|
||||||
<ParallelAgentsPanel
|
|
||||||
:username="currentUsername"
|
|
||||||
:agents="[
|
|
||||||
{ agent_id: 'roundtable-intelligence', name: '情报收集' },
|
|
||||||
{ agent_id: 'roundtable-risk-review', name: '风险审查' },
|
|
||||||
]"
|
|
||||||
@saved="onSaved"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</YourDrawer>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 接口地址 & 登录
|
|
||||||
|
|
||||||
- **接口地址**写在配置文件 `src/config.ts` 的 `DEFAULT_BASE_URL`(dev 可用 `VITE_PA_BASE_URL` 覆盖);
|
|
||||||
也可用 `:base-url` prop 覆盖。
|
|
||||||
- **登录**:把宿主侧的**用户名**通过 `:username` 传进来即可,组件会自动走 DeerFlow 的
|
|
||||||
**免密「用户名登录」**端点(`POST /api/v1/auth/login/username`,公共 + CSRF 豁免)换取 token
|
|
||||||
并缓存、自动续期(401 重登)。**不需要宿主自己管 token**。
|
|
||||||
- 若你已有 token,也可直接传 `:token` / `:get-token`(优先级高于 username)。
|
|
||||||
- 若后端开了口令门(`auth_login.username_login.require_password=true`),再传 `:login-password`。
|
|
||||||
|
|
||||||
### Props
|
|
||||||
|
|
||||||
| prop | 类型 | 说明 |
|
|
||||||
| --------------- | -------------------------------------- | ----------------------------------------------------- |
|
|
||||||
| `agents` | `{agent_id,name,avatarType?}[]`(必填)| 要并行调用的智能体 |
|
|
||||||
| `username` | `string` | 免密自动登录用户名(推荐方式,宿主自己传) |
|
|
||||||
| `baseUrl` | `string` | 后端地址;不传用 `config.ts` 默认 |
|
|
||||||
| `token` | `string` | 已有 Bearer token(与 `username` 二选一,优先) |
|
|
||||||
| `getToken` | `() => string \| Promise<string>` | 动态取 token |
|
|
||||||
| `loginPassword` | `string` | 仅当后端开启口令门时需要 |
|
|
||||||
| `taskId` | `string` | 传入则挂载时自动加载并回显该已保存任务 |
|
|
||||||
| `model` | `string` | 可选模型名(不传用 `config.ts` 默认 / 后端回退) |
|
|
||||||
| `dark` | `boolean`(默认 `true`) | 深色面板(自带成色,不依赖宿主主题) |
|
|
||||||
|
|
||||||
### 事件
|
|
||||||
|
|
||||||
- `@saved="(task) => ..."` —— 保存成功(含新建/更新)后回传 `SavedTask`。
|
|
||||||
- `@update:taskId="(id) => ..."` —— 当前任务 id 变化(保存 / 加载历史时)。
|
|
||||||
|
|
||||||
> 样式作用域全部包在根类 `.parallel-agents` 下,且**不引入 Tailwind preflight**,不会污染宿主全局样式。
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# 并行多智能体面板 · 测试用例总览
|
|
||||||
|
|
||||||
本目录是 `parallel-agents-vue` 的**手工测试用例**(Markdown)。覆盖:并行调用与流式、文件写入禁用、
|
|
||||||
保存与回显、重新分析、免密登录与鉴权、嵌入与深色样式。
|
|
||||||
|
|
||||||
## 用例清单
|
|
||||||
|
|
||||||
| 文件 | 主题 |
|
|
||||||
| -------------------------------------------- | ------------------------------------- |
|
|
||||||
| [01-并行调用与流式.md](./01-并行调用与流式.md) | 并行扇出、流式输出、步骤条、席位状态条、禁写文件 |
|
|
||||||
| [02-保存与回显.md](./02-保存与回显.md) | 保存聊天记录、历史列表、加载回显、更新保存 |
|
|
||||||
| [03-重新分析.md](./03-重新分析.md) | 重新分析(覆盖重跑)、停止、空输入校验 |
|
|
||||||
| [04-免密登录与鉴权.md](./04-免密登录与鉴权.md) | username 免密登录、token 优先级、401 重登、口令门 |
|
|
||||||
| [05-嵌入与深色样式.md](./05-嵌入与深色样式.md) | 配置文件接口地址、抽屉/弹窗嵌入、深色自适应、不污染宿主 |
|
|
||||||
| [06-可用智能体清单.md](./06-可用智能体清单.md) | **测试用真实 agent_id 清单(可直接复制粘贴)** + 示例任务 |
|
|
||||||
| [07-使用说明.md](./07-使用说明.md) | **在其它 Vue 项目中引入哪些组件、如何使用**(props/事件/嵌入) |
|
|
||||||
| [08-开发文档.md](./08-开发文档.md) | **二次开发**:架构/数据流/事件协议/目录职责/后端契约/扩展指南 |
|
|
||||||
|
|
||||||
## 测试环境
|
|
||||||
|
|
||||||
| 组件 | 地址 / 命令 |
|
|
||||||
| --------------- | ------------------------------------------------------ |
|
|
||||||
| DeerFlow 后端 | `http://localhost:8001`(`make dev`,需含本功能的新路由)|
|
|
||||||
| Vue dev demo | `http://localhost:5183`(`pnpm dev`) |
|
|
||||||
| 接口地址配置 | `src/config.ts` 的 `DEFAULT_BASE_URL` |
|
|
||||||
|
|
||||||
## 前置准备(所有用例通用)
|
|
||||||
|
|
||||||
1. 后端已启动,且包含本功能:
|
|
||||||
- `POST /api/parallel-agents/run/stream`(并行扇出,合并 SSE)
|
|
||||||
- `GET/POST/PUT/DELETE /api/parallel-agent-tasks`(任务保存/回显)
|
|
||||||
- `POST /api/v1/auth/login/username`(免密登录,公共)
|
|
||||||
- 数据表 `parallel_agent_tasks` 已建(`alembic upgrade head` 或 `create_all`)。
|
|
||||||
2. 至少准备 **2 个真实存在的 agent_id**(必须是后端注册过的真实 id,不能只写名字)。
|
|
||||||
本环境可直接用:`roundtable-solution-design`(方案设计)、`roundtable-risk-review`(风险审查)等
|
|
||||||
—— 完整可复制清单见 [06-可用智能体清单.md](./06-可用智能体清单.md)。
|
|
||||||
3. 打开 dev demo `http://localhost:5183`,填:
|
|
||||||
- 后端 baseUrl:`http://localhost:8001`
|
|
||||||
- 用户名:`demo`(免密自动登录)
|
|
||||||
- 智能体:每行 `agent_id, 显示名`
|
|
||||||
点「打开面板(弹窗内)」。
|
|
||||||
|
|
||||||
## 用例字段约定
|
|
||||||
|
|
||||||
- **用例ID**:`PA-<模块>-<序号>`
|
|
||||||
- **优先级**:P0(核心,必过)/ P1(重要)/ P2(边界)
|
|
||||||
- **前置条件 → 操作步骤 → 预期结果**:逐条核对,全部命中才算通过。
|
|
||||||
- **结果记录**:建议在「实际结果 / 通过?」列里回填。
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
# 测试用例 · 并行调用与流式
|
|
||||||
|
|
||||||
覆盖:并行扇出多个智能体、流式逐字输出、步骤条(工具调用)、点头像切换显示(单选)、席位状态条、
|
|
||||||
运行期间**禁止写文件**。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-RUN-01 · 并行调用多个智能体(P0)
|
|
||||||
|
|
||||||
**前置条件**
|
|
||||||
- 面板已打开,填入任务名「竞品调研」、简介「对比 A、B 两款产品的核心功能与定价」。
|
|
||||||
- 传入 2 个智能体(情报收集、风险审查)。
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 点击「开始分析」。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 顶部出现**席位状态条**,显示「并行 2 席」,两个席位头像角标先后转为「进行中」(转圈)。
|
|
||||||
- 两个智能体**同时**开始执行(并行,而非一个跑完再跑下一个)——可在状态条上看到都在「进行中」。
|
|
||||||
- 会话区每次显示**一个**席位的消息列表(默认第 1 个),点头像可切换查看另一个(见 PA-RUN-06)。
|
|
||||||
- 气泡头部显示:头像、智能体名、状态徽标「并行执行中」、时间。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-RUN-02 · 流式逐字输出 + 光标(P0)
|
|
||||||
|
|
||||||
**前置条件**:PA-RUN-01 正在运行中。
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 观察当前显示席位的气泡正文(点头像可切换看其它席位)。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 正文**逐步增量**出现(流式),不是一次性整段刷出。
|
|
||||||
- 正在输出的气泡末尾有**闪烁光标**(`.pa-cursor`)。
|
|
||||||
- 内容还没来时,显示 loading 占位(转圈 + 跳动圆点 + 「并行执行中」文案)。
|
|
||||||
- Markdown 能正确渲染(标题/列表/代码块/表格按 `.pa-md` 样式)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-RUN-03 · 步骤条(思维链 / 工具调用)(P1)
|
|
||||||
|
|
||||||
**前置条件**:智能体会调用工具(如检索类技能)。
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 运行一轮,观察气泡顶部的「执行步骤」折叠卡。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 出现「执行步骤(N)」折叠卡(`MessageStepsCard`),点击可展开/收起。
|
|
||||||
- 每条步骤显示:工具标签(如「调用工具 xxx」)、参数摘要(如有 `path` 优先显示)、时间。
|
|
||||||
- 同一工具调用**不重复**出条目(按 key 去重)。
|
|
||||||
- 若智能体本轮不调工具,则不出现步骤卡(属正常)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-RUN-04 · 席位状态流转(P1)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 运行一轮,全程观察席位状态条各头像角标。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 角标状态依次:灰(pending)→ 蓝色转圈(running)→ 绿色对勾(done)。
|
|
||||||
- 某智能体失败时其角标变**红色叉**(error),且对应气泡显示「该智能体执行出错:…」、状态徽标「出错」。
|
|
||||||
- 单个智能体失败**不影响**其它智能体继续完成(互不拖累)。
|
|
||||||
- 全部结束后,运行按钮从「停止」恢复为「重新分析」。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-RUN-05 · 运行期间禁止写文件(P0)
|
|
||||||
|
|
||||||
**目的**:核对后端 `seat_parallel` 策略生效——并行席位被禁用 `write_file` / `str_replace` / `bash` 等。
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 简介里**故意要求写文件**,例如:「请把结果保存成一个 .md 文件」。
|
|
||||||
2. 点击「开始分析」,等待完成。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 智能体**不会**真正落地文件,产出**直接呈现在气泡正文**里。
|
|
||||||
- 步骤条里**不出现** `write_file` / `bash` / `str_replace` 工具调用(被策略排除)。
|
|
||||||
- 可旁证:后端日志中该席位 `role=seat_parallel`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-RUN-06 · 点头像切换显示(单选)(P1)
|
|
||||||
|
|
||||||
**前置条件**:传入 ≥2 个智能体并运行。
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 点击席位状态条上的某个头像。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 主区**切换为只显示**该智能体的消息列表(**单选**,每次显示一个)。
|
|
||||||
- 被选中的头像高亮(外发光描边);再点别的头像即切到那个。
|
|
||||||
- 切换不影响后台并行执行:未显示的智能体仍在跑/已完成,其增量在切回时能看到。
|
|
||||||
- 默认显示第 1 个席位。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-RUN-07 · 后端不可达 / 智能体 id 非法(P2)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 把 baseUrl 改成一个打不通的地址,或传入不存在的 agent_id,点「开始分析」。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 顶部出现红色错误提示(请求失败信息),不白屏、不卡死。
|
|
||||||
- 非法 agent_id 时,对应席位以 error 角标 + 出错气泡呈现,其它正常席位不受影响。
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
# 测试用例 · 保存与回显
|
|
||||||
|
|
||||||
覆盖:保存聊天记录(新建/更新)、历史列表、加载已存任务并回显、`taskId` 自动加载、跨刷新持久化。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-SAVE-01 · 首次保存(新建)(P0)
|
|
||||||
|
|
||||||
**前置条件**:已跑完一轮、会话区有结果。任务名「竞品调研」。
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 点击「保存」。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 按钮短暂显示「保存中…」,成功后变为「更新保存」(说明已绑定 taskId)。
|
|
||||||
- 触发 `@saved` 事件,回传 `SavedTask`(含 id、title、agents、messages)。
|
|
||||||
- 「历史」下拉里出现这条记录(标题 + 更新时间)。
|
|
||||||
- 后端 `parallel_agent_tasks` 表新增一行(owner = 当前登录用户)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-SAVE-02 · 再次保存(更新覆盖)(P0)
|
|
||||||
|
|
||||||
**前置条件**:PA-SAVE-01 已保存、当前仍是同一任务。
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 修改任务名 / 简介,或重新分析得到新结果。
|
|
||||||
2. 点击「更新保存」。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- **不新增**记录,而是更新原 taskId 那条(title / brief / agents / messages 被覆盖为最新)。
|
|
||||||
- 历史列表里该条的更新时间刷新、排到最前。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-SAVE-03 · 历史加载并回显(P0)
|
|
||||||
|
|
||||||
**前置条件**:历史里至少有 1 条已保存任务。
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 点击「历史 ▾」展开列表。
|
|
||||||
2. 选择一条历史任务。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 任务名、简介**回填**为该任务保存时的值。
|
|
||||||
- 会话区**完整回显**保存时的消息列表(各智能体气泡、正文、步骤条),且**均为非流式终态**(无光标、无 loading)。
|
|
||||||
- 席位状态条据消息重建:正常席位显示 done(绿勾),出错的显示 error(红叉)。
|
|
||||||
- 当前 taskId 切换为该任务(再点「保存」是「更新保存」该条)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-SAVE-04 · taskId 进入即自动回显(P1)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 以 `:task-id="<已存在任务id>"` 方式挂载面板(嵌入场景)。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 面板挂载后**自动加载**该任务并回显(等同于 PA-SAVE-03 的结果),无需手动点历史。
|
|
||||||
- `@update:taskId` 随之触发。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-SAVE-05 · 跨刷新持久化(P1)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 保存一条任务后,刷新浏览器(或重开宿主页面)。
|
|
||||||
2. 用相同用户名进入,打开历史。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 历史里仍能看到之前保存的任务(数据存在后端、按用户隔离,非浏览器 localStorage)。
|
|
||||||
- 加载后能正常回显。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-SAVE-06 · 无结果时禁止保存(P2)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 新开面板、尚未运行任何分析。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 「保存」按钮**禁用**(无可保存内容)。
|
|
||||||
- 运行中也不应因保存导致状态错乱(保存按钮在有结果后才可用)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-SAVE-07 · 用户隔离(P1)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 用户名 A 保存一条任务。
|
|
||||||
2. 切换用户名为 B(重新进入面板),打开历史。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- B 的历史里**看不到** A 的任务。
|
|
||||||
- B 直接用 A 的 taskId 加载应失败(404 / 无内容),不报错崩溃。
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
# 测试用例 · 重新分析与运行控制
|
|
||||||
|
|
||||||
覆盖:重新分析(覆盖重跑)、运行中停止、空输入校验、运行态按钮切换。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-RE-01 · 重新分析(覆盖重跑)(P0)
|
|
||||||
|
|
||||||
**前置条件**:已跑完一轮、会话区有结果(按钮显示「重新分析」)。
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. (可选)修改任务名 / 简介。
|
|
||||||
2. 点击「重新分析」。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- **清空**上一轮的所有气泡与席位状态,按当前输入**重新并行**调用各智能体。
|
|
||||||
- 不会和上一轮结果混在一起(旧消息被替换,不是追加)。
|
|
||||||
- 重跑过程与首跑一致(流式、单列消息 + 点头像切换、步骤条、席位状态流转)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-RE-02 · 运行中按钮为「停止」(P1)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 开始分析,运行未结束时观察操作区。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 运行期间,主按钮显示「停止」(不再是「开始/重新分析」)。
|
|
||||||
- 「保存」按钮在运行中可点(保存当前已产出的内容)或按实现禁用——以不报错为准。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-RE-03 · 停止当前运行(P0)
|
|
||||||
|
|
||||||
**前置条件**:正在运行中。
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 点击「停止」。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 进行中的请求被取消(AbortController),不再有新增量进来。
|
|
||||||
- 仍在 streaming 的气泡被收尾为终态(去掉光标,徽标置「已完成」)。
|
|
||||||
- 按钮恢复为「重新分析」,可立即再次发起。
|
|
||||||
- 控制台不应出现未捕获的 AbortError 报错(已被吞掉)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-RE-04 · 空输入校验(P1)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 任务名与简介**都留空**,点击「开始分析」。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 按钮处于**禁用**态,点击无效(`canStart` 为假需任务名或简介至少其一 + 至少一个智能体)。
|
|
||||||
- 只填任务名或只填简介,按钮可用。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-RE-05 · 未传智能体(P2)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. `:agents="[]"`(空数组)挂载面板。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 操作区显示提示「⚠️ 未传入任何智能体(agents 为空)」。
|
|
||||||
- 「开始分析」禁用,不会发起空请求。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-RE-06 · 连续多次重新分析(P2)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 连续点击「重新分析」若干次(每次等上一轮结束或中途停止再点)。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 每次都干净重置 + 重跑,无残留旧气泡、无席位状态错乱。
|
|
||||||
- 不出现多份 SSE 叠加导致的内容串列 / 重复。
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
# 测试用例 · 免密登录与鉴权
|
|
||||||
|
|
||||||
覆盖:username 免密自动登录、token 优先级、token 缓存与 401 重登、口令门、CSRF 绕过。
|
|
||||||
|
|
||||||
> 背景:面板优先用宿主传入的 token;没有 token 时,用宿主传入的 **username** 走 DeerFlow 公共免密端点
|
|
||||||
> `POST /api/v1/auth/login/username` 换取 `access_token`,以 `Authorization: Bearer` 发起后续请求
|
|
||||||
> (带 Bearer 会绕过 CSRF)。token 解析优先级:`token` > `getToken` > 缓存 > `username` 登录。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-AUTH-01 · username 免密自动登录(P0)
|
|
||||||
|
|
||||||
**前置条件**:后端 `auth_login.username_login.require_password = false`(默认)。
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 只传 `:username="demo"`(不传 token),打开面板。
|
|
||||||
2. 开始分析 / 打开历史。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 面板能正常拉历史、能发起并行流式——说明已用 username 换到 token 并附带在请求头。
|
|
||||||
- 旁证(手工 curl):
|
|
||||||
```bash
|
|
||||||
curl -s -X POST -H "Content-Type: application/json" \
|
|
||||||
-d '{"username":"demo"}' http://localhost:8001/api/v1/auth/login/username
|
|
||||||
# → 返回 {"access_token":"...","token_type":"bearer",...}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-AUTH-02 · token 优先级高于 username(P1)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 同时传 `:token="<有效token>"` 和 `:username="demo"`。
|
|
||||||
2. 触发请求(开始分析 / 历史)。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 直接用传入的 `token`(不会再去调登录端点)。
|
|
||||||
- `:get-token` 同理优先于 username(动态取 token)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-AUTH-03 · token 缓存(不重复登录)(P1)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 只传 username,先后触发多次请求(拉历史 → 开始分析 → 保存)。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 登录端点**只调用一次**(首个请求),后续请求复用缓存 token(按 `baseUrl::username` 缓存)。
|
|
||||||
- 可在浏览器 Network 面板核对 `login/username` 只出现一次。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-AUTH-04 · 401 自动重登重试(P1)
|
|
||||||
|
|
||||||
**前置条件**:能制造 token 失效(例如等待过期,或后端清理)。
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 用 username 登录并缓存 token 后,使其失效,再触发一次 CRUD 请求。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 首次请求收到 401 → 面板**清缓存并用 username 重登一次**,再自动重试该请求。
|
|
||||||
- 用户无感知(不需手动重新登录),请求最终成功。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-AUTH-05 · 口令门(require_password=true)(P2)
|
|
||||||
|
|
||||||
**前置条件**:后端把 `auth_login.username_login.require_password` 设为 `true`、配置 `password`。
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 只传 username、不传 `loginPassword` → 触发请求。
|
|
||||||
2. 再传上正确的 `:login-password="<口令>"` → 触发请求。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 不传口令时:登录返回 401「口令错误」,面板顶部红色错误提示,不崩溃。
|
|
||||||
- 传对口令时:登录成功,后续请求正常。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-AUTH-06 · Bearer 绕过 CSRF(P1)
|
|
||||||
|
|
||||||
**目的**:核对带 Bearer 的 POST 不被 CSRF 拦截。
|
|
||||||
|
|
||||||
**操作步骤(手工 curl 旁证)**
|
|
||||||
```bash
|
|
||||||
TOKEN=... # 上面登录拿到的 access_token
|
|
||||||
# 带 Bearer 的 POST:参数非法应为 400/422,而非 403(403=CSRF 拦截)
|
|
||||||
curl -s -o /dev/null -w "%{http_code}\n" -X POST \
|
|
||||||
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
|
||||||
-d '{}' http://localhost:8001/api/parallel-agents/run/stream
|
|
||||||
```
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 返回 **400/422**(业务参数校验),**不是 403**——证明 Bearer 已绕过 CSRF。
|
|
||||||
- 不带任何鉴权时该 POST 返回 401/403(被拦截),属正常。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-AUTH-07 · 未传 token 且未传 username(P2)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 既不传 token / getToken,也不传 username,触发需要鉴权的请求。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 请求因缺少鉴权失败(401/403),面板给出可读的错误提示(不白屏)。
|
|
||||||
- 控制台/提示能反映「缺少 token 且未提供 username,无法登录」之类信息。
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
# 测试用例 · 嵌入与深色样式
|
|
||||||
|
|
||||||
覆盖:接口地址走配置文件、组件库构建产物、嵌入其它 Vue 项目、放进抽屉/弹窗自适应、深色成色、
|
|
||||||
不污染宿主全局样式。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-EMBED-01 · 接口地址走配置文件(P0)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 不传 `:base-url`,只改 `src/config.ts` 的 `DEFAULT_BASE_URL` 为目标后端地址。
|
|
||||||
2. 运行面板触发请求。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 请求打到配置文件里的地址。
|
|
||||||
- 传 `:base-url` prop 时**优先**用 prop(覆盖配置默认)。
|
|
||||||
- dev 下设 `VITE_PA_BASE_URL` 环境变量也能覆盖默认。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-EMBED-02 · 组件库构建产物(P0)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
```bash
|
|
||||||
pnpm build:lib
|
|
||||||
```
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- `dist/` 产出:`parallel-agents-vue.js`(ESM)、`parallel-agents-vue.umd.cjs`(UMD)、`parallel-agents-vue.css`。
|
|
||||||
- `vue` 被 external(不打进库,由宿主提供)。
|
|
||||||
- 构建无报错、无「named + default exports」警告。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-EMBED-03 · 嵌入其它 Vue 项目(P0)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 在另一个 Vue3 项目里:
|
|
||||||
```ts
|
|
||||||
import { ParallelAgentsPanel } from "parallel-agents-vue";
|
|
||||||
import "parallel-agents-vue/style.css";
|
|
||||||
```
|
|
||||||
2. 模板中放置:
|
|
||||||
```vue
|
|
||||||
<ParallelAgentsPanel :username="user" :agents="agents" />
|
|
||||||
```
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 组件正常渲染、可交互(深色面板,与 demo 一致)。
|
|
||||||
- 只需 `:username` + `:agents` 两个 prop 即可跑起来(接口地址走配置)。
|
|
||||||
- 也支持 `app.use(ParallelAgentsVue)` 全局注册后用 `<ParallelAgentsPanel/>`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-EMBED-04 · 放进抽屉 / 弹窗自适应(P0)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 把面板放进宿主的抽屉 / 对话框,外层给一个固定高度容器:
|
|
||||||
```vue
|
|
||||||
<YourDrawer v-model="open">
|
|
||||||
<div style="height: 80vh"><ParallelAgentsPanel :username="user" :agents="agents" /></div>
|
|
||||||
</YourDrawer>
|
|
||||||
```
|
|
||||||
2. 改变容器尺寸 / 视口大小。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 面板**充满容器**(根元素 `h-full w-full flex flex-col min-h-0`),不溢出、不塌缩。
|
|
||||||
- 会话区内部独立滚动,输入区与席位条固定不被挤掉。
|
|
||||||
- 窄容器下消息列表正常显示、内部滚动,不溢出。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-EMBED-05 · 深色成色自带、与宿主主题无关(P1)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 分别在**浅色**宿主页和**深色**宿主页里嵌入面板。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 面板始终是深蓝深色(自带 `--pa-*` token,作用域在 `.parallel-agents`),不随宿主明暗变化。
|
|
||||||
- 文案、徽标、步骤卡、并行列底、滚动条等观感与圆桌 Step2 深色一致。
|
|
||||||
- 传 `:dark="false"` 时按实现切换(如未实现浅色则保持深色,至少不报错)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-EMBED-06 · 不污染宿主全局样式(P0)
|
|
||||||
|
|
||||||
**目的**:核对**未引入 Tailwind preflight**(不重置宿主全局标签样式)。
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 在一个有自己样式的宿主页里嵌入面板(页面有 `h1/p/button/ul` 等默认样式)。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 嵌入后,宿主页面**自身**的 `h1/p/button/列表/边距` 等样式**不被改变**(没有被 Tailwind 的 reset 抹平)。
|
|
||||||
- 面板内部样式正常(不依赖宿主提供 Tailwind)。
|
|
||||||
- 类名都在 `.parallel-agents` 作用域下,不与宿主类名冲突。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PA-EMBED-07 · 多实例并存(P2)
|
|
||||||
|
|
||||||
**操作步骤**
|
|
||||||
1. 同一页面放置 2 个面板实例(不同 agents / 不同 username)。
|
|
||||||
|
|
||||||
**预期结果**
|
|
||||||
- 两个实例各自独立运行、各自保存/回显,互不串流、互不串 token 缓存(缓存按 `baseUrl::username` 区分)。
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
# 测试用 · 可用智能体清单(可直接复制粘贴)
|
|
||||||
|
|
||||||
测试时面板需要传入**真实存在的 `agent_id`**(后端真正拿去跑的标识,不能只写名字)。
|
|
||||||
下面是当前后端里**可直接用于测试**的内置智能体(已排除 intent / recommender / coordinator 这些系统角色)。
|
|
||||||
|
|
||||||
> 注:`name` 只是显示用、可省略;`agent_id` 必须照抄。下列为本环境实测可用的内置智能体;
|
|
||||||
> 你部署里的智能体可能不同,用文末方法自查。
|
|
||||||
|
|
||||||
## 可用智能体
|
|
||||||
|
|
||||||
| agent_id | 显示名 |
|
|
||||||
| --------------------------- | -------- |
|
|
||||||
| `roundtable-solution-design`| 方案设计 |
|
|
||||||
| `roundtable-risk-review` | 风险审查 |
|
|
||||||
| `roundtable-execution-plan` | 执行规划 |
|
|
||||||
| `roundtable-report` | 方案总结 |
|
|
||||||
| `demo-expert` | 演示专家 |
|
|
||||||
|
|
||||||
## A. 粘贴进 dev demo 页「智能体」输入框(每行 `agent_id, 显示名`)
|
|
||||||
|
|
||||||
推荐至少 2 个以便点头像切换查看不同智能体:
|
|
||||||
|
|
||||||
```
|
|
||||||
roundtable-solution-design, 方案设计
|
|
||||||
roundtable-risk-review, 风险审查
|
|
||||||
```
|
|
||||||
|
|
||||||
想多看几列就多贴几行:
|
|
||||||
|
|
||||||
```
|
|
||||||
roundtable-solution-design, 方案设计
|
|
||||||
roundtable-risk-review, 风险审查
|
|
||||||
roundtable-execution-plan, 执行规划
|
|
||||||
```
|
|
||||||
|
|
||||||
## B. 粘贴进组件 `:agents` prop(嵌入场景)
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const agents = [
|
|
||||||
{ agent_id: "roundtable-solution-design", name: "方案设计" },
|
|
||||||
{ agent_id: "roundtable-risk-review", name: "风险审查" },
|
|
||||||
{ agent_id: "roundtable-execution-plan", name: "执行规划" },
|
|
||||||
];
|
|
||||||
```
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<ParallelAgentsPanel :username="'demo'" :agents="agents" />
|
|
||||||
```
|
|
||||||
|
|
||||||
## C. 配一个示例任务(直接粘贴到面板)
|
|
||||||
|
|
||||||
- **任务名称**:`新功能上线方案评估`
|
|
||||||
- **任务简介**:
|
|
||||||
```
|
|
||||||
我们要给 App 上线「扫码点餐」功能。请从你的专业角度,评估方案可行性、列出关键风险、
|
|
||||||
并给出落地步骤与里程碑。
|
|
||||||
```
|
|
||||||
|
|
||||||
## 自查:列出你部署里所有可用的 agent_id
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1) 免密登录拿 token(用户名换成你的)
|
|
||||||
TOKEN=$(curl -s -X POST -H "Content-Type: application/json" \
|
|
||||||
-d '{"username":"demo"}' http://localhost:8001/api/v1/auth/login/username \
|
|
||||||
| python -c "import sys,json;print(json.load(sys.stdin)['access_token'])")
|
|
||||||
|
|
||||||
# 2) 列出 agent_id <TAB> name
|
|
||||||
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8001/api/agents -o agents.json
|
|
||||||
python -c "import json,io;[print(a['id'],'\t',a['name']) for a in json.load(io.open('agents.json',encoding='utf-8')).get('agents',[])]"
|
|
||||||
```
|
|
||||||
|
|
||||||
第一列 `id` 才是要填的 `agent_id`。挑你需要的智能体即可(避开 `roundtable-intent` /
|
|
||||||
`roundtable-recommender` / `roundtable-coordinator*` 这些系统角色——它们不是用来当并行席位的)。
|
|
||||||
@@ -1,345 +0,0 @@
|
|||||||
# 使用说明 · 在其它 Vue 项目中引入
|
|
||||||
|
|
||||||
本组件库只对外暴露**一个**组件 `ParallelAgentsPanel`(其余 `MessageBubble` / `MessageStepsCard` /
|
|
||||||
`ParallelSeatStrip` / `Avatar` / `MarkdownContent` 都是它的内部子组件,**无需也不要单独引入**)。
|
|
||||||
宿主只管把它放进自己的页面 / 抽屉 / 弹窗,并传几个 prop。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、要引入的东西(就这两样)
|
|
||||||
|
|
||||||
1. **组件**:`ParallelAgentsPanel`
|
|
||||||
2. **样式**:`parallel-agents-vue/style.css`(一次性引入即可,样式作用域全在 `.parallel-agents` 下)
|
|
||||||
|
|
||||||
> 依赖说明:`vue ^3.4`(宿主提供,库不打包 vue);`markdown-it` 已打进库内,无需宿主安装。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、两种引入方式
|
|
||||||
|
|
||||||
### 方式 A:构建成库后按包引入(推荐)
|
|
||||||
|
|
||||||
1. 在本项目构建:
|
|
||||||
```bash
|
|
||||||
pnpm install
|
|
||||||
pnpm build:lib # 产出 dist/parallel-agents-vue.{js,umd.cjs,css}
|
|
||||||
```
|
|
||||||
2. 宿主项目里安装(任选其一):
|
|
||||||
- 把本目录作为本地依赖:`"parallel-agents-vue": "file:../parallel-agents-vue"`;
|
|
||||||
- 或发到你们的私有 npm 后正常 `pnpm add parallel-agents-vue`。
|
|
||||||
3. 代码里引入:
|
|
||||||
```ts
|
|
||||||
import { ParallelAgentsPanel } from "parallel-agents-vue";
|
|
||||||
import "parallel-agents-vue/style.css";
|
|
||||||
```
|
|
||||||
|
|
||||||
### 方式 B:直接拷贝源码(无需发包)
|
|
||||||
|
|
||||||
把本项目 `src/` 下这些拷进宿主项目(保持相对结构),按相对路径引入 `ParallelAgentsPanel.vue` +
|
|
||||||
`styles/parallel-agents.css` 即可:
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
components/ ParallelAgentsPanel.vue · MessageBubble.vue · MessageStepsCard.vue
|
|
||||||
ParallelSeatStrip.vue · Avatar.vue · MarkdownContent.vue
|
|
||||||
composables/ useParallelRun.ts
|
|
||||||
lib/ avatar-constants.ts
|
|
||||||
styles/ parallel-agents.css
|
|
||||||
api.ts · config.ts · types.ts
|
|
||||||
```
|
|
||||||
宿主需有 `markdown-it` 依赖(`pnpm add markdown-it`),并保证构建支持 `.vue`(Vite/@vitejs/plugin-vue)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、注册与使用
|
|
||||||
|
|
||||||
### 局部引入(推荐)
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ParallelAgentsPanel } from "parallel-agents-vue";
|
|
||||||
import "parallel-agents-vue/style.css";
|
|
||||||
|
|
||||||
const agents = [
|
|
||||||
{ agent_id: "roundtable-solution-design", name: "方案设计" },
|
|
||||||
{ agent_id: "roundtable-risk-review", name: "风险审查" },
|
|
||||||
];
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div style="height: 80vh">
|
|
||||||
<ParallelAgentsPanel :username="'demo'" :agents="agents" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 全局注册(可选)
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import ParallelAgentsVue from "parallel-agents-vue";
|
|
||||||
import "parallel-agents-vue/style.css";
|
|
||||||
app.use(ParallelAgentsVue); // 之后任意模板可直接用 <ParallelAgentsPanel/>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、放进抽屉 / 弹窗
|
|
||||||
|
|
||||||
组件根元素是 `h-full w-full flex flex-col min-h-0`,会**充满父容器**。所以**父容器要给一个高度**
|
|
||||||
(弹窗/抽屉本身有高度即可):
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<YourDrawer v-model="open" size="large">
|
|
||||||
<div style="height: 86vh">
|
|
||||||
<ParallelAgentsPanel :username="currentUsername" :agents="agents" @saved="onSaved" />
|
|
||||||
</div>
|
|
||||||
</YourDrawer>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、接口地址与登录
|
|
||||||
|
|
||||||
- **接口地址**:默认读 `src/config.ts` 的 `DEFAULT_BASE_URL`(可改源码,或 dev 设 `VITE_PA_BASE_URL`);
|
|
||||||
也可用 `:base-url` prop 临时覆盖。
|
|
||||||
- **登录**:把宿主的**用户名**用 `: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` 维度,多个实例/多用户互不串。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 六、Props
|
|
||||||
|
|
||||||
| prop | 类型 | 必填 | 说明 |
|
|
||||||
| --------------- | --------------------------------------- | :--: | ----------------------------------------------- |
|
|
||||||
| `agents` | `{ agent_id, name, avatarType? }[]` | ✔ | 要并行调用的智能体(`agent_id` 必须是真实 id) |
|
|
||||||
| `username` | `string` | | 免密自动登录用户名(推荐方式) |
|
|
||||||
| `baseUrl` | `string` | | 后端地址;不传用 `config.ts` 默认 |
|
|
||||||
| `token` | `string` | | 已有 Bearer token(优先于 username) |
|
|
||||||
| `getToken` | `() => string \| Promise<string>` | | 动态取 token |
|
|
||||||
| `loginPassword` | `string` | | 仅当后端开启口令门时需要 |
|
|
||||||
| `taskId` | `string` | | 传入则挂载时自动加载并回显该任务 |
|
|
||||||
| `model` | `string` | | 可选模型名(不传走默认 / 后端回退) |
|
|
||||||
| `dark` | `boolean`(默认 `true`) | | 深色面板(自带成色,不依赖宿主主题) |
|
|
||||||
|
|
||||||
`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 变化(保存 / 加载历史时) |
|
|
||||||
| `completed` | `AgentResult[]` | 一轮并行分析结束时,回传各智能体返回内容 |
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<ParallelAgentsPanel
|
|
||||||
:username="user"
|
|
||||||
:agents="agents"
|
|
||||||
: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 / 配置 / 后端回退。
|
|
||||||
切换作用于**下一次**「开始分析 / 重新分析」,运行中禁用。
|
|
||||||
- 「保存」存档;「历史」里选回**回显**;「重新分析」清空重跑。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 九、注意事项
|
|
||||||
|
|
||||||
- **不会污染宿主全局样式**:库未引入 Tailwind preflight,类名都在 `.parallel-agents` 作用域内。
|
|
||||||
- **深色自带成色**:面板始终深蓝深色,不随宿主明暗切换(与圆桌 Step2 观感一致)。
|
|
||||||
- **跨域**:若宿主与后端不同源,后端需允许该来源的 CORS + 带 `credentials`;登录端点公共且 CSRF 豁免,
|
|
||||||
业务请求用 Bearer 头已绕过 CSRF。
|
|
||||||
- **agent_id 必须真实**:见 [06-可用智能体清单.md](./06-可用智能体清单.md) 获取可用 id 与自查方法。
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
# 开发文档 · 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`。
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>并行多智能体面板 · Dev</title>
|
|
||||||
</head>
|
|
||||||
<body style="margin: 0">
|
|
||||||
<div id="app"></div>
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "parallel-agents-vue",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"description": "并行多智能体面板 —— Vue3 可嵌入组件(输入任务名+简介,并行调用多个智能体,深色消息列表 + 步骤条 + 保存回显 + 重新分析)",
|
|
||||||
"type": "module",
|
|
||||||
"main": "./dist/parallel-agents-vue.umd.cjs",
|
|
||||||
"module": "./dist/parallel-agents-vue.js",
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./dist/parallel-agents-vue.js",
|
|
||||||
"require": "./dist/parallel-agents-vue.umd.cjs"
|
|
||||||
},
|
|
||||||
"./style.css": "./dist/parallel-agents-vue.css"
|
|
||||||
},
|
|
||||||
"files": ["dist"],
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vue-tsc --noEmit && vite build",
|
|
||||||
"build:lib": "vite build --mode lib",
|
|
||||||
"typecheck": "vue-tsc --noEmit",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"vue": "^3.4.0"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"markdown-it": "^14.1.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@tailwindcss/vite": "^4.1.0",
|
|
||||||
"@types/markdown-it": "^14.1.2",
|
|
||||||
"@vitejs/plugin-vue": "^5.2.0",
|
|
||||||
"tailwindcss": "^4.1.0",
|
|
||||||
"typescript": "^5.6.0",
|
|
||||||
"vite": "^6.0.0",
|
|
||||||
"vue": "^3.5.0",
|
|
||||||
"vue-tsc": "^2.1.10"
|
|
||||||
},
|
|
||||||
"pnpm": {
|
|
||||||
"onlyBuiltDependencies": [
|
|
||||||
"esbuild",
|
|
||||||
"@tailwindcss/oxide"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-1389
File diff suppressed because it is too large.
Load diff
@@ -1,89 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/** Dev demo 调试页:填后端地址 / token / 智能体 id,把面板塞进一个弹窗,验证嵌入与流式。 */
|
|
||||||
import { reactive, ref } from "vue";
|
|
||||||
import ParallelAgentsPanel from "./components/ParallelAgentsPanel.vue";
|
|
||||||
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
|
|
||||||
.split("\n")
|
|
||||||
.map((l) => l.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((l) => {
|
|
||||||
const [id, name] = l.split(",").map((x) => x.trim());
|
|
||||||
return { agent_id: id, name: name || id };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div style="min-height: 100vh; background: #0a1020; color: #cbd5e1; padding: 24px; font-family: system-ui">
|
|
||||||
<h1 style="font-size: 18px; font-weight: 700; margin-bottom: 12px">并行多智能体面板 · Dev Demo</h1>
|
|
||||||
<div style="display: flex; flex-direction: column; gap: 8px; max-width: 640px">
|
|
||||||
<label>后端 baseUrl
|
|
||||||
<input v-model="cfg.baseUrl" style="width: 100%; padding: 6px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px" />
|
|
||||||
</label>
|
|
||||||
<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-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">
|
|
||||||
打开面板(弹窗内)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 模拟宿主弹窗:面板自适应容器 -->
|
|
||||||
<div
|
|
||||||
v-if="open"
|
|
||||||
style="position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 50"
|
|
||||||
@click.self="open = false"
|
|
||||||
>
|
|
||||||
<div style="width: 92vw; max-width: 1080px; height: 86vh; border-radius: 12px; overflow: hidden; box-shadow: 0 20px 60px rgba(0,0,0,0.5)">
|
|
||||||
<ParallelAgentsPanel
|
|
||||||
:base-url="cfg.baseUrl"
|
|
||||||
:username="cfg.username || undefined"
|
|
||||||
:task-id="cfg.taskId || undefined"
|
|
||||||
:agents="agents"
|
|
||||||
@update:taskId="onTaskId"
|
|
||||||
@completed="onCompleted"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
/**
|
|
||||||
* 后端访问层:鉴权(宿主传入 token)+ 并行扇出 SSE + 任务 CRUD。
|
|
||||||
*
|
|
||||||
* 全部打到 DeerFlow 网关(宿主传入 `baseUrl`,如 http://localhost:8001):
|
|
||||||
* - `POST {baseUrl}/api/parallel-agents/run/stream` —— 并行扇出,合并 SSE;
|
|
||||||
* - `{baseUrl}/api/parallel-agent-tasks` —— 任务保存/回显 CRUD。
|
|
||||||
*/
|
|
||||||
import type { AgentInput, Dialogue, SavedTask, StreamEvent } from "./types";
|
|
||||||
import { TOKEN_PATH } from "./config";
|
|
||||||
|
|
||||||
export interface ApiConfig {
|
|
||||||
baseUrl: string;
|
|
||||||
/** 已有登录态 token(Bearer)。可传字符串或一个(异步)取 token 的函数。 */
|
|
||||||
token?: string;
|
|
||||||
getToken?: () => string | Promise<string>;
|
|
||||||
/**
|
|
||||||
* 免密自动登录用户名:不传 token 时,组件用它走「用户名登录」(公共端点)换取 token
|
|
||||||
* 并缓存。用户名由宿主自己传。
|
|
||||||
*/
|
|
||||||
username?: string;
|
|
||||||
/** 仅当后端 `auth_login.username_login.require_password=true` 时需要的口令。 */
|
|
||||||
loginPassword?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function base(cfg: ApiConfig): string {
|
|
||||||
return cfg.baseUrl.replace(/\/$/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 免密登录拿到的 token 按 baseUrl::username 缓存,避免每个请求都登一次。
|
|
||||||
const _tokenCache = new Map<string, string>();
|
|
||||||
const cacheKey = (cfg: ApiConfig) => `${base(cfg)}::${cfg.username ?? ""}`;
|
|
||||||
|
|
||||||
/** 用 username 走本面板**专用**的「绕过常规登录」公共端点换 access_token(CSRF 豁免)。 */
|
|
||||||
async function loginByUsername(cfg: ApiConfig): Promise<string> {
|
|
||||||
if (!cfg.username) throw new Error("缺少 token 且未提供 username,无法登录");
|
|
||||||
const res = await fetch(`${base(cfg)}${TOKEN_PATH}`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ username: cfg.username }),
|
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text().catch(() => "");
|
|
||||||
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;
|
|
||||||
_tokenCache.set(cacheKey(cfg), token);
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** token 解析优先级:显式 token > getToken > 缓存 > 用 username 免密登录。 */
|
|
||||||
async function resolveToken(cfg: ApiConfig): Promise<string | undefined> {
|
|
||||||
if (cfg.token) return cfg.token;
|
|
||||||
if (cfg.getToken) return await cfg.getToken();
|
|
||||||
const cached = _tokenCache.get(cacheKey(cfg));
|
|
||||||
if (cached) return cached;
|
|
||||||
if (cfg.username) return await loginByUsername(cfg);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 清掉缓存 token(401 时强制下次重登)。 */
|
|
||||||
function invalidateToken(cfg: ApiConfig): void {
|
|
||||||
_tokenCache.delete(cacheKey(cfg));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function authHeaders(cfg: ApiConfig, json = true): Promise<Record<string, string>> {
|
|
||||||
const h: Record<string, string> = {};
|
|
||||||
if (json) h["Content-Type"] = "application/json";
|
|
||||||
const token = await resolveToken(cfg);
|
|
||||||
if (token) h["Authorization"] = token.startsWith("Bearer ") ? token : `Bearer ${token}`;
|
|
||||||
return h;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 带鉴权的 fetch;若 401 且用 username 免密登录,则清缓存重登并重试一次。 */
|
|
||||||
async function authedFetch(
|
|
||||||
cfg: ApiConfig,
|
|
||||||
url: string,
|
|
||||||
init: RequestInit & { json?: boolean } = {},
|
|
||||||
): Promise<Response> {
|
|
||||||
const { json = true, headers, ...rest } = init;
|
|
||||||
const run = async () =>
|
|
||||||
fetch(url, {
|
|
||||||
...rest,
|
|
||||||
headers: { ...(await authHeaders(cfg, json)), ...(headers as Record<string, string> | undefined) },
|
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
let res = await run();
|
|
||||||
if (res.status === 401 && !cfg.token && !cfg.getToken && cfg.username) {
|
|
||||||
invalidateToken(cfg);
|
|
||||||
res = await run();
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 并行扇出 SSE ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface StreamHandlers {
|
|
||||||
onEvent: (ev: StreamEvent) => void;
|
|
||||||
onError?: (err: unknown) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 打开 `/api/parallel-agents/run/stream` 一条合并 SSE,逐条把统一事件回调出去。
|
|
||||||
* 返回的 Promise 在流结束(all_done / 连接关闭)后 resolve。用 `signal` 取消。
|
|
||||||
*/
|
|
||||||
export async function streamParallelAgents(
|
|
||||||
cfg: ApiConfig,
|
|
||||||
body: { task_name: string; brief: string; agent_ids: string[]; model?: string },
|
|
||||||
handlers: StreamHandlers,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<void> {
|
|
||||||
const res = await authedFetch(cfg, `${base(cfg)}/api/parallel-agents/run/stream`, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
signal,
|
|
||||||
});
|
|
||||||
if (!res.ok || !res.body) {
|
|
||||||
const text = await res.text().catch(() => "");
|
|
||||||
throw new Error(`run/stream failed (${res.status}): ${text}`);
|
|
||||||
}
|
|
||||||
const reader = res.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = "";
|
|
||||||
try {
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
const lines = buffer.split("\n");
|
|
||||||
buffer = lines.pop() ?? "";
|
|
||||||
for (const raw of lines) {
|
|
||||||
const line = raw.trim();
|
|
||||||
if (!line.startsWith("data:")) continue;
|
|
||||||
const payload = line.slice(5).trim();
|
|
||||||
if (!payload) continue;
|
|
||||||
try {
|
|
||||||
handlers.onEvent(JSON.parse(payload) as StreamEvent);
|
|
||||||
} catch {
|
|
||||||
/* 忽略坏帧 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if ((err as { name?: string })?.name !== "AbortError") handlers.onError?.(err);
|
|
||||||
} finally {
|
|
||||||
reader.releaseLock();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 任务 CRUD(保存 / 回显)─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface ServerTask {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
brief: string | null;
|
|
||||||
agents: AgentInput[];
|
|
||||||
messages?: Dialogue[];
|
|
||||||
created_at: string | null;
|
|
||||||
updated_at: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TASKS = (cfg: ApiConfig) => `${base(cfg)}/api/parallel-agent-tasks`;
|
|
||||||
|
|
||||||
async function readJson<T>(res: Response, ctx: string): Promise<T> {
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text().catch(() => "");
|
|
||||||
throw new Error(`${ctx} failed (${res.status}): ${text}`);
|
|
||||||
}
|
|
||||||
return (await res.json()) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listTasks(cfg: ApiConfig): Promise<SavedTask[]> {
|
|
||||||
const res = await authedFetch(cfg, TASKS(cfg), { json: false });
|
|
||||||
const data = await readJson<{ tasks: ServerTask[] }>(res, "list tasks");
|
|
||||||
return data.tasks ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getTask(cfg: ApiConfig, id: string): Promise<SavedTask | null> {
|
|
||||||
const res = await authedFetch(cfg, `${TASKS(cfg)}/${encodeURIComponent(id)}`, { json: false });
|
|
||||||
if (res.status === 404) return null;
|
|
||||||
return readJson<SavedTask>(res, "get task");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createTask(
|
|
||||||
cfg: ApiConfig,
|
|
||||||
payload: { id?: string; title: string; brief?: string; agents: AgentInput[]; messages: Dialogue[] },
|
|
||||||
): Promise<SavedTask> {
|
|
||||||
const res = await authedFetch(cfg, TASKS(cfg), { method: "POST", body: JSON.stringify(payload) });
|
|
||||||
return readJson<SavedTask>(res, "create task");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateTask(
|
|
||||||
cfg: ApiConfig,
|
|
||||||
id: string,
|
|
||||||
patch: { title?: string; brief?: string; agents?: AgentInput[]; messages?: Dialogue[] },
|
|
||||||
): Promise<SavedTask | null> {
|
|
||||||
const res = await authedFetch(cfg, `${TASKS(cfg)}/${encodeURIComponent(id)}`, {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify(patch),
|
|
||||||
});
|
|
||||||
if (res.status === 404) return null;
|
|
||||||
return readJson<SavedTask>(res, "update task");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 模型列表 ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface ModelItem {
|
|
||||||
name: string;
|
|
||||||
display_name?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 列出后端可用模型(GET /api/models)。失败返回空数组(不阻断主流程)。 */
|
|
||||||
export async function listModels(cfg: ApiConfig): Promise<ModelItem[]> {
|
|
||||||
try {
|
|
||||||
const res = await authedFetch(cfg, `${base(cfg)}/api/models`, { json: false });
|
|
||||||
if (!res.ok) return [];
|
|
||||||
const data = (await res.json()) as { models?: ModelItem[] };
|
|
||||||
return data.models ?? [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteTask(cfg: ApiConfig, id: string): Promise<void> {
|
|
||||||
const res = await authedFetch(cfg, `${TASKS(cfg)}/${encodeURIComponent(id)}`, {
|
|
||||||
method: "DELETE",
|
|
||||||
json: false,
|
|
||||||
});
|
|
||||||
if (!res.ok && res.status !== 404) {
|
|
||||||
const text = await res.text().catch(() => "");
|
|
||||||
throw new Error(`delete task failed (${res.status}): ${text}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-50
@@ -1,50 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from "vue";
|
|
||||||
import { avatarColor, isHuman } from "@/lib/avatar-constants";
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{ type: string; size?: number }>(), { size: 36 });
|
|
||||||
const color = computed(() => avatarColor(props.type));
|
|
||||||
const human = computed(() => isHuman(props.type));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<span
|
|
||||||
class="inline-flex shrink-0 items-center justify-center rounded-xl"
|
|
||||||
:style="{ width: `${size}px`, height: `${size}px`, backgroundColor: color.bg }"
|
|
||||||
>
|
|
||||||
<!-- 真人 -->
|
|
||||||
<svg
|
|
||||||
v-if="human"
|
|
||||||
:width="size * 0.58"
|
|
||||||
:height="size * 0.58"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
:stroke="color.fg"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="8" r="4" />
|
|
||||||
<path d="M4 21c0-4 4-6 8-6s8 2 8 6" />
|
|
||||||
</svg>
|
|
||||||
<!-- 机器人 -->
|
|
||||||
<svg
|
|
||||||
v-else
|
|
||||||
:width="size * 0.6"
|
|
||||||
:height="size * 0.6"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
:stroke="color.fg"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<rect x="4" y="8" width="16" height="11" rx="3" />
|
|
||||||
<path d="M12 8V4" />
|
|
||||||
<circle cx="12" cy="3" r="1" :fill="color.fg" />
|
|
||||||
<circle cx="9" cy="13" r="1.2" :fill="color.fg" stroke="none" />
|
|
||||||
<circle cx="15" cy="13" r="1.2" :fill="color.fg" stroke="none" />
|
|
||||||
<path d="M9 16.5h6" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
-152
@@ -1,152 +0,0 @@
|
|||||||
<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>
|
|
||||||
-13
@@ -1,13 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from "vue";
|
|
||||||
import MarkdownIt from "markdown-it";
|
|
||||||
|
|
||||||
const props = defineProps<{ source: string }>();
|
|
||||||
|
|
||||||
const md = new MarkdownIt({ html: false, linkify: true, breaks: true });
|
|
||||||
const html = computed(() => md.render(props.source ?? ""));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="pa-md" v-html="html" />
|
|
||||||
</template>
|
|
||||||
-60
@@ -1,60 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/** 单条消息气泡(对齐 Step2 MessageBubble + Step2Message 结构):头像 / 发言人 / 状态徽标 /
|
|
||||||
* 时间 / 步骤条 / Markdown 正文 / 流式光标 / loading 占位。 */
|
|
||||||
import { computed } from "vue";
|
|
||||||
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; submitting?: boolean }>();
|
|
||||||
const emit = defineEmits<{ (e: "clarify", agentId: string, answer: string): void }>();
|
|
||||||
const hasContent = computed(() => (props.msg.content ?? "").trim().length > 0);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<Avatar :type="msg.avatarType" :size="36" />
|
|
||||||
<div class="min-w-0 flex-1 space-y-1">
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-sm font-extrabold pa-text">{{ msg.sender }}</span>
|
|
||||||
<span v-if="msg.confidence" class="pa-badge">{{ msg.confidence }}</span>
|
|
||||||
</div>
|
|
||||||
<span class="shrink-0 font-mono text-[11px] pa-text-muted">{{ msg.time }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MessageStepsCard
|
|
||||||
v-if="msg.steps && msg.steps.length"
|
|
||||||
:steps="msg.steps"
|
|
||||||
:streaming="msg.streaming"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="pa-text break-words">
|
|
||||||
<div
|
|
||||||
v-if="msg.streaming && !hasContent"
|
|
||||||
class="flex items-center gap-2 py-1 text-sm pa-text-muted"
|
|
||||||
>
|
|
||||||
<span class="pa-spin inline-block">◜</span>
|
|
||||||
<span>{{ msg.confidence || "正在生成…" }}</span>
|
|
||||||
<span class="inline-flex items-center gap-0.5">
|
|
||||||
<span class="pa-bounce inline-block size-1 rounded-full" style="background: var(--pa-primary)" />
|
|
||||||
<span class="pa-bounce inline-block size-1 rounded-full" style="background: var(--pa-primary); animation-delay: 0.15s" />
|
|
||||||
<span class="pa-bounce inline-block size-1 rounded-full" style="background: var(--pa-primary); animation-delay: 0.3s" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<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>
|
|
||||||
-95
@@ -1,95 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* 步骤条 / 思维链卡 —— 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";
|
|
||||||
|
|
||||||
const props = defineProps<{ steps: DialogueStep[]; streaming?: boolean }>();
|
|
||||||
const open = ref(true);
|
|
||||||
|
|
||||||
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 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>执行步骤({{ steps.length }})</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<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'"
|
|
||||||
>
|
|
||||||
<!-- 图标列 + 竖向连接线 -->
|
|
||||||
<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>
|
|
||||||
-375
@@ -1,375 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* 并行多智能体面板(主组件,可嵌入)。
|
|
||||||
*
|
|
||||||
* 输入「任务名称 + 简介」,并行调用宿主传入的多个智能体(后端固定禁写文件),消息列表
|
|
||||||
* 照搬圆桌 Step2 并行多席位的深色样式与结构(步骤条 / 并行双列 / 席位状态条),支持保存
|
|
||||||
* 聊天记录 + 回显 + 重新分析。
|
|
||||||
*
|
|
||||||
* 自适应容器:根元素 `h-full w-full flex flex-col min-h-0`,宿主直接塞进抽屉 / 弹窗即可。
|
|
||||||
*/
|
|
||||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
|
||||||
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";
|
|
||||||
import ParallelSeatStrip from "./ParallelSeatStrip.vue";
|
|
||||||
import MessageBubble from "./MessageBubble.vue";
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
/** 后端地址;不传用配置文件默认(config.ts 的 DEFAULT_BASE_URL)。 */
|
|
||||||
baseUrl?: string;
|
|
||||||
/** 已有 token(Bearer),与 username 二选一;都不传走配置默认地址 + 免密登录失败提示。 */
|
|
||||||
token?: string;
|
|
||||||
getToken?: () => string | Promise<string>;
|
|
||||||
/** 免密自动登录用户名(由宿主自己传)。不传 token 时用它登录换 token。 */
|
|
||||||
username?: string;
|
|
||||||
/** 仅当后端开启口令门时需要。 */
|
|
||||||
loginPassword?: string;
|
|
||||||
agents: AgentInput[];
|
|
||||||
taskId?: string;
|
|
||||||
model?: string;
|
|
||||||
dark?: boolean;
|
|
||||||
}>(),
|
|
||||||
{ dark: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: "saved", task: SavedTask): void;
|
|
||||||
(e: "update:taskId", id: string): void;
|
|
||||||
/** 一轮并行分析结束时回传各智能体的返回内容。 */
|
|
||||||
(e: "completed", results: AgentResult[]): void;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const cfg = computed<ApiConfig>(() => ({
|
|
||||||
baseUrl: props.baseUrl || DEFAULT_BASE_URL,
|
|
||||||
token: props.token,
|
|
||||||
getToken: props.getToken,
|
|
||||||
username: props.username,
|
|
||||||
loginPassword: props.loginPassword,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const run = useParallelRun();
|
|
||||||
const { dialogues, seats, running } = run;
|
|
||||||
|
|
||||||
const form = reactive({ taskName: "", brief: "" });
|
|
||||||
const currentTaskId = ref<string | null>(props.taskId ?? null);
|
|
||||||
const selected = ref<string[]>([]);
|
|
||||||
const saving = ref(false);
|
|
||||||
const history = ref<SavedTask[]>([]);
|
|
||||||
const showHistory = ref(false);
|
|
||||||
const errorMsg = ref<string | null>(null);
|
|
||||||
|
|
||||||
// 模型切换:拉后端模型列表,空字符串 = 用默认(prop/config/后端回退)。
|
|
||||||
const models = ref<ModelItem[]>([]);
|
|
||||||
const selectedModel = ref<string>(props.model ?? DEFAULT_MODEL ?? "");
|
|
||||||
|
|
||||||
const dialogueById = computed(() => {
|
|
||||||
const m = new Map<number, Dialogue>();
|
|
||||||
for (const d of dialogues.value) m.set(d.id, d);
|
|
||||||
return m;
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasResults = computed(() => dialogues.value.length > 0);
|
|
||||||
const canStart = computed(
|
|
||||||
() => !running.value && props.agents.length > 0 && (form.taskName.trim() || form.brief.trim()),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 单选:主区每次只显示**一个**席位的消息列表,点头像切换。
|
|
||||||
watch(
|
|
||||||
seats,
|
|
||||||
(s) => {
|
|
||||||
if (!s.length) {
|
|
||||||
selected.value = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 保留仍存在的当前选中;首次 / 失效时默认显示第 1 个席位。
|
|
||||||
const cur = selected.value[0];
|
|
||||||
const keep = cur && s.some((x) => x.agentId === cur) ? cur : s[0].agentId;
|
|
||||||
selected.value = [keep];
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
// 宿主传入 / 改变 taskId:作为存储主键,并尝试加载回显该任务(不存在则只设 id)。
|
|
||||||
watch(
|
|
||||||
() => props.taskId,
|
|
||||||
(id) => {
|
|
||||||
currentTaskId.value = id ?? null;
|
|
||||||
if (id) void loadTask(id);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/** 点头像切换显示:单选,始终切到点击的那个席位。 */
|
|
||||||
function toggleSeat(agentId: string) {
|
|
||||||
selected.value = [agentId];
|
|
||||||
}
|
|
||||||
|
|
||||||
const visibleSeats = computed(() => {
|
|
||||||
const id = selected.value[0] ?? seats.value[0]?.agentId;
|
|
||||||
const seat = seats.value.find((s) => s.agentId === id);
|
|
||||||
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;
|
|
||||||
try {
|
|
||||||
await run.start(cfg.value, {
|
|
||||||
taskName: form.taskName,
|
|
||||||
brief: form.brief,
|
|
||||||
agents: props.agents,
|
|
||||||
model: selectedModel.value || undefined,
|
|
||||||
});
|
|
||||||
// 一轮结束:回传各智能体返回内容(宿主也可用 ref 调 getResults())。
|
|
||||||
emit("completed", getResults());
|
|
||||||
} catch (err) {
|
|
||||||
errorMsg.value = err instanceof Error ? err.message : String(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSave() {
|
|
||||||
if (saving.value || !hasResults.value) return;
|
|
||||||
saving.value = true;
|
|
||||||
errorMsg.value = null;
|
|
||||||
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;
|
|
||||||
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, payload);
|
|
||||||
}
|
|
||||||
if (saved) {
|
|
||||||
currentTaskId.value = saved.id;
|
|
||||||
emit("update:taskId", saved.id);
|
|
||||||
emit("saved", saved);
|
|
||||||
await refreshHistory();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
errorMsg.value = err instanceof Error ? err.message : String(err);
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshHistory() {
|
|
||||||
try {
|
|
||||||
history.value = await listTasks(cfg.value);
|
|
||||||
} catch {
|
|
||||||
/* 历史拉取失败不阻断主流程 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadTask(id: string) {
|
|
||||||
showHistory.value = false;
|
|
||||||
errorMsg.value = null;
|
|
||||||
try {
|
|
||||||
const task = await getTask(cfg.value, id);
|
|
||||||
if (!task) return;
|
|
||||||
currentTaskId.value = task.id;
|
|
||||||
form.taskName = task.title ?? "";
|
|
||||||
form.brief = task.brief ?? "";
|
|
||||||
selected.value = [];
|
|
||||||
run.hydrate(task.messages ?? []);
|
|
||||||
emit("update:taskId", task.id);
|
|
||||||
} catch (err) {
|
|
||||||
errorMsg.value = err instanceof Error ? err.message : String(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshModels() {
|
|
||||||
const list = await listModels(cfg.value);
|
|
||||||
models.value = list;
|
|
||||||
// 当前选择不在列表里(且非空)时,回退为默认(空 = 后端回退)。
|
|
||||||
if (selectedModel.value && !list.some((m) => m.name === selectedModel.value)) {
|
|
||||||
selectedModel.value = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
void refreshHistory();
|
|
||||||
void refreshModels();
|
|
||||||
if (props.taskId) void loadTask(props.taskId);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="parallel-agents pa-bg flex h-full w-full min-h-0 flex-col"
|
|
||||||
:class="dark ? 'parallel-agents--dark' : ''"
|
|
||||||
>
|
|
||||||
<!-- 顶部:任务名 + 操作 -->
|
|
||||||
<div class="pa-border flex shrink-0 flex-col gap-2 border-b px-4 py-3">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
v-model="form.taskName"
|
|
||||||
class="pa-input min-w-0 flex-1 px-3 py-1.5 text-sm"
|
|
||||||
placeholder="任务名称"
|
|
||||||
/>
|
|
||||||
<div class="relative shrink-0">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="pa-btn-ghost px-2.5 py-1.5 text-xs"
|
|
||||||
@click="showHistory = !showHistory"
|
|
||||||
>
|
|
||||||
历史 ▾
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
v-if="showHistory"
|
|
||||||
class="pa-card pa-border absolute right-0 z-20 mt-1 max-h-72 w-72 overflow-auto rounded-lg border p-1 shadow-lg"
|
|
||||||
>
|
|
||||||
<div v-if="!history.length" class="px-3 py-4 text-center text-xs pa-text-muted">
|
|
||||||
暂无保存的任务
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
v-for="t in history"
|
|
||||||
:key="t.id"
|
|
||||||
type="button"
|
|
||||||
class="pa-btn-ghost block w-full truncate px-2.5 py-1.5 text-left text-xs"
|
|
||||||
style="border: 0"
|
|
||||||
@click="loadTask(t.id)"
|
|
||||||
>
|
|
||||||
<span class="font-semibold">{{ t.title || "未命名任务" }}</span>
|
|
||||||
<span class="pa-text-muted"> · {{ (t.updated_at || "").slice(0, 16).replace("T", " ") }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
v-model="form.brief"
|
|
||||||
rows="2"
|
|
||||||
class="pa-input w-full resize-y px-3 py-1.5 text-sm"
|
|
||||||
placeholder="任务简介:把背景、目标、约束写清楚,会并行下发给各智能体"
|
|
||||||
/>
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<button
|
|
||||||
v-if="!running"
|
|
||||||
type="button"
|
|
||||||
class="pa-btn-primary px-3.5 py-1.5 text-sm font-semibold"
|
|
||||||
:disabled="!canStart"
|
|
||||||
@click="onStart"
|
|
||||||
>
|
|
||||||
{{ hasResults ? "重新分析" : "开始分析" }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-else
|
|
||||||
type="button"
|
|
||||||
class="pa-btn-ghost px-3.5 py-1.5 text-sm font-semibold"
|
|
||||||
@click="run.cancel()"
|
|
||||||
>
|
|
||||||
停止
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="pa-btn-ghost px-3.5 py-1.5 text-sm font-semibold"
|
|
||||||
:disabled="saving || !hasResults"
|
|
||||||
@click="onSave"
|
|
||||||
>
|
|
||||||
{{ saving ? "保存中…" : currentTaskId ? "更新保存" : "保存" }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- 模型切换:作用于下一次「开始分析 / 重新分析」 -->
|
|
||||||
<label class="ml-auto flex items-center gap-1.5 text-xs pa-text-muted">
|
|
||||||
模型
|
|
||||||
<select
|
|
||||||
v-model="selectedModel"
|
|
||||||
:disabled="running"
|
|
||||||
class="pa-input px-2 py-1 text-xs"
|
|
||||||
title="选择本次并行分析使用的模型"
|
|
||||||
>
|
|
||||||
<option value="">默认</option>
|
|
||||||
<option v-for="m in models" :key="m.name" :value="m.name">
|
|
||||||
{{ m.display_name || m.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<span v-if="props.agents.length === 0" class="text-xs pa-text-muted">
|
|
||||||
⚠️ 未传入任何智能体(agents 为空)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p v-if="errorMsg" class="text-xs" style="color: var(--pa-rose)">{{ errorMsg }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 席位状态条 -->
|
|
||||||
<div v-if="seats.length" class="pa-border shrink-0 border-b px-4">
|
|
||||||
<ParallelSeatStrip :seats="seats" :selected="selected" @toggle="toggleSeat" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 会话区:并行双列 -->
|
|
||||||
<div class="min-h-0 flex-1 overflow-y-auto p-4">
|
|
||||||
<div
|
|
||||||
v-if="hasResults"
|
|
||||||
class="grid gap-4"
|
|
||||||
:class="visibleSeats.length > 1 ? 'grid-cols-1 lg:grid-cols-2' : 'grid-cols-1'"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="seat in visibleSeats"
|
|
||||||
:key="seat.agentId"
|
|
||||||
class="pa-surface-soft pa-border flex min-w-0 flex-col rounded-xl border p-3"
|
|
||||||
>
|
|
||||||
<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 }} · 等待输出…
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="flex h-full flex-col items-center justify-center gap-2 text-center"
|
|
||||||
>
|
|
||||||
<p class="text-sm pa-text-muted">填写任务名称与简介,点「开始分析」并行调用各智能体。</p>
|
|
||||||
<p class="text-xs pa-text-muted">并行运行期间禁止写文件,产出直接呈现在正文。</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
-60
@@ -1,60 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/** 并行席位状态条(对齐 Step2 ParallelSeatStrip):席位头像 + running/done/error 角标,
|
|
||||||
* 点击可切换主区显示哪两列。 */
|
|
||||||
import { computed } from "vue";
|
|
||||||
import type { SeatView } from "@/types";
|
|
||||||
import Avatar from "./Avatar.vue";
|
|
||||||
|
|
||||||
const props = defineProps<{ seats: SeatView[]; selected: string[] }>();
|
|
||||||
const emit = defineEmits<{ (e: "toggle", agentId: string): void }>();
|
|
||||||
|
|
||||||
const runningCount = computed(() => props.seats.filter((s) => s.status === "running").length);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex items-center gap-2 overflow-x-auto py-1">
|
|
||||||
<span class="shrink-0 text-[11px] font-bold pa-text-muted">
|
|
||||||
并行 {{ seats.length }} 席
|
|
||||||
<span v-if="runningCount > 0" class="pa-text-primary">({{ runningCount }} 进行中)</span>
|
|
||||||
<span v-if="seats.length > 1" class="font-normal pa-text-muted">· 点头像切换显示</span>
|
|
||||||
</span>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
v-for="seat in seats"
|
|
||||||
:key="seat.agentId"
|
|
||||||
type="button"
|
|
||||||
class="relative shrink-0 rounded-full transition"
|
|
||||||
:style="
|
|
||||||
selected.includes(seat.agentId)
|
|
||||||
? 'box-shadow: 0 0 0 2px var(--pa-primary)'
|
|
||||||
: ''
|
|
||||||
"
|
|
||||||
:title="seat.name"
|
|
||||||
@click="emit('toggle', seat.agentId)"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
v-if="seat.status === 'running'"
|
|
||||||
class="pa-ping absolute inset-0 rounded-full"
|
|
||||||
style="background: rgba(22, 119, 255, 0.3)"
|
|
||||||
/>
|
|
||||||
<span class="relative block" :style="!selected.includes(seat.agentId) ? 'opacity:0.8' : ''">
|
|
||||||
<Avatar :type="seat.avatarType" :size="28" />
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="absolute -bottom-0.5 -right-0.5 flex size-3.5 items-center justify-center rounded-full text-[8px] font-black"
|
|
||||||
style="border: 1px solid var(--pa-bg)"
|
|
||||||
:class="{
|
|
||||||
'pa-dot-running': seat.status === 'running',
|
|
||||||
'pa-dot-done': seat.status === 'done',
|
|
||||||
'pa-dot-error': seat.status === 'error',
|
|
||||||
}"
|
|
||||||
:style="seat.status === 'pending' ? 'background: var(--pa-border)' : ''"
|
|
||||||
>
|
|
||||||
<span v-if="seat.status === 'running'" class="pa-spin">◜</span>
|
|
||||||
<span v-else-if="seat.status === 'done'">✓</span>
|
|
||||||
<span v-else-if="seat.status === 'error'">✕</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
-56
@@ -1,56 +0,0 @@
|
|||||||
<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>
|
|
||||||
-274
@@ -1,274 +0,0 @@
|
|||||||
/**
|
|
||||||
* 并行运行编排(前端态)。消费后端合并 SSE,按 agent_id 把增量/步骤/收口路由到对应席位
|
|
||||||
* 气泡,维护并行席位状态条;带轻量节流(rAF 合并增量),支持取消、回显 hydrate、重置。
|
|
||||||
*/
|
|
||||||
import { ref, shallowRef } from "vue";
|
|
||||||
import type { AgentInput, Dialogue, SeatView, StreamEvent } from "@/types";
|
|
||||||
import { getAvatarTypeFor } from "@/lib/avatar-constants";
|
|
||||||
import { streamParallelAgents, type ApiConfig } from "@/api";
|
|
||||||
|
|
||||||
function nowTime(): string {
|
|
||||||
const d = new Date();
|
|
||||||
const p = (n: number) => String(n).padStart(2, "0");
|
|
||||||
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let _seq = 1;
|
|
||||||
function nextId(): number {
|
|
||||||
return _seq++;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useParallelRun() {
|
|
||||||
const dialogues = ref<Dialogue[]>([]);
|
|
||||||
const seats = ref<SeatView[]>([]);
|
|
||||||
const running = ref(false);
|
|
||||||
const abortRef = shallowRef<AbortController | null>(null);
|
|
||||||
|
|
||||||
// 增量节流:累积到 pending,rAF 合并刷新一次,避免逐 token reflow。
|
|
||||||
const pending = new Map<string, string>(); // agentId -> 待追加文本
|
|
||||||
let rafScheduled = false;
|
|
||||||
const bubbleByAgent = new Map<string, number>(); // agentId -> dialogue.id
|
|
||||||
|
|
||||||
function flushPending() {
|
|
||||||
rafScheduled = false;
|
|
||||||
if (pending.size === 0) return;
|
|
||||||
for (const [agentId, text] of pending) {
|
|
||||||
const bid = bubbleByAgent.get(agentId);
|
|
||||||
const d = dialogues.value.find((x) => x.id === bid);
|
|
||||||
if (d) d.content += text;
|
|
||||||
}
|
|
||||||
pending.clear();
|
|
||||||
// 触发数组响应(对象内字段已变更,这里强制一次浅刷新以更新双列视图)。
|
|
||||||
dialogues.value = [...dialogues.value];
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleFlush() {
|
|
||||||
if (rafScheduled) return;
|
|
||||||
rafScheduled = true;
|
|
||||||
requestAnimationFrame(flushPending);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSeatStatus(agentId: string, status: SeatView["status"]) {
|
|
||||||
const s = seats.value.find((x) => x.agentId === agentId);
|
|
||||||
if (s) s.status = status;
|
|
||||||
seats.value = [...seats.value];
|
|
||||||
}
|
|
||||||
|
|
||||||
function findDialogue(agentId: string): Dialogue | undefined {
|
|
||||||
const bid = bubbleByAgent.get(agentId);
|
|
||||||
return dialogues.value.find((x) => x.id === bid);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEvent(ev: StreamEvent) {
|
|
||||||
switch (ev.type) {
|
|
||||||
case "agent_start":
|
|
||||||
setSeatStatus(ev.agent_id, "running");
|
|
||||||
break;
|
|
||||||
case "delta":
|
|
||||||
pending.set(ev.agent_id, (pending.get(ev.agent_id) ?? "") + ev.text);
|
|
||||||
scheduleFlush();
|
|
||||||
break;
|
|
||||||
case "step": {
|
|
||||||
const d = findDialogue(ev.agent_id);
|
|
||||||
if (d) {
|
|
||||||
d.steps = d.steps ?? [];
|
|
||||||
d.steps.push({
|
|
||||||
id: `${d.id}-${d.steps.length}`,
|
|
||||||
key: ev.key,
|
|
||||||
label: ev.label,
|
|
||||||
ts: nowTime(),
|
|
||||||
args: ev.args ?? null,
|
|
||||||
});
|
|
||||||
dialogues.value = [...dialogues.value];
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "agent_done": {
|
|
||||||
flushPending();
|
|
||||||
const d = findDialogue(ev.agent_id);
|
|
||||||
if (d) {
|
|
||||||
if (ev.content) d.content = ev.content; // 终态正文以后端为准
|
|
||||||
d.streaming = false;
|
|
||||||
d.confidence = "已完成";
|
|
||||||
}
|
|
||||||
setSeatStatus(ev.agent_id, "done");
|
|
||||||
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);
|
|
||||||
if (d) {
|
|
||||||
d.content = d.content || `⚠️ 该智能体执行出错:${ev.message}`;
|
|
||||||
d.streaming = false;
|
|
||||||
d.confidence = "出错";
|
|
||||||
}
|
|
||||||
setSeatStatus(ev.agent_id, "error");
|
|
||||||
dialogues.value = [...dialogues.value];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "all_done":
|
|
||||||
flushPending();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 启动一轮并行运行。会清空旧对话、为每个 agent 建一个流式气泡。 */
|
|
||||||
async function start(
|
|
||||||
cfg: ApiConfig,
|
|
||||||
args: { taskName: string; brief: string; agents: AgentInput[]; model?: string },
|
|
||||||
): Promise<void> {
|
|
||||||
if (running.value) return;
|
|
||||||
reset();
|
|
||||||
running.value = true;
|
|
||||||
|
|
||||||
bubbleByAgent.clear();
|
|
||||||
const newSeats: SeatView[] = [];
|
|
||||||
const newDialogues: Dialogue[] = [];
|
|
||||||
args.agents.forEach((a, i) => {
|
|
||||||
const avatarType = a.avatarType || getAvatarTypeFor(a.agent_id, i);
|
|
||||||
const bubbleId = nextId();
|
|
||||||
bubbleByAgent.set(a.agent_id, bubbleId);
|
|
||||||
newDialogues.push({
|
|
||||||
id: bubbleId,
|
|
||||||
agentId: a.agent_id,
|
|
||||||
sender: a.name || a.agent_id,
|
|
||||||
avatarType,
|
|
||||||
content: "",
|
|
||||||
time: nowTime(),
|
|
||||||
confidence: "并行执行中",
|
|
||||||
streaming: true,
|
|
||||||
steps: [],
|
|
||||||
});
|
|
||||||
newSeats.push({ agentId: a.agent_id, name: a.name || a.agent_id, avatarType, status: "pending", bubbleId });
|
|
||||||
});
|
|
||||||
dialogues.value = newDialogues;
|
|
||||||
seats.value = newSeats;
|
|
||||||
|
|
||||||
const ac = new AbortController();
|
|
||||||
abortRef.value = ac;
|
|
||||||
try {
|
|
||||||
await streamParallelAgents(
|
|
||||||
cfg,
|
|
||||||
{
|
|
||||||
task_name: args.taskName,
|
|
||||||
brief: args.brief,
|
|
||||||
agent_ids: args.agents.map((a) => a.agent_id),
|
|
||||||
model: args.model,
|
|
||||||
},
|
|
||||||
{ onEvent: handleEvent, onError: (err) => console.error("[parallel-run]", err) },
|
|
||||||
ac.signal,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
// 收尾:任何仍在 streaming 的气泡都置完成态。
|
|
||||||
for (const d of dialogues.value) {
|
|
||||||
if (d.streaming) {
|
|
||||||
d.streaming = false;
|
|
||||||
if (d.confidence === "并行执行中") d.confidence = "已完成";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dialogues.value = [...dialogues.value];
|
|
||||||
running.value = false;
|
|
||||||
abortRef.value = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancel() {
|
|
||||||
abortRef.value?.abort();
|
|
||||||
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();
|
|
||||||
bubbleByAgent.clear();
|
|
||||||
const restored = saved.map((d) => ({ ...d, streaming: false }));
|
|
||||||
dialogues.value = restored;
|
|
||||||
const seatMap = new Map<string, SeatView>();
|
|
||||||
restored.forEach((d) => {
|
|
||||||
bubbleByAgent.set(d.agentId, d.id);
|
|
||||||
if (!seatMap.has(d.agentId)) {
|
|
||||||
seatMap.set(d.agentId, {
|
|
||||||
agentId: d.agentId,
|
|
||||||
name: d.sender,
|
|
||||||
avatarType: d.avatarType,
|
|
||||||
status: d.confidence === "出错" ? "error" : "done",
|
|
||||||
bubbleId: d.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
seats.value = [...seatMap.values()];
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
cancel();
|
|
||||||
pending.clear();
|
|
||||||
bubbleByAgent.clear();
|
|
||||||
dialogues.value = [];
|
|
||||||
seats.value = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return { dialogues, seats, running, start, cancel, hydrate, reset, rerunAgent };
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
/**
|
|
||||||
* 前端配置:后端接口地址等。嵌入方一般直接改这里的默认值即可;也可在 dev 用
|
|
||||||
* Vite 环境变量覆盖(VITE_PA_BASE_URL)。组件 props 传入时优先级最高。
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** DeerFlow 网关默认地址(接口都打到这里)。 */
|
|
||||||
export const DEFAULT_BASE_URL: string =
|
|
||||||
(import.meta as unknown as { env?: Record<string, string> }).env?.VITE_PA_BASE_URL ||
|
|
||||||
"http://localhost:8001";
|
|
||||||
|
|
||||||
/** 可选默认模型名(不填由后端回退到 config.yaml models[0])。 */
|
|
||||||
export const DEFAULT_MODEL: string | undefined =
|
|
||||||
(import.meta as unknown as { env?: Record<string, string> }).env?.VITE_PA_MODEL || undefined;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 取 token 端点:本面板**专用**的「绕过常规登录」公共端点(传用户名直接签发 token)。
|
|
||||||
* 内网部署对标准 /api/v1/auth/login/username 有额外限制,故走这个专用端点。
|
|
||||||
*/
|
|
||||||
export const TOKEN_PATH = "/api/parallel-agents/auth/token";
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
/**
|
|
||||||
* 组件库入口。其它 Vue 项目:
|
|
||||||
* import { ParallelAgentsPanel } from "parallel-agents-vue";
|
|
||||||
* import "parallel-agents-vue/style.css";
|
|
||||||
* 或全局注册:app.use(ParallelAgentsVue)。
|
|
||||||
*/
|
|
||||||
import "./styles/parallel-agents.css";
|
|
||||||
import type { App } from "vue";
|
|
||||||
import ParallelAgentsPanel from "./components/ParallelAgentsPanel.vue";
|
|
||||||
|
|
||||||
export { ParallelAgentsPanel };
|
|
||||||
export type { AgentInput, AgentResult, Dialogue, SavedTask, SeatView, StreamEvent } from "./types";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
install(app: App) {
|
|
||||||
app.component("ParallelAgentsPanel", ParallelAgentsPanel);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
-27
@@ -1,27 +0,0 @@
|
|||||||
/** 头像配色(对齐圆桌 Step2 的 avatarType:blue/purple/emerald/amber/pink/human)。 */
|
|
||||||
|
|
||||||
export const FALLBACK_AVATAR_TYPES = ["blue", "purple", "emerald", "amber", "pink"] as const;
|
|
||||||
|
|
||||||
/** 无显式 avatarType 时按下标轮转一个配色(与 Step2 getAvatarTypeFor 行为一致)。 */
|
|
||||||
export function getAvatarTypeFor(_agentId: string, index: number): string {
|
|
||||||
return FALLBACK_AVATAR_TYPES[index % FALLBACK_AVATAR_TYPES.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 每种 avatarType 在深色面板里的底色 + 主体色(机器人头 / 真人)。 */
|
|
||||||
export const AVATAR_COLORS: Record<string, { bg: string; fg: string }> = {
|
|
||||||
blue: { bg: "rgba(22,119,255,0.18)", fg: "#4096ff" },
|
|
||||||
purple: { bg: "rgba(124,58,237,0.20)", fg: "#a78bfa" },
|
|
||||||
emerald: { bg: "rgba(16,185,129,0.20)", fg: "#34d399" },
|
|
||||||
amber: { bg: "rgba(245,158,11,0.20)", fg: "#fbbf24" },
|
|
||||||
pink: { bg: "rgba(219,39,119,0.20)", fg: "#f472b6" },
|
|
||||||
human: { bg: "rgba(148,163,184,0.20)", fg: "#cbd5e1" },
|
|
||||||
man: { bg: "rgba(148,163,184,0.20)", fg: "#cbd5e1" },
|
|
||||||
};
|
|
||||||
|
|
||||||
export function avatarColor(type: string): { bg: string; fg: string } {
|
|
||||||
return AVATAR_COLORS[type] ?? AVATAR_COLORS.blue;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isHuman(type: string): boolean {
|
|
||||||
return type === "human" || type === "man";
|
|
||||||
}
|
|
||||||
-120
@@ -1,120 +0,0 @@
|
|||||||
/**
|
|
||||||
* 步骤条渲染辅助(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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { createApp } from "vue";
|
|
||||||
import App from "./App.vue";
|
|
||||||
import "./styles/parallel-agents.css";
|
|
||||||
|
|
||||||
createApp(App).mount("#app");
|
|
||||||
-282
@@ -1,282 +0,0 @@
|
|||||||
/* 并行多智能体面板样式。
|
|
||||||
*
|
|
||||||
* 嵌入安全:只引 Tailwind 的 theme + utilities(**不引 preflight**,避免重置宿主全局样式)。
|
|
||||||
* 颜色用作用域内的 `--pa-*` 深蓝 token(取自《深蓝 UI 规范》ZS-004 / ZS-002,与圆桌 Step2
|
|
||||||
* 深色一致),全部包在根类 `.parallel-agents` 下,绝不污染宿主。布局用 Tailwind 工具类,
|
|
||||||
* 主题色用下面的 `.pa-*` 语义类。 */
|
|
||||||
|
|
||||||
@layer theme, base, components, utilities;
|
|
||||||
@import "tailwindcss/theme.css" layer(theme);
|
|
||||||
@import "tailwindcss/utilities.css" layer(utilities);
|
|
||||||
|
|
||||||
/* ── 作用域 token(深色深蓝,自带成色,不依赖宿主主题)─────────────────────── */
|
|
||||||
.parallel-agents {
|
|
||||||
--pa-bg: #0c1836; /* 页面背景 zs-004-09 */
|
|
||||||
--pa-card: #122347; /* 卡片 / 气泡容器 zs-004-08 */
|
|
||||||
--pa-muted: #16294d; /* 次级表面(步骤卡 / 并行列底) */
|
|
||||||
--pa-accent: #1a2f5a; /* hover / 软高亮 */
|
|
||||||
--pa-fg: #c5cfe0; /* 正文 zs-004-02 */
|
|
||||||
--pa-fg-muted: #6e88b0; /* 次级文字 zs-004-04 */
|
|
||||||
--pa-border: #2d4473; /* 容器边框 zs-004-06 */
|
|
||||||
--pa-input: #4a6491; /* 控件描边 */
|
|
||||||
--pa-primary: #1677ff; /* 主操作 zs-002-06 */
|
|
||||||
--pa-primary-fg: #ffffff;
|
|
||||||
--pa-chip-bg: #16335f; /* badge 底 */
|
|
||||||
--pa-chip-fg: #4096ff; /* badge 文字 */
|
|
||||||
--pa-amber: #f59e0b;
|
|
||||||
--pa-emerald: #10b981;
|
|
||||||
--pa-rose: #ef4444;
|
|
||||||
|
|
||||||
color: var(--pa-fg);
|
|
||||||
background-color: var(--pa-bg);
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.6;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.parallel-agents *,
|
|
||||||
.parallel-agents *::before,
|
|
||||||
.parallel-agents *::after {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 滚动条 */
|
|
||||||
.parallel-agents ::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
}
|
|
||||||
.parallel-agents ::-webkit-scrollbar-thumb {
|
|
||||||
background: color-mix(in oklch, var(--pa-primary) 40%, var(--pa-border));
|
|
||||||
border-radius: 999px;
|
|
||||||
}
|
|
||||||
.parallel-agents ::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: color-mix(in oklch, var(--pa-primary) 60%, var(--pa-border));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 语义颜色类(组件用这些上色,布局仍用 Tailwind 工具类)──────────────────── */
|
|
||||||
.parallel-agents .pa-bg {
|
|
||||||
background-color: var(--pa-bg);
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-card {
|
|
||||||
background-color: var(--pa-card);
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-surface {
|
|
||||||
background-color: var(--pa-muted);
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-surface-soft {
|
|
||||||
background-color: color-mix(in oklch, var(--pa-muted) 55%, transparent);
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-border {
|
|
||||||
border-color: var(--pa-border);
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-text {
|
|
||||||
color: var(--pa-fg);
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-text-muted {
|
|
||||||
color: var(--pa-fg-muted);
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-text-primary {
|
|
||||||
color: var(--pa-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 输入框 / 文本域 */
|
|
||||||
.parallel-agents .pa-input {
|
|
||||||
background-color: var(--pa-card);
|
|
||||||
border: 1px solid var(--pa-border);
|
|
||||||
color: var(--pa-fg);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-input::placeholder {
|
|
||||||
color: color-mix(in oklch, var(--pa-fg-muted) 80%, transparent);
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: color-mix(in oklch, var(--pa-primary) 55%, var(--pa-border));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 主按钮 / 次按钮 */
|
|
||||||
.parallel-agents .pa-btn-primary {
|
|
||||||
background-color: var(--pa-primary);
|
|
||||||
color: var(--pa-primary-fg);
|
|
||||||
border-radius: 8px;
|
|
||||||
transition: filter 0.15s ease;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-btn-primary:hover {
|
|
||||||
filter: brightness(1.08);
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-btn-primary:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-btn-ghost {
|
|
||||||
border: 1px solid var(--pa-border);
|
|
||||||
color: var(--pa-fg);
|
|
||||||
background-color: transparent;
|
|
||||||
border-radius: 8px;
|
|
||||||
transition: background-color 0.15s ease;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-btn-ghost:hover {
|
|
||||||
background-color: var(--pa-accent);
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-btn-ghost:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 状态徽标("实时输出" / "已完成" …)—— 对齐 Step2 confidence chip */
|
|
||||||
.parallel-agents .pa-badge {
|
|
||||||
background-color: var(--pa-chip-bg);
|
|
||||||
color: var(--pa-chip-fg);
|
|
||||||
border: 1px solid var(--pa-border);
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
||||||
font-weight: 800;
|
|
||||||
text-transform: uppercase;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 1px 6px;
|
|
||||||
font-size: 11px;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 席位状态角标色 */
|
|
||||||
.parallel-agents .pa-dot-running {
|
|
||||||
background-color: var(--pa-primary);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-dot-done {
|
|
||||||
background-color: var(--pa-emerald);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-dot-error {
|
|
||||||
background-color: var(--pa-rose);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 流式光标 + loading 点 */
|
|
||||||
.parallel-agents .pa-cursor {
|
|
||||||
display: inline-block;
|
|
||||||
width: 4px;
|
|
||||||
height: 13px;
|
|
||||||
margin-left: 2px;
|
|
||||||
vertical-align: middle;
|
|
||||||
background: var(--pa-primary);
|
|
||||||
animation: pa-pulse 1s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
@keyframes pa-pulse {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 0.25;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-bounce {
|
|
||||||
animation: pa-bounce 0.9s infinite;
|
|
||||||
}
|
|
||||||
@keyframes pa-bounce {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: translateY(-3px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@keyframes pa-spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-spin {
|
|
||||||
animation: pa-spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
@keyframes pa-ping {
|
|
||||||
75%,
|
|
||||||
100% {
|
|
||||||
transform: scale(1.8);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-ping {
|
|
||||||
animation: pa-ping 1.2s cubic-bezier(0, 0, 0.2, 1) infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Markdown 正文(对齐 Step2 sharedMarkdownComponents 的深色观感)──────────── */
|
|
||||||
.parallel-agents .pa-md {
|
|
||||||
color: var(--pa-fg);
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.7;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-md p {
|
|
||||||
margin: 0.35em 0;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-md h1,
|
|
||||||
.parallel-agents .pa-md h2,
|
|
||||||
.parallel-agents .pa-md h3,
|
|
||||||
.parallel-agents .pa-md h4 {
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0.7em 0 0.35em;
|
|
||||||
color: var(--pa-fg);
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-md h1 {
|
|
||||||
font-size: 1.3em;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-md h2 {
|
|
||||||
font-size: 1.18em;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-md h3 {
|
|
||||||
font-size: 1.06em;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-md ul,
|
|
||||||
.parallel-agents .pa-md ol {
|
|
||||||
margin: 0.35em 0;
|
|
||||||
padding-left: 1.4em;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-md li {
|
|
||||||
margin: 0.2em 0;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-md a {
|
|
||||||
color: var(--pa-chip-fg);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-md code {
|
|
||||||
background: var(--pa-muted);
|
|
||||||
border: 1px solid var(--pa-border);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.1em 0.35em;
|
|
||||||
font-size: 0.9em;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-md pre {
|
|
||||||
background: var(--pa-muted);
|
|
||||||
border: 1px solid var(--pa-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 0.75em;
|
|
||||||
overflow-x: auto;
|
|
||||||
margin: 0.5em 0;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-md pre code {
|
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-md blockquote {
|
|
||||||
border-left: 3px solid var(--pa-border);
|
|
||||||
padding-left: 0.75em;
|
|
||||||
color: var(--pa-fg-muted);
|
|
||||||
margin: 0.5em 0;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-md table {
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin: 0.5em 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-md th,
|
|
||||||
.parallel-agents .pa-md td {
|
|
||||||
border: 1px solid var(--pa-border);
|
|
||||||
padding: 0.35em 0.6em;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.parallel-agents .pa-md th {
|
|
||||||
background: var(--pa-muted);
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
/**
|
|
||||||
* 并行多智能体面板的共享类型。
|
|
||||||
*
|
|
||||||
* `Dialogue` 镜像圆桌 Step2 的 `Step2Dialogue`,外加 `agentId`(并行场景下用来把消息
|
|
||||||
* 归到对应席位列)。`StreamEvent` 是后端 `/api/parallel-agents/run/stream` 的统一事件协议。
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** 一条工具调用步骤(喂步骤条 / 思维链卡)。`key` 形如 `tool_calling:<toolName>`。 */
|
|
||||||
export interface DialogueStep {
|
|
||||||
id: string;
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
ts: string;
|
|
||||||
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;
|
|
||||||
/** 归属席位的 agent_id(并行列定位用)。 */
|
|
||||||
agentId: string;
|
|
||||||
sender: string;
|
|
||||||
avatarType: string;
|
|
||||||
content: string;
|
|
||||||
time: string;
|
|
||||||
/** 状态标签("实时输出" / "已完成" / "出错" 等)。 */
|
|
||||||
confidence: string;
|
|
||||||
streaming?: boolean;
|
|
||||||
steps?: DialogueStep[];
|
|
||||||
/** 该智能体在要澄清时的协作卡片载荷(有则渲染卡片)。 */
|
|
||||||
clarification?: ClarificationPayload;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 并行席位的视图状态(席位状态条用)。 */
|
|
||||||
export interface SeatView {
|
|
||||||
agentId: string;
|
|
||||||
name: string;
|
|
||||||
avatarType: string;
|
|
||||||
status: "pending" | "running" | "done" | "error";
|
|
||||||
/** 关联的 Dialogue.id。 */
|
|
||||||
bubbleId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 宿主传入的一个待并行调用的智能体。 */
|
|
||||||
export interface AgentInput {
|
|
||||||
agent_id: string;
|
|
||||||
name: string;
|
|
||||||
avatarType?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 后端合并 SSE 的统一事件。 */
|
|
||||||
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;
|
|
||||||
title: string;
|
|
||||||
brief?: string | null;
|
|
||||||
agents: AgentInput[];
|
|
||||||
messages?: Dialogue[];
|
|
||||||
created_at?: string | null;
|
|
||||||
updated_at?: string | null;
|
|
||||||
}
|
|
||||||
-7
@@ -1,7 +0,0 @@
|
|||||||
/// <reference types="vite/client" />
|
|
||||||
|
|
||||||
declare module "*.vue" {
|
|
||||||
import type { DefineComponent } from "vue";
|
|
||||||
const component: DefineComponent<{}, {}, any>;
|
|
||||||
export default component;
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2020",
|
|
||||||
"useDefineForClassFields": true,
|
|
||||||
"module": "ESNext",
|
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"jsx": "preserve",
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"types": ["vite/client"],
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": { "@/*": ["./src/*"] }
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { fileURLToPath, URL } from "node:url";
|
|
||||||
import { defineConfig } from "vite";
|
|
||||||
import vue from "@vitejs/plugin-vue";
|
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
|
||||||
|
|
||||||
// 两种构建:
|
|
||||||
// - 默认(`vite build` / `vite dev`):独立 demo 应用(index.html)。
|
|
||||||
// - `--mode lib`(`pnpm build:lib`):组件库,产出 ESM + UMD + 一个 style.css,
|
|
||||||
// 供其它 Vue 项目 `import { ParallelAgentsPanel } from "parallel-agents-vue"`。
|
|
||||||
export default defineConfig(({ mode }) => {
|
|
||||||
const isLib = mode === "lib";
|
|
||||||
return {
|
|
||||||
plugins: [vue(), tailwindcss()],
|
|
||||||
resolve: {
|
|
||||||
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
|
|
||||||
},
|
|
||||||
server: { port: 5183 },
|
|
||||||
build: isLib
|
|
||||||
? {
|
|
||||||
lib: {
|
|
||||||
entry: fileURLToPath(new URL("./src/index.ts", import.meta.url)),
|
|
||||||
name: "ParallelAgentsVue",
|
|
||||||
fileName: "parallel-agents-vue",
|
|
||||||
},
|
|
||||||
rollupOptions: {
|
|
||||||
// vue 由宿主提供,不打进库。
|
|
||||||
external: ["vue"],
|
|
||||||
output: {
|
|
||||||
exports: "named",
|
|
||||||
globals: { vue: "Vue" },
|
|
||||||
assetFileNames: "parallel-agents-vue.[ext]",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
cssCodeSplit: false,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
outDir: "dist-app",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
Reference in new issue
Block a user