Files
waooplus/src/components/ui/config-modals/ConfigEditModal.tsx

514 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useTranslations } from 'next-intl'
import {
ART_STYLES,
VIDEO_RATIOS,
} from '@/lib/constants'
import type {
CapabilitySelections,
CapabilityValue,
ModelCapabilities,
} from '@/lib/model-config-contract'
import { filterNormalVideoModelOptions } from '@/lib/model-capabilities/video-model-options'
import { RatioSelector, StyleSelector } from './config-modal-selectors'
import { ModelCapabilityDropdown } from './ModelCapabilityDropdown'
import { AppIcon } from '@/components/ui/icons'
interface ModelOption {
value: string
label: string
provider?: string
providerName?: string
capabilities?: ModelCapabilities
}
interface UserModels {
llm: ModelOption[]
image: ModelOption[]
video: ModelOption[]
audio: ModelOption[]
}
interface CapabilityFieldDefinition {
field: string
options: CapabilityValue[]
label: string
}
interface SettingsModalProps {
isOpen: boolean
onClose: () => void
availableModels?: Partial<UserModels>
modelsLoaded?: boolean
artStyle?: string
analysisModel?: string
characterModel?: string
locationModel?: string
imageModel?: string
editModel?: string
videoModel?: string
audioModel?: string
videoRatio?: string
capabilityOverrides?: CapabilitySelections
ttsRate?: string
onArtStyleChange?: (value: string) => void
onAnalysisModelChange?: (value: string) => void
onCharacterModelChange?: (value: string) => void
onLocationModelChange?: (value: string) => void
onImageModelChange?: (value: string) => void
onEditModelChange?: (value: string) => void
onVideoModelChange?: (value: string) => void
onAudioModelChange?: (value: string) => void
onVideoRatioChange?: (value: string) => void
onCapabilityOverridesChange?: (value: CapabilitySelections) => void
onTTSRateChange?: (value: string) => void
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
function isCapabilityValue(value: unknown): value is CapabilityValue {
return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
}
function toFieldLabel(field: string): string {
return field.replace(/([A-Z])/g, ' $1').replace(/^./, (char) => char.toUpperCase())
}
function parseBySample(input: string, sample: CapabilityValue): CapabilityValue {
if (typeof sample === 'number') return Number(input)
if (typeof sample === 'boolean') return input === 'true'
return input
}
function extractCapabilityFields(
capabilities: ModelCapabilities | undefined,
namespace: 'llm' | 'image' | 'video' | 'audio',
): CapabilityFieldDefinition[] {
const rawNamespace = capabilities?.[namespace]
if (!isRecord(rawNamespace)) return []
return Object.entries(rawNamespace)
.filter(([key, value]) => key.endsWith('Options') && Array.isArray(value) && value.every(isCapabilityValue) && value.length > 0)
.map(([key, value]) => {
const field = key.slice(0, -'Options'.length)
return {
field,
options: value as CapabilityValue[],
label: toFieldLabel(field),
}
})
}
function readCapabilitySelectionForModel(
overrides: CapabilitySelections | undefined,
modelKey: string | undefined,
): Record<string, CapabilityValue> {
if (!modelKey || !overrides) return {}
const raw = overrides[modelKey]
if (!isRecord(raw)) return {}
const normalized: Record<string, CapabilityValue> = {}
for (const [field, value] of Object.entries(raw)) {
if (isCapabilityValue(value)) {
normalized[field] = value
}
}
return normalized
}
export function SettingsModal({
isOpen,
onClose,
availableModels,
modelsLoaded = false,
artStyle = 'american-comic',
analysisModel,
characterModel,
locationModel,
imageModel,
editModel,
videoModel,
audioModel,
videoRatio = '9:16',
capabilityOverrides,
ttsRate,
onArtStyleChange,
onAnalysisModelChange,
onCharacterModelChange,
onLocationModelChange,
onImageModelChange,
onEditModelChange,
onVideoModelChange,
onAudioModelChange,
onVideoRatioChange,
onCapabilityOverridesChange,
onTTSRateChange,
}: SettingsModalProps) {
const t = useTranslations('configModal')
const [saveStatus, setSaveStatus] = useState<'idle' | 'saved'>('idle')
const userModels = useMemo<UserModels>(() => ({
llm: Array.isArray(availableModels?.llm) ? availableModels.llm : [],
image: Array.isArray(availableModels?.image) ? availableModels.image : [],
video: Array.isArray(availableModels?.video) ? availableModels.video : [],
audio: Array.isArray(availableModels?.audio) ? availableModels.audio : [],
}), [availableModels])
const normalVideoModels = useMemo<ModelOption[]>(
() => filterNormalVideoModelOptions(userModels.video),
[userModels.video],
)
const selectedVideoModelOption = useMemo(
() => normalVideoModels.find((model) => model.value === videoModel) || null,
[normalVideoModels, videoModel],
)
const selectedAnalysisModelOption = useMemo(
() => userModels.llm.find((model) => model.value === analysisModel) || null,
[userModels.llm, analysisModel],
)
const selectedAudioModelOption = useMemo(
() => userModels.audio.find((model) => model.value === audioModel) || null,
[userModels.audio, audioModel],
)
const videoCapabilityFields = useMemo(
() => extractCapabilityFields(selectedVideoModelOption?.capabilities, 'video'),
[selectedVideoModelOption],
)
const analysisCapabilityFields = useMemo(
() => extractCapabilityFields(selectedAnalysisModelOption?.capabilities, 'llm'),
[selectedAnalysisModelOption],
)
const audioCapabilityFields = useMemo(
() => extractCapabilityFields(selectedAudioModelOption?.capabilities, 'audio'),
[selectedAudioModelOption],
)
const selectedCharacterModelOption = useMemo(
() => userModels.image.find((model) => model.value === characterModel) || null,
[userModels.image, characterModel],
)
const selectedLocationModelOption = useMemo(
() => userModels.image.find((model) => model.value === locationModel) || null,
[userModels.image, locationModel],
)
const selectedStoryboardModelOption = useMemo(
() => userModels.image.find((model) => model.value === imageModel) || null,
[userModels.image, imageModel],
)
const selectedEditModelOption = useMemo(
() => userModels.image.find((model) => model.value === editModel) || null,
[userModels.image, editModel],
)
const characterCapabilityFields = useMemo(
() => extractCapabilityFields(selectedCharacterModelOption?.capabilities, 'image'),
[selectedCharacterModelOption],
)
const locationCapabilityFields = useMemo(
() => extractCapabilityFields(selectedLocationModelOption?.capabilities, 'image'),
[selectedLocationModelOption],
)
const storyboardCapabilityFields = useMemo(
() => extractCapabilityFields(selectedStoryboardModelOption?.capabilities, 'image'),
[selectedStoryboardModelOption],
)
const editCapabilityFields = useMemo(
() => extractCapabilityFields(selectedEditModelOption?.capabilities, 'image'),
[selectedEditModelOption],
)
const selectedVideoOverrides = useMemo<Record<string, CapabilityValue>>(() => {
return readCapabilitySelectionForModel(capabilityOverrides, videoModel)
}, [capabilityOverrides, videoModel])
const selectedAnalysisOverrides = useMemo<Record<string, CapabilityValue>>(() => {
return readCapabilitySelectionForModel(capabilityOverrides, analysisModel)
}, [capabilityOverrides, analysisModel])
const selectedAudioOverrides = useMemo<Record<string, CapabilityValue>>(() => {
return readCapabilitySelectionForModel(capabilityOverrides, audioModel)
}, [capabilityOverrides, audioModel])
const selectedCharacterOverrides = useMemo<Record<string, CapabilityValue>>(() => {
return readCapabilitySelectionForModel(capabilityOverrides, characterModel)
}, [capabilityOverrides, characterModel])
const selectedLocationOverrides = useMemo<Record<string, CapabilityValue>>(() => {
return readCapabilitySelectionForModel(capabilityOverrides, locationModel)
}, [capabilityOverrides, locationModel])
const selectedStoryboardOverrides = useMemo<Record<string, CapabilityValue>>(() => {
return readCapabilitySelectionForModel(capabilityOverrides, imageModel)
}, [capabilityOverrides, imageModel])
const selectedEditOverrides = useMemo<Record<string, CapabilityValue>>(() => {
return readCapabilitySelectionForModel(capabilityOverrides, editModel)
}, [capabilityOverrides, editModel])
const applyCapabilityOverride = (modelKey: string | undefined, field: string, value: string, sample: CapabilityValue) => {
if (!modelKey || !onCapabilityOverridesChange) return
const nextOverrides: CapabilitySelections = {
...(capabilityOverrides || {}),
}
const currentSelection = isRecord(nextOverrides[modelKey])
? { ...(nextOverrides[modelKey] as Record<string, CapabilityValue>) }
: {}
if (!value) {
delete currentSelection[field]
} else {
currentSelection[field] = parseBySample(value, sample)
}
if (Object.keys(currentSelection).length === 0) {
delete nextOverrides[modelKey]
} else {
nextOverrides[modelKey] = currentSelection
}
onCapabilityOverridesChange(nextOverrides)
showSaved()
}
/**
* 切换模型时,自动将该模型所有 capability fields 的第一个 option 写入 overrides
* 解决 UI 视觉上显示默认选中(第一项高亮)但 DB 实际为空,导致 requireAllFields 报错的问题
*/
const handleModelChange = (
modelKey: string,
modelOptions: ModelOption[],
namespace: 'llm' | 'image' | 'video' | 'audio',
onModelChangeFn?: (v: string) => void,
) => {
onModelChangeFn?.(modelKey)
showSaved()
if (!onCapabilityOverridesChange) return
// 用新选中的模型的 capabilities 计算 fields而不是旧模型的
const newModel = modelOptions.find((m) => m.value === modelKey)
const capabilityFieldsForModel = extractCapabilityFields(newModel?.capabilities, namespace)
if (capabilityFieldsForModel.length === 0) return
const nextOverrides: CapabilitySelections = { ...(capabilityOverrides || {}) }
const existing = isRecord(nextOverrides[modelKey])
? { ...(nextOverrides[modelKey] as Record<string, CapabilityValue>) }
: {}
// 只对尚未配置的 field 设置默认值(不覆盖已有配置)
let changed = false
for (const def of capabilityFieldsForModel) {
if (existing[def.field] === undefined && def.options.length > 0) {
existing[def.field] = def.options[0]
changed = true
}
}
if (changed) {
nextOverrides[modelKey] = existing
onCapabilityOverridesChange(nextOverrides)
}
}
void ttsRate
void onTTSRateChange
useEffect(() => {
if (!isOpen) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [isOpen, onClose])
const showSaved = () => {
setSaveStatus('saved')
setTimeout(() => setSaveStatus('idle'), 2000)
}
const handleChange = (callback?: (value: string) => void) => (value: string) => {
callback?.(value)
showSaved()
}
if (!isOpen) return null
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center glass-overlay animate-fadeIn"
onClick={(e) => {
if (e.target === e.currentTarget) onClose()
}}
>
<div className="glass-surface-modal p-7 w-full max-w-3xl transform transition-all scale-100 max-h-[90vh] flex flex-col">
<div className="flex justify-between items-center mb-2">
<h2 className="text-2xl font-bold text-[var(--glass-text-primary)]">{t('title')}</h2>
<div className="flex items-center gap-3">
<div className={`glass-chip text-xs transition-all duration-300 ${saveStatus === 'saved'
? 'glass-chip-success'
: 'glass-chip-neutral'
}`}>
{saveStatus === 'saved' ? (
<>
<AppIcon name="check" className="w-3.5 h-3.5" />
{t('saved')}
</>
) : (
<>
<span className="w-1.5 h-1.5 bg-[var(--glass-tone-success-fg)] rounded-full"></span>
{t('autoSave')}
</>
)}
</div>
<button
onClick={onClose}
className="glass-btn-base glass-btn-soft rounded-full p-2 text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)]"
>
<AppIcon name="close" className="w-6 h-6" />
</button>
</div>
</div>
<p className="text-[12px] text-[var(--glass-text-tertiary)] mb-6">{t('subtitle')}</p>
<div className="space-y-5 flex-1 min-h-0 overflow-y-auto app-scrollbar">
<div className="glass-surface-soft p-5 sm:p-6 space-y-4">
<h3 className="text-sm font-semibold text-[var(--glass-text-tertiary)]">{t('visualSettings')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--glass-text-secondary)]">{t('visualStyle')}</label>
<StyleSelector
value={artStyle}
onChange={(value) => handleChange(onArtStyleChange)(value)}
options={ART_STYLES}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--glass-text-secondary)]">{t('aspectRatio')}</label>
<RatioSelector
value={videoRatio}
onChange={(value) => { handleChange(onVideoRatioChange)(value) }}
options={VIDEO_RATIOS}
/>
</div>
</div>
</div>
<div className="glass-surface-soft p-5 sm:p-6 space-y-4">
<h3 className="text-sm font-semibold text-[var(--glass-text-tertiary)]">{t('modelParams')}</h3>
{!modelsLoaded && (
<div className="text-xs text-[var(--glass-text-tertiary)]">{t('loadingModels')}</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--glass-text-secondary)]">{t('analysisModel')}</label>
<ModelCapabilityDropdown
models={userModels.llm}
value={analysisModel}
onModelChange={(v) => handleChange(onAnalysisModelChange)(v)}
capabilityFields={analysisCapabilityFields}
placementMode="downward"
capabilityOverrides={selectedAnalysisOverrides}
onCapabilityChange={(field, rawValue, sample) => {
applyCapabilityOverride(analysisModel, field, rawValue, sample)
}}
placeholder={t('pleaseSelect')}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--glass-text-secondary)]">{t('characterModel')}</label>
<ModelCapabilityDropdown
models={userModels.image}
value={characterModel}
onModelChange={(v) => handleModelChange(v, userModels.image, 'image', onCharacterModelChange)}
capabilityFields={characterCapabilityFields}
placementMode="downward"
capabilityOverrides={selectedCharacterOverrides}
onCapabilityChange={(field, rawValue, sample) => {
applyCapabilityOverride(characterModel, field, rawValue, sample)
}}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--glass-text-secondary)]">{t('locationModel')}</label>
<ModelCapabilityDropdown
models={userModels.image}
value={locationModel}
onModelChange={(v) => handleModelChange(v, userModels.image, 'image', onLocationModelChange)}
capabilityFields={locationCapabilityFields}
placementMode="downward"
capabilityOverrides={selectedLocationOverrides}
onCapabilityChange={(field, rawValue, sample) => {
applyCapabilityOverride(locationModel, field, rawValue, sample)
}}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--glass-text-secondary)]">{t('storyboardModel')}</label>
<ModelCapabilityDropdown
models={userModels.image}
value={imageModel}
onModelChange={(v) => handleModelChange(v, userModels.image, 'image', onImageModelChange)}
capabilityFields={storyboardCapabilityFields}
placementMode="downward"
capabilityOverrides={selectedStoryboardOverrides}
onCapabilityChange={(field, rawValue, sample) => {
applyCapabilityOverride(imageModel, field, rawValue, sample)
}}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--glass-text-secondary)]">{t('editModel')}</label>
<ModelCapabilityDropdown
models={userModels.image}
value={editModel}
onModelChange={(v) => handleModelChange(v, userModels.image, 'image', onEditModelChange)}
capabilityFields={editCapabilityFields}
placementMode="downward"
capabilityOverrides={selectedEditOverrides}
onCapabilityChange={(field, rawValue, sample) => {
applyCapabilityOverride(editModel, field, rawValue, sample)
}}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--glass-text-secondary)]">{t('videoModel')}</label>
<ModelCapabilityDropdown
models={normalVideoModels}
value={videoModel}
onModelChange={(v) => handleModelChange(v, normalVideoModels, 'video', onVideoModelChange)}
capabilityFields={videoCapabilityFields}
placementMode="downward"
capabilityOverrides={selectedVideoOverrides}
onCapabilityChange={(field, rawValue, sample) => {
applyCapabilityOverride(videoModel, field, rawValue, sample)
}}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--glass-text-secondary)]">{t('audioModel')}</label>
<ModelCapabilityDropdown
models={userModels.audio}
value={audioModel}
onModelChange={(v) => handleModelChange(v, userModels.audio, 'audio', onAudioModelChange)}
capabilityFields={audioCapabilityFields}
placementMode="downward"
capabilityOverrides={selectedAudioOverrides}
onCapabilityChange={(field, rawValue, sample) => {
applyCapabilityOverride(audioModel, field, rawValue, sample)
}}
placeholder={t('pleaseSelect')}
/>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
export { SettingsModal as ConfigEditModal }
export { WorldContextModal } from './WorldContextModal'