Move AI model selector from Dork chat to Settings > Advanced

This commit is contained in:
Lemon
2026-03-30 23:44:52 -07:00
parent 7cb244e8a9
commit 4ff019e9cf
5 changed files with 109 additions and 49 deletions
+1
View File
@@ -156,6 +156,7 @@ const hardcodedConfig: AppConfig = {
{ id: 'hot-posts' },
{ id: 'wikipedia' },
],
aiModel: '',
};
/**
+92 -2
View File
@@ -1,12 +1,14 @@
import { useState } from 'react';
import { ChevronDown, ChevronUp, Bug, RotateCcw, AlertTriangle } from 'lucide-react';
import { useState, useEffect } from 'react';
import { ChevronDown, ChevronUp, Bot, Bug, RotateCcw, AlertTriangle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { RequestToVanishDialog } from '@/components/RequestToVanishDialog';
import { useAppContext } from '@/hooks/useAppContext';
import { useShakespeare, type Model } from '@/hooks/useShakespeare';
import { useToast } from '@/hooks/useToast';
import { useEncryptedSettings } from '@/hooks/useEncryptedSettings';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@@ -19,6 +21,10 @@ export function AdvancedSettings() {
const { toast } = useToast();
const { updateSettings } = useEncryptedSettings();
const { user } = useCurrentUser();
const { getAvailableModels } = useShakespeare();
const [aiOpen, setAiOpen] = useState(false);
const [aiModels, setAiModels] = useState<Model[]>([]);
const [aiModelsLoading, setAiModelsLoading] = useState(false);
const [systemOpen, setSystemOpen] = useState(true);
const [sentryOpen, setSentryOpen] = useState(false);
const [dangerOpen, setDangerOpen] = useState(false);
@@ -29,6 +35,26 @@ export function AdvancedSettings() {
const [corsProxy, setCorsProxy] = useState(config.corsProxy);
const [sentryDsn, setSentryDsn] = useState(config.sentryDsn);
// Fetch AI models when the section opens
useEffect(() => {
if (!aiOpen || !user || aiModels.length > 0) return;
let cancelled = false;
setAiModelsLoading(true);
getAvailableModels()
.then((response) => {
if (cancelled) return;
const sorted = response.data.sort((a, b) => {
const costA = parseFloat(a.pricing.prompt) + parseFloat(a.pricing.completion);
const costB = parseFloat(b.pricing.prompt) + parseFloat(b.pricing.completion);
return costA - costB;
});
setAiModels(sorted);
})
.catch(() => {})
.finally(() => { if (!cancelled) setAiModelsLoading(false); });
return () => { cancelled = true; };
}, [aiOpen, user, aiModels.length, getAvailableModels]);
const handleStatsPubkeyChange = (value: string) => {
setStatsPubkey(value);
if (value.length === 64 && /^[0-9a-f]{64}$/i.test(value)) {
@@ -42,6 +68,70 @@ export function AdvancedSettings() {
return (
<div>
{/* Dork AI Section */}
{user && (
<div>
<Collapsible open={aiOpen} onOpenChange={setAiOpen}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="relative w-full justify-between px-3 py-3.5 h-auto hover:bg-muted/20 hover:text-foreground rounded-none"
>
<span className="flex items-center gap-2 text-base font-semibold">
<Bot className="h-4 w-4" />
Dork
</span>
{aiOpen ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="px-4 py-4 space-y-4 border-b border-border">
<div className="space-y-2">
<Label htmlFor="ai-model">Model</Label>
<Select
value={config.aiModel || (aiModels.length > 0 ? aiModels[0].id : '')}
onValueChange={(value) => {
updateConfig(() => ({ aiModel: value }));
toast({ title: 'AI model updated' });
}}
disabled={aiModelsLoading || aiModels.length === 0}
>
<SelectTrigger id="ai-model">
<SelectValue placeholder={aiModelsLoading ? 'Loading models...' : 'Select model'} />
</SelectTrigger>
<SelectContent>
{aiModels.map((model) => {
const totalCost = parseFloat(model.pricing.prompt) + parseFloat(model.pricing.completion);
const isFree = totalCost === 0;
return (
<SelectItem key={model.id} value={model.id}>
<span className="flex items-center gap-1.5">
{model.name}
{isFree && (
<span className="text-[10px] font-medium text-green-600 dark:text-green-400 bg-green-500/10 px-1 rounded">
FREE
</span>
)}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Choose which AI model Dork uses for chat responses.
</p>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
)}
{/* System Section (includes Stats Source) */}
<div>
<Collapsible open={systemOpen} onOpenChange={setSystemOpen}>
+2
View File
@@ -247,6 +247,8 @@ export interface AppConfig {
sandboxDomain: string;
/** Ordered list of right sidebar widget configs. Each entry is a widget type ID with optional display settings. */
sidebarWidgets: WidgetConfig[];
/** Selected AI model ID for Dork chat. Empty string means "use cheapest available". */
aiModel: string;
}
/** Configuration for a single widget in the right sidebar. */
+13 -21
View File
@@ -1,6 +1,7 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { useShakespeare, type ChatMessage, type Model } from '@/hooks/useShakespeare';
import { useShakespeare, type ChatMessage } from '@/hooks/useShakespeare';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAppContext } from '@/hooks/useAppContext';
import { useAIChatTools } from '@/hooks/useAIChatTools';
import { TOOLS, type DisplayMessage, type ToolCall } from '@/lib/aiChatTools';
import { SYSTEM_PROMPT } from '@/lib/aiChatSystemPrompt';
@@ -40,15 +41,17 @@ function saveMessages(messages: DisplayMessage[]): void {
export function useAIChatSession() {
const { user } = useCurrentUser();
const { config } = useAppContext();
const { sendChatMessage, getAvailableModels, getCredits, isLoading: apiLoading, error: apiError, clearError } = useShakespeare();
const { executeToolCall } = useAIChatTools();
const [messages, setMessages] = useState<DisplayMessage[]>(loadMessages);
const [input, setInput] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [models, setModels] = useState<Model[]>([]);
const [selectedModel, setSelectedModel] = useState('');
const [modelsLoading, setModelsLoading] = useState(false);
// Resolve the effective model: config value, or fetch the cheapest as default
const [defaultModel, setDefaultModel] = useState('');
const selectedModel = config.aiModel || defaultModel;
const messagesEndRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
@@ -63,13 +66,11 @@ export function useAIChatSession() {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Fetch available models on mount
// Fetch cheapest model as fallback when no model is configured
useEffect(() => {
if (!user) return;
if (!user || config.aiModel) return;
let cancelled = false;
setModelsLoading(true);
getAvailableModels()
.then((response) => {
if (cancelled) return;
@@ -78,20 +79,14 @@ export function useAIChatSession() {
const costB = parseFloat(b.pricing.prompt) + parseFloat(b.pricing.completion);
return costA - costB;
});
setModels(sorted);
if (sorted.length > 0 && !selectedModel) {
setSelectedModel(sorted[0].id);
if (sorted.length > 0) {
setDefaultModel(sorted[0].id);
}
})
.catch((err) => {
if (!cancelled) console.error('Failed to fetch models:', err);
})
.finally(() => {
if (!cancelled) setModelsLoading(false);
});
.catch(() => {});
return () => { cancelled = true; };
}, [user, getAvailableModels]); // eslint-disable-line react-hooks/exhaustive-deps
}, [user, config.aiModel, getAvailableModels]);
// Build the chat messages array for the API
const buildApiMessages = useCallback((displayMsgs: DisplayMessage[]): ChatMessage[] => {
@@ -271,10 +266,7 @@ export function useAIChatSession() {
input,
setInput,
isStreaming,
models,
selectedModel,
setSelectedModel,
modelsLoading,
apiLoading,
apiError,
messagesEndRef,
+1 -26
View File
@@ -15,7 +15,6 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { useLayoutOptions } from '@/contexts/LayoutContext';
@@ -27,8 +26,7 @@ export function AIChatPage() {
const { config } = useAppContext();
const { user } = useCurrentUser();
const {
messages, input, setInput, isStreaming,
models, selectedModel, setSelectedModel, modelsLoading,
messages, input, setInput, isStreaming, selectedModel,
apiLoading, apiError, messagesEndRef,
handleSend, handleStop, handleKeyDown, handleClear, getCredits,
} = useAIChatSession();
@@ -63,29 +61,6 @@ export function AIChatPage() {
<PageHeader title="Dork" icon={<Bot className="size-5" />} className="shrink-0 py-3">
<div className="flex items-center gap-2">
<CreditsBadge getCredits={getCredits} />
<Select value={selectedModel} onValueChange={setSelectedModel} disabled={modelsLoading}>
<SelectTrigger className="h-8 min-w-0 text-base md:text-xs">
<SelectValue placeholder={modelsLoading ? 'Loading...' : 'Select model'} />
</SelectTrigger>
<SelectContent>
{models.map((model) => {
const totalCost = parseFloat(model.pricing.prompt) + parseFloat(model.pricing.completion);
const isFree = totalCost === 0;
return (
<SelectItem key={model.id} value={model.id}>
<span className="flex items-center gap-1.5">
{model.name}
{isFree && (
<span className="text-[10px] font-medium text-green-600 dark:text-green-400 bg-green-500/10 px-1 rounded">
FREE
</span>
)}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon"