import { useQueryClient } from "@tanstack/react-query";
import { type AgentRunStatus, EventType } from "@umt/contracts";
import { Bot, ChevronDown, FlaskConical, ListChecks, RefreshCw, RotateCcw, WifiOff } from "lucide-react";
import { type ReactNode, useId, useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import {
type AgentSessionOptions,
agentKeys,
agentMockEnabled,
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 { TechInfo } from "~/components/console/tech-info.tsx";
import { formatUsd } from "~/components/schedules/labels.ts";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { useSse, useSseStatus } from "~/lib/sse.ts";
import { formatTokens } from "~/lib/tokens.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
import {
AgentConversation,
ApprovalItem,
ResultLine,
RunAnnouncer,
RunStatusBadge,
runPlanIds,
} from "./agent-conversation.tsx";
import { RunPlan } from "./agent-interaction.tsx";
import { readDraft, runDraftKey, writeDraft } from "./agent-nav.ts";
import { shortGoalTitle } from "./agent-store.ts";
import { ParentRunLink, SubagentChip, SubagentNodesContext } from "./agent-subagents.tsx";
import { callCounts, sessionStatsParts, stepProgress, subagentViews } from "./conversation-model.ts";
import { COMMANDS, PROPOSE_FIRST, PROPOSE_FIRST_MESSAGE, parseCommand } from "./slash-commands.ts";
import { type AgentStreamState, useAgentStream } from "./use-agent-stream.ts";
/** 新目标的提交内容:`/先给方案` 开头 → proposeFirst(只作用于这一次) */
export function goalFromInput(text: string): { goal: string; opts: AgentSessionOptions } | null {
const { command, rest } = parseCommand(text);
if (!rest) return null;
return { goal: rest, opts: { proposeFirst: command === PROPOSE_FIRST.name } };
}
/**
* 新对话(抽屉和列表页是同一个组件):上面一句说明,下面开场建议和输入框。开场建议点一下直接发出;
* `/先给方案` 让这一次先给方案。上下文(当前页面的对象)显示成输入框上的名称标签,可以去掉。
*/
export function GoalComposer({
onSubmit,
pending,
context,
onClearContext,
defaultGoal = "",
suggestions,
draftKey,
className,
}: {
onSubmit: (goal: string, opts: AgentSessionOptions) => void;
pending: boolean;
/** 上下文标签(对象名称) */
context?: string | null;
onClearContext?: () => void;
defaultGoal?: string;
/** 开场建议:输入框为空时显示 */
suggestions?: string[];
/** 草稿记在这个键下(没有预填目标时恢复);发送成功后由调用方清掉 */
draftKey?: string;
className?: string;
}) {
const [goal, setGoalState] = useState(() => defaultGoal || readDraft(draftKey));
const setGoal = (text: string) => {
setGoalState(text);
writeDraft(draftKey, text);
};
const submit = (text: string) => {
const g = goalFromInput(text);
if (!g) {
toast.error("在指令后面写上目标");
return;
}
onSubmit(g.goal, g.opts);
};
return (
有什么要我做的?
说一个目标就行,比如收一部电影、查一个下载为什么不动。需要批准的操作会先停下来问你。
{suggestions?.length && !goal.trim() ? (
{suggestions.map((s) => (
-
))}
) : null}
);
}
/**
* 顶部概况条:一行「步骤 4/5 · 当前:核对下载任务」;展开看步骤清单、子会话和技术信息(模型、预算、用量、ID)。
* 不 sticky、不压在会话上;没有步骤和子会话时只在运行中显示状态。
*/
/**
* 会话统计(AG-4):概况条上方恒定一行小字——工具调用、MCP、已用 token、距上限、费用;
* 运行中随事件流实时更新,结束后固定为快照里的最终值。
*/
export function RunStatsLine({
snapshot,
live,
className,
}: {
snapshot: RunSnapshot;
live: AgentStreamState;
className?: string;
}) {
const r = snapshot.run;
const u = { ...r.usage, ...(live.usage ?? {}) };
const n = callCounts(live.items.flatMap((i) => (i.kind === "tool" ? [i.call] : [])));
const parts = sessionStatsParts(
{
toolCalls: Math.max(r.toolCallCount - n.mcp, n.tools),
mcpCalls: n.mcp,
totalTokens: u.totalTokens ?? null,
maxTokens: r.budget.maxTokens ?? null,
costUsd: u.costUsd ?? null,
},
{ tokens: formatTokens, usd: formatUsd },
);
return (
{/* 窄屏折行,不截断(费用在最后) */}
{parts.map((t, i) => (
{i ? "· " : ""}
{t}
))}
);
}
export function RunOverviewBar({
snapshot,
live,
active,
className,
}: {
snapshot: RunSnapshot;
live: AgentStreamState;
active: boolean;
className?: string;
}) {
const id = useId();
const [open, setOpen] = useState(false);
const r = snapshot.run;
const steps = live.plan?.steps ?? [];
const progress = stepProgress(steps);
const failed = progress.failedStep;
// 没有步骤清单就不显示(状态在顶部一行,子会话在会话流里)
if (!steps.length) return null;
const u = { ...r.usage, ...(live.usage ?? {}) };
return (
{TRIGGER_LABEL[r.trigger]} · {formatDateTime(r.createdAt)}
);
}
/**
* 一次运行(抽屉和会话页是同一个组件,compact = 抽屉):顶部一行状态与统计 → 步骤清单(有才显示)→
* 会话流(子会话卡片在派出的位置)→ 底部输入区。结束后顶部有「重新运行」;输入框只有 `/先给方案` 一个指令。
*/
export function AgentRunPanel({
runId,
compact = false,
className,
onCreated,
actions,
}: {
runId: string;
compact?: boolean;
className?: string;
/** 统计行右侧的操作(会话页的「在侧边打开」) */
actions?: ReactNode;
/** 重新运行之后:缺省跳到新会话页;抽屉里改成在抽屉内打开 */
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 create = useCreateAgentRun();
const navigate = useNavigate();
// 草稿按会话记:切到别的会话再回来还在,不会串到别的会话
const [msg, setMsgState] = useState(() => readDraft(runDraftKey(runId)));
const setMsg = (text: string) => {
setMsgState(text);
writeDraft(runDraftKey(runId), text);
};
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 秒轮询
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 rerun = () =>
create.mutate(
{ goal: r.goal, proposeFirst: r.scope.proposeFirst === true },
{ onSuccess: (n) => created(n.id), onError: (e) => toast.error(errorMessage(e)) },
);
const send = (text: string) => {
const { command, rest } = parseCommand(text);
const body = command === PROPOSE_FIRST.name ? `${PROPOSE_FIRST_MESSAGE}${rest ? `\n${rest}` : ""}` : text;
append.mutate(
{ id: r.id, text: body },
{
onSuccess: () => {
setMsg("");
void run.refetch();
},
onError: (err) => toast.error(errorMessage(err)),
},
);
};
const column = "mx-auto w-full max-w-[88ch]";
const subagents = subagentViews(live.items);
const nodes = new Map((run.data.children ?? []).map((c) => [c.id, c] as const));
// 只标需要注意的状态;正常结束不加徽章(结果在会话末尾)
const showBadge = shownStatus !== "succeeded";
return (
{agentMockEnabled() && !compact ? (
演示数据
) : null}
{run.data.parent ? (
) : null}
{/* 顶部一行:状态 · 统计 · 子会话 · 操作;窄的时候折行 */}
{showBadge ?
: null}
{ended ? (
) : null}
{actions}
{streaming && sseStatus === "reconnecting" ? (
实时连接中断,正在重连。
) : null}
{live.error ? (
事件读取失败:{live.error}
) : null}
[c.id, shortGoalTitle(c.goal, 24)] as [string, string]),
...(run.data.parent
? [[run.data.parent.id, shortGoalTitle(run.data.parent.goal, 24)] as [string, string]]
: []),
]}
audit={audit.data}
userMessages={messages.map((m) => ({ at: m.createdAt, text: m.text, pending: m.status === "pending" }))}
after={
<>
{approvals.items
.filter((a) => !inline.has(a.id) && a.status === "pending")
.map((a) => (
))}
{finished ? : null}
>
}
/>
setConfirmStop(true)}
stopLabel={abort.isPending ? "停止中…" : "停止"}
minRows={1}
maxRows={6}
maxLength={8000}
aria-label="发消息"
placeholder={ended ? "继续对话" : "补充或纠正"}
commands={COMMANDS}
/>
abort.mutateAsync(r.id).then(
() => toast.success("正在停止"),
(e) => {
toast.error(errorMessage(e));
throw e;
},
)
}
/>
);
}