import { useQueryClient } from "@tanstack/react-query"; import { EventType } from "@umt/contracts"; import { FlaskConical, RefreshCw, Sparkles, WifiOff } from "lucide-react"; import { useId, useRef, useState } from "react"; import { toast } from "sonner"; import { type AgentSessionOptions, agentKeys, agentMockEnabled, isProposeFirst, isRunActive, useAbortAgentRun, useAgentRun, useAppendAgentMessage, useRunApprovals, useRunInvocationAudit, } from "~/api/agent.ts"; 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 { 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 { useAgentStream } from "./use-agent-stream.ts"; const TRIGGER_LABEL = { user: "用户发起", event: "事件触发", schedule: "定时", subagent: "子会话" } as const; /** "先看方案再执行"开关(会话选项,默认关:自动化优先,按步骤直接执行,需授权的动作在步骤处审批) */ 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 ? "Agent 先调查并给出方案,你选定后才执行。" : "Agent 列出步骤后直接执行,需要审批的动作会停在对应步骤等你批准。"}{" "} Enter 发送,Shift+Enter 换行。

{suggestions?.length ? (

常用目标

    {suggestions.map((s) => (
  • ))}
) : null}
); } /** 一次运行:头部状态 + 预算条 + 步骤清单 + 流式对话(方案选择 / 审批卡在对应步骤处)+ 最终汇报 + 输入框 */ export function AgentRunPanel({ runId, compact = false, className, }: { runId: string; compact?: boolean; className?: string; }) { 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 [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 proposeFirst = isProposeFirst(r); const shownStatus = // 事件流已结束但快照还没刷到终态时按运行中显示;waiting(等待下载/入库核验)照实显示 (live.finished && (r.status === "queued" || r.status === "running")) || (r.status === "queued" && live.events.length > 0) ? "running" : r.status; const send = (text: string) => append.mutate( { id: r.id, text }, { onSuccess: () => { setMsg(""); void run.refetch(); }, onError: (err) => toast.error(errorMessage(err)), }, ); return (
{proposeFirst ? 先看方案再执行 : null} {TRIGGER_LABEL[r.trigger]} · {formatDateTime(r.createdAt)} {r.model ? ` · ${r.provider ?? ""}/${r.model}` : ""} {agentMockEnabled() ? ( mock 数据 ) : null}
{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="追加消息" placeholder={streaming ? "补充或纠正(运行中也可追加)" : "追加消息"} />

{r.status === "waiting" && (r.summary === "等待选择方案" || r.summary === "等待审批") ? `${r.summary}:在上面的卡片里处理,会话随即继续。` : "追加的消息在本轮结束后读取。"}{" "} Enter 发送,Shift+Enter 换行{streaming ? ",停止 = 结束本次运行" : ""}。

abort.mutateAsync(r.id).then( () => toast.success("已请求停止"), (e) => { toast.error(errorMessage(e)); throw e; }, ) } />
); }