diff --git a/src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx b/src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx index ffa46dbd7e..12c9b304e1 100644 --- a/src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx +++ b/src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx @@ -1,6 +1,13 @@ "use client"; -import { useState, useEffect, useRef, useCallback } from "react"; +import { + useState, + useEffect, + useRef, + useCallback, + useImperativeHandle, + type RefObject, +} from "react"; import { useTranslations } from "next-intl"; import { cn } from "@/shared/utils/cn"; import { useApiKey } from "../../providers/hooks/useApiKey"; @@ -20,10 +27,23 @@ interface Stats { latencyMs: number; } +export interface LlmChatControls { + clear: () => void; + hasMessages: boolean; + streaming: boolean; +} + interface Props { providerId: string; initialModel?: string; embedded?: boolean; + hideToolbar?: boolean; + model?: string; + onModelChange?: (model: string) => void; + selectedKey?: string; + onSelectedKeyChange?: (key: string) => void; + controlsRef?: RefObject; + onControlsChange?: (controls: LlmChatControls) => void; } function extractDeltaContent(line: string): string { @@ -60,22 +80,63 @@ function extractUsage(line: string): { prompt_tokens?: number; completion_tokens } } -export function LlmChatCard({ providerId, initialModel, embedded = false }: Props) { +export function LlmChatCard({ + providerId, + initialModel, + embedded = false, + hideToolbar = false, + model: modelProp, + onModelChange, + selectedKey: selectedKeyProp, + onSelectedKeyChange, + controlsRef, + onControlsChange, +}: Props) { const t = useTranslations("miniPlayground"); const { apiKey, keys } = useApiKey(); const { models } = useProviderModels(providerId); - const [selectedKey, setSelectedKey] = useState(""); - const [model, setModel] = useState(initialModel ?? ""); + const [internalSelectedKey, setInternalSelectedKey] = useState(""); + const [internalModel, setInternalModel] = useState(initialModel ?? ""); + const selectedKey = selectedKeyProp ?? internalSelectedKey; + const setSelectedKey = useCallback( + (k: string) => { + if (onSelectedKeyChange) onSelectedKeyChange(k); + else setInternalSelectedKey(k); + }, + [onSelectedKeyChange] + ); + const model = modelProp ?? internalModel; + const setModel = useCallback( + (m: string) => { + if (onModelChange) onModelChange(m); + else setInternalModel(m); + }, + [onModelChange] + ); + const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [streaming, setStreaming] = useState(false); const [stats, setStats] = useState(null); const abortRef = useRef(null); const scrollRef = useRef(null); + const textareaRef = useRef(null); const firstModel = models[0]?.id ?? ""; const effectiveModel = model || firstModel || initialModel || ""; + // Auto-prefix model with providerId when no provider/model prefix present, to avoid + // OmniRoute "Ambiguous model" rejection when same alias is registered under multiple providers. + const qualifiedModel = effectiveModel.includes("/") + ? effectiveModel + : providerId + ? `${providerId}/${effectiveModel}` + : effectiveModel; + + // Autofocus textarea in embedded mode + useEffect(() => { + if (embedded) textareaRef.current?.focus(); + }, [embedded]); // Auto-scroll to bottom when messages update useEffect(() => { @@ -95,7 +156,7 @@ export function LlmChatCard({ providerId, initialModel, embedded = false }: Prop if (!trimmed || streaming) return; const userMsg: Message = { role: "user", content: trimmed }; - const assistantMsg: Message = { role: "assistant", content: "", model: effectiveModel }; + const assistantMsg: Message = { role: "assistant", content: "", model: qualifiedModel }; setMessages((prev) => [...prev, userMsg, assistantMsg]); setInput(""); setStreaming(true); @@ -117,7 +178,7 @@ export function LlmChatCard({ providerId, initialModel, embedded = false }: Prop "x-connection-id": providerId, }, body: JSON.stringify({ - model: effectiveModel, + model: qualifiedModel, messages: [ // Include history (all except the last assistant placeholder) ...messages, @@ -217,8 +278,10 @@ export function LlmChatCard({ providerId, initialModel, embedded = false }: Prop } finally { setStreaming(false); abortRef.current = null; + // Refocus textarea so user can keep typing + requestAnimationFrame(() => textareaRef.current?.focus()); } - }, [input, streaming, selectedKey, apiKey, providerId, effectiveModel, messages]); + }, [input, streaming, selectedKey, apiKey, providerId, qualifiedModel, messages]); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { @@ -231,11 +294,30 @@ export function LlmChatCard({ providerId, initialModel, embedded = false }: Prop abortRef.current?.abort(); }; - const handleClear = () => { - if (streaming) handleStop(); + const handleClear = useCallback(() => { + if (streaming) abortRef.current?.abort(); setMessages([]); setStats(null); - }; + }, [streaming]); + + useImperativeHandle( + controlsRef, + () => ({ + clear: handleClear, + hasMessages: messages.length > 0, + streaming, + }), + [handleClear, messages.length, streaming] + ); + + // Notify parent of control state changes (for external toolbar) + useEffect(() => { + onControlsChange?.({ + clear: handleClear, + hasMessages: messages.length > 0, + streaming, + }); + }, [onControlsChange, handleClear, messages.length, streaming]); const modelOptions = models.length > 0 ? models : initialModel ? [{ id: initialModel }] : []; @@ -246,53 +328,55 @@ export function LlmChatCard({ providerId, initialModel, embedded = false }: Prop embedded ? "flex-1 min-h-0" : "rounded-lg border border-border bg-bg-card p-4" )} > - {/* Header controls */} -
- {/* Model select */} -
- - -
- {/* Key select */} - {keys.length > 0 && ( -
- + {/* Header controls (hidden when parent renders its own toolbar) */} + {!hideToolbar && ( +
+ {/* Model select */} +
+
- )} - {/* Clear button */} - {messages.length > 0 && ( - - )} -
+ {/* Key select */} + {keys.length > 0 && ( +
+ + +
+ )} + {/* Clear button */} + {messages.length > 0 && ( + + )} +
+ )} {/* Messages */}