/* Copyright (C) 2025 QuantumNous This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { API, processModelsData, processGroupsData, showError, } from '../../helpers'; import { API_ENDPOINTS } from '../../constants/playground.constants'; /** * 判断「全部类型」未选(与 vendor_id=0 的已选相区分,0 为合法值) * @param {string|number|undefined|null} v * @returns {boolean} */ const isTypeSelectionEmpty = (v) => v === '' || v == null || v === undefined; /** * 规范化操練场「模型类型」的值为数字或空(全部类型) * @param {string|number|undefined|null} v * @returns {string|number} */ const normalizeTypeId = (v) => { if (isTypeSelectionEmpty(v)) return ''; const n = Number(v); return Number.isNaN(n) ? '' : n; }; /** * 与模型广场一致:按「当前选中的类型」在客户端筛模型;优先 vendor_id,其次与下拉项 label 与 item.vendor 对齐(与广场按 vendor 名称一致) * @param {{ model_name: string, vendor_id?: number, vendor?: string }[]} listForPlayground * @param {string|number} effectiveType * @param {Array<{ label: string, value: string|number }>} typeOptions * @returns {typeof listForPlayground} */ const filterPlaygroundModelsByType = ( listForPlayground, effectiveType, typeOptions, ) => { if (effectiveType === '' || isTypeSelectionEmpty(effectiveType)) { return listForPlayground; } const eff = Number(effectiveType); const typeOpt = (typeOptions || []).find( (o) => o && o.value !== '' && Number(o.value) === eff, ); const labelForType = String(typeOpt?.label || '').trim(); return listForPlayground.filter((item) => { const vid = Number(item?.vendor_id ?? 0); if (Number.isFinite(eff) && vid === eff) { return true; } if (labelForType && String(item?.vendor || '').trim() === labelForType) { return true; } return false; }); }; const normalizeTagList = (csv) => { if (!csv || typeof csv !== 'string') return []; return [ ...new Set( csv .replaceAll(',', ',') .replaceAll('、', ',') .split(',') .map((item) => item.trim()) .filter(Boolean), ), ]; }; const hasTag = (csv, tag) => normalizeTagList(csv).includes(tag); /** * 体验数据加载:拉取用户模型与类型(与模型广场同源元数据)、按「全部/类型」在客户端筛模型(同模型广场逻辑),分组单独加载。 * @param {{ user?: object }} userState 已登录用户状态 * @param {{ model?: string, model_type?: string|number, group?: string, selected_route_slug?: string }} inputs 当前表单/配置 * @param {Array<{ label: string, value: string|number }>} modelTypes 类型下拉项(与接口同步后的状态,用于按类型重算模型列表) * @param {function(string, unknown): void} handleInputChange 更新单项输入 * @param {import('react').Dispatch>} setModels 设置模型下拉选项 * @param {import('react').Dispatch>} setModelTypes 设置模型类型下拉 * @param {import('react').Dispatch>} setSupplierOptions 设置渠道下拉 * @param {import('react').Dispatch>} setGroups 设置分组下拉 * @returns {{ loadModels: function, loadGroups: function }} */ export const useDataLoader = ( userState, inputs, modelTypes, handleInputChange, setModels, setModelTypes, setSupplierOptions, setGroups, setStatus, ) => { const { t } = useTranslation(); /** * 接口返回的带 vendor 信息的原始模型行,供「仅客户端」按类型过滤(与模型广场一致、且避免与请求竞态导致类型与列表不同步) */ const [playgroundRawModels, setPlaygroundRawModels] = useState([]); /** * 拉取 scene=playground 模型列表,写入「类型选项」与原始行;不依赖当前 model_type,避免重复请求与请求返回顺序错配。 */ const loadModels = useCallback(async () => { try { const res = await API.get( `${API_ENDPOINTS.USER_MODELS}?scene=playground`, ); const { success, message, data } = res.data; if (success) { const items = Array.isArray(data) ? data : data?.items || []; const vendorOptionsFromAPI = Array.isArray(data?.vendor_options) ? data.vendor_options : []; const normalizedItems = items.map((item) => { if (typeof item === 'string') { return { model_name: item, vendor_id: 0, vendor: '', tested_success: true, }; } return item; }); // 后端已仅返回「渠道单测最近一次成功」的模型;下列表与类型均基于此子集 const listForPlayground = normalizedItems.filter( (item) => item?.model_name, ); setPlaygroundRawModels(listForPlayground); const typeOptionsFromAPI = vendorOptionsFromAPI .map((item) => ({ label: String(item?.name || '').trim(), value: Number(item?.id), })) .filter( (item) => item.label !== '' && Number.isFinite(item.value) && (item.value > 0 || item.value === 0), ); // 兜底:若后端未返回 vendor_options,从当前用户可用模型项(全量项)提取类型(与广场「从数据里出现的供应商」一致;含未关联 id=0) const fallbackTypeMap = new Map(); normalizedItems.forEach((item) => { const vendorName = String(item?.vendor || '').trim(); const vendorID = Number(item?.vendor_id ?? 0); if (Number.isFinite(vendorID) && vendorID === 0) { fallbackTypeMap.set(0, { label: t('未知模型类型'), value: 0, }); return; } if (vendorName !== '' && Number.isFinite(vendorID) && vendorID > 0) { fallbackTypeMap.set(vendorID, { label: vendorName, value: vendorID, }); } }); const rawTypeOptions = typeOptionsFromAPI.length > 0 ? typeOptionsFromAPI : Array.from(fallbackTypeMap.values()); rawTypeOptions.sort((a, b) => String(a.label).localeCompare(String(b.label), 'zh-Hans-CN'), ); const modelTypeOptions = [{ label: t('全部类型'), value: '' }].concat( rawTypeOptions, ); setModelTypes(modelTypeOptions); } else { showError(t(message)); } } catch (error) { showError(t('加载模型失败')); } }, [setModelTypes, t]); /** * 在原始模型行与「模型类型」上按模型广场方式本地筛出模型下拉里模型名;不发起额外请求 */ useEffect(() => { if (!userState?.user) { setPlaygroundRawModels([]); setModels([]); setSupplierOptions([{ label: `${t('随机')} (${t('默认')})`, value: '' }]); return; } if (playgroundRawModels.length === 0) { setModels([]); setSupplierOptions([{ label: `${t('随机')} (${t('默认')})`, value: '' }]); return; } const typeOptions = modelTypes && modelTypes.length > 0 ? modelTypes : [{ label: t('全部类型'), value: '' }]; const selectedType = normalizeTypeId(inputs.model_type); const hasSelectedType = typeOptions.some((option) => { if (option.value === '' && selectedType === '') return true; if (option.value === '' || isTypeSelectionEmpty(selectedType)) return false; return Number(option.value) === selectedType; }); if (!hasSelectedType && !isTypeSelectionEmpty(selectedType)) { handleInputChange('model_type', ''); } const effectiveType = hasSelectedType ? selectedType : ''; const filteredItems = filterPlaygroundModelsByType( playgroundRawModels, effectiveType, typeOptions, ); const displayMode = inputs.display_mode || 'text'; const modeFilteredItems = filteredItems.filter((item) => { const tags = item?.tags || ''; if (displayMode === 'video') { return hasTag(tags, '视频'); } if (displayMode === 'image') { return hasTag(tags, '图片'); } // 文本模式:无标签按文本处理;有标签则要求包含文本 if (!tags || !tags.trim()) return true; return hasTag(tags, '文本'); }); const { modelOptions, selectedModel } = processModelsData( modeFilteredItems.map((item) => item.model_name), inputs.model, ); setModels(modelOptions); if (selectedModel !== inputs.model) { handleInputChange('model', selectedModel); } const selectedModelName = selectedModel !== undefined && selectedModel !== null ? selectedModel : inputs.model; const selectedModelRow = modeFilteredItems.find( (item) => item?.model_name === selectedModelName, ); const selectedModelTags = normalizeTagList(selectedModelRow?.tags || ''); setStatus((prev) => ({ ...prev, selectedModelTags, })); handleInputChange('selected_model_tags', selectedModelTags); const supplierOptionsRaw = Array.isArray(selectedModelRow?.channel_options) ? selectedModelRow.channel_options : []; const supplierOptions = [ { label: `${t('随机')} (${t('默认')})`, value: '' }, ...supplierOptionsRaw .map((option) => { const id = Number(option?.id); if (!Number.isFinite(id) || id <= 0) { return null; } const routeSlug = String(option?.route_slug || '').trim(); const supplierType = String(option?.supplier_type || '').trim(); const parts = [routeSlug, supplierType].filter(Boolean); const label = parts.length > 0 ? parts.join('-') : `渠道#${id}`; if (!routeSlug) { return null; } return { label, value: routeSlug, }; }) .filter(Boolean), ]; setSupplierOptions(supplierOptions); const selectedRouteSlug = inputs.selected_route_slug; const hasSelectedSupplier = supplierOptions.some((option) => { if ( option.value === '' && (selectedRouteSlug === '' || selectedRouteSlug == null) ) { return true; } if ( option.value === '' || selectedRouteSlug === '' || selectedRouteSlug == null ) { return false; } return option.value === selectedRouteSlug; }); if (!hasSelectedSupplier) { handleInputChange('selected_route_slug', ''); } }, [ userState?.user, playgroundRawModels, modelTypes, inputs.model_type, inputs.model, inputs.display_mode, inputs.selected_route_slug, t, setModels, setSupplierOptions, setStatus, handleInputChange, ]); /** * 拉取用户可用分组,并校正当前选中分组 */ const loadGroups = useCallback(async () => { try { const res = await API.get(API_ENDPOINTS.USER_GROUPS); const { success, message, data } = res.data; if (success) { const userGroup = userState?.user?.group || JSON.parse(localStorage.getItem('user'))?.group; const groupOptions = processGroupsData(data, userGroup); setGroups(groupOptions); const hasCurrentGroup = groupOptions.some( (option) => option.value === inputs.group, ); if (!hasCurrentGroup) { handleInputChange('group', groupOptions[0]?.value || ''); } } else { showError(t(message)); } } catch (error) { showError(t('加载分组失败')); } }, [userState, inputs.group, handleInputChange, setGroups, t]); /** * 用户登录后自动加载模型列表与分组;依赖 loadModels / loadGroups 以在 model_type 等变化时重算筛选结果 */ useEffect(() => { if (userState?.user) { loadModels(); loadGroups(); } }, [userState?.user, loadModels, loadGroups]); // 切换展示模式时主动刷新模型数据,确保网络请求与筛选状态同步更新 useEffect(() => { if (!userState?.user) return; loadModels(); }, [inputs.display_mode, userState?.user, loadModels]); return { loadModels, loadGroups, }; };