diff --git a/controller/channel.go b/controller/channel.go index 480d5b4f..5d075f3c 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -8,6 +8,7 @@ import ( "one-api/constant" "one-api/dto" "one-api/model" + "one-api/service" "strconv" "strings" @@ -633,6 +634,7 @@ func AddChannel(c *gin.Context) { common.ApiError(c, err) return } + service.ResetProxyClientCache() c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", @@ -894,6 +896,7 @@ func UpdateChannel(c *gin.Context) { return } model.InitChannelCache() + service.ResetProxyClientCache() channel.Key = "" clearChannelInfo(&channel.Channel) c.JSON(http.StatusOK, gin.H{ diff --git a/controller/topup_stripe.go b/controller/topup_stripe.go index ccde91db..9a568d85 100644 --- a/controller/topup_stripe.go +++ b/controller/topup_stripe.go @@ -225,7 +225,8 @@ func genStripeLink(referenceId string, customerId string, email string, amount i Quantity: stripe.Int64(amount), }, }, - Mode: stripe.String(string(stripe.CheckoutSessionModePayment)), + Mode: stripe.String(string(stripe.CheckoutSessionModePayment)), + AllowPromotionCodes: stripe.Bool(setting.StripePromotionCodesEnabled), } if "" == customerId { diff --git a/dto/gemini.go b/dto/gemini.go index bc05c6aa..80552aad 100644 --- a/dto/gemini.go +++ b/dto/gemini.go @@ -251,6 +251,7 @@ type GeminiChatTool struct { GoogleSearchRetrieval any `json:"googleSearchRetrieval,omitempty"` CodeExecution any `json:"codeExecution,omitempty"` FunctionDeclarations any `json:"functionDeclarations,omitempty"` + URLContext any `json:"urlContext,omitempty"` } type GeminiChatGenerationConfig struct { diff --git a/main.go b/main.go index b1421f9e..e12dddc5 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "embed" "fmt" "log" @@ -16,6 +17,7 @@ import ( "one-api/setting/ratio_setting" "os" "strconv" + "strings" "time" "github.com/bytedance/gopkg/util/gopool" @@ -147,6 +149,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/model/option.go b/model/option.go index ceecff65..9ace8fec 100644 --- a/model/option.go +++ b/model/option.go @@ -82,6 +82,7 @@ func InitOptionMap() { common.OptionMap["StripeWebhookSecret"] = setting.StripeWebhookSecret common.OptionMap["StripePriceId"] = setting.StripePriceId common.OptionMap["StripeUnitPrice"] = strconv.FormatFloat(setting.StripeUnitPrice, 'f', -1, 64) + common.OptionMap["StripePromotionCodesEnabled"] = strconv.FormatBool(setting.StripePromotionCodesEnabled) common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString() common.OptionMap["Chats"] = setting.Chats2JsonString() common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString() @@ -330,6 +331,8 @@ func updateOptionMap(key string, value string) (err error) { setting.StripeUnitPrice, _ = strconv.ParseFloat(value, 64) case "StripeMinTopUp": setting.StripeMinTopUp, _ = strconv.Atoi(value) + case "StripePromotionCodesEnabled": + setting.StripePromotionCodesEnabled = value == "true" case "TopupGroupRatio": err = common.UpdateTopupGroupRatioByJSONString(value) case "GitHubClientId": diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index 199c8466..c8e9c757 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -245,6 +245,7 @@ func CovertGemini2OpenAI(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i functions := make([]dto.FunctionRequest, 0, len(textRequest.Tools)) googleSearch := false codeExecution := false + urlContext := false for _, tool := range textRequest.Tools { if tool.Function.Name == "googleSearch" { googleSearch = true @@ -254,6 +255,10 @@ func CovertGemini2OpenAI(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i codeExecution = true continue } + if tool.Function.Name == "urlContext" { + urlContext = true + continue + } if tool.Function.Parameters != nil { params, ok := tool.Function.Parameters.(map[string]interface{}) @@ -281,6 +286,11 @@ func CovertGemini2OpenAI(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i GoogleSearch: make(map[string]string), }) } + if urlContext { + geminiTools = append(geminiTools, dto.GeminiChatTool{ + URLContext: make(map[string]string), + }) + } if len(functions) > 0 { geminiTools = append(geminiTools, dto.GeminiChatTool{ FunctionDeclarations: functions, diff --git a/service/http_client.go b/service/http_client.go index b191ddd7..d8fcfae0 100644 --- a/service/http_client.go +++ b/service/http_client.go @@ -7,12 +7,17 @@ import ( "net/http" "net/url" "one-api/common" + "sync" "time" "golang.org/x/net/proxy" ) -var httpClient *http.Client +var ( + httpClient *http.Client + proxyClientLock sync.Mutex + proxyClients = make(map[string]*http.Client) +) func InitHttpClient() { if common.RelayTimeout == 0 { @@ -28,12 +33,31 @@ func GetHttpClient() *http.Client { return httpClient } +// ResetProxyClientCache 清空代理客户端缓存,确保下次使用时重新初始化 +func ResetProxyClientCache() { + proxyClientLock.Lock() + defer proxyClientLock.Unlock() + for _, client := range proxyClients { + if transport, ok := client.Transport.(*http.Transport); ok && transport != nil { + transport.CloseIdleConnections() + } + } + proxyClients = make(map[string]*http.Client) +} + // NewProxyHttpClient 创建支持代理的 HTTP 客户端 func NewProxyHttpClient(proxyURL string) (*http.Client, error) { if proxyURL == "" { return http.DefaultClient, nil } + proxyClientLock.Lock() + if client, ok := proxyClients[proxyURL]; ok { + proxyClientLock.Unlock() + return client, nil + } + proxyClientLock.Unlock() + parsedURL, err := url.Parse(proxyURL) if err != nil { return nil, err @@ -41,11 +65,16 @@ func NewProxyHttpClient(proxyURL string) (*http.Client, error) { switch parsedURL.Scheme { case "http", "https": - return &http.Client{ + client := &http.Client{ Transport: &http.Transport{ Proxy: http.ProxyURL(parsedURL), }, - }, nil + } + client.Timeout = time.Duration(common.RelayTimeout) * time.Second + proxyClientLock.Lock() + proxyClients[proxyURL] = client + proxyClientLock.Unlock() + return client, nil case "socks5", "socks5h": // 获取认证信息 @@ -67,13 +96,18 @@ func NewProxyHttpClient(proxyURL string) (*http.Client, error) { return nil, err } - return &http.Client{ + client := &http.Client{ Transport: &http.Transport{ DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { return dialer.Dial(network, addr) }, }, - }, nil + } + client.Timeout = time.Duration(common.RelayTimeout) * time.Second + proxyClientLock.Lock() + proxyClients[proxyURL] = client + proxyClientLock.Unlock() + return client, nil default: return nil, fmt.Errorf("unsupported proxy scheme: %s", parsedURL.Scheme) diff --git a/setting/payment_stripe.go b/setting/payment_stripe.go index 80d877df..d97120c8 100644 --- a/setting/payment_stripe.go +++ b/setting/payment_stripe.go @@ -5,3 +5,4 @@ var StripeWebhookSecret = "" var StripePriceId = "" var StripeUnitPrice = 8.0 var StripeMinTopUp = 1 +var StripePromotionCodesEnabled = false diff --git a/web/index.html b/web/index.html index 09d87ae1..df6b0e39 100644 --- a/web/index.html +++ b/web/index.html @@ -10,6 +10,7 @@ content="OpenAI 接口聚合管理,支持多种渠道包括 Azure,可用于二次分发管理 key,仅单可执行文件,已打包好 Docker 镜像,一键部署,开箱即用" /> New API + diff --git a/web/src/components/settings/PaymentSetting.jsx b/web/src/components/settings/PaymentSetting.jsx index faaa9561..220c8664 100644 --- a/web/src/components/settings/PaymentSetting.jsx +++ b/web/src/components/settings/PaymentSetting.jsx @@ -45,6 +45,7 @@ const PaymentSetting = () => { StripePriceId: '', StripeUnitPrice: 8.0, StripeMinTopUp: 1, + StripePromotionCodesEnabled: false, }); let [loading, setLoading] = useState(false); diff --git a/web/src/components/settings/PersonalSetting.jsx b/web/src/components/settings/PersonalSetting.jsx index 3ba8dcfd..15dfbd97 100644 --- a/web/src/components/settings/PersonalSetting.jsx +++ b/web/src/components/settings/PersonalSetting.jsx @@ -19,7 +19,14 @@ For commercial licensing, please contact support@quantumnous.com import React, { useContext, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { API, copy, showError, showInfo, showSuccess } from '../../helpers'; +import { + API, + copy, + showError, + showInfo, + showSuccess, + setStatusData, +} from '../../helpers'; import { UserContext } from '../../context/User'; import { Modal } from '@douyinfe/semi-ui'; import { useTranslation } from 'react-i18next'; @@ -71,18 +78,40 @@ const PersonalSetting = () => { }); useEffect(() => { - let status = localStorage.getItem('status'); - if (status) { - status = JSON.parse(status); - setStatus(status); - if (status.turnstile_check) { + let saved = localStorage.getItem('status'); + if (saved) { + const parsed = JSON.parse(saved); + setStatus(parsed); + if (parsed.turnstile_check) { setTurnstileEnabled(true); - setTurnstileSiteKey(status.turnstile_site_key); + setTurnstileSiteKey(parsed.turnstile_site_key); + } else { + setTurnstileEnabled(false); + setTurnstileSiteKey(''); } } - getUserData().then((res) => { - console.log(userState); - }); + // Always refresh status from server to avoid stale flags (e.g., admin just enabled OAuth) + (async () => { + try { + const res = await API.get('/api/status'); + const { success, data } = res.data; + if (success && data) { + setStatus(data); + setStatusData(data); + if (data.turnstile_check) { + setTurnstileEnabled(true); + setTurnstileSiteKey(data.turnstile_site_key); + } else { + setTurnstileEnabled(false); + setTurnstileSiteKey(''); + } + } + } catch (e) { + // ignore and keep local status + } + })(); + + getUserData(); }, []); useEffect(() => { diff --git a/web/src/components/settings/personal/cards/AccountManagement.jsx b/web/src/components/settings/personal/cards/AccountManagement.jsx index 515a5c19..017e7c1e 100644 --- a/web/src/components/settings/personal/cards/AccountManagement.jsx +++ b/web/src/components/settings/personal/cards/AccountManagement.jsx @@ -28,6 +28,7 @@ import { Tabs, TabPane, Popover, + Modal, } from '@douyinfe/semi-ui'; import { IconMail, @@ -83,6 +84,9 @@ const AccountManagement = ({ ); }; + const isBound = (accountId) => Boolean(accountId); + const [showTelegramBindModal, setShowTelegramBindModal] = React.useState(false); + return ( {/* 卡片头部 */} @@ -142,7 +146,7 @@ const AccountManagement = ({ size='small' onClick={() => setShowEmailBindModal(true)} > - {userState.user && userState.user.email !== '' + {isBound(userState.user?.email) ? t('修改绑定') : t('绑定')} @@ -165,9 +169,11 @@ const AccountManagement = ({ {t('微信')}
- {userState.user && userState.user.wechat_id !== '' - ? t('已绑定') - : t('未绑定')} + {!status.wechat_login + ? t('未启用') + : isBound(userState.user?.wechat_id) + ? t('已绑定') + : t('未绑定')}
@@ -179,7 +185,7 @@ const AccountManagement = ({ disabled={!status.wechat_login} onClick={() => setShowWeChatBindModal(true)} > - {userState.user && userState.user.wechat_id !== '' + {isBound(userState.user?.wechat_id) ? t('修改绑定') : status.wechat_login ? t('绑定') @@ -220,8 +226,7 @@ const AccountManagement = ({ onGitHubOAuthClicked(status.github_client_id) } disabled={ - (userState.user && userState.user.github_id !== '') || - !status.github_oauth + isBound(userState.user?.github_id) || !status.github_oauth } > {status.github_oauth ? t('绑定') : t('未启用')} @@ -264,8 +269,7 @@ const AccountManagement = ({ ) } disabled={ - (userState.user && userState.user.oidc_id !== '') || - !status.oidc_enabled + isBound(userState.user?.oidc_id) || !status.oidc_enabled } > {status.oidc_enabled ? t('绑定') : t('未启用')} @@ -298,26 +302,56 @@ const AccountManagement = ({
{status.telegram_oauth ? ( - userState.user.telegram_id !== '' ? ( - ) : ( -
- -
+ ) ) : ( - )}
+ setShowTelegramBindModal(false)} + footer={null} + > +
+ {t('点击下方按钮通过 Telegram 完成绑定')} +
+
+
+ +
+
+
{/* LinuxDO绑定 */} @@ -350,8 +384,7 @@ const AccountManagement = ({ onLinuxDOOAuthClicked(status.linuxdo_client_id) } disabled={ - (userState.user && userState.user.linux_do_id !== '') || - !status.linuxdo_oauth + isBound(userState.user?.linux_do_id) || !status.linuxdo_oauth } > {status.linuxdo_oauth ? t('绑定') : t('未启用')} diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 25ef68c6..2eb480e7 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -455,6 +455,14 @@ const EditChannelModal = (props) => { data.is_enterprise_account = false; } + if ( + data.type === 45 && + (!data.base_url || + (typeof data.base_url === 'string' && data.base_url.trim() === '')) + ) { + data.base_url = 'https://ark.cn-beijing.volces.com'; + } + setInputs(data); if (formApiRef.current) { formApiRef.current.setValues(data); @@ -837,7 +845,9 @@ const EditChannelModal = (props) => { delete localInputs.key; } } else { - localInputs.key = batch ? JSON.stringify(keys) : JSON.stringify(keys[0]); + localInputs.key = batch + ? JSON.stringify(keys) + : JSON.stringify(keys[0]); } } } @@ -954,6 +964,56 @@ const EditChannelModal = (props) => { } }; + // 密钥去重函数 + const deduplicateKeys = () => { + const currentKey = formApiRef.current?.getValue('key') || inputs.key || ''; + + if (!currentKey.trim()) { + showInfo(t('请先输入密钥')); + return; + } + + // 按行分割密钥 + const keyLines = currentKey.split('\n'); + const beforeCount = keyLines.length; + + // 使用哈希表去重,保持原有顺序 + const keySet = new Set(); + const deduplicatedKeys = []; + + keyLines.forEach((line) => { + const trimmedLine = line.trim(); + if (trimmedLine && !keySet.has(trimmedLine)) { + keySet.add(trimmedLine); + deduplicatedKeys.push(trimmedLine); + } + }); + + const afterCount = deduplicatedKeys.length; + const deduplicatedKeyText = deduplicatedKeys.join('\n'); + + // 更新表单和状态 + if (formApiRef.current) { + formApiRef.current.setValue('key', deduplicatedKeyText); + } + handleInputChange('key', deduplicatedKeyText); + + // 显示去重结果 + const message = t( + '去重完成:去重前 {{before}} 个密钥,去重后 {{after}} 个密钥', + { + before: beforeCount, + after: afterCount, + }, + ); + + if (beforeCount === afterCount) { + showInfo(t('未发现重复密钥')); + } else { + showSuccess(message); + } + }; + const addCustomModels = () => { if (customModel.trim() === '') return; const modelArray = customModel.split(',').map((model) => model.trim()); @@ -1049,24 +1109,41 @@ const EditChannelModal = (props) => { )} {batch && ( - { - setMultiToSingle((prev) => !prev); - setInputs((prev) => { - const newInputs = { ...prev }; - if (!multiToSingle) { - newInputs.multi_key_mode = multiKeyMode; - } else { - delete newInputs.multi_key_mode; - } - return newInputs; - }); - }} - > - {t('密钥聚合模式')} - + <> + { + setMultiToSingle((prev) => { + const nextValue = !prev; + setInputs((prevInputs) => { + const newInputs = { ...prevInputs }; + if (nextValue) { + newInputs.multi_key_mode = multiKeyMode; + } else { + delete newInputs.multi_key_mode; + } + return newInputs; + }); + return nextValue; + }); + }} + > + {t('密钥聚合模式')} + + + {inputs.type !== 41 && ( + + )} + )} ) : null; @@ -1268,7 +1345,10 @@ const EditChannelModal = (props) => { value={inputs.vertex_key_type || 'json'} onChange={(value) => { // 更新设置中的 vertex_key_type - handleChannelOtherSettingsChange('vertex_key_type', value); + handleChannelOtherSettingsChange( + 'vertex_key_type', + value, + ); // 切换为 api_key 时,关闭批量与手动/文件切换,并清理已选文件 if (value === 'api_key') { setBatch(false); @@ -1288,7 +1368,8 @@ const EditChannelModal = (props) => { /> )} {batch ? ( - inputs.type === 41 && (inputs.vertex_key_type || 'json') === 'json' ? ( + inputs.type === 41 && + (inputs.vertex_key_type || 'json') === 'json' ? ( { autoComplete='new-password' onChange={(value) => handleInputChange('key', value)} extraText={ -
+
{isEdit && isMultiKeyChannel && keyMode === 'append' && ( @@ -1352,7 +1433,8 @@ const EditChannelModal = (props) => { ) ) : ( <> - {inputs.type === 41 && (inputs.vertex_key_type || 'json') === 'json' ? ( + {inputs.type === 41 && + (inputs.vertex_key_type || 'json') === 'json' ? ( <> {!batch && (
diff --git a/web/src/components/table/task-logs/modals/ContentModal.jsx b/web/src/components/table/task-logs/modals/ContentModal.jsx index 3d747b77..3bfba37b 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 ? ( -