feat(web): W3-3 site workbench, sources/feeds/snapshots, downloads with business timeline, organization records and agent sessions
- sites + /sites/:siteId: organized view with snapshot-first release list, inventory overlay, candidate reasons, volatile-field recheck, stale marking that keeps existing data, live search, unified download entry; origin view placeholder (M2) - sources: feed CRUD/enable/fetch/reset, cursor/health/gap hints; per-site snapshot history - tasks: download tasks with pause/resume (threshold pause cannot be bypassed), plans, cache volume thresholds and cleanup, business timeline (observation → plan → download → organization → agent/engine) - organize: record list/detail with file-level results, manual organize with dryRun preview - agent: session list/page and layout drawer with context prefix; streaming events, tool cards, rejection alerts, compaction, budget meters, model text vs server report; mock switch (?mock=1) until W2-D endpoints land Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
6280d52ec3
commit
7103bc8f7c
35 files changed
+6134
-45
No files matched your search
@@ -0,0 +1,449 @@
|
||||
import type { AgentRunStatus } from "@umt/contracts";
|
||||
import {
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
ChevronRight,
|
||||
CircleDashed,
|
||||
CircleX,
|
||||
Layers,
|
||||
LoaderCircle,
|
||||
ShieldAlert,
|
||||
User,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import {
|
||||
type AgentRunView,
|
||||
type GoalReport,
|
||||
isRunActive,
|
||||
REPORT_STATE,
|
||||
RUN_STATUS_LABEL,
|
||||
RUN_STATUS_TONE,
|
||||
} from "~/api/agent.ts";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import type { AgentStreamState, StreamItem, ToolCallView } from "./use-agent-stream.ts";
|
||||
|
||||
const BUDGET_LABEL: Record<string, string> = {
|
||||
tool_calls: "工具调用次数",
|
||||
duration: "运行时长",
|
||||
tokens: "token",
|
||||
cost: "费用",
|
||||
};
|
||||
const COMPACT_REASON: Record<string, string> = { manual: "手动", threshold: "达到阈值", overflow: "上下文溢出" };
|
||||
|
||||
function durationText(a: string, b: string | null) {
|
||||
if (!b) return null;
|
||||
const ms = new Date(b).getTime() - new Date(a).getTime();
|
||||
return ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`;
|
||||
}
|
||||
|
||||
/** 摘要块:长文本在块内滚动,不撑坏布局 */
|
||||
function Summary({ label, text, tone }: { label: string; text: string; tone?: "destructive" }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="mb-1 text-meta text-muted-foreground">{label}</p>
|
||||
<pre
|
||||
className={cn(
|
||||
"max-h-48 overflow-auto rounded-lg border border-border/70 bg-foreground/[0.03] px-3 py-2 font-mono text-meta break-all whitespace-pre-wrap text-foreground",
|
||||
tone === "destructive" && "border-destructive/30 text-destructive",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TOOL_STATUS: Record<
|
||||
ToolCallView["status"],
|
||||
{ label: string; tone: "success" | "destructive" | "warning" | "neutral" }
|
||||
> = {
|
||||
running: { label: "执行中", tone: "neutral" },
|
||||
succeeded: { label: "成功", tone: "success" },
|
||||
failed: { label: "失败", tone: "destructive" },
|
||||
denied: { label: "已拒绝", tone: "destructive" },
|
||||
};
|
||||
|
||||
/** 工具调用卡片:默认折叠,展开看脱敏输入/输出 */
|
||||
export function ToolCallCard({ call }: { call: ToolCallView }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const st = TOOL_STATUS[call.status];
|
||||
if (call.status === "denied") {
|
||||
return (
|
||||
<div role="alert" className="rounded-xl border border-destructive/40 bg-destructive/10 px-3 py-2.5">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm font-medium text-destructive">
|
||||
<ShieldAlert className="h-4 w-4 shrink-0" />
|
||||
<span>服务端拒绝调用</span>
|
||||
<code className="font-mono text-meta break-all">{call.capabilityId}</code>
|
||||
</div>
|
||||
<p className="mt-1 text-caption break-words text-destructive">{call.reason ?? "未授权"}</p>
|
||||
<p className="mt-1 text-meta text-muted-foreground">授权校验在服务端完成;Agent 没有绕过的途径。</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const Icon = call.status === "running" ? LoaderCircle : call.status === "failed" ? CircleX : CheckCircle2;
|
||||
return (
|
||||
<div className="min-w-0 rounded-xl border border-border/70">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
className="flex w-full min-w-0 cursor-pointer items-center gap-2 px-3 py-2 text-left transition-colors duration-micro hover:bg-foreground/[0.04]"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-base",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
<Wrench className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<code className="min-w-0 flex-1 truncate font-mono text-caption text-foreground">{call.capabilityId}</code>
|
||||
{call.recovery ? <StatusBadge>恢复补记</StatusBadge> : null}
|
||||
<span className="shrink-0 text-meta text-muted-foreground tabular-nums">
|
||||
{durationText(call.startedAt, call.endedAt) ?? ""}
|
||||
</span>
|
||||
<StatusBadge tone={st.tone}>
|
||||
<Icon className={cn("h-3 w-3", call.status === "running" && "animate-spin")} />
|
||||
{st.label}
|
||||
</StatusBadge>
|
||||
</button>
|
||||
{!open && call.outputSummary ? (
|
||||
<p className="truncate px-3 pb-2 pl-9 text-meta text-muted-foreground">{call.outputSummary}</p>
|
||||
) : null}
|
||||
{open ? (
|
||||
<div className="space-y-2 border-t border-border/60 px-3 py-2.5">
|
||||
{call.inputSummary ? <Summary label="输入(已脱敏)" text={call.inputSummary} /> : null}
|
||||
{call.outputSummary ? (
|
||||
<Summary
|
||||
label={call.status === "failed" ? "错误" : "结果(已脱敏)"}
|
||||
text={call.outputSummary}
|
||||
tone={call.status === "failed" ? "destructive" : undefined}
|
||||
/>
|
||||
) : call.status === "running" ? (
|
||||
<p className="text-meta text-muted-foreground">等待结果…</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamBlock({ item }: { item: StreamItem }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
switch (item.kind) {
|
||||
case "text":
|
||||
return (
|
||||
<div className="flex gap-2.5">
|
||||
<span className="mt-0.5 grid h-6 w-6 shrink-0 place-items-center rounded-full bg-foreground/[0.06] text-muted-foreground">
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<p className="min-w-0 flex-1 text-sm leading-relaxed break-words whitespace-pre-wrap text-foreground">
|
||||
{item.text}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
case "thinking":
|
||||
return (
|
||||
<div className="pl-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
className="inline-flex cursor-pointer items-center gap-1 text-meta text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ChevronRight className={cn("h-3 w-3 transition-transform duration-base", open && "rotate-90")} />
|
||||
思考过程
|
||||
</button>
|
||||
{open ? (
|
||||
<p className="mt-1 max-h-48 overflow-auto text-caption break-words whitespace-pre-wrap text-muted-foreground">
|
||||
{item.text}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
case "tool":
|
||||
return (
|
||||
<div className="pl-8">
|
||||
<ToolCallCard call={item.call} />
|
||||
</div>
|
||||
);
|
||||
case "compaction":
|
||||
return (
|
||||
<div className="flex items-center gap-2 pl-8 text-meta text-muted-foreground">
|
||||
<Layers className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
上下文压缩({COMPACT_REASON[item.reason] ?? item.reason})
|
||||
{item.done ? `· ${item.status === "completed" ? "已完成,业务进度保留" : item.status}` : "· 进行中…"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
case "end":
|
||||
if (item.ok) return null;
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className={cn(
|
||||
"ml-8 rounded-xl border px-3 py-2.5 text-caption",
|
||||
item.reason === "budget"
|
||||
? "border-warning/40 bg-warning/10 text-warning"
|
||||
: item.reason === "aborted"
|
||||
? "border-border bg-foreground/[0.04] text-muted-foreground"
|
||||
: "border-destructive/40 bg-destructive/10 text-destructive",
|
||||
)}
|
||||
>
|
||||
<p className="font-medium">
|
||||
{item.reason === "budget"
|
||||
? `超出预算${item.budget ? `(${BUDGET_LABEL[item.budget] ?? item.budget})` : ""}`
|
||||
: item.reason === "aborted"
|
||||
? "已中止"
|
||||
: "运行失败"}
|
||||
</p>
|
||||
<p className="mt-0.5 break-words">{item.message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function Meter({
|
||||
label,
|
||||
used,
|
||||
max,
|
||||
format,
|
||||
}: {
|
||||
label: string;
|
||||
used: number | undefined;
|
||||
max?: number;
|
||||
format: (n: number) => string;
|
||||
}) {
|
||||
const pct = max && used !== undefined ? Math.min(100, (used / max) * 100) : null;
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-baseline justify-between gap-2 text-meta">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="truncate text-foreground tabular-nums">
|
||||
{used === undefined ? "未报告" : format(used)}
|
||||
{max ? <span className="text-muted-foreground"> / {format(max)}</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1 overflow-hidden rounded-full bg-foreground/[0.08]">
|
||||
{pct !== null ? (
|
||||
<div
|
||||
className={cn("h-full rounded-full", pct >= 90 ? "bg-warning" : "bg-foreground/60")}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 预算用量条:上游未报告的数据显示"未报告",费用标注估算 */
|
||||
export function BudgetBar({ run, live }: { run: AgentRunView; live: AgentStreamState }) {
|
||||
const u = { ...run.usage, ...(live.usage ?? {}) };
|
||||
const toolCalls = Math.max(run.toolCallCount, live.items.filter((i) => i.kind === "tool").length);
|
||||
const durationMs =
|
||||
u.durationMs ??
|
||||
(run.startedAt
|
||||
? (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">
|
||||
<Meter label="工具调用" used={toolCalls} max={run.budget.maxToolCalls} format={(n) => String(n)} />
|
||||
<Meter
|
||||
label="token"
|
||||
used={u.totalTokens}
|
||||
max={run.budget.maxTokens}
|
||||
format={(n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))}
|
||||
/>
|
||||
<Meter
|
||||
label="时长"
|
||||
used={durationMs === undefined ? undefined : Math.round(durationMs / 1000)}
|
||||
max={run.budget.maxDurationSec}
|
||||
format={(n) => `${n}s`}
|
||||
/>
|
||||
<Meter label="费用(估算)" used={u.costUsd} max={run.budget.maxCostUsd} format={(n) => `$${n.toFixed(3)}`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 最终汇报:模型说的 vs 系统核实的(服务端从下载计划/下载任务/整理记录汇总,不采信模型自述) */
|
||||
export function FinalReport({
|
||||
run,
|
||||
report,
|
||||
live,
|
||||
}: {
|
||||
run: AgentRunView;
|
||||
report: GoalReport | null;
|
||||
live: AgentStreamState;
|
||||
}) {
|
||||
const lastTool = live.items.map((i) => i.kind).lastIndexOf("tool");
|
||||
const tail = live.items
|
||||
.slice(lastTool + 1)
|
||||
.filter((i): i is Extract<StreamItem, { kind: "text" }> => i.kind === "text")
|
||||
.map((i) => i.text)
|
||||
.join("");
|
||||
const said = report?.modelText ?? (tail || run.summary || "");
|
||||
const calls = live.items
|
||||
.filter((i): i is Extract<StreamItem, { kind: "tool" }> => i.kind === "tool")
|
||||
.map((i) => i.call);
|
||||
const count = (s: ToolCallView["status"]) => calls.filter((c) => c.status === s).length;
|
||||
const state = report ? REPORT_STATE[report.state] : null;
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="min-w-0 rounded-xl border border-border/70 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 className="h-3.5 w-3.5" />
|
||||
模型说的
|
||||
</p>
|
||||
<p className="max-h-60 overflow-auto text-sm break-words whitespace-pre-wrap text-foreground">
|
||||
{said || "(没有文字汇报)"}
|
||||
</p>
|
||||
<p className="mt-2 text-meta text-muted-foreground">仅供参考,不作为完成依据。</p>
|
||||
</div>
|
||||
<div className="min-w-0 rounded-xl border border-border/70 p-3">
|
||||
<p className="mb-1.5 flex items-center gap-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
系统核实的结果
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{state ? <StatusBadge tone={state.tone}>{state.label}</StatusBadge> : null}
|
||||
<StatusBadge tone={RUN_STATUS_TONE[run.status]}>运行{RUN_STATUS_LABEL[run.status]}</StatusBadge>
|
||||
<StatusBadge>工具成功 {count("succeeded")}</StatusBadge>
|
||||
{count("failed") ? <StatusBadge tone="destructive">失败 {count("failed")}</StatusBadge> : null}
|
||||
{count("denied") ? <StatusBadge tone="destructive">被拒绝 {count("denied")}</StatusBadge> : null}
|
||||
</div>
|
||||
{report ? (
|
||||
<>
|
||||
<p className="mt-2 text-caption break-words text-foreground">{report.summary}</p>
|
||||
<dl className="mt-2 grid grid-cols-4 gap-2 text-center">
|
||||
{(
|
||||
[
|
||||
["已提交", report.submitted],
|
||||
["已入库", report.imported],
|
||||
["进行中", report.inProgress],
|
||||
["失败", report.failed],
|
||||
] as const
|
||||
).map(([k, v]) => (
|
||||
<div key={k} className="rounded-lg border border-border/60 px-1 py-1.5">
|
||||
<dd className="text-lead font-semibold text-foreground tabular-nums">{v}</dd>
|
||||
<dt className="text-meta text-muted-foreground">{k}</dt>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<ul className="mt-2 space-y-1 text-caption">
|
||||
{report.downloads.map((d) => (
|
||||
<li key={d.downloadTaskId} className="min-w-0 truncate">
|
||||
<Link
|
||||
to={`/console/tasks/${d.downloadTaskId}`}
|
||||
className="text-foreground underline-offset-4 hover:underline"
|
||||
>
|
||||
下载 · {d.name ?? d.downloadTaskId.slice(0, 8)}
|
||||
</Link>
|
||||
<span className="text-muted-foreground"> · {d.status}</span>
|
||||
</li>
|
||||
))}
|
||||
{report.organization.map((o) => (
|
||||
<li key={o.recordId} className="min-w-0 truncate">
|
||||
<Link
|
||||
to={`/console/organize/${o.recordId}`}
|
||||
className="text-foreground underline-offset-4 hover:underline"
|
||||
>
|
||||
整理记录 · {o.targetPath ?? o.recordId.slice(0, 8)}
|
||||
</Link>
|
||||
<span className="text-muted-foreground"> · {o.status}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-2 text-meta text-muted-foreground">服务端汇报尚未生成;以任务页、整理记录的实际状态为准。</p>
|
||||
)}
|
||||
{run.error ? (
|
||||
<p className="mt-2 text-caption break-words text-destructive">
|
||||
{String((run.error as { message?: unknown }).message ?? JSON.stringify(run.error))}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 会话流:用户目标 → 流式文本/工具卡片/拒绝/压缩 → 结束 */
|
||||
export function AgentConversation({
|
||||
run,
|
||||
live,
|
||||
userMessages = [],
|
||||
compact = false,
|
||||
}: {
|
||||
run: AgentRunView;
|
||||
live: AgentStreamState;
|
||||
userMessages?: { at: string; text: string; pending?: boolean }[];
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
const active = isRunActive(run.status) && !live.finished;
|
||||
const count = live.items.length;
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 有新内容时滚到底
|
||||
useEffect(() => {
|
||||
if (active) endRef.current?.scrollIntoView({ block: "nearest" });
|
||||
}, [count, active]);
|
||||
|
||||
// 追加的用户消息按时间插入
|
||||
type Row = { key: string; at: string; node: ReactNode };
|
||||
const rows: Row[] = live.items.map((it) => ({
|
||||
key: it.key,
|
||||
at: it.kind === "tool" ? it.call.startedAt : it.at,
|
||||
node: <StreamBlock item={it} />,
|
||||
}));
|
||||
for (const m of userMessages)
|
||||
rows.push({
|
||||
key: `u${m.at}`,
|
||||
at: m.at,
|
||||
node: <UserBubble text={m.text} note={m.pending ? "等待 Agent 读取" : undefined} />,
|
||||
});
|
||||
rows.sort((a, b) => a.at.localeCompare(b.at));
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", compact && "text-sm")}>
|
||||
<UserBubble text={run.goal} mode={run.mode ?? (run.scope.mode as string | undefined)} />
|
||||
{rows.map((r) => (
|
||||
<div key={r.key}>{r.node}</div>
|
||||
))}
|
||||
{active ? (
|
||||
<div className="flex items-center gap-2 pl-8 text-meta text-muted-foreground">
|
||||
<CircleDashed className="h-3.5 w-3.5 animate-spin" />
|
||||
{run.status === "waiting" ? "等待中…" : "正在处理…"}
|
||||
</div>
|
||||
) : null}
|
||||
{live.error ? <p className="pl-8 text-meta text-destructive">事件读取失败:{live.error}(稍后自动重试)</p> : null}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserBubble({ text, mode, note }: { text: string; mode?: string; note?: string }) {
|
||||
return (
|
||||
<div className="flex justify-end gap-2.5">
|
||||
<div className="max-w-[85%] min-w-0 rounded-2xl border border-border/70 bg-foreground/[0.05] px-3 py-2">
|
||||
{mode === "plan_only" ? <p className="mb-0.5 text-meta text-muted-foreground">只要方案</p> : null}
|
||||
<p className="text-sm break-words whitespace-pre-wrap text-foreground">{text}</p>
|
||||
{note ? <p className="mt-0.5 text-meta text-muted-foreground">{note}</p> : null}
|
||||
</div>
|
||||
<span className="mt-0.5 grid h-6 w-6 shrink-0 place-items-center rounded-full bg-foreground/[0.06] text-muted-foreground">
|
||||
<User className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RunStatusBadge({ status }: { status: AgentRunStatus }) {
|
||||
return (
|
||||
<StatusBadge tone={RUN_STATUS_TONE[status]}>
|
||||
{isRunActive(status) ? <LoaderCircle className="h-3 w-3 animate-spin" /> : null}
|
||||
{RUN_STATUS_LABEL[status]}
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Bot, ExternalLink, Plus } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useCreateAgentRun } from "~/api/agent.ts";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { AgentRunPanel, GoalComposer } from "./agent-run-panel.tsx";
|
||||
import {
|
||||
contextPrefix,
|
||||
openAgentDrawer,
|
||||
setAgentDrawerOpen,
|
||||
setAgentDrawerRun,
|
||||
useAgentDrawer,
|
||||
} from "./agent-store.ts";
|
||||
|
||||
/**
|
||||
* Agent 侧栏抽屉:任意页面可打开,带当前页面上下文(entityId 等)作为 goal 前缀。
|
||||
* 挂载:在 console layout 里渲染一次 <AgentDrawer />;顶栏放 <AgentDrawerTrigger />。
|
||||
* 页面内打开:import { openAgentDrawer } from "~/components/agent/agent-store.ts"。
|
||||
*/
|
||||
export function AgentDrawer() {
|
||||
const { open, context, runId } = useAgentDrawer();
|
||||
const create = useCreateAgentRun();
|
||||
const prefix = contextPrefix(context);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setAgentDrawerOpen}>
|
||||
<SheetContent side="right" className="w-full gap-0 p-0 sm:max-w-lg">
|
||||
<SheetHeader className="border-b border-[var(--shell-border)] px-5 py-4 pr-14">
|
||||
<SheetTitle className="flex items-center gap-2 text-lead">
|
||||
<Bot className="h-4 w-4" />
|
||||
Agent
|
||||
</SheetTitle>
|
||||
<SheetDescription className="break-words">
|
||||
{context?.label ? `当前对象:${context.label}` : "交代目标,Agent 在授权范围内边执行边汇报。"}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
{runId ? (
|
||||
<AgentRunPanel runId={runId} compact />
|
||||
) : (
|
||||
<GoalComposer
|
||||
key={`${context?.entityId ?? ""}${context?.releaseId ?? ""}${context?.goal ?? ""}`}
|
||||
compact
|
||||
prefix={prefix || undefined}
|
||||
defaultGoal={context?.goal ?? ""}
|
||||
pending={create.isPending}
|
||||
onSubmit={(goal, mode) =>
|
||||
create.mutate(
|
||||
{ goal: `${prefix}${goal}`, mode },
|
||||
{
|
||||
onSuccess: (run) => setAgentDrawerRun(run.id),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-[var(--shell-border)] px-5 py-3">
|
||||
{runId ? (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={() => openAgentDrawer(context)}>
|
||||
<Plus />
|
||||
新目标
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to={`/console/agent/${runId}`} onClick={() => setAgentDrawerOpen(false)}>
|
||||
<ExternalLink />
|
||||
在会话页打开
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/console/agent" onClick={() => setAgentDrawerOpen(false)}>
|
||||
全部会话
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/** 顶栏按钮:打开抽屉(不带上下文;页面内按钮用 openAgentDrawer 传上下文) */
|
||||
export function AgentDrawerTrigger() {
|
||||
return (
|
||||
<Button variant="ghost" size="icon-sm" aria-label="打开 Agent" title="Agent" onClick={() => openAgentDrawer(null)}>
|
||||
<Bot />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { Send, Square } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
type AgentMode,
|
||||
agentMockEnabled,
|
||||
isRunActive,
|
||||
useAbortAgentRun,
|
||||
useAgentRun,
|
||||
useAppendAgentMessage,
|
||||
} from "~/api/agent.ts";
|
||||
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 { Textarea } from "~/components/ui/textarea.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||||
import { AgentConversation, BudgetBar, FinalReport, RunStatusBadge } from "./agent-conversation.tsx";
|
||||
import { useAgentStream } from "./use-agent-stream.ts";
|
||||
|
||||
const TRIGGER_LABEL = { user: "用户发起", event: "事件触发", schedule: "定时", subagent: "子代理" } as const;
|
||||
|
||||
/** 目标输入:execute / plan_only 分段切换 + 文本框 */
|
||||
export function GoalComposer({
|
||||
onSubmit,
|
||||
pending,
|
||||
prefix,
|
||||
defaultGoal = "",
|
||||
compact = false,
|
||||
}: {
|
||||
onSubmit: (goal: string, mode: AgentMode) => void;
|
||||
pending: boolean;
|
||||
/** 上下文前缀(只读展示) */
|
||||
prefix?: string;
|
||||
defaultGoal?: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const [goal, setGoal] = useState(defaultGoal);
|
||||
const [mode, setMode] = useState<AgentMode>("execute");
|
||||
const submit = () => {
|
||||
const g = goal.trim();
|
||||
if (!g) {
|
||||
toast.error("先写下目标");
|
||||
return;
|
||||
}
|
||||
onSubmit(g, mode);
|
||||
};
|
||||
return (
|
||||
<form
|
||||
className="space-y-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
{prefix ? (
|
||||
<p
|
||||
className="truncate rounded-lg border border-border/70 px-2.5 py-1 font-mono text-meta text-muted-foreground"
|
||||
title={prefix}
|
||||
>
|
||||
{prefix}
|
||||
</p>
|
||||
) : null}
|
||||
<Textarea
|
||||
value={goal}
|
||||
onChange={(e) => setGoal(e.target.value)}
|
||||
placeholder="例如:把 沙丘:第二部 的 4K 收进来"
|
||||
rows={compact ? 3 : 3}
|
||||
maxLength={4000}
|
||||
aria-label="目标"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<fieldset aria-label="运行模式" className="flex items-center gap-1">
|
||||
{(
|
||||
[
|
||||
["execute", "授权内执行"],
|
||||
["plan_only", "只要方案"],
|
||||
] as const
|
||||
).map(([v, label]) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
aria-pressed={mode === v}
|
||||
onClick={() => setMode(v)}
|
||||
className={cn(
|
||||
"h-8 cursor-pointer rounded-full border border-transparent px-3 text-sm text-muted-foreground transition-colors duration-micro hover:text-foreground",
|
||||
mode === v && "pill-selected text-foreground",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
<Button type="submit" size="sm" disabled={pending}>
|
||||
<Send />
|
||||
{pending ? "提交中…" : "开始"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-meta text-muted-foreground">
|
||||
{mode === "plan_only"
|
||||
? "只调查、出方案,不执行任何写操作。"
|
||||
: "在已授权范围内执行;未授权的动作会被服务端拒绝并在对话里标出。"}
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** 一次运行:头部状态 + 预算条 + 流式对话 + 最终汇报 + 追加消息 */
|
||||
export function AgentRunPanel({ runId, compact = false }: { runId: string; compact?: boolean }) {
|
||||
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 [msg, setMsg] = useState("");
|
||||
|
||||
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;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<RunStatusBadge status={live.finished && isRunActive(r.status) ? "running" : r.status} />
|
||||
<span className="text-meta text-muted-foreground">
|
||||
{TRIGGER_LABEL[r.trigger]} · {formatDateTime(r.createdAt)}
|
||||
{r.model ? ` · ${r.provider ?? ""}/${r.model}` : ""}
|
||||
</span>
|
||||
{agentMockEnabled() ? <StatusBadge tone="warning">mock 数据</StatusBadge> : null}
|
||||
{active ? (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="destructive" size="sm" className="ml-auto" disabled={abort.isPending}>
|
||||
<Square />
|
||||
中止
|
||||
</Button>
|
||||
}
|
||||
title="中止这次运行?"
|
||||
description="停止后续步骤;已经完成的外部动作(如已提交的下载)不会被撤销。"
|
||||
confirmLabel="中止"
|
||||
destructive
|
||||
onConfirm={() =>
|
||||
abort.mutateAsync(r.id).then(
|
||||
() => toast.success("已请求中止"),
|
||||
(e) => toast.error(errorMessage(e)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={cn("rounded-xl border border-border/70 p-3", compact && "p-2.5")}>
|
||||
<BudgetBar run={r} live={live} />
|
||||
</div>
|
||||
|
||||
<AgentConversation
|
||||
run={r}
|
||||
live={live}
|
||||
userMessages={messages.map((m) => ({ at: m.createdAt, text: m.text, pending: m.status === "pending" }))}
|
||||
compact={compact}
|
||||
/>
|
||||
|
||||
{finished ? <FinalReport run={r} report={report} live={live} /> : null}
|
||||
|
||||
<form
|
||||
className="flex items-end gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const text = msg.trim();
|
||||
if (!text) return;
|
||||
append.mutate(
|
||||
{ id: r.id, text },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setMsg("");
|
||||
void run.refetch();
|
||||
},
|
||||
onError: (err) => toast.error(errorMessage(err)),
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Textarea
|
||||
value={msg}
|
||||
onChange={(e) => setMsg(e.target.value)}
|
||||
rows={1}
|
||||
maxLength={8000}
|
||||
placeholder={active ? "补充或纠正(运行中也可追加)" : "追加消息,继续这次会话"}
|
||||
aria-label="追加消息"
|
||||
className="min-h-9 flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
disabled={append.isPending || !msg.trim()}
|
||||
aria-label="发送"
|
||||
>
|
||||
<Send />
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
// Agent 侧栏抽屉的全局开关与上下文:任意页面调用 openAgentDrawer({ entityId, ... }) 即可带着当前对象打开。
|
||||
|
||||
export type AgentContext = {
|
||||
/** 显示给用户的上下文说明,如"作品:沙丘2 (2024)" */
|
||||
label?: string;
|
||||
entityId?: string;
|
||||
releaseId?: string;
|
||||
downloadTaskId?: string;
|
||||
recordId?: string;
|
||||
siteId?: string;
|
||||
/** 预填目标 */
|
||||
goal?: string;
|
||||
};
|
||||
|
||||
type State = { open: boolean; context: AgentContext | null; runId: string | null };
|
||||
|
||||
let state: State = { open: false, context: null, runId: null };
|
||||
const listeners = new Set<() => void>();
|
||||
const emit = () => {
|
||||
for (const l of listeners) l();
|
||||
};
|
||||
|
||||
export function openAgentDrawer(context: AgentContext | null = null) {
|
||||
state = { open: true, context, runId: null };
|
||||
emit();
|
||||
}
|
||||
|
||||
export function setAgentDrawerOpen(open: boolean) {
|
||||
state = { ...state, open };
|
||||
emit();
|
||||
}
|
||||
|
||||
export function setAgentDrawerRun(runId: string | null) {
|
||||
state = { ...state, runId };
|
||||
emit();
|
||||
}
|
||||
|
||||
export function useAgentDrawer(): State {
|
||||
return useSyncExternalStore(
|
||||
(l) => {
|
||||
listeners.add(l);
|
||||
return () => listeners.delete(l);
|
||||
},
|
||||
() => state,
|
||||
() => state,
|
||||
);
|
||||
}
|
||||
|
||||
/** 上下文 → goal 前缀(服务端按 scope.context 解析对象;前缀让模型和人都看得到指代) */
|
||||
export function contextPrefix(ctx: AgentContext | null): string {
|
||||
if (!ctx) return "";
|
||||
const parts: string[] = [];
|
||||
if (ctx.label) parts.push(ctx.label);
|
||||
if (ctx.entityId) parts.push(`entityId=${ctx.entityId}`);
|
||||
if (ctx.releaseId) parts.push(`sourceReleaseId=${ctx.releaseId}`);
|
||||
if (ctx.downloadTaskId) parts.push(`downloadTaskId=${ctx.downloadTaskId}`);
|
||||
if (ctx.recordId) parts.push(`organizationRecordId=${ctx.recordId}`);
|
||||
if (ctx.siteId) parts.push(`siteId=${ctx.siteId}`);
|
||||
return parts.length ? `[上下文 ${parts.join(" · ")}] ` : "";
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import type { AgentRunEvent, AgentRunEventBody, AgentUsageTotals } from "@umt/contracts";
|
||||
import type { AgentMode, AgentRunView, GoalReport, RunMessage, RunSnapshot } from "~/api/agent.ts";
|
||||
|
||||
// Agent 会话 mock(W2-D 端点就绪前开发用;?mock=1 打开)。事件按时间逐步"到达",模拟流式输出。
|
||||
|
||||
type Step = { delayMs: number; body: AgentRunEventBody };
|
||||
type Entry = {
|
||||
run: AgentRunView;
|
||||
steps: Step[];
|
||||
startedAt: number;
|
||||
abortedAt: number | null;
|
||||
appended: RunMessage[];
|
||||
};
|
||||
|
||||
const store = new Map<string, Entry>();
|
||||
let seeded = false;
|
||||
|
||||
const uuid = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, "0")}`;
|
||||
const iso = (ms: number) => new Date(ms).toISOString();
|
||||
|
||||
const USAGE: AgentUsageTotals = {
|
||||
inputTokens: 18_420,
|
||||
outputTokens: 1_236,
|
||||
cacheReadTokens: 12_800,
|
||||
cacheWriteTokens: 2_048,
|
||||
totalTokens: 19_656,
|
||||
costUsd: 0.042,
|
||||
};
|
||||
|
||||
function chunks(text: string, size = 6): string[] {
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < text.length; i += size) out.push(text.slice(i, i + size));
|
||||
return out;
|
||||
}
|
||||
|
||||
function script(goal: string, mode: AgentMode): Step[] {
|
||||
const s: Step[] = [];
|
||||
let t = 0;
|
||||
const push = (dt: number, body: AgentRunEventBody) => {
|
||||
t += dt;
|
||||
s.push({ delayMs: t, body });
|
||||
};
|
||||
const say = (text: string) => {
|
||||
for (const c of chunks(text)) push(60, { type: "text_delta", delta: c });
|
||||
};
|
||||
push(0, { type: "run_started", goal, resumed: false });
|
||||
say("先确认本地库存与版本目标,再去站点找候选。");
|
||||
push(400, {
|
||||
type: "tool_call_start",
|
||||
toolCallId: "t1",
|
||||
capabilityId: "inventory.query",
|
||||
inputSummary: '{"query":"沙丘:第二部","year":2024}',
|
||||
recovery: false,
|
||||
});
|
||||
push(900, {
|
||||
type: "tool_call_end",
|
||||
toolCallId: "t1",
|
||||
capabilityId: "inventory.query",
|
||||
isError: false,
|
||||
outputSummary: "作品 沙丘:第二部 (2024):已有 1080p WEB-DL(已核验);Slot「archival」缺 2160p;无下载中任务",
|
||||
recovery: false,
|
||||
});
|
||||
push(300, {
|
||||
type: "tool_call_start",
|
||||
toolCallId: "t2",
|
||||
capabilityId: "release.search",
|
||||
inputSummary: '{"site":"audiences","q":"Dune Part Two 2160p","passkey":"[REDACTED]"}',
|
||||
recovery: false,
|
||||
});
|
||||
push(1200, {
|
||||
type: "tool_call_end",
|
||||
toolCallId: "t2",
|
||||
capabilityId: "release.search",
|
||||
isError: false,
|
||||
outputSummary:
|
||||
"3 条候选:① Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.HDR.DV-FraMeSToR 78.4 GB 免费 · 做种 41;② 2160p WEB-DL DDP5.1 Atmos 24.1 GB;③ 2160p x265 10bit 18.9 GB",
|
||||
recovery: false,
|
||||
});
|
||||
push(300, { type: "compaction", phase: "start", reason: "threshold" });
|
||||
push(700, { type: "compaction", phase: "end", reason: "threshold", status: "completed" });
|
||||
say("①符合 archival 的硬条件(REMUX、HDR),免费截止时间在 6 小时后。");
|
||||
if (mode === "execute") {
|
||||
push(300, {
|
||||
type: "tool_call_start",
|
||||
toolCallId: "t3",
|
||||
capabilityId: "download.plan",
|
||||
inputSummary: '{"sourceReleaseId":"…c3f1","slot":"archival"}',
|
||||
recovery: false,
|
||||
});
|
||||
push(800, {
|
||||
type: "tool_call_end",
|
||||
toolCallId: "t3",
|
||||
capabilityId: "download.plan",
|
||||
isError: false,
|
||||
outputSummary: "计划已预留:78.4 GB,缓存卷 cache-a 使用率 71% → 预计 86%(硬阈值 90%)",
|
||||
recovery: false,
|
||||
});
|
||||
push(300, {
|
||||
type: "tool_call_rejected",
|
||||
toolCallId: "t4",
|
||||
toolName: "qb_delete_torrent",
|
||||
capabilityId: "download.cleanup",
|
||||
reason: "未授权:删除下载器任务不在本次委托范围内(需要显式授权)",
|
||||
});
|
||||
push(300, {
|
||||
type: "tool_call_start",
|
||||
toolCallId: "t5",
|
||||
capabilityId: "download.submit",
|
||||
inputSummary: '{"planId":"…9a2e"}',
|
||||
recovery: false,
|
||||
});
|
||||
push(1000, {
|
||||
type: "tool_call_end",
|
||||
toolCallId: "t5",
|
||||
capabilityId: "download.submit",
|
||||
isError: false,
|
||||
outputSummary: "已提交到 qb-main,标签 umt-managed,任务 ID …41d0",
|
||||
recovery: false,
|
||||
});
|
||||
say("已提交 4K REMUX 下载。完成后会自动核验并 Copy 入库,原 1080p 保留。");
|
||||
} else {
|
||||
say("只要方案:建议提交①,预计占用缓存 78.4 GB,完成后 Copy 入库、保留 1080p。未执行任何写操作。");
|
||||
}
|
||||
push(200, { type: "run_completed", finalText: "", usage: USAGE });
|
||||
return s;
|
||||
}
|
||||
|
||||
function baseRun(id: string, goal: string, mode: AgentMode, createdAt: number, status: AgentRunView["status"]) {
|
||||
return {
|
||||
id,
|
||||
createdAt: iso(createdAt),
|
||||
updatedAt: iso(createdAt),
|
||||
taskId: null,
|
||||
parentRunId: null,
|
||||
trigger: "user",
|
||||
goal,
|
||||
status,
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet",
|
||||
thinkingLevel: "medium",
|
||||
scope: { mode },
|
||||
budget: { maxToolCalls: 20, maxTokens: 200_000, maxDurationSec: 600, maxCostUsd: 1 },
|
||||
usage: {},
|
||||
evidenceVersion: null,
|
||||
sessionState: null,
|
||||
summary: null,
|
||||
toolCallCount: 0,
|
||||
lastEventSeq: 0,
|
||||
error: null,
|
||||
cancelRequestedAt: null,
|
||||
startedAt: iso(createdAt),
|
||||
finishedAt: null,
|
||||
createdBy: "admin",
|
||||
mode,
|
||||
dedupeKey: null,
|
||||
} satisfies AgentRunView;
|
||||
}
|
||||
|
||||
function add(goal: string, mode: AgentMode, startedAt: number, n: number): Entry {
|
||||
const id = uuid(n);
|
||||
const e: Entry = {
|
||||
run: baseRun(id, goal, mode, startedAt, "running"),
|
||||
steps: script(goal, mode),
|
||||
startedAt,
|
||||
abortedAt: null,
|
||||
appended: [],
|
||||
};
|
||||
store.set(id, e);
|
||||
return e;
|
||||
}
|
||||
|
||||
function seed() {
|
||||
if (seeded) return;
|
||||
seeded = true;
|
||||
const now = Date.now();
|
||||
add("把 沙丘:第二部 的 4K 收进来", "execute", now - 3_600_000, 1);
|
||||
add("对比一下 奥本海默 现有版本和站点上的 4K,只给方案", "plan_only", now - 7_200_000, 2);
|
||||
const failed = add("检查 qb-main 为什么占满磁盘", "execute", now - 86_400_000, 3);
|
||||
failed.steps = [
|
||||
{ delayMs: 0, body: { type: "run_started", goal: failed.run.goal, resumed: false } },
|
||||
{
|
||||
delayMs: 100,
|
||||
body: {
|
||||
type: "tool_call_start",
|
||||
toolCallId: "x1",
|
||||
capabilityId: "task.inspect",
|
||||
inputSummary: '{"instance":"qb-main"}',
|
||||
recovery: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
delayMs: 200,
|
||||
body: {
|
||||
type: "tool_call_end",
|
||||
toolCallId: "x1",
|
||||
capabilityId: "task.inspect",
|
||||
isError: true,
|
||||
outputSummary: "下载器连接超时(qb-main 10.0.0.12:8080)",
|
||||
recovery: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
delayMs: 300,
|
||||
body: {
|
||||
type: "run_failed",
|
||||
reason: "budget",
|
||||
budget: "duration",
|
||||
message: "超出单次运行时长预算(600 秒)",
|
||||
usage: { ...USAGE, totalTokens: 4_210, inputTokens: 3_900, outputTokens: 310, costUsd: 0.009 },
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function revealed(e: Entry, now = Date.now()): AgentRunEvent[] {
|
||||
const cut = e.abortedAt ?? now;
|
||||
const out: AgentRunEvent[] = [];
|
||||
let seq = 0;
|
||||
for (const st of e.steps) {
|
||||
if (e.startedAt + st.delayMs > cut) break;
|
||||
out.push({ ...st.body, runId: e.run.id, seq: seq++, at: iso(e.startedAt + st.delayMs) } as AgentRunEvent);
|
||||
}
|
||||
if (e.abortedAt && !out.some((x) => x.type === "run_completed" || x.type === "run_failed")) {
|
||||
out.push({
|
||||
type: "run_failed",
|
||||
reason: "aborted",
|
||||
message: "用户中止",
|
||||
usage: USAGE,
|
||||
runId: e.run.id,
|
||||
seq: seq++,
|
||||
at: iso(e.abortedAt),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function view(e: Entry): AgentRunView {
|
||||
const evs = revealed(e);
|
||||
const end = evs.find((x) => x.type === "run_completed" || x.type === "run_failed");
|
||||
const tools = evs.filter((x) => x.type === "tool_call_start").length;
|
||||
const text = evs
|
||||
.filter((x) => x.type === "text_delta")
|
||||
.map((x) => (x.type === "text_delta" ? x.delta : ""))
|
||||
.join("");
|
||||
const status: AgentRunView["status"] = !end
|
||||
? "running"
|
||||
: end.type === "run_completed"
|
||||
? "succeeded"
|
||||
: end.reason === "budget"
|
||||
? "budget_exceeded"
|
||||
: end.reason === "aborted"
|
||||
? "cancelled"
|
||||
: "failed";
|
||||
const frac = end ? 1 : Math.min(1, evs.length / Math.max(1, e.steps.length));
|
||||
return {
|
||||
...e.run,
|
||||
status,
|
||||
toolCallCount: tools,
|
||||
lastEventSeq: evs.length ? evs[evs.length - 1]!.seq : 0,
|
||||
usage: end
|
||||
? { ...end.usage, durationMs: new Date(end.at).getTime() - e.startedAt }
|
||||
: {
|
||||
inputTokens: Math.round(USAGE.inputTokens * frac),
|
||||
outputTokens: Math.round(USAGE.outputTokens * frac),
|
||||
totalTokens: Math.round(USAGE.totalTokens * frac),
|
||||
costUsd: +(USAGE.costUsd * frac).toFixed(4),
|
||||
durationMs: Date.now() - e.startedAt,
|
||||
},
|
||||
summary: end ? (end.type === "run_failed" ? end.message : text) : null,
|
||||
error: end?.type === "run_failed" ? { reason: end.reason, message: end.message } : null,
|
||||
finishedAt: end ? end.at : null,
|
||||
updatedAt: evs.length ? evs[evs.length - 1]!.at : e.run.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function MOCK_RUNS(): AgentRunView[] {
|
||||
seed();
|
||||
return [...store.values()].map(view).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
export function mockDetail(id: string, op?: { abort?: boolean; append?: string }): RunSnapshot {
|
||||
seed();
|
||||
const e = store.get(id);
|
||||
if (!e) throw Object.assign(new Error("会话不存在(mock)"), { code: "not_found" });
|
||||
if (op?.abort && !e.abortedAt) e.abortedAt = Date.now();
|
||||
if (op?.append) {
|
||||
// 追加消息:模拟 Agent 回一句确认
|
||||
const at = Date.now() - e.startedAt;
|
||||
e.appended.push({
|
||||
id: uuid(9000 + e.appended.length),
|
||||
createdAt: iso(Date.now()),
|
||||
text: op.append,
|
||||
status: "pending",
|
||||
consumedAt: null,
|
||||
});
|
||||
const last = e.steps.findIndex((s) => s.body.type === "run_completed" || s.body.type === "run_failed");
|
||||
const reply: Step[] = chunks(`收到补充:「${op.append}」,按此调整。`).map((c, i) => ({
|
||||
delayMs: at + 200 + i * 60,
|
||||
body: { type: "text_delta", delta: c },
|
||||
}));
|
||||
if (last >= 0 && e.startedAt + e.steps[last]!.delayMs > Date.now()) e.steps.splice(last, 0, ...reply);
|
||||
e.steps.sort((a, b) => a.delayMs - b.delayMs);
|
||||
}
|
||||
const run = view(e);
|
||||
return { run, messages: e.appended, report: report(e, run) };
|
||||
}
|
||||
|
||||
/** 服务端目标汇报的 mock:只按"业务表"口径给数,模型文本单独放 modelText */
|
||||
function report(e: Entry, run: AgentRunView): GoalReport | null {
|
||||
if (!run.finishedAt) return null;
|
||||
const evs = revealed(e);
|
||||
const submitted = evs.some((x) => x.type === "tool_call_end" && x.capabilityId === "download.submit" && !x.isError);
|
||||
const text = evs
|
||||
.filter((x) => x.type === "text_delta")
|
||||
.map((x) => (x.type === "text_delta" ? x.delta : ""))
|
||||
.join("");
|
||||
return {
|
||||
state: submitted ? "waiting" : run.status === "succeeded" ? "no_actions" : "failed",
|
||||
summary: submitted
|
||||
? "1 个下载已进入下载器(qb-main),尚未入库;入库以整理记录 succeeded 为准"
|
||||
: run.status === "succeeded"
|
||||
? "本次没有产生下载或整理动作"
|
||||
: "运行未完成,没有产生业务动作",
|
||||
submitted: submitted ? 1 : 0,
|
||||
imported: 0,
|
||||
inProgress: submitted ? 1 : 0,
|
||||
failed: 0,
|
||||
plans: submitted ? [{ planId: uuid(7001), status: "submitted" }] : [],
|
||||
downloads: submitted
|
||||
? [{ downloadTaskId: uuid(7002), status: "downloading", name: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX" }]
|
||||
: [],
|
||||
organization: [],
|
||||
modelText: text || null,
|
||||
generatedAt: run.finishedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function mockEventsAfter(id: string, after: number): AgentRunEvent[] {
|
||||
seed();
|
||||
const e = store.get(id);
|
||||
if (!e) return [];
|
||||
return revealed(e).filter((x) => x.seq > after);
|
||||
}
|
||||
|
||||
export function mockCreateRun(goal: string, mode: AgentMode): AgentRunView {
|
||||
seed();
|
||||
const e = add(goal, mode, Date.now(), store.size + 1);
|
||||
return view(e);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { type AgentRunEvent, type AgentUsageTotals, EventType } from "@umt/contracts";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { agentMockEnabled, fetchRunEvents } from "~/api/agent.ts";
|
||||
import { sse } from "~/lib/sse.ts";
|
||||
|
||||
// Agent 运行流:先按 seq 回放 agent_run_event,再跟随 SSE(agent.* 事件只作"有新事件"的提示,
|
||||
// 收到后按 lastSeq 增量补读;thinking/compaction 不上全局总线,补读时一并拿到)。SSE 不可用时退回轮询。
|
||||
|
||||
export type ToolCallView = {
|
||||
toolCallId: string;
|
||||
capabilityId: string;
|
||||
inputSummary: string | null;
|
||||
outputSummary: string | null;
|
||||
status: "running" | "succeeded" | "failed" | "denied";
|
||||
reason: string | null;
|
||||
startedAt: string;
|
||||
endedAt: string | null;
|
||||
recovery: boolean;
|
||||
};
|
||||
|
||||
export type StreamItem =
|
||||
| { kind: "text"; key: string; text: string; at: string }
|
||||
| { kind: "thinking"; key: string; text: string; at: string }
|
||||
| { kind: "tool"; key: string; call: ToolCallView }
|
||||
| { kind: "compaction"; key: string; at: string; reason: string; status: string | null; done: boolean }
|
||||
| {
|
||||
kind: "end";
|
||||
key: string;
|
||||
at: string;
|
||||
ok: boolean;
|
||||
message: string;
|
||||
reason: string | null;
|
||||
budget: string | null;
|
||||
};
|
||||
|
||||
export type AgentStreamState = {
|
||||
events: AgentRunEvent[];
|
||||
items: StreamItem[];
|
||||
usage: AgentUsageTotals | null;
|
||||
finished: boolean;
|
||||
error: string | null;
|
||||
connected: "sse" | "poll" | "mock" | "idle";
|
||||
};
|
||||
|
||||
const SSE_TYPES: string[] = [EventType.agentTextDelta, EventType.agentToolCall, EventType.agentRunStatus];
|
||||
|
||||
/** 事件 → 展示项:相邻文本增量合并;工具调用按 toolCallId 折叠成一张卡 */
|
||||
export function foldEvents(events: AgentRunEvent[]): StreamItem[] {
|
||||
const items: StreamItem[] = [];
|
||||
const tools = new Map<string, ToolCallView>();
|
||||
for (const e of events) {
|
||||
const last = items[items.length - 1];
|
||||
switch (e.type) {
|
||||
case "text_delta":
|
||||
if (last?.kind === "text") last.text += e.delta;
|
||||
else items.push({ kind: "text", key: `t${e.seq}`, text: e.delta, at: e.at });
|
||||
break;
|
||||
case "thinking_delta":
|
||||
if (last?.kind === "thinking") last.text += e.delta;
|
||||
else items.push({ kind: "thinking", key: `th${e.seq}`, text: e.delta, at: e.at });
|
||||
break;
|
||||
case "tool_call_start": {
|
||||
const call: ToolCallView = {
|
||||
toolCallId: e.toolCallId,
|
||||
capabilityId: e.capabilityId,
|
||||
inputSummary: e.inputSummary,
|
||||
outputSummary: null,
|
||||
status: "running",
|
||||
reason: null,
|
||||
startedAt: e.at,
|
||||
endedAt: null,
|
||||
recovery: e.recovery,
|
||||
};
|
||||
tools.set(e.toolCallId, call);
|
||||
items.push({ kind: "tool", key: `c${e.toolCallId}`, call });
|
||||
break;
|
||||
}
|
||||
case "tool_call_end": {
|
||||
const call = tools.get(e.toolCallId);
|
||||
if (call) {
|
||||
call.outputSummary = e.outputSummary;
|
||||
call.status = e.isError ? "failed" : "succeeded";
|
||||
call.endedAt = e.at;
|
||||
} else {
|
||||
items.push({
|
||||
kind: "tool",
|
||||
key: `c${e.toolCallId}`,
|
||||
call: {
|
||||
toolCallId: e.toolCallId,
|
||||
capabilityId: e.capabilityId,
|
||||
inputSummary: null,
|
||||
outputSummary: e.outputSummary,
|
||||
status: e.isError ? "failed" : "succeeded",
|
||||
reason: null,
|
||||
startedAt: e.at,
|
||||
endedAt: e.at,
|
||||
recovery: e.recovery,
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tool_call_rejected": {
|
||||
const call = tools.get(e.toolCallId);
|
||||
if (call) {
|
||||
call.status = "denied";
|
||||
call.reason = e.reason;
|
||||
call.endedAt = e.at;
|
||||
} else {
|
||||
items.push({
|
||||
kind: "tool",
|
||||
key: `c${e.toolCallId}`,
|
||||
call: {
|
||||
toolCallId: e.toolCallId,
|
||||
capabilityId: e.capabilityId ?? e.toolName,
|
||||
inputSummary: null,
|
||||
outputSummary: null,
|
||||
status: "denied",
|
||||
reason: e.reason,
|
||||
startedAt: e.at,
|
||||
endedAt: e.at,
|
||||
recovery: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "compaction":
|
||||
if (e.phase === "start")
|
||||
items.push({ kind: "compaction", key: `k${e.seq}`, at: e.at, reason: e.reason, status: null, done: false });
|
||||
else {
|
||||
const open = [...items].reverse().find((i) => i.kind === "compaction" && !i.done);
|
||||
if (open && open.kind === "compaction") {
|
||||
open.done = true;
|
||||
open.status = e.status ?? "completed";
|
||||
} else
|
||||
items.push({
|
||||
kind: "compaction",
|
||||
key: `k${e.seq}`,
|
||||
at: e.at,
|
||||
reason: e.reason,
|
||||
status: e.status ?? "completed",
|
||||
done: true,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "run_completed":
|
||||
items.push({
|
||||
kind: "end",
|
||||
key: `e${e.seq}`,
|
||||
at: e.at,
|
||||
ok: true,
|
||||
message: e.finalText,
|
||||
reason: null,
|
||||
budget: null,
|
||||
});
|
||||
break;
|
||||
case "run_failed":
|
||||
items.push({
|
||||
kind: "end",
|
||||
key: `e${e.seq}`,
|
||||
at: e.at,
|
||||
ok: false,
|
||||
message: e.message,
|
||||
reason: e.reason,
|
||||
budget: e.budget ?? null,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function useAgentStream(runId: string | undefined, active: boolean): AgentStreamState {
|
||||
const [events, setEvents] = useState<AgentRunEvent[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [connected, setConnected] = useState<AgentStreamState["connected"]>("idle");
|
||||
const lastSeq = useRef(-1);
|
||||
const inflight = useRef(false);
|
||||
const pending = useRef(false);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 换 run 时重置
|
||||
useEffect(() => {
|
||||
setEvents([]);
|
||||
setError(null);
|
||||
lastSeq.current = -1;
|
||||
}, [runId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runId) return;
|
||||
let stopped = false;
|
||||
const ctrl = new AbortController();
|
||||
|
||||
const pull = async () => {
|
||||
if (inflight.current) {
|
||||
pending.current = true;
|
||||
return;
|
||||
}
|
||||
inflight.current = true;
|
||||
try {
|
||||
do {
|
||||
pending.current = false;
|
||||
const next = await fetchRunEvents(runId, lastSeq.current, ctrl.signal);
|
||||
if (stopped) return;
|
||||
const fresh = next.filter((e) => e.seq > lastSeq.current).sort((a, b) => a.seq - b.seq);
|
||||
if (fresh.length) {
|
||||
lastSeq.current = fresh[fresh.length - 1]!.seq;
|
||||
setEvents((prev) => [...prev, ...fresh]);
|
||||
}
|
||||
setError(null);
|
||||
} while (pending.current && !stopped);
|
||||
} catch (err) {
|
||||
if ((err as Error).name !== "AbortError" && !stopped) setError((err as Error).message);
|
||||
} finally {
|
||||
inflight.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
void pull();
|
||||
if (!active)
|
||||
return () => {
|
||||
stopped = true;
|
||||
ctrl.abort();
|
||||
};
|
||||
|
||||
const mock = agentMockEnabled();
|
||||
// 共用页面级 SSE 连接:agent.* 事件只当"有新事件"的提示,按 lastSeq 补读
|
||||
const unsubscribe = mock
|
||||
? null
|
||||
: sse.subscribe(SSE_TYPES, (e) => {
|
||||
const p = e.payload as { runId?: string } | null;
|
||||
if (e.type === EventType.resync || p?.runId === runId) void pull();
|
||||
});
|
||||
setConnected(mock ? "mock" : typeof EventSource !== "undefined" ? "sse" : "poll");
|
||||
// mock 模拟流式:密集轮询;真实端点:SSE 为主,2.5 秒轮询兜底
|
||||
const timer = setInterval(() => void pull(), mock ? 150 : 2500);
|
||||
return () => {
|
||||
stopped = true;
|
||||
ctrl.abort();
|
||||
clearInterval(timer);
|
||||
unsubscribe?.();
|
||||
setConnected("idle");
|
||||
};
|
||||
}, [runId, active]);
|
||||
|
||||
return useMemo(() => {
|
||||
const items = foldEvents(events);
|
||||
const end = [...events].reverse().find((e) => e.type === "run_completed" || e.type === "run_failed");
|
||||
const usage = end && (end.type === "run_completed" || end.type === "run_failed") ? end.usage : null;
|
||||
return { events, items, usage, finished: !!end, error, connected };
|
||||
}, [events, error, connected]);
|
||||
}
|
||||
Reference in new issue
Block a user