Spaces:
Running
Running
| import React, { useEffect, useRef, useState } from "react"; | |
| import { | |
| Accordion, | |
| AccordionDetails, | |
| AccordionSummary, | |
| Box, | |
| Button, | |
| Chip, | |
| CircularProgress, | |
| FormControl, | |
| FormControlLabel, | |
| FormHelperText, | |
| FormLabel, | |
| InputLabel, | |
| MenuItem, | |
| Radio, | |
| RadioGroup, | |
| Select, | |
| TextField, | |
| ToggleButton, | |
| ToggleButtonGroup, | |
| Typography, | |
| } from "@mui/material"; | |
| import type { SelectChangeEvent } from "@mui/material"; | |
| import { Clear, ExpandMore, PlayArrow, Upload } from "@mui/icons-material"; | |
| import type { AnalyzeRequest, ModelsResponse } from "../types"; | |
| import { getModelDescription, getModelLabel } from "../utils/modelLabels"; | |
| import { uploadFile } from "../api/client"; | |
| // Fields that ArticleInput owns (text, metadata — no pipeline/model) | |
| type ArticleFields = Omit<AnalyzeRequest, "pipeline" | "model">; | |
| interface ArticleInputProps { | |
| onSubmit?: (request: AnalyzeRequest) => void; | |
| isLoading: boolean; | |
| showPipelineSelector?: boolean; | |
| availableModels?: ModelsResponse; | |
| // When true, hide the submit button (CompareTab renders its own button) | |
| hideSubmitButton?: boolean; | |
| // Fires on every text/metadata change so parent can track current content | |
| onRequestChange?: (request: ArticleFields) => void; | |
| } | |
| function ArticleInput({ | |
| onSubmit, | |
| isLoading, | |
| showPipelineSelector = true, | |
| availableModels, | |
| hideSubmitButton = false, | |
| onRequestChange, | |
| }: ArticleInputProps): React.ReactElement { | |
| const [inputMode, setInputMode] = useState<"text" | "file">("text"); | |
| const [textValue, setTextValue] = useState<string>(""); | |
| const [isUploading, setIsUploading] = useState<boolean>(false); | |
| const [uploadError, setUploadError] = useState<string | null>(null); | |
| const [extractedInfo, setExtractedInfo] = useState<{ filename: string; char_count: number } | null>(null); | |
| const [title, setTitle] = useState<string>(""); | |
| const [publicationDate, setPublicationDate] = useState<string>(""); | |
| const [source, setSource] = useState<string>(""); | |
| const [pipeline, setPipeline] = useState<"spacy" | "llm">("spacy"); | |
| const [model, setModel] = useState<string>(""); | |
| const fileInputRef = useRef<HTMLInputElement>(null); | |
| // Sync model default when pipeline changes or availableModels first loads | |
| useEffect(() => { | |
| if (availableModels) { | |
| setModel(availableModels[pipeline].default); | |
| } | |
| }, [availableModels, pipeline]); | |
| // Notify parent whenever article content changes (used by CompareTab) | |
| const onRequestChangeRef = useRef(onRequestChange); | |
| onRequestChangeRef.current = onRequestChange; | |
| useEffect(() => { | |
| onRequestChangeRef.current?.({ | |
| text: textValue, | |
| title: title || undefined, | |
| publication_date: publicationDate || null, | |
| source: source || undefined, | |
| }); | |
| }, [textValue, title, publicationDate, source]); | |
| const handleModeChange = ( | |
| _: React.MouseEvent<HTMLElement>, | |
| newMode: string | null, | |
| ): void => { | |
| if (newMode !== null) { | |
| setInputMode(newMode as "text" | "file"); | |
| } | |
| }; | |
| const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => { | |
| const file = e.target.files?.[0]; | |
| if (!file) return; | |
| setUploadError(null); | |
| setExtractedInfo(null); | |
| setIsUploading(true); | |
| try { | |
| const result = await uploadFile(file); | |
| setTextValue(result.text); | |
| setExtractedInfo({ filename: result.filename, char_count: result.char_count }); | |
| setInputMode("text"); | |
| } catch (err: unknown) { | |
| const msg = | |
| (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? | |
| "Upload failed"; | |
| setUploadError(msg); | |
| } finally { | |
| setIsUploading(false); | |
| if (fileInputRef.current) fileInputRef.current.value = ""; | |
| } | |
| }; | |
| const handleClearFile = (): void => { | |
| setExtractedInfo(null); | |
| setUploadError(null); | |
| setTextValue(""); | |
| if (fileInputRef.current) fileInputRef.current.value = ""; | |
| }; | |
| const handleSubmit = (): void => { | |
| if (!onSubmit) return; | |
| const request: AnalyzeRequest = { | |
| text: textValue, | |
| title: title || undefined, | |
| publication_date: publicationDate || null, | |
| source: source || undefined, | |
| pipeline: showPipelineSelector ? pipeline : undefined, | |
| model: showPipelineSelector && model ? model : undefined, | |
| }; | |
| onSubmit(request); | |
| }; | |
| return ( | |
| <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> | |
| <ToggleButtonGroup | |
| value={inputMode} | |
| exclusive | |
| onChange={handleModeChange} | |
| size="small" | |
| > | |
| <ToggleButton value="text">Paste Text</ToggleButton> | |
| <ToggleButton value="file">Upload File</ToggleButton> | |
| </ToggleButtonGroup> | |
| {inputMode === "text" && ( | |
| <Box> | |
| <TextField | |
| multiline | |
| minRows={8} | |
| fullWidth | |
| placeholder="Paste article text here..." | |
| value={textValue} | |
| onChange={(e) => setTextValue(e.target.value)} | |
| sx={{ "& .MuiInputBase-input": { fontFamily: "IBM Plex Mono, monospace" } }} | |
| /> | |
| <Box sx={{ mt: 0.5, display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}> | |
| <Typography variant="caption" color="text.secondary"> | |
| {textValue.length} characters | |
| </Typography> | |
| {extractedInfo && ( | |
| <Chip | |
| size="small" | |
| label={`Extracted from: ${extractedInfo.filename} (${extractedInfo.char_count} chars)`} | |
| onDelete={handleClearFile} | |
| color="info" | |
| variant="outlined" | |
| /> | |
| )} | |
| </Box> | |
| </Box> | |
| )} | |
| {inputMode === "file" && ( | |
| <Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}> | |
| <input | |
| type="file" | |
| accept=".txt,.pdf,.docx,.doc" | |
| ref={fileInputRef} | |
| onChange={handleFileChange} | |
| style={{ display: "none" }} | |
| /> | |
| <Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}> | |
| <Button | |
| variant="outlined" | |
| startIcon={isUploading ? <CircularProgress size={18} color="inherit" /> : <Upload />} | |
| onClick={() => !isUploading && fileInputRef.current?.click()} | |
| disabled={isUploading} | |
| > | |
| {isUploading ? "Extracting..." : "Choose File"} | |
| </Button> | |
| {!isUploading && extractedInfo && ( | |
| <Button | |
| size="small" | |
| color="inherit" | |
| startIcon={<Clear />} | |
| onClick={handleClearFile} | |
| > | |
| Clear | |
| </Button> | |
| )} | |
| </Box> | |
| {uploadError && ( | |
| <Typography variant="caption" color="error"> | |
| {uploadError} | |
| </Typography> | |
| )} | |
| <Typography variant="caption" color="text.secondary"> | |
| Supported formats: .txt, .pdf, .docx | |
| </Typography> | |
| </Box> | |
| )} | |
| <Accordion | |
| disableGutters | |
| elevation={0} | |
| sx={{ | |
| "&:before": { display: "none" }, | |
| border: "1px solid rgba(148,163,184,0.15)", | |
| borderRadius: 1, | |
| }} | |
| > | |
| <AccordionSummary expandIcon={<ExpandMore />}> | |
| <Typography variant="body2">Article Metadata</Typography> | |
| </AccordionSummary> | |
| <AccordionDetails> | |
| <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> | |
| <TextField | |
| label="Title" | |
| fullWidth | |
| value={title} | |
| onChange={(e) => setTitle(e.target.value)} | |
| size="small" | |
| /> | |
| <TextField | |
| label="Publication Date" | |
| type="date" | |
| fullWidth | |
| value={publicationDate} | |
| onChange={(e) => setPublicationDate(e.target.value)} | |
| size="small" | |
| slotProps={{ inputLabel: { shrink: true } }} | |
| /> | |
| <TextField | |
| label="Source" | |
| fullWidth | |
| value={source} | |
| onChange={(e) => setSource(e.target.value)} | |
| size="small" | |
| /> | |
| </Box> | |
| </AccordionDetails> | |
| </Accordion> | |
| {showPipelineSelector && ( | |
| <> | |
| <FormControl> | |
| <FormLabel>Pipeline</FormLabel> | |
| <RadioGroup | |
| value={pipeline} | |
| onChange={(e) => setPipeline(e.target.value as "spacy" | "llm")} | |
| row | |
| > | |
| <FormControlLabel | |
| value="spacy" | |
| control={<Radio size="small" />} | |
| label="Pipeline A" | |
| /> | |
| <FormControlLabel | |
| value="llm" | |
| control={<Radio size="small" />} | |
| label="Pipeline B" | |
| /> | |
| </RadioGroup> | |
| </FormControl> | |
| {availableModels && ( | |
| <FormControl size="small" sx={{ maxWidth: 360 }}> | |
| <InputLabel>Model</InputLabel> | |
| <Select | |
| value={model} | |
| label="Model" | |
| onChange={(e: SelectChangeEvent<string>) => setModel(e.target.value)} | |
| > | |
| {availableModels[pipeline].models.map((m) => ( | |
| <MenuItem key={m} value={m}> | |
| {getModelLabel(m)} | |
| </MenuItem> | |
| ))} | |
| </Select> | |
| {model && ( | |
| <FormHelperText>{getModelDescription(model)}</FormHelperText> | |
| )} | |
| </FormControl> | |
| )} | |
| </> | |
| )} | |
| {!hideSubmitButton && ( | |
| <Box> | |
| <Button | |
| variant="contained" | |
| color="primary" | |
| startIcon={ | |
| isLoading ? ( | |
| <CircularProgress size={20} color="inherit" /> | |
| ) : ( | |
| <PlayArrow /> | |
| ) | |
| } | |
| disabled={textValue.length < 20 || isLoading} | |
| onClick={handleSubmit} | |
| > | |
| {isLoading ? "Analyzing..." : "Analyze"} | |
| </Button> | |
| </Box> | |
| )} | |
| </Box> | |
| ); | |
| } | |
| export default ArticleInput; | |