代码提交
This commit is contained in:
1 parent
bd426ed1d3
commit
d9afd8d887
15 files changed
+497
-229
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = [];
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user