/** * 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; onRegisterSaveAction?: (action: (() => Promise) | 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([]); const [ocrTask, setOcrTask] = useState(null); const [summaryTask, setSummaryTask] = useState(null); const [routingTask, setRoutingTask] = useState(null); const [handwritingStyle, setHandwritingStyle] = useState(null); const [predefinedPaths, setPredefinedPaths] = useState([]); const [predefinedTags, setPredefinedTags] = useState([]); const [newPredefinedPath, setNewPredefinedPath] = useState(''); const [newPredefinedTag, setNewPredefinedTag] = useState(''); const [uploadDefaults, setUploadDefaults] = useState(null); const [displaySettings, setDisplaySettings] = useState(null); const [cardsPerPageInput, setCardsPerPageInput] = useState('12'); const [error, setError] = useState(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 => { 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 (

Settings

Loading settings...

); } return (
{error &&

{error}

}

Workspace

Defaults and display behavior for document operations.

Catalog Presets

Pre-register allowed paths and tags. Global-shared is irreversible.

Predefined Paths

setNewPredefinedPath(event.target.value)} />
{predefinedPaths.map((entry) => (
{entry.value}
))}

Predefined Tags

setNewPredefinedTag(event.target.value)} />
{predefinedTags.map((entry) => (
{entry.value}
))}

Providers

Configure OpenAI-compatible model endpoints.

{providers.map((provider, index) => (

{provider.label || `Provider ${index + 1}`}

))}

Task Runtime

Bind providers and tune OCR, summary, routing, and handwriting style behavior.

OCR Handwriting