/** * Pinia Store: Model Config | 模型配置 Store * 管理模型配置、渠道切换和模型选择 */ import { defineStore } from 'pinia' import { ref, computed, watch } from 'vue' import { CHAT_MODELS, IMAGE_MODELS, VIDEO_MODELS, DEFAULT_CHAT_MODEL, DEFAULT_IMAGE_MODEL, DEFAULT_VIDEO_MODEL } from '@/config/models' import { PROVIDERS, getProviderList, getDefaultProvider, getProviderConfig, getDefaultBaseUrl } from '@/config/providers' // 存储键名 const STORAGE_KEYS = { PROVIDER: 'api-provider', CUSTOM_CHAT_MODELS: 'custom-chat-models', CUSTOM_IMAGE_MODELS: 'custom-image-models', CUSTOM_VIDEO_MODELS: 'custom-video-models', SELECTED_CHAT_MODEL: 'selected-chat-model', SELECTED_IMAGE_MODEL: 'selected-image-model', SELECTED_VIDEO_MODEL: 'selected-video-model', CUSTOM_CHAT_MODELS_BY_PROVIDER: 'custom-chat-models-by-provider', CUSTOM_IMAGE_MODELS_BY_PROVIDER: 'custom-image-models-by-provider', CUSTOM_VIDEO_MODELS_BY_PROVIDER: 'custom-video-models-by-provider', API_KEYS_BY_PROVIDER: 'api-keys-by-provider', BASE_URLS_BY_PROVIDER: 'base-urls-by-provider' } /** * Get stored value from localStorage */ const getStored = (key, defaultValue = '') => { try { return localStorage.getItem(key) || defaultValue } catch { return defaultValue } } /** * Set stored value to localStorage */ const setStored = (key, value) => { try { if (value) { localStorage.setItem(key, value) } else { localStorage.removeItem(key) } } catch { // ignore } } const removeStored = (key) => { try { localStorage.removeItem(key) } catch { // ignore } } /** * Get stored JSON value from localStorage */ const getStoredJson = (key, defaultValue = []) => { try { const stored = localStorage.getItem(key) return stored ? JSON.parse(stored) : defaultValue } catch { return defaultValue } } /** * Set stored JSON value to localStorage */ const setStoredJson = (key, value) => { try { localStorage.setItem(key, JSON.stringify(value)) } catch { // ignore } } const getValidStoredModel = (key, defaultValue, builtInModels) => { const stored = getStored(key, defaultValue) return builtInModels.some(model => model.key === stored) ? stored : defaultValue } /** * 检查模型是否支持指定渠道 */ const isModelSupported = (model, provider) => { if (!model.provider) { return true } return model.provider.includes(provider) } const normalizeRuntimeSizeOptions = (items = []) => { if (!Array.isArray(items)) return [] return items .map(item => { const key = item?.value || item?.key || item?.id if (!key) return null return { label: item.label || key, key } }) .filter(Boolean) } const normalizeRuntimeImageModel = (item) => { const key = item?.id || item?.model if (!key) return null const sizeOptions = normalizeRuntimeSizeOptions(item.size_options) return { label: item.label || item.model || key, key, provider: ['chatfire'], sizes: sizeOptions.map(option => option.key), sizeOptions, qualities: [{ label: '标准', key: 'standard' }], defaultParams: { size: item.default_size || sizeOptions[0]?.key || 'auto', quality: 'standard', style: item.provider === 'ark_seedream' ? 'commercial' : 'vivid' }, available: item.available !== false, providerName: item.provider, isRuntime: true } } const normalizeRuntimeDurationOptions = (items = []) => { if (!Array.isArray(items)) return [] return items .map(item => { const key = typeof item === 'object' ? item?.value || item?.key || item?.id : item if (!key) return null return { label: typeof item === 'object' ? item.label || `${key} 秒` : `${key} 秒`, key } }) .filter(Boolean) } const normalizeRuntimeResolutionOptions = (items = []) => { if (!Array.isArray(items)) return [] return items .map(item => { const key = typeof item === 'object' ? item?.value || item?.key || item?.id : item if (!key) return null return { label: typeof item === 'object' ? item.label || key : key, key } }) .filter(Boolean) } const normalizeRuntimeVideoModel = (item) => { const key = item?.id || item?.model if (!key) return null const sizeOptions = normalizeRuntimeSizeOptions(item.size_options) const durationOptions = normalizeRuntimeDurationOptions(item.duration_options) const resolutionOptions = normalizeRuntimeResolutionOptions(item.resolution_options) const resolutions = resolutionOptions.length ? resolutionOptions.map(option => option.key) : ['720p'] const defaultResolution = item.default_resolution || resolutions[0] || '720p' return { label: item.label || item.model || key, key, provider: ['chatfire'], type: 't2v+i2v', model: item.model, ratios: sizeOptions.map(option => option.key), durs: durationOptions, resolutions, resolutionOptions, defaultResolution, defaultParams: { ratio: sizeOptions[0]?.key || '720x1280', duration: durationOptions[0]?.key || 5, resolution: defaultResolution }, available: item.available !== false, isRuntime: true } } const mergeModels = (builtInModels, runtimeModels) => { const byKey = new Map() builtInModels.forEach(model => byKey.set(model.key, { ...model, isCustom: false })) runtimeModels.forEach(model => byKey.set(model.key, { ...byKey.get(model.key), ...model, isCustom: false })) return Array.from(byKey.values()) } export const useModelStore = defineStore('model', () => { // ============ Provider 状态 | Provider State ============ // 当前选中的渠道 const storedProvider = getStored(STORAGE_KEYS.PROVIDER) const currentProvider = ref(PROVIDERS[storedProvider] ? storedProvider : getDefaultProvider()) // 渠道列表 const providerList = computed(() => getProviderList()) // 当前渠道配置 const providerConfig = computed(() => getProviderConfig(currentProvider.value)) // 当前渠道标签 const providerLabel = computed(() => providerConfig.value.label || currentProvider.value) // 设置当前渠道 const setProvider = (provider) => { if (PROVIDERS[provider]) { currentProvider.value = provider setStored(STORAGE_KEYS.PROVIDER, provider) } } // 清除渠道配置 const clearProvider = () => { currentProvider.value = getDefaultProvider() removeStored(STORAGE_KEYS.PROVIDER) } // 适配请求参数 const adaptRequest = (type, params) => { const config = providerConfig.value if (config.requestAdapter && config.requestAdapter[type]) { return config.requestAdapter[type](params) } return params } // 适配响应数据 const adaptResponse = (type, response) => { const config = providerConfig.value if (config.responseAdapter && config.responseAdapter[type]) { return config.responseAdapter[type](response) } return response } // ============ Custom Models 状态 | Custom Models State ============ // 全局自定义模型(不区分渠道) const customChatModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS, [])) const customImageModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS, [])) const customVideoModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS, [])) // 按渠道存储的自定义模型 | 结构: { 'skg': [{key, label}] } const customChatModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS_BY_PROVIDER, {})) const customImageModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS_BY_PROVIDER, {})) const customVideoModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS_BY_PROVIDER, {})) const runtimeImageModels = ref([]) const runtimeVideoModels = ref([]) const runtimeVideoModelsLoaded = ref(false) // 选中的模型 const selectedChatModel = ref(getStored(STORAGE_KEYS.SELECTED_CHAT_MODEL, DEFAULT_CHAT_MODEL)) const selectedImageModel = ref(getValidStoredModel(STORAGE_KEYS.SELECTED_IMAGE_MODEL, DEFAULT_IMAGE_MODEL, IMAGE_MODELS)) const selectedVideoModel = ref(getValidStoredModel(STORAGE_KEYS.SELECTED_VIDEO_MODEL, DEFAULT_VIDEO_MODEL, VIDEO_MODELS)) // 按渠道存储的 API 配置 const apiKeysByProvider = ref(getStoredJson(STORAGE_KEYS.API_KEYS_BY_PROVIDER, {})) const baseUrlsByProvider = ref(getStoredJson(STORAGE_KEYS.BASE_URLS_BY_PROVIDER, {})) // 内部模式由服务端会话鉴权,不在浏览器暴露上游模型密钥。 const currentApiKey = computed(() => 'internal-session') const currentBaseUrl = computed(() => baseUrlsByProvider.value[currentProvider.value] || getDefaultBaseUrl(currentProvider.value)) // 设置指定渠道凭据(兼容旧本地状态) const setApiKeyByProvider = (provider, apiKey) => { apiKeysByProvider.value[provider] = apiKey } // 设置指定渠道的 Base URL const setBaseUrlByProvider = (provider, baseUrl) => { baseUrlsByProvider.value[provider] = baseUrl } // 清除指定渠道的 API 配置 const clearApiConfigByProvider = (provider) => { delete apiKeysByProvider.value[provider] delete baseUrlsByProvider.value[provider] } // ============ Computed: All Models (built-in + custom + by provider) ============ const allChatModels = computed(() => [ ...CHAT_MODELS.map(m => ({ ...m, isCustom: false })), ...customChatModels.value.map(m => ({ label: m.label || m.key, key: m.key, isCustom: true })), // 添加当前渠道的自定义模型 ...(customChatModelsByProvider.value[currentProvider.value] || []).map(m => ({ label: m.label || m.key, key: m.key, isCustom: true, provider: [currentProvider.value] })) ]) const allImageModels = computed(() => mergeModels(IMAGE_MODELS, runtimeImageModels.value) ) const allVideoModels = computed(() => runtimeVideoModelsLoaded.value ? runtimeVideoModels.value : mergeModels(VIDEO_MODELS, runtimeVideoModels.value) ) // ============ Computed: Available Models (filtered by provider) ============ // 按渠道过滤的可用模型 const availableChatModels = computed(() => allChatModels.value.filter(m => isModelSupported(m, currentProvider.value)) ) const availableImageModels = computed(() => allImageModels.value.filter(m => isModelSupported(m, currentProvider.value) && m.available !== false) ) const availableVideoModels = computed(() => allVideoModels.value.filter(m => isModelSupported(m, currentProvider.value) && m.available !== false) ) // ============ Computed: Model Options for UI (all models, not filtered by provider) ============ // 返回适合 n-dropdown 使用的选项格式(全部模型,不按渠道过滤) const allImageModelOptions = computed(() => allImageModels.value.map(m => ({ label: m.label, key: m.key, disabled: false })) ) const allVideoModelOptions = computed(() => allVideoModels.value.map(m => ({ label: m.label, key: m.key, disabled: false })) ) const allChatModelOptions = computed(() => allChatModels.value.map(m => ({ label: m.label, key: m.key })) ) // ============ Computed: Model Options for UI (filtered by provider - deprecated, use all* instead) ============ // 返回适合 n-dropdown 使用的选项格式 const imageModelOptions = computed(() => availableImageModels.value.map(m => ({ label: m.label, key: m.key, disabled: m.available === false })) ) const videoModelOptions = computed(() => availableVideoModels.value.map(m => ({ label: m.label, key: m.key })) ) const chatModelOptions = computed(() => availableChatModels.value.map(m => ({ label: m.label, key: m.key })) ) // ============ Methods: Add/Remove Custom Models ============ const addCustomChatModel = (modelKey, label = '') => { if (!modelKey || customChatModels.value.some(m => m.key === modelKey)) return false customChatModels.value.push({ key: modelKey, label: label || modelKey }) return true } const addCustomImageModel = (modelKey, label = '') => { if (!modelKey || customImageModels.value.some(m => m.key === modelKey)) return false customImageModels.value.push({ key: modelKey, label: label || modelKey }) return true } const addCustomVideoModel = (modelKey, label = '') => { if (!modelKey || customVideoModels.value.some(m => m.key === modelKey)) return false customVideoModels.value.push({ key: modelKey, label: label || modelKey }) return true } const removeCustomChatModel = (modelKey) => { const idx = customChatModels.value.findIndex(m => m.key === modelKey) if (idx > -1) { customChatModels.value.splice(idx, 1) if (selectedChatModel.value === modelKey) { selectedChatModel.value = DEFAULT_CHAT_MODEL } return true } return false } const removeCustomImageModel = (modelKey) => { const idx = customImageModels.value.findIndex(m => m.key === modelKey) if (idx > -1) { customImageModels.value.splice(idx, 1) if (selectedImageModel.value === modelKey) { selectedImageModel.value = DEFAULT_IMAGE_MODEL } return true } return false } const removeCustomVideoModel = (modelKey) => { const idx = customVideoModels.value.findIndex(m => m.key === modelKey) if (idx > -1) { customVideoModels.value.splice(idx, 1) if (selectedVideoModel.value === modelKey) { selectedVideoModel.value = DEFAULT_VIDEO_MODEL } return true } return false } // ============ Methods: Get Model Config ============ const getChatModel = (key) => allChatModels.value.find(m => m.key === key) const getImageModel = (key) => allImageModels.value.find(m => m.key === key) const getVideoModel = (key) => allVideoModels.value.find(m => m.key === key) const loadRuntimeModels = async () => { try { const response = await fetch('/api/health', { credentials: 'include' }) if (!response.ok) return false const data = await response.json() const imageOptions = data?.models?.image_options || [] runtimeImageModels.value = imageOptions .filter(item => item?.id && item.id !== 'auto') .map(normalizeRuntimeImageModel) .filter(Boolean) const videoOptions = data?.models?.video_options || [] runtimeVideoModels.value = videoOptions .filter(item => item?.id && item.available !== false) .map(normalizeRuntimeVideoModel) .filter(Boolean) runtimeVideoModelsLoaded.value = true if (!availableVideoModels.value.some(model => model.key === selectedVideoModel.value)) { selectedVideoModel.value = availableVideoModels.value[0]?.key || DEFAULT_VIDEO_MODEL } return true } catch (err) { console.warn('[model store] runtime model load failed', err) return false } } // ============ Methods: Get API Endpoints ============ // 获取图片端点 const getImageEndpoint = () => { const endpoint = providerConfig.value.endpoints?.image || '/images/generations' return `${currentBaseUrl.value}${endpoint}` } // 获取视频生成端点 const getVideoEndpoint = () => { const endpoint = providerConfig.value.endpoints?.video || '/videos' return `${currentBaseUrl.value}${endpoint}` } // 获取视频任务查询端点 const getVideoTaskEndpoint = () => { const config = providerConfig.value // 优先使用 videoQuery 端点,支持 {taskId} 占位符替换 let endpoint = config.endpoints?.videoQuery || config.endpoints?.video || '/videos' return `${currentBaseUrl.value}${endpoint}` } // 获取聊天端点(支持参考图片) const getChatEndpoint = () => { const endpoint = providerConfig.value?.endpoints?.chat || '/chat/completions' return `${currentBaseUrl.value}${endpoint}` } // ============ Methods: Get Models By Provider ============ const getModelsByProvider = (provider) => { const chat = [ ...CHAT_MODELS.filter(m => isModelSupported(m, provider)).map(m => ({ ...m, isCustom: false })), ...(customChatModelsByProvider.value[provider] || []).map(m => ({ label: m.label || m.key, key: m.key, isCustom: true, provider: [provider] })) ] const image = allImageModels.value .filter(m => isModelSupported(m, provider)) .map(m => ({ ...m, isCustom: false })) const video = allVideoModels.value .filter(m => isModelSupported(m, provider)) .map(m => ({ ...m, isCustom: false })) return { chat, image, video } } // ============ Methods: Add/Remove Custom Models By Provider ============ const addCustomChatModelByProvider = (modelKey, provider, label = '') => { if (!modelKey) return false if (!customChatModelsByProvider.value[provider]) { customChatModelsByProvider.value[provider] = [] } if (customChatModelsByProvider.value[provider].some(m => m.key === modelKey)) return false customChatModelsByProvider.value[provider].push({ key: modelKey, label: label || modelKey }) return true } const addCustomImageModelByProvider = (modelKey, provider, label = '') => { if (!modelKey) return false if (!customImageModelsByProvider.value[provider]) { customImageModelsByProvider.value[provider] = [] } if (customImageModelsByProvider.value[provider].some(m => m.key === modelKey)) return false customImageModelsByProvider.value[provider].push({ key: modelKey, label: label || modelKey }) return true } const addCustomVideoModelByProvider = (modelKey, provider, label = '') => { if (!modelKey) return false if (!customVideoModelsByProvider.value[provider]) { customVideoModelsByProvider.value[provider] = [] } if (customVideoModelsByProvider.value[provider].some(m => m.key === modelKey)) return false customVideoModelsByProvider.value[provider].push({ key: modelKey, label: label || modelKey }) return true } const removeCustomChatModelByProvider = (modelKey, provider) => { if (!customChatModelsByProvider.value[provider]) return false const idx = customChatModelsByProvider.value[provider].findIndex(m => m.key === modelKey) if (idx > -1) { customChatModelsByProvider.value[provider].splice(idx, 1) return true } return false } const removeCustomImageModelByProvider = (modelKey, provider) => { if (!customImageModelsByProvider.value[provider]) return false const idx = customImageModelsByProvider.value[provider].findIndex(m => m.key === modelKey) if (idx > -1) { customImageModelsByProvider.value[provider].splice(idx, 1) return true } return false } const removeCustomVideoModelByProvider = (modelKey, provider) => { if (!customVideoModelsByProvider.value[provider]) return false const idx = customVideoModelsByProvider.value[provider].findIndex(m => m.key === modelKey) if (idx > -1) { customVideoModelsByProvider.value[provider].splice(idx, 1) return true } return false } // 清除所有自定义模型 const clearCustomModels = () => { customChatModels.value = [] customImageModels.value = [] customVideoModels.value = [] selectedChatModel.value = DEFAULT_CHAT_MODEL selectedImageModel.value = DEFAULT_IMAGE_MODEL selectedVideoModel.value = DEFAULT_VIDEO_MODEL } // ============ Watch & Persist ============ // 监听并持久化自定义模型 watch(customChatModels, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS, val), { deep: true }) watch(customImageModels, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS, val), { deep: true }) watch(customVideoModels, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS, val), { deep: true }) // 监听并持久化按渠道的自定义模型 watch(customChatModelsByProvider, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS_BY_PROVIDER, val), { deep: true }) watch(customImageModelsByProvider, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS_BY_PROVIDER, val), { deep: true }) watch(customVideoModelsByProvider, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS_BY_PROVIDER, val), { deep: true }) // 监听并持久化选中的模型 watch(selectedChatModel, (val) => setStored(STORAGE_KEYS.SELECTED_CHAT_MODEL, val)) watch(selectedImageModel, (val) => setStored(STORAGE_KEYS.SELECTED_IMAGE_MODEL, val)) watch(selectedVideoModel, (val) => setStored(STORAGE_KEYS.SELECTED_VIDEO_MODEL, val)) // 监听并持久化 API 配置 watch(apiKeysByProvider, (val) => setStoredJson(STORAGE_KEYS.API_KEYS_BY_PROVIDER, val), { deep: true }) watch(baseUrlsByProvider, (val) => setStoredJson(STORAGE_KEYS.BASE_URLS_BY_PROVIDER, val), { deep: true }) return { // Provider currentProvider, providerList, providerConfig, providerLabel, setProvider, clearProvider, adaptRequest, adaptResponse, // All models (built-in + custom) allChatModels, allImageModels, allVideoModels, runtimeImageModels, runtimeVideoModels, runtimeVideoModelsLoaded, // Available models filtered by provider availableChatModels, availableImageModels, availableVideoModels, // Model options for UI (dropdown format) imageModelOptions, videoModelOptions, chatModelOptions, // All model options (not filtered by provider) allImageModelOptions, allVideoModelOptions, allChatModelOptions, // Selected models selectedChatModel, selectedImageModel, selectedVideoModel, // Custom models customChatModels, customImageModels, customVideoModels, // Custom models by provider customChatModelsByProvider, customImageModelsByProvider, customVideoModelsByProvider, // Add/Remove methods addCustomChatModel, addCustomImageModel, addCustomVideoModel, removeCustomChatModel, removeCustomImageModel, removeCustomVideoModel, // Add/Remove by provider methods addCustomChatModelByProvider, addCustomImageModelByProvider, addCustomVideoModelByProvider, removeCustomChatModelByProvider, removeCustomImageModelByProvider, removeCustomVideoModelByProvider, // Get model getChatModel, getImageModel, getVideoModel, loadRuntimeModels, // Get API endpoints getImageEndpoint, getVideoEndpoint, getVideoTaskEndpoint, getChatEndpoint, // Get models by provider getModelsByProvider, // Clear all custom models clearCustomModels, // API Config by provider currentApiKey, currentBaseUrl, apiKeysByProvider, baseUrlsByProvider, setApiKeyByProvider, setBaseUrlByProvider, clearApiConfigByProvider } })