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
+51
-1
@@ -17,7 +17,7 @@ import {
|
||||
saveSecret,
|
||||
} from "@/lib/bootstrap";
|
||||
import { NanobotClient } from "@/lib/nanobot-client";
|
||||
import { ClientProvider } from "@/providers/ClientProvider";
|
||||
import { ClientProvider, useClient } from "@/providers/ClientProvider";
|
||||
import type { ChatSummary } from "@/lib/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -34,6 +34,7 @@ type BootState =
|
||||
};
|
||||
|
||||
const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
|
||||
const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt";
|
||||
const SIDEBAR_WIDTH = 272;
|
||||
type ShellView = "chat" | "settings";
|
||||
|
||||
@@ -237,6 +238,7 @@ export default function App() {
|
||||
|
||||
function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName: string | null) => void; onLogout: () => void }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { client } = useClient();
|
||||
const { theme, toggle } = useTheme();
|
||||
const { sessions, loading, refresh, createChat, deleteChat } = useSessions();
|
||||
const [activeKey, setActiveKey] = useState<string | null>(null);
|
||||
@@ -249,6 +251,8 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
|
||||
label: string;
|
||||
} | null>(null);
|
||||
const lastSessionsLen = useRef(0);
|
||||
const restartSawDisconnectRef = useRef(false);
|
||||
const [restartToast, setRestartToast] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
@@ -326,6 +330,43 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
|
||||
setMobileSidebarOpen(false);
|
||||
}, []);
|
||||
|
||||
const onRestart = useCallback(() => {
|
||||
const chatId = activeSession?.chatId ?? client.defaultChatId;
|
||||
if (!chatId) return;
|
||||
restartSawDisconnectRef.current = false;
|
||||
try {
|
||||
window.localStorage.setItem(RESTART_STARTED_KEY, String(Date.now()));
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
client.sendMessage(chatId, "/restart");
|
||||
}, [activeSession?.chatId, client]);
|
||||
|
||||
useEffect(() => {
|
||||
return client.onStatus((status) => {
|
||||
let startedAt = 0;
|
||||
try {
|
||||
startedAt = Number(window.localStorage.getItem(RESTART_STARTED_KEY) ?? "0");
|
||||
} catch {
|
||||
startedAt = 0;
|
||||
}
|
||||
if (!startedAt) return;
|
||||
if (status !== "open") {
|
||||
restartSawDisconnectRef.current = true;
|
||||
return;
|
||||
}
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
if (!restartSawDisconnectRef.current && elapsedMs < 1500) return;
|
||||
try {
|
||||
window.localStorage.removeItem(RESTART_STARTED_KEY);
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
setRestartToast(t("app.restart.completed", { seconds: (elapsedMs / 1000).toFixed(1) }));
|
||||
window.setTimeout(() => setRestartToast(null), 3_500);
|
||||
});
|
||||
}, [client, t]);
|
||||
|
||||
const onTurnEnd = useCallback(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
@@ -414,6 +455,7 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
|
||||
onBackToChat={() => setView("chat")}
|
||||
onModelNameChange={onModelNameChange}
|
||||
onLogout={onLogout}
|
||||
onRestart={onRestart}
|
||||
/>
|
||||
) : (
|
||||
<ThreadShell
|
||||
@@ -437,6 +479,14 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
onConfirm={onConfirmDelete}
|
||||
/>
|
||||
{restartToast ? (
|
||||
<div
|
||||
role="status"
|
||||
className="fixed left-1/2 top-4 z-50 -translate-x-1/2 rounded-full border border-border/70 bg-popover px-4 py-2 text-sm font-medium text-popover-foreground shadow-lg"
|
||||
>
|
||||
{restartToast}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { toMediaAttachment } from "@/lib/media";
|
||||
import type { StreamError } from "@/lib/nanobot-client";
|
||||
import type {
|
||||
InboundEvent,
|
||||
OutboundImageGeneration,
|
||||
OutboundMedia,
|
||||
UIImage,
|
||||
UIMessage,
|
||||
@@ -34,6 +35,10 @@ export interface SendImage {
|
||||
preview: UIImage;
|
||||
}
|
||||
|
||||
export interface SendOptions {
|
||||
imageGeneration?: OutboundImageGeneration;
|
||||
}
|
||||
|
||||
export function useNanobotStream(
|
||||
chatId: string | null,
|
||||
initialMessages: UIMessage[] = [],
|
||||
@@ -42,7 +47,8 @@ export function useNanobotStream(
|
||||
): {
|
||||
messages: UIMessage[];
|
||||
isStreaming: boolean;
|
||||
send: (content: string, images?: SendImage[]) => void;
|
||||
send: (content: string, images?: SendImage[], options?: SendOptions) => void;
|
||||
stop: () => void;
|
||||
setMessages: React.Dispatch<React.SetStateAction<UIMessage[]>>;
|
||||
/** Latest transport-level fault raised since the last ``dismissStreamError``.
|
||||
* ``null`` when there is nothing to show. */
|
||||
@@ -62,6 +68,7 @@ export function useNanobotStream(
|
||||
const [isStreaming, setIsStreaming] = useState(initialStreaming || hasPendingToolCalls);
|
||||
const [streamError, setStreamError] = useState<StreamError | null>(null);
|
||||
const buffer = useRef<StreamBuffer | null>(null);
|
||||
const suppressStreamUntilTurnEndRef = useRef(false);
|
||||
/** Timer that defers ``isStreaming = false`` after ``stream_end``.
|
||||
*
|
||||
* When the model finishes a text segment and calls a tool, the server
|
||||
@@ -77,31 +84,29 @@ export function useNanobotStream(
|
||||
|
||||
const dismissStreamError = useCallback(() => setStreamError(null), []);
|
||||
|
||||
// Reset local state when switching chats. ``streamError`` is scoped to the
|
||||
// send that triggered it, so a chat swap should wipe it out: a stale
|
||||
// "Message too large" banner on a freshly-opened chat-B would confuse the
|
||||
// user about which send actually failed (and in which chat).
|
||||
useEffect(() => {
|
||||
setMessages(initialMessages);
|
||||
// Check if the new chat's last message is a trace row — if so, the
|
||||
// model may still be processing.
|
||||
setIsStreaming(
|
||||
initialMessages.length > 0
|
||||
? initialMessages[initialMessages.length - 1].kind === "trace"
|
||||
: false,
|
||||
);
|
||||
// Also consider hasPendingToolCalls from session history.
|
||||
if (hasPendingToolCalls) {
|
||||
setIsStreaming(true);
|
||||
}
|
||||
setStreamError(null);
|
||||
buffer.current = null;
|
||||
if (streamEndTimerRef.current !== null) {
|
||||
clearTimeout(streamEndTimerRef.current);
|
||||
streamEndTimerRef.current = null;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chatId, initialMessages, hasPendingToolCalls]);
|
||||
// Reset local state when switching chats. Do not reset on every
|
||||
// ``initialMessages`` update: a brand-new chat can receive an empty/404
|
||||
// history response after the optimistic first message has already rendered.
|
||||
useEffect(() => {
|
||||
setMessages(initialMessages);
|
||||
setIsStreaming(
|
||||
(initialMessages.length > 0
|
||||
? initialMessages[initialMessages.length - 1].kind === "trace"
|
||||
: false) || hasPendingToolCalls,
|
||||
);
|
||||
setStreamError(null);
|
||||
buffer.current = null;
|
||||
suppressStreamUntilTurnEndRef.current = false;
|
||||
if (streamEndTimerRef.current !== null) {
|
||||
clearTimeout(streamEndTimerRef.current);
|
||||
streamEndTimerRef.current = null;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chatId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasPendingToolCalls) setIsStreaming(true);
|
||||
}, [hasPendingToolCalls]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatId) return;
|
||||
@@ -116,6 +121,7 @@ export function useNanobotStream(
|
||||
}
|
||||
|
||||
if (ev.event === "delta") {
|
||||
if (suppressStreamUntilTurnEndRef.current) return;
|
||||
const id = buffer.current?.messageId ?? crypto.randomUUID();
|
||||
if (!buffer.current) {
|
||||
buffer.current = { messageId: id, parts: [] };
|
||||
@@ -141,6 +147,10 @@ export function useNanobotStream(
|
||||
}
|
||||
|
||||
if (ev.event === "stream_end") {
|
||||
if (suppressStreamUntilTurnEndRef.current) {
|
||||
buffer.current = null;
|
||||
return;
|
||||
}
|
||||
// stream_end only means the text segment finished — the model may
|
||||
// still be executing tools. Do NOT reset isStreaming here; the
|
||||
// definitive "turn is complete" signal is ``turn_end``.
|
||||
@@ -160,6 +170,7 @@ export function useNanobotStream(
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)),
|
||||
);
|
||||
suppressStreamUntilTurnEndRef.current = false;
|
||||
onTurnEnd?.();
|
||||
return;
|
||||
}
|
||||
@@ -170,6 +181,12 @@ export function useNanobotStream(
|
||||
}
|
||||
|
||||
if (ev.event === "message") {
|
||||
if (
|
||||
suppressStreamUntilTurnEndRef.current &&
|
||||
(ev.kind === "tool_hint" || ev.kind === "progress")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Intermediate agent breadcrumbs (tool-call hints, raw progress).
|
||||
// Attach them to the last trace row if it was the last emitted item
|
||||
// so a sequence of calls collapses into one compact trace group.
|
||||
@@ -203,6 +220,7 @@ export function useNanobotStream(
|
||||
const media = ev.media_urls?.length
|
||||
? ev.media_urls.map((m) => toMediaAttachment(m))
|
||||
: ev.media?.map((url) => toMediaAttachment({ url }));
|
||||
const hasMedia = !!media && media.length > 0;
|
||||
|
||||
// A complete (non-streamed) assistant message. If a stream was in
|
||||
// flight, drop the placeholder so we don't render the text twice.
|
||||
@@ -221,10 +239,13 @@ export function useNanobotStream(
|
||||
content,
|
||||
createdAt: Date.now(),
|
||||
...(ev.buttons && ev.buttons.length > 0 ? { buttons: ev.buttons } : {}),
|
||||
...(media && media.length > 0 ? { media } : {}),
|
||||
...(hasMedia ? { media } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
if (hasMedia) {
|
||||
suppressStreamUntilTurnEndRef.current = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// ``attached`` / ``error`` frames aren't actionable here; the client
|
||||
@@ -243,7 +264,7 @@ export function useNanobotStream(
|
||||
}, [chatId, client, onTurnEnd]);
|
||||
|
||||
const send = useCallback(
|
||||
(content: string, images?: SendImage[]) => {
|
||||
(content: string, images?: SendImage[], options?: SendOptions) => {
|
||||
if (!chatId) return;
|
||||
const hasImages = !!images && images.length > 0;
|
||||
// Text is optional when images are attached — the agent will still see
|
||||
@@ -265,15 +286,30 @@ export function useNanobotStream(
|
||||
// right away, before the first delta arrives from the server.
|
||||
setIsStreaming(true);
|
||||
const wireMedia = hasImages ? images!.map((i) => i.media) : undefined;
|
||||
client.sendMessage(chatId, content, wireMedia);
|
||||
if (options) {
|
||||
client.sendMessage(chatId, content, wireMedia, options);
|
||||
} else {
|
||||
client.sendMessage(chatId, content, wireMedia);
|
||||
}
|
||||
},
|
||||
[chatId, client],
|
||||
);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (!chatId) return;
|
||||
setIsStreaming(false);
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)),
|
||||
);
|
||||
suppressStreamUntilTurnEndRef.current = false;
|
||||
client.sendMessage(chatId, "/stop");
|
||||
}, [chatId, client]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
isStreaming,
|
||||
send,
|
||||
stop,
|
||||
setMessages,
|
||||
streamError,
|
||||
dismissStreamError,
|
||||
|
||||
@@ -21,6 +21,14 @@
|
||||
"logoutHint": "Disconnect this browser from the gateway.",
|
||||
"logout": "Sign out"
|
||||
},
|
||||
"system": {
|
||||
"section": "System",
|
||||
"restartHint": "Restart nanobot to apply runtime changes.",
|
||||
"restart": "Restart nanobot"
|
||||
},
|
||||
"restart": {
|
||||
"completed": "Restart completed in {{seconds}}s."
|
||||
},
|
||||
"documentTitle": {
|
||||
"base": "nanobot",
|
||||
"chat": "{{title}} · nanobot"
|
||||
@@ -104,6 +112,32 @@
|
||||
"title": "More",
|
||||
"prompt": "Show me a few useful ways you can help in this workspace."
|
||||
}
|
||||
},
|
||||
"imageQuickActions": {
|
||||
"icon": {
|
||||
"title": "Design an app icon",
|
||||
"prompt": "Generate a clean 1:1 app icon for nanobot: friendly robot, simple vector style, soft blue and white palette, no text."
|
||||
},
|
||||
"sticker": {
|
||||
"title": "Make a sticker",
|
||||
"prompt": "Generate a cute sticker-style image of a tiny robot assistant, transparent-looking background, expressive and playful."
|
||||
},
|
||||
"poster": {
|
||||
"title": "Create a poster",
|
||||
"prompt": "Generate a polished poster concept for a personal AI assistant, modern composition, strong visual hierarchy, suitable for a landing page."
|
||||
},
|
||||
"product": {
|
||||
"title": "Product mockup",
|
||||
"prompt": "Generate a clean product mockup image for a conversational AI web app, minimal interface, premium lighting, realistic device frame."
|
||||
},
|
||||
"portrait": {
|
||||
"title": "Stylized portrait",
|
||||
"prompt": "Generate a stylized portrait of a friendly AI companion, soft lighting, detailed but approachable, modern illustration style."
|
||||
},
|
||||
"edit": {
|
||||
"title": "Edit an image",
|
||||
"prompt": "Help me edit an image. Ask me to upload or reference the image first, then generate the edited result."
|
||||
}
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -120,7 +154,23 @@
|
||||
"inputAria": "Message input",
|
||||
"sendHint": "Enter to send · Shift+Enter for newline",
|
||||
"send": "Send message",
|
||||
"stop": "Stop response",
|
||||
"attachImage": "Attach image",
|
||||
"imageMode": {
|
||||
"label": "Image Generation",
|
||||
"toggle": "Toggle image generation mode",
|
||||
"placeholder": "Describe or edit an image…",
|
||||
"aspectAria": "Image aspect ratio",
|
||||
"aspectLabel": "Image aspect",
|
||||
"aspect": {
|
||||
"auto": "Auto",
|
||||
"1_1": "Square 1:1",
|
||||
"3_4": "Portrait 3:4",
|
||||
"9_16": "Story 9:16",
|
||||
"4_3": "Landscape 4:3",
|
||||
"16_9": "Wide 16:9"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"search": "Search",
|
||||
"reason": "Reason",
|
||||
|
||||
@@ -9,6 +9,14 @@
|
||||
"title": "No se pudo conectar con nanobot",
|
||||
"gatewayHint": "Asegúrate de que la gateway esté en ejecución (`nanobot gateway`) y de que esta página esté abierta en la misma máquina."
|
||||
},
|
||||
"system": {
|
||||
"section": "Sistema",
|
||||
"restartHint": "Reinicia nanobot para aplicar los cambios de ejecución.",
|
||||
"restart": "Reiniciar nanobot"
|
||||
},
|
||||
"restart": {
|
||||
"completed": "Reinicio completado en {{seconds}} s."
|
||||
},
|
||||
"documentTitle": {
|
||||
"base": "nanobot",
|
||||
"chat": "{{title}} · nanobot"
|
||||
@@ -80,6 +88,32 @@
|
||||
"title": "Más",
|
||||
"prompt": "Muéstrame algunas formas útiles en las que puedes ayudar en este workspace."
|
||||
}
|
||||
},
|
||||
"imageQuickActions": {
|
||||
"icon": {
|
||||
"title": "Diseñar un icono de app",
|
||||
"prompt": "Genera un icono de app 1:1 limpio para nanobot: robot amigable, estilo vectorial simple, paleta suave azul y blanca, sin texto."
|
||||
},
|
||||
"sticker": {
|
||||
"title": "Crear un sticker",
|
||||
"prompt": "Genera una imagen estilo sticker de un pequeño asistente robot, con fondo de apariencia transparente, expresivo y divertido."
|
||||
},
|
||||
"poster": {
|
||||
"title": "Crear un póster",
|
||||
"prompt": "Genera un concepto de póster pulido para un asistente personal de IA, composición moderna, jerarquía visual fuerte, apto para una landing page."
|
||||
},
|
||||
"product": {
|
||||
"title": "Mockup de producto",
|
||||
"prompt": "Genera una imagen limpia de mockup de producto para una app web de IA conversacional, interfaz mínima, iluminación premium, marco de dispositivo realista."
|
||||
},
|
||||
"portrait": {
|
||||
"title": "Retrato estilizado",
|
||||
"prompt": "Genera un retrato estilizado de un compañero de IA amigable, luz suave, detallado pero cercano, estilo de ilustración moderna."
|
||||
},
|
||||
"edit": {
|
||||
"title": "Editar una imagen",
|
||||
"prompt": "Ayúdame a editar una imagen. Primero pídeme que suba o indique la imagen, y luego genera el resultado editado."
|
||||
}
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -93,7 +127,23 @@
|
||||
"inputAria": "Entrada de mensaje",
|
||||
"sendHint": "Enter para enviar · Shift+Enter para nueva línea",
|
||||
"send": "Enviar mensaje",
|
||||
"stop": "Detener respuesta",
|
||||
"attachImage": "Adjuntar imagen",
|
||||
"imageMode": {
|
||||
"label": "Generar imagen",
|
||||
"toggle": "Activar o desactivar modo de generación de imágenes",
|
||||
"placeholder": "Describe o edita una imagen…",
|
||||
"aspectAria": "Relación de aspecto de imagen",
|
||||
"aspectLabel": "Formato de imagen",
|
||||
"aspect": {
|
||||
"auto": "Auto",
|
||||
"1_1": "Cuadrado 1:1",
|
||||
"3_4": "Vertical 3:4",
|
||||
"9_16": "Historia 9:16",
|
||||
"4_3": "Horizontal 4:3",
|
||||
"16_9": "Panorámico 16:9"
|
||||
}
|
||||
},
|
||||
"encoding": "Procesando…",
|
||||
"remove": "Quitar adjunto",
|
||||
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
|
||||
|
||||
@@ -9,6 +9,14 @@
|
||||
"title": "Impossible de joindre nanobot",
|
||||
"gatewayHint": "Assurez-vous que la gateway est en cours d’exécution (`nanobot gateway`) et que cette page est ouverte sur la même machine."
|
||||
},
|
||||
"system": {
|
||||
"section": "Système",
|
||||
"restartHint": "Redémarrez nanobot pour appliquer les changements d’exécution.",
|
||||
"restart": "Redémarrer nanobot"
|
||||
},
|
||||
"restart": {
|
||||
"completed": "Redémarrage terminé en {{seconds}} s."
|
||||
},
|
||||
"documentTitle": {
|
||||
"base": "nanobot",
|
||||
"chat": "{{title}} · nanobot"
|
||||
@@ -80,6 +88,32 @@
|
||||
"title": "Plus",
|
||||
"prompt": "Montrez-moi quelques façons utiles dont vous pouvez m’aider dans cet espace de travail."
|
||||
}
|
||||
},
|
||||
"imageQuickActions": {
|
||||
"icon": {
|
||||
"title": "Créer une icône d’app",
|
||||
"prompt": "Générez une icône d’application 1:1 propre pour nanobot : robot sympathique, style vectoriel simple, palette douce bleu et blanc, sans texte."
|
||||
},
|
||||
"sticker": {
|
||||
"title": "Créer un sticker",
|
||||
"prompt": "Générez une image façon sticker d’un petit assistant robot, avec un fond d’apparence transparente, expressive et ludique."
|
||||
},
|
||||
"poster": {
|
||||
"title": "Créer une affiche",
|
||||
"prompt": "Générez un concept d’affiche soigné pour un assistant IA personnel, composition moderne, hiérarchie visuelle forte, adapté à une landing page."
|
||||
},
|
||||
"product": {
|
||||
"title": "Maquette produit",
|
||||
"prompt": "Générez une maquette produit propre pour une application web d’IA conversationnelle, interface minimale, éclairage premium, cadre d’appareil réaliste."
|
||||
},
|
||||
"portrait": {
|
||||
"title": "Portrait stylisé",
|
||||
"prompt": "Générez un portrait stylisé d’un compagnon IA sympathique, lumière douce, détaillé mais accessible, style illustration moderne."
|
||||
},
|
||||
"edit": {
|
||||
"title": "Modifier une image",
|
||||
"prompt": "Aidez-moi à modifier une image. Demandez-moi d’abord de téléverser ou d’indiquer l’image, puis générez le résultat modifié."
|
||||
}
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -93,7 +127,23 @@
|
||||
"inputAria": "Champ de message",
|
||||
"sendHint": "Entrée pour envoyer · Maj+Entrée pour un retour à la ligne",
|
||||
"send": "Envoyer le message",
|
||||
"stop": "Arrêter la réponse",
|
||||
"attachImage": "Joindre une image",
|
||||
"imageMode": {
|
||||
"label": "Génération d’image",
|
||||
"toggle": "Activer ou désactiver le mode génération d’image",
|
||||
"placeholder": "Décrire ou modifier une image…",
|
||||
"aspectAria": "Format de l’image",
|
||||
"aspectLabel": "Format de l’image",
|
||||
"aspect": {
|
||||
"auto": "Auto",
|
||||
"1_1": "Carré 1:1",
|
||||
"3_4": "Portrait 3:4",
|
||||
"9_16": "Story 9:16",
|
||||
"4_3": "Paysage 4:3",
|
||||
"16_9": "Large 16:9"
|
||||
}
|
||||
},
|
||||
"encoding": "Traitement…",
|
||||
"remove": "Retirer la pièce jointe",
|
||||
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
|
||||
|
||||
@@ -9,6 +9,14 @@
|
||||
"title": "Tidak dapat menjangkau nanobot",
|
||||
"gatewayHint": "Pastikan gateway sedang berjalan (`nanobot gateway`) dan halaman ini dibuka pada mesin yang sama."
|
||||
},
|
||||
"system": {
|
||||
"section": "Sistem",
|
||||
"restartHint": "Mulai ulang nanobot untuk menerapkan perubahan runtime.",
|
||||
"restart": "Mulai ulang nanobot"
|
||||
},
|
||||
"restart": {
|
||||
"completed": "Mulai ulang selesai dalam {{seconds}} dtk."
|
||||
},
|
||||
"documentTitle": {
|
||||
"base": "nanobot",
|
||||
"chat": "{{title}} · nanobot"
|
||||
@@ -80,6 +88,32 @@
|
||||
"title": "Lainnya",
|
||||
"prompt": "Tunjukkan beberapa cara berguna Anda dapat membantu di workspace ini."
|
||||
}
|
||||
},
|
||||
"imageQuickActions": {
|
||||
"icon": {
|
||||
"title": "Desain ikon aplikasi",
|
||||
"prompt": "Buat ikon aplikasi 1:1 yang bersih untuk nanobot: robot ramah, gaya vektor sederhana, palet biru dan putih lembut, tanpa teks."
|
||||
},
|
||||
"sticker": {
|
||||
"title": "Buat stiker",
|
||||
"prompt": "Buat gambar gaya stiker yang lucu dari asisten robot kecil, latar terlihat transparan, ekspresif dan menyenangkan."
|
||||
},
|
||||
"poster": {
|
||||
"title": "Buat poster",
|
||||
"prompt": "Buat konsep poster yang rapi untuk asisten AI pribadi, komposisi modern, hierarki visual kuat, cocok untuk landing page."
|
||||
},
|
||||
"product": {
|
||||
"title": "Mockup produk",
|
||||
"prompt": "Buat gambar mockup produk yang bersih untuk aplikasi web AI percakapan, antarmuka minimal, pencahayaan premium, bingkai perangkat realistis."
|
||||
},
|
||||
"portrait": {
|
||||
"title": "Potret bergaya",
|
||||
"prompt": "Buat potret bergaya dari pendamping AI yang ramah, pencahayaan lembut, detail tetapi tetap mudah didekati, gaya ilustrasi modern."
|
||||
},
|
||||
"edit": {
|
||||
"title": "Edit gambar",
|
||||
"prompt": "Bantu saya mengedit gambar. Minta saya mengunggah atau menyebutkan gambar terlebih dahulu, lalu buat hasil editnya."
|
||||
}
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -93,7 +127,23 @@
|
||||
"inputAria": "Input pesan",
|
||||
"sendHint": "Enter untuk kirim · Shift+Enter untuk baris baru",
|
||||
"send": "Kirim pesan",
|
||||
"stop": "Hentikan respons",
|
||||
"attachImage": "Lampirkan gambar",
|
||||
"imageMode": {
|
||||
"label": "Buat gambar",
|
||||
"toggle": "Alihkan mode pembuatan gambar",
|
||||
"placeholder": "Deskripsikan atau edit gambar…",
|
||||
"aspectAria": "Rasio aspek gambar",
|
||||
"aspectLabel": "Rasio gambar",
|
||||
"aspect": {
|
||||
"auto": "Otomatis",
|
||||
"1_1": "Persegi 1:1",
|
||||
"3_4": "Potret 3:4",
|
||||
"9_16": "Story 9:16",
|
||||
"4_3": "Lanskap 4:3",
|
||||
"16_9": "Lebar 16:9"
|
||||
}
|
||||
},
|
||||
"encoding": "Memproses…",
|
||||
"remove": "Hapus lampiran",
|
||||
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
|
||||
|
||||
@@ -9,6 +9,14 @@
|
||||
"title": "nanobot に接続できませんでした",
|
||||
"gatewayHint": "gateway(`nanobot gateway`)が起動しており、このページが同じマシン上で開かれていることを確認してください。"
|
||||
},
|
||||
"system": {
|
||||
"section": "システム",
|
||||
"restartHint": "実行時の変更を適用するには nanobot を再起動します。",
|
||||
"restart": "nanobot を再起動"
|
||||
},
|
||||
"restart": {
|
||||
"completed": "{{seconds}} 秒で再起動が完了しました。"
|
||||
},
|
||||
"documentTitle": {
|
||||
"base": "nanobot",
|
||||
"chat": "{{title}} · nanobot"
|
||||
@@ -80,6 +88,32 @@
|
||||
"title": "その他",
|
||||
"prompt": "このワークスペースであなたが手伝える便利な方法をいくつか見せてください。"
|
||||
}
|
||||
},
|
||||
"imageQuickActions": {
|
||||
"icon": {
|
||||
"title": "アプリアイコンを作る",
|
||||
"prompt": "nanobot のクリーンな 1:1 アプリアイコンを生成してください。親しみやすいロボット、シンプルなベクター風、柔らかい青と白の配色、文字なし。"
|
||||
},
|
||||
"sticker": {
|
||||
"title": "ステッカーを作る",
|
||||
"prompt": "小さなロボットアシスタントのかわいいステッカー風画像を生成してください。透明風の背景で、表情豊かで遊び心のある雰囲気。"
|
||||
},
|
||||
"poster": {
|
||||
"title": "ポスターを作る",
|
||||
"prompt": "個人向け AI アシスタントの洗練されたポスター案を生成してください。モダンな構図、強い視覚階層、ランディングページ向け。"
|
||||
},
|
||||
"product": {
|
||||
"title": "製品モックアップ",
|
||||
"prompt": "会話型 AI Web アプリのクリーンな製品モックアップ画像を生成してください。ミニマルな UI、上質なライティング、リアルなデバイスフレーム。"
|
||||
},
|
||||
"portrait": {
|
||||
"title": "スタイル付きポートレート",
|
||||
"prompt": "親しみやすい AI コンパニオンのスタイル付きポートレートを生成してください。柔らかい光、細部は豊かで近づきやすい、モダンなイラスト風。"
|
||||
},
|
||||
"edit": {
|
||||
"title": "画像を編集",
|
||||
"prompt": "画像編集を手伝ってください。まず編集する画像のアップロードまたは指定を求め、その後に編集後の結果を生成してください。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -93,7 +127,23 @@
|
||||
"inputAria": "メッセージ入力欄",
|
||||
"sendHint": "Enter で送信 · Shift+Enter で改行",
|
||||
"send": "メッセージを送信",
|
||||
"stop": "応答を停止",
|
||||
"attachImage": "画像を添付",
|
||||
"imageMode": {
|
||||
"label": "画像生成",
|
||||
"toggle": "画像生成モードを切り替え",
|
||||
"placeholder": "画像を説明または編集…",
|
||||
"aspectAria": "画像のアスペクト比",
|
||||
"aspectLabel": "画像の比率",
|
||||
"aspect": {
|
||||
"auto": "自動",
|
||||
"1_1": "正方形 1:1",
|
||||
"3_4": "縦長 3:4",
|
||||
"9_16": "ストーリー 9:16",
|
||||
"4_3": "横長 4:3",
|
||||
"16_9": "ワイド 16:9"
|
||||
}
|
||||
},
|
||||
"encoding": "処理中…",
|
||||
"remove": "添付を削除",
|
||||
"normalizedSizeHint": "{{orig}} → {{current}}(自動圧縮)",
|
||||
|
||||
@@ -9,6 +9,14 @@
|
||||
"title": "nanobot에 연결할 수 없습니다",
|
||||
"gatewayHint": "gateway(`nanobot gateway`)가 실행 중인지, 그리고 이 페이지가 같은 머신에서 열려 있는지 확인하세요."
|
||||
},
|
||||
"system": {
|
||||
"section": "시스템",
|
||||
"restartHint": "런타임 변경 사항을 적용하려면 nanobot을 다시 시작하세요.",
|
||||
"restart": "nanobot 다시 시작"
|
||||
},
|
||||
"restart": {
|
||||
"completed": "{{seconds}}초 만에 다시 시작되었습니다."
|
||||
},
|
||||
"documentTitle": {
|
||||
"base": "nanobot",
|
||||
"chat": "{{title}} · nanobot"
|
||||
@@ -80,6 +88,32 @@
|
||||
"title": "더 보기",
|
||||
"prompt": "이 워크스페이스에서 도와줄 수 있는 유용한 방법을 몇 가지 보여 주세요."
|
||||
}
|
||||
},
|
||||
"imageQuickActions": {
|
||||
"icon": {
|
||||
"title": "앱 아이콘 디자인",
|
||||
"prompt": "nanobot을 위한 깔끔한 1:1 앱 아이콘을 생성해 주세요. 친근한 로봇, 단순한 벡터 스타일, 부드러운 파란색과 흰색 팔레트, 텍스트 없음."
|
||||
},
|
||||
"sticker": {
|
||||
"title": "스티커 만들기",
|
||||
"prompt": "작은 로봇 도우미의 귀여운 스티커 스타일 이미지를 생성해 주세요. 투명해 보이는 배경, 표정이 풍부하고 장난스러운 느낌."
|
||||
},
|
||||
"poster": {
|
||||
"title": "포스터 만들기",
|
||||
"prompt": "개인 AI 도우미를 위한 세련된 포스터 콘셉트를 생성해 주세요. 현대적인 구성, 강한 시각적 계층, 랜딩 페이지에 어울리는 스타일."
|
||||
},
|
||||
"product": {
|
||||
"title": "제품 목업",
|
||||
"prompt": "대화형 AI 웹 앱을 위한 깔끔한 제품 목업 이미지를 생성해 주세요. 미니멀한 인터페이스, 고급스러운 조명, 현실적인 기기 프레임."
|
||||
},
|
||||
"portrait": {
|
||||
"title": "스타일화된 초상화",
|
||||
"prompt": "친근한 AI 동반자의 스타일화된 초상화를 생성해 주세요. 부드러운 조명, 세밀하지만 다가가기 쉬운 분위기, 현대적인 일러스트 스타일."
|
||||
},
|
||||
"edit": {
|
||||
"title": "이미지 편집",
|
||||
"prompt": "이미지 편집을 도와주세요. 먼저 편집할 이미지를 업로드하거나 지정하게 한 뒤, 편집된 결과를 생성해 주세요."
|
||||
}
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -93,7 +127,23 @@
|
||||
"inputAria": "메시지 입력",
|
||||
"sendHint": "Enter로 전송 · Shift+Enter로 줄바꿈",
|
||||
"send": "메시지 보내기",
|
||||
"stop": "응답 중지",
|
||||
"attachImage": "이미지 첨부",
|
||||
"imageMode": {
|
||||
"label": "이미지 생성",
|
||||
"toggle": "이미지 생성 모드 전환",
|
||||
"placeholder": "이미지를 설명하거나 편집하세요…",
|
||||
"aspectAria": "이미지 화면 비율",
|
||||
"aspectLabel": "이미지 비율",
|
||||
"aspect": {
|
||||
"auto": "자동",
|
||||
"1_1": "정사각형 1:1",
|
||||
"3_4": "세로 3:4",
|
||||
"9_16": "스토리 9:16",
|
||||
"4_3": "가로 4:3",
|
||||
"16_9": "와이드 16:9"
|
||||
}
|
||||
},
|
||||
"encoding": "처리 중…",
|
||||
"remove": "첨부 제거",
|
||||
"normalizedSizeHint": "{{orig}} → {{current}} (자동 압축)",
|
||||
|
||||
@@ -9,6 +9,14 @@
|
||||
"title": "Không thể kết nối tới nanobot",
|
||||
"gatewayHint": "Hãy chắc chắn gateway đang chạy (`nanobot gateway`) và trang này được mở trên cùng máy."
|
||||
},
|
||||
"system": {
|
||||
"section": "Hệ thống",
|
||||
"restartHint": "Khởi động lại nanobot để áp dụng thay đổi runtime.",
|
||||
"restart": "Khởi động lại nanobot"
|
||||
},
|
||||
"restart": {
|
||||
"completed": "Khởi động lại hoàn tất sau {{seconds}} giây."
|
||||
},
|
||||
"documentTitle": {
|
||||
"base": "nanobot",
|
||||
"chat": "{{title}} · nanobot"
|
||||
@@ -80,6 +88,32 @@
|
||||
"title": "Thêm",
|
||||
"prompt": "Cho tôi xem vài cách hữu ích mà bạn có thể giúp trong workspace này."
|
||||
}
|
||||
},
|
||||
"imageQuickActions": {
|
||||
"icon": {
|
||||
"title": "Thiết kế biểu tượng app",
|
||||
"prompt": "Tạo một biểu tượng ứng dụng 1:1 gọn gàng cho nanobot: robot thân thiện, phong cách vector đơn giản, bảng màu xanh trắng dịu, không có chữ."
|
||||
},
|
||||
"sticker": {
|
||||
"title": "Tạo sticker",
|
||||
"prompt": "Tạo một hình kiểu sticker dễ thương của trợ lý robot nhỏ, nền trông như trong suốt, biểu cảm và vui nhộn."
|
||||
},
|
||||
"poster": {
|
||||
"title": "Tạo poster",
|
||||
"prompt": "Tạo một ý tưởng poster chỉn chu cho trợ lý AI cá nhân, bố cục hiện đại, phân cấp thị giác rõ, phù hợp cho landing page."
|
||||
},
|
||||
"product": {
|
||||
"title": "Mockup sản phẩm",
|
||||
"prompt": "Tạo một hình mockup sản phẩm gọn gàng cho ứng dụng web AI hội thoại, giao diện tối giản, ánh sáng cao cấp, khung thiết bị chân thực."
|
||||
},
|
||||
"portrait": {
|
||||
"title": "Chân dung cách điệu",
|
||||
"prompt": "Tạo chân dung cách điệu của một người bạn đồng hành AI thân thiện, ánh sáng mềm, chi tiết nhưng dễ gần, phong cách minh họa hiện đại."
|
||||
},
|
||||
"edit": {
|
||||
"title": "Chỉnh sửa ảnh",
|
||||
"prompt": "Giúp tôi chỉnh sửa một ảnh. Trước tiên hãy yêu cầu tôi tải lên hoặc chỉ định ảnh, rồi tạo kết quả đã chỉnh sửa."
|
||||
}
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -93,7 +127,23 @@
|
||||
"inputAria": "Ô nhập tin nhắn",
|
||||
"sendHint": "Enter để gửi · Shift+Enter để xuống dòng",
|
||||
"send": "Gửi tin nhắn",
|
||||
"stop": "Dừng phản hồi",
|
||||
"attachImage": "Đính kèm ảnh",
|
||||
"imageMode": {
|
||||
"label": "Tạo ảnh",
|
||||
"toggle": "Bật/tắt chế độ tạo ảnh",
|
||||
"placeholder": "Mô tả hoặc chỉnh sửa ảnh…",
|
||||
"aspectAria": "Tỷ lệ khung hình ảnh",
|
||||
"aspectLabel": "Tỷ lệ ảnh",
|
||||
"aspect": {
|
||||
"auto": "Tự động",
|
||||
"1_1": "Vuông 1:1",
|
||||
"3_4": "Dọc 3:4",
|
||||
"9_16": "Story 9:16",
|
||||
"4_3": "Ngang 4:3",
|
||||
"16_9": "Rộng 16:9"
|
||||
}
|
||||
},
|
||||
"encoding": "Đang xử lý…",
|
||||
"remove": "Xóa tệp đính kèm",
|
||||
"normalizedSizeHint": "{{orig}} → {{current}} (tự động)",
|
||||
|
||||
@@ -9,6 +9,14 @@
|
||||
"title": "无法连接到 nanobot",
|
||||
"gatewayHint": "请确认 gateway 已启动(`nanobot gateway`),并且当前页面与 gateway 运行在同一台机器上。"
|
||||
},
|
||||
"system": {
|
||||
"section": "系统",
|
||||
"restartHint": "重启 nanobot 以应用运行时更改。",
|
||||
"restart": "重启 nanobot"
|
||||
},
|
||||
"restart": {
|
||||
"completed": "重启已完成,用时 {{seconds}} 秒。"
|
||||
},
|
||||
"documentTitle": {
|
||||
"base": "nanobot",
|
||||
"chat": "{{title}} · nanobot"
|
||||
@@ -92,6 +100,32 @@
|
||||
"title": "更多",
|
||||
"prompt": "展示几个你在这个工作区里可以帮我的实用方式。"
|
||||
}
|
||||
},
|
||||
"imageQuickActions": {
|
||||
"icon": {
|
||||
"title": "设计应用图标",
|
||||
"prompt": "生成一个干净的 1:1 nanobot 应用图标:友好的机器人,简洁矢量风格,蓝白柔和配色,不要文字。"
|
||||
},
|
||||
"sticker": {
|
||||
"title": "制作贴纸",
|
||||
"prompt": "生成一张可爱的贴纸风小机器人助手图片,背景像透明贴纸,表情活泼有趣。"
|
||||
},
|
||||
"poster": {
|
||||
"title": "创建海报",
|
||||
"prompt": "生成一张个人 AI 助手的精致海报概念图,现代构图,视觉层级清晰,适合落地页展示。"
|
||||
},
|
||||
"product": {
|
||||
"title": "产品样机",
|
||||
"prompt": "生成一张对话式 AI Web 应用的干净产品样机图,极简界面,高级光影,真实设备边框。"
|
||||
},
|
||||
"portrait": {
|
||||
"title": "风格化头像",
|
||||
"prompt": "生成一个友好的 AI 伙伴风格化头像,柔和光线,细节丰富但亲切,现代插画风格。"
|
||||
},
|
||||
"edit": {
|
||||
"title": "编辑图片",
|
||||
"prompt": "帮我编辑一张图片。先让我上传或指定要编辑的图片,然后生成编辑后的结果。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -108,7 +142,23 @@
|
||||
"inputAria": "消息输入框",
|
||||
"sendHint": "Enter 发送 · Shift+Enter 换行",
|
||||
"send": "发送消息",
|
||||
"stop": "停止响应",
|
||||
"attachImage": "添加图片",
|
||||
"imageMode": {
|
||||
"label": "图片生成",
|
||||
"toggle": "切换图片生成模式",
|
||||
"placeholder": "描述或编辑图片…",
|
||||
"aspectAria": "图片画幅",
|
||||
"aspectLabel": "图片画幅",
|
||||
"aspect": {
|
||||
"auto": "自动",
|
||||
"1_1": "方形 1:1",
|
||||
"3_4": "竖版 3:4",
|
||||
"9_16": "故事版 9:16",
|
||||
"4_3": "横版 4:3",
|
||||
"16_9": "宽屏 16:9"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"search": "搜索",
|
||||
"reason": "推理",
|
||||
|
||||
@@ -9,6 +9,14 @@
|
||||
"title": "無法連線到 nanobot",
|
||||
"gatewayHint": "請確認 gateway 已啟動(`nanobot gateway`),並且目前頁面與 gateway 在同一台機器上開啟。"
|
||||
},
|
||||
"system": {
|
||||
"section": "系統",
|
||||
"restartHint": "重新啟動 nanobot 以套用執行階段變更。",
|
||||
"restart": "重新啟動 nanobot"
|
||||
},
|
||||
"restart": {
|
||||
"completed": "重新啟動已完成,耗時 {{seconds}} 秒。"
|
||||
},
|
||||
"documentTitle": {
|
||||
"base": "nanobot",
|
||||
"chat": "{{title}} · nanobot"
|
||||
@@ -80,6 +88,32 @@
|
||||
"title": "更多",
|
||||
"prompt": "展示幾個你在這個工作區裡可以幫我的實用方式。"
|
||||
}
|
||||
},
|
||||
"imageQuickActions": {
|
||||
"icon": {
|
||||
"title": "設計應用程式圖示",
|
||||
"prompt": "生成一個乾淨的 1:1 nanobot 應用程式圖示:友善的機器人、簡潔向量風格、柔和藍白配色,不要文字。"
|
||||
},
|
||||
"sticker": {
|
||||
"title": "製作貼圖",
|
||||
"prompt": "生成一張可愛貼圖風格的小型機器人助理圖片,背景像透明貼紙,表情活潑有趣。"
|
||||
},
|
||||
"poster": {
|
||||
"title": "建立海報",
|
||||
"prompt": "生成一張個人 AI 助理的精緻海報概念圖,現代構圖、清楚的視覺層級,適合登陸頁展示。"
|
||||
},
|
||||
"product": {
|
||||
"title": "產品樣機",
|
||||
"prompt": "生成一張對話式 AI Web 應用的乾淨產品樣機圖,極簡介面、高級光影、真實裝置邊框。"
|
||||
},
|
||||
"portrait": {
|
||||
"title": "風格化頭像",
|
||||
"prompt": "生成一個友善 AI 夥伴的風格化頭像,柔和光線、細節豐富但親切,現代插畫風格。"
|
||||
},
|
||||
"edit": {
|
||||
"title": "編輯圖片",
|
||||
"prompt": "幫我編輯一張圖片。先請我上傳或指定要編輯的圖片,然後生成編輯後的結果。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -93,7 +127,23 @@
|
||||
"inputAria": "訊息輸入框",
|
||||
"sendHint": "Enter 送出 · Shift+Enter 換行",
|
||||
"send": "送出訊息",
|
||||
"stop": "停止回覆",
|
||||
"attachImage": "附加圖片",
|
||||
"imageMode": {
|
||||
"label": "圖片生成",
|
||||
"toggle": "切換圖片生成模式",
|
||||
"placeholder": "描述或編輯圖片…",
|
||||
"aspectAria": "圖片畫幅",
|
||||
"aspectLabel": "圖片畫幅",
|
||||
"aspect": {
|
||||
"auto": "自動",
|
||||
"1_1": "方形 1:1",
|
||||
"3_4": "直式 3:4",
|
||||
"9_16": "故事版 9:16",
|
||||
"4_3": "橫式 4:3",
|
||||
"16_9": "寬螢幕 16:9"
|
||||
}
|
||||
},
|
||||
"encoding": "處理中…",
|
||||
"remove": "移除附件",
|
||||
"normalizedSizeHint": "{{orig}} → {{current}}(已自動壓縮)",
|
||||
|
||||
@@ -126,13 +126,15 @@ export async function listSlashCommands(
|
||||
arg_hint?: string;
|
||||
};
|
||||
const body = await request<{ commands: Row[] }>(`${base}/api/commands`, token);
|
||||
return body.commands.map((command) => ({
|
||||
command: command.command,
|
||||
title: command.title,
|
||||
description: command.description,
|
||||
icon: command.icon,
|
||||
argHint: command.arg_hint ?? "",
|
||||
}));
|
||||
return body.commands
|
||||
.filter((command) => !["/stop", "/restart"].includes(command.command))
|
||||
.map((command) => ({
|
||||
command: command.command,
|
||||
title: command.title,
|
||||
description: command.description,
|
||||
icon: command.icon,
|
||||
argHint: command.arg_hint ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
export async function updateSettings(
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
ConnectionStatus,
|
||||
InboundEvent,
|
||||
Outbound,
|
||||
OutboundImageGeneration,
|
||||
OutboundMedia,
|
||||
} from "./types";
|
||||
|
||||
@@ -181,12 +182,21 @@ export class NanobotClient {
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage(chatId: string, content: string, media?: OutboundMedia[]): void {
|
||||
sendMessage(
|
||||
chatId: string,
|
||||
content: string,
|
||||
media?: OutboundMedia[],
|
||||
options?: { imageGeneration?: OutboundImageGeneration },
|
||||
): void {
|
||||
this.knownChats.add(chatId);
|
||||
const frame: Outbound =
|
||||
media && media.length > 0
|
||||
? { type: "message", chat_id: chatId, content, media, webui: true }
|
||||
: { type: "message", chat_id: chatId, content, webui: true };
|
||||
const frame: Outbound = {
|
||||
type: "message",
|
||||
chat_id: chatId,
|
||||
content,
|
||||
...(media && media.length > 0 ? { media } : {}),
|
||||
...(options?.imageGeneration ? { image_generation: options.imageGeneration } : {}),
|
||||
webui: true,
|
||||
};
|
||||
this.queueSend(frame);
|
||||
}
|
||||
|
||||
|
||||
@@ -150,6 +150,11 @@ export interface OutboundMedia {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface OutboundImageGeneration {
|
||||
enabled: true;
|
||||
aspect_ratio?: string | null;
|
||||
}
|
||||
|
||||
export type Outbound =
|
||||
| { type: "new_chat" }
|
||||
| { type: "attach"; chat_id: string }
|
||||
@@ -158,6 +163,7 @@ export type Outbound =
|
||||
chat_id: string;
|
||||
content: string;
|
||||
media?: OutboundMedia[];
|
||||
image_generation?: OutboundImageGeneration;
|
||||
/** Marks messages sent by the embedded WebUI, without changing the
|
||||
* generic websocket protocol for other clients. */
|
||||
webui?: true;
|
||||
|
||||
@@ -84,6 +84,18 @@ describe("webui API helpers", () => {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
commands: [
|
||||
{
|
||||
command: "/stop",
|
||||
title: "Stop current task",
|
||||
description: "Cancel the active task.",
|
||||
icon: "square",
|
||||
},
|
||||
{
|
||||
command: "/restart",
|
||||
title: "Restart nanobot",
|
||||
description: "Restart the bot process.",
|
||||
icon: "rotate-cw",
|
||||
},
|
||||
{
|
||||
command: "/history",
|
||||
title: "Show conversation history",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ThreadComposer } from "@/components/thread/ThreadComposer";
|
||||
import { resources } from "@/i18n";
|
||||
|
||||
const QUICK_ACTION_KEYS = ["plan", "analyze", "brainstorm", "code", "summarize", "more"];
|
||||
const IMAGE_QUICK_ACTION_KEYS = ["icon", "sticker", "poster", "product", "portrait", "edit"];
|
||||
|
||||
describe("webui i18n", () => {
|
||||
it("switches UI copy and document locale through the language switcher", async () => {
|
||||
@@ -54,6 +55,11 @@ describe("webui i18n", () => {
|
||||
expect(action.title).toBeTruthy();
|
||||
expect(action.prompt).toBeTruthy();
|
||||
}
|
||||
for (const key of IMAGE_QUICK_ACTION_KEYS) {
|
||||
const action = empty.imageQuickActions[key as keyof typeof empty.imageQuickActions];
|
||||
expect(action.title).toBeTruthy();
|
||||
expect(action.prompt).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,4 +102,26 @@ describe("MessageBubble", () => {
|
||||
expect(video).toHaveAttribute("src", "/api/media/sig/payload");
|
||||
expect(container.querySelector("video[controls]")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders assistant image media as a larger generated result", () => {
|
||||
const message: UIMessage = {
|
||||
id: "a-image",
|
||||
role: "assistant",
|
||||
content: "done",
|
||||
createdAt: Date.now(),
|
||||
media: [
|
||||
{
|
||||
kind: "image",
|
||||
url: "/api/media/sig/image",
|
||||
name: "generated.png",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { container } = render(<MessageBubble message={message} />);
|
||||
|
||||
const imageButton = screen.getByRole("button", { name: /view image/i });
|
||||
expect(imageButton).toHaveClass("h-56", "sm:h-72");
|
||||
expect(container.querySelector("img")).toHaveClass("object-contain");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -120,6 +120,33 @@ describe("NanobotClient", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("includes image generation options in outbound messages", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
|
||||
client.sendMessage(
|
||||
"chat-img",
|
||||
"draw a banner",
|
||||
undefined,
|
||||
{ imageGeneration: { enabled: true, aspect_ratio: "16:9" } },
|
||||
);
|
||||
|
||||
expect(lastSocket().sent).toContain(
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
chat_id: "chat-img",
|
||||
content: "draw a banner",
|
||||
image_generation: { enabled: true, aspect_ratio: "16:9" },
|
||||
webui: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("re-attaches known chats after a reconnect", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
|
||||
@@ -91,4 +91,116 @@ describe("ThreadComposer", () => {
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sends image generation mode with automatic aspect ratio", () => {
|
||||
const onSend = vi.fn();
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={onSend}
|
||||
placeholder="Type your message..."
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Toggle image generation mode" }));
|
||||
expect(screen.getByPlaceholderText("Describe or edit an image…")).toBeInTheDocument();
|
||||
|
||||
const input = screen.getByLabelText("Message input");
|
||||
fireEvent.change(input, { target: { value: "Draw a friendly robot" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith(
|
||||
"Draw a friendly robot",
|
||||
undefined,
|
||||
{ imageGeneration: { enabled: true, aspect_ratio: null } },
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a stop button while streaming", () => {
|
||||
const onStop = vi.fn();
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
onStop={onStop}
|
||||
isStreaming
|
||||
placeholder="Type your message..."
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Stop response" }));
|
||||
|
||||
expect(onStop).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole("button", { name: "Send message" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lets users select a concrete image aspect ratio", () => {
|
||||
const onSend = vi.fn();
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={onSend}
|
||||
placeholder="Type your message..."
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Toggle image generation mode" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Image aspect ratio" }));
|
||||
expect(screen.getByRole("listbox", { name: "Image aspect ratio" }).className).toContain(
|
||||
"bottom-full",
|
||||
);
|
||||
fireEvent.mouseDown(screen.getByRole("option", { name: "Wide 16:9" }));
|
||||
|
||||
const input = screen.getByLabelText("Message input");
|
||||
fireEvent.change(input, { target: { value: "Draw a banner" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith(
|
||||
"Draw a banner",
|
||||
undefined,
|
||||
{ imageGeneration: { enabled: true, aspect_ratio: "16:9" } },
|
||||
);
|
||||
});
|
||||
|
||||
it("opens the hero image aspect menu downward", () => {
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
placeholder="Ask anything..."
|
||||
variant="hero"
|
||||
imageMode
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Image aspect ratio" }));
|
||||
|
||||
expect(screen.getByRole("listbox", { name: "Image aspect ratio" }).className).toContain(
|
||||
"top-full",
|
||||
);
|
||||
});
|
||||
|
||||
it("dismisses the image aspect menu on outside click, escape, and wheel", () => {
|
||||
render(
|
||||
<div>
|
||||
<button type="button">outside</button>
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
placeholder="Type your message..."
|
||||
imageMode
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
|
||||
const aspectButton = screen.getByRole("button", { name: "Image aspect ratio" });
|
||||
fireEvent.click(aspectButton);
|
||||
expect(screen.getByRole("listbox", { name: "Image aspect ratio" })).toBeInTheDocument();
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "outside" }));
|
||||
expect(screen.queryByRole("listbox", { name: "Image aspect ratio" })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(aspectButton);
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(screen.queryByRole("listbox", { name: "Image aspect ratio" })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(aspectButton);
|
||||
fireEvent.wheel(screen.getByRole("listbox", { name: "Image aspect ratio" }), { deltaY: 120 });
|
||||
expect(screen.queryByRole("listbox", { name: "Image aspect ratio" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -250,6 +250,64 @@ describe("ThreadShell", () => {
|
||||
expect(onNewChat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the first landing message when new chat history is still empty", async () => {
|
||||
const client = makeClient();
|
||||
const onCreateChat = vi.fn().mockResolvedValue("chat-new");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({}),
|
||||
})),
|
||||
);
|
||||
|
||||
const { rerender } = render(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={null}
|
||||
title="nanobot"
|
||||
onToggleSidebar={() => {}}
|
||||
onCreateChat={onCreateChat}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Message input"), {
|
||||
target: { value: "first message should stay" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
await waitFor(() => expect(onCreateChat).toHaveBeenCalledTimes(1));
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("chat-new")}
|
||||
title="Chat chat-new"
|
||||
onToggleSidebar={() => {}}
|
||||
onCreateChat={onCreateChat}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
"chat-new",
|
||||
"first message should stay",
|
||||
undefined,
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("first message should stay")).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.queryByText("What can I do for you?")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sends quick action prompts from the empty thread landing", async () => {
|
||||
const client = makeClient();
|
||||
const onNewChat = vi.fn().mockResolvedValue("chat-a");
|
||||
@@ -566,6 +624,30 @@ describe("ThreadShell", () => {
|
||||
expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches welcome quick actions when image mode is enabled", async () => {
|
||||
const client = makeClient();
|
||||
render(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={null}
|
||||
title="nanobot"
|
||||
onToggleSidebar={() => {}}
|
||||
onNewChat={() => {}}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
await act(async () => {});
|
||||
|
||||
expect(screen.getByText("Write code")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Design an app icon")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Toggle image generation mode" }));
|
||||
|
||||
expect(screen.getByText("Design an app icon")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Write code")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces a dismissible banner when the stream reports message_too_big", async () => {
|
||||
const client = makeClient();
|
||||
const onNewChat = vi.fn().mockResolvedValue("chat-a");
|
||||
|
||||
@@ -134,6 +134,89 @@ describe("useNanobotStream", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("suppresses redundant stream confirmation after assistant media", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-img-result", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-img-result", {
|
||||
event: "message",
|
||||
chat_id: "chat-img-result",
|
||||
text: "image ready",
|
||||
media_urls: [{ url: "/api/media/sig/image", name: "generated.png" }],
|
||||
});
|
||||
fake.emit("chat-img-result", {
|
||||
event: "message",
|
||||
chat_id: "chat-img-result",
|
||||
text: "message()",
|
||||
kind: "tool_hint",
|
||||
});
|
||||
fake.emit("chat-img-result", {
|
||||
event: "delta",
|
||||
chat_id: "chat-img-result",
|
||||
text: "发送成功",
|
||||
});
|
||||
fake.emit("chat-img-result", {
|
||||
event: "stream_end",
|
||||
chat_id: "chat-img-result",
|
||||
});
|
||||
fake.emit("chat-img-result", {
|
||||
event: "turn_end",
|
||||
chat_id: "chat-img-result",
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0].content).toBe("image ready");
|
||||
expect(result.current.messages[0].media).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("passes image generation options to the websocket client", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-img", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.send(
|
||||
"draw a square icon",
|
||||
undefined,
|
||||
{ imageGeneration: { enabled: true, aspect_ratio: "1:1" } },
|
||||
);
|
||||
});
|
||||
|
||||
expect(fake.client.sendMessage).toHaveBeenCalledWith(
|
||||
"chat-img",
|
||||
"draw a square icon",
|
||||
undefined,
|
||||
{ imageGeneration: { enabled: true, aspect_ratio: "1:1" } },
|
||||
);
|
||||
});
|
||||
|
||||
it("stops the active turn without adding a user slash command bubble", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-stop", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.send("long task");
|
||||
});
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.stop();
|
||||
});
|
||||
|
||||
expect(fake.client.sendMessage).toHaveBeenLastCalledWith("chat-stop", "/stop");
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0].content).toBe("long task");
|
||||
});
|
||||
|
||||
it("keeps assistant buttons on complete messages", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-q", EMPTY_MESSAGES), {
|
||||
|
||||
Reference in New Issue
Block a user