939 lines
39 KiB
TypeScript
939 lines
39 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useRef, useMemo, useEffect } from 'react';
|
|
import { Bot, Brain, Check, Paperclip, FileText, X, Globe2, Search } from 'lucide-react';
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
import { cn } from '@/lib/utils';
|
|
import { useI18n } from '@/lib/hooks/use-i18n';
|
|
import { useSettingsStore } from '@/lib/store/settings';
|
|
import { isLLMProviderConfigured } from '@/lib/store/settings-validation';
|
|
import { PDF_PROVIDERS } from '@/lib/pdf/constants';
|
|
import type { PDFProviderId } from '@/lib/pdf/types';
|
|
import { WEB_SEARCH_PROVIDERS, getWebSearchProviderDisplayName } from '@/lib/web-search/constants';
|
|
import type { WebSearchProviderId } from '@/lib/web-search/types';
|
|
import type { ProviderId } from '@/lib/ai/providers';
|
|
import type {
|
|
ModelInfo,
|
|
ThinkingConfig,
|
|
ThinkingEffort,
|
|
ThinkingLevel,
|
|
} from '@/lib/types/provider';
|
|
import {
|
|
getDefaultThinkingConfig,
|
|
getThinkingDisplayValue,
|
|
getThinkingConfigKey,
|
|
normalizeThinkingConfig,
|
|
supportsConfigurableThinking,
|
|
} from '@/lib/ai/thinking-config';
|
|
import type { SettingsSection } from '@/lib/types/settings';
|
|
import { MediaPopover } from '@/components/generation/media-popover';
|
|
import { getAcceptStringForProviders, isMimeSupportedByProviders } from '@/lib/document/mime';
|
|
|
|
// ─── Constants ───────────────────────────────────────────────
|
|
const MAX_COURSE_MATERIAL_SIZE_MB = 50;
|
|
const MAX_COURSE_MATERIAL_SIZE_BYTES = MAX_COURSE_MATERIAL_SIZE_MB * 1024 * 1024;
|
|
|
|
// ─── Types ───────────────────────────────────────────────────
|
|
export interface GenerationToolbarProps {
|
|
webSearch: boolean;
|
|
onWebSearchChange: (v: boolean) => void;
|
|
onSettingsOpen: (section?: SettingsSection) => void;
|
|
// PDF
|
|
pdfFile: File | null;
|
|
onPdfFileChange: (file: File | null) => void;
|
|
onPdfError: (error: string | null) => void;
|
|
}
|
|
|
|
// ─── Component ───────────────────────────────────────────────
|
|
export function GenerationToolbar({
|
|
webSearch,
|
|
onWebSearchChange,
|
|
onSettingsOpen,
|
|
pdfFile,
|
|
onPdfFileChange,
|
|
onPdfError,
|
|
}: GenerationToolbarProps) {
|
|
const { t } = useI18n();
|
|
const currentProviderId = useSettingsStore((s) => s.providerId);
|
|
const currentModelId = useSettingsStore((s) => s.modelId);
|
|
const providersConfig = useSettingsStore((s) => s.providersConfig);
|
|
const setModel = useSettingsStore((s) => s.setModel);
|
|
const thinkingConfigs = useSettingsStore((s) => s.thinkingConfigs);
|
|
const setThinkingConfig = useSettingsStore((s) => s.setThinkingConfig);
|
|
const pdfProviderId = useSettingsStore((s) => s.pdfProviderId);
|
|
const pdfProvidersConfig = useSettingsStore((s) => s.pdfProvidersConfig);
|
|
const setPDFProvider = useSettingsStore((s) => s.setPDFProvider);
|
|
const webSearchProviderId = useSettingsStore((s) => s.webSearchProviderId);
|
|
const webSearchProvidersConfig = useSettingsStore((s) => s.webSearchProvidersConfig);
|
|
const setWebSearchProvider = useSettingsStore((s) => s.setWebSearchProvider);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
|
|
// Check web search availability. Keyless providers such as Brave should keep
|
|
// the toolbar reachable even when the current API-key provider is not ready.
|
|
const webSearchProvider = WEB_SEARCH_PROVIDERS[webSearchProviderId];
|
|
const webSearchConfig = webSearchProvidersConfig[webSearchProviderId];
|
|
const selectedWebSearchAvailable = webSearchProvider
|
|
? !webSearchProvider.requiresApiKey ||
|
|
!!webSearchConfig?.apiKey ||
|
|
!!webSearchConfig?.isServerConfigured
|
|
: false;
|
|
const webSearchAvailable = Object.values(WEB_SEARCH_PROVIDERS).some((provider) => {
|
|
const cfg = webSearchProvidersConfig[provider.id];
|
|
return !provider.requiresApiKey || !!cfg?.apiKey || !!cfg?.isServerConfigured;
|
|
});
|
|
|
|
// Configured LLM providers (only those with valid credentials + models + endpoint)
|
|
const configuredProviders = providersConfig
|
|
? Object.entries(providersConfig)
|
|
.filter(([, config]) => isLLMProviderConfigured(config))
|
|
.map(([id, config]) => ({
|
|
id: id as ProviderId,
|
|
name: config.name,
|
|
icon: config.icon,
|
|
isServerConfigured: config.isServerConfigured,
|
|
models:
|
|
config.isServerConfigured && !config.apiKey && config.serverModels?.length
|
|
? config.models.filter((m) => new Set(config.serverModels).has(m.id))
|
|
: config.models,
|
|
}))
|
|
: [];
|
|
|
|
const currentProviderConfig = providersConfig?.[currentProviderId];
|
|
const currentModel = currentProviderConfig?.models.find((model) => model.id === currentModelId);
|
|
const currentThinkingConfig =
|
|
thinkingConfigs[getThinkingConfigKey(currentProviderId, currentModelId)];
|
|
|
|
// Course material handler. `plain-text` is always active alongside the
|
|
// user-selected extractor so txt/md files remain uploadable without
|
|
// configuring an external service.
|
|
const acceptForCurrentProvider = useMemo(
|
|
() => getAcceptStringForProviders([pdfProviderId, 'plain-text']),
|
|
[pdfProviderId],
|
|
);
|
|
|
|
// If the user switches to a provider that doesn't support the currently
|
|
// attached file, drop the file so we don't submit an unsupported upload
|
|
// that would only fail server-side.
|
|
useEffect(() => {
|
|
if (!pdfFile) return;
|
|
const stillSupported = isMimeSupportedByProviders(
|
|
{ mimeType: pdfFile.type, fileName: pdfFile.name },
|
|
[pdfProviderId, 'plain-text'],
|
|
);
|
|
if (!stillSupported) {
|
|
onPdfFileChange(null);
|
|
onPdfError(t('upload.unsupportedCourseMaterial'));
|
|
}
|
|
// Intentionally omit onPdfFileChange/onPdfError/t from deps: they are
|
|
// stable enough for this check and adding them would re-run the effect
|
|
// on unrelated parent re-renders.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [pdfProviderId, pdfFile]);
|
|
|
|
const handleFileSelect = (file: File) => {
|
|
if (
|
|
!isMimeSupportedByProviders({ mimeType: file.type, fileName: file.name }, [
|
|
pdfProviderId,
|
|
'plain-text',
|
|
])
|
|
) {
|
|
onPdfError(t('upload.unsupportedCourseMaterial'));
|
|
return;
|
|
}
|
|
if (file.size > MAX_COURSE_MATERIAL_SIZE_BYTES) {
|
|
onPdfError(t('upload.fileTooLarge'));
|
|
return;
|
|
}
|
|
onPdfError(null);
|
|
onPdfFileChange(file);
|
|
};
|
|
|
|
// ─── Pill button helper ─────────────────────────────
|
|
const pillCls =
|
|
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-all cursor-pointer select-none whitespace-nowrap border';
|
|
const pillMuted = `${pillCls} border-border/50 text-muted-foreground/70 hover:text-foreground hover:bg-muted/60`;
|
|
const pillActive = `${pillCls} border-violet-200/60 dark:border-violet-700/50 bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300`;
|
|
|
|
return (
|
|
<div className="flex items-center gap-1 flex-wrap">
|
|
{/* ── Model selector ── */}
|
|
{configuredProviders.length > 0 ? (
|
|
<ModelSettingsPopover
|
|
configuredProviders={configuredProviders}
|
|
currentProviderId={currentProviderId}
|
|
currentModelId={currentModelId}
|
|
currentProviderConfig={currentProviderConfig}
|
|
currentModel={currentModel}
|
|
setModel={setModel}
|
|
thinkingConfig={currentThinkingConfig}
|
|
onThinkingChange={(config) =>
|
|
setThinkingConfig(currentProviderId, currentModelId, config)
|
|
}
|
|
t={t}
|
|
/>
|
|
) : (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
onClick={() => onSettingsOpen('providers')}
|
|
className={cn(
|
|
pillCls,
|
|
'text-amber-600 dark:text-amber-400 animate-pulse',
|
|
'bg-amber-50 dark:bg-amber-950/30 hover:bg-amber-100 dark:hover:bg-amber-950/50',
|
|
)}
|
|
>
|
|
<Bot className="size-3.5" />
|
|
<span>{t('toolbar.configureProvider')}</span>
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{t('toolbar.configureProviderHint')}</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
|
|
<div className="flex min-w-0 items-center gap-1">
|
|
{/* ── Separator ── */}
|
|
<div className="w-px h-4 bg-border/60 mx-1" />
|
|
|
|
{/* ── Course material (extractor + upload) combined Popover ── */}
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
{pdfFile ? (
|
|
<button className={pillActive}>
|
|
<Paperclip className="size-3.5" />
|
|
<span className="max-w-[100px] truncate">{pdfFile.name}</span>
|
|
<span
|
|
role="button"
|
|
className="size-4 rounded-full inline-flex items-center justify-center hover:bg-violet-200 dark:hover:bg-violet-800 transition-colors"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onPdfFileChange(null);
|
|
}}
|
|
>
|
|
<X className="size-2.5" />
|
|
</span>
|
|
</button>
|
|
) : (
|
|
<button className={pillMuted}>
|
|
<Paperclip className="size-3.5" />
|
|
</button>
|
|
)}
|
|
</PopoverTrigger>
|
|
<PopoverContent align="start" className="w-72 p-0">
|
|
{/* Extractor selector */}
|
|
<div className="flex items-center gap-2 px-3 pt-3 pb-2">
|
|
<span className="text-xs font-medium text-muted-foreground shrink-0">
|
|
{t('toolbar.documentExtractor')}
|
|
</span>
|
|
<Select
|
|
value={pdfProviderId}
|
|
onValueChange={(v) => setPDFProvider(v as PDFProviderId)}
|
|
>
|
|
<SelectTrigger className="h-7 text-xs flex-1 min-w-0">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.values(PDF_PROVIDERS).map((provider) => {
|
|
const cfg = pdfProvidersConfig[provider.id];
|
|
const available =
|
|
!provider.requiresApiKey || !!cfg?.apiKey || !!cfg?.isServerConfigured;
|
|
return (
|
|
<SelectItem key={provider.id} value={provider.id} disabled={!available}>
|
|
<div
|
|
className={cn('flex items-center gap-1.5', !available && 'opacity-50')}
|
|
>
|
|
{provider.icon && (
|
|
<img src={provider.icon} alt={provider.name} className="w-3.5 h-3.5" />
|
|
)}
|
|
{provider.name}
|
|
{cfg?.isServerConfigured && (
|
|
<span className="text-[9px] px-1 py-0 rounded border text-muted-foreground">
|
|
{t('settings.serverConfigured')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</SelectItem>
|
|
);
|
|
})}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Upload area / file info */}
|
|
<div className="px-3 pb-3">
|
|
<input
|
|
type="file"
|
|
ref={fileInputRef}
|
|
className="hidden"
|
|
accept={acceptForCurrentProvider}
|
|
onChange={(e) => {
|
|
const f = e.target.files?.[0];
|
|
if (f) handleFileSelect(f);
|
|
e.target.value = '';
|
|
}}
|
|
/>
|
|
{pdfFile ? (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<div className="size-8 rounded-lg bg-violet-100 dark:bg-violet-900/30 flex items-center justify-center shrink-0">
|
|
<FileText className="size-4 text-violet-600 dark:text-violet-400" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm font-medium truncate">{pdfFile.name}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{(pdfFile.size / 1024 / 1024).toFixed(2)} MB
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => onPdfFileChange(null)}
|
|
className="w-full text-xs text-destructive hover:underline text-left"
|
|
>
|
|
{t('toolbar.removeCourseMaterial')}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div
|
|
className={cn(
|
|
'flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-4 transition-colors cursor-pointer',
|
|
isDragging
|
|
? 'border-violet-400 bg-violet-50 dark:bg-violet-950/20'
|
|
: 'border-muted-foreground/20 hover:border-violet-300',
|
|
)}
|
|
onClick={() => fileInputRef.current?.click()}
|
|
onDragOver={(e) => {
|
|
e.preventDefault();
|
|
setIsDragging(true);
|
|
}}
|
|
onDragLeave={() => setIsDragging(false)}
|
|
onDrop={(e) => {
|
|
e.preventDefault();
|
|
setIsDragging(false);
|
|
const f = e.dataTransfer.files?.[0];
|
|
if (f) handleFileSelect(f);
|
|
}}
|
|
>
|
|
<Paperclip className="size-5 text-muted-foreground/50 mb-1.5" />
|
|
<p className="text-xs font-medium">{t('toolbar.courseMaterialUpload')}</p>
|
|
<p className="text-[10px] text-muted-foreground/60 mt-0.5">
|
|
{t('upload.courseMaterialSizeLimit')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
{/* ── Web Search ── */}
|
|
{webSearchAvailable ? (
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<button className={webSearch ? pillActive : pillMuted}>
|
|
<Globe2 className={cn('size-3.5', webSearch && 'animate-pulse')} />
|
|
{webSearch && (
|
|
<span>
|
|
{WEB_SEARCH_PROVIDERS[webSearchProviderId]
|
|
? getWebSearchProviderDisplayName(webSearchProviderId, t)
|
|
: 'Search'}
|
|
</span>
|
|
)}
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="start" className="w-64 p-3 space-y-3">
|
|
{/* Toggle */}
|
|
<button
|
|
onClick={() => {
|
|
if (!selectedWebSearchAvailable) return;
|
|
onWebSearchChange(!webSearch);
|
|
}}
|
|
className={cn(
|
|
'w-full flex items-center gap-2.5 rounded-lg border px-3 py-2.5 text-left transition-all',
|
|
webSearch
|
|
? 'bg-violet-50 dark:bg-violet-950/20 border-violet-200 dark:border-violet-800'
|
|
: 'border-border hover:bg-muted/50',
|
|
!selectedWebSearchAvailable && 'opacity-60',
|
|
)}
|
|
>
|
|
<Globe2
|
|
className={cn(
|
|
'size-4 shrink-0',
|
|
webSearch ? 'text-violet-600 dark:text-violet-400' : 'text-muted-foreground',
|
|
)}
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-xs font-medium">
|
|
{webSearch ? t('toolbar.webSearchOn') : t('toolbar.webSearchOff')}
|
|
</p>
|
|
<p className="text-[10px] text-muted-foreground/70 mt-0.5">
|
|
{t('toolbar.webSearchDesc')}
|
|
</p>
|
|
</div>
|
|
</button>
|
|
|
|
{/* Provider selector */}
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs font-medium text-muted-foreground shrink-0">
|
|
{t('toolbar.webSearchProvider')}
|
|
</span>
|
|
<Select
|
|
value={webSearchProviderId}
|
|
onValueChange={(v) => setWebSearchProvider(v as WebSearchProviderId)}
|
|
>
|
|
<SelectTrigger className="h-7 text-xs flex-1 min-w-0">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.values(WEB_SEARCH_PROVIDERS).map((provider) => {
|
|
const cfg = webSearchProvidersConfig[provider.id];
|
|
const available =
|
|
!provider.requiresApiKey || !!cfg?.apiKey || !!cfg?.isServerConfigured;
|
|
return (
|
|
<SelectItem key={provider.id} value={provider.id} disabled={!available}>
|
|
<div
|
|
className={cn('flex items-center gap-1.5', !available && 'opacity-50')}
|
|
>
|
|
{getWebSearchProviderDisplayName(provider.id, t)}
|
|
{cfg?.isServerConfigured && (
|
|
<span className="text-[9px] px-1 py-0 rounded border text-muted-foreground">
|
|
{t('settings.serverConfigured')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</SelectItem>
|
|
);
|
|
})}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
) : (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
className={cn(pillCls, 'text-muted-foreground/40 cursor-not-allowed')}
|
|
disabled
|
|
>
|
|
<Globe2 className="size-3.5" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{t('toolbar.webSearchNoProvider')}</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
|
|
{/* ── Separator ── */}
|
|
<div className="w-px h-4 bg-border/60 mx-1" />
|
|
|
|
{/* ── Media popover ── */}
|
|
<MediaPopover onSettingsOpen={onSettingsOpen} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatThinkingValue(value?: string, t?: (key: string) => string) {
|
|
if (!value) return '';
|
|
if (value === 'none') return t ? t('toolbar.off') : 'off';
|
|
if (t && (value === 'dynamic' || value === 'on' || value === 'off' || value === 'auto')) {
|
|
return t(`toolbar.${value}`);
|
|
}
|
|
return value === 'xhigh' ? 'x-high' : value;
|
|
}
|
|
|
|
function formatCompactThinkingValue(value?: string, t?: (key: string) => string) {
|
|
if (!value) return '';
|
|
const numericValue = Number(value);
|
|
if (Number.isFinite(numericValue) && value.trim() !== '') {
|
|
return numericValue >= 10000 ? `${Math.round(numericValue / 1000)}k` : `${numericValue}`;
|
|
}
|
|
return formatThinkingValue(value, t);
|
|
}
|
|
|
|
function InlineThinkingControl({
|
|
model,
|
|
config,
|
|
onChange,
|
|
t,
|
|
}: {
|
|
model?: ModelInfo;
|
|
config?: ThinkingConfig;
|
|
onChange: (config: ThinkingConfig | undefined) => void;
|
|
t: (key: string) => string;
|
|
}) {
|
|
const thinking = model?.capabilities?.thinking;
|
|
if (!supportsConfigurableThinking(thinking)) return null;
|
|
|
|
const effective = normalizeThinkingConfig(thinking, config) ?? getDefaultThinkingConfig(thinking);
|
|
const applyConfig = (next: ThinkingConfig) => {
|
|
onChange(normalizeThinkingConfig(thinking, next));
|
|
};
|
|
|
|
const applyBudget = (value: number | undefined) => {
|
|
applyConfig({ ...effective, mode: effective?.mode ?? 'enabled', budgetTokens: value });
|
|
};
|
|
const defaultEnabledBudget =
|
|
typeof thinking.defaultBudgetTokens === 'number' && thinking.defaultBudgetTokens > 0
|
|
? thinking.defaultBudgetTokens
|
|
: (thinking.budgetRange?.step ?? thinking.budgetRange?.min);
|
|
const applyAutoBudget = () => {
|
|
applyConfig({ ...effective, mode: 'auto', enabled: undefined, budgetTokens: -1 });
|
|
};
|
|
const applyBudgetMode = (mode: 'disabled' | 'enabled' | 'auto') => {
|
|
if (mode === 'auto') {
|
|
applyAutoBudget();
|
|
return;
|
|
}
|
|
|
|
applyConfig({
|
|
...effective,
|
|
mode,
|
|
enabled: mode === 'enabled',
|
|
budgetTokens:
|
|
mode === 'enabled' && effective?.budgetTokens === -1
|
|
? defaultEnabledBudget
|
|
: effective?.budgetTokens,
|
|
});
|
|
};
|
|
const applySimpleMode = (mode: 'disabled' | 'enabled' | 'auto') => {
|
|
applyConfig({
|
|
...effective,
|
|
mode,
|
|
enabled: mode === 'enabled' ? true : mode === 'disabled' ? false : undefined,
|
|
});
|
|
};
|
|
|
|
const selectTriggerCls =
|
|
'h-6 min-w-[84px] rounded-full border-0 bg-violet-100 px-2 py-0 !text-[10px] font-medium leading-none text-violet-700 shadow-none focus-visible:ring-0 data-[size=sm]:h-6 dark:bg-violet-900/40 dark:text-violet-200 [&_svg]:size-3';
|
|
const selectItemCls = 'py-1 text-xs';
|
|
const hasAutoBudget =
|
|
(thinking.control === 'toggle-budget' || thinking.control === 'budget-only') &&
|
|
!!thinking.budgetRange?.allowDynamic;
|
|
const autoBudgetMode =
|
|
effective?.budgetTokens === -1 && thinking.budgetRange?.allowDynamic
|
|
? 'auto'
|
|
: effective?.mode === 'disabled'
|
|
? 'disabled'
|
|
: 'enabled';
|
|
const simpleMode =
|
|
thinking.control === 'mode' && effective?.mode === 'auto'
|
|
? 'auto'
|
|
: effective?.mode === 'disabled'
|
|
? 'disabled'
|
|
: 'enabled';
|
|
|
|
return (
|
|
<div
|
|
className="flex min-w-0 shrink-0 items-center gap-1"
|
|
onClick={(event) => event.stopPropagation()}
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
onKeyDown={(event) => event.stopPropagation()}
|
|
>
|
|
<Brain className="size-3.5 shrink-0 text-violet-500" />
|
|
<div className="flex min-w-0 items-center gap-0.5 rounded-full border border-violet-200/70 bg-white/65 p-0.5 dark:border-violet-800/70 dark:bg-violet-950/25">
|
|
{hasAutoBudget && (
|
|
<Select
|
|
value={autoBudgetMode}
|
|
onValueChange={(mode) => applyBudgetMode(mode as 'disabled' | 'enabled' | 'auto')}
|
|
>
|
|
<SelectTrigger
|
|
size="sm"
|
|
className="h-6 min-w-[76px] rounded-full border-0 bg-violet-100 px-2 py-0 !text-[10px] font-medium leading-none text-violet-700 shadow-none focus-visible:ring-0 data-[size=sm]:h-6 dark:bg-violet-900/40 dark:text-violet-200 [&_svg]:size-3"
|
|
>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent align="end" className="min-w-[96px]">
|
|
{thinking.control === 'toggle-budget' && (
|
|
<SelectItem value="disabled" className={selectItemCls}>
|
|
{t('toolbar.off')}
|
|
</SelectItem>
|
|
)}
|
|
<SelectItem value="enabled" className={selectItemCls}>
|
|
{t('toolbar.on')}
|
|
</SelectItem>
|
|
<SelectItem value="auto" className={selectItemCls}>
|
|
{t('toolbar.auto')}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
|
|
{(thinking.control === 'toggle' ||
|
|
(thinking.control === 'toggle-budget' && !hasAutoBudget) ||
|
|
thinking.control === 'mode') && (
|
|
<Select
|
|
value={simpleMode}
|
|
onValueChange={(mode) => applySimpleMode(mode as 'disabled' | 'enabled' | 'auto')}
|
|
>
|
|
<SelectTrigger
|
|
size="sm"
|
|
className="h-6 min-w-[76px] rounded-full border-0 bg-violet-100 px-2 py-0 !text-[10px] font-medium leading-none text-violet-700 shadow-none focus-visible:ring-0 data-[size=sm]:h-6 dark:bg-violet-900/40 dark:text-violet-200 [&_svg]:size-3"
|
|
>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent align="end" className="min-w-[96px]">
|
|
{thinking.control === 'mode' && (
|
|
<SelectItem value="auto" className={selectItemCls}>
|
|
{t('toolbar.auto')}
|
|
</SelectItem>
|
|
)}
|
|
<SelectItem value="disabled" className={selectItemCls}>
|
|
{t('toolbar.off')}
|
|
</SelectItem>
|
|
<SelectItem value="enabled" className={selectItemCls}>
|
|
{t('toolbar.on')}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
|
|
{thinking.control === 'level' && !!thinking.levelValues?.length && (
|
|
<Select
|
|
value={effective?.level ?? thinking.defaultLevel ?? thinking.levelValues[0]}
|
|
onValueChange={(level) =>
|
|
applyConfig({ ...effective, mode: 'enabled', level: level as ThinkingLevel })
|
|
}
|
|
>
|
|
<SelectTrigger size="sm" className={selectTriggerCls}>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent align="end" className="min-w-[96px]">
|
|
{thinking.levelValues.map((level: ThinkingLevel) => (
|
|
<SelectItem key={level} value={level} className={selectItemCls}>
|
|
{level}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
|
|
{thinking.control === 'effort' && !!thinking.effortValues?.length && (
|
|
<Select
|
|
value={effective?.effort ?? thinking.defaultEffort ?? thinking.effortValues[0]}
|
|
onValueChange={(effort) =>
|
|
applyConfig({
|
|
...effective,
|
|
mode: effort === 'none' ? 'disabled' : 'enabled',
|
|
effort: effort as ThinkingEffort,
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger size="sm" className={selectTriggerCls}>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent align="end" className="min-w-[104px]">
|
|
{thinking.effortValues.map((effort: ThinkingEffort) => (
|
|
<SelectItem key={effort} value={effort} className={selectItemCls}>
|
|
{formatThinkingValue(effort, t)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
|
|
{(thinking.control === 'toggle-budget' || thinking.control === 'budget-only') &&
|
|
thinking.budgetRange &&
|
|
(!hasAutoBudget || autoBudgetMode === 'enabled') && (
|
|
<label className="ml-0.5 grid h-6 shrink-0 grid-cols-[auto_60px] items-stretch overflow-hidden rounded-full border border-violet-200/70 bg-background dark:border-violet-800/70">
|
|
<span className="grid h-[22px] shrink-0 place-items-center border-r border-violet-200/70 bg-muted/30 px-2 font-sans text-[11px] font-medium leading-[22px] text-muted-foreground dark:border-violet-800/70">
|
|
{t('toolbar.thinkingBudget')}
|
|
</span>
|
|
<input
|
|
type="text"
|
|
inputMode="numeric"
|
|
aria-label={t('toolbar.thinkingBudget')}
|
|
disabled={effective?.mode === 'disabled'}
|
|
value={
|
|
typeof effective?.budgetTokens === 'number' && effective.budgetTokens !== -1
|
|
? effective.budgetTokens
|
|
: ''
|
|
}
|
|
placeholder={`${thinking.budgetRange.min}-${thinking.budgetRange.max}`}
|
|
title={`${thinking.budgetRange.min}-${thinking.budgetRange.max} tokens`}
|
|
onChange={(event) => {
|
|
const rawValue = event.target.value.trim();
|
|
if (!/^\d*$/.test(rawValue)) return;
|
|
const value = rawValue ? Number(rawValue) : undefined;
|
|
applyBudget(value);
|
|
}}
|
|
className="block h-[22px] w-[60px] border-0 bg-transparent px-1 py-0 text-center font-sans text-[11px] font-medium leading-[22px] tabular-nums outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
|
/>
|
|
</label>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── ModelSettingsPopover (provider + model picker) ─────
|
|
interface ConfiguredProvider {
|
|
id: ProviderId;
|
|
name: string;
|
|
icon?: string;
|
|
isServerConfigured?: boolean;
|
|
models: ModelInfo[];
|
|
}
|
|
|
|
function ModelSettingsPopover({
|
|
configuredProviders,
|
|
currentProviderId,
|
|
currentModelId,
|
|
currentProviderConfig,
|
|
currentModel,
|
|
setModel,
|
|
thinkingConfig,
|
|
onThinkingChange,
|
|
t,
|
|
}: {
|
|
configuredProviders: ConfiguredProvider[];
|
|
currentProviderId: ProviderId;
|
|
currentModelId: string;
|
|
currentProviderConfig: { name: string; icon?: string } | undefined;
|
|
currentModel?: ModelInfo;
|
|
setModel: (providerId: ProviderId, modelId: string) => void;
|
|
thinkingConfig?: ThinkingConfig;
|
|
onThinkingChange: (config: ThinkingConfig | undefined) => void;
|
|
t: (key: string) => string;
|
|
}) {
|
|
const [popoverOpen, setPopoverOpen] = useState(false);
|
|
const [activeProviderId, setActiveProviderId] = useState<ProviderId>(currentProviderId);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
|
|
const currentProvider = configuredProviders.find((provider) => provider.id === currentProviderId);
|
|
const searchTerm = searchQuery.trim().toLowerCase();
|
|
const isSearching = searchTerm.length > 0;
|
|
const providerEntries = useMemo(() => {
|
|
const matchesSearch = (model: ModelInfo) =>
|
|
!searchTerm ||
|
|
model.name.toLowerCase().includes(searchTerm) ||
|
|
model.id.toLowerCase().includes(searchTerm);
|
|
|
|
return configuredProviders
|
|
.map((provider) => ({
|
|
provider,
|
|
matchingModels: provider.models.filter(matchesSearch),
|
|
}))
|
|
.filter((entry) => !isSearching || entry.matchingModels.length > 0);
|
|
}, [configuredProviders, isSearching, searchTerm]);
|
|
|
|
const activeProviderVisible = providerEntries.some(
|
|
(entry) => entry.provider.id === activeProviderId,
|
|
);
|
|
const firstVisibleProviderId = providerEntries[0]?.provider.id;
|
|
const resolvedActiveProviderId =
|
|
isSearching && !activeProviderVisible ? firstVisibleProviderId : activeProviderId;
|
|
const activeProviderEntry =
|
|
providerEntries.find((entry) => entry.provider.id === resolvedActiveProviderId) ??
|
|
providerEntries[0];
|
|
const activeProvider = activeProviderEntry?.provider;
|
|
|
|
const visibleModelEntries = useMemo(() => {
|
|
if (!activeProviderEntry) return [];
|
|
const { provider, matchingModels } = activeProviderEntry;
|
|
return matchingModels.map((model) => ({ provider, model }));
|
|
}, [activeProviderEntry]);
|
|
|
|
const currentProviderName =
|
|
currentProvider?.name ?? currentProviderConfig?.name ?? currentProviderId;
|
|
const currentProviderIcon = currentProvider?.icon ?? currentProviderConfig?.icon;
|
|
// Under the #580 invariant this popover only renders when a usable provider
|
|
// exists, which guarantees a concrete model — so the label is always
|
|
// provider / model (no "Select Model" fallback state).
|
|
const currentModelLabel = currentModel?.name || currentModelId;
|
|
const currentThinkingValue = getThinkingDisplayValue(
|
|
currentModel?.capabilities?.thinking,
|
|
thinkingConfig,
|
|
);
|
|
const currentThinkingLabel = formatCompactThinkingValue(currentThinkingValue, t);
|
|
|
|
return (
|
|
<Popover
|
|
open={popoverOpen}
|
|
onOpenChange={(nextOpen) => {
|
|
setPopoverOpen(nextOpen);
|
|
if (nextOpen) {
|
|
setActiveProviderId(currentProviderId);
|
|
setSearchQuery('');
|
|
}
|
|
}}
|
|
>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
aria-label={`${currentProviderName} / ${currentModelLabel}`}
|
|
className={cn(
|
|
'inline-flex h-8 min-w-0 items-center gap-1.5 rounded-full border px-2 text-xs font-medium transition-all',
|
|
'border-violet-200/70 bg-violet-50 text-violet-700 hover:bg-violet-100 dark:border-violet-800/70 dark:bg-violet-950/30 dark:text-violet-300',
|
|
currentModelId &&
|
|
'shadow-[0_0_0_1px_rgba(124,58,237,0.12)] dark:shadow-[0_0_0_1px_rgba(167,139,250,0.16)]',
|
|
)}
|
|
>
|
|
{currentProviderIcon ? (
|
|
<img
|
|
src={currentProviderIcon}
|
|
alt={currentProviderName}
|
|
className="size-4 shrink-0 rounded-sm"
|
|
/>
|
|
) : (
|
|
<Bot className="size-3.5 shrink-0" />
|
|
)}
|
|
{currentThinkingLabel && (
|
|
<span className="shrink-0 rounded-full bg-white/80 px-1.5 py-0.5 text-[10px] font-semibold leading-none text-violet-700 ring-1 ring-violet-200/70 dark:bg-violet-950/50 dark:text-violet-200 dark:ring-violet-800/70">
|
|
{currentThinkingLabel}
|
|
</span>
|
|
)}
|
|
</button>
|
|
</PopoverTrigger>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{`${currentProviderConfig?.name || currentProviderId} / ${currentModelId}`}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
|
|
<PopoverContent
|
|
align="start"
|
|
side="top"
|
|
sideOffset={8}
|
|
collisionPadding={12}
|
|
className="w-[640px] max-w-[calc(100vw-2rem)] overflow-hidden p-0"
|
|
>
|
|
<div className="grid h-[430px] grid-cols-[128px_minmax(0,1fr)] sm:grid-cols-[160px_minmax(0,1fr)]">
|
|
<div className="min-h-0 border-r bg-muted/20">
|
|
<div className="px-3 py-2 text-[10px] font-semibold uppercase text-muted-foreground">
|
|
{t('toolbar.selectProvider')}
|
|
</div>
|
|
<div className="h-[calc(100%-28px)] overflow-y-auto px-2 pb-2 pt-1">
|
|
{providerEntries.length === 0 ? (
|
|
<div className="px-2 py-4 text-[11px] text-muted-foreground">
|
|
{t('settings.noModelsFound')}
|
|
</div>
|
|
) : (
|
|
providerEntries.map(({ provider, matchingModels }) => {
|
|
const isActive = activeProvider?.id === provider.id;
|
|
const isCurrent = currentProviderId === provider.id;
|
|
return (
|
|
<button
|
|
key={provider.id}
|
|
onClick={() => setActiveProviderId(provider.id)}
|
|
className={cn(
|
|
'mb-1 flex h-10 w-full items-center gap-2 rounded-md px-2 text-left transition-colors',
|
|
isActive
|
|
? 'bg-background text-foreground shadow-sm ring-1 ring-border/70'
|
|
: 'text-muted-foreground hover:bg-background/70 hover:text-foreground',
|
|
)}
|
|
>
|
|
{provider.icon ? (
|
|
<img
|
|
src={provider.icon}
|
|
alt={provider.name}
|
|
className="size-4 shrink-0 rounded-sm"
|
|
/>
|
|
) : (
|
|
<Bot className="size-4 shrink-0" />
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate text-xs font-medium">{provider.name}</div>
|
|
<div className="text-[10px] text-muted-foreground">
|
|
{isSearching ? `${matchingModels.length}/` : ''}
|
|
{provider.models.length}
|
|
</div>
|
|
</div>
|
|
{isCurrent && <span className="size-1.5 rounded-full bg-violet-500" />}
|
|
</button>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex min-h-0 min-w-0 flex-col overflow-hidden">
|
|
<div className="border-b p-3">
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
|
<div className="relative min-w-0 flex-1">
|
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
value={searchQuery}
|
|
onChange={(event) => {
|
|
const nextSearch = event.target.value;
|
|
setSearchQuery(nextSearch);
|
|
if (!nextSearch.trim()) setActiveProviderId(currentProviderId);
|
|
}}
|
|
placeholder={t('settings.searchModels')}
|
|
className="h-8 pl-8 text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
|
{visibleModelEntries.length === 0 ? (
|
|
<div className="px-3 py-8 text-center text-xs text-muted-foreground">
|
|
{searchQuery ? t('settings.noModelsFound') : t('settings.noModelsAvailable')}
|
|
</div>
|
|
) : (
|
|
visibleModelEntries.map(({ provider, model }) => {
|
|
const isSelected =
|
|
currentProviderId === provider.id && currentModelId === model.id;
|
|
const selectModel = () => {
|
|
setActiveProviderId(provider.id);
|
|
setModel(provider.id, model.id);
|
|
};
|
|
return (
|
|
<div
|
|
key={`${provider.id}:${model.id}`}
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={selectModel}
|
|
onKeyDown={(event) => {
|
|
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
event.preventDefault();
|
|
selectModel();
|
|
}}
|
|
className={cn(
|
|
'mb-1 flex min-h-11 w-full items-center gap-2 rounded-md px-2.5 py-2 text-left transition-colors',
|
|
isSelected
|
|
? 'bg-violet-50 text-violet-700 ring-1 ring-violet-200 dark:bg-violet-950/25 dark:text-violet-300 dark:ring-violet-800'
|
|
: 'hover:bg-muted/60',
|
|
)}
|
|
>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate font-mono text-xs font-medium">{model.name}</div>
|
|
{model.id !== model.name && (
|
|
<div className="truncate font-mono text-[10px] text-muted-foreground">
|
|
{model.id}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{isSelected && currentModel && (
|
|
<InlineThinkingControl
|
|
model={currentModel}
|
|
config={thinkingConfig}
|
|
onChange={onThinkingChange}
|
|
t={t}
|
|
/>
|
|
)}
|
|
{isSelected && (
|
|
<Check className="size-3.5 shrink-0 text-violet-600 dark:text-violet-400" />
|
|
)}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|