Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7422f083b4 | ||
|
|
5080f1dc39 | ||
|
|
cba165933c | ||
|
|
dddd3c1670 | ||
|
|
10795d55e6 | ||
|
|
230fab013a | ||
|
|
2082d20b88 | ||
|
|
c230f7a6c6 | ||
|
|
3405795371 | ||
|
|
1938c80061 | ||
|
|
b101ee7fc9 | ||
|
|
c05e7df48e | ||
|
|
834df64650 | ||
|
|
004853f030 | ||
|
|
37be59b0dd |
No files matched your search
@@ -4,7 +4,8 @@
|
|||||||
"PowerShell(uv --version)",
|
"PowerShell(uv --version)",
|
||||||
"PowerShell(netstat -ano)",
|
"PowerShell(netstat -ano)",
|
||||||
"PowerShell(Get-NetTCPConnection -LocalPort 8001 -ErrorAction SilentlyContinue)",
|
"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)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+10540
File diff suppressed because it is too large.
Load diff
@@ -119,7 +119,8 @@
|
|||||||
border: 1px solid;
|
border: 1px solid;
|
||||||
border-color: rgba(43, 143, 254, 0.6);
|
border-color: rgba(43, 143, 254, 0.6);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
backdrop-filter: blur(2px);
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 108px;
|
left: 108px;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|||||||
@@ -207,6 +207,12 @@ export const sidebarMenuItems: MenuItem[] = [
|
|||||||
icon: Settings,
|
icon: Settings,
|
||||||
children: [
|
children: [
|
||||||
{ id: "mgmt-agents", label: "智能体管理", icon: Bot, path: "/page/strategy/agents" },
|
{ 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-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
|
||||||
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
|
{ 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from "@/components/workspace/messages";
|
} from "@/components/workspace/messages";
|
||||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||||
import { TodoList } from "@/components/workspace/todo-list";
|
import { TodoList } from "@/components/workspace/todo-list";
|
||||||
|
import { getAccessToken } from "@/core/auth";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { useModels } from "@/core/models/hooks";
|
import { useModels } from "@/core/models/hooks";
|
||||||
import { useNotification } from "@/core/notification/hooks";
|
import { useNotification } from "@/core/notification/hooks";
|
||||||
@@ -33,6 +34,13 @@ import {
|
|||||||
} from "./iframe-chat-submit";
|
} from "./iframe-chat-submit";
|
||||||
|
|
||||||
type BrainyChatStatus = "started" | "finished";
|
type BrainyChatStatus = "started" | "finished";
|
||||||
|
type BrainyAuthTokenMessage = {
|
||||||
|
source: "brainy-iframe";
|
||||||
|
type: "brainy:auth-token";
|
||||||
|
payload: {
|
||||||
|
accessToken: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
type BrainyChatInterruptMessage = {
|
type BrainyChatInterruptMessage = {
|
||||||
source?: string;
|
source?: string;
|
||||||
type?: 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() {
|
export default function IframeChatPage() {
|
||||||
const routeBase = useRouteBase();
|
const routeBase = useRouteBase();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -79,6 +99,28 @@ export default function IframeChatPage() {
|
|||||||
setMounted(true);
|
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(() => {
|
useEffect(() => {
|
||||||
if (isNewThread) {
|
if (isNewThread) {
|
||||||
setHasStartedConversation(false);
|
setHasStartedConversation(false);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanni
|
|||||||
import "@/roundtable-planning/styles/roundtable-planning.css";
|
import "@/roundtable-planning/styles/roundtable-planning.css";
|
||||||
import AgentChatPage from "./AgentChatPage";
|
import AgentChatPage from "./AgentChatPage";
|
||||||
import AgentsPage from "./AgentsPage";
|
import AgentsPage from "./AgentsPage";
|
||||||
|
import BusinessChainMappingPage from "./BusinessChainMappingPage";
|
||||||
import ChatPage from "./ChatPage";
|
import ChatPage from "./ChatPage";
|
||||||
import ChatsPage from "./ChatsPage";
|
import ChatsPage from "./ChatsPage";
|
||||||
import AdminUserLeaderboardPage from "./AdminUserLeaderboardPage";
|
import AdminUserLeaderboardPage from "./AdminUserLeaderboardPage";
|
||||||
@@ -172,6 +173,7 @@ export default function StrategyRoutes() {
|
|||||||
<Route path="skills" element={<SkillsPage />} />
|
<Route path="skills" element={<SkillsPage />} />
|
||||||
<Route path="skills/evolution" element={<SkillEvolutionPage />} />
|
<Route path="skills/evolution" element={<SkillEvolutionPage />} />
|
||||||
<Route path="memory" element={<MemoryPage />} />
|
<Route path="memory" element={<MemoryPage />} />
|
||||||
|
<Route path="business-chain-mapping" element={<BusinessChainMappingPage />} />
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="scheduled"
|
path="scheduled"
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ export interface ChainCreatePayload {
|
|||||||
seats: ChainSeat[];
|
seats: ChainSeat[];
|
||||||
/** Optional layered orchestration (agent_id groups). null/absent = linear. */
|
/** Optional layered orchestration (agent_id groups). null/absent = linear. */
|
||||||
stages?: string[][] | null;
|
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. */
|
/** Partial update — send only what changed. */
|
||||||
@@ -33,6 +37,10 @@ export interface ChainUpdatePayload {
|
|||||||
seats?: ChainSeat[];
|
seats?: ChainSeat[];
|
||||||
/** Send `null` to clear layering back to linear; omit to leave unchanged. */
|
/** Send `null` to clear layering back to linear; omit to leave unchanged. */
|
||||||
stages?: string[][] | null;
|
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 生效。 */
|
/** 发布开关:true 发布为公共 / false 取消发布;omit 不改。仅 owner 生效。 */
|
||||||
isPublic?: boolean;
|
isPublic?: boolean;
|
||||||
}
|
}
|
||||||
@@ -60,6 +68,8 @@ interface ServerChain {
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
seats: ChainSeat[];
|
seats: ChainSeat[];
|
||||||
stages?: string[][] | null;
|
stages?: string[][] | null;
|
||||||
|
stage_goals?: (string | null)[] | null;
|
||||||
|
coordinator_prompt?: string | null;
|
||||||
is_public?: boolean;
|
is_public?: boolean;
|
||||||
is_mine?: boolean;
|
is_mine?: boolean;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
@@ -73,6 +83,8 @@ function toChain(s: ServerChain): BusinessChain {
|
|||||||
description: s.description ?? undefined,
|
description: s.description ?? undefined,
|
||||||
seats: Array.isArray(s.seats) ? s.seats : [],
|
seats: Array.isArray(s.seats) ? s.seats : [],
|
||||||
stages: Array.isArray(s.stages) ? s.stages : null,
|
stages: Array.isArray(s.stages) ? s.stages : null,
|
||||||
|
stageGoals: Array.isArray(s.stage_goals) ? s.stage_goals : null,
|
||||||
|
coordinatorPrompt: s.coordinator_prompt ?? null,
|
||||||
isPublic: s.is_public ?? false,
|
isPublic: s.is_public ?? false,
|
||||||
// 后端缺省时按「我的」处理(向后兼容旧响应)。
|
// 后端缺省时按「我的」处理(向后兼容旧响应)。
|
||||||
isMine: s.is_mine ?? true,
|
isMine: s.is_mine ?? true,
|
||||||
@@ -88,6 +100,8 @@ function fromCreate(p: ChainCreatePayload) {
|
|||||||
...(p.description !== undefined ? { description: p.description } : {}),
|
...(p.description !== undefined ? { description: p.description } : {}),
|
||||||
seats: p.seats,
|
seats: p.seats,
|
||||||
...(p.stages !== undefined ? { stages: p.stages } : {}),
|
...(p.stages !== undefined ? { stages: p.stages } : {}),
|
||||||
|
...(p.stageGoals !== undefined ? { stage_goals: p.stageGoals } : {}),
|
||||||
|
...(p.coordinatorPrompt !== undefined ? { coordinator_prompt: p.coordinatorPrompt } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +112,9 @@ function fromUpdate(p: ChainUpdatePayload) {
|
|||||||
if (p.seats !== undefined) out.seats = p.seats;
|
if (p.seats !== undefined) out.seats = p.seats;
|
||||||
// Forward stages even when explicitly null (clears layering server-side).
|
// Forward stages even when explicitly null (clears layering server-side).
|
||||||
if (p.stages !== undefined) out.stages = p.stages;
|
if (p.stages !== undefined) out.stages = p.stages;
|
||||||
|
// Forward stage goals even when explicitly null (clears goals server-side).
|
||||||
|
if (p.stageGoals !== undefined) out.stage_goals = p.stageGoals;
|
||||||
|
if (p.coordinatorPrompt !== undefined) out.coordinator_prompt = p.coordinatorPrompt;
|
||||||
if (p.isPublic !== undefined) out.is_public = p.isPublic;
|
if (p.isPublic !== undefined) out.is_public = p.isPublic;
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
const canUse = !!selectedChain && survivingSeats.length >= CHAIN_MIN_SEATS;
|
||||||
|
|
||||||
// 选中链条若有分层、且过滤到存活席位后**仍含并行 stage** → 走 dag。此时让用户在
|
// 选中链条若有分层、且过滤到存活席位后**仍含并行 stage,或任一 stage 带「本阶段目标」**
|
||||||
|
// → 走 dag。并行要 dag 才能并行调度;带目标也要 dag —— 目标靠总控**逐阶段派活**注入
|
||||||
|
// (buildDagStageLeaderTask),chain 模式不消费目标,故串行带目标链同样走 dag。此时让用户在
|
||||||
// 「快速(并行双列禁写) / 沙箱(并行单列可写、沙箱跟随)」之间二选一(§DAG 执行子模式)。
|
// 「快速(并行双列禁写) / 沙箱(并行单列可写、沙箱跟随)」之间二选一(§DAG 执行子模式)。
|
||||||
const dagPlan = useMemo(() => {
|
const dagPlan = useMemo(() => {
|
||||||
if (!selectedChain?.stages || selectedChain.stages.length === 0) return null;
|
if (!selectedChain?.stages || selectedChain.stages.length === 0) return null;
|
||||||
const surviveIds = survivingSeats.map((s) => s.agent_id);
|
const surviveIds = survivingSeats.map((s) => s.agent_id);
|
||||||
const plan = buildPlanFromStages(selectedChain.stages, surviveIds);
|
const plan = buildPlanFromStages(
|
||||||
return plan.stages.some((st) => st.agentIds.length > 1) ? plan : null;
|
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]);
|
}, [selectedChain, survivingSeats]);
|
||||||
const [dagExecMode, setDagExecMode] = useState<"fast" | "sandbox">("fast");
|
const [dagExecMode, setDagExecMode] = useState<"fast" | "sandbox">("fast");
|
||||||
|
|
||||||
|
|||||||
@@ -211,6 +211,13 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
|||||||
[artifactsBySeat],
|
[artifactsBySeat],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 只保留「有产出文件」的席位;加载中 / 出错的席位仍保留以展示其状态。
|
||||||
|
// 全部席位都没有文件(且都已加载完成、无错误)时,整块「生成的报告文件」隐藏。
|
||||||
|
const seatsWithFiles = useMemo(
|
||||||
|
() => artifactsBySeat.filter((s) => s.loading || s.error || s.files.length > 0),
|
||||||
|
[artifactsBySeat],
|
||||||
|
);
|
||||||
|
|
||||||
const toggleSeat = useCallback((key: string) => {
|
const toggleSeat = useCallback((key: string) => {
|
||||||
setExpandedSeats((prev) => {
|
setExpandedSeats((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -424,21 +431,20 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Section 3: Generated files per seat —— 点击「查看」进入沙箱分栏预览 */}
|
{/* 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">
|
{seatsWithFiles.length > 0 && (
|
||||||
<Download className="w-4 h-4 text-primary" />
|
<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">
|
||||||
<span className="text-[10px] font-mono font-semibold text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">
|
<Download className="w-4 h-4 text-primary" />
|
||||||
{totalFileCount}
|
生成的报告文件
|
||||||
</span>
|
<span className="text-[10px] font-mono font-semibold text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">
|
||||||
</h4>
|
{totalFileCount}
|
||||||
|
</span>
|
||||||
|
</h4>
|
||||||
|
|
||||||
{artifactsBySeat.length === 0 ? (
|
|
||||||
<p className="text-[11px] text-slate-400 italic">尚未初始化席位线程,无法列出文件。</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{artifactsBySeat.map((seat) => (
|
{seatsWithFiles.map((seat) => (
|
||||||
<div key={seat.agentName} className="border border-slate-150 rounded-xl">
|
<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">
|
<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>
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</section>
|
||||||
</section>
|
)}
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="pt-2 border-t border-slate-100 flex items-center justify-between flex-wrap gap-4">
|
<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,
|
totalStages: plan.stages.length,
|
||||||
seats: stageSeats.map((p) => ({ agentId: p.agentId, name: p.name })),
|
seats: stageSeats.map((p) => ({ agentId: p.agentId, name: p.name })),
|
||||||
isFirst: s === 0,
|
isFirst: s === 0,
|
||||||
|
goal: stage.goal, // 业务链条里为本阶段写的「本阶段目标」(可空)
|
||||||
|
coordinatorPrompt: plan.coordinatorPrompt, // 写给总控的整体派活指引(可空,每阶段都注入)
|
||||||
});
|
});
|
||||||
const leaderAborter = new AbortController();
|
const leaderAborter = new AbortController();
|
||||||
activeAbortersRef.current.add(leaderAborter);
|
activeAbortersRef.current.add(leaderAborter);
|
||||||
@@ -1984,6 +1986,7 @@ export function useStep2Orchestration(
|
|||||||
seedMessage,
|
seedMessage,
|
||||||
priorDeliveries.length > 0,
|
priorDeliveries.length > 0,
|
||||||
dagExecModeRef.current === "sandbox", // 仅沙箱模式要求写 .md 文件;快速模式产物走正文
|
dagExecModeRef.current === "sandbox", // 仅沙箱模式要求写 .md 文件;快速模式产物走正文
|
||||||
|
stage.goal, // 兜底注入「本阶段目标」(总控没派好时席位仍据此对齐)
|
||||||
);
|
);
|
||||||
|
|
||||||
const subAborter = new AbortController();
|
const subAborter = new AbortController();
|
||||||
|
|||||||
@@ -5,9 +5,12 @@
|
|||||||
* (`/api/roundtable-chains`,按登录用户隔离)。在 Step 2 圆桌研讨里,总控会**按
|
* (`/api/roundtable-chains`,按登录用户隔离)。在 Step 2 圆桌研讨里,总控会**按
|
||||||
* 链条顺序逐个派活**(见 `docs/multi-agent-business-chain-dev.md` §6)。
|
* 链条顺序逐个派活**(见 `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]` 是第一个发言者。 */
|
/** 链条里的一个有序席位。`seats[0]` 是第一个发言者。 */
|
||||||
export interface ChainSeat {
|
export interface ChainSeat {
|
||||||
agent_id: string;
|
agent_id: string;
|
||||||
@@ -33,6 +36,18 @@ export interface BusinessChain {
|
|||||||
* (每席位独立一层)。有分层时,选入 Step 2 走 `dag` 编排(见 `lib/dag-types.ts`)。
|
* (每席位独立一层)。有分层时,选入 Step 2 走 `dag` 编排(见 `lib/dag-types.ts`)。
|
||||||
*/
|
*/
|
||||||
stages?: string[][] | null;
|
stages?: string[][] | null;
|
||||||
|
/**
|
||||||
|
* 可选「每阶段目标」:与 `stages` **同下标对齐**(`stageGoals[i]` 是 `stages[i]` 的
|
||||||
|
* 目标),告诉总控本阶段要产出什么、如何依赖上一阶段产出。仅 dag 模式注入;`stages`
|
||||||
|
* 为 null(线性)时本字段也为 null。某位为 null / 空 = 该阶段不带目标。
|
||||||
|
*/
|
||||||
|
stageGoals?: (string | null)[] | null;
|
||||||
|
/**
|
||||||
|
* 可选「总控编排提示」:用户写给**总控(协调)智能体**的一段指引,告诉总控该怎么派活、
|
||||||
|
* 做什么(整体编排策略 / 风格 / 约束)。**仅 dag 模式生效**,注入总控每阶段派活提示。
|
||||||
|
* 空 / null = 不注入。
|
||||||
|
*/
|
||||||
|
coordinatorPrompt?: string | null;
|
||||||
/** 是否已发布为公共(所有账号可在「公共」筛选查看 / 选入研讨)。 */
|
/** 是否已发布为公共(所有账号可在「公共」筛选查看 / 选入研讨)。 */
|
||||||
isPublic?: boolean;
|
isPublic?: boolean;
|
||||||
/** 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条为 false)。 */
|
/** 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条为 false)。 */
|
||||||
@@ -74,6 +89,55 @@ export interface SeatSelectionResult {
|
|||||||
export const CHAIN_MIN_SEATS = 2;
|
export const CHAIN_MIN_SEATS = 2;
|
||||||
export const CHAIN_MAX_SEATS = 8;
|
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)
|
* 把 `getAvatarTypeFor()` 产出的头像配色(blue/purple/emerald/amber/pink/human)
|
||||||
* 映射成 tag chip 的 tailwind 类(背景 + 文字 + 边框,含深色变体)。
|
* 映射成 tag chip 的 tailwind 类(背景 + 文字 + 边框,含深色变体)。
|
||||||
|
|||||||
@@ -12,14 +12,19 @@ import {
|
|||||||
moveSeatToStage,
|
moveSeatToStage,
|
||||||
moveStage,
|
moveStage,
|
||||||
removeSeat,
|
removeSeat,
|
||||||
|
setStageGoal,
|
||||||
splitSeatToNewStage,
|
splitSeatToNewStage,
|
||||||
stageGroupsToSeats,
|
stageGroupsToSeats,
|
||||||
|
stageGroupsToStageGoals,
|
||||||
stageGroupsToStagePayload,
|
stageGroupsToStagePayload,
|
||||||
type StageGroups,
|
type StageGroups,
|
||||||
} from "./chain-stages";
|
} from "./chain-stages";
|
||||||
|
|
||||||
const seat = (id: string) => ({ agent_id: id, name: id.toUpperCase(), description: "" });
|
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 ────────────────────────────────────────────────────────
|
// ── chainToStageGroups ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -52,32 +57,75 @@ test("chainToStageGroups: stages that resolve to nothing → linear fallback", (
|
|||||||
// ── serialization ─────────────────────────────────────────────────────────────
|
// ── serialization ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
test("stageGroupsToSeats flattens in order", () => {
|
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(
|
assert.deepEqual(
|
||||||
stageGroupsToSeats(groups).map((s) => s.agent_id),
|
stageGroupsToSeats(groups).map((s) => s.agent_id),
|
||||||
["a", "b", "c"],
|
["a", "b", "c"],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("stageGroupsToStagePayload returns null when all singletons (linear)", () => {
|
test("stageGroupsToStagePayload returns null when all singletons AND no goals (linear)", () => {
|
||||||
const groups: StageGroups = [[seat("a")], [seat("b")]];
|
const groups = grp([seat("a")], [seat("b")]);
|
||||||
assert.equal(stageGroupsToStagePayload(groups), null);
|
assert.equal(stageGroupsToStagePayload(groups), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("stageGroupsToStagePayload returns explicit stages when serial but a goal is set", () => {
|
||||||
|
// 串行单席位 + 任一「本阶段目标」→ 落显式分层(目标必须挂在显式 stages 上,否则丢失)。
|
||||||
|
const groups = setStageGoal(grp([seat("a")], [seat("b")]), 0, "分析目的");
|
||||||
|
assert.deepEqual(stageGroupsToStagePayload(groups), [["a"], ["b"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stageGroupsToStagePayload forceExplicit lifts a pure-linear chain to explicit stages", () => {
|
||||||
|
// 无并行无目标,但调用方因「总控编排提示」要求强制显式分层(否则提示在线性链里被忽略)。
|
||||||
|
const groups = grp([seat("a")], [seat("b")]);
|
||||||
|
assert.equal(stageGroupsToStagePayload(groups), null);
|
||||||
|
assert.deepEqual(stageGroupsToStagePayload(groups, true), [["a"], ["b"]]);
|
||||||
|
assert.deepEqual(stageGroupsToStageGoals(groups, true), [null, null]);
|
||||||
|
});
|
||||||
|
|
||||||
test("stageGroupsToStagePayload returns groups when any stage is parallel", () => {
|
test("stageGroupsToStagePayload returns groups when any stage is parallel", () => {
|
||||||
const groups: StageGroups = [[seat("a"), seat("b")], [seat("c")]];
|
const groups = grp([seat("a"), seat("b")], [seat("c")]);
|
||||||
assert.deepEqual(stageGroupsToStagePayload(groups), [["a", "b"], ["c"]]);
|
assert.deepEqual(stageGroupsToStagePayload(groups), [["a", "b"], ["c"]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("stageGroupsToStagePayload ignores empty stages", () => {
|
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"]]);
|
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 ───────────────────────────────────────────────────────────────────
|
// ── lookups ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
test("findStageIndexOfSeat / countSeats", () => {
|
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, "c"), 1);
|
||||||
assert.equal(findStageIndexOfSeat(groups, "zzz"), -1);
|
assert.equal(findStageIndexOfSeat(groups, "zzz"), -1);
|
||||||
assert.equal(countSeats(groups), 3);
|
assert.equal(countSeats(groups), 3);
|
||||||
@@ -86,60 +134,97 @@ test("findStageIndexOfSeat / countSeats", () => {
|
|||||||
// ── mutations ─────────────────────────────────────────────────────────────────
|
// ── mutations ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
test("addSeatAsNewStage appends a singleton stage, dedupes", () => {
|
test("addSeatAsNewStage appends a singleton stage, dedupes", () => {
|
||||||
let g: StageGroups = [[seat("a")]];
|
let g = grp([seat("a")]);
|
||||||
g = addSeatAsNewStage(g, seat("b"));
|
g = addSeatAsNewStage(g, seat("b"));
|
||||||
assert.deepEqual(ids(g), [["a"], ["b"]]);
|
assert.deepEqual(ids(g), [["a"], ["b"]]);
|
||||||
g = addSeatAsNewStage(g, seat("a")); // dup → no-op
|
g = addSeatAsNewStage(g, seat("a")); // dup → no-op
|
||||||
assert.deepEqual(ids(g), [["a"], ["b"]]);
|
assert.deepEqual(ids(g), [["a"], ["b"]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("addSeatToStage inserts into a stage at index", () => {
|
test("addSeatToStage inserts into a stage at index, preserves goal", () => {
|
||||||
const g = addSeatToStage([[seat("a")], [seat("c")]], seat("b"), 0, 0);
|
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.deepEqual(ids(g), [["b", "a"], ["c"]]);
|
||||||
|
assert.equal(g[0].goal, "g0"); // goal 保留
|
||||||
});
|
});
|
||||||
|
|
||||||
test("removeSeat drops the seat and prunes an emptied stage", () => {
|
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"]]);
|
assert.deepEqual(ids(g), [["b", "c"]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("moveSeatToStage merges across stages and prunes empty source", () => {
|
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"]]);
|
assert.deepEqual(ids(g), [["b", "a"]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("moveSeatToStage within same stage reorders", () => {
|
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"]]);
|
assert.deepEqual(ids(g), [["c", "a", "b"]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("splitSeatToNewStage pulls a seat out into a new following stage", () => {
|
test("splitSeatToNewStage pulls a seat out; original keeps goal, split has none", () => {
|
||||||
const g = splitSeatToNewStage([[seat("a"), seat("b")], [seat("c")]], "b");
|
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.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", () => {
|
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"]]);
|
assert.deepEqual(ids(g), [["a"], ["b"]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("moveStage reorders serial stages", () => {
|
test("moveStage reorders serial stages, goal travels with the stage", () => {
|
||||||
const g = moveStage([[seat("a")], [seat("b")], [seat("c")]], 2, -1);
|
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.deepEqual(ids(g), [["a"], ["c"], ["b"]]);
|
||||||
|
assert.equal(g[1].goal, "gc"); // c 的目标随它移到了第 2 位
|
||||||
});
|
});
|
||||||
|
|
||||||
test("addEmptyStage / deleteStage", () => {
|
test("addEmptyStage / deleteStage", () => {
|
||||||
let g = addEmptyStage([[seat("a")]]);
|
let g = addEmptyStage(grp([seat("a")]));
|
||||||
assert.deepEqual(ids(g), [["a"], []]);
|
assert.deepEqual(ids(g), [["a"], []]);
|
||||||
g = deleteStage(g, 1);
|
g = deleteStage(g, 1);
|
||||||
assert.deepEqual(ids(g), [["a"]]);
|
assert.deepEqual(ids(g), [["a"]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// round-trip: editor groups → payload → reload preserves structure
|
// round-trip: editor groups → payload → reload preserves structure + goals
|
||||||
test("round-trip: groups → {seats, stages} → chainToStageGroups", () => {
|
test("round-trip: groups → {seats, stages, stageGoals} → chainToStageGroups", () => {
|
||||||
const groups: StageGroups = [[seat("a"), seat("b")], [seat("c")]];
|
const groups = setStageGoal(grp([seat("a"), seat("b")], [seat("c")]), 1, "依赖上一阶段");
|
||||||
const seats = stageGroupsToSeats(groups);
|
const seats = stageGroupsToSeats(groups);
|
||||||
const stages = stageGroupsToStagePayload(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.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 内
|
* 编辑器内部用 `StageGroups`(`StageGroup[]`,每个 `StageGroup` = 一个 stage,含其
|
||||||
* 并行、stage 间串行)作为**单一数据源**:
|
* 并行席位 `seats` 与可选「本阶段目标」`goal`;stage 内并行、stage 间串行)作为
|
||||||
|
* **单一数据源**:
|
||||||
* - 展平得到落库的有序 `seats`({@link stageGroupsToSeats});
|
* - 展平得到落库的有序 `seats`({@link stageGroupsToSeats});
|
||||||
* - 折叠得到落库的 `stages`({@link stageGroupsToStagePayload})。
|
* - 折叠得到落库的 `stages`({@link stageGroupsToStagePayload});
|
||||||
|
* - 折叠得到落库的 `stageGoals`({@link stageGroupsToStageGoals}),与 `stages` 同下标对齐。
|
||||||
*
|
*
|
||||||
|
* `goal` 挂在 stage 对象上,随 stage 在增删改 / 重排中自动跟随,避免与席位错位。
|
||||||
* 这里只放纯函数(无 React / 无副作用),便于单测;组件只做渲染 + 调用这些函数。
|
* 这里只放纯函数(无 React / 无副作用),便于单测;组件只做渲染 + 调用这些函数。
|
||||||
* 对应需求文档 `docs/roundtable-dag-orchestration.md` §2(分层编辑器,竖列分组)。
|
* 对应需求文档 `docs/roundtable-dag-orchestration.md` §2(分层编辑器,竖列分组)。
|
||||||
*/
|
*/
|
||||||
import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain";
|
import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain";
|
||||||
|
|
||||||
/** 编辑器内部的分层结构:每个内层数组是一个并行 stage,外层顺序即串行顺序。 */
|
/** 一个并行 stage:含其并行席位与可选「本阶段目标」(注入给总控/席位,仅 dag 模式生效)。 */
|
||||||
export type StageGroups = ChainSeat[][];
|
export interface StageGroup {
|
||||||
|
seats: ChainSeat[];
|
||||||
|
/** 本阶段目标:告诉总控本阶段要产出什么、如何依赖上一阶段产出。空 = 不注入。 */
|
||||||
|
goal?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑器内部的分层结构:每个元素是一个并行 stage,外层顺序即串行顺序。 */
|
||||||
|
export type StageGroups = StageGroup[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从一条业务链条还原分层结构。
|
* 从一条业务链条还原分层结构。
|
||||||
* - 链条带 `stages`:按分层分组(agent_id → 对应 seat),丢弃找不到 seat 的 id 与空组;
|
* - 链条带 `stages`:按分层分组(agent_id → 对应 seat),丢弃找不到 seat 的 id 与空组;
|
||||||
* 分层全空时回退到线性。
|
* `goal` 按**原始 stage 下标**从 `stageGoals` 取,随被丢弃的组一并丢弃;分层全空时回退到线性。
|
||||||
* - 无 `stages`(老链条):线性 —— 每个 seat 独立成一层。
|
* - 无 `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 seats = chain.seats ?? [];
|
||||||
const byId = new Map(seats.map((s) => [s.agent_id, s]));
|
const byId = new Map(seats.map((s) => [s.agent_id, s]));
|
||||||
|
const goals = chain.stageGoals ?? null;
|
||||||
if (chain.stages && chain.stages.length > 0) {
|
if (chain.stages && chain.stages.length > 0) {
|
||||||
const groups: StageGroups = [];
|
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));
|
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;
|
if (groups.length > 0) return groups;
|
||||||
}
|
}
|
||||||
return seats.map((s) => [s]);
|
return seats.map((s) => ({ seats: [s] }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 展平成落库 / 校验用的有序席位列表(stage 顺序 → stage 内顺序)。 */
|
/** 展平成落库 / 校验用的有序席位列表(stage 顺序 → stage 内顺序)。 */
|
||||||
export function stageGroupsToSeats(groups: StageGroups): ChainSeat[] {
|
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** —— 语义上就是线性
|
* 折叠成落库的 `stages`。返回 null = 线性链(Step 2 走 chain 模式,老行为不变);返回显式
|
||||||
* 链,落库为 null 以便老行为不变(Step 2 仍走 chain 模式);只要有一个 stage ≥2 席位
|
* 分层 = Step 2 走 dag 模式。判定:**只要有一个 stage ≥2 席位(并行),或用户给任一 stage
|
||||||
* 就返回显式分层(Step 2 走 dag 模式)。空 stage 已被忽略。
|
* 填了「本阶段目标」,就落显式分层**;否则(纯单席位且无目标)落 null。空 stage 已被忽略。
|
||||||
|
*
|
||||||
|
* 为什么「填了目标也要落显式分层」:每阶段目标按 stage 下标对齐落库(见
|
||||||
|
* {@link stageGroupsToStageGoals}),只能挂在显式 `stages` 上。若串行单席位链照旧折叠为
|
||||||
|
* null,目标就会被一并丢掉——保存丢、回显也丢(正是用户反馈的「本阶段目标没保存上 / 没回显」)。
|
||||||
|
* 填了目标的串行链因此落成「每席位一层」的显式分层,由总控**逐阶段按目标派活**(dag 模式)。
|
||||||
|
*
|
||||||
|
* `forceExplicit`:调用方还有别的「必须走 dag」的理由时(如填了链条级「总控编排提示」,
|
||||||
|
* 仅 dag 模式注入)传 true,强制落显式分层,避免该提示在纯线性链里被静默忽略。
|
||||||
*/
|
*/
|
||||||
export function stageGroupsToStagePayload(groups: StageGroups): string[][] | null {
|
export function stageGroupsToStagePayload(groups: StageGroups, forceExplicit = false): string[][] | null {
|
||||||
const nonEmpty = groups.filter((g) => g.length > 0);
|
const nonEmpty = groups.filter((g) => g.seats.length > 0);
|
||||||
const hasParallel = nonEmpty.some((g) => g.length > 1);
|
const hasParallel = nonEmpty.some((g) => g.seats.length > 1);
|
||||||
if (!hasParallel) return null;
|
if (!hasParallel && !hasAnyGoal(nonEmpty) && !forceExplicit) return null;
|
||||||
return nonEmpty.map((g) => g.map((s) => s.agent_id));
|
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。 */
|
/** 该席位所在 stage 下标;不存在返回 -1。 */
|
||||||
export function findStageIndexOfSeat(groups: StageGroups, agentId: string): number {
|
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 中。 */
|
/** 席位是否已在任意 stage 中。 */
|
||||||
@@ -63,7 +103,13 @@ export function seatExistsInGroups(groups: StageGroups, agentId: string): boolea
|
|||||||
|
|
||||||
/** 总席位数(用于 2–8 约束)。 */
|
/** 总席位数(用于 2–8 约束)。 */
|
||||||
export function countSeats(groups: StageGroups): number {
|
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 点击加入默认行为)。 */
|
/** 末尾追加一个**新单席位 stage**(线性追加,pool 点击加入默认行为)。 */
|
||||||
export function addSeatAsNewStage(groups: StageGroups, seat: ChainSeat): StageGroups {
|
export function addSeatAsNewStage(groups: StageGroups, seat: ChainSeat): StageGroups {
|
||||||
if (seatExistsInGroups(groups, seat.agent_id)) return groups;
|
if (seatExistsInGroups(groups, seat.agent_id)) return groups;
|
||||||
return [...groups, [seat]];
|
return [...groups, { seats: [seat] }];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 把席位加入指定 stage(可选插入位置);席位已存在则不动。 */
|
/** 把席位加入指定 stage(可选插入位置);席位已存在则不动。保留目标 stage 的 goal。 */
|
||||||
export function addSeatToStage(
|
export function addSeatToStage(
|
||||||
groups: StageGroups,
|
groups: StageGroups,
|
||||||
seat: ChainSeat,
|
seat: ChainSeat,
|
||||||
@@ -85,18 +131,18 @@ export function addSeatToStage(
|
|||||||
if (stageIdx < 0 || stageIdx >= groups.length) return groups;
|
if (stageIdx < 0 || stageIdx >= groups.length) return groups;
|
||||||
return groups.map((g, i) => {
|
return groups.map((g, i) => {
|
||||||
if (i !== stageIdx) return g;
|
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));
|
const at = atIndex == null ? next.length : Math.max(0, Math.min(atIndex, next.length));
|
||||||
next.splice(at, 0, seat);
|
next.splice(at, 0, seat);
|
||||||
return next;
|
return { ...g, seats: next };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 移除一个席位;该 stage 因此变空则连 stage 一起删掉。 */
|
/** 移除一个席位;该 stage 因此变空则连 stage(含其 goal)一起删掉。 */
|
||||||
export function removeSeat(groups: StageGroups, agentId: string): StageGroups {
|
export function removeSeat(groups: StageGroups, agentId: string): StageGroups {
|
||||||
return groups
|
return groups
|
||||||
.map((g) => g.filter((s) => s.agent_id !== agentId))
|
.map((g) => ({ ...g, seats: g.seats.filter((s) => s.agent_id !== agentId) }))
|
||||||
.filter((g) => g.length > 0);
|
.filter((g) => g.seats.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -112,50 +158,53 @@ export function moveSeatToStage(
|
|||||||
): StageGroups {
|
): StageGroups {
|
||||||
const from = findStageIndexOfSeat(groups, agentId);
|
const from = findStageIndexOfSeat(groups, agentId);
|
||||||
if (from < 0 || targetStageIdx < 0 || targetStageIdx >= groups.length) return groups;
|
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;
|
if (!seat) return groups;
|
||||||
// 先按身份记下目标 stage 引用,避免删空源 stage 后下标错位。
|
// 先按身份记下目标 stage 引用,避免删空源 stage 后下标错位。goal 始终随 stage 保留。
|
||||||
const targetRef = groups[targetStageIdx];
|
const targetRef = groups[targetStageIdx];
|
||||||
let next: StageGroups = groups.map((g) =>
|
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) => {
|
next = next.map((g) => {
|
||||||
if (g !== targetRef) return 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 插回。
|
// 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));
|
const at = atIndex == null ? without.length : Math.max(0, Math.min(atIndex, without.length));
|
||||||
without.splice(at, 0, seat);
|
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));
|
const at = atIndex == null ? merged.length : Math.max(0, Math.min(atIndex, merged.length));
|
||||||
merged.splice(at, 0, seat);
|
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 {
|
export function splitSeatToNewStage(groups: StageGroups, agentId: string): StageGroups {
|
||||||
const from = findStageIndexOfSeat(groups, agentId);
|
const from = findStageIndexOfSeat(groups, agentId);
|
||||||
if (from < 0) return groups;
|
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 (!seat) return groups;
|
||||||
if (groups[from].length === 1) return groups; // 已经独占一层,无需拆
|
if (groups[from].seats.length === 1) return groups; // 已经独占一层,无需拆
|
||||||
const out: StageGroups = [];
|
const out: StageGroups = [];
|
||||||
groups.forEach((g, i) => {
|
groups.forEach((g, i) => {
|
||||||
if (i === from) {
|
if (i === from) {
|
||||||
out.push(g.filter((s) => s.agent_id !== agentId));
|
out.push({ ...g, seats: g.seats.filter((s) => s.agent_id !== agentId) });
|
||||||
out.push([seat]);
|
out.push({ seats: [seat] });
|
||||||
} else {
|
} else {
|
||||||
out.push(g);
|
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 {
|
export function moveStage(groups: StageGroups, stageIdx: number, dir: -1 | 1): StageGroups {
|
||||||
const target = stageIdx + dir;
|
const target = stageIdx + dir;
|
||||||
if (stageIdx < 0 || stageIdx >= groups.length || target < 0 || target >= groups.length) {
|
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」后再拖 / 加席位进去)。 */
|
/** 末尾追加一个空 stage(用户「新建 Stage」后再拖 / 加席位进去)。 */
|
||||||
export function addEmptyStage(groups: StageGroups): StageGroups {
|
export function addEmptyStage(groups: StageGroups): StageGroups {
|
||||||
return [...groups, []];
|
return [...groups, { seats: [] }];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除整个 stage(含其席位)。 */
|
/** 删除整个 stage(含其席位)。 */
|
||||||
|
|||||||
@@ -188,6 +188,35 @@ test("a chain plan validates clean against its surviving roster", () => {
|
|||||||
assert.equal(res.ok, true);
|
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 依赖摘要注入) ─────────────────────────────────
|
// ── buildDependencySummary (§3.1 依赖摘要注入) ─────────────────────────────────
|
||||||
|
|
||||||
test("buildDependencySummary is empty for no deliveries (first stage)", () => {
|
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, /真正调用 agent_orchestration 工具/);
|
||||||
assert.match(task, /不要.*只用文字/);
|
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;
|
id: string;
|
||||||
/** 本 stage 并行执行的席位(agent_id 列表)。1 个 = 退化为串行节点。 */
|
/** 本 stage 并行执行的席位(agent_id 列表)。1 个 = 退化为串行节点。 */
|
||||||
agentIds: string[];
|
agentIds: string[];
|
||||||
|
/**
|
||||||
|
* 可选「本阶段目标」:来自业务链条 `stageGoals`,注入总控派活提示(主)与席位任务
|
||||||
|
* (兜底),告诉本阶段要产出什么、如何依赖上一阶段产出。空 = 不注入。
|
||||||
|
*/
|
||||||
|
goal?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 用户编排出的执行计划。stages 顺序即串行顺序。 */
|
/** 用户编排出的执行计划。stages 顺序即串行顺序。 */
|
||||||
@@ -29,6 +34,12 @@ export interface OrchestrationPlan {
|
|||||||
stages: OrchestrationStage[];
|
stages: OrchestrationStage[];
|
||||||
/** 收口:是否在所有 stage 完成后由总控综合(默认 true)。 */
|
/** 收口:是否在所有 stage 完成后由总控综合(默认 true)。 */
|
||||||
finalSynthesis: boolean;
|
finalSynthesis: boolean;
|
||||||
|
/**
|
||||||
|
* 可选「总控编排提示」:来自业务链条 `coordinatorPrompt`,写给总控的整体派活指引
|
||||||
|
* (怎么派活 / 做什么)。注入总控**每阶段**派活提示({@link buildDagStageLeaderTask})。
|
||||||
|
* 空 = 不注入。
|
||||||
|
*/
|
||||||
|
coordinatorPrompt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 通用 DAG 表示:自由连线时才需要(§1.2,本期仅预留)───────────────────────
|
// ── 通用 DAG 表示:自由连线时才需要(§1.2,本期仅预留)───────────────────────
|
||||||
@@ -180,19 +191,30 @@ export function emptyPlan(finalSynthesis = true): OrchestrationPlan {
|
|||||||
* @param stages 链条分层(每组并行、组间串行)。
|
* @param stages 链条分层(每组并行、组间串行)。
|
||||||
* @param allowedIds 仅保留这些 agent_id(存活席位);省略则全保留。
|
* @param allowedIds 仅保留这些 agent_id(存活席位);省略则全保留。
|
||||||
* @param finalSynthesis 是否收口综合,默认 true。
|
* @param finalSynthesis 是否收口综合,默认 true。
|
||||||
|
* @param goals 可选「每阶段目标」,与 `stages` **同源下标**对齐;存活 stage 取对应 goal
|
||||||
|
* (被丢弃的 stage 其 goal 一并丢弃,因为按源下标取)。
|
||||||
|
* @param coordinatorPrompt 可选「总控编排提示」(写给总控的整体派活指引),挂在计划上,
|
||||||
|
* 注入总控每阶段派活提示。空 = 不带。
|
||||||
*/
|
*/
|
||||||
export function buildPlanFromStages(
|
export function buildPlanFromStages(
|
||||||
stages: string[][],
|
stages: string[][],
|
||||||
allowedIds?: Iterable<string>,
|
allowedIds?: Iterable<string>,
|
||||||
finalSynthesis = true,
|
finalSynthesis = true,
|
||||||
|
goals?: ReadonlyArray<string | null> | null,
|
||||||
|
coordinatorPrompt?: string | null,
|
||||||
): OrchestrationPlan {
|
): OrchestrationPlan {
|
||||||
const allow = allowedIds ? new Set(allowedIds) : null;
|
const allow = allowedIds ? new Set(allowedIds) : null;
|
||||||
const cleaned: OrchestrationStage[] = [];
|
const cleaned: OrchestrationStage[] = [];
|
||||||
stages.forEach((group, idx) => {
|
stages.forEach((group, idx) => {
|
||||||
const ids = allow ? group.filter((id) => allow.has(id)) : group.slice();
|
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 决定显示。 */
|
/** 该 stage 是否真并行(≥2 席位)—— Phase 3 决定是否禁写文件 / Phase 5 决定显示。 */
|
||||||
@@ -330,13 +352,22 @@ export function buildDagStageLeaderTask(args: {
|
|||||||
/** 本 stage 计划钉死的席位。 */
|
/** 本 stage 计划钉死的席位。 */
|
||||||
seats: Array<{ agentId: string; name: string }>;
|
seats: Array<{ agentId: string; name: string }>;
|
||||||
isFirst: boolean;
|
isFirst: boolean;
|
||||||
|
/** 可选「本阶段目标」:用户在业务链条里为本阶段写的产出 / 依赖说明。空 = 不注入。 */
|
||||||
|
goal?: string;
|
||||||
|
/**
|
||||||
|
* 可选「总控编排提示」:用户在业务链条里写给总控的整体派活指引(怎么派活 / 做什么 /
|
||||||
|
* 风格 / 约束)。每个 stage 的派活轮都注入,作为总控的高优先编排原则。空 = 不注入。
|
||||||
|
*/
|
||||||
|
coordinatorPrompt?: string;
|
||||||
}): string {
|
}): string {
|
||||||
const { seedMessage, stageIndex, totalStages, seats, isFirst } = args;
|
const { seedMessage, stageIndex, totalStages, seats, isFirst, goal, coordinatorPrompt } = args;
|
||||||
const ordinal = `${stageIndex + 1}/${totalStages}`;
|
const ordinal = `${stageIndex + 1}/${totalStages}`;
|
||||||
const seatList = seats
|
const seatList = seats
|
||||||
.map((s) => `「${s.name}」(agent_name: ${s.agentId})`)
|
.map((s) => `「${s.name}」(agent_name: ${s.agentId})`)
|
||||||
.join("、");
|
.join("、");
|
||||||
const isParallel = seats.length > 1;
|
const isParallel = seats.length > 1;
|
||||||
|
const goalText = goal?.trim();
|
||||||
|
const coordText = coordinatorPrompt?.trim();
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
if (isFirst) {
|
if (isFirst) {
|
||||||
lines.push("这是一次【分层 DAG 编排】的多智能体圆桌研讨。任务背景如下:", seedMessage, "");
|
lines.push("这是一次【分层 DAG 编排】的多智能体圆桌研讨。任务背景如下:", seedMessage, "");
|
||||||
@@ -346,10 +377,25 @@ export function buildDagStageLeaderTask(args: {
|
|||||||
"",
|
"",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (coordText) {
|
||||||
|
lines.push(
|
||||||
|
"【总控编排指引(用户预设,最高优先)】以下是本次业务链条为你(总控)预设的整体派活原则,请在每一阶段派活时都严格遵循:",
|
||||||
|
coordText,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
}
|
||||||
lines.push(
|
lines.push(
|
||||||
`本研讨严格按预设分层顺序进行,共 ${totalStages} 个阶段,现在是第 ${ordinal} 个阶段。`,
|
`本研讨严格按预设分层顺序进行,共 ${totalStages} 个阶段,现在是第 ${ordinal} 个阶段。`,
|
||||||
`本阶段需要执行的席位(已由编排计划钉死)是:${seatList}。`,
|
`本阶段需要执行的席位(已由编排计划钉死)是:${seatList}。`,
|
||||||
`请你作为总控,通过 agent_orchestration 技能**分别**向上述${isParallel ? `这 ${seats.length} 个席位` : "席位"}派活,结合任务背景与此前交付,为每个席位分配聚焦其职责的本阶段任务。`,
|
);
|
||||||
|
if (goalText) {
|
||||||
|
lines.push(
|
||||||
|
`【本阶段目标】${goalText}`,
|
||||||
|
"请据此目标为每个席位拆解任务,并要求他们**显式承接上一阶段的产出**来完成本阶段目标。",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lines.push(
|
||||||
|
`请你作为总控,通过 agent_orchestration 技能**分别**向上述${isParallel ? `这 ${seats.length} 个席位` : "席位"}派活,结合任务背景${goalText ? "、本阶段目标" : ""}与此前交付,为每个席位分配聚焦其职责的本阶段任务。`,
|
||||||
"只派给上述席位、且每个都必须派到;不要派给清单外的任何席位,也不要自己直接作答或给出最终结论。",
|
"只派给上述席位、且每个都必须派到;不要派给清单外的任何席位,也不要自己直接作答或给出最终结论。",
|
||||||
"⚠️ 必须**真正调用 agent_orchestration 工具**来派活(每个席位对应一次工具调用);**不要**只用文字罗列「席位1:…席位2:…」这样的任务清单——只写文字而不调用工具,系统无法把任务下发给席位。",
|
"⚠️ 必须**真正调用 agent_orchestration 工具**来派活(每个席位对应一次工具调用);**不要**只用文字罗列「席位1:…席位2:…」这样的任务清单——只写文字而不调用工具,系统无法把任务下发给席位。",
|
||||||
);
|
);
|
||||||
@@ -386,6 +432,11 @@ export function pickDagSeatTask(
|
|||||||
* - `false`(**快速模式**,默认):产物**直接写在正文**,不写文件(fast 模式产物走正文)。
|
* - `false`(**快速模式**,默认):产物**直接写在正文**,不写文件(fast 模式产物走正文)。
|
||||||
*/
|
*/
|
||||||
allowFiles = false,
|
allowFiles = false,
|
||||||
|
/**
|
||||||
|
* 可选「本阶段目标」(兜底注入):总控派活质量差 / 没派到时,席位仍据此知道本阶段要产出
|
||||||
|
* 什么、如何依赖上一阶段产出。空 = 不注入。
|
||||||
|
*/
|
||||||
|
goal = "",
|
||||||
): string {
|
): string {
|
||||||
const guard =
|
const guard =
|
||||||
`\n\n【角色边界·必读】你是**执行席位**「${seatName}」,不是总控协调者,本轮**只**对你自己的交付负责。**严禁**任何形式的派活 / 调度:不要调用 agent_orchestration 或 skill_list,不要用 python / 脚本去访问内部接口(如 /api/agents、agent_orchestration)来调度,不要罗列或给其他席位分配任务清单、不要复述「我来调度各智能体」这类总控口吻。`;
|
`\n\n【角色边界·必读】你是**执行席位**「${seatName}」,不是总控协调者,本轮**只**对你自己的交付负责。**严禁**任何形式的派活 / 调度:不要调用 agent_orchestration 或 skill_list,不要用 python / 脚本去访问内部接口(如 /api/agents、agent_orchestration)来调度,不要罗列或给其他席位分配任务清单、不要复述「我来调度各智能体」这类总控口吻。`;
|
||||||
@@ -402,15 +453,20 @@ export function pickDagSeatTask(
|
|||||||
const peerRule = hasPeers
|
const peerRule = hasPeers
|
||||||
? `\n\n【按需取全文】此前各阶段席位的交付你默认只看到**摘要**;若需要某个前序席位的**完整交付**,调用工具 read_peer_delivery('<该席位名称>') 取回全文(摘要够用就不必调)。`
|
? `\n\n【按需取全文】此前各阶段席位的交付你默认只看到**摘要**;若需要某个前序席位的**完整交付**,调用工具 read_peer_delivery('<该席位名称>') 取回全文(摘要够用就不必调)。`
|
||||||
: "";
|
: "";
|
||||||
|
// 本阶段目标(兜底):用户为本阶段写的产出/依赖说明,即便总控没派好,席位也据此对齐本阶段意图。
|
||||||
|
const goalRule = goal.trim()
|
||||||
|
? `\n\n【本阶段目标】${goal.trim()}\n请确保你的交付服务于该目标,并显式承接上一阶段的产出。`
|
||||||
|
: "";
|
||||||
const wanted = seatAgentId.toLowerCase();
|
const wanted = seatAgentId.toLowerCase();
|
||||||
const exact = dispatched.find(([name]) => name.toLowerCase() === wanted);
|
const exact = dispatched.find(([name]) => name.toLowerCase() === wanted);
|
||||||
if (exact && exact[1]?.trim()) {
|
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 为空 / 未命中 → 干净本职任务,**不**注入总控派活叙述(根治「席位模仿派活」)。
|
// dispatched 为空 / 未命中 → 干净本职任务,**不**注入总控派活叙述(根治「席位模仿派活」)。
|
||||||
return (
|
return (
|
||||||
`${bg}请你作为「${seatName}」,从你的专业职责出发,独立完成本阶段属于你的分析与交付。` +
|
`${bg}请你作为「${seatName}」,从你的专业职责出发,独立完成本阶段属于你的分析与交付。` +
|
||||||
`此前各阶段席位的交付已在你的会话上下文里,请据此承接。` +
|
`此前各阶段席位的交付已在你的会话上下文里,请据此承接。` +
|
||||||
|
goalRule +
|
||||||
fileRule +
|
fileRule +
|
||||||
peerRule +
|
peerRule +
|
||||||
guard
|
guard
|
||||||
|
|||||||
@@ -30,8 +30,10 @@ import {
|
|||||||
GitBranch,
|
GitBranch,
|
||||||
Globe,
|
Globe,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
|
Info,
|
||||||
Layers,
|
Layers,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Network,
|
||||||
Plus,
|
Plus,
|
||||||
Save,
|
Save,
|
||||||
SplitSquareVertical,
|
SplitSquareVertical,
|
||||||
@@ -60,6 +62,12 @@ import { CSS } from "@dnd-kit/utilities";
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Input } from "@/components/ui/input";
|
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 { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
||||||
import { listAgents } from "@/core/agents";
|
import { listAgents } from "@/core/agents";
|
||||||
|
|
||||||
@@ -93,13 +101,17 @@ import {
|
|||||||
moveStage,
|
moveStage,
|
||||||
removeSeat as removeSeatFromGroups,
|
removeSeat as removeSeatFromGroups,
|
||||||
seatExistsInGroups,
|
seatExistsInGroups,
|
||||||
|
setStageGoal,
|
||||||
splitSeatToNewStage,
|
splitSeatToNewStage,
|
||||||
stageGroupsToSeats,
|
stageGroupsToSeats,
|
||||||
|
stageGroupsToStageGoals,
|
||||||
stageGroupsToStagePayload,
|
stageGroupsToStagePayload,
|
||||||
|
type StageGroup,
|
||||||
type StageGroups,
|
type StageGroups,
|
||||||
} from "@/roundtable-planning/lib/chain-stages";
|
} from "@/roundtable-planning/lib/chain-stages";
|
||||||
import { getAvatarTypeFor } from "@/roundtable-planning/lib/roundtable-constants";
|
import { getAvatarTypeFor } from "@/roundtable-planning/lib/roundtable-constants";
|
||||||
import { DagFlowChart } from "@/roundtable-planning/components/DagFlowChart";
|
import { DagFlowChart } from "@/roundtable-planning/components/DagFlowChart";
|
||||||
|
import { AgentDetailSheet } from "@/roundtable-planning/components/AgentDetailSheet";
|
||||||
|
|
||||||
const POOL_PREFIX = "pool::";
|
const POOL_PREFIX = "pool::";
|
||||||
const SEAT_PREFIX = "seat::";
|
const SEAT_PREFIX = "seat::";
|
||||||
@@ -138,12 +150,20 @@ export default function BusinessChainEditorPage() {
|
|||||||
const [publishing, setPublishing] = useState(false);
|
const [publishing, setPublishing] = useState(false);
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
|
// 写给总控的「编排提示」:告诉总控该怎么派活、做什么(仅分层 DAG 模式注入)。
|
||||||
|
const [coordinatorPrompt, setCoordinatorPrompt] = useState("");
|
||||||
// 分层编排是唯一数据源;落库时展平为 seats + 折叠为 stages。
|
// 分层编排是唯一数据源;落库时展平为 seats + 折叠为 stages。
|
||||||
const [stageGroups, setStageGroups] = useState<StageGroups>([]);
|
const [stageGroups, setStageGroups] = useState<StageGroups>([]);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
const [activeDrag, setActiveDrag] = useState<ActiveDrag>(null);
|
const [activeDrag, setActiveDrag] = useState<ActiveDrag>(null);
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
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(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
@@ -155,7 +175,7 @@ export default function BusinessChainEditorPage() {
|
|||||||
return m;
|
return m;
|
||||||
}, [pool]);
|
}, [pool]);
|
||||||
const seatIdSet = useMemo(
|
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],
|
[stageGroups],
|
||||||
);
|
);
|
||||||
const seatCount = useMemo(() => countSeats(stageGroups), [stageGroups]);
|
const seatCount = useMemo(() => countSeats(stageGroups), [stageGroups]);
|
||||||
@@ -165,14 +185,14 @@ export default function BusinessChainEditorPage() {
|
|||||||
const previewStages = useMemo(
|
const previewStages = useMemo(
|
||||||
() =>
|
() =>
|
||||||
stageGroups
|
stageGroups
|
||||||
.filter((g) => g.length > 0)
|
.filter((g) => g.seats.length > 0)
|
||||||
.map((g, i) => ({ id: `stage-${i}`, agentIds: g.map((s) => s.agent_id) })),
|
.map((g, i) => ({ id: `stage-${i}`, agentIds: g.seats.map((s) => s.agent_id) })),
|
||||||
[stageGroups],
|
[stageGroups],
|
||||||
);
|
);
|
||||||
const seatNameById = useMemo(() => {
|
const seatNameById = useMemo(() => {
|
||||||
const m = new Map<string, string>();
|
const m = new Map<string, string>();
|
||||||
for (const g of stageGroups)
|
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;
|
return m;
|
||||||
}, [stageGroups]);
|
}, [stageGroups]);
|
||||||
const resolvePreviewSeat = useCallback(
|
const resolvePreviewSeat = useCallback(
|
||||||
@@ -228,6 +248,7 @@ export default function BusinessChainEditorPage() {
|
|||||||
setCurrentChainMine(chain.isMine !== false);
|
setCurrentChainMine(chain.isMine !== false);
|
||||||
setTitle(chain.title);
|
setTitle(chain.title);
|
||||||
setDescription(chain.description ?? "");
|
setDescription(chain.description ?? "");
|
||||||
|
setCoordinatorPrompt(chain.coordinatorPrompt ?? "");
|
||||||
setStageGroups(chainToStageGroups(chain));
|
setStageGroups(chainToStageGroups(chain));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -237,6 +258,7 @@ export default function BusinessChainEditorPage() {
|
|||||||
setCurrentChainMine(true);
|
setCurrentChainMine(true);
|
||||||
setTitle("");
|
setTitle("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
|
setCoordinatorPrompt("");
|
||||||
setStageGroups([]);
|
setStageGroups([]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -291,6 +313,10 @@ export default function BusinessChainEditorPage() {
|
|||||||
setStageGroups((prev) => moveSeatToStage(prev, agentId, targetStageIdx));
|
setStageGroups((prev) => moveSeatToStage(prev, agentId, targetStageIdx));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const setGoal = useCallback((stageIdx: number, goal: string) => {
|
||||||
|
setStageGroups((prev) => setStageGoal(prev, stageIdx, goal));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const splitSeat = useCallback((agentId: string) => {
|
const splitSeat = useCallback((agentId: string) => {
|
||||||
setStageGroups((prev) => splitSeatToNewStage(prev, agentId));
|
setStageGroups((prev) => splitSeatToNewStage(prev, agentId));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -306,8 +332,8 @@ export default function BusinessChainEditorPage() {
|
|||||||
const removeStage = useCallback((stageIdx: number) => {
|
const removeStage = useCallback((stageIdx: number) => {
|
||||||
setStageGroups((prev) => {
|
setStageGroups((prev) => {
|
||||||
const stage = prev[stageIdx];
|
const stage = prev[stageIdx];
|
||||||
if (stage && stage.length > 0) {
|
if (stage && stage.seats.length > 0) {
|
||||||
if (!window.confirm(`删除 Stage ${stageIdx + 1} 会移除其中 ${stage.length} 个席位,确认?`)) {
|
if (!window.confirm(`删除 Stage ${stageIdx + 1} 会移除其中 ${stage.seats.length} 个席位,确认?`)) {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,7 +375,7 @@ export default function BusinessChainEditorPage() {
|
|||||||
} else if (overId.startsWith(SEAT_PREFIX)) {
|
} else if (overId.startsWith(SEAT_PREFIX)) {
|
||||||
const overAgent = overId.slice(SEAT_PREFIX.length);
|
const overAgent = overId.slice(SEAT_PREFIX.length);
|
||||||
targetStage = findStageIndexOfSeat(stageGroups, overAgent);
|
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;
|
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 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 canSave = title.trim().length > 0 && !seatCountInvalid && !saving;
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
@@ -403,20 +429,29 @@ export default function BusinessChainEditorPage() {
|
|||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
// 展平 + 折叠:seats 用候选池最新 name/description 覆盖快照;stages 全单席位 → null。
|
// 展平 + 折叠:seats 用候选池最新 name/description 覆盖快照;stages 在「全单席位且无
|
||||||
|
// 目标」时折叠为 null(线性链),有并行或填了「本阶段目标」时落显式分层。
|
||||||
const flat = stageGroupsToSeats(stageGroups);
|
const flat = stageGroupsToSeats(stageGroups);
|
||||||
const payloadSeats = flat.map((s) => ({
|
const payloadSeats = flat.map((s) => ({
|
||||||
agent_id: s.agent_id,
|
agent_id: s.agent_id,
|
||||||
name: poolById.get(s.agent_id)?.name ?? s.name,
|
name: poolById.get(s.agent_id)?.name ?? s.name,
|
||||||
description: poolById.get(s.agent_id)?.description ?? s.description ?? "",
|
description: poolById.get(s.agent_id)?.description ?? s.description ?? "",
|
||||||
}));
|
}));
|
||||||
const stages = stageGroupsToStagePayload(stageGroups);
|
// 总控编排提示:trim 后空串落 null(清空),与后端「空=不注入」一致。
|
||||||
|
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) {
|
if (currentChainId) {
|
||||||
const updated = await updateChain(currentChainId, {
|
const updated = await updateChain(currentChainId, {
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
seats: payloadSeats,
|
seats: payloadSeats,
|
||||||
stages,
|
stages,
|
||||||
|
stageGoals,
|
||||||
|
coordinatorPrompt: coordPrompt,
|
||||||
});
|
});
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
toast.error("链条已不存在,可能被删除");
|
toast.error("链条已不存在,可能被删除");
|
||||||
@@ -433,6 +468,8 @@ export default function BusinessChainEditorPage() {
|
|||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
seats: payloadSeats,
|
seats: payloadSeats,
|
||||||
stages,
|
stages,
|
||||||
|
stageGoals,
|
||||||
|
coordinatorPrompt: coordPrompt,
|
||||||
});
|
});
|
||||||
setCurrentChainId(created.id);
|
setCurrentChainId(created.id);
|
||||||
toast.success("已创建业务链条");
|
toast.success("已创建业务链条");
|
||||||
@@ -446,6 +483,7 @@ export default function BusinessChainEditorPage() {
|
|||||||
}, [
|
}, [
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
coordinatorPrompt,
|
||||||
stageGroups,
|
stageGroups,
|
||||||
seatCountInvalid,
|
seatCountInvalid,
|
||||||
currentChainId,
|
currentChainId,
|
||||||
@@ -711,6 +749,29 @@ export default function BusinessChainEditorPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 总控编排提示:写给总控(协调)智能体的整体派活指引,仅分层 DAG 模式生效。 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mt-4 overflow-hidden border border-border bg-card transition focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/30",
|
||||||
|
R_CTRL,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b border-border bg-muted/50 px-4 py-2">
|
||||||
|
<span className="select-none text-sm font-medium text-muted-foreground">总控提示词</span>
|
||||||
|
<span className="select-none text-[10px] text-muted-foreground/70">
|
||||||
|
写给总控:怎么派活、做什么(仅分层研讨生效,可选)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={coordinatorPrompt}
|
||||||
|
onChange={(e) => setCoordinatorPrompt(e.target.value)}
|
||||||
|
maxLength={4000}
|
||||||
|
rows={3}
|
||||||
|
placeholder="例如:你是本次研讨的总控。请严格按阶段顺序派活,每阶段先明确产出目标再分配;要求各席位的交付聚焦本职、显式承接上一阶段结论;最终阶段做综合收口,给出可落地的全案。"
|
||||||
|
className="w-full resize-y bg-card px-4 py-2.5 text-sm leading-relaxed text-foreground placeholder:text-muted-foreground/60 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 双栏拖拽编辑器 */}
|
{/* 双栏拖拽编辑器 */}
|
||||||
@@ -744,7 +805,7 @@ export default function BusinessChainEditorPage() {
|
|||||||
{pool.length}
|
{pool.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[11px] font-medium text-muted-foreground">拖到右侧 / 点击加入</span>
|
<span className="text-[11px] font-medium text-muted-foreground">拖手柄到右侧 / 点卡片看详情</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0 border-b border-border px-4 py-3">
|
<div className="shrink-0 border-b border-border px-4 py-3">
|
||||||
@@ -775,6 +836,7 @@ export default function BusinessChainEditorPage() {
|
|||||||
agent={a}
|
agent={a}
|
||||||
inChain={seatIdSet.has(a.agent_id)}
|
inChain={seatIdSet.has(a.agent_id)}
|
||||||
onAdd={() => addAgentAsNewStage(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}
|
onRemoveSeat={removeSeat}
|
||||||
onMergeSeat={mergeSeatToStage}
|
onMergeSeat={mergeSeatToStage}
|
||||||
onSplitSeat={splitSeat}
|
onSplitSeat={splitSeat}
|
||||||
|
onStageGoalChange={setGoal}
|
||||||
|
onSeatDetail={showDetail}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -811,24 +875,58 @@ export default function BusinessChainEditorPage() {
|
|||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
|
|
||||||
{/* Phase 7:分层 DAG 只读流程图预览(§2.2 底部)。空编排时不渲染。 */}
|
{/* Phase 7:分层 DAG 只读流程图预览(§2.2 底部)。空编排时不渲染。
|
||||||
|
改为弹窗查看:底栏一个入口按钮,点击后在 Dialog 内展示完整流程图。 */}
|
||||||
{previewStages.length > 0 && (
|
{previewStages.length > 0 && (
|
||||||
<section className="shrink-0 border-t border-border bg-card px-6 py-2">
|
<section className="shrink-0 flex items-center justify-between gap-2 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">
|
<span className="flex items-center gap-2 text-[11px] font-semibold text-muted-foreground">
|
||||||
<span>编排预览</span>
|
<span>编排预览</span>
|
||||||
<span className="text-muted-foreground/50">
|
<span className="text-muted-foreground/50">
|
||||||
总控派活 → 各 Stage(串行)→ 共识 → 结果绘制
|
总控派活 → 各 Stage(串行)→ 共识 → 结果绘制
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</span>
|
||||||
<DagFlowChart
|
<button
|
||||||
stages={previewStages}
|
type="button"
|
||||||
resolveSeat={resolvePreviewSeat}
|
onClick={() => setFlowOpen(true)}
|
||||||
showReportMilestone={false}
|
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"
|
||||||
compact
|
>
|
||||||
/>
|
<Network className="size-3.5" />
|
||||||
|
查看编排流程图
|
||||||
|
</button>
|
||||||
</section>
|
</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">
|
<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 之间串行。全单席位 = 线性链。保存后自动同步至圆桌。
|
分层编排:同一 Stage 内的席位并行研讨,Stage 之间串行。全单席位 = 线性链。保存后自动同步至圆桌。
|
||||||
@@ -843,11 +941,14 @@ function PoolAgentCard({
|
|||||||
agent,
|
agent,
|
||||||
inChain,
|
inChain,
|
||||||
onAdd,
|
onAdd,
|
||||||
|
onShowDetail,
|
||||||
}: {
|
}: {
|
||||||
agent: RecommendCandidate;
|
agent: RecommendCandidate;
|
||||||
inChain: boolean;
|
inChain: boolean;
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
|
onShowDetail: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
// 只把拖拽 listeners 挂到左侧手柄上 —— 点手柄才拖动;点卡片本体打开详情抽屉。
|
||||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||||
id: POOL_PREFIX + agent.agent_id,
|
id: POOL_PREFIX + agent.agent_id,
|
||||||
disabled: inChain,
|
disabled: inChain,
|
||||||
@@ -855,55 +956,79 @@ function PoolAgentCard({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
onClick={inChain ? undefined : onAdd}
|
onClick={onShowDetail}
|
||||||
className={cn(
|
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,
|
R_CARD,
|
||||||
inChain
|
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)]",
|
: "cursor-pointer border-border bg-card hover:border-primary hover:shadow-[0_2px_8px_rgba(0,0,0,0.04)]",
|
||||||
isDragging && "opacity-30",
|
isDragging && "opacity-30",
|
||||||
)}
|
)}
|
||||||
{...(inChain ? {} : listeners)}
|
|
||||||
{...attributes}
|
{...attributes}
|
||||||
>
|
>
|
||||||
<div
|
{/* 拖拽手柄(与右栏席位一致):点手柄才开始拖动;已加入则禁用拖拽。 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-0.5 flex size-6 shrink-0 items-center justify-center transition-colors",
|
"flex w-7 shrink-0 items-center justify-center border-r border-border bg-muted/40 text-muted-foreground/60 transition-colors",
|
||||||
R_CARD,
|
|
||||||
inChain
|
inChain
|
||||||
? "bg-primary/10 text-primary"
|
? "cursor-not-allowed opacity-40"
|
||||||
: "bg-muted text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary",
|
: "cursor-grab touch-none hover:text-foreground active:cursor-grabbing",
|
||||||
)}
|
)}
|
||||||
|
title={inChain ? "已在链条中" : "拖动到右侧加入 / 排序"}
|
||||||
|
{...(inChain ? {} : listeners)}
|
||||||
>
|
>
|
||||||
<Bot className="size-3.5" />
|
<GripVertical className="size-3.5" />
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1 pr-16">
|
<div className="flex min-w-0 flex-1 items-start gap-3 p-3 pr-2">
|
||||||
<div className="truncate text-xs font-bold leading-tight text-foreground transition-colors group-hover:text-primary">
|
<div
|
||||||
{agent.name || agent.agent_id}
|
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>
|
</div>
|
||||||
{agent.description && (
|
|
||||||
<div className="mt-1 line-clamp-2 text-[11px] font-medium leading-normal text-muted-foreground">
|
<div className="min-w-0 flex-1">
|
||||||
{agent.description}
|
<div className="truncate text-xs font-bold leading-tight text-foreground transition-colors group-hover:text-primary">
|
||||||
|
{agent.name || agent.agent_id}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<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 ? (
|
{inChain ? (
|
||||||
<span className={cn("border border-primary/30 bg-primary/10 px-2 py-1 text-[10px] font-bold text-primary", R_CTRL)}>
|
<span className={cn("border border-primary/30 bg-primary/10 px-2 py-1 text-[10px] font-bold text-primary", R_CTRL)}>
|
||||||
已加入
|
已加入
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAdd();
|
||||||
|
}}
|
||||||
className={cn(
|
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,
|
R_CTRL,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
点击加入
|
<Plus className="size-3" />
|
||||||
</span>
|
加入
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -923,6 +1048,8 @@ function StagesColumn({
|
|||||||
onRemoveSeat,
|
onRemoveSeat,
|
||||||
onMergeSeat,
|
onMergeSeat,
|
||||||
onSplitSeat,
|
onSplitSeat,
|
||||||
|
onStageGoalChange,
|
||||||
|
onSeatDetail,
|
||||||
}: {
|
}: {
|
||||||
stageGroups: StageGroups;
|
stageGroups: StageGroups;
|
||||||
seatCount: number;
|
seatCount: number;
|
||||||
@@ -934,6 +1061,8 @@ function StagesColumn({
|
|||||||
onRemoveSeat: (agentId: string) => void;
|
onRemoveSeat: (agentId: string) => void;
|
||||||
onMergeSeat: (agentId: string, targetStageIdx: number) => void;
|
onMergeSeat: (agentId: string, targetStageIdx: number) => void;
|
||||||
onSplitSeat: (agentId: string) => void;
|
onSplitSeat: (agentId: string) => void;
|
||||||
|
onStageGoalChange: (stageIdx: number, goal: string) => void;
|
||||||
|
onSeatDetail: (agentId: string, name?: string) => void;
|
||||||
}) {
|
}) {
|
||||||
// 跨 Stage 的全局序号(头像编号):每个 Stage 起始累加。
|
// 跨 Stage 的全局序号(头像编号):每个 Stage 起始累加。
|
||||||
const stageStartIndex = useMemo(() => {
|
const stageStartIndex = useMemo(() => {
|
||||||
@@ -941,10 +1070,12 @@ function StagesColumn({
|
|||||||
let acc = 0;
|
let acc = 0;
|
||||||
for (const g of stageGroups) {
|
for (const g of stageGroups) {
|
||||||
out.push(acc);
|
out.push(acc);
|
||||||
acc += g.length;
|
acc += g.seats.length;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}, [stageGroups]);
|
}, [stageGroups]);
|
||||||
|
// 是否含并行 stage → 决定提示「目标仅对含并行的分层链条生效」。
|
||||||
|
const hasParallelStage = stageGroups.some((g) => g.seats.length > 1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
@@ -1018,11 +1149,14 @@ function StagesColumn({
|
|||||||
group={group}
|
group={group}
|
||||||
stageCount={stageGroups.length}
|
stageCount={stageGroups.length}
|
||||||
startIndex={stageStartIndex[stageIdx]}
|
startIndex={stageStartIndex[stageIdx]}
|
||||||
|
hasParallelStage={hasParallelStage}
|
||||||
onRemoveStage={() => onRemoveStage(stageIdx)}
|
onRemoveStage={() => onRemoveStage(stageIdx)}
|
||||||
onReorderStage={(dir) => onReorderStage(stageIdx, dir)}
|
onReorderStage={(dir) => onReorderStage(stageIdx, dir)}
|
||||||
onRemoveSeat={onRemoveSeat}
|
onRemoveSeat={onRemoveSeat}
|
||||||
onMergeSeat={onMergeSeat}
|
onMergeSeat={onMergeSeat}
|
||||||
onSplitSeat={onSplitSeat}
|
onSplitSeat={onSplitSeat}
|
||||||
|
onGoalChange={(goal) => onStageGoalChange(stageIdx, goal)}
|
||||||
|
onSeatDetail={onSeatDetail}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<NewStageDropZone hasEmptyStage={hasEmptyStage} onAddStage={onAddStage} />
|
<NewStageDropZone hasEmptyStage={hasEmptyStage} onAddStage={onAddStage} />
|
||||||
@@ -1040,27 +1174,35 @@ function StageSection({
|
|||||||
group,
|
group,
|
||||||
stageCount,
|
stageCount,
|
||||||
startIndex,
|
startIndex,
|
||||||
|
hasParallelStage,
|
||||||
onRemoveStage,
|
onRemoveStage,
|
||||||
onReorderStage,
|
onReorderStage,
|
||||||
onRemoveSeat,
|
onRemoveSeat,
|
||||||
onMergeSeat,
|
onMergeSeat,
|
||||||
onSplitSeat,
|
onSplitSeat,
|
||||||
|
onGoalChange,
|
||||||
|
onSeatDetail,
|
||||||
}: {
|
}: {
|
||||||
stageIdx: number;
|
stageIdx: number;
|
||||||
group: ChainSeat[];
|
group: StageGroup;
|
||||||
stageCount: number;
|
stageCount: number;
|
||||||
startIndex: number;
|
startIndex: number;
|
||||||
|
/** 整条链是否含并行 stage(决定目标输入框的提示语,目标仅 dag 模式生效)。 */
|
||||||
|
hasParallelStage: boolean;
|
||||||
onRemoveStage: () => void;
|
onRemoveStage: () => void;
|
||||||
onReorderStage: (dir: -1 | 1) => void;
|
onReorderStage: (dir: -1 | 1) => void;
|
||||||
onRemoveSeat: (agentId: string) => void;
|
onRemoveSeat: (agentId: string) => void;
|
||||||
onMergeSeat: (agentId: string, targetStageIdx: number) => void;
|
onMergeSeat: (agentId: string, targetStageIdx: number) => void;
|
||||||
onSplitSeat: (agentId: string) => void;
|
onSplitSeat: (agentId: string) => void;
|
||||||
|
onGoalChange: (goal: string) => void;
|
||||||
|
onSeatDetail: (agentId: string, name?: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { setNodeRef, isOver } = useDroppable({ id: STAGE_PREFIX + stageIdx });
|
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 isFirstStage = stageIdx === 0;
|
||||||
const isLastStage = stageIdx === stageCount - 1;
|
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 (
|
return (
|
||||||
<div className={cn("border border-border bg-muted/20", R_CARD, PANEL_SHADOW)}>
|
<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",
|
: "border-border bg-muted text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isParallel ? `并行 ${group.length}` : "串行"}
|
{isParallel ? `并行 ${seats.length}` : "串行"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
@@ -1131,18 +1273,45 @@ function StageSection({
|
|||||||
</div>
|
</div>
|
||||||
</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 内席位 */}
|
{/* Stage 内席位 */}
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
className={cn("space-y-2 p-2.5 transition-colors", isOver && "bg-primary/[0.04]")}
|
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)}>
|
<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。
|
空 Stage —— 把席位拖到此处,或删除该 Stage。
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<SortableContext items={seatItemIds} strategy={verticalListSortingStrategy}>
|
<SortableContext items={seatItemIds} strategy={verticalListSortingStrategy}>
|
||||||
{group.map((seat, i) => (
|
{seats.map((seat, i) => (
|
||||||
<SeatStrip
|
<SeatStrip
|
||||||
key={seat.agent_id}
|
key={seat.agent_id}
|
||||||
seat={seat}
|
seat={seat}
|
||||||
@@ -1155,6 +1324,7 @@ function StageSection({
|
|||||||
onMergeUp={() => onMergeSeat(seat.agent_id, stageIdx - 1)}
|
onMergeUp={() => onMergeSeat(seat.agent_id, stageIdx - 1)}
|
||||||
onMergeDown={() => onMergeSeat(seat.agent_id, stageIdx + 1)}
|
onMergeDown={() => onMergeSeat(seat.agent_id, stageIdx + 1)}
|
||||||
onSplit={() => onSplitSeat(seat.agent_id)}
|
onSplit={() => onSplitSeat(seat.agent_id)}
|
||||||
|
onDetail={() => onSeatDetail(seat.agent_id, seat.name)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
@@ -1176,6 +1346,7 @@ function SeatStrip({
|
|||||||
onMergeUp,
|
onMergeUp,
|
||||||
onMergeDown,
|
onMergeDown,
|
||||||
onSplit,
|
onSplit,
|
||||||
|
onDetail,
|
||||||
}: {
|
}: {
|
||||||
seat: ChainSeat;
|
seat: ChainSeat;
|
||||||
globalIndex: number;
|
globalIndex: number;
|
||||||
@@ -1187,6 +1358,7 @@ function SeatStrip({
|
|||||||
onMergeUp: () => void;
|
onMergeUp: () => void;
|
||||||
onMergeDown: () => void;
|
onMergeDown: () => void;
|
||||||
onSplit: () => void;
|
onSplit: () => void;
|
||||||
|
onDetail: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||||
useSortable({ id: SEAT_PREFIX + seat.agent_id });
|
useSortable({ id: SEAT_PREFIX + seat.agent_id });
|
||||||
@@ -1236,8 +1408,19 @@ function SeatStrip({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 控制:并入上一阶段 / 独立成阶段 / 并入下一阶段 / 移除 */}
|
{/* 控制:查看详情 / 并入上一阶段 / 独立成阶段 / 并入下一阶段 / 移除 */}
|
||||||
<div className="flex shrink-0 items-center gap-0.5 border-l border-border px-2">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onMergeUp}
|
onClick={onMergeUp}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
* 6) 模型选择全局默认 `availableModels[0].name`,Persona 覆盖更高优先级
|
* 6) 模型选择全局默认 `availableModels[0].name`,Persona 覆盖更高优先级
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Check,
|
Check,
|
||||||
@@ -84,14 +84,44 @@ import {
|
|||||||
type RoleEntry,
|
type RoleEntry,
|
||||||
type SelectedAgent,
|
type SelectedAgent,
|
||||||
} from "@/roundtable-planning/lib/roundtable-constants";
|
} 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 type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types";
|
||||||
import { getJobByDraft, startJob } from "@/roundtable-planning/api/roundtable-jobs";
|
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() {
|
export default function RoundtablePlanningPage() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
const navigate = useNavigate();
|
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();
|
const { models: availableModels, isLoading: modelsLoading } = useModels();
|
||||||
|
|
||||||
@@ -604,6 +634,69 @@ export default function RoundtablePlanningPage() {
|
|||||||
[buildCurrentStep2Snapshot, mergeActiveRun, step2, step3Report, triggerToast],
|
[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("⚠️ 请先与任务理解智能体完成澄清,生成任务摘要后再进入");
|
triggerToast("⚠️ 请先与任务理解智能体完成澄清,生成任务摘要后再进入");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 会商自动化串联(模块 C):若任务页带来的业务链已解析成功,跳过选链弹窗,
|
||||||
|
// 直接据该业务链进入 Step 2 研讨(用户无需再手选)。
|
||||||
|
if (autoChainResult) {
|
||||||
|
const result = autoChainResult;
|
||||||
|
void drafts.ensureDraft(1).finally(() => handleConfirmAgents(result));
|
||||||
|
return;
|
||||||
|
}
|
||||||
// 先把草稿落地拿到 draft_id,推荐历史才有归属;失败也照常打开弹窗
|
// 先把草稿落地拿到 draft_id,推荐历史才有归属;失败也照常打开弹窗
|
||||||
// (弹窗在无 draftId 时退化为「直接推荐、不存历史」)。
|
// (弹窗在无 draftId 时退化为「直接推荐、不存历史」)。
|
||||||
void drafts.ensureDraft(1).finally(() => setIsRecommendDialogOpen(true));
|
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);
|
||||||
|
}
|
||||||
+53
-26
@@ -4,9 +4,11 @@ import { Bot, ChevronRight, Download, Edit, Eye, FileText, Layers, MoreHorizonta
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { requestCrudService } from '@/service/request-axios-service';
|
||||||
import { ActionColumn } from './ActionColumn';
|
import { ActionColumn } from './ActionColumn';
|
||||||
|
import { resolveStatusIcon } from './icons';
|
||||||
|
import { mockActionColumns, mockTaskList } from './actionMock';
|
||||||
|
|
||||||
interface ActionAnalyzesProps {
|
interface ActionAnalyzesProps {
|
||||||
TaskType?: string;
|
TaskType?: string;
|
||||||
@@ -68,6 +70,14 @@ export const ActionAnalyzes: React.FC<ActionAnalyzesProps> = ({ title, category,
|
|||||||
await taskListSearch();
|
await taskListSearch();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 接口不可用 / 返回为空时回落到本地预览数据,方便联调看效果。
|
||||||
|
const fallbackToMock = () => {
|
||||||
|
setTaskList(mockTaskList);
|
||||||
|
setTotal(mockTaskList.length);
|
||||||
|
setActionColumnList(mockActionColumns);
|
||||||
|
if (mockTaskList[0]) setTaskId(mockTaskList[0].id);
|
||||||
|
};
|
||||||
|
|
||||||
// 任务查询
|
// 任务查询
|
||||||
const taskListSearch = async () => {
|
const taskListSearch = async () => {
|
||||||
const param = {
|
const param = {
|
||||||
@@ -76,21 +86,27 @@ export const ActionAnalyzes: React.FC<ActionAnalyzesProps> = ({ title, category,
|
|||||||
pageSize: state.current.pageSize
|
pageSize: state.current.pageSize
|
||||||
}
|
}
|
||||||
setTaskLoading(true);
|
setTaskLoading(true);
|
||||||
const res = await service.post(`${zncmUrl}/action/cop-action-tasks`, param);
|
try {
|
||||||
setTaskLoading(false);
|
const res = await service.post(`${zncmUrl}/action/cop-action-tasks`, param);
|
||||||
console.log('任务搜索', res);
|
const result: TaskListItem[] = Array.isArray(res?.data) ? res.data : [];
|
||||||
const result = res.data;
|
if (result.length === 0) {
|
||||||
result.forEach((item: TaskListItem) => {
|
fallbackToMock();
|
||||||
item.progress = item.endCount / item.sumAll || 0; // 获取已完成和未完成的占比
|
return;
|
||||||
item.taskStatusData = taskStatusList[item.taskStatus] // 根据任务状态码获取相关信息
|
}
|
||||||
})
|
result.forEach((item: TaskListItem) => {
|
||||||
setTaskList(result || []); // 设置任务列表页
|
item.progress = item.endCount / item.sumAll || 0; // 获取已完成和未完成的占比
|
||||||
const totalRes = await service.post(`${zncmUrl}/action/cop-action-tasks-count`, param); // 查询任务总数
|
item.taskStatusData = taskStatusList[item.taskStatus] // 根据任务状态码获取相关信息
|
||||||
console.log('totalRes', totalRes)
|
})
|
||||||
setTotal(totalRes.data || 0); // 设置总数
|
setTaskList(result); // 设置任务列表页
|
||||||
if (result.length > 0) {
|
const totalRes = await service.post(`${zncmUrl}/action/cop-action-tasks-count`, param); // 查询任务总数
|
||||||
|
setTotal(totalRes?.data || result.length); // 设置总数
|
||||||
setTaskId(result[0].id);
|
setTaskId(result[0].id);
|
||||||
await actionListSearch(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,
|
taskId: taskId,
|
||||||
}
|
}
|
||||||
setActionLoading(true);
|
setActionLoading(true);
|
||||||
const res = await service.get(`${zncmUrl}/action/cop-task-actions`, param);
|
try {
|
||||||
console.log('行动查询', res);
|
const res = await service.get(`${zncmUrl}/action/cop-task-actions`, param);
|
||||||
setActionLoading(false);
|
const result = res?.data ?? {};
|
||||||
const result = res.data;
|
const columns = [
|
||||||
setActionColumnList([
|
{ columnName: '待分析', icon: '待CEH.svg', actionList: result.collect || [] },
|
||||||
{ columnName: '待分析', icon: '待CEH.svg', actionList: result.collect || [] },
|
{ columnName: 'CEH中', icon: '进行中.svg', actionList: result.collect1 || [] },
|
||||||
{ columnName: 'CEH中', icon: '进行中.svg', actionList: result.collect1 || [] },
|
{ columnName: 'CEH完成', icon: '完成.svg', actionList: result.collect2 || [] },
|
||||||
{ columnName: 'CEH完成', icon: '完成.svg', actionList: result.collect2 || [] },
|
{ columnName: '已终止', icon: '已终止.svg', actionList: result.endDate || [] },
|
||||||
{ 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) => {
|
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-[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="text-[#7f7f7f] mb-[5px]">{format(new Date(item.taskStartDate), 'yyyy-MM-dd HH:mm:ss')}</div>
|
||||||
<div className="flex items-center mb-[8px]">
|
<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>
|
<span className="ml-[5px] leading-[16px]">{item?.taskStatusData?.label}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="pb-[10px] pt-[10px]">
|
<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>
|
</div>
|
||||||
</Loading>
|
</Loading>
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import { Bot, ChevronRight, Download, Edit, Eye, FileText, Layers, MoreHorizonta
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { requestCrudService } from '@/service/request-axios-service';
|
||||||
|
import { resolveStatusIcon } from './icons';
|
||||||
|
|
||||||
interface ActionColumnProps {
|
interface ActionColumnProps {
|
||||||
TaskType?: string;
|
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={`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="mb-[10px]">
|
||||||
<div className="flex items-center mb-[20px]">
|
<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]">{columnName}</span>
|
||||||
<span className="text-[#222222] text-[18px] font-bold mr-[20px]">{actionCount}</span>
|
<span className="text-[#222222] text-[18px] font-bold mr-[20px]">{actionCount}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,13 +100,11 @@ export const ActionColumn: React.FC<ActionColumnProps> = ({ title, category, sub
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
actionList.length === 0 && <Empty image={
|
actionList.length === 0 && (
|
||||||
<img
|
<div className={`flex items-center justify-center py-[48px] text-sm ${isDark ? 'text-slate-500' : 'text-[#bfbfbf]'}`}>
|
||||||
src="/icon/empty-list.png"
|
暂无数据
|
||||||
alt="empty"
|
</div>
|
||||||
className="w-[135px]"
|
)
|
||||||
/>
|
|
||||||
} />
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+38
-29
@@ -4,10 +4,10 @@ import dayjs from 'dayjs';
|
|||||||
import { DateRangePicker, DateRangePickerProps, DateRangeValue, Loading } from 'tdesign-react';
|
import { DateRangePicker, DateRangePickerProps, DateRangeValue, Loading } from 'tdesign-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTheme } from '@/contexts/ThemeContext';
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
import { useChatContext } from '@/contexts/ChatContext';
|
|
||||||
import { requestCrudService } from '@/service/request-axios-service';
|
import { requestCrudService } from '@/service/request-axios-service';
|
||||||
import { KanbanColumn } from './KanbanColumn';
|
import { KanbanColumn } from './KanbanColumn';
|
||||||
import { TaskListItem } from './TaskManagement';
|
import { TaskListItem } from './TaskManagement';
|
||||||
|
import { sentimentAnalyzeMock } from './kanbanMock';
|
||||||
|
|
||||||
interface TaskListPageProps {
|
interface TaskListPageProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -28,7 +28,6 @@ const zncmUrl = import.meta.env.VITE_API_BASE_URL || '';
|
|||||||
|
|
||||||
const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategory }) => {
|
const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategory }) => {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const { setStartConfig } = useChatContext();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isDark = theme === 'dark';
|
const isDark = theme === 'dark';
|
||||||
|
|
||||||
@@ -80,6 +79,19 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
|
|||||||
await taskDataChange();
|
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 (
|
const taskListSearch = async (
|
||||||
taskType: ColumnType,
|
taskType: ColumnType,
|
||||||
pageNum = 1,
|
pageNum = 1,
|
||||||
@@ -107,18 +119,19 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
|
|||||||
column: taskType,
|
column: taskType,
|
||||||
taskLevel: item.taskLevel || 1,
|
taskLevel: item.taskLevel || 1,
|
||||||
}));
|
}));
|
||||||
const total = Number(result.total ?? normalized.length);
|
|
||||||
|
|
||||||
if (taskType === 'unanalyzed') {
|
if (normalized.length === 0) {
|
||||||
setUnanalyzedTotal(total);
|
// 接口无数据时回落到本地预览数据,方便联调看效果
|
||||||
setUnanalyzedList(normalized);
|
const mock = sentimentAnalyzeMock[taskType];
|
||||||
} else if (taskType === 'running') {
|
applyColumnData(taskType, mock, mock.length);
|
||||||
setRunningTotal(total);
|
return;
|
||||||
setRunningList(normalized);
|
|
||||||
} else {
|
|
||||||
setCompletedTotal(total);
|
|
||||||
setCompletedList(normalized);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyColumnData(taskType, normalized, Number(result.total ?? normalized.length));
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('三情任务查询失败,使用本地预览数据', err);
|
||||||
|
const mock = sentimentAnalyzeMock[taskType];
|
||||||
|
applyColumnData(taskType, mock, mock.length);
|
||||||
} finally {
|
} finally {
|
||||||
if (taskType === 'unanalyzed') setUnanalyzedLoading(false);
|
if (taskType === 'unanalyzed') setUnanalyzedLoading(false);
|
||||||
if (taskType === 'running') setRunningLoading(false);
|
if (taskType === 'running') setRunningLoading(false);
|
||||||
@@ -153,23 +166,19 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
|
|||||||
const handleOpenConsultation = () => {
|
const handleOpenConsultation = () => {
|
||||||
const task = stateRef.current.task;
|
const task = stateRef.current.task;
|
||||||
if (!task) return;
|
if (!task) return;
|
||||||
|
// 会商自动化串联(模块 C):跳转圆桌规划页,带上任务名称/描述 + 本页业务码(3Q=三情分析)。
|
||||||
try {
|
// 圆桌据此自动开跑 Step 1,并在「进入研讨」时自动选用 3Q 配置的业务链(跳过选链弹窗)。
|
||||||
const sceneItem = JSON.parse(task.scene || '{}');
|
navigate('/page/strategy/qa/multi-agent-roundtable', {
|
||||||
if (sceneItem.taskJson) {
|
state: {
|
||||||
const taskJson = JSON.parse(sceneItem.taskJson);
|
roundtablePrefill: {
|
||||||
setStartConfig({
|
taskTitle: task.taskName,
|
||||||
...taskJson,
|
taskGoal: task.overview,
|
||||||
llm_preset_id: 'BAILING',
|
businessCode: '3Q',
|
||||||
consistency_mode: 'soft',
|
autoStart: true,
|
||||||
phase_config: { diverge_rounds: 3 },
|
},
|
||||||
task_context: task.overview,
|
},
|
||||||
});
|
});
|
||||||
navigate('/qa-center/mope-chat');
|
setShowAnalysisModal(false);
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setShowAnalysisModal(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = async (event: React.KeyboardEvent<HTMLInputElement>) => {
|
const handleKeyDown = async (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { DateRangePicker, DateRangePickerProps, DateRangeValue, Loading } from '
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTheme } from '@/contexts/ThemeContext';
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
import { useChatContext } from '@/contexts/ChatContext';
|
|
||||||
import { requestCrudService } from '@/service/request-axios-service';
|
import { requestCrudService } from '@/service/request-axios-service';
|
||||||
import { KanbanColumn } from './KanbanColumn';
|
import { KanbanColumn } from './KanbanColumn';
|
||||||
import { TaskListItem } from './TaskManagement';
|
import { TaskListItem } from './TaskManagement';
|
||||||
|
import { taskAnalyzeMock } from './kanbanMock';
|
||||||
|
|
||||||
interface TaskListPageProps {
|
interface TaskListPageProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -28,7 +28,6 @@ const zncmUrl = import.meta.env.VITE_API_BASE_URL || '';
|
|||||||
|
|
||||||
const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategory }) => {
|
const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategory }) => {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const { setStartConfig } = useChatContext();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isDark = theme === 'dark';
|
const isDark = theme === 'dark';
|
||||||
|
|
||||||
@@ -80,6 +79,19 @@ const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategor
|
|||||||
await taskDataChange();
|
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 (
|
const taskListSearch = async (
|
||||||
taskType: ColumnType,
|
taskType: ColumnType,
|
||||||
pageNum = 1,
|
pageNum = 1,
|
||||||
@@ -107,18 +119,19 @@ const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategor
|
|||||||
column: taskType,
|
column: taskType,
|
||||||
taskLevel: item.taskLevel || 1,
|
taskLevel: item.taskLevel || 1,
|
||||||
}));
|
}));
|
||||||
const total = Number(result.total ?? normalized.length);
|
|
||||||
|
|
||||||
if (taskType === 'unanalyzed') {
|
if (normalized.length === 0) {
|
||||||
setUnanalyzedTotal(total);
|
// 接口无数据时回落到本地预览数据,方便联调看效果
|
||||||
setUnanalyzedList(normalized);
|
const mock = taskAnalyzeMock[taskType];
|
||||||
} else if (taskType === 'running') {
|
applyColumnData(taskType, mock, mock.length);
|
||||||
setRunningTotal(total);
|
return;
|
||||||
setRunningList(normalized);
|
|
||||||
} else {
|
|
||||||
setCompletedTotal(total);
|
|
||||||
setCompletedList(normalized);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyColumnData(taskType, normalized, Number(result.total ?? normalized.length));
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('任务查询失败,使用本地预览数据', err);
|
||||||
|
const mock = taskAnalyzeMock[taskType];
|
||||||
|
applyColumnData(taskType, mock, mock.length);
|
||||||
} finally {
|
} finally {
|
||||||
if (taskType === 'unanalyzed') setUnanalyzedLoading(false);
|
if (taskType === 'unanalyzed') setUnanalyzedLoading(false);
|
||||||
if (taskType === 'running') setRunningLoading(false);
|
if (taskType === 'running') setRunningLoading(false);
|
||||||
@@ -153,23 +166,19 @@ const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategor
|
|||||||
const handleOpenConsultation = () => {
|
const handleOpenConsultation = () => {
|
||||||
const task = stateRef.current.task;
|
const task = stateRef.current.task;
|
||||||
if (!task) return;
|
if (!task) return;
|
||||||
|
// 会商自动化串联(模块 C):跳转圆桌规划页,带上任务名称/描述 + 本页业务码(6BF=任务FX)。
|
||||||
try {
|
// 圆桌据此自动开跑 Step 1,并在「进入研讨」时自动选用 6BF 配置的业务链(跳过选链弹窗)。
|
||||||
const sceneItem = JSON.parse(task.scene || '{}');
|
navigate('/page/strategy/qa/multi-agent-roundtable', {
|
||||||
if (sceneItem.taskJson) {
|
state: {
|
||||||
const taskJson = JSON.parse(sceneItem.taskJson);
|
roundtablePrefill: {
|
||||||
setStartConfig({
|
taskTitle: task.taskName,
|
||||||
...taskJson,
|
taskGoal: task.overview,
|
||||||
llm_preset_id: 'BAILING',
|
businessCode: '6BF',
|
||||||
consistency_mode: 'soft',
|
autoStart: true,
|
||||||
phase_config: { diverge_rounds: 3 },
|
},
|
||||||
task_context: task.overview,
|
},
|
||||||
});
|
});
|
||||||
navigate('/qa-center/mope-chat');
|
setShowAnalysisModal(false);
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setShowAnalysisModal(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = async (event: React.KeyboardEvent<HTMLInputElement>) => {
|
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="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-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>
|
<div>
|
||||||
<h1 className={`text-[26px] font-semibold tracking-[0.02em] ${isDark ? 'text-slate-50' : 'text-[#222222]'}`}>
|
<h1 className={`text-[26px] font-semibold tracking-[0.02em] ${isDark ? 'text-slate-50' : 'text-[#222222]'}`}>
|
||||||
{title}
|
{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",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -17,7 +17,7 @@ SERPER_API_KEY=your-serper-api-key
|
|||||||
# AUTH_JWT_SECRET must stay stable across restarts, or existing logins expire.
|
# AUTH_JWT_SECRET must stay stable across restarts, or existing logins expire.
|
||||||
# GATEWAY_CORS_ORIGINS must include the exact frontend origins, separated by commas.
|
# GATEWAY_CORS_ORIGINS must include the exact frontend origins, separated by commas.
|
||||||
AUTH_JWT_SECRET=i3_BiBK44sYHiFIkChhDs46y57ZewJKO3diQDw01anI
|
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_CORS_ALLOW_ALL=0
|
||||||
GATEWAY_ENABLE_DOCS=true
|
GATEWAY_ENABLE_DOCS=true
|
||||||
GATEWAY_WORKERS=2
|
GATEWAY_WORKERS=2
|
||||||
|
|||||||
@@ -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,
|
artifacts,
|
||||||
assistants_compat,
|
assistants_compat,
|
||||||
auth,
|
auth,
|
||||||
channels,
|
business_mapping,
|
||||||
checkpoint_migration,
|
channels,
|
||||||
feedback,
|
checkpoint_migration,
|
||||||
|
feedback,
|
||||||
html_page_favorites,
|
html_page_favorites,
|
||||||
intent,
|
intent,
|
||||||
llm_metrics,
|
llm_metrics,
|
||||||
@@ -41,6 +42,8 @@ from app.gateway.routers import (
|
|||||||
models,
|
models,
|
||||||
multi_agent,
|
multi_agent,
|
||||||
notifications,
|
notifications,
|
||||||
|
parallel_agent_tasks,
|
||||||
|
parallel_agents,
|
||||||
public_embed,
|
public_embed,
|
||||||
public_scheduled_tasks,
|
public_scheduled_tasks,
|
||||||
recommend,
|
recommend,
|
||||||
@@ -378,7 +381,7 @@ def _release_singleton_lock(handle) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def _curator_scheduler_loop() -> None:
|
async def _curator_scheduler_loop() -> None:
|
||||||
"""常驻后台任务:每隔 _CURATOR_CHECK_INTERVAL_SECONDS 检查一次需不需要进化。
|
"""常驻后台任务:每隔 _CURATOR_CHECK_INTERVAL_SECONDS 检查一次需不需要进化。
|
||||||
|
|
||||||
多 worker 部署下用文件锁做 leader 选举 —— 只有抢到锁的那个 worker 进程真正
|
多 worker 部署下用文件锁做 leader 选举 —— 只有抢到锁的那个 worker 进程真正
|
||||||
@@ -404,248 +407,248 @@ async def _curator_scheduler_loop() -> None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Curator scheduler tick failed (non-fatal)")
|
logger.exception("Curator scheduler tick failed (non-fatal)")
|
||||||
await asyncio.sleep(_CURATOR_CHECK_INTERVAL_SECONDS)
|
await asyncio.sleep(_CURATOR_CHECK_INTERVAL_SECONDS)
|
||||||
finally:
|
finally:
|
||||||
_release_singleton_lock(lock_handle)
|
_release_singleton_lock(lock_handle)
|
||||||
|
|
||||||
|
|
||||||
def _env_bool(name: str, default: bool) -> bool:
|
def _env_bool(name: str, default: bool) -> bool:
|
||||||
raw = os.getenv(name)
|
raw = os.getenv(name)
|
||||||
if raw is None:
|
if raw is None:
|
||||||
return default
|
return default
|
||||||
return raw.strip().lower() not in {"0", "false", "no", "off"}
|
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:
|
def _env_int(name: str, default: int, *, alias: str | None = None, minimum: int = 1) -> int:
|
||||||
raw = os.getenv(name)
|
raw = os.getenv(name)
|
||||||
if raw is None and alias:
|
if raw is None and alias:
|
||||||
raw = os.getenv(alias)
|
raw = os.getenv(alias)
|
||||||
if raw is None:
|
if raw is None:
|
||||||
raw = str(default)
|
raw = str(default)
|
||||||
try:
|
try:
|
||||||
value = int(raw)
|
value = int(raw)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return default
|
return default
|
||||||
return max(minimum, value)
|
return max(minimum, value)
|
||||||
|
|
||||||
|
|
||||||
def _env_float(name: str, default: float, *, alias: str | None = None, minimum: float = 0.0) -> float:
|
def _env_float(name: str, default: float, *, alias: str | None = None, minimum: float = 0.0) -> float:
|
||||||
raw = os.getenv(name)
|
raw = os.getenv(name)
|
||||||
if raw is None and alias:
|
if raw is None and alias:
|
||||||
raw = os.getenv(alias)
|
raw = os.getenv(alias)
|
||||||
if raw is None:
|
if raw is None:
|
||||||
raw = str(default)
|
raw = str(default)
|
||||||
try:
|
try:
|
||||||
value = float(raw)
|
value = float(raw)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return default
|
return default
|
||||||
return max(minimum, value)
|
return max(minimum, value)
|
||||||
|
|
||||||
|
|
||||||
_LEADERBOARD_SNAPSHOT_BACKGROUND_ENABLED = _env_bool("ADMIN_LEADERBOARD_BACKGROUND_ENABLED", True)
|
_LEADERBOARD_SNAPSHOT_BACKGROUND_ENABLED = _env_bool("ADMIN_LEADERBOARD_BACKGROUND_ENABLED", True)
|
||||||
_LEADERBOARD_SNAPSHOT_PREWARM_DAYS = _env_int(
|
_LEADERBOARD_SNAPSHOT_PREWARM_DAYS = _env_int(
|
||||||
"ADMIN_LEADERBOARD_BACKGROUND_PREWARM_DAYS",
|
"ADMIN_LEADERBOARD_BACKGROUND_PREWARM_DAYS",
|
||||||
30,
|
30,
|
||||||
)
|
)
|
||||||
_LEADERBOARD_SNAPSHOT_BATCH_SIZE = _env_int(
|
_LEADERBOARD_SNAPSHOT_BATCH_SIZE = _env_int(
|
||||||
"ADMIN_LEADERBOARD_BACKGROUND_BATCH_SIZE",
|
"ADMIN_LEADERBOARD_BACKGROUND_BATCH_SIZE",
|
||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
_LEADERBOARD_SNAPSHOT_INTERVAL_SECONDS = _env_float(
|
_LEADERBOARD_SNAPSHOT_INTERVAL_SECONDS = _env_float(
|
||||||
"ADMIN_LEADERBOARD_BACKGROUND_INTERVAL_SECONDS",
|
"ADMIN_LEADERBOARD_BACKGROUND_INTERVAL_SECONDS",
|
||||||
300.0,
|
300.0,
|
||||||
)
|
)
|
||||||
_LEADERBOARD_SNAPSHOT_SLEEP_SECONDS = _env_float(
|
_LEADERBOARD_SNAPSHOT_SLEEP_SECONDS = _env_float(
|
||||||
"ADMIN_LEADERBOARD_BACKGROUND_SLEEP_SECONDS",
|
"ADMIN_LEADERBOARD_BACKGROUND_SLEEP_SECONDS",
|
||||||
2.0,
|
2.0,
|
||||||
)
|
)
|
||||||
_LEADERBOARD_SNAPSHOT_RUNNING_TTL_SECONDS = _env_int(
|
_LEADERBOARD_SNAPSHOT_RUNNING_TTL_SECONDS = _env_int(
|
||||||
"ADMIN_LEADERBOARD_BACKGROUND_RUNNING_TTL_SECONDS",
|
"ADMIN_LEADERBOARD_BACKGROUND_RUNNING_TTL_SECONDS",
|
||||||
1800,
|
1800,
|
||||||
)
|
)
|
||||||
_LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS = _env_float(
|
_LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS = _env_float(
|
||||||
"ADMIN_LEADERBOARD_BACKGROUND_STARTUP_DELAY_SECONDS",
|
"ADMIN_LEADERBOARD_BACKGROUND_STARTUP_DELAY_SECONDS",
|
||||||
60.0,
|
60.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _recent_leaderboard_stat_dates(days: int) -> list[str]:
|
def _recent_leaderboard_stat_dates(days: int) -> list[str]:
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from deerflow.persistence.types import BEIJING_TZ
|
from deerflow.persistence.types import BEIJING_TZ
|
||||||
|
|
||||||
today = datetime.now(BEIJING_TZ).date()
|
today = datetime.now(BEIJING_TZ).date()
|
||||||
start = today - timedelta(days=max(1, days) - 1)
|
start = today - timedelta(days=max(1, days) - 1)
|
||||||
return [(start + timedelta(days=i)).isoformat() for i in range((today - start).days + 1)]
|
return [(start + timedelta(days=i)).isoformat() for i in range((today - start).days + 1)]
|
||||||
|
|
||||||
|
|
||||||
async def _leaderboard_snapshot_due_dates(
|
async def _leaderboard_snapshot_due_dates(
|
||||||
session_factory,
|
session_factory,
|
||||||
*,
|
*,
|
||||||
settings_hash: str,
|
settings_hash: str,
|
||||||
limit: int,
|
limit: int,
|
||||||
running_ttl_seconds: int,
|
running_ttl_seconds: int,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
from sqlalchemy import and_, or_, select
|
from sqlalchemy import and_, or_, select
|
||||||
|
|
||||||
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
|
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
|
||||||
|
|
||||||
cutoff = datetime.now(UTC) - timedelta(seconds=running_ttl_seconds)
|
cutoff = datetime.now(UTC) - timedelta(seconds=running_ttl_seconds)
|
||||||
async with session_factory() as session:
|
async with session_factory() as session:
|
||||||
rows = (
|
rows = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(AdminLeaderboardDailyStatRow.stat_date)
|
select(AdminLeaderboardDailyStatRow.stat_date)
|
||||||
.where(
|
.where(
|
||||||
AdminLeaderboardDailyStatRow.settings_hash == settings_hash,
|
AdminLeaderboardDailyStatRow.settings_hash == settings_hash,
|
||||||
or_(
|
or_(
|
||||||
AdminLeaderboardDailyStatRow.status.in_(["queued", "error"]),
|
AdminLeaderboardDailyStatRow.status.in_(["queued", "error"]),
|
||||||
and_(
|
and_(
|
||||||
AdminLeaderboardDailyStatRow.status == "running",
|
AdminLeaderboardDailyStatRow.status == "running",
|
||||||
AdminLeaderboardDailyStatRow.updated_at < cutoff,
|
AdminLeaderboardDailyStatRow.updated_at < cutoff,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.order_by(AdminLeaderboardDailyStatRow.stat_date.desc())
|
.order_by(AdminLeaderboardDailyStatRow.stat_date.desc())
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
return [str(row[0]) for row in rows]
|
return [str(row[0]) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
async def _claim_leaderboard_snapshot(
|
async def _claim_leaderboard_snapshot(
|
||||||
session_factory,
|
session_factory,
|
||||||
*,
|
*,
|
||||||
stat_date: str,
|
stat_date: str,
|
||||||
settings_hash: str,
|
settings_hash: str,
|
||||||
running_ttl_seconds: int,
|
running_ttl_seconds: int,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from app.gateway.routers.admin_users import _snapshot_is_fresh
|
from app.gateway.routers.admin_users import _snapshot_is_fresh
|
||||||
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
|
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
|
||||||
from deerflow.persistence.types import BEIJING_TZ
|
from deerflow.persistence.types import BEIJING_TZ
|
||||||
|
|
||||||
async with session_factory() as session:
|
async with session_factory() as session:
|
||||||
row = await session.get(
|
row = await session.get(
|
||||||
AdminLeaderboardDailyStatRow,
|
AdminLeaderboardDailyStatRow,
|
||||||
{"stat_date": stat_date, "settings_hash": settings_hash},
|
{"stat_date": stat_date, "settings_hash": settings_hash},
|
||||||
)
|
)
|
||||||
if row is None or _snapshot_is_fresh(row, stat_date):
|
if row is None or _snapshot_is_fresh(row, stat_date):
|
||||||
return False
|
return False
|
||||||
if row.status == "running":
|
if row.status == "running":
|
||||||
updated_at = row.updated_at
|
updated_at = row.updated_at
|
||||||
if isinstance(updated_at, datetime):
|
if isinstance(updated_at, datetime):
|
||||||
if updated_at.tzinfo is None:
|
if updated_at.tzinfo is None:
|
||||||
updated_at = updated_at.replace(tzinfo=BEIJING_TZ)
|
updated_at = updated_at.replace(tzinfo=BEIJING_TZ)
|
||||||
age = (datetime.now(BEIJING_TZ) - updated_at.astimezone(BEIJING_TZ)).total_seconds()
|
age = (datetime.now(BEIJING_TZ) - updated_at.astimezone(BEIJING_TZ)).total_seconds()
|
||||||
if age < running_ttl_seconds:
|
if age < running_ttl_seconds:
|
||||||
return False
|
return False
|
||||||
row.status = "running"
|
row.status = "running"
|
||||||
row.message = "排行榜后台统计任务已认领。"
|
row.message = "排行榜后台统计任务已认领。"
|
||||||
row.error = None
|
row.error = None
|
||||||
row.updated_at = datetime.now(UTC)
|
row.updated_at = datetime.now(UTC)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def _run_leaderboard_snapshot_tick() -> tuple[int, int]:
|
async def _run_leaderboard_snapshot_tick() -> tuple[int, int]:
|
||||||
from app.gateway.routers.admin_users import (
|
from app.gateway.routers.admin_users import (
|
||||||
_daily_job_key,
|
_daily_job_key,
|
||||||
_queue_daily_leaderboard_snapshots,
|
_queue_daily_leaderboard_snapshots,
|
||||||
_refresh_daily_leaderboard_snapshot,
|
_refresh_daily_leaderboard_snapshot,
|
||||||
_resolve_leaderboard_snapshot_scope,
|
_resolve_leaderboard_snapshot_scope,
|
||||||
)
|
)
|
||||||
from deerflow.config.system_settings import load_system_settings
|
from deerflow.config.system_settings import load_system_settings
|
||||||
from deerflow.persistence.engine import get_session_factory
|
from deerflow.persistence.engine import get_session_factory
|
||||||
|
|
||||||
session_factory = get_session_factory()
|
session_factory = get_session_factory()
|
||||||
if session_factory is None:
|
if session_factory is None:
|
||||||
return 0, 0
|
return 0, 0
|
||||||
|
|
||||||
settings = load_system_settings().leaderboard
|
settings = load_system_settings().leaderboard
|
||||||
excluded, include_scheduled, include_admins, include_failed, settings_hash = _resolve_leaderboard_snapshot_scope(settings)
|
excluded, include_scheduled, include_admins, include_failed, settings_hash = _resolve_leaderboard_snapshot_scope(settings)
|
||||||
stat_dates = _recent_leaderboard_stat_dates(_LEADERBOARD_SNAPSHOT_PREWARM_DAYS)
|
stat_dates = _recent_leaderboard_stat_dates(_LEADERBOARD_SNAPSHOT_PREWARM_DAYS)
|
||||||
queued_dates = await _queue_daily_leaderboard_snapshots(
|
queued_dates = await _queue_daily_leaderboard_snapshots(
|
||||||
session_factory,
|
session_factory,
|
||||||
stat_dates,
|
stat_dates,
|
||||||
settings_hash,
|
settings_hash,
|
||||||
message="排行榜后台统计任务定期预热/补算队列。",
|
message="排行榜后台统计任务定期预热/补算队列。",
|
||||||
)
|
)
|
||||||
|
|
||||||
due_dates = await _leaderboard_snapshot_due_dates(
|
due_dates = await _leaderboard_snapshot_due_dates(
|
||||||
session_factory,
|
session_factory,
|
||||||
settings_hash=settings_hash,
|
settings_hash=settings_hash,
|
||||||
limit=_LEADERBOARD_SNAPSHOT_BATCH_SIZE,
|
limit=_LEADERBOARD_SNAPSHOT_BATCH_SIZE,
|
||||||
running_ttl_seconds=_LEADERBOARD_SNAPSHOT_RUNNING_TTL_SECONDS,
|
running_ttl_seconds=_LEADERBOARD_SNAPSHOT_RUNNING_TTL_SECONDS,
|
||||||
)
|
)
|
||||||
processed = 0
|
processed = 0
|
||||||
for stat_date in due_dates:
|
for stat_date in due_dates:
|
||||||
claimed = await _claim_leaderboard_snapshot(
|
claimed = await _claim_leaderboard_snapshot(
|
||||||
session_factory,
|
session_factory,
|
||||||
stat_date=stat_date,
|
stat_date=stat_date,
|
||||||
settings_hash=settings_hash,
|
settings_hash=settings_hash,
|
||||||
running_ttl_seconds=_LEADERBOARD_SNAPSHOT_RUNNING_TTL_SECONDS,
|
running_ttl_seconds=_LEADERBOARD_SNAPSHOT_RUNNING_TTL_SECONDS,
|
||||||
)
|
)
|
||||||
if not claimed:
|
if not claimed:
|
||||||
continue
|
continue
|
||||||
logger.info("Leaderboard snapshot scheduler: building date=%s hash=%s", stat_date, settings_hash[:10])
|
logger.info("Leaderboard snapshot scheduler: building date=%s hash=%s", stat_date, settings_hash[:10])
|
||||||
await _refresh_daily_leaderboard_snapshot(
|
await _refresh_daily_leaderboard_snapshot(
|
||||||
_daily_job_key(stat_date, settings_hash),
|
_daily_job_key(stat_date, settings_hash),
|
||||||
session_factory=session_factory,
|
session_factory=session_factory,
|
||||||
settings=settings,
|
settings=settings,
|
||||||
stat_date=stat_date,
|
stat_date=stat_date,
|
||||||
settings_hash=settings_hash,
|
settings_hash=settings_hash,
|
||||||
excluded=excluded,
|
excluded=excluded,
|
||||||
include_scheduled=include_scheduled,
|
include_scheduled=include_scheduled,
|
||||||
include_admins=include_admins,
|
include_admins=include_admins,
|
||||||
include_failed=include_failed,
|
include_failed=include_failed,
|
||||||
)
|
)
|
||||||
processed += 1
|
processed += 1
|
||||||
if _LEADERBOARD_SNAPSHOT_SLEEP_SECONDS > 0 and processed < len(due_dates):
|
if _LEADERBOARD_SNAPSHOT_SLEEP_SECONDS > 0 and processed < len(due_dates):
|
||||||
await asyncio.sleep(_LEADERBOARD_SNAPSHOT_SLEEP_SECONDS)
|
await asyncio.sleep(_LEADERBOARD_SNAPSHOT_SLEEP_SECONDS)
|
||||||
return processed, len(queued_dates)
|
return processed, len(queued_dates)
|
||||||
|
|
||||||
|
|
||||||
async def _leaderboard_snapshot_scheduler_loop() -> None:
|
async def _leaderboard_snapshot_scheduler_loop() -> None:
|
||||||
"""Background task inside the existing gateway process for daily snapshots."""
|
"""Background task inside the existing gateway process for daily snapshots."""
|
||||||
if not _LEADERBOARD_SNAPSHOT_BACKGROUND_ENABLED:
|
if not _LEADERBOARD_SNAPSHOT_BACKGROUND_ENABLED:
|
||||||
logger.info("Leaderboard snapshot scheduler disabled by ADMIN_LEADERBOARD_BACKGROUND_ENABLED")
|
logger.info("Leaderboard snapshot scheduler disabled by ADMIN_LEADERBOARD_BACKGROUND_ENABLED")
|
||||||
return
|
return
|
||||||
|
|
||||||
from deerflow.config.runtime_paths import runtime_home
|
from deerflow.config.runtime_paths import runtime_home
|
||||||
|
|
||||||
lock_handle = _acquire_singleton_lock(runtime_home() / ".leaderboard_snapshot_scheduler.lock")
|
lock_handle = _acquire_singleton_lock(runtime_home() / ".leaderboard_snapshot_scheduler.lock")
|
||||||
if lock_handle is None:
|
if lock_handle is None:
|
||||||
logger.info("Leaderboard snapshot scheduler: another gateway worker holds the lock; this worker skips")
|
logger.info("Leaderboard snapshot scheduler: another gateway worker holds the lock; this worker skips")
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Leaderboard snapshot scheduler started: prewarm_days=%d batch_size=%d interval=%.1fs",
|
"Leaderboard snapshot scheduler started: prewarm_days=%d batch_size=%d interval=%.1fs",
|
||||||
_LEADERBOARD_SNAPSHOT_PREWARM_DAYS,
|
_LEADERBOARD_SNAPSHOT_PREWARM_DAYS,
|
||||||
_LEADERBOARD_SNAPSHOT_BATCH_SIZE,
|
_LEADERBOARD_SNAPSHOT_BATCH_SIZE,
|
||||||
_LEADERBOARD_SNAPSHOT_INTERVAL_SECONDS,
|
_LEADERBOARD_SNAPSHOT_INTERVAL_SECONDS,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
if _LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS > 0:
|
if _LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS > 0:
|
||||||
await asyncio.sleep(_LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS)
|
await asyncio.sleep(_LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS)
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
processed, queued = await _run_leaderboard_snapshot_tick()
|
processed, queued = await _run_leaderboard_snapshot_tick()
|
||||||
if processed or queued:
|
if processed or queued:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Leaderboard snapshot scheduler tick: queued=%d processed=%d",
|
"Leaderboard snapshot scheduler tick: queued=%d processed=%d",
|
||||||
queued,
|
queued,
|
||||||
processed,
|
processed,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Leaderboard snapshot scheduler tick failed (non-fatal)")
|
logger.exception("Leaderboard snapshot scheduler tick failed (non-fatal)")
|
||||||
await asyncio.sleep(_LEADERBOARD_SNAPSHOT_INTERVAL_SECONDS)
|
await asyncio.sleep(_LEADERBOARD_SNAPSHOT_INTERVAL_SECONDS)
|
||||||
finally:
|
finally:
|
||||||
_release_singleton_lock(lock_handle)
|
_release_singleton_lock(lock_handle)
|
||||||
|
|
||||||
|
|
||||||
def _warn_if_multi_worker() -> None:
|
def _warn_if_multi_worker() -> None:
|
||||||
"""检测 uvicorn / gunicorn 多 worker 部署并打 ERROR 日志。
|
"""检测 uvicorn / gunicorn 多 worker 部署并打 ERROR 日志。
|
||||||
|
|
||||||
主 agent / canvas / AI 写作流式响应在 Gateway 进程内通过 ``StreamBridge``
|
主 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)"
|
"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 表。
|
# 一次性把 .deer-flow/agents/{id}/ 目录里历史遗留的 agent 导入 agents 表。
|
||||||
# 路由层原本每次请求都跑一遍这个 sync(扫文件系统 + N 个 agent 各一次 DB
|
# 路由层原本每次请求都跑一遍这个 sync(扫文件系统 + N 个 agent 各一次 DB
|
||||||
# SELECT/UPDATE),把它挪到启动期 + 路由层只保留 TTL 兜底,显著降低
|
# SELECT/UPDATE),把它挪到启动期 + 路由层只保留 TTL 兜底,显著降低
|
||||||
@@ -799,21 +812,21 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
# 默认每天凌晨 3 点扫一次,删 retention_days(默认 7 天)以前的会话,联动
|
# 默认每天凌晨 3 点扫一次,删 retention_days(默认 7 天)以前的会话,联动
|
||||||
# 清理 MySQL 业务表 + SQLite checkpointer 里的 thread 状态。多 worker
|
# 清理 MySQL 业务表 + SQLite checkpointer 里的 thread 状态。多 worker
|
||||||
# 下用文件锁选 leader,只有抢到锁的 worker 真正跑清理。
|
# 下用文件锁选 leader,只有抢到锁的 worker 真正跑清理。
|
||||||
try:
|
try:
|
||||||
from app.gateway.ai_writing_cleanup import cleanup_scheduler_loop
|
from app.gateway.ai_writing_cleanup import cleanup_scheduler_loop
|
||||||
|
|
||||||
app.state.ai_writing_cleanup_task = asyncio.create_task(cleanup_scheduler_loop(app))
|
app.state.ai_writing_cleanup_task = asyncio.create_task(cleanup_scheduler_loop(app))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("AI writing cleanup scheduler failed to start (non-fatal)")
|
logger.exception("AI writing cleanup scheduler failed to start (non-fatal)")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
app.state.leaderboard_snapshot_scheduler_task = asyncio.create_task(_leaderboard_snapshot_scheduler_loop())
|
app.state.leaderboard_snapshot_scheduler_task = asyncio.create_task(_leaderboard_snapshot_scheduler_loop())
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Leaderboard snapshot scheduler failed to start (non-fatal)")
|
logger.exception("Leaderboard snapshot scheduler failed to start (non-fatal)")
|
||||||
|
|
||||||
# Start IM channel service if any channels are configured
|
# Start IM channel service if any channels are configured
|
||||||
try:
|
try:
|
||||||
from app.channels.service import start_channel_service
|
from app.channels.service import start_channel_service
|
||||||
|
|
||||||
channel_service = await start_channel_service(app.state.config)
|
channel_service = await start_channel_service(app.state.config)
|
||||||
logger.info("Channel service started: %s", channel_service.get_status())
|
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:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("AI writing cleanup scheduler shutdown error")
|
logger.exception("AI writing cleanup scheduler shutdown error")
|
||||||
|
|
||||||
leaderboard_snapshot_task = getattr(app.state, "leaderboard_snapshot_scheduler_task", None)
|
leaderboard_snapshot_task = getattr(app.state, "leaderboard_snapshot_scheduler_task", None)
|
||||||
if leaderboard_snapshot_task is not None:
|
if leaderboard_snapshot_task is not None:
|
||||||
leaderboard_snapshot_task.cancel()
|
leaderboard_snapshot_task.cancel()
|
||||||
try:
|
try:
|
||||||
await leaderboard_snapshot_task
|
await leaderboard_snapshot_task
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Leaderboard snapshot scheduler shutdown error")
|
logger.exception("Leaderboard snapshot scheduler shutdown error")
|
||||||
|
|
||||||
# 阶段 7 删冗余:AI 写作 graph 不再自管 checkpointer / in-flight task;
|
# 阶段 7 删冗余:AI 写作 graph 不再自管 checkpointer / in-flight task;
|
||||||
# thread 状态由 LangGraph Server 主 checkpointer 接管,lifespan 出口由
|
# 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
|
# Roundtable-planning business chains at /api/roundtable-chains
|
||||||
app.include_router(roundtable_chains.router)
|
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
|
# Roundtable-planning background jobs at /api/roundtable-jobs
|
||||||
app.include_router(roundtable_jobs.router)
|
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)
|
app.include_router(tool_metrics.router)
|
||||||
|
|
||||||
# Channels API is mounted at /api/channels
|
# Channels API is mounted at /api/channels
|
||||||
app.include_router(channels.router)
|
app.include_router(channels.router)
|
||||||
|
|
||||||
# Admin checkpoint migration API: SQLite legacy checkpoints -> Postgres.
|
# Admin checkpoint migration API: SQLite legacy checkpoints -> Postgres.
|
||||||
app.include_router(checkpoint_migration.router)
|
app.include_router(checkpoint_migration.router)
|
||||||
|
|
||||||
# Assistants compatibility API (LangGraph Platform stub)
|
# Assistants compatibility API (LangGraph Platform stub)
|
||||||
app.include_router(assistants_compat.router)
|
app.include_router(assistants_compat.router)
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ _PUBLIC_EXACT_PATHS: frozenset[str] = frozenset(
|
|||||||
"/api/v1/auth/logout",
|
"/api/v1/auth/logout",
|
||||||
"/api/v1/auth/setup-status",
|
"/api/v1/auth/setup-status",
|
||||||
"/api/v1/auth/initialize",
|
"/api/v1/auth/initialize",
|
||||||
|
# 并行多智能体面板专用的「绕过常规登录」取 token 端点(内网部署限制了标准登录时用)。
|
||||||
|
"/api/parallel-agents/auth/token",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ _AUTH_EXEMPT_PATHS: frozenset[str] = frozenset(
|
|||||||
"/api/v1/auth/logout",
|
"/api/v1/auth/logout",
|
||||||
"/api/v1/auth/register",
|
"/api/v1/auth/register",
|
||||||
"/api/v1/auth/initialize",
|
"/api/v1/auth/initialize",
|
||||||
|
# 并行多智能体面板专用取 token 端点(公共 + CSRF 豁免)。
|
||||||
|
"/api/parallel-agents/auth/token",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -179,6 +179,14 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
|
|
||||||
app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
|
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 (
|
from deerflow.persistence.roundtable_jobs import (
|
||||||
make_roundtable_job_store,
|
make_roundtable_job_store,
|
||||||
register_default_store as _register_roundtable_job_default_store,
|
register_default_store as _register_roundtable_job_default_store,
|
||||||
|
|||||||
@@ -19,24 +19,30 @@
|
|||||||
并配合 ``SkillStopMiddleware`` 在 ``agent_orchestration`` 调用后停图,便于网关从
|
并配合 ``SkillStopMiddleware`` 在 ``agent_orchestration`` 调用后停图,便于网关从
|
||||||
AIMessage.tool_calls 解析派活列表。
|
AIMessage.tool_calls 解析派活列表。
|
||||||
- ``seat`` —— 子智能体交付:开 thinking + medium reasoning;**可写文件**
|
- ``seat`` —— 子智能体交付:开 thinking + medium reasoning;**可写文件**
|
||||||
(``write_file`` / ``str_replace`` 产出 md 等成品文档),但禁派活 / 澄清 /
|
(``write_file`` / ``str_replace`` 产出 md 等成品文档)+ **可跑 ``bash``**
|
||||||
展示文件 / 看图 / 联网搜索 / **``bash``**。禁 ``bash`` 的原因:圆桌席位是
|
(2026-06 放开:业务需要跑知识库检索 / Python 辅助分析)。禁派活 / 澄清 /
|
||||||
「文档产出者」,弱模型爱写 ``.py`` 脚本再跑出产物(乱且抓不到预览),与
|
展示文件 / 看图 / 联网搜索。``report`` 通过 ``[*_SEAT_EXCLUDED, ...]`` 继承本角色,
|
||||||
``report`` 角色禁 ``bash`` 同理;让它**直接 write_file 写成品文档**。
|
所以 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)**:**禁写文件**
|
- ``seat_parallel`` —— **DAG 快速(fast)模式的并行席位(方案 A,2026-06)**:**禁写文件**
|
||||||
(``write_file`` / ``str_replace``,产物走正文)+ 禁 ``bash`` + 禁「打断 / 越权」类,
|
(``write_file`` / ``str_replace``,产物走正文)+ **禁 ``bash``** + 禁「打断 / 越权」类,
|
||||||
**放开 ``web_search`` 与技能**(搜索不碰 sandbox;技能靠 ``read_file`` SKILL.md)——
|
**放开 ``web_search`` 与技能**(搜索不碰 sandbox;技能靠 ``read_file`` SKILL.md)——
|
||||||
否则并行席位几乎调不动工具、产生不了步骤(步骤条不显示)。
|
否则并行席位几乎调不动工具、产生不了步骤(步骤条不显示)。
|
||||||
|
并行席位同时跑 bash 会在共享 sandbox 里抢路径 / 占进程数;且 fast 模式整体不
|
||||||
|
写文件,bash 跑出来的产物既不能预览也没意义。
|
||||||
「写文件产出 md」归 **沙箱(sandbox)模式**(走 ``seat`` policy,单列 + 沙箱跟随预览);
|
「写文件产出 md」归 **沙箱(sandbox)模式**(走 ``seat`` policy,单列 + 沙箱跟随预览);
|
||||||
fast 模式快、双列、产物走正文 —— 用户明确要求 fast 不生成文件。详见
|
fast 模式快、双列、产物走正文 —— 用户明确要求 fast 不生成文件。详见
|
||||||
``docs/roundtable-dag-orchestration.md`` §4 / §12.1 / §12.8 / §12.10。
|
``docs/roundtable-dag-orchestration.md`` §4 / §12.1 / §12.8 / §12.10。
|
||||||
与 ``seat`` 的差异:``seat`` 禁 ``web_search``(旧「圆桌一律禁搜索」),
|
与 ``seat`` 的差异:``seat`` 禁 ``web_search``(旧「圆桌一律禁搜索」),
|
||||||
``seat_parallel`` **放开搜索**(本期诉求)。
|
``seat_parallel`` **放开搜索**(本期诉求);``seat`` 已放开 ``bash``,本角色仍禁。
|
||||||
- ``report`` —— Step3 报告绘制:在 ``seat``(已禁 ``bash``)基础上**额外禁
|
- ``report`` —— Step3 报告绘制:在 ``seat`` 基础上**额外禁 ``str_replace``**:
|
||||||
``str_replace``**:
|
* 继承 ``seat`` 的 bash 放开(2026-06)—— Step3 也可跑 bash 做辅助查询,同
|
||||||
* ``bash`` 已在 ``seat`` 禁 → 杜绝写 Python 脚本再跑出 HTML(脚本产物落在
|
``seat`` 警示:**结果必须 write_file 落盘**,否则前端虚拟预览会空白
|
||||||
outputs,但前端虚拟预览只抓 ``write_file`` 的 ``args.content``,抓不到脚本
|
(预览只抓 ``write_file.args.content``,抓不到脚本产物 → 预览停在半截
|
||||||
产物 → 预览停在半截 ``write_file``,正是「没写完就去生成 py 文件」的现象);
|
``write_file``,正是「没写完就去生成 py 文件」的现象)。
|
||||||
* 禁 ``str_replace`` → 强制返工用整文件 ``write_file`` 重写
|
* 禁 ``str_replace`` → 强制返工用整文件 ``write_file`` 重写
|
||||||
(``str_replace`` 没有 ``content`` 字段,会让前端虚拟预览读到 undefined
|
(``str_replace`` 没有 ``content`` 字段,会让前端虚拟预览读到 undefined
|
||||||
而清空沙箱)。
|
而清空沙箱)。
|
||||||
@@ -72,16 +78,21 @@ _LEADER_EXCLUDED: list[str] = [
|
|||||||
"hindsight_retain",
|
"hindsight_retain",
|
||||||
"read_peer_delivery", # 总控靠 _collect_delivery_status 直读各席位,不需要这个席位侧工具
|
"read_peer_delivery", # 总控靠 _collect_delivery_status 直读各席位,不需要这个席位侧工具
|
||||||
]
|
]
|
||||||
# seat:席位**可以写文件**(write_file / str_replace)产出 md 等成品文档,但**禁 bash** ——
|
# seat:席位**可以写文件**(write_file / str_replace)产出 md 等成品文档,**bash 放开**
|
||||||
# 圆桌席位是「文档产出者」,不该跑 shell/python 脚本(弱模型爱写 .py 脚本再跑生成产物,既乱又
|
# (2026-06 决策,业务需要 bash 跑知识库检索 / Python 辅助分析)。禁派活 / 澄清 /
|
||||||
# 抓不到产物预览,与 report 角色禁 bash 同理)。禁 web_search(圆桌串行一律禁搜索,旧决策)。
|
# 展示文件 / 看图 / 联网搜索。``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] = [
|
_SEAT_EXCLUDED: list[str] = [
|
||||||
"web_search",
|
"web_search",
|
||||||
"ask_clarification",
|
"ask_clarification",
|
||||||
"present_files",
|
"present_files",
|
||||||
"view_image",
|
"view_image",
|
||||||
"agent_orchestration",
|
"agent_orchestration",
|
||||||
"bash",
|
|
||||||
]
|
]
|
||||||
# 并行席位 = **快速(fast)模式**(双列):**禁写文件**(产物走正文)+ 禁跑命令 +「打断/越权」类。
|
# 并行席位 = **快速(fast)模式**(双列):**禁写文件**(产物走正文)+ 禁跑命令 +「打断/越权」类。
|
||||||
# **禁 web_search** —— 离线内网部署无外网,席位检索一律走「技能(skill)」(靠 read_file 读
|
# **禁 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
|
# Optional layered orchestration: list of stages, each a list of agent_ids
|
||||||
# run in parallel (stages run serially). None/absent = linear chain.
|
# run in parallel (stages run serially). None/absent = linear chain.
|
||||||
stages: list[list[str]] | None = None
|
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 = 公共(所有账号可在「公共」筛选查看 / 选用)。
|
# 发布状态:True = 公共(所有账号可在「公共」筛选查看 / 选用)。
|
||||||
is_public: bool = False
|
is_public: bool = False
|
||||||
# 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。
|
# 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。
|
||||||
@@ -69,6 +73,8 @@ class ChainCreateRequest(BaseModel):
|
|||||||
description: str | None = Field(default=None, max_length=2048)
|
description: str | None = Field(default=None, max_length=2048)
|
||||||
seats: list[ChainSeatSchema] = []
|
seats: list[ChainSeatSchema] = []
|
||||||
stages: list[list[str]] | None = None
|
stages: list[list[str]] | None = None
|
||||||
|
stage_goals: list[str | None] | None = None
|
||||||
|
coordinator_prompt: str | None = Field(default=None, max_length=4000)
|
||||||
|
|
||||||
|
|
||||||
class ChainUpdateRequest(BaseModel):
|
class ChainUpdateRequest(BaseModel):
|
||||||
@@ -77,6 +83,8 @@ class ChainUpdateRequest(BaseModel):
|
|||||||
description: str | None = Field(default=None, max_length=2048)
|
description: str | None = Field(default=None, max_length=2048)
|
||||||
seats: list[ChainSeatSchema] | None = None
|
seats: list[ChainSeatSchema] | None = None
|
||||||
stages: list[list[str]] | None = None
|
stages: list[list[str]] | None = None
|
||||||
|
stage_goals: list[str | None] | None = None
|
||||||
|
coordinator_prompt: str | None = Field(default=None, max_length=4000)
|
||||||
# 发布开关(True 发布为公共 / False 取消发布);仅 owner 可改(update 本就 owner 限定)。
|
# 发布开关(True 发布为公共 / False 取消发布);仅 owner 可改(update 本就 owner 限定)。
|
||||||
is_public: bool | None = None
|
is_public: bool | None = None
|
||||||
|
|
||||||
@@ -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 ───────────────────────────────────────────────────────────────
|
# ── routes ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -174,6 +214,7 @@ async def list_chains(request: Request, scope: str = "mine") -> ChainListRespons
|
|||||||
async def create_chain(request: Request, body: ChainCreateRequest) -> ChainResponse:
|
async def create_chain(request: Request, body: ChainCreateRequest) -> ChainResponse:
|
||||||
_validate_seats(body.seats)
|
_validate_seats(body.seats)
|
||||||
_validate_stages(body.seats, body.stages)
|
_validate_stages(body.seats, body.stages)
|
||||||
|
_validate_stage_goals(body.stages, body.stage_goals)
|
||||||
store = _get_store(request)
|
store = _get_store(request)
|
||||||
user_id = _current_user_id(request)
|
user_id = _current_user_id(request)
|
||||||
row = await store.create_chain(
|
row = await store.create_chain(
|
||||||
@@ -184,6 +225,8 @@ async def create_chain(request: Request, body: ChainCreateRequest) -> ChainRespo
|
|||||||
"description": body.description,
|
"description": body.description,
|
||||||
"seats": [s.model_dump() for s in body.seats],
|
"seats": [s.model_dump() for s in body.seats],
|
||||||
"stages": body.stages,
|
"stages": body.stages,
|
||||||
|
"stage_goals": body.stage_goals,
|
||||||
|
"coordinator_prompt": body.coordinator_prompt,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return ChainResponse(**row)
|
return ChainResponse(**row)
|
||||||
@@ -209,6 +252,8 @@ async def update_chain(request: Request, chain_id: str, body: ChainUpdateRequest
|
|||||||
if body.seats is None:
|
if body.seats is None:
|
||||||
raise HTTPException(status_code=400, detail="更新编排分层时必须同时提交席位列表")
|
raise HTTPException(status_code=400, detail="更新编排分层时必须同时提交席位列表")
|
||||||
_validate_stages(body.seats, body.stages)
|
_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)
|
store = _get_store(request)
|
||||||
user_id = _current_user_id(request)
|
user_id = _current_user_id(request)
|
||||||
# Distinguish "omitted" from "explicitly sent"; only forward provided fields.
|
# Distinguish "omitted" from "explicitly sent"; only forward provided fields.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ models:
|
|||||||
display_name: DeepSeek Chat
|
display_name: DeepSeek Chat
|
||||||
use: langchain_openai:ChatOpenAI
|
use: langchain_openai:ChatOpenAI
|
||||||
model: deepseek-chat
|
model: deepseek-chat
|
||||||
api_key: sk-e1c22b7931dc446f8b67e57878edd105
|
api_key: sk-3fee562f834a4c08bb0d471ef44cb445
|
||||||
base_url: https://api.deepseek.com
|
base_url: https://api.deepseek.com
|
||||||
request_timeout: 600.0
|
request_timeout: 600.0
|
||||||
max_retries: 2
|
max_retries: 2
|
||||||
@@ -27,7 +27,7 @@ models:
|
|||||||
display_name: DeepSeek V4 Flash
|
display_name: DeepSeek V4 Flash
|
||||||
use: langchain_openai:ChatOpenAI
|
use: langchain_openai:ChatOpenAI
|
||||||
model: deepseek-v4-flash
|
model: deepseek-v4-flash
|
||||||
api_key: sk-e1c22b7931dc446f8b67e57878edd105
|
api_key: sk-3fee562f834a4c08bb0d471ef44cb445
|
||||||
base_url: https://api.deepseek.com
|
base_url: https://api.deepseek.com
|
||||||
request_timeout: 600.0
|
request_timeout: 600.0
|
||||||
max_retries: 2
|
max_retries: 2
|
||||||
|
|||||||
+18
@@ -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)
|
||||||
+53
@@ -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),
|
||||||
|
)
|
||||||
+120
@@ -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
|
||||||
+56
@@ -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")
|
||||||
+61
@@ -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")
|
||||||
+56
@@ -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")
|
||||||
+4
@@ -22,11 +22,13 @@ from deerflow.persistence.agents.model import (
|
|||||||
AgentSkillRow,
|
AgentSkillRow,
|
||||||
)
|
)
|
||||||
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
|
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.memory_config.model import UserMemoryConfigRow
|
||||||
from deerflow.persistence.feedback.model import FeedbackRow
|
from deerflow.persistence.feedback.model import FeedbackRow
|
||||||
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
|
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
|
||||||
from deerflow.persistence.models.run_event import RunEventRow
|
from deerflow.persistence.models.run_event import RunEventRow
|
||||||
from deerflow.persistence.notifications.model import NotificationRow
|
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.recommended_questions.model import RecommendedQuestionRow
|
||||||
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow
|
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow
|
||||||
from deerflow.persistence.roundtable_draft_shares.model import RoundtableDraftShareRow
|
from deerflow.persistence.roundtable_draft_shares.model import RoundtableDraftShareRow
|
||||||
@@ -60,8 +62,10 @@ __all__ = [
|
|||||||
"AgentRow",
|
"AgentRow",
|
||||||
"AgentSkillRow",
|
"AgentSkillRow",
|
||||||
"AdminLeaderboardDailyStatRow",
|
"AdminLeaderboardDailyStatRow",
|
||||||
|
"BusinessMappingRow",
|
||||||
"LlmCallMetricRow",
|
"LlmCallMetricRow",
|
||||||
"NotificationRow",
|
"NotificationRow",
|
||||||
|
"ParallelAgentTaskRow",
|
||||||
"RecommendedQuestionRow",
|
"RecommendedQuestionRow",
|
||||||
"RoundtableChainRow",
|
"RoundtableChainRow",
|
||||||
"RoundtableDraftRow",
|
"RoundtableDraftRow",
|
||||||
|
|||||||
+18
@@ -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)
|
||||||
+46
@@ -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),
|
||||||
|
)
|
||||||
+131
@@ -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
|
||||||
+10
@@ -39,6 +39,16 @@ class RoundtableChainRow(Base):
|
|||||||
# (each seat its own stage). Drives Step 2 dag-mode orchestration; see
|
# (each seat its own stage). Drives Step 2 dag-mode orchestration; see
|
||||||
# frontend ``lib/dag-types.ts``. Stored as LONGTEXT-backed JSON string.
|
# frontend ``lib/dag-types.ts``. Stored as LONGTEXT-backed JSON string.
|
||||||
stages: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
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 = 仅本人可见。
|
# 发布开关:True = 公共(所有账号可在「公共」筛选里查看 / 选用);False = 仅本人可见。
|
||||||
# 编辑 / 删除始终只限 owner;发布只是放开「查看 + 选入研讨」。索引以支持「公共」列表筛选。
|
# 编辑 / 删除始终只限 owner;发布只是放开「查看 + 选入研讨」。索引以支持「公共」列表筛选。
|
||||||
is_public: Mapped[bool] = mapped_column(
|
is_public: Mapped[bool] = mapped_column(
|
||||||
|
|||||||
+12
@@ -52,6 +52,10 @@ class RoundtableChainRepository:
|
|||||||
"seats": _loads(row.seats) or [],
|
"seats": _loads(row.seats) or [],
|
||||||
# None when absent → frontend reads it as "linear chain" (no layering).
|
# None when absent → frontend reads it as "linear chain" (no layering).
|
||||||
"stages": _loads(row.stages),
|
"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_public": bool(getattr(row, "is_public", False)),
|
||||||
"is_mine": (current_user_id is None) or (row.user_id == current_user_id),
|
"is_mine": (current_user_id is None) or (row.user_id == current_user_id),
|
||||||
@@ -102,6 +106,8 @@ class RoundtableChainRepository:
|
|||||||
description=(data.get("description") or None),
|
description=(data.get("description") or None),
|
||||||
seats=_dumps(data.get("seats") or []),
|
seats=_dumps(data.get("seats") or []),
|
||||||
stages=_dumps(data.get("stages")),
|
stages=_dumps(data.get("stages")),
|
||||||
|
stage_goals=_dumps(data.get("stage_goals")),
|
||||||
|
coordinator_prompt=(data.get("coordinator_prompt") or None),
|
||||||
is_public=bool(data.get("is_public") or False),
|
is_public=bool(data.get("is_public") or False),
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
@@ -121,6 +127,8 @@ class RoundtableChainRepository:
|
|||||||
description=_UNSET,
|
description=_UNSET,
|
||||||
seats=_UNSET,
|
seats=_UNSET,
|
||||||
stages=_UNSET,
|
stages=_UNSET,
|
||||||
|
stage_goals=_UNSET,
|
||||||
|
coordinator_prompt=_UNSET,
|
||||||
is_public=_UNSET,
|
is_public=_UNSET,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
@@ -135,6 +143,10 @@ class RoundtableChainRepository:
|
|||||||
row.seats = _dumps(seats)
|
row.seats = _dumps(seats)
|
||||||
if stages is not _UNSET:
|
if stages is not _UNSET:
|
||||||
row.stages = _dumps(stages)
|
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:
|
if is_public is not _UNSET and is_public is not None:
|
||||||
row.is_public = bool(is_public)
|
row.is_public = bool(is_public)
|
||||||
row.updated_at = datetime.now(UTC)
|
row.updated_at = datetime.now(UTC)
|
||||||
|
|||||||
@@ -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*` 这些系统角色——它们不是用来当并行席位的)。
|
||||||
@@ -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 与自查方法。
|
||||||
@@ -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`。
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1389
File diff suppressed because it is too large.
Load diff
@@ -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, 方案设计 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>
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module "*.vue" {
|
||||||
|
import type { DefineComponent } from "vue";
|
||||||
|
const component: DefineComponent<{}, {}, any>;
|
||||||
|
export default component;
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
Reference in new issue
Block a user