import { useQueryClient } from "@tanstack/react-query";
import { type AgentRunStatus, EventType } from "@umt/contracts";
import { ChevronDown, FlaskConical, ListChecks, RefreshCw, RotateCcw, Sparkles, WifiOff } from "lucide-react";
import { useId, useRef, useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import {
type AgentSessionOptions,
agentKeys,
agentMockEnabled,
isProposeFirst,
isRunActive,
type RunSnapshot,
TRIGGER_LABEL,
useAbortAgentRun,
useAgentRun,
useAppendAgentMessage,
useCreateAgentRun,
useRunApprovals,
useRunInvocationAudit,
} from "~/api/agent.ts";
import { CHEVRON_CLASS, Disclosure } from "~/components/agentui/disclosure.tsx";
import { MessageScroller } from "~/components/agentui/message-scroller.tsx";
import { PromptInput } from "~/components/agentui/prompt-input.tsx";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { formatUsd } from "~/components/schedules/labels.ts";
import { Button } from "~/components/ui/button.tsx";
import { Label } from "~/components/ui/label.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
import { useSse, useSseStatus } from "~/lib/sse.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
import {
AgentConversation,
ApprovalItem,
BudgetBar,
FinalReport,
RunAnnouncer,
RunStatusBadge,
runPlanIds,
} from "./agent-conversation.tsx";
import { RunPlan } from "./agent-interaction.tsx";
import { SubagentTree } from "./agent-subagent-tree.tsx";
import { type AgentStreamState, useAgentStream } from "./use-agent-stream.ts";
/** "先看方案再执行"开关(会话选项,默认关:自动化优先,按步骤直接执行,需授权的动作在步骤处审批) */
export function ProposeFirstToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
const id = useId();
return (
);
}
/** 新目标输入:"先看方案再执行"开关 + 自增高输入框(Enter 发送,Shift+Enter 换行) */
export function GoalComposer({
onSubmit,
pending,
prefix,
defaultGoal = "",
compact = false,
suggestions,
}: {
onSubmit: (goal: string, opts: AgentSessionOptions) => void;
pending: boolean;
/** 上下文前缀(只读展示,提交时拼在目标前) */
prefix?: string;
defaultGoal?: string;
compact?: boolean;
/** 快捷目标:点一下填入输入框(不直接提交),光标停在末尾 */
suggestions?: string[];
}) {
const [goal, setGoal] = useState(defaultGoal);
const [proposeFirst, setProposeFirst] = useState(false);
const box = useRef(null);
const fill = (text: string) => {
setGoal(text);
requestAnimationFrame(() => {
const ta = box.current?.querySelector("textarea");
if (!ta) return;
ta.focus();
ta.setSelectionRange(text.length, text.length);
});
};
return (
onSubmit(g, { proposeFirst })}
pending={pending}
minRows={compact ? 2 : 3}
maxRows={10}
maxLength={4000}
aria-label="目标"
placeholder="例如:把 沙丘:第二部 的 4K 收进来"
submitLabel={pending ? "提交中" : "开始"}
header={
prefix ? (
{prefix}
) : null
}
toolbar={}
/>
{proposeFirst
? "智能体先调查并给出方案,你选定后才执行。"
: "智能体列出步骤后直接执行,需要审批的动作会停在对应步骤等你批准。"}
{suggestions?.length ? (
常用目标
{suggestions.map((s) => (
-
))}
) : null}
);
}
/** 状态 + 来源 + 时间 + 模型 */
function RunMeta({ run, status }: { run: RunSnapshot["run"]; status: AgentRunStatus }) {
return (
{isProposeFirst(run) ? 先看方案再执行 : null}
{agentMockEnabled() ? (
mock 数据
) : null}
{TRIGGER_LABEL[run.trigger]} · {formatDateTime(run.createdAt)}
);
}
/**
* 窄屏 / 抽屉的概况条:收起时一行(状态 · 步骤进度 · 当前步骤 · 用量),展开看步骤清单、子会话与预算。
* 不 sticky、不压在会话上,方案卡与审批卡始终在会话流里完整可见。
*/
function RunOverviewBar({
snapshot,
status,
live,
active,
className,
}: {
snapshot: RunSnapshot;
status: AgentRunStatus;
live: AgentStreamState;
active: boolean;
className?: string;
}) {
const id = useId();
const [open, setOpen] = useState(false);
const r = snapshot.run;
const steps = live.plan?.steps ?? [];
const done = steps.filter((s) => s.status === "done" || s.status === "skipped").length;
const current = steps.find((s) => s.status === "running") ?? steps.find((s) => s.status === "pending");
const failed = steps.some((s) => s.status === "failed");
const u = { ...r.usage, ...(live.usage ?? {}) };
const usage = u.costUsd ? `费用约 ${formatUsd(u.costUsd)}` : "";
return (
{steps.length ? (
) : null}
);
}
/** 已结束的会话:用同一个目标新建(可切"先看方案再执行") */
function NewFromGoal({ goal, onCreated }: { goal: string; onCreated: (runId: string) => void }) {
const create = useCreateAgentRun();
const [proposeFirst, setProposeFirst] = useState(false);
return (
);
}
/**
* 一次运行。page:会话列 + 右侧栏(状态、步骤、子会话,底部预算与用量);窄于 lg 时侧栏换成顶部可折叠概况条。
* compact(抽屉):只有概况条。会话列统一最大宽度,输入区固定在底部(停止、续跑、用同目标新建)。
*/
export function AgentRunPanel({
runId,
compact = false,
className,
onCreated,
}: {
runId: string;
compact?: boolean;
className?: string;
/** 用同目标新建后:缺省跳到新会话页;抽屉里改成在抽屉内打开 */
onCreated?: (runId: string) => void;
}) {
const run = useAgentRun(runId);
const active = isRunActive(run.data?.run.status);
const live = useAgentStream(runId, active || !run.data);
const abort = useAbortAgentRun();
const append = useAppendAgentMessage();
const navigate = useNavigate();
const [msg, setMsg] = useState("");
const [confirmStop, setConfirmStop] = useState(false);
const sseStatus = useSseStatus();
const qc = useQueryClient();
const toolCount = live.items.filter((i) => i.kind === "tool" && i.call.status !== "running").length;
const audit = useRunInvocationAudit(runId, toolCount > 0, toolCount);
const approvals = useRunApprovals(runPlanIds(live.items, run.data?.report ?? null));
// 会话内审批卡已在对应步骤处显示,这里只补本次运行计划上的其它审批
const inline = new Set(live.items.flatMap((i) => (i.kind === "approval" ? [i.view.approvalId] : [])));
// run 状态变化(queued → running → 终态)经 SSE 推送:到达即刷新快照,不等 3 秒轮询
// 子会话:本会话收到子会话提示(agent.tool_call / agent.spawn)或任一子会话 / 父会话状态变化时刷新会话树
const related = new Set([
runId,
...(run.data?.children ?? []).map((c) => c.id),
...(run.data?.parent ? [run.data.parent.id] : []),
]);
useSse(
[EventType.agentRunStatus, EventType.agentToolCall],
(e) => {
const p = e.payload as { runId?: string; capabilityId?: string } | null;
if (!p?.runId) return;
const hit =
e.type === EventType.agentRunStatus
? related.has(p.runId)
: p.runId === runId && p.capabilityId === "agent.spawn";
if (hit) void qc.invalidateQueries({ queryKey: agentKeys.run(runId) });
},
!agentMockEnabled(),
);
if (run.isPending) return ;
if (run.error || !run.data) return void run.refetch()} />;
const { run: r, report, messages } = run.data;
const finished = !isRunActive(r.status) || live.finished;
const streaming = active && !live.finished;
const shownStatus: AgentRunStatus =
// 事件流已结束但快照还没刷到终态时按运行中显示;waiting(等待下载/入库核验)照实显示
(live.finished && (r.status === "queued" || r.status === "running")) ||
(r.status === "queued" && live.events.length > 0)
? "running"
: r.status;
const ended = !isRunActive(r.status);
const created = onCreated ?? ((id: string) => navigate(`/console/agent/${id}${agentMockEnabled() ? "?mock=1" : ""}`));
const send = (text: string) =>
append.mutate(
{ id: r.id, text },
{
onSuccess: () => {
setMsg("");
void run.refetch();
},
onError: (err) => toast.error(errorMessage(err)),
},
);
const column = "mx-auto w-full max-w-[88ch]";
return (
{streaming && sseStatus === "reconnecting" ? (
实时连接中断,正在重连。期间每 2.5 秒轮询一次。
) : null}
{live.error ? (
事件读取失败:{live.error}
) : null}
({ at: m.createdAt, text: m.text, pending: m.status === "pending" }))}
after={
<>
{approvals.items
.filter((a) => !inline.has(a.id))
.map((a) => (
))}
{finished ? : null}
>
}
/>
setConfirmStop(true)}
stopLabel={abort.isPending ? "停止中…" : "停止"}
minRows={1}
maxRows={6}
maxLength={8000}
aria-label={ended ? "追加消息续跑" : "追加消息"}
placeholder={ended ? "追加消息,在本会话里续跑" : "补充或纠正(运行中也可追加)"}
submitLabel={ended ? "续跑" : "发送"}
toolbar={
ended ? (
) : isProposeFirst(r) ? (
先看方案再执行
) : null
}
/>
{r.status === "waiting" && (r.summary === "等待选择方案" || r.summary === "等待审批")
? `${r.summary}:在会话里的卡片上处理,会话随即继续。`
: ended
? "续跑沿用本会话的上下文与预算。"
: "追加的消息在本轮结束后读取。"}{" "}
Enter 发送,Shift+Enter 换行。
{compact ? null : (
)}
abort.mutateAsync(r.id).then(
() => toast.success("已请求停止"),
(e) => {
toast.error(errorMessage(e));
throw e;
},
)
}
/>
);
}