| import { useCallback, useEffect, useMemo, useState } from 'react' |
|
|
| import { |
| fetchSettings, |
| getExportData, |
| postImportData, |
| postPassword, |
| putSettings, |
| } from './settingsApi' |
|
|
| const MAX_AUTO_FETCH_FAILURES = 3 |
|
|
| const DEFAULT_FORM = { |
| admin: { jwt_expire_hours: 24 }, |
| runtime: { account_max_inflight: 2, account_max_queue: 10, global_max_inflight: 10, token_refresh_interval_hours: 6 }, |
| responses: { store_ttl_seconds: 900 }, |
| embeddings: { provider: '' }, |
| auto_delete: { mode: 'none' }, |
| current_input_file: { enabled: true, min_chars: 0 }, |
| thinking_injection: { enabled: true, prompt: '', default_prompt: '' }, |
| model_aliases_text: '{}', |
| } |
|
|
| function parseJSONMap(raw, fieldName, t) { |
| const text = String(raw || '').trim() |
| if (!text) { |
| return {} |
| } |
| let parsed |
| try { |
| parsed = JSON.parse(text) |
| } catch (_e) { |
| throw new Error(t('settings.invalidJsonField', { field: fieldName })) |
| } |
| if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { |
| throw new Error(t('settings.invalidJsonField', { field: fieldName })) |
| } |
| return parsed |
| } |
|
|
| function normalizeAutoDeleteMode(raw) { |
| const mode = String(raw?.mode || '').trim().toLowerCase() |
| if (mode === 'none' || mode === 'single' || mode === 'all') { |
| return mode |
| } |
| if (Boolean(raw?.sessions)) { |
| return 'all' |
| } |
| return 'none' |
| } |
|
|
| function fromServerForm(data) { |
| const currentInputFileEnabled = data.current_input_file?.enabled ?? true |
| return { |
| admin: { jwt_expire_hours: Number(data.admin?.jwt_expire_hours || 24) }, |
| runtime: { |
| account_max_inflight: Number(data.runtime?.account_max_inflight || 2), |
| account_max_queue: Number(data.runtime?.account_max_queue || 10), |
| global_max_inflight: Number(data.runtime?.global_max_inflight || 10), |
| token_refresh_interval_hours: Number(data.runtime?.token_refresh_interval_hours || 6), |
| }, |
| responses: { |
| store_ttl_seconds: Number(data.responses?.store_ttl_seconds || 900), |
| }, |
| embeddings: { |
| provider: data.embeddings?.provider || '', |
| }, |
| auto_delete: { |
| mode: normalizeAutoDeleteMode(data.auto_delete), |
| }, |
| current_input_file: { |
| enabled: currentInputFileEnabled, |
| min_chars: Number(data.current_input_file?.min_chars ?? 0), |
| }, |
| thinking_injection: { |
| enabled: data.thinking_injection?.enabled ?? true, |
| prompt: data.thinking_injection?.prompt || '', |
| default_prompt: data.thinking_injection?.default_prompt || '', |
| }, |
| model_aliases_text: JSON.stringify(data.model_aliases || {}, null, 2), |
| } |
| } |
|
|
| function toServerPayload(form) { |
| const currentInputFileEnabled = Boolean(form.current_input_file?.enabled) |
| return { |
| admin: { jwt_expire_hours: Number(form.admin.jwt_expire_hours) }, |
| runtime: { |
| account_max_inflight: Number(form.runtime.account_max_inflight), |
| account_max_queue: Number(form.runtime.account_max_queue), |
| global_max_inflight: Number(form.runtime.global_max_inflight), |
| token_refresh_interval_hours: Number(form.runtime.token_refresh_interval_hours), |
| }, |
| responses: { store_ttl_seconds: Number(form.responses.store_ttl_seconds) }, |
| embeddings: { provider: String(form.embeddings.provider || '').trim() }, |
| auto_delete: { mode: normalizeAutoDeleteMode(form.auto_delete) }, |
| current_input_file: { |
| enabled: currentInputFileEnabled, |
| min_chars: Number(form.current_input_file?.min_chars ?? 0), |
| }, |
| thinking_injection: { |
| enabled: Boolean(form.thinking_injection?.enabled ?? true), |
| prompt: String(form.thinking_injection?.prompt || '').trim(), |
| }, |
| } |
| } |
|
|
| export function useSettingsForm({ apiFetch, t, onMessage, onRefresh, onForceLogout, isVercel = false }) { |
| const [loading, setLoading] = useState(false) |
| const [saving, setSaving] = useState(false) |
| const [changingPassword, setChangingPassword] = useState(false) |
| const [importing, setImporting] = useState(false) |
| const [exportData, setExportData] = useState(null) |
| const [importMode, setImportMode] = useState('merge') |
| const [importText, setImportText] = useState('') |
| const [newPassword, setNewPassword] = useState('') |
| const [consecutiveFailures, setConsecutiveFailures] = useState(0) |
| const [autoFetchPaused, setAutoFetchPaused] = useState(false) |
| const [lastError, setLastError] = useState('') |
| const [settingsMeta, setSettingsMeta] = useState({ |
| default_password_warning: false, |
| env_backed: false, |
| needs_vercel_sync: false, |
| }) |
| const [form, setForm] = useState(DEFAULT_FORM) |
|
|
| const trackLoadFailure = useCallback(() => { |
| setConsecutiveFailures((prev) => { |
| const next = prev + 1 |
| if (isVercel && next >= MAX_AUTO_FETCH_FAILURES) { |
| setAutoFetchPaused(true) |
| } |
| return next |
| }) |
| }, [isVercel]) |
|
|
| const loadSettings = useCallback(async ({ manual = false } = {}) => { |
| if (isVercel && autoFetchPaused && !manual) { |
| return |
| } |
| setLoading(true) |
| try { |
| const { res, data } = await fetchSettings(apiFetch, t) |
| if (!res.ok) { |
| const detail = data.detail || t('settings.loadFailed') |
| setLastError(detail) |
| onMessage('error', detail) |
| trackLoadFailure() |
| return |
| } |
| setConsecutiveFailures(0) |
| setAutoFetchPaused(false) |
| setLastError('') |
| setSettingsMeta({ |
| default_password_warning: Boolean(data.admin?.default_password_warning), |
| env_backed: Boolean(data.env_backed), |
| needs_vercel_sync: Boolean(data.needs_vercel_sync), |
| }) |
| setForm(fromServerForm(data)) |
| } catch (e) { |
| const detail = e?.message || t('settings.loadFailed') |
| setLastError(detail) |
| onMessage('error', detail) |
| trackLoadFailure() |
| |
| console.error(e) |
| } finally { |
| setLoading(false) |
| } |
| }, [apiFetch, autoFetchPaused, isVercel, onMessage, t, trackLoadFailure]) |
|
|
| useEffect(() => { |
| loadSettings() |
| }, [loadSettings]) |
|
|
| const retryLoadSettings = useCallback(() => { |
| setAutoFetchPaused(false) |
| loadSettings({ manual: true }) |
| }, [loadSettings]) |
|
|
| const saveSettings = useCallback(async () => { |
| let modelAliases = {} |
| try { |
| modelAliases = parseJSONMap(form.model_aliases_text, 'model_aliases', t) |
| } catch (e) { |
| onMessage('error', e.message) |
| return |
| } |
|
|
| const payload = { |
| ...toServerPayload(form), |
| model_aliases: modelAliases, |
| } |
|
|
| setSaving(true) |
| try { |
| const { res, data } = await putSettings(apiFetch, payload) |
| if (!res.ok) { |
| onMessage('error', data.detail || t('settings.saveFailed')) |
| return |
| } |
| onMessage('success', t('settings.saveSuccess')) |
| if (typeof onRefresh === 'function') { |
| onRefresh() |
| } |
| await loadSettings() |
| } catch (e) { |
| onMessage('error', t('settings.saveFailed')) |
| |
| console.error(e) |
| } finally { |
| setSaving(false) |
| } |
| }, [apiFetch, form, loadSettings, onMessage, onRefresh, t]) |
|
|
| const updatePassword = useCallback(async () => { |
| if (String(newPassword || '').trim().length < 4) { |
| onMessage('error', t('settings.passwordTooShort')) |
| return |
| } |
| setChangingPassword(true) |
| try { |
| const { res, data } = await postPassword(apiFetch, newPassword.trim()) |
| if (!res.ok) { |
| onMessage('error', data.detail || t('settings.passwordUpdateFailed')) |
| return |
| } |
| onMessage('success', t('settings.passwordUpdated')) |
| setNewPassword('') |
| if (typeof onForceLogout === 'function') { |
| onForceLogout() |
| } |
| } catch (_e) { |
| onMessage('error', t('settings.passwordUpdateFailed')) |
| } finally { |
| setChangingPassword(false) |
| } |
| }, [apiFetch, newPassword, onForceLogout, onMessage, t]) |
|
|
| const loadExportData = useCallback(async () => { |
| try { |
| const { res, data } = await getExportData(apiFetch) |
| if (!res.ok) { |
| onMessage('error', data.detail || t('settings.exportFailed')) |
| return null |
| } |
| setExportData(data) |
| onMessage('success', t('settings.exportLoaded')) |
| return data |
| } catch (_e) { |
| onMessage('error', t('settings.exportFailed')) |
| return null |
| } |
| }, [apiFetch, onMessage, t]) |
|
|
| const downloadExportFile = useCallback(async () => { |
| let latest = exportData |
| if (!latest?.json) { |
| const loaded = await loadExportData() |
| if (!loaded) { |
| return |
| } |
| latest = loaded |
| } |
| const jsonText = String(latest?.json || '').trim() |
| if (!jsonText) { |
| onMessage('error', t('settings.exportFailed')) |
| return |
| } |
| const blob = new Blob([jsonText], { type: 'application/json;charset=utf-8' }) |
| const url = URL.createObjectURL(blob) |
| const now = new Date() |
| const pad = (n) => String(n).padStart(2, '0') |
| const filename = `ds2api-config-backup-${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}.json` |
| const link = document.createElement('a') |
| link.href = url |
| link.download = filename |
| document.body.appendChild(link) |
| link.click() |
| document.body.removeChild(link) |
| URL.revokeObjectURL(url) |
| onMessage('success', t('settings.exportDownloaded')) |
| }, [exportData, loadExportData, onMessage, t]) |
|
|
| const loadImportFile = useCallback((file) => { |
| if (!file) return |
| const reader = new FileReader() |
| reader.onload = () => { |
| const text = String(reader.result || '') |
| setImportText(text) |
| onMessage('success', t('settings.importFileLoaded')) |
| } |
| reader.onerror = () => { |
| onMessage('error', t('settings.importFileReadFailed')) |
| } |
| reader.readAsText(file, 'utf-8') |
| }, [onMessage, t]) |
|
|
| const doImport = useCallback(async () => { |
| if (!String(importText || '').trim()) { |
| onMessage('error', t('settings.importEmpty')) |
| return |
| } |
| let parsed |
| try { |
| parsed = JSON.parse(importText) |
| } catch (_e) { |
| onMessage('error', t('settings.importInvalidJson')) |
| return |
| } |
| setImporting(true) |
| try { |
| const { res, data } = await postImportData(apiFetch, importMode, parsed) |
| if (!res.ok) { |
| onMessage('error', data.detail || t('settings.importFailed')) |
| return |
| } |
| onMessage('success', t('settings.importSuccess', { mode: importMode })) |
| if (typeof onRefresh === 'function') { |
| onRefresh() |
| } |
| await loadSettings() |
| } catch (_e) { |
| onMessage('error', t('settings.importFailed')) |
| } finally { |
| setImporting(false) |
| } |
| }, [apiFetch, importMode, importText, loadSettings, onMessage, onRefresh, t]) |
|
|
| const syncHintVisible = useMemo( |
| () => settingsMeta.env_backed || settingsMeta.needs_vercel_sync, |
| [settingsMeta.env_backed, settingsMeta.needs_vercel_sync], |
| ) |
|
|
| return { |
| form, |
| setForm, |
| loading, |
| saving, |
| changingPassword, |
| importing, |
| exportData, |
| importMode, |
| setImportMode, |
| importText, |
| setImportText, |
| newPassword, |
| setNewPassword, |
| consecutiveFailures, |
| autoFetchPaused, |
| lastError, |
| settingsMeta, |
| syncHintVisible, |
| retryLoadSettings, |
| saveSettings, |
| updatePassword, |
| loadExportData, |
| downloadExportFile, |
| loadImportFile, |
| doImport, |
| } |
| } |
|
|