grok2api / frontend /src /features /creative-console /creative-console-page.tsx
fromozuzhouzzz
Deploy grok2api v3.0.11 to HF Spaces
f1dd159
Raw
History Blame Contribute Delete
73.8 kB
import { useMutation, useQuery } from "@tanstack/react-query";
import { ArrowUp, BrainCircuit, Check, CheckCircle2, Clock3, ExternalLink, Globe, History, ImageIcon, ImagePlus, ImageUpscale, Images, Loader2, MessageSquareText, Pencil, RefreshCw, Sparkle, Square, SquarePen, Trash2, TriangleAlert, TvMinimal, Video, Wrench, X } from "lucide-react";
import { marked } from "marked";
import { useEffect, useMemo, useRef, useState, type FormEvent, type KeyboardEvent, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Message, MessageContent, MessageFooter } from "@/components/ui/message";
import { MessageScroller, MessageScrollerButton, MessageScrollerContent, MessageScrollerItem, MessageScrollerProvider, MessageScrollerViewport } from "@/components/ui/message-scroller";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { listModels } from "@/entities/model/model-api";
import type { ModelRouteDTO } from "@/entities/model/types";
import {
createChatResponse,
createVideo,
generateImage,
getVideo,
type ChatMessage,
type ChatStreamSnapshot,
type ChatToolActivity,
type ImageResult,
type ReasoningEffort,
type VideoStatus,
} from "@/features/creative-console/creative-console-api";
import { getClientKeySecret, listClientKeys, type ClientKeyDTO } from "@/features/client-keys/client-keys-api";
import { PageHeader } from "@/shared/components/page-header";
import { cn } from "@/shared/lib/cn";
type CreativeMode = "chat" | "image" | "video";
type ConversationMessage = ChatMessage & {
id: string;
reasoning?: string;
tools?: ChatToolActivity[];
};
type SecretState = {
keyId: string;
secret: string;
};
type ChatRequest = {
messages: ChatMessage[];
promptCacheKey: string;
reasoningEffort: ReasoningEffort;
webSearch: boolean;
xSearch: boolean;
assistantMessageId: string;
apiKey: string;
model: string;
requestSeq: number;
};
type PendingTruncateAction =
| { kind: "delete"; messageId: string; trailingCount: number }
| { kind: "regenerate"; messageId: string; trailingCount: number }
| { kind: "edit-user"; messageId: string; content: string; trailingCount: number };
type ChatSession = {
id: string;
title: string;
createdAt: number;
updatedAt: number;
model: string;
promptCacheKey: string;
reasoningEffort: ReasoningEffort;
webSearch: boolean;
xSearch: boolean;
messages: ConversationMessage[];
};
const imageAspectRatios = ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3"] as const;
const videoAspectRatios = ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3"] as const;
const imageResolutions = ["1k", "2k"] as const;
const videoResolutions = ["480p", "720p", "1080p"] as const;
const videoDurations = ["6", "10", "15"] as const;
const chatHistoryStoragePrefix = "grok2api:creative-console:chat-history:";
const chatHistoryMaxSessions = 50;
const chatHistoryMaxBytes = 4 * 1024 * 1024;
const composerClassName = "overflow-hidden rounded-2xl bg-secondary/45 ring-1 ring-transparent transition-colors focus-within:bg-secondary/60 focus-within:ring-ring";
export function CreativeConsolePage() {
const { t } = useTranslation();
const [mode, setMode] = useState<CreativeMode>("chat");
const [selectedKeyId, setSelectedKeyId] = useState("");
const [secretState, setSecretState] = useState<SecretState | null>(null);
const [keyError, setKeyError] = useState("");
const [selectedModels, setSelectedModels] = useState<Record<CreativeMode, string>>({ chat: "", image: "", video: "" });
const [chatToolbarElement, setChatToolbarElement] = useState<HTMLDivElement | null>(null);
const requestedSecretKeyRef = useRef("");
const keysQuery = useQuery({
queryKey: ["creative-console", "client-keys"],
queryFn: () => listAllPaginatedItems((page, pageSize) => listClientKeys({ page, pageSize, status: "active" })),
staleTime: 30_000,
});
const activeKeys = useMemo(() => (keysQuery.data ?? []).filter(isUsableKey), [keysQuery.data]);
const effectiveKeyId = activeKeys.some((key) => key.id === selectedKeyId) ? selectedKeyId : activeKeys[0]?.id ?? "";
const selectedKey = activeKeys.find((key) => key.id === effectiveKeyId);
const modelProviderScope = (selectedKey?.providerScope ?? ["all"]).filter((value) => value !== "all");
const modelTierScope = (selectedKey?.tierScope ?? ["all"]).filter((value) => value !== "all");
const modelsQuery = useQuery({
queryKey: ["creative-console", "models", modelProviderScope.join(","), modelTierScope.join(",")],
queryFn: () => listAllPaginatedItems((page, pageSize) => listModels({ page, pageSize, status: "enabled", providerScope: modelProviderScope, tierScope: modelTierScope, activeScope: true })),
enabled: Boolean(selectedKey),
staleTime: 30_000,
});
const availableModels = useMemo(() => (modelsQuery.data ?? []).filter((model) => model.enabled && model.available), [modelsQuery.data]);
const permittedModels = useMemo(() => {
if (!selectedKey || selectedKey.allowedModelIds.length === 0) return availableModels;
const allowedModelIds = new Set(selectedKey.allowedModelIds);
return availableModels.filter((model) => allowedModelIds.has(model.id));
}, [availableModels, selectedKey]);
const modelGroups = useMemo(() => ({
chat: uniqueModelsByPublicID(permittedModels.filter((model) => model.capability === "chat" || model.capability === "responses")),
image: uniqueModelsByPublicID(permittedModels.filter((model) => model.capability === "image")),
video: uniqueModelsByPublicID(permittedModels.filter((model) => model.capability === "video")),
}), [permittedModels]);
const effectiveModels = useMemo<Record<CreativeMode, string>>(() => ({
chat: modelGroups.chat.some((model) => model.publicId === selectedModels.chat) ? selectedModels.chat : modelGroups.chat[0]?.publicId ?? "",
image: modelGroups.image.some((model) => model.publicId === selectedModels.image) ? selectedModels.image : modelGroups.image[0]?.publicId ?? "",
video: modelGroups.video.some((model) => model.publicId === selectedModels.video) ? selectedModels.video : modelGroups.video[0]?.publicId ?? "",
}), [modelGroups, selectedModels]);
const secretMutation = useMutation({
mutationFn: (id: string) => getClientKeySecret(id),
onSuccess: ({ secret }, id) => {
if (id !== effectiveKeyId) return;
setSecretState({ keyId: id, secret });
setKeyError("");
},
onError: (error, id) => {
if (id !== effectiveKeyId) return;
setKeyError(error instanceof Error ? error.message : t("creativeConsole.errors.keyUnavailable"));
},
});
const loadSecret = secretMutation.mutate;
useEffect(() => {
if (!effectiveKeyId) {
requestedSecretKeyRef.current = "";
return;
}
if (requestedSecretKeyRef.current === effectiveKeyId) return;
requestedSecretKeyRef.current = effectiveKeyId;
loadSecret(effectiveKeyId);
}, [effectiveKeyId, loadSecret]);
const apiKey = secretState?.keyId === effectiveKeyId ? secretState.secret : "";
function panelProps(panelMode: CreativeMode): CreativePanelProps {
return {
apiKey,
model: effectiveModels[panelMode],
modelOptions: modelGroups[panelMode],
onModelChange: (model) => setSelectedModels((current) => ({ ...current, [panelMode]: model })),
};
}
function changeKey(id: string): void {
setSelectedKeyId(id);
setSecretState(null);
setKeyError("");
}
return (
<div className="flex h-[calc(100dvh-5rem)] min-h-[36rem] flex-col gap-5 overflow-hidden">
<PageHeader title={t("creativeConsole.title")} description={t("creativeConsole.description")} />
<aside className="flex shrink-0 flex-col gap-2 rounded-lg bg-secondary/45 px-4 py-2.5 text-xs leading-5 text-muted-foreground sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<Sparkle className="size-4 shrink-0 text-foreground/70" />
<p>{t("creativeConsole.promotion", { product: "DEEIX Chat" })}</p>
</div>
<a className="inline-flex shrink-0 items-center gap-1.5 self-end font-medium text-foreground hover:underline sm:self-auto" href="https://github.com/DEEIX-AI/DEEIX-Chat" target="_blank" rel="noopener noreferrer">
{t("creativeConsole.promotionAction")}<ExternalLink className="size-3.5" />
</a>
</aside>
<section className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="flex min-h-9 shrink-0 flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<Tabs value={mode} onValueChange={(value) => setMode(value as CreativeMode)}>
<TabsList className="h-9 w-full rounded-full bg-secondary/50 p-1 lg:w-auto">
<TabsTrigger className="flex-1 gap-1.5 rounded-full px-3 lg:min-w-20 [&_svg]:size-3.5" value="chat"><MessageSquareText />{t("creativeConsole.modes.chat")}</TabsTrigger>
<TabsTrigger className="flex-1 gap-1.5 rounded-full px-3 lg:min-w-20 [&_svg]:size-3.5" value="image"><ImageIcon />{t("creativeConsole.modes.image")}</TabsTrigger>
<TabsTrigger className="flex-1 gap-1.5 rounded-full px-3 lg:min-w-20 [&_svg]:size-3.5" value="video"><Video />{t("creativeConsole.modes.video")}</TabsTrigger>
</TabsList>
</Tabs>
<div className="flex min-w-0 items-center gap-2">
<Select value={effectiveKeyId} onValueChange={changeKey} disabled={keysQuery.isPending || activeKeys.length === 0}>
<SelectTrigger id="creative-key" className="min-w-0 flex-1 bg-secondary/55 lg:w-64 lg:flex-none" aria-label={t("creativeConsole.clientKey")}>
<SelectValue placeholder={keysQuery.isPending ? t("common.loading") : t("creativeConsole.selectKey")} />
</SelectTrigger>
<SelectContent>
{activeKeys.map((key) => <SelectItem key={key.id} value={key.id}>{key.name} · {key.prefix}</SelectItem>)}
</SelectContent>
</Select>
<div ref={setChatToolbarElement} className={cn("items-center gap-1", mode === "chat" ? "flex" : "hidden")} />
</div>
</div>
<div className="shrink-0 space-y-2 px-3">
{keysQuery.isError ? <RetryableError message={keysQuery.error.message} onRetry={() => void keysQuery.refetch()} /> : null}
{!keysQuery.isPending && !keysQuery.isError && activeKeys.length === 0 ? <InlineError message={t("creativeConsole.errors.noKeys")} /> : null}
{keyError ? <InlineError message={keyError} /> : null}
{modelsQuery.isError ? <RetryableError message={modelsQuery.error.message} onRetry={() => void modelsQuery.refetch()} /> : null}
</div>
<div className="min-h-0 flex-1">
<div className="h-full" hidden={mode !== "chat"}><ChatPanel key={effectiveKeyId || "default"} storageScope={effectiveKeyId || "default"} toolbarElement={chatToolbarElement} {...panelProps("chat")} /></div>
<div className="h-full" hidden={mode !== "image"}><ImagePanel {...panelProps("image")} /></div>
<div className="h-full" hidden={mode !== "video"}><VideoPanel {...panelProps("video")} /></div>
</div>
</section>
</div>
);
}
type CreativePanelProps = {
apiKey: string;
model: string;
modelOptions: ModelRouteDTO[];
onModelChange: (model: string) => void;
};
function ChatPanel({ apiKey, model, modelOptions, onModelChange, storageScope, toolbarElement }: CreativePanelProps & { storageScope: string; toolbarElement: HTMLDivElement | null }) {
const { t, i18n } = useTranslation();
const [initialHistory] = useState(() => {
const sessions = loadChatSessions(storageScope);
return { sessions, active: sessions[0] ?? createBlankChatSession(model) };
});
const [sessions, setSessions] = useState<ChatSession[]>(initialHistory.sessions);
const [sessionId, setSessionId] = useState(initialHistory.active.id);
const [sessionCreatedAt, setSessionCreatedAt] = useState(initialHistory.active.createdAt);
const [webSearch, setWebSearch] = useState(initialHistory.active.webSearch);
const [xSearch, setXSearch] = useState(initialHistory.active.xSearch);
const [reasoningEffort, setReasoningEffort] = useState<ReasoningEffort>(initialHistory.active.reasoningEffort);
const [promptCacheKey, setPromptCacheKey] = useState(initialHistory.active.promptCacheKey);
const [prompt, setPrompt] = useState("");
const [messages, setMessages] = useState<ConversationMessage[]>(initialHistory.active.messages);
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
const [editDraft, setEditDraft] = useState("");
const [pendingTruncate, setPendingTruncate] = useState<PendingTruncateAction | null>(null);
const streamSnapshotRef = useRef<ChatStreamSnapshot>({ text: "", reasoning: "", tools: [] });
const streamFrameRef = useRef<number | null>(null);
const requestControllerRef = useRef<AbortController | null>(null);
// requestSeq starts at 1 and increases; 0 means no active request owns the stream callbacks.
const requestSeqRef = useRef(0);
const activeRequestSeqRef = useRef(0);
const restoredInitialModelRef = useRef(false);
useEffect(() => {
if (restoredInitialModelRef.current || modelOptions.length === 0) return;
restoredInitialModelRef.current = true;
if (initialHistory.active.model && modelOptions.some((option) => option.publicId === initialHistory.active.model)) {
onModelChange(initialHistory.active.model);
}
}, [initialHistory.active.model, modelOptions, onModelChange]);
useEffect(() => {
if (messages.length === 0) return;
const timer = window.setTimeout(() => {
const session: ChatSession = {
id: sessionId,
title: createChatSessionTitle(messages),
createdAt: sessionCreatedAt,
updatedAt: currentTimestamp(),
model,
promptCacheKey,
reasoningEffort,
webSearch,
xSearch,
messages,
};
setSessions((current) => {
const next = upsertChatSession(current, session);
return persistChatSessions(storageScope, next);
});
}, 300);
return () => window.clearTimeout(timer);
}, [messages, model, promptCacheKey, reasoningEffort, sessionCreatedAt, sessionId, storageScope, webSearch, xSearch]);
useEffect(() => () => {
cancelActiveRequest();
}, []);
function isActiveRequest(requestSeq: number): boolean {
return activeRequestSeqRef.current === requestSeq;
}
function cancelActiveRequest(): void {
if (streamFrameRef.current !== null) {
cancelAnimationFrame(streamFrameRef.current);
streamFrameRef.current = null;
}
requestControllerRef.current?.abort();
requestControllerRef.current = null;
// 0 = no active request owns stream callbacks.
activeRequestSeqRef.current = 0;
streamSnapshotRef.current = { text: "", reasoning: "", tools: [] };
}
function invalidatePromptCache(): string {
const next = createCreativeCacheKey();
setPromptCacheKey(next);
return next;
}
function toRequestMessages(items: ConversationMessage[]): ChatMessage[] {
return items
.filter((message) => message.role === "user" || message.role === "assistant")
.filter((message) => message.content.trim())
.map(({ role, content }) => ({ role, content }));
}
function clearEditState(): void {
setEditingMessageId(null);
setEditDraft("");
}
function clearEditStateIfAtOrAfter(index: number): void {
if (!editingMessageId) return;
const editIndex = messages.findIndex((message) => message.id === editingMessageId);
if (editIndex < 0 || editIndex >= index) clearEditState();
}
function renderStreamSnapshot(messageId: string, requestSeq: number): void {
if (streamFrameRef.current !== null) return;
streamFrameRef.current = requestAnimationFrame(() => {
streamFrameRef.current = null;
if (!isActiveRequest(requestSeq)) return;
const snapshot = streamSnapshotRef.current;
setMessages((current) => current.map((message) => message.id === messageId
? { ...message, content: snapshot.text, reasoning: snapshot.reasoning, tools: snapshot.tools }
: message));
});
}
const mutation = useMutation({
mutationFn: (request: ChatRequest) => {
streamSnapshotRef.current = { text: "", reasoning: "", tools: [] };
const controller = new AbortController();
requestControllerRef.current = controller;
activeRequestSeqRef.current = request.requestSeq;
return createChatResponse({
apiKey: request.apiKey,
model: request.model,
messages: request.messages,
promptCacheKey: request.promptCacheKey || undefined,
reasoningEffort: request.reasoningEffort,
webSearch: request.webSearch,
xSearch: request.xSearch,
signal: controller.signal,
onUpdate: (snapshot) => {
if (!isActiveRequest(request.requestSeq)) return;
streamSnapshotRef.current = snapshot;
renderStreamSnapshot(request.assistantMessageId, request.requestSeq);
},
});
},
onSuccess: (result, request) => {
if (!isActiveRequest(request.requestSeq)) return;
if (streamFrameRef.current !== null) cancelAnimationFrame(streamFrameRef.current);
streamFrameRef.current = null;
setMessages((current) => current.map((message) => message.id === request.assistantMessageId
? { ...message, content: result.text, reasoning: result.reasoning, tools: result.tools }
: message));
requestControllerRef.current = null;
activeRequestSeqRef.current = 0;
},
onError: (error, request) => {
if (!isActiveRequest(request.requestSeq)) return;
if (streamFrameRef.current !== null) cancelAnimationFrame(streamFrameRef.current);
streamFrameRef.current = null;
const snapshot = streamSnapshotRef.current;
const aborted = isAbortError(error);
setMessages((current) => current.flatMap((message) => {
if (message.id !== request.assistantMessageId) return [message];
// Drop empty/aborted assistant placeholders; keep partial text from real failures.
if (aborted || (!snapshot.text.trim() && !snapshot.reasoning.trim() && snapshot.tools.length === 0)) return [];
return [{ ...message, content: snapshot.text, reasoning: snapshot.reasoning, tools: snapshot.tools }];
}));
requestControllerRef.current = null;
activeRequestSeqRef.current = 0;
},
});
function beginAssistantRequest(params: {
history: ConversationMessage[];
assistantMessage: ConversationMessage;
cacheKey: string;
cancelPrevious?: boolean;
}): void {
if (!apiKey || !model) return;
if (params.cancelPrevious) cancelActiveRequest();
const requestSeq = ++requestSeqRef.current;
const requestMessages = toRequestMessages(params.history);
mutation.reset();
mutation.mutate({
messages: requestMessages,
promptCacheKey: params.cacheKey,
reasoningEffort,
webSearch,
xSearch,
assistantMessageId: params.assistantMessage.id,
apiKey,
model,
requestSeq,
});
}
function stopGenerating(): void {
if (!mutation.isPending) return;
const assistantMessageId = mutation.variables?.assistantMessageId;
const snapshot = streamSnapshotRef.current;
cancelActiveRequest();
if (assistantMessageId) {
setMessages((current) => current.flatMap((message) => {
if (message.id !== assistantMessageId) return [message];
const updated = hasChatStreamContent(snapshot)
? { ...message, content: snapshot.text, reasoning: snapshot.reasoning, tools: snapshot.tools }
: message;
if (!updated.content.trim() && !updated.reasoning?.trim() && !(updated.tools?.length)) return [];
return [updated];
}));
}
mutation.reset();
}
function submit(event?: FormEvent): void {
event?.preventDefault();
const userText = prompt.trim();
if (!apiKey || !model || !userText || mutation.isPending) return;
const userMessage: ConversationMessage = { id: createCreativeMessageId(), role: "user", content: userText };
const assistantMessage: ConversationMessage = { id: createCreativeMessageId(), role: "assistant", content: "", reasoning: "", tools: [] };
const history = [...messages, userMessage];
setMessages([...history, assistantMessage]);
setPrompt("");
clearEditState();
beginAssistantRequest({ history, assistantMessage, cacheKey: promptCacheKey });
}
function applyRegenerateAssistant(messageId: string): void {
if (!apiKey || !model) return;
const index = messages.findIndex((message) => message.id === messageId);
if (index < 0 || messages[index]?.role !== "assistant") return;
const history = messages.slice(0, index);
if (!history.some((message) => message.role === "user" && message.content.trim())) return;
// Allow interrupt-regenerate: cancel the in-flight stream first, then start a new one.
const cacheKey = invalidatePromptCache();
const assistantMessage: ConversationMessage = { id: messageId, role: "assistant", content: "", reasoning: "", tools: [] };
setMessages([...history, assistantMessage]);
clearEditState();
beginAssistantRequest({ history, assistantMessage, cacheKey, cancelPrevious: true });
}
function regenerateAssistant(messageId: string): void {
if (!apiKey || !model) return;
const index = messages.findIndex((message) => message.id === messageId);
if (index < 0 || messages[index]?.role !== "assistant") return;
const trailingCount = messages.length - index - 1;
if (trailingCount > 0) {
setPendingTruncate({ kind: "regenerate", messageId, trailingCount });
return;
}
applyRegenerateAssistant(messageId);
}
function startEditMessage(messageId: string): void {
if (mutation.isPending) return;
const target = messages.find((message) => message.id === messageId);
if (!target) return;
setEditingMessageId(messageId);
setEditDraft(target.content);
}
function cancelEditMessage(): void {
clearEditState();
}
function applyUserEditAndRegenerate(messageId: string, nextContent: string): void {
if (!apiKey || !model) return;
const index = messages.findIndex((message) => message.id === messageId);
if (index < 0) return;
const target = messages[index];
if (!target || target.role !== "user") return;
const cacheKey = invalidatePromptCache();
const historyPrefix = messages.slice(0, index);
const userMessage: ConversationMessage = { ...target, content: nextContent };
const assistantMessage: ConversationMessage = { id: createCreativeMessageId(), role: "assistant", content: "", reasoning: "", tools: [] };
const history = [...historyPrefix, userMessage];
setMessages([...history, assistantMessage]);
clearEditState();
beginAssistantRequest({ history, assistantMessage, cacheKey, cancelPrevious: true });
}
function applyDeleteMessage(messageId: string): void {
const index = messages.findIndex((message) => message.id === messageId);
if (index < 0) return;
cancelActiveRequest();
invalidatePromptCache();
// Drop the selected message and every turn after it so the transcript stays a single continuous branch.
const nextMessages = messages.slice(0, index);
setMessages(nextMessages);
if (nextMessages.length === 0) {
setSessions((current) => {
const next = current.filter((session) => session.id !== sessionId);
return persistChatSessions(storageScope, next);
});
}
clearEditStateIfAtOrAfter(index);
mutation.reset();
}
function saveEditMessage(messageId: string): void {
if (mutation.isPending) return;
const nextContent = editDraft.trim();
if (!nextContent) return;
const index = messages.findIndex((message) => message.id === messageId);
if (index < 0) return;
const target = messages[index];
if (!target) return;
if (target.role === "assistant") {
// Local-only edit for assistant replies; keep subsequent turns intact.
// Clear reasoning/tools so they cannot contradict the edited body.
if (index < messages.length - 1) invalidatePromptCache();
setMessages((current) => current.map((message) => message.id === messageId
? { ...message, content: nextContent, reasoning: undefined, tools: undefined }
: message));
clearEditState();
return;
}
// Editing a user message truncates the branch and re-requests a new reply.
if (!apiKey || !model) return;
const trailingCount = messages.length - index - 1;
if (trailingCount > 0) {
setPendingTruncate({ kind: "edit-user", messageId, content: nextContent, trailingCount });
return;
}
applyUserEditAndRegenerate(messageId, nextContent);
}
function deleteMessage(messageId: string): void {
if (mutation.isPending) return;
const index = messages.findIndex((message) => message.id === messageId);
if (index < 0) return;
const trailingCount = messages.length - index - 1;
if (trailingCount > 0) {
setPendingTruncate({ kind: "delete", messageId, trailingCount });
return;
}
applyDeleteMessage(messageId);
}
function confirmPendingTruncate(): void {
if (!pendingTruncate) return;
const action = pendingTruncate;
setPendingTruncate(null);
if (action.kind === "delete") {
applyDeleteMessage(action.messageId);
return;
}
if (action.kind === "regenerate") {
applyRegenerateAssistant(action.messageId);
return;
}
applyUserEditAndRegenerate(action.messageId, action.content);
}
function clearConversation(): void {
cancelActiveRequest();
setSessions((current) => {
const next = current.filter((session) => session.id !== sessionId);
return persistChatSessions(storageScope, next);
});
const blank = createBlankChatSession(model);
setSessionId(blank.id);
setSessionCreatedAt(blank.createdAt);
setMessages([]);
setPromptCacheKey(blank.promptCacheKey);
setPrompt("");
clearEditState();
setPendingTruncate(null);
mutation.reset();
}
function startNewConversation(): void {
if (mutation.isPending) return;
setSessions((current) => {
const next = messages.length > 0 ? upsertChatSession(current, {
id: sessionId,
title: createChatSessionTitle(messages),
createdAt: sessionCreatedAt,
updatedAt: currentTimestamp(),
model,
promptCacheKey,
reasoningEffort,
webSearch,
xSearch,
messages,
}) : current;
return persistChatSessions(storageScope, next);
});
const blank = createBlankChatSession(model);
setSessionId(blank.id);
setSessionCreatedAt(blank.createdAt);
setMessages([]);
setPromptCacheKey(blank.promptCacheKey);
setReasoningEffort(blank.reasoningEffort);
setWebSearch(blank.webSearch);
setXSearch(blank.xSearch);
setPrompt("");
clearEditState();
setPendingTruncate(null);
mutation.reset();
}
function switchConversation(targetId: string): void {
if (mutation.isPending || targetId === sessionId) return;
let availableSessions = sessions;
if (messages.length > 0) {
availableSessions = upsertChatSession(sessions, {
id: sessionId,
title: createChatSessionTitle(messages),
createdAt: sessionCreatedAt,
updatedAt: currentTimestamp(),
model,
promptCacheKey,
reasoningEffort,
webSearch,
xSearch,
messages,
});
}
const target = availableSessions.find((session) => session.id === targetId);
if (!target) return;
availableSessions = persistChatSessions(storageScope, availableSessions);
setSessions(availableSessions);
setSessionId(target.id);
setSessionCreatedAt(target.createdAt);
setMessages(target.messages);
setPromptCacheKey(target.promptCacheKey || createCreativeCacheKey());
setReasoningEffort(target.reasoningEffort);
setWebSearch(target.webSearch);
setXSearch(target.xSearch);
setPrompt("");
clearEditState();
setPendingTruncate(null);
mutation.reset();
if (target.model && modelOptions.some((option) => option.publicId === target.model)) onModelChange(target.model);
}
function handlePromptKeyDown(event: KeyboardEvent<HTMLTextAreaElement>): void {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
submit();
}
}
function handleEditKeyDown(event: KeyboardEvent<HTMLTextAreaElement>, messageId: string): void {
if (event.key === "Escape") {
event.preventDefault();
cancelEditMessage();
return;
}
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
saveEditMessage(messageId);
}
}
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden">
{toolbarElement ? createPortal(<>
<Tooltip>
<TooltipTrigger asChild>
<Button type="button" variant="ghost" size="icon" className="rounded-full" aria-label={t("creativeConsole.newConversation")} onClick={startNewConversation} disabled={mutation.isPending}>
<SquarePen />
</Button>
</TooltipTrigger>
<TooltipContent>{t("creativeConsole.newConversation")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button type="button" variant="ghost" size="icon" className="rounded-full" aria-label={t("creativeConsole.clearCurrent")} onClick={clearConversation} disabled={messages.length === 0 || mutation.isPending}>
<Trash2 />
</Button>
</TooltipTrigger>
<TooltipContent>{t("creativeConsole.clearCurrent")}</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="ghost" size="icon" className="rounded-full" aria-label={t("creativeConsole.history")} disabled={mutation.isPending}>
<History />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
<DropdownMenuLabel>{t("creativeConsole.history")}</DropdownMenuLabel>
{sessions.length === 0 ? (
<div className="px-2 py-5 text-center text-xs text-muted-foreground">{t("creativeConsole.noHistory")}</div>
) : sessions.map((session) => (
<DropdownMenuItem key={session.id} className="min-h-12 gap-2" onSelect={() => switchConversation(session.id)}>
<div className="min-w-0 flex-1">
<div className="truncate text-xs">{session.title}</div>
<div className="mt-0.5 truncate text-[10px] text-muted-foreground">{session.model || t("creativeConsole.model")} · {formatChatSessionTime(session.updatedAt, i18n.language)}</div>
</div>
{session.id === sessionId ? <Check className="text-muted-foreground" /> : null}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</>, toolbarElement) : null}
<MessageScrollerProvider autoScroll defaultScrollPosition="end">
<MessageScroller className="min-h-0 flex-1">
<MessageScrollerViewport aria-label={t("creativeConsole.messageList")}>
<MessageScrollerContent className={cn("w-full px-3 py-6 sm:px-6", messages.length === 0 && !mutation.isPending && "justify-center")}>
{messages.length === 0 && !mutation.isPending ? <WelcomeState title={t("creativeConsole.welcome")} /> : null}
{messages.map((message) => (
<MessageScrollerItem key={message.id} messageId={message.id} scrollAnchor={message.role === "user"}>
<ChatMessageItem
message={message}
loading={mutation.isPending && mutation.variables?.assistantMessageId === message.id}
busy={mutation.isPending}
editing={editingMessageId === message.id}
editDraft={editingMessageId === message.id ? editDraft : ""}
onEditDraftChange={setEditDraft}
onStartEdit={() => startEditMessage(message.id)}
onCancelEdit={cancelEditMessage}
onSaveEdit={() => saveEditMessage(message.id)}
onEditKeyDown={(event) => handleEditKeyDown(event, message.id)}
onRegenerate={() => regenerateAssistant(message.id)}
onStop={stopGenerating}
onDelete={() => deleteMessage(message.id)}
/>
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton aria-label={t("creativeConsole.scrollToLatest")} />
</MessageScroller>
</MessageScrollerProvider>
<form className="w-full shrink-0 px-3 pb-2 sm:px-6 sm:pb-3" onSubmit={submit}>
<div className={composerClassName}>
<Textarea id="chat-prompt" value={prompt} onChange={(event) => setPrompt(event.target.value)} onKeyDown={handlePromptKeyDown} placeholder={t("creativeConsole.chatPlaceholder")} className="min-h-24 resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0" />
<div className="flex items-center justify-between gap-3 px-3 pb-3">
<div className="flex min-w-0 items-center gap-0.5 overflow-x-auto">
<CompactModelSelect value={model} models={modelOptions} onChange={onModelChange} />
<CompactIconSelect
value={webSearch ? "on" : "off"}
options={[{ value: "off", label: t("creativeConsole.webSearchOff") }, { value: "on", label: t("creativeConsole.webSearchOn") }]}
onChange={(value) => setWebSearch(value === "on")}
ariaLabel={t("creativeConsole.webSearch")}
icon={<Globe />}
active={webSearch}
/>
<CompactIconSelect
value={xSearch ? "on" : "off"}
options={[{ value: "off", label: t("creativeConsole.xSearchOff") }, { value: "on", label: t("creativeConsole.xSearchOn") }]}
onChange={(value) => setXSearch(value === "on")}
ariaLabel={t("creativeConsole.xSearch")}
icon={<XSocialIcon />}
active={xSearch}
/>
<CompactIconSelect
value={reasoningEffort}
options={(["auto", "none", "low", "medium", "high", "xhigh"] as ReasoningEffort[]).map((effort) => ({ value: effort, label: t(`creativeConsole.reasoning.${effort}`) }))}
onChange={(value) => setReasoningEffort(value as ReasoningEffort)}
ariaLabel={t("creativeConsole.reasoningEffort")}
icon={<Sparkle />}
active={reasoningEffort !== "auto" && reasoningEffort !== "none"}
/>
</div>
{mutation.isPending ? (
<Button type="button" size="icon" variant="secondary" aria-label={t("creativeConsole.stopGenerating")} onClick={stopGenerating}>
<Square className="size-3.5 fill-current" />
</Button>
) : (
<Button type="submit" size="icon" aria-label={t("creativeConsole.send")} disabled={!apiKey || !model || !prompt.trim()}>
<ArrowUp />
</Button>
)}
</div>
</div>
{mutation.isError ? <div className="mt-1 px-2 text-[11px] text-destructive">{mutation.error.message}</div> : null}
</form>
<AlertDialog open={pendingTruncate !== null} onOpenChange={(open) => { if (!open) setPendingTruncate(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{pendingTruncate?.kind === "edit-user"
? t("creativeConsole.editUserTruncateTitle")
: pendingTruncate?.kind === "regenerate"
? t("creativeConsole.regenerateTruncateTitle")
: t("creativeConsole.deleteMessageConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{pendingTruncate
? t(
pendingTruncate.kind === "edit-user"
? "creativeConsole.editUserTruncateDescription"
: pendingTruncate.kind === "regenerate"
? "creativeConsole.regenerateTruncateDescription"
: "creativeConsole.deleteMessageConfirmDescription",
{ count: pendingTruncate.trailingCount },
)
: null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className={pendingTruncate?.kind === "delete" ? "bg-destructive text-white hover:bg-destructive/90" : undefined}
onClick={(event) => {
event.preventDefault();
confirmPendingTruncate();
}}
>
{pendingTruncate?.kind === "edit-user"
? t("creativeConsole.saveAndRegenerate")
: pendingTruncate?.kind === "regenerate"
? t("creativeConsole.regenerate")
: t("creativeConsole.deleteMessage")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
function ImagePanel({ apiKey, model, modelOptions, onModelChange }: CreativePanelProps) {
const { t } = useTranslation();
const [prompt, setPrompt] = useState("");
const [count, setCount] = useState("1");
const [aspectRatio, setAspectRatio] = useState("1:1");
const [resolution, setResolution] = useState("1k");
const [images, setImages] = useState<ImageResult[]>([]);
const mutation = useMutation({
mutationFn: (request: Parameters<typeof generateImage>[0]) => generateImage(request),
onSuccess: setImages,
});
function submit(event: FormEvent): void {
event.preventDefault();
if (!apiKey || !model || !prompt.trim() || mutation.isPending) return;
mutation.reset();
mutation.mutate({ apiKey, model, prompt: prompt.trim(), count: Number(count), aspectRatio, resolution });
}
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden">
<div className="min-h-0 flex-1 overflow-y-auto py-6">
<div className="flex min-h-full w-full flex-col justify-center px-3 sm:px-6">
{images.length === 0 && !mutation.isPending ? <WelcomeState title={t("creativeConsole.welcomeImage")} /> : null}
{mutation.isPending ? <LoadingResult text={t("creativeConsole.generatingImage")} /> : null}
{images.length > 0 ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2" aria-live="polite">
{images.map((image, index) => (
<figure key={`${image.url}-${index}`} className="group min-w-0 overflow-hidden">
<img src={image.url} alt={t("creativeConsole.generatedImageAlt", { index: index + 1 })} className="aspect-square w-full rounded-xl bg-muted object-contain" loading="lazy" />
<figcaption className="flex min-w-0 items-center justify-between gap-2 py-1.5">
<span className="truncate text-xs text-muted-foreground">{t("creativeConsole.imageNumber", { index: index + 1 })}</span>
<Button variant="ghost" size="icon" asChild><a href={image.url} target="_blank" rel="noreferrer" aria-label={t("creativeConsole.open")}><ExternalLink /></a></Button>
</figcaption>
</figure>
))}
</div>
) : null}
</div>
</div>
<form className="w-full shrink-0 px-3 pb-2 sm:px-6 sm:pb-3" onSubmit={submit}>
<div className={composerClassName}>
<Textarea id="image-prompt" value={prompt} onChange={(event) => setPrompt(event.target.value)} placeholder={t("creativeConsole.imagePlaceholder")} className="min-h-24 resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0" />
<div className="flex flex-wrap items-center justify-between gap-2 px-3 pb-3">
<div className="flex min-w-0 flex-wrap items-center gap-1">
<CompactModelSelect value={model} models={modelOptions} onChange={onModelChange} />
<CompactSelect value={count} options={["1", "2", "3", "4"]} onChange={setCount} ariaLabel={t("creativeConsole.count")} suffix="×" icon={<Images />} />
<CompactSelect value={aspectRatio} options={imageAspectRatios} onChange={setAspectRatio} ariaLabel={t("creativeConsole.aspectRatio")} icon={<TvMinimal />} />
<CompactSelect value={resolution} options={imageResolutions} onChange={setResolution} ariaLabel={t("creativeConsole.resolution")} icon={<ImageUpscale />} />
</div>
<Button type="submit" size="icon" aria-label={t("creativeConsole.generateImage")} disabled={!apiKey || !model || !prompt.trim() || mutation.isPending}>{mutation.isPending ? <Loader2 className="animate-spin" /> : <ArrowUp />}</Button>
</div>
</div>
{mutation.isError ? <div className="mt-1 px-2 text-[11px] text-destructive">{mutation.error.message}</div> : null}
</form>
</div>
);
}
function VideoPanel({ apiKey, model, modelOptions, onModelChange }: CreativePanelProps) {
const { t } = useTranslation();
const [prompt, setPrompt] = useState("");
const [imageURL, setImageURL] = useState("");
const [duration, setDuration] = useState("6");
const [aspectRatio, setAspectRatio] = useState("16:9");
const [resolution, setResolution] = useState("720p");
const [job, setJob] = useState<{ requestId: string; apiKey: string } | null>(null);
const createMutation = useMutation({
mutationFn: (request: Parameters<typeof createVideo>[0]) => createVideo(request),
onSuccess: (requestId, request) => setJob({ requestId, apiKey: request.apiKey }),
});
const statusQuery = useQuery({
queryKey: ["creative-console", "video", job?.requestId],
queryFn: ({ signal }) => getVideo({ apiKey: job!.apiKey, requestId: job!.requestId, signal }),
enabled: Boolean(job),
refetchInterval: (query) => query.state.data?.status === "pending" ? 3_000 : false,
retry: 2,
});
function submit(event: FormEvent): void {
event.preventDefault();
if (!apiKey || !model || (!prompt.trim() && !imageURL.trim()) || !validDuration(duration) || createMutation.isPending) return;
setJob(null);
createMutation.reset();
createMutation.mutate({
apiKey,
model,
prompt: prompt.trim(),
imageURL: imageURL.trim() || undefined,
duration: Number(duration),
aspectRatio,
resolution,
});
}
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden">
<div className="min-h-0 flex-1 overflow-y-auto py-6">
<div className="flex min-h-full w-full flex-col justify-center px-3 sm:px-6">
{!job && !createMutation.isPending ? <WelcomeState title={t("creativeConsole.welcomeVideo")} /> : null}
{createMutation.isPending ? <LoadingResult text={t("creativeConsole.submittingVideo")} /> : null}
{job ? (
<VideoResult
requestId={job.requestId}
status={statusQuery.data}
loading={statusQuery.isPending || statusQuery.isFetching}
error={statusQuery.isError ? statusQuery.error.message : ""}
onRetry={() => void statusQuery.refetch()}
/>
) : null}
</div>
</div>
<form className="w-full shrink-0 px-3 pb-2 sm:px-6 sm:pb-3" onSubmit={submit}>
<div className={composerClassName}>
<Textarea id="video-prompt" value={prompt} onChange={(event) => setPrompt(event.target.value)} placeholder={t("creativeConsole.videoPlaceholder")} className="min-h-24 resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0" />
<div className="flex items-center justify-between gap-3 px-3 pb-3">
<div className="flex min-w-0 items-center gap-1 overflow-x-auto">
<CompactModelSelect value={model} models={modelOptions} onChange={onModelChange} />
<Popover>
<PopoverTrigger asChild>
<Button type="button" variant="ghost" size="sm" className={cn("h-8 gap-1.5 px-2 font-normal", imageURL && "bg-secondary/70 text-foreground")} aria-label={t("creativeConsole.referenceImage")}>
<ImagePlus />{imageURL ? t("creativeConsole.referenceImageAdded") : t("creativeConsole.referenceImageShort")}
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-80 p-3">
<div className="mb-2 text-xs font-medium">{t("creativeConsole.referenceImage")}</div>
<div className="flex items-center gap-2">
<Input id="video-image" type="url" value={imageURL} onChange={(event) => setImageURL(event.target.value)} placeholder="https://..." aria-label={t("creativeConsole.referenceImage")} />
{imageURL ? <Button type="button" variant="ghost" size="icon" className="shrink-0" aria-label={t("creativeConsole.clearReferenceImage")} onClick={() => setImageURL("")}><X /></Button> : null}
</div>
</PopoverContent>
</Popover>
<CompactSelect value={duration} options={videoDurations} onChange={setDuration} ariaLabel={t("creativeConsole.duration")} suffix="s" icon={<Clock3 />} />
<CompactSelect value={aspectRatio} options={videoAspectRatios} onChange={setAspectRatio} ariaLabel={t("creativeConsole.aspectRatio")} icon={<TvMinimal />} />
<CompactSelect value={resolution} options={videoResolutions} onChange={setResolution} ariaLabel={t("creativeConsole.resolution")} icon={<ImageUpscale />} />
</div>
<Button type="submit" size="icon" aria-label={t("creativeConsole.generateVideo")} disabled={!apiKey || !model || (!prompt.trim() && !imageURL.trim()) || !validDuration(duration) || createMutation.isPending}>
{createMutation.isPending ? <Loader2 className="animate-spin" /> : <ArrowUp />}
</Button>
</div>
</div>
{createMutation.isError ? <div className="mt-1 px-2 text-[11px] text-destructive">{createMutation.error.message}</div> : null}
</form>
</div>
);
}
function VideoResult({ requestId, status, loading, error, onRetry }: { requestId: string; status?: VideoStatus; loading: boolean; error: string; onRetry: () => void }) {
const { t } = useTranslation();
const progress = status?.progress ?? 0;
return (
<div className="w-full space-y-4" aria-live="polite">
<div className="grid gap-3 sm:grid-cols-2">
<MetaItem label={t("creativeConsole.requestId")} value={requestId} mono />
<MetaItem label={t("creativeConsole.status")} value={status ? t(`creativeConsole.videoStatus.${status.status}`) : t("common.loading")} />
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs"><span className="text-muted-foreground">{t("creativeConsole.progress")}</span><span className="tabular-nums">{progress}%</span></div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted"><div className="h-full rounded-full bg-primary transition-[width]" style={{ width: `${progress}%` }} /></div>
</div>
{loading && status?.status !== "done" && status?.status !== "failed" ? <div className="flex items-center gap-2 text-xs text-muted-foreground"><Spinner />{t("creativeConsole.pollingVideo")}</div> : null}
{error ? <RetryableError message={error} onRetry={onRetry} /> : null}
{status?.status === "failed" ? <InlineError message={status.error?.message || t("creativeConsole.errors.videoFailed")} /> : null}
{status?.status === "done" && status.video ? (
<div className="space-y-3">
<video src={status.video.url} controls preload="metadata" className="max-h-[60vh] w-full rounded-2xl bg-black shadow-sm" />
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">{status.video.duration ? t("creativeConsole.videoDuration", { count: status.video.duration }) : ""}</span>
<Button variant="secondary" size="sm" asChild><a href={status.video.url} target="_blank" rel="noreferrer"><ExternalLink />{t("creativeConsole.openVideo")}</a></Button>
</div>
</div>
) : null}
</div>
);
}
function WelcomeState({ title }: { title: string }) {
return (
<div className="flex min-h-[20rem] items-center justify-center px-6 text-center">
<h2 className="max-w-2xl text-xl font-medium tracking-tight text-muted-foreground sm:text-2xl">{title}</h2>
</div>
);
}
function CompactModelSelect({ value, models, onChange }: { value: string; models: ModelRouteDTO[]; onChange: (model: string) => void }) {
const { t } = useTranslation();
return (
<Select value={value} onValueChange={onChange} disabled={models.length === 0}>
<SelectTrigger className="h-8 w-auto max-w-56 gap-1 border-0 bg-transparent px-2 shadow-none hover:bg-secondary/70 focus:bg-secondary/70 focus:ring-0" aria-label={t("creativeConsole.model")}>
<SelectValue placeholder={models.length === 0 ? t("creativeConsole.noModels") : t("creativeConsole.selectModel")} />
</SelectTrigger>
<SelectContent>{models.map((item) => <SelectItem key={item.id} value={item.publicId}>{item.publicId}</SelectItem>)}</SelectContent>
</Select>
);
}
function CompactSelect({ value, options, onChange, ariaLabel, suffix, icon }: { value: string; options: readonly string[]; onChange: (value: string) => void; ariaLabel: string; suffix?: string; icon?: ReactNode }) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger className="h-8 w-auto gap-1.5 border-0 bg-transparent px-2 shadow-none hover:bg-secondary/70 focus:bg-secondary/70 focus:ring-0 [&>svg]:size-3.5 [&>svg]:shrink-0" aria-label={ariaLabel}>
{icon}<SelectValue />
</SelectTrigger>
<SelectContent>{options.map((option) => <SelectItem key={option} value={option}>{option}{suffix}</SelectItem>)}</SelectContent>
</Select>
);
}
function CompactIconSelect({ value, options, onChange, ariaLabel, icon, active = false }: { value: string; options: Array<{ value: string; label: string }>; onChange: (value: string) => void; ariaLabel: string; icon: ReactNode; active?: boolean }) {
const selectedLabel = options.find((option) => option.value === value)?.label ?? ariaLabel;
return (
<Select value={value} onValueChange={onChange}>
<Tooltip>
<TooltipTrigger asChild>
<SelectTrigger className={cn("h-8 w-auto min-w-8 gap-1 bg-transparent px-2 shadow-none hover:bg-secondary/70 focus:bg-secondary/70 focus:ring-0", active && "bg-secondary/70 text-foreground")} aria-label={`${ariaLabel}: ${selectedLabel}`}>
<span className="flex items-center [&_svg]:size-3.5">{icon}</span>
</SelectTrigger>
</TooltipTrigger>
<TooltipContent>{ariaLabel} · {selectedLabel}</TooltipContent>
</Tooltip>
<SelectContent>{options.map((option) => <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}</SelectContent>
</Select>
);
}
function XSocialIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
);
}
function ChatMessageItem({
message,
loading = false,
busy = false,
editing = false,
editDraft = "",
onEditDraftChange,
onStartEdit,
onCancelEdit,
onSaveEdit,
onEditKeyDown,
onRegenerate,
onStop,
onDelete,
}: {
message: ConversationMessage;
loading?: boolean;
busy?: boolean;
editing?: boolean;
editDraft?: string;
onEditDraftChange: (value: string) => void;
onStartEdit: () => void;
onCancelEdit: () => void;
onSaveEdit: () => void;
onEditKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
onRegenerate: () => void;
onStop: () => void;
onDelete: () => void;
}) {
const { t } = useTranslation();
const isUser = message.role === "user";
const canStop = loading && !editing;
const canRegenerate = !isUser && !editing && (!busy || loading);
const canEdit = !loading && !busy;
const canDelete = !loading && !busy && !editing;
return (
<Message align={isUser ? "end" : "start"}>
<MessageContent className={cn(!isUser && "w-full max-w-full")}>
{!isUser && message.reasoning ? (
<div className="w-full rounded-xl bg-secondary/45 px-3 py-2.5 text-xs text-muted-foreground">
<div className="mb-1.5 flex items-center gap-1.5 font-medium text-foreground/75"><BrainCircuit className="size-3.5" />{t("creativeConsole.thinkingProcess")}</div>
<div className="whitespace-pre-wrap break-words leading-5">{message.reasoning}</div>
</div>
) : null}
{!isUser && message.tools?.length ? (
<div className="flex w-full flex-col gap-1.5">
{message.tools.map((tool) => <ToolActivityItem key={tool.id} tool={tool} />)}
</div>
) : null}
{editing ? (
<div className={cn("w-full space-y-2", isUser ? "max-w-full" : "")}>
<Textarea
value={editDraft}
onChange={(event) => onEditDraftChange(event.target.value)}
onKeyDown={onEditKeyDown}
className="min-h-24 resize-y bg-background/70 text-sm"
autoFocus
aria-label={t("creativeConsole.editMessage")}
/>
{!isUser ? (
<p className="text-[11px] leading-4 text-muted-foreground">{t("creativeConsole.localEditNote")}</p>
) : null}
<div className={cn("flex items-center gap-2", isUser && "justify-end")}>
<Button type="button" variant="ghost" size="sm" onClick={onCancelEdit}>{t("creativeConsole.cancelEdit")}</Button>
<Button type="button" size="sm" onClick={onSaveEdit} disabled={!editDraft.trim()}>
{isUser ? t("creativeConsole.saveAndRegenerate") : t("creativeConsole.saveEdit")}
</Button>
</div>
</div>
) : message.content || isUser ? (
isUser ? (
<div className="whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-secondary px-4 py-2.5 text-sm leading-6">{message.content}</div>
) : <AssistantContent content={message.content} />
) : null}
{loading ? <div className="flex items-center gap-2 py-1 text-xs text-muted-foreground"><Spinner />{t("creativeConsole.streaming")}</div> : null}
{!editing && (canStop || canRegenerate || canEdit || canDelete) ? (
<MessageFooter className="gap-0.5 opacity-100 transition-opacity [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover/message:opacity-100 [@media(hover:hover)]:group-focus-within/message:opacity-100">
{canStop ? (
<Tooltip>
<TooltipTrigger asChild>
<Button type="button" variant="ghost" size="icon" className="size-7 rounded-full" aria-label={t("creativeConsole.stopGenerating")} onClick={onStop}>
<Square className="size-3.5 fill-current" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("creativeConsole.stopGenerating")}</TooltipContent>
</Tooltip>
) : null}
{canRegenerate ? (
<Tooltip>
<TooltipTrigger asChild>
<Button type="button" variant="ghost" size="icon" className="size-7 rounded-full" aria-label={t("creativeConsole.regenerate")} onClick={onRegenerate}>
<RefreshCw className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("creativeConsole.regenerate")}</TooltipContent>
</Tooltip>
) : null}
{canEdit ? (
<Tooltip>
<TooltipTrigger asChild>
<Button type="button" variant="ghost" size="icon" className="size-7 rounded-full" aria-label={t("creativeConsole.editMessage")} onClick={onStartEdit}>
<Pencil className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("creativeConsole.editMessage")}</TooltipContent>
</Tooltip>
) : null}
{canDelete ? (
<Tooltip>
<TooltipTrigger asChild>
<Button type="button" variant="ghost" size="icon" className="size-7 rounded-full text-destructive hover:text-destructive" aria-label={t("creativeConsole.deleteMessage")} onClick={onDelete}>
<Trash2 className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("creativeConsole.deleteMessage")}</TooltipContent>
</Tooltip>
) : null}
</MessageFooter>
) : null}
</MessageContent>
</Message>
);
}
function AssistantContent({ content }: { content: string }) {
const renderedHTML = useMemo(() => renderAssistantMarkup(content), [content]);
if (!renderedHTML) return <div className="w-full whitespace-pre-wrap break-words py-1 text-sm leading-6">{content}</div>;
return (
<div
className="w-full break-words py-1 text-sm leading-6 [&>:first-child]:mt-0 [&>:last-child]:mb-0 [&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2 [&_blockquote]:my-3 [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_code]:rounded [&_code]:bg-secondary [&_code]:px-1 [&_code]:py-0.5 [&_h1]:mb-3 [&_h1]:mt-5 [&_h1]:text-xl [&_h1]:font-semibold [&_h2]:mb-2 [&_h2]:mt-4 [&_h2]:text-lg [&_h2]:font-semibold [&_h3]:mb-2 [&_h3]:mt-3 [&_h3]:font-semibold [&_hr]:my-4 [&_hr]:border-border [&_img]:my-3 [&_img]:max-h-[32rem] [&_img]:max-w-full [&_img]:rounded-xl [&_li]:my-1 [&_ol]:my-3 [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:my-2 [&_pre]:my-3 [&_pre]:overflow-x-auto [&_pre]:rounded-xl [&_pre]:bg-secondary [&_pre]:p-3 [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_table]:my-3 [&_table]:w-full [&_table]:border-collapse [&_td]:border-b [&_td]:border-border [&_td]:px-3 [&_td]:py-2 [&_th]:border-b [&_th]:border-border [&_th]:px-3 [&_th]:py-2 [&_th]:text-left [&_ul]:my-3 [&_ul]:list-disc [&_ul]:pl-6"
dangerouslySetInnerHTML={{ __html: renderedHTML }}
/>
);
}
function ToolActivityItem({ tool }: { tool: ChatToolActivity }) {
const { t } = useTranslation();
const isWebSearch = tool.name === "web_search" || tool.type === "web_search_call";
const isXSearch = tool.name === "x_search" || tool.type === "x_search_call";
const label = isWebSearch
? t("creativeConsole.toolNames.webSearch")
: isXSearch
? t("creativeConsole.toolNames.xSearch")
: tool.name;
const statusLabel = t(`creativeConsole.toolStatus.${tool.status}`);
return (
<div className="flex min-w-0 items-start gap-2 rounded-xl bg-secondary/45 px-3 py-2.5 text-xs">
<span className="mt-0.5 text-muted-foreground">
{isWebSearch ? <Globe className="size-3.5" /> : isXSearch ? <XSocialIcon className="size-3.5" /> : <Wrench className="size-3.5" />}
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium">{t("creativeConsole.toolCall")} · {label}</span>
<span className="ml-auto flex shrink-0 items-center gap-1 text-muted-foreground">
{tool.status === "in_progress" ? <Loader2 className="size-3 animate-spin" /> : tool.status === "failed" ? <TriangleAlert className="size-3 text-destructive" /> : <CheckCircle2 className="size-3" />}
{statusLabel}
</span>
</div>
{tool.detail ? <div className="mt-1 line-clamp-2 break-all leading-5 text-muted-foreground" title={tool.detail}>{tool.detail}</div> : null}
</div>
</div>
);
}
function LoadingResult({ text }: { text: string }) {
return <div className="flex min-h-[20rem] items-center justify-center gap-3 text-xs text-muted-foreground"><Spinner className="size-5" />{text}</div>;
}
function InlineError({ message }: { message: string }) {
return <div role="alert" className="rounded-md bg-destructive/8 px-3 py-2 text-xs leading-5 text-destructive">{message}</div>;
}
function RetryableError({ message, onRetry }: { message: string; onRetry: () => void }) {
const { t } = useTranslation();
return (
<div role="alert" className="flex flex-col gap-2 rounded-md bg-destructive/8 px-3 py-2 text-xs leading-5 text-destructive sm:flex-row sm:items-center sm:justify-between">
<span>{message}</span>
<Button type="button" variant="ghost" size="sm" className="self-start text-destructive hover:text-destructive sm:self-auto" onClick={onRetry}>
<RefreshCw />{t("common.retry")}
</Button>
</div>
);
}
function MetaItem({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
return <div className="min-w-0 py-2"><div className="mb-1 text-[11px] text-muted-foreground">{label}</div><div className={cn("truncate text-xs", mono && "font-mono")} title={value}>{value}</div></div>;
}
function isUsableKey(key: ClientKeyDTO): boolean {
if (!key.enabled) return false;
return !key.expiresAt || new Date(key.expiresAt).getTime() > Date.now();
}
function validDuration(value: string): boolean {
const duration = Number(value);
return Number.isInteger(duration) && duration >= 1 && duration <= 15;
}
function uniqueModelsByPublicID(models: ModelRouteDTO[]): ModelRouteDTO[] {
const seen = new Set<string>();
return models.filter((model) => {
if (seen.has(model.publicId)) return false;
seen.add(model.publicId);
return true;
});
}
let fallbackMessageID = 0;
function createCreativeMessageId(): string {
if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
fallbackMessageID += 1;
return `creative-${Date.now().toString(36)}-${fallbackMessageID.toString(36)}`;
}
function createCreativeCacheKey(): string {
return `creative-console-${createCreativeMessageId()}`;
}
function currentTimestamp(): number {
return Date.now();
}
function createBlankChatSession(model: string): ChatSession {
const now = Date.now();
return {
id: createCreativeMessageId(),
title: "",
createdAt: now,
updatedAt: now,
model,
promptCacheKey: createCreativeCacheKey(),
reasoningEffort: "auto",
webSearch: false,
xSearch: false,
messages: [],
};
}
function createChatSessionTitle(messages: ConversationMessage[]): string {
const title = messages.find((message) => message.role === "user")?.content.replace(/\s+/g, " ").trim() ?? "";
return title.length > 48 ? `${title.slice(0, 48)}…` : title || "Conversation";
}
function upsertChatSession(sessions: ChatSession[], session: ChatSession): ChatSession[] {
return [session, ...sessions.filter((item) => item.id !== session.id)]
.sort((left, right) => right.updatedAt - left.updatedAt)
.slice(0, chatHistoryMaxSessions);
}
function chatHistoryStorageKey(scope: string): string {
return `${chatHistoryStoragePrefix}${encodeURIComponent(scope)}`;
}
function loadChatSessions(scope: string): ChatSession[] {
if (typeof window === "undefined") return [];
try {
const parsed: unknown = JSON.parse(window.localStorage.getItem(chatHistoryStorageKey(scope)) ?? "[]");
if (!Array.isArray(parsed)) return [];
return parsed.flatMap(parseChatSession).sort((left, right) => right.updatedAt - left.updatedAt).slice(0, chatHistoryMaxSessions);
} catch {
return [];
}
}
function persistChatSessions(scope: string, sessions: ChatSession[]): ChatSession[] {
if (typeof window === "undefined") return sessions;
const retained = sessions.slice(0, chatHistoryMaxSessions);
while (retained.length > 0) {
try {
const serialized = JSON.stringify(retained);
if (serialized.length * 2 > chatHistoryMaxBytes) {
retained.pop();
continue;
}
window.localStorage.setItem(chatHistoryStorageKey(scope), serialized);
return retained;
} catch {
retained.pop();
}
}
try {
window.localStorage.removeItem(chatHistoryStorageKey(scope));
} catch {
// Storage may be unavailable; the in-memory conversation remains usable.
}
return retained;
}
function parseChatSession(value: unknown): ChatSession[] {
if (!isLocalRecord(value) || typeof value.id !== "string" || !Array.isArray(value.messages)) return [];
const messages = value.messages.flatMap(parseConversationMessage);
if (messages.length === 0) return [];
const now = Date.now();
const createdAt = finiteTimestamp(value.createdAt) ?? now;
const updatedAt = finiteTimestamp(value.updatedAt) ?? createdAt;
return [{
id: value.id,
title: typeof value.title === "string" && value.title.trim() ? value.title.trim() : createChatSessionTitle(messages),
createdAt,
updatedAt,
model: typeof value.model === "string" ? value.model : "",
promptCacheKey: typeof value.promptCacheKey === "string" && value.promptCacheKey ? value.promptCacheKey : createCreativeCacheKey(),
reasoningEffort: isReasoningEffort(value.reasoningEffort) ? value.reasoningEffort : "auto",
webSearch: value.webSearch === true,
xSearch: value.xSearch === true,
messages,
}];
}
function parseConversationMessage(value: unknown): ConversationMessage[] {
if (!isLocalRecord(value) || (value.role !== "user" && value.role !== "assistant") || typeof value.content !== "string") return [];
return [{
id: typeof value.id === "string" && value.id ? value.id : createCreativeMessageId(),
role: value.role,
content: value.content,
reasoning: typeof value.reasoning === "string" ? value.reasoning : undefined,
tools: Array.isArray(value.tools) ? value.tools.flatMap(parseChatToolActivity) : undefined,
}];
}
function parseChatToolActivity(value: unknown): ChatToolActivity[] {
if (!isLocalRecord(value) || typeof value.id !== "string" || typeof value.type !== "string" || typeof value.name !== "string") return [];
const status = value.status === "completed" || value.status === "failed" || value.status === "in_progress" ? value.status : "completed";
return [{ id: value.id, type: value.type, name: value.name, status, detail: typeof value.detail === "string" ? value.detail : "" }];
}
function isReasoningEffort(value: unknown): value is ReasoningEffort {
return value === "auto" || value === "none" || value === "low" || value === "medium" || value === "high" || value === "xhigh";
}
function finiteTimestamp(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
}
function isLocalRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isAbortError(error: unknown): boolean {
return (error instanceof DOMException || error instanceof Error) && error.name === "AbortError";
}
function hasChatStreamContent(snapshot: ChatStreamSnapshot): boolean {
return Boolean(snapshot.text.trim() || snapshot.reasoning.trim() || snapshot.tools.length);
}
function formatChatSessionTime(value: number, language: string): string {
return new Intl.DateTimeFormat(language, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(new Date(value));
}
const safeAssistantHTMLTags = new Set([
"a", "b", "blockquote", "br", "code", "del", "details", "div", "em", "h1", "h2", "h3", "h4", "h5", "h6",
"hr", "i", "img", "kbd", "li", "mark", "ol", "p", "pre", "s", "span", "strong", "sub", "summary", "sup", "table",
"tbody", "td", "tfoot", "th", "thead", "tr", "u", "ul",
]);
const discardedAssistantHTMLTags = new Set([
"applet", "audio", "base", "button", "canvas", "embed", "form", "frame", "frameset", "iframe", "input", "link",
"math", "meta", "object", "picture", "script", "select", "source", "style", "svg", "template", "textarea", "video",
]);
function renderAssistantMarkup(content: string): string {
const rendered = marked.parse(content, { async: false, breaks: true, gfm: true });
return sanitizeAssistantHTML(typeof rendered === "string" ? rendered : "");
}
function sanitizeAssistantHTML(content: string): string {
if (typeof DOMParser === "undefined") return "";
const source = content.trim();
if (!/<\/?[a-z][^>]*>/i.test(source)) return "";
const documentValue = new DOMParser().parseFromString(source, "text/html");
const elements = Array.from(documentValue.body.querySelectorAll("*"));
for (const element of elements) {
if (!element.isConnected) continue;
const tag = element.tagName.toLowerCase();
if (discardedAssistantHTMLTags.has(tag)) {
element.remove();
continue;
}
if (!safeAssistantHTMLTags.has(tag)) {
element.replaceWith(...Array.from(element.childNodes));
continue;
}
const href = tag === "a" ? safeAssistantLink(element.getAttribute("href")) : "";
const title = tag === "a" ? element.getAttribute("title")?.slice(0, 512) ?? "" : "";
const imageSource = tag === "img" ? safeAssistantImage(element.getAttribute("src")) : "";
const imageAlt = tag === "img" ? element.getAttribute("alt")?.slice(0, 512) ?? "" : "";
const colSpan = tag === "td" || tag === "th" ? boundedTableSpan(element.getAttribute("colspan")) : "";
const rowSpan = tag === "td" || tag === "th" ? boundedTableSpan(element.getAttribute("rowspan")) : "";
const open = tag === "details" && element.hasAttribute("open");
for (const attribute of Array.from(element.attributes)) element.removeAttribute(attribute.name);
if (href) {
element.setAttribute("href", href);
element.setAttribute("target", "_blank");
element.setAttribute("rel", "nofollow noopener noreferrer");
}
if (title) element.setAttribute("title", title);
if (imageSource) {
element.setAttribute("src", imageSource);
element.setAttribute("alt", imageAlt);
element.setAttribute("loading", "lazy");
element.setAttribute("decoding", "async");
element.setAttribute("referrerpolicy", "no-referrer");
} else if (tag === "img") {
element.remove();
continue;
}
if (colSpan) element.setAttribute("colspan", colSpan);
if (rowSpan) element.setAttribute("rowspan", rowSpan);
if (open) element.setAttribute("open", "");
}
return documentValue.body.innerHTML;
}
function safeAssistantLink(value: string | null): string {
const link = value?.trim() ?? "";
if (!link) return "";
try {
const parsed = new URL(link);
return parsed.protocol === "http:" || parsed.protocol === "https:" || parsed.protocol === "mailto:" ? parsed.toString() : "";
} catch {
return "";
}
}
function safeAssistantImage(value: string | null): string {
const source = value?.trim() ?? "";
if (!source) return "";
if (source.startsWith("/v1/media/images/")) return source;
try {
const parsed = new URL(source);
return parsed.protocol === "https:" ? parsed.toString() : "";
} catch {
return "";
}
}
function boundedTableSpan(value: string | null): string {
const parsed = Number.parseInt(value ?? "", 10);
return Number.isInteger(parsed) && parsed >= 1 && parsed <= 100 ? String(parsed) : "";
}
async function listAllPaginatedItems<T>(loadPage: (page: number, pageSize: number) => Promise<{ items: T[]; total: number }>): Promise<T[]> {
const items: T[] = [];
for (let page = 1; page <= 50; page += 1) {
const result = await loadPage(page, 100);
items.push(...result.items);
if (result.items.length === 0 || items.length >= result.total) break;
}
return items;
}