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("chat"); const [selectedKeyId, setSelectedKeyId] = useState(""); const [secretState, setSecretState] = useState(null); const [keyError, setKeyError] = useState(""); const [selectedModels, setSelectedModels] = useState>({ chat: "", image: "", video: "" }); const [chatToolbarElement, setChatToolbarElement] = useState(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>(() => ({ 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 (
setMode(value as CreativeMode)}> {t("creativeConsole.modes.chat")} {t("creativeConsole.modes.image")}
{keysQuery.isError ? void keysQuery.refetch()} /> : null} {!keysQuery.isPending && !keysQuery.isError && activeKeys.length === 0 ? : null} {keyError ? : null} {modelsQuery.isError ? void modelsQuery.refetch()} /> : null}
); } 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(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(initialHistory.active.reasoningEffort); const [promptCacheKey, setPromptCacheKey] = useState(initialHistory.active.promptCacheKey); const [prompt, setPrompt] = useState(""); const [messages, setMessages] = useState(initialHistory.active.messages); const [editingMessageId, setEditingMessageId] = useState(null); const [editDraft, setEditDraft] = useState(""); const [pendingTruncate, setPendingTruncate] = useState(null); const streamSnapshotRef = useRef({ text: "", reasoning: "", tools: [] }); const streamFrameRef = useRef(null); const requestControllerRef = useRef(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): void { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); submit(); } } function handleEditKeyDown(event: KeyboardEvent, messageId: string): void { if (event.key === "Escape") { event.preventDefault(); cancelEditMessage(); return; } if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { event.preventDefault(); saveEditMessage(messageId); } } return (
{toolbarElement ? createPortal(<> {t("creativeConsole.newConversation")} {t("creativeConsole.clearCurrent")} {t("creativeConsole.history")} {sessions.length === 0 ? (
{t("creativeConsole.noHistory")}
) : sessions.map((session) => ( switchConversation(session.id)}>
{session.title}
{session.model || t("creativeConsole.model")} · {formatChatSessionTime(session.updatedAt, i18n.language)}
{session.id === sessionId ? : null}
))}
, toolbarElement) : null} {messages.length === 0 && !mutation.isPending ? : null} {messages.map((message) => ( 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)} /> ))}