Files
UHD_Media_Tool/apps/web/app/components/agentui/streaming-response.tsx
T
rootandClaude Opus 5.5 8352e5e881 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]>
2026-09-27 23:08:17 +08:00

125 lines
4.3 KiB
TypeScript

// 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>
);
}