ds2api / webui /src /features /settings /ModelProfilesSection.jsx
luckfun233
feat(prompt): 新增 prompt_style 风格档位与自定义模型列表
fcdd4aa
Raw History Blame
13.1 kB
import { useCallback, useEffect, useState } from 'react'
import { ChevronDown, ChevronUp, Plus, Save, Trash2 } from 'lucide-react'
import { fetchModelProfiles, putModelProfiles } from './settingsApi'
const FALLBACK_BASE_MODELS = [
'deepseek-v4-flash',
'deepseek-v4-flash-search',
'deepseek-v4-flash-nothinking',
'deepseek-v4-flash-search-nothinking',
]
function emptyProfile() {
return { id: '', base: FALLBACK_BASE_MODELS[0], name: '', remark: '', prompt: '' }
}
function normalizeItems(data) {
const items = Array.isArray(data?.items) ? data.items : []
return items.map((item) => ({
id: String(item?.id || ''),
base: String(item?.base || FALLBACK_BASE_MODELS[0]),
name: String(item?.name || ''),
remark: String(item?.remark || ''),
prompt: String(item?.prompt || ''),
}))
}
export default function ModelProfilesSection({ t, apiFetch, onMessage }) {
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [items, setItems] = useState([])
const [baseModels, setBaseModels] = useState(FALLBACK_BASE_MODELS)
const [maxCount, setMaxCount] = useState(50)
const [maxPromptChars, setMaxPromptChars] = useState(20000)
const [expandedRows, setExpandedRows] = useState({})
const [error, setError] = useState('')
const loadProfiles = useCallback(async () => {
setLoading(true)
try {
const { res, data } = await fetchModelProfiles(apiFetch)
if (!res.ok) {
setError(data?.detail || t('settings.modelProfilesLoadFailed'))
return
}
setItems(normalizeItems(data))
if (Array.isArray(data?.base_models) && data.base_models.length > 0) {
setBaseModels(data.base_models)
}
if (Number(data?.max_count) > 0) {
setMaxCount(Number(data.max_count))
}
if (Number(data?.max_prompt_chars) > 0) {
setMaxPromptChars(Number(data.max_prompt_chars))
}
setError('')
} catch (_e) {
setError(t('settings.modelProfilesLoadFailed'))
} finally {
setLoading(false)
}
}, [apiFetch, t])
useEffect(() => {
loadProfiles()
}, [loadProfiles])
const updateItem = useCallback((index, patch) => {
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)))
}, [])
const removeItem = useCallback((index) => {
setItems((prev) => prev.filter((_item, i) => i !== index))
setExpandedRows({})
}, [])
const addItem = useCallback(() => {
setItems((prev) => (prev.length >= maxCount ? prev : [...prev, emptyProfile()]))
}, [maxCount])
const toggleRow = useCallback((index) => {
setExpandedRows((prev) => ({ ...prev, [index]: !prev[index] }))
}, [])
const validate = useCallback(() => {
const seen = new Set()
for (let i = 0; i < items.length; i += 1) {
const item = items[i]
const id = String(item.id || '').trim()
if (!id) {
return t('settings.modelProfilesIdRequired', { index: i + 1 })
}
if (!/^[A-Za-z0-9._-]{1,64}$/.test(id)) {
return t('settings.modelProfilesIdInvalid', { id })
}
const key = id.toLowerCase()
if (seen.has(key)) {
return t('settings.modelProfilesIdDuplicate', { id })
}
seen.add(key)
if (!String(item.base || '').trim()) {
return t('settings.modelProfilesBaseRequired', { id })
}
if (String(item.prompt || '').length > maxPromptChars) {
return t('settings.modelProfilesPromptTooLong', { id, count: maxPromptChars })
}
}
return ''
}, [items, maxPromptChars, t])
const save = useCallback(async () => {
const message = validate()
if (message) {
setError(message)
onMessage?.('error', message)
return
}
setSaving(true)
try {
const payload = {
items: items.map((item) => ({
id: String(item.id || '').trim(),
base: String(item.base || '').trim(),
name: String(item.name || '').trim(),
remark: String(item.remark || '').trim(),
prompt: String(item.prompt || ''),
})),
}
const { res, data } = await putModelProfiles(apiFetch, payload)
if (!res.ok) {
const detail = data?.detail || t('settings.modelProfilesSaveFailed')
setError(detail)
onMessage?.('error', detail)
return
}
setError('')
onMessage?.('success', t('settings.modelProfilesSaveSuccess', { count: items.length }))
await loadProfiles()
} catch (_e) {
setError(t('settings.modelProfilesSaveFailed'))
onMessage?.('error', t('settings.modelProfilesSaveFailed'))
} finally {
setSaving(false)
}
}, [apiFetch, items, loadProfiles, maxPromptChars, onMessage, t, validate])
return (
<div className="bg-card border border-border rounded-xl p-5 space-y-4">
<div className="space-y-1">
<div className="flex items-center justify-between gap-3">
<h3 className="font-semibold">{t('settings.modelProfilesTitle')}</h3>
<span className="text-xs text-muted-foreground">
{t('settings.modelProfilesCount', { count: items.length })}
</span>
</div>
<p className="text-xs text-muted-foreground">{t('settings.modelProfilesDesc')}</p>
</div>
{error && <p className="text-xs text-destructive">{error}</p>}
{loading && <p className="text-xs text-muted-foreground">{t('actions.loading')}</p>}
{!loading && items.length === 0 && (
<p className="text-xs text-muted-foreground">{t('settings.modelProfilesEmpty')}</p>
)}
<div className="space-y-3">
{items.map((item, index) => {
const expanded = Boolean(expandedRows[index])
return (
<div key={`${index}-${item.id}`} className="rounded-lg border border-border bg-background/60 p-4 space-y-3">
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<label className="text-xs space-y-1">
<span className="text-muted-foreground block">{t('settings.modelProfilesId')}</span>
<input
type="text"
value={item.id}
onChange={(e) => updateItem(index, { id: e.target.value })}
placeholder={t('settings.modelProfilesIdPlaceholder')}
className="w-full bg-background border border-border rounded-lg px-3 py-2 text-sm"
/>
</label>
<label className="text-xs space-y-1">
<span className="text-muted-foreground block">{t('settings.modelProfilesBase')}</span>
<select
value={item.base}
onChange={(e) => updateItem(index, { base: e.target.value })}
className="w-full bg-background border border-border rounded-lg px-3 py-2 text-sm"
>
{baseModels.map((model) => (
<option key={model} value={model}>{model}</option>
))}
</select>
</label>
<label className="text-xs space-y-1">
<span className="text-muted-foreground block">{t('settings.modelProfilesName')}</span>
<input
type="text"
value={item.name}
onChange={(e) => updateItem(index, { name: e.target.value })}
placeholder={t('settings.modelProfilesNamePlaceholder')}
className="w-full bg-background border border-border rounded-lg px-3 py-2 text-sm"
/>
</label>
<label className="text-xs space-y-1">
<span className="text-muted-foreground block">{t('settings.modelProfilesRemark')}</span>
<input
type="text"
value={item.remark}
onChange={(e) => updateItem(index, { remark: e.target.value })}
className="w-full bg-background border border-border rounded-lg px-3 py-2 text-sm"
/>
</label>
</div>
<div className="flex items-center justify-between gap-2">
<button
type="button"
onClick={() => toggleRow(index)}
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
>
{expanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
{expanded ? t('settings.skillsCollapse') : t('settings.modelProfilesPrompt')}
</button>
<button
type="button"
onClick={() => removeItem(index)}
className="text-xs text-destructive hover:opacity-80 flex items-center gap-1"
>
<Trash2 className="w-3.5 h-3.5" />
{t('settings.modelProfilesRemove')}
</button>
</div>
{expanded && (
<div className="space-y-1">
<textarea
rows={8}
value={item.prompt}
onChange={(e) => updateItem(index, { prompt: e.target.value })}
placeholder={t('settings.modelProfilesPromptPlaceholder')}
className="w-full bg-background border border-border rounded-lg px-3 py-2 resize-y min-h-32 text-sm leading-6"
/>
<p className="text-xs text-muted-foreground">
{t('settings.modelProfilesPromptHint')}
</p>
</div>
)}
</div>
)
})}
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={addItem}
disabled={items.length >= maxCount}
className="h-9 px-3 rounded-lg border border-border bg-background text-sm hover:bg-secondary/70 disabled:opacity-50 flex items-center gap-1"
>
<Plus className="w-4 h-4" />
{t('settings.modelProfilesAdd')}
</button>
<button
type="button"
onClick={save}
disabled={saving}
className="h-9 px-3 rounded-lg bg-primary text-primary-foreground text-sm hover:bg-primary/90 disabled:opacity-50 flex items-center gap-1"
>
<Save className="w-4 h-4" />
{saving ? t('settings.skillsSaving') : t('settings.modelProfilesSave')}
</button>
<span className="text-xs text-muted-foreground">{t('settings.modelProfilesHint')}</span>
</div>
</div>
)
}