Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.
 
 
 

412 řádky
11 KiB

  1. /*
  2. Copyright (C) 2025 QuantumNous
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU Affero General Public License as
  5. published by the Free Software Foundation, either version 3 of the
  6. License, or (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU Affero General Public License for more details.
  11. You should have received a copy of the GNU Affero General Public License
  12. along with this program. If not, see <https://www.gnu.org/licenses/>.
  13. For commercial licensing, please contact support@quantumnous.com
  14. */
  15. import { useState, useEffect, useContext, useRef, useMemo } from 'react';
  16. import { useTranslation } from 'react-i18next';
  17. import { API, copy, showError, showInfo, showSuccess } from '../../helpers';
  18. import { Modal } from '@douyinfe/semi-ui';
  19. import { UserContext } from '../../context/User';
  20. import { StatusContext } from '../../context/Status';
  21. export const useModelPricingData = () => {
  22. const { t } = useTranslation();
  23. const [searchValue, setSearchValue] = useState('');
  24. const compositionRef = useRef({ isComposition: false });
  25. const [selectedRowKeys, setSelectedRowKeys] = useState([]);
  26. const [modalImageUrl, setModalImageUrl] = useState('');
  27. const [isModalOpenurl, setIsModalOpenurl] = useState(false);
  28. const [selectedGroup, setSelectedGroup] = useState('all');
  29. const [showModelDetail, setShowModelDetail] = useState(false);
  30. const [selectedModel, setSelectedModel] = useState(null);
  31. const [filterGroup, setFilterGroup] = useState('all'); // 用于 Table 的可用分组筛选,"all" 表示不过滤
  32. const [filterQuotaType, setFilterQuotaType] = useState('all'); // 计费类型筛选: 'all' | 0 | 1
  33. const [filterEndpointType, setFilterEndpointType] = useState('all'); // 端点类型筛选: 'all' | string
  34. const [filterVendor, setFilterVendor] = useState('all'); // 供应商筛选: 'all' | 'unknown' | string
  35. const [filterTag, setFilterTag] = useState('all'); // 模型标签筛选: 'all' | string
  36. const [filterType, setFilterType] = useState('all'); // 模型类型筛选: 'all' | number (model_meta.ModelType)
  37. const [pageSize, setPageSize] = useState(20);
  38. const [currentPage, setCurrentPage] = useState(1);
  39. const [currency, setCurrency] = useState('USD');
  40. const [showWithRecharge, setShowWithRecharge] = useState(false);
  41. const [tokenUnit, setTokenUnit] = useState('M');
  42. const [models, setModels] = useState([]);
  43. const [vendorsMap, setVendorsMap] = useState({});
  44. const [loading, setLoading] = useState(true);
  45. const [groupRatio, setGroupRatio] = useState({});
  46. const [usableGroup, setUsableGroup] = useState({});
  47. const [endpointMap, setEndpointMap] = useState({});
  48. const [autoGroups, setAutoGroups] = useState([]);
  49. const [statusState] = useContext(StatusContext);
  50. const [userState] = useContext(UserContext);
  51. // 充值汇率(price)与美元兑人民币汇率(usd_exchange_rate)
  52. const priceRate = useMemo(
  53. () => statusState?.status?.price ?? 1,
  54. [statusState],
  55. );
  56. const usdExchangeRate = useMemo(
  57. () => statusState?.status?.usd_exchange_rate ?? priceRate,
  58. [statusState, priceRate],
  59. );
  60. const customExchangeRate = useMemo(
  61. () => statusState?.status?.custom_currency_exchange_rate ?? 1,
  62. [statusState],
  63. );
  64. const customCurrencySymbol = useMemo(
  65. () => statusState?.status?.custom_currency_symbol ?? '¤',
  66. [statusState],
  67. );
  68. // 默认货币与站点展示类型同步(USD/CNY),TOKENS 时仍允许切换视图内货币
  69. const siteDisplayType = useMemo(
  70. () => statusState?.status?.quota_display_type || 'USD',
  71. [statusState],
  72. );
  73. useEffect(() => {
  74. if (
  75. siteDisplayType === 'USD' ||
  76. siteDisplayType === 'CNY' ||
  77. siteDisplayType === 'CUSTOM'
  78. ) {
  79. setCurrency(siteDisplayType);
  80. }
  81. }, [siteDisplayType]);
  82. const filteredModels = useMemo(() => {
  83. let result = models;
  84. // 分组筛选
  85. if (filterGroup !== 'all') {
  86. result = result.filter((model) =>
  87. model.enable_groups.includes(filterGroup),
  88. );
  89. }
  90. // 计费类型筛选
  91. if (filterQuotaType !== 'all') {
  92. result = result.filter((model) => model.quota_type === filterQuotaType);
  93. }
  94. // 端点类型筛选
  95. if (filterEndpointType !== 'all') {
  96. result = result.filter(
  97. (model) =>
  98. model.supported_endpoint_types &&
  99. model.supported_endpoint_types.includes(filterEndpointType),
  100. );
  101. }
  102. // 供应商筛选
  103. if (filterVendor !== 'all') {
  104. if (filterVendor === 'unknown') {
  105. result = result.filter((model) => !model.vendor_name);
  106. } else {
  107. result = result.filter((model) => model.vendor_name === filterVendor);
  108. }
  109. }
  110. // 标签筛选
  111. if (filterTag !== 'all') {
  112. const tagLower = filterTag.toLowerCase();
  113. result = result.filter((model) => {
  114. if (!model.tags) return false;
  115. const tagsArr = model.tags
  116. .toLowerCase()
  117. .split(/[,;|]+/)
  118. .map((tag) => tag.trim())
  119. .filter(Boolean);
  120. return tagsArr.includes(tagLower);
  121. });
  122. }
  123. // 模型类型筛选(pricing.Type / model.Type)
  124. if (filterType !== 'all') {
  125. const typeNum = Number(filterType);
  126. result = result.filter((model) => model.type === typeNum);
  127. }
  128. // 搜索筛选
  129. if (searchValue.length > 0) {
  130. const searchTerm = searchValue.toLowerCase();
  131. result = result.filter(
  132. (model) =>
  133. (model.model_name &&
  134. model.model_name.toLowerCase().includes(searchTerm)) ||
  135. (model.description &&
  136. model.description.toLowerCase().includes(searchTerm)) ||
  137. (model.tags && model.tags.toLowerCase().includes(searchTerm)) ||
  138. (model.vendor_name &&
  139. model.vendor_name.toLowerCase().includes(searchTerm)),
  140. );
  141. }
  142. return result;
  143. }, [
  144. models,
  145. searchValue,
  146. filterGroup,
  147. filterQuotaType,
  148. filterEndpointType,
  149. filterVendor,
  150. filterTag,
  151. filterType,
  152. ]);
  153. const rowSelection = useMemo(
  154. () => ({
  155. selectedRowKeys,
  156. onChange: (keys) => {
  157. setSelectedRowKeys(keys);
  158. },
  159. }),
  160. [selectedRowKeys],
  161. );
  162. const displayPrice = (usdPrice) => {
  163. let priceInUSD = usdPrice;
  164. if (showWithRecharge) {
  165. priceInUSD = (usdPrice * priceRate) / usdExchangeRate;
  166. }
  167. if (currency === 'CNY') {
  168. return `¥${(priceInUSD * usdExchangeRate).toFixed(3)}`;
  169. } else if (currency === 'CUSTOM') {
  170. return `${customCurrencySymbol}${(priceInUSD * customExchangeRate).toFixed(3)}`;
  171. }
  172. return `$${priceInUSD.toFixed(3)}`;
  173. };
  174. const setModelsFormat = (models, groupRatio, vendorMap) => {
  175. for (let i = 0; i < models.length; i++) {
  176. const m = models[i];
  177. m.key = m.model_name;
  178. m.group_ratio = groupRatio[m.model_name];
  179. if (m.vendor_id && vendorMap[m.vendor_id]) {
  180. const vendor = vendorMap[m.vendor_id];
  181. m.vendor_name = vendor.name;
  182. m.vendor_icon = vendor.icon;
  183. m.vendor_description = vendor.description;
  184. }
  185. }
  186. models.sort((a, b) => {
  187. return a.quota_type - b.quota_type;
  188. });
  189. models.sort((a, b) => {
  190. if (a.model_name.startsWith('gpt') && !b.model_name.startsWith('gpt')) {
  191. return -1;
  192. } else if (
  193. !a.model_name.startsWith('gpt') &&
  194. b.model_name.startsWith('gpt')
  195. ) {
  196. return 1;
  197. } else {
  198. return a.model_name.localeCompare(b.model_name);
  199. }
  200. });
  201. setModels(models);
  202. };
  203. const loadPricing = async () => {
  204. setLoading(true);
  205. let url = '/api/pricing';
  206. const res = await API.get(url);
  207. const {
  208. success,
  209. message,
  210. data,
  211. vendors,
  212. group_ratio,
  213. usable_group,
  214. supported_endpoint,
  215. auto_groups,
  216. } = res.data;
  217. if (success) {
  218. setGroupRatio(group_ratio);
  219. setUsableGroup(usable_group);
  220. setSelectedGroup('all');
  221. // 构建供应商 Map 方便查找
  222. const vendorMap = {};
  223. if (Array.isArray(vendors)) {
  224. vendors.forEach((v) => {
  225. vendorMap[v.id] = v;
  226. });
  227. }
  228. setVendorsMap(vendorMap);
  229. setEndpointMap(supported_endpoint || {});
  230. setAutoGroups(auto_groups || []);
  231. setModelsFormat(data, group_ratio, vendorMap);
  232. } else {
  233. showError(message);
  234. }
  235. setLoading(false);
  236. };
  237. const refresh = async () => {
  238. await loadPricing();
  239. };
  240. const copyText = async (text) => {
  241. if (await copy(text)) {
  242. showSuccess(t('已复制:') + text);
  243. } else {
  244. Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
  245. }
  246. };
  247. const handleChange = (value) => {
  248. const newSearchValue = value ? value : '';
  249. setSearchValue(newSearchValue);
  250. };
  251. const handleCompositionStart = () => {
  252. compositionRef.current.isComposition = true;
  253. };
  254. const handleCompositionEnd = (event) => {
  255. compositionRef.current.isComposition = false;
  256. const value = event.target.value;
  257. const newSearchValue = value ? value : '';
  258. setSearchValue(newSearchValue);
  259. };
  260. const handleGroupClick = (group) => {
  261. setSelectedGroup(group);
  262. setFilterGroup(group);
  263. if (group === 'all') {
  264. showInfo(t('已切换至最优倍率视图,每个模型使用其最低倍率分组'));
  265. } else {
  266. showInfo(
  267. t('当前查看的分组为:{{group}},倍率为:{{ratio}}', {
  268. group: group,
  269. ratio: groupRatio[group] ?? 1,
  270. }),
  271. );
  272. }
  273. };
  274. const openModelDetail = (model) => {
  275. setSelectedModel(model);
  276. setShowModelDetail(true);
  277. };
  278. const closeModelDetail = () => {
  279. setShowModelDetail(false);
  280. setTimeout(() => {
  281. setSelectedModel(null);
  282. }, 300);
  283. };
  284. useEffect(() => {
  285. refresh().then();
  286. }, []);
  287. // 当筛选条件变化时重置到第一页
  288. useEffect(() => {
  289. setCurrentPage(1);
  290. }, [
  291. filterGroup,
  292. filterQuotaType,
  293. filterEndpointType,
  294. filterVendor,
  295. filterTag,
  296. filterType,
  297. searchValue,
  298. ]);
  299. return {
  300. // 状态
  301. searchValue,
  302. setSearchValue,
  303. selectedRowKeys,
  304. setSelectedRowKeys,
  305. modalImageUrl,
  306. setModalImageUrl,
  307. isModalOpenurl,
  308. setIsModalOpenurl,
  309. selectedGroup,
  310. setSelectedGroup,
  311. showModelDetail,
  312. setShowModelDetail,
  313. selectedModel,
  314. setSelectedModel,
  315. filterGroup,
  316. setFilterGroup,
  317. filterQuotaType,
  318. setFilterQuotaType,
  319. filterEndpointType,
  320. setFilterEndpointType,
  321. filterVendor,
  322. setFilterVendor,
  323. filterTag,
  324. setFilterTag,
  325. filterType,
  326. setFilterType,
  327. pageSize,
  328. setPageSize,
  329. currentPage,
  330. setCurrentPage,
  331. currency,
  332. setCurrency,
  333. showWithRecharge,
  334. setShowWithRecharge,
  335. tokenUnit,
  336. setTokenUnit,
  337. models,
  338. loading,
  339. groupRatio,
  340. usableGroup,
  341. endpointMap,
  342. autoGroups,
  343. // 计算属性
  344. priceRate,
  345. usdExchangeRate,
  346. filteredModels,
  347. rowSelection,
  348. // 供应商
  349. vendorsMap,
  350. // 用户和状态
  351. userState,
  352. statusState,
  353. // 方法
  354. displayPrice,
  355. refresh,
  356. copyText,
  357. handleChange,
  358. handleCompositionStart,
  359. handleCompositionEnd,
  360. handleGroupClick,
  361. openModelDetail,
  362. closeModelDetail,
  363. // 引用
  364. compositionRef,
  365. // 国际化
  366. t,
  367. };
  368. };