返回 presentation-ai
ModelPicker.tsx
根目录 / src / components / notebook / presentation / components / ModelPicker.tsx
1 "use client";
2
3 import { createLogger } from "@/lib/observability/logger";
4 import {
5 Select,
6 SelectContent,
7 SelectGroup,
8 SelectItem,
9 SelectLabel,
10 SelectTrigger,
11 } from "@/components/ui/select";
12 import {
13 fallbackModels,
14 getSelectedModel,
15 setSelectedModel,
16 useLocalModels,
17 } from "@/hooks/presentation/useLocalModels";
18 import { usePresentationState } from "@/states/presentation-state";
19 import { Bot, Cpu, Loader2, Monitor } from "lucide-react";
20 import { useEffect, useRef } from "react";
21
22 const modelPickerLogger = createLogger("client:model-picker");
23 const OPENAI_MODELS = [
24 {
25 id: "gpt-4o-mini",
26 label: "GPT-4o-mini",
27 description: "Fast cloud model for everyday presentation drafts",
28 },
29 {
30 id: "gpt-4o",
31 label: "GPT-4o",
32 description: "Balanced cloud model for higher quality drafts",
33 },
34 {
35 id: "gpt-4.1-mini",
36 label: "GPT-4.1-mini",
37 description: "Efficient cloud model for structured generation",
38 },
39 {
40 id: "gpt-4.1-nano",
41 label: "GPT-4.1-nano",
42 description: "Fastest, lowest-cost GPT-4.1 model",
43 },
44 {
45 id: "gpt-4.1",
46 label: "GPT-4.1",
47 description: "Stronger cloud model for complex presentations",
48 },
49 {
50 id: "gpt-5.2",
51 label: "GPT-5.2",
52 description: "Latest flagship GPT model",
53 },
54 {
55 id: "gpt-5.2-chat-latest",
56 label: "GPT-5.2 Chat",
57 description: "Latest ChatGPT-style GPT-5.2 model",
58 },
59 {
60 id: "gpt-5.2-pro",
61 label: "GPT-5.2 Pro",
62 description: "More compute for harder problems",
63 },
64 {
65 id: "gpt-5.1",
66 label: "GPT-5.1",
67 description: "Flagship GPT model with configurable reasoning",
68 },
69 {
70 id: "gpt-5",
71 label: "GPT-5",
72 description: "Previous GPT-5 reasoning model",
73 },
74 {
75 id: "gpt-5-mini",
76 label: "GPT-5-mini",
77 description: "Faster, cost-efficient GPT-5 model",
78 },
79 {
80 id: "gpt-5-nano",
81 label: "GPT-5-nano",
82 description: "Fastest, most cost-efficient GPT-5 model",
83 },
84 ] as const;
85
86 function getOpenAIModel(modelId: string) {
87 return (
88 OPENAI_MODELS.find((model) => model.id === modelId) ?? OPENAI_MODELS[0]
89 );
90 }
91
92 export function ModelPicker({
93 shouldShowLabel = true,
94 }: {
95 shouldShowLabel?: boolean;
96 }) {
97 const { modelProvider, setModelProvider, modelId, setModelId } =
98 usePresentationState();
99
100 const { data: modelsData, isLoading, isInitialLoad } = useLocalModels();
101 const hasRestoredFromStorage = useRef(false);
102
103 useEffect(() => {
104 if (!hasRestoredFromStorage.current) {
105 const savedModel = getSelectedModel();
106 if (savedModel) {
107 modelPickerLogger.info("Restoring previously selected model", {
108 modelProvider: savedModel.modelProvider,
109 modelId: savedModel.modelId || "gpt-4o-mini",
110 });
111 setModelProvider(
112 savedModel.modelProvider as "openai" | "ollama" | "lmstudio",
113 );
114 setModelId(savedModel.modelId);
115 }
116 hasRestoredFromStorage.current = true;
117 }
118 }, [setModelId, setModelProvider]);
119
120 const displayData = modelsData || {
121 localModels: [],
122 downloadableModels: fallbackModels,
123 showDownloadable: true,
124 };
125
126 const { localModels, downloadableModels, showDownloadable } = displayData;
127
128 const ollamaModels = localModels.filter(
129 (model) => model.provider === "ollama",
130 );
131 const lmStudioModels = localModels.filter(
132 (model) => model.provider === "lmstudio",
133 );
134 const downloadableOllamaModels = downloadableModels.filter(
135 (model) => model.provider === "ollama",
136 );
137
138 const createModelOption = (
139 model: (typeof localModels)[0],
140 isDownloadable = false,
141 ) => ({
142 id: model.id,
143 label: model.name,
144 displayLabel:
145 model.provider === "ollama"
146 ? `ollama ${model.name}`
147 : `lm-studio ${model.name}`,
148 icon: model.provider === "ollama" ? Cpu : Monitor,
149 description: isDownloadable
150 ? `Downloadable ${model.provider === "ollama" ? "Ollama" : "LM Studio"} model (will auto-download)`
151 : `Local ${model.provider === "ollama" ? "Ollama" : "LM Studio"} model`,
152 });
153
154 const getCurrentModelValue = () => {
155 if (modelProvider === "ollama") {
156 return `ollama-${modelId}`;
157 }
158
159 if (modelProvider === "lmstudio") {
160 return `lmstudio-${modelId}`;
161 }
162
163 return `openai-${getOpenAIModel(modelId).id}`;
164 };
165
166 const getCurrentModelOption = () => {
167 const currentValue = getCurrentModelValue();
168
169 if (modelProvider === "openai") {
170 const currentModel = getOpenAIModel(modelId);
171 return {
172 label: currentModel.label,
173 icon: Bot,
174 };
175 }
176
177 const localModel = localModels.find((model) => model.id === currentValue);
178 if (localModel) {
179 return {
180 label: localModel.name,
181 icon: localModel.provider === "ollama" ? Cpu : Monitor,
182 };
183 }
184
185 const downloadableModel = downloadableModels.find(
186 (model) => model.id === currentValue,
187 );
188 if (downloadableModel) {
189 return {
190 label: downloadableModel.name,
191 icon: downloadableModel.provider === "ollama" ? Cpu : Monitor,
192 };
193 }
194
195 return {
196 label: "Select model",
197 icon: Bot,
198 };
199 };
200
201 const handleModelChange = (value: string) => {
202 if (value.startsWith("openai-")) {
203 const selectedModelId = value.replace("openai-", "");
204 const selectedModel = getOpenAIModel(selectedModelId);
205 modelPickerLogger.info("Selected OpenAI model", {
206 modelProvider: "openai",
207 modelId: selectedModel.id,
208 });
209 setModelProvider("openai");
210 setModelId(selectedModel.id);
211 setSelectedModel("openai", selectedModel.id);
212 return;
213 }
214
215 if (value.startsWith("ollama-")) {
216 const model = value.replace("ollama-", "");
217 const isDownloadableSelection = downloadableModels.some(
218 (candidate) => candidate.id === value,
219 );
220 modelPickerLogger.info("Selected Ollama model", {
221 modelProvider: "ollama",
222 modelId: model,
223 isDownloadableSelection,
224 });
225 if (isDownloadableSelection) {
226 modelPickerLogger.info(
227 "Selected a downloadable Ollama model suggestion; the server will download it on first use if needed",
228 {
229 modelProvider: "ollama",
230 modelId: model,
231 },
232 );
233 }
234 setModelProvider("ollama");
235 setModelId(model);
236 setSelectedModel("ollama", model);
237 return;
238 }
239
240 if (value.startsWith("lmstudio-")) {
241 const model = value.replace("lmstudio-", "");
242 modelPickerLogger.info("Selected LM Studio model", {
243 modelProvider: "lmstudio",
244 modelId: model,
245 });
246 setModelProvider("lmstudio");
247 setModelId(model);
248 setSelectedModel("lmstudio", model);
249 }
250 };
251
252 return (
253 <div className="min-w-0">
254 {shouldShowLabel && (
255 <label className="block text-xs font-medium text-muted-foreground">
256 Text model
257 </label>
258 )}
259 <Select value={getCurrentModelValue()} onValueChange={handleModelChange}>
260 <SelectTrigger className="h-8 w-auto max-w-full gap-2 overflow-hidden rounded-full border-border bg-background px-3 text-[13px] font-medium text-foreground transition-colors hover:bg-accent sm:h-9 sm:px-3.5 sm:text-sm">
261 <div className="flex min-w-0 items-center gap-2">
262 {(() => {
263 const currentOption = getCurrentModelOption();
264 const Icon = currentOption.icon;
265 return <Icon className="h-4 w-4 flex-shrink-0" />;
266 })()}
267 <span className="truncate text-sm">
268 {getCurrentModelOption().label}
269 </span>
270 </div>
271 </SelectTrigger>
272 <SelectContent className="w-80 max-w-[calc(100vw-1rem)]">
273 {isLoading && !isInitialLoad && (
274 <SelectGroup>
275 <SelectLabel>Loading Models</SelectLabel>
276 <SelectItem value="loading" disabled className="overflow-hidden">
277 <div className="flex min-w-0 max-w-full items-center gap-3">
278 <Loader2 className="h-4 w-4 flex-shrink-0 animate-spin" />
279 <div className="flex min-w-0 flex-1 flex-col overflow-hidden">
280 <span className="truncate text-sm">
281 Refreshing models...
282 </span>
283 <span className="line-clamp-2 whitespace-normal break-words text-xs leading-snug text-muted-foreground">
284 Checking for new models
285 </span>
286 </div>
287 </div>
288 </SelectItem>
289 </SelectGroup>
290 )}
291
292 <SelectGroup>
293 <SelectLabel>Cloud Models</SelectLabel>
294 {OPENAI_MODELS.map((model) => (
295 <SelectItem
296 key={model.id}
297 value={`openai-${model.id}`}
298 className="overflow-hidden"
299 >
300 <div className="flex min-w-0 max-w-full items-center gap-3">
301 <Bot className="h-4 w-4 flex-shrink-0" />
302 <div className="flex min-w-0 flex-1 flex-col overflow-hidden">
303 <span className="truncate text-sm">{model.label}</span>
304 <span className="line-clamp-2 whitespace-normal break-words text-xs leading-snug text-muted-foreground">
305 {model.description}
306 </span>
307 </div>
308 </div>
309 </SelectItem>
310 ))}
311 </SelectGroup>
312
313 {ollamaModels.length > 0 && (
314 <SelectGroup>
315 <SelectLabel>Local Ollama Models</SelectLabel>
316 {ollamaModels.map((model) => {
317 const option = createModelOption(model);
318 const Icon = option.icon;
319
320 return (
321 <SelectItem
322 key={option.id}
323 value={option.id}
324 className="overflow-hidden"
325 >
326 <div className="flex min-w-0 max-w-full items-center gap-3">
327 <Icon className="h-4 w-4 flex-shrink-0" />
328 <div className="flex min-w-0 flex-1 flex-col overflow-hidden">
329 <span className="truncate text-sm">
330 {option.displayLabel}
331 </span>
332 <span className="line-clamp-2 whitespace-normal break-words text-xs leading-snug text-muted-foreground">
333 {option.description}
334 </span>
335 </div>
336 </div>
337 </SelectItem>
338 );
339 })}
340 </SelectGroup>
341 )}
342
343 {lmStudioModels.length > 0 && (
344 <SelectGroup>
345 <SelectLabel>Local LM Studio Models</SelectLabel>
346 {lmStudioModels.map((model) => {
347 const option = createModelOption(model);
348 const Icon = option.icon;
349
350 return (
351 <SelectItem
352 key={option.id}
353 value={option.id}
354 className="overflow-hidden"
355 >
356 <div className="flex min-w-0 max-w-full items-center gap-3">
357 <Icon className="h-4 w-4 flex-shrink-0" />
358 <div className="flex min-w-0 flex-1 flex-col overflow-hidden">
359 <span className="truncate text-sm">
360 {option.displayLabel}
361 </span>
362 <span className="line-clamp-2 whitespace-normal break-words text-xs leading-snug text-muted-foreground">
363 {option.description}
364 </span>
365 </div>
366 </div>
367 </SelectItem>
368 );
369 })}
370 </SelectGroup>
371 )}
372
373 {lmStudioModels.length === 0 && (
374 <SelectGroup>
375 <SelectLabel>LM Studio</SelectLabel>
376 <SelectItem
377 value="lmstudio-setup"
378 disabled
379 className="overflow-hidden"
380 >
381 <div className="flex min-w-0 max-w-full items-center gap-3">
382 <Monitor className="h-4 w-4 flex-shrink-0" />
383 <div className="flex min-w-0 flex-1 flex-col overflow-hidden">
384 <span className="line-clamp-2 whitespace-normal break-words text-sm leading-snug">
385 Start LM Studio to use local models
386 </span>
387 <span className="line-clamp-2 whitespace-normal break-words text-xs leading-snug text-muted-foreground">
388 Turn on the server and load a model to make it selectable
389 </span>
390 </div>
391 </div>
392 </SelectItem>
393 </SelectGroup>
394 )}
395
396 {showDownloadable && downloadableOllamaModels.length > 0 && (
397 <SelectGroup>
398 <SelectLabel>Downloadable Ollama Models</SelectLabel>
399 {downloadableOllamaModels.map((model) => {
400 const option = createModelOption(model, true);
401 const Icon = option.icon;
402
403 return (
404 <SelectItem
405 key={option.id}
406 value={option.id}
407 className="overflow-hidden"
408 >
409 <div className="flex min-w-0 max-w-full items-center gap-3">
410 <Icon className="h-4 w-4 flex-shrink-0" />
411 <div className="flex min-w-0 flex-1 flex-col overflow-hidden">
412 <span className="truncate text-sm">
413 {option.displayLabel}
414 </span>
415 <span className="line-clamp-2 whitespace-normal break-words text-xs leading-snug text-muted-foreground">
416 {option.description}
417 </span>
418 </div>
419 </div>
420 </SelectItem>
421 );
422 })}
423 </SelectGroup>
424 )}
425 </SelectContent>
426 </Select>
427 </div>
428 );
429 }
430
430 lines Plain Text