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

591 lines
19 KiB
TypeScript

"use client";
import { useState } from "react";
import ShikiHighlighter from "react-shiki";
import { CheckIcon, CopyIcon } from "lucide-react";
import type { BuilderConfig } from "./types";
import {
BORDER_RADIUS_CLASS,
FONT_SIZE_CLASS,
MESSAGE_SPACING_CLASS,
isLightColor,
} from "@/lib/builder-utils";
import { analytics } from "@/lib/analytics";
interface BuilderCodeOutputProps {
config: BuilderConfig;
}
export function BuilderCodeOutput({ config }: BuilderCodeOutputProps) {
const [copied, setCopied] = useState(false);
const componentCode = generateComponentCode(config);
const handleCopy = async () => {
analytics.builder.codeCopied();
await navigator.clipboard.writeText(componentCode);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between px-3 py-2">
<span className="text-sm font-medium">thread.tsx</span>
<button
type="button"
onClick={handleCopy}
className="text-muted-foreground hover:text-foreground flex items-center gap-1.5 rounded-md px-2 py-1 text-xs transition-colors"
>
{copied ? (
<>
<CheckIcon className="size-3.5" />
Copied
</>
) : (
<>
<CopyIcon className="size-3.5" />
Copy
</>
)}
</button>
</div>
<div className="min-h-0 flex-1 overflow-auto text-xs leading-relaxed [&_pre]:m-0! [&_pre]:bg-transparent! [&_pre]:p-0!">
<ShikiHighlighter
language="tsx"
theme={{ dark: "vitesse-dark", light: "vitesse-light" }}
addDefaultStyles={false}
showLanguage={false}
defaultColor="light-dark()"
>
{componentCode.trim()}
</ShikiHighlighter>
</div>
</div>
);
}
function generateComponentCode(config: BuilderConfig): string {
const { components, styles } = config;
const iconImports = generateIconImports(config);
const externalImports = [
iconImports,
`import {`,
` ActionBarPrimitive,`,
` AuiIf,`,
components.branchPicker && ` BranchPickerPrimitive,`,
` ComposerPrimitive,`,
` ErrorPrimitive,`,
` MessagePrimitive,`,
` ThreadPrimitive,`,
`} from "@assistant-ui/react";`,
components.markdown &&
components.typingIndicator === "dot" &&
`import "@assistant-ui/react-markdown/styles/dot.css";`,
]
.filter(Boolean)
.join("\n");
const internalImports = [
`import { Button } from "@/components/ui/button";`,
`import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";`,
components.markdown &&
`import { MarkdownText } from "@/components/assistant-ui/markdown-text";`,
components.markdown &&
`import { ToolFallback } from "@/components/assistant-ui/tool-fallback";`,
components.reasoning &&
`import { Reasoning, ReasoningGroup } from "@/components/assistant-ui/reasoning";`,
components.sources &&
`import { Sources } from "@/components/assistant-ui/sources";`,
components.attachments &&
`import {
ComposerAddAttachment,
ComposerAttachments,
UserMessageAttachments,
} from "@/components/assistant-ui/attachment";`,
`import { cn } from "@/lib/utils";`,
]
.filter(Boolean)
.join("\n");
const borderRadiusClass = BORDER_RADIUS_CLASS[styles.borderRadius];
const fontSizeClass = FONT_SIZE_CLASS[styles.fontSize];
const messageSpacingClass = MESSAGE_SPACING_CLASS[styles.messageSpacing];
const accentColor = styles.colors.accent.light;
const accentForeground = isLightColor(accentColor) ? "#000000" : "#ffffff";
const cssVariables = `
"--thread-max-width": "${styles.maxWidth}",
"--accent-color": "${accentColor}",
"--accent-foreground": "${accentForeground}",`;
const fontFamilyStyle =
styles.fontFamily !== "system-ui"
? `\n fontFamily: "${styles.fontFamily}",`
: "";
const threadComponent = `
export function Thread() {
return (
<ThreadPrimitive.Root
className="flex h-full flex-col bg-background ${fontSizeClass}"
style={{${cssVariables}${fontFamilyStyle}
}}
>
<ThreadPrimitive.Viewport
turnAnchor="top"
className="relative flex flex-1 flex-col overflow-x-auto overflow-y-scroll scroll-smooth px-4 pt-4"
>
${
components.threadWelcome
? `<AuiIf condition={(s) => s.thread.isEmpty}>
<ThreadWelcome />
</AuiIf>`
: ""
}
<ThreadPrimitive.Messages
components={{
UserMessage,${
components.editMessage
? `
EditComposer,`
: ""
}
AssistantMessage,
}}
/>
<ThreadPrimitive.ViewportFooter className="sticky bottom-0 mx-auto mt-auto flex w-full max-w-[var(--thread-max-width)] flex-col gap-4 overflow-visible rounded-t-3xl bg-background pb-4">
${components.scrollToBottom ? "<ThreadScrollToBottom />" : ""}
<Composer />
</ThreadPrimitive.ViewportFooter>
</ThreadPrimitive.Viewport>
</ThreadPrimitive.Root>
);
}`;
const welcomeComponent = components.threadWelcome
? `
function ThreadWelcome() {
return (
<div className="mx-auto my-auto flex w-full max-w-[var(--thread-max-width)] flex-grow flex-col">
<div className="flex w-full flex-grow flex-col items-center justify-center">
<div className="flex size-full flex-col justify-center px-8">
<div className="text-2xl font-semibold">Hello there!</div>
<div className="text-2xl text-muted-foreground/65">
How can I help you today?
</div>
</div>
</div>
${
components.suggestions
? `<div className="grid w-full gap-2 pb-4 md:grid-cols-2">
{/* Add your suggestions here */}
<ThreadPrimitive.Suggestion prompt="What's the weather in San Francisco?" asChild>
<Button variant="ghost" className="h-auto w-full flex-col items-start justify-start gap-1 border ${borderRadiusClass} px-5 py-4 text-left text-sm">
<span className="font-medium">What's the weather</span>
<span className="text-muted-foreground">in San Francisco?</span>
</Button>
</ThreadPrimitive.Suggestion>
<ThreadPrimitive.Suggestion prompt="Explain React hooks like useState" asChild>
<Button variant="ghost" className="h-auto w-full flex-col items-start justify-start gap-1 border ${borderRadiusClass} px-5 py-4 text-left text-sm">
<span className="font-medium">Explain React hooks</span>
<span className="text-muted-foreground">like useState</span>
</Button>
</ThreadPrimitive.Suggestion>
</div>`
: ""
}
</div>
);
}`
: "";
const composerComponent = `
function Composer() {
return (
<ComposerPrimitive.Root className="relative flex w-full flex-col">
<ComposerPrimitive.AttachmentDropzone className="flex w-full flex-col ${borderRadiusClass} border border-input bg-background px-1 pt-2 outline-none transition-colors has-[textarea:focus-visible]:border-foreground/60 data-[dragging=true]:border-ring data-[dragging=true]:border-dashed data-[dragging=true]:bg-accent/50">
${components.attachments ? "<ComposerAttachments />" : ""}
<ComposerPrimitive.Input
placeholder="Send a message..."
className="mb-1 max-h-32 min-h-14 w-full resize-none bg-transparent px-4 pt-2 pb-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-0"
rows={1}
autoFocus
aria-label="Message input"
/>
<ComposerAction />
</ComposerPrimitive.AttachmentDropzone>
</ComposerPrimitive.Root>
);
}
function ComposerAction() {
return (
<div className="relative mx-2 mb-2 flex items-center justify-between">
${components.attachments ? "<ComposerAddAttachment />" : "<div />"}
<AuiIf condition={(s) => !s.thread.isRunning}>
<ComposerPrimitive.Send asChild>
<TooltipIconButton
tooltip="Send message"
side="bottom"
variant="default"
size="icon"
className="size-8 rounded-full"
style={{
backgroundColor: "var(--accent-color)",
color: "var(--accent-foreground)",
}}
aria-label="Send message"
>
<ArrowUpIcon className="size-4" />
</TooltipIconButton>
</ComposerPrimitive.Send>
</AuiIf>
<AuiIf condition={(s) => s.thread.isRunning}>
<ComposerPrimitive.Cancel asChild>
<Button
type="button"
variant="default"
size="icon"
className="size-8 rounded-full"
style={{
backgroundColor: "var(--accent-color)",
color: "var(--accent-foreground)",
}}
aria-label="Stop generating"
>
<SquareIcon className="size-3 fill-current" />
</Button>
</ComposerPrimitive.Cancel>
</AuiIf>
</div>
);
}`;
const scrollToBottomComponent = components.scrollToBottom
? `
function ThreadScrollToBottom() {
return (
<ThreadPrimitive.ScrollToBottom asChild>
<TooltipIconButton
tooltip="Scroll to bottom"
variant="outline"
className="absolute -top-12 z-10 self-center rounded-full p-4 disabled:invisible"
>
<ArrowDownIcon />
</TooltipIconButton>
</ThreadPrimitive.ScrollToBottom>
);
}`
: "";
const animationClass = styles.animations
? " fade-in slide-in-from-bottom-1 animate-in duration-150"
: "";
const userMessageComponent = `
function UserMessage() {
return (
<MessagePrimitive.Root
className="mx-auto grid w-full max-w-[var(--thread-max-width)] auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 ${messageSpacingClass}${animationClass}"
data-role="user"
>
${components.attachments ? "<UserMessageAttachments />" : ""}
<div className="relative col-start-2 min-w-0">
<div className="${borderRadiusClass} bg-muted px-4 py-2.5 break-words text-foreground">
<MessagePrimitive.Parts />
</div>
${
components.editMessage
? `<div className="absolute top-1/2 left-0 -translate-x-full -translate-y-1/2 pr-2">
<UserActionBar />
</div>`
: ""
}
</div>
${components.branchPicker ? `<BranchPicker className="col-span-full col-start-1 row-start-3 -mr-1 justify-end" />` : ""}
</MessagePrimitive.Root>
);
}
${
components.editMessage
? `function UserActionBar() {
return (
<ActionBarPrimitive.Root
hideWhenRunning
autohide="not-last"
className="flex flex-col items-end"
>
<ActionBarPrimitive.Edit asChild>
<TooltipIconButton tooltip="Edit" className="p-4">
<PencilIcon />
</TooltipIconButton>
</ActionBarPrimitive.Edit>
</ActionBarPrimitive.Root>
);
}`
: ""
}`;
const assistantMessageRootClass = `relative mx-auto w-full max-w-[var(--thread-max-width)] ${messageSpacingClass}${animationClass}`;
// Build MessagePrimitive.Parts components object
const partsComponents: string[] = [];
if (components.markdown) {
partsComponents.push(`Text: MarkdownText`);
partsComponents.push(`tools: { Fallback: ToolFallback }`);
}
if (components.reasoning) {
partsComponents.push(`Reasoning`);
partsComponents.push(`ReasoningGroup`);
}
if (components.sources) {
partsComponents.push(`Source: Sources`);
}
const partsComponentsStr =
partsComponents.length > 0
? `
components={{
${partsComponents.join(",\n ")},
}}`
: "";
const assistantMessageComponent = `
function AssistantMessage() {
return (
<MessagePrimitive.Root
className="${assistantMessageRootClass}"
data-role="assistant"
>
${
components.avatar
? `<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/10">
<BotIcon className="size-4" />
</div>`
: ""
}
<div className="break-words px-2 leading-relaxed text-foreground">
<MessagePrimitive.Parts${partsComponentsStr} />
<MessageError />${
components.loadingIndicator !== "none"
? `
<AuiIf condition={(s) => s.thread.isRunning && s.message.content.length === 0}>
<div className="flex items-center gap-2 text-muted-foreground">
<LoaderIcon className="size-4 animate-spin" />${
components.loadingIndicator === "text"
? `
<span className="text-sm">${components.loadingText}</span>`
: ""
}
</div>
</AuiIf>`
: ""
}
</div>
<div className="mt-1 ml-2 flex min-h-6 items-center">
${components.branchPicker ? "<BranchPicker />" : ""}
<AssistantActionBar />
</div>
${
components.followUpSuggestions
? `
<AuiIf condition={(s) => !s.thread.isRunning}>
<div className="mt-4 flex flex-wrap gap-2">
<ThreadPrimitive.Suggestion
prompt="Tell me more"
className="rounded-full border bg-background px-3 py-1 text-sm hover:bg-muted"
>
Tell me more
</ThreadPrimitive.Suggestion>
<ThreadPrimitive.Suggestion
prompt="Can you explain differently?"
className="rounded-full border bg-background px-3 py-1 text-sm hover:bg-muted"
>
Explain differently
</ThreadPrimitive.Suggestion>
</div>
</AuiIf>`
: ""
}
</MessagePrimitive.Root>
);
}
function MessageError() {
return (
<MessagePrimitive.Error>
<ErrorPrimitive.Root className="mt-2 rounded-md border border-destructive bg-destructive/10 p-3 text-destructive text-sm dark:bg-destructive/5 dark:text-red-200">
<ErrorPrimitive.Message className="line-clamp-2" />
</ErrorPrimitive.Root>
</MessagePrimitive.Error>
);
}`;
const feedbackButtons = components.actionBar.feedback
? `
<ActionBarPrimitive.FeedbackPositive asChild>
<TooltipIconButton tooltip="Good response">
<ThumbsUpIcon />
</TooltipIconButton>
</ActionBarPrimitive.FeedbackPositive>
<ActionBarPrimitive.FeedbackNegative asChild>
<TooltipIconButton tooltip="Bad response">
<ThumbsDownIcon />
</TooltipIconButton>
</ActionBarPrimitive.FeedbackNegative>`
: "";
const actionBarComponent = `
function AssistantActionBar() {
return (
<ActionBarPrimitive.Root
hideWhenRunning
autohide="not-last"
className="-ml-1 flex gap-1 text-muted-foreground"
>
${
components.actionBar.copy
? `<ActionBarPrimitive.Copy asChild>
<TooltipIconButton tooltip="Copy">
<AuiIf condition={(s) => s.message.isCopied}>
<CheckIcon />
</AuiIf>
<AuiIf condition={(s) => !s.message.isCopied}>
<CopyIcon />
</AuiIf>
</TooltipIconButton>
</ActionBarPrimitive.Copy>`
: ""
}
<ActionBarPrimitive.ExportMarkdown asChild>
<TooltipIconButton tooltip="Export as Markdown">
<DownloadIcon />
</TooltipIconButton>
</ActionBarPrimitive.ExportMarkdown>
${
components.actionBar.reload
? `<ActionBarPrimitive.Reload asChild>
<TooltipIconButton tooltip="Refresh">
<RefreshCwIcon />
</TooltipIconButton>
</ActionBarPrimitive.Reload>`
: ""
}
${
components.actionBar.speak
? `<ActionBarPrimitive.Speak asChild>
<TooltipIconButton tooltip="Read aloud">
<Volume2Icon />
</TooltipIconButton>
</ActionBarPrimitive.Speak>`
: ""
}${feedbackButtons}
</ActionBarPrimitive.Root>
);
}`;
const branchPickerComponent = components.branchPicker
? `
function BranchPicker({ className, ...rest }: { className?: string }) {
return (
<BranchPickerPrimitive.Root
hideWhenSingleBranch
className={cn("mr-2 -ml-2 inline-flex items-center text-xs text-muted-foreground", className)}
{...rest}
>
<BranchPickerPrimitive.Previous asChild>
<TooltipIconButton tooltip="Previous">
<ChevronLeftIcon />
</TooltipIconButton>
</BranchPickerPrimitive.Previous>
<span className="font-medium">
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
</span>
<BranchPickerPrimitive.Next asChild>
<TooltipIconButton tooltip="Next">
<ChevronRightIcon />
</TooltipIconButton>
</BranchPickerPrimitive.Next>
</BranchPickerPrimitive.Root>
);
}`
: "";
const editComposerComponent = components.editMessage
? `
function EditComposer() {
return (
<MessagePrimitive.Root className="mx-auto flex w-full max-w-[var(--thread-max-width)] flex-col px-2 py-3">
<ComposerPrimitive.Root className="ml-auto flex w-full max-w-[85%] flex-col ${borderRadiusClass} bg-muted">
<ComposerPrimitive.Input
className="min-h-14 w-full resize-none bg-transparent p-4 text-foreground text-sm outline-none"
autoFocus
/>
<div className="mx-3 mb-3 flex items-center gap-2 self-end">
<ComposerPrimitive.Cancel asChild>
<Button variant="ghost" size="sm">Cancel</Button>
</ComposerPrimitive.Cancel>
<ComposerPrimitive.Send asChild>
<Button size="sm">Update</Button>
</ComposerPrimitive.Send>
</div>
</ComposerPrimitive.Root>
</MessagePrimitive.Root>
);
}`
: "";
const allImports = `"use client";
${externalImports}
${internalImports}`;
return [
allImports,
threadComponent,
welcomeComponent,
composerComponent,
scrollToBottomComponent,
userMessageComponent,
editComposerComponent,
assistantMessageComponent,
actionBarComponent,
branchPickerComponent,
]
.filter(Boolean)
.join("\n");
}
function generateIconImports(config: BuilderConfig): string {
const { components } = config;
const icons: string[] = ["ArrowUpIcon", "DownloadIcon", "SquareIcon"];
if (components.scrollToBottom) icons.push("ArrowDownIcon");
if (components.editMessage) icons.push("PencilIcon");
if (components.branchPicker)
icons.push("ChevronLeftIcon", "ChevronRightIcon");
if (components.actionBar.copy) icons.push("CheckIcon", "CopyIcon");
if (components.actionBar.reload) icons.push("RefreshCwIcon");
if (components.actionBar.speak) icons.push("Volume2Icon");
if (components.actionBar.feedback)
icons.push("ThumbsUpIcon", "ThumbsDownIcon");
if (components.avatar) icons.push("BotIcon", "UserIcon");
if (components.loadingIndicator !== "none") icons.push("LoaderIcon");
return `import {\n ${[...new Set(icons)].sort().join(",\n ")},\n} from "lucide-react";`;
}