modelApi.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608
  1. import { Model } from '../types/models';
  2. import { authService } from './authService';
  3. // API Base URL - 从环境变量获取,必须配置
  4. const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
  5. if (!API_BASE_URL) {
  6. console.error('VITE_API_BASE_URL 环境变量未配置,请在 .env 文件中设置 VITE_API_BASE_URL');
  7. throw new Error('VITE_API_BASE_URL 环境变量未配置');
  8. }
  9. // API 响应类型定义
  10. export interface ApiResponse<T> {
  11. code: number;
  12. message: string;
  13. data: T;
  14. }
  15. // 后端API响应类型(对应新表 models_new)
  16. export interface BackendModelResponse {
  17. id: number;
  18. model_code: string;
  19. display_name: string | null;
  20. img: string | null;
  21. tag1: string | null;
  22. tag2: string | null;
  23. description: string | null;
  24. custom_description: string | null;
  25. keyword: string | null;
  26. keywords: string | null;
  27. categories: number[];
  28. category: number;
  29. supplier: string;
  30. is_featured: boolean;
  31. is_api_enabled: boolean;
  32. is_show_enabled: boolean;
  33. created_at: string;
  34. updated_at: string;
  35. // 前端兼容字段(后端 model_service 里有映射)
  36. title: string;
  37. name: string;
  38. time: string | null;
  39. price_id: number | null;
  40. pricing_mode: string | null;
  41. input_price: string | null;
  42. output_price: string | null;
  43. price_unit: string | null;
  44. price_currency: string | null;
  45. price_tiers: any[];
  46. }
  47. // 精选模型列表响应类型
  48. export interface FeaturedModelsResponse {
  49. items: BackendModelResponse[];
  50. }
  51. export interface ParsedPricingResponse {
  52. model_code: string;
  53. model_intro?: string | null;
  54. model_tags?: string[] | null;
  55. model_capabilities?: any | null;
  56. model_pricing?: any | null;
  57. tool_call_pricing?: any | null;
  58. model_limits?: any | null;
  59. api_examples?: any | null;
  60. is_api_enabled?: boolean;
  61. categories?: number[];
  62. }
  63. export interface ParsedPricingMappingItem {
  64. source_key: string;
  65. normalized_key: string;
  66. model_code: string;
  67. }
  68. export interface ParsedPricingMappingResponse {
  69. items: ParsedPricingMappingItem[];
  70. }
  71. export interface BackendPaginatedResponse {
  72. total: number;
  73. page: number;
  74. page_size: number;
  75. items: BackendModelResponse[];
  76. }
  77. export interface Pagination {
  78. page: number;
  79. pageSize: number;
  80. total: number;
  81. totalPages: number;
  82. }
  83. export interface ModelListResponse {
  84. list: Model[];
  85. pagination: Pagination;
  86. }
  87. export interface ModelDetailResponse extends Model {
  88. apiEndpoint?: string;
  89. apiDocumentation?: string;
  90. pricing?: {
  91. type: string;
  92. unit: string;
  93. price: number;
  94. };
  95. capabilities?: string[];
  96. limitations?: string[];
  97. }
  98. // 请求参数接口
  99. export interface GetModelsParams {
  100. keyword?: string;
  101. page?: number;
  102. pageSize?: number;
  103. category?: string;
  104. supplier?: string; // 供应商筛选(对应后端 supplier 参数)
  105. group_name?: string; // 模型分组筛选(对应后端 group_name 参数)
  106. filter_keyword?: string; // 关键字筛选(对应后端 filter_keyword 参数)
  107. filter_tag?: string; // 标签筛选(对应后端 filter_tag 参数)
  108. is_api_enabled?: boolean; // 是否支持API调用筛选
  109. // 以下为前端本地筛选参数
  110. isNew?: boolean;
  111. isHot?: boolean;
  112. sortBy?: string;
  113. order?: 'asc' | 'desc';
  114. }
  115. // 模型分类映射到分类字符串(支持多分类,取优先级最高的)
  116. const CATEGORY_NAMES: Record<number, string> = {
  117. 0: '语言模型', 1: '多模态模型', 2: 'TTS模型', 3: 'STT模型',
  118. 4: '生图模型', 5: '生视频模型', 6: '图像编辑', 7: 'Embedding', 8: 'Rerank'
  119. };
  120. const getModelCategory = (categories: number[], category?: number): string => {
  121. const cats = categories?.length ? categories : (category !== undefined ? [category] : [0]);
  122. const priority = [6, 5, 4, 3, 2, 8, 7, 1, 0];
  123. const main = priority.find(c => cats.includes(c)) ?? cats[0] ?? 0;
  124. return CATEGORY_NAMES[main] || '语言模型';
  125. };
  126. // 前端分类名称映射到后端 category 数值
  127. const categoryNameToNumber: Record<string, number> = {
  128. '语言模型': 0, '多模态模型': 1, 'TTS模型': 2, 'STT模型': 3,
  129. '生图模型': 4, '生视频模型': 5, '图像编辑': 6, 'Embedding': 7, 'Rerank': 8
  130. };
  131. // 将后端模型数据转换为前端Model格式(字段与 Model 类型对齐)
  132. function convertBackendModelToModel(backendModel: BackendModelResponse): Model {
  133. const cats = backendModel.categories?.length ? backendModel.categories : [backendModel.category ?? 0];
  134. return {
  135. id: backendModel.id,
  136. title: backendModel.model_code,
  137. img: backendModel.img || '',
  138. name: backendModel.display_name || backendModel.model_code,
  139. tag1: backendModel.tag1,
  140. description: backendModel.custom_description || backendModel.description,
  141. keyword: backendModel.keywords || backendModel.keyword,
  142. time: null,
  143. tag2: backendModel.tag2,
  144. category: cats[0],
  145. categories: cats,
  146. supplier: backendModel.supplier,
  147. price_id: null,
  148. is_featured: backendModel.is_featured,
  149. is_api_enabled: backendModel.is_api_enabled,
  150. created_at: backendModel.created_at,
  151. updated_at: backendModel.updated_at,
  152. pricing_mode: null,
  153. input_price: null,
  154. output_price: null,
  155. price_unit: null,
  156. price_currency: null,
  157. price_tiers: [],
  158. };
  159. }
  160. // 真实 API 调用
  161. class ModelApiService {
  162. private baseUrl = `${API_BASE_URL}/api/models`;
  163. /**
  164. * 获取请求头(包含鉴权信息)
  165. * @returns 请求头对象
  166. */
  167. private getHeaders(): Record<string, string> {
  168. const headers: Record<string, string> = {
  169. 'Content-Type': 'application/json'
  170. };
  171. // 始终添加 Authorization header(如果有token)
  172. const authHeader = authService.getAuthHeader();
  173. if (authHeader) {
  174. headers['Authorization'] = authHeader;
  175. }
  176. return headers;
  177. }
  178. /**
  179. * 处理 API 响应,检查鉴权错误
  180. * @param response 响应对象
  181. */
  182. private handleAuthError(response: ApiResponse<any>): void {
  183. if (response.code === 401) {
  184. // Token 过期或无效,清除本地存储并重定向到登录页
  185. authService.logout();
  186. // 实际项目中可以触发路由跳转到登录页
  187. if (typeof window !== 'undefined') {
  188. console.warn('Token 已过期,请重新登录');
  189. // window.location.href = '/login';
  190. }
  191. }
  192. }
  193. /**
  194. * 发送HTTP请求
  195. * @param url 请求URL
  196. * @param options 请求选项
  197. * @returns Promise<Response>
  198. */
  199. private async fetchApi(url: string, options: RequestInit = {}): Promise<Response> {
  200. const response = await fetch(url, {
  201. ...options,
  202. headers: {
  203. 'Content-Type': 'application/json',
  204. ...options.headers,
  205. },
  206. });
  207. if (!response.ok) {
  208. if (response.status === 401) {
  209. authService.logout();
  210. throw new Error('未授权:请先登录');
  211. }
  212. if (response.status === 404) {
  213. throw new Error('资源不存在');
  214. }
  215. throw new Error(`HTTP错误: ${response.status}`);
  216. }
  217. return response;
  218. }
  219. /**
  220. * 获取模型列表
  221. * API: GET /api/models
  222. * @param params 查询参数
  223. */
  224. async getModels(params: GetModelsParams = {}): Promise<ApiResponse<ModelListResponse>> {
  225. try {
  226. // 构建查询参数 - 使用后端API支持的参数
  227. const queryParams = new URLSearchParams();
  228. if (params.page) {
  229. queryParams.append('page', params.page.toString());
  230. }
  231. if (params.pageSize) {
  232. queryParams.append('page_size', params.pageSize.toString());
  233. }
  234. if (params.keyword) {
  235. queryParams.append('keyword', params.keyword);
  236. }
  237. // 分类筛选 - 直接传给后端处理
  238. if (params.category && params.category !== '全部') {
  239. queryParams.append('category', params.category);
  240. }
  241. // 供应商筛选 - 后端支持精确匹配
  242. if (params.supplier) {
  243. queryParams.append('supplier', params.supplier);
  244. }
  245. // 模型分组筛选 - 后端支持精确匹配
  246. if (params.group_name) {
  247. queryParams.append('group_name', params.group_name);
  248. }
  249. // 关键字筛选 - 后端支持精确匹配 keyword 字段
  250. if (params.filter_keyword) {
  251. queryParams.append('filter_keyword', params.filter_keyword);
  252. }
  253. // 标签筛选 - 后端支持在 tag1 或 tag2 中精确匹配
  254. if (params.filter_tag) {
  255. queryParams.append('filter_tag', params.filter_tag);
  256. }
  257. // API支持筛选
  258. if (params.is_api_enabled !== undefined) {
  259. queryParams.append('is_api_enabled', params.is_api_enabled.toString());
  260. }
  261. const url = `${this.baseUrl}?${queryParams.toString()}`;
  262. const headers = this.getHeaders();
  263. const response = await this.fetchApi(url, {
  264. method: 'GET',
  265. headers,
  266. });
  267. const apiResponse: ApiResponse<BackendPaginatedResponse> = await response.json();
  268. // 检查鉴权错误
  269. this.handleAuthError(apiResponse);
  270. if (apiResponse.code !== 200) {
  271. throw new Error(apiResponse.message || '获取模型列表失败');
  272. }
  273. // 转换后端数据格式为前端格式
  274. const convertedModels = apiResponse.data.items.map(convertBackendModelToModel);
  275. // 前端本地筛选(后端不支持的筛选条件)
  276. let filteredModels = [...convertedModels];
  277. // 最新模型筛选:基于 tag1 / tag2 推断
  278. if (params.isNew !== undefined) {
  279. filteredModels = filteredModels.filter(m =>
  280. params.isNew
  281. ? (m.tag1 === 'New' || m.tag2 === 'New')
  282. : !(m.tag1 === 'New' || m.tag2 === 'New')
  283. );
  284. }
  285. // 热门模型筛选:基于 is_featured 字段
  286. if (params.isHot !== undefined) {
  287. filteredModels = filteredModels.filter(m =>
  288. params.isHot ? m.is_featured : !m.is_featured
  289. );
  290. }
  291. // 排序
  292. const sortBy = params.sortBy || 'createdAt';
  293. const order = params.order || 'desc';
  294. filteredModels.sort((a, b) => {
  295. if (sortBy === 'name') {
  296. return order === 'asc'
  297. ? a.name.localeCompare(b.name)
  298. : b.name.localeCompare(a.name);
  299. }
  300. // 默认按创建时间排序
  301. return order === 'asc'
  302. ? new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
  303. : new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
  304. });
  305. // 使用后端返回的分页信息
  306. const page = apiResponse.data.page;
  307. const pageSize = apiResponse.data.page_size;
  308. // 如果有前端本地筛选,需要重新计算 total
  309. const hasLocalFilter = params.isNew !== undefined || params.isHot !== undefined;
  310. const total = hasLocalFilter ? filteredModels.length : apiResponse.data.total;
  311. const totalPages = Math.ceil(total / pageSize);
  312. // 如果进行了前端本地筛选,需要重新分页
  313. let paginatedModels = filteredModels;
  314. if (hasLocalFilter) {
  315. const startIndex = (page - 1) * pageSize;
  316. const endIndex = startIndex + pageSize;
  317. paginatedModels = filteredModels.slice(startIndex, endIndex);
  318. }
  319. return {
  320. code: 200,
  321. message: 'success',
  322. data: {
  323. list: paginatedModels,
  324. pagination: {
  325. page,
  326. pageSize,
  327. total,
  328. totalPages
  329. }
  330. }
  331. };
  332. } catch (error) {
  333. console.error('Failed to fetch models:', error);
  334. throw error;
  335. }
  336. }
  337. /**
  338. * 获取 parsed_json 中的模型定价信息
  339. * API: GET /api/models/pricing/{model_code}
  340. * @param modelCode 模型代码
  341. */
  342. async getParsedPricing(modelCode: string): Promise<ApiResponse<ParsedPricingResponse>> {
  343. const url = `${this.baseUrl}/pricing/${encodeURIComponent(modelCode)}`;
  344. const response = await this.fetchApi(url, {
  345. method: 'GET',
  346. headers: this.getHeaders()
  347. });
  348. const data = await response.json();
  349. this.handleAuthError(data);
  350. return data;
  351. }
  352. /**
  353. * 获取模型定价映射关系
  354. * API: GET /api/models/pricing/mapping
  355. */
  356. async getParsedPricingMapping(): Promise<ApiResponse<ParsedPricingMappingResponse>> {
  357. const url = `${this.baseUrl}/pricing/mapping`;
  358. const response = await this.fetchApi(url, {
  359. method: 'GET',
  360. headers: this.getHeaders()
  361. });
  362. const data = await response.json();
  363. this.handleAuthError(data);
  364. return data;
  365. }
  366. /**
  367. * 获取模型详情
  368. * API: GET /api/models/{modelId}
  369. * @param modelId 模型 ID(数字ID)
  370. */
  371. async getModelDetail(modelId: number): Promise<ApiResponse<ModelDetailResponse>> {
  372. try {
  373. const url = `${this.baseUrl}/${modelId}`;
  374. const headers = this.getHeaders();
  375. const response = await this.fetchApi(url, {
  376. method: 'GET',
  377. headers,
  378. });
  379. const apiResponse: ApiResponse<BackendModelResponse> = await response.json();
  380. // 检查鉴权错误
  381. this.handleAuthError(apiResponse);
  382. if (apiResponse.code !== 200) {
  383. throw new Error(apiResponse.message || '获取模型详情失败');
  384. }
  385. // 转换后端数据格式为前端格式
  386. const model = convertBackendModelToModel(apiResponse.data);
  387. const category = getModelCategory(model.category);
  388. // 扩展模型详情信息
  389. const detail: ModelDetailResponse = {
  390. ...model,
  391. apiEndpoint: `${API_BASE_URL}/api/models/${modelId}`,
  392. apiDocumentation: `${API_BASE_URL}/docs`,
  393. pricing: {
  394. type: 'usage',
  395. unit: 'token',
  396. price: 0.001
  397. },
  398. capabilities: [
  399. `${category}生成`,
  400. `${category}理解`,
  401. '多语言支持'
  402. ],
  403. limitations: [
  404. '最大上下文长度:128K tokens',
  405. '不支持图像输入'
  406. ]
  407. };
  408. return {
  409. code: 200,
  410. message: 'success',
  411. data: detail
  412. };
  413. } catch (error) {
  414. console.error('Failed to fetch model detail:', error);
  415. throw error;
  416. }
  417. }
  418. /**
  419. * 获取模型分类统计
  420. * 通过获取所有模型并统计分类
  421. */
  422. async getCategoryStatistics(): Promise<ApiResponse<Record<string, number>>> {
  423. try {
  424. // 获取所有模型(使用较大的page_size)
  425. const response = await this.getModels({ page: 1, pageSize: 1000 });
  426. const allModels = response.data.list;
  427. const hasCategory = (m: Model, c: number) => {
  428. const cats: number[] = (m as any).categories?.length ? (m as any).categories : [m.category];
  429. return cats.includes(c);
  430. };
  431. const statistics: Record<string, number> = {
  432. '全部': response.data.pagination.total,
  433. '语言模型': allModels.filter(m => hasCategory(m, 0)).length,
  434. '多模态模型': allModels.filter(m => hasCategory(m, 1)).length,
  435. 'TTS模型': allModels.filter(m => hasCategory(m, 2)).length,
  436. 'STT模型': allModels.filter(m => hasCategory(m, 3)).length,
  437. '生图模型': allModels.filter(m => hasCategory(m, 4)).length,
  438. '生视频模型': allModels.filter(m => hasCategory(m, 5)).length,
  439. '图像编辑': allModels.filter(m => hasCategory(m, 6)).length,
  440. 'Embedding': allModels.filter(m => hasCategory(m, 7)).length,
  441. 'Rerank': allModels.filter(m => hasCategory(m, 8)).length,
  442. };
  443. return {
  444. code: 200,
  445. message: 'success',
  446. data: statistics
  447. };
  448. } catch (error) {
  449. console.error('Failed to fetch category statistics:', error);
  450. throw error;
  451. }
  452. }
  453. /**
  454. * 获取精选模型列表
  455. * API: GET /api/models/featured/list
  456. * @param limit 返回数量,范围1-10,默认3
  457. */
  458. async getFeaturedModels(limit: number = 3): Promise<ApiResponse<{ items: Model[] }>> {
  459. try {
  460. const queryParams = new URLSearchParams();
  461. queryParams.append('limit', limit.toString());
  462. const url = `${this.baseUrl}/featured/list?${queryParams.toString()}`;
  463. const response = await this.fetchApi(url, {
  464. method: 'GET',
  465. });
  466. const apiResponse: ApiResponse<FeaturedModelsResponse> = await response.json();
  467. if (apiResponse.code !== 200) {
  468. throw new Error(apiResponse.message || '获取精选模型列表失败');
  469. }
  470. // 转换后端数据格式为前端格式
  471. const convertedModels = apiResponse.data.items.map(convertBackendModelToModel);
  472. return {
  473. code: 200,
  474. message: 'success',
  475. data: {
  476. items: convertedModels
  477. }
  478. };
  479. } catch (error) {
  480. console.error('Failed to fetch featured models:', error);
  481. throw error;
  482. }
  483. }
  484. /**
  485. * 获取所有模型分组名称(去重排序)
  486. * API: GET /api/models/group-names/list
  487. */
  488. async getGroupNames(): Promise<ApiResponse<string[]>> {
  489. try {
  490. const url = `${this.baseUrl}/group-names/list`;
  491. const response = await this.fetchApi(url, {
  492. method: 'GET',
  493. });
  494. const apiResponse: ApiResponse<string[]> = await response.json();
  495. if (apiResponse.code !== 200) {
  496. throw new Error(apiResponse.message || '获取分组列表失败');
  497. }
  498. return apiResponse;
  499. } catch (error) {
  500. console.error('Failed to fetch group names:', error);
  501. throw error;
  502. }
  503. }
  504. }
  505. // 导出单例
  506. export const modelApi = new ModelApiService();
  507. let pricingMappingCache: Map<string, string> | null = null;
  508. let pricingMappingPromise: Promise<Map<string, string>> | null = null;
  509. export const getPricingMappingLookup = async (): Promise<Map<string, string>> => {
  510. if (pricingMappingCache) {
  511. return pricingMappingCache;
  512. }
  513. if (pricingMappingPromise) {
  514. return pricingMappingPromise;
  515. }
  516. pricingMappingPromise = modelApi.getParsedPricingMapping()
  517. .then((response) => {
  518. const mapping = new Map<string, string>();
  519. if (response?.data?.items) {
  520. response.data.items.forEach((item) => {
  521. if (item.normalized_key && item.model_code) {
  522. mapping.set(item.normalized_key, item.model_code);
  523. }
  524. if (item.source_key && item.model_code) {
  525. mapping.set(item.source_key, item.model_code);
  526. }
  527. });
  528. }
  529. pricingMappingCache = mapping;
  530. return mapping;
  531. })
  532. .catch((error) => {
  533. pricingMappingPromise = null;
  534. throw error;
  535. });
  536. return pricingMappingPromise;
  537. };