Initial commit

This commit is contained in:
2026-02-21 09:44:18 -03:00
commit 5dfc2cbd85
65 changed files with 11989 additions and 0 deletions

View File

@@ -0,0 +1,79 @@
/**
* Reusable modal for confirmations and multi-action prompts.
*/
interface ActionModalOption {
key: string;
label: string;
tone?: 'neutral' | 'primary' | 'warning' | 'danger';
}
interface ActionModalProps {
isOpen: boolean;
title: string;
message: string;
options: ActionModalOption[];
onSelect: (key: string) => void;
onDismiss: () => void;
}
/**
* Renders a centered modal dialog with configurable action buttons.
*/
export default function ActionModal({
isOpen,
title,
message,
options,
onSelect,
onDismiss,
}: ActionModalProps): JSX.Element | null {
if (!isOpen) {
return null;
}
return (
<div
className="modal-backdrop"
role="button"
tabIndex={0}
aria-label="Close dialog"
onClick={onDismiss}
onKeyDown={(event) => {
if (event.key === 'Escape') {
onDismiss();
}
}}
>
<section
className="action-modal"
role="dialog"
aria-modal="true"
aria-labelledby="action-modal-title"
onClick={(event) => event.stopPropagation()}
>
<h3 id="action-modal-title">{title}</h3>
<p>{message}</p>
<div className="action-modal-buttons">
{options.map((option) => (
<button
key={option.key}
type="button"
className={
option.tone === 'danger'
? 'danger-action'
: option.tone === 'warning'
? 'warning-action'
: option.tone === 'neutral'
? 'secondary-action'
: ''
}
onClick={() => onSelect(option.key)}
>
{option.label}
</button>
))}
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,220 @@
/**
* Card view for displaying document summary, preview, and metadata.
*/
import { useState } from 'react';
import { Download, FileText, Trash2 } from 'lucide-react';
import type { DmsDocument } from '../types';
import { contentMarkdownUrl, downloadUrl, thumbnailUrl } from '../lib/api';
/**
* Defines properties accepted by the document card component.
*/
interface DocumentCardProps {
document: DmsDocument;
isSelected: boolean;
isChecked: boolean;
isTrashView: boolean;
onSelect: (document: DmsDocument) => void;
onToggleChecked: (documentId: string, checked: boolean) => void;
onTrashDocument: (documentId: string) => Promise<void>;
onFilterPath: (path: string) => void;
onFilterTag: (tag: string) => void;
}
/**
* Defines visual processing status variants rendered in the card header indicator.
*/
type StatusTone = 'success' | 'progress' | 'failed';
/**
* Resolves status tone and tooltip text from backend document status values.
*/
function statusPresentation(status: DmsDocument['status']): { tone: StatusTone; tooltip: string } {
if (status === 'processed') {
return { tone: 'success', tooltip: 'Processing status: success' };
}
if (status === 'queued') {
return { tone: 'progress', tooltip: 'Processing status: in progress' };
}
if (status === 'error') {
return { tone: 'failed', tooltip: 'Processing status: failed' };
}
if (status === 'unsupported') {
return { tone: 'failed', tooltip: 'Processing status: failed (unsupported type)' };
}
return { tone: 'success', tooltip: 'Processing status: success (moved to trash)' };
}
/**
* Limits logical-path length while preserving start and end context with middle ellipsis.
*/
function compactLogicalPath(path: string, maxChars = 180): string {
const normalized = path.trim();
if (!normalized) {
return '';
}
if (normalized.length <= maxChars) {
return normalized;
}
const keepChars = Math.max(12, maxChars - 3);
const headChars = Math.ceil(keepChars * 0.6);
const tailChars = keepChars - headChars;
return `${normalized.slice(0, headChars)}...${normalized.slice(-tailChars)}`;
}
/**
* Renders one document card with optional image preview and searchable metadata.
*/
export default function DocumentCard({
document,
isSelected,
isChecked,
isTrashView,
onSelect,
onToggleChecked,
onTrashDocument,
onFilterPath,
onFilterTag,
}: DocumentCardProps): JSX.Element {
const [isTrashing, setIsTrashing] = useState<boolean>(false);
const createdDate = new Date(document.created_at).toLocaleString();
const status = statusPresentation(document.status);
const compactPath = compactLogicalPath(document.logical_path, 180);
const trashDisabled = isTrashView || document.status === 'trashed' || isTrashing;
const trashTitle = trashDisabled ? 'Already in trash' : 'Move to trash';
return (
<article
className={`document-card ${isSelected ? 'selected' : ''}`}
role="button"
tabIndex={0}
onClick={() => onSelect(document)}
onKeyDown={(event) => {
if (event.currentTarget !== event.target) {
return;
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onSelect(document);
}
}}
>
<header className="document-card-header">
<div
className={`card-status-indicator ${status.tone}`}
title={status.tooltip}
aria-label={status.tooltip}
/>
<label className="card-checkbox card-checkbox-compact" onClick={(event) => event.stopPropagation()}>
<input
type="checkbox"
checked={isChecked}
onChange={(event) => onToggleChecked(document.id, event.target.checked)}
onClick={(event) => event.stopPropagation()}
aria-label={`Select ${document.original_filename}`}
title="Select document"
/>
</label>
</header>
<div className="document-preview">
{document.preview_available ? (
<img src={thumbnailUrl(document.id)} alt={document.original_filename} loading="lazy" />
) : (
<div className="document-preview-fallback">{document.extension || 'file'}</div>
)}
</div>
<div className="document-content document-card-body">
<h3 title={`${document.logical_path}/${document.original_filename}`}>
<span className="document-title-path">{compactPath}/</span>
<span className="document-title-name">{document.original_filename}</span>
</h3>
<p className="document-date">{createdDate}</p>
</div>
<footer className="document-card-footer">
<div className="card-footer-discovery">
<button
type="button"
className="card-chip path-chip"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onFilterPath(document.logical_path);
}}
title={`Filter by path: ${document.logical_path}`}
>
{document.logical_path}
</button>
<div className="card-chip-row">
{document.tags.slice(0, 4).map((tag) => (
<button
key={`${document.id}-${tag}`}
type="button"
className="card-chip"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onFilterTag(tag);
}}
title={`Filter by tag: ${tag}`}
>
#{tag}
</button>
))}
</div>
</div>
<div className="card-action-row">
<button
type="button"
className="card-icon-button"
aria-label="Download original"
title="Download original"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
window.open(downloadUrl(document.id), '_blank', 'noopener,noreferrer');
}}
>
<Download aria-hidden="true" />
</button>
<button
type="button"
className="card-icon-button"
aria-label="Export recognized text as markdown"
title="Export recognized text as markdown"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
window.open(contentMarkdownUrl(document.id), '_blank', 'noopener,noreferrer');
}}
>
<FileText aria-hidden="true" />
</button>
<button
type="button"
className="card-icon-button danger"
aria-label={trashTitle}
title={trashTitle}
disabled={trashDisabled}
onClick={async (event) => {
event.preventDefault();
event.stopPropagation();
if (trashDisabled) {
return;
}
setIsTrashing(true);
try {
await onTrashDocument(document.id);
} catch {
} finally {
setIsTrashing(false);
}
}}
>
<Trash2 aria-hidden="true" />
</button>
</div>
</footer>
</article>
);
}

View File

@@ -0,0 +1,54 @@
/**
* Grid renderer for document collections.
*/
import type { DmsDocument } from '../types';
import DocumentCard from './DocumentCard';
/**
* Defines props for document grid rendering.
*/
interface DocumentGridProps {
documents: DmsDocument[];
selectedDocumentId: string | null;
selectedDocumentIds: string[];
isTrashView: boolean;
onSelect: (document: DmsDocument) => void;
onToggleChecked: (documentId: string, checked: boolean) => void;
onTrashDocument: (documentId: string) => Promise<void>;
onFilterPath: (path: string) => void;
onFilterTag: (tag: string) => void;
}
/**
* Renders cards in a responsive grid with selection state.
*/
export default function DocumentGrid({
documents,
selectedDocumentId,
selectedDocumentIds,
isTrashView,
onSelect,
onToggleChecked,
onTrashDocument,
onFilterPath,
onFilterTag,
}: DocumentGridProps): JSX.Element {
return (
<section className="document-grid">
{documents.map((document) => (
<DocumentCard
key={document.id}
document={document}
onSelect={onSelect}
isSelected={selectedDocumentId === document.id}
isChecked={selectedDocumentIds.includes(document.id)}
onToggleChecked={onToggleChecked}
isTrashView={isTrashView}
onTrashDocument={onTrashDocument}
onFilterPath={onFilterPath}
onFilterTag={onFilterTag}
/>
))}
</section>
);
}

View File

@@ -0,0 +1,585 @@
/**
* Embedded document viewer panel for preview, metadata updates, and lifecycle actions.
*/
import { useEffect, useMemo, useState } from 'react';
import {
contentMarkdownUrl,
deleteDocument,
getDocumentDetails,
previewUrl,
reprocessDocument,
restoreDocument,
trashDocument,
updateDocumentMetadata,
} from '../lib/api';
import type { DmsDocument, DmsDocumentDetail } from '../types';
import PathInput from './PathInput';
import TagInput from './TagInput';
/**
* Defines props for the selected document viewer panel.
*/
interface DocumentViewerProps {
document: DmsDocument | null;
isTrashView: boolean;
existingTags: string[];
existingPaths: string[];
onDocumentUpdated: (document: DmsDocument) => void;
onDocumentDeleted: (documentId: string) => void;
requestConfirmation: (title: string, message: string, confirmLabel?: string) => Promise<boolean>;
}
/**
* Renders selected document preview with editable metadata and lifecycle controls.
*/
export default function DocumentViewer({
document,
isTrashView,
existingTags,
existingPaths,
onDocumentUpdated,
onDocumentDeleted,
requestConfirmation,
}: DocumentViewerProps): JSX.Element {
const [documentDetail, setDocumentDetail] = useState<DmsDocumentDetail | null>(null);
const [isLoadingDetails, setIsLoadingDetails] = useState<boolean>(false);
const [originalFilename, setOriginalFilename] = useState<string>('');
const [logicalPath, setLogicalPath] = useState<string>('');
const [tags, setTags] = useState<string[]>([]);
const [isSaving, setIsSaving] = useState<boolean>(false);
const [isReprocessing, setIsReprocessing] = useState<boolean>(false);
const [isTrashing, setIsTrashing] = useState<boolean>(false);
const [isRestoring, setIsRestoring] = useState<boolean>(false);
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const [isMetadataDirty, setIsMetadataDirty] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
/**
* Syncs editable metadata fields whenever selection changes.
*/
useEffect(() => {
if (!document) {
setDocumentDetail(null);
setIsMetadataDirty(false);
return;
}
setOriginalFilename(document.original_filename);
setLogicalPath(document.logical_path);
setTags(document.tags);
setIsMetadataDirty(false);
setError(null);
}, [document?.id]);
/**
* Refreshes editable metadata from list updates only while form is clean.
*/
useEffect(() => {
if (!document || isMetadataDirty) {
return;
}
setOriginalFilename(document.original_filename);
setLogicalPath(document.logical_path);
setTags(document.tags);
}, [
document?.id,
document?.original_filename,
document?.logical_path,
document?.tags,
isMetadataDirty,
]);
/**
* Loads full selected-document details for extracted text and metadata display.
*/
useEffect(() => {
if (!document) {
return;
}
let cancelled = false;
const loadDocumentDetails = async (): Promise<void> => {
setIsLoadingDetails(true);
try {
const payload = await getDocumentDetails(document.id);
if (!cancelled) {
setDocumentDetail(payload);
}
} catch (caughtError) {
if (!cancelled) {
setError(caughtError instanceof Error ? caughtError.message : 'Failed to load document details');
}
} finally {
if (!cancelled) {
setIsLoadingDetails(false);
}
}
};
void loadDocumentDetails();
return () => {
cancelled = true;
};
}, [document?.id]);
/**
* Resolves whether selected document should render as an image element in preview.
*/
const isImageDocument = useMemo(() => {
if (!document) {
return false;
}
return document.mime_type.startsWith('image/');
}, [document]);
/**
* Extracts provider/transcription errors from document metadata for user visibility.
*/
const transcriptionError = useMemo(() => {
const value = documentDetail?.metadata_json?.transcription_error;
return typeof value === 'string' ? value : '';
}, [documentDetail]);
/**
* Extracts routing errors from metadata to surface classification issues.
*/
const routingError = useMemo(() => {
const value = documentDetail?.metadata_json?.routing_error;
return typeof value === 'string' ? value : '';
}, [documentDetail]);
/**
* Builds a compact routing status summary for user visibility.
*/
const routingSummary = useMemo(() => {
const value = documentDetail?.metadata_json?.routing;
if (!value || typeof value !== 'object') {
return '';
}
const routing = value as Record<string, unknown>;
const confidence = typeof routing.confidence === 'number' ? routing.confidence : null;
const similarity = typeof routing.neighbor_similarity === 'number' ? routing.neighbor_similarity : null;
const confidenceThreshold =
typeof routing.auto_apply_confidence_threshold === 'number'
? routing.auto_apply_confidence_threshold
: null;
const autoApplied = typeof routing.auto_applied === 'boolean' ? routing.auto_applied : null;
const autoAppliedPath = typeof routing.auto_applied_path === 'boolean' ? routing.auto_applied_path : null;
const autoAppliedTags = typeof routing.auto_applied_tags === 'boolean' ? routing.auto_applied_tags : null;
const blockedReasonsRaw = routing.auto_apply_blocked_reasons;
const blockedReasons =
Array.isArray(blockedReasonsRaw) && blockedReasonsRaw.length > 0
? blockedReasonsRaw
.map((reason) => String(reason))
.map((reason) => {
if (reason === 'missing_chosen_path') {
return 'no chosen path';
}
if (reason === 'confidence_below_threshold') {
return 'confidence below threshold';
}
if (reason === 'neighbor_similarity_below_threshold') {
return 'neighbor similarity below threshold';
}
return reason;
})
: [];
const parts: string[] = [];
if (autoApplied !== null) {
parts.push(`Auto Applied: ${autoApplied ? 'yes' : 'no'}`);
}
if (autoApplied) {
const appliedTargets: string[] = [];
if (autoAppliedPath) {
appliedTargets.push('path');
}
if (autoAppliedTags) {
appliedTargets.push('tags');
}
if (appliedTargets.length > 0) {
parts.push(`Applied: ${appliedTargets.join(' + ')}`);
}
}
if (confidence !== null) {
if (confidenceThreshold !== null) {
parts.push(`Confidence: ${confidence.toFixed(2)} / ${confidenceThreshold.toFixed(2)}`);
} else {
parts.push(`Confidence: ${confidence.toFixed(2)}`);
}
}
if (similarity !== null) {
parts.push(`Neighbor Similarity (info): ${similarity.toFixed(2)}`);
}
if (autoApplied === false && blockedReasons.length > 0) {
parts.push(`Blocked: ${blockedReasons.join(', ')}`);
}
return parts.join(' | ');
}, [documentDetail]);
/**
* Resolves whether routing already auto-applied path and tags.
*/
const routingAutoApplyState = useMemo(() => {
const value = documentDetail?.metadata_json?.routing;
if (!value || typeof value !== 'object') {
return {
autoAppliedPath: false,
autoAppliedTags: false,
};
}
const routing = value as Record<string, unknown>;
return {
autoAppliedPath: routing.auto_applied_path === true,
autoAppliedTags: routing.auto_applied_tags === true,
};
}, [documentDetail]);
/**
* Resolves whether any routing suggestion still needs manual application.
*/
const hasPathSuggestion = Boolean(document?.suggested_path) && !routingAutoApplyState.autoAppliedPath;
const hasTagSuggestions = (document?.suggested_tags.length ?? 0) > 0 && !routingAutoApplyState.autoAppliedTags;
const canApplyAllSuggestions = hasPathSuggestion || hasTagSuggestions;
/**
* Applies suggested path value to editable metadata field.
*/
const applySuggestedPath = (): void => {
if (!hasPathSuggestion || !document?.suggested_path) {
return;
}
setLogicalPath(document.suggested_path);
setIsMetadataDirty(true);
};
/**
* Applies one suggested tag to editable metadata field.
*/
const applySuggestedTag = (tag: string): void => {
if (!hasTagSuggestions || tags.includes(tag)) {
return;
}
setTags([...tags, tag]);
setIsMetadataDirty(true);
};
/**
* Applies all suggested routing values into editable metadata fields.
*/
const applyAllSuggestions = (): void => {
if (hasPathSuggestion && document?.suggested_path) {
setLogicalPath(document.suggested_path);
}
if (hasTagSuggestions && document?.suggested_tags.length) {
const nextTags = [...tags];
for (const tag of document.suggested_tags) {
if (!nextTags.includes(tag)) {
nextTags.push(tag);
}
}
setTags(nextTags);
}
setIsMetadataDirty(true);
};
/**
* Persists metadata changes to backend.
*/
const handleSave = async (): Promise<void> => {
if (!document) {
return;
}
setIsSaving(true);
setError(null);
try {
const updated = await updateDocumentMetadata(document.id, {
original_filename: originalFilename,
logical_path: logicalPath,
tags,
});
setOriginalFilename(updated.original_filename);
setLogicalPath(updated.logical_path);
setTags(updated.tags);
setIsMetadataDirty(false);
onDocumentUpdated(updated);
const payload = await getDocumentDetails(document.id);
setDocumentDetail(payload);
} catch (caughtError) {
setError(caughtError instanceof Error ? caughtError.message : 'Failed to save metadata');
} finally {
setIsSaving(false);
}
};
/**
* Re-runs extraction and routing logic for the currently selected document.
*/
const handleReprocess = async (): Promise<void> => {
if (!document) {
return;
}
setIsReprocessing(true);
setError(null);
try {
const updated = await reprocessDocument(document.id);
onDocumentUpdated(updated);
const payload = await getDocumentDetails(document.id);
setDocumentDetail(payload);
} catch (caughtError) {
setError(caughtError instanceof Error ? caughtError.message : 'Failed to reprocess document');
} finally {
setIsReprocessing(false);
}
};
/**
* Moves the selected document to trash state.
*/
const handleTrash = async (): Promise<void> => {
if (!document) {
return;
}
setIsTrashing(true);
setError(null);
try {
const updated = await trashDocument(document.id);
onDocumentUpdated(updated);
} catch (caughtError) {
setError(caughtError instanceof Error ? caughtError.message : 'Failed to trash document');
} finally {
setIsTrashing(false);
}
};
/**
* Restores the selected document from trash.
*/
const handleRestore = async (): Promise<void> => {
if (!document) {
return;
}
setIsRestoring(true);
setError(null);
try {
const updated = await restoreDocument(document.id);
onDocumentUpdated(updated);
} catch (caughtError) {
setError(caughtError instanceof Error ? caughtError.message : 'Failed to restore document');
} finally {
setIsRestoring(false);
}
};
/**
* Permanently deletes the selected document and associated files.
*/
const handleDelete = async (): Promise<void> => {
if (!document) {
return;
}
const confirmed = await requestConfirmation(
'Delete Document Permanently',
'This removes the document record and stored file from the system.',
'Delete Permanently',
);
if (!confirmed) {
return;
}
setIsDeleting(true);
setError(null);
try {
await deleteDocument(document.id);
onDocumentDeleted(document.id);
} catch (caughtError) {
setError(caughtError instanceof Error ? caughtError.message : 'Failed to delete document');
} finally {
setIsDeleting(false);
}
};
if (!document) {
return (
<aside className="document-viewer empty">
<h2>Document Details</h2>
<p>Select a document to preview and manage metadata.</p>
</aside>
);
}
const isTrashed = document.status === 'trashed' || isTrashView;
const metadataDisabled = isTrashed || isSaving || isTrashing || isRestoring || isDeleting;
return (
<aside className="document-viewer">
<h2>{document.original_filename}</h2>
<p className="small">Status: {document.status}</p>
<div className="viewer-preview">
{isImageDocument ? (
<img src={previewUrl(document.id)} alt={document.original_filename} />
) : (
<iframe src={previewUrl(document.id)} title={document.original_filename} />
)}
</div>
<label>
File Name
<input
value={originalFilename}
onChange={(event) => {
setOriginalFilename(event.target.value);
setIsMetadataDirty(true);
}}
disabled={metadataDisabled}
/>
</label>
<label>
Destination Path
<PathInput
value={logicalPath}
onChange={(value) => {
setLogicalPath(value);
setIsMetadataDirty(true);
}}
suggestions={existingPaths}
disabled={metadataDisabled}
/>
</label>
<label>
Tags
<TagInput
value={tags}
onChange={(value) => {
setTags(value);
setIsMetadataDirty(true);
}}
suggestions={existingTags}
disabled={metadataDisabled}
/>
</label>
{(document.suggested_path || document.suggested_tags.length > 0 || routingSummary || routingError) && (
<section className="routing-suggestions-panel">
<div className="routing-suggestions-header">
<h3>Routing Suggestions</h3>
{canApplyAllSuggestions && (
<button
type="button"
className="secondary-action"
onClick={applyAllSuggestions}
disabled={metadataDisabled}
>
Apply All
</button>
)}
</div>
{routingError && <p className="small error">{routingError}</p>}
{routingSummary && <p className="small">{routingSummary}</p>}
{hasPathSuggestion && document.suggested_path && (
<div className="routing-suggestion-group">
<p className="small">Suggested Path</p>
<button
type="button"
className="routing-pill"
onClick={applySuggestedPath}
disabled={metadataDisabled}
>
{document.suggested_path}
</button>
</div>
)}
{hasTagSuggestions && document.suggested_tags.length > 0 && (
<div className="routing-suggestion-group">
<p className="small">Suggested Tags</p>
<div className="routing-pill-row">
{document.suggested_tags.map((tag) => (
<button
key={tag}
type="button"
className="routing-pill"
onClick={() => applySuggestedTag(tag)}
disabled={metadataDisabled}
>
{tag}
</button>
))}
</div>
</div>
)}
</section>
)}
<section className="extracted-text-panel">
<h3>Extracted Text</h3>
{transcriptionError && <p className="small error">{transcriptionError}</p>}
{isLoadingDetails ? (
<p className="small">Loading extracted text...</p>
) : documentDetail?.extracted_text.trim() ? (
<pre>{documentDetail.extracted_text}</pre>
) : (
<p className="small">No extracted text available for this document yet.</p>
)}
</section>
{error && <p className="error">{error}</p>}
<div className="viewer-actions">
{!isTrashed && (
<button type="button" onClick={handleSave} disabled={metadataDisabled}>
{isSaving ? 'Saving...' : 'Save Metadata'}
</button>
)}
{!isTrashed && (
<button
type="button"
className="secondary-action"
onClick={handleReprocess}
disabled={metadataDisabled || isReprocessing}
title="Re-runs OCR/extraction, summary generation, routing suggestion, and indexing for this document."
>
{isReprocessing ? 'Reprocessing...' : 'Reprocess Document'}
</button>
)}
{!isTrashed && (
<button
type="button"
className="warning-action"
onClick={handleTrash}
disabled={metadataDisabled || isTrashing}
>
{isTrashing ? 'Trashing...' : 'Move To Trash'}
</button>
)}
{isTrashed && (
<button
type="button"
className="secondary-action"
onClick={handleRestore}
disabled={isRestoring || isDeleting}
>
{isRestoring ? 'Restoring...' : 'Restore Document'}
</button>
)}
<button
type="button"
className="secondary-action"
onClick={() => window.open(contentMarkdownUrl(document.id), '_blank', 'noopener,noreferrer')}
disabled={isDeleting}
title="Downloads recognized/extracted content as markdown for this document."
>
Download Recognized MD
</button>
{isTrashed && (
<button
type="button"
className="danger-action"
onClick={handleDelete}
disabled={isDeleting || isRestoring}
>
{isDeleting ? 'Deleting...' : 'Delete Permanently'}
</button>
)}
</div>
<p className="viewer-inline-help">
Reprocess runs OCR/extraction, updates summary, refreshes routing suggestions, and re-indexes search.
</p>
</aside>
);
}

View File

@@ -0,0 +1,73 @@
/**
* Path editor with suggestion dropdown for scalable logical-path selection.
*/
import { useMemo, useState } from 'react';
/**
* Defines properties for the reusable path input component.
*/
interface PathInputProps {
value: string;
suggestions: string[];
placeholder?: string;
disabled?: boolean;
onChange: (nextValue: string) => void;
}
/**
* Renders a text input with filtered clickable path suggestions.
*/
export default function PathInput({
value,
suggestions,
placeholder = 'Destination path',
disabled = false,
onChange,
}: PathInputProps): JSX.Element {
const [isFocused, setIsFocused] = useState<boolean>(false);
/**
* Calculates filtered suggestions based on current input value.
*/
const filteredSuggestions = useMemo(() => {
const normalized = value.trim().toLowerCase();
if (!normalized) {
return suggestions.slice(0, 20);
}
return suggestions.filter((candidate) => candidate.toLowerCase().includes(normalized)).slice(0, 20);
}, [suggestions, value]);
return (
<div className={`path-input ${disabled ? 'disabled' : ''}`}>
<input
value={value}
onChange={(event) => onChange(event.target.value)}
onFocus={() => setIsFocused(true)}
onBlur={() => {
window.setTimeout(() => setIsFocused(false), 120);
}}
placeholder={placeholder}
disabled={disabled}
/>
{isFocused && filteredSuggestions.length > 0 && (
<div className="path-suggestions" role="listbox" aria-label="Path suggestions">
{filteredSuggestions.map((suggestion) => (
<button
key={suggestion}
type="button"
className="path-suggestion-item"
onMouseDown={(event) => {
event.preventDefault();
onChange(suggestion);
setIsFocused(false);
}}
disabled={disabled}
>
{suggestion}
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,203 @@
/**
* Processing log timeline panel for upload, OCR, summarization, routing, and indexing events.
*/
import { useEffect, useMemo, useRef, useState } from 'react';
import type { ProcessingLogEntry } from '../types';
interface ProcessingLogPanelProps {
entries: ProcessingLogEntry[];
isLoading: boolean;
isClearing: boolean;
selectedDocumentId: string | null;
isProcessingActive: boolean;
typingAnimationEnabled: boolean;
onClear: () => void;
}
/**
* Renders processing events in a terminal-style stream with optional typed headers.
*/
export default function ProcessingLogPanel({
entries,
isLoading,
isClearing,
selectedDocumentId,
isProcessingActive,
typingAnimationEnabled,
onClear,
}: ProcessingLogPanelProps): JSX.Element {
const timeline = useMemo(() => [...entries].reverse(), [entries]);
const [typedEntryIds, setTypedEntryIds] = useState<Set<number>>(() => new Set());
const [typingEntryId, setTypingEntryId] = useState<number | null>(null);
const [typingHeader, setTypingHeader] = useState<string>('');
const [expandedIds, setExpandedIds] = useState<Set<number>>(() => new Set());
const timerRef = useRef<number | null>(null);
const formatTimestamp = (value: string): string => {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return value;
}
return parsed.toLocaleString();
};
const payloadText = (payload: Record<string, unknown>): string => {
try {
return JSON.stringify(payload, null, 2);
} catch (error) {
return String(error);
}
};
const renderHeader = (entry: ProcessingLogEntry): string => {
const headerParts = [formatTimestamp(entry.created_at), entry.level.toUpperCase(), entry.stage];
if (entry.document_filename) {
headerParts.push(entry.document_filename);
}
if (selectedDocumentId !== null && selectedDocumentId === entry.document_id) {
headerParts.push('selected-document');
}
return `[${headerParts.join(' | ')}] ${entry.event}`;
};
useEffect(() => {
const knownIds = new Set(typedEntryIds);
if (typingEntryId !== null) {
knownIds.add(typingEntryId);
}
const nextUntyped = timeline.find((entry) => !knownIds.has(entry.id));
if (!nextUntyped) {
return;
}
if (!typingAnimationEnabled) {
setTypedEntryIds((current) => {
const next = new Set(current);
next.add(nextUntyped.id);
return next;
});
return;
}
if (typingEntryId !== null) {
return;
}
const fullHeader = renderHeader(nextUntyped);
setTypingEntryId(nextUntyped.id);
setTypingHeader('');
let cursor = 0;
timerRef.current = window.setInterval(() => {
cursor += 1;
setTypingHeader(fullHeader.slice(0, cursor));
if (cursor >= fullHeader.length) {
if (timerRef.current !== null) {
window.clearInterval(timerRef.current);
timerRef.current = null;
}
setTypedEntryIds((current) => {
const next = new Set(current);
next.add(nextUntyped.id);
return next;
});
setTypingEntryId(null);
}
}, 10);
}, [timeline, typedEntryIds, typingAnimationEnabled, typingEntryId]);
useEffect(() => {
return () => {
if (timerRef.current !== null) {
window.clearInterval(timerRef.current);
}
};
}, []);
return (
<section className="processing-log-panel">
<div className="panel-header">
<h2>Processing Log</h2>
<div className="processing-log-header-actions">
<p>{isLoading ? 'Refreshing...' : `${entries.length} recent event(s)`}</p>
<button type="button" className="secondary-action" onClick={onClear} disabled={isLoading || isClearing}>
{isClearing ? 'Clearing...' : 'Clear All Logs'}
</button>
</div>
</div>
<div className="processing-log-terminal-wrap">
<div className="processing-log-terminal">
{timeline.length === 0 && <p className="terminal-empty">No processing events yet.</p>}
{timeline.map((entry, index) => {
const groupKey = `${entry.document_id ?? 'unbound'}:${entry.stage}`;
const previousGroupKey = index > 0 ? `${timeline[index - 1].document_id ?? 'unbound'}:${timeline[index - 1].stage}` : null;
const showSeparator = index > 0 && groupKey !== previousGroupKey;
const isTyping = entry.id === typingEntryId;
const isTyped = typedEntryIds.has(entry.id) || (!typingAnimationEnabled && !isTyping);
const isExpanded = expandedIds.has(entry.id);
const providerModel = [entry.provider_id, entry.model_name].filter(Boolean).join(' / ');
const hasDetails =
providerModel.length > 0 ||
Object.keys(entry.payload_json).length > 0 ||
Boolean(entry.prompt_text) ||
Boolean(entry.response_text);
return (
<div key={entry.id}>
{showSeparator && <div className="terminal-separator">------</div>}
<div className="terminal-row-header">
<span>{isTyping ? typingHeader : renderHeader(entry)}</span>
{hasDetails && isTyped && (
<button
type="button"
className="terminal-unfold-button"
onClick={() =>
setExpandedIds((current) => {
const next = new Set(current);
if (next.has(entry.id)) {
next.delete(entry.id);
} else {
next.add(entry.id);
}
return next;
})
}
>
{isExpanded ? 'Fold' : 'Unfold'}
</button>
)}
</div>
{isExpanded && isTyped && (
<div className="terminal-row-details">
{providerModel && <div>provider/model: {providerModel}</div>}
{Object.keys(entry.payload_json).length > 0 && (
<>
<div>payload:</div>
<pre>{payloadText(entry.payload_json)}</pre>
</>
)}
{entry.prompt_text && (
<>
<div>prompt:</div>
<pre>{entry.prompt_text}</pre>
</>
)}
{entry.response_text && (
<>
<div>response:</div>
<pre>{entry.response_text}</pre>
</>
)}
</div>
)}
</div>
);
})}
{isProcessingActive && typingEntryId === null && (
<div className="terminal-idle-prompt">
<span className="terminal-caret">&gt;</span>
<span className="terminal-caret-blink">_</span>
</div>
)}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,107 @@
/**
* Compact search and filter controls for document discovery.
*/
interface SearchFiltersBarProps {
searchText: string;
onSearchTextChange: (value: string) => void;
onSearchSubmit: () => void;
onReset: () => void;
hasActiveSearch: boolean;
knownTags: string[];
knownPaths: string[];
knownTypes: string[];
tagFilter: string;
onTagFilterChange: (value: string) => void;
typeFilter: string;
onTypeFilterChange: (value: string) => void;
pathFilter: string;
onPathFilterChange: (value: string) => void;
processedFrom: string;
onProcessedFromChange: (value: string) => void;
processedTo: string;
onProcessedToChange: (value: string) => void;
isLoading: boolean;
}
/**
* Renders dense search, filter, and quick reset controls.
*/
export default function SearchFiltersBar({
searchText,
onSearchTextChange,
onSearchSubmit,
onReset,
hasActiveSearch,
knownTags,
knownPaths,
knownTypes,
tagFilter,
onTagFilterChange,
typeFilter,
onTypeFilterChange,
pathFilter,
onPathFilterChange,
processedFrom,
onProcessedFromChange,
processedTo,
onProcessedToChange,
isLoading,
}: SearchFiltersBarProps): JSX.Element {
return (
<div className="search-filters-bar">
<input
value={searchText}
onChange={(event) => onSearchTextChange(event.target.value)}
placeholder="Search across name, text, path, tags"
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
onSearchSubmit();
}
}}
/>
<select value={tagFilter} onChange={(event) => onTagFilterChange(event.target.value)}>
<option value="">All Tags</option>
{knownTags.map((tag) => (
<option key={tag} value={tag}>
{tag}
</option>
))}
</select>
<select value={typeFilter} onChange={(event) => onTypeFilterChange(event.target.value)}>
<option value="">All Types</option>
{knownTypes.map((typeValue) => (
<option key={typeValue} value={typeValue}>
{typeValue}
</option>
))}
</select>
<select value={pathFilter} onChange={(event) => onPathFilterChange(event.target.value)}>
<option value="">All Paths</option>
{knownPaths.map((path) => (
<option key={path} value={path}>
{path}
</option>
))}
</select>
<input
type="date"
value={processedFrom}
onChange={(event) => onProcessedFromChange(event.target.value)}
title="Processed from"
/>
<input
type="date"
value={processedTo}
onChange={(event) => onProcessedToChange(event.target.value)}
title="Processed to"
/>
<button type="button" onClick={onSearchSubmit} disabled={isLoading}>
Search
</button>
<button type="button" className="secondary-action" onClick={onReset} disabled={!hasActiveSearch || isLoading}>
Reset
</button>
</div>
);
}

View File

@@ -0,0 +1,721 @@
/**
* Dedicated settings screen for providers, task model bindings, and catalog controls.
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import PathInput from './PathInput';
import TagInput from './TagInput';
import type {
AppSettings,
AppSettingsUpdate,
DisplaySettings,
HandwritingStyleClusteringSettings,
OcrTaskSettings,
PredefinedPathEntry,
PredefinedTagEntry,
ProviderSettings,
RoutingTaskSettings,
SummaryTaskSettings,
UploadDefaultsSettings,
} from '../types';
interface EditableProvider extends ProviderSettings {
row_id: string;
api_key: string;
clear_api_key: boolean;
}
interface SettingsScreenProps {
settings: AppSettings | null;
isSaving: boolean;
knownTags: string[];
knownPaths: string[];
onSave: (payload: AppSettingsUpdate) => Promise<void>;
onRegisterSaveAction?: (action: (() => Promise<void>) | null) => void;
}
function clampCardsPerPage(value: number): number {
return Math.max(1, Math.min(200, value));
}
function parseCardsPerPageInput(input: string, fallback: number): number {
const parsed = Number.parseInt(input, 10);
if (Number.isNaN(parsed)) {
return clampCardsPerPage(fallback);
}
return clampCardsPerPage(parsed);
}
/**
* Renders compact human-oriented settings controls.
*/
export default function SettingsScreen({
settings,
isSaving,
knownTags,
knownPaths,
onSave,
onRegisterSaveAction,
}: SettingsScreenProps): JSX.Element {
const [providers, setProviders] = useState<EditableProvider[]>([]);
const [ocrTask, setOcrTask] = useState<OcrTaskSettings | null>(null);
const [summaryTask, setSummaryTask] = useState<SummaryTaskSettings | null>(null);
const [routingTask, setRoutingTask] = useState<RoutingTaskSettings | null>(null);
const [handwritingStyle, setHandwritingStyle] = useState<HandwritingStyleClusteringSettings | null>(null);
const [predefinedPaths, setPredefinedPaths] = useState<PredefinedPathEntry[]>([]);
const [predefinedTags, setPredefinedTags] = useState<PredefinedTagEntry[]>([]);
const [newPredefinedPath, setNewPredefinedPath] = useState<string>('');
const [newPredefinedTag, setNewPredefinedTag] = useState<string>('');
const [uploadDefaults, setUploadDefaults] = useState<UploadDefaultsSettings | null>(null);
const [displaySettings, setDisplaySettings] = useState<DisplaySettings | null>(null);
const [cardsPerPageInput, setCardsPerPageInput] = useState<string>('12');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!settings) {
return;
}
setProviders(
settings.providers.map((provider) => ({
...provider,
row_id: `${provider.id}-${Math.random().toString(36).slice(2, 9)}`,
api_key: '',
clear_api_key: false,
})),
);
setOcrTask(settings.tasks.ocr_handwriting);
setSummaryTask(settings.tasks.summary_generation);
setRoutingTask(settings.tasks.routing_classification);
setHandwritingStyle(settings.handwriting_style_clustering);
setPredefinedPaths(settings.predefined_paths);
setPredefinedTags(settings.predefined_tags);
setUploadDefaults(settings.upload_defaults);
setDisplaySettings(settings.display);
setCardsPerPageInput(String(settings.display.cards_per_page));
setError(null);
}, [settings]);
const fallbackProviderId = useMemo(() => providers[0]?.id ?? '', [providers]);
const addProvider = (): void => {
const sequence = providers.length + 1;
setProviders((current) => [
...current,
{
row_id: `provider-row-${Date.now()}-${sequence}`,
id: `provider-${sequence}`,
label: `Provider ${sequence}`,
provider_type: 'openai_compatible',
base_url: 'http://localhost:11434/v1',
timeout_seconds: 45,
api_key_set: false,
api_key_masked: '',
api_key: '',
clear_api_key: false,
},
]);
};
const removeProvider = (rowId: string): void => {
const target = providers.find((provider) => provider.row_id === rowId);
if (!target || providers.length <= 1) {
return;
}
const remaining = providers.filter((provider) => provider.row_id !== rowId);
const fallback = remaining[0]?.id ?? '';
setProviders(remaining);
if (ocrTask?.provider_id === target.id && fallback) {
setOcrTask({ ...ocrTask, provider_id: fallback });
}
if (summaryTask?.provider_id === target.id && fallback) {
setSummaryTask({ ...summaryTask, provider_id: fallback });
}
if (routingTask?.provider_id === target.id && fallback) {
setRoutingTask({ ...routingTask, provider_id: fallback });
}
};
const addPredefinedPath = (): void => {
const value = newPredefinedPath.trim().replace(/^\/+|\/+$/g, '');
if (!value) {
return;
}
if (predefinedPaths.some((entry) => entry.value.toLowerCase() === value.toLowerCase())) {
setNewPredefinedPath('');
return;
}
setPredefinedPaths([...predefinedPaths, { value, global_shared: false }]);
setNewPredefinedPath('');
};
const addPredefinedTag = (): void => {
const value = newPredefinedTag.trim();
if (!value) {
return;
}
if (predefinedTags.some((entry) => entry.value.toLowerCase() === value.toLowerCase())) {
setNewPredefinedTag('');
return;
}
setPredefinedTags([...predefinedTags, { value, global_shared: false }]);
setNewPredefinedTag('');
};
const handleSave = useCallback(async (): Promise<void> => {
if (!ocrTask || !summaryTask || !routingTask || !handwritingStyle || !uploadDefaults || !displaySettings) {
setError('Settings are not fully loaded yet');
return;
}
if (providers.length === 0) {
setError('At least one provider is required');
return;
}
setError(null);
try {
const resolvedCardsPerPage = parseCardsPerPageInput(cardsPerPageInput, displaySettings.cards_per_page);
setDisplaySettings({ ...displaySettings, cards_per_page: resolvedCardsPerPage });
setCardsPerPageInput(String(resolvedCardsPerPage));
await onSave({
upload_defaults: {
logical_path: uploadDefaults.logical_path.trim(),
tags: uploadDefaults.tags,
},
display: {
cards_per_page: resolvedCardsPerPage,
log_typing_animation_enabled: displaySettings.log_typing_animation_enabled,
},
predefined_paths: predefinedPaths,
predefined_tags: predefinedTags,
handwriting_style_clustering: {
enabled: handwritingStyle.enabled,
embed_model: handwritingStyle.embed_model.trim(),
neighbor_limit: handwritingStyle.neighbor_limit,
match_min_similarity: handwritingStyle.match_min_similarity,
bootstrap_match_min_similarity: handwritingStyle.bootstrap_match_min_similarity,
bootstrap_sample_size: handwritingStyle.bootstrap_sample_size,
image_max_side: handwritingStyle.image_max_side,
},
providers: providers.map((provider) => ({
id: provider.id.trim(),
label: provider.label.trim(),
provider_type: provider.provider_type,
base_url: provider.base_url.trim(),
timeout_seconds: provider.timeout_seconds,
api_key: provider.api_key.trim() || undefined,
clear_api_key: provider.clear_api_key,
})),
tasks: {
ocr_handwriting: {
enabled: ocrTask.enabled,
provider_id: ocrTask.provider_id,
model: ocrTask.model.trim(),
prompt: ocrTask.prompt,
},
summary_generation: {
enabled: summaryTask.enabled,
provider_id: summaryTask.provider_id,
model: summaryTask.model.trim(),
prompt: summaryTask.prompt,
max_input_tokens: summaryTask.max_input_tokens,
},
routing_classification: {
enabled: routingTask.enabled,
provider_id: routingTask.provider_id,
model: routingTask.model.trim(),
prompt: routingTask.prompt,
neighbor_count: routingTask.neighbor_count,
neighbor_min_similarity: routingTask.neighbor_min_similarity,
auto_apply_confidence_threshold: routingTask.auto_apply_confidence_threshold,
auto_apply_neighbor_similarity_threshold: routingTask.auto_apply_neighbor_similarity_threshold,
neighbor_path_override_enabled: routingTask.neighbor_path_override_enabled,
neighbor_path_override_min_similarity: routingTask.neighbor_path_override_min_similarity,
neighbor_path_override_min_gap: routingTask.neighbor_path_override_min_gap,
neighbor_path_override_max_confidence: routingTask.neighbor_path_override_max_confidence,
},
},
});
} catch (caughtError) {
setError(caughtError instanceof Error ? caughtError.message : 'Failed to save settings');
}
}, [
cardsPerPageInput,
displaySettings,
handwritingStyle,
ocrTask,
onSave,
predefinedPaths,
predefinedTags,
providers,
routingTask,
summaryTask,
uploadDefaults,
]);
useEffect(() => {
if (!onRegisterSaveAction) {
return;
}
if (!settings || !ocrTask || !summaryTask || !routingTask || !handwritingStyle || !uploadDefaults || !displaySettings) {
onRegisterSaveAction(null);
return;
}
onRegisterSaveAction(() => handleSave());
return () => onRegisterSaveAction(null);
}, [displaySettings, handleSave, handwritingStyle, ocrTask, onRegisterSaveAction, routingTask, settings, summaryTask, uploadDefaults]);
if (!settings || !ocrTask || !summaryTask || !routingTask || !handwritingStyle || !uploadDefaults || !displaySettings) {
return (
<section className="settings-layout">
<div className="settings-card">
<h2>Settings</h2>
<p>Loading settings...</p>
</div>
</section>
);
}
return (
<section className="settings-layout">
{error && <p className="error-banner">{error}</p>}
<div className="settings-card settings-section">
<div className="settings-section-header">
<h3>Workspace</h3>
<p className="small">Defaults and display behavior for document operations.</p>
</div>
<div className="settings-field-grid">
<label className="settings-field settings-field-wide">
Default Path
<PathInput
value={uploadDefaults.logical_path}
onChange={(nextPath) => setUploadDefaults({ ...uploadDefaults, logical_path: nextPath })}
suggestions={knownPaths}
/>
</label>
<label className="settings-field settings-field-wide">
Default Tags
<TagInput
value={uploadDefaults.tags}
onChange={(nextTags) => setUploadDefaults({ ...uploadDefaults, tags: nextTags })}
suggestions={knownTags}
/>
</label>
<label className="settings-field">
Cards Per Page
<input
type="number"
min={1}
max={200}
value={cardsPerPageInput}
onChange={(event) => setCardsPerPageInput(event.target.value)}
/>
</label>
<label className="inline-checkbox settings-checkbox-field">
<input
type="checkbox"
checked={displaySettings.log_typing_animation_enabled}
onChange={(event) =>
setDisplaySettings({ ...displaySettings, log_typing_animation_enabled: event.target.checked })
}
/>
Processing log typing animation enabled
</label>
</div>
</div>
<div className="settings-card settings-section">
<div className="settings-section-header">
<h3>Catalog Presets</h3>
<p className="small">Pre-register allowed paths and tags. Global-shared is irreversible.</p>
</div>
<div className="settings-catalog-grid">
<section className="settings-catalog-card">
<h4>Predefined Paths</h4>
<div className="settings-catalog-add-row">
<input
placeholder="Add path"
value={newPredefinedPath}
onChange={(event) => setNewPredefinedPath(event.target.value)}
/>
<button type="button" className="secondary-action" onClick={addPredefinedPath}>
Add
</button>
</div>
<div className="settings-catalog-list">
{predefinedPaths.map((entry) => (
<div key={entry.value} className="settings-catalog-row">
<span>{entry.value}</span>
<label className="inline-checkbox">
<input
type="checkbox"
checked={entry.global_shared}
disabled={entry.global_shared}
onChange={(event) =>
setPredefinedPaths((current) =>
current.map((item) =>
item.value === entry.value
? { ...item, global_shared: item.global_shared || event.target.checked }
: item,
),
)
}
/>
Global
</label>
<button
type="button"
className="secondary-action"
onClick={() => setPredefinedPaths((current) => current.filter((item) => item.value !== entry.value))}
>
Remove
</button>
</div>
))}
</div>
</section>
<section className="settings-catalog-card">
<h4>Predefined Tags</h4>
<div className="settings-catalog-add-row">
<input
placeholder="Add tag"
value={newPredefinedTag}
onChange={(event) => setNewPredefinedTag(event.target.value)}
/>
<button type="button" className="secondary-action" onClick={addPredefinedTag}>
Add
</button>
</div>
<div className="settings-catalog-list">
{predefinedTags.map((entry) => (
<div key={entry.value} className="settings-catalog-row">
<span>{entry.value}</span>
<label className="inline-checkbox">
<input
type="checkbox"
checked={entry.global_shared}
disabled={entry.global_shared}
onChange={(event) =>
setPredefinedTags((current) =>
current.map((item) =>
item.value === entry.value
? { ...item, global_shared: item.global_shared || event.target.checked }
: item,
),
)
}
/>
Global
</label>
<button
type="button"
className="secondary-action"
onClick={() => setPredefinedTags((current) => current.filter((item) => item.value !== entry.value))}
>
Remove
</button>
</div>
))}
</div>
</section>
</div>
</div>
<div className="settings-card settings-section">
<div className="settings-section-header">
<h3>Providers</h3>
<p className="small">Configure OpenAI-compatible model endpoints.</p>
</div>
<div className="provider-list">
{providers.map((provider, index) => (
<div key={provider.row_id} className="provider-grid">
<div className="provider-header">
<h4>{provider.label || `Provider ${index + 1}`}</h4>
<button
type="button"
className="danger-action"
onClick={() => removeProvider(provider.row_id)}
disabled={providers.length <= 1 || isSaving}
>
Remove
</button>
</div>
<div className="settings-field-grid">
<label className="settings-field">
Provider ID
<input
value={provider.id}
onChange={(event) =>
setProviders((current) =>
current.map((item) => (item.row_id === provider.row_id ? { ...item, id: event.target.value } : item)),
)
}
/>
</label>
<label className="settings-field">
Label
<input
value={provider.label}
onChange={(event) =>
setProviders((current) =>
current.map((item) =>
item.row_id === provider.row_id ? { ...item, label: event.target.value } : item,
),
)
}
/>
</label>
<label className="settings-field">
Timeout Seconds
<input
type="number"
value={provider.timeout_seconds}
onChange={(event) => {
const nextTimeout = Number.parseInt(event.target.value, 10);
if (Number.isNaN(nextTimeout)) {
return;
}
setProviders((current) =>
current.map((item) =>
item.row_id === provider.row_id ? { ...item, timeout_seconds: nextTimeout } : item,
),
);
}}
/>
</label>
<label className="settings-field settings-field-wide">
Base URL
<input
value={provider.base_url}
onChange={(event) =>
setProviders((current) =>
current.map((item) =>
item.row_id === provider.row_id ? { ...item, base_url: event.target.value } : item,
),
)
}
/>
</label>
<label className="settings-field settings-field-wide">
API Key
<input
type="password"
placeholder={provider.api_key_set ? `Stored: ${provider.api_key_masked}` : 'Optional API key'}
value={provider.api_key}
onChange={(event) =>
setProviders((current) =>
current.map((item) =>
item.row_id === provider.row_id ? { ...item, api_key: event.target.value } : item,
),
)
}
/>
</label>
<label className="inline-checkbox settings-checkbox-field">
<input
type="checkbox"
checked={provider.clear_api_key}
onChange={(event) =>
setProviders((current) =>
current.map((item) =>
item.row_id === provider.row_id ? { ...item, clear_api_key: event.target.checked } : item,
),
)
}
/>
Clear Stored API Key
</label>
</div>
</div>
))}
</div>
<div className="settings-section-actions">
<button type="button" className="secondary-action" onClick={addProvider} disabled={isSaving}>
Add Provider
</button>
</div>
</div>
<div className="settings-card settings-section">
<div className="settings-section-header">
<h3>Task Runtime</h3>
<p className="small">Bind providers and tune OCR, summary, routing, and handwriting style behavior.</p>
</div>
<div className="task-settings-block">
<div className="task-block-header">
<h4>OCR Handwriting</h4>
<label className="inline-checkbox settings-toggle">
<input type="checkbox" checked={ocrTask.enabled} onChange={(event) => setOcrTask({ ...ocrTask, enabled: event.target.checked })} />
Enabled
</label>
</div>
<div className="settings-field-grid">
<label className="settings-field">
Provider
<select value={ocrTask.provider_id} onChange={(event) => setOcrTask({ ...ocrTask, provider_id: event.target.value || fallbackProviderId })}>
{providers.map((provider) => (
<option key={provider.row_id} value={provider.id}>
{provider.label} ({provider.id})
</option>
))}
</select>
</label>
<label className="settings-field">
Model
<input value={ocrTask.model} onChange={(event) => setOcrTask({ ...ocrTask, model: event.target.value })} />
</label>
<label className="settings-field settings-field-wide">
OCR Prompt
<textarea value={ocrTask.prompt} onChange={(event) => setOcrTask({ ...ocrTask, prompt: event.target.value })} />
</label>
</div>
</div>
<div className="task-settings-block">
<div className="task-block-header">
<h4>Summary Generation</h4>
<label className="inline-checkbox settings-toggle">
<input type="checkbox" checked={summaryTask.enabled} onChange={(event) => setSummaryTask({ ...summaryTask, enabled: event.target.checked })} />
Enabled
</label>
</div>
<div className="settings-field-grid">
<label className="settings-field">
Provider
<select value={summaryTask.provider_id} onChange={(event) => setSummaryTask({ ...summaryTask, provider_id: event.target.value || fallbackProviderId })}>
{providers.map((provider) => (
<option key={provider.row_id} value={provider.id}>
{provider.label} ({provider.id})
</option>
))}
</select>
</label>
<label className="settings-field">
Model
<input value={summaryTask.model} onChange={(event) => setSummaryTask({ ...summaryTask, model: event.target.value })} />
</label>
<label className="settings-field">
Max Input Tokens
<input
type="number"
min={512}
max={64000}
value={summaryTask.max_input_tokens}
onChange={(event) => {
const nextValue = Number.parseInt(event.target.value, 10);
if (!Number.isNaN(nextValue)) {
setSummaryTask({ ...summaryTask, max_input_tokens: nextValue });
}
}}
/>
</label>
<label className="settings-field settings-field-wide">
Summary Prompt
<textarea value={summaryTask.prompt} onChange={(event) => setSummaryTask({ ...summaryTask, prompt: event.target.value })} />
</label>
</div>
</div>
<div className="task-settings-block">
<div className="task-block-header">
<h4>Routing Classification</h4>
<label className="inline-checkbox settings-toggle">
<input type="checkbox" checked={routingTask.enabled} onChange={(event) => setRoutingTask({ ...routingTask, enabled: event.target.checked })} />
Enabled
</label>
</div>
<div className="settings-field-grid">
<label className="settings-field">
Provider
<select value={routingTask.provider_id} onChange={(event) => setRoutingTask({ ...routingTask, provider_id: event.target.value || fallbackProviderId })}>
{providers.map((provider) => (
<option key={provider.row_id} value={provider.id}>
{provider.label} ({provider.id})
</option>
))}
</select>
</label>
<label className="settings-field">
Model
<input value={routingTask.model} onChange={(event) => setRoutingTask({ ...routingTask, model: event.target.value })} />
</label>
<label className="settings-field">
Neighbor Count
<input type="number" value={routingTask.neighbor_count} onChange={(event) => setRoutingTask({ ...routingTask, neighbor_count: Number.parseInt(event.target.value, 10) || routingTask.neighbor_count })} />
</label>
<label className="settings-field">
Min Neighbor Similarity
<input type="number" step="0.01" min="0" max="1" value={routingTask.neighbor_min_similarity} onChange={(event) => setRoutingTask({ ...routingTask, neighbor_min_similarity: Number.parseFloat(event.target.value) || routingTask.neighbor_min_similarity })} />
</label>
<label className="settings-field">
Auto Apply Confidence
<input type="number" step="0.01" min="0" max="1" value={routingTask.auto_apply_confidence_threshold} onChange={(event) => setRoutingTask({ ...routingTask, auto_apply_confidence_threshold: Number.parseFloat(event.target.value) || routingTask.auto_apply_confidence_threshold })} />
</label>
<label className="settings-field">
Auto Apply Neighbor Similarity
<input type="number" step="0.01" min="0" max="1" value={routingTask.auto_apply_neighbor_similarity_threshold} onChange={(event) => setRoutingTask({ ...routingTask, auto_apply_neighbor_similarity_threshold: Number.parseFloat(event.target.value) || routingTask.auto_apply_neighbor_similarity_threshold })} />
</label>
<label className="inline-checkbox settings-checkbox-field">
<input type="checkbox" checked={routingTask.neighbor_path_override_enabled} onChange={(event) => setRoutingTask({ ...routingTask, neighbor_path_override_enabled: event.target.checked })} />
Dominant neighbor path override enabled
</label>
<label className="settings-field">
Override Min Similarity
<input type="number" step="0.01" min="0" max="1" value={routingTask.neighbor_path_override_min_similarity} onChange={(event) => setRoutingTask({ ...routingTask, neighbor_path_override_min_similarity: Number.parseFloat(event.target.value) || routingTask.neighbor_path_override_min_similarity })} />
</label>
<label className="settings-field">
Override Min Gap
<input type="number" step="0.01" min="0" max="1" value={routingTask.neighbor_path_override_min_gap} onChange={(event) => setRoutingTask({ ...routingTask, neighbor_path_override_min_gap: Number.parseFloat(event.target.value) || routingTask.neighbor_path_override_min_gap })} />
</label>
<label className="settings-field">
Override Max LLM Confidence
<input type="number" step="0.01" min="0" max="1" value={routingTask.neighbor_path_override_max_confidence} onChange={(event) => setRoutingTask({ ...routingTask, neighbor_path_override_max_confidence: Number.parseFloat(event.target.value) || routingTask.neighbor_path_override_max_confidence })} />
</label>
<label className="settings-field settings-field-wide">
Routing Prompt
<textarea value={routingTask.prompt} onChange={(event) => setRoutingTask({ ...routingTask, prompt: event.target.value })} />
</label>
</div>
</div>
<div className="task-settings-block">
<div className="task-block-header">
<h4>Handwriting Style Clustering</h4>
<label className="inline-checkbox settings-toggle">
<input type="checkbox" checked={handwritingStyle.enabled} onChange={(event) => setHandwritingStyle({ ...handwritingStyle, enabled: event.target.checked })} />
Enabled
</label>
</div>
<div className="settings-field-grid">
<label className="settings-field settings-field-wide">
Typesense Embedding Model Slug
<input value={handwritingStyle.embed_model} onChange={(event) => setHandwritingStyle({ ...handwritingStyle, embed_model: event.target.value })} />
</label>
<label className="settings-field">
Neighbor Limit
<input type="number" min={1} max={32} value={handwritingStyle.neighbor_limit} onChange={(event) => setHandwritingStyle({ ...handwritingStyle, neighbor_limit: Number.parseInt(event.target.value, 10) || handwritingStyle.neighbor_limit })} />
</label>
<label className="settings-field">
Match Min Similarity
<input type="number" step="0.01" min="0" max="1" value={handwritingStyle.match_min_similarity} onChange={(event) => setHandwritingStyle({ ...handwritingStyle, match_min_similarity: Number.parseFloat(event.target.value) || handwritingStyle.match_min_similarity })} />
</label>
<label className="settings-field">
Bootstrap Match Min Similarity
<input type="number" step="0.01" min="0" max="1" value={handwritingStyle.bootstrap_match_min_similarity} onChange={(event) => setHandwritingStyle({ ...handwritingStyle, bootstrap_match_min_similarity: Number.parseFloat(event.target.value) || handwritingStyle.bootstrap_match_min_similarity })} />
</label>
<label className="settings-field">
Bootstrap Sample Size
<input type="number" min={1} max={30} value={handwritingStyle.bootstrap_sample_size} onChange={(event) => setHandwritingStyle({ ...handwritingStyle, bootstrap_sample_size: Number.parseInt(event.target.value, 10) || handwritingStyle.bootstrap_sample_size })} />
</label>
<label className="settings-field">
Max Image Side (px)
<input type="number" min={256} max={4096} value={handwritingStyle.image_max_side} onChange={(event) => setHandwritingStyle({ ...handwritingStyle, image_max_side: Number.parseInt(event.target.value, 10) || handwritingStyle.image_max_side })} />
</label>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,123 @@
/**
* Tag editor with suggestion dropdown and keyboard-friendly chip interactions.
*/
import { useMemo, useState } from 'react';
import type { KeyboardEvent } from 'react';
/**
* Defines properties for the reusable tag input component.
*/
interface TagInputProps {
value: string[];
suggestions: string[];
placeholder?: string;
disabled?: boolean;
onChange: (tags: string[]) => void;
}
/**
* Renders a chip-based tag editor with inline suggestions.
*/
export default function TagInput({
value,
suggestions,
placeholder = 'Add tag',
disabled = false,
onChange,
}: TagInputProps): JSX.Element {
const [draft, setDraft] = useState<string>('');
/**
* Calculates filtered suggestions based on current draft and selected tags.
*/
const filteredSuggestions = useMemo(() => {
const normalized = draft.trim().toLowerCase();
return suggestions
.filter((candidate) => !value.includes(candidate))
.filter((candidate) => (normalized ? candidate.toLowerCase().includes(normalized) : false))
.slice(0, 8);
}, [draft, suggestions, value]);
/**
* Adds a tag to the selected value list when valid.
*/
const addTag = (tag: string): void => {
const normalized = tag.trim();
if (!normalized) {
return;
}
if (value.includes(normalized)) {
setDraft('');
return;
}
onChange([...value, normalized]);
setDraft('');
};
/**
* Removes one tag by value.
*/
const removeTag = (tag: string): void => {
onChange(value.filter((candidate) => candidate !== tag));
};
/**
* Handles keyboard interactions for quick tag editing.
*/
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'Enter' || event.key === ',') {
event.preventDefault();
addTag(draft);
return;
}
if (event.key === 'Backspace' && draft.length === 0 && value.length > 0) {
event.preventDefault();
onChange(value.slice(0, -1));
}
};
return (
<div className={`tag-input ${disabled ? 'disabled' : ''}`}>
<div className="tag-chip-row">
{value.map((tag) => (
<button
key={tag}
type="button"
className="tag-chip"
onClick={() => removeTag(tag)}
disabled={disabled}
title="Remove tag"
>
{tag}
</button>
))}
</div>
<input
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => addTag(draft)}
placeholder={placeholder}
disabled={disabled}
/>
{filteredSuggestions.length > 0 && (
<div className="tag-suggestions" role="listbox" aria-label="Tag suggestions">
{filteredSuggestions.map((suggestion) => (
<button
key={suggestion}
type="button"
className="tag-suggestion-item"
onMouseDown={(event) => {
event.preventDefault();
addTag(suggestion);
}}
disabled={disabled}
>
{suggestion}
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,127 @@
/**
* Upload surface that supports global drag-and-drop and file/folder picking.
*/
import { useEffect, useMemo, useRef, useState } from 'react';
import type { ChangeEvent } from 'react';
/**
* Defines callback signature for queued file uploads.
*/
interface UploadSurfaceProps {
onUploadRequested: (files: File[]) => Promise<void>;
isUploading: boolean;
variant?: 'panel' | 'inline';
}
/**
* Renders upload actions and drag overlay for dropping documents anywhere.
*/
export default function UploadSurface({
onUploadRequested,
isUploading,
variant = 'panel',
}: UploadSurfaceProps): JSX.Element {
const [isDragging, setIsDragging] = useState<boolean>(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const folderInputRef = useRef<HTMLInputElement | null>(null);
/**
* Installs folder-selection attributes unsupported by default React typings.
*/
useEffect(() => {
if (folderInputRef.current) {
folderInputRef.current.setAttribute('webkitdirectory', '');
folderInputRef.current.setAttribute('directory', '');
folderInputRef.current.setAttribute('multiple', '');
}
}, []);
/**
* Registers global drag listeners so users can drop files anywhere in the app.
*/
useEffect(() => {
const onDragOver = (event: DragEvent): void => {
event.preventDefault();
setIsDragging(true);
};
const onDragLeave = (event: DragEvent): void => {
event.preventDefault();
if (!event.relatedTarget) {
setIsDragging(false);
}
};
const onDrop = async (event: DragEvent): Promise<void> => {
event.preventDefault();
setIsDragging(false);
const droppedFiles = Array.from(event.dataTransfer?.files ?? []);
if (droppedFiles.length > 0) {
await onUploadRequested(droppedFiles);
}
};
window.addEventListener('dragover', onDragOver);
window.addEventListener('dragleave', onDragLeave);
window.addEventListener('drop', onDrop);
return () => {
window.removeEventListener('dragover', onDragOver);
window.removeEventListener('dragleave', onDragLeave);
window.removeEventListener('drop', onDrop);
};
}, [onUploadRequested]);
/**
* Provides helper text based on current upload activity.
*/
const statusLabel = useMemo(() => {
if (isUploading) {
return 'Uploading and scheduling processing...';
}
return 'Drop files anywhere or use file/folder upload.';
}, [isUploading]);
/**
* Handles manual file and folder input selection events.
*/
const handlePickedFiles = async (event: ChangeEvent<HTMLInputElement>): Promise<void> => {
const pickedFiles = Array.from(event.target.files ?? []);
if (pickedFiles.length > 0) {
await onUploadRequested(pickedFiles);
}
event.target.value = '';
};
if (variant === 'inline') {
return (
<>
{isDragging && <div className="drop-overlay">Drop to upload</div>}
<div className="upload-actions upload-actions-inline">
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={isUploading}>
Upload Files
</button>
<button type="button" onClick={() => folderInputRef.current?.click()} disabled={isUploading}>
Upload Folder
</button>
</div>
<input ref={fileInputRef} type="file" multiple hidden onChange={handlePickedFiles} />
<input ref={folderInputRef} type="file" hidden onChange={handlePickedFiles} />
</>
);
}
return (
<section className="upload-surface">
{isDragging && <div className="drop-overlay">Drop to upload</div>}
<div className="upload-actions">
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={isUploading}>
Upload Files
</button>
<button type="button" onClick={() => folderInputRef.current?.click()} disabled={isUploading}>
Upload Folder
</button>
</div>
<p>{statusLabel}</p>
<input ref={fileInputRef} type="file" multiple hidden onChange={handlePickedFiles} />
<input ref={folderInputRef} type="file" hidden onChange={handlePickedFiles} />
</section>
);
}