소스 검색

Merge pull request #1910 from seefs001/fix/volcengine_default_baseurl

alpha -> main
Seefs 3 달 전
부모
커밋
a91f3e7556

+ 2 - 1
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 {

+ 1 - 0
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 {

+ 18 - 0
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("<script defer src=\"")
+		analyticsInjectBuilder.WriteString(umamiScriptURL)
+		analyticsInjectBuilder.WriteString("\" data-website-id=\"")
+		analyticsInjectBuilder.WriteString(umamiSiteID)
+		analyticsInjectBuilder.WriteString("\"></script>")
+	}
+	analyticsInject := analyticsInjectBuilder.String()
+	indexPage = bytes.ReplaceAll(indexPage, []byte("<analytics></analytics>\n"), []byte(analyticsInject))
+
 	router.SetRouter(server, buildFS, indexPage)
 	var port = os.Getenv("PORT")
 	if port == "" {

+ 3 - 0
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":

+ 10 - 0
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,

+ 1 - 0
setting/payment_stripe.go

@@ -5,3 +5,4 @@ var StripeWebhookSecret = ""
 var StripePriceId = ""
 var StripeUnitPrice = 8.0
 var StripeMinTopUp = 1
+var StripePromotionCodesEnabled = false

+ 1 - 0
web/index.html

@@ -10,6 +10,7 @@
       content="OpenAI 接口聚合管理,支持多种渠道包括 Azure,可用于二次分发管理 key,仅单可执行文件,已打包好 Docker 镜像,一键部署,开箱即用"
     />
     <title>New API</title>
+<analytics></analytics>
   </head>
 
   <body>

+ 1 - 0
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);

+ 105 - 23
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) => {
         </Checkbox>
       )}
       {batch && (
-        <Checkbox
-          disabled={isEdit}
-          checked={multiToSingle}
-          onChange={() => {
-            setMultiToSingle((prev) => !prev);
-            setInputs((prev) => {
-              const newInputs = { ...prev };
-              if (!multiToSingle) {
-                newInputs.multi_key_mode = multiKeyMode;
-              } else {
-                delete newInputs.multi_key_mode;
-              }
-              return newInputs;
-            });
-          }}
-        >
-          {t('密钥聚合模式')}
-        </Checkbox>
+        <>
+          <Checkbox
+            disabled={isEdit}
+            checked={multiToSingle}
+            onChange={() => {
+              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('密钥聚合模式')}
+          </Checkbox>
+
+          {inputs.type !== 41 && (
+            <Button
+              size='small'
+              type='tertiary'
+              theme='outline'
+              onClick={deduplicateKeys}
+              style={{ textDecoration: 'underline' }}
+            >
+              {t('密钥去重')}
+            </Button>
+          )}
+        </>
       )}
     </Space>
   ) : 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' ? (
                       <Form.Upload
                         field='vertex_files'
                         label={t('密钥文件 (.json)')}
@@ -1324,7 +1405,7 @@ const EditChannelModal = (props) => {
                         autoComplete='new-password'
                         onChange={(value) => handleInputChange('key', value)}
                         extraText={
-                          <div className='flex items-center gap-2'>
+                          <div className='flex items-center gap-2 flex-wrap'>
                             {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 && (
                             <div className='flex items-center justify-between mb-3'>

+ 110 - 4
web/src/components/table/task-logs/modals/ContentModal.jsx

@@ -17,8 +17,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
 For commercial licensing, please contact [email protected]
 */
 
-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 (
+        <div style={{ textAlign: 'center', padding: '40px' }}>
+          <Text type="tertiary" style={{ display: 'block', marginBottom: '16px' }}>
+            视频无法在当前浏览器中播放,这可能是由于:
+          </Text>
+          <Text type="tertiary" style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}>
+            • 视频服务商的跨域限制
+          </Text>
+          <Text type="tertiary" style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}>
+            • 需要特定的请求头或认证
+          </Text>
+          <Text type="tertiary" style={{ display: 'block', marginBottom: '16px', fontSize: '12px' }}>
+            • 防盗链保护机制
+          </Text>
+          
+          <div style={{ marginTop: '20px' }}>
+            <Button 
+              icon={<IconExternalOpen />}
+              onClick={handleOpenInNewTab}
+              style={{ marginRight: '8px' }}
+            >
+              在新标签页中打开
+            </Button>
+            <Button 
+              icon={<IconCopy />}
+              onClick={handleCopyUrl}
+            >
+              复制链接
+            </Button>
+          </div>
+          
+          <div style={{ marginTop: '16px', padding: '8px', backgroundColor: '#f8f9fa', borderRadius: '4px' }}>
+            <Text 
+              type="tertiary" 
+              style={{ fontSize: '10px', wordBreak: 'break-all' }}
+            >
+              {modalContent}
+            </Text>
+          </div>
+        </div>
+      );
+    }
+
+    return (
+      <div style={{ position: 'relative' }}>
+        {isLoading && (
+          <div style={{
+            position: 'absolute',
+            top: '50%',
+            left: '50%',
+            transform: 'translate(-50%, -50%)',
+            zIndex: 10
+          }}>
+            <Spin size="large" />
+          </div>
+        )}
+        <video 
+          src={modalContent} 
+          controls 
+          style={{ width: '100%' }} 
+          autoPlay
+          crossOrigin="anonymous"
+          onError={handleVideoError}
+          onLoadedData={handleVideoLoaded}
+          onLoadStart={() => setIsLoading(true)}
+        />
+      </div>
+    );
+  };
+
   return (
     <Modal
       visible={isModalOpen}
       onOk={() => 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 ? (
-        <video src={modalContent} controls style={{ width: '100%' }} autoPlay />
+        renderVideoContent()
       ) : (
         <p style={{ whiteSpace: 'pre-line' }}>{modalContent}</p>
       )}

+ 1 - 0
web/src/i18n/locales/en.json

@@ -837,6 +837,7 @@
   "确定要充值 $": "Confirm to top up $",
   "微信/支付宝 实付金额:": "WeChat/Alipay actual payment amount:",
   "Stripe 实付金额:": "Stripe actual payment amount:",
+  "允许在 Stripe 支付中输入促销码": "Allow entering promotion codes during Stripe checkout",
   "支付中...": "Paying",
   "支付宝": "Alipay",
   "收益统计": "Income statistics",

+ 2 - 1
web/src/i18n/locales/zh.json

@@ -32,5 +32,6 @@
   "端口配置详细说明": "限制外部请求只能访问指定端口。支持单个端口(80, 443)或端口范围(8000-8999)。空列表允许所有端口。默认包含常用Web端口。",
   "输入端口后回车,如:80 或 8000-8999": "输入端口后回车,如:80 或 8000-8999",
   "更新SSRF防护设置": "更新SSRF防护设置",
-  "域名IP过滤详细说明": "⚠️此功能为实验性选项,域名可能解析到多个 IPv4/IPv6 地址,若开启,请确保 IP 过滤列表覆盖这些地址,否则可能导致访问失败。"
+  "域名IP过滤详细说明": "⚠️此功能为实验性选项,域名可能解析到多个 IPv4/IPv6 地址,若开启,请确保 IP 过滤列表覆盖这些地址,否则可能导致访问失败。",
+  "允许在 Stripe 支付中输入促销码": "允许在 Stripe 支付中输入促销码"
 }

+ 24 - 0
web/src/pages/Setting/Payment/SettingsPaymentGatewayStripe.jsx

@@ -45,6 +45,7 @@ export default function SettingsPaymentGateway(props) {
     StripePriceId: '',
     StripeUnitPrice: 8.0,
     StripeMinTopUp: 1,
+    StripePromotionCodesEnabled: false,
   });
   const [originInputs, setOriginInputs] = useState({});
   const formApiRef = useRef(null);
@@ -63,6 +64,10 @@ export default function SettingsPaymentGateway(props) {
           props.options.StripeMinTopUp !== undefined
             ? parseFloat(props.options.StripeMinTopUp)
             : 1,
+        StripePromotionCodesEnabled:
+          props.options.StripePromotionCodesEnabled !== undefined
+            ? props.options.StripePromotionCodesEnabled
+            : false,
       };
       setInputs(currentInputs);
       setOriginInputs({ ...currentInputs });
@@ -114,6 +119,16 @@ export default function SettingsPaymentGateway(props) {
           value: inputs.StripeMinTopUp.toString(),
         });
       }
+      if (
+        originInputs['StripePromotionCodesEnabled'] !==
+          inputs.StripePromotionCodesEnabled &&
+        inputs.StripePromotionCodesEnabled !== undefined
+      ) {
+        options.push({
+          key: 'StripePromotionCodesEnabled',
+          value: inputs.StripePromotionCodesEnabled ? 'true' : 'false',
+        });
+      }
 
       // 发送请求
       const requestQueue = options.map((opt) =>
@@ -225,6 +240,15 @@ export default function SettingsPaymentGateway(props) {
                 placeholder={t('例如:2,就是最低充值2$')}
               />
             </Col>
+            <Col xs={24} sm={24} md={8} lg={8} xl={8}>
+              <Form.Switch
+                field='StripePromotionCodesEnabled'
+                size='default'
+                checkedText='|'
+                uncheckedText='〇'
+                label={t('允许在 Stripe 支付中输入促销码')}
+              />
+            </Col>
           </Row>
           <Button onClick={submitStripeSetting}>{t('更新 Stripe 设置')}</Button>
         </Form.Section>