'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 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 { 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 { if (!modelKey || !overrides) return {} const raw = overrides[modelKey] if (!isRecord(raw)) return {} const normalized: Record = {} 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(() => ({ 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( () => 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>(() => { return readCapabilitySelectionForModel(capabilityOverrides, videoModel) }, [capabilityOverrides, videoModel]) const selectedAnalysisOverrides = useMemo>(() => { return readCapabilitySelectionForModel(capabilityOverrides, analysisModel) }, [capabilityOverrides, analysisModel]) const selectedAudioOverrides = useMemo>(() => { return readCapabilitySelectionForModel(capabilityOverrides, audioModel) }, [capabilityOverrides, audioModel]) const selectedCharacterOverrides = useMemo>(() => { return readCapabilitySelectionForModel(capabilityOverrides, characterModel) }, [capabilityOverrides, characterModel]) const selectedLocationOverrides = useMemo>(() => { return readCapabilitySelectionForModel(capabilityOverrides, locationModel) }, [capabilityOverrides, locationModel]) const selectedStoryboardOverrides = useMemo>(() => { return readCapabilitySelectionForModel(capabilityOverrides, imageModel) }, [capabilityOverrides, imageModel]) const selectedEditOverrides = useMemo>(() => { 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) } : {} 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) } : {} // 只对尚未配置的 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 (
{ if (e.target === e.currentTarget) onClose() }} >

{t('title')}

{saveStatus === 'saved' ? ( <> {t('saved')} ) : ( <> {t('autoSave')} )}

{t('subtitle')}

{t('visualSettings')}

handleChange(onArtStyleChange)(value)} options={ART_STYLES} />
{ handleChange(onVideoRatioChange)(value) }} options={VIDEO_RATIOS} />

{t('modelParams')}

{!modelsLoaded && (
{t('loadingModels')}
)}
handleChange(onAnalysisModelChange)(v)} capabilityFields={analysisCapabilityFields} placementMode="downward" capabilityOverrides={selectedAnalysisOverrides} onCapabilityChange={(field, rawValue, sample) => { applyCapabilityOverride(analysisModel, field, rawValue, sample) }} placeholder={t('pleaseSelect')} />
handleModelChange(v, userModels.image, 'image', onCharacterModelChange)} capabilityFields={characterCapabilityFields} placementMode="downward" capabilityOverrides={selectedCharacterOverrides} onCapabilityChange={(field, rawValue, sample) => { applyCapabilityOverride(characterModel, field, rawValue, sample) }} />
handleModelChange(v, userModels.image, 'image', onLocationModelChange)} capabilityFields={locationCapabilityFields} placementMode="downward" capabilityOverrides={selectedLocationOverrides} onCapabilityChange={(field, rawValue, sample) => { applyCapabilityOverride(locationModel, field, rawValue, sample) }} />
handleModelChange(v, userModels.image, 'image', onImageModelChange)} capabilityFields={storyboardCapabilityFields} placementMode="downward" capabilityOverrides={selectedStoryboardOverrides} onCapabilityChange={(field, rawValue, sample) => { applyCapabilityOverride(imageModel, field, rawValue, sample) }} />
handleModelChange(v, userModels.image, 'image', onEditModelChange)} capabilityFields={editCapabilityFields} placementMode="downward" capabilityOverrides={selectedEditOverrides} onCapabilityChange={(field, rawValue, sample) => { applyCapabilityOverride(editModel, field, rawValue, sample) }} />
handleModelChange(v, normalVideoModels, 'video', onVideoModelChange)} capabilityFields={videoCapabilityFields} placementMode="downward" capabilityOverrides={selectedVideoOverrides} onCapabilityChange={(field, rawValue, sample) => { applyCapabilityOverride(videoModel, field, rawValue, sample) }} />
handleModelChange(v, userModels.audio, 'audio', onAudioModelChange)} capabilityFields={audioCapabilityFields} placementMode="downward" capabilityOverrides={selectedAudioOverrides} onCapabilityChange={(field, rawValue, sample) => { applyCapabilityOverride(audioModel, field, rawValue, sample) }} placeholder={t('pleaseSelect')} />
) } export { SettingsModal as ConfigEditModal } export { WorldContextModal } from './WorldContextModal'