feat(web): W4-F rebuild agent conversation and drawer with agentui components

- Port agentui (MIT, ashish200729/agentui@b2b752b) Message/MessageBubble/MessageScroller,
  StreamingResponse, ToolResult/ToolApproval, TodoList, ApprovalCard, CodeBlock/FileDiff,
  Citations, PromptInput into components/agentui; drop motion/shiki/Next, switch colors,
  type scale, radii, durations and easing to semantic tokens, honour reduced motion
- Conversation: streaming cursor, collapsible thinking, tool cards with kind/scope/risk/
  idempotency badges and audit idempotency key, prominent server-rejection state for both
  whitelist rejections and unauthorized errors, compaction markers, budget meters with
  over-limit state, approval cards (admin session only), two-column final report
  (model said vs system verified with task/record links), follow-scroll with jump-to-bottom
- Composer: Enter send / Shift+Enter newline, stop = abort (confirmed), append,
  execute/plan_only switch, "execute this plan" from finished plan_only runs
- Session list sidebar with status filter and usage; reconnect/error banners with retry
- Drawer: route-derived context (entity/site/task), minimize to a live mini dock
- Fix stream refetch race when the run switches from loading to finished

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 23:08:17 +08:00
1 parent 29cd10b27e
commit 8352e5e881
26 files changed
+3390 -585

No files matched your search

+111 -1
View File
@@ -1,4 +1,11 @@
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
keepPreviousData,
useInfiniteQuery,
useMutation,
useQueries,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
AgentGoalReport,
AgentRun,
@@ -7,11 +14,15 @@ import {
type AgentRunMessage,
AgentRunMode,
type AgentRunStatus,
Approval,
type ApprovalStatus,
AuditEvent,
agentRunEventFromRow,
cursorPage,
} from "@umt/contracts";
import { MOCK_RUNS, mockCreateRun, mockDetail, mockEventsAfter } from "~/components/agent/mock.ts";
import { TTL_SECOND } from "~/lib/api.ts";
import { useSession } from "./auth.ts";
import { apiMutate, apiParsed, cleanParams } from "./parse.ts";
import { useSettings } from "./settings.ts";
@@ -242,3 +253,102 @@ export const REPORT_STATE: Record<
partial: { label: "部分完成", tone: "warning" },
failed: { label: "未达成", tone: "destructive" },
};
// ── 会话页附属数据:审批、幂等键、管理员会话判定 ──
const ApprovalPage = cursorPage(Approval);
/** 本次运行产生的计划上的审批(按 report.plans 的 planId 逐个查;空列表不发请求) */
export function useRunApprovals(planIds: string[]) {
const mock = agentMockEnabled();
const results = useQueries({
queries: planIds.map((planId) => ({
queryKey: ["agent", "approvals", planId],
enabled: !mock,
queryFn: ({ signal }: { signal: AbortSignal }) =>
apiParsed(ApprovalPage, "/approvals", { params: { planId, limit: 20 }, signal }),
refetchInterval: 15 * TTL_SECOND,
})),
});
return {
items: results.flatMap((r) => r.data?.items ?? []),
isPending: results.some((r) => r.isPending && r.fetchStatus !== "idle"),
error: results.find((r) => r.error)?.error ?? null,
};
}
/** 批准/拒绝审批:回传 payloadHash,服务端比对防挪用;只接受控制台管理员会话 */
export function useDecideApproval() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: {
id: string;
status: Extract<ApprovalStatus, "approved" | "rejected">;
payloadHash: string;
}) =>
apiMutate(`/approvals/:id`, {
method: "PATCH",
pathParams: { id: input.id },
body: { status: input.status, payloadHash: input.payloadHash },
}).then((d) => Approval.parse(d)),
onSuccess: (a) => qc.invalidateQueries({ queryKey: ["agent", "approvals", a.planId] }),
});
}
/** 当前是否为控制台管理员会话(cookie 会话;API Key 不算)。审批按钮只在这里可用 */
export function useIsAdminSession(): boolean {
const s = useSession();
return s.data?.actor?.type === "admin" && s.data.expiresAt !== null;
}
export type InvocationAudit = {
capabilityId: string;
at: string;
outcome: string;
idempotencyKey: string | null;
durationMs: number | null;
grantId: string | null;
approvalId: string | null;
};
const AuditPage = cursorPage(AuditEvent);
/**
* 本次运行的能力调用审计(capability.invoke / capability.denied,按时间升序)。
* 审计按全站新→旧分页,这里最多翻 3 页、按 payload.agentRunId 过滤;较早的运行可能拿不全,界面按"未取到"处理。
*/
export function useRunInvocationAudit(runId: string, enabled: boolean, version = 0) {
const mock = agentMockEnabled();
return useQuery({
queryKey: ["agent", "invocation-audit", runId, version],
placeholderData: keepPreviousData,
enabled: enabled && !mock && !!runId,
staleTime: 10 * TTL_SECOND,
queryFn: async ({ signal }): Promise<InvocationAudit[]> => {
const out: InvocationAudit[] = [];
let cursor: string | undefined;
for (let page = 0; page < 3; page++) {
const d = await apiParsed(AuditPage, "/audit-events", {
params: cleanParams({ action: "capability.*", targetType: "capability", limit: 200, cursor }),
signal,
});
for (const e of d.items) {
const p = e.payload as Record<string, unknown>;
if (p.agentRunId !== runId) continue;
out.push({
capabilityId: e.targetId ?? "",
at: e.at,
outcome: String(p.outcome ?? e.action),
idempotencyKey: typeof p.idempotencyKey === "string" ? p.idempotencyKey : null,
durationMs: typeof p.durationMs === "number" ? p.durationMs : null,
grantId: typeof p.grantId === "string" ? p.grantId : null,
approvalId: typeof p.approvalId === "string" ? p.approvalId : null,
});
}
if (!d.nextCursor) break;
cursor = d.nextCursor;
}
return out.sort((a, b) => a.at.localeCompare(b.at));
},
});
}