diff --git a/ui/desktop/src/assets/battle-game/background.png b/ui/desktop/src/assets/battle-game/background.png new file mode 100644 index 00000000..6e9410bb Binary files /dev/null and b/ui/desktop/src/assets/battle-game/background.png differ diff --git a/ui/desktop/src/assets/battle-game/battle.mp3 b/ui/desktop/src/assets/battle-game/battle.mp3 new file mode 100644 index 00000000..7923636a Binary files /dev/null and b/ui/desktop/src/assets/battle-game/battle.mp3 differ diff --git a/ui/desktop/src/assets/battle-game/goose.png b/ui/desktop/src/assets/battle-game/goose.png new file mode 100644 index 00000000..4c60d6b2 Binary files /dev/null and b/ui/desktop/src/assets/battle-game/goose.png differ diff --git a/ui/desktop/src/assets/battle-game/llama.png b/ui/desktop/src/assets/battle-game/llama.png new file mode 100644 index 00000000..6f915bb2 Binary files /dev/null and b/ui/desktop/src/assets/battle-game/llama.png differ diff --git a/ui/desktop/src/components/settings/OllamaBattleGame.tsx b/ui/desktop/src/components/settings/OllamaBattleGame.tsx new file mode 100644 index 00000000..d251ce63 --- /dev/null +++ b/ui/desktop/src/components/settings/OllamaBattleGame.tsx @@ -0,0 +1,467 @@ +import React, { useState, useEffect, useRef } from 'react'; + +// Import actual PNG images +import llamaSprite from '../../assets/battle-game/llama.png'; +import gooseSprite from '../../assets/battle-game/goose.png'; +import battleBackground from '../../assets/battle-game/background.png'; +import battleMusic from '../../assets/battle-game/battle.mp3'; + +interface BattleState { + currentStep: number; + gooseHp: number; + llamaHp: number; + message: string; + animation: string | null; + lastChoice?: string; + showHostInput?: boolean; + processingAction?: boolean; +} + +interface OllamaBattleGameProps { + onComplete: (configValues: { [key: string]: string }) => void; + requiredKeys: string[]; +} + +export function OllamaBattleGame({ onComplete, _requiredKeys }: OllamaBattleGameProps) { + // Use type assertion for audioRef to avoid DOM lib dependency + const audioRef = useRef(null); + const [isMuted, setIsMuted] = useState(false); + + const [battleState, setBattleState] = useState({ + currentStep: 0, + gooseHp: 100, + llamaHp: 100, + message: 'A wild Ollama appeared!', + animation: null, + processingAction: false, + }); + + const [configValues, setConfigValues] = useState<{ [key: string]: string }>({}); + + // Initialize audio when component mounts + useEffect(() => { + if (typeof window !== 'undefined') { + audioRef.current = new window.Audio(battleMusic); + audioRef.current.loop = true; + audioRef.current.volume = 0.2; + audioRef.current.play().catch((e) => console.log('Audio autoplay prevented:', e)); + } + + return () => { + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current = null; + } + }; + }, []); + + const toggleMute = () => { + if (audioRef.current) { + if (isMuted) { + audioRef.current.volume = 0.2; + } else { + audioRef.current.volume = 0; + } + setIsMuted(!isMuted); + } + }; + + const battleSteps = [ + { + message: 'A wild Ollama appeared!', + action: null, + animation: 'appear', + }, + { + message: 'What will GOOSE do?', + action: 'choice', + choices: ['Pacify', 'HONK!'], + animation: 'attack', + followUpMessages: ["It's not very effective...", 'But OLLAMA is confused!'], + }, + { + message: 'OLLAMA used YAML Confusion!', + action: null, + animation: 'counter', + followUpMessages: ['OLLAMA hurt itself in confusion!', 'GOOSE maintained composure!'], + }, + { + message: 'What will GOOSE do?', + action: 'final_choice', + choices: (previousChoice: string) => [ + previousChoice === 'Pacify' ? 'HONK!' : 'Pacify', + 'Configure Host', + ], + animation: 'attack', + }, + { + message: 'OLLAMA used Docker Dependency!', + action: null, + animation: 'counter', + followUpMessages: ["It's not very effective...", 'GOOSE knows containerization!'], + }, + { + message: 'What will GOOSE do?', + action: 'host_choice', + choices: ['Configure Host'], + animation: 'finish', + }, + { + message: '', // Will be set dynamically based on choice + action: 'host_input', + prompt: 'Enter your Ollama host address:', + configKey: 'OLLAMA_HOST', + animation: 'finish', + followUpMessages: [ + "It's super effective!", + 'OLLAMA has been configured!', + 'OLLAMA joined your team!', + ], + }, + { + message: 'Configuration complete!\nOLLAMA will remember this friendship!', + action: 'complete', + }, + ]; + + const animateHit = (isLlama: boolean) => { + const element = document.querySelector(isLlama ? '.llama-sprite' : '.goose-sprite'); + if (element) { + element.classList.add('hit-flash'); + setTimeout(() => { + element.classList.remove('hit-flash'); + }, 500); + } + }; + + useEffect(() => { + // Add CSS for the hit animation and defeat animation + const style = document.createElement('style'); + style.textContent = ` + @keyframes hitFlash { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } + } + .hit-flash { + animation: hitFlash 0.5s; + } + @keyframes defeat { + 0% { transform: translateY(0); opacity: 1; } + 20% { transform: translateY(-30px); opacity: 1; } + 100% { transform: translateY(500px); opacity: 0; } + } + .defeated { + animation: defeat 1.3s cubic-bezier(.36,.07,.19,.97) both; + } + `; + document.head.appendChild(style); + return () => { + document.head.removeChild(style); + }; + }, []); + + const handleAction = async (value: string) => { + const currentStep = + battleState.currentStep < battleSteps.length ? battleSteps[battleState.currentStep] : null; + + if (!currentStep) return; + + // Handle host input + if (currentStep.action === 'host_input' && value) { + setConfigValues((prev) => ({ + ...prev, + [currentStep.configKey]: value, + })); + return; + } + + // Handle host submit + if (currentStep.action === 'host_input' && !value) { + setBattleState((prev) => ({ + ...prev, + processingAction: true, + llamaHp: 0, + message: "It's super effective!", + })); + animateHit(true); + + // Add defeat class to llama sprite and health bar + const llamaContainer = document.querySelector('.llama-container'); + if (llamaContainer) { + await new Promise((resolve) => setTimeout(resolve, 500)); + llamaContainer.classList.add('defeated'); + } + + // Show victory messages with delays + if (currentStep.followUpMessages) { + for (const msg of currentStep.followUpMessages) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + setBattleState((prev) => ({ ...prev, message: msg })); + } + } + + await new Promise((resolve) => setTimeout(resolve, 1000)); + onComplete(configValues); + return; + } + + // Handle continue button for messages + if (!currentStep.action) { + setBattleState((prev) => ({ + ...prev, + currentStep: prev.currentStep + 1, + message: battleSteps[prev.currentStep + 1]?.message || prev.message, + processingAction: false, + })); + return; + } + + // Handle choices (Pacify/HONK/Configure Host) + if ( + (currentStep.action === 'choice' || + currentStep.action === 'final_choice' || + currentStep.action === 'host_choice') && + value + ) { + // Set processing flag to hide buttons + setBattleState((prev) => ({ + ...prev, + processingAction: true, + })); + + if (value === 'Configure Host') { + setBattleState((prev) => ({ + ...prev, + message: 'GOOSE used Configure Host!', + showHostInput: true, + currentStep: battleSteps.findIndex((step) => step.action === 'host_input'), + processingAction: false, + })); + return; + } + + // Handle Pacify or HONK attacks + setBattleState((prev) => ({ + ...prev, + lastChoice: value, + llamaHp: Math.max(0, prev.llamaHp - 25), + message: `GOOSE used ${value}!`, + })); + animateHit(true); + + // Show follow-up messages + if (currentStep.followUpMessages) { + for (const msg of currentStep.followUpMessages) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + setBattleState((prev) => ({ ...prev, message: msg })); + } + } + + // Proceed to counter-attack + await new Promise((resolve) => setTimeout(resolve, 1000)); + const isFirstCycle = currentStep.action === 'choice'; + const nextStep = battleSteps[battleState.currentStep + 1]; + setBattleState((prev) => ({ + ...prev, + gooseHp: Math.max(0, prev.gooseHp - 25), + message: isFirstCycle ? 'OLLAMA used YAML Confusion!' : 'OLLAMA used Docker Dependency!', + currentStep: prev.currentStep + 1, + processingAction: false, + })); + animateHit(false); + + // Show counter-attack messages + if (nextStep?.followUpMessages) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + for (const msg of nextStep.followUpMessages) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + setBattleState((prev) => ({ ...prev, message: msg })); + } + } + + return; + } + + // Check for battle completion + if (battleState.currentStep === battleSteps.length - 2) { + onComplete(configValues); + } + }; + + return ( +
+ {/* Battle Scene */} +
+ {/* Llama sprite */} +
+
+
+ OLLAMA + Lv.1 +
+
+
+
50 + ? '#10B981' + : battleState.llamaHp > 20 + ? '#F59E0B' + : '#EF4444', + }} + /> +
+ + {Math.floor(battleState.llamaHp)}/100 + +
+
+ Llama +
+ + {/* Goose sprite */} +
+ Goose +
+
+ GOOSE + Lv.99 +
+
+
+
50 + ? '#10B981' + : battleState.gooseHp > 20 + ? '#F59E0B' + : '#EF4444', + }} + /> +
+ + {Math.floor(battleState.gooseHp)}/100 + +
+
+
+
+ + {/* Dialog Box */} +
+
+
+ +
+

+ {battleState.message} +

+ + {battleState.currentStep < battleSteps.length && ( +
+ {/* Show battle choices */} + {(battleSteps[battleState.currentStep].action === 'choice' || + battleSteps[battleState.currentStep].action === 'final_choice' || + battleSteps[battleState.currentStep].action === 'host_choice') && + !battleState.showHostInput && + !battleState.processingAction && ( +
+ {(typeof battleSteps[battleState.currentStep].choices === 'function' + ? battleSteps[battleState.currentStep].choices(battleState.lastChoice || '') + : battleSteps[battleState.currentStep].choices + )?.map((choice: string) => ( + + ))} +
+ )} + + {/* Show host input when needed */} + {battleState.showHostInput && !battleState.processingAction && ( +
+

+ Enter your Ollama host address: +

+
+ handleAction(e.target.value)} + /> + +
+
+ )} + + {/* Continue button for messages */} + {!battleSteps[battleState.currentStep].action && !battleState.processingAction && ( + + )} +
+ )} +
+ + {/* Black corners for that classic Pokemon feel */} +
+
+
+
+
+
+ ); +} diff --git a/ui/desktop/src/components/settings/ProviderSetupModal.tsx b/ui/desktop/src/components/settings/ProviderSetupModal.tsx index 006d0952..bda3cc2d 100644 --- a/ui/desktop/src/components/settings/ProviderSetupModal.tsx +++ b/ui/desktop/src/components/settings/ProviderSetupModal.tsx @@ -5,29 +5,39 @@ import { Input } from '../ui/input'; import { Button } from '../ui/button'; import { required_keys } from './models/hardcoded_stuff'; import { isSecretKey } from './api_keys/utils'; -// import UnionIcon from "../images/Union@2x.svg"; +import { OllamaBattleGame } from './OllamaBattleGame'; interface ProviderSetupModalProps { provider: string; - model: string; - endpoint: string; + _model: string; + _endpoint: string; title?: string; onSubmit: (configValues: { [key: string]: string }) => void; onCancel: () => void; + forceBattle?: boolean; } export function ProviderSetupModal({ provider, - model, - endpoint, + _model, + _endpoint, title, onSubmit, onCancel, + forceBattle = false, }: ProviderSetupModalProps) { const [configValues, setConfigValues] = React.useState<{ [key: string]: string }>({}); const requiredKeys = required_keys[provider] || ['API Key']; const headerText = title || `Setup ${provider}`; + const shouldShowBattle = React.useMemo(() => { + if (forceBattle) return true; + if (provider.toLowerCase() !== 'ollama') return false; + + const now = new Date(); + return now.getMinutes() === 0; + }, [provider, forceBattle]); + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); onSubmit(configValues); @@ -35,62 +45,69 @@ export function ProviderSetupModal({ return (
- +
{/* Header */}
- {/* Purple icon */} - {/*
- Union icon -
*/}

{headerText}

- {/* Form */} -
-
- {requiredKeys.map((keyName) => ( -
- - setConfigValues((prev) => ({ - ...prev, - [keyName]: e.target.value, - })) + {provider.toLowerCase() === 'ollama' && shouldShowBattle ? ( + + ) : ( + +
+ {requiredKeys.map((keyName) => ( +
+ + setConfigValues((prev) => ({ + ...prev, + [keyName]: e.target.value, + })) + } + placeholder={keyName} + className="w-full h-14 px-4 font-regular rounded-lg border shadow-none border-gray-300 bg-white text-lg placeholder:text-gray-400 font-regular text-gray-900" + required + /> +
+ ))} +
{ + if (provider.toLowerCase() === 'ollama') { + onCancel(); + onSubmit({ forceBattle: 'true' }); } - placeholder={keyName} - className="w-full h-14 px-4 font-regular rounded-lg border shadow-none border-gray-300 bg-white text-lg placeholder:text-gray-400 font-regular text-gray-900" - required - /> + }} + > + + {`Your configuration values will be stored securely in the keychain and used only for making requests to ${provider}`}
- ))} -
- - {`Your configuration values will be stored securely in the keychain and used only for making requests to ${provider}`}
-
- {/* Actions */} -
- - -
- + {/* Actions */} +
+ + +
+ + )}
diff --git a/ui/desktop/src/components/settings/providers/BaseProviderGrid.tsx b/ui/desktop/src/components/settings/providers/BaseProviderGrid.tsx index 0af1d6e9..c9fd6249 100644 --- a/ui/desktop/src/components/settings/providers/BaseProviderGrid.tsx +++ b/ui/desktop/src/components/settings/providers/BaseProviderGrid.tsx @@ -1,10 +1,9 @@ import React, { useEffect, useState } from 'react'; -import { Check, Plus, Settings, X, Rocket, RefreshCw, AlertCircle } from 'lucide-react'; +import { Check, Plus, Settings, X, Rocket } from 'lucide-react'; import { Button } from '../../ui/button'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../ui/Tooltip'; import { Portal } from '@radix-ui/react-portal'; import { required_keys } from '../models/hardcoded_stuff'; -import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; import { useActiveKeys } from '../api_keys/ActiveKeysContext'; import { getActiveProviders } from '../api_keys/utils'; diff --git a/ui/desktop/src/components/settings/providers/ConfigureProvidersGrid.tsx b/ui/desktop/src/components/settings/providers/ConfigureProvidersGrid.tsx index 339adc6a..504ea154 100644 --- a/ui/desktop/src/components/settings/providers/ConfigureProvidersGrid.tsx +++ b/ui/desktop/src/components/settings/providers/ConfigureProvidersGrid.tsx @@ -41,7 +41,7 @@ export function ConfigureProvidersGrid() { const { activeKeys, setActiveKeys } = useActiveKeys(); const [showSetupModal, setShowSetupModal] = useState(false); const [selectedForSetup, setSelectedForSetup] = useState(null); - const [modalMode, setModalMode] = useState<'edit' | 'setup'>('setup'); + const [modalMode, setModalMode] = useState<'edit' | 'setup' | 'battle'>('setup'); const [isConfirmationOpen, setIsConfirmationOpen] = useState(false); const [providerToDelete, setProviderToDelete] = useState(null); const { currentModel } = useModel(); @@ -242,11 +242,21 @@ export function ConfigureProvidersGrid() { ? `Edit ${providers.find((p) => p.id === selectedForSetup)?.name} Configuration` : undefined } - onSubmit={handleModalSubmit} + onSubmit={(configValues) => { + if (configValues.forceBattle === 'true') { + setSelectedForSetup(selectedForSetup); + setModalMode('battle'); + setShowSetupModal(true); + return; + } + handleModalSubmit(configValues); + }} onCancel={() => { setShowSetupModal(false); setSelectedForSetup(null); + setModalMode('setup'); }} + forceBattle={modalMode === 'battle'} />
)} diff --git a/ui/desktop/src/styles/pokemon.css b/ui/desktop/src/styles/pokemon.css new file mode 100644 index 00000000..29b48aef --- /dev/null +++ b/ui/desktop/src/styles/pokemon.css @@ -0,0 +1,21 @@ +@font-face { + font-family: 'Pokemon'; + src: url('../assets/fonts/pokemon.woff2') format('woff2'); + font-weight: normal; + font-style: normal; +} + +.font-pokemon { + font-family: + 'Pokemon', + system-ui, + -apple-system, + sans-serif; + letter-spacing: 0.5px; +} + +.pixelated { + image-rendering: pixelated; + image-rendering: -moz-crisp-edges; + image-rendering: crisp-edges; +}