diff --git a/bun.lockb b/bun.lockb index 80d40df..579c86a 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 7040811..f092f25 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,13 @@ "lint": "eslint" }, "dependencies": { + "@ai-sdk/anthropic": "^2.0.40", + "@ai-sdk/google": "^2.0.26", "@ai-sdk/openai": "^2.0.53", "@ai-sdk/react": "^2.0.78", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-slot": "^1.2.3", "@vercel/analytics": "^1.5.0", diff --git a/src/app/api/agent/route.ts b/src/app/api/agent/route.ts index d45c429..31e545d 100644 --- a/src/app/api/agent/route.ts +++ b/src/app/api/agent/route.ts @@ -1,52 +1,132 @@ import { createOpenAI } from '@ai-sdk/openai'; +import { createAnthropic } from '@ai-sdk/anthropic'; +import { createGoogleGenerativeAI } from '@ai-sdk/google'; import { convertToModelMessages, stepCountIs, streamText, UIMessage } from 'ai'; import { NextRequest } from 'next/server'; - +import { getModelConfig, getProviderFromModelId, DEFAULT_MODELS, getBestDefaultModel } from '@/lib/models/config'; +import type { ProviderId } from '@/lib/models/config'; export const maxDuration = 300; -export async function POST(req: NextRequest) { +/** + * Get the appropriate AI model instance based on model ID and API keys + */ +function getModel(modelId: string, apiKeys: Record) { + const provider = getProviderFromModelId(modelId); + + if (!provider) { + // Fallback to OpenAI default if model not found + const openaiKey = apiKeys.openai; + if (!openaiKey) { + throw new Error('OpenAI API key is required'); + } + const openai = createOpenAI({ apiKey: openaiKey }); + return openai(DEFAULT_MODELS.openai); + } - const { messages }: { messages: UIMessage[] } = await req.json(); + const apiKey = apiKeys[provider]; + if (!apiKey) { + throw new Error(`${provider} API key is required for model ${modelId}`); + } - // Get API key from query parameter - const apiKey = req.nextUrl.searchParams.get('apiKey'); + switch (provider) { + case 'openai': { + const openai = createOpenAI({ apiKey }); + return openai(modelId); + } + case 'anthropic': { + const anthropic = createAnthropic({ apiKey }); + return anthropic(modelId); + } + case 'google': { + const google = createGoogleGenerativeAI({ apiKey }); + return google(modelId); + } + default: + throw new Error(`Unsupported provider: ${provider}`); + } +} - // Use API key from query param if provided, otherwise fall back to environment variable - const effectiveApiKey = apiKey; - // const effectiveApiKey = apiKey || process.env.OPENAI_API_KEY; +export async function POST(req: NextRequest) { + try { + const { messages, modelId }: { messages: UIMessage[]; modelId?: string } = await req.json(); - if (!effectiveApiKey) { - return new Response( - JSON.stringify({ error: 'API key is required. Please add your OpenAI API key in settings.' }), - { status: 401, headers: { 'Content-Type': 'application/json' } } - ); - } + // Get API keys from query parameters + const openaiKey = req.nextUrl.searchParams.get('openaiKey'); + const anthropicKey = req.nextUrl.searchParams.get('anthropicKey'); + const googleKey = req.nextUrl.searchParams.get('googleKey'); + + // Build API keys object + const apiKeys: Record = { + openai: openaiKey, + anthropic: anthropicKey, + google: googleKey, + }; - // const anthropic = createAnthropic({ apiKey: process.env.ANTROPIC_API_KEY! }) - const openai = createOpenAI({ apiKey: effectiveApiKey }) + // Determine which model to use + // Only use default if no modelId was explicitly provided + let targetModelId = modelId; - const result = streamText({ - // model: anthropic('claude-sonnet-4-20250514' - model: openai('gpt-4'), - messages: convertToModelMessages(messages), + // If no modelId provided, use best available based on API keys + if (!targetModelId || targetModelId.trim() === '') { + targetModelId = getBestDefaultModel(apiKeys); + } - onError: (e => { - console.log("❌❌❌❌ Error in agent: ", e) - }), + // Validate model exists + let modelConfig = getModelConfig(targetModelId); + if (!modelConfig) { + // If model was explicitly provided but not found, return error + if (modelId && modelId.trim() !== '') { + return new Response( + JSON.stringify({ error: `Model "${targetModelId}" not found. Please select a valid model.` }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + } + // If it was a default that's not found, try to find any valid model for the provider + const fallbackModelId = getBestDefaultModel(apiKeys); + const fallbackConfig = getModelConfig(fallbackModelId); + if (fallbackConfig) { + targetModelId = fallbackModelId; + modelConfig = fallbackConfig; + } else { + return new Response( + JSON.stringify({ error: `No valid model configuration available` }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + } + } - onFinish: (e => { - console.log("✅✅✅✅ Agent finished: ", e) - // finish reason - console.log("✅✅✅✅ Agent finished reason: ", e.finishReason) - }), + // Get the model instance + let model; + try { + model = getModel(targetModelId, apiKeys); + } catch (error: any) { + console.error('Error creating model instance:', error); + const errorMessage = error.message || 'API key is required. Please add your API key in settings.'; + return new Response( + JSON.stringify({ error: errorMessage }), + { status: 401, headers: { 'Content-Type': 'application/json' } } + ); + } - // system: `Answer in short and concise sentences.`, + const result = streamText({ + model, + messages: convertToModelMessages(messages), - stopWhen: stepCountIs(15), - tools: {} + onError: (e) => { + console.error("Error in agent: ", e); + }, - }); + stopWhen: stepCountIs(15), + tools: {} + }); - return result.toUIMessageStreamResponse(); + return result.toUIMessageStreamResponse(); + } catch (error: any) { + console.error('Error in API route:', error); + return new Response( + JSON.stringify({ error: error.message || 'Internal server error' }), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ); + } } \ No newline at end of file diff --git a/src/app/chats/[id]/page.tsx b/src/app/chats/[id]/page.tsx index 7249310..e80d9db 100644 --- a/src/app/chats/[id]/page.tsx +++ b/src/app/chats/[id]/page.tsx @@ -163,7 +163,7 @@ export default function ChatPage() { onClick={() => router.push('/chats')} className="bg-black/10 gap-2 shadow-none w-8" > - arrow-left + arrow-left {/* All Chats */} - + @@ -107,7 +107,7 @@ function ChatsPage() { ) : chats.length === 0 ? (
- msg-content + msg-content

No chats yet

Create your first chat to get started

@@ -142,7 +142,7 @@ function ChatsPage() { {deleting === chat.id ? (
) : ( - trash + trash )}
diff --git a/src/app/globals.css b/src/app/globals.css index 5ba689d..5dc4159 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,5 +1,5 @@ @import 'tailwindcss'; -@import "tw-animate-css"; +@import 'tw-animate-css'; @custom-variant dark (&:is(.dark *)); @@ -129,10 +129,32 @@ button { } @layer base { - * { - @apply border-border outline-ring/50; + * { + @apply border-border outline-ring/50; } - body { - @apply bg-background text-foreground; + body { + @apply bg-background text-foreground; } } + +/* width */ +.floatiog-menu::-webkit-scrollbar { + width: 8px; + border-radius: 99px; +} + +/* Track */ +.floatiog-menu::-webkit-scrollbar-track { + background: transparent; +} + +/* Handle */ +.floatiog-menu::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.3); + border-radius: 99px; +} + +/* Handle on hover */ +.floatiog-menu::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.4); +} diff --git a/src/app/provider.tsx b/src/app/provider.tsx index ea99e54..a87a4a9 100644 --- a/src/app/provider.tsx +++ b/src/app/provider.tsx @@ -2,18 +2,43 @@ import { usePlaygroundStore } from '@/store/Playground' import { usePathname } from 'next/navigation' +import { getAllApiKeys } from '@/lib/storage' +import type { ProviderId } from '@/lib/models/config' -import React, { useEffect } from 'react' +import React, { useEffect, useRef } from 'react' function Provider({ children }: { children: React.ReactNode }) { const pathname = usePathname() - const store = usePlaygroundStore() - + const setApiKey = usePlaygroundStore((state) => state.setApiKey) + const reset = usePlaygroundStore((state) => state.reset) + const prevPathnameRef = useRef(null) + // Load API keys from storage on mount (only once) + useEffect(() => { + const allKeys = getAllApiKeys(); + Object.entries(allKeys).forEach(([provider, key]) => { + if (key) { + setApiKey(provider as ProviderId, key); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Only run once on mount + + // Reset store when pathname changes (but not on initial mount) useEffect(() => { - store.reset() - }, [pathname]) + // Skip reset on initial mount + if (prevPathnameRef.current === null) { + prevPathnameRef.current = pathname; + return; + } + + // Only reset if pathname actually changed + if (prevPathnameRef.current !== pathname) { + prevPathnameRef.current = pathname; + reset(); + } + }, [pathname, reset]) return (
diff --git a/src/components/ApiKeyDialog.tsx b/src/components/ApiKeyDialog.tsx index 6724493..4001290 100644 --- a/src/components/ApiKeyDialog.tsx +++ b/src/components/ApiKeyDialog.tsx @@ -1,9 +1,10 @@ "use client" -import { useState, useEffect } from 'react'; -import { Settings, Eye, EyeOff, Check, AlertCircle } from 'lucide-react'; +import { useState, useEffect, startTransition } from 'react'; +import { Eye, EyeOff, Check, AlertCircle } from 'lucide-react'; import { usePlaygroundStore } from '@/store/Playground'; -import { getApiKey, saveApiKey, removeApiKey } from '@/lib/storage'; +import { saveApiKey, removeApiKey, getAllApiKeys } from '@/lib/storage'; +import { PROVIDER_METADATA, type ProviderId } from '@/lib/models/config'; import { Dialog, DialogContent, @@ -19,51 +20,70 @@ import { Label } from '@/components/ui/label'; export function ApiKeyDialog() { const [open, setOpen] = useState(false); - const [apiKeyInput, setApiKeyInput] = useState(''); - const [showApiKey, setShowApiKey] = useState(false); - const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle'); - const { apiKey, setApiKey } = usePlaygroundStore(); + const [activeProvider, setActiveProvider] = useState('openai'); + const [apiKeyInputs, setApiKeyInputs] = useState>({ + openai: '', + anthropic: '', + google: '', + }); + const [showApiKeys, setShowApiKeys] = useState>({ + openai: false, + anthropic: false, + google: false, + }); + const [saveStatus, setSaveStatus] = useState>({ + openai: 'idle', + anthropic: 'idle', + google: 'idle', + }); + + const { apiKeys, setApiKey } = usePlaygroundStore(); - // Load API key from localStorage on mount + // Load API keys from localStorage on mount useEffect(() => { - const storedKey = getApiKey(); - if (storedKey) { - setApiKey(storedKey); - setApiKeyInput(storedKey); - } - }, [setApiKey]); + const allKeys = getAllApiKeys(); + const store = usePlaygroundStore.getState(); + + Object.entries(allKeys).forEach(([provider, key]) => { + if (key) { + store.setApiKey(provider as ProviderId, key); + setApiKeyInputs(prev => ({ + ...prev, + [provider]: key || '', + })); + } + }); + }, []); - const handleSave = () => { - if (!apiKeyInput.trim()) { - setSaveStatus('error'); - setTimeout(() => setSaveStatus('idle'), 2000); + const handleSave = (provider: ProviderId) => { + const apiKey = apiKeyInputs[provider]?.trim() || ''; + + if (!apiKey) { + setSaveStatus(prev => ({ ...prev, [provider]: 'error' })); + setTimeout(() => setSaveStatus(prev => ({ ...prev, [provider]: 'idle' })), 2000); return; } - const success = saveApiKey(apiKeyInput); + const success = saveApiKey(provider, apiKey); if (success) { - setApiKey(apiKeyInput); - setSaveStatus('success'); + setApiKey(provider, apiKey); + setSaveStatus(prev => ({ ...prev, [provider]: 'success' })); setTimeout(() => { - setSaveStatus('idle'); - setOpen(false); + setSaveStatus(prev => ({ ...prev, [provider]: 'idle' })); }, 1000); - window.location.reload(); } else { - setSaveStatus('error'); - setTimeout(() => setSaveStatus('idle'), 2000); + setSaveStatus(prev => ({ ...prev, [provider]: 'error' })); + setTimeout(() => setSaveStatus(prev => ({ ...prev, [provider]: 'idle' })), 2000); } }; - const handleRemove = () => { - removeApiKey(); - setApiKey(null); - setApiKeyInput(''); - setSaveStatus('success'); - window.location.reload(); + const handleRemove = (provider: ProviderId) => { + removeApiKey(provider); + setApiKey(provider, null); + setApiKeyInputs(prev => ({ ...prev, [provider]: '' })); + setSaveStatus(prev => ({ ...prev, [provider]: 'success' })); setTimeout(() => { - setSaveStatus('idle'); - setOpen(false); + setSaveStatus(prev => ({ ...prev, [provider]: 'idle' })); }, 1000); }; @@ -71,22 +91,41 @@ export function ApiKeyDialog() { setOpen(newOpen); if (!newOpen) { // Reset state when closing - setSaveStatus('idle'); - setShowApiKey(false); - // Reset input to current API key - setApiKeyInput(apiKey || ''); + Object.keys(saveStatus).forEach(provider => { + setSaveStatus(prev => ({ ...prev, [provider]: 'idle' })); + }); + Object.keys(showApiKeys).forEach(provider => { + setShowApiKeys(prev => ({ ...prev, [provider]: false })); + }); + // Reset inputs to current API keys + Object.entries(apiKeys).forEach(([provider, key]) => { + setApiKeyInputs(prev => ({ + ...prev, + [provider]: key || '', + })); + }); } }; + // Check if any API key is missing + const hasAnyKey = Object.values(apiKeys).some(key => key?.trim()); + useEffect(() => { - if(!apiKey?.trim()){ - setOpen(true); - }else{ - setOpen(false); + if (!hasAnyKey) { + // Use startTransition to defer state update and avoid synchronous setState in effect + startTransition(() => { + setOpen(true); + }); } - }, [apiKey]) + }, [hasAnyKey]); + + const providerMetadata = PROVIDER_METADATA[activeProvider]; + const currentInput = apiKeyInputs[activeProvider]; + const currentStatus = saveStatus[activeProvider]; + const showKey = showApiKeys[activeProvider]; + const hasKey = !!apiKeys[activeProvider]?.trim(); - return ( + return ( - + - OpenAI API Key + API Keys - Enter your OpenAI API key to use the chat functionality. Your key is stored locally in your browser. + Add API keys for different providers. Your keys are stored locally in your browser. + {/* Provider Tabs */} +
+ {(['openai', 'anthropic', 'google'] as ProviderId[]).map((provider) => { + const metadata = PROVIDER_METADATA[provider]; + const isActive = activeProvider === provider; + const hasProviderKey = !!apiKeys[provider]?.trim(); + + return ( + + ); + })} +
+ + {/* Current Provider Form */}
- +
setApiKeyInput(e.target.value)} + type={showKey ? "text" : "password"} + placeholder="sk-... or api-..." + value={currentInput} + onChange={(e) => setApiKeyInputs(prev => ({ + ...prev, + [activeProvider]: e.target.value, + }))} className="pr-10 bg-white/20 border-2 border-white/20 focus-visible:border-white/60 font-mono text-white/80 font-medium" onKeyDown={(e) => { if (e.key === 'Enter') { - handleSave(); + handleSave(activeProvider); } }} />
- {saveStatus === 'success' && ( + {currentStatus === 'success' && (

API key saved successfully

)} - {saveStatus === 'error' && ( + {currentStatus === 'error' && (

Please enter a valid API key @@ -151,30 +228,30 @@ export function ApiKeyDialog() {

- Note: Your API key is stored locally in your browser and is only sent directly to OpenAI's servers. It is never sent to any other server or stored on our servers. + Note: Your API key is stored locally in your browser and is only sent directly to {providerMetadata.name}'s servers. It is never sent to any other server or stored on our servers.

-

Don't have an API key?

+

Don't have an API key?

- Get one from OpenAI → + Get one from {providerMetadata.name} →
- {apiKey && ( + {hasKey && ( diff --git a/src/components/ModelSwitcher.tsx b/src/components/ModelSwitcher.tsx new file mode 100644 index 0000000..ca09e05 --- /dev/null +++ b/src/components/ModelSwitcher.tsx @@ -0,0 +1,172 @@ +"use client" + +import { ChevronDown } from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from './ui/dropdown-menu'; +import { + MODEL_CONFIGS, + getModelConfig, + getProviderFromModelId, + type ProviderId, +} from '@/lib/models/config'; +import { cn } from '@/lib/utils'; + +/** + * Provider SVG Icons + */ +function ProviderIcon({ provider, className }: { provider: ProviderId; className?: string }) { + switch (provider) { + case 'openai': + return ( + + + + ); + case 'anthropic': + // Claude/Anthropic official logo + return ( + + + + ); + case 'google': + // Google Gemini official logo + return ( + + + + ); + default: + return null; + } +} + +interface ModelSwitcherProps { + /** + * Currently selected model ID + */ + currentModelId: string; + + /** + * Callback when model changes + */ + onModelChange: (modelId: string) => void; + + /** + * Optional className for styling + */ + className?: string; + + /** + * Show model description in the trigger button + */ + showDescription?: boolean; + + /** + * Compact mode (smaller button) + */ + compact?: boolean; +} + +/** + * Scalable Model Switcher Component + * + * Features: + * - Supports multiple providers (OpenAI, Anthropic, Google) + * - Easy to extend with new providers/models + * - Clean, reusable interface + * - Grouped by provider in context menu + */ +export function ModelSwitcher({ + currentModelId, + onModelChange, + className, + showDescription = false, + compact = false, +}: ModelSwitcherProps) { + const currentModel = getModelConfig(currentModelId); + const displayName = currentModel?.name || currentModelId; + const provider = getProviderFromModelId(currentModelId) || 'openai'; + + return ( + + + + + + { + if (value) { + onModelChange(value); + } + }} + > + {/* Render models grouped by provider */} + {(['openai', 'anthropic', 'google'] as ProviderId[]).map( + (provider, index) => { + const models = MODEL_CONFIGS[provider]; + if (!models || models.length === 0) return null; + + return ( +
+ {index > 0 && } + + + + {provider === 'openai' + ? 'OpenAI' + : provider === 'anthropic' + ? 'Anthropic' + : 'Google'} + + + {models.map((model) => ( + +
+ {model.name} + {model.description && ( + + {model.description} + + )} +
+
+ ))} +
+ ); + } + )} +
+
+
+ ); +} + diff --git a/src/components/chat/ChatSection.tsx b/src/components/chat/ChatSection.tsx index e7d9a96..7eabd92 100644 --- a/src/components/chat/ChatSection.tsx +++ b/src/components/chat/ChatSection.tsx @@ -7,7 +7,7 @@ function ChatSection({ messages, status }: { messages: UIMessage {['submitted', 'streaming'].includes(status) && (
- loader-2 + loader-2
)} diff --git a/src/components/nodes/ChatNode.tsx b/src/components/nodes/ChatNode.tsx index af7f5b3..d04416c 100644 --- a/src/components/nodes/ChatNode.tsx +++ b/src/components/nodes/ChatNode.tsx @@ -1,13 +1,16 @@ -import { PlaygroundActions, getHistoricalNodeIds } from '@/lib/playground'; +import { getBestDefaultModel, getProviderFromModelId } from '@/lib/models/config'; +import { PlaygroundActions } from '@/lib/playground'; +import { extractResponseText } from '@/lib/utils/extract-response-text'; import { usePlaygroundStore } from '@/store/Playground'; +import { NodeChat } from '@/types/chat'; import { useChat } from '@ai-sdk/react'; import { Handle, NodeProps, Position } from '@xyflow/react'; import { DefaultChatTransport } from 'ai'; -import { useEffect, useState } from 'react'; -import { NodeChat } from '@/types/chat'; +import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import ChatSection from '../chat/ChatSection'; +import { ModelSwitcher } from '../ModelSwitcher'; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '../ui/context-menu'; - +import { CopyButton } from '../ui/copy-button'; function ChatNode(props: NodeProps) { @@ -15,12 +18,54 @@ function ChatNode(props: NodeProps) { const [question, setQuestion] = useState(''); const [nodeChat, setNodeChat] = useState(null); - const { selectedNodeId, selectedNodeHistoricalNodeIds, apiKey } = usePlaygroundStore(); + const { selectedNodeId, selectedNodeHistoricalNodeIds, apiKeys, getApiKey } = usePlaygroundStore(); + + // Track if model was manually changed by user to prevent auto-override + const modelManuallySetRef = useRef(false); + + // Initialize modelId from persisted state or best available model + const [currentModelId, setCurrentModelId] = useState(() => { + const chat = PlaygroundActions.getNodeChat(props.id); + if (chat?.model) { + return chat.model; + } + // Get API keys from store state if available + const store = usePlaygroundStore.getState(); + return getBestDefaultModel(store.apiKeys); + }); + + // Use ref to always have current modelId for transport body function + // This ensures the body function always reads the latest modelId + const currentModelIdRef = useRef(currentModelId); + useEffect(() => { + currentModelIdRef.current = currentModelId; + }, [currentModelId]); + + // Build API URL with all provider keys + const buildApiUrl = useCallback(() => { + const params = new URLSearchParams(); + if (apiKeys.openai) params.append('openaiKey', apiKeys.openai); + if (apiKeys.anthropic) params.append('anthropicKey', apiKeys.anthropic); + if (apiKeys.google) params.append('googleKey', apiKeys.google); + return `/api/agent?${params.toString()}`; + }, [apiKeys]); + + // Create transport with body as function to always use current modelId from ref + // The transport is created once, but body function reads from ref each time + const transport = useMemo(() => { + return new DefaultChatTransport({ + api: buildApiUrl(), + body: () => { + // Always read from ref to get the latest modelId at request time + const modelId = currentModelIdRef.current; + return { modelId }; + }, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [buildApiUrl]); // Intentionally don't depend on currentModelId - use ref instead const { messages, sendMessage, status, setMessages, error } = useChat({ - transport: new DefaultChatTransport({ - api: apiKey ? `/api/agent?apiKey=${encodeURIComponent(apiKey)}` : `/api/agent`, - }), + transport, onFinish: () => { }, onError: () => { @@ -28,12 +73,15 @@ function ChatNode(props: NodeProps) { }, }); - useEffect(() => { - if (error && !apiKey) { - alert("Please add API key from top right!") + if (error) { + const provider = getProviderFromModelId(currentModelId); + const requiredKey = provider ? getApiKey(provider) : null; + if (!requiredKey) { + alert(`Please add ${provider || 'API'} key from top right!`) + } } - }, [error]) + }, [error, currentModelId, getApiKey]) useEffect(() => { const chat = PlaygroundActions.getNodeChat(props.id) @@ -48,29 +96,75 @@ function ChatNode(props: NodeProps) { if (firstUserMessage && firstUserMessage.parts && firstUserMessage.parts[0]) { const text = firstUserMessage.parts[0].text || '' // Extract the query from the formatted message - // @ts-ignore + // @ts-expect-error - text type checking const queryMatch = text.match(/Query:\s*(.+)/s) if (queryMatch) { setQuestion(queryMatch[1].trim()) } } } + + // Update nodeChat + setNodeChat(chat) } }, [props.id, setMessages]) + // Sync modelId from chat when node ID changes + useEffect(() => { + const chat = PlaygroundActions.getNodeChat(props.id); + + if (chat?.model && chat.model !== currentModelId) { + // Load saved model from chat + modelManuallySetRef.current = false; // Reset flag when loading from chat + startTransition(() => { + setCurrentModelId(chat.model!); + }); + } else if (!chat?.model && !modelManuallySetRef.current) { + // Only set default if no saved model exists AND user hasn't manually set it + const bestModel = getBestDefaultModel(apiKeys); + if (bestModel !== currentModelId) { + startTransition(() => { + setCurrentModelId(bestModel); + }); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.id]) // Only depend on props.id, not currentModelId or apiKeys + useEffect(() => { PlaygroundActions.attachMessageToNode(props.id, messages) }, [messages, props.id]) const handleSendMessage = () => { - if (!apiKey?.trim()) return alert("Please add API key from top right!") + const provider = getProviderFromModelId(currentModelId); + const requiredKey = provider ? getApiKey(provider) : null; + + if (!requiredKey?.trim()) { + // Auto-switch to a model with available API key + const bestModel = getBestDefaultModel(apiKeys); + const bestProvider = getProviderFromModelId(bestModel); + const bestKey = bestProvider ? getApiKey(bestProvider) : null; + + if (bestModel && bestModel !== currentModelId && bestKey?.trim()) { + startTransition(() => { + setCurrentModelId(bestModel); + PlaygroundActions.updateChatModel(props.id, bestModel); + }); + alert(`Switched to ${bestProvider || 'a compatible'} model since ${provider || 'the required'} API key is not available. Please try sending again.`); + return; + } + alert(`Please add ${provider || 'API'} key from top right!`); + return; + } + setSubmitted(true); // set historicals - let messageHistory: any[] = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const messageHistory: any[] = []; let nodeIds: string[] = [] - const historicalNodeIds = getHistoricalNodeIds(props.id, usePlaygroundStore.getState().connectors); + const historicalNodeIds = PlaygroundActions.getHistoricalNodeIds(props.id, usePlaygroundStore.getState().connectors); for (const nodeId of historicalNodeIds.filter(i => i !== nodeChat?.nodeId)) { nodeIds.push(nodeId) @@ -85,7 +179,6 @@ function ChatNode(props: NodeProps) { for (const i in nodeIds) { const chat = PlaygroundActions.getNodeChat(nodeIds[i]) if (chat && Array.isArray(chat.messages)) { - console.log("Chat: ", chat.messages) messageHistory.push(...chat.messages) } } @@ -123,21 +216,6 @@ function ChatNode(props: NodeProps) { }]) } - const handleClick = () => { - - } - - const handleAdd = () => { - let source = "" - - // if component has selected text, set it as the source - if (window.getSelection()?.toString()) { - source = window.getSelection()?.toString() || "" - } - - PlaygroundActions.addNewChatNode(props.id, source.trim()) - } - const handleAddAsSource = () => { let source = "" @@ -161,6 +239,34 @@ function ChatNode(props: NodeProps) { PlaygroundActions.deleteNode(props.id) } + // Extract response text for copying + const responseText = useMemo(() => { + return extractResponseText(messages); + }, [messages]); + + const handleCopyResponse = async () => { + if (!responseText.trim()) return; + + try { + await navigator.clipboard.writeText(responseText); + } catch (err) { + console.error("Failed to copy response:", err); + // Fallback for older browsers + const textArea = document.createElement("textarea"); + textArea.value = responseText; + textArea.style.position = "fixed"; + textArea.style.opacity = "0"; + document.body.appendChild(textArea); + textArea.select(); + try { + document.execCommand("copy"); + } catch (fallbackErr) { + console.error("Fallback copy failed:", fallbackErr); + } + document.body.removeChild(textArea); + } + } + return ( @@ -171,7 +277,7 @@ function ChatNode(props: NodeProps) { {/* selected context */} {nodeChat?.source && (
- merge + merge

{nodeChat.source}

)} @@ -180,22 +286,23 @@ function ChatNode(props: NodeProps) {
- - {/*

{props.id}

*/} + { + modelManuallySetRef.current = true; // Mark as manually set + setCurrentModelId(modelId); + PlaygroundActions.updateChatModel(props.id, modelId); + }} + compact + />
@@ -226,7 +333,17 @@ function ChatNode(props: NodeProps) {

{/* response */} -
+
+ {responseText.trim() && ( +
+ +
+ )}
- - {/*
- -
*/}
{typeof window !== undefined && window.getSelection()?.toString() && ( - text-plus + text-plus Add as source )} - connection-2 + connection-2 New child - {/* Team */} - {/* */} + {submitted && responseText.trim() && ( + + + + + + Copy response + + )} - trash + trash Delete diff --git a/src/components/playground/index.tsx b/src/components/playground/index.tsx index 617c86b..1b4a22a 100644 --- a/src/components/playground/index.tsx +++ b/src/components/playground/index.tsx @@ -20,7 +20,7 @@ const nodeTypes = { function PlaygroundContent() { - const { nodes, setNodes, connectors, setConnectors, setSelectedNodeId, selectedNodeId, setSelectedNodeHistoricalNodeIds, nodeChats, apiKey, fitViewNodeId, setFitViewNodeId } = usePlaygroundStore(); + const { nodes, setNodes, connectors, setConnectors, setSelectedNodeId, selectedNodeId, setSelectedNodeHistoricalNodeIds, nodeChats, apiKeys, fitViewNodeId, setFitViewNodeId } = usePlaygroundStore(); const { screenToFlowPosition, fitView } = useReactFlow(); const store = useStoreApi(); @@ -71,7 +71,7 @@ function PlaygroundContent() { const onNodesChange = useCallback( (changes: NodeChange[]) => setNodes(applyNodeChanges(changes, nodes)), - [nodes, setNodes, nodeChats, apiKey], + [nodes, setNodes, nodeChats, apiKeys], ); const onEdgesChange = useCallback( (changes: EdgeChange[]) => setConnectors(applyEdgeChanges(changes, connectors)), diff --git a/src/components/ui/context-menu.tsx b/src/components/ui/context-menu.tsx index 115d320..68c34d3 100644 --- a/src/components/ui/context-menu.tsx +++ b/src/components/ui/context-menu.tsx @@ -103,9 +103,10 @@ function ContextMenuContent({ data-slot="context-menu-content" className={cn( "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md", - "bg-[#333333]/60 backdrop-blur-sm border-none rounded-xl shadow-2xl", + "bg-[#333333]/75 backdrop-blur-sm border-none rounded-xl shadow-2xl", className )} + onKeyDown={e => e.stopPropagation()} {...props} /> diff --git a/src/components/ui/copy-button.tsx b/src/components/ui/copy-button.tsx new file mode 100644 index 0000000..26eec1c --- /dev/null +++ b/src/components/ui/copy-button.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useState } from "react"; +import { Button, ButtonProps } from "./button"; +import { cn } from "@/lib/utils"; + +interface CopyButtonProps extends Omit { + text: string; + onCopy?: (text: string) => void; + showText?: boolean; + copiedText?: string; + defaultText?: string; +} + +/** + * Reusable CopyButton component that copies text to clipboard + * @param text - The text to copy + * @param onCopy - Optional callback when copy succeeds + * @param showText - Whether to show text label or just icon + * @param copiedText - Text to show when copied (default: "Copied!") + * @param defaultText - Default text to show (default: "Copy") + */ +export function CopyButton({ + text, + onCopy, + showText = false, + copiedText = "Copied!", + defaultText = "Copy", + className, + variant = "ghost", + size = "icon", + ...props +}: CopyButtonProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + if (!text.trim()) return; + + try { + await navigator.clipboard.writeText(text); + setCopied(true); + onCopy?.(text); + + // Reset copied state after 2 seconds + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error("Failed to copy text:", err); + // Fallback for older browsers + const textArea = document.createElement("textarea"); + textArea.value = text; + textArea.style.position = "fixed"; + textArea.style.opacity = "0"; + document.body.appendChild(textArea); + textArea.select(); + try { + document.execCommand("copy"); + setCopied(true); + onCopy?.(text); + setTimeout(() => setCopied(false), 2000); + } catch (fallbackErr) { + console.error("Fallback copy failed:", fallbackErr); + } + document.body.removeChild(textArea); + } + }; + + return ( + + ); +} diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..414abbe --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,265 @@ +"use client" + +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + e.stopPropagation()} + {...props} + /> + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/src/lib/models/config.ts b/src/lib/models/config.ts new file mode 100644 index 0000000..ff0be7a --- /dev/null +++ b/src/lib/models/config.ts @@ -0,0 +1,229 @@ +/** + * Model Configuration System + * + * Defines available AI models across different providers. + * This is a centralized configuration that makes it easy to: + * - Add new models + * - Add new providers + * - Maintain model metadata (name, description, etc.) + */ + +export type ProviderId = 'openai' | 'anthropic' | 'google'; + +export interface ModelConfig { + id: string; + provider: ProviderId; + name: string; + description?: string; + requiresApiKey?: boolean; +} + +/** + * Available models organized by provider + * Easy to extend with new models or providers + */ +export const MODEL_CONFIGS: Record = { + openai: [ + { + id: 'gpt-4', + provider: 'openai', + name: 'GPT-4', + description: 'Most capable model, best for complex tasks', + requiresApiKey: true, + }, + { + id: 'gpt-4.1', + provider: 'openai', + name: 'GPT-4 Turbo', + description: 'Smartest non-reasoning model', + requiresApiKey: true, + }, + { + id: 'gpt-4o', + provider: 'openai', + name: 'GPT-4o', + description: 'Optimized GPT-4 model', + requiresApiKey: true, + }, + { + id: 'gpt-4o-mini', + provider: 'openai', + name: 'GPT-4o Mini', + description: 'Fast and affordable GPT-4 variant', + requiresApiKey: true, + }, + { + id: 'gpt-5', + provider: 'openai', + name: 'GPT-5', + description: 'The best model for coding and agentic tasks across domains', + requiresApiKey: true, + }, + ], + anthropic: [ + { + id: 'claude-sonnet-4-20250514', + provider: 'anthropic', + name: 'Claude Sonnet 4', + description: 'Most capable Claude model', + requiresApiKey: true, + }, + { + id: 'claude-opus-4-20250514', + provider: 'anthropic', + name: 'Claude Opus 4', + description: 'Most powerful Claude model', + requiresApiKey: true, + }, + { + id: 'claude-3-5-haiku-20241022', + provider: 'anthropic', + name: 'Claude 3.5 Haiku', + description: 'Balanced performance and speed', + requiresApiKey: true, + }, + { + id: 'claude-3-haiku-20240307', + provider: 'anthropic', + name: 'Claude 3 Haiku', + description: 'Fastest Claude model', + requiresApiKey: true, + }, + ], + google: [ + { + id: 'gemini-2.5-flash', + provider: 'google', + name: 'Gemini 2.5 Flash', + description: 'Fastest Gemini model', + requiresApiKey: true, + }, + { + id: 'gemini-2.5-pro', + provider: 'google', + name: 'Gemini 2.5 Pro', + description: 'Most capable Gemini model', + requiresApiKey: true, + }, + { + id: 'gemini-2.5-flash-lite', + provider: 'google', + name: 'Gemini 2.5 Flash Lite', + description: 'Fast Gemini model for low-resource environments', + requiresApiKey: true, + }, + { + id: 'gemini-2.0-flash', + provider: 'google', + name: 'Gemini 2.0 Flash', + description: 'Second generation workhorse model', + requiresApiKey: true, + } + ], +}; + +/** + * Normalize model configs to ensure IDs have no trailing/leading whitespace + */ +function normalizeModelConfigs(): ModelConfig[] { + return Object.values(MODEL_CONFIGS).flat().map(model => ({ + ...model, + id: model.id.trim(), // Ensure no whitespace in IDs + })); +} + +/** + * Get all available models flattened into a single array + * Uses normalized configs to prevent whitespace issues + */ +export function getAllModels(): ModelConfig[] { + return normalizeModelConfigs(); +} + +/** + * Get model config by ID + * Trims whitespace and does exact match to prevent issues with trailing spaces + */ +export function getModelConfig(modelId: string): ModelConfig | undefined { + const trimmedId = modelId.trim(); + return getAllModels().find((model) => model.id === trimmedId || model.id.trim() === trimmedId); +} + +/** + * Get models by provider + */ +export function getModelsByProvider(provider: ProviderId): ModelConfig[] { + return MODEL_CONFIGS[provider] || []; +} + +/** + * Get provider from model ID + * Handles trimmed/normalized model IDs + */ +export function getProviderFromModelId(modelId: string): ProviderId | null { + const trimmedId = modelId.trim(); + const model = getModelConfig(trimmedId); + return model?.provider || null; +} + +/** + * Default model for each provider + */ +export const DEFAULT_MODELS: Record = { + openai: 'gpt-4', + anthropic: 'claude-sonnet-4-20250514', + google: 'gemini-2.5-pro', +}; + +/** + * Get default model for a provider + */ +export function getDefaultModel(provider: ProviderId): string { + return DEFAULT_MODELS[provider]; +} + +/** + * Get the best default model based on available API keys + * Returns the first available provider's default model, or OpenAI as fallback + */ +export function getBestDefaultModel(apiKeys: Record): string { + // Check in order of preference + if (apiKeys.openai) return DEFAULT_MODELS.openai; + if (apiKeys.google) return DEFAULT_MODELS.google; + if (apiKeys.anthropic) return DEFAULT_MODELS.anthropic; + + // Fallback to OpenAI if no keys are available + return DEFAULT_MODELS.openai; +} + +/** + * Provider metadata + */ +export interface ProviderMetadata { + id: ProviderId; + name: string; + apiKeyName: string; + apiKeyUrl: string; +} + +export const PROVIDER_METADATA: Record = { + openai: { + id: 'openai', + name: 'OpenAI', + apiKeyName: 'OpenAI API Key', + apiKeyUrl: 'https://platform.openai.com/api-keys', + }, + anthropic: { + id: 'anthropic', + name: 'Anthropic', + apiKeyName: 'Anthropic API Key', + apiKeyUrl: 'https://console.anthropic.com/settings/keys', + }, + google: { + id: 'google', + name: 'Google', + apiKeyName: 'Google API Key', + apiKeyUrl: 'https://makersuite.google.com/app/apikey', + }, +}; + diff --git a/src/lib/playground/actions/add-new-chat-node.ts b/src/lib/playground/actions/add-new-chat-node.ts index 7d58b06..a75e6a6 100644 --- a/src/lib/playground/actions/add-new-chat-node.ts +++ b/src/lib/playground/actions/add-new-chat-node.ts @@ -1,6 +1,7 @@ -import { v4 as uuid } from 'uuid'; -import { NodeChat } from '@/types/chat'; +import { getBestDefaultModel } from '@/lib/models/config'; import { usePlaygroundStore } from "@/store/Playground"; +import { NodeChat } from '@/types/chat'; +import { v4 as uuid } from 'uuid'; export default function addNewChatNode(nodeId?: string, source?: string) { @@ -41,7 +42,8 @@ export default function addNewChatNode(nodeId?: string, source?: string) { createdAt: new Date().toISOString(), messages: [], source: source, - nodeId: id + nodeId: id, + model: getBestDefaultModel(store.apiKeys) } store.setNodeChats([...store.nodeChats, newChat]) } diff --git a/src/lib/playground/actions/index.ts b/src/lib/playground/actions/index.ts index e112da1..7af2da0 100644 --- a/src/lib/playground/actions/index.ts +++ b/src/lib/playground/actions/index.ts @@ -4,9 +4,10 @@ import deleteNode from "./delete-node"; import { getHistoricalNodeIds } from "./get-historical-node-ids"; import getNodeChat from "./get-node-chat"; import handleConnectionEnd from "./handle-connection-end"; +import updateChatModel from "./update-chat-model"; // Export individual functions -export { addNewChatNode, attachMessageToNode, deleteNode, getHistoricalNodeIds, getNodeChat, handleConnectionEnd }; +export { addNewChatNode, attachMessageToNode, deleteNode, getHistoricalNodeIds, getNodeChat, handleConnectionEnd, updateChatModel }; // Export as a namespace object export const PlaygroundActions = { @@ -16,4 +17,5 @@ export const PlaygroundActions = { getNodeChat, handleConnectionEnd, getHistoricalNodeIds, + updateChatModel, }; \ No newline at end of file diff --git a/src/lib/playground/actions/update-chat-model.ts b/src/lib/playground/actions/update-chat-model.ts new file mode 100644 index 0000000..288dcb9 --- /dev/null +++ b/src/lib/playground/actions/update-chat-model.ts @@ -0,0 +1,31 @@ +import { usePlaygroundStore } from "@/store/Playground"; +import { NodeChat } from '@/types/chat'; +import { v4 as uuid } from 'uuid'; + + +export default function updateChatModel(nodeId: string, modelId: string) { + + const store = usePlaygroundStore.getState() + const chat = store.nodeChats.find(i => i.nodeId === nodeId) + + if (chat) { + // Update existing chat + store.setNodeChats( + store.nodeChats.map(c => + c.nodeId === nodeId + ? { ...c, model: modelId } + : c + ) + ); + } else { + // Create new chat with the selected model + const newChat: NodeChat = { + id: uuid(), + nodeId: nodeId, + messages: [], + model: modelId, + createdAt: new Date().toISOString(), + }; + store.setNodeChats([...store.nodeChats, newChat]); + } +} diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 6c3a39f..74a67fa 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -1,9 +1,14 @@ /** * LocalStorage helper functions for managing API keys and application state + * Extended to support multiple providers */ +export type ProviderId = 'openai' | 'anthropic' | 'google'; + const STORAGE_KEYS = { OPENAI_API_KEY: 'pod-ai:openai-api-key', + ANTHROPIC_API_KEY: 'pod-ai:anthropic-api-key', + GOOGLE_API_KEY: 'pod-ai:google-api-key', } as const; /** @@ -60,25 +65,65 @@ export function removeStorageItem(key: string): boolean { } /** - * Get the OpenAI API key from localStorage + * Get API key for a specific provider */ -export function getApiKey(): string | null { - return getStorageItem(STORAGE_KEYS.OPENAI_API_KEY); +export function getApiKey(provider: ProviderId): string | null { + const keyMap: Record = { + openai: STORAGE_KEYS.OPENAI_API_KEY, + anthropic: STORAGE_KEYS.ANTHROPIC_API_KEY, + google: STORAGE_KEYS.GOOGLE_API_KEY, + }; + return getStorageItem(keyMap[provider]); } /** - * Save the OpenAI API key to localStorage + * Save API key for a specific provider */ -export function saveApiKey(apiKey: string): boolean { +export function saveApiKey(provider: ProviderId, apiKey: string): boolean { + const keyMap: Record = { + openai: STORAGE_KEYS.OPENAI_API_KEY, + anthropic: STORAGE_KEYS.ANTHROPIC_API_KEY, + google: STORAGE_KEYS.GOOGLE_API_KEY, + }; + if (!apiKey || apiKey.trim() === '') { - return removeApiKey(); + return removeApiKey(provider); } - return setStorageItem(STORAGE_KEYS.OPENAI_API_KEY, apiKey.trim()); + return setStorageItem(keyMap[provider], apiKey.trim()); +} + +/** + * Remove API key for a specific provider + */ +export function removeApiKey(provider: ProviderId): boolean { + const keyMap: Record = { + openai: STORAGE_KEYS.OPENAI_API_KEY, + anthropic: STORAGE_KEYS.ANTHROPIC_API_KEY, + google: STORAGE_KEYS.GOOGLE_API_KEY, + }; + return removeStorageItem(keyMap[provider]); } /** - * Remove the OpenAI API key from localStorage + * Get all API keys */ -export function removeApiKey(): boolean { - return removeStorageItem(STORAGE_KEYS.OPENAI_API_KEY); +export function getAllApiKeys(): Record { + return { + openai: getApiKey('openai'), + anthropic: getApiKey('anthropic'), + google: getApiKey('google'), + }; +} + +// Legacy functions for backward compatibility +export function getApiKey_legacy(): string | null { + return getApiKey('openai'); +} + +export function saveApiKey_legacy(apiKey: string): boolean { + return saveApiKey('openai', apiKey); +} + +export function removeApiKey_legacy(): boolean { + return removeApiKey('openai'); } diff --git a/src/lib/utils/extract-response-text.ts b/src/lib/utils/extract-response-text.ts new file mode 100644 index 0000000..9a46934 --- /dev/null +++ b/src/lib/utils/extract-response-text.ts @@ -0,0 +1,26 @@ +import { UIMessage } from '@ai-sdk/react'; + +/** + * Extracts all assistant response text from messages + * @param messages - Array of UI messages + * @returns Combined text from all assistant messages + */ +export function extractResponseText( + messages: any[] +): string { + const textParts: string[] = []; + + for (const message of messages) { + if (message.role === 'assistant' && message.parts) { + for (const part of message.parts) { + if (part.type === 'text' && typeof part.text === 'string') { + textParts.push(part.text); + } else if (part.type === 'reasoning' && typeof part.text === 'string') { + textParts.push(part.text); + } + } + } + } + + return textParts.join('\n\n').trim(); +} diff --git a/src/store/Playground.ts b/src/store/Playground.ts index bf5183c..1a75345 100644 --- a/src/store/Playground.ts +++ b/src/store/Playground.ts @@ -1,6 +1,8 @@ import { Edge, Node } from '@xyflow/react'; import { create } from 'zustand'; import { NodeChat } from '@/types/chat'; +import { DEFAULT_MODELS } from '@/lib/models/config'; +import type { ProviderId } from '@/lib/models/config'; interface PlaygroundStore { @@ -19,8 +21,10 @@ interface PlaygroundStore { selectedNodeHistoricalNodeIds: string[] | null; setSelectedNodeHistoricalNodeIds: (nodeIds: string[] | null) => void; - apiKey: string | null; - setApiKey: (apiKey: string | null) => void; + // API keys for different providers + apiKeys: Record; + setApiKey: (provider: ProviderId, apiKey: string | null) => void; + getApiKey: (provider: ProviderId) => string | null; fitViewNodeId: string | null; setFitViewNodeId: (nodeId: string | null) => void; @@ -28,7 +32,7 @@ interface PlaygroundStore { reset: () => void; } -export const usePlaygroundStore = create((set) => ({ +export const usePlaygroundStore = create((set, get) => ({ nodes: [], connectors: [], @@ -44,8 +48,21 @@ export const usePlaygroundStore = create((set) => ({ selectedNodeHistoricalNodeIds: null, setSelectedNodeHistoricalNodeIds: (selectedNodeHistoricalNodeIds) => set({ selectedNodeHistoricalNodeIds }), - apiKey: null, - setApiKey: (apiKey) => set({ apiKey }), + apiKeys: { + openai: null, + anthropic: null, + google: null, + }, + setApiKey: (provider, apiKey) => set((state) => ({ + apiKeys: { + ...state.apiKeys, + [provider]: apiKey, + }, + })), + getApiKey: (provider) => { + const state = get(); + return state.apiKeys[provider] || null; + }, fitViewNodeId: null, setFitViewNodeId: (fitViewNodeId) => set({ fitViewNodeId }), diff --git a/src/types/chat.ts b/src/types/chat.ts index 5f2e77e..d8a7ed7 100644 --- a/src/types/chat.ts +++ b/src/types/chat.ts @@ -7,6 +7,7 @@ export type NodeChat = { nodeId: string messages: any[] source?: string + model?: string } /**