Compare commits

...

7 Commits

Author SHA1 Message Date
JorySeverijnse
5ed5b2d49b
Delete .env 2025-12-04 13:44:07 +01:00
someone
69368e1461 added correct aiProviders.ts 2025-12-04 13:41:46 +01:00
someone
0757265b0d removed hardcoded api key 2025-12-04 12:08:41 +01:00
gpt-engineer-app[bot]
ad7e9c4c86 Add custom API key option
Expose ability to configure a custom AI API key in UI while preserving default key usage; store custom config locally and adapt requests to use either the default provider or user-provided endpoint/key/model. Key remains accessible in code for GPT-OSS IP-locked integration, but a new Custom API path allows safer testing with user-supplied credentials.

X-Lovable-Edit-ID: edt-b36d6ce3-a723-4d18-b4b0-e2689af97347
2025-12-04 00:43:39 +00:00
gpt-engineer-app[bot]
7218d57061 Changes 2025-12-04 00:43:38 +00:00
gpt-engineer-app[bot]
3e56bb56cc Add multi AI providers support
Introduce a second AI provider (GPT-OSS-20B) and UI to switch between Pollinations.ai and GPT-OSS, including integration scaffolding and API key handling as a secret. This enables testing the new model without altering core UX.

X-Lovable-Edit-ID: edt-0d2ba6be-027b-4029-9e71-17c272bde735
2025-12-04 00:34:30 +00:00
gpt-engineer-app[bot]
eff48e239f Changes 2025-12-04 00:34:29 +00:00
2 changed files with 282 additions and 9 deletions

74
src/lib/aiProviders.ts Normal file
View File

@ -0,0 +1,74 @@
export type AIProvider = 'pollinations' | 'gpt-oss' | 'custom';
export interface AIProviderConfig {
id: AIProvider;
name: string;
description: string;
endpoint: string;
requiresAuth: boolean;
apiKey?: string;
model?: string;
allowsCustomKey?: boolean;
}
const getGptOssApiKey = (): string | undefined => {
return import.meta.env?.VITE_GPT_OSS_API_KEY ||
process.env.VITE_GPT_OSS_API_KEY ||
process.env.NEXT_PUBLIC_GPT_OSS_API_KEY;
};
export const AI_PROVIDERS: AIProviderConfig[] = [
{
id: 'pollinations',
name: 'Pollinations.ai',
description: 'Free, no login required',
endpoint: 'https://text.pollinations.ai/openai',
requiresAuth: false,
model: 'openai',
},
{
id: 'gpt-oss',
name: 'GPT-OSS-20B',
description: 'Alternative model (requires API key)',
endpoint: 'https://api.pawan.krd/gpt-oss-20b/v1/chat/completions',
requiresAuth: true,
get apiKey() {
return getGptOssApiKey();
},
model: 'gpt-oss-20b',
},
{
id: 'custom',
name: 'Custom API',
description: 'Use your own OpenAI-compatible API',
endpoint: '',
requiresAuth: true,
allowsCustomKey: true,
model: '',
},
];
export const getProvider = (id: AIProvider): AIProviderConfig => {
const provider = AI_PROVIDERS.find(p => p.id === id) || AI_PROVIDERS[0];
if (id === 'custom') {
const stored = localStorage.getItem(CUSTOM_API_STORAGE_KEY);
if (stored) {
try {
const customConfig = JSON.parse(stored);
return {
...provider,
endpoint: customConfig.endpoint || '',
apiKey: customConfig.apiKey || undefined,
model: customConfig.model || '',
};
} catch {
return provider;
}
}
}
return provider;
};
export const CUSTOM_API_STORAGE_KEY = 'ai-chat-custom-api';

View File

@ -1,6 +1,6 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Send, Bot, User, Loader2, Trash2, AlertTriangle, Maximize2, Minimize2 } from 'lucide-react'; import { Send, Bot, User, Loader2, Trash2, AlertTriangle, Maximize2, Minimize2, ChevronDown, Settings } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
@ -8,6 +8,22 @@ import { useSettings } from '@/contexts/SettingsContext';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import GlitchText from '@/components/GlitchText'; import GlitchText from '@/components/GlitchText';
import MessageContent from '@/components/MessageContent'; import MessageContent from '@/components/MessageContent';
import { AI_PROVIDERS, AIProvider, getProvider, CUSTOM_API_STORAGE_KEY } from '@/lib/aiProviders';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
interface Message { interface Message {
id: string; id: string;
@ -16,7 +32,14 @@ interface Message {
timestamp: Date; timestamp: Date;
} }
interface CustomApiConfig {
endpoint: string;
apiKey: string;
model: string;
}
const STORAGE_KEY = 'ai-chat-history'; const STORAGE_KEY = 'ai-chat-history';
const PROVIDER_KEY = 'ai-chat-provider';
const AIChat = () => { const AIChat = () => {
const [messages, setMessages] = useState<Message[]>(() => { const [messages, setMessages] = useState<Message[]>(() => {
@ -37,6 +60,23 @@ const AIChat = () => {
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
const [selectedProvider, setSelectedProvider] = useState<AIProvider>(() => {
const stored = localStorage.getItem(PROVIDER_KEY);
return (stored as AIProvider) || 'pollinations';
});
const [customApiConfig, setCustomApiConfig] = useState<CustomApiConfig>(() => {
const stored = localStorage.getItem(CUSTOM_API_STORAGE_KEY);
if (stored) {
try {
return JSON.parse(stored);
} catch {
return { endpoint: '', apiKey: '', model: '' };
}
}
return { endpoint: '', apiKey: '', model: '' };
});
const [showCustomApiDialog, setShowCustomApiDialog] = useState(false);
const [tempCustomConfig, setTempCustomConfig] = useState<CustomApiConfig>({ endpoint: '', apiKey: '', model: '' });
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const { playSound } = useSettings(); const { playSound } = useSettings();
const { toast } = useToast(); const { toast } = useToast();
@ -50,6 +90,18 @@ const AIChat = () => {
} }
}, [messages]); }, [messages]);
// Persist provider selection
useEffect(() => {
localStorage.setItem(PROVIDER_KEY, selectedProvider);
}, [selectedProvider]);
// Persist custom API config
useEffect(() => {
if (customApiConfig.endpoint || customApiConfig.apiKey) {
localStorage.setItem(CUSTOM_API_STORAGE_KEY, JSON.stringify(customApiConfig));
}
}, [customApiConfig]);
// Exit fullscreen on Escape key // Exit fullscreen on Escape key
useEffect(() => { useEffect(() => {
const handleEscape = (e: KeyboardEvent) => { const handleEscape = (e: KeyboardEvent) => {
@ -146,16 +198,40 @@ const AIChat = () => {
setMessages(prev => [...prev, assistantMessage]); setMessages(prev => [...prev, assistantMessage]);
// Helper function to make API request with retry logic // Helper function to make API request with retry logic
const provider = getProvider(selectedProvider);
// For custom provider, use user's config
const apiEndpoint = selectedProvider === 'custom' ? customApiConfig.endpoint : provider.endpoint;
const apiKey = selectedProvider === 'custom' ? customApiConfig.apiKey : provider.apiKey;
const apiModel = selectedProvider === 'custom' ? customApiConfig.model : provider.model;
if (selectedProvider === 'custom' && (!customApiConfig.endpoint || !customApiConfig.apiKey)) {
toast({
title: 'Custom API not configured',
description: 'Please configure your custom API settings first.',
variant: 'destructive',
});
setMessages(prev => prev.filter(msg => msg.id !== assistantMessage.id));
setIsLoading(false);
return;
}
const makeRequest = async (retries = 3, delay = 1000): Promise<Response> => { const makeRequest = async (retries = 3, delay = 1000): Promise<Response> => {
for (let attempt = 1; attempt <= retries; attempt++) { for (let attempt = 1; attempt <= retries; attempt++) {
try { try {
const response = await fetch('https://text.pollinations.ai/openai', { const headers: Record<string, string> = {
method: 'POST',
headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, };
if ((provider.requiresAuth || selectedProvider === 'custom') && apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`;
}
const response = await fetch(apiEndpoint, {
method: 'POST',
headers,
body: JSON.stringify({ body: JSON.stringify({
model: 'openai', model: apiModel,
messages: [ messages: [
{ role: 'system', content: 'You are a helpful AI assistant with a hacker/cyberpunk personality. Keep responses concise and engaging. IMPORTANT: When sharing code examples, ALWAYS wrap them in markdown code blocks with the language specified, like ```python\ncode here\n``` or ```javascript\ncode here\n```. Never show code without proper markdown code block formatting.' }, { role: 'system', content: 'You are a helpful AI assistant with a hacker/cyberpunk personality. Keep responses concise and engaging. IMPORTANT: When sharing code examples, ALWAYS wrap them in markdown code blocks with the language specified, like ```python\ncode here\n``` or ```javascript\ncode here\n```. Never show code without proper markdown code block formatting.' },
...chatHistory, ...chatHistory,
@ -348,9 +424,64 @@ const AIChat = () => {
</div> </div>
</div> </div>
<p className="text-muted-foreground mb-4 font-pixel text-sm"> <div className="flex items-center gap-2 mb-4">
{'>'} Free AI chat powered by Pollinations.ai - no login required <span className="text-muted-foreground font-pixel text-sm">{'>'} Model:</span>
</p> <DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-primary hover:bg-primary/20 font-pixel text-sm gap-1"
>
{getProvider(selectedProvider).name}
<ChevronDown className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-background border-primary/50">
{AI_PROVIDERS.map((provider) => (
<DropdownMenuItem
key={provider.id}
onClick={() => {
if (provider.id === 'custom') {
setTempCustomConfig(customApiConfig);
setShowCustomApiDialog(true);
}
setSelectedProvider(provider.id);
playSound('click');
}}
className={`font-pixel text-xs cursor-pointer ${
selectedProvider === provider.id ? 'text-primary' : 'text-foreground'
}`}
>
<div className="flex items-center gap-2 w-full">
<div className="flex-1">
<div>{provider.name}</div>
<div className="text-muted-foreground text-[10px]">{provider.description}</div>
</div>
{provider.id === 'custom' && (
<Settings className="w-3 h-3 text-muted-foreground" />
)}
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{selectedProvider === 'custom' && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setTempCustomConfig(customApiConfig);
setShowCustomApiDialog(true);
playSound('click');
}}
className="text-primary hover:bg-primary/20 font-pixel text-xs"
>
<Settings className="w-3 h-3 mr-1" />
Configure
</Button>
)}
</div>
<ScrollArea className="flex-1 border border-primary/30 rounded-lg p-4 mb-4 bg-background/50" ref={scrollRef}> <ScrollArea className="flex-1 border border-primary/30 rounded-lg p-4 mb-4 bg-background/50" ref={scrollRef}>
{messages.length === 0 ? ( {messages.length === 0 ? (
@ -449,6 +580,74 @@ const AIChat = () => {
)} )}
</Button> </Button>
</div> </div>
{/* Custom API Configuration Dialog */}
<Dialog open={showCustomApiDialog} onOpenChange={setShowCustomApiDialog}>
<DialogContent className="bg-background border-primary/50">
<DialogHeader>
<DialogTitle className="font-pixel text-primary">Custom API Configuration</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="endpoint" className="font-pixel text-sm">API Endpoint</Label>
<Input
id="endpoint"
placeholder="https://api.example.com/v1/chat/completions"
value={tempCustomConfig.endpoint}
onChange={(e) => setTempCustomConfig(prev => ({ ...prev, endpoint: e.target.value }))}
className="font-pixel text-sm bg-background/50 border-primary/50"
/>
</div>
<div className="space-y-2">
<Label htmlFor="apiKey" className="font-pixel text-sm">API Key</Label>
<Input
id="apiKey"
type="password"
placeholder="sk-..."
value={tempCustomConfig.apiKey}
onChange={(e) => setTempCustomConfig(prev => ({ ...prev, apiKey: e.target.value }))}
className="font-pixel text-sm bg-background/50 border-primary/50"
/>
</div>
<div className="space-y-2">
<Label htmlFor="model" className="font-pixel text-sm">Model Name</Label>
<Input
id="model"
placeholder="gpt-4, claude-3, etc."
value={tempCustomConfig.model}
onChange={(e) => setTempCustomConfig(prev => ({ ...prev, model: e.target.value }))}
className="font-pixel text-sm bg-background/50 border-primary/50"
/>
</div>
<p className="text-xs text-muted-foreground font-pixel">
Your API key is stored locally in your browser and never sent to our servers.
</p>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setShowCustomApiDialog(false)}
className="font-pixel text-sm"
>
Cancel
</Button>
<Button
onClick={() => {
setCustomApiConfig(tempCustomConfig);
setShowCustomApiDialog(false);
playSound('click');
toast({
title: 'Configuration Saved',
description: 'Custom API settings have been updated.',
});
}}
className="font-pixel text-sm bg-primary/20 border border-primary hover:bg-primary/30 text-primary"
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</motion.div> </motion.div>
); );
}; };