From da98972ddabcd7a1476e81a3bb511a23169c14f2 Mon Sep 17 00:00:00 2001 From: wzxjohn Date: Fri, 16 May 2025 16:44:47 +0800 Subject: [PATCH 001/243] feat: support UMAMI analytics --- main.go | 18 ++++++++++++++++++ web/index.html | 1 + 2 files changed, 19 insertions(+) diff --git a/main.go b/main.go index 95c6820d..e9f55b09 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "embed" "fmt" "log" @@ -15,6 +16,7 @@ import ( "one-api/setting/operation_setting" "os" "strconv" + "strings" "github.com/bytedance/gopkg/util/gopool" "github.com/gin-contrib/sessions" @@ -161,6 +163,22 @@ func main() { }) server.Use(sessions.Sessions("session", store)) + analyticsInjectBuilder := &strings.Builder{} + if os.Getenv("UMAMI_WEBSITE_ID") != "" { + umamiSiteID := os.Getenv("UMAMI_WEBSITE_ID") + umamiScriptURL := os.Getenv("UMAMI_SCRIPT_URL") + if umamiScriptURL == "" { + umamiScriptURL = "https://analytics.umami.is/script.js" + } + analyticsInjectBuilder.WriteString("") + } + analyticsInject := analyticsInjectBuilder.String() + indexPage = bytes.ReplaceAll(indexPage, []byte("\n"), []byte(analyticsInject)) + router.SetRouter(server, buildFS, indexPage) var port = os.Getenv("PORT") if port == "" { diff --git a/web/index.html b/web/index.html index 1e75f3d7..c6ce7b84 100644 --- a/web/index.html +++ b/web/index.html @@ -10,6 +10,7 @@ content="OpenAI 接口聚合管理,支持多种渠道包括 Azure,可用于二次分发管理 key,仅单可执行文件,已打包好 Docker 镜像,一键部署,开箱即用" /> New API + From 2488e6ab66dfaae716fc15b4cb5d6d567ccc3733 Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Sat, 19 Jul 2025 21:18:38 +0800 Subject: [PATCH 002/243] feat: add ali qwen channel autoDisabled --- service/channel.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/service/channel.go b/service/channel.go index 4d38e6ed..fe76fc21 100644 --- a/service/channel.go +++ b/service/channel.go @@ -67,6 +67,8 @@ func ShouldDisableChannel(channelType int, err *types.NewAPIError) bool { return true case "pre_consume_token_quota_failed": return true + case "Arrearage": + return true } switch oaiErr.Type { case "insufficient_quota": From ef0780c0968d78fa8971f3864e886edd3a53bc94 Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Sun, 10 Aug 2025 16:34:53 +0800 Subject: [PATCH 003/243] feat: if video cannot play open in a new tab --- .../table/task-logs/modals/ContentModal.jsx | 114 +++++++++++++++++- 1 file changed, 110 insertions(+), 4 deletions(-) diff --git a/web/src/components/table/task-logs/modals/ContentModal.jsx b/web/src/components/table/task-logs/modals/ContentModal.jsx index a6f16c98..fd17c206 100644 --- a/web/src/components/table/task-logs/modals/ContentModal.jsx +++ b/web/src/components/table/task-logs/modals/ContentModal.jsx @@ -17,8 +17,11 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React from 'react'; -import { Modal } from '@douyinfe/semi-ui'; +import React, { useState, useEffect } from 'react'; +import { Modal, Button, Typography, Spin } from '@douyinfe/semi-ui'; +import { IconExternalOpen, IconCopy } from '@douyinfe/semi-icons'; + +const { Text } = Typography; const ContentModal = ({ isModalOpen, @@ -26,17 +29,120 @@ const ContentModal = ({ modalContent, isVideo, }) => { + const [videoError, setVideoError] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (isModalOpen && isVideo) { + setVideoError(false); + setIsLoading(true); + } + }, [isModalOpen, isVideo]); + + const handleVideoError = () => { + setVideoError(true); + setIsLoading(false); + }; + + const handleVideoLoaded = () => { + setIsLoading(false); + }; + + const handleCopyUrl = () => { + navigator.clipboard.writeText(modalContent); + }; + + const handleOpenInNewTab = () => { + window.open(modalContent, '_blank'); + }; + + const renderVideoContent = () => { + if (videoError) { + return ( +
+ + 视频无法在当前浏览器中播放,这可能是由于: + + + • 视频服务商的跨域限制 + + + • 需要特定的请求头或认证 + + + • 防盗链保护机制 + + +
+ + +
+ +
+ + {modalContent} + +
+
+ ); + } + + return ( +
+ {isLoading && ( +
+ +
+ )} +
+ ); + }; + return ( setIsModalOpen(false)} onCancel={() => setIsModalOpen(false)} closable={null} - bodyStyle={{ height: '400px', overflow: 'auto' }} + bodyStyle={{ + height: isVideo ? '450px' : '400px', + overflow: 'auto', + padding: isVideo && videoError ? '0' : '24px' + }} width={800} > {isVideo ? ( - + ); +}; + +export default SecureVerificationModal; \ No newline at end of file diff --git a/web/src/components/settings/PersonalSetting.jsx b/web/src/components/settings/PersonalSetting.jsx index 15dfbd97..19a83515 100644 --- a/web/src/components/settings/PersonalSetting.jsx +++ b/web/src/components/settings/PersonalSetting.jsx @@ -26,6 +26,9 @@ import { showInfo, showSuccess, setStatusData, + prepareCredentialCreationOptions, + buildRegistrationResult, + isPasskeySupported, } from '../../helpers'; import { UserContext } from '../../context/User'; import { Modal } from '@douyinfe/semi-ui'; @@ -66,6 +69,10 @@ const PersonalSetting = () => { const [disableButton, setDisableButton] = useState(false); const [countdown, setCountdown] = useState(30); const [systemToken, setSystemToken] = useState(''); + const [passkeyStatus, setPasskeyStatus] = useState({ enabled: false }); + const [passkeyRegisterLoading, setPasskeyRegisterLoading] = useState(false); + const [passkeyDeleteLoading, setPasskeyDeleteLoading] = useState(false); + const [passkeySupported, setPasskeySupported] = useState(false); const [notificationSettings, setNotificationSettings] = useState({ warningType: 'email', warningThreshold: 100000, @@ -112,6 +119,10 @@ const PersonalSetting = () => { })(); getUserData(); + + isPasskeySupported() + .then(setPasskeySupported) + .catch(() => setPasskeySupported(false)); }, []); useEffect(() => { @@ -160,11 +171,89 @@ const PersonalSetting = () => { } }; + const loadPasskeyStatus = async () => { + try { + const res = await API.get('/api/user/passkey'); + const { success, data, message } = res.data; + if (success) { + setPasskeyStatus({ + enabled: data?.enabled || false, + last_used_at: data?.last_used_at || null, + backup_eligible: data?.backup_eligible || false, + backup_state: data?.backup_state || false, + }); + } else { + showError(message); + } + } catch (error) { + // 忽略错误,保留默认状态 + } + }; + + const handleRegisterPasskey = async () => { + if (!passkeySupported || !window.PublicKeyCredential) { + showInfo(t('当前设备不支持 Passkey')); + return; + } + setPasskeyRegisterLoading(true); + try { + const beginRes = await API.post('/api/user/passkey/register/begin'); + const { success, message, data } = beginRes.data; + if (!success) { + showError(message || t('无法发起 Passkey 注册')); + return; + } + + const publicKey = prepareCredentialCreationOptions(data?.options || data?.publicKey || data); + const credential = await navigator.credentials.create({ publicKey }); + const payload = buildRegistrationResult(credential); + if (!payload) { + showError(t('Passkey 注册失败,请重试')); + return; + } + + const finishRes = await API.post('/api/user/passkey/register/finish', payload); + if (finishRes.data.success) { + showSuccess(t('Passkey 注册成功')); + await loadPasskeyStatus(); + } else { + showError(finishRes.data.message || t('Passkey 注册失败,请重试')); + } + } catch (error) { + if (error?.name === 'AbortError') { + showInfo(t('已取消 Passkey 注册')); + } else { + showError(t('Passkey 注册失败,请重试')); + } + } finally { + setPasskeyRegisterLoading(false); + } + }; + + const handleRemovePasskey = async () => { + setPasskeyDeleteLoading(true); + try { + const res = await API.delete('/api/user/passkey'); + const { success, message } = res.data; + if (success) { + showSuccess(t('Passkey 已解绑')); + await loadPasskeyStatus(); + } else { + showError(message || t('操作失败,请重试')); + } + } catch (error) { + showError(t('操作失败,请重试')); + } finally { + setPasskeyDeleteLoading(false); + } + }; + const getUserData = async () => { let res = await API.get(`/api/user/self`); const { success, message, data } = res.data; if (success) { userDispatch({ type: 'login', payload: data }); + await loadPasskeyStatus(); } else { showError(message); } @@ -352,6 +441,12 @@ const PersonalSetting = () => { handleSystemTokenClick={handleSystemTokenClick} setShowChangePasswordModal={setShowChangePasswordModal} setShowAccountDeleteModal={setShowAccountDeleteModal} + passkeyStatus={passkeyStatus} + passkeySupported={passkeySupported} + passkeyRegisterLoading={passkeyRegisterLoading} + passkeyDeleteLoading={passkeyDeleteLoading} + onPasskeyRegister={handleRegisterPasskey} + onPasskeyDelete={handleRemovePasskey} /> {/* 右侧:其他设置 */} diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx index f9a2c019..abb55301 100644 --- a/web/src/components/settings/SystemSetting.jsx +++ b/web/src/components/settings/SystemSetting.jsx @@ -30,6 +30,7 @@ import { Spin, Card, Radio, + Select, } from '@douyinfe/semi-ui'; const { Text } = Typography; import { @@ -77,6 +78,13 @@ const SystemSetting = () => { TurnstileSiteKey: '', TurnstileSecretKey: '', RegisterEnabled: '', + 'passkey.enabled': '', + 'passkey.rp_display_name': '', + 'passkey.rp_id': '', + 'passkey.origins': [], + 'passkey.allow_insecure_origin': '', + 'passkey.user_verification': 'preferred', + 'passkey.attachment_preference': '', EmailDomainRestrictionEnabled: '', EmailAliasRestrictionEnabled: '', SMTPSSLEnabled: '', @@ -114,6 +122,7 @@ const SystemSetting = () => { const [domainList, setDomainList] = useState([]); const [ipList, setIpList] = useState([]); const [allowedPorts, setAllowedPorts] = useState([]); + const [passkeyOrigins, setPasskeyOrigins] = useState([]); const getOptions = async () => { setLoading(true); @@ -173,9 +182,28 @@ const SystemSetting = () => { case 'SMTPSSLEnabled': case 'LinuxDOOAuthEnabled': case 'oidc.enabled': + case 'passkey.enabled': + case 'passkey.allow_insecure_origin': case 'WorkerAllowHttpImageRequestEnabled': item.value = toBoolean(item.value); break; + case 'passkey.origins': + try { + const origins = item.value ? JSON.parse(item.value) : []; + setPasskeyOrigins(Array.isArray(origins) ? origins : []); + item.value = Array.isArray(origins) ? origins : []; + } catch (e) { + setPasskeyOrigins([]); + item.value = []; + } + break; + case 'passkey.rp_display_name': + case 'passkey.rp_id': + case 'passkey.user_verification': + case 'passkey.attachment_preference': + // 确保字符串字段不为null/undefined + item.value = item.value || ''; + break; case 'Price': case 'MinTopUp': item.value = parseFloat(item.value); @@ -582,6 +610,45 @@ const SystemSetting = () => { } }; + const submitPasskeySettings = async () => { + const options = []; + + // 只在值有变化时才提交,并确保空值转换为空字符串 + if (originInputs['passkey.rp_display_name'] !== inputs['passkey.rp_display_name']) { + options.push({ + key: 'passkey.rp_display_name', + value: inputs['passkey.rp_display_name'] || '', + }); + } + if (originInputs['passkey.rp_id'] !== inputs['passkey.rp_id']) { + options.push({ + key: 'passkey.rp_id', + value: inputs['passkey.rp_id'] || '', + }); + } + if (originInputs['passkey.user_verification'] !== inputs['passkey.user_verification']) { + options.push({ + key: 'passkey.user_verification', + value: inputs['passkey.user_verification'] || 'preferred', + }); + } + if (originInputs['passkey.attachment_preference'] !== inputs['passkey.attachment_preference']) { + options.push({ + key: 'passkey.attachment_preference', + value: inputs['passkey.attachment_preference'] || '', + }); + } + // Origins总是提交,因为它们可能会被用户清空 + options.push({ + key: 'passkey.origins', + value: JSON.stringify(Array.isArray(passkeyOrigins) ? passkeyOrigins : []), + }); + + if (options.length > 0) { + await updateOptions(options); + } + }; + const handleCheckboxChange = async (optionKey, event) => { const value = event.target.checked; @@ -957,6 +1024,126 @@ const SystemSetting = () => { + + + {t('用以支持基于 WebAuthn 的无密码登录注册')} + + + + + handleCheckboxChange('passkey.enabled', e) + } + > + {t('允许通过 Passkey 登录 & 注册')} + + + + + + + + + + + + + + + + + + + + + + + handleCheckboxChange('passkey.allow_insecure_origin', e) + } + > + {t('允许不安全的 Origin(HTTP)')} + + + + + + {t('允许的 Origins')} + + {t('留空将自动使用服务器地址,多个 Origin 用于支持多域名部署')} + + { + setPasskeyOrigins(value); + setInputs(prev => ({ + ...prev, + 'passkey.origins': value + })); + }} + placeholder={t('输入 Origin 后回车,如:https://example.com')} + style={{ width: '100%' }} + /> + + + + + + {t('用以防止恶意用户利用临时邮箱批量注册')} diff --git a/web/src/components/settings/personal/cards/AccountManagement.jsx b/web/src/components/settings/personal/cards/AccountManagement.jsx index 017e7c1e..b5baa55e 100644 --- a/web/src/components/settings/personal/cards/AccountManagement.jsx +++ b/web/src/components/settings/personal/cards/AccountManagement.jsx @@ -59,6 +59,12 @@ const AccountManagement = ({ handleSystemTokenClick, setShowChangePasswordModal, setShowAccountDeleteModal, + passkeyStatus, + passkeySupported, + passkeyRegisterLoading, + passkeyDeleteLoading, + onPasskeyRegister, + onPasskeyDelete, }) => { const renderAccountInfo = (accountId, label) => { if (!accountId || accountId === '') { @@ -86,6 +92,10 @@ const AccountManagement = ({ }; const isBound = (accountId) => Boolean(accountId); const [showTelegramBindModal, setShowTelegramBindModal] = React.useState(false); + const passkeyEnabled = passkeyStatus?.enabled; + const lastUsedLabel = passkeyStatus?.last_used_at + ? new Date(passkeyStatus.last_used_at).toLocaleString() + : t('尚未使用'); return ( @@ -476,6 +486,58 @@ const AccountManagement = ({ + {/* Passkey 设置 */} + +
+
+
+ +
+
+ + {t('Passkey 登录')} + + + {passkeyEnabled + ? t('已启用 Passkey,无需密码即可登录') + : t('使用 Passkey 实现免密且更安全的登录体验')} + +
+
+ {t('最后使用时间')}:{lastUsedLabel} +
+ {/*{passkeyEnabled && (*/} + {/*
*/} + {/* {t('备份支持')}:*/} + {/* {passkeyStatus?.backup_eligible*/} + {/* ? t('支持备份')*/} + {/* : t('不支持')}*/} + {/* ,{t('备份状态')}:*/} + {/* {passkeyStatus?.backup_state ? t('已备份') : t('未备份')}*/} + {/*
*/} + {/*)}*/} + {!passkeySupported && ( +
+ {t('当前设备不支持 Passkey')} +
+ )} +
+
+
+ +
+
+ {/* 两步验证设置 */} diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 2eb480e7..c049fdc2 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -56,8 +56,10 @@ import { } from '../../../../helpers'; import ModelSelectModal from './ModelSelectModal'; import JSONEditor from '../../../common/ui/JSONEditor'; -import TwoFactorAuthModal from '../../../common/modals/TwoFactorAuthModal'; +import SecureVerificationModal from '../../../common/modals/SecureVerificationModal'; import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay'; +import { useSecureVerification } from '../../../../hooks/common/useSecureVerification'; +import { createApiCalls } from '../../../../services/secureVerification'; import { IconSave, IconClose, @@ -193,43 +195,43 @@ const EditChannelModal = (props) => { const [keyMode, setKeyMode] = useState('append'); // 密钥模式:replace(覆盖)或 append(追加) const [isEnterpriseAccount, setIsEnterpriseAccount] = useState(false); // 是否为企业账户 - // 2FA验证查看密钥相关状态 - const [twoFAState, setTwoFAState] = useState({ + // 密钥显示状态 + const [keyDisplayState, setKeyDisplayState] = useState({ showModal: false, - code: '', - loading: false, - showKey: false, keyData: '', }); - // 专门的2FA验证状态(用于TwoFactorAuthModal) - const [show2FAVerifyModal, setShow2FAVerifyModal] = useState(false); - const [verifyCode, setVerifyCode] = useState(''); - const [verifyLoading, setVerifyLoading] = useState(false); + // 使用通用安全验证 Hook + const { + isModalVisible, + verificationMethods, + verificationState, + startVerification, + executeVerification, + cancelVerification, + setVerificationCode, + switchVerificationMethod, + } = useSecureVerification({ + onSuccess: (result) => { + // 验证成功后显示密钥 + if (result.success && result.data?.key) { + setKeyDisplayState({ + showModal: true, + keyData: result.data.key, + }); + } + }, + successMessage: t('密钥获取成功'), + }); - // 2FA状态更新辅助函数 - const updateTwoFAState = (updates) => { - setTwoFAState((prev) => ({ ...prev, ...updates })); - }; - - // 重置2FA状态 - const resetTwoFAState = () => { - setTwoFAState({ + // 重置密钥显示状态 + const resetKeyDisplayState = () => { + setKeyDisplayState({ showModal: false, - code: '', - loading: false, - showKey: false, keyData: '', }); }; - // 重置2FA验证状态 - const reset2FAVerifyState = () => { - setShow2FAVerifyModal(false); - setVerifyCode(''); - setVerifyLoading(false); - }; - // 渠道额外设置状态 const [channelSettings, setChannelSettings] = useState({ force_format: false, @@ -602,42 +604,31 @@ const EditChannelModal = (props) => { } }; - // 使用TwoFactorAuthModal的验证函数 - const handleVerify2FA = async () => { - if (!verifyCode) { - showError(t('请输入验证码或备用码')); - return; - } - - setVerifyLoading(true); + // 显示安全验证模态框并开始验证流程 + const handleShow2FAModal = async () => { try { - const res = await API.post(`/api/channel/${channelId}/key`, { - code: verifyCode, + console.log('=== handleShow2FAModal called ==='); + console.log('channelId:', channelId); + console.log('startVerification function:', typeof startVerification); + + // 测试模态框状态 + console.log('Current modal state:', isModalVisible); + + const apiCall = createApiCalls.viewChannelKey(channelId); + console.log('apiCall created:', typeof apiCall); + + const result = await startVerification(apiCall, { + title: t('查看渠道密钥'), + description: t('为了保护账户安全,请验证您的身份。'), + preferredMethod: 'passkey', // 优先使用 Passkey }); - if (res.data.success) { - // 验证成功,显示密钥 - updateTwoFAState({ - showModal: true, - showKey: true, - keyData: res.data.data.key, - }); - reset2FAVerifyState(); - showSuccess(t('验证成功')); - } else { - showError(res.data.message); - } + console.log('startVerification result:', result); } catch (error) { - showError(t('获取密钥失败')); - } finally { - setVerifyLoading(false); + console.error('handleShow2FAModal error:', error); + showError(error.message || t('启动验证失败')); } }; - // 显示2FA验证模态框 - 使用TwoFactorAuthModal - const handleShow2FAModal = () => { - setShow2FAVerifyModal(true); - }; - useEffect(() => { const modelMap = new Map(); @@ -741,10 +732,8 @@ const EditChannelModal = (props) => { } // 重置本地输入,避免下次打开残留上一次的 JSON 字段值 setInputs(getInitValues()); - // 重置2FA状态 - resetTwoFAState(); - // 重置2FA验证状态 - reset2FAVerifyState(); + // 重置密钥显示状态 + resetKeyDisplayState(); }; const handleVertexUploadChange = ({ fileList }) => { @@ -2498,17 +2487,17 @@ const EditChannelModal = (props) => { onVisibleChange={(visible) => setIsModalOpenurl(visible)} /> - {/* 使用TwoFactorAuthModal组件进行2FA验证 */} - {/* 使用ChannelKeyDisplay组件显示密钥 */} @@ -2531,10 +2520,10 @@ const EditChannelModal = (props) => { {t('渠道密钥信息')} } - visible={twoFAState.showModal && twoFAState.showKey} - onCancel={resetTwoFAState} + visible={keyDisplayState.showModal} + onCancel={resetKeyDisplayState} footer={ - } @@ -2542,7 +2531,7 @@ const EditChannelModal = (props) => { style={{ maxWidth: '90vw' }} > { @@ -253,6 +255,20 @@ const renderOperations = ( > {t('降级')} + + - - -
- + {modalContent} @@ -104,22 +120,24 @@ const ContentModal = ({ return (
{isLoading && ( -
- +
+
)} -
diff --git a/web/src/components/topup/RechargeCard.jsx b/web/src/components/topup/RechargeCard.jsx index 03ea2b31..0a299ffa 100644 --- a/web/src/components/topup/RechargeCard.jsx +++ b/web/src/components/topup/RechargeCard.jsx @@ -30,7 +30,8 @@ import { Space, Row, Col, - Spin, Tooltip + Spin, + Tooltip, } from '@douyinfe/semi-ui'; import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si'; import { CreditCard, Coins, Wallet, BarChart2, TrendingUp } from 'lucide-react'; @@ -266,7 +267,8 @@ const RechargeCard = ({ {payMethods && payMethods.length > 0 ? ( {payMethods.map((payMethod) => { - const minTopupVal = Number(payMethod.min_topup) || 0; + const minTopupVal = + Number(payMethod.min_topup) || 0; const isStripe = payMethod.type === 'stripe'; const disabled = (!enableOnlineTopUp && !isStripe) || @@ -280,7 +282,9 @@ const RechargeCard = ({ type='tertiary' onClick={() => preTopUp(payMethod.type)} disabled={disabled} - loading={paymentLoading && payWay === payMethod.type} + loading={ + paymentLoading && payWay === payMethod.type + } icon={ payMethod.type === 'alipay' ? ( @@ -291,7 +295,10 @@ const RechargeCard = ({ ) : ( ) } @@ -301,12 +308,22 @@ const RechargeCard = ({ ); - return disabled && minTopupVal > Number(topUpCount || 0) ? ( - + return disabled && + minTopupVal > Number(topUpCount || 0) ? ( + {buttonEl} ) : ( - {buttonEl} + + {buttonEl} + ); })} @@ -324,23 +341,27 @@ const RechargeCard = ({
{presetAmounts.map((preset, index) => { - const discount = preset.discount || topupInfo?.discount?.[preset.value] || 1.0; + const discount = + preset.discount || + topupInfo?.discount?.[preset.value] || + 1.0; const originalPrice = preset.value * priceRatio; const discountedPrice = originalPrice * discount; const hasDiscount = discount < 1.0; const actualPay = discountedPrice; const save = originalPrice - discountedPrice; - + return ( { @@ -352,24 +373,35 @@ const RechargeCard = ({ }} >
- + {formatLargeNumber(preset.value)} {hasDiscount && ( - - {t('折').includes('off') ? - ((1 - parseFloat(discount)) * 100).toFixed(1) : - (discount * 10).toFixed(1)}{t('折')} - + + {t('折').includes('off') + ? ( + (1 - parseFloat(discount)) * + 100 + ).toFixed(1) + : (discount * 10).toFixed(1)} + {t('折')} + )} -
+
{t('实付')} {actualPay.toFixed(2)}, - {hasDiscount ? `${t('节省')} ${save.toFixed(2)}` : `${t('节省')} 0.00`} + {hasDiscount + ? `${t('节省')} ${save.toFixed(2)}` + : `${t('节省')} 0.00`}
diff --git a/web/src/components/topup/index.jsx b/web/src/components/topup/index.jsx index 929a47e3..558c6705 100644 --- a/web/src/components/topup/index.jsx +++ b/web/src/components/topup/index.jsx @@ -80,11 +80,11 @@ const TopUp = () => { // 预设充值额度选项 const [presetAmounts, setPresetAmounts] = useState([]); const [selectedPreset, setSelectedPreset] = useState(null); - + // 充值配置信息 const [topupInfo, setTopupInfo] = useState({ amount_options: [], - discount: {} + discount: {}, }); const topUp = async () => { @@ -262,9 +262,9 @@ const TopUp = () => { if (success) { setTopupInfo({ amount_options: data.amount_options || [], - discount: data.discount || {} + discount: data.discount || {}, }); - + // 处理支付方式 let payMethods = data.pay_methods || []; try { @@ -280,10 +280,15 @@ const TopUp = () => { payMethods = payMethods.map((method) => { // 规范化最小充值数 const normalizedMinTopup = Number(method.min_topup); - method.min_topup = Number.isFinite(normalizedMinTopup) ? normalizedMinTopup : 0; + method.min_topup = Number.isFinite(normalizedMinTopup) + ? normalizedMinTopup + : 0; // Stripe 的最小充值从后端字段回填 - if (method.type === 'stripe' && (!method.min_topup || method.min_topup <= 0)) { + if ( + method.type === 'stripe' && + (!method.min_topup || method.min_topup <= 0) + ) { const stripeMin = Number(data.stripe_min_topup); if (Number.isFinite(stripeMin)) { method.min_topup = stripeMin; @@ -313,7 +318,11 @@ const TopUp = () => { setPayMethods(payMethods); const enableStripeTopUp = data.enable_stripe_topup || false; const enableOnlineTopUp = data.enable_online_topup || false; - const minTopUpValue = enableOnlineTopUp? data.min_topup : enableStripeTopUp? data.stripe_min_topup : 1; + const minTopUpValue = enableOnlineTopUp + ? data.min_topup + : enableStripeTopUp + ? data.stripe_min_topup + : 1; setEnableOnlineTopUp(enableOnlineTopUp); setEnableStripeTopUp(enableStripeTopUp); setMinTopUp(minTopUpValue); @@ -330,12 +339,12 @@ const TopUp = () => { console.log('解析支付方式失败:', e); setPayMethods([]); } - + // 如果有自定义充值数量选项,使用它们替换默认的预设选项 if (data.amount_options && data.amount_options.length > 0) { - const customPresets = data.amount_options.map(amount => ({ + const customPresets = data.amount_options.map((amount) => ({ value: amount, - discount: data.discount[amount] || 1.0 + discount: data.discount[amount] || 1.0, })); setPresetAmounts(customPresets); } @@ -483,7 +492,7 @@ const TopUp = () => { const selectPresetAmount = (preset) => { setTopUpCount(preset.value); setSelectedPreset(preset.value); - + // 计算实际支付金额,考虑折扣 const discount = preset.discount || topupInfo.discount[preset.value] || 1.0; const discountedAmount = preset.value * priceRatio * discount; diff --git a/web/src/components/topup/modals/PaymentConfirmModal.jsx b/web/src/components/topup/modals/PaymentConfirmModal.jsx index 1bffbfed..8bd5455c 100644 --- a/web/src/components/topup/modals/PaymentConfirmModal.jsx +++ b/web/src/components/topup/modals/PaymentConfirmModal.jsx @@ -40,9 +40,10 @@ const PaymentConfirmModal = ({ amountNumber, discountRate, }) => { - const hasDiscount = discountRate && discountRate > 0 && discountRate < 1 && amountNumber > 0; - const originalAmount = hasDiscount ? (amountNumber / discountRate) : 0; - const discountAmount = hasDiscount ? (originalAmount - amountNumber) : 0; + const hasDiscount = + discountRate && discountRate > 0 && discountRate < 1 && amountNumber > 0; + const originalAmount = hasDiscount ? amountNumber / discountRate : 0; + const discountAmount = hasDiscount ? originalAmount - amountNumber : 0; return ( dayjs().startOf('day').toDate(), - end: () => dayjs().endOf('day').toDate() + end: () => dayjs().endOf('day').toDate(), }, { text: '近 7 天', start: () => dayjs().subtract(6, 'day').startOf('day').toDate(), - end: () => dayjs().endOf('day').toDate() + end: () => dayjs().endOf('day').toDate(), }, { text: '本周', start: () => dayjs().startOf('week').toDate(), - end: () => dayjs().endOf('week').toDate() + end: () => dayjs().endOf('week').toDate(), }, { text: '近 30 天', start: () => dayjs().subtract(29, 'day').startOf('day').toDate(), - end: () => dayjs().endOf('day').toDate() + end: () => dayjs().endOf('day').toDate(), }, { text: '本月', start: () => dayjs().startOf('month').toDate(), - end: () => dayjs().endOf('month').toDate() + end: () => dayjs().endOf('month').toDate(), }, ]; diff --git a/web/src/helpers/api.js b/web/src/helpers/api.js index bc389b2e..1ccfffaf 100644 --- a/web/src/helpers/api.js +++ b/web/src/helpers/api.js @@ -131,13 +131,11 @@ export const buildApiPayload = ( seed: 'seed', }; - Object.entries(parameterMappings).forEach(([key, param]) => { const enabled = parameterEnabled[key]; const value = inputs[param]; const hasValue = value !== undefined && value !== null; - if (enabled && hasValue) { payload[param] = value; } diff --git a/web/src/helpers/data.js b/web/src/helpers/data.js index b894a953..e45aac3e 100644 --- a/web/src/helpers/data.js +++ b/web/src/helpers/data.js @@ -23,7 +23,9 @@ export function setStatusData(data) { localStorage.setItem('logo', data.logo); localStorage.setItem('footer_html', data.footer_html); localStorage.setItem('quota_per_unit', data.quota_per_unit); + // 兼容:保留旧字段,同时写入新的额度展示类型 localStorage.setItem('display_in_currency', data.display_in_currency); + localStorage.setItem('quota_display_type', data.quota_display_type || 'USD'); localStorage.setItem('enable_drawing', data.enable_drawing); localStorage.setItem('enable_task', data.enable_task); localStorage.setItem('enable_data_export', data.enable_data_export); diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index c19e2849..19e10ce3 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -830,12 +830,25 @@ export function renderQuotaNumberWithDigit(num, digits = 2) { if (typeof num !== 'number' || isNaN(num)) { return 0; } - let displayInCurrency = localStorage.getItem('display_in_currency'); + const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD'; num = num.toFixed(digits); - if (displayInCurrency) { + if (quotaDisplayType === 'CNY') { + return '¥' + num; + } else if (quotaDisplayType === 'USD') { return '$' + num; + } else if (quotaDisplayType === 'CUSTOM') { + const statusStr = localStorage.getItem('status'); + let symbol = '¤'; + try { + if (statusStr) { + const s = JSON.parse(statusStr); + symbol = s?.custom_currency_symbol || symbol; + } + } catch (e) {} + return symbol + num; + } else { + return num; } - return num; } export function renderNumberWithPoint(num) { @@ -887,33 +900,67 @@ export function getQuotaWithUnit(quota, digits = 6) { } export function renderQuotaWithAmount(amount) { - let displayInCurrency = localStorage.getItem('display_in_currency'); - displayInCurrency = displayInCurrency === 'true'; - if (displayInCurrency) { - return '$' + amount; - } else { + const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD'; + if (quotaDisplayType === 'TOKENS') { return renderNumber(renderUnitWithQuota(amount)); } + if (quotaDisplayType === 'CNY') { + return '¥' + amount; + } else if (quotaDisplayType === 'CUSTOM') { + const statusStr = localStorage.getItem('status'); + let symbol = '¤'; + try { + if (statusStr) { + const s = JSON.parse(statusStr); + symbol = s?.custom_currency_symbol || symbol; + } + } catch (e) {} + return symbol + amount; + } + return '$' + amount; } export function renderQuota(quota, digits = 2) { let quotaPerUnit = localStorage.getItem('quota_per_unit'); - let displayInCurrency = localStorage.getItem('display_in_currency'); + const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD'; quotaPerUnit = parseFloat(quotaPerUnit); - displayInCurrency = displayInCurrency === 'true'; - if (displayInCurrency) { - const result = quota / quotaPerUnit; - const fixedResult = result.toFixed(digits); - - // 如果 toFixed 后结果为 0 但原始值不为 0,显示最小值 - if (parseFloat(fixedResult) === 0 && quota > 0 && result > 0) { - const minValue = Math.pow(10, -digits); - return '$' + minValue.toFixed(digits); - } - - return '$' + fixedResult; + if (quotaDisplayType === 'TOKENS') { + return renderNumber(quota); } - return renderNumber(quota); + const resultUSD = quota / quotaPerUnit; + let symbol = '$'; + let value = resultUSD; + if (quotaDisplayType === 'CNY') { + const statusStr = localStorage.getItem('status'); + let usdRate = 1; + try { + if (statusStr) { + const s = JSON.parse(statusStr); + usdRate = s?.usd_exchange_rate || 1; + } + } catch (e) {} + value = resultUSD * usdRate; + symbol = '¥'; + } else if (quotaDisplayType === 'CUSTOM') { + const statusStr = localStorage.getItem('status'); + let symbolCustom = '¤'; + let rate = 1; + try { + if (statusStr) { + const s = JSON.parse(statusStr); + symbolCustom = s?.custom_currency_symbol || symbolCustom; + rate = s?.custom_currency_exchange_rate || rate; + } + } catch (e) {} + value = resultUSD * rate; + symbol = symbolCustom; + } + const fixedResult = value.toFixed(digits); + if (parseFloat(fixedResult) === 0 && quota > 0 && value > 0) { + const minValue = Math.pow(10, -digits); + return symbol + minValue.toFixed(digits); + } + return symbol + fixedResult; } function isValidGroupRatio(ratio) { @@ -1072,7 +1119,7 @@ export function renderModelPrice( (completionTokens / 1000000) * completionRatioPrice * groupRatio + (webSearchCallCount / 1000) * webSearchPrice * groupRatio + (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio + - (imageGenerationCallPrice * groupRatio); + imageGenerationCallPrice * groupRatio; return ( <> @@ -1510,9 +1557,8 @@ export function renderAudioModelPrice( } export function renderQuotaWithPrompt(quota, digits) { - let displayInCurrency = localStorage.getItem('display_in_currency'); - displayInCurrency = displayInCurrency === 'true'; - if (displayInCurrency) { + const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD'; + if (quotaDisplayType !== 'TOKENS') { return i18next.t('等价金额:') + renderQuota(quota, digits); } return ''; diff --git a/web/src/helpers/utils.jsx b/web/src/helpers/utils.jsx index bcd13230..0cbdebd1 100644 --- a/web/src/helpers/utils.jsx +++ b/web/src/helpers/utils.jsx @@ -646,9 +646,25 @@ export const calculateModelPrice = ({ const numCompletion = parseFloat(rawDisplayCompletion.replace(/[^0-9.]/g, '')) / unitDivisor; + let symbol = '$'; + if (currency === 'CNY') { + symbol = '¥'; + } else if (currency === 'CUSTOM') { + try { + const statusStr = localStorage.getItem('status'); + if (statusStr) { + const s = JSON.parse(statusStr); + symbol = s?.custom_currency_symbol || '¤'; + } else { + symbol = '¤'; + } + } catch (e) { + symbol = '¤'; + } + } return { - inputPrice: `${currency === 'CNY' ? '¥' : '$'}${numInput.toFixed(precision)}`, - completionPrice: `${currency === 'CNY' ? '¥' : '$'}${numCompletion.toFixed(precision)}`, + inputPrice: `${symbol}${numInput.toFixed(precision)}`, + completionPrice: `${symbol}${numCompletion.toFixed(precision)}`, unitLabel, isPerToken: true, usedGroup, diff --git a/web/src/hooks/channels/useChannelsData.jsx b/web/src/hooks/channels/useChannelsData.jsx index 7d09d4df..2d1f4c1e 100644 --- a/web/src/hooks/channels/useChannelsData.jsx +++ b/web/src/hooks/channels/useChannelsData.jsx @@ -25,9 +25,13 @@ import { showInfo, showSuccess, loadChannelModels, - copy + copy, } from '../../helpers'; -import { CHANNEL_OPTIONS, ITEMS_PER_PAGE, MODEL_TABLE_PAGE_SIZE } from '../../constants'; +import { + CHANNEL_OPTIONS, + ITEMS_PER_PAGE, + MODEL_TABLE_PAGE_SIZE, +} from '../../constants'; import { useIsMobile } from '../common/useIsMobile'; import { useTableCompactMode } from '../common/useTableCompactMode'; import { Modal } from '@douyinfe/semi-ui'; @@ -64,7 +68,7 @@ export const useChannelsData = () => { // Status filter const [statusFilter, setStatusFilter] = useState( - localStorage.getItem('channel-status-filter') || 'all' + localStorage.getItem('channel-status-filter') || 'all', ); // Type tabs states @@ -80,7 +84,7 @@ export const useChannelsData = () => { const [selectedModelKeys, setSelectedModelKeys] = useState([]); const [isBatchTesting, setIsBatchTesting] = useState(false); const [modelTablePage, setModelTablePage] = useState(1); - + // 使用 ref 来避免闭包问题,类似旧版实现 const shouldStopBatchTestingRef = useRef(false); @@ -116,9 +120,12 @@ export const useChannelsData = () => { // Initialize from localStorage useEffect(() => { const localIdSort = localStorage.getItem('id-sort') === 'true'; - const localPageSize = parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE; - const localEnableTagMode = localStorage.getItem('enable-tag-mode') === 'true'; - const localEnableBatchDelete = localStorage.getItem('enable-batch-delete') === 'true'; + const localPageSize = + parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE; + const localEnableTagMode = + localStorage.getItem('enable-tag-mode') === 'true'; + const localEnableBatchDelete = + localStorage.getItem('enable-batch-delete') === 'true'; setIdSort(localIdSort); setPageSize(localPageSize); @@ -176,7 +183,10 @@ export const useChannelsData = () => { // Save column preferences useEffect(() => { if (Object.keys(visibleColumns).length > 0) { - localStorage.setItem('channels-table-columns', JSON.stringify(visibleColumns)); + localStorage.setItem( + 'channels-table-columns', + JSON.stringify(visibleColumns), + ); } }, [visibleColumns]); @@ -290,14 +300,21 @@ export const useChannelsData = () => { const { searchKeyword, searchGroup, searchModel } = getFormValues(); if (searchKeyword !== '' || searchGroup !== '' || searchModel !== '') { setLoading(true); - await searchChannels(enableTagMode, typeKey, statusF, page, pageSize, idSort); + await searchChannels( + enableTagMode, + typeKey, + statusF, + page, + pageSize, + idSort, + ); setLoading(false); return; } const reqId = ++requestCounter.current; setLoading(true); - const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : ''; + const typeParam = typeKey !== 'all' ? `&type=${typeKey}` : ''; const statusParam = statusF !== 'all' ? `&status=${statusF}` : ''; const res = await API.get( `/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}${statusParam}`, @@ -311,7 +328,10 @@ export const useChannelsData = () => { if (success) { const { items, total, type_counts } = data; if (type_counts) { - const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0); + const sumAll = Object.values(type_counts).reduce( + (acc, v) => acc + v, + 0, + ); setTypeCounts({ ...type_counts, all: sumAll }); } setChannelFormat(items, enableTagMode); @@ -335,11 +355,18 @@ export const useChannelsData = () => { setSearching(true); try { if (searchKeyword === '' && searchGroup === '' && searchModel === '') { - await loadChannels(page, pageSz, sortFlag, enableTagMode, typeKey, statusF); + await loadChannels( + page, + pageSz, + sortFlag, + enableTagMode, + typeKey, + statusF, + ); return; } - const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : ''; + const typeParam = typeKey !== 'all' ? `&type=${typeKey}` : ''; const statusParam = statusF !== 'all' ? `&status=${statusF}` : ''; const res = await API.get( `/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${sortFlag}&tag_mode=${enableTagMode}&p=${page}&page_size=${pageSz}${typeParam}${statusParam}`, @@ -347,7 +374,10 @@ export const useChannelsData = () => { const { success, message, data } = res.data; if (success) { const { items = [], total = 0, type_counts = {} } = data; - const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0); + const sumAll = Object.values(type_counts).reduce( + (acc, v) => acc + v, + 0, + ); setTypeCounts({ ...type_counts, all: sumAll }); setChannelFormat(items, enableTagMode); setChannelCount(total); @@ -366,7 +396,14 @@ export const useChannelsData = () => { if (searchKeyword === '' && searchGroup === '' && searchModel === '') { await loadChannels(page, pageSize, idSort, enableTagMode); } else { - await searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort); + await searchChannels( + enableTagMode, + activeTypeKey, + statusFilter, + page, + pageSize, + idSort, + ); } }; @@ -452,9 +489,16 @@ export const useChannelsData = () => { const { searchKeyword, searchGroup, searchModel } = getFormValues(); setActivePage(page); if (searchKeyword === '' && searchGroup === '' && searchModel === '') { - loadChannels(page, pageSize, idSort, enableTagMode).then(() => { }); + loadChannels(page, pageSize, idSort, enableTagMode).then(() => {}); } else { - searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort); + searchChannels( + enableTagMode, + activeTypeKey, + statusFilter, + page, + pageSize, + idSort, + ); } }; @@ -470,7 +514,14 @@ export const useChannelsData = () => { showError(reason); }); } else { - searchChannels(enableTagMode, activeTypeKey, statusFilter, 1, size, idSort); + searchChannels( + enableTagMode, + activeTypeKey, + statusFilter, + 1, + size, + idSort, + ); } }; @@ -501,7 +552,10 @@ export const useChannelsData = () => { showError(res?.data?.message || t('渠道复制失败')); } } catch (error) { - showError(t('渠道复制失败: ') + (error?.response?.data?.message || error?.message || error)); + showError( + t('渠道复制失败: ') + + (error?.response?.data?.message || error?.message || error), + ); } }; @@ -540,7 +594,11 @@ export const useChannelsData = () => { data.priority = parseInt(data.priority); break; case 'weight': - if (data.weight === undefined || data.weight < 0 || data.weight === '') { + if ( + data.weight === undefined || + data.weight < 0 || + data.weight === '' + ) { showInfo('权重必须是非负整数!'); return; } @@ -683,7 +741,11 @@ export const useChannelsData = () => { const res = await API.post(`/api/channel/fix`); const { success, message, data } = res.data; if (success) { - showSuccess(t('已修复 ${success} 个通道,失败 ${fails} 个通道。').replace('${success}', data.success).replace('${fails}', data.fails)); + showSuccess( + t('已修复 ${success} 个通道,失败 ${fails} 个通道。') + .replace('${success}', data.success) + .replace('${fails}', data.fails), + ); await refresh(); } else { showError(message); @@ -700,10 +762,12 @@ export const useChannelsData = () => { } // 添加到正在测试的模型集合 - setTestingModels(prev => new Set([...prev, model])); + setTestingModels((prev) => new Set([...prev, model])); try { - const res = await API.get(`/api/channel/test/${record.id}?model=${model}`); + const res = await API.get( + `/api/channel/test/${record.id}?model=${model}`, + ); // 检查是否在请求期间被停止 if (shouldStopBatchTestingRef.current && isBatchTesting) { @@ -713,14 +777,14 @@ export const useChannelsData = () => { const { success, message, time } = res.data; // 更新测试结果 - setModelTestResults(prev => ({ + setModelTestResults((prev) => ({ ...prev, [testKey]: { success, message, time: time || 0, - timestamp: Date.now() - } + timestamp: Date.now(), + }, })); if (success) { @@ -738,7 +802,9 @@ export const useChannelsData = () => { ); } else { showInfo( - t('通道 ${name} 测试成功,模型 ${model} 耗时 ${time.toFixed(2)} 秒。') + t( + '通道 ${name} 测试成功,模型 ${model} 耗时 ${time.toFixed(2)} 秒。', + ) .replace('${name}', record.name) .replace('${model}', model) .replace('${time.toFixed(2)}', time.toFixed(2)), @@ -750,19 +816,19 @@ export const useChannelsData = () => { } catch (error) { // 处理网络错误 const testKey = `${record.id}-${model}`; - setModelTestResults(prev => ({ + setModelTestResults((prev) => ({ ...prev, [testKey]: { success: false, message: error.message || t('网络错误'), time: 0, - timestamp: Date.now() - } + timestamp: Date.now(), + }, })); showError(`${t('模型')} ${model}: ${error.message || t('测试失败')}`); } finally { // 从正在测试的模型集合中移除 - setTestingModels(prev => { + setTestingModels((prev) => { const newSet = new Set(prev); newSet.delete(model); return newSet; @@ -777,9 +843,11 @@ export const useChannelsData = () => { return; } - const models = currentTestChannel.models.split(',').filter(model => - model.toLowerCase().includes(modelSearchKeyword.toLowerCase()) - ); + const models = currentTestChannel.models + .split(',') + .filter((model) => + model.toLowerCase().includes(modelSearchKeyword.toLowerCase()), + ); if (models.length === 0) { showError(t('没有找到匹配的模型')); @@ -790,9 +858,9 @@ export const useChannelsData = () => { shouldStopBatchTestingRef.current = false; // 重置停止标志 // 清空该渠道之前的测试结果 - setModelTestResults(prev => { + setModelTestResults((prev) => { const newResults = { ...prev }; - models.forEach(model => { + models.forEach((model) => { const testKey = `${currentTestChannel.id}-${model}`; delete newResults[testKey]; }); @@ -800,7 +868,12 @@ export const useChannelsData = () => { }); try { - showInfo(t('开始批量测试 ${count} 个模型,已清空上次结果...').replace('${count}', models.length)); + showInfo( + t('开始批量测试 ${count} 个模型,已清空上次结果...').replace( + '${count}', + models.length, + ), + ); // 提高并发数量以加快测试速度,参考旧版的并发限制 const concurrencyLimit = 5; @@ -814,13 +887,16 @@ export const useChannelsData = () => { } const batch = models.slice(i, i + concurrencyLimit); - showInfo(t('正在测试第 ${current} - ${end} 个模型 (共 ${total} 个)') - .replace('${current}', i + 1) - .replace('${end}', Math.min(i + concurrencyLimit, models.length)) - .replace('${total}', models.length) + showInfo( + t('正在测试第 ${current} - ${end} 个模型 (共 ${total} 个)') + .replace('${current}', i + 1) + .replace('${end}', Math.min(i + concurrencyLimit, models.length)) + .replace('${total}', models.length), ); - const batchPromises = batch.map(model => testChannel(currentTestChannel, model)); + const batchPromises = batch.map((model) => + testChannel(currentTestChannel, model), + ); const batchResults = await Promise.allSettled(batchPromises); results.push(...batchResults); @@ -832,20 +908,20 @@ export const useChannelsData = () => { // 短暂延迟避免过于频繁的请求 if (i + concurrencyLimit < models.length) { - await new Promise(resolve => setTimeout(resolve, 100)); + await new Promise((resolve) => setTimeout(resolve, 100)); } } if (!shouldStopBatchTestingRef.current) { // 等待一小段时间确保所有结果都已更新 - await new Promise(resolve => setTimeout(resolve, 300)); + await new Promise((resolve) => setTimeout(resolve, 300)); // 使用当前状态重新计算结果统计 - setModelTestResults(currentResults => { + setModelTestResults((currentResults) => { let successCount = 0; let failCount = 0; - models.forEach(model => { + models.forEach((model) => { const testKey = `${currentTestChannel.id}-${model}`; const result = currentResults[testKey]; if (result && result.success) { @@ -857,10 +933,11 @@ export const useChannelsData = () => { // 显示完成消息 setTimeout(() => { - showSuccess(t('批量测试完成!成功: ${success}, 失败: ${fail}, 总计: ${total}') - .replace('${success}', successCount) - .replace('${fail}', failCount) - .replace('${total}', models.length) + showSuccess( + t('批量测试完成!成功: ${success}, 失败: ${fail}, 总计: ${total}') + .replace('${success}', successCount) + .replace('${fail}', failCount) + .replace('${total}', models.length), ); }, 100); @@ -1045,4 +1122,4 @@ export const useChannelsData = () => { setCompactMode, setActivePage, }; -}; \ No newline at end of file +}; diff --git a/web/src/hooks/common/useSidebar.js b/web/src/hooks/common/useSidebar.js index 13d76fd8..0b8eb3d8 100644 --- a/web/src/hooks/common/useSidebar.js +++ b/web/src/hooks/common/useSidebar.js @@ -128,7 +128,7 @@ export const useSidebar = () => { // 刷新用户配置的方法(供外部调用) const refreshUserConfig = async () => { - if (Object.keys(adminConfig).length > 0) { + if (Object.keys(adminConfig).length > 0) { await loadUserConfig(); } @@ -155,7 +155,10 @@ export const useSidebar = () => { sidebarEventTarget.addEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh); return () => { - sidebarEventTarget.removeEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh); + sidebarEventTarget.removeEventListener( + SIDEBAR_REFRESH_EVENT, + handleRefresh, + ); }; }, [adminConfig]); diff --git a/web/src/hooks/model-pricing/useModelPricingData.jsx b/web/src/hooks/model-pricing/useModelPricingData.jsx index 799cdc13..0a407236 100644 --- a/web/src/hooks/model-pricing/useModelPricingData.jsx +++ b/web/src/hooks/model-pricing/useModelPricingData.jsx @@ -64,6 +64,29 @@ export const useModelPricingData = () => { () => statusState?.status?.usd_exchange_rate ?? priceRate, [statusState, priceRate], ); + const customExchangeRate = useMemo( + () => statusState?.status?.custom_currency_exchange_rate ?? 1, + [statusState], + ); + const customCurrencySymbol = useMemo( + () => statusState?.status?.custom_currency_symbol ?? '¤', + [statusState], + ); + + // 默认货币与站点展示类型同步(USD/CNY),TOKENS 时仍允许切换视图内货币 + const siteDisplayType = useMemo( + () => statusState?.status?.quota_display_type || 'USD', + [statusState], + ); + useEffect(() => { + if ( + siteDisplayType === 'USD' || + siteDisplayType === 'CNY' || + siteDisplayType === 'CUSTOM' + ) { + setCurrency(siteDisplayType); + } + }, [siteDisplayType]); const filteredModels = useMemo(() => { let result = models; @@ -156,6 +179,8 @@ export const useModelPricingData = () => { if (currency === 'CNY') { return `¥${(priceInUSD * usdExchangeRate).toFixed(3)}`; + } else if (currency === 'CUSTOM') { + return `${customCurrencySymbol}${(priceInUSD * customExchangeRate).toFixed(3)}`; } return `$${priceInUSD.toFixed(3)}`; }; diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 8a9d32f2..6f26d670 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1773,7 +1773,10 @@ "自定义模型名称": "Custom model name", "启用全部密钥": "Enable all keys", "充值价格显示": "Recharge price", - "美元汇率(非充值汇率,仅用于定价页面换算)": "USD exchange rate (not recharge rate, only used for pricing page conversion)", + "自定义货币": "Custom currency", + "自定义货币符号": "Custom currency symbol", + "例如 €, £, Rp, ₩, ₹...": "For example, €, £, Rp, ₩, ₹...", + "站点额度展示类型及汇率": "Site quota display type and exchange rate", "美元汇率": "USD exchange rate", "隐藏操作项": "Hide actions", "显示操作项": "Show actions", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index 35a635bd..6d601ef2 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -1773,7 +1773,10 @@ "自定义模型名称": "Nom de modèle personnalisé", "启用全部密钥": "Activer toutes les clés", "充值价格显示": "Prix de recharge", - "美元汇率(非充值汇率,仅用于定价页面换算)": "Taux de change USD (pas de taux de recharge, uniquement utilisé pour la conversion de la page de tarification)", + "站点额度展示类型及汇率": "Type d'affichage du quota du site et taux de change", + "自定义货币": "Devise personnalisée", + "自定义货币符号": "Symbole de devise personnalisé", + "例如 €, £, Rp, ₩, ₹...": "Par exemple, €, £, Rp, ₩, ₹...", "美元汇率": "Taux de change USD", "隐藏操作项": "Masquer les actions", "显示操作项": "Afficher les actions", @@ -2137,4 +2140,4 @@ "common": { "changeLanguage": "Changer de langue" } -} \ No newline at end of file +} diff --git a/web/src/pages/Setting/Operation/SettingsGeneral.jsx b/web/src/pages/Setting/Operation/SettingsGeneral.jsx index 5af750ec..fbfa0ed9 100644 --- a/web/src/pages/Setting/Operation/SettingsGeneral.jsx +++ b/web/src/pages/Setting/Operation/SettingsGeneral.jsx @@ -17,8 +17,19 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useEffect, useState, useRef } from 'react'; -import { Banner, Button, Col, Form, Row, Spin, Modal } from '@douyinfe/semi-ui'; +import React, { useEffect, useState, useRef, useMemo } from 'react'; +import { + Banner, + Button, + Col, + Form, + Row, + Spin, + Modal, + Select, + InputGroup, + Input, +} from '@douyinfe/semi-ui'; import { compareObjects, API, @@ -35,10 +46,12 @@ export default function GeneralSettings(props) { const [inputs, setInputs] = useState({ TopUpLink: '', 'general_setting.docs_link': '', + 'general_setting.quota_display_type': 'USD', + 'general_setting.custom_currency_symbol': '¤', + 'general_setting.custom_currency_exchange_rate': '', QuotaPerUnit: '', RetryTimes: '', USDExchangeRate: '', - DisplayInCurrencyEnabled: false, DisplayTokenStatEnabled: false, DefaultCollapseSidebar: false, DemoSiteEnabled: false, @@ -88,6 +101,30 @@ export default function GeneralSettings(props) { }); } + // 计算展示在输入框中的“1 USD = X ”中的 X + const combinedRate = useMemo(() => { + const type = inputs['general_setting.quota_display_type']; + if (type === 'USD') return '1'; + if (type === 'CNY') return String(inputs['USDExchangeRate'] || ''); + if (type === 'TOKENS') return String(inputs['QuotaPerUnit'] || ''); + if (type === 'CUSTOM') + return String( + inputs['general_setting.custom_currency_exchange_rate'] || '', + ); + return ''; + }, [inputs]); + + const onCombinedRateChange = (val) => { + const type = inputs['general_setting.quota_display_type']; + if (type === 'CNY') { + handleFieldChange('USDExchangeRate')(val); + } else if (type === 'TOKENS') { + handleFieldChange('QuotaPerUnit')(val); + } else if (type === 'CUSTOM') { + handleFieldChange('general_setting.custom_currency_exchange_rate')(val); + } + }; + useEffect(() => { const currentInputs = {}; for (let key in props.options) { @@ -95,6 +132,28 @@ export default function GeneralSettings(props) { currentInputs[key] = props.options[key]; } } + // 若旧字段存在且新字段缺失,则做一次兜底映射 + if ( + currentInputs['general_setting.quota_display_type'] === undefined && + props.options?.DisplayInCurrencyEnabled !== undefined + ) { + currentInputs['general_setting.quota_display_type'] = props.options + .DisplayInCurrencyEnabled + ? 'USD' + : 'TOKENS'; + } + // 回填自定义货币相关字段(如果后端已存在) + if (props.options['general_setting.custom_currency_symbol'] !== undefined) { + currentInputs['general_setting.custom_currency_symbol'] = + props.options['general_setting.custom_currency_symbol']; + } + if ( + props.options['general_setting.custom_currency_exchange_rate'] !== + undefined + ) { + currentInputs['general_setting.custom_currency_exchange_rate'] = + props.options['general_setting.custom_currency_exchange_rate']; + } setInputs(currentInputs); setInputsRow(structuredClone(currentInputs)); refForm.current.setValues(currentInputs); @@ -130,29 +189,7 @@ export default function GeneralSettings(props) { showClear /> - {inputs.QuotaPerUnit !== '500000' && inputs.QuotaPerUnit !== 500000 && ( - - setShowQuotaWarning(true)} - /> - - )} - - - + {/* 单位美元额度已合入汇率组合控件(TOKENS 模式下编辑),不再单独展示 */} - - - + + + + + + + + + + - - setInputs({ ...inputs, - 'monitor_setting.auto_test_channel_minutes': parseInt(value), + 'monitor_setting.auto_test_channel_minutes': + parseInt(value), }) } /> diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx b/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx index d681b6a2..a4f1029a 100644 --- a/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx +++ b/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx @@ -118,14 +118,20 @@ export default function SettingsPaymentGateway(props) { } } - if (originInputs['AmountOptions'] !== inputs.AmountOptions && inputs.AmountOptions.trim() !== '') { + if ( + originInputs['AmountOptions'] !== inputs.AmountOptions && + inputs.AmountOptions.trim() !== '' + ) { if (!verifyJSON(inputs.AmountOptions)) { showError(t('自定义充值数量选项不是合法的 JSON 数组')); return; } } - if (originInputs['AmountDiscount'] !== inputs.AmountDiscount && inputs.AmountDiscount.trim() !== '') { + if ( + originInputs['AmountDiscount'] !== inputs.AmountDiscount && + inputs.AmountDiscount.trim() !== '' + ) { if (!verifyJSON(inputs.AmountDiscount)) { showError(t('充值金额折扣配置不是合法的 JSON 对象')); return; @@ -163,10 +169,16 @@ export default function SettingsPaymentGateway(props) { options.push({ key: 'PayMethods', value: inputs.PayMethods }); } if (originInputs['AmountOptions'] !== inputs.AmountOptions) { - options.push({ key: 'payment_setting.amount_options', value: inputs.AmountOptions }); + options.push({ + key: 'payment_setting.amount_options', + value: inputs.AmountOptions, + }); } if (originInputs['AmountDiscount'] !== inputs.AmountDiscount) { - options.push({ key: 'payment_setting.amount_discount', value: inputs.AmountDiscount }); + options.push({ + key: 'payment_setting.amount_discount', + value: inputs.AmountDiscount, + }); } // 发送请求 @@ -273,7 +285,7 @@ export default function SettingsPaymentGateway(props) { placeholder={t('为一个 JSON 文本')} autosize /> - + - + - + diff --git a/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx b/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx index ed982edc..b298cc78 100644 --- a/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx +++ b/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx @@ -226,8 +226,12 @@ export default function ModelRatioSettings(props) { - setInputs({ ...inputs, ImageRatio: value }) - } + onChange={(value) => setInputs({ ...inputs, ImageRatio: value })} /> @@ -249,7 +251,9 @@ export default function ModelRatioSettings(props) { - setInputs({ ...inputs, AudioRatio: value }) - } + onChange={(value) => setInputs({ ...inputs, AudioRatio: value })} /> @@ -270,8 +272,12 @@ export default function ModelRatioSettings(props) { Date: Tue, 30 Sep 2025 09:14:12 +0800 Subject: [PATCH 068/243] feat: support claude-sonnet-4-5-20250929 --- relay/channel/aws/constants.go | 6 ++++++ relay/channel/claude/constants.go | 2 ++ relay/channel/vertex/adaptor.go | 1 + setting/ratio_setting/cache_ratio.go | 4 ++++ setting/ratio_setting/model_ratio.go | 1 + 5 files changed, 14 insertions(+) diff --git a/relay/channel/aws/constants.go b/relay/channel/aws/constants.go index 5ac7ce99..45112d23 100644 --- a/relay/channel/aws/constants.go +++ b/relay/channel/aws/constants.go @@ -16,6 +16,7 @@ var awsModelIDMap = map[string]string{ "claude-sonnet-4-20250514": "anthropic.claude-sonnet-4-20250514-v1:0", "claude-opus-4-20250514": "anthropic.claude-opus-4-20250514-v1:0", "claude-opus-4-1-20250805": "anthropic.claude-opus-4-1-20250805-v1:0", + "claude-sonnet-4-5-20250929": "anthropic.claude-sonnet-4-5-20250929-v1:0", // Nova models "nova-micro-v1:0": "amazon.nova-micro-v1:0", "nova-lite-v1:0": "amazon.nova-lite-v1:0", @@ -69,6 +70,11 @@ var awsModelCanCrossRegionMap = map[string]map[string]bool{ "anthropic.claude-opus-4-1-20250805-v1:0": { "us": true, }, + "anthropic.claude-sonnet-4-5-20250929-v1:0": { + "us": true, + "ap": true, + "eu": true, + }, // Nova models - all support three major regions "amazon.nova-micro-v1:0": { "us": true, diff --git a/relay/channel/claude/constants.go b/relay/channel/claude/constants.go index a23543d2..d0b36fe4 100644 --- a/relay/channel/claude/constants.go +++ b/relay/channel/claude/constants.go @@ -19,6 +19,8 @@ var ModelList = []string{ "claude-opus-4-20250514-thinking", "claude-opus-4-1-20250805", "claude-opus-4-1-20250805-thinking", + "claude-sonnet-4-5-20250929", + "claude-sonnet-4-5-20250929-thinking", } var ChannelName = "claude" diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go index a424cb1a..91a7f88c 100644 --- a/relay/channel/vertex/adaptor.go +++ b/relay/channel/vertex/adaptor.go @@ -37,6 +37,7 @@ var claudeModelMap = map[string]string{ "claude-sonnet-4-20250514": "claude-sonnet-4@20250514", "claude-opus-4-20250514": "claude-opus-4@20250514", "claude-opus-4-1-20250805": "claude-opus-4-1@20250805", + "claude-sonnet-4-5-20250929": "claude-sonnet-4-5@20250929", } const anthropicVersion = "vertex-2023-10-16" diff --git a/setting/ratio_setting/cache_ratio.go b/setting/ratio_setting/cache_ratio.go index 5993cdee..8e4b227a 100644 --- a/setting/ratio_setting/cache_ratio.go +++ b/setting/ratio_setting/cache_ratio.go @@ -52,6 +52,8 @@ var defaultCacheRatio = map[string]float64{ "claude-opus-4-20250514-thinking": 0.1, "claude-opus-4-1-20250805": 0.1, "claude-opus-4-1-20250805-thinking": 0.1, + "claude-sonnet-4-5-20250929": 0.1, + "claude-sonnet-4-5-20250929-thinking": 0.1, } var defaultCreateCacheRatio = map[string]float64{ @@ -69,6 +71,8 @@ var defaultCreateCacheRatio = map[string]float64{ "claude-opus-4-20250514-thinking": 1.25, "claude-opus-4-1-20250805": 1.25, "claude-opus-4-1-20250805-thinking": 1.25, + "claude-sonnet-4-5-20250929": 1.25, + "claude-sonnet-4-5-20250929-thinking": 1.25, } //var defaultCreateCacheRatio = map[string]float64{} diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index 887c5bd5..2244593a 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -141,6 +141,7 @@ var defaultModelRatio = map[string]float64{ "claude-3-7-sonnet-20250219": 1.5, "claude-3-7-sonnet-20250219-thinking": 1.5, "claude-sonnet-4-20250514": 1.5, + "claude-sonnet-4-5-20250929": 1.5, "claude-3-opus-20240229": 7.5, // $15 / 1M tokens "claude-opus-4-20250514": 7.5, "claude-opus-4-1-20250805": 7.5, From 30cb3b8bc29741d157b0da45b9a8c479fa442681 Mon Sep 17 00:00:00 2001 From: Seefs Date: Tue, 30 Sep 2025 09:22:40 +0800 Subject: [PATCH 069/243] feat: claude context editing --- dto/claude.go | 9 +++++---- relay/channel/aws/adaptor.go | 4 ++++ relay/channel/claude/adaptor.go | 4 ++++ web/src/i18n/locales/en.json | 1 + web/src/i18n/locales/fr.json | 1 + web/src/pages/Setting/Model/SettingClaudeModel.jsx | 3 +-- 6 files changed, 16 insertions(+), 6 deletions(-) diff --git a/dto/claude.go b/dto/claude.go index 963e588b..42774226 100644 --- a/dto/claude.go +++ b/dto/claude.go @@ -196,10 +196,11 @@ type ClaudeRequest struct { TopP float64 `json:"top_p,omitempty"` TopK int `json:"top_k,omitempty"` //ClaudeMetadata `json:"metadata,omitempty"` - Stream bool `json:"stream,omitempty"` - Tools any `json:"tools,omitempty"` - ToolChoice any `json:"tool_choice,omitempty"` - Thinking *Thinking `json:"thinking,omitempty"` + Stream bool `json:"stream,omitempty"` + Tools any `json:"tools,omitempty"` + ContextManagement json.RawMessage `json:"context_management,omitempty"` + ToolChoice any `json:"tool_choice,omitempty"` + Thinking *Thinking `json:"thinking,omitempty"` } func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta { diff --git a/relay/channel/aws/adaptor.go b/relay/channel/aws/adaptor.go index 9d5e5891..6202c9fc 100644 --- a/relay/channel/aws/adaptor.go +++ b/relay/channel/aws/adaptor.go @@ -52,6 +52,10 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { } func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + anthropicBeta := c.Request.Header.Get("anthropic-beta") + if anthropicBeta != "" { + req.Set("anthropic-beta", anthropicBeta) + } model_setting.GetClaudeSettings().WriteHeaders(info.OriginModelName, req) return nil } diff --git a/relay/channel/claude/adaptor.go b/relay/channel/claude/adaptor.go index 959327e1..fca26966 100644 --- a/relay/channel/claude/adaptor.go +++ b/relay/channel/claude/adaptor.go @@ -67,6 +67,10 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel anthropicVersion = "2023-06-01" } req.Set("anthropic-version", anthropicVersion) + anthropicBeta := c.Request.Header.Get("anthropic-beta") + if anthropicBeta != "" { + req.Set("anthropic-beta", anthropicBeta) + } model_setting.GetClaudeSettings().WriteHeaders(info.OriginModelName, req) return nil } diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 8a9d32f2..4e6c0ba8 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1404,6 +1404,7 @@ "Claude思考适配 BudgetTokens = MaxTokens * BudgetTokens 百分比": "Claude thinking adaptation BudgetTokens = MaxTokens * BudgetTokens percentage", "思考适配 BudgetTokens 百分比": "Thinking adaptation BudgetTokens percentage", "0.1-1之间的小数": "Decimal between 0.1 and 1", + "0.1以上的小数": "Decimal above 0.1", "模型相关设置": "Model related settings", "收起侧边栏": "Collapse sidebar", "展开侧边栏": "Expand sidebar", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index 35a635bd..3a216e53 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -1404,6 +1404,7 @@ "Claude思考适配 BudgetTokens = MaxTokens * BudgetTokens 百分比": "Adaptation de la pensée Claude BudgetTokens = MaxTokens * BudgetTokens pourcentage", "思考适配 BudgetTokens 百分比": "Adaptation de la pensée BudgetTokens pourcentage", "0.1-1之间的小数": "Décimal entre 0,1 et 1", + "0.1以上的小数": "Décimal supérieur à 0,1", "模型相关设置": "Paramètres liés au modèle", "收起侧边栏": "Réduire la barre latérale", "展开侧边栏": "Développer la barre latérale", diff --git a/web/src/pages/Setting/Model/SettingClaudeModel.jsx b/web/src/pages/Setting/Model/SettingClaudeModel.jsx index 04d7956a..688fc2d3 100644 --- a/web/src/pages/Setting/Model/SettingClaudeModel.jsx +++ b/web/src/pages/Setting/Model/SettingClaudeModel.jsx @@ -202,9 +202,8 @@ export default function SettingClaudeModel(props) { label={t('思考适配 BudgetTokens 百分比')} field={'claude.thinking_adapter_budget_tokens_percentage'} initValue={''} - extraText={t('0.1-1之间的小数')} + extraText={t('0.1以上的小数')} min={0.1} - max={1} onChange={(value) => setInputs({ ...inputs, From 81184240390b21aa37c04d3426755e65c70e266e Mon Sep 17 00:00:00 2001 From: Seefs Date: Tue, 30 Sep 2025 09:46:46 +0800 Subject: [PATCH 070/243] fix: claude beta=true --- relay/channel/claude/adaptor.go | 9 +++++++-- relay/common/relay_info.go | 6 +++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/relay/channel/claude/adaptor.go b/relay/channel/claude/adaptor.go index 959327e1..59f7dd0a 100644 --- a/relay/channel/claude/adaptor.go +++ b/relay/channel/claude/adaptor.go @@ -52,11 +52,16 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) { } func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + baseURL := "" if a.RequestMode == RequestModeMessage { - return fmt.Sprintf("%s/v1/messages", info.ChannelBaseUrl), nil + baseURL = fmt.Sprintf("%s/v1/messages", info.ChannelBaseUrl) } else { - return fmt.Sprintf("%s/v1/complete", info.ChannelBaseUrl), nil + baseURL = fmt.Sprintf("%s/v1/complete", info.ChannelBaseUrl) } + if info.IsClaudeBetaQuery { + baseURL = baseURL + "?beta=true" + } + return baseURL, nil } func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 99925dc5..f4ffaee2 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -105,7 +105,8 @@ type RelayInfo struct { UserQuota int RelayFormat types.RelayFormat SendResponseCount int - FinalPreConsumedQuota int // 最终预消耗的配额 + FinalPreConsumedQuota int // 最终预消耗的配额 + IsClaudeBetaQuery bool // /v1/messages?beta=true PriceData types.PriceData @@ -279,6 +280,9 @@ func GenRelayInfoClaude(c *gin.Context, request dto.Request) *RelayInfo { info.ClaudeConvertInfo = &ClaudeConvertInfo{ LastMessagesType: LastMessageTypeNone, } + if c.Query("beta") == "true" { + info.IsClaudeBetaQuery = true + } return info } From 8e10af82b1a7ef75b39062a5f82cdac3be44bfe3 Mon Sep 17 00:00:00 2001 From: CaIon Date: Tue, 30 Sep 2025 11:22:00 +0800 Subject: [PATCH 071/243] fix(main): conditionally log missing .env file message based on debug mode --- main.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index e12dddc5..ba96d209 100644 --- a/main.go +++ b/main.go @@ -185,8 +185,9 @@ func InitResources() error { // This is a placeholder function for future resource initialization err := godotenv.Load(".env") if err != nil { - common.SysLog("未找到 .env 文件,使用默认环境变量,如果需要,请创建 .env 文件并设置相关变量") - common.SysLog("No .env file found, using default environment variables. If needed, please create a .env file and set the relevant variables.") + if common.DebugEnabled { + common.SysLog("No .env file found, using default environment variables. If needed, please create a .env file and set the relevant variables.") + } } // 加载环境变量 From e8425addf08da67ca487fc71b42cdf9b5a22d17a Mon Sep 17 00:00:00 2001 From: Seefs Date: Tue, 30 Sep 2025 12:12:50 +0800 Subject: [PATCH 072/243] =?UTF-8?q?feat:=20=E9=80=9A=E7=94=A8=E4=BA=8C?= =?UTF-8?q?=E6=AD=A5=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controller/channel.go | 123 +------ controller/secure_verification.go | 313 ++++++++++++++++++ middleware/secure_verification.go | 131 ++++++++ router/api-router.go | 7 +- .../common/modals/SecureVerificationModal.jsx | 298 +++++++++-------- web/src/components/settings/SystemSetting.jsx | 2 +- .../channels/modals/EditChannelModal.jsx | 43 ++- web/src/helpers/secureApiCall.js | 62 ++++ .../hooks/common/useSecureVerification.jsx | 47 ++- web/src/i18n/locales/en.json | 1 + web/src/i18n/locales/zh.json | 3 +- web/src/services/secureVerification.js | 91 +++-- 12 files changed, 798 insertions(+), 323 deletions(-) create mode 100644 controller/secure_verification.go create mode 100644 middleware/secure_verification.go create mode 100644 web/src/helpers/secureApiCall.js diff --git a/controller/channel.go b/controller/channel.go index 542f35fd..4aedee3b 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -384,19 +384,9 @@ func GetChannel(c *gin.Context) { return } -// GetChannelKey 验证2FA或Passkey后获取渠道密钥 +// GetChannelKey 获取渠道密钥(需要通过安全验证中间件) +// 此函数依赖 SecureVerificationRequired 中间件,确保用户已通过安全验证 func GetChannelKey(c *gin.Context) { - type GetChannelKeyRequest struct { - Code string `json:"code,omitempty"` // 2FA验证码或备用码 - Method string `json:"method,omitempty"` // 验证方式: "2fa" 或 "passkey" - } - - var req GetChannelKeyRequest - if err := c.ShouldBindJSON(&req); err != nil { - common.ApiError(c, fmt.Errorf("参数错误: %v", err)) - return - } - userId := c.GetInt("id") channelId, err := strconv.Atoi(c.Param("id")) if err != nil { @@ -404,111 +394,6 @@ func GetChannelKey(c *gin.Context) { return } - // 检查用户支持的验证方式 - twoFA, err := model.GetTwoFAByUserId(userId) - if err != nil { - common.ApiError(c, fmt.Errorf("获取2FA信息失败: %v", err)) - return - } - - passkey, passkeyErr := model.GetPasskeyByUserID(userId) - hasPasskey := passkeyErr == nil && passkey != nil - - has2FA := twoFA != nil && twoFA.IsEnabled - - // 至少需要启用一种验证方式 - if !has2FA && !hasPasskey { - common.ApiError(c, fmt.Errorf("用户未启用2FA或Passkey,无法查看密钥")) - return - } - - // 根据请求的验证方式进行验证 - switch req.Method { - case "2fa": - if !has2FA { - common.ApiError(c, fmt.Errorf("用户未启用2FA")) - return - } - if req.Code == "" { - common.ApiError(c, fmt.Errorf("2FA验证码不能为空")) - return - } - if !validateTwoFactorAuth(twoFA, req.Code) { - common.ApiError(c, fmt.Errorf("验证码或备用码错误,请重试")) - return - } - - case "passkey": - if !hasPasskey { - common.ApiError(c, fmt.Errorf("用户未启用Passkey")) - return - } - // Passkey验证已在前端完成,这里只需要检查是否有有效的Passkey验证会话 - // 由于Passkey验证是基于WebAuthn协议的,验证过程已经在PasskeyVerifyFinish中完成 - // 这里我们可以设置一个临时标记来验证Passkey验证是否成功 - - default: - // 自动选择验证方式:如果提供了code则使用2FA,否则需要用户明确指定 - if req.Code != "" && has2FA { - if !validateTwoFactorAuth(twoFA, req.Code) { - common.ApiError(c, fmt.Errorf("验证码或备用码错误,请重试")) - return - } - } else { - common.ApiError(c, fmt.Errorf("请指定验证方式(method: '2fa' 或 'passkey')")) - return - } - } - - // 获取渠道信息(包含密钥) - channel, err := model.GetChannelById(channelId, true) - if err != nil { - common.ApiError(c, fmt.Errorf("获取渠道信息失败: %v", err)) - return - } - - if channel == nil { - common.ApiError(c, fmt.Errorf("渠道不存在")) - return - } - - // 记录操作日志 - logMethod := req.Method - if logMethod == "" { - logMethod = "2fa" - } - model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d, 验证方式: %s)", channelId, logMethod)) - - // 统一的成功响应格式 - c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "验证成功", - "data": map[string]interface{}{ - "key": channel.Key, - }, - }) -} - -// GetChannelKeyWithPasskey 使用Passkey验证查看渠道密钥 -func GetChannelKeyWithPasskey(c *gin.Context) { - userId := c.GetInt("id") - channelId, err := strconv.Atoi(c.Param("id")) - if err != nil { - common.ApiError(c, fmt.Errorf("渠道ID格式错误: %v", err)) - return - } - - // 检查用户是否已绑定Passkey - passkey, err := model.GetPasskeyByUserID(userId) - if err != nil { - common.ApiError(c, fmt.Errorf("用户未绑定Passkey,无法使用此验证方式")) - return - } - if passkey == nil { - common.ApiError(c, fmt.Errorf("用户未绑定Passkey")) - return - } - // 获取渠道信息(包含密钥) channel, err := model.GetChannelById(channelId, true) if err != nil { @@ -522,12 +407,12 @@ func GetChannelKeyWithPasskey(c *gin.Context) { } // 记录操作日志 - model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d, 验证方式: passkey)", channelId)) + model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d)", channelId)) // 返回渠道密钥 c.JSON(http.StatusOK, gin.H{ "success": true, - "message": "Passkey验证成功", + "message": "获取成功", "data": map[string]interface{}{ "key": channel.Key, }, diff --git a/controller/secure_verification.go b/controller/secure_verification.go new file mode 100644 index 00000000..1c5f0981 --- /dev/null +++ b/controller/secure_verification.go @@ -0,0 +1,313 @@ +package controller + +import ( + "fmt" + "net/http" + "one-api/common" + "one-api/model" + passkeysvc "one-api/service/passkey" + "one-api/setting/system_setting" + "time" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +const ( + // SecureVerificationSessionKey 安全验证的 session key + SecureVerificationSessionKey = "secure_verified_at" + // SecureVerificationTimeout 验证有效期(秒) + SecureVerificationTimeout = 300 // 5分钟 +) + +type UniversalVerifyRequest struct { + Method string `json:"method"` // "2fa" 或 "passkey" + Code string `json:"code,omitempty"` +} + +type VerificationStatusResponse struct { + Verified bool `json:"verified"` + ExpiresAt int64 `json:"expires_at,omitempty"` +} + +// UniversalVerify 通用验证接口 +// 支持 2FA 和 Passkey 验证,验证成功后在 session 中记录时间戳 +func UniversalVerify(c *gin.Context) { + userId := c.GetInt("id") + if userId == 0 { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "未登录", + }) + return + } + + var req UniversalVerifyRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiError(c, fmt.Errorf("参数错误: %v", err)) + return + } + + // 获取用户信息 + user := &model.User{Id: userId} + if err := user.FillUserById(); err != nil { + common.ApiError(c, fmt.Errorf("获取用户信息失败: %v", err)) + return + } + + if user.Status != common.UserStatusEnabled { + common.ApiError(c, fmt.Errorf("该用户已被禁用")) + return + } + + // 检查用户的验证方式 + twoFA, _ := model.GetTwoFAByUserId(userId) + has2FA := twoFA != nil && twoFA.IsEnabled + + passkey, passkeyErr := model.GetPasskeyByUserID(userId) + hasPasskey := passkeyErr == nil && passkey != nil + + if !has2FA && !hasPasskey { + common.ApiError(c, fmt.Errorf("用户未启用2FA或Passkey")) + return + } + + // 根据验证方式进行验证 + var verified bool + var verifyMethod string + + switch req.Method { + case "2fa": + if !has2FA { + common.ApiError(c, fmt.Errorf("用户未启用2FA")) + return + } + if req.Code == "" { + common.ApiError(c, fmt.Errorf("验证码不能为空")) + return + } + verified = validateTwoFactorAuth(twoFA, req.Code) + verifyMethod = "2FA" + + case "passkey": + if !hasPasskey { + common.ApiError(c, fmt.Errorf("用户未启用Passkey")) + return + } + // Passkey 验证需要先调用 PasskeyVerifyBegin 和 PasskeyVerifyFinish + // 这里只是验证 Passkey 验证流程是否已经完成 + // 实际上,前端应该先调用这两个接口,然后再调用本接口 + verified = true // Passkey 验证逻辑已在 PasskeyVerifyFinish 中完成 + verifyMethod = "Passkey" + + default: + common.ApiError(c, fmt.Errorf("不支持的验证方式: %s", req.Method)) + return + } + + if !verified { + common.ApiError(c, fmt.Errorf("验证失败,请检查验证码")) + return + } + + // 验证成功,在 session 中记录时间戳 + session := sessions.Default(c) + now := time.Now().Unix() + session.Set(SecureVerificationSessionKey, now) + if err := session.Save(); err != nil { + common.ApiError(c, fmt.Errorf("保存验证状态失败: %v", err)) + return + } + + // 记录日志 + model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("通用安全验证成功 (验证方式: %s)", verifyMethod)) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "验证成功", + "data": gin.H{ + "verified": true, + "expires_at": now + SecureVerificationTimeout, + }, + }) +} + +// GetVerificationStatus 获取验证状态 +func GetVerificationStatus(c *gin.Context) { + userId := c.GetInt("id") + if userId == 0 { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "未登录", + }) + return + } + + session := sessions.Default(c) + verifiedAtRaw := session.Get(SecureVerificationSessionKey) + + if verifiedAtRaw == nil { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": VerificationStatusResponse{ + Verified: false, + }, + }) + return + } + + verifiedAt, ok := verifiedAtRaw.(int64) + if !ok { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": VerificationStatusResponse{ + Verified: false, + }, + }) + return + } + + elapsed := time.Now().Unix() - verifiedAt + if elapsed >= SecureVerificationTimeout { + // 验证已过期 + session.Delete(SecureVerificationSessionKey) + _ = session.Save() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": VerificationStatusResponse{ + Verified: false, + }, + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": VerificationStatusResponse{ + Verified: true, + ExpiresAt: verifiedAt + SecureVerificationTimeout, + }, + }) +} + +// CheckSecureVerification 检查是否已通过安全验证 +// 返回 true 表示验证有效,false 表示需要重新验证 +func CheckSecureVerification(c *gin.Context) bool { + session := sessions.Default(c) + verifiedAtRaw := session.Get(SecureVerificationSessionKey) + + if verifiedAtRaw == nil { + return false + } + + verifiedAt, ok := verifiedAtRaw.(int64) + if !ok { + return false + } + + elapsed := time.Now().Unix() - verifiedAt + if elapsed >= SecureVerificationTimeout { + // 验证已过期,清除 session + session.Delete(SecureVerificationSessionKey) + _ = session.Save() + return false + } + + return true +} + +// PasskeyVerifyAndSetSession Passkey 验证完成后设置 session +// 这是一个辅助函数,供 PasskeyVerifyFinish 调用 +func PasskeyVerifyAndSetSession(c *gin.Context) { + session := sessions.Default(c) + now := time.Now().Unix() + session.Set(SecureVerificationSessionKey, now) + _ = session.Save() +} + +// PasskeyVerifyForSecure 用于安全验证的 Passkey 验证流程 +// 整合了 begin 和 finish 流程 +func PasskeyVerifyForSecure(c *gin.Context) { + if !system_setting.GetPasskeySettings().Enabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未启用 Passkey 登录", + }) + return + } + + userId := c.GetInt("id") + if userId == 0 { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "未登录", + }) + return + } + + user := &model.User{Id: userId} + if err := user.FillUserById(); err != nil { + common.ApiError(c, fmt.Errorf("获取用户信息失败: %v", err)) + return + } + + if user.Status != common.UserStatusEnabled { + common.ApiError(c, fmt.Errorf("该用户已被禁用")) + return + } + + credential, err := model.GetPasskeyByUserID(userId) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该用户尚未绑定 Passkey", + }) + return + } + + wa, err := passkeysvc.BuildWebAuthn(c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + waUser := passkeysvc.NewWebAuthnUser(user, credential) + sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.VerifySessionKey) + if err != nil { + common.ApiError(c, err) + return + } + + _, err = wa.FinishLogin(waUser, *sessionData, c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + // 更新凭证的最后使用时间 + now := time.Now() + credential.LastUsedAt = &now + if err := model.UpsertPasskeyCredential(credential); err != nil { + common.ApiError(c, err) + return + } + + // 验证成功,设置 session + PasskeyVerifyAndSetSession(c) + + // 记录日志 + model.RecordLog(userId, model.LogTypeSystem, "Passkey 安全验证成功") + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Passkey 验证成功", + "data": gin.H{ + "verified": true, + "expires_at": time.Now().Unix() + SecureVerificationTimeout, + }, + }) +} diff --git a/middleware/secure_verification.go b/middleware/secure_verification.go new file mode 100644 index 00000000..19fae9a5 --- /dev/null +++ b/middleware/secure_verification.go @@ -0,0 +1,131 @@ +package middleware + +import ( + "net/http" + "time" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +const ( + // SecureVerificationSessionKey 安全验证的 session key(与 controller 保持一致) + SecureVerificationSessionKey = "secure_verified_at" + // SecureVerificationTimeout 验证有效期(秒) + SecureVerificationTimeout = 300 // 5分钟 +) + +// SecureVerificationRequired 安全验证中间件 +// 检查用户是否在有效时间内通过了安全验证 +// 如果未验证或验证已过期,返回 401 错误 +func SecureVerificationRequired() gin.HandlerFunc { + return func(c *gin.Context) { + // 检查用户是否已登录 + userId := c.GetInt("id") + if userId == 0 { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "未登录", + }) + c.Abort() + return + } + + // 检查 session 中的验证时间戳 + session := sessions.Default(c) + verifiedAtRaw := session.Get(SecureVerificationSessionKey) + + if verifiedAtRaw == nil { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "需要安全验证", + "code": "VERIFICATION_REQUIRED", + }) + c.Abort() + return + } + + verifiedAt, ok := verifiedAtRaw.(int64) + if !ok { + // session 数据格式错误 + session.Delete(SecureVerificationSessionKey) + _ = session.Save() + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "验证状态异常,请重新验证", + "code": "VERIFICATION_INVALID", + }) + c.Abort() + return + } + + // 检查验证是否过期 + elapsed := time.Now().Unix() - verifiedAt + if elapsed >= SecureVerificationTimeout { + // 验证已过期,清除 session + session.Delete(SecureVerificationSessionKey) + _ = session.Save() + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "验证已过期,请重新验证", + "code": "VERIFICATION_EXPIRED", + }) + c.Abort() + return + } + + // 验证有效,继续处理请求 + c.Next() + } +} + +// OptionalSecureVerification 可选的安全验证中间件 +// 如果用户已验证,则在 context 中设置标记,但不阻止请求继续 +// 用于某些需要区分是否已验证的场景 +func OptionalSecureVerification() gin.HandlerFunc { + return func(c *gin.Context) { + userId := c.GetInt("id") + if userId == 0 { + c.Set("secure_verified", false) + c.Next() + return + } + + session := sessions.Default(c) + verifiedAtRaw := session.Get(SecureVerificationSessionKey) + + if verifiedAtRaw == nil { + c.Set("secure_verified", false) + c.Next() + return + } + + verifiedAt, ok := verifiedAtRaw.(int64) + if !ok { + c.Set("secure_verified", false) + c.Next() + return + } + + elapsed := time.Now().Unix() - verifiedAt + if elapsed >= SecureVerificationTimeout { + session.Delete(SecureVerificationSessionKey) + _ = session.Save() + c.Set("secure_verified", false) + c.Next() + return + } + + c.Set("secure_verified", true) + c.Set("secure_verified_at", verifiedAt) + c.Next() + } +} + +// ClearSecureVerification 清除安全验证状态 +// 用于用户登出或需要强制重新验证的场景 +func ClearSecureVerification(c *gin.Context) { + session := sessions.Default(c) + session.Delete(SecureVerificationSessionKey) + _ = session.Save() +} diff --git a/router/api-router.go b/router/api-router.go index 31d4ba3f..4afc0a0f 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -40,6 +40,10 @@ func SetApiRouter(router *gin.Engine) { apiRouter.POST("/stripe/webhook", controller.StripeWebhook) + // Universal secure verification routes + apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify) + apiRouter.GET("/verify/status", middleware.UserAuth(), controller.GetVerificationStatus) + userRoute := apiRouter.Group("/user") { userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register) @@ -124,8 +128,7 @@ func SetApiRouter(router *gin.Engine) { channelRoute.GET("/models", controller.ChannelListModels) channelRoute.GET("/models_enabled", controller.EnabledListModels) channelRoute.GET("/:id", controller.GetChannel) - channelRoute.POST("/:id/key", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetChannelKey) - channelRoute.POST("/:id/key/passkey", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetChannelKeyWithPasskey) + channelRoute.POST("/:id/key", middleware.CriticalRateLimit(), middleware.DisableCache(), middleware.SecureVerificationRequired(), controller.GetChannelKey) channelRoute.GET("/test", controller.TestAllChannels) channelRoute.GET("/test/:id", controller.TestChannel) channelRoute.GET("/update_balance", controller.UpdateAllChannelsBalance) diff --git a/web/src/components/common/modals/SecureVerificationModal.jsx b/web/src/components/common/modals/SecureVerificationModal.jsx index 46770aa7..06f18c7e 100644 --- a/web/src/components/common/modals/SecureVerificationModal.jsx +++ b/web/src/components/common/modals/SecureVerificationModal.jsx @@ -17,9 +17,9 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Modal, Button, Input, Typography, Tabs, TabPane, Card } from '@douyinfe/semi-ui'; +import { Modal, Button, Input, Typography, Tabs, TabPane, Space, Spin } from '@douyinfe/semi-ui'; /** * 通用安全验证模态框组件 @@ -47,14 +47,28 @@ const SecureVerificationModal = ({ description, }) => { const { t } = useTranslation(); + const [isAnimating, setIsAnimating] = useState(false); + const [verifySuccess, setVerifySuccess] = useState(false); const { has2FA, hasPasskey, passkeySupported } = verificationMethods; const { method, loading, code } = verificationState; + useEffect(() => { + if (visible) { + setIsAnimating(true); + setVerifySuccess(false); + } else { + setIsAnimating(false); + } + }, [visible]); + const handleKeyDown = (e) => { if (e.key === 'Enter' && code.trim() && !loading && method === '2fa') { onVerify(method, code); } + if (e.key === 'Escape' && !loading) { + onCancel(); + } }; // 如果用户没有启用任何验证方式 @@ -101,165 +115,165 @@ const SecureVerificationModal = ({ return ( -
- - - -
- {title || t('安全验证')} -
- } + title={title || t('安全验证')} visible={visible} - onCancel={onCancel} + onCancel={loading ? undefined : onCancel} + closeOnEsc={!loading} footer={null} - width={600} - style={{ maxWidth: '90vw' }} + width={460} + centered + style={{ + maxWidth: 'calc(100vw - 32px)' + }} + bodyStyle={{ + padding: '20px 24px' + }} > -
- {/* 安全提示 */} -
-
- - - -
- - {t('安全验证')} - - - {description || t('为了保护账户安全,请选择一种方式进行验证。')} - -
-
-
+
+ {/* 描述信息 */} + {description && ( + + {description} + + )} {/* 验证方式选择 */} - + {has2FA && ( - - - - - {t('两步验证')} -
- } + tab={t('两步验证')} itemKey='2fa' > - -
-
- - {t('验证码')} - - - - {t('支持6位TOTP验证码或8位备用码')} - -
-
- - -
+
+
+ + + + } + style={{ width: '100%' }} + />
- + + + {t('从认证器应用中获取验证码,或使用备用码')} + + +
+ + +
+
)} {hasPasskey && passkeySupported && ( - - - - {t('Passkey')} -
- } + tab={t('Passkey')} itemKey='passkey' > - -
-
-
- - - -
- - {t('使用 Passkey 验证')} - - - {t('点击下方按钮,使用您的生物特征或安全密钥进行验证')} - -
-
- - +
+
+
+ + +
+ + {t('使用 Passkey 验证')} + + + {t('点击验证按钮,使用您的生物特征或安全密钥')} +
- + +
+ + +
+
)} diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx index abb55301..f0c2dbc3 100644 --- a/web/src/components/settings/SystemSetting.jsx +++ b/web/src/components/settings/SystemSetting.jsx @@ -1043,7 +1043,7 @@ const SystemSetting = () => { handleCheckboxChange('passkey.enabled', e) } > - {t('允许通过 Passkey 登录 & 注册')} + {t('允许通过 Passkey 登录 & 认证')} diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 27499f82..54b4525d 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -206,7 +206,7 @@ const EditChannelModal = (props) => { isModalVisible, verificationMethods, verificationState, - startVerification, + withVerification, executeVerification, cancelVerification, setVerificationCode, @@ -214,12 +214,20 @@ const EditChannelModal = (props) => { } = useSecureVerification({ onSuccess: (result) => { // 验证成功后显示密钥 - if (result.success && result.data?.key) { + console.log('Verification success, result:', result); + if (result && result.success && result.data?.key) { showSuccess(t('密钥获取成功')); setKeyDisplayState({ showModal: true, keyData: result.data.key, }); + } else if (result && result.key) { + // 直接返回了 key(没有包装在 data 中) + showSuccess(t('密钥获取成功')); + setKeyDisplayState({ + showModal: true, + keyData: result.key, + }); } }, }); @@ -604,19 +612,30 @@ const EditChannelModal = (props) => { } }; - // 显示安全验证模态框并开始验证流程 + // 查看渠道密钥(透明验证) const handleShow2FAModal = async () => { try { - const apiCall = createApiCalls.viewChannelKey(channelId); - - await startVerification(apiCall, { - title: t('查看渠道密钥'), - description: t('为了保护账户安全,请验证您的身份。'), - preferredMethod: 'passkey', // 优先使用 Passkey - }); + // 使用 withVerification 包装,会自动处理需要验证的情况 + const result = await withVerification( + createApiCalls.viewChannelKey(channelId), + { + title: t('查看渠道密钥'), + description: t('为了保护账户安全,请验证您的身份。'), + preferredMethod: 'passkey', // 优先使用 Passkey + } + ); + + // 如果直接返回了结果(已验证),显示密钥 + if (result && result.success && result.data?.key) { + showSuccess(t('密钥获取成功')); + setKeyDisplayState({ + showModal: true, + keyData: result.data.key, + }); + } } catch (error) { - console.error('Failed to start verification:', error); - showError(error.message || t('启动验证失败')); + console.error('Failed to view channel key:', error); + showError(error.message || t('获取密钥失败')); } }; diff --git a/web/src/helpers/secureApiCall.js b/web/src/helpers/secureApiCall.js new file mode 100644 index 00000000..b82a6ae9 --- /dev/null +++ b/web/src/helpers/secureApiCall.js @@ -0,0 +1,62 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +/** + * 安全 API 调用包装器 + * 自动处理需要验证的 403 错误,透明地触发验证流程 + */ + +/** + * 检查错误是否是需要安全验证的错误 + * @param {Error} error - 错误对象 + * @returns {boolean} + */ +export function isVerificationRequiredError(error) { + if (!error.response) return false; + + const { status, data } = error.response; + + // 检查是否是 403 错误且包含验证相关的错误码 + if (status === 403 && data) { + const verificationCodes = [ + 'VERIFICATION_REQUIRED', + 'VERIFICATION_EXPIRED', + 'VERIFICATION_INVALID' + ]; + + return verificationCodes.includes(data.code); + } + + return false; +} + +/** + * 从错误中提取验证需求信息 + * @param {Error} error - 错误对象 + * @returns {Object} 验证需求信息 + */ +export function extractVerificationInfo(error) { + const data = error.response?.data || {}; + + return { + code: data.code, + message: data.message || '需要安全验证', + required: true + }; +} \ No newline at end of file diff --git a/web/src/hooks/common/useSecureVerification.jsx b/web/src/hooks/common/useSecureVerification.jsx index 271345d1..e60a104d 100644 --- a/web/src/hooks/common/useSecureVerification.jsx +++ b/web/src/hooks/common/useSecureVerification.jsx @@ -21,6 +21,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { SecureVerificationService } from '../../services/secureVerification'; import { showError, showSuccess } from '../../helpers'; +import { isVerificationRequiredError } from '../../helpers/secureApiCall'; /** * 通用安全验证 Hook @@ -82,10 +83,10 @@ export const useSecureVerification = ({ // 开始验证流程 const startVerification = useCallback(async (apiCall, options = {}) => { const { preferredMethod, title, description } = options; - + // 检查验证方式 const methods = await checkVerificationMethods(); - + if (!methods.has2FA && !methods.hasPasskey) { const errorMessage = t('您需要先启用两步验证或 Passkey 才能执行此操作'); showError(errorMessage); @@ -111,7 +112,7 @@ export const useSecureVerification = ({ description })); setIsModalVisible(true); - + return true; }, [checkVerificationMethods, onError, t]); @@ -125,10 +126,11 @@ export const useSecureVerification = ({ setVerificationState(prev => ({ ...prev, loading: true })); try { - const result = await SecureVerificationService.verify(method, { - code, - apiCall: verificationState.apiCall - }); + // 先调用验证 API,成功后后端会设置 session + await SecureVerificationService.verify(method, code); + + // 验证成功,调用业务 API(此时中间件会通过) + const result = await verificationState.apiCall(); // 显示成功消息 if (successMessage) { @@ -191,12 +193,36 @@ export const useSecureVerification = ({ return null; }, [verificationMethods]); + /** + * 包装 API 调用,自动处理验证错误 + * 当 API 返回需要验证的错误时,自动弹出验证模态框 + * @param {Function} apiCall - API 调用函数 + * @param {Object} options - 验证选项(同 startVerification) + * @returns {Promise} + */ + const withVerification = useCallback(async (apiCall, options = {}) => { + try { + // 直接尝试调用 API + return await apiCall(); + } catch (error) { + // 检查是否是需要验证的错误 + if (isVerificationRequiredError(error)) { + // 自动触发验证流程 + await startVerification(apiCall, options); + // 不抛出错误,让验证模态框处理 + return null; + } + // 其他错误继续抛出 + throw error; + } + }, [startVerification]); + return { // 状态 isModalVisible, verificationMethods, verificationState, - + // 方法 startVerification, executeVerification, @@ -205,11 +231,12 @@ export const useSecureVerification = ({ setVerificationCode, switchVerificationMethod, checkVerificationMethods, - + // 辅助方法 canUseMethod, getRecommendedMethod, - + withVerification, // 新增:自动处理验证的包装函数 + // 便捷属性 hasAnyVerificationMethod: verificationMethods.has2FA || verificationMethods.hasPasskey, isLoading: verificationState.loading, diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index e221c3b2..5586e0a8 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -333,6 +333,7 @@ "通过密码注册时需要进行邮箱验证": "Email verification is required when registering via password", "允许通过 GitHub 账户登录 & 注册": "Allow login & registration via GitHub account", "允许通过微信登录 & 注册": "Allow login & registration via WeChat", + "允许通过 Passkey 登录 & 认证": "Allow login & authentication via Passkey", "允许新用户注册(此项为否时,新用户将无法以任何方式进行注册": "Allow new user registration (if this option is off, new users will not be able to register in any way", "启用 Turnstile 用户校验": "Enable Turnstile user verification", "配置 SMTP": "Configure SMTP", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 26c41820..e6dafac1 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -87,5 +87,6 @@ "此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。": "此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。", "目标用户:{{username}}": "目标用户:{{username}}", "Passkey 已重置": "Passkey 已重置", - "二步验证已重置": "二步验证已重置" + "二步验证已重置": "二步验证已重置", + "允许通过 Passkey 登录 & 认证": "允许通过 Passkey 登录 & 认证" } diff --git a/web/src/services/secureVerification.js b/web/src/services/secureVerification.js index 1af53204..93cdd0a4 100644 --- a/web/src/services/secureVerification.js +++ b/web/src/services/secureVerification.js @@ -18,14 +18,15 @@ For commercial licensing, please contact support@quantumnous.com */ import { API, showError } from '../helpers'; -import { - prepareCredentialRequestOptions, - buildAssertionResult, - isPasskeySupported +import { + prepareCredentialRequestOptions, + buildAssertionResult, + isPasskeySupported } from '../helpers/passkey'; /** * 通用安全验证服务 + * 验证状态完全由后端 Session 控制,前端不存储任何状态 */ export class SecureVerificationService { /** @@ -81,36 +82,41 @@ export class SecureVerificationService { /** * 执行2FA验证 * @param {string} code - 验证码 - * @param {Function} apiCall - API调用函数,接收 {method: '2fa', code} 参数 - * @returns {Promise} API响应结果 + * @returns {Promise} */ - static async verify2FA(code, apiCall) { + static async verify2FA(code) { if (!code?.trim()) { throw new Error('请输入验证码或备用码'); } - return await apiCall({ + // 调用通用验证 API,验证成功后后端会设置 session + const verifyResponse = await API.post('/api/verify', { method: '2fa', code: code.trim() }); + + if (!verifyResponse.data?.success) { + throw new Error(verifyResponse.data?.message || '验证失败'); + } + + // 验证成功,session 已在后端设置 } /** * 执行Passkey验证 - * @param {Function} apiCall - API调用函数,接收 {method: 'passkey'} 参数 - * @returns {Promise} API响应结果 + * @returns {Promise} */ - static async verifyPasskey(apiCall) { + static async verifyPasskey() { try { // 开始Passkey验证 const beginResponse = await API.post('/api/user/passkey/verify/begin'); - if (!beginResponse.success) { - throw new Error(beginResponse.message); + if (!beginResponse.data?.success) { + throw new Error(beginResponse.data?.message || '开始验证失败'); } // 准备WebAuthn选项 - const publicKey = prepareCredentialRequestOptions(beginResponse.data); - + const publicKey = prepareCredentialRequestOptions(beginResponse.data.data.options); + // 执行WebAuthn验证 const credential = await navigator.credentials.get({ publicKey }); if (!credential) { @@ -119,17 +125,23 @@ export class SecureVerificationService { // 构建验证结果 const assertionResult = buildAssertionResult(credential); - + // 完成验证 const finishResponse = await API.post('/api/user/passkey/verify/finish', assertionResult); - if (!finishResponse.success) { - throw new Error(finishResponse.message); + if (!finishResponse.data?.success) { + throw new Error(finishResponse.data?.message || '验证失败'); } - // 调用业务API - return await apiCall({ + // 调用通用验证 API 设置 session(Passkey 验证已完成) + const verifyResponse = await API.post('/api/verify', { method: 'passkey' }); + + if (!verifyResponse.data?.success) { + throw new Error(verifyResponse.data?.message || '验证失败'); + } + + // 验证成功,session 已在后端设置 } catch (error) { if (error.name === 'NotAllowedError') { throw new Error('Passkey 验证被取消或超时'); @@ -144,17 +156,15 @@ export class SecureVerificationService { /** * 通用验证方法,根据验证类型执行相应的验证流程 * @param {string} method - 验证方式: '2fa' | 'passkey' - * @param {Object} params - 参数对象 - * @param {string} params.code - 2FA验证码(当method为'2fa'时必需) - * @param {Function} params.apiCall - API调用函数 - * @returns {Promise} API响应结果 + * @param {string} code - 2FA验证码(当method为'2fa'时必需) + * @returns {Promise} */ - static async verify(method, { code, apiCall }) { + static async verify(method, code = '') { switch (method) { case '2fa': - return await this.verify2FA(code, apiCall); + return await this.verify2FA(code); case 'passkey': - return await this.verifyPasskey(apiCall); + return await this.verifyPasskey(); default: throw new Error(`不支持的验证方式: ${method}`); } @@ -169,8 +179,10 @@ export const createApiCalls = { * 创建查看渠道密钥的API调用 * @param {number} channelId - 渠道ID */ - viewChannelKey: (channelId) => async (verificationData) => { - return await API.post(`/api/channel/${channelId}/key`, verificationData); + viewChannelKey: (channelId) => async () => { + // 新系统中,验证已通过中间件处理,直接调用 API 即可 + const response = await API.post(`/api/channel/${channelId}/key`, {}); + return response.data; }, /** @@ -179,20 +191,27 @@ export const createApiCalls = { * @param {string} method - HTTP方法,默认为 'POST' * @param {Object} extraData - 额外的请求数据 */ - custom: (url, method = 'POST', extraData = {}) => async (verificationData) => { - const data = { ...extraData, ...verificationData }; - + custom: (url, method = 'POST', extraData = {}) => async () => { + // 新系统中,验证已通过中间件处理 + const data = extraData; + + let response; switch (method.toUpperCase()) { case 'GET': - return await API.get(url, { params: data }); + response = await API.get(url, { params: data }); + break; case 'POST': - return await API.post(url, data); + response = await API.post(url, data); + break; case 'PUT': - return await API.put(url, data); + response = await API.put(url, data); + break; case 'DELETE': - return await API.delete(url, { data }); + response = await API.delete(url, { data }); + break; default: throw new Error(`不支持的HTTP方法: ${method}`); } + return response.data; } }; \ No newline at end of file From 013a575541b7da22a2a4ada911bbd82fc79da0ef Mon Sep 17 00:00:00 2001 From: Seefs Date: Tue, 30 Sep 2025 12:26:24 +0800 Subject: [PATCH 073/243] fix: personal setting --- model/passkey.go | 5 +++-- .../personal/cards/AccountManagement.jsx | 21 +++++++++++++++---- web/src/i18n/locales/en.json | 3 +++ web/src/i18n/locales/zh.json | 5 ++++- 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/model/passkey.go b/model/passkey.go index 09263901..3f45e176 100644 --- a/model/passkey.go +++ b/model/passkey.go @@ -141,9 +141,10 @@ func GetPasskeyByUserID(userID int) (*PasskeyCredential, error) { var credential PasskeyCredential if err := DB.Where("user_id = ?", userID).First(&credential).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - common.SysLog(fmt.Sprintf("GetPasskeyByUserID: passkey not found for user %d", userID)) - return nil, ErrFriendlyPasskeyNotFound + // 未找到记录是正常情况(用户未绑定),返回 ErrPasskeyNotFound 而不记录日志 + return nil, ErrPasskeyNotFound } + // 只有真正的数据库错误才记录日志 common.SysLog(fmt.Sprintf("GetPasskeyByUserID: database error for user %d: %v", userID, err)) return nil, ErrFriendlyPasskeyNotFound } diff --git a/web/src/components/settings/personal/cards/AccountManagement.jsx b/web/src/components/settings/personal/cards/AccountManagement.jsx index b5baa55e..93a2daf8 100644 --- a/web/src/components/settings/personal/cards/AccountManagement.jsx +++ b/web/src/components/settings/personal/cards/AccountManagement.jsx @@ -525,10 +525,23 @@ const AccountManagement = ({
- - - + + + + ), + }, + ]; + return ( -
(refForm.current = formAPI)} - style={{ marginBottom: 15 }} - > + - { - return verifyJSON(value); - }, - message: t('不是合法的 JSON 字符串'), - }, - ]} - onChange={(value) => - setInputs({ - ...inputs, - Chats: value, - }) - } - /> + + + +
+ + {t('编辑模式')}: + + { + const newMode = e.target.value; + setEditMode(newMode); + + // 确保模式切换时数据正确同步 + setTimeout(() => { + if (newMode === 'json' && refForm.current) { + refForm.current.setValues(inputs); + } + }, 100); + }} + > + {t('可视化编辑')} + {t('JSON编辑')} + +
+ + {editMode === 'visual' ? ( +
+ + + } + placeholder={t('搜索聊天应用名称')} + value={searchText} + onChange={(value) => setSearchText(value)} + style={{ width: 250 }} + showClear + /> + + + + t('共 {{total}} 项,当前显示 {{start}}-{{end}} 项', { + total, + start: range[0], + end: range[1], + }), + }} + /> + + ) : ( + (refForm.current = formAPI)} + > + { + return verifyJSON(value); + }, + message: t('不是合法的 JSON 字符串'), + }, + ]} + onChange={(value) => + setInputs({ + ...inputs, + Chats: value, + }) + } + /> + + )} - - - + + + + + + +
(modalFormRef.current = api)}> + + + + +
); } From 14283385467676ef10bbec65ec1fe145d0d73ee6 Mon Sep 17 00:00:00 2001 From: HynoR <20227709+HynoR@users.noreply.github.com> Date: Wed, 1 Oct 2025 18:40:02 +0800 Subject: [PATCH 087/243] feat: Enhance SettingsChats edit interface --- web/src/pages/Setting/Chat/SettingsChats.jsx | 69 +++++++++++++++++--- 1 file changed, 61 insertions(+), 8 deletions(-) diff --git a/web/src/pages/Setting/Chat/SettingsChats.jsx b/web/src/pages/Setting/Chat/SettingsChats.jsx index 368a66f5..01591c78 100644 --- a/web/src/pages/Setting/Chat/SettingsChats.jsx +++ b/web/src/pages/Setting/Chat/SettingsChats.jsx @@ -36,6 +36,7 @@ import { IconEdit, IconDelete, IconSearch, + IconSaveStroked, } from '@douyinfe/semi-icons'; import { compareObjects, @@ -55,7 +56,7 @@ export default function SettingsChats(props) { }); const refForm = useRef(); const [inputsRow, setInputsRow] = useState(inputs); - const [editMode, setEditMode] = useState('json'); + const [editMode, setEditMode] = useState('visual'); const [chatConfigs, setChatConfigs] = useState([]); const [modalVisible, setModalVisible] = useState(false); const [editingConfig, setEditingConfig] = useState(null); @@ -167,7 +168,9 @@ export default function SettingsChats(props) { } setInputs(currentInputs); setInputsRow(structuredClone(currentInputs)); - refForm.current.setValues(currentInputs); + if (refForm.current) { + refForm.current.setValues(currentInputs); + } // 同步到可视化配置 const configs = jsonToConfigs(currentInputs.Chats || '[]'); @@ -220,6 +223,18 @@ export default function SettingsChats(props) { modalFormRef.current .validate() .then((values) => { + // 检查名称是否重复 + const isDuplicate = chatConfigs.some( + (config) => + config.name === values.name && + (!isEdit || config.id !== editingConfig.id) + ); + + if (isDuplicate) { + showError(t('聊天应用名称已存在,请使用其他名称')); + return; + } + if (isEdit) { const newConfigs = chatConfigs.map((config) => config.id === editingConfig.id @@ -263,6 +278,28 @@ export default function SettingsChats(props) { config.name.toLowerCase().includes(searchText.toLowerCase()), ); + const highlightKeywords = (text) => { + if (!text) return text; + + const parts = text.split(/(\{address\}|\{key\})/g); + return parts.map((part, index) => { + if (part === '{address}') { + return ( + + {part} + + ); + } else if (part === '{key}') { + return ( + + {part} + + ); + } + return part; + }); + }; + const columns = [ { title: t('聊天应用名称'), @@ -275,7 +312,9 @@ export default function SettingsChats(props) { dataIndex: 'url', key: 'url', render: (text) => ( -
{text}
+
+ {highlightKeywords(text)} +
), }, { @@ -351,6 +390,14 @@ export default function SettingsChats(props) { > {t('添加聊天配置')} + } placeholder={t('搜索聊天应用名称')} @@ -410,11 +457,17 @@ export default function SettingsChats(props) { )} - - - + {editMode === 'json' && ( + + + + )} Date: Wed, 1 Oct 2025 19:15:00 +0800 Subject: [PATCH 088/243] feat: add Gotify notification option for quota alerts --- controller/user.go | 51 +++++++- dto/user_settings.go | 4 + service/quota.go | 5 +- service/user_notify.go | 116 +++++++++++++++++- .../components/settings/PersonalSetting.jsx | 15 +++ .../personal/cards/NotificationSettings.jsx | 102 +++++++++++++++ 6 files changed, 287 insertions(+), 6 deletions(-) diff --git a/controller/user.go b/controller/user.go index c03afa32..33d4636b 100644 --- a/controller/user.go +++ b/controller/user.go @@ -1102,6 +1102,9 @@ type UpdateUserSettingRequest struct { WebhookSecret string `json:"webhook_secret,omitempty"` NotificationEmail string `json:"notification_email,omitempty"` BarkUrl string `json:"bark_url,omitempty"` + GotifyUrl string `json:"gotify_url,omitempty"` + GotifyToken string `json:"gotify_token,omitempty"` + GotifyPriority int `json:"gotify_priority,omitempty"` AcceptUnsetModelRatioModel bool `json:"accept_unset_model_ratio_model"` RecordIpLog bool `json:"record_ip_log"` } @@ -1117,7 +1120,7 @@ func UpdateUserSetting(c *gin.Context) { } // 验证预警类型 - if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark { + if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark && req.QuotaWarningType != dto.NotifyTypeGotify { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "无效的预警类型", @@ -1192,6 +1195,40 @@ func UpdateUserSetting(c *gin.Context) { } } + // 如果是Gotify类型,验证Gotify URL和Token + if req.QuotaWarningType == dto.NotifyTypeGotify { + if req.GotifyUrl == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "Gotify服务器地址不能为空", + }) + return + } + if req.GotifyToken == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "Gotify令牌不能为空", + }) + return + } + // 验证URL格式 + if _, err := url.ParseRequestURI(req.GotifyUrl); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的Gotify服务器地址", + }) + return + } + // 检查是否是HTTP或HTTPS + if !strings.HasPrefix(req.GotifyUrl, "https://") && !strings.HasPrefix(req.GotifyUrl, "http://") { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "Gotify服务器地址必须以http://或https://开头", + }) + return + } + } + userId := c.GetInt("id") user, err := model.GetUserById(userId, true) if err != nil { @@ -1225,6 +1262,18 @@ func UpdateUserSetting(c *gin.Context) { settings.BarkUrl = req.BarkUrl } + // 如果是Gotify类型,添加Gotify配置到设置中 + if req.QuotaWarningType == dto.NotifyTypeGotify { + settings.GotifyUrl = req.GotifyUrl + settings.GotifyToken = req.GotifyToken + // Gotify优先级范围0-10,超出范围则使用默认值5 + if req.GotifyPriority < 0 || req.GotifyPriority > 10 { + settings.GotifyPriority = 5 + } else { + settings.GotifyPriority = req.GotifyPriority + } + } + // 更新用户设置 user.SetSetting(settings) if err := user.Update(false); err != nil { diff --git a/dto/user_settings.go b/dto/user_settings.go index 89dd926e..16ce7b98 100644 --- a/dto/user_settings.go +++ b/dto/user_settings.go @@ -7,6 +7,9 @@ type UserSetting struct { WebhookSecret string `json:"webhook_secret,omitempty"` // WebhookSecret webhook密钥 NotificationEmail string `json:"notification_email,omitempty"` // NotificationEmail 通知邮箱地址 BarkUrl string `json:"bark_url,omitempty"` // BarkUrl Bark推送URL + GotifyUrl string `json:"gotify_url,omitempty"` // GotifyUrl Gotify服务器地址 + GotifyToken string `json:"gotify_token,omitempty"` // GotifyToken Gotify应用令牌 + GotifyPriority int `json:"gotify_priority"` // GotifyPriority Gotify消息优先级 AcceptUnsetRatioModel bool `json:"accept_unset_model_ratio_model,omitempty"` // AcceptUnsetRatioModel 是否接受未设置价格的模型 RecordIpLog bool `json:"record_ip_log,omitempty"` // 是否记录请求和错误日志IP SidebarModules string `json:"sidebar_modules,omitempty"` // SidebarModules 左侧边栏模块配置 @@ -16,4 +19,5 @@ var ( NotifyTypeEmail = "email" // Email 邮件 NotifyTypeWebhook = "webhook" // Webhook NotifyTypeBark = "bark" // Bark 推送 + NotifyTypeGotify = "gotify" // Gotify 推送 ) diff --git a/service/quota.go b/service/quota.go index 12017e11..43c4024a 100644 --- a/service/quota.go +++ b/service/quota.go @@ -549,8 +549,11 @@ func checkAndSendQuotaNotify(relayInfo *relaycommon.RelayInfo, quota int, preCon // Bark推送使用简短文本,不支持HTML content = "{{value}},剩余额度:{{value}},请及时充值" values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)} + } else if notifyType == dto.NotifyTypeGotify { + content = "{{value}},当前剩余额度为 {{value}},请及时充值。" + values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)} } else { - // 默认内容格式,适用于Email和Webhook + // 默认内容格式,适用于Email和Webhook(支持HTML) content = "{{value}},当前剩余额度为 {{value}},为了不影响您的使用,请及时充值。
充值链接:{{value}}" values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota), topUpLink, topUpLink} } diff --git a/service/user_notify.go b/service/user_notify.go index fba12d9d..0f92e7d7 100644 --- a/service/user_notify.go +++ b/service/user_notify.go @@ -1,6 +1,8 @@ package service import ( + "bytes" + "encoding/json" "fmt" "net/http" "net/url" @@ -37,13 +39,16 @@ func NotifyUser(userId int, userEmail string, userSetting dto.UserSetting, data switch notifyType { case dto.NotifyTypeEmail: - // check setting email - userEmail = userSetting.NotificationEmail - if userEmail == "" { + // 优先使用设置中的通知邮箱,如果为空则使用用户的默认邮箱 + emailToUse := userSetting.NotificationEmail + if emailToUse == "" { + emailToUse = userEmail + } + if emailToUse == "" { common.SysLog(fmt.Sprintf("user %d has no email, skip sending email", userId)) return nil } - return sendEmailNotify(userEmail, data) + return sendEmailNotify(emailToUse, data) case dto.NotifyTypeWebhook: webhookURLStr := userSetting.WebhookUrl if webhookURLStr == "" { @@ -61,6 +66,14 @@ func NotifyUser(userId int, userEmail string, userSetting dto.UserSetting, data return nil } return sendBarkNotify(barkURL, data) + case dto.NotifyTypeGotify: + gotifyUrl := userSetting.GotifyUrl + gotifyToken := userSetting.GotifyToken + if gotifyUrl == "" || gotifyToken == "" { + common.SysLog(fmt.Sprintf("user %d has no gotify url or token, skip sending gotify", userId)) + return nil + } + return sendGotifyNotify(gotifyUrl, gotifyToken, userSetting.GotifyPriority, data) } return nil } @@ -144,3 +157,98 @@ func sendBarkNotify(barkURL string, data dto.Notify) error { return nil } + +func sendGotifyNotify(gotifyUrl string, gotifyToken string, priority int, data dto.Notify) error { + // 处理占位符 + content := data.Content + for _, value := range data.Values { + content = strings.Replace(content, dto.ContentValueParam, fmt.Sprintf("%v", value), 1) + } + + // 构建完整的 Gotify API URL + // 确保 URL 以 /message 结尾 + finalURL := strings.TrimSuffix(gotifyUrl, "/") + "/message?token=" + url.QueryEscape(gotifyToken) + + // Gotify优先级范围0-10,如果超出范围则使用默认值5 + if priority < 0 || priority > 10 { + priority = 5 + } + + // 构建 JSON payload + type GotifyMessage struct { + Title string `json:"title"` + Message string `json:"message"` + Priority int `json:"priority"` + } + + payload := GotifyMessage{ + Title: data.Title, + Message: content, + Priority: priority, + } + + // 序列化为 JSON + payloadBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal gotify payload: %v", err) + } + + var req *http.Request + var resp *http.Response + + if system_setting.EnableWorker() { + // 使用worker发送请求 + workerReq := &WorkerRequest{ + URL: finalURL, + Key: system_setting.WorkerValidKey, + Method: http.MethodPost, + Headers: map[string]string{ + "Content-Type": "application/json; charset=utf-8", + "User-Agent": "OneAPI-Gotify-Notify/1.0", + }, + Body: payloadBytes, + } + + resp, err = DoWorkerRequest(workerReq) + if err != nil { + return fmt.Errorf("failed to send gotify request through worker: %v", err) + } + defer resp.Body.Close() + + // 检查响应状态 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("gotify request failed with status code: %d", resp.StatusCode) + } + } else { + // SSRF防护:验证Gotify URL(非Worker模式) + fetchSetting := system_setting.GetFetchSetting() + if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { + return fmt.Errorf("request reject: %v", err) + } + + // 直接发送请求 + req, err = http.NewRequest(http.MethodPost, finalURL, bytes.NewBuffer(payloadBytes)) + if err != nil { + return fmt.Errorf("failed to create gotify request: %v", err) + } + + // 设置请求头 + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("User-Agent", "NewAPI-Gotify-Notify/1.0") + + // 发送请求 + client := GetHttpClient() + resp, err = client.Do(req) + if err != nil { + return fmt.Errorf("failed to send gotify request: %v", err) + } + defer resp.Body.Close() + + // 检查响应状态 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("gotify request failed with status code: %d", resp.StatusCode) + } + } + + return nil +} diff --git a/web/src/components/settings/PersonalSetting.jsx b/web/src/components/settings/PersonalSetting.jsx index 01e7023a..c9934604 100644 --- a/web/src/components/settings/PersonalSetting.jsx +++ b/web/src/components/settings/PersonalSetting.jsx @@ -81,6 +81,9 @@ const PersonalSetting = () => { webhookSecret: '', notificationEmail: '', barkUrl: '', + gotifyUrl: '', + gotifyToken: '', + gotifyPriority: 5, acceptUnsetModelRatioModel: false, recordIpLog: false, }); @@ -149,6 +152,12 @@ const PersonalSetting = () => { webhookSecret: settings.webhook_secret || '', notificationEmail: settings.notification_email || '', barkUrl: settings.bark_url || '', + gotifyUrl: settings.gotify_url || '', + gotifyToken: settings.gotify_token || '', + gotifyPriority: + settings.gotify_priority !== undefined + ? settings.gotify_priority + : 5, acceptUnsetModelRatioModel: settings.accept_unset_model_ratio_model || false, recordIpLog: settings.record_ip_log || false, @@ -406,6 +415,12 @@ const PersonalSetting = () => { webhook_secret: notificationSettings.webhookSecret, notification_email: notificationSettings.notificationEmail, bark_url: notificationSettings.barkUrl, + gotify_url: notificationSettings.gotifyUrl, + gotify_token: notificationSettings.gotifyToken, + gotify_priority: (() => { + const parsed = parseInt(notificationSettings.gotifyPriority); + return isNaN(parsed) ? 5 : parsed; + })(), accept_unset_model_ratio_model: notificationSettings.acceptUnsetModelRatioModel, record_ip_log: notificationSettings.recordIpLog, diff --git a/web/src/components/settings/personal/cards/NotificationSettings.jsx b/web/src/components/settings/personal/cards/NotificationSettings.jsx index aad612d2..dc428f14 100644 --- a/web/src/components/settings/personal/cards/NotificationSettings.jsx +++ b/web/src/components/settings/personal/cards/NotificationSettings.jsx @@ -400,6 +400,7 @@ const NotificationSettings = ({ {t('邮件通知')} {t('Webhook通知')} {t('Bark通知')} + {t('Gotify通知')} )} + + {/* Gotify推送设置 */} + {notificationSettings.warningType === 'gotify' && ( + <> + handleFormChange('gotifyUrl', val)} + prefix={} + extraText={t( + '支持HTTP和HTTPS,填写Gotify服务器的完整URL地址', + )} + showClear + rules={[ + { + required: + notificationSettings.warningType === 'gotify', + message: t('请输入Gotify服务器地址'), + }, + { + pattern: /^https?:\/\/.+/, + message: t('Gotify服务器地址必须以http://或https://开头'), + }, + ]} + /> + + handleFormChange('gotifyToken', val)} + prefix={} + extraText={t( + '在Gotify服务器创建应用后获得的令牌,用于发送通知', + )} + showClear + rules={[ + { + required: + notificationSettings.warningType === 'gotify', + message: t('请输入Gotify应用令牌'), + }, + ]} + /> + + + handleFormChange('gotifyPriority', val) + } + prefix={} + extraText={t('消息优先级,范围0-10,默认为5')} + style={{ width: '100%', maxWidth: '300px' }} + /> + +
+
+ {t('配置说明')} +
+
+
+ 1. {t('在Gotify服务器的应用管理中创建新应用')} +
+
+ 2.{' '} + {t( + '复制应用的令牌(Token)并填写到上方的应用令牌字段', + )} +
+
+ 3. {t('填写Gotify服务器的完整URL地址')} +
+
+ + {t('更多信息请参考')} + {' '} + + Gotify 官方文档 + +
+
+
+ + )} From d6db10b4bc5aeba46e400d0146fb527516d2304f Mon Sep 17 00:00:00 2001 From: RedwindA Date: Wed, 1 Oct 2025 19:36:19 +0800 Subject: [PATCH 089/243] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Bark=20?= =?UTF-8?q?=E5=92=8C=20Gotify=20=E9=80=9A=E7=9F=A5=E7=9A=84=E5=9B=BD?= =?UTF-8?q?=E9=99=85=E5=8C=96=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../personal/cards/NotificationSettings.jsx | 4 +-- web/src/i18n/locales/en.json | 32 +++++++++++++++++++ web/src/i18n/locales/fr.json | 32 +++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/web/src/components/settings/personal/cards/NotificationSettings.jsx b/web/src/components/settings/personal/cards/NotificationSettings.jsx index dc428f14..0c99e285 100644 --- a/web/src/components/settings/personal/cards/NotificationSettings.jsx +++ b/web/src/components/settings/personal/cards/NotificationSettings.jsx @@ -590,7 +590,7 @@ const NotificationSettings = ({ rel='noopener noreferrer' className='text-blue-500 hover:text-blue-600 font-medium' > - Bark 官方文档 + Bark {t('官方文档')} @@ -691,7 +691,7 @@ const NotificationSettings = ({ rel='noopener noreferrer' className='text-blue-500 hover:text-blue-600 font-medium' > - Gotify 官方文档 + Gotify {t('官方文档')} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 1e1064b5..7ba76a0d 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1313,6 +1313,8 @@ "请输入Webhook地址,例如: https://example.com/webhook": "Please enter the Webhook URL, e.g.: https://example.com/webhook", "邮件通知": "Email notification", "Webhook通知": "Webhook notification", + "Bark通知": "Bark notification", + "Gotify通知": "Gotify notification", "接口凭证(可选)": "Interface credentials (optional)", "密钥将以 Bearer 方式添加到请求头中,用于验证webhook请求的合法性": "The secret will be added to the request header as a Bearer token to verify the legitimacy of the webhook request", "Authorization: Bearer your-secret-key": "Authorization: Bearer your-secret-key", @@ -1323,6 +1325,36 @@ "通知邮箱": "Notification email", "设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱": "Set the email address for receiving quota warning notifications, if not set, the email address bound to the account will be used", "留空则使用账号绑定的邮箱": "If left blank, the email address bound to the account will be used", + "Bark推送URL": "Bark Push URL", + "请输入Bark推送URL,例如: https://api.day.app/yourkey/{{title}}/{{content}}": "Please enter Bark push URL, e.g.: https://api.day.app/yourkey/{{title}}/{{content}}", + "支持HTTP和HTTPS,模板变量: {{title}} (通知标题), {{content}} (通知内容)": "Supports HTTP and HTTPS, template variables: {{title}} (notification title), {{content}} (notification content)", + "请输入Bark推送URL": "Please enter Bark push URL", + "Bark推送URL必须以http://或https://开头": "Bark push URL must start with http:// or https://", + "模板示例": "Template example", + "更多参数请参考": "For more parameters, please refer to", + "Gotify服务器地址": "Gotify server address", + "请输入Gotify服务器地址,例如: https://gotify.example.com": "Please enter Gotify server address, e.g.: https://gotify.example.com", + "支持HTTP和HTTPS,填写Gotify服务器的完整URL地址": "Supports HTTP and HTTPS, enter the complete URL of the Gotify server", + "请输入Gotify服务器地址": "Please enter Gotify server address", + "Gotify服务器地址必须以http://或https://开头": "Gotify server address must start with http:// or https://", + "Gotify应用令牌": "Gotify application token", + "请输入Gotify应用令牌": "Please enter Gotify application token", + "在Gotify服务器创建应用后获得的令牌,用于发送通知": "Token obtained after creating an application on the Gotify server, used to send notifications", + "消息优先级": "Message priority", + "请选择消息优先级": "Please select message priority", + "0 - 最低": "0 - Lowest", + "2 - 低": "2 - Low", + "5 - 正常(默认)": "5 - Normal (default)", + "8 - 高": "8 - High", + "10 - 最高": "10 - Highest", + "消息优先级,范围0-10,默认为5": "Message priority, range 0-10, default is 5", + "配置说明": "Configuration instructions", + "在Gotify服务器的应用管理中创建新应用": "Create a new application in the Gotify server's application management", + "复制应用的令牌(Token)并填写到上方的应用令牌字段": "Copy the application token and fill it in the application token field above", + "填写Gotify服务器的完整URL地址": "Fill in the complete URL address of the Gotify server", + "更多信息请参考": "For more information, please refer to", + "通知内容": "Notification content", + "官方文档": "Official documentation", "API地址": "Base URL", "对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写": "For official channels, the new-api has a built-in address. Unless it is a third-party proxy site or a special Azure access address, there is no need to fill it in", "渠道额外设置": "Channel extra settings", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index 3a216e53..6dde5597 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -1308,6 +1308,8 @@ "请输入Webhook地址,例如: https://example.com/webhook": "Veuillez saisir l'URL du Webhook, par exemple : https://example.com/webhook", "邮件通知": "Notification par e-mail", "Webhook通知": "Notification par Webhook", + "Bark通知": "Notification Bark", + "Gotify通知": "Notification Gotify", "接口凭证(可选)": "Informations d'identification de l'interface (facultatif)", "密钥将以 Bearer 方式添加到请求头中,用于验证webhook请求的合法性": "Le secret sera ajouté à l'en-tête de la requête en tant que jeton Bearer pour vérifier la légitimité de la requête webhook", "Authorization: Bearer your-secret-key": "Autorisation : Bearer votre-clé-secrète", @@ -1318,6 +1320,36 @@ "通知邮箱": "E-mail de notification", "设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱": "Définissez l'adresse e-mail pour recevoir les notifications d'avertissement de quota, si elle n'est pas définie, l'adresse e-mail liée au compte sera utilisée", "留空则使用账号绑定的邮箱": "Si ce champ est laissé vide, l'adresse e-mail liée au compte sera utilisée", + "Bark推送URL": "URL de notification Bark", + "请输入Bark推送URL,例如: https://api.day.app/yourkey/{{title}}/{{content}}": "Veuillez saisir l'URL de notification Bark, par exemple : https://api.day.app/yourkey/{{title}}/{{content}}", + "支持HTTP和HTTPS,模板变量: {{title}} (通知标题), {{content}} (通知内容)": "Prend en charge HTTP et HTTPS, variables de modèle : {{title}} (titre de la notification), {{content}} (contenu de la notification)", + "请输入Bark推送URL": "Veuillez saisir l'URL de notification Bark", + "Bark推送URL必须以http://或https://开头": "L'URL de notification Bark doit commencer par http:// ou https://", + "模板示例": "Exemple de modèle", + "更多参数请参考": "Pour plus de paramètres, veuillez vous référer à", + "Gotify服务器地址": "Adresse du serveur Gotify", + "请输入Gotify服务器地址,例如: https://gotify.example.com": "Veuillez saisir l'adresse du serveur Gotify, par exemple : https://gotify.example.com", + "支持HTTP和HTTPS,填写Gotify服务器的完整URL地址": "Prend en charge HTTP et HTTPS, saisissez l'URL complète du serveur Gotify", + "请输入Gotify服务器地址": "Veuillez saisir l'adresse du serveur Gotify", + "Gotify服务器地址必须以http://或https://开头": "L'adresse du serveur Gotify doit commencer par http:// ou https://", + "Gotify应用令牌": "Jeton d'application Gotify", + "请输入Gotify应用令牌": "Veuillez saisir le jeton d'application Gotify", + "在Gotify服务器创建应用后获得的令牌,用于发送通知": "Jeton obtenu après la création d'une application sur le serveur Gotify, utilisé pour envoyer des notifications", + "消息优先级": "Priorité du message", + "请选择消息优先级": "Veuillez sélectionner la priorité du message", + "0 - 最低": "0 - La plus basse", + "2 - 低": "2 - Basse", + "5 - 正常(默认)": "5 - Normale (par défaut)", + "8 - 高": "8 - Haute", + "10 - 最高": "10 - La plus haute", + "消息优先级,范围0-10,默认为5": "Priorité du message, plage 0-10, par défaut 5", + "配置说明": "Instructions de configuration", + "在Gotify服务器的应用管理中创建新应用": "Créer une nouvelle application dans la gestion des applications du serveur Gotify", + "复制应用的令牌(Token)并填写到上方的应用令牌字段": "Copier le jeton de l'application et le remplir dans le champ de jeton d'application ci-dessus", + "填写Gotify服务器的完整URL地址": "Remplir l'adresse URL complète du serveur Gotify", + "更多信息请参考": "Pour plus d'informations, veuillez vous référer à", + "通知内容": "Contenu de la notification", + "官方文档": "Documentation officielle", "API地址": "URL de base", "对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写": "Pour les canaux officiels, le new-api a une adresse intégrée. Sauf s'il s'agit d'un site proxy tiers ou d'une adresse d'accès Azure spéciale, il n'est pas nécessaire de la remplir", "渠道额外设置": "Paramètres supplémentaires du canal", From 2200bb9166e20bca3168273e04ce039e7210e075 Mon Sep 17 00:00:00 2001 From: RedwindA Date: Wed, 1 Oct 2025 22:19:22 +0800 Subject: [PATCH 090/243] fix(openai): add nil checks for web_search streaming to prevent panic --- relay/channel/openai/relay_responses.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/relay/channel/openai/relay_responses.go b/relay/channel/openai/relay_responses.go index 85938a77..7b148f32 100644 --- a/relay/channel/openai/relay_responses.go +++ b/relay/channel/openai/relay_responses.go @@ -115,7 +115,11 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp if streamResponse.Item != nil { switch streamResponse.Item.Type { case dto.BuildInCallWebSearchCall: - info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview].CallCount++ + if info != nil && info.ResponsesUsageInfo != nil && info.ResponsesUsageInfo.BuiltInTools != nil { + if webSearchTool, exists := info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists && webSearchTool != nil { + webSearchTool.CallCount++ + } + } } } } From 0e9ad4a15f6cee8b3c0193215db1fa6bb7d5453d Mon Sep 17 00:00:00 2001 From: Seefs Date: Thu, 2 Oct 2025 00:14:35 +0800 Subject: [PATCH 091/243] fix: missing field & field control --- dto/channel_settings.go | 3 + dto/claude.go | 5 +- dto/openai_request.go | 39 ++++-- relay/claude_handler.go | 6 + relay/common/relay_info.go | 34 +++++ relay/compatible_handler.go | 6 + relay/responses_handler.go | 7 + .../channels/modals/EditChannelModal.jsx | 125 ++++++++++++++++-- web/src/i18n/locales/en.json | 7 + web/src/i18n/locales/fr.json | 7 + 10 files changed, 213 insertions(+), 26 deletions(-) diff --git a/dto/channel_settings.go b/dto/channel_settings.go index d6d6e084..d57184b3 100644 --- a/dto/channel_settings.go +++ b/dto/channel_settings.go @@ -20,6 +20,9 @@ type ChannelOtherSettings struct { AzureResponsesVersion string `json:"azure_responses_version,omitempty"` VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key" OpenRouterEnterprise *bool `json:"openrouter_enterprise,omitempty"` + AllowServiceTier bool `json:"allow_service_tier,omitempty"` // 是否允许 service_tier 透传(默认过滤以避免额外计费) + DisableStore bool `json:"disable_store,omitempty"` // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用) + AllowSafetyIdentifier bool `json:"allow_safety_identifier,omitempty"` // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私) } func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool { diff --git a/dto/claude.go b/dto/claude.go index 42774226..dfc5cfd4 100644 --- a/dto/claude.go +++ b/dto/claude.go @@ -195,12 +195,15 @@ type ClaudeRequest struct { Temperature *float64 `json:"temperature,omitempty"` TopP float64 `json:"top_p,omitempty"` TopK int `json:"top_k,omitempty"` - //ClaudeMetadata `json:"metadata,omitempty"` Stream bool `json:"stream,omitempty"` Tools any `json:"tools,omitempty"` ContextManagement json.RawMessage `json:"context_management,omitempty"` ToolChoice any `json:"tool_choice,omitempty"` Thinking *Thinking `json:"thinking,omitempty"` + McpServers json.RawMessage `json:"mcp_servers,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` + // 服务层级字段,用于指定 API 服务等级。允许透传可能导致实际计费高于预期,默认应过滤 + ServiceTier string `json:"service_tier,omitempty"` } func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta { diff --git a/dto/openai_request.go b/dto/openai_request.go index 191fa638..dbdfad44 100644 --- a/dto/openai_request.go +++ b/dto/openai_request.go @@ -57,6 +57,18 @@ type GeneralOpenAIRequest struct { Dimensions int `json:"dimensions,omitempty"` Modalities json.RawMessage `json:"modalities,omitempty"` Audio json.RawMessage `json:"audio,omitempty"` + // 安全标识符,用于帮助 OpenAI 检测可能违反使用政策的应用程序用户 + // 注意:此字段会向 OpenAI 发送用户标识信息,默认过滤以保护用户隐私 + SafetyIdentifier string `json:"safety_identifier,omitempty"` + // Whether or not to store the output of this chat completion request for use in our model distillation or evals products. + // 是否存储此次请求数据供 OpenAI 用于评估和优化产品 + // 注意:默认过滤此字段以保护用户隐私,但过滤后可能导致 Codex 无法正常使用 + Store json.RawMessage `json:"store,omitempty"` + // Used by OpenAI to cache responses for similar requests to optimize your cache hit rates. Replaces the user field + PromptCacheKey string `json:"prompt_cache_key,omitempty"` + LogitBias json.RawMessage `json:"logit_bias,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` + Prediction json.RawMessage `json:"prediction,omitempty"` // gemini ExtraBody json.RawMessage `json:"extra_body,omitempty"` //xai @@ -775,19 +787,20 @@ type OpenAIResponsesRequest struct { ParallelToolCalls json.RawMessage `json:"parallel_tool_calls,omitempty"` PreviousResponseID string `json:"previous_response_id,omitempty"` Reasoning *Reasoning `json:"reasoning,omitempty"` - ServiceTier string `json:"service_tier,omitempty"` - Store json.RawMessage `json:"store,omitempty"` - PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"` - Stream bool `json:"stream,omitempty"` - Temperature float64 `json:"temperature,omitempty"` - Text json.RawMessage `json:"text,omitempty"` - ToolChoice json.RawMessage `json:"tool_choice,omitempty"` - Tools json.RawMessage `json:"tools,omitempty"` // 需要处理的参数很少,MCP 参数太多不确定,所以用 map - TopP float64 `json:"top_p,omitempty"` - Truncation string `json:"truncation,omitempty"` - User string `json:"user,omitempty"` - MaxToolCalls uint `json:"max_tool_calls,omitempty"` - Prompt json.RawMessage `json:"prompt,omitempty"` + // 服务层级字段,用于指定 API 服务等级。允许透传可能导致实际计费高于预期,默认应过滤 + ServiceTier string `json:"service_tier,omitempty"` + Store json.RawMessage `json:"store,omitempty"` + PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"` + Stream bool `json:"stream,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + Text json.RawMessage `json:"text,omitempty"` + ToolChoice json.RawMessage `json:"tool_choice,omitempty"` + Tools json.RawMessage `json:"tools,omitempty"` // 需要处理的参数很少,MCP 参数太多不确定,所以用 map + TopP float64 `json:"top_p,omitempty"` + Truncation string `json:"truncation,omitempty"` + User string `json:"user,omitempty"` + MaxToolCalls uint `json:"max_tool_calls,omitempty"` + Prompt json.RawMessage `json:"prompt,omitempty"` } func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta { diff --git a/relay/claude_handler.go b/relay/claude_handler.go index 59d12abe..3a739785 100644 --- a/relay/claude_handler.go +++ b/relay/claude_handler.go @@ -112,6 +112,12 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } + // remove disabled fields for Claude API + jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + // apply param override if len(info.ParamOverride) > 0 { jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride) diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index f4ffaee2..cb66cd80 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -507,3 +507,37 @@ type TaskInfo struct { Url string `json:"url,omitempty"` Progress string `json:"progress,omitempty"` } + +// RemoveDisabledFields 从请求 JSON 数据中移除渠道设置中禁用的字段 +// service_tier: 服务层级字段,可能导致额外计费(OpenAI、Claude、Responses API 支持) +// store: 数据存储授权字段,涉及用户隐私(仅 OpenAI、Responses API 支持,默认允许透传,禁用后可能导致 Codex 无法使用) +// safety_identifier: 安全标识符,用于向 OpenAI 报告违规用户(仅 OpenAI 支持,涉及用户隐私) +func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOtherSettings) ([]byte, error) { + var data map[string]interface{} + if err := common.Unmarshal(jsonData, &data); err != nil { + return jsonData, err + } + + // 默认移除 service_tier,除非明确允许(避免额外计费风险) + if !channelOtherSettings.AllowServiceTier { + if _, exists := data["service_tier"]; exists { + delete(data, "service_tier") + } + } + + // 默认允许 store 透传,除非明确禁用(禁用可能影响 Codex 使用) + if channelOtherSettings.DisableStore { + if _, exists := data["store"]; exists { + delete(data, "store") + } + } + + // 默认移除 safety_identifier,除非明确允许(保护用户隐私,避免向 OpenAI 报告用户信息) + if !channelOtherSettings.AllowSafetyIdentifier { + if _, exists := data["safety_identifier"]; exists { + delete(data, "safety_identifier") + } + } + + return common.Marshal(data) +} diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index 38b820f7..a3ddf6d4 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -135,6 +135,12 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types return types.NewError(err, types.ErrorCodeJsonMarshalFailed, types.ErrOptionWithSkipRetry()) } + // remove disabled fields for OpenAI API + jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + // apply param override if len(info.ParamOverride) > 0 { jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride) diff --git a/relay/responses_handler.go b/relay/responses_handler.go index 0c57a303..6958f96e 100644 --- a/relay/responses_handler.go +++ b/relay/responses_handler.go @@ -56,6 +56,13 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } + + // remove disabled fields for OpenAI Responses API + jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + // apply param override if len(info.ParamOverride) > 0 { jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride) diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index f625ab14..571c136f 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -169,6 +169,10 @@ const EditChannelModal = (props) => { vertex_key_type: 'json', // 企业账户设置 is_enterprise_account: false, + // 字段透传控制默认值 + allow_service_tier: false, + disable_store: false, // false = 允许透传(默认开启) + allow_safety_identifier: false, }; const [batch, setBatch] = useState(false); const [multiToSingle, setMultiToSingle] = useState(false); @@ -453,17 +457,27 @@ const EditChannelModal = (props) => { data.vertex_key_type = parsedSettings.vertex_key_type || 'json'; // 读取企业账户设置 data.is_enterprise_account = parsedSettings.openrouter_enterprise === true; + // 读取字段透传控制设置 + data.allow_service_tier = parsedSettings.allow_service_tier || false; + data.disable_store = parsedSettings.disable_store || false; + data.allow_safety_identifier = parsedSettings.allow_safety_identifier || false; } catch (error) { console.error('解析其他设置失败:', error); data.azure_responses_version = ''; data.region = ''; data.vertex_key_type = 'json'; data.is_enterprise_account = false; + data.allow_service_tier = false; + data.disable_store = false; + data.allow_safety_identifier = false; } } else { // 兼容历史数据:老渠道没有 settings 时,默认按 json 展示 data.vertex_key_type = 'json'; data.is_enterprise_account = false; + data.allow_service_tier = false; + data.disable_store = false; + data.allow_safety_identifier = false; } if ( @@ -900,21 +914,33 @@ const EditChannelModal = (props) => { }; localInputs.setting = JSON.stringify(channelExtraSettings); - // 处理type === 20的企业账户设置 - if (localInputs.type === 20) { - let settings = {}; - if (localInputs.settings) { - try { - settings = JSON.parse(localInputs.settings); - } catch (error) { - console.error('解析settings失败:', error); - } + // 处理 settings 字段(包括企业账户设置和字段透传控制) + let settings = {}; + if (localInputs.settings) { + try { + settings = JSON.parse(localInputs.settings); + } catch (error) { + console.error('解析settings失败:', error); } - // 设置企业账户标识,无论是true还是false都要传到后端 - settings.openrouter_enterprise = localInputs.is_enterprise_account === true; - localInputs.settings = JSON.stringify(settings); } + // type === 20: 设置企业账户标识,无论是true还是false都要传到后端 + if (localInputs.type === 20) { + settings.openrouter_enterprise = localInputs.is_enterprise_account === true; + } + + // type === 1 (OpenAI) 或 type === 14 (Claude): 设置字段透传控制(显式保存布尔值) + if (localInputs.type === 1 || localInputs.type === 14) { + settings.allow_service_tier = localInputs.allow_service_tier === true; + // 仅 OpenAI 渠道需要 store 和 safety_identifier + if (localInputs.type === 1) { + settings.disable_store = localInputs.disable_store === true; + settings.allow_safety_identifier = localInputs.allow_safety_identifier === true; + } + } + + localInputs.settings = JSON.stringify(settings); + // 清理不需要发送到后端的字段 delete localInputs.force_format; delete localInputs.thinking_to_content; @@ -925,6 +951,10 @@ const EditChannelModal = (props) => { delete localInputs.is_enterprise_account; // 顶层的 vertex_key_type 不应发送给后端 delete localInputs.vertex_key_type; + // 清理字段透传控制的临时字段 + delete localInputs.allow_service_tier; + delete localInputs.disable_store; + delete localInputs.allow_safety_identifier; let res; localInputs.auto_ban = localInputs.auto_ban ? 1 : 0; @@ -2384,6 +2414,76 @@ const EditChannelModal = (props) => { '键为原状态码,值为要复写的状态码,仅影响本地判断', )} /> + + {/* 字段透传控制 - OpenAI 渠道 */} + {inputs.type === 1 && ( + <> +
+ {t('字段透传控制')} +
+ + + handleChannelOtherSettingsChange('allow_service_tier', value) + } + extraText={t( + 'service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用', + )} + /> + + + handleChannelOtherSettingsChange('disable_store', value) + } + extraText={t( + 'store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用', + )} + /> + + + handleChannelOtherSettingsChange('allow_safety_identifier', value) + } + extraText={t( + 'safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私', + )} + /> + + )} + + {/* 字段透传控制 - Claude 渠道 */} + {(inputs.type === 14) && ( + <> +
+ {t('字段透传控制')} +
+ + + handleChannelOtherSettingsChange('allow_service_tier', value) + } + extraText={t( + 'service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用', + )} + /> + + )} {/* Channel Extra Settings Card */} @@ -2487,6 +2587,7 @@ const EditChannelModal = (props) => { '如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面', )} /> + diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 1e1064b5..0d940d82 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -2191,6 +2191,13 @@ "输入 Origin 后回车,如:https://example.com": "Enter Origin and press Enter, e.g.: https://example.com", "保存 Passkey 设置": "Save Passkey Settings", "黑名单": "Blacklist", + "字段透传控制": "Field Pass-through Control", + "允许 service_tier 透传": "Allow service_tier Pass-through", + "禁用 store 透传": "Disable store Pass-through", + "允许 safety_identifier 透传": "Allow safety_identifier Pass-through", + "service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用": "The service_tier field is used to specify service level. Allowing pass-through may result in higher billing than expected. Disabled by default to avoid extra charges", + "store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用": "The store field authorizes OpenAI to store request data for product evaluation and optimization. Disabled by default. Enabling may cause Codex to malfunction", + "safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私": "The safety_identifier field helps OpenAI identify application users who may violate usage policies. Disabled by default to protect user privacy", "common": { "changeLanguage": "Change Language" } diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index 3a216e53..f67b88ef 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -2135,6 +2135,13 @@ "关闭侧边栏": "Fermer la barre latérale", "定价": "Tarification", "语言": "Langue", + "字段透传控制": "Contrôle du passage des champs", + "允许 service_tier 透传": "Autoriser le passage de service_tier", + "禁用 store 透传": "Désactiver le passage de store", + "允许 safety_identifier 透传": "Autoriser le passage de safety_identifier", + "service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用": "Le champ service_tier est utilisé pour spécifier le niveau de service. Permettre le passage peut entraîner une facturation plus élevée que prévu. Désactivé par défaut pour éviter des frais supplémentaires", + "store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用": "Le champ store autorise OpenAI à stocker les données de requête pour l'évaluation et l'optimisation du produit. Désactivé par défaut. L'activation peut causer un dysfonctionnement de Codex", + "safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私": "Le champ safety_identifier aide OpenAI à identifier les utilisateurs d'applications susceptibles de violer les politiques d'utilisation. Désactivé par défaut pour protéger la confidentialité des utilisateurs", "common": { "changeLanguage": "Changer de langue" } From c320410c848cd3f3f1f7666d4d2978d28668f78d Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Thu, 2 Oct 2025 01:03:20 +0800 Subject: [PATCH 092/243] feat: add doubao video generate --- constant/channel.go | 3 +- controller/channel-test.go | 6 + relay/channel/task/doubao/adaptor.go | 245 +++++++++++++++++++++++++ relay/channel/task/doubao/constants.go | 9 + relay/relay_adaptor.go | 7 +- web/src/constants/channel.constants.js | 5 + web/src/helpers/render.jsx | 2 + 7 files changed, 274 insertions(+), 3 deletions(-) create mode 100644 relay/channel/task/doubao/adaptor.go create mode 100644 relay/channel/task/doubao/constants.go diff --git a/constant/channel.go b/constant/channel.go index 34fb20f4..7d8893c1 100644 --- a/constant/channel.go +++ b/constant/channel.go @@ -51,9 +51,9 @@ const ( ChannelTypeJimeng = 51 ChannelTypeVidu = 52 ChannelTypeSubmodel = 53 + ChannelTypeDoubaoVideo = 54 ChannelTypeDummy // this one is only for count, do not add any channel after this - ) var ChannelBaseURLs = []string{ @@ -111,4 +111,5 @@ var ChannelBaseURLs = []string{ "https://visual.volcengineapi.com", //51 "https://api.vidu.cn", //52 "https://llm.submodel.ai", //53 + "https://ark.cn-beijing.volces.com", //54 } diff --git a/controller/channel-test.go b/controller/channel-test.go index b3a3be4e..ff1e8cef 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -70,6 +70,12 @@ func testChannel(channel *model.Channel, testModel string, endpointType string) newAPIError: nil, } } + if channel.Type == constant.ChannelTypeDoubaoVideo { + return testResult{ + localErr: errors.New("doubao video channel test is not supported"), + newAPIError: nil, + } + } if channel.Type == constant.ChannelTypeVidu { return testResult{ localErr: errors.New("vidu channel test is not supported"), diff --git a/relay/channel/task/doubao/adaptor.go b/relay/channel/task/doubao/adaptor.go new file mode 100644 index 00000000..9b40a249 --- /dev/null +++ b/relay/channel/task/doubao/adaptor.go @@ -0,0 +1,245 @@ +package doubao + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "one-api/constant" + "one-api/dto" + "one-api/model" + "one-api/relay/channel" + relaycommon "one-api/relay/common" + "one-api/service" + + "github.com/gin-gonic/gin" + "github.com/pkg/errors" +) + +// ============================ +// Request / Response structures +// ============================ + +type ContentItem struct { + Type string `json:"type"` // "text" or "image_url" + Text string `json:"text,omitempty"` // for text type + ImageURL *ImageURL `json:"image_url,omitempty"` // for image_url type +} + +type ImageURL struct { + URL string `json:"url"` +} + +type requestPayload struct { + Model string `json:"model"` + Content []ContentItem `json:"content"` +} + +type responsePayload struct { + ID string `json:"id"` // task_id +} + +type responseTask struct { + ID string `json:"id"` + Model string `json:"model"` + Status string `json:"status"` + Content struct { + VideoURL string `json:"video_url"` + } `json:"content"` + Seed int `json:"seed"` + Resolution string `json:"resolution"` + Duration int `json:"duration"` + Ratio string `json:"ratio"` + FramesPerSecond int `json:"framespersecond"` + Usage struct { + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + } `json:"usage"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +// ============================ +// Adaptor implementation +// ============================ + +type TaskAdaptor struct { + ChannelType int + apiKey string + baseURL string +} + +func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { + a.ChannelType = info.ChannelType + a.baseURL = info.ChannelBaseUrl + a.apiKey = info.ApiKey +} + +// ValidateRequestAndSetAction parses body, validates fields and sets default action. +func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) { + // Accept only POST /v1/video/generations as "generate" action. + return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate) +} + +// BuildRequestURL constructs the upstream URL. +func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) { + return fmt.Sprintf("%s/api/v3/contents/generations/tasks", a.baseURL), nil +} + +// BuildRequestHeader sets required headers. +func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+a.apiKey) + return nil +} + +// BuildRequestBody converts request into Doubao specific format. +func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) { + v, exists := c.Get("task_request") + if !exists { + return nil, fmt.Errorf("request not found in context") + } + req := v.(relaycommon.TaskSubmitReq) + + body, err := a.convertToRequestPayload(&req) + if err != nil { + return nil, errors.Wrap(err, "convert request payload failed") + } + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + return bytes.NewReader(data), nil +} + +// DoRequest delegates to common helper. +func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) { + return channel.DoTaskApiRequest(a, c, info, requestBody) +} + +// DoResponse handles upstream response, returns taskID etc. +func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError) + return + } + _ = resp.Body.Close() + + // Parse Doubao response + var dResp responsePayload + if err := json.Unmarshal(responseBody, &dResp); err != nil { + taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError) + return + } + + if dResp.ID == "" { + taskErr = service.TaskErrorWrapper(fmt.Errorf("task_id is empty"), "invalid_response", http.StatusInternalServerError) + return + } + + c.JSON(http.StatusOK, gin.H{"task_id": dResp.ID}) + return dResp.ID, responseBody, nil +} + +// FetchTask fetch task status +func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) { + taskID, ok := body["task_id"].(string) + if !ok { + return nil, fmt.Errorf("invalid task_id") + } + + uri := fmt.Sprintf("%s/api/v3/contents/generations/tasks/%s", baseUrl, taskID) + + req, err := http.NewRequest(http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+key) + + return service.GetHttpClient().Do(req) +} + +func (a *TaskAdaptor) GetModelList() []string { + return ModelList +} + +func (a *TaskAdaptor) GetChannelName() string { + return ChannelName +} + +func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) { + r := requestPayload{ + Model: req.Model, + Content: []ContentItem{}, + } + + // Add text prompt + if req.Prompt != "" { + r.Content = append(r.Content, ContentItem{ + Type: "text", + Text: req.Prompt, + }) + } + + // Add images if present + if req.HasImage() { + for _, imgURL := range req.Images { + r.Content = append(r.Content, ContentItem{ + Type: "image_url", + ImageURL: &ImageURL{ + URL: imgURL, + }, + }) + } + } + + // TODO: Add support for additional parameters from metadata + // such as ratio, duration, seed, etc. + // metadata := req.Metadata + // if metadata != nil { + // // Parse and apply metadata parameters + // } + + return &r, nil +} + +func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { + resTask := responseTask{} + if err := json.Unmarshal(respBody, &resTask); err != nil { + return nil, errors.Wrap(err, "unmarshal task result failed") + } + + taskResult := relaycommon.TaskInfo{ + Code: 0, + } + + // Map Doubao status to internal status + switch resTask.Status { + case "pending", "queued": + taskResult.Status = model.TaskStatusQueued + taskResult.Progress = "10%" + case "processing": + taskResult.Status = model.TaskStatusInProgress + taskResult.Progress = "50%" + case "succeeded": + taskResult.Status = model.TaskStatusSuccess + taskResult.Progress = "100%" + taskResult.Url = resTask.Content.VideoURL + case "failed": + taskResult.Status = model.TaskStatusFailure + taskResult.Progress = "100%" + taskResult.Reason = "task failed" + default: + // Unknown status, treat as processing + taskResult.Status = model.TaskStatusInProgress + taskResult.Progress = "30%" + } + + return &taskResult, nil +} diff --git a/relay/channel/task/doubao/constants.go b/relay/channel/task/doubao/constants.go new file mode 100644 index 00000000..74b416c6 --- /dev/null +++ b/relay/channel/task/doubao/constants.go @@ -0,0 +1,9 @@ +package doubao + +var ModelList = []string{ + "doubao-seedance-1-0-pro-250528", + "doubao-seedance-1-0-lite-t2v", + "doubao-seedance-1-0-lite-i2v", +} + +var ChannelName = "doubao-video" diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go index 406074c5..c8fd51a1 100644 --- a/relay/relay_adaptor.go +++ b/relay/relay_adaptor.go @@ -1,6 +1,7 @@ package relay import ( + "github.com/gin-gonic/gin" "one-api/constant" "one-api/relay/channel" "one-api/relay/channel/ali" @@ -24,6 +25,8 @@ import ( "one-api/relay/channel/palm" "one-api/relay/channel/perplexity" "one-api/relay/channel/siliconflow" + "one-api/relay/channel/submodel" + taskdoubao "one-api/relay/channel/task/doubao" taskjimeng "one-api/relay/channel/task/jimeng" "one-api/relay/channel/task/kling" "one-api/relay/channel/task/suno" @@ -37,8 +40,6 @@ import ( "one-api/relay/channel/zhipu" "one-api/relay/channel/zhipu_4v" "strconv" - "one-api/relay/channel/submodel" - "github.com/gin-gonic/gin" ) func GetAdaptor(apiType int) channel.Adaptor { @@ -134,6 +135,8 @@ func GetTaskAdaptor(platform constant.TaskPlatform) channel.TaskAdaptor { return &taskvertex.TaskAdaptor{} case constant.ChannelTypeVidu: return &taskVidu.TaskAdaptor{} + case constant.ChannelTypeDoubaoVideo: + return &taskdoubao.TaskAdaptor{} } } return nil diff --git a/web/src/constants/channel.constants.js b/web/src/constants/channel.constants.js index 9ed2e8b5..3b376ed3 100644 --- a/web/src/constants/channel.constants.js +++ b/web/src/constants/channel.constants.js @@ -164,6 +164,11 @@ export const CHANNEL_OPTIONS = [ color: 'blue', label: 'SubModel', }, + { + value: 54, + color: 'blue', + label: '豆包视频', + }, ]; export const MODEL_TABLE_PAGE_SIZE = 10; diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index 82d164b3..25afacec 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -337,6 +337,8 @@ export function getChannelIcon(channelType) { return ; case 51: // 即梦 Jimeng return ; + case 54: // 豆包视频 Doubao Video + return ; case 8: // 自定义渠道 case 22: // 知识库:FastGPT return ; From b244a06ca1d8dd56289ff4556416aa8921ccb185 Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Thu, 2 Oct 2025 02:46:47 +0800 Subject: [PATCH 093/243] feat: add doubao video use quota by total token --- controller/task_video.go | 84 ++++++++++++++++++++++++++++ relay/channel/task/doubao/adaptor.go | 3 + relay/common/relay_info.go | 14 +++-- 3 files changed, 95 insertions(+), 6 deletions(-) diff --git a/controller/task_video.go b/controller/task_video.go index 73d5c39b..8e8a5852 100644 --- a/controller/task_video.go +++ b/controller/task_video.go @@ -13,6 +13,7 @@ import ( "one-api/relay" "one-api/relay/channel" relaycommon "one-api/relay/common" + "one-api/setting/ratio_setting" "time" ) @@ -120,6 +121,89 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha if !(len(taskResult.Url) > 5 && taskResult.Url[:5] == "data:") { task.FailReason = taskResult.Url } + + // 如果返回了 total_tokens 并且配置了模型倍率(非固定价格),则重新计费 + if taskResult.TotalTokens > 0 { + // 获取模型名称 + var taskData map[string]interface{} + if err := json.Unmarshal(task.Data, &taskData); err == nil { + if modelName, ok := taskData["model"].(string); ok && modelName != "" { + // 获取模型价格和倍率 + modelRatio, hasRatioSetting, _ := ratio_setting.GetModelRatio(modelName) + + // 只有配置了倍率(非固定价格)时才按 token 重新计费 + if hasRatioSetting && modelRatio > 0 { + // 获取用户和组的倍率信息 + user, err := model.GetUserById(task.UserId, false) + if err == nil { + groupRatio := ratio_setting.GetGroupRatio(user.Group) + userGroupRatio, hasUserGroupRatio := ratio_setting.GetGroupGroupRatio(user.Group, user.Group) + + var finalGroupRatio float64 + if hasUserGroupRatio { + finalGroupRatio = userGroupRatio + } else { + finalGroupRatio = groupRatio + } + + // 计算实际应扣费额度: totalTokens * modelRatio * groupRatio + actualQuota := int(float64(taskResult.TotalTokens) * modelRatio * finalGroupRatio) + + // 计算差额 + preConsumedQuota := task.Quota + quotaDelta := actualQuota - preConsumedQuota + + if quotaDelta > 0 { + // 需要补扣费 + logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费后补扣费:%s(实际消耗:%s,预扣费:%s,tokens:%d)", + task.TaskID, + logger.LogQuota(quotaDelta), + logger.LogQuota(actualQuota), + logger.LogQuota(preConsumedQuota), + taskResult.TotalTokens, + )) + if err := model.DecreaseUserQuota(task.UserId, quotaDelta); err != nil { + logger.LogError(ctx, fmt.Sprintf("补扣费失败: %s", err.Error())) + } else { + model.UpdateUserUsedQuotaAndRequestCount(task.UserId, quotaDelta) + model.UpdateChannelUsedQuota(task.ChannelId, quotaDelta) + task.Quota = actualQuota // 更新任务记录的实际扣费额度 + + // 记录消费日志 + logContent := fmt.Sprintf("视频任务成功补扣费,模型倍率 %.2f,分组倍率 %.2f,tokens %d", + modelRatio, finalGroupRatio, taskResult.TotalTokens) + model.RecordLog(task.UserId, model.LogTypeSystem, logContent) + } + } else if quotaDelta < 0 { + // 需要退还多扣的费用 + refundQuota := -quotaDelta + logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费后返还:%s(实际消耗:%s,预扣费:%s,tokens:%d)", + task.TaskID, + logger.LogQuota(refundQuota), + logger.LogQuota(actualQuota), + logger.LogQuota(preConsumedQuota), + taskResult.TotalTokens, + )) + if err := model.IncreaseUserQuota(task.UserId, refundQuota, false); err != nil { + logger.LogError(ctx, fmt.Sprintf("退还预扣费失败: %s", err.Error())) + } else { + task.Quota = actualQuota // 更新任务记录的实际扣费额度 + + // 记录退款日志 + logContent := fmt.Sprintf("视频任务成功退还多扣费用,模型倍率 %.2f,分组倍率 %.2f,tokens %d,退还 %s", + modelRatio, finalGroupRatio, taskResult.TotalTokens, logger.LogQuota(refundQuota)) + model.RecordLog(task.UserId, model.LogTypeSystem, logContent) + } + } else { + // quotaDelta == 0, 预扣费刚好准确 + logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费准确(%s,tokens:%d)", + task.TaskID, logger.LogQuota(actualQuota), taskResult.TotalTokens)) + } + } + } + } + } + } case model.TaskStatusFailure: task.Status = model.TaskStatusFailure task.Progress = "100%" diff --git a/relay/channel/task/doubao/adaptor.go b/relay/channel/task/doubao/adaptor.go index 9b40a249..8cc1fa4f 100644 --- a/relay/channel/task/doubao/adaptor.go +++ b/relay/channel/task/doubao/adaptor.go @@ -231,6 +231,9 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e taskResult.Status = model.TaskStatusSuccess taskResult.Progress = "100%" taskResult.Url = resTask.Content.VideoURL + // 解析 usage 信息用于按倍率计费 + taskResult.CompletionTokens = resTask.Usage.CompletionTokens + taskResult.TotalTokens = resTask.Usage.TotalTokens case "failed": taskResult.Status = model.TaskStatusFailure taskResult.Progress = "100%" diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index f4ffaee2..b2905c57 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -500,10 +500,12 @@ func (t TaskSubmitReq) HasImage() bool { } type TaskInfo struct { - Code int `json:"code"` - TaskID string `json:"task_id"` - Status string `json:"status"` - Reason string `json:"reason,omitempty"` - Url string `json:"url,omitempty"` - Progress string `json:"progress,omitempty"` + Code int `json:"code"` + TaskID string `json:"task_id"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + Url string `json:"url,omitempty"` + Progress string `json:"progress,omitempty"` + CompletionTokens int `json:"completion_tokens,omitempty"` // 用于按倍率计费 + TotalTokens int `json:"total_tokens,omitempty"` // 用于按倍率计费 } From 7ca65a5e8e58bf56c1339510d434f18517435010 Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Thu, 2 Oct 2025 03:46:00 +0800 Subject: [PATCH 094/243] feat: add doubao video add log detail --- controller/task_video.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/controller/task_video.go b/controller/task_video.go index 8e8a5852..ded011fe 100644 --- a/controller/task_video.go +++ b/controller/task_video.go @@ -170,8 +170,9 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha task.Quota = actualQuota // 更新任务记录的实际扣费额度 // 记录消费日志 - logContent := fmt.Sprintf("视频任务成功补扣费,模型倍率 %.2f,分组倍率 %.2f,tokens %d", - modelRatio, finalGroupRatio, taskResult.TotalTokens) + logContent := fmt.Sprintf("视频任务成功补扣费,模型倍率 %.2f,分组倍率 %.2f,tokens %d,预扣费 %s,实际扣费 %s,补扣费 %s", + modelRatio, finalGroupRatio, taskResult.TotalTokens, + logger.LogQuota(preConsumedQuota), logger.LogQuota(actualQuota), logger.LogQuota(quotaDelta)) model.RecordLog(task.UserId, model.LogTypeSystem, logContent) } } else if quotaDelta < 0 { @@ -190,8 +191,9 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha task.Quota = actualQuota // 更新任务记录的实际扣费额度 // 记录退款日志 - logContent := fmt.Sprintf("视频任务成功退还多扣费用,模型倍率 %.2f,分组倍率 %.2f,tokens %d,退还 %s", - modelRatio, finalGroupRatio, taskResult.TotalTokens, logger.LogQuota(refundQuota)) + logContent := fmt.Sprintf("视频任务成功退还多扣费用,模型倍率 %.2f,分组倍率 %.2f,tokens %d,预扣费 %s,实际扣费 %s,退还 %s", + modelRatio, finalGroupRatio, taskResult.TotalTokens, + logger.LogQuota(preConsumedQuota), logger.LogQuota(actualQuota), logger.LogQuota(refundQuota)) model.RecordLog(task.UserId, model.LogTypeSystem, logContent) } } else { From 26a563da54c65f4ade601b567a51da86013e0537 Mon Sep 17 00:00:00 2001 From: Seefs Date: Thu, 2 Oct 2025 13:57:49 +0800 Subject: [PATCH 095/243] fix: Return the original payload and nil error on Unmarshal or Marshal failures in RemoveDisabledFields --- relay/common/relay_info.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index cb66cd80..35f8ad19 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -515,7 +515,8 @@ type TaskInfo struct { func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOtherSettings) ([]byte, error) { var data map[string]interface{} if err := common.Unmarshal(jsonData, &data); err != nil { - return jsonData, err + common.SysError("RemoveDisabledFields Unmarshal error :" + err.Error()) + return jsonData, nil } // 默认移除 service_tier,除非明确允许(避免额外计费风险) @@ -539,5 +540,10 @@ func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOther } } - return common.Marshal(data) + jsonDataAfter, err := common.Marshal(data) + if err != nil { + common.SysError("RemoveDisabledFields Marshal error :" + err.Error()) + return jsonData, nil + } + return jsonDataAfter, nil } From 6a1de0ebdca9ffaa665ffb0346a04b30986a50e5 Mon Sep 17 00:00:00 2001 From: Seefs Date: Thu, 2 Oct 2025 14:28:58 +0800 Subject: [PATCH 096/243] fix: merge conflict --- .../components/table/channels/modals/EditChannelModal.jsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 5cff8961..09cfb0f0 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -2521,8 +2521,6 @@ const EditChannelModal = (props) => { '键为原状态码,值为要复写的状态码,仅影响本地判断', )} /> - - {/* 字段透传控制 - OpenAI 渠道 */} {inputs.type === 1 && ( @@ -2593,7 +2591,8 @@ const EditChannelModal = (props) => { /> )} - + + {/* Channel Extra Settings Card */}
formSectionRefs.current.channelExtraSettings = el}> @@ -2699,8 +2698,6 @@ const EditChannelModal = (props) => { />
- - )} From 15b21c075f1007bfce7ccfb514ff694cf288762c Mon Sep 17 00:00:00 2001 From: bubblepipe42 Date: Thu, 2 Oct 2025 14:55:06 +0800 Subject: [PATCH 097/243] windows tray icon --- electron/main.js | 8 +++++++- electron/package.json | 3 ++- electron/tray-icon-windows.png | Bin 0 -> 1203 bytes 3 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 electron/tray-icon-windows.png diff --git a/electron/main.js b/electron/main.js index bbe6e7cc..9a8534f3 100644 --- a/electron/main.js +++ b/electron/main.js @@ -146,7 +146,13 @@ function createWindow() { } function createTray() { - tray = new Tray(path.join(__dirname, 'tray-iconTemplate.png')); + // Use template icon for macOS (black with transparency, auto-adapts to theme) + // Use colored icon for Windows + const trayIconPath = process.platform === 'darwin' + ? path.join(__dirname, 'tray-iconTemplate.png') + : path.join(__dirname, 'tray-icon-windows.png'); + + tray = new Tray(trayIconPath); const contextMenu = Menu.buildFromTemplate([ { diff --git a/electron/package.json b/electron/package.json index 0eb07afd..9cdf3d12 100644 --- a/electron/package.json +++ b/electron/package.json @@ -39,7 +39,8 @@ "preload.js", "icon.png", "tray-iconTemplate.png", - "tray-iconTemplate@2x.png" + "tray-iconTemplate@2x.png", + "tray-icon-windows.png" ], "mac": { "category": "public.app-category.developer-tools", diff --git a/electron/tray-icon-windows.png b/electron/tray-icon-windows.png new file mode 100644 index 0000000000000000000000000000000000000000..57df8ead031450e7787bb01bec1c0be0e906f0f6 GIT binary patch literal 1203 zcmZWoX;f236n;rvfI{xH-x6O3{njggI5v=Wc$#U=e%R68bWBN8VMHfzbW1tZ*yONWT$X`dbxkNYM3rj|%}VT7BC zju^x>I%DJ=G98V%s;iP<%|iUxDZSIIx_b|ieoi0Ip~))Kf2u-R3u-c5ZIY8o6}p5< z-JMVz6Wt?I=17^YJlS~ixZ%V}17{rB-G_o`(#yJ50S**iFKUEa<)yj<_ceHIzkjDD zUts`tw=o24cp2aF*o(+5(m(egou(G;(Iq13PaYl51sxuz3U1aq zEU1l#n*gtravdiADZD{T%Jeq6B`l`b4qf_e040+D0(xyEHpwS%gkc!KmX3to(t18a z&tw?Mw0|S&wSWa4`y{dsClTLn zlt_66Zt?CxiTDvfkTtJ9@KCdT7$FiTK(R!%+G-dB_JTaauiHtlpHaaB4T!&E2q9Dd z0qM#*z05K62dzD_6!fD8pf$+}02NgBe5!%{Qhim7^?8w?5V&;EBsWDgXhD5TvW>Qy z#btJIv-6Ca%y@G!^R?`(-ahxtb?{d1%Yp+BLe&3T*NQi`*Ka+mfiS&Qofu876Fph< zFNQ}BafQFFxH!V0p@MT^Gj>R*KD%9v)xq!A*tDimtIvnhG1}P3(cD7;sT|v4LAAev ziEYDnKityNL$eP~p+33a*gzk&9z3=sg)+=i;_1iPW5>-X>U-W)6h-bTj0&Ly^Fbdf z7-Ue5^r8cuqUNx`r^B!5#}nF0OxLKp%1f2tW5E0Ucp=$UVwjQ4juT68ugj#_?P47W(#k5m*34;x)Qo3<`Uja=OtMc=kLnFp3iS9+q2e8;1>k<4h)RH7iGtRY`dO$ zTbEVCWw#TjEUle}Ec~5YAjO8_{I6LomSzJ(*8Y{Mlg?oLjvz=*wM*Re+}z;-<=Gf! z_%{{ZN})=n>SXe}oJAs!roHSMDfiR@hs}OoTJ3$9&kq}2^YqJCt7f<{63LakYNc;1 zk?HBVo^kJNVc`>VzM|;6+S<0v#+3!@I|Cv*ohs*i;A zH%2+!N1NzR?Mj0}1pG@9xPn!8)74RA6}>V(F45+3h!PfVcJCa#$djf$NMNp$TDyE3 kyV0{HeVn Date: Thu, 2 Oct 2025 15:28:09 +0800 Subject: [PATCH 098/243] refactor(adaptor): extract common header operations into a separate function --- relay/channel/aws/adaptor.go | 7 +------ relay/channel/claude/adaptor.go | 15 ++++++++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/relay/channel/aws/adaptor.go b/relay/channel/aws/adaptor.go index 6202c9fc..92d60df4 100644 --- a/relay/channel/aws/adaptor.go +++ b/relay/channel/aws/adaptor.go @@ -7,7 +7,6 @@ import ( "one-api/dto" "one-api/relay/channel/claude" relaycommon "one-api/relay/common" - "one-api/setting/model_setting" "one-api/types" "github.com/gin-gonic/gin" @@ -52,11 +51,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { } func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { - anthropicBeta := c.Request.Header.Get("anthropic-beta") - if anthropicBeta != "" { - req.Set("anthropic-beta", anthropicBeta) - } - model_setting.GetClaudeSettings().WriteHeaders(info.OriginModelName, req) + claude.CommonClaudeHeadersOperation(c, req, info) return nil } diff --git a/relay/channel/claude/adaptor.go b/relay/channel/claude/adaptor.go index 362f09e7..17e7cbd2 100644 --- a/relay/channel/claude/adaptor.go +++ b/relay/channel/claude/adaptor.go @@ -64,6 +64,15 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { return baseURL, nil } +func CommonClaudeHeadersOperation(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) { + // common headers operation + anthropicBeta := c.Request.Header.Get("anthropic-beta") + if anthropicBeta != "" { + req.Set("anthropic-beta", anthropicBeta) + } + model_setting.GetClaudeSettings().WriteHeaders(info.OriginModelName, req) +} + func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { channel.SetupApiRequestHeader(info, c, req) req.Set("x-api-key", info.ApiKey) @@ -72,11 +81,7 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel anthropicVersion = "2023-06-01" } req.Set("anthropic-version", anthropicVersion) - anthropicBeta := c.Request.Header.Get("anthropic-beta") - if anthropicBeta != "" { - req.Set("anthropic-beta", anthropicBeta) - } - model_setting.GetClaudeSettings().WriteHeaders(info.OriginModelName, req) + CommonClaudeHeadersOperation(c, req, info) return nil } From 81a66be721b3fd8b9b1c695f7673fe9231469c16 Mon Sep 17 00:00:00 2001 From: CaIon Date: Thu, 2 Oct 2025 16:14:15 +0800 Subject: [PATCH 099/243] chore(docker): switch from MySQL to PostgreSQL in docker-compose configuration --- docker-compose.yml | 63 +++++++++++++++++++++++++++++++++------------- 1 file changed, 46 insertions(+), 17 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index d98fd706..b98776d1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,17 @@ +# New-API Docker Compose Configuration +# +# Quick Start: +# 1. docker-compose up -d +# 2. Access at http://localhost:3000 +# +# Using MySQL instead of PostgreSQL: +# 1. Comment out the postgres service and SQL_DSN line 15 +# 2. Uncomment the mysql service and SQL_DSN line 16 +# 3. Uncomment mysql in depends_on (line 28) +# 4. Uncomment mysql_data in volumes section (line 64) +# +# ⚠️ IMPORTANT: Change all default passwords before deploying to production! + version: '3.4' services: @@ -12,21 +26,22 @@ services: - ./data:/data - ./logs:/app/logs environment: - - SQL_DSN=root:123456@tcp(mysql:3306)/new-api # Point to the mysql service + - SQL_DSN=postgresql://root:123456@postgres:5432/new-api # ⚠️ IMPORTANT: Change the password in production! +# - SQL_DSN=root:123456@tcp(mysql:3306)/new-api # Point to the mysql service, uncomment if using MySQL - REDIS_CONN_STRING=redis://redis - TZ=Asia/Shanghai - ERROR_LOG_ENABLED=true # 是否启用错误日志记录 - # - STREAMING_TIMEOUT=300 # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值 - # - SESSION_SECRET=random_string # 多机部署时设置,必须修改这个随机字符串!!!!!!! - # - NODE_TYPE=slave # Uncomment for slave node in multi-node deployment - # - SYNC_FREQUENCY=60 # Uncomment if regular database syncing is needed - # - FRONTEND_BASE_URL=https://openai.justsong.cn # Uncomment for multi-node deployment with front-end URL + - BATCH_UPDATE_ENABLED=true # 是否启用批量更新 batch update enabled +# - STREAMING_TIMEOUT=300 # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值 Streaming timeout in seconds, default is 120s. Increase if experiencing empty completions +# - SESSION_SECRET=random_string # 多机部署时设置,必须修改这个随机字符串!! multi-node deployment, set this to a random string!!!!!!! +# - SYNC_FREQUENCY=60 # Uncomment if regular database syncing is needed depends_on: - redis - - mysql + - postgres +# - mysql # Uncomment if using MySQL healthcheck: - test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' | awk -F: '{print $$2}'"] + test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' || exit 1"] interval: 30s timeout: 10s retries: 3 @@ -36,17 +51,31 @@ services: container_name: redis restart: always - mysql: - image: mysql:8.2 - container_name: mysql + postgres: + image: postgres:15 + container_name: postgres restart: always environment: - MYSQL_ROOT_PASSWORD: 123456 # Ensure this matches the password in SQL_DSN - MYSQL_DATABASE: new-api + POSTGRES_USER: root + POSTGRES_PASSWORD: 123456 # ⚠️ IMPORTANT: Change this password in production! + POSTGRES_DB: new-api volumes: - - mysql_data:/var/lib/mysql - # ports: - # - "3306:3306" # If you want to access MySQL from outside Docker, uncomment + - pg_data:/var/lib/postgresql/data +# ports: +# - "5432:5432" # Uncomment if you need to access PostgreSQL from outside Docker + +# mysql: +# image: mysql:8.2 +# container_name: mysql +# restart: always +# environment: +# MYSQL_ROOT_PASSWORD: 123456 # ⚠️ IMPORTANT: Change this password in production! +# MYSQL_DATABASE: new-api +# volumes: +# - mysql_data:/var/lib/mysql +# ports: +# - "3306:3306" # Uncomment if you need to access MySQL from outside Docker volumes: - mysql_data: + pg_data: +# mysql_data: From b0b275b2360e1a9f75f50cffb7d7dbe46e0150fd Mon Sep 17 00:00:00 2001 From: CaIon Date: Thu, 2 Oct 2025 16:20:15 +0800 Subject: [PATCH 100/243] chore(docker): add comment for compatibility with older Docker versions --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index b98776d1..e657390a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,7 @@ # # ⚠️ IMPORTANT: Change all default passwords before deploying to production! -version: '3.4' +version: '3.4' # For compatibility with older Docker versions services: new-api: From df19a8de5dd3b4659bdcb4844ddce41d59dd96eb Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sat, 27 Sep 2025 18:47:53 +0800 Subject: [PATCH 101/243] =?UTF-8?q?=E2=9C=A8=20feat(layout):=20refine=20fo?= =?UTF-8?q?oter=20visibility=20logic=20to=20target=20CardPro=20component?= =?UTF-8?q?=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace blanket console route footer hiding with specific page targeting - Only hide footer on pages that use CardPro component: * /console/channel (channels management) * /console/log (usage logs) * /console/redemption (redemption codes) * /console/user (user management) * /console/token (token management) * /console/midjourney (midjourney logs) * /console/task (task logs) * /console/models (model management) * /pricing (pricing page) - Footer now displays on other console pages (dashboard, settings, topup, etc.) - Improves UI consistency by showing footer where CardPro's internal pagination isn't used This change ensures footer is only hidden when CardPro component provides its own pagination/footer functionality, while preserving footer visibility on other pages that benefit from the global footer navigation. --- web/jsconfig.json | 2 +- .../common/modals/TwoFactorAuthModal.jsx | 4 +- web/src/components/layout/PageLayout.jsx | 16 +++- web/src/components/settings/SystemSetting.jsx | 74 +++++++++++------ .../channels/modals/EditChannelModal.jsx | 31 ++----- .../table/mj-logs/MjLogsFilters.jsx | 4 +- .../table/task-logs/TaskLogsColumnDefs.jsx | 5 +- .../table/task-logs/TaskLogsFilters.jsx | 4 +- .../table/usage-logs/UsageLogsFilters.jsx | 4 +- web/src/components/topup/RechargeCard.jsx | 82 +++++++++++++------ web/src/components/topup/index.jsx | 31 ++++--- .../topup/modals/PaymentConfirmModal.jsx | 7 +- web/src/constants/console.constants.js | 10 +-- web/src/helpers/api.js | 2 - web/src/helpers/render.jsx | 2 +- web/src/hooks/common/useSidebar.js | 5 +- .../Setting/Operation/SettingsGeneral.jsx | 27 +++--- .../Setting/Operation/SettingsMonitoring.jsx | 3 +- .../Payment/SettingsPaymentGateway.jsx | 42 +++++++--- .../Setting/Ratio/ModelRatioSettings.jsx | 28 ++++--- 20 files changed, 240 insertions(+), 143 deletions(-) diff --git a/web/jsconfig.json b/web/jsconfig.json index ced4d054..170a7cb4 100644 --- a/web/jsconfig.json +++ b/web/jsconfig.json @@ -6,4 +6,4 @@ } }, "include": ["src/**/*"] -} \ No newline at end of file +} diff --git a/web/src/components/common/modals/TwoFactorAuthModal.jsx b/web/src/components/common/modals/TwoFactorAuthModal.jsx index 2a9a8b25..082e63d7 100644 --- a/web/src/components/common/modals/TwoFactorAuthModal.jsx +++ b/web/src/components/common/modals/TwoFactorAuthModal.jsx @@ -135,7 +135,9 @@ const TwoFactorAuthModal = ({ autoFocus /> - {t('支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。')} + {t( + '支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。', + )} diff --git a/web/src/components/layout/PageLayout.jsx b/web/src/components/layout/PageLayout.jsx index f8cdfb0c..6474501d 100644 --- a/web/src/components/layout/PageLayout.jsx +++ b/web/src/components/layout/PageLayout.jsx @@ -48,9 +48,19 @@ const PageLayout = () => { const { i18n } = useTranslation(); const location = useLocation(); - const shouldHideFooter = - location.pathname.startsWith('/console') || - location.pathname === '/pricing'; + const cardProPages = [ + '/console/channel', + '/console/log', + '/console/redemption', + '/console/user', + '/console/token', + '/console/midjourney', + '/console/task', + '/console/models', + '/pricing', + ]; + + const shouldHideFooter = cardProPages.includes(location.pathname); const shouldInnerPadding = location.pathname.includes('/console') && diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx index 112d104a..780e89fb 100644 --- a/web/src/components/settings/SystemSetting.jsx +++ b/web/src/components/settings/SystemSetting.jsx @@ -46,7 +46,6 @@ import { useTranslation } from 'react-i18next'; const SystemSetting = () => { const { t } = useTranslation(); let [inputs, setInputs] = useState({ - PasswordLoginEnabled: '', PasswordRegisterEnabled: '', EmailVerificationEnabled: '', @@ -212,7 +211,9 @@ const SystemSetting = () => { setInputs(newInputs); setOriginInputs(newInputs); // 同步模式布尔到本地状态 - if (typeof newInputs['fetch_setting.domain_filter_mode'] !== 'undefined') { + if ( + typeof newInputs['fetch_setting.domain_filter_mode'] !== 'undefined' + ) { setDomainFilterMode(!!newInputs['fetch_setting.domain_filter_mode']); } if (typeof newInputs['fetch_setting.ip_filter_mode'] !== 'undefined') { @@ -749,14 +750,17 @@ const SystemSetting = () => { noLabel extraText={t('SSRF防护开关详细说明')} onChange={(e) => - handleCheckboxChange('fetch_setting.enable_ssrf_protection', e) + handleCheckboxChange( + 'fetch_setting.enable_ssrf_protection', + e, + ) } > {t('启用SSRF防护(推荐开启以保护服务器安全)')} - + { noLabel extraText={t('私有IP访问详细说明')} onChange={(e) => - handleCheckboxChange('fetch_setting.allow_private_ip', e) + handleCheckboxChange( + 'fetch_setting.allow_private_ip', + e, + ) } > - {t('允许访问私有IP地址(127.0.0.1、192.168.x.x等内网地址)')} + {t( + '允许访问私有IP地址(127.0.0.1、192.168.x.x等内网地址)', + )} - + { noLabel extraText={t('域名IP过滤详细说明')} onChange={(e) => - handleCheckboxChange('fetch_setting.apply_ip_filter_for_domain', e) + handleCheckboxChange( + 'fetch_setting.apply_ip_filter_for_domain', + e, + ) } style={{ marginBottom: 8 }} > @@ -794,17 +806,23 @@ const SystemSetting = () => { {t(domainFilterMode ? '域名白名单' : '域名黑名单')} - - {t('支持通配符格式,如:example.com, *.api.example.com')} + + {t( + '支持通配符格式,如:example.com, *.api.example.com', + )} { - const selected = val && val.target ? val.target.value : val; + const selected = + val && val.target ? val.target.value : val; const isWhitelist = selected === 'whitelist'; setDomainFilterMode(isWhitelist); - setInputs(prev => ({ + setInputs((prev) => ({ ...prev, 'fetch_setting.domain_filter_mode': isWhitelist, })); @@ -819,9 +837,9 @@ const SystemSetting = () => { onChange={(value) => { setDomainList(value); // 触发Form的onChange事件 - setInputs(prev => ({ + setInputs((prev) => ({ ...prev, - 'fetch_setting.domain_list': value + 'fetch_setting.domain_list': value, })); }} placeholder={t('输入域名后回车,如:example.com')} @@ -838,17 +856,21 @@ const SystemSetting = () => { {t(ipFilterMode ? 'IP白名单' : 'IP黑名单')} - + {t('支持CIDR格式,如:8.8.8.8, 192.168.1.0/24')} { - const selected = val && val.target ? val.target.value : val; + const selected = + val && val.target ? val.target.value : val; const isWhitelist = selected === 'whitelist'; setIpFilterMode(isWhitelist); - setInputs(prev => ({ + setInputs((prev) => ({ ...prev, 'fetch_setting.ip_filter_mode': isWhitelist, })); @@ -863,9 +885,9 @@ const SystemSetting = () => { onChange={(value) => { setIpList(value); // 触发Form的onChange事件 - setInputs(prev => ({ + setInputs((prev) => ({ ...prev, - 'fetch_setting.ip_list': value + 'fetch_setting.ip_list': value, })); }} placeholder={t('输入IP地址后回车,如:8.8.8.8')} @@ -880,7 +902,10 @@ const SystemSetting = () => { >
{t('允许的端口')} - + {t('支持单个端口和端口范围,如:80, 443, 8000-8999')} { onChange={(value) => { setAllowedPorts(value); // 触发Form的onChange事件 - setInputs(prev => ({ + setInputs((prev) => ({ ...prev, - 'fetch_setting.allowed_ports': value + 'fetch_setting.allowed_ports': value, })); }} placeholder={t('输入端口后回车,如:80 或 8000-8999')} style={{ width: '100%' }} /> - + {t('端口配置详细说明')} diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 09cfb0f0..e9a21c20 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -91,22 +91,7 @@ const REGION_EXAMPLE = { // 支持并且已适配通过接口获取模型列表的渠道类型 const MODEL_FETCHABLE_TYPES = new Set([ - 1, - 4, - 14, - 34, - 17, - 26, - 24, - 47, - 25, - 20, - 23, - 31, - 35, - 40, - 42, - 48, + 1, 4, 14, 34, 17, 26, 24, 47, 25, 20, 23, 31, 35, 40, 42, 48, 43, ]); @@ -279,8 +264,8 @@ const EditChannelModal = (props) => { const scrollToSection = (sectionKey) => { const sectionElement = formSectionRefs.current[sectionKey]; if (sectionElement) { - sectionElement.scrollIntoView({ - behavior: 'smooth', + sectionElement.scrollIntoView({ + behavior: 'smooth', block: 'start', inline: 'nearest' }); @@ -301,7 +286,7 @@ const EditChannelModal = (props) => { } else { newIndex = currentSectionIndex < availableSections.length - 1 ? currentSectionIndex + 1 : 0; } - + setCurrentSectionIndex(newIndex); scrollToSection(availableSections[newIndex]); }; @@ -1340,7 +1325,7 @@ const EditChannelModal = (props) => { type='tertiary' icon={} onClick={() => navigateToSection('up')} - style={{ + style={{ borderRadius: '50%', width: '32px', height: '32px', @@ -1356,7 +1341,7 @@ const EditChannelModal = (props) => { type='tertiary' icon={} onClick={() => navigateToSection('down')} - style={{ + style={{ borderRadius: '50%', width: '32px', height: '32px', @@ -1398,8 +1383,8 @@ const EditChannelModal = (props) => { > {() => ( -
formSectionRefs.current.basicInfo = el}> diff --git a/web/src/components/table/mj-logs/MjLogsFilters.jsx b/web/src/components/table/mj-logs/MjLogsFilters.jsx index 6db96e79..7c61454e 100644 --- a/web/src/components/table/mj-logs/MjLogsFilters.jsx +++ b/web/src/components/table/mj-logs/MjLogsFilters.jsx @@ -56,10 +56,10 @@ const MjLogsFilters = ({ showClear pure size='small' - presets={DATE_RANGE_PRESETS.map(preset => ({ + presets={DATE_RANGE_PRESETS.map((preset) => ({ text: t(preset.text), start: preset.start(), - end: preset.end() + end: preset.end(), }))} />
diff --git a/web/src/components/table/task-logs/TaskLogsColumnDefs.jsx b/web/src/components/table/task-logs/TaskLogsColumnDefs.jsx index b63c7dd4..1f097b2b 100644 --- a/web/src/components/table/task-logs/TaskLogsColumnDefs.jsx +++ b/web/src/components/table/task-logs/TaskLogsColumnDefs.jsx @@ -36,8 +36,9 @@ import { } from 'lucide-react'; import { TASK_ACTION_FIRST_TAIL_GENERATE, - TASK_ACTION_GENERATE, TASK_ACTION_REFERENCE_GENERATE, - TASK_ACTION_TEXT_GENERATE + TASK_ACTION_GENERATE, + TASK_ACTION_REFERENCE_GENERATE, + TASK_ACTION_TEXT_GENERATE, } from '../../../constants/common.constant'; import { CHANNEL_OPTIONS } from '../../../constants/channel.constants'; diff --git a/web/src/components/table/task-logs/TaskLogsFilters.jsx b/web/src/components/table/task-logs/TaskLogsFilters.jsx index e27cea86..3bfae77a 100644 --- a/web/src/components/table/task-logs/TaskLogsFilters.jsx +++ b/web/src/components/table/task-logs/TaskLogsFilters.jsx @@ -56,10 +56,10 @@ const TaskLogsFilters = ({ showClear pure size='small' - presets={DATE_RANGE_PRESETS.map(preset => ({ + presets={DATE_RANGE_PRESETS.map((preset) => ({ text: t(preset.text), start: preset.start(), - end: preset.end() + end: preset.end(), }))} />
diff --git a/web/src/components/table/usage-logs/UsageLogsFilters.jsx b/web/src/components/table/usage-logs/UsageLogsFilters.jsx index 58e5a469..840c82ee 100644 --- a/web/src/components/table/usage-logs/UsageLogsFilters.jsx +++ b/web/src/components/table/usage-logs/UsageLogsFilters.jsx @@ -57,10 +57,10 @@ const LogsFilters = ({ showClear pure size='small' - presets={DATE_RANGE_PRESETS.map(preset => ({ + presets={DATE_RANGE_PRESETS.map((preset) => ({ text: t(preset.text), start: preset.start(), - end: preset.end() + end: preset.end(), }))} /> diff --git a/web/src/components/topup/RechargeCard.jsx b/web/src/components/topup/RechargeCard.jsx index 03ea2b31..0a299ffa 100644 --- a/web/src/components/topup/RechargeCard.jsx +++ b/web/src/components/topup/RechargeCard.jsx @@ -30,7 +30,8 @@ import { Space, Row, Col, - Spin, Tooltip + Spin, + Tooltip, } from '@douyinfe/semi-ui'; import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si'; import { CreditCard, Coins, Wallet, BarChart2, TrendingUp } from 'lucide-react'; @@ -266,7 +267,8 @@ const RechargeCard = ({ {payMethods && payMethods.length > 0 ? ( {payMethods.map((payMethod) => { - const minTopupVal = Number(payMethod.min_topup) || 0; + const minTopupVal = + Number(payMethod.min_topup) || 0; const isStripe = payMethod.type === 'stripe'; const disabled = (!enableOnlineTopUp && !isStripe) || @@ -280,7 +282,9 @@ const RechargeCard = ({ type='tertiary' onClick={() => preTopUp(payMethod.type)} disabled={disabled} - loading={paymentLoading && payWay === payMethod.type} + loading={ + paymentLoading && payWay === payMethod.type + } icon={ payMethod.type === 'alipay' ? ( @@ -291,7 +295,10 @@ const RechargeCard = ({ ) : ( ) } @@ -301,12 +308,22 @@ const RechargeCard = ({ ); - return disabled && minTopupVal > Number(topUpCount || 0) ? ( - + return disabled && + minTopupVal > Number(topUpCount || 0) ? ( + {buttonEl} ) : ( - {buttonEl} + + {buttonEl} + ); })} @@ -324,23 +341,27 @@ const RechargeCard = ({
{presetAmounts.map((preset, index) => { - const discount = preset.discount || topupInfo?.discount?.[preset.value] || 1.0; + const discount = + preset.discount || + topupInfo?.discount?.[preset.value] || + 1.0; const originalPrice = preset.value * priceRatio; const discountedPrice = originalPrice * discount; const hasDiscount = discount < 1.0; const actualPay = discountedPrice; const save = originalPrice - discountedPrice; - + return ( { @@ -352,24 +373,35 @@ const RechargeCard = ({ }} >
- + {formatLargeNumber(preset.value)} {hasDiscount && ( - - {t('折').includes('off') ? - ((1 - parseFloat(discount)) * 100).toFixed(1) : - (discount * 10).toFixed(1)}{t('折')} - + + {t('折').includes('off') + ? ( + (1 - parseFloat(discount)) * + 100 + ).toFixed(1) + : (discount * 10).toFixed(1)} + {t('折')} + )} -
+
{t('实付')} {actualPay.toFixed(2)}, - {hasDiscount ? `${t('节省')} ${save.toFixed(2)}` : `${t('节省')} 0.00`} + {hasDiscount + ? `${t('节省')} ${save.toFixed(2)}` + : `${t('节省')} 0.00`}
diff --git a/web/src/components/topup/index.jsx b/web/src/components/topup/index.jsx index 929a47e3..558c6705 100644 --- a/web/src/components/topup/index.jsx +++ b/web/src/components/topup/index.jsx @@ -80,11 +80,11 @@ const TopUp = () => { // 预设充值额度选项 const [presetAmounts, setPresetAmounts] = useState([]); const [selectedPreset, setSelectedPreset] = useState(null); - + // 充值配置信息 const [topupInfo, setTopupInfo] = useState({ amount_options: [], - discount: {} + discount: {}, }); const topUp = async () => { @@ -262,9 +262,9 @@ const TopUp = () => { if (success) { setTopupInfo({ amount_options: data.amount_options || [], - discount: data.discount || {} + discount: data.discount || {}, }); - + // 处理支付方式 let payMethods = data.pay_methods || []; try { @@ -280,10 +280,15 @@ const TopUp = () => { payMethods = payMethods.map((method) => { // 规范化最小充值数 const normalizedMinTopup = Number(method.min_topup); - method.min_topup = Number.isFinite(normalizedMinTopup) ? normalizedMinTopup : 0; + method.min_topup = Number.isFinite(normalizedMinTopup) + ? normalizedMinTopup + : 0; // Stripe 的最小充值从后端字段回填 - if (method.type === 'stripe' && (!method.min_topup || method.min_topup <= 0)) { + if ( + method.type === 'stripe' && + (!method.min_topup || method.min_topup <= 0) + ) { const stripeMin = Number(data.stripe_min_topup); if (Number.isFinite(stripeMin)) { method.min_topup = stripeMin; @@ -313,7 +318,11 @@ const TopUp = () => { setPayMethods(payMethods); const enableStripeTopUp = data.enable_stripe_topup || false; const enableOnlineTopUp = data.enable_online_topup || false; - const minTopUpValue = enableOnlineTopUp? data.min_topup : enableStripeTopUp? data.stripe_min_topup : 1; + const minTopUpValue = enableOnlineTopUp + ? data.min_topup + : enableStripeTopUp + ? data.stripe_min_topup + : 1; setEnableOnlineTopUp(enableOnlineTopUp); setEnableStripeTopUp(enableStripeTopUp); setMinTopUp(minTopUpValue); @@ -330,12 +339,12 @@ const TopUp = () => { console.log('解析支付方式失败:', e); setPayMethods([]); } - + // 如果有自定义充值数量选项,使用它们替换默认的预设选项 if (data.amount_options && data.amount_options.length > 0) { - const customPresets = data.amount_options.map(amount => ({ + const customPresets = data.amount_options.map((amount) => ({ value: amount, - discount: data.discount[amount] || 1.0 + discount: data.discount[amount] || 1.0, })); setPresetAmounts(customPresets); } @@ -483,7 +492,7 @@ const TopUp = () => { const selectPresetAmount = (preset) => { setTopUpCount(preset.value); setSelectedPreset(preset.value); - + // 计算实际支付金额,考虑折扣 const discount = preset.discount || topupInfo.discount[preset.value] || 1.0; const discountedAmount = preset.value * priceRatio * discount; diff --git a/web/src/components/topup/modals/PaymentConfirmModal.jsx b/web/src/components/topup/modals/PaymentConfirmModal.jsx index 1bffbfed..8bd5455c 100644 --- a/web/src/components/topup/modals/PaymentConfirmModal.jsx +++ b/web/src/components/topup/modals/PaymentConfirmModal.jsx @@ -40,9 +40,10 @@ const PaymentConfirmModal = ({ amountNumber, discountRate, }) => { - const hasDiscount = discountRate && discountRate > 0 && discountRate < 1 && amountNumber > 0; - const originalAmount = hasDiscount ? (amountNumber / discountRate) : 0; - const discountAmount = hasDiscount ? (originalAmount - amountNumber) : 0; + const hasDiscount = + discountRate && discountRate > 0 && discountRate < 1 && amountNumber > 0; + const originalAmount = hasDiscount ? amountNumber / discountRate : 0; + const discountAmount = hasDiscount ? originalAmount - amountNumber : 0; return ( dayjs().startOf('day').toDate(), - end: () => dayjs().endOf('day').toDate() + end: () => dayjs().endOf('day').toDate(), }, { text: '近 7 天', start: () => dayjs().subtract(6, 'day').startOf('day').toDate(), - end: () => dayjs().endOf('day').toDate() + end: () => dayjs().endOf('day').toDate(), }, { text: '本周', start: () => dayjs().startOf('week').toDate(), - end: () => dayjs().endOf('week').toDate() + end: () => dayjs().endOf('week').toDate(), }, { text: '近 30 天', start: () => dayjs().subtract(29, 'day').startOf('day').toDate(), - end: () => dayjs().endOf('day').toDate() + end: () => dayjs().endOf('day').toDate(), }, { text: '本月', start: () => dayjs().startOf('month').toDate(), - end: () => dayjs().endOf('month').toDate() + end: () => dayjs().endOf('month').toDate(), }, ]; diff --git a/web/src/helpers/api.js b/web/src/helpers/api.js index bc389b2e..1ccfffaf 100644 --- a/web/src/helpers/api.js +++ b/web/src/helpers/api.js @@ -131,13 +131,11 @@ export const buildApiPayload = ( seed: 'seed', }; - Object.entries(parameterMappings).forEach(([key, param]) => { const enabled = parameterEnabled[key]; const value = inputs[param]; const hasValue = value !== undefined && value !== null; - if (enabled && hasValue) { payload[param] = value; } diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index 25afacec..78ff8a44 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -1074,7 +1074,7 @@ export function renderModelPrice( (completionTokens / 1000000) * completionRatioPrice * groupRatio + (webSearchCallCount / 1000) * webSearchPrice * groupRatio + (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio + - (imageGenerationCallPrice * groupRatio); + imageGenerationCallPrice * groupRatio; return ( <> diff --git a/web/src/hooks/common/useSidebar.js b/web/src/hooks/common/useSidebar.js index 0ccc5835..76d74ac3 100644 --- a/web/src/hooks/common/useSidebar.js +++ b/web/src/hooks/common/useSidebar.js @@ -183,7 +183,10 @@ export const useSidebar = () => { sidebarEventTarget.addEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh); return () => { - sidebarEventTarget.removeEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh); + sidebarEventTarget.removeEventListener( + SIDEBAR_REFRESH_EVENT, + handleRefresh, + ); }; }, [adminConfig]); diff --git a/web/src/pages/Setting/Operation/SettingsGeneral.jsx b/web/src/pages/Setting/Operation/SettingsGeneral.jsx index 5af750ec..b8b925dc 100644 --- a/web/src/pages/Setting/Operation/SettingsGeneral.jsx +++ b/web/src/pages/Setting/Operation/SettingsGeneral.jsx @@ -130,19 +130,20 @@ export default function GeneralSettings(props) { showClear /> - {inputs.QuotaPerUnit !== '500000' && inputs.QuotaPerUnit !== 500000 && ( -
- setShowQuotaWarning(true)} - /> - - )} + {inputs.QuotaPerUnit !== '500000' && + inputs.QuotaPerUnit !== 500000 && ( + + setShowQuotaWarning(true)} + /> + + )} setInputs({ ...inputs, - 'monitor_setting.auto_test_channel_minutes': parseInt(value), + 'monitor_setting.auto_test_channel_minutes': + parseInt(value), }) } /> diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx b/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx index d681b6a2..a4f1029a 100644 --- a/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx +++ b/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx @@ -118,14 +118,20 @@ export default function SettingsPaymentGateway(props) { } } - if (originInputs['AmountOptions'] !== inputs.AmountOptions && inputs.AmountOptions.trim() !== '') { + if ( + originInputs['AmountOptions'] !== inputs.AmountOptions && + inputs.AmountOptions.trim() !== '' + ) { if (!verifyJSON(inputs.AmountOptions)) { showError(t('自定义充值数量选项不是合法的 JSON 数组')); return; } } - if (originInputs['AmountDiscount'] !== inputs.AmountDiscount && inputs.AmountDiscount.trim() !== '') { + if ( + originInputs['AmountDiscount'] !== inputs.AmountDiscount && + inputs.AmountDiscount.trim() !== '' + ) { if (!verifyJSON(inputs.AmountDiscount)) { showError(t('充值金额折扣配置不是合法的 JSON 对象')); return; @@ -163,10 +169,16 @@ export default function SettingsPaymentGateway(props) { options.push({ key: 'PayMethods', value: inputs.PayMethods }); } if (originInputs['AmountOptions'] !== inputs.AmountOptions) { - options.push({ key: 'payment_setting.amount_options', value: inputs.AmountOptions }); + options.push({ + key: 'payment_setting.amount_options', + value: inputs.AmountOptions, + }); } if (originInputs['AmountDiscount'] !== inputs.AmountDiscount) { - options.push({ key: 'payment_setting.amount_discount', value: inputs.AmountDiscount }); + options.push({ + key: 'payment_setting.amount_discount', + value: inputs.AmountDiscount, + }); } // 发送请求 @@ -273,7 +285,7 @@ export default function SettingsPaymentGateway(props) { placeholder={t('为一个 JSON 文本')} autosize /> - + - + - + diff --git a/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx b/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx index ed982edc..b298cc78 100644 --- a/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx +++ b/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx @@ -226,8 +226,12 @@ export default function ModelRatioSettings(props) { - setInputs({ ...inputs, ImageRatio: value }) - } + onChange={(value) => setInputs({ ...inputs, ImageRatio: value })} /> @@ -249,7 +251,9 @@ export default function ModelRatioSettings(props) { - setInputs({ ...inputs, AudioRatio: value }) - } + onChange={(value) => setInputs({ ...inputs, AudioRatio: value })} /> @@ -270,8 +272,12 @@ export default function ModelRatioSettings(props) { Date: Thu, 2 Oct 2025 19:00:07 +0800 Subject: [PATCH 102/243] refactor(footer): update footer links and localization text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Removed the 'chatnio' link from the footer. - Added new links for 'CoAI' and 'GPT-Load' in the footer. - Updated the localization key for '基于New API的项目' to '友情链接' for better clarity. - Adjusted the design of the footer to improve layout and visibility of the developer credit. --- web/src/components/layout/Footer.jsx | 57 +++++++++++++++++----------- web/src/i18n/locales/en.json | 3 +- 2 files changed, 35 insertions(+), 25 deletions(-) diff --git a/web/src/components/layout/Footer.jsx b/web/src/components/layout/Footer.jsx index 5c210fca..c827a581 100644 --- a/web/src/components/layout/Footer.jsx +++ b/web/src/components/layout/Footer.jsx @@ -142,14 +142,6 @@ const FooterBar = () => { > Midjourney-Proxy - - chatnio - { @@ -200,15 +207,6 @@ const FooterBar = () => { > New API - & - - One API - @@ -223,10 +221,23 @@ const FooterBar = () => { return (
{footer ? ( -
+
+
+
+ {t('设计与开发由')} + + New API + +
+
) : ( customFooter )} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 6ffff050..cb213b99 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -759,7 +759,6 @@ "获取当前设置失败": "Failed to get current settings", "设置已更新": "Settings updated", "更新设置失败": "Update settings failed", - "确认解绑": "Confirm unbinding", "您确定要解绑WxPusher吗?": "Are you sure you want to unbind WxPusher?", "解绑失败": "Unbinding failed", "订阅事件": "Subscribe to events", @@ -1478,7 +1477,7 @@ "相关项目": "Related Projects", "基于New API的项目": "Projects Based on New API", "版权所有": "All rights reserved", - "设计与开发由": "Designed & Developed with love by", + "设计与开发由": "Designed & Developed by", "演示站点": "Demo Site", "页面未找到,请检查您的浏览器地址是否正确": "Page not found, please check if your browser address is correct", "您无权访问此页面,请联系管理员": "You do not have permission to access this page. Please contact the administrator.", From 01bcbf09c6b11ff49bfb01299e5a3736c28d5e17 Mon Sep 17 00:00:00 2001 From: CaIon Date: Thu, 2 Oct 2025 19:29:57 +0800 Subject: [PATCH 103/243] =?UTF-8?q?=E2=9C=A8=20feat(api):=20add=20header?= =?UTF-8?q?=20override=20processing=20with=20variable=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- relay/channel/api_request.go | 48 ++++++++++++----- .../channels/modals/EditChannelModal.jsx | 52 ++++++++++++------- web/src/i18n/locales/en.json | 16 +++++- 3 files changed, 81 insertions(+), 35 deletions(-) diff --git a/relay/channel/api_request.go b/relay/channel/api_request.go index 79a0f706..548e720d 100644 --- a/relay/channel/api_request.go +++ b/relay/channel/api_request.go @@ -14,6 +14,7 @@ import ( "one-api/service" "one-api/setting/operation_setting" "one-api/types" + "strings" "sync" "time" @@ -36,6 +37,26 @@ func SetupApiRequestHeader(info *common.RelayInfo, c *gin.Context, req *http.Hea } } +// processHeaderOverride 处理请求头覆盖,支持变量替换 +// 支持的变量:{api_key} +func processHeaderOverride(info *common.RelayInfo) (map[string]string, error) { + headerOverride := make(map[string]string) + for k, v := range info.HeadersOverride { + str, ok := v.(string) + if !ok { + return nil, types.NewError(nil, types.ErrorCodeChannelHeaderOverrideInvalid) + } + + // 替换支持的变量 + if strings.Contains(str, "{api_key}") { + str = strings.ReplaceAll(str, "{api_key}", info.ApiKey) + } + + headerOverride[k] = str + } + return headerOverride, nil +} + func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { fullRequestURL, err := a.GetRequestURL(info) if err != nil { @@ -49,13 +70,9 @@ func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody return nil, fmt.Errorf("new request failed: %w", err) } headers := req.Header - headerOverride := make(map[string]string) - for k, v := range info.HeadersOverride { - if str, ok := v.(string); ok { - headerOverride[k] = str - } else { - return nil, types.NewError(err, types.ErrorCodeChannelHeaderOverrideInvalid) - } + headerOverride, err := processHeaderOverride(info) + if err != nil { + return nil, err } for key, value := range headerOverride { headers.Set(key, value) @@ -86,13 +103,9 @@ func DoFormRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBod // set form data req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type")) headers := req.Header - headerOverride := make(map[string]string) - for k, v := range info.HeadersOverride { - if str, ok := v.(string); ok { - headerOverride[k] = str - } else { - return nil, types.NewError(err, types.ErrorCodeChannelHeaderOverrideInvalid) - } + headerOverride, err := processHeaderOverride(info) + if err != nil { + return nil, err } for key, value := range headerOverride { headers.Set(key, value) @@ -114,6 +127,13 @@ func DoWssRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody return nil, fmt.Errorf("get request url failed: %w", err) } targetHeader := http.Header{} + headerOverride, err := processHeaderOverride(info) + if err != nil { + return nil, err + } + for key, value := range headerOverride { + targetHeader.Set(key, value) + } err = a.SetupRequestHeader(c, &targetHeader, info) if err != nil { return nil, fmt.Errorf("setup request header failed: %w", err) diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index e9a21c20..dfbd75a4 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -2452,32 +2452,44 @@ const EditChannelModal = (props) => { t('此项可选,用于覆盖请求头参数') + '\n' + t('格式示例:') + - '\n{\n "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0"\n}' + '\n{\n "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0",\n "Authorization": "Bearer {api_key}"\n}' } autosize onChange={(value) => handleInputChange('header_override', value) } extraText={ -
- - handleInputChange( - 'header_override', - JSON.stringify( - { - 'User-Agent': - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0', - }, - null, - 2, - ), - ) - } - > - {t('格式模板')} - + +
+
+ + handleInputChange( + 'header_override', + JSON.stringify( + { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0', + 'Authorization': 'Bearer{api_key}', + }, + null, + 2, + ), + ) + } + > + {t('填入模板')} + +
+
+ + {t('支持变量:')} + +
+
{t('渠道密钥')}: {'{api_key}'}
+
+
} showClear diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index cb213b99..58b743e1 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -2221,7 +2221,6 @@ "留空将自动使用服务器地址,多个 Origin 用于支持多域名部署": "Leave blank to auto-use server address, multiple Origins for multi-domain deployment", "输入 Origin 后回车,如:https://example.com": "Enter Origin and press Enter, e.g.: https://example.com", "保存 Passkey 设置": "Save Passkey Settings", - "黑名单": "Blacklist", "字段透传控制": "Field Pass-through Control", "允许 service_tier 透传": "Allow service_tier Pass-through", "禁用 store 透传": "Disable store Pass-through", @@ -2229,6 +2228,21 @@ "service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用": "The service_tier field is used to specify service level. Allowing pass-through may result in higher billing than expected. Disabled by default to avoid extra charges", "store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用": "The store field authorizes OpenAI to store request data for product evaluation and optimization. Disabled by default. Enabling may cause Codex to malfunction", "safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私": "The safety_identifier field helps OpenAI identify application users who may violate usage policies. Disabled by default to protect user privacy", + "支持变量:": "Supported variables:", + "请求头覆盖": "Request header override", + "旧格式模板": "Old format template", + "新格式模板": "New format template", + "系统提示词拼接": "System prompt append", + "如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面": "If the user request contains a system prompt, this setting will be appended to the user's system prompt", + "键为请求中的模型名称,值为要替换的模型名称": "Key is the model name in the request, value is the model name to replace", + "仅影响本地判断,不修改返回到上游的状态码,比如将claude渠道的400错误复写为500(用于重试),请勿滥用该功能,例如:": "Only affects local judgment, does not modify the status code returned to the upstream, for example, rewrite the 400 error of the claude channel to 500 (for retry). Please do not abuse this function, for example:", + "密钥更新模式": "Key update mode", + "请选择密钥更新模式": "Please select key update mode", + "追加到现有密钥": "Append to existing key", + "覆盖现有密钥": "Overwrite existing key", + "追加模式:将新密钥添加到现有密钥列表末尾": "Append mode: add new keys to the end of the existing key list", + "覆盖模式:将完全替换现有的所有密钥": "Overwrite mode: completely replace all existing keys", + "轮询模式必须搭配Redis和内存缓存功能使用,否则性能将大幅降低,并且无法实现轮询功能": "Polling mode must be used with Redis and memory cache functions, otherwise the performance will be significantly reduced and the polling function will not be implemented", "common": { "changeLanguage": "Change Language" } From 66d0764fc1e7a188d66b0c489568dc85deb35f7f Mon Sep 17 00:00:00 2001 From: CaIon Date: Thu, 2 Oct 2025 19:55:37 +0800 Subject: [PATCH 104/243] =?UTF-8?q?=E2=9C=A8=20docs:=20update=20README=20f?= =?UTF-8?q?iles=20to=20include=20Japanese=20language=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.en.md | 21 +++-- README.fr.md | 21 +++-- README.ja.md | 224 +++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 20 ++--- 4 files changed, 266 insertions(+), 20 deletions(-) create mode 100644 README.ja.md diff --git a/README.en.md b/README.en.md index 2349104a..60d4f6a0 100644 --- a/README.en.md +++ b/README.en.md @@ -1,6 +1,10 @@

- 中文 | English | Français + 中文 | English | Français | 日本語

+ +> [!NOTE] +> **MT (Machine Translation)**: This document is machine translated. For the most accurate information, please refer to the [Chinese version](./README.md). +
![new-api](/web/public/logo.png) @@ -75,7 +79,7 @@ New API offers a wide range of features, please refer to [Features Introduction] 1. 🎨 Brand new UI interface 2. 🌍 Multi-language support -3. 💰 Online recharge functionality (YiPay) +3. 💰 Online recharge functionality, currently supports EPay and Stripe 4. 🔍 Support for querying usage quotas with keys (works with [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)) 5. 🔄 Compatible with the original One API database 6. 💵 Support for pay-per-use model pricing @@ -96,7 +100,11 @@ New API offers a wide range of features, please refer to [Features Introduction] - Add `-thinking` suffix to enable thinking mode (e.g.: `claude-3-7-sonnet-20250219-thinking`) 16. 🔄 Thinking-to-content functionality 17. 🔄 Model rate limiting for users -18. 💰 Cache billing support, which allows billing at a set ratio when cache is hit: +18. 🔄 Request format conversion functionality, supporting the following three format conversions: + 1. OpenAI Chat Completions => Claude Messages + 2. Claude Messages => OpenAI Chat Completions (can be used for Claude Code to call third-party models) + 3. OpenAI Chat Completions => Gemini Chat +19. 💰 Cache billing support, which allows billing at a set ratio when cache is hit: 1. Set the `Prompt Cache Ratio` option in `System Settings-Operation Settings` 2. Set `Prompt Cache Ratio` in the channel, range 0-1, e.g., setting to 0.5 means billing at 50% when cache is hit 3. Supported channels: @@ -115,7 +123,9 @@ This version supports multiple models, please refer to [API Documentation-Relay 4. Custom channels, supporting full call address input 5. Rerank models ([Cohere](https://cohere.ai/) and [Jina](https://jina.ai/)), [API Documentation](https://docs.newapi.pro/api/jinaai-rerank) 6. Claude Messages format, [API Documentation](https://docs.newapi.pro/api/anthropic-chat) -7. Dify, currently only supports chatflow +7. Google Gemini format, [API Documentation](https://docs.newapi.pro/api/google-gemini-chat/) +8. Dify, currently only supports chatflow +9. For more interfaces, please refer to [API Documentation](https://docs.newapi.pro/api) ## Environment Variable Configuration @@ -192,7 +202,8 @@ For detailed API documentation, please refer to [API Documentation](https://docs - [Image API](https://docs.newapi.pro/api/openai-image) - [Rerank API](https://docs.newapi.pro/api/jinaai-rerank) - [Realtime API](https://docs.newapi.pro/api/openai-realtime) -- [Claude Chat API (messages)](https://docs.newapi.pro/api/anthropic-chat) +- [Claude Chat API](https://docs.newapi.pro/api/anthropic-chat) +- [Google Gemini Chat API](https://docs.newapi.pro/api/google-gemini-chat) ## Related Projects - [One API](https://github.com/songquanpeng/one-api): Original project diff --git a/README.fr.md b/README.fr.md index de788ede..d0698005 100644 --- a/README.fr.md +++ b/README.fr.md @@ -1,6 +1,10 @@

- 中文 | English | Français + 中文 | English | Français | 日本語

+ +> [!NOTE] +> **MT (Traduction Automatique)**: Ce document est traduit automatiquement. Pour les informations les plus précises, veuillez vous référer à la [version chinoise](./README.md). +
![new-api](/web/public/logo.png) @@ -75,7 +79,7 @@ New API offre un large éventail de fonctionnalités, veuillez vous référer à 1. 🎨 Nouvelle interface utilisateur 2. 🌍 Prise en charge multilingue -3. 💰 Fonctionnalité de recharge en ligne (YiPay) +3. 💰 Fonctionnalité de recharge en ligne, prend actuellement en charge EPay et Stripe 4. 🔍 Prise en charge de la recherche de quotas d'utilisation avec des clés (fonctionne avec [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)) 5. 🔄 Compatible avec la base de données originale de One API 6. 💵 Prise en charge de la tarification des modèles de paiement à l'utilisation @@ -96,7 +100,11 @@ New API offre un large éventail de fonctionnalités, veuillez vous référer à - Ajouter le suffixe `-thinking` pour activer le mode de pensée (par exemple : `claude-3-7-sonnet-20250219-thinking`) 16. 🔄 Fonctionnalité de la pensée au contenu 17. 🔄 Limitation du débit du modèle pour les utilisateurs -18. 💰 Prise en charge de la facturation du cache, qui permet de facturer à un ratio défini lorsque le cache est atteint : +18. 🔄 Fonctionnalité de conversion de format de requête, prenant en charge les trois conversions de format suivantes : + 1. OpenAI Chat Completions => Claude Messages + 2. Claude Messages => OpenAI Chat Completions (peut être utilisé pour Claude Code pour appeler des modèles tiers) + 3. OpenAI Chat Completions => Gemini Chat +19. 💰 Prise en charge de la facturation du cache, qui permet de facturer à un ratio défini lorsque le cache est atteint : 1. Définir l'option `Ratio de cache d'invite` dans `Paramètres système->Paramètres de fonctionnement` 2. Définir le `Ratio de cache d'invite` dans le canal, plage de 0 à 1, par exemple, le définir sur 0,5 signifie facturer à 50 % lorsque le cache est atteint 3. Canaux pris en charge : @@ -115,7 +123,9 @@ Cette version prend en charge plusieurs modèles, veuillez vous référer à [Do 4. Canaux personnalisés, prenant en charge la saisie complète de l'adresse d'appel 5. Modèles Rerank ([Cohere](https://cohere.ai/) et [Jina](https://jina.ai/)), [Documentation de l'API](https://docs.newapi.pro/api/jinaai-rerank) 6. Format de messages Claude, [Documentation de l'API](https://docs.newapi.pro/api/anthropic-chat) -7. Dify, ne prend actuellement en charge que chatflow +7. Format Google Gemini, [Documentation de l'API](https://docs.newapi.pro/api/google-gemini-chat/) +8. Dify, ne prend actuellement en charge que chatflow +9. Pour plus d'interfaces, veuillez vous référer à la [Documentation de l'API](https://docs.newapi.pro/api) ## Configuration des variables d'environnement @@ -192,7 +202,8 @@ Pour une documentation détaillée de l'API, veuillez vous référer à [Documen - [API d'image](https://docs.newapi.pro/api/openai-image) - [API de rerank](https://docs.newapi.pro/api/jinaai-rerank) - [API en temps réel](https://docs.newapi.pro/api/openai-realtime) -- [API de discussion Claude (messages)](https://docs.newapi.pro/api/anthropic-chat) +- [API de discussion Claude](https://docs.newapi.pro/api/anthropic-chat) +- [API de discussion Google Gemini](https://docs.newapi.pro/api/google-gemini-chat) ## Projets connexes - [One API](https://github.com/songquanpeng/one-api) : Projet original diff --git a/README.ja.md b/README.ja.md new file mode 100644 index 00000000..13049e86 --- /dev/null +++ b/README.ja.md @@ -0,0 +1,224 @@ +

+ 中文 | English | Français | 日本語 +

+ +> [!NOTE] +> **MT(機械翻訳)**: この文書は機械翻訳されています。最も正確な情報については、[中国語版](./README.md)を参照してください。 + +
+ +![new-api](/web/public/logo.png) + +# New API + +🍥次世代大規模モデルゲートウェイとAI資産管理システム + +Calcium-Ion%2Fnew-api | Trendshift + +

+ + license + + + release + + + docker + + + docker + + + GoReportCard + +

+
+ +## 📝 プロジェクト説明 + +> [!NOTE] +> 本プロジェクトは、[One API](https://github.com/songquanpeng/one-api)をベースに二次開発されたオープンソースプロジェクトです + +> [!IMPORTANT] +> - 本プロジェクトは個人学習用のみであり、安定性の保証や技術サポートは提供しません。 +> - ユーザーは、OpenAIの[利用規約](https://openai.com/policies/terms-of-use)および**法律法規**を遵守する必要があり、違法な目的で使用してはいけません。 +> - [《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)の要求に従い、中国地域の公衆に未登録の生成式AI サービスを提供しないでください。 + +

🤝 信頼できるパートナー

+

 

+

順不同

+

+ Cherry Studio + 北京大学 + UCloud 優刻得 + Alibaba Cloud + IO.NET +

+

 

+ +## 📚 ドキュメント + +詳細なドキュメントは公式Wikiをご覧ください:[https://docs.newapi.pro/](https://docs.newapi.pro/) + +AIが生成したDeepWikiにもアクセスできます: +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api) + +## ✨ 主な機能 + +New APIは豊富な機能を提供しています。詳細な機能については[機能説明](https://docs.newapi.pro/wiki/features-introduction)を参照してください: + +1. 🎨 全く新しいUIインターフェース +2. 🌍 多言語サポート +3. 💰 オンラインチャージ機能をサポート、現在EPayとStripeをサポート +4. 🔍 キーによる使用量クォータの照会をサポート([neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)と連携) +5. 🔄 オリジナルのOne APIデータベースと互換性あり +6. 💵 モデルの従量課金をサポート +7. ⚖️ チャネルの重み付けランダムをサポート +8. 📈 データダッシュボード(コンソール) +9. 🔒 トークングループ化、モデル制限 +10. 🤖 より多くの認証ログイン方法をサポート(LinuxDO、Telegram、OIDC) +11. 🔄 Rerankモデルをサポート(CohereとJina)、[API ドキュメント](https://docs.newapi.pro/api/jinaai-rerank) +12. ⚡ OpenAI Realtime APIをサポート(Azureチャネルを含む)、[APIドキュメント](https://docs.newapi.pro/api/openai-realtime) +13. ⚡ Claude Messages形式をサポート、[APIドキュメント](https://docs.newapi.pro/api/anthropic-chat) +14. /chat2linkルートを使用してチャット画面に入ることをサポート +15. 🧠 モデル名のサフィックスを通じてreasoning effortを設定することをサポート: + 1. OpenAI oシリーズモデル + - `-high`サフィックスを追加してhigh reasoning effortに設定(例:`o3-mini-high`) + - `-medium`サフィックスを追加してmedium reasoning effortに設定(例:`o3-mini-medium`) + - `-low`サフィックスを追加してlow reasoning effortに設定(例:`o3-mini-low`) + 2. Claude思考モデル + - `-thinking`サフィックスを追加して思考モードを有効にする(例:`claude-3-7-sonnet-20250219-thinking`) +16. 🔄 思考からコンテンツへの機能 +17. 🔄 ユーザーに対するモデルレート制限機能 +18. 🔄 リクエストフォーマット変換機能、以下の3つのフォーマット変換をサポート: + 1. OpenAI Chat Completions => Claude Messages + 2. Claude Messages => OpenAI Chat Completions(Claude Codeがサードパーティモデルを呼び出す際に使用可能) + 3. OpenAI Chat Completions => Gemini Chat +19. 💰 キャッシュ課金サポート、有効にするとキャッシュがヒットした際に設定された比率で課金できます: + 1. `システム設定-運営設定`で`プロンプトキャッシュ倍率`オプションを設定 + 2. チャネルで`プロンプトキャッシュ倍率`を設定、範囲は0-1、例えば0.5に設定するとキャッシュがヒットした際に50%で課金 + 3. サポートされているチャネル: + - [x] OpenAI + - [x] Azure + - [x] DeepSeek + - [x] Claude + +## モデルサポート + +このバージョンは複数のモデルをサポートしています。詳細は[APIドキュメント-中継インターフェース](https://docs.newapi.pro/api)を参照してください: + +1. サードパーティモデル **gpts**(gpt-4-gizmo-*) +2. サードパーティチャネル[Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy)インターフェース、[APIドキュメント](https://docs.newapi.pro/api/midjourney-proxy-image) +3. サードパーティチャネル[Suno API](https://github.com/Suno-API/Suno-API)インターフェース、[APIドキュメント](https://docs.newapi.pro/api/suno-music) +4. カスタムチャネル、完全な呼び出しアドレスの入力をサポート +5. Rerankモデル([Cohere](https://cohere.ai/)と[Jina](https://jina.ai/))、[APIドキュメント](https://docs.newapi.pro/api/jinaai-rerank) +6. Claude Messages形式、[APIドキュメント](https://docs.newapi.pro/api/anthropic-chat) +7. Google Gemini形式、[APIドキュメント](https://docs.newapi.pro/api/google-gemini-chat/) +8. Dify、現在はchatflowのみをサポート +9. その他のインターフェースについては[APIドキュメント](https://docs.newapi.pro/api)を参照してください + +## 環境変数設定 + +詳細な設定説明については[インストールガイド-環境変数設定](https://docs.newapi.pro/installation/environment-variables)を参照してください: + +- `GENERATE_DEFAULT_TOKEN`:新規登録ユーザーに初期トークンを生成するかどうか、デフォルトは`false` +- `STREAMING_TIMEOUT`:ストリーミング応答のタイムアウト時間、デフォルトは300秒 +- `DIFY_DEBUG`:Difyチャネルがワークフローとノード情報を出力するかどうか、デフォルトは`true` +- `GET_MEDIA_TOKEN`:画像トークンを統計するかどうか、デフォルトは`true` +- `GET_MEDIA_TOKEN_NOT_STREAM`:非ストリーミングの場合に画像トークンを統計するかどうか、デフォルトは`true` +- `UPDATE_TASK`:非同期タスク(Midjourney、Suno)を更新するかどうか、デフォルトは`true` +- `GEMINI_VISION_MAX_IMAGE_NUM`:Geminiモデルの最大画像数、デフォルトは`16` +- `MAX_FILE_DOWNLOAD_MB`: 最大ファイルダウンロードサイズ、単位MB、デフォルトは`20` +- `CRYPTO_SECRET`:暗号化キー、Redisデータベースの内容を暗号化するために使用 +- `AZURE_DEFAULT_API_VERSION`:Azureチャネルのデフォルトのバージョン、デフォルトは`2025-04-01-preview` +- `NOTIFICATION_LIMIT_DURATION_MINUTE`:メールなどの通知制限の継続時間、デフォルトは`10`分 +- `NOTIFY_LIMIT_COUNT`:指定された継続時間内のユーザー通知の最大数、デフォルトは`2` +- `ERROR_LOG_ENABLED=true`: エラーログを記録して表示するかどうか、デフォルトは`false` + +## デプロイ + +詳細なデプロイガイドについては[インストールガイド-デプロイ方法](https://docs.newapi.pro/installation)を参照してください: + +> [!TIP] +> 最新のDockerイメージ:`calciumion/new-api:latest` + +### マルチマシンデプロイの注意事項 +- 環境変数`SESSION_SECRET`を設定する必要があります。そうしないとマルチマシンデプロイ時にログイン状態が不一致になります +- Redisを共有する場合、`CRYPTO_SECRET`を設定する必要があります。そうしないとマルチマシンデプロイ時にRedisの内容を取得できません + +### デプロイ要件 +- ローカルデータベース(デフォルト):SQLite(Dockerデプロイの場合は`/data`ディレクトリをマウントする必要があります) +- リモートデータベース:MySQLバージョン >= 5.7.8、PgSQLバージョン >= 9.6 + +### デプロイ方法 + +#### 宝塔パネルのDocker機能を使用してデプロイ +宝塔パネル(**9.2.0バージョン**以上)をインストールし、アプリケーションストアで**New-API**を見つけてインストールします。 +[画像付きチュートリアル](./docs/BT.md) + +#### Docker Composeを使用してデプロイ(推奨) +```shell +# プロジェクトをダウンロード +git clone https://github.com/Calcium-Ion/new-api.git +cd new-api +# 必要に応じてdocker-compose.ymlを編集 +# 起動 +docker-compose up -d +``` + +#### Dockerイメージを直接使用 +```shell +# SQLiteを使用 +docker run --name new-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest + +# MySQLを使用 +docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest +``` + +## チャネルリトライとキャッシュ +チャネルリトライ機能はすでに実装されており、`設定->運営設定->一般設定->失敗リトライ回数`でリトライ回数を設定できます。**キャッシュ機能を有効にすることを推奨します**。 + +### キャッシュ設定方法 +1. `REDIS_CONN_STRING`:Redisをキャッシュとして設定 +2. `MEMORY_CACHE_ENABLED`:メモリキャッシュを有効にする(Redisを設定した場合は手動設定不要) + +## APIドキュメント + +詳細なAPIドキュメントについては[APIドキュメント](https://docs.newapi.pro/api)を参照してください: + +- [チャットインターフェース(Chat)](https://docs.newapi.pro/api/openai-chat) +- [画像インターフェース(Image)](https://docs.newapi.pro/api/openai-image) +- [再ランク付けインターフェース(Rerank)](https://docs.newapi.pro/api/jinaai-rerank) +- [リアルタイム対話インターフェース(Realtime)](https://docs.newapi.pro/api/openai-realtime) +- [Claudeチャットインターフェース](https://docs.newapi.pro/api/anthropic-chat) +- [Google Geminiチャットインターフェース](https://docs.newapi.pro/api/google-gemini-chat) + +## 関連プロジェクト +- [One API](https://github.com/songquanpeng/one-api):オリジナルプロジェクト +- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy):Midjourneyインターフェースサポート +- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool):キーを使用して使用量クォータを照会 + +New APIベースのその他のプロジェクト: +- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon):New API高性能最適化版 + +## ヘルプサポート + +問題がある場合は、[ヘルプサポート](https://docs.newapi.pro/support)を参照してください: +- [コミュニティ交流](https://docs.newapi.pro/support/community-interaction) +- [問題のフィードバック](https://docs.newapi.pro/support/feedback-issues) +- [よくある質問](https://docs.newapi.pro/support/faq) + +## 🌟 Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=Calcium-Ion/new-api&type=Date)](https://star-history.com/#Calcium-Ion/new-api&Date) + diff --git a/README.md b/README.md index 2103fe8f..af2b64b4 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- 中文 | English | Français + 中文 | English | Français | 日本語

@@ -75,7 +75,7 @@ New API提供了丰富的功能,详细特性请参考[特性说明](https://do 1. 🎨 全新的UI界面 2. 🌍 多语言支持 -3. 💰 支持在线充值功能(易支付) +3. 💰 支持在线充值功能,当前支持易支付和Stripe 4. 🔍 支持用key查询使用额度(配合[neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)) 5. 🔄 兼容原版One API的数据库 6. 💵 支持模型按次数收费 @@ -119,7 +119,9 @@ New API提供了丰富的功能,详细特性请参考[特性说明](https://do 4. 自定义渠道,支持填入完整调用地址 5. Rerank模型([Cohere](https://cohere.ai/)和[Jina](https://jina.ai/)),[接口文档](https://docs.newapi.pro/api/jinaai-rerank) 6. Claude Messages 格式,[接口文档](https://docs.newapi.pro/api/anthropic-chat) -7. Dify,当前仅支持chatflow +7. Google Gemini格式,[接口文档](https://docs.newapi.pro/api/google-gemini-chat/) +8. Dify,当前仅支持chatflow +9. 更多接口请参考[接口文档](https://docs.newapi.pro/api) ## 环境变量配置 @@ -128,16 +130,14 @@ New API提供了丰富的功能,详细特性请参考[特性说明](https://do - `GENERATE_DEFAULT_TOKEN`:是否为新注册用户生成初始令牌,默认为 `false` - `STREAMING_TIMEOUT`:流式回复超时时间,默认300秒 - `DIFY_DEBUG`:Dify渠道是否输出工作流和节点信息,默认 `true` -- `FORCE_STREAM_OPTION`:是否覆盖客户端stream_options参数,默认 `true` - `GET_MEDIA_TOKEN`:是否统计图片token,默认 `true` - `GET_MEDIA_TOKEN_NOT_STREAM`:非流情况下是否统计图片token,默认 `true` - `UPDATE_TASK`:是否更新异步任务(Midjourney、Suno),默认 `true` -- `COHERE_SAFETY_SETTING`:Cohere模型安全设置,可选值为 `NONE`, `CONTEXTUAL`, `STRICT`,默认 `NONE` - `GEMINI_VISION_MAX_IMAGE_NUM`:Gemini模型最大图片数量,默认 `16` - `MAX_FILE_DOWNLOAD_MB`: 最大文件下载大小,单位MB,默认 `20` -- `CRYPTO_SECRET`:加密密钥,用于加密数据库内容 +- `CRYPTO_SECRET`:加密密钥,用于加密Redis数据库内容 - `AZURE_DEFAULT_API_VERSION`:Azure渠道默认API版本,默认 `2025-04-01-preview` -- `NOTIFICATION_LIMIT_DURATION_MINUTE`:通知限制持续时间,默认 `10`分钟 +- `NOTIFICATION_LIMIT_DURATION_MINUTE`:邮件等通知限制持续时间,默认 `10`分钟 - `NOTIFY_LIMIT_COUNT`:用户通知在指定持续时间内的最大数量,默认 `2` - `ERROR_LOG_ENABLED=true`: 是否记录并显示错误日志,默认`false` @@ -182,7 +182,7 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234 ``` ## 渠道重试与缓存 -渠道重试功能已经实现,可以在`设置->运营设置->通用设置`设置重试次数,**建议开启缓存**功能。 +渠道重试功能已经实现,可以在`设置->运营设置->通用设置->失败重试次数`设置重试次数,**建议开启缓存**功能。 ### 缓存设置方法 1. `REDIS_CONN_STRING`:设置Redis作为缓存 @@ -196,12 +196,12 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234 - [图像接口(Image)](https://docs.newapi.pro/api/openai-image) - [重排序接口(Rerank)](https://docs.newapi.pro/api/jinaai-rerank) - [实时对话接口(Realtime)](https://docs.newapi.pro/api/openai-realtime) -- [Claude聊天接口(messages)](https://docs.newapi.pro/api/anthropic-chat) +- [Claude聊天接口](https://docs.newapi.pro/api/anthropic-chat) +- [Google Gemini聊天接口](https://docs.newapi.pro/api/google-gemini-chat) ## 相关项目 - [One API](https://github.com/songquanpeng/one-api):原版项目 - [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy):Midjourney接口支持 -- [chatnio](https://github.com/Deeptrain-Community/chatnio):下一代AI一站式B/C端解决方案 - [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool):用key查询使用额度 其他基于New API的项目: From 69e1542fc9e8b3f32e5a6db763640b5e9c57356f Mon Sep 17 00:00:00 2001 From: RedwindA Date: Fri, 3 Oct 2025 02:27:02 +0800 Subject: [PATCH 105/243] feat: Allow FIM chat requests without messages --- relay/channel/siliconflow/adaptor.go | 10 ++++++++++ relay/helper/valid_request.go | 4 +++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/relay/channel/siliconflow/adaptor.go b/relay/channel/siliconflow/adaptor.go index 4c176c08..41ab7632 100644 --- a/relay/channel/siliconflow/adaptor.go +++ b/relay/channel/siliconflow/adaptor.go @@ -61,6 +61,16 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel } func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + // SiliconFlow requires messages array for FIM requests, even if client doesn't send it + if (request.Prefix != nil || request.Suffix != nil) && len(request.Messages) == 0 { + // Add an empty user message to satisfy SiliconFlow's requirement + request.Messages = []dto.Message{ + { + Role: "user", + Content: "", + }, + } + } return request, nil } diff --git a/relay/helper/valid_request.go b/relay/helper/valid_request.go index f4a290ec..c9d3ed0b 100644 --- a/relay/helper/valid_request.go +++ b/relay/helper/valid_request.go @@ -275,7 +275,9 @@ func GetAndValidateTextRequest(c *gin.Context, relayMode int) (*dto.GeneralOpenA return nil, errors.New("field prompt is required") } case relayconstant.RelayModeChatCompletions: - if len(textRequest.Messages) == 0 { + // For FIM (Fill-in-the-middle) requests with prefix/suffix, messages is optional + // It will be filled by provider-specific adaptors if needed (e.g., SiliconFlow)。Or it is allowed by model vendor(s) (e.g., DeepSeek) + if len(textRequest.Messages) == 0 && textRequest.Prefix == nil && textRequest.Suffix == nil { return nil, errors.New("field messages is required") } case relayconstant.RelayModeEmbeddings: From d5e01a3eab08ca6c5f3071530607fb99777a82d2 Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 3 Oct 2025 12:26:17 +0800 Subject: [PATCH 106/243] =?UTF-8?q?=E2=9C=A8=20feat(gemini):=20add=20image?= =?UTF-8?q?Config=20field=20to=20GeminiChatRequest=20for=20flexible=20imag?= =?UTF-8?q?e=20configuration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dto/gemini.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dto/gemini.go b/dto/gemini.go index 80552aad..fdeb2793 100644 --- a/dto/gemini.go +++ b/dto/gemini.go @@ -2,11 +2,12 @@ package dto import ( "encoding/json" - "github.com/gin-gonic/gin" "one-api/common" "one-api/logger" "one-api/types" "strings" + + "github.com/gin-gonic/gin" ) type GeminiChatRequest struct { @@ -273,6 +274,7 @@ type GeminiChatGenerationConfig struct { ResponseModalities []string `json:"responseModalities,omitempty"` ThinkingConfig *GeminiThinkingConfig `json:"thinkingConfig,omitempty"` SpeechConfig json.RawMessage `json:"speechConfig,omitempty"` // RawMessage to allow flexible speech config + ImageConfig json.RawMessage `json:"imageConfig,omitempty"` // RawMessage to allow flexible image config } type MediaResolution string From 937d93144230892556336e0dfd8e97764d0fd62e Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Fri, 3 Oct 2025 12:29:31 +0800 Subject: [PATCH 107/243] refactor: simplify unsupported test channel types --- constant/channel.go | 61 ++++++++++++++++++++++++++++++++++++++ controller/channel-test.go | 50 ++++++++----------------------- 2 files changed, 73 insertions(+), 38 deletions(-) diff --git a/constant/channel.go b/constant/channel.go index 7d8893c1..1b5c2724 100644 --- a/constant/channel.go +++ b/constant/channel.go @@ -113,3 +113,64 @@ var ChannelBaseURLs = []string{ "https://llm.submodel.ai", //53 "https://ark.cn-beijing.volces.com", //54 } + +var ChannelTypeNames = map[int]string{ + ChannelTypeUnknown: "Unknown", + ChannelTypeOpenAI: "OpenAI", + ChannelTypeMidjourney: "Midjourney", + ChannelTypeAzure: "Azure", + ChannelTypeOllama: "Ollama", + ChannelTypeMidjourneyPlus: "MidjourneyPlus", + ChannelTypeOpenAIMax: "OpenAIMax", + ChannelTypeOhMyGPT: "OhMyGPT", + ChannelTypeCustom: "Custom", + ChannelTypeAILS: "AILS", + ChannelTypeAIProxy: "AIProxy", + ChannelTypePaLM: "PaLM", + ChannelTypeAPI2GPT: "API2GPT", + ChannelTypeAIGC2D: "AIGC2D", + ChannelTypeAnthropic: "Anthropic", + ChannelTypeBaidu: "Baidu", + ChannelTypeZhipu: "Zhipu", + ChannelTypeAli: "Ali", + ChannelTypeXunfei: "Xunfei", + ChannelType360: "360", + ChannelTypeOpenRouter: "OpenRouter", + ChannelTypeAIProxyLibrary: "AIProxyLibrary", + ChannelTypeFastGPT: "FastGPT", + ChannelTypeTencent: "Tencent", + ChannelTypeGemini: "Gemini", + ChannelTypeMoonshot: "Moonshot", + ChannelTypeZhipu_v4: "ZhipuV4", + ChannelTypePerplexity: "Perplexity", + ChannelTypeLingYiWanWu: "LingYiWanWu", + ChannelTypeAws: "AWS", + ChannelTypeCohere: "Cohere", + ChannelTypeMiniMax: "MiniMax", + ChannelTypeSunoAPI: "SunoAPI", + ChannelTypeDify: "Dify", + ChannelTypeJina: "Jina", + ChannelCloudflare: "Cloudflare", + ChannelTypeSiliconFlow: "SiliconFlow", + ChannelTypeVertexAi: "VertexAI", + ChannelTypeMistral: "Mistral", + ChannelTypeDeepSeek: "DeepSeek", + ChannelTypeMokaAI: "MokaAI", + ChannelTypeVolcEngine: "VolcEngine", + ChannelTypeBaiduV2: "BaiduV2", + ChannelTypeXinference: "Xinference", + ChannelTypeXai: "xAI", + ChannelTypeCoze: "Coze", + ChannelTypeKling: "Kling", + ChannelTypeJimeng: "Jimeng", + ChannelTypeVidu: "Vidu", + ChannelTypeSubmodel: "Submodel", + ChannelTypeDoubaoVideo: "DoubaoVideo", +} + +func GetChannelTypeName(channelType int) string { + if name, ok := ChannelTypeNames[channelType]; ok { + return name + } + return "Unknown" +} diff --git a/controller/channel-test.go b/controller/channel-test.go index ff1e8cef..8b0e37da 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -28,6 +28,7 @@ import ( "time" "github.com/bytedance/gopkg/util/gopool" + "github.com/samber/lo" "github.com/gin-gonic/gin" ) @@ -40,46 +41,19 @@ type testResult struct { func testChannel(channel *model.Channel, testModel string, endpointType string) testResult { tik := time.Now() - if channel.Type == constant.ChannelTypeMidjourney { - return testResult{ - localErr: errors.New("midjourney channel test is not supported"), - newAPIError: nil, - } + var unsupportedTestChannelTypes = []int{ + constant.ChannelTypeMidjourney, + constant.ChannelTypeMidjourneyPlus, + constant.ChannelTypeSunoAPI, + constant.ChannelTypeKling, + constant.ChannelTypeJimeng, + constant.ChannelTypeDoubaoVideo, + constant.ChannelTypeVidu, } - if channel.Type == constant.ChannelTypeMidjourneyPlus { + if lo.Contains(unsupportedTestChannelTypes, channel.Type) { + channelTypeName := constant.GetChannelTypeName(channel.Type) return testResult{ - localErr: errors.New("midjourney plus channel test is not supported"), - newAPIError: nil, - } - } - if channel.Type == constant.ChannelTypeSunoAPI { - return testResult{ - localErr: errors.New("suno channel test is not supported"), - newAPIError: nil, - } - } - if channel.Type == constant.ChannelTypeKling { - return testResult{ - localErr: errors.New("kling channel test is not supported"), - newAPIError: nil, - } - } - if channel.Type == constant.ChannelTypeJimeng { - return testResult{ - localErr: errors.New("jimeng channel test is not supported"), - newAPIError: nil, - } - } - if channel.Type == constant.ChannelTypeDoubaoVideo { - return testResult{ - localErr: errors.New("doubao video channel test is not supported"), - newAPIError: nil, - } - } - if channel.Type == constant.ChannelTypeVidu { - return testResult{ - localErr: errors.New("vidu channel test is not supported"), - newAPIError: nil, + localErr: fmt.Errorf("%s channel test is not supported", channelTypeName), } } w := httptest.NewRecorder() From 6b75bc001637b1faae47843374076a8d24c6a720 Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 3 Oct 2025 12:44:33 +0800 Subject: [PATCH 108/243] refactor(openai_image): replace json.Marshal with common.Marshal for improved serialization #1961 --- dto/openai_image.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/dto/openai_image.go b/dto/openai_image.go index 5aece25f..16e9a175 100644 --- a/dto/openai_image.go +++ b/dto/openai_image.go @@ -74,14 +74,15 @@ func (r ImageRequest) MarshalJSON() ([]byte, error) { return nil, err } + // 不能合并ExtraFields!!!!!!!! // 合并 ExtraFields - for k, v := range r.Extra { - if _, exists := baseMap[k]; !exists { - baseMap[k] = v - } - } + //for k, v := range r.Extra { + // if _, exists := baseMap[k]; !exists { + // baseMap[k] = v + // } + //} - return json.Marshal(baseMap) + return common.Marshal(baseMap) } func GetJSONFieldNames(t reflect.Type) map[string]struct{} { From b39885be1e8c8175bf12c3222c43e77b63643c4a Mon Sep 17 00:00:00 2001 From: bubblepipe42 Date: Fri, 3 Oct 2025 13:23:56 +0800 Subject: [PATCH 109/243] action --- .github/workflows/electron-build.yml | 103 +++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 .github/workflows/electron-build.yml diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml new file mode 100644 index 00000000..c7b56293 --- /dev/null +++ b/.github/workflows/electron-build.yml @@ -0,0 +1,103 @@ +name: Build Electron App + +on: + push: + tags: + - 'v*.*.*' # Triggers on version tags like v1.0.0 + workflow_dispatch: # Allows manual triggering + +jobs: + build: + strategy: + matrix: + os: [macos-latest, windows-latest] + + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Build frontend + run: | + cd web + npm install --legacy-peer-deps + npm run build + env: + DISABLE_ESLINT_PLUGIN: 'true' + + - name: Build Go binary (darwin/Linux) + if: runner.os != 'Windows' + run: | + go build -ldflags="-s -w" -o new-api + + - name: Build Go binary (Windows) + if: runner.os == 'Windows' + run: | + go build -ldflags="-s -w" -o new-api.exe + + - name: Install Electron dependencies + run: | + cd electron + npm install + + - name: Build Electron app (macOS) + if: runner.os == 'macOS' + run: | + cd electron + npm run build:mac + env: + CSC_IDENTITY_AUTO_DISCOVERY: false # Skip code signing + + - name: Build Electron app (Windows) + if: runner.os == 'Windows' + run: | + cd electron + npm run build:win + + - name: Upload artifacts (macOS) + if: runner.os == 'macOS' + uses: actions/upload-artifact@v4 + with: + name: darwin-build + path: | + electron/dist/*.dmg + electron/dist/*.zip + + - name: Upload artifacts (Windows) + if: runner.os == 'Windows' + uses: actions/upload-artifact@v4 + with: + name: windows-build + path: | + electron/dist/*.exe + + release: + needs: build + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + files: | + darwin-build/* + windows-build/* + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file From 93e30703d4a19a54a38373a47665b8573058bd4c Mon Sep 17 00:00:00 2001 From: bubblepipe42 Date: Fri, 3 Oct 2025 13:55:19 +0800 Subject: [PATCH 110/243] action --- .github/workflows/electron-build.yml | 104 ++++++++++++ .gitignore | 6 +- electron/README.md | 73 ++++++++ electron/build.sh | 41 +++++ electron/create-tray-icon.js | 60 +++++++ electron/entitlements.mac.plist | 18 ++ electron/icon.png | Bin 0 -> 31262 bytes electron/main.js | 239 +++++++++++++++++++++++++++ electron/package.json | 100 +++++++++++ electron/preload.js | 6 + electron/tray-icon-windows.png | Bin 0 -> 1203 bytes electron/tray-iconTemplate.png | Bin 0 -> 459 bytes electron/tray-iconTemplate@2x.png | Bin 0 -> 754 bytes web/package.json | 1 + 14 files changed, 647 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/electron-build.yml create mode 100644 electron/README.md create mode 100755 electron/build.sh create mode 100644 electron/create-tray-icon.js create mode 100644 electron/entitlements.mac.plist create mode 100644 electron/icon.png create mode 100644 electron/main.js create mode 100644 electron/package.json create mode 100644 electron/preload.js create mode 100644 electron/tray-icon-windows.png create mode 100644 electron/tray-iconTemplate.png create mode 100644 electron/tray-iconTemplate@2x.png diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml new file mode 100644 index 00000000..b274db85 --- /dev/null +++ b/.github/workflows/electron-build.yml @@ -0,0 +1,104 @@ +name: Build Electron App + +on: + push: + tags: + - 'v*.*.*' # Triggers on version tags like v1.0.0 + workflow_dispatch: # Allows manual triggering + +jobs: + build: + strategy: + matrix: + os: [macos-latest, windows-latest] + + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Build frontend + run: | + cd web + npm install --legacy-peer-deps + npm run build + env: + DISABLE_ESLINT_PLUGIN: 'true' + NODE_OPTIONS: '--max_old_space_size=4096' + + - name: Build Go binary (macos/Linux) + if: runner.os != 'Windows' + run: | + go build -ldflags="-s -w" -o new-api + + - name: Build Go binary (Windows) + if: runner.os == 'Windows' + run: | + go build -ldflags="-s -w" -o new-api.exe + + - name: Install Electron dependencies + run: | + cd electron + npm install + + - name: Build Electron app (macOS) + if: runner.os == 'macOS' + run: | + cd electron + npm run build:mac + env: + CSC_IDENTITY_AUTO_DISCOVERY: false # Skip code signing + + - name: Build Electron app (Windows) + if: runner.os == 'Windows' + run: | + cd electron + npm run build:win + + - name: Upload artifacts (macOS) + if: runner.os == 'macOS' + uses: actions/upload-artifact@v4 + with: + name: macos-build + path: | + electron/dist/*.dmg + electron/dist/*.zip + + - name: Upload artifacts (Windows) + if: runner.os == 'Windows' + uses: actions/upload-artifact@v4 + with: + name: windows-build + path: | + electron/dist/*.exe + + release: + needs: build + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + files: | + macos-build/* + windows-build/* + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1382829f..570a4385 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,8 @@ web/dist one-api .DS_Store tiktoken_cache -.eslintcache \ No newline at end of file +.eslintcache + +electron/node_modules +electron/dist +electron/package-lock.json \ No newline at end of file diff --git a/electron/README.md b/electron/README.md new file mode 100644 index 00000000..88463b8a --- /dev/null +++ b/electron/README.md @@ -0,0 +1,73 @@ +# New API Electron Desktop App + +This directory contains the Electron wrapper for New API, providing a native desktop application with system tray support for Windows, macOS, and Linux. + +## Prerequisites + +### 1. Go Binary (Required) +The Electron app requires the compiled Go binary to function. You have two options: + +**Option A: Use existing binary (without Go installed)** +```bash +# If you have a pre-built binary (e.g., new-api-macos) +cp ../new-api-macos ../new-api +``` + +**Option B: Build from source (requires Go)** +TODO + +### 3. Electron Dependencies +```bash +cd electron +npm install +``` + +## Development + +Run the app in development mode: +```bash +npm start +``` + +This will: +- Start the Go backend on port 3000 +- Open an Electron window with DevTools enabled +- Create a system tray icon (menu bar on macOS) +- Store database in `../data/new-api.db` + +## Building for Production + +### Quick Build +```bash +# Ensure Go binary exists in parent directory +ls ../new-api # Should exist + +# Build for current platform +npm run build + +# Platform-specific builds +npm run build:mac # Creates .dmg and .zip +npm run build:win # Creates .exe installer +npm run build:linux # Creates .AppImage and .deb +``` + +### Build Output +- Built applications are in `electron/dist/` +- macOS: `.dmg` (installer) and `.zip` (portable) +- Windows: `.exe` (installer) and portable exe +- Linux: `.AppImage` and `.deb` + +## Configuration + +### Port +Default port is 3000. To change, edit `main.js`: +```javascript +const PORT = 3000; // Change to desired port +``` + +### Database Location +- **Development**: `../data/new-api.db` (project directory) +- **Production**: + - macOS: `~/Library/Application Support/New API/data/` + - Windows: `%APPDATA%/New API/data/` + - Linux: `~/.config/New API/data/` diff --git a/electron/build.sh b/electron/build.sh new file mode 100755 index 00000000..cef71432 --- /dev/null +++ b/electron/build.sh @@ -0,0 +1,41 @@ +#!/bin/bash + +set -e + +echo "Building New API Electron App..." + +echo "Step 1: Building frontend..." +cd ../web +DISABLE_ESLINT_PLUGIN='true' bun run build +cd ../electron + +echo "Step 2: Building Go backend..." +cd .. + +if [[ "$OSTYPE" == "darwin"* ]]; then + echo "Building for macOS..." + CGO_ENABLED=1 go build -ldflags="-s -w" -o new-api + cd electron + npm install + npm run build:mac +elif [[ "$OSTYPE" == "linux-gnu"* ]]; then + echo "Building for Linux..." + CGO_ENABLED=1 go build -ldflags="-s -w" -o new-api + cd electron + npm install + npm run build:linux +elif [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" || "$OSTYPE" == "win32" ]]; then + echo "Building for Windows..." + CGO_ENABLED=1 go build -ldflags="-s -w" -o new-api.exe + cd electron + npm install + npm run build:win +else + echo "Unknown OS, building for current platform..." + CGO_ENABLED=1 go build -ldflags="-s -w" -o new-api + cd electron + npm install + npm run build +fi + +echo "Build complete! Check electron/dist/ for output." \ No newline at end of file diff --git a/electron/create-tray-icon.js b/electron/create-tray-icon.js new file mode 100644 index 00000000..517393b2 --- /dev/null +++ b/electron/create-tray-icon.js @@ -0,0 +1,60 @@ +// Create a simple tray icon for macOS +// Run: node create-tray-icon.js + +const fs = require('fs'); +const { createCanvas } = require('canvas'); + +function createTrayIcon() { + // For macOS, we'll use a Template image (black and white) + // Size should be 22x22 for Retina displays (@2x would be 44x44) + const canvas = createCanvas(22, 22); + const ctx = canvas.getContext('2d'); + + // Clear canvas + ctx.clearRect(0, 0, 22, 22); + + // Draw a simple "API" icon + ctx.fillStyle = '#000000'; + ctx.font = 'bold 10px system-ui'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('API', 11, 11); + + // Save as PNG + const buffer = canvas.toBuffer('image/png'); + fs.writeFileSync('tray-icon.png', buffer); + + // For Template images on macOS (will adapt to menu bar theme) + fs.writeFileSync('tray-iconTemplate.png', buffer); + fs.writeFileSync('tray-iconTemplate@2x.png', buffer); + + console.log('Tray icon created successfully!'); +} + +// Check if canvas is installed +try { + createTrayIcon(); +} catch (err) { + console.log('Canvas module not installed.'); + console.log('For now, creating a placeholder. Install canvas with: npm install canvas'); + + // Create a minimal 1x1 transparent PNG as placeholder + const minimalPNG = Buffer.from([ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x01, 0x03, 0x00, 0x00, 0x00, 0x25, 0xDB, 0x56, + 0xCA, 0x00, 0x00, 0x00, 0x03, 0x50, 0x4C, 0x54, + 0x45, 0x00, 0x00, 0x00, 0xA7, 0x7A, 0x3D, 0xDA, + 0x00, 0x00, 0x00, 0x01, 0x74, 0x52, 0x4E, 0x53, + 0x00, 0x40, 0xE6, 0xD8, 0x66, 0x00, 0x00, 0x00, + 0x0A, 0x49, 0x44, 0x41, 0x54, 0x08, 0x1D, 0x62, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x01, 0x0A, 0x2D, 0xCB, 0x59, 0x00, 0x00, + 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, + 0x60, 0x82 + ]); + + fs.writeFileSync('tray-icon.png', minimalPNG); + console.log('Created placeholder tray icon.'); +} \ No newline at end of file diff --git a/electron/entitlements.mac.plist b/electron/entitlements.mac.plist new file mode 100644 index 00000000..a00aebcd --- /dev/null +++ b/electron/entitlements.mac.plist @@ -0,0 +1,18 @@ + + + + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.allow-jit + + com.apple.security.cs.disable-library-validation + + com.apple.security.cs.allow-dyld-environment-variables + + com.apple.security.network.client + + com.apple.security.network.server + + + \ No newline at end of file diff --git a/electron/icon.png b/electron/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c63ac2d77598af361f898c561ed40938719c8391 GIT binary patch literal 31262 zcmeFYRYP1svo*YD7(BQIcSvx8yF+jfE(spo9R>{&g1ZKX;2PX51Pd;~-QDG#Jm)#z zPx!9R)v)(4-PK)PRjXE2hpQ;bprH_<004j{`(9EF0Kl+6!2l8h?Bmj7>K^t1a#oWO z2P#HM_5gqakd+kE@Gv~gM9jd|{L}fvt9!dQ4lOJf%z)4iWv}u^Y)eFfb?AbdJQL@W9y|uC@hd`Cu>WIIH66seQ%Qed@<)C6B?C zp?in)st8>tBn7}R1}k(rLdG4joI4eu$ZVkhymf%*i_+-hgO^Wgt}{}Z&z z_y7Gi0P+*$VEl*A^nZt20!~R_gZ}5|tD@i~fR}BLpvL~u~!i~17vq%Qvdr3wp{1`Tkd~%;QzGye~0;> zJ^r8V{{QQ$1fYQGhJDMk2m!PBYhE_pRR)SUxMGJen8ypog88o^IGieKknZ+S#cp#p z!Kx^*E4#ao@ZJE^(jHuZ3B|+&>2iZdK1q?s-23-oDEMJUL}&?08M=xf&I2@oC~o~1o;B`ia_(i4wi@&>!u)LIJaA#Y z)QukIOaF2VJ};(shN2O|!|Uu|Gwe{0ivSd;(03pJ|o8-}T7`Mr-yS$km>Au=fcQo55nF z8%(AugI{wW)sZnj8oKVm%ZZYLQOJ<57Vo#8u$T}N>2P-%?xqCk=l;byw|@bMw6grJk8^!K6ua|LGf&lV$6F2cWVVo*Tm@qaeoNj!`kU*&p zrvX69(Eyzx6F%%twu)#4qUyMNnswMs2;GeofztjbM8?9xawkU+>~IXSF3-U)S@o!> zBEX6ApmrextFQcX_P?DkjV>~860~$fQlN^k|2CfNtzP8ErZ~sdz{^H~=7CmU1+5Iv zuJZbR4}4d_$>`q{+Es{736v2%ipxOi87U46-&82&BP>vUq6C>z8zW$PiYrl-N~*vY zf1rLVpa~_l>pfy9Nr&59DVt#+cEU$nF#jxSlYyL%30sZYn^ckrl7MSGJkm!MffP)7 zVD;E3FP^FmN2589w`Fz0*ij*(4-%Dt=36~3zrR*&Os*MXg7qB(*mZw^d`|#AP);;T z{%Xt_AKPjFkG~C9-Lk`2W>qNo7ZONu16^~*(~B9%F=w6Aiw`< zEA?{T?H#i5$LVWfhPP6HhweMH;W_c{@|@1O%+$NF;ji)yOjkzh74yzJ>`0fafZ$L< zE;+R&curb!%mb4^3(hZH;N_8Y$h`MD1v>q2yRTkV`-fvusGN`m^8uJI!}U z8cv}~j?P#BhNk9N%nqwCK~%JjHal1>jpv+z3m#G47nMUWc^_D_6snbMO7MHO2R_dw z!RYxBMAkhE%869vj{BY*)-Ea4_TyV=$6%&E-^nW)IR3>d8knM(Qt{p7I6zcPZ`*S8j8=QWz4G9R&>-C8-Z0Q$_P8LV2m{Dg~n_}haTk?U63NJkIsM9Uk9lp@1AAy z+4fsrb0Nb?C||)nK>*Tt<558zNCAk}L7mhKAOE*dWv`$N%#_}f!6b|uNMk0Hq-gPR z_KuFbrUFRtCWDvzge(=LcUb1-IwA&=w)Gx*gfx@es#?|qSK@DxO@hqBGIYTn6&3sr zuE)7zeUEejBmzkd1hCN#6IrjhGXPaxG0n5h(Am4qi0U`86JYYSklHX2mO!*cdZ8Ku zTs)oowH)Eh|`_{fg&VTOSTvr_gRq z#VfPJ@onc5{cvv~`;h`psH|09cE3VL885}%P9H&i`|Wf?9Zu)7BnfAiW=i&9alz>$ zG?B3V8loC;bDuvvU&XcmU_-rnR(X`S%n-F^Ta*C>nb?;hQSL?3{=L}@iN@~57? z#+9MtJ)#^98d1=`RrQAgA?LNvdI+6L90B++FSYviBL;Xl$#dayZpVs(7X#3zEE-`t zU2pi|!*I#?_RZ}wdd?37R+6GO6>r^6r%A|z8o|5n=Mpbpi&$4lLC$fVA6%(m2BAcu z6Xgg&k&|bYmhydNlKGa^Fs6?rae~0$8e@9rq@~TEMe&pLNaE+8Cmr~}zQ|~gzbZ0? zs3^R)p&BV~EslRqm_@{Y{SIH(QES`^s$fNiRyfQic@ML5uz*)SU6Udqm;+d?HVQz6l7f%*zec{)Rk=X{p}4 zp$nfN0HV|~*N-oofENx`hg_Bwm6~t%k7oYhytC`hiIC4aUcL6<0Vpb;>(5r?L>odmHCD7v6AN)_>x6Z&f*1u;ysi=mq%; z`7w^B1$@(f7c06{$hJ-wfxv?(F#M=3z+|ypz<5Z4_;CE{U#der)(}=xRd(wHNr3?H z-59sw+~9W*N!JD&7LA|2@*khupq6{?kJR6b3GW{l0gswjbV*TiIS4Ui505w5c0{Ki zW3&0#K|lUwD5a^6aYmAIE$49-;j3VqyW=KH_M|o1QgscMTXAp+P6IB!lHHSC;8&CF zK|4WfY4eX_i=V)!X&Yjnb!nkoekl5>wZ`L!C4`hRX72wyhfomrjY!AOTTGtr;knb0 zreON~-$@;o0nyF~Bp}1;0=AX&lUtc|hD3>Mktz7wsF9*LuD|rBKk`TQjat^5;qdX! z213hHX$QO#Pp@XGH+fHckuX0#{8=Xkopz`d-G$>H<+$h8gp-`hvH3VJ1~!FvvAMe( zRqc$?;u*c0jZA2K{!v;g!I4dJtMg0d7ib|Arhx`o&3)bl&y9Wse{<>6fLhcI=|oYi zX#OTHd>@-6neSfA!mS43yRjGO zQwLUpzy#(@uPrm$Mv<+S)k^!us1*;@fr9pePL)VEiH|G84kfmH4Sfkx4S%@7J8; z#Tv)J-2M{M;;EU83G7Hf>BGK577piP55&yKz!}8vXjDw8`Ecuh%8XknIAeLu+K(C? zoy&j=XByY6zqC78o$~5Q(1RJHrHrG9pc@?7&8y1&y^uIr7*I0(*#c$sNo4rd}%H1f2#hpAVk#FSd&d1Q<5w*c(ASI1|;du9| zf=86=M*+0cm!)QW~A_g?9eQedPJ@sme< zmF?24#h2EfVSIYt0|6wA~P*afv>=_9W zP^6!!(r8hAn20o%9ftmY{n-H_0p$VF|e695Fuh1YvkMCb4lFY>}un28Mlk{r+Q)7UX*214|~sf>J2#~u@xugA+PvHlQ++R zR}`G(MUb=Z+MJ&Y&dQ6DB(Gw2I;4Ah)EP??!lY?c226jGTP|WK6-eT%u_3`A7+cBE zE33xxf=ckXj#zqbpY~KoaP~3=QJn8Fu+}2Nzj=iqsW~;K5&NV4@UClD4SyFWn#ser ztgs3{@%@v1e_+6i(%NgjIvSi+1d5?f^`WI>J^2+#j(S0)4ID%r#YY>gyFd>;GPF0& zt`7+@D@mZA(Eb(LuDuICM|b5at-2)}FTd9x6lUqxABZYLi$+f@X9aUb`7T~jsCJFa zv?mmuveiss_#Uc!sg|ZL+=FVJ%1=CpI;YiNGd1GNiOwH2;1k2fobq+c?t4w-dd}4D zA5lkV2mEK&P5=2@z6TukZvJhsH=G&FCt}eEwgtqMxL`KOTgi4zKH)XWQN3j zbk(|^(?7=rAA0c%2OLyb_T;Ds*oSiK(}Qpy-i#kq{zbi$U_wjy%;IlF4pcq2>>&T{ z==HNMB(&S&#_~;SUwzEOQagat4Bz@96A80<(S(HftuTi%McR1aOjq^QhqtHYl_@RH z$uZISh7ESn+{%&tTewa8!$v-@#G2muwa&Z^J@cIw>#lRiBc_9nP3~m7cz0$2|E zY;^Is5kpjd{^az1ZW=f}0-V)MwSK_D3750%xp!rUo1u6_jdh64mmXh9mK4eKrRnK5 zzxj@fYntzMnW_8VSq;Ax|E|y62<2sZSMrLT>3c#_{+PgvM>vRS-{GGQeZaja{?Q^4 z(V9zhhe&D@(~WOF1ZaA_Pb}6~Xjz+eP#e>oEf}%R_2XClhQM+;WA5b-fj(ut?(k!$ zuk=(2HyqCJ_uDmQc{Tx`!BPXv=opEYw&DIGAzX(SQQ#O895u@}(ap?2ufJ-NrSKlr z??{bp>U&8dO3Qu?ft%LSF`8PCX`6Fueoq6LTLT!D+cTh77VVa4tUH z#9W&$ClOX7(r)`@#mY**A{2>v=NBw_Sz40$+PX7SKCkJTr3{?K*=&)lJu^`I&XK; zpz^ot@^N*|3l@@#UM-#CRl@^1R4$*XgAou{PF9=S3bzlr#(J|K z1z_9=1&$tN`n@zvet9eqG1!@j2taN7Tl0mGj-Qg@f%O5t}MmW_(8zM zXnW$yD>hDv2GvnCsI6K-N8Ur(3LgTVnV0?u5vq?$rDqG`Rgz|Q<8?WuLV6lHQJW!PMeld4BU$?g0<<3(nE5O?$_bZ*NoePvN#Ad@66bD9zGICrMk+ z4>91dInx1dKmU0Z`U+VoaFgGR-db<6bsWzkOVfpe{PQa)Ey5jo}l$h1>@WUo$p_3izRF^CY~~A*-b5bvI3DU#jyM zzn%|7zK{sEP&MW}$MyM>+03H&+*YE6`{)}B^8n@3zy4gLixta4?lP^IcPm%aDloYc zf!h>6BAxgN0n+yl&I_%zY2?p+bEFb5@@ycVh~oBF_PQN`;H!L-d;^Wtp_o$?;qTj2 za(~iX{tz&>Y+Y?k`=8UhUsdr`h~}jBYb%xG{bc`|Uq4ftb3K;feNFI(+7o(&Jg9$R zOI|h!H|K9~_5*lxS?*Aa(u$6OUuxK>duuQoh+VX^Sa$p4ONTHCvU$3hkx|PxOMXB3 z#ax#G9_7mEWK!!|{(lG|i!$?Z(SPKd*s7Dz7|81;d z(&u|x(OX=A)+fqQ64@@PbW=tFGG_W);6m-f{FNSekZ8Av5=Vx4x)UT&@5N5*0Sy>e zx?Qu9Xx!bnt^C`d8~My)eZ%~y{tZ)}SS)Aze0Ee6-U!rrJC-i#42?5-6;zR8G9AVg zwoj7?-mm%!Z&7p`l#wf#K{)^`znm{U&Hh$YZ|5mA#K>j@!(>Uu0aB@W?p_8 z876F4k;Yh}hVBwy&#*HfEF*NjLTx6Pr~SfB3aRVSlK<(uv+PEz!;IXec6 z%!VxB*gE0?j^PAZ=&EvH$QLU~IX9lEYdbIuJW#f~Y+mA7H}6CPu9q+rh*`QN1G^cE zt0HL^?cGxN9uv|#WMznnUM?KIB6|DUEV{lR9h9@zoqpQ#vW<6pe<;}@RCP6Q?5S_e z#Q&uc!=P2KVfYHYpU;QkQyeo<{L$ZtITs4760O29fyo>mJ{x7`0x%2~75teOOv$p2 zq=$R6K^^+WD_;HK>2}i>5gO_SGe2TAfyBb zmlL-jYTLMUw{yWqgrL?C_!*|y7*98hc$v9g@nm@6D2r z-^mIzJik_sNT_Gy;^^)T=<6Sy`tW_V$~}sl=s!Cd6DyL%HvGsilY9?02o^!^KS6DD@StPe|gM5y@ka>#l5U0eVHq2*;p6YSd_bmU!QeDH@G3^RDpw?IZn~q`-5f;*o8_|^lH*i z$yfK_q za#cZtnP+#rwfV2}MFyX2S?2}H|HTKn(cBHMXEmQF@n6)Ae{q6m-7UyCTO>On7QCjB}<)hp|~B+sgQ4(-?33Jud`v~`zIm# zPQLcM|5XDChS#uoza(l|S=oCUtP0KCMiLr&(?0h|x-Bu)ScY}{VeNF$qpHReAXpF7 ziL`=a zbLWY&k6joY0&MPpa^w`H3eROZ^ijhdI)`?$njhmrnJF&E-ef*8)< zwShxF?kpuGlZ-u=FE1{I+6v1yJh1ukG==OvD+80wrfSQskjFb|=Uw?+MZleg)oD5h zGI7OUl0Nef9$2OKV?gNA)UBgt3k9{*&g}_pHuOCI?>sV5@Q(-5$TG z=9wYGn}hV<1B_Wwfv|((2t%5Qk#-OD_UP;GFJ@{cGV{$?CxX{ELc5G3XMgke?c;Q? z`te)?TDyysD*Z>pUTlW4dR_|b}`&$;GUD+=kpHFHXNNvEC)YOwecqp@^Pd{EzP z?%j^!%y z&u+u(vtZY;KtdGP5<+v{QSJj|lA4-LQVK!YFqz<$Cb7yLDL0wcP99?Nu7SQ`^icu5 zH|F?qgka5{mFF+=s>1LQPJ@XovA^&n3=|d=kg=GQvz^b^xSiAgMv21KN~|0iq;<#( z2u6)Z|8Flq7pdSy$Fp)>=dWK8Y;vERzpEJ!@_ecDRO@9&kq4SMH>!d%runfSl{t$-fkZBoPSGH9jhsk$?=*?_x`^AOaxjeG<3NRLo#qF zcAptqneWlNeuoV*_>7pRC>$W+&*TdMRbVwR>6%Jy1*6F?rD6Vz63t(}yZQ+4KA2)L zKSiS=wE0)afNYBqyq_Z^ZrDlJX_xALEh|HD$^vx@-TtVG#B{@_JKRIXWwexcp;hg%)5Rd9^ z=vR>4`4NQdB8qmMAm%GQB|<+dj7y#x-e<)3E{n&VCP~G^-~)8iVP@o4Y_{!S@$l#q z8j|?H>VBN)znr9~ty3oYi#RJ^>!s$&(uHf#P=s2j=v8rJ3&W}*2ts%k^sexAf1P#U z&@IVj>}kmqqxZ3Fmp#vE_6X9hnura4ZTVlJ!7nEVD;BA7?yBy*UYaxFUuGhhLWeChdyv@jeRE=-sA z{WXebimo`4imDu5=HiHOkzhTQ{E;P$r;+vZW?fF%TM%;Q0JeMG=R@J!*^7&#(C~d{ z=)Y*aK8qj2MuY52`?5tNg)U%(38kYa8hi8~;%qNOH#!S#BXrU=iL9sp`3_}c-FAM- zZ2`j^oQZfa^P`luQ$eZ>QRX4=+pxT}WFP9genbe49n` zB7kEl23Sk`Rgr;O!r~_iUPq+MQw{;06RFB19h@QJa(gN+C1`II2f)m*0J8VqN(q*1h2? zT9O%f4f0vq*MT3q{qqtb$ZgYBaxy^Jp0KV<3Xb9MeXo_UGn{eI*Bb-FXCdG!V!yN> z%zBUV{S_5qW^PpH%8CHf{U!M$kfT7R-M|%)hDEL@o{t-SmF^-fvfsCP?8V+KD2sfxfWG`?|EI}Y zED+~QMx9mt3TKb>&O>A!JpzeU9o9@XFAUybTUZu{wh#ZJryjqhqt5;EVAxMp|pPVr8{Oy|!Ixh#=osdiHU#Z4J^=XxWG6+3CZhdKdI}M`$VGLH`dVKO@N%pp?y-~>Q zl!DQls6>ICaC^!br^?JGo(ku%Ogu(#=1C$ar7U^A}r3@|8`&cC;;&D=Br=IzCip=%yDm}4JT~kboR}0* zC93%`SPUi^|l|avb3X^cUE!@9uOuz^0WeE=nC$XYd9DMr4j*GT_nb+-ZeH z%P9`6>VfM0o02KICa%A#Z1i=V8mnqrf4A-rG!X(A0we`R1z^Xe;uF0ZOOt+1gjGin=gfGQ-|mJ-U8g= zuKJMcR2@dqS$Gq@H3meE5b>3?MjX+<+4W-O{YCEJ$~v~S$RvheujTXTFUqU>jZS`m zM)cHd0cYUSOm5~MdpGT*Ci%;eJ~cHIWRyo%a3%e|Wp^%g@%HvZP#% z)1n-w9ZsONxt+2%J+k)VL^?(hj%*hz&qu3U7yntH`&%!|!{xR2z*Z&$9t|og)|+Am z9eByV2gHC^5pT=LTX8s23Js3+y!eSNFM$FHMW;!z_eF(c{M_}a3l7~dA5xutvBVGUWe|Gtwy6%cij_V=caaZU{#SqX)H}BIm9~ zc%hGj&oY-+wVBWr?}uBs zTF#6var@YL&jlBg*1LcxUd{M_%YPJlc^{cfG-lUM!bG$>n@w*$B?k+R<(scw&-xF| zeA`u8p=th;>?o#D#L|M83*WAZ^QW7CLh*zo`#~g?SBD!t0FC(F!lp$sRRDb}NHmbV zaf{&clV1zc_#7+h!RMz%ATK)$RzKS2A@!SfH=MzNGi<=9ow`YY8PL{kFvf5Nz0~95 zi^Hs~&T0=_n1e4BUVoeSr%_JSg2C&GI%Y;fc*)wd56>U3Dk|#As_NvEKP@7bm;f*X zZBrI|O#a3|nqRsVotCAX?)qC45me<;r8CjQW-YBpI?V{+KEeK>VQ z@sT9&w)w#j<#Uq1G>mT(;6rue*AcB<~X7{<_ATkR{{fj&p|#6>OKB<9#e zRyA2xW!a;e#eGn$FW{))YUGL7DNZQ7mRIu*G8o?Yj{DOU83Qn3=Nf^38=MyTUUjX& zJ~QU&YlxbuwHCk4ieD@E_~yX5?LczSpat~vZVcTs(}px3G^##onvI z)LZGY-NBsfw%e*wbg34#%t(X!J%m{S5Ck6${wXP5<^79}kIyMOt7ciMVG(TNCKAEP z?XK@mfL*R5w-vQ>M&R%-?*L5sYZvbetU69Uuqm;om*jqlh`?RxSN{y`uVhtiW(yI0 zJ87-GC;cqzhwc*bZ#hrBv;|{St5Oq7)usUifBBFQ3O??LqZLc62y@v7NWpfChu?mw zsR%W3B@p$gpWL?=A%{}r{pHjBUsUvJ*D3@)BcA(OS%9~T;_6z1aoz=Uys%D3#gZVom?@6zUXps8BW?)*If@G9Fe9H}Q>co9m>7!CS`-#XG7)5U8WPL%bF{4? zbW(YW>^+?`Kn9WAWjOhg47ws~qWRwCae%5Ar7qAR#Z;4oRXOw{&FwRy5 zuGnZn#oeu!4Qt(n{X^KnNviO>d2(J#G3ik0(=$fos ze?lIC+!HW67LiXDCFPQE$2kPdK3%oW++3o~H>pgAS7s&+D8I7K(z$}Wxog`*0g#UJ z>jZ(~tsv-oB*^8?*u&-te>9cYLkV~5aRh5SVsEHPJPu=MNxw7=8I9K@$HCyFl&Ma? zHZ1>sJ*@TqA^ari+LiIwPI<6YB{Naujs!eq+Lzphpbf5tX!~?BpQEWI;t+}&6%FuT zKgK&EWOVx(fb%^fs^4%w6jP)4$D*(1m|Oa5YiqD;Ui&T@iXZ90jtLV_DtS?LmgR(! zB=MvemjxhYb1sWvG^1a?IP&qp&(gzTR-VMh94Fvm5aIC-cUwP z!EEb#?Vn%)q`AT2>Yr^qHzB^88-$d~_#1A5x6rrqLV#%xi)-8K)_8noK{`ipZatl& zd>JJ}z~u6e+lv#{x;_KN6rEGWny0??NvCg{C3%^m(Od=g6rEOzM4tp7XEh!VZW@N$ zPg0YM{yKe*&JSR0Z4Znf1yYy^O8gkD_*#G(|7`xfRDr=? zG||4j9hh@J%~4aE*3m?kjE%~PxqCfQ=(d-dnhp{Tf4TJU$*CeSff_z<$2?xVwloE| zt7hNOCtHVOl3UMwq@1ls?n6(}_(^|s<_~L_;)(wAuY1fZRzY-Z?<4_n?6vZwuCc35 zm)@>s*Qu<|7gtaFEJN)iz;FOv$ieKc2c>v6bX(?j&wuR;tZaydWPKT9d{HBDdjPGb zQ~8@x8HBjd>uL7qKP+4|ISKRG=;OfIry3?RT-b`fQZatN`Pw+_3#@+Q?k$c!3TrO* zgkP}~Eo`!+{81XvMv$G^VK}9gv?#T@idV3RnPck#H@+_+x)vq&V-aq5vB^_@bYb#l zOLbh;fsHyDBk#9A;ZbQGXF5t&)TXAA3-JJSJF9g(a@US; zE9xEp#xP|>-D4ZJ$ymbrw)WYZG*rfXu70=9Zk&G%Fo&Q&e7AWGj9@W7j*KB7Y2{-+ zp6g^UJrUZwfL*m%^pGJ!Y4Yz0B#S63&PR89$l>4d9eC;IDWZ45x|DruFL8Mo?5)~f zSarXesA2zO;!V}CW$3@Z+u!1-&5BVSssVi4neXB;-L3xuhiU9< zM@BvtvJ*ikjFiE3-4=ni(=Jn<7MJ1Yno?P|Loi?`TJv6}+POb{D-RD^tXEcIxSC!i zX$=WG_CCv4@=wp8TTF$~6*aoRd4u2`t6OTiVQrW2xf7&%t?=O9>Xj-8NFaMcapavRo(e$q?QTQwVtT z9~k@I3%|Az=$Fi2;kGDZCJ$;=3%iKit#@&Luu+~N1zcQjpPno+z}(iDx@}->`!aS` zMSjfv_aWsBf68qeH-+mhF%d}4B%*Lkr;E3I|FT#X)$~OpCiV{y|K}dm$Iwh!N6f*5Pk7!Nhohet=3CBY|U5e8^uJk>uo;=^~$gT$-t zQ4eco$9*6VeDKCXm`qwzotIKOJN_|pp5>$$Bg$b8q6~ee8cdKn23xO_Ea^Rqwlm~i zHS6{CkKvY(QU7`>7vo4yN?73<)<3$voePg9>91?<`5dUu|3S{VW)&JF{Pyjjyt9FL zsTVpxR)#qx!Yf^#-5lMJNsb%c9%V#c*ZcIa9m22M6+P_}#)%u%WI5-G#D$HtNC~XG zDP~cFD^O-8s*^c=fNyw0Z9_*Y-Z+4UR&1BeY$5cK*_k~eOF?^YS z%#ZnO9r*U|m|XYR_TIuvJHhnoU$?=UTg4WL<_qUtHOs?;8|91w%ONfxs1pr90tM#w z|30u?t2SPk3`x$9R@x&Fr3d1_5GHk|Sb%!l5-|-}g9#qB+k=-GIxAzR+$j42P8nPb@~oo*l8I0$n;MK3$3XXkirPzbz8o*D!PC;^>kOLyO|jnrt*cOeGzhVo z<-HZ0#8B+{z)E_65|Y?Q0gFIGlX>l+Vt+KY_3X7Va4$e{kC|PZ@#+AH-x}}maUEVK z-E=C2a?yVXyK!IWQu|eBNSKEpHbVBH{nNN%@A_+tL~<*I$aO-)-VMCRl*ZR-t4Ga} z1BsGE&ag=n5EIGjrQ%tsPeqSS#xOG4`oc4RKn_EmJz zxt6!rs4LrKFuL0bjM%QBDg_X(!BZJhV`_ciq)X=_KpF2-M*HQd#3}e=F!NG%uRXup z4C?U?lZ5Q~x6EYxec<957I8SF;Yxx4db)`A{l)IE!7sm&l+7cFr3(doPXaPJ)v)ko zn=D-MKQ%Zr5pjY~(lvY5nTrlZ$k`Ix#lPn5)89bo+zdwdss4mYQO9$y2Y4+;K)5I-iXF1`brr!@a~XzVY@cg>nCNBYCb+0c)P}XMv7A zH+WkRUWZIhU;Xi6QC9n_$c<5EVx;kylUkp76);5he%V+2hr?tT6(9m%?(nhmjWr#u_xc6jyJG4@(dg zO&L-{qO{uS)w-12=ilx>ZZv!XtdaIOfjLAZapm^|Ehl8Eg;HI~o^m2_?LU?GWUkfW zp;7Ov)TGkXQ=hkUrGgOn)-SqTwfsZ~Fqs}^BDRq$^o}q*RdzD{*gD{I_z1%>HW4Ts zLy@gsU;5lRLwN_s>37Sg^l9eiDAtPMlTC$UF63E57fw|@sALB-+BZ~%3K#9lx|<2P|w_z8awMz|Zrc(Q24 zix4|gJh>knH0_5UuOi5U+Kes-G`5G;MQ$~TaU940^^iced)E6Q#D+PVRab)@gtaN` z`s2GpsL>J*RnCN)!h;iP8nd!t#0U%RL2wx2zUdfEewRs7oECUAmpN0zD^D#>-)R41_T`{=aK^s(K4 z0^9w8Oy-p<Qf7K5+-yi!t(bze;!pzmou1K8+8dhw=SD6nXQHI-R zO@9KTVwGmKqeQ4sIbgl_ZIc+n6Tesro9+1(M>Pga^?i~B4g3I(u8j5n?FH~SEegYz z)LMLZy!Th1#=+pv{gOl#Ci71wnE}>{A)v0|u#=t<{ru;tDuiFxPeA1G{Ys%hV5kH% zOxW|2B)5Ny0o7Bz)^in6^)u!h?`PW64Sb{&q%67gW$P;Fo5ir?{{8Z@Sb+maO#+4h!sJ(J z0(1{D^fgmN&PRzNOya$$_1~1Q93_9X!PNSbYkH&3$p^-WQV1lol%2vsI$f^HZ9m;}UUOSj#wAgnm=}cCDIw?{95*+H*nVrr_}RYpl*=bJC{Z zxxv+j5aiZ==;O!r1%HYKTRQuE6i&LQYD12Ai&*CRf4BPJw|fgviQ5q=YPVP!ew-0e z>8`>$J=h}m^1tk$jaqqmi%WN?o67vIWJ1h6O#1O)t|$igH3b7)T6I>kqErE$>cJwy5=vCV3$skb#A1ECMU{M~*EkM}#`=3qq|WC{(KucaSX z_#gh7)YSC->mtLn4<*X6+vC8vpUyqz>t_Dfu({nemXr)KK7eEA#Gf1Q6VTN!nba>X zExk_h_hjk#j7K|5ZK3r|6(}4KEac&B=pzQ0pIhwvk}BVcFcD$4D)#wfL-s3lr-@Yz z=-6p)OG6TVMuiMbt^qvwA5ZJvKXZh`$OO6o3?6~o)CgN({6jz91emq?&upKYHhcJd za9zyg=X50VG14?4HS^YZ(*b3}_z%fVdVD~iAU(w>eNdw^Vfyr_?9ydKVugNvva;&z zLBSZC&^9Nj--!vL*Q>~IJk);~2U2{scu;nQ2RY_9`#LK1Iw@jMDw3q@uiLkujWjG& zz5(373Deq&itXrs8F2k@!ohU0@uU+i7K^+%_V;D6Jg45MsdS}kku+Ime~w(r5LL3u z^-^;ZR4u=$hMQD>$4w012UC0;*XI0rOF-4L>+2x$p9kK~ZbICPZDGYgSJv^;N2$-*WV5PuMtYm zg}scEXw>j$Q(;@h4>z4_cN!QGk4w-xX;ap39k`34TZk1C{&v0vQSx*xsgFzC z;m@~bG6BO^R&Q3AwI0h+{hnTGtdDKeU2H#i)1Unjm|6It^%w#5J}Bp5_a$Ow2`9AW zYySPP3-Y`#HMRYpyU^y)$C$hH+VM;SbljIY6%N$a)P@7%q%bm#1T#~7Snch5Pr_{_ zSAccl2ZYA&&cu6C0t7i*set6=jKr)3yolm&e&V+1<9;Bf;?BL5?&H^a>T$R7C^IkHcxbrAWy_ExQO{Wp&(wx!E6aLCEC z)M@foWNT)XO0)x@-?iv^?BOK^o+?%Qqo4E0+gXx3j*~l0&kWkuH330J5MFs`q4Kd_tuK3?_4~dKS~bSNyc5Hhtb@wBN$QW)TOK-v(!)*g zBgZAJ(L~Wpc+$&GVOb9|UKm;?Yz6xu;HMb*>=jGTy+?r*Di+C}ZyCM+ggUAq=>V)z znfkY+23oq&89zk^OR0qUqCp)NX8qr~xUZa@Dd3ggY6%{G8C-%tk`-Pl#kHM%FGYKg zxj-9!&1QT%3VeUJEy7c9!FPnLguOETuDB+6cyaKa^L->``fO=PpD06W7F83T6B z^keY-o7dN-ThpSP<*MmNs6OCXw29DyRFVLU$ND4lZ&p1g1~MseJTH~^wg7)qyQlSS zq!S|me4d>u8#B;w0YtP@SH~9Jr%Pc)@h-@8{=(_!u{pm=d_aCZ%w=ga-wf8lv|^C~%K&(7}b&g|G{a<(ywrAweg zQ<&zmX^f#|hzRG&yYW`sjQTIfZG!c-VUs19Nh!$wKw(w0TBxxyrKQbglNO8UPo$_GoCBX__8eiqCi$7t3DQRhcFgz2hF9MWFFEf1%0nY!v??GpR ztOCcyW_V3M^MSoA)N?|?bBI%0(}56( zzj;;wv~K+T`kgUBcL$x)D6}s!<|?kmedVABtp~p2dP$d@Qg}Ce>B&@RFoCHPu*82T zyv@}=XQd*=Zs;x^|NeMTYR7nnei!BJ(>;G)J$gm;Jgid=ehTo=R6_b)i!}jny(QOt z5+}rYQ~P(Kcdly2sN(<|DYbYzSZ3`xBa2eK0M$C(LoeQ|h|uci*o)2e1NFVF(l*(* zwj9Da3%pe-QPu2xm-pv3EN78Rbol{egxOEVuM6j}t5*E2wtt|l@vZyn=RAjhi+0RS z7yX0%wm1;y*;&k3LIZXb+5k(M|Ef{u=5RrNsA=!;Zu~6nCEe-uJ2>MhRoujFB(*js zIv-=4R?i$qw}-yZJ^XRBo=Wsx^Xv?mzt)xuRliN$XZD~cDj!7{f#R~So+n96WcmiuH-}Y_ za0yrpEMV=?(tD)98;9*Q>-YE_aesuB|Mn71($UcAO?pZ=yxPt<)ZpUcbR{Q@>Y1p$ zFh&S!bE+f`M^lQtAng`vT1j?B>zj%fUu`yKdBhLg>;EYB4g@WktvY%9XAj?xbamtcy&1c-B-C zuc{{_RB&DRAl?%Of~#uY7bA*aroq*3*5$j|twrIV#UH2Zmu+1?)dykF_T!R7$^#XF z%@Ik=9NtokEo<%_xbtOTq<;#_*ked=nD<=EiX`De8y8;_x&xjyFA?C$aGCDWVa1qG zp!oAL+S0ws1rx3B)3xFCh}ego2Wk>dGbBgPKkw0Jl8V)Dej4&+9wO;Gz1TkeJ0B;p zU7QTGHQ&xWPIq%g%6TF=Sq9BN%pq$WKNem5ObdjqL@LBHHb7n}%k=THnVVSvI3{8< z6FJ=|B-C(Twnn$R960O4AIYX#KM~--ht+R)bV5;rn8~ZXkpuN|Q)s=<|278$(GwDT zAFuVfQEKRyy!%ByGj1>Vf33&jDa#9p%~&^N*VgxXiFZSs8;Fma^W*fvHDnBv->rz$$Glj9`g)_mtk2T&~6<>|OLv(gm>)Tu5xlQU1 zgSssLExgyoK_EyfuPOM|d@c-{q#TI>2T z52xw<6}oa&TS{2G`2KF7@(vkNK|#Un0L9$(UJ!~ zBLL*mRoLCTp9%ix-(J4LFYHc3lfE6QKe9BA@PyZYpcV8iS@#9aHyd6J2%2isY`y}U zLj{Ci(-SkwwN6i6ygZvwzZ6>?{0n_6-^JRSTFTgn!jPIpD%9bE42p3iQfFF;pZ^s+ z&+Ld;@}{IggkIVDQkrRuU{S|N@cV_|ti^C%)1~`xoW&g!(RcpQo4W)m>pS!X)l6&6 zMRbqtDbngdbnu`m6^{gk)6GLgBeNp#-*K z2WqG`Isas2E9AL9I_)wSbei}fa}waAPbPSWSS@w>K6fF1mBIj$RX7s;)ka?qqha;* z9s>qfI$$IqBg}nzYKYgWq|A5x*vd_yLAGE!<|78k>5VP{s|%}v z;st^`=b_7cWpqb2joE;iPkdWp?t0L8R2sQJDOkp?vbr4htl8l?V!5z-+fBXO8^#l1 zJ`k_W*=+K#kEihPuufHmE>Iz@ifaQNkdo5k=t8Nfv|KgzQ2_sPs$9dwH-lG2oryJc zC1lFkPDDy-LgeF;l;EBfayvE5{m45_)JT>XxYCeGnIW~d#F&~({SgxJHo{2xKPiyW z6zo|>Ci-O557{zj=TCg*@92MKSb)U=aMR|TaRG&>9lBsX0g7LTre9FVRFTJntZ`+9 zqVh}XUv^sDa9**rJp)wK&rPYbSN~6{q$zt1&1goNC@o*G-;r9_7lsag{G0S9Ak9It zo6qryEKp}lgB$TG#6wQo{~oe|1H6RM^d*xW<;4zTH^{$&=^MR>VSJO?&s#}Zf=EVe zCq?guMk}0rpp6QJ?^zs$2Mm&h%0Gpu8oe>%_$+TJqO9^m z^#M2n4uH5Hd3w$buyjv_64u3Nvt;%nGD*YchBCuQ)cJpVie24`sNY2SJqKN`>2R6? z&l))Gj}jgLxYbg5LI-hoH5JwV>9 zEkGu|GS)1V(xAY9_bEOSfs~#vGdljKvlOU#J@H^9$l6h&sR6ge^L%74B{9~j=^P>n z)QCqeIoPs&c@A(KZZ@za91+Y6bXqy369HMDGzNV7ZroYv?lU;fg;DKt!%`!C1%MK$ zl@*QRpr?&l!Sxlf;t7@V7yr2@ZA^jnVVkGnapx@p2eR=<)VaMYiR_L|E<1(#h#4NC>IYE~xO~)58ZcAM z=4`@}I3C%HIW3k8>h6Ac&c4hq@k(>0gMJQ1gl9OOZJq$z!gXT>F0Wa))K_K8FoG{5qO)~aM;$y!5?Ml{NXiO%{Aq#xN zyyIRWCQU6Uqw&fqTPL_MhtcQVaYf9skRC8uMi&WrSypsl+lk_#>XzOw%ol;^(*v*= zS#KLrDf7hF@&E@zm-c6zMfRsW$H!b8OdB;@Y0{Q`l^`~`%oU5zwu3o(lg>cnD_^5q zmHrQoYW^s#)$-UDXr(a~48|`8b-sIuOTOSI7oFJWC4`esk${dE)HF9_I0#QbJ4)cL z0QHigD%FP8_m4j71tMkH_-)ynvxV*pM1k3Uuw8`IQ>uZxf1YN6YLZm~so&@$J8w6) z)r;QW>^Z9wAsu+Hy6X3wUW}hbXwYJ|!^oR0H>KdQ9}#Op8&7eR&{o&L?Yo~)%ReK@ z(k7fWh?M^ORcnb@6;N}b!e?@302dhptxW@)Pa0@W# z#aK|i=oPDP4q!SNu?PJWJz>+0-)}@OG{S76hVQHH%25JOWG2t@!z8~Ycc{C!&Dl5? ze{2WtpKvs+y=w!LfC)l}8)m(ZpPK9}^>P$gjb=Oq63cj9Q^h3=n|wsmEScVjzMHUn zLd>|xcVNnLr@9YnAZyswkWa*QTi+x{%~*!W#7Od`}+wR($49w1wjp^m`=PGdj&k(Y3f}s@Wf1n6foBQm!*n5LoQ> z;Uy&{hb#Mk@*#)|$s;Bf6!pdaB_m%vwvzuns$~6ABtd6CNBZ?Jpd3PxXGl(o#ah$z z#oS!7k6^Y`RcR(RFH2=TEg{-Th@*24_Ny4qM2;P9(^bX~71oSioH%kVbs$ZQPq{&# z6D~w{Sdy;Jq4UzTtyEG{$i;7;sIan$G84w$N@G}WJ;F_Znlija05w#2FX!`tNr9bd zxS1)mh%g*GDDZTt3!j?7Dh!6xa8#lmlSNX?EV>d2@vZeTaw|pRwMtMsfw&vb27uOf zzJ*}3o&hI845vzNrlh9&fXYLlDvVj9h)BMwnlfq3t(8_s9@PC!Q9mND1Dr$OuugG} zKC!hr!NFzJScw5db*|Q}iCBMHB^0lYALzy0MuLJi@X+Km#v|Z!ySdOfQ6n3J3F1Wj zUC%So_Fv)QeGZK=X0h`Fp3G@#Cfz%zF>S!!)ECd0V7NHTeiKrq=&XhCA>-}k`9bctX_Y>#URd>!8 zo=4kZarwB=7}Rai$rt7$E)nz-CVG9!)yG;JQ~)4DC`y}?osJsYm?qq*I~5Qav{JTw zx$UC=jlx$qIcV$`>7R^w24bKmW%^jkJyb8Z5=LZnJUBIr$qKGlTacpE^j^yhBA~~| zqtj%+Y*jkPH$>7y%MS%KIHZRUoNR|TAAvW4E_h)KC}54RhNSslkAnA-&r>au0fFV< z_-uy?+MVT)LM5>%eOlavg#$S2ihyF-M>XcHX&eON3T}ee6QH?g5&x1GwhK4Pa>Ln%E`zin!;PNa>UA0FlX<)=Q2Z?^l8ZIo-2yKcbnZY_(EFy?LVk0ruW z#piAOkpcCPa#DMm;%7JJ5h}uw1n^X9s-fhhEvhUxYz^)MGUOq?c?sQ$%GP+ZVJ2dS z$}z}E_c!%#60NmQFk-t~5L`qcTiba6ctYo^puj+7_csR!R(H=Fy3Qs>p_e!lg~3M7 zjQAn2+V0OXaH|rUCtJ%;H zF7(U>M|wBnko-@&*-x7^wW_Ia8;>BUM?~eb1KGXlZQt>DqZ{H;`%7=^Z>oi9YpYFj z*OK4wDd7tHqZI19M=Ba6Wuunjnxf+L&GmWT{Fu)*d*rFQFsr=b;ie>%CdZD+Ia3*6 zQT1mj#t2&Tfj!%K_=chQZQ9K&j16epw04+m`D9YJ>^Q(G%b1RNab- z1tXcP-$N7p_yY%Bvc5YScT+0U*-Z}j*b_f*(C!WhB?&_14jtfoDHVc@Fc_S;P zvstpOCoe&%2cBI$Ml$m?QMT%9-=DPuhu+^j6RiJ0r8#qOhY&_+q$uj|yBr7A5cxUF z=vHmNn0C@}XY8TgQXfmv7KFT7{%?D>&bI`3szvk09Y!Z6IJK-Ulf6XRY`)(=R0CZc z^U12QBJ~07dT7muV~Pv4B#Lus`#{V8kiobgeVvV7QArDzQB$c7N{yWyO4oRDVqvJ}czZM#&##{3F z14y&5TWmmiOm9GafdhUov&IiEe^&Ia-0C_!aN-ogXJ$Ki9j0pDpGFHX#X>#VVDO}^ z;eblqm)Bgg{tMt3>-0q1Z%5;AHY>`6949DF?&N>4S%D|7Znm>#lh{NZru?8NfPB;9 zWvdl~XX~=7%B0Io(p2kHM9=E^MQix^PV;*BN_uHbW)UP%lMV#5)=;O!1J61G=2t>c znz#Xj;B4I`Uy|489Jj&2GRUb`gq&%3awpkZI7tx8d3UGMP6&@xFfXq7mp@{Ob)=Ar z_m`Sl+j+h6IOZ=;26Oo~adv8%iG=pFcN2-g>|e~6s9zasNSws9W_W;1#JNz19%j+Y zrxVat(M&ym#H*X*cy+U>t#G!zirw7oXKm}lil)V?e1P4Y2rE^U$*n;Js>|jRQfnd* zd`WwdQsZS1EyCdv-#2q$Vv$OOS*ZLBqsxzr8{_9hTZB@ed=Qs(HYewFWWk|}2>cU4 zH7t$QquRqk`6F40UGsWeCHt$Ip`qsKsve~B`MS%?Ljdx?wA=n*G%Iw=EK@aQe>i#|Sc>^))7OQSXh`RF z;_0Th(7E%ixdFtW@a=jj-$U`NvN;q>Q=|0Hla=A3pUkRq1mnUF!?WJem6EOMXP;&6 zM+G^~>dEak;n|{dEG-2%w7_%~_5B)JzfV4Ta{k3srAf|%tK)16OYfT=n{pE@rXOys za1HKbyzKHfswDBwB~DOqPwaokSUnQO=3`WshK@|Mw>CL3WtYcB@YL;vvX|@?)880E zn9rm}h3;-Wl0hJ%`j`I*aL;8rm0{29yL~bXX*dnS1s%6F@<`qkD!&<**FF1k^o?Is zuSG(LvBg2)nl3xAA2y*Sx6<#ICZ9+9d>Xfs$RKm$yhGH$TyqRzroG$fEi)KroDPrL z(&+6QZ*ILEr|DH*^e#UG{@WXpDyrM6OnJfo<9pmkb` zx%&2>8S1`(&&Kg3&4&VtE5_%$(EfkQW(qRTE6SApOc}cD*LT1Rk6<Xnf3tmg+8`U`~^{{8Zs|{rn!TSY@L) zLB#24UNUO@)N;I)(kp-^(0=^agL`HA@|>9U%xIx)aV-OA$%co7bfl1cxt+&gzh4dZ`ALl{wR%M<9f2H72Mg{5e$P3|8(tft! zxDq)aCu6c>`{*oA-m2PWKqqEB7lafTU^h2KWZd}$+EuX@9-Lq8-K4;$gw5XeNyS2q z^pJg{+^v6tL1Lh;QoICHRpSFLUrz9ylm1miHJ4JHw6f6k{rI82_Y{riSx~3g#Bqa7 z7iQx3S>CY$!(MF>c64P~_Uy~!vgPTTV9224X<@&8oC>Ou2+wDjXsJtjuip6&Qa+lx7 z@>m?V?aC%MssWav0_MhUVHwKNcqZG|_Vqv$XsM74=z0NZT_8Yih@|MTyS z;GNKFmt9Y3(jQ7bc7t1O!)&rHj=IIjEEBxs;aou`mrve{3d$bbWhfbRJg6$5ncsZd zB!|NRq(-b%;RL6MDeV{R8&TQqa-#jl#fZlHL~uX}(PQg+64%!wu3sh$Oi9BnGYI%08LifxPcyVdr%j4 zf;($Q=Vq82{PJV(7mywn~0} zFH!D%0gkT}QwW*qP+K=t=XpCByyLFzy*S#`U}6$>We^D8eTk$nbyY27wpU){9Tt@) zR=vdEBaH_i+J>GeD^#XaYI-ZKsMx!nt<>x=&`OidHiN<{gL9} zRu}VQ9&wPJGKnCU43hSCbl~yr)yS1b${KaDdyg!Mm;H?9*A-S+aQX3{{GRhUi4GiJ z|1!J>Zm+G?Cb#i&OG7`$WD%!L^!y9aYSWSw7*G5Aj;}jD9Z}&!bV`9_fxlU0eH8Xc z$VmkaSeiYS=b?Jwn4j*5ON>47xuQs+vjrGlgeOl`Lan*>@m#EeQAc+nR*{Rtd(`q| zlS%ThVRfDs;ST%z6#ulJtqld5wIC$Wu0$UYy@H(?VegqyO&R)b8(hx%e3*&@a$jvD zDhpPhUZ)L1zC^?nmzYGhif}x4uR)zZvw7UNbuM3Chq3ww3%+~ai}ooNQtQG_{2E-O z`I#y8_1o8d6NTR=pu%Don}fD=erYcXlyYMCb~f24YkGS2DX-*61YeG4i2ndh)G?Q~ zD^>-W|KvER7glZN-$%*WLvX#Hu_iC2u~2+4HVBz?PTDQj+PVs8J8Lqm-dnl$!peT< zRrBiAH{zVhTJ!sPndf&bY4qv^pfmFyzBxN?#rhr|mREYo`93vOvb-mnUNg=-$L};c zy(c`#gnFdM>Y{=z9)%+pJ`<(GaPvMY@N)}L<{;QBnx1d53tu8XGGaYp<10piQJ34if})_ul`l&ToUwrsaioBn?{ee_a^(`zzd?E%WJ;NvMbj z-U3i;`~n#aCf-j&BpCaDt9)i@7{2rWOq$neEAznEpoNw=+@1^k7GUGd$%j2;PFg9s zN{_)Dm7lG$mJirraZ&b=wQMHu`qLor0uJ~KbnoA`>!lFUBc+N5aOqow_DJ}xx- z;ZN8sJ4R>U-;lenVm4@rKKY2=?cVbmQ|FU^&Ggu4e7v0Q65$bigzK;$glp2_Ck}!F zkLGzL|F1ZB?PJ(OOWVs4mkMg|<0Upn1LjSE}4>x0@L0Shd zZE)w0iSh+2J<-Gk90o(r^r6swq7DL^uDF4dC>^|9qxBt_;aWh}(XR?fca`ogIk}`g?Ic=h8YZ!vfTkyxZ80 zhhdg*0Vv#(z_J-f-(X9xHR4F!OI}UePQCZ{U1WwfYoBgvx8q=&f*0$5O)w2sLDkx# z{FVfySEqHit2g^~$96~ojj;C>eM|8Bq+Z(n2mu*i^=k7vLR;mEL!CS+X|#AH!086G zsm?0+@_)%*cH_N=npdf2WNkD9%|oImyicCysu(N%*=As}Z;u>_#_B7A-(^v|hD9~6 zMWp{FcXm`o&u@+YiVj!Oc9nl_P^zu!4~YaXA??e1ruMg>+r*2@!l8kbVt%8p zG*4`1i})O8Lvlo|rj(ndXg4_}KV?8i>@j*$yLdlvfS#OE~V9pE38i# z4V&y>Dr1h5`y%vqU14LfL6ey24B~LzM2XfxkL)O&UQ0a$<$qet*zuUI2~u%4jU)#} zsuH!&RBJUDg7e>{uiU&tP87m0o_4m+C+PgWRRIb9vUyf4d41VA<~QQ&)}hhzADzB{gMUc5Yz^>nDid`;(V<0O|_&~s+w zG3gz%zv}Ip{Lw+0Z&~LVb7-93fLAm7jWW_8sgLg{rzm)R^v{X3Sbd+duYmN?k=X5y z<&kG>RMyIh!2IWBh&cuQx|j#saYucNs}(n5KA>XqWBI8*!_SkQKb(B`=y>Gh5t;q% zy+N~Bi@xA8u|z;OmoDp`MPqwZ8xX&2HM+?}2R)CB-L1-Z61HYwe(RlQwp`6lXKzEV zILKB5P9uBDDJ4z0YEepuq|rZ~Lb&-K$90Xt4gbnpxE2|y#o;(C(psP34c&fsubjl? zXi{BRV#!O=K}!(z6DrK;p(UcP*{RIlk$~5t?_(~}!yn%>B@NknSARO47lVG`edF(Y zX0f_|6+-nZ9OH6TRC?9KbhqgH*QYk)OUp)e#h2?+=}9u+Ge7ysr=;jv2*qvU5y@#r z)63k2z1=n*Tu)tnA%BD@G0tUcKPajg9QsNmA>&bS2QdeSdN=+k6W>oHA?eq6;N~LB-IaiF=5j!96}pS+@7Eo&KVMD*E4Nzg{K2KEerL>hwFjg3VY72FpP#Ct$G$%|(L+(d zKHEbWIH1H-UdssyyHRi}BIiooSM=+4^!3)nM^8lh)H4!G6`!pm{EhCD5IHoN=x_tI z@ucMFj=HJj097&f!!^OPrGQU>x0g?cUd#UTn+0q@y{FZ1S^q@3I1PjS!wP9TGReM; z+%rnlzXDRV&h8iobGy(5)ix|7{bx2ayYyApf7Bw8ou?4?ryeVUfCC$#rTyft6xo9@ z(Hjq64fg3o!5dj^6mEdOc{Vd|X3&CsDbKp~E!=stTRuuaNbUj+Q)PMQj)RbCp{!=# z9~r_IDH?Y5-%W`%_KKdQ|0E~(Q!o28AMvNY+2O#|!0r1JlM5^_HjBe==&Iow{HlZE zFEOy@xn?fg*N9r=pX;U;N8#Uh=xAED8c5P}_c%R7eU8-087boJ65U^ZZt4H29k~al z!meLS`9i9eJ8WJz_?7kWUWEPBxnT250u8W5U3AkmsFB`51_k*>iT!5f1&WO&p4nWq z^Lq5J@T%6BX`w?B`dyb!rUaQqVqB+Kibu(Nc4GDJ;VsiU!nUUR|L)U|x^{o@y4dd~ z*~FKg&2^fy(#c4RvVWiIj1@pien zRzmAxCEyAL*#joS{4V=dQX-$@xkJ8=?AG>47Hx` zn!!!>y5NIOC*#X9rFU~0S8YE+nbi?W$@hl0{N?j^7WP3wgaF<53H*p~fKINM*a<5= z`}kf&T0?D;b%Rv(`^NeZ$D1y2!_mBV+Kn)dl}6kL6DGjpQV7+glR_kBFtO5?wv_Tz zV)`3^l~_ygzTt7gJfx%&jrP&Im9HbC_)fo*N`WE>pn+Z7jY&=U1d3zB1Yn}$;hiChZ#%-ddMW+VMf z`|*)7P#h0fgl66(Vo)hf0*nl?Mf`@Rjy-dak~QLq-SVaY+1)Myz4M3sHB67=RhUHH zF(NR#qbw>)&^?MyS0SI%czf;g)O^$d$-%UpIs1&&LH>` zfW(Pqy?9WG-ufgmJt1eJa4pn}(10p(&sggN9TMhy96I6IkLM)M9`5TWM|VVnjx z%ZrqB5i=^~vW04w#T9m;S3&`O69;sD{a|wVVI_^{Jum!lorS<82$ z-Jf^fc^f{ji}NTbsn@!Ih!4z81`&k<*dq!*AX5CvlOQ-mOmPq#44C54_!AZI75Lq) z3pm=~807yO31t_DTZ0i}!@>XO_c*}badYnfjtl@)7HmENP-;vJ% z320ZC#0xo!z`#0yJ{J-C|BXB|21vkoXyE@t9x)4`pEU$<<3A%|0Ey>nO054Zlwyqq zpsyf>@P8v80nh6!06p@*3%3I3TX7QqcjW)J_`g&9-$nhOEdKvfH$kvh+8wnpwytbG Q5b%+eRFbIrXcGMY0IP { + const binaryPath = getBinaryPath(); + const isDev = process.env.NODE_ENV === 'development'; + + console.log('Starting server from:', binaryPath); + + const env = { ...process.env, PORT: PORT.toString() }; + + let dataDir; + if (isDev) { + dataDir = path.join(__dirname, '..', 'data'); + } else { + const userDataPath = app.getPath('userData'); + dataDir = path.join(userDataPath, 'data'); + } + + if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); + } + + env.SQLITE_PATH = path.join(dataDir, 'new-api.db'); + + const workingDir = isDev + ? path.join(__dirname, '..') + : process.resourcesPath; + + serverProcess = spawn(binaryPath, [], { + env, + cwd: workingDir + }); + + serverProcess.stdout.on('data', (data) => { + console.log(`Server: ${data}`); + }); + + serverProcess.stderr.on('data', (data) => { + console.error(`Server Error: ${data}`); + }); + + serverProcess.on('error', (err) => { + console.error('Failed to start server:', err); + reject(err); + }); + + serverProcess.on('close', (code) => { + console.log(`Server process exited with code ${code}`); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.close(); + } + }); + + waitForServer(resolve, reject); + }); +} + +function waitForServer(resolve, reject, retries = 30) { + if (retries === 0) { + reject(new Error('Server failed to start within timeout')); + return; + } + + const req = http.get(`http://localhost:${PORT}`, (res) => { + console.log('Server is ready'); + resolve(); + }); + + req.on('error', () => { + setTimeout(() => waitForServer(resolve, reject, retries - 1), 1000); + }); + + req.end(); +} + +function createWindow() { + mainWindow = new BrowserWindow({ + width: 1400, + height: 900, + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + nodeIntegration: false, + contextIsolation: true + }, + title: 'New API', + icon: path.join(__dirname, 'icon.png') + }); + + mainWindow.loadURL(`http://localhost:${PORT}`); + + if (process.env.NODE_ENV === 'development') { + mainWindow.webContents.openDevTools(); + } + + // Close to tray instead of quitting + mainWindow.on('close', (event) => { + if (!app.isQuitting) { + event.preventDefault(); + mainWindow.hide(); + if (process.platform === 'darwin') { + app.dock.hide(); + } + } + }); + + mainWindow.on('closed', () => { + mainWindow = null; + }); +} + +function createTray() { + // Use template icon for macOS (black with transparency, auto-adapts to theme) + // Use colored icon for Windows + const trayIconPath = process.platform === 'darwin' + ? path.join(__dirname, 'tray-iconTemplate.png') + : path.join(__dirname, 'tray-icon-windows.png'); + + tray = new Tray(trayIconPath); + + const contextMenu = Menu.buildFromTemplate([ + { + label: 'Show New API', + click: () => { + if (mainWindow === null) { + createWindow(); + } else { + mainWindow.show(); + if (process.platform === 'darwin') { + app.dock.show(); + } + } + } + }, + { type: 'separator' }, + { + label: 'Quit', + click: () => { + app.isQuitting = true; + app.quit(); + } + } + ]); + + tray.setToolTip('New API'); + tray.setContextMenu(contextMenu); + + // On macOS, clicking the tray icon shows the window + tray.on('click', () => { + if (mainWindow === null) { + createWindow(); + } else { + mainWindow.isVisible() ? mainWindow.hide() : mainWindow.show(); + if (mainWindow.isVisible() && process.platform === 'darwin') { + app.dock.show(); + } + } + }); +} + +app.whenReady().then(async () => { + try { + await startServer(); + createTray(); + createWindow(); + } catch (err) { + console.error('Failed to start application:', err); + dialog.showErrorBox('Startup Error', `Failed to start server: ${err.message}`); + app.quit(); + } +}); + +app.on('window-all-closed', () => { + // Don't quit when window is closed, keep running in tray + // Only quit when explicitly choosing Quit from tray menu +}); + +app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } +}); + +app.on('before-quit', (event) => { + if (serverProcess) { + event.preventDefault(); + + console.log('Shutting down server...'); + serverProcess.kill('SIGTERM'); + + setTimeout(() => { + if (serverProcess) { + serverProcess.kill('SIGKILL'); + } + app.exit(); + }, 5000); + + serverProcess.on('close', () => { + serverProcess = null; + app.exit(); + }); + } +}); \ No newline at end of file diff --git a/electron/package.json b/electron/package.json new file mode 100644 index 00000000..9cdf3d12 --- /dev/null +++ b/electron/package.json @@ -0,0 +1,100 @@ +{ + "name": "new-api-electron", + "version": "1.0.0", + "description": "New API - AI Model Gateway Desktop Application", + "main": "main.js", + "scripts": { + "start": "set NODE_ENV=development&& electron .", + "build": "electron-builder", + "build:mac": "electron-builder --mac", + "build:win": "electron-builder --win", + "build:linux": "electron-builder --linux" + }, + "keywords": [ + "ai", + "api", + "gateway", + "openai", + "claude" + ], + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/Calcium-Ion/new-api" + }, + "devDependencies": { + "electron": "^28.0.0", + "electron-builder": "^24.9.1" + }, + "build": { + "appId": "com.newapi.desktop", + "productName": "New API", + "publish": null, + "directories": { + "output": "dist" + }, + "files": [ + "main.js", + "preload.js", + "icon.png", + "tray-iconTemplate.png", + "tray-iconTemplate@2x.png", + "tray-icon-windows.png" + ], + "mac": { + "category": "public.app-category.developer-tools", + "icon": "icon.png", + "identity": null, + "hardenedRuntime": false, + "gatekeeperAssess": false, + "entitlements": "entitlements.mac.plist", + "entitlementsInherit": "entitlements.mac.plist", + "target": [ + "dmg", + "zip" + ], + "extraResources": [ + { + "from": "../new-api", + "to": "bin/new-api" + }, + { + "from": "../web/dist", + "to": "web/dist" + } + ] + }, + "win": { + "icon": "icon.png", + "target": [ + "nsis", + "portable" + ], + "extraResources": [ + { + "from": "../new-api.exe", + "to": "bin/new-api.exe" + } + ] + }, + "linux": { + "icon": "icon.png", + "target": [ + "AppImage", + "deb" + ], + "category": "Development", + "extraResources": [ + { + "from": "../new-api", + "to": "bin/new-api" + } + ] + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true + } + } +} \ No newline at end of file diff --git a/electron/preload.js b/electron/preload.js new file mode 100644 index 00000000..6d8b6daa --- /dev/null +++ b/electron/preload.js @@ -0,0 +1,6 @@ +const { contextBridge } = require('electron'); + +contextBridge.exposeInMainWorld('electron', { + version: process.versions.electron, + platform: process.platform +}); \ No newline at end of file diff --git a/electron/tray-icon-windows.png b/electron/tray-icon-windows.png new file mode 100644 index 0000000000000000000000000000000000000000..57df8ead031450e7787bb01bec1c0be0e906f0f6 GIT binary patch literal 1203 zcmZWoX;f236n;rvfI{xH-x6O3{njggI5v=Wc$#U=e%R68bWBN8VMHfzbW1tZ*yONWT$X`dbxkNYM3rj|%}VT7BC zju^x>I%DJ=G98V%s;iP<%|iUxDZSIIx_b|ieoi0Ip~))Kf2u-R3u-c5ZIY8o6}p5< z-JMVz6Wt?I=17^YJlS~ixZ%V}17{rB-G_o`(#yJ50S**iFKUEa<)yj<_ceHIzkjDD zUts`tw=o24cp2aF*o(+5(m(egou(G;(Iq13PaYl51sxuz3U1aq zEU1l#n*gtravdiADZD{T%Jeq6B`l`b4qf_e040+D0(xyEHpwS%gkc!KmX3to(t18a z&tw?Mw0|S&wSWa4`y{dsClTLn zlt_66Zt?CxiTDvfkTtJ9@KCdT7$FiTK(R!%+G-dB_JTaauiHtlpHaaB4T!&E2q9Dd z0qM#*z05K62dzD_6!fD8pf$+}02NgBe5!%{Qhim7^?8w?5V&;EBsWDgXhD5TvW>Qy z#btJIv-6Ca%y@G!^R?`(-ahxtb?{d1%Yp+BLe&3T*NQi`*Ka+mfiS&Qofu876Fph< zFNQ}BafQFFxH!V0p@MT^Gj>R*KD%9v)xq!A*tDimtIvnhG1}P3(cD7;sT|v4LAAev ziEYDnKityNL$eP~p+33a*gzk&9z3=sg)+=i;_1iPW5>-X>U-W)6h-bTj0&Ly^Fbdf z7-Ue5^r8cuqUNx`r^B!5#}nF0OxLKp%1f2tW5E0Ucp=$UVwjQ4juT68ugj#_?P47W(#k5m*34;x)Qo3<`Uja=OtMc=kLnFp3iS9+q2e8;1>k<4h)RH7iGtRY`dO$ zTbEVCWw#TjEUle}Ec~5YAjO8_{I6LomSzJ(*8Y{Mlg?oLjvz=*wM*Re+}z;-<=Gf! z_%{{ZN})=n>SXe}oJAs!roHSMDfiR@hs}OoTJ3$9&kq}2^YqJCt7f<{63LakYNc;1 zk?HBVo^kJNVc`>VzM|;6+S<0v#+3!@I|Cv*ohs*i;A zH%2+!N1NzR?Mj0}1pG@9xPn!8)74RA6}>V(F45+3h!PfVcJCa#$djf$NMNp$TDyE3 kyV0{HeVneK@{oCO|{#S9GG z!XV7ZFl&wkP_Qw;C&U#sR%fZ>$7zmYc*&kIant>eck|4iehX2gQcmEuz z{F=Cqqha|&@g1y&Q9jxs3q=B%)Lf^Ze_X8e;U;hDvn_g?JpUEm5dvyk4UX$+uyx4kfBSaIWU^~T@O3fm^-Kre+=ySF ztIN7`AEV68qdOf$gj<*MZ%*T%kkyu}&oDVnIL!Bma2BgTrd;A1okE#&RSFl@D1GU% z_B&L@y!aW1fUfGC726WB=3QBK{+fx4&*Zy3b6A)2uj2#x-P6_2Wt~$(69B(4fSCXQ literal 0 HcmV?d00001 diff --git a/electron/tray-iconTemplate@2x.png b/electron/tray-iconTemplate@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..d5666a04e55b0233bb3c3981a361d1f61ea575e8 GIT binary patch literal 754 zcmeAS@N?(olHy`uVBq!ia0vp^Iv~u!3?wz9Rv7~+#^NA%C&rs6b?Si}&H|6fVg?3o zVGw3ym^DWND7Z1eC&U#<(-tr?Qf@vs7dImtJ1-9(0~3?yBj?9J6Rk^v{DK+&OR<)| z@c6UW_#^+~Ut2k5J`mR9I#O^({^GUsYC#Th;(3R59yh-|afM>FwWe=t+Uu`f%O0zo zbTf6`a%|?!3}7Hl_H=O!shIP2%JoTy40v4Ms`xd2VN?8a&-(Vi|A()MO1#-%ZT-7L z&gk0Jjf*_W7%Fbs&svwcU*)n&dHZdVBlh_#_`UekvNvo}p7QkU@->g6FIclOTv6Eg zc|qLSGC?(`GfXGWeA4cIDOd1kwf{89m3iFz%O1O3*>V5TQ~94gb*}vMbBs2Q zW&ExqberRQQt>Xci8r^XOuV)>`mf@0_o7!vlo`M4P1sWLY8%UQlLUj~CMP!^4xTL} z6I6R#ra6!68IPUarl{su-p>{?-C4R#e)sXjpv{>g0!qyLYYdkrJuWnmnWx9mvpiTJ zqWo&XncKG(#x>5^6>|8&i3gPhza1FLLb#Xa&G?}A^|$(auBkkNHAU=&arfnkhefSI<14qid^b4TcP^#>`ia1e z6$Z(U>06Q>$?eJ#yS#4&i#4MrQ|P8A+Y8g&B{r+`$G_{6+F|;c$MqZ68sisJ_p0yb t4fNr;^!CD8Vb}Vr(k55u*4>={R(g)PNNv{CrnR7Svd$@?2>^`i{gwa# literal 0 HcmV?d00001 diff --git a/web/package.json b/web/package.json index f014d84b..b94445f3 100644 --- a/web/package.json +++ b/web/package.json @@ -10,6 +10,7 @@ "@visactor/react-vchart": "~1.8.8", "@visactor/vchart": "~1.8.8", "@visactor/vchart-semi-theme": "~1.8.8", + "antd": "^5.27.4", "axios": "^0.27.2", "clsx": "^2.1.1", "country-flag-icons": "^1.5.19", From a8bfa7ad29d794a4a41671dc3db835fd3dd6c3da Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 3 Oct 2025 13:59:01 +0800 Subject: [PATCH 111/243] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20English=20bug?= =?UTF-8?q?=20report=20template=20to=20GitHub=20issue=20tracker=20for=20im?= =?UTF-8?q?proved=20issue=20reporting=20#1960?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/ISSUE_TEMPLATE/bug_report_en.md | 26 +++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report_en.md diff --git a/.github/ISSUE_TEMPLATE/bug_report_en.md b/.github/ISSUE_TEMPLATE/bug_report_en.md new file mode 100644 index 00000000..5c250618 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report_en.md @@ -0,0 +1,26 @@ +--- +name: Bug Report +about: Describe the issue you encountered with clear and detailed language +title: '' +labels: bug +assignees: '' + +--- + +**Routine Checks** + +[//]: # (Remove the space in the box and fill with an x) ++ [ ] I have confirmed there are no similar issues currently ++ [ ] I have confirmed I have upgraded to the latest version ++ [ ] I have thoroughly read the project README, especially the FAQ section ++ [ ] I understand and am willing to follow up on this issue, assist with testing and provide feedback ++ [ ] I understand and acknowledge the above, and understand that project maintainers have limited time and energy, **issues that do not follow the rules may be ignored or closed directly** + +**Issue Description** + +**Steps to Reproduce** + +**Expected Result** + +**Related Screenshots** +If none, please delete this section. \ No newline at end of file From c9abe1d769a5e360732720606112a2b0b263acfc Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 3 Oct 2025 14:01:16 +0800 Subject: [PATCH 112/243] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20English=20fea?= =?UTF-8?q?ture=20request=20template=20to=20GitHub=20issue=20tracker=20for?= =?UTF-8?q?=20enhanced=20feature=20proposal=20submissions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/ISSUE_TEMPLATE/feature_request_en.md | 22 ++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/feature_request_en.md diff --git a/.github/ISSUE_TEMPLATE/feature_request_en.md b/.github/ISSUE_TEMPLATE/feature_request_en.md new file mode 100644 index 00000000..cdfc43f0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request_en.md @@ -0,0 +1,22 @@ +--- +name: Feature Request +about: Describe the new feature you would like to add with clear and detailed language +title: '' +labels: enhancement +assignees: '' + +--- + +**Routine Checks** + +[//]: # (Remove the space in the box and fill with an x) ++ [ ] I have confirmed there are no similar issues currently ++ [ ] I have confirmed I have upgraded to the latest version ++ [ ] I have thoroughly read the project README and confirmed the current version cannot meet my needs ++ [ ] I understand and am willing to follow up on this issue, assist with testing and provide feedback ++ [ ] I understand and acknowledge the above, and understand that project maintainers have limited time and energy, **issues that do not follow the rules may be ignored or closed directly** + +**Feature Description** + +**Use Case** + From d2492d2af963b391faf6b61f128bb6242f6c4ea4 Mon Sep 17 00:00:00 2001 From: bubblepipe42 Date: Fri, 3 Oct 2025 14:28:29 +0800 Subject: [PATCH 113/243] fix deps --- web/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/web/package.json b/web/package.json index b94445f3..f014d84b 100644 --- a/web/package.json +++ b/web/package.json @@ -10,7 +10,6 @@ "@visactor/react-vchart": "~1.8.8", "@visactor/vchart": "~1.8.8", "@visactor/vchart-semi-theme": "~1.8.8", - "antd": "^5.27.4", "axios": "^0.27.2", "clsx": "^2.1.1", "country-flag-icons": "^1.5.19", From 8026e5142ba9178f866d369dd6715adc98db0c43 Mon Sep 17 00:00:00 2001 From: bubblepipe42 Date: Fri, 3 Oct 2025 14:30:48 +0800 Subject: [PATCH 114/243] fix deps --- web/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/web/package.json b/web/package.json index b94445f3..f014d84b 100644 --- a/web/package.json +++ b/web/package.json @@ -10,7 +10,6 @@ "@visactor/react-vchart": "~1.8.8", "@visactor/vchart": "~1.8.8", "@visactor/vchart-semi-theme": "~1.8.8", - "antd": "^5.27.4", "axios": "^0.27.2", "clsx": "^2.1.1", "country-flag-icons": "^1.5.19", From 51d71a6e1aa818784ea9d57dd2a2213ec601c2ea Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 3 Oct 2025 14:45:39 +0800 Subject: [PATCH 115/243] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20Spanish=20fea?= =?UTF-8?q?ture=20request=20template=20to=20GitHub=20issue=20tracker=20for?= =?UTF-8?q?=20improved=20feature=20proposal=20submissions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/translation-glossary.md | 72 ++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 docs/translation-glossary.md diff --git a/docs/translation-glossary.md b/docs/translation-glossary.md new file mode 100644 index 00000000..2a617072 --- /dev/null +++ b/docs/translation-glossary.md @@ -0,0 +1,72 @@ +# 翻译术语表 (Translation Glossary) + +本文档为翻译贡献者提供项目中关键术语的标准翻译参考,以确保翻译的一致性和准确性。 + +This document provides standard translation references for key terminology in the project to ensure consistency and accuracy for translation contributors. + +## 核心概念 (Core Concepts) + +| 中文 | English | 说明 | Description | +|------|---------|------|-------------| +| 倍率 | Ratio | 用于计算价格的乘数因子 | Multiplier factor used for price calculation | +| 令牌 | Token | API访问凭证,也指模型处理的文本单元 | API access credentials or text units processed by models | +| 渠道 | Channel | API服务提供商的接入通道 | Access channel for API service providers | +| 分组 | Group | 用户或令牌的分类,影响价格倍率 | Classification of users or tokens, affecting price ratios | +| 额度 | Quota | 用户可用的服务额度 | Available service quota for users | + +## 模型相关 (Model Related) + +| 中文 | English | 说明 | Description | +|------|---------|------|-------------| +| 提示 | Prompt | 模型输入内容 | Model input content | +| 补全 | Completion | 模型输出内容 | Model output content | +| 输入 | Input/Prompt | 发送给模型的内容 | Content sent to the model | +| 输出 | Output/Completion | 模型返回的内容 | Content returned by the model | +| 模型倍率 | Model Ratio | 不同模型的计费倍率 | Billing ratio for different models | +| 补全倍率 | Completion Ratio | 输出内容的额外计费倍率 | Additional billing ratio for output content | +| 固定价格 | Price per call | 按次计费的价格 | Fixed price per call | +| 按量计费 | Pay-as-you-go | 根据使用量计费 | Billing based on usage | +| 按次计费 | Pay-per-view | 每次调用固定价格 | Fixed price per invocation | + +## 用户管理 (User Management) + +| 中文 | English | 说明 | Description | +|------|---------|------|-------------| +| 超级管理员 | Root User | 最高权限管理员 | Administrator with highest privileges | +| 管理员 | Admin User | 系统管理员 | System administrator | +| 普通用户 | Normal User | 普通权限用户 | Regular user with standard privileges | + +## 充值与兑换 (Recharge & Redemption) + +| 中文 | English | 说明 | Description | +|------|---------|------|-------------| +| 充值 | Top Up | 为账户增加额度 | Add quota to account | +| 兑换码 | Redemption Code | 可兑换额度的代码 | Code that can be redeemed for quota | + +## 渠道管理 (Channel Management) + +| 中文 | English | 说明 | Description | +|------|---------|------|-------------| +| 渠道 | Channel | API服务提供通道 | API service provider channel | +| 密钥 | Key | API访问密钥 | API access key | +| 优先级 | Priority | 渠道选择优先级 | Channel selection priority | +| 权重 | Weight | 负载均衡权重 | Load balancing weight | +| 代理 | Proxy | 代理服务器地址 | Proxy server address | +| 模型重定向 | Model Mapping | 请求体中模型名称替换 | Model name replacement in request body | + +## 翻译注意事项 (Translation Guidelines) + +- **提示 (Prompt)** = 模型输入内容 / Model input content +- **补全 (Completion)** = 模型输出内容 / Model output content +- **倍率 (Ratio)** = 价格计算的乘数因子 / Multiplier factor for price calculation +- **额度 (Quota)** = 可用的用户服务额度,有时也翻译为 Credit / Available service quota for users, sometimes also translated as Credit +- **Token** = 根据上下文可能指 / Depending on context, may refer to: + - API访问令牌 (API Token) + - 模型处理的文本单元 (Text Token) + - 系统访问令牌 (Access Token) + +--- + +**贡献说明**: 如发现术语翻译不一致或有更好的翻译建议,欢迎提交 Issue 或 Pull Request。 + +**Contribution Note**: If you find any inconsistencies in terminology translations or have better translation suggestions, please feel free to submit an Issue or Pull Request. From 0adfcf9d271de988962008e92aeeaec2a86ed346 Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 3 Oct 2025 20:27:34 +0800 Subject: [PATCH 116/243] chore: update README files --- README.en.md | 1 - README.fr.md | 1 - 2 files changed, 2 deletions(-) diff --git a/README.en.md b/README.en.md index 60d4f6a0..b107fc78 100644 --- a/README.en.md +++ b/README.en.md @@ -213,7 +213,6 @@ For detailed API documentation, please refer to [API Documentation](https://docs Other projects based on New API: - [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon): High-performance optimized version of New API -- [VoAPI](https://github.com/VoAPI/VoAPI): Frontend beautified version based on New API ## Help and Support diff --git a/README.fr.md b/README.fr.md index d0698005..6e4b707e 100644 --- a/README.fr.md +++ b/README.fr.md @@ -213,7 +213,6 @@ Pour une documentation détaillée de l'API, veuillez vous référer à [Documen Autres projets basés sur New API : - [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) : Version optimisée hautes performances de New API -- [VoAPI](https://github.com/VoAPI/VoAPI) : Version embellie du frontend basée sur New API ## Aide et support From 72a12e3747d528aee7f5bb5e9af0e6d68351e9c0 Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 3 Oct 2025 20:28:26 +0800 Subject: [PATCH 117/243] chore: update README files --- README.en.md | 1 - README.fr.md | 1 - 2 files changed, 2 deletions(-) diff --git a/README.en.md b/README.en.md index b107fc78..6648a733 100644 --- a/README.en.md +++ b/README.en.md @@ -208,7 +208,6 @@ For detailed API documentation, please refer to [API Documentation](https://docs ## Related Projects - [One API](https://github.com/songquanpeng/one-api): Original project - [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy): Midjourney interface support -- [chatnio](https://github.com/Deeptrain-Community/chatnio): Next-generation AI one-stop B/C-end solution - [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool): Query usage quota with key Other projects based on New API: diff --git a/README.fr.md b/README.fr.md index 6e4b707e..65dd458d 100644 --- a/README.fr.md +++ b/README.fr.md @@ -208,7 +208,6 @@ Pour une documentation détaillée de l'API, veuillez vous référer à [Documen ## Projets connexes - [One API](https://github.com/songquanpeng/one-api) : Projet original - [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy) : Prise en charge de l'interface Midjourney -- [chatnio](https://github.com/Deeptrain-Community/chatnio) : Solution B/C unique d'IA de nouvelle génération - [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) : Interroger le quota d'utilisation avec une clé Autres projets basés sur New API : From 731e9f4ca905c3f1a611c29aafc69f26410161a3 Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 3 Oct 2025 21:39:28 +0800 Subject: [PATCH 118/243] =?UTF-8?q?=F0=9F=92=B1=20feat(RechargeCard):=20en?= =?UTF-8?q?hance=20currency=20display=20logic=20for=20top-up=20amounts=20b?= =?UTF-8?q?ased=20on=20user=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/topup/RechargeCard.jsx | 91 +++++++++++++++++++++-- 1 file changed, 86 insertions(+), 5 deletions(-) diff --git a/web/src/components/topup/RechargeCard.jsx b/web/src/components/topup/RechargeCard.jsx index 0a299ffa..ee251a0b 100644 --- a/web/src/components/topup/RechargeCard.jsx +++ b/web/src/components/topup/RechargeCard.jsx @@ -338,7 +338,40 @@ const RechargeCard = ({ )} {(enableOnlineTopUp || enableStripeTopUp) && ( - + + {t('选择充值额度')} + {(() => { + const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD'; + if (quotaDisplayType === 'USD') return null; + + const statusStr = localStorage.getItem('status'); + let symbol = '¥'; + let rate = 7; + + try { + if (statusStr) { + const s = JSON.parse(statusStr); + if (quotaDisplayType === 'CNY') { + rate = s?.usd_exchange_rate || 7; + symbol = '¥'; + } else if (quotaDisplayType === 'CUSTOM') { + rate = s?.custom_currency_exchange_rate || 1; + symbol = s?.custom_currency_symbol || '¤'; + } + } + } catch (e) {} + + return ( + + (1 $ = {rate.toFixed(2)} {symbol}) + + ); + })()} +
+ } + >
{presetAmounts.map((preset, index) => { const discount = @@ -351,6 +384,54 @@ const RechargeCard = ({ const actualPay = discountedPrice; const save = originalPrice - discountedPrice; + // 根据当前货币类型换算显示金额和数量 + const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD'; + let symbol = '¥'; + let displayValue = preset.value; // 显示的数量 + let displayActualPay = actualPay; + let displaySave = save; + + // 获取汇率 + const statusStr = localStorage.getItem('status'); + let usdRate = 7; // 默认汇率 1 USD = 7 CNY + try { + if (statusStr) { + const s = JSON.parse(statusStr); + usdRate = s?.usd_exchange_rate || 7; + } + } catch (e) {} + + if (quotaDisplayType === 'USD') { + symbol = '$'; + // 数量和价格都保持美元(系统默认) + displayValue = preset.value; + displayActualPay = actualPay / usdRate; + displaySave = save / usdRate; + } else if (quotaDisplayType === 'CNY') { + symbol = '¥'; + // 数量需要换算为人民币 + displayValue = preset.value * usdRate; + // 价格已经是人民币,保持不变 + displayActualPay = actualPay; + displaySave = save; + } else if (quotaDisplayType === 'CUSTOM') { + let customSymbol = '¤'; + let customRate = 1; + try { + if (statusStr) { + const s = JSON.parse(statusStr); + customSymbol = s?.custom_currency_symbol || '¤'; + customRate = s?.custom_currency_exchange_rate || 1; + } + } catch (e) {} + symbol = customSymbol; + // 数量从USD转为自定义货币 + displayValue = preset.value * customRate; + // 价格从CNY先转USD再转自定义货币 + displayActualPay = (actualPay / usdRate) * customRate; + displaySave = (save / usdRate) * customRate; + } + return ( - {formatLargeNumber(preset.value)} + {formatLargeNumber(preset.value)} $ {hasDiscount && ( {t('折').includes('off') @@ -398,10 +479,10 @@ const RechargeCard = ({ margin: '4px 0', }} > - {t('实付')} {actualPay.toFixed(2)}, + {t('实付')} {symbol}{displayActualPay.toFixed(2)}, {hasDiscount - ? `${t('节省')} ${save.toFixed(2)}` - : `${t('节省')} 0.00`} + ? `${t('节省')} ${symbol}${displaySave.toFixed(2)}` + : `${t('节省')} ${symbol}0.00`}
From 55d19df029f470da25acf77e884dad685fd54856 Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 3 Oct 2025 21:40:07 +0800 Subject: [PATCH 119/243] chore: remove outdated instructions from pull request template --- .github/PULL_REQUEST_TEMPLATE/pull_request_template.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md index 4f6e41ac..7403f6c0 100644 --- a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md +++ b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md @@ -13,7 +13,3 @@ ### PR 描述 **请在下方详细描述您的 PR,包括目的、实现细节等。** - -### **重要提示** - -**所有 PR 都必须提交到 `alpha` 分支。请确保您的 PR 目标分支是 `alpha`。** From 7437b671efb6b994eae3d8d721e3cbe215e5abc9 Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 3 Oct 2025 21:49:24 +0800 Subject: [PATCH 120/243] =?UTF-8?q?=F0=9F=92=B1=20feat:=20implement=20curr?= =?UTF-8?q?ency=20configuration=20helper=20and=20update=20currency=20displ?= =?UTF-8?q?ay=20logic=20in=20RechargeCard=20and=20render=20functions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../table/usage-logs/UsageLogsColumnDefs.jsx | 4 +- web/src/components/topup/RechargeCard.jsx | 71 ++-- web/src/helpers/render.jsx | 304 ++++++++++++------ 3 files changed, 221 insertions(+), 158 deletions(-) diff --git a/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx b/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx index 59ec89d6..9af30226 100644 --- a/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx +++ b/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx @@ -419,7 +419,7 @@ export const getLogsColumns = ({ }, { key: COLUMN_KEYS.PROMPT, - title: t('提示'), + title: t('输入'), dataIndex: 'prompt_tokens', render: (text, record, index) => { return record.type === 0 || record.type === 2 || record.type === 5 ? ( @@ -431,7 +431,7 @@ export const getLogsColumns = ({ }, { key: COLUMN_KEYS.COMPLETION, - title: t('补全'), + title: t('输出'), dataIndex: 'completion_tokens', render: (text, record, index) => { return parseInt(text) > 0 && diff --git a/web/src/components/topup/RechargeCard.jsx b/web/src/components/topup/RechargeCard.jsx index ee251a0b..85a4220e 100644 --- a/web/src/components/topup/RechargeCard.jsx +++ b/web/src/components/topup/RechargeCard.jsx @@ -37,6 +37,7 @@ import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si'; import { CreditCard, Coins, Wallet, BarChart2, TrendingUp } from 'lucide-react'; import { IconGift } from '@douyinfe/semi-icons'; import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime'; +import { getCurrencyConfig } from '../../helpers/render'; const { Text } = Typography; @@ -343,25 +344,8 @@ const RechargeCard = ({
{t('选择充值额度')} {(() => { - const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD'; - if (quotaDisplayType === 'USD') return null; - - const statusStr = localStorage.getItem('status'); - let symbol = '¥'; - let rate = 7; - - try { - if (statusStr) { - const s = JSON.parse(statusStr); - if (quotaDisplayType === 'CNY') { - rate = s?.usd_exchange_rate || 7; - symbol = '¥'; - } else if (quotaDisplayType === 'CUSTOM') { - rate = s?.custom_currency_exchange_rate || 1; - symbol = s?.custom_currency_symbol || '¤'; - } - } - } catch (e) {} + const { symbol, rate, type } = getCurrencyConfig(); + if (type === 'USD') return null; return ( @@ -385,15 +369,9 @@ const RechargeCard = ({ const save = originalPrice - discountedPrice; // 根据当前货币类型换算显示金额和数量 - const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD'; - let symbol = '¥'; - let displayValue = preset.value; // 显示的数量 - let displayActualPay = actualPay; - let displaySave = save; - - // 获取汇率 + const { symbol, rate, type } = getCurrencyConfig(); const statusStr = localStorage.getItem('status'); - let usdRate = 7; // 默认汇率 1 USD = 7 CNY + let usdRate = 7; // 默认CNY汇率 try { if (statusStr) { const s = JSON.parse(statusStr); @@ -401,35 +379,22 @@ const RechargeCard = ({ } } catch (e) {} - if (quotaDisplayType === 'USD') { - symbol = '$'; - // 数量和价格都保持美元(系统默认) - displayValue = preset.value; + let displayValue = preset.value; // 显示的数量 + let displayActualPay = actualPay; + let displaySave = save; + + if (type === 'USD') { + // 数量保持USD,价格从CNY转USD displayActualPay = actualPay / usdRate; displaySave = save / usdRate; - } else if (quotaDisplayType === 'CNY') { - symbol = '¥'; - // 数量需要换算为人民币 + } else if (type === 'CNY') { + // 数量转CNY,价格已是CNY displayValue = preset.value * usdRate; - // 价格已经是人民币,保持不变 - displayActualPay = actualPay; - displaySave = save; - } else if (quotaDisplayType === 'CUSTOM') { - let customSymbol = '¤'; - let customRate = 1; - try { - if (statusStr) { - const s = JSON.parse(statusStr); - customSymbol = s?.custom_currency_symbol || '¤'; - customRate = s?.custom_currency_exchange_rate || 1; - } - } catch (e) {} - symbol = customSymbol; - // 数量从USD转为自定义货币 - displayValue = preset.value * customRate; - // 价格从CNY先转USD再转自定义货币 - displayActualPay = (actualPay / usdRate) * customRate; - displaySave = (save / usdRate) * customRate; + } else if (type === 'CUSTOM') { + // 数量和价格都转自定义货币 + displayValue = preset.value * rate; + displayActualPay = (actualPay / usdRate) * rate; + displaySave = (save / usdRate) * rate; } return ( diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index 6559b1b6..b5fcb4d6 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -922,6 +922,50 @@ export function renderQuotaWithAmount(amount) { return '$' + amount; } +/** + * 获取当前货币配置信息 + * @returns {Object} - { symbol, rate, type } + */ +export function getCurrencyConfig() { + const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD'; + const statusStr = localStorage.getItem('status'); + + let symbol = '$'; + let rate = 1; + + if (quotaDisplayType === 'CNY') { + symbol = '¥'; + try { + if (statusStr) { + const s = JSON.parse(statusStr); + rate = s?.usd_exchange_rate || 7; + } + } catch (e) {} + } else if (quotaDisplayType === 'CUSTOM') { + try { + if (statusStr) { + const s = JSON.parse(statusStr); + symbol = s?.custom_currency_symbol || '¤'; + rate = s?.custom_currency_exchange_rate || 1; + } + } catch (e) {} + } + + return { symbol, rate, type: quotaDisplayType }; +} + +/** + * 将美元金额转换为当前选择的货币 + * @param {number} usdAmount - 美元金额 + * @param {number} digits - 小数位数 + * @returns {string} - 格式化后的货币字符串 + */ +export function convertUSDToCurrency(usdAmount, digits = 2) { + const { symbol, rate } = getCurrencyConfig(); + const convertedAmount = usdAmount * rate; + return symbol + convertedAmount.toFixed(digits); +} + export function renderQuota(quota, digits = 2) { let quotaPerUnit = localStorage.getItem('quota_per_unit'); const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD'; @@ -1084,14 +1128,20 @@ export function renderModelPrice( user_group_ratio, ); groupRatio = effectiveGroupRatio; + + // 获取货币配置 + const { symbol, rate } = getCurrencyConfig(); if (modelPrice !== -1) { + const displayPrice = (modelPrice * rate).toFixed(6); + const displayTotal = (modelPrice * groupRatio * rate).toFixed(6); return i18next.t( - '模型价格:${{price}} * {{ratioType}}:{{ratio}} = ${{total}}', + '模型价格:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}', { - price: modelPrice, + symbol: symbol, + price: displayPrice, ratio: groupRatio, - total: modelPrice * groupRatio, + total: displayTotal, ratioType: ratioLabel, }, ); @@ -1127,19 +1177,21 @@ export function renderModelPrice( <>

- {i18next.t('输入价格:${{price}} / 1M tokens{{audioPrice}}', { - price: inputRatioPrice, + {i18next.t('输入价格:{{symbol}}{{price}} / 1M tokens{{audioPrice}}', { + symbol: symbol, + price: (inputRatioPrice * rate).toFixed(6), audioPrice: audioInputSeperatePrice - ? `,音频 $${audioInputPrice} / 1M tokens` + ? `,音频 ${symbol}${(audioInputPrice * rate).toFixed(6)} / 1M tokens` : '', })}

{i18next.t( - '输出价格:${{price}} * {{completionRatio}} = ${{total}} / 1M tokens (补全倍率: {{completionRatio}})', + '输出价格:{{symbol}}{{price}} * {{completionRatio}} = {{symbol}}{{total}} / 1M tokens (补全倍率: {{completionRatio}})', { - price: inputRatioPrice, - total: completionRatioPrice, + symbol: symbol, + price: (inputRatioPrice * rate).toFixed(6), + total: (completionRatioPrice * rate).toFixed(6), completionRatio: completionRatio, }, )} @@ -1147,10 +1199,11 @@ export function renderModelPrice( {cacheTokens > 0 && (

{i18next.t( - '缓存价格:${{price}} * {{cacheRatio}} = ${{total}} / 1M tokens (缓存倍率: {{cacheRatio}})', + '缓存价格:{{symbol}}{{price}} * {{cacheRatio}} = {{symbol}}{{total}} / 1M tokens (缓存倍率: {{cacheRatio}})', { - price: inputRatioPrice, - total: inputRatioPrice * cacheRatio, + symbol: symbol, + price: (inputRatioPrice * rate).toFixed(6), + total: (inputRatioPrice * cacheRatio * rate).toFixed(6), cacheRatio: cacheRatio, }, )} @@ -1159,11 +1212,12 @@ export function renderModelPrice( {image && imageOutputTokens > 0 && (

{i18next.t( - '图片输入价格:${{price}} * {{ratio}} = ${{total}} / 1M tokens (图片倍率: {{imageRatio}})', + '图片输入价格:{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens (图片倍率: {{imageRatio}})', { - price: imageRatioPrice, + symbol: symbol, + price: (imageRatioPrice * rate).toFixed(6), ratio: groupRatio, - total: imageRatioPrice * groupRatio, + total: (imageRatioPrice * groupRatio * rate).toFixed(6), imageRatio: imageRatio, }, )} @@ -1171,22 +1225,25 @@ export function renderModelPrice( )} {webSearch && webSearchCallCount > 0 && (

- {i18next.t('Web搜索价格:${{price}} / 1K 次', { - price: webSearchPrice, + {i18next.t('Web搜索价格:{{symbol}}{{price}} / 1K 次', { + symbol: symbol, + price: (webSearchPrice * rate).toFixed(6), })}

)} {fileSearch && fileSearchCallCount > 0 && (

- {i18next.t('文件搜索价格:${{price}} / 1K 次', { - price: fileSearchPrice, + {i18next.t('文件搜索价格:{{symbol}}{{price}} / 1K 次', { + symbol: symbol, + price: (fileSearchPrice * rate).toFixed(6), })}

)} {imageGenerationCall && imageGenerationCallPrice > 0 && (

- {i18next.t('图片生成调用:${{price}} / 1次', { - price: imageGenerationCallPrice, + {i18next.t('图片生成调用:{{symbol}}{{price}} / 1次', { + symbol: symbol, + price: (imageGenerationCallPrice * rate).toFixed(6), })}

)} @@ -1196,50 +1253,55 @@ export function renderModelPrice( let inputDesc = ''; if (image && imageOutputTokens > 0) { inputDesc = i18next.t( - '(输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * ${{price}}', + '(输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * {{symbol}}{{price}}', { nonImageInput: inputTokens - imageOutputTokens, imageInput: imageOutputTokens, imageRatio: imageRatio, - price: inputRatioPrice, + symbol: symbol, + price: (inputRatioPrice * rate).toFixed(6), }, ); } else if (cacheTokens > 0) { inputDesc = i18next.t( - '(输入 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}}', + '(输入 {{nonCacheInput}} tokens / 1M tokens * {{symbol}}{{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * {{symbol}}{{cachePrice}}', { nonCacheInput: inputTokens - cacheTokens, cacheInput: cacheTokens, - price: inputRatioPrice, - cachePrice: cacheRatioPrice, + symbol: symbol, + price: (inputRatioPrice * rate).toFixed(6), + cachePrice: (cacheRatioPrice * rate).toFixed(6), }, ); } else if (audioInputSeperatePrice && audioInputTokens > 0) { inputDesc = i18next.t( - '(输入 {{nonAudioInput}} tokens / 1M tokens * ${{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * ${{audioPrice}}', + '(输入 {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}', { nonAudioInput: inputTokens - audioInputTokens, audioInput: audioInputTokens, - price: inputRatioPrice, - audioPrice: audioInputPrice, + symbol: symbol, + price: (inputRatioPrice * rate).toFixed(6), + audioPrice: (audioInputPrice * rate).toFixed(6), }, ); } else { inputDesc = i18next.t( - '(输入 {{input}} tokens / 1M tokens * ${{price}}', + '(输入 {{input}} tokens / 1M tokens * {{symbol}}{{price}}', { input: inputTokens, - price: inputRatioPrice, + symbol: symbol, + price: (inputRatioPrice * rate).toFixed(6), }, ); } // 构建输出部分描述 const outputDesc = i18next.t( - '输出 {{completion}} tokens / 1M tokens * ${{compPrice}}) * {{ratioType}} {{ratio}}', + '输出 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}}) * {{ratioType}} {{ratio}}', { completion: completionTokens, - compPrice: completionRatioPrice, + symbol: symbol, + compPrice: (completionRatioPrice * rate).toFixed(6), ratio: groupRatio, ratioType: ratioLabel, }, @@ -1249,10 +1311,11 @@ export function renderModelPrice( const extraServices = [ webSearch && webSearchCallCount > 0 ? i18next.t( - ' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}', + ' + Web搜索 {{count}}次 / 1K 次 * {{symbol}}{{price}} * {{ratioType}} {{ratio}}', { count: webSearchCallCount, - price: webSearchPrice, + symbol: symbol, + price: (webSearchPrice * rate).toFixed(6), ratio: groupRatio, ratioType: ratioLabel, }, @@ -1260,10 +1323,11 @@ export function renderModelPrice( : '', fileSearch && fileSearchCallCount > 0 ? i18next.t( - ' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}', + ' + 文件搜索 {{count}}次 / 1K 次 * {{symbol}}{{price}} * {{ratioType}} {{ratio}}', { count: fileSearchCallCount, - price: fileSearchPrice, + symbol: symbol, + price: (fileSearchPrice * rate).toFixed(6), ratio: groupRatio, ratioType: ratioLabel, }, @@ -1271,9 +1335,10 @@ export function renderModelPrice( : '', imageGenerationCall && imageGenerationCallPrice > 0 ? i18next.t( - ' + 图片生成调用 ${{price}} / 1次 * {{ratioType}} {{ratio}}', + ' + 图片生成调用 {{symbol}}{{price}} / 1次 * {{ratioType}} {{ratio}}', { - price: imageGenerationCallPrice, + symbol: symbol, + price: (imageGenerationCallPrice * rate).toFixed(6), ratio: groupRatio, ratioType: ratioLabel, }, @@ -1282,12 +1347,13 @@ export function renderModelPrice( ].join(''); return i18next.t( - '{{inputDesc}} + {{outputDesc}}{{extraServices}} = ${{total}}', + '{{inputDesc}} + {{outputDesc}}{{extraServices}} = {{symbol}}{{total}}', { inputDesc, outputDesc, extraServices, - total: price.toFixed(6), + symbol: symbol, + total: (price * rate).toFixed(6), }, ); })()} @@ -1318,10 +1384,14 @@ export function renderLogContent( label: ratioLabel, useUserGroupRatio: useUserGroupRatio, } = getEffectiveRatio(groupRatio, user_group_ratio); + + // 获取货币配置 + const { symbol, rate } = getCurrencyConfig(); if (modelPrice !== -1) { - return i18next.t('模型价格 ${{price}},{{ratioType}} {{ratio}}', { - price: modelPrice, + return i18next.t('模型价格 {{symbol}}{{price}},{{ratioType}} {{ratio}}', { + symbol: symbol, + price: (modelPrice * rate).toFixed(6), ratioType: ratioLabel, ratio, }); @@ -1414,14 +1484,19 @@ export function renderAudioModelPrice( user_group_ratio, ); groupRatio = effectiveGroupRatio; + + // 获取货币配置 + const { symbol, rate } = getCurrencyConfig(); + // 1 ratio = $0.002 / 1K tokens if (modelPrice !== -1) { return i18next.t( - '模型价格:${{price}} * {{ratioType}}:{{ratio}} = ${{total}}', + '模型价格:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}', { - price: modelPrice, + symbol: symbol, + price: (modelPrice * rate).toFixed(6), ratio: groupRatio, - total: modelPrice * groupRatio, + total: (modelPrice * groupRatio * rate).toFixed(6), ratioType: ratioLabel, }, ); @@ -1456,16 +1531,18 @@ export function renderAudioModelPrice( <>

- {i18next.t('提示价格:${{price}} / 1M tokens', { - price: inputRatioPrice, + {i18next.t('提示价格:{{symbol}}{{price}} / 1M tokens', { + symbol: symbol, + price: (inputRatioPrice * rate).toFixed(6), })}

{i18next.t( - '补全价格:${{price}} * {{completionRatio}} = ${{total}} / 1M tokens (补全倍率: {{completionRatio}})', + '补全价格:{{symbol}}{{price}} * {{completionRatio}} = {{symbol}}{{total}} / 1M tokens (补全倍率: {{completionRatio}})', { - price: inputRatioPrice, - total: completionRatioPrice, + symbol: symbol, + price: (inputRatioPrice * rate).toFixed(6), + total: (completionRatioPrice * rate).toFixed(6), completionRatio: completionRatio, }, )} @@ -1473,10 +1550,11 @@ export function renderAudioModelPrice( {cacheTokens > 0 && (

{i18next.t( - '缓存价格:${{price}} * {{cacheRatio}} = ${{total}} / 1M tokens (缓存倍率: {{cacheRatio}})', + '缓存价格:{{symbol}}{{price}} * {{cacheRatio}} = {{symbol}}{{total}} / 1M tokens (缓存倍率: {{cacheRatio}})', { - price: inputRatioPrice, - total: inputRatioPrice * cacheRatio, + symbol: symbol, + price: (inputRatioPrice * rate).toFixed(6), + total: (inputRatioPrice * cacheRatio * rate).toFixed(6), cacheRatio: cacheRatio, }, )} @@ -1484,20 +1562,22 @@ export function renderAudioModelPrice( )}

{i18next.t( - '音频提示价格:${{price}} * {{audioRatio}} = ${{total}} / 1M tokens (音频倍率: {{audioRatio}})', + '音频提示价格:{{symbol}}{{price}} * {{audioRatio}} = {{symbol}}{{total}} / 1M tokens (音频倍率: {{audioRatio}})', { - price: inputRatioPrice, - total: inputRatioPrice * audioRatio, + symbol: symbol, + price: (inputRatioPrice * rate).toFixed(6), + total: (inputRatioPrice * audioRatio * rate).toFixed(6), audioRatio: audioRatio, }, )}

{i18next.t( - '音频补全价格:${{price}} * {{audioRatio}} * {{audioCompRatio}} = ${{total}} / 1M tokens (音频补全倍率: {{audioCompRatio}})', + '音频补全价格:{{symbol}}{{price}} * {{audioRatio}} * {{audioCompRatio}} = {{symbol}}{{total}} / 1M tokens (音频补全倍率: {{audioCompRatio}})', { - price: inputRatioPrice, - total: inputRatioPrice * audioRatio * audioCompletionRatio, + symbol: symbol, + price: (inputRatioPrice * rate).toFixed(6), + total: (inputRatioPrice * audioRatio * audioCompletionRatio * rate).toFixed(6), audioRatio: audioRatio, audioCompRatio: audioCompletionRatio, }, @@ -1506,48 +1586,52 @@ export function renderAudioModelPrice(

{cacheTokens > 0 ? i18next.t( - '文字提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}', + '文字提示 {{nonCacheInput}} tokens / 1M tokens * {{symbol}}{{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * {{symbol}}{{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} = {{symbol}}{{total}}', { nonCacheInput: inputTokens - cacheTokens, cacheInput: cacheTokens, - cachePrice: inputRatioPrice * cacheRatio, - price: inputRatioPrice, + symbol: symbol, + cachePrice: (inputRatioPrice * cacheRatio * rate).toFixed(6), + price: (inputRatioPrice * rate).toFixed(6), completion: completionTokens, - compPrice: completionRatioPrice, - total: textPrice.toFixed(6), + compPrice: (completionRatioPrice * rate).toFixed(6), + total: (textPrice * rate).toFixed(6), }, ) : i18next.t( - '文字提示 {{input}} tokens / 1M tokens * ${{price}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}', + '文字提示 {{input}} tokens / 1M tokens * {{symbol}}{{price}} + 文字补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} = {{symbol}}{{total}}', { input: inputTokens, - price: inputRatioPrice, + symbol: symbol, + price: (inputRatioPrice * rate).toFixed(6), completion: completionTokens, - compPrice: completionRatioPrice, - total: textPrice.toFixed(6), + compPrice: (completionRatioPrice * rate).toFixed(6), + total: (textPrice * rate).toFixed(6), }, )}

{i18next.t( - '音频提示 {{input}} tokens / 1M tokens * ${{audioInputPrice}} + 音频补全 {{completion}} tokens / 1M tokens * ${{audioCompPrice}} = ${{total}}', + '音频提示 {{input}} tokens / 1M tokens * {{symbol}}{{audioInputPrice}} + 音频补全 {{completion}} tokens / 1M tokens * {{symbol}}{{audioCompPrice}} = {{symbol}}{{total}}', { input: audioInputTokens, completion: audioCompletionTokens, - audioInputPrice: audioRatio * inputRatioPrice, + symbol: symbol, + audioInputPrice: (audioRatio * inputRatioPrice * rate).toFixed(6), audioCompPrice: - audioRatio * audioCompletionRatio * inputRatioPrice, - total: audioPrice.toFixed(6), + (audioRatio * audioCompletionRatio * inputRatioPrice * rate).toFixed(6), + total: (audioPrice * rate).toFixed(6), }, )}

{i18next.t( - '总价:文字价格 {{textPrice}} + 音频价格 {{audioPrice}} = ${{total}}', + '总价:文字价格 {{textPrice}} + 音频价格 {{audioPrice}} = {{symbol}}{{total}}', { - total: price.toFixed(6), - textPrice: textPrice.toFixed(6), - audioPrice: audioPrice.toFixed(6), + symbol: symbol, + total: (price * rate).toFixed(6), + textPrice: (textPrice * rate).toFixed(6), + audioPrice: (audioPrice * rate).toFixed(6), }, )}

@@ -1584,15 +1668,19 @@ export function renderClaudeModelPrice( user_group_ratio, ); groupRatio = effectiveGroupRatio; + + // 获取货币配置 + const { symbol, rate } = getCurrencyConfig(); if (modelPrice !== -1) { return i18next.t( - '模型价格:${{price}} * {{ratioType}}:{{ratio}} = ${{total}}', + '模型价格:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}', { - price: modelPrice, + symbol: symbol, + price: (modelPrice * rate).toFixed(6), ratioType: ratioLabel, ratio: groupRatio, - total: modelPrice * groupRatio, + total: (modelPrice * groupRatio * rate).toFixed(6), }, ); } else { @@ -1621,28 +1709,31 @@ export function renderClaudeModelPrice( <>

- {i18next.t('提示价格:${{price}} / 1M tokens', { - price: inputRatioPrice, + {i18next.t('提示价格:{{symbol}}{{price}} / 1M tokens', { + symbol: symbol, + price: (inputRatioPrice * rate).toFixed(6), })}

{i18next.t( - '补全价格:${{price}} * {{ratio}} = ${{total}} / 1M tokens', + '补全价格:{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens', { - price: inputRatioPrice, + symbol: symbol, + price: (inputRatioPrice * rate).toFixed(6), ratio: completionRatio, - total: completionRatioPrice, + total: (completionRatioPrice * rate).toFixed(6), }, )}

{cacheTokens > 0 && (

{i18next.t( - '缓存价格:${{price}} * {{ratio}} = ${{total}} / 1M tokens (缓存倍率: {{cacheRatio}})', + '缓存价格:{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens (缓存倍率: {{cacheRatio}})', { - price: inputRatioPrice, + symbol: symbol, + price: (inputRatioPrice * rate).toFixed(6), ratio: cacheRatio, - total: cacheRatioPrice, + total: (cacheRatioPrice * rate).toFixed(2), cacheRatio: cacheRatio, }, )} @@ -1651,11 +1742,12 @@ export function renderClaudeModelPrice( {cacheCreationTokens > 0 && (

{i18next.t( - '缓存创建价格:${{price}} * {{ratio}} = ${{total}} / 1M tokens (缓存创建倍率: {{cacheCreationRatio}})', + '缓存创建价格:{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens (缓存创建倍率: {{cacheCreationRatio}})', { - price: inputRatioPrice, + symbol: symbol, + price: (inputRatioPrice * rate).toFixed(6), ratio: cacheCreationRatio, - total: cacheCreationRatioPrice, + total: (cacheCreationRatioPrice * rate).toFixed(6), cacheCreationRatio: cacheCreationRatio, }, )} @@ -1665,33 +1757,35 @@ export function renderClaudeModelPrice(

{cacheTokens > 0 || cacheCreationTokens > 0 ? i18next.t( - '提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * ${{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}', + '提示 {{nonCacheInput}} tokens / 1M tokens * {{symbol}}{{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * {{symbol}}{{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * {{symbol}}{{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}', { nonCacheInput: nonCachedTokens, cacheInput: cacheTokens, cacheRatio: cacheRatio, cacheCreationInput: cacheCreationTokens, cacheCreationRatio: cacheCreationRatio, - cachePrice: cacheRatioPrice, - cacheCreationPrice: cacheCreationRatioPrice, - price: inputRatioPrice, + symbol: symbol, + cachePrice: (cacheRatioPrice * rate).toFixed(2), + cacheCreationPrice: (cacheCreationRatioPrice * rate).toFixed(6), + price: (inputRatioPrice * rate).toFixed(6), completion: completionTokens, - compPrice: completionRatioPrice, + compPrice: (completionRatioPrice * rate).toFixed(6), ratio: groupRatio, ratioType: ratioLabel, - total: price.toFixed(6), + total: (price * rate).toFixed(6), }, ) : i18next.t( - '提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}', + '提示 {{input}} tokens / 1M tokens * {{symbol}}{{price}} + 补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}', { input: inputTokens, - price: inputRatioPrice, + symbol: symbol, + price: (inputRatioPrice * rate).toFixed(6), completion: completionTokens, - compPrice: completionRatioPrice, + compPrice: (completionRatioPrice * rate).toFixed(6), ratio: groupRatio, ratioType: ratioLabel, - total: price.toFixed(6), + total: (price * rate).toFixed(6), }, )}

@@ -1716,10 +1810,14 @@ export function renderClaudeLogContent( user_group_ratio, ); groupRatio = effectiveGroupRatio; + + // 获取货币配置 + const { symbol, rate } = getCurrencyConfig(); if (modelPrice !== -1) { - return i18next.t('模型价格 ${{price}},{{ratioType}} {{ratio}}', { - price: modelPrice, + return i18next.t('模型价格 {{symbol}}{{price}},{{ratioType}} {{ratio}}', { + symbol: symbol, + price: (modelPrice * rate).toFixed(6), ratioType: ratioLabel, ratio: groupRatio, }); From 7763f11da7616c56500afdbbc82fa3067ef6b337 Mon Sep 17 00:00:00 2001 From: Moment Date: Sat, 4 Oct 2025 13:02:35 +0800 Subject: [PATCH 121/243] fix: Gemini missing func name for multi-streaming tool calls (except the first). --- relay/channel/gemini/relay-gemini.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index c8e9c757..fa932e51 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -961,9 +961,15 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp * // send first response emptyResponse := helper.GenerateStartEmptyResponse(id, createAt, info.UpstreamModelName, nil) if response.IsToolCall() { - emptyResponse.Choices[0].Delta.ToolCalls = make([]dto.ToolCallResponse, 1) - emptyResponse.Choices[0].Delta.ToolCalls[0] = *response.GetFirstToolCall() - emptyResponse.Choices[0].Delta.ToolCalls[0].Function.Arguments = "" + if len(emptyResponse.Choices) > 0 && len(response.Choices) > 0 { + toolCalls := response.Choices[0].Delta.ToolCalls + copiedToolCalls := make([]dto.ToolCallResponse, len(toolCalls)) + for idx := range toolCalls { + copiedToolCalls[idx] = toolCalls[idx] + copiedToolCalls[idx].Function.Arguments = "" + } + emptyResponse.Choices[0].Delta.ToolCalls = copiedToolCalls + } finishReason = constant.FinishReasonToolCalls err = handleStream(c, info, emptyResponse) if err != nil { From 3da7cebec67b50c392a09fb7b7a3554af53fc2a0 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sun, 5 Oct 2025 15:44:00 +0800 Subject: [PATCH 122/243] =?UTF-8?q?feat:=20=E9=98=B2=E5=91=86=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + electron/main.js | 294 +++++++++++++++++++++++++++++++++++++++++- electron/package.json | 11 +- electron/preload.js | 4 +- 4 files changed, 298 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index 570a4385..bc0b1457 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ logs web/dist .env one-api +new-api .DS_Store tiktoken_cache .eslintcache diff --git a/electron/main.js b/electron/main.js index 9a8534f3..82f954f0 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1,4 +1,4 @@ -const { app, BrowserWindow, dialog, Tray, Menu } = require('electron'); +const { app, BrowserWindow, dialog, Tray, Menu, shell } = require('electron'); const { spawn } = require('child_process'); const path = require('path'); const http = require('http'); @@ -7,8 +7,145 @@ const fs = require('fs'); let mainWindow; let serverProcess; let tray = null; +let serverErrorLogs = []; const PORT = 3000; +// 保存日志到文件并打开 +function saveAndOpenErrorLog() { + try { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const logFileName = `new-api-crash-${timestamp}.log`; + const logDir = app.getPath('logs'); + const logFilePath = path.join(logDir, logFileName); + + // 确保日志目录存在 + if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }); + } + + // 写入日志 + const logContent = `New API 崩溃日志 +生成时间: ${new Date().toLocaleString('zh-CN')} +平台: ${process.platform} +架构: ${process.arch} +应用版本: ${app.getVersion()} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +完整错误日志: + +${serverErrorLogs.join('\n')} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +日志文件位置: ${logFilePath} +`; + + fs.writeFileSync(logFilePath, logContent, 'utf8'); + + // 打开日志文件 + shell.openPath(logFilePath).then((error) => { + if (error) { + console.error('Failed to open log file:', error); + // 如果打开文件失败,至少显示文件位置 + shell.showItemInFolder(logFilePath); + } + }); + + return logFilePath; + } catch (err) { + console.error('Failed to save error log:', err); + return null; + } +} + +// 分析错误日志,识别常见错误并提供解决方案 +function analyzeError(errorLogs) { + const allLogs = errorLogs.join('\n'); + + // 检测端口占用错误 + if (allLogs.includes('failed to start HTTP server') || + allLogs.includes('bind: address already in use') || + allLogs.includes('listen tcp') && allLogs.includes('bind: address already in use')) { + return { + type: '端口被占用', + title: '端口 ' + PORT + ' 被占用', + message: '无法启动服务器,端口已被其他程序占用', + solution: `可能的解决方案:\n\n1. 关闭占用端口 ${PORT} 的其他程序\n2. 检查是否已经运行了另一个 New API 实例\n3. 使用以下命令查找占用端口的进程:\n Mac/Linux: lsof -i :${PORT}\n Windows: netstat -ano | findstr :${PORT}\n4. 重启电脑以释放端口` + }; + } + + // 检测数据库错误 + if (allLogs.includes('database is locked') || + allLogs.includes('unable to open database')) { + return { + type: '数据库错误', + title: '数据库访问失败', + message: '无法访问或锁定数据库文件', + solution: '可能的解决方案:\n\n1. 确保没有其他 New API 实例正在运行\n2. 检查数据库文件权限\n3. 尝试删除数据库锁文件(.db-shm 和 .db-wal)\n4. 重启应用程序' + }; + } + + // 检测权限错误 + if (allLogs.includes('permission denied') || + allLogs.includes('access denied')) { + return { + type: '权限错误', + title: '权限不足', + message: '程序没有足够的权限执行操作', + solution: '可能的解决方案:\n\n1. 以管理员/root权限运行程序\n2. 检查数据目录的读写权限\n3. 检查可执行文件的权限\n4. 在 Mac 上,检查安全性与隐私设置' + }; + } + + // 检测网络错误 + if (allLogs.includes('network is unreachable') || + allLogs.includes('no such host') || + allLogs.includes('connection refused')) { + return { + type: '网络错误', + title: '网络连接失败', + message: '无法建立网络连接', + solution: '可能的解决方案:\n\n1. 检查网络连接是否正常\n2. 检查防火墙设置\n3. 检查代理配置\n4. 确认目标服务器地址正确' + }; + } + + // 检测配置文件错误 + if (allLogs.includes('invalid configuration') || + allLogs.includes('failed to parse config') || + allLogs.includes('yaml') || allLogs.includes('json') && allLogs.includes('parse')) { + return { + type: '配置错误', + title: '配置文件错误', + message: '配置文件格式不正确或包含无效配置', + solution: '可能的解决方案:\n\n1. 检查配置文件格式是否正确\n2. 恢复默认配置\n3. 删除配置文件让程序重新生成\n4. 查看文档了解正确的配置格式' + }; + } + + // 检测内存不足 + if (allLogs.includes('out of memory') || + allLogs.includes('cannot allocate memory')) { + return { + type: '内存不足', + title: '系统内存不足', + message: '程序运行时内存不足', + solution: '可能的解决方案:\n\n1. 关闭其他占用内存的程序\n2. 增加系统可用内存\n3. 重启电脑释放内存\n4. 检查是否存在内存泄漏' + }; + } + + // 检测文件不存在错误 + if (allLogs.includes('no such file or directory') || + allLogs.includes('cannot find the file')) { + return { + type: '文件缺失', + title: '找不到必需的文件', + message: '缺少程序运行所需的文件', + solution: '可能的解决方案:\n\n1. 重新安装应用程序\n2. 检查安装目录是否完整\n3. 确保所有依赖文件都存在\n4. 检查文件路径是否正确' + }; + } + + return null; +} + function getBinaryPath() { const isDev = process.env.NODE_ENV === 'development'; const platform = process.platform; @@ -73,7 +210,13 @@ function startServer() { }); serverProcess.stderr.on('data', (data) => { - console.error(`Server Error: ${data}`); + const errorMsg = data.toString(); + console.error(`Server Error: ${errorMsg}`); + serverErrorLogs.push(errorMsg); + // 只保留最近的100条错误日志 + if (serverErrorLogs.length > 100) { + serverErrorLogs.shift(); + } }); serverProcess.on('error', (err) => { @@ -83,8 +226,76 @@ function startServer() { serverProcess.on('close', (code) => { console.log(`Server process exited with code ${code}`); - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.close(); + + // 如果退出代码不是0,说明服务器异常退出 + if (code !== 0 && code !== null) { + const errorDetails = serverErrorLogs.length > 0 + ? serverErrorLogs.slice(-20).join('\n') + : '没有捕获到错误日志'; + + // 分析错误类型 + const knownError = analyzeError(serverErrorLogs); + + let dialogOptions; + if (knownError) { + // 识别到已知错误,显示友好的错误信息和解决方案 + dialogOptions = { + type: 'error', + title: knownError.title, + message: knownError.message, + detail: `${knownError.solution}\n\n━━━━━━━━━━━━━━━━━━━━━━\n\n退出代码: ${code}\n\n错误类型: ${knownError.type}\n\n最近的错误日志:\n${errorDetails}`, + buttons: ['退出应用', '查看完整日志'], + defaultId: 0, + cancelId: 0 + }; + } else { + // 未识别的错误,显示通用错误信息 + dialogOptions = { + type: 'error', + title: '服务器崩溃', + message: '服务器进程异常退出', + detail: `退出代码: ${code}\n\n最近的错误信息:\n${errorDetails}`, + buttons: ['退出应用', '查看完整日志'], + defaultId: 0, + cancelId: 0 + }; + } + + dialog.showMessageBox(dialogOptions).then((result) => { + if (result.response === 1) { + // 用户选择查看详情,保存并打开日志文件 + const logPath = saveAndOpenErrorLog(); + + // 显示确认对话框 + const confirmMessage = logPath + ? `日志已保存到:\n${logPath}\n\n日志文件已在默认文本编辑器中打开。\n\n点击"退出"关闭应用程序。` + : '日志保存失败,但已在控制台输出。\n\n点击"退出"关闭应用程序。'; + + dialog.showMessageBox({ + type: 'info', + title: '日志已保存', + message: confirmMessage, + buttons: ['退出'], + defaultId: 0 + }).then(() => { + app.isQuitting = true; + app.quit(); + }); + + // 同时在控制台输出 + console.log('=== 完整错误日志 ==='); + console.log(serverErrorLogs.join('\n')); + } else { + // 用户选择直接退出 + app.isQuitting = true; + app.quit(); + } + }); + } else { + // 正常退出(code为0或null),直接关闭窗口 + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.close(); + } } }); @@ -201,8 +412,79 @@ app.whenReady().then(async () => { createWindow(); } catch (err) { console.error('Failed to start application:', err); - dialog.showErrorBox('Startup Error', `Failed to start server: ${err.message}`); - app.quit(); + + // 分析启动失败的错误 + const knownError = analyzeError(serverErrorLogs); + + if (knownError) { + dialog.showMessageBox({ + type: 'error', + title: knownError.title, + message: `启动失败: ${knownError.message}`, + detail: `${knownError.solution}\n\n━━━━━━━━━━━━━━━━━━━━━━\n\n错误信息: ${err.message}\n\n错误类型: ${knownError.type}`, + buttons: ['退出', '查看完整日志'], + defaultId: 0, + cancelId: 0 + }).then((result) => { + if (result.response === 1) { + // 用户选择查看日志 + const logPath = saveAndOpenErrorLog(); + + const confirmMessage = logPath + ? `日志已保存到:\n${logPath}\n\n日志文件已在默认文本编辑器中打开。\n\n点击"退出"关闭应用程序。` + : '日志保存失败,但已在控制台输出。\n\n点击"退出"关闭应用程序。'; + + dialog.showMessageBox({ + type: 'info', + title: '日志已保存', + message: confirmMessage, + buttons: ['退出'], + defaultId: 0 + }).then(() => { + app.quit(); + }); + + console.log('=== 完整错误日志 ==='); + console.log(serverErrorLogs.join('\n')); + } else { + app.quit(); + } + }); + } else { + dialog.showMessageBox({ + type: 'error', + title: '启动失败', + message: '无法启动服务器', + detail: `错误信息: ${err.message}\n\n请检查日志获取更多信息。`, + buttons: ['退出', '查看完整日志'], + defaultId: 0, + cancelId: 0 + }).then((result) => { + if (result.response === 1) { + // 用户选择查看日志 + const logPath = saveAndOpenErrorLog(); + + const confirmMessage = logPath + ? `日志已保存到:\n${logPath}\n\n日志文件已在默认文本编辑器中打开。\n\n点击"退出"关闭应用程序。` + : '日志保存失败,但已在控制台输出。\n\n点击"退出"关闭应用程序。'; + + dialog.showMessageBox({ + type: 'info', + title: '日志已保存', + message: confirmMessage, + buttons: ['退出'], + defaultId: 0 + }).then(() => { + app.quit(); + }); + + console.log('=== 完整错误日志 ==='); + console.log(serverErrorLogs.join('\n')); + } else { + app.quit(); + } + }); + } } }); diff --git a/electron/package.json b/electron/package.json index 9cdf3d12..21c93356 100644 --- a/electron/package.json +++ b/electron/package.json @@ -4,7 +4,8 @@ "description": "New API - AI Model Gateway Desktop Application", "main": "main.js", "scripts": { - "start": "set NODE_ENV=development&& electron .", + "start": "electron .", + "dev": "cross-env NODE_ENV=development electron .", "build": "electron-builder", "build:mac": "electron-builder --mac", "build:win": "electron-builder --win", @@ -17,14 +18,14 @@ "openai", "claude" ], - "author": "", - "license": "MIT", + "author": "QuantumNous", "repository": { "type": "git", - "url": "https://github.com/Calcium-Ion/new-api" + "url": "https://github.com/QuantumNous/new-api" }, "devDependencies": { - "electron": "^28.0.0", + "cross-env": "^7.0.3", + "electron": "28.3.3", "electron-builder": "^24.9.1" }, "build": { diff --git a/electron/preload.js b/electron/preload.js index 6d8b6daa..217f5a41 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -1,6 +1,8 @@ const { contextBridge } = require('electron'); contextBridge.exposeInMainWorld('electron', { + isElectron: true, version: process.versions.electron, - platform: process.platform + platform: process.platform, + versions: process.versions }); \ No newline at end of file From ff77ba11577fba55f746472e7179a320ecb7d911 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sun, 5 Oct 2025 16:45:29 +0800 Subject: [PATCH 123/243] feat: enhance Electron environment detection and improve database warnings --- electron/main.js | 147 +++++++++++++----- electron/package.json | 4 +- electron/preload.js | 22 ++- .../setup/components/steps/DatabaseStep.jsx | 53 +++++-- 4 files changed, 168 insertions(+), 58 deletions(-) diff --git a/electron/main.js b/electron/main.js index 82f954f0..3cebd34f 100644 --- a/electron/main.js +++ b/electron/main.js @@ -9,6 +9,7 @@ let serverProcess; let tray = null; let serverErrorLogs = []; const PORT = 3000; +const DEV_FRONTEND_PORT = 5173; // Vite dev server port // 保存日志到文件并打开 function saveAndOpenErrorLog() { @@ -79,10 +80,10 @@ function analyzeError(errorLogs) { if (allLogs.includes('database is locked') || allLogs.includes('unable to open database')) { return { - type: '数据库错误', - title: '数据库访问失败', - message: '无法访问或锁定数据库文件', - solution: '可能的解决方案:\n\n1. 确保没有其他 New API 实例正在运行\n2. 检查数据库文件权限\n3. 尝试删除数据库锁文件(.db-shm 和 .db-wal)\n4. 重启应用程序' + type: '数据文件被占用', + title: '无法访问数据文件', + message: '应用的数据文件正被其他程序占用', + solution: '可能的解决方案:\n\n1. 检查是否已经打开了另一个 New API 窗口\n - 查看任务栏/Dock 中是否有其他 New API 图标\n - 查看系统托盘(Windows)或菜单栏(Mac)中是否有 New API 图标\n\n2. 如果刚刚关闭过应用,请等待 10 秒后再试\n\n3. 重启电脑以释放被占用的文件\n\n4. 如果问题持续,可以尝试:\n - 退出所有 New API 实例\n - 删除数据目录中的临时文件(.db-shm 和 .db-wal)\n - 重新启动应用' }; } @@ -173,32 +174,101 @@ function getBinaryPath() { return path.join(process.resourcesPath, 'bin', binaryName); } +// Check if a server is available with retry logic +function checkServerAvailability(port, maxRetries = 30, retryDelay = 1000) { + return new Promise((resolve, reject) => { + let currentAttempt = 0; + + const tryConnect = () => { + currentAttempt++; + + if (currentAttempt % 5 === 1 && currentAttempt > 1) { + console.log(`Attempting to connect to port ${port}... (attempt ${currentAttempt}/${maxRetries})`); + } + + const req = http.get({ + hostname: '127.0.0.1', // Use IPv4 explicitly instead of 'localhost' to avoid IPv6 issues + port: port, + timeout: 10000 + }, (res) => { + // Server responded, connection successful + req.destroy(); + console.log(`✓ Successfully connected to port ${port} (status: ${res.statusCode})`); + resolve(); + }); + + req.on('error', (err) => { + if (currentAttempt >= maxRetries) { + reject(new Error(`Failed to connect to port ${port} after ${maxRetries} attempts: ${err.message}`)); + } else { + setTimeout(tryConnect, retryDelay); + } + }); + + req.on('timeout', () => { + req.destroy(); + if (currentAttempt >= maxRetries) { + reject(new Error(`Connection timeout on port ${port} after ${maxRetries} attempts`)); + } else { + setTimeout(tryConnect, retryDelay); + } + }); + }; + + tryConnect(); + }); +} + function startServer() { return new Promise((resolve, reject) => { - const binaryPath = getBinaryPath(); const isDev = process.env.NODE_ENV === 'development'; - - console.log('Starting server from:', binaryPath); - - const env = { ...process.env, PORT: PORT.toString() }; - - let dataDir; + if (isDev) { - dataDir = path.join(__dirname, '..', 'data'); - } else { - const userDataPath = app.getPath('userData'); - dataDir = path.join(userDataPath, 'data'); + // 开发模式:假设开发者手动启动了 Go 后端和前端开发服务器 + // 只需要等待前端开发服务器就绪 + console.log('Development mode: skipping server startup'); + console.log('Please make sure you have started:'); + console.log(' 1. Go backend: go run main.go (port 3000)'); + console.log(' 2. Frontend dev server: cd web && bun dev (port 5173)'); + console.log(''); + console.log('Checking if servers are running...'); + + // First check if both servers are accessible + checkServerAvailability(DEV_FRONTEND_PORT) + .then(() => { + console.log('✓ Frontend dev server is accessible on port 5173'); + resolve(); + }) + .catch((err) => { + console.error(`✗ Cannot connect to frontend dev server on port ${DEV_FRONTEND_PORT}`); + console.error('Please make sure the frontend dev server is running:'); + console.error(' cd web && bun dev'); + reject(err); + }); + return; } + // 生产模式:启动二进制服务器 + const env = { ...process.env, PORT: PORT.toString() }; + const userDataPath = app.getPath('userData'); + const dataDir = path.join(userDataPath, 'data'); + if (!fs.existsSync(dataDir)) { fs.mkdirSync(dataDir, { recursive: true }); } env.SQLITE_PATH = path.join(dataDir, 'new-api.db'); + + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log('📁 您的数据存储位置:'); + console.log(' ' + dataDir); + console.log(' 💡 备份提示:复制此目录即可备份所有数据'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - const workingDir = isDev - ? path.join(__dirname, '..') - : process.resourcesPath; + const binaryPath = getBinaryPath(); + const workingDir = process.resourcesPath; + + console.log('Starting server from:', binaryPath); serverProcess = spawn(binaryPath, [], { env, @@ -299,32 +369,25 @@ function startServer() { } }); - waitForServer(resolve, reject); + checkServerAvailability(PORT) + .then(() => { + console.log('✓ Backend server is accessible on port 3000'); + resolve(); + }) + .catch((err) => { + console.error('✗ Failed to connect to backend server'); + reject(err); + }); }); } -function waitForServer(resolve, reject, retries = 30) { - if (retries === 0) { - reject(new Error('Server failed to start within timeout')); - return; - } - - const req = http.get(`http://localhost:${PORT}`, (res) => { - console.log('Server is ready'); - resolve(); - }); - - req.on('error', () => { - setTimeout(() => waitForServer(resolve, reject, retries - 1), 1000); - }); - - req.end(); -} - function createWindow() { + const isDev = process.env.NODE_ENV === 'development'; + const loadPort = isDev ? DEV_FRONTEND_PORT : PORT; + mainWindow = new BrowserWindow({ - width: 1400, - height: 900, + width: 1080, + height: 720, webPreferences: { preload: path.join(__dirname, 'preload.js'), nodeIntegration: false, @@ -334,9 +397,11 @@ function createWindow() { icon: path.join(__dirname, 'icon.png') }); - mainWindow.loadURL(`http://localhost:${PORT}`); + mainWindow.loadURL(`http://127.0.0.1:${loadPort}`); + + console.log(`Loading from: http://127.0.0.1:${loadPort}`); - if (process.env.NODE_ENV === 'development') { + if (isDev) { mainWindow.webContents.openDevTools(); } diff --git a/electron/package.json b/electron/package.json index 21c93356..4b5880ea 100644 --- a/electron/package.json +++ b/electron/package.json @@ -4,8 +4,8 @@ "description": "New API - AI Model Gateway Desktop Application", "main": "main.js", "scripts": { - "start": "electron .", - "dev": "cross-env NODE_ENV=development electron .", + "start-app": "electron .", + "dev-app": "cross-env NODE_ENV=development electron .", "build": "electron-builder", "build:mac": "electron-builder --mac", "build:win": "electron-builder --win", diff --git a/electron/preload.js b/electron/preload.js index 217f5a41..e089bbb5 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -1,8 +1,28 @@ const { contextBridge } = require('electron'); +// 获取数据目录路径(用于显示给用户) +// 使用字符串拼接而不是 path.join 避免模块依赖问题 +function getDataDirPath() { + const platform = process.platform; + const homeDir = process.env.HOME || process.env.USERPROFILE || ''; + + switch (platform) { + case 'darwin': + return `${homeDir}/Library/Application Support/New API/data`; + case 'win32': + const appData = process.env.APPDATA || `${homeDir}\\AppData\\Roaming`; + return `${appData}\\New API\\data`; + case 'linux': + return `${homeDir}/.config/New API/data`; + default: + return `${homeDir}/.new-api/data`; + } +} + contextBridge.exposeInMainWorld('electron', { isElectron: true, version: process.versions.electron, platform: process.platform, - versions: process.versions + versions: process.versions, + dataDir: getDataDirPath() }); \ No newline at end of file diff --git a/web/src/components/setup/components/steps/DatabaseStep.jsx b/web/src/components/setup/components/steps/DatabaseStep.jsx index 04dd76a3..66923f44 100644 --- a/web/src/components/setup/components/steps/DatabaseStep.jsx +++ b/web/src/components/setup/components/steps/DatabaseStep.jsx @@ -25,29 +25,54 @@ import { Banner } from '@douyinfe/semi-ui'; * 显示当前数据库类型和相关警告信息 */ const DatabaseStep = ({ setupStatus, renderNavigationButtons, t }) => { + // 检测是否在 Electron 环境中运行 + const isElectron = typeof window !== 'undefined' && window.electron?.isElectron; + return ( <> {/* 数据库警告 */} {setupStatus.database_type === 'sqlite' && ( -

- {t( - '您正在使用 SQLite 数据库。如果您在容器环境中运行,请确保已正确设置数据库文件的持久化映射,否则容器重启后所有数据将丢失!', - )} -

-

- + isElectron ? ( +

+

{t( - '建议在生产环境中使用 MySQL 或 PostgreSQL 数据库,或确保 SQLite 数据库文件已映射到宿主机的持久化存储。', + '您的数据将安全地存储在本地计算机上。所有配置、用户信息和使用记录都会自动保存,关闭应用后不会丢失。', )} - -

-
+

+ {window.electron?.dataDir && ( +

+ {t('数据存储位置:')} +
+ + {window.electron.dataDir} + +

+ )} +

+ 💡 {t('提示:如需备份数据,只需复制上述目录即可')} +

+
+ ) : ( +
+

+ {t( + '您正在使用 SQLite 数据库。如果您在容器环境中运行,请确保已正确设置数据库文件的持久化映射,否则容器重启后所有数据将丢失!', + )} +

+

+ + {t( + '建议在生产环境中使用 MySQL 或 PostgreSQL 数据库,或确保 SQLite 数据库文件已映射到宿主机的持久化存储。', + )} + +

+
+ ) } className='!rounded-lg' fullMode={false} From c1137027e657e890b2ec6f1aa98b1b0911011d31 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sun, 5 Oct 2025 17:11:30 +0800 Subject: [PATCH 124/243] chore: update build workflows for Electron and Go, including version tagging and dependency management --- .github/workflows/electron-build.yml | 79 +++++++++++++++++---------- .github/workflows/linux-release.yml | 9 +-- .github/workflows/macos-release.yml | 5 +- .github/workflows/windows-release.yml | 5 +- 4 files changed, 61 insertions(+), 37 deletions(-) diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml index b274db85..c3be05bf 100644 --- a/.github/workflows/electron-build.yml +++ b/.github/workflows/electron-build.yml @@ -3,20 +3,31 @@ name: Build Electron App on: push: tags: - - 'v*.*.*' # Triggers on version tags like v1.0.0 + - '*' # Triggers on version tags like v1.0.0 workflow_dispatch: # Allows manual triggering jobs: build: strategy: matrix: - os: [macos-latest, windows-latest] + # os: [macos-latest, windows-latest] + os: [windows-latest] runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash steps: - name: Checkout code uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest - name: Setup Node.js uses: actions/setup-node@v4 @@ -26,39 +37,49 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '1.21' + go-version: '>=1.18.0' - name: Build frontend + env: + CI: "" + NODE_OPTIONS: "--max-old-space-size=4096" run: | cd web - npm install --legacy-peer-deps - npm run build - env: - DISABLE_ESLINT_PLUGIN: 'true' - NODE_OPTIONS: '--max_old_space_size=4096' + bun install + DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build + cd .. - - name: Build Go binary (macos/Linux) - if: runner.os != 'Windows' - run: | - go build -ldflags="-s -w" -o new-api + # - name: Build Go binary (macos/Linux) + # if: runner.os != 'Windows' + # run: | + # go mod download + # go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o new-api - name: Build Go binary (Windows) if: runner.os == 'Windows' run: | - go build -ldflags="-s -w" -o new-api.exe + go mod download + go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)'" -o new-api.exe + + - name: Update Electron version + run: | + cd electron + VERSION=$(git describe --tags) + VERSION=${VERSION#v} # Remove 'v' prefix if present + npm version $VERSION --no-git-tag-version --allow-same-version - name: Install Electron dependencies run: | cd electron npm install - - name: Build Electron app (macOS) - if: runner.os == 'macOS' - run: | - cd electron - npm run build:mac - env: - CSC_IDENTITY_AUTO_DISCOVERY: false # Skip code signing + # - name: Build Electron app (macOS) + # if: runner.os == 'macOS' + # run: | + # cd electron + # npm run build:mac + # env: + # CSC_IDENTITY_AUTO_DISCOVERY: false # Skip code signing - name: Build Electron app (Windows) if: runner.os == 'Windows' @@ -66,14 +87,14 @@ jobs: cd electron npm run build:win - - name: Upload artifacts (macOS) - if: runner.os == 'macOS' - uses: actions/upload-artifact@v4 - with: - name: macos-build - path: | - electron/dist/*.dmg - electron/dist/*.zip + # - name: Upload artifacts (macOS) + # if: runner.os == 'macOS' + # uses: actions/upload-artifact@v4 + # with: + # name: macos-build + # path: | + # electron/dist/*.dmg + # electron/dist/*.zip - name: Upload artifacts (Windows) if: runner.os == 'Windows' @@ -96,7 +117,7 @@ jobs: uses: softprops/action-gh-release@v1 with: files: | - macos-build/* + # macos-build/* windows-build/* draft: false prerelease: false diff --git a/.github/workflows/linux-release.yml b/.github/workflows/linux-release.yml index 3e3ddc53..d2039dce 100644 --- a/.github/workflows/linux-release.yml +++ b/.github/workflows/linux-release.yml @@ -38,21 +38,22 @@ jobs: - name: Build Backend (amd64) run: | go mod download - go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o new-api + VERSION=$(git describe --tags) + go build -ldflags "-s -w -X 'one-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-$VERSION - name: Build Backend (arm64) run: | sudo apt-get update DEBIAN_FRONTEND=noninteractive sudo apt-get install -y gcc-aarch64-linux-gnu - CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o new-api-arm64 + VERSION=$(git describe --tags) + CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'one-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-arm64-$VERSION - name: Release uses: softprops/action-gh-release@v1 if: startsWith(github.ref, 'refs/tags/') with: files: | - new-api - new-api-arm64 + new-api-* draft: true generate_release_notes: true env: diff --git a/.github/workflows/macos-release.yml b/.github/workflows/macos-release.yml index 8eaf2d67..135e4610 100644 --- a/.github/workflows/macos-release.yml +++ b/.github/workflows/macos-release.yml @@ -39,12 +39,13 @@ jobs: - name: Build Backend run: | go mod download - go build -ldflags "-X 'one-api/common.Version=$(git describe --tags)'" -o new-api-macos + VERSION=$(git describe --tags) + go build -ldflags "-X 'one-api/common.Version=$VERSION'" -o new-api-macos-$VERSION - name: Release uses: softprops/action-gh-release@v1 if: startsWith(github.ref, 'refs/tags/') with: - files: new-api-macos + files: new-api-macos-* draft: true generate_release_notes: true env: diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml index 30e864f3..e6cc456c 100644 --- a/.github/workflows/windows-release.yml +++ b/.github/workflows/windows-release.yml @@ -41,12 +41,13 @@ jobs: - name: Build Backend run: | go mod download - go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)'" -o new-api.exe + VERSION=$(git describe --tags) + go build -ldflags "-s -w -X 'one-api/common.Version=$VERSION'" -o new-api-$VERSION.exe - name: Release uses: softprops/action-gh-release@v1 if: startsWith(github.ref, 'refs/tags/') with: - files: new-api.exe + files: new-api-*.exe draft: true generate_release_notes: true env: From 414be64d33ce5bc3a7114e33a1ac18c057342431 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sun, 5 Oct 2025 17:15:10 +0800 Subject: [PATCH 125/243] fix: correct Windows path handling in preload.js and update .gitignore for consistency --- .gitignore | 3 +- electron/package-lock.json | 3677 ++++++++++++++++++++++++++++++++++++ electron/preload.js | 3 +- 3 files changed, 3680 insertions(+), 3 deletions(-) create mode 100644 electron/package-lock.json diff --git a/.gitignore b/.gitignore index bc0b1457..3e6020e8 100644 --- a/.gitignore +++ b/.gitignore @@ -15,5 +15,4 @@ tiktoken_cache .eslintcache electron/node_modules -electron/dist -electron/package-lock.json \ No newline at end of file +electron/dist \ No newline at end of file diff --git a/electron/package-lock.json b/electron/package-lock.json new file mode 100644 index 00000000..7b6ff058 --- /dev/null +++ b/electron/package-lock.json @@ -0,0 +1,3677 @@ +{ + "name": "new-api-electron", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "new-api-electron", + "version": "1.0.0", + "devDependencies": { + "cross-env": "^7.0.3", + "electron": "28.3.3", + "electron-builder": "^24.9.1" + } + }, + "node_modules/@develar/schema-utils": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", + "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.0", + "ajv-keywords": "^3.4.1" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@electron/notarize": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.2.1.tgz", + "integrity": "sha512-aL+bFMIkpR0cmmj5Zgy0LMKEpgy43/hw5zadEArgmAMWWlKc5buwFvFT9G/o/YJkvXAJm5q3iuTuLaiaXW39sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/notarize/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/notarize/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.0.5.tgz", + "integrity": "sha512-k9ZzUQtamSoweGQDV2jILiRIHUu7lYlJ3c6IEmjv1hC17rclE+eb9U+f6UFlOOETo0JzY1HNlXy4YOlCvl+Lww==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/osx-sign/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/osx-sign/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/universal": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-1.5.1.tgz", + "integrity": "sha512-kbgXxyEauPJiQQUNG2VgUeyfQNFk6hBF11ISN2PNI6agUgPl55pv4eQmaqHzTAzchBvqZ2tQuRVaPStGf0mxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.2.1", + "@malept/cross-spawn-promise": "^1.1.0", + "debug": "^4.3.1", + "dir-compare": "^3.0.0", + "fs-extra": "^9.0.1", + "minimatch": "^3.0.4", + "plist": "^3.0.4" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/universal/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/universal/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz", + "integrity": "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", + "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "18.19.129", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.129.tgz", + "integrity": "sha512-hrmi5jWt2w60ayox3iIXwpMEnfUvOLJCRtrOPbHtH15nTjvO7uhnelvrdAs0dO0/zl5DZ3ZbahiaXEVb54ca/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/plist": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", + "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*", + "xmlbuilder": ">=11.0.1" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/verror": { + "version": "1.10.11", + "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", + "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.11", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", + "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/7zip-bin": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", + "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/app-builder-bin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-4.0.0.tgz", + "integrity": "sha512-xwdG0FJPQMe0M0UA4Tz0zEB8rBJTRA5a476ZawAqiBkMv16GRK5xpXThOjMaEOFnZ6zabejjG4J3da0SXG63KA==", + "dev": true, + "license": "MIT" + }, + "node_modules/app-builder-lib": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-24.13.3.tgz", + "integrity": "sha512-FAzX6IBit2POXYGnTCT8YHFO/lr5AapAII6zzhQO3Rw4cEDOgK+t1xhLc5tNcKlicTHlo9zxIwnYCX9X2DLkig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@develar/schema-utils": "~2.6.5", + "@electron/notarize": "2.2.1", + "@electron/osx-sign": "1.0.5", + "@electron/universal": "1.5.1", + "@malept/flatpak-bundler": "^0.4.0", + "@types/fs-extra": "9.0.13", + "async-exit-hook": "^2.0.1", + "bluebird-lst": "^1.0.9", + "builder-util": "24.13.1", + "builder-util-runtime": "9.2.4", + "chromium-pickle-js": "^0.2.0", + "debug": "^4.3.4", + "ejs": "^3.1.8", + "electron-publish": "24.13.1", + "form-data": "^4.0.0", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "is-ci": "^3.0.0", + "isbinaryfile": "^5.0.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "minimatch": "^5.1.1", + "read-config-file": "6.3.2", + "sanitize-filename": "^1.6.3", + "semver": "^7.3.8", + "tar": "^6.1.12", + "temp-file": "^3.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "24.13.3", + "electron-builder-squirrel-windows": "24.13.3" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/app-builder-lib/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/app-builder-lib/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/bluebird-lst": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/bluebird-lst/-/bluebird-lst-1.0.9.tgz", + "integrity": "sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "^3.5.5" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-1.0.1.tgz", + "integrity": "sha512-QoV3ptgEaQpvVwbXdSO39iqPQTCxSF7A5U99AxbHYqUdCizL/lH2Z0A2y6nbZucxMEOtNyZfG2s6gsVugGpKkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/builder-util": { + "version": "24.13.1", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-24.13.1.tgz", + "integrity": "sha512-NhbCSIntruNDTOVI9fdXz0dihaqX2YuE1D6zZMrwiErzH4ELZHE6mdiB40wEgZNprDia+FghRFgKoAqMZRRjSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "7zip-bin": "~5.2.0", + "app-builder-bin": "4.0.0", + "bluebird-lst": "^1.0.9", + "builder-util-runtime": "9.2.4", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.3", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-ci": "^3.0.0", + "js-yaml": "^4.1.0", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.4.tgz", + "integrity": "sha512-upp+biKpN/XZMLim7aguUyW8s0FUpDvOtK6sbanMFDAMBzpHDqdhgVYm6zc9HJ6nWo7u2Lxk60i2M6Jd3aiNrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/builder-util/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/builder-util/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/builder-util/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-truncate": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", + "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "slice-ansi": "^3.0.0", + "string-width": "^4.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/config-file-ts": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.6.tgz", + "integrity": "sha512-6boGVaglwblBgJqGyxm4+xCmEGcWgnWHSWHY5jad58awQhB6gftq0G8HbzU39YqCIYHMLAiL1yjwiZ36m/CL8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^10.3.10", + "typescript": "^5.3.3" + } + }, + "node_modules/config-file-ts/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/config-file-ts/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/config-file-ts/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/crc": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", + "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.1.0" + } + }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/dir-compare": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-3.3.0.tgz", + "integrity": "sha512-J7/et3WlGUCxjdnD3HAAzQ6nsnc0WL6DD7WcwJb7c39iH1+AWfg+9OqzJNaI6PkBwBvm1mhZNL9iY/nRiZXlPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal": "^1.0.0", + "minimatch": "^3.0.4" + } + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dmg-builder": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-24.13.3.tgz", + "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "24.13.3", + "builder-util": "24.13.1", + "builder-util-runtime": "9.2.4", + "fs-extra": "^10.1.0", + "iconv-lite": "^0.6.2", + "js-yaml": "^4.1.0" + }, + "optionalDependencies": { + "dmg-license": "^1.0.11" + } + }, + "node_modules/dmg-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dmg-builder/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/dmg-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/dmg-license": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", + "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "@types/plist": "^3.0.1", + "@types/verror": "^1.10.3", + "ajv": "^6.10.0", + "crc": "^3.8.0", + "iconv-corefoundation": "^1.1.7", + "plist": "^3.0.4", + "smart-buffer": "^4.0.2", + "verror": "^1.10.0" + }, + "bin": { + "dmg-license": "bin/dmg-license.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-9.0.2.tgz", + "integrity": "sha512-I9OvvrHp4pIARv4+x9iuewrWycX6CcZtoAu1XrzPxc5UygMJXJZYmBsynku8IkrJwgypE5DGNjDPmPRhDCptUg==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=10" + } + }, + "node_modules/dotenv-expand": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz", + "integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "28.3.3", + "resolved": "https://registry.npmjs.org/electron/-/electron-28.3.3.tgz", + "integrity": "sha512-ObKMLSPNhomtCOBAxFS8P2DW/4umkh72ouZUlUKzXGtYuPzgr1SYhskhFWgzAsPtUzhL2CzyV2sfbHcEW4CXqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^18.11.18", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/electron-builder": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-24.13.3.tgz", + "integrity": "sha512-yZSgVHft5dNVlo31qmJAe4BVKQfFdwpRw7sFp1iQglDRCDD6r22zfRJuZlhtB5gp9FHUxCMEoWGq10SkCnMAIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "24.13.3", + "builder-util": "24.13.1", + "builder-util-runtime": "9.2.4", + "chalk": "^4.1.2", + "dmg-builder": "24.13.3", + "fs-extra": "^10.1.0", + "is-ci": "^3.0.0", + "lazy-val": "^1.0.5", + "read-config-file": "6.3.2", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-publish": { + "version": "24.13.1", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-24.13.1.tgz", + "integrity": "sha512-2ZgdEqJ8e9D17Hwp5LEq5mLQPjqU3lv/IALvgp+4W8VeNhryfGhYEQC/PgDPMrnWUp+l60Ou5SJLsu+k4mhQ8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "builder-util": "24.13.1", + "builder-util-runtime": "9.2.4", + "chalk": "^4.1.2", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-publish/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-publish/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-publish/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-agent/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/iconv-corefoundation": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", + "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "cli-truncate": "^2.1.0", + "node-addon-api": "^1.6.3" + }, + "engines": { + "node": "^8.11.2 || >=10" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-ci": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", + "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ci-info": "^3.2.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isbinaryfile": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.6.tgz", + "integrity": "sha512-I+NmIfBHUl+r2wcDd6JwE9yWje/PIVY/R5/CmV8dXLZd5K+L9X2klAOwfAHNnondLXkbHyTAleQAWonpTJBTtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", + "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-config-file": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/read-config-file/-/read-config-file-6.3.2.tgz", + "integrity": "sha512-M80lpCjnE6Wt6zb98DoW8WHR09nzMSpu8XHtPkiTHrJ5Az9CybfeQhTJ8D7saeBHpGhLPIVyA8lcL6ZmdKwY6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-file-ts": "^0.2.4", + "dotenv": "^9.0.2", + "dotenv-expand": "^5.1.0", + "js-yaml": "^4.1.0", + "json5": "^2.2.0", + "lazy-val": "^1.0.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", + "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", + "dev": true, + "license": "ISC" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/slice-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", + "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dev": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/temp-file/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/temp-file/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/temp-file/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + } + } +} diff --git a/electron/preload.js b/electron/preload.js index e089bbb5..b0579172 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -9,9 +9,10 @@ function getDataDirPath() { switch (platform) { case 'darwin': return `${homeDir}/Library/Application Support/New API/data`; - case 'win32': + case 'win32': { const appData = process.env.APPDATA || `${homeDir}\\AppData\\Roaming`; return `${appData}\\New API\\data`; + } case 'linux': return `${homeDir}/.config/New API/data`; default: From 7074ea2ed6ae7ecfc25350e82882fe13af978391 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sun, 5 Oct 2025 17:19:52 +0800 Subject: [PATCH 126/243] chore: upgrade action-gh-release to v2 in build workflows --- .github/workflows/electron-build.yml | 2 +- .github/workflows/linux-release.yml | 2 +- .github/workflows/macos-release.yml | 2 +- .github/workflows/windows-release.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml index c3be05bf..a58ad964 100644 --- a/.github/workflows/electron-build.yml +++ b/.github/workflows/electron-build.yml @@ -114,7 +114,7 @@ jobs: uses: actions/download-artifact@v4 - name: Create Release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 with: files: | # macos-build/* diff --git a/.github/workflows/linux-release.yml b/.github/workflows/linux-release.yml index d2039dce..9e270c2a 100644 --- a/.github/workflows/linux-release.yml +++ b/.github/workflows/linux-release.yml @@ -49,7 +49,7 @@ jobs: CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'one-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-arm64-$VERSION - name: Release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 if: startsWith(github.ref, 'refs/tags/') with: files: | diff --git a/.github/workflows/macos-release.yml b/.github/workflows/macos-release.yml index 135e4610..ecd3a670 100644 --- a/.github/workflows/macos-release.yml +++ b/.github/workflows/macos-release.yml @@ -42,7 +42,7 @@ jobs: VERSION=$(git describe --tags) go build -ldflags "-X 'one-api/common.Version=$VERSION'" -o new-api-macos-$VERSION - name: Release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 if: startsWith(github.ref, 'refs/tags/') with: files: new-api-macos-* diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml index e6cc456c..a08c3648 100644 --- a/.github/workflows/windows-release.yml +++ b/.github/workflows/windows-release.yml @@ -44,7 +44,7 @@ jobs: VERSION=$(git describe --tags) go build -ldflags "-s -w -X 'one-api/common.Version=$VERSION'" -o new-api-$VERSION.exe - name: Release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 if: startsWith(github.ref, 'refs/tags/') with: files: new-api-*.exe From 1154077eea23bc76e4be9e7af31bd123a23d7b0f Mon Sep 17 00:00:00 2001 From: CaIon Date: Sun, 5 Oct 2025 17:31:01 +0800 Subject: [PATCH 127/243] feat: enhance versioning logic in electron-build.yml for semver compliance --- .github/workflows/electron-build.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml index a58ad964..2de54453 100644 --- a/.github/workflows/electron-build.yml +++ b/.github/workflows/electron-build.yml @@ -66,6 +66,29 @@ jobs: cd electron VERSION=$(git describe --tags) VERSION=${VERSION#v} # Remove 'v' prefix if present + # Convert to valid semver: take first 3 components and convert rest to prerelease format + # e.g., 0.9.0.9.1-50-g7074ea2e -> 0.9.0-dev.9.1.50.g7074ea2e + if [[ $VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(.*)$ ]]; then + MAJOR=${BASH_REMATCH[1]} + MINOR=${BASH_REMATCH[2]} + PATCH=${BASH_REMATCH[3]} + REST=${BASH_REMATCH[4]} + + VERSION="$MAJOR.$MINOR.$PATCH" + + # If there's extra content, parse and convert to prerelease format + if [[ -n "$REST" ]]; then + if [[ $REST =~ ^(\..*)?(-[0-9]+-g[0-9a-f]+)$ ]]; then + EXTRA=${BASH_REMATCH[1]} + GIT_SUFFIX=${BASH_REMATCH[2]} + VERSION="$VERSION-dev" + [[ -n "$EXTRA" ]] && VERSION="$VERSION${EXTRA//./.}" + [[ -n "$GIT_SUFFIX" ]] && VERSION="$VERSION${GIT_SUFFIX//-/.}" + else + VERSION="$VERSION-dev${REST//./.}" + fi + fi + fi npm version $VERSION --no-git-tag-version --allow-same-version - name: Install Electron dependencies From 8a56795bd88cc8abef9d42f55f60423d31614003 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sun, 5 Oct 2025 17:50:35 +0800 Subject: [PATCH 128/243] chore: update electron-build.yml to add write permissions and enable file overwriting in artifact uploads --- .github/workflows/electron-build.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml index 2de54453..9e53ff76 100644 --- a/.github/workflows/electron-build.yml +++ b/.github/workflows/electron-build.yml @@ -131,6 +131,8 @@ jobs: needs: build runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/') + permissions: + contents: write steps: - name: Download all artifacts @@ -140,9 +142,9 @@ jobs: uses: softprops/action-gh-release@v2 with: files: | - # macos-build/* windows-build/* draft: false prerelease: false + overwrite_files: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file From 9f44116260f1d3989bd7f49cf5a54b1905691f0b Mon Sep 17 00:00:00 2001 From: CaIon Date: Sun, 5 Oct 2025 18:20:43 +0800 Subject: [PATCH 129/243] fix: update versioning logic in electron-build.yml to correctly handle prerelease formats and modify product name in package.json --- .github/workflows/electron-build.yml | 14 +++----------- electron/package.json | 2 +- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml index 9e53ff76..cae38a0d 100644 --- a/.github/workflows/electron-build.yml +++ b/.github/workflows/electron-build.yml @@ -67,7 +67,7 @@ jobs: VERSION=$(git describe --tags) VERSION=${VERSION#v} # Remove 'v' prefix if present # Convert to valid semver: take first 3 components and convert rest to prerelease format - # e.g., 0.9.0.9.1-50-g7074ea2e -> 0.9.0-dev.9.1.50.g7074ea2e + # e.g., 0.9.3-patch.1 -> 0.9.3-patch.1 if [[ $VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(.*)$ ]]; then MAJOR=${BASH_REMATCH[1]} MINOR=${BASH_REMATCH[2]} @@ -76,17 +76,9 @@ jobs: VERSION="$MAJOR.$MINOR.$PATCH" - # If there's extra content, parse and convert to prerelease format + # If there's extra content, append it without adding -dev if [[ -n "$REST" ]]; then - if [[ $REST =~ ^(\..*)?(-[0-9]+-g[0-9a-f]+)$ ]]; then - EXTRA=${BASH_REMATCH[1]} - GIT_SUFFIX=${BASH_REMATCH[2]} - VERSION="$VERSION-dev" - [[ -n "$EXTRA" ]] && VERSION="$VERSION${EXTRA//./.}" - [[ -n "$GIT_SUFFIX" ]] && VERSION="$VERSION${GIT_SUFFIX//-/.}" - else - VERSION="$VERSION-dev${REST//./.}" - fi + VERSION="$VERSION$REST" fi fi npm version $VERSION --no-git-tag-version --allow-same-version diff --git a/electron/package.json b/electron/package.json index 4b5880ea..d28ef73b 100644 --- a/electron/package.json +++ b/electron/package.json @@ -30,7 +30,7 @@ }, "build": { "appId": "com.newapi.desktop", - "productName": "New API", + "productName": "New-API-App", "publish": null, "directories": { "output": "dist" From 021892b17d1d5a1b2c470a4a79c2eabc4070b9fd Mon Sep 17 00:00:00 2001 From: CaIon Date: Sun, 5 Oct 2025 18:38:25 +0800 Subject: [PATCH 130/243] feat: set data directory path in main process and update preload.js to use it --- electron/main.js | 8 ++++++-- electron/preload.js | 19 ++++--------------- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/electron/main.js b/electron/main.js index 3cebd34f..210a4565 100644 --- a/electron/main.js +++ b/electron/main.js @@ -222,6 +222,12 @@ function checkServerAvailability(port, maxRetries = 30, retryDelay = 1000) { function startServer() { return new Promise((resolve, reject) => { const isDev = process.env.NODE_ENV === 'development'; + + const userDataPath = app.getPath('userData'); + const dataDir = path.join(userDataPath, 'data'); + + // 设置环境变量供 preload.js 使用 + process.env.ELECTRON_DATA_DIR = dataDir; if (isDev) { // 开发模式:假设开发者手动启动了 Go 后端和前端开发服务器 @@ -250,8 +256,6 @@ function startServer() { // 生产模式:启动二进制服务器 const env = { ...process.env, PORT: PORT.toString() }; - const userDataPath = app.getPath('userData'); - const dataDir = path.join(userDataPath, 'data'); if (!fs.existsSync(dataDir)) { fs.mkdirSync(dataDir, { recursive: true }); diff --git a/electron/preload.js b/electron/preload.js index b0579172..ac971fd0 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -1,22 +1,11 @@ const { contextBridge } = require('electron'); // 获取数据目录路径(用于显示给用户) -// 使用字符串拼接而不是 path.join 避免模块依赖问题 +// 优先使用主进程设置的真实路径,如果没有则回退到手动拼接 function getDataDirPath() { - const platform = process.platform; - const homeDir = process.env.HOME || process.env.USERPROFILE || ''; - - switch (platform) { - case 'darwin': - return `${homeDir}/Library/Application Support/New API/data`; - case 'win32': { - const appData = process.env.APPDATA || `${homeDir}\\AppData\\Roaming`; - return `${appData}\\New API\\data`; - } - case 'linux': - return `${homeDir}/.config/New API/data`; - default: - return `${homeDir}/.new-api/data`; + // 如果主进程已设置真实路径,直接使用 + if (process.env.ELECTRON_DATA_DIR) { + return process.env.ELECTRON_DATA_DIR; } } From 74f93d41f3755dc3843d40dee1397599066f046b Mon Sep 17 00:00:00 2001 From: CaIon Date: Sun, 5 Oct 2025 19:33:47 +0800 Subject: [PATCH 131/243] feat: update Gemini API response handling to include block reason and improve error reporting --- dto/gemini.go | 7 ++++--- relay/channel/gemini/relay-gemini.go | 7 ++++++- service/convert.go | 6 ------ types/error.go | 1 + 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/dto/gemini.go b/dto/gemini.go index fdeb2793..feee333c 100644 --- a/dto/gemini.go +++ b/dto/gemini.go @@ -293,12 +293,13 @@ type GeminiChatSafetyRating struct { type GeminiChatPromptFeedback struct { SafetyRatings []GeminiChatSafetyRating `json:"safetyRatings"` + BlockReason *string `json:"blockReason,omitempty"` } type GeminiChatResponse struct { - Candidates []GeminiChatCandidate `json:"candidates"` - PromptFeedback GeminiChatPromptFeedback `json:"promptFeedback"` - UsageMetadata GeminiUsageMetadata `json:"usageMetadata"` + Candidates []GeminiChatCandidate `json:"candidates"` + PromptFeedback *GeminiChatPromptFeedback `json:"promptFeedback,omitempty"` + UsageMetadata GeminiUsageMetadata `json:"usageMetadata"` } type GeminiUsageMetadata struct { diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index fa932e51..a8247217 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -1050,7 +1050,12 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } if len(geminiResponse.Candidates) == 0 { - return nil, types.NewOpenAIError(errors.New("no candidates returned"), types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + //return nil, types.NewOpenAIError(errors.New("no candidates returned"), types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + if geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil { + return nil, types.NewOpenAIError(errors.New("request blocked by Gemini API: "+*geminiResponse.PromptFeedback.BlockReason), types.ErrorCodePromptBlocked, http.StatusBadRequest) + } else { + return nil, types.NewOpenAIError(errors.New("empty response from Gemini API"), types.ErrorCodeEmptyResponse, http.StatusInternalServerError) + } } fullTextResponse := responseGeminiChat2OpenAI(c, &geminiResponse) fullTextResponse.Model = info.UpstreamModelName diff --git a/service/convert.go b/service/convert.go index b232ca39..1a39e537 100644 --- a/service/convert.go +++ b/service/convert.go @@ -636,9 +636,6 @@ func extractTextFromGeminiParts(parts []dto.GeminiPart) string { func ResponseOpenAI2Gemini(openAIResponse *dto.OpenAITextResponse, info *relaycommon.RelayInfo) *dto.GeminiChatResponse { geminiResponse := &dto.GeminiChatResponse{ Candidates: make([]dto.GeminiChatCandidate, 0, len(openAIResponse.Choices)), - PromptFeedback: dto.GeminiChatPromptFeedback{ - SafetyRatings: []dto.GeminiChatSafetyRating{}, - }, UsageMetadata: dto.GeminiUsageMetadata{ PromptTokenCount: openAIResponse.PromptTokens, CandidatesTokenCount: openAIResponse.CompletionTokens, @@ -735,9 +732,6 @@ func StreamResponseOpenAI2Gemini(openAIResponse *dto.ChatCompletionsStreamRespon geminiResponse := &dto.GeminiChatResponse{ Candidates: make([]dto.GeminiChatCandidate, 0, len(openAIResponse.Choices)), - PromptFeedback: dto.GeminiChatPromptFeedback{ - SafetyRatings: []dto.GeminiChatSafetyRating{}, - }, UsageMetadata: dto.GeminiUsageMetadata{ PromptTokenCount: info.PromptTokens, CandidatesTokenCount: 0, // 流式响应中可能没有完整的 usage 信息 diff --git a/types/error.go b/types/error.go index a42e8438..72f26ff3 100644 --- a/types/error.go +++ b/types/error.go @@ -69,6 +69,7 @@ const ( ErrorCodeEmptyResponse ErrorCode = "empty_response" ErrorCodeAwsInvokeError ErrorCode = "aws_invoke_error" ErrorCodeModelNotFound ErrorCode = "model_not_found" + ErrorCodePromptBlocked ErrorCode = "prompt_blocked" // sql error ErrorCodeQueryDataError ErrorCode = "query_data_error" From 98261ec9fa14e2a0b7532ce18c207922636f7e45 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sun, 5 Oct 2025 19:40:31 +0800 Subject: [PATCH 132/243] chore: update README files --- README.en.md | 30 +++++++++++++++--------------- README.fr.md | 30 +++++++++++++++--------------- README.ja.md | 18 ++++++++++-------- README.md | 22 ++++++++++++---------- 4 files changed, 52 insertions(+), 48 deletions(-) diff --git a/README.en.md b/README.en.md index 6648a733..54f4274a 100644 --- a/README.en.md +++ b/README.en.md @@ -89,22 +89,23 @@ New API offers a wide range of features, please refer to [Features Introduction] 10. 🤖 Support for more authorization login methods (LinuxDO, Telegram, OIDC) 11. 🔄 Support for Rerank models (Cohere and Jina), [API Documentation](https://docs.newapi.pro/api/jinaai-rerank) 12. ⚡ Support for OpenAI Realtime API (including Azure channels), [API Documentation](https://docs.newapi.pro/api/openai-realtime) -13. ⚡ Support for Claude Messages format, [API Documentation](https://docs.newapi.pro/api/anthropic-chat) -14. Support for entering chat interface via /chat2link route -15. 🧠 Support for setting reasoning effort through model name suffixes: +13. ⚡ Support for **OpenAI Responses** format, [API Documentation](https://docs.newapi.pro/api/openai-responses) +14. ⚡ Support for **Claude Messages** format, [API Documentation](https://docs.newapi.pro/api/anthropic-chat) +15. ⚡ Support for **Google Gemini** format, [API Documentation](https://docs.newapi.pro/api/google-gemini-chat/) +16. 🧠 Support for setting reasoning effort through model name suffixes: 1. OpenAI o-series models - Add `-high` suffix for high reasoning effort (e.g.: `o3-mini-high`) - Add `-medium` suffix for medium reasoning effort (e.g.: `o3-mini-medium`) - Add `-low` suffix for low reasoning effort (e.g.: `o3-mini-low`) 2. Claude thinking models - Add `-thinking` suffix to enable thinking mode (e.g.: `claude-3-7-sonnet-20250219-thinking`) -16. 🔄 Thinking-to-content functionality -17. 🔄 Model rate limiting for users -18. 🔄 Request format conversion functionality, supporting the following three format conversions: +17. 🔄 Thinking-to-content functionality +18. 🔄 Model rate limiting for users +19. 🔄 Request format conversion functionality, supporting the following three format conversions: 1. OpenAI Chat Completions => Claude Messages 2. Claude Messages => OpenAI Chat Completions (can be used for Claude Code to call third-party models) 3. OpenAI Chat Completions => Gemini Chat -19. 💰 Cache billing support, which allows billing at a set ratio when cache is hit: +20. 💰 Cache billing support, which allows billing at a set ratio when cache is hit: 1. Set the `Prompt Cache Ratio` option in `System Settings-Operation Settings` 2. Set `Prompt Cache Ratio` in the channel, range 0-1, e.g., setting to 0.5 means billing at 50% when cache is hit 3. Supported channels: @@ -134,14 +135,12 @@ For detailed configuration instructions, please refer to [Installation Guide-Env - `GENERATE_DEFAULT_TOKEN`: Whether to generate initial tokens for newly registered users, default is `false` - `STREAMING_TIMEOUT`: Streaming response timeout, default is 300 seconds - `DIFY_DEBUG`: Whether to output workflow and node information for Dify channels, default is `true` -- `FORCE_STREAM_OPTION`: Whether to override client stream_options parameter, default is `true` - `GET_MEDIA_TOKEN`: Whether to count image tokens, default is `true` - `GET_MEDIA_TOKEN_NOT_STREAM`: Whether to count image tokens in non-streaming cases, default is `true` - `UPDATE_TASK`: Whether to update asynchronous tasks (Midjourney, Suno), default is `true` -- `COHERE_SAFETY_SETTING`: Cohere model safety settings, options are `NONE`, `CONTEXTUAL`, `STRICT`, default is `NONE` - `GEMINI_VISION_MAX_IMAGE_NUM`: Maximum number of images for Gemini models, default is `16` - `MAX_FILE_DOWNLOAD_MB`: Maximum file download size in MB, default is `20` -- `CRYPTO_SECRET`: Encryption key used for encrypting database content +- `CRYPTO_SECRET`: Encryption key used for encrypting Redis database content - `AZURE_DEFAULT_API_VERSION`: Azure channel default API version, default is `2025-04-01-preview` - `NOTIFICATION_LIMIT_DURATION_MINUTE`: Notification limit duration, default is `10` minutes - `NOTIFY_LIMIT_COUNT`: Maximum number of user notifications within the specified duration, default is `2` @@ -188,7 +187,7 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234 ``` ## Channel Retry and Cache -Channel retry functionality has been implemented, you can set the number of retries in `Settings->Operation Settings->General Settings`. It is **recommended to enable caching**. +Channel retry functionality has been implemented, you can set the number of retries in `Settings->Operation Settings->General Settings->Failure Retry Count`, **recommended to enable caching** functionality. ### Cache Configuration Method 1. `REDIS_CONN_STRING`: Set Redis as cache @@ -198,10 +197,11 @@ Channel retry functionality has been implemented, you can set the number of retr For detailed API documentation, please refer to [API Documentation](https://docs.newapi.pro/api): -- [Chat API](https://docs.newapi.pro/api/openai-chat) -- [Image API](https://docs.newapi.pro/api/openai-image) -- [Rerank API](https://docs.newapi.pro/api/jinaai-rerank) -- [Realtime API](https://docs.newapi.pro/api/openai-realtime) +- [Chat API (Chat Completions)](https://docs.newapi.pro/api/openai-chat) +- [Response API (Responses)](https://docs.newapi.pro/api/openai-responses) +- [Image API (Image)](https://docs.newapi.pro/api/openai-image) +- [Rerank API (Rerank)](https://docs.newapi.pro/api/jinaai-rerank) +- [Realtime Chat API (Realtime)](https://docs.newapi.pro/api/openai-realtime) - [Claude Chat API](https://docs.newapi.pro/api/anthropic-chat) - [Google Gemini Chat API](https://docs.newapi.pro/api/google-gemini-chat) diff --git a/README.fr.md b/README.fr.md index 65dd458d..b308660c 100644 --- a/README.fr.md +++ b/README.fr.md @@ -89,22 +89,23 @@ New API offre un large éventail de fonctionnalités, veuillez vous référer à 10. 🤖 Prise en charge de plus de méthodes de connexion par autorisation (LinuxDO, Telegram, OIDC) 11. 🔄 Prise en charge des modèles Rerank (Cohere et Jina), [Documentation de l'API](https://docs.newapi.pro/api/jinaai-rerank) 12. ⚡ Prise en charge de l'API OpenAI Realtime (y compris les canaux Azure), [Documentation de l'API](https://docs.newapi.pro/api/openai-realtime) -13. ⚡ Prise en charge du format Claude Messages, [Documentation de l'API](https://docs.newapi.pro/api/anthropic-chat) -14. Prise en charge de l'accès à l'interface de discussion via la route /chat2link -15. 🧠 Prise en charge de la définition de l'effort de raisonnement via les suffixes de nom de modèle : +13. ⚡ Prise en charge du format **OpenAI Responses**, [Documentation de l'API](https://docs.newapi.pro/api/openai-responses) +14. ⚡ Prise en charge du format **Claude Messages**, [Documentation de l'API](https://docs.newapi.pro/api/anthropic-chat) +15. ⚡ Prise en charge du format **Google Gemini**, [Documentation de l'API](https://docs.newapi.pro/api/google-gemini-chat/) +16. 🧠 Prise en charge de la définition de l'effort de raisonnement via les suffixes de nom de modèle : 1. Modèles de la série o d'OpenAI - Ajouter le suffixe `-high` pour un effort de raisonnement élevé (par exemple : `o3-mini-high`) - Ajouter le suffixe `-medium` pour un effort de raisonnement moyen (par exemple : `o3-mini-medium`) - Ajouter le suffixe `-low` pour un effort de raisonnement faible (par exemple : `o3-mini-low`) 2. Modèles de pensée de Claude - Ajouter le suffixe `-thinking` pour activer le mode de pensée (par exemple : `claude-3-7-sonnet-20250219-thinking`) -16. 🔄 Fonctionnalité de la pensée au contenu -17. 🔄 Limitation du débit du modèle pour les utilisateurs -18. 🔄 Fonctionnalité de conversion de format de requête, prenant en charge les trois conversions de format suivantes : +17. 🔄 Fonctionnalité de la pensée au contenu +18. 🔄 Limitation du débit du modèle pour les utilisateurs +19. 🔄 Fonctionnalité de conversion de format de requête, prenant en charge les trois conversions de format suivantes : 1. OpenAI Chat Completions => Claude Messages 2. Claude Messages => OpenAI Chat Completions (peut être utilisé pour Claude Code pour appeler des modèles tiers) 3. OpenAI Chat Completions => Gemini Chat -19. 💰 Prise en charge de la facturation du cache, qui permet de facturer à un ratio défini lorsque le cache est atteint : +20. 💰 Prise en charge de la facturation du cache, qui permet de facturer à un ratio défini lorsque le cache est atteint : 1. Définir l'option `Ratio de cache d'invite` dans `Paramètres système->Paramètres de fonctionnement` 2. Définir le `Ratio de cache d'invite` dans le canal, plage de 0 à 1, par exemple, le définir sur 0,5 signifie facturer à 50 % lorsque le cache est atteint 3. Canaux pris en charge : @@ -134,14 +135,12 @@ Pour des instructions de configuration détaillées, veuillez vous référer à - `GENERATE_DEFAULT_TOKEN` : S'il faut générer des jetons initiaux pour les utilisateurs nouvellement enregistrés, la valeur par défaut est `false` - `STREAMING_TIMEOUT` : Délai d'expiration de la réponse en streaming, la valeur par défaut est de 300 secondes - `DIFY_DEBUG` : S'il faut afficher les informations sur le flux de travail et les nœuds pour les canaux Dify, la valeur par défaut est `true` -- `FORCE_STREAM_OPTION` : S'il faut remplacer le paramètre client stream_options, la valeur par défaut est `true` - `GET_MEDIA_TOKEN` : S'il faut compter les jetons d'image, la valeur par défaut est `true` - `GET_MEDIA_TOKEN_NOT_STREAM` : S'il faut compter les jetons d'image dans les cas sans streaming, la valeur par défaut est `true` - `UPDATE_TASK` : S'il faut mettre à jour les tâches asynchrones (Midjourney, Suno), la valeur par défaut est `true` -- `COHERE_SAFETY_SETTING` : Paramètres de sécurité du modèle Cohere, les options sont `NONE`, `CONTEXTUAL`, `STRICT`, la valeur par défaut est `NONE` - `GEMINI_VISION_MAX_IMAGE_NUM` : Nombre maximum d'images pour les modèles Gemini, la valeur par défaut est `16` - `MAX_FILE_DOWNLOAD_MB` : Taille maximale de téléchargement de fichier en Mo, la valeur par défaut est `20` -- `CRYPTO_SECRET` : Clé de chiffrement utilisée pour chiffrer le contenu de la base de données +- `CRYPTO_SECRET` : Clé de chiffrement utilisée pour chiffrer le contenu de la base de données Redis - `AZURE_DEFAULT_API_VERSION` : Version de l'API par défaut du canal Azure, la valeur par défaut est `2025-04-01-preview` - `NOTIFICATION_LIMIT_DURATION_MINUTE` : Durée de la limite de notification, la valeur par défaut est de `10` minutes - `NOTIFY_LIMIT_COUNT` : Nombre maximal de notifications utilisateur dans la durée spécifiée, la valeur par défaut est `2` @@ -188,7 +187,7 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234 ``` ## Nouvelle tentative de canal et cache -La fonctionnalité de nouvelle tentative de canal a été implémentée, vous pouvez définir le nombre de tentatives dans `Paramètres->Paramètres de fonctionnement->Paramètres généraux`. Il est **recommandé d'activer la mise en cache**. +La fonctionnalité de nouvelle tentative de canal a été implémentée, vous pouvez définir le nombre de tentatives dans `Paramètres->Paramètres de fonctionnement->Paramètres généraux->Nombre de tentatives en cas d'échec`, **recommandé d'activer la fonctionnalité de mise en cache**. ### Méthode de configuration du cache 1. `REDIS_CONN_STRING` : Définir Redis comme cache @@ -198,10 +197,11 @@ La fonctionnalité de nouvelle tentative de canal a été implémentée, vous po Pour une documentation détaillée de l'API, veuillez vous référer à [Documentation de l'API](https://docs.newapi.pro/api) : -- [API de discussion](https://docs.newapi.pro/api/openai-chat) -- [API d'image](https://docs.newapi.pro/api/openai-image) -- [API de rerank](https://docs.newapi.pro/api/jinaai-rerank) -- [API en temps réel](https://docs.newapi.pro/api/openai-realtime) +- [API de discussion (Chat Completions)](https://docs.newapi.pro/api/openai-chat) +- [API de réponse (Responses)](https://docs.newapi.pro/api/openai-responses) +- [API d'image (Image)](https://docs.newapi.pro/api/openai-image) +- [API de rerank (Rerank)](https://docs.newapi.pro/api/jinaai-rerank) +- [API de discussion en temps réel (Realtime)](https://docs.newapi.pro/api/openai-realtime) - [API de discussion Claude](https://docs.newapi.pro/api/anthropic-chat) - [API de discussion Google Gemini](https://docs.newapi.pro/api/google-gemini-chat) diff --git a/README.ja.md b/README.ja.md index 13049e86..315518c9 100644 --- a/README.ja.md +++ b/README.ja.md @@ -89,22 +89,23 @@ New APIは豊富な機能を提供しています。詳細な機能について 10. 🤖 より多くの認証ログイン方法をサポート(LinuxDO、Telegram、OIDC) 11. 🔄 Rerankモデルをサポート(CohereとJina)、[API ドキュメント](https://docs.newapi.pro/api/jinaai-rerank) 12. ⚡ OpenAI Realtime APIをサポート(Azureチャネルを含む)、[APIドキュメント](https://docs.newapi.pro/api/openai-realtime) -13. ⚡ Claude Messages形式をサポート、[APIドキュメント](https://docs.newapi.pro/api/anthropic-chat) -14. /chat2linkルートを使用してチャット画面に入ることをサポート -15. 🧠 モデル名のサフィックスを通じてreasoning effortを設定することをサポート: +13. ⚡ **OpenAI Responses**形式をサポート、[APIドキュメント](https://docs.newapi.pro/api/openai-responses) +14. ⚡ **Claude Messages**形式をサポート、[APIドキュメント](https://docs.newapi.pro/api/anthropic-chat) +15. ⚡ **Google Gemini**形式をサポート、[APIドキュメント](https://docs.newapi.pro/api/google-gemini-chat/) +16. 🧠 モデル名のサフィックスを通じてreasoning effortを設定することをサポート: 1. OpenAI oシリーズモデル - `-high`サフィックスを追加してhigh reasoning effortに設定(例:`o3-mini-high`) - `-medium`サフィックスを追加してmedium reasoning effortに設定(例:`o3-mini-medium`) - `-low`サフィックスを追加してlow reasoning effortに設定(例:`o3-mini-low`) 2. Claude思考モデル - `-thinking`サフィックスを追加して思考モードを有効にする(例:`claude-3-7-sonnet-20250219-thinking`) -16. 🔄 思考からコンテンツへの機能 -17. 🔄 ユーザーに対するモデルレート制限機能 -18. 🔄 リクエストフォーマット変換機能、以下の3つのフォーマット変換をサポート: +17. 🔄 思考からコンテンツへの機能 +18. 🔄 ユーザーに対するモデルレート制限機能 +19. 🔄 リクエストフォーマット変換機能、以下の3つのフォーマット変換をサポート: 1. OpenAI Chat Completions => Claude Messages 2. Claude Messages => OpenAI Chat Completions(Claude Codeがサードパーティモデルを呼び出す際に使用可能) 3. OpenAI Chat Completions => Gemini Chat -19. 💰 キャッシュ課金サポート、有効にするとキャッシュがヒットした際に設定された比率で課金できます: +20. 💰 キャッシュ課金サポート、有効にするとキャッシュがヒットした際に設定された比率で課金できます: 1. `システム設定-運営設定`で`プロンプトキャッシュ倍率`オプションを設定 2. チャネルで`プロンプトキャッシュ倍率`を設定、範囲は0-1、例えば0.5に設定するとキャッシュがヒットした際に50%で課金 3. サポートされているチャネル: @@ -196,7 +197,8 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234 詳細なAPIドキュメントについては[APIドキュメント](https://docs.newapi.pro/api)を参照してください: -- [チャットインターフェース(Chat)](https://docs.newapi.pro/api/openai-chat) +- [チャットインターフェース(Chat Completions)](https://docs.newapi.pro/api/openai-chat) +- [レスポンスインターフェース(Responses)](https://docs.newapi.pro/api/openai-responses) - [画像インターフェース(Image)](https://docs.newapi.pro/api/openai-image) - [再ランク付けインターフェース(Rerank)](https://docs.newapi.pro/api/jinaai-rerank) - [リアルタイム対話インターフェース(Realtime)](https://docs.newapi.pro/api/openai-realtime) diff --git a/README.md b/README.md index af2b64b4..feeff86f 100644 --- a/README.md +++ b/README.md @@ -85,22 +85,23 @@ New API提供了丰富的功能,详细特性请参考[特性说明](https://do 10. 🤖 支持更多授权登陆方式(LinuxDO,Telegram、OIDC) 11. 🔄 支持Rerank模型(Cohere和Jina),[接口文档](https://docs.newapi.pro/api/jinaai-rerank) 12. ⚡ 支持OpenAI Realtime API(包括Azure渠道),[接口文档](https://docs.newapi.pro/api/openai-realtime) -13. ⚡ 支持Claude Messages 格式,[接口文档](https://docs.newapi.pro/api/anthropic-chat) -14. 支持使用路由/chat2link进入聊天界面 -15. 🧠 支持通过模型名称后缀设置 reasoning effort: +13. ⚡ 支持 **OpenAI Responses** 格式,[接口文档](https://docs.newapi.pro/api/openai-responses) +14. ⚡ 支持 **Claude Messages** 格式,[接口文档](https://docs.newapi.pro/api/anthropic-chat) +15. ⚡ 支持 **Google Gemini** 格式,[接口文档](https://docs.newapi.pro/api/google-gemini-chat/) +16. 🧠 支持通过模型名称后缀设置 reasoning effort: 1. OpenAI o系列模型 - 添加后缀 `-high` 设置为 high reasoning effort (例如: `o3-mini-high`) - 添加后缀 `-medium` 设置为 medium reasoning effort (例如: `o3-mini-medium`) - 添加后缀 `-low` 设置为 low reasoning effort (例如: `o3-mini-low`) 2. Claude 思考模型 - 添加后缀 `-thinking` 启用思考模式 (例如: `claude-3-7-sonnet-20250219-thinking`) -16. 🔄 思考转内容功能 -17. 🔄 针对用户的模型限流功能 -18. 🔄 请求格式转换功能,支持以下三种格式转换: - 1. OpenAI Chat Completions => Claude Messages +17. 🔄 思考转内容功能 +18. 🔄 针对用户的模型限流功能 +19. 🔄 请求格式转换功能,支持以下三种格式转换: + 1. OpenAI Chat Completions => Claude Messages (OpenAI格式调用Claude模型) 2. Clade Messages => OpenAI Chat Completions (可用于Claude Code调用第三方模型) - 3. OpenAI Chat Completions => Gemini Chat -19. 💰 缓存计费支持,开启后可以在缓存命中时按照设定的比例计费: + 3. OpenAI Chat Completions => Gemini Chat (OpenAI格式调用Gemini模型) +20. 💰 缓存计费支持,开启后可以在缓存命中时按照设定的比例计费: 1. 在 `系统设置-运营设置` 中设置 `提示缓存倍率` 选项 2. 在渠道中设置 `提示缓存倍率`,范围 0-1,例如设置为 0.5 表示缓存命中时按照 50% 计费 3. 支持的渠道: @@ -192,7 +193,8 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234 详细接口文档请参考[接口文档](https://docs.newapi.pro/api): -- [聊天接口(Chat)](https://docs.newapi.pro/api/openai-chat) +- [聊天接口(Chat Completions)](https://docs.newapi.pro/api/openai-chat) +- [响应接口 (Responses)](https://docs.newapi.pro/api/openai-responses) - [图像接口(Image)](https://docs.newapi.pro/api/openai-image) - [重排序接口(Rerank)](https://docs.newapi.pro/api/jinaai-rerank) - [实时对话接口(Realtime)](https://docs.newapi.pro/api/openai-realtime) From 407da544fe6927f3d728d42f9f858ca67174600c Mon Sep 17 00:00:00 2001 From: CaIon Date: Sun, 5 Oct 2025 22:51:28 +0800 Subject: [PATCH 133/243] feat: enhance SettingsLog component with confirmation modal for log deletion and improve user feedback --- .../pages/Setting/Operation/SettingsLog.jsx | 96 +++++++++++++++---- 1 file changed, 76 insertions(+), 20 deletions(-) diff --git a/web/src/pages/Setting/Operation/SettingsLog.jsx b/web/src/pages/Setting/Operation/SettingsLog.jsx index dcd17081..067e968b 100644 --- a/web/src/pages/Setting/Operation/SettingsLog.jsx +++ b/web/src/pages/Setting/Operation/SettingsLog.jsx @@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com */ import React, { useEffect, useState, useRef } from 'react'; -import { Button, Col, Form, Row, Spin, DatePicker } from '@douyinfe/semi-ui'; +import { Button, Col, Form, Row, Spin, DatePicker, Typography, Modal } from '@douyinfe/semi-ui'; import dayjs from 'dayjs'; import { useTranslation } from 'react-i18next'; import { @@ -29,6 +29,8 @@ import { showWarning, } from '../../../helpers'; +const { Text } = Typography; + export default function SettingsLog(props) { const { t } = useTranslation(); const [loading, setLoading] = useState(false); @@ -78,24 +80,75 @@ export default function SettingsLog(props) { }); } async function onCleanHistoryLog() { - try { - setLoadingCleanHistoryLog(true); - if (!inputs.historyTimestamp) throw new Error(t('请选择日志记录时间')); - const res = await API.delete( - `/api/log/?target_timestamp=${Date.parse(inputs.historyTimestamp) / 1000}`, - ); - const { success, message, data } = res.data; - if (success) { - showSuccess(`${data} ${t('条日志已清理!')}`); - return; - } else { - throw new Error(t('日志清理失败:') + message); - } - } catch (error) { - showError(error.message); - } finally { - setLoadingCleanHistoryLog(false); + if (!inputs.historyTimestamp) { + showError(t('请选择日志记录时间')); + return; } + + const now = dayjs(); + const targetDate = dayjs(inputs.historyTimestamp); + const targetTime = targetDate.format('YYYY-MM-DD HH:mm:ss'); + const currentTime = now.format('YYYY-MM-DD HH:mm:ss'); + const daysDiff = now.diff(targetDate, 'day'); + + Modal.confirm({ + title: t('确认清除历史日志'), + content: ( +
+

+ {t('当前时间')}: + {currentTime} +

+

+ {t('选择时间')}: + {targetTime} + {daysDiff > 0 && ( + ({t('约')} {daysDiff} {t('天前')}) + )} +

+
+ ⚠️ {t('注意')}: + {t('将删除')} + {targetTime} + {daysDiff > 0 && ( + ({t('约')} {daysDiff} {t('天前')}) + )} + {t('之前的所有日志')} +
+

+ {t('此操作不可恢复,请仔细确认时间后再操作!')} +

+
+ ), + okText: t('确认删除'), + cancelText: t('取消'), + okType: 'danger', + onOk: async () => { + try { + setLoadingCleanHistoryLog(true); + const res = await API.delete( + `/api/log/?target_timestamp=${Date.parse(inputs.historyTimestamp) / 1000}`, + ); + const { success, message, data } = res.data; + if (success) { + showSuccess(`${data} ${t('条日志已清理!')}`); + return; + } else { + throw new Error(t('日志清理失败:') + message); + } + } catch (error) { + showError(error.message); + } finally { + setLoadingCleanHistoryLog(false); + } + }, + }); } useEffect(() => { @@ -138,7 +191,7 @@ export default function SettingsLog(props) {
- From bb0ed4dddf1b898f34d0be4fa828ee40524d53a7 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sun, 5 Oct 2025 23:11:45 +0800 Subject: [PATCH 134/243] feat: implement user preferences management in SettingsLog component for improved customization --- .github/workflows/sync-to-gitee.yml | 91 +++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 .github/workflows/sync-to-gitee.yml diff --git a/.github/workflows/sync-to-gitee.yml b/.github/workflows/sync-to-gitee.yml new file mode 100644 index 00000000..a5cd1a29 --- /dev/null +++ b/.github/workflows/sync-to-gitee.yml @@ -0,0 +1,91 @@ +name: Sync Release to Gitee + +permissions: + contents: read + +on: + workflow_dispatch: + inputs: + tag_name: + description: 'Release Tag to sync (e.g. v1.0.0)' + required: true + type: string + +# 配置你的 Gitee 仓库信息 +env: + GITEE_OWNER: 'QuantumNous' # 修改为你的 Gitee 用户名 + GITEE_REPO: 'new-api' # 修改为你的 Gitee 仓库名 + +jobs: + sync-to-gitee: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Get Release Info + id: release_info + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG_NAME: ${{ github.event.inputs.tag_name }} + run: | + # 获取 release 信息 + RELEASE_INFO=$(gh release view "$TAG_NAME" --json name,body,tagName,targetCommitish) + + RELEASE_NAME=$(echo "$RELEASE_INFO" | jq -r '.name') + TARGET_COMMITISH=$(echo "$RELEASE_INFO" | jq -r '.targetCommitish') + + # 使用多行字符串输出 + { + echo "release_name=$RELEASE_NAME" + echo "target_commitish=$TARGET_COMMITISH" + echo "release_body<> $GITHUB_OUTPUT + + # 下载 release 的所有附件 + gh release download "$TAG_NAME" --dir ./release_assets || echo "No assets to download" + + # 列出下载的文件 + ls -la ./release_assets/ || echo "No assets directory" + + - name: Create Gitee Release + id: create_release + uses: nICEnnnnnnnLee/action-gitee-release@v2.0.0 + with: + gitee_action: create_release + gitee_owner: ${{ env.GITEE_OWNER }} + gitee_repo: ${{ env.GITEE_REPO }} + gitee_token: ${{ secrets.GITEE_TOKEN }} + gitee_tag_name: ${{ github.event.inputs.tag_name }} + gitee_release_name: ${{ steps.release_info.outputs.release_name }} + gitee_release_body: ${{ steps.release_info.outputs.release_body }} + gitee_target_commitish: ${{ steps.release_info.outputs.target_commitish }} + + - name: Upload Assets to Gitee + if: hashFiles('release_assets/*') != '' + uses: nICEnnnnnnnLee/action-gitee-release@v2.0.0 + with: + gitee_action: upload_asset + gitee_owner: ${{ env.GITEE_OWNER }} + gitee_repo: ${{ env.GITEE_REPO }} + gitee_token: ${{ secrets.GITEE_TOKEN }} + gitee_release_id: ${{ steps.create_release.outputs.release-id }} + gitee_upload_retry_times: 3 + gitee_files: | + release_assets/* + + - name: Cleanup + if: always() + run: | + rm -rf release_assets/ + + - name: Summary + if: success() + run: | + echo "✅ Successfully synced release ${{ github.event.inputs.tag_name }} to Gitee!" + echo "🔗 Gitee Release URL: https://gitee.com/${{ env.GITEE_OWNER }}/${{ env.GITEE_REPO }}/releases/tag/${{ github.event.inputs.tag_name }}" + From ca9ee54fba02a1a1671778cf9cde7b9c18b7c43a Mon Sep 17 00:00:00 2001 From: RedwindA Date: Sun, 5 Oct 2025 23:39:20 +0800 Subject: [PATCH 135/243] fix: improve text visibility in warning box for dark mode in SettingsLog --- .../pages/Setting/Operation/SettingsLog.jsx | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/web/src/pages/Setting/Operation/SettingsLog.jsx b/web/src/pages/Setting/Operation/SettingsLog.jsx index 067e968b..a1f5a49f 100644 --- a/web/src/pages/Setting/Operation/SettingsLog.jsx +++ b/web/src/pages/Setting/Operation/SettingsLog.jsx @@ -106,20 +106,21 @@ export default function SettingsLog(props) { ({t('约')} {daysDiff} {t('天前')}) )}

-
- ⚠️ {t('注意')}: - {t('将删除')} - {targetTime} + ⚠️ {t('注意')}: + {t('将删除')} + {targetTime} {daysDiff > 0 && ( - ({t('约')} {daysDiff} {t('天前')}) + ({t('约')} {daysDiff} {t('天前')}) )} - {t('之前的所有日志')} + {t('之前的所有日志')}

{t('此操作不可恢复,请仔细确认时间后再操作!')} From c24608730bff157f50f89f17ff46839148f039f5 Mon Sep 17 00:00:00 2001 From: CaIon Date: Mon, 6 Oct 2025 14:33:48 +0800 Subject: [PATCH 136/243] CI --- .github/workflows/sync-to-gitee.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sync-to-gitee.yml b/.github/workflows/sync-to-gitee.yml index a5cd1a29..4f515a18 100644 --- a/.github/workflows/sync-to-gitee.yml +++ b/.github/workflows/sync-to-gitee.yml @@ -18,7 +18,7 @@ env: jobs: sync-to-gitee: - runs-on: ubuntu-latest + runs-on: sync steps: - name: Checkout uses: actions/checkout@v3 From 6ef95c97cc0b8f3a115e8bfddcd6d556575f4088 Mon Sep 17 00:00:00 2001 From: "Apple\\Apple" Date: Tue, 7 Oct 2025 00:22:45 +0800 Subject: [PATCH 137/243] =?UTF-8?q?=E2=9C=A8=20feat:=20Add=20topup=20billi?= =?UTF-8?q?ng=20history=20with=20admin=20manual=20completion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement comprehensive topup billing system with user history viewing and admin management capabilities. ## Features Added ### Frontend - Add topup history modal with paginated billing records - Display order details: trade number, payment method, amount, money, status, create time - Implement empty state with proper illustrations - Add payment method column with localized display (Stripe, Alipay, WeChat) - Add admin manual completion feature for pending orders - Add Coins icon for recharge amount display - Integrate "Bills" button in RechargeCard header - Optimize code quality by using shared utility functions (isAdmin) - Extract constants for status and payment method mappings - Use React.useMemo for performance optimization ### Backend - Create GET `/api/user/topup/self` endpoint for user topup history with pagination - Create POST `/api/user/topup/complete` endpoint for admin manual order completion - Add `payment_method` field to TopUp model for tracking payment types - Implement `GetUserTopUps` method with proper pagination and ordering - Implement `ManualCompleteTopUp` with transaction safety and row-level locking - Add application-level mutex locks to prevent concurrent order processing - Record payment method in Epay and Stripe payment flows - Ensure idempotency and data consistency with proper error handling ### Internationalization - Add i18n keys for Chinese (zh), English (en), and French (fr) - Support for billing-related UI text and status messages ## Technical Improvements - Use database transactions with FOR UPDATE row-level locking - Implement sync.Map-based mutex for order-level concurrency control - Proper error handling and user-friendly toast notifications - Follow existing codebase patterns for empty states and modals - Maintain code quality with extracted render functions and constants ## Files Changed - Backend: controller/topup.go, controller/topup_stripe.go, model/topup.go, router/api-router.go - Frontend: web/src/components/topup/modals/TopupHistoryModal.jsx (new), web/src/components/topup/RechargeCard.jsx, web/src/components/topup/index.jsx - i18n: web/src/i18n/locales/{zh,en,fr}.json --- controller/topup.go | 51 +- controller/topup_stripe.go | 13 +- model/topup.go | 124 +- router/api-router.go | 2 + web/src/components/auth/LoginForm.jsx | 20 +- .../common/examples/ChannelKeyViewExample.jsx | 10 +- .../common/modals/SecureVerificationModal.jsx | 143 +- .../components/settings/PersonalSetting.jsx | 13 +- web/src/components/settings/SystemSetting.jsx | 50 +- .../personal/cards/AccountManagement.jsx | 10 +- .../personal/cards/NotificationSettings.jsx | 8 +- .../setup/components/steps/DatabaseStep.jsx | 5 +- .../channels/modals/EditChannelModal.jsx | 2353 +++++++++-------- .../table/channels/modals/EditTagModal.jsx | 15 +- .../table/channels/modals/ModelTestModal.jsx | 18 +- .../table/users/UsersColumnDefs.jsx | 12 +- .../table/users/modals/ResetPasskeyModal.jsx | 5 +- .../table/users/modals/ResetTwoFAModal.jsx | 9 +- web/src/components/topup/RechargeCard.jsx | 56 +- web/src/components/topup/index.jsx | 20 + .../topup/modals/TopupHistoryModal.jsx | 253 ++ web/src/constants/channel.constants.js | 2 +- web/src/helpers/passkey.js | 58 +- web/src/helpers/render.jsx | 206 +- web/src/helpers/secureApiCall.js | 6 +- web/src/hooks/channels/useChannelsData.jsx | 2 +- .../hooks/common/useSecureVerification.jsx | 230 +- web/src/hooks/users/useUsersData.jsx | 2 +- web/src/i18n/locales/en.json | 17 +- web/src/i18n/locales/fr.json | 15 +- web/src/i18n/locales/zh.json | 19 +- web/src/pages/Setting/Chat/SettingsChats.jsx | 2 +- .../pages/Setting/Operation/SettingsLog.jsx | 73 +- web/src/services/secureVerification.js | 103 +- 34 files changed, 2354 insertions(+), 1571 deletions(-) create mode 100644 web/src/components/topup/modals/TopupHistoryModal.jsx diff --git a/controller/topup.go b/controller/topup.go index 7e2cadf1..e6f6eef2 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -183,12 +183,13 @@ func RequestEpay(c *gin.Context) { amount = dAmount.Div(dQuotaPerUnit).IntPart() } topUp := &model.TopUp{ - UserId: id, - Amount: amount, - Money: payMoney, - TradeNo: tradeNo, - CreateTime: time.Now().Unix(), - Status: "pending", + UserId: id, + Amount: amount, + Money: payMoney, + TradeNo: tradeNo, + PaymentMethod: req.PaymentMethod, + CreateTime: time.Now().Unix(), + Status: "pending", } err = topUp.Insert() if err != nil { @@ -313,3 +314,41 @@ func RequestAmount(c *gin.Context) { } c.JSON(200, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)}) } + +func GetUserTopUps(c *gin.Context) { + userId := c.GetInt("id") + pageInfo := common.GetPageQuery(c) + + topups, total, err := model.GetUserTopUps(userId, pageInfo) + if err != nil { + common.ApiError(c, err) + return + } + + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(topups) + common.ApiSuccess(c, pageInfo) +} + +type AdminCompleteTopupRequest struct { + TradeNo string `json:"trade_no"` +} + +// AdminCompleteTopUp 管理员补单接口 +func AdminCompleteTopUp(c *gin.Context) { + var req AdminCompleteTopupRequest + if err := c.ShouldBindJSON(&req); err != nil || req.TradeNo == "" { + common.ApiErrorMsg(c, "参数错误") + return + } + + // 订单级互斥,防止并发补单 + LockOrder(req.TradeNo) + defer UnlockOrder(req.TradeNo) + + if err := model.ManualCompleteTopUp(req.TradeNo); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, nil) +} diff --git a/controller/topup_stripe.go b/controller/topup_stripe.go index 628a3fea..a4bdf064 100644 --- a/controller/topup_stripe.go +++ b/controller/topup_stripe.go @@ -83,12 +83,13 @@ func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) { } topUp := &model.TopUp{ - UserId: id, - Amount: req.Amount, - Money: chargedMoney, - TradeNo: referenceId, - CreateTime: time.Now().Unix(), - Status: common.TopUpStatusPending, + UserId: id, + Amount: req.Amount, + Money: chargedMoney, + TradeNo: referenceId, + PaymentMethod: PaymentMethodStripe, + CreateTime: time.Now().Unix(), + Status: common.TopUpStatusPending, } err = topUp.Insert() if err != nil { diff --git a/model/topup.go b/model/topup.go index 802c866f..c280db43 100644 --- a/model/topup.go +++ b/model/topup.go @@ -6,18 +6,20 @@ import ( "one-api/common" "one-api/logger" + "github.com/shopspring/decimal" "gorm.io/gorm" ) type TopUp struct { - Id int `json:"id"` - UserId int `json:"user_id" gorm:"index"` - Amount int64 `json:"amount"` - Money float64 `json:"money"` - TradeNo string `json:"trade_no" gorm:"unique;type:varchar(255);index"` - CreateTime int64 `json:"create_time"` - CompleteTime int64 `json:"complete_time"` - Status string `json:"status"` + Id int `json:"id"` + UserId int `json:"user_id" gorm:"index"` + Amount int64 `json:"amount"` + Money float64 `json:"money"` + TradeNo string `json:"trade_no" gorm:"unique;type:varchar(255);index"` + PaymentMethod string `json:"payment_method" gorm:"type:varchar(50)"` + CreateTime int64 `json:"create_time"` + CompleteTime int64 `json:"complete_time"` + Status string `json:"status"` } func (topUp *TopUp) Insert() error { @@ -99,3 +101,109 @@ func Recharge(referenceId string, customerId string) (err error) { return nil } + +func GetUserTopUps(userId int, pageInfo *common.PageInfo) (topups []*TopUp, total int64, err error) { + // Start transaction + tx := DB.Begin() + if tx.Error != nil { + return nil, 0, tx.Error + } + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + // Get total count within transaction + err = tx.Model(&TopUp{}).Where("user_id = ?", userId).Count(&total).Error + if err != nil { + tx.Rollback() + return nil, 0, err + } + + // Get paginated topups within same transaction + err = tx.Where("user_id = ?", userId).Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&topups).Error + if err != nil { + tx.Rollback() + return nil, 0, err + } + + // Commit transaction + if err = tx.Commit().Error; err != nil { + return nil, 0, err + } + + return topups, total, nil +} + +// ManualCompleteTopUp 管理员手动完成订单并给用户充值 +func ManualCompleteTopUp(tradeNo string) error { + if tradeNo == "" { + return errors.New("未提供订单号") + } + + refCol := "`trade_no`" + if common.UsingPostgreSQL { + refCol = `"trade_no"` + } + + var userId int + var quotaToAdd int + var payMoney float64 + + err := DB.Transaction(func(tx *gorm.DB) error { + topUp := &TopUp{} + // 行级锁,避免并发补单 + if err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(topUp).Error; err != nil { + return errors.New("充值订单不存在") + } + + // 幂等处理:已成功直接返回 + if topUp.Status == common.TopUpStatusSuccess { + return nil + } + + if topUp.Status != common.TopUpStatusPending { + return errors.New("订单状态不是待支付,无法补单") + } + + // 计算应充值额度: + // - Stripe 订单:Money 代表经分组倍率换算后的美元数量,直接 * QuotaPerUnit + // - 其他订单(如易支付):Amount 为美元数量,* QuotaPerUnit + if topUp.PaymentMethod == "stripe" { + dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) + quotaToAdd = int(decimal.NewFromFloat(topUp.Money).Mul(dQuotaPerUnit).IntPart()) + } else { + dAmount := decimal.NewFromInt(topUp.Amount) + dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) + quotaToAdd = int(dAmount.Mul(dQuotaPerUnit).IntPart()) + } + if quotaToAdd <= 0 { + return errors.New("无效的充值额度") + } + + // 标记完成 + topUp.CompleteTime = common.GetTimestamp() + topUp.Status = common.TopUpStatusSuccess + if err := tx.Save(topUp).Error; err != nil { + return err + } + + // 增加用户额度(立即写库,保持一致性) + if err := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error; err != nil { + return err + } + + userId = topUp.UserId + payMoney = topUp.Money + return nil + }) + + if err != nil { + return err + } + + // 事务外记录日志,避免阻塞 + RecordLog(userId, LogTypeTopup, fmt.Sprintf("管理员补单成功,充值金额: %v,支付金额:%f", logger.FormatQuota(quotaToAdd), payMoney)) + return nil +} diff --git a/router/api-router.go b/router/api-router.go index d2961591..49a42360 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -73,12 +73,14 @@ func SetApiRouter(router *gin.Engine) { selfRoute.DELETE("/passkey", controller.PasskeyDelete) selfRoute.GET("/aff", controller.GetAffCode) selfRoute.GET("/topup/info", controller.GetTopUpInfo) + selfRoute.GET("/topup/self", controller.GetUserTopUps) selfRoute.POST("/topup", middleware.CriticalRateLimit(), controller.TopUp) selfRoute.POST("/pay", middleware.CriticalRateLimit(), controller.RequestEpay) selfRoute.POST("/amount", controller.RequestAmount) selfRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.RequestStripePay) selfRoute.POST("/stripe/amount", controller.RequestStripeAmount) selfRoute.POST("/aff_transfer", controller.TransferAffQuota) + selfRoute.POST("/topup/complete", middleware.AdminAuth(), controller.AdminCompleteTopUp) selfRoute.PUT("/setting", controller.UpdateUserSetting) // 2FA routes diff --git a/web/src/components/auth/LoginForm.jsx b/web/src/components/auth/LoginForm.jsx index 828e7178..fea4e39b 100644 --- a/web/src/components/auth/LoginForm.jsx +++ b/web/src/components/auth/LoginForm.jsx @@ -42,7 +42,12 @@ import Title from '@douyinfe/semi-ui/lib/es/typography/title'; import Text from '@douyinfe/semi-ui/lib/es/typography/text'; import TelegramLoginButton from 'react-telegram-login'; -import { IconGithubLogo, IconMail, IconLock, IconKey } from '@douyinfe/semi-icons'; +import { + IconGithubLogo, + IconMail, + IconLock, + IconKey, +} from '@douyinfe/semi-icons'; import OIDCIcon from '../common/logo/OIDCIcon'; import WeChatIcon from '../common/logo/WeChatIcon'; import LinuxDoIcon from '../common/logo/LinuxDoIcon'; @@ -296,15 +301,22 @@ const LoginForm = () => { return; } - const publicKeyOptions = prepareCredentialRequestOptions(data?.options || data?.publicKey || data); - const assertion = await navigator.credentials.get({ publicKey: publicKeyOptions }); + const publicKeyOptions = prepareCredentialRequestOptions( + data?.options || data?.publicKey || data, + ); + const assertion = await navigator.credentials.get({ + publicKey: publicKeyOptions, + }); const payload = buildAssertionResult(assertion); if (!payload) { showError('Passkey 验证失败,请重试'); return; } - const finishRes = await API.post('/api/user/passkey/login/finish', payload); + const finishRes = await API.post( + '/api/user/passkey/login/finish', + payload, + ); const finish = finishRes.data; if (finish.success) { userDispatch({ type: 'login', payload: finish.data }); diff --git a/web/src/components/common/examples/ChannelKeyViewExample.jsx b/web/src/components/common/examples/ChannelKeyViewExample.jsx index cd387752..1bb2998b 100644 --- a/web/src/components/common/examples/ChannelKeyViewExample.jsx +++ b/web/src/components/common/examples/ChannelKeyViewExample.jsx @@ -58,7 +58,7 @@ const ChannelKeyViewExample = ({ channelId }) => { // 开始查看密钥流程 const handleViewKey = async () => { const apiCall = createApiCalls.viewChannelKey(channelId); - + await startVerification(apiCall, { title: t('查看渠道密钥'), description: t('为了保护账户安全,请验证您的身份。'), @@ -69,11 +69,7 @@ const ChannelKeyViewExample = ({ channelId }) => { return ( <> {/* 查看密钥按钮 */} - @@ -114,4 +110,4 @@ const ChannelKeyViewExample = ({ channelId }) => { ); }; -export default ChannelKeyViewExample; \ No newline at end of file +export default ChannelKeyViewExample; diff --git a/web/src/components/common/modals/SecureVerificationModal.jsx b/web/src/components/common/modals/SecureVerificationModal.jsx index 06f18c7e..6c61c291 100644 --- a/web/src/components/common/modals/SecureVerificationModal.jsx +++ b/web/src/components/common/modals/SecureVerificationModal.jsx @@ -19,7 +19,16 @@ For commercial licensing, please contact support@quantumnous.com import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Modal, Button, Input, Typography, Tabs, TabPane, Space, Spin } from '@douyinfe/semi-ui'; +import { + Modal, + Button, + Input, + Typography, + Tabs, + TabPane, + Space, + Spin, +} from '@douyinfe/semi-ui'; /** * 通用安全验证模态框组件 @@ -78,9 +87,7 @@ const SecureVerificationModal = ({ title={title || t('安全验证')} visible={visible} onCancel={onCancel} - footer={ - - } + footer={} width={500} style={{ maxWidth: '90vw' }} > @@ -123,21 +130,21 @@ const SecureVerificationModal = ({ width={460} centered style={{ - maxWidth: 'calc(100vw - 32px)' + maxWidth: 'calc(100vw - 32px)', }} bodyStyle={{ - padding: '20px 24px' + padding: '20px 24px', }} >

{/* 描述信息 */} {description && ( {description} @@ -153,10 +160,7 @@ const SecureVerificationModal = ({ style={{ margin: 0 }} > {has2FA && ( - +
- + + } style={{ width: '100%' }} @@ -178,24 +195,26 @@ const SecureVerificationModal = ({
{t('从认证器应用中获取验证码,或使用备用码')} -
+
@@ -214,31 +233,47 @@ const SecureVerificationModal = ({ )} {hasPasskey && passkeySupported && ( - +
-
-
- - +
+
+ +
- + {t('使用 Passkey 验证')} {t('点击验证按钮,使用您的生物特征或安全密钥')}
-
+
@@ -282,4 +319,4 @@ const SecureVerificationModal = ({ ); }; -export default SecureVerificationModal; \ No newline at end of file +export default SecureVerificationModal; diff --git a/web/src/components/settings/PersonalSetting.jsx b/web/src/components/settings/PersonalSetting.jsx index c9934604..18d37480 100644 --- a/web/src/components/settings/PersonalSetting.jsx +++ b/web/src/components/settings/PersonalSetting.jsx @@ -155,9 +155,7 @@ const PersonalSetting = () => { gotifyUrl: settings.gotify_url || '', gotifyToken: settings.gotify_token || '', gotifyPriority: - settings.gotify_priority !== undefined - ? settings.gotify_priority - : 5, + settings.gotify_priority !== undefined ? settings.gotify_priority : 5, acceptUnsetModelRatioModel: settings.accept_unset_model_ratio_model || false, recordIpLog: settings.record_ip_log || false, @@ -214,7 +212,9 @@ const PersonalSetting = () => { return; } - const publicKey = prepareCredentialCreationOptions(data?.options || data?.publicKey || data); + const publicKey = prepareCredentialCreationOptions( + data?.options || data?.publicKey || data, + ); const credential = await navigator.credentials.create({ publicKey }); const payload = buildRegistrationResult(credential); if (!payload) { @@ -222,7 +222,10 @@ const PersonalSetting = () => { return; } - const finishRes = await API.post('/api/user/passkey/register/finish', payload); + const finishRes = await API.post( + '/api/user/passkey/register/finish', + payload, + ); if (finishRes.data.success) { showSuccess(t('Passkey 注册成功')); await loadPasskeyStatus(); diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx index 780e89fb..2f0b892f 100644 --- a/web/src/components/settings/SystemSetting.jsx +++ b/web/src/components/settings/SystemSetting.jsx @@ -615,7 +615,10 @@ const SystemSetting = () => { options.push({ key: 'passkey.rp_display_name', - value: formValues['passkey.rp_display_name'] || inputs['passkey.rp_display_name'] || '', + value: + formValues['passkey.rp_display_name'] || + inputs['passkey.rp_display_name'] || + '', }); options.push({ key: 'passkey.rp_id', @@ -623,11 +626,17 @@ const SystemSetting = () => { }); options.push({ key: 'passkey.user_verification', - value: formValues['passkey.user_verification'] || inputs['passkey.user_verification'] || 'preferred', + value: + formValues['passkey.user_verification'] || + inputs['passkey.user_verification'] || + 'preferred', }); options.push({ key: 'passkey.attachment_preference', - value: formValues['passkey.attachment_preference'] || inputs['passkey.attachment_preference'] || '', + value: + formValues['passkey.attachment_preference'] || + inputs['passkey.attachment_preference'] || + '', }); options.push({ key: 'passkey.origins', @@ -1044,7 +1053,9 @@ const SystemSetting = () => { {t('用以支持基于 WebAuthn 的无密码登录注册')} { field="['passkey.rp_display_name']" label={t('服务显示名称')} placeholder={t('默认使用系统名称')} - extraText={t('用户注册时看到的网站名称,比如\'我的网站\'')} + extraText={t( + "用户注册时看到的网站名称,比如'我的网站'", + )} />
@@ -1078,7 +1091,9 @@ const SystemSetting = () => { field="['passkey.rp_id']" label={t('网站域名标识')} placeholder={t('例如:example.com')} - extraText={t('留空则默认使用服务器地址,注意不能携带http://或者https://')} + extraText={t( + '留空则默认使用服务器地址,注意不能携带http://或者https://', + )} /> @@ -1092,7 +1107,10 @@ const SystemSetting = () => { label={t('安全验证级别')} placeholder={t('是否要求指纹/面容等生物识别')} optionList={[ - { label: t('推荐使用(用户可选)'), value: 'preferred' }, + { + label: t('推荐使用(用户可选)'), + value: 'preferred', + }, { label: t('强制要求'), value: 'required' }, { label: t('不建议使用'), value: 'discouraged' }, ]} @@ -1109,7 +1127,9 @@ const SystemSetting = () => { { label: t('本设备内置'), value: 'platform' }, { label: t('外接设备'), value: 'cross-platform' }, ]} - extraText={t('本设备:手机指纹/面容,外接:USB安全密钥')} + extraText={t( + '本设备:手机指纹/面容,外接:USB安全密钥', + )} /> @@ -1123,7 +1143,10 @@ const SystemSetting = () => { noLabel extraText={t('仅用于开发环境,生产环境应使用 HTTPS')} onChange={(e) => - handleCheckboxChange('passkey.allow_insecure_origin', e) + handleCheckboxChange( + 'passkey.allow_insecure_origin', + e, + ) } > {t('允许不安全的 Origin(HTTP)')} @@ -1139,11 +1162,16 @@ const SystemSetting = () => { field="['passkey.origins']" label={t('允许的 Origins')} placeholder={t('填写带https的域名,逗号分隔')} - extraText={t('为空则默认使用服务器地址,多个 Origin 用逗号分隔,例如 https://newapi.pro,https://newapi.com ,注意不能携带[],需使用https')} + extraText={t( + '为空则默认使用服务器地址,多个 Origin 用逗号分隔,例如 https://newapi.pro,https://newapi.com ,注意不能携带[],需使用https', + )} /> - diff --git a/web/src/components/settings/personal/cards/AccountManagement.jsx b/web/src/components/settings/personal/cards/AccountManagement.jsx index ac2146c2..d54edb93 100644 --- a/web/src/components/settings/personal/cards/AccountManagement.jsx +++ b/web/src/components/settings/personal/cards/AccountManagement.jsx @@ -535,7 +535,9 @@ const AccountManagement = ({ ? () => { Modal.confirm({ title: t('确认解绑 Passkey'), - content: t('解绑后将无法使用 Passkey 登录,确定要继续吗?'), + content: t( + '解绑后将无法使用 Passkey 登录,确定要继续吗?', + ), okText: t('确认解绑'), cancelText: t('取消'), okType: 'danger', @@ -547,7 +549,11 @@ const AccountManagement = ({ className={`w-full sm:w-auto ${passkeyEnabled ? '!bg-slate-500 hover:!bg-slate-600' : ''}`} icon={} disabled={!passkeySupported && !passkeyEnabled} - loading={passkeyEnabled ? passkeyDeleteLoading : passkeyRegisterLoading} + loading={ + passkeyEnabled + ? passkeyDeleteLoading + : passkeyRegisterLoading + } > {passkeyEnabled ? t('解绑 Passkey') : t('注册 Passkey')} diff --git a/web/src/components/settings/personal/cards/NotificationSettings.jsx b/web/src/components/settings/personal/cards/NotificationSettings.jsx index 0c99e285..c19084a5 100644 --- a/web/src/components/settings/personal/cards/NotificationSettings.jsx +++ b/web/src/components/settings/personal/cards/NotificationSettings.jsx @@ -621,7 +621,9 @@ const NotificationSettings = ({ }, { pattern: /^https?:\/\/.+/, - message: t('Gotify服务器地址必须以http://或https://开头'), + message: t( + 'Gotify服务器地址必须以http://或https://开头', + ), }, ]} /> @@ -678,9 +680,7 @@ const NotificationSettings = ({ '复制应用的令牌(Token)并填写到上方的应用令牌字段', )} -
- 3. {t('填写Gotify服务器的完整URL地址')} -
+
3. {t('填写Gotify服务器的完整URL地址')}
{t('更多信息请参考')} diff --git a/web/src/components/setup/components/steps/DatabaseStep.jsx b/web/src/components/setup/components/steps/DatabaseStep.jsx index 66923f44..d8d1d4f9 100644 --- a/web/src/components/setup/components/steps/DatabaseStep.jsx +++ b/web/src/components/setup/components/steps/DatabaseStep.jsx @@ -26,8 +26,9 @@ import { Banner } from '@douyinfe/semi-ui'; */ const DatabaseStep = ({ setupStatus, renderNavigationButtons, t }) => { // 检测是否在 Electron 环境中运行 - const isElectron = typeof window !== 'undefined' && window.electron?.isElectron; - + const isElectron = + typeof window !== 'undefined' && window.electron?.isElectron; + return ( <> {/* 数据库警告 */} diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index d5d29996..cce63340 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -157,7 +157,7 @@ const EditChannelModal = (props) => { is_enterprise_account: false, // 字段透传控制默认值 allow_service_tier: false, - disable_store: false, // false = 允许透传(默认开启) + disable_store: false, // false = 允许透传(默认开启) allow_safety_identifier: false, }; const [batch, setBatch] = useState(false); @@ -206,7 +206,13 @@ const EditChannelModal = (props) => { channelExtraSettings: null, }); const [currentSectionIndex, setCurrentSectionIndex] = useState(0); - const formSections = ['basicInfo', 'apiConfig', 'modelConfig', 'advancedSettings', 'channelExtraSettings']; + const formSections = [ + 'basicInfo', + 'apiConfig', + 'modelConfig', + 'advancedSettings', + 'channelExtraSettings', + ]; const formContainerRef = useRef(null); // 2FA状态更新辅助函数 @@ -266,13 +272,13 @@ const EditChannelModal = (props) => { sectionElement.scrollIntoView({ behavior: 'smooth', block: 'start', - inline: 'nearest' + inline: 'nearest', }); } }; const navigateToSection = (direction) => { - const availableSections = formSections.filter(section => { + const availableSections = formSections.filter((section) => { if (section === 'apiConfig') { return showApiConfigCard; } @@ -281,9 +287,15 @@ const EditChannelModal = (props) => { let newIndex; if (direction === 'up') { - newIndex = currentSectionIndex > 0 ? currentSectionIndex - 1 : availableSections.length - 1; + newIndex = + currentSectionIndex > 0 + ? currentSectionIndex - 1 + : availableSections.length - 1; } else { - newIndex = currentSectionIndex < availableSections.length - 1 ? currentSectionIndex + 1 : 0; + newIndex = + currentSectionIndex < availableSections.length - 1 + ? currentSectionIndex + 1 + : 0; } setCurrentSectionIndex(newIndex); @@ -509,7 +521,8 @@ const EditChannelModal = (props) => { // 读取字段透传控制设置 data.allow_service_tier = parsedSettings.allow_service_tier || false; data.disable_store = parsedSettings.disable_store || false; - data.allow_safety_identifier = parsedSettings.allow_safety_identifier || false; + data.allow_safety_identifier = + parsedSettings.allow_safety_identifier || false; } catch (error) { console.error('解析其他设置失败:', error); data.azure_responses_version = ''; @@ -686,7 +699,7 @@ const EditChannelModal = (props) => { title: t('查看渠道密钥'), description: t('为了保护账户安全,请验证您的身份。'), preferredMethod: 'passkey', // 优先使用 Passkey - } + }, ); // 如果直接返回了结果(已验证),显示密钥 @@ -990,7 +1003,8 @@ const EditChannelModal = (props) => { // 仅 OpenAI 渠道需要 store 和 safety_identifier if (localInputs.type === 1) { settings.disable_store = localInputs.disable_store === true; - settings.allow_safety_identifier = localInputs.allow_safety_identifier === true; + settings.allow_safety_identifier = + localInputs.allow_safety_identifier === true; } } @@ -1339,7 +1353,7 @@ const EditChannelModal = (props) => { padding: 0, display: 'flex', alignItems: 'center', - justifyContent: 'center' + justifyContent: 'center', }} title={t('上一个表单块')} /> @@ -1355,7 +1369,7 @@ const EditChannelModal = (props) => { padding: 0, display: 'flex', alignItems: 'center', - justifyContent: 'center' + justifyContent: 'center', }} title={t('下一个表单块')} /> @@ -1390,336 +1404,149 @@ const EditChannelModal = (props) => { > {() => ( -
-
formSectionRefs.current.basicInfo = el}> +
+
(formSectionRefs.current.basicInfo = el)}> {/* Header: Basic Info */}
- - - -
- - {t('基本信息')} - -
- {t('渠道的基本配置信息')} + + + +
+ + {t('基本信息')} + +
+ {t('渠道的基本配置信息')} +
-
- setChannelSearchValue(value)} - renderOptionItem={renderChannelOption} - onChange={(value) => handleInputChange('type', value)} - /> - - {inputs.type === 20 && ( - { - setIsEnterpriseAccount(value); - handleInputChange('is_enterprise_account', value); - }} - extraText={t( - '企业账户为特殊返回格式,需要特殊处理,如果非企业账户,请勿勾选', - )} - initValue={inputs.is_enterprise_account} - /> - )} - - handleInputChange('name', value)} - autoComplete='new-password' - /> - - {inputs.type === 41 && ( { - // 更新设置中的 vertex_key_type - handleChannelOtherSettingsChange( - 'vertex_key_type', - value, - ); - // 切换为 api_key 时,关闭批量与手动/文件切换,并清理已选文件 - if (value === 'api_key') { - setBatch(false); - setUseManualInput(false); - setVertexKeys([]); - setVertexFileList([]); - if (formApiRef.current) { - formApiRef.current.setValue('vertex_files', []); - } - } - }} - extraText={ - inputs.vertex_key_type === 'api_key' - ? t('API Key 模式下不支持批量创建') - : t('JSON 模式支持手动输入或上传服务账号 JSON') - } + filter={selectFilter} + autoClearSearchValue={false} + searchPosition='dropdown' + onSearch={(value) => setChannelSearchValue(value)} + renderOptionItem={renderChannelOption} + onChange={(value) => handleInputChange('type', value)} /> - )} - {batch ? ( - inputs.type === 41 && - (inputs.vertex_key_type || 'json') === 'json' ? ( - } - dragMainText={t('点击上传文件或拖拽文件到这里')} - dragSubText={t('仅支持 JSON 文件,支持多文件')} - style={{ marginTop: 10 }} - uploadTrigger='custom' - beforeUpload={() => false} - onChange={handleVertexUploadChange} - fileList={vertexFileList} - rules={ - isEdit - ? [] - : [{ required: true, message: t('请上传密钥文件') }] - } - extraText={batchExtra} - /> - ) : ( - handleInputChange('key', value)} - extraText={ -
- {isEdit && - isMultiKeyChannel && - keyMode === 'append' && ( - - {t( - '追加模式:新密钥将添加到现有密钥列表的末尾', - )} - - )} - {isEdit && ( - - )} - {batchExtra} -
- } - showClear - /> - ) - ) : ( - <> - {inputs.type === 41 && - (inputs.vertex_key_type || 'json') === 'json' ? ( - <> - {!batch && ( -
- - {t('密钥输入方式')} - - - - - -
- )} - {batch && ( - - )} + {inputs.type === 20 && ( + { + setIsEnterpriseAccount(value); + handleInputChange('is_enterprise_account', value); + }} + extraText={t( + '企业账户为特殊返回格式,需要特殊处理,如果非企业账户,请勿勾选', + )} + initValue={inputs.is_enterprise_account} + /> + )} - {useManualInput && !batch ? ( - - handleInputChange('key', value) - } - extraText={ -
- - {t('请输入完整的 JSON 格式密钥内容')} - - {isEdit && - isMultiKeyChannel && - keyMode === 'append' && ( - - {t( - '追加模式:新密钥将添加到现有密钥列表的末尾', - )} - - )} - {isEdit && ( - - )} - {batchExtra} -
- } - autosize - showClear - /> - ) : ( - } - dragMainText={t('点击上传文件或拖拽文件到这里')} - dragSubText={t('仅支持 JSON 文件')} - style={{ marginTop: 10 }} - uploadTrigger='custom' - beforeUpload={() => false} - onChange={handleVertexUploadChange} - fileList={vertexFileList} - rules={ - isEdit - ? [] - : [ - { - required: true, - message: t('请上传密钥文件'), - }, - ] - } - extraText={batchExtra} - /> - )} - - ) : ( - handleInputChange('name', value)} + autoComplete='new-password' + /> + + {inputs.type === 41 && ( + { + // 更新设置中的 vertex_key_type + handleChannelOtherSettingsChange( + 'vertex_key_type', + value, + ); + // 切换为 api_key 时,关闭批量与手动/文件切换,并清理已选文件 + if (value === 'api_key') { + setBatch(false); + setUseManualInput(false); + setVertexKeys([]); + setVertexFileList([]); + if (formApiRef.current) { + formApiRef.current.setValue('vertex_files', []); + } } - placeholder={t(type2secretPrompt(inputs.type))} + }} + extraText={ + inputs.vertex_key_type === 'api_key' + ? t('API Key 模式下不支持批量创建') + : t('JSON 模式支持手动输入或上传服务账号 JSON') + } + /> + )} + {batch ? ( + inputs.type === 41 && + (inputs.vertex_key_type || 'json') === 'json' ? ( + } + dragMainText={t('点击上传文件或拖拽文件到这里')} + dragSubText={t('仅支持 JSON 文件,支持多文件')} + style={{ marginTop: 10 }} + uploadTrigger='custom' + beforeUpload={() => false} + onChange={handleVertexUploadChange} + fileList={vertexFileList} + rules={ + isEdit + ? [] + : [ + { + required: true, + message: t('请上传密钥文件'), + }, + ] + } + extraText={batchExtra} + /> + ) : ( + handleInputChange('key', value)} extraText={ -
+
{isEdit && isMultiKeyChannel && keyMode === 'append' && ( @@ -1744,743 +1571,912 @@ const EditChannelModal = (props) => { } showClear /> - )} - - )} + ) + ) : ( + <> + {inputs.type === 41 && + (inputs.vertex_key_type || 'json') === 'json' ? ( + <> + {!batch && ( +
+ + {t('密钥输入方式')} + + + + + +
+ )} - {isEdit && isMultiKeyChannel && ( - setKeyMode(value)} - extraText={ - - {keyMode === 'replace' - ? t('覆盖模式:将完全替换现有的所有密钥') - : t('追加模式:将新密钥添加到现有密钥列表末尾')} - - } - /> - )} - {batch && multiToSingle && ( - <> + {batch && ( + + )} + + {useManualInput && !batch ? ( + + handleInputChange('key', value) + } + extraText={ +
+ + {t('请输入完整的 JSON 格式密钥内容')} + + {isEdit && + isMultiKeyChannel && + keyMode === 'append' && ( + + {t( + '追加模式:新密钥将添加到现有密钥列表的末尾', + )} + + )} + {isEdit && ( + + )} + {batchExtra} +
+ } + autosize + showClear + /> + ) : ( + } + dragMainText={t('点击上传文件或拖拽文件到这里')} + dragSubText={t('仅支持 JSON 文件')} + style={{ marginTop: 10 }} + uploadTrigger='custom' + beforeUpload={() => false} + onChange={handleVertexUploadChange} + fileList={vertexFileList} + rules={ + isEdit + ? [] + : [ + { + required: true, + message: t('请上传密钥文件'), + }, + ] + } + extraText={batchExtra} + /> + )} + + ) : ( + + handleInputChange('key', value) + } + extraText={ +
+ {isEdit && + isMultiKeyChannel && + keyMode === 'append' && ( + + {t( + '追加模式:新密钥将添加到现有密钥列表的末尾', + )} + + )} + {isEdit && ( + + )} + {batchExtra} +
+ } + showClear + /> + )} + + )} + + {isEdit && isMultiKeyChannel && ( { - setMultiKeyMode(value); - handleInputChange('multi_key_mode', value); - }} + value={keyMode} + onChange={(value) => setKeyMode(value)} + extraText={ + + {keyMode === 'replace' + ? t('覆盖模式:将完全替换现有的所有密钥') + : t('追加模式:将新密钥添加到现有密钥列表末尾')} + + } /> - {inputs.multi_key_mode === 'polling' && ( - + { + setMultiKeyMode(value); + handleInputChange('multi_key_mode', value); + }} /> - )} - - )} + {inputs.multi_key_mode === 'polling' && ( + + )} + + )} - {inputs.type === 18 && ( - handleInputChange('other', value)} - showClear - /> - )} + {inputs.type === 18 && ( + handleInputChange('other', value)} + showClear + /> + )} - {inputs.type === 41 && ( - handleInputChange('other', value)} - rules={[{ required: true, message: t('请填写部署地区') }]} - template={REGION_EXAMPLE} - templateLabel={t('填入模板')} - editorType='region' - formApi={formApiRef.current} - extraText={t('设置默认地区和特定模型的专用地区')} - /> - )} + {inputs.type === 41 && ( + handleInputChange('other', value)} + rules={[ + { required: true, message: t('请填写部署地区') }, + ]} + template={REGION_EXAMPLE} + templateLabel={t('填入模板')} + editorType='region' + formApi={formApiRef.current} + extraText={t('设置默认地区和特定模型的专用地区')} + /> + )} - {inputs.type === 21 && ( - handleInputChange('other', value)} - showClear - /> - )} + {inputs.type === 21 && ( + handleInputChange('other', value)} + showClear + /> + )} - {inputs.type === 39 && ( - handleInputChange('other', value)} - showClear - /> - )} + {inputs.type === 39 && ( + handleInputChange('other', value)} + showClear + /> + )} - {inputs.type === 49 && ( - handleInputChange('other', value)} - showClear - /> - )} + {inputs.type === 49 && ( + handleInputChange('other', value)} + showClear + /> + )} - {inputs.type === 1 && ( - - handleInputChange('openai_organization', value) - } - /> - )} + {inputs.type === 1 && ( + + handleInputChange('openai_organization', value) + } + /> + )}
{/* API Configuration Card */} {showApiConfigCard && ( -
formSectionRefs.current.apiConfig = el}> +
(formSectionRefs.current.apiConfig = el)}> {/* Header: API Config */}
- - - -
- - {t('API 配置')} - -
- {t('API 地址和相关配置')} + + + +
+ + {t('API 配置')} + +
+ {t('API 地址和相关配置')} +
-
- {inputs.type === 40 && ( - + {t('邀请链接')}: + + window.open( + 'https://cloud.siliconflow.cn/i/hij0YNTZ', + ) + } + > + https://cloud.siliconflow.cn/i/hij0YNTZ + +
+ } + className='!rounded-lg' + /> + )} + + {inputs.type === 3 && ( + <> +
- {t('邀请链接')}: - - window.open( - 'https://cloud.siliconflow.cn/i/hij0YNTZ', + + handleInputChange('base_url', value) + } + showClear + /> +
+
+ + handleInputChange('other', value) + } + showClear + /> +
+
+ + handleChannelOtherSettingsChange( + 'azure_responses_version', + value, ) } - > - https://cloud.siliconflow.cn/i/hij0YNTZ - + showClear + />
- } - className='!rounded-lg' - /> - )} + + )} - {inputs.type === 3 && ( - <> + {inputs.type === 8 && ( + <> + +
+ + handleInputChange('base_url', value) + } + showClear + /> +
+ + )} + + {inputs.type === 37 && ( -
- - handleInputChange('base_url', value) - } - showClear - /> -
-
- - handleInputChange('other', value) - } - showClear - /> -
-
- - handleChannelOtherSettingsChange( - 'azure_responses_version', - value, - ) - } - showClear - /> -
- - )} + )} - {inputs.type === 8 && ( - <> - -
- - handleInputChange('base_url', value) - } - showClear - /> -
- - )} - - {inputs.type === 37 && ( - + + handleInputChange('base_url', value) + } + showClear + extraText={t( + '对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写', + )} + /> +
)} - className='!rounded-lg' - /> - )} - {inputs.type !== 3 && - inputs.type !== 8 && - inputs.type !== 22 && - inputs.type !== 36 && - inputs.type !== 45 && ( + {inputs.type === 22 && (
handleInputChange('base_url', value) } showClear - extraText={t( - '对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写', - )} />
)} - {inputs.type === 22 && ( -
- - handleInputChange('base_url', value) - } - showClear - /> -
- )} + {inputs.type === 36 && ( +
+ + handleInputChange('base_url', value) + } + showClear + /> +
+ )} - {inputs.type === 36 && ( -
- - handleInputChange('base_url', value) - } - showClear - /> -
- )} - - {inputs.type === 45 && ( -
- - handleInputChange('base_url', value) - } - optionList={[ - { - value: 'https://ark.cn-beijing.volces.com', - label: 'https://ark.cn-beijing.volces.com', - }, - { - value: 'https://ark.ap-southeast.bytepluses.com', - label: 'https://ark.ap-southeast.bytepluses.com', - }, - ]} - defaultValue='https://ark.cn-beijing.volces.com' - /> -
- )} + {inputs.type === 45 && ( +
+ + handleInputChange('base_url', value) + } + optionList={[ + { + value: 'https://ark.cn-beijing.volces.com', + label: 'https://ark.cn-beijing.volces.com', + }, + { + value: + 'https://ark.ap-southeast.bytepluses.com', + label: + 'https://ark.ap-southeast.bytepluses.com', + }, + ]} + defaultValue='https://ark.cn-beijing.volces.com' + /> +
+ )}
)} {/* Model Configuration Card */} -
formSectionRefs.current.modelConfig = el}> +
(formSectionRefs.current.modelConfig = el)}> {/* Header: Model Config */}
- - - -
- - {t('模型配置')} - -
- {t('模型选择和映射设置')} + + + +
+ + {t('模型配置')} + +
+ {t('模型选择和映射设置')} +
-
- handleInputChange('models', value)} - renderSelectedItem={(optionNode) => { - const modelName = String(optionNode?.value ?? ''); - return { - isRenderInTag: true, - content: ( - { - e.stopPropagation(); - const ok = await copy(modelName); - if (ok) { - showSuccess( - t('已复制:{{name}}', { name: modelName }), - ); - } else { + handleInputChange('models', value)} + renderSelectedItem={(optionNode) => { + const modelName = String(optionNode?.value ?? ''); + return { + isRenderInTag: true, + content: ( + { + e.stopPropagation(); + const ok = await copy(modelName); + if (ok) { + showSuccess( + t('已复制:{{name}}', { name: modelName }), + ); + } else { + showError(t('复制失败')); + } + }} + > + {optionNode.label || modelName} + + ), + }; + }} + extraText={ + + + + {MODEL_FETCHABLE_TYPES.has(inputs.type) && ( + + )} + + + {modelGroups && + modelGroups.length > 0 && + modelGroups.map((group) => ( + + ))} + + } + /> + + setCustomModel(value.trim())} + value={customModel} + suffix={ - - {MODEL_FETCHABLE_TYPES.has(inputs.type) && ( - - )} - - - {modelGroups && - modelGroups.length > 0 && - modelGroups.map((group) => ( - - ))} - - } - /> + } + /> - setCustomModel(value.trim())} - value={customModel} - suffix={ - - } - /> + + handleInputChange('test_model', value) + } + showClear + /> - handleInputChange('test_model', value)} - showClear - /> - - - handleInputChange('model_mapping', value) - } - template={MODEL_MAPPING_EXAMPLE} - templateLabel={t('填入模板')} - editorType='keyValue' - formApi={formApiRef.current} - extraText={t('键为请求中的模型名称,值为要替换的模型名称')} - /> + + handleInputChange('model_mapping', value) + } + template={MODEL_MAPPING_EXAMPLE} + templateLabel={t('填入模板')} + editorType='keyValue' + formApi={formApiRef.current} + extraText={t( + '键为请求中的模型名称,值为要替换的模型名称', + )} + />
{/* Advanced Settings Card */} -
formSectionRefs.current.advancedSettings = el}> +
(formSectionRefs.current.advancedSettings = el)} + > {/* Header: Advanced Settings */}
- - - -
- - {t('高级设置')} - -
- {t('渠道的高级配置选项')} + + + +
+ + {t('高级设置')} + +
+ {t('渠道的高级配置选项')} +
-
- handleInputChange('groups', value)} - /> + handleInputChange('groups', value)} + /> - handleInputChange('tag', value)} - /> - handleInputChange('remark', value)} - /> + handleInputChange('tag', value)} + /> + handleInputChange('remark', value)} + /> - -
- - handleInputChange('priority', value) - } - style={{ width: '100%' }} - /> - - - - handleInputChange('weight', value) - } - style={{ width: '100%' }} - /> - - - - setAutoBan(value)} - extraText={t( - '仅当自动禁用开启时有效,关闭后不会自动禁用该渠道', - )} - initValue={autoBan} - /> - - - handleInputChange('param_override', value) - } - extraText={ -
- - handleInputChange( - 'param_override', - JSON.stringify({ temperature: 0 }, null, 2), - ) + +
+ + handleInputChange('priority', value) } - > - {t('旧格式模板')} - - - handleInputChange( - 'param_override', - JSON.stringify( - { - operations: [ - { - path: 'temperature', - mode: 'set', - value: 0.7, - conditions: [ - { - path: 'model', - mode: 'prefix', - value: 'gpt', - }, - ], - logic: 'AND', - }, - ], - }, - null, - 2, - ), - ) + style={{ width: '100%' }} + /> + + + + handleInputChange('weight', value) } - > - {t('新格式模板')} - - - } - showClear - /> + style={{ width: '100%' }} + /> + + - - handleInputChange('header_override', value) - } - extraText={ + setAutoBan(value)} + extraText={t( + '仅当自动禁用开启时有效,关闭后不会自动禁用该渠道', + )} + initValue={autoBan} + /> -
-
+ + handleInputChange('param_override', value) + } + extraText={ +
handleInputChange( - 'header_override', + 'param_override', + JSON.stringify({ temperature: 0 }, null, 2), + ) + } + > + {t('旧格式模板')} + + + handleInputChange( + 'param_override', JSON.stringify( { - 'User-Agent': - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0', - 'Authorization': 'Bearer{api_key}', + operations: [ + { + path: 'temperature', + mode: 'set', + value: 0.7, + conditions: [ + { + path: 'model', + mode: 'prefix', + value: 'gpt', + }, + ], + logic: 'AND', + }, + ], }, null, 2, @@ -2488,220 +2484,281 @@ const EditChannelModal = (props) => { ) } > - {t('填入模板')} + {t('新格式模板')}
-
- - {t('支持变量:')} - -
-
{t('渠道密钥')}: {'{api_key}'}
+ } + showClear + /> + + + handleInputChange('header_override', value) + } + extraText={ +
+
+ + handleInputChange( + 'header_override', + JSON.stringify( + { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0', + Authorization: 'Bearer{api_key}', + }, + null, + 2, + ), + ) + } + > + {t('填入模板')} + +
+
+ + {t('支持变量:')} + +
+
+ {t('渠道密钥')}: {'{api_key}'} +
+
-
- } - showClear - /> + } + showClear + /> - - handleInputChange('status_code_mapping', value) - } - template={STATUS_CODE_MAPPING_EXAMPLE} - templateLabel={t('填入模板')} - editorType='keyValue' - formApi={formApiRef.current} - extraText={t( - '键为原状态码,值为要复写的状态码,仅影响本地判断', + + handleInputChange('status_code_mapping', value) + } + template={STATUS_CODE_MAPPING_EXAMPLE} + templateLabel={t('填入模板')} + editorType='keyValue' + formApi={formApiRef.current} + extraText={t( + '键为原状态码,值为要复写的状态码,仅影响本地判断', + )} + /> + + {/* 字段透传控制 - OpenAI 渠道 */} + {inputs.type === 1 && ( + <> +
+ {t('字段透传控制')} +
+ + + handleChannelOtherSettingsChange( + 'allow_service_tier', + value, + ) + } + extraText={t( + 'service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用', + )} + /> + + + handleChannelOtherSettingsChange( + 'disable_store', + value, + ) + } + extraText={t( + 'store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用', + )} + /> + + + handleChannelOtherSettingsChange( + 'allow_safety_identifier', + value, + ) + } + extraText={t( + 'safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私', + )} + /> + )} - /> - {/* 字段透传控制 - OpenAI 渠道 */} - {inputs.type === 1 && ( - <> -
- {t('字段透传控制')} -
+ {/* 字段透传控制 - Claude 渠道 */} + {inputs.type === 14 && ( + <> +
+ {t('字段透传控制')} +
- - handleChannelOtherSettingsChange('allow_service_tier', value) - } - extraText={t( - 'service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用', - )} - /> - - - handleChannelOtherSettingsChange('disable_store', value) - } - extraText={t( - 'store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用', - )} - /> - - - handleChannelOtherSettingsChange('allow_safety_identifier', value) - } - extraText={t( - 'safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私', - )} - /> - - )} - - {/* 字段透传控制 - Claude 渠道 */} - {(inputs.type === 14) && ( - <> -
- {t('字段透传控制')} -
- - - handleChannelOtherSettingsChange('allow_service_tier', value) - } - extraText={t( - 'service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用', - )} - /> - - )} + + handleChannelOtherSettingsChange( + 'allow_service_tier', + value, + ) + } + extraText={t( + 'service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用', + )} + /> + + )}
{/* Channel Extra Settings Card */} -
formSectionRefs.current.channelExtraSettings = el}> +
+ (formSectionRefs.current.channelExtraSettings = el) + } + > {/* Header: Channel Extra Settings */}
- - - -
- - {t('渠道额外设置')} - + + + +
+ + {t('渠道额外设置')} + +
-
- {inputs.type === 1 && ( + {inputs.type === 1 && ( + + handleChannelSettingsChange('force_format', value) + } + extraText={t( + '强制将响应格式化为 OpenAI 标准格式(只适用于OpenAI渠道类型)', + )} + /> + )} + - handleChannelSettingsChange('force_format', value) + handleChannelSettingsChange( + 'thinking_to_content', + value, + ) } extraText={t( - '强制将响应格式化为 OpenAI 标准格式(只适用于OpenAI渠道类型)', + '将 reasoning_content 转换为 标签拼接到内容中', )} /> - )} - - handleChannelSettingsChange('thinking_to_content', value) - } - extraText={t( - '将 reasoning_content 转换为 标签拼接到内容中', - )} - /> + + handleChannelSettingsChange( + 'pass_through_body_enabled', + value, + ) + } + extraText={t('启用请求体透传功能')} + /> - - handleChannelSettingsChange( - 'pass_through_body_enabled', - value, - ) - } - extraText={t('启用请求体透传功能')} - /> + + handleChannelSettingsChange('proxy', value) + } + showClear + extraText={t('用于配置网络代理,支持 socks5 协议')} + /> - - handleChannelSettingsChange('proxy', value) - } - showClear - extraText={t('用于配置网络代理,支持 socks5 协议')} - /> - - - handleChannelSettingsChange('system_prompt', value) - } - autosize - showClear - extraText={t( - '用户优先:如果用户在请求中指定了系统提示词,将优先使用用户的设置', - )} - /> - - handleChannelSettingsChange( - 'system_prompt_override', - value, - ) - } - extraText={t( - '如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面', - )} - /> + + handleChannelSettingsChange('system_prompt', value) + } + autosize + showClear + extraText={t( + '用户优先:如果用户在请求中指定了系统提示词,将优先使用用户的设置', + )} + /> + + handleChannelSettingsChange( + 'system_prompt_override', + value, + ) + } + extraText={t( + '如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面', + )} + />
diff --git a/web/src/components/table/channels/modals/EditTagModal.jsx b/web/src/components/table/channels/modals/EditTagModal.jsx index 752ff3dc..0b2f17d9 100644 --- a/web/src/components/table/channels/modals/EditTagModal.jsx +++ b/web/src/components/table/channels/modals/EditTagModal.jsx @@ -119,8 +119,19 @@ const EditTagModal = (props) => { localModels = ['suno_music', 'suno_lyrics']; break; case 53: - localModels = ['NousResearch/Hermes-4-405B-FP8', 'Qwen/Qwen3-235B-A22B-Thinking-2507', 'Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8','Qwen/Qwen3-235B-A22B-Instruct-2507', 'zai-org/GLM-4.5-FP8', 'openai/gpt-oss-120b', 'deepseek-ai/DeepSeek-R1-0528', 'deepseek-ai/DeepSeek-R1', 'deepseek-ai/DeepSeek-V3-0324', 'deepseek-ai/DeepSeek-V3.1']; - break; + localModels = [ + 'NousResearch/Hermes-4-405B-FP8', + 'Qwen/Qwen3-235B-A22B-Thinking-2507', + 'Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8', + 'Qwen/Qwen3-235B-A22B-Instruct-2507', + 'zai-org/GLM-4.5-FP8', + 'openai/gpt-oss-120b', + 'deepseek-ai/DeepSeek-R1-0528', + 'deepseek-ai/DeepSeek-R1', + 'deepseek-ai/DeepSeek-V3-0324', + 'deepseek-ai/DeepSeek-V3.1', + ]; + break; default: localModels = getChannelModels(value); break; diff --git a/web/src/components/table/channels/modals/ModelTestModal.jsx b/web/src/components/table/channels/modals/ModelTestModal.jsx index 7cc56612..1879cd57 100644 --- a/web/src/components/table/channels/modals/ModelTestModal.jsx +++ b/web/src/components/table/channels/modals/ModelTestModal.jsx @@ -67,9 +67,15 @@ const ModelTestModal = ({ { value: 'openai', label: 'OpenAI (/v1/chat/completions)' }, { value: 'openai-response', label: 'OpenAI Response (/v1/responses)' }, { value: 'anthropic', label: 'Anthropic (/v1/messages)' }, - { value: 'gemini', label: 'Gemini (/v1beta/models/{model}:generateContent)' }, + { + value: 'gemini', + label: 'Gemini (/v1beta/models/{model}:generateContent)', + }, { value: 'jina-rerank', label: 'Jina Rerank (/rerank)' }, - { value: 'image-generation', label: t('图像生成') + ' (/v1/images/generations)' }, + { + value: 'image-generation', + label: t('图像生成') + ' (/v1/images/generations)', + }, { value: 'embeddings', label: 'Embeddings (/v1/embeddings)' }, ]; @@ -166,7 +172,13 @@ const ModelTestModal = ({ return ( - -
@@ -339,16 +356,22 @@ const RechargeCard = ({ )} {(enableOnlineTopUp || enableStripeTopUp) && ( - {t('选择充值额度')} {(() => { const { symbol, rate, type } = getCurrencyConfig(); if (type === 'USD') return null; - + return ( - + (1 $ = {rate.toFixed(2)} {symbol}) ); @@ -378,11 +401,11 @@ const RechargeCard = ({ usdRate = s?.usd_exchange_rate || 7; } } catch (e) {} - + let displayValue = preset.value; // 显示的数量 let displayActualPay = actualPay; let displaySave = save; - + if (type === 'USD') { // 数量保持USD,价格从CNY转USD displayActualPay = actualPay / usdRate; @@ -444,7 +467,8 @@ const RechargeCard = ({ margin: '4px 0', }} > - {t('实付')} {symbol}{displayActualPay.toFixed(2)}, + {t('实付')} {symbol} + {displayActualPay.toFixed(2)}, {hasDiscount ? `${t('节省')} ${symbol}${displaySave.toFixed(2)}` : `${t('节省')} ${symbol}0.00`} diff --git a/web/src/components/topup/index.jsx b/web/src/components/topup/index.jsx index 558c6705..9054da52 100644 --- a/web/src/components/topup/index.jsx +++ b/web/src/components/topup/index.jsx @@ -37,6 +37,7 @@ import RechargeCard from './RechargeCard'; import InvitationCard from './InvitationCard'; import TransferModal from './modals/TransferModal'; import PaymentConfirmModal from './modals/PaymentConfirmModal'; +import TopupHistoryModal from './modals/TopupHistoryModal'; const TopUp = () => { const { t } = useTranslation(); @@ -77,6 +78,9 @@ const TopUp = () => { const [openTransfer, setOpenTransfer] = useState(false); const [transferAmount, setTransferAmount] = useState(0); + // 账单Modal状态 + const [openHistory, setOpenHistory] = useState(false); + // 预设充值额度选项 const [presetAmounts, setPresetAmounts] = useState([]); const [selectedPreset, setSelectedPreset] = useState(null); @@ -488,6 +492,14 @@ const TopUp = () => { setOpenTransfer(false); }; + const handleOpenHistory = () => { + setOpenHistory(true); + }; + + const handleHistoryCancel = () => { + setOpenHistory(false); + }; + // 选择预设充值额度 const selectPresetAmount = (preset) => { setTopUpCount(preset.value); @@ -544,6 +556,13 @@ const TopUp = () => { discountRate={topupInfo?.discount?.[topUpCount] || 1.0} /> + {/* 充值账单模态框 */} + + {/* 用户信息头部 */}
@@ -580,6 +599,7 @@ const TopUp = () => { renderQuota={renderQuota} statusLoading={statusLoading} topupInfo={topupInfo} + onOpenHistory={handleOpenHistory} />
diff --git a/web/src/components/topup/modals/TopupHistoryModal.jsx b/web/src/components/topup/modals/TopupHistoryModal.jsx new file mode 100644 index 00000000..e2548f38 --- /dev/null +++ b/web/src/components/topup/modals/TopupHistoryModal.jsx @@ -0,0 +1,253 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import React, { useState, useEffect, useMemo } from 'react'; +import { + Modal, + Table, + Badge, + Typography, + Toast, + Empty, + Button, +} from '@douyinfe/semi-ui'; +import { + IllustrationNoResult, + IllustrationNoResultDark, +} from '@douyinfe/semi-illustrations'; +import { Coins } from 'lucide-react'; +import { API, timestamp2string } from '../../../helpers'; +import { isAdmin } from '../../../helpers/utils'; +import { useIsMobile } from '../../../hooks/common/useIsMobile'; + +const { Text } = Typography; + +// 状态映射配置 +const STATUS_CONFIG = { + success: { type: 'success', key: '成功' }, + pending: { type: 'warning', key: '待支付' }, + expired: { type: 'danger', key: '已过期' }, +}; + +// 支付方式映射 +const PAYMENT_METHOD_MAP = { + stripe: 'Stripe', + alipay: '支付宝', + wxpay: '微信', +}; + +const TopupHistoryModal = ({ visible, onCancel, t }) => { + const [loading, setLoading] = useState(false); + const [topups, setTopups] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + + const isMobile = useIsMobile(); + + const loadTopups = async (currentPage, currentPageSize) => { + setLoading(true); + try { + const res = await API.get( + `/api/user/topup/self?p=${currentPage}&page_size=${currentPageSize}`, + ); + const { success, message, data } = res.data; + if (success) { + setTopups(data.items || []); + setTotal(data.total || 0); + } else { + Toast.error({ content: message || t('加载失败') }); + } + } catch (error) { + console.error('Load topups error:', error); + Toast.error({ content: t('加载账单失败') }); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (visible) { + loadTopups(page, pageSize); + } + }, [visible, page, pageSize]); + + const handlePageChange = (currentPage) => { + setPage(currentPage); + }; + + const handlePageSizeChange = (currentPageSize) => { + setPageSize(currentPageSize); + setPage(1); + }; + + // 管理员补单 + const handleAdminComplete = async (tradeNo) => { + try { + const res = await API.post('/api/user/topup/complete', { + trade_no: tradeNo, + }); + const { success, message } = res.data; + if (success) { + Toast.success({ content: t('补单成功') }); + await loadTopups(page, pageSize); + } else { + Toast.error({ content: message || t('补单失败') }); + } + } catch (e) { + Toast.error({ content: t('补单失败') }); + } + }; + + const confirmAdminComplete = (tradeNo) => { + Modal.confirm({ + title: t('确认补单'), + content: t('是否将该订单标记为成功并为用户入账?'), + onOk: () => handleAdminComplete(tradeNo), + }); + }; + + // 渲染状态徽章 + const renderStatusBadge = (status) => { + const config = STATUS_CONFIG[status] || { type: 'primary', key: status }; + return ( + + + {t(config.key)} + + ); + }; + + // 渲染支付方式 + const renderPaymentMethod = (pm) => { + const displayName = PAYMENT_METHOD_MAP[pm]; + return {displayName ? t(displayName) : pm || '-'}; + }; + + // 检查是否为管理员 + const userIsAdmin = useMemo(() => isAdmin(), []); + + const columns = useMemo(() => { + const baseColumns = [ + { + title: t('订单号'), + dataIndex: 'trade_no', + key: 'trade_no', + render: (text) => {text}, + }, + { + title: t('支付方式'), + dataIndex: 'payment_method', + key: 'payment_method', + render: renderPaymentMethod, + }, + { + title: t('充值额度'), + dataIndex: 'amount', + key: 'amount', + render: (amount) => ( + + + {amount} + + ), + }, + { + title: t('支付金额'), + dataIndex: 'money', + key: 'money', + render: (money) => ¥{money.toFixed(2)}, + }, + { + title: t('状态'), + dataIndex: 'status', + key: 'status', + render: renderStatusBadge, + }, + ]; + + // 管理员才显示操作列 + if (userIsAdmin) { + baseColumns.push({ + title: t('操作'), + key: 'action', + render: (_, record) => { + if (record.status !== 'pending') return null; + return ( + + ); + }, + }); + } + + baseColumns.push({ + title: t('创建时间'), + dataIndex: 'create_time', + key: 'create_time', + render: (time) => timestamp2string(time), + }); + + return baseColumns; + }, [t, userIsAdmin]); + + return ( + +
} + darkModeImage={ + + } + description={t('暂无充值记录')} + style={{ padding: 30 }} + /> + } + /> + + ); +}; + +export default TopupHistoryModal; diff --git a/web/src/constants/channel.constants.js b/web/src/constants/channel.constants.js index 3b376ed3..ad699936 100644 --- a/web/src/constants/channel.constants.js +++ b/web/src/constants/channel.constants.js @@ -159,7 +159,7 @@ export const CHANNEL_OPTIONS = [ color: 'purple', label: 'Vidu', }, - { + { value: 53, color: 'blue', label: 'SubModel', diff --git a/web/src/helpers/passkey.js b/web/src/helpers/passkey.js index ae62775e..c3f3e927 100644 --- a/web/src/helpers/passkey.js +++ b/web/src/helpers/passkey.js @@ -1,3 +1,21 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ export function base64UrlToBuffer(base64url) { if (!base64url) return new ArrayBuffer(0); let padding = '='.repeat((4 - (base64url.length % 4)) % 4); @@ -26,7 +44,11 @@ export function bufferToBase64Url(buffer) { } export function prepareCredentialCreationOptions(payload) { - const options = payload?.publicKey || payload?.PublicKey || payload?.response || payload?.Response; + const options = + payload?.publicKey || + payload?.PublicKey || + payload?.response || + payload?.Response; if (!options) { throw new Error('无法从服务端响应中解析 Passkey 注册参数'); } @@ -46,7 +68,10 @@ export function prepareCredentialCreationOptions(payload) { })); } - if (Array.isArray(options.attestationFormats) && options.attestationFormats.length === 0) { + if ( + Array.isArray(options.attestationFormats) && + options.attestationFormats.length === 0 + ) { delete publicKey.attestationFormats; } @@ -54,7 +79,11 @@ export function prepareCredentialCreationOptions(payload) { } export function prepareCredentialRequestOptions(payload) { - const options = payload?.publicKey || payload?.PublicKey || payload?.response || payload?.Response; + const options = + payload?.publicKey || + payload?.PublicKey || + payload?.response || + payload?.Response; if (!options) { throw new Error('无法从服务端响应中解析 Passkey 登录参数'); } @@ -77,7 +106,10 @@ export function buildRegistrationResult(credential) { if (!credential) return null; const { response } = credential; - const transports = typeof response.getTransports === 'function' ? response.getTransports() : undefined; + const transports = + typeof response.getTransports === 'function' + ? response.getTransports() + : undefined; return { id: credential.id, @@ -107,7 +139,9 @@ export function buildAssertionResult(assertion) { authenticatorData: bufferToBase64Url(response.authenticatorData), clientDataJSON: bufferToBase64Url(response.clientDataJSON), signature: bufferToBase64Url(response.signature), - userHandle: response.userHandle ? bufferToBase64Url(response.userHandle) : null, + userHandle: response.userHandle + ? bufferToBase64Url(response.userHandle) + : null, }, clientExtensionResults: assertion.getClientExtensionResults?.() ?? {}, }; @@ -117,15 +151,22 @@ export async function isPasskeySupported() { if (typeof window === 'undefined' || !window.PublicKeyCredential) { return false; } - if (typeof window.PublicKeyCredential.isConditionalMediationAvailable === 'function') { + if ( + typeof window.PublicKeyCredential.isConditionalMediationAvailable === + 'function' + ) { try { - const available = await window.PublicKeyCredential.isConditionalMediationAvailable(); + const available = + await window.PublicKeyCredential.isConditionalMediationAvailable(); if (available) return true; } catch (error) { // ignore } } - if (typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable === 'function') { + if ( + typeof window.PublicKeyCredential + .isUserVerifyingPlatformAuthenticatorAvailable === 'function' + ) { try { return await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); } catch (error) { @@ -134,4 +175,3 @@ export async function isPasskeySupported() { } return true; } - diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index b5fcb4d6..c22bdd78 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -929,10 +929,10 @@ export function renderQuotaWithAmount(amount) { export function getCurrencyConfig() { const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD'; const statusStr = localStorage.getItem('status'); - + let symbol = '$'; let rate = 1; - + if (quotaDisplayType === 'CNY') { symbol = '¥'; try { @@ -950,7 +950,7 @@ export function getCurrencyConfig() { } } catch (e) {} } - + return { symbol, rate, type: quotaDisplayType }; } @@ -1128,7 +1128,7 @@ export function renderModelPrice( user_group_ratio, ); groupRatio = effectiveGroupRatio; - + // 获取货币配置 const { symbol, rate } = getCurrencyConfig(); @@ -1177,13 +1177,16 @@ export function renderModelPrice( <>

- {i18next.t('输入价格:{{symbol}}{{price}} / 1M tokens{{audioPrice}}', { - symbol: symbol, - price: (inputRatioPrice * rate).toFixed(6), - audioPrice: audioInputSeperatePrice - ? `,音频 ${symbol}${(audioInputPrice * rate).toFixed(6)} / 1M tokens` - : '', - })} + {i18next.t( + '输入价格:{{symbol}}{{price}} / 1M tokens{{audioPrice}}', + { + symbol: symbol, + price: (inputRatioPrice * rate).toFixed(6), + audioPrice: audioInputSeperatePrice + ? `,音频 ${symbol}${(audioInputPrice * rate).toFixed(6)} / 1M tokens` + : '', + }, + )}

{i18next.t( @@ -1311,27 +1314,27 @@ export function renderModelPrice( const extraServices = [ webSearch && webSearchCallCount > 0 ? i18next.t( - ' + Web搜索 {{count}}次 / 1K 次 * {{symbol}}{{price}} * {{ratioType}} {{ratio}}', - { - count: webSearchCallCount, - symbol: symbol, - price: (webSearchPrice * rate).toFixed(6), - ratio: groupRatio, - ratioType: ratioLabel, - }, - ) + ' + Web搜索 {{count}}次 / 1K 次 * {{symbol}}{{price}} * {{ratioType}} {{ratio}}', + { + count: webSearchCallCount, + symbol: symbol, + price: (webSearchPrice * rate).toFixed(6), + ratio: groupRatio, + ratioType: ratioLabel, + }, + ) : '', fileSearch && fileSearchCallCount > 0 ? i18next.t( - ' + 文件搜索 {{count}}次 / 1K 次 * {{symbol}}{{price}} * {{ratioType}} {{ratio}}', - { - count: fileSearchCallCount, - symbol: symbol, - price: (fileSearchPrice * rate).toFixed(6), - ratio: groupRatio, - ratioType: ratioLabel, - }, - ) + ' + 文件搜索 {{count}}次 / 1K 次 * {{symbol}}{{price}} * {{ratioType}} {{ratio}}', + { + count: fileSearchCallCount, + symbol: symbol, + price: (fileSearchPrice * rate).toFixed(6), + ratio: groupRatio, + ratioType: ratioLabel, + }, + ) : '', imageGenerationCall && imageGenerationCallPrice > 0 ? i18next.t( @@ -1384,7 +1387,7 @@ export function renderLogContent( label: ratioLabel, useUserGroupRatio: useUserGroupRatio, } = getEffectiveRatio(groupRatio, user_group_ratio); - + // 获取货币配置 const { symbol, rate } = getCurrencyConfig(); @@ -1484,10 +1487,10 @@ export function renderAudioModelPrice( user_group_ratio, ); groupRatio = effectiveGroupRatio; - + // 获取货币配置 const { symbol, rate } = getCurrencyConfig(); - + // 1 ratio = $0.002 / 1K tokens if (modelPrice !== -1) { return i18next.t( @@ -1522,10 +1525,10 @@ export function renderAudioModelPrice( let audioPrice = (audioInputTokens / 1000000) * inputRatioPrice * audioRatio * groupRatio + (audioCompletionTokens / 1000000) * - inputRatioPrice * - audioRatio * - audioCompletionRatio * - groupRatio; + inputRatioPrice * + audioRatio * + audioCompletionRatio * + groupRatio; let price = textPrice + audioPrice; return ( <> @@ -1577,7 +1580,12 @@ export function renderAudioModelPrice( { symbol: symbol, price: (inputRatioPrice * rate).toFixed(6), - total: (inputRatioPrice * audioRatio * audioCompletionRatio * rate).toFixed(6), + total: ( + inputRatioPrice * + audioRatio * + audioCompletionRatio * + rate + ).toFixed(6), audioRatio: audioRatio, audioCompRatio: audioCompletionRatio, }, @@ -1586,29 +1594,31 @@ export function renderAudioModelPrice(

{cacheTokens > 0 ? i18next.t( - '文字提示 {{nonCacheInput}} tokens / 1M tokens * {{symbol}}{{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * {{symbol}}{{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} = {{symbol}}{{total}}', - { - nonCacheInput: inputTokens - cacheTokens, - cacheInput: cacheTokens, - symbol: symbol, - cachePrice: (inputRatioPrice * cacheRatio * rate).toFixed(6), - price: (inputRatioPrice * rate).toFixed(6), - completion: completionTokens, - compPrice: (completionRatioPrice * rate).toFixed(6), - total: (textPrice * rate).toFixed(6), - }, - ) + '文字提示 {{nonCacheInput}} tokens / 1M tokens * {{symbol}}{{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * {{symbol}}{{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} = {{symbol}}{{total}}', + { + nonCacheInput: inputTokens - cacheTokens, + cacheInput: cacheTokens, + symbol: symbol, + cachePrice: (inputRatioPrice * cacheRatio * rate).toFixed( + 6, + ), + price: (inputRatioPrice * rate).toFixed(6), + completion: completionTokens, + compPrice: (completionRatioPrice * rate).toFixed(6), + total: (textPrice * rate).toFixed(6), + }, + ) : i18next.t( - '文字提示 {{input}} tokens / 1M tokens * {{symbol}}{{price}} + 文字补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} = {{symbol}}{{total}}', - { - input: inputTokens, - symbol: symbol, - price: (inputRatioPrice * rate).toFixed(6), - completion: completionTokens, - compPrice: (completionRatioPrice * rate).toFixed(6), - total: (textPrice * rate).toFixed(6), - }, - )} + '文字提示 {{input}} tokens / 1M tokens * {{symbol}}{{price}} + 文字补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} = {{symbol}}{{total}}', + { + input: inputTokens, + symbol: symbol, + price: (inputRatioPrice * rate).toFixed(6), + completion: completionTokens, + compPrice: (completionRatioPrice * rate).toFixed(6), + total: (textPrice * rate).toFixed(6), + }, + )}

{i18next.t( @@ -1617,9 +1627,15 @@ export function renderAudioModelPrice( input: audioInputTokens, completion: audioCompletionTokens, symbol: symbol, - audioInputPrice: (audioRatio * inputRatioPrice * rate).toFixed(6), - audioCompPrice: - (audioRatio * audioCompletionRatio * inputRatioPrice * rate).toFixed(6), + audioInputPrice: (audioRatio * inputRatioPrice * rate).toFixed( + 6, + ), + audioCompPrice: ( + audioRatio * + audioCompletionRatio * + inputRatioPrice * + rate + ).toFixed(6), total: (audioPrice * rate).toFixed(6), }, )} @@ -1668,7 +1684,7 @@ export function renderClaudeModelPrice( user_group_ratio, ); groupRatio = effectiveGroupRatio; - + // 获取货币配置 const { symbol, rate } = getCurrencyConfig(); @@ -1757,37 +1773,39 @@ export function renderClaudeModelPrice(

{cacheTokens > 0 || cacheCreationTokens > 0 ? i18next.t( - '提示 {{nonCacheInput}} tokens / 1M tokens * {{symbol}}{{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * {{symbol}}{{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * {{symbol}}{{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}', - { - nonCacheInput: nonCachedTokens, - cacheInput: cacheTokens, - cacheRatio: cacheRatio, - cacheCreationInput: cacheCreationTokens, - cacheCreationRatio: cacheCreationRatio, - symbol: symbol, - cachePrice: (cacheRatioPrice * rate).toFixed(2), - cacheCreationPrice: (cacheCreationRatioPrice * rate).toFixed(6), - price: (inputRatioPrice * rate).toFixed(6), - completion: completionTokens, - compPrice: (completionRatioPrice * rate).toFixed(6), - ratio: groupRatio, - ratioType: ratioLabel, - total: (price * rate).toFixed(6), - }, - ) + '提示 {{nonCacheInput}} tokens / 1M tokens * {{symbol}}{{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * {{symbol}}{{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * {{symbol}}{{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}', + { + nonCacheInput: nonCachedTokens, + cacheInput: cacheTokens, + cacheRatio: cacheRatio, + cacheCreationInput: cacheCreationTokens, + cacheCreationRatio: cacheCreationRatio, + symbol: symbol, + cachePrice: (cacheRatioPrice * rate).toFixed(2), + cacheCreationPrice: ( + cacheCreationRatioPrice * rate + ).toFixed(6), + price: (inputRatioPrice * rate).toFixed(6), + completion: completionTokens, + compPrice: (completionRatioPrice * rate).toFixed(6), + ratio: groupRatio, + ratioType: ratioLabel, + total: (price * rate).toFixed(6), + }, + ) : i18next.t( - '提示 {{input}} tokens / 1M tokens * {{symbol}}{{price}} + 补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}', - { - input: inputTokens, - symbol: symbol, - price: (inputRatioPrice * rate).toFixed(6), - completion: completionTokens, - compPrice: (completionRatioPrice * rate).toFixed(6), - ratio: groupRatio, - ratioType: ratioLabel, - total: (price * rate).toFixed(6), - }, - )} + '提示 {{input}} tokens / 1M tokens * {{symbol}}{{price}} + 补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}', + { + input: inputTokens, + symbol: symbol, + price: (inputRatioPrice * rate).toFixed(6), + completion: completionTokens, + compPrice: (completionRatioPrice * rate).toFixed(6), + ratio: groupRatio, + ratioType: ratioLabel, + total: (price * rate).toFixed(6), + }, + )}

{i18next.t('仅供参考,以实际扣费为准')}

@@ -1810,7 +1828,7 @@ export function renderClaudeLogContent( user_group_ratio, ); groupRatio = effectiveGroupRatio; - + // 获取货币配置 const { symbol, rate } = getCurrencyConfig(); diff --git a/web/src/helpers/secureApiCall.js b/web/src/helpers/secureApiCall.js index b82a6ae9..0054e04a 100644 --- a/web/src/helpers/secureApiCall.js +++ b/web/src/helpers/secureApiCall.js @@ -37,7 +37,7 @@ export function isVerificationRequiredError(error) { const verificationCodes = [ 'VERIFICATION_REQUIRED', 'VERIFICATION_EXPIRED', - 'VERIFICATION_INVALID' + 'VERIFICATION_INVALID', ]; return verificationCodes.includes(data.code); @@ -57,6 +57,6 @@ export function extractVerificationInfo(error) { return { code: data.code, message: data.message || '需要安全验证', - required: true + required: true, }; -} \ No newline at end of file +} diff --git a/web/src/hooks/channels/useChannelsData.jsx b/web/src/hooks/channels/useChannelsData.jsx index 109d2e0f..f3f99f01 100644 --- a/web/src/hooks/channels/useChannelsData.jsx +++ b/web/src/hooks/channels/useChannelsData.jsx @@ -84,7 +84,7 @@ export const useChannelsData = () => { const [selectedModelKeys, setSelectedModelKeys] = useState([]); const [isBatchTesting, setIsBatchTesting] = useState(false); const [modelTablePage, setModelTablePage] = useState(1); -const [selectedEndpointType, setSelectedEndpointType] = useState(''); + const [selectedEndpointType, setSelectedEndpointType] = useState(''); // 使用 ref 来避免闭包问题,类似旧版实现 const shouldStopBatchTestingRef = useRef(false); diff --git a/web/src/hooks/common/useSecureVerification.jsx b/web/src/hooks/common/useSecureVerification.jsx index e60a104d..9109ec7d 100644 --- a/web/src/hooks/common/useSecureVerification.jsx +++ b/web/src/hooks/common/useSecureVerification.jsx @@ -31,11 +31,11 @@ import { isVerificationRequiredError } from '../../helpers/secureApiCall'; * @param {string} options.successMessage - 成功提示消息 * @param {boolean} options.autoReset - 验证完成后是否自动重置状态,默认为 true */ -export const useSecureVerification = ({ - onSuccess, - onError, +export const useSecureVerification = ({ + onSuccess, + onError, successMessage, - autoReset = true + autoReset = true, } = {}) => { const { t } = useTranslation(); @@ -43,7 +43,7 @@ export const useSecureVerification = ({ const [verificationMethods, setVerificationMethods] = useState({ has2FA: false, hasPasskey: false, - passkeySupported: false + passkeySupported: false, }); // 模态框状态 @@ -54,12 +54,13 @@ export const useSecureVerification = ({ method: null, // '2fa' | 'passkey' loading: false, code: '', - apiCall: null + apiCall: null, }); // 检查可用的验证方式 const checkVerificationMethods = useCallback(async () => { - const methods = await SecureVerificationService.checkAvailableVerificationMethods(); + const methods = + await SecureVerificationService.checkAvailableVerificationMethods(); setVerificationMethods(methods); return methods; }, []); @@ -75,94 +76,108 @@ export const useSecureVerification = ({ method: null, loading: false, code: '', - apiCall: null + apiCall: null, }); setIsModalVisible(false); }, []); // 开始验证流程 - const startVerification = useCallback(async (apiCall, options = {}) => { - const { preferredMethod, title, description } = options; + const startVerification = useCallback( + async (apiCall, options = {}) => { + const { preferredMethod, title, description } = options; - // 检查验证方式 - const methods = await checkVerificationMethods(); + // 检查验证方式 + const methods = await checkVerificationMethods(); - if (!methods.has2FA && !methods.hasPasskey) { - const errorMessage = t('您需要先启用两步验证或 Passkey 才能执行此操作'); - showError(errorMessage); - onError?.(new Error(errorMessage)); - return false; - } - - // 设置默认验证方式 - let defaultMethod = preferredMethod; - if (!defaultMethod) { - if (methods.hasPasskey && methods.passkeySupported) { - defaultMethod = 'passkey'; - } else if (methods.has2FA) { - defaultMethod = '2fa'; + if (!methods.has2FA && !methods.hasPasskey) { + const errorMessage = t('您需要先启用两步验证或 Passkey 才能执行此操作'); + showError(errorMessage); + onError?.(new Error(errorMessage)); + return false; } - } - setVerificationState(prev => ({ - ...prev, - method: defaultMethod, - apiCall, - title, - description - })); - setIsModalVisible(true); + // 设置默认验证方式 + let defaultMethod = preferredMethod; + if (!defaultMethod) { + if (methods.hasPasskey && methods.passkeySupported) { + defaultMethod = 'passkey'; + } else if (methods.has2FA) { + defaultMethod = '2fa'; + } + } - return true; - }, [checkVerificationMethods, onError, t]); + setVerificationState((prev) => ({ + ...prev, + method: defaultMethod, + apiCall, + title, + description, + })); + setIsModalVisible(true); + + return true; + }, + [checkVerificationMethods, onError, t], + ); // 执行验证 - const executeVerification = useCallback(async (method, code = '') => { - if (!verificationState.apiCall) { - showError(t('验证配置错误')); - return; - } - - setVerificationState(prev => ({ ...prev, loading: true })); - - try { - // 先调用验证 API,成功后后端会设置 session - await SecureVerificationService.verify(method, code); - - // 验证成功,调用业务 API(此时中间件会通过) - const result = await verificationState.apiCall(); - - // 显示成功消息 - if (successMessage) { - showSuccess(successMessage); + const executeVerification = useCallback( + async (method, code = '') => { + if (!verificationState.apiCall) { + showError(t('验证配置错误')); + return; } - // 调用成功回调 - onSuccess?.(result, method); + setVerificationState((prev) => ({ ...prev, loading: true })); - // 自动重置状态 - if (autoReset) { - resetState(); + try { + // 先调用验证 API,成功后后端会设置 session + await SecureVerificationService.verify(method, code); + + // 验证成功,调用业务 API(此时中间件会通过) + const result = await verificationState.apiCall(); + + // 显示成功消息 + if (successMessage) { + showSuccess(successMessage); + } + + // 调用成功回调 + onSuccess?.(result, method); + + // 自动重置状态 + if (autoReset) { + resetState(); + } + + return result; + } catch (error) { + showError(error.message || t('验证失败,请重试')); + onError?.(error); + throw error; + } finally { + setVerificationState((prev) => ({ ...prev, loading: false })); } - - return result; - } catch (error) { - showError(error.message || t('验证失败,请重试')); - onError?.(error); - throw error; - } finally { - setVerificationState(prev => ({ ...prev, loading: false })); - } - }, [verificationState.apiCall, successMessage, onSuccess, onError, autoReset, resetState, t]); + }, + [ + verificationState.apiCall, + successMessage, + onSuccess, + onError, + autoReset, + resetState, + t, + ], + ); // 设置验证码 const setVerificationCode = useCallback((code) => { - setVerificationState(prev => ({ ...prev, code })); + setVerificationState((prev) => ({ ...prev, code })); }, []); // 切换验证方式 const switchVerificationMethod = useCallback((method) => { - setVerificationState(prev => ({ ...prev, method, code: '' })); + setVerificationState((prev) => ({ ...prev, method, code: '' })); }, []); // 取消验证 @@ -171,20 +186,29 @@ export const useSecureVerification = ({ }, [resetState]); // 检查是否可以使用某种验证方式 - const canUseMethod = useCallback((method) => { - switch (method) { - case '2fa': - return verificationMethods.has2FA; - case 'passkey': - return verificationMethods.hasPasskey && verificationMethods.passkeySupported; - default: - return false; - } - }, [verificationMethods]); + const canUseMethod = useCallback( + (method) => { + switch (method) { + case '2fa': + return verificationMethods.has2FA; + case 'passkey': + return ( + verificationMethods.hasPasskey && + verificationMethods.passkeySupported + ); + default: + return false; + } + }, + [verificationMethods], + ); // 获取推荐的验证方式 const getRecommendedMethod = useCallback(() => { - if (verificationMethods.hasPasskey && verificationMethods.passkeySupported) { + if ( + verificationMethods.hasPasskey && + verificationMethods.passkeySupported + ) { return 'passkey'; } if (verificationMethods.has2FA) { @@ -200,22 +224,25 @@ export const useSecureVerification = ({ * @param {Object} options - 验证选项(同 startVerification) * @returns {Promise} */ - const withVerification = useCallback(async (apiCall, options = {}) => { - try { - // 直接尝试调用 API - return await apiCall(); - } catch (error) { - // 检查是否是需要验证的错误 - if (isVerificationRequiredError(error)) { - // 自动触发验证流程 - await startVerification(apiCall, options); - // 不抛出错误,让验证模态框处理 - return null; + const withVerification = useCallback( + async (apiCall, options = {}) => { + try { + // 直接尝试调用 API + return await apiCall(); + } catch (error) { + // 检查是否是需要验证的错误 + if (isVerificationRequiredError(error)) { + // 自动触发验证流程 + await startVerification(apiCall, options); + // 不抛出错误,让验证模态框处理 + return null; + } + // 其他错误继续抛出 + throw error; } - // 其他错误继续抛出 - throw error; - } - }, [startVerification]); + }, + [startVerification], + ); return { // 状态 @@ -238,9 +265,10 @@ export const useSecureVerification = ({ withVerification, // 新增:自动处理验证的包装函数 // 便捷属性 - hasAnyVerificationMethod: verificationMethods.has2FA || verificationMethods.hasPasskey, + hasAnyVerificationMethod: + verificationMethods.has2FA || verificationMethods.hasPasskey, isLoading: verificationState.loading, currentMethod: verificationState.method, - code: verificationState.code + code: verificationState.code, }; -}; \ No newline at end of file +}; diff --git a/web/src/hooks/users/useUsersData.jsx b/web/src/hooks/users/useUsersData.jsx index 38579c2f..f906be54 100644 --- a/web/src/hooks/users/useUsersData.jsx +++ b/web/src/hooks/users/useUsersData.jsx @@ -86,7 +86,7 @@ export const useUsersData = () => { }; // Search users with keyword and group -const searchUsers = async ( + const searchUsers = async ( startIdx, pageSize, searchKeyword = null, diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 45f7181e..8b4f1b41 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1285,7 +1285,6 @@ "可视化倍率设置": "Visual model ratio settings", "确定重置模型倍率吗?": "Confirm to reset model ratio?", "模型固定价格": "Model price per call", - "模型补全倍率(仅对自定义模型有效)": "Model completion ratio (only effective for custom models)", "保存模型倍率设置": "Save model ratio settings", "重置模型倍率": "Reset model ratio", "一次调用消耗多少刀,优先级大于模型倍率": "How much USD one call costs, priority over model ratio", @@ -2177,7 +2176,6 @@ "最后使用时间": "Last used time", "备份支持": "Backup support", "支持备份": "Supported", - "不支持": "Not supported", "备份状态": "Backup state", "已备份": "Backed up", "未备份": "Not backed up", @@ -2248,5 +2246,18 @@ "轮询模式必须搭配Redis和内存缓存功能使用,否则性能将大幅降低,并且无法实现轮询功能": "Polling mode must be used with Redis and memory cache functions, otherwise the performance will be significantly reduced and the polling function will not be implemented", "common": { "changeLanguage": "Change Language" - } + }, + "充值账单": "Recharge Bills", + "订单号": "Order No.", + "支付金额": "Payment Amount", + "待支付": "Pending", + "加载失败": "Load failed", + "加载账单失败": "Failed to load bills", + "暂无充值记录": "No recharge records", + "账单": "Bills", + "补单": "Complete Order", + "补单成功": "Order completed successfully", + "补单失败": "Failed to complete order", + "确认补单": "Confirm Order Completion", + "是否将该订单标记为成功并为用户入账?": "Mark this order as successful and credit the user?" } diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index ad60a6f5..e5db1125 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -2238,5 +2238,18 @@ "配置 Passkey": "Configurer Passkey", "重置 2FA": "Réinitialiser 2FA", "重置 Passkey": "Réinitialiser le Passkey", - "默认使用系统名称": "Le nom du système est utilisé par défaut" + "默认使用系统名称": "Le nom du système est utilisé par défaut", + "充值账单": "Factures de recharge", + "订单号": "N° de commande", + "支付金额": "Montant payé", + "待支付": "En attente", + "加载失败": "Échec du chargement", + "加载账单失败": "Échec du chargement des factures", + "暂无充值记录": "Aucune recharge", + "账单": "Factures", + "补单": "Compléter la commande", + "补单成功": "Commande complétée avec succès", + "补单失败": "Échec de la complétion de la commande", + "确认补单": "Confirmer la complétion", + "是否将该订单标记为成功并为用户入账?": "Marquer cette commande comme réussie et créditer l'utilisateur ?" } diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index fb6fbf99..dcb693ec 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -94,5 +94,22 @@ "允许通过 Passkey 登录 & 认证": "允许通过 Passkey 登录 & 认证", "确认解绑 Passkey": "确认解绑 Passkey", "解绑后将无法使用 Passkey 登录,确定要继续吗?": "解绑后将无法使用 Passkey 登录,确定要继续吗?", - "确认解绑": "确认解绑" + "确认解绑": "确认解绑", + "充值账单": "充值账单", + "订单号": "订单号", + "支付金额": "支付金额", + "待支付": "待支付", + "加载失败": "加载失败", + "加载账单失败": "加载账单失败", + "暂无充值记录": "暂无充值记录", + "账单": "账单", + "支付方式": "支付方式", + "支付宝": "支付宝", + "微信": "微信", + "补单": "补单", + "补单成功": "补单成功", + "补单失败": "补单失败", + "确认补单": "确认补单", + "是否将该订单标记为成功并为用户入账?": "是否将该订单标记为成功并为用户入账?", + "操作": "操作" } diff --git a/web/src/pages/Setting/Chat/SettingsChats.jsx b/web/src/pages/Setting/Chat/SettingsChats.jsx index 01591c78..f7f309ac 100644 --- a/web/src/pages/Setting/Chat/SettingsChats.jsx +++ b/web/src/pages/Setting/Chat/SettingsChats.jsx @@ -227,7 +227,7 @@ export default function SettingsChats(props) { const isDuplicate = chatConfigs.some( (config) => config.name === values.name && - (!isEdit || config.id !== editingConfig.id) + (!isEdit || config.id !== editingConfig.id), ); if (isDuplicate) { diff --git a/web/src/pages/Setting/Operation/SettingsLog.jsx b/web/src/pages/Setting/Operation/SettingsLog.jsx index a1f5a49f..d309a12b 100644 --- a/web/src/pages/Setting/Operation/SettingsLog.jsx +++ b/web/src/pages/Setting/Operation/SettingsLog.jsx @@ -18,7 +18,16 @@ For commercial licensing, please contact support@quantumnous.com */ import React, { useEffect, useState, useRef } from 'react'; -import { Button, Col, Form, Row, Spin, DatePicker, Typography, Modal } from '@douyinfe/semi-ui'; +import { + Button, + Col, + Form, + Row, + Spin, + DatePicker, + Typography, + Modal, +} from '@douyinfe/semi-ui'; import dayjs from 'dayjs'; import { useTranslation } from 'react-i18next'; import { @@ -90,40 +99,58 @@ export default function SettingsLog(props) { const targetTime = targetDate.format('YYYY-MM-DD HH:mm:ss'); const currentTime = now.format('YYYY-MM-DD HH:mm:ss'); const daysDiff = now.diff(targetDate, 'day'); - + Modal.confirm({ title: t('确认清除历史日志'), content: (

{t('当前时间')}: - {currentTime} + + {currentTime} +

{t('选择时间')}: - {targetTime} + + {targetTime} + {daysDiff > 0 && ( - ({t('约')} {daysDiff} {t('天前')}) + + {' '} + ({t('约')} {daysDiff} {t('天前')}) + )}

-
- ⚠️ {t('注意')}: +
+ + ⚠️ {t('注意')}: + {t('将删除')} - {targetTime} + + {targetTime} + {daysDiff > 0 && ( - ({t('约')} {daysDiff} {t('天前')}) + + {' '} + ({t('约')} {daysDiff} {t('天前')}) + )} {t('之前的所有日志')}

- {t('此操作不可恢复,请仔细确认时间后再操作!')} + + {t('此操作不可恢复,请仔细确认时间后再操作!')} +

), @@ -203,10 +230,18 @@ export default function SettingsLog(props) { }); }} /> - + {t('将清除选定时间之前的所有日志')} - diff --git a/web/src/services/secureVerification.js b/web/src/services/secureVerification.js index 93cdd0a4..51f871a9 100644 --- a/web/src/services/secureVerification.js +++ b/web/src/services/secureVerification.js @@ -21,7 +21,7 @@ import { API, showError } from '../helpers'; import { prepareCredentialRequestOptions, buildAssertionResult, - isPasskeySupported + isPasskeySupported, } from '../helpers/passkey'; /** @@ -35,46 +35,54 @@ export class SecureVerificationService { */ static async checkAvailableVerificationMethods() { try { - const [twoFAResponse, passkeyResponse, passkeySupported] = await Promise.all([ - API.get('/api/user/2fa/status'), - API.get('/api/user/passkey'), - isPasskeySupported() - ]); + const [twoFAResponse, passkeyResponse, passkeySupported] = + await Promise.all([ + API.get('/api/user/2fa/status'), + API.get('/api/user/passkey'), + isPasskeySupported(), + ]); console.log('=== DEBUGGING VERIFICATION METHODS ==='); console.log('2FA Response:', JSON.stringify(twoFAResponse, null, 2)); - console.log('Passkey Response:', JSON.stringify(passkeyResponse, null, 2)); - - const has2FA = twoFAResponse.data?.success && twoFAResponse.data?.data?.enabled === true; - const hasPasskey = passkeyResponse.data?.success && passkeyResponse.data?.data?.enabled === true; - + console.log( + 'Passkey Response:', + JSON.stringify(passkeyResponse, null, 2), + ); + + const has2FA = + twoFAResponse.data?.success && + twoFAResponse.data?.data?.enabled === true; + const hasPasskey = + passkeyResponse.data?.success && + passkeyResponse.data?.data?.enabled === true; + console.log('has2FA calculation:', { success: twoFAResponse.data?.success, dataExists: !!twoFAResponse.data?.data, enabled: twoFAResponse.data?.data?.enabled, - result: has2FA + result: has2FA, }); - + console.log('hasPasskey calculation:', { success: passkeyResponse.data?.success, dataExists: !!passkeyResponse.data?.data, enabled: passkeyResponse.data?.data?.enabled, - result: hasPasskey + result: hasPasskey, }); const result = { has2FA, hasPasskey, - passkeySupported + passkeySupported, }; - + return result; } catch (error) { console.error('Failed to check verification methods:', error); return { has2FA: false, hasPasskey: false, - passkeySupported: false + passkeySupported: false, }; } } @@ -92,7 +100,7 @@ export class SecureVerificationService { // 调用通用验证 API,验证成功后后端会设置 session const verifyResponse = await API.post('/api/verify', { method: '2fa', - code: code.trim() + code: code.trim(), }); if (!verifyResponse.data?.success) { @@ -115,7 +123,9 @@ export class SecureVerificationService { } // 准备WebAuthn选项 - const publicKey = prepareCredentialRequestOptions(beginResponse.data.data.options); + const publicKey = prepareCredentialRequestOptions( + beginResponse.data.data.options, + ); // 执行WebAuthn验证 const credential = await navigator.credentials.get({ publicKey }); @@ -127,14 +137,17 @@ export class SecureVerificationService { const assertionResult = buildAssertionResult(credential); // 完成验证 - const finishResponse = await API.post('/api/user/passkey/verify/finish', assertionResult); + const finishResponse = await API.post( + '/api/user/passkey/verify/finish', + assertionResult, + ); if (!finishResponse.data?.success) { throw new Error(finishResponse.data?.message || '验证失败'); } // 调用通用验证 API 设置 session(Passkey 验证已完成) const verifyResponse = await API.post('/api/verify', { - method: 'passkey' + method: 'passkey', }); if (!verifyResponse.data?.success) { @@ -191,27 +204,29 @@ export const createApiCalls = { * @param {string} method - HTTP方法,默认为 'POST' * @param {Object} extraData - 额外的请求数据 */ - custom: (url, method = 'POST', extraData = {}) => async () => { - // 新系统中,验证已通过中间件处理 - const data = extraData; + custom: + (url, method = 'POST', extraData = {}) => + async () => { + // 新系统中,验证已通过中间件处理 + const data = extraData; - let response; - switch (method.toUpperCase()) { - case 'GET': - response = await API.get(url, { params: data }); - break; - case 'POST': - response = await API.post(url, data); - break; - case 'PUT': - response = await API.put(url, data); - break; - case 'DELETE': - response = await API.delete(url, { data }); - break; - default: - throw new Error(`不支持的HTTP方法: ${method}`); - } - return response.data; - } -}; \ No newline at end of file + let response; + switch (method.toUpperCase()) { + case 'GET': + response = await API.get(url, { params: data }); + break; + case 'POST': + response = await API.post(url, data); + break; + case 'PUT': + response = await API.put(url, data); + break; + case 'DELETE': + response = await API.delete(url, { data }); + break; + default: + throw new Error(`不支持的HTTP方法: ${method}`); + } + return response.data; + }, +}; From 2389dbafc5b7f134f678c3120616ae1c76d1f490 Mon Sep 17 00:00:00 2001 From: "Apple\\Apple" Date: Tue, 7 Oct 2025 00:46:47 +0800 Subject: [PATCH 138/243] =?UTF-8?q?=E2=9C=A8=20feat(topup):=20Admin-wide?= =?UTF-8?q?=20topup=20listing=20and=20route=20reorganization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow administrators to view all platform topup orders and streamline admin-only routes. Frontend - TopupHistoryModal: dynamically switch endpoint by role - Admin → GET /api/user/topup (all orders) - Non-admin → GET /api/user/topup/self (own orders) - Use shared utils `isAdmin()`; keep logic centralized and DRY - Minor UI: set admin action button theme to outline for clarity Backend - model/topup.go: add GetAllTopUps(pageInfo) with pagination (ordered by id desc) - controller/topup.go: add GetAllTopUps handler returning PageInfo response - router/api-router.go: - Add admin route GET /api/user/topup (AdminAuth) - Move POST /api/user/topup/complete to adminRoute (keeps path stable, consolidates admin endpoints) Security/Behavior - Admin-only endpoints now reside under the admin route group with AdminAuth - No behavior change for regular users; no schema changes Affected files - model/topup.go - controller/topup.go - router/api-router.go - web/src/components/topup/modals/TopupHistoryModal.jsx --- controller/topup.go | 15 ++++++++++ model/topup.go | 29 +++++++++++++++++++ router/api-router.go | 3 +- .../topup/modals/TopupHistoryModal.jsx | 7 +++-- 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/controller/topup.go b/controller/topup.go index e6f6eef2..8626695f 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -330,6 +330,21 @@ func GetUserTopUps(c *gin.Context) { common.ApiSuccess(c, pageInfo) } +// GetAllTopUps 管理员获取全平台充值记录 +func GetAllTopUps(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + + topups, total, err := model.GetAllTopUps(pageInfo) + if err != nil { + common.ApiError(c, err) + return + } + + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(topups) + common.ApiSuccess(c, pageInfo) +} + type AdminCompleteTopupRequest struct { TradeNo string `json:"trade_no"` } diff --git a/model/topup.go b/model/topup.go index c280db43..e46a5a88 100644 --- a/model/topup.go +++ b/model/topup.go @@ -136,6 +136,35 @@ func GetUserTopUps(userId int, pageInfo *common.PageInfo) (topups []*TopUp, tota return topups, total, nil } +// GetAllTopUps 获取全平台的充值记录(管理员使用) +func GetAllTopUps(pageInfo *common.PageInfo) (topups []*TopUp, total int64, err error) { + tx := DB.Begin() + if tx.Error != nil { + return nil, 0, tx.Error + } + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + if err = tx.Model(&TopUp{}).Count(&total).Error; err != nil { + tx.Rollback() + return nil, 0, err + } + + if err = tx.Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&topups).Error; err != nil { + tx.Rollback() + return nil, 0, err + } + + if err = tx.Commit().Error; err != nil { + return nil, 0, err + } + + return topups, total, nil +} + // ManualCompleteTopUp 管理员手动完成订单并给用户充值 func ManualCompleteTopUp(tradeNo string) error { if tradeNo == "" { diff --git a/router/api-router.go b/router/api-router.go index 49a42360..963abd10 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -80,7 +80,6 @@ func SetApiRouter(router *gin.Engine) { selfRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.RequestStripePay) selfRoute.POST("/stripe/amount", controller.RequestStripeAmount) selfRoute.POST("/aff_transfer", controller.TransferAffQuota) - selfRoute.POST("/topup/complete", middleware.AdminAuth(), controller.AdminCompleteTopUp) selfRoute.PUT("/setting", controller.UpdateUserSetting) // 2FA routes @@ -95,6 +94,8 @@ func SetApiRouter(router *gin.Engine) { adminRoute.Use(middleware.AdminAuth()) { adminRoute.GET("/", controller.GetAllUsers) + adminRoute.GET("/topup", controller.GetAllTopUps) + adminRoute.POST("/topup/complete", controller.AdminCompleteTopUp) adminRoute.GET("/search", controller.SearchUsers) adminRoute.GET("/:id", controller.GetUser) adminRoute.POST("/", controller.CreateUser) diff --git a/web/src/components/topup/modals/TopupHistoryModal.jsx b/web/src/components/topup/modals/TopupHistoryModal.jsx index e2548f38..fe50c686 100644 --- a/web/src/components/topup/modals/TopupHistoryModal.jsx +++ b/web/src/components/topup/modals/TopupHistoryModal.jsx @@ -63,9 +63,10 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => { const loadTopups = async (currentPage, currentPageSize) => { setLoading(true); try { - const res = await API.get( - `/api/user/topup/self?p=${currentPage}&page_size=${currentPageSize}`, - ); + const endpoint = isAdmin() + ? `/api/user/topup?p=${currentPage}&page_size=${currentPageSize}` + : `/api/user/topup/self?p=${currentPage}&page_size=${currentPageSize}`; + const res = await API.get(endpoint); const { success, message, data } = res.data; if (success) { setTopups(data.items || []); From a8c9b24c7eb71b755924a5793166ab5fe42d44a3 Mon Sep 17 00:00:00 2001 From: "Apple\\Apple" Date: Tue, 7 Oct 2025 00:55:01 +0800 Subject: [PATCH 139/243] =?UTF-8?q?=F0=9F=94=8E=20feat(topup):=20add=20ord?= =?UTF-8?q?er=20number=20search=20for=20billing=20history=20(admin=20and?= =?UTF-8?q?=20user)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable searching topup records by trade_no across both admin-wide and user-only views. Frontend - TopupHistoryModal.jsx: - Add search input with prefix icon (IconSearch) to filter by order number - Send `keyword` query param to backend; works with both endpoints: - Admin: GET /api/user/topup?p=1&page_size=10&keyword=... - User: GET /api/user/topup/self?p=1&page_size=10&keyword=... - Keep endpoint auto-switching based on role (isAdmin) - Minor UI polish: outlined admin action button; keep Coins icon for amount Backend - model/topup.go: - Add SearchUserTopUps(userId, keyword, pageInfo) - Add SearchAllTopUps(keyword, pageInfo) - Both support pagination and `trade_no LIKE %keyword%` filtering (ordered by id desc) - controller/topup.go: - GetUserTopUps / GetAllTopUps accept optional `keyword` and route to search functions when present Routes - No new endpoints; search is enabled via `keyword` on existing: - GET /api/user/topup - GET /api/user/topup/self Affected files - model/topup.go - controller/topup.go - web/src/components/topup/modals/TopupHistoryModal.jsx --- controller/topup.go | 24 ++++++- model/topup.go | 68 +++++++++++++++++++ .../topup/modals/TopupHistoryModal.jsx | 22 ++++-- 3 files changed, 108 insertions(+), 6 deletions(-) diff --git a/controller/topup.go b/controller/topup.go index 8626695f..76a2521d 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -318,8 +318,18 @@ func RequestAmount(c *gin.Context) { func GetUserTopUps(c *gin.Context) { userId := c.GetInt("id") pageInfo := common.GetPageQuery(c) + keyword := c.Query("keyword") - topups, total, err := model.GetUserTopUps(userId, pageInfo) + var ( + topups []*model.TopUp + total int64 + err error + ) + if keyword != "" { + topups, total, err = model.SearchUserTopUps(userId, keyword, pageInfo) + } else { + topups, total, err = model.GetUserTopUps(userId, pageInfo) + } if err != nil { common.ApiError(c, err) return @@ -333,8 +343,18 @@ func GetUserTopUps(c *gin.Context) { // GetAllTopUps 管理员获取全平台充值记录 func GetAllTopUps(c *gin.Context) { pageInfo := common.GetPageQuery(c) + keyword := c.Query("keyword") - topups, total, err := model.GetAllTopUps(pageInfo) + var ( + topups []*model.TopUp + total int64 + err error + ) + if keyword != "" { + topups, total, err = model.SearchAllTopUps(keyword, pageInfo) + } else { + topups, total, err = model.GetAllTopUps(pageInfo) + } if err != nil { common.ApiError(c, err) return diff --git a/model/topup.go b/model/topup.go index e46a5a88..380f5851 100644 --- a/model/topup.go +++ b/model/topup.go @@ -165,6 +165,74 @@ func GetAllTopUps(pageInfo *common.PageInfo) (topups []*TopUp, total int64, err return topups, total, nil } +// SearchUserTopUps 按订单号搜索某用户的充值记录 +func SearchUserTopUps(userId int, keyword string, pageInfo *common.PageInfo) (topups []*TopUp, total int64, err error) { + tx := DB.Begin() + if tx.Error != nil { + return nil, 0, tx.Error + } + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + query := tx.Model(&TopUp{}).Where("user_id = ?", userId) + if keyword != "" { + like := "%%" + keyword + "%%" + query = query.Where("trade_no LIKE ?", like) + } + + if err = query.Count(&total).Error; err != nil { + tx.Rollback() + return nil, 0, err + } + + if err = query.Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&topups).Error; err != nil { + tx.Rollback() + return nil, 0, err + } + + if err = tx.Commit().Error; err != nil { + return nil, 0, err + } + return topups, total, nil +} + +// SearchAllTopUps 按订单号搜索全平台充值记录(管理员使用) +func SearchAllTopUps(keyword string, pageInfo *common.PageInfo) (topups []*TopUp, total int64, err error) { + tx := DB.Begin() + if tx.Error != nil { + return nil, 0, tx.Error + } + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + query := tx.Model(&TopUp{}) + if keyword != "" { + like := "%%" + keyword + "%%" + query = query.Where("trade_no LIKE ?", like) + } + + if err = query.Count(&total).Error; err != nil { + tx.Rollback() + return nil, 0, err + } + + if err = query.Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&topups).Error; err != nil { + tx.Rollback() + return nil, 0, err + } + + if err = tx.Commit().Error; err != nil { + return nil, 0, err + } + return topups, total, nil +} + // ManualCompleteTopUp 管理员手动完成订单并给用户充值 func ManualCompleteTopUp(tradeNo string) error { if tradeNo == "" { diff --git a/web/src/components/topup/modals/TopupHistoryModal.jsx b/web/src/components/topup/modals/TopupHistoryModal.jsx index fe50c686..57916a9a 100644 --- a/web/src/components/topup/modals/TopupHistoryModal.jsx +++ b/web/src/components/topup/modals/TopupHistoryModal.jsx @@ -25,12 +25,14 @@ import { Toast, Empty, Button, + Input, } from '@douyinfe/semi-ui'; import { IllustrationNoResult, IllustrationNoResultDark, } from '@douyinfe/semi-illustrations'; import { Coins } from 'lucide-react'; +import { IconSearch } from '@douyinfe/semi-icons'; import { API, timestamp2string } from '../../../helpers'; import { isAdmin } from '../../../helpers/utils'; import { useIsMobile } from '../../../hooks/common/useIsMobile'; @@ -57,15 +59,18 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => { const [total, setTotal] = useState(0); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(10); + const [keyword, setKeyword] = useState(''); const isMobile = useIsMobile(); const loadTopups = async (currentPage, currentPageSize) => { setLoading(true); try { - const endpoint = isAdmin() - ? `/api/user/topup?p=${currentPage}&page_size=${currentPageSize}` - : `/api/user/topup/self?p=${currentPage}&page_size=${currentPageSize}`; + const base = isAdmin() ? '/api/user/topup' : '/api/user/topup/self'; + const qs = + `p=${currentPage}&page_size=${currentPageSize}` + + (keyword ? `&keyword=${encodeURIComponent(keyword)}` : ''); + const endpoint = `${base}?${qs}`; const res = await API.get(endpoint); const { success, message, data } = res.data; if (success) { @@ -86,7 +91,7 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => { if (visible) { loadTopups(page, pageSize); } - }, [visible, page, pageSize]); + }, [visible, page, pageSize, keyword]); const handlePageChange = (currentPage) => { setPage(currentPage); @@ -221,6 +226,15 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => { footer={null} size={isMobile ? 'full-width' : 'large'} > +
+ } + placeholder={t('订单号')} + value={keyword} + onChange={setKeyword} + showClear + /> +
Date: Tue, 7 Oct 2025 14:13:14 +0800 Subject: [PATCH 140/243] =?UTF-8?q?=E2=9D=A4=20fix(topup):=20prevent=20nil?= =?UTF-8?q?-pointer=20in=20Epay=20callback;=20reset=20page=20on=20search?= =?UTF-8?q?=20Add=20early=20return=20when=20Epay=20client=20is=20missing?= =?UTF-8?q?=20in=20controller/topup.go=20to=20avoid=20panic=20Introduce=20?= =?UTF-8?q?handleKeywordChange=20in=20TopupHistoryModal.jsx=20to=20reset?= =?UTF-8?q?=20page=20to=201=20when=20keyword=20updates=20Wire=20input=20on?= =?UTF-8?q?Change=20to=20new=20handler;=20minor=20UX=20improvement=20to=20?= =?UTF-8?q?avoid=20empty=20results=20on=20pagination=20mismatch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controller/topup.go | 2 +- web/src/components/topup/modals/TopupHistoryModal.jsx | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/controller/topup.go b/controller/topup.go index 76a2521d..62bc676d 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -237,8 +237,8 @@ func EpayNotify(c *gin.Context) { _, err := c.Writer.Write([]byte("fail")) if err != nil { log.Println("易支付回调写入失败") - return } + return } verifyInfo, err := client.Verify(params) if err == nil && verifyInfo.VerifyStatus { diff --git a/web/src/components/topup/modals/TopupHistoryModal.jsx b/web/src/components/topup/modals/TopupHistoryModal.jsx index 57916a9a..12abfbd7 100644 --- a/web/src/components/topup/modals/TopupHistoryModal.jsx +++ b/web/src/components/topup/modals/TopupHistoryModal.jsx @@ -102,6 +102,11 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => { setPage(1); }; + const handleKeywordChange = (value) => { + setKeyword(value); + setPage(1); + }; + // 管理员补单 const handleAdminComplete = async (tradeNo) => { try { @@ -231,7 +236,7 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => { prefix={} placeholder={t('订单号')} value={keyword} - onChange={setKeyword} + onChange={handleKeywordChange} showClear /> From 689105764738b9b2d0cefeb0eca2a333b68e5ce5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=AD=E3=83=A5=E3=83=93=E3=83=93=E3=82=A4?= Date: Wed, 8 Oct 2025 10:43:47 +0800 Subject: [PATCH 141/243] feat(web): add settings & pages of privacy policy & user agreement --- controller/misc.go | 22 ++ model/option.go | 2 + router/api-router.go | 2 + web/src/App.jsx | 18 ++ web/src/components/auth/RegisterForm.jsx | 64 +++++ web/src/components/settings/OtherSetting.jsx | 58 +++++ web/src/i18n/locales/en.json | 19 +- web/src/i18n/locales/fr.json | 21 +- web/src/i18n/locales/zh.json | 21 +- web/src/pages/PrivacyPolicy/index.jsx | 249 ++++++++++++++++++ web/src/pages/UserAgreement/index.jsx | 252 +++++++++++++++++++ web/src/utils/contentDetector.js | 61 +++++ 12 files changed, 786 insertions(+), 3 deletions(-) create mode 100644 web/src/pages/PrivacyPolicy/index.jsx create mode 100644 web/src/pages/UserAgreement/index.jsx create mode 100644 web/src/utils/contentDetector.js diff --git a/controller/misc.go b/controller/misc.go index a3e017f8..38e98fe9 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -151,6 +151,28 @@ func GetAbout(c *gin.Context) { return } +func GetUserAgreement(c *gin.Context) { + common.OptionMapRWMutex.RLock() + defer common.OptionMapRWMutex.RUnlock() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": common.OptionMap["UserAgreement"], + }) + return +} + +func GetPrivacyPolicy(c *gin.Context) { + common.OptionMapRWMutex.RLock() + defer common.OptionMapRWMutex.RUnlock() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": common.OptionMap["PrivacyPolicy"], + }) + return +} + func GetMidjourney(c *gin.Context) { common.OptionMapRWMutex.RLock() defer common.OptionMapRWMutex.RUnlock() diff --git a/model/option.go b/model/option.go index 77525ea2..7f341ff3 100644 --- a/model/option.go +++ b/model/option.go @@ -61,6 +61,8 @@ func InitOptionMap() { common.OptionMap["SMTPToken"] = "" common.OptionMap["SMTPSSLEnabled"] = strconv.FormatBool(common.SMTPSSLEnabled) common.OptionMap["Notice"] = "" + common.OptionMap["UserAgreement"] = "" + common.OptionMap["PrivacyPolicy"] = "" common.OptionMap["About"] = "" common.OptionMap["HomePageContent"] = "" common.OptionMap["Footer"] = common.Footer diff --git a/router/api-router.go b/router/api-router.go index 963abd10..7eefa936 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -20,6 +20,8 @@ func SetApiRouter(router *gin.Engine) { apiRouter.GET("/models", middleware.UserAuth(), controller.DashboardListModels) apiRouter.GET("/status/test", middleware.AdminAuth(), controller.TestStatus) apiRouter.GET("/notice", controller.GetNotice) + apiRouter.GET("/user-agreement", controller.GetUserAgreement) + apiRouter.GET("/privacy-policy", controller.GetPrivacyPolicy) apiRouter.GET("/about", controller.GetAbout) //apiRouter.GET("/midjourney", controller.GetMidjourney) apiRouter.GET("/home_page_content", controller.GetHomePageContent) diff --git a/web/src/App.jsx b/web/src/App.jsx index 635742f9..06e36489 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -51,6 +51,8 @@ import SetupCheck from './components/layout/SetupCheck'; const Home = lazy(() => import('./pages/Home')); const Dashboard = lazy(() => import('./pages/Dashboard')); const About = lazy(() => import('./pages/About')); +const UserAgreement = lazy(() => import('./pages/UserAgreement')); +const PrivacyPolicy = lazy(() => import('./pages/PrivacyPolicy')); function App() { const location = useLocation(); @@ -301,6 +303,22 @@ function App() { } /> + } key={location.pathname}> + + + } + /> + } key={location.pathname}> + + + } + /> { const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false); const [disableButton, setDisableButton] = useState(false); const [countdown, setCountdown] = useState(30); + const [agreedToTerms, setAgreedToTerms] = useState(false); + const [hasUserAgreement, setHasUserAgreement] = useState(false); + const [hasPrivacyPolicy, setHasPrivacyPolicy] = useState(false); const logo = getLogo(); const systemName = getSystemName(); @@ -106,6 +109,28 @@ const RegisterForm = () => { setTurnstileEnabled(true); setTurnstileSiteKey(status.turnstile_site_key); } + + // 检查用户协议和隐私政策是否已设置 + const checkTermsAvailability = async () => { + try { + const [userAgreementRes, privacyPolicyRes] = await Promise.all([ + API.get('/api/user-agreement'), + API.get('/api/privacy-policy') + ]); + + if (userAgreementRes.data.success && userAgreementRes.data.data) { + setHasUserAgreement(true); + } + + if (privacyPolicyRes.data.success && privacyPolicyRes.data.data) { + setHasPrivacyPolicy(true); + } + } catch (error) { + console.error('检查用户协议和隐私政策失败:', error); + } + }; + + checkTermsAvailability(); }, [status]); useEffect(() => { @@ -505,6 +530,44 @@ const RegisterForm = () => { )} + {(hasUserAgreement || hasPrivacyPolicy) && ( +
+ setAgreedToTerms(checked)} + > + + {t('我已阅读并同意')} + {hasUserAgreement && ( + <> + + {t('用户协议')} + + + )} + {hasUserAgreement && hasPrivacyPolicy && t('和')} + {hasPrivacyPolicy && ( + <> + + {t('隐私政策')} + + + )} + + +
+ )} +
diff --git a/web/src/components/settings/OtherSetting.jsx b/web/src/components/settings/OtherSetting.jsx index 18119d24..6a159e81 100644 --- a/web/src/components/settings/OtherSetting.jsx +++ b/web/src/components/settings/OtherSetting.jsx @@ -38,6 +38,8 @@ const OtherSetting = () => { const { t } = useTranslation(); let [inputs, setInputs] = useState({ Notice: '', + UserAgreement: '', + PrivacyPolicy: '', SystemName: '', Logo: '', Footer: '', @@ -69,6 +71,8 @@ const OtherSetting = () => { const [loadingInput, setLoadingInput] = useState({ Notice: false, + UserAgreement: false, + PrivacyPolicy: false, SystemName: false, Logo: false, HomePageContent: false, @@ -96,6 +100,32 @@ const OtherSetting = () => { setLoadingInput((loadingInput) => ({ ...loadingInput, Notice: false })); } }; + // 通用设置 - UserAgreement + const submitUserAgreement = async () => { + try { + setLoadingInput((loadingInput) => ({ ...loadingInput, UserAgreement: true })); + await updateOption('UserAgreement', inputs.UserAgreement); + showSuccess(t('用户协议已更新')); + } catch (error) { + console.error(t('用户协议更新失败'), error); + showError(t('用户协议更新失败')); + } finally { + setLoadingInput((loadingInput) => ({ ...loadingInput, UserAgreement: false })); + } + }; + // 通用设置 - PrivacyPolicy + const submitPrivacyPolicy = async () => { + try { + setLoadingInput((loadingInput) => ({ ...loadingInput, PrivacyPolicy: true })); + await updateOption('PrivacyPolicy', inputs.PrivacyPolicy); + showSuccess(t('隐私政策已更新')); + } catch (error) { + console.error(t('隐私政策更新失败'), error); + showError(t('隐私政策更新失败')); + } finally { + setLoadingInput((loadingInput) => ({ ...loadingInput, PrivacyPolicy: false })); + } + }; // 个性化设置 const formAPIPersonalization = useRef(); // 个性化设置 - SystemName @@ -324,6 +354,34 @@ const OtherSetting = () => { + + + + diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 8b4f1b41..7b0f4793 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -244,6 +244,8 @@ "检查更新": "Check for updates", "公告": "Announcement", "在此输入新的公告内容,支持 Markdown & HTML 代码": "Enter the new announcement content here, supports Markdown & HTML code", + "在此输入用户协议内容,支持 Markdown & HTML 代码": "Enter user agreement content here, supports Markdown & HTML code", + "在此输入隐私政策内容,支持 Markdown & HTML 代码": "Enter privacy policy content here, supports Markdown & HTML code", "保存公告": "Save Announcement", "个性化设置": "Personalization Settings", "系统名称": "System Name", @@ -1260,6 +1262,8 @@ "仅修改展示粒度,统计精确到小时": "Only modify display granularity, statistics accurate to the hour", "当运行通道全部测试时,超过此时间将自动禁用通道": "When running all channel tests, the channel will be automatically disabled when this time is exceeded", "设置公告": "Set notice", + "设置用户协议": "Set user agreement", + "设置隐私政策": "Set privacy policy", "设置 Logo": "Set Logo", "设置首页内容": "Set home page content", "设置关于": "Set about", @@ -2259,5 +2263,18 @@ "补单成功": "Order completed successfully", "补单失败": "Failed to complete order", "确认补单": "Confirm Order Completion", - "是否将该订单标记为成功并为用户入账?": "Mark this order as successful and credit the user?" + "是否将该订单标记为成功并为用户入账?": "Mark this order as successful and credit the user?", + "用户协议": "User Agreement", + "隐私政策": "Privacy Policy", + "用户协议更新失败": "Failed to update user agreement", + "隐私政策更新失败": "Failed to update privacy policy", + "管理员未设置用户协议内容": "Administrator has not set user agreement content", + "管理员未设置隐私政策内容": "Administrator has not set privacy policy content", + "加载用户协议内容失败...": "Failed to load user agreement content...", + "加载隐私政策内容失败...": "Failed to load privacy policy content...", + "我已阅读并同意": "I have read and agree to", + "和": " and ", + "请先阅读并同意用户协议和隐私政策": "Please read and agree to the user agreement and privacy policy first", + "填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议": "After filling in the user agreement content, users will be required to check that they have read the user agreement when registering", + "填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策": "After filling in the privacy policy content, users will be required to check that they have read the privacy policy when registering" } diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index e5db1125..ee2a3c29 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -2251,5 +2251,24 @@ "补单成功": "Commande complétée avec succès", "补单失败": "Échec de la complétion de la commande", "确认补单": "Confirmer la complétion", - "是否将该订单标记为成功并为用户入账?": "Marquer cette commande comme réussie et créditer l'utilisateur ?" + "是否将该订单标记为成功并为用户入账?": "Marquer cette commande comme réussie et créditer l'utilisateur ?", + "用户协议": "Accord utilisateur", + "隐私政策": "Politique de confidentialité", + "在此输入用户协议内容,支持 Markdown & HTML 代码": "Saisissez ici le contenu de l'accord utilisateur, prend en charge le code Markdown et HTML", + "在此输入隐私政策内容,支持 Markdown & HTML 代码": "Saisissez ici le contenu de la politique de confidentialité, prend en charge le code Markdown et HTML", + "设置用户协议": "Définir l'accord utilisateur", + "设置隐私政策": "Définir la politique de confidentialité", + "用户协议已更新": "L'accord utilisateur a été mis à jour", + "隐私政策已更新": "La politique de confidentialité a été mise à jour", + "用户协议更新失败": "Échec de la mise à jour de l'accord utilisateur", + "隐私政策更新失败": "Échec de la mise à jour de la politique de confidentialité", + "管理员未设置用户协议内容": "L'administrateur n'a pas défini le contenu de l'accord utilisateur", + "管理员未设置隐私政策内容": "L'administrateur n'a pas défini le contenu de la politique de confidentialité", + "加载用户协议内容失败...": "Échec du chargement du contenu de l'accord utilisateur...", + "加载隐私政策内容失败...": "Échec du chargement du contenu de la politique de confidentialité...", + "我已阅读并同意": "J'ai lu et j'accepte", + "和": " et ", + "请先阅读并同意用户协议和隐私政策": "Veuillez d'abord lire et accepter l'accord utilisateur et la politique de confidentialité", + "填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议": "Après avoir rempli le contenu de l'accord utilisateur, les utilisateurs devront cocher qu'ils ont lu l'accord utilisateur lors de l'inscription", + "填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策": "Après avoir rempli le contenu de la politique de confidentialité, les utilisateurs devront cocher qu'ils ont lu la politique de confidentialité lors de l'inscription" } diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index dcb693ec..661cbea9 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -111,5 +111,24 @@ "补单失败": "补单失败", "确认补单": "确认补单", "是否将该订单标记为成功并为用户入账?": "是否将该订单标记为成功并为用户入账?", - "操作": "操作" + "操作": "操作", + "用户协议": "用户协议", + "隐私政策": "隐私政策", + "在此输入用户协议内容,支持 Markdown & HTML 代码": "在此输入用户协议内容,支持 Markdown & HTML 代码", + "在此输入隐私政策内容,支持 Markdown & HTML 代码": "在此输入隐私政策内容,支持 Markdown & HTML 代码", + "设置用户协议": "设置用户协议", + "设置隐私政策": "设置隐私政策", + "用户协议已更新": "用户协议已更新", + "隐私政策已更新": "隐私政策已更新", + "用户协议更新失败": "用户协议更新失败", + "隐私政策更新失败": "隐私政策更新失败", + "管理员未设置用户协议内容": "管理员未设置用户协议内容", + "管理员未设置隐私政策内容": "管理员未设置隐私政策内容", + "加载用户协议内容失败...": "加载用户协议内容失败...", + "加载隐私政策内容失败...": "加载隐私政策内容失败...", + "我已阅读并同意": "我已阅读并同意", + "和": "和", + "请先阅读并同意用户协议和隐私政策": "请先阅读并同意用户协议和隐私政策", + "填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议": "填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议", + "填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策": "填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策" } diff --git a/web/src/pages/PrivacyPolicy/index.jsx b/web/src/pages/PrivacyPolicy/index.jsx new file mode 100644 index 00000000..373b8a28 --- /dev/null +++ b/web/src/pages/PrivacyPolicy/index.jsx @@ -0,0 +1,249 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useEffect, useState } from 'react'; +import { API, showError } from '../../helpers'; +import { Empty } from '@douyinfe/semi-ui'; +import { + IllustrationConstruction, + IllustrationConstructionDark, +} from '@douyinfe/semi-illustrations'; +import { useTranslation } from 'react-i18next'; +import MarkdownRenderer from '../../components/common/markdown/MarkdownRenderer'; +import { getContentType } from '../../utils/contentDetector'; + +const PrivacyPolicy = () => { + const { t } = useTranslation(); + const [privacyPolicy, setPrivacyPolicy] = useState(''); + const [privacyPolicyLoaded, setPrivacyPolicyLoaded] = useState(false); + const [contentType, setContentType] = useState('empty'); + const [htmlBody, setHtmlBody] = useState(''); + const [htmlStyles, setHtmlStyles] = useState(''); + const [htmlLinks, setHtmlLinks] = useState([]); + // Height of the top navigation/header in pixels. Adjust if your header is a different height. + const HEADER_HEIGHT = 64; + + const displayPrivacyPolicy = async () => { + // 先从缓存中获取 + const cachedContent = localStorage.getItem('privacy_policy') || ''; + if (cachedContent) { + setPrivacyPolicy(cachedContent); + const ct = getContentType(cachedContent); + setContentType(ct); + if (ct === 'html') { + // parse cached HTML to extract body and inline styles + try { + const parser = new DOMParser(); + const doc = parser.parseFromString(cachedContent, 'text/html'); + setHtmlBody(doc.body ? doc.body.innerHTML : cachedContent); + const styles = Array.from(doc.querySelectorAll('style')) + .map((s) => s.innerHTML) + .join('\n'); + setHtmlStyles(styles); + const links = Array.from(doc.querySelectorAll('link[rel="stylesheet"]')) + .map((l) => l.getAttribute('href') || l.href) + .filter(Boolean); + setHtmlLinks(links); + } catch (e) { + setHtmlBody(cachedContent); + setHtmlStyles(''); + setHtmlLinks([]); + } + } + } + + try { + const res = await API.get('/api/privacy-policy'); + const { success, message, data } = res.data; + if (success && data) { + // 直接使用原始数据,不进行任何预处理 + setPrivacyPolicy(data); + const ct = getContentType(data); + setContentType(ct); + // 如果是完整 HTML 文档,解析 body 内容并提取内联样式放到 head + if (ct === 'html') { + try { + const parser = new DOMParser(); + const doc = parser.parseFromString(data, 'text/html'); + setHtmlBody(doc.body ? doc.body.innerHTML : data); + const styles = Array.from(doc.querySelectorAll('style')) + .map((s) => s.innerHTML) + .join('\n'); + setHtmlStyles(styles); + const links = Array.from(doc.querySelectorAll('link[rel="stylesheet"]')) + .map((l) => l.getAttribute('href') || l.href) + .filter(Boolean); + setHtmlLinks(links); + } catch (e) { + setHtmlBody(data); + setHtmlStyles(''); + setHtmlLinks([]); + } + } else { + setHtmlBody(''); + setHtmlStyles(''); + setHtmlLinks([]); + } + localStorage.setItem('privacy_policy', data); + } else { + if (!cachedContent) { + showError(message || t('加载隐私政策内容失败...')); + setPrivacyPolicy(''); + setContentType('empty'); + } + } + } catch (error) { + if (!cachedContent) { + showError(t('加载隐私政策内容失败...')); + setPrivacyPolicy(''); + setContentType('empty'); + } + } + setPrivacyPolicyLoaded(true); + }; + + useEffect(() => { + displayPrivacyPolicy(); + }, []); + + // inject inline styles for parsed HTML content and cleanup on unmount or styles change + useEffect(() => { + const styleId = 'privacy-policy-inline-styles'; + const createdLinkIds = []; + + if (htmlStyles) { + let styleEl = document.getElementById(styleId); + if (!styleEl) { + styleEl = document.createElement('style'); + styleEl.id = styleId; + styleEl.type = 'text/css'; + document.head.appendChild(styleEl); + } + styleEl.innerHTML = htmlStyles; + } else { + const el = document.getElementById(styleId); + if (el) el.remove(); + } + + if (htmlLinks && htmlLinks.length) { + htmlLinks.forEach((href, idx) => { + try { + const existing = document.querySelector(`link[rel="stylesheet"][href="${href}"]`); + if (existing) return; + const linkId = `${styleId}-link-${idx}`; + const linkEl = document.createElement('link'); + linkEl.id = linkId; + linkEl.rel = 'stylesheet'; + linkEl.href = href; + document.head.appendChild(linkEl); + createdLinkIds.push(linkId); + } catch (e) { + // ignore + } + }); + } + + return () => { + const el = document.getElementById(styleId); + if (el) el.remove(); + createdLinkIds.forEach((id) => { + const l = document.getElementById(id); + if (l) l.remove(); + }); + }; + }, [htmlStyles]); + + const renderContent = () => { + if (!privacyPolicyLoaded) { + return ( +
+ +
+ ); + } + + if (contentType === 'empty' || !privacyPolicy) { + return ( +
+ + } + darkModeImage={ + + } + description={t('管理员未设置隐私政策内容')} + /> +
+ ); + } + + if (contentType === 'url') { + return ( +