modelApi.ts 18 KB

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