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;
/** Optional per-stage goals aligned to `stages` (dag-only). null = none. */
stageGoals?: (string | null)[] | null;
/** Optional coordinator orchestration prompt (dag-only). null/"" = none. */
coordinatorPrompt?: string | null;
}
/** Partial update — send only what changed. */
@@ -37,6 +39,8 @@ export interface ChainUpdatePayload {
stages?: string[][] | null;
/** Per-stage goals aligned to `stages`; send `null` to clear, omit to leave. */
stageGoals?: (string | null)[] | null;
/** 写给总控的编排提示;send `null`/"" to clear, omit to leave. */
coordinatorPrompt?: string | null;
/** 发布开关:true 发布为公共 / false 取消发布;omit 不改。仅 owner 生效。 */
isPublic?: boolean;
}
@@ -65,6 +69,7 @@ interface ServerChain {
seats: ChainSeat[];
stages?: string[][] | null;
stage_goals?: (string | null)[] | null;
coordinator_prompt?: string | null;
is_public?: boolean;
is_mine?: boolean;
created_at: string | null;
@@ -79,6 +84,7 @@ function toChain(s: ServerChain): BusinessChain {
seats: Array.isArray(s.seats) ? s.seats : [],
stages: Array.isArray(s.stages) ? s.stages : null,
stageGoals: Array.isArray(s.stage_goals) ? s.stage_goals : null,
coordinatorPrompt: s.coordinator_prompt ?? null,
isPublic: s.is_public ?? false,
// 后端缺省时按「我的」处理(向后兼容旧响应)。
isMine: s.is_mine ?? true,
@@ -95,6 +101,7 @@ function fromCreate(p: ChainCreatePayload) {
seats: p.seats,
...(p.stages !== undefined ? { stages: p.stages } : {}),
...(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;
// Forward stage goals even when explicitly null (clears goals server-side).
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;
return out;
}
@@ -108,7 +108,9 @@ export function BusinessChainPicker({
);
const canUse = !!selectedChain && survivingSeats.length >= CHAIN_MIN_SEATS;
// 选中链条若有分层、且过滤到存活席位后**仍含并行 stage** → 走 dag。此时让用户在
// 选中链条若有分层、且过滤到存活席位后**仍含并行 stage,或任一 stage 带「本阶段目标」**
// → 走 dag。并行要 dag 才能并行调度;带目标也要 dag —— 目标靠总控**逐阶段派活**注入
// (buildDagStageLeaderTask),chain 模式不消费目标,故串行带目标链同样走 dag。此时让用户在
// 「快速(并行双列禁写) / 沙箱(并行单列可写、沙箱跟随)」之间二选一(§DAG 执行子模式)。
const dagPlan = useMemo(() => {
if (!selectedChain?.stages || selectedChain.stages.length === 0) return null;
@@ -118,8 +120,12 @@ export function BusinessChainPicker({
surviveIds,
true,
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]);
const [dagExecMode, setDagExecMode] = useState<"fast" | "sandbox">("fast");
@@ -1871,6 +1871,7 @@ export function useStep2Orchestration(
seats: stageSeats.map((p) => ({ agentId: p.agentId, name: p.name })),
isFirst: s === 0,
goal: stage.goal, // 业务链条里为本阶段写的「本阶段目标」(可空)
coordinatorPrompt: plan.coordinatorPrompt, // 写给总控的整体派活指引(可空,每阶段都注入)
});
const leaderAborter = new AbortController();
activeAbortersRef.current.add(leaderAborter);
@@ -39,6 +39,12 @@ export interface BusinessChain {
* 为 null(线性)时本字段也为 null。某位为 null / 空 = 该阶段不带目标。
*/
stageGoals?: (string | null)[] | null;
/**
* 可选「总控编排提示」:用户写给**总控(协调)智能体**的一段指引,告诉总控该怎么派活、
* 做什么(整体编排策略 / 风格 / 约束)。**仅 dag 模式生效**,注入总控每阶段派活提示。
* 空 / null = 不注入。
*/
coordinatorPrompt?: string | null;
/** 是否已发布为公共(所有账号可在「公共」筛选查看 / 选入研讨)。 */
isPublic?: boolean;
/** 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条为 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")]);
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", () => {
const groups = grp([seat("a"), seat("b")], [seat("c")]);
assert.deepEqual(stageGroupsToStagePayload(groups), [["a", "b"], ["c"]]);
@@ -81,12 +95,18 @@ test("stageGroupsToStagePayload ignores empty stages", () => {
// ── stage goals ─────────────────────────────────────────────────────────────
test("stageGroupsToStageGoals returns null when linear (no parallel)", () => {
const groups = setStageGoal(grp([seat("a")], [seat("b")]), 0, "目标");
// 全单席位 = 线性 → 目标也折叠为 null(仅 dag 模式落库)。
test("stageGroupsToStageGoals returns null when linear AND no goals", () => {
const groups = grp([seat("a")], [seat("b")]);
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", () => {
let groups = grp([seat("a"), seat("b")], [seat("c")]);
groups = setStageGoal(groups, 0, " 分析目的 ");
@@ -182,6 +202,22 @@ test("round-trip: groups → {seats, stages, stageGoals} → chainToStageGroups"
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", () => {
// stages[0] 全是 ghost → 整组(含 g0)丢弃;存活的 ["c"] 应拿到 g1,不是 g0。
const restored = chainToStageGroups({
@@ -54,27 +54,40 @@ export function stageGroupsToSeats(groups: StageGroups): ChainSeat[] {
return groups.flatMap((g) => g.seats);
}
/** 一组里是否有人填了「本阶段目标」(trim 后非空)。 */
function hasAnyGoal(groups: StageGroups): boolean {
return groups.some((g) => (g.goal?.trim() ?? "") !== "");
}
/**
* 折叠成落库的 `stages`。**全是单元素 stage(无并行)时返回 null** —— 语义上就是线性
* 链,落库为 null 以便老行为不变(Step 2 仍走 chain 模式);只要有一个 stage ≥2 席位
* 就返回显式分层(Step 2 走 dag 模式)。空 stage 已被忽略。
* 折叠成落库的 `stages`。返回 null = 线性链(Step 2 走 chain 模式,老行为不变);返回显式
* 分层 = Step 2 走 dag 模式。判定:**只要有一个 stage ≥2 席位(并行),或用户给任一 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 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));
}
/**
* 折叠成落库的 `stageGoals`,与 {@link stageGroupsToStagePayload} 做**完全相同的折叠**
* (过滤空组、无并行返回 null),保证两个数组下标一一对应。每个存活组取其 `goal`
* (trim 后为空则该位为 null)。仅 dag 模式(有并行)才落库目标——与「目标仅分层 DAG 生效」一致。
* 折叠成落库的 `stageGoals`,与 {@link stageGroupsToStagePayload} 做**完全相同的折叠判定**
* (过滤空组;无并行且无目标、且未 `forceExplicit` 时才返回 null),保证两个数组下标一一对应。
* 每个存活组取其 `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 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);
}
@@ -207,6 +207,16 @@ test("buildPlanFromStages without goals leaves stage.goal undefined (old plans)"
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 依赖摘要注入) ─────────────────────────────────
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, /本阶段目标/);
});
// ── 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", () => {
const goal = "根据上一阶段的各个目的,分析出行为体";
const hit = pickDagSeatTask([["actor", "干这个"]], "actor", "行为体", "种子", true, false, goal);
@@ -34,6 +34,12 @@ export interface OrchestrationPlan {
stages: OrchestrationStage[];
/** 收口:是否在所有 stage 完成后由总控综合(默认 true)。 */
finalSynthesis: boolean;
/**
* 可选「总控编排提示」:来自业务链条 `coordinatorPrompt`,写给总控的整体派活指引
* (怎么派活 / 做什么)。注入总控**每阶段**派活提示({@link buildDagStageLeaderTask})。
* 空 = 不注入。
*/
coordinatorPrompt?: string;
}
// ── 通用 DAG 表示:自由连线时才需要(§1.2,本期仅预留)───────────────────────
@@ -187,12 +193,15 @@ export function emptyPlan(finalSynthesis = true): OrchestrationPlan {
* @param finalSynthesis 是否收口综合,默认 true。
* @param goals 可选「每阶段目标」,与 `stages` **同源下标**对齐;存活 stage 取对应 goal
* (被丢弃的 stage 其 goal 一并丢弃,因为按源下标取)。
* @param coordinatorPrompt 可选「总控编排提示」(写给总控的整体派活指引),挂在计划上,
* 注入总控每阶段派活提示。空 = 不带。
*/
export function buildPlanFromStages(
stages: string[][],
allowedIds?: Iterable<string>,
finalSynthesis = true,
goals?: ReadonlyArray<string | null> | null,
coordinatorPrompt?: string | null,
): OrchestrationPlan {
const allow = allowedIds ? new Set(allowedIds) : null;
const cleaned: OrchestrationStage[] = [];
@@ -202,7 +211,10 @@ export function buildPlanFromStages(
const goal = goals?.[idx]?.trim() || undefined;
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 决定显示。 */
@@ -342,14 +354,20 @@ export function buildDagStageLeaderTask(args: {
isFirst: boolean;
/** 可选「本阶段目标」:用户在业务链条里为本阶段写的产出 / 依赖说明。空 = 不注入。 */
goal?: string;
/**
* 可选「总控编排提示」:用户在业务链条里写给总控的整体派活指引(怎么派活 / 做什么 /
* 风格 / 约束)。每个 stage 的派活轮都注入,作为总控的高优先编排原则。空 = 不注入。
*/
coordinatorPrompt?: 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 seatList = seats
.map((s) => `「${s.name}」(agent_name: ${s.agentId})`)
.join("、");
const isParallel = seats.length > 1;
const goalText = goal?.trim();
const coordText = coordinatorPrompt?.trim();
const lines: string[] = [];
if (isFirst) {
lines.push("这是一次【分层 DAG 编排】的多智能体圆桌研讨。任务背景如下:", seedMessage, "");
@@ -359,6 +377,13 @@ export function buildDagStageLeaderTask(args: {
"",
);
}
if (coordText) {
lines.push(
"【总控编排指引(用户预设,最高优先)】以下是本次业务链条为你(总控)预设的整体派活原则,请在每一阶段派活时都严格遵循:",
coordText,
"",
);
}
lines.push(
`本研讨严格按预设分层顺序进行,共 ${totalStages} 个阶段,现在是第 ${ordinal} 个阶段。`,
`本阶段需要执行的席位(已由编排计划钉死)是:${seatList}。`,
@@ -150,6 +150,8 @@ export default function BusinessChainEditorPage() {
const [publishing, setPublishing] = useState(false);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
// 写给总控的「编排提示」:告诉总控该怎么派活、做什么(仅分层 DAG 模式注入)。
const [coordinatorPrompt, setCoordinatorPrompt] = useState("");
// 分层编排是唯一数据源;落库时展平为 seats + 折叠为 stages。
const [stageGroups, setStageGroups] = useState<StageGroups>([]);
const [saving, setSaving] = useState(false);
@@ -246,6 +248,7 @@ export default function BusinessChainEditorPage() {
setCurrentChainMine(chain.isMine !== false);
setTitle(chain.title);
setDescription(chain.description ?? "");
setCoordinatorPrompt(chain.coordinatorPrompt ?? "");
setStageGroups(chainToStageGroups(chain));
}, []);
@@ -255,6 +258,7 @@ export default function BusinessChainEditorPage() {
setCurrentChainMine(true);
setTitle("");
setDescription("");
setCoordinatorPrompt("");
setStageGroups([]);
}, []);
@@ -425,16 +429,21 @@ export default function BusinessChainEditorPage() {
}
setSaving(true);
try {
// 展平 + 折叠:seats 用候选池最新 name/description 覆盖快照;stages 全单席位 → null。
// 展平 + 折叠:seats 用候选池最新 name/description 覆盖快照;stages 在「全单席位且无
// 目标」时折叠为 null(线性链),有并行或填了「本阶段目标」时落显式分层。
const flat = stageGroupsToSeats(stageGroups);
const payloadSeats = flat.map((s) => ({
agent_id: s.agent_id,
name: poolById.get(s.agent_id)?.name ?? s.name,
description: poolById.get(s.agent_id)?.description ?? s.description ?? "",
}));
const stages = stageGroupsToStagePayload(stageGroups);
// 每阶段目标,与 stages 同下标对齐(无并行时同样为 null)。
const stageGoals = stageGroupsToStageGoals(stageGroups);
// 总控编排提示:trim 后空串落 null(清空),与后端「空=不注入」一致。
const coordPrompt = coordinatorPrompt.trim() || null;
// 填了「总控编排提示」也强制落显式分层 —— 该提示仅 dag 模式注入,否则纯线性链里被静默忽略。
const forceExplicit = coordPrompt !== null;
const stages = stageGroupsToStagePayload(stageGroups, forceExplicit);
// 每阶段目标,与 stages 同下标对齐、同步折叠(stages 为 null 时这里也是 null)。
const stageGoals = stageGroupsToStageGoals(stageGroups, forceExplicit);
if (currentChainId) {
const updated = await updateChain(currentChainId, {
title: title.trim(),
@@ -442,6 +451,7 @@ export default function BusinessChainEditorPage() {
seats: payloadSeats,
stages,
stageGoals,
coordinatorPrompt: coordPrompt,
});
if (!updated) {
toast.error("链条已不存在,可能被删除");
@@ -459,6 +469,7 @@ export default function BusinessChainEditorPage() {
seats: payloadSeats,
stages,
stageGoals,
coordinatorPrompt: coordPrompt,
});
setCurrentChainId(created.id);
toast.success("已创建业务链条");
@@ -472,6 +483,7 @@ export default function BusinessChainEditorPage() {
}, [
title,
description,
coordinatorPrompt,
stageGroups,
seatCountInvalid,
currentChainId,
@@ -737,6 +749,29 @@ export default function BusinessChainEditorPage() {
/>
</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>
{/* 双栏拖拽编辑器 */}
@@ -1258,8 +1293,8 @@ function StageSection({
)}
/>
{!hasParallelStage && (group.goal?.trim()?.length ?? 0) > 0 && (
<p className="mt-1 text-[10px] text-amber-600 dark:text-amber-400">
提示:当前链条全为串行,目标仅在含并行 Stage 的分层(DAG)研讨中生效。
<p className="mt-1 text-[10px] text-muted-foreground">
提示:填写本阶段目标后,本链条将按阶段顺序由总控逐阶段派活(分层研讨),并把该目标注入总控。
</p>
)}
</div>
@@ -47,6 +47,8 @@ _PUBLIC_EXACT_PATHS: frozenset[str] = frozenset(
"/api/v1/auth/logout",
"/api/v1/auth/setup-status",
"/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/register",
"/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.
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),
then **merges** their LangGraph streams into ONE SSE tagged per ``agent_id``, so the
client only consumes one stream and never parses LangGraph internals.
— and this endpoint **fans out** to every agent in parallel, then **merges** their
LangGraph streams into ONE SSE tagged per ``agent_id``, so the client only consumes
one stream and never parses LangGraph internals.
It reuses the roundtable single-agent machinery from ``multi_agent``:
``_special_run(..., no_file=True)`` runs one seat under the ``seat_parallel`` run
policy (which excludes ``write_file`` / ``str_replace`` / ``bash`` / ``web_search``
/ ``agent_orchestration`` …), so parallel agents can't write files or dispatch.
Each fan-out agent runs as a pure **executing seat**: ``excluded_tools`` =
``_EXCLUDED_TOOLS`` (``write_file`` / ``str_replace`` / ``agent_orchestration`` /
``bash``) so a seat can neither write files (parallel writes would clash) nor
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)::
@@ -31,18 +33,24 @@ import json
import logging
from typing import Any, AsyncIterator
import httpx
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from app.gateway.routers.multi_agent import (
_ROUNDTABLE_THREAD_METADATA,
_auth_headers,
_create_thread,
_flatten_content,
_loopback_base,
_make_loopback_client,
_special_run,
_parse_last_messages,
_run_payload,
_stream_upstream,
_validate_agent_id,
)
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
logger = logging.getLogger(__name__)
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.
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) ─────────────────────────────
@@ -75,33 +126,136 @@ def _extract_ai_text(chunk: dict[str, Any]) -> str:
return ""
def _extract_tool_steps(chunk: dict[str, Any]) -> list[dict[str, Any]]:
"""Tool-call steps from a chunk obj — both complete ``tool_calls`` and
incremental ``tool_call_chunks`` (we only need the name to surface a step)."""
steps: list[dict[str, Any]] = []
tool_calls = chunk.get("tool_calls")
if isinstance(tool_calls, list):
for tc in tool_calls:
if not isinstance(tc, dict):
continue
name = tc.get("name")
if not isinstance(name, str) or not name:
continue
ident = tc.get("id") or name
args = tc.get("args") if isinstance(tc.get("args"), dict) else None
steps.append({"key": f"tool:{ident}", "label": f"调用工具 {name}", "args": args})
tcc = chunk.get("tool_call_chunks")
if isinstance(tcc, list):
for c in tcc:
if not isinstance(c, dict):
continue
name = c.get("name")
if not isinstance(name, str) or not name:
continue
idx = c.get("index") if isinstance(c.get("index"), int) else 0
ident = c.get("id") or f"{name}:{idx}"
steps.append({"key": f"tool:{ident}", "label": f"调用工具 {name}", "args": None})
return steps
def _ingest_tool_chunks(chunks: Any, acc: dict[int, dict[str, Any]]) -> None:
"""累积 ``tool_call_chunks`` 的增量(name + args 字符串),按 index 归并
(mirror multi-agent.ts ``ingestToolCallChunks``)。"""
if not isinstance(chunks, list):
return
for c in chunks:
if not isinstance(c, dict):
continue
idx = c.get("index") if isinstance(c.get("index"), int) else 0
entry = acc.setdefault(idx, {"id": None, "name": "", "argsStr": ""})
cid = c.get("id")
if isinstance(cid, str) and cid:
if entry["id"] and entry["id"] != cid: # 同 index 换了新调用 → 重置
entry["name"] = ""
entry["argsStr"] = ""
entry["id"] = cid
name = c.get("name")
if isinstance(name, str) and name:
entry["name"] = name
a = c.get("args")
if isinstance(a, str):
entry["argsStr"] += a
def _synthetic_tool_calls(acc: dict[int, dict[str, Any]]) -> list[dict[str, Any]]:
"""把累积的 argsStr 尽力 parse 成完整 args,产出 ``{id,name,args}``(未成完整 JSON 则跳过)。"""
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:
@@ -112,8 +266,15 @@ def _build_task_message(task_name: str, brief: str) -> str:
parts.append(f"任务简介:\n{brief.strip()}")
parts.append(
"请你从你的专业职责出发,针对以上任务给出你的分析与交付。"
"本轮为**并行**研讨:请把你的产出**直接写在回复正文里**,"
"不要写文件、不要执行脚本/命令。"
"本轮为**并行**研讨:请把你的产出**直接写在回复正文里**(本轮禁止写文件)。"
)
# 角色边界护栏:本面板里每个智能体都是**执行席位**,不是总控 / 指挥。即便任务里带有
# 「总控 / 业务链条 / 调度 / 派活」字样,也只完成属于自己的那份交付,严禁模仿总控派活。
parts.append(
"【角色边界】你是本轮的**执行席位**,不是总控 / 指挥。即使任务里出现「总控 / 业务链条 / 调度 / "
"派活」等字样,你也只需完成**属于你自己**的那份分析与交付;严禁模仿总控口吻输出「总控调度指令 / "
"调度对象 / 分配给『…』的任务 / agent_name」之类调度内容,严禁罗列或转派其他席位的任务,严禁调用"
"任何派活 / 调度类工具,也不要用脚本(bash / python)去调用内部接口或翻找派活脚本。"
)
return "\n\n".join(parts)
@@ -156,28 +317,60 @@ async def run_parallel_agents_stream(request: Request, payload: dict = Body(...)
) -> None:
await queue.put({"type": "agent_start", "agent_id": agent_id})
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] = []
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:
thread_id = await _create_thread(client, base, headers, metadata=_ROUNDTABLE_THREAD_METADATA)
gen = _special_run(
client,
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…
gen = _run_one_agent( # 只禁文件写入,其余工具放开(保留步骤条)
client, base, headers, agent_id, thread_id, new_message, model_name
)
async for chunk in gen:
if isinstance(chunk, dict):
# 终态帧 {status, content, agent_name}
content = str(chunk.get("content") or "")
await queue.put({"type": "agent_done", "agent_id": agent_id, "content": content})
# 终态:先用最终 tool_calls(全量 args)补发漏掉的 step,再发收口/澄清。
for tc in chunk.get("tool_calls") or []:
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
continue
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:
buffer.append(text)
await queue.put({"type": "delta", "agent_id": agent_id, "text": text})
for step in _extract_tool_steps(obj):
key = step["key"]
if key in seen_steps:
continue
seen_steps.add(key)
await queue.put({"type": "step", "agent_id": agent_id, **step})
# 完整 tool_calls(部分模型直接给)+ 累积 chunks 后的合成调用,都尝试发 step。
for tc in obj.get("tool_calls") or []:
await _emit_tool(tc)
_ingest_tool_chunks(obj.get("tool_call_chunks"), tool_acc)
for tc in _synthetic_tool_calls(tool_acc):
await _emit_tool(tc)
if not done_sent:
# 流被抢占/截断,没收到终态帧 —— 用已累积正文兜底收口本席位。
await queue.put(
@@ -51,6 +51,8 @@ class ChainResponse(BaseModel):
stages: list[list[str]] | None = None
# Optional per-stage goals aligned to ``stages`` (dag-only). None = no goals.
stage_goals: list[str | None] | None = None
# 写给总控的「编排提示」(dag-only):告诉总控怎么派活、做什么。None/空 = 不注入。
coordinator_prompt: str | None = None
# 发布状态:True = 公共(所有账号可在「公共」筛选查看 / 选用)。
is_public: bool = False
# 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。
@@ -72,6 +74,7 @@ class ChainCreateRequest(BaseModel):
seats: list[ChainSeatSchema] = []
stages: list[list[str]] | None = None
stage_goals: list[str | None] | None = None
coordinator_prompt: str | None = Field(default=None, max_length=4000)
class ChainUpdateRequest(BaseModel):
@@ -81,6 +84,7 @@ class ChainUpdateRequest(BaseModel):
seats: list[ChainSeatSchema] | None = None
stages: list[list[str]] | None = None
stage_goals: list[str | None] | None = None
coordinator_prompt: str | None = Field(default=None, max_length=4000)
# 发布开关(True 发布为公共 / False 取消发布);仅 owner 可改(update 本就 owner 限定)。
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],
"stages": body.stages,
"stage_goals": body.stage_goals,
"coordinator_prompt": body.coordinator_prompt,
},
)
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 =
# no goals (and always NULL when ``stages`` is NULL/linear). Stored as JSON string.
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 = 仅本人可见。
# 编辑 / 删除始终只限 owner;发布只是放开「查看 + 选入研讨」。索引以支持「公共」列表筛选。
is_public: Mapped[bool] = mapped_column(
@@ -54,6 +54,8 @@ class RoundtableChainRepository:
"stages": _loads(row.stages),
# Per-stage goals aligned to ``stages``; None when absent.
"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_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 []),
stages=_dumps(data.get("stages")),
stage_goals=_dumps(data.get("stage_goals")),
coordinator_prompt=(data.get("coordinator_prompt") or None),
is_public=bool(data.get("is_public") or False),
created_at=now,
updated_at=now,
@@ -125,6 +128,7 @@ class RoundtableChainRepository:
seats=_UNSET,
stages=_UNSET,
stage_goals=_UNSET,
coordinator_prompt=_UNSET,
is_public=_UNSET,
) -> dict[str, Any] | None:
async with self._sf() as session:
@@ -141,6 +145,8 @@ class RoundtableChainRepository:
row.stages = _dumps(stages)
if stage_goals is not _UNSET:
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:
row.is_public = bool(is_public)
row.updated_at = datetime.now(UTC)
@@ -14,6 +14,7 @@
| [05-嵌入与深色样式.md](./05-嵌入与深色样式.md) | 配置文件接口地址、抽屉/弹窗嵌入、深色自适应、不污染宿主 |
| [06-可用智能体清单.md](./06-可用智能体清单.md) | **测试用真实 agent_id 清单(可直接复制粘贴)** + 示例任务 |
| [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`);
也可用 `:base-url` prop 临时覆盖。
- **登录**:把宿主的**用户名**用 `:username` 传进来即可——组件自动走 DeerFlow 免密登录端点换 token、
缓存、401 自动重登。**宿主不用管 token**。若已有 token,可改传 `:token` / `:get-token`(优先级更高)。
- **登录**:把宿主的**用户名**用 `:username` 传进来即可——组件自动走本面板**专用**的「绕过常规登录」
公共端点 `POST /api/parallel-agents/auth/token` 换 token、缓存、401 自动重登。**宿主不用管 token**。
若已有 token,可改传 `:token` / `:get-token`(优先级更高)。
- 为什么专用端点:内网部署对标准 `/api/v1/auth/login/username` 有额外限制(口令门/网关裁剪等),拿不到
token;该端点传用户名直接签发(账号不存在自动注册),已在 auth/csrf 中间件白名单里放行。
- token 缓存按 `baseUrl::username` 维度,多个实例/多用户互不串。
---
@@ -122,14 +125,121 @@ app.use(ParallelAgentsVue); // 之后任意模板可直接用 <ParallelAgentsPan
| `model` | `string` | | 可选模型名(不传走默认 / 后端回退) |
| `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` | 保存成功(新建或更新)后 |
| `update:taskId` | `string` | 当前任务 id 变化(保存 / 加载历史时) |
| 事件 | 载荷 | 触发时机 |
| ----------------- | ------------------ | --------------------------------------- |
| `saved` | `SavedTask` | 保存成功(新建或更新)后 |
| `update:taskId` | `string` | 当前任务 id 变化(保存 / 加载历史时) |
| `completed` | `AgentResult[]` | 一轮并行分析结束时,回传各智能体返回内容 |
```vue
<ParallelAgentsPanel
@@ -138,14 +248,86 @@ app.use(ParallelAgentsVue); // 之后任意模板可直接用 <ParallelAgentsPan
:task-id="taskId"
@saved="(t) => console.log('saved', t.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 一致)
- 输入「任务名称 + 简介」→「开始分析」→ 各智能体**并行**输出,运行期间**禁止写文件**(产出走正文)。
- 输入「任务名称 + 简介」→「开始分析」→ 各智能体**并行**输出。运行期间禁用**写文件**(`write_file`/
`str_replace`)与**派活/脚本**(`agent_orchestration`/`bash`,防子智能体模仿总控调度);检索、查看、
`ask_clarification` 等照常 —— 所以**步骤条 / 协作卡片正常显示**,每个智能体只做自己那份分析。
- 主区每次只显示**一个**席位的消息列表;**点席位头像切换**要看哪个(单选)。
- 顶部席位状态条显示每个智能体的进行中 / 完成 / 出错。
- 操作区右侧有**模型切换**下拉(拉取后端 `/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,把面板塞进一个弹窗,验证嵌入与流式。 */
import { reactive, ref } from "vue";
import ParallelAgentsPanel from "./components/ParallelAgentsPanel.vue";
import type { AgentInput } from "./types";
import type { AgentInput, AgentResult } from "./types";
import { DEFAULT_BASE_URL } from "./config";
const cfg = reactive({
baseUrl: localStorage.getItem("pa.baseUrl") || DEFAULT_BASE_URL,
username: localStorage.getItem("pa.username") || "demo",
taskId: localStorage.getItem("pa.taskId") || "",
agentsRaw: localStorage.getItem("pa.agents") || "",
});
const open = ref(false);
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[] {
// 每行一个:`agent_id, 显示名` 或仅 `agent_id`。
return cfg.agentsRaw
@@ -30,6 +40,7 @@ function parseAgents(): AgentInput[] {
function launch() {
localStorage.setItem("pa.baseUrl", cfg.baseUrl);
localStorage.setItem("pa.username", cfg.username);
localStorage.setItem("pa.taskId", cfg.taskId);
localStorage.setItem("pa.agents", cfg.agentsRaw);
agents.value = parseAgents();
open.value = true;
@@ -46,8 +57,11 @@ function launch() {
<label>用户名(免密自动登录,由你自己传)
<input v-model="cfg.username" style="width: 100%; padding: 6px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px" />
</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, 显示名)
<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>
<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
:base-url="cfg.baseUrl"
:username="cfg.username || undefined"
:task-id="cfg.taskId || undefined"
:agents="agents"
@update:taskId="onTaskId"
@completed="onCompleted"
/>
</div>
</div>
+4 -6
View File
@@ -6,7 +6,7 @@
* - `{baseUrl}/api/parallel-agent-tasks` —— 任务保存/回显 CRUD。
*/
import type { AgentInput, Dialogue, SavedTask, StreamEvent } from "./types";
import { USERNAME_LOGIN_PATH } from "./config";
import { TOKEN_PATH } from "./config";
export interface ApiConfig {
baseUrl: string;
@@ -30,12 +30,10 @@ function base(cfg: ApiConfig): string {
const _tokenCache = new Map<string, string>();
const cacheKey = (cfg: ApiConfig) => `${base(cfg)}::${cfg.username ?? ""}`;
/** 用 username 走公共「用户名登录」端点换 access_token(CSRF 豁免)。 */
/** 用 username 走本面板**专用**的「绕过常规登录」公共端点换 access_token(CSRF 豁免)。 */
async function loginByUsername(cfg: ApiConfig): Promise<string> {
if (!cfg.username) throw new Error("缺少 token 且未提供 username,无法登录");
let url = `${base(cfg)}${USERNAME_LOGIN_PATH}`;
if (cfg.loginPassword) url += `?password=${encodeURIComponent(cfg.loginPassword)}`;
const res = await fetch(url, {
const res = await fetch(`${base(cfg)}${TOKEN_PATH}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: cfg.username }),
@@ -43,7 +41,7 @@ async function loginByUsername(cfg: ApiConfig): Promise<string> {
});
if (!res.ok) {
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 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 MessageStepsCard from "./MessageStepsCard.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);
</script>
@@ -45,6 +47,14 @@ const hasContent = computed(() => (props.msg.content ?? "").trim().length > 0);
<MarkdownContent v-if="hasContent" :source="msg.content" />
<span v-if="msg.streaming && hasContent" class="pa-cursor" />
</div>
<!-- 协作 / 澄清卡片(对齐圆桌第一步) -->
<ClarificationCard
v-if="msg.clarification"
:payload="msg.clarification"
:submitting="submitting"
@submit="(answer) => emit('clarify', msg.agentId, answer)"
/>
</div>
</div>
</template>
@@ -1,52 +1,95 @@
<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 { 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);
function argSummary(args?: Record<string, unknown> | null): string {
if (!args) return "";
const path = (args as { path?: unknown }).path;
if (typeof path === "string") return path;
try {
const s = JSON.stringify(args);
return s.length > 60 ? s.slice(0, 60) + "…" : s;
} catch {
return "";
}
}
const rows = computed(() =>
props.steps.map((s, i) => {
const display = getStepDisplay(s.key, s.label, s.args);
return {
step: s,
display,
args: getStepArgs(s.key, s.args),
// 最后一步在 streaming 时为「进行中」(前景色),其余为已完成(次级色)。
active: props.streaming && i === props.steps.length - 1,
isLast: i === props.steps.length - 1,
};
}),
);
</script>
<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
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"
@click="open = !open"
>
<span
class="inline-block transition-transform"
:class="open ? 'rotate-90' : ''"
>▶</span
>
<span class="inline-block transition-transform" :class="open ? 'rotate-90' : ''">▶</span>
<span>执行步骤({{ steps.length }})</span>
<span v-if="streaming" class="pa-spin inline-block">◜</span>
</button>
<ol v-show="open" class="space-y-0.5 px-2 pb-1.5 pt-0.5">
<li
v-for="s in steps"
:key="s.id"
class="flex items-start gap-2 text-[11px] pa-text"
<div v-show="open" class="space-y-2 px-3 pb-2 pt-0.5">
<div
v-for="row in rows"
: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">
<span class="font-semibold">{{ s.label }}</span>
<span v-if="argSummary(s.args)" class="pa-text-muted"> · {{ argSummary(s.args) }}</span>
<span class="pa-text-muted"> · {{ s.ts }}</span>
</span>
</li>
</ol>
<!-- 图标列 + 竖向连接线 -->
<div class="relative mt-0.5">
<StepIcon :name="row.display.icon" :size="16" />
<div
v-if="!row.isLast"
class="absolute left-1/2 -mx-px w-px"
style="top: 26px; bottom: -8px; background: var(--pa-border)"
/>
</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>
</template>
@@ -9,7 +9,7 @@
* 自适应容器:根元素 `h-full w-full flex flex-col min-h-0`,宿主直接塞进抽屉 / 弹窗即可。
*/
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 { createTask, getTask, listModels, listTasks, updateTask, type ApiConfig, type ModelItem } from "@/api";
import { DEFAULT_BASE_URL, DEFAULT_MODEL } from "@/config";
@@ -38,6 +38,8 @@ const props = withDefaults(
const emit = defineEmits<{
(e: "saved", task: SavedTask): void;
(e: "update:taskId", id: string): void;
/** 一轮并行分析结束时回传各智能体的返回内容。 */
(e: "completed", results: AgentResult[]): void;
}>();
const cfg = computed<ApiConfig>(() => ({
@@ -90,6 +92,15 @@ watch(
{ immediate: true },
);
// 宿主传入 / 改变 taskId:作为存储主键,并尝试加载回显该任务(不存在则只设 id)。
watch(
() => props.taskId,
(id) => {
currentTaskId.value = id ?? null;
if (id) void loadTask(id);
},
);
/** 点头像切换显示:单选,始终切到点击的那个席位。 */
function toggleSeat(agentId: string) {
selected.value = [agentId];
@@ -101,6 +112,43 @@ const visibleSeats = computed(() => {
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() {
if (!canStart.value) return;
errorMsg.value = null;
@@ -111,6 +159,8 @@ async function onStart() {
agents: props.agents,
model: selectedModel.value || undefined,
});
// 一轮结束:回传各智能体返回内容(宿主也可用 ref 调 getResults())。
emit("completed", getResults());
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
}
@@ -123,21 +173,15 @@ async function onSave() {
try {
const title = form.taskName.trim() || "未命名任务";
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;
if (currentTaskId.value) {
saved = await updateTask(cfg.value, currentTaskId.value, {
title,
brief: form.brief,
agents: agentsSnapshot,
messages: dialogues.value,
});
const id = currentTaskId.value;
if (id) {
saved = await updateTask(cfg.value, id, payload);
if (!saved) saved = await createTask(cfg.value, { id, ...payload });
} else {
saved = await createTask(cfg.value, {
title,
brief: form.brief,
agents: agentsSnapshot,
messages: dialogues.value,
});
saved = await createTask(cfg.value, payload);
}
if (saved) {
currentTaskId.value = saved.id;
@@ -311,6 +355,8 @@ onMounted(() => {
<MessageBubble
v-if="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">
{{ 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];
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": {
flushPending();
const d = findDialogue(ev.agent_id);
@@ -178,6 +198,48 @@ export function useParallelRun() {
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[]) {
cancel();
@@ -208,5 +270,5 @@ export function useParallelRun() {
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 =
(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";
export { ParallelAgentsPanel };
export type { AgentInput, Dialogue, SavedTask, SeatView, StreamEvent } from "./types";
export type { AgentInput, AgentResult, Dialogue, SavedTask, SeatView, StreamEvent } from "./types";
export default {
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` 的统一事件协议。
*/
/** 一条工具调用步骤(喂步骤条 / 思维链卡)。 */
/** 一条工具调用步骤(喂步骤条 / 思维链卡)。`key` 形如 `tool_calling:<toolName>`。 */
export interface DialogueStep {
id: string;
key: string;
@@ -14,6 +14,19 @@ export interface DialogueStep {
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)。 */
export interface Dialogue {
id: number;
@@ -27,6 +40,8 @@ export interface Dialogue {
confidence: string;
streaming?: boolean;
steps?: DialogueStep[];
/** 该智能体在要澄清时的协作卡片载荷(有则渲染卡片)。 */
clarification?: ClarificationPayload;
}
/** 并行席位的视图状态(席位状态条用)。 */
@@ -51,10 +66,30 @@ export type StreamEvent =
| { type: "agent_start"; agent_id: string }
| { type: "delta"; agent_id: string; text: string }
| { 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_error"; agent_id: string; message: string }
| { 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 对应)。 */
export interface SavedTask {
id: string;