feat(browser): unify site credentials with browser profiles (BR-2)

- 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]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 15:25:07 +08:00
1 parent 901b01d1a1
commit 28974d1e1a
58 files changed
+2843 -1112

No files matched your search

+260 -96
View File
@@ -1,7 +1,8 @@
import { useQueryClient } from "@tanstack/react-query";
import { EventType } from "@umt/contracts";
import { FlaskConical, RefreshCw, Sparkles, WifiOff } from "lucide-react";
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,
@@ -9,12 +10,16 @@ import {
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";
@@ -37,9 +42,7 @@ import {
} 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;
import { type AgentStreamState, useAgentStream } from "./use-agent-stream.ts";
/** "先看方案再执行"开关(会话选项,默认关:自动化优先,按步骤直接执行,需授权的动作在步骤处审批) */
export function ProposeFirstToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
@@ -140,21 +143,160 @@ export function GoalComposer({
);
}
/** 一次运行:头部状态 + 预算条 + 步骤清单 + 流式对话(方案选择 / 审批卡在对应步骤处)+ 最终汇报 + 输入框 */
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();
@@ -190,13 +332,14 @@ export function AgentRunPanel({
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 =
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(
@@ -210,101 +353,122 @@ export function AgentRunPanel({
},
);
const column = "mx-auto w-full max-w-[88ch]";
return (
<div className={cn("flex min-h-0 flex-col gap-3", className)}>
<div className={cn("flex min-h-0 gap-4", className)}>
<RunAnnouncer status={shownStatus} finished={live.finished} />
<div className="flex flex-wrap items-center gap-2">
<RunStatusBadge status={shownStatus} />
{proposeFirst ? <StatusBadge>先看方案再执行</StatusBadge> : null}
<span className="min-w-0 text-meta text-muted-foreground">
{TRIGGER_LABEL[r.trigger]} · {formatDateTime(r.createdAt)}
{r.model ? ` · ${r.provider ?? ""}/${r.model}` : ""}
</span>
{agentMockEnabled() ? (
<StatusBadge tone="warning">
<FlaskConical className="size-3" />
mock 数据
</StatusBadge>
) : null}
</div>
<div className={cn("rounded-xl border border-border/70 p-3", compact && "p-2.5")}>
<BudgetBar run={r} live={live} />
</div>
<SubagentTree snapshot={run.data} />
<RunPlan plan={live.plan} active={streaming || r.status === "waiting"} compact={compact} />
{streaming && sseStatus === "reconnecting" ? (
<div
role="status"
className="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="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-64 flex-1", compact && "min-h-48")}
viewportClassName="pr-1"
busy={streaming}
label="Agent 会话记录"
>
<AgentConversation
run={r}
<div className="@container flex min-h-0 min-w-0 flex-1 flex-col gap-3">
<RunOverviewBar
snapshot={run.data}
status={shownStatus}
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}
</>
}
active={streaming || r.status === "waiting"}
className={cn(column, !compact && "lg:hidden")}
/>
</MessageScroller>
<div className="space-y-1.5">
<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="追加消息"
placeholder={streaming ? "补充或纠正(运行中也可追加)" : "追加消息"}
/>
<p className="px-1 text-meta text-muted-foreground">
{r.status === "waiting" && (r.summary === "等待选择方案" || r.summary === "等待审批")
? `${r.summary}:在上面的卡片里处理,会话随即继续。`
: "追加的消息在本轮结束后读取。"}{" "}
Enter 发送,Shift+Enter 换行{streaming ? ",停止 = 结束本次运行" : ""}。
</p>
{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}