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:
1 parent
29cd10b27e
commit
8352e5e881
26 files changed
+3390
-585
No files matched your search
+111
-1
@@ -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
@@ -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: "排查这个任务" });
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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`(摘要被截断时可能漏)与服务端汇报。
|
||||
Reference in new issue
Block a user