386 lines
13 KiB
JavaScript
386 lines
13 KiB
JavaScript
/*
|
||
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 <https://www.gnu.org/licenses/>.
|
||
|
||
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<import('react').SetStateAction<any[]>>} setModels 设置模型下拉选项
|
||
* @param {import('react').Dispatch<import('react').SetStateAction<any[]>>} setModelTypes 设置模型类型下拉
|
||
* @param {import('react').Dispatch<import('react').SetStateAction<any[]>>} setSupplierOptions 设置渠道下拉
|
||
* @param {import('react').Dispatch<import('react').SetStateAction<any[]>>} 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,
|
||
};
|
||
};
|