代码提交

This commit is contained in:
董宏凯 committed 2026-06-09 22:52:12 +08:00
1 parent ace30af202
commit 64afa9a1ed
5 files changed
+409 -172

No files matched your search

+198 -22
View File
@@ -4,6 +4,14 @@
`ParallelSeatStrip` / `Avatar` / `MarkdownContent` 都是它的内部子组件,**无需也不要单独引入**)。
宿主只管把它放进自己的页面 / 抽屉 / 弹窗,并传几个 prop。
> **⚠️ 契约变更(纯展示版)**:面板**不再自带顶部表单与按钮**。
> - **任务名称 / 简介 / 模型** 全部由宿主通过 props 传入(`taskName` / `brief` 支持 `v-model`);
> - **「重新分析 / 保存 / 停止」按钮由宿主自己渲染**,通过模板 ref 调用面板 `defineExpose` 出的
> `reanalyze()` / `save()` / `cancel()` 等方法(见 [§六·二](#六二暴露的方法与状态模板-ref));
> - 面板本体只展示「席位状态条 + 并行会话 + 错误提示」。
>
> 完整的宿主参考实现见仓库 `src/App.vue`(弹窗里宿主自渲染工具条 + 调用 ref 方法)。
---
## 一、要引入的东西(就这两样)
@@ -55,8 +63,11 @@ src/
### 局部引入(推荐)
宿主持有任务名 / 简介 / 模型,并自渲染按钮调用面板暴露的方法:
```vue
<script setup lang="ts">
import { ref } from "vue";
import { ParallelAgentsPanel } from "parallel-agents-vue";
import "parallel-agents-vue/style.css";
@@ -64,15 +75,43 @@ const agents = [
{ agent_id: "roundtable-solution-design", name: "方案设计" },
{ agent_id: "roundtable-risk-review", name: "风险审查" },
];
// 宿主持有这三项(面板不再渲染输入框)
const taskName = ref("");
const brief = ref("");
const model = ref("");
// 模板 ref:拿到面板暴露的方法 / 状态
const panel = ref<InstanceType<typeof ParallelAgentsPanel> | null>(null);
</script>
<template>
<div style="height: 80vh">
<ParallelAgentsPanel :username="'demo'" :agents="agents" />
<div style="display: flex; flex-direction: column; height: 80vh">
<!-- 宿主自己的工具条 -->
<input v-model="taskName" placeholder="任务名称" />
<textarea v-model="brief" placeholder="任务简介" />
<button :disabled="!panel?.canStart" @click="panel?.reanalyze()">
{{ panel?.hasResults ? "重新分析" : "开始分析" }}
</button>
<button :disabled="panel?.saving || !panel?.hasResults" @click="panel?.save()">保存</button>
<!-- 面板:纯展示,需父容器给高度 -->
<ParallelAgentsPanel
ref="panel"
style="flex: 1; min-height: 0"
:username="'demo'"
:agents="agents"
v-model:task-name="taskName"
v-model:brief="brief"
:model="model"
/>
</div>
</template>
```
> 面板暴露出的 `running` / `saving` / `hasResults` / `canStart` 等是 ref,**在组件实例上会自动解包**,
> 模板里直接 `panel?.canStart` 读,**无需 `.value`**。
### 全局注册(可选)
```ts
@@ -90,8 +129,20 @@ app.use(ParallelAgentsVue); // 之后任意模板可直接用 <ParallelAgentsPan
```vue
<YourDrawer v-model="open" size="large">
<div style="height: 86vh">
<ParallelAgentsPanel :username="currentUsername" :agents="agents" @saved="onSaved" />
<div style="display: flex; flex-direction: column; height: 86vh">
<!-- 宿主工具条:任务名/简介/模型 + 按钮,由你自己渲染 -->
<MyToolbar v-model:task-name="taskName" v-model:brief="brief"
@start="panel?.reanalyze()" @save="panel?.save()" />
<ParallelAgentsPanel
ref="panel"
style="flex: 1; min-height: 0"
:username="currentUsername"
:agents="agents"
v-model:task-name="taskName"
v-model:brief="brief"
:model="model"
@saved="onSaved"
/>
</div>
</YourDrawer>
```
@@ -122,14 +173,66 @@ app.use(ParallelAgentsVue); // 之后任意模板可直接用 <ParallelAgentsPan
| `getToken` | `() => string \| Promise<string>` | | 动态取 token |
| `loginPassword` | `string` | | 仅当后端开启口令门时需要 |
| `taskId` | `string` | | 传入则挂载时自动加载并回显该任务 |
| `model` | `string` | | 可选模型名(不传走默认 / 后端回退) |
| `taskName` | `string` | | **任务名称(宿主持有)**,支持 `v-model:task-name` |
| `brief` | `string` | | **任务简介(宿主持有)**,支持 `v-model:brief` |
| `model` | `string` | | 模型名(宿主持有;空 = 默认 / 后端回退) |
| `dark` | `boolean`(默认 `true`) | | 深色面板(自带成色,不依赖宿主主题) |
> `taskName` / `brief` 用 `v-model` 是为了**回显**:加载已保存任务时,面板会通过 `update:taskName` /
> `update:brief` 把存档里的标题 / 简介回传给宿主,宿主据此同步自己的输入框。
`AgentInput`/`SavedTask`/`AgentResult` 等类型也从包里导出:
`import type { AgentInput, SavedTask, AgentResult } from "parallel-agents-vue"`。
---
## 六·二、暴露的方法与状态(模板 ref)
面板通过 `defineExpose` 暴露下列**方法**与**状态**,供宿主用模板 ref 调用 / 读取。
拿 ref:`const panel = ref<InstanceType<typeof ParallelAgentsPanel> | null>(null)`,模板 `<ParallelAgentsPanel ref="panel" .../>`。
**方法**
| 方法 | 等价于 | 说明 |
| --------------------- | ----------------- | -------------------------------------------- |
| `reanalyze()` | 旧「重新分析」按钮 | 用当前 `taskName`/`brief`/`model` 开始 / 重跑一轮 |
| `start()` | 同 `reanalyze()` | 别名,语义同上 |
| `save()` | 旧「保存」按钮 | 保存 / 更新当前聊天记录(按 `taskId` upsert)|
| `cancel()` | 旧「停止」按钮 | 中止本轮并行运行 |
| `loadTask(id)` | 旧「历史」选择 | 加载并回显某条任务(同时回传标题/简介) |
| `refreshHistory()` | — | 重新拉取历史列表(结果在 `history` 里) |
| `getResults()` | — | 取所有智能体返回内容 `AgentResult[]` |
| `getResult(id)` | — | 取某个智能体返回内容 `AgentResult \| null` |
**状态(ref,组件实例上自动解包,模板里直接读无需 `.value`)**
| 状态 | 类型 | 说明 |
| -------------- | ---------------- | ----------------------------------- |
| `running` | `boolean` | 是否正在并行运行(用于切「停止」按钮)|
| `saving` | `boolean` | 是否正在保存 |
| `hasResults` | `boolean` | 是否已有结果(「开始」/「重新」文案 & 保存可用)|
| `canStart` | `boolean` | 是否满足开始条件(有 agents + 任务名或简介)|
| `history` | `SavedTask[]` | 已保存任务列表(宿主可自建历史下拉)|
| `errorMsg` | `string \| null` | 最近一次错误(面板内也会展示一条) |
```vue
<script setup lang="ts">
const panel = ref<InstanceType<typeof ParallelAgentsPanel> | null>(null);
// JS 里读 ref 状态要带 .value:
function onClickStart() {
if (panel.value?.canStart) panel.value.reanalyze();
}
</script>
<template>
<!-- 模板里自动解包,直接读 -->
<button :disabled="!panel?.canStart" @click="panel?.reanalyze()">
{{ panel?.hasResults ? "重新分析" : "开始分析" }}
</button>
</template>
```
---
## 六·一、每个参数怎么传(逐个举例)
### `agents`(必填)——「智能体参数」怎么传
@@ -171,11 +274,24 @@ const agents = [
<ParallelAgentsPanel :get-token="() => authStore.getAccessToken()" :agents="agents" />
```
### `model`——指定模型(不传走默认/后端回退;也可在面板右上角下拉切)
### `model`——指定模型(宿主持有;不传走默认/后端回退)
```vue
<ParallelAgentsPanel :model="'deepseek-chat'" :username="'demo'" :agents="agents" />
<ParallelAgentsPanel :model="model" :username="'demo'" :agents="agents" />
```
> 模型下拉已移到宿主侧:宿主自己渲染下拉(可调后端 `/api/models`),把选中的模型名用 `:model` 传进来。
### `taskName` / `brief`——任务名 / 简介(宿主持有,支持 v-model)
```vue
<ParallelAgentsPanel
v-model:task-name="taskName"
v-model:brief="brief"
:username="'demo'"
:agents="agents"
/>
```
> 用 `v-model` 是为了**回显**:加载已保存任务时面板会把存档标题 / 简介回传给宿主。
### `dark`——深色(默认 true,自带成色)
@@ -208,6 +324,11 @@ const agents: AgentInput[] = [
{ agent_id: "roundtable-risk-review", name: "风险审查" },
];
const taskId = ref("task-001");
const taskName = ref("");
const brief = ref("");
const model = ref("deepseek-chat");
const panel = ref<InstanceType<typeof ParallelAgentsPanel> | null>(null);
function onSaved(t: SavedTask) {
console.log("已保存", t.id);
}
@@ -217,13 +338,26 @@ function onCompleted(results: AgentResult[]) {
</script>
<template>
<div style="height: 86vh">
<div style="display: flex; flex-direction: column; height: 86vh">
<!-- 宿主工具条 -->
<input v-model="taskName" placeholder="任务名称" />
<textarea v-model="brief" placeholder="任务简介" />
<button v-if="!panel?.running" :disabled="!panel?.canStart" @click="panel?.reanalyze()">
{{ panel?.hasResults ? "重新分析" : "开始分析" }}
</button>
<button v-else @click="panel?.cancel()">停止</button>
<button :disabled="panel?.saving || !panel?.hasResults" @click="panel?.save()">保存</button>
<ParallelAgentsPanel
ref="panel"
style="flex: 1; min-height: 0"
:base-url="'http://localhost:8001'"
:username="'demo'"
:agents="agents"
:task-id="taskId"
:model="'deepseek-chat'"
v-model:task-name="taskName"
v-model:brief="brief"
:model="model"
:dark="true"
@saved="onSaved"
@update:taskId="(id) => (taskId = id)"
@@ -237,9 +371,12 @@ function onCompleted(results: AgentResult[]) {
| 事件 | 载荷 | 触发时机 |
| ----------------- | ------------------ | --------------------------------------- |
| `saved` | `SavedTask` | 保存成功(新建或更新)后 |
| `saved` | `SavedTask` | 保存成功(新建或更新)后;**含自动保存** |
| `update:taskId` | `string` | 当前任务 id 变化(保存 / 加载历史时) |
| `completed` | `AgentResult[]` | 一轮并行分析结束时,回传各智能体返回内容 |
| `update:taskName` | `string` | 加载已保存任务回显标题(配合 `v-model:task-name`)|
| `update:brief` | `string` | 加载已保存任务回显简介(配合 `v-model:brief`)|
| `agent-completed` | `AgentResult` | **每个**智能体一分析完就回传它单个的结果(各席位各触发一次)|
| `completed` | `AgentResult[]` | 一轮并行分析**全部**结束时,回传所有智能体返回内容 |
```vue
<ParallelAgentsPanel
@@ -254,9 +391,22 @@ function onCompleted(results: AgentResult[]) {
## 七·一、如何获取模型返回的内容
每个智能体(模型)返回的正文是 `AgentResult.content`。两种方式,任选其一:
每个智能体(模型)返回的正文是 `AgentResult.content`。组件会**自动把结果推给宿主**,无需轮询:
**A. 事件**:监听 `@completed`,一轮分析结束时自动回传 `AgentResult[]`:
**A·1. 单个完成就推**:监听 `@agent-completed`,**哪个智能体先分析完就先回传哪个**(各触发一次):
```vue
<ParallelAgentsPanel
:username="'demo'"
:agents="agents"
@agent-completed="(r) => {
// r: { agent_id, name, content, status } —— 单个智能体
console.log('完成一个:', r.name, '→', r.content);
}"
/>
```
**A·2. 全部完成再推一次**:监听 `@completed`,一轮分析**全部**结束时回传 `AgentResult[]`:
```vue
<ParallelAgentsPanel
@@ -269,6 +419,8 @@ function onCompleted(results: AgentResult[]) {
/>
```
> 两个事件可同时用:`agent-completed` 做「来一个渲染一个」的增量更新,`completed` 做「全部到齐」的汇总。
**B. 方法(模板 ref)**:随时主动取(不必等事件):
```vue
@@ -294,14 +446,22 @@ function readOne(id: string) {
```
`AgentResult = { agent_id, name, content, status }`,`content` 即该智能体模型返回的正文(Markdown)。
`defineExpose` 还暴露了 `start()` / `cancel()`,可由宿主代替按钮触发开始 / 停止。
`defineExpose` 还暴露了 `reanalyze()` / `start()` / `save()` / `cancel()` / `loadTask(id)` 等方法及
`running` / `saving` / `hasResults` / `canStart` / `history` 等状态,完整清单见 [§六·二](#六二暴露的方法与状态模板-ref)。
## 七·二、如何保存 + 回显聊天记录
**保存**:面板里点「保存」即把当前整段聊天记录(各智能体气泡 + 步骤条等)存到后端,按用户隔离。
**保存**:宿主调用 `panel.save()` 即把当前整段聊天记录(各智能体气泡 + 步骤条等)存到后端,按用户隔离。
传 `:task-id` 时**以它为存储主键**做 upsert(已存在→更新;不存在→用该 id 新建);不传则首次保存由后端
生成 id(通过 `@update:taskId` 回传,下次再保存即更新这条)。
> **自动保存**:每轮分析(含单席位重跑)**结束后会自动保存一次**,宿主无需手动点保存也不丢记录;
> 自动保存同样走 upsert,并照常触发 `saved` / `update:taskId` 事件(首轮会回传新生成的 id)。
> `panel.save()` 仍保留,供宿主想随时手动存档时调用。
> **进入默认显示最新**:组件挂载时若**没传 `taskId`**,会自动加载并回显**该用户最新的一条**聊天记录
> (历史按更新时间倒序,取第一条);传了 `taskId` 则优先回显指定那条。
**回显(两种方式)**:
```vue
@@ -316,7 +476,20 @@ const taskId = ref<string>(""); // 留空=新任务
taskId.value = "task-001"; // 改了之后面板自动回显 task-001
```
> 方式 2:面板**右上角内置「历史」下拉**——点开选一条历史任务即回显,**无需写任何代码**。
> 方式 2:**历史下拉已移到宿主侧**(面板不再内置)。宿主读 `panel.history`(`SavedTask[]`)自建下拉,
> 选中某条时调 `panel.loadTask(id)` 回显;面板会通过 `update:taskName` / `update:brief` 把存档标题 / 简介
> 回传给宿主,配合 `v-model` 同步宿主自己的输入框。需要刷新列表调 `panel.refreshHistory()`。
```vue
<template>
<!-- 宿主自建历史下拉 -->
<select @change="(e) => panel?.loadTask((e.target as HTMLSelectElement).value)">
<option v-for="t in panel?.history || []" :key="t.id" :value="t.id">
{{ t.title || "未命名任务" }}
</option>
</select>
</template>
```
要点:先**保存过**、再用**同一个 taskId**(或从历史里选)才会有内容回显;没存过的新 id 第一次进去是空白
(后台会探测一次 `GET /…/{id}` 返回 404,属正常,面板静默处理)。
@@ -325,14 +498,17 @@ taskId.value = "task-001"; // 改了之后面板自动回显 task-
## 八、交互要点(与圆桌 Step2 一致)
- 输入「任务名称 + 简介」→「开始分析」→ 各智能体**并行**输出。运行期间禁用**写文件**(`write_file`/
`str_replace`)与**派活/脚本**(`agent_orchestration`/`bash`,防子智能体模仿总控调度);检索、查看、
`ask_clarification` 等照常 —— 所以**步骤条 / 协作卡片正常显示**,每个智能体只做自己那份分析。
- 宿主备好「任务名称 + 简介(+ 模型)」props → 调 `panel.reanalyze()` → 各智能体**并行**输出。
运行期间禁用**写文件**(`write_file`/`str_replace`)与**派活/脚本**(`agent_orchestration`/`bash`,
防子智能体模仿总控调度);检索、查看、`ask_clarification` 等照常 —— 所以**步骤条 / 协作卡片正常
显示**,每个智能体只做自己那份分析。
- 主区每次只显示**一个**席位的消息列表;**点席位头像切换**要看哪个(单选)。
- 顶部席位状态条显示每个智能体的进行中 / 完成 / 出错。
- 操作区右侧有**模型切换**下拉(拉取后端 `/api/models`);选「默认」则用 `model` prop / 配置 / 后端回退。
切换作用于**下一次**「开始分析 / 重新分析」,运行中禁用。
- 「保存」存档;「历史」里选回**回显**;「重新分析」清空重跑。
- **任务名 / 简介 / 模型 / 按钮 / 历史下拉全部在宿主侧**:模型空 = `model` prop / 配置 / 后端回退;
模型作用于**下一次**`reanalyze()`。
- `panel.save()` 存档;`panel.loadTask(id)` 回显;`panel.reanalyze()` 清空重跑;`panel.cancel()` 停止。
- **每轮分析结束自动保存一次**;挂载时没传 `taskId` 会**默认回显最新一条**记录。
- 结果自动推给宿主:`@agent-completed`(来一个推一个)+ `@completed`(全部到齐再推一次)。
---
+81 -12
View File
@@ -1,5 +1,10 @@
<script setup lang="ts">
/** Dev demo 调试页:填后端地址 / token / 智能体 id,把面板塞进一个弹窗,验证嵌入与流式。 */
/**
* Dev demo 调试页:填后端地址 / token / 智能体 id,把面板塞进一个弹窗,验证嵌入与流式。
*
* 同时作为「新契约」的参考实现:任务名 / 简介 / 模型由宿主持有并通过 props 传入,
* 「重新分析 / 保存 / 停止」等按钮由宿主渲染,通过模板 ref 调用面板 expose 出的方法。
*/
import { reactive, ref } from "vue";
import ParallelAgentsPanel from "./components/ParallelAgentsPanel.vue";
import type { AgentInput, AgentResult } from "./types";
@@ -16,13 +21,24 @@ const cfg = reactive({
const open = ref(false);
const agents = ref<AgentInput[]>([]);
// 宿主持有:任务名 / 简介 / 模型(面板不再渲染这些输入)。
const taskName = ref(localStorage.getItem("pa.taskName") || "");
const brief = ref(localStorage.getItem("pa.brief") || "");
const model = ref(localStorage.getItem("pa.model") || "");
// 模板 ref:拿到面板暴露的方法 / 状态。
const panel = ref<InstanceType<typeof ParallelAgentsPanel> | null>(null);
// 面板保存 / 生成 id 时回填,方便复制下来下次填进去测「按 taskId 回显」。
function onTaskId(id: string) {
cfg.taskId = id;
localStorage.setItem("pa.taskId", id);
}
function onAgentCompleted(result: AgentResult) {
console.log("[demo] agent-completed → 某智能体分析完:", result.name, result.status, result.content);
}
function onCompleted(results: AgentResult[]) {
console.log("[demo] completed → 各智能体返回内容:", results);
console.log("[demo] completed → 全部分析完,各智能体返回内容:", results);
}
function parseAgents(): AgentInput[] {
@@ -68,21 +84,74 @@ function launch() {
</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 style="width: 92vw; max-width: 1080px; height: 86vh; border-radius: 12px; overflow: hidden; box-shadow: 0 20px 60px rgba(0,0,0,0.5); display: flex; flex-direction: column; background: #0d1526">
<!-- 宿主工具条:这部分原来在面板内,现在由宿主自己掌控 -->
<div style="display: flex; flex-direction: column; gap: 8px; padding: 12px 16px; border-bottom: 1px solid #1e2d4d">
<input
v-model="taskName"
placeholder="任务名称"
style="padding: 6px 10px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px"
/>
<textarea
v-model="brief"
rows="2"
placeholder="任务简介:把背景、目标、约束写清楚"
style="padding: 6px 10px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px; resize: vertical"
/>
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 8px">
<!-- expose 出的 ref 在组件实例上会自动解包,直接读,无需 .value -->
<button
v-if="!panel?.running"
:disabled="!panel?.canStart"
@click="panel?.reanalyze()"
style="padding: 6px 14px; background: #1677ff; color: #fff; border: 0; border-radius: 6px; cursor: pointer"
>
{{ panel?.hasResults ? "重新分析" : "开始分析" }}
</button>
<button
v-else
@click="panel?.cancel()"
style="padding: 6px 14px; background: #2d4473; color: #e5edf7; border: 0; border-radius: 6px; cursor: pointer"
>
停止
</button>
<button
:disabled="panel?.saving || !panel?.hasResults"
@click="panel?.save()"
style="padding: 6px 14px; background: #2d4473; color: #e5edf7; border: 0; border-radius: 6px; cursor: pointer"
>
{{ panel?.saving ? "保存中…" : cfg.taskId ? "更新保存" : "保存" }}
</button>
<input
v-model="model"
placeholder="模型(留空=默认)"
style="margin-left: auto; width: 200px; padding: 6px 10px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px"
/>
</div>
</div>
<!-- 面板:自适应容器,纯展示会话 -->
<div style="flex: 1; min-height: 0">
<ParallelAgentsPanel
ref="panel"
:base-url="cfg.baseUrl"
:username="cfg.username || undefined"
:task-id="cfg.taskId || undefined"
v-model:task-name="taskName"
v-model:brief="brief"
:model="model"
:agents="agents"
@update:taskId="onTaskId"
@agent-completed="onAgentCompleted"
@completed="onCompleted"
/>
</div>
</div>
</div>
</div>
@@ -1,26 +1,27 @@
<script setup lang="ts">
/**
* 并行多智能体面板(主组件,可嵌入)。
* 并行多智能体面板(主组件,可嵌入)—— 纯展示 + 暴露方法版本。
*
* 输入「任务名称 + 简介」,并行调用宿主传入的多个智能体(后端固定禁写文件),消息列表
* 照搬圆桌 Step2 并行多席位的深色样式与结构(步骤条 / 并行双列 / 席位状态条),支持保存
* 聊天记录 + 回显 + 重新分析。
* 「任务名称 / 简介 / 模型」全部由宿主通过 props 传入;本组件不再渲染顶部表单与按钮,
* 只展示「席位状态条 + 并行会话」。宿主自行渲染「重新分析 / 保存」等按钮,并通过模板 ref
* 调用本组件暴露的 `reanalyze()` / `save()` / `cancel()` 等方法。
*
* 并行调用宿主传入的多个智能体(后端固定禁写文件),消息列表照搬圆桌 Step2 并行多席位的
* 深色样式与结构(步骤条 / 并行双列 / 席位状态条),支持保存聊天记录 + 回显 + 重新分析。
*
* 自适应容器:根元素 `h-full w-full flex flex-col min-h-0`,宿主直接塞进抽屉 / 弹窗即可。
*/
import { computed, onMounted, reactive, ref, watch } from "vue";
import { computed, onMounted, ref, watch } from "vue";
import type { AgentInput, AgentResult, Dialogue, SavedTask, SeatView } from "@/types";
import { useParallelRun } from "@/composables/useParallelRun";
import {
createTask,
ensureTask,
listModels,
listTasks,
updateTask,
type ApiConfig,
type ModelItem,
} from "@/api";
import { DEFAULT_BASE_URL, DEFAULT_MODEL } from "@/config";
import { DEFAULT_BASE_URL } from "@/config";
import ParallelSeatStrip from "./ParallelSeatStrip.vue";
import MessageBubble from "./MessageBubble.vue";
@@ -37,15 +38,26 @@ const props = withDefaults(
loginPassword?: string;
agents: AgentInput[];
taskId?: string;
/** 任务名称:由宿主传入(可配合 update:taskName 做 v-model)。 */
taskName?: string;
/** 任务简介:由宿主传入(可配合 update:brief 做 v-model)。 */
brief?: string;
/** 本次并行分析使用的模型;空 = 后端回退默认。 */
model?: string;
dark?: boolean;
}>(),
{ dark: true },
{ dark: true, taskName: "", brief: "", model: "" },
);
const emit = defineEmits<{
(e: "saved", task: SavedTask): void;
(e: "update:taskId", id: string): void;
/** 回显已保存任务时回传标题,宿主可据此同步自己的输入框。 */
(e: "update:taskName", name: string): void;
/** 回显已保存任务时回传简介。 */
(e: "update:brief", brief: string): void;
/** 单个智能体一分析完就回传它的结果(每个席位完成时各触发一次)。 */
(e: "agent-completed", result: AgentResult): void;
/** 一轮并行分析结束时回传各智能体的返回内容。 */
(e: "completed", results: AgentResult[]): void;
}>();
@@ -58,21 +70,26 @@ const cfg = computed<ApiConfig>(() => ({
loginPassword: props.loginPassword,
}));
const run = useParallelRun();
// 单个智能体完成即回传它的结果(一分析完就给宿主)。
const run = useParallelRun({
onAgentSettled: (agentId) => {
const r = getResult(agentId);
if (r) emit("agent-completed", r);
},
});
const { dialogues, seats, running } = run;
const form = reactive({ taskName: "", brief: "" });
// 任务名 / 简介 / 模型全部由宿主 props 提供(本组件不再持有可编辑表单)。
const taskName = computed(() => props.taskName ?? "");
const brief = computed(() => props.brief ?? "");
const model = computed(() => (props.model || "").trim() || undefined);
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);
@@ -81,7 +98,7 @@ const dialogueById = computed(() => {
const hasResults = computed(() => dialogues.value.length > 0);
const canStart = computed(
() => !running.value && props.agents.length > 0 && (form.taskName.trim() || form.brief.trim()),
() => !running.value && props.agents.length > 0 && !!(taskName.value.trim() || brief.value.trim()),
);
// 单选:主区每次只显示**一个**席位的消息列表,点头像切换。
@@ -137,21 +154,43 @@ function getResults(): AgentResult[] {
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() });
// 模板 ref 用:`<ParallelAgentsPanel ref="panelRef" />`
// 宿主自行渲染按钮,调用下面这些方法 / 读取状态:
// panelRef.value.reanalyze() // 开始 / 重新分析
// panelRef.value.save() // 保存 / 更新保存
// panelRef.value.cancel() // 停止
// panelRef.value.loadTask(id) // 回显某条历史
// panelRef.value.running / saving / hasResults / canStart // 按钮态(ref)
defineExpose({
getResults,
getResult,
start: () => onStart(),
reanalyze: () => onStart(),
save: () => onSave(),
cancel: () => run.cancel(),
loadTask: (id: string) => loadTask(id),
refreshHistory: () => refreshHistory(),
history,
running,
saving,
hasResults,
canStart,
errorMsg,
});
/** 用户在某智能体的协作卡片里提交了回答 → 带上回答重跑该智能体。 */
async function onClarify(agentId: string, answer: string) {
errorMsg.value = null;
const brief = `${form.brief}\n\n【用户补充】${answer}`.trim();
const merged = `${brief.value}\n\n【用户补充】${answer}`.trim();
try {
await run.rerunAgent(cfg.value, {
taskName: form.taskName,
brief,
taskName: taskName.value,
brief: merged,
agentId,
model: selectedModel.value || undefined,
model: model.value,
});
emit("completed", getResults());
void autoSave(); // 重跑完也自动存一次
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
}
@@ -162,26 +201,37 @@ async function onStart() {
errorMsg.value = null;
try {
await run.start(cfg.value, {
taskName: form.taskName,
brief: form.brief,
taskName: taskName.value,
brief: brief.value,
agents: props.agents,
model: selectedModel.value || undefined,
model: model.value,
});
// 一轮结束:回传各智能体返回内容(宿主也可用 ref 调 getResults())。
emit("completed", getResults());
void autoSave(); // 分析完自动保存一次
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
}
}
/** 一轮分析结束后自动保存一次(有结果才存;失败静默,不打断主流程)。 */
async function autoSave() {
if (!hasResults.value || saving.value) return;
try {
await onSave();
} catch {
/* 自动保存失败不打扰用户,错误已在 onSave 内记录 */
}
}
async function onSave() {
if (saving.value || !hasResults.value) return;
saving.value = true;
errorMsg.value = null;
try {
const title = form.taskName.trim() || "未命名任务";
const title = taskName.value.trim() || "未命名任务";
const agentsSnapshot: AgentInput[] = props.agents.map((a) => ({ ...a }));
const payload = { title, brief: form.brief, agents: agentsSnapshot, messages: dialogues.value };
const payload = { title, brief: brief.value, agents: agentsSnapshot, messages: dialogues.value };
// 按宿主传入的 taskId **upsert**:有 id 先更新,不存在(404)再用该 id 创建;无 id 则新建。
let saved: SavedTask | null;
const id = currentTaskId.value;
@@ -206,14 +256,17 @@ async function onSave() {
async function refreshHistory() {
try {
history.value = await listTasks(cfg.value);
const list = await listTasks(cfg.value);
// 按更新时间倒序,最新的在最前(供「进入默认显示最新一条」用)。
history.value = [...list].sort((a, b) =>
(b.updated_at || b.created_at || "").localeCompare(a.updated_at || a.created_at || ""),
);
} catch {
/* 历史拉取失败不阻断主流程 */
}
}
async function loadTask(id: string) {
showHistory.value = false;
errorMsg.value = null;
try {
// get-or-create:新 id 直接初始化空记录(不 404);已存在则回显其聊天记录。
@@ -224,11 +277,12 @@ async function loadTask(id: string) {
currentTaskId.value = task.id;
selected.value = [];
if (task.messages && task.messages.length) {
form.taskName = task.title ?? "";
form.brief = task.brief ?? "";
// 标题 / 简介由宿主持有:回传让宿主同步自己的输入框(v-model)。
emit("update:taskName", task.title ?? "");
emit("update:brief", task.brief ?? "");
run.hydrate(task.messages);
} else {
// 空记录(新初始化):清空当前会话,等用户填任务 / 开始分析。
// 空记录(新初始化):清空当前会话,等宿主填任务 / 触发分析。
run.reset();
}
emit("update:taskId", task.id);
@@ -237,19 +291,14 @@ async function loadTask(id: string) {
}
}
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(async () => {
await refreshHistory();
if (props.taskId) {
void loadTask(props.taskId);
} else if (history.value.length) {
// 没指定 taskId:默认回显最新一条聊天记录。
void loadTask(history.value[0].id);
}
}
onMounted(() => {
void refreshHistory();
void refreshModels();
if (props.taskId) void loadTask(props.taskId);
});
</script>
@@ -258,98 +307,17 @@ onMounted(() => {
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>
<!-- 顶部表单与按钮已移除:任务名 / 简介 / 模型由宿主 props 传入,
「重新分析 / 保存 / 停止」由宿主调用本组件 expose 出的方法。 -->
<!-- 模型切换:作用于下一次「开始分析 / 重新分析」 -->
<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>
<!-- 错误提示(保留,便于宿主无需额外处理也能看到失败原因) -->
<p
v-if="errorMsg"
class="pa-border shrink-0 border-b px-4 py-2 text-xs"
style="color: var(--pa-rose)"
>
{{ errorMsg }}
</p>
<!-- 席位状态条 -->
<div v-if="seats.length" class="pa-border shrink-0 border-b px-4">
@@ -383,7 +351,7 @@ onMounted(() => {
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-sm pa-text-muted">等待宿主触发「开始 / 重新分析」,并行调用各智能体。</p>
<p class="text-xs pa-text-muted">并行运行期间禁止写文件,产出直接呈现在正文。</p>
</div>
</div>
@@ -18,12 +18,25 @@ function nextId(): number {
return _seq++;
}
export function useParallelRun() {
export interface ParallelRunOptions {
/** 单个智能体进入终态(完成 / 待确认 / 出错)时回调一次,用于「一分析完就回传」。 */
onAgentSettled?: (agentId: string, status: SeatView["status"]) => void;
}
export function useParallelRun(opts: ParallelRunOptions = {}) {
const dialogues = ref<Dialogue[]>([]);
const seats = ref<SeatView[]>([]);
const running = ref(false);
const abortRef = shallowRef<AbortController | null>(null);
// 本轮已「结算」(回调过 onAgentSettled)的 agentId,去重,避免重复回传。
const settled = new Set<string>();
function settleAgent(agentId: string, status: SeatView["status"]) {
if (settled.has(agentId)) return;
settled.add(agentId);
opts.onAgentSettled?.(agentId, status);
}
// 增量节流:累积到 pending,rAF 合并刷新一次,避免逐 token reflow。
const pending = new Map<string, string>(); // agentId -> 待追加文本
let rafScheduled = false;
@@ -93,6 +106,7 @@ export function useParallelRun() {
}
setSeatStatus(ev.agent_id, "done");
dialogues.value = [...dialogues.value];
settleAgent(ev.agent_id, "done");
break;
}
case "agent_clarification": {
@@ -113,6 +127,7 @@ export function useParallelRun() {
}
setSeatStatus(ev.agent_id, "done");
dialogues.value = [...dialogues.value];
settleAgent(ev.agent_id, "done");
break;
}
case "agent_error": {
@@ -125,6 +140,7 @@ export function useParallelRun() {
}
setSeatStatus(ev.agent_id, "error");
dialogues.value = [...dialogues.value];
settleAgent(ev.agent_id, "error");
break;
}
case "all_done":
@@ -188,6 +204,8 @@ export function useParallelRun() {
}
}
dialogues.value = [...dialogues.value];
// 兜底:没收到终态事件就断流的席位也回调一次(按当前席位状态)。
for (const s of seats.value) settleAgent(s.agentId, s.status === "error" ? "error" : "done");
running.value = false;
abortRef.value = null;
}
@@ -218,6 +236,7 @@ export function useParallelRun() {
setSeatStatus(args.agentId, "running");
dialogues.value = [...dialogues.value];
settled.delete(args.agentId); // 该席位重跑,允许再次回传
running.value = true;
const ac = new AbortController();
abortRef.value = ac;
@@ -235,6 +254,8 @@ export function useParallelRun() {
if (dd.confidence === "并行执行中") dd.confidence = "已完成";
}
dialogues.value = [...dialogues.value];
const st = seats.value.find((x) => x.agentId === args.agentId);
settleAgent(args.agentId, st?.status === "error" ? "error" : "done");
running.value = false;
abortRef.value = null;
}
@@ -266,6 +287,7 @@ export function useParallelRun() {
cancel();
pending.clear();
bubbleByAgent.clear();
settled.clear();
dialogues.value = [];
seats.value = [];
}