| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564 |
- 'use client';
- import { useState, useRef, useMemo } from 'react';
- import { Bot, Check, ChevronLeft, Globe, Paperclip, FileText, X, Globe2 } from 'lucide-react';
- import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
- import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
- } from '@/components/ui/select';
- 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 { PDF_PROVIDERS } from '@/lib/pdf/constants';
- import type { PDFProviderId } from '@/lib/pdf/types';
- import { WEB_SEARCH_PROVIDERS } from '@/lib/web-search/constants';
- import type { WebSearchProviderId } from '@/lib/web-search/types';
- import type { ProviderId } from '@/lib/ai/providers';
- import { MONO_LOGO_PROVIDERS } from '@/lib/ai/providers';
- import type { SettingsSection } from '@/lib/types/settings';
- import { MediaPopover } from '@/components/generation/media-popover';
- // ─── Constants ───────────────────────────────────────────────
- const MAX_PDF_SIZE_MB = 50;
- const MAX_PDF_SIZE_BYTES = MAX_PDF_SIZE_MB * 1024 * 1024;
- // ─── Types ───────────────────────────────────────────────────
- export interface GenerationToolbarProps {
- language: 'zh-CN' | 'en-US';
- onLanguageChange: (lang: 'zh-CN' | 'en-US') => void;
- 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({
- language,
- onLanguageChange,
- 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 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 if the selected web search provider has a valid config (API key or server-configured)
- const webSearchProvider = WEB_SEARCH_PROVIDERS[webSearchProviderId];
- const webSearchConfig = webSearchProvidersConfig[webSearchProviderId];
- const webSearchAvailable = webSearchProvider
- ? !webSearchProvider.requiresApiKey ||
- !!webSearchConfig?.apiKey ||
- !!webSearchConfig?.isServerConfigured
- : false;
- // Configured LLM providers (only those with valid credentials + models + endpoint)
- const configuredProviders = providersConfig
- ? Object.entries(providersConfig)
- .filter(
- ([, config]) =>
- (!config.requiresApiKey || config.apiKey || config.isServerConfigured) &&
- config.models.length >= 1 &&
- (config.baseUrl || config.defaultBaseUrl || config.serverBaseUrl),
- )
- .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];
- // PDF handler
- const handleFileSelect = (file: File) => {
- if (file.type !== 'application/pdf') return;
- if (file.size > MAX_PDF_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 ? (
- <ModelSelectorPopover
- configuredProviders={configuredProviders}
- currentProviderId={currentProviderId}
- currentModelId={currentModelId}
- currentProviderConfig={currentProviderConfig}
- setModel={setModel}
- 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>
- )}
- {/* ── Separator ── */}
- <div className="w-px h-4 bg-border/60 mx-1" />
- {/* ── PDF (parser + 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">
- {/* Parser 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.pdfParser')}
- </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=".pdf"
- 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.removePdf')}
- </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.pdfUpload')}</p>
- <p className="text-[10px] text-muted-foreground/60 mt-0.5">
- {t('upload.pdfSizeLimit')}
- </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]?.name || 'Search'}</span>
- )}
- </button>
- </PopoverTrigger>
- <PopoverContent align="start" className="w-64 p-3 space-y-3">
- {/* Toggle */}
- <button
- onClick={() => 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',
- )}
- >
- <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')}
- >
- {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>
- </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>
- )}
- {/* ── Language pill ── */}
- <Tooltip>
- <TooltipTrigger asChild>
- <button
- onClick={() => onLanguageChange(language === 'zh-CN' ? 'en-US' : 'zh-CN')}
- className={pillMuted}
- >
- <Globe className="size-3.5" />
- <span>{language === 'zh-CN' ? '中文' : 'EN'}</span>
- </button>
- </TooltipTrigger>
- <TooltipContent>{t('toolbar.languageHint')}</TooltipContent>
- </Tooltip>
- {/* ── Separator ── */}
- <div className="w-px h-4 bg-border/60 mx-1" />
- {/* ── Media popover ── */}
- <MediaPopover onSettingsOpen={onSettingsOpen} />
- </div>
- );
- }
- // ─── ModelSelectorPopover (two-level: provider → model) ─────
- interface ConfiguredProvider {
- id: ProviderId;
- name: string;
- icon?: string;
- isServerConfigured?: boolean;
- models: { id: string; name: string }[];
- }
- function ModelSelectorPopover({
- configuredProviders,
- currentProviderId,
- currentModelId,
- currentProviderConfig,
- setModel,
- t,
- }: {
- configuredProviders: ConfiguredProvider[];
- currentProviderId: ProviderId;
- currentModelId: string;
- currentProviderConfig: { name: string; icon?: string } | undefined;
- setModel: (providerId: ProviderId, modelId: string) => void;
- t: (key: string) => string;
- }) {
- const [popoverOpen, setPopoverOpen] = useState(false);
- // null = provider list, ProviderId = model list for that provider
- const [drillProvider, setDrillProvider] = useState<ProviderId | null>(null);
- const activeProvider = useMemo(
- () => configuredProviders.find((p) => p.id === drillProvider),
- [configuredProviders, drillProvider],
- );
- return (
- <Popover
- open={popoverOpen}
- onOpenChange={(open) => {
- setPopoverOpen(open);
- if (open) setDrillProvider(null);
- }}
- >
- <Tooltip>
- <TooltipTrigger asChild>
- <PopoverTrigger asChild>
- <button
- className={cn(
- 'inline-flex items-center justify-center size-7 rounded-full transition-all cursor-pointer select-none',
- 'ring-1 ring-border/60 hover:ring-border hover:bg-muted/60',
- currentModelId &&
- 'ring-violet-300 dark:ring-violet-700 bg-violet-50 dark:bg-violet-950/20',
- )}
- >
- {currentProviderConfig?.icon ? (
- <img
- src={currentProviderConfig.icon}
- alt={currentProviderConfig.name}
- className={cn(
- 'size-4 rounded-sm',
- MONO_LOGO_PROVIDERS.has(currentProviderId) && 'dark:invert',
- )}
- />
- ) : (
- <Bot className="size-3.5 text-muted-foreground" />
- )}
- </button>
- </PopoverTrigger>
- </TooltipTrigger>
- <TooltipContent>
- {currentModelId
- ? `${currentProviderConfig?.name || currentProviderId} / ${currentModelId}`
- : t('settings.selectModel')}
- </TooltipContent>
- </Tooltip>
- <PopoverContent align="start" className="w-64 p-0">
- {/* Level 1: Provider list */}
- {!drillProvider && (
- <div className="max-h-72 overflow-y-auto">
- <div className="px-3 py-2 border-b">
- <span className="text-xs font-semibold text-muted-foreground">
- {t('toolbar.selectProvider')}
- </span>
- </div>
- {configuredProviders.map((provider) => {
- const isActive = currentProviderId === provider.id;
- return (
- <button
- key={provider.id}
- onClick={() => setDrillProvider(provider.id)}
- className={cn(
- 'w-full flex items-center gap-2.5 px-3 py-2.5 text-left transition-colors border-b border-border/30',
- isActive ? 'bg-violet-50/50 dark:bg-violet-950/10' : 'hover:bg-muted/50',
- )}
- >
- {provider.icon ? (
- <img
- src={provider.icon}
- alt={provider.name}
- className={cn(
- 'size-5 rounded-sm shrink-0',
- MONO_LOGO_PROVIDERS.has(provider.id) && 'dark:invert',
- )}
- />
- ) : (
- <Bot className="size-5 text-muted-foreground shrink-0" />
- )}
- <div className="flex-1 min-w-0">
- <span className="text-sm font-medium">{provider.name}</span>
- {provider.isServerConfigured && (
- <span className="text-[9px] px-1 py-0 rounded border text-muted-foreground ml-1.5">
- {t('settings.serverConfigured')}
- </span>
- )}
- </div>
- {isActive && currentModelId && (
- <span className="text-[10px] text-muted-foreground truncate max-w-[80px]">
- {currentModelId}
- </span>
- )}
- </button>
- );
- })}
- </div>
- )}
- {/* Level 2: Model list for selected provider */}
- {drillProvider && activeProvider && (
- <div className="max-h-72 overflow-y-auto">
- {/* Back header */}
- <button
- onClick={() => setDrillProvider(null)}
- className="w-full flex items-center gap-2 px-3 py-2 border-b bg-muted/40 hover:bg-muted/60 transition-colors"
- >
- <ChevronLeft className="size-3.5 text-muted-foreground" />
- {activeProvider.icon ? (
- <img
- src={activeProvider.icon}
- alt={activeProvider.name}
- className={cn(
- 'size-4 rounded-sm',
- MONO_LOGO_PROVIDERS.has(activeProvider.id) && 'dark:invert',
- )}
- />
- ) : (
- <Bot className="size-4 text-muted-foreground" />
- )}
- <span className="text-xs font-semibold">{activeProvider.name}</span>
- <span className="text-[10px] text-muted-foreground ml-auto">
- {activeProvider.models.length} {t('settings.modelCount')}
- </span>
- </button>
- {/* Models */}
- {activeProvider.models.map((model) => {
- const isSelected = currentProviderId === drillProvider && currentModelId === model.id;
- return (
- <button
- key={model.id}
- onClick={() => {
- setModel(drillProvider, model.id);
- setPopoverOpen(false);
- }}
- className={cn(
- 'w-full flex items-center gap-2 px-3 py-2 text-left transition-colors border-b border-border/30',
- isSelected
- ? 'bg-violet-50 dark:bg-violet-950/20 text-violet-700 dark:text-violet-300'
- : 'hover:bg-muted/50',
- )}
- >
- <span className="flex-1 truncate font-mono text-xs">{model.name}</span>
- {isSelected && (
- <Check className="size-3.5 shrink-0 text-violet-600 dark:text-violet-400" />
- )}
- </button>
- );
- })}
- </div>
- )}
- </PopoverContent>
- </Popover>
- );
- }
|