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));
},
});
}
File diff suppressed because it is too large. Load diff
+184 -62
View File
@@ -1,94 +1,216 @@
import { Bot, ExternalLink, Plus } from "lucide-react";
import { Link } from "react-router";
import { Bot, ExternalLink, Maximize2, Minus, Plus, X } from "lucide-react";
import { Link, useLocation } from "react-router";
import { toast } from "sonner";
import { useCreateAgentRun } from "~/api/agent.ts";
import { isRunActive, useAgentRun, useCreateAgentRun } from "~/api/agent.ts";
import { StreamingCursor } from "~/components/agentui/streaming-response.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
import { errorMessage } from "~/lib/api.ts";
import { capabilityMeta, isDenied, RunStatusBadge } from "./agent-conversation.tsx";
import { AgentRunPanel, GoalComposer } from "./agent-run-panel.tsx";
import {
type AgentContext,
contextFromPath,
contextPrefix,
minimizeAgentDrawer,
openAgentDrawer,
restoreAgentDrawer,
setAgentDrawerOpen,
setAgentDrawerRun,
splitContextGoal,
useAgentDrawer,
} from "./agent-store.ts";
import { useAgentStream } from "./use-agent-stream.ts";
const CONTEXT_KEYS: [keyof AgentContext, string][] = [
["entityId", "作品"],
["releaseId", "资源"],
["downloadTaskId", "下载任务"],
["recordId", "整理记录"],
["siteId", "站点"],
["taskId", "任务"],
];
function ContextChips({ context }: { context: AgentContext }) {
const chips = CONTEXT_KEYS.filter(([k]) => context[k]);
if (!chips.length) return null;
return (
<ul aria-label="当前页面上下文" className="mt-1.5 flex flex-wrap gap-1">
{chips.map(([k, label]) => (
<li
key={k}
className="max-w-full truncate rounded-full border border-border/70 px-2 py-0.5 font-mono text-label text-muted-foreground"
title={String(context[k])}
>
{label} · {String(context[k]).slice(0, 12)}
</li>
))}
</ul>
);
}
/**
* Agent 侧栏抽屉:任意页面可打开,带当前页面上下文(entityId 等)作为 goal 前缀。
* Agent 侧栏抽屉:任意页面可打开,带当前页面上下文(entityId / siteId / taskId 等)作为 goal 前缀;
* 运行中可最小化到右下角小窗,流式输出继续。
* 挂载:在 console layout 里渲染一次 <AgentDrawer />;顶栏放 <AgentDrawerTrigger />。
* 页面内打开:import { openAgentDrawer } from "~/components/agent/agent-store.ts"。
*/
export function AgentDrawer() {
const { open, context, runId } = useAgentDrawer();
const { open, minimized, context, runId } = useAgentDrawer();
const create = useCreateAgentRun();
const prefix = contextPrefix(context);
return (
<Sheet open={open} onOpenChange={setAgentDrawerOpen}>
<SheetContent side="right" className="w-full gap-0 p-0 sm:max-w-lg">
<SheetHeader className="border-b border-[var(--shell-border)] px-5 py-4 pr-14">
<SheetTitle className="flex items-center gap-2 text-lead">
<Bot className="h-4 w-4" />
Agent
</SheetTitle>
<SheetDescription className="break-words">
{context?.label ? `当前对象:${context.label}` : "交代目标,Agent 在授权范围内边执行边汇报。"}
</SheetDescription>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
<>
<Sheet open={open} onOpenChange={setAgentDrawerOpen}>
<SheetContent side="right" className="w-full gap-0 p-0 sm:max-w-xl">
<SheetHeader className="border-b border-[var(--shell-border)] px-5 py-4 pr-24">
<SheetTitle className="flex items-center gap-2 text-lead">
<Bot className="h-4 w-4" />
Agent
</SheetTitle>
<SheetDescription className="break-words">
{context?.label ? `当前对象:${context.label}` : "交代目标,Agent 在授权范围内边执行边汇报。"}
</SheetDescription>
{context ? <ContextChips context={context} /> : null}
</SheetHeader>
{runId ? (
<AgentRunPanel runId={runId} compact />
) : (
<GoalComposer
key={`${context?.entityId ?? ""}${context?.releaseId ?? ""}${context?.goal ?? ""}`}
compact
prefix={prefix || undefined}
defaultGoal={context?.goal ?? ""}
pending={create.isPending}
onSubmit={(goal, mode) =>
create.mutate(
{ goal: `${prefix}${goal}`, mode },
{
onSuccess: (run) => setAgentDrawerRun(run.id),
onError: (e) => toast.error(errorMessage(e)),
},
)
}
/>
)}
</div>
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-[var(--shell-border)] px-5 py-3">
{runId ? (
<>
<Button variant="ghost" size="sm" onClick={() => openAgentDrawer(context)}>
<Plus />
新目标
</Button>
<Button asChild variant="outline" size="sm">
<Link to={`/console/agent/${runId}`} onClick={() => setAgentDrawerOpen(false)}>
<ExternalLink />
在会话页打开
<Button
variant="ghost"
size="icon-sm"
className="absolute top-4 right-14"
aria-label="最小化(运行继续)"
title="最小化(运行继续)"
onClick={minimizeAgentDrawer}
>
<Minus />
</Button>
) : null}
<div className="flex min-h-0 flex-1 flex-col px-4 py-3">
{runId ? (
<AgentRunPanel runId={runId} compact className="flex-1" onRunCreated={setAgentDrawerRun} />
) : (
<GoalComposer
key={`${context?.entityId ?? ""}${context?.releaseId ?? ""}${context?.goal ?? ""}`}
compact
prefix={prefix || undefined}
defaultGoal={context?.goal ?? ""}
pending={create.isPending}
onSubmit={(goal, mode) =>
create.mutate(
{ goal: `${prefix}${goal}`, mode },
{
onSuccess: (run) => setAgentDrawerRun(run.id),
onError: (e) => toast.error(errorMessage(e)),
},
)
}
/>
)}
</div>
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-[var(--shell-border)] px-5 py-3">
{runId ? (
<>
<Button variant="ghost" size="sm" onClick={() => openAgentDrawer(context)}>
<Plus />
新目标
</Button>
<Button asChild variant="outline" size="sm">
<Link to={`/console/agent/${runId}`} onClick={() => setAgentDrawerOpen(false)}>
<ExternalLink />
在会话页打开
</Link>
</Button>
</>
) : (
<Button asChild variant="ghost" size="sm">
<Link to="/console/agent" onClick={() => setAgentDrawerOpen(false)}>
全部会话
</Link>
</Button>
</>
) : (
<Button asChild variant="ghost" size="sm">
<Link to="/console/agent" onClick={() => setAgentDrawerOpen(false)}>
全部会话
</Link>
</Button>
)}
</div>
</SheetContent>
</Sheet>
)}
</div>
</SheetContent>
</Sheet>
{minimized && runId ? <AgentMiniDock runId={runId} /> : null}
</>
);
}
/** 顶栏按钮:打开抽屉(不带上下文;页面内按钮用 openAgentDrawer 传上下文) */
export function AgentDrawerTrigger() {
/** 最小化后的小窗:保持事件流订阅,显示状态与最新一句输出 */
function AgentMiniDock({ runId }: { runId: string }) {
const run = useAgentRun(runId);
const active = isRunActive(run.data?.run.status);
const live = useAgentStream(runId, active || !run.data);
const last = live.items[live.items.length - 1];
const streaming = active && !live.finished;
const line =
last?.kind === "text"
? last.text.trim().split("\n").pop()
: last?.kind === "tool"
? `${isDenied(last.call) ? "被拒绝" : last.call.status === "running" ? "调用" : "完成"} · ${capabilityMeta(last.call.capabilityId)?.title ?? last.call.capabilityId}`
: last?.kind === "thinking"
? "思考中…"
: last?.kind === "end"
? last.ok
? "已完成,展开查看核实结果"
: last.message
: "等待输出…";
const status = run.data
? live.finished && isRunActive(run.data.run.status)
? "running"
: run.data.run.status
: null;
return (
<Button variant="ghost" size="icon-sm" aria-label="打开 Agent" title="Agent" onClick={() => openAgentDrawer(null)}>
<section
aria-label="Agent 小窗"
className="pg-float fixed right-4 bottom-4 z-40 w-80 max-w-[calc(100vw-2rem)] rounded-2xl p-3 animate-in fade-in-0 slide-in-from-bottom-2 duration-base ease-pop motion-reduce:animate-none"
>
<div className="flex items-center gap-2">
<Bot aria-hidden className="size-4 shrink-0 text-muted-foreground" />
{status ? <RunStatusBadge status={status} /> : null}
<span className="min-w-0 flex-1 truncate text-meta text-muted-foreground">
{run.data ? splitContextGoal(run.data.run.goal).text : ""}
</span>
<Button variant="ghost" size="icon-xs" aria-label="展开 Agent" title="展开" onClick={restoreAgentDrawer}>
<Maximize2 />
</Button>
<Button
variant="ghost"
size="icon-xs"
aria-label="关闭小窗(运行在后台继续)"
title="关闭小窗(运行在后台继续)"
onClick={() => setAgentDrawerOpen(false)}
>
<X />
</Button>
</div>
<button
type="button"
onClick={restoreAgentDrawer}
className="mt-2 block w-full cursor-pointer rounded-lg px-1 text-left text-caption leading-5 text-foreground/90 outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="line-clamp-2 break-words">
{line}
{streaming && last?.kind === "text" ? <StreamingCursor /> : null}
</span>
</button>
</section>
);
}
/** 顶栏按钮:有最小化的运行时恢复它;否则按当前路由带上下文打开(页面内按钮用 openAgentDrawer 传更完整的上下文) */
export function AgentDrawerTrigger() {
const { minimized, runId } = useAgentDrawer();
const { pathname } = useLocation();
return (
<Button
variant="ghost"
size="icon-sm"
aria-label="打开 Agent"
title="Agent"
onClick={() => (minimized && runId ? restoreAgentDrawer() : openAgentDrawer(contextFromPath(pathname)))}
>
<Bot />
</Button>
);
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import { authDenial, isDenied, pairAudit, runPlanIds } from "./agent-conversation.tsx";
import { contextFromPath, contextPrefix, splitContextGoal } from "./agent-store.ts";
import type { StreamItem, ToolCallView } from "./use-agent-stream.ts";
const call = (p: Partial<ToolCallView>): ToolCallView => ({
toolCallId: "t1",
capabilityId: "task.retry",
inputSummary: null,
outputSummary: null,
status: "succeeded",
reason: null,
startedAt: "2026-09-27T00:00:00.000Z",
endedAt: null,
recovery: false,
...p,
});
describe("拒绝判定", () => {
it("授权校验失败(tool_call_end + unauthorized)按拒绝展示", () => {
const c = call({
status: "failed",
outputSummary: '{"error":{"code":"unauthorized","message":"缺少 task.retry 的持续授权"}}',
});
expect(authDenial(c)).toBe("缺少 task.retry 的持续授权");
expect(isDenied(c)).toBe(true);
});
it("截断的摘要也能认出授权错误;普通失败不算拒绝", () => {
expect(authDenial(call({ status: "failed", outputSummary: '{"error":{"code":"forbidden","message":"x"}…' }))).toBe(
"x",
);
expect(isDenied(call({ status: "failed", outputSummary: '{"error":{"code":"internal"}}' }))).toBe(false);
expect(isDenied(call({ status: "denied", reason: "不在白名单" }))).toBe(true);
});
});
describe("计划与审计", () => {
const plan = "32320000-0000-4000-8000-000700000002";
it("从提交输入与下载计划输出里收集计划 ID", () => {
const items: StreamItem[] = [
{ kind: "tool", key: "a", call: call({ capabilityId: "download.submit", inputSummary: `{"planId":"${plan}"}` }) },
{
kind: "tool",
key: "b",
call: call({
toolCallId: "t2",
capabilityId: "download.plan",
outputSummary: '{"plan":{"id":"33000000-0000-4000-8000-000000000701","status":"draft"…',
}),
},
];
expect(runPlanIds(items, null).sort()).toEqual([plan, "33000000-0000-4000-8000-000000000701"]);
});
it("同能力的审计按先后与工具调用配对", () => {
const calls = [
call({ toolCallId: "a", capabilityId: "download.submit", startedAt: "2026-09-27T00:00:01.000Z" }),
call({ toolCallId: "b", capabilityId: "download.submit", startedAt: "2026-09-27T00:00:02.000Z" }),
];
const audit = ["k1", "k2"].map((k, i) => ({
capabilityId: "download.submit",
at: `2026-09-27T00:00:0${i + 1}.500Z`,
outcome: "succeeded",
idempotencyKey: k,
durationMs: 1,
grantId: null,
approvalId: null,
}));
const m = pairAudit(calls, audit);
expect(m.get("a")?.idempotencyKey).toBe("k1");
expect(m.get("b")?.idempotencyKey).toBe("k2");
});
});
describe("侧栏上下文", () => {
it("按路由推断上下文并生成前缀,列表里能拆回正文", () => {
const ctx = contextFromPath("/console/engine/5b4e76e0-f27f-4e7a-8e08-71486387df1d");
expect(ctx?.taskId).toBe("5b4e76e0-f27f-4e7a-8e08-71486387df1d");
expect(contextFromPath("/console/sites/hdhome")?.siteId).toBe("hdhome");
expect(contextFromPath("/console/agent")).toBeNull();
const goal = `${contextPrefix(ctx)}排查这个任务`;
expect(splitContextGoal(goal)).toEqual({ context: expect.stringContaining("taskId="), text: "排查这个任务" });
});
});
+228 -146
View File
@@ -1,6 +1,6 @@
import { useQueryClient } from "@tanstack/react-query";
import { EventType } from "@umt/contracts";
import { Send, Square } from "lucide-react";
import { FlaskConical, Play, RefreshCw, WifiOff } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import {
@@ -11,21 +11,67 @@ import {
useAbortAgentRun,
useAgentRun,
useAppendAgentMessage,
useCreateAgentRun,
useRunApprovals,
useRunInvocationAudit,
} from "~/api/agent.ts";
import { MessageScroller } from "~/components/agentui/message-scroller.tsx";
import { PromptInput } from "~/components/agentui/prompt-input.tsx";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
import { useSse } from "~/lib/sse.ts";
import { useSse, useSseStatus } from "~/lib/sse.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
import { AgentConversation, BudgetBar, FinalReport, RunStatusBadge } from "./agent-conversation.tsx";
import {
AgentConversation,
ApprovalItem,
BudgetBar,
FinalReport,
PlanSteps,
RunAnnouncer,
RunStatusBadge,
runPlanIds,
} from "./agent-conversation.tsx";
import { useAgentStream } from "./use-agent-stream.ts";
const TRIGGER_LABEL = { user: "用户发起", event: "事件触发", schedule: "定时", subagent: "子代理" } as const;
/** 目标输入:execute / plan_only 分段切换 + 文本框 */
export const MODE_HELP: Record<AgentMode, { label: string; help: string }> = {
execute: {
label: "授权内执行",
help: "在已授权范围内执行;未授权的动作会被服务端拒绝并在对话里标出。",
},
plan_only: {
label: "只要方案",
help: "只调查、出方案,不执行任何写操作;满意后可一键按方案执行。",
},
};
/** 运行模式分段切换 */
export function ModeSwitch({ mode, onChange }: { mode: AgentMode; onChange: (m: AgentMode) => void }) {
return (
<fieldset aria-label="运行模式" className="flex items-center gap-1">
{(["execute", "plan_only"] as const).map((v) => (
<button
key={v}
type="button"
aria-pressed={mode === v}
onClick={() => onChange(v)}
className={cn(
"h-8 cursor-pointer rounded-full border border-transparent px-3 text-sm text-muted-foreground outline-none transition-colors duration-micro hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
mode === v && "pill-selected text-foreground",
)}
>
{MODE_HELP[v].label}
</button>
))}
</fieldset>
);
}
/** 新目标输入:execute / plan_only 切换 + 自增高输入框(Enter 发送,Shift+Enter 换行) */
export function GoalComposer({
onSubmit,
pending,
@@ -35,96 +81,71 @@ export function GoalComposer({
}: {
onSubmit: (goal: string, mode: AgentMode) => void;
pending: boolean;
/** 上下文前缀(只读展示) */
/** 上下文前缀(只读展示,提交时拼在目标前) */
prefix?: string;
defaultGoal?: string;
compact?: boolean;
}) {
const [goal, setGoal] = useState(defaultGoal);
const [mode, setMode] = useState<AgentMode>("execute");
const submit = () => {
const g = goal.trim();
if (!g) {
toast.error("先写下目标");
return;
}
onSubmit(g, mode);
};
return (
<form
className="space-y-2"
onSubmit={(e) => {
e.preventDefault();
submit();
}}
>
{prefix ? (
<p
className="truncate rounded-lg border border-border/70 px-2.5 py-1 font-mono text-meta text-muted-foreground"
title={prefix}
>
{prefix}
</p>
) : null}
<Textarea
<div className="space-y-1.5">
<PromptInput
value={goal}
onChange={(e) => setGoal(e.target.value)}
placeholder="例如:把 沙丘:第二部 的 4K 收进来"
rows={compact ? 3 : 3}
onValueChange={setGoal}
onSubmit={(g) => onSubmit(g, mode)}
pending={pending}
minRows={compact ? 2 : 3}
maxRows={10}
maxLength={4000}
aria-label="目标"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
submit();
}
}}
/>
<div className="flex flex-wrap items-center justify-between gap-2">
<fieldset aria-label="运行模式" className="flex items-center gap-1">
{(
[
["execute", "授权内执行"],
["plan_only", "只要方案"],
] as const
).map(([v, label]) => (
<button
key={v}
type="button"
aria-pressed={mode === v}
onClick={() => setMode(v)}
className={cn(
"h-8 cursor-pointer rounded-full border border-transparent px-3 text-sm text-muted-foreground transition-colors duration-micro hover:text-foreground",
mode === v && "pill-selected text-foreground",
)}
placeholder="例如:把 沙丘:第二部 的 4K 收进来"
submitLabel={pending ? "提交中" : "开始"}
header={
prefix ? (
<p
className="mx-1 mb-1 truncate rounded-lg border border-border/70 px-2.5 py-1 font-mono text-meta text-muted-foreground"
title={prefix}
>
{label}
</button>
))}
</fieldset>
<Button type="submit" size="sm" disabled={pending}>
<Send />
{pending ? "提交中…" : "开始"}
</Button>
</div>
<p className="text-meta text-muted-foreground">
{mode === "plan_only"
? "只调查、出方案,不执行任何写操作。"
: "在已授权范围内执行;未授权的动作会被服务端拒绝并在对话里标出。"}
{prefix}
</p>
) : null
}
toolbar={<ModeSwitch mode={mode} onChange={setMode} />}
/>
<p className="px-1 text-meta text-muted-foreground" aria-live="polite">
{MODE_HELP[mode].help} Enter 发送,Shift+Enter 换行。
</p>
</form>
</div>
);
}
/** 一次运行:头部状态 + 预算条 + 流式对话 + 最终汇报 + 追加消息 */
export function AgentRunPanel({ runId, compact = false }: { runId: string; compact?: boolean }) {
/** 一次运行:头部状态 + 预算条 + (只要方案的步骤清单)+ 流式对话 + 审批 + 最终汇报 + 输入框 */
export function AgentRunPanel({
runId,
compact = false,
className,
onRunCreated,
}: {
runId: string;
compact?: boolean;
className?: string;
/** 从本会话派生了新运行(按方案执行)时 */
onRunCreated?: (id: string) => void;
}) {
const run = useAgentRun(runId);
const active = isRunActive(run.data?.run.status);
const live = useAgentStream(runId, active || !run.data);
const abort = useAbortAgentRun();
const append = useAppendAgentMessage();
const create = useCreateAgentRun();
const [msg, setMsg] = useState("");
const [confirmStop, setConfirmStop] = useState(false);
const sseStatus = useSseStatus();
const qc = useQueryClient();
const toolCount = live.items.filter((i) => i.kind === "tool" && i.call.status !== "running").length;
const audit = useRunInvocationAudit(runId, toolCount > 0, toolCount);
const approvals = useRunApprovals(runPlanIds(live.items, run.data?.report ?? null));
// run 状态变化(queued → running → 终态)经 SSE 推送:到达即刷新快照,不等 3 秒轮询
useSse(
[EventType.agentRunStatus],
@@ -139,41 +160,50 @@ export function AgentRunPanel({ runId, compact = false }: { runId: string; compa
if (run.error || !run.data) return <ErrorState plain error={run.error} onRetry={() => void run.refetch()} />;
const { run: r, report, messages } = run.data;
const finished = !isRunActive(r.status) || live.finished;
const streaming = active && !live.finished;
const mode: AgentMode = (r.mode ?? (r.scope.mode as AgentMode | undefined)) || "execute";
const shownStatus =
// 事件流已结束但快照还没刷到终态时按运行中显示;waiting(等待下载/入库核验)照实显示
(live.finished && (r.status === "queued" || r.status === "running")) ||
(r.status === "queued" && live.events.length > 0)
? "running"
: r.status;
const send = (text: string) =>
append.mutate(
{ id: r.id, text },
{
onSuccess: () => {
setMsg("");
void run.refetch();
},
onError: (err) => toast.error(errorMessage(err)),
},
);
const executePlan = () =>
create.mutate(
{ goal: `${r.goal}\n\n(按会话 ${r.id.slice(0, 8)} 的方案执行)`, mode: "execute" },
{
onSuccess: (n) => onRunCreated?.(n.id),
onError: (e) => toast.error(errorMessage(e)),
},
);
return (
<div className="space-y-4">
<div className={cn("flex min-h-0 flex-col gap-3", className)}>
<RunAnnouncer status={shownStatus} finished={live.finished} />
<div className="flex flex-wrap items-center gap-2">
<RunStatusBadge
status={
(live.finished && isRunActive(r.status)) || (r.status === "queued" && live.events.length > 0)
? "running"
: r.status
}
/>
<span className="text-meta text-muted-foreground">
<RunStatusBadge status={shownStatus} />
<StatusBadge tone={mode === "plan_only" ? "warning" : "neutral"}>{MODE_HELP[mode].label}</StatusBadge>
<span className="min-w-0 text-meta text-muted-foreground">
{TRIGGER_LABEL[r.trigger]} · {formatDateTime(r.createdAt)}
{r.model ? ` · ${r.provider ?? ""}/${r.model}` : ""}
</span>
{agentMockEnabled() ? <StatusBadge tone="warning">mock 数据</StatusBadge> : null}
{active ? (
<ConfirmDialog
trigger={
<Button variant="destructive" size="sm" className="ml-auto" disabled={abort.isPending}>
<Square />
中止
</Button>
}
title="中止这次运行?"
description="停止后续步骤;已经完成的外部动作(如已提交的下载)不会被撤销。"
confirmLabel="中止"
destructive
onConfirm={() =>
abort.mutateAsync(r.id).then(
() => toast.success("已请求中止"),
(e) => toast.error(errorMessage(e)),
)
}
/>
{agentMockEnabled() ? (
<StatusBadge tone="warning">
<FlaskConical className="size-3" />
mock 数据
</StatusBadge>
) : null}
</div>
@@ -181,52 +211,104 @@ export function AgentRunPanel({ runId, compact = false }: { runId: string; compa
<BudgetBar run={r} live={live} />
</div>
<AgentConversation
run={r}
live={live}
userMessages={messages.map((m) => ({ at: m.createdAt, text: m.text, pending: m.status === "pending" }))}
compact={compact}
/>
{mode === "plan_only" ? <PlanSteps live={live} active={streaming} /> : null}
{finished ? <FinalReport run={r} report={report} live={live} /> : null}
<form
className="flex items-end gap-2"
onSubmit={(e) => {
e.preventDefault();
const text = msg.trim();
if (!text) return;
append.mutate(
{ id: r.id, text },
{
onSuccess: () => {
setMsg("");
void run.refetch();
},
onError: (err) => toast.error(errorMessage(err)),
},
);
}}
>
<Textarea
value={msg}
onChange={(e) => setMsg(e.target.value)}
rows={1}
maxLength={8000}
placeholder={active ? "补充或纠正(运行中也可追加)" : "追加消息,继续这次会话"}
aria-label="追加消息"
className="min-h-9 flex-1"
/>
<Button
type="submit"
size="icon"
variant="outline"
disabled={append.isPending || !msg.trim()}
aria-label="发送"
{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"
>
<Send />
</Button>
</form>
<WifiOff aria-hidden className="size-4 shrink-0" />
<span className="min-w-0 flex-1">
实时连接中断,正在重连;恢复后按 after= 补读漏掉的事件,期间每 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}
audit={audit.data}
userMessages={messages.map((m) => ({ at: m.createdAt, text: m.text, pending: m.status === "pending" }))}
after={
<>
{approvals.items.map((a) => (
<ApprovalItem key={a.id} approval={a} />
))}
{finished ? <FinalReport run={r} report={report} live={live} /> : null}
</>
}
/>
</MessageScroller>
<div className="space-y-1.5">
<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="追加消息"
placeholder={streaming ? "补充或纠正(运行中也可追加)" : "追加消息,继续这次会话"}
toolbar={
mode === "plan_only" && finished ? (
<Button type="button" size="sm" variant="outline" onClick={executePlan} disabled={create.isPending}>
<Play />
按方案执行
</Button>
) : (
<span className="px-1 text-meta text-muted-foreground">本会话:{MODE_HELP[mode].label}</span>
)
}
/>
<p className="px-1 text-meta text-muted-foreground">
{mode === "plan_only" && finished
? "「按方案执行」会以授权内执行模式新开一次运行,仍受服务端授权校验。"
: MODE_HELP[mode].help}{" "}
Enter 发送,Shift+Enter 换行{streaming ? ",停止 = 中止本次运行" : ""}。
</p>
</div>
<ConfirmDialog
open={confirmStop}
onOpenChange={setConfirmStop}
title="停止这次运行?"
description="停止后续步骤;已经完成的外部动作(如已提交的下载)不会被撤销。"
confirmLabel="停止"
destructive
onConfirm={() =>
abort.mutateAsync(r.id).then(
() => toast.success("已请求中止"),
(e) => {
toast.error(errorMessage(e));
throw e;
},
)
}
/>
</div>
);
}
@@ -0,0 +1,135 @@
import type { AgentRunStatus } from "@umt/contracts";
import { Bot } from "lucide-react";
import { Link } from "react-router";
import { type AgentRunView, agentMockEnabled, RUN_STATUS_LABEL, useAgentRuns } from "~/api/agent.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { Button } from "~/components/ui/button.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { cn } from "~/lib/utils.ts";
import { RunStatusBadge } from "./agent-conversation.tsx";
import { splitContextGoal } from "./agent-store.ts";
const FILTERS: { value: "" | AgentRunStatus; label: string }[] = [
{ value: "", label: "全部" },
{ value: "running", label: RUN_STATUS_LABEL.running },
{ value: "waiting", label: RUN_STATUS_LABEL.waiting },
{ value: "succeeded", label: RUN_STATUS_LABEL.succeeded },
{ value: "failed", label: RUN_STATUS_LABEL.failed },
{ value: "budget_exceeded", label: RUN_STATUS_LABEL.budget_exceeded },
{ value: "cancelled", label: RUN_STATUS_LABEL.cancelled },
];
function usageText(r: AgentRunView) {
const parts = [`${r.toolCallCount} 次调用`];
if (r.usage.totalTokens !== undefined)
parts.push(
r.usage.totalTokens >= 1000
? `${(r.usage.totalTokens / 1000).toFixed(1)}k token`
: `${r.usage.totalTokens} token`,
);
if (r.usage.costUsd !== undefined) parts.push(`$${r.usage.costUsd.toFixed(3)}`);
return parts.join(" · ");
}
/** 会话列表(侧栏):状态筛选 + 每条的目标/状态/模式/时间/用量 */
export function AgentSessionList({
activeId,
status,
onStatusChange,
className,
}: {
activeId?: string;
status: "" | AgentRunStatus;
onStatusChange: (s: "" | AgentRunStatus) => void;
className?: string;
}) {
const runs = useAgentRuns(status || undefined);
const mock = agentMockEnabled();
const rows = runs.data?.pages.flatMap((p) => p.items) ?? [];
return (
<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">
{FILTERS.map((f) => (
<button
key={f.value || "all"}
type="button"
aria-pressed={status === f.value}
onClick={() => onStatusChange(f.value)}
className={cn(
"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",
)}
>
{f.label}
</button>
))}
</fieldset>
<div className="min-h-0 flex-1 overflow-y-auto">
{runs.isPending ? (
<LoadingState rows={4} />
) : runs.error ? (
<ErrorState plain error={runs.error} onRetry={() => void runs.refetch()} />
) : rows.length === 0 ? (
<EmptyState
plain
icon={Bot}
title={status ? "没有这个状态的会话" : "还没有会话"}
description={status ? "换个筛选看看。" : "交代一个目标开始;也可以在任意页面右上角打开 Agent 侧栏。"}
/>
) : (
<ul className="space-y-1">
{rows.map((r) => {
const current = r.id === activeId;
return (
<li key={r.id}>
<Link
to={`/console/agent/${r.id}${mock ? "?mock=1" : ""}`}
aria-current={current ? "page" : undefined}
className={cn(
"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",
current && "pill-selected",
)}
>
<p className="line-clamp-2 text-caption break-words text-foreground">
{splitContextGoal(r.goal).text}
</p>
{splitContextGoal(r.goal).context ? (
<p className="mt-0.5 truncate font-mono text-label text-muted-foreground">
上下文 · {splitContextGoal(r.goal).context?.split(" · ")[0]}
</p>
) : null}
<div className="mt-1 flex flex-wrap items-center gap-1.5">
<RunStatusBadge status={r.status} />
{(r.mode ?? r.scope.mode) === "plan_only" ? (
<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>
);
})}
</ul>
)}
{runs.hasNextPage ? (
<Button
variant="ghost"
size="sm"
className="mt-2 w-full"
disabled={runs.isFetchingNextPage}
onClick={() => void runs.fetchNextPage()}
>
{runs.isFetchingNextPage ? "加载中…" : "加载更多"}
</Button>
) : null}
</div>
</nav>
);
}
+47 -4
View File
@@ -10,28 +10,64 @@ export type AgentContext = {
downloadTaskId?: string;
recordId?: string;
siteId?: string;
/** 引擎任务(OpenWorkflow run)等后台任务 */
taskId?: string;
/** 预填目标 */
goal?: string;
};
type State = { open: boolean; context: AgentContext | null; runId: string | null };
type State = { open: boolean; minimized: boolean; context: AgentContext | null; runId: string | null };
let state: State = { open: false, context: null, runId: null };
let state: State = { open: false, minimized: false, context: null, runId: null };
const listeners = new Set<() => void>();
const emit = () => {
for (const l of listeners) l();
};
export function openAgentDrawer(context: AgentContext | null = null) {
state = { open: true, context, runId: null };
state = { open: true, minimized: false, context, runId: null };
emit();
}
export function setAgentDrawerOpen(open: boolean) {
state = { ...state, open };
state = { ...state, open, minimized: false };
emit();
}
/** 最小化:抽屉收起,运行继续在右下角的小窗里流式显示 */
export function minimizeAgentDrawer() {
state = { ...state, open: false, minimized: true };
emit();
}
/** 从小窗恢复抽屉 */
export function restoreAgentDrawer() {
state = { ...state, open: true, minimized: false };
emit();
}
/** 按当前路由推断页面上下文(顶栏按钮用;页面内按钮直接传更完整的上下文) */
export function contextFromPath(pathname: string): AgentContext | null {
const m = pathname.match(/^\/console\/(library|sites|tasks|organize|engine)\/([^/?#]+)/);
if (!m) return null;
const [, kind, id] = m as unknown as [string, string, string];
const short = id.slice(0, 8);
switch (kind) {
case "library":
return { label: `作品 ${short}`, entityId: id };
case "sites":
return { label: `站点 ${id}`, siteId: id };
case "tasks":
return { label: `下载任务 ${short}`, downloadTaskId: id };
case "organize":
return { label: `整理记录 ${short}`, recordId: id };
case "engine":
return { label: `引擎任务 ${short}`, taskId: id };
default:
return null;
}
}
export function setAgentDrawerRun(runId: string | null) {
state = { ...state, runId };
emit();
@@ -58,5 +94,12 @@ export function contextPrefix(ctx: AgentContext | null): string {
if (ctx.downloadTaskId) parts.push(`downloadTaskId=${ctx.downloadTaskId}`);
if (ctx.recordId) parts.push(`organizationRecordId=${ctx.recordId}`);
if (ctx.siteId) parts.push(`siteId=${ctx.siteId}`);
if (ctx.taskId) parts.push(`taskId=${ctx.taskId}`);
return parts.length ? `[上下文 ${parts.join(" · ")}] ` : "";
}
/** 拆出目标里的上下文前缀(列表、小窗只显示目标正文,前缀另行标注) */
export function splitContextGoal(goal: string): { context: string | null; text: string } {
const m = /^\[上下文 ([^\]]*)\]\s*/.exec(goal);
return m ? { context: m[1] ?? null, text: goal.slice(m[0].length) } : { context: null, text: goal };
}
@@ -1,5 +1,5 @@
import { type AgentRunEvent, type AgentUsageTotals, EventType } from "@umt/contracts";
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { agentMockEnabled, fetchRunEvents } from "~/api/agent.ts";
import { sse } from "~/lib/sse.ts";
@@ -40,6 +40,8 @@ export type AgentStreamState = {
finished: boolean;
error: string | null;
connected: "sse" | "poll" | "mock" | "idle";
/** 读取失败后手动重试(从最后收到的 seq 之后补读) */
retry: () => void;
};
const SSE_TYPES: string[] = [EventType.agentTextDelta, EventType.agentToolCall, EventType.agentRunStatus];
@@ -177,9 +179,8 @@ export function useAgentStream(runId: string | undefined, active: boolean): Agen
const [events, setEvents] = useState<AgentRunEvent[]>([]);
const [error, setError] = useState<string | null>(null);
const [connected, setConnected] = useState<AgentStreamState["connected"]>("idle");
const [nonce, setNonce] = useState(0);
const lastSeq = useRef(-1);
const inflight = useRef(false);
const pending = useRef(false);
// biome-ignore lint/correctness/useExhaustiveDependencies: 换 run 时重置
useEffect(() => {
@@ -188,10 +189,14 @@ export function useAgentStream(runId: string | undefined, active: boolean): Agen
lastSeq.current = -1;
}, [runId]);
// biome-ignore lint/correctness/useExhaustiveDependencies: nonce 变化 = 手动重试,重新建立补读
useEffect(() => {
if (!runId) return;
let stopped = false;
const ctrl = new AbortController();
// 在途/待补标记按本次 effect 独立:active 变化重建 effect 时,被中止的旧请求不会把新一轮的补读挡在门外
const inflight = { current: false };
const pending = { current: false };
const pull = async () => {
if (inflight.current) {
@@ -243,12 +248,13 @@ export function useAgentStream(runId: string | undefined, active: boolean): Agen
unsubscribe?.();
setConnected("idle");
};
}, [runId, active]);
}, [runId, active, nonce]);
const retry = useCallback(() => setNonce((n) => n + 1), []);
return useMemo(() => {
const items = foldEvents(events);
const end = [...events].reverse().find((e) => e.type === "run_completed" || e.type === "run_failed");
const usage = end && (end.type === "run_completed" || end.type === "run_failed") ? end.usage : null;
return { events, items, usage, finished: !!end, error, connected };
}, [events, error, connected]);
return { events, items, usage, finished: !!end, error, connected, retry };
}, [events, error, connected, retry]);
}
@@ -0,0 +1,32 @@
# agentui 许可证
本目录的组件改造自 [agentui](https://github.com/ashish200729/agentui)(<https://www.agentui.pro/>),
取自提交 `b2b752bcadad032ce555f37dc7c8faabf395cefe`(2026-09-26)的 `components/agents/`。
每个文件头注明了对应的上游文件与改造点:去掉 `motion` / `shiki` / Next.js 依赖,颜色、字号、圆角、
时长、缓动换成本项目的语义 token(见 `docs/design/`),文案改中文,状态机按本项目的事件与对象裁剪。
上游许可证原文如下。
```
MIT License
Copyright (c) 2026 Saurabh Chauhan
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
@@ -0,0 +1,139 @@
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/approval-card/index.tsx + types.ts
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
// 改造:状态集按本项目 Approval 对齐——上游的 submitting 保留,changes-requested/answered 与多步问答模式去掉
// (依赖 motion 的 Checkbox/Radio,且本项目审批只有批准/拒绝);新增 revoked/expired/consumed 终态;
// 状态色换 warning/success/destructive 并配文字;按钮用项目 <Button>(胶囊,主操作 pill-primary);
// 新增 `disabledReason`:按钮不可用时说明原因(如"只有控制台管理员会话能批准")。
import { Check, Clock, LoaderCircle, MessageSquareText, Undo2, X } from "lucide-react";
import type { ReactNode } from "react";
import { Button } from "~/components/ui/button.tsx";
import { cn } from "~/lib/utils.ts";
import { Disclosure } from "./disclosure.tsx";
export type ApprovalCardStatus =
| "pending"
| "submitting"
| "approved"
| "rejected"
| "revoked"
| "expired"
| "consumed";
const LABEL: Record<ApprovalCardStatus, string> = {
pending: "待批准",
submitting: "提交中",
approved: "已批准",
rejected: "已拒绝",
revoked: "已撤销",
expired: "已过期",
consumed: "已执行",
};
const BADGE: Record<ApprovalCardStatus, string> = {
pending: "border-warning/30 bg-warning/10 text-warning",
submitting: "border-border bg-foreground/[0.04] text-muted-foreground",
approved: "border-success/30 bg-success/10 text-success",
consumed: "border-success/30 bg-success/10 text-success",
rejected: "border-destructive/30 bg-destructive/10 text-destructive",
revoked: "border-border bg-foreground/[0.04] text-muted-foreground",
expired: "border-border bg-foreground/[0.04] text-muted-foreground",
};
function StatusIcon({ status }: { status: ApprovalCardStatus }) {
if (status === "submitting") return <LoaderCircle className="size-4 animate-spin motion-reduce:animate-none" />;
if (status === "pending") return <MessageSquareText className="size-4" />;
if (status === "rejected") return <X className="size-4" />;
if (status === "expired") return <Clock className="size-4" />;
if (status === "revoked") return <Undo2 className="size-4" />;
return <Check className="size-4" />;
}
export interface ApprovalCardProps {
title?: ReactNode;
description?: ReactNode;
children?: ReactNode;
status?: ApprovalCardStatus;
badges?: ReactNode;
onApprove?: () => void;
onReject?: () => void;
approveLabel?: ReactNode;
rejectLabel?: ReactNode;
/** 按钮不可用的原因;给了就禁用按钮并显示 */
disabledReason?: string;
/** 终态时的结论文字 */
result?: ReactNode;
className?: string;
}
export function ApprovalCard({
title = "需要批准",
description,
children,
status = "pending",
badges,
onApprove,
onReject,
approveLabel = "批准",
rejectLabel = "拒绝",
disabledReason,
result,
className,
}: ApprovalCardProps) {
const busy = status === "submitting";
const interactive = status === "pending" || busy;
return (
<section
data-state={status}
aria-busy={busy}
aria-label={typeof title === "string" ? title : "审批"}
className={cn(
"w-full min-w-0 overflow-hidden rounded-xl border p-3 text-sm",
status === "pending" ? "border-warning/40 bg-warning/[0.06]" : "border-border/70",
className,
)}
>
<div className="flex items-start gap-3">
<span
aria-hidden
className={cn(
"mt-0.5 grid size-5 shrink-0 place-items-center text-muted-foreground",
status === "pending" && "text-warning",
(status === "approved" || status === "consumed") && "text-success",
status === "rejected" && "text-destructive",
)}
>
<StatusIcon status={status} />
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-start gap-x-3 gap-y-1">
<h3 className="min-w-0 flex-1 text-body leading-5 font-medium text-foreground">{title}</h3>
<span className={cn("shrink-0 rounded-full border px-2 py-0.5 text-label font-medium", BADGE[status])}>
{LABEL[status]}
</span>
</div>
{badges ? <div className="mt-1.5 flex flex-wrap items-center gap-1">{badges}</div> : null}
{description ? <p className="mt-1.5 text-caption leading-5 text-muted-foreground">{description}</p> : null}
{children ? <div className="mt-2.5">{children}</div> : null}
<Disclosure open={interactive}>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button size="sm" disabled={busy || !!disabledReason || !onApprove} onClick={onApprove}>
{approveLabel}
</Button>
<Button
size="sm"
variant="ghost"
disabled={busy || !!disabledReason || !onReject}
onClick={onReject}
className="hover:text-destructive"
>
{rejectLabel}
</Button>
{disabledReason ? <span className="text-meta text-muted-foreground">{disabledReason}</span> : null}
</div>
</Disclosure>
{!interactive ? <p className="mt-1.5 text-caption text-muted-foreground">{result ?? LABEL[status]}</p> : null}
</div>
</div>
</section>
);
}
@@ -0,0 +1,159 @@
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/citations.tsx
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
// 改造:去掉 favicon 抓取(控制台链接都是站内),改成可选图标槽;站内链接走 react-router 的 Link(`to`),
// 外链仍新窗口打开;新增 `meta`(右侧状态文字);入场/折叠去 motion,用 Disclosure 与 tw-animate;字号/圆角换 token。
import { BookOpenText, ChevronDown, ExternalLink } from "lucide-react";
import { type ReactNode, useId, useState } from "react";
import { Link } from "react-router";
import { cn } from "~/lib/utils.ts";
import { CHEVRON_CLASS, Disclosure } from "./disclosure.tsx";
export interface CitationItem {
id: string;
title: ReactNode;
/** 副标题(如状态、域名) */
domain?: ReactNode;
/** 右侧附加信息(如状态徽章) */
meta?: ReactNode;
icon?: ReactNode;
/** 站内路由 */
to?: string;
/** 外链 */
url?: string;
}
function targetId(prefix: string, id: string) {
return `${prefix}-${id.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
}
/** 行内角标:跳到同 idPrefix 的来源行 */
export function Citation({
citationId,
index,
idPrefix,
className,
}: {
citationId: string;
index: number;
idPrefix: string;
className?: string;
}) {
return (
<a
href={`#${targetId(idPrefix, citationId)}`}
aria-label={`查看来源 ${index}`}
className={cn(
"mx-0.5 inline-flex min-w-4 -translate-y-0.5 items-center justify-center rounded-tight bg-foreground/[0.06] px-1 py-0.5 text-micro leading-none font-semibold text-muted-foreground no-underline outline-none transition-colors duration-micro hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
>
{index}
</a>
);
}
function Row({ c, index, prefix }: { c: CitationItem; index: number; prefix: string }) {
const body = (
<>
{c.icon ? (
<span aria-hidden className="grid size-5 shrink-0 place-items-center text-muted-foreground [&_svg]:size-3.5">
{c.icon}
</span>
) : null}
<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/85 transition-colors duration-micro group-hover/citation:text-foreground">
{c.title}
</span>
{c.domain ? <span className="min-w-0 truncate text-meta text-muted-foreground">{c.domain}</span> : null}
</span>
<span className="flex shrink-0 items-center gap-1.5">
{c.meta}
<span className="grid size-5 place-items-center rounded-md bg-foreground/[0.05] text-micro font-semibold text-muted-foreground tabular-nums">
{index}
</span>
{c.url ? <ExternalLink aria-hidden className="size-3.5 text-muted-foreground/60" /> : null}
</span>
</>
);
const cls =
"group/citation flex min-w-0 items-center gap-2 rounded-md px-1.5 py-1 outline-none transition-colors duration-micro hover:bg-foreground/[0.04] focus-visible:ring-2 focus-visible:ring-ring";
const id = targetId(prefix, c.id);
if (c.to)
return (
<Link id={id} to={c.to} className={cls}>
{body}
</Link>
);
if (c.url)
return (
<a id={id} href={c.url} target="_blank" rel="noreferrer noopener" className={cls}>
{body}
</a>
);
return (
<div id={id} className={cls}>
{body}
</div>
);
}
export function CitationList({
citations,
idPrefix,
className,
}: {
citations: CitationItem[];
idPrefix?: string;
className?: string;
}) {
const base = useId();
const prefix = idPrefix ?? `citation-${base.replace(/:/g, "")}`;
return (
<ol className={cn("grid gap-0.5", className)}>
{citations.map((c, i) => (
<li key={c.id} className="min-w-0 animate-in fade-in-0 duration-base motion-reduce:animate-none">
<Row c={c} index={i + 1} prefix={prefix} />
</li>
))}
</ol>
);
}
/** 可折叠的来源列表(上游默认收起;这里给核实结果用,默认展开) */
export function Citations({
citations,
title = "来源",
defaultOpen = false,
idPrefix,
className,
}: {
citations: CitationItem[];
title?: ReactNode;
defaultOpen?: boolean;
idPrefix?: string;
className?: string;
}) {
const id = useId();
const [open, setOpen] = useState(defaultOpen);
return (
<div className={cn("w-full text-sm", className)}>
<button
type="button"
aria-expanded={open}
aria-controls={id}
onClick={() => setOpen(!open)}
className="-ml-1 flex min-h-8 cursor-pointer items-center gap-2 rounded-lg px-1 text-left text-muted-foreground outline-none transition-colors duration-micro hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<BookOpenText aria-hidden className="size-4" />
<span className="text-caption font-medium">{title}</span>
<span className="rounded-full bg-foreground/[0.06] px-1.5 py-0.5 text-micro font-semibold tabular-nums">
{citations.length}
</span>
<ChevronDown aria-hidden className={cn("size-3.5", CHEVRON_CLASS, open && "rotate-180")} />
</button>
<Disclosure id={id} open={open}>
<CitationList citations={citations} idPrefix={idPrefix} className="mt-1" />
</Disclosure>
</div>
);
}
@@ -0,0 +1,156 @@
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/code-block.tsx + agent-code.tsx
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
// 改造:不引 shiki(包体大,且高亮主题带十六进制颜色),纯文本按行渲染;JSON 输入先尝试缩进美化;
// 状态色:streaming 用中性色 + 转圈,complete 用 success;高亮行用 warning 洗层;行号/字号/圆角换 token;
// 去掉 motion 按压;复制失败静默。
import { Check, Copy, FileCode2, LoaderCircle } from "lucide-react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { cn } from "~/lib/utils.ts";
export type CodeLanguage = "text" | "json" | "bash" | "diff";
/** JSON 文本尝试缩进美化;不是合法 JSON 原样返回(摘要可能被截断) */
export function prettyMaybeJson(s: string): { text: string; language: CodeLanguage } {
const t = s.trim();
if ((t.startsWith("{") && t.endsWith("}")) || (t.startsWith("[") && t.endsWith("]"))) {
try {
return { text: JSON.stringify(JSON.parse(t), null, 2), language: "json" };
} catch {
return { text: s, language: "json" };
}
}
return { text: s, language: "text" };
}
export interface CodeBlockProps {
code: string;
language?: CodeLanguage;
filename?: ReactNode;
status?: "streaming" | "complete";
showLineNumbers?: boolean;
highlightLines?: number[];
/** 最大高度(Tailwind 类,如 max-h-56) */
maxHeightClassName?: string;
wrap?: boolean;
copyable?: boolean;
/** 不显示顶栏(嵌在别的卡片里时) */
bare?: boolean;
tone?: "default" | "destructive";
className?: string;
}
export function CodeBlock({
code,
language = "text",
filename,
status = "complete",
showLineNumbers = false,
highlightLines = [],
maxHeightClassName = "max-h-56",
wrap = true,
copyable = true,
bare = false,
tone = "default",
className,
}: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const t = useRef<number | undefined>(undefined);
useEffect(
() => () => {
if (t.current) window.clearTimeout(t.current);
},
[],
);
const streaming = status === "streaming";
const hl = new Set(highlightLines);
const lines = code.split("\n");
const copy = async () => {
try {
await navigator.clipboard?.writeText(code);
setCopied(true);
if (t.current) window.clearTimeout(t.current);
t.current = window.setTimeout(() => setCopied(false), 1600);
} catch {
// 非安全上下文没有剪贴板
}
};
return (
<div
data-state={status}
aria-busy={streaming}
className={cn(
"w-full min-w-0 overflow-hidden rounded-lg border border-border/60 bg-foreground/[0.03] text-sm",
tone === "destructive" && "border-destructive/30",
className,
)}
>
{bare ? null : (
<div className="flex h-8 items-center gap-2 border-b border-border/50 px-2.5">
<FileCode2 aria-hidden className="size-3.5 shrink-0 text-muted-foreground/70" />
{filename ? <span className="min-w-0 truncate text-meta text-foreground/80">{filename}</span> : null}
<span className="text-micro font-medium tracking-wide text-muted-foreground uppercase">{language}</span>
<span
className={cn(
"ml-auto inline-flex shrink-0 items-center gap-1 text-micro font-medium",
streaming ? "text-muted-foreground" : "text-success",
)}
>
{streaming ? (
<LoaderCircle aria-hidden className="size-3 animate-spin motion-reduce:animate-none" />
) : (
<Check aria-hidden className="size-3" />
)}
{streaming ? "输出中" : "完整"}
</span>
{copyable ? (
<button
type="button"
aria-label={copied ? "已复制" : "复制"}
title={copied ? "已复制" : "复制"}
onClick={copy}
className="grid size-6 shrink-0 cursor-pointer place-items-center rounded-full text-muted-foreground outline-none transition-colors duration-micro hover:bg-foreground/[0.06] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
</button>
) : null}
</div>
)}
<div className={cn("overflow-auto py-1.5", maxHeightClassName)}>
<pre
className={cn(
"m-0 font-mono text-meta leading-5 text-foreground/85",
tone === "destructive" && "text-destructive",
!wrap && "min-w-max",
)}
>
<code>
{lines.map((line, i) => (
<span
key={i}
className={cn(
"grid min-h-5",
showLineNumbers ? "grid-cols-[2.25rem_minmax(0,1fr)]" : "grid-cols-1",
hl.has(i + 1) && "bg-warning/10",
)}
>
{showLineNumbers ? (
<span className="pr-2 text-right text-muted-foreground/50 tabular-nums select-none">{i + 1}</span>
) : null}
<span
className={cn(
"pr-3",
showLineNumbers ? "pl-1" : "pl-3",
wrap ? "break-all whitespace-pre-wrap" : "whitespace-pre",
)}
>
{line || " "}
</span>
</span>
))}
</code>
</pre>
</div>
</div>
);
}
@@ -0,0 +1,34 @@
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/agent-disclosure.tsx
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
// 改造:motion 的 clipPath/height 动画换成 CSS grid-rows 0fr→1fr 过渡(duration-layout / ease-standard),
// reduced motion 下不做过渡;收起时 inert + aria-hidden,键盘与读屏都跳过。
import type { ComponentPropsWithRef } from "react";
import { cn } from "~/lib/utils.ts";
export interface DisclosureProps extends ComponentPropsWithRef<"div"> {
open: boolean;
innerClassName?: string;
}
/** 折叠区:展开/收起只动高度与透明度 */
export function Disclosure({ open, className, innerClassName, children, ...props }: DisclosureProps) {
return (
<div
{...props}
data-state={open ? "open" : "closed"}
aria-hidden={!open}
inert={!open}
className={cn(
"grid transition-[grid-template-rows,opacity] duration-layout ease-standard motion-reduce:transition-none",
open ? "grid-rows-[1fr] opacity-100" : "pointer-events-none grid-rows-[0fr] opacity-0",
className,
)}
>
<div className={cn("min-h-0 overflow-hidden", innerClassName)}>{children}</div>
</div>
);
}
/** 折叠箭头:展开时转 180°(旋转过渡 duration-base) */
export const CHEVRON_CLASS =
"shrink-0 text-muted-foreground/60 transition-transform duration-base ease-standard motion-reduce:transition-none";
@@ -0,0 +1,99 @@
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/file-diff.tsx
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
// 改造:新增/删除行底色 emerald/rose → success/destructive 洗层(并保留 +/− 符号,不只靠颜色);不做语法高亮(去 shiki);
// 折叠用 Disclosure;maxHeight 改 Tailwind 类;用于展示路径类变更(如整理记录的入库目标)。
import { ChevronDown, FileCode2 } from "lucide-react";
import { type ReactNode, useId, useState } from "react";
import { cn } from "~/lib/utils.ts";
import { CHEVRON_CLASS, Disclosure } from "./disclosure.tsx";
export type FileDiffLineType = "added" | "removed" | "context";
export interface FileDiffLine {
id: string;
type?: FileDiffLineType;
content: ReactNode;
/** 行尾附注(如状态) */
note?: ReactNode;
}
function Count({ value, type }: { value: number; type: "added" | "removed" }) {
if (!value) return null;
return (
<span className={cn("font-mono text-meta tabular-nums", type === "added" ? "text-success" : "text-destructive")}>
{type === "added" ? "+" : "−"}
{value}
</span>
);
}
export function FileDiff({
file,
lines,
defaultOpen = true,
maxHeightClassName = "max-h-56",
className,
}: {
file: ReactNode;
lines: FileDiffLine[];
defaultOpen?: boolean;
maxHeightClassName?: string;
className?: string;
}) {
const id = useId();
const [open, setOpen] = useState(defaultOpen);
const added = lines.filter((l) => l.type === "added").length;
const removed = lines.filter((l) => l.type === "removed").length;
return (
<div className={cn("w-full min-w-0 overflow-hidden rounded-lg border border-border/60 text-sm", className)}>
<button
type="button"
aria-expanded={open}
aria-controls={id}
onClick={() => setOpen(!open)}
className="flex h-8 w-full cursor-pointer items-center gap-2 px-2.5 text-left outline-none transition-colors duration-micro hover:bg-foreground/[0.03] focus-visible:ring-2 focus-visible:ring-ring"
>
<FileCode2 aria-hidden className="size-3.5 shrink-0 text-muted-foreground/70" />
<span className="min-w-0 flex-1 truncate text-meta text-foreground/85">{file}</span>
<Count value={added} type="added" />
<Count value={removed} type="removed" />
<ChevronDown aria-hidden className={cn("size-3.5", CHEVRON_CLASS, open && "rotate-180")} />
</button>
<Disclosure id={id} open={open}>
<div className={cn("overflow-auto border-t border-border/50 py-1", maxHeightClassName)}>
<ul className="font-mono text-meta leading-5">
{lines.map((l) => (
<li
key={l.id}
className={cn(
"grid grid-cols-[1.25rem_minmax(0,1fr)_auto] gap-1 pr-2.5",
l.type === "added" && "bg-success/[0.08]",
l.type === "removed" && "bg-destructive/[0.08]",
)}
>
<span
aria-hidden
className={cn(
"text-center select-none",
l.type === "added"
? "text-success"
: l.type === "removed"
? "text-destructive"
: "text-muted-foreground/50",
)}
>
{l.type === "added" ? "+" : l.type === "removed" ? "−" : " "}
</span>
<span className="min-w-0 break-all text-foreground/85">
<span className="sr-only">{l.type === "added" ? "新增:" : l.type === "removed" ? "移除:" : ""}</span>
{l.content}
</span>
{l.note ? <span className="shrink-0 font-sans text-muted-foreground">{l.note}</span> : null}
</li>
))}
</ul>
</div>
</Disclosure>
</div>
);
}
@@ -0,0 +1,104 @@
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/message-bubble.tsx
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
// 改造:去掉 motion 的 layout 动画与 render 克隆;变体底色换语义 token(soft = foreground 洗层,danger = destructive,
// warning 为新增);折叠遮罩用 mask-image 渐变到 transparent(不写十六进制),箭头旋转用 duration-base。
import { ChevronDown } from "lucide-react";
import { type ComponentPropsWithRef, createContext, useContext, useId, useState } from "react";
import { cn } from "~/lib/utils.ts";
import { CHEVRON_CLASS } from "./disclosure.tsx";
import { MessageSideContext } from "./message.tsx";
export type MessageBubbleVariant = "solid" | "soft" | "outline" | "ghost" | "danger" | "warning";
const BubbleContext = createContext<{ variant: MessageBubbleVariant; align: "start" | "end" }>({
variant: "soft",
align: "start",
});
export function MessageBubble({
variant = "soft",
align,
className,
...props
}: ComponentPropsWithRef<"div"> & { variant?: MessageBubbleVariant; align?: "start" | "end" }) {
const side = useContext(MessageSideContext);
const resolved = align ?? side ?? "start";
return (
<BubbleContext.Provider value={{ variant, align: resolved }}>
<div
data-slot="message-bubble"
data-variant={variant}
className={cn("flex w-full min-w-0 flex-col", resolved === "end" ? "items-end" : "items-start", className)}
{...props}
/>
</BubbleContext.Provider>
);
}
const VARIANT: Record<MessageBubbleVariant, string> = {
solid: "bg-foreground text-background",
soft: "border border-border/60 bg-foreground/[0.05]",
outline: "border border-border/70",
ghost: "w-full max-w-none rounded-none border-0 px-0 py-0",
danger: "border border-destructive/40 bg-destructive/10 text-destructive",
warning: "border border-warning/40 bg-warning/10 text-warning",
};
export function MessageBubbleContent({ className, ...props }: ComponentPropsWithRef<"div">) {
const { variant } = useContext(BubbleContext);
return (
<div
data-slot="message-bubble-content"
className={cn(
"relative min-w-9 max-w-[85%] rounded-2xl px-3.5 py-2.5 text-sm leading-6 break-words text-foreground",
VARIANT[variant],
className,
)}
{...props}
/>
);
}
const CLAMP = {
2: "line-clamp-2",
3: "line-clamp-3",
4: "line-clamp-4",
5: "line-clamp-5",
6: "line-clamp-6",
} as const;
/** 长消息折叠:收起时按行截断并渐隐 */
export function MessageBubbleCollapsible({
collapsedLines = 4,
moreLabel = "展开",
lessLabel = "收起",
className,
children,
...props
}: ComponentPropsWithRef<"div"> & { collapsedLines?: 2 | 3 | 4 | 5 | 6; moreLabel?: string; lessLabel?: string }) {
const id = useId();
const [open, setOpen] = useState(false);
return (
<div data-state={open ? "open" : "closed"} className={cn("w-full", className)} {...props}>
<div
id={id}
className={cn(
!open && CLAMP[collapsedLines],
!open && "[mask-image:linear-gradient(to_bottom,black_68%,transparent)]",
)}
>
{children}
</div>
<button
type="button"
aria-expanded={open}
aria-controls={id}
onClick={() => setOpen(!open)}
className="mt-1.5 inline-flex h-7 cursor-pointer items-center gap-1 rounded-full px-2 text-meta font-medium text-muted-foreground outline-none transition-colors duration-micro hover:bg-foreground/[0.06] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
{open ? lessLabel : moreLabel}
<ChevronDown aria-hidden className={cn("size-3.5", CHEVRON_CLASS, open && "rotate-180")} />
</button>
</div>
);
}
@@ -0,0 +1,179 @@
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/message-scroller.tsx
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
// 改造:保留"离底部 followThreshold 以内才跟随、滚轮/触摸/上翻键主动离开、ResizeObserver 跟随内容增长"的状态机;
// 去掉 PreviewRail 导航;新增"回到底部"按钮(离开跟随时出现,点了恢复跟随);reduced motion 下不用平滑滚动;
// 程序滚动的静默期取 --duration-layout 的值,不写字面量时长;log 只播报新增行(additions),流式正文不逐字播报。
import { ArrowDown } from "lucide-react";
import {
type ComponentPropsWithRef,
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { cn } from "~/lib/utils.ts";
import { useReducedMotion } from "./use-reduced-motion.ts";
export interface MessageScrollerProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
children: ReactNode;
/** 用户停在底部附近时,新内容自动滚到底 */
followOutput?: boolean;
/** 离底部多少像素内仍算"跟随中" */
followThreshold?: number;
onFollowChange?: (following: boolean) => void;
/** 滚动区的无障碍名称 */
label?: string;
/** 还在等待流式内容 */
busy?: boolean;
viewportClassName?: string;
contentClassName?: string;
/** "回到底部"按钮文案 */
jumpLabel?: string;
}
function layoutMs(el: Element | null): number {
if (!el) return 0;
const v = getComputedStyle(el).getPropertyValue("--duration-layout").trim();
const n = Number.parseFloat(v);
return Number.isFinite(n) ? (v.endsWith("ms") ? n : n * 1000) : 0;
}
export function MessageScroller({
followOutput = true,
followThreshold = 56,
onFollowChange,
label = "对话记录",
busy,
viewportClassName,
contentClassName,
jumpLabel = "回到底部",
className,
children,
...props
}: MessageScrollerProps) {
const reduce = useReducedMotion();
const viewportRef = useRef<HTMLElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const followingRef = useRef(followOutput);
const programmatic = useRef(false);
const timer = useRef<number | undefined>(undefined);
const [following, setFollowingState] = useState(true);
const setFollowing = useCallback(
(next: boolean) => {
if (followingRef.current === next) return;
followingRef.current = next;
setFollowingState(next);
onFollowChange?.(next);
},
[onFollowChange],
);
const scrollToEnd = useCallback((behavior: ScrollBehavior) => {
const vp = viewportRef.current;
if (!vp) return;
programmatic.current = true;
if (typeof vp.scrollTo === "function") vp.scrollTo({ top: vp.scrollHeight, behavior });
else vp.scrollTop = vp.scrollHeight;
if (timer.current) window.clearTimeout(timer.current);
// 平滑滚动的静默期:等 scrollend,兜底取 3 × --duration-layout
const done = () => {
programmatic.current = false;
vp.removeEventListener("scrollend", done);
};
vp.addEventListener("scrollend", done, { once: true });
timer.current = window.setTimeout(done, behavior === "smooth" ? layoutMs(vp) * 3 : 0);
}, []);
const lastTop = useRef(0);
const onScroll = useCallback(() => {
const vp = viewportRef.current;
if (!vp) return;
const up = vp.scrollTop < lastTop.current - 1;
lastTop.current = vp.scrollTop;
const nearEnd = vp.scrollHeight - vp.scrollTop - vp.clientHeight <= followThreshold;
// 回到底部附近:恢复跟随;离开底部:只有"往上翻"才算用户离开(程序平滑滚动与内容增长只会往下)
if (nearEnd) setFollowing(true);
else if (up && !programmatic.current) setFollowing(false);
}, [followThreshold, setFollowing]);
const leaveLiveEdge = useCallback(() => {
programmatic.current = false;
}, []);
useLayoutEffect(() => {
followingRef.current = followOutput;
if (!followOutput) return;
const f = requestAnimationFrame(() => scrollToEnd("auto"));
return () => cancelAnimationFrame(f);
}, [followOutput, scrollToEnd]);
useEffect(() => {
const content = contentRef.current;
if (!content || typeof ResizeObserver === "undefined") return;
// 首次铺满内容(回放历史)直接跳到底,之后的增长才平滑跟随
let first = true;
const ro = new ResizeObserver(() => {
if (!followOutput || !followingRef.current) return;
scrollToEnd(reduce || first ? "auto" : "smooth");
first = false;
});
ro.observe(content);
return () => ro.disconnect();
}, [followOutput, reduce, scrollToEnd]);
useEffect(
() => () => {
if (timer.current) window.clearTimeout(timer.current);
},
[],
);
return (
<div data-slot="message-scroller" className={cn("relative min-h-0", className)} {...props}>
<section
ref={viewportRef}
aria-label={label}
// biome-ignore lint/a11y/noNoninteractiveTabindex: 滚动区要能用键盘聚焦后方向键滚动
tabIndex={0}
onScroll={onScroll}
onWheel={leaveLiveEdge}
onTouchStart={leaveLiveEdge}
onKeyDown={(e) => {
if (["ArrowUp", "PageUp", "Home"].includes(e.key)) leaveLiveEdge();
}}
className={cn(
"h-full overflow-y-auto overscroll-contain outline-none [overflow-anchor:none] [scrollbar-gutter:stable] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
viewportClassName,
)}
>
<div
ref={contentRef}
role="log"
aria-live="polite"
aria-relevant="additions"
aria-busy={busy}
className={contentClassName}
>
{children}
</div>
</section>
{!following ? (
<button
type="button"
onClick={() => {
setFollowing(true);
scrollToEnd(reduce ? "auto" : "smooth");
}}
className="pg-float absolute bottom-3 left-1/2 z-10 inline-flex h-8 -translate-x-1/2 cursor-pointer items-center gap-1.5 rounded-full px-3 text-meta font-medium text-foreground outline-none animate-in fade-in-0 slide-in-from-bottom-1 duration-base motion-reduce:animate-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ArrowDown aria-hidden className="size-3.5" />
{jumpLabel}
{busy ? <span className="sr-only">(仍在输出)</span> : null}
</button>
) : null}
</div>
);
}
+155
View File
@@ -0,0 +1,155 @@
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/message.tsx + message-context.tsx
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
// 改造:motion.article 的弹簧入场换成 tw-animate 的 animate-in(duration-base / ease-pop,motion-reduce 关闭);
// 头像底色、字号(text-label / text-meta)、圆角换语义 token;新增 from="system" 行(居中提示,用于压缩/中止等系统消息);
// MessageTyping 三点动画用 animate-pulse + 具名时长的 animation-delay。
import { type ComponentPropsWithRef, createContext, useContext } from "react";
import { cn } from "~/lib/utils.ts";
export type MessageFrom = "user" | "assistant" | "system";
export type MessageSide = "start" | "end";
export const MessageSideContext = createContext<MessageSide | undefined>(undefined);
const MessageContext = createContext<{ from: MessageFrom }>({ from: "assistant" });
export interface MessageProps extends ComponentPropsWithRef<"article"> {
from: MessageFrom;
/** 挂载时播一次入场(新到达的消息用;回放历史时不播) */
animateIn?: boolean;
}
const FROM_LABEL: Record<MessageFrom, string> = { user: "用户消息", assistant: "Agent 消息", system: "系统消息" };
export function Message({ from, animateIn = false, className, children, ...props }: MessageProps) {
return (
<MessageSideContext.Provider value={from === "user" ? "end" : "start"}>
<MessageContext.Provider value={{ from }}>
<article
data-slot="message"
data-from={from}
aria-label={props["aria-label"] ?? FROM_LABEL[from]}
{...props}
className={cn(
"group/message flex w-full min-w-0 items-start gap-2.5",
from === "user" ? "flex-row-reverse" : "flex-row",
animateIn &&
"animate-in fade-in-0 slide-in-from-bottom-1 duration-base ease-pop motion-reduce:animate-none",
className,
)}
>
{children}
</article>
</MessageContext.Provider>
</MessageSideContext.Provider>
);
}
export function MessageGroup({
spacing = "default",
className,
...props
}: ComponentPropsWithRef<"div"> & { spacing?: "compact" | "default" }) {
return (
<div
data-slot="message-group"
className={cn("flex w-full flex-col", spacing === "compact" ? "gap-1.5" : "gap-4", className)}
{...props}
/>
);
}
export function MessageAvatar({
placeholder = false,
className,
...props
}: ComponentPropsWithRef<"div"> & { placeholder?: boolean }) {
return (
<div
data-slot="message-avatar"
aria-hidden
className={cn(
"mt-0.5 grid size-7 shrink-0 place-items-center overflow-hidden rounded-full bg-foreground/[0.06] text-meta font-medium text-muted-foreground [&_svg]:size-3.5",
placeholder && "invisible",
className,
)}
{...props}
/>
);
}
export function MessageContent({ className, ...props }: ComponentPropsWithRef<"div">) {
const { from } = useContext(MessageContext);
return (
<div
data-slot="message-content"
className={cn("flex min-w-0 flex-1 flex-col gap-1.5", from === "user" ? "items-end" : "items-start", className)}
{...props}
/>
);
}
export function MessageHeader({ className, ...props }: ComponentPropsWithRef<"div">) {
const { from } = useContext(MessageContext);
return (
<div
data-slot="message-header"
className={cn(
"flex items-center gap-1.5 px-1 text-label leading-none text-muted-foreground",
from === "user" ? "justify-end" : "justify-start",
className,
)}
{...props}
/>
);
}
export function MessageFooter({ className, ...props }: ComponentPropsWithRef<"div">) {
const { from } = useContext(MessageContext);
return (
<div
data-slot="message-footer"
className={cn(
"flex min-h-5 items-center gap-1 px-1 text-label text-muted-foreground",
from === "user" ? "justify-end" : "justify-start",
className,
)}
{...props}
/>
);
}
/** 居中的系统提示条(上下文压缩、续跑等) */
export function MessageMarker({ className, ...props }: ComponentPropsWithRef<"div">) {
return (
<div
data-slot="message-marker"
role="note"
className={cn(
"mx-auto flex w-fit max-w-[88%] items-center gap-1.5 rounded-full border border-border/60 bg-foreground/[0.04] px-2.5 py-1 text-center text-meta text-muted-foreground",
className,
)}
{...props}
/>
);
}
const DOT_DELAY = ["", "[animation-delay:var(--duration-micro)]", "[animation-delay:var(--duration-layout)]"];
export function MessageTyping({
label = "正在回复",
className,
...props
}: ComponentPropsWithRef<"span"> & { label?: string }) {
return (
<span data-slot="message-typing" className={cn("inline-flex h-5 items-center gap-1", className)} {...props}>
<span className="sr-only">{label}</span>
{DOT_DELAY.map((d, i) => (
<span
key={i}
aria-hidden
className={cn("size-1 rounded-full bg-current opacity-60 animate-pulse motion-reduce:animate-none", d)}
/>
))}
</span>
);
}
@@ -0,0 +1,137 @@
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/prompt-input.tsx
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
// 改造:保留自增高(隐藏测量层 + minRows/maxRows)、Enter 发送 / Shift+Enter 换行 / 输入法组字中不发送、
// loading 时发送键换成"停止";去掉模型选择与 MorphPopover 动作菜单,换成 `toolbar` 槽(放模式切换等);
// 外框用 border + focus-within 提亮(不做玻璃叠玻璃),按钮用项目 <Button>。
import { ArrowUp, Square } from "lucide-react";
import {
type FormEvent,
type KeyboardEvent,
type ReactNode,
type TextareaHTMLAttributes,
useCallback,
useLayoutEffect,
useRef,
} from "react";
import { Button } from "~/components/ui/button.tsx";
import { cn } from "~/lib/utils.ts";
export interface PromptInputProps
extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "value" | "onChange" | "onSubmit" | "children"> {
value: string;
onValueChange: (v: string) => void;
onSubmit: (v: string) => void;
/** 运行中:发送键变"停止" */
loading?: boolean;
onStop?: () => void;
/** 提交中(禁用发送) */
pending?: boolean;
minRows?: number;
maxRows?: number;
/** 输入框下方左侧工具条 */
toolbar?: ReactNode;
submitLabel?: string;
stopLabel?: string;
/** 顶部附加内容(如上下文前缀) */
header?: ReactNode;
className?: string;
}
/** 行高 24px(text-sm leading-6),与测量层一致 */
const LINE = 24;
export function PromptInput({
value,
onValueChange,
onSubmit,
loading = false,
onStop,
pending = false,
minRows = 2,
maxRows = 8,
toolbar,
submitLabel = "发送",
stopLabel = "停止",
header,
className,
disabled,
placeholder,
"aria-label": ariaLabel = "输入",
...rest
}: PromptInputProps) {
const ta = useRef<HTMLTextAreaElement>(null);
const measure = useRef<HTMLDivElement>(null);
const resize = useCallback(() => {
const t = ta.current;
const m = measure.current;
if (!t || !m) return;
const h = Math.min(Math.max(m.scrollHeight, minRows * LINE), maxRows * LINE);
t.style.height = `${h}px`;
}, [minRows, maxRows]);
// biome-ignore lint/correctness/useExhaustiveDependencies: 内容变化时重新测量
useLayoutEffect(resize, [value, resize]);
const canSubmit = !!value.trim() && !disabled && !pending;
const submit = (e?: FormEvent) => {
e?.preventDefault();
if (!canSubmit) return;
onSubmit(value.trim());
};
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key !== "Enter" || e.shiftKey || e.nativeEvent.isComposing) return;
e.preventDefault();
submit();
};
return (
<form
onSubmit={submit}
className={cn(
"relative w-full min-w-0 rounded-2xl border border-border/80 p-2 transition-colors duration-base focus-within:border-foreground/30",
disabled && "opacity-60",
className,
)}
>
{header}
<div className="relative">
<div
ref={measure}
aria-hidden
className="pointer-events-none invisible absolute inset-x-0 top-0 px-2 pt-1.5 text-sm leading-6 break-words whitespace-pre-wrap"
>
{`${value}​`}
</div>
<textarea
ref={ta}
value={value}
disabled={disabled}
placeholder={placeholder}
aria-label={ariaLabel}
rows={minRows}
{...rest}
onChange={(e) => onValueChange(e.target.value)}
onKeyDown={onKeyDown}
className="block w-full resize-none overflow-y-auto bg-transparent px-2 pt-1.5 text-sm leading-6 text-foreground outline-none placeholder:text-muted-foreground/70"
/>
</div>
<div className="mt-1 flex min-h-8 flex-wrap items-center gap-1.5">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">{toolbar}</div>
{loading && onStop ? (
<Button type="button" size="sm" variant="destructive" onClick={onStop} aria-label={stopLabel}>
<Square className="size-3.5" />
{stopLabel}
</Button>
) : null}
<Button
type="submit"
size="icon-sm"
disabled={!canSubmit}
aria-label={submitLabel}
title={`${submitLabel}(Enter)`}
>
<ArrowUp />
</Button>
</div>
</form>
);
}
@@ -0,0 +1,124 @@
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/streaming-response.tsx
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
// 改造:上游是"整段替换"模型,这里由调用方把 text_delta 累积成全文再传入;新增流式光标(animate-pulse,
// reduced motion 下静止);去掉点赞/点踩(本项目不收集反馈)与 motion 按压动画;来源列表接 Citations;
// error 态用 destructive 语义色。
import { Check, Copy, RotateCcw } from "lucide-react";
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { cn } from "~/lib/utils.ts";
import { type CitationItem, CitationList } from "./citations.tsx";
export type StreamingResponseStatus = "streaming" | "complete" | "error";
export interface StreamingResponseProps {
children: ReactNode;
status?: StreamingResponseStatus;
/** 复制按钮复制的纯文本 */
copyText?: string;
onRetry?: () => void;
sources?: CitationItem[];
/** 外层已有 aria-live(MessageScroller 的 log)时设 false,避免重复播报 */
announce?: boolean;
showActions?: boolean;
className?: string;
contentClassName?: string;
}
/** 复制成功提示的停留时长(交互停留,非动效) */
const COPIED_HOLD_MS = 1600;
function ActionButton({ label, onClick, children }: { label: string; onClick: () => void; children: ReactNode }) {
return (
<button
type="button"
aria-label={label}
title={label}
onClick={onClick}
className="grid size-7 cursor-pointer place-items-center rounded-md text-muted-foreground outline-none transition-[background-color,color,transform] duration-micro hover:bg-foreground/[0.06] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.95]"
>
{children}
</button>
);
}
/** 流式光标:只在 streaming 时出现 */
export function StreamingCursor({ className }: { className?: string }) {
return (
<span
aria-hidden
data-slot="streaming-cursor"
className={cn(
"ml-0.5 inline-block h-4 w-0.5 translate-y-0.5 rounded-full bg-foreground/70 animate-pulse motion-reduce:animate-none",
className,
)}
/>
);
}
export function StreamingResponse({
children,
status = "streaming",
copyText,
onRetry,
sources = [],
announce = true,
showActions = true,
className,
contentClassName,
}: StreamingResponseProps) {
const [copied, setCopied] = useState(false);
const t = useRef<number | undefined>(undefined);
const streaming = status === "streaming";
useEffect(
() => () => {
if (t.current) window.clearTimeout(t.current);
},
[],
);
const copy = useCallback(async () => {
if (!copyText) return;
try {
await navigator.clipboard?.writeText(copyText);
setCopied(true);
if (t.current) window.clearTimeout(t.current);
t.current = window.setTimeout(() => setCopied(false), COPIED_HOLD_MS);
} catch {
// 剪贴板不可用(非安全上下文)时静默
}
}, [copyText]);
const actions = showActions && !streaming && (copyText || onRetry);
return (
<div data-state={status} aria-busy={streaming} className={cn("w-full min-w-0", className)}>
<div
aria-live={announce ? "polite" : "off"}
className={cn(
"text-sm leading-6 break-words whitespace-pre-wrap text-foreground/90",
status === "error" && "text-destructive",
contentClassName,
)}
>
{children}
{streaming ? <StreamingCursor /> : null}
</div>
{actions ? (
<div className="mt-1 flex items-center gap-0.5 animate-in fade-in-0 duration-base motion-reduce:animate-none">
{copyText ? (
<ActionButton label={copied ? "已复制" : "复制这段回复"} onClick={copy}>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
</ActionButton>
) : null}
{onRetry ? (
<ActionButton label="重试" onClick={onRetry}>
<RotateCcw className="size-3.5" />
</ActionButton>
) : null}
<span className="sr-only" aria-live="polite">
{copied ? "已复制" : ""}
</span>
</div>
) : null}
{sources.length && !streaming ? <CitationList citations={sources} className="mt-2" /> : null}
</div>
);
}
@@ -0,0 +1,184 @@
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/todo-list.tsx
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
// 改造:SVG 状态图标保留几何(虚线圈=待办、弧线=进行中、勾=完成、叉=取消),pathLength 弹簧动画换成
// CSS 过渡/animate-spin(motion-reduce 关闭);完成色 emerald → success,取消 rose → destructive,且都配 sr-only 文字;
// 表头完成数去掉滚字;折叠用 Disclosure;maxHeight 改成 Tailwind 类;新增 `error` 状态(工具失败)与 `detail` 槽。
import { ChevronDown, ListTodo } from "lucide-react";
import { type ReactNode, useEffect, useId, useRef, useState } from "react";
import { cn } from "~/lib/utils.ts";
import { CHEVRON_CLASS, Disclosure } from "./disclosure.tsx";
export type TodoItemStatus = "pending" | "in-progress" | "completed" | "cancelled" | "error";
export interface TodoItem {
id: string;
title: ReactNode;
status?: TodoItemStatus;
detail?: ReactNode;
}
const LABEL: Record<TodoItemStatus, string> = {
pending: "待办",
"in-progress": "进行中",
completed: "已完成",
cancelled: "被拒绝",
error: "失败",
};
function StatusIcon({ status }: { status: TodoItemStatus }) {
return (
<svg
aria-hidden
viewBox="0 0 24 24"
className={cn(
"mx-0.5 size-5 shrink-0 overflow-visible text-muted-foreground",
status === "in-progress" && "text-foreground",
status === "completed" && "text-success",
(status === "cancelled" || status === "error") && "text-destructive",
)}
>
<circle
cx="12"
cy="12"
r="9"
fill="currentColor"
fillOpacity={status === "completed" ? 0.1 : 0}
stroke="currentColor"
strokeWidth="1.5"
strokeDasharray={status === "pending" ? "2 3" : undefined}
className={cn(status === "in-progress" && "opacity-20")}
/>
{status === "in-progress" ? (
<circle
cx="12"
cy="12"
r="9"
pathLength={1}
strokeDasharray="0.3 1"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
className="origin-center animate-spin motion-reduce:animate-none"
/>
) : null}
{status === "completed" ? (
<path
d="M7.5 12.25 10.5 15.25 16.75 8.75"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
) : null}
{status === "cancelled" || status === "error" ? (
<path
d="M8.5 8.5 15.5 15.5M15.5 8.5 8.5 15.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
) : null}
</svg>
);
}
export interface TodoListProps {
items: TodoItem[];
title?: ReactNode;
defaultOpen?: boolean;
collapseOnComplete?: boolean;
/** 列表最大高度(Tailwind 类) */
maxHeightClassName?: string;
emptyText?: ReactNode;
className?: string;
}
export function TodoList({
items,
title = "步骤",
defaultOpen = true,
collapseOnComplete = false,
maxHeightClassName = "max-h-64",
emptyText = "还没有步骤",
className,
}: TodoListProps) {
const id = useId();
const [open, setOpen] = useState(defaultOpen);
const done = items.filter((i) => i.status === "completed").length;
const settled =
items.length > 0 && items.every((i) => i.status && i.status !== "pending" && i.status !== "in-progress");
const prevSettled = useRef(settled);
useEffect(() => {
if (!prevSettled.current && settled && collapseOnComplete) setOpen(false);
prevSettled.current = settled;
}, [settled, collapseOnComplete]);
return (
<section
aria-label="步骤清单"
className={cn("w-full min-w-0 overflow-hidden rounded-xl border border-border/70", className)}
>
<button
id={`${id}-t`}
type="button"
aria-expanded={open}
aria-controls={`${id}-c`}
onClick={() => setOpen(!open)}
className="flex h-10 w-full cursor-pointer items-center gap-2.5 rounded-xl px-3 text-left outline-none transition-colors duration-micro hover:bg-foreground/[0.03] focus-visible:ring-2 focus-visible:ring-ring"
>
<ListTodo aria-hidden className={cn("size-4 text-muted-foreground", settled && "text-success")} />
<h3 className="min-w-0 flex-1 truncate text-caption font-medium text-foreground/90">{title}</h3>
<span
className={cn("shrink-0 text-meta font-medium text-muted-foreground tabular-nums", settled && "text-success")}
>
<span className="sr-only">
已完成 {done} / 共 {items.length}
</span>
<span aria-hidden>
{done}/{items.length}
</span>
</span>
<ChevronDown aria-hidden className={cn("size-3.5", CHEVRON_CLASS, open && "rotate-180")} />
</button>
<Disclosure id={`${id}-c`} role="region" aria-labelledby={`${id}-t`} open={open}>
<div className={cn("overflow-y-auto px-2 pb-2", maxHeightClassName)}>
{items.length ? (
<ol className="space-y-0">
{items.map((item) => {
const s = item.status ?? "pending";
return (
<li
key={item.id}
className="flex min-h-9 min-w-0 items-center gap-2.5 rounded-lg px-1.5 py-1 animate-in fade-in-0 slide-in-from-bottom-1 duration-base motion-reduce:animate-none"
>
<StatusIcon status={s} />
<span className="sr-only">{LABEL[s]}:</span>
<span
className={cn(
"min-w-0 flex-1 truncate text-caption leading-5",
s === "pending" && "text-muted-foreground",
s === "in-progress" && "text-foreground",
s === "completed" && "text-muted-foreground line-through decoration-foreground/30",
(s === "cancelled" || s === "error") && "text-destructive",
)}
>
{item.title}
</span>
{item.detail ? (
<span className="shrink-0 text-meta text-muted-foreground">{item.detail}</span>
) : null}
</li>
);
})}
</ol>
) : (
<p className="px-1.5 py-2 text-caption text-muted-foreground">{emptyText}</p>
)}
</div>
</Disclosure>
</section>
);
}
@@ -0,0 +1,184 @@
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/tool-approval.tsx
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
// 改造:状态徽章 amber/blue/emerald/rose → warning/中性/success/destructive;去掉 "Always allow"(本项目的持续授权
// 只在设置页按策略版本发放,不在对话里一键放行);新增 `rejected` 醒目态:服务端拒绝了一次工具调用时,
// 整卡换 destructive 材质、role=alert、给出拒绝原因与授权说明;新增 `badges` 槽(scope/风险级别);参数区用 Disclosure。
import { Check, ChevronDown, CircleAlert, LoaderCircle, ShieldAlert, ShieldCheck, X } from "lucide-react";
import { type ReactNode, useId, useState } from "react";
import { Button } from "~/components/ui/button.tsx";
import { cn } from "~/lib/utils.ts";
import { CHEVRON_CLASS, Disclosure } from "./disclosure.tsx";
export type ToolApprovalStatus =
| "pending"
| "approving"
| "approved"
| "denied"
| "rejected"
| "running"
| "complete"
| "error";
export interface ToolApprovalParameter {
id: string;
label: ReactNode;
value: ReactNode;
}
const COPY: Record<ToolApprovalStatus, string> = {
pending: "待授权",
approving: "提交中",
approved: "已批准",
denied: "已拒绝",
rejected: "服务端拒绝",
running: "执行中",
complete: "已完成",
error: "失败",
};
const BADGE: Record<ToolApprovalStatus, string> = {
pending: "border-warning/30 bg-warning/10 text-warning",
approving: "border-border bg-foreground/[0.04] text-muted-foreground",
running: "border-border bg-foreground/[0.04] text-muted-foreground",
approved: "border-success/30 bg-success/10 text-success",
complete: "border-success/30 bg-success/10 text-success",
denied: "border-destructive/30 bg-destructive/10 text-destructive",
rejected: "border-destructive/40 bg-destructive/15 text-destructive",
error: "border-destructive/30 bg-destructive/10 text-destructive",
};
export interface ToolApprovalProps {
tool: ReactNode;
title?: ReactNode;
description?: ReactNode;
parameters?: ToolApprovalParameter[];
status?: ToolApprovalStatus;
badges?: ReactNode;
/** rejected 态:拒绝原因(服务端给出) */
reason?: ReactNode;
/** rejected 态:为什么会被拒绝、怎样才能放行 */
explanation?: ReactNode;
defaultOpen?: boolean;
onApprove?: () => void;
onDeny?: () => void;
/** 为什么按钮不可用(如非管理员会话) */
actionsDisabledReason?: string;
className?: string;
}
export function ToolApproval({
tool,
title = "允许执行这个工具?",
description,
parameters = [],
status = "pending",
badges,
reason,
explanation,
defaultOpen = false,
onApprove,
onDeny,
actionsDisabledReason,
className,
}: ToolApprovalProps) {
const id = useId();
const [open, setOpen] = useState(defaultOpen);
const busy = status === "approving" || status === "running";
const rejected = status === "rejected";
return (
<div
data-state={status}
aria-busy={busy}
role={rejected ? "alert" : undefined}
className={cn(
"w-full min-w-0 overflow-hidden rounded-xl border text-sm",
rejected ? "border-destructive/50 bg-destructive/10" : "border-border/70 bg-foreground/[0.02]",
className,
)}
>
<div className="flex items-start gap-3 p-3">
<span
aria-hidden
className={cn(
"mt-0.5 grid size-8 shrink-0 place-items-center rounded-lg border",
rejected
? "border-destructive/40 bg-destructive/15 text-destructive"
: "border-border/60 text-muted-foreground",
status === "error" && "text-destructive",
)}
>
{busy ? (
<LoaderCircle className="size-4 animate-spin motion-reduce:animate-none" />
) : rejected ? (
<ShieldAlert className="size-4" />
) : status === "error" ? (
<CircleAlert className="size-4" />
) : status === "denied" ? (
<X className="size-4" />
) : status === "approved" || status === "complete" ? (
<Check className="size-4" />
) : (
<ShieldCheck className="size-4" />
)}
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-start justify-between gap-x-3 gap-y-1">
<div className="min-w-0">
<div className={cn("font-medium", rejected ? "text-destructive" : "text-foreground")}>{title}</div>
<div className="mt-0.5 font-mono text-meta break-all text-muted-foreground">{tool}</div>
</div>
<span className={cn("shrink-0 rounded-full border px-2 py-0.5 text-label font-medium", BADGE[status])}>
{COPY[status]}
</span>
</div>
{badges ? <div className="mt-1.5 flex flex-wrap items-center gap-1">{badges}</div> : null}
{rejected && reason ? (
<p className="mt-2 text-caption font-medium break-words text-destructive">原因:{reason}</p>
) : null}
{description ? <p className="mt-1.5 text-caption leading-5 text-muted-foreground">{description}</p> : null}
{rejected && explanation ? (
<p className="mt-1.5 text-meta leading-5 text-muted-foreground">{explanation}</p>
) : null}
{parameters.length ? (
<button
type="button"
aria-expanded={open}
aria-controls={id}
onClick={() => setOpen(!open)}
className="mt-2 inline-flex cursor-pointer items-center gap-1 rounded-md text-meta font-medium text-muted-foreground outline-none transition-colors duration-micro hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
{open ? "收起详情" : "查看详情"}
<ChevronDown aria-hidden className={cn("size-3.5", CHEVRON_CLASS, open && "rotate-180")} />
</button>
) : null}
</div>
</div>
{parameters.length ? (
<Disclosure id={id} open={open}>
<dl className="mx-3 mb-3 grid gap-1.5 rounded-lg border border-border/50 p-2.5">
{parameters.map((p) => (
<div key={p.id} className="grid grid-cols-[minmax(0,7rem)_minmax(0,1fr)] items-start gap-3 text-meta">
<dt className="text-muted-foreground">{p.label}</dt>
<dd className="min-w-0 font-mono break-all text-foreground/85">{p.value}</dd>
</div>
))}
</dl>
</Disclosure>
) : null}
{status === "pending" && (onApprove || onDeny) ? (
<div className="flex flex-wrap items-center gap-2 border-t border-border/60 px-3 py-2.5">
<Button size="sm" onClick={onApprove} disabled={!!actionsDisabledReason || !onApprove}>
批准
</Button>
<Button size="sm" variant="ghost" onClick={onDeny} disabled={!!actionsDisabledReason || !onDeny}>
拒绝
</Button>
{actionsDisabledReason ? (
<span className="text-meta text-muted-foreground">{actionsDisabledReason}</span>
) : null}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,135 @@
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/tool-result.tsx
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
// 改造:状态色 blue/emerald/rose → 中性/success/destructive(并配文字);ActionSwapRollText 滚字去掉;
// 折叠用 Disclosure;新增 `badges` 槽(scope/风险/幂等等审计徽标)与 `summary`(收起时的一行结果);
// 默认不在完成后自动展开历史卡片(回放时 defaultOpen=false),运行中的卡片展开、完成后按 collapseOnComplete 收起。
import { Ban, Braces, ChevronDown, CircleCheck, CircleX, LoaderCircle, SquareTerminal, Wrench } from "lucide-react";
import { type ReactNode, useCallback, useEffect, useId, useRef, useState } from "react";
import { cn } from "~/lib/utils.ts";
import { CodeBlock, prettyMaybeJson } from "./code-block.tsx";
import { CHEVRON_CLASS, Disclosure } from "./disclosure.tsx";
export type ToolResultStatus = "running" | "success" | "error" | "cancelled";
export type ToolResultKind = "terminal" | "request" | "custom";
const STATUS: Record<ToolResultStatus, { label: string; cls: string }> = {
running: { label: "执行中", cls: "text-muted-foreground" },
success: { label: "成功", cls: "text-success" },
error: { label: "失败", cls: "text-destructive" },
cancelled: { label: "已取消", cls: "text-muted-foreground" },
};
function StatusIcon({ status }: { status: ToolResultStatus }) {
if (status === "running") return <LoaderCircle className="size-3 animate-spin motion-reduce:animate-none" />;
if (status === "success") return <CircleCheck className="size-3" />;
if (status === "error") return <CircleX className="size-3" />;
return <Ban className="size-3" />;
}
function KindIcon({ kind }: { kind: ToolResultKind }) {
if (kind === "terminal") return <SquareTerminal className="size-4" />;
if (kind === "request") return <Braces className="size-4" />;
return <Wrench className="size-4" />;
}
export interface ToolResultProps {
/** 能力/工具 ID(等宽显示) */
tool: ReactNode;
title: ReactNode;
children?: ReactNode;
status?: ToolResultStatus;
kind?: ToolResultKind;
/** 耗时等 */
meta?: ReactNode;
/** 标题行下方的徽标 */
badges?: ReactNode;
/** 收起时显示的一行摘要 */
summary?: ReactNode;
icon?: ReactNode;
defaultOpen?: boolean;
collapseOnComplete?: boolean;
className?: string;
}
export function ToolResult({
tool,
title,
children,
status = "running",
kind = "custom",
meta,
badges,
summary,
icon,
defaultOpen = status === "running",
collapseOnComplete = true,
className,
}: ToolResultProps) {
const id = useId();
const [open, setOpenState] = useState(defaultOpen);
const prev = useRef(status);
const setOpen = useCallback((v: boolean) => setOpenState(v), []);
useEffect(() => {
if (prev.current !== "running" && status === "running") setOpen(true);
if (prev.current === "running" && status !== "running" && collapseOnComplete) setOpen(false);
prev.current = status;
}, [collapseOnComplete, setOpen, status]);
const st = STATUS[status];
return (
<div
data-state={status}
aria-busy={status === "running"}
className={cn("w-full min-w-0 rounded-xl border border-border/70 text-sm", className)}
>
<button
id={`${id}-t`}
type="button"
aria-expanded={open}
aria-controls={`${id}-c`}
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"
>
<span aria-hidden className="grid size-4 shrink-0 place-items-center text-muted-foreground">
{icon ?? <KindIcon kind={kind} />}
</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="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)}>
<StatusIcon status={status} />
{st.label}
</span>
<ChevronDown aria-hidden className={cn("size-3.5", CHEVRON_CLASS, open && "rotate-180")} />
</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}>
<div className="space-y-2 border-t border-border/60 px-3 py-2.5">{children}</div>
</Disclosure>
</div>
);
}
/** 工具输入/输出块:JSON 自动缩进,长内容块内滚动 */
export function ToolResultOutput({
label,
children,
tone,
}: {
label: ReactNode;
children: string;
tone?: "destructive";
}) {
const { text, language } = prettyMaybeJson(children);
return (
<div className="min-w-0">
<p className="mb-1 text-meta text-muted-foreground">{label}</p>
<CodeBlock code={text} language={language} bare tone={tone === "destructive" ? "destructive" : "default"} />
</div>
);
}
@@ -0,0 +1,21 @@
// Adapted from agentui (https://github.com/ashish200729/agentui) — replaces `useReducedMotion` from motion/react.
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
// 改造:不引 motion 库,直接读 prefers-reduced-motion;样式层的减弱动效由 motion-reduce: 变体负责,这里只给 JS 滚动用。
import { useSyncExternalStore } from "react";
const QUERY = "(prefers-reduced-motion: reduce)";
function subscribe(cb: () => void) {
if (typeof window === "undefined" || !window.matchMedia) return () => {};
const mq = window.matchMedia(QUERY);
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
}
export function useReducedMotion(): boolean {
return useSyncExternalStore(
subscribe,
() => (typeof window !== "undefined" && window.matchMedia ? window.matchMedia(QUERY).matches : false),
() => false,
);
}
+18 -3
View File
@@ -1,7 +1,10 @@
import type { AgentRunStatus } from "@umt/contracts";
import { ArrowLeft } from "lucide-react";
import { Link, useParams } from "react-router";
import { useState } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { agentMockEnabled, useAgentRun } from "~/api/agent.ts";
import { AgentRunPanel } from "~/components/agent/agent-run-panel.tsx";
import { AgentSessionList } from "~/components/agent/agent-session-list.tsx";
import { PageHeader } from "~/components/console/page-header.tsx";
import { Button } from "~/components/ui/button.tsx";
@@ -9,6 +12,8 @@ export default function AgentRunPage() {
const { runId = "" } = useParams();
const run = useAgentRun(runId);
const mock = agentMockEnabled();
const navigate = useNavigate();
const [status, setStatus] = useState<"" | AgentRunStatus>("");
return (
<>
<PageHeader
@@ -23,8 +28,18 @@ export default function AgentRunPage() {
</Button>
}
/>
<div className="pg-slab rounded-xl p-4 md:p-5">
<AgentRunPanel runId={runId} />
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,17rem)_minmax(0,1fr)]">
<aside className="pg-slab hidden rounded-xl p-3 lg:block lg:h-[calc(100dvh-13rem)] lg:overflow-hidden">
<AgentSessionList activeId={runId} status={status} onStatusChange={setStatus} className="h-full" />
</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)]">
<AgentRunPanel
key={runId}
runId={runId}
className="flex-1"
onRunCreated={(id) => navigate(`/console/agent/${id}${mock ? "?mock=1" : ""}`)}
/>
</div>
</div>
</>
);
+32 -74
View File
@@ -1,52 +1,20 @@
import type { AgentRunStatus } from "@umt/contracts";
import { Bot, FlaskConical } from "lucide-react";
import { FlaskConical, ShieldCheck } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { type AgentRunView, agentMockEnabled, RUN_STATUS_LABEL, useAgentRuns, useCreateAgentRun } from "~/api/agent.ts";
import { RunStatusBadge } from "~/components/agent/agent-conversation.tsx";
import { agentMockEnabled, useCreateAgentRun } from "~/api/agent.ts";
import { GoalComposer } from "~/components/agent/agent-run-panel.tsx";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { AgentSessionList } from "~/components/agent/agent-session-list.tsx";
import { PageHeader } from "~/components/console/page-header.tsx";
import { Button } from "~/components/ui/button.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { errorMessage } from "~/lib/api.ts";
const COLUMNS: DataColumn<AgentRunView>[] = [
{
key: "goal",
header: "目标",
cell: (r) => (
<div className="min-w-0 max-w-md">
<p className="line-clamp-2 break-words text-foreground">{r.goal}</p>
{r.summary ? <p className="mt-0.5 line-clamp-1 text-meta text-muted-foreground">{r.summary}</p> : null}
</div>
),
},
{ key: "status", header: "状态", cell: (r) => <RunStatusBadge status={r.status} /> },
{
key: "mode",
header: "模式",
hideBelow: "md",
cell: (r) => ((r.mode ?? r.scope.mode) === "plan_only" ? "只要方案" : "执行"),
},
{ key: "tools", header: "工具调用", hideBelow: "md", align: "right", cell: (r) => r.toolCallCount },
{
key: "at",
header: "开始",
hideBelow: "sm",
cell: (r) => <span className="whitespace-nowrap text-muted-foreground">{relTime(r.createdAt)}</span>,
},
];
export default function AgentPage() {
const [status, setStatus] = useState("");
const runs = useAgentRuns((status || undefined) as AgentRunStatus | undefined);
const [status, setStatus] = useState<"" | AgentRunStatus>("");
const create = useCreateAgentRun();
const navigate = useNavigate();
const mock = agentMockEnabled();
const rows = runs.data?.pages.flatMap((p) => p.items);
return (
<>
@@ -63,45 +31,35 @@ export default function AgentPage() {
</Button>
</div>
) : null}
<div className="pg-slab mb-6 rounded-xl p-4">
<GoalComposer
pending={create.isPending}
onSubmit={(goal, mode) =>
create.mutate(
{ goal, mode },
{
onSuccess: (run) => navigate(`/console/agent/${run.id}${mock ? "?mock=1" : ""}`),
onError: (e) => toast.error(errorMessage(e)),
},
)
}
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]">
<div className="pg-slab order-2 rounded-xl p-3 lg:order-1 lg:max-h-[calc(100dvh-12rem)] lg:overflow-hidden">
<AgentSessionList status={status} onStatusChange={setStatus} className="h-full" />
</div>
<div className="order-1 space-y-4 lg:order-2">
<div className="pg-slab rounded-xl p-4">
<h2 className="mb-3 text-body font-medium text-foreground">新会话</h2>
<GoalComposer
pending={create.isPending}
onSubmit={(goal, mode) =>
create.mutate(
{ goal, mode },
{
onSuccess: (run) => navigate(`/console/agent/${run.id}${mock ? "?mock=1" : ""}`),
onError: (e) => toast.error(errorMessage(e)),
},
)
}
/>
</div>
<div className="flex gap-3 rounded-xl border border-border/70 p-4 text-caption text-muted-foreground">
<ShieldCheck aria-hidden className="mt-0.5 size-4 shrink-0" />
<div className="space-y-1">
<p>每一次工具调用都经服务端授权校验:不在白名单或没有授权覆盖的调用会被拒绝,并在对话里醒目标出。</p>
<p>运行结束后,汇报分两栏:模型的自述只作参考,"系统核实的结果"取自下载计划、下载任务与整理记录。</p>
</div>
</div>
</div>
</div>
<FilterBar onReset={status ? () => setStatus("") : undefined}>
<FilterSelect
label="状态"
value={status}
onChange={setStatus}
options={Object.entries(RUN_STATUS_LABEL).map(([value, label]) => ({ value, label }))}
/>
</FilterBar>
<DataTable
columns={COLUMNS}
rows={rows}
rowKey={(r) => r.id}
loading={runs.isPending}
error={runs.error}
onRetry={() => void runs.refetch()}
empty={{
icon: Bot,
title: "还没有会话",
description: "例如:“把某部电影的 1080p 收进来”,或在任意页面右上角打开 Agent 侧栏。",
}}
onRowClick={(r) => navigate(`/console/agent/${r.id}${mock ? "?mock=1" : ""}`)}
hasMore={runs.hasNextPage}
onLoadMore={() => void runs.fetchNextPage()}
loadingMore={runs.isFetchingNextPage}
/>
</>
);
}
+29
View File
@@ -142,3 +142,32 @@
8. `AgentActivity`/`ReasoningText` —— 只借鉴时间线骨架与"Thought for Ns"摘要交互,不直接复制状态机(缺"保留完整推理文本可展开"这一我们需要的能力)。
9. `UsageDashboard` —— 仅作历史用量图表参考;预算条(`BudgetBar`/`Meter`)在此库能力范围之外,需独立新写。
10. `AISidebar`/`ChatApp` —— 不支持会话分支、无搜索,只作整体布局排版参考,不建议直接采用其资源树数据模型。
## 6. 已采用清单(W4-F,2026-09-27)
源码取自 `ashish200729/agentui@b2b752b`(MIT),放在 `apps/web/app/components/agentui/`,每个文件头保留版权与来源,
许可证全文见同目录 `LICENSE-agentui.md`。统一改造:去掉 `motion`/`shiki`/Next.js 依赖(入场用 tw-animate 的
`animate-in`,折叠用 CSS grid-rows 过渡,全部带 `motion-reduce:` 关闭);调色板色 → `success`/`warning`/`destructive`
并配文字;字号/圆角/时长/缓动换项目 token;文案中文化。
| 上游组件 | 本项目文件 | 用在哪 | 主要改造 |
|---|---|---|---|
| `Message`(+`message-context`) | `message.tsx` | 会话流的用户/Agent/系统行 | 新增 `from="system"`;`MessageMarker` 作压缩提示;`MessageTyping` 用 animate-pulse |
| `MessageBubble` | `message-bubble.tsx` | 用户消息、失败/超预算/中止提示 | 变体换语义底色,新增 `warning`;去 layout 动画 |
| `MessageScroller` | `message-scroller.tsx` | 会话页与侧栏抽屉的滚动区 | 保留跟随阈值状态机;只有"往上翻"才离开跟随;新增"回到底部"按钮;首屏回放直接跳底;log 只播报新增行 |
| `StreamingResponse` | `streaming-response.tsx` | 流式文本、"模型说的"栏 | 新增流式光标;去点赞/点踩;来源接 `CitationList` |
| `ToolResult` | `tool-result.tsx` | 工具调用卡片 | 新增 `badges`(类别/scope/风险/幂等/恢复补记)与收起摘要;输入输出用 `CodeBlock` |
| `ToolApproval` | `tool-approval.tsx` | **拒绝态**(白名单拦截 `tool_call_rejected` 与授权校验失败的 `unauthorized`) | 新增 `rejected` 醒目态:destructive 材质 + `role=alert` + 原因 + 授权说明;去掉 "Always allow" |
| `TodoList` | `todo-list.tsx` | 只要方案模式的"调查步骤" | SVG 状态图标保留,弹簧动画换 CSS;新增 `error` 态 |
| `ApprovalCard` | `approval-card.tsx` | Approval 对象(计划、payload hash、版本、到期、风险) | 状态集对齐 `pending/approved/rejected/revoked/expired/consumed`(+`submitting`);去掉问答模式;非管理员会话按钮禁用并说明 |
| `CodeBlock`(+`agent-code`) | `code-block.tsx` | 工具输入/输出 | 不做语法高亮;JSON 自动缩进 |
| `FileDiff` | `file-diff.tsx` | 系统核实栏的"入库目标" | +/− 符号保留,底色换 success/destructive 洗层 |
| `Citations` | `citations.tsx` | 系统核实栏的下载任务/整理记录链接 | 去 favicon;站内链接走 `Link` |
| `PromptInput` | `prompt-input.tsx` | 新目标与追加消息输入框 | 保留自增高、Enter 发送/Shift+Enter 换行/组字不发送、运行中"停止";去模型选择与动作菜单,换 `toolbar` 槽 |
| `AgentDisclosure` | `disclosure.tsx` | 所有折叠区 | motion → CSS grid-rows;收起时 `inert` |
未采用:`AgentActivity`/`ReasoningText`(思考块自写,保留完整推理文本可展开)、`UsageDashboard`(预算条自写,带超限态)、
`AISidebar`/`ChatApp`(会话列表自写:状态筛选 + 目标/状态/模式/时间/用量)、`ImageGeneration`。
已知限制:幂等键来自审计事件(`/audit-events` 按 `payload.agentRunId` 过滤,最多翻 3 页、按同能力先后与工具调用配对),
较早的运行可能取不到;审批卡片的计划来自工具调用输入/输出里能解析到的 `planId`(摘要被截断时可能漏)与服务端汇报。