mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-17 22:55:10 +08:00
feat: Implement DeepSeek integration, refactor model adapters for streaming and tool calls, enhance admin and account management, and introduce new UI features for settings, API testing, and Vercel sync.
This commit is contained in:
290
webui/src/features/settings/useSettingsForm.js
Normal file
290
webui/src/features/settings/useSettingsForm.js
Normal file
@@ -0,0 +1,290 @@
|
||||
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 },
|
||||
toolcall: { mode: 'feature_match', early_emit_confidence: 'high' },
|
||||
responses: { store_ttl_seconds: 900 },
|
||||
embeddings: { provider: '' },
|
||||
claude_mapping_text: '{\n "fast": "deepseek-chat",\n "slow": "deepseek-reasoner"\n}',
|
||||
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 fromServerForm(data) {
|
||||
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),
|
||||
},
|
||||
toolcall: {
|
||||
mode: data.toolcall?.mode || 'feature_match',
|
||||
early_emit_confidence: data.toolcall?.early_emit_confidence || 'high',
|
||||
},
|
||||
responses: {
|
||||
store_ttl_seconds: Number(data.responses?.store_ttl_seconds || 900),
|
||||
},
|
||||
embeddings: {
|
||||
provider: data.embeddings?.provider || '',
|
||||
},
|
||||
claude_mapping_text: JSON.stringify(data.claude_mapping || {}, null, 2),
|
||||
model_aliases_text: JSON.stringify(data.model_aliases || {}, null, 2),
|
||||
}
|
||||
}
|
||||
|
||||
function toServerPayload(form) {
|
||||
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),
|
||||
},
|
||||
toolcall: {
|
||||
mode: String(form.toolcall.mode || '').trim(),
|
||||
early_emit_confidence: String(form.toolcall.early_emit_confidence || '').trim(),
|
||||
},
|
||||
responses: { store_ttl_seconds: Number(form.responses.store_ttl_seconds) },
|
||||
embeddings: { provider: String(form.embeddings.provider || '').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()
|
||||
// eslint-disable-next-line no-console
|
||||
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 claudeMapping = {}
|
||||
let modelAliases = {}
|
||||
try {
|
||||
claudeMapping = parseJSONMap(form.claude_mapping_text, 'claude_mapping', t)
|
||||
modelAliases = parseJSONMap(form.model_aliases_text, 'model_aliases', t)
|
||||
} catch (e) {
|
||||
onMessage('error', e.message)
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
...toServerPayload(form),
|
||||
claude_mapping: claudeMapping,
|
||||
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'))
|
||||
// eslint-disable-next-line no-console
|
||||
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
|
||||
}
|
||||
setExportData(data)
|
||||
onMessage('success', t('settings.exportLoaded'))
|
||||
} catch (_e) {
|
||||
onMessage('error', t('settings.exportFailed'))
|
||||
}
|
||||
}, [apiFetch, 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,
|
||||
doImport,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user