- Inject the site account Cookie (manual + auto-login session, session wins)
into the camofox profile before the first tab opens; password accounts
without a session run refresh-login first; API Key sites are skipped.
- Cookie expired after injection -> credential.status=expired; cookie
accounts go to waiting/login_required ("log in once, applies site-wide").
- Write back site-domain cookies from the browser on handover, session close,
every 10 min of activity, and from refresh-login when the stored cookie is
invalid (verified before saving). user_info.cookieOrigin records
source=browser; source shown as manual/login/browser in the origin view.
- New camofox plugin umt-cookies (GET/POST /umt/sessions/:userId/cookies
under the access key); profile dir 0700/files 0600 in the desktop image.
- Single entry: sources-identity/service/credentials.ts.
- Real check: HHClub cookie injected into a fresh profile -> home page
logged in (logout link present), 5 cookies read back.
Requires rebuilding umt-browser-desktop (docker compose build camofox &&
docker compose up -d camofox) to take effect.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
492 lines
19 KiB
TypeScript
492 lines
19 KiB
TypeScript
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 { 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 (
|
|
<div className="flex items-center gap-2 px-1">
|
|
<Switch id={id} checked={checked} onCheckedChange={onChange} />
|
|
<Label htmlFor={id} className="cursor-pointer text-sm font-normal text-muted-foreground">
|
|
先看方案再执行
|
|
</Label>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 新目标输入:"先看方案再执行"开关 + 自增高输入框(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<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);
|
|
});
|
|
};
|
|
return (
|
|
<div ref={box} className="space-y-1.5">
|
|
<PromptInput
|
|
value={goal}
|
|
onValueChange={setGoal}
|
|
onSubmit={(g) => onSubmit(g, { proposeFirst })}
|
|
pending={pending}
|
|
minRows={compact ? 2 : 3}
|
|
maxRows={10}
|
|
maxLength={4000}
|
|
aria-label="目标"
|
|
placeholder="例如:把 沙丘:第二部 的 4K 收进来"
|
|
submitLabel={pending ? "提交中" : "开始"}
|
|
header={
|
|
prefix ? (
|
|
<p
|
|
className="mx-1 mb-1 truncate rounded-lg border border-border/70 px-2.5 py-1 font-mono text-meta text-muted-foreground"
|
|
title={prefix}
|
|
>
|
|
{prefix}
|
|
</p>
|
|
) : null
|
|
}
|
|
toolbar={<ProposeFirstToggle checked={proposeFirst} onChange={setProposeFirst} />}
|
|
/>
|
|
<p className="px-1 text-meta text-muted-foreground" aria-live="polite">
|
|
{proposeFirst
|
|
? "Agent 先调查并给出方案,你选定后才执行。"
|
|
: "Agent 列出步骤后直接执行,需要审批的动作会停在对应步骤等你批准。"}{" "}
|
|
Enter 发送,Shift+Enter 换行。
|
|
</p>
|
|
{suggestions?.length ? (
|
|
<div className="pt-2">
|
|
<p className="mb-1.5 flex items-center gap-1.5 px-1 text-meta text-muted-foreground">
|
|
<Sparkles aria-hidden className="size-3.5" />
|
|
常用目标
|
|
</p>
|
|
<ul aria-label="快捷目标" className="flex flex-col gap-1.5">
|
|
{suggestions.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}…` : s}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function shortTokens(n: number | undefined) {
|
|
return n === undefined ? null : n >= 1000 ? `${(n / 1000).toFixed(1)}k token` : `${n} token`;
|
|
}
|
|
|
|
/** 状态 + 来源 + 时间 + 模型 */
|
|
function RunMeta({ run, status }: { run: RunSnapshot["run"]; status: AgentRunStatus }) {
|
|
return (
|
|
<div className="space-y-1.5">
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
<RunStatusBadge status={status} />
|
|
{isProposeFirst(run) ? <StatusBadge>先看方案再执行</StatusBadge> : null}
|
|
{agentMockEnabled() ? (
|
|
<StatusBadge tone="warning">
|
|
<FlaskConical className="size-3" />
|
|
mock 数据
|
|
</StatusBadge>
|
|
) : null}
|
|
</div>
|
|
<p className="text-meta break-words text-muted-foreground">
|
|
{TRIGGER_LABEL[run.trigger]} · {formatDateTime(run.createdAt)}
|
|
{run.model ? ` · ${run.provider ?? ""}/${run.model}` : ""}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 窄屏 / 抽屉的概况条:收起时一行(状态 · 步骤进度 · 当前步骤 · 用量),展开看步骤清单、子会话与预算。
|
|
* 不 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 calls = Math.max(r.toolCallCount, live.items.filter((i) => i.kind === "tool").length);
|
|
const usage = [`${calls} 次调用`, shortTokens(u.totalTokens)].filter(Boolean).join(" · ");
|
|
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-10 w-full min-w-0 cursor-pointer items-center gap-2 rounded-xl px-3 py-1.5 text-left text-meta outline-none transition-colors duration-micro hover:bg-foreground/[0.03] focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
<RunStatusBadge status={status} />
|
|
{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">
|
|
步骤 {done}/{steps.length}
|
|
</span>
|
|
</>
|
|
) : null}
|
|
<span className="min-w-0 flex-1 truncate text-muted-foreground">
|
|
{steps.length ? (current ? current.title : failed ? "有步骤失败" : "全部完成") : ""}
|
|
</span>
|
|
<span className="hidden shrink-0 text-muted-foreground tabular-nums sm:inline">{usage}</span>
|
|
<ChevronDown aria-hidden className={cn("size-3.5 shrink-0", CHEVRON_CLASS, open && "rotate-180")} />
|
|
</button>
|
|
{steps.length ? (
|
|
<div className="mx-3 mb-2 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: `${Math.round((done / steps.length) * 100)}%` }}
|
|
/>
|
|
</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">
|
|
<RunMeta run={r} status={status} />
|
|
<RunPlan plan={live.plan} active={active} fill />
|
|
<SubagentTree snapshot={snapshot} className="p-2.5" />
|
|
<div>
|
|
<p className="mb-2 text-meta text-muted-foreground">预算与用量</p>
|
|
<BudgetBar run={r} live={live} />
|
|
</div>
|
|
</div>
|
|
</Disclosure>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/** 已结束的会话:用同一个目标新建(可切"先看方案再执行") */
|
|
function NewFromGoal({ goal, onCreated }: { goal: string; onCreated: (runId: string) => void }) {
|
|
const create = useCreateAgentRun();
|
|
const [proposeFirst, setProposeFirst] = useState(false);
|
|
return (
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
<Button
|
|
type="button"
|
|
size="xs"
|
|
variant="outline"
|
|
disabled={create.isPending}
|
|
onClick={() =>
|
|
create.mutate(
|
|
{ goal, proposeFirst },
|
|
{ onSuccess: (run) => onCreated(run.id), onError: (e) => toast.error(errorMessage(e)) },
|
|
)
|
|
}
|
|
>
|
|
<RotateCcw />
|
|
{create.isPending ? "新建中…" : "用同目标新建"}
|
|
</Button>
|
|
<ProposeFirstToggle checked={proposeFirst} onChange={setProposeFirst} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 一次运行。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 <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) =>
|
|
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 (
|
|
<div className={cn("flex min-h-0 gap-4", className)}>
|
|
<RunAnnouncer status={shownStatus} finished={live.finished} />
|
|
<div className="@container flex min-h-0 min-w-0 flex-1 flex-col gap-3">
|
|
<RunOverviewBar
|
|
snapshot={run.data}
|
|
status={shownStatus}
|
|
live={live}
|
|
active={streaming || r.status === "waiting"}
|
|
className={cn(column, !compact && "lg:hidden")}
|
|
/>
|
|
|
|
{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">实时连接中断,正在重连。期间每 2.5 秒轮询一次。</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="Agent 会话记录"
|
|
>
|
|
<AgentConversation
|
|
run={r}
|
|
live={live}
|
|
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))
|
|
.map((a) => (
|
|
<ApprovalItem key={a.id} approval={a} />
|
|
))}
|
|
{finished ? <FinalReport run={r} report={report} live={live} /> : null}
|
|
</>
|
|
}
|
|
/>
|
|
</MessageScroller>
|
|
|
|
<div className={cn(column, "shrink-0 space-y-1")}>
|
|
<PromptInput
|
|
value={msg}
|
|
onValueChange={setMsg}
|
|
onSubmit={send}
|
|
pending={append.isPending}
|
|
loading={streaming}
|
|
onStop={() => setConfirmStop(true)}
|
|
stopLabel={abort.isPending ? "停止中…" : "停止"}
|
|
minRows={1}
|
|
maxRows={6}
|
|
maxLength={8000}
|
|
aria-label={ended ? "追加消息续跑" : "追加消息"}
|
|
placeholder={ended ? "追加消息,在本会话里续跑" : "补充或纠正(运行中也可追加)"}
|
|
submitLabel={ended ? "续跑" : "发送"}
|
|
toolbar={
|
|
ended ? (
|
|
<NewFromGoal goal={r.goal} onCreated={created} />
|
|
) : isProposeFirst(r) ? (
|
|
<span className="px-1 text-meta text-muted-foreground">先看方案再执行</span>
|
|
) : null
|
|
}
|
|
/>
|
|
<p className="px-1 text-meta text-muted-foreground">
|
|
{r.status === "waiting" && (r.summary === "等待选择方案" || r.summary === "等待审批")
|
|
? `${r.summary}:在会话里的卡片上处理,会话随即继续。`
|
|
: ended
|
|
? "续跑沿用本会话的上下文与预算。"
|
|
: "追加的消息在本轮结束后读取。"}{" "}
|
|
Enter 发送,Shift+Enter 换行。
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{compact ? null : (
|
|
<aside
|
|
aria-label="会话概况"
|
|
className="hidden w-72 shrink-0 flex-col gap-4 overflow-y-auto border-l border-[var(--shell-border)] pl-4 lg:flex"
|
|
>
|
|
<RunMeta run={r} status={shownStatus} />
|
|
<RunPlan plan={live.plan} active={streaming || r.status === "waiting"} fill />
|
|
<SubagentTree snapshot={run.data} className="p-2.5" />
|
|
<section aria-label="预算与用量" className="mt-auto rounded-xl border border-border/70 p-3">
|
|
<p className="mb-2 text-meta text-muted-foreground">预算与用量</p>
|
|
<BudgetBar run={r} live={live} stacked />
|
|
</section>
|
|
</aside>
|
|
)}
|
|
|
|
<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>
|
|
);
|
|
}
|