25개 이상의 토픽을 선택하실 수 없습니다. Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

427 lines
12 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 {
  16. getUserIdFromLocalStorage,
  17. showError,
  18. formatMessageForAPI,
  19. isValidMessage,
  20. } from './utils';
  21. import axios from 'axios';
  22. import i18next from 'i18next';
  23. import { MESSAGE_ROLES } from '../constants/playground.constants';
  24. export let API = axios.create({
  25. baseURL: import.meta.env.VITE_REACT_APP_SERVER_URL
  26. ? import.meta.env.VITE_REACT_APP_SERVER_URL
  27. : '',
  28. headers: {
  29. 'New-API-User': getUserIdFromLocalStorage(),
  30. 'Cache-Control': 'no-store',
  31. },
  32. });
  33. function patchAPIInstance(instance) {
  34. const originalGet = instance.get.bind(instance);
  35. const inFlightGetRequests = new Map();
  36. const genKey = (url, config = {}) => {
  37. const params = config.params ? JSON.stringify(config.params) : '{}';
  38. return `${url}?${params}`;
  39. };
  40. instance.get = (url, config = {}) => {
  41. if (config?.disableDuplicate) {
  42. return originalGet(url, config);
  43. }
  44. const key = genKey(url, config);
  45. if (inFlightGetRequests.has(key)) {
  46. return inFlightGetRequests.get(key);
  47. }
  48. const reqPromise = originalGet(url, config).finally(() => {
  49. inFlightGetRequests.delete(key);
  50. });
  51. inFlightGetRequests.set(key, reqPromise);
  52. return reqPromise;
  53. };
  54. }
  55. patchAPIInstance(API);
  56. export function updateAPI() {
  57. API = axios.create({
  58. baseURL: import.meta.env.VITE_REACT_APP_SERVER_URL
  59. ? import.meta.env.VITE_REACT_APP_SERVER_URL
  60. : '',
  61. headers: {
  62. 'New-API-User': getUserIdFromLocalStorage(),
  63. 'Cache-Control': 'no-store',
  64. },
  65. });
  66. patchAPIInstance(API);
  67. }
  68. API.interceptors.response.use(
  69. (response) => response,
  70. (error) => {
  71. // 如果请求配置中显式要求跳过全局错误处理,则不弹出默认错误提示
  72. if (error.config && error.config.skipErrorHandler) {
  73. return Promise.reject(error);
  74. }
  75. showError(error);
  76. return Promise.reject(error);
  77. },
  78. );
  79. // playground
  80. // 构建API请求负载
  81. export const buildApiPayload = (
  82. messages,
  83. systemPrompt,
  84. inputs,
  85. parameterEnabled,
  86. ) => {
  87. const processedMessages = messages
  88. .filter(isValidMessage)
  89. .map(formatMessageForAPI)
  90. .filter(Boolean);
  91. // 如果有系统提示,插入到消息开头
  92. if (systemPrompt && systemPrompt.trim()) {
  93. processedMessages.unshift({
  94. role: MESSAGE_ROLES.SYSTEM,
  95. content: systemPrompt.trim(),
  96. });
  97. }
  98. const payload = {
  99. model: inputs.model,
  100. group: inputs.group,
  101. messages: processedMessages,
  102. stream: inputs.stream,
  103. };
  104. // 添加启用的参数
  105. const parameterMappings = {
  106. temperature: 'temperature',
  107. top_p: 'top_p',
  108. max_tokens: 'max_tokens',
  109. frequency_penalty: 'frequency_penalty',
  110. presence_penalty: 'presence_penalty',
  111. seed: 'seed',
  112. };
  113. Object.entries(parameterMappings).forEach(([key, param]) => {
  114. const enabled = parameterEnabled[key];
  115. const value = inputs[param];
  116. const hasValue = value !== undefined && value !== null;
  117. if (enabled && hasValue) {
  118. payload[param] = value;
  119. }
  120. });
  121. return payload;
  122. };
  123. // 处理API错误响应
  124. export const handleApiError = (error, response = null) => {
  125. const errorInfo = {
  126. error: error.message || i18next.t('未知错误'),
  127. timestamp: new Date().toISOString(),
  128. stack: error.stack,
  129. };
  130. if (response) {
  131. errorInfo.status = response.status;
  132. errorInfo.statusText = response.statusText;
  133. }
  134. if (error.message.includes('HTTP error')) {
  135. errorInfo.details = i18next.t('服务器返回了错误状态码');
  136. } else if (error.message.includes('Failed to fetch')) {
  137. errorInfo.details = i18next.t('网络连接失败或服务器无响应');
  138. }
  139. return errorInfo;
  140. };
  141. // 处理模型数据
  142. export const processModelsData = (data, currentModel) => {
  143. const modelOptions = data.map((model) => ({
  144. label: model,
  145. value: model,
  146. }));
  147. const hasCurrentModel = modelOptions.some(
  148. (option) => option.value === currentModel,
  149. );
  150. const selectedModel =
  151. hasCurrentModel && modelOptions.length > 0
  152. ? currentModel
  153. : modelOptions[0]?.value;
  154. return { modelOptions, selectedModel };
  155. };
  156. // 处理分组数据
  157. export const processGroupsData = (data, userGroup) => {
  158. let groupOptions = Object.entries(data).map(([group, info]) => ({
  159. label:
  160. info.desc.length > 20 ? info.desc.substring(0, 20) + '...' : info.desc,
  161. value: group,
  162. ratio: info.ratio,
  163. fullLabel: info.desc,
  164. }));
  165. if (groupOptions.length === 0) {
  166. groupOptions = [
  167. {
  168. label: i18next.t('用户分组'),
  169. value: '',
  170. ratio: 1,
  171. },
  172. ];
  173. } else if (userGroup) {
  174. const userGroupIndex = groupOptions.findIndex((g) => g.value === userGroup);
  175. if (userGroupIndex > -1) {
  176. const userGroupOption = groupOptions.splice(userGroupIndex, 1)[0];
  177. groupOptions.unshift(userGroupOption);
  178. }
  179. }
  180. return groupOptions;
  181. };
  182. // 原来components中的utils.js
  183. export async function getOAuthState() {
  184. let path = '/api/oauth/state';
  185. let affCode = localStorage.getItem('aff');
  186. if (affCode && affCode.length > 0) {
  187. path += `?aff=${affCode}`;
  188. }
  189. const res = await API.get(path);
  190. const { success, message, data } = res.data;
  191. if (success) {
  192. return data;
  193. } else {
  194. showError(message);
  195. return '';
  196. }
  197. }
  198. async function prepareOAuthState(options = {}) {
  199. const { shouldLogout = false } = options;
  200. if (shouldLogout) {
  201. try {
  202. await API.get('/api/user/logout', { skipErrorHandler: true });
  203. } catch (err) {}
  204. localStorage.removeItem('user');
  205. updateAPI();
  206. }
  207. return await getOAuthState();
  208. }
  209. export async function onDiscordOAuthClicked(client_id, options = {}) {
  210. const state = await prepareOAuthState(options);
  211. if (!state) return;
  212. const redirect_uri = `${window.location.origin}/oauth/discord`;
  213. const response_type = 'code';
  214. const scope = 'identify+openid';
  215. window.open(
  216. `https://discord.com/oauth2/authorize?client_id=${client_id}&redirect_uri=${redirect_uri}&response_type=${response_type}&scope=${scope}&state=${state}`,
  217. );
  218. }
  219. export async function onOIDCClicked(
  220. auth_url,
  221. client_id,
  222. openInNewTab = false,
  223. options = {},
  224. ) {
  225. const state = await prepareOAuthState(options);
  226. if (!state) return;
  227. const url = new URL(auth_url);
  228. url.searchParams.set('client_id', client_id);
  229. url.searchParams.set('redirect_uri', `${window.location.origin}/oauth/oidc`);
  230. url.searchParams.set('response_type', 'code');
  231. url.searchParams.set('scope', 'openid profile email');
  232. url.searchParams.set('state', state);
  233. if (openInNewTab) {
  234. window.open(url.toString(), '_blank');
  235. } else {
  236. window.location.href = url.toString();
  237. }
  238. }
  239. export async function onGitHubOAuthClicked(github_client_id, options = {}) {
  240. const state = await prepareOAuthState(options);
  241. if (!state) return;
  242. window.open(
  243. `https://github.com/login/oauth/authorize?client_id=${github_client_id}&state=${state}&scope=user:email`,
  244. );
  245. }
  246. export async function onLinuxDOOAuthClicked(
  247. linuxdo_client_id,
  248. options = { shouldLogout: false },
  249. ) {
  250. const state = await prepareOAuthState(options);
  251. if (!state) return;
  252. window.open(
  253. `https://connect.linux.do/oauth2/authorize?response_type=code&client_id=${linuxdo_client_id}&state=${state}`,
  254. );
  255. }
  256. /**
  257. * Initiate custom OAuth login
  258. * @param {Object} provider - Custom OAuth provider config from status API
  259. * @param {string} provider.slug - Provider slug (used for callback URL)
  260. * @param {string} provider.client_id - OAuth client ID
  261. * @param {string} provider.authorization_endpoint - Authorization URL
  262. * @param {string} provider.scopes - OAuth scopes (space-separated)
  263. * @param {Object} options - Options
  264. * @param {boolean} options.shouldLogout - Whether to logout first
  265. */
  266. export async function onCustomOAuthClicked(provider, options = {}) {
  267. const state = await prepareOAuthState(options);
  268. if (!state) return;
  269. try {
  270. const redirect_uri = `${window.location.origin}/oauth/${provider.slug}`;
  271. // Check if authorization_endpoint is a full URL or relative path
  272. let authUrl;
  273. if (
  274. provider.authorization_endpoint.startsWith('http://') ||
  275. provider.authorization_endpoint.startsWith('https://')
  276. ) {
  277. authUrl = new URL(provider.authorization_endpoint);
  278. } else {
  279. // Relative path - this is a configuration error, show error message
  280. console.error(
  281. 'Custom OAuth authorization_endpoint must be a full URL:',
  282. provider.authorization_endpoint,
  283. );
  284. showError(
  285. i18next.t('OAuth 配置错误:授权端点必须是完整的 URL(以 http:// 或 https:// 开头)'),
  286. );
  287. return;
  288. }
  289. authUrl.searchParams.set('client_id', provider.client_id);
  290. authUrl.searchParams.set('redirect_uri', redirect_uri);
  291. authUrl.searchParams.set('response_type', 'code');
  292. authUrl.searchParams.set(
  293. 'scope',
  294. provider.scopes || 'openid profile email',
  295. );
  296. authUrl.searchParams.set('state', state);
  297. window.open(authUrl.toString());
  298. } catch (error) {
  299. console.error('Failed to initiate custom OAuth:', error);
  300. showError(i18next.t('OAuth 登录失败:') + (error.message || i18next.t('未知错误')));
  301. }
  302. }
  303. let channelModels = undefined;
  304. export async function loadChannelModels() {
  305. const res = await API.get('/api/models');
  306. const { success, data } = res.data;
  307. if (!success) {
  308. return;
  309. }
  310. channelModels = data;
  311. localStorage.setItem('channel_models', JSON.stringify(data));
  312. }
  313. export function getChannelModels(type) {
  314. if (channelModels !== undefined && type in channelModels) {
  315. if (!channelModels[type]) {
  316. return [];
  317. }
  318. return channelModels[type];
  319. }
  320. let models = localStorage.getItem('channel_models');
  321. if (!models) {
  322. return [];
  323. }
  324. channelModels = JSON.parse(models);
  325. if (type in channelModels) {
  326. return channelModels[type];
  327. }
  328. return [];
  329. }
  330. // 渠道定价 API
  331. export const channelPricingApi = {
  332. // 获取所有渠道定价
  333. getAll: (page = 1, pageSize = 10) =>
  334. API.get(`/api/channel_pricing/?p=${page}&page_size=${pageSize}`),
  335. // 获取渠道定价(带标签)
  336. getWithTags: (page = 1, pageSize = 10) =>
  337. API.get(`/api/channel_pricing/with_tags?p=${page}&page_size=${pageSize}`),
  338. // 获取指定模型的渠道定价
  339. getByModel: (modelName) => API.get(`/api/channel_pricing/model/${modelName}`),
  340. // 创建/更新渠道定价
  341. create: (data) => API.post('/api/channel_pricing/', data),
  342. // 批量创建
  343. batchCreate: (items) => API.post('/api/channel_pricing/batch', { items }),
  344. // 删除
  345. delete: (id) => API.delete(`/api/channel_pricing/${id}`),
  346. // 复制全局定价
  347. copyGlobal: (channelId, overwrite = false) =>
  348. API.post(`/api/channel_pricing/copy_global/${channelId}`, { overwrite }),
  349. // 设置默认通道
  350. setDefault: (modelName, channelId) =>
  351. API.post('/api/channel_pricing/set_default', { model_name: modelName, channel_id: channelId }),
  352. // 清除默认通道
  353. clearDefault: (modelName) =>
  354. API.delete(`/api/channel_pricing/default/${encodeURIComponent(modelName)}`),
  355. };
  356. // 定价标签 API
  357. export const pricingTagApi = {
  358. getAll: () => API.get('/api/pricing_tag/'),
  359. create: (data) => API.post('/api/pricing_tag/', data),
  360. update: (id, data) => API.put(`/api/pricing_tag/${id}`, data),
  361. delete: (id) => API.delete(`/api/pricing_tag/${id}`),
  362. };
  363. // 用户模型限流 API
  364. export const userRateLimitApi = {
  365. get: (userId) => API.get(`/api/user/${userId}/rate-limits`),
  366. set: (userId, items) => API.put(`/api/user/${userId}/rate-limits`, items),
  367. };