- 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]>
125 lines
4.3 KiB
TypeScript
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>
|
|
);
|
|
}
|