Files
wehub-resource-sync e30e75b5d4
Changesets / Create Version PR (push) Has been cancelled
Deploy Shadcn Registry / Deploy Production (push) Has been cancelled
Template Metrics / LOC + Bundle Size (push) Has been cancelled
Code Quality / Oxlint + Oxfmt (push) Has been cancelled
Code Quality / Template Sync (push) Has been cancelled
Code Quality / Build Changed Packages (push) Has been cancelled
Code Quality / Test Changed Packages (push) Has been cancelled
Deploy Expo Example / Deploy Production (push) Has been cancelled
Deploy Ink Example / Deploy Production (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.12) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:40:13 +08:00

373 lines
16 KiB
TypeScript

"use client";
import {
ActionBarPrimitive,
AuiIf,
AttachmentPrimitive,
ComposerPrimitive,
MessagePrimitive,
ThreadPrimitive,
useAuiState,
useMessageTiming,
} from "@assistant-ui/react";
import {
ArrowUpIcon,
CheckIcon,
ChevronDownIcon,
CopyIcon,
Mic,
Moon,
Paperclip,
PencilIcon,
RefreshCwIcon,
Square,
ThumbsDown,
ThumbsUp,
XIcon,
Zap,
} from "lucide-react";
import { useEffect, useState, type FC } from "react";
import { useShallow } from "zustand/shallow";
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import { GrokIcon } from "@/components/icons/grok";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export const Grok: FC = () => {
return (
<ThreadPrimitive.Root className="flex h-full flex-col items-stretch bg-[#fdfdfd] px-4 dark:bg-[#141414]">
<AuiIf condition={(s) => s.thread.isEmpty}>
<div className="flex h-full flex-col items-center justify-center">
<GrokIcon className="mb-6 h-10 text-[#0d0d0d] dark:text-white" />
<Composer />
</div>
</AuiIf>
<AuiIf condition={(s) => s.thread.isEmpty === false}>
<ThreadPrimitive.Viewport className="flex grow flex-col overflow-y-scroll pt-16">
<ThreadPrimitive.Messages>
{() => <ChatMessage />}
</ThreadPrimitive.Messages>
</ThreadPrimitive.Viewport>
<Composer />
<p className="mx-auto w-full max-w-3xl pb-2 text-center text-xs text-[#9a9a9a]">
Grok can make mistakes. Verify important information.
</p>
</AuiIf>
</ThreadPrimitive.Root>
);
};
const Composer: FC = () => {
const isEmpty = useAuiState((s) => s.composer.isEmpty);
const isRunning = useAuiState((s) => s.thread.isRunning);
return (
<ComposerPrimitive.Root
className="group/composer mx-auto mb-3 w-full max-w-3xl"
data-empty={isEmpty}
data-running={isRunning}
>
<div className="overflow-hidden rounded-4xl bg-[#f8f8f8] shadow-xs ring-1 ring-[#e5e5e5] transition-shadow ring-inset focus-within:ring-[#d0d0d0] dark:bg-[#212121] dark:ring-[#2a2a2a] dark:focus-within:ring-[#3a3a3a]">
<AuiIf condition={(s) => s.composer.attachments.length > 0}>
<div className="flex flex-row flex-wrap gap-2 px-4 pt-3">
<ComposerPrimitive.Attachments>
{() => <GrokAttachment />}
</ComposerPrimitive.Attachments>
</div>
</AuiIf>
<div className="flex items-end gap-1 p-2">
<ComposerPrimitive.AddAttachment className="mb-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-[#0d0d0d] transition-colors hover:bg-[#f0f0f0] dark:text-white dark:hover:bg-[#2a2a2a]">
<Paperclip width={18} height={18} />
</ComposerPrimitive.AddAttachment>
<ComposerPrimitive.Input
placeholder="What do you want to know?"
minRows={1}
className="my-2 h-6 max-h-100 min-w-0 flex-1 resize-none bg-transparent text-base leading-6 text-[#0d0d0d] outline-none placeholder:text-[#9a9a9a] dark:text-white dark:placeholder:text-[#6b6b6b]"
/>
<GrokModelPicker />
<div className="relative mb-0.5 h-9 w-9 shrink-0 rounded-full bg-[#0d0d0d] text-white dark:bg-white dark:text-[#0d0d0d]">
<button
type="button"
className="absolute inset-0 flex items-center justify-center transition-all duration-300 ease-out group-data-[empty=false]/composer:scale-0 group-data-[empty=false]/composer:opacity-0 group-data-[running=true]/composer:scale-0 group-data-[running=true]/composer:opacity-0"
aria-label="Voice mode"
>
<Mic width={18} height={18} />
</button>
<ComposerPrimitive.Send className="absolute inset-0 flex items-center justify-center transition-all duration-300 ease-out group-data-[empty=true]/composer:scale-0 group-data-[empty=true]/composer:opacity-0 group-data-[running=true]/composer:scale-0 group-data-[running=true]/composer:opacity-0">
<ArrowUpIcon width={18} height={18} />
</ComposerPrimitive.Send>
<ComposerPrimitive.Cancel className="absolute inset-0 flex items-center justify-center transition-all duration-300 ease-out group-data-[running=false]/composer:scale-0 group-data-[running=false]/composer:opacity-0">
<Square width={14} height={14} fill="currentColor" />
</ComposerPrimitive.Cancel>
</div>
</div>
</div>
</ComposerPrimitive.Root>
);
};
const GROK_MODELS = [
{
id: "grok-4.1-fast",
name: "Fast",
description: "Default. Quick responses",
Icon: Zap,
},
{
id: "grok-4.1",
name: "Grok 4.1",
description: "Standard reasoning",
Icon: Moon,
},
{
id: "grok-4.1-think",
name: "Think",
description: "Multi-step reasoning",
Icon: Moon,
},
];
const GrokModelPicker: FC = () => {
const [model, setModel] = useState(GROK_MODELS[0]!.id);
const current = GROK_MODELS.find((m) => m.id === model) ?? GROK_MODELS[0]!;
const CurrentIcon = current.Icon;
return (
<DropdownMenu>
<DropdownMenuTrigger className="mb-0.5 flex h-9 shrink-0 items-center gap-2 rounded-full px-2.5 text-[#0d0d0d] hover:bg-[#f0f0f0] dark:text-white dark:hover:bg-[#2a2a2a]">
<CurrentIcon width={18} height={18} className="shrink-0" />
<div className="flex items-center gap-1 overflow-hidden transition-[max-width,opacity] duration-300 group-data-[empty=false]/composer:max-w-0 group-data-[empty=false]/composer:opacity-0 group-data-[empty=true]/composer:max-w-32 group-data-[empty=true]/composer:opacity-100">
<span className="text-sm font-semibold whitespace-nowrap">
{current.name}
</span>
<ChevronDownIcon width={16} height={16} className="shrink-0" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-60">
{GROK_MODELS.map(({ id, name, description, Icon }) => (
<DropdownMenuItem
key={id}
onClick={() => setModel(id)}
className="flex items-start gap-3"
>
<span className="mt-0.5 flex size-4 items-center justify-center text-[#0d0d0d] dark:text-white">
{id === model ? <CheckIcon /> : <Icon width={14} height={14} />}
</span>
<span className="flex flex-1 flex-col">
<span className="text-foreground text-sm">{name}</span>
<span className="text-muted-foreground text-xs">
{description}
</span>
</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem className="text-muted-foreground text-sm">
Subscribe to SuperGrok
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
const ChatMessage: FC = () => {
return (
<MessagePrimitive.Root className="group/message relative mx-auto mb-2 flex w-full max-w-3xl flex-col pb-0.5">
<AuiIf condition={(s) => s.message.role === "user"}>
<div className="flex flex-col items-end">
<div className="relative max-w-[90%] rounded-3xl rounded-br-lg border border-[#e5e5e5] bg-[#f0f0f0] px-4 py-3 text-[#0d0d0d] dark:border-[#2a2a2a] dark:bg-[#1a1a1a] dark:text-white">
<div className="prose prose-sm dark:prose-invert prose-p:my-0 wrap-break-word">
<MessagePrimitive.Parts>
{({ part }) => {
if (part.type === "text") return <MarkdownText />;
return null;
}}
</MessagePrimitive.Parts>
</div>
</div>
<div className="mt-1 flex h-8 items-center justify-end gap-0.5 opacity-0 transition-opacity group-focus-within/message:opacity-100 group-hover/message:opacity-100">
<ActionBarPrimitive.Root className="flex items-center gap-0.5">
<ActionBarPrimitive.Edit className="flex h-8 w-8 items-center justify-center rounded-full text-[#6b6b6b] transition-colors hover:bg-[#e5e5e5] hover:text-[#0d0d0d] dark:text-[#9a9a9a] dark:hover:bg-[#2a2a2a] dark:hover:text-white">
<PencilIcon width={16} height={16} />
</ActionBarPrimitive.Edit>
<ActionBarPrimitive.Copy className="flex h-8 w-8 items-center justify-center rounded-full text-[#6b6b6b] transition-colors hover:bg-[#e5e5e5] hover:text-[#0d0d0d] dark:text-[#9a9a9a] dark:hover:bg-[#2a2a2a] dark:hover:text-white">
<CopyIcon width={16} height={16} />
</ActionBarPrimitive.Copy>
</ActionBarPrimitive.Root>
</div>
</div>
</AuiIf>
<AuiIf condition={(s) => s.message.role === "assistant"}>
<div className="flex flex-col items-start">
<div className="w-full max-w-none">
<div className="prose prose-sm dark:prose-invert prose-li:my-1 prose-ol:my-1 prose-p:my-2 prose-ul:my-1 wrap-break-word text-[#0d0d0d] dark:text-[#e5e5e5]">
<MessagePrimitive.Parts>
{({ part }) => {
if (part.type === "text") return <MarkdownText />;
return null;
}}
</MessagePrimitive.Parts>
</div>
</div>
<div className="mt-1 flex h-8 w-full items-center justify-start gap-0.5 opacity-0 transition-opacity group-focus-within/message:opacity-100 group-hover/message:opacity-100">
<ActionBarPrimitive.Root className="-ml-2 flex items-center gap-0.5">
<ActionBarPrimitive.Reload className="flex h-8 w-8 items-center justify-center rounded-full text-[#6b6b6b] transition-colors hover:bg-[#e5e5e5] hover:text-[#0d0d0d] dark:text-[#9a9a9a] dark:hover:bg-[#2a2a2a] dark:hover:text-white">
<RefreshCwIcon width={16} height={16} />
</ActionBarPrimitive.Reload>
<ActionBarPrimitive.Copy className="flex h-8 w-8 items-center justify-center rounded-full text-[#6b6b6b] transition-colors hover:bg-[#e5e5e5] hover:text-[#0d0d0d] dark:text-[#9a9a9a] dark:hover:bg-[#2a2a2a] dark:hover:text-white">
<CopyIcon width={16} height={16} />
</ActionBarPrimitive.Copy>
<ActionBarPrimitive.FeedbackPositive className="flex h-8 w-8 items-center justify-center rounded-full text-[#6b6b6b] transition-colors hover:bg-[#e5e5e5] hover:text-[#0d0d0d] dark:text-[#9a9a9a] dark:hover:bg-[#2a2a2a] dark:hover:text-white">
<ThumbsUp width={16} height={16} />
</ActionBarPrimitive.FeedbackPositive>
<ActionBarPrimitive.FeedbackNegative className="flex h-8 w-8 items-center justify-center rounded-full text-[#6b6b6b] transition-colors hover:bg-[#e5e5e5] hover:text-[#0d0d0d] dark:text-[#9a9a9a] dark:hover:bg-[#2a2a2a] dark:hover:text-white">
<ThumbsDown width={16} height={16} />
</ActionBarPrimitive.FeedbackNegative>
<MessageTimingDisplay />
</ActionBarPrimitive.Root>
</div>
</div>
</AuiIf>
</MessagePrimitive.Root>
);
};
const formatTime = (ms: number | undefined) => {
if (ms === undefined) return null;
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(1)}s`;
};
const formatMs = (ms: number | undefined) => {
if (ms === undefined) return "\u2014";
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(2)}s`;
};
const MessageTimingDisplay: FC = () => {
const timing = useMessageTiming();
if (!timing?.totalStreamTime) return null;
const totalTimeText = formatTime(timing.totalStreamTime);
if (!totalTimeText) return null;
return (
<div className="group/timing relative">
<button
type="button"
className="ml-1 flex h-auto items-center justify-center rounded-md px-1.5 py-0.5 font-mono text-xs text-[#6b6b6b] tabular-nums transition-colors hover:bg-[#e5e5e5] hover:text-[#0d0d0d] dark:text-[#9a9a9a] dark:hover:bg-[#2a2a2a] dark:hover:text-white"
>
{totalTimeText}
</button>
<div className="pointer-events-none absolute top-1/2 left-full z-10 ml-2 -translate-y-1/2 scale-95 rounded-lg border border-[#e5e5e5] bg-white px-3 py-2 opacity-0 shadow-lg transition-all group-hover/timing:pointer-events-auto group-hover/timing:scale-100 group-hover/timing:opacity-100 before:absolute before:top-0 before:-left-2 before:h-full before:w-2 before:content-[''] dark:border-[#2a2a2a] dark:bg-[#1a1a1a]">
<div className="grid min-w-[140px] gap-1.5 text-xs">
{timing.firstTokenTime !== undefined && (
<div className="flex items-center justify-between gap-4">
<span className="text-[#6b6b6b] dark:text-[#9a9a9a]">
First token
</span>
<span className="font-mono text-[#0d0d0d] tabular-nums dark:text-white">
{formatMs(timing.firstTokenTime)}
</span>
</div>
)}
<div className="flex items-center justify-between gap-4">
<span className="text-[#6b6b6b] dark:text-[#9a9a9a]">Total</span>
<span className="font-mono text-[#0d0d0d] tabular-nums dark:text-white">
{formatMs(timing.totalStreamTime)}
</span>
</div>
{timing.tokensPerSecond !== undefined && (
<div className="flex items-center justify-between gap-4">
<span className="text-[#6b6b6b] dark:text-[#9a9a9a]">Speed</span>
<span className="font-mono text-[#0d0d0d] tabular-nums dark:text-white">
{timing.tokensPerSecond.toFixed(1)} tok/s
</span>
</div>
)}
{timing.totalChunks > 0 && (
<div className="flex items-center justify-between gap-4">
<span className="text-[#6b6b6b] dark:text-[#9a9a9a]">Chunks</span>
<span className="font-mono text-[#0d0d0d] tabular-nums dark:text-white">
{timing.totalChunks}
</span>
</div>
)}
</div>
</div>
</div>
);
};
const useAttachmentSrc = () => {
const { file, src } = useAuiState(
useShallow((s): { file?: File; src?: string } => {
if (s.attachment.type !== "image") return {};
if (s.attachment.file) return { file: s.attachment.file };
const src = s.attachment.content?.filter((c) => c.type === "image")[0]
?.image;
if (!src) return {};
return { src };
}),
);
const [fileSrc, setFileSrc] = useState<string | undefined>(undefined);
useEffect(() => {
if (!file) {
setFileSrc(undefined);
return;
}
const objectUrl = URL.createObjectURL(file);
setFileSrc(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
}, [file]);
return fileSrc ?? src;
};
const GrokAttachment: FC = () => {
const src = useAttachmentSrc();
return (
<AttachmentPrimitive.Root className="group/attachment relative">
<div className="flex h-12 items-center gap-2 overflow-hidden rounded-xl border border-[#e5e5e5] bg-[#f0f0f0] p-0.5 transition-colors hover:border-[#d0d0d0] dark:border-[#2a2a2a] dark:bg-[#252525] dark:hover:border-[#3a3a3a]">
<AuiIf condition={(s) => s.attachment.type === "image"}>
{src ? (
<img
className="h-full w-12 rounded-[9px] object-cover"
alt="Attachment"
src={src}
/>
) : (
<div className="flex h-full w-12 items-center justify-center rounded-[9px] bg-[#e5e5e5] text-[#6b6b6b] dark:bg-[#3a3a3a] dark:text-[#9a9a9a]">
<AttachmentPrimitive.unstable_Thumb className="text-xs" />
</div>
)}
</AuiIf>
<AuiIf condition={(s) => s.attachment.type !== "image"}>
<div className="flex h-full w-12 items-center justify-center rounded-[9px] bg-[#e5e5e5] text-[#6b6b6b] dark:bg-[#3a3a3a] dark:text-[#9a9a9a]">
<AttachmentPrimitive.unstable_Thumb className="text-xs" />
</div>
</AuiIf>
</div>
<AttachmentPrimitive.Remove className="absolute -top-1.5 -right-1.5 flex h-6 w-6 scale-50 items-center justify-center rounded-full border border-[#e5e5e5] bg-white text-[#6b6b6b] opacity-0 transition-all group-hover/attachment:scale-100 group-hover/attachment:opacity-100 hover:bg-[#f5f5f5] hover:text-[#0d0d0d] dark:border-[#3a3a3a] dark:bg-[#1a1a1a] dark:text-[#9a9a9a] dark:hover:bg-[#252525] dark:hover:text-white">
<XIcon width={14} height={14} />
</AttachmentPrimitive.Remove>
</AttachmentPrimitive.Root>
);
};