Compare commits

...
15 Commits
Author SHA1 Message Date
hkdong 7422f083b4 tjl 2026-06-10 17:19:27 +08:00
董宏凯 5080f1dc39 Merge branch 'dhk-1' into 'master'
Dhk 1

See merge request knowledge-base-bj/deerflow-server!104
2026-06-09 09:19:14 +00:00
fengxiangdebaozi cba165933c 提交代码 2026-06-09 17:18:40 +08:00
fengxiangdebaozi dddd3c1670 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-09 14:06:18 +08:00
fengxiangdebaozi 10795d55e6 Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-09 14:06:01 +08:00
fengxiangdebaozi 230fab013a 代码合并 2026-06-09 14:05:51 +08:00
董宏凯 2082d20b88 Merge branch 'dhk-1' into 'master'
feat(roundtable): 业务链条流程图改弹窗 + 每阶段「本阶段目标」注入总控

See merge request knowledge-base-bj/deerflow-server!103
2026-06-09 05:43:00 +00:00
董宏凯andClaude Opus 4.8 c230f7a6c6 feat(parallel-agents): 子智能体禁派活/脚本(防模仿总控) + 面板能力完善
后端 parallel_agents.py:每个并行席位 excluded_tools 增加 agent_orchestration
(派活) 与 bash(脚本逃逸),并在任务消息里加「角色边界」护栏——根治弱模型拿到
带「总控/业务链条」语气的任务后模仿总控输出「总控调度指令…」、绕道 glob/bash
翻找派活脚本的问题。新增 /api/parallel-agents/auth/token 取 token 端点
(auth/csrf 豁免)。

parallel-agents-vue 面板:协作澄清卡片、步骤图标/步骤展示、API 与类型完善等。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:41:39 +08:00
董宏凯andClaude Opus 4.8 3405795371 fix(roundtable): 业务链条「本阶段目标」串行链可保存/回显 + 新增「总控提示词」
本阶段目标 bug:每步只有一个智能体(无并行)时,stageGroupsToStagePayload/
stageGroupsToStageGoals 一律折叠为 null(旧设计=线性链),导致目标保存即丢、
无从回显。改为「有并行 或 任一 stage 填了目标」就落显式分层,串行带目标链按
分层 DAG 跑(总控逐阶段按目标派活);BusinessChainPicker 同步:有并行/有目标/
有总控提示都走 dag。

新增「总控提示词」(per-chain,仅 dag 模式生效):写给总控的整体派活指引。
后端 roundtable_chains 加 coordinator_prompt LONGTEXT 列 + migration 20260608_06;
前端编辑器加输入框,注入 buildDagStageLeaderTask 每阶段派活提示。填了总控提示
也强制显式分层(forceExplicit),否则纯线性链里被静默忽略。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:41:20 +08:00
董宏凯 1938c80061 代码提交 2026-06-08 22:52:18 +08:00
董宏凯andClaude Opus 4.8 b101ee7fc9 feat(roundtable): 业务链条左栏卡片拖动手柄 + 加入按钮 + 智能体详情抽屉
- 左栏候选卡片加拖动手柄(与右栏席位一致),仅点手柄才拖动
- 加入改为显式「加入」按钮,点卡片本体改为打开详情抽屉
- 右栏席位操作区加「查看详情」按钮,打开同一抽屉
- 新增 AgentDetailSheet:按需拉取(useAgent)展示名称/模型/描述/
  标签/技能+工具组/人设,对齐智能体详情页内容

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:00:42 +08:00
董宏凯 c05e7df48e Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1 2026-06-08 16:31:00 +08:00
董宏凯andClaude Opus 4.8 834df64650 feat(roundtable): 业务链条流程图改弹窗 + 每阶段「本阶段目标」注入总控
- 业务链条配置页流程图从底部内嵌改为按钮 + Dialog 弹窗查看
- 每个 Stage 新增可选「本阶段目标」文本框,告诉总控本阶段要产出
  什么、如何依赖上一阶段产出(仅分层 DAG 生效)
- StageGroups 重构为 StageGroup[]({seats, goal?}),goal 随 stage
  在增删改/重排中自动跟随;新增 stageGoals 持久化(与 stages 同下标对齐)
- 运行时注入:buildDagStageLeaderTask(总控派活,主)+ pickDagSeatTask
  (席位任务,兜底)
- 后端 roundtable_chains 加 stage_goals 列 + 校验 + Alembic 20260608_04
- 老链条/草稿无该字段时不注入,行为不变

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:10:46 +08:00
g zy 004853f030 Merge branch 'gzy_iframedf' into 'master'
feat: 登录token回传至宿主

See merge request knowledge-base-bj/deerflow-server!102
2026-06-08 04:35:35 +00:00
ZyGuo 37be59b0dd feat: 登录token回传至宿主 2026-06-08 12:33:24 +08:00
91 changed files with 19615 additions and 540 deletions

No files matched your search

+2 -1
View File
@@ -4,7 +4,8 @@
"PowerShell(uv --version)",
"PowerShell(netstat -ano)",
"PowerShell(Get-NetTCPConnection -LocalPort 8001 -ErrorAction SilentlyContinue)",
"PowerShell(Get-Process -Id 20560 -ErrorAction SilentlyContinue | Select-Object Id,ProcessName,Path,StartTime; try { \\(Invoke-WebRequest -Uri \"http://127.0.0.1:8001/health\" -UseBasicParsing -TimeoutSec 5\\).Content } catch { \"health check failed: $_\" })"
"PowerShell(Get-Process -Id 20560 -ErrorAction SilentlyContinue | Select-Object Id,ProcessName,Path,StartTime; try { \\(Invoke-WebRequest -Uri \"http://127.0.0.1:8001/health\" -UseBasicParsing -TimeoutSec 5\\).Content } catch { \"health check failed: $_\" })",
"Bash(curl -s -X POST -H \"Content-Type: application/json\" -d '{\"username\":\"demo\"}' http://localhost:8001/api/v1/auth/login/username)"
]
}
}
+10540
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -119,7 +119,8 @@
border: 1px solid;
border-color: rgba(43, 143, 254, 0.6);
border-radius: 4px;
backdrop-filter: blur(2px);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
position: absolute;
left: 108px;
top: 0;
@@ -207,6 +207,12 @@ export const sidebarMenuItems: MenuItem[] = [
icon: Settings,
children: [
{ id: "mgmt-agents", label: "智能体管理", icon: Bot, path: "/page/strategy/agents" },
{
id: "mgmt-business-chain-mapping",
label: "业务链条管理",
icon: Network,
path: "/page/strategy/business-chain-mapping",
},
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
{
@@ -0,0 +1,273 @@
/**
* 业务链条管理页 —— 把固定业务码(3Q/6BF/7BF/8BF)绑定到「智能体」+「业务链」。
*
* 表格三列:业务(只读)/ 智能体 / 业务链。编辑模式下后两列为可搜索下拉:
* - 智能体来自 `/api/agents`
* - 业务链来自 `/api/roundtable-chains?scope=public`(只列公共链,保证全局共享时各账号都能解析)
* 保存调 `PUT /api/business-mapping/{code}`。映射全局共享、无用户隔离。
*/
import { useEffect, useMemo, useState } from "react";
import { Loading, Select } from "tdesign-react";
import { Link2, Pencil, Save, Users, X } from "lucide-react";
import { toast } from "sonner";
import { useTheme } from "@/contexts/ThemeContext";
import { listAgents } from "@/core/agents/api";
import type { Agent } from "@/core/agents/types";
import { listChains } from "@/roundtable-planning/api/chains";
import type { BusinessChain } from "@/roundtable-planning/lib/business-chain";
import {
listBusinessMappings,
updateBusinessMapping,
type BusinessMapping,
} from "@/strategy-components/api/business-mapping";
interface DraftSelection {
agentId: string | null;
chainId: string | null;
}
export default function BusinessChainMappingPage() {
const { theme } = useTheme();
const isDark = theme === "dark";
const [rows, setRows] = useState<BusinessMapping[]>([]);
const [agents, setAgents] = useState<Agent[]>([]);
const [chains, setChains] = useState<BusinessChain[]>([]);
const [loading, setLoading] = useState(true);
const [editingCode, setEditingCode] = useState<string | null>(null);
const [draft, setDraft] = useState<DraftSelection>({ agentId: null, chainId: null });
const [saving, setSaving] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
try {
const [mappingRes, agentRes, chainRes] = await Promise.allSettled([
listBusinessMappings(),
listAgents(),
listChains("public"),
]);
if (cancelled) return;
if (mappingRes.status === "fulfilled") setRows(mappingRes.value);
else toast.error(`加载业务映射失败:${mappingRes.reason}`);
if (agentRes.status === "fulfilled") setAgents(agentRes.value);
if (chainRes.status === "fulfilled") setChains(chainRes.value);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
const agentOptions = useMemo(
() => agents.map((a) => ({ label: a.name || a.id, value: a.id })),
[agents],
);
const chainOptions = useMemo(
() => chains.map((c) => ({ label: c.title || c.id, value: c.id })),
[chains],
);
const beginEdit = (row: BusinessMapping) => {
setEditingCode(row.businessCode);
setDraft({ agentId: row.agentId, chainId: row.chainId });
};
const cancelEdit = () => {
setEditingCode(null);
setDraft({ agentId: null, chainId: null });
};
const handleSave = async (row: BusinessMapping) => {
setSaving(true);
try {
const agentName = draft.agentId
? agents.find((a) => a.id === draft.agentId)?.name ?? row.agentName ?? null
: null;
const chainTitle = draft.chainId
? chains.find((c) => c.id === draft.chainId)?.title ?? row.chainTitle ?? null
: null;
const updated = await updateBusinessMapping(row.businessCode, {
agentId: draft.agentId,
agentName,
chainId: draft.chainId,
chainTitle,
});
if (!updated) {
toast.error(`业务「${row.label}」不存在,保存失败`);
return;
}
setRows((prev) => prev.map((r) => (r.businessCode === row.businessCode ? updated : r)));
toast.success(`已保存「${row.label}」的配置`);
cancelEdit();
} catch (err) {
toast.error(`保存失败:${(err as Error).message}`);
} finally {
setSaving(false);
}
};
const shellClass = isDark
? "h-full overflow-y-auto bg-[linear-gradient(180deg,#081322_0%,#0d1d33_42%,#0a1628_100%)] text-slate-100"
: "h-full overflow-y-auto bg-[linear-gradient(180deg,#fffdfd_0%,#fff7f7_42%,#fafafa_100%)] text-[#222222]";
const cardClass = isDark
? "border border-[rgba(67,89,124,0.46)] bg-[rgba(12,29,54,0.94)] shadow-[0_16px_36px_rgba(3,10,22,0.2)]"
: "border border-[#E8E8E8] bg-white shadow-[0_14px_30px_rgba(245,34,45,0.06)]";
const headRowClass = isDark ? "text-slate-400" : "text-[#8C8C8C]";
const rowBorder = isDark ? "border-[rgba(67,89,124,0.32)]" : "border-[#F0F0F0]";
const mutedText = isDark ? "text-slate-500" : "text-[#A6A6A6]";
const codeBadge = isDark
? "bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]"
: "bg-[#FFF1F0] text-[#CF1322]";
return (
<div className={shellClass}>
<div className="mx-auto flex min-h-full max-w-[1280px] flex-col gap-5 px-6 py-6 lg:px-8">
<div className="space-y-1">
<h1 className={`text-[26px] font-semibold tracking-[0.02em] ${isDark ? "text-slate-50" : "text-[#222222]"}`}>
业务链条管理
</h1>
<p className={`text-sm ${isDark ? "text-slate-300/80" : "text-slate-500"}`}>
为每个业务绑定智能体与业务链。多智能体会商从对应业务自动取用此处配置的业务链。业务链仅可选择「公共」链条。
</p>
</div>
<Loading loading={loading} showOverlay={false}>
<div className={`overflow-hidden rounded-3xl ${cardClass}`}>
<table className="w-full border-collapse text-sm">
<thead>
<tr className={`text-left text-xs uppercase tracking-widest ${headRowClass}`}>
<th className="px-6 py-4 font-semibold">业务</th>
<th className="px-6 py-4 font-semibold">智能体</th>
<th className="px-6 py-4 font-semibold">业务链</th>
<th className="px-6 py-4 text-right font-semibold">操作</th>
</tr>
</thead>
<tbody>
{rows.length === 0 && !loading ? (
<tr>
<td colSpan={4} className={`px-6 py-12 text-center ${mutedText}`}>
暂无业务数据
</td>
</tr>
) : (
rows.map((row) => {
const editing = editingCode === row.businessCode;
return (
<tr key={row.businessCode} className={`border-t ${rowBorder}`}>
{/* 业务(只读) */}
<td className="px-6 py-4 align-middle">
<div className="flex items-center gap-2">
<span className={`text-[15px] font-semibold ${isDark ? "text-white" : "text-[#222222]"}`}>
{row.label}
</span>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-mono ${codeBadge}`}>
{row.businessCode}
</span>
</div>
</td>
{/* 智能体 */}
<td className="px-6 py-4 align-middle">
{editing ? (
<Select
className="min-w-[200px]"
value={draft.agentId ?? undefined}
options={agentOptions}
filterable
clearable
placeholder="搜索并选择智能体"
onChange={(v) => setDraft((d) => ({ ...d, agentId: (v as string) ?? null }))}
/>
) : (
<span className="inline-flex items-center gap-1.5">
<Users className={`h-3.5 w-3.5 ${mutedText}`} />
{row.agentName || row.agentId ? (
<span>{row.agentName || row.agentId}</span>
) : (
<span className={mutedText}>未配置</span>
)}
</span>
)}
</td>
{/* 业务链 */}
<td className="px-6 py-4 align-middle">
{editing ? (
<Select
className="min-w-[220px]"
value={draft.chainId ?? undefined}
options={chainOptions}
filterable
clearable
placeholder="搜索并选择业务链(公共)"
onChange={(v) => setDraft((d) => ({ ...d, chainId: (v as string) ?? null }))}
/>
) : (
<span className="inline-flex items-center gap-1.5">
<Link2 className={`h-3.5 w-3.5 ${mutedText}`} />
{row.chainTitle || row.chainId ? (
<span>{row.chainTitle || row.chainId}</span>
) : (
<span className={mutedText}>未配置</span>
)}
</span>
)}
</td>
{/* 操作 */}
<td className="px-6 py-4 text-right align-middle">
{editing ? (
<div className="inline-flex items-center gap-2">
<button
type="button"
disabled={saving}
onClick={() => handleSave(row)}
className={`inline-flex items-center gap-1 rounded-xl px-3 py-1.5 text-xs font-medium disabled:opacity-50 ${
isDark ? "bg-[rgba(34,197,94,0.18)] text-[#86EFAC]" : "bg-[#EEFCF7] text-[#0F9D6C]"
}`}
>
<Save className="h-3.5 w-3.5" />
保存
</button>
<button
type="button"
disabled={saving}
onClick={cancelEdit}
className={`inline-flex items-center gap-1 rounded-xl px-3 py-1.5 text-xs font-medium disabled:opacity-50 ${
isDark ? "bg-[rgba(110,136,176,0.18)] text-slate-300" : "bg-[#F5F5F5] text-[#575757]"
}`}
>
<X className="h-3.5 w-3.5" />
取消
</button>
</div>
) : (
<button
type="button"
onClick={() => beginEdit(row)}
className={`inline-flex items-center gap-1 rounded-xl px-3 py-1.5 text-xs font-medium ${
isDark ? "bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]" : "bg-[#FFF1F0] text-[#CF1322]"
}`}
>
<Pencil className="h-3.5 w-3.5" />
编辑
</button>
)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</Loading>
</div>
</div>
);
}
+42
View File
@@ -17,6 +17,7 @@ import {
} from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { TodoList } from "@/components/workspace/todo-list";
import { getAccessToken } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks";
@@ -33,6 +34,13 @@ import {
} from "./iframe-chat-submit";
type BrainyChatStatus = "started" | "finished";
type BrainyAuthTokenMessage = {
source: "brainy-iframe";
type: "brainy:auth-token";
payload: {
accessToken: string;
};
};
type BrainyChatInterruptMessage = {
source?: string;
type?: string;
@@ -57,6 +65,18 @@ function postBrainyChatStatus(status: BrainyChatStatus, requestId: string) {
);
}
function postBrainyAuthToken(accessToken: string) {
const message: BrainyAuthTokenMessage = {
source: "brainy-iframe",
type: "brainy:auth-token",
payload: {
accessToken,
},
};
window.parent?.postMessage(message, "*");
}
export default function IframeChatPage() {
const routeBase = useRouteBase();
const router = useRouter();
@@ -79,6 +99,28 @@ export default function IframeChatPage() {
setMounted(true);
}, []);
useEffect(() => {
let lastToken: string | undefined;
const emitAccessToken = () => {
const accessToken = getAccessToken();
if (!accessToken || accessToken === lastToken) {
return;
}
lastToken = accessToken;
postBrainyAuthToken(accessToken);
};
emitAccessToken();
window.addEventListener("focus", emitAccessToken);
window.addEventListener("storage", emitAccessToken);
return () => {
window.removeEventListener("focus", emitAccessToken);
window.removeEventListener("storage", emitAccessToken);
};
}, []);
useEffect(() => {
if (isNewThread) {
setHasStartedConversation(false);
@@ -20,6 +20,7 @@ import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanni
import "@/roundtable-planning/styles/roundtable-planning.css";
import AgentChatPage from "./AgentChatPage";
import AgentsPage from "./AgentsPage";
import BusinessChainMappingPage from "./BusinessChainMappingPage";
import ChatPage from "./ChatPage";
import ChatsPage from "./ChatsPage";
import AdminUserLeaderboardPage from "./AdminUserLeaderboardPage";
@@ -172,6 +173,7 @@ export default function StrategyRoutes() {
<Route path="skills" element={<SkillsPage />} />
<Route path="skills/evolution" element={<SkillEvolutionPage />} />
<Route path="memory" element={<MemoryPage />} />
<Route path="business-chain-mapping" element={<BusinessChainMappingPage />} />
<Route
path="scheduled"
@@ -24,6 +24,10 @@ export interface ChainCreatePayload {
seats: ChainSeat[];
/** Optional layered orchestration (agent_id groups). null/absent = linear. */
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. */
@@ -33,6 +37,10 @@ export interface ChainUpdatePayload {
seats?: ChainSeat[];
/** Send `null` to clear layering back to linear; omit to leave unchanged. */
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;
}
@@ -60,6 +68,8 @@ interface ServerChain {
description: string | null;
seats: ChainSeat[];
stages?: string[][] | null;
stage_goals?: (string | null)[] | null;
coordinator_prompt?: string | null;
is_public?: boolean;
is_mine?: boolean;
created_at: string | null;
@@ -73,6 +83,8 @@ function toChain(s: ServerChain): BusinessChain {
description: s.description ?? undefined,
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,
@@ -88,6 +100,8 @@ function fromCreate(p: ChainCreatePayload) {
...(p.description !== undefined ? { description: p.description } : {}),
seats: p.seats,
...(p.stages !== undefined ? { stages: p.stages } : {}),
...(p.stageGoals !== undefined ? { stage_goals: p.stageGoals } : {}),
...(p.coordinatorPrompt !== undefined ? { coordinator_prompt: p.coordinatorPrompt } : {}),
};
}
@@ -98,6 +112,9 @@ function fromUpdate(p: ChainUpdatePayload) {
if (p.seats !== undefined) out.seats = p.seats;
// Forward stages even when explicitly null (clears layering server-side).
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;
}
@@ -0,0 +1,151 @@
/**
* 智能体详情抽屉(右侧 Sheet)。
*
* 业务链条配置页:左栏候选卡片点击卡片本体、右栏席位点「查看详情」都打开这个抽屉,
* 展示该智能体的详情(与「智能体」详情页展示的内容差不多):名称 / 模型 / 描述 /
* 标签 / 技能 + 工具组 / 人设(SOUL.md)。
*
* 详情按需拉取:`useAgent(open ? agentId : null)` —— 抽屉打开时才请求 `GET /api/agents/{id}`
* (该接口才返回 soul),关闭不请求。字段渲染对齐 `components/workspace/agents/agent-card.tsx`。
*/
import { Bot, Globe, Loader2, Lock } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { useAgent } from "@/core/agents";
import { desensitize } from "@/lib/desensitize";
export function AgentDetailSheet({
agentId,
/** 卡片/席位上的名称快照,详情还没拉回来时先用它占位。 */
fallbackName,
open,
onOpenChange,
}: {
agentId: string | null;
fallbackName?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
// 抽屉打开时才拉详情(含 soul);关闭传 null 让 useAgent 不发请求。
const { agent, isLoading } = useAgent(open ? agentId : null);
const name = agent?.name || fallbackName || agentId || "";
const skillItems = agent?.skills ?? [];
const toolGroups = agent?.tool_groups ?? [];
const hasSkillsOrTools = skillItems.length > 0 || toolGroups.length > 0;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full gap-0 p-0 sm:max-w-md">
<SheetHeader className="border-b border-border px-5 py-4">
<SheetTitle className="flex items-center gap-2.5">
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
<Bot className="size-4" />
</span>
<span className="min-w-0 flex-1 truncate text-base font-bold">{name}</span>
{agent?.model && (
<Badge variant="outline" className="shrink-0 text-[10px]">
{desensitize(agent.model)}
</Badge>
)}
</SheetTitle>
<SheetDescription className="sr-only">智能体详情</SheetDescription>
</SheetHeader>
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-5 py-4 text-sm">
{isLoading && !agent ? (
<div className="flex items-center gap-2 py-8 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
正在加载详情…
</div>
) : !agent ? (
<div className="py-8 text-center text-muted-foreground">
未找到该智能体的详情(可能已被删除)。
</div>
) : (
<>
{/* 状态徽标 */}
<div className="flex flex-wrap items-center gap-1.5">
{agent.builtin && (
<Badge variant="secondary" className="text-[10px]">内置</Badge>
)}
{agent.published && (
<Badge variant="outline" className="gap-1 text-[10px]">
<Globe className="size-3" />
已发布
</Badge>
)}
{agent.is_private && (
<Badge variant="outline" className="gap-1 text-[10px]">
<Lock className="size-3" />
私密
</Badge>
)}
</div>
{/* 描述 */}
<section className="space-y-1.5">
<h4 className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
描述
</h4>
<p className="whitespace-pre-wrap leading-relaxed text-foreground">
{agent.description?.trim() || "(无描述)"}
</p>
</section>
{/* 标签 */}
{agent.tags && agent.tags.length > 0 && (
<section className="space-y-1.5">
<h4 className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
标签
</h4>
<TagChipList tags={agent.tags} />
</section>
)}
{/* 技能 / 工具组 */}
{hasSkillsOrTools && (
<section className="space-y-1.5">
<h4 className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
技能与工具
</h4>
<div className="flex flex-wrap gap-1.5">
{toolGroups.map((group) => (
<Badge key={`tg:${group}`} variant="outline" className="text-xs">
{desensitize(group)}
</Badge>
))}
{skillItems.map((skill) => (
<Badge key={`sk:${skill}`} variant="secondary" className="text-xs">
{desensitize(skill)}
</Badge>
))}
</div>
</section>
)}
{/* 人设(SOUL.md) */}
{agent.soul?.trim() && (
<section className="space-y-1.5">
<h4 className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
人设
</h4>
<pre className="whitespace-pre-wrap break-words rounded-md border border-border bg-muted/30 p-3 text-[12px] leading-relaxed text-foreground">
{agent.soul}
</pre>
</section>
)}
</>
)}
</div>
</SheetContent>
</Sheet>
);
}
@@ -108,13 +108,24 @@ 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;
const surviveIds = survivingSeats.map((s) => s.agent_id);
const plan = buildPlanFromStages(selectedChain.stages, surviveIds);
return plan.stages.some((st) => st.agentIds.length > 1) ? plan : null;
const plan = buildPlanFromStages(
selectedChain.stages,
surviveIds,
true,
selectedChain.stageGoals,
selectedChain.coordinatorPrompt,
);
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");
@@ -211,6 +211,13 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
[artifactsBySeat],
);
// 只保留「有产出文件」的席位;加载中 / 出错的席位仍保留以展示其状态。
// 全部席位都没有文件(且都已加载完成、无错误)时,整块「生成的报告文件」隐藏。
const seatsWithFiles = useMemo(
() => artifactsBySeat.filter((s) => s.loading || s.error || s.files.length > 0),
[artifactsBySeat],
);
const toggleSeat = useCallback((key: string) => {
setExpandedSeats((prev) => {
const next = new Set(prev);
@@ -424,21 +431,20 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
)}
</section>
{/* Section 3: Generated files per seat —— 点击「查看」进入沙箱分栏预览 */}
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
<Download className="w-4 h-4 text-primary" />
生成的报告文件
<span className="text-[10px] font-mono font-semibold text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">
{totalFileCount}
</span>
</h4>
{/* Section 3: Generated files per seat —— 点击「查看」进入沙箱分栏预览。
仅当存在「有产出文件 / 加载中 / 出错」的席位时才渲染;全部席位无文件则整块隐藏。 */}
{seatsWithFiles.length > 0 && (
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
<Download className="w-4 h-4 text-primary" />
生成的报告文件
<span className="text-[10px] font-mono font-semibold text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">
{totalFileCount}
</span>
</h4>
{artifactsBySeat.length === 0 ? (
<p className="text-[11px] text-slate-400 italic">尚未初始化席位线程,无法列出文件。</p>
) : (
<div className="space-y-3">
{artifactsBySeat.map((seat) => (
{seatsWithFiles.map((seat) => (
<div key={seat.agentName} className="border border-slate-150 rounded-xl">
<div className="flex items-center justify-between gap-2 px-3 py-2 bg-slate-50 border-b border-slate-150 rounded-t-xl">
<span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.displayName}</span>
@@ -501,8 +507,8 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
</div>
))}
</div>
)}
</section>
</section>
)}
{/* Footer */}
<div className="pt-2 border-t border-slate-100 flex items-center justify-between flex-wrap gap-4">
@@ -1870,6 +1870,8 @@ export function useStep2Orchestration(
totalStages: plan.stages.length,
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);
@@ -1984,6 +1986,7 @@ export function useStep2Orchestration(
seedMessage,
priorDeliveries.length > 0,
dagExecModeRef.current === "sandbox", // 仅沙箱模式要求写 .md 文件;快速模式产物走正文
stage.goal, // 兜底注入「本阶段目标」(总控没派好时席位仍据此对齐)
);
const subAborter = new AbortController();
@@ -5,9 +5,12 @@
* (`/api/roundtable-chains`,按登录用户隔离)。在 Step 2 圆桌研讨里,总控会**按
* 链条顺序逐个派活**(见 `docs/multi-agent-business-chain-dev.md` §6)。
*
* 这里只放纯类型 / 常量,不含 React 状态或请求逻辑(CRUD 在 `api/chains.ts`)。
* 这里放纯类型 / 常量,以及不依赖 React 的纯函数(如 `buildChainSeatSelection`);
* 不含状态或请求逻辑(CRUD 在 `api/chains.ts`)。
*/
import { buildPlanFromStages } from "./dag-types";
/** 链条里的一个有序席位。`seats[0]` 是第一个发言者。 */
export interface ChainSeat {
agent_id: string;
@@ -33,6 +36,18 @@ export interface BusinessChain {
* (每席位独立一层)。有分层时,选入 Step 2 走 `dag` 编排(见 `lib/dag-types.ts`)。
*/
stages?: string[][] | null;
/**
* 可选「每阶段目标」:与 `stages` **同下标对齐**(`stageGoals[i]` 是 `stages[i]` 的
* 目标),告诉总控本阶段要产出什么、如何依赖上一阶段产出。仅 dag 模式注入;`stages`
* 为 null(线性)时本字段也为 null。某位为 null / 空 = 该阶段不带目标。
*/
stageGoals?: (string | null)[] | null;
/**
* 可选「总控编排提示」:用户写给**总控(协调)智能体**的一段指引,告诉总控该怎么派活、
* 做什么(整体编排策略 / 风格 / 约束)。**仅 dag 模式生效**,注入总控每阶段派活提示。
* 空 / null = 不注入。
*/
coordinatorPrompt?: string | null;
/** 是否已发布为公共(所有账号可在「公共」筛选查看 / 选入研讨)。 */
isPublic?: boolean;
/** 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条为 false)。 */
@@ -74,6 +89,55 @@ export interface SeatSelectionResult {
export const CHAIN_MIN_SEATS = 2;
export const CHAIN_MAX_SEATS = 8;
/**
* 由一条业务链条 + 当前候选智能体,构造 `SeatSelectionResult`。
*
* 与 `BusinessChainPicker.handleUse` 同源逻辑,抽成纯函数供「自动选链」复用
* (会商自动化串联:从任务页带 chainId 进圆桌时跳过选链弹窗,直接据此进 Step 2):
* - 仅保留候选池里仍存在的存活席位;存活不足 `CHAIN_MIN_SEATS` 返回 null(调用方回退弹窗);
* - 链条带分层、过滤存活后仍含并行 stage / 任一阶段目标 / 总控提示 → 走 `dag`(默认 fast);
* - 否则走线性 `chain`,席位顺序 = 链条顺序。
*
* `candidates` 为 `{ agent_id, name }` 列表(一般来自 `filterRecommendCandidates(listAgents())`)。
*/
export function buildChainSeatSelection(
chain: BusinessChain,
candidates: ReadonlyArray<{ agent_id: string; name: string }>,
): SeatSelectionResult | null {
const existingIds = new Set(candidates.map((c) => c.agent_id));
const nameById = new Map(candidates.map((c) => [c.agent_id, c.name]));
const seatNameById = new Map(chain.seats.map((s) => [s.agent_id, s.name]));
const nameOf = (id: string) => nameById.get(id) ?? seatNameById.get(id) ?? id;
const survivingSeats = chain.seats.filter((s) => existingIds.has(s.agent_id));
if (survivingSeats.length < CHAIN_MIN_SEATS) return null;
const chainMeta = { id: chain.id, title: chain.title };
if (chain.stages && chain.stages.length > 0) {
const surviveIds = survivingSeats.map((s) => s.agent_id);
const plan = buildPlanFromStages(
chain.stages,
surviveIds,
true,
chain.stageGoals,
chain.coordinatorPrompt,
);
const hasParallel = plan.stages.some((st) => st.agentIds.length > 1);
const hasGoal = plan.stages.some((st) => (st.goal?.trim() ?? "") !== "");
const hasCoord = (plan.coordinatorPrompt?.trim() ?? "") !== "";
if (hasParallel || hasGoal || hasCoord) {
const agents = plan.stages
.flatMap((st) => st.agentIds)
.map((id) => ({ agent_id: id, name: nameOf(id) }));
return { agents, mode: "dag", chain: chainMeta, plan, dagExecMode: "fast" };
}
}
const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id) }));
return { agents, mode: "chain", chain: chainMeta };
}
/**
* 把 `getAvatarTypeFor()` 产出的头像配色(blue/purple/emerald/amber/pink/human)
* 映射成 tag chip 的 tailwind 类(背景 + 文字 + 边框,含深色变体)。
@@ -12,14 +12,19 @@ import {
moveSeatToStage,
moveStage,
removeSeat,
setStageGoal,
splitSeatToNewStage,
stageGroupsToSeats,
stageGroupsToStageGoals,
stageGroupsToStagePayload,
type StageGroups,
} from "./chain-stages";
const seat = (id: string) => ({ agent_id: id, name: id.toUpperCase(), description: "" });
const ids = (groups: StageGroups) => groups.map((g) => g.map((s) => s.agent_id));
/** 把席位数组的数组包成 StageGroup[](测试构造便捷)。 */
const grp = (...stages: ReturnType<typeof seat>[][]): StageGroups =>
stages.map((seats) => ({ seats }));
const ids = (groups: StageGroups) => groups.map((g) => g.seats.map((s) => s.agent_id));
// ── chainToStageGroups ────────────────────────────────────────────────────────
@@ -52,32 +57,75 @@ test("chainToStageGroups: stages that resolve to nothing → linear fallback", (
// ── serialization ─────────────────────────────────────────────────────────────
test("stageGroupsToSeats flattens in order", () => {
const groups: StageGroups = [[seat("a"), seat("b")], [seat("c")]];
const groups = grp([seat("a"), seat("b")], [seat("c")]);
assert.deepEqual(
stageGroupsToSeats(groups).map((s) => s.agent_id),
["a", "b", "c"],
);
});
test("stageGroupsToStagePayload returns null when all singletons (linear)", () => {
const groups: StageGroups = [[seat("a")], [seat("b")]];
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: StageGroups = [[seat("a"), seat("b")], [seat("c")]];
const groups = grp([seat("a"), seat("b")], [seat("c")]);
assert.deepEqual(stageGroupsToStagePayload(groups), [["a", "b"], ["c"]]);
});
test("stageGroupsToStagePayload ignores empty stages", () => {
const groups: StageGroups = [[seat("a"), seat("b")], []];
const groups = grp([seat("a"), seat("b")], []);
assert.deepEqual(stageGroupsToStagePayload(groups), [["a", "b"]]);
});
// ── stage goals ─────────────────────────────────────────────────────────────
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, " 分析目的 ");
// stage 1 无目标 → null;stage 0 目标 trim。
assert.deepEqual(stageGroupsToStageGoals(groups), ["分析目的", null]);
});
test("setStageGoal is immutable + out-of-range no-op", () => {
const groups = grp([seat("a"), seat("b")], [seat("c")]);
const next = setStageGoal(groups, 1, "g1");
assert.equal(next[1].goal, "g1");
assert.equal(groups[1].goal, undefined); // 原对象不变
assert.equal(setStageGoal(groups, 9, "x"), groups); // 越界返回原引用
});
// ── lookups ───────────────────────────────────────────────────────────────────
test("findStageIndexOfSeat / countSeats", () => {
const groups: StageGroups = [[seat("a"), seat("b")], [seat("c")]];
const groups = grp([seat("a"), seat("b")], [seat("c")]);
assert.equal(findStageIndexOfSeat(groups, "c"), 1);
assert.equal(findStageIndexOfSeat(groups, "zzz"), -1);
assert.equal(countSeats(groups), 3);
@@ -86,60 +134,97 @@ test("findStageIndexOfSeat / countSeats", () => {
// ── mutations ─────────────────────────────────────────────────────────────────
test("addSeatAsNewStage appends a singleton stage, dedupes", () => {
let g: StageGroups = [[seat("a")]];
let g = grp([seat("a")]);
g = addSeatAsNewStage(g, seat("b"));
assert.deepEqual(ids(g), [["a"], ["b"]]);
g = addSeatAsNewStage(g, seat("a")); // dup → no-op
assert.deepEqual(ids(g), [["a"], ["b"]]);
});
test("addSeatToStage inserts into a stage at index", () => {
const g = addSeatToStage([[seat("a")], [seat("c")]], seat("b"), 0, 0);
test("addSeatToStage inserts into a stage at index, preserves goal", () => {
const base = setStageGoal(grp([seat("a")], [seat("c")]), 0, "g0");
const g = addSeatToStage(base, seat("b"), 0, 0);
assert.deepEqual(ids(g), [["b", "a"], ["c"]]);
assert.equal(g[0].goal, "g0"); // goal 保留
});
test("removeSeat drops the seat and prunes an emptied stage", () => {
const g = removeSeat([[seat("a")], [seat("b"), seat("c")]], "a");
const g = removeSeat(grp([seat("a")], [seat("b"), seat("c")]), "a");
assert.deepEqual(ids(g), [["b", "c"]]);
});
test("moveSeatToStage merges across stages and prunes empty source", () => {
const g = moveSeatToStage([[seat("a")], [seat("b")]], "a", 1);
const g = moveSeatToStage(grp([seat("a")], [seat("b")]), "a", 1);
assert.deepEqual(ids(g), [["b", "a"]]);
});
test("moveSeatToStage within same stage reorders", () => {
const g = moveSeatToStage([[seat("a"), seat("b"), seat("c")]], "c", 0, 0);
const g = moveSeatToStage(grp([seat("a"), seat("b"), seat("c")]), "c", 0, 0);
assert.deepEqual(ids(g), [["c", "a", "b"]]);
});
test("splitSeatToNewStage pulls a seat out into a new following stage", () => {
const g = splitSeatToNewStage([[seat("a"), seat("b")], [seat("c")]], "b");
test("splitSeatToNewStage pulls a seat out; original keeps goal, split has none", () => {
const base = setStageGoal(grp([seat("a"), seat("b")], [seat("c")]), 0, "g0");
const g = splitSeatToNewStage(base, "b");
assert.deepEqual(ids(g), [["a"], ["b"], ["c"]]);
assert.equal(g[0].goal, "g0"); // 原 stage 保留目标
assert.equal(g[1].goal, undefined); // 拆出的新单席位 stage 无目标
});
test("splitSeatToNewStage on an already-solo seat is a no-op", () => {
const g = splitSeatToNewStage([[seat("a")], [seat("b")]], "a");
const g = splitSeatToNewStage(grp([seat("a")], [seat("b")]), "a");
assert.deepEqual(ids(g), [["a"], ["b"]]);
});
test("moveStage reorders serial stages", () => {
const g = moveStage([[seat("a")], [seat("b")], [seat("c")]], 2, -1);
test("moveStage reorders serial stages, goal travels with the stage", () => {
const base = setStageGoal(grp([seat("a")], [seat("b")], [seat("c")]), 2, "gc");
const g = moveStage(base, 2, -1);
assert.deepEqual(ids(g), [["a"], ["c"], ["b"]]);
assert.equal(g[1].goal, "gc"); // c 的目标随它移到了第 2 位
});
test("addEmptyStage / deleteStage", () => {
let g = addEmptyStage([[seat("a")]]);
let g = addEmptyStage(grp([seat("a")]));
assert.deepEqual(ids(g), [["a"], []]);
g = deleteStage(g, 1);
assert.deepEqual(ids(g), [["a"]]);
});
// round-trip: editor groups → payload → reload preserves structure
test("round-trip: groups → {seats, stages} → chainToStageGroups", () => {
const groups: StageGroups = [[seat("a"), seat("b")], [seat("c")]];
// round-trip: editor groups → payload → reload preserves structure + goals
test("round-trip: groups → {seats, stages, stageGoals} → chainToStageGroups", () => {
const groups = setStageGoal(grp([seat("a"), seat("b")], [seat("c")]), 1, "依赖上一阶段");
const seats = stageGroupsToSeats(groups);
const stages = stageGroupsToStagePayload(groups);
const restored = chainToStageGroups({ seats, stages });
const stageGoals = stageGroupsToStageGoals(groups);
const restored = chainToStageGroups({ seats, stages, stageGoals });
assert.deepEqual(ids(restored), [["a", "b"], ["c"]]);
assert.equal(restored[0].goal, undefined);
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({
seats: [seat("c")],
stages: [["ghosta", "ghostb"], ["c"]],
stageGoals: ["g0", "g1"],
});
assert.deepEqual(ids(restored), [["c"]]);
assert.equal(restored[0].goal, "g1");
});
@@ -1,59 +1,99 @@
/**
* 业务链条「分层编排」编辑器的纯数据操作。
*
* 编辑器内部用 `StageGroups`(`ChainSeat[][]`,每个内层数组 = 一个 stage,stage 内
* 并行、stage 间串行)作为**单一数据源**:
* 编辑器内部用 `StageGroups`(`StageGroup[]`,每个 `StageGroup` = 一个 stage,含其
* 并行席位 `seats` 与可选「本阶段目标」`goal`;stage 内并行、stage 间串行)作为
* **单一数据源**:
* - 展平得到落库的有序 `seats`({@link stageGroupsToSeats});
* - 折叠得到落库的 `stages`({@link stageGroupsToStagePayload})。
* - 折叠得到落库的 `stages`({@link stageGroupsToStagePayload});
* - 折叠得到落库的 `stageGoals`({@link stageGroupsToStageGoals}),与 `stages` 同下标对齐。
*
* `goal` 挂在 stage 对象上,随 stage 在增删改 / 重排中自动跟随,避免与席位错位。
* 这里只放纯函数(无 React / 无副作用),便于单测;组件只做渲染 + 调用这些函数。
* 对应需求文档 `docs/roundtable-dag-orchestration.md` §2(分层编辑器,竖列分组)。
*/
import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain";
/** 编辑器内部的分层结构:每个内层数组是一个并行 stage,外层顺序即串行顺序。 */
export type StageGroups = ChainSeat[][];
/** 一个并行 stage:含其并行席位与可选「本阶段目标」(注入给总控/席位,仅 dag 模式生效)。 */
export interface StageGroup {
seats: ChainSeat[];
/** 本阶段目标:告诉总控本阶段要产出什么、如何依赖上一阶段产出。空 = 不注入。 */
goal?: string;
}
/** 编辑器内部的分层结构:每个元素是一个并行 stage,外层顺序即串行顺序。 */
export type StageGroups = StageGroup[];
/**
* 从一条业务链条还原分层结构。
* - 链条带 `stages`:按分层分组(agent_id → 对应 seat),丢弃找不到 seat 的 id 与空组;
* 分层全空时回退到线性。
* - 无 `stages`(老链条):线性 —— 每个 seat 独立成一层。
* `goal` 按**原始 stage 下标**从 `stageGoals` 取,随被丢弃的组一并丢弃;分层全空时回退到线性。
* - 无 `stages`(老链条):线性 —— 每个 seat 独立成一层(无 goal)。
*/
export function chainToStageGroups(chain: Pick<BusinessChain, "seats" | "stages">): StageGroups {
export function chainToStageGroups(
chain: Pick<BusinessChain, "seats" | "stages" | "stageGoals">,
): StageGroups {
const seats = chain.seats ?? [];
const byId = new Map(seats.map((s) => [s.agent_id, s]));
const goals = chain.stageGoals ?? null;
if (chain.stages && chain.stages.length > 0) {
const groups: StageGroups = [];
for (const group of chain.stages) {
chain.stages.forEach((group, idx) => {
const mapped = group.map((id) => byId.get(id)).filter((s): s is ChainSeat => Boolean(s));
if (mapped.length > 0) groups.push(mapped);
}
if (mapped.length === 0) return; // 整组解析不到席位 → 连同其 goal 一并丢弃
const goal = goals?.[idx]?.trim() || undefined;
groups.push(goal ? { seats: mapped, goal } : { seats: mapped });
});
if (groups.length > 0) return groups;
}
return seats.map((s) => [s]);
return seats.map((s) => ({ seats: [s] }));
}
/** 展平成落库 / 校验用的有序席位列表(stage 顺序 → stage 内顺序)。 */
export function stageGroupsToSeats(groups: StageGroups): ChainSeat[] {
return groups.flat();
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 {
const nonEmpty = groups.filter((g) => g.length > 0);
const hasParallel = nonEmpty.some((g) => g.length > 1);
if (!hasParallel) return null;
return nonEmpty.map((g) => g.map((s) => s.agent_id));
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 && !hasAnyGoal(nonEmpty) && !forceExplicit) return null;
return nonEmpty.map((g) => g.seats.map((s) => s.agent_id));
}
/**
* 折叠成落库的 `stageGoals`,与 {@link stageGroupsToStagePayload} 做**完全相同的折叠判定**
* (过滤空组;无并行且无目标、且未 `forceExplicit` 时才返回 null),保证两个数组下标一一对应。
* 每个存活组取其 `goal`(trim 后为空则该位为 null)。只要落了 `stages`,就同步落目标。
*/
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 && !hasAnyGoal(nonEmpty) && !forceExplicit) return null;
return nonEmpty.map((g) => g.goal?.trim() || null);
}
/** 该席位所在 stage 下标;不存在返回 -1。 */
export function findStageIndexOfSeat(groups: StageGroups, agentId: string): number {
return groups.findIndex((g) => g.some((s) => s.agent_id === agentId));
return groups.findIndex((g) => g.seats.some((s) => s.agent_id === agentId));
}
/** 席位是否已在任意 stage 中。 */
@@ -63,7 +103,13 @@ export function seatExistsInGroups(groups: StageGroups, agentId: string): boolea
/** 总席位数(用于 2–8 约束)。 */
export function countSeats(groups: StageGroups): number {
return groups.reduce((n, g) => n + g.length, 0);
return groups.reduce((n, g) => n + g.seats.length, 0);
}
/** 设置某个 stage 的「本阶段目标」(不可变,供编辑器 onChange)。 */
export function setStageGoal(groups: StageGroups, stageIdx: number, goal: string): StageGroups {
if (stageIdx < 0 || stageIdx >= groups.length) return groups;
return groups.map((g, i) => (i === stageIdx ? { ...g, goal } : g));
}
// ── 变换(均返回新结构,不可变)──────────────────────────────────────────────
@@ -71,10 +117,10 @@ export function countSeats(groups: StageGroups): number {
/** 末尾追加一个**新单席位 stage**(线性追加,pool 点击加入默认行为)。 */
export function addSeatAsNewStage(groups: StageGroups, seat: ChainSeat): StageGroups {
if (seatExistsInGroups(groups, seat.agent_id)) return groups;
return [...groups, [seat]];
return [...groups, { seats: [seat] }];
}
/** 把席位加入指定 stage(可选插入位置);席位已存在则不动。 */
/** 把席位加入指定 stage(可选插入位置);席位已存在则不动。保留目标 stage 的 goal。 */
export function addSeatToStage(
groups: StageGroups,
seat: ChainSeat,
@@ -85,18 +131,18 @@ export function addSeatToStage(
if (stageIdx < 0 || stageIdx >= groups.length) return groups;
return groups.map((g, i) => {
if (i !== stageIdx) return g;
const next = [...g];
const next = [...g.seats];
const at = atIndex == null ? next.length : Math.max(0, Math.min(atIndex, next.length));
next.splice(at, 0, seat);
return next;
return { ...g, seats: next };
});
}
/** 移除一个席位;该 stage 因此变空则连 stage 一起删掉。 */
/** 移除一个席位;该 stage 因此变空则连 stage(含其 goal)一起删掉。 */
export function removeSeat(groups: StageGroups, agentId: string): StageGroups {
return groups
.map((g) => g.filter((s) => s.agent_id !== agentId))
.filter((g) => g.length > 0);
.map((g) => ({ ...g, seats: g.seats.filter((s) => s.agent_id !== agentId) }))
.filter((g) => g.seats.length > 0);
}
/**
@@ -112,50 +158,53 @@ export function moveSeatToStage(
): StageGroups {
const from = findStageIndexOfSeat(groups, agentId);
if (from < 0 || targetStageIdx < 0 || targetStageIdx >= groups.length) return groups;
const seat = groups[from].find((s) => s.agent_id === agentId);
const seat = groups[from].seats.find((s) => s.agent_id === agentId);
if (!seat) return groups;
// 先按身份记下目标 stage 引用,避免删空源 stage 后下标错位。
// 先按身份记下目标 stage 引用,避免删空源 stage 后下标错位。goal 始终随 stage 保留。
const targetRef = groups[targetStageIdx];
let next: StageGroups = groups.map((g) =>
g === targetRef ? g : g.filter((s) => s.agent_id !== agentId),
g === targetRef ? g : { ...g, seats: g.seats.filter((s) => s.agent_id !== agentId) },
);
next = next.map((g) => {
if (g !== targetRef) return g;
if (g.some((s) => s.agent_id === agentId)) {
if (g.seats.some((s) => s.agent_id === agentId)) {
// stage 内移动:先抽出再按 atIndex 插回。
const without = g.filter((s) => s.agent_id !== agentId);
const without = g.seats.filter((s) => s.agent_id !== agentId);
const at = atIndex == null ? without.length : Math.max(0, Math.min(atIndex, without.length));
without.splice(at, 0, seat);
return without;
return { ...g, seats: without };
}
const merged = [...g];
const merged = [...g.seats];
const at = atIndex == null ? merged.length : Math.max(0, Math.min(atIndex, merged.length));
merged.splice(at, 0, seat);
return merged;
return { ...g, seats: merged };
});
return next.filter((g) => g.length > 0);
return next.filter((g) => g.seats.length > 0);
}
/** 把席位从当前 stage 抽出,独立成一个**新 stage**(插在原 stage 之后)。 */
/**
* 把席位从当前 stage 抽出,独立成一个**新 stage**(插在原 stage 之后)。
* 原 stage 保留其 `goal`(目标描述的是这一批),拆出的新单席位 stage 无 goal。
*/
export function splitSeatToNewStage(groups: StageGroups, agentId: string): StageGroups {
const from = findStageIndexOfSeat(groups, agentId);
if (from < 0) return groups;
const seat = groups[from].find((s) => s.agent_id === agentId);
const seat = groups[from].seats.find((s) => s.agent_id === agentId);
if (!seat) return groups;
if (groups[from].length === 1) return groups; // 已经独占一层,无需拆
if (groups[from].seats.length === 1) return groups; // 已经独占一层,无需拆
const out: StageGroups = [];
groups.forEach((g, i) => {
if (i === from) {
out.push(g.filter((s) => s.agent_id !== agentId));
out.push([seat]);
out.push({ ...g, seats: g.seats.filter((s) => s.agent_id !== agentId) });
out.push({ seats: [seat] });
} else {
out.push(g);
}
});
return out.filter((g) => g.length > 0);
return out.filter((g) => g.seats.length > 0);
}
/** 整段 stage 上移 / 下移(改串行顺序)。 */
/** 整段 stage 上移 / 下移(改串行顺序)。goal 随 stage 一起移动。 */
export function moveStage(groups: StageGroups, stageIdx: number, dir: -1 | 1): StageGroups {
const target = stageIdx + dir;
if (stageIdx < 0 || stageIdx >= groups.length || target < 0 || target >= groups.length) {
@@ -168,7 +217,7 @@ export function moveStage(groups: StageGroups, stageIdx: number, dir: -1 | 1): S
/** 末尾追加一个空 stage(用户「新建 Stage」后再拖 / 加席位进去)。 */
export function addEmptyStage(groups: StageGroups): StageGroups {
return [...groups, []];
return [...groups, { seats: [] }];
}
/** 删除整个 stage(含其席位)。 */
@@ -188,6 +188,35 @@ test("a chain plan validates clean against its surviving roster", () => {
assert.equal(res.ok, true);
});
test("buildPlanFromStages stamps per-stage goals (trimmed), aligned to source index", () => {
const plan = buildPlanFromStages([["a", "b"], ["c"]], undefined, true, [" 目的 ", ""]);
assert.equal(plan.stages[0].goal, "目的"); // trim
assert.equal(plan.stages[1].goal, undefined); // 空 → 无 goal
});
test("buildPlanFromStages drops a filtered-out stage's goal, keeps survivor's by source index", () => {
// stage 0 全被过滤 → 连同 goals[0] 丢弃;存活的 ["c"] 是源下标 1 → 拿 goals[1]。
const plan = buildPlanFromStages([["x"], ["c"]], ["c"], true, ["g0", "g1"]);
assert.deepEqual(plan.stages.map((s) => s.agentIds), [["c"]]);
assert.equal(plan.stages[0].goal, "g1");
});
test("buildPlanFromStages without goals leaves stage.goal undefined (old plans)", () => {
const plan = buildPlanFromStages([["a"], ["b"]]);
assert.equal(plan.stages[0].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 依赖摘要注入) ─────────────────────────────────
test("buildDependencySummary is empty for no deliveries (first stage)", () => {
@@ -424,3 +453,87 @@ test("buildDagStageLeaderTask insists on actually calling the tool (not narratin
assert.match(task, /真正调用 agent_orchestration 工具/);
assert.match(task, /不要.*只用文字/);
});
// ── per-stage goal injection (本阶段目标) ──────────────────────────────────────
test("buildDagStageLeaderTask injects the stage goal when provided", () => {
const task = buildDagStageLeaderTask({
seedMessage: "x",
stageIndex: 1,
totalStages: 2,
seats: [{ agentId: "actor", name: "行为体分析" }],
isFirst: false,
goal: "根据上一阶段的各个目的,分析出行为体",
});
assert.match(task, /本阶段目标/);
assert.match(task, /分析出行为体/);
assert.match(task, /显式承接上一阶段/);
});
test("buildDagStageLeaderTask omits the goal line when goal is empty/blank", () => {
const task = buildDagStageLeaderTask({
seedMessage: "x",
stageIndex: 0,
totalStages: 1,
seats: [{ agentId: "a", name: "甲" }],
isFirst: true,
goal: " ",
});
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);
const miss = pickDagSeatTask([], "actor", "行为体", "种子", true, false, goal);
for (const t of [hit, miss]) {
assert.match(t, /本阶段目标/);
assert.match(t, /分析出行为体/);
assert.match(t, /显式承接上一阶段/);
}
});
test("pickDagSeatTask omits the goal block when no goal (old plans)", () => {
const t = pickDagSeatTask([], "actor", "行为体", "种子", true);
assert.doesNotMatch(t, /本阶段目标/);
});
@@ -19,6 +19,11 @@ export interface OrchestrationStage {
id: string;
/** 本 stage 并行执行的席位(agent_id 列表)。1 个 = 退化为串行节点。 */
agentIds: string[];
/**
* 可选「本阶段目标」:来自业务链条 `stageGoals`,注入总控派活提示(主)与席位任务
* (兜底),告诉本阶段要产出什么、如何依赖上一阶段产出。空 = 不注入。
*/
goal?: string;
}
/** 用户编排出的执行计划。stages 顺序即串行顺序。 */
@@ -29,6 +34,12 @@ export interface OrchestrationPlan {
stages: OrchestrationStage[];
/** 收口:是否在所有 stage 完成后由总控综合(默认 true)。 */
finalSynthesis: boolean;
/**
* 可选「总控编排提示」:来自业务链条 `coordinatorPrompt`,写给总控的整体派活指引
* (怎么派活 / 做什么)。注入总控**每阶段**派活提示({@link buildDagStageLeaderTask})。
* 空 = 不注入。
*/
coordinatorPrompt?: string;
}
// ── 通用 DAG 表示:自由连线时才需要(§1.2,本期仅预留)───────────────────────
@@ -180,19 +191,30 @@ export function emptyPlan(finalSynthesis = true): OrchestrationPlan {
* @param stages 链条分层(每组并行、组间串行)。
* @param allowedIds 仅保留这些 agent_id(存活席位);省略则全保留。
* @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[] = [];
stages.forEach((group, idx) => {
const ids = allow ? group.filter((id) => allow.has(id)) : group.slice();
if (ids.length > 0) cleaned.push({ id: stageId(idx), agentIds: ids });
if (ids.length === 0) return;
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 决定显示。 */
@@ -330,13 +352,22 @@ export function buildDagStageLeaderTask(args: {
/** 本 stage 计划钉死的席位。 */
seats: Array<{ agentId: string; name: string }>;
isFirst: boolean;
/** 可选「本阶段目标」:用户在业务链条里为本阶段写的产出 / 依赖说明。空 = 不注入。 */
goal?: string;
/**
* 可选「总控编排提示」:用户在业务链条里写给总控的整体派活指引(怎么派活 / 做什么 /
* 风格 / 约束)。每个 stage 的派活轮都注入,作为总控的高优先编排原则。空 = 不注入。
*/
coordinatorPrompt?: string;
}): string {
const { seedMessage, stageIndex, totalStages, seats, isFirst } = 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, "");
@@ -346,10 +377,25 @@ export function buildDagStageLeaderTask(args: {
"",
);
}
if (coordText) {
lines.push(
"【总控编排指引(用户预设,最高优先)】以下是本次业务链条为你(总控)预设的整体派活原则,请在每一阶段派活时都严格遵循:",
coordText,
"",
);
}
lines.push(
`本研讨严格按预设分层顺序进行,共 ${totalStages} 个阶段,现在是第 ${ordinal} 个阶段。`,
`本阶段需要执行的席位(已由编排计划钉死)是:${seatList}。`,
`请你作为总控,通过 agent_orchestration 技能**分别**向上述${isParallel ? `这 ${seats.length} 个席位` : "席位"}派活,结合任务背景与此前交付,为每个席位分配聚焦其职责的本阶段任务。`,
);
if (goalText) {
lines.push(
`【本阶段目标】${goalText}`,
"请据此目标为每个席位拆解任务,并要求他们**显式承接上一阶段的产出**来完成本阶段目标。",
);
}
lines.push(
`请你作为总控,通过 agent_orchestration 技能**分别**向上述${isParallel ? `这 ${seats.length} 个席位` : "席位"}派活,结合任务背景${goalText ? "、本阶段目标" : ""}与此前交付,为每个席位分配聚焦其职责的本阶段任务。`,
"只派给上述席位、且每个都必须派到;不要派给清单外的任何席位,也不要自己直接作答或给出最终结论。",
"⚠️ 必须**真正调用 agent_orchestration 工具**来派活(每个席位对应一次工具调用);**不要**只用文字罗列「席位1:…席位2:…」这样的任务清单——只写文字而不调用工具,系统无法把任务下发给席位。",
);
@@ -386,6 +432,11 @@ export function pickDagSeatTask(
* - `false`(**快速模式**,默认):产物**直接写在正文**,不写文件(fast 模式产物走正文)。
*/
allowFiles = false,
/**
* 可选「本阶段目标」(兜底注入):总控派活质量差 / 没派到时,席位仍据此知道本阶段要产出
* 什么、如何依赖上一阶段产出。空 = 不注入。
*/
goal = "",
): string {
const guard =
`\n\n【角色边界·必读】你是**执行席位**「${seatName}」,不是总控协调者,本轮**只**对你自己的交付负责。**严禁**任何形式的派活 / 调度:不要调用 agent_orchestration 或 skill_list,不要用 python / 脚本去访问内部接口(如 /api/agents、agent_orchestration)来调度,不要罗列或给其他席位分配任务清单、不要复述「我来调度各智能体」这类总控口吻。`;
@@ -402,15 +453,20 @@ export function pickDagSeatTask(
const peerRule = hasPeers
? `\n\n【按需取全文】此前各阶段席位的交付你默认只看到**摘要**;若需要某个前序席位的**完整交付**,调用工具 read_peer_delivery('<该席位名称>') 取回全文(摘要够用就不必调)。`
: "";
// 本阶段目标(兜底):用户为本阶段写的产出/依赖说明,即便总控没派好,席位也据此对齐本阶段意图。
const goalRule = goal.trim()
? `\n\n【本阶段目标】${goal.trim()}\n请确保你的交付服务于该目标,并显式承接上一阶段的产出。`
: "";
const wanted = seatAgentId.toLowerCase();
const exact = dispatched.find(([name]) => name.toLowerCase() === wanted);
if (exact && exact[1]?.trim()) {
return `${bg}请你作为「${seatName}」,完成总控派给你的本阶段任务:\n${exact[1].trim()}${fileRule}${peerRule}${guard}`;
return `${bg}请你作为「${seatName}」,完成总控派给你的本阶段任务:\n${exact[1].trim()}${goalRule}${fileRule}${peerRule}${guard}`;
}
// dispatched 为空 / 未命中 → 干净本职任务,**不**注入总控派活叙述(根治「席位模仿派活」)。
return (
`${bg}请你作为「${seatName}」,从你的专业职责出发,独立完成本阶段属于你的分析与交付。` +
`此前各阶段席位的交付已在你的会话上下文里,请据此承接。` +
goalRule +
fileRule +
peerRule +
guard
@@ -30,8 +30,10 @@ import {
GitBranch,
Globe,
GripVertical,
Info,
Layers,
Loader2,
Network,
Plus,
Save,
SplitSquareVertical,
@@ -60,6 +62,12 @@ import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { listAgents } from "@/core/agents";
@@ -93,13 +101,17 @@ import {
moveStage,
removeSeat as removeSeatFromGroups,
seatExistsInGroups,
setStageGoal,
splitSeatToNewStage,
stageGroupsToSeats,
stageGroupsToStageGoals,
stageGroupsToStagePayload,
type StageGroup,
type StageGroups,
} from "@/roundtable-planning/lib/chain-stages";
import { getAvatarTypeFor } from "@/roundtable-planning/lib/roundtable-constants";
import { DagFlowChart } from "@/roundtable-planning/components/DagFlowChart";
import { AgentDetailSheet } from "@/roundtable-planning/components/AgentDetailSheet";
const POOL_PREFIX = "pool::";
const SEAT_PREFIX = "seat::";
@@ -138,12 +150,20 @@ 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);
const [activeDrag, setActiveDrag] = useState<ActiveDrag>(null);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [flowOpen, setFlowOpen] = useState(false);
// 详情抽屉:左栏点卡片本体、右栏点「查看详情」都打开它,展示该智能体详情。
const [detailAgent, setDetailAgent] = useState<{ id: string; name?: string } | null>(null);
const showDetail = useCallback((id: string, name?: string) => {
setDetailAgent({ id, name });
}, []);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
@@ -155,7 +175,7 @@ export default function BusinessChainEditorPage() {
return m;
}, [pool]);
const seatIdSet = useMemo(
() => new Set(stageGroups.flat().map((s) => s.agent_id)),
() => new Set(stageGroups.flatMap((g) => g.seats).map((s) => s.agent_id)),
[stageGroups],
);
const seatCount = useMemo(() => countSeats(stageGroups), [stageGroups]);
@@ -165,14 +185,14 @@ export default function BusinessChainEditorPage() {
const previewStages = useMemo(
() =>
stageGroups
.filter((g) => g.length > 0)
.map((g, i) => ({ id: `stage-${i}`, agentIds: g.map((s) => s.agent_id) })),
.filter((g) => g.seats.length > 0)
.map((g, i) => ({ id: `stage-${i}`, agentIds: g.seats.map((s) => s.agent_id) })),
[stageGroups],
);
const seatNameById = useMemo(() => {
const m = new Map<string, string>();
for (const g of stageGroups)
for (const s of g) m.set(s.agent_id.toLowerCase(), s.name || s.agent_id);
for (const s of g.seats) m.set(s.agent_id.toLowerCase(), s.name || s.agent_id);
return m;
}, [stageGroups]);
const resolvePreviewSeat = useCallback(
@@ -228,6 +248,7 @@ export default function BusinessChainEditorPage() {
setCurrentChainMine(chain.isMine !== false);
setTitle(chain.title);
setDescription(chain.description ?? "");
setCoordinatorPrompt(chain.coordinatorPrompt ?? "");
setStageGroups(chainToStageGroups(chain));
}, []);
@@ -237,6 +258,7 @@ export default function BusinessChainEditorPage() {
setCurrentChainMine(true);
setTitle("");
setDescription("");
setCoordinatorPrompt("");
setStageGroups([]);
}, []);
@@ -291,6 +313,10 @@ export default function BusinessChainEditorPage() {
setStageGroups((prev) => moveSeatToStage(prev, agentId, targetStageIdx));
}, []);
const setGoal = useCallback((stageIdx: number, goal: string) => {
setStageGroups((prev) => setStageGoal(prev, stageIdx, goal));
}, []);
const splitSeat = useCallback((agentId: string) => {
setStageGroups((prev) => splitSeatToNewStage(prev, agentId));
}, []);
@@ -306,8 +332,8 @@ export default function BusinessChainEditorPage() {
const removeStage = useCallback((stageIdx: number) => {
setStageGroups((prev) => {
const stage = prev[stageIdx];
if (stage && stage.length > 0) {
if (!window.confirm(`删除 Stage ${stageIdx + 1} 会移除其中 ${stage.length} 个席位,确认?`)) {
if (stage && stage.seats.length > 0) {
if (!window.confirm(`删除 Stage ${stageIdx + 1} 会移除其中 ${stage.seats.length} 个席位,确认?`)) {
return prev;
}
}
@@ -349,7 +375,7 @@ export default function BusinessChainEditorPage() {
} else if (overId.startsWith(SEAT_PREFIX)) {
const overAgent = overId.slice(SEAT_PREFIX.length);
targetStage = findStageIndexOfSeat(stageGroups, overAgent);
atIndex = stageGroups[targetStage]?.findIndex((s) => s.agent_id === overAgent);
atIndex = stageGroups[targetStage]?.seats.findIndex((s) => s.agent_id === overAgent);
}
const toNewStage = overId === NEWSTAGE_DROPPABLE_ID;
@@ -389,7 +415,7 @@ export default function BusinessChainEditorPage() {
// ─── 保存 / 删除 ──────────────────────────────────────────────────────
const seatCountInvalid = seatCount < CHAIN_MIN_SEATS || seatCount > CHAIN_MAX_SEATS;
const hasEmptyStage = stageGroups.some((g) => g.length === 0);
const hasEmptyStage = stageGroups.some((g) => g.seats.length === 0);
const canSave = title.trim().length > 0 && !seatCountInvalid && !saving;
const handleSave = useCallback(async () => {
@@ -403,20 +429,29 @@ 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);
// 总控编排提示: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(),
description: description.trim() || undefined,
seats: payloadSeats,
stages,
stageGoals,
coordinatorPrompt: coordPrompt,
});
if (!updated) {
toast.error("链条已不存在,可能被删除");
@@ -433,6 +468,8 @@ export default function BusinessChainEditorPage() {
description: description.trim() || undefined,
seats: payloadSeats,
stages,
stageGoals,
coordinatorPrompt: coordPrompt,
});
setCurrentChainId(created.id);
toast.success("已创建业务链条");
@@ -446,6 +483,7 @@ export default function BusinessChainEditorPage() {
}, [
title,
description,
coordinatorPrompt,
stageGroups,
seatCountInvalid,
currentChainId,
@@ -711,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>
{/* 双栏拖拽编辑器 */}
@@ -744,7 +805,7 @@ export default function BusinessChainEditorPage() {
{pool.length}
</span>
</div>
<span className="text-[11px] font-medium text-muted-foreground">拖到右侧 / 点击加入</span>
<span className="text-[11px] font-medium text-muted-foreground">拖手柄到右侧 / 点卡片看详情</span>
</div>
<div className="shrink-0 border-b border-border px-4 py-3">
@@ -775,6 +836,7 @@ export default function BusinessChainEditorPage() {
agent={a}
inChain={seatIdSet.has(a.agent_id)}
onAdd={() => addAgentAsNewStage(a.agent_id)}
onShowDetail={() => showDetail(a.agent_id, a.name)}
/>
))
)}
@@ -793,6 +855,8 @@ export default function BusinessChainEditorPage() {
onRemoveSeat={removeSeat}
onMergeSeat={mergeSeatToStage}
onSplitSeat={splitSeat}
onStageGoalChange={setGoal}
onSeatDetail={showDetail}
/>
</main>
@@ -811,24 +875,58 @@ export default function BusinessChainEditorPage() {
</DragOverlay>
</DndContext>
{/* Phase 7:分层 DAG 只读流程图预览(§2.2 底部)。空编排时不渲染。 */}
{/* Phase 7:分层 DAG 只读流程图预览(§2.2 底部)。空编排时不渲染。
改为弹窗查看:底栏一个入口按钮,点击后在 Dialog 内展示完整流程图。 */}
{previewStages.length > 0 && (
<section className="shrink-0 border-t border-border bg-card px-6 py-2">
<div className="mb-1 flex items-center gap-2 text-[11px] font-semibold text-muted-foreground">
<section className="shrink-0 flex items-center justify-between gap-2 border-t border-border bg-card px-6 py-2">
<span className="flex items-center gap-2 text-[11px] font-semibold text-muted-foreground">
<span>编排预览</span>
<span className="text-muted-foreground/50">
总控派活 → 各 Stage(串行)→ 共识 → 结果绘制
</span>
</div>
<DagFlowChart
stages={previewStages}
resolveSeat={resolvePreviewSeat}
showReportMilestone={false}
compact
/>
</span>
<button
type="button"
onClick={() => setFlowOpen(true)}
className="inline-flex items-center gap-1.5 rounded-lg border border-primary/30 bg-primary/5 px-2.5 py-1 text-xs font-semibold text-primary transition hover:bg-primary/10"
>
<Network className="size-3.5" />
查看编排流程图
</button>
</section>
)}
{/* 编排流程图弹窗 */}
{previewStages.length > 0 && (
<Dialog open={flowOpen} onOpenChange={setFlowOpen}>
<DialogContent className="flex max-h-[88vh] w-[94vw] flex-col gap-0 sm:max-w-[1100px]">
<DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2">
<Network className="size-5 text-primary" />
编排流程图
</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-auto pt-2">
<DagFlowChart
stages={previewStages}
resolveSeat={resolvePreviewSeat}
showReportMilestone={false}
/>
</div>
</DialogContent>
</Dialog>
)}
{/* 智能体详情抽屉(左栏卡片 / 右栏席位共用) */}
<AgentDetailSheet
agentId={detailAgent?.id ?? null}
fallbackName={detailAgent?.name}
open={detailAgent !== null}
onOpenChange={(o) => {
if (!o) setDetailAgent(null);
}}
/>
{/* 页脚 */}
<footer className="shrink-0 select-none border-t border-border bg-card px-6 py-3 text-center text-[11px] text-muted-foreground">
分层编排:同一 Stage 内的席位并行研讨,Stage 之间串行。全单席位 = 线性链。保存后自动同步至圆桌。
@@ -843,11 +941,14 @@ function PoolAgentCard({
agent,
inChain,
onAdd,
onShowDetail,
}: {
agent: RecommendCandidate;
inChain: boolean;
onAdd: () => void;
onShowDetail: () => void;
}) {
// 只把拖拽 listeners 挂到左侧手柄上 —— 点手柄才拖动;点卡片本体打开详情抽屉。
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: POOL_PREFIX + agent.agent_id,
disabled: inChain,
@@ -855,55 +956,79 @@ function PoolAgentCard({
return (
<div
ref={setNodeRef}
onClick={inChain ? undefined : onAdd}
onClick={onShowDetail}
className={cn(
"group relative flex items-start gap-3 border p-3.5 transition-all duration-150",
"group relative flex items-stretch overflow-hidden border transition-all duration-150",
R_CARD,
inChain
? "cursor-default border-primary/30 bg-primary/[0.03]"
? "cursor-pointer border-primary/30 bg-primary/[0.03]"
: "cursor-pointer border-border bg-card hover:border-primary hover:shadow-[0_2px_8px_rgba(0,0,0,0.04)]",
isDragging && "opacity-30",
)}
{...(inChain ? {} : listeners)}
{...attributes}
>
<div
{/* 拖拽手柄(与右栏席位一致):点手柄才开始拖动;已加入则禁用拖拽。 */}
<button
type="button"
onClick={(e) => e.stopPropagation()}
className={cn(
"mt-0.5 flex size-6 shrink-0 items-center justify-center transition-colors",
R_CARD,
"flex w-7 shrink-0 items-center justify-center border-r border-border bg-muted/40 text-muted-foreground/60 transition-colors",
inChain
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary",
? "cursor-not-allowed opacity-40"
: "cursor-grab touch-none hover:text-foreground active:cursor-grabbing",
)}
title={inChain ? "已在链条中" : "拖动到右侧加入 / 排序"}
{...(inChain ? {} : listeners)}
>
<Bot className="size-3.5" />
</div>
<GripVertical className="size-3.5" />
</button>
<div className="min-w-0 flex-1 pr-16">
<div className="truncate text-xs font-bold leading-tight text-foreground transition-colors group-hover:text-primary">
{agent.name || agent.agent_id}
<div className="flex min-w-0 flex-1 items-start gap-3 p-3 pr-2">
<div
className={cn(
"mt-0.5 flex size-6 shrink-0 items-center justify-center transition-colors",
R_CARD,
inChain
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary",
)}
>
<Bot className="size-3.5" />
</div>
{agent.description && (
<div className="mt-1 line-clamp-2 text-[11px] font-medium leading-normal text-muted-foreground">
{agent.description}
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-bold leading-tight text-foreground transition-colors group-hover:text-primary">
{agent.name || agent.agent_id}
</div>
)}
{agent.description && (
<div className="mt-1 line-clamp-2 text-[11px] font-medium leading-normal text-muted-foreground">
{agent.description}
</div>
)}
</div>
</div>
<div className="absolute right-3.5 top-3.5 shrink-0 select-none">
{/* 加入按钮:只有点它才加入(点卡片其它区域是看详情)。已加入显示「已加入」。 */}
<div className="flex shrink-0 items-center pr-3 pl-1 select-none">
{inChain ? (
<span className={cn("border border-primary/30 bg-primary/10 px-2 py-1 text-[10px] font-bold text-primary", R_CTRL)}>
已加入
</span>
) : (
<span
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onAdd();
}}
className={cn(
"border border-transparent bg-muted px-2 py-1 text-[10px] font-semibold text-muted-foreground transition-all group-hover:border-primary/30 group-hover:bg-primary/10 group-hover:text-primary",
"inline-flex items-center gap-1 border border-primary/30 bg-primary/5 px-2 py-1 text-[10px] font-semibold text-primary transition-all hover:bg-primary/10",
R_CTRL,
)}
>
点击加入
</span>
<Plus className="size-3" />
加入
</button>
)}
</div>
</div>
@@ -923,6 +1048,8 @@ function StagesColumn({
onRemoveSeat,
onMergeSeat,
onSplitSeat,
onStageGoalChange,
onSeatDetail,
}: {
stageGroups: StageGroups;
seatCount: number;
@@ -934,6 +1061,8 @@ function StagesColumn({
onRemoveSeat: (agentId: string) => void;
onMergeSeat: (agentId: string, targetStageIdx: number) => void;
onSplitSeat: (agentId: string) => void;
onStageGoalChange: (stageIdx: number, goal: string) => void;
onSeatDetail: (agentId: string, name?: string) => void;
}) {
// 跨 Stage 的全局序号(头像编号):每个 Stage 起始累加。
const stageStartIndex = useMemo(() => {
@@ -941,10 +1070,12 @@ function StagesColumn({
let acc = 0;
for (const g of stageGroups) {
out.push(acc);
acc += g.length;
acc += g.seats.length;
}
return out;
}, [stageGroups]);
// 是否含并行 stage → 决定提示「目标仅对含并行的分层链条生效」。
const hasParallelStage = stageGroups.some((g) => g.seats.length > 1);
return (
<section
@@ -1018,11 +1149,14 @@ function StagesColumn({
group={group}
stageCount={stageGroups.length}
startIndex={stageStartIndex[stageIdx]}
hasParallelStage={hasParallelStage}
onRemoveStage={() => onRemoveStage(stageIdx)}
onReorderStage={(dir) => onReorderStage(stageIdx, dir)}
onRemoveSeat={onRemoveSeat}
onMergeSeat={onMergeSeat}
onSplitSeat={onSplitSeat}
onGoalChange={(goal) => onStageGoalChange(stageIdx, goal)}
onSeatDetail={onSeatDetail}
/>
))}
<NewStageDropZone hasEmptyStage={hasEmptyStage} onAddStage={onAddStage} />
@@ -1040,27 +1174,35 @@ function StageSection({
group,
stageCount,
startIndex,
hasParallelStage,
onRemoveStage,
onReorderStage,
onRemoveSeat,
onMergeSeat,
onSplitSeat,
onGoalChange,
onSeatDetail,
}: {
stageIdx: number;
group: ChainSeat[];
group: StageGroup;
stageCount: number;
startIndex: number;
/** 整条链是否含并行 stage(决定目标输入框的提示语,目标仅 dag 模式生效)。 */
hasParallelStage: boolean;
onRemoveStage: () => void;
onReorderStage: (dir: -1 | 1) => void;
onRemoveSeat: (agentId: string) => void;
onMergeSeat: (agentId: string, targetStageIdx: number) => void;
onSplitSeat: (agentId: string) => void;
onGoalChange: (goal: string) => void;
onSeatDetail: (agentId: string, name?: string) => void;
}) {
const { setNodeRef, isOver } = useDroppable({ id: STAGE_PREFIX + stageIdx });
const isParallel = group.length > 1;
const seats = group.seats;
const isParallel = seats.length > 1;
const isFirstStage = stageIdx === 0;
const isLastStage = stageIdx === stageCount - 1;
const seatItemIds = group.map((s) => SEAT_PREFIX + s.agent_id);
const seatItemIds = seats.map((s) => SEAT_PREFIX + s.agent_id);
return (
<div className={cn("border border-border bg-muted/20", R_CARD, PANEL_SHADOW)}>
@@ -1086,7 +1228,7 @@ function StageSection({
: "border-border bg-muted text-muted-foreground",
)}
>
{isParallel ? `并行 ${group.length}` : "串行"}
{isParallel ? `并行 ${seats.length}` : "串行"}
</span>
</div>
<div className="flex items-center gap-0.5">
@@ -1131,18 +1273,45 @@ function StageSection({
</div>
</div>
{/* 本阶段目标:告诉总控本阶段要产出什么、如何依赖上一阶段产出(仅含并行的分层链条生效)。 */}
{seats.length > 0 && (
<div className="border-b border-border bg-muted/10 px-2.5 py-2">
<textarea
value={group.goal ?? ""}
onChange={(e) => onGoalChange(e.target.value)}
maxLength={500}
rows={2}
placeholder={
isFirstStage
? "本阶段目标(可选):本阶段要产出什么,供后续阶段承接"
: "本阶段目标(可选):本阶段要产出什么、如何依赖上一阶段产出"
}
className={cn(
"w-full resize-y bg-card px-2.5 py-1.5 text-[11px] leading-relaxed text-foreground placeholder:text-muted-foreground/60",
"border border-border focus:border-primary/40 focus:outline-none",
R_CTRL,
)}
/>
{!hasParallelStage && (group.goal?.trim()?.length ?? 0) > 0 && (
<p className="mt-1 text-[10px] text-muted-foreground">
提示:填写本阶段目标后,本链条将按阶段顺序由总控逐阶段派活(分层研讨),并把该目标注入总控。
</p>
)}
</div>
)}
{/* Stage 内席位 */}
<div
ref={setNodeRef}
className={cn("space-y-2 p-2.5 transition-colors", isOver && "bg-primary/[0.04]")}
>
{group.length === 0 ? (
{seats.length === 0 ? (
<div className={cn("border border-dashed border-border bg-card/40 px-3 py-4 text-center text-[11px] text-muted-foreground", R_CARD)}>
空 Stage —— 把席位拖到此处,或删除该 Stage。
</div>
) : (
<SortableContext items={seatItemIds} strategy={verticalListSortingStrategy}>
{group.map((seat, i) => (
{seats.map((seat, i) => (
<SeatStrip
key={seat.agent_id}
seat={seat}
@@ -1155,6 +1324,7 @@ function StageSection({
onMergeUp={() => onMergeSeat(seat.agent_id, stageIdx - 1)}
onMergeDown={() => onMergeSeat(seat.agent_id, stageIdx + 1)}
onSplit={() => onSplitSeat(seat.agent_id)}
onDetail={() => onSeatDetail(seat.agent_id, seat.name)}
/>
))}
</SortableContext>
@@ -1176,6 +1346,7 @@ function SeatStrip({
onMergeUp,
onMergeDown,
onSplit,
onDetail,
}: {
seat: ChainSeat;
globalIndex: number;
@@ -1187,6 +1358,7 @@ function SeatStrip({
onMergeUp: () => void;
onMergeDown: () => void;
onSplit: () => void;
onDetail: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: SEAT_PREFIX + seat.agent_id });
@@ -1236,8 +1408,19 @@ function SeatStrip({
)}
</div>
{/* 控制:并入上一阶段 / 独立成阶段 / 并入下一阶段 / 移除 */}
{/* 控制:查看详情 / 并入上一阶段 / 独立成阶段 / 并入下一阶段 / 移除 */}
<div className="flex shrink-0 items-center gap-0.5 border-l border-border px-2">
<button
type="button"
onClick={onDetail}
title="查看详情"
className={cn(
"p-1 text-muted-foreground transition hover:bg-muted hover:text-foreground",
R_CTRL,
)}
>
<Info className="size-3.5" />
</button>
<button
type="button"
onClick={onMergeUp}
@@ -23,7 +23,7 @@
* 6) 模型选择全局默认 `availableModels[0].name`,Persona 覆盖更高优先级
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useNavigate, useLocation } from "react-router-dom";
import {
Activity,
Check,
@@ -84,14 +84,44 @@ import {
type RoleEntry,
type SelectedAgent,
} from "@/roundtable-planning/lib/roundtable-constants";
import type { SeatSelectionResult } from "@/roundtable-planning/lib/business-chain";
import {
buildChainSeatSelection,
type SeatSelectionResult,
} from "@/roundtable-planning/lib/business-chain";
import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types";
import { getJobByDraft, startJob } from "@/roundtable-planning/api/roundtable-jobs";
import { getChain } from "@/roundtable-planning/api/chains";
import { filterRecommendCandidates } from "@/roundtable-planning/api/recommend";
import { listAgents } from "@/core/agents";
import { listBusinessMappings } from "@/strategy-components/api/business-mapping";
/** 从任务页带进来的预填(经 navigation state 传递)。见会商自动化串联(模块 C)。 */
interface RoundtablePrefill {
/** 任务名称(注入 Step 1 首条用户消息)。 */
taskTitle?: string;
/** 任务描述(注入 Step 1 首条用户消息)。 */
taskGoal?: string;
/** 来源业务码(如 "6BF"):据此查业务映射拿到要自动选用的业务链。 */
businessCode?: string;
/** 是否进入即自动开跑 Step 1 意图分析。 */
autoStart?: boolean;
}
export default function RoundtablePlanningPage() {
const { theme } = useTheme();
const isDark = theme === "dark";
const navigate = useNavigate();
const location = useLocation();
// ─── 会商自动化串联(模块 C):任务页带进来的预填 ─────────────────
// 只在首次渲染读一次 navigation state;随后清掉 history state,避免刷新/后退重放。
const prefillRef = useRef<RoundtablePrefill | null>(
((location.state as { roundtablePrefill?: RoundtablePrefill } | null)?.roundtablePrefill) ?? null,
);
// 由 prefill.businessCode 解析出的「自动选用业务链」结果;有值时「进入研讨」跳过选链弹窗直接进 Step 2。
const [autoChainResult, setAutoChainResult] = useState<SeatSelectionResult | null>(null);
const autoStartedRef = useRef(false);
const prefillClearedRef = useRef(false);
const { models: availableModels, isLoading: modelsLoading } = useModels();
@@ -604,6 +634,69 @@ export default function RoundtablePlanningPage() {
[buildCurrentStep2Snapshot, mergeActiveRun, step2, step3Report, triggerToast],
);
// ─── 会商自动化串联(模块 C) ──────────────────────────────────
// 消费 prefill 后清掉 history state,避免刷新 / 后退重放自动开跑与自动选链。
useEffect(() => {
if (prefillClearedRef.current || !prefillRef.current) return;
prefillClearedRef.current = true;
try {
window.history.replaceState({ ...window.history.state, usr: null }, "");
} catch {
/* noop */
}
}, []);
// C2:带 autoStart 时,等 Step 1 意图线程就绪后自动把「任务名称/描述」作为首条用户消息发出,
// 触发任务理解智能体自动分析(只发一次)。
useEffect(() => {
const prefill = prefillRef.current;
if (!prefill?.autoStart || autoStartedRef.current) return;
if (currentStep !== 1) return;
if (!step1.intentThreadId || step1.intentReady) return;
const title = prefill.taskTitle?.trim();
const goal = prefill.taskGoal?.trim();
const text = [title ? `任务名称:${title}` : "", goal ? `任务描述:${goal}` : ""]
.filter(Boolean)
.join("\n");
if (!text) return;
autoStartedRef.current = true;
void step1.sendIntentMessage(text);
}, [currentStep, step1.intentThreadId, step1.intentReady, step1.sendIntentMessage]);
// C3:据 prefill.businessCode 查业务映射 → 取 chainId → 拉公共链 + 候选智能体 → 构造自动选链结果。
// 解析成功后「进入研讨」会跳过选链弹窗直接据此进 Step 2;解析失败(未配置 / 链不存在 / 席位不足)
// 静默回退到弹窗手选。
// 不加「已启动」永久闸门 —— StrictMode 的 mount→unmount→remount 会让首次 run 在 cleanup
// 里被 cancelled,闸门又让 remount 跳过重启,导致 autoChainResult 永远设不上(与
// useStep1Intent 同款坑)。改为:每次 setup 都重跑,靠 cancelled 丢弃过期的首次结果,
// 末次 setup 正常落地(生产无 StrictMode 只跑一次)。
useEffect(() => {
const prefill = prefillRef.current;
if (!prefill?.businessCode) return;
let cancelled = false;
void (async () => {
try {
const mappings = await listBusinessMappings();
const row = mappings.find((m) => m.businessCode === prefill.businessCode);
const chainId = row?.chainId;
if (!chainId) return;
const [chain, agents] = await Promise.all([
getChain(chainId),
listAgents().catch(() => []),
]);
if (cancelled || !chain) return;
const candidates = filterRecommendCandidates(agents);
const result = buildChainSeatSelection(chain, candidates);
if (!cancelled && result) setAutoChainResult(result);
} catch {
/* 解析失败 → 保持 null,回退弹窗手选 */
}
})();
return () => {
cancelled = true;
};
}, []);
// 把页面重置回「第一步全新任务」——新建任务 + 后台挂起后都复用。
// 注意:只清前端状态,不删后端已建的草稿 / 已挂起的后台作业(它们继续在后台跑,
// 在历史记录里可见)。
@@ -903,6 +996,13 @@ export default function RoundtablePlanningPage() {
triggerToast("⚠️ 请先与任务理解智能体完成澄清,生成任务摘要后再进入");
return;
}
// 会商自动化串联(模块 C):若任务页带来的业务链已解析成功,跳过选链弹窗,
// 直接据该业务链进入 Step 2 研讨(用户无需再手选)。
if (autoChainResult) {
const result = autoChainResult;
void drafts.ensureDraft(1).finally(() => handleConfirmAgents(result));
return;
}
// 先把草稿落地拿到 draft_id,推荐历史才有归属;失败也照常打开弹窗
// (弹窗在无 draftId 时退化为「直接推荐、不存历史」)。
void drafts.ensureDraft(1).finally(() => setIsRecommendDialogOpen(true));
@@ -0,0 +1,87 @@
/**
* 全局业务映射(业务码 → 智能体 / 业务链)API 客户端。
*
* 后端:`/api/business-mapping`(见 app/gateway/routers/business_mapping.py)。
* 全局共享、无用户隔离;只读列表 + 更新 agent / chain 两列(业务行由后端 seed 固定)。
* snake_case ⇄ camelCase 在此边界收敛,调用方保持干净。
*/
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
export interface BusinessMapping {
businessCode: string;
label: string;
agentId: string | null;
agentName: string | null;
chainId: string | null;
chainTitle: string | null;
sortOrder: number;
}
/** 部分更新 —— 只发改动的字段;显式传 null 清空该字段。 */
export interface BusinessMappingPatch {
agentId?: string | null;
agentName?: string | null;
chainId?: string | null;
chainTitle?: string | null;
}
const BASE = () => `${getBackendBaseURL()}/api/business-mapping`;
interface ServerMapping {
business_code: string;
label: string;
agent_id: string | null;
agent_name: string | null;
chain_id: string | null;
chain_title: string | null;
sort_order: number;
}
function toMapping(s: ServerMapping): BusinessMapping {
return {
businessCode: s.business_code,
label: s.label ?? "",
agentId: s.agent_id ?? null,
agentName: s.agent_name ?? null,
chainId: s.chain_id ?? null,
chainTitle: s.chain_title ?? null,
sortOrder: s.sort_order ?? 0,
};
}
async function readJson<T>(res: Response, ctx: string): Promise<T> {
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`${ctx} failed (${res.status}): ${text}`);
}
return (await res.json()) as T;
}
/** 列出全部业务映射(按展示顺序)。 */
export async function listBusinessMappings(): Promise<BusinessMapping[]> {
const res = await apiFetch(BASE(), { method: "GET" });
const data = await readJson<{ mappings: ServerMapping[] }>(res, "list business mappings");
return (data.mappings ?? []).map(toMapping);
}
/** 更新某业务行的智能体 / 业务链选择。404 返回 null。 */
export async function updateBusinessMapping(
businessCode: string,
patch: BusinessMappingPatch,
): Promise<BusinessMapping | null> {
const body: Record<string, unknown> = {};
if (patch.agentId !== undefined) body.agent_id = patch.agentId;
if (patch.agentName !== undefined) body.agent_name = patch.agentName;
if (patch.chainId !== undefined) body.chain_id = patch.chainId;
if (patch.chainTitle !== undefined) body.chain_title = patch.chainTitle;
const res = await apiFetch(`${BASE()}/${encodeURIComponent(businessCode)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (res.status === 404) return null;
const data = await readJson<ServerMapping>(res, "update business mapping");
return toMapping(data);
}
@@ -4,9 +4,11 @@ import { Bot, ChevronRight, Download, Edit, Eye, FileText, Layers, MoreHorizonta
import React, { useEffect, useRef, useState } from 'react';
import { format } from 'date-fns';
import { useNavigate } from 'react-router-dom';
import { Col, Empty, Loading, Pagination, Progress, Row } from 'tdesign-react';
import { Col, Loading, Pagination, Progress, Row } from 'tdesign-react';
import { requestCrudService } from '@/service/request-axios-service';
import { ActionColumn } from './ActionColumn';
import { resolveStatusIcon } from './icons';
import { mockActionColumns, mockTaskList } from './actionMock';
interface ActionAnalyzesProps {
TaskType?: string;
@@ -68,6 +70,14 @@ export const ActionAnalyzes: React.FC<ActionAnalyzesProps> = ({ title, category,
await taskListSearch();
}
// 接口不可用 / 返回为空时回落到本地预览数据,方便联调看效果。
const fallbackToMock = () => {
setTaskList(mockTaskList);
setTotal(mockTaskList.length);
setActionColumnList(mockActionColumns);
if (mockTaskList[0]) setTaskId(mockTaskList[0].id);
};
// 任务查询
const taskListSearch = async () => {
const param = {
@@ -76,21 +86,27 @@ export const ActionAnalyzes: React.FC<ActionAnalyzesProps> = ({ title, category,
pageSize: state.current.pageSize
}
setTaskLoading(true);
const res = await service.post(`${zncmUrl}/action/cop-action-tasks`, param);
setTaskLoading(false);
console.log('任务搜索', res);
const result = res.data;
result.forEach((item: TaskListItem) => {
item.progress = item.endCount / item.sumAll || 0; // 获取已完成和未完成的占比
item.taskStatusData = taskStatusList[item.taskStatus] // 根据任务状态码获取相关信息
})
setTaskList(result || []); // 设置任务列表页
const totalRes = await service.post(`${zncmUrl}/action/cop-action-tasks-count`, param); // 查询任务总数
console.log('totalRes', totalRes)
setTotal(totalRes.data || 0); // 设置总数
if (result.length > 0) {
try {
const res = await service.post(`${zncmUrl}/action/cop-action-tasks`, param);
const result: TaskListItem[] = Array.isArray(res?.data) ? res.data : [];
if (result.length === 0) {
fallbackToMock();
return;
}
result.forEach((item: TaskListItem) => {
item.progress = item.endCount / item.sumAll || 0; // 获取已完成和未完成的占比
item.taskStatusData = taskStatusList[item.taskStatus] // 根据任务状态码获取相关信息
})
setTaskList(result); // 设置任务列表页
const totalRes = await service.post(`${zncmUrl}/action/cop-action-tasks-count`, param); // 查询任务总数
setTotal(totalRes?.data || result.length); // 设置总数
setTaskId(result[0].id);
await actionListSearch(result[0].id);
} catch (err) {
console.warn('任务搜索失败,使用本地预览数据', err);
fallbackToMock();
} finally {
setTaskLoading(false);
}
}
@@ -100,16 +116,23 @@ export const ActionAnalyzes: React.FC<ActionAnalyzesProps> = ({ title, category,
taskId: taskId,
}
setActionLoading(true);
const res = await service.get(`${zncmUrl}/action/cop-task-actions`, param);
console.log('行动查询', res);
setActionLoading(false);
const result = res.data;
setActionColumnList([
{ columnName: '待分析', icon: '待CEH.svg', actionList: result.collect || [] },
{ columnName: 'CEH中', icon: '进行中.svg', actionList: result.collect1 || [] },
{ columnName: 'CEH完成', icon: '完成.svg', actionList: result.collect2 || [] },
{ columnName: '已终止', icon: '已终止.svg', actionList: result.endDate || [] },
]);
try {
const res = await service.get(`${zncmUrl}/action/cop-task-actions`, param);
const result = res?.data ?? {};
const columns = [
{ columnName: '待分析', icon: '待CEH.svg', actionList: result.collect || [] },
{ columnName: 'CEH中', icon: '进行中.svg', actionList: result.collect1 || [] },
{ columnName: 'CEH完成', icon: '完成.svg', actionList: result.collect2 || [] },
{ columnName: '已终止', icon: '已终止.svg', actionList: result.endDate || [] },
];
const hasAny = columns.some((c) => c.actionList.length > 0);
setActionColumnList(hasAny ? columns : mockActionColumns);
} catch (err) {
console.warn('行动查询失败,使用本地预览数据', err);
setActionColumnList(mockActionColumns);
} finally {
setActionLoading(false);
}
}
const taskDataChange = async (taskId: number) => {
@@ -151,7 +174,7 @@ export const ActionAnalyzes: React.FC<ActionAnalyzesProps> = ({ title, category,
<div className="text-[var(--text-color)] text-[17px] mb-[5px] font-[500]">{item.taskName}</div>
<div className="text-[#7f7f7f] mb-[5px]">{format(new Date(item.taskStartDate), 'yyyy-MM-dd HH:mm:ss')}</div>
<div className="flex items-center mb-[8px]">
<img src={`/icon/${item?.taskStatusData?.icon}`} alt="" />
<img src={resolveStatusIcon(item?.taskStatusData?.icon)} className="w-[20px]" alt="" />
<span className="ml-[5px] leading-[16px]">{item?.taskStatusData?.label}</span>
</div>
<div className="pb-[10px] pt-[10px]">
@@ -195,7 +218,11 @@ export const ActionAnalyzes: React.FC<ActionAnalyzesProps> = ({ title, category,
))
}
{
actionColumnList.length === 0 && <Empty/>
actionColumnList.length === 0 && (
<div className={`col-span-4 flex items-center justify-center py-[60px] text-sm ${isDark ? 'text-slate-500' : 'text-[#bfbfbf]'}`}>
暂无数据
</div>
)
}
</div>
</Loading>
@@ -4,8 +4,9 @@ import { Bot, ChevronRight, Download, Edit, Eye, FileText, Layers, MoreHorizonta
import React, { useEffect, useRef, useState } from 'react';
import { format } from 'date-fns';
import { useNavigate } from 'react-router-dom';
import { Col, Empty, Pagination, Progress, Row } from 'tdesign-react';
import { Col, Pagination, Progress, Row } from 'tdesign-react';
import { requestCrudService } from '@/service/request-axios-service';
import { resolveStatusIcon } from './icons';
interface ActionColumnProps {
TaskType?: string;
@@ -66,7 +67,7 @@ export const ActionColumn: React.FC<ActionColumnProps> = ({ title, category, sub
<div className={`h-[calc(100vh-100px)] p-[10px] flex-1 ${isDark? 'dark-action-bg' : `bg-white action-bg${columnIndex} shadow-[2px_0px_6px_rgba(0,0,0,0.12)]`} rounded-[4px]`}>
<div className="mb-[10px]">
<div className="flex items-center mb-[20px]">
<img src={`/icon/${icon}`} className="w-[24px] mr-[10px]" alt="" />
<img src={resolveStatusIcon(icon)} className="w-[24px] mr-[10px]" alt="" />
<span className="text-[#222222] text-[18px] font-bold mr-[20px]">{columnName}</span>
<span className="text-[#222222] text-[18px] font-bold mr-[20px]">{actionCount}</span>
</div>
@@ -99,13 +100,11 @@ export const ActionColumn: React.FC<ActionColumnProps> = ({ title, category, sub
))
}
{
actionList.length === 0 && <Empty image={
<img
src="/icon/empty-list.png"
alt="empty"
className="w-[135px]"
/>
} />
actionList.length === 0 && (
<div className={`flex items-center justify-center py-[48px] text-sm ${isDark ? 'text-slate-500' : 'text-[#bfbfbf]'}`}>
暂无数据
</div>
)
}
</div>
</div>
@@ -4,10 +4,10 @@ import dayjs from 'dayjs';
import { DateRangePicker, DateRangePickerProps, DateRangeValue, Loading } from 'tdesign-react';
import { useNavigate } from 'react-router-dom';
import { useTheme } from '@/contexts/ThemeContext';
import { useChatContext } from '@/contexts/ChatContext';
import { requestCrudService } from '@/service/request-axios-service';
import { KanbanColumn } from './KanbanColumn';
import { TaskListItem } from './TaskManagement';
import { sentimentAnalyzeMock } from './kanbanMock';
interface TaskListPageProps {
title: string;
@@ -28,7 +28,6 @@ const zncmUrl = import.meta.env.VITE_API_BASE_URL || '';
const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategory }) => {
const { theme } = useTheme();
const { setStartConfig } = useChatContext();
const navigate = useNavigate();
const isDark = theme === 'dark';
@@ -80,6 +79,19 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
await taskDataChange();
};
const applyColumnData = (taskType: ColumnType, list: TaskListItem[], total: number) => {
if (taskType === 'unanalyzed') {
setUnanalyzedTotal(total);
setUnanalyzedList(list);
} else if (taskType === 'running') {
setRunningTotal(total);
setRunningList(list);
} else {
setCompletedTotal(total);
setCompletedList(list);
}
};
const taskListSearch = async (
taskType: ColumnType,
pageNum = 1,
@@ -107,18 +119,19 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
column: taskType,
taskLevel: item.taskLevel || 1,
}));
const total = Number(result.total ?? normalized.length);
if (taskType === 'unanalyzed') {
setUnanalyzedTotal(total);
setUnanalyzedList(normalized);
} else if (taskType === 'running') {
setRunningTotal(total);
setRunningList(normalized);
} else {
setCompletedTotal(total);
setCompletedList(normalized);
if (normalized.length === 0) {
// 接口无数据时回落到本地预览数据,方便联调看效果
const mock = sentimentAnalyzeMock[taskType];
applyColumnData(taskType, mock, mock.length);
return;
}
applyColumnData(taskType, normalized, Number(result.total ?? normalized.length));
} catch (err) {
console.warn('三情任务查询失败,使用本地预览数据', err);
const mock = sentimentAnalyzeMock[taskType];
applyColumnData(taskType, mock, mock.length);
} finally {
if (taskType === 'unanalyzed') setUnanalyzedLoading(false);
if (taskType === 'running') setRunningLoading(false);
@@ -153,23 +166,19 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
const handleOpenConsultation = () => {
const task = stateRef.current.task;
if (!task) return;
try {
const sceneItem = JSON.parse(task.scene || '{}');
if (sceneItem.taskJson) {
const taskJson = JSON.parse(sceneItem.taskJson);
setStartConfig({
...taskJson,
llm_preset_id: 'BAILING',
consistency_mode: 'soft',
phase_config: { diverge_rounds: 3 },
task_context: task.overview,
});
navigate('/qa-center/mope-chat');
}
} finally {
setShowAnalysisModal(false);
}
// 会商自动化串联(模块 C):跳转圆桌规划页,带上任务名称/描述 + 本页业务码(3Q=三情分析)。
// 圆桌据此自动开跑 Step 1,并在「进入研讨」时自动选用 3Q 配置的业务链(跳过选链弹窗)。
navigate('/page/strategy/qa/multi-agent-roundtable', {
state: {
roundtablePrefill: {
taskTitle: task.taskName,
taskGoal: task.overview,
businessCode: '3Q',
autoStart: true,
},
},
});
setShowAnalysisModal(false);
};
const handleKeyDown = async (event: React.KeyboardEvent<HTMLInputElement>) => {
@@ -4,10 +4,10 @@ import { DateRangePicker, DateRangePickerProps, DateRangeValue, Loading } from '
import dayjs from 'dayjs';
import { useNavigate } from 'react-router-dom';
import { useTheme } from '@/contexts/ThemeContext';
import { useChatContext } from '@/contexts/ChatContext';
import { requestCrudService } from '@/service/request-axios-service';
import { KanbanColumn } from './KanbanColumn';
import { TaskListItem } from './TaskManagement';
import { taskAnalyzeMock } from './kanbanMock';
interface TaskListPageProps {
title: string;
@@ -28,7 +28,6 @@ const zncmUrl = import.meta.env.VITE_API_BASE_URL || '';
const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategory }) => {
const { theme } = useTheme();
const { setStartConfig } = useChatContext();
const navigate = useNavigate();
const isDark = theme === 'dark';
@@ -80,6 +79,19 @@ const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategor
await taskDataChange();
};
const applyColumnData = (taskType: ColumnType, list: TaskListItem[], total: number) => {
if (taskType === 'unanalyzed') {
setUnanalyzedTotal(total);
setUnanalyzedList(list);
} else if (taskType === 'running') {
setRunningTotal(total);
setRunningList(list);
} else {
setCompletedTotal(total);
setCompletedList(list);
}
};
const taskListSearch = async (
taskType: ColumnType,
pageNum = 1,
@@ -107,18 +119,19 @@ const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategor
column: taskType,
taskLevel: item.taskLevel || 1,
}));
const total = Number(result.total ?? normalized.length);
if (taskType === 'unanalyzed') {
setUnanalyzedTotal(total);
setUnanalyzedList(normalized);
} else if (taskType === 'running') {
setRunningTotal(total);
setRunningList(normalized);
} else {
setCompletedTotal(total);
setCompletedList(normalized);
if (normalized.length === 0) {
// 接口无数据时回落到本地预览数据,方便联调看效果
const mock = taskAnalyzeMock[taskType];
applyColumnData(taskType, mock, mock.length);
return;
}
applyColumnData(taskType, normalized, Number(result.total ?? normalized.length));
} catch (err) {
console.warn('任务查询失败,使用本地预览数据', err);
const mock = taskAnalyzeMock[taskType];
applyColumnData(taskType, mock, mock.length);
} finally {
if (taskType === 'unanalyzed') setUnanalyzedLoading(false);
if (taskType === 'running') setRunningLoading(false);
@@ -153,23 +166,19 @@ const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategor
const handleOpenConsultation = () => {
const task = stateRef.current.task;
if (!task) return;
try {
const sceneItem = JSON.parse(task.scene || '{}');
if (sceneItem.taskJson) {
const taskJson = JSON.parse(sceneItem.taskJson);
setStartConfig({
...taskJson,
llm_preset_id: 'BAILING',
consistency_mode: 'soft',
phase_config: { diverge_rounds: 3 },
task_context: task.overview,
});
navigate('/qa-center/mope-chat');
}
} finally {
setShowAnalysisModal(false);
}
// 会商自动化串联(模块 C):跳转圆桌规划页,带上任务名称/描述 + 本页业务码(6BF=任务FX)。
// 圆桌据此自动开跑 Step 1,并在「进入研讨」时自动选用 6BF 配置的业务链(跳过选链弹窗)。
navigate('/page/strategy/qa/multi-agent-roundtable', {
state: {
roundtablePrefill: {
taskTitle: task.taskName,
taskGoal: task.overview,
businessCode: '6BF',
autoStart: true,
},
},
});
setShowAnalysisModal(false);
};
const handleKeyDown = async (event: React.KeyboardEvent<HTMLInputElement>) => {
@@ -236,9 +245,6 @@ const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategor
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-[rgba(96,165,250,0.28)] bg-[linear-gradient(180deg,rgba(22,119,255,0.28),rgba(14,63,135,0.18))] shadow-[0_10px_30px_rgba(14,63,135,0.24)]">
<Bot className="h-5 w-5 text-[#7CC9FF]" />
</div>
<div>
<h1 className={`text-[26px] font-semibold tracking-[0.02em] ${isDark ? 'text-slate-50' : 'text-[#222222]'}`}>
{title}
@@ -0,0 +1,130 @@
/**
* 行动 CE 看板的本地预览数据。
*
* 仅在接口不可用 / 返回为空时回落使用(见 ActionAnalyzes 的 catch 分支),
* 后端正常返回数据时不会用到。纯前端联调 / 演示效果用。
*/
import {
actionColumnItem,
ActionListItem,
TaskListItem,
taskStatusList,
} from "./TaskManagement";
const statusMap = taskStatusList as Record<string, TaskListItem["taskStatusData"]>;
/** 左侧任务列表 —— 覆盖「待分析 / 分析中 / 审核通过(完成) / 审核未通过」四种状态图标。 */
export const mockTaskList: TaskListItem[] = (
[
{
id: 9001,
taskName: "京津冀区域舆情联动处置专项",
taskStartDate: "2026-06-08 09:12:00",
taskStatus: 20,
endCount: 3,
sumAll: 8,
},
{
id: 9002,
taskName: "重点人群引导策略分析",
taskStartDate: "2026-06-07 14:30:00",
taskStatus: 10,
endCount: 0,
sumAll: 5,
},
{
id: 9003,
taskName: "阶段性成效复盘与归档",
taskStartDate: "2026-06-05 16:45:00",
taskStatus: 110,
endCount: 6,
sumAll: 6,
},
{
id: 9004,
taskName: "审核未通过任务回溯",
taskStartDate: "2026-06-04 11:10:00",
taskStatus: 50,
endCount: 2,
sumAll: 7,
},
] as Array<Partial<TaskListItem>>
).map((t) => ({
...t,
progress: t.sumAll ? (t.endCount as number) / (t.sumAll as number) : 0,
taskStatusData: statusMap[String(t.taskStatus)],
})) as unknown as TaskListItem[];
function action(partial: Partial<ActionListItem>): ActionListItem {
return partial as unknown as ActionListItem;
}
/** 右侧四栏行动 —— 列与 ActionAnalyzes 里真实构建的四栏一一对应。 */
export const mockActionColumns: actionColumnItem[] = [
{
columnName: "待分析",
icon: "待CEH.svg",
actionList: [
action({
id: 1101,
actionName: "梳理重点平台舆情数据,输出待分析清单",
level: 1,
deptName: "情报研判处",
actionStatus: 0,
}),
action({
id: 1102,
actionName: "采集竞品动向与受众反馈样本",
level: 2,
deptName: "数据采集组",
actionStatus: 0,
}),
],
},
{
columnName: "CEH中",
icon: "进行中.svg",
actionList: [
action({
id: 1201,
actionName: "开展多智能体会商,形成初步处置共识",
level: 1,
deptName: "策略策划处",
actionStatus: 1,
}),
],
},
{
columnName: "CEH完成",
icon: "完成.svg",
actionList: [
action({
id: 1301,
actionName: "完成作战方案撰写并通过审核",
level: 1,
deptName: "指挥协调中心",
actionStatus: 1,
}),
action({
id: 1302,
actionName: "归档阶段性成效报告并同步相关单位",
level: 2,
deptName: "综合保障组",
actionStatus: 1,
}),
],
},
{
columnName: "已终止",
icon: "已终止.svg",
actionList: [
action({
id: 1401,
actionName: "因优先级调整终止的预备行动",
level: 2,
deptName: "综合保障组",
actionStatus: 1,
}),
],
},
];
@@ -0,0 +1,30 @@
/**
* 行动 / 任务状态图标。
*
* 用 `import` 引入 SVG,让 Vite 把它们打进产物并生成带 hash 的最终 URL,
* 这样无论部署到子路径(base)还是带指纹的静态资源服务都能正确访问 ——
* 不能再用 `/icon/xxx.svg` 这种运行时绝对路径(部署后会 404)。
*
* key 沿用后端 / taskStatusList 里给的中文文件名,方便按名取用。
*/
import daiQianShou from "./待签收.svg";
import daiCEH from "./待CEH.svg";
import jinXingZhong from "./进行中.svg";
import wanCheng from "./完成.svg";
import weiTongGuo from "./未通过.svg";
import yiZhongZhi from "./已终止.svg";
export const statusIconMap: Record<string, string> = {
"待签收.svg": daiQianShou,
"待CEH.svg": daiCEH,
"进行中.svg": jinXingZhong,
"完成.svg": wanCheng,
"未通过.svg": weiTongGuo,
"已终止.svg": yiZhongZhi,
};
/** 按文件名取打包后的图标 URL;取不到返回 undefined(<img> 不渲染 broken icon)。 */
export function resolveStatusIcon(name?: string | null): string | undefined {
if (!name) return undefined;
return statusIconMap[name];
}
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="11" fill="#1eb07f"/>
<path d="M7.5 12.3 10.5 15.3 16.5 9" stroke="#fff" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 284 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="11" fill="#8c8c8c"/>
<rect x="8.7" y="8.7" width="6.6" height="6.6" rx="1.4" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 225 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="11" fill="#2f9acc"/>
<circle cx="11" cy="11" r="3.2" stroke="#fff" stroke-width="1.8" fill="none"/>
<path d="M13.4 13.4 16.2 16.2" stroke="#fff" stroke-width="1.8" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 325 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="11" fill="#0062d9"/>
<rect x="7.6" y="7.8" width="8.8" height="9.6" rx="1.6" stroke="#fff" stroke-width="1.5" fill="none"/>
<path d="M9.8 7.8v-.5a1 1 0 0 1 1-1h2.4a1 1 0 0 1 1 1v.5" stroke="#fff" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.8 11h4.4M9.8 13.6h2.8" stroke="#fff" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 507 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="11" fill="#cf1322"/>
<path d="M8.8 8.8 15.2 15.2M15.2 8.8 8.8 15.2" stroke="#fff" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 258 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="11" fill="#d97b00"/>
<path d="M10.2 8.4 15.4 12l-5.2 3.6z" fill="#fff" stroke="#fff" stroke-width="1.2" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 264 B

@@ -0,0 +1,148 @@
/**
* 看板(任务分析 / 三情分析)的本地预览数据。
*
* 仅在接口不可用 / 返回为空时回落使用(见各页 taskListSearch 的空数据 / catch 分支),
* 后端正常返回数据时不会用到。纯前端联调 / 演示效果用。
*/
import { TaskListItem } from "./TaskManagement";
export type KanbanColumnType = "unanalyzed" | "running" | "completed";
interface Seed {
taskName: string;
overview: string;
taskDirection: string;
taskType?: number; // 1单任务 2场景 3会商 4多智能体 5检索
taskLevel?: number; // 1一般 2紧急 3加急
createTime?: string;
updateTime?: string;
}
function buildTask(seed: Seed, column: KanbanColumnType, id: number): TaskListItem {
return {
id,
column,
taskName: seed.taskName,
overview: seed.overview,
taskDirection: seed.taskDirection,
taskType: seed.taskType ?? 1,
taskLevel: seed.taskLevel ?? 1,
createTime: seed.createTime ?? "2026-06-08 09:30:00",
updateTime: seed.updateTime ?? "2026-06-09 10:15:00",
scene: "",
} as unknown as TaskListItem;
}
function makeMock(
baseId: number,
seeds: Record<KanbanColumnType, Seed[]>,
): Record<KanbanColumnType, TaskListItem[]> {
let id = baseId;
const build = (col: KanbanColumnType) => seeds[col].map((s) => buildTask(s, col, (id += 1)));
return {
unanalyzed: build("unanalyzed"),
running: build("running"),
completed: build("completed"),
};
}
/** 任务分析(plan-task-fx)预览数据。 */
export const taskAnalyzeMock = makeMock(7100, {
unanalyzed: [
{
taskName: "区域目标人群拆解专项",
overview: "对重点区域目标人群进行分层拆解,明确触达优先级与切入口。",
taskDirection: "策略策划处",
taskType: 4,
taskLevel: 2,
createTime: "2026-06-09 08:20:00",
updateTime: "2026-06-09 08:20:00",
},
{
taskName: "竞品动向监测立项",
overview: "梳理同类项目近期动向,输出可对照的策略阻力清单。",
taskDirection: "情报研判处",
taskType: 5,
taskLevel: 1,
createTime: "2026-06-08 16:45:00",
updateTime: "2026-06-08 17:02:00",
},
],
running: [
{
taskName: "阶段目标可行性会商",
overview: "多智能体围绕阶段目标的可达性与风险点开展讨论并形成初步结论。",
taskDirection: "指挥协调中心",
taskType: 3,
taskLevel: 3,
createTime: "2026-06-07 10:10:00",
updateTime: "2026-06-09 09:40:00",
},
],
completed: [
{
taskName: "重点议题策略路径分析",
overview: "完成重点议题的策略路径推演,已形成可执行的行动建议。",
taskDirection: "策略策划处",
taskType: 2,
taskLevel: 1,
createTime: "2026-06-04 14:00:00",
updateTime: "2026-06-06 11:25:00",
},
{
taskName: "受众态度归因复盘",
overview: "对上一阶段受众态度变化进行归因复盘,沉淀经验供后续复用。",
taskDirection: "综合保障组",
taskType: 1,
taskLevel: 2,
createTime: "2026-06-03 09:15:00",
updateTime: "2026-06-05 18:30:00",
},
],
});
/** 三情分析(plan-task-sentiment)预览数据。 */
export const sentimentAnalyzeMock = makeMock(7200, {
unanalyzed: [
{
taskName: "重点平台情绪监测专项",
overview: "采集重点平台舆论样本,识别情绪倾向与潜在风险信号。",
taskDirection: "情报研判处",
taskType: 5,
taskLevel: 2,
createTime: "2026-06-09 07:50:00",
updateTime: "2026-06-09 07:50:00",
},
{
taskName: "舆情走势预研立项",
overview: "围绕热点事件开展舆情走势预研,输出三情研判初判结论。",
taskDirection: "策略策划处",
taskType: 1,
taskLevel: 1,
createTime: "2026-06-08 13:20:00",
updateTime: "2026-06-08 13:48:00",
},
],
running: [
{
taskName: "群体情绪共识会商",
overview: "多智能体从社情、舆情、敌情多视角讨论群体情绪态势并形成共识。",
taskDirection: "指挥协调中心",
taskType: 3,
taskLevel: 3,
createTime: "2026-06-07 11:30:00",
updateTime: "2026-06-09 10:05:00",
},
],
completed: [
{
taskName: "阶段性情绪研判归档",
overview: "完成阶段性三情研判,结论已通过审核并归档同步相关单位。",
taskDirection: "综合保障组",
taskType: 2,
taskLevel: 1,
createTime: "2026-06-02 15:10:00",
updateTime: "2026-06-05 16:40:00",
},
],
});
+1 -1
View File
@@ -17,7 +17,7 @@ SERPER_API_KEY=your-serper-api-key
# AUTH_JWT_SECRET must stay stable across restarts, or existing logins expire.
# GATEWAY_CORS_ORIGINS must include the exact frontend origins, separated by commas.
AUTH_JWT_SECRET=i3_BiBK44sYHiFIkChhDs46y57ZewJKO3diQDw01anI
GATEWAY_CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:5174,http://127.0.0.1:5174,http://localhost:4200
GATEWAY_CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:5183,http://localhost:5174,http://127.0.0.1:5174,http://localhost:4200
GATEWAY_CORS_ALLOW_ALL=0
GATEWAY_ENABLE_DOCS=true
GATEWAY_WORKERS=2
+760
View File
@@ -0,0 +1,760 @@
# This file was autogenerated by uv via the following command:
# uv export --no-hashes --no-emit-project --no-dev -o _reqs.txt
-e ./packages/harness
# via deer-flow
agent-client-protocol==0.10.1
# via deerflow-harness
agent-sandbox==0.0.30
# via deerflow-harness
aiohappyeyeballs==2.6.2
# via aiohttp
aiohttp==3.14.1
# via
# aiohttp-retry
# dingtalk-stream
# firecrawl-py
# hindsight-client
# kubernetes
# wecom-aibot-python-sdk
aiohttp-retry==2.9.1
# via hindsight-client
aiosignal==1.4.0
# via aiohttp
aiosqlite==0.22.1
# via
# deerflow-harness
# langgraph-checkpoint-sqlite
alembic==1.18.4
# via deerflow-harness
annotated-doc==0.0.4
# via fastapi
annotated-types==0.7.0
# via pydantic
anthropic==0.107.1
# via langchain-anthropic
anyio==4.13.0
# via
# anthropic
# google-genai
# httpx
# mcp
# openai
# sse-starlette
# starlette
# watchfiles
asyncmy==0.2.11
# via deer-flow
attrs==26.1.0
# via
# aiohttp
# jsonschema
# referencing
audioop-lts==0.2.2 ; python_full_version >= '3.13'
# via
# speechrecognition
# standard-aifc
azure-ai-documentintelligence==1.0.2
# via markitdown
azure-core==1.41.0
# via
# azure-ai-documentintelligence
# azure-identity
azure-identity==1.25.3
# via markitdown
backoff==2.2.1
# via langfuse
bcrypt==5.0.0
# via deer-flow
beautifulsoup4==4.15.0
# via
# markdownify
# markitdown
# readabilipy
blockbuster==1.5.26
# via langgraph-runtime-inmem
brotli==1.2.0 ; platform_python_implementation == 'CPython'
# via httpx
brotlicffi==1.2.0.1 ; platform_python_implementation != 'CPython'
# via httpx
certifi==2026.5.20
# via
# httpcore
# httpx
# kubernetes
# requests
# volcengine-python-sdk
# wecom-aibot-python-sdk
cffi==2.0.0
# via
# brotlicffi
# cryptography
charset-normalizer==3.4.7
# via
# markitdown
# pdfminer-six
# requests
click==8.4.1
# via
# ddgs
# langgraph-cli
# magika
# uvicorn
cloudpickle==3.1.2
# via langgraph-api
cobble==0.1.4
# via mammoth
colorama==0.4.6 ; sys_platform == 'win32'
# via
# click
# tqdm
# uvicorn
coloredlogs==15.0.1
# via onnxruntime
croniter==6.2.2
# via
# deer-flow
# langgraph-runtime-inmem
cryptography==48.0.0
# via
# azure-identity
# google-auth
# langgraph-api
# msal
# pdfminer-six
# pyjwt
# wecom-aibot-python-sdk
ddgs==9.14.4
# via deerflow-harness
defusedxml==0.7.1
# via
# markitdown
# youtube-transcript-api
dingtalk-stream==0.24.3
# via deer-flow
distro==1.9.0
# via
# anthropic
# google-genai
# openai
dnspython==2.8.0
# via email-validator
docstring-parser==0.18.0
# via anthropic
dotenv==0.9.9
# via deerflow-harness
duckdb==1.5.3
# via deerflow-harness
durationpy==0.10
# via kubernetes
email-validator==2.3.0
# via deer-flow
et-xmlfile==2.0.0
# via openpyxl
exa-py==2.13.0
# via deerflow-harness
fake-useragent==2.2.0
# via ddgs
fastapi==0.136.3
# via deer-flow
filetype==1.2.0
# via langchain-google-genai
firecrawl-py==4.28.2
# via deerflow-harness
flatbuffers==25.12.19
# via onnxruntime
forbiddenfruit==0.1.4 ; implementation_name == 'cpython'
# via blockbuster
frozenlist==1.8.0
# via
# aiohttp
# aiosignal
google-auth==2.53.0
# via google-genai
google-genai==2.8.0
# via langchain-google-genai
googleapis-common-protos==1.75.0
# via opentelemetry-exporter-otlp-proto-http
greenlet==3.5.1
# via sqlalchemy
grpcio==1.80.0
# via
# grpcio-health-checking
# grpcio-tools
# langgraph-api
grpcio-health-checking==1.80.0
# via langgraph-api
grpcio-tools==1.80.0
# via langgraph-api
h11==0.16.0
# via
# httpcore
# uvicorn
h2==4.3.0
# via httpx
hindsight-client==0.7.2
# via deer-flow
hpack==4.1.0
# via h2
html5lib==1.1
# via readabilipy
httpcore==1.0.9
# via
# exa-py
# httpx
# python-telegram-bot
httptools==0.8.0
# via
# langgraph-api
# uvicorn
httpx==0.28.1
# via
# agent-sandbox
# anthropic
# ddgs
# deer-flow
# deerflow-harness
# exa-py
# firecrawl-py
# google-genai
# langfuse
# langgraph-api
# langgraph-cli
# langgraph-sdk
# langsmith
# lark-oapi
# mcp
# openai
# python-telegram-bot
# tavily-python
httpx-sse==0.4.3
# via mcp
humanfriendly==10.0
# via coloredlogs
hyperframe==6.1.0
# via h2
idna==3.18
# via
# anyio
# email-validator
# httpx
# requests
# yarl
isodate==0.7.2
# via azure-ai-documentintelligence
jiter==0.15.0
# via
# anthropic
# openai
jsonpatch==1.33
# via langchain-core
jsonpointer==3.1.1
# via jsonpatch
jsonschema==4.26.0
# via mcp
jsonschema-rs==0.44.1
# via langgraph-api
jsonschema-specifications==2025.9.1
# via jsonschema
kubernetes==36.0.2
# via deerflow-harness
langchain==1.3.4
# via deerflow-harness
langchain-anthropic==1.4.4
# via deerflow-harness
langchain-core==1.4.1
# via
# langchain
# langchain-anthropic
# langchain-deepseek
# langchain-google-genai
# langchain-mcp-adapters
# langchain-openai
# langgraph
# langgraph-api
# langgraph-checkpoint
# langgraph-prebuilt
# langgraph-sdk
langchain-deepseek==1.1.0
# via deerflow-harness
langchain-google-genai==4.2.4
# via deerflow-harness
langchain-mcp-adapters==0.2.2
# via deerflow-harness
langchain-openai==1.2.2
# via
# deerflow-harness
# langchain-deepseek
langchain-protocol==0.0.16
# via
# langchain-core
# langgraph-api
# langgraph-sdk
langfuse==4.7.1
# via deerflow-harness
langgraph==1.2.4
# via
# deerflow-harness
# langchain
# langgraph-api
# langgraph-runtime-inmem
langgraph-api==0.9.0
# via deerflow-harness
langgraph-checkpoint==4.1.1
# via
# langgraph
# langgraph-api
# langgraph-checkpoint-postgres
# langgraph-checkpoint-sqlite
# langgraph-prebuilt
# langgraph-runtime-inmem
langgraph-checkpoint-postgres==3.1.0
# via deerflow-harness
langgraph-checkpoint-sqlite==3.1.0
# via deerflow-harness
langgraph-cli==0.4.27
# via deerflow-harness
langgraph-prebuilt==1.1.0
# via langgraph
langgraph-runtime-inmem==0.29.0
# via
# deerflow-harness
# langgraph-api
langgraph-sdk==0.4.2
# via
# deer-flow
# deerflow-harness
# langgraph
# langgraph-api
# langgraph-cli
langsmith==0.8.9
# via
# langchain-core
# langgraph-api
lark-oapi==1.6.8
# via deer-flow
lxml==6.1.1
# via
# ddgs
# markitdown
# python-pptx
# readabilipy
magika==0.6.3
# via markitdown
mako==1.3.12
# via alembic
mammoth==1.11.0
# via markitdown
markdown-to-mrkdwn==0.3.3
# via deer-flow
markdownify==1.2.2
# via
# deerflow-harness
# markitdown
markitdown==0.1.5
# via deerflow-harness
markupsafe==3.0.3
# via mako
mcp==1.27.2
# via langchain-mcp-adapters
mpmath==1.3.0
# via sympy
msal==1.37.0
# via
# azure-identity
# msal-extensions
msal-extensions==1.3.1
# via azure-identity
multidict==6.7.1
# via
# aiohttp
# yarl
nest-asyncio==1.6.0
# via firecrawl-py
numpy==2.4.6
# via
# magika
# onnxruntime
# pandas
oauthlib==3.3.1
# via requests-oauthlib
olefile==0.47
# via markitdown
onnxruntime==1.20.1
# via magika
openai==2.41.0
# via
# exa-py
# langchain-openai
openpyxl==3.1.5
# via markitdown
opentelemetry-api==1.42.1
# via
# langfuse
# langgraph-api
# langsmith
# opentelemetry-exporter-otlp-proto-http
# opentelemetry-sdk
# opentelemetry-semantic-conventions
opentelemetry-exporter-otlp-proto-common==1.42.1
# via opentelemetry-exporter-otlp-proto-http
opentelemetry-exporter-otlp-proto-http==1.42.1
# via
# langfuse
# langgraph-api
# langsmith
opentelemetry-proto==1.42.1
# via
# opentelemetry-exporter-otlp-proto-common
# opentelemetry-exporter-otlp-proto-http
opentelemetry-sdk==1.42.1
# via
# langfuse
# langgraph-api
# langsmith
# opentelemetry-exporter-otlp-proto-http
opentelemetry-semantic-conventions==0.63b1
# via opentelemetry-sdk
orjson==3.11.9
# via
# langgraph-api
# langgraph-checkpoint-postgres
# langgraph-sdk
# langsmith
ormsgpack==1.12.2
# via langgraph-checkpoint
packaging==26.2
# via
# langchain-core
# langfuse
# langsmith
# onnxruntime
pandas==3.0.3
# via markitdown
pathspec==1.1.1
# via langgraph-cli
pdfminer-six==20251230
# via
# markitdown
# pdfplumber
pdfplumber==0.11.9
# via markitdown
pillow==12.2.0
# via
# pdfplumber
# python-pptx
primp==1.3.1
# via ddgs
propcache==0.5.2
# via
# aiohttp
# yarl
protobuf==6.33.6
# via
# googleapis-common-protos
# grpcio-health-checking
# grpcio-tools
# langgraph-api
# onnxruntime
# opentelemetry-proto
psycopg==3.3.4
# via
# deerflow-harness
# langgraph-checkpoint-postgres
psycopg-binary==3.3.4 ; implementation_name != 'pypy'
# via psycopg
psycopg-pool==3.3.1
# via
# deerflow-harness
# langgraph-checkpoint-postgres
pyasn1==0.6.3
# via pyasn1-modules
pyasn1-modules==0.4.2
# via google-auth
pycparser==3.0 ; implementation_name != 'PyPy'
# via cffi
pycryptodome==3.23.0
# via lark-oapi
pydantic==2.13.4
# via
# agent-client-protocol
# agent-sandbox
# anthropic
# deerflow-harness
# exa-py
# fastapi
# firecrawl-py
# google-genai
# hindsight-client
# langchain
# langchain-anthropic
# langchain-core
# langchain-google-genai
# langfuse
# langgraph
# langsmith
# mcp
# openai
# pydantic-settings
pydantic-core==2.46.4
# via pydantic
pydantic-settings==2.14.1
# via mcp
pydub==0.25.1
# via markitdown
pyee==13.0.1
# via wecom-aibot-python-sdk
pyjwt==2.13.0
# via
# deer-flow
# langgraph-api
# mcp
# msal
pypdfium2==5.9.0
# via pdfplumber
pyreadline3==3.5.6 ; sys_platform == 'win32'
# via humanfriendly
python-dateutil==2.9.0.post0
# via
# croniter
# hindsight-client
# kubernetes
# pandas
# volcengine-python-sdk
python-dotenv==1.2.2
# via
# dotenv
# exa-py
# firecrawl-py
# langgraph-cli
# magika
# pydantic-settings
# uvicorn
python-multipart==0.0.32
# via
# deer-flow
# mcp
python-pptx==1.0.2
# via markitdown
python-telegram-bot==22.7
# via deer-flow
pywin32==312 ; sys_platform == 'win32'
# via mcp
pyyaml==6.0.3
# via
# deerflow-harness
# kubernetes
# langchain-core
# uvicorn
readabilipy==0.3.0
# via deerflow-harness
referencing==0.37.0
# via
# jsonschema
# jsonschema-specifications
regex==2026.5.9
# via
# readabilipy
# tiktoken
requests==2.34.2
# via
# azure-core
# dingtalk-stream
# exa-py
# firecrawl-py
# google-auth
# google-genai
# kubernetes
# langsmith
# lark-oapi
# markitdown
# msal
# opentelemetry-exporter-otlp-proto-http
# requests-oauthlib
# requests-toolbelt
# tavily-python
# tiktoken
# youtube-transcript-api
requests-oauthlib==2.0.0
# via kubernetes
requests-toolbelt==1.0.0
# via
# langsmith
# lark-oapi
rpds-py==2026.5.1
# via
# jsonschema
# referencing
setuptools==82.0.1
# via grpcio-tools
six==1.17.0
# via
# html5lib
# kubernetes
# markdownify
# python-dateutil
# volcengine-python-sdk
slack-sdk==3.42.0
# via deer-flow
sniffio==1.3.1
# via
# anthropic
# google-genai
# openai
socksio==1.0.0
# via httpx
soupsieve==2.8.4
# via beautifulsoup4
speechrecognition==3.16.1
# via markitdown
sqlalchemy==2.0.50
# via
# alembic
# deerflow-harness
sqlite-vec==0.1.9
# via langgraph-checkpoint-sqlite
sse-starlette==3.3.4
# via
# deer-flow
# langgraph-api
# langgraph-runtime-inmem
# mcp
standard-aifc==3.13.0 ; python_full_version >= '3.13'
# via speechrecognition
standard-chunk==3.13.0 ; python_full_version >= '3.13'
# via standard-aifc
starlette==1.2.1
# via
# fastapi
# langgraph-api
# langgraph-runtime-inmem
# mcp
# sse-starlette
structlog==25.5.0
# via
# langgraph-api
# langgraph-runtime-inmem
sympy==1.14.0
# via onnxruntime
tavily-python==0.7.25
# via deerflow-harness
tenacity==9.1.4
# via
# google-genai
# langchain-core
# langgraph-api
tiktoken==0.13.0
# via
# deerflow-harness
# langchain-openai
# tavily-python
tqdm==4.68.1
# via openai
truststore==0.10.4
# via langgraph-api
typing-extensions==4.15.0
# via
# aiohttp
# aiosignal
# alembic
# anthropic
# anyio
# azure-ai-documentintelligence
# azure-core
# azure-identity
# beautifulsoup4
# exa-py
# fastapi
# google-genai
# grpcio
# hindsight-client
# langchain-core
# langchain-mcp-adapters
# langchain-protocol
# mcp
# openai
# opentelemetry-api
# opentelemetry-exporter-otlp-proto-http
# opentelemetry-sdk
# opentelemetry-semantic-conventions
# psycopg
# psycopg-pool
# pydantic
# pydantic-core
# pyee
# python-pptx
# referencing
# speechrecognition
# sqlalchemy
# starlette
# typing-inspection
typing-inspection==0.4.2
# via
# fastapi
# mcp
# pydantic
# pydantic-settings
tzdata==2026.2 ; sys_platform == 'emscripten' or sys_platform == 'win32'
# via
# pandas
# psycopg
urllib3==2.7.0
# via
# hindsight-client
# kubernetes
# requests
# volcengine-python-sdk
uuid-utils==0.16.0
# via
# langchain-core
# langgraph-api
# langsmith
uvicorn==0.49.0
# via
# deer-flow
# langgraph-api
# mcp
uvloop==0.22.1 ; sys_platform != 'win32'
# via
# langgraph-api
# uvicorn
volcengine-python-sdk==5.0.33
# via agent-sandbox
watchfiles==1.2.0
# via
# langgraph-api
# uvicorn
webencodings==0.5.1
# via html5lib
websocket-client==1.9.0
# via kubernetes
websockets==15.0.1
# via
# dingtalk-stream
# firecrawl-py
# google-genai
# langgraph-api
# langgraph-sdk
# langsmith
# lark-oapi
# uvicorn
# wecom-aibot-python-sdk
wecom-aibot-python-sdk==1.0.2
# via deer-flow
wrapt==1.17.3
# via langfuse
xlrd==2.0.2
# via markitdown
xlsxwriter==3.2.9
# via python-pptx
xxhash==3.7.0
# via
# langgraph
# langsmith
yarl==1.24.2
# via aiohttp
youtube-transcript-api==1.0.3
# via markitdown
zstandard==0.25.0
# via
# langgraph-api
# langsmith
@@ -30,9 +30,10 @@ from app.gateway.routers import (
artifacts,
assistants_compat,
auth,
channels,
checkpoint_migration,
feedback,
business_mapping,
channels,
checkpoint_migration,
feedback,
html_page_favorites,
intent,
llm_metrics,
@@ -41,6 +42,8 @@ from app.gateway.routers import (
models,
multi_agent,
notifications,
parallel_agent_tasks,
parallel_agents,
public_embed,
public_scheduled_tasks,
recommend,
@@ -378,7 +381,7 @@ def _release_singleton_lock(handle) -> None:
pass
async def _curator_scheduler_loop() -> None:
async def _curator_scheduler_loop() -> None:
"""常驻后台任务:每隔 _CURATOR_CHECK_INTERVAL_SECONDS 检查一次需不需要进化。
多 worker 部署下用文件锁做 leader 选举 —— 只有抢到锁的那个 worker 进程真正
@@ -404,248 +407,248 @@ async def _curator_scheduler_loop() -> None:
except Exception:
logger.exception("Curator scheduler tick failed (non-fatal)")
await asyncio.sleep(_CURATOR_CHECK_INTERVAL_SECONDS)
finally:
_release_singleton_lock(lock_handle)
def _env_bool(name: str, default: bool) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return raw.strip().lower() not in {"0", "false", "no", "off"}
def _env_int(name: str, default: int, *, alias: str | None = None, minimum: int = 1) -> int:
raw = os.getenv(name)
if raw is None and alias:
raw = os.getenv(alias)
if raw is None:
raw = str(default)
try:
value = int(raw)
except ValueError:
return default
return max(minimum, value)
def _env_float(name: str, default: float, *, alias: str | None = None, minimum: float = 0.0) -> float:
raw = os.getenv(name)
if raw is None and alias:
raw = os.getenv(alias)
if raw is None:
raw = str(default)
try:
value = float(raw)
except ValueError:
return default
return max(minimum, value)
_LEADERBOARD_SNAPSHOT_BACKGROUND_ENABLED = _env_bool("ADMIN_LEADERBOARD_BACKGROUND_ENABLED", True)
_LEADERBOARD_SNAPSHOT_PREWARM_DAYS = _env_int(
"ADMIN_LEADERBOARD_BACKGROUND_PREWARM_DAYS",
30,
)
_LEADERBOARD_SNAPSHOT_BATCH_SIZE = _env_int(
"ADMIN_LEADERBOARD_BACKGROUND_BATCH_SIZE",
1,
)
_LEADERBOARD_SNAPSHOT_INTERVAL_SECONDS = _env_float(
"ADMIN_LEADERBOARD_BACKGROUND_INTERVAL_SECONDS",
300.0,
)
_LEADERBOARD_SNAPSHOT_SLEEP_SECONDS = _env_float(
"ADMIN_LEADERBOARD_BACKGROUND_SLEEP_SECONDS",
2.0,
)
_LEADERBOARD_SNAPSHOT_RUNNING_TTL_SECONDS = _env_int(
"ADMIN_LEADERBOARD_BACKGROUND_RUNNING_TTL_SECONDS",
1800,
)
_LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS = _env_float(
"ADMIN_LEADERBOARD_BACKGROUND_STARTUP_DELAY_SECONDS",
60.0,
)
def _recent_leaderboard_stat_dates(days: int) -> list[str]:
from datetime import datetime, timedelta
from deerflow.persistence.types import BEIJING_TZ
today = datetime.now(BEIJING_TZ).date()
start = today - timedelta(days=max(1, days) - 1)
return [(start + timedelta(days=i)).isoformat() for i in range((today - start).days + 1)]
async def _leaderboard_snapshot_due_dates(
session_factory,
*,
settings_hash: str,
limit: int,
running_ttl_seconds: int,
) -> list[str]:
from datetime import UTC, datetime, timedelta
from sqlalchemy import and_, or_, select
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
cutoff = datetime.now(UTC) - timedelta(seconds=running_ttl_seconds)
async with session_factory() as session:
rows = (
await session.execute(
select(AdminLeaderboardDailyStatRow.stat_date)
.where(
AdminLeaderboardDailyStatRow.settings_hash == settings_hash,
or_(
AdminLeaderboardDailyStatRow.status.in_(["queued", "error"]),
and_(
AdminLeaderboardDailyStatRow.status == "running",
AdminLeaderboardDailyStatRow.updated_at < cutoff,
),
),
)
.order_by(AdminLeaderboardDailyStatRow.stat_date.desc())
.limit(limit)
)
).all()
return [str(row[0]) for row in rows]
async def _claim_leaderboard_snapshot(
session_factory,
*,
stat_date: str,
settings_hash: str,
running_ttl_seconds: int,
) -> bool:
from datetime import UTC, datetime
from app.gateway.routers.admin_users import _snapshot_is_fresh
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
from deerflow.persistence.types import BEIJING_TZ
async with session_factory() as session:
row = await session.get(
AdminLeaderboardDailyStatRow,
{"stat_date": stat_date, "settings_hash": settings_hash},
)
if row is None or _snapshot_is_fresh(row, stat_date):
return False
if row.status == "running":
updated_at = row.updated_at
if isinstance(updated_at, datetime):
if updated_at.tzinfo is None:
updated_at = updated_at.replace(tzinfo=BEIJING_TZ)
age = (datetime.now(BEIJING_TZ) - updated_at.astimezone(BEIJING_TZ)).total_seconds()
if age < running_ttl_seconds:
return False
row.status = "running"
row.message = "排行榜后台统计任务已认领。"
row.error = None
row.updated_at = datetime.now(UTC)
await session.commit()
return True
async def _run_leaderboard_snapshot_tick() -> tuple[int, int]:
from app.gateway.routers.admin_users import (
_daily_job_key,
_queue_daily_leaderboard_snapshots,
_refresh_daily_leaderboard_snapshot,
_resolve_leaderboard_snapshot_scope,
)
from deerflow.config.system_settings import load_system_settings
from deerflow.persistence.engine import get_session_factory
session_factory = get_session_factory()
if session_factory is None:
return 0, 0
settings = load_system_settings().leaderboard
excluded, include_scheduled, include_admins, include_failed, settings_hash = _resolve_leaderboard_snapshot_scope(settings)
stat_dates = _recent_leaderboard_stat_dates(_LEADERBOARD_SNAPSHOT_PREWARM_DAYS)
queued_dates = await _queue_daily_leaderboard_snapshots(
session_factory,
stat_dates,
settings_hash,
message="排行榜后台统计任务定期预热/补算队列。",
)
due_dates = await _leaderboard_snapshot_due_dates(
session_factory,
settings_hash=settings_hash,
limit=_LEADERBOARD_SNAPSHOT_BATCH_SIZE,
running_ttl_seconds=_LEADERBOARD_SNAPSHOT_RUNNING_TTL_SECONDS,
)
processed = 0
for stat_date in due_dates:
claimed = await _claim_leaderboard_snapshot(
session_factory,
stat_date=stat_date,
settings_hash=settings_hash,
running_ttl_seconds=_LEADERBOARD_SNAPSHOT_RUNNING_TTL_SECONDS,
)
if not claimed:
continue
logger.info("Leaderboard snapshot scheduler: building date=%s hash=%s", stat_date, settings_hash[:10])
await _refresh_daily_leaderboard_snapshot(
_daily_job_key(stat_date, settings_hash),
session_factory=session_factory,
settings=settings,
stat_date=stat_date,
settings_hash=settings_hash,
excluded=excluded,
include_scheduled=include_scheduled,
include_admins=include_admins,
include_failed=include_failed,
)
processed += 1
if _LEADERBOARD_SNAPSHOT_SLEEP_SECONDS > 0 and processed < len(due_dates):
await asyncio.sleep(_LEADERBOARD_SNAPSHOT_SLEEP_SECONDS)
return processed, len(queued_dates)
async def _leaderboard_snapshot_scheduler_loop() -> None:
"""Background task inside the existing gateway process for daily snapshots."""
if not _LEADERBOARD_SNAPSHOT_BACKGROUND_ENABLED:
logger.info("Leaderboard snapshot scheduler disabled by ADMIN_LEADERBOARD_BACKGROUND_ENABLED")
return
from deerflow.config.runtime_paths import runtime_home
lock_handle = _acquire_singleton_lock(runtime_home() / ".leaderboard_snapshot_scheduler.lock")
if lock_handle is None:
logger.info("Leaderboard snapshot scheduler: another gateway worker holds the lock; this worker skips")
return
logger.info(
"Leaderboard snapshot scheduler started: prewarm_days=%d batch_size=%d interval=%.1fs",
_LEADERBOARD_SNAPSHOT_PREWARM_DAYS,
_LEADERBOARD_SNAPSHOT_BATCH_SIZE,
_LEADERBOARD_SNAPSHOT_INTERVAL_SECONDS,
)
try:
if _LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS > 0:
await asyncio.sleep(_LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS)
while True:
try:
processed, queued = await _run_leaderboard_snapshot_tick()
if processed or queued:
logger.info(
"Leaderboard snapshot scheduler tick: queued=%d processed=%d",
queued,
processed,
)
except Exception:
logger.exception("Leaderboard snapshot scheduler tick failed (non-fatal)")
await asyncio.sleep(_LEADERBOARD_SNAPSHOT_INTERVAL_SECONDS)
finally:
_release_singleton_lock(lock_handle)
def _warn_if_multi_worker() -> None:
finally:
_release_singleton_lock(lock_handle)
def _env_bool(name: str, default: bool) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return raw.strip().lower() not in {"0", "false", "no", "off"}
def _env_int(name: str, default: int, *, alias: str | None = None, minimum: int = 1) -> int:
raw = os.getenv(name)
if raw is None and alias:
raw = os.getenv(alias)
if raw is None:
raw = str(default)
try:
value = int(raw)
except ValueError:
return default
return max(minimum, value)
def _env_float(name: str, default: float, *, alias: str | None = None, minimum: float = 0.0) -> float:
raw = os.getenv(name)
if raw is None and alias:
raw = os.getenv(alias)
if raw is None:
raw = str(default)
try:
value = float(raw)
except ValueError:
return default
return max(minimum, value)
_LEADERBOARD_SNAPSHOT_BACKGROUND_ENABLED = _env_bool("ADMIN_LEADERBOARD_BACKGROUND_ENABLED", True)
_LEADERBOARD_SNAPSHOT_PREWARM_DAYS = _env_int(
"ADMIN_LEADERBOARD_BACKGROUND_PREWARM_DAYS",
30,
)
_LEADERBOARD_SNAPSHOT_BATCH_SIZE = _env_int(
"ADMIN_LEADERBOARD_BACKGROUND_BATCH_SIZE",
1,
)
_LEADERBOARD_SNAPSHOT_INTERVAL_SECONDS = _env_float(
"ADMIN_LEADERBOARD_BACKGROUND_INTERVAL_SECONDS",
300.0,
)
_LEADERBOARD_SNAPSHOT_SLEEP_SECONDS = _env_float(
"ADMIN_LEADERBOARD_BACKGROUND_SLEEP_SECONDS",
2.0,
)
_LEADERBOARD_SNAPSHOT_RUNNING_TTL_SECONDS = _env_int(
"ADMIN_LEADERBOARD_BACKGROUND_RUNNING_TTL_SECONDS",
1800,
)
_LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS = _env_float(
"ADMIN_LEADERBOARD_BACKGROUND_STARTUP_DELAY_SECONDS",
60.0,
)
def _recent_leaderboard_stat_dates(days: int) -> list[str]:
from datetime import datetime, timedelta
from deerflow.persistence.types import BEIJING_TZ
today = datetime.now(BEIJING_TZ).date()
start = today - timedelta(days=max(1, days) - 1)
return [(start + timedelta(days=i)).isoformat() for i in range((today - start).days + 1)]
async def _leaderboard_snapshot_due_dates(
session_factory,
*,
settings_hash: str,
limit: int,
running_ttl_seconds: int,
) -> list[str]:
from datetime import UTC, datetime, timedelta
from sqlalchemy import and_, or_, select
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
cutoff = datetime.now(UTC) - timedelta(seconds=running_ttl_seconds)
async with session_factory() as session:
rows = (
await session.execute(
select(AdminLeaderboardDailyStatRow.stat_date)
.where(
AdminLeaderboardDailyStatRow.settings_hash == settings_hash,
or_(
AdminLeaderboardDailyStatRow.status.in_(["queued", "error"]),
and_(
AdminLeaderboardDailyStatRow.status == "running",
AdminLeaderboardDailyStatRow.updated_at < cutoff,
),
),
)
.order_by(AdminLeaderboardDailyStatRow.stat_date.desc())
.limit(limit)
)
).all()
return [str(row[0]) for row in rows]
async def _claim_leaderboard_snapshot(
session_factory,
*,
stat_date: str,
settings_hash: str,
running_ttl_seconds: int,
) -> bool:
from datetime import UTC, datetime
from app.gateway.routers.admin_users import _snapshot_is_fresh
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
from deerflow.persistence.types import BEIJING_TZ
async with session_factory() as session:
row = await session.get(
AdminLeaderboardDailyStatRow,
{"stat_date": stat_date, "settings_hash": settings_hash},
)
if row is None or _snapshot_is_fresh(row, stat_date):
return False
if row.status == "running":
updated_at = row.updated_at
if isinstance(updated_at, datetime):
if updated_at.tzinfo is None:
updated_at = updated_at.replace(tzinfo=BEIJING_TZ)
age = (datetime.now(BEIJING_TZ) - updated_at.astimezone(BEIJING_TZ)).total_seconds()
if age < running_ttl_seconds:
return False
row.status = "running"
row.message = "排行榜后台统计任务已认领。"
row.error = None
row.updated_at = datetime.now(UTC)
await session.commit()
return True
async def _run_leaderboard_snapshot_tick() -> tuple[int, int]:
from app.gateway.routers.admin_users import (
_daily_job_key,
_queue_daily_leaderboard_snapshots,
_refresh_daily_leaderboard_snapshot,
_resolve_leaderboard_snapshot_scope,
)
from deerflow.config.system_settings import load_system_settings
from deerflow.persistence.engine import get_session_factory
session_factory = get_session_factory()
if session_factory is None:
return 0, 0
settings = load_system_settings().leaderboard
excluded, include_scheduled, include_admins, include_failed, settings_hash = _resolve_leaderboard_snapshot_scope(settings)
stat_dates = _recent_leaderboard_stat_dates(_LEADERBOARD_SNAPSHOT_PREWARM_DAYS)
queued_dates = await _queue_daily_leaderboard_snapshots(
session_factory,
stat_dates,
settings_hash,
message="排行榜后台统计任务定期预热/补算队列。",
)
due_dates = await _leaderboard_snapshot_due_dates(
session_factory,
settings_hash=settings_hash,
limit=_LEADERBOARD_SNAPSHOT_BATCH_SIZE,
running_ttl_seconds=_LEADERBOARD_SNAPSHOT_RUNNING_TTL_SECONDS,
)
processed = 0
for stat_date in due_dates:
claimed = await _claim_leaderboard_snapshot(
session_factory,
stat_date=stat_date,
settings_hash=settings_hash,
running_ttl_seconds=_LEADERBOARD_SNAPSHOT_RUNNING_TTL_SECONDS,
)
if not claimed:
continue
logger.info("Leaderboard snapshot scheduler: building date=%s hash=%s", stat_date, settings_hash[:10])
await _refresh_daily_leaderboard_snapshot(
_daily_job_key(stat_date, settings_hash),
session_factory=session_factory,
settings=settings,
stat_date=stat_date,
settings_hash=settings_hash,
excluded=excluded,
include_scheduled=include_scheduled,
include_admins=include_admins,
include_failed=include_failed,
)
processed += 1
if _LEADERBOARD_SNAPSHOT_SLEEP_SECONDS > 0 and processed < len(due_dates):
await asyncio.sleep(_LEADERBOARD_SNAPSHOT_SLEEP_SECONDS)
return processed, len(queued_dates)
async def _leaderboard_snapshot_scheduler_loop() -> None:
"""Background task inside the existing gateway process for daily snapshots."""
if not _LEADERBOARD_SNAPSHOT_BACKGROUND_ENABLED:
logger.info("Leaderboard snapshot scheduler disabled by ADMIN_LEADERBOARD_BACKGROUND_ENABLED")
return
from deerflow.config.runtime_paths import runtime_home
lock_handle = _acquire_singleton_lock(runtime_home() / ".leaderboard_snapshot_scheduler.lock")
if lock_handle is None:
logger.info("Leaderboard snapshot scheduler: another gateway worker holds the lock; this worker skips")
return
logger.info(
"Leaderboard snapshot scheduler started: prewarm_days=%d batch_size=%d interval=%.1fs",
_LEADERBOARD_SNAPSHOT_PREWARM_DAYS,
_LEADERBOARD_SNAPSHOT_BATCH_SIZE,
_LEADERBOARD_SNAPSHOT_INTERVAL_SECONDS,
)
try:
if _LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS > 0:
await asyncio.sleep(_LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS)
while True:
try:
processed, queued = await _run_leaderboard_snapshot_tick()
if processed or queued:
logger.info(
"Leaderboard snapshot scheduler tick: queued=%d processed=%d",
queued,
processed,
)
except Exception:
logger.exception("Leaderboard snapshot scheduler tick failed (non-fatal)")
await asyncio.sleep(_LEADERBOARD_SNAPSHOT_INTERVAL_SECONDS)
finally:
_release_singleton_lock(lock_handle)
def _warn_if_multi_worker() -> None:
"""检测 uvicorn / gunicorn 多 worker 部署并打 ERROR 日志。
主 agent / canvas / AI 写作流式响应在 Gateway 进程内通过 ``StreamBridge``
@@ -764,6 +767,16 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"Roundtable functional agent seeding failed at startup (non-fatal)"
)
# 业务链条管理:幂等补建固定业务行(3Q/6BF/7BF/8BF),agent/chain 初始为空。
try:
business_mapping_store = getattr(app.state, "business_mapping_store", None)
if business_mapping_store is not None:
inserted = await business_mapping_store.ensure_seed_defaults()
if inserted:
logger.info("Seeded %d business mapping rows", inserted)
except Exception:
logger.exception("Business mapping seeding failed at startup (non-fatal)")
# 一次性把 .deer-flow/agents/{id}/ 目录里历史遗留的 agent 导入 agents 表。
# 路由层原本每次请求都跑一遍这个 sync(扫文件系统 + N 个 agent 各一次 DB
# SELECT/UPDATE),把它挪到启动期 + 路由层只保留 TTL 兜底,显著降低
@@ -799,21 +812,21 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# 默认每天凌晨 3 点扫一次,删 retention_days(默认 7 天)以前的会话,联动
# 清理 MySQL 业务表 + SQLite checkpointer 里的 thread 状态。多 worker
# 下用文件锁选 leader,只有抢到锁的 worker 真正跑清理。
try:
from app.gateway.ai_writing_cleanup import cleanup_scheduler_loop
app.state.ai_writing_cleanup_task = asyncio.create_task(cleanup_scheduler_loop(app))
except Exception:
logger.exception("AI writing cleanup scheduler failed to start (non-fatal)")
try:
app.state.leaderboard_snapshot_scheduler_task = asyncio.create_task(_leaderboard_snapshot_scheduler_loop())
except Exception:
logger.exception("Leaderboard snapshot scheduler failed to start (non-fatal)")
# Start IM channel service if any channels are configured
try:
from app.channels.service import start_channel_service
try:
from app.gateway.ai_writing_cleanup import cleanup_scheduler_loop
app.state.ai_writing_cleanup_task = asyncio.create_task(cleanup_scheduler_loop(app))
except Exception:
logger.exception("AI writing cleanup scheduler failed to start (non-fatal)")
try:
app.state.leaderboard_snapshot_scheduler_task = asyncio.create_task(_leaderboard_snapshot_scheduler_loop())
except Exception:
logger.exception("Leaderboard snapshot scheduler failed to start (non-fatal)")
# Start IM channel service if any channels are configured
try:
from app.channels.service import start_channel_service
channel_service = await start_channel_service(app.state.config)
logger.info("Channel service started: %s", channel_service.get_status())
@@ -857,17 +870,17 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
except asyncio.CancelledError:
pass
except Exception:
logger.exception("AI writing cleanup scheduler shutdown error")
leaderboard_snapshot_task = getattr(app.state, "leaderboard_snapshot_scheduler_task", None)
if leaderboard_snapshot_task is not None:
leaderboard_snapshot_task.cancel()
try:
await leaderboard_snapshot_task
except asyncio.CancelledError:
pass
except Exception:
logger.exception("Leaderboard snapshot scheduler shutdown error")
logger.exception("AI writing cleanup scheduler shutdown error")
leaderboard_snapshot_task = getattr(app.state, "leaderboard_snapshot_scheduler_task", None)
if leaderboard_snapshot_task is not None:
leaderboard_snapshot_task.cancel()
try:
await leaderboard_snapshot_task
except asyncio.CancelledError:
pass
except Exception:
logger.exception("Leaderboard snapshot scheduler shutdown error")
# 阶段 7 删冗余:AI 写作 graph 不再自管 checkpointer / in-flight task;
# thread 状态由 LangGraph Server 主 checkpointer 接管,lifespan 出口由
@@ -1122,6 +1135,13 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Roundtable-planning business chains at /api/roundtable-chains
app.include_router(roundtable_chains.router)
# Global business → agent / chain mapping at /api/business-mapping
app.include_router(business_mapping.router)
# Standalone parallel multi-agent panel: fan-out streaming + saved tasks
app.include_router(parallel_agents.router)
app.include_router(parallel_agent_tasks.router)
# Roundtable-planning background jobs at /api/roundtable-jobs
app.include_router(roundtable_jobs.router)
@@ -1159,10 +1179,10 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
app.include_router(tool_metrics.router)
# Channels API is mounted at /api/channels
app.include_router(channels.router)
# Admin checkpoint migration API: SQLite legacy checkpoints -> Postgres.
app.include_router(checkpoint_migration.router)
app.include_router(channels.router)
# Admin checkpoint migration API: SQLite legacy checkpoints -> Postgres.
app.include_router(checkpoint_migration.router)
# Assistants compatibility API (LangGraph Platform stub)
app.include_router(assistants_compat.router)
@@ -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",
}
)
@@ -179,6 +179,14 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
from deerflow.persistence.business_mapping import make_business_mapping_store
app.state.business_mapping_store = make_business_mapping_store(sf)
from deerflow.persistence.parallel_agent_tasks import make_parallel_agent_task_store
app.state.parallel_agent_task_store = make_parallel_agent_task_store(sf)
from deerflow.persistence.roundtable_jobs import (
make_roundtable_job_store,
register_default_store as _register_roundtable_job_default_store,
@@ -19,24 +19,30 @@
并配合 ``SkillStopMiddleware`` 在 ``agent_orchestration`` 调用后停图,便于网关从
AIMessage.tool_calls 解析派活列表。
- ``seat`` —— 子智能体交付:开 thinking + medium reasoning;**可写文件**
(``write_file`` / ``str_replace`` 产出 md 等成品文档),但禁派活 / 澄清 /
展示文件 / 看图 / 联网搜索 / **``bash``**。禁 ``bash`` 的原因:圆桌席位是
「文档产出者」,弱模型爱写 ``.py`` 脚本再跑出产物(乱且抓不到预览),与
``report`` 角色禁 ``bash`` 同理;让它**直接 write_file 写成品文档**。
(``write_file`` / ``str_replace`` 产出 md 等成品文档)+ **可跑 ``bash``**
(2026-06 放开:业务需要跑知识库检索 / Python 辅助分析)。禁派活 / 澄清 /
展示文件 / 看图 / 联网搜索。``report`` 通过 ``[*_SEAT_EXCLUDED, ...]`` 继承本角色,
所以 Step3 报告也跟着拿到 bash;只有 ``leader`` 和 ``seat_parallel`` 仍各自显式禁。
历史警示(保留):弱模型用 ``bash`` 跑 ``python xxx.py > out.md`` 生成产物会
**绕过前端虚拟预览**(预览只拦截 ``write_file.args.content``)→ 沙箱里有文件但
Step2 右侧预览空白。席位 SOUL 必须明确「bash 只用于辅助查询拿数据,**最终交付
必须用 write_file 写 .md 落盘**,不要让脚本生成最终产物」。
- ``seat_parallel`` —— **DAG 快速(fast)模式的并行席位(方案 A,2026-06)**:**禁写文件**
(``write_file`` / ``str_replace``,产物走正文)+ 禁 ``bash`` + 禁「打断 / 越权」类,
(``write_file`` / ``str_replace``,产物走正文)+ **禁 ``bash``** + 禁「打断 / 越权」类,
**放开 ``web_search`` 与技能**(搜索不碰 sandbox;技能靠 ``read_file`` SKILL.md)——
否则并行席位几乎调不动工具、产生不了步骤(步骤条不显示)。
并行席位同时跑 bash 会在共享 sandbox 里抢路径 / 占进程数;且 fast 模式整体不
写文件,bash 跑出来的产物既不能预览也没意义。
「写文件产出 md」归 **沙箱(sandbox)模式**(走 ``seat`` policy,单列 + 沙箱跟随预览);
fast 模式快、双列、产物走正文 —— 用户明确要求 fast 不生成文件。详见
``docs/roundtable-dag-orchestration.md`` §4 / §12.1 / §12.8 / §12.10。
与 ``seat`` 的差异:``seat`` 禁 ``web_search``(旧「圆桌一律禁搜索」),
``seat_parallel`` **放开搜索**(本期诉求)。
- ``report`` —— Step3 报告绘制:在 ``seat``(已禁 ``bash``)基础上**额外禁
``str_replace``**:
* ``bash`` 已在 ``seat`` 禁 → 杜绝写 Python 脚本再跑出 HTML(脚本产物落在
outputs,但前端虚拟预览只抓 ``write_file`` 的 ``args.content``,抓不到脚本
产物 → 预览停在半截 ``write_file``,正是「没写完就去生成 py 文件」的现象);
``seat_parallel`` **放开搜索**(本期诉求);``seat`` 已放开 ``bash``,本角色仍禁。
- ``report`` —— Step3 报告绘制:在 ``seat`` 基础上**额外禁 ``str_replace``**:
* 继承 ``seat`` 的 bash 放开(2026-06)—— Step3 也可跑 bash 做辅助查询,同
``seat`` 警示:**结果必须 write_file 落盘**,否则前端虚拟预览会空白
(预览只抓 ``write_file.args.content``,抓不到脚本产物 → 预览停在半截
``write_file``,正是「没写完就去生成 py 文件」的现象)。
* 禁 ``str_replace`` → 强制返工用整文件 ``write_file`` 重写
(``str_replace`` 没有 ``content`` 字段,会让前端虚拟预览读到 undefined
而清空沙箱)。
@@ -72,16 +78,21 @@ _LEADER_EXCLUDED: list[str] = [
"hindsight_retain",
"read_peer_delivery", # 总控靠 _collect_delivery_status 直读各席位,不需要这个席位侧工具
]
# seat:席位**可以写文件**(write_file / str_replace)产出 md 等成品文档,但**禁 bash** ——
# 圆桌席位是「文档产出者」,不该跑 shell/python 脚本(弱模型爱写 .py 脚本再跑生成产物,既乱又
# 抓不到产物预览,与 report 角色禁 bash 同理)。禁 web_search(圆桌串行一律禁搜索,旧决策)。
# seat:席位**可以写文件**(write_file / str_replace)产出 md 等成品文档,**bash 放开**
# (2026-06 决策,业务需要 bash 跑知识库检索 / Python 辅助分析)。禁派活 / 澄清 /
# 展示文件 / 看图 / 联网搜索。``report`` 通过 ``[*_SEAT_EXCLUDED, ...]`` 继承本角色,
# 所以 Step3 报告跟着拿到 bash;只有 leader / seat_parallel 仍各自显式禁。
#
# **历史警示(保留)**:弱模型用 bash 跑 ``python xxx.py > out.md`` 生成最终产物会
# 绕过前端虚拟预览(只拦 write_file.args.content) → 沙箱里有文件但 Step2 右侧预览
# 空白。必须靠席位 SOUL 强约束「bash 只用于辅助查询拿数据,最终交付必须用 write_file
# 写 .md 落盘」。
_SEAT_EXCLUDED: list[str] = [
"web_search",
"ask_clarification",
"present_files",
"view_image",
"agent_orchestration",
"bash",
]
# 并行席位 = **快速(fast)模式**(双列):**禁写文件**(产物走正文)+ 禁跑命令 +「打断/越权」类。
# **禁 web_search** —— 离线内网部署无外网,席位检索一律走「技能(skill)」(靠 read_file 读
@@ -0,0 +1,87 @@
"""CRUD API for the global business → agent / chain mapping (业务链条管理).
Binds a fixed business code (3Q / 6BF / 7BF / 8BF) to an optional 智能体 and an
optional 业务链条 (a **public** ``roundtable_chains`` row). Used to auto-pick the
business chain when a task page launches the multi-agent roundtable.
Global / shared — there is no user scoping; all accounts see and edit the same
rows. The four business rows are seeded on startup; this API only lists them and
updates the agent / chain columns (no create/delete).
Routes (prefix ``/api/business-mapping``):
GET "" list all business mappings (display order)
PUT "/{business_code}" update one row's agent / chain selection
"""
from __future__ import annotations
import logging
from datetime import datetime
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/business-mapping", tags=["business-mapping"])
# ── schemas ────────────────────────────────────────────────────────────────
class BusinessMappingResponse(BaseModel):
business_code: str
label: str = ""
agent_id: str | None = None
agent_name: str | None = None
chain_id: str | None = None
chain_title: str | None = None
sort_order: int = 0
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
class BusinessMappingListResponse(BaseModel):
mappings: list[BusinessMappingResponse]
class BusinessMappingUpdateRequest(BaseModel):
# All optional → callers send only what changed; explicit null clears a field.
agent_id: str | None = Field(default=None, max_length=64)
agent_name: str | None = Field(default=None, max_length=191)
chain_id: str | None = Field(default=None, max_length=64)
chain_title: str | None = Field(default=None, max_length=512)
# ── helpers ──────────────────────────────────────────────────────────────
def _get_store(request: Request):
store = getattr(request.app.state, "business_mapping_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Business mapping store not available")
return store
# ── routes ───────────────────────────────────────────────────────────────
@router.get("", response_model=BusinessMappingListResponse)
async def list_mappings(request: Request) -> BusinessMappingListResponse:
store = _get_store(request)
rows = await store.list_mappings()
return BusinessMappingListResponse(
mappings=[BusinessMappingResponse(**r) for r in rows]
)
@router.put("/{business_code}", response_model=BusinessMappingResponse)
async def update_mapping(
request: Request, business_code: str, body: BusinessMappingUpdateRequest
) -> BusinessMappingResponse:
store = _get_store(request)
# Only forward fields the caller explicitly sent (distinguish omitted vs null).
fields = body.model_dump(exclude_unset=True)
row = await store.update_mapping(business_code, **fields)
if row is None:
raise HTTPException(status_code=404, detail="Business mapping not found")
return BusinessMappingResponse(**row)
@@ -0,0 +1,148 @@
"""CRUD API for parallel multi-agent task records (并行多智能体任务).
A task = one saved run of the standalone parallel multi-agent panel: a task name
+ brief dispatched to several agents in parallel, plus the resulting message list.
Bound to the requesting user (not the browser), so it follows the account across
devices/sessions — same model as roundtable drafts.
Routes (prefix ``/api/parallel-agent-tasks``):
GET "" list current user's tasks (lightweight meta, no messages)
POST "" create a task
GET "/{task_id}" one task (full, incl. messages)
PUT "/{task_id}" partial update (title / brief / agents / messages)
DELETE "/{task_id}" delete
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/parallel-agent-tasks", tags=["parallel-agent-tasks"])
# ── schemas ────────────────────────────────────────────────────────────────
class TaskAgentSchema(BaseModel):
agent_id: str = Field(min_length=1, max_length=128)
name: str = ""
avatarType: str | None = None
class TaskResponse(BaseModel):
id: str
title: str = ""
brief: str | None = None
agents: list[TaskAgentSchema] = []
# messages is an opaque dialogue list (mirrors the frontend Dialogue[] shape);
# absent on list responses (lightweight), present on get.
messages: list[dict[str, Any]] | None = None
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
class TaskListResponse(BaseModel):
tasks: list[TaskResponse]
class TaskCreateRequest(BaseModel):
id: str | None = Field(default=None, max_length=64)
title: str = Field(default="", max_length=512)
brief: str | None = None
agents: list[TaskAgentSchema] = []
messages: list[dict[str, Any]] = []
class TaskUpdateRequest(BaseModel):
# All optional → callers send only what changed.
title: str | None = Field(default=None, max_length=512)
brief: str | None = None
agents: list[TaskAgentSchema] | None = None
messages: list[dict[str, Any]] | None = None
# ── helpers ──────────────────────────────────────────────────────────────
def _current_user_id(request: Request) -> str:
user = getattr(request.state, "user", None)
if user is not None:
return str(user.id)
return get_effective_user_id()
def _get_store(request: Request):
store = getattr(request.app.state, "parallel_agent_task_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Parallel agent task store not available")
return store
# ── routes ───────────────────────────────────────────────────────────────
@router.get("", response_model=TaskListResponse)
async def list_tasks(request: Request) -> TaskListResponse:
store = _get_store(request)
user_id = _current_user_id(request)
rows = await store.list_tasks(user_id)
return TaskListResponse(tasks=[TaskResponse(**r) for r in rows])
@router.post("", response_model=TaskResponse, status_code=201)
async def create_task(request: Request, body: TaskCreateRequest) -> TaskResponse:
store = _get_store(request)
user_id = _current_user_id(request)
row = await store.create_task(
user_id,
{
"id": (body.id or uuid4().hex),
"title": body.title.strip(),
"brief": body.brief,
"agents": [a.model_dump() for a in body.agents],
"messages": body.messages,
},
)
return TaskResponse(**row)
@router.get("/{task_id}", response_model=TaskResponse)
async def get_task(request: Request, task_id: str) -> TaskResponse:
store = _get_store(request)
user_id = _current_user_id(request)
row = await store.get_task(task_id, user_id)
if row is None:
raise HTTPException(status_code=404, detail="Task not found")
return TaskResponse(**row)
@router.put("/{task_id}", response_model=TaskResponse)
async def update_task(request: Request, task_id: str, body: TaskUpdateRequest) -> TaskResponse:
store = _get_store(request)
user_id = _current_user_id(request)
fields = body.model_dump(exclude_unset=True)
# Normalize agents (pydantic models) to plain dicts for JSON storage.
if "agents" in fields and fields["agents"] is not None:
fields["agents"] = [a if isinstance(a, dict) else a.model_dump() for a in body.agents or []]
row = await store.update_task(task_id, user_id, **fields)
if row is None:
raise HTTPException(status_code=404, detail="Task not found")
return TaskResponse(**row)
@router.delete("/{task_id}", status_code=204)
async def delete_task(request: Request, task_id: str) -> None:
store = _get_store(request)
user_id = _current_user_id(request)
deleted = await store.delete_task(task_id, user_id)
if not deleted:
raise HTTPException(status_code=404, detail="Task not found")
@@ -0,0 +1,440 @@
"""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, then **merges** their
LangGraph streams into ONE SSE tagged per ``agent_id``, so the client only consumes
one stream and never parses LangGraph internals.
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)::
{"type": "agent_start", "agent_id": "..."}
{"type": "delta", "agent_id": "...", "text": "..."} # AI text increment
{"type": "step", "agent_id": "...", "key": "...", "label": "...", "args": {...}}
{"type": "agent_done", "agent_id": "...", "content": "..."}
{"type": "agent_error", "agent_id": "...", "message": "..."}
{"type": "all_done"}
The frame-normalisation (LangGraph ``messages-tuple`` → delta / tool step) mirrors
the frontend parser in ``frontend-web/src/roundtable-planning/api/multi-agent.ts``
(``extractAiTextDelta`` + ``tool_call_chunks`` accumulation).
"""
from __future__ import annotations
import asyncio
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,
_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"])
# 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) ─────────────────────────────
def _extract_ai_text(chunk: dict[str, Any]) -> str:
"""AI text delta from a ``messages-tuple`` chunk obj; "" when not AI text."""
type_ = str(chunk.get("type") or "").lower()
if type_ not in ("ai", "aimessagechunk", "aimessage"):
return ""
content = chunk.get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
out: list[str] = []
for item in content:
if isinstance(item, str):
out.append(item)
elif isinstance(item, dict):
t = item.get("text")
if isinstance(t, str):
out.append(t)
return "".join(out)
return ""
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:
parts: list[str] = []
if task_name.strip():
parts.append(f"任务名称:{task_name.strip()}")
if brief.strip():
parts.append(f"任务简介:\n{brief.strip()}")
parts.append(
"请你从你的专业职责出发,针对以上任务给出你的分析与交付。"
"本轮为**并行**研讨:请把你的产出**直接写在回复正文里**(本轮禁止写文件)。"
)
# 角色边界护栏:本面板里每个智能体都是**执行席位**,不是总控 / 指挥。即便任务里带有
# 「总控 / 业务链条 / 调度 / 派活」字样,也只完成属于自己的那份交付,严禁模仿总控派活。
parts.append(
"【角色边界】你是本轮的**执行席位**,不是总控 / 指挥。即使任务里出现「总控 / 业务链条 / 调度 / "
"派活」等字样,你也只需完成**属于你自己**的那份分析与交付;严禁模仿总控口吻输出「总控调度指令 / "
"调度对象 / 分配给『…』的任务 / agent_name」之类调度内容,严禁罗列或转派其他席位的任务,严禁调用"
"任何派活 / 调度类工具,也不要用脚本(bash / python)去调用内部接口或翻找派活脚本。"
)
return "\n\n".join(parts)
# ── endpoint ─────────────────────────────────────────────────────────────────
@router.post("/run/stream")
async def run_parallel_agents_stream(request: Request, payload: dict = Body(...)) -> StreamingResponse:
"""并行扇出 N 个智能体,合并成一条带 ``agent_id`` 标记的 SSE。文件写入固定禁用。"""
task_name: str = str(payload.get("task_name") or "").strip()
brief: str = str(payload.get("brief") or "").strip()
raw_ids = payload.get("agent_ids") or []
model_name: str = str(payload.get("model") or "")
if not isinstance(raw_ids, list) or not raw_ids:
raise HTTPException(status_code=400, detail="agent_ids 不能为空")
agent_ids: list[str] = []
for aid in raw_ids:
sid = str(aid or "").strip().lower()
_validate_agent_id(sid, "agent_ids")
if sid not in agent_ids:
agent_ids.append(sid)
if len(agent_ids) > MAX_PARALLEL_AGENTS:
raise HTTPException(
status_code=400,
detail=f"并行智能体数量上限 {MAX_PARALLEL_AGENTS}(当前 {len(agent_ids)})",
)
if not task_name and not brief:
raise HTTPException(status_code=400, detail="任务名称与简介至少填写其一")
base = _loopback_base(request)
headers = _auth_headers(request)
new_message = _build_task_message(task_name, brief)
async def _consume_agent(
client: Any,
agent_id: str,
queue: asyncio.Queue[dict[str, Any]],
) -> None:
await queue.put({"type": "agent_start", "agent_id": agent_id})
last_event = ""
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 = _run_one_agent( # 只禁文件写入,其余工具放开(保留步骤条)
client, base, headers, agent_id, thread_id, new_message, model_name
)
async for chunk in gen:
if isinstance(chunk, dict):
# 终态:先用最终 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 ""
if line.startswith("event:"):
last_event = line[6:].strip()
continue
if not line.startswith("data:"):
continue
raw = line[5:].strip()
if not raw:
continue
try:
parsed = json.loads(raw)
except Exception:
continue
if last_event == "messages" and isinstance(parsed, list) and parsed:
obj = parsed[0]
if isinstance(obj, dict):
text = _extract_ai_text(obj)
if text:
buffer.append(text)
await queue.put({"type": "delta", "agent_id": agent_id, "text": text})
# 完整 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(
{"type": "agent_done", "agent_id": agent_id, "content": "".join(buffer)}
)
except Exception as exc: # noqa: BLE001 — 单个 agent 失败不拖垮其它
logger.warning("[parallel-agents] %s failed: %r", agent_id, exc)
await queue.put({"type": "agent_error", "agent_id": agent_id, "message": str(exc)})
finally:
await queue.put({"type": "__agent_end__", "agent_id": agent_id})
async def stream_generator() -> AsyncIterator[str]:
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
async with _make_loopback_client(request) as client:
tasks = [asyncio.create_task(_consume_agent(client, aid, queue)) for aid in agent_ids]
ended = 0
try:
while ended < len(agent_ids):
event = await queue.get()
if event.get("type") == "__agent_end__":
ended += 1
continue
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
yield f"data: {json.dumps({'type': 'all_done'}, ensure_ascii=False)}\n\n"
finally:
for t in tasks:
if not t.done():
t.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
return StreamingResponse(
stream_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)
@@ -49,6 +49,10 @@ class ChainResponse(BaseModel):
# Optional layered orchestration: list of stages, each a list of agent_ids
# run in parallel (stages run serially). None/absent = linear chain.
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)。
@@ -69,6 +73,8 @@ class ChainCreateRequest(BaseModel):
description: str | None = Field(default=None, max_length=2048)
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):
@@ -77,6 +83,8 @@ class ChainUpdateRequest(BaseModel):
description: str | None = Field(default=None, max_length=2048)
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
@@ -151,6 +159,38 @@ def _validate_stages(seats: list[ChainSeatSchema], stages: list[list[str]] | Non
)
# Per-goal length cap to keep injected prompts bounded.
MAX_STAGE_GOAL_CHARS = 1000
def _validate_stage_goals(
stages: list[list[str]] | None, stage_goals: list[str | None] | None
) -> None:
"""Validate the optional per-stage goals (None = no goals, always valid).
Rules:
- goals only make sense alongside a non-null layering (dag-only): reject
goals sent without ``stages``;
- length must match ``stages`` exactly (positional alignment);
- each goal (when present) must be ≤ ``MAX_STAGE_GOAL_CHARS`` chars.
"""
if stage_goals is None:
return
if stages is None:
raise HTTPException(status_code=400, detail="阶段目标需与编排分层一起提交(线性链不支持阶段目标)")
if len(stage_goals) != len(stages):
raise HTTPException(
status_code=400,
detail=f"阶段目标数量({len(stage_goals)})与编排分层阶段数({len(stages)})不一致",
)
for idx, goal in enumerate(stage_goals):
if goal is not None and len(goal) > MAX_STAGE_GOAL_CHARS:
raise HTTPException(
status_code=400,
detail=f"第 {idx + 1} 阶段目标过长(上限 {MAX_STAGE_GOAL_CHARS} 字)",
)
# ── routes ───────────────────────────────────────────────────────────────
@@ -174,6 +214,7 @@ async def list_chains(request: Request, scope: str = "mine") -> ChainListRespons
async def create_chain(request: Request, body: ChainCreateRequest) -> ChainResponse:
_validate_seats(body.seats)
_validate_stages(body.seats, body.stages)
_validate_stage_goals(body.stages, body.stage_goals)
store = _get_store(request)
user_id = _current_user_id(request)
row = await store.create_chain(
@@ -184,6 +225,8 @@ async def create_chain(request: Request, body: ChainCreateRequest) -> ChainRespo
"description": body.description,
"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)
@@ -209,6 +252,8 @@ async def update_chain(request: Request, chain_id: str, body: ChainUpdateRequest
if body.seats is None:
raise HTTPException(status_code=400, detail="更新编排分层时必须同时提交席位列表")
_validate_stages(body.seats, body.stages)
# Goals align to stages; reject goals sent without a layering, enforce length/size.
_validate_stage_goals(body.stages, body.stage_goals)
store = _get_store(request)
user_id = _current_user_id(request)
# Distinguish "omitted" from "explicitly sent"; only forward provided fields.
+2 -2
View File
@@ -14,7 +14,7 @@ models:
display_name: DeepSeek Chat
use: langchain_openai:ChatOpenAI
model: deepseek-chat
api_key: sk-e1c22b7931dc446f8b67e57878edd105
api_key: sk-3fee562f834a4c08bb0d471ef44cb445
base_url: https://api.deepseek.com
request_timeout: 600.0
max_retries: 2
@@ -27,7 +27,7 @@ models:
display_name: DeepSeek V4 Flash
use: langchain_openai:ChatOpenAI
model: deepseek-v4-flash
api_key: sk-e1c22b7931dc446f8b67e57878edd105
api_key: sk-3fee562f834a4c08bb0d471ef44cb445
base_url: https://api.deepseek.com
request_timeout: 600.0
max_retries: 2
@@ -0,0 +1,18 @@
"""Global business → agent / chain mapping persistence (业务链条管理)."""
from __future__ import annotations
from deerflow.persistence.business_mapping.model import BusinessMappingRow
from deerflow.persistence.business_mapping.sql import BusinessMappingRepository
__all__ = [
"BusinessMappingRepository",
"BusinessMappingRow",
"make_business_mapping_store",
]
def make_business_mapping_store(session_factory):
if session_factory is None:
return None
return BusinessMappingRepository(session_factory)
@@ -0,0 +1,53 @@
"""ORM model for the global business → agent / chain mapping (业务链条管理).
A "business mapping" row binds a fixed business code (3Q / 6BF / 7BF / 8BF) to an
optional 智能体 (``agent_id``) and an optional 业务链条 (``chain_id`` → a
``roundtable_chains`` row, which must be **public** so every account can resolve
it). Used to auto-pick the business chain when a task page launches the
multi-agent roundtable.
Unlike ``roundtable_chains`` (per-user), this mapping is **global / shared** —
there is no ``user_id`` column; all accounts see and use the same configuration.
The fixed business rows are seeded on startup; users only fill in the
agent / chain columns from the management page.
``agent_name`` / ``chain_title`` are denormalized display snapshots so the table
renders without cross-referencing and survives a later agent/chain rename or
delete (the picked id still drives the auto-flow).
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime
class BusinessMappingRow(Base):
__tablename__ = "business_mapping"
# Business code is the natural primary key (e.g. "3Q", "6BF"). Global, no user scope.
business_code: Mapped[str] = mapped_column(String(32), primary_key=True)
# Display label shown in the 业务 column (e.g. "任务FX").
label: Mapped[str] = mapped_column(String(128), nullable=False, default="")
# Picked 智能体 (暂存不用,自动流程当前不消费此字段). NULL = 未配置.
agent_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
agent_name: Mapped[str | None] = mapped_column(String(191), nullable=True)
# Picked 业务链条 → roundtable_chains.id (须为 public). NULL = 未配置.
chain_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
chain_title: Mapped[str | None] = mapped_column(String(512), nullable=True)
# Fixed display order for the management table.
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)
)
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(),
nullable=False,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
@@ -0,0 +1,120 @@
"""SQLAlchemy-backed global business → agent / chain mapping store.
Global / shared (no ``user_id`` scoping): every account reads and writes the same
rows. The four business rows are seeded once on startup (``ensure_seed_defaults``)
with empty agent/chain; users fill those in from the management page via
``update_mapping``.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.business_mapping.model import BusinessMappingRow
_UNSET = object()
# Fixed business rows (业务码 → 展示名). Seeded on first boot; agent/chain left empty.
# 8BF 页面尚未建好,先占位保留行。顺序即管理表的展示顺序。
DEFAULT_BUSINESS_ROWS: list[dict[str, Any]] = [
{"business_code": "3Q", "label": "3QFX"},
{"business_code": "6BF", "label": "任务FX"},
{"business_code": "7BF", "label": "XDFX"},
{"business_code": "8BF", "label": "8BF"},
]
class BusinessMappingRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@staticmethod
def _to_dict(row: BusinessMappingRow) -> dict[str, Any]:
return {
"business_code": row.business_code,
"label": row.label,
"agent_id": row.agent_id,
"agent_name": row.agent_name,
"chain_id": row.chain_id,
"chain_title": row.chain_title,
"sort_order": row.sort_order,
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
"updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
}
async def list_mappings(self) -> list[dict[str, Any]]:
stmt = select(BusinessMappingRow).order_by(
BusinessMappingRow.sort_order.asc(), BusinessMappingRow.business_code.asc()
)
async with self._sf() as session:
result = await session.execute(stmt)
return [self._to_dict(row) for row in result.scalars()]
async def get_mapping(self, business_code: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(BusinessMappingRow, business_code)
return self._to_dict(row) if row is not None else None
async def update_mapping(
self,
business_code: str,
*,
agent_id=_UNSET,
agent_name=_UNSET,
chain_id=_UNSET,
chain_title=_UNSET,
) -> dict[str, Any] | None:
"""Update an existing (seeded) business row. Returns None if the code is unknown.
Only forwarded fields are touched; explicit ``None`` clears that field.
"""
async with self._sf() as session:
row = await session.get(BusinessMappingRow, business_code)
if row is None:
return None
if agent_id is not _UNSET:
row.agent_id = agent_id or None
if agent_name is not _UNSET:
row.agent_name = agent_name or None
if chain_id is not _UNSET:
row.chain_id = chain_id or None
if chain_title is not _UNSET:
row.chain_title = chain_title or None
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return self._to_dict(row)
async def ensure_seed_defaults(self) -> int:
"""Idempotently insert the fixed business rows if missing. Returns inserted count.
Never overwrites an existing row (respects user-configured agent/chain).
"""
inserted = 0
now = datetime.now(UTC)
async with self._sf() as session:
for idx, seed in enumerate(DEFAULT_BUSINESS_ROWS):
existing = await session.get(BusinessMappingRow, seed["business_code"])
if existing is not None:
continue
session.add(
BusinessMappingRow(
business_code=seed["business_code"],
label=seed.get("label", ""),
agent_id=None,
agent_name=None,
chain_id=None,
chain_title=None,
sort_order=idx,
created_at=now,
updated_at=now,
)
)
inserted += 1
if inserted:
await session.commit()
return inserted
@@ -0,0 +1,56 @@
"""Add stage_goals column to roundtable_chains.
Per-stage goals (每阶段目标) for business chains: an optional list aligned by index
to ``stages`` where ``stage_goals[i]`` is the「本阶段目标」for ``stages[i]`` — what
that serial stage should produce / how it depends on the previous stage's output.
Injected to the coordinator dispatch prompt (and seat tasks as fallback) in dag-mode
only. NULL = no goals. The column is LONGTEXT on MySQL (mirrors ``stages`` headroom)
and plain TEXT elsewhere — matching ``PortableLongText`` on
``RoundtableChainRow.stage_goals``. ``create_all`` adds the column for brand-new DBs;
this revision patches DBs that already have the table.
Chained after ``20260608_03`` (chain is_public) so the revision graph stays
single-headed — otherwise ``alembic upgrade head`` aborts with "multiple heads" and
the auto-migrate on startup silently falls back to the create_all schema (which lacks
this column → 500 on list).
Revision ID: 20260608_04
Revises: 20260608_03
Create Date: 2026-06-08
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
from sqlalchemy.dialects.mysql import LONGTEXT
revision = "20260608_04"
down_revision = "20260608_03"
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", "stage_goals"):
col_type = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
op.add_column("roundtable_chains", sa.Column("stage_goals", col_type, nullable=True))
def downgrade() -> None:
if _has_column("roundtable_chains", "stage_goals"):
op.drop_column("roundtable_chains", "stage_goals")
@@ -0,0 +1,61 @@
"""Create parallel_agent_tasks table.
Saved runs of the standalone parallel multi-agent panel (Vue embeddable
component): task name + brief + agents snapshot + the resulting message list,
bound per user. ``brief`` / ``agents`` / ``messages`` are LONGTEXT on MySQL
(mirror ``roundtable_drafts`` headroom) and plain TEXT elsewhere — matching
``PortableLongText``. ``create_all`` builds the table for brand-new DBs; this
revision patches DBs that already exist.
Chained after ``20260608_04`` (chain stage_goals) 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.
Revision ID: 20260608_05
Revises: 20260608_04
Create Date: 2026-06-08
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
from sqlalchemy.dialects.mysql import LONGTEXT
revision = "20260608_05"
down_revision = "20260608_04"
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 upgrade() -> None:
if _has_table("parallel_agent_tasks"):
return
long_text = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
op.create_table(
"parallel_agent_tasks",
sa.Column("id", sa.String(length=64), primary_key=True, nullable=False),
sa.Column("user_id", sa.String(length=64), nullable=False),
sa.Column("title", sa.String(length=512), nullable=False, server_default=""),
sa.Column("brief", long_text, nullable=True),
sa.Column("agents", long_text, nullable=True),
sa.Column("messages", long_text, nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
)
op.create_index(
"ix_parallel_agent_tasks_user_id", "parallel_agent_tasks", ["user_id"]
)
def downgrade() -> None:
if _has_table("parallel_agent_tasks"):
op.drop_index("ix_parallel_agent_tasks_user_id", table_name="parallel_agent_tasks")
op.drop_table("parallel_agent_tasks")
@@ -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")
@@ -22,11 +22,13 @@ from deerflow.persistence.agents.model import (
AgentSkillRow,
)
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
from deerflow.persistence.business_mapping.model import BusinessMappingRow
from deerflow.persistence.memory_config.model import UserMemoryConfigRow
from deerflow.persistence.feedback.model import FeedbackRow
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
from deerflow.persistence.models.run_event import RunEventRow
from deerflow.persistence.notifications.model import NotificationRow
from deerflow.persistence.parallel_agent_tasks.model import ParallelAgentTaskRow
from deerflow.persistence.recommended_questions.model import RecommendedQuestionRow
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow
from deerflow.persistence.roundtable_draft_shares.model import RoundtableDraftShareRow
@@ -60,8 +62,10 @@ __all__ = [
"AgentRow",
"AgentSkillRow",
"AdminLeaderboardDailyStatRow",
"BusinessMappingRow",
"LlmCallMetricRow",
"NotificationRow",
"ParallelAgentTaskRow",
"RecommendedQuestionRow",
"RoundtableChainRow",
"RoundtableDraftRow",
@@ -0,0 +1,18 @@
"""Parallel multi-agent task persistence."""
from __future__ import annotations
from deerflow.persistence.parallel_agent_tasks.model import ParallelAgentTaskRow
from deerflow.persistence.parallel_agent_tasks.sql import ParallelAgentTaskRepository
__all__ = [
"ParallelAgentTaskRepository",
"ParallelAgentTaskRow",
"make_parallel_agent_task_store",
]
def make_parallel_agent_task_store(session_factory):
if session_factory is None:
return None
return ParallelAgentTaskRepository(session_factory)
@@ -0,0 +1,46 @@
"""ORM model for parallel multi-agent task records.
A "parallel-agent task" is one saved run of the standalone *parallel multi-agent*
panel (the Vue3 embeddable component): a task name + brief dispatched to several
agents in parallel, plus the resulting chat/message list. Persisted per user so it
follows the account across devices/sessions — same model as ``roundtable_drafts``.
``agents`` and ``messages`` are stored as JSON serialized into ``PortableLongText``
(MySQL ``LONGTEXT``) rather than ``PortableJSON`` (MySQL ``TEXT``, 64 KB cap),
because the parallel transcript can carry several agents' full deliveries that
easily exceed 64 KB — mirroring ``roundtable_drafts``.
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime, PortableLongText
class ParallelAgentTaskRow(Base):
__tablename__ = "parallel_agent_tasks"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
# Task name (用户输入的任务名称) shown on the history list.
title: Mapped[str] = mapped_column(String(512), nullable=False, default="")
# Task brief / 简介 — the free-text task description.
brief: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
# JSON: snapshot of the agents dispatched, [{"agent_id", "name", "avatarType"?}].
agents: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
# JSON: the full message/dialogue list (mirrors the frontend Dialogue[] shape).
messages: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
created_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)
)
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(),
nullable=False,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
@@ -0,0 +1,131 @@
"""SQLAlchemy-backed store for parallel multi-agent task records.
``agents`` / ``messages`` JSON columns are stored as serialized strings in
``PortableLongText`` and round-tripped here with ``json.dumps`` / ``json.loads``
— same approach as ``roundtable_drafts`` / ``roundtable_chains``.
All write/read paths are scoped by ``user_id`` for ownership isolation; a row
that belongs to another user is treated as not found.
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.parallel_agent_tasks.model import ParallelAgentTaskRow
_UNSET = object()
def _loads(value: Any) -> Any:
if not isinstance(value, str):
return value
if not value:
return None
try:
return json.loads(value)
except Exception:
return None
def _dumps(value: Any) -> str | None:
if value is None:
return None
return json.dumps(value, ensure_ascii=False)
class ParallelAgentTaskRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@staticmethod
def _to_dict(row: ParallelAgentTaskRow, *, with_messages: bool = True) -> dict[str, Any]:
data: dict[str, Any] = {
"id": row.id,
"title": row.title,
"brief": row.brief,
"agents": _loads(row.agents) or [],
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
"updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
}
if with_messages:
data["messages"] = _loads(row.messages) or []
return data
async def list_tasks(self, user_id: str, *, limit: int = 200) -> list[dict[str, Any]]:
"""列出当前用户的任务(轻量元信息,不含 messages,供历史列表)。"""
stmt = (
select(ParallelAgentTaskRow)
.where(ParallelAgentTaskRow.user_id == user_id)
.order_by(ParallelAgentTaskRow.updated_at.desc())
.limit(limit)
)
async with self._sf() as session:
result = await session.execute(stmt)
return [self._to_dict(row, with_messages=False) for row in result.scalars()]
async def get_task(self, task_id: str, user_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(ParallelAgentTaskRow, task_id)
if row is None or row.user_id != user_id:
return None
return self._to_dict(row)
async def create_task(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
now = datetime.now(UTC)
row = ParallelAgentTaskRow(
id=data["id"],
user_id=user_id,
title=(data.get("title") or "").strip(),
brief=(data.get("brief") or None),
agents=_dumps(data.get("agents") or []),
messages=_dumps(data.get("messages") or []),
created_at=now,
updated_at=now,
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return self._to_dict(row)
async def update_task(
self,
task_id: str,
user_id: str,
*,
title=_UNSET,
brief=_UNSET,
agents=_UNSET,
messages=_UNSET,
) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(ParallelAgentTaskRow, task_id)
if row is None or row.user_id != user_id:
return None
if title is not _UNSET and title is not None:
row.title = str(title).strip()
if brief is not _UNSET:
row.brief = brief or None
if agents is not _UNSET:
row.agents = _dumps(agents)
if messages is not _UNSET:
row.messages = _dumps(messages)
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return self._to_dict(row)
async def delete_task(self, task_id: str, user_id: str) -> bool:
async with self._sf() as session:
row = await session.get(ParallelAgentTaskRow, task_id)
if row is None or row.user_id != user_id:
return False
await session.delete(row)
await session.commit()
return True
@@ -39,6 +39,16 @@ class RoundtableChainRow(Base):
# (each seat its own stage). Drives Step 2 dag-mode orchestration; see
# frontend ``lib/dag-types.ts``. Stored as LONGTEXT-backed JSON string.
stages: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
# Optional JSON: per-stage goals aligned by index to ``stages`` — a list where
# ``stage_goals[i]`` is the「本阶段目标」for ``stages[i]`` (what that serial stage
# should produce / how it depends on the previous stage's output). Injected to the
# 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(
@@ -52,6 +52,10 @@ class RoundtableChainRepository:
"seats": _loads(row.seats) or [],
# None when absent → frontend reads it as "linear chain" (no layering).
"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),
@@ -102,6 +106,8 @@ class RoundtableChainRepository:
description=(data.get("description") or None),
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,
@@ -121,6 +127,8 @@ class RoundtableChainRepository:
description=_UNSET,
seats=_UNSET,
stages=_UNSET,
stage_goals=_UNSET,
coordinator_prompt=_UNSET,
is_public=_UNSET,
) -> dict[str, Any] | None:
async with self._sf() as session:
@@ -135,6 +143,10 @@ class RoundtableChainRepository:
row.seats = _dumps(seats)
if stages is not _UNSET:
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)
+79
View File
@@ -0,0 +1,79 @@
# parallel-agents-vue
并行多智能体面板 —— Vue3 可嵌入组件。输入「任务名称 + 简介」,**并行调用多个智能体**
(智能体清单由宿主传入,运行期间后端固定**禁止写文件**),消息列表照搬 DeerFlow 圆桌
研讨第二步并行多席位的**深色样式与完整结构**(步骤条 / 并行双列 / 席位状态条),支持
**保存聊天记录 + 回显**与**重新分析**。
## 开发调试
```bash
pnpm install
pnpm dev # http://localhost:5183 ,填后端 baseUrl / token / 智能体 id 即可联调
```
后端需要一个跑着的 DeerFlow 网关(默认 `http://localhost:8001`),它提供:
- `POST /api/parallel-agents/run/stream` —— 并行扇出,合并 SSE(事件带 `agent_id`);
- `/api/parallel-agent-tasks` —— 任务保存 / 回显 CRUD。
## 构建为组件库
```bash
pnpm build:lib # 产出 dist/parallel-agents-vue.{js,umd.cjs} + dist/parallel-agents-vue.css
```
## 嵌入其它 Vue 项目
```ts
import { ParallelAgentsPanel } from "parallel-agents-vue";
import "parallel-agents-vue/style.css";
```
```vue
<!-- 放进你自己的抽屉 / 弹窗里,面板会自适应容器(h-full w-full)。
接口地址走配置文件默认(src/config.ts),登录只需把用户名传进来(免密自动登录)。 -->
<YourDrawer v-model="open">
<div style="height: 80vh">
<ParallelAgentsPanel
:username="currentUsername"
:agents="[
{ agent_id: 'roundtable-intelligence', name: '情报收集' },
{ agent_id: 'roundtable-risk-review', name: '风险审查' },
]"
@saved="onSaved"
/>
</div>
</YourDrawer>
```
### 接口地址 & 登录
- **接口地址**写在配置文件 `src/config.ts` 的 `DEFAULT_BASE_URL`(dev 可用 `VITE_PA_BASE_URL` 覆盖);
也可用 `:base-url` prop 覆盖。
- **登录**:把宿主侧的**用户名**通过 `:username` 传进来即可,组件会自动走 DeerFlow 的
**免密「用户名登录」**端点(`POST /api/v1/auth/login/username`,公共 + CSRF 豁免)换取 token
并缓存、自动续期(401 重登)。**不需要宿主自己管 token**。
- 若你已有 token,也可直接传 `:token` / `:get-token`(优先级高于 username)。
- 若后端开了口令门(`auth_login.username_login.require_password=true`),再传 `:login-password`。
### Props
| prop | 类型 | 说明 |
| --------------- | -------------------------------------- | ----------------------------------------------------- |
| `agents` | `{agent_id,name,avatarType?}[]`(必填)| 要并行调用的智能体 |
| `username` | `string` | 免密自动登录用户名(推荐方式,宿主自己传) |
| `baseUrl` | `string` | 后端地址;不传用 `config.ts` 默认 |
| `token` | `string` | 已有 Bearer token(与 `username` 二选一,优先) |
| `getToken` | `() => string \| Promise<string>` | 动态取 token |
| `loginPassword` | `string` | 仅当后端开启口令门时需要 |
| `taskId` | `string` | 传入则挂载时自动加载并回显该已保存任务 |
| `model` | `string` | 可选模型名(不传用 `config.ts` 默认 / 后端回退) |
| `dark` | `boolean`(默认 `true`) | 深色面板(自带成色,不依赖宿主主题) |
### 事件
- `@saved="(task) => ..."` —— 保存成功(含新建/更新)后回传 `SavedTask`。
- `@update:taskId="(id) => ..."` —— 当前任务 id 变化(保存 / 加载历史时)。
> 样式作用域全部包在根类 `.parallel-agents` 下,且**不引入 Tailwind preflight**,不会污染宿主全局样式。
@@ -0,0 +1,48 @@
# 并行多智能体面板 · 测试用例总览
本目录是 `parallel-agents-vue` 的**手工测试用例**(Markdown)。覆盖:并行调用与流式、文件写入禁用、
保存与回显、重新分析、免密登录与鉴权、嵌入与深色样式。
## 用例清单
| 文件 | 主题 |
| -------------------------------------------- | ------------------------------------- |
| [01-并行调用与流式.md](./01-并行调用与流式.md) | 并行扇出、流式输出、步骤条、席位状态条、禁写文件 |
| [02-保存与回显.md](./02-保存与回显.md) | 保存聊天记录、历史列表、加载回显、更新保存 |
| [03-重新分析.md](./03-重新分析.md) | 重新分析(覆盖重跑)、停止、空输入校验 |
| [04-免密登录与鉴权.md](./04-免密登录与鉴权.md) | username 免密登录、token 优先级、401 重登、口令门 |
| [05-嵌入与深色样式.md](./05-嵌入与深色样式.md) | 配置文件接口地址、抽屉/弹窗嵌入、深色自适应、不污染宿主 |
| [06-可用智能体清单.md](./06-可用智能体清单.md) | **测试用真实 agent_id 清单(可直接复制粘贴)** + 示例任务 |
| [07-使用说明.md](./07-使用说明.md) | **在其它 Vue 项目中引入哪些组件、如何使用**(props/事件/嵌入) |
| [08-开发文档.md](./08-开发文档.md) | **二次开发**:架构/数据流/事件协议/目录职责/后端契约/扩展指南 |
## 测试环境
| 组件 | 地址 / 命令 |
| --------------- | ------------------------------------------------------ |
| DeerFlow 后端 | `http://localhost:8001`(`make dev`,需含本功能的新路由)|
| Vue dev demo | `http://localhost:5183`(`pnpm dev`) |
| 接口地址配置 | `src/config.ts` 的 `DEFAULT_BASE_URL` |
## 前置准备(所有用例通用)
1. 后端已启动,且包含本功能:
- `POST /api/parallel-agents/run/stream`(并行扇出,合并 SSE)
- `GET/POST/PUT/DELETE /api/parallel-agent-tasks`(任务保存/回显)
- `POST /api/v1/auth/login/username`(免密登录,公共)
- 数据表 `parallel_agent_tasks` 已建(`alembic upgrade head` 或 `create_all`)。
2. 至少准备 **2 个真实存在的 agent_id**(必须是后端注册过的真实 id,不能只写名字)。
本环境可直接用:`roundtable-solution-design`(方案设计)、`roundtable-risk-review`(风险审查)等
—— 完整可复制清单见 [06-可用智能体清单.md](./06-可用智能体清单.md)。
3. 打开 dev demo `http://localhost:5183`,填:
- 后端 baseUrl:`http://localhost:8001`
- 用户名:`demo`(免密自动登录)
- 智能体:每行 `agent_id, 显示名`
点「打开面板(弹窗内)」。
## 用例字段约定
- **用例ID**:`PA-<模块>-<序号>`
- **优先级**:P0(核心,必过)/ P1(重要)/ P2(边界)
- **前置条件 → 操作步骤 → 预期结果**:逐条核对,全部命中才算通过。
- **结果记录**:建议在「实际结果 / 通过?」列里回填。
@@ -0,0 +1,105 @@
# 测试用例 · 并行调用与流式
覆盖:并行扇出多个智能体、流式逐字输出、步骤条(工具调用)、点头像切换显示(单选)、席位状态条、
运行期间**禁止写文件**。
---
## PA-RUN-01 · 并行调用多个智能体(P0)
**前置条件**
- 面板已打开,填入任务名「竞品调研」、简介「对比 A、B 两款产品的核心功能与定价」。
- 传入 2 个智能体(情报收集、风险审查)。
**操作步骤**
1. 点击「开始分析」。
**预期结果**
- 顶部出现**席位状态条**,显示「并行 2 席」,两个席位头像角标先后转为「进行中」(转圈)。
- 两个智能体**同时**开始执行(并行,而非一个跑完再跑下一个)——可在状态条上看到都在「进行中」。
- 会话区每次显示**一个**席位的消息列表(默认第 1 个),点头像可切换查看另一个(见 PA-RUN-06)。
- 气泡头部显示:头像、智能体名、状态徽标「并行执行中」、时间。
---
## PA-RUN-02 · 流式逐字输出 + 光标(P0)
**前置条件**:PA-RUN-01 正在运行中。
**操作步骤**
1. 观察当前显示席位的气泡正文(点头像可切换看其它席位)。
**预期结果**
- 正文**逐步增量**出现(流式),不是一次性整段刷出。
- 正在输出的气泡末尾有**闪烁光标**(`.pa-cursor`)。
- 内容还没来时,显示 loading 占位(转圈 + 跳动圆点 + 「并行执行中」文案)。
- Markdown 能正确渲染(标题/列表/代码块/表格按 `.pa-md` 样式)。
---
## PA-RUN-03 · 步骤条(思维链 / 工具调用)(P1)
**前置条件**:智能体会调用工具(如检索类技能)。
**操作步骤**
1. 运行一轮,观察气泡顶部的「执行步骤」折叠卡。
**预期结果**
- 出现「执行步骤(N)」折叠卡(`MessageStepsCard`),点击可展开/收起。
- 每条步骤显示:工具标签(如「调用工具 xxx」)、参数摘要(如有 `path` 优先显示)、时间。
- 同一工具调用**不重复**出条目(按 key 去重)。
- 若智能体本轮不调工具,则不出现步骤卡(属正常)。
---
## PA-RUN-04 · 席位状态流转(P1)
**操作步骤**
1. 运行一轮,全程观察席位状态条各头像角标。
**预期结果**
- 角标状态依次:灰(pending)→ 蓝色转圈(running)→ 绿色对勾(done)。
- 某智能体失败时其角标变**红色叉**(error),且对应气泡显示「该智能体执行出错:…」、状态徽标「出错」。
- 单个智能体失败**不影响**其它智能体继续完成(互不拖累)。
- 全部结束后,运行按钮从「停止」恢复为「重新分析」。
---
## PA-RUN-05 · 运行期间禁止写文件(P0)
**目的**:核对后端 `seat_parallel` 策略生效——并行席位被禁用 `write_file` / `str_replace` / `bash` 等。
**操作步骤**
1. 简介里**故意要求写文件**,例如:「请把结果保存成一个 .md 文件」。
2. 点击「开始分析」,等待完成。
**预期结果**
- 智能体**不会**真正落地文件,产出**直接呈现在气泡正文**里。
- 步骤条里**不出现** `write_file` / `bash` / `str_replace` 工具调用(被策略排除)。
- 可旁证:后端日志中该席位 `role=seat_parallel`。
---
## PA-RUN-06 · 点头像切换显示(单选)(P1)
**前置条件**:传入 ≥2 个智能体并运行。
**操作步骤**
1. 点击席位状态条上的某个头像。
**预期结果**
- 主区**切换为只显示**该智能体的消息列表(**单选**,每次显示一个)。
- 被选中的头像高亮(外发光描边);再点别的头像即切到那个。
- 切换不影响后台并行执行:未显示的智能体仍在跑/已完成,其增量在切回时能看到。
- 默认显示第 1 个席位。
---
## PA-RUN-07 · 后端不可达 / 智能体 id 非法(P2)
**操作步骤**
1. 把 baseUrl 改成一个打不通的地址,或传入不存在的 agent_id,点「开始分析」。
**预期结果**
- 顶部出现红色错误提示(请求失败信息),不白屏、不卡死。
- 非法 agent_id 时,对应席位以 error 角标 + 出错气泡呈现,其它正常席位不受影响。
@@ -0,0 +1,94 @@
# 测试用例 · 保存与回显
覆盖:保存聊天记录(新建/更新)、历史列表、加载已存任务并回显、`taskId` 自动加载、跨刷新持久化。
---
## PA-SAVE-01 · 首次保存(新建)(P0)
**前置条件**:已跑完一轮、会话区有结果。任务名「竞品调研」。
**操作步骤**
1. 点击「保存」。
**预期结果**
- 按钮短暂显示「保存中…」,成功后变为「更新保存」(说明已绑定 taskId)。
- 触发 `@saved` 事件,回传 `SavedTask`(含 id、title、agents、messages)。
- 「历史」下拉里出现这条记录(标题 + 更新时间)。
- 后端 `parallel_agent_tasks` 表新增一行(owner = 当前登录用户)。
---
## PA-SAVE-02 · 再次保存(更新覆盖)(P0)
**前置条件**:PA-SAVE-01 已保存、当前仍是同一任务。
**操作步骤**
1. 修改任务名 / 简介,或重新分析得到新结果。
2. 点击「更新保存」。
**预期结果**
- **不新增**记录,而是更新原 taskId 那条(title / brief / agents / messages 被覆盖为最新)。
- 历史列表里该条的更新时间刷新、排到最前。
---
## PA-SAVE-03 · 历史加载并回显(P0)
**前置条件**:历史里至少有 1 条已保存任务。
**操作步骤**
1. 点击「历史 ▾」展开列表。
2. 选择一条历史任务。
**预期结果**
- 任务名、简介**回填**为该任务保存时的值。
- 会话区**完整回显**保存时的消息列表(各智能体气泡、正文、步骤条),且**均为非流式终态**(无光标、无 loading)。
- 席位状态条据消息重建:正常席位显示 done(绿勾),出错的显示 error(红叉)。
- 当前 taskId 切换为该任务(再点「保存」是「更新保存」该条)。
---
## PA-SAVE-04 · taskId 进入即自动回显(P1)
**操作步骤**
1. 以 `:task-id="<已存在任务id>"` 方式挂载面板(嵌入场景)。
**预期结果**
- 面板挂载后**自动加载**该任务并回显(等同于 PA-SAVE-03 的结果),无需手动点历史。
- `@update:taskId` 随之触发。
---
## PA-SAVE-05 · 跨刷新持久化(P1)
**操作步骤**
1. 保存一条任务后,刷新浏览器(或重开宿主页面)。
2. 用相同用户名进入,打开历史。
**预期结果**
- 历史里仍能看到之前保存的任务(数据存在后端、按用户隔离,非浏览器 localStorage)。
- 加载后能正常回显。
---
## PA-SAVE-06 · 无结果时禁止保存(P2)
**操作步骤**
1. 新开面板、尚未运行任何分析。
**预期结果**
- 「保存」按钮**禁用**(无可保存内容)。
- 运行中也不应因保存导致状态错乱(保存按钮在有结果后才可用)。
---
## PA-SAVE-07 · 用户隔离(P1)
**操作步骤**
1. 用户名 A 保存一条任务。
2. 切换用户名为 B(重新进入面板),打开历史。
**预期结果**
- B 的历史里**看不到** A 的任务。
- B 直接用 A 的 taskId 加载应失败(404 / 无内容),不报错崩溃。
@@ -0,0 +1,77 @@
# 测试用例 · 重新分析与运行控制
覆盖:重新分析(覆盖重跑)、运行中停止、空输入校验、运行态按钮切换。
---
## PA-RE-01 · 重新分析(覆盖重跑)(P0)
**前置条件**:已跑完一轮、会话区有结果(按钮显示「重新分析」)。
**操作步骤**
1. (可选)修改任务名 / 简介。
2. 点击「重新分析」。
**预期结果**
- **清空**上一轮的所有气泡与席位状态,按当前输入**重新并行**调用各智能体。
- 不会和上一轮结果混在一起(旧消息被替换,不是追加)。
- 重跑过程与首跑一致(流式、单列消息 + 点头像切换、步骤条、席位状态流转)。
---
## PA-RE-02 · 运行中按钮为「停止」(P1)
**操作步骤**
1. 开始分析,运行未结束时观察操作区。
**预期结果**
- 运行期间,主按钮显示「停止」(不再是「开始/重新分析」)。
- 「保存」按钮在运行中可点(保存当前已产出的内容)或按实现禁用——以不报错为准。
---
## PA-RE-03 · 停止当前运行(P0)
**前置条件**:正在运行中。
**操作步骤**
1. 点击「停止」。
**预期结果**
- 进行中的请求被取消(AbortController),不再有新增量进来。
- 仍在 streaming 的气泡被收尾为终态(去掉光标,徽标置「已完成」)。
- 按钮恢复为「重新分析」,可立即再次发起。
- 控制台不应出现未捕获的 AbortError 报错(已被吞掉)。
---
## PA-RE-04 · 空输入校验(P1)
**操作步骤**
1. 任务名与简介**都留空**,点击「开始分析」。
**预期结果**
- 按钮处于**禁用**态,点击无效(`canStart` 为假需任务名或简介至少其一 + 至少一个智能体)。
- 只填任务名或只填简介,按钮可用。
---
## PA-RE-05 · 未传智能体(P2)
**操作步骤**
1. `:agents="[]"`(空数组)挂载面板。
**预期结果**
- 操作区显示提示「⚠️ 未传入任何智能体(agents 为空)」。
- 「开始分析」禁用,不会发起空请求。
---
## PA-RE-06 · 连续多次重新分析(P2)
**操作步骤**
1. 连续点击「重新分析」若干次(每次等上一轮结束或中途停止再点)。
**预期结果**
- 每次都干净重置 + 重跑,无残留旧气泡、无席位状态错乱。
- 不出现多份 SSE 叠加导致的内容串列 / 重复。
@@ -0,0 +1,106 @@
# 测试用例 · 免密登录与鉴权
覆盖:username 免密自动登录、token 优先级、token 缓存与 401 重登、口令门、CSRF 绕过。
> 背景:面板优先用宿主传入的 token;没有 token 时,用宿主传入的 **username** 走 DeerFlow 公共免密端点
> `POST /api/v1/auth/login/username` 换取 `access_token`,以 `Authorization: Bearer` 发起后续请求
> (带 Bearer 会绕过 CSRF)。token 解析优先级:`token` > `getToken` > 缓存 > `username` 登录。
---
## PA-AUTH-01 · username 免密自动登录(P0)
**前置条件**:后端 `auth_login.username_login.require_password = false`(默认)。
**操作步骤**
1. 只传 `:username="demo"`(不传 token),打开面板。
2. 开始分析 / 打开历史。
**预期结果**
- 面板能正常拉历史、能发起并行流式——说明已用 username 换到 token 并附带在请求头。
- 旁证(手工 curl):
```bash
curl -s -X POST -H "Content-Type: application/json" \
-d '{"username":"demo"}' http://localhost:8001/api/v1/auth/login/username
# → 返回 {"access_token":"...","token_type":"bearer",...}
```
---
## PA-AUTH-02 · token 优先级高于 username(P1)
**操作步骤**
1. 同时传 `:token="<有效token>"` 和 `:username="demo"`。
2. 触发请求(开始分析 / 历史)。
**预期结果**
- 直接用传入的 `token`(不会再去调登录端点)。
- `:get-token` 同理优先于 username(动态取 token)。
---
## PA-AUTH-03 · token 缓存(不重复登录)(P1)
**操作步骤**
1. 只传 username,先后触发多次请求(拉历史 → 开始分析 → 保存)。
**预期结果**
- 登录端点**只调用一次**(首个请求),后续请求复用缓存 token(按 `baseUrl::username` 缓存)。
- 可在浏览器 Network 面板核对 `login/username` 只出现一次。
---
## PA-AUTH-04 · 401 自动重登重试(P1)
**前置条件**:能制造 token 失效(例如等待过期,或后端清理)。
**操作步骤**
1. 用 username 登录并缓存 token 后,使其失效,再触发一次 CRUD 请求。
**预期结果**
- 首次请求收到 401 → 面板**清缓存并用 username 重登一次**,再自动重试该请求。
- 用户无感知(不需手动重新登录),请求最终成功。
---
## PA-AUTH-05 · 口令门(require_password=true)(P2)
**前置条件**:后端把 `auth_login.username_login.require_password` 设为 `true`、配置 `password`。
**操作步骤**
1. 只传 username、不传 `loginPassword` → 触发请求。
2. 再传上正确的 `:login-password="<口令>"` → 触发请求。
**预期结果**
- 不传口令时:登录返回 401「口令错误」,面板顶部红色错误提示,不崩溃。
- 传对口令时:登录成功,后续请求正常。
---
## PA-AUTH-06 · Bearer 绕过 CSRF(P1)
**目的**:核对带 Bearer 的 POST 不被 CSRF 拦截。
**操作步骤(手工 curl 旁证)**
```bash
TOKEN=... # 上面登录拿到的 access_token
# 带 Bearer 的 POST:参数非法应为 400/422,而非 403(403=CSRF 拦截)
curl -s -o /dev/null -w "%{http_code}\n" -X POST \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{}' http://localhost:8001/api/parallel-agents/run/stream
```
**预期结果**
- 返回 **400/422**(业务参数校验),**不是 403**——证明 Bearer 已绕过 CSRF。
- 不带任何鉴权时该 POST 返回 401/403(被拦截),属正常。
---
## PA-AUTH-07 · 未传 token 且未传 username(P2)
**操作步骤**
1. 既不传 token / getToken,也不传 username,触发需要鉴权的请求。
**预期结果**
- 请求因缺少鉴权失败(401/403),面板给出可读的错误提示(不白屏)。
- 控制台/提示能反映「缺少 token 且未提供 username,无法登录」之类信息。
@@ -0,0 +1,105 @@
# 测试用例 · 嵌入与深色样式
覆盖:接口地址走配置文件、组件库构建产物、嵌入其它 Vue 项目、放进抽屉/弹窗自适应、深色成色、
不污染宿主全局样式。
---
## PA-EMBED-01 · 接口地址走配置文件(P0)
**操作步骤**
1. 不传 `:base-url`,只改 `src/config.ts` 的 `DEFAULT_BASE_URL` 为目标后端地址。
2. 运行面板触发请求。
**预期结果**
- 请求打到配置文件里的地址。
- 传 `:base-url` prop 时**优先**用 prop(覆盖配置默认)。
- dev 下设 `VITE_PA_BASE_URL` 环境变量也能覆盖默认。
---
## PA-EMBED-02 · 组件库构建产物(P0)
**操作步骤**
```bash
pnpm build:lib
```
**预期结果**
- `dist/` 产出:`parallel-agents-vue.js`(ESM)、`parallel-agents-vue.umd.cjs`(UMD)、`parallel-agents-vue.css`。
- `vue` 被 external(不打进库,由宿主提供)。
- 构建无报错、无「named + default exports」警告。
---
## PA-EMBED-03 · 嵌入其它 Vue 项目(P0)
**操作步骤**
1. 在另一个 Vue3 项目里:
```ts
import { ParallelAgentsPanel } from "parallel-agents-vue";
import "parallel-agents-vue/style.css";
```
2. 模板中放置:
```vue
<ParallelAgentsPanel :username="user" :agents="agents" />
```
**预期结果**
- 组件正常渲染、可交互(深色面板,与 demo 一致)。
- 只需 `:username` + `:agents` 两个 prop 即可跑起来(接口地址走配置)。
- 也支持 `app.use(ParallelAgentsVue)` 全局注册后用 `<ParallelAgentsPanel/>`。
---
## PA-EMBED-04 · 放进抽屉 / 弹窗自适应(P0)
**操作步骤**
1. 把面板放进宿主的抽屉 / 对话框,外层给一个固定高度容器:
```vue
<YourDrawer v-model="open">
<div style="height: 80vh"><ParallelAgentsPanel :username="user" :agents="agents" /></div>
</YourDrawer>
```
2. 改变容器尺寸 / 视口大小。
**预期结果**
- 面板**充满容器**(根元素 `h-full w-full flex flex-col min-h-0`),不溢出、不塌缩。
- 会话区内部独立滚动,输入区与席位条固定不被挤掉。
- 窄容器下消息列表正常显示、内部滚动,不溢出。
---
## PA-EMBED-05 · 深色成色自带、与宿主主题无关(P1)
**操作步骤**
1. 分别在**浅色**宿主页和**深色**宿主页里嵌入面板。
**预期结果**
- 面板始终是深蓝深色(自带 `--pa-*` token,作用域在 `.parallel-agents`),不随宿主明暗变化。
- 文案、徽标、步骤卡、并行列底、滚动条等观感与圆桌 Step2 深色一致。
- 传 `:dark="false"` 时按实现切换(如未实现浅色则保持深色,至少不报错)。
---
## PA-EMBED-06 · 不污染宿主全局样式(P0)
**目的**:核对**未引入 Tailwind preflight**(不重置宿主全局标签样式)。
**操作步骤**
1. 在一个有自己样式的宿主页里嵌入面板(页面有 `h1/p/button/ul` 等默认样式)。
**预期结果**
- 嵌入后,宿主页面**自身**的 `h1/p/button/列表/边距` 等样式**不被改变**(没有被 Tailwind 的 reset 抹平)。
- 面板内部样式正常(不依赖宿主提供 Tailwind)。
- 类名都在 `.parallel-agents` 作用域下,不与宿主类名冲突。
---
## PA-EMBED-07 · 多实例并存(P2)
**操作步骤**
1. 同一页面放置 2 个面板实例(不同 agents / 不同 username)。
**预期结果**
- 两个实例各自独立运行、各自保存/回显,互不串流、互不串 token 缓存(缓存按 `baseUrl::username` 区分)。
@@ -0,0 +1,73 @@
# 测试用 · 可用智能体清单(可直接复制粘贴)
测试时面板需要传入**真实存在的 `agent_id`**(后端真正拿去跑的标识,不能只写名字)。
下面是当前后端里**可直接用于测试**的内置智能体(已排除 intent / recommender / coordinator 这些系统角色)。
> 注:`name` 只是显示用、可省略;`agent_id` 必须照抄。下列为本环境实测可用的内置智能体;
> 你部署里的智能体可能不同,用文末方法自查。
## 可用智能体
| agent_id | 显示名 |
| --------------------------- | -------- |
| `roundtable-solution-design`| 方案设计 |
| `roundtable-risk-review` | 风险审查 |
| `roundtable-execution-plan` | 执行规划 |
| `roundtable-report` | 方案总结 |
| `demo-expert` | 演示专家 |
## A. 粘贴进 dev demo 页「智能体」输入框(每行 `agent_id, 显示名`)
推荐至少 2 个以便点头像切换查看不同智能体:
```
roundtable-solution-design, 方案设计
roundtable-risk-review, 风险审查
```
想多看几列就多贴几行:
```
roundtable-solution-design, 方案设计
roundtable-risk-review, 风险审查
roundtable-execution-plan, 执行规划
```
## B. 粘贴进组件 `:agents` prop(嵌入场景)
```ts
const agents = [
{ agent_id: "roundtable-solution-design", name: "方案设计" },
{ agent_id: "roundtable-risk-review", name: "风险审查" },
{ agent_id: "roundtable-execution-plan", name: "执行规划" },
];
```
```vue
<ParallelAgentsPanel :username="'demo'" :agents="agents" />
```
## C. 配一个示例任务(直接粘贴到面板)
- **任务名称**:`新功能上线方案评估`
- **任务简介**:
```
我们要给 App 上线「扫码点餐」功能。请从你的专业角度,评估方案可行性、列出关键风险、
并给出落地步骤与里程碑。
```
## 自查:列出你部署里所有可用的 agent_id
```bash
# 1) 免密登录拿 token(用户名换成你的)
TOKEN=$(curl -s -X POST -H "Content-Type: application/json" \
-d '{"username":"demo"}' http://localhost:8001/api/v1/auth/login/username \
| python -c "import sys,json;print(json.load(sys.stdin)['access_token'])")
# 2) 列出 agent_id <TAB> name
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8001/api/agents -o agents.json
python -c "import json,io;[print(a['id'],'\t',a['name']) for a in json.load(io.open('agents.json',encoding='utf-8')).get('agents',[])]"
```
第一列 `id` 才是要填的 `agent_id`。挑你需要的智能体即可(避开 `roundtable-intent` /
`roundtable-recommender` / `roundtable-coordinator*` 这些系统角色——它们不是用来当并行席位的)。
+345
View File
@@ -0,0 +1,345 @@
# 使用说明 · 在其它 Vue 项目中引入
本组件库只对外暴露**一个**组件 `ParallelAgentsPanel`(其余 `MessageBubble` / `MessageStepsCard` /
`ParallelSeatStrip` / `Avatar` / `MarkdownContent` 都是它的内部子组件,**无需也不要单独引入**)。
宿主只管把它放进自己的页面 / 抽屉 / 弹窗,并传几个 prop。
---
## 一、要引入的东西(就这两样)
1. **组件**:`ParallelAgentsPanel`
2. **样式**:`parallel-agents-vue/style.css`(一次性引入即可,样式作用域全在 `.parallel-agents` 下)
> 依赖说明:`vue ^3.4`(宿主提供,库不打包 vue);`markdown-it` 已打进库内,无需宿主安装。
---
## 二、两种引入方式
### 方式 A:构建成库后按包引入(推荐)
1. 在本项目构建:
```bash
pnpm install
pnpm build:lib # 产出 dist/parallel-agents-vue.{js,umd.cjs,css}
```
2. 宿主项目里安装(任选其一):
- 把本目录作为本地依赖:`"parallel-agents-vue": "file:../parallel-agents-vue"`;
- 或发到你们的私有 npm 后正常 `pnpm add parallel-agents-vue`。
3. 代码里引入:
```ts
import { ParallelAgentsPanel } from "parallel-agents-vue";
import "parallel-agents-vue/style.css";
```
### 方式 B:直接拷贝源码(无需发包)
把本项目 `src/` 下这些拷进宿主项目(保持相对结构),按相对路径引入 `ParallelAgentsPanel.vue` +
`styles/parallel-agents.css` 即可:
```
src/
components/ ParallelAgentsPanel.vue · MessageBubble.vue · MessageStepsCard.vue
ParallelSeatStrip.vue · Avatar.vue · MarkdownContent.vue
composables/ useParallelRun.ts
lib/ avatar-constants.ts
styles/ parallel-agents.css
api.ts · config.ts · types.ts
```
宿主需有 `markdown-it` 依赖(`pnpm add markdown-it`),并保证构建支持 `.vue`(Vite/@vitejs/plugin-vue)。
---
## 三、注册与使用
### 局部引入(推荐)
```vue
<script setup lang="ts">
import { ParallelAgentsPanel } from "parallel-agents-vue";
import "parallel-agents-vue/style.css";
const agents = [
{ agent_id: "roundtable-solution-design", name: "方案设计" },
{ agent_id: "roundtable-risk-review", name: "风险审查" },
];
</script>
<template>
<div style="height: 80vh">
<ParallelAgentsPanel :username="'demo'" :agents="agents" />
</div>
</template>
```
### 全局注册(可选)
```ts
import ParallelAgentsVue from "parallel-agents-vue";
import "parallel-agents-vue/style.css";
app.use(ParallelAgentsVue); // 之后任意模板可直接用 <ParallelAgentsPanel/>
```
---
## 四、放进抽屉 / 弹窗
组件根元素是 `h-full w-full flex flex-col min-h-0`,会**充满父容器**。所以**父容器要给一个高度**
(弹窗/抽屉本身有高度即可):
```vue
<YourDrawer v-model="open" size="large">
<div style="height: 86vh">
<ParallelAgentsPanel :username="currentUsername" :agents="agents" @saved="onSaved" />
</div>
</YourDrawer>
```
---
## 五、接口地址与登录
- **接口地址**:默认读 `src/config.ts` 的 `DEFAULT_BASE_URL`(可改源码,或 dev 设 `VITE_PA_BASE_URL`);
也可用 `:base-url` prop 临时覆盖。
- **登录**:把宿主的**用户名**用 `: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` 维度,多个实例/多用户互不串。
---
## 六、Props
| prop | 类型 | 必填 | 说明 |
| --------------- | --------------------------------------- | :--: | ----------------------------------------------- |
| `agents` | `{ agent_id, name, avatarType? }[]` | ✔ | 要并行调用的智能体(`agent_id` 必须是真实 id) |
| `username` | `string` | | 免密自动登录用户名(推荐方式) |
| `baseUrl` | `string` | | 后端地址;不传用 `config.ts` 默认 |
| `token` | `string` | | 已有 Bearer token(优先于 username) |
| `getToken` | `() => string \| Promise<string>` | | 动态取 token |
| `loginPassword` | `string` | | 仅当后端开启口令门时需要 |
| `taskId` | `string` | | 传入则挂载时自动加载并回显该任务 |
| `model` | `string` | | 可选模型名(不传走默认 / 后端回退) |
| `dark` | `boolean`(默认 `true`) | | 深色面板(自带成色,不依赖宿主主题) |
`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 变化(保存 / 加载历史时) |
| `completed` | `AgentResult[]` | 一轮并行分析结束时,回传各智能体返回内容 |
```vue
<ParallelAgentsPanel
:username="user"
:agents="agents"
: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 / 配置 / 后端回退。
切换作用于**下一次**「开始分析 / 重新分析」,运行中禁用。
- 「保存」存档;「历史」里选回**回显**;「重新分析」清空重跑。
---
## 九、注意事项
- **不会污染宿主全局样式**:库未引入 Tailwind preflight,类名都在 `.parallel-agents` 作用域内。
- **深色自带成色**:面板始终深蓝深色,不随宿主明暗切换(与圆桌 Step2 观感一致)。
- **跨域**:若宿主与后端不同源,后端需允许该来源的 CORS + 带 `credentials`;登录端点公共且 CSRF 豁免,
业务请求用 Bearer 头已绕过 CSRF。
- **agent_id 必须真实**:见 [06-可用智能体清单.md](./06-可用智能体清单.md) 获取可用 id 与自查方法。
+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`。
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>并行多智能体面板 · Dev</title>
</head>
<body style="margin: 0">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
{
"name": "parallel-agents-vue",
"version": "0.1.0",
"description": "并行多智能体面板 —— Vue3 可嵌入组件(输入任务名+简介,并行调用多个智能体,深色消息列表 + 步骤条 + 保存回显 + 重新分析)",
"type": "module",
"main": "./dist/parallel-agents-vue.umd.cjs",
"module": "./dist/parallel-agents-vue.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/parallel-agents-vue.js",
"require": "./dist/parallel-agents-vue.umd.cjs"
},
"./style.css": "./dist/parallel-agents-vue.css"
},
"files": ["dist"],
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"build:lib": "vite build --mode lib",
"typecheck": "vue-tsc --noEmit",
"preview": "vite preview"
},
"peerDependencies": {
"vue": "^3.4.0"
},
"dependencies": {
"markdown-it": "^14.1.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.0",
"@types/markdown-it": "^14.1.2",
"@vitejs/plugin-vue": "^5.2.0",
"tailwindcss": "^4.1.0",
"typescript": "^5.6.0",
"vite": "^6.0.0",
"vue": "^3.5.0",
"vue-tsc": "^2.1.10"
}
}
+1389
View File
File diff suppressed because it is too large. Load diff
+89
View File
@@ -0,0 +1,89 @@
<script setup lang="ts">
/** Dev demo 调试页:填后端地址 / token / 智能体 id,把面板塞进一个弹窗,验证嵌入与流式。 */
import { reactive, ref } from "vue";
import ParallelAgentsPanel from "./components/ParallelAgentsPanel.vue";
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
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
.map((l) => {
const [id, name] = l.split(",").map((x) => x.trim());
return { agent_id: id, name: name || id };
});
}
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;
}
</script>
<template>
<div style="min-height: 100vh; background: #0a1020; color: #cbd5e1; padding: 24px; font-family: system-ui">
<h1 style="font-size: 18px; font-weight: 700; margin-bottom: 12px">并行多智能体面板 · Dev Demo</h1>
<div style="display: flex; flex-direction: column; gap: 8px; max-width: 640px">
<label>后端 baseUrl
<input v-model="cfg.baseUrl" style="width: 100%; padding: 6px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px" />
</label>
<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-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">
打开面板(弹窗内)
</button>
</div>
<!-- 模拟宿主弹窗:面板自适应容器 -->
<div
v-if="open"
style="position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 50"
@click.self="open = false"
>
<div style="width: 92vw; max-width: 1080px; height: 86vh; border-radius: 12px; overflow: hidden; box-shadow: 0 20px 60px rgba(0,0,0,0.5)">
<ParallelAgentsPanel
:base-url="cfg.baseUrl"
:username="cfg.username || undefined"
:task-id="cfg.taskId || undefined"
:agents="agents"
@update:taskId="onTaskId"
@completed="onCompleted"
/>
</div>
</div>
</div>
</template>
+234
View File
@@ -0,0 +1,234 @@
/**
* 后端访问层:鉴权(宿主传入 token)+ 并行扇出 SSE + 任务 CRUD。
*
* 全部打到 DeerFlow 网关(宿主传入 `baseUrl`,如 http://localhost:8001):
* - `POST {baseUrl}/api/parallel-agents/run/stream` —— 并行扇出,合并 SSE;
* - `{baseUrl}/api/parallel-agent-tasks` —— 任务保存/回显 CRUD。
*/
import type { AgentInput, Dialogue, SavedTask, StreamEvent } from "./types";
import { TOKEN_PATH } from "./config";
export interface ApiConfig {
baseUrl: string;
/** 已有登录态 token(Bearer)。可传字符串或一个(异步)取 token 的函数。 */
token?: string;
getToken?: () => string | Promise<string>;
/**
* 免密自动登录用户名:不传 token 时,组件用它走「用户名登录」(公共端点)换取 token
* 并缓存。用户名由宿主自己传。
*/
username?: string;
/** 仅当后端 `auth_login.username_login.require_password=true` 时需要的口令。 */
loginPassword?: string;
}
function base(cfg: ApiConfig): string {
return cfg.baseUrl.replace(/\/$/, "");
}
// 免密登录拿到的 token 按 baseUrl::username 缓存,避免每个请求都登一次。
const _tokenCache = new Map<string, string>();
const cacheKey = (cfg: ApiConfig) => `${base(cfg)}::${cfg.username ?? ""}`;
/** 用 username 走本面板**专用**的「绕过常规登录」公共端点换 access_token(CSRF 豁免)。 */
async function loginByUsername(cfg: ApiConfig): Promise<string> {
if (!cfg.username) throw new Error("缺少 token 且未提供 username,无法登录");
const res = await fetch(`${base(cfg)}${TOKEN_PATH}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: cfg.username }),
credentials: "include",
});
if (!res.ok) {
const text = await res.text().catch(() => "");
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;
_tokenCache.set(cacheKey(cfg), token);
return token;
}
/** token 解析优先级:显式 token > getToken > 缓存 > 用 username 免密登录。 */
async function resolveToken(cfg: ApiConfig): Promise<string | undefined> {
if (cfg.token) return cfg.token;
if (cfg.getToken) return await cfg.getToken();
const cached = _tokenCache.get(cacheKey(cfg));
if (cached) return cached;
if (cfg.username) return await loginByUsername(cfg);
return undefined;
}
/** 清掉缓存 token(401 时强制下次重登)。 */
function invalidateToken(cfg: ApiConfig): void {
_tokenCache.delete(cacheKey(cfg));
}
async function authHeaders(cfg: ApiConfig, json = true): Promise<Record<string, string>> {
const h: Record<string, string> = {};
if (json) h["Content-Type"] = "application/json";
const token = await resolveToken(cfg);
if (token) h["Authorization"] = token.startsWith("Bearer ") ? token : `Bearer ${token}`;
return h;
}
/** 带鉴权的 fetch;若 401 且用 username 免密登录,则清缓存重登并重试一次。 */
async function authedFetch(
cfg: ApiConfig,
url: string,
init: RequestInit & { json?: boolean } = {},
): Promise<Response> {
const { json = true, headers, ...rest } = init;
const run = async () =>
fetch(url, {
...rest,
headers: { ...(await authHeaders(cfg, json)), ...(headers as Record<string, string> | undefined) },
credentials: "include",
});
let res = await run();
if (res.status === 401 && !cfg.token && !cfg.getToken && cfg.username) {
invalidateToken(cfg);
res = await run();
}
return res;
}
// ── 并行扇出 SSE ──────────────────────────────────────────────────────────────
export interface StreamHandlers {
onEvent: (ev: StreamEvent) => void;
onError?: (err: unknown) => void;
}
/**
* 打开 `/api/parallel-agents/run/stream` 一条合并 SSE,逐条把统一事件回调出去。
* 返回的 Promise 在流结束(all_done / 连接关闭)后 resolve。用 `signal` 取消。
*/
export async function streamParallelAgents(
cfg: ApiConfig,
body: { task_name: string; brief: string; agent_ids: string[]; model?: string },
handlers: StreamHandlers,
signal?: AbortSignal,
): Promise<void> {
const res = await authedFetch(cfg, `${base(cfg)}/api/parallel-agents/run/stream`, {
method: "POST",
body: JSON.stringify(body),
signal,
});
if (!res.ok || !res.body) {
const text = await res.text().catch(() => "");
throw new Error(`run/stream failed (${res.status}): ${text}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const raw of lines) {
const line = raw.trim();
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (!payload) continue;
try {
handlers.onEvent(JSON.parse(payload) as StreamEvent);
} catch {
/* 忽略坏帧 */
}
}
}
} catch (err) {
if ((err as { name?: string })?.name !== "AbortError") handlers.onError?.(err);
} finally {
reader.releaseLock();
}
}
// ── 任务 CRUD(保存 / 回显)─────────────────────────────────────────────────
interface ServerTask {
id: string;
title: string;
brief: string | null;
agents: AgentInput[];
messages?: Dialogue[];
created_at: string | null;
updated_at: string | null;
}
const TASKS = (cfg: ApiConfig) => `${base(cfg)}/api/parallel-agent-tasks`;
async function readJson<T>(res: Response, ctx: string): Promise<T> {
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`${ctx} failed (${res.status}): ${text}`);
}
return (await res.json()) as T;
}
export async function listTasks(cfg: ApiConfig): Promise<SavedTask[]> {
const res = await authedFetch(cfg, TASKS(cfg), { json: false });
const data = await readJson<{ tasks: ServerTask[] }>(res, "list tasks");
return data.tasks ?? [];
}
export async function getTask(cfg: ApiConfig, id: string): Promise<SavedTask | null> {
const res = await authedFetch(cfg, `${TASKS(cfg)}/${encodeURIComponent(id)}`, { json: false });
if (res.status === 404) return null;
return readJson<SavedTask>(res, "get task");
}
export async function createTask(
cfg: ApiConfig,
payload: { id?: string; title: string; brief?: string; agents: AgentInput[]; messages: Dialogue[] },
): Promise<SavedTask> {
const res = await authedFetch(cfg, TASKS(cfg), { method: "POST", body: JSON.stringify(payload) });
return readJson<SavedTask>(res, "create task");
}
export async function updateTask(
cfg: ApiConfig,
id: string,
patch: { title?: string; brief?: string; agents?: AgentInput[]; messages?: Dialogue[] },
): Promise<SavedTask | null> {
const res = await authedFetch(cfg, `${TASKS(cfg)}/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(patch),
});
if (res.status === 404) return null;
return readJson<SavedTask>(res, "update task");
}
// ── 模型列表 ──────────────────────────────────────────────────────────────
export interface ModelItem {
name: string;
display_name?: string;
}
/** 列出后端可用模型(GET /api/models)。失败返回空数组(不阻断主流程)。 */
export async function listModels(cfg: ApiConfig): Promise<ModelItem[]> {
try {
const res = await authedFetch(cfg, `${base(cfg)}/api/models`, { json: false });
if (!res.ok) return [];
const data = (await res.json()) as { models?: ModelItem[] };
return data.models ?? [];
} catch {
return [];
}
}
export async function deleteTask(cfg: ApiConfig, id: string): Promise<void> {
const res = await authedFetch(cfg, `${TASKS(cfg)}/${encodeURIComponent(id)}`, {
method: "DELETE",
json: false,
});
if (!res.ok && res.status !== 404) {
const text = await res.text().catch(() => "");
throw new Error(`delete task failed (${res.status}): ${text}`);
}
}
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { computed } from "vue";
import { avatarColor, isHuman } from "@/lib/avatar-constants";
const props = withDefaults(defineProps<{ type: string; size?: number }>(), { size: 36 });
const color = computed(() => avatarColor(props.type));
const human = computed(() => isHuman(props.type));
</script>
<template>
<span
class="inline-flex shrink-0 items-center justify-center rounded-xl"
:style="{ width: `${size}px`, height: `${size}px`, backgroundColor: color.bg }"
>
<!-- 真人 -->
<svg
v-if="human"
:width="size * 0.58"
:height="size * 0.58"
viewBox="0 0 24 24"
fill="none"
:stroke="color.fg"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="8" r="4" />
<path d="M4 21c0-4 4-6 8-6s8 2 8 6" />
</svg>
<!-- 机器人 -->
<svg
v-else
:width="size * 0.6"
:height="size * 0.6"
viewBox="0 0 24 24"
fill="none"
:stroke="color.fg"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="4" y="8" width="16" height="11" rx="3" />
<path d="M12 8V4" />
<circle cx="12" cy="3" r="1" :fill="color.fg" />
<circle cx="9" cy="13" r="1.2" :fill="color.fg" stroke="none" />
<circle cx="15" cy="13" r="1.2" :fill="color.fg" stroke="none" />
<path d="M9 16.5h6" />
</svg>
</span>
</template>
@@ -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>
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { computed } from "vue";
import MarkdownIt from "markdown-it";
const props = defineProps<{ source: string }>();
const md = new MarkdownIt({ html: false, linkify: true, breaks: true });
const html = computed(() => md.render(props.source ?? ""));
</script>
<template>
<div class="pa-md" v-html="html" />
</template>
@@ -0,0 +1,60 @@
<script setup lang="ts">
/** 单条消息气泡(对齐 Step2 MessageBubble + Step2Message 结构):头像 / 发言人 / 状态徽标 /
* 时间 / 步骤条 / Markdown 正文 / 流式光标 / loading 占位。 */
import { computed } from "vue";
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; submitting?: boolean }>();
const emit = defineEmits<{ (e: "clarify", agentId: string, answer: string): void }>();
const hasContent = computed(() => (props.msg.content ?? "").trim().length > 0);
</script>
<template>
<div class="flex items-start gap-3">
<Avatar :type="msg.avatarType" :size="36" />
<div class="min-w-0 flex-1 space-y-1">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<span class="text-sm font-extrabold pa-text">{{ msg.sender }}</span>
<span v-if="msg.confidence" class="pa-badge">{{ msg.confidence }}</span>
</div>
<span class="shrink-0 font-mono text-[11px] pa-text-muted">{{ msg.time }}</span>
</div>
<MessageStepsCard
v-if="msg.steps && msg.steps.length"
:steps="msg.steps"
:streaming="msg.streaming"
/>
<div class="pa-text break-words">
<div
v-if="msg.streaming && !hasContent"
class="flex items-center gap-2 py-1 text-sm pa-text-muted"
>
<span class="pa-spin inline-block">◜</span>
<span>{{ msg.confidence || "正在生成…" }}</span>
<span class="inline-flex items-center gap-0.5">
<span class="pa-bounce inline-block size-1 rounded-full" style="background: var(--pa-primary)" />
<span class="pa-bounce inline-block size-1 rounded-full" style="background: var(--pa-primary); animation-delay: 0.15s" />
<span class="pa-bounce inline-block size-1 rounded-full" style="background: var(--pa-primary); animation-delay: 0.3s" />
</span>
</div>
<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>
@@ -0,0 +1,95 @@
<script setup lang="ts">
/**
* 步骤条 / 思维链卡 —— 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";
const props = defineProps<{ steps: DialogueStep[]; streaming?: boolean }>();
const open = ref(true);
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 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>执行步骤({{ steps.length }})</span>
</button>
<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'"
>
<!-- 图标列 + 竖向连接线 -->
<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>
@@ -0,0 +1,375 @@
<script setup lang="ts">
/**
* 并行多智能体面板(主组件,可嵌入)。
*
* 输入「任务名称 + 简介」,并行调用宿主传入的多个智能体(后端固定禁写文件),消息列表
* 照搬圆桌 Step2 并行多席位的深色样式与结构(步骤条 / 并行双列 / 席位状态条),支持保存
* 聊天记录 + 回显 + 重新分析。
*
* 自适应容器:根元素 `h-full w-full flex flex-col min-h-0`,宿主直接塞进抽屉 / 弹窗即可。
*/
import { computed, onMounted, reactive, ref, watch } from "vue";
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";
import ParallelSeatStrip from "./ParallelSeatStrip.vue";
import MessageBubble from "./MessageBubble.vue";
const props = withDefaults(
defineProps<{
/** 后端地址;不传用配置文件默认(config.ts 的 DEFAULT_BASE_URL)。 */
baseUrl?: string;
/** 已有 token(Bearer),与 username 二选一;都不传走配置默认地址 + 免密登录失败提示。 */
token?: string;
getToken?: () => string | Promise<string>;
/** 免密自动登录用户名(由宿主自己传)。不传 token 时用它登录换 token。 */
username?: string;
/** 仅当后端开启口令门时需要。 */
loginPassword?: string;
agents: AgentInput[];
taskId?: string;
model?: string;
dark?: boolean;
}>(),
{ dark: true },
);
const emit = defineEmits<{
(e: "saved", task: SavedTask): void;
(e: "update:taskId", id: string): void;
/** 一轮并行分析结束时回传各智能体的返回内容。 */
(e: "completed", results: AgentResult[]): void;
}>();
const cfg = computed<ApiConfig>(() => ({
baseUrl: props.baseUrl || DEFAULT_BASE_URL,
token: props.token,
getToken: props.getToken,
username: props.username,
loginPassword: props.loginPassword,
}));
const run = useParallelRun();
const { dialogues, seats, running } = run;
const form = reactive({ taskName: "", brief: "" });
const currentTaskId = ref<string | null>(props.taskId ?? null);
const selected = ref<string[]>([]);
const saving = ref(false);
const history = ref<SavedTask[]>([]);
const showHistory = ref(false);
const errorMsg = ref<string | null>(null);
// 模型切换:拉后端模型列表,空字符串 = 用默认(prop/config/后端回退)。
const models = ref<ModelItem[]>([]);
const selectedModel = ref<string>(props.model ?? DEFAULT_MODEL ?? "");
const dialogueById = computed(() => {
const m = new Map<number, Dialogue>();
for (const d of dialogues.value) m.set(d.id, d);
return m;
});
const hasResults = computed(() => dialogues.value.length > 0);
const canStart = computed(
() => !running.value && props.agents.length > 0 && (form.taskName.trim() || form.brief.trim()),
);
// 单选:主区每次只显示**一个**席位的消息列表,点头像切换。
watch(
seats,
(s) => {
if (!s.length) {
selected.value = [];
return;
}
// 保留仍存在的当前选中;首次 / 失效时默认显示第 1 个席位。
const cur = selected.value[0];
const keep = cur && s.some((x) => x.agentId === cur) ? cur : s[0].agentId;
selected.value = [keep];
},
{ immediate: true },
);
// 宿主传入 / 改变 taskId:作为存储主键,并尝试加载回显该任务(不存在则只设 id)。
watch(
() => props.taskId,
(id) => {
currentTaskId.value = id ?? null;
if (id) void loadTask(id);
},
);
/** 点头像切换显示:单选,始终切到点击的那个席位。 */
function toggleSeat(agentId: string) {
selected.value = [agentId];
}
const visibleSeats = computed(() => {
const id = selected.value[0] ?? seats.value[0]?.agentId;
const seat = seats.value.find((s) => s.agentId === id);
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;
try {
await run.start(cfg.value, {
taskName: form.taskName,
brief: form.brief,
agents: props.agents,
model: selectedModel.value || undefined,
});
// 一轮结束:回传各智能体返回内容(宿主也可用 ref 调 getResults())。
emit("completed", getResults());
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
}
}
async function onSave() {
if (saving.value || !hasResults.value) return;
saving.value = true;
errorMsg.value = null;
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;
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, payload);
}
if (saved) {
currentTaskId.value = saved.id;
emit("update:taskId", saved.id);
emit("saved", saved);
await refreshHistory();
}
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
} finally {
saving.value = false;
}
}
async function refreshHistory() {
try {
history.value = await listTasks(cfg.value);
} catch {
/* 历史拉取失败不阻断主流程 */
}
}
async function loadTask(id: string) {
showHistory.value = false;
errorMsg.value = null;
try {
const task = await getTask(cfg.value, id);
if (!task) return;
currentTaskId.value = task.id;
form.taskName = task.title ?? "";
form.brief = task.brief ?? "";
selected.value = [];
run.hydrate(task.messages ?? []);
emit("update:taskId", task.id);
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
}
}
async function refreshModels() {
const list = await listModels(cfg.value);
models.value = list;
// 当前选择不在列表里(且非空)时,回退为默认(空 = 后端回退)。
if (selectedModel.value && !list.some((m) => m.name === selectedModel.value)) {
selectedModel.value = "";
}
}
onMounted(() => {
void refreshHistory();
void refreshModels();
if (props.taskId) void loadTask(props.taskId);
});
</script>
<template>
<div
class="parallel-agents pa-bg flex h-full w-full min-h-0 flex-col"
:class="dark ? 'parallel-agents--dark' : ''"
>
<!-- 顶部:任务名 + 操作 -->
<div class="pa-border flex shrink-0 flex-col gap-2 border-b px-4 py-3">
<div class="flex items-center gap-2">
<input
v-model="form.taskName"
class="pa-input min-w-0 flex-1 px-3 py-1.5 text-sm"
placeholder="任务名称"
/>
<div class="relative shrink-0">
<button
type="button"
class="pa-btn-ghost px-2.5 py-1.5 text-xs"
@click="showHistory = !showHistory"
>
历史 ▾
</button>
<div
v-if="showHistory"
class="pa-card pa-border absolute right-0 z-20 mt-1 max-h-72 w-72 overflow-auto rounded-lg border p-1 shadow-lg"
>
<div v-if="!history.length" class="px-3 py-4 text-center text-xs pa-text-muted">
暂无保存的任务
</div>
<button
v-for="t in history"
:key="t.id"
type="button"
class="pa-btn-ghost block w-full truncate px-2.5 py-1.5 text-left text-xs"
style="border: 0"
@click="loadTask(t.id)"
>
<span class="font-semibold">{{ t.title || "未命名任务" }}</span>
<span class="pa-text-muted"> · {{ (t.updated_at || "").slice(0, 16).replace("T", " ") }}</span>
</button>
</div>
</div>
</div>
<textarea
v-model="form.brief"
rows="2"
class="pa-input w-full resize-y px-3 py-1.5 text-sm"
placeholder="任务简介:把背景、目标、约束写清楚,会并行下发给各智能体"
/>
<div class="flex flex-wrap items-center gap-2">
<button
v-if="!running"
type="button"
class="pa-btn-primary px-3.5 py-1.5 text-sm font-semibold"
:disabled="!canStart"
@click="onStart"
>
{{ hasResults ? "重新分析" : "开始分析" }}
</button>
<button
v-else
type="button"
class="pa-btn-ghost px-3.5 py-1.5 text-sm font-semibold"
@click="run.cancel()"
>
停止
</button>
<button
type="button"
class="pa-btn-ghost px-3.5 py-1.5 text-sm font-semibold"
:disabled="saving || !hasResults"
@click="onSave"
>
{{ saving ? "保存中…" : currentTaskId ? "更新保存" : "保存" }}
</button>
<!-- 模型切换:作用于下一次「开始分析 / 重新分析」 -->
<label class="ml-auto flex items-center gap-1.5 text-xs pa-text-muted">
模型
<select
v-model="selectedModel"
:disabled="running"
class="pa-input px-2 py-1 text-xs"
title="选择本次并行分析使用的模型"
>
<option value="">默认</option>
<option v-for="m in models" :key="m.name" :value="m.name">
{{ m.display_name || m.name }}
</option>
</select>
</label>
<span v-if="props.agents.length === 0" class="text-xs pa-text-muted">
⚠️ 未传入任何智能体(agents 为空)
</span>
</div>
<p v-if="errorMsg" class="text-xs" style="color: var(--pa-rose)">{{ errorMsg }}</p>
</div>
<!-- 席位状态条 -->
<div v-if="seats.length" class="pa-border shrink-0 border-b px-4">
<ParallelSeatStrip :seats="seats" :selected="selected" @toggle="toggleSeat" />
</div>
<!-- 会话区:并行双列 -->
<div class="min-h-0 flex-1 overflow-y-auto p-4">
<div
v-if="hasResults"
class="grid gap-4"
:class="visibleSeats.length > 1 ? 'grid-cols-1 lg:grid-cols-2' : 'grid-cols-1'"
>
<div
v-for="seat in visibleSeats"
:key="seat.agentId"
class="pa-surface-soft pa-border flex min-w-0 flex-col rounded-xl border p-3"
>
<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 }} · 等待输出…
</div>
</div>
</div>
<div
v-else
class="flex h-full flex-col items-center justify-center gap-2 text-center"
>
<p class="text-sm pa-text-muted">填写任务名称与简介,点「开始分析」并行调用各智能体。</p>
<p class="text-xs pa-text-muted">并行运行期间禁止写文件,产出直接呈现在正文。</p>
</div>
</div>
</div>
</template>
@@ -0,0 +1,60 @@
<script setup lang="ts">
/** 并行席位状态条(对齐 Step2 ParallelSeatStrip):席位头像 + running/done/error 角标,
* 点击可切换主区显示哪两列。 */
import { computed } from "vue";
import type { SeatView } from "@/types";
import Avatar from "./Avatar.vue";
const props = defineProps<{ seats: SeatView[]; selected: string[] }>();
const emit = defineEmits<{ (e: "toggle", agentId: string): void }>();
const runningCount = computed(() => props.seats.filter((s) => s.status === "running").length);
</script>
<template>
<div class="flex items-center gap-2 overflow-x-auto py-1">
<span class="shrink-0 text-[11px] font-bold pa-text-muted">
并行 {{ seats.length }} 席
<span v-if="runningCount > 0" class="pa-text-primary">({{ runningCount }} 进行中)</span>
<span v-if="seats.length > 1" class="font-normal pa-text-muted">· 点头像切换显示</span>
</span>
<div class="flex items-center gap-2">
<button
v-for="seat in seats"
:key="seat.agentId"
type="button"
class="relative shrink-0 rounded-full transition"
:style="
selected.includes(seat.agentId)
? 'box-shadow: 0 0 0 2px var(--pa-primary)'
: ''
"
:title="seat.name"
@click="emit('toggle', seat.agentId)"
>
<span
v-if="seat.status === 'running'"
class="pa-ping absolute inset-0 rounded-full"
style="background: rgba(22, 119, 255, 0.3)"
/>
<span class="relative block" :style="!selected.includes(seat.agentId) ? 'opacity:0.8' : ''">
<Avatar :type="seat.avatarType" :size="28" />
</span>
<span
class="absolute -bottom-0.5 -right-0.5 flex size-3.5 items-center justify-center rounded-full text-[8px] font-black"
style="border: 1px solid var(--pa-bg)"
:class="{
'pa-dot-running': seat.status === 'running',
'pa-dot-done': seat.status === 'done',
'pa-dot-error': seat.status === 'error',
}"
:style="seat.status === 'pending' ? 'background: var(--pa-border)' : ''"
>
<span v-if="seat.status === 'running'" class="pa-spin">◜</span>
<span v-else-if="seat.status === 'done'">✓</span>
<span v-else-if="seat.status === 'error'">✕</span>
</span>
</button>
</div>
</div>
</template>
@@ -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>
@@ -0,0 +1,274 @@
/**
* 并行运行编排(前端态)。消费后端合并 SSE,按 agent_id 把增量/步骤/收口路由到对应席位
* 气泡,维护并行席位状态条;带轻量节流(rAF 合并增量),支持取消、回显 hydrate、重置。
*/
import { ref, shallowRef } from "vue";
import type { AgentInput, Dialogue, SeatView, StreamEvent } from "@/types";
import { getAvatarTypeFor } from "@/lib/avatar-constants";
import { streamParallelAgents, type ApiConfig } from "@/api";
function nowTime(): string {
const d = new Date();
const p = (n: number) => String(n).padStart(2, "0");
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
let _seq = 1;
function nextId(): number {
return _seq++;
}
export function useParallelRun() {
const dialogues = ref<Dialogue[]>([]);
const seats = ref<SeatView[]>([]);
const running = ref(false);
const abortRef = shallowRef<AbortController | null>(null);
// 增量节流:累积到 pending,rAF 合并刷新一次,避免逐 token reflow。
const pending = new Map<string, string>(); // agentId -> 待追加文本
let rafScheduled = false;
const bubbleByAgent = new Map<string, number>(); // agentId -> dialogue.id
function flushPending() {
rafScheduled = false;
if (pending.size === 0) return;
for (const [agentId, text] of pending) {
const bid = bubbleByAgent.get(agentId);
const d = dialogues.value.find((x) => x.id === bid);
if (d) d.content += text;
}
pending.clear();
// 触发数组响应(对象内字段已变更,这里强制一次浅刷新以更新双列视图)。
dialogues.value = [...dialogues.value];
}
function scheduleFlush() {
if (rafScheduled) return;
rafScheduled = true;
requestAnimationFrame(flushPending);
}
function setSeatStatus(agentId: string, status: SeatView["status"]) {
const s = seats.value.find((x) => x.agentId === agentId);
if (s) s.status = status;
seats.value = [...seats.value];
}
function findDialogue(agentId: string): Dialogue | undefined {
const bid = bubbleByAgent.get(agentId);
return dialogues.value.find((x) => x.id === bid);
}
function handleEvent(ev: StreamEvent) {
switch (ev.type) {
case "agent_start":
setSeatStatus(ev.agent_id, "running");
break;
case "delta":
pending.set(ev.agent_id, (pending.get(ev.agent_id) ?? "") + ev.text);
scheduleFlush();
break;
case "step": {
const d = findDialogue(ev.agent_id);
if (d) {
d.steps = d.steps ?? [];
d.steps.push({
id: `${d.id}-${d.steps.length}`,
key: ev.key,
label: ev.label,
ts: nowTime(),
args: ev.args ?? null,
});
dialogues.value = [...dialogues.value];
}
break;
}
case "agent_done": {
flushPending();
const d = findDialogue(ev.agent_id);
if (d) {
if (ev.content) d.content = ev.content; // 终态正文以后端为准
d.streaming = false;
d.confidence = "已完成";
}
setSeatStatus(ev.agent_id, "done");
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);
if (d) {
d.content = d.content || `⚠️ 该智能体执行出错:${ev.message}`;
d.streaming = false;
d.confidence = "出错";
}
setSeatStatus(ev.agent_id, "error");
dialogues.value = [...dialogues.value];
break;
}
case "all_done":
flushPending();
break;
}
}
/** 启动一轮并行运行。会清空旧对话、为每个 agent 建一个流式气泡。 */
async function start(
cfg: ApiConfig,
args: { taskName: string; brief: string; agents: AgentInput[]; model?: string },
): Promise<void> {
if (running.value) return;
reset();
running.value = true;
bubbleByAgent.clear();
const newSeats: SeatView[] = [];
const newDialogues: Dialogue[] = [];
args.agents.forEach((a, i) => {
const avatarType = a.avatarType || getAvatarTypeFor(a.agent_id, i);
const bubbleId = nextId();
bubbleByAgent.set(a.agent_id, bubbleId);
newDialogues.push({
id: bubbleId,
agentId: a.agent_id,
sender: a.name || a.agent_id,
avatarType,
content: "",
time: nowTime(),
confidence: "并行执行中",
streaming: true,
steps: [],
});
newSeats.push({ agentId: a.agent_id, name: a.name || a.agent_id, avatarType, status: "pending", bubbleId });
});
dialogues.value = newDialogues;
seats.value = newSeats;
const ac = new AbortController();
abortRef.value = ac;
try {
await streamParallelAgents(
cfg,
{
task_name: args.taskName,
brief: args.brief,
agent_ids: args.agents.map((a) => a.agent_id),
model: args.model,
},
{ onEvent: handleEvent, onError: (err) => console.error("[parallel-run]", err) },
ac.signal,
);
} finally {
// 收尾:任何仍在 streaming 的气泡都置完成态。
for (const d of dialogues.value) {
if (d.streaming) {
d.streaming = false;
if (d.confidence === "并行执行中") d.confidence = "已完成";
}
}
dialogues.value = [...dialogues.value];
running.value = false;
abortRef.value = null;
}
}
function cancel() {
abortRef.value?.abort();
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();
bubbleByAgent.clear();
const restored = saved.map((d) => ({ ...d, streaming: false }));
dialogues.value = restored;
const seatMap = new Map<string, SeatView>();
restored.forEach((d) => {
bubbleByAgent.set(d.agentId, d.id);
if (!seatMap.has(d.agentId)) {
seatMap.set(d.agentId, {
agentId: d.agentId,
name: d.sender,
avatarType: d.avatarType,
status: d.confidence === "出错" ? "error" : "done",
bubbleId: d.id,
});
}
});
seats.value = [...seatMap.values()];
}
function reset() {
cancel();
pending.clear();
bubbleByAgent.clear();
dialogues.value = [];
seats.value = [];
}
return { dialogues, seats, running, start, cancel, hydrate, reset, rerunAgent };
}
+19
View File
@@ -0,0 +1,19 @@
/**
* 前端配置:后端接口地址等。嵌入方一般直接改这里的默认值即可;也可在 dev 用
* Vite 环境变量覆盖(VITE_PA_BASE_URL)。组件 props 传入时优先级最高。
*/
/** DeerFlow 网关默认地址(接口都打到这里)。 */
export const DEFAULT_BASE_URL: string =
(import.meta as unknown as { env?: Record<string, string> }).env?.VITE_PA_BASE_URL ||
"http://localhost:8001";
/** 可选默认模型名(不填由后端回退到 config.yaml models[0])。 */
export const DEFAULT_MODEL: string | undefined =
(import.meta as unknown as { env?: Record<string, string> }).env?.VITE_PA_MODEL || undefined;
/**
* 取 token 端点:本面板**专用**的「绕过常规登录」公共端点(传用户名直接签发 token)。
* 内网部署对标准 /api/v1/auth/login/username 有额外限制,故走这个专用端点。
*/
export const TOKEN_PATH = "/api/parallel-agents/auth/token";
+18
View File
@@ -0,0 +1,18 @@
/**
* 组件库入口。其它 Vue 项目:
* import { ParallelAgentsPanel } from "parallel-agents-vue";
* import "parallel-agents-vue/style.css";
* 或全局注册:app.use(ParallelAgentsVue)。
*/
import "./styles/parallel-agents.css";
import type { App } from "vue";
import ParallelAgentsPanel from "./components/ParallelAgentsPanel.vue";
export { ParallelAgentsPanel };
export type { AgentInput, AgentResult, Dialogue, SavedTask, SeatView, StreamEvent } from "./types";
export default {
install(app: App) {
app.component("ParallelAgentsPanel", ParallelAgentsPanel);
},
};
@@ -0,0 +1,27 @@
/** 头像配色(对齐圆桌 Step2 的 avatarType:blue/purple/emerald/amber/pink/human)。 */
export const FALLBACK_AVATAR_TYPES = ["blue", "purple", "emerald", "amber", "pink"] as const;
/** 无显式 avatarType 时按下标轮转一个配色(与 Step2 getAvatarTypeFor 行为一致)。 */
export function getAvatarTypeFor(_agentId: string, index: number): string {
return FALLBACK_AVATAR_TYPES[index % FALLBACK_AVATAR_TYPES.length];
}
/** 每种 avatarType 在深色面板里的底色 + 主体色(机器人头 / 真人)。 */
export const AVATAR_COLORS: Record<string, { bg: string; fg: string }> = {
blue: { bg: "rgba(22,119,255,0.18)", fg: "#4096ff" },
purple: { bg: "rgba(124,58,237,0.20)", fg: "#a78bfa" },
emerald: { bg: "rgba(16,185,129,0.20)", fg: "#34d399" },
amber: { bg: "rgba(245,158,11,0.20)", fg: "#fbbf24" },
pink: { bg: "rgba(219,39,119,0.20)", fg: "#f472b6" },
human: { bg: "rgba(148,163,184,0.20)", fg: "#cbd5e1" },
man: { bg: "rgba(148,163,184,0.20)", fg: "#cbd5e1" },
};
export function avatarColor(type: string): { bg: string; fg: string } {
return AVATAR_COLORS[type] ?? AVATAR_COLORS.blue;
}
export function isHuman(type: string): boolean {
return type === "human" || type === "man";
}
+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;
}
}
}
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from "vue";
import App from "./App.vue";
import "./styles/parallel-agents.css";
createApp(App).mount("#app");
@@ -0,0 +1,282 @@
/* 并行多智能体面板样式。
*
* 嵌入安全:只引 Tailwind 的 theme + utilities(**不引 preflight**,避免重置宿主全局样式)。
* 颜色用作用域内的 `--pa-*` 深蓝 token(取自《深蓝 UI 规范》ZS-004 / ZS-002,与圆桌 Step2
* 深色一致),全部包在根类 `.parallel-agents` 下,绝不污染宿主。布局用 Tailwind 工具类,
* 主题色用下面的 `.pa-*` 语义类。 */
@layer theme, base, components, utilities;
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/utilities.css" layer(utilities);
/* ── 作用域 token(深色深蓝,自带成色,不依赖宿主主题)─────────────────────── */
.parallel-agents {
--pa-bg: #0c1836; /* 页面背景 zs-004-09 */
--pa-card: #122347; /* 卡片 / 气泡容器 zs-004-08 */
--pa-muted: #16294d; /* 次级表面(步骤卡 / 并行列底) */
--pa-accent: #1a2f5a; /* hover / 软高亮 */
--pa-fg: #c5cfe0; /* 正文 zs-004-02 */
--pa-fg-muted: #6e88b0; /* 次级文字 zs-004-04 */
--pa-border: #2d4473; /* 容器边框 zs-004-06 */
--pa-input: #4a6491; /* 控件描边 */
--pa-primary: #1677ff; /* 主操作 zs-002-06 */
--pa-primary-fg: #ffffff;
--pa-chip-bg: #16335f; /* badge 底 */
--pa-chip-fg: #4096ff; /* badge 文字 */
--pa-amber: #f59e0b;
--pa-emerald: #10b981;
--pa-rose: #ef4444;
color: var(--pa-fg);
background-color: var(--pa-bg);
font-size: 14px;
line-height: 1.6;
box-sizing: border-box;
}
.parallel-agents *,
.parallel-agents *::before,
.parallel-agents *::after {
box-sizing: border-box;
}
/* 滚动条 */
.parallel-agents ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.parallel-agents ::-webkit-scrollbar-thumb {
background: color-mix(in oklch, var(--pa-primary) 40%, var(--pa-border));
border-radius: 999px;
}
.parallel-agents ::-webkit-scrollbar-thumb:hover {
background: color-mix(in oklch, var(--pa-primary) 60%, var(--pa-border));
}
/* ── 语义颜色类(组件用这些上色,布局仍用 Tailwind 工具类)──────────────────── */
.parallel-agents .pa-bg {
background-color: var(--pa-bg);
}
.parallel-agents .pa-card {
background-color: var(--pa-card);
}
.parallel-agents .pa-surface {
background-color: var(--pa-muted);
}
.parallel-agents .pa-surface-soft {
background-color: color-mix(in oklch, var(--pa-muted) 55%, transparent);
}
.parallel-agents .pa-border {
border-color: var(--pa-border);
}
.parallel-agents .pa-text {
color: var(--pa-fg);
}
.parallel-agents .pa-text-muted {
color: var(--pa-fg-muted);
}
.parallel-agents .pa-text-primary {
color: var(--pa-primary);
}
/* 输入框 / 文本域 */
.parallel-agents .pa-input {
background-color: var(--pa-card);
border: 1px solid var(--pa-border);
color: var(--pa-fg);
border-radius: 8px;
}
.parallel-agents .pa-input::placeholder {
color: color-mix(in oklch, var(--pa-fg-muted) 80%, transparent);
}
.parallel-agents .pa-input:focus {
outline: none;
border-color: color-mix(in oklch, var(--pa-primary) 55%, var(--pa-border));
}
/* 主按钮 / 次按钮 */
.parallel-agents .pa-btn-primary {
background-color: var(--pa-primary);
color: var(--pa-primary-fg);
border-radius: 8px;
transition: filter 0.15s ease;
}
.parallel-agents .pa-btn-primary:hover {
filter: brightness(1.08);
}
.parallel-agents .pa-btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.parallel-agents .pa-btn-ghost {
border: 1px solid var(--pa-border);
color: var(--pa-fg);
background-color: transparent;
border-radius: 8px;
transition: background-color 0.15s ease;
}
.parallel-agents .pa-btn-ghost:hover {
background-color: var(--pa-accent);
}
.parallel-agents .pa-btn-ghost:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 状态徽标("实时输出" / "已完成" …)—— 对齐 Step2 confidence chip */
.parallel-agents .pa-badge {
background-color: var(--pa-chip-bg);
color: var(--pa-chip-fg);
border: 1px solid var(--pa-border);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-weight: 800;
text-transform: uppercase;
border-radius: 4px;
padding: 1px 6px;
font-size: 11px;
letter-spacing: 0.02em;
}
/* 席位状态角标色 */
.parallel-agents .pa-dot-running {
background-color: var(--pa-primary);
color: #fff;
}
.parallel-agents .pa-dot-done {
background-color: var(--pa-emerald);
color: #fff;
}
.parallel-agents .pa-dot-error {
background-color: var(--pa-rose);
color: #fff;
}
/* 流式光标 + loading 点 */
.parallel-agents .pa-cursor {
display: inline-block;
width: 4px;
height: 13px;
margin-left: 2px;
vertical-align: middle;
background: var(--pa-primary);
animation: pa-pulse 1s ease-in-out infinite;
}
@keyframes pa-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.25;
}
}
.parallel-agents .pa-bounce {
animation: pa-bounce 0.9s infinite;
}
@keyframes pa-bounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-3px);
}
}
@keyframes pa-spin {
to {
transform: rotate(360deg);
}
}
.parallel-agents .pa-spin {
animation: pa-spin 0.8s linear infinite;
}
@keyframes pa-ping {
75%,
100% {
transform: scale(1.8);
opacity: 0;
}
}
.parallel-agents .pa-ping {
animation: pa-ping 1.2s cubic-bezier(0, 0, 0.2, 1) infinite;
}
/* ── Markdown 正文(对齐 Step2 sharedMarkdownComponents 的深色观感)──────────── */
.parallel-agents .pa-md {
color: var(--pa-fg);
font-size: 14px;
line-height: 1.7;
word-break: break-word;
}
.parallel-agents .pa-md p {
margin: 0.35em 0;
}
.parallel-agents .pa-md h1,
.parallel-agents .pa-md h2,
.parallel-agents .pa-md h3,
.parallel-agents .pa-md h4 {
font-weight: 700;
margin: 0.7em 0 0.35em;
color: var(--pa-fg);
}
.parallel-agents .pa-md h1 {
font-size: 1.3em;
}
.parallel-agents .pa-md h2 {
font-size: 1.18em;
}
.parallel-agents .pa-md h3 {
font-size: 1.06em;
}
.parallel-agents .pa-md ul,
.parallel-agents .pa-md ol {
margin: 0.35em 0;
padding-left: 1.4em;
}
.parallel-agents .pa-md li {
margin: 0.2em 0;
}
.parallel-agents .pa-md a {
color: var(--pa-chip-fg);
text-decoration: underline;
}
.parallel-agents .pa-md code {
background: var(--pa-muted);
border: 1px solid var(--pa-border);
border-radius: 4px;
padding: 0.1em 0.35em;
font-size: 0.9em;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.parallel-agents .pa-md pre {
background: var(--pa-muted);
border: 1px solid var(--pa-border);
border-radius: 8px;
padding: 0.75em;
overflow-x: auto;
margin: 0.5em 0;
}
.parallel-agents .pa-md pre code {
background: transparent;
border: 0;
padding: 0;
}
.parallel-agents .pa-md blockquote {
border-left: 3px solid var(--pa-border);
padding-left: 0.75em;
color: var(--pa-fg-muted);
margin: 0.5em 0;
}
.parallel-agents .pa-md table {
border-collapse: collapse;
margin: 0.5em 0;
width: 100%;
}
.parallel-agents .pa-md th,
.parallel-agents .pa-md td {
border: 1px solid var(--pa-border);
padding: 0.35em 0.6em;
text-align: left;
}
.parallel-agents .pa-md th {
background: var(--pa-muted);
}
+102
View File
@@ -0,0 +1,102 @@
/**
* 并行多智能体面板的共享类型。
*
* `Dialogue` 镜像圆桌 Step2 的 `Step2Dialogue`,外加 `agentId`(并行场景下用来把消息
* 归到对应席位列)。`StreamEvent` 是后端 `/api/parallel-agents/run/stream` 的统一事件协议。
*/
/** 一条工具调用步骤(喂步骤条 / 思维链卡)。`key` 形如 `tool_calling:<toolName>`。 */
export interface DialogueStep {
id: string;
key: string;
label: string;
ts: string;
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;
/** 归属席位的 agent_id(并行列定位用)。 */
agentId: string;
sender: string;
avatarType: string;
content: string;
time: string;
/** 状态标签("实时输出" / "已完成" / "出错" 等)。 */
confidence: string;
streaming?: boolean;
steps?: DialogueStep[];
/** 该智能体在要澄清时的协作卡片载荷(有则渲染卡片)。 */
clarification?: ClarificationPayload;
}
/** 并行席位的视图状态(席位状态条用)。 */
export interface SeatView {
agentId: string;
name: string;
avatarType: string;
status: "pending" | "running" | "done" | "error";
/** 关联的 Dialogue.id。 */
bubbleId: number;
}
/** 宿主传入的一个待并行调用的智能体。 */
export interface AgentInput {
agent_id: string;
name: string;
avatarType?: string;
}
/** 后端合并 SSE 的统一事件。 */
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;
title: string;
brief?: string | null;
agents: AgentInput[];
messages?: Dialogue[];
created_at?: string | null;
updated_at?: string | null;
}
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["vite/client"],
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
}
+40
View File
@@ -0,0 +1,40 @@
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import tailwindcss from "@tailwindcss/vite";
// 两种构建:
// - 默认(`vite build` / `vite dev`):独立 demo 应用(index.html)。
// - `--mode lib`(`pnpm build:lib`):组件库,产出 ESM + UMD + 一个 style.css,
// 供其它 Vue 项目 `import { ParallelAgentsPanel } from "parallel-agents-vue"`。
export default defineConfig(({ mode }) => {
const isLib = mode === "lib";
return {
plugins: [vue(), tailwindcss()],
resolve: {
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
},
server: { port: 5183 },
build: isLib
? {
lib: {
entry: fileURLToPath(new URL("./src/index.ts", import.meta.url)),
name: "ParallelAgentsVue",
fileName: "parallel-agents-vue",
},
rollupOptions: {
// vue 由宿主提供,不打进库。
external: ["vue"],
output: {
exports: "named",
globals: { vue: "Vue" },
assetFileNames: "parallel-agents-vue.[ext]",
},
},
cssCodeSplit: false,
}
: {
outDir: "dist-app",
},
};
});