- 在智能体页面按 Ctrl+J / 点智能体入口聚焦页面输入框,不再在抽屉里叠一份同一会话 - 会话页统计行右侧加「在侧边打开」:回到进智能体页面之前的页面,会话在抽屉里接着看 - 窄屏顶栏按钮与快捷键、悬浮按钮共用同一套切换 - 抽屉关闭钮放进标题栏按钮行(原来绝对定位,比其它按钮低 5px) - 关闭抽屉后焦点回到打开前的元素(快捷键打开没有触发元素,原来落到 body) Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
519 lines
19 KiB
TypeScript
519 lines
19 KiB
TypeScript
import { useQueryClient } from "@tanstack/react-query";
|
||
import { type AgentRunStatus, EventType } from "@umt/contracts";
|
||
import { ChevronDown, FlaskConical, ListChecks, RefreshCw, WifiOff } from "lucide-react";
|
||
import { type ReactNode, useId, useRef, 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 { SubagentTree } from "./agent-subagent-tree.tsx";
|
||
import { callCounts, sessionStatsParts, stepProgress } from "./conversation-model.ts";
|
||
import {
|
||
NEW_RUN_COMMANDS,
|
||
PROPOSE_FIRST,
|
||
PROPOSE_FIRST_MESSAGE,
|
||
parseCommand,
|
||
RERUN,
|
||
RUN_COMMANDS,
|
||
} 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, NEW_RUN_COMMANDS);
|
||
if (!rest) return null;
|
||
return { goal: rest, opts: { proposeFirst: command === PROPOSE_FIRST.name } };
|
||
}
|
||
|
||
/**
|
||
* 新目标输入:只有输入框和发送;`/先给方案` 让这一次先给方案。空态下面 3 个快捷目标,点一下填进输入框
|
||
* (以空格或冒号结尾的是半句,光标停在末尾等补完)。上下文(当前页面的对象)显示成名称标签,可以去掉。
|
||
*/
|
||
export function GoalComposer({
|
||
onSubmit,
|
||
pending,
|
||
context,
|
||
onClearContext,
|
||
defaultGoal = "",
|
||
suggestions,
|
||
draftKey,
|
||
}: {
|
||
onSubmit: (goal: string, opts: AgentSessionOptions) => void;
|
||
pending: boolean;
|
||
/** 上下文标签(对象名称) */
|
||
context?: string | null;
|
||
onClearContext?: () => void;
|
||
defaultGoal?: string;
|
||
/** 快捷目标:输入框为空时显示 */
|
||
suggestions?: string[];
|
||
/** 草稿记在这个键下(没有预填目标时恢复);发送成功后由调用方清掉 */
|
||
draftKey?: string;
|
||
}) {
|
||
const [goal, setGoalState] = useState(() => defaultGoal || readDraft(draftKey));
|
||
const setGoal = (text: string) => {
|
||
setGoalState(text);
|
||
writeDraft(draftKey, text);
|
||
};
|
||
const box = useRef<HTMLDivElement>(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);
|
||
});
|
||
};
|
||
const submit = (text: string) => {
|
||
const g = goalFromInput(text);
|
||
if (!g) {
|
||
toast.error("在指令后面写上目标");
|
||
return;
|
||
}
|
||
onSubmit(g.goal, g.opts);
|
||
};
|
||
return (
|
||
<div ref={box} className="flex flex-col gap-3">
|
||
<PromptInput
|
||
value={goal}
|
||
onValueChange={setGoal}
|
||
onSubmit={submit}
|
||
pending={pending}
|
||
minRows={2}
|
||
maxRows={10}
|
||
maxLength={4000}
|
||
aria-label="目标"
|
||
placeholder="例如:把沙丘:第二部的 4K 收进来"
|
||
submitLabel={pending ? "提交中" : "开始"}
|
||
commands={NEW_RUN_COMMANDS}
|
||
context={context}
|
||
onClearContext={onClearContext}
|
||
/>
|
||
{suggestions?.length && !goal.trim() ? (
|
||
<ul aria-label="快捷目标" className="flex flex-col gap-1.5">
|
||
{suggestions.slice(0, 3).map((s) => (
|
||
<li key={s}>
|
||
<button
|
||
type="button"
|
||
onClick={() => fill(s)}
|
||
className="w-full cursor-pointer rounded-xl border border-border/70 px-3 py-2 text-left text-caption text-foreground/90 outline-none transition-colors duration-micro hover:bg-foreground/[0.04] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||
>
|
||
{s.endsWith(" ") || s.endsWith(":") ? `${s.trimEnd()}…` : s}
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 顶部概况条:一行「步骤 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 (
|
||
<section aria-label="会话统计" className={cn("shrink-0", className)}>
|
||
{/* 窄屏折行,不截断(费用在最后) */}
|
||
<p data-slot="run-stats" className="flex flex-wrap gap-x-1.5 px-1 text-meta text-muted-foreground tabular-nums">
|
||
{parts.map((t, i) => (
|
||
<span key={t} className="whitespace-nowrap">
|
||
{i ? "· " : ""}
|
||
{t}
|
||
</span>
|
||
))}
|
||
</p>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
export 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 progress = stepProgress(steps);
|
||
const failed = progress.failedStep;
|
||
const family = (snapshot.children?.length ?? 0) > 0 || !!snapshot.parent;
|
||
if (!steps.length && !family && !isRunActive(status)) return null;
|
||
const u = { ...r.usage, ...(live.usage ?? {}) };
|
||
return (
|
||
<section aria-label="会话概况" className={cn("shrink-0 rounded-xl border border-border/70", className)}>
|
||
<button
|
||
type="button"
|
||
aria-expanded={open}
|
||
aria-controls={id}
|
||
onClick={() => setOpen((v) => !v)}
|
||
className="flex min-h-9 w-full min-w-0 cursor-pointer items-center gap-2 rounded-xl px-3 py-1 text-left text-meta outline-none transition-colors duration-micro hover:bg-foreground/[0.03] focus-visible:ring-2 focus-visible:ring-ring"
|
||
>
|
||
{isRunActive(status) ? <RunStatusBadge status={status} /> : null}
|
||
{steps.length ? (
|
||
<>
|
||
<ListChecks
|
||
aria-hidden
|
||
className={cn("size-3.5 shrink-0 text-muted-foreground", failed && "text-destructive")}
|
||
/>
|
||
<span className="shrink-0 text-foreground tabular-nums">
|
||
步骤 {progress.done}/{progress.total}
|
||
</span>
|
||
<span className="min-w-0 flex-1 truncate text-muted-foreground">{progress.status}</span>
|
||
</>
|
||
) : (
|
||
<span className="min-w-0 flex-1 truncate text-muted-foreground">
|
||
{family ? `${snapshot.children?.length ?? 0} 个子会话` : ""}
|
||
</span>
|
||
)}
|
||
<ChevronDown aria-hidden className={cn("size-3.5 shrink-0", CHEVRON_CLASS, open && "rotate-180")} />
|
||
</button>
|
||
{steps.length ? (
|
||
<div className="mx-3 mb-1.5 h-0.5 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden>
|
||
<div
|
||
className={cn(
|
||
"h-full rounded-full transition-[width] duration-layout ease-standard motion-reduce:transition-none",
|
||
failed ? "bg-destructive" : "bg-foreground/60",
|
||
)}
|
||
style={{ width: `${progress.pct}%` }}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
<Disclosure id={id} open={open}>
|
||
<div className="max-h-[50dvh] space-y-3 overflow-y-auto border-t border-border/60 p-3">
|
||
<RunPlan plan={live.plan} active={active} fill />
|
||
<SubagentTree snapshot={snapshot} className="p-2.5" />
|
||
<p className="text-meta text-muted-foreground">
|
||
{TRIGGER_LABEL[r.trigger]} · {formatDateTime(r.createdAt)}
|
||
</p>
|
||
<TechInfo
|
||
items={[
|
||
{ label: "模型", value: r.model ? `${r.provider ? `${r.provider}/` : ""}${r.model}` : null },
|
||
{
|
||
label: "费用",
|
||
value: u.costUsd
|
||
? `约 ${formatUsd(u.costUsd)}${r.budget.maxCostUsd ? ` / 上限 ${formatUsd(r.budget.maxCostUsd)}` : ""}`
|
||
: null,
|
||
},
|
||
{ label: "token", value: u.totalTokens !== undefined ? formatTokens(u.totalTokens) : null },
|
||
{
|
||
label: "工具调用",
|
||
value: `${r.toolCallCount}${r.budget.maxToolCalls ? ` / ${r.budget.maxToolCalls}` : ""}`,
|
||
},
|
||
{ label: "会话 ID", value: r.id, wide: true },
|
||
]}
|
||
/>
|
||
</div>
|
||
</Disclosure>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 一次运行:顶部概况条 → 会话流(统一最大宽度)→ 底部输入区(只有输入框和发送 / 停止)。
|
||
* 输入区的 `/重跑` 用同一个目标新开;`/先给方案` 让智能体先给方案。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 <LoadingState rows={4} />;
|
||
if (run.error || !run.data) return <ErrorState plain error={run.error} onRetry={() => 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) => {
|
||
const { command, rest } = parseCommand(text, RUN_COMMANDS);
|
||
if (command === RERUN.name) {
|
||
create.mutate(
|
||
{ goal: r.goal, proposeFirst: r.scope.proposeFirst === true },
|
||
{
|
||
onSuccess: (n) => {
|
||
setMsg("");
|
||
created(n.id);
|
||
},
|
||
onError: (e) => toast.error(errorMessage(e)),
|
||
},
|
||
);
|
||
return;
|
||
}
|
||
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]";
|
||
|
||
return (
|
||
<div className={cn("flex min-h-0 flex-col gap-3", className)}>
|
||
<RunAnnouncer status={shownStatus} finished={live.finished} />
|
||
{agentMockEnabled() && !compact ? (
|
||
<div className={column}>
|
||
<StatusBadge tone="warning">
|
||
<FlaskConical className="size-3" />
|
||
演示数据
|
||
</StatusBadge>
|
||
</div>
|
||
) : null}
|
||
{actions ? (
|
||
<div className={cn(column, "-my-1 flex min-w-0 items-center gap-2")}>
|
||
<RunStatsLine snapshot={run.data} live={live} className="min-w-0 flex-1" />
|
||
<div className="flex shrink-0 items-center gap-1">{actions}</div>
|
||
</div>
|
||
) : (
|
||
<RunStatsLine snapshot={run.data} live={live} className={column} />
|
||
)}
|
||
<RunOverviewBar
|
||
snapshot={run.data}
|
||
status={shownStatus}
|
||
live={live}
|
||
active={streaming || r.status === "waiting"}
|
||
className={column}
|
||
/>
|
||
|
||
{streaming && sseStatus === "reconnecting" ? (
|
||
<div
|
||
role="status"
|
||
className={cn(
|
||
column,
|
||
"flex items-center gap-2 rounded-xl border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning",
|
||
)}
|
||
>
|
||
<WifiOff aria-hidden className="size-4 shrink-0" />
|
||
<span className="min-w-0 flex-1">实时连接中断,正在重连。</span>
|
||
</div>
|
||
) : null}
|
||
{live.error ? (
|
||
<div
|
||
role="alert"
|
||
className={cn(
|
||
column,
|
||
"flex flex-wrap items-center gap-2 rounded-xl border border-destructive/40 bg-destructive/10 px-3 py-2 text-caption text-destructive",
|
||
)}
|
||
>
|
||
<span className="min-w-0 flex-1 break-words">事件读取失败:{live.error}</span>
|
||
<Button size="xs" variant="outline" onClick={live.retry}>
|
||
<RefreshCw />
|
||
重试
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
|
||
<MessageScroller
|
||
className={cn("min-h-48 flex-1", compact && "min-h-40")}
|
||
viewportClassName="pr-1"
|
||
contentClassName={column}
|
||
busy={streaming}
|
||
label="智能体会话记录"
|
||
>
|
||
<AgentConversation
|
||
run={r}
|
||
live={live}
|
||
knownNames={[
|
||
...(run.data.children ?? []).map((c) => [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) => (
|
||
<ApprovalItem key={a.id} approval={a} />
|
||
))}
|
||
{finished ? (
|
||
<ResultLine
|
||
run={r}
|
||
report={report}
|
||
live={live}
|
||
subagents={(run.data.children ?? []).filter((c) => c.parentRunId === r.id).length}
|
||
showCost={false}
|
||
/>
|
||
) : null}
|
||
</>
|
||
}
|
||
/>
|
||
</MessageScroller>
|
||
|
||
<div className={cn(column, "shrink-0")}>
|
||
<PromptInput
|
||
value={msg}
|
||
onValueChange={setMsg}
|
||
onSubmit={send}
|
||
pending={append.isPending || create.isPending}
|
||
loading={streaming}
|
||
onStop={() => setConfirmStop(true)}
|
||
stopLabel={abort.isPending ? "停止中…" : "停止"}
|
||
minRows={1}
|
||
maxRows={6}
|
||
maxLength={8000}
|
||
aria-label="发消息"
|
||
placeholder={ended ? "接着说,输入 / 看指令" : "补充或纠正"}
|
||
commands={RUN_COMMANDS}
|
||
/>
|
||
</div>
|
||
|
||
<ConfirmDialog
|
||
open={confirmStop}
|
||
onOpenChange={setConfirmStop}
|
||
title="停止这次运行?"
|
||
description="后续步骤不再执行,已完成的动作(如已提交的下载)不会撤销。"
|
||
confirmLabel="停止运行"
|
||
destructive
|
||
onConfirm={() =>
|
||
abort.mutateAsync(r.id).then(
|
||
() => toast.success("已请求停止"),
|
||
(e) => {
|
||
toast.error(errorMessage(e));
|
||
throw e;
|
||
},
|
||
)
|
||
}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|