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,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>
);
}