feat(agent): in-drawer history tab and denser session layout (AGUI-2)
- Drawer tabs 当前/历史: searchable run list (status/source/time filters, cursor auto-load), opens runs inside the drawer, keeps scroll and filters, remembers the last tab; 在页面打开 is secondary - Ended runs: append to resume, or 用同目标新建 with 先看方案再执行 toggle - Session page: 88ch conversation column, turns grouped with one timestamp, tool cards collapse to one line, 3+ activities fold; steps/subagents/budget moved to a right rail (collapsible overview bar on narrow screens and in the drawer), long model reports fold behind 展开全文 - Smoke covers the drawer history flow Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
ac478673bc
commit
20b63db254
14 files changed
+1234
-349
No files matched your search
@@ -13,6 +13,7 @@ import {
|
|||||||
type AgentRunEvent,
|
type AgentRunEvent,
|
||||||
type AgentRunMessage,
|
type AgentRunMessage,
|
||||||
type AgentRunStatus,
|
type AgentRunStatus,
|
||||||
|
type AgentTrigger,
|
||||||
Approval,
|
Approval,
|
||||||
type ApprovalStatus,
|
type ApprovalStatus,
|
||||||
AuditEvent,
|
AuditEvent,
|
||||||
@@ -119,21 +120,38 @@ function useAgentMockGate(): boolean {
|
|||||||
|
|
||||||
export const agentKeys = {
|
export const agentKeys = {
|
||||||
all: ["agent"] as const,
|
all: ["agent"] as const,
|
||||||
runs: (status?: AgentRunStatus) => ["agent", "list", status ?? "all"] as const,
|
runs: (status?: AgentRunStatus, trigger?: AgentTrigger) =>
|
||||||
|
["agent", "list", status ?? "all", trigger ?? "any"] as const,
|
||||||
run: (id: string) => ["agent", "detail", id] as const,
|
run: (id: string) => ["agent", "detail", id] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
const RUNNING: AgentRunStatus[] = ["queued", "running", "waiting"];
|
const RUNNING: AgentRunStatus[] = ["queued", "running", "waiting"];
|
||||||
export const isRunActive = (s: AgentRunStatus | undefined) => !!s && RUNNING.includes(s);
|
export const isRunActive = (s: AgentRunStatus | undefined) => !!s && RUNNING.includes(s);
|
||||||
|
|
||||||
export function useAgentRuns(status?: AgentRunStatus) {
|
/** 会话来源(run.trigger)的界面名:手动 / 巡检 / 事件 / 子会话 */
|
||||||
|
export const TRIGGER_LABEL: Record<AgentTrigger, string> = {
|
||||||
|
user: "手动",
|
||||||
|
schedule: "巡检",
|
||||||
|
event: "事件",
|
||||||
|
subagent: "子会话",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 会话列表:按状态、来源(trigger)在服务端筛选,游标翻页 */
|
||||||
|
export function useAgentRuns(status?: AgentRunStatus, trigger?: AgentTrigger) {
|
||||||
const mock = useAgentMockGate();
|
const mock = useAgentMockGate();
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: [...agentKeys.runs(status), mock],
|
queryKey: [...agentKeys.runs(status, trigger), mock],
|
||||||
initialPageParam: undefined as string | undefined,
|
initialPageParam: undefined as string | undefined,
|
||||||
queryFn: async ({ pageParam, signal }) => {
|
queryFn: async ({ pageParam, signal }) => {
|
||||||
if (mock) return { items: MOCK_RUNS().filter((r) => !status || r.status === status), nextCursor: null };
|
if (mock)
|
||||||
return apiParsed(RunPage, AGENT_BASE, { params: cleanParams({ status, cursor: pageParam, limit: 30 }), signal });
|
return {
|
||||||
|
items: MOCK_RUNS().filter((r) => (!status || r.status === status) && (!trigger || r.trigger === trigger)),
|
||||||
|
nextCursor: null,
|
||||||
|
};
|
||||||
|
return apiParsed(RunPage, AGENT_BASE, {
|
||||||
|
params: cleanParams({ status, trigger, cursor: pageParam, limit: 30 }),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
getNextPageParam: (last) => last.nextCursor ?? undefined,
|
getNextPageParam: (last) => last.nextCursor ?? undefined,
|
||||||
refetchInterval: (q) =>
|
refetchInterval: (q) =>
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import { StreamingCursor, StreamingResponse } from "~/components/agentui/streami
|
|||||||
import { ToolApproval } from "~/components/agentui/tool-approval.tsx";
|
import { ToolApproval } from "~/components/agentui/tool-approval.tsx";
|
||||||
import { ToolResult, ToolResultOutput, type ToolResultStatus } from "~/components/agentui/tool-result.tsx";
|
import { ToolResult, ToolResultOutput, type ToolResultStatus } from "~/components/agentui/tool-result.tsx";
|
||||||
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.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 { errorMessage } from "~/lib/api.ts";
|
||||||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||||||
import { InlineApproval, OptionsCard } from "./agent-interaction.tsx";
|
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;
|
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/风险徽标、幂等键(审计);服务端拒绝时换醒目拒绝态 */
|
/** 工具调用卡片:脱敏输入/输出折叠、耗时、scope/风险徽标、幂等键(审计);服务端拒绝时换醒目拒绝态 */
|
||||||
export function ToolCallCard({
|
export function ToolCallCard({
|
||||||
call,
|
call,
|
||||||
@@ -198,7 +231,7 @@ export function ToolCallCard({
|
|||||||
extra={call.recovery ? <StatusBadge tone="warning">恢复补记</StatusBadge> : null}
|
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.inputSummary ? <ToolResultOutput label="输入(已脱敏)">{call.inputSummary}</ToolResultOutput> : null}
|
||||||
{call.outputSummary ? (
|
{call.outputSummary ? (
|
||||||
@@ -384,7 +417,112 @@ export function pairAudit(calls: ToolCallView[], audit: InvocationAudit[] | unde
|
|||||||
return out;
|
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({
|
export function AgentConversation({
|
||||||
run,
|
run,
|
||||||
live,
|
live,
|
||||||
@@ -413,16 +551,28 @@ export function AgentConversation({
|
|||||||
const lastIndex = live.items.length - 1;
|
const lastIndex = live.items.length - 1;
|
||||||
const lastText = live.items.map((i) => i.kind).lastIndexOf("text");
|
const lastText = live.items.map((i) => i.kind).lastIndexOf("text");
|
||||||
|
|
||||||
type Row = { key: string; at: string; node: ReactNode };
|
const entries: ConvEntry[] = [];
|
||||||
const rows: Row[] = live.items.map((it, idx) => {
|
live.items.forEach((it, idx) => {
|
||||||
const streaming = active && idx === lastIndex;
|
const streaming = active && idx === lastIndex;
|
||||||
const anim = fresh(idx);
|
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) {
|
switch (it.kind) {
|
||||||
case "text":
|
case "text":
|
||||||
node = (
|
entries.push({
|
||||||
<AssistantRow animateIn={anim}>
|
key: it.key,
|
||||||
|
at: it.at,
|
||||||
|
role: "assistant",
|
||||||
|
foldable: false,
|
||||||
|
node: wrap(
|
||||||
<StreamingResponse
|
<StreamingResponse
|
||||||
|
className="max-w-[72ch]"
|
||||||
status={streaming ? "streaming" : "complete"}
|
status={streaming ? "streaming" : "complete"}
|
||||||
copyText={it.text}
|
copyText={it.text}
|
||||||
announce={false}
|
announce={false}
|
||||||
@@ -430,77 +580,128 @@ export function AgentConversation({
|
|||||||
>
|
>
|
||||||
{/* 流式中按原文显示(半截 Markdown 会闪),结束后按安全子集渲染 */}
|
{/* 流式中按原文显示(半截 Markdown 会闪),结束后按安全子集渲染 */}
|
||||||
{streaming ? it.text : <Markdown text={it.text} />}
|
{streaming ? it.text : <Markdown text={it.text} />}
|
||||||
</StreamingResponse>
|
</StreamingResponse>,
|
||||||
</AssistantRow>
|
),
|
||||||
);
|
});
|
||||||
break;
|
break;
|
||||||
case "thinking":
|
case "thinking":
|
||||||
node = (
|
entries.push({
|
||||||
<AssistantRow animateIn={anim} avatar={false}>
|
key: it.key,
|
||||||
<ThinkingBlock text={it.text} streaming={streaming} />
|
at: it.at,
|
||||||
</AssistantRow>
|
role: "assistant",
|
||||||
);
|
foldable: !streaming,
|
||||||
|
label: "思考",
|
||||||
|
node: wrap(<ThinkingBlock text={it.text} streaming={streaming} />),
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
case "tool":
|
case "tool": {
|
||||||
node = (
|
const denied = isDenied(it.call);
|
||||||
<AssistantRow animateIn={anim} avatar={false}>
|
entries.push({
|
||||||
<ToolCallCard call={it.call} audit={auditByCall.get(it.call.toolCallId)} />
|
key: it.key,
|
||||||
</AssistantRow>
|
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;
|
break;
|
||||||
|
}
|
||||||
case "compaction":
|
case "compaction":
|
||||||
node = (
|
entries.push({
|
||||||
<MessageMarker>
|
key: it.key,
|
||||||
<Layers aria-hidden className="size-3.5 shrink-0" />
|
at: it.at,
|
||||||
上下文压缩({COMPACT_REASON[it.reason] ?? it.reason})·{" "}
|
role: "system",
|
||||||
{it.done ? (COMPACT_STATUS[it.status ?? "completed"] ?? it.status) : "进行中…"}
|
foldable: false,
|
||||||
</MessageMarker>
|
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;
|
break;
|
||||||
case "options":
|
case "options":
|
||||||
node = (
|
entries.push({
|
||||||
<AssistantRow animateIn={anim} avatar={false}>
|
key: it.key,
|
||||||
<OptionsCard runId={run.id} view={it.view} waiting={waiting} />
|
at: it.at,
|
||||||
</AssistantRow>
|
role: "assistant",
|
||||||
);
|
foldable: false,
|
||||||
|
node: wrap(<OptionsCard runId={run.id} view={it.view} waiting={waiting} />),
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
case "approval":
|
case "approval":
|
||||||
node = (
|
entries.push({
|
||||||
<AssistantRow animateIn={anim} avatar={false}>
|
key: it.key,
|
||||||
<InlineApproval view={it.view} stepTitle={stepTitle(it.view.stepId)} />
|
at: it.at,
|
||||||
</AssistantRow>
|
role: "assistant",
|
||||||
);
|
foldable: false,
|
||||||
|
node: wrap(<InlineApproval view={it.view} stepTitle={stepTitle(it.view.stepId)} />),
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
case "end":
|
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;
|
break;
|
||||||
}
|
}
|
||||||
return { key: it.key, at: it.kind === "tool" ? it.call.startedAt : it.at, node };
|
|
||||||
});
|
});
|
||||||
for (const m of userMessages)
|
for (const m of userMessages)
|
||||||
rows.push({
|
entries.push({
|
||||||
key: `u${m.at}`,
|
key: `u${m.at}`,
|
||||||
at: m.at,
|
at: m.at,
|
||||||
|
role: "user",
|
||||||
|
foldable: false,
|
||||||
node: <UserMessage text={m.text} at={m.at} note={m.pending ? "已送达,等待 Agent 读取" : undefined} animateIn />,
|
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 (
|
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} />
|
<UserMessage text={run.goal} at={run.createdAt} tag={isProposeFirst(run) ? "先看方案再执行" : undefined} />
|
||||||
{rows.map((r) => (r.node ? <div key={r.key}>{r.node}</div> : null))}
|
{blocks.map((b) =>
|
||||||
{active && live.items[lastIndex]?.kind !== "text" && live.items[lastIndex]?.kind !== "thinking" ? (
|
b.kind === "single" ? (
|
||||||
<AssistantRow avatar={false}>
|
<div key={b.entry.key}>{b.entry.node}</div>
|
||||||
<span className="inline-flex items-center gap-2 text-meta text-muted-foreground">
|
) : (
|
||||||
<MessageTyping label={run.status === "waiting" ? "等待中" : "正在处理"} />
|
<Message key={b.key} from="assistant" aria-label="Agent 回合">
|
||||||
{run.status === "waiting"
|
<MessageAvatar>
|
||||||
? `${run.summary === "等待选择方案" || run.summary === "等待审批" ? run.summary : "等待中"}…`
|
<Bot />
|
||||||
: run.status === "queued"
|
</MessageAvatar>
|
||||||
? "排队中…"
|
<MessageContent className="gap-2">
|
||||||
: "正在处理…"}
|
<MessageHeader>
|
||||||
</span>
|
<span className="font-medium text-foreground/80">Agent</span>
|
||||||
</AssistantRow>
|
<time dateTime={b.at}>{clockText(b.at)}</time>
|
||||||
) : null}
|
</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}
|
{after}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -552,7 +753,16 @@ function Meter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 预算用量条:工具调用/token/时长/费用;超限的一项标红,上游未报告的显示"未报告",费用标注估算 */
|
/** 预算用量条:工具调用/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 u = { ...run.usage, ...(live.usage ?? {}) };
|
||||||
const toolCalls = Math.max(run.toolCallCount, live.items.filter((i) => i.kind === "tool").length);
|
const toolCalls = Math.max(run.toolCallCount, live.items.filter((i) => i.kind === "tool").length);
|
||||||
const end = [...live.items].reverse().find((i) => i.kind === "end");
|
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()
|
? (run.finishedAt ? new Date(run.finishedAt).getTime() : Date.now()) - new Date(run.startedAt).getTime()
|
||||||
: undefined);
|
: undefined);
|
||||||
return (
|
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
|
<Meter
|
||||||
label="工具调用"
|
label="工具调用"
|
||||||
used={toolCalls}
|
used={toolCalls}
|
||||||
@@ -672,6 +882,42 @@ const PLAN_STATUS_TONE = (s: string): StatusTone =>
|
|||||||
? "destructive"
|
? "destructive"
|
||||||
: "warning";
|
: "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({
|
export function FinalReport({
|
||||||
run,
|
run,
|
||||||
@@ -719,15 +965,17 @@ export function FinalReport({
|
|||||||
const imported = report?.organization.filter((o) => o.targetPath) ?? [];
|
const imported = report?.organization.filter((o) => o.targetPath) ?? [];
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
<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" />
|
<Bot aria-hidden className="size-3.5" />
|
||||||
模型说的(未经核查)
|
模型说的(未经核查)
|
||||||
</p>
|
</p>
|
||||||
<StreamingResponse status="complete" copyText={said || undefined} contentClassName="max-h-60 overflow-auto">
|
<LongText long={isLongReport(said)}>
|
||||||
{said ? <Markdown text={said} /> : "(没有文字汇报)"}
|
<StreamingResponse status="complete" copyText={said || undefined}>
|
||||||
</StreamingResponse>
|
{said ? <Markdown text={said} /> : "(没有文字汇报)"}
|
||||||
|
</StreamingResponse>
|
||||||
|
</LongText>
|
||||||
<p className="mt-2 text-meta text-muted-foreground">仅供参考,不作为完成依据。</p>
|
<p className="mt-2 text-meta text-muted-foreground">仅供参考,不作为完成依据。</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 rounded-xl border border-border/70 bg-foreground/[0.02] p-3">
|
<div className="min-w-0 rounded-xl border border-border/70 bg-foreground/[0.02] p-3">
|
||||||
|
|||||||
@@ -1,27 +1,35 @@
|
|||||||
import { Bot, ExternalLink, Maximize2, Minus, Plus, X } from "lucide-react";
|
import { ArrowLeft, Bot, ExternalLink, History, Maximize2, Minus, Plus, X } from "lucide-react";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { Link, useLocation } from "react-router";
|
import { Link, useLocation } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { isRunActive, useAgentRun, useCreateAgentRun } from "~/api/agent.ts";
|
import { agentMockEnabled, isRunActive, useAgentRun, useCreateAgentRun } from "~/api/agent.ts";
|
||||||
import { StreamingCursor } from "~/components/agentui/streaming-response.tsx";
|
import { StreamingCursor } from "~/components/agentui/streaming-response.tsx";
|
||||||
import { Button } from "~/components/ui/button.tsx";
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
|
||||||
import { errorMessage } from "~/lib/api.ts";
|
import { errorMessage } from "~/lib/api.ts";
|
||||||
|
import { cn } from "~/lib/utils.ts";
|
||||||
import { capabilityMeta, isDenied, RunStatusBadge } from "./agent-conversation.tsx";
|
import { capabilityMeta, isDenied, RunStatusBadge } from "./agent-conversation.tsx";
|
||||||
import { AgentRunPanel, GoalComposer } from "./agent-run-panel.tsx";
|
import { AgentRunPanel, GoalComposer } from "./agent-run-panel.tsx";
|
||||||
|
import { AgentSessionList } from "./agent-session-list.tsx";
|
||||||
import {
|
import {
|
||||||
type AgentContext,
|
type AgentContext,
|
||||||
|
type AgentDrawerTab,
|
||||||
agentShortcutLabel,
|
agentShortcutLabel,
|
||||||
contextFromPath,
|
contextFromPath,
|
||||||
contextPrefix,
|
contextPrefix,
|
||||||
|
lastAgentDrawerTab,
|
||||||
minimizeAgentDrawer,
|
minimizeAgentDrawer,
|
||||||
openAgentDrawer,
|
openAgentDrawer,
|
||||||
|
openAgentHistoryRun,
|
||||||
restoreAgentDrawer,
|
restoreAgentDrawer,
|
||||||
setAgentDrawerOpen,
|
setAgentDrawerOpen,
|
||||||
setAgentDrawerRun,
|
setAgentDrawerRun,
|
||||||
|
setAgentDrawerTab,
|
||||||
|
showAgentRunInDrawer,
|
||||||
splitContextGoal,
|
splitContextGoal,
|
||||||
useAgentDrawer,
|
useAgentDrawer,
|
||||||
} from "./agent-store.ts";
|
} from "./agent-store.ts";
|
||||||
|
import { AgentRunOpenContext } from "./agent-subagent-tree.tsx";
|
||||||
import { quickGoalsFor } from "./quick-goals.ts";
|
import { quickGoalsFor } from "./quick-goals.ts";
|
||||||
import { useAgentStream } from "./use-agent-stream.ts";
|
import { useAgentStream } from "./use-agent-stream.ts";
|
||||||
|
|
||||||
@@ -55,18 +63,81 @@ function ContextChips({ context }: { context: AgentContext }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TABS: { value: AgentDrawerTab; label: string }[] = [
|
||||||
|
{ value: "current", label: "当前" },
|
||||||
|
{ value: "history", label: "历史" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function DrawerTabs({ tab }: { tab: AgentDrawerTab }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Agent 视图"
|
||||||
|
className="mt-2 inline-flex w-fit gap-1 rounded-full border border-border/70 p-0.5"
|
||||||
|
>
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.value}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
id={`agent-tab-${t.value}`}
|
||||||
|
aria-selected={tab === t.value}
|
||||||
|
aria-controls="agent-tabpanel"
|
||||||
|
onClick={() => setAgentDrawerTab(t.value)}
|
||||||
|
className={cn(
|
||||||
|
"h-7 cursor-pointer rounded-full border border-transparent px-3 text-meta text-muted-foreground outline-none transition-colors duration-micro hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
tab === t.value && "pill-selected text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 历史 Tab 里打开的一条会话:返回列表 + 目标 + 在页面打开(次要);正文复用会话组件,进行中可继续输入 */
|
||||||
|
function HistoryRunView({ runId }: { runId: string }) {
|
||||||
|
const run = useAgentRun(runId);
|
||||||
|
const mock = agentMockEnabled();
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||||
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
|
<Button variant="ghost" size="sm" className="-ml-2 shrink-0" onClick={() => openAgentHistoryRun(null)}>
|
||||||
|
<ArrowLeft />
|
||||||
|
返回列表
|
||||||
|
</Button>
|
||||||
|
<p className="min-w-0 flex-1 truncate text-caption text-muted-foreground" title={run.data?.run.goal}>
|
||||||
|
{run.data ? splitContextGoal(run.data.run.goal).text : ""}
|
||||||
|
</p>
|
||||||
|
<Button asChild variant="ghost" size="xs" className="shrink-0 text-muted-foreground">
|
||||||
|
<Link to={`/console/agent/${runId}${mock ? "?mock=1" : ""}`} onClick={() => setAgentDrawerOpen(false)}>
|
||||||
|
<ExternalLink />
|
||||||
|
在页面打开
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<AgentRunOpenContext.Provider value={openAgentHistoryRun}>
|
||||||
|
<AgentRunPanel key={runId} runId={runId} compact className="flex-1" onCreated={showAgentRunInDrawer} />
|
||||||
|
</AgentRunOpenContext.Provider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent 侧栏抽屉:任意页面可打开,带当前页面上下文(entityId / siteId / taskId 等)作为 goal 前缀;
|
* Agent 侧栏抽屉:任意页面可打开,带当前页面上下文(entityId / siteId / taskId 等)作为 goal 前缀;
|
||||||
|
* 顶部 Tab「当前 / 历史」:历史里点一条在抽屉内打开(不跳页),返回列表保留筛选与滚动位置;抽屉记住上次 Tab。
|
||||||
* 运行中可最小化到右下角小窗,流式输出继续。
|
* 运行中可最小化到右下角小窗,流式输出继续。
|
||||||
* 挂载:在 console layout 里渲染一次 <AgentDrawer />;顶栏放 <AgentDrawerTrigger />。
|
* 挂载:在 console layout 里渲染一次 <AgentDrawer />;顶栏放 <AgentDrawerTrigger />。
|
||||||
* 页面内打开:import { openAgentDrawer } from "~/components/agent/agent-store.ts"。
|
* 页面内打开:import { openAgentDrawer } from "~/components/agent/agent-store.ts"。
|
||||||
*/
|
*/
|
||||||
export function AgentDrawer() {
|
export function AgentDrawer() {
|
||||||
const { open, minimized, context, runId } = useAgentDrawer();
|
const { open, minimized, context, runId, tab, historyRunId } = useAgentDrawer();
|
||||||
const create = useCreateAgentRun();
|
const create = useCreateAgentRun();
|
||||||
const prefix = contextPrefix(context);
|
const prefix = contextPrefix(context);
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const content = useRef<HTMLDivElement>(null);
|
const content = useRef<HTMLDivElement>(null);
|
||||||
|
const history = tab === "history";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -87,20 +158,21 @@ export function AgentDrawer() {
|
|||||||
if ((e.target as Element | null)?.closest?.(`[${AGENT_LAUNCHER_ATTR}]`)) e.preventDefault();
|
if ((e.target as Element | null)?.closest?.(`[${AGENT_LAUNCHER_ATTR}]`)) e.preventDefault();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SheetHeader className="border-b border-[var(--shell-border)] px-5 py-4 pr-24">
|
<SheetHeader className="gap-1 border-b border-[var(--shell-border)] px-5 pt-4 pb-3 pr-24">
|
||||||
<SheetTitle className="flex items-center gap-2 text-lead">
|
<SheetTitle className="flex items-center gap-2 text-lead">
|
||||||
<Bot className="h-4 w-4" />
|
<Bot className="h-4 w-4" />
|
||||||
Agent
|
Agent
|
||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
<SheetDescription className="break-words">
|
<SheetDescription className={cn("break-words", history && "sr-only")}>
|
||||||
{context?.label ? `当前对象:${context.label}` : "交代目标,Agent 在授权范围内执行并汇报。"}
|
{context?.label ? `当前对象:${context.label}` : "交代目标,Agent 在授权范围内执行并汇报。"}
|
||||||
<span className="ml-1 hidden text-muted-foreground/75 sm:inline">
|
<span className="ml-1 hidden text-muted-foreground/75 sm:inline">
|
||||||
({agentShortcutLabel()} 开关,Esc 关闭)
|
({agentShortcutLabel()} 开关,Esc 关闭)
|
||||||
</span>
|
</span>
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
{context ? <ContextChips context={context} /> : null}
|
{context && !history ? <ContextChips context={context} /> : null}
|
||||||
|
<DrawerTabs tab={tab} />
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
{runId ? (
|
{runId && !history ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
@@ -112,9 +184,27 @@ export function AgentDrawer() {
|
|||||||
<Minus />
|
<Minus />
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="flex min-h-0 flex-1 flex-col px-4 py-3">
|
<div
|
||||||
{runId ? (
|
id="agent-tabpanel"
|
||||||
<AgentRunPanel runId={runId} compact className="flex-1" />
|
role="tabpanel"
|
||||||
|
aria-labelledby={`agent-tab-${tab}`}
|
||||||
|
className="flex min-h-0 flex-1 flex-col px-4 py-3"
|
||||||
|
>
|
||||||
|
{history ? (
|
||||||
|
historyRunId ? (
|
||||||
|
<HistoryRunView runId={historyRunId} />
|
||||||
|
) : (
|
||||||
|
<AgentSessionList
|
||||||
|
remember
|
||||||
|
activeId={runId ?? undefined}
|
||||||
|
onOpen={openAgentHistoryRun}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : runId ? (
|
||||||
|
<AgentRunOpenContext.Provider value={openAgentHistoryRun}>
|
||||||
|
<AgentRunPanel runId={runId} compact className="flex-1" onCreated={showAgentRunInDrawer} />
|
||||||
|
</AgentRunOpenContext.Provider>
|
||||||
) : (
|
) : (
|
||||||
<GoalComposer
|
<GoalComposer
|
||||||
key={`${context?.entityId ?? ""}${context?.releaseId ?? ""}${context?.goal ?? ""}`}
|
key={`${context?.entityId ?? ""}${context?.releaseId ?? ""}${context?.goal ?? ""}`}
|
||||||
@@ -135,28 +225,29 @@ export function AgentDrawer() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-[var(--shell-border)] px-5 py-3">
|
{history ? null : (
|
||||||
{runId ? (
|
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-[var(--shell-border)] px-5 py-2.5">
|
||||||
<>
|
{runId ? (
|
||||||
<Button variant="ghost" size="sm" onClick={() => openAgentDrawer(context)}>
|
<>
|
||||||
<Plus />
|
<Button variant="ghost" size="sm" onClick={() => openAgentDrawer(context)}>
|
||||||
新目标
|
<Plus />
|
||||||
|
新目标
|
||||||
|
</Button>
|
||||||
|
<Button asChild variant="ghost" size="sm" className="text-muted-foreground">
|
||||||
|
<Link to={`/console/agent/${runId}`} onClick={() => setAgentDrawerOpen(false)}>
|
||||||
|
<ExternalLink />
|
||||||
|
在页面打开
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setAgentDrawerTab("history")}>
|
||||||
|
<History />
|
||||||
|
历史会话
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild variant="outline" size="sm">
|
)}
|
||||||
<Link to={`/console/agent/${runId}`} onClick={() => setAgentDrawerOpen(false)}>
|
</div>
|
||||||
<ExternalLink />
|
)}
|
||||||
在会话页打开
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Button asChild variant="ghost" size="sm">
|
|
||||||
<Link to="/console/agent" onClick={() => setAgentDrawerOpen(false)}>
|
|
||||||
全部会话
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
{minimized && runId ? <AgentMiniDock runId={runId} /> : null}
|
{minimized && runId ? <AgentMiniDock runId={runId} /> : null}
|
||||||
@@ -237,7 +328,9 @@ export function AgentDrawerTrigger({ className }: { className?: string }) {
|
|||||||
aria-label="打开 Agent"
|
aria-label="打开 Agent"
|
||||||
title={`Agent(${agentShortcutLabel()})`}
|
title={`Agent(${agentShortcutLabel()})`}
|
||||||
className={className}
|
className={className}
|
||||||
onClick={() => (minimized && runId ? restoreAgentDrawer() : openAgentDrawer(contextFromPath(pathname)))}
|
onClick={() =>
|
||||||
|
minimized && runId ? restoreAgentDrawer() : openAgentDrawer(contextFromPath(pathname), lastAgentDrawerTab())
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Bot />
|
<Bot />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import type { AgentRunEvent } from "@umt/contracts";
|
import type { AgentRunEvent } from "@umt/contracts";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { authDenial, isDenied, pairAudit, runPlanIds } from "./agent-conversation.tsx";
|
import {
|
||||||
|
authDenial,
|
||||||
|
type ConvEntry,
|
||||||
|
groupConversation,
|
||||||
|
isDenied,
|
||||||
|
isLongReport,
|
||||||
|
pairAudit,
|
||||||
|
runPlanIds,
|
||||||
|
toolSummary,
|
||||||
|
} from "./agent-conversation.tsx";
|
||||||
|
import { matchesHistory, rangeStart } from "./agent-session-list.tsx";
|
||||||
import { contextFromPath, contextPrefix, splitContextGoal } from "./agent-store.ts";
|
import { contextFromPath, contextPrefix, splitContextGoal } from "./agent-store.ts";
|
||||||
import { foldEvents, type StreamItem, type ToolCallView } from "./use-agent-stream.ts";
|
import { foldEvents, type StreamItem, type ToolCallView } from "./use-agent-stream.ts";
|
||||||
|
|
||||||
@@ -111,3 +121,67 @@ describe("事件折叠", () => {
|
|||||||
expect(tools[0]?.kind === "tool" && tools[0].call).toMatchObject({ status: "succeeded", recovery: true });
|
expect(tools[0]?.kind === "tool" && tools[0].call).toMatchObject({ status: "succeeded", recovery: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("会话排版(AGUI-2)", () => {
|
||||||
|
const e = (key: string, role: ConvEntry["role"], foldable = false): ConvEntry => ({
|
||||||
|
key,
|
||||||
|
at: key,
|
||||||
|
role,
|
||||||
|
foldable,
|
||||||
|
label: foldable ? "查本地库存" : undefined,
|
||||||
|
node: null,
|
||||||
|
});
|
||||||
|
it("同一助手回合归为一组,用户 / 系统条目打断回合", () => {
|
||||||
|
const blocks = groupConversation([e("a", "assistant"), e("b", "assistant"), e("c", "user"), e("d", "assistant")]);
|
||||||
|
expect(blocks.map((b) => b.kind)).toEqual(["turn", "single", "turn"]);
|
||||||
|
const first = blocks[0];
|
||||||
|
expect(first?.kind === "turn" && first.parts.length).toBe(2);
|
||||||
|
});
|
||||||
|
it("连续 ≥3 条可折叠的活动并成一行,不足 3 条原样展示", () => {
|
||||||
|
const [t] = groupConversation([
|
||||||
|
e("1", "assistant", true),
|
||||||
|
e("2", "assistant", true),
|
||||||
|
e("3", "assistant", true),
|
||||||
|
e("4", "assistant"),
|
||||||
|
e("5", "assistant", true),
|
||||||
|
e("6", "assistant", true),
|
||||||
|
]);
|
||||||
|
expect(t?.kind).toBe("turn");
|
||||||
|
if (t?.kind !== "turn") return;
|
||||||
|
expect(t.parts.map((p) => (p.kind === "fold" ? `fold${p.entries.length}` : p.entry.key))).toEqual([
|
||||||
|
"fold3",
|
||||||
|
"4",
|
||||||
|
"5",
|
||||||
|
"6",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it("工具卡一行摘要", () => {
|
||||||
|
expect(toolSummary({ status: "running", outputSummary: null })).toBe("等待结果…");
|
||||||
|
expect(toolSummary({ status: "succeeded", outputSummary: '{"items":[1,2,3]}' })).toBe("3 条结果");
|
||||||
|
expect(toolSummary({ status: "failed", outputSummary: '{"error":{"code":"x","message":"超时"}}' })).toBe("超时");
|
||||||
|
expect(toolSummary({ status: "succeeded", outputSummary: '{"found":true,"count":2}' })).toBe(
|
||||||
|
"found: true · count: 2",
|
||||||
|
);
|
||||||
|
expect(toolSummary({ status: "succeeded", outputSummary: "下载器连接超时" })).toBe("下载器连接超时");
|
||||||
|
});
|
||||||
|
it("长汇报判定", () => {
|
||||||
|
expect(isLongReport("短")).toBe(false);
|
||||||
|
expect(isLongReport("x".repeat(601))).toBe(true);
|
||||||
|
expect(isLongReport(Array(14).fill("行").join("\n"))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("会话历史筛选", () => {
|
||||||
|
const now = new Date("2026-09-29T12:00:00Z").getTime();
|
||||||
|
const run = { id: "abcd1234-0000", goal: "[上下文 作品 x] 把 沙丘 收进来", createdAt: "2026-09-25T12:00:00Z" };
|
||||||
|
it("搜索匹配目标或 ID 前缀", () => {
|
||||||
|
expect(matchesHistory(run, { q: "沙丘", range: "" }, now)).toBe(true);
|
||||||
|
expect(matchesHistory(run, { q: "abcd", range: "" }, now)).toBe(true);
|
||||||
|
expect(matchesHistory(run, { q: "奥本海默", range: "" }, now)).toBe(false);
|
||||||
|
});
|
||||||
|
it("时间范围按创建时间", () => {
|
||||||
|
expect(matchesHistory(run, { q: "", range: "7d" }, now)).toBe(true);
|
||||||
|
expect(matchesHistory(run, { q: "", range: "today" }, now)).toBe(false);
|
||||||
|
expect(rangeStart("", now)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,15 +32,18 @@ export function planTodoItems(steps: AgentPlanStep[]): TodoItem[] {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 步骤清单:会话页顶部完整列表(实时勾选);抽屉里压缩成一行进度 */
|
/** 步骤清单:会话页右侧栏完整列表(实时勾选);compact 压缩成一行进度 */
|
||||||
export function RunPlan({
|
export function RunPlan({
|
||||||
plan,
|
plan,
|
||||||
active,
|
active,
|
||||||
compact = false,
|
compact = false,
|
||||||
|
fill = false,
|
||||||
}: {
|
}: {
|
||||||
plan: { steps: AgentPlanStep[]; revision: number } | null;
|
plan: { steps: AgentPlanStep[]; revision: number } | null;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
/** 侧栏 / 展开的概况条里:不限高、不自动收起 */
|
||||||
|
fill?: boolean;
|
||||||
}) {
|
}) {
|
||||||
if (!plan?.steps.length) return null;
|
if (!plan?.steps.length) return null;
|
||||||
const done = plan.steps.filter((s) => s.status === "done" || s.status === "skipped").length;
|
const done = plan.steps.filter((s) => s.status === "done" || s.status === "skipped").length;
|
||||||
@@ -79,8 +82,8 @@ export function RunPlan({
|
|||||||
title="步骤"
|
title="步骤"
|
||||||
items={planTodoItems(plan.steps)}
|
items={planTodoItems(plan.steps)}
|
||||||
defaultOpen
|
defaultOpen
|
||||||
collapseOnComplete={!active}
|
collapseOnComplete={!fill && !active}
|
||||||
maxHeightClassName="max-h-56"
|
maxHeightClassName={fill ? "max-h-none" : "max-h-56"}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { EventType } from "@umt/contracts";
|
import { type AgentRunStatus, EventType } from "@umt/contracts";
|
||||||
import { FlaskConical, RefreshCw, Sparkles, WifiOff } from "lucide-react";
|
import { ChevronDown, FlaskConical, ListChecks, RefreshCw, RotateCcw, Sparkles, WifiOff } from "lucide-react";
|
||||||
import { useId, useRef, useState } from "react";
|
import { useId, useRef, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
type AgentSessionOptions,
|
type AgentSessionOptions,
|
||||||
@@ -9,12 +10,16 @@ import {
|
|||||||
agentMockEnabled,
|
agentMockEnabled,
|
||||||
isProposeFirst,
|
isProposeFirst,
|
||||||
isRunActive,
|
isRunActive,
|
||||||
|
type RunSnapshot,
|
||||||
|
TRIGGER_LABEL,
|
||||||
useAbortAgentRun,
|
useAbortAgentRun,
|
||||||
useAgentRun,
|
useAgentRun,
|
||||||
useAppendAgentMessage,
|
useAppendAgentMessage,
|
||||||
|
useCreateAgentRun,
|
||||||
useRunApprovals,
|
useRunApprovals,
|
||||||
useRunInvocationAudit,
|
useRunInvocationAudit,
|
||||||
} from "~/api/agent.ts";
|
} from "~/api/agent.ts";
|
||||||
|
import { CHEVRON_CLASS, Disclosure } from "~/components/agentui/disclosure.tsx";
|
||||||
import { MessageScroller } from "~/components/agentui/message-scroller.tsx";
|
import { MessageScroller } from "~/components/agentui/message-scroller.tsx";
|
||||||
import { PromptInput } from "~/components/agentui/prompt-input.tsx";
|
import { PromptInput } from "~/components/agentui/prompt-input.tsx";
|
||||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||||
@@ -37,9 +42,7 @@ import {
|
|||||||
} from "./agent-conversation.tsx";
|
} from "./agent-conversation.tsx";
|
||||||
import { RunPlan } from "./agent-interaction.tsx";
|
import { RunPlan } from "./agent-interaction.tsx";
|
||||||
import { SubagentTree } from "./agent-subagent-tree.tsx";
|
import { SubagentTree } from "./agent-subagent-tree.tsx";
|
||||||
import { useAgentStream } from "./use-agent-stream.ts";
|
import { type AgentStreamState, useAgentStream } from "./use-agent-stream.ts";
|
||||||
|
|
||||||
const TRIGGER_LABEL = { user: "用户发起", event: "事件触发", schedule: "定时", subagent: "子会话" } as const;
|
|
||||||
|
|
||||||
/** "先看方案再执行"开关(会话选项,默认关:自动化优先,按步骤直接执行,需授权的动作在步骤处审批) */
|
/** "先看方案再执行"开关(会话选项,默认关:自动化优先,按步骤直接执行,需授权的动作在步骤处审批) */
|
||||||
export function ProposeFirstToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
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({
|
export function AgentRunPanel({
|
||||||
runId,
|
runId,
|
||||||
compact = false,
|
compact = false,
|
||||||
className,
|
className,
|
||||||
|
onCreated,
|
||||||
}: {
|
}: {
|
||||||
runId: string;
|
runId: string;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** 用同目标新建后:缺省跳到新会话页;抽屉里改成在抽屉内打开 */
|
||||||
|
onCreated?: (runId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const run = useAgentRun(runId);
|
const run = useAgentRun(runId);
|
||||||
const active = isRunActive(run.data?.run.status);
|
const active = isRunActive(run.data?.run.status);
|
||||||
const live = useAgentStream(runId, active || !run.data);
|
const live = useAgentStream(runId, active || !run.data);
|
||||||
const abort = useAbortAgentRun();
|
const abort = useAbortAgentRun();
|
||||||
const append = useAppendAgentMessage();
|
const append = useAppendAgentMessage();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [msg, setMsg] = useState("");
|
const [msg, setMsg] = useState("");
|
||||||
const [confirmStop, setConfirmStop] = useState(false);
|
const [confirmStop, setConfirmStop] = useState(false);
|
||||||
const sseStatus = useSseStatus();
|
const sseStatus = useSseStatus();
|
||||||
@@ -190,13 +332,14 @@ export function AgentRunPanel({
|
|||||||
const { run: r, report, messages } = run.data;
|
const { run: r, report, messages } = run.data;
|
||||||
const finished = !isRunActive(r.status) || live.finished;
|
const finished = !isRunActive(r.status) || live.finished;
|
||||||
const streaming = active && !live.finished;
|
const streaming = active && !live.finished;
|
||||||
const proposeFirst = isProposeFirst(r);
|
const shownStatus: AgentRunStatus =
|
||||||
const shownStatus =
|
|
||||||
// 事件流已结束但快照还没刷到终态时按运行中显示;waiting(等待下载/入库核验)照实显示
|
// 事件流已结束但快照还没刷到终态时按运行中显示;waiting(等待下载/入库核验)照实显示
|
||||||
(live.finished && (r.status === "queued" || r.status === "running")) ||
|
(live.finished && (r.status === "queued" || r.status === "running")) ||
|
||||||
(r.status === "queued" && live.events.length > 0)
|
(r.status === "queued" && live.events.length > 0)
|
||||||
? "running"
|
? "running"
|
||||||
: r.status;
|
: r.status;
|
||||||
|
const ended = !isRunActive(r.status);
|
||||||
|
const created = onCreated ?? ((id: string) => navigate(`/console/agent/${id}${agentMockEnabled() ? "?mock=1" : ""}`));
|
||||||
|
|
||||||
const send = (text: string) =>
|
const send = (text: string) =>
|
||||||
append.mutate(
|
append.mutate(
|
||||||
@@ -210,101 +353,122 @@ export function AgentRunPanel({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const column = "mx-auto w-full max-w-[88ch]";
|
||||||
|
|
||||||
return (
|
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} />
|
<RunAnnouncer status={shownStatus} finished={live.finished} />
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="@container flex min-h-0 min-w-0 flex-1 flex-col gap-3">
|
||||||
<RunStatusBadge status={shownStatus} />
|
<RunOverviewBar
|
||||||
{proposeFirst ? <StatusBadge>先看方案再执行</StatusBadge> : null}
|
snapshot={run.data}
|
||||||
<span className="min-w-0 text-meta text-muted-foreground">
|
status={shownStatus}
|
||||||
{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}
|
|
||||||
live={live}
|
live={live}
|
||||||
audit={audit.data}
|
active={streaming || r.status === "waiting"}
|
||||||
userMessages={messages.map((m) => ({ at: m.createdAt, text: m.text, pending: m.status === "pending" }))}
|
className={cn(column, !compact && "lg:hidden")}
|
||||||
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="space-y-1.5">
|
{streaming && sseStatus === "reconnecting" ? (
|
||||||
<PromptInput
|
<div
|
||||||
value={msg}
|
role="status"
|
||||||
onValueChange={setMsg}
|
className={cn(
|
||||||
onSubmit={send}
|
column,
|
||||||
pending={append.isPending}
|
"flex items-center gap-2 rounded-xl border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning",
|
||||||
loading={streaming}
|
)}
|
||||||
onStop={() => setConfirmStop(true)}
|
>
|
||||||
stopLabel={abort.isPending ? "停止中…" : "停止"}
|
<WifiOff aria-hidden className="size-4 shrink-0" />
|
||||||
minRows={1}
|
<span className="min-w-0 flex-1">实时连接中断,正在重连。期间每 2.5 秒轮询一次。</span>
|
||||||
maxRows={6}
|
</div>
|
||||||
maxLength={8000}
|
) : null}
|
||||||
aria-label="追加消息"
|
{live.error ? (
|
||||||
placeholder={streaming ? "补充或纠正(运行中也可追加)" : "追加消息"}
|
<div
|
||||||
/>
|
role="alert"
|
||||||
<p className="px-1 text-meta text-muted-foreground">
|
className={cn(
|
||||||
{r.status === "waiting" && (r.summary === "等待选择方案" || r.summary === "等待审批")
|
column,
|
||||||
? `${r.summary}:在上面的卡片里处理,会话随即继续。`
|
"flex flex-wrap items-center gap-2 rounded-xl border border-destructive/40 bg-destructive/10 px-3 py-2 text-caption text-destructive",
|
||||||
: "追加的消息在本轮结束后读取。"}{" "}
|
)}
|
||||||
Enter 发送,Shift+Enter 换行{streaming ? ",停止 = 结束本次运行" : ""}。
|
>
|
||||||
</p>
|
<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>
|
</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
|
<ConfirmDialog
|
||||||
open={confirmStop}
|
open={confirmStop}
|
||||||
onOpenChange={setConfirmStop}
|
onOpenChange={setConfirmStop}
|
||||||
|
|||||||
@@ -1,25 +1,63 @@
|
|||||||
import type { AgentRunStatus } from "@umt/contracts";
|
import type { AgentRunStatus, AgentTrigger } from "@umt/contracts";
|
||||||
import { Bot } from "lucide-react";
|
import { Bot, ChevronRight } from "lucide-react";
|
||||||
|
import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { type AgentRunView, agentMockEnabled, RUN_STATUS_LABEL, useAgentRuns } from "~/api/agent.ts";
|
import { type AgentRunView, RUN_STATUS_LABEL, TRIGGER_LABEL, useAgentRuns } from "~/api/agent.ts";
|
||||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||||
import { Button } from "~/components/ui/button.tsx";
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
|
import { Input } from "~/components/ui/input.tsx";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
||||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||||
import { cn } from "~/lib/utils.ts";
|
import { cn } from "~/lib/utils.ts";
|
||||||
import { RunStatusBadge } from "./agent-conversation.tsx";
|
import { RunStatusBadge } from "./agent-conversation.tsx";
|
||||||
import { splitContextGoal } from "./agent-store.ts";
|
import { splitContextGoal } from "./agent-store.ts";
|
||||||
|
|
||||||
const FILTERS: { value: "" | AgentRunStatus; label: string }[] = [
|
// 会话历史列表:会话页左栏与抽屉「历史」Tab 共用。状态、来源在服务端筛选(游标翻页),
|
||||||
{ value: "", label: "全部" },
|
// 搜索与时间在已加载的条目里筛(按创建时间倒序,早于时间范围的页不再加载)。
|
||||||
{ value: "running", label: RUN_STATUS_LABEL.running },
|
|
||||||
{ value: "waiting", label: RUN_STATUS_LABEL.waiting },
|
export type TimeRange = "" | "today" | "7d" | "30d";
|
||||||
{ value: "succeeded", label: RUN_STATUS_LABEL.succeeded },
|
export type HistoryFilters = { q: string; status: "" | AgentRunStatus; trigger: "" | AgentTrigger; range: TimeRange };
|
||||||
{ value: "failed", label: RUN_STATUS_LABEL.failed },
|
export const EMPTY_FILTERS: HistoryFilters = { q: "", status: "", trigger: "", range: "" };
|
||||||
{ value: "budget_exceeded", label: RUN_STATUS_LABEL.budget_exceeded },
|
|
||||||
{ value: "cancelled", label: RUN_STATUS_LABEL.cancelled },
|
const STATUS_OPTIONS = (
|
||||||
|
["running", "waiting", "succeeded", "failed", "budget_exceeded", "cancelled"] as AgentRunStatus[]
|
||||||
|
).map((s) => ({ value: s, label: RUN_STATUS_LABEL[s] }));
|
||||||
|
const TRIGGER_OPTIONS = (["user", "schedule", "event", "subagent"] as AgentTrigger[]).map((t) => ({
|
||||||
|
value: t,
|
||||||
|
label: TRIGGER_LABEL[t],
|
||||||
|
}));
|
||||||
|
const RANGE_OPTIONS = [
|
||||||
|
{ value: "today", label: "今天" },
|
||||||
|
{ value: "7d", label: "近 7 天" },
|
||||||
|
{ value: "30d", label: "近 30 天" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function usageText(r: AgentRunView) {
|
/** 时间范围的起点(毫秒);不限为 null */
|
||||||
|
export function rangeStart(range: TimeRange, now = Date.now()): number | null {
|
||||||
|
if (range === "today") {
|
||||||
|
const d = new Date(now);
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
return d.getTime();
|
||||||
|
}
|
||||||
|
if (range === "7d") return now - 7 * 86_400_000;
|
||||||
|
if (range === "30d") return now - 30 * 86_400_000;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 纯函数(单测覆盖):搜索词匹配目标正文、上下文或会话 ID 前缀;时间按创建时间 */
|
||||||
|
export function matchesHistory(
|
||||||
|
run: Pick<AgentRunView, "id" | "goal" | "createdAt">,
|
||||||
|
f: Pick<HistoryFilters, "q" | "range">,
|
||||||
|
now = Date.now(),
|
||||||
|
): boolean {
|
||||||
|
const start = rangeStart(f.range, now);
|
||||||
|
if (start !== null && new Date(run.createdAt).getTime() < start) return false;
|
||||||
|
const q = f.q.trim().toLowerCase();
|
||||||
|
if (!q) return true;
|
||||||
|
return run.goal.toLowerCase().includes(q) || run.id.startsWith(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usageText(r: Pick<AgentRunView, "toolCallCount" | "usage">) {
|
||||||
const parts = [`${r.toolCallCount} 次调用`];
|
const parts = [`${r.toolCallCount} 次调用`];
|
||||||
if (r.usage.totalTokens !== undefined)
|
if (r.usage.totalTokens !== undefined)
|
||||||
parts.push(
|
parts.push(
|
||||||
@@ -31,104 +69,232 @@ function usageText(r: AgentRunView) {
|
|||||||
return parts.join(" · ");
|
return parts.join(" · ");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 会话列表(侧栏):状态筛选 + 每条的目标/状态/模式/时间/用量 */
|
const ALL = "__all__";
|
||||||
|
|
||||||
|
/** 窄栏里的下拉:只显示取值("全部"时显示维度名),读屏名带维度 */
|
||||||
|
function MiniSelect({
|
||||||
|
label,
|
||||||
|
allLabel,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
allLabel: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
options: { value: string; label: string }[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Select value={value || ALL} onValueChange={(v) => onChange(v === ALL ? "" : v)}>
|
||||||
|
<SelectTrigger
|
||||||
|
size="sm"
|
||||||
|
aria-label={label}
|
||||||
|
className={cn("h-7 min-w-0 flex-1 gap-1 px-2 text-meta", !value && "text-muted-foreground")}
|
||||||
|
>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent position="popper">
|
||||||
|
<SelectItem value={ALL}>{allLabel}</SelectItem>
|
||||||
|
{options.map((o) => (
|
||||||
|
<SelectItem key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 抽屉里跨开关保留的筛选与滚动位置(模块级,刷新页面才清) */
|
||||||
|
const drawerMemory = { filters: EMPTY_FILTERS, scroll: 0 };
|
||||||
|
|
||||||
|
function RunRow({ r, current, children }: { r: AgentRunView; current: boolean; children: ReactNode }) {
|
||||||
|
const goal = splitContextGoal(r.goal);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<p className="min-w-0 flex-1 truncate text-caption text-foreground" title={goal.text}>
|
||||||
|
{goal.text}
|
||||||
|
</p>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1">
|
||||||
|
<RunStatusBadge status={r.status} />
|
||||||
|
<span className="text-meta text-muted-foreground">{TRIGGER_LABEL[r.trigger]}</span>
|
||||||
|
{r.scope.proposeFirst === true ? <span className="text-meta text-muted-foreground">· 先看方案</span> : null}
|
||||||
|
<time
|
||||||
|
dateTime={r.createdAt}
|
||||||
|
className={cn("ml-auto text-meta whitespace-nowrap text-muted-foreground", current && "text-foreground/80")}
|
||||||
|
>
|
||||||
|
{relTime(r.createdAt)}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 truncate text-meta text-muted-foreground tabular-nums">
|
||||||
|
{goal.context ? `${goal.context.split(" · ")[0]} · ` : ""}
|
||||||
|
{usageText(r)}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话列表:搜索 + 状态 / 来源 / 时间筛选 + 游标加载(滚到底自动加载)。
|
||||||
|
* - hrefFor:会话页左栏,点一条跳到该会话页。
|
||||||
|
* - onOpen:抽屉里,点一条在抽屉内打开;remember 时筛选与滚动位置在返回列表时恢复。
|
||||||
|
*/
|
||||||
export function AgentSessionList({
|
export function AgentSessionList({
|
||||||
activeId,
|
activeId,
|
||||||
status,
|
hrefFor,
|
||||||
onStatusChange,
|
onOpen,
|
||||||
|
remember = false,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
activeId?: string;
|
activeId?: string;
|
||||||
status: "" | AgentRunStatus;
|
hrefFor?: (id: string) => string;
|
||||||
onStatusChange: (s: "" | AgentRunStatus) => void;
|
onOpen?: (id: string) => void;
|
||||||
|
remember?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const runs = useAgentRuns(status || undefined);
|
const [f, setF] = useState<HistoryFilters>(remember ? drawerMemory.filters : EMPTY_FILTERS);
|
||||||
const mock = agentMockEnabled();
|
const set = (patch: Partial<HistoryFilters>) =>
|
||||||
const rows = runs.data?.pages.flatMap((p) => p.items) ?? [];
|
setF((prev) => {
|
||||||
|
const next = { ...prev, ...patch };
|
||||||
|
if (remember) drawerMemory.filters = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
const runs = useAgentRuns(f.status || undefined, f.trigger || undefined);
|
||||||
|
const loaded = runs.data?.pages.flatMap((p) => p.items) ?? [];
|
||||||
|
const now = Date.now();
|
||||||
|
const rows = loaded.filter((r) => matchesHistory(r, f, now));
|
||||||
|
const start = rangeStart(f.range, now);
|
||||||
|
const oldest = loaded.at(-1);
|
||||||
|
// 列表按创建时间倒序:已加载的最早一条早于时间范围,再往后翻也不会命中
|
||||||
|
const canLoadMore = !!runs.hasNextPage && !(start !== null && oldest && new Date(oldest.createdAt).getTime() < start);
|
||||||
|
const filtered = !!(f.q.trim() || f.status || f.trigger || f.range);
|
||||||
|
|
||||||
|
const scroller = useRef<HTMLDivElement>(null);
|
||||||
|
const sentinel = useRef<HTMLDivElement>(null);
|
||||||
|
const restored = useRef(!remember);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (restored.current || !rows.length || !scroller.current) return;
|
||||||
|
restored.current = true;
|
||||||
|
scroller.current.scrollTop = drawerMemory.scroll;
|
||||||
|
});
|
||||||
|
const { fetchNextPage, isFetchingNextPage } = runs;
|
||||||
|
useEffect(() => {
|
||||||
|
const el = sentinel.current;
|
||||||
|
if (!el || !canLoadMore || typeof IntersectionObserver === "undefined") return;
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
(es) => {
|
||||||
|
if (es.some((e) => e.isIntersecting) && !isFetchingNextPage) void fetchNextPage();
|
||||||
|
},
|
||||||
|
{ root: scroller.current, rootMargin: "120px" },
|
||||||
|
);
|
||||||
|
io.observe(el);
|
||||||
|
return () => io.disconnect();
|
||||||
|
}, [canLoadMore, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav aria-label="Agent 会话列表" className={cn("flex min-h-0 flex-col", className)}>
|
<nav aria-label="Agent 会话列表" className={cn("flex min-h-0 flex-col", className)}>
|
||||||
<fieldset aria-label="按状态筛选" className="scroll-bleed mb-2 flex flex-wrap gap-1">
|
<div className="mb-2 space-y-1.5">
|
||||||
{FILTERS.map((f) => (
|
<Input
|
||||||
<button
|
type="search"
|
||||||
key={f.value || "all"}
|
value={f.q}
|
||||||
type="button"
|
onChange={(e) => set({ q: e.target.value })}
|
||||||
aria-pressed={status === f.value}
|
placeholder="搜索目标"
|
||||||
onClick={() => onStatusChange(f.value)}
|
aria-label="搜索会话目标"
|
||||||
className={cn(
|
className="h-8 md:text-caption"
|
||||||
"h-7 cursor-pointer rounded-full border border-transparent px-2.5 text-meta text-muted-foreground outline-none transition-colors duration-micro hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
|
/>
|
||||||
status === f.value && "pill-selected text-foreground",
|
<div className="flex flex-wrap gap-1.5">
|
||||||
)}
|
<MiniSelect
|
||||||
>
|
label="按状态筛选"
|
||||||
{f.label}
|
allLabel="全部状态"
|
||||||
</button>
|
value={f.status}
|
||||||
))}
|
onChange={(v) => set({ status: v as HistoryFilters["status"] })}
|
||||||
</fieldset>
|
options={STATUS_OPTIONS}
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
/>
|
||||||
|
<MiniSelect
|
||||||
|
label="按来源筛选"
|
||||||
|
allLabel="全部来源"
|
||||||
|
value={f.trigger}
|
||||||
|
onChange={(v) => set({ trigger: v as HistoryFilters["trigger"] })}
|
||||||
|
options={TRIGGER_OPTIONS}
|
||||||
|
/>
|
||||||
|
<MiniSelect
|
||||||
|
label="按时间筛选"
|
||||||
|
allLabel="不限时间"
|
||||||
|
value={f.range}
|
||||||
|
onChange={(v) => set({ range: v as TimeRange })}
|
||||||
|
options={RANGE_OPTIONS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={scroller}
|
||||||
|
onScroll={(e) => {
|
||||||
|
if (remember) drawerMemory.scroll = e.currentTarget.scrollTop;
|
||||||
|
}}
|
||||||
|
className="min-h-0 flex-1 overflow-y-auto"
|
||||||
|
>
|
||||||
{runs.isPending ? (
|
{runs.isPending ? (
|
||||||
<LoadingState rows={4} />
|
<LoadingState rows={4} />
|
||||||
) : runs.error ? (
|
) : runs.error ? (
|
||||||
<ErrorState plain error={runs.error} onRetry={() => void runs.refetch()} />
|
<ErrorState plain error={runs.error} onRetry={() => void runs.refetch()} />
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 && !canLoadMore ? (
|
||||||
<EmptyState
|
<EmptyState plain icon={Bot} title={filtered ? "没有符合条件的会话" : "还没有会话,交代一个目标开始"} />
|
||||||
plain
|
|
||||||
icon={Bot}
|
|
||||||
title={status ? "没有这个状态的会话" : "还没有会话"}
|
|
||||||
description={status ? undefined : "交代一个目标开始。任意页面都能从右下角打开 Agent。"}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-0.5">
|
||||||
{rows.map((r) => {
|
{rows.map((r) => {
|
||||||
const current = r.id === activeId;
|
const current = r.id === activeId;
|
||||||
|
const cls = cn(
|
||||||
|
"block w-full min-w-0 cursor-pointer rounded-xl border border-transparent px-3 py-2 text-left outline-none transition-colors duration-micro hover:bg-foreground/[0.04] focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
current && "pill-selected",
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<li key={r.id}>
|
<li key={r.id}>
|
||||||
<Link
|
{onOpen ? (
|
||||||
to={`/console/agent/${r.id}${mock ? "?mock=1" : ""}`}
|
<button type="button" className={cn(cls, "group")} onClick={() => onOpen(r.id)}>
|
||||||
aria-current={current ? "page" : undefined}
|
<RunRow r={r} current={current}>
|
||||||
className={cn(
|
<ChevronRight
|
||||||
"block min-w-0 rounded-xl border border-transparent px-3 py-2 outline-none transition-colors duration-micro hover:bg-foreground/[0.04] focus-visible:ring-2 focus-visible:ring-ring",
|
aria-hidden
|
||||||
current && "pill-selected",
|
className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity duration-micro group-hover:opacity-100 group-focus-visible:opacity-100"
|
||||||
)}
|
/>
|
||||||
>
|
</RunRow>
|
||||||
<p className="line-clamp-2 text-caption break-words text-foreground">
|
</button>
|
||||||
{splitContextGoal(r.goal).text}
|
) : (
|
||||||
</p>
|
<Link
|
||||||
{splitContextGoal(r.goal).context ? (
|
to={hrefFor?.(r.id) ?? `/console/agent/${r.id}`}
|
||||||
<p className="mt-0.5 truncate font-mono text-label text-muted-foreground">
|
aria-current={current ? "page" : undefined}
|
||||||
上下文 · {splitContextGoal(r.goal).context?.split(" · ")[0]}
|
className={cls}
|
||||||
</p>
|
>
|
||||||
) : null}
|
<RunRow r={r} current={current}>
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
{null}
|
||||||
<RunStatusBadge status={r.status} />
|
</RunRow>
|
||||||
{r.scope.proposeFirst === true ? (
|
</Link>
|
||||||
<span className="text-meta text-muted-foreground">先看方案</span>
|
)}
|
||||||
) : null}
|
|
||||||
{r.parentRunId ? <span className="text-meta text-muted-foreground">子会话</span> : null}
|
|
||||||
<time
|
|
||||||
dateTime={r.createdAt}
|
|
||||||
className="ml-auto text-meta whitespace-nowrap text-muted-foreground"
|
|
||||||
>
|
|
||||||
{relTime(r.createdAt)}
|
|
||||||
</time>
|
|
||||||
</div>
|
|
||||||
<p className="mt-0.5 truncate text-meta text-muted-foreground tabular-nums">{usageText(r)}</p>
|
|
||||||
</Link>
|
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
{runs.hasNextPage ? (
|
{canLoadMore ? (
|
||||||
<Button
|
<div ref={sentinel} className="pt-2">
|
||||||
variant="ghost"
|
{filtered && rows.length === 0 ? (
|
||||||
size="sm"
|
<p className="px-1 pb-1 text-meta text-muted-foreground">
|
||||||
className="mt-2 w-full"
|
已加载的 {loaded.length} 条里没有匹配,继续往前找。
|
||||||
disabled={runs.isFetchingNextPage}
|
</p>
|
||||||
onClick={() => void runs.fetchNextPage()}
|
) : null}
|
||||||
>
|
<Button
|
||||||
{runs.isFetchingNextPage ? "加载中…" : "加载更多"}
|
variant="ghost"
|
||||||
</Button>
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isFetchingNextPage}
|
||||||
|
onClick={() => void fetchNextPage()}
|
||||||
|
>
|
||||||
|
{isFetchingNextPage ? "加载中…" : "加载更早的会话"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -16,16 +16,82 @@ export type AgentContext = {
|
|||||||
goal?: string;
|
goal?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type State = { open: boolean; minimized: boolean; context: AgentContext | null; runId: string | null };
|
/** 抽屉顶部的两个 Tab:当前(新目标 / 正在看的运行)、历史(会话列表,点一条在抽屉内打开) */
|
||||||
|
export type AgentDrawerTab = "current" | "history";
|
||||||
|
|
||||||
let state: State = { open: false, minimized: false, context: null, runId: null };
|
type State = {
|
||||||
|
open: boolean;
|
||||||
|
minimized: boolean;
|
||||||
|
context: AgentContext | null;
|
||||||
|
runId: string | null;
|
||||||
|
tab: AgentDrawerTab;
|
||||||
|
/** 历史 Tab 里打开的会话(null = 列表) */
|
||||||
|
historyRunId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TAB_KEY = "umt-agent-drawer-tab";
|
||||||
|
|
||||||
|
function readTab(): AgentDrawerTab {
|
||||||
|
try {
|
||||||
|
return globalThis.localStorage?.getItem(TAB_KEY) === "history" ? "history" : "current";
|
||||||
|
} catch {
|
||||||
|
return "current";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeTab(tab: AgentDrawerTab) {
|
||||||
|
try {
|
||||||
|
globalThis.localStorage?.setItem(TAB_KEY, tab);
|
||||||
|
} catch {
|
||||||
|
// 存储不可用时只在本页记住
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let state: State = { open: false, minimized: false, context: null, runId: null, tab: "current", historyRunId: null };
|
||||||
|
let tabLoaded = false;
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
const emit = () => {
|
const emit = () => {
|
||||||
for (const l of listeners) l();
|
for (const l of listeners) l();
|
||||||
};
|
};
|
||||||
|
|
||||||
export function openAgentDrawer(context: AgentContext | null = null) {
|
/** 上次停留的 Tab(抽屉记住;首次从 localStorage 读) */
|
||||||
state = { open: true, minimized: false, context, runId: null };
|
export function lastAgentDrawerTab(): AgentDrawerTab {
|
||||||
|
if (!tabLoaded) {
|
||||||
|
tabLoaded = true;
|
||||||
|
state = { ...state, tab: readTab() };
|
||||||
|
}
|
||||||
|
return state.tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开抽屉。页面内按钮带着对象打开时落在「当前」开始新目标;
|
||||||
|
* 快捷键 / 悬浮按钮不指定 tab,回到上次停留的 Tab。
|
||||||
|
*/
|
||||||
|
export function openAgentDrawer(context: AgentContext | null = null, tab: AgentDrawerTab = "current") {
|
||||||
|
tabLoaded = true;
|
||||||
|
state = { ...state, open: true, minimized: false, context, runId: null, tab };
|
||||||
|
if (tab === "current") writeTab(tab);
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAgentDrawerTab(tab: AgentDrawerTab) {
|
||||||
|
tabLoaded = true;
|
||||||
|
state = { ...state, tab };
|
||||||
|
writeTab(tab);
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 历史 Tab:在抽屉内打开一条会话(null 回到列表) */
|
||||||
|
export function openAgentHistoryRun(runId: string | null) {
|
||||||
|
state = { ...state, tab: "history", historyRunId: runId };
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把一条会话放到「当前」(同目标新建后、从历史里继续时) */
|
||||||
|
export function showAgentRunInDrawer(runId: string) {
|
||||||
|
tabLoaded = true;
|
||||||
|
state = { ...state, open: true, minimized: false, runId, tab: "current" };
|
||||||
|
writeTab("current");
|
||||||
emit();
|
emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,9 +106,9 @@ export function minimizeAgentDrawer() {
|
|||||||
emit();
|
emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从小窗恢复抽屉 */
|
/** 从小窗恢复抽屉(小窗里的运行在「当前」) */
|
||||||
export function restoreAgentDrawer() {
|
export function restoreAgentDrawer() {
|
||||||
state = { ...state, open: true, minimized: false };
|
state = { ...state, open: true, minimized: false, tab: "current" };
|
||||||
emit();
|
emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +132,7 @@ export function isAgentShortcut(
|
|||||||
export function toggleAgentDrawer(pathname: string) {
|
export function toggleAgentDrawer(pathname: string) {
|
||||||
if (state.open) setAgentDrawerOpen(false);
|
if (state.open) setAgentDrawerOpen(false);
|
||||||
else if (state.minimized && state.runId) restoreAgentDrawer();
|
else if (state.minimized && state.runId) restoreAgentDrawer();
|
||||||
else openAgentDrawer(contextFromPath(pathname));
|
else openAgentDrawer(contextFromPath(pathname), lastAgentDrawerTab());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按当前路由推断页面上下文(顶栏按钮用;页面内按钮直接传更完整的上下文) */
|
/** 按当前路由推断页面上下文(顶栏按钮用;页面内按钮直接传更完整的上下文) */
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { AgentFamilyUsage, AgentRunNode } from "@umt/contracts";
|
import type { AgentFamilyUsage, AgentRunNode } from "@umt/contracts";
|
||||||
import { ChevronRight, CornerLeftUp, GitBranch } from "lucide-react";
|
import { ChevronRight, CornerLeftUp, GitBranch } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { createContext, type ReactNode, useContext, useState } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { agentMockEnabled, type RunSnapshot } from "~/api/agent.ts";
|
import { agentMockEnabled, type RunSnapshot } from "~/api/agent.ts";
|
||||||
import { cn } from "~/lib/utils.ts";
|
import { cn } from "~/lib/utils.ts";
|
||||||
@@ -20,6 +20,27 @@ export function usageLine(u: { toolCalls: number; totalTokens?: number; costUsd?
|
|||||||
|
|
||||||
const href = (id: string) => `/console/agent/${id}${agentMockEnabled() ? "?mock=1" : ""}`;
|
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>) {
|
function budgetText(b: Record<string, number | undefined>) {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (b.maxToolCalls !== undefined) parts.push(`${b.maxToolCalls} 次调用`);
|
if (b.maxToolCalls !== undefined) parts.push(`${b.maxToolCalls} 次调用`);
|
||||||
@@ -49,12 +70,7 @@ function Node({ node, byParent, depth }: { node: AgentRunNode; byParent: Map<str
|
|||||||
<span className="mt-0.5 size-4 shrink-0" aria-hidden />
|
<span className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<Link
|
<RunLink id={node.id}>{splitContextGoal(node.goal).text}</RunLink>
|
||||||
to={href(node.id)}
|
|
||||||
className="line-clamp-2 rounded-tight text-caption break-words text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
>
|
|
||||||
{splitContextGoal(node.goal).text}
|
|
||||||
</Link>
|
|
||||||
<div className="mt-0.5 flex flex-wrap items-center gap-1.5">
|
<div className="mt-0.5 flex flex-wrap items-center gap-1.5">
|
||||||
<RunStatusBadge status={node.status} />
|
<RunStatusBadge status={node.status} />
|
||||||
<span className="text-meta text-muted-foreground tabular-nums">
|
<span className="text-meta text-muted-foreground tabular-nums">
|
||||||
@@ -82,7 +98,7 @@ function Node({ node, byParent, depth }: { node: AgentRunNode; byParent: Map<str
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 子会话树 + 父会话链接;两者都没有时返回 null */
|
/** 子会话树 + 父会话链接;两者都没有时返回 null */
|
||||||
export function SubagentTree({ snapshot }: { snapshot: RunSnapshot }) {
|
export function SubagentTree({ snapshot, className }: { snapshot: RunSnapshot; className?: string }) {
|
||||||
const { run, parent, children = [], familyUsage } = snapshot;
|
const { run, parent, children = [], familyUsage } = snapshot;
|
||||||
if (!parent && children.length === 0) return null;
|
if (!parent && children.length === 0) return null;
|
||||||
const byParent = new Map<string, AgentRunNode[]>();
|
const byParent = new Map<string, AgentRunNode[]>();
|
||||||
@@ -93,19 +109,14 @@ export function SubagentTree({ snapshot }: { snapshot: RunSnapshot }) {
|
|||||||
const direct = byParent.get(run.id) ?? [];
|
const direct = byParent.get(run.id) ?? [];
|
||||||
const caps = ((run.scope as { capabilities?: string[] }).capabilities ?? []).length;
|
const caps = ((run.scope as { capabilities?: string[] }).capabilities ?? []).length;
|
||||||
return (
|
return (
|
||||||
<section aria-label="子会话" className="space-y-2 rounded-xl border border-border/70 p-3">
|
<section aria-label="子会话" className={cn("space-y-2 rounded-xl border border-border/70 p-3", className)}>
|
||||||
{parent ? (
|
{parent ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="flex items-center gap-1.5 text-meta text-muted-foreground">
|
<p className="flex items-center gap-1.5 text-meta text-muted-foreground">
|
||||||
<CornerLeftUp aria-hidden className="size-3.5" />
|
<CornerLeftUp aria-hidden className="size-3.5" />
|
||||||
父会话
|
父会话
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<RunLink id={parent.id}>{splitContextGoal(parent.goal).text}</RunLink>
|
||||||
to={href(parent.id)}
|
|
||||||
className="line-clamp-2 rounded-tight text-caption break-words text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
>
|
|
||||||
{splitContextGoal(parent.goal).text}
|
|
||||||
</Link>
|
|
||||||
<p className="text-meta text-muted-foreground">
|
<p className="text-meta text-muted-foreground">
|
||||||
第 {run.depth} 层子会话 · 继承父会话的发起人与授权上限 · 能力 {caps} 项(⊆ 父会话)· 划拨预算{" "}
|
第 {run.depth} 层子会话 · 继承父会话的发起人与授权上限 · 能力 {caps} 项(⊆ 父会话)· 划拨预算{" "}
|
||||||
{budgetText(run.budget as Record<string, number | undefined>)}
|
{budgetText(run.budget as Record<string, number | undefined>)}
|
||||||
@@ -115,7 +126,7 @@ export function SubagentTree({ snapshot }: { snapshot: RunSnapshot }) {
|
|||||||
) : null}
|
) : null}
|
||||||
{direct.length ? (
|
{direct.length ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="flex items-center gap-1.5 text-meta text-muted-foreground">
|
<p className="flex flex-wrap items-center gap-1.5 text-meta text-muted-foreground">
|
||||||
<GitBranch aria-hidden className="size-3.5" />
|
<GitBranch aria-hidden className="size-3.5" />
|
||||||
子会话 {children.length} 个{familyUsage ? <FamilyUsage usage={familyUsage} /> : null}
|
子会话 {children.length} 个{familyUsage ? <FamilyUsage usage={familyUsage} /> : null}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -245,7 +245,15 @@ function seed() {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
add("把 沙丘:第二部 的 4K 收进来", { proposeFirst: false }, now - 3_600_000, 1);
|
add("把 沙丘:第二部 的 4K 收进来", { proposeFirst: false }, now - 3_600_000, 1);
|
||||||
add("对比一下 奥本海默 现有版本和站点上的 4K", { proposeFirst: true }, now - 7_200_000, 2);
|
add("对比一下 奥本海默 现有版本和站点上的 4K", { proposeFirst: true }, now - 7_200_000, 2);
|
||||||
const failed = add("检查 qb-main 为什么占满磁盘", { proposeFirst: false }, now - 86_400_000, 3);
|
const failed = add("巡检:检查 qb-main 为什么占满磁盘", { proposeFirst: false }, now - 86_400_000, 3);
|
||||||
|
failed.run = { ...failed.run, trigger: "schedule" };
|
||||||
|
const evt = add(
|
||||||
|
"新资源到达:看看 奥本海默 的 REMUX 是否值得替换现有版本",
|
||||||
|
{ proposeFirst: false },
|
||||||
|
now - 3 * 86_400_000,
|
||||||
|
4,
|
||||||
|
);
|
||||||
|
evt.run = { ...evt.run, trigger: "event" };
|
||||||
failed.steps = [
|
failed.steps = [
|
||||||
{ delayMs: 0, body: { type: "run_started", goal: failed.run.goal, resumed: false } },
|
{ delayMs: 0, body: { type: "run_started", goal: failed.run.goal, resumed: false } },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
|
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
|
||||||
// 改造:状态色 blue/emerald/rose → 中性/success/destructive(并配文字);ActionSwapRollText 滚字去掉;
|
// 改造:状态色 blue/emerald/rose → 中性/success/destructive(并配文字);ActionSwapRollText 滚字去掉;
|
||||||
// 折叠用 Disclosure;新增 `badges` 槽(scope/风险/幂等等审计徽标)与 `summary`(收起时的一行结果);
|
// 折叠用 Disclosure;新增 `badges` 槽(scope/风险/幂等等审计徽标)与 `summary`(收起时的一行结果);
|
||||||
// 默认不在完成后自动展开历史卡片(回放时 defaultOpen=false),运行中的卡片展开、完成后按 collapseOnComplete 收起。
|
// AGUI-2:收起态压成一行(能力名 · 状态 · 耗时 · 摘要),能力 ID 与徽标移进展开区;
|
||||||
|
// 默认一律收起(运行中也只显示一行状态);用户点开的卡片完成后保持展开(collapseOnComplete 可改)。
|
||||||
import { Ban, Braces, ChevronDown, CircleCheck, CircleX, LoaderCircle, SquareTerminal, Wrench } from "lucide-react";
|
import { Ban, Braces, ChevronDown, CircleCheck, CircleX, LoaderCircle, SquareTerminal, Wrench } from "lucide-react";
|
||||||
import { type ReactNode, useCallback, useEffect, useId, useRef, useState } from "react";
|
import { type ReactNode, useCallback, useEffect, useId, useRef, useState } from "react";
|
||||||
import { cn } from "~/lib/utils.ts";
|
import { cn } from "~/lib/utils.ts";
|
||||||
@@ -61,8 +62,8 @@ export function ToolResult({
|
|||||||
badges,
|
badges,
|
||||||
summary,
|
summary,
|
||||||
icon,
|
icon,
|
||||||
defaultOpen = status === "running",
|
defaultOpen = false,
|
||||||
collapseOnComplete = true,
|
collapseOnComplete = false,
|
||||||
className,
|
className,
|
||||||
}: ToolResultProps) {
|
}: ToolResultProps) {
|
||||||
const id = useId();
|
const id = useId();
|
||||||
@@ -70,7 +71,6 @@ export function ToolResult({
|
|||||||
const prev = useRef(status);
|
const prev = useRef(status);
|
||||||
const setOpen = useCallback((v: boolean) => setOpenState(v), []);
|
const setOpen = useCallback((v: boolean) => setOpenState(v), []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (prev.current !== "running" && status === "running") setOpen(true);
|
|
||||||
if (prev.current === "running" && status !== "running" && collapseOnComplete) setOpen(false);
|
if (prev.current === "running" && status !== "running" && collapseOnComplete) setOpen(false);
|
||||||
prev.current = status;
|
prev.current = status;
|
||||||
}, [collapseOnComplete, setOpen, status]);
|
}, [collapseOnComplete, setOpen, status]);
|
||||||
@@ -88,28 +88,31 @@ export function ToolResult({
|
|||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-controls={`${id}-c`}
|
aria-controls={`${id}-c`}
|
||||||
onClick={() => setOpen(!open)}
|
onClick={() => setOpen(!open)}
|
||||||
className="group flex min-h-10 w-full min-w-0 cursor-pointer items-center gap-2 rounded-xl px-3 py-1.5 text-left outline-none transition-colors duration-micro hover:bg-foreground/[0.03] focus-visible:ring-2 focus-visible:ring-ring"
|
className="group flex min-h-9 w-full min-w-0 cursor-pointer items-center gap-2 rounded-xl px-3 py-1.5 text-left outline-none transition-colors duration-micro hover:bg-foreground/[0.03] focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
<span aria-hidden className="grid size-4 shrink-0 place-items-center text-muted-foreground">
|
<span aria-hidden className="grid size-4 shrink-0 place-items-center text-muted-foreground">
|
||||||
{icon ?? <KindIcon kind={kind} />}
|
{icon ?? <KindIcon kind={kind} />}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
{/* 收起时一行:能力名 · 状态 · 耗时 · 摘要;展开后摘要让位给完整输入输出 */}
|
||||||
<span className="min-w-0 truncate text-caption font-medium text-foreground/90">{title}</span>
|
<span className="max-w-[45%] shrink-0 truncate text-caption font-medium text-foreground/90">{title}</span>
|
||||||
<span className="min-w-0 truncate font-mono text-label text-muted-foreground">{tool}</span>
|
|
||||||
{meta ? <span className="shrink-0 text-label text-muted-foreground tabular-nums">{meta}</span> : null}
|
|
||||||
</span>
|
|
||||||
<span className={cn("inline-flex shrink-0 items-center gap-1 text-label font-medium", st.cls)}>
|
<span className={cn("inline-flex shrink-0 items-center gap-1 text-label font-medium", st.cls)}>
|
||||||
<StatusIcon status={status} />
|
<StatusIcon status={status} />
|
||||||
{st.label}
|
{st.label}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown aria-hidden className={cn("size-3.5", CHEVRON_CLASS, open && "rotate-180")} />
|
{meta ? <span className="shrink-0 text-label text-muted-foreground tabular-nums">{meta}</span> : null}
|
||||||
|
<span className="min-w-0 flex-1 truncate text-meta text-muted-foreground">
|
||||||
|
{!open && summary ? summary : null}
|
||||||
|
</span>
|
||||||
|
<ChevronDown aria-hidden className={cn("size-3.5 shrink-0", CHEVRON_CLASS, open && "rotate-180")} />
|
||||||
</button>
|
</button>
|
||||||
{badges ? <div className="flex flex-wrap items-center gap-1 px-3 pb-2 pl-9">{badges}</div> : null}
|
|
||||||
{!open && summary ? (
|
|
||||||
<p className="-mt-1 truncate px-3 pb-2 pl-9 text-meta text-muted-foreground">{summary}</p>
|
|
||||||
) : null}
|
|
||||||
<Disclosure id={`${id}-c`} role="region" aria-labelledby={`${id}-t`} open={open}>
|
<Disclosure id={`${id}-c`} role="region" aria-labelledby={`${id}-t`} open={open}>
|
||||||
<div className="space-y-2 border-t border-border/60 px-3 py-2.5">{children}</div>
|
<div className="space-y-2 border-t border-border/60 px-3 py-2.5">
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
<span className="mr-1 min-w-0 truncate font-mono text-label text-muted-foreground">{tool}</span>
|
||||||
|
{badges}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</Disclosure>
|
</Disclosure>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,35 +1,44 @@
|
|||||||
import type { AgentRunStatus } from "@umt/contracts";
|
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { useState } from "react";
|
|
||||||
import { Link, useParams } from "react-router";
|
import { Link, useParams } from "react-router";
|
||||||
import { agentMockEnabled, useAgentRun } from "~/api/agent.ts";
|
import { agentMockEnabled, useAgentRun } from "~/api/agent.ts";
|
||||||
import { AgentRunPanel } from "~/components/agent/agent-run-panel.tsx";
|
import { AgentRunPanel } from "~/components/agent/agent-run-panel.tsx";
|
||||||
import { AgentSessionList } from "~/components/agent/agent-session-list.tsx";
|
import { AgentSessionList } from "~/components/agent/agent-session-list.tsx";
|
||||||
|
import { splitContextGoal } from "~/components/agent/agent-store.ts";
|
||||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||||
import { Button } from "~/components/ui/button.tsx";
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
|
|
||||||
|
// 会话页:左栏会话列表(xl 以上)· 中间会话列(统一最大宽度,输入区固定在底部)· 右侧栏状态/步骤/预算(lg 以上;
|
||||||
|
// 更窄时换成会话列顶部的可折叠概况条)。
|
||||||
export default function AgentRunPage() {
|
export default function AgentRunPage() {
|
||||||
const { runId = "" } = useParams();
|
const { runId = "" } = useParams();
|
||||||
const run = useAgentRun(runId);
|
const run = useAgentRun(runId);
|
||||||
const mock = agentMockEnabled();
|
const mock = agentMockEnabled();
|
||||||
const [status, setStatus] = useState<"" | AgentRunStatus>("");
|
const suffix = mock ? "?mock=1" : "";
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Agent 会话"
|
title="Agent 会话"
|
||||||
description={run.data ? <span className="line-clamp-2 break-words">{run.data.run.goal}</span> : "加载中…"}
|
description={
|
||||||
|
run.data ? (
|
||||||
|
<span className="line-clamp-2 break-words" title={run.data.run.goal}>
|
||||||
|
{splitContextGoal(run.data.run.goal).text}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"加载中…"
|
||||||
|
)
|
||||||
|
}
|
||||||
actions={
|
actions={
|
||||||
<Button asChild variant="ghost" size="sm">
|
<Button asChild variant="ghost" size="sm">
|
||||||
<Link to={`/console/agent${mock ? "?mock=1" : ""}`}>
|
<Link to={`/console/agent${suffix}`}>
|
||||||
<ArrowLeft />
|
<ArrowLeft />
|
||||||
全部会话
|
全部会话
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,17rem)_minmax(0,1fr)]">
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[minmax(0,16rem)_minmax(0,1fr)]">
|
||||||
<aside className="pg-slab hidden rounded-xl p-3 lg:block lg:h-[calc(100dvh-13rem)] lg:overflow-hidden">
|
<aside className="pg-slab hidden rounded-xl p-3 xl:block xl:h-[calc(100dvh-13rem)] xl:overflow-hidden">
|
||||||
<AgentSessionList activeId={runId} status={status} onStatusChange={setStatus} className="h-full" />
|
<AgentSessionList activeId={runId} hrefFor={(id) => `/console/agent/${id}${suffix}`} className="h-full" />
|
||||||
</aside>
|
</aside>
|
||||||
<div className="pg-slab flex h-[calc(100dvh-11rem)] min-h-[32rem] flex-col rounded-xl p-3 md:p-4 lg:h-[calc(100dvh-13rem)]">
|
<div className="pg-slab flex h-[calc(100dvh-11rem)] min-h-[32rem] flex-col rounded-xl p-3 md:p-4 lg:h-[calc(100dvh-13rem)]">
|
||||||
<AgentRunPanel key={runId} runId={runId} className="flex-1" />
|
<AgentRunPanel key={runId} runId={runId} className="flex-1" />
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import type { AgentRunStatus } from "@umt/contracts";
|
|
||||||
import { FlaskConical, ShieldCheck } from "lucide-react";
|
import { FlaskConical, ShieldCheck } from "lucide-react";
|
||||||
import { useState } from "react";
|
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { agentMockEnabled, useCreateAgentRun } from "~/api/agent.ts";
|
import { agentMockEnabled, useCreateAgentRun } from "~/api/agent.ts";
|
||||||
@@ -11,7 +9,6 @@ import { Button } from "~/components/ui/button.tsx";
|
|||||||
import { errorMessage } from "~/lib/api.ts";
|
import { errorMessage } from "~/lib/api.ts";
|
||||||
|
|
||||||
export default function AgentPage() {
|
export default function AgentPage() {
|
||||||
const [status, setStatus] = useState<"" | AgentRunStatus>("");
|
|
||||||
const create = useCreateAgentRun();
|
const create = useCreateAgentRun();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const mock = agentMockEnabled();
|
const mock = agentMockEnabled();
|
||||||
@@ -31,11 +28,11 @@ export default function AgentPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)] lg:items-start">
|
||||||
<div className="pg-slab order-2 rounded-xl p-3 lg:order-1 lg:max-h-[calc(100dvh-12rem)] lg:overflow-hidden">
|
<div className="pg-slab order-2 rounded-xl p-3 lg:order-1 lg:h-[calc(100dvh-12rem)] lg:overflow-hidden">
|
||||||
<AgentSessionList status={status} onStatusChange={setStatus} className="h-full" />
|
<AgentSessionList hrefFor={(id) => `/console/agent/${id}${mock ? "?mock=1" : ""}`} className="h-full" />
|
||||||
</div>
|
</div>
|
||||||
<div className="order-1 space-y-4 lg:order-2">
|
<div className="order-1 mx-auto w-full max-w-[88ch] space-y-3 lg:order-2">
|
||||||
<div className="pg-slab rounded-xl p-4">
|
<div className="pg-slab rounded-xl p-4">
|
||||||
<h2 className="mb-3 text-body font-medium text-foreground">新会话</h2>
|
<h2 className="mb-3 text-body font-medium text-foreground">新会话</h2>
|
||||||
<GoalComposer
|
<GoalComposer
|
||||||
@@ -51,15 +48,10 @@ export default function AgentPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 rounded-xl border border-border/70 p-4 text-caption text-muted-foreground">
|
<p className="flex items-center gap-2 px-1 text-meta text-muted-foreground">
|
||||||
<ShieldCheck aria-hidden className="mt-0.5 size-4 shrink-0" />
|
<ShieldCheck aria-hidden className="size-3.5 shrink-0" />
|
||||||
<div className="space-y-1">
|
每次工具调用都经服务端授权校验,汇报以系统核实的结果为准。
|
||||||
<p>
|
</p>
|
||||||
每次工具调用都经服务端授权校验。控制台发起的会话里,没有持续授权的下载、整理、策略发布会在对应步骤出审批卡。
|
|
||||||
</p>
|
|
||||||
<p>汇报分两栏:模型自述仅供参考,"系统核实的结果"取自下载计划、下载任务和整理记录。</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -250,6 +250,36 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 交互:Agent 抽屉「历史」Tab(AGUI-2;演示数据,点一条在抽屉内打开、返回列表、关了再开仍停在历史)──
|
||||||
|
{
|
||||||
|
const label = "Agent 抽屉 → 历史 → 抽屉内打开会话";
|
||||||
|
problems = [];
|
||||||
|
const t0 = Date.now();
|
||||||
|
try {
|
||||||
|
await page.goto("about:blank");
|
||||||
|
await page.goto(`${BASE}/console/audit?mock=1`, { waitUntil: "networkidle", timeout: 30_000 });
|
||||||
|
const launcher = page.locator("[data-agent-launcher]:visible").last();
|
||||||
|
await launcher.click();
|
||||||
|
await page.getByRole("tab", { name: "历史" }).click();
|
||||||
|
const list = page.getByRole("navigation", { name: "Agent 会话列表" });
|
||||||
|
await list.getByRole("button").first().click();
|
||||||
|
await page.getByRole("button", { name: "返回列表" }).waitFor({ timeout: 10_000 });
|
||||||
|
if (new URL(page.url()).pathname !== "/console/audit") problems.push("点历史会话跳走了页面");
|
||||||
|
await page.getByRole("button", { name: "返回列表" }).click();
|
||||||
|
await list.waitFor({ timeout: 5_000 });
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await launcher.click();
|
||||||
|
const tab = await page.getByRole("tab", { name: "历史" }).getAttribute("aria-selected");
|
||||||
|
if (tab !== "true") problems.push("再次打开没有停在「历史」");
|
||||||
|
if (SHOTS) await page.screenshot({ path: join(OUT, "agent_drawer_history.png") }).catch(() => {});
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await page.goto(`${BASE}/console/agent?mock=0`, { waitUntil: "networkidle", timeout: 30_000 });
|
||||||
|
} catch (err) {
|
||||||
|
problems.push(`交互失败:${String(err?.message ?? err).split("\n")[0]}`);
|
||||||
|
}
|
||||||
|
results.push({ path: label, ms: Date.now() - t0, problems: [...new Set(problems)] });
|
||||||
|
}
|
||||||
|
|
||||||
// ── 旧链接重定向 ──
|
// ── 旧链接重定向 ──
|
||||||
for (const r of REDIRECT_ROUTES) {
|
for (const r of REDIRECT_ROUTES) {
|
||||||
let from = r.from;
|
let from = r.from;
|
||||||
|
|||||||
Reference in new issue
Block a user