generation-toolbar.tsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564
  1. 'use client';
  2. import { useState, useRef, useMemo } from 'react';
  3. import { Bot, Check, ChevronLeft, Globe, Paperclip, FileText, X, Globe2 } from 'lucide-react';
  4. import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
  5. import {
  6. Select,
  7. SelectContent,
  8. SelectItem,
  9. SelectTrigger,
  10. SelectValue,
  11. } from '@/components/ui/select';
  12. import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
  13. import { cn } from '@/lib/utils';
  14. import { useI18n } from '@/lib/hooks/use-i18n';
  15. import { useSettingsStore } from '@/lib/store/settings';
  16. import { PDF_PROVIDERS } from '@/lib/pdf/constants';
  17. import type { PDFProviderId } from '@/lib/pdf/types';
  18. import { WEB_SEARCH_PROVIDERS } from '@/lib/web-search/constants';
  19. import type { WebSearchProviderId } from '@/lib/web-search/types';
  20. import type { ProviderId } from '@/lib/ai/providers';
  21. import { MONO_LOGO_PROVIDERS } from '@/lib/ai/providers';
  22. import type { SettingsSection } from '@/lib/types/settings';
  23. import { MediaPopover } from '@/components/generation/media-popover';
  24. // ─── Constants ───────────────────────────────────────────────
  25. const MAX_PDF_SIZE_MB = 50;
  26. const MAX_PDF_SIZE_BYTES = MAX_PDF_SIZE_MB * 1024 * 1024;
  27. // ─── Types ───────────────────────────────────────────────────
  28. export interface GenerationToolbarProps {
  29. language: 'zh-CN' | 'en-US';
  30. onLanguageChange: (lang: 'zh-CN' | 'en-US') => void;
  31. webSearch: boolean;
  32. onWebSearchChange: (v: boolean) => void;
  33. onSettingsOpen: (section?: SettingsSection) => void;
  34. // PDF
  35. pdfFile: File | null;
  36. onPdfFileChange: (file: File | null) => void;
  37. onPdfError: (error: string | null) => void;
  38. }
  39. // ─── Component ───────────────────────────────────────────────
  40. export function GenerationToolbar({
  41. language,
  42. onLanguageChange,
  43. webSearch,
  44. onWebSearchChange,
  45. onSettingsOpen,
  46. pdfFile,
  47. onPdfFileChange,
  48. onPdfError,
  49. }: GenerationToolbarProps) {
  50. const { t } = useI18n();
  51. const currentProviderId = useSettingsStore((s) => s.providerId);
  52. const currentModelId = useSettingsStore((s) => s.modelId);
  53. const providersConfig = useSettingsStore((s) => s.providersConfig);
  54. const setModel = useSettingsStore((s) => s.setModel);
  55. const pdfProviderId = useSettingsStore((s) => s.pdfProviderId);
  56. const pdfProvidersConfig = useSettingsStore((s) => s.pdfProvidersConfig);
  57. const setPDFProvider = useSettingsStore((s) => s.setPDFProvider);
  58. const webSearchProviderId = useSettingsStore((s) => s.webSearchProviderId);
  59. const webSearchProvidersConfig = useSettingsStore((s) => s.webSearchProvidersConfig);
  60. const setWebSearchProvider = useSettingsStore((s) => s.setWebSearchProvider);
  61. const fileInputRef = useRef<HTMLInputElement>(null);
  62. const [isDragging, setIsDragging] = useState(false);
  63. // Check if the selected web search provider has a valid config (API key or server-configured)
  64. const webSearchProvider = WEB_SEARCH_PROVIDERS[webSearchProviderId];
  65. const webSearchConfig = webSearchProvidersConfig[webSearchProviderId];
  66. const webSearchAvailable = webSearchProvider
  67. ? !webSearchProvider.requiresApiKey ||
  68. !!webSearchConfig?.apiKey ||
  69. !!webSearchConfig?.isServerConfigured
  70. : false;
  71. // Configured LLM providers (only those with valid credentials + models + endpoint)
  72. const configuredProviders = providersConfig
  73. ? Object.entries(providersConfig)
  74. .filter(
  75. ([, config]) =>
  76. (!config.requiresApiKey || config.apiKey || config.isServerConfigured) &&
  77. config.models.length >= 1 &&
  78. (config.baseUrl || config.defaultBaseUrl || config.serverBaseUrl),
  79. )
  80. .map(([id, config]) => ({
  81. id: id as ProviderId,
  82. name: config.name,
  83. icon: config.icon,
  84. isServerConfigured: config.isServerConfigured,
  85. models:
  86. config.isServerConfigured && !config.apiKey && config.serverModels?.length
  87. ? config.models.filter((m) => new Set(config.serverModels).has(m.id))
  88. : config.models,
  89. }))
  90. : [];
  91. const currentProviderConfig = providersConfig?.[currentProviderId];
  92. // PDF handler
  93. const handleFileSelect = (file: File) => {
  94. if (file.type !== 'application/pdf') return;
  95. if (file.size > MAX_PDF_SIZE_BYTES) {
  96. onPdfError(t('upload.fileTooLarge'));
  97. return;
  98. }
  99. onPdfError(null);
  100. onPdfFileChange(file);
  101. };
  102. // ─── Pill button helper ─────────────────────────────
  103. const pillCls =
  104. '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';
  105. const pillMuted = `${pillCls} border-border/50 text-muted-foreground/70 hover:text-foreground hover:bg-muted/60`;
  106. 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`;
  107. return (
  108. <div className="flex items-center gap-1 flex-wrap">
  109. {/* ── Model selector ── */}
  110. {configuredProviders.length > 0 ? (
  111. <ModelSelectorPopover
  112. configuredProviders={configuredProviders}
  113. currentProviderId={currentProviderId}
  114. currentModelId={currentModelId}
  115. currentProviderConfig={currentProviderConfig}
  116. setModel={setModel}
  117. t={t}
  118. />
  119. ) : (
  120. <Tooltip>
  121. <TooltipTrigger asChild>
  122. <button
  123. onClick={() => onSettingsOpen('providers')}
  124. className={cn(
  125. pillCls,
  126. 'text-amber-600 dark:text-amber-400 animate-pulse',
  127. 'bg-amber-50 dark:bg-amber-950/30 hover:bg-amber-100 dark:hover:bg-amber-950/50',
  128. )}
  129. >
  130. <Bot className="size-3.5" />
  131. <span>{t('toolbar.configureProvider')}</span>
  132. </button>
  133. </TooltipTrigger>
  134. <TooltipContent>{t('toolbar.configureProviderHint')}</TooltipContent>
  135. </Tooltip>
  136. )}
  137. {/* ── Separator ── */}
  138. <div className="w-px h-4 bg-border/60 mx-1" />
  139. {/* ── PDF (parser + upload) combined Popover ── */}
  140. <Popover>
  141. <PopoverTrigger asChild>
  142. {pdfFile ? (
  143. <button className={pillActive}>
  144. <Paperclip className="size-3.5" />
  145. <span className="max-w-[100px] truncate">{pdfFile.name}</span>
  146. <span
  147. role="button"
  148. className="size-4 rounded-full inline-flex items-center justify-center hover:bg-violet-200 dark:hover:bg-violet-800 transition-colors"
  149. onClick={(e) => {
  150. e.stopPropagation();
  151. onPdfFileChange(null);
  152. }}
  153. >
  154. <X className="size-2.5" />
  155. </span>
  156. </button>
  157. ) : (
  158. <button className={pillMuted}>
  159. <Paperclip className="size-3.5" />
  160. </button>
  161. )}
  162. </PopoverTrigger>
  163. <PopoverContent align="start" className="w-72 p-0">
  164. {/* Parser selector */}
  165. <div className="flex items-center gap-2 px-3 pt-3 pb-2">
  166. <span className="text-xs font-medium text-muted-foreground shrink-0">
  167. {t('toolbar.pdfParser')}
  168. </span>
  169. <Select value={pdfProviderId} onValueChange={(v) => setPDFProvider(v as PDFProviderId)}>
  170. <SelectTrigger className="h-7 text-xs flex-1 min-w-0">
  171. <SelectValue />
  172. </SelectTrigger>
  173. <SelectContent>
  174. {Object.values(PDF_PROVIDERS).map((provider) => {
  175. const cfg = pdfProvidersConfig[provider.id];
  176. const available =
  177. !provider.requiresApiKey || !!cfg?.apiKey || !!cfg?.isServerConfigured;
  178. return (
  179. <SelectItem key={provider.id} value={provider.id} disabled={!available}>
  180. <div className={cn('flex items-center gap-1.5', !available && 'opacity-50')}>
  181. {provider.icon && (
  182. <img src={provider.icon} alt={provider.name} className="w-3.5 h-3.5" />
  183. )}
  184. {provider.name}
  185. {cfg?.isServerConfigured && (
  186. <span className="text-[9px] px-1 py-0 rounded border text-muted-foreground">
  187. {t('settings.serverConfigured')}
  188. </span>
  189. )}
  190. </div>
  191. </SelectItem>
  192. );
  193. })}
  194. </SelectContent>
  195. </Select>
  196. </div>
  197. {/* Upload area / file info */}
  198. <div className="px-3 pb-3">
  199. <input
  200. type="file"
  201. ref={fileInputRef}
  202. className="hidden"
  203. accept=".pdf"
  204. onChange={(e) => {
  205. const f = e.target.files?.[0];
  206. if (f) handleFileSelect(f);
  207. e.target.value = '';
  208. }}
  209. />
  210. {pdfFile ? (
  211. <div className="space-y-2">
  212. <div className="flex items-center gap-2">
  213. <div className="size-8 rounded-lg bg-violet-100 dark:bg-violet-900/30 flex items-center justify-center shrink-0">
  214. <FileText className="size-4 text-violet-600 dark:text-violet-400" />
  215. </div>
  216. <div className="min-w-0 flex-1">
  217. <p className="text-sm font-medium truncate">{pdfFile.name}</p>
  218. <p className="text-xs text-muted-foreground">
  219. {(pdfFile.size / 1024 / 1024).toFixed(2)} MB
  220. </p>
  221. </div>
  222. </div>
  223. <button
  224. onClick={() => onPdfFileChange(null)}
  225. className="w-full text-xs text-destructive hover:underline text-left"
  226. >
  227. {t('toolbar.removePdf')}
  228. </button>
  229. </div>
  230. ) : (
  231. <div
  232. className={cn(
  233. 'flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-4 transition-colors cursor-pointer',
  234. isDragging
  235. ? 'border-violet-400 bg-violet-50 dark:bg-violet-950/20'
  236. : 'border-muted-foreground/20 hover:border-violet-300',
  237. )}
  238. onClick={() => fileInputRef.current?.click()}
  239. onDragOver={(e) => {
  240. e.preventDefault();
  241. setIsDragging(true);
  242. }}
  243. onDragLeave={() => setIsDragging(false)}
  244. onDrop={(e) => {
  245. e.preventDefault();
  246. setIsDragging(false);
  247. const f = e.dataTransfer.files?.[0];
  248. if (f) handleFileSelect(f);
  249. }}
  250. >
  251. <Paperclip className="size-5 text-muted-foreground/50 mb-1.5" />
  252. <p className="text-xs font-medium">{t('toolbar.pdfUpload')}</p>
  253. <p className="text-[10px] text-muted-foreground/60 mt-0.5">
  254. {t('upload.pdfSizeLimit')}
  255. </p>
  256. </div>
  257. )}
  258. </div>
  259. </PopoverContent>
  260. </Popover>
  261. {/* ── Web Search ── */}
  262. {webSearchAvailable ? (
  263. <Popover>
  264. <PopoverTrigger asChild>
  265. <button className={webSearch ? pillActive : pillMuted}>
  266. <Globe2 className={cn('size-3.5', webSearch && 'animate-pulse')} />
  267. {webSearch && (
  268. <span>{WEB_SEARCH_PROVIDERS[webSearchProviderId]?.name || 'Search'}</span>
  269. )}
  270. </button>
  271. </PopoverTrigger>
  272. <PopoverContent align="start" className="w-64 p-3 space-y-3">
  273. {/* Toggle */}
  274. <button
  275. onClick={() => onWebSearchChange(!webSearch)}
  276. className={cn(
  277. 'w-full flex items-center gap-2.5 rounded-lg border px-3 py-2.5 text-left transition-all',
  278. webSearch
  279. ? 'bg-violet-50 dark:bg-violet-950/20 border-violet-200 dark:border-violet-800'
  280. : 'border-border hover:bg-muted/50',
  281. )}
  282. >
  283. <Globe2
  284. className={cn(
  285. 'size-4 shrink-0',
  286. webSearch ? 'text-violet-600 dark:text-violet-400' : 'text-muted-foreground',
  287. )}
  288. />
  289. <div className="flex-1 min-w-0">
  290. <p className="text-xs font-medium">
  291. {webSearch ? t('toolbar.webSearchOn') : t('toolbar.webSearchOff')}
  292. </p>
  293. <p className="text-[10px] text-muted-foreground/70 mt-0.5">
  294. {t('toolbar.webSearchDesc')}
  295. </p>
  296. </div>
  297. </button>
  298. {/* Provider selector */}
  299. <div className="flex items-center gap-2">
  300. <span className="text-xs font-medium text-muted-foreground shrink-0">
  301. {t('toolbar.webSearchProvider')}
  302. </span>
  303. <Select
  304. value={webSearchProviderId}
  305. onValueChange={(v) => setWebSearchProvider(v as WebSearchProviderId)}
  306. >
  307. <SelectTrigger className="h-7 text-xs flex-1 min-w-0">
  308. <SelectValue />
  309. </SelectTrigger>
  310. <SelectContent>
  311. {Object.values(WEB_SEARCH_PROVIDERS).map((provider) => {
  312. const cfg = webSearchProvidersConfig[provider.id];
  313. const available =
  314. !provider.requiresApiKey || !!cfg?.apiKey || !!cfg?.isServerConfigured;
  315. return (
  316. <SelectItem key={provider.id} value={provider.id} disabled={!available}>
  317. <div
  318. className={cn('flex items-center gap-1.5', !available && 'opacity-50')}
  319. >
  320. {provider.name}
  321. {cfg?.isServerConfigured && (
  322. <span className="text-[9px] px-1 py-0 rounded border text-muted-foreground">
  323. {t('settings.serverConfigured')}
  324. </span>
  325. )}
  326. </div>
  327. </SelectItem>
  328. );
  329. })}
  330. </SelectContent>
  331. </Select>
  332. </div>
  333. </PopoverContent>
  334. </Popover>
  335. ) : (
  336. <Tooltip>
  337. <TooltipTrigger asChild>
  338. <button className={cn(pillCls, 'text-muted-foreground/40 cursor-not-allowed')} disabled>
  339. <Globe2 className="size-3.5" />
  340. </button>
  341. </TooltipTrigger>
  342. <TooltipContent>{t('toolbar.webSearchNoProvider')}</TooltipContent>
  343. </Tooltip>
  344. )}
  345. {/* ── Language pill ── */}
  346. <Tooltip>
  347. <TooltipTrigger asChild>
  348. <button
  349. onClick={() => onLanguageChange(language === 'zh-CN' ? 'en-US' : 'zh-CN')}
  350. className={pillMuted}
  351. >
  352. <Globe className="size-3.5" />
  353. <span>{language === 'zh-CN' ? '中文' : 'EN'}</span>
  354. </button>
  355. </TooltipTrigger>
  356. <TooltipContent>{t('toolbar.languageHint')}</TooltipContent>
  357. </Tooltip>
  358. {/* ── Separator ── */}
  359. <div className="w-px h-4 bg-border/60 mx-1" />
  360. {/* ── Media popover ── */}
  361. <MediaPopover onSettingsOpen={onSettingsOpen} />
  362. </div>
  363. );
  364. }
  365. // ─── ModelSelectorPopover (two-level: provider → model) ─────
  366. interface ConfiguredProvider {
  367. id: ProviderId;
  368. name: string;
  369. icon?: string;
  370. isServerConfigured?: boolean;
  371. models: { id: string; name: string }[];
  372. }
  373. function ModelSelectorPopover({
  374. configuredProviders,
  375. currentProviderId,
  376. currentModelId,
  377. currentProviderConfig,
  378. setModel,
  379. t,
  380. }: {
  381. configuredProviders: ConfiguredProvider[];
  382. currentProviderId: ProviderId;
  383. currentModelId: string;
  384. currentProviderConfig: { name: string; icon?: string } | undefined;
  385. setModel: (providerId: ProviderId, modelId: string) => void;
  386. t: (key: string) => string;
  387. }) {
  388. const [popoverOpen, setPopoverOpen] = useState(false);
  389. // null = provider list, ProviderId = model list for that provider
  390. const [drillProvider, setDrillProvider] = useState<ProviderId | null>(null);
  391. const activeProvider = useMemo(
  392. () => configuredProviders.find((p) => p.id === drillProvider),
  393. [configuredProviders, drillProvider],
  394. );
  395. return (
  396. <Popover
  397. open={popoverOpen}
  398. onOpenChange={(open) => {
  399. setPopoverOpen(open);
  400. if (open) setDrillProvider(null);
  401. }}
  402. >
  403. <Tooltip>
  404. <TooltipTrigger asChild>
  405. <PopoverTrigger asChild>
  406. <button
  407. className={cn(
  408. 'inline-flex items-center justify-center size-7 rounded-full transition-all cursor-pointer select-none',
  409. 'ring-1 ring-border/60 hover:ring-border hover:bg-muted/60',
  410. currentModelId &&
  411. 'ring-violet-300 dark:ring-violet-700 bg-violet-50 dark:bg-violet-950/20',
  412. )}
  413. >
  414. {currentProviderConfig?.icon ? (
  415. <img
  416. src={currentProviderConfig.icon}
  417. alt={currentProviderConfig.name}
  418. className={cn(
  419. 'size-4 rounded-sm',
  420. MONO_LOGO_PROVIDERS.has(currentProviderId) && 'dark:invert',
  421. )}
  422. />
  423. ) : (
  424. <Bot className="size-3.5 text-muted-foreground" />
  425. )}
  426. </button>
  427. </PopoverTrigger>
  428. </TooltipTrigger>
  429. <TooltipContent>
  430. {currentModelId
  431. ? `${currentProviderConfig?.name || currentProviderId} / ${currentModelId}`
  432. : t('settings.selectModel')}
  433. </TooltipContent>
  434. </Tooltip>
  435. <PopoverContent align="start" className="w-64 p-0">
  436. {/* Level 1: Provider list */}
  437. {!drillProvider && (
  438. <div className="max-h-72 overflow-y-auto">
  439. <div className="px-3 py-2 border-b">
  440. <span className="text-xs font-semibold text-muted-foreground">
  441. {t('toolbar.selectProvider')}
  442. </span>
  443. </div>
  444. {configuredProviders.map((provider) => {
  445. const isActive = currentProviderId === provider.id;
  446. return (
  447. <button
  448. key={provider.id}
  449. onClick={() => setDrillProvider(provider.id)}
  450. className={cn(
  451. 'w-full flex items-center gap-2.5 px-3 py-2.5 text-left transition-colors border-b border-border/30',
  452. isActive ? 'bg-violet-50/50 dark:bg-violet-950/10' : 'hover:bg-muted/50',
  453. )}
  454. >
  455. {provider.icon ? (
  456. <img
  457. src={provider.icon}
  458. alt={provider.name}
  459. className={cn(
  460. 'size-5 rounded-sm shrink-0',
  461. MONO_LOGO_PROVIDERS.has(provider.id) && 'dark:invert',
  462. )}
  463. />
  464. ) : (
  465. <Bot className="size-5 text-muted-foreground shrink-0" />
  466. )}
  467. <div className="flex-1 min-w-0">
  468. <span className="text-sm font-medium">{provider.name}</span>
  469. {provider.isServerConfigured && (
  470. <span className="text-[9px] px-1 py-0 rounded border text-muted-foreground ml-1.5">
  471. {t('settings.serverConfigured')}
  472. </span>
  473. )}
  474. </div>
  475. {isActive && currentModelId && (
  476. <span className="text-[10px] text-muted-foreground truncate max-w-[80px]">
  477. {currentModelId}
  478. </span>
  479. )}
  480. </button>
  481. );
  482. })}
  483. </div>
  484. )}
  485. {/* Level 2: Model list for selected provider */}
  486. {drillProvider && activeProvider && (
  487. <div className="max-h-72 overflow-y-auto">
  488. {/* Back header */}
  489. <button
  490. onClick={() => setDrillProvider(null)}
  491. className="w-full flex items-center gap-2 px-3 py-2 border-b bg-muted/40 hover:bg-muted/60 transition-colors"
  492. >
  493. <ChevronLeft className="size-3.5 text-muted-foreground" />
  494. {activeProvider.icon ? (
  495. <img
  496. src={activeProvider.icon}
  497. alt={activeProvider.name}
  498. className={cn(
  499. 'size-4 rounded-sm',
  500. MONO_LOGO_PROVIDERS.has(activeProvider.id) && 'dark:invert',
  501. )}
  502. />
  503. ) : (
  504. <Bot className="size-4 text-muted-foreground" />
  505. )}
  506. <span className="text-xs font-semibold">{activeProvider.name}</span>
  507. <span className="text-[10px] text-muted-foreground ml-auto">
  508. {activeProvider.models.length} {t('settings.modelCount')}
  509. </span>
  510. </button>
  511. {/* Models */}
  512. {activeProvider.models.map((model) => {
  513. const isSelected = currentProviderId === drillProvider && currentModelId === model.id;
  514. return (
  515. <button
  516. key={model.id}
  517. onClick={() => {
  518. setModel(drillProvider, model.id);
  519. setPopoverOpen(false);
  520. }}
  521. className={cn(
  522. 'w-full flex items-center gap-2 px-3 py-2 text-left transition-colors border-b border-border/30',
  523. isSelected
  524. ? 'bg-violet-50 dark:bg-violet-950/20 text-violet-700 dark:text-violet-300'
  525. : 'hover:bg-muted/50',
  526. )}
  527. >
  528. <span className="flex-1 truncate font-mono text-xs">{model.name}</span>
  529. {isSelected && (
  530. <Check className="size-3.5 shrink-0 text-violet-600 dark:text-violet-400" />
  531. )}
  532. </button>
  533. );
  534. })}
  535. </div>
  536. )}
  537. </PopoverContent>
  538. </Popover>
  539. );
  540. }