代码提交

This commit is contained in:
董宏凯 committed 2026-06-10 17:44:44 +08:00
1 parent bd426ed1d3
commit d9afd8d887
15 files changed
+497 -229

No files matched your search

+40 -21
View File
@@ -51,6 +51,8 @@ src/
├─ parallel-agents.css 作用域深色 token(--pa-*) + .pa-* 语义类 + Markdown 样式 + 工具类子集(无 Tailwind)
├─ ParallelAgentsPanel.vue 主组件:席位条/会话区 + 全部状态编排(表单/按钮在宿主侧)
├─ ParallelSeatStrip.vue 席位状态条(头像 + running/done/clarify/error 角标,点击切换单显)
├─ AutoScrollPane.vue 自动下滑滚动窗格:包住单席位消息,超高出滚动条 + 流式自动钉底 +
│ 用户滚动暂停 + 半透明「自动滚动」浮动按钮(每席位一实例,互不影响)
├─ MessageBubble.vue 单条气泡:头像/发言人/徽标/时间/步骤条/正文/光标/协作卡片
├─ MessageStepsCard.vue 步骤条(ChainOfThought 结构,1:1 对齐 Step2)
├─ StepIcon.vue 步骤图标(lucide 风格 inline SVG,按 IconKey)
@@ -100,7 +102,7 @@ panel emit "completed"(AgentResult[]);可点「保存」(按 taskId upsert) /
| `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` → 卡片;席位置 **clarify**(待协助,非 done)。**注**:后端已把 `ask_clarification` 加入 `_EXCLUDED_TOOLS`,本面板**无人值守**不再发该事件;前端协作卡片相关代码保留(兜底,正常不触发) |
| `agent_clarification` | `agent_id, content, question, clarification_type, clarification_context, options, allow_*` | 设 `dialogue.clarification` → 卡片;席位置 **clarify**(待协助,非 done)。澄清卡片**任何时候都可点**(即便整批仍在跑),用户答完 → `panel.onClarify` → `rerunAgent` 重跑该席位 |
| `agent_done` | `agent_id, content` | 收口,正文以终态为准 |
| `agent_error` | `agent_id, message` | 该席位置 error |
| `all_done` | — | 全部结束 |
@@ -122,17 +124,30 @@ panel emit "completed"(AgentResult[]);可点「保存」(按 taskId upsert) /
```
ParallelAgentsPanel
├─ ParallelSeatStrip ── Avatar (席位条,点头像切换单显)
└─ MessageBubble (当前选中席位)
├─ Avatar
├─ MessageStepsCard ── StepIcon (步骤条;getStepDisplay/getStepArgs 翻译 key+args)
├─ MarkdownContent (markdown-it)
└─ ClarificationCard (msg.clarification 存在时;提交→panel.onClarify→rerunAgent)
└─ AutoScrollPane (当前选中席位;限高滚动 + 流式自动下滑 + 浮动「自动滚动」按钮)
└─ MessageBubble
├─ Avatar
├─ MessageStepsCard ── StepIcon (步骤条;getStepDisplay/getStepArgs 翻译 key+args)
├─ MarkdownContent (markdown-it)
└─ ClarificationCard (msg.clarification 存在时;提交→panel.onClarify→rerunAgent)
```
> **自动下滑 / 高度自适应**(`AutoScrollPane.vue`):会话区只负责限高(`min-h-0 flex-1 overflow-hidden`),
> 真正的滚动与自动下滑下放到**每个席位卡片内部**的窗格 —— 父元素不够高时窗格内出现滚动条;模型流式输出时
> 用 `ResizeObserver` 监听内容增高并钉到底部;用户向上滚动(离开底部)即暂停自动下滑、滚回底部自动恢复;
> 暂停且内容可滚动时底部居中浮出半透明「自动滚动」按钮(`.pa-autoscroll-btn`),点击恢复并滚到底。
> 每个席位各一个实例(`v-for :key=agentId`),autoScroll / 滚动位置都在实例内,**多智能体互不影响**。
> 席位卡片靠 `.pa-rows-fill`(`grid-auto-rows: minmax(0,1fr)`)受限于会话区高度,从而内部滚动而非撑破容器。
`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()`
- emits:
- `agent-completed(AgentReply)` —— **单个**智能体**真正完成(done)**时各触发一次;`AgentReply = {agent_id, name, content}`
(智能体名称 + 模型返回结果,**不含 status**)。待用户协助(clarify)/ 出错(error)**不触发**。
- `completed(AgentReply[])` —— **所有**智能体都完成(done/error、**没有任何 clarify 在等用户**)后,整批触发一次;
若有席位在等用户协助,本轮不发,待其答完重跑完成后再发。
- `saved(SavedTask)` / `update:taskId(string)` / `update:taskName(string)` / `update:brief(string)`
- `defineExpose`:`getResults()`(返回 `AgentResult[]`,含 status) / `getResult(id)` / `start()` / `reanalyze()` / `save()` / `cancel()` / `loadTask(id)`
---
@@ -160,16 +175,20 @@ ParallelAgentsPanel
| 模型列表 | `GET /api/models` → `{models:[{name, display_name}]}` |
**后端运行策略(重要)**:每个并行席位是「执行席位」,不是总控。
- **禁用工具** `_EXCLUDED_TOOLS = [write_file, str_replace, agent_orchestration, bash, ask_clarification]`:
①禁写文件(并行不互相打架);②禁 `agent_orchestration`/`bash`(防弱模型模仿总控**派活**或绕道脚本逃逸);
③禁 `ask_clarification`(**禁用用户协助**:本面板是并行**无人值守**扇出,没有让单席位中途打断、向用户
要澄清的交互位 —— 席位信息不足时自行做**合理假设**并写进正文交付,而非挂起等人)。
- **保留** 只读 / 检索工具 → 步骤条正常。
- 任务消息带**角色边界护栏**(「执行席位、不要模仿总控调度 / 派活」)+ **无人值守护栏**(「信息不足时
自行合理假设并注明,不要反问用户 / 不要挂起等待补充」)。
- **禁用工具** `_EXCLUDED_TOOLS = [write_file, str_replace, agent_orchestration, bash]`:①禁写文件(并行
不互相打架);②禁 `agent_orchestration`/`bash`(防弱模型模仿总控**派活**或绕道脚本逃逸)。
- **保留** 只读 / 检索工具 + `ask_clarification` → 步骤条、协作卡片正常(**用户协助开启**)。
- 任务消息带**角色边界护栏**(「执行席位、不要模仿总控调度 / 派活」)。
- 步骤 `args` 通过累积 `tool_call_chunks` + 终态全量 `tool_calls` 补全(流式期间空 args 不发,防钉死)。
- `ask_clarification` 已被禁用,正常不再产生 `agent_clarification` 事件;`_clarification_from_messages`
仅作终态兜底(工具被禁后恒返回 `None`),不影响其他业务路由(圆桌 / 普通问答的澄清照常)。
- `ask_clarification` 从终态 messages 抽成结构化 `agent_clarification` 事件。
**前端澄清(用户协助)交互要点**(`useParallelRun` / `ClarificationCard` / `ParallelAgentsPanel`):
- 协作卡片**不再因 `submitting` 禁用**:整批仍在跑时也能立刻回答某个已澄清的席位。
- `rerunAgent` 放开了「整批 running 时禁止重跑」的限制(澄清是该席位合并流里的终态,不会再抢气泡),
只拦同一席位的并发重跑;收尾循环(`start` 的 finally)**跳过 busy 席位**,交给 `rerunAgent` 自己结算。
- 答澄清后**不立刻清空**正文/步骤:先移除卡片、置流式并标记 `rerunPending`,等重跑产出**第一条新事件**
时(`maybeStartRerun`)再清空替换,避免单选视图整列空白闪烁。
- 「单席位结算回传」`onAgentSettled`:**`clarify` 不回传**(待用户协助≠模型完成),等重跑出 `done/error` 再回传。
---
@@ -223,6 +242,7 @@ pnpm build:lib # 组件库:dist/parallel-agents-vue.{js,umd.cjs,css}
| 改布局类 / 加新工具类 | `parallel-agents.css` 末尾的「工具类子集」补规则,或组件 scoped `<style>`(不要装 Tailwind) |
| 改默认后端地址 / 模型 | `config.ts`(`DEFAULT_BASE_URL` / `DEFAULT_MODEL`) |
| 多席位并排显示(取消单选) | `ParallelAgentsPanel.vue` 的 `selected` / `visibleSeats` / `toggleSeat` 逻辑 |
| 改自动下滑 / 浮动按钮行为 | `AutoScrollPane.vue`(`BOTTOM_TOLERANCE` 容差 / `onScroll` 暂停恢复逻辑)+ `.pa-autoscroll-btn` 样式 |
| 宿主自定义存储(不走后端) | 用 `@completed` + `getResults()` 取数据,自行存;不调用 `createTask/updateTask` |
| 改鉴权方式 | `api.ts` 的 `resolveToken` / `loginByUsername` / `authedFetch` + `config.ts` 的 `TOKEN_PATH` |
@@ -232,9 +252,8 @@ pnpm build:lib # 组件库:dist/parallel-agents-vue.{js,umd.cjs,css}
- **agent_id 必须真实**(后端注册过的 id),只填名字不行;可用 id 见 [06-可用智能体清单.md](./06-可用智能体清单.md)。
- 并行席位**禁派活/脚本/写文件**:本面板里每个智能体只做自己那份分析,产出走正文。
- **已禁用「用户协助」**:并行无人值守,后端把 `ask_clarification` 加入 `_EXCLUDED_TOOLS`,席位不再向用户要
澄清(信息不足时自行合理假设并在正文注明)。前端 `ClarificationCard.vue` / `onClarify` / `rerunAgent`
等协作链路代码保留作兜底,正常不触发;**仅影响本面板**,圆桌 / 普通问答的澄清不受影响。
- 协作卡片回答是**重跑单个智能体**(新线程 + 把回答拼进任务),不是 LangGraph 原地 resume(当前已禁用,仅兜底保留)。
- **用户协助(澄清)已启用**:`ask_clarification` 保留;席位信息不足时可弹协作卡片。卡片任何时候都可点
(含整批仍在跑时),用户答完即重跑该席位;待协助(clarify)不算完成、不回传给宿主。
- 协作卡片回答是**重跑单个智能体**(新线程 + 把回答拼进任务),不是 LangGraph 原地 resume。
- token 取自公共端点(无门槛旁路)—— 适合内网;若网关要暴露公网,建议给取 token 端点加共享密钥校验。
- 跨域:宿主与后端不同源时,后端需放开该来源 CORS + `credentials`。
+4 -4
View File
@@ -7,7 +7,7 @@
*/
import { reactive, ref } from "vue";
import { ParallelAgentsPanel } from "./parallel-agents";
import type { AgentInput, AgentResult } from "./parallel-agents";
import type { AgentInput, AgentReply } from "./parallel-agents";
import { DEFAULT_BASE_URL } from "./parallel-agents/config";
@@ -34,10 +34,10 @@ 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 onAgentCompleted(result: AgentReply) {
console.log("[demo] agent-completed → 某智能体分析完:", result.name, result.content);
}
function onCompleted(results: AgentResult[]) {
function onCompleted(results: AgentReply[]) {
console.log("[demo] completed → 全部分析完,各智能体返回内容:", results);
}
+1
View File
@@ -13,6 +13,7 @@ import { ParallelAgentsPanel } from "./parallel-agents";
export { ParallelAgentsPanel };
export type {
AgentInput,
AgentReply,
AgentResult,
Dialogue,
SavedTask,
@@ -0,0 +1,108 @@
<script setup lang="ts">
/**
* 自动下滑滚动窗格 —— 包住单个席位的消息内容,提供:
* ① 高度自适应:父元素不够高时,内容超出即在**本窗格内**出现滚动条(不撑破容器)。
* ② 流式自动下滑:模型输出(内容变高)时自动钉到底部。
* ③ 用户接管:用户向上滚动离开底部 → 暂停自动下滑;滚回底部 → 自动恢复。
* ④ 暂停且可滚动时,窗格底部居中浮出半透明「自动滚动」按钮,点击恢复并滚到底。
*
* **每个席位各渲染一个本组件实例**(父级 `v-for :key=agentId`),状态(autoScroll / 滚动位置 /
* 观察器)都在实例内,因此多个智能体之间的自动下滑**互不影响**。
*
* 设计要点:滚动容器(viewport)与浮动按钮是**兄弟**关系,按钮挂在不滚动的 relative 根上,
* 所以按钮不会跟着内容一起滚走。自动下滑一律用瞬时跳转(behavior:auto)——既能跟上流式速度,
* 又避免平滑滚动过程中的中间帧把 autoScroll 误判成「离开底部」。
*/
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
/** 判定「已在底部」的容差(px):避免亚像素/缩放导致永远差 1px 到不了底。 */
const BOTTOM_TOLERANCE = 24;
const viewport = ref<HTMLElement | null>(null);
const content = ref<HTMLElement | null>(null);
/** 是否处于「跟随底部」状态(true = 自动下滑开启)。 */
const autoScroll = ref(true);
/** 内容是否超出窗格(只有可滚动时浮动按钮才有意义)。 */
const canScroll = ref(false);
let ro: ResizeObserver | null = null;
function atBottom(el: HTMLElement): boolean {
return el.scrollHeight - el.scrollTop - el.clientHeight <= BOTTOM_TOLERANCE;
}
function scrollToBottom(): void {
const el = viewport.value;
if (!el) return;
// 瞬时跳转:跟上流式、且不产生平滑滚动的中间帧(否则 onScroll 会误判离开底部)。
el.scrollTop = el.scrollHeight;
}
function refreshMetrics(): void {
const el = viewport.value;
canScroll.value = !!el && el.scrollHeight - el.clientHeight > BOTTOM_TOLERANCE;
}
/** 用户滚动:到底→跟随,离开底部→暂停。程序化跳到底后这里也会判为到底,故保持开启。 */
function onScroll(): void {
const el = viewport.value;
if (!el) return;
autoScroll.value = atBottom(el);
refreshMetrics();
}
/** 点击浮动按钮:恢复自动下滑并立即滚到底。 */
function resume(): void {
autoScroll.value = true;
scrollToBottom();
refreshMetrics();
}
const showButton = computed(() => canScroll.value && !autoScroll.value);
onMounted(() => {
// 首屏(含切到一个已在后台流式的席位):先钉到底部。
void nextTick(() => {
scrollToBottom();
refreshMetrics();
});
// 内容高度变化(流式追加正文 / 步骤条 / 表格渲染等)→ 跟随时钉底,并刷新可滚动态。
ro = new ResizeObserver(() => {
if (autoScroll.value) scrollToBottom();
refreshMetrics();
});
if (content.value) ro.observe(content.value);
if (viewport.value) ro.observe(viewport.value);
});
onBeforeUnmount(() => {
ro?.disconnect();
ro = null;
});
</script>
<template>
<div class="pa-autoscroll">
<div ref="viewport" class="pa-autoscroll-view" @scroll.passive="onScroll">
<div ref="content">
<slot />
</div>
</div>
<button
v-if="showButton"
type="button"
class="pa-autoscroll-btn"
title="恢复自动滚动"
aria-label="恢复自动滚动"
@click="resume"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 5v14" />
<path d="m19 12-7 7-7-7" />
</svg>
<span>自动滚动</span>
</button>
</div>
</template>
@@ -24,7 +24,9 @@ function norm(opts: ClarOption[]): NormOption[] {
}
const options = computed(() => norm(props.payload.options));
const allowMultiple = computed(() => !!props.payload.allowMultiple);
// 产品约定:协作卡片的选项**默认多选**(勾选多个 + 自定义补充,再点「发送回答」),
// 不再受后端 allow_multiple 影响,避免单选「点击即发」误触。
const allowMultiple = computed(() => true);
const allowCustom = computed(() => props.payload.allowCustom !== false);
const deferSubmit = computed(() => allowCustom.value); // 允许自定义时不在单击即发,给机会补充
const showSubmit = computed(() => allowMultiple.value || allowCustom.value || options.value.length === 0);
@@ -44,7 +46,7 @@ function buildAnswer(ids: string[]): string | null {
}
function clickOption(id: string) {
if (props.submitting) return;
// 注意:不再因 submitting 禁止点击 —— 并行批次仍在跑(running)时也允许用户立刻回答澄清。
if (!allowMultiple.value) {
if (deferSubmit.value) {
sel.ids = [id];
@@ -58,7 +60,8 @@ function clickOption(id: string) {
const canSubmit = computed(() => sel.ids.length > 0 || sel.custom.trim().length > 0);
function submit() {
if (props.submitting || !canSubmit.value) return;
// 同上:不因 submitting 拦截,只要求至少选了/填了内容。
if (!canSubmit.value) return;
const ans = buildAnswer(sel.ids);
if (ans) emit("submit", ans);
}
@@ -93,7 +96,6 @@ function submit() {
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)
@@ -116,7 +118,6 @@ function submit() {
<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="
@@ -134,10 +135,10 @@ function submit() {
<button
type="button"
class="pa-btn-primary h-8 rounded-xl px-4 text-xs font-bold"
:disabled="submitting || !canSubmit"
:disabled="!canSubmit"
@click="submit"
>
{{ submitting ? "发送中…" : "发送回答" }}
发送回答
</button>
</div>
</div>
@@ -12,7 +12,7 @@
* 自适应容器:根元素 `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 type { AgentInput, AgentReply, AgentResult, Dialogue, SavedTask, SeatView } from "./types";
import { useParallelRun } from "./useParallelRun";
import {
createTask,
@@ -24,6 +24,7 @@ import {
import { DEFAULT_BASE_URL } from "./config";
import ParallelSeatStrip from "./ParallelSeatStrip.vue";
import MessageBubble from "./MessageBubble.vue";
import AutoScrollPane from "./AutoScrollPane.vue";
const props = withDefaults(
defineProps<{
@@ -56,10 +57,10 @@ const emit = defineEmits<{
(e: "update:taskName", name: string): void;
/** 回显已保存任务时回传简介。 */
(e: "update:brief", brief: string): void;
/** 单个智能体一分析完就回传它的结果(每个席位完成时各触发一次)。 */
(e: "agent-completed", result: AgentResult): void;
/** 一轮并行分析结束时回传各智能体的返回内容。 */
(e: "completed", results: AgentResult[]): void;
/** 单个智能体**真正完成(done)**时回传它的结果对象 `{ agent_id, name, content }`(待协助/出错不发)。 */
(e: "agent-completed", result: AgentReply): void;
/** **所有**智能体都完成(done/error、无待协助)后,回传各智能体的返回结果数组。 */
(e: "completed", results: AgentReply[]): void;
}>();
const cfg = computed<ApiConfig>(() => ({
@@ -72,9 +73,15 @@ const cfg = computed<ApiConfig>(() => ({
// 单个智能体完成即回传它的结果(一分析完就给宿主)。
const run = useParallelRun({
onAgentSettled: (agentId) => {
const r = getResult(agentId);
if (r) emit("agent-completed", r);
onAgentSettled: (agentId, status) => {
// 只在**真正完成(done)**时回传单个结果;待用户协助(clarify)/出错(error)都不算完成,不回传,
// 免得宿主把「在等用户回答澄清」的中间态当成已出结果。等用户答完、该席位重跑出 done 时再回传。
if (status === "done") {
const r = getResult(agentId);
if (r) emit("agent-completed", toReply(r));
}
// 每有一个席位收尾就检查:是否**所有**席位都完成了 → 是则发一次批量结果。
maybeEmitCompleted();
},
});
const { dialogues, seats, running, busy } = run;
@@ -154,6 +161,21 @@ function getResults(): AgentResult[] {
function getResult(agentId: string): AgentResult | null {
return getResults().find((r) => r.agent_id === agentId) ?? null;
}
/** AgentResult → 回传给宿主的精简对象(智能体名称 + 模型返回结果),去掉 status。 */
function toReply(r: AgentResult): AgentReply {
return { agent_id: r.agent_id, name: r.name, content: r.content };
}
/** 是否所有席位都已收尾:done / error 都算「已返回」,只要还有 clarify(待协助)/ running / pending 就不算。 */
function allAgentsDone(): boolean {
const list = seats.value;
if (!list.length) return false;
return list.every((s) => s.status === "done" || s.status === "error");
}
/** 仅当**所有**智能体都完成时,才把全部结果作为批量 completed 发给宿主(待协助未答完不发)。 */
function maybeEmitCompleted() {
if (!allAgentsDone()) return;
emit("completed", getResults().map(toReply));
}
// 模板 ref 用:`<ParallelAgentsPanel ref="panelRef" />`
// 宿主自行渲染按钮,调用下面这些方法 / 读取状态:
// panelRef.value.reanalyze() // 开始 / 重新分析
@@ -188,8 +210,9 @@ async function onClarify(agentId: string, answer: string) {
brief: merged,
agentId,
model: model.value,
userNote: answer, // 在该席位历史里展示「我的补充」,协助前的内容不丢
});
emit("completed", getResults());
// 批量 completed 由 onAgentSettled→maybeEmitCompleted 在「全部完成」时统一发;这里不再直接发。
void autoSave(); // 重跑完也自动存一次
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
@@ -206,8 +229,8 @@ async function onStart() {
agents: props.agents,
model: model.value,
});
// 一轮结束:回传各智能体返回内容(宿主也可用 ref 调 getResults())。
emit("completed", getResults());
// 批量 completed 改由 onAgentSettled→maybeEmitCompleted 在「全部智能体完成」时统一发:
// 若有席位在等用户协助(clarify),本轮不发批量,待其答完重跑完成后再发。
void autoSave(); // 分析完自动保存一次
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
@@ -324,25 +347,29 @@ onMounted(async () => {
<ParallelSeatStrip :seats="seats" :selected="selected" @toggle="toggleSeat" />
</div>
<!-- 会话区:并行双列 -->
<div class="min-h-0 flex-1 overflow-y-auto p-4">
<!-- 会话区:并行双列。本容器只负责限高(min-h-0 flex-1),不整体滚动;
滚动条与自动下滑下放到每个席位卡片内部的 AutoScrollPane(各智能体互不影响)。 -->
<div class="flex min-h-0 flex-1 flex-col overflow-hidden p-4">
<div
v-if="hasResults"
class="grid gap-4"
class="grid min-h-0 flex-1 gap-4 pa-rows-fill"
: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"
class="pa-surface-soft pa-border relative flex min-h-0 min-w-0 flex-col overflow-hidden rounded-xl border"
>
<MessageBubble
v-if="dialogueById.get(seat.bubbleId)"
:msg="dialogueById.get(seat.bubbleId)!"
:submitting="running || busy.has(seat.agentId)"
@clarify="onClarify"
/>
<div v-else class="py-6 text-center text-xs pa-text-muted">
<AutoScrollPane v-if="dialogueById.get(seat.bubbleId)">
<div class="p-3">
<MessageBubble
:msg="dialogueById.get(seat.bubbleId)!"
:submitting="running || busy.has(seat.agentId)"
@clarify="onClarify"
/>
</div>
</AutoScrollPane>
<div v-else class="flex flex-1 items-center justify-center py-6 text-center text-xs pa-text-muted">
{{ seat.name }} · 等待输出…
</div>
</div>
@@ -14,6 +14,7 @@ import "./parallel-agents.css";
export { default as ParallelAgentsPanel } from "./ParallelAgentsPanel.vue";
export type {
AgentInput,
AgentReply,
AgentResult,
ClarificationPayload,
ClarOption,
@@ -30,6 +30,16 @@
font-size: 14px;
line-height: 1.6;
box-sizing: border-box;
/* 根容器布局:必须写在这里,不能只靠模板上的 .h-full/.flex/.flex-col 工具类 ——
* 那些工具类是后代选择器(.parallel-agents .flex),匹配不到「自身就带 .parallel-agents
* 类」的根元素,于是 height:100% / display:flex 在根上从不生效,整条高度链断裂、不出滚动条。
* 这里直接给根定死:撑满宿主给定的高度并作纵向 flex 容器(min-height:0 允许子项内部滚动)。 */
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 0;
}
.parallel-agents *,
.parallel-agents *::before,
@@ -202,6 +212,58 @@
animation: pa-ping 1.2s cubic-bezier(0, 0, 0.2, 1) infinite;
}
/* ── 自动下滑窗格(AutoScrollPane.vue)────────────────────────────────────────
* 根 = 不滚动的 relative 容器,靠 flex-1 填满席位卡片(卡片高度受会话区限制);
* viewport = 绝对定位铺满根,自己滚动 —— 内部高度不依赖逐层 flex 传导,只要卡片
* 有确定高度就一定能正确限高出滚动条。浮动按钮是 viewport 的兄弟,挂在不滚动的根上。 */
.parallel-agents .pa-autoscroll {
position: relative;
flex: 1 1 0%;
min-height: 0;
width: 100%;
}
.parallel-agents .pa-autoscroll-view {
position: absolute;
inset: 0;
overflow-y: auto;
overflow-x: hidden;
}
/* 浮动「自动滚动」按钮:半透明、底部居中、悬浮在内容之上;不随内容滚走。 */
.parallel-agents .pa-autoscroll-btn {
position: absolute;
left: 50%;
bottom: 12px;
transform: translateX(-50%);
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
font-size: 12px;
font-weight: 600;
color: var(--pa-fg);
background: color-mix(in oklch, var(--pa-card) 70%, transparent);
border: 1px solid color-mix(in oklch, var(--pa-primary) 45%, var(--pa-border));
border-radius: 9999px;
cursor: pointer;
opacity: 0.82;
backdrop-filter: blur(6px);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.28);
transition: opacity 0.15s ease, background-color 0.15s ease, transform 0.15s ease;
z-index: 5;
}
.parallel-agents .pa-autoscroll-btn:hover {
opacity: 1;
background: color-mix(in oklch, var(--pa-card) 88%, transparent);
}
.parallel-agents .pa-autoscroll-btn:active {
transform: translateX(-50%) translateY(1px);
}
.parallel-agents .pa-autoscroll-btn svg {
width: 14px;
height: 14px;
}
/* ── Markdown 正文(对齐 Step2 sharedMarkdownComponents 的深色观感)──────────── */
.parallel-agents .pa-md {
color: var(--pa-fg);
@@ -318,6 +380,8 @@
@media (min-width: 1024px) {
.parallel-agents .lg\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
/* 行等高填充容器:让席位卡片高度受限于会话区,从而卡片内部出现滚动条而非撑破容器 */
.parallel-agents .pa-rows-fill { grid-auto-rows: minmax(0, 1fr); }
/* position */
.parallel-agents .relative { position: relative; }
@@ -84,7 +84,7 @@ export type StreamEvent =
| { type: "agent_error"; agent_id: string; message: string }
| { type: "all_done" };
/** 单个智能体的返回结果(供宿主通过 getResults() / completed 事件读取)。 */
/** 单个智能体的返回结果(供宿主通过 getResults() 读取)。 */
export interface AgentResult {
agent_id: string;
name: string;
@@ -94,6 +94,18 @@ export interface AgentResult {
status: SeatStatus;
}
/**
* 回传给宿主的「已完成」结果(agent-completed / completed 事件用):只含**智能体名称 + 模型返回
* 结果**(agent_id 便于识别)。不带 status —— 因为只在真正完成(done)时才回传,本身即代表已完成。
*/
export interface AgentReply {
agent_id: string;
/** 智能体名称(显示名)。 */
name: string;
/** 模型返回结果(正文 Markdown)。 */
content: string;
}
/** 持久化的任务记录(与后端 /api/parallel-agent-tasks 对应)。 */
export interface SavedTask {
id: string;
@@ -49,6 +49,16 @@ export function useParallelRun(opts: ParallelRunOptions = {}) {
opts.onAgentSettled?.(agentId, status);
}
// 「单席位重跑」(回答澄清后继续)要**保留**协助前的历史正文:把「旧正文 + 我的补充」作为
// 前缀(在 rerunAgent 里写进 d.content 并记到这里),新一轮输出接在后面。该席位收到带 content
// 的终态事件(done / clarification)时,把前缀拼回最终正文最前面,避免被后端终态正文覆盖掉历史。
const rerunPrefix = new Map<string, string>();
function applyRerunPrefix(agentId: string, content: string): string {
const pre = rerunPrefix.get(agentId);
rerunPrefix.delete(agentId);
return (pre ?? "") + content;
}
// 增量节流:累积到 pending,rAF 合并刷新一次,避免逐 token reflow。
const pending = new Map<string, string>(); // agentId -> 待追加文本
let rafScheduled = false;
@@ -117,7 +127,9 @@ export function useParallelRun(opts: ParallelRunOptions = {}) {
flushPending();
const d = findDialogue(ev.agent_id);
if (d) {
if (ev.content) d.content = ev.content; // 终态正文以后端为准
// 终态正文以后端为准;若是「回答澄清后的重跑」,把协助前的历史前缀拼回正文最前面。
if (ev.content) d.content = applyRerunPrefix(ev.agent_id, ev.content);
else rerunPrefix.delete(ev.agent_id);
d.streaming = false;
d.confidence = "已完成";
}
@@ -130,7 +142,9 @@ export function useParallelRun(opts: ParallelRunOptions = {}) {
flushPending();
const d = findDialogue(ev.agent_id);
if (d) {
if (ev.content) d.content = ev.content;
// 同 done:重跑后再次澄清也要保留历史前缀。
if (ev.content) d.content = applyRerunPrefix(ev.agent_id, ev.content);
else rerunPrefix.delete(ev.agent_id);
d.clarification = {
question: ev.question,
clarificationType: ev.clarification_type,
@@ -150,6 +164,7 @@ export function useParallelRun(opts: ParallelRunOptions = {}) {
}
case "agent_error": {
flushPending();
rerunPrefix.delete(ev.agent_id); // 出错就丢弃前缀(正文已含历史+已流出的增量)
const d = findDialogue(ev.agent_id);
if (d) {
d.content = d.content || `⚠️ 该智能体执行出错:${ev.message}`;
@@ -214,8 +229,10 @@ export function useParallelRun(opts: ParallelRunOptions = {}) {
ac.signal,
);
} finally {
// 收尾:任何仍在 streaming 的气泡都置完成态。
// 收尾:任何仍在 streaming 的气泡都置完成态。跳过正在「协助重跑」的席位(busy)——
// 它有自己独立的流,由 rerunAgent 的 finally 负责收尾,别被整批收尾误置完成。
for (const d of dialogues.value) {
if (busy.value.has(d.agentId)) continue;
if (d.streaming) {
d.streaming = false;
if (d.confidence === "并行执行中") d.confidence = "已完成";
@@ -223,7 +240,11 @@ export function useParallelRun(opts: ParallelRunOptions = {}) {
}
dialogues.value = [...dialogues.value];
// 兜底:没收到终态事件就断流的席位也回调一次(按当前席位状态,保留待协助/出错)。
for (const s of seats.value) settleAgent(s.agentId, settleStatusOf(s.status));
// 同样跳过 busy 席位:它的结算交给 rerunAgent,避免在重跑途中被提前判完成回传。
for (const s of seats.value) {
if (busy.value.has(s.agentId)) continue;
settleAgent(s.agentId, settleStatusOf(s.status));
}
running.value = false;
abortRef.value = null;
}
@@ -243,14 +264,23 @@ export function useParallelRun(opts: ParallelRunOptions = {}) {
*/
async function rerunAgent(
cfg: ApiConfig,
args: { taskName: string; brief: string; agentId: string; model?: string },
args: { taskName: string; brief: string; agentId: string; model?: string; userNote?: string },
): Promise<void> {
// 整批分析进行中不允许单独重跑(会和合并流抢同一气泡);同一席位也不能并发重跑。
if (running.value || busy.value.has(args.agentId)) return;
// 允许在整批仍在跑(running)时回答某个已澄清的席位:它在合并流里那一轮已经结束
// (澄清是该席位的终态),不会再抢同一气泡。这里只拦截同一席位的并发重跑。
if (busy.value.has(args.agentId)) return;
const d = dialogues.value.find((x) => x.agentId === args.agentId);
if (!d) return;
d.content = "";
d.steps = [];
// 保留协助前的历史:把「旧正文 + 我的补充」作为前缀立刻写进正文(不清空,避免历史丢失),
// 新一轮输出接在后面;终态事件回来时由 applyRerunPrefix 把前缀拼到最终正文最前面。
const prev = (d.content || "").trim();
const note = (args.userNote || "").trim();
const prefix = prev
? `${prev}\n\n---\n\n**🙋 我的补充:** ${note || "(已补充)"}\n\n---\n\n`
: "";
rerunPrefix.set(args.agentId, prefix);
d.content = prefix;
d.steps = []; // 步骤条按新一轮重新累计
d.clarification = undefined;
d.streaming = true;
d.confidence = "并行执行中";
@@ -313,6 +343,7 @@ export function useParallelRun(opts: ParallelRunOptions = {}) {
pending.clear();
bubbleByAgent.clear();
settled.clear();
rerunPrefix.clear();
dialogues.value = [];
seats.value = [];
}