diff --git a/Dockerfile b/Dockerfile index 214ceaa3..3b42089b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,8 +24,7 @@ RUN go build -ldflags "-s -w -X 'one-api/common.Version=$(cat VERSION)'" -o one- FROM alpine -RUN apk update \ - && apk upgrade \ +RUN apk upgrade --no-cache \ && apk add --no-cache ca-certificates tzdata ffmpeg \ && update-ca-certificates diff --git a/controller/channel-billing.go b/controller/channel-billing.go index 2bda0fd2..9bf5d1fe 100644 --- a/controller/channel-billing.go +++ b/controller/channel-billing.go @@ -4,11 +4,13 @@ import ( "encoding/json" "errors" "fmt" + "github.com/shopspring/decimal" "io" "net/http" "one-api/common" "one-api/model" "one-api/service" + "one-api/setting" "strconv" "time" @@ -304,6 +306,40 @@ func updateChannelOpenRouterBalance(channel *model.Channel) (float64, error) { return balance, nil } +func updateChannelMoonshotBalance(channel *model.Channel) (float64, error) { + url := "https://api.moonshot.cn/v1/users/me/balance" + body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) + if err != nil { + return 0, err + } + + type MoonshotBalanceData struct { + AvailableBalance float64 `json:"available_balance"` + VoucherBalance float64 `json:"voucher_balance"` + CashBalance float64 `json:"cash_balance"` + } + + type MoonshotBalanceResponse struct { + Code int `json:"code"` + Data MoonshotBalanceData `json:"data"` + Scode string `json:"scode"` + Status bool `json:"status"` + } + + response := MoonshotBalanceResponse{} + err = json.Unmarshal(body, &response) + if err != nil { + return 0, err + } + if !response.Status || response.Code != 0 { + return 0, fmt.Errorf("failed to update moonshot balance, status: %v, code: %d, scode: %s", response.Status, response.Code, response.Scode) + } + availableBalanceCny := response.Data.AvailableBalance + availableBalanceUsd := decimal.NewFromFloat(availableBalanceCny).Div(decimal.NewFromFloat(setting.Price)).InexactFloat64() + channel.UpdateBalance(availableBalanceUsd) + return availableBalanceUsd, nil +} + func updateChannelBalance(channel *model.Channel) (float64, error) { baseURL := common.ChannelBaseURLs[channel.Type] if channel.GetBaseURL() == "" { @@ -332,6 +368,8 @@ func updateChannelBalance(channel *model.Channel) (float64, error) { return updateChannelDeepSeekBalance(channel) case common.ChannelTypeOpenRouter: return updateChannelOpenRouterBalance(channel) + case common.ChannelTypeMoonshot: + return updateChannelMoonshotBalance(channel) default: return 0, errors.New("尚未实现") } diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index ef2c35be..18edfd04 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -103,7 +103,6 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon isNew25Pro := strings.HasPrefix(modelName, "gemini-2.5-pro") && !strings.HasPrefix(modelName, "gemini-2.5-pro-preview-05-06") && !strings.HasPrefix(modelName, "gemini-2.5-pro-preview-03-25") - is25FlashLite := strings.HasPrefix(modelName, "gemini-2.5-flash-lite") if strings.Contains(modelName, "-thinking-") { parts := strings.SplitN(modelName, "-thinking-", 2) @@ -134,15 +133,17 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon IncludeThoughts: true, } } else { - budgetTokens := model_setting.GetGeminiSettings().ThinkingAdapterBudgetTokensPercentage * float64(geminiRequest.GenerationConfig.MaxOutputTokens) - clampedBudget := clampThinkingBudget(modelName, int(budgetTokens)) geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{ - ThinkingBudget: common.GetPointer(clampedBudget), IncludeThoughts: true, } + if geminiRequest.GenerationConfig.MaxOutputTokens > 0 { + budgetTokens := model_setting.GetGeminiSettings().ThinkingAdapterBudgetTokensPercentage * float64(geminiRequest.GenerationConfig.MaxOutputTokens) + clampedBudget := clampThinkingBudget(modelName, int(budgetTokens)) + geminiRequest.GenerationConfig.ThinkingConfig.ThinkingBudget = common.GetPointer(clampedBudget) + } } } else if strings.HasSuffix(modelName, "-nothinking") { - if !isNew25Pro && !is25FlashLite { + if !isNew25Pro { geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{ ThinkingBudget: common.GetPointer(0), } diff --git a/setting/operation_setting/tools.go b/setting/operation_setting/tools.go index 3e1af99e..a401b923 100644 --- a/setting/operation_setting/tools.go +++ b/setting/operation_setting/tools.go @@ -17,6 +17,8 @@ const ( const ( // Gemini Audio Input Price Gemini25FlashPreviewInputAudioPrice = 1.00 + Gemini25FlashProductionInputAudioPrice = 1.00 // for `gemini-2.5-flash` + Gemini25FlashLitePreviewInputAudioPrice = 0.50 Gemini25FlashNativeAudioInputAudioPrice = 3.00 Gemini20FlashInputAudioPrice = 0.70 ) @@ -64,10 +66,14 @@ func GetFileSearchPricePerThousand() float64 { } func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 { - if strings.HasPrefix(modelName, "gemini-2.5-flash-preview") { - return Gemini25FlashPreviewInputAudioPrice - } else if strings.HasPrefix(modelName, "gemini-2.5-flash-preview-native-audio") { + if strings.HasPrefix(modelName, "gemini-2.5-flash-preview-native-audio") { return Gemini25FlashNativeAudioInputAudioPrice + } else if strings.HasPrefix(modelName, "gemini-2.5-flash-preview-lite") { + return Gemini25FlashLitePreviewInputAudioPrice + } else if strings.HasPrefix(modelName, "gemini-2.5-flash-preview") { + return Gemini25FlashPreviewInputAudioPrice + } else if strings.HasPrefix(modelName, "gemini-2.5-flash") { + return Gemini25FlashProductionInputAudioPrice } else if strings.HasPrefix(modelName, "gemini-2.0-flash") { return Gemini20FlashInputAudioPrice } diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index 1eaf25b1..879423eb 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -140,6 +140,7 @@ var defaultModelRatio = map[string]float64{ "gemini-2.0-flash": 0.05, "gemini-2.5-pro-exp-03-25": 0.625, "gemini-2.5-pro-preview-03-25": 0.625, + "gemini-2.5-pro": 0.625, "gemini-2.5-flash-preview-04-17": 0.075, "gemini-2.5-flash-preview-04-17-thinking": 0.075, "gemini-2.5-flash-preview-04-17-nothinking": 0.075, @@ -148,6 +149,8 @@ var defaultModelRatio = map[string]float64{ "gemini-2.5-flash-preview-05-20-nothinking": 0.075, "gemini-2.5-flash-thinking-*": 0.075, // 用于为后续所有2.5 flash thinking budget 模型设置默认倍率 "gemini-2.5-pro-thinking-*": 0.625, // 用于为后续所有2.5 pro thinking budget 模型设置默认倍率 + "gemini-2.5-flash-lite-preview-06-17": 0.05, + "gemini-2.5-flash": 0.15, "text-embedding-004": 0.001, "chatglm_turbo": 0.3572, // ¥0.005 / 1k tokens "chatglm_pro": 0.7143, // ¥0.01 / 1k tokens @@ -423,7 +426,12 @@ func UpdateCompletionRatioByJSONString(jsonStr string) error { func GetCompletionRatio(name string) float64 { CompletionRatioMutex.RLock() defer CompletionRatioMutex.RUnlock() - + if strings.HasPrefix(name, "gpt-4-gizmo") { + name = "gpt-4-gizmo-*" + } + if strings.HasPrefix(name, "gpt-4o-gizmo") { + name = "gpt-4o-gizmo-*" + } if strings.Contains(name, "/") { if ratio, ok := CompletionRatio[name]; ok { return ratio @@ -441,12 +449,6 @@ func GetCompletionRatio(name string) float64 { func getHardcodedCompletionModelRatio(name string) (float64, bool) { lowercaseName := strings.ToLower(name) - if strings.HasPrefix(name, "gpt-4-gizmo") { - name = "gpt-4-gizmo-*" - } - if strings.HasPrefix(name, "gpt-4o-gizmo") { - name = "gpt-4o-gizmo-*" - } if strings.HasPrefix(name, "gpt-4") && !strings.HasSuffix(name, "-all") && !strings.HasSuffix(name, "-gizmo-*") { if strings.HasPrefix(name, "gpt-4o") { if name == "gpt-4o-2024-05-13" { @@ -500,12 +502,17 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) { return 4, true } else if strings.HasPrefix(name, "gemini-2.5-pro") { // 移除preview来增加兼容性,这里假设正式版的倍率和preview一致 return 8, true - } else if strings.HasPrefix(name, "gemini-2.5-flash") { // 同上 - if strings.HasSuffix(name, "-nothinking") { - return 4, false - } else { - return 3.5 / 0.6, false + } else if strings.HasPrefix(name, "gemini-2.5-flash") { // 处理不同的flash模型倍率 + if strings.HasPrefix(name, "gemini-2.5-flash-preview") { + if strings.HasSuffix(name, "-nothinking") { + return 4, true + } + return 3.5 / 0.15, true } + if strings.HasPrefix(name, "gemini-2.5-flash-lite-preview") { + return 4, true + } + return 2.5 / 0.3, true } return 4, false } diff --git a/web/src/components/layout/HeaderBar.js b/web/src/components/layout/HeaderBar.js index 6317c576..b7425645 100644 --- a/web/src/components/layout/HeaderBar.js +++ b/web/src/components/layout/HeaderBar.js @@ -28,6 +28,7 @@ import { Tag, Typography, Skeleton, + Badge, } from '@douyinfe/semi-ui'; import { StatusContext } from '../../context/Status/index.js'; import { useStyle, styleActions } from '../../context/Style/index.js'; @@ -43,6 +44,7 @@ const HeaderBar = () => { const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const location = useLocation(); const [noticeVisible, setNoticeVisible] = useState(false); + const [unreadCount, setUnreadCount] = useState(0); const systemName = getSystemName(); const logo = getLogo(); @@ -53,9 +55,44 @@ const HeaderBar = () => { const docsLink = statusState?.status?.docs_link || ''; const isDemoSiteMode = statusState?.status?.demo_site_enabled || false; + const isConsoleRoute = location.pathname.startsWith('/console'); + const theme = useTheme(); const setTheme = useSetTheme(); + const announcements = statusState?.status?.announcements || []; + + const getAnnouncementKey = (a) => `${a?.publishDate || ''}-${(a?.content || '').slice(0, 30)}`; + + const calculateUnreadCount = () => { + if (!announcements.length) return 0; + let readKeys = []; + try { + readKeys = JSON.parse(localStorage.getItem('notice_read_keys')) || []; + } catch (_) { + readKeys = []; + } + const readSet = new Set(readKeys); + return announcements.filter((a) => !readSet.has(getAnnouncementKey(a))).length; + }; + + const getUnreadKeys = () => { + if (!announcements.length) return []; + let readKeys = []; + try { + readKeys = JSON.parse(localStorage.getItem('notice_read_keys')) || []; + } catch (_) { + readKeys = []; + } + const readSet = new Set(readKeys); + return announcements.filter((a) => !readSet.has(getAnnouncementKey(a))).map(getAnnouncementKey); + }; + + useEffect(() => { + setUnreadCount(calculateUnreadCount()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [announcements]); + const mainNavLinks = [ { text: t('首页'), @@ -106,6 +143,25 @@ const HeaderBar = () => { }, 3000); }; + const handleNoticeOpen = () => { + setNoticeVisible(true); + }; + + const handleNoticeClose = () => { + setNoticeVisible(false); + if (announcements.length) { + let readKeys = []; + try { + readKeys = JSON.parse(localStorage.getItem('notice_read_keys')) || []; + } catch (_) { + readKeys = []; + } + const mergedKeys = Array.from(new Set([...readKeys, ...announcements.map(getAnnouncementKey)])); + localStorage.setItem('notice_read_keys', JSON.stringify(mergedKeys)); + } + setUnreadCount(0); + }; + useEffect(() => { if (theme === 'dark') { document.body.setAttribute('theme-mode', 'dark'); @@ -353,15 +409,14 @@ const HeaderBar = () => { } }; - // 检查当前路由是否以/console开头 - const isConsoleRoute = location.pathname.startsWith('/console'); - return (
setNoticeVisible(false)} + onClose={handleNoticeClose} isMobile={styleState.isMobile} + defaultTab={unreadCount > 0 ? 'system' : 'inApp'} + unreadKeys={getUnreadKeys()} />
@@ -462,14 +517,27 @@ const HeaderBar = () => { )} - - - @@ -604,80 +528,6 @@ const SystemSetting = () => { - - - - (当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - { const currentInputs = {}; for (let key in props.options) { @@ -119,13 +100,7 @@ export default function SettingsChats(props) { getFormApi={(formAPI) => (refForm.current = formAPI)} style={{ marginBottom: 15 }} > - - + {
); - // 计算当前页显示的数据 + // 计算当前页显示的数据(按发布时间倒序排序,最新优先显示) const getCurrentPageData = () => { + const sortedList = [...announcementsList].sort((a, b) => { + const dateA = new Date(a.publishDate).getTime(); + const dateB = new Date(b.publishDate).getTime(); + return dateB - dateA; // 倒序,最新的排在前面 + }); + const startIndex = (currentPage - 1) * pageSize; const endIndex = startIndex + pageSize; - return announcementsList.slice(startIndex, endIndex); + return sortedList.slice(startIndex, endIndex); }; const rowSelection = { diff --git a/web/src/pages/Setting/Operation/SettingsDataDashboard.js b/web/src/pages/Setting/Dashboard/SettingsDataDashboard.js similarity index 100% rename from web/src/pages/Setting/Operation/SettingsDataDashboard.js rename to web/src/pages/Setting/Dashboard/SettingsDataDashboard.js diff --git a/web/src/pages/Setting/Operation/SettingsDrawing.js b/web/src/pages/Setting/Drawing/SettingsDrawing.js similarity index 100% rename from web/src/pages/Setting/Operation/SettingsDrawing.js rename to web/src/pages/Setting/Drawing/SettingsDrawing.js diff --git a/web/src/pages/Setting/Model/SettingGeminiModel.js b/web/src/pages/Setting/Model/SettingGeminiModel.js index 1d28ae92..a5daace6 100644 --- a/web/src/pages/Setting/Model/SettingGeminiModel.js +++ b/web/src/pages/Setting/Model/SettingGeminiModel.js @@ -209,8 +209,8 @@ export default function SettingGeminiModel(props) { label={t('思考预算占比')} field={'gemini.thinking_adapter_budget_tokens_percentage'} initValue={''} - extraText={t('0.1-1之间的小数')} - min={0.1} + extraText={t('0.002-1之间的小数')} + min={0.002} max={1} onChange={(value) => setInputs({ diff --git a/web/src/pages/Setting/Operation/SettingsGeneral.js b/web/src/pages/Setting/Operation/SettingsGeneral.js index 2b8c29d4..cc774ef8 100644 --- a/web/src/pages/Setting/Operation/SettingsGeneral.js +++ b/web/src/pages/Setting/Operation/SettingsGeneral.js @@ -6,7 +6,6 @@ import { Form, Row, Spin, - Collapse, Modal, } from '@douyinfe/semi-ui'; import { @@ -92,10 +91,6 @@ export default function GeneralSettings(props) { return ( <> -
(refForm.current = formAPI)} diff --git a/web/src/pages/Setting/Payment/SettingsGeneralPayment.js b/web/src/pages/Setting/Payment/SettingsGeneralPayment.js new file mode 100644 index 00000000..dda9d5ea --- /dev/null +++ b/web/src/pages/Setting/Payment/SettingsGeneralPayment.js @@ -0,0 +1,74 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { + Button, + Form, + Spin, +} from '@douyinfe/semi-ui'; +import { + API, + removeTrailingSlash, + showError, + showSuccess, +} from '../../../helpers'; +import { useTranslation } from 'react-i18next'; + +export default function SettingsGeneralPayment(props) { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const [inputs, setInputs] = useState({ + ServerAddress: '', + }); + const formApiRef = useRef(null); + + useEffect(() => { + if (props.options && formApiRef.current) { + const currentInputs = { ServerAddress: props.options.ServerAddress || '' }; + setInputs(currentInputs); + formApiRef.current.setValues(currentInputs); + } + }, [props.options]); + + const handleFormChange = (values) => { + setInputs(values); + }; + + const submitServerAddress = async () => { + setLoading(true); + try { + let ServerAddress = removeTrailingSlash(inputs.ServerAddress); + const res = await API.put('/api/option/', { + key: 'ServerAddress', + value: ServerAddress, + }); + if (res.data.success) { + showSuccess(t('更新成功')); + props.refresh && props.refresh(); + } else { + showError(res.data.message); + } + } catch (error) { + showError(t('更新失败')); + } + setLoading(false); + }; + + return ( + + (formApiRef.current = api)} + > + + + + + +
+ ); +} \ No newline at end of file diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGateway.js b/web/src/pages/Setting/Payment/SettingsPaymentGateway.js new file mode 100644 index 00000000..0bb63b53 --- /dev/null +++ b/web/src/pages/Setting/Payment/SettingsPaymentGateway.js @@ -0,0 +1,218 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { + Button, + Form, + Row, + Col, + Typography, + Spin, +} from '@douyinfe/semi-ui'; +const { Text } = Typography; +import { + API, + removeTrailingSlash, + showError, + showSuccess, + verifyJSON, +} from '../../../helpers'; +import { useTranslation } from 'react-i18next'; + +export default function SettingsPaymentGateway(props) { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const [inputs, setInputs] = useState({ + PayAddress: '', + EpayId: '', + EpayKey: '', + Price: 7.3, + MinTopUp: 1, + TopupGroupRatio: '', + CustomCallbackAddress: '', + PayMethods: '', + }); + const [originInputs, setOriginInputs] = useState({}); + const formApiRef = useRef(null); + + useEffect(() => { + if (props.options && formApiRef.current) { + const currentInputs = { + PayAddress: props.options.PayAddress || '', + EpayId: props.options.EpayId || '', + EpayKey: props.options.EpayKey || '', + Price: props.options.Price !== undefined ? parseFloat(props.options.Price) : 7.3, + MinTopUp: props.options.MinTopUp !== undefined ? parseFloat(props.options.MinTopUp) : 1, + TopupGroupRatio: props.options.TopupGroupRatio || '', + CustomCallbackAddress: props.options.CustomCallbackAddress || '', + PayMethods: props.options.PayMethods || '', + }; + setInputs(currentInputs); + setOriginInputs({ ...currentInputs }); + formApiRef.current.setValues(currentInputs); + } + }, [props.options]); + + const handleFormChange = (values) => { + setInputs(values); + }; + + const submitPayAddress = async () => { + if (props.options.ServerAddress === '') { + showError(t('请先填写服务器地址')); + return; + } + + if (originInputs['TopupGroupRatio'] !== inputs.TopupGroupRatio) { + if (!verifyJSON(inputs.TopupGroupRatio)) { + showError(t('充值分组倍率不是合法的 JSON 字符串')); + return; + } + } + + if (originInputs['PayMethods'] !== inputs.PayMethods) { + if (!verifyJSON(inputs.PayMethods)) { + showError(t('充值方式设置不是合法的 JSON 字符串')); + return; + } + } + + setLoading(true); + try { + const options = [ + { key: 'PayAddress', value: removeTrailingSlash(inputs.PayAddress) }, + ]; + + if (inputs.EpayId !== '') { + options.push({ key: 'EpayId', value: inputs.EpayId }); + } + if (inputs.EpayKey !== undefined && inputs.EpayKey !== '') { + options.push({ key: 'EpayKey', value: inputs.EpayKey }); + } + if (inputs.Price !== '') { + options.push({ key: 'Price', value: inputs.Price.toString() }); + } + if (inputs.MinTopUp !== '') { + options.push({ key: 'MinTopUp', value: inputs.MinTopUp.toString() }); + } + if (inputs.CustomCallbackAddress !== '') { + options.push({ + key: 'CustomCallbackAddress', + value: inputs.CustomCallbackAddress, + }); + } + if (originInputs['TopupGroupRatio'] !== inputs.TopupGroupRatio) { + options.push({ key: 'TopupGroupRatio', value: inputs.TopupGroupRatio }); + } + if (originInputs['PayMethods'] !== inputs.PayMethods) { + options.push({ key: 'PayMethods', value: inputs.PayMethods }); + } + + // 发送请求 + const requestQueue = options.map(opt => + API.put('/api/option/', { + key: opt.key, + value: opt.value, + }) + ); + + const results = await Promise.all(requestQueue); + + // 检查所有请求是否成功 + const errorResults = results.filter(res => !res.data.success); + if (errorResults.length > 0) { + errorResults.forEach(res => { + showError(res.data.message); + }); + } else { + showSuccess(t('更新成功')); + // 更新本地存储的原始值 + setOriginInputs({ ...inputs }); + props.refresh && props.refresh(); + } + } catch (error) { + showError(t('更新失败')); + } + setLoading(false); + }; + + return ( + +
(formApiRef.current = api)} + > + + + {t('(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)')} + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ ); +} \ No newline at end of file diff --git a/web/src/pages/Setting/Ratio/ModelRationNotSetEditor.js b/web/src/pages/Setting/Ratio/ModelRationNotSetEditor.js index d5d8d832..25c67eee 100644 --- a/web/src/pages/Setting/Ratio/ModelRationNotSetEditor.js +++ b/web/src/pages/Setting/Ratio/ModelRationNotSetEditor.js @@ -372,7 +372,7 @@ export default function ModelRatioNotSetEditor(props) { return ( <> - + diff --git a/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.js b/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.js index 85426a5e..983f3afe 100644 --- a/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.js +++ b/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.js @@ -404,7 +404,7 @@ export default function ModelSettingsVisualEditor(props) { return ( <> - +