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

@@ -51,6 +51,7 @@ import { StreamingCursor, StreamingResponse } from "~/components/agentui/streami
import { ToolApproval } from "~/components/agentui/tool-approval.tsx";
import { ToolResult, ToolResultOutput, type ToolResultStatus } from "~/components/agentui/tool-result.tsx";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
import { InlineApproval, OptionsCard } from "./agent-interaction.tsx";
@@ -148,6 +149,38 @@ export function authDenial(call: ToolCallView): string | null {
/** 被服务端拒绝(白名单拦截或授权校验失败) */
export const isDenied = (call: ToolCallView) => call.status === "denied" || authDenial(call) !== null;
/**
* 工具卡收起时那一行的摘要:错误取 message;数组 / items 取条数;对象取前几个简单字段;
* 其余截断原文。只做展示,不做判断(纯函数,单测覆盖)。
*/
export function toolSummary(call: Pick<ToolCallView, "status" | "outputSummary">): string | null {
const raw = call.outputSummary?.trim();
if (!raw) return call.status === "running" ? "等待结果…" : null;
const clip = (t: string) => (t.length > 120 ? `${t.slice(0, 120)}…` : t);
let v: unknown;
try {
v = JSON.parse(raw);
} catch {
return clip(raw.replace(/\s+/g, " "));
}
if (Array.isArray(v)) return `${v.length} 条`;
if (v && typeof v === "object") {
const o = v as Record<string, unknown>;
const err = o.error as { message?: unknown; code?: unknown } | undefined;
if (err && typeof err === "object") return clip(String(err.message ?? err.code ?? "失败"));
for (const k of ["summary", "message"]) if (typeof o[k] === "string") return clip(o[k] as string);
for (const k of ["items", "results", "releases", "candidates"])
if (Array.isArray(o[k])) return `${(o[k] as unknown[]).length} 条结果`;
const simple = Object.entries(o)
.filter(([, x]) => ["string", "number", "boolean"].includes(typeof x))
.slice(0, 3)
.map(([k, x]) => `${k}: ${String(x)}`);
if (simple.length) return clip(simple.join(" · "));
return `${Object.keys(o).length} 个字段`;
}
return clip(String(v));
}
/** 工具调用卡片:脱敏输入/输出折叠、耗时、scope/风险徽标、幂等键(审计);服务端拒绝时换醒目拒绝态 */
export function ToolCallCard({
call,
@@ -198,7 +231,7 @@ export function ToolCallCard({
extra={call.recovery ? <StatusBadge tone="warning">恢复补记</StatusBadge> : null}
/>
}
summary={call.outputSummary ?? undefined}
summary={toolSummary(call) ?? undefined}
>
{call.inputSummary ? <ToolResultOutput label="输入(已脱敏)">{call.inputSummary}</ToolResultOutput> : null}
{call.outputSummary ? (
@@ -384,7 +417,112 @@ export function pairAudit(calls: ToolCallView[], audit: InvocationAudit[] | unde
return out;
}
/** 会话流:用户目标 → 流式文本/思考/工具卡片/拒绝/压缩 → 结束;追加的用户消息按时间插入 */
// ── 消息分组:同一助手回合(两条用户消息 / 系统提示之间)的文本、思考、工具卡归为一组,时间戳只在组头;
// 组内连续 ≥3 条已结束的思考 / 工具调用再折叠成一行"活动"(被拒绝、运行中的保持展开,醒目可见) ──
export type ConvEntry = {
key: string;
at: string;
role: "user" | "assistant" | "system";
/** 可并入"活动"折叠(已结束的思考 / 工具调用) */
foldable: boolean;
/** 折叠摘要里的名字(能力名 / "思考") */
label?: string;
failed?: boolean;
node: ReactNode;
};
export type ConvPart = { kind: "item"; entry: ConvEntry } | { kind: "fold"; key: string; entries: ConvEntry[] };
export type ConvBlock =
| { kind: "single"; entry: ConvEntry }
| { kind: "turn"; key: string; at: string; parts: ConvPart[] };
function foldRuns(parts: ConvPart[], min: number): ConvPart[] {
const out: ConvPart[] = [];
let run: ConvEntry[] = [];
const flush = () => {
if (run.length >= min) out.push({ kind: "fold", key: `f${run[0]?.key}`, entries: run });
else for (const e of run) out.push({ kind: "item", entry: e });
run = [];
};
for (const p of parts) {
if (p.kind === "item" && p.entry.foldable) run.push(p.entry);
else {
flush();
out.push(p);
}
}
flush();
return out;
}
/** 纯函数(单测覆盖):按时间排好的条目 → 用户/系统单条 + 助手回合组 */
export function groupConversation(entries: ConvEntry[], minFold = 3): ConvBlock[] {
const out: ConvBlock[] = [];
let turn: Extract<ConvBlock, { kind: "turn" }> | null = null;
for (const e of entries) {
if (e.role !== "assistant") {
turn = null;
out.push({ kind: "single", entry: e });
continue;
}
if (!turn) {
turn = { kind: "turn", key: `t${e.key}`, at: e.at, parts: [] };
out.push(turn);
}
turn.parts.push({ kind: "item", entry: e });
}
for (const b of out) if (b.kind === "turn") b.parts = foldRuns(b.parts, minFold);
return out;
}
function clockText(iso: string) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const sameDay = new Date().toDateString() === d.toDateString();
return sameDay
? d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false })
: formatDateTime(iso);
}
/** 折叠的一串活动:一行摘要(次数 + 能力名预览 + 失败数),展开看每张卡 */
function ActivityFold({ entries }: { entries: ConvEntry[] }) {
const id = useId();
const [open, setOpen] = useState(false);
const tools = entries.filter((e) => e.label !== "思考").length;
const thoughts = entries.length - tools;
const failed = entries.filter((e) => e.failed).length;
const names = [...new Set(entries.filter((e) => e.label && e.label !== "思考").map((e) => e.label))];
return (
<div className="w-full min-w-0 rounded-xl border border-border/60 bg-foreground/[0.015]">
<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.5 text-left text-meta text-muted-foreground outline-none transition-colors duration-micro hover:bg-foreground/[0.03] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<Layers aria-hidden className="size-3.5 shrink-0" />
<span className="shrink-0 text-foreground/85 tabular-nums">
{tools ? `${tools} 次工具调用` : ""}
{tools && thoughts ? "," : ""}
{thoughts ? `思考 ${thoughts} 段` : ""}
</span>
{failed ? <span className="shrink-0 text-destructive tabular-nums">失败 {failed}</span> : null}
<span className="min-w-0 flex-1 truncate">{names.slice(0, 4).join(" → ")}</span>
<ChevronDown aria-hidden className={cn("size-3.5 shrink-0", CHEVRON_CLASS, open && "rotate-180")} />
</button>
<Disclosure id={id} open={open}>
<div className="flex flex-col gap-2 border-t border-border/50 p-2">
{entries.map((e) => (
<div key={e.key}>{e.node}</div>
))}
</div>
</Disclosure>
</div>
);
}
/** 会话流:用户目标 → 助手回合(流式文本/思考/工具卡片/方案/审批)→ 结束;追加的用户消息按时间插入 */
export function AgentConversation({
run,
live,
@@ -413,16 +551,28 @@ export function AgentConversation({
const lastIndex = live.items.length - 1;
const lastText = live.items.map((i) => i.kind).lastIndexOf("text");
type Row = { key: string; at: string; node: ReactNode };
const rows: Row[] = live.items.map((it, idx) => {
const entries: ConvEntry[] = [];
live.items.forEach((it, idx) => {
const streaming = active && idx === lastIndex;
const anim = fresh(idx);
let node: ReactNode = null;
const wrap = (node: ReactNode) =>
anim ? (
<div className="animate-in fade-in-0 slide-in-from-bottom-1 duration-base ease-pop motion-reduce:animate-none">
{node}
</div>
) : (
node
);
switch (it.kind) {
case "text":
node = (
<AssistantRow animateIn={anim}>
entries.push({
key: it.key,
at: it.at,
role: "assistant",
foldable: false,
node: wrap(
<StreamingResponse
className="max-w-[72ch]"
status={streaming ? "streaming" : "complete"}
copyText={it.text}
announce={false}
@@ -430,77 +580,128 @@ export function AgentConversation({
>
{/* 流式中按原文显示(半截 Markdown 会闪),结束后按安全子集渲染 */}
{streaming ? it.text : <Markdown text={it.text} />}
</StreamingResponse>
</AssistantRow>
);
</StreamingResponse>,
),
});
break;
case "thinking":
node = (
<AssistantRow animateIn={anim} avatar={false}>
<ThinkingBlock text={it.text} streaming={streaming} />
</AssistantRow>
);
entries.push({
key: it.key,
at: it.at,
role: "assistant",
foldable: !streaming,
label: "思考",
node: wrap(<ThinkingBlock text={it.text} streaming={streaming} />),
});
break;
case "tool":
node = (
<AssistantRow animateIn={anim} avatar={false}>
<ToolCallCard call={it.call} audit={auditByCall.get(it.call.toolCallId)} />
</AssistantRow>
);
case "tool": {
const denied = isDenied(it.call);
entries.push({
key: it.key,
at: it.call.startedAt,
role: "assistant",
foldable: !denied && it.call.status !== "running" && idx !== lastIndex,
label: capabilityMeta(it.call.capabilityId)?.title ?? it.call.capabilityId,
failed: it.call.status === "failed",
node: wrap(<ToolCallCard call={it.call} audit={auditByCall.get(it.call.toolCallId)} />),
});
break;
}
case "compaction":
node = (
<MessageMarker>
<Layers aria-hidden className="size-3.5 shrink-0" />
上下文压缩({COMPACT_REASON[it.reason] ?? it.reason})·{" "}
{it.done ? (COMPACT_STATUS[it.status ?? "completed"] ?? it.status) : "进行中…"}
</MessageMarker>
);
entries.push({
key: it.key,
at: it.at,
role: "system",
foldable: false,
node: (
<MessageMarker>
<Layers aria-hidden className="size-3.5 shrink-0" />
上下文压缩({COMPACT_REASON[it.reason] ?? it.reason})·{" "}
{it.done ? (COMPACT_STATUS[it.status ?? "completed"] ?? it.status) : "进行中…"}
</MessageMarker>
),
});
break;
case "options":
node = (
<AssistantRow animateIn={anim} avatar={false}>
<OptionsCard runId={run.id} view={it.view} waiting={waiting} />
</AssistantRow>
);
entries.push({
key: it.key,
at: it.at,
role: "assistant",
foldable: false,
node: wrap(<OptionsCard runId={run.id} view={it.view} waiting={waiting} />),
});
break;
case "approval":
node = (
<AssistantRow animateIn={anim} avatar={false}>
<InlineApproval view={it.view} stepTitle={stepTitle(it.view.stepId)} />
</AssistantRow>
);
entries.push({
key: it.key,
at: it.at,
role: "assistant",
foldable: false,
node: wrap(<InlineApproval view={it.view} stepTitle={stepTitle(it.view.stepId)} />),
});
break;
case "end":
node = it.ok ? null : <EndNotice item={it} />;
if (!it.ok)
entries.push({ key: it.key, at: it.at, role: "system", foldable: false, node: <EndNotice item={it} /> });
break;
}
return { key: it.key, at: it.kind === "tool" ? it.call.startedAt : it.at, node };
});
for (const m of userMessages)
rows.push({
entries.push({
key: `u${m.at}`,
at: m.at,
role: "user",
foldable: false,
node: <UserMessage text={m.text} at={m.at} note={m.pending ? "已送达,等待 Agent 读取" : undefined} animateIn />,
});
rows.sort((a, b) => a.at.localeCompare(b.at));
entries.sort((a, b) => a.at.localeCompare(b.at));
const blocks = groupConversation(entries);
const tail = live.items[lastIndex]?.kind;
const typing =
active && tail !== "text" && tail !== "thinking" ? (
<span className="inline-flex items-center gap-2 text-meta text-muted-foreground">
<MessageTyping label={run.status === "waiting" ? "等待中" : "正在处理"} />
{run.status === "waiting"
? `${run.summary === "等待选择方案" || run.summary === "等待审批" ? run.summary : "等待中"}…`
: run.status === "queued"
? "排队中…"
: "正在处理…"}
</span>
) : null;
const lastBlock = blocks[blocks.length - 1];
const typingInTurn = typing && lastBlock?.kind === "turn";
return (
<div className="space-y-4 px-1 py-2">
<div className="space-y-5 px-1 py-2">
<UserMessage text={run.goal} at={run.createdAt} tag={isProposeFirst(run) ? "先看方案再执行" : undefined} />
{rows.map((r) => (r.node ? <div key={r.key}>{r.node}</div> : null))}
{active && live.items[lastIndex]?.kind !== "text" && live.items[lastIndex]?.kind !== "thinking" ? (
<AssistantRow avatar={false}>
<span className="inline-flex items-center gap-2 text-meta text-muted-foreground">
<MessageTyping label={run.status === "waiting" ? "等待中" : "正在处理"} />
{run.status === "waiting"
? `${run.summary === "等待选择方案" || run.summary === "等待审批" ? run.summary : "等待中"}…`
: run.status === "queued"
? "排队中…"
: "正在处理…"}
</span>
</AssistantRow>
) : null}
{blocks.map((b) =>
b.kind === "single" ? (
<div key={b.entry.key}>{b.entry.node}</div>
) : (
<Message key={b.key} from="assistant" aria-label="Agent 回合">
<MessageAvatar>
<Bot />
</MessageAvatar>
<MessageContent className="gap-2">
<MessageHeader>
<span className="font-medium text-foreground/80">Agent</span>
<time dateTime={b.at}>{clockText(b.at)}</time>
</MessageHeader>
{b.parts.map((p) =>
p.kind === "fold" ? (
<ActivityFold key={p.key} entries={p.entries} />
) : (
<div key={p.entry.key} className="w-full min-w-0">
{p.entry.node}
</div>
),
)}
{typingInTurn && b === lastBlock ? typing : null}
</MessageContent>
</Message>
),
)}
{typing && !typingInTurn ? <AssistantRow avatar>{typing}</AssistantRow> : null}
{after}
</div>
);
@@ -552,7 +753,16 @@ function Meter({
}
/** 预算用量条:工具调用/token/时长/费用;超限的一项标红,上游未报告的显示"未报告",费用标注估算 */
export function BudgetBar({ run, live }: { run: AgentRunView; live: AgentStreamState }) {
export function BudgetBar({
run,
live,
stacked = false,
}: {
run: AgentRunView;
live: AgentStreamState;
/** 侧栏里单列排 */
stacked?: boolean;
}) {
const u = { ...run.usage, ...(live.usage ?? {}) };
const toolCalls = Math.max(run.toolCallCount, live.items.filter((i) => i.kind === "tool").length);
const end = [...live.items].reverse().find((i) => i.kind === "end");
@@ -563,7 +773,7 @@ export function BudgetBar({ run, live }: { run: AgentRunView; live: AgentStreamS
? (run.finishedAt ? new Date(run.finishedAt).getTime() : Date.now()) - new Date(run.startedAt).getTime()
: undefined);
return (
<div className="grid grid-cols-2 gap-x-4 gap-y-2 sm:grid-cols-4">
<div className={cn("grid gap-x-4 gap-y-2", stacked ? "grid-cols-1 gap-y-2.5" : "grid-cols-2 sm:grid-cols-4")}>
<Meter
label="工具调用"
used={toolCalls}
@@ -672,6 +882,42 @@ const PLAN_STATUS_TONE = (s: string): StatusTone =>
? "destructive"
: "warning";
/** 长汇报:超过 ~12 行或 600 字按长文处理(纯函数,单测覆盖) */
export function isLongReport(text: string): boolean {
return text.length > 600 || text.split("\n").length > 12;
}
/** 长文折叠:收起时限高 + 底部渐隐,"展开全文"/"收起" */
function LongText({ long, children }: { long: boolean; children: ReactNode }) {
const id = useId();
const [open, setOpen] = useState(false);
if (!long) return <>{children}</>;
return (
<div>
<div
id={id}
className={cn(
"relative",
!open && "max-h-56 overflow-hidden [mask-image:linear-gradient(to_bottom,black_70%,transparent)]",
)}
>
{children}
</div>
<Button
variant="ghost"
size="xs"
className="mt-1"
aria-expanded={open}
aria-controls={id}
onClick={() => setOpen((v) => !v)}
>
<ChevronDown aria-hidden className={cn("size-3.5", CHEVRON_CLASS, open && "rotate-180")} />
{open ? "收起" : "展开全文"}
</Button>
</div>
);
}
/** 最终汇报:左"模型说的(未经核查)",右"系统核实的结果"(服务端从下载计划/下载任务/整理记录汇总,不采信模型自述) */
export function FinalReport({
run,
@@ -719,15 +965,17 @@ export function FinalReport({
const imported = report?.organization.filter((o) => o.targetPath) ?? [];
return (
<section aria-label="最终汇报" className="grid gap-3 md:grid-cols-2">
<section aria-label="最终汇报" className="grid gap-3 @2xl:grid-cols-2">
<div className="min-w-0 rounded-xl border border-dashed border-border p-3">
<p className="mb-1.5 flex items-center gap-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
<Bot aria-hidden className="size-3.5" />
模型说的(未经核查)
</p>
<StreamingResponse status="complete" copyText={said || undefined} contentClassName="max-h-60 overflow-auto">
{said ? <Markdown text={said} /> : "(没有文字汇报)"}
</StreamingResponse>
<LongText long={isLongReport(said)}>
<StreamingResponse status="complete" copyText={said || undefined}>
{said ? <Markdown text={said} /> : "(没有文字汇报)"}
</StreamingResponse>
</LongText>
<p className="mt-2 text-meta text-muted-foreground">仅供参考,不作为完成依据。</p>
</div>
<div className="min-w-0 rounded-xl border border-border/70 bg-foreground/[0.02] p-3">