This commit is contained in:
董宏凯 committed 2026-06-10 12:51:13 +08:00
1 parent ca9960c8bc
commit bd426ed1d3
24 files changed
+912 -108

No files matched your search

+3 -3
View File
@@ -6,10 +6,10 @@
* 「重新分析 / 保存 / 停止」等按钮由宿主渲染,通过模板 ref 调用面板 expose 出的方法。
*/
import { reactive, ref } from "vue";
import ParallelAgentsPanel from "./components/ParallelAgentsPanel.vue";
import type { AgentInput, AgentResult } from "./types";
import { ParallelAgentsPanel } from "./parallel-agents";
import type { AgentInput, AgentResult } from "./parallel-agents";
import { DEFAULT_BASE_URL } from "./config";
import { DEFAULT_BASE_URL } from "./parallel-agents/config";
const cfg = reactive({
baseUrl: localStorage.getItem("pa.baseUrl") || DEFAULT_BASE_URL,
+13 -3
View File
@@ -3,13 +3,23 @@
* import { ParallelAgentsPanel } from "parallel-agents-vue";
* import "parallel-agents-vue/style.css";
* 或全局注册:app.use(ParallelAgentsVue)。
*
* 实现全部收在自包含的 `./parallel-agents/` 文件夹里(含样式,零 Tailwind 依赖)。需要直接拷
* 源码的用户,整个 parallel-agents/ 文件夹拷走即用,见该目录下 README.md。
*/
import "./styles/parallel-agents.css";
import type { App } from "vue";
import ParallelAgentsPanel from "./components/ParallelAgentsPanel.vue";
import { ParallelAgentsPanel } from "./parallel-agents";
export { ParallelAgentsPanel };
export type { AgentInput, AgentResult, Dialogue, SavedTask, SeatView, StreamEvent } from "./types";
export type {
AgentInput,
AgentResult,
Dialogue,
SavedTask,
SeatStatus,
SeatView,
StreamEvent,
} from "./parallel-agents";
export default {
install(app: App) {
-1
View File
@@ -1,5 +1,4 @@
import { createApp } from "vue";
import App from "./App.vue";
import "./styles/parallel-agents.css";
createApp(App).mount("#app");
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from "vue";
import { avatarColor, isHuman } from "@/lib/avatar-constants";
import { avatarColor, isHuman } from "./avatar-constants";
const props = withDefaults(defineProps<{ type: string; size?: number }>(), { size: 36 });
const color = computed(() => avatarColor(props.type));
@@ -5,7 +5,7 @@
* 自定义输入 + 发送按钮。提交时回传格式化好的回答文本。
*/
import { computed, reactive } from "vue";
import type { ClarificationPayload, ClarOption } from "@/types";
import type { ClarificationPayload, ClarOption } from "./types";
const props = defineProps<{ payload: ClarificationPayload; submitting?: boolean }>();
const emit = defineEmits<{ (e: "submit", answer: string): void }>();
@@ -2,7 +2,7 @@
/** 单条消息气泡(对齐 Step2 MessageBubble + Step2Message 结构):头像 / 发言人 / 状态徽标 /
* 时间 / 步骤条 / Markdown 正文 / 流式光标 / loading 占位。 */
import { computed } from "vue";
import type { Dialogue } from "@/types";
import type { Dialogue } from "./types";
import Avatar from "./Avatar.vue";
import MessageStepsCard from "./MessageStepsCard.vue";
import MarkdownContent from "./MarkdownContent.vue";
@@ -4,8 +4,8 @@
* slate 边框卡 + 每步「图标列(带竖向连接线) + 中文文案 + 时间 + 参数子节点」。
*/
import { computed, ref } from "vue";
import type { DialogueStep } from "@/types";
import { getStepArgs, getStepDisplay } from "@/lib/step-display";
import type { DialogueStep } from "./types";
import { getStepArgs, getStepDisplay } from "./step-display";
import StepIcon from "./StepIcon.vue";
const props = defineProps<{ steps: DialogueStep[]; streaming?: boolean }>();
@@ -12,16 +12,16 @@
* 自适应容器:根元素 `h-full w-full flex flex-col min-h-0`,宿主直接塞进抽屉 / 弹窗即可。
*/
import { computed, onMounted, ref, watch } from "vue";
import type { AgentInput, AgentResult, Dialogue, SavedTask, SeatView } from "@/types";
import { useParallelRun } from "@/composables/useParallelRun";
import type { AgentInput, AgentResult, Dialogue, SavedTask, SeatView } from "./types";
import { useParallelRun } from "./useParallelRun";
import {
createTask,
ensureTask,
listTasks,
updateTask,
type ApiConfig,
} from "@/api";
import { DEFAULT_BASE_URL } from "@/config";
} from "./api";
import { DEFAULT_BASE_URL } from "./config";
import ParallelSeatStrip from "./ParallelSeatStrip.vue";
import MessageBubble from "./MessageBubble.vue";
@@ -77,7 +77,7 @@ const run = useParallelRun({
if (r) emit("agent-completed", r);
},
});
const { dialogues, seats, running } = run;
const { dialogues, seats, running, busy } = run;
// 任务名 / 简介 / 模型全部由宿主 props 提供(本组件不再持有可编辑表单)。
const taskName = computed(() => props.taskName ?? "");
@@ -339,7 +339,7 @@ onMounted(async () => {
<MessageBubble
v-if="dialogueById.get(seat.bubbleId)"
:msg="dialogueById.get(seat.bubbleId)!"
:submitting="running"
:submitting="running || busy.has(seat.agentId)"
@clarify="onClarify"
/>
<div v-else class="py-6 text-center text-xs pa-text-muted">
@@ -2,13 +2,14 @@
/** 并行席位状态条(对齐 Step2 ParallelSeatStrip):席位头像 + running/done/error 角标,
* 点击可切换主区显示哪两列。 */
import { computed } from "vue";
import type { SeatView } from "@/types";
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);
const clarifyCount = computed(() => props.seats.filter((s) => s.status === "clarify").length);
</script>
<template>
@@ -16,6 +17,7 @@ const runningCount = computed(() => props.seats.filter((s) => s.status === "runn
<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="clarifyCount > 0" style="color: var(--pa-amber)">({{ clarifyCount }} 待协助)</span>
<span v-if="seats.length > 1" class="font-normal pa-text-muted">· 点头像切换显示</span>
</span>
<div class="flex items-center gap-2">
@@ -46,12 +48,14 @@ const runningCount = computed(() => props.seats.filter((s) => s.status === "runn
:class="{
'pa-dot-running': seat.status === 'running',
'pa-dot-done': seat.status === 'done',
'pa-dot-clarify': seat.status === 'clarify',
'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 === 'clarify'">?</span>
<span v-else-if="seat.status === 'error'">✕</span>
</span>
</button>
@@ -0,0 +1,61 @@
# 并行多智能体面板(parallel-agents)
一个**自包含**的 Vue 3 可嵌入组件:输入任务名 + 简介,并行调用多个智能体,深色消息列表 +
步骤条 + 协作卡片 + 保存回显 + 重新分析。
## 怎么用(复制即用)
把**整个 `parallel-agents/` 文件夹**拷到你项目的 `src/` 下,然后:
```ts
import { ParallelAgentsPanel } from "@/parallel-agents";
// 或: import { ParallelAgentsPanel } from "./parallel-agents";
```
```vue
<ParallelAgentsPanel
ref="panel"
:base-url="'http://localhost:8001'"
:username="'demo'"
:task-id="taskId || undefined"
v-model:task-name="taskName"
v-model:brief="brief"
:model="model"
:agents="agents"
@agent-completed="onAgentCompleted"
@completed="onCompleted"
/>
```
样式由入口 `index.ts` 自动 `import "./parallel-agents.css"`,**无需** Tailwind / 任何 CSS 框架。
若你不走 `index.ts` 而是直接 import 组件,请自行引一次该 CSS。
## 依赖
- `vue` ^3.4
- `markdown-it` ^14(正文 Markdown 渲染)—— `pnpm add markdown-it`
> 不再依赖 Tailwind:布局类已内置为 `parallel-agents.css` 末尾的「最小工具类子集」,全部作用域
> 在根类 `.parallel-agents` 下,绝不外泄污染宿主页面。
## 文件结构(都在本文件夹内,可随意改源码)
| 文件 | 作用 |
|------|------|
| `ParallelAgentsPanel.vue` | 主组件(席位条 + 并行会话,暴露 start/save/cancel 等方法) |
| `ParallelSeatStrip.vue` | 席位状态条(running / done / **clarify 待协助** / error) |
| `MessageBubble.vue` | 单条消息气泡(头像 / 步骤条 / 正文 / 协作卡片) |
| `MessageStepsCard.vue` · `StepIcon.vue` | 执行步骤条 / 思维链卡 + 图标 |
| `ClarificationCard.vue` | 协作 / 澄清卡片(用户协助) |
| `MarkdownContent.vue` | Markdown 正文渲染(markdown-it) |
| `Avatar.vue` | 头像(机器人 / 真人 SVG) |
| `useParallelRun.ts` | 并行运行编排(消费合并 SSE、按席位路由、待协助/并发协助) |
| `api.ts` | 后端访问层(鉴权 + 扇出 SSE + 任务 CRUD) |
| `types.ts` · `config.ts` · `avatar-constants.ts` · `step-display.ts` | 类型 / 配置 / 头像配色 / 步骤文案 |
| `parallel-agents.css` | 作用域样式(深蓝深色 token + 语义类 + 工具类子集) |
## 关键状态
席位/结果状态 `SeatStatus`:`pending | running | done | clarify | error`。其中 **`clarify`** 表示
该智能体在等用户协助(待确认),**不是已完成**——宿主据此判断「未完成」,多个智能体同时待协助
时可分别协助、互不阻塞。
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { IconKey } from "@/lib/step-display";
import type { IconKey } from "./step-display";
defineProps<{ name: IconKey; size?: number }>();
</script>
@@ -0,0 +1,27 @@
/**
* 并行多智能体面板 —— 自包含文件夹入口。
*
* 使用方式(把整个 parallel-agents/ 文件夹拷进你的项目 src 下即可):
*
* import { ParallelAgentsPanel } from "@/parallel-agents";
* // 或相对路径: import { ParallelAgentsPanel } from "./parallel-agents";
*
* 样式会被这里自动引入(零依赖、作用域在 .parallel-agents 下,不污染宿主,无需 Tailwind)。
* 仅依赖 vue 与 markdown-it(正文 Markdown 渲染)。详见同目录 README.md。
*/
import "./parallel-agents.css";
export { default as ParallelAgentsPanel } from "./ParallelAgentsPanel.vue";
export type {
AgentInput,
AgentResult,
ClarificationPayload,
ClarOption,
Dialogue,
DialogueStep,
SavedTask,
SeatStatus,
SeatView,
StreamEvent,
} from "./types";
export type { ApiConfig } from "./api";
@@ -1,13 +1,11 @@
/* 并行多智能体面板样式。
/* 并行多智能体面板样式 —— 零依赖、不污染宿主。
*
* 不再依赖 Tailwind:本文件自带一份「最小布局工具类子集」(文件末尾),全部作用域在根类
* `.parallel-agents` 下,绝不外泄到宿主页面。其它项目把整个 parallel-agents/ 文件夹拷走、
* 只 `import "./parallel-agents.css"` 即可,无需安装或配置 Tailwind。
*
* 嵌入安全:只引 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);
* 深色一致)。语义颜色用 `.pa-*` 类,布局用末尾的工具类子集。 */
/* ── 作用域 token(深色深蓝,自带成色,不依赖宿主主题)─────────────────────── */
.parallel-agents {
@@ -52,7 +50,7 @@
background: color-mix(in oklch, var(--pa-primary) 60%, var(--pa-border));
}
/* ── 语义颜色类(组件用这些上色,布局仍用 Tailwind 工具类)──────────────────── */
/* ── 语义颜色类(组件用这些上色,布局用文件末尾的工具类子集)──────────────────── */
.parallel-agents .pa-bg {
background-color: var(--pa-bg);
}
@@ -149,6 +147,10 @@
background-color: var(--pa-rose);
color: #fff;
}
.parallel-agents .pa-dot-clarify {
background-color: var(--pa-amber);
color: #fff;
}
/* 流式光标 + loading 点 */
.parallel-agents .pa-cursor {
@@ -280,3 +282,175 @@
.parallel-agents .pa-md th {
background: var(--pa-muted);
}
/* ════════════════════════════════════════════════════════════════════════════
* 最小布局工具类子集(替代 Tailwind)
* 只实现本组件模板用到的类,全部作用域在 .parallel-agents 下,不影响宿主。
* 维护提示:模板里新增布局类时,在此处补一条对应规则即可(或直接写 scoped <style>)。
*
* 放进 @layer:让无层级的 `.pa-*` 语义类(颜色/按钮/徽标)在同名属性冲突时仍**压过**这里的
* 工具类,复刻原先 Tailwind utilities 在 @layer、pa-* 无层级时的优先级(如 pa-btn-primary
* 的 8px 圆角压过 rounded-xl)。
* ════════════════════════════════════════════════════════════════════════════ */
@layer pa-utilities {
/* display */
.parallel-agents .flex { display: flex; }
.parallel-agents .inline-flex { display: inline-flex; }
.parallel-agents .inline-block { display: inline-block; }
.parallel-agents .block { display: block; }
.parallel-agents .grid { display: grid; }
/* flex */
.parallel-agents .flex-col { flex-direction: column; }
.parallel-agents .flex-1 { flex: 1 1 0%; }
.parallel-agents .flex-wrap { flex-wrap: wrap; }
.parallel-agents .items-center { align-items: center; }
.parallel-agents .items-start { align-items: flex-start; }
.parallel-agents .justify-center { justify-content: center; }
.parallel-agents .justify-between { justify-content: space-between; }
.parallel-agents .justify-end { justify-content: flex-end; }
.parallel-agents .shrink-0 { flex-shrink: 0; }
/* grid */
.parallel-agents .grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
@media (min-width: 1024px) {
.parallel-agents .lg\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
/* position */
.parallel-agents .relative { position: relative; }
.parallel-agents .absolute { position: absolute; }
.parallel-agents .inset-0 { inset: 0; }
.parallel-agents .left-1\/2 { left: 50%; }
.parallel-agents .-bottom-0\.5 { bottom: -0.125rem; }
.parallel-agents .-right-0\.5 { right: -0.125rem; }
/* sizing */
.parallel-agents .w-full { width: 100%; }
.parallel-agents .w-px { width: 1px; }
.parallel-agents .h-full { height: 100%; }
.parallel-agents .h-8 { height: 2rem; }
.parallel-agents .min-w-0 { min-width: 0; }
.parallel-agents .min-h-0 { min-height: 0; }
.parallel-agents .min-h-16 { min-height: 4rem; }
.parallel-agents .max-w-full { max-width: 100%; }
.parallel-agents .max-h-60 { max-height: 15rem; }
.parallel-agents .size-1 { width: 0.25rem; height: 0.25rem; }
.parallel-agents .size-3\.5 { width: 0.875rem; height: 0.875rem; }
/* gap */
.parallel-agents .gap-0\.5 { gap: 0.125rem; }
.parallel-agents .gap-1 { gap: 0.25rem; }
.parallel-agents .gap-1\.5 { gap: 0.375rem; }
.parallel-agents .gap-2 { gap: 0.5rem; }
.parallel-agents .gap-3 { gap: 0.75rem; }
.parallel-agents .gap-4 { gap: 1rem; }
/* space-y(子元素纵向间距) */
.parallel-agents .space-y-1 > * + * { margin-top: 0.25rem; }
.parallel-agents .space-y-1\.5 > * + * { margin-top: 0.375rem; }
.parallel-agents .space-y-2 > * + * { margin-top: 0.5rem; }
/* padding */
.parallel-agents .p-0\.5 { padding: 0.125rem; }
.parallel-agents .p-2 { padding: 0.5rem; }
.parallel-agents .p-3 { padding: 0.75rem; }
.parallel-agents .p-4 { padding: 1rem; }
.parallel-agents .px-1\.5 { padding-left: 0.375rem; padding-right: 0.375rem; }
.parallel-agents .px-2 { padding-left: 0.5rem; padding-right: 0.5rem; }
.parallel-agents .px-2\.5 { padding-left: 0.625rem; padding-right: 0.625rem; }
.parallel-agents .px-3 { padding-left: 0.75rem; padding-right: 0.75rem; }
.parallel-agents .px-4 { padding-left: 1rem; padding-right: 1rem; }
.parallel-agents .py-0\.5 { padding-top: 0.125rem; padding-bottom: 0.125rem; }
.parallel-agents .py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; }
.parallel-agents .py-1\.5 { padding-top: 0.375rem; padding-bottom: 0.375rem; }
.parallel-agents .py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
.parallel-agents .py-6 { padding-top: 1.5rem; padding-bottom: 1.5rem; }
.parallel-agents .pt-0\.5 { padding-top: 0.125rem; }
.parallel-agents .pb-2 { padding-bottom: 0.5rem; }
/* margin */
.parallel-agents .mt-0\.5 { margin-top: 0.125rem; }
.parallel-agents .mt-2 { margin-top: 0.5rem; }
.parallel-agents .mt-3 { margin-top: 0.75rem; }
.parallel-agents .mb-2 { margin-bottom: 0.5rem; }
.parallel-agents .ml-2 { margin-left: 0.5rem; }
.parallel-agents .-mx-px { margin-left: -1px; margin-right: -1px; }
/* border + radius */
.parallel-agents .border { border-width: 1px; border-style: solid; }
.parallel-agents .border-b { border-bottom-width: 1px; border-bottom-style: solid; }
.parallel-agents .rounded { border-radius: 0.25rem; }
.parallel-agents .rounded-md { border-radius: 0.375rem; }
.parallel-agents .rounded-lg { border-radius: 0.5rem; }
.parallel-agents .rounded-xl { border-radius: 0.75rem; }
.parallel-agents .rounded-full { border-radius: 9999px; }
/* font-size(含任意值) */
.parallel-agents .text-\[8px\] { font-size: 8px; }
.parallel-agents .text-\[10px\] { font-size: 10px; }
.parallel-agents .text-\[11px\] { font-size: 11px; }
.parallel-agents .text-xs { font-size: 0.75rem; line-height: 1rem; }
.parallel-agents .text-sm { font-size: 0.875rem; line-height: 1.25rem; }
/* font-weight */
.parallel-agents .font-normal { font-weight: 400; }
.parallel-agents .font-medium { font-weight: 500; }
.parallel-agents .font-semibold { font-weight: 600; }
.parallel-agents .font-bold { font-weight: 700; }
.parallel-agents .font-extrabold { font-weight: 800; }
.parallel-agents .font-black { font-weight: 900; }
/* font 其它 */
.parallel-agents .font-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.parallel-agents .italic { font-style: italic; }
.parallel-agents .uppercase { text-transform: uppercase; }
.parallel-agents .tracking-wide { letter-spacing: 0.025em; }
/* line-height */
.parallel-agents .leading-snug { line-height: 1.375; }
.parallel-agents .leading-relaxed { line-height: 1.625; }
/* text-align */
.parallel-agents .text-left { text-align: left; }
.parallel-agents .text-center { text-align: center; }
/* 文本截断 / 换行 */
.parallel-agents .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.parallel-agents .break-words { overflow-wrap: break-word; }
.parallel-agents .line-clamp-2 {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
/* overflow */
.parallel-agents .overflow-hidden { overflow: hidden; }
.parallel-agents .overflow-auto { overflow: auto; }
.parallel-agents .overflow-x-auto { overflow-x: auto; }
.parallel-agents .overflow-y-auto { overflow-y: auto; }
/* 效果 / 变换 / 过渡 */
.parallel-agents .opacity-60 { opacity: 0.6; }
.parallel-agents .shadow-sm { box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); }
.parallel-agents .resize-none { resize: none; }
.parallel-agents .rotate-90 { transform: rotate(90deg); }
.parallel-agents .transition {
transition-property: color, background-color, border-color, fill, stroke, opacity,
box-shadow, transform, filter, backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.parallel-agents .transition-transform {
transition-property: transform;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.parallel-agents .hover\:opacity-90:hover { opacity: 0.9; }
} /* @layer pa-utilities */
@@ -44,12 +44,15 @@ export interface Dialogue {
clarification?: ClarificationPayload;
}
/** 席位 / 结果的状态。`clarify` = 智能体在等用户协助(待确认),不是已完成。 */
export type SeatStatus = "pending" | "running" | "done" | "clarify" | "error";
/** 并行席位的视图状态(席位状态条用)。 */
export interface SeatView {
agentId: string;
name: string;
avatarType: string;
status: "pending" | "running" | "done" | "error";
status: SeatStatus;
/** 关联的 Dialogue.id。 */
bubbleId: number;
}
@@ -87,7 +90,8 @@ export interface AgentResult {
name: string;
/** 该智能体模型返回的正文(Markdown)。 */
content: string;
status: "pending" | "running" | "done" | "error";
/** `clarify` 表示在等用户协助(待确认),宿主据此判断「未完成」。 */
status: SeatStatus;
}
/** 持久化的任务记录(与后端 /api/parallel-agent-tasks 对应)。 */
@@ -3,9 +3,9 @@
* 气泡,维护并行席位状态条;带轻量节流(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";
import type { AgentInput, Dialogue, SeatStatus, SeatView, StreamEvent } from "./types";
import { getAvatarTypeFor } from "./avatar-constants";
import { streamParallelAgents, type ApiConfig } from "./api";
function nowTime(): string {
const d = new Date();
@@ -29,6 +29,18 @@ export function useParallelRun(opts: ParallelRunOptions = {}) {
const running = ref(false);
const abortRef = shallowRef<AbortController | null>(null);
// 正在「单席位重跑」(用户协助后继续)的 agentId 集合。澄清卡片的禁用态按席位取这里,
// 而不是全局 running —— 这样多个智能体都待协助时,协助 A 不会禁用 B/C 的卡片。
const busy = ref<Set<string>>(new Set());
// 每个重跑各自的 AbortController(支持多席位并发协助 + 统一取消)。
const acByAgent = new Map<string, AbortController>();
function setBusy(agentId: string, on: boolean) {
const next = new Set(busy.value);
if (on) next.add(agentId);
else next.delete(agentId);
busy.value = next;
}
// 本轮已「结算」(回调过 onAgentSettled)的 agentId,去重,避免重复回传。
const settled = new Set<string>();
function settleAgent(agentId: string, status: SeatView["status"]) {
@@ -61,6 +73,11 @@ export function useParallelRun(opts: ParallelRunOptions = {}) {
requestAnimationFrame(flushPending);
}
// 断流兜底时把席位状态归一为「终态」:保留待协助 / 出错,其余(running/pending)算完成。
function settleStatusOf(status: SeatStatus): SeatStatus {
return status === "clarify" || status === "error" ? status : "done";
}
function setSeatStatus(agentId: string, status: SeatView["status"]) {
const s = seats.value.find((x) => x.agentId === agentId);
if (s) s.status = status;
@@ -125,9 +142,10 @@ export function useParallelRun(opts: ParallelRunOptions = {}) {
d.streaming = false;
d.confidence = "待确认";
}
setSeatStatus(ev.agent_id, "done");
// 待协助是独立状态,不是 done —— 否则席位条显示 ✓、宿主判定已完成。
setSeatStatus(ev.agent_id, "clarify");
dialogues.value = [...dialogues.value];
settleAgent(ev.agent_id, "done");
settleAgent(ev.agent_id, "clarify");
break;
}
case "agent_error": {
@@ -204,8 +222,8 @@ export function useParallelRun(opts: ParallelRunOptions = {}) {
}
}
dialogues.value = [...dialogues.value];
// 兜底:没收到终态事件就断流的席位也回调一次(按当前席位状态)。
for (const s of seats.value) settleAgent(s.agentId, s.status === "error" ? "error" : "done");
// 兜底:没收到终态事件就断流的席位也回调一次(按当前席位状态,保留待协助/出错)。
for (const s of seats.value) settleAgent(s.agentId, settleStatusOf(s.status));
running.value = false;
abortRef.value = null;
}
@@ -213,6 +231,9 @@ export function useParallelRun(opts: ParallelRunOptions = {}) {
function cancel() {
abortRef.value?.abort();
for (const ac of acByAgent.values()) ac.abort();
acByAgent.clear();
busy.value = new Set();
running.value = false;
}
@@ -224,7 +245,8 @@ export function useParallelRun(opts: ParallelRunOptions = {}) {
cfg: ApiConfig,
args: { taskName: string; brief: string; agentId: string; model?: string },
): Promise<void> {
if (running.value) return;
// 整批分析进行中不允许单独重跑(会和合并流抢同一气泡);同一席位也不能并发重跑。
if (running.value || busy.value.has(args.agentId)) return;
const d = dialogues.value.find((x) => x.agentId === args.agentId);
if (!d) return;
d.content = "";
@@ -237,9 +259,10 @@ export function useParallelRun(opts: ParallelRunOptions = {}) {
dialogues.value = [...dialogues.value];
settled.delete(args.agentId); // 该席位重跑,允许再次回传
running.value = true;
// 不动全局 running —— 用按席位的 busy,这样可同时协助多个待确认的智能体。
setBusy(args.agentId, true);
const ac = new AbortController();
abortRef.value = ac;
acByAgent.set(args.agentId, ac);
try {
await streamParallelAgents(
cfg,
@@ -255,9 +278,9 @@ export function useParallelRun(opts: ParallelRunOptions = {}) {
}
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;
settleAgent(args.agentId, settleStatusOf(st?.status ?? "done"));
acByAgent.delete(args.agentId);
setBusy(args.agentId, false);
}
}
@@ -271,11 +294,13 @@ export function useParallelRun(opts: ParallelRunOptions = {}) {
restored.forEach((d) => {
bubbleByAgent.set(d.agentId, d.id);
if (!seatMap.has(d.agentId)) {
const status: SeatStatus =
d.confidence === "出错" ? "error" : d.clarification ? "clarify" : "done";
seatMap.set(d.agentId, {
agentId: d.agentId,
name: d.sender,
avatarType: d.avatarType,
status: d.confidence === "出错" ? "error" : "done",
status,
bubbleId: d.id,
});
}
@@ -292,5 +317,5 @@ export function useParallelRun(opts: ParallelRunOptions = {}) {
seats.value = [];
}
return { dialogues, seats, running, start, cancel, hydrate, reset, rerunAgent };
return { dialogues, seats, running, busy, start, cancel, hydrate, reset, rerunAgent };
}