feat(i18n): add multi-language support (ja/ko/vi/ru/zh-TW) with system auto-detection
- Extend AppLanguage to 7 locales: zh-CN, zh-TW, en, ja, ko, vi, ru - Auto-detect system language via navigator.languages; fallback to English - Add BCP47 locale mapping for date formatting and sorting - Create 50 translation JSON files (5 languages × 10 namespaces, 1137 keys each) - Update language selector with native language names - Extend locale key parity test to cover all 7 languages (70 assertions) - Add VNC/RDP desktop, serial terminal, AI agent, port forwarding, and SFTP features - Add credential encryption, host metrics, session backup crypto, and zmodem bridge - Update Rust backend: desktop services, host metrics, SSH/SFTP enhancements
This commit is contained in:
1 parent
fea6d8db04
commit
b0d0e3a5f3
301 files changed
+28002
-2705
No files matched your search
@@ -1,16 +1,19 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Check, CheckCircle2, ChevronDown, KeyRound, Loader2, Plus, Save, Trash2, Wifi } from 'lucide-react'
|
||||
import type { AiModelConfig, AiProviderConfig } from '../../../shared/types/ai.types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Check, CheckCircle2, ChevronDown, Loader2, Plus, Save, Trash2, Wifi } from 'lucide-react'
|
||||
import type { AiBillingMode, AiProviderConfig } from '../../../shared/types/ai.types'
|
||||
import {
|
||||
AI_PROVIDER_PRESETS,
|
||||
AI_PROVIDER_FAMILY_ORDER,
|
||||
createCustomProvider,
|
||||
createProviderFromPreset,
|
||||
presetsByFamily,
|
||||
resolveProviderFamily,
|
||||
} from '../../ai/providerPresets'
|
||||
import { useAiProviderStore } from '../../stores/aiProviderStore'
|
||||
|
||||
type TestState = 'idle' | 'testing' | 'success' | 'error'
|
||||
|
||||
function selectedModel(provider: AiProviderConfig | null): AiModelConfig | null {
|
||||
function selectedModel(provider: AiProviderConfig | null) {
|
||||
if (!provider) return null
|
||||
return provider.models.find((model) => model.id === provider.defaultModelId) || null
|
||||
}
|
||||
@@ -35,6 +38,7 @@ function validDraft(provider: AiProviderConfig | null, apiKey: string): boolean
|
||||
}
|
||||
|
||||
export default function AiProviderSettings() {
|
||||
const { t } = useTranslation(['settings', 'common'])
|
||||
const store = useAiProviderStore()
|
||||
const [selectedId, setSelectedId] = useState('')
|
||||
const [presetId, setPresetId] = useState('deepseek')
|
||||
@@ -62,6 +66,7 @@ export default function AiProviderSettings() {
|
||||
setDraft(structuredClone(provider))
|
||||
setApiKey('')
|
||||
setManualModel(provider.models.length === 0)
|
||||
setAdvancedOpen(resolveProviderFamily(provider) === 'custom' || provider.presetId === 'vllm')
|
||||
setTestState('idle')
|
||||
setVerifiedSnapshot(null)
|
||||
setMessage('')
|
||||
@@ -73,16 +78,18 @@ export default function AiProviderSettings() {
|
||||
const currentModel = selectedModel(draft)
|
||||
const isStored = !!draft && store.providers.some((provider) => provider.id === draft.id)
|
||||
const canTest = validDraft(draft, apiKey) && testState !== 'testing'
|
||||
const family = draft ? resolveProviderFamily(draft) : null
|
||||
const needsEndpoint = family === 'custom' || family === 'local' || draft?.presetId === 'vllm'
|
||||
|
||||
const chooseDraft = (provider: AiProviderConfig) => {
|
||||
setSelectedId(provider.id)
|
||||
setDraft(provider)
|
||||
setApiKey('')
|
||||
setManualModel(provider.models.length === 0)
|
||||
setAdvancedOpen(false)
|
||||
setAdvancedOpen(resolveProviderFamily(provider) === 'custom' || provider.presetId === 'vllm')
|
||||
setTestState('idle')
|
||||
setVerifiedSnapshot(null)
|
||||
setMessage('请填写配置并测试连接;测试通过前不会保存任何内容')
|
||||
setMessage('')
|
||||
setTestAnswer('')
|
||||
}
|
||||
|
||||
@@ -92,7 +99,7 @@ export default function AiProviderSettings() {
|
||||
const updateDraft = (changes: Partial<AiProviderConfig>) => {
|
||||
if (!draft) return
|
||||
setDraft({ ...draft, ...changes })
|
||||
if (testState !== 'idle') setMessage('连接字段已变更,请重新测试')
|
||||
if (testState !== 'idle') setMessage(t('ai.retest'))
|
||||
}
|
||||
|
||||
const chooseModel = (modelId: string) => {
|
||||
@@ -105,19 +112,28 @@ export default function AiProviderSettings() {
|
||||
updateDraft({ defaultModelId: modelId })
|
||||
}
|
||||
|
||||
const defaultManualBilling = (): AiBillingMode => {
|
||||
if (!draft) return 'payg'
|
||||
const resolved = resolveProviderFamily(draft)
|
||||
if (resolved === 'local') return 'local'
|
||||
if (resolved === 'plan') return 'plan'
|
||||
return 'payg'
|
||||
}
|
||||
|
||||
const updateManualModel = (modelId: string) => {
|
||||
if (!draft) return
|
||||
const active = selectedModel(draft)
|
||||
if (active) {
|
||||
const models = draft.models.map((model) =>
|
||||
model.id === active.id ? { ...model, model: modelId, label: modelId || '自定义模型' } : model
|
||||
)
|
||||
updateDraft({ models })
|
||||
updateDraft({
|
||||
models: draft.models.map((model) =>
|
||||
model.id === active.id ? { ...model, model: modelId, label: modelId || t('ai.customModel') } : model
|
||||
),
|
||||
})
|
||||
return
|
||||
}
|
||||
const id = `manual-${Date.now().toString(36)}`
|
||||
updateDraft({
|
||||
models: [{ id, model: modelId, label: modelId || '自定义模型', capability: 'chat', billing: 'payg' }],
|
||||
models: [{ id, model: modelId, label: modelId || t('ai.customModel'), capability: 'chat', billing: defaultManualBilling() }],
|
||||
defaultModelId: id,
|
||||
})
|
||||
}
|
||||
@@ -126,7 +142,7 @@ export default function AiProviderSettings() {
|
||||
if (!draft || !currentModel || !canTest) return
|
||||
setTestState('testing')
|
||||
setVerifiedSnapshot(null)
|
||||
setMessage('正在发送最小请求验证 Endpoint、模型和凭据...')
|
||||
setMessage(t('ai.testingMessage'))
|
||||
setTestAnswer('')
|
||||
const snapshot = currentSnapshot
|
||||
try {
|
||||
@@ -135,11 +151,11 @@ export default function AiProviderSettings() {
|
||||
model: currentModel,
|
||||
apiKey: apiKey.trim() || undefined,
|
||||
})
|
||||
if (!result.success) throw new Error(result.error || '连接测试失败')
|
||||
if (!result.success) throw new Error(result.error || t('ai.testFailed'))
|
||||
setTestState('success')
|
||||
setVerifiedSnapshot(snapshot)
|
||||
setTestAnswer(result.answer?.trim() || '')
|
||||
setMessage(`测试成功 · HTTP ${result.status ?? 200} · ${result.model || currentModel.model} · ${result.latencyMs ?? '-'} ms`)
|
||||
setMessage(t('ai.available', { latency: result.latencyMs ?? '-' }))
|
||||
} catch (error) {
|
||||
setTestState('error')
|
||||
setMessage(error instanceof Error ? error.message : String(error))
|
||||
@@ -153,7 +169,7 @@ export default function AiProviderSettings() {
|
||||
await store.saveProvider(draft, apiKey)
|
||||
if (!store.activeProviderId || !isStored) await store.setActiveProvider(draft.id)
|
||||
setApiKey('')
|
||||
setMessage('配置已安全保存,并可用于终端助手')
|
||||
setMessage(t('ai.saved'))
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : String(error))
|
||||
setTestState('error')
|
||||
@@ -174,82 +190,96 @@ export default function AiProviderSettings() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<section className="border-dark-700/50 bg-dark-800/45 rounded-xl border p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
aria-label="供应商预设"
|
||||
value={presetId}
|
||||
onChange={(event) => setPresetId(event.target.value)}
|
||||
className="ui-input-sm min-w-52 flex-1"
|
||||
>
|
||||
{AI_PROVIDER_PRESETS.map((preset) => (
|
||||
<option key={preset.id} value={preset.id}>
|
||||
{preset.name} · {preset.description}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" onClick={addPreset} className="ui-btn-primary flex items-center gap-1.5">
|
||||
<Plus className="h-3.5 w-3.5" /> 添加
|
||||
</button>
|
||||
<button type="button" onClick={addCustom} className="ui-btn-secondary">
|
||||
自定义
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-txt-muted">
|
||||
新配置只在当前表单中暂存。真实请求测试成功后才可保存;API Key 仅由 Rust 加密存储。
|
||||
</p>
|
||||
</section>
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
aria-label={t('ai.presets')}
|
||||
value={presetId}
|
||||
onChange={(event) => setPresetId(event.target.value)}
|
||||
className="ui-input-sm min-w-52 flex-1"
|
||||
>
|
||||
{AI_PROVIDER_FAMILY_ORDER.map((familyId) => (
|
||||
<optgroup key={familyId} label={t(`ai.family.${familyId}`)}>
|
||||
{presetsByFamily(familyId).map((preset) => (
|
||||
<option key={preset.id} value={preset.id}>
|
||||
{preset.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (presetId === 'custom' ? addCustom() : addPreset())}
|
||||
className="ui-btn-primary flex items-center gap-1.5"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> {t('ai.add')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-dark-700/50 bg-dark-800/45 grid min-h-[440px] grid-cols-[180px_minmax(0,1fr)] overflow-hidden rounded-xl border">
|
||||
<aside className="border-dark-700/50 border-r p-2">
|
||||
<div className="px-2 pb-2 text-[11px] font-medium text-txt-faint">已配置</div>
|
||||
<div className="grid min-h-[400px] grid-cols-[168px_minmax(0,1fr)] gap-6">
|
||||
<aside className="space-y-0.5">
|
||||
<div className="px-2 pb-2 text-[11px] font-medium text-txt-faint">{t('ai.configured')}</div>
|
||||
{store.providers.length === 0 ? (
|
||||
<p className="px-2 py-4 text-xs leading-relaxed text-txt-faint">尚无配置,从上方选择供应商添加。</p>
|
||||
<p className="px-2 py-6 text-xs leading-relaxed text-txt-faint">{t('ai.configuredHint')}</p>
|
||||
) : (
|
||||
store.providers.map((provider) => (
|
||||
<button
|
||||
key={provider.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(provider.id)}
|
||||
className={`mb-1 w-full rounded-lg px-3 py-2 text-left ${selectedId === provider.id ? 'bg-accent-blue/15 text-accent-blue' : 'text-content-secondary hover:bg-dark-700/60'}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{store.activeProviderId === provider.id && <Check className="h-3 w-3 flex-shrink-0" />}
|
||||
<span className="truncate text-sm font-medium">{provider.name}</span>
|
||||
</div>
|
||||
<div className={`mt-0.5 truncate text-[10px] ${provider.hasApiKey || provider.authStyle === 'none' ? 'text-emerald-400' : 'text-amber-400'}`}>
|
||||
{provider.models.find((model) => model.id === provider.defaultModelId)?.label || '未选择模型'}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
store.providers.map((provider) => {
|
||||
const model = provider.models.find((item) => item.id === provider.defaultModelId)
|
||||
return (
|
||||
<button
|
||||
key={provider.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(provider.id)}
|
||||
className={`w-full rounded-lg px-2.5 py-2 text-left transition-colors ${
|
||||
selectedId === provider.id
|
||||
? 'bg-accent-blue/12 text-accent-blue'
|
||||
: 'text-content-secondary hover:bg-dark-700/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{store.activeProviderId === provider.id && <Check className="h-3 w-3 flex-shrink-0" />}
|
||||
<span className="truncate text-sm font-medium">{provider.name}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 truncate pl-4 text-[10px] text-txt-faint">
|
||||
{model?.label || t('ai.noModel')}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<main className="min-w-0 p-5">
|
||||
<main className="min-w-0">
|
||||
{!draft ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-txt-faint">选择已有配置,或添加新供应商</div>
|
||||
<div className="flex h-full items-center justify-center text-sm text-txt-faint">{t('ai.emptyHint')}</div>
|
||||
) : (
|
||||
<div className="mx-auto max-w-2xl space-y-4">
|
||||
<div className="max-w-xl space-y-3.5">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="供应商">
|
||||
<Field label={t('ai.name')}>
|
||||
<input value={draft.name} onChange={(event) => updateDraft({ name: event.target.value })} className="ui-input-sm" />
|
||||
</Field>
|
||||
<Field label="模型">
|
||||
<Field label={t('ai.model')}>
|
||||
{manualModel || draft.models.length === 0 ? (
|
||||
<input
|
||||
aria-label="模型"
|
||||
aria-label={t('ai.model')}
|
||||
value={currentModel?.model || ''}
|
||||
onChange={(event) => updateManualModel(event.target.value)}
|
||||
placeholder="例如 deepseek-chat"
|
||||
placeholder="deepseek-v4-pro"
|
||||
className="ui-input-sm font-mono"
|
||||
/>
|
||||
) : (
|
||||
<select value={draft.defaultModelId} onChange={(event) => chooseModel(event.target.value)} className="ui-input-sm">
|
||||
<select
|
||||
aria-label={t('ai.model')}
|
||||
value={draft.defaultModelId}
|
||||
onChange={(event) => chooseModel(event.target.value)}
|
||||
className="ui-input-sm"
|
||||
>
|
||||
{draft.models.map((model) => (
|
||||
<option key={model.id} value={model.id}>{model.label || model.model}</option>
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.label || model.model}
|
||||
</option>
|
||||
))}
|
||||
<option value="__manual">手动输入模型...</option>
|
||||
<option value="__manual">{t('ai.otherModels')}</option>
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
@@ -257,49 +287,47 @@ export default function AiProviderSettings() {
|
||||
|
||||
{manualModel && draft.models.length > 0 && (
|
||||
<button type="button" className="text-xs text-accent-blue hover:underline" onClick={() => setManualModel(false)}>
|
||||
返回预设模型列表
|
||||
{t('ai.backToList')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Field label="Endpoint">
|
||||
<Field label={draft.authStyle === 'none' ? t('ai.apiKeyOptional') : 'API Key'}>
|
||||
<input
|
||||
value={draft.baseUrl}
|
||||
onChange={(event) => updateDraft({ baseUrl: event.target.value })}
|
||||
placeholder="https://api.example.com/v1"
|
||||
className="ui-input-sm font-mono"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={apiKey}
|
||||
disabled={draft.authStyle === 'none'}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
placeholder={draft.hasApiKey ? t('ai.savedKeepBlank') : draft.authStyle === 'none' ? t('ai.notRequired') : 'sk-...'}
|
||||
className="ui-input-sm"
|
||||
/>
|
||||
</Field>
|
||||
{currentModel?.baseUrl && (
|
||||
<p className="-mt-2 text-[10px] text-txt-faint">当前模型使用专用 Endpoint:{currentModel.baseUrl}</p>
|
||||
)}
|
||||
|
||||
<Field label={draft.authStyle === 'none' ? 'API Key(无需填写)' : 'API Key'}>
|
||||
<div className="relative">
|
||||
<KeyRound className="absolute left-2.5 top-2 h-3.5 w-3.5 text-txt-faint" />
|
||||
{(needsEndpoint || advancedOpen) && (
|
||||
<Field label="Endpoint">
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={apiKey}
|
||||
disabled={draft.authStyle === 'none'}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
placeholder={draft.hasApiKey ? '已保存;留空使用现有密钥' : draft.authStyle === 'none' ? '本地服务无需认证' : 'sk-...'}
|
||||
className="ui-input-sm !pl-8"
|
||||
aria-label="Endpoint"
|
||||
value={currentModel?.baseUrl || draft.baseUrl}
|
||||
onChange={(event) => updateDraft({ baseUrl: event.target.value })}
|
||||
placeholder="https://api.example.com/v1"
|
||||
className="ui-input-sm font-mono"
|
||||
disabled={!!currentModel?.baseUrl && !needsEndpoint}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvancedOpen((open) => !open)}
|
||||
className="flex items-center gap-1 text-xs text-txt-muted hover:text-content-secondary"
|
||||
className="flex items-center gap-1 text-xs text-txt-faint hover:text-txt-muted"
|
||||
>
|
||||
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${advancedOpen ? 'rotate-180' : ''}`} />
|
||||
高级设置
|
||||
{t('ai.advanced')}
|
||||
</button>
|
||||
|
||||
{advancedOpen && (
|
||||
<div className="border-dark-700/40 bg-dark-900/30 grid grid-cols-2 gap-3 rounded-lg border p-3">
|
||||
<Field label="接口协议">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label={t('ai.protocol')}>
|
||||
<select
|
||||
value={draft.protocol}
|
||||
onChange={(event) => updateDraft({ protocol: event.target.value as AiProviderConfig['protocol'] })}
|
||||
@@ -307,34 +335,33 @@ export default function AiProviderSettings() {
|
||||
>
|
||||
<option value="openai-chat">OpenAI Chat</option>
|
||||
<option value="openai-responses">OpenAI Responses</option>
|
||||
<option value="anthropic">Anthropic Messages</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="认证方式">
|
||||
<Field label={t('ai.auth')}>
|
||||
<select
|
||||
value={draft.authStyle}
|
||||
onChange={(event) => updateDraft({ authStyle: event.target.value as AiProviderConfig['authStyle'] })}
|
||||
className="ui-input-sm"
|
||||
>
|
||||
<option value="bearer">Bearer Token</option>
|
||||
<option value="bearer">Bearer</option>
|
||||
<option value="x-api-key">x-api-key</option>
|
||||
<option value="both">Bearer + x-api-key</option>
|
||||
<option value="none">无需认证</option>
|
||||
<option value="none">{t('ai.authNone')}</option>
|
||||
</select>
|
||||
</Field>
|
||||
<label className="col-span-2 flex items-start gap-2 text-xs text-txt-muted">
|
||||
<label className="col-span-2 flex items-center gap-2 text-xs text-txt-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.allowInsecureHttp}
|
||||
onChange={(event) => updateDraft({ allowInsecureHttp: event.target.checked })}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
允许 HTTP(只用于可信本机或内网模型服务)
|
||||
{t('ai.allowHttp')}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-dark-700/40 flex flex-wrap items-center gap-2 border-t pt-4">
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void testConnection()}
|
||||
@@ -342,7 +369,7 @@ export default function AiProviderSettings() {
|
||||
className="ui-btn-secondary flex items-center gap-1.5"
|
||||
>
|
||||
{testState === 'testing' ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wifi className="h-3.5 w-3.5" />}
|
||||
{testState === 'testing' ? '测试中...' : '测试连接'}
|
||||
{testState === 'testing' ? t('ai.testing') : t('ai.test')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -351,28 +378,27 @@ export default function AiProviderSettings() {
|
||||
className="ui-btn-primary flex items-center gap-1.5"
|
||||
>
|
||||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
|
||||
保存
|
||||
{t('common:action.save')}
|
||||
</button>
|
||||
{isStored && store.activeProviderId !== draft.id && (
|
||||
<button type="button" onClick={() => void store.setActiveProvider(draft.id)} className="ui-btn-secondary">
|
||||
设为当前
|
||||
{t('ai.setActive')}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<button type="button" title="删除配置" onClick={() => void remove()} className="ui-btn-secondary text-red-300">
|
||||
<button type="button" title={t('ai.deleteTitle')} onClick={() => void remove()} className="rounded p-1.5 text-txt-faint hover:bg-dark-700 hover:text-red-300">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`flex min-h-5 items-center gap-1.5 text-xs ${verified ? 'text-emerald-400' : testState === 'error' ? 'text-red-300' : 'text-txt-muted'}`} role={testState === 'error' ? 'alert' : 'status'}>
|
||||
<div
|
||||
className={`flex min-h-5 items-center gap-1.5 text-xs ${verified ? 'text-emerald-400' : testState === 'error' ? 'text-red-300' : 'text-txt-faint'}`}
|
||||
role={testState === 'error' ? 'alert' : 'status'}
|
||||
>
|
||||
{verified && <CheckCircle2 className="h-3.5 w-3.5" />}
|
||||
<span>{message || '修改连接字段后需要重新测试'}</span>
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
{verified && testAnswer && (
|
||||
<div className="border-emerald-500/20 bg-emerald-500/5 rounded-lg border px-3 py-2 text-xs text-content-secondary">
|
||||
<span className="text-txt-faint">模型回答:</span>{testAnswer}
|
||||
</div>
|
||||
)}
|
||||
{verified && testAnswer && <p className="text-xs text-content-secondary">{testAnswer}</p>}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
@@ -383,7 +409,7 @@ export default function AiProviderSettings() {
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-[11px] text-txt-muted">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
|
||||
Reference in new issue
Block a user