Files
UHD_Media_Tool/apps/web/app/components/agentui/message.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

156 lines
5.1 KiB
TypeScript

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