Files
UHD_Media_Tool/apps/web/app/components/agentui/streaming-response.tsx
T
rootandClaude Opus 5.5 31a54186c7 copy: second-round copy review across console, settings, agent and server messages
- Trim descriptions, empty states, confirm buttons and toasts to facts
- Unify terms per glossary: 待处理/待识别/整理记录/运行记录/凭证/子会话/Agent 上游
- Subscription-login termsNote states facts only (borrowed client identity, ToS/ban risk)
- Shorter, concrete goal templates for built-in Agent checks
- Human-readable nouns instead of table names in instance CRUD errors
- Audit labels for pi_upstream / schedule / automation_grant actions
- Glossary: add 上游、订阅登录、凭证、定时任务、巡检、运行记录、子会话、停止、持续授权

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 13:29:42 +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>
);
}