Files
openmaic/OpenMAIC/components/generation/generation-toolbar.tsx
2026-08-16 14:58:47 +08:00

252 lines
11 KiB
TypeScript

'use client';
import { useState, useRef, useMemo, useEffect } from 'react';
import { Paperclip, FileText, X } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/hooks/use-i18n';
import { useSettingsStore } from '@/lib/store/settings';
import type { SettingsSection } from '@/lib/types/settings';
import { MediaPopover } from '@/components/generation/media-popover';
import { getAcceptStringForProviders, isMimeSupportedByProviders } from '@/lib/document/mime';
import {
MAX_DOCUMENT_BUNDLE_FILES,
MAX_DOCUMENT_BUNDLE_TOTAL_SIZE_BYTES,
} from '@/lib/document/bundle';
import { dedupeCourseMaterialFiles } from '@/lib/document/course-materials';
import type { SelectedCourseMaterial } from '@/lib/types/generation';
// ─── 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
courseMaterials: SelectedCourseMaterial[];
onCourseMaterialsAdd: (files: File[]) => void;
onCourseMaterialRemove: (id: string) => void;
onPdfError: (error: string | null) => void;
}
// ─── Component ───────────────────────────────────────────────
export function GenerationToolbar({
webSearch,
onWebSearchChange,
onSettingsOpen,
courseMaterials,
onCourseMaterialsAdd,
onCourseMaterialRemove,
onPdfError,
}: GenerationToolbarProps) {
const { t } = useI18n();
const pdfProviderId = useSettingsStore((s) => s.pdfProviderId);
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
// Course material handler. `plain-text` is always active alongside the
// configured extractor so txt/md files remain uploadable without an
// additional service choice in the generation form.
const activeDocumentProviderIds = useMemo(
() => [pdfProviderId, 'plain-text'] as const,
[pdfProviderId],
);
const acceptForCurrentProvider = useMemo(
() => getAcceptStringForProviders(activeDocumentProviderIds),
[activeDocumentProviderIds],
);
// If the configured extractor changes and no longer supports already
// attached materials, drop only the incompatible files so the eventual
// extraction request matches its capability.
useEffect(() => {
const unsupportedMaterials = courseMaterials.filter(
(file) =>
!isMimeSupportedByProviders(
{ mimeType: file.type, fileName: file.name },
activeDocumentProviderIds,
),
);
if (unsupportedMaterials.length === 0) return;
for (const file of unsupportedMaterials) {
onCourseMaterialRemove(file.id);
}
onPdfError(t('upload.unsupportedCourseMaterial'));
// Intentionally omit callbacks/t from deps: adding them would re-run this
// provider capability cleanup on unrelated parent re-renders.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeDocumentProviderIds, courseMaterials]);
const handleFilesSelect = (incomingFiles: File[]) => {
const supportedFiles = incomingFiles.filter((file) =>
isMimeSupportedByProviders(
{ mimeType: file.type, fileName: file.name },
activeDocumentProviderIds,
),
);
if (supportedFiles.length === 0) {
onPdfError(t('upload.unsupportedCourseMaterial'));
return;
}
if (supportedFiles.length !== incomingFiles.length) {
onPdfError(t('upload.unsupportedCourseMaterial'));
return;
}
if (supportedFiles.some((file) => file.size > MAX_COURSE_MATERIAL_SIZE_BYTES)) {
onPdfError(t('upload.fileTooLarge'));
return;
}
const dedupedFiles = dedupeCourseMaterialFiles(courseMaterials, supportedFiles);
if (dedupedFiles.length === 0) return;
if (courseMaterials.length + dedupedFiles.length > MAX_DOCUMENT_BUNDLE_FILES) {
onPdfError(t('upload.courseMaterialCountLimit', { n: MAX_DOCUMENT_BUNDLE_FILES }));
return;
}
const totalSize =
courseMaterials.reduce((sum, file) => sum + file.size, 0) +
dedupedFiles.reduce((sum, file) => sum + file.size, 0);
if (totalSize > MAX_DOCUMENT_BUNDLE_TOTAL_SIZE_BYTES) {
onPdfError(
t('upload.courseMaterialTotalSizeLimit', {
n: Math.floor(MAX_DOCUMENT_BUNDLE_TOTAL_SIZE_BYTES / 1024 / 1024),
}),
);
return;
}
onPdfError(null);
onCourseMaterialsAdd(dedupedFiles);
};
// ─── 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">
<div className="flex min-w-0 items-center gap-1">
{/* ── Course material (extractor + upload) combined Popover ── */}
<Popover>
<PopoverTrigger asChild>
{courseMaterials.length > 0 ? (
<button className={pillActive}>
<Paperclip className="size-3.5" />
<span className="max-w-[140px] truncate">
{courseMaterials.length === 1
? courseMaterials[0].name
: t('toolbar.courseMaterialsSelected', { n: courseMaterials.length })}
</span>
</button>
) : (
<button className={pillMuted}>
<Paperclip className="size-3.5" />
</button>
)}
</PopoverTrigger>
<PopoverContent align="start" className="w-72 p-0">
{/* Upload area / file info */}
<div className="px-3 pb-3 pt-3">
<input
type="file"
ref={fileInputRef}
className="hidden"
accept={acceptForCurrentProvider}
multiple
onChange={(e) => {
const files = Array.from(e.target.files ?? []);
if (files.length > 0) handleFilesSelect(files);
e.target.value = '';
}}
/>
<div className="space-y-3">
<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 files = Array.from(e.dataTransfer.files ?? []);
if (files.length > 0) handleFilesSelect(files);
}}
>
<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 text-center">
{t('upload.courseMaterialSizeLimit')}
</p>
<p className="text-[10px] text-muted-foreground/60 text-center">
{t('upload.courseMaterialCountLimit', { n: MAX_DOCUMENT_BUNDLE_FILES })}
</p>
</div>
{courseMaterials.length > 0 && (
<div className="space-y-2">
<p className="text-[10px] text-muted-foreground/70">
{t('toolbar.courseMaterialMergeOrder')}
</p>
<div className="max-h-44 space-y-2 overflow-y-auto pr-1">
{[...courseMaterials]
.sort((a, b) => a.order - b.order)
.map((file) => (
<div
key={file.id}
className="flex items-center gap-2 rounded-lg border border-border/50 px-2 py-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">
{file.order}. {file.name}
</p>
<p className="text-xs text-muted-foreground">
{(file.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
<button
onClick={() => onCourseMaterialRemove(file.id)}
className="size-6 rounded-full inline-flex items-center justify-center text-muted-foreground hover:bg-muted transition-colors"
aria-label={t('toolbar.removeCourseMaterial')}
>
<X className="size-3.5" />
</button>
</div>
))}
</div>
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
{/* ── Media popover ── */}
<MediaPopover
onSettingsOpen={onSettingsOpen}
webSearch={webSearch}
onWebSearchChange={onWebSearchChange}
/>
</div>
</div>
);
}