This commit is contained in:
fengxiangdebaozi committed 2026-06-09 14:06:01 +08:00
commit 10795d55e6
31 files changed
+1551 -152

No files matched your search

@@ -26,6 +26,8 @@ export interface ChainCreatePayload {
stages?: string[][] | null; stages?: string[][] | null;
/** Optional per-stage goals aligned to `stages` (dag-only). null = none. */ /** Optional per-stage goals aligned to `stages` (dag-only). null = none. */
stageGoals?: (string | null)[] | null; stageGoals?: (string | null)[] | null;
/** Optional coordinator orchestration prompt (dag-only). null/"" = none. */
coordinatorPrompt?: string | null;
} }
/** Partial update — send only what changed. */ /** Partial update — send only what changed. */
@@ -37,6 +39,8 @@ export interface ChainUpdatePayload {
stages?: string[][] | null; stages?: string[][] | null;
/** Per-stage goals aligned to `stages`; send `null` to clear, omit to leave. */ /** Per-stage goals aligned to `stages`; send `null` to clear, omit to leave. */
stageGoals?: (string | null)[] | null; stageGoals?: (string | null)[] | null;
/** 写给总控的编排提示;send `null`/"" to clear, omit to leave. */
coordinatorPrompt?: string | null;
/** 发布开关:true 发布为公共 / false 取消发布;omit 不改。仅 owner 生效。 */ /** 发布开关:true 发布为公共 / false 取消发布;omit 不改。仅 owner 生效。 */
isPublic?: boolean; isPublic?: boolean;
} }
@@ -65,6 +69,7 @@ interface ServerChain {
seats: ChainSeat[]; seats: ChainSeat[];
stages?: string[][] | null; stages?: string[][] | null;
stage_goals?: (string | null)[] | null; stage_goals?: (string | null)[] | null;
coordinator_prompt?: string | null;
is_public?: boolean; is_public?: boolean;
is_mine?: boolean; is_mine?: boolean;
created_at: string | null; created_at: string | null;
@@ -79,6 +84,7 @@ function toChain(s: ServerChain): BusinessChain {
seats: Array.isArray(s.seats) ? s.seats : [], seats: Array.isArray(s.seats) ? s.seats : [],
stages: Array.isArray(s.stages) ? s.stages : null, stages: Array.isArray(s.stages) ? s.stages : null,
stageGoals: Array.isArray(s.stage_goals) ? s.stage_goals : null, stageGoals: Array.isArray(s.stage_goals) ? s.stage_goals : null,
coordinatorPrompt: s.coordinator_prompt ?? null,
isPublic: s.is_public ?? false, isPublic: s.is_public ?? false,
// 后端缺省时按「我的」处理(向后兼容旧响应)。 // 后端缺省时按「我的」处理(向后兼容旧响应)。
isMine: s.is_mine ?? true, isMine: s.is_mine ?? true,
@@ -95,6 +101,7 @@ function fromCreate(p: ChainCreatePayload) {
seats: p.seats, seats: p.seats,
...(p.stages !== undefined ? { stages: p.stages } : {}), ...(p.stages !== undefined ? { stages: p.stages } : {}),
...(p.stageGoals !== undefined ? { stage_goals: p.stageGoals } : {}), ...(p.stageGoals !== undefined ? { stage_goals: p.stageGoals } : {}),
...(p.coordinatorPrompt !== undefined ? { coordinator_prompt: p.coordinatorPrompt } : {}),
}; };
} }
@@ -107,6 +114,7 @@ function fromUpdate(p: ChainUpdatePayload) {
if (p.stages !== undefined) out.stages = p.stages; if (p.stages !== undefined) out.stages = p.stages;
// Forward stage goals even when explicitly null (clears goals server-side). // Forward stage goals even when explicitly null (clears goals server-side).
if (p.stageGoals !== undefined) out.stage_goals = p.stageGoals; if (p.stageGoals !== undefined) out.stage_goals = p.stageGoals;
if (p.coordinatorPrompt !== undefined) out.coordinator_prompt = p.coordinatorPrompt;
if (p.isPublic !== undefined) out.is_public = p.isPublic; if (p.isPublic !== undefined) out.is_public = p.isPublic;
return out; return out;
} }
@@ -108,7 +108,9 @@ export function BusinessChainPicker({
); );
const canUse = !!selectedChain && survivingSeats.length >= CHAIN_MIN_SEATS; const canUse = !!selectedChain && survivingSeats.length >= CHAIN_MIN_SEATS;
// 选中链条若有分层、且过滤到存活席位后**仍含并行 stage** → 走 dag。此时让用户在 // 选中链条若有分层、且过滤到存活席位后**仍含并行 stage,或任一 stage 带「本阶段目标」**
// → 走 dag。并行要 dag 才能并行调度;带目标也要 dag —— 目标靠总控**逐阶段派活**注入
// (buildDagStageLeaderTask),chain 模式不消费目标,故串行带目标链同样走 dag。此时让用户在
// 「快速(并行双列禁写) / 沙箱(并行单列可写、沙箱跟随)」之间二选一(§DAG 执行子模式)。 // 「快速(并行双列禁写) / 沙箱(并行单列可写、沙箱跟随)」之间二选一(§DAG 执行子模式)。
const dagPlan = useMemo(() => { const dagPlan = useMemo(() => {
if (!selectedChain?.stages || selectedChain.stages.length === 0) return null; if (!selectedChain?.stages || selectedChain.stages.length === 0) return null;
@@ -118,8 +120,12 @@ export function BusinessChainPicker({
surviveIds, surviveIds,
true, true,
selectedChain.stageGoals, selectedChain.stageGoals,
selectedChain.coordinatorPrompt,
); );
return plan.stages.some((st) => st.agentIds.length > 1) ? plan : null; const hasParallel = plan.stages.some((st) => st.agentIds.length > 1);
const hasGoal = plan.stages.some((st) => (st.goal?.trim() ?? "") !== "");
const hasCoord = (plan.coordinatorPrompt?.trim() ?? "") !== "";
return hasParallel || hasGoal || hasCoord ? plan : null;
}, [selectedChain, survivingSeats]); }, [selectedChain, survivingSeats]);
const [dagExecMode, setDagExecMode] = useState<"fast" | "sandbox">("fast"); const [dagExecMode, setDagExecMode] = useState<"fast" | "sandbox">("fast");
@@ -1871,6 +1871,7 @@ export function useStep2Orchestration(
seats: stageSeats.map((p) => ({ agentId: p.agentId, name: p.name })), seats: stageSeats.map((p) => ({ agentId: p.agentId, name: p.name })),
isFirst: s === 0, isFirst: s === 0,
goal: stage.goal, // 业务链条里为本阶段写的「本阶段目标」(可空) goal: stage.goal, // 业务链条里为本阶段写的「本阶段目标」(可空)
coordinatorPrompt: plan.coordinatorPrompt, // 写给总控的整体派活指引(可空,每阶段都注入)
}); });
const leaderAborter = new AbortController(); const leaderAborter = new AbortController();
activeAbortersRef.current.add(leaderAborter); activeAbortersRef.current.add(leaderAborter);
@@ -39,6 +39,12 @@ export interface BusinessChain {
* 为 null(线性)时本字段也为 null。某位为 null / 空 = 该阶段不带目标。 * 为 null(线性)时本字段也为 null。某位为 null / 空 = 该阶段不带目标。
*/ */
stageGoals?: (string | null)[] | null; stageGoals?: (string | null)[] | null;
/**
* 可选「总控编排提示」:用户写给**总控(协调)智能体**的一段指引,告诉总控该怎么派活、
* 做什么(整体编排策略 / 风格 / 约束)。**仅 dag 模式生效**,注入总控每阶段派活提示。
* 空 / null = 不注入。
*/
coordinatorPrompt?: string | null;
/** 是否已发布为公共(所有账号可在「公共」筛选查看 / 选入研讨)。 */ /** 是否已发布为公共(所有账号可在「公共」筛选查看 / 选入研讨)。 */
isPublic?: boolean; isPublic?: boolean;
/** 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条为 false)。 */ /** 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条为 false)。 */
@@ -64,11 +64,25 @@ test("stageGroupsToSeats flattens in order", () => {
); );
}); });
test("stageGroupsToStagePayload returns null when all singletons (linear)", () => { test("stageGroupsToStagePayload returns null when all singletons AND no goals (linear)", () => {
const groups = grp([seat("a")], [seat("b")]); const groups = grp([seat("a")], [seat("b")]);
assert.equal(stageGroupsToStagePayload(groups), null); assert.equal(stageGroupsToStagePayload(groups), null);
}); });
test("stageGroupsToStagePayload returns explicit stages when serial but a goal is set", () => {
// 串行单席位 + 任一「本阶段目标」→ 落显式分层(目标必须挂在显式 stages 上,否则丢失)。
const groups = setStageGoal(grp([seat("a")], [seat("b")]), 0, "分析目的");
assert.deepEqual(stageGroupsToStagePayload(groups), [["a"], ["b"]]);
});
test("stageGroupsToStagePayload forceExplicit lifts a pure-linear chain to explicit stages", () => {
// 无并行无目标,但调用方因「总控编排提示」要求强制显式分层(否则提示在线性链里被忽略)。
const groups = grp([seat("a")], [seat("b")]);
assert.equal(stageGroupsToStagePayload(groups), null);
assert.deepEqual(stageGroupsToStagePayload(groups, true), [["a"], ["b"]]);
assert.deepEqual(stageGroupsToStageGoals(groups, true), [null, null]);
});
test("stageGroupsToStagePayload returns groups when any stage is parallel", () => { test("stageGroupsToStagePayload returns groups when any stage is parallel", () => {
const groups = grp([seat("a"), seat("b")], [seat("c")]); const groups = grp([seat("a"), seat("b")], [seat("c")]);
assert.deepEqual(stageGroupsToStagePayload(groups), [["a", "b"], ["c"]]); assert.deepEqual(stageGroupsToStagePayload(groups), [["a", "b"], ["c"]]);
@@ -81,12 +95,18 @@ test("stageGroupsToStagePayload ignores empty stages", () => {
// ── stage goals ───────────────────────────────────────────────────────────── // ── stage goals ─────────────────────────────────────────────────────────────
test("stageGroupsToStageGoals returns null when linear (no parallel)", () => { test("stageGroupsToStageGoals returns null when linear AND no goals", () => {
const groups = setStageGoal(grp([seat("a")], [seat("b")]), 0, "目标"); const groups = grp([seat("a")], [seat("b")]);
// 全单席位 = 线性 → 目标也折叠为 null(仅 dag 模式落库)。
assert.equal(stageGroupsToStageGoals(groups), null); assert.equal(stageGroupsToStageGoals(groups), null);
}); });
test("stageGroupsToStageGoals persists goals on a serial chain when any goal is set", () => {
// 旧行为:串行 → 目标折叠为 null(被丢)。现改为:填了目标的串行链也落库,与显式分层对齐,
// 否则保存 / 回显皆失(用户反馈的 bug)。stage 1 无目标 → null。
const groups = setStageGoal(grp([seat("a")], [seat("b")]), 0, " 分析目的 ");
assert.deepEqual(stageGroupsToStageGoals(groups), ["分析目的", null]);
});
test("stageGroupsToStageGoals aligns to surviving stages, trims, nulls empties", () => { test("stageGroupsToStageGoals aligns to surviving stages, trims, nulls empties", () => {
let groups = grp([seat("a"), seat("b")], [seat("c")]); let groups = grp([seat("a"), seat("b")], [seat("c")]);
groups = setStageGoal(groups, 0, " 分析目的 "); groups = setStageGoal(groups, 0, " 分析目的 ");
@@ -182,6 +202,22 @@ test("round-trip: groups → {seats, stages, stageGoals} → chainToStageGroups"
assert.equal(restored[1].goal, "依赖上一阶段"); assert.equal(restored[1].goal, "依赖上一阶段");
}); });
// round-trip on a **serial single-seat chain with a goal** (the reported bug):
// save must persist stages+goals, and reload must show the goal back on its stage.
test("round-trip: serial single-seat chain + goal preserves goal across save/reload", () => {
const groups = setStageGoal(grp([seat("a")], [seat("b")]), 0, "作战目的分析");
const seats = stageGroupsToSeats(groups);
const stages = stageGroupsToStagePayload(groups);
const stageGoals = stageGroupsToStageGoals(groups);
// 不再折叠为 null —— 串行+目标落显式分层。
assert.deepEqual(stages, [["a"], ["b"]]);
assert.deepEqual(stageGoals, ["作战目的分析", null]);
const restored = chainToStageGroups({ seats, stages, stageGoals });
assert.deepEqual(ids(restored), [["a"], ["b"]]);
assert.equal(restored[0].goal, "作战目的分析");
assert.equal(restored[1].goal, undefined);
});
test("chainToStageGroups: goal drops with a stage whose agents are all gone", () => { test("chainToStageGroups: goal drops with a stage whose agents are all gone", () => {
// stages[0] 全是 ghost → 整组(含 g0)丢弃;存活的 ["c"] 应拿到 g1,不是 g0。 // stages[0] 全是 ghost → 整组(含 g0)丢弃;存活的 ["c"] 应拿到 g1,不是 g0。
const restored = chainToStageGroups({ const restored = chainToStageGroups({
@@ -54,27 +54,40 @@ export function stageGroupsToSeats(groups: StageGroups): ChainSeat[] {
return groups.flatMap((g) => g.seats); return groups.flatMap((g) => g.seats);
} }
/** 一组里是否有人填了「本阶段目标」(trim 后非空)。 */
function hasAnyGoal(groups: StageGroups): boolean {
return groups.some((g) => (g.goal?.trim() ?? "") !== "");
}
/** /**
* 折叠成落库的 `stages`。**全是单元素 stage(无并行)时返回 null** —— 语义上就是线性 * 折叠成落库的 `stages`。返回 null = 线性链(Step 2 走 chain 模式,老行为不变);返回显式
* 链,落库为 null 以便老行为不变(Step 2 仍走 chain 模式);只要有一个 stage ≥2 席位 * 分层 = Step 2 走 dag 模式。判定:**只要有一个 stage ≥2 席位(并行),或用户给任一 stage
* 就返回显式分层(Step 2 走 dag 模式)。空 stage 已被忽略。 * 填了「本阶段目标」,就落显式分层**;否则(纯单席位且无目标)落 null。空 stage 已被忽略。
*
* 为什么「填了目标也要落显式分层」:每阶段目标按 stage 下标对齐落库(见
* {@link stageGroupsToStageGoals}),只能挂在显式 `stages` 上。若串行单席位链照旧折叠为
* null,目标就会被一并丢掉——保存丢、回显也丢(正是用户反馈的「本阶段目标没保存上 / 没回显」)。
* 填了目标的串行链因此落成「每席位一层」的显式分层,由总控**逐阶段按目标派活**(dag 模式)。
*
* `forceExplicit`:调用方还有别的「必须走 dag」的理由时(如填了链条级「总控编排提示」,
* 仅 dag 模式注入)传 true,强制落显式分层,避免该提示在纯线性链里被静默忽略。
*/ */
export function stageGroupsToStagePayload(groups: StageGroups): string[][] | null { export function stageGroupsToStagePayload(groups: StageGroups, forceExplicit = false): string[][] | null {
const nonEmpty = groups.filter((g) => g.seats.length > 0); const nonEmpty = groups.filter((g) => g.seats.length > 0);
const hasParallel = nonEmpty.some((g) => g.seats.length > 1); const hasParallel = nonEmpty.some((g) => g.seats.length > 1);
if (!hasParallel) return null; if (!hasParallel && !hasAnyGoal(nonEmpty) && !forceExplicit) return null;
return nonEmpty.map((g) => g.seats.map((s) => s.agent_id)); return nonEmpty.map((g) => g.seats.map((s) => s.agent_id));
} }
/** /**
* 折叠成落库的 `stageGoals`,与 {@link stageGroupsToStagePayload} 做**完全相同的折叠** * 折叠成落库的 `stageGoals`,与 {@link stageGroupsToStagePayload} 做**完全相同的折叠判定**
* (过滤空组、无并行返回 null),保证两个数组下标一一对应。每个存活组取其 `goal` * (过滤空组;无并行且无目标、且未 `forceExplicit` 时才返回 null),保证两个数组下标一一对应。
* (trim 后为空则该位为 null)。仅 dag 模式(有并行)才落库目标——与「目标仅分层 DAG 生效」一致。 * 每个存活组取其 `goal`(trim 后为空则该位为 null)。只要落了 `stages`,就同步落目标。
*/ */
export function stageGroupsToStageGoals(groups: StageGroups): (string | null)[] | null { export function stageGroupsToStageGoals(groups: StageGroups, forceExplicit = false): (string | null)[] | null {
const nonEmpty = groups.filter((g) => g.seats.length > 0); const nonEmpty = groups.filter((g) => g.seats.length > 0);
const hasParallel = nonEmpty.some((g) => g.seats.length > 1); const hasParallel = nonEmpty.some((g) => g.seats.length > 1);
if (!hasParallel) return null; if (!hasParallel && !hasAnyGoal(nonEmpty) && !forceExplicit) return null;
return nonEmpty.map((g) => g.goal?.trim() || null); return nonEmpty.map((g) => g.goal?.trim() || null);
} }
@@ -207,6 +207,16 @@ test("buildPlanFromStages without goals leaves stage.goal undefined (old plans)"
assert.equal(plan.stages[1].goal, undefined); assert.equal(plan.stages[1].goal, undefined);
}); });
test("buildPlanFromStages stamps trimmed coordinatorPrompt onto the plan", () => {
const plan = buildPlanFromStages([["a"], ["b"]], undefined, true, null, " 按阶段派活 ");
assert.equal(plan.coordinatorPrompt, "按阶段派活");
});
test("buildPlanFromStages leaves coordinatorPrompt undefined when blank/absent", () => {
assert.equal(buildPlanFromStages([["a"], ["b"]]).coordinatorPrompt, undefined);
assert.equal(buildPlanFromStages([["a"]], undefined, true, null, " ").coordinatorPrompt, undefined);
});
// ── buildDependencySummary (§3.1 依赖摘要注入) ───────────────────────────────── // ── buildDependencySummary (§3.1 依赖摘要注入) ─────────────────────────────────
test("buildDependencySummary is empty for no deliveries (first stage)", () => { test("buildDependencySummary is empty for no deliveries (first stage)", () => {
@@ -472,6 +482,46 @@ test("buildDagStageLeaderTask omits the goal line when goal is empty/blank", ()
assert.doesNotMatch(task, /本阶段目标/); assert.doesNotMatch(task, /本阶段目标/);
}); });
// ── coordinator prompt injection (写给总控的编排提示) ─────────────────────────
test("buildDagStageLeaderTask injects the coordinator prompt as a top-priority guideline", () => {
const task = buildDagStageLeaderTask({
seedMessage: "x",
stageIndex: 0,
totalStages: 2,
seats: [{ agentId: "a", name: "甲" }],
isFirst: true,
coordinatorPrompt: "严格按阶段顺序派活,最终做综合收口",
});
assert.match(task, /总控编排指引/);
assert.match(task, /严格按阶段顺序派活,最终做综合收口/);
});
test("buildDagStageLeaderTask injects the coordinator prompt on later stages too", () => {
const task = buildDagStageLeaderTask({
seedMessage: "x",
stageIndex: 1,
totalStages: 2,
seats: [{ agentId: "b", name: "乙" }],
isFirst: false,
coordinatorPrompt: "聚焦本职、显式承接",
});
assert.match(task, /总控编排指引/);
assert.match(task, /聚焦本职、显式承接/);
});
test("buildDagStageLeaderTask omits the coordinator block when prompt is empty/blank", () => {
const task = buildDagStageLeaderTask({
seedMessage: "x",
stageIndex: 0,
totalStages: 1,
seats: [{ agentId: "a", name: "甲" }],
isFirst: true,
coordinatorPrompt: " ",
});
assert.doesNotMatch(task, /总控编排指引/);
});
test("pickDagSeatTask injects the stage goal in BOTH the exact-match and the miss branches", () => { test("pickDagSeatTask injects the stage goal in BOTH the exact-match and the miss branches", () => {
const goal = "根据上一阶段的各个目的,分析出行为体"; const goal = "根据上一阶段的各个目的,分析出行为体";
const hit = pickDagSeatTask([["actor", "干这个"]], "actor", "行为体", "种子", true, false, goal); const hit = pickDagSeatTask([["actor", "干这个"]], "actor", "行为体", "种子", true, false, goal);
@@ -34,6 +34,12 @@ export interface OrchestrationPlan {
stages: OrchestrationStage[]; stages: OrchestrationStage[];
/** 收口:是否在所有 stage 完成后由总控综合(默认 true)。 */ /** 收口:是否在所有 stage 完成后由总控综合(默认 true)。 */
finalSynthesis: boolean; finalSynthesis: boolean;
/**
* 可选「总控编排提示」:来自业务链条 `coordinatorPrompt`,写给总控的整体派活指引
* (怎么派活 / 做什么)。注入总控**每阶段**派活提示({@link buildDagStageLeaderTask})。
* 空 = 不注入。
*/
coordinatorPrompt?: string;
} }
// ── 通用 DAG 表示:自由连线时才需要(§1.2,本期仅预留)─────────────────────── // ── 通用 DAG 表示:自由连线时才需要(§1.2,本期仅预留)───────────────────────
@@ -187,12 +193,15 @@ export function emptyPlan(finalSynthesis = true): OrchestrationPlan {
* @param finalSynthesis 是否收口综合,默认 true。 * @param finalSynthesis 是否收口综合,默认 true。
* @param goals 可选「每阶段目标」,与 `stages` **同源下标**对齐;存活 stage 取对应 goal * @param goals 可选「每阶段目标」,与 `stages` **同源下标**对齐;存活 stage 取对应 goal
* (被丢弃的 stage 其 goal 一并丢弃,因为按源下标取)。 * (被丢弃的 stage 其 goal 一并丢弃,因为按源下标取)。
* @param coordinatorPrompt 可选「总控编排提示」(写给总控的整体派活指引),挂在计划上,
* 注入总控每阶段派活提示。空 = 不带。
*/ */
export function buildPlanFromStages( export function buildPlanFromStages(
stages: string[][], stages: string[][],
allowedIds?: Iterable<string>, allowedIds?: Iterable<string>,
finalSynthesis = true, finalSynthesis = true,
goals?: ReadonlyArray<string | null> | null, goals?: ReadonlyArray<string | null> | null,
coordinatorPrompt?: string | null,
): OrchestrationPlan { ): OrchestrationPlan {
const allow = allowedIds ? new Set(allowedIds) : null; const allow = allowedIds ? new Set(allowedIds) : null;
const cleaned: OrchestrationStage[] = []; const cleaned: OrchestrationStage[] = [];
@@ -202,7 +211,10 @@ export function buildPlanFromStages(
const goal = goals?.[idx]?.trim() || undefined; const goal = goals?.[idx]?.trim() || undefined;
cleaned.push(goal ? { id: stageId(idx), agentIds: ids, goal } : { id: stageId(idx), agentIds: ids }); cleaned.push(goal ? { id: stageId(idx), agentIds: ids, goal } : { id: stageId(idx), agentIds: ids });
}); });
return { mode: "dag", stages: cleaned, finalSynthesis }; const cp = coordinatorPrompt?.trim() || undefined;
return cp
? { mode: "dag", stages: cleaned, finalSynthesis, coordinatorPrompt: cp }
: { mode: "dag", stages: cleaned, finalSynthesis };
} }
/** 该 stage 是否真并行(≥2 席位)—— Phase 3 决定是否禁写文件 / Phase 5 决定显示。 */ /** 该 stage 是否真并行(≥2 席位)—— Phase 3 决定是否禁写文件 / Phase 5 决定显示。 */
@@ -342,14 +354,20 @@ export function buildDagStageLeaderTask(args: {
isFirst: boolean; isFirst: boolean;
/** 可选「本阶段目标」:用户在业务链条里为本阶段写的产出 / 依赖说明。空 = 不注入。 */ /** 可选「本阶段目标」:用户在业务链条里为本阶段写的产出 / 依赖说明。空 = 不注入。 */
goal?: string; goal?: string;
/**
* 可选「总控编排提示」:用户在业务链条里写给总控的整体派活指引(怎么派活 / 做什么 /
* 风格 / 约束)。每个 stage 的派活轮都注入,作为总控的高优先编排原则。空 = 不注入。
*/
coordinatorPrompt?: string;
}): string { }): string {
const { seedMessage, stageIndex, totalStages, seats, isFirst, goal } = args; const { seedMessage, stageIndex, totalStages, seats, isFirst, goal, coordinatorPrompt } = args;
const ordinal = `${stageIndex + 1}/${totalStages}`; const ordinal = `${stageIndex + 1}/${totalStages}`;
const seatList = seats const seatList = seats
.map((s) => `「${s.name}」(agent_name: ${s.agentId})`) .map((s) => `「${s.name}」(agent_name: ${s.agentId})`)
.join("、"); .join("、");
const isParallel = seats.length > 1; const isParallel = seats.length > 1;
const goalText = goal?.trim(); const goalText = goal?.trim();
const coordText = coordinatorPrompt?.trim();
const lines: string[] = []; const lines: string[] = [];
if (isFirst) { if (isFirst) {
lines.push("这是一次【分层 DAG 编排】的多智能体圆桌研讨。任务背景如下:", seedMessage, ""); lines.push("这是一次【分层 DAG 编排】的多智能体圆桌研讨。任务背景如下:", seedMessage, "");
@@ -359,6 +377,13 @@ export function buildDagStageLeaderTask(args: {
"", "",
); );
} }
if (coordText) {
lines.push(
"【总控编排指引(用户预设,最高优先)】以下是本次业务链条为你(总控)预设的整体派活原则,请在每一阶段派活时都严格遵循:",
coordText,
"",
);
}
lines.push( lines.push(
`本研讨严格按预设分层顺序进行,共 ${totalStages} 个阶段,现在是第 ${ordinal} 个阶段。`, `本研讨严格按预设分层顺序进行,共 ${totalStages} 个阶段,现在是第 ${ordinal} 个阶段。`,
`本阶段需要执行的席位(已由编排计划钉死)是:${seatList}。`, `本阶段需要执行的席位(已由编排计划钉死)是:${seatList}。`,
@@ -150,6 +150,8 @@ export default function BusinessChainEditorPage() {
const [publishing, setPublishing] = useState(false); const [publishing, setPublishing] = useState(false);
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
// 写给总控的「编排提示」:告诉总控该怎么派活、做什么(仅分层 DAG 模式注入)。
const [coordinatorPrompt, setCoordinatorPrompt] = useState("");
// 分层编排是唯一数据源;落库时展平为 seats + 折叠为 stages。 // 分层编排是唯一数据源;落库时展平为 seats + 折叠为 stages。
const [stageGroups, setStageGroups] = useState<StageGroups>([]); const [stageGroups, setStageGroups] = useState<StageGroups>([]);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -246,6 +248,7 @@ export default function BusinessChainEditorPage() {
setCurrentChainMine(chain.isMine !== false); setCurrentChainMine(chain.isMine !== false);
setTitle(chain.title); setTitle(chain.title);
setDescription(chain.description ?? ""); setDescription(chain.description ?? "");
setCoordinatorPrompt(chain.coordinatorPrompt ?? "");
setStageGroups(chainToStageGroups(chain)); setStageGroups(chainToStageGroups(chain));
}, []); }, []);
@@ -255,6 +258,7 @@ export default function BusinessChainEditorPage() {
setCurrentChainMine(true); setCurrentChainMine(true);
setTitle(""); setTitle("");
setDescription(""); setDescription("");
setCoordinatorPrompt("");
setStageGroups([]); setStageGroups([]);
}, []); }, []);
@@ -425,16 +429,21 @@ export default function BusinessChainEditorPage() {
} }
setSaving(true); setSaving(true);
try { try {
// 展平 + 折叠:seats 用候选池最新 name/description 覆盖快照;stages 全单席位 → null。 // 展平 + 折叠:seats 用候选池最新 name/description 覆盖快照;stages 在「全单席位且无
// 目标」时折叠为 null(线性链),有并行或填了「本阶段目标」时落显式分层。
const flat = stageGroupsToSeats(stageGroups); const flat = stageGroupsToSeats(stageGroups);
const payloadSeats = flat.map((s) => ({ const payloadSeats = flat.map((s) => ({
agent_id: s.agent_id, agent_id: s.agent_id,
name: poolById.get(s.agent_id)?.name ?? s.name, name: poolById.get(s.agent_id)?.name ?? s.name,
description: poolById.get(s.agent_id)?.description ?? s.description ?? "", description: poolById.get(s.agent_id)?.description ?? s.description ?? "",
})); }));
const stages = stageGroupsToStagePayload(stageGroups); // 总控编排提示:trim 后空串落 null(清空),与后端「空=不注入」一致。
// 每阶段目标,与 stages 同下标对齐(无并行时同样为 null)。 const coordPrompt = coordinatorPrompt.trim() || null;
const stageGoals = stageGroupsToStageGoals(stageGroups); // 填了「总控编排提示」也强制落显式分层 —— 该提示仅 dag 模式注入,否则纯线性链里被静默忽略。
const forceExplicit = coordPrompt !== null;
const stages = stageGroupsToStagePayload(stageGroups, forceExplicit);
// 每阶段目标,与 stages 同下标对齐、同步折叠(stages 为 null 时这里也是 null)。
const stageGoals = stageGroupsToStageGoals(stageGroups, forceExplicit);
if (currentChainId) { if (currentChainId) {
const updated = await updateChain(currentChainId, { const updated = await updateChain(currentChainId, {
title: title.trim(), title: title.trim(),
@@ -442,6 +451,7 @@ export default function BusinessChainEditorPage() {
seats: payloadSeats, seats: payloadSeats,
stages, stages,
stageGoals, stageGoals,
coordinatorPrompt: coordPrompt,
}); });
if (!updated) { if (!updated) {
toast.error("链条已不存在,可能被删除"); toast.error("链条已不存在,可能被删除");
@@ -459,6 +469,7 @@ export default function BusinessChainEditorPage() {
seats: payloadSeats, seats: payloadSeats,
stages, stages,
stageGoals, stageGoals,
coordinatorPrompt: coordPrompt,
}); });
setCurrentChainId(created.id); setCurrentChainId(created.id);
toast.success("已创建业务链条"); toast.success("已创建业务链条");
@@ -472,6 +483,7 @@ export default function BusinessChainEditorPage() {
}, [ }, [
title, title,
description, description,
coordinatorPrompt,
stageGroups, stageGroups,
seatCountInvalid, seatCountInvalid,
currentChainId, currentChainId,
@@ -737,6 +749,29 @@ export default function BusinessChainEditorPage() {
/> />
</div> </div>
</div> </div>
{/* 总控编排提示:写给总控(协调)智能体的整体派活指引,仅分层 DAG 模式生效。 */}
<div
className={cn(
"mt-4 overflow-hidden border border-border bg-card transition focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/30",
R_CTRL,
)}
>
<div className="flex items-center justify-between border-b border-border bg-muted/50 px-4 py-2">
<span className="select-none text-sm font-medium text-muted-foreground">总控提示词</span>
<span className="select-none text-[10px] text-muted-foreground/70">
写给总控:怎么派活、做什么(仅分层研讨生效,可选)
</span>
</div>
<textarea
value={coordinatorPrompt}
onChange={(e) => setCoordinatorPrompt(e.target.value)}
maxLength={4000}
rows={3}
placeholder="例如:你是本次研讨的总控。请严格按阶段顺序派活,每阶段先明确产出目标再分配;要求各席位的交付聚焦本职、显式承接上一阶段结论;最终阶段做综合收口,给出可落地的全案。"
className="w-full resize-y bg-card px-4 py-2.5 text-sm leading-relaxed text-foreground placeholder:text-muted-foreground/60 focus:outline-none"
/>
</div>
</section> </section>
{/* 双栏拖拽编辑器 */} {/* 双栏拖拽编辑器 */}
@@ -1258,8 +1293,8 @@ function StageSection({
)} )}
/> />
{!hasParallelStage && (group.goal?.trim()?.length ?? 0) > 0 && ( {!hasParallelStage && (group.goal?.trim()?.length ?? 0) > 0 && (
<p className="mt-1 text-[10px] text-amber-600 dark:text-amber-400"> <p className="mt-1 text-[10px] text-muted-foreground">
提示:当前链条全为串行,目标仅在含并行 Stage 的分层(DAG)研讨中生效。 提示:填写本阶段目标后,本链条将按阶段顺序由总控逐阶段派活(分层研讨),并把该目标注入总控。
</p> </p>
)} )}
</div> </div>
@@ -47,6 +47,8 @@ _PUBLIC_EXACT_PATHS: frozenset[str] = frozenset(
"/api/v1/auth/logout", "/api/v1/auth/logout",
"/api/v1/auth/setup-status", "/api/v1/auth/setup-status",
"/api/v1/auth/initialize", "/api/v1/auth/initialize",
# 并行多智能体面板专用的「绕过常规登录」取 token 端点(内网部署限制了标准登录时用)。
"/api/parallel-agents/auth/token",
} }
) )
@@ -59,6 +59,8 @@ _AUTH_EXEMPT_PATHS: frozenset[str] = frozenset(
"/api/v1/auth/logout", "/api/v1/auth/logout",
"/api/v1/auth/register", "/api/v1/auth/register",
"/api/v1/auth/initialize", "/api/v1/auth/initialize",
# 并行多智能体面板专用取 token 端点(公共 + CSRF 豁免)。
"/api/parallel-agents/auth/token",
} }
) )
@@ -1,14 +1,16 @@
"""Server-side parallel fan-out for the standalone parallel multi-agent panel. """Server-side parallel fan-out for the standalone parallel multi-agent panel.
The Vue embeddable panel sends a single request — ``{task_name, brief, agent_ids}`` The Vue embeddable panel sends a single request — ``{task_name, brief, agent_ids}``
— and this endpoint **fans out** to every agent in parallel (file writes disabled), — and this endpoint **fans out** to every agent in parallel, then **merges** their
then **merges** their LangGraph streams into ONE SSE tagged per ``agent_id``, so the LangGraph streams into ONE SSE tagged per ``agent_id``, so the client only consumes
client only consumes one stream and never parses LangGraph internals. one stream and never parses LangGraph internals.
It reuses the roundtable single-agent machinery from ``multi_agent``: Each fan-out agent runs as a pure **executing seat**: ``excluded_tools`` =
``_special_run(..., no_file=True)`` runs one seat under the ``seat_parallel`` run ``_EXCLUDED_TOOLS`` (``write_file`` / ``str_replace`` / ``agent_orchestration`` /
policy (which excludes ``write_file`` / ``str_replace`` / ``bash`` / ``web_search`` ``bash``) so a seat can neither write files (parallel writes would clash) nor
/ ``agent_orchestration`` …), so parallel agents can't write files or dispatch. dispatch / run scripts (a weak model fed "总控 / 业务链条" framing would otherwise
mimic the controller and "派活"). Read-only tools + ``ask_clarification`` stay on,
so the step bar and clarification cards still work.
Unified event protocol (one JSON object per ``data:`` frame):: Unified event protocol (one JSON object per ``data:`` frame)::
@@ -31,18 +33,24 @@ import json
import logging import logging
from typing import Any, AsyncIterator from typing import Any, AsyncIterator
import httpx
from fastapi import APIRouter, Body, HTTPException, Request from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from app.gateway.routers.multi_agent import ( from app.gateway.routers.multi_agent import (
_ROUNDTABLE_THREAD_METADATA, _ROUNDTABLE_THREAD_METADATA,
_auth_headers, _auth_headers,
_create_thread, _create_thread,
_flatten_content,
_loopback_base, _loopback_base,
_make_loopback_client, _make_loopback_client,
_special_run, _parse_last_messages,
_run_payload,
_stream_upstream,
_validate_agent_id, _validate_agent_id,
) )
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/parallel-agents", tags=["parallel-agents"]) router = APIRouter(prefix="/api/parallel-agents", tags=["parallel-agents"])
@@ -50,6 +58,49 @@ router = APIRouter(prefix="/api/parallel-agents", tags=["parallel-agents"])
# Soft cap on parallel degree — protects remote model concurrency + sandbox. # Soft cap on parallel degree — protects remote model concurrency + sandbox.
MAX_PARALLEL_AGENTS = 8 MAX_PARALLEL_AGENTS = 8
# 禁两类工具:①**写文件**(write_file / str_replace)—— 并行席位互相写文件会打架;
# ②**派活 / 脚本逃逸**(agent_orchestration / bash)—— 本面板里每个智能体只做自己那
# 份分析交付,绝不该扮演「总控」去调度别人。弱模型(deepseek 等)拿到带「总控 / 业务
# 链条」语气的任务后,会概率性地用 agent_orchestration 真去派活,或绕道 bash 跑脚本
# 调内部接口模仿总控调度(bug:单步单智能体链条里子智能体输出「总控调度指令…」并用
# glob / bash 翻找派活脚本)。物理禁掉这两类即可根治。其余只读工具(ls / read_file /
# 检索)与 ask_clarification(协作卡片)保留:既不互相打架,又能正常出步骤条 / 要澄清。
_EXCLUDED_TOOLS: list[str] = [
"write_file", # 并行不互相写文件打架
"str_replace",
"agent_orchestration", # 子智能体不派活 / 不调度(防模仿总控)
"bash", # 禁跑脚本(防绕道脚本调内部接口模仿派活)
]
# ── 专用「绕过常规登录」取 token ──────────────────────────────────────────────
# 内网部署对标准 /api/v1/auth/login/username 有额外操作限制(口令门 / 网关裁剪等),
# 嵌入方拿不到 token。这里给并行多智能体面板单开一个**公共**端点:传用户名直接签发 token
# (账号不存在则自动注册,与 username 登录同一套逻辑)。已在 auth_middleware / csrf_middleware
# 的白名单里放行(公共 + CSRF 豁免)。⚠️ 这是有意的鉴权旁路,仅供内网部署的本功能使用。
class _TokenRequest(BaseModel):
username: str = Field(min_length=1, max_length=128)
class _TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user_id: str
@router.post("/auth/token", response_model=_TokenResponse)
async def issue_token(body: _TokenRequest) -> _TokenResponse:
"""传用户名 → 直接签发 access_token(绕过常规登录,公共端点)。"""
# 懒加载,避免与 auth 路由的模块级循环依赖。
from app.gateway.auth import create_access_token
from app.gateway.routers.auth import _get_or_create_user_by_username
user, _created = await _get_or_create_user_by_username(body.username.strip())
token = create_access_token(str(user.id), token_version=user.token_version)
return _TokenResponse(access_token=token, token_type="bearer", user_id=str(user.id))
# ── frame normalisation (mirrors multi-agent.ts) ───────────────────────────── # ── frame normalisation (mirrors multi-agent.ts) ─────────────────────────────
@@ -75,33 +126,136 @@ def _extract_ai_text(chunk: dict[str, Any]) -> str:
return "" return ""
def _extract_tool_steps(chunk: dict[str, Any]) -> list[dict[str, Any]]: def _ingest_tool_chunks(chunks: Any, acc: dict[int, dict[str, Any]]) -> None:
"""Tool-call steps from a chunk obj — both complete ``tool_calls`` and """累积 ``tool_call_chunks`` 的增量(name + args 字符串),按 index 归并
incremental ``tool_call_chunks`` (we only need the name to surface a step).""" (mirror multi-agent.ts ``ingestToolCallChunks``)。"""
steps: list[dict[str, Any]] = [] if not isinstance(chunks, list):
tool_calls = chunk.get("tool_calls") return
if isinstance(tool_calls, list): for c in chunks:
for tc in tool_calls: if not isinstance(c, dict):
if not isinstance(tc, dict): continue
continue idx = c.get("index") if isinstance(c.get("index"), int) else 0
name = tc.get("name") entry = acc.setdefault(idx, {"id": None, "name": "", "argsStr": ""})
if not isinstance(name, str) or not name: cid = c.get("id")
continue if isinstance(cid, str) and cid:
ident = tc.get("id") or name if entry["id"] and entry["id"] != cid: # 同 index 换了新调用 → 重置
args = tc.get("args") if isinstance(tc.get("args"), dict) else None entry["name"] = ""
steps.append({"key": f"tool:{ident}", "label": f"调用工具 {name}", "args": args}) entry["argsStr"] = ""
tcc = chunk.get("tool_call_chunks") entry["id"] = cid
if isinstance(tcc, list): name = c.get("name")
for c in tcc: if isinstance(name, str) and name:
if not isinstance(c, dict): entry["name"] = name
continue a = c.get("args")
name = c.get("name") if isinstance(a, str):
if not isinstance(name, str) or not name: entry["argsStr"] += a
continue
idx = c.get("index") if isinstance(c.get("index"), int) else 0
ident = c.get("id") or f"{name}:{idx}" def _synthetic_tool_calls(acc: dict[int, dict[str, Any]]) -> list[dict[str, Any]]:
steps.append({"key": f"tool:{ident}", "label": f"调用工具 {name}", "args": None}) """把累积的 argsStr 尽力 parse 成完整 args,产出 ``{id,name,args}``(未成完整 JSON 则跳过)。"""
return steps out: list[dict[str, Any]] = []
for entry in acc.values():
if not entry["name"]:
continue
s = str(entry["argsStr"]).strip()
if not s:
continue
try:
args = json.loads(s)
except Exception:
continue
if isinstance(args, dict):
out.append({"id": entry["id"], "name": entry["name"], "args": args})
return out
def _clarification_from_messages(messages: list[dict[str, Any]]) -> dict[str, Any] | None:
"""从最终 messages 里找最后一个 ``ask_clarification`` 工具调用,抽出结构化澄清载荷
(字段对齐 multi_agent leader 的 clarification 帧)。"""
for msg in reversed(messages):
if not isinstance(msg, dict):
continue
if (msg.get("type") or "").lower() not in ("ai", "aimessage", "aimessagechunk"):
continue
for tc in msg.get("tool_calls") or []:
if isinstance(tc, dict) and (tc.get("name") or "").lower() == "ask_clarification":
cargs = tc.get("args") if isinstance(tc.get("args"), dict) else {}
return {
"question": str(cargs.get("question") or ""),
"clarification_type": str(cargs.get("clarification_type") or ""),
"clarification_context": str(cargs.get("context") or ""),
"options": cargs.get("options") or [],
"allow_custom": bool(cargs.get("allow_custom", True)),
"allow_multiple": resolve_allow_multiple(cargs),
}
return None
async def _run_one_agent(
client: httpx.AsyncClient,
base: str,
headers: dict[str, str],
agent_id: str,
thread_id: str,
new_message: str,
model_name: str,
) -> AsyncIterator[str | dict[str, Any]]:
"""跑一个智能体一轮(禁写文件 + 禁派活 / 脚本,其余只读工具放开)。
与 ``multi_agent._special_run`` 同形(yield 原始上游 SSE 行 + 末尾一条终态 dict),
但 ``excluded_tools`` = ``_EXCLUDED_TOOLS``(write_file / str_replace /
agent_orchestration / bash),且不做 post-broadcast(独立面板各智能体互不广播)。
"""
body = _run_payload(
agent_name=agent_id,
model_name=model_name,
thread_id=thread_id,
new_message=new_message,
excluded_tools=list(_EXCLUDED_TOOLS),
skill_stop_names=[],
thinking_enabled=True,
reasoning_effort="medium",
multitask_strategy="rollback",
)
captured: list[str] = []
async for line, done in _stream_upstream(client, base, headers, thread_id, body):
if line is not None:
yield line
elif done is not None:
captured = done
messages = _parse_last_messages(captured)
if messages is None:
# 流被抢占/截断,没有终态 values 帧 —— 静默收尾(调用方据无终态帧兜底)。
return
last_ai: dict[str, Any] = {}
for msg in reversed(messages):
if isinstance(msg, dict) and (msg.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"):
last_ai = msg
break
content = _flatten_content(last_ai.get("content", ""))
# 收集**所有** AI 消息的 tool_calls(全量 args,按顺序)—— 用于在收口时补发所有步骤
# (含 ls / skill_list 这类无参/中间步骤),保证步骤条不丢步、且 args 完整。
final_tool_calls: list[dict[str, Any]] = []
for msg in messages:
if isinstance(msg, dict) and (msg.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"):
for tc in msg.get("tool_calls") or []:
if isinstance(tc, dict):
final_tool_calls.append(tc)
clarification = _clarification_from_messages(messages)
if clarification is not None:
# 智能体在向用户要澄清 —— 交给前端渲染「协作卡片」(对齐圆桌第一步)。
yield {
"type": "clarification",
"content": content,
"clarification": clarification,
"tool_calls": final_tool_calls,
}
else:
yield {
"status": f"子智能体 {agent_id} 完成",
"content": content,
"agent_name": agent_id,
"tool_calls": final_tool_calls,
}
def _build_task_message(task_name: str, brief: str) -> str: def _build_task_message(task_name: str, brief: str) -> str:
@@ -112,8 +266,15 @@ def _build_task_message(task_name: str, brief: str) -> str:
parts.append(f"任务简介:\n{brief.strip()}") parts.append(f"任务简介:\n{brief.strip()}")
parts.append( parts.append(
"请你从你的专业职责出发,针对以上任务给出你的分析与交付。" "请你从你的专业职责出发,针对以上任务给出你的分析与交付。"
"本轮为**并行**研讨:请把你的产出**直接写在回复正文里**," "本轮为**并行**研讨:请把你的产出**直接写在回复正文里**(本轮禁止写文件)。"
"不要写文件、不要执行脚本/命令。" )
# 角色边界护栏:本面板里每个智能体都是**执行席位**,不是总控 / 指挥。即便任务里带有
# 「总控 / 业务链条 / 调度 / 派活」字样,也只完成属于自己的那份交付,严禁模仿总控派活。
parts.append(
"【角色边界】你是本轮的**执行席位**,不是总控 / 指挥。即使任务里出现「总控 / 业务链条 / 调度 / "
"派活」等字样,你也只需完成**属于你自己**的那份分析与交付;严禁模仿总控口吻输出「总控调度指令 / "
"调度对象 / 分配给『…』的任务 / agent_name」之类调度内容,严禁罗列或转派其他席位的任务,严禁调用"
"任何派活 / 调度类工具,也不要用脚本(bash / python)去调用内部接口或翻找派活脚本。"
) )
return "\n\n".join(parts) return "\n\n".join(parts)
@@ -156,28 +317,60 @@ async def run_parallel_agents_stream(request: Request, payload: dict = Body(...)
) -> None: ) -> None:
await queue.put({"type": "agent_start", "agent_id": agent_id}) await queue.put({"type": "agent_start", "agent_id": agent_id})
last_event = "" last_event = ""
seen_steps: set[str] = set() emitted_tools: set[str] = set() # 已发过 step 的 tool_call id(去重)
tool_acc: dict[int, dict[str, Any]] = {} # tool_call_chunks 累积
buffer: list[str] = [] buffer: list[str] = []
done_sent = False done_sent = False
async def _emit_tool(tc: Any, allow_empty: bool = False) -> None:
"""一个 tool_call(含完整 args)→ 一条 step 事件;按 id 去重。
``allow_empty=False``(流式期间):args 为空先不发 —— 流式帧里的 tool_calls 常先给
空 ``{}`` 占位、真实 args 走 tool_call_chunks 后到,过早发会把空 args 钉死。等累积出
完整 args(synthetic)或终态全量 tool_calls(``allow_empty=True``)再发。
"""
if not isinstance(tc, dict):
return
name = tc.get("name")
if not isinstance(name, str) or not name:
return
args = tc.get("args") if isinstance(tc.get("args"), dict) else None
if args is None:
return
if not args and not allow_empty: # 空 args 先不发,等 chunks / 终态补全
return
key_id = str(tc.get("id") or name)
if key_id in emitted_tools:
return
emitted_tools.add(key_id)
await queue.put(
{"type": "step", "agent_id": agent_id, "key": f"tool_calling:{name}", "label": name, "args": args}
)
try: try:
thread_id = await _create_thread(client, base, headers, metadata=_ROUNDTABLE_THREAD_METADATA) thread_id = await _create_thread(client, base, headers, metadata=_ROUNDTABLE_THREAD_METADATA)
gen = _special_run( gen = _run_one_agent( # 只禁文件写入,其余工具放开(保留步骤条)
client, client, base, headers, agent_id, thread_id, new_message, model_name
base,
headers,
{agent_id: thread_id},
agent_id,
new_message,
[],
model_name,
display_name="",
no_file=True, # seat_parallel policy → 禁 write_file/bash/str_replace…
) )
async for chunk in gen: async for chunk in gen:
if isinstance(chunk, dict): if isinstance(chunk, dict):
# 终态帧 {status, content, agent_name} # 终态:先用最终 tool_calls(全量 args)补发漏掉的 step,再发收口/澄清。
content = str(chunk.get("content") or "") for tc in chunk.get("tool_calls") or []:
await queue.put({"type": "agent_done", "agent_id": agent_id, "content": content}) await _emit_tool(tc, allow_empty=True)
if chunk.get("type") == "clarification":
clar = chunk.get("clarification") or {}
await queue.put(
{
"type": "agent_clarification",
"agent_id": agent_id,
"content": str(chunk.get("content") or ""),
**clar,
}
)
else:
await queue.put(
{"type": "agent_done", "agent_id": agent_id, "content": str(chunk.get("content") or "")}
)
done_sent = True done_sent = True
continue continue
line = chunk.strip() if isinstance(chunk, str) else "" line = chunk.strip() if isinstance(chunk, str) else ""
@@ -200,12 +393,12 @@ async def run_parallel_agents_stream(request: Request, payload: dict = Body(...)
if text: if text:
buffer.append(text) buffer.append(text)
await queue.put({"type": "delta", "agent_id": agent_id, "text": text}) await queue.put({"type": "delta", "agent_id": agent_id, "text": text})
for step in _extract_tool_steps(obj): # 完整 tool_calls(部分模型直接给)+ 累积 chunks 后的合成调用,都尝试发 step。
key = step["key"] for tc in obj.get("tool_calls") or []:
if key in seen_steps: await _emit_tool(tc)
continue _ingest_tool_chunks(obj.get("tool_call_chunks"), tool_acc)
seen_steps.add(key) for tc in _synthetic_tool_calls(tool_acc):
await queue.put({"type": "step", "agent_id": agent_id, **step}) await _emit_tool(tc)
if not done_sent: if not done_sent:
# 流被抢占/截断,没收到终态帧 —— 用已累积正文兜底收口本席位。 # 流被抢占/截断,没收到终态帧 —— 用已累积正文兜底收口本席位。
await queue.put( await queue.put(
@@ -51,6 +51,8 @@ class ChainResponse(BaseModel):
stages: list[list[str]] | None = None stages: list[list[str]] | None = None
# Optional per-stage goals aligned to ``stages`` (dag-only). None = no goals. # Optional per-stage goals aligned to ``stages`` (dag-only). None = no goals.
stage_goals: list[str | None] | None = None stage_goals: list[str | None] | None = None
# 写给总控的「编排提示」(dag-only):告诉总控怎么派活、做什么。None/空 = 不注入。
coordinator_prompt: str | None = None
# 发布状态:True = 公共(所有账号可在「公共」筛选查看 / 选用)。 # 发布状态:True = 公共(所有账号可在「公共」筛选查看 / 选用)。
is_public: bool = False is_public: bool = False
# 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。 # 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。
@@ -72,6 +74,7 @@ class ChainCreateRequest(BaseModel):
seats: list[ChainSeatSchema] = [] seats: list[ChainSeatSchema] = []
stages: list[list[str]] | None = None stages: list[list[str]] | None = None
stage_goals: list[str | None] | None = None stage_goals: list[str | None] | None = None
coordinator_prompt: str | None = Field(default=None, max_length=4000)
class ChainUpdateRequest(BaseModel): class ChainUpdateRequest(BaseModel):
@@ -81,6 +84,7 @@ class ChainUpdateRequest(BaseModel):
seats: list[ChainSeatSchema] | None = None seats: list[ChainSeatSchema] | None = None
stages: list[list[str]] | None = None stages: list[list[str]] | None = None
stage_goals: list[str | None] | None = None stage_goals: list[str | None] | None = None
coordinator_prompt: str | None = Field(default=None, max_length=4000)
# 发布开关(True 发布为公共 / False 取消发布);仅 owner 可改(update 本就 owner 限定)。 # 发布开关(True 发布为公共 / False 取消发布);仅 owner 可改(update 本就 owner 限定)。
is_public: bool | None = None is_public: bool | None = None
@@ -222,6 +226,7 @@ async def create_chain(request: Request, body: ChainCreateRequest) -> ChainRespo
"seats": [s.model_dump() for s in body.seats], "seats": [s.model_dump() for s in body.seats],
"stages": body.stages, "stages": body.stages,
"stage_goals": body.stage_goals, "stage_goals": body.stage_goals,
"coordinator_prompt": body.coordinator_prompt,
}, },
) )
return ChainResponse(**row) return ChainResponse(**row)
@@ -0,0 +1,56 @@
"""Add coordinator_prompt column to roundtable_chains.
Per-chain「总控编排提示」: an optional free-text instruction the user writes for the
**coordinator (总控) agent** of a business chain — telling it how to dispatch / what to
do (overall orchestration strategy / style / constraints). Injected into the coordinator
per-stage dispatch prompt (``buildDagStageLeaderTask``) in **dag-mode only**. NULL = no
prompt. The column is LONGTEXT on MySQL (mirrors ``stage_goals`` headroom) and plain TEXT
elsewhere — matching ``PortableLongText`` on ``RoundtableChainRow.coordinator_prompt``.
``create_all`` adds the column for brand-new DBs; this revision patches DBs that already
have the table.
Chained after ``20260608_05`` (parallel_agent_tasks) so the revision graph stays
single-headed — otherwise ``alembic upgrade head`` aborts with "multiple heads" and the
startup auto-migrate silently falls back to the create_all schema (which lacks this
column → 500 on list).
Revision ID: 20260608_06
Revises: 20260608_05
Create Date: 2026-06-09
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
from sqlalchemy.dialects.mysql import LONGTEXT
revision = "20260608_06"
down_revision = "20260608_05"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
inspector = inspect(op.get_bind())
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
def upgrade() -> None:
if _has_table("roundtable_chains") and not _has_column("roundtable_chains", "coordinator_prompt"):
col_type = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
op.add_column("roundtable_chains", sa.Column("coordinator_prompt", col_type, nullable=True))
def downgrade() -> None:
if _has_column("roundtable_chains", "coordinator_prompt"):
op.drop_column("roundtable_chains", "coordinator_prompt")
@@ -45,6 +45,10 @@ class RoundtableChainRow(Base):
# coordinator dispatch prompt (and seat tasks as fallback) in dag-mode only. NULL = # coordinator dispatch prompt (and seat tasks as fallback) in dag-mode only. NULL =
# no goals (and always NULL when ``stages`` is NULL/linear). Stored as JSON string. # no goals (and always NULL when ``stages`` is NULL/linear). Stored as JSON string.
stage_goals: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) stage_goals: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
# 可选「总控编排提示」:用户为本链条写给**总控(协调)智能体**的一段指引,告诉总控该
# 怎么派活、做什么(整体编排策略 / 风格 / 约束)。**仅分层 DAG 模式生效**:注入到总控
# 每阶段派活提示(``buildDagStageLeaderTask``)。NULL = 不注入。纯文本,存为 LONGTEXT。
coordinator_prompt: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
# 发布开关:True = 公共(所有账号可在「公共」筛选里查看 / 选用);False = 仅本人可见。 # 发布开关:True = 公共(所有账号可在「公共」筛选里查看 / 选用);False = 仅本人可见。
# 编辑 / 删除始终只限 owner;发布只是放开「查看 + 选入研讨」。索引以支持「公共」列表筛选。 # 编辑 / 删除始终只限 owner;发布只是放开「查看 + 选入研讨」。索引以支持「公共」列表筛选。
is_public: Mapped[bool] = mapped_column( is_public: Mapped[bool] = mapped_column(
@@ -54,6 +54,8 @@ class RoundtableChainRepository:
"stages": _loads(row.stages), "stages": _loads(row.stages),
# Per-stage goals aligned to ``stages``; None when absent. # Per-stage goals aligned to ``stages``; None when absent.
"stage_goals": _loads(getattr(row, "stage_goals", None)), "stage_goals": _loads(getattr(row, "stage_goals", None)),
# 总控编排提示(写给总控的派活指引,dag-only);None/空 = 不注入。纯文本,不 json。
"coordinator_prompt": getattr(row, "coordinator_prompt", None),
# 发布状态 + 是否本人所有(前端据此显示「公共」徽标、决定能否编辑 / 发布)。 # 发布状态 + 是否本人所有(前端据此显示「公共」徽标、决定能否编辑 / 发布)。
"is_public": bool(getattr(row, "is_public", False)), "is_public": bool(getattr(row, "is_public", False)),
"is_mine": (current_user_id is None) or (row.user_id == current_user_id), "is_mine": (current_user_id is None) or (row.user_id == current_user_id),
@@ -105,6 +107,7 @@ class RoundtableChainRepository:
seats=_dumps(data.get("seats") or []), seats=_dumps(data.get("seats") or []),
stages=_dumps(data.get("stages")), stages=_dumps(data.get("stages")),
stage_goals=_dumps(data.get("stage_goals")), stage_goals=_dumps(data.get("stage_goals")),
coordinator_prompt=(data.get("coordinator_prompt") or None),
is_public=bool(data.get("is_public") or False), is_public=bool(data.get("is_public") or False),
created_at=now, created_at=now,
updated_at=now, updated_at=now,
@@ -125,6 +128,7 @@ class RoundtableChainRepository:
seats=_UNSET, seats=_UNSET,
stages=_UNSET, stages=_UNSET,
stage_goals=_UNSET, stage_goals=_UNSET,
coordinator_prompt=_UNSET,
is_public=_UNSET, is_public=_UNSET,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
async with self._sf() as session: async with self._sf() as session:
@@ -141,6 +145,8 @@ class RoundtableChainRepository:
row.stages = _dumps(stages) row.stages = _dumps(stages)
if stage_goals is not _UNSET: if stage_goals is not _UNSET:
row.stage_goals = _dumps(stage_goals) row.stage_goals = _dumps(stage_goals)
if coordinator_prompt is not _UNSET:
row.coordinator_prompt = coordinator_prompt or None
if is_public is not _UNSET and is_public is not None: if is_public is not _UNSET and is_public is not None:
row.is_public = bool(is_public) row.is_public = bool(is_public)
row.updated_at = datetime.now(UTC) row.updated_at = datetime.now(UTC)
@@ -14,6 +14,7 @@
| [05-嵌入与深色样式.md](./05-嵌入与深色样式.md) | 配置文件接口地址、抽屉/弹窗嵌入、深色自适应、不污染宿主 | | [05-嵌入与深色样式.md](./05-嵌入与深色样式.md) | 配置文件接口地址、抽屉/弹窗嵌入、深色自适应、不污染宿主 |
| [06-可用智能体清单.md](./06-可用智能体清单.md) | **测试用真实 agent_id 清单(可直接复制粘贴)** + 示例任务 | | [06-可用智能体清单.md](./06-可用智能体清单.md) | **测试用真实 agent_id 清单(可直接复制粘贴)** + 示例任务 |
| [07-使用说明.md](./07-使用说明.md) | **在其它 Vue 项目中引入哪些组件、如何使用**(props/事件/嵌入) | | [07-使用说明.md](./07-使用说明.md) | **在其它 Vue 项目中引入哪些组件、如何使用**(props/事件/嵌入) |
| [08-开发文档.md](./08-开发文档.md) | **二次开发**:架构/数据流/事件协议/目录职责/后端契约/扩展指南 |
## 测试环境 ## 测试环境
+190 -8
View File
@@ -102,8 +102,11 @@ app.use(ParallelAgentsVue); // 之后任意模板可直接用 <ParallelAgentsPan
- **接口地址**:默认读 `src/config.ts` 的 `DEFAULT_BASE_URL`(可改源码,或 dev 设 `VITE_PA_BASE_URL`); - **接口地址**:默认读 `src/config.ts` 的 `DEFAULT_BASE_URL`(可改源码,或 dev 设 `VITE_PA_BASE_URL`);
也可用 `:base-url` prop 临时覆盖。 也可用 `:base-url` prop 临时覆盖。
- **登录**:把宿主的**用户名**用 `:username` 传进来即可——组件自动走 DeerFlow 免密登录端点换 token、 - **登录**:把宿主的**用户名**用 `:username` 传进来即可——组件自动走本面板**专用**的「绕过常规登录」
缓存、401 自动重登。**宿主不用管 token**。若已有 token,可改传 `:token` / `:get-token`(优先级更高)。 公共端点 `POST /api/parallel-agents/auth/token` 换 token、缓存、401 自动重登。**宿主不用管 token**。
若已有 token,可改传 `:token` / `:get-token`(优先级更高)。
- 为什么专用端点:内网部署对标准 `/api/v1/auth/login/username` 有额外限制(口令门/网关裁剪等),拿不到
token;该端点传用户名直接签发(账号不存在自动注册),已在 auth/csrf 中间件白名单里放行。
- token 缓存按 `baseUrl::username` 维度,多个实例/多用户互不串。 - token 缓存按 `baseUrl::username` 维度,多个实例/多用户互不串。
--- ---
@@ -122,14 +125,121 @@ app.use(ParallelAgentsVue); // 之后任意模板可直接用 <ParallelAgentsPan
| `model` | `string` | | 可选模型名(不传走默认 / 后端回退) | | `model` | `string` | | 可选模型名(不传走默认 / 后端回退) |
| `dark` | `boolean`(默认 `true`) | | 深色面板(自带成色,不依赖宿主主题) | | `dark` | `boolean`(默认 `true`) | | 深色面板(自带成色,不依赖宿主主题) |
`AgentInput`/`SavedTask` 等类型也从包里导出:`import type { AgentInput, SavedTask } from "parallel-agents-vue"`。 `AgentInput`/`SavedTask`/`AgentResult` 等类型也从包里导出:
`import type { AgentInput, SavedTask, AgentResult } from "parallel-agents-vue"`。
---
## 六·一、每个参数怎么传(逐个举例)
### `agents`(必填)——「智能体参数」怎么传
一个数组,每项 `{ agent_id, name?, avatarType? }`:
```ts
const agents = [
{ agent_id: "roundtable-solution-design", name: "方案设计" }, // 最常用:id + 显示名
{ agent_id: "roundtable-risk-review", name: "风险审查", avatarType: "amber" }, // 顺带指定头像配色
{ agent_id: "demo-expert" }, // 只给 id(显示名回退成 id)
];
```
```vue
<ParallelAgentsPanel :agents="agents" :username="'demo'" />
```
- `agent_id`**必须是后端真实注册的 id**(只写名字无效);可用 id 见 [06-可用智能体清单.md](./06-可用智能体清单.md)。
- `name`:席位/气泡显示名,可省(省了用 id)。
- `avatarType`:可选 `blue|purple|emerald|amber|pink|human`;不传按顺序自动轮转配色。
### `username`——免密登录(推荐)
```vue
<ParallelAgentsPanel :username="currentUser.name" :agents="agents" />
```
### `baseUrl`——后端地址(不传用 config 默认)
```vue
<ParallelAgentsPanel :base-url="'http://10.0.0.5:8001'" :username="'demo'" :agents="agents" />
```
### `token` / `getToken`——已有登录态时(优先于 username)
```vue
<!-- 直接给字符串 token -->
<ParallelAgentsPanel :token="hostToken" :agents="agents" />
<!-- 或给一个(异步)取 token 的函数 -->
<ParallelAgentsPanel :get-token="() => authStore.getAccessToken()" :agents="agents" />
```
### `model`——指定模型(不传走默认/后端回退;也可在面板右上角下拉切)
```vue
<ParallelAgentsPanel :model="'deepseek-chat'" :username="'demo'" :agents="agents" />
```
### `dark`——深色(默认 true,自带成色)
```vue
<ParallelAgentsPanel :dark="true" :username="'demo'" :agents="agents" />
```
### `taskId`——存储主键 + 回显(见 §七·二)
```vue
<ParallelAgentsPanel
:task-id="taskId"
:username="'demo'"
:agents="agents"
@update:taskId="(id) => (taskId = id)"
/>
```
### 全量示例(所有参数一起)
```vue
<script setup lang="ts">
import { ref } from "vue";
import { ParallelAgentsPanel } from "parallel-agents-vue";
import "parallel-agents-vue/style.css";
import type { AgentInput, AgentResult, SavedTask } from "parallel-agents-vue";
const agents: AgentInput[] = [
{ agent_id: "roundtable-solution-design", name: "方案设计" },
{ agent_id: "roundtable-risk-review", name: "风险审查" },
];
const taskId = ref("task-001");
function onSaved(t: SavedTask) {
console.log("已保存", t.id);
}
function onCompleted(results: AgentResult[]) {
console.log("各智能体返回内容", results);
}
</script>
<template>
<div style="height: 86vh">
<ParallelAgentsPanel
:base-url="'http://localhost:8001'"
:username="'demo'"
:agents="agents"
:task-id="taskId"
:model="'deepseek-chat'"
:dark="true"
@saved="onSaved"
@update:taskId="(id) => (taskId = id)"
@completed="onCompleted"
/>
</div>
</template>
```
## 七、事件 ## 七、事件
| 事件 | 载荷 | 触发时机 | | 事件 | 载荷 | 触发时机 |
| ----------------- | ------------ | --------------------------------------- | | ----------------- | ------------------ | --------------------------------------- |
| `saved` | `SavedTask` | 保存成功(新建或更新)后 | | `saved` | `SavedTask` | 保存成功(新建或更新)后 |
| `update:taskId` | `string` | 当前任务 id 变化(保存 / 加载历史时) | | `update:taskId` | `string` | 当前任务 id 变化(保存 / 加载历史时) |
| `completed` | `AgentResult[]` | 一轮并行分析结束时,回传各智能体返回内容 |
```vue ```vue
<ParallelAgentsPanel <ParallelAgentsPanel
@@ -138,14 +248,86 @@ app.use(ParallelAgentsVue); // 之后任意模板可直接用 <ParallelAgentsPan
:task-id="taskId" :task-id="taskId"
@saved="(t) => console.log('saved', t.id)" @saved="(t) => console.log('saved', t.id)"
@update:taskId="(id) => (taskId = id)" @update:taskId="(id) => (taskId = id)"
@completed="(results) => console.log('各智能体返回:', results)"
/> />
``` ```
## 七·一、如何获取模型返回的内容
每个智能体(模型)返回的正文是 `AgentResult.content`。两种方式,任选其一:
**A. 事件**:监听 `@completed`,一轮分析结束时自动回传 `AgentResult[]`:
```vue
<ParallelAgentsPanel
:username="'demo'"
:agents="agents"
@completed="(results) => {
// results: [{ agent_id, name, content, status }]
for (const r of results) console.log(r.name, '→', r.content);
}"
/>
```
**B. 方法(模板 ref)**:随时主动取(不必等事件):
```vue
<script setup lang="ts">
import { ref } from "vue";
import { ParallelAgentsPanel } from "parallel-agents-vue";
import type { AgentResult } from "parallel-agents-vue";
const panelRef = ref<InstanceType<typeof ParallelAgentsPanel> | null>(null);
function readAll() {
const all: AgentResult[] = panelRef.value!.getResults();
// [{ agent_id, name, content, status }]
}
function readOne(id: string) {
const one = panelRef.value!.getResult(id); // AgentResult | null
}
</script>
<template>
<ParallelAgentsPanel ref="panelRef" :username="user" :agents="agents" />
</template>
```
`AgentResult = { agent_id, name, content, status }`,`content` 即该智能体模型返回的正文(Markdown)。
`defineExpose` 还暴露了 `start()` / `cancel()`,可由宿主代替按钮触发开始 / 停止。
## 七·二、如何保存 + 回显聊天记录
**保存**:面板里点「保存」即把当前整段聊天记录(各智能体气泡 + 步骤条等)存到后端,按用户隔离。
传 `:task-id` 时**以它为存储主键**做 upsert(已存在→更新;不存在→用该 id 新建);不传则首次保存由后端
生成 id(通过 `@update:taskId` 回传,下次再保存即更新这条)。
**回显(两种方式)**:
```vue
<!-- 方式 1:挂载时传一个已存在的 taskId → 自动加载并回显该任务的聊天记录 -->
<ParallelAgentsPanel :task-id="'task-001'" :username="'demo'" :agents="agents" />
```
```ts
// 方式 1 变体:运行中改 taskId(双向绑定),面板会自动切到并回显那条任务
const taskId = ref<string>(""); // 留空=新任务
// <ParallelAgentsPanel :task-id="taskId" @update:taskId="id => taskId = id" ... />
taskId.value = "task-001"; // 改了之后面板自动回显 task-001
```
> 方式 2:面板**右上角内置「历史」下拉**——点开选一条历史任务即回显,**无需写任何代码**。
要点:先**保存过**、再用**同一个 taskId**(或从历史里选)才会有内容回显;没存过的新 id 第一次进去是空白
(后台会探测一次 `GET /…/{id}` 返回 404,属正常,面板静默处理)。
--- ---
## 八、交互要点(与圆桌 Step2 一致) ## 八、交互要点(与圆桌 Step2 一致)
- 输入「任务名称 + 简介」→「开始分析」→ 各智能体**并行**输出,运行期间**禁止写文件**(产出走正文)。 - 输入「任务名称 + 简介」→「开始分析」→ 各智能体**并行**输出。运行期间禁用**写文件**(`write_file`/
`str_replace`)与**派活/脚本**(`agent_orchestration`/`bash`,防子智能体模仿总控调度);检索、查看、
`ask_clarification` 等照常 —— 所以**步骤条 / 协作卡片正常显示**,每个智能体只做自己那份分析。
- 主区每次只显示**一个**席位的消息列表;**点席位头像切换**要看哪个(单选)。 - 主区每次只显示**一个**席位的消息列表;**点席位头像切换**要看哪个(单选)。
- 顶部席位状态条显示每个智能体的进行中 / 完成 / 出错。 - 顶部席位状态条显示每个智能体的进行中 / 完成 / 出错。
- 操作区右侧有**模型切换**下拉(拉取后端 `/api/models`);选「默认」则用 `model` prop / 配置 / 后端回退。 - 操作区右侧有**模型切换**下拉(拉取后端 `/api/models`);选「默认」则用 `model` prop / 配置 / 后端回退。
+226
View File
@@ -0,0 +1,226 @@
# 开发文档 · parallel-agents-vue
面向**维护 / 二次开发**这个前端的同学。讲清楚:它是什么、架构与数据流、目录与各文件职责、
对接的后端契约、样式系统、以及常见扩展怎么改。使用 / 嵌入请看 [07-使用说明.md](./07-使用说明.md)。
---
## 1. 它是什么
一个**独立、可嵌入**的 Vue3 组件:输入「任务名称 + 简介」→ **并行**调用宿主指定的多个智能体 →
深色消息列表(**1:1 复刻圆桌 Step2**:步骤条 / 协作卡片 / 席位状态条 / 点头像切换单显)→
支持**保存 / 回显 / 重新分析**。只对外暴露一个组件 `ParallelAgentsPanel`。
- 后端是同仓库的 DeerFlow 网关(`offline-backend-20260512/backend`),本前端只消费它的几个 HTTP 接口。
- 并行**编排在后端**:前端发一条请求,后端扇出 N 个智能体、合并成**一条 SSE**回来;前端只解析这一条流。
---
## 2. 技术栈
| 方面 | 选型 |
| -------- | ----------------------------------------------------------------- |
| 框架 | Vue 3(`<script setup>` + Composition API) |
| 构建 | Vite 6(dev app + `build:lib` 组件库双产物) |
| 语言 | TypeScript(`strict` + `noUnusedLocals/Parameters`) |
| 样式 | TailwindCSS v4(**仅 utilities,不引 preflight**)+ 作用域 `.pa-*` token |
| Markdown | `markdown-it`(打进库,宿主无需安装) |
| peer dep | `vue ^3.4`(宿主提供,库不打包 vue) |
---
## 3. 目录结构与各文件职责
```
src/
├─ index.ts 库入口:导出 ParallelAgentsPanel + install 插件 + 类型;import 样式
├─ main.ts / App.vue dev demo(仅本地调试用,不进库)
├─ config.ts 配置:DEFAULT_BASE_URL / DEFAULT_MODEL / TOKEN_PATH
├─ types.ts 共享类型:Dialogue / SeatView / DialogueStep / ClarificationPayload
│ / AgentInput / AgentResult / SavedTask / StreamEvent(事件协议)
├─ api.ts 后端访问层:鉴权(取 token+缓存+401 重登) / SSE 流 / 任务 CRUD / 模型列表
├─ composables/
│ └─ useParallelRun.ts 运行编排(前端态核心):消费合并 SSE → 维护 dialogues/seats;
│ start / cancel / hydrate(回显) / reset / rerunAgent(单席位重跑)
├─ lib/
│ ├─ avatar-constants.ts 头像配色 + getAvatarTypeFor(轮转)
│ └─ step-display.ts 步骤条:getStepDisplay(图标+中文文案) / getStepArgs(参数子节点)
├─ styles/
│ └─ parallel-agents.css 作用域深色 token(--pa-*) + .pa-* 语义类 + Markdown 样式(无 preflight)
└─ components/
├─ ParallelAgentsPanel.vue 主组件:输入区/操作区/模型切换/历史/席位条/会话区 + 全部状态编排
├─ ParallelSeatStrip.vue 席位状态条(头像 + running/done/error 角标,点击切换单显)
├─ MessageBubble.vue 单条气泡:头像/发言人/徽标/时间/步骤条/正文/光标/协作卡片
├─ MessageStepsCard.vue 步骤条(ChainOfThought 结构,1:1 对齐 Step2)
├─ StepIcon.vue 步骤图标(lucide 风格 inline SVG,按 IconKey)
├─ ClarificationCard.vue 协作 / 澄清卡片(1:1 对齐圆桌第一步 IntentClarificationCard)
├─ MarkdownContent.vue markdown-it 渲染
└─ Avatar.vue 机器人 / 真人 SVG 头像
```
---
## 4. 架构与数据流
一次「开始分析」的端到端链路:
```
用户输入(任务名+简介) ──onStart──> useParallelRun.start(cfg, {taskName, brief, agents, model})
│ 为每个 agent 建一个 Dialogue(流式气泡) + SeatView(席位)
▼
api.streamParallelAgents(cfg, body, handlers, signal)
│ POST {baseUrl}/api/parallel-agents/run/stream (Authorization: Bearer <token>)
▼
后端扇出:每个 agent 各跑一轮 LangGraph,合并成「一条带 agent_id 的 SSE」
▼ 逐条 StreamEvent(见 §5)
useParallelRun.handleEvent(ev) 按 agent_id 路由:
delta→气泡正文(rAF 节流) · step→步骤条 · agent_clarification→协作卡片
agent_done→收口 · agent_error→错误 · all_done→收尾
▼ 响应式 dialogues / seats 变化
组件渲染:ParallelSeatStrip + 当前选中席位的 MessageBubble(单显,点头像切换)
▼ run 结束
panel emit "completed"(AgentResult[]);可点「保存」(按 taskId upsert) / 「重新分析」(重跑)
```
**关键不变量**
- 主区**单选**显示一个席位的消息(`selected=[agentId]`,点头像切换);后台 N 个智能体仍并行执行。
- delta 用 `requestAnimationFrame` 合并刷新,避免逐 token reflow。
- `bubbleByAgent: Map<agentId, bubbleId>` 把事件路由到对应气泡;hydrate/start 时重建。
---
## 5. 统一事件协议(前后端唯一契约)
后端 `/run/stream` 每条 `data:` 是一个 JSON(`StreamEvent`,定义在 `types.ts`):
| type | 字段 | 前端处理 |
| --------------------- | --------------------------------------------------------------------------------------------- | --------------------------------- |
| `agent_start` | `agent_id` | 该席位置 running |
| `delta` | `agent_id, text` | 追加正文(节流) |
| `step` | `agent_id, key(=`tool_calling:<tool>`), label, args` | 步骤条加一步(含完整 args) |
| `agent_clarification` | `agent_id, content, question, clarification_type, clarification_context, options, allow_*` | 设 `dialogue.clarification` → 卡片 |
| `agent_done` | `agent_id, content` | 收口,正文以终态为准 |
| `agent_error` | `agent_id, message` | 该席位置 error |
| `all_done` | — | 全部结束 |
**核心数据模型**(`types.ts`):
- `Dialogue { id, agentId, sender, avatarType, content, time, confidence, streaming?, steps?, clarification? }`
- `DialogueStep { id, key, label, ts, args? }`(`key` 形如 `tool_calling:web_search`)
- `SeatView { agentId, name, avatarType, status: pending|running|done|error, bubbleId }`
- `ClarificationPayload { question, clarificationType, context, options, allowCustom, allowMultiple }`
- `AgentResult { agent_id, name, content, status }`(宿主读取用)
> 改协议时:`types.ts` 的 `StreamEvent` 与后端 `parallel_agents.py` 的事件 dict **必须同步**。
---
## 6. 组件树
```
ParallelAgentsPanel
├─ ParallelSeatStrip ── Avatar (席位条,点头像切换单显)
└─ MessageBubble (当前选中席位)
├─ Avatar
├─ MessageStepsCard ── StepIcon (步骤条;getStepDisplay/getStepArgs 翻译 key+args)
├─ MarkdownContent (markdown-it)
└─ ClarificationCard (msg.clarification 存在时;提交→panel.onClarify→rerunAgent)
```
`ParallelAgentsPanel` 的对外接口(详见 [07-使用说明.md](./07-使用说明.md)):
- props:`agents`(必填) / `username` / `baseUrl` / `token` / `getToken` / `taskId` / `model` / `dark`
- emits:`saved(SavedTask)` / `update:taskId(string)` / `completed(AgentResult[])`
- `defineExpose`:`getResults()` / `getResult(id)` / `start()` / `cancel()`
---
## 7. 鉴权
`api.ts` 的 token 解析优先级:`token` > `getToken()` > 缓存 > 用 `username` 取 token。
- **取 token**:`POST {baseUrl}{TOKEN_PATH}`(`TOKEN_PATH=/api/parallel-agents/auth/token`)—— 本面板
**专用的「绕过常规登录」公共端点**(内网部署限制了标准 `/login/username`)。传 `{username}` 直接签发。
- 取到的 token 按 `baseUrl::username` **缓存**;业务请求带 `Authorization: Bearer`(顺带绕过 CSRF)。
- `authedFetch` 在 **401** 时清缓存、用 username 重登并**重试一次**。
- 后端该端点在 `auth_middleware._PUBLIC_EXACT_PATHS` + `csrf_middleware._AUTH_EXEMPT_PATHS` 白名单里。
---
## 8. 对接的后端契约
后端实现:`offline-backend-20260512/backend/app/gateway/routers/parallel_agents.py`
(任务存储:`parallel_agent_tasks.py` + `deerflow/persistence/parallel_agent_tasks/`)。
| 用途 | 接口 |
| ---------- | -------------------------------------------------------------------- |
| 取 token | `POST /api/parallel-agents/auth/token` `{username}` |
| 并行流式 | `POST /api/parallel-agents/run/stream` `{task_name, brief, agent_ids[], model?}` → SSE |
| 任务 CRUD | `GET/POST/GET{id}/PUT{id}/DELETE{id} /api/parallel-agent-tasks` |
| 模型列表 | `GET /api/models` → `{models:[{name, display_name}]}` |
**后端运行策略(重要)**:每个并行席位是「执行席位」,不是总控。
- **禁用工具** `_EXCLUDED_TOOLS = [write_file, str_replace, agent_orchestration, bash]`:①禁写文件(并行
不互相打架);②禁 `agent_orchestration`/`bash`(防弱模型模仿总控**派活**或绕道脚本逃逸)。
- **保留** 只读 / 检索工具 + `ask_clarification` → 步骤条、协作卡片正常。
- 任务消息带**角色边界护栏**(「执行席位、不要模仿总控调度 / 派活」)。
- 步骤 `args` 通过累积 `tool_call_chunks` + 终态全量 `tool_calls` 补全(流式期间空 args 不发,防钉死)。
- `ask_clarification` 从终态 messages 抽成结构化 `agent_clarification` 事件。
---
## 9. 样式系统(嵌入安全)
`styles/parallel-agents.css`:
- **不引 Tailwind preflight**(`@import "tailwindcss/theme.css"` + `utilities.css`),避免重置宿主全局样式。
- 颜色全用作用域内的 `--pa-*` 深蓝 token(取自《深蓝 UI 规范》,与圆桌 Step2 深色一致),全部包在
根类 `.parallel-agents` 下,**不污染宿主**。
- 语义类 `.pa-card / .pa-surface / .pa-border / .pa-text(-muted/-primary) / .pa-input / .pa-btn-* /
.pa-badge / .pa-md(Markdown)` —— 组件**上色用这些类**,布局用 Tailwind 工具类。
- 面板**自带深色成色**,不随宿主明暗变化;根元素 `h-full w-full flex flex-col min-h-0` 自适应容器。
---
## 10. 步骤条 / 协作卡片(与圆桌的对应)
- **步骤条** `MessageStepsCard.vue` ← 圆桌 `MessageStepsCard.tsx` + `ChainOfThought`:图标列(带竖向连接线)
+ 中文文案 + 时间 + 参数子节点。文案/图标映射在 `lib/step-display.ts`(`getStepDisplay`),参数子节点
在 `getStepArgs`(chip / code / text / kv)。加新工具图标:在 `step-display.ts` 的 switch + `StepIcon.vue`
各加一条。
- **协作卡片** `ClarificationCard.vue` ← 圆桌第一步 `IntentClarificationCard.tsx`:标题/类型标签/背景/选项
chip(单选点击即发、多选可勾)/自定义输入/发送。提交回传格式化回答 → 面板 `onClarify` → `rerunAgent`
带「【用户补充】…」**重跑该单个智能体**。
---
## 11. 构建与产物
```bash
pnpm install
pnpm dev # dev demo(连本地后端,App.vue 里填 baseUrl/username/agents)
pnpm typecheck # vue-tsc --noEmit
pnpm build:lib # 组件库:dist/parallel-agents-vue.{js,umd.cjs,css}
```
- `vite.config.ts`:`--mode lib` 走 `build.lib`,`external:['vue']`,`output.exports:'named'`,CSS 不拆分。
- `package.json` `exports`:`.`→ESM/UMD + types;`./style.css`→样式。宿主 `import "parallel-agents-vue/style.css"`。
---
## 12. 常见扩展怎么改
| 需求 | 改哪里 |
| ---------------------------- | --------------------------------------------------------------------------------------- |
| 新增一个 SSE 事件类型 | `types.ts` 的 `StreamEvent` + `useParallelRun.handleEvent` + 后端 `parallel_agents.py` |
| 步骤条支持新工具的图标/文案 | `lib/step-display.ts`(`getStepDisplay`/`getStepArgs`)+ `StepIcon.vue` |
| 改深色配色 / 加浅色主题 | `styles/parallel-agents.css` 的 `--pa-*`(浅色可在 `.parallel-agents--light` 下覆盖) |
| 改默认后端地址 / 模型 | `config.ts`(`DEFAULT_BASE_URL` / `DEFAULT_MODEL`) |
| 多席位并排显示(取消单选) | `ParallelAgentsPanel.vue` 的 `selected` / `visibleSeats` / `toggleSeat` 逻辑 |
| 宿主自定义存储(不走后端) | 用 `@completed` + `getResults()` 取数据,自行存;不调用 `createTask/updateTask` |
| 改鉴权方式 | `api.ts` 的 `resolveToken` / `loginByUsername` / `authedFetch` + `config.ts` 的 `TOKEN_PATH` |
---
## 13. 注意事项 / 已知约束
- **agent_id 必须真实**(后端注册过的 id),只填名字不行;可用 id 见 [06-可用智能体清单.md](./06-可用智能体清单.md)。
- 并行席位**禁派活/脚本/写文件**:本面板里每个智能体只做自己那份分析,产出走正文。
- 协作卡片回答是**重跑单个智能体**(新线程 + 把回答拼进任务),不是 LangGraph 原地 resume。
- token 取自公共端点(无门槛旁路)—— 适合内网;若网关要暴露公网,建议给取 token 端点加共享密钥校验。
- 跨域:宿主与后端不同源时,后端需放开该来源 CORS + `credentials`。
+19 -2
View File
@@ -2,19 +2,29 @@
/** Dev demo 调试页:填后端地址 / token / 智能体 id,把面板塞进一个弹窗,验证嵌入与流式。 */ /** Dev demo 调试页:填后端地址 / token / 智能体 id,把面板塞进一个弹窗,验证嵌入与流式。 */
import { reactive, ref } from "vue"; import { reactive, ref } from "vue";
import ParallelAgentsPanel from "./components/ParallelAgentsPanel.vue"; import ParallelAgentsPanel from "./components/ParallelAgentsPanel.vue";
import type { AgentInput } from "./types"; import type { AgentInput, AgentResult } from "./types";
import { DEFAULT_BASE_URL } from "./config"; import { DEFAULT_BASE_URL } from "./config";
const cfg = reactive({ const cfg = reactive({
baseUrl: localStorage.getItem("pa.baseUrl") || DEFAULT_BASE_URL, baseUrl: localStorage.getItem("pa.baseUrl") || DEFAULT_BASE_URL,
username: localStorage.getItem("pa.username") || "demo", username: localStorage.getItem("pa.username") || "demo",
taskId: localStorage.getItem("pa.taskId") || "",
agentsRaw: localStorage.getItem("pa.agents") || "", agentsRaw: localStorage.getItem("pa.agents") || "",
}); });
const open = ref(false); const open = ref(false);
const agents = ref<AgentInput[]>([]); const agents = ref<AgentInput[]>([]);
// 面板保存 / 生成 id 时回填,方便复制下来下次填进去测「按 taskId 回显」。
function onTaskId(id: string) {
cfg.taskId = id;
localStorage.setItem("pa.taskId", id);
}
function onCompleted(results: AgentResult[]) {
console.log("[demo] completed → 各智能体返回内容:", results);
}
function parseAgents(): AgentInput[] { function parseAgents(): AgentInput[] {
// 每行一个:`agent_id, 显示名` 或仅 `agent_id`。 // 每行一个:`agent_id, 显示名` 或仅 `agent_id`。
return cfg.agentsRaw return cfg.agentsRaw
@@ -30,6 +40,7 @@ function parseAgents(): AgentInput[] {
function launch() { function launch() {
localStorage.setItem("pa.baseUrl", cfg.baseUrl); localStorage.setItem("pa.baseUrl", cfg.baseUrl);
localStorage.setItem("pa.username", cfg.username); localStorage.setItem("pa.username", cfg.username);
localStorage.setItem("pa.taskId", cfg.taskId);
localStorage.setItem("pa.agents", cfg.agentsRaw); localStorage.setItem("pa.agents", cfg.agentsRaw);
agents.value = parseAgents(); agents.value = parseAgents();
open.value = true; open.value = true;
@@ -46,8 +57,11 @@ function launch() {
<label>用户名(免密自动登录,由你自己传) <label>用户名(免密自动登录,由你自己传)
<input v-model="cfg.username" style="width: 100%; padding: 6px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px" /> <input v-model="cfg.username" style="width: 100%; padding: 6px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px" />
</label> </label>
<label>taskId(存储主键,留空则首次保存自动生成;填已存在的 id 会自动回显)
<input v-model="cfg.taskId" placeholder="例如 my-task-001,留空也行" style="width: 100%; padding: 6px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px" />
</label>
<label>智能体(每行一个:agent_id, 显示名) <label>智能体(每行一个:agent_id, 显示名)
<textarea v-model="cfg.agentsRaw" rows="4" placeholder="roundtable-intelligence, 情报收集&#10;roundtable-risk-review, 风险审查" style="width: 100%; padding: 6px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px" /> <textarea v-model="cfg.agentsRaw" rows="4" placeholder="roundtable-solution-design, 方案设计&#10;roundtable-risk-review, 风险审查" style="width: 100%; padding: 6px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px" />
</label> </label>
<button @click="launch" style="align-self: flex-start; padding: 8px 16px; background: #1677ff; color: #fff; border: 0; border-radius: 6px; cursor: pointer"> <button @click="launch" style="align-self: flex-start; padding: 8px 16px; background: #1677ff; color: #fff; border: 0; border-radius: 6px; cursor: pointer">
打开面板(弹窗内) 打开面板(弹窗内)
@@ -64,7 +78,10 @@ function launch() {
<ParallelAgentsPanel <ParallelAgentsPanel
:base-url="cfg.baseUrl" :base-url="cfg.baseUrl"
:username="cfg.username || undefined" :username="cfg.username || undefined"
:task-id="cfg.taskId || undefined"
:agents="agents" :agents="agents"
@update:taskId="onTaskId"
@completed="onCompleted"
/> />
</div> </div>
</div> </div>
+4 -6
View File
@@ -6,7 +6,7 @@
* - `{baseUrl}/api/parallel-agent-tasks` —— 任务保存/回显 CRUD。 * - `{baseUrl}/api/parallel-agent-tasks` —— 任务保存/回显 CRUD。
*/ */
import type { AgentInput, Dialogue, SavedTask, StreamEvent } from "./types"; import type { AgentInput, Dialogue, SavedTask, StreamEvent } from "./types";
import { USERNAME_LOGIN_PATH } from "./config"; import { TOKEN_PATH } from "./config";
export interface ApiConfig { export interface ApiConfig {
baseUrl: string; baseUrl: string;
@@ -30,12 +30,10 @@ function base(cfg: ApiConfig): string {
const _tokenCache = new Map<string, string>(); const _tokenCache = new Map<string, string>();
const cacheKey = (cfg: ApiConfig) => `${base(cfg)}::${cfg.username ?? ""}`; const cacheKey = (cfg: ApiConfig) => `${base(cfg)}::${cfg.username ?? ""}`;
/** 用 username 走公共「用户名登录」端点换 access_token(CSRF 豁免)。 */ /** 用 username 走本面板**专用**的「绕过常规登录」公共端点换 access_token(CSRF 豁免)。 */
async function loginByUsername(cfg: ApiConfig): Promise<string> { async function loginByUsername(cfg: ApiConfig): Promise<string> {
if (!cfg.username) throw new Error("缺少 token 且未提供 username,无法登录"); if (!cfg.username) throw new Error("缺少 token 且未提供 username,无法登录");
let url = `${base(cfg)}${USERNAME_LOGIN_PATH}`; const res = await fetch(`${base(cfg)}${TOKEN_PATH}`, {
if (cfg.loginPassword) url += `?password=${encodeURIComponent(cfg.loginPassword)}`;
const res = await fetch(url, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: cfg.username }), body: JSON.stringify({ username: cfg.username }),
@@ -43,7 +41,7 @@ async function loginByUsername(cfg: ApiConfig): Promise<string> {
}); });
if (!res.ok) { if (!res.ok) {
const text = await res.text().catch(() => ""); const text = await res.text().catch(() => "");
throw new Error(`username login failed (${res.status}): ${text}`); throw new Error(`get token failed (${res.status}): ${text}`);
} }
const data = (await res.json()) as { access_token: string; token_type?: string }; const data = (await res.json()) as { access_token: string; token_type?: string };
const token = data.access_token; const token = data.access_token;
@@ -0,0 +1,152 @@
<script setup lang="ts">
/**
* 协作 / 澄清卡片 —— 对齐圆桌第一步的 IntentClarificationCard 样式与交互:
* 「需要你的确认」标题 + 类型标签 + 背景说明 + 选项 chip(单选点击即发 / 多选可勾)+
* 自定义输入 + 发送按钮。提交时回传格式化好的回答文本。
*/
import { computed, reactive } from "vue";
import type { ClarificationPayload, ClarOption } from "@/types";
const props = defineProps<{ payload: ClarificationPayload; submitting?: boolean }>();
const emit = defineEmits<{ (e: "submit", answer: string): void }>();
interface NormOption {
id: string;
label: string;
description?: string;
}
function norm(opts: ClarOption[]): NormOption[] {
return (opts || []).map((o, i) =>
typeof o === "string"
? { id: o, label: o }
: { id: o.id || o.label || String(i), label: o.label || o.id || "", description: o.description },
);
}
const options = computed(() => norm(props.payload.options));
const allowMultiple = computed(() => !!props.payload.allowMultiple);
const allowCustom = computed(() => props.payload.allowCustom !== false);
const deferSubmit = computed(() => allowCustom.value); // 允许自定义时不在单击即发,给机会补充
const showSubmit = computed(() => allowMultiple.value || allowCustom.value || options.value.length === 0);
const sel = reactive({ ids: [] as string[], custom: "" });
function labelOf(id: string): string {
return options.value.find((o) => o.id === id)?.label || id;
}
function buildAnswer(ids: string[]): string | null {
const labels = ids.map(labelOf);
const custom = sel.custom.trim();
if (labels.length && custom) return `我选择:${labels.join("、")};补充:${custom}`;
if (labels.length) return `我选择:${labels.join("、")}`;
if (custom) return custom;
return null;
}
function clickOption(id: string) {
if (props.submitting) return;
if (!allowMultiple.value) {
if (deferSubmit.value) {
sel.ids = [id];
return;
}
emit("submit", buildAnswer([id]) ?? labelOf(id));
return;
}
sel.ids = sel.ids.includes(id) ? sel.ids.filter((x) => x !== id) : [...sel.ids, id];
}
const canSubmit = computed(() => sel.ids.length > 0 || sel.custom.trim().length > 0);
function submit() {
if (props.submitting || !canSubmit.value) return;
const ans = buildAnswer(sel.ids);
if (ans) emit("submit", ans);
}
</script>
<template>
<div class="pa-card pa-border mt-2 rounded-xl border p-3 shadow-sm">
<!-- 头部 -->
<div class="space-y-1.5">
<div class="flex items-center gap-2">
<span class="text-[10px] font-bold uppercase tracking-wide pa-text-primary">需要你的确认</span>
<span
v-if="payload.clarificationType"
class="pa-badge rounded-full"
style="text-transform: none"
>
{{ payload.clarificationType }}
</span>
</div>
<p v-if="payload.context" class="text-[11px] italic leading-relaxed pa-text-muted">
{{ payload.context }}
</p>
<div class="text-xs font-medium leading-relaxed pa-text">{{ payload.question }}</div>
</div>
<!-- 选项 -->
<div v-if="options.length" class="mt-3 grid gap-2">
<div class="text-[10px] pa-text-muted">
{{ allowMultiple ? "可多选(再次点击取消选择)" : "请选择一项(点击即发送)" }}
</div>
<button
v-for="opt in options"
:key="opt.id"
type="button"
:disabled="submitting"
class="flex w-full items-start justify-between gap-2 rounded-xl border px-3 py-2 text-left transition"
:class="
sel.ids.includes(opt.id)
? 'pa-opt-selected'
: 'pa-border pa-surface-soft pa-text hover:opacity-90'
"
@click="clickOption(opt.id)"
>
<span class="flex min-w-0 flex-1 flex-col items-start gap-1">
<span class="text-[11px] font-bold">{{ opt.label }}</span>
<span v-if="opt.description" class="text-[10px] leading-snug" :class="sel.ids.includes(opt.id) ? '' : 'pa-text-muted'">
{{ opt.description }}
</span>
</span>
<span v-if="sel.ids.includes(opt.id)" class="mt-0.5 shrink-0">✓</span>
</button>
</div>
<!-- 自定义输入 -->
<div v-if="allowCustom" class="mt-3 flex flex-col gap-2">
<textarea
v-model="sel.custom"
:disabled="submitting"
rows="2"
class="pa-input min-h-16 resize-none px-2.5 py-1.5 text-xs"
:placeholder="
options.length
? allowMultiple
? '可选:补充说明(与所选项一起发送),或填写自定义回答'
: '如果以上选项都不合适,可在这里输入自定义回答'
: '请输入你的回答'
"
/>
</div>
<!-- 发送 -->
<div v-if="showSubmit" class="mt-3 flex justify-end">
<button
type="button"
class="pa-btn-primary h-8 rounded-xl px-4 text-xs font-bold"
:disabled="submitting || !canSubmit"
@click="submit"
>
{{ submitting ? "发送中…" : "发送回答" }}
</button>
</div>
</div>
</template>
<style scoped>
.pa-opt-selected {
background-color: var(--pa-primary);
color: #fff;
border: 1px solid var(--pa-primary);
}
</style>
@@ -6,8 +6,10 @@ import type { Dialogue } from "@/types";
import Avatar from "./Avatar.vue"; import Avatar from "./Avatar.vue";
import MessageStepsCard from "./MessageStepsCard.vue"; import MessageStepsCard from "./MessageStepsCard.vue";
import MarkdownContent from "./MarkdownContent.vue"; import MarkdownContent from "./MarkdownContent.vue";
import ClarificationCard from "./ClarificationCard.vue";
const props = defineProps<{ msg: Dialogue }>(); const props = defineProps<{ msg: Dialogue; submitting?: boolean }>();
const emit = defineEmits<{ (e: "clarify", agentId: string, answer: string): void }>();
const hasContent = computed(() => (props.msg.content ?? "").trim().length > 0); const hasContent = computed(() => (props.msg.content ?? "").trim().length > 0);
</script> </script>
@@ -45,6 +47,14 @@ const hasContent = computed(() => (props.msg.content ?? "").trim().length > 0);
<MarkdownContent v-if="hasContent" :source="msg.content" /> <MarkdownContent v-if="hasContent" :source="msg.content" />
<span v-if="msg.streaming && hasContent" class="pa-cursor" /> <span v-if="msg.streaming && hasContent" class="pa-cursor" />
</div> </div>
<!-- 协作 / 澄清卡片(对齐圆桌第一步) -->
<ClarificationCard
v-if="msg.clarification"
:payload="msg.clarification"
:submitting="submitting"
@submit="(answer) => emit('clarify', msg.agentId, answer)"
/>
</div> </div>
</div> </div>
</template> </template>
@@ -1,52 +1,95 @@
<script setup lang="ts"> <script setup lang="ts">
/** 步骤条 / 思维链卡:折叠展示该气泡运行期间的工具调用步骤(对齐 Step2 MessageStepsCard)。 */ /**
import { ref } from "vue"; * 步骤条 / 思维链卡 —— 1:1 复刻圆桌 Step2 的 MessageStepsCard + ChainOfThought:
* slate 边框卡 + 每步「图标列(带竖向连接线) + 中文文案 + 时间 + 参数子节点」。
*/
import { computed, ref } from "vue";
import type { DialogueStep } from "@/types"; import type { DialogueStep } from "@/types";
import { getStepArgs, getStepDisplay } from "@/lib/step-display";
import StepIcon from "./StepIcon.vue";
defineProps<{ steps: DialogueStep[]; streaming?: boolean }>(); const props = defineProps<{ steps: DialogueStep[]; streaming?: boolean }>();
const open = ref(true); const open = ref(true);
function argSummary(args?: Record<string, unknown> | null): string { const rows = computed(() =>
if (!args) return ""; props.steps.map((s, i) => {
const path = (args as { path?: unknown }).path; const display = getStepDisplay(s.key, s.label, s.args);
if (typeof path === "string") return path; return {
try { step: s,
const s = JSON.stringify(args); display,
return s.length > 60 ? s.slice(0, 60) + "…" : s; args: getStepArgs(s.key, s.args),
} catch { // 最后一步在 streaming 时为「进行中」(前景色),其余为已完成(次级色)。
return ""; active: props.streaming && i === props.steps.length - 1,
} isLast: i === props.steps.length - 1,
} };
}),
);
</script> </script>
<template> <template>
<div class="pa-surface pa-border mb-2 rounded-lg border p-0.5"> <div class="pa-surface pa-border mb-2 w-full gap-1 rounded-lg border p-0.5">
<button <button
type="button" type="button"
class="flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left text-[11px] font-semibold pa-text-muted" class="flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left text-[11px] font-semibold pa-text-muted"
@click="open = !open" @click="open = !open"
> >
<span <span class="inline-block transition-transform" :class="open ? 'rotate-90' : ''">▶</span>
class="inline-block transition-transform"
:class="open ? 'rotate-90' : ''"
>▶</span
>
<span>执行步骤({{ steps.length }})</span> <span>执行步骤({{ steps.length }})</span>
<span v-if="streaming" class="pa-spin inline-block">◜</span>
</button> </button>
<ol v-show="open" class="space-y-0.5 px-2 pb-1.5 pt-0.5">
<li <div v-show="open" class="space-y-2 px-3 pb-2 pt-0.5">
v-for="s in steps" <div
:key="s.id" v-for="row in rows"
class="flex items-start gap-2 text-[11px] pa-text" :key="row.step.id"
class="flex gap-2 text-sm"
:class="row.active ? 'pa-text' : 'pa-text-muted'"
> >
<span class="mt-1 inline-block size-1.5 shrink-0 rounded-full" style="background: var(--pa-primary)" /> <!-- 图标列 + 竖向连接线 -->
<span class="min-w-0"> <div class="relative mt-0.5">
<span class="font-semibold">{{ s.label }}</span> <StepIcon :name="row.display.icon" :size="16" />
<span v-if="argSummary(s.args)" class="pa-text-muted"> · {{ argSummary(s.args) }}</span> <div
<span class="pa-text-muted"> · {{ s.ts }}</span> v-if="!row.isLast"
</span> class="absolute left-1/2 -mx-px w-px"
</li> style="top: 26px; bottom: -8px; background: var(--pa-border)"
</ol> />
</div>
<!-- 文案 + 时间 + 参数 -->
<div class="min-w-0 flex-1 space-y-1 overflow-hidden">
<div class="leading-snug">
<span class="font-medium pa-text">{{ row.display.label }}</span>
<span class="ml-2 font-mono text-[11px] pa-text-muted">{{ row.step.ts }}</span>
</div>
<!-- 参数子节点(按 kind 渲染,对齐 renderStepArgs) -->
<template v-if="row.args">
<span
v-if="row.args.kind === 'chip'"
class="pa-surface pa-border inline-block max-w-full truncate rounded border px-1.5 py-0.5 text-[11px] pa-text-muted"
>
{{ row.args.text }}
</span>
<pre
v-else-if="row.args.kind === 'code'"
class="pa-md max-h-60 overflow-auto rounded-md p-2 text-[11px]"
><code>{{ row.args.text }}</code></pre>
<span
v-else-if="row.args.kind === 'text'"
class="line-clamp-2 block text-[11px] leading-snug pa-text-muted"
>
{{ row.args.text }}
</span>
<span v-else-if="row.args.kind === 'kv'" class="flex flex-wrap gap-1">
<span
v-for="p in row.args.pairs"
:key="p.k"
class="pa-surface pa-border inline-block rounded border px-1.5 py-0.5 text-[11px] pa-text-muted"
>
<span class="opacity-60">{{ p.k }}:</span> {{ p.v }}
</span>
</span>
</template>
</div>
</div>
</div>
</div> </div>
</template> </template>
@@ -9,7 +9,7 @@
* 自适应容器:根元素 `h-full w-full flex flex-col min-h-0`,宿主直接塞进抽屉 / 弹窗即可。 * 自适应容器:根元素 `h-full w-full flex flex-col min-h-0`,宿主直接塞进抽屉 / 弹窗即可。
*/ */
import { computed, onMounted, reactive, ref, watch } from "vue"; import { computed, onMounted, reactive, ref, watch } from "vue";
import type { AgentInput, Dialogue, SavedTask } from "@/types"; import type { AgentInput, AgentResult, Dialogue, SavedTask, SeatView } from "@/types";
import { useParallelRun } from "@/composables/useParallelRun"; import { useParallelRun } from "@/composables/useParallelRun";
import { createTask, getTask, listModels, listTasks, updateTask, type ApiConfig, type ModelItem } from "@/api"; import { createTask, getTask, listModels, listTasks, updateTask, type ApiConfig, type ModelItem } from "@/api";
import { DEFAULT_BASE_URL, DEFAULT_MODEL } from "@/config"; import { DEFAULT_BASE_URL, DEFAULT_MODEL } from "@/config";
@@ -38,6 +38,8 @@ const props = withDefaults(
const emit = defineEmits<{ const emit = defineEmits<{
(e: "saved", task: SavedTask): void; (e: "saved", task: SavedTask): void;
(e: "update:taskId", id: string): void; (e: "update:taskId", id: string): void;
/** 一轮并行分析结束时回传各智能体的返回内容。 */
(e: "completed", results: AgentResult[]): void;
}>(); }>();
const cfg = computed<ApiConfig>(() => ({ const cfg = computed<ApiConfig>(() => ({
@@ -90,6 +92,15 @@ watch(
{ immediate: true }, { immediate: true },
); );
// 宿主传入 / 改变 taskId:作为存储主键,并尝试加载回显该任务(不存在则只设 id)。
watch(
() => props.taskId,
(id) => {
currentTaskId.value = id ?? null;
if (id) void loadTask(id);
},
);
/** 点头像切换显示:单选,始终切到点击的那个席位。 */ /** 点头像切换显示:单选,始终切到点击的那个席位。 */
function toggleSeat(agentId: string) { function toggleSeat(agentId: string) {
selected.value = [agentId]; selected.value = [agentId];
@@ -101,6 +112,43 @@ const visibleSeats = computed(() => {
return seat ? [seat] : []; return seat ? [seat] : [];
}); });
// ── 给宿主读取「每个智能体模型返回的内容」的简单方法 ──────────────────────────
function _statusOf(agentId: string, streaming?: boolean): SeatView["status"] {
return seats.value.find((s) => s.agentId === agentId)?.status ?? (streaming ? "running" : "done");
}
/** 取所有智能体的返回内容(按席位顺序)。宿主:`panelRef.value.getResults()`。 */
function getResults(): AgentResult[] {
return dialogues.value.map((d) => ({
agent_id: d.agentId,
name: d.sender,
content: d.content,
status: _statusOf(d.agentId, d.streaming),
}));
}
/** 取某个智能体的返回内容;不存在返回 null。 */
function getResult(agentId: string): AgentResult | null {
return getResults().find((r) => r.agent_id === agentId) ?? null;
}
// 模板 ref 用:`<ParallelAgentsPanel ref="panelRef" />` → panelRef.value.getResults()
defineExpose({ getResults, getResult, start: () => onStart(), cancel: () => run.cancel() });
/** 用户在某智能体的协作卡片里提交了回答 → 带上回答重跑该智能体。 */
async function onClarify(agentId: string, answer: string) {
errorMsg.value = null;
const brief = `${form.brief}\n\n【用户补充】${answer}`.trim();
try {
await run.rerunAgent(cfg.value, {
taskName: form.taskName,
brief,
agentId,
model: selectedModel.value || undefined,
});
emit("completed", getResults());
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
}
}
async function onStart() { async function onStart() {
if (!canStart.value) return; if (!canStart.value) return;
errorMsg.value = null; errorMsg.value = null;
@@ -111,6 +159,8 @@ async function onStart() {
agents: props.agents, agents: props.agents,
model: selectedModel.value || undefined, model: selectedModel.value || undefined,
}); });
// 一轮结束:回传各智能体返回内容(宿主也可用 ref 调 getResults())。
emit("completed", getResults());
} catch (err) { } catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err); errorMsg.value = err instanceof Error ? err.message : String(err);
} }
@@ -123,21 +173,15 @@ async function onSave() {
try { try {
const title = form.taskName.trim() || "未命名任务"; const title = form.taskName.trim() || "未命名任务";
const agentsSnapshot: AgentInput[] = props.agents.map((a) => ({ ...a })); const agentsSnapshot: AgentInput[] = props.agents.map((a) => ({ ...a }));
const payload = { title, brief: form.brief, agents: agentsSnapshot, messages: dialogues.value };
// 按宿主传入的 taskId **upsert**:有 id 先更新,不存在(404)再用该 id 创建;无 id 则新建。
let saved: SavedTask | null; let saved: SavedTask | null;
if (currentTaskId.value) { const id = currentTaskId.value;
saved = await updateTask(cfg.value, currentTaskId.value, { if (id) {
title, saved = await updateTask(cfg.value, id, payload);
brief: form.brief, if (!saved) saved = await createTask(cfg.value, { id, ...payload });
agents: agentsSnapshot,
messages: dialogues.value,
});
} else { } else {
saved = await createTask(cfg.value, { saved = await createTask(cfg.value, payload);
title,
brief: form.brief,
agents: agentsSnapshot,
messages: dialogues.value,
});
} }
if (saved) { if (saved) {
currentTaskId.value = saved.id; currentTaskId.value = saved.id;
@@ -311,6 +355,8 @@ onMounted(() => {
<MessageBubble <MessageBubble
v-if="dialogueById.get(seat.bubbleId)" v-if="dialogueById.get(seat.bubbleId)"
:msg="dialogueById.get(seat.bubbleId)!" :msg="dialogueById.get(seat.bubbleId)!"
:submitting="running"
@clarify="onClarify"
/> />
<div v-else class="py-6 text-center text-xs pa-text-muted"> <div v-else class="py-6 text-center text-xs pa-text-muted">
{{ seat.name }} · 等待输出… {{ seat.name }} · 等待输出…
@@ -0,0 +1,56 @@
<script setup lang="ts">
import type { IconKey } from "@/lib/step-display";
defineProps<{ name: IconKey; size?: number }>();
</script>
<template>
<svg
:width="size || 16"
:height="size || 16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<template v-if="name === 'search'">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
</template>
<template v-else-if="name === 'globe'">
<circle cx="12" cy="12" r="10" /><path d="M2 12h20" />
<path d="M12 2a15 15 0 0 1 4 10 15 15 0 0 1-4 10 15 15 0 0 1-4-10 15 15 0 0 1 4-10Z" />
</template>
<template v-else-if="name === 'folder'">
<path
d="M6 14l1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.55 6A2 2 0 0 1 18.45 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"
/>
</template>
<template v-else-if="name === 'book'">
<path d="M12 7v14" /><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" />
</template>
<template v-else-if="name === 'pen'">
<path d="M12 20h9" /><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
</template>
<template v-else-if="name === 'terminal'">
<rect x="3" y="4" width="18" height="16" rx="2" /><path d="m7 11 2-2-2-2" /><path d="M11 13h4" />
</template>
<template v-else-if="name === 'todo'">
<rect x="3" y="5" width="6" height="6" rx="1" /><path d="m3 17 2 2 4-4" />
<path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" />
</template>
<template v-else-if="name === 'question'">
<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" /><path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3" /><path d="M12 17h.01" />
</template>
<template v-else-if="name === 'lightbulb'">
<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5" />
<path d="M9 18h6" /><path d="M10 22h4" />
</template>
<template v-else-if="name === 'dot'">
<circle cx="12" cy="12" r="2" fill="currentColor" stroke="none" />
</template>
<template v-else>
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</template>
</svg>
</template>
@@ -95,6 +95,26 @@ export function useParallelRun() {
dialogues.value = [...dialogues.value]; dialogues.value = [...dialogues.value];
break; break;
} }
case "agent_clarification": {
flushPending();
const d = findDialogue(ev.agent_id);
if (d) {
if (ev.content) d.content = ev.content;
d.clarification = {
question: ev.question,
clarificationType: ev.clarification_type,
context: ev.clarification_context,
options: ev.options,
allowCustom: ev.allow_custom,
allowMultiple: ev.allow_multiple,
};
d.streaming = false;
d.confidence = "待确认";
}
setSeatStatus(ev.agent_id, "done");
dialogues.value = [...dialogues.value];
break;
}
case "agent_error": { case "agent_error": {
flushPending(); flushPending();
const d = findDialogue(ev.agent_id); const d = findDialogue(ev.agent_id);
@@ -178,6 +198,48 @@ export function useParallelRun() {
running.value = false; running.value = false;
} }
/**
* 重跑**单个**智能体(用于回答澄清后继续):清掉它的旧气泡/步骤/澄清,带上新消息重新
* 流式,只更新该席位列,其它席位不动。
*/
async function rerunAgent(
cfg: ApiConfig,
args: { taskName: string; brief: string; agentId: string; model?: string },
): Promise<void> {
if (running.value) return;
const d = dialogues.value.find((x) => x.agentId === args.agentId);
if (!d) return;
d.content = "";
d.steps = [];
d.clarification = undefined;
d.streaming = true;
d.confidence = "并行执行中";
d.time = nowTime();
setSeatStatus(args.agentId, "running");
dialogues.value = [...dialogues.value];
running.value = true;
const ac = new AbortController();
abortRef.value = ac;
try {
await streamParallelAgents(
cfg,
{ task_name: args.taskName, brief: args.brief, agent_ids: [args.agentId], model: args.model },
{ onEvent: handleEvent, onError: (err) => console.error("[parallel-run]", err) },
ac.signal,
);
} finally {
const dd = dialogues.value.find((x) => x.agentId === args.agentId);
if (dd && dd.streaming) {
dd.streaming = false;
if (dd.confidence === "并行执行中") dd.confidence = "已完成";
}
dialogues.value = [...dialogues.value];
running.value = false;
abortRef.value = null;
}
}
/** 回显:从保存的对话列表重建(并据此还原席位条为已完成)。 */ /** 回显:从保存的对话列表重建(并据此还原席位条为已完成)。 */
function hydrate(saved: Dialogue[]) { function hydrate(saved: Dialogue[]) {
cancel(); cancel();
@@ -208,5 +270,5 @@ export function useParallelRun() {
seats.value = []; seats.value = [];
} }
return { dialogues, seats, running, start, cancel, hydrate, reset }; return { dialogues, seats, running, start, cancel, hydrate, reset, rerunAgent };
} }
+5 -2
View File
@@ -12,5 +12,8 @@ export const DEFAULT_BASE_URL: string =
export const DEFAULT_MODEL: string | undefined = export const DEFAULT_MODEL: string | undefined =
(import.meta as unknown as { env?: Record<string, string> }).env?.VITE_PA_MODEL || undefined; (import.meta as unknown as { env?: Record<string, string> }).env?.VITE_PA_MODEL || undefined;
/** 免密「用户名登录」端点(公共 + CSRF 豁免)。 */ /**
export const USERNAME_LOGIN_PATH = "/api/v1/auth/login/username"; * 取 token 端点:本面板**专用**的「绕过常规登录」公共端点(传用户名直接签发 token)。
* 内网部署对标准 /api/v1/auth/login/username 有额外限制,故走这个专用端点。
*/
export const TOKEN_PATH = "/api/parallel-agents/auth/token";
+1 -1
View File
@@ -9,7 +9,7 @@ import type { App } from "vue";
import ParallelAgentsPanel from "./components/ParallelAgentsPanel.vue"; import ParallelAgentsPanel from "./components/ParallelAgentsPanel.vue";
export { ParallelAgentsPanel }; export { ParallelAgentsPanel };
export type { AgentInput, Dialogue, SavedTask, SeatView, StreamEvent } from "./types"; export type { AgentInput, AgentResult, Dialogue, SavedTask, SeatView, StreamEvent } from "./types";
export default { export default {
install(app: App) { install(app: App) {
+120
View File
@@ -0,0 +1,120 @@
/**
* 步骤条渲染辅助(Vue 端,1:1 对齐圆桌 `roundtable-planning/lib/step-display.tsx`)。
* 把 step.key(`tool_calling:<toolName>`)+ args 翻译成「图标 key + 中文文案」,
* 以及参数子节点的结构化描述(chip / code / text / kv),供 MessageStepsCard 渲染。
*/
export type IconKey =
| "search"
| "globe"
| "folder"
| "book"
| "pen"
| "terminal"
| "todo"
| "question"
| "wrench"
| "lightbulb"
| "dot";
export interface StepDisplay {
icon: IconKey;
label: string;
}
function str(v: unknown): string {
return typeof v === "string" ? v.trim() : "";
}
/** key=`tool_calling:<toolName>` + args → 图标 + 中文文案(对齐主聊天 toolCalls 文案)。 */
export function getStepDisplay(key: string, fallbackLabel: string, args?: Record<string, unknown> | null): StepDisplay {
const [phase, toolName] = key.split(":");
if (phase === "tool_calling" && toolName) {
switch (toolName) {
case "web_search": {
const q = str(args?.query);
return { icon: "search", label: q ? `在网络上搜索 “${q}”` : "搜索相关信息" };
}
case "image_search": {
const q = str(args?.query);
return { icon: "search", label: q ? `搜索相关图片 “${q}”` : "搜索相关图片" };
}
case "web_fetch":
return { icon: "globe", label: "查看网页" };
case "ls":
return { icon: "folder", label: str(args?.description) || "列出文件夹" };
case "read_file":
return { icon: "book", label: str(args?.description) || "读取文件" };
case "write_file":
case "str_replace":
return { icon: "pen", label: str(args?.description) || "写入文件" };
case "bash":
return { icon: "terminal", label: str(args?.description) || "执行命令" };
case "write_todos":
return { icon: "todo", label: "更新 To-do 列表" };
case "ask_clarification":
return { icon: "question", label: "需要你的协助" };
case "present_files":
return { icon: "folder", label: "展示文件" };
case "agent_orchestration":
return { icon: "wrench", label: "派活给子智能体" };
default:
return { icon: "wrench", label: `使用 “${toolName}” 工具` };
}
}
if (phase === "streaming") return { icon: "lightbulb", label: fallbackLabel };
return { icon: "dot", label: fallbackLabel };
}
/** 参数子节点的结构化描述(供模板按 kind 渲染)。null = 不渲染。 */
export type StepArgs =
| { kind: "chip"; text: string }
| { kind: "code"; text: string }
| { kind: "text"; text: string }
| { kind: "kv"; pairs: Array<{ k: string; v: string }> }
| null;
export function getStepArgs(key: string, args?: Record<string, unknown> | null): StepArgs {
if (!args) return null;
const [, toolName] = key.split(":");
if (!toolName) return null;
switch (toolName) {
case "web_search":
case "image_search": {
const q = str(args.query);
return q ? { kind: "chip", text: q } : null;
}
case "web_fetch": {
const u = str(args.url);
return u ? { kind: "chip", text: u } : null;
}
case "read_file":
case "ls":
case "write_file":
case "str_replace": {
const p = str(args.path);
return p ? { kind: "chip", text: p } : null;
}
case "bash": {
const c = str(args.command);
return c ? { kind: "code", text: c } : null;
}
case "write_todos":
return null;
case "ask_clarification": {
const q = str(args.question);
return q ? { kind: "text", text: q } : null;
}
case "agent_orchestration": {
const task = str(args.task);
return task ? { kind: "text", text: task } : null;
}
default: {
const pairs = Object.entries(args)
.filter(([, v]) => v !== null && v !== undefined && v !== "")
.slice(0, 4)
.map(([k, v]) => ({ k, v: typeof v === "string" ? v : JSON.stringify(v) }));
return pairs.length ? { kind: "kv", pairs } : null;
}
}
}
+36 -1
View File
@@ -5,7 +5,7 @@
* 归到对应席位列)。`StreamEvent` 是后端 `/api/parallel-agents/run/stream` 的统一事件协议。 * 归到对应席位列)。`StreamEvent` 是后端 `/api/parallel-agents/run/stream` 的统一事件协议。
*/ */
/** 一条工具调用步骤(喂步骤条 / 思维链卡)。 */ /** 一条工具调用步骤(喂步骤条 / 思维链卡)。`key` 形如 `tool_calling:<toolName>`。 */
export interface DialogueStep { export interface DialogueStep {
id: string; id: string;
key: string; key: string;
@@ -14,6 +14,19 @@ export interface DialogueStep {
args?: Record<string, unknown> | null; args?: Record<string, unknown> | null;
} }
/** 澄清/协作卡片的一个选项(字符串或带描述的对象)。 */
export type ClarOption = string | { id?: string; label?: string; description?: string };
/** 智能体向用户要澄清时的结构化载荷(对齐圆桌第一步协作卡片)。 */
export interface ClarificationPayload {
question: string;
clarificationType: string;
context: string;
options: ClarOption[];
allowCustom: boolean;
allowMultiple: boolean;
}
/** 一条消息气泡(对齐 Step2Dialogue + agentId)。 */ /** 一条消息气泡(对齐 Step2Dialogue + agentId)。 */
export interface Dialogue { export interface Dialogue {
id: number; id: number;
@@ -27,6 +40,8 @@ export interface Dialogue {
confidence: string; confidence: string;
streaming?: boolean; streaming?: boolean;
steps?: DialogueStep[]; steps?: DialogueStep[];
/** 该智能体在要澄清时的协作卡片载荷(有则渲染卡片)。 */
clarification?: ClarificationPayload;
} }
/** 并行席位的视图状态(席位状态条用)。 */ /** 并行席位的视图状态(席位状态条用)。 */
@@ -51,10 +66,30 @@ export type StreamEvent =
| { type: "agent_start"; agent_id: string } | { type: "agent_start"; agent_id: string }
| { type: "delta"; agent_id: string; text: string } | { type: "delta"; agent_id: string; text: string }
| { type: "step"; agent_id: string; key: string; label: string; args?: Record<string, unknown> | null } | { type: "step"; agent_id: string; key: string; label: string; args?: Record<string, unknown> | null }
| {
type: "agent_clarification";
agent_id: string;
content: string;
question: string;
clarification_type: string;
clarification_context: string;
options: ClarOption[];
allow_custom: boolean;
allow_multiple: boolean;
}
| { type: "agent_done"; agent_id: string; content: string } | { type: "agent_done"; agent_id: string; content: string }
| { type: "agent_error"; agent_id: string; message: string } | { type: "agent_error"; agent_id: string; message: string }
| { type: "all_done" }; | { type: "all_done" };
/** 单个智能体的返回结果(供宿主通过 getResults() / completed 事件读取)。 */
export interface AgentResult {
agent_id: string;
name: string;
/** 该智能体模型返回的正文(Markdown)。 */
content: string;
status: "pending" | "running" | "done" | "error";
}
/** 持久化的任务记录(与后端 /api/parallel-agent-tasks 对应)。 */ /** 持久化的任务记录(与后端 /api/parallel-agent-tasks 对应)。 */
export interface SavedTask { export interface SavedTask {
id: string; id: string;