- 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]>
147 lines
6.3 KiB
TypeScript
147 lines
6.3 KiB
TypeScript
import type { AgentFamilyUsage, AgentRunNode } from "@umt/contracts";
|
|
import { ChevronRight, CornerLeftUp, GitBranch } from "lucide-react";
|
|
import { createContext, type ReactNode, useContext, useState } from "react";
|
|
import { Link } from "react-router";
|
|
import { agentMockEnabled, type RunSnapshot } from "~/api/agent.ts";
|
|
import { cn } from "~/lib/utils.ts";
|
|
import { RunStatusBadge } from "./agent-conversation.tsx";
|
|
import { splitContextGoal } from "./agent-store.ts";
|
|
|
|
// 子代理(agent.spawn):会话页显示父会话链接 + 继承信息,以及子会话树(可展开、可跳转)与会话树合计用量。
|
|
// 未派生过子会话、也不是子会话时不渲染(不显示空的多 Agent 看板)。
|
|
|
|
const tokens = (n: number | undefined) => (n === undefined ? "—" : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
|
|
|
|
export function usageLine(u: { toolCalls: number; totalTokens?: number; costUsd?: number }) {
|
|
const parts = [`${u.toolCalls} 次调用`, `${tokens(u.totalTokens)} token`];
|
|
if (u.costUsd) parts.push(`$${u.costUsd.toFixed(3)}`);
|
|
return parts.join(" · ");
|
|
}
|
|
|
|
const href = (id: string) => `/console/agent/${id}${agentMockEnabled() ? "?mock=1" : ""}`;
|
|
|
|
/** 抽屉里提供:点父/子会话在抽屉内打开,不跳页 */
|
|
export const AgentRunOpenContext = createContext<((runId: string) => void) | null>(null);
|
|
|
|
const LINK_CLASS =
|
|
"line-clamp-2 cursor-pointer rounded-tight text-left text-caption break-words text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring";
|
|
|
|
function RunLink({ id, children }: { id: string; children: ReactNode }) {
|
|
const open = useContext(AgentRunOpenContext);
|
|
if (open)
|
|
return (
|
|
<button type="button" onClick={() => open(id)} className={LINK_CLASS}>
|
|
{children}
|
|
</button>
|
|
);
|
|
return (
|
|
<Link to={href(id)} className={LINK_CLASS}>
|
|
{children}
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
function budgetText(b: Record<string, number | undefined>) {
|
|
const parts: string[] = [];
|
|
if (b.maxToolCalls !== undefined) parts.push(`${b.maxToolCalls} 次调用`);
|
|
if (b.maxTokens !== undefined) parts.push(`${tokens(b.maxTokens)} token`);
|
|
if (b.maxDurationSec !== undefined) parts.push(`${b.maxDurationSec}s`);
|
|
if (b.maxCostUsd !== undefined) parts.push(`$${b.maxCostUsd}`);
|
|
return parts.length ? parts.join(" · ") : "沿用设置";
|
|
}
|
|
|
|
function Node({ node, byParent, depth }: { node: AgentRunNode; byParent: Map<string, AgentRunNode[]>; depth: number }) {
|
|
const kids = byParent.get(node.id) ?? [];
|
|
const [open, setOpen] = useState(true);
|
|
return (
|
|
<li>
|
|
<div className="flex min-w-0 items-start gap-1.5 rounded-lg px-1.5 py-1 hover:bg-foreground/[0.04]">
|
|
{kids.length ? (
|
|
<button
|
|
type="button"
|
|
aria-expanded={open}
|
|
aria-label={open ? "收起子会话" : "展开子会话"}
|
|
onClick={() => setOpen(!open)}
|
|
className="mt-0.5 cursor-pointer rounded-tight text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
<ChevronRight className={cn("size-4 transition-transform duration-micro", open && "rotate-90")} />
|
|
</button>
|
|
) : (
|
|
<span className="mt-0.5 size-4 shrink-0" aria-hidden />
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<RunLink id={node.id}>{splitContextGoal(node.goal).text}</RunLink>
|
|
<div className="mt-0.5 flex flex-wrap items-center gap-1.5">
|
|
<RunStatusBadge status={node.status} />
|
|
<span className="text-meta text-muted-foreground tabular-nums">
|
|
{usageLine({
|
|
toolCalls: node.toolCallCount,
|
|
totalTokens: node.usage.totalTokens,
|
|
costUsd: node.usage.costUsd,
|
|
})}
|
|
</span>
|
|
</div>
|
|
{node.spawnReason ? (
|
|
<p className="mt-0.5 truncate text-meta text-muted-foreground">理由:{node.spawnReason}</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
{kids.length && open ? (
|
|
<ul className={cn("ml-3 border-l border-border/70 pl-2", depth > 2 && "ml-1")}>
|
|
{kids.map((k) => (
|
|
<Node key={k.id} node={k} byParent={byParent} depth={depth + 1} />
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
</li>
|
|
);
|
|
}
|
|
|
|
/** 子会话树 + 父会话链接;两者都没有时返回 null */
|
|
export function SubagentTree({ snapshot, className }: { snapshot: RunSnapshot; className?: string }) {
|
|
const { run, parent, children = [], familyUsage } = snapshot;
|
|
if (!parent && children.length === 0) return null;
|
|
const byParent = new Map<string, AgentRunNode[]>();
|
|
for (const c of children) {
|
|
const k = c.parentRunId ?? "";
|
|
byParent.set(k, [...(byParent.get(k) ?? []), c]);
|
|
}
|
|
const direct = byParent.get(run.id) ?? [];
|
|
const caps = ((run.scope as { capabilities?: string[] }).capabilities ?? []).length;
|
|
return (
|
|
<section aria-label="子会话" className={cn("space-y-2 rounded-xl border border-border/70 p-3", className)}>
|
|
{parent ? (
|
|
<div className="space-y-1">
|
|
<p className="flex items-center gap-1.5 text-meta text-muted-foreground">
|
|
<CornerLeftUp aria-hidden className="size-3.5" />
|
|
父会话
|
|
</p>
|
|
<RunLink id={parent.id}>{splitContextGoal(parent.goal).text}</RunLink>
|
|
<p className="text-meta text-muted-foreground">
|
|
第 {run.depth} 层子会话 · 继承父会话的发起人与授权上限 · 能力 {caps} 项(⊆ 父会话)· 划拨预算{" "}
|
|
{budgetText(run.budget as Record<string, number | undefined>)}
|
|
{run.spawnReason ? ` · 理由:${run.spawnReason}` : ""}
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
{direct.length ? (
|
|
<div className="space-y-1">
|
|
<p className="flex flex-wrap items-center gap-1.5 text-meta text-muted-foreground">
|
|
<GitBranch aria-hidden className="size-3.5" />
|
|
子会话 {children.length} 个{familyUsage ? <FamilyUsage usage={familyUsage} /> : null}
|
|
</p>
|
|
<ul className="space-y-0.5">
|
|
{direct.map((c) => (
|
|
<Node key={c.id} node={c} byParent={byParent} depth={1} />
|
|
))}
|
|
</ul>
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function FamilyUsage({ usage }: { usage: AgentFamilyUsage }) {
|
|
return <span className="ml-auto tabular-nums">会话树合计 {usageLine(usage)}</span>;
|
|
}
|