Files
Cyrene_v2/frontend/web/src/admin/modules/Agents.tsx
T
AskaEth 14e76b6480
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(admin): 流程图方向——Flow 页自下而上(最新在上),Backthink/Agents 图块最新在左
主人要求:① 全链路流程页从下往上渲染(时序自下而上 ⇒ 最新一定在最上)+ 容器高度自动拓高;② 主动思考页保持原渲染方式但最新放最左(容器默认露最左);③ 追加:改布局后多路渲染仍要合适。

- 共享布局唯一入口 flow/layout.ts:新增枚举 FlowOrientation(newest-top|newest-left) + layoutFlow(graph, orientation);排布拆成「抽象网格→像素」两步,方向开关只在 toPixelNode/toPixelBand;泳道成道逻辑零改动
- 边几何抽到新文件 flow/edges.ts:只按方向选锚点,带回 fromId/toId(不重排图数据),悬空边如实丢弃
- FlowGraphView 的 orientation 是**必填** prop(漏传=编译错误);画布不限高、无 overflow-y(不内部裁切,纵向随图生长);图例加方向小字
- 三处调用同一套布局:Flow=newest-top、Backthink=newest-left、Agents=newest-left(后者是我按同一档裁定,一行可改)
- 顺带修复:画布最大列现在把「刚开出、还没跑出步骤的泳道」最小宽度算进去(否则泳道框被裁到画布外)
- 测试 593 checks(orientation 逻辑 131 + 渲染 338 + lanes 40 + 既有回归);变异 7 条全红,含「边端点连到错节点」「容器限高裁切」「空泳道被裁」
- 多路:lanes.test 期望随新方向镜像更新,泳道仍成道;并行路数据互相覆盖的已知问题归后续分桶切片
2026-10-05 19:51:45 +08:00

194 lines
8.5 KiB
TypeScript

import { useState } from "react";
import { useAppStore } from "../../store/useAppStore";
import type { AgentInfo, AgentStatus, FlowGraph } from "../../lib/types";
import { Notice, PageHeader } from "../components";
import { Button, EmptyState, Surface, Tag, type TagTone } from "../../components/primitives";
import { sendUp } from "../../lib/ws";
import { useAuditStore } from "../audit";
import { Icon } from "../../components/Icon";
import { FlowGraphView } from "./FlowGraph";
import { FlowSnapshotHint } from "./flow/FlowSnapshotHint";
import { useFlowSnapshot, type FlowGraphSource } from "./flow/useFlowSnapshot";
import { MAIN_SESSION_ID } from "../../store/sessionCache";
const statusTone: Record<AgentStatus, { label: string; tone: TagTone }> = {
idle: { label: "空闲", tone: "neutral" },
running: { label: "运行中", tone: "accent" },
blocked: { label: "阻塞", tone: "warn" },
done: { label: "完成", tone: "ok" },
error: { label: "错误", tone: "err" },
};
function AgentCard({ agent }: { agent: AgentInfo }): React.ReactNode {
const meta = statusTone[agent.status] ?? statusTone.idle;
const log = useAuditStore((s) => s.log);
const [hint, setHint] = useState<string | null>(null);
const query = () => {
const ok = sendUp({ type: "task_output_query", data: { taskId: agent.id } });
log(`agent.query:${agent.id}`, ok ? "ok" : "error");
setHint(ok ? "已请求上下文,输出见下方「任务日志」。" : "WS 未连接,无法查询。");
};
const kill = () => {
log(`agent.kill:${agent.id}`, "error");
setHint("终止子代理需后端 M10+ 能力接入,当前为只读展示,未执行任何操作。");
};
return (
<Surface className="space-y-2">
<div className="flex items-center gap-2">
<span className={`h-2.5 w-2.5 rounded-full ${agent.status === "running" ? "bg-[var(--mu-accent)] mu-blink" : "bg-[var(--mu-surface-5)]"}`} />
<span className="text-sm font-medium">{agent.name}</span>
{agent.role ? <span className="text-xs text-[var(--mu-text-lo)]">{agent.role}</span> : null}
<Tag tone={meta.tone} className="ml-auto">{meta.label}</Tag>
</div>
{typeof agent.progress === "number" ? (
<div className="h-1.5 w-full overflow-hidden rounded-[var(--mu-radius-pill)] bg-[var(--mu-surface-4)]">
<div className="h-full rounded-[var(--mu-radius-pill)] bg-[var(--mu-accent)]" style={{ width: `${Math.min(100, Math.max(0, agent.progress))}%` }} />
</div>
) : null}
{agent.thinking ? (
<pre className="max-h-24 overflow-auto whitespace-pre-wrap rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-2 py-1.5 text-xs text-[var(--mu-text-mid)]">{agent.thinking}</pre>
) : null}
{agent.tools?.map((t, i) => (
<div key={i} className="inline-flex items-center gap-1 text-xs text-[var(--mu-text-lo)]">
<Icon name="gear" size={12} />{t.name}{t.ok === false ? <Icon name="close" size={12} className="ml-0.5" /> : ""}
</div>
))}
{agent.message ? <div className="text-xs text-[var(--mu-text-mid)]">{agent.message}</div> : null}
<div className="flex gap-2">
<Button size="sm" variant="ghost" onClick={query}>
上下文查询
</Button>
<Button size="sm" variant="outline" onClick={kill}>
终止
</Button>
</div>
{hint ? <div className="text-xs text-[var(--mu-text-lo)]">{hint}</div> : null}
</Surface>
);
}
export function AgentsModule(): React.ReactNode {
const agents = useAppStore((s) => s.agents);
const activeSessionId = useAppStore((s) => s.activeSessionId);
// 与 Flow 页同一条补拉(契约 v0.71):本页读的也是「激活会话」的图,
// 刷新过页面后实时帧已经错过,不补拉就永远停在空态。
const graphSource = useFlowSnapshot(activeSessionId || MAIN_SESSION_ID);
const flowGraph = useAppStore((s) => s.flowGraph);
const taskLogs = useAppStore((s) => s.taskLogs);
const clearTaskLog = useAppStore((s) => s.clearTaskLog);
const log = useAuditStore((s) => s.log);
const [tab, setTab] = useState<"main" | "sub">("main");
const [respawnHint, setRespawnHint] = useState(false);
const subAgents = agents.filter((a) => a.id !== "main");
const list = tab === "main" ? agents.filter((a) => a.id === "main") : subAgents;
return (
<div className="space-y-5">
<PageHeader
title="Agent 运行监控"
subtitle="主会话专属页签 + 子代理:状态/进度/思维链/工具链/流程图/后台任务/agent 消息流"
actions={
<Button
variant="soft"
onClick={() => {
log("agent.respawn", "error");
setRespawnHint(true);
}}
>
重派
</Button>
}
/>
{respawnHint ? (
<Notice tone="warn">子代理重派需后端 M10+ 能力接入,当前未启用;可先通过「上下文查询」与任务日志查看已运行子代理。</Notice>
) : null}
<div className="flex items-center gap-2">
<TabPill active={tab === "main"} onClick={() => setTab("main")}>主会话</TabPill>
<TabPill active={tab === "sub"} onClick={() => setTab("sub")}>子代理 ({subAgents.length})</TabPill>
</div>
<AgentsFlowBlock
graph={flowGraph}
sessionId={activeSessionId || MAIN_SESSION_ID}
source={graphSource}
/>
{list.length === 0 ? (
<EmptyState icon={<Icon name="agent" size={32} />} title={tab === "main" ? "无主会话 Agent" : "暂无子代理"} />
) : (
<div className="space-y-3">
{list.map((a) => (
<AgentCard key={a.id} agent={a} />
))}
</div>
)}
<Surface className="space-y-2">
<h3 className="text-sm font-semibold">Agent 消息流 / 后台任务日志</h3>
{Object.keys(taskLogs).length === 0 ? (
<div className="text-xs text-[var(--mu-text-lo)]">暂无任务日志。</div>
) : (
Object.entries(taskLogs).map(([id, text]) => (
<div key={id} className="rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] p-2">
<div className="mb-1 flex items-center gap-2 text-xs">
<Tag tone="accent">{id}</Tag>
<button className="ml-auto text-[var(--mu-text-lo)]" onClick={() => clearTaskLog(id)}>清空</button>
</div>
<pre className="max-h-40 overflow-auto whitespace-pre-wrap font-mono text-xs text-[var(--mu-text-mid)]">{text}</pre>
</div>
))
)}
<Notice>
任务日志实时来自 task_output 事件;「上下文查询」通过 task_output_query 上行帧查询真实输出(已接后端)。终止 / 重派需后端 M10+ 提供,当前为只读展示。
</Notice>
</Surface>
</div>
);
}
/** 流程图这一块(有图就画、没图就诚实空态)。props 驱动(不读 store),
* 因此可被真实渲染测试直接喂两种输入(见 flowSnapshot.render.test.ts 头注释)。
*
* 空态文案里必须说明**两条**来源都查过(实时帧 + 最近一轮快照,契约 v0.71):
* 否则用户分不清「本会话没跑过带 span 的轮次」与「图源没接线」。 */
export function AgentsFlowBlock({
graph,
sessionId,
source,
}: {
graph: FlowGraph | null;
sessionId: string;
source: FlowGraphSource;
}): React.ReactNode {
if (!graph) {
return (
<Notice>暂无流程图数据 —— 本会话还没有跑过带 span 的轮次(实时帧与「最近一轮快照」都为空)。发起一次会话后此处会显示链路图。</Notice>
);
}
return (
<Surface gap="sm">
{/* 与后台思考的图块同一档:横向渲染 + 最新在最左(FV0),
容器默认露出最左边 ⇒ 打开就先看到最新那拍。 */}
<FlowGraphView graph={graph} orientation="newest-left" />
<FlowSnapshotHint sessionId={graph.sessionId ?? sessionId} source={source ?? "live"} />
</Surface>
);
}
function TabPill({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }): React.ReactNode { return (
<button
onClick={onClick}
className={`rounded-[var(--mu-radius)] px-3 py-1.5 text-xs font-medium transition-all duration-200 ease-[var(--mu-spring)] ${
active ? "bg-[var(--mu-accent)]/20 text-[var(--mu-accent-hi)]" : "bg-[var(--mu-surface-4)] text-[var(--mu-text-mid)]"
}`}
>
{children}
</button>
);
}