feat: add image generation tool and WebUI mode
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
Xubin Ren
co-authored by
Cursor
parent
3a2f47d720
commit
e936ed48bd
@@ -142,7 +142,9 @@ function MessageMedia({
|
||||
align === "right" ? "justify-end" : "justify-start",
|
||||
)}
|
||||
>
|
||||
{images.length > 0 ? <UserImages images={images} align={align} /> : null}
|
||||
{images.length > 0 ? (
|
||||
<UserImages images={images} align={align} size={align === "left" ? "large" : "compact"} />
|
||||
) : null}
|
||||
{nonImages.map((item, i) => (
|
||||
<MediaCell key={`${item.url ?? item.name ?? item.kind}-${i}`} media={item} />
|
||||
))}
|
||||
@@ -208,9 +210,11 @@ function MediaCell({ media }: { media: UIMediaAttachment }) {
|
||||
function UserImages({
|
||||
images,
|
||||
align = "right",
|
||||
size = "compact",
|
||||
}: {
|
||||
images: UIImage[];
|
||||
align?: "left" | "right";
|
||||
size?: "compact" | "large";
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// Only real-URL images can open in the lightbox; historical-replay
|
||||
@@ -230,6 +234,7 @@ function UserImages({
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-wrap items-end gap-2",
|
||||
size === "large" && "gap-3",
|
||||
align === "right" ? "ml-auto justify-end" : "mr-auto justify-start",
|
||||
)}
|
||||
>
|
||||
@@ -237,6 +242,7 @@ function UserImages({
|
||||
<UserImageCell
|
||||
key={`${img.url ?? "placeholder"}-${i}`}
|
||||
image={img}
|
||||
size={size}
|
||||
placeholderLabel={t("message.imageAttachment")}
|
||||
openLabel={t("lightbox.open")}
|
||||
onOpen={
|
||||
@@ -261,18 +267,23 @@ function UserImages({
|
||||
|
||||
function UserImageCell({
|
||||
image,
|
||||
size,
|
||||
placeholderLabel,
|
||||
openLabel,
|
||||
onOpen,
|
||||
}: {
|
||||
image: UIImage;
|
||||
size: "compact" | "large";
|
||||
placeholderLabel: string;
|
||||
openLabel: string;
|
||||
onOpen?: () => void;
|
||||
}) {
|
||||
const hasUrl = typeof image.url === "string" && image.url.length > 0;
|
||||
const tileClasses = cn(
|
||||
"relative h-24 w-24 overflow-hidden rounded-[14px] border border-border/60 bg-muted/40",
|
||||
"relative overflow-hidden border border-border/60 bg-muted/40",
|
||||
size === "large"
|
||||
? "h-56 w-[min(100%,22rem)] rounded-[18px] sm:h-72 sm:w-[26rem]"
|
||||
: "h-24 w-24 rounded-[14px]",
|
||||
"shadow-[0_6px_18px_-14px_rgba(0,0,0,0.45)]",
|
||||
);
|
||||
|
||||
@@ -296,7 +307,7 @@ function UserImageCell({
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
className="h-full w-full object-cover"
|
||||
className={cn("h-full w-full", size === "large" ? "object-contain" : "object-cover")}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -16,12 +16,14 @@ interface SettingsViewProps {
|
||||
onBackToChat: () => void;
|
||||
onModelNameChange: (modelName: string | null) => void;
|
||||
onLogout?: () => void;
|
||||
onRestart?: () => void;
|
||||
}
|
||||
|
||||
export function SettingsView({
|
||||
onBackToChat,
|
||||
onModelNameChange,
|
||||
onLogout,
|
||||
onRestart,
|
||||
}: SettingsViewProps) {
|
||||
const { token } = useClient();
|
||||
const [settings, setSettings] = useState<SettingsPayload | null>(null);
|
||||
@@ -119,6 +121,7 @@ export function SettingsView({
|
||||
saving={saving}
|
||||
onSave={save}
|
||||
onLogout={onLogout}
|
||||
onRestart={onRestart}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
@@ -134,6 +137,7 @@ function SettingsSection({
|
||||
saving,
|
||||
onSave,
|
||||
onLogout,
|
||||
onRestart,
|
||||
}: {
|
||||
form: {
|
||||
model: string;
|
||||
@@ -148,6 +152,7 @@ function SettingsSection({
|
||||
saving: boolean;
|
||||
onSave: () => void;
|
||||
onLogout?: () => void;
|
||||
onRestart?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -200,6 +205,19 @@ function SettingsSection({
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
|
||||
{onRestart && (
|
||||
<section>
|
||||
<h2 className="mb-2 px-2 text-xs font-medium text-muted-foreground">{t("app.system.section")}</h2>
|
||||
<SettingsGroup>
|
||||
<SettingsRow title={t("app.system.restartHint")}>
|
||||
<Button size="sm" variant="outline" onClick={onRestart}>
|
||||
{t("app.system.restart")}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{onLogout && (
|
||||
<section>
|
||||
<h2 className="mb-2 px-2 text-xs font-medium text-muted-foreground">{t("app.account.section")}</h2>
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
Activity,
|
||||
ArrowUp,
|
||||
BookOpen,
|
||||
Check,
|
||||
ChevronDown,
|
||||
CircleHelp,
|
||||
History,
|
||||
ImageIcon,
|
||||
@@ -33,7 +35,7 @@ import {
|
||||
MAX_IMAGES_PER_MESSAGE,
|
||||
} from "@/hooks/useAttachedImages";
|
||||
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
|
||||
import type { SendImage } from "@/hooks/useNanobotStream";
|
||||
import type { SendImage, SendOptions } from "@/hooks/useNanobotStream";
|
||||
import type { SlashCommand } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -48,13 +50,16 @@ function formatBytes(n: number): string {
|
||||
}
|
||||
|
||||
interface ThreadComposerProps {
|
||||
onSend: (content: string, images?: SendImage[]) => void;
|
||||
onSend: (content: string, images?: SendImage[], options?: SendOptions) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
isStreaming?: boolean;
|
||||
modelLabel?: string | null;
|
||||
variant?: "thread" | "hero";
|
||||
slashCommands?: SlashCommand[];
|
||||
imageMode?: boolean;
|
||||
onImageModeChange?: (enabled: boolean) => void;
|
||||
onStop?: () => void;
|
||||
}
|
||||
|
||||
const COMMAND_ICONS: Record<string, LucideIcon> = {
|
||||
@@ -69,10 +74,28 @@ const COMMAND_ICONS: Record<string, LucideIcon> = {
|
||||
"undo-2": Undo2,
|
||||
};
|
||||
|
||||
type ImageAspectRatio = "auto" | "1:1" | "3:4" | "9:16" | "4:3" | "16:9";
|
||||
|
||||
const IMAGE_ASPECT_RATIOS: ImageAspectRatio[] = ["auto", "1:1", "3:4", "9:16", "4:3", "16:9"];
|
||||
|
||||
function slashCommandI18nKey(command: string): string {
|
||||
return command.replace(/^\//, "").replace(/-/g, "_");
|
||||
}
|
||||
|
||||
function scrollNearestOverflowParent(target: EventTarget | null, deltaY: number) {
|
||||
if (!(target instanceof Element) || deltaY === 0) return;
|
||||
let el: HTMLElement | null = target.parentElement;
|
||||
while (el) {
|
||||
const style = window.getComputedStyle(el);
|
||||
const canScroll = /(auto|scroll)/.test(style.overflowY) && el.scrollHeight > el.clientHeight;
|
||||
if (canScroll) {
|
||||
el.scrollTop += deltaY;
|
||||
return;
|
||||
}
|
||||
el = el.parentElement;
|
||||
}
|
||||
}
|
||||
|
||||
export function ThreadComposer({
|
||||
onSend,
|
||||
disabled,
|
||||
@@ -81,19 +104,38 @@ export function ThreadComposer({
|
||||
modelLabel = null,
|
||||
variant = "thread",
|
||||
slashCommands = [],
|
||||
imageMode: controlledImageMode,
|
||||
onImageModeChange,
|
||||
onStop,
|
||||
}: ThreadComposerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState("");
|
||||
const [inlineError, setInlineError] = useState<string | null>(null);
|
||||
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
|
||||
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0);
|
||||
const [uncontrolledImageMode, setUncontrolledImageMode] = useState(false);
|
||||
const [imageAspectRatio, setImageAspectRatio] = useState<ImageAspectRatio>("auto");
|
||||
const [aspectMenuOpen, setAspectMenuOpen] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const aspectControlRef = useRef<HTMLDivElement>(null);
|
||||
const chipRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||
const isHero = variant === "hero";
|
||||
const imageMode = controlledImageMode ?? uncontrolledImageMode;
|
||||
const setImageMode = useCallback(
|
||||
(enabled: boolean) => {
|
||||
if (controlledImageMode === undefined) {
|
||||
setUncontrolledImageMode(enabled);
|
||||
}
|
||||
onImageModeChange?.(enabled);
|
||||
},
|
||||
[controlledImageMode, onImageModeChange],
|
||||
);
|
||||
const resolvedPlaceholder = isStreaming
|
||||
? t("thread.composer.placeholderStreaming")
|
||||
: placeholder ?? t("thread.composer.placeholderThread");
|
||||
: imageMode
|
||||
? t("thread.composer.imageMode.placeholder")
|
||||
: placeholder ?? t("thread.composer.placeholderThread");
|
||||
|
||||
const { images, enqueue, remove, clear, encoding, full } =
|
||||
useAttachedImages();
|
||||
@@ -190,6 +232,38 @@ export function ThreadComposer({
|
||||
}
|
||||
}, [filteredSlashCommands.length, selectedCommandIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!aspectMenuOpen) return;
|
||||
|
||||
const closeOnPointerDown = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (target instanceof Node && aspectControlRef.current?.contains(target)) return;
|
||||
setAspectMenuOpen(false);
|
||||
};
|
||||
const closeOnKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setAspectMenuOpen(false);
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
};
|
||||
const closeOnScroll = () => setAspectMenuOpen(false);
|
||||
const closeOnWheel = (event: WheelEvent) => {
|
||||
setAspectMenuOpen(false);
|
||||
scrollNearestOverflowParent(event.target, event.deltaY);
|
||||
};
|
||||
|
||||
document.addEventListener("pointerdown", closeOnPointerDown, true);
|
||||
document.addEventListener("keydown", closeOnKeyDown);
|
||||
document.addEventListener("scroll", closeOnScroll, true);
|
||||
document.addEventListener("wheel", closeOnWheel, { capture: true, passive: true });
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", closeOnPointerDown, true);
|
||||
document.removeEventListener("keydown", closeOnKeyDown);
|
||||
document.removeEventListener("scroll", closeOnScroll, true);
|
||||
document.removeEventListener("wheel", closeOnWheel, true);
|
||||
};
|
||||
}, [aspectMenuOpen]);
|
||||
|
||||
const resizeTextarea = useCallback(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const el = textareaRef.current;
|
||||
@@ -227,7 +301,15 @@ export function ThreadComposer({
|
||||
preview: { url: img.dataUrl, name: img.file.name },
|
||||
}))
|
||||
: undefined;
|
||||
onSend(trimmed, payload);
|
||||
const options: SendOptions | undefined = imageMode
|
||||
? {
|
||||
imageGeneration: {
|
||||
enabled: true,
|
||||
aspect_ratio: imageAspectRatio === "auto" ? null : imageAspectRatio,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
onSend(trimmed, payload, options);
|
||||
setValue("");
|
||||
setInlineError(null);
|
||||
// Bubble owns the data URL copy; safe to revoke every staged blob
|
||||
@@ -235,7 +317,7 @@ export function ThreadComposer({
|
||||
clear();
|
||||
setSlashMenuDismissed(false);
|
||||
resizeTextarea();
|
||||
}, [canSend, clear, onSend, readyImages, resizeTextarea, value]);
|
||||
}, [canSend, clear, imageAspectRatio, imageMode, onSend, readyImages, resizeTextarea, value]);
|
||||
|
||||
const onKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (showSlashMenu) {
|
||||
@@ -312,6 +394,7 @@ export function ThreadComposer({
|
||||
);
|
||||
|
||||
const attachButtonDisabled = disabled || full;
|
||||
const showStopButton = isStreaming && !!onStop;
|
||||
|
||||
return (
|
||||
<form
|
||||
@@ -336,7 +419,7 @@ export function ThreadComposer({
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"relative mx-auto flex w-full flex-col overflow-hidden transition-all duration-200",
|
||||
"relative mx-auto flex w-full flex-col overflow-visible transition-all duration-200",
|
||||
isHero
|
||||
? "max-w-[58rem] rounded-[28px] border border-black/[0.035] bg-card shadow-[0_20px_55px_rgba(15,23,42,0.08)] dark:border-white/[0.06] dark:shadow-[0_24px_55px_rgba(0,0,0,0.34)]"
|
||||
: "max-w-[49.5rem] rounded-[22px] border border-black/[0.035] bg-card shadow-[0_12px_30px_rgba(15,23,42,0.07)] dark:border-white/[0.06] dark:shadow-[0_16px_34px_rgba(0,0,0,0.28)]",
|
||||
@@ -439,6 +522,59 @@ export function ThreadComposer({
|
||||
>
|
||||
<Plus className={cn(isHero ? "h-5 w-5" : "h-4 w-4")} />
|
||||
</Button>
|
||||
<div ref={aspectControlRef} className="relative flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={disabled}
|
||||
aria-pressed={imageMode}
|
||||
aria-label={t("thread.composer.imageMode.toggle")}
|
||||
onClick={() => {
|
||||
setImageMode(!imageMode);
|
||||
setAspectMenuOpen(false);
|
||||
textareaRef.current?.focus();
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-full border border-border/55 px-2.5 font-medium shadow-[0_2px_8px_rgba(15,23,42,0.04)]",
|
||||
isHero ? "h-9 text-[12px]" : "h-7.5 text-[10.5px]",
|
||||
imageMode
|
||||
? "border-primary/30 bg-primary/10 text-primary hover:bg-primary/12"
|
||||
: "bg-card text-muted-foreground hover:bg-card hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<ImageIcon className={cn("mr-1.5", isHero ? "h-4 w-4" : "h-3.5 w-3.5")} />
|
||||
{t("thread.composer.imageMode.label")}
|
||||
</Button>
|
||||
{imageMode ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={disabled}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={aspectMenuOpen}
|
||||
aria-label={t("thread.composer.imageMode.aspectAria")}
|
||||
onClick={() => setAspectMenuOpen((open) => !open)}
|
||||
className={cn(
|
||||
"rounded-full border border-border/55 bg-card px-2.5 font-medium text-foreground/80 shadow-[0_2px_8px_rgba(15,23,42,0.04)] hover:bg-card",
|
||||
isHero ? "h-9 text-[12px]" : "h-7.5 text-[10.5px]",
|
||||
)}
|
||||
>
|
||||
<span>{t(`thread.composer.imageMode.aspect.${imageAspectRatio.replace(":", "_")}`)}</span>
|
||||
<ChevronDown className={cn("ml-1.5", isHero ? "h-3.5 w-3.5" : "h-3 w-3")} />
|
||||
</Button>
|
||||
) : null}
|
||||
{imageMode && aspectMenuOpen ? (
|
||||
<ImageAspectMenu
|
||||
selected={imageAspectRatio}
|
||||
isHero={isHero}
|
||||
onSelect={(ratio) => {
|
||||
setImageAspectRatio(ratio);
|
||||
setAspectMenuOpen(false);
|
||||
textareaRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{modelLabel ? (
|
||||
<span
|
||||
title={modelLabel}
|
||||
@@ -465,19 +601,25 @@ export function ThreadComposer({
|
||||
</div>
|
||||
<span className={cn(isHero ? "hidden" : "sm:hidden")} aria-hidden />
|
||||
<Button
|
||||
type="submit"
|
||||
type={showStopButton ? "button" : "submit"}
|
||||
size="icon"
|
||||
disabled={!canSend}
|
||||
aria-label={t("thread.composer.send")}
|
||||
disabled={showStopButton ? disabled : !canSend}
|
||||
aria-label={showStopButton ? t("thread.composer.stop") : t("thread.composer.send")}
|
||||
onClick={showStopButton ? onStop : undefined}
|
||||
className={cn(
|
||||
isHero
|
||||
? "h-9 w-9 rounded-full border border-foreground bg-foreground text-background shadow-[0_4px_12px_rgba(15,23,42,0.20)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80"
|
||||
: "rounded-full border border-foreground bg-foreground text-background shadow-[0_3px_10px_rgba(15,23,42,0.18)] transition-transform hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80",
|
||||
"rounded-full transition-transform",
|
||||
showStopButton
|
||||
? "border border-border/70 bg-card text-foreground/85 shadow-[0_3px_10px_rgba(15,23,42,0.08)] hover:bg-muted/65 hover:text-foreground disabled:text-muted-foreground/50"
|
||||
: isHero
|
||||
? "border border-foreground bg-foreground text-background shadow-[0_4px_12px_rgba(15,23,42,0.20)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80"
|
||||
: "border border-foreground bg-foreground text-background shadow-[0_3px_10px_rgba(15,23,42,0.18)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80",
|
||||
isHero ? "" : "h-7.5 w-7.5",
|
||||
canSend && "hover:scale-[1.03] active:scale-95",
|
||||
(canSend || showStopButton) && "hover:scale-[1.03] active:scale-95",
|
||||
)}
|
||||
>
|
||||
{isStreaming ? (
|
||||
{showStopButton ? (
|
||||
<Square className={cn("fill-current stroke-current", isHero ? "h-3 w-3" : "h-2.5 w-2.5")} />
|
||||
) : isStreaming ? (
|
||||
<Loader2 className={cn(isHero ? "h-4.5 w-4.5" : "h-4 w-4", "animate-spin")} />
|
||||
) : (
|
||||
<ArrowUp className={cn(isHero ? "h-4.5 w-4.5" : "h-4 w-4")} />
|
||||
@@ -497,6 +639,59 @@ interface SlashCommandPaletteProps {
|
||||
onChoose: (command: SlashCommand) => void;
|
||||
}
|
||||
|
||||
function ImageAspectMenu({
|
||||
selected,
|
||||
isHero,
|
||||
onSelect,
|
||||
}: {
|
||||
selected: ImageAspectRatio;
|
||||
isHero: boolean;
|
||||
onSelect: (ratio: ImageAspectRatio) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label={t("thread.composer.imageMode.aspectAria")}
|
||||
className={cn(
|
||||
"absolute left-0 z-30 w-44 overflow-hidden rounded-[16px] border",
|
||||
isHero ? "top-full mt-2" : "bottom-full mb-2",
|
||||
"border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_16px_45px_rgba(15,23,42,0.16)]",
|
||||
"dark:border-white/10 dark:shadow-[0_18px_45px_rgba(0,0,0,0.42)]",
|
||||
isHero ? "text-[12px]" : "text-[11.5px]",
|
||||
)}
|
||||
>
|
||||
<div className="px-2 pb-1 pt-1 font-medium text-muted-foreground/70">
|
||||
{t("thread.composer.imageMode.aspectLabel")}
|
||||
</div>
|
||||
{IMAGE_ASPECT_RATIOS.map((ratio) => {
|
||||
const label = t(`thread.composer.imageMode.aspect.${ratio.replace(":", "_")}`);
|
||||
return (
|
||||
<button
|
||||
key={ratio}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected === ratio}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect(ratio);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-[11px] px-2.5 py-2 text-left transition-colors",
|
||||
selected === ratio
|
||||
? "bg-primary/10 text-foreground"
|
||||
: "text-foreground/86 hover:bg-accent/55",
|
||||
)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{selected === ratio ? <Check className="h-3.5 w-3.5 text-primary" /> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SlashCommandPalette({
|
||||
commands,
|
||||
selectedIndex,
|
||||
@@ -511,7 +706,7 @@ function SlashCommandPalette({
|
||||
aria-label={t("thread.composer.slash.ariaLabel")}
|
||||
className={cn(
|
||||
"absolute bottom-full left-1/2 z-30 mb-2 max-h-[22rem] w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden rounded-[18px] border",
|
||||
"border-border/65 bg-popover/98 p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)] backdrop-blur",
|
||||
"border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)]",
|
||||
"dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]",
|
||||
isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
|
||||
)}
|
||||
|
||||
@@ -4,9 +4,12 @@ import {
|
||||
BookOpen,
|
||||
ChevronRight,
|
||||
Code2,
|
||||
ImageIcon,
|
||||
LayoutGrid,
|
||||
Lightbulb,
|
||||
MoreHorizontal,
|
||||
Palette,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -15,7 +18,7 @@ import { ThreadComposer } from "@/components/thread/ThreadComposer";
|
||||
import { ThreadHeader } from "@/components/thread/ThreadHeader";
|
||||
import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
|
||||
import { ThreadViewport } from "@/components/thread/ThreadViewport";
|
||||
import { useNanobotStream } from "@/hooks/useNanobotStream";
|
||||
import { useNanobotStream, type SendImage, type SendOptions } from "@/hooks/useNanobotStream";
|
||||
import { useSessionHistory } from "@/hooks/useSessions";
|
||||
import { listSlashCommands } from "@/lib/api";
|
||||
import type { ChatSummary, SlashCommand, UIMessage } from "@/lib/types";
|
||||
@@ -52,6 +55,21 @@ const QUICK_ACTION_KEYS = [
|
||||
{ key: "more", icon: MoreHorizontal, tone: "text-muted-foreground/65" },
|
||||
] as const;
|
||||
|
||||
const IMAGE_QUICK_ACTION_KEYS = [
|
||||
{ key: "icon", icon: ImageIcon, tone: "text-[#4f9de8]" },
|
||||
{ key: "sticker", icon: Sparkles, tone: "text-[#f25b8f]" },
|
||||
{ key: "poster", icon: Palette, tone: "text-[#eba45d]" },
|
||||
{ key: "product", icon: LayoutGrid, tone: "text-[#53c59d]" },
|
||||
{ key: "portrait", icon: ImageIcon, tone: "text-[#a877e7]" },
|
||||
{ key: "edit", icon: MoreHorizontal, tone: "text-muted-foreground/65" },
|
||||
] as const;
|
||||
|
||||
interface PendingFirstMessage {
|
||||
content: string;
|
||||
images?: SendImage[];
|
||||
options?: SendOptions;
|
||||
}
|
||||
|
||||
export function ThreadShell({
|
||||
session,
|
||||
title,
|
||||
@@ -67,10 +85,11 @@ export function ThreadShell({
|
||||
const chatId = session?.chatId ?? null;
|
||||
const historyKey = session?.key ?? null;
|
||||
const { messages: historical, loading, hasPendingToolCalls } = useSessionHistory(historyKey);
|
||||
const { client, modelName, token } = useClient();
|
||||
const { modelName, token } = useClient();
|
||||
const [booting, setBooting] = useState(false);
|
||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
||||
const pendingFirstRef = useRef<string | null>(null);
|
||||
const [heroImageMode, setHeroImageMode] = useState(false);
|
||||
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
|
||||
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
|
||||
const lastCachedChatIdRef = useRef<string | null>(null);
|
||||
|
||||
@@ -82,6 +101,7 @@ export function ThreadShell({
|
||||
messages,
|
||||
isStreaming,
|
||||
send,
|
||||
stop,
|
||||
setMessages,
|
||||
streamError,
|
||||
dismissStreamError,
|
||||
@@ -109,7 +129,11 @@ export function ThreadShell({
|
||||
// When the user switches away and back, keep the local in-memory thread
|
||||
// state (including not-yet-persisted messages) instead of replacing it with
|
||||
// whatever the history endpoint currently knows about.
|
||||
setMessages(cached && cached.length > 0 ? cached : historical);
|
||||
setMessages((prev) => {
|
||||
if (cached && cached.length > 0) return cached;
|
||||
if (historical.length === 0 && prev.length > 0) return prev;
|
||||
return historical;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [loading, chatId, historical]);
|
||||
|
||||
@@ -142,18 +166,9 @@ export function ThreadShell({
|
||||
const pending = pendingFirstRef.current;
|
||||
if (!pending) return;
|
||||
pendingFirstRef.current = null;
|
||||
client.sendMessage(chatId, pending);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: "user",
|
||||
content: pending,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
send(pending.content, pending.images, pending.options);
|
||||
setBooting(false);
|
||||
}, [chatId, client, setMessages]);
|
||||
}, [chatId, send]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -171,10 +186,10 @@ export function ThreadShell({
|
||||
}, [token]);
|
||||
|
||||
const handleWelcomeSend = useCallback(
|
||||
async (content: string) => {
|
||||
async (content: string, images?: SendImage[], options?: SendOptions) => {
|
||||
if (booting) return;
|
||||
setBooting(true);
|
||||
pendingFirstRef.current = content;
|
||||
pendingFirstRef.current = { content, images, options };
|
||||
const newId = await onCreateChat?.();
|
||||
if (!newId) {
|
||||
pendingFirstRef.current = null;
|
||||
@@ -186,20 +201,27 @@ export function ThreadShell({
|
||||
|
||||
const handleQuickAction = useCallback(
|
||||
(prompt: string) => {
|
||||
const options: SendOptions | undefined = heroImageMode
|
||||
? { imageGeneration: { enabled: true, aspect_ratio: null } }
|
||||
: undefined;
|
||||
if (session) {
|
||||
send(prompt);
|
||||
send(prompt, undefined, options);
|
||||
return;
|
||||
}
|
||||
void handleWelcomeSend(prompt);
|
||||
void handleWelcomeSend(prompt, undefined, options);
|
||||
},
|
||||
[handleWelcomeSend, send, session],
|
||||
[handleWelcomeSend, heroImageMode, send, session],
|
||||
);
|
||||
|
||||
const quickActionItems = heroImageMode ? IMAGE_QUICK_ACTION_KEYS : QUICK_ACTION_KEYS;
|
||||
const quickActionPrefix = heroImageMode
|
||||
? "thread.empty.imageQuickActions"
|
||||
: "thread.empty.quickActions";
|
||||
const quickActions = (
|
||||
<div className="mx-auto grid w-full max-w-[58rem] grid-cols-2 gap-3 pt-4 sm:grid-cols-3 lg:grid-cols-6 lg:gap-4">
|
||||
{QUICK_ACTION_KEYS.map(({ key, icon: Icon, tone }) => {
|
||||
const title = t(`thread.empty.quickActions.${key}.title`);
|
||||
const prompt = t(`thread.empty.quickActions.${key}.prompt`);
|
||||
{quickActionItems.map(({ key, icon: Icon, tone }) => {
|
||||
const title = t(`${quickActionPrefix}.${key}.title`);
|
||||
const prompt = t(`${quickActionPrefix}.${key}.prompt`);
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
@@ -247,6 +269,9 @@ export function ThreadShell({
|
||||
modelLabel={toModelBadgeLabel(modelName)}
|
||||
variant={showHeroComposer ? "hero" : "thread"}
|
||||
slashCommands={slashCommands}
|
||||
imageMode={showHeroComposer ? heroImageMode : undefined}
|
||||
onImageModeChange={showHeroComposer ? setHeroImageMode : undefined}
|
||||
onStop={stop}
|
||||
/>
|
||||
) : (
|
||||
<ThreadComposer
|
||||
@@ -260,6 +285,8 @@ export function ThreadShell({
|
||||
}
|
||||
modelLabel={toModelBadgeLabel(modelName)}
|
||||
variant="hero"
|
||||
imageMode={heroImageMode}
|
||||
onImageModeChange={setHeroImageMode}
|
||||
/>
|
||||
)}
|
||||
{showHeroComposer ? quickActions : null}
|
||||
|
||||
Reference in New Issue
Block a user