代码提交
This commit is contained in:
1 parent
8859c13cfb
commit
673adef7df
14 files changed
+4262
-1
No files matched your search
@@ -0,0 +1,247 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Bot } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useAgents } from "@/core/agents";
|
||||
import type { Agent } from "@/core/agents";
|
||||
import { themeToBaseMode, useTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
import "@/styles/agents-orbit.css";
|
||||
import "@/styles/agents-orbit-dark.css";
|
||||
import "@/styles/agents-orbit-light.css";
|
||||
|
||||
/** 转盘旋转过渡时长(ms),与 CSS 中 .ao-type-arm 的 transition 保持一致。 */
|
||||
const DIAL_TRANSITION_MS = 850;
|
||||
|
||||
/** 一个类型 = 一个标签(取智能体的第一个标签;无标签归入「未分类」)。 */
|
||||
interface AgentGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
agents: Agent[];
|
||||
}
|
||||
|
||||
function groupAgentsByType(agents: Agent[]): AgentGroup[] {
|
||||
const map = new Map<string, AgentGroup>();
|
||||
for (const agent of agents) {
|
||||
const tag = agent.tags?.[0];
|
||||
const id = tag?.id ?? "__uncategorized__";
|
||||
let group = map.get(id);
|
||||
if (!group) {
|
||||
group = { id, name: tag?.name ?? "未分类", agents: [] };
|
||||
map.set(id, group);
|
||||
}
|
||||
group.agents.push(agent);
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) =>
|
||||
a.name.localeCompare(b.name, "zh-Hans-CN"),
|
||||
);
|
||||
}
|
||||
|
||||
const mod = (value: number, n: number): number => ((value % n) + n) % n;
|
||||
|
||||
/**
|
||||
* 密码转盘模型:
|
||||
* - n 个类型始终均匀占据右半圆的 n 个固定槽位(间距恒定,不随点击改变);
|
||||
* - 激活槽位固定为最贴近正右侧中间的槽,被点击的类型旋转到该槽;
|
||||
* - 点击上半部分 → 转盘顺时针转(k > 0),下半部分 → 逆时针(k < 0);
|
||||
* - 超出半圆端点的标签沿“背面长路”补位(角度 ±360 扫过左半圆,途中半透明),
|
||||
* 动画结束后角度归一化回槽位角(同一位置 mod 360,无视觉跳变)。
|
||||
*/
|
||||
export default function AgentsOrbitPage() {
|
||||
const router = useRouter();
|
||||
const { theme } = useTheme();
|
||||
const isDark = themeToBaseMode(theme) === "dark";
|
||||
const { agents, isLoading } = useAgents();
|
||||
const groups = useMemo(() => groupAgentsByType(agents), [agents]);
|
||||
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
/** 转盘动画期间的非归一化角度(含 ±360 长路,长路全程在左半圆不可视区内)。 */
|
||||
const [anim, setAnim] = useState<Record<string, number> | null>(null);
|
||||
/** 归一化帧:瞬移到等价角度时禁用过渡。 */
|
||||
const [frozen, setFrozen] = useState(false);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current != null) window.clearTimeout(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`agents-orbit-page ${isDark ? "ao-dark" : "ao-light"}`}>
|
||||
<div className="ao-state">
|
||||
<div className="ao-spinner" />
|
||||
<span>正在加载智能体…</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<div className={`agents-orbit-page ${isDark ? "ao-dark" : "ao-light"}`}>
|
||||
<div className="ao-state">
|
||||
<span>还没有可展示的智能体</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const n = groups.length;
|
||||
const midSlot = Math.floor((n - 1) / 2);
|
||||
const step = 180 / n;
|
||||
/** 槽位 j 的世界角:-90°(正上) → +90°(正下),均布在右半圆开区间内。 */
|
||||
const slotAngle = (slot: number) => -90 + (slot + 0.5) * step;
|
||||
|
||||
const requestedIdx = activeId
|
||||
? groups.findIndex((g) => g.id === activeId)
|
||||
: -1;
|
||||
const activeIdx = requestedIdx >= 0 ? requestedIdx : midSlot;
|
||||
const active = groups[activeIdx];
|
||||
/** 静态分组数组的旋转量:group(gi) 落在槽位 (gi + shift) mod n,激活组恰在 midSlot。 */
|
||||
const shift = midSlot - activeIdx;
|
||||
const groupAtSlot = (slot: number) => groups[mod(slot - shift, n)];
|
||||
|
||||
const handleTypeClick = (slot: number) => {
|
||||
const group = groupAtSlot(slot);
|
||||
if (group.id === active.id) return;
|
||||
|
||||
const k = midSlot - slot; // 正=顺时针向下转,负=逆时针向上转
|
||||
const angles: Record<string, number> = {};
|
||||
for (let j = 0; j < n; j++) {
|
||||
const g = groupAtSlot(j);
|
||||
const nextSlot = j + k;
|
||||
let angle = slotAngle(mod(nextSlot, n));
|
||||
if (nextSlot >= n) {
|
||||
angle += 360; // 从底部滑出(进入左半圆不可视区),绕背回顶部补位
|
||||
} else if (nextSlot < 0) {
|
||||
angle -= 360; // 从顶部滑出(进入左半圆不可视区),绕背回底部补位
|
||||
}
|
||||
angles[g.id] = angle;
|
||||
}
|
||||
|
||||
setActiveId(group.id);
|
||||
setAnim(angles);
|
||||
if (timerRef.current != null) window.clearTimeout(timerRef.current);
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
// 动画结束:归一化长路角度(同一位置 mod 360),下一帧恢复过渡。
|
||||
setFrozen(true);
|
||||
setAnim(null);
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() => setFrozen(false)),
|
||||
);
|
||||
}, DIAL_TRANSITION_MS + 60);
|
||||
};
|
||||
|
||||
const handleAgentClick = (agent: Agent) => {
|
||||
router.push(`/page/workspace/agents/${agent.id}/chats/new`);
|
||||
};
|
||||
|
||||
const themeClass = isDark ? "ao-dark" : "ao-light";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`agents-orbit-page ${themeClass}${n >= 8 ? " ao-crowded" : ""}`}
|
||||
>
|
||||
<div className="ao-header">
|
||||
<span className="ao-header-title">智能体 · 星轨转盘</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ao-header-link"
|
||||
onClick={() => router.push("/page/workspace/agents")}
|
||||
>
|
||||
经典视图
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 左侧星轨舞台:中心圆盘 + 中心图片 + 三层圆环 + 卫星 + 类型转盘 */}
|
||||
<div className="ao-stage">
|
||||
<div className="ao-root">
|
||||
<div className="ao-ring ao-ring--ticks" />
|
||||
<div className="ao-ring ao-ring--dash" />
|
||||
<div className="ao-ring ao-ring--arc" />
|
||||
<div className="ao-sat-orbit">
|
||||
<div className="ao-sat" />
|
||||
</div>
|
||||
|
||||
<div className="ao-core">
|
||||
<div className="ao-core-img">
|
||||
<Bot size={60} strokeWidth={1.4} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 转盘容器:左半圆被裁剪为不可视区域,绕背旋转不可见 */}
|
||||
<div className="ao-types">
|
||||
{groups.map((group, gi) => {
|
||||
const slot = mod(gi + shift, n);
|
||||
const angle = anim
|
||||
? (anim[group.id] ?? slotAngle(slot))
|
||||
: slotAngle(slot);
|
||||
const isActive = group.id === active.id;
|
||||
const transitionStyle = frozen ? { transition: "none" } : {};
|
||||
return (
|
||||
<div
|
||||
key={group.id}
|
||||
className="ao-type-arm"
|
||||
style={{
|
||||
transform: `rotate(${angle}deg)`,
|
||||
...transitionStyle,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`ao-type-anchor${isActive ? " is-active" : ""}`}
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) rotate(${-angle}deg)`,
|
||||
...transitionStyle,
|
||||
}}
|
||||
onClick={() => handleTypeClick(slot)}
|
||||
title={`${group.name} · ${group.agents.length} 个智能体`}
|
||||
>
|
||||
<span
|
||||
className="ao-type-chip"
|
||||
style={{ animationDelay: `${-gi * 0.8}s` }}
|
||||
>
|
||||
{group.name}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧面板:当前类型下的智能体(圆角正方形图标 + 名称),与舞台 1:1 */}
|
||||
<aside className="ao-panel">
|
||||
<div className="ao-panel-head">
|
||||
<div className="ao-panel-title">{active.name}</div>
|
||||
<div className="ao-panel-sub">共 {active.agents.length} 个智能体</div>
|
||||
</div>
|
||||
{active.agents.length > 0 ? (
|
||||
<div className="ao-agents" key={active.id}>
|
||||
{active.agents.map((agent) => (
|
||||
<button
|
||||
key={agent.id}
|
||||
type="button"
|
||||
className="ao-agent"
|
||||
onClick={() => handleAgentClick(agent)}
|
||||
title={agent.description || agent.name}
|
||||
>
|
||||
<span className="ao-agent-icon">
|
||||
{(agent.name || "?").trim().charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<span className="ao-agent-name">{agent.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="ao-state">
|
||||
<span>该类型下暂时没有智能体</span>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Clock3,
|
||||
LoaderCircle,
|
||||
MessageSquareText,
|
||||
Network,
|
||||
PanelLeftClose,
|
||||
Pause,
|
||||
Play,
|
||||
@@ -42,9 +43,17 @@ import {
|
||||
OFFICIAL_DEERFLOW_ROUTE_PREFIX,
|
||||
} from "@/core/official-deerflow/routes";
|
||||
|
||||
import MultiAgentTasksPage from "./official-multi-agent-tasks/MultiAgentTasksPage";
|
||||
|
||||
import "./official-deerflow-entry.css";
|
||||
|
||||
type OfficialPage = "chat" | "agents" | "agent-chat" | "scheduled" | "settings";
|
||||
type OfficialPage =
|
||||
| "chat"
|
||||
| "agents"
|
||||
| "agent-chat"
|
||||
| "scheduled"
|
||||
| "multi-agent-tasks"
|
||||
| "settings";
|
||||
|
||||
function pageFromPath(pathname: string): OfficialPage {
|
||||
const path = pathname.slice(OFFICIAL_DEERFLOW_ROUTE_PREFIX.length);
|
||||
@@ -53,6 +62,7 @@ function pageFromPath(pathname: string): OfficialPage {
|
||||
}
|
||||
if (path.startsWith("/workspace/agents")) return "agents";
|
||||
if (path.startsWith("/workspace/scheduled-tasks")) return "scheduled";
|
||||
if (path.startsWith("/workspace/multi-agent-tasks")) return "multi-agent-tasks";
|
||||
if (path.startsWith("/workspace/settings")) return "settings";
|
||||
return "chat";
|
||||
}
|
||||
@@ -78,6 +88,11 @@ function parseScheduledTaskId(pathname: string) {
|
||||
return match ? decodeURIComponent(match[1]!) : null;
|
||||
}
|
||||
|
||||
function parseMultiAgentTaskId(pathname: string) {
|
||||
const match = pathname.match(/\/workspace\/multi-agent-tasks\/([^/]+)/);
|
||||
return match ? decodeURIComponent(match[1]!) : null;
|
||||
}
|
||||
|
||||
function readableError(error: unknown, fallback: string) {
|
||||
return error instanceof Error && error.message ? error.message : fallback;
|
||||
}
|
||||
@@ -658,6 +673,10 @@ export default function OfficialDeerFlowEntry() {
|
||||
() => parseScheduledTaskId(location.pathname),
|
||||
[location.pathname],
|
||||
);
|
||||
const multiAgentTaskId = useMemo(
|
||||
() => parseMultiAgentTaskId(location.pathname),
|
||||
[location.pathname],
|
||||
);
|
||||
const { data: threads = [], isLoading: threadsLoading, error: threadsError } =
|
||||
useThreads({
|
||||
limit: 20,
|
||||
@@ -672,6 +691,7 @@ export default function OfficialDeerFlowEntry() {
|
||||
{ icon: MessageSquareText, label: "对话", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/new`, pages: ["chat"] },
|
||||
{ icon: Bot, label: "智能体", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents`, pages: ["agents", "agent-chat"] },
|
||||
{ icon: CalendarClock, label: "定时任务", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/scheduled-tasks`, pages: ["scheduled"] },
|
||||
{ icon: Network, label: "多智能体定时任务", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/multi-agent-tasks`, pages: ["multi-agent-tasks"] },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
@@ -769,6 +789,9 @@ export default function OfficialDeerFlowEntry() {
|
||||
/>
|
||||
)}
|
||||
{page === "scheduled" && <ScheduledTasksPage selectedTaskId={scheduledTaskId} />}
|
||||
{page === "multi-agent-tasks" && (
|
||||
<MultiAgentTasksPage routeTaskId={multiAgentTaskId} />
|
||||
)}
|
||||
{page === "settings" && <SettingsPage />}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -39,6 +39,7 @@ import ChatPage from "./ChatPage";
|
||||
import ArtifactLibraryPage from "./ArtifactLibraryPage";
|
||||
import IframeChatPage from "./IframeChatPage";
|
||||
import AgentsPage from "./AgentsPage";
|
||||
import AgentsOrbitPage from "./AgentsOrbitPage";
|
||||
import AgentChatPage from "./AgentChatPage";
|
||||
import SentimentAgentChatPage from "./SentimentAgentChatPage";
|
||||
import NewAgentPage from "./NewAgentPage";
|
||||
@@ -620,6 +621,16 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
{/* 智能体星轨视图:/agents 的另一种展示形式(手写 CSS 圆环星轨布局)。
|
||||
字面量路由,置于 agents/:agent_id/* 参数路由之前。 */}
|
||||
<Route
|
||||
path="agents/orbit"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AgentsOrbitPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
{/* 舆情分析虚拟智能体:静态段优先于 :agent_id 命中,走 Gateway 转发
|
||||
外部 AG-UI 网关的专用对话页(不在后端 /api/agents 落库)。 */}
|
||||
<Route
|
||||
|
||||
@@ -0,0 +1,606 @@
|
||||
import {
|
||||
CalendarClock,
|
||||
CircleAlert,
|
||||
Clock3,
|
||||
Network,
|
||||
Pencil,
|
||||
Pause,
|
||||
Play,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Button,
|
||||
DialogPlugin,
|
||||
Drawer,
|
||||
Input,
|
||||
MessagePlugin,
|
||||
Popconfirm,
|
||||
Switch,
|
||||
Tabs,
|
||||
Tag,
|
||||
} from "tdesign-react";
|
||||
|
||||
import { OFFICIAL_DEERFLOW_ROUTE_PREFIX } from "@/core/official-deerflow/routes";
|
||||
|
||||
import AgentFlow from "./components/AgentFlow";
|
||||
import TaskEditorDialog from "./components/TaskEditorDialog";
|
||||
import { useMultiAgentTaskActions, useMultiAgentTasks } from "./store";
|
||||
import type { MatRunView, MatTask, MatTaskPayload, MatTaskView } from "./types";
|
||||
import {
|
||||
describeCron,
|
||||
expandStages,
|
||||
formatDateTime,
|
||||
formatDuration,
|
||||
formatRelative,
|
||||
runStatusLabel,
|
||||
taskBadgeOf,
|
||||
} from "./utils";
|
||||
import "./multi-agent-tasks.css";
|
||||
|
||||
type StatusFilter = "all" | "running" | "enabled" | "paused";
|
||||
|
||||
function isTaskLive(task: MatTaskView) {
|
||||
return task.lastRun?.status === "running" || task.lastRun?.status === "pending";
|
||||
}
|
||||
|
||||
function matchFilter(task: MatTaskView, filter: StatusFilter) {
|
||||
if (filter === "all") return true;
|
||||
if (filter === "running") return isTaskLive(task);
|
||||
if (filter === "paused") return !task.enabled;
|
||||
return task.enabled && !isTaskLive(task);
|
||||
}
|
||||
|
||||
function runTagTheme(status: MatRunView["status"]): "success" | "danger" | "warning" | "default" {
|
||||
if (status === "succeeded") return "success";
|
||||
if (status === "failed") return "danger";
|
||||
if (status === "running") return "warning";
|
||||
return "default";
|
||||
}
|
||||
|
||||
export default function MultiAgentTasksPage({
|
||||
routeTaskId,
|
||||
}: {
|
||||
routeTaskId: string | null;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { tasks, isLoading } = useMultiAgentTasks();
|
||||
const actions = useMultiAgentTaskActions();
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState<StatusFilter>("all");
|
||||
const [editorTask, setEditorTask] = useState<MatTask | null>(null);
|
||||
const [editorVisible, setEditorVisible] = useState(false);
|
||||
const [busyTaskId, setBusyTaskId] = useState<string | null>(null);
|
||||
const [activeRunId, setActiveRunId] = useState<string | null>(null);
|
||||
|
||||
const selectedTask = tasks.find((task) => task.id === routeTaskId) ?? null;
|
||||
|
||||
const openTask = useCallback(
|
||||
(taskId: string) => {
|
||||
setActiveRunId(null);
|
||||
navigate(
|
||||
`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/multi-agent-tasks/${encodeURIComponent(taskId)}`,
|
||||
);
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const closeTask = useCallback(() => {
|
||||
setActiveRunId(null);
|
||||
navigate(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/multi-agent-tasks`);
|
||||
}, [navigate]);
|
||||
|
||||
const visibleTasks = useMemo(() => {
|
||||
const keyword = search.trim().toLowerCase();
|
||||
return tasks.filter((task) => {
|
||||
if (!matchFilter(task, filter)) return false;
|
||||
if (!keyword) return true;
|
||||
return (
|
||||
task.name.toLowerCase().includes(keyword) ||
|
||||
(task.description ?? "").toLowerCase().includes(keyword) ||
|
||||
task.seats.some((seat) => seat.name.toLowerCase().includes(keyword))
|
||||
);
|
||||
});
|
||||
}, [tasks, search, filter]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = tasks.length;
|
||||
const enabled = tasks.filter((task) => task.enabled).length;
|
||||
const running = tasks.filter(isTaskLive).length;
|
||||
const runTotal = tasks.reduce((sum, task) => sum + task.runs.length, 0);
|
||||
return { total, enabled, running, runTotal };
|
||||
}, [tasks]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditorTask(null);
|
||||
setEditorVisible(true);
|
||||
};
|
||||
|
||||
const openEdit = (task: MatTask) => {
|
||||
setEditorTask(task);
|
||||
setEditorVisible(true);
|
||||
};
|
||||
|
||||
const handleEditorSubmit = async (payload: MatTaskPayload) => {
|
||||
try {
|
||||
if (editorTask) {
|
||||
await actions.update.mutateAsync({ id: editorTask.id, payload });
|
||||
void MessagePlugin.success("任务已更新");
|
||||
} else {
|
||||
const created = await actions.create.mutateAsync(payload);
|
||||
void MessagePlugin.success("任务已创建");
|
||||
if (created?.id) openTask(created.id);
|
||||
}
|
||||
setEditorVisible(false);
|
||||
setEditorTask(null);
|
||||
} catch {
|
||||
void MessagePlugin.error("保存失败,请重试");
|
||||
}
|
||||
};
|
||||
|
||||
const runTrigger = async (task: MatTaskView) => {
|
||||
setBusyTaskId(task.id);
|
||||
try {
|
||||
await actions.trigger.mutateAsync(task.id);
|
||||
setActiveRunId(null);
|
||||
openTask(task.id);
|
||||
void MessagePlugin.success("已触发执行");
|
||||
} catch {
|
||||
void MessagePlugin.error("触发失败,请重试");
|
||||
} finally {
|
||||
setBusyTaskId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const runToggle = async (task: MatTaskView) => {
|
||||
setBusyTaskId(task.id);
|
||||
try {
|
||||
await actions.setEnabled.mutateAsync({ id: task.id, enabled: !task.enabled });
|
||||
void MessagePlugin.success(task.enabled ? "任务已暂停" : "任务已恢复");
|
||||
} catch {
|
||||
void MessagePlugin.error("操作失败,请重试");
|
||||
} finally {
|
||||
setBusyTaskId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = (task: MatTaskView) => {
|
||||
const dialog = DialogPlugin.confirm({
|
||||
header: "删除定时任务",
|
||||
body: `确定删除「${task.name}」吗?其编排配置与运行记录将一并清除,且不可恢复。`,
|
||||
confirmBtn: { content: "删除", theme: "danger" },
|
||||
cancelBtn: "取消",
|
||||
onConfirm: () => {
|
||||
dialog.destroy();
|
||||
void actions.remove.mutateAsync(task.id).then(() => {
|
||||
if (selectedTask?.id === task.id) closeTask();
|
||||
void MessagePlugin.success("任务已删除");
|
||||
});
|
||||
},
|
||||
onClose: () => dialog.destroy(),
|
||||
});
|
||||
};
|
||||
|
||||
const selectedStages = selectedTask ? expandStages(selectedTask) : [];
|
||||
const selectedRuns = selectedTask?.runs ?? [];
|
||||
const activeRun =
|
||||
selectedRuns.find((run) => run.id === activeRunId) ?? selectedRuns[0] ?? null;
|
||||
|
||||
return (
|
||||
<section className="mat-page">
|
||||
<header className="mat-hero">
|
||||
<div className="mat-hero-copy">
|
||||
<p className="mat-hero-eyebrow">
|
||||
<Network aria-hidden="true" size={14} />
|
||||
编排 · 协作 · 自动执行
|
||||
</p>
|
||||
<h1>多智能体定时任务</h1>
|
||||
<p className="mat-hero-desc">
|
||||
把多个智能体编排成协作编队:同阶段并行、阶段间串行,按计划自动执行并沉淀运行记录。
|
||||
</p>
|
||||
</div>
|
||||
<div className="mat-hero-side">
|
||||
<div className="mat-hero-stats">
|
||||
<div className="mat-hero-stat">
|
||||
<strong>{stats.total}</strong>
|
||||
<span>编排任务</span>
|
||||
</div>
|
||||
<div className="mat-hero-stat">
|
||||
<strong>{stats.enabled}</strong>
|
||||
<span>已启用</span>
|
||||
</div>
|
||||
<div className="mat-hero-stat">
|
||||
<strong>{stats.running}</strong>
|
||||
<span>执行中</span>
|
||||
</div>
|
||||
<div className="mat-hero-stat">
|
||||
<strong>{stats.runTotal}</strong>
|
||||
<span>累计执行</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="mat-hero-create" onClick={openCreate} theme="default">
|
||||
<Plus aria-hidden="true" size={16} />
|
||||
新建编排任务
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mat-body">
|
||||
<div className="mat-toolbar">
|
||||
<div className="mat-toolbar-search">
|
||||
<Input
|
||||
clearable
|
||||
placeholder="搜索任务名称 / 描述 / 智能体"
|
||||
prefixIcon={<Search aria-hidden="true" size={15} />}
|
||||
value={search}
|
||||
onChange={(value) => setSearch(String(value ?? ""))}
|
||||
/>
|
||||
</div>
|
||||
<Tabs
|
||||
className="mat-toolbar-tabs"
|
||||
value={filter}
|
||||
onChange={(value) => setFilter(value as StatusFilter)}
|
||||
>
|
||||
<Tabs.TabPanel label="全部" value="all" />
|
||||
<Tabs.TabPanel label="执行中" value="running" />
|
||||
<Tabs.TabPanel label="已启用" value="enabled" />
|
||||
<Tabs.TabPanel label="已暂停" value="paused" />
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="mat-state">
|
||||
<Clock3 aria-hidden="true" className="mat-spin" size={18} />
|
||||
正在加载编排任务…
|
||||
</div>
|
||||
) : visibleTasks.length ? (
|
||||
<div className="mat-grid">
|
||||
{visibleTasks.map((task) => {
|
||||
const badge = taskBadgeOf(task);
|
||||
const live = isTaskLive(task);
|
||||
const busy = busyTaskId === task.id;
|
||||
return (
|
||||
<article className="mat-card" key={task.id}>
|
||||
<header className="mat-card-head" onClick={() => openTask(task.id)}>
|
||||
<span className="mat-card-glyph" aria-hidden="true">
|
||||
<Network size={17} />
|
||||
</span>
|
||||
<div className="mat-card-title">
|
||||
<h3>{task.name}</h3>
|
||||
<p>
|
||||
<CalendarClock aria-hidden="true" size={13} />
|
||||
{describeCron(task.cron_expr)}
|
||||
<em> · {task.timezone}</em>
|
||||
</p>
|
||||
</div>
|
||||
<Tag size="small" theme={badge.theme} variant="light">
|
||||
{badge.label}
|
||||
</Tag>
|
||||
</header>
|
||||
{task.description && (
|
||||
<p className="mat-card-desc" onClick={() => openTask(task.id)}>
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="mat-card-flow" onClick={() => openTask(task.id)}>
|
||||
<AgentFlow
|
||||
seats={task.seats}
|
||||
size="sm"
|
||||
stages={expandStages(task)}
|
||||
/>
|
||||
</div>
|
||||
<footer className="mat-card-foot">
|
||||
<div className="mat-card-meta">
|
||||
<span title={task.nextRunAt ?? undefined}>
|
||||
下次 {task.enabled ? formatDateTime(task.nextRunAt) : "已暂停"}
|
||||
</span>
|
||||
<span>
|
||||
上次 {task.lastRun ? formatRelative(task.lastRun.started_at) : "未执行"}
|
||||
{task.lastRun && task.lastRun.status !== "pending" && (
|
||||
<em className={`mat-run-text is-${task.lastRun.status}`}>
|
||||
· {runStatusLabel(task.lastRun.status)}
|
||||
</em>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mat-card-ops">
|
||||
<Button
|
||||
disabled={busy}
|
||||
loading={busy}
|
||||
size="small"
|
||||
theme="primary"
|
||||
variant="text"
|
||||
onClick={() => void runTrigger(task)}
|
||||
>
|
||||
<Zap aria-hidden="true" size={14} />
|
||||
立即执行
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy}
|
||||
size="small"
|
||||
variant="text"
|
||||
onClick={() => void runToggle(task)}
|
||||
>
|
||||
{task.enabled ? (
|
||||
<Pause aria-hidden="true" size={14} />
|
||||
) : (
|
||||
<Play aria-hidden="true" size={14} />
|
||||
)}
|
||||
{task.enabled ? "暂停" : "恢复"}
|
||||
</Button>
|
||||
<Button size="small" variant="text" onClick={() => openEdit(task)}>
|
||||
<Pencil aria-hidden="true" size={14} />
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
content="删除后不可恢复,确定删除该任务?"
|
||||
placement="bottom-right"
|
||||
theme="danger"
|
||||
onConfirm={() => confirmDelete(task)}
|
||||
>
|
||||
<Button className="mat-danger-btn" size="small" variant="text">
|
||||
<Trash2 aria-hidden="true" size={14} />
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mat-state mat-state--empty">
|
||||
<span className="mat-state-glyph" aria-hidden="true">
|
||||
<Network size={22} />
|
||||
</span>
|
||||
<h3>{tasks.length ? "没有符合条件的任务" : "还没有多智能体定时任务"}</h3>
|
||||
<p>
|
||||
{tasks.length
|
||||
? "换个关键词或筛选条件试试。"
|
||||
: "创建一个编排任务,把多个智能体按并行 / 串行组织起来定时执行。"}
|
||||
</p>
|
||||
{!tasks.length && (
|
||||
<Button theme="primary" onClick={openCreate}>
|
||||
<Plus aria-hidden="true" size={15} />
|
||||
新建编排任务
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mat-footnote">
|
||||
<CircleAlert aria-hidden="true" size={13} />
|
||||
前端演示阶段:任务数据保存在本浏览器,执行过程为本地模拟;后端服务接入后自动切换。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Drawer
|
||||
className="mat-detail-drawer"
|
||||
closeBtn
|
||||
destroyOnClose
|
||||
footer={
|
||||
selectedTask ? (
|
||||
<div className="mat-detail-footer">
|
||||
<Button
|
||||
disabled={busyTaskId === selectedTask.id}
|
||||
loading={busyTaskId === selectedTask.id}
|
||||
theme="primary"
|
||||
onClick={() => void runTrigger(selectedTask)}
|
||||
>
|
||||
<Zap aria-hidden="true" size={15} />
|
||||
立即执行
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void runToggle(selectedTask)}
|
||||
>
|
||||
{selectedTask.enabled ? (
|
||||
<Pause aria-hidden="true" size={15} />
|
||||
) : (
|
||||
<Play aria-hidden="true" size={15} />
|
||||
)}
|
||||
{selectedTask.enabled ? "暂停任务" : "恢复任务"}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => openEdit(selectedTask)}>
|
||||
<Pencil aria-hidden="true" size={15} />
|
||||
编辑编排
|
||||
</Button>
|
||||
<Button
|
||||
className="mat-danger-btn"
|
||||
theme="danger"
|
||||
variant="outline"
|
||||
onClick={() => confirmDelete(selectedTask)}
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={15} />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
header={
|
||||
selectedTask ? (
|
||||
<div className="mat-detail-header">
|
||||
<span className="mat-detail-glyph" aria-hidden="true">
|
||||
<Network size={17} />
|
||||
</span>
|
||||
<div className="mat-detail-heading">
|
||||
<h3>{selectedTask.name}</h3>
|
||||
<p>{describeCron(selectedTask.cron_expr)} · {selectedTask.timezone}</p>
|
||||
</div>
|
||||
{(() => {
|
||||
const badge = taskBadgeOf(selectedTask);
|
||||
return (
|
||||
<Tag theme={badge.theme} variant="light">
|
||||
{badge.label}
|
||||
</Tag>
|
||||
);
|
||||
})()}
|
||||
<Switch
|
||||
value={selectedTask.enabled}
|
||||
onChange={() => void runToggle(selectedTask)}
|
||||
/>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
placement="right"
|
||||
size="680px"
|
||||
visible={!!selectedTask}
|
||||
onClose={closeTask}
|
||||
>
|
||||
{selectedTask && (
|
||||
<div className="mat-detail">
|
||||
{selectedTask.description && <p className="mat-detail-desc">{selectedTask.description}</p>}
|
||||
|
||||
<div className="mat-detail-grid">
|
||||
<div>
|
||||
<span>下次执行</span>
|
||||
<strong>
|
||||
{selectedTask.enabled ? formatDateTime(selectedTask.nextRunAt) : "已暂停"}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>最近执行</span>
|
||||
<strong>
|
||||
{selectedTask.lastRun
|
||||
? `${formatRelative(selectedTask.lastRun.started_at)} · ${runStatusLabel(selectedTask.lastRun.status)}`
|
||||
: "未执行"}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>累计执行</span>
|
||||
<strong>{selectedTask.runs.length} 次</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>智能体 / 阶段</span>
|
||||
<strong>
|
||||
{selectedTask.seats.length} 个 · {selectedStages.length} 阶段
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="mat-detail-section">
|
||||
<header className="mat-detail-section-head">
|
||||
<h4>协作编排</h4>
|
||||
{activeRun && (activeRun.status === "running" || activeRun.status === "pending") && (
|
||||
<span className="mat-run-text is-running">
|
||||
阶段 {Math.min(activeRun.stage_index + 1, activeRun.stage_total)} /{" "}
|
||||
{activeRun.stage_total} 执行中…
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
<div className="mat-detail-flow">
|
||||
<AgentFlow
|
||||
activeStageIndex={
|
||||
activeRun && activeRun.status !== "succeeded" ? activeRun.stage_index : null
|
||||
}
|
||||
seatStates={activeRun?.seat_states ?? null}
|
||||
seats={selectedTask.seats}
|
||||
showGoals
|
||||
stageGoals={selectedTask.stage_goals}
|
||||
stages={selectedStages}
|
||||
/>
|
||||
</div>
|
||||
{activeRun && (
|
||||
<div className="mat-flow-legend">
|
||||
<span>
|
||||
<i className="mat-seat-dot is-done" /> 完成
|
||||
</span>
|
||||
<span>
|
||||
<i className="mat-seat-dot is-running" /> 执行中
|
||||
</span>
|
||||
<span>
|
||||
<i className="mat-seat-dot is-waiting" /> 等待
|
||||
</span>
|
||||
<span>
|
||||
<i className="mat-seat-dot is-failed" /> 失败
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="mat-detail-section">
|
||||
<header className="mat-detail-section-head">
|
||||
<h4>任务指令</h4>
|
||||
</header>
|
||||
<p className="mat-detail-prompt">{selectedTask.prompt}</p>
|
||||
{selectedTask.coordinator_prompt && (
|
||||
<>
|
||||
<header className="mat-detail-section-head mat-detail-section-head--sub">
|
||||
<h5>总控提示</h5>
|
||||
</header>
|
||||
<p className="mat-detail-prompt is-sub">{selectedTask.coordinator_prompt}</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="mat-detail-section">
|
||||
<header className="mat-detail-section-head">
|
||||
<h4>运行记录</h4>
|
||||
{!!selectedRuns.length && (
|
||||
<small>点击记录可在上方流程图回看各智能体状态</small>
|
||||
)}
|
||||
</header>
|
||||
{selectedRuns.length ? (
|
||||
<div className="mat-run-list">
|
||||
{selectedRuns.map((run) => {
|
||||
const active = activeRun?.id === run.id;
|
||||
const duration = run.finished_at
|
||||
? formatDuration(
|
||||
new Date(run.finished_at).getTime() -
|
||||
new Date(run.started_at).getTime(),
|
||||
)
|
||||
: null;
|
||||
return (
|
||||
<button
|
||||
className={`mat-run-item${active ? " is-active" : ""}`}
|
||||
key={run.id}
|
||||
onClick={() => setActiveRunId(run.id)}
|
||||
type="button"
|
||||
>
|
||||
<Tag size="small" theme={runTagTheme(run.status)} variant="light">
|
||||
{runStatusLabel(run.status)}
|
||||
</Tag>
|
||||
<span className="mat-run-item-time">
|
||||
{formatDateTime(run.started_at)}
|
||||
</span>
|
||||
<span className="mat-run-item-trigger">
|
||||
{run.trigger === "manual" ? "手动触发" : "按计划执行"}
|
||||
</span>
|
||||
<span className="mat-run-item-duration">
|
||||
{duration ?? `阶段 ${Math.min(run.stage_index + 1, run.stage_total)}/${run.stage_total}`}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mat-detail-empty">还没有执行记录,可点「立即执行」体验完整流程。</p>
|
||||
)}
|
||||
{activeRun?.error && (
|
||||
<p className="mat-detail-error">
|
||||
<CircleAlert aria-hidden="true" size={14} />
|
||||
{activeRun.error}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<TaskEditorDialog
|
||||
onClose={() => {
|
||||
setEditorVisible(false);
|
||||
setEditorTask(null);
|
||||
}}
|
||||
onSubmit={handleEditorSubmit}
|
||||
submitting={actions.create.isPending || actions.update.isPending}
|
||||
task={editorTask}
|
||||
visible={editorVisible}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { MoveRight } from "lucide-react";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import type { MatSeat, MatSeatRunState } from "../types";
|
||||
import { seatColorIndex } from "../utils";
|
||||
|
||||
export interface AgentFlowProps {
|
||||
stages: string[][];
|
||||
seats: MatSeat[];
|
||||
stageGoals?: (string | null)[] | null;
|
||||
/** 提供时在席位上渲染执行状态点(运行图例)。 */
|
||||
seatStates?: Record<string, MatSeatRunState> | null;
|
||||
/** 当前执行到的阶段下标;提供时高亮进度。 */
|
||||
activeStageIndex?: number | null;
|
||||
size?: "sm" | "md";
|
||||
showGoals?: boolean;
|
||||
}
|
||||
|
||||
const STATE_LABEL: Record<MatSeatRunState, string> = {
|
||||
waiting: "等待",
|
||||
running: "执行中",
|
||||
done: "完成",
|
||||
failed: "失败",
|
||||
};
|
||||
|
||||
function initialsOf(name: string): string {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return "?";
|
||||
return trimmed.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export default function AgentFlow({
|
||||
stages,
|
||||
seats,
|
||||
stageGoals,
|
||||
seatStates,
|
||||
activeStageIndex,
|
||||
size = "md",
|
||||
showGoals = false,
|
||||
}: AgentFlowProps) {
|
||||
const seatById = new Map(seats.map((seat) => [seat.agent_id, seat]));
|
||||
|
||||
return (
|
||||
<div className={`mat-flow mat-flow--${size}`}>
|
||||
{stages.map((stage, stageIndex) => {
|
||||
const parallel = stage.length > 1;
|
||||
const isActive = typeof activeStageIndex === "number" && activeStageIndex === stageIndex;
|
||||
const isPast =
|
||||
typeof activeStageIndex === "number" && activeStageIndex > stageIndex;
|
||||
const goal = showGoals ? stageGoals?.[stageIndex] ?? null : null;
|
||||
return (
|
||||
<Fragment key={`stage-${stageIndex}`}>
|
||||
{stageIndex > 0 && (
|
||||
<div className="mat-flow-arrow" aria-hidden="true">
|
||||
<MoveRight size={size === "sm" ? 14 : 16} strokeWidth={2} />
|
||||
</div>
|
||||
)}
|
||||
<section
|
||||
className={[
|
||||
"mat-flow-stage",
|
||||
isActive ? "is-active" : "",
|
||||
isPast ? "is-done" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
<header className="mat-flow-stage-head">
|
||||
<span className="mat-flow-stage-no">阶段 {stageIndex + 1}</span>
|
||||
{parallel ? (
|
||||
<span className="mat-flow-mode is-parallel">并行 ×{stage.length}</span>
|
||||
) : (
|
||||
<span className="mat-flow-mode is-serial">串行</span>
|
||||
)}
|
||||
</header>
|
||||
<div className="mat-flow-seats">
|
||||
{stage.map((agentId) => {
|
||||
const seat = seatById.get(agentId);
|
||||
const state = seatStates?.[agentId] ?? null;
|
||||
return (
|
||||
<div
|
||||
className={`mat-seat mat-seat--c${seatColorIndex(agentId)}${
|
||||
state ? ` is-${state}` : ""
|
||||
}`}
|
||||
key={agentId}
|
||||
title={
|
||||
seat?.description
|
||||
? `${seat.name}:${seat.description}`
|
||||
: (seat?.name ?? agentId)
|
||||
}
|
||||
>
|
||||
<span className="mat-seat-avatar">{initialsOf(seat?.name ?? agentId)}</span>
|
||||
<span className="mat-seat-name">{seat?.name ?? agentId}</span>
|
||||
{state && (
|
||||
<span className={`mat-seat-dot is-${state}`} title={STATE_LABEL[state]} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{goal && <p className="mat-flow-goal">{goal}</p>}
|
||||
</section>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Select } from "tdesign-react";
|
||||
|
||||
import type { ScheduleConfig, ScheduleFreq } from "@/components/workspace/scheduled-tasks/schedule-builder";
|
||||
|
||||
import { describeScheduleConfig } from "../utils";
|
||||
|
||||
const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
|
||||
const HOURS = Array.from({ length: 24 }, (_, index) => index);
|
||||
const MINUTES = [0, 15, 30, 45];
|
||||
const DATES = Array.from({ length: 31 }, (_, index) => index + 1);
|
||||
|
||||
const FREQ_OPTIONS: Array<{ label: string; value: ScheduleFreq }> = [
|
||||
{ label: "每小时", value: "hourly" },
|
||||
{ label: "每天", value: "daily" },
|
||||
{ label: "每周", value: "weekly" },
|
||||
{ label: "每月", value: "monthly" },
|
||||
];
|
||||
|
||||
export default function SchedulePicker({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: ScheduleConfig;
|
||||
onChange: (config: ScheduleConfig) => void;
|
||||
}) {
|
||||
const set = (patch: Partial<ScheduleConfig>) => onChange({ ...value, ...patch });
|
||||
const toNumber = (input: unknown) => Number(input as string | number);
|
||||
|
||||
return (
|
||||
<div className="mat-schedule-picker">
|
||||
<div className="mat-schedule-row">
|
||||
<Select
|
||||
options={FREQ_OPTIONS}
|
||||
value={value.freq}
|
||||
onChange={(next) => set({ freq: next as ScheduleFreq })}
|
||||
/>
|
||||
{value.freq === "hourly" && (
|
||||
<Select
|
||||
options={MINUTES.map((minute) => ({ label: `${minute} 分`, value: minute }))}
|
||||
value={value.minute}
|
||||
onChange={(next) => set({ minute: toNumber(next) })}
|
||||
/>
|
||||
)}
|
||||
{value.freq === "weekly" && (
|
||||
<Select
|
||||
options={WEEKDAYS.map((label, index) => ({ label, value: index }))}
|
||||
value={value.weekday}
|
||||
onChange={(next) => set({ weekday: toNumber(next) })}
|
||||
/>
|
||||
)}
|
||||
{value.freq === "monthly" && (
|
||||
<Select
|
||||
options={DATES.map((date) => ({ label: `第 ${date} 日`, value: date }))}
|
||||
value={value.date}
|
||||
onChange={(next) => set({ date: toNumber(next) })}
|
||||
/>
|
||||
)}
|
||||
{value.freq !== "hourly" && (
|
||||
<Select
|
||||
options={HOURS.map((hour) => ({ label: `${hour} 时`, value: hour }))}
|
||||
value={value.hour}
|
||||
onChange={(next) => set({ hour: toNumber(next) })}
|
||||
/>
|
||||
)}
|
||||
{value.freq !== "hourly" && (
|
||||
<Select
|
||||
options={MINUTES.map((minute) => ({ label: `${minute} 分`, value: minute }))}
|
||||
value={value.minute}
|
||||
onChange={(next) => set({ minute: toNumber(next) })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="mat-schedule-preview">{describeScheduleConfig(value)} 自动执行</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Plus,
|
||||
Search,
|
||||
SplitSquareVertical,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
Input,
|
||||
MessagePlugin,
|
||||
Select,
|
||||
Steps,
|
||||
Switch,
|
||||
Textarea,
|
||||
} from "tdesign-react";
|
||||
|
||||
import { useAgents } from "@/core/agents";
|
||||
import {
|
||||
type ScheduleConfig,
|
||||
buildCron,
|
||||
parseCron,
|
||||
} from "@/components/workspace/scheduled-tasks/schedule-builder";
|
||||
|
||||
import AgentFlow from "./AgentFlow";
|
||||
import SchedulePicker from "./SchedulePicker";
|
||||
import type { MatSeat, MatStageGroup, MatTask, MatTaskPayload } from "../types";
|
||||
import { defaultScheduleConfig, expandStages } from "../utils";
|
||||
|
||||
const TIMEZONE_OPTIONS = ["Asia/Shanghai", "UTC", "America/New_York", "Europe/London"].map(
|
||||
(zone) => ({ label: zone, value: zone }),
|
||||
);
|
||||
|
||||
const MAX_SEATS = 20;
|
||||
const MIN_SEATS = 2;
|
||||
|
||||
interface EditorDraft {
|
||||
name: string;
|
||||
description: string;
|
||||
schedule: ScheduleConfig;
|
||||
timezone: string;
|
||||
enabled: boolean;
|
||||
stageGroups: MatStageGroup[];
|
||||
prompt: string;
|
||||
coordinatorPrompt: string;
|
||||
}
|
||||
|
||||
function draftFromTask(task: MatTask | null): EditorDraft {
|
||||
if (!task) {
|
||||
return {
|
||||
name: "",
|
||||
description: "",
|
||||
schedule: defaultScheduleConfig(),
|
||||
timezone: "Asia/Shanghai",
|
||||
enabled: true,
|
||||
stageGroups: [],
|
||||
prompt: "",
|
||||
coordinatorPrompt: "",
|
||||
};
|
||||
}
|
||||
const stages = expandStages(task);
|
||||
const seatById = new Map(task.seats.map((seat) => [seat.agent_id, seat]));
|
||||
return {
|
||||
name: task.name,
|
||||
description: task.description ?? "",
|
||||
schedule: parseCron(task.cron_expr) ?? defaultScheduleConfig(),
|
||||
timezone: task.timezone || "Asia/Shanghai",
|
||||
enabled: task.enabled,
|
||||
stageGroups: stages.map((stage, index) => ({
|
||||
seats: stage
|
||||
.map((agentId) => seatById.get(agentId))
|
||||
.filter((seat): seat is MatSeat => !!seat),
|
||||
goal: task.stage_goals?.[index] ?? "",
|
||||
})),
|
||||
prompt: task.prompt,
|
||||
coordinatorPrompt: task.coordinator_prompt ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function draftToPayload(draft: EditorDraft): MatTaskPayload {
|
||||
const groups = draft.stageGroups.filter((group) => group.seats.length > 0);
|
||||
const seats = groups.flatMap((group) => group.seats);
|
||||
const anyParallel = groups.some((group) => group.seats.length > 1);
|
||||
const anyGoal = groups.some((group) => group.goal.trim().length > 0);
|
||||
const explicit = anyParallel || anyGoal;
|
||||
return {
|
||||
name: draft.name.trim(),
|
||||
description: draft.description.trim() || undefined,
|
||||
prompt: draft.prompt.trim(),
|
||||
coordinator_prompt: draft.coordinatorPrompt.trim() || undefined,
|
||||
cron_expr: buildCron(draft.schedule),
|
||||
timezone: draft.timezone,
|
||||
enabled: draft.enabled,
|
||||
seats,
|
||||
stages: explicit ? groups.map((group) => group.seats.map((seat) => seat.agent_id)) : null,
|
||||
stage_goals: explicit ? groups.map((group) => group.goal.trim() || null) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export default function TaskEditorDialog({
|
||||
visible,
|
||||
task,
|
||||
submitting,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
visible: boolean;
|
||||
task: MatTask | null;
|
||||
submitting: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (payload: MatTaskPayload) => void;
|
||||
}) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [draft, setDraft] = useState<EditorDraft>(() => draftFromTask(task));
|
||||
const [poolSearch, setPoolSearch] = useState("");
|
||||
const [stageAddValues, setStageAddValues] = useState<Record<number, string | undefined>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setStep(0);
|
||||
setPoolSearch("");
|
||||
setDraft(draftFromTask(task));
|
||||
}
|
||||
}, [visible, task]);
|
||||
|
||||
const { agents, isLoading: agentsLoading } = useAgents({ search: poolSearch.trim() || undefined });
|
||||
|
||||
const usedAgentIds = useMemo(
|
||||
() => new Set(draft.stageGroups.flatMap((group) => group.seats.map((seat) => seat.agent_id))),
|
||||
[draft.stageGroups],
|
||||
);
|
||||
const seatTotal = usedAgentIds.size;
|
||||
|
||||
const updateDraft = (patch: Partial<EditorDraft>) => setDraft((prev) => ({ ...prev, ...patch }));
|
||||
const updateGroups = (
|
||||
updater: (groups: MatStageGroup[]) => MatStageGroup[],
|
||||
) => updateDraft({ stageGroups: updater(draft.stageGroups) });
|
||||
|
||||
const addAgentAsNewStage = (seat: MatSeat) => {
|
||||
if (usedAgentIds.has(seat.agent_id) || seatTotal >= MAX_SEATS) return;
|
||||
updateGroups((groups) => [...groups, { seats: [seat], goal: "" }]);
|
||||
};
|
||||
|
||||
const addAgentToStage = (seat: MatSeat, stageIndex: number) => {
|
||||
if (usedAgentIds.has(seat.agent_id) || seatTotal >= MAX_SEATS) return;
|
||||
updateGroups((groups) =>
|
||||
groups.map((group, index) =>
|
||||
index === stageIndex ? { ...group, seats: [...group.seats, seat] } : group,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const removeSeat = (stageIndex: number, seatIndex: number) => {
|
||||
updateGroups((groups) =>
|
||||
groups
|
||||
.map((group, index) =>
|
||||
index === stageIndex
|
||||
? { ...group, seats: group.seats.filter((_, seatIdx) => seatIdx !== seatIndex) }
|
||||
: group,
|
||||
)
|
||||
.filter((group) => group.seats.length > 0),
|
||||
);
|
||||
};
|
||||
|
||||
const moveSeatAcrossStage = (stageIndex: number, seatIndex: number, delta: 1 | -1) => {
|
||||
const target = stageIndex + delta;
|
||||
if (target < 0 || target >= draft.stageGroups.length) return;
|
||||
updateGroups((groups) => {
|
||||
const next = groups.map((group) => ({ seats: [...group.seats], goal: group.goal }));
|
||||
const seat = next[stageIndex]!.seats[seatIndex];
|
||||
if (!seat) return groups;
|
||||
next[stageIndex]!.seats.splice(seatIndex, 1);
|
||||
next[target]!.seats.push(seat);
|
||||
return next.filter((group) => group.seats.length > 0);
|
||||
});
|
||||
};
|
||||
|
||||
const splitSeatToStage = (stageIndex: number, seatIndex: number) => {
|
||||
updateGroups((groups) => {
|
||||
const next = groups.map((group) => ({ seats: [...group.seats], goal: group.goal }));
|
||||
const seat = next[stageIndex]!.seats[seatIndex];
|
||||
if (!seat) return groups;
|
||||
next[stageIndex]!.seats.splice(seatIndex, 1);
|
||||
next.splice(stageIndex + 1, 0, { seats: [seat], goal: "" });
|
||||
return next.filter((group) => group.seats.length > 0);
|
||||
});
|
||||
};
|
||||
|
||||
const moveStage = (stageIndex: number, delta: 1 | -1) => {
|
||||
const target = stageIndex + delta;
|
||||
if (target < 0 || target >= draft.stageGroups.length) return;
|
||||
updateGroups((groups) => {
|
||||
const next = [...groups];
|
||||
const [group] = next.splice(stageIndex, 1);
|
||||
if (!group) return groups;
|
||||
next.splice(target, 0, group);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const deleteStage = (stageIndex: number) => {
|
||||
updateGroups((groups) => groups.filter((_, index) => index !== stageIndex));
|
||||
};
|
||||
|
||||
const setStageGoal = (stageIndex: number, goal: string) => {
|
||||
updateGroups((groups) =>
|
||||
groups.map((group, index) => (index === stageIndex ? { ...group, goal } : group)),
|
||||
);
|
||||
};
|
||||
|
||||
const trySubmit = () => {
|
||||
if (!draft.name.trim()) {
|
||||
setStep(0);
|
||||
void MessagePlugin.warning("请填写任务名称");
|
||||
return;
|
||||
}
|
||||
if (seatTotal < MIN_SEATS) {
|
||||
setStep(1);
|
||||
void MessagePlugin.warning(`编排至少需要 ${MIN_SEATS} 个智能体`);
|
||||
return;
|
||||
}
|
||||
if (!draft.prompt.trim()) {
|
||||
setStep(2);
|
||||
void MessagePlugin.warning("请填写任务指令");
|
||||
return;
|
||||
}
|
||||
onSubmit(draftToPayload(draft));
|
||||
};
|
||||
|
||||
const previewStages = draft.stageGroups
|
||||
.filter((group) => group.seats.length > 0)
|
||||
.map((group) => group.seats.map((seat) => seat.agent_id));
|
||||
const previewSeats = draft.stageGroups.flatMap((group) => group.seats);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
className="mat-editor-dialog"
|
||||
closeBtn
|
||||
closeOnOverlayClick={false}
|
||||
destroyOnClose
|
||||
footer={null}
|
||||
header={task ? "编辑多智能体定时任务" : "新建多智能体定时任务"}
|
||||
placement="top"
|
||||
visible={visible}
|
||||
width={980}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="mat-editor">
|
||||
<Steps className="mat-editor-steps" current={step} readonly>
|
||||
<Steps.StepItem title="基本信息" />
|
||||
<Steps.StepItem title="智能体编排" />
|
||||
<Steps.StepItem title="执行设置" />
|
||||
</Steps>
|
||||
|
||||
{step === 0 && (
|
||||
<div className="mat-editor-pane">
|
||||
<label className="mat-field">
|
||||
<span className="mat-field-label">
|
||||
任务名称<i>*</i>
|
||||
</span>
|
||||
<Input
|
||||
clearable
|
||||
maxlength={40}
|
||||
placeholder="例如:每日舆情早报"
|
||||
value={draft.name}
|
||||
onChange={(value) => updateDraft({ name: String(value ?? "") })}
|
||||
/>
|
||||
</label>
|
||||
<label className="mat-field">
|
||||
<span className="mat-field-label">任务描述</span>
|
||||
<Textarea
|
||||
autosize={{ minRows: 2, maxRows: 4 }}
|
||||
maxlength={200}
|
||||
placeholder="一句话说明这个编排任务要达成什么(可选)"
|
||||
value={draft.description}
|
||||
onChange={(value) => updateDraft({ description: String(value ?? "") })}
|
||||
/>
|
||||
</label>
|
||||
<div className="mat-field">
|
||||
<span className="mat-field-label">执行频率</span>
|
||||
<SchedulePicker
|
||||
value={draft.schedule}
|
||||
onChange={(schedule) => updateDraft({ schedule })}
|
||||
/>
|
||||
</div>
|
||||
<div className="mat-field-row">
|
||||
<label className="mat-field">
|
||||
<span className="mat-field-label">时区</span>
|
||||
<Select
|
||||
options={TIMEZONE_OPTIONS}
|
||||
value={draft.timezone}
|
||||
onChange={(value) => updateDraft({ timezone: String(value) })}
|
||||
/>
|
||||
</label>
|
||||
<label className="mat-field mat-field--switch">
|
||||
<span className="mat-field-label">创建后立即启用</span>
|
||||
<Switch value={draft.enabled} onChange={(value) => updateDraft({ enabled: !!value })} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div className="mat-editor-pane mat-editor-pane--wide">
|
||||
<aside className="mat-pool">
|
||||
<header className="mat-pool-head">
|
||||
<span>候选智能体</span>
|
||||
<em>
|
||||
{seatTotal} / {MAX_SEATS}
|
||||
</em>
|
||||
</header>
|
||||
<div className="mat-pool-search">
|
||||
<Search aria-hidden="true" size={15} />
|
||||
<input
|
||||
onChange={(event) => setPoolSearch(event.target.value)}
|
||||
placeholder="搜索智能体"
|
||||
value={poolSearch}
|
||||
/>
|
||||
</div>
|
||||
<div className="mat-pool-list">
|
||||
{agentsLoading ? (
|
||||
<p className="mat-pool-tip">正在加载智能体…</p>
|
||||
) : agents.length ? (
|
||||
agents.map((agent) => {
|
||||
const used = usedAgentIds.has(agent.id);
|
||||
const seat: MatSeat = {
|
||||
agent_id: agent.id,
|
||||
name: agent.name,
|
||||
description: agent.description ?? undefined,
|
||||
};
|
||||
return (
|
||||
<button
|
||||
className="mat-pool-item"
|
||||
disabled={used || seatTotal >= MAX_SEATS}
|
||||
key={agent.id}
|
||||
onClick={() => addAgentAsNewStage(seat)}
|
||||
title={used ? "该智能体已在编排中" : "点击追加为新阶段"}
|
||||
type="button"
|
||||
>
|
||||
<span className="mat-pool-item-name">{agent.name}</span>
|
||||
<span className="mat-pool-item-desc">
|
||||
{agent.description || "暂无简介"}
|
||||
</span>
|
||||
<span className="mat-pool-item-add" aria-hidden="true">
|
||||
<Plus size={14} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="mat-pool-tip">没有匹配的智能体</p>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="mat-stage-editor">
|
||||
<header className="mat-stage-editor-head">
|
||||
<span>阶段编排</span>
|
||||
<small>同阶段并行执行 · 阶段之间串行;点击候选智能体追加为新阶段</small>
|
||||
</header>
|
||||
<div className="mat-stage-list">
|
||||
{draft.stageGroups.map((group, stageIndex) => {
|
||||
const unusedOptions = agents
|
||||
.filter((agent) => !usedAgentIds.has(agent.id))
|
||||
.map((agent) => ({ label: agent.name, value: agent.id, agent }));
|
||||
return (
|
||||
<article className="mat-stage-box" key={`stage-box-${stageIndex}`}>
|
||||
<header className="mat-stage-box-head">
|
||||
<span className="mat-stage-box-no">阶段 {stageIndex + 1}</span>
|
||||
{group.seats.length > 1 ? (
|
||||
<span className="mat-flow-mode is-parallel">并行 ×{group.seats.length}</span>
|
||||
) : (
|
||||
<span className="mat-flow-mode is-serial">串行</span>
|
||||
)}
|
||||
<span className="mat-stage-box-ops">
|
||||
<button
|
||||
aria-label="阶段上移"
|
||||
disabled={stageIndex === 0}
|
||||
onClick={() => moveStage(stageIndex, -1)}
|
||||
title="上移"
|
||||
type="button"
|
||||
>
|
||||
<ArrowUp size={14} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="阶段下移"
|
||||
disabled={stageIndex === draft.stageGroups.length - 1}
|
||||
onClick={() => moveStage(stageIndex, 1)}
|
||||
title="下移"
|
||||
type="button"
|
||||
>
|
||||
<ArrowDown size={14} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="删除阶段"
|
||||
onClick={() => deleteStage(stageIndex)}
|
||||
title="删除整个阶段"
|
||||
type="button"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</span>
|
||||
</header>
|
||||
<Textarea
|
||||
autosize
|
||||
className="mat-stage-goal"
|
||||
maxlength={120}
|
||||
placeholder="本阶段目标(可选):告诉总控本阶段要产出什么、如何衔接上一阶段"
|
||||
value={group.goal}
|
||||
onChange={(value) => setStageGoal(stageIndex, String(value ?? ""))}
|
||||
/>
|
||||
<div className="mat-stage-seats">
|
||||
{group.seats.map((seat, seatIndex) => (
|
||||
<div className="mat-seat-row" key={seat.agent_id}>
|
||||
<span className="mat-seat-row-name" title={seat.description}>
|
||||
{seat.name}
|
||||
</span>
|
||||
<span className="mat-seat-row-ops">
|
||||
<button
|
||||
aria-label="并入上一阶段(与其并行)"
|
||||
disabled={stageIndex === 0}
|
||||
onClick={() => moveSeatAcrossStage(stageIndex, seatIndex, -1)}
|
||||
title="并入上一阶段(与其并行)"
|
||||
type="button"
|
||||
>
|
||||
<ChevronUp size={14} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="独立成阶段"
|
||||
onClick={() => splitSeatToStage(stageIndex, seatIndex)}
|
||||
title="独立成阶段"
|
||||
type="button"
|
||||
>
|
||||
<SplitSquareVertical size={14} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="并入下一阶段(与其并行)"
|
||||
disabled={stageIndex === draft.stageGroups.length - 1}
|
||||
onClick={() => moveSeatAcrossStage(stageIndex, seatIndex, 1)}
|
||||
title="并入下一阶段(与其并行)"
|
||||
type="button"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="移除智能体"
|
||||
onClick={() => removeSeat(stageIndex, seatIndex)}
|
||||
title="移除"
|
||||
type="button"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{seatTotal < MAX_SEATS && unusedOptions.length > 0 && (
|
||||
<Select
|
||||
className="mat-stage-add"
|
||||
filterable
|
||||
options={unusedOptions.map(({ label, value }) => ({ label, value }))}
|
||||
placeholder="+ 添加智能体到本阶段(并行)"
|
||||
value={stageAddValues[stageIndex] ?? undefined}
|
||||
onChange={(value) => {
|
||||
const agent = unusedOptions.find(
|
||||
(option) => option.value === value,
|
||||
)?.agent;
|
||||
if (agent) {
|
||||
addAgentToStage(
|
||||
{ agent_id: agent.id, name: agent.name, description: agent.description ?? undefined },
|
||||
stageIndex,
|
||||
);
|
||||
}
|
||||
setStageAddValues((prev) => ({ ...prev, [stageIndex]: undefined }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
className="mat-stage-new"
|
||||
onClick={() => updateGroups((groups) => [...groups, { seats: [], goal: "" }])}
|
||||
type="button"
|
||||
>
|
||||
<Plus size={15} />
|
||||
新增空阶段(串在最后)
|
||||
</button>
|
||||
{!draft.stageGroups.length && (
|
||||
<p className="mat-stage-empty">从左侧候选智能体点击添加,至少编排 {MIN_SEATS} 个智能体。</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="mat-editor-pane">
|
||||
<label className="mat-field">
|
||||
<span className="mat-field-label">
|
||||
任务指令<i>*</i>
|
||||
</span>
|
||||
<Textarea
|
||||
autosize={{ minRows: 4, maxRows: 8 }}
|
||||
maxlength={2000}
|
||||
placeholder="描述每次定时执行要让智能体编队完成的事情,例如:汇总过去 24 小时重点舆情并生成早报…"
|
||||
value={draft.prompt}
|
||||
onChange={(value) => updateDraft({ prompt: String(value ?? "") })}
|
||||
/>
|
||||
</label>
|
||||
<label className="mat-field">
|
||||
<span className="mat-field-label">总控提示(可选)</span>
|
||||
<Textarea
|
||||
autosize={{ minRows: 3, maxRows: 6 }}
|
||||
maxlength={1000}
|
||||
placeholder="写给总控(协调者)的编排要求,例如各阶段如何衔接、产出格式约束等"
|
||||
value={draft.coordinatorPrompt}
|
||||
onChange={(value) => updateDraft({ coordinatorPrompt: String(value ?? "") })}
|
||||
/>
|
||||
</label>
|
||||
{previewStages.length > 0 && (
|
||||
<div className="mat-field">
|
||||
<span className="mat-field-label">编排预览</span>
|
||||
<div className="mat-editor-preview">
|
||||
<AgentFlow
|
||||
seats={previewSeats}
|
||||
showGoals
|
||||
size="sm"
|
||||
stageGoals={draft.stageGroups.map((group) => group.goal.trim() || null)}
|
||||
stages={previewStages}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="mat-editor-footer">
|
||||
<span className="mat-editor-hint">
|
||||
{step === 1 ? `已编排 ${seatTotal} 个智能体 · 至少 ${MIN_SEATS} 个` : ""}
|
||||
</span>
|
||||
<div className="mat-editor-footer-ops">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
{step > 0 && (
|
||||
<Button variant="outline" onClick={() => setStep((current) => current - 1)}>
|
||||
上一步
|
||||
</Button>
|
||||
)}
|
||||
{step < 2 ? (
|
||||
<Button theme="primary" onClick={() => setStep((current) => current + 1)}>
|
||||
下一步
|
||||
</Button>
|
||||
) : (
|
||||
<Button loading={submitting} theme="primary" onClick={trySubmit}>
|
||||
{task ? "保存修改" : "创建任务"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* 多智能体定时任务的本地 mock 存储层。
|
||||
*
|
||||
* 后端接口尚未实现,先用 localStorage 持久化任务与运行记录:
|
||||
* - 「立即执行」写入一条运行记录,状态由 started_at + duration_ms 在
|
||||
* 读取时按当前时间推导(排队 → 按阶段推进 → 成功/失败),配合
|
||||
* React Query 的轮询即可获得无后端的“实时”执行动画。
|
||||
* - 接口就绪后,把本文件的 CRUD 换成 HTTP 调用即可,页面无需改动。
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { uuid } from "@/core/utils/uuid";
|
||||
|
||||
import { expandStages, nextCronDate } from "./utils";
|
||||
import type {
|
||||
MatRunRecord,
|
||||
MatRunStatus,
|
||||
MatRunView,
|
||||
MatSeatRunState,
|
||||
MatTask,
|
||||
MatTaskPayload,
|
||||
MatTaskView,
|
||||
} from "./types";
|
||||
|
||||
const STORAGE_KEY = "deerflow.official.multi-agent-tasks.v1";
|
||||
|
||||
interface MatStore {
|
||||
tasks: MatTask[];
|
||||
runs: MatRunRecord[];
|
||||
}
|
||||
|
||||
const PENDING_MS = 1_500;
|
||||
const STAGE_MS = 4_000;
|
||||
|
||||
function readStore(): MatStore {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as Partial<MatStore>;
|
||||
if (Array.isArray(parsed.tasks)) {
|
||||
return { tasks: parsed.tasks as MatTask[], runs: parsed.runs ?? [] };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 解析失败按首次访问处理,重写种子数据。
|
||||
}
|
||||
const seeded = seedStore();
|
||||
writeStore(seeded);
|
||||
return seeded;
|
||||
}
|
||||
|
||||
function writeStore(store: MatStore) {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
|
||||
} catch {
|
||||
// 存储被禁用时静默降级为内存态。
|
||||
}
|
||||
}
|
||||
|
||||
function mutateStore(mutator: (store: MatStore) => MatStore): MatStore {
|
||||
const next = mutator(readStore());
|
||||
writeStore(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function hoursAgo(hours: number): string {
|
||||
return new Date(Date.now() - hours * 3_600_000).toISOString();
|
||||
}
|
||||
|
||||
function seedStore(): MatStore {
|
||||
const seats = {
|
||||
collector: { agent_id: "mat-seed-collector", name: "全网舆情采集", description: "多源检索并汇总最新舆情线索" },
|
||||
monitor: { agent_id: "mat-seed-monitor", name: "舆情监测雷达", description: "盯住重点信源并输出监测明细" },
|
||||
analyst: { agent_id: "mat-seed-analyst", name: "舆情研判分析", description: "对采集结果做情感与趋势研判" },
|
||||
writer: { agent_id: "mat-seed-writer", name: "报告撰写助手", description: "把各阶段产出整理成结构化报告" },
|
||||
verifier: { agent_id: "mat-seed-verifier", name: "数据核查员", description: "交叉校验数据与结论的可靠性" },
|
||||
rival: { agent_id: "mat-seed-rival", name: "竞品情报员", description: "跟踪竞品动态与公开信息" },
|
||||
researcher: { agent_id: "mat-seed-researcher", name: "行业研究员", description: "梳理行业政策与市场变化" },
|
||||
forecaster: { agent_id: "mat-seed-forecaster", name: "趋势预测专家", description: "基于数据推演后续走势" },
|
||||
reviewer: { agent_id: "mat-seed-reviewer", name: "内容审核官", description: "审核报告口径与合规风险" },
|
||||
} satisfies Record<string, MatTask["seats"][number]>;
|
||||
|
||||
const tasks: MatTask[] = [
|
||||
{
|
||||
id: "mat-task-daily",
|
||||
name: "每日舆情早报",
|
||||
description: "每天早上自动汇总过去 24 小时重点舆情,并行采集 + 研判 + 成稿。",
|
||||
prompt:
|
||||
"汇总过去 24 小时与本公司相关的重点舆情:先并行完成多源采集与监测,再研判情感与趋势,最后汇总为一份不超过 1500 字的舆情早报,附风险提示。",
|
||||
coordinator_prompt: "采集阶段允许两个智能体各自独立检索,不做信息合并;研判阶段需引用采集阶段的原始线索。",
|
||||
cron_expr: "0 9 * * *",
|
||||
timezone: "Asia/Shanghai",
|
||||
enabled: true,
|
||||
seats: [seats.collector, seats.monitor, seats.analyst, seats.writer],
|
||||
stages: [
|
||||
[seats.collector.agent_id, seats.monitor.agent_id],
|
||||
[seats.analyst.agent_id],
|
||||
[seats.writer.agent_id],
|
||||
],
|
||||
stage_goals: ["并行完成全网采集与重点信源监测", "基于原始线索完成情感与趋势研判", "汇总为可直发的舆情早报"],
|
||||
created_at: hoursAgo(24 * 18),
|
||||
updated_at: hoursAgo(26),
|
||||
},
|
||||
{
|
||||
id: "mat-task-weekly",
|
||||
name: "周度竞品情报速览",
|
||||
description: "每周一串行走查竞品动态,核查后输出速览简报。",
|
||||
prompt: "跟踪 5 家核心竞品过去一周的公开动态(产品、市场、人事),核查数据可信度后输出竞品速览简报。",
|
||||
cron_expr: "30 8 * * 1",
|
||||
timezone: "Asia/Shanghai",
|
||||
enabled: false,
|
||||
seats: [seats.rival, seats.verifier, seats.writer],
|
||||
stages: null,
|
||||
stage_goals: null,
|
||||
created_at: hoursAgo(24 * 40),
|
||||
updated_at: hoursAgo(24 * 3),
|
||||
},
|
||||
{
|
||||
id: "mat-task-monthly",
|
||||
name: "月度行业洞察报告",
|
||||
description: "每月并行完成行业研究、趋势预测与数据核查,审核后成稿。",
|
||||
prompt:
|
||||
"围绕所在行业输出月度洞察:行业研究员、趋势预测专家与数据核查员并行推进,随后由内容审核官把关,最终产出月度洞察报告。",
|
||||
cron_expr: "0 10 1 * *",
|
||||
timezone: "Asia/Shanghai",
|
||||
enabled: true,
|
||||
seats: [seats.researcher, seats.forecaster, seats.verifier, seats.reviewer, seats.writer],
|
||||
stages: [
|
||||
[seats.researcher.agent_id, seats.forecaster.agent_id, seats.verifier.agent_id],
|
||||
[seats.reviewer.agent_id],
|
||||
[seats.writer.agent_id],
|
||||
],
|
||||
stage_goals: ["并行完成行业研究、趋势预测与数据核查", "审核结论口径与合规风险", "整合为月度洞察报告"],
|
||||
created_at: hoursAgo(24 * 9),
|
||||
updated_at: hoursAgo(24 * 9),
|
||||
},
|
||||
];
|
||||
|
||||
const runs: MatRunRecord[] = [
|
||||
runSeed("mat-task-daily", 2.2, 3, null),
|
||||
runSeed("mat-task-daily", 26, 3, null),
|
||||
runSeed("mat-task-daily", 50, 3, null),
|
||||
runSeed("mat-task-weekly", 24 * 3, 3, 2),
|
||||
runSeed("mat-task-weekly", 24 * 10, 3, null),
|
||||
];
|
||||
return { tasks, runs };
|
||||
}
|
||||
|
||||
function runSeed(
|
||||
taskId: string,
|
||||
hoursAgoValue: number,
|
||||
stageTotal: number,
|
||||
failStage: number | null,
|
||||
): MatRunRecord {
|
||||
const started = hoursAgo(hoursAgoValue);
|
||||
return {
|
||||
id: uuid() as string,
|
||||
task_id: taskId,
|
||||
trigger: "schedule",
|
||||
scheduled_for: started,
|
||||
started_at: started,
|
||||
duration_ms: PENDING_MS + stageTotal * STAGE_MS,
|
||||
fail_stage: failStage,
|
||||
};
|
||||
}
|
||||
|
||||
/** 由记录 + 当前时间推导运行视图:排队 → 按阶段推进 → 成功/失败。 */
|
||||
function resolveRunView(run: MatRunRecord, stages: string[][], now = Date.now()): MatRunView {
|
||||
const startedAt = new Date(run.started_at).getTime();
|
||||
const elapsed = now - startedAt;
|
||||
const stageTotal = Math.max(1, stages.length);
|
||||
const duration = run.duration_ms || PENDING_MS + stageTotal * STAGE_MS;
|
||||
|
||||
const buildStates = (
|
||||
status: MatRunStatus,
|
||||
activeStage: number,
|
||||
): Record<string, MatSeatRunState> => {
|
||||
const states: Record<string, MatSeatRunState> = {};
|
||||
stages.forEach((stage, index) => {
|
||||
const seatState: MatSeatRunState =
|
||||
status === "succeeded"
|
||||
? "done"
|
||||
: index < activeStage
|
||||
? "done"
|
||||
: index === activeStage
|
||||
? status === "failed"
|
||||
? "failed"
|
||||
: "running"
|
||||
: "waiting";
|
||||
stage.forEach((agentId) => {
|
||||
states[agentId] = seatState;
|
||||
});
|
||||
});
|
||||
return states;
|
||||
};
|
||||
|
||||
if (elapsed < PENDING_MS) {
|
||||
return {
|
||||
...run,
|
||||
finished_at: null,
|
||||
status: "pending",
|
||||
stage_index: 0,
|
||||
stage_total: stageTotal,
|
||||
seat_states: buildStates("pending", -1),
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
const failStage = typeof run.fail_stage === "number" ? run.fail_stage : null;
|
||||
const progress = Math.min(elapsed - PENDING_MS, duration - PENDING_MS);
|
||||
const activeStage = Math.min(Math.floor(progress / STAGE_MS), stageTotal - 1);
|
||||
|
||||
if (failStage !== null && activeStage >= failStage) {
|
||||
const finishedAt = new Date(startedAt + PENDING_MS + (failStage + 1) * STAGE_MS).toISOString();
|
||||
return {
|
||||
...run,
|
||||
finished_at: finishedAt,
|
||||
status: "failed",
|
||||
stage_index: failStage,
|
||||
stage_total: stageTotal,
|
||||
seat_states: buildStates("failed", failStage),
|
||||
error: `阶段 ${failStage + 1} 执行失败:模拟的执行异常(前端演示数据)`,
|
||||
};
|
||||
}
|
||||
|
||||
if (elapsed >= duration) {
|
||||
return {
|
||||
...run,
|
||||
finished_at: new Date(startedAt + duration).toISOString(),
|
||||
status: "succeeded",
|
||||
stage_index: stageTotal - 1,
|
||||
stage_total: stageTotal,
|
||||
seat_states: buildStates("succeeded", stageTotal),
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...run,
|
||||
finished_at: null,
|
||||
status: "running",
|
||||
stage_index: activeStage,
|
||||
stage_total: stageTotal,
|
||||
seat_states: buildStates("running", activeStage),
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTaskViews(now = Date.now()): MatTaskView[] {
|
||||
const store = readStore();
|
||||
return store.tasks.map((task) => {
|
||||
const stages = expandStages(task);
|
||||
const runs = store.runs
|
||||
.filter((run) => run.task_id === task.id)
|
||||
.map((run) => resolveRunView(run, stages, now))
|
||||
.sort((a, b) => (a.started_at < b.started_at ? 1 : -1));
|
||||
const next = task.enabled ? nextCronDate(task.cron_expr, new Date(now)) : null;
|
||||
return {
|
||||
...task,
|
||||
runs,
|
||||
lastRun: runs[0] ?? null,
|
||||
nextRunAt: next ? next.toISOString() : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function anyRunLive(tasks: MatTaskView[] | undefined): boolean {
|
||||
return !!tasks?.some((task) =>
|
||||
task.runs.some((run) => run.status === "pending" || run.status === "running"),
|
||||
);
|
||||
}
|
||||
|
||||
export function useMultiAgentTasks() {
|
||||
const query = useQuery({
|
||||
queryKey: ["mat", "tasks"],
|
||||
queryFn: () => buildTaskViews(),
|
||||
refetchInterval: (q) => (anyRunLive(q.state.data as MatTaskView[] | undefined) ? 1_800 : false),
|
||||
});
|
||||
return { tasks: query.data ?? [], isLoading: query.isLoading, error: query.error };
|
||||
}
|
||||
|
||||
export function useMultiAgentTaskActions() {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["mat", "tasks"] });
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: async (payload: MatTaskPayload) => {
|
||||
const now = new Date().toISOString();
|
||||
const task: MatTask = { ...payload, id: uuid() as string, created_at: now, updated_at: now };
|
||||
mutateStore((store) => ({ ...store, tasks: [task, ...store.tasks] }));
|
||||
return task;
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: async ({ id, payload }: { id: string; payload: MatTaskPayload }) => {
|
||||
mutateStore((store) => ({
|
||||
...store,
|
||||
tasks: store.tasks.map((task) =>
|
||||
task.id === id ? { ...task, ...payload, updated_at: new Date().toISOString() } : task,
|
||||
),
|
||||
}));
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
mutateStore((store) => ({
|
||||
tasks: store.tasks.filter((task) => task.id !== id),
|
||||
runs: store.runs.filter((run) => run.task_id !== id),
|
||||
}));
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const setEnabled = useMutation({
|
||||
mutationFn: async ({ id, enabled }: { id: string; enabled: boolean }) => {
|
||||
mutateStore((store) => ({
|
||||
...store,
|
||||
tasks: store.tasks.map((task) =>
|
||||
task.id === id ? { ...task, enabled, updated_at: new Date().toISOString() } : task,
|
||||
),
|
||||
}));
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const trigger = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const store = readStore();
|
||||
const task = store.tasks.find((item) => item.id === id);
|
||||
if (!task) throw new Error("任务不存在");
|
||||
const stageTotal = expandStages(task).length;
|
||||
const now = new Date().toISOString();
|
||||
const run: MatRunRecord = {
|
||||
id: uuid() as string,
|
||||
task_id: id,
|
||||
trigger: "manual",
|
||||
scheduled_for: now,
|
||||
started_at: now,
|
||||
duration_ms: PENDING_MS + stageTotal * STAGE_MS,
|
||||
fail_stage: null,
|
||||
};
|
||||
mutateStore((current) => ({ ...current, runs: [run, ...current.runs] }));
|
||||
return run;
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const removeRun = useMutation({
|
||||
mutationFn: async (runId: string) => {
|
||||
mutateStore((store) => ({ ...store, runs: store.runs.filter((run) => run.id !== runId) }));
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { create, update, remove, setEnabled, trigger, removeRun };
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 多智能体定时任务 —— 前端先行数据模型。
|
||||
*
|
||||
* 编排语义与业务链条保持一致:同一阶段(stage)内的多个智能体并行执行,
|
||||
* 阶段之间串行执行;`stages: null` 表示纯串行线性链(每个智能体一层)。
|
||||
* 当前后端尚未提供接口,数据先落 localStorage,接口就绪后只需替换 store 层。
|
||||
*/
|
||||
|
||||
export type MatRunStatus = "pending" | "running" | "succeeded" | "failed";
|
||||
export type MatSeatRunState = "waiting" | "running" | "done" | "failed";
|
||||
export type MatRunTrigger = "manual" | "schedule";
|
||||
|
||||
/** 编排中的一个智能体席位(落库时保存名称快照,展示优先用快照)。 */
|
||||
export interface MatSeat {
|
||||
agent_id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface MatTask {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
/** 任务指令:本次定时执行要让智能体编队完成什么。 */
|
||||
prompt: string;
|
||||
/** 总控(协调者)提示词:告诉总控如何编排各阶段的产出。 */
|
||||
coordinator_prompt?: string;
|
||||
cron_expr: string;
|
||||
timezone: string;
|
||||
enabled: boolean;
|
||||
seats: MatSeat[];
|
||||
/** `stages[i]` 内的智能体并行,i 递增串行;null = 线性串行。 */
|
||||
stages: string[][] | null;
|
||||
/** 与 stages 同下标对齐的每阶段目标。 */
|
||||
stage_goals?: (string | null)[] | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** 落库的运行记录;状态由 started_at + duration_ms 在读取时推导。 */
|
||||
export interface MatRunRecord {
|
||||
id: string;
|
||||
task_id: string;
|
||||
trigger: MatRunTrigger;
|
||||
scheduled_for: string;
|
||||
started_at: string;
|
||||
duration_ms: number;
|
||||
/** 模拟在该阶段(下标)失败;null = 成功。 */
|
||||
fail_stage?: number | null;
|
||||
}
|
||||
|
||||
/** 读取时推导出的运行视图。 */
|
||||
export interface MatRunView {
|
||||
id: string;
|
||||
task_id: string;
|
||||
trigger: MatRunTrigger;
|
||||
scheduled_for: string;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
status: MatRunStatus;
|
||||
/** 当前执行到的阶段下标(从 0 开始)。 */
|
||||
stage_index: number;
|
||||
stage_total: number;
|
||||
seat_states: Record<string, MatSeatRunState>;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface MatTaskView extends MatTask {
|
||||
runs: MatRunView[];
|
||||
lastRun: MatRunView | null;
|
||||
nextRunAt: string | null;
|
||||
}
|
||||
|
||||
/** 编辑器内部使用的阶段草稿。 */
|
||||
export interface MatStageGroup {
|
||||
seats: MatSeat[];
|
||||
goal: string;
|
||||
}
|
||||
|
||||
/** 编辑器保存时对外提交的载荷。 */
|
||||
export interface MatTaskPayload {
|
||||
name: string;
|
||||
description?: string;
|
||||
prompt: string;
|
||||
coordinator_prompt?: string;
|
||||
cron_expr: string;
|
||||
timezone: string;
|
||||
enabled: boolean;
|
||||
seats: MatSeat[];
|
||||
stages: string[][] | null;
|
||||
stage_goals: (string | null)[] | null;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
DEFAULT_SCHEDULE_CONFIG,
|
||||
type ScheduleConfig,
|
||||
parseCron,
|
||||
} from "@/components/workspace/scheduled-tasks/schedule-builder";
|
||||
|
||||
import type { MatSeat, MatTask, MatTaskView } from "./types";
|
||||
|
||||
const WEEKDAY_LABELS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
|
||||
|
||||
function pad2(value: number) {
|
||||
return String(value).padStart(2, "0");
|
||||
}
|
||||
|
||||
/** 把任务里的 stages 语义展开成显式阶段列表;null = 每个席位独立一层。 */
|
||||
export function expandStages(task: Pick<MatTask, "seats" | "stages">): string[][] {
|
||||
const known = new Set(task.seats.map((seat) => seat.agent_id));
|
||||
if (!task.stages?.length) {
|
||||
return task.seats.map((seat) => [seat.agent_id]);
|
||||
}
|
||||
const expanded = task.stages
|
||||
.map((stage) => stage.filter((id) => known.has(id)))
|
||||
.filter((stage) => stage.length > 0);
|
||||
return expanded.length ? expanded : task.seats.map((seat) => [seat.agent_id]);
|
||||
}
|
||||
|
||||
export function describeScheduleConfig(cfg: ScheduleConfig): string {
|
||||
const clock = `${pad2(cfg.hour)}:${pad2(cfg.minute)}`;
|
||||
switch (cfg.freq) {
|
||||
case "hourly":
|
||||
return `每小时 ${cfg.minute} 分`;
|
||||
case "weekly":
|
||||
return `每${WEEKDAY_LABELS[cfg.weekday] ?? "周一"} ${clock}`;
|
||||
case "monthly":
|
||||
return `每月 ${cfg.date} 日 ${clock}`;
|
||||
default:
|
||||
return `每天 ${clock}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** 由 cron 反推人类可读的执行频率描述(覆盖本页生成的所有 cron 形态)。 */
|
||||
export function describeCron(cron: string | null | undefined): string {
|
||||
const cfg = parseCron(cron ?? undefined);
|
||||
if (!cfg) return cron || "未设置";
|
||||
return describeScheduleConfig(cfg);
|
||||
}
|
||||
|
||||
/** 计算下一次执行时间;仅支持本页 schedule picker 生成的 5 段 cron 形态。 */
|
||||
export function nextCronDate(cron: string | null | undefined, from = new Date()): Date | null {
|
||||
const cfg = parseCron(cron ?? undefined);
|
||||
if (!cfg) return null;
|
||||
const candidate = new Date(from);
|
||||
candidate.setSeconds(0, 0);
|
||||
switch (cfg.freq) {
|
||||
case "hourly": {
|
||||
candidate.setMinutes(cfg.minute);
|
||||
if (candidate <= from) candidate.setHours(candidate.getHours() + 1);
|
||||
return candidate;
|
||||
}
|
||||
case "daily": {
|
||||
candidate.setHours(cfg.hour, cfg.minute, 0, 0);
|
||||
if (candidate <= from) candidate.setDate(candidate.getDate() + 1);
|
||||
return candidate;
|
||||
}
|
||||
case "weekly": {
|
||||
candidate.setHours(cfg.hour, cfg.minute, 0, 0);
|
||||
const day = candidate.getDay();
|
||||
let delta = (cfg.weekday - day + 7) % 7;
|
||||
if (delta === 0 && candidate <= from) delta = 7;
|
||||
candidate.setDate(candidate.getDate() + delta);
|
||||
return candidate;
|
||||
}
|
||||
case "monthly": {
|
||||
candidate.setHours(cfg.hour, cfg.minute, 0, 0);
|
||||
candidate.setDate(cfg.date);
|
||||
if (candidate <= from) candidate.setMonth(candidate.getMonth() + 1);
|
||||
return candidate;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDateTime(value: string | null | undefined): string {
|
||||
if (!value) return "—";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "—";
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function formatRelative(value: string | null | undefined): string {
|
||||
if (!value) return "—";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "—";
|
||||
const elapsed = Date.now() - date.getTime();
|
||||
if (elapsed < 60_000) return "刚刚";
|
||||
if (elapsed < 3_600_000) return `${Math.floor(elapsed / 60_000)} 分钟前`;
|
||||
if (elapsed < 86_400_000) return `${Math.floor(elapsed / 3_600_000)} 小时前`;
|
||||
if (elapsed < 7 * 86_400_000) return `${Math.floor(elapsed / 86_400_000)} 天前`;
|
||||
return formatDateTime(value);
|
||||
}
|
||||
|
||||
export function formatDuration(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms <= 0) return "—";
|
||||
const seconds = Math.round(ms / 1000);
|
||||
if (seconds < 60) return `${seconds} 秒`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const rest = seconds % 60;
|
||||
return rest ? `${minutes} 分 ${rest} 秒` : `${minutes} 分钟`;
|
||||
}
|
||||
|
||||
export function runStatusLabel(status: string | null | undefined): string {
|
||||
switch (status) {
|
||||
case "succeeded":
|
||||
return "执行成功";
|
||||
case "failed":
|
||||
return "执行失败";
|
||||
case "running":
|
||||
return "执行中";
|
||||
case "pending":
|
||||
return "排队中";
|
||||
default:
|
||||
return "暂无执行";
|
||||
}
|
||||
}
|
||||
|
||||
export type MatTaskBadge = {
|
||||
label: string;
|
||||
theme: "success" | "warning" | "danger" | "default";
|
||||
};
|
||||
|
||||
/** 任务卡右上角的状态徽标:以最近一次执行状态优先,其次启停。 */
|
||||
export function taskBadgeOf(task: MatTaskView): MatTaskBadge {
|
||||
const status = task.lastRun?.status;
|
||||
if (status === "running" || status === "pending") {
|
||||
return { label: "执行中", theme: "warning" };
|
||||
}
|
||||
if (!task.enabled) return { label: "已暂停", theme: "default" };
|
||||
if (status === "failed") return { label: "上次失败", theme: "danger" };
|
||||
if (status === "succeeded") return { label: "运行正常", theme: "success" };
|
||||
return { label: "已启用", theme: "success" };
|
||||
}
|
||||
|
||||
/** 席位配色槽位:按 agent_id 稳定散列到 0..7。 */
|
||||
export function seatColorIndex(agentId: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < agentId.length; i += 1) {
|
||||
hash = (hash * 31 + agentId.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return hash % 8;
|
||||
}
|
||||
|
||||
export function seatIndexOf(seats: MatSeat[], agentId: string): number {
|
||||
const index = seats.findIndex((seat) => seat.agent_id === agentId);
|
||||
return index >= 0 ? index : seats.length;
|
||||
}
|
||||
|
||||
export function defaultScheduleConfig(): ScheduleConfig {
|
||||
return { ...DEFAULT_SCHEDULE_CONFIG };
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/* ============================================================
|
||||
智能体星轨页 — 深色主题(星海蓝)
|
||||
只放颜色,结构与动画在 agents-orbit.css
|
||||
============================================================ */
|
||||
|
||||
.agents-orbit-page.ao-dark {
|
||||
color: #d9e8ff;
|
||||
background:
|
||||
radial-gradient(1100px 640px at 22% 40%, rgba(56, 130, 246, 0.18), transparent 62%),
|
||||
radial-gradient(880px 560px at 80% 26%, rgba(139, 92, 246, 0.14), transparent 60%),
|
||||
linear-gradient(158deg, #050b1e 0%, #081429 46%, #03060f 100%);
|
||||
}
|
||||
|
||||
/* 星点 */
|
||||
.agents-orbit-page.ao-dark::before {
|
||||
background-image:
|
||||
radial-gradient(1.6px 1.6px at 11% 16%, rgba(255, 255, 255, 0.55), transparent 55%),
|
||||
radial-gradient(1.2px 1.2px at 27% 74%, rgba(255, 255, 255, 0.35), transparent 55%),
|
||||
radial-gradient(1.4px 1.4px at 42% 26%, rgba(255, 255, 255, 0.4), transparent 55%),
|
||||
radial-gradient(1px 1px at 58% 82%, rgba(255, 255, 255, 0.3), transparent 55%),
|
||||
radial-gradient(1.6px 1.6px at 66% 12%, rgba(255, 255, 255, 0.5), transparent 55%),
|
||||
radial-gradient(1.2px 1.2px at 79% 58%, rgba(255, 255, 255, 0.35), transparent 55%),
|
||||
radial-gradient(1px 1px at 88% 34%, rgba(255, 255, 255, 0.3), transparent 55%),
|
||||
radial-gradient(1.4px 1.4px at 18% 88%, rgba(255, 255, 255, 0.35), transparent 55%),
|
||||
radial-gradient(1px 1px at 35% 48%, rgba(255, 255, 255, 0.25), transparent 55%),
|
||||
radial-gradient(1.2px 1.2px at 92% 78%, rgba(255, 255, 255, 0.3), transparent 55%);
|
||||
}
|
||||
|
||||
/* 顶部标题 */
|
||||
.agents-orbit-page.ao-dark .ao-header-title {
|
||||
color: #cfe4ff;
|
||||
text-shadow: 0 0 14px rgba(90, 160, 255, 0.5);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-dark .ao-header-link {
|
||||
border-color: rgba(110, 170, 255, 0.35);
|
||||
background: rgba(12, 28, 58, 0.7);
|
||||
color: #9fc4f5;
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-dark .ao-header-link:hover {
|
||||
color: #ffffff;
|
||||
border-color: rgba(140, 205, 255, 0.8);
|
||||
box-shadow: 0 0 14px rgba(90, 170, 255, 0.4);
|
||||
}
|
||||
|
||||
/* 中心圆盘 */
|
||||
.agents-orbit-page.ao-dark .ao-core {
|
||||
background: radial-gradient(
|
||||
circle at 34% 30%,
|
||||
rgba(96, 165, 250, 0.95),
|
||||
rgba(37, 99, 235, 0.55) 45%,
|
||||
rgba(8, 20, 45, 0.92) 100%
|
||||
);
|
||||
border-color: rgba(125, 190, 255, 0.7);
|
||||
box-shadow:
|
||||
0 0 46px rgba(64, 140, 255, 0.5),
|
||||
inset 0 0 34px rgba(120, 190, 255, 0.35);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-dark .ao-core-img {
|
||||
color: #eaf4ff;
|
||||
filter: drop-shadow(0 0 14px rgba(120, 190, 255, 0.85));
|
||||
}
|
||||
|
||||
/* 圆环族 */
|
||||
.agents-orbit-page.ao-dark .ao-ring--dash {
|
||||
border-color: rgba(110, 170, 255, 0.55);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-dark .ao-ring--arc {
|
||||
border-top-color: rgba(88, 200, 255, 0.9);
|
||||
border-right-color: rgba(88, 200, 255, 0.32);
|
||||
filter: drop-shadow(0 0 6px rgba(88, 200, 255, 0.55));
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-dark .ao-ring--ticks {
|
||||
border-color: rgba(110, 170, 255, 0.28);
|
||||
background-image: repeating-conic-gradient(
|
||||
from 0deg,
|
||||
rgba(120, 180, 255, 0.5) 0deg 0.8deg,
|
||||
transparent 0.8deg 6deg
|
||||
);
|
||||
}
|
||||
|
||||
/* 卫星 */
|
||||
.agents-orbit-page.ao-dark .ao-sat {
|
||||
background: radial-gradient(circle at 35% 30%, #ffffff, #7fd0ff 55%, #1f7dff);
|
||||
box-shadow:
|
||||
0 0 12px 3px rgba(110, 200, 255, 0.85),
|
||||
-14px 0 22px rgba(110, 200, 255, 0.35);
|
||||
}
|
||||
|
||||
/* 类型标签药丸 */
|
||||
.agents-orbit-page.ao-dark .ao-type-chip {
|
||||
color: #b9d7ff;
|
||||
background: rgba(12, 28, 58, 0.82);
|
||||
border-color: rgba(110, 170, 255, 0.35);
|
||||
box-shadow: 0 4px 16px rgba(4, 12, 32, 0.55);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-dark .ao-type-anchor:hover .ao-type-chip {
|
||||
color: #ffffff;
|
||||
border-color: rgba(140, 205, 255, 0.8);
|
||||
box-shadow: 0 0 18px rgba(90, 170, 255, 0.45);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-dark .ao-type-anchor.is-active .ao-type-chip {
|
||||
color: #06122b;
|
||||
background: linear-gradient(135deg, #66c4ff, #2f7dff);
|
||||
border-color: rgba(160, 220, 255, 0.9);
|
||||
box-shadow: 0 0 26px rgba(80, 170, 255, 0.65);
|
||||
}
|
||||
|
||||
/* 右侧面板 */
|
||||
.agents-orbit-page.ao-dark .ao-panel {
|
||||
background: rgba(10, 22, 48, 0.72);
|
||||
border-color: rgba(110, 170, 255, 0.22);
|
||||
box-shadow: 0 10px 40px rgba(2, 8, 24, 0.5);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-dark .ao-panel-head {
|
||||
border-bottom-color: rgba(110, 170, 255, 0.16);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-dark .ao-panel-title {
|
||||
color: #e8f2ff;
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-dark .ao-panel-sub {
|
||||
color: #8fb7e8;
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-dark .ao-agents::-webkit-scrollbar-thumb {
|
||||
background: rgba(110, 170, 255, 0.3);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-dark .ao-agent {
|
||||
border-color: rgba(110, 170, 255, 0.18);
|
||||
background: rgba(20, 42, 86, 0.5);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-dark .ao-agent:hover {
|
||||
border-color: rgba(130, 200, 255, 0.6);
|
||||
background: rgba(30, 58, 112, 0.65);
|
||||
box-shadow: 0 8px 24px rgba(20, 80, 200, 0.35);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-dark .ao-agent-icon {
|
||||
background: linear-gradient(135deg, #2f7dff, #7a5cff);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.25),
|
||||
0 4px 14px rgba(47, 125, 255, 0.4);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-dark .ao-agent-name {
|
||||
color: #d7e8ff;
|
||||
}
|
||||
|
||||
/* 加载 / 空态 */
|
||||
.agents-orbit-page.ao-dark .ao-state {
|
||||
color: #8fb7e8;
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-dark .ao-spinner {
|
||||
border-color: rgba(110, 170, 255, 0.25);
|
||||
border-top-color: #6fc2ff;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/* ============================================================
|
||||
智能体星轨页 — 浅色主题(云际蓝)
|
||||
只放颜色,结构与动画在 agents-orbit.css
|
||||
============================================================ */
|
||||
|
||||
.agents-orbit-page.ao-light {
|
||||
color: #1f3a63;
|
||||
background:
|
||||
radial-gradient(1100px 640px at 22% 40%, rgba(47, 125, 255, 0.1), transparent 62%),
|
||||
radial-gradient(880px 560px at 80% 26%, rgba(122, 92, 246, 0.08), transparent 60%),
|
||||
linear-gradient(158deg, #f4f8ff 0%, #e7f0ff 46%, #eef4fd 100%);
|
||||
}
|
||||
|
||||
/* 浅色下不渲染星点 */
|
||||
.agents-orbit-page.ao-light::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 顶部标题 */
|
||||
.agents-orbit-page.ao-light .ao-header-title {
|
||||
color: #164a96;
|
||||
text-shadow: 0 0 14px rgba(120, 170, 255, 0.35);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-light .ao-header-link {
|
||||
border-color: rgba(47, 125, 255, 0.4);
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
color: #2a66c2;
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-light .ao-header-link:hover {
|
||||
color: #0f3f96;
|
||||
border-color: rgba(47, 125, 255, 0.85);
|
||||
box-shadow: 0 4px 14px rgba(47, 125, 255, 0.22);
|
||||
}
|
||||
|
||||
/* 中心圆盘 */
|
||||
.agents-orbit-page.ao-light .ao-core {
|
||||
background: radial-gradient(
|
||||
circle at 34% 30%,
|
||||
#ffffff,
|
||||
#dbe9ff 45%,
|
||||
#b9d4ff 100%
|
||||
);
|
||||
border-color: rgba(90, 150, 240, 0.85);
|
||||
box-shadow:
|
||||
0 10px 34px rgba(47, 125, 255, 0.28),
|
||||
inset 0 0 30px rgba(160, 200, 255, 0.55);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-light .ao-core-img {
|
||||
color: #1f5fd6;
|
||||
filter: drop-shadow(0 4px 10px rgba(47, 125, 255, 0.35));
|
||||
}
|
||||
|
||||
/* 圆环族 */
|
||||
.agents-orbit-page.ao-light .ao-ring--dash {
|
||||
border-color: rgba(47, 125, 255, 0.45);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-light .ao-ring--arc {
|
||||
border-top-color: rgba(37, 110, 245, 0.85);
|
||||
border-right-color: rgba(37, 110, 245, 0.28);
|
||||
filter: drop-shadow(0 2px 5px rgba(47, 125, 255, 0.3));
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-light .ao-ring--ticks {
|
||||
border-color: rgba(47, 125, 255, 0.3);
|
||||
background-image: repeating-conic-gradient(
|
||||
from 0deg,
|
||||
rgba(47, 105, 220, 0.45) 0deg 0.8deg,
|
||||
transparent 0.8deg 6deg
|
||||
);
|
||||
}
|
||||
|
||||
/* 卫星 */
|
||||
.agents-orbit-page.ao-light .ao-sat {
|
||||
background: radial-gradient(circle at 35% 30%, #ffffff, #7fb4ff 55%, #2f7dff);
|
||||
box-shadow:
|
||||
0 0 10px 2px rgba(47, 125, 255, 0.55),
|
||||
-14px 0 20px rgba(47, 125, 255, 0.25);
|
||||
}
|
||||
|
||||
/* 类型标签药丸 */
|
||||
.agents-orbit-page.ao-light .ao-type-chip {
|
||||
color: #1e4fa8;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-color: rgba(47, 125, 255, 0.4);
|
||||
box-shadow: 0 4px 14px rgba(30, 80, 180, 0.14);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-light .ao-type-anchor:hover .ao-type-chip {
|
||||
color: #0f3f96;
|
||||
border-color: rgba(47, 125, 255, 0.85);
|
||||
box-shadow: 0 6px 18px rgba(47, 125, 255, 0.28);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-light .ao-type-anchor.is-active .ao-type-chip {
|
||||
color: #ffffff;
|
||||
background: linear-gradient(135deg, #4d9aff, #2f7dff);
|
||||
border-color: rgba(47, 125, 255, 0.95);
|
||||
box-shadow: 0 8px 24px rgba(47, 125, 255, 0.45);
|
||||
}
|
||||
|
||||
/* 右侧面板 */
|
||||
.agents-orbit-page.ao-light .ao-panel {
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
border-color: rgba(47, 125, 255, 0.25);
|
||||
box-shadow: 0 10px 32px rgba(30, 70, 160, 0.12);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-light .ao-panel-head {
|
||||
border-bottom-color: rgba(47, 125, 255, 0.18);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-light .ao-panel-title {
|
||||
color: #0f3a78;
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-light .ao-panel-sub {
|
||||
color: #5b7ea8;
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-light .ao-agents::-webkit-scrollbar-thumb {
|
||||
background: rgba(47, 125, 255, 0.3);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-light .ao-agent {
|
||||
border-color: rgba(47, 125, 255, 0.22);
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-light .ao-agent:hover {
|
||||
border-color: rgba(47, 125, 255, 0.6);
|
||||
background: #f0f7ff;
|
||||
box-shadow: 0 8px 22px rgba(47, 125, 255, 0.22);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-light .ao-agent-icon {
|
||||
background: linear-gradient(135deg, #2f7dff, #7a5cff);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.35),
|
||||
0 4px 12px rgba(47, 125, 255, 0.35);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-light .ao-agent-name {
|
||||
color: #1f3a63;
|
||||
}
|
||||
|
||||
/* 加载 / 空态 */
|
||||
.agents-orbit-page.ao-light .ao-state {
|
||||
color: #5b7ea8;
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-light .ao-spinner {
|
||||
border-color: rgba(47, 125, 255, 0.25);
|
||||
border-top-color: #2f7dff;
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
/* ============================================================
|
||||
智能体星轨页 — 基础结构 + 动画(不含任何颜色)
|
||||
颜色主题在 agents-orbit-dark.css / agents-orbit-light.css 两个文件里
|
||||
命名统一 ao- 前缀
|
||||
============================================================ */
|
||||
|
||||
/* ---------- 页面骨架:左舞台 / 右面板 1:1 ---------- */
|
||||
|
||||
.agents-orbit-page {
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 星点背景层(内容在主题文件里着色;浅色主题直接隐藏) */
|
||||
.agents-orbit-page::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
animation: ao-star-blink 7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ao-star-blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.65;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 顶部小标题 + 返回经典视图入口 */
|
||||
.ao-header {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
left: 22px;
|
||||
z-index: 6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.ao-header-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.ao-header-link {
|
||||
border: 1px solid;
|
||||
background: none;
|
||||
font-size: 12px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: color 0.25s, border-color 0.25s, box-shadow 0.25s;
|
||||
}
|
||||
|
||||
/* ---------- 左侧星轨舞台 ---------- */
|
||||
|
||||
.ao-stage {
|
||||
position: relative;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ao-root {
|
||||
position: relative;
|
||||
width: min(56vh, 520px);
|
||||
max-width: calc(100% - 48px);
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
/* 中心圆盘(核心) */
|
||||
.ao-core {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 32%;
|
||||
aspect-ratio: 1 / 1;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
border: 2px solid;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
/* 中心图片:上下摇晃(频率适中,约 3.2s 一个来回) */
|
||||
.ao-core-img {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: ao-float 3.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ao-float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(-7px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(9px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 圆环族 ----------
|
||||
由内到外:虚线环(54%) 顺时针 / 弧光环(70%) 逆时针 / 刻度环(97%) 缓慢顺时针 */
|
||||
|
||||
.ao-ring {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ao-ring--dash {
|
||||
width: 54%;
|
||||
aspect-ratio: 1 / 1;
|
||||
border: 2px dashed;
|
||||
animation: ao-spin-cw 18s linear infinite;
|
||||
}
|
||||
|
||||
.ao-ring--arc {
|
||||
width: 70%;
|
||||
aspect-ratio: 1 / 1;
|
||||
border: 3px solid transparent;
|
||||
animation: ao-spin-ccw 12s linear infinite;
|
||||
}
|
||||
|
||||
/* 最外层刻度圆圈:细边框 + 环形遮罩刻度(密码转盘的刻度感),
|
||||
缓慢自转,转盘属性刻度环 */
|
||||
.ao-ring--ticks {
|
||||
width: 97%;
|
||||
aspect-ratio: 1 / 1;
|
||||
border: 1px solid;
|
||||
-webkit-mask: radial-gradient(
|
||||
farthest-side,
|
||||
transparent calc(100% - 5px),
|
||||
#000 calc(100% - 4px)
|
||||
);
|
||||
mask: radial-gradient(
|
||||
farthest-side,
|
||||
transparent calc(100% - 5px),
|
||||
#000 calc(100% - 4px)
|
||||
);
|
||||
animation: ao-spin-cw 90s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ao-spin-cw {
|
||||
to {
|
||||
transform: translate(-50%, -50%) rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ao-spin-ccw {
|
||||
to {
|
||||
transform: translate(-50%, -50%) rotate(-360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 卫星:一个自转的轨道容器,卫星固定在轨道顶端;
|
||||
轨道直径与最外层刻度圆圈(97%)一致,卫星贴着最外圈环绕 */
|
||||
.ao-sat-orbit {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 97%;
|
||||
aspect-ratio: 1 / 1;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
animation: ao-orbit-cw 9s linear infinite;
|
||||
}
|
||||
|
||||
.ao-sat {
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
left: 50%;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-left: -7px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@keyframes ao-orbit-cw {
|
||||
to {
|
||||
transform: translate(-50%, -50%) rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 类型标签(密码转盘) ---------- */
|
||||
|
||||
/* 转盘裁剪容器:垂直中线以左(左半圆)是不可视区域,
|
||||
绕背补位的标签滑出边界即被裁掉、从另一端滑入才重新出现。
|
||||
上/右/下方向用负 inset 放宽裁剪框,不切到溢出 root 的标签。 */
|
||||
.ao-types {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 4;
|
||||
clip-path: inset(-80px -80px -80px 50%);
|
||||
}
|
||||
|
||||
/* 旋转臂:铺满整个 root、以圆心为轴旋转;标签挂在 93%/50% 处(刻度环内侧)。
|
||||
换角度时 CSS 过渡让标签沿圆弧运动,文字由锚点反向旋转保持水平。
|
||||
超界补位的标签角度会 ±360 走“背面长路”,全程经过被裁剪的左半圆。 */
|
||||
.ao-type-arm {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
transform-origin: 50% 50%;
|
||||
transition: transform 0.85s cubic-bezier(0.68, -0.12, 0.28, 1.08);
|
||||
}
|
||||
|
||||
.ao-type-anchor {
|
||||
position: absolute;
|
||||
left: 93%;
|
||||
top: 50%;
|
||||
pointer-events: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transform: translate(-50%, -50%) rotate(0deg);
|
||||
transition: transform 0.85s cubic-bezier(0.68, -0.12, 0.28, 1.08);
|
||||
}
|
||||
|
||||
/* 标签药丸:内层元素承担上下摇晃动画,不与定位 transform 冲突 */
|
||||
.ao-type-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 16px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
animation: ao-bob 3.6s ease-in-out infinite;
|
||||
transition: color 0.3s, border-color 0.3s, box-shadow 0.3s, background 0.3s;
|
||||
}
|
||||
|
||||
.ao-type-chip::before {
|
||||
content: "";
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
box-shadow: 0 0 8px currentColor;
|
||||
}
|
||||
|
||||
/* 激活位(正右侧中间)微微放大强调 */
|
||||
.ao-type-anchor.is-active .ao-type-chip {
|
||||
padding: 8px 18px;
|
||||
}
|
||||
|
||||
/* 类型较多时收缩标签尺寸,保持间距可辨 */
|
||||
.agents-orbit-page.ao-crowded .ao-type-chip {
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-crowded .ao-type-anchor.is-active .ao-type-chip {
|
||||
padding: 5px 12px;
|
||||
}
|
||||
|
||||
@keyframes ao-bob {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(4px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 右侧智能体面板(与舞台 1:1) ---------- */
|
||||
|
||||
.ao-panel {
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
margin: 16px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid;
|
||||
backdrop-filter: blur(10px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ao-panel-head {
|
||||
padding: 18px 20px 12px;
|
||||
border-bottom: 1px solid;
|
||||
}
|
||||
|
||||
.ao-panel-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.ao-panel-sub {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ao-agents {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px 20px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(108px, 1fr));
|
||||
gap: 14px;
|
||||
align-content: start;
|
||||
animation: ao-fade-in 0.5s ease both;
|
||||
}
|
||||
|
||||
.ao-agents::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.ao-agents::-webkit-scrollbar-thumb {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
@keyframes ao-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(14px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ao-agent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 8px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid;
|
||||
cursor: pointer;
|
||||
transition: transform 0.25s, box-shadow 0.25s, border-color 0.25s, background 0.25s;
|
||||
}
|
||||
|
||||
.ao-agent:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
/* 圆角正方形图标(取智能体名称首字) */
|
||||
.ao-agent-icon {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ao-agent-name {
|
||||
max-width: 100%;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---------- 加载 / 空态 ---------- */
|
||||
|
||||
.ao-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ao-spinner {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid;
|
||||
animation: ao-spin-cw-center 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ao-spin-cw-center {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 动效敏感用户降级 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.agents-orbit-page::before,
|
||||
.ao-core-img,
|
||||
.ao-ring--dash,
|
||||
.ao-ring--arc,
|
||||
.ao-ring--ticks,
|
||||
.ao-sat-orbit,
|
||||
.ao-type-chip,
|
||||
.ao-agents {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.ao-type-arm,
|
||||
.ao-type-anchor {
|
||||
transition-duration: 0.01s;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user