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

822 lines
23 KiB
TypeScript

"use client";
import { CircleAlertIcon, SunIcon, MoonIcon } from "lucide-react";
import { Select } from "@/components/assistant-ui/select";
import { Switch } from "@/components/ui/switch";
import {
ThemeColorPicker,
OptionalThemeColorPicker,
} from "@/components/shared/color-picker";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/radix/tooltip";
import {
BORDER_RADIUSES,
CODE_HIGHLIGHT_THEMES,
DEFAULT_COLORS,
FONT_FAMILIES,
FONT_SIZES,
LOADING_INDICATORS,
MAX_WIDTHS,
MESSAGE_SPACINGS,
TYPING_INDICATORS,
USER_MESSAGE_POSITIONS,
type BuilderConfig,
type BorderRadius,
type CodeHighlightTheme,
type FontSize,
type LoadingIndicator,
type MessageSpacing,
type ThemeColor,
type TypingIndicator,
} from "./types";
import { PRESETS } from "./presets";
import { analytics } from "@/lib/analytics";
interface BuilderControlsProps {
config: BuilderConfig;
onChange: (config: BuilderConfig) => void;
}
export function BuilderControls({ config, onChange }: BuilderControlsProps) {
const updateComponents = (updates: Partial<BuilderConfig["components"]>) => {
onChange({
...config,
components: { ...config.components, ...updates },
});
};
const updateStyles = (updates: Partial<BuilderConfig["styles"]>) => {
onChange({
...config,
styles: { ...config.styles, ...updates },
});
};
const updateColor = <K extends keyof BuilderConfig["styles"]["colors"]>(
key: K,
value: BuilderConfig["styles"]["colors"][K],
) => {
onChange({
...config,
styles: {
...config.styles,
colors: { ...config.styles.colors, [key]: value },
},
});
};
const updateOptionalColor = <
K extends keyof BuilderConfig["styles"]["colors"],
>(
key: K,
value: ThemeColor | undefined,
) => {
const newColors = { ...config.styles.colors };
if (value === undefined) {
delete newColors[key];
} else {
newColors[key] = value;
}
onChange({
...config,
styles: {
...config.styles,
colors: newColors,
},
});
};
const updateActionBar = (
updates: Partial<BuilderConfig["components"]["actionBar"]>,
) => {
onChange({
...config,
components: {
...config.components,
actionBar: { ...config.components.actionBar, ...updates },
},
});
};
const { components, styles } = config;
return (
<div className="h-full scrollbar-none overflow-y-auto">
<div className="space-y-5">
<div className="bg-muted/30 flex items-center justify-between rounded-lg border px-3 py-1">
<span className="text-sm font-medium">Preset</span>
<PresetSelect config={config} onChange={onChange} />
</div>
<Section
title="Messages"
headerRight={<ColorModeHeader />}
withDivider={false}
>
<div className="space-y-1">
<Row
label="User Position"
control={
<Select
value={styles.userMessagePosition}
onValueChange={(value) =>
updateStyles({
userMessagePosition: value as "left" | "right",
})
}
options={USER_MESSAGE_POSITIONS}
/>
}
/>
<Row
label="User Background"
control={
<ThemeColorPicker
value={
styles.colors.userMessage ?? DEFAULT_COLORS.userMessage
}
onChange={(value) => updateColor("userMessage", value)}
/>
}
/>
<SwitchColorRow
label="Assistant Background"
enabled={styles.colors.assistantMessage !== undefined}
onEnabledChange={(enabled) => {
if (enabled) {
updateOptionalColor(
"assistantMessage",
DEFAULT_COLORS.background,
);
} else {
updateOptionalColor("assistantMessage", undefined);
}
}}
color={styles.colors.assistantMessage}
defaultColor={DEFAULT_COLORS.background}
onColorChange={(color) =>
updateOptionalColor("assistantMessage", color)
}
/>
<Row
label="Spacing"
control={
<Select
value={styles.messageSpacing}
onValueChange={(value) =>
updateStyles({ messageSpacing: value as MessageSpacing })
}
options={MESSAGE_SPACINGS}
/>
}
/>
<Row
label="Edit"
control={
<Switch
checked={components.editMessage}
onCheckedChange={(checked) =>
updateComponents({ editMessage: checked })
}
/>
}
/>
<Row
label="Branch Picker"
control={
<Switch
checked={components.branchPicker}
onCheckedChange={(checked) =>
updateComponents({ branchPicker: checked })
}
/>
}
/>
</div>
</Section>
<Section title="Thread">
<div className="space-y-1">
<Row
label="Welcome"
control={
<Switch
checked={components.threadWelcome}
onCheckedChange={(checked) =>
updateComponents({ threadWelcome: checked })
}
/>
}
/>
<Row
label="Scroll to Bottom"
control={
<Switch
checked={components.scrollToBottom}
onCheckedChange={(checked) =>
updateComponents({ scrollToBottom: checked })
}
/>
}
/>
<Row
label="Max Width"
control={
<Select
value={styles.maxWidth}
onValueChange={(value) => updateStyles({ maxWidth: value })}
options={MAX_WIDTHS}
/>
}
/>
</div>
</Section>
<Section title="Composer" headerRight={<ColorModeHeader />}>
<div className="space-y-1">
<Row
label="Attachments"
control={
<Switch
checked={components.attachments}
onCheckedChange={(checked) =>
updateComponents({ attachments: checked })
}
/>
}
/>
<SwitchColorRow
label="Background"
enabled={styles.colors.composer !== undefined}
onEnabledChange={(enabled) => {
if (enabled) {
updateOptionalColor("composer", DEFAULT_COLORS.composer);
} else {
updateOptionalColor("composer", undefined);
}
}}
color={styles.colors.composer}
defaultColor={DEFAULT_COLORS.composer}
onColorChange={(color) => updateOptionalColor("composer", color)}
/>
</div>
</Section>
<Section title="Content">
<div className="space-y-1">
<Row
label="Markdown"
control={
<Switch
checked={components.markdown}
onCheckedChange={(checked) =>
updateComponents({ markdown: checked })
}
/>
}
/>
{components.markdown && (
<IndentedRow
label="Code Theme"
info={
<>
Syntax highlighting powered by{" "}
<a
href="https://shiki.style/themes"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Shiki
</a>
</>
}
control={
<Select
value={components.codeHighlightTheme}
onValueChange={(value) =>
updateComponents({
codeHighlightTheme: value as CodeHighlightTheme,
})
}
options={CODE_HIGHLIGHT_THEMES}
/>
}
/>
)}
<Row
label="Reasoning"
control={
<Switch
checked={components.reasoning}
onCheckedChange={(checked) =>
updateComponents({ reasoning: checked })
}
/>
}
/>
<Row
label="Sources"
control={
<Switch
checked={components.sources}
onCheckedChange={(checked) =>
updateComponents({ sources: checked })
}
/>
}
/>
<Row
label="Follow-ups"
control={
<Switch
checked={components.followUpSuggestions}
onCheckedChange={(checked) =>
updateComponents({ followUpSuggestions: checked })
}
/>
}
/>
<Row
label="Suggestions"
control={
<Switch
checked={components.suggestions}
onCheckedChange={(checked) =>
updateComponents({ suggestions: checked })
}
/>
}
/>
{components.suggestions && (
<>
<IndentedRow
label="Background"
control={
<OptionalThemeColorPicker
value={styles.colors.suggestion}
defaultValue={DEFAULT_COLORS.suggestion}
onChange={(value) =>
updateOptionalColor("suggestion", value)
}
/>
}
/>
<IndentedRow
label="Border"
control={
<OptionalThemeColorPicker
value={styles.colors.suggestionBorder}
defaultValue={DEFAULT_COLORS.suggestionBorder}
onChange={(value) =>
updateOptionalColor("suggestionBorder", value)
}
/>
}
/>
</>
)}
</div>
</Section>
<SectionWithToggle
title="Avatar"
headerRight={<ColorModeHeader />}
enabled={components.avatar}
onEnabledChange={(checked) => updateComponents({ avatar: checked })}
>
<div className="space-y-1">
<Row
label="User"
control={
<OptionalThemeColorPicker
value={styles.colors.userAvatar}
defaultValue={DEFAULT_COLORS.userAvatar}
onChange={(value) => updateOptionalColor("userAvatar", value)}
/>
}
/>
<Row
label="Assistant"
control={
<OptionalThemeColorPicker
value={styles.colors.assistantAvatar}
defaultValue={DEFAULT_COLORS.assistantAvatar}
onChange={(value) =>
updateOptionalColor("assistantAvatar", value)
}
/>
}
/>
</div>
</SectionWithToggle>
<Section title="Loading">
<div className="space-y-1">
<Row
label="Typing Indicator"
control={
<Select
value={components.typingIndicator}
onValueChange={(value) =>
updateComponents({
typingIndicator: value as TypingIndicator,
})
}
options={TYPING_INDICATORS}
/>
}
/>
<Row
label="Loading Style"
control={
<Select
value={components.loadingIndicator}
onValueChange={(value) =>
updateComponents({
loadingIndicator: value as LoadingIndicator,
})
}
options={LOADING_INDICATORS}
/>
}
/>
{components.loadingIndicator === "text" && (
<IndentedRow
label="Text"
control={
<input
type="text"
value={components.loadingText}
onChange={(e) =>
updateComponents({ loadingText: e.target.value })
}
className="bg-background h-7 w-32 rounded-md border px-2 text-sm"
placeholder="Thinking..."
/>
}
/>
)}
</div>
</Section>
<Section title="Actions">
<div className="space-y-1">
<Row
label="Copy"
control={
<Switch
checked={components.actionBar.copy}
onCheckedChange={(checked) =>
updateActionBar({ copy: checked })
}
/>
}
/>
<Row
label="Reload"
control={
<Switch
checked={components.actionBar.reload}
onCheckedChange={(checked) =>
updateActionBar({ reload: checked })
}
/>
}
/>
<Row
label="Speak"
control={
<Switch
checked={components.actionBar.speak}
onCheckedChange={(checked) =>
updateActionBar({ speak: checked })
}
/>
}
/>
<Row
label="Feedback"
control={
<Switch
checked={components.actionBar.feedback}
onCheckedChange={(checked) =>
updateActionBar({ feedback: checked })
}
/>
}
/>
</div>
</Section>
<Section title="Typography">
<div className="space-y-1">
<Row
label="Font"
control={
<Select
value={styles.fontFamily}
onValueChange={(value) => updateStyles({ fontFamily: value })}
options={FONT_FAMILIES}
/>
}
/>
<Row
label="Size"
control={
<Select
value={styles.fontSize}
onValueChange={(value) =>
updateStyles({ fontSize: value as FontSize })
}
options={FONT_SIZES}
/>
}
/>
</div>
</Section>
<Section title="Layout">
<div className="space-y-1">
<Row
label="Radius"
control={
<Select
value={styles.borderRadius}
onValueChange={(value) =>
updateStyles({ borderRadius: value as BorderRadius })
}
options={BORDER_RADIUSES}
/>
}
/>
<Row
label="Animations"
control={
<Switch
checked={styles.animations}
onCheckedChange={(checked) =>
updateStyles({ animations: checked })
}
/>
}
/>
</div>
</Section>
<Section title="Colors" headerRight={<ColorModeHeader />}>
<div className="space-y-1">
<Row
label="Accent"
control={
<ThemeColorPicker
value={styles.colors.accent}
onChange={(value) => updateColor("accent", value)}
/>
}
/>
<Row
label="Background"
control={
<OptionalThemeColorPicker
value={styles.colors.background}
defaultValue={DEFAULT_COLORS.background}
onChange={(value) => updateOptionalColor("background", value)}
/>
}
/>
<Row
label="Foreground"
control={
<OptionalThemeColorPicker
value={styles.colors.foreground}
defaultValue={DEFAULT_COLORS.foreground}
onChange={(value) => updateOptionalColor("foreground", value)}
/>
}
/>
<Row
label="Muted"
control={
<OptionalThemeColorPicker
value={styles.colors.mutedForeground}
defaultValue={DEFAULT_COLORS.mutedForeground}
onChange={(value) =>
updateOptionalColor("mutedForeground", value)
}
/>
}
/>
<Row
label="Border"
control={
<OptionalThemeColorPicker
value={styles.colors.border}
defaultValue={DEFAULT_COLORS.border}
onChange={(value) => updateOptionalColor("border", value)}
/>
}
/>
</div>
</Section>
</div>
</div>
);
}
function Section({
title,
headerRight,
children,
withDivider = true,
}: {
title: string;
headerRight?: React.ReactNode;
children: React.ReactNode;
withDivider?: boolean;
}) {
return (
<div
className={
withDivider ? "border-border/50 space-y-2 border-t pt-4" : "space-y-2"
}
>
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs font-medium">
{title}
</span>
{headerRight}
</div>
{children}
</div>
);
}
function SectionWithToggle({
title,
headerRight,
enabled,
onEnabledChange,
children,
}: {
title: string;
headerRight?: React.ReactNode;
enabled: boolean;
onEnabledChange: (enabled: boolean) => void;
children: React.ReactNode;
}) {
return (
<div className="border-border/50 space-y-2 border-t pt-4">
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs font-medium">
{title}
</span>
<div className="flex items-center gap-2">
{enabled && headerRight}
<Switch checked={enabled} onCheckedChange={onEnabledChange} />
</div>
</div>
{enabled && children}
</div>
);
}
function ColorModeHeader() {
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex gap-1">
<div className="flex size-5 items-center justify-center">
<SunIcon className="text-muted-foreground size-3.5" />
</div>
<div className="flex size-5 items-center justify-center">
<MoonIcon className="text-muted-foreground size-3.5" />
</div>
</div>
</TooltipTrigger>
<TooltipContent side="top">Light &amp; Dark mode colors</TooltipContent>
</Tooltip>
);
}
function Row({
label,
control,
info,
}: {
label: string;
control: React.ReactNode;
info?: React.ReactNode;
}) {
return (
<div className="flex h-7 items-center justify-between">
<span className="flex items-center gap-2 text-sm">
{label}
{info && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-foreground flex items-center"
>
<CircleAlertIcon className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="top">{info}</TooltipContent>
</Tooltip>
)}
</span>
{control}
</div>
);
}
function IndentedRow({
label,
control,
info,
}: {
label: string;
control: React.ReactNode;
info?: React.ReactNode;
}) {
return (
<div className="border-border ml-4 border-l pl-3">
<Row label={label} control={control} info={info} />
</div>
);
}
function SwitchColorRow({
label,
enabled,
onEnabledChange,
color,
defaultColor,
onColorChange,
}: {
label: string;
enabled: boolean;
onEnabledChange: (enabled: boolean) => void;
color: ThemeColor | undefined;
defaultColor: ThemeColor;
onColorChange: (color: ThemeColor | undefined) => void;
}) {
return (
<div className="flex h-7 items-center justify-between">
<span className="text-sm">{label}</span>
<div className="flex items-center gap-2">
<Switch
checked={enabled}
onCheckedChange={(checked) => {
onEnabledChange(checked);
if (!checked) onColorChange(undefined);
}}
/>
<div className={enabled ? "" : "pointer-events-none opacity-40"}>
<ThemeColorPicker
value={color ?? defaultColor}
onChange={onColorChange}
/>
</div>
</div>
</div>
);
}
function PresetSelect({
config,
onChange,
}: {
config: BuilderConfig;
onChange: (config: BuilderConfig) => void;
}) {
const currentPreset = PRESETS.find(
(preset) => JSON.stringify(preset.config) === JSON.stringify(config),
);
const options = PRESETS.map((preset) => ({
label: preset.name,
value: preset.id,
}));
return (
<Select
value={currentPreset?.id ?? ""}
onValueChange={(id) => {
const preset = PRESETS.find((p) => p.id === id);
if (preset) {
analytics.builder.presetSelected(preset.name);
onChange(preset.config);
}
}}
options={options}
placeholder="Custom"
/>
);
}