Commit f244a9e6 by Seefs Committed by GitHub

fix: channel affinity (#2799)

* fix: channel affinity log styles

* fix: Issue with incorrect data storage when switching key sources

* feat: support not retrying after a single rule configuration fails

* fix: render channel affinity tooltip as multiline content

* feat: channel affinity cache hit

* fix: prevent ChannelAffinityUsageCacheModal infinite loading and hide data before fetch

* chore: format backend with gofmt and frontend with prettier/eslint autofix
parent 80a609b7
...@@ -58,3 +58,31 @@ func ClearChannelAffinityCache(c *gin.Context) { ...@@ -58,3 +58,31 @@ func ClearChannelAffinityCache(c *gin.Context) {
}, },
}) })
} }
func GetChannelAffinityUsageCacheStats(c *gin.Context) {
ruleName := strings.TrimSpace(c.Query("rule_name"))
usingGroup := strings.TrimSpace(c.Query("using_group"))
keyFp := strings.TrimSpace(c.Query("key_fp"))
if ruleName == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "missing param: rule_name",
})
return
}
if keyFp == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "missing param: key_fp",
})
return
}
stats := service.GetChannelAffinityUsageCacheStats(ruleName, usingGroup, keyFp)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": stats,
})
}
...@@ -115,7 +115,7 @@ func GetStatus(c *gin.Context) { ...@@ -115,7 +115,7 @@ func GetStatus(c *gin.Context) {
"user_agreement_enabled": legalSetting.UserAgreement != "", "user_agreement_enabled": legalSetting.UserAgreement != "",
"privacy_policy_enabled": legalSetting.PrivacyPolicy != "", "privacy_policy_enabled": legalSetting.PrivacyPolicy != "",
"checkin_enabled": operation_setting.GetCheckinSetting().Enabled, "checkin_enabled": operation_setting.GetCheckinSetting().Enabled,
"_qn": "new-api", "_qn": "new-api",
} }
// 根据启用状态注入可选内容 // 根据启用状态注入可选内容
......
...@@ -91,11 +91,11 @@ func GetPerformanceStats(c *gin.Context) { ...@@ -91,11 +91,11 @@ func GetPerformanceStats(c *gin.Context) {
// 获取配置信息 // 获取配置信息
diskConfig := common.GetDiskCacheConfig() diskConfig := common.GetDiskCacheConfig()
config := PerformanceConfig{ config := PerformanceConfig{
DiskCacheEnabled: diskConfig.Enabled, DiskCacheEnabled: diskConfig.Enabled,
DiskCacheThresholdMB: diskConfig.ThresholdMB, DiskCacheThresholdMB: diskConfig.ThresholdMB,
DiskCacheMaxSizeMB: diskConfig.MaxSizeMB, DiskCacheMaxSizeMB: diskConfig.MaxSizeMB,
DiskCachePath: diskConfig.Path, DiskCachePath: diskConfig.Path,
IsRunningInContainer: common.IsRunningInContainer(), IsRunningInContainer: common.IsRunningInContainer(),
} }
// 获取磁盘空间信息 // 获取磁盘空间信息
...@@ -199,4 +199,3 @@ func getDiskCacheInfo() DiskCacheInfo { ...@@ -199,4 +199,3 @@ func getDiskCacheInfo() DiskCacheInfo {
return info return info
} }
...@@ -311,6 +311,9 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b ...@@ -311,6 +311,9 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
if openaiErr == nil { if openaiErr == nil {
return false return false
} }
if service.ShouldSkipRetryAfterChannelAffinityFailure(c) {
return false
}
if types.IsChannelError(openaiErr) { if types.IsChannelError(openaiErr) {
return true return true
} }
...@@ -514,6 +517,9 @@ func shouldRetryTaskRelay(c *gin.Context, channelId int, taskErr *dto.TaskError, ...@@ -514,6 +517,9 @@ func shouldRetryTaskRelay(c *gin.Context, channelId int, taskErr *dto.TaskError,
if taskErr == nil { if taskErr == nil {
return false return false
} }
if service.ShouldSkipRetryAfterChannelAffinityFailure(c) {
return false
}
if retryTimes <= 0 { if retryTimes <= 0 {
return false return false
} }
......
...@@ -19,8 +19,8 @@ import ( ...@@ -19,8 +19,8 @@ import (
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/router" "github.com/QuantumNous/new-api/router"
"github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/service"
_ "github.com/QuantumNous/new-api/setting/performance_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/QuantumNous/new-api/setting/ratio_setting"
_ "github.com/QuantumNous/new-api/setting/performance_setting" // 注册性能设置
"github.com/bytedance/gopkg/util/gopool" "github.com/bytedance/gopkg/util/gopool"
"github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions"
......
...@@ -13,8 +13,8 @@ import ( ...@@ -13,8 +13,8 @@ import (
"github.com/QuantumNous/new-api/relay/channel/openai" "github.com/QuantumNous/new-api/relay/channel/openai"
relaycommon "github.com/QuantumNous/new-api/relay/common" relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/constant" "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/QuantumNous/new-api/types" "github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
......
...@@ -219,6 +219,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types ...@@ -219,6 +219,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
} }
func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent ...string) { func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent ...string) {
originUsage := usage
if usage == nil { if usage == nil {
usage = &dto.Usage{ usage = &dto.Usage{
PromptTokens: relayInfo.GetEstimatePromptTokens(), PromptTokens: relayInfo.GetEstimatePromptTokens(),
...@@ -228,6 +229,10 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage ...@@ -228,6 +229,10 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
extraContent = append(extraContent, "上游无计费信息") extraContent = append(extraContent, "上游无计费信息")
} }
if originUsage != nil {
service.ObserveChannelAffinityUsageCacheFromContext(ctx, usage)
}
adminRejectReason := common.GetContextKeyString(ctx, constant.ContextKeyAdminRejectReason) adminRejectReason := common.GetContextKeyString(ctx, constant.ContextKeyAdminRejectReason)
useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix() useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
......
...@@ -220,6 +220,7 @@ func SetApiRouter(router *gin.Engine) { ...@@ -220,6 +220,7 @@ func SetApiRouter(router *gin.Engine) {
logRoute.DELETE("/", middleware.AdminAuth(), controller.DeleteHistoryLogs) logRoute.DELETE("/", middleware.AdminAuth(), controller.DeleteHistoryLogs)
logRoute.GET("/stat", middleware.AdminAuth(), controller.GetLogsStat) logRoute.GET("/stat", middleware.AdminAuth(), controller.GetLogsStat)
logRoute.GET("/self/stat", middleware.UserAuth(), controller.GetLogsSelfStat) logRoute.GET("/self/stat", middleware.UserAuth(), controller.GetLogsSelfStat)
logRoute.GET("/channel_affinity_usage_cache", middleware.AdminAuth(), controller.GetChannelAffinityUsageCacheStats)
logRoute.GET("/search", middleware.AdminAuth(), controller.SearchAllLogs) logRoute.GET("/search", middleware.AdminAuth(), controller.SearchAllLogs)
logRoute.GET("/self", middleware.UserAuth(), controller.GetUserLogs) logRoute.GET("/self", middleware.UserAuth(), controller.GetUserLogs)
logRoute.GET("/self/search", middleware.UserAuth(), controller.SearchUserLogs) logRoute.GET("/self/search", middleware.UserAuth(), controller.SearchUserLogs)
......
...@@ -18,6 +18,8 @@ type ChannelAffinityRule struct { ...@@ -18,6 +18,8 @@ type ChannelAffinityRule struct {
ValueRegex string `json:"value_regex"` ValueRegex string `json:"value_regex"`
TTLSeconds int `json:"ttl_seconds"` TTLSeconds int `json:"ttl_seconds"`
SkipRetryOnFailure bool `json:"skip_retry_on_failure,omitempty"`
IncludeUsingGroup bool `json:"include_using_group"` IncludeUsingGroup bool `json:"include_using_group"`
IncludeRuleName bool `json:"include_rule_name"` IncludeRuleName bool `json:"include_rule_name"`
} }
......
...@@ -21,77 +21,66 @@ import { defineConfig } from 'i18next-cli'; ...@@ -21,77 +21,66 @@ import { defineConfig } from 'i18next-cli';
/** @type {import('i18next-cli').I18nextToolkitConfig} */ /** @type {import('i18next-cli').I18nextToolkitConfig} */
export default defineConfig({ export default defineConfig({
locales: [ locales: ['zh', 'en', 'fr', 'ru', 'ja', 'vi'],
"zh",
"en",
"fr",
"ru",
"ja",
"vi"
],
extract: { extract: {
input: [ input: ['src/**/*.{js,jsx,ts,tsx}'],
"src/**/*.{js,jsx,ts,tsx}" ignore: ['src/i18n/**/*'],
], output: 'src/i18n/locales/{{language}}.json',
ignore: [
"src/i18n/**/*"
],
output: "src/i18n/locales/{{language}}.json",
ignoredAttributes: [ ignoredAttributes: [
"accept", 'accept',
"align", 'align',
"aria-label", 'aria-label',
"autoComplete", 'autoComplete',
"className", 'className',
"clipRule", 'clipRule',
"color", 'color',
"crossOrigin", 'crossOrigin',
"data-index", 'data-index',
"data-name", 'data-name',
"data-testid", 'data-testid',
"data-type", 'data-type',
"defaultActiveKey", 'defaultActiveKey',
"direction", 'direction',
"editorType", 'editorType',
"field", 'field',
"fill", 'fill',
"fillRule", 'fillRule',
"height", 'height',
"hoverStyle", 'hoverStyle',
"htmlType", 'htmlType',
"id", 'id',
"itemKey", 'itemKey',
"key", 'key',
"keyPrefix", 'keyPrefix',
"layout", 'layout',
"margin", 'margin',
"maxHeight", 'maxHeight',
"mode", 'mode',
"name", 'name',
"overflow", 'overflow',
"placement", 'placement',
"position", 'position',
"rel", 'rel',
"role", 'role',
"rowKey", 'rowKey',
"searchPosition", 'searchPosition',
"selectedStyle", 'selectedStyle',
"shape", 'shape',
"size", 'size',
"style", 'style',
"theme", 'theme',
"trigger", 'trigger',
"uploadTrigger", 'uploadTrigger',
"validateStatus", 'validateStatus',
"value", 'value',
"viewBox", 'viewBox',
"width" 'width',
], ],
sort: true, sort: true,
disablePlurals: false, disablePlurals: false,
removeUnusedKeys: false, removeUnusedKeys: false,
nsSeparator: false, nsSeparator: false,
keySeparator: false, keySeparator: false,
mergeNamespaces: true mergeNamespaces: true,
} },
}); });
\ No newline at end of file
...@@ -39,7 +39,15 @@ import { ...@@ -39,7 +39,15 @@ import {
isPasskeySupported, isPasskeySupported,
} from '../../helpers'; } from '../../helpers';
import Turnstile from 'react-turnstile'; import Turnstile from 'react-turnstile';
import { Button, Card, Checkbox, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui'; import {
Button,
Card,
Checkbox,
Divider,
Form,
Icon,
Modal,
} from '@douyinfe/semi-ui';
import Title from '@douyinfe/semi-ui/lib/es/typography/title'; import Title from '@douyinfe/semi-ui/lib/es/typography/title';
import Text from '@douyinfe/semi-ui/lib/es/typography/text'; import Text from '@douyinfe/semi-ui/lib/es/typography/text';
import TelegramLoginButton from 'react-telegram-login'; import TelegramLoginButton from 'react-telegram-login';
...@@ -55,7 +63,7 @@ import WeChatIcon from '../common/logo/WeChatIcon'; ...@@ -55,7 +63,7 @@ import WeChatIcon from '../common/logo/WeChatIcon';
import LinuxDoIcon from '../common/logo/LinuxDoIcon'; import LinuxDoIcon from '../common/logo/LinuxDoIcon';
import TwoFAVerification from './TwoFAVerification'; import TwoFAVerification from './TwoFAVerification';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { SiDiscord }from 'react-icons/si'; import { SiDiscord } from 'react-icons/si';
const LoginForm = () => { const LoginForm = () => {
let navigate = useNavigate(); let navigate = useNavigate();
...@@ -126,7 +134,7 @@ const LoginForm = () => { ...@@ -126,7 +134,7 @@ const LoginForm = () => {
setTurnstileEnabled(true); setTurnstileEnabled(true);
setTurnstileSiteKey(status.turnstile_site_key); setTurnstileSiteKey(status.turnstile_site_key);
} }
// 从 status 获取用户协议和隐私政策的启用状态 // 从 status 获取用户协议和隐私政策的启用状态
setHasUserAgreement(status?.user_agreement_enabled || false); setHasUserAgreement(status?.user_agreement_enabled || false);
setHasPrivacyPolicy(status?.privacy_policy_enabled || false); setHasPrivacyPolicy(status?.privacy_policy_enabled || false);
...@@ -514,7 +522,15 @@ const LoginForm = () => { ...@@ -514,7 +522,15 @@ const LoginForm = () => {
theme='outline' theme='outline'
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors' className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type='tertiary' type='tertiary'
icon={<SiDiscord style={{ color: '#5865F2', width: '20px', height: '20px' }} />} icon={
<SiDiscord
style={{
color: '#5865F2',
width: '20px',
height: '20px',
}}
/>
}
onClick={handleDiscordClick} onClick={handleDiscordClick}
loading={discordLoading} loading={discordLoading}
> >
...@@ -626,11 +642,11 @@ const LoginForm = () => { ...@@ -626,11 +642,11 @@ const LoginForm = () => {
{t('隐私政策')} {t('隐私政策')}
</a> </a>
</> </>
)} )}
</Text> </Text>
</Checkbox> </Checkbox>
</div> </div>
)} )}
{!status.self_use_mode_enabled && ( {!status.self_use_mode_enabled && (
<div className='mt-6 text-center text-sm'> <div className='mt-6 text-center text-sm'>
...@@ -746,7 +762,9 @@ const LoginForm = () => { ...@@ -746,7 +762,9 @@ const LoginForm = () => {
htmlType='submit' htmlType='submit'
onClick={handleSubmit} onClick={handleSubmit}
loading={loginLoading} loading={loginLoading}
disabled={(hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms} disabled={
(hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms
}
> >
{t('继续')} {t('继续')}
</Button> </Button>
......
...@@ -41,7 +41,7 @@ const isUrl = (content) => { ...@@ -41,7 +41,7 @@ const isUrl = (content) => {
// 检查是否为 HTML 内容 // 检查是否为 HTML 内容
const isHtmlContent = (content) => { const isHtmlContent = (content) => {
if (!content || typeof content !== 'string') return false; if (!content || typeof content !== 'string') return false;
// 检查是否包含HTML标签 // 检查是否包含HTML标签
const htmlTagRegex = /<\/?[a-z][\s\S]*>/i; const htmlTagRegex = /<\/?[a-z][\s\S]*>/i;
return htmlTagRegex.test(content); return htmlTagRegex.test(content);
...@@ -52,16 +52,16 @@ const sanitizeHtml = (html) => { ...@@ -52,16 +52,16 @@ const sanitizeHtml = (html) => {
// 创建一个临时元素来解析HTML // 创建一个临时元素来解析HTML
const tempDiv = document.createElement('div'); const tempDiv = document.createElement('div');
tempDiv.innerHTML = html; tempDiv.innerHTML = html;
// 提取样式 // 提取样式
const styles = Array.from(tempDiv.querySelectorAll('style')) const styles = Array.from(tempDiv.querySelectorAll('style'))
.map(style => style.innerHTML) .map((style) => style.innerHTML)
.join('\n'); .join('\n');
// 提取body内容,如果没有body标签则使用全部内容 // 提取body内容,如果没有body标签则使用全部内容
const bodyContent = tempDiv.querySelector('body'); const bodyContent = tempDiv.querySelector('body');
const content = bodyContent ? bodyContent.innerHTML : html; const content = bodyContent ? bodyContent.innerHTML : html;
return { content, styles }; return { content, styles };
}; };
...@@ -129,7 +129,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { ...@@ -129,7 +129,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
// 处理HTML样式注入 // 处理HTML样式注入
useEffect(() => { useEffect(() => {
const styleId = `document-renderer-styles-${cacheKey}`; const styleId = `document-renderer-styles-${cacheKey}`;
if (htmlStyles) { if (htmlStyles) {
let styleEl = document.getElementById(styleId); let styleEl = document.getElementById(styleId);
if (!styleEl) { if (!styleEl) {
...@@ -165,8 +165,12 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { ...@@ -165,8 +165,12 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
<div className='flex justify-center items-center min-h-screen bg-gray-50'> <div className='flex justify-center items-center min-h-screen bg-gray-50'>
<Empty <Empty
title={t('管理员未设置' + title + '内容')} title={t('管理员未设置' + title + '内容')}
image={<IllustrationConstruction style={{ width: 150, height: 150 }} />} image={
darkModeImage={<IllustrationConstructionDark style={{ width: 150, height: 150 }} />} <IllustrationConstruction style={{ width: 150, height: 150 }} />
}
darkModeImage={
<IllustrationConstructionDark style={{ width: 150, height: 150 }} />
}
className='p-8' className='p-8'
/> />
</div> </div>
...@@ -179,7 +183,9 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { ...@@ -179,7 +183,9 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
<div className='flex justify-center items-center min-h-screen bg-gray-50 p-4'> <div className='flex justify-center items-center min-h-screen bg-gray-50 p-4'>
<Card className='max-w-md w-full'> <Card className='max-w-md w-full'>
<div className='text-center'> <div className='text-center'>
<Title heading={4} className='mb-4'>{title}</Title> <Title heading={4} className='mb-4'>
{title}
</Title>
<p className='text-gray-600 mb-4'> <p className='text-gray-600 mb-4'>
{t('管理员设置了外部链接,点击下方按钮访问')} {t('管理员设置了外部链接,点击下方按钮访问')}
</p> </p>
...@@ -202,20 +208,22 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { ...@@ -202,20 +208,22 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
// 如果是 HTML 内容,直接渲染 // 如果是 HTML 内容,直接渲染
if (isHtmlContent(content)) { if (isHtmlContent(content)) {
const { content: htmlContent, styles } = sanitizeHtml(content); const { content: htmlContent, styles } = sanitizeHtml(content);
// 设置样式(如果有的话) // 设置样式(如果有的话)
useEffect(() => { useEffect(() => {
if (styles && styles !== htmlStyles) { if (styles && styles !== htmlStyles) {
setHtmlStyles(styles); setHtmlStyles(styles);
} }
}, [content, styles, htmlStyles]); }, [content, styles, htmlStyles]);
return ( return (
<div className='min-h-screen bg-gray-50'> <div className='min-h-screen bg-gray-50'>
<div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'> <div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'>
<div className='bg-white rounded-lg shadow-sm p-8'> <div className='bg-white rounded-lg shadow-sm p-8'>
<Title heading={2} className='text-center mb-8'>{title}</Title> <Title heading={2} className='text-center mb-8'>
<div {title}
</Title>
<div
className='prose prose-lg max-w-none' className='prose prose-lg max-w-none'
dangerouslySetInnerHTML={{ __html: htmlContent }} dangerouslySetInnerHTML={{ __html: htmlContent }}
/> />
...@@ -230,7 +238,9 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { ...@@ -230,7 +238,9 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
<div className='min-h-screen bg-gray-50'> <div className='min-h-screen bg-gray-50'>
<div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'> <div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'>
<div className='bg-white rounded-lg shadow-sm p-8'> <div className='bg-white rounded-lg shadow-sm p-8'>
<Title heading={2} className='text-center mb-8'>{title}</Title> <Title heading={2} className='text-center mb-8'>
{title}
</Title>
<div className='prose prose-lg max-w-none'> <div className='prose prose-lg max-w-none'>
<MarkdownRenderer content={content} /> <MarkdownRenderer content={content} />
</div> </div>
...@@ -240,4 +250,4 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { ...@@ -240,4 +250,4 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
); );
}; };
export default DocumentRenderer; export default DocumentRenderer;
\ No newline at end of file
...@@ -136,9 +136,7 @@ const SkeletonWrapper = ({ ...@@ -136,9 +136,7 @@ const SkeletonWrapper = ({
loading={true} loading={true}
active active
placeholder={ placeholder={
<Skeleton.Title <Skeleton.Title style={{ width, height, borderRadius: 9999 }} />
style={{ width, height, borderRadius: 9999 }}
/>
} }
/> />
</div> </div>
...@@ -186,7 +184,9 @@ const SkeletonWrapper = ({ ...@@ -186,7 +184,9 @@ const SkeletonWrapper = ({
loading={true} loading={true}
active active
placeholder={ placeholder={
<Skeleton.Title style={{ width: width || 60, height: height || 12 }} /> <Skeleton.Title
style={{ width: width || 60, height: height || 12 }}
/>
} }
/> />
</div> </div>
...@@ -221,9 +221,7 @@ const SkeletonWrapper = ({ ...@@ -221,9 +221,7 @@ const SkeletonWrapper = ({
loading={true} loading={true}
active active
placeholder={ placeholder={
<Skeleton.Title <Skeleton.Title style={{ width: labelWidth, height: TEXT_HEIGHT }} />
style={{ width: labelWidth, height: TEXT_HEIGHT }}
/>
} }
/> />
); );
......
...@@ -115,8 +115,7 @@ const linkifyHtml = (html) => { ...@@ -115,8 +115,7 @@ const linkifyHtml = (html) => {
if (part.startsWith('<')) return part; if (part.startsWith('<')) return part;
return part.replace( return part.replace(
linkRegex, linkRegex,
(url) => (url) => `<a href="${url}" target="_blank" rel="noreferrer">${url}</a>`,
`<a href="${url}" target="_blank" rel="noreferrer">${url}</a>`,
); );
}) })
.join(''); .join('');
......
...@@ -30,64 +30,67 @@ const CustomInputRender = (props) => { ...@@ -30,64 +30,67 @@ const CustomInputRender = (props) => {
detailProps; detailProps;
const containerRef = useRef(null); const containerRef = useRef(null);
const handlePaste = useCallback(async (e) => { const handlePaste = useCallback(
const items = e.clipboardData?.items; async (e) => {
if (!items) return; const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type.indexOf('image') !== -1) {
e.preventDefault();
const file = item.getAsFile();
if (file) {
try {
if (!imageEnabled) {
Toast.warning({
content: t('请先在设置中启用图片功能'),
duration: 3,
});
return;
}
const reader = new FileReader(); for (let i = 0; i < items.length; i++) {
reader.onload = (event) => { const item = items[i];
const base64 = event.target.result;
if (item.type.indexOf('image') !== -1) {
if (onPasteImage) { e.preventDefault();
onPasteImage(base64); const file = item.getAsFile();
Toast.success({
content: t('图片已添加'), if (file) {
duration: 2, try {
if (!imageEnabled) {
Toast.warning({
content: t('请先在设置中启用图片功能'),
duration: 3,
}); });
} else { return;
}
const reader = new FileReader();
reader.onload = (event) => {
const base64 = event.target.result;
if (onPasteImage) {
onPasteImage(base64);
Toast.success({
content: t('图片已添加'),
duration: 2,
});
} else {
Toast.error({
content: t('无法添加图片'),
duration: 2,
});
}
};
reader.onerror = () => {
console.error('Failed to read image file:', reader.error);
Toast.error({ Toast.error({
content: t('无法添加图片'), content: t('粘贴图片失败'),
duration: 2, duration: 2,
}); });
} };
}; reader.readAsDataURL(file);
reader.onerror = () => { } catch (error) {
console.error('Failed to read image file:', reader.error); console.error('Failed to paste image:', error);
Toast.error({ Toast.error({
content: t('粘贴图片失败'), content: t('粘贴图片失败'),
duration: 2, duration: 2,
}); });
}; }
reader.readAsDataURL(file);
} catch (error) {
console.error('Failed to paste image:', error);
Toast.error({
content: t('粘贴图片失败'),
duration: 2,
});
} }
break;
} }
break;
} }
} },
}, [onPasteImage, imageEnabled, t]); [onPasteImage, imageEnabled, t],
);
useEffect(() => { useEffect(() => {
const container = containerRef.current; const container = containerRef.current;
......
...@@ -140,7 +140,9 @@ const CustomRequestEditor = ({ ...@@ -140,7 +140,9 @@ const CustomRequestEditor = ({
{/* 提示信息 */} {/* 提示信息 */}
<Banner <Banner
type='warning' type='warning'
description={t('启用此模式后,将使用您自定义的请求体发送API请求,模型配置面板的参数设置将被忽略。')} description={t(
'启用此模式后,将使用您自定义的请求体发送API请求,模型配置面板的参数设置将被忽略。',
)}
icon={<AlertTriangle size={16} />} icon={<AlertTriangle size={16} />}
className='!rounded-lg' className='!rounded-lg'
closeIcon={null} closeIcon={null}
...@@ -201,7 +203,9 @@ const CustomRequestEditor = ({ ...@@ -201,7 +203,9 @@ const CustomRequestEditor = ({
)} )}
<Typography.Text className='text-xs text-gray-500 mt-2 block'> <Typography.Text className='text-xs text-gray-500 mt-2 block'>
{t('请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。')} {t(
'请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。',
)}
</Typography.Text> </Typography.Text>
</div> </div>
</> </>
......
...@@ -191,10 +191,7 @@ const DebugPanel = ({ ...@@ -191,10 +191,7 @@ const DebugPanel = ({
itemKey='response' itemKey='response'
> >
{debugData.sseMessages && debugData.sseMessages.length > 0 ? ( {debugData.sseMessages && debugData.sseMessages.length > 0 ? (
<SSEViewer <SSEViewer sseData={debugData.sseMessages} title='response' />
sseData={debugData.sseMessages}
title='response'
/>
) : ( ) : (
<CodeViewer <CodeViewer
content={debugData.response} content={debugData.response}
......
...@@ -18,8 +18,22 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -18,8 +18,22 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import React, { useState, useMemo, useCallback } from 'react'; import React, { useState, useMemo, useCallback } from 'react';
import { Button, Tooltip, Toast, Collapse, Badge, Typography } from '@douyinfe/semi-ui'; import {
import { Copy, ChevronDown, ChevronUp, Zap, CheckCircle, XCircle } from 'lucide-react'; Button,
Tooltip,
Toast,
Collapse,
Badge,
Typography,
} from '@douyinfe/semi-ui';
import {
Copy,
ChevronDown,
ChevronUp,
Zap,
CheckCircle,
XCircle,
} from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { copy } from '../../helpers'; import { copy } from '../../helpers';
...@@ -67,19 +81,19 @@ const SSEViewer = ({ sseData }) => { ...@@ -67,19 +81,19 @@ const SSEViewer = ({ sseData }) => {
const stats = useMemo(() => { const stats = useMemo(() => {
const total = parsedSSEData.length; const total = parsedSSEData.length;
const errors = parsedSSEData.filter(item => item.error).length; const errors = parsedSSEData.filter((item) => item.error).length;
const done = parsedSSEData.filter(item => item.isDone).length; const done = parsedSSEData.filter((item) => item.isDone).length;
const valid = total - errors - done; const valid = total - errors - done;
return { total, errors, done, valid }; return { total, errors, done, valid };
}, [parsedSSEData]); }, [parsedSSEData]);
const handleToggleAll = useCallback(() => { const handleToggleAll = useCallback(() => {
setExpandedKeys(prev => { setExpandedKeys((prev) => {
if (prev.length === parsedSSEData.length) { if (prev.length === parsedSSEData.length) {
return []; return [];
} else { } else {
return parsedSSEData.map(item => item.key); return parsedSSEData.map((item) => item.key);
} }
}); });
}, [parsedSSEData]); }, [parsedSSEData]);
...@@ -87,7 +101,9 @@ const SSEViewer = ({ sseData }) => { ...@@ -87,7 +101,9 @@ const SSEViewer = ({ sseData }) => {
const handleCopyAll = useCallback(async () => { const handleCopyAll = useCallback(async () => {
try { try {
const allData = parsedSSEData const allData = parsedSSEData
.map(item => (item.parsed ? JSON.stringify(item.parsed, null, 2) : item.raw)) .map((item) =>
item.parsed ? JSON.stringify(item.parsed, null, 2) : item.raw,
)
.join('\n\n'); .join('\n\n');
await copy(allData); await copy(allData);
...@@ -100,15 +116,20 @@ const SSEViewer = ({ sseData }) => { ...@@ -100,15 +116,20 @@ const SSEViewer = ({ sseData }) => {
} }
}, [parsedSSEData, t]); }, [parsedSSEData, t]);
const handleCopySingle = useCallback(async (item) => { const handleCopySingle = useCallback(
try { async (item) => {
const textToCopy = item.parsed ? JSON.stringify(item.parsed, null, 2) : item.raw; try {
await copy(textToCopy); const textToCopy = item.parsed
Toast.success(t('已复制')); ? JSON.stringify(item.parsed, null, 2)
} catch (err) { : item.raw;
Toast.error(t('复制失败')); await copy(textToCopy);
} Toast.success(t('已复制'));
}, [t]); } catch (err) {
Toast.error(t('复制失败'));
}
},
[t],
);
const renderSSEItem = (item) => { const renderSSEItem = (item) => {
if (item.isDone) { if (item.isDone) {
...@@ -158,18 +179,24 @@ const SSEViewer = ({ sseData }) => { ...@@ -158,18 +179,24 @@ const SSEViewer = ({ sseData }) => {
{item.parsed?.choices?.[0] && ( {item.parsed?.choices?.[0] && (
<div className='flex flex-wrap gap-2 text-xs'> <div className='flex flex-wrap gap-2 text-xs'>
{item.parsed.choices[0].delta?.content && ( {item.parsed.choices[0].delta?.content && (
<Badge count={`${t('内容')}: "${String(item.parsed.choices[0].delta.content).substring(0, 20)}..."`} type='primary' /> <Badge
count={`${t('内容')}: "${String(item.parsed.choices[0].delta.content).substring(0, 20)}..."`}
type='primary'
/>
)} )}
{item.parsed.choices[0].delta?.reasoning_content && ( {item.parsed.choices[0].delta?.reasoning_content && (
<Badge count={t('有 Reasoning')} type='warning' /> <Badge count={t('有 Reasoning')} type='warning' />
)} )}
{item.parsed.choices[0].finish_reason && ( {item.parsed.choices[0].finish_reason && (
<Badge count={`${t('完成')}: ${item.parsed.choices[0].finish_reason}`} type='success' /> <Badge
count={`${t('完成')}: ${item.parsed.choices[0].finish_reason}`}
type='success'
/>
)} )}
{item.parsed.usage && ( {item.parsed.usage && (
<Badge <Badge
count={`${t('令牌')}: ${item.parsed.usage.prompt_tokens || 0}/${item.parsed.usage.completion_tokens || 0}`} count={`${t('令牌')}: ${item.parsed.usage.prompt_tokens || 0}/${item.parsed.usage.completion_tokens || 0}`}
type='tertiary' type='tertiary'
/> />
)} )}
</div> </div>
...@@ -194,7 +221,9 @@ const SSEViewer = ({ sseData }) => { ...@@ -194,7 +221,9 @@ const SSEViewer = ({ sseData }) => {
<Zap size={16} className='text-blue-500' /> <Zap size={16} className='text-blue-500' />
<Typography.Text strong>{t('SSE数据流')}</Typography.Text> <Typography.Text strong>{t('SSE数据流')}</Typography.Text>
<Badge count={stats.total} type='primary' /> <Badge count={stats.total} type='primary' />
{stats.errors > 0 && <Badge count={`${stats.errors} ${t('错误')}`} type='danger' />} {stats.errors > 0 && (
<Badge count={`${stats.errors} ${t('错误')}`} type='danger' />
)}
</div> </div>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
...@@ -208,14 +237,28 @@ const SSEViewer = ({ sseData }) => { ...@@ -208,14 +237,28 @@ const SSEViewer = ({ sseData }) => {
{copied ? t('已复制') : t('复制全部')} {copied ? t('已复制') : t('复制全部')}
</Button> </Button>
</Tooltip> </Tooltip>
<Tooltip content={expandedKeys.length === parsedSSEData.length ? t('全部收起') : t('全部展开')}> <Tooltip
content={
expandedKeys.length === parsedSSEData.length
? t('全部收起')
: t('全部展开')
}
>
<Button <Button
icon={expandedKeys.length === parsedSSEData.length ? <ChevronUp size={14} /> : <ChevronDown size={14} />} icon={
expandedKeys.length === parsedSSEData.length ? (
<ChevronUp size={14} />
) : (
<ChevronDown size={14} />
)
}
size='small' size='small'
onClick={handleToggleAll} onClick={handleToggleAll}
theme='borderless' theme='borderless'
> >
{expandedKeys.length === parsedSSEData.length ? t('收起') : t('展开')} {expandedKeys.length === parsedSSEData.length
? t('收起')
: t('展开')}
</Button> </Button>
</Tooltip> </Tooltip>
</div> </div>
...@@ -242,11 +285,16 @@ const SSEViewer = ({ sseData }) => { ...@@ -242,11 +285,16 @@ const SSEViewer = ({ sseData }) => {
) : ( ) : (
<> <>
<span className='text-gray-600'> <span className='text-gray-600'>
{item.parsed?.id || item.parsed?.object || t('SSE 事件')} {item.parsed?.id ||
item.parsed?.object ||
t('SSE 事件')}
</span> </span>
{item.parsed?.choices?.[0]?.delta && ( {item.parsed?.choices?.[0]?.delta && (
<span className='text-xs text-gray-400'> <span className='text-xs text-gray-400'>
{Object.keys(item.parsed.choices[0].delta).filter(k => item.parsed.choices[0].delta[k]).join(', ')} {' '}
{Object.keys(item.parsed.choices[0].delta)
.filter((k) => item.parsed.choices[0].delta[k])
.join(', ')}
</span> </span>
)} )}
</> </>
......
...@@ -68,4 +68,3 @@ export default function HttpStatusCodeRulesInput(props) { ...@@ -68,4 +68,3 @@ export default function HttpStatusCodeRulesInput(props) {
</> </>
); );
} }
...@@ -40,7 +40,7 @@ const ModelDeploymentSetting = () => { ...@@ -40,7 +40,7 @@ const ModelDeploymentSetting = () => {
'model_deployment.ionet.api_key': '', 'model_deployment.ionet.api_key': '',
'model_deployment.ionet.enabled': false, 'model_deployment.ionet.enabled': false,
}; };
data.forEach((item) => { data.forEach((item) => {
if (item.key.endsWith('Enabled') || item.key.endsWith('enabled')) { if (item.key.endsWith('Enabled') || item.key.endsWith('enabled')) {
newInputs[item.key] = toBoolean(item.value); newInputs[item.key] = toBoolean(item.value);
...@@ -82,4 +82,4 @@ const ModelDeploymentSetting = () => { ...@@ -82,4 +82,4 @@ const ModelDeploymentSetting = () => {
); );
}; };
export default ModelDeploymentSetting; export default ModelDeploymentSetting;
\ No newline at end of file
...@@ -71,7 +71,8 @@ const OperationSetting = () => { ...@@ -71,7 +71,8 @@ const OperationSetting = () => {
AutomaticEnableChannelEnabled: false, AutomaticEnableChannelEnabled: false,
AutomaticDisableKeywords: '', AutomaticDisableKeywords: '',
AutomaticDisableStatusCodes: '401', AutomaticDisableStatusCodes: '401',
AutomaticRetryStatusCodes: '100-199,300-399,401-407,409-499,500-503,505-523,525-599', AutomaticRetryStatusCodes:
'100-199,300-399,401-407,409-499,500-503,505-523,525-599',
'monitor_setting.auto_test_channel_enabled': false, 'monitor_setting.auto_test_channel_enabled': false,
'monitor_setting.auto_test_channel_minutes': 10 /* 签到设置 */, 'monitor_setting.auto_test_channel_minutes': 10 /* 签到设置 */,
'checkin_setting.enabled': false, 'checkin_setting.enabled': false,
......
...@@ -378,13 +378,15 @@ const OtherSetting = () => { ...@@ -378,13 +378,15 @@ const OtherSetting = () => {
<Form.TextArea <Form.TextArea
label={t('用户协议')} label={t('用户协议')}
placeholder={t( placeholder={t(
'在此输入用户协议内容,支持 Markdown & HTML 代码', '在此输入用户协议内容,支持 Markdown & HTML 代码',
)} )}
field={LEGAL_USER_AGREEMENT_KEY} field={LEGAL_USER_AGREEMENT_KEY}
onChange={handleInputChange} onChange={handleInputChange}
style={{ fontFamily: 'JetBrains Mono, Consolas' }} style={{ fontFamily: 'JetBrains Mono, Consolas' }}
autosize={{ minRows: 6, maxRows: 12 }} autosize={{ minRows: 6, maxRows: 12 }}
helpText={t('填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议')} helpText={t(
'填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议',
)}
/> />
<Button <Button
onClick={submitUserAgreement} onClick={submitUserAgreement}
...@@ -401,7 +403,9 @@ const OtherSetting = () => { ...@@ -401,7 +403,9 @@ const OtherSetting = () => {
onChange={handleInputChange} onChange={handleInputChange}
style={{ fontFamily: 'JetBrains Mono, Consolas' }} style={{ fontFamily: 'JetBrains Mono, Consolas' }}
autosize={{ minRows: 6, maxRows: 12 }} autosize={{ minRows: 6, maxRows: 12 }}
helpText={t('填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策')} helpText={t(
'填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策',
)}
/> />
<Button <Button
onClick={submitPrivacyPolicy} onClick={submitPrivacyPolicy}
......
...@@ -57,9 +57,7 @@ const RatioSetting = () => { ...@@ -57,9 +57,7 @@ const RatioSetting = () => {
if (success) { if (success) {
let newInputs = {}; let newInputs = {};
data.forEach((item) => { data.forEach((item) => {
if ( if (item.value.startsWith('{') || item.value.startsWith('[')) {
item.value.startsWith('{') || item.value.startsWith('[')
) {
try { try {
item.value = JSON.stringify(JSON.parse(item.value), null, 2); item.value = JSON.stringify(JSON.parse(item.value), null, 2);
} catch (e) { } catch (e) {
......
...@@ -481,10 +481,14 @@ const SystemSetting = () => { ...@@ -481,10 +481,14 @@ const SystemSetting = () => {
const options = []; const options = [];
if (originInputs['discord.client_id'] !== inputs['discord.client_id']) { if (originInputs['discord.client_id'] !== inputs['discord.client_id']) {
options.push({ key: 'discord.client_id', value: inputs['discord.client_id'] }); options.push({
key: 'discord.client_id',
value: inputs['discord.client_id'],
});
} }
if ( if (
originInputs['discord.client_secret'] !== inputs['discord.client_secret'] && originInputs['discord.client_secret'] !==
inputs['discord.client_secret'] &&
inputs['discord.client_secret'] !== '' inputs['discord.client_secret'] !== ''
) { ) {
options.push({ options.push({
...@@ -745,8 +749,8 @@ const SystemSetting = () => { ...@@ -745,8 +749,8 @@ const SystemSetting = () => {
rel='noreferrer' rel='noreferrer'
> >
new-api-worker new-api-worker
</a> </a>{' '}
{' '}{t('或其兼容new-api-worker格式的其他版本')} {t('或其兼容new-api-worker格式的其他版本')}
</Text> </Text>
<Row <Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }} gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
......
...@@ -109,7 +109,9 @@ const renderType = (type, record = {}, t) => { ...@@ -109,7 +109,9 @@ const renderType = (type, record = {}, t) => {
<Tooltip <Tooltip
content={ content={
<div className='max-w-xs'> <div className='max-w-xs'>
<div className='text-xs text-gray-600'>{t('来源于 IO.NET 部署')}</div> <div className='text-xs text-gray-600'>
{t('来源于 IO.NET 部署')}
</div>
{ionetMeta?.deployment_id && ( {ionetMeta?.deployment_id && (
<div className='text-xs text-gray-500 mt-1'> <div className='text-xs text-gray-500 mt-1'>
{t('部署 ID')}: {ionetMeta.deployment_id} {t('部署 ID')}: {ionetMeta.deployment_id}
......
...@@ -19,7 +19,14 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -19,7 +19,14 @@ For commercial licensing, please contact support@quantumnous.com
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Modal, Button, Space, Typography, Input, Banner } from '@douyinfe/semi-ui'; import {
Modal,
Button,
Space,
Typography,
Input,
Banner,
} from '@douyinfe/semi-ui';
import { API, copy, showError, showSuccess } from '../../../../helpers'; import { API, copy, showError, showSuccess } from '../../../../helpers';
const { Text } = Typography; const { Text } = Typography;
...@@ -33,14 +40,21 @@ const CodexOAuthModal = ({ visible, onCancel, onSuccess }) => { ...@@ -33,14 +40,21 @@ const CodexOAuthModal = ({ visible, onCancel, onSuccess }) => {
const startOAuth = async () => { const startOAuth = async () => {
setLoading(true); setLoading(true);
try { try {
const res = await API.post('/api/channel/codex/oauth/start', {}, { skipErrorHandler: true }); const res = await API.post(
'/api/channel/codex/oauth/start',
{},
{ skipErrorHandler: true },
);
if (!res?.data?.success) { if (!res?.data?.success) {
console.error('Codex OAuth start failed:', res?.data?.message); console.error('Codex OAuth start failed:', res?.data?.message);
throw new Error(t('启动授权失败')); throw new Error(t('启动授权失败'));
} }
const url = res?.data?.data?.authorize_url || ''; const url = res?.data?.data?.authorize_url || '';
if (!url) { if (!url) {
console.error('Codex OAuth start response missing authorize_url:', res?.data); console.error(
'Codex OAuth start response missing authorize_url:',
res?.data,
);
throw new Error(t('响应缺少授权链接')); throw new Error(t('响应缺少授权链接'));
} }
setAuthorizeUrl(url); setAuthorizeUrl(url);
...@@ -106,7 +120,12 @@ const CodexOAuthModal = ({ visible, onCancel, onSuccess }) => { ...@@ -106,7 +120,12 @@ const CodexOAuthModal = ({ visible, onCancel, onSuccess }) => {
<Button theme='borderless' onClick={onCancel} disabled={loading}> <Button theme='borderless' onClick={onCancel} disabled={loading}>
{t('取消')} {t('取消')}
</Button> </Button>
<Button theme='solid' type='primary' onClick={completeOAuth} loading={loading}> <Button
theme='solid'
type='primary'
onClick={completeOAuth}
loading={loading}
>
{t('生成并填入')} {t('生成并填入')}
</Button> </Button>
</Space> </Space>
...@@ -141,7 +160,9 @@ const CodexOAuthModal = ({ visible, onCancel, onSuccess }) => { ...@@ -141,7 +160,9 @@ const CodexOAuthModal = ({ visible, onCancel, onSuccess }) => {
/> />
<Text type='tertiary' size='small'> <Text type='tertiary' size='small'>
{t('说明:生成结果是可直接粘贴到渠道密钥里的 JSON(包含 access_token / refresh_token / account_id)。')} {t(
'说明:生成结果是可直接粘贴到渠道密钥里的 JSON(包含 access_token / refresh_token / account_id)。',
)}
</Text> </Text>
</Space> </Space>
</Modal> </Modal>
......
...@@ -18,7 +18,14 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -18,7 +18,14 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Modal, Button, Progress, Tag, Typography, Spin } from '@douyinfe/semi-ui'; import {
Modal,
Button,
Progress,
Tag,
Typography,
Spin,
} from '@douyinfe/semi-ui';
import { API, showError } from '../../../../helpers'; import { API, showError } from '../../../../helpers';
const { Text } = Typography; const { Text } = Typography;
...@@ -134,7 +141,12 @@ const CodexUsageView = ({ t, record, payload, onCopy, onRefresh }) => { ...@@ -134,7 +141,12 @@ const CodexUsageView = ({ t, record, payload, onCopy, onRefresh }) => {
</Text> </Text>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
{statusTag} {statusTag}
<Button size='small' type='tertiary' theme='borderless' onClick={onRefresh}> <Button
size='small'
type='tertiary'
theme='borderless'
onClick={onRefresh}
>
{tt('刷新')} {tt('刷新')}
</Button> </Button>
</div> </div>
...@@ -243,7 +255,12 @@ const CodexUsageLoader = ({ t, record, initialPayload, onCopy }) => { ...@@ -243,7 +255,12 @@ const CodexUsageLoader = ({ t, record, initialPayload, onCopy }) => {
<div className='flex flex-col gap-3'> <div className='flex flex-col gap-3'>
<Text type='danger'>{tt('获取用量失败')}</Text> <Text type='danger'>{tt('获取用量失败')}</Text>
<div className='flex justify-end'> <div className='flex justify-end'>
<Button size='small' type='primary' theme='outline' onClick={fetchUsage}> <Button
size='small'
type='primary'
theme='outline'
onClick={fetchUsage}
>
{tt('刷新')} {tt('刷新')}
</Button> </Button>
</div> </div>
......
...@@ -533,7 +533,11 @@ const EditTagModal = (props) => { ...@@ -533,7 +533,11 @@ const EditTagModal = (props) => {
<Card className='!rounded-2xl shadow-sm border-0 mb-6'> <Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Advanced Settings */} {/* Header: Advanced Settings */}
<div className='flex items-center mb-2'> <div className='flex items-center mb-2'>
<Avatar size='small' color='orange' className='mr-2 shadow-md'> <Avatar
size='small'
color='orange'
className='mr-2 shadow-md'
>
<IconSetting size={16} /> <IconSetting size={16} />
</Avatar> </Avatar>
<div> <div>
...@@ -549,9 +553,7 @@ const EditTagModal = (props) => { ...@@ -549,9 +553,7 @@ const EditTagModal = (props) => {
field='param_override' field='param_override'
label={t('参数覆盖')} label={t('参数覆盖')}
placeholder={ placeholder={
t( t('此项可选,用于覆盖请求参数。不支持覆盖 stream 参数') +
'此项可选,用于覆盖请求参数。不支持覆盖 stream 参数',
) +
'\n' + '\n' +
t('旧格式(直接覆盖):') + t('旧格式(直接覆盖):') +
'\n{\n "temperature": 0,\n "max_tokens": 1000\n}' + '\n{\n "temperature": 0,\n "max_tokens": 1000\n}' +
......
...@@ -104,7 +104,9 @@ const ModelSelectModal = ({ ...@@ -104,7 +104,9 @@ const ModelSelectModal = ({
}, [normalizedRedirectModels, normalizedSelectedSet]); }, [normalizedRedirectModels, normalizedSelectedSet]);
const filteredModels = models.filter((m) => const filteredModels = models.filter((m) =>
String(m || '').toLowerCase().includes(keyword.toLowerCase()), String(m || '')
.toLowerCase()
.includes(keyword.toLowerCase()),
); );
// 分类模型:新获取的模型和已有模型 // 分类模型:新获取的模型和已有模型
......
...@@ -30,7 +30,7 @@ const ConfirmationDialog = ({ ...@@ -30,7 +30,7 @@ const ConfirmationDialog = ({
type = 'danger', type = 'danger',
deployment, deployment,
t, t,
loading = false loading = false,
}) => { }) => {
const [confirmText, setConfirmText] = useState(''); const [confirmText, setConfirmText] = useState('');
...@@ -66,17 +66,17 @@ const ConfirmationDialog = ({ ...@@ -66,17 +66,17 @@ const ConfirmationDialog = ({
okButtonProps={{ okButtonProps={{
disabled: !isConfirmed, disabled: !isConfirmed,
type: type === 'danger' ? 'danger' : 'primary', type: type === 'danger' ? 'danger' : 'primary',
loading loading,
}} }}
width={480} width={480}
> >
<div className="space-y-4"> <div className='space-y-4'>
<Text type="danger" strong> <Text type='danger' strong>
{t('此操作具有风险,请确认要继续执行')} {t('此操作具有风险,请确认要继续执行')}
</Text> </Text>
<Text> <Text>
{t('请输入部署名称以完成二次确认')} {t('请输入部署名称以完成二次确认')}
<Text code className="ml-1"> <Text code className='ml-1'>
{requiredText || t('未知部署')} {requiredText || t('未知部署')}
</Text> </Text>
</Text> </Text>
...@@ -87,7 +87,7 @@ const ConfirmationDialog = ({ ...@@ -87,7 +87,7 @@ const ConfirmationDialog = ({
autoFocus autoFocus
/> />
{!isConfirmed && confirmText && ( {!isConfirmed && confirmText && (
<Text type="danger" size="small"> <Text type='danger' size='small'>
{t('部署名称不匹配,请检查后重新输入')} {t('部署名称不匹配,请检查后重新输入')}
</Text> </Text>
)} )}
......
...@@ -130,9 +130,7 @@ const ExtendDurationModal = ({ ...@@ -130,9 +130,7 @@ const ExtendDurationModal = ({
? details.locations ? details.locations
.map((location) => .map((location) =>
Number( Number(
location?.id ?? location?.id ?? location?.location_id ?? location?.locationId,
location?.location_id ??
location?.locationId,
), ),
) )
.filter((id) => Number.isInteger(id) && id > 0) .filter((id) => Number.isInteger(id) && id > 0)
...@@ -181,9 +179,7 @@ const ExtendDurationModal = ({ ...@@ -181,9 +179,7 @@ const ExtendDurationModal = ({
} else { } else {
const message = response.data.message || ''; const message = response.data.message || '';
setPriceEstimation(null); setPriceEstimation(null);
setPriceError( setPriceError(t('价格计算失败') + (message ? `: ${message}` : ''));
t('价格计算失败') + (message ? `: ${message}` : ''),
);
} }
} catch (error) { } catch (error) {
if (costRequestIdRef.current !== requestId) { if (costRequestIdRef.current !== requestId) {
...@@ -192,9 +188,7 @@ const ExtendDurationModal = ({ ...@@ -192,9 +188,7 @@ const ExtendDurationModal = ({
const message = error?.response?.data?.message || error.message || ''; const message = error?.response?.data?.message || error.message || '';
setPriceEstimation(null); setPriceEstimation(null);
setPriceError( setPriceError(t('价格计算失败') + (message ? `: ${message}` : ''));
t('价格计算失败') + (message ? `: ${message}` : ''),
);
} finally { } finally {
if (costRequestIdRef.current === requestId) { if (costRequestIdRef.current === requestId) {
setCostLoading(false); setCostLoading(false);
...@@ -269,11 +263,8 @@ const ExtendDurationModal = ({ ...@@ -269,11 +263,8 @@ const ExtendDurationModal = ({
const newTotalTime = `${currentRemainingTime} + ${durationHours}${t('小时')}`; const newTotalTime = `${currentRemainingTime} + ${durationHours}${t('小时')}`;
const priceData = priceEstimation || {}; const priceData = priceEstimation || {};
const breakdown = const breakdown = priceData.price_breakdown || priceData.PriceBreakdown || {};
priceData.price_breakdown || priceData.PriceBreakdown || {}; const currencyLabel = (priceData.currency || priceData.Currency || 'USDC')
const currencyLabel = (
priceData.currency || priceData.Currency || 'USDC'
)
.toString() .toString()
.toUpperCase(); .toUpperCase();
...@@ -316,7 +307,10 @@ const ExtendDurationModal = ({ ...@@ -316,7 +307,10 @@ const ExtendDurationModal = ({
confirmLoading={loading} confirmLoading={loading}
okButtonProps={{ okButtonProps={{
disabled: disabled:
!deployment?.id || detailsLoading || !durationHours || durationHours < 1, !deployment?.id ||
detailsLoading ||
!durationHours ||
durationHours < 1,
}} }}
width={600} width={600}
className='extend-duration-modal' className='extend-duration-modal'
...@@ -357,9 +351,7 @@ const ExtendDurationModal = ({ ...@@ -357,9 +351,7 @@ const ExtendDurationModal = ({
<p> <p>
{t('延长容器时长将会产生额外费用,请确认您有足够的账户余额。')} {t('延长容器时长将会产生额外费用,请确认您有足够的账户余额。')}
</p> </p>
<p> <p>{t('延长操作一旦确认无法撤销,费用将立即扣除。')}</p>
{t('延长操作一旦确认无法撤销,费用将立即扣除。')}
</p>
</div> </div>
} }
/> />
...@@ -370,7 +362,9 @@ const ExtendDurationModal = ({ ...@@ -370,7 +362,9 @@ const ExtendDurationModal = ({
onValueChange={(values) => { onValueChange={(values) => {
if (values.duration_hours !== undefined) { if (values.duration_hours !== undefined) {
const numericValue = Number(values.duration_hours); const numericValue = Number(values.duration_hours);
setDurationHours(Number.isFinite(numericValue) ? numericValue : 0); setDurationHours(
Number.isFinite(numericValue) ? numericValue : 0,
);
} }
}} }}
> >
......
...@@ -378,7 +378,12 @@ const EditTokenModal = (props) => { ...@@ -378,7 +378,12 @@ const EditTokenModal = (props) => {
/> />
)} )}
</Col> </Col>
<Col span={24} style={{ display: values.group === 'auto' ? 'block' : 'none' }}> <Col
span={24}
style={{
display: values.group === 'auto' ? 'block' : 'none',
}}
>
<Form.Switch <Form.Switch
field='cross_group_retry' field='cross_group_retry'
label={t('跨分组重试')} label={t('跨分组重试')}
...@@ -561,7 +566,9 @@ const EditTokenModal = (props) => { ...@@ -561,7 +566,9 @@ const EditTokenModal = (props) => {
placeholder={t('允许的IP,一行一个,不填写则不限制')} placeholder={t('允许的IP,一行一个,不填写则不限制')}
autosize autosize
rows={1} rows={1}
extraText={t('请勿过度信任此功能,IP可能被伪造,请配合nginx和cdn等网关使用')} extraText={t(
'请勿过度信任此功能,IP可能被伪造,请配合nginx和cdn等网关使用',
)}
showClear showClear
style={{ width: '100%' }} style={{ width: '100%' }}
/> />
......
...@@ -20,6 +20,7 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -20,6 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
import React from 'react'; import React from 'react';
import { import {
Avatar, Avatar,
Button,
Space, Space,
Tag, Tag,
Tooltip, Tooltip,
...@@ -71,6 +72,34 @@ function formatRatio(ratio) { ...@@ -71,6 +72,34 @@ function formatRatio(ratio) {
return String(ratio); return String(ratio);
} }
function buildChannelAffinityTooltip(affinity, t) {
if (!affinity) {
return null;
}
const keySource = affinity.key_source || '-';
const keyPath = affinity.key_path || affinity.key_key || '-';
const keyHint = affinity.key_hint || '';
const keyFp = affinity.key_fp ? `#${affinity.key_fp}` : '';
const keyText = `${keySource}:${keyPath}${keyFp}`;
const lines = [
t('渠道亲和性'),
`${t('规则')}${affinity.rule_name || '-'}`,
`${t('分组')}${affinity.selected_group || '-'}`,
`${t('Key')}${keyText}`,
...(keyHint ? [`${t('Key 摘要')}${keyHint}`] : []),
];
return (
<div style={{ lineHeight: 1.6, display: 'flex', flexDirection: 'column' }}>
{lines.map((line, i) => (
<div key={i}>{line}</div>
))}
</div>
);
}
// Render functions // Render functions
function renderType(type, t) { function renderType(type, t) {
switch (type) { switch (type) {
...@@ -250,6 +279,7 @@ export const getLogsColumns = ({ ...@@ -250,6 +279,7 @@ export const getLogsColumns = ({
COLUMN_KEYS, COLUMN_KEYS,
copyText, copyText,
showUserInfoFunc, showUserInfoFunc,
openChannelAffinityUsageCacheModal,
isAdminUser, isAdminUser,
}) => { }) => {
return [ return [
...@@ -532,42 +562,39 @@ export const getLogsColumns = ({ ...@@ -532,42 +562,39 @@ export const getLogsColumns = ({
return isAdminUser ? ( return isAdminUser ? (
<Space> <Space>
<div>{content}</div> <div>{content}</div>
{affinity ? ( {affinity ? (
<Tooltip <Tooltip
content={ content={
<div style={{ lineHeight: 1.6 }}> <div>
<Typography.Text strong>{t('渠道亲和性')}</Typography.Text> {buildChannelAffinityTooltip(affinity, t)}
<div> <div style={{ marginTop: 6 }}>
<Typography.Text type='secondary'> <Button
{t('规则')}{affinity.rule_name || '-'} theme='borderless'
</Typography.Text> size='small'
</div> onClick={(e) => {
<div> e.stopPropagation();
<Typography.Text type='secondary'> openChannelAffinityUsageCacheModal?.(affinity);
{t('分组')}{affinity.selected_group || '-'} }}
</Typography.Text> >
</div> {t('查看详情')}
<div> </Button>
<Typography.Text type='secondary'>
{t('Key')}
{(affinity.key_source || '-') +
':' +
(affinity.key_path || affinity.key_key || '-') +
(affinity.key_fp ? `#${affinity.key_fp}` : '')}
</Typography.Text>
</div> </div>
</div> </div>
} }
> >
<span> <span>
<Tag className='channel-affinity-tag' color='cyan' shape='circle'> <Tag
<span className='channel-affinity-tag-content'> className='channel-affinity-tag'
<IconStarStroked style={{ fontSize: 13 }} /> color='cyan'
{t('优选')} shape='circle'
</span> >
</Tag> <span className='channel-affinity-tag-content'>
</span> <IconStarStroked style={{ fontSize: 13 }} />
</Tooltip> {t('优选')}
</span>
</Tag>
</span>
</Tooltip>
) : null} ) : null}
</Space> </Space>
) : ( ) : (
......
...@@ -40,6 +40,7 @@ const LogsTable = (logsData) => { ...@@ -40,6 +40,7 @@ const LogsTable = (logsData) => {
handlePageSizeChange, handlePageSizeChange,
copyText, copyText,
showUserInfoFunc, showUserInfoFunc,
openChannelAffinityUsageCacheModal,
hasExpandableRows, hasExpandableRows,
isAdminUser, isAdminUser,
t, t,
...@@ -53,9 +54,17 @@ const LogsTable = (logsData) => { ...@@ -53,9 +54,17 @@ const LogsTable = (logsData) => {
COLUMN_KEYS, COLUMN_KEYS,
copyText, copyText,
showUserInfoFunc, showUserInfoFunc,
openChannelAffinityUsageCacheModal,
isAdminUser, isAdminUser,
}); });
}, [t, COLUMN_KEYS, copyText, showUserInfoFunc, isAdminUser]); }, [
t,
COLUMN_KEYS,
copyText,
showUserInfoFunc,
openChannelAffinityUsageCacheModal,
isAdminUser,
]);
// Filter columns based on visibility settings // Filter columns based on visibility settings
const getVisibleColumns = () => { const getVisibleColumns = () => {
......
...@@ -24,6 +24,7 @@ import LogsActions from './UsageLogsActions'; ...@@ -24,6 +24,7 @@ import LogsActions from './UsageLogsActions';
import LogsFilters from './UsageLogsFilters'; import LogsFilters from './UsageLogsFilters';
import ColumnSelectorModal from './modals/ColumnSelectorModal'; import ColumnSelectorModal from './modals/ColumnSelectorModal';
import UserInfoModal from './modals/UserInfoModal'; import UserInfoModal from './modals/UserInfoModal';
import ChannelAffinityUsageCacheModal from './modals/ChannelAffinityUsageCacheModal';
import { useLogsData } from '../../../hooks/usage-logs/useUsageLogsData'; import { useLogsData } from '../../../hooks/usage-logs/useUsageLogsData';
import { useIsMobile } from '../../../hooks/common/useIsMobile'; import { useIsMobile } from '../../../hooks/common/useIsMobile';
import { createCardProPagination } from '../../../helpers/utils'; import { createCardProPagination } from '../../../helpers/utils';
...@@ -37,6 +38,7 @@ const LogsPage = () => { ...@@ -37,6 +38,7 @@ const LogsPage = () => {
{/* Modals */} {/* Modals */}
<ColumnSelectorModal {...logsData} /> <ColumnSelectorModal {...logsData} />
<UserInfoModal {...logsData} /> <UserInfoModal {...logsData} />
<ChannelAffinityUsageCacheModal {...logsData} />
{/* Main Content */} {/* Main Content */}
<CardPro <CardPro
......
/*
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 <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Modal, Descriptions, Spin, Typography } from '@douyinfe/semi-ui';
import { API, showError, timestamp2string } from '../../../../helpers';
const { Text } = Typography;
function formatRate(hit, total) {
if (!total || total <= 0) return '-';
const r = (Number(hit || 0) / Number(total || 0)) * 100;
if (!Number.isFinite(r)) return '-';
return `${r.toFixed(2)}%`;
}
function formatTokenRate(n, d) {
const nn = Number(n || 0);
const dd = Number(d || 0);
if (!dd || dd <= 0) return '-';
const r = (nn / dd) * 100;
if (!Number.isFinite(r)) return '-';
return `${r.toFixed(2)}%`;
}
const ChannelAffinityUsageCacheModal = ({
t,
showChannelAffinityUsageCacheModal,
setShowChannelAffinityUsageCacheModal,
channelAffinityUsageCacheTarget,
}) => {
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState(null);
const requestSeqRef = useRef(0);
const params = useMemo(() => {
const x = channelAffinityUsageCacheTarget || {};
return {
rule_name: (x.rule_name || '').trim(),
using_group: (x.using_group || '').trim(),
key_hint: (x.key_hint || '').trim(),
key_fp: (x.key_fp || '').trim(),
};
}, [channelAffinityUsageCacheTarget]);
useEffect(() => {
if (!showChannelAffinityUsageCacheModal) {
requestSeqRef.current += 1; // invalidate inflight request
setLoading(false);
setStats(null);
return;
}
if (!params.rule_name || !params.key_fp) {
setLoading(false);
setStats(null);
return;
}
const reqSeq = (requestSeqRef.current += 1);
setStats(null);
setLoading(true);
(async () => {
try {
const res = await API.get('/api/log/channel_affinity_usage_cache', {
params,
disableDuplicate: true,
});
if (reqSeq !== requestSeqRef.current) return;
const { success, message, data } = res.data || {};
if (!success) {
setStats(null);
showError(t(message || '请求失败'));
return;
}
setStats(data || {});
} catch (e) {
if (reqSeq !== requestSeqRef.current) return;
setStats(null);
showError(t('请求失败'));
} finally {
if (reqSeq !== requestSeqRef.current) return;
setLoading(false);
}
})();
}, [
showChannelAffinityUsageCacheModal,
params.rule_name,
params.using_group,
params.key_hint,
params.key_fp,
t,
]);
const rows = useMemo(() => {
const s = stats || {};
const hit = Number(s.hit || 0);
const total = Number(s.total || 0);
const windowSeconds = Number(s.window_seconds || 0);
const lastSeenAt = Number(s.last_seen_at || 0);
const promptTokens = Number(s.prompt_tokens || 0);
const completionTokens = Number(s.completion_tokens || 0);
const totalTokens = Number(s.total_tokens || 0);
const cachedTokens = Number(s.cached_tokens || 0);
const promptCacheHitTokens = Number(s.prompt_cache_hit_tokens || 0);
return [
{ key: t('规则'), value: s.rule_name || params.rule_name || '-' },
{ key: t('分组'), value: s.using_group || params.using_group || '-' },
{
key: t('Key 摘要'),
value: params.key_hint || '-',
},
{
key: t('Key 指纹'),
value: s.key_fp || params.key_fp || '-',
},
{ key: t('TTL(秒)'), value: windowSeconds > 0 ? windowSeconds : '-' },
{
key: t('命中率'),
value: `${hit}/${total} (${formatRate(hit, total)})`,
},
{
key: t('Prompt tokens'),
value: promptTokens,
},
{
key: t('Cached tokens'),
value: `${cachedTokens} (${formatTokenRate(cachedTokens, promptTokens)})`,
},
{
key: t('Prompt cache hit tokens'),
value: promptCacheHitTokens,
},
{
key: t('Completion tokens'),
value: completionTokens,
},
{
key: t('Total tokens'),
value: totalTokens,
},
{
key: t('最近一次'),
value: lastSeenAt > 0 ? timestamp2string(lastSeenAt) : '-',
},
];
}, [stats, params, t]);
return (
<Modal
title={t('渠道亲和性:上游缓存命中')}
visible={showChannelAffinityUsageCacheModal}
onCancel={() => setShowChannelAffinityUsageCacheModal(false)}
footer={null}
centered
closable
maskClosable
width={640}
>
<div style={{ padding: 16 }}>
<div style={{ marginBottom: 12 }}>
<Text type='tertiary' size='small'>
{t(
'命中判定:usage 中存在 cached tokens(例如 cached_tokens/prompt_cache_hit_tokens)即视为命中。',
)}
</Text>
</div>
<Spin spinning={loading} tip={t('加载中...')}>
{stats ? (
<Descriptions data={rows} />
) : (
<div style={{ padding: '24px 0' }}>
<Text type='tertiary' size='small'>
{loading ? t('加载中...') : t('暂无数据')}
</Text>
</div>
)}
</Spin>
</div>
</Modal>
);
};
export default ChannelAffinityUsageCacheModal;
...@@ -87,7 +87,12 @@ const RechargeCard = ({ ...@@ -87,7 +87,12 @@ const RechargeCard = ({
const onlineFormApiRef = useRef(null); const onlineFormApiRef = useRef(null);
const redeemFormApiRef = useRef(null); const redeemFormApiRef = useRef(null);
const showAmountSkeleton = useMinimumLoadingTime(amountLoading); const showAmountSkeleton = useMinimumLoadingTime(amountLoading);
console.log(' enabled screem ?', enableCreemTopUp, ' products ?', creemProducts); console.log(
' enabled screem ?',
enableCreemTopUp,
' products ?',
creemProducts,
);
return ( return (
<Card className='!rounded-2xl shadow-sm border-0'> <Card className='!rounded-2xl shadow-sm border-0'>
{/* 卡片头部 */} {/* 卡片头部 */}
...@@ -503,7 +508,8 @@ const RechargeCard = ({ ...@@ -503,7 +508,8 @@ const RechargeCard = ({
{t('充值额度')}: {product.quota} {t('充值额度')}: {product.quota}
</div> </div>
<div className='text-lg font-semibold text-blue-600'> <div className='text-lg font-semibold text-blue-600'>
{product.currency === 'EUR' ? '€' : '$'}{product.price} {product.currency === 'EUR' ? '€' : '$'}
{product.price}
</div> </div>
</Card> </Card>
))} ))}
......
...@@ -651,7 +651,8 @@ const TopUp = () => { ...@@ -651,7 +651,8 @@ const TopUp = () => {
{t('产品名称')}{selectedCreemProduct.name} {t('产品名称')}{selectedCreemProduct.name}
</p> </p>
<p> <p>
{t('价格')}{selectedCreemProduct.currency === 'EUR' ? '€' : '$'}{selectedCreemProduct.price} {t('价格')}{selectedCreemProduct.currency === 'EUR' ? '€' : '$'}
{selectedCreemProduct.price}
</p> </p>
<p> <p>
{t('充值额度')}{selectedCreemProduct.quota} {t('充值额度')}{selectedCreemProduct.quota}
......
...@@ -236,9 +236,7 @@ async function prepareOAuthState(options = {}) { ...@@ -236,9 +236,7 @@ async function prepareOAuthState(options = {}) {
if (shouldLogout) { if (shouldLogout) {
try { try {
await API.get('/api/user/logout', { skipErrorHandler: true }); await API.get('/api/user/logout', { skipErrorHandler: true });
} catch (err) { } catch (err) {}
}
localStorage.removeItem('user'); localStorage.removeItem('user');
updateAPI(); updateAPI();
} }
......
...@@ -261,7 +261,7 @@ export const processRawData = ( ...@@ -261,7 +261,7 @@ export const processRawData = (
}; };
// 检查数据是否跨年 // 检查数据是否跨年
const showYear = isDataCrossYear(data.map(item => item.created_at)); const showYear = isDataCrossYear(data.map((item) => item.created_at));
data.forEach((item) => { data.forEach((item) => {
result.uniqueModels.add(item.model_name); result.uniqueModels.add(item.model_name);
...@@ -269,7 +269,11 @@ export const processRawData = ( ...@@ -269,7 +269,11 @@ export const processRawData = (
result.totalQuota += item.quota; result.totalQuota += item.quota;
result.totalTimes += item.count; result.totalTimes += item.count;
const timeKey = timestamp2string1(item.created_at, dataExportDefaultTime, showYear); const timeKey = timestamp2string1(
item.created_at,
dataExportDefaultTime,
showYear,
);
if (!result.timePoints.includes(timeKey)) { if (!result.timePoints.includes(timeKey)) {
result.timePoints.push(timeKey); result.timePoints.push(timeKey);
} }
...@@ -328,10 +332,14 @@ export const aggregateDataByTimeAndModel = (data, dataExportDefaultTime) => { ...@@ -328,10 +332,14 @@ export const aggregateDataByTimeAndModel = (data, dataExportDefaultTime) => {
const aggregatedData = new Map(); const aggregatedData = new Map();
// 检查数据是否跨年 // 检查数据是否跨年
const showYear = isDataCrossYear(data.map(item => item.created_at)); const showYear = isDataCrossYear(data.map((item) => item.created_at));
data.forEach((item) => { data.forEach((item) => {
const timeKey = timestamp2string1(item.created_at, dataExportDefaultTime, showYear); const timeKey = timestamp2string1(
item.created_at,
dataExportDefaultTime,
showYear,
);
const modelKey = item.model_name; const modelKey = item.model_name;
const key = `${timeKey}-${modelKey}`; const key = `${timeKey}-${modelKey}`;
...@@ -372,7 +380,7 @@ export const generateChartTimePoints = ( ...@@ -372,7 +380,7 @@ export const generateChartTimePoints = (
); );
const showYear = isDataCrossYear(generatedTimestamps); const showYear = isDataCrossYear(generatedTimestamps);
chartTimePoints = generatedTimestamps.map(ts => chartTimePoints = generatedTimestamps.map((ts) =>
timestamp2string1(ts, dataExportDefaultTime, showYear), timestamp2string1(ts, dataExportDefaultTime, showYear),
); );
} }
......
/*
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 <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
export function parseHttpStatusCodeRules(input) { export function parseHttpStatusCodeRules(input) {
const raw = (input ?? '').toString().trim(); const raw = (input ?? '').toString().trim();
if (raw.length === 0) { if (raw.length === 0) {
...@@ -35,7 +53,9 @@ export function parseHttpStatusCodeRules(input) { ...@@ -35,7 +53,9 @@ export function parseHttpStatusCodeRules(input) {
} }
const merged = mergeRanges(ranges); const merged = mergeRanges(ranges);
const tokens = merged.map((r) => (r.start === r.end ? `${r.start}` : `${r.start}-${r.end}`)); const tokens = merged.map((r) =>
r.start === r.end ? `${r.start}` : `${r.start}-${r.end}`,
);
const normalized = tokens.join(','); const normalized = tokens.join(',');
return { return {
...@@ -78,7 +98,9 @@ function isNumber(s) { ...@@ -78,7 +98,9 @@ function isNumber(s) {
function mergeRanges(ranges) { function mergeRanges(ranges) {
if (!Array.isArray(ranges) || ranges.length === 0) return []; if (!Array.isArray(ranges) || ranges.length === 0) return [];
const sorted = [...ranges].sort((a, b) => (a.start !== b.start ? a.start - b.start : a.end - b.end)); const sorted = [...ranges].sort((a, b) =>
a.start !== b.start ? a.start - b.start : a.end - b.end,
);
const merged = [sorted[0]]; const merged = [sorted[0]];
for (let i = 1; i < sorted.length; i += 1) { for (let i = 1; i < sorted.length; i += 1) {
......
...@@ -217,7 +217,11 @@ export function timestamp2string(timestamp) { ...@@ -217,7 +217,11 @@ export function timestamp2string(timestamp) {
); );
} }
export function timestamp2string1(timestamp, dataExportDefaultTime = 'hour', showYear = false) { export function timestamp2string1(
timestamp,
dataExportDefaultTime = 'hour',
showYear = false,
) {
let date = new Date(timestamp * 1000); let date = new Date(timestamp * 1000);
let year = date.getFullYear(); let year = date.getFullYear();
let month = (date.getMonth() + 1).toString(); let month = (date.getMonth() + 1).toString();
...@@ -248,7 +252,9 @@ export function timestamp2string1(timestamp, dataExportDefaultTime = 'hour', sho ...@@ -248,7 +252,9 @@ export function timestamp2string1(timestamp, dataExportDefaultTime = 'hour', sho
nextDay = '0' + nextDay; nextDay = '0' + nextDay;
} }
// 周视图结束日期也仅在跨年时显示年份 // 周视图结束日期也仅在跨年时显示年份
let nextStr = showYear ? nextWeekYear + '-' + nextMonth + '-' + nextDay : nextMonth + '-' + nextDay; let nextStr = showYear
? nextWeekYear + '-' + nextMonth + '-' + nextDay
: nextMonth + '-' + nextDay;
str += ' - ' + nextStr; str += ' - ' + nextStr;
} }
return str; return str;
...@@ -257,7 +263,9 @@ export function timestamp2string1(timestamp, dataExportDefaultTime = 'hour', sho ...@@ -257,7 +263,9 @@ export function timestamp2string1(timestamp, dataExportDefaultTime = 'hour', sho
// 检查时间戳数组是否跨年 // 检查时间戳数组是否跨年
export function isDataCrossYear(timestamps) { export function isDataCrossYear(timestamps) {
if (!timestamps || timestamps.length === 0) return false; if (!timestamps || timestamps.length === 0) return false;
const years = new Set(timestamps.map(ts => new Date(ts * 1000).getFullYear())); const years = new Set(
timestamps.map((ts) => new Date(ts * 1000).getFullYear()),
);
return years.size > 1; return years.size > 1;
} }
......
...@@ -55,13 +55,20 @@ export const useModelDeploymentSettings = () => { ...@@ -55,13 +55,20 @@ export const useModelDeploymentSettings = () => {
const isIoNetEnabled = settings['model_deployment.ionet.enabled']; const isIoNetEnabled = settings['model_deployment.ionet.enabled'];
const buildConnectionError = (rawMessage, fallbackMessage = 'Connection failed') => { const buildConnectionError = (
rawMessage,
fallbackMessage = 'Connection failed',
) => {
const message = (rawMessage || fallbackMessage).trim(); const message = (rawMessage || fallbackMessage).trim();
const normalized = message.toLowerCase(); const normalized = message.toLowerCase();
if (normalized.includes('expired') || normalized.includes('expire')) { if (normalized.includes('expired') || normalized.includes('expire')) {
return { type: 'expired', message }; return { type: 'expired', message };
} }
if (normalized.includes('invalid') || normalized.includes('unauthorized') || normalized.includes('api key')) { if (
normalized.includes('invalid') ||
normalized.includes('unauthorized') ||
normalized.includes('api key')
) {
return { type: 'invalid', message }; return { type: 'invalid', message };
} }
if (normalized.includes('network') || normalized.includes('timeout')) { if (normalized.includes('network') || normalized.includes('timeout')) {
...@@ -85,7 +92,11 @@ export const useModelDeploymentSettings = () => { ...@@ -85,7 +92,11 @@ export const useModelDeploymentSettings = () => {
} }
const message = response?.data?.message || 'Connection failed'; const message = response?.data?.message || 'Connection failed';
setConnectionState({ loading: false, ok: false, error: buildConnectionError(message) }); setConnectionState({
loading: false,
ok: false,
error: buildConnectionError(message),
});
} catch (error) { } catch (error) {
if (error?.code === 'ERR_NETWORK') { if (error?.code === 'ERR_NETWORK') {
setConnectionState({ setConnectionState({
...@@ -95,8 +106,13 @@ export const useModelDeploymentSettings = () => { ...@@ -95,8 +106,13 @@ export const useModelDeploymentSettings = () => {
}); });
return; return;
} }
const rawMessage = error?.response?.data?.message || error?.message || 'Unknown error'; const rawMessage =
setConnectionState({ loading: false, ok: false, error: buildConnectionError(rawMessage, 'Connection failed') }); error?.response?.data?.message || error?.message || 'Unknown error';
setConnectionState({
loading: false,
ok: false,
error: buildConnectionError(rawMessage, 'Connection failed'),
});
} }
}, []); }, []);
......
...@@ -231,7 +231,10 @@ export const useApiRequest = ( ...@@ -231,7 +231,10 @@ export const useApiRequest = (
if (data.choices?.[0]) { if (data.choices?.[0]) {
const choice = data.choices[0]; const choice = data.choices[0];
let content = choice.message?.content || ''; let content = choice.message?.content || '';
let reasoningContent = choice.message?.reasoning_content || choice.message?.reasoning || ''; let reasoningContent =
choice.message?.reasoning_content ||
choice.message?.reasoning ||
'';
const processed = processThinkTags(content, reasoningContent); const processed = processThinkTags(content, reasoningContent);
...@@ -318,8 +321,8 @@ export const useApiRequest = ( ...@@ -318,8 +321,8 @@ export const useApiRequest = (
isStreamComplete = true; // 标记流正常完成 isStreamComplete = true; // 标记流正常完成
source.close(); source.close();
sseSourceRef.current = null; sseSourceRef.current = null;
setDebugData((prev) => ({ setDebugData((prev) => ({
...prev, ...prev,
response: responseData, response: responseData,
sseMessages: [...(prev.sseMessages || []), '[DONE]'], // 添加 DONE 标记 sseMessages: [...(prev.sseMessages || []), '[DONE]'], // 添加 DONE 标记
isStreaming: false, isStreaming: false,
......
...@@ -36,18 +36,23 @@ import { processIncompleteThinkTags } from '../../helpers'; ...@@ -36,18 +36,23 @@ import { processIncompleteThinkTags } from '../../helpers';
export const usePlaygroundState = () => { export const usePlaygroundState = () => {
const { t } = useTranslation(); const { t } = useTranslation();
// 使用惰性初始化,确保只在组件首次挂载时加载配置和消息 // 使用惰性初始化,确保只在组件首次挂载时加载配置和消息
const [savedConfig] = useState(() => loadConfig()); const [savedConfig] = useState(() => loadConfig());
const [initialMessages] = useState(() => { const [initialMessages] = useState(() => {
const loaded = loadMessages(); const loaded = loadMessages();
// 检查是否是旧的中文默认消息,如果是则清除 // 检查是否是旧的中文默认消息,如果是则清除
if (loaded && loaded.length === 2 && loaded[0].id === '2' && loaded[1].id === '3') { if (
const hasOldChinese = loaded &&
loaded[0].content === '你好' || loaded.length === 2 &&
loaded[0].id === '2' &&
loaded[1].id === '3'
) {
const hasOldChinese =
loaded[0].content === '你好' ||
loaded[1].content === '你好,请问有什么可以帮助您的吗?' || loaded[1].content === '你好,请问有什么可以帮助您的吗?' ||
loaded[1].content === '你好!很高兴见到你。有什么我可以帮助你的吗?'; loaded[1].content === '你好!很高兴见到你。有什么我可以帮助你的吗?';
if (hasOldChinese) { if (hasOldChinese) {
// 清除旧的默认消息 // 清除旧的默认消息
localStorage.removeItem('playground_messages'); localStorage.removeItem('playground_messages');
...@@ -81,8 +86,10 @@ export const usePlaygroundState = () => { ...@@ -81,8 +86,10 @@ export const usePlaygroundState = () => {
const [status, setStatus] = useState({}); const [status, setStatus] = useState({});
// 消息相关状态 - 使用加载的消息或默认消息初始化 // 消息相关状态 - 使用加载的消息或默认消息初始化
const [message, setMessage] = useState(() => initialMessages || getDefaultMessages(t)); const [message, setMessage] = useState(
() => initialMessages || getDefaultMessages(t),
);
// 当语言改变时,如果是默认消息则更新 // 当语言改变时,如果是默认消息则更新
useEffect(() => { useEffect(() => {
// 只在没有保存的消息时才更新默认消息 // 只在没有保存的消息时才更新默认消息
......
...@@ -112,6 +112,14 @@ export const useLogsData = () => { ...@@ -112,6 +112,14 @@ export const useLogsData = () => {
const [showUserInfo, setShowUserInfoModal] = useState(false); const [showUserInfo, setShowUserInfoModal] = useState(false);
const [userInfoData, setUserInfoData] = useState(null); const [userInfoData, setUserInfoData] = useState(null);
// Channel affinity usage cache stats modal state (admin only)
const [
showChannelAffinityUsageCacheModal,
setShowChannelAffinityUsageCacheModal,
] = useState(false);
const [channelAffinityUsageCacheTarget, setChannelAffinityUsageCacheTarget] =
useState(null);
// Load saved column preferences from localStorage // Load saved column preferences from localStorage
useEffect(() => { useEffect(() => {
const savedColumns = localStorage.getItem(STORAGE_KEY); const savedColumns = localStorage.getItem(STORAGE_KEY);
...@@ -304,6 +312,17 @@ export const useLogsData = () => { ...@@ -304,6 +312,17 @@ export const useLogsData = () => {
} }
}; };
const openChannelAffinityUsageCacheModal = (affinity) => {
const a = affinity || {};
setChannelAffinityUsageCacheTarget({
rule_name: a.rule_name || a.reason || '',
using_group: a.using_group || '',
key_hint: a.key_hint || '',
key_fp: a.key_fp || '',
});
setShowChannelAffinityUsageCacheModal(true);
};
// Format logs data // Format logs data
const setLogsFormat = (logs) => { const setLogsFormat = (logs) => {
const requestConversionDisplayValue = (conversionChain) => { const requestConversionDisplayValue = (conversionChain) => {
...@@ -372,9 +391,13 @@ export const useLogsData = () => { ...@@ -372,9 +391,13 @@ export const useLogsData = () => {
other.cache_ratio || 1.0, other.cache_ratio || 1.0,
other.cache_creation_ratio || 1.0, other.cache_creation_ratio || 1.0,
other.cache_creation_tokens_5m || 0, other.cache_creation_tokens_5m || 0,
other.cache_creation_ratio_5m || other.cache_creation_ratio || 1.0, other.cache_creation_ratio_5m ||
other.cache_creation_ratio ||
1.0,
other.cache_creation_tokens_1h || 0, other.cache_creation_tokens_1h || 0,
other.cache_creation_ratio_1h || other.cache_creation_ratio || 1.0, other.cache_creation_ratio_1h ||
other.cache_creation_ratio ||
1.0,
) )
: renderLogContent( : renderLogContent(
other?.model_ratio, other?.model_ratio,
...@@ -524,8 +547,8 @@ export const useLogsData = () => { ...@@ -524,8 +547,8 @@ export const useLogsData = () => {
localCountMode = t('上游返回'); localCountMode = t('上游返回');
} }
expandDataLocal.push({ expandDataLocal.push({
key: t('计费模式'), key: t('计费模式'),
value: localCountMode, value: localCountMode,
}); });
} }
expandDatesLocal[logs[i].key] = expandDataLocal; expandDatesLocal[logs[i].key] = expandDataLocal;
...@@ -680,6 +703,12 @@ export const useLogsData = () => { ...@@ -680,6 +703,12 @@ export const useLogsData = () => {
userInfoData, userInfoData,
showUserInfoFunc, showUserInfoFunc,
// Channel affinity usage cache stats modal
showChannelAffinityUsageCacheModal,
setShowChannelAffinityUsageCacheModal,
channelAffinityUsageCacheTarget,
openChannelAffinityUsageCacheModal,
// Functions // Functions
loadLogs, loadLogs,
handlePageChange, handlePageChange,
......
...@@ -438,14 +438,17 @@ const Playground = () => { ...@@ -438,14 +438,17 @@ const Playground = () => {
}, [setMessage, saveMessagesImmediately]); }, [setMessage, saveMessagesImmediately]);
// 处理粘贴图片 // 处理粘贴图片
const handlePasteImage = useCallback((base64Data) => { const handlePasteImage = useCallback(
if (!inputs.imageEnabled) { (base64Data) => {
return; if (!inputs.imageEnabled) {
} return;
// 添加图片到 imageUrls 数组 }
const newUrls = [...(inputs.imageUrls || []), base64Data]; // 添加图片到 imageUrls 数组
handleInputChange('imageUrls', newUrls); const newUrls = [...(inputs.imageUrls || []), base64Data];
}, [inputs.imageEnabled, inputs.imageUrls, handleInputChange]); handleInputChange('imageUrls', newUrls);
},
[inputs.imageEnabled, inputs.imageUrls, handleInputChange],
);
// Playground Context 值 // Playground Context 值
const playgroundContextValue = { const playgroundContextValue = {
...@@ -457,10 +460,10 @@ const Playground = () => { ...@@ -457,10 +460,10 @@ const Playground = () => {
return ( return (
<PlaygroundProvider value={playgroundContextValue}> <PlaygroundProvider value={playgroundContextValue}>
<div className='h-full'> <div className='h-full'>
<Layout className='h-full bg-transparent flex flex-col md:flex-row'> <Layout className='h-full bg-transparent flex flex-col md:flex-row'>
{(showSettings || !isMobile) && ( {(showSettings || !isMobile) && (
<Layout.Sider <Layout.Sider
className={` className={`
bg-transparent border-r-0 flex-shrink-0 overflow-auto mt-[60px] bg-transparent border-r-0 flex-shrink-0 overflow-auto mt-[60px]
${ ${
isMobile isMobile
...@@ -468,93 +471,93 @@ const Playground = () => { ...@@ -468,93 +471,93 @@ const Playground = () => {
: 'relative z-[1] w-80 h-[calc(100vh-66px)]' : 'relative z-[1] w-80 h-[calc(100vh-66px)]'
} }
`} `}
width={isMobile ? '100%' : 320} width={isMobile ? '100%' : 320}
> >
<OptimizedSettingsPanel <OptimizedSettingsPanel
inputs={inputs}
parameterEnabled={parameterEnabled}
models={models}
groups={groups}
styleState={styleState}
showSettings={showSettings}
showDebugPanel={showDebugPanel}
customRequestMode={customRequestMode}
customRequestBody={customRequestBody}
onInputChange={handleInputChange}
onParameterToggle={handleParameterToggle}
onCloseSettings={() => setShowSettings(false)}
onConfigImport={handleConfigImport}
onConfigReset={handleConfigReset}
onCustomRequestModeChange={setCustomRequestMode}
onCustomRequestBodyChange={setCustomRequestBody}
previewPayload={previewPayload}
messages={message}
/>
</Layout.Sider>
)}
<Layout.Content className='relative flex-1 overflow-hidden'>
<div className='overflow-hidden flex flex-col lg:flex-row h-[calc(100vh-66px)] mt-[60px]'>
<div className='flex-1 flex flex-col'>
<ChatArea
chatRef={chatRef}
message={message}
inputs={inputs} inputs={inputs}
parameterEnabled={parameterEnabled}
models={models}
groups={groups}
styleState={styleState} styleState={styleState}
showSettings={showSettings}
showDebugPanel={showDebugPanel} showDebugPanel={showDebugPanel}
roleInfo={roleInfo} customRequestMode={customRequestMode}
onMessageSend={onMessageSend} customRequestBody={customRequestBody}
onMessageCopy={messageActions.handleMessageCopy} onInputChange={handleInputChange}
onMessageReset={messageActions.handleMessageReset} onParameterToggle={handleParameterToggle}
onMessageDelete={messageActions.handleMessageDelete} onCloseSettings={() => setShowSettings(false)}
onStopGenerator={onStopGenerator} onConfigImport={handleConfigImport}
onClearMessages={handleClearMessages} onConfigReset={handleConfigReset}
onToggleDebugPanel={() => setShowDebugPanel(!showDebugPanel)} onCustomRequestModeChange={setCustomRequestMode}
renderCustomChatContent={renderCustomChatContent} onCustomRequestBodyChange={setCustomRequestBody}
renderChatBoxAction={renderChatBoxAction} previewPayload={previewPayload}
messages={message}
/> />
</Layout.Sider>
)}
<Layout.Content className='relative flex-1 overflow-hidden'>
<div className='overflow-hidden flex flex-col lg:flex-row h-[calc(100vh-66px)] mt-[60px]'>
<div className='flex-1 flex flex-col'>
<ChatArea
chatRef={chatRef}
message={message}
inputs={inputs}
styleState={styleState}
showDebugPanel={showDebugPanel}
roleInfo={roleInfo}
onMessageSend={onMessageSend}
onMessageCopy={messageActions.handleMessageCopy}
onMessageReset={messageActions.handleMessageReset}
onMessageDelete={messageActions.handleMessageDelete}
onStopGenerator={onStopGenerator}
onClearMessages={handleClearMessages}
onToggleDebugPanel={() => setShowDebugPanel(!showDebugPanel)}
renderCustomChatContent={renderCustomChatContent}
renderChatBoxAction={renderChatBoxAction}
/>
</div>
{/* 调试面板 - 桌面端 */}
{showDebugPanel && !isMobile && (
<div className='w-96 flex-shrink-0 h-full'>
<OptimizedDebugPanel
debugData={debugData}
activeDebugTab={activeDebugTab}
onActiveDebugTabChange={setActiveDebugTab}
styleState={styleState}
customRequestMode={customRequestMode}
/>
</div>
)}
</div> </div>
{/* 调试面板 - 桌面端 */} {/* 调试面板 - 移动端覆盖层 */}
{showDebugPanel && !isMobile && ( {showDebugPanel && isMobile && (
<div className='w-96 flex-shrink-0 h-full'> <div className='fixed top-0 left-0 right-0 bottom-0 z-[1000] bg-white overflow-auto shadow-lg'>
<OptimizedDebugPanel <OptimizedDebugPanel
debugData={debugData} debugData={debugData}
activeDebugTab={activeDebugTab} activeDebugTab={activeDebugTab}
onActiveDebugTabChange={setActiveDebugTab} onActiveDebugTabChange={setActiveDebugTab}
styleState={styleState} styleState={styleState}
showDebugPanel={showDebugPanel}
onCloseDebugPanel={() => setShowDebugPanel(false)}
customRequestMode={customRequestMode} customRequestMode={customRequestMode}
/> />
</div> </div>
)} )}
</div>
{/* 调试面板 - 移动端覆盖层 */}
{showDebugPanel && isMobile && (
<div className='fixed top-0 left-0 right-0 bottom-0 z-[1000] bg-white overflow-auto shadow-lg'>
<OptimizedDebugPanel
debugData={debugData}
activeDebugTab={activeDebugTab}
onActiveDebugTabChange={setActiveDebugTab}
styleState={styleState}
showDebugPanel={showDebugPanel}
onCloseDebugPanel={() => setShowDebugPanel(false)}
customRequestMode={customRequestMode}
/>
</div>
)}
{/* 浮动按钮 */} {/* 浮动按钮 */}
<FloatingButtons <FloatingButtons
styleState={styleState} styleState={styleState}
showSettings={showSettings} showSettings={showSettings}
showDebugPanel={showDebugPanel} showDebugPanel={showDebugPanel}
onToggleSettings={() => setShowSettings(!showSettings)} onToggleSettings={() => setShowSettings(!showSettings)}
onToggleDebugPanel={() => setShowDebugPanel(!showDebugPanel)} onToggleDebugPanel={() => setShowDebugPanel(!showDebugPanel)}
/> />
</Layout.Content> </Layout.Content>
</Layout> </Layout>
</div> </div>
</PlaygroundProvider> </PlaygroundProvider>
); );
}; };
......
...@@ -26,12 +26,12 @@ const PrivacyPolicy = () => { ...@@ -26,12 +26,12 @@ const PrivacyPolicy = () => {
return ( return (
<DocumentRenderer <DocumentRenderer
apiEndpoint="/api/privacy-policy" apiEndpoint='/api/privacy-policy'
title={t('隐私政策')} title={t('隐私政策')}
cacheKey="privacy_policy" cacheKey='privacy_policy'
emptyMessage={t('加载隐私政策内容失败...')} emptyMessage={t('加载隐私政策内容失败...')}
/> />
); );
}; };
export default PrivacyPolicy; export default PrivacyPolicy;
\ No newline at end of file
...@@ -199,9 +199,9 @@ export default function SettingGlobalModel(props) { ...@@ -199,9 +199,9 @@ export default function SettingGlobalModel(props) {
'global.pass_through_request_enabled': value, 'global.pass_through_request_enabled': value,
}) })
} }
extraText={ extraText={t(
t('开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启') '开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启',
} )}
/> />
</Col> </Col>
</Row> </Row>
...@@ -210,11 +210,7 @@ export default function SettingGlobalModel(props) { ...@@ -210,11 +210,7 @@ export default function SettingGlobalModel(props) {
<Form.TextArea <Form.TextArea
label={t('禁用思考处理的模型列表')} label={t('禁用思考处理的模型列表')}
field={'global.thinking_model_blacklist'} field={'global.thinking_model_blacklist'}
placeholder={ placeholder={t('例如:') + '\n' + thinkingExample}
t('例如:') +
'\n' +
thinkingExample
}
rows={4} rows={4}
rules={[ rules={[
{ {
...@@ -270,12 +266,12 @@ export default function SettingGlobalModel(props) { ...@@ -270,12 +266,12 @@ export default function SettingGlobalModel(props) {
<Row style={{ marginTop: 10 }}> <Row style={{ marginTop: 10 }}>
<Col span={24}> <Col span={24}>
<Form.TextArea <Form.TextArea
label={t('参数配置')} label={t('参数配置')}
field={chatCompletionsToResponsesPolicyKey} field={chatCompletionsToResponsesPolicyKey}
placeholder={ placeholder={
t('例如(指定渠道):') + t('例如(指定渠道):') +
'\n' + '\n' +
chatCompletionsToResponsesPolicyExample + chatCompletionsToResponsesPolicyExample +
'\n\n' + '\n\n' +
t('例如(全渠道):') + t('例如(全渠道):') +
...@@ -370,7 +366,9 @@ export default function SettingGlobalModel(props) { ...@@ -370,7 +366,9 @@ export default function SettingGlobalModel(props) {
<Col span={24}> <Col span={24}>
<Banner <Banner
type='warning' type='warning'
description={t('警告:启用保活后,如果已经写入保活数据后渠道出错,系统无法重试,如果必须开启,推荐设置尽可能大的Ping间隔')} description={t(
'警告:启用保活后,如果已经写入保活数据后渠道出错,系统无法重试,如果必须开启,推荐设置尽可能大的Ping间隔',
)}
/> />
</Col> </Col>
</Row> </Row>
......
...@@ -49,8 +49,7 @@ export default function SettingGrokModel(props) { ...@@ -49,8 +49,7 @@ export default function SettingGrokModel(props) {
.validate() .validate()
.then(() => { .then(() => {
const updateArray = compareObjects(inputs, inputsRow); const updateArray = compareObjects(inputs, inputsRow);
if (!updateArray.length) if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
return showWarning(t('你似乎并没有修改什么'));
const requestQueue = updateArray.map((item) => { const requestQueue = updateArray.map((item) => {
const value = String(inputs[item.key]); const value = String(inputs[item.key]);
......
...@@ -18,7 +18,15 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -18,7 +18,15 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useState, useRef } from 'react';
import { Button, Col, Form, Row, Spin, Card, Typography } from '@douyinfe/semi-ui'; import {
Button,
Col,
Form,
Row,
Spin,
Card,
Typography,
} from '@douyinfe/semi-ui';
import { import {
compareObjects, compareObjects,
API, API,
...@@ -88,9 +96,7 @@ export default function SettingModelDeployment(props) { ...@@ -88,9 +96,7 @@ export default function SettingModelDeployment(props) {
showError(t('网络连接失败,请检查网络设置或稍后重试')); showError(t('网络连接失败,请检查网络设置或稍后重试'));
} else { } else {
const rawMessage = const rawMessage =
error?.response?.data?.message || error?.response?.data?.message || error?.message || '';
error?.message ||
'';
const localizedMessage = rawMessage const localizedMessage = rawMessage
? getLocalizedMessage(rawMessage) ? getLocalizedMessage(rawMessage)
: t('未知错误'); : t('未知错误');
...@@ -104,7 +110,7 @@ export default function SettingModelDeployment(props) { ...@@ -104,7 +110,7 @@ export default function SettingModelDeployment(props) {
function onSubmit() { function onSubmit() {
const updateArray = compareObjects(inputs, inputsRow); const updateArray = compareObjects(inputs, inputsRow);
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么')); if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
const requestQueue = updateArray.map((item) => { const requestQueue = updateArray.map((item) => {
let value = String(inputs[item.key]); let value = String(inputs[item.key]);
return API.put('/api/option/', { return API.put('/api/option/', {
...@@ -112,7 +118,7 @@ export default function SettingModelDeployment(props) { ...@@ -112,7 +118,7 @@ export default function SettingModelDeployment(props) {
value, value,
}); });
}); });
setLoading(true); setLoading(true);
Promise.all(requestQueue) Promise.all(requestQueue)
.then((res) => { .then((res) => {
...@@ -141,7 +147,7 @@ export default function SettingModelDeployment(props) { ...@@ -141,7 +147,7 @@ export default function SettingModelDeployment(props) {
'model_deployment.ionet.api_key': '', 'model_deployment.ionet.api_key': '',
'model_deployment.ionet.enabled': false, 'model_deployment.ionet.enabled': false,
}; };
const currentInputs = {}; const currentInputs = {};
for (let key in defaultInputs) { for (let key in defaultInputs) {
if (props.options.hasOwnProperty(key)) { if (props.options.hasOwnProperty(key)) {
...@@ -150,7 +156,7 @@ export default function SettingModelDeployment(props) { ...@@ -150,7 +156,7 @@ export default function SettingModelDeployment(props) {
currentInputs[key] = defaultInputs[key]; currentInputs[key] = defaultInputs[key];
} }
} }
setInputs(currentInputs); setInputs(currentInputs);
setInputsRow(structuredClone(currentInputs)); setInputsRow(structuredClone(currentInputs));
refForm.current?.setValues(currentInputs); refForm.current?.setValues(currentInputs);
...@@ -165,9 +171,11 @@ export default function SettingModelDeployment(props) { ...@@ -165,9 +171,11 @@ export default function SettingModelDeployment(props) {
getFormApi={(formAPI) => (refForm.current = formAPI)} getFormApi={(formAPI) => (refForm.current = formAPI)}
style={{ marginBottom: 15 }} style={{ marginBottom: 15 }}
> >
<Form.Section <Form.Section
text={ text={
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}> <div
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
>
<span>{t('模型部署设置')}</span> <span>{t('模型部署设置')}</span>
</div> </div>
} }
...@@ -186,7 +194,9 @@ export default function SettingModelDeployment(props) { ...@@ -186,7 +194,9 @@ export default function SettingModelDeployment(props) {
<Card <Card
title={ title={
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}> <div
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
>
<Cloud size={18} /> <Cloud size={18} />
<span>io.net</span> <span>io.net</span>
</div> </div>
...@@ -226,18 +236,16 @@ export default function SettingModelDeployment(props) { ...@@ -226,18 +236,16 @@ export default function SettingModelDeployment(props) {
} }
disabled={!inputs['model_deployment.ionet.enabled']} disabled={!inputs['model_deployment.ionet.enabled']}
extraText={t('请使用 Project 为 io.cloud 的密钥')} extraText={t('请使用 Project 为 io.cloud 的密钥')}
mode="password" mode='password'
/> />
<div style={{ display: 'flex', gap: '12px' }}> <div style={{ display: 'flex', gap: '12px' }}>
<Button <Button
type="outline" type='outline'
size="small" size='small'
icon={<Zap size={16} />} icon={<Zap size={16} />}
onClick={testApiKey} onClick={testApiKey}
loading={testing} loading={testing}
disabled={ disabled={!inputs['model_deployment.ionet.enabled']}
!inputs['model_deployment.ionet.enabled']
}
style={{ style={{
height: '32px', height: '32px',
fontSize: '13px', fontSize: '13px',
...@@ -271,7 +279,10 @@ export default function SettingModelDeployment(props) { ...@@ -271,7 +279,10 @@ export default function SettingModelDeployment(props) {
}} }}
> >
<div> <div>
<Text strong style={{ display: 'block', marginBottom: '8px' }}> <Text
strong
style={{ display: 'block', marginBottom: '8px' }}
>
{t('获取 io.net API Key')} {t('获取 io.net API Key')}
</Text> </Text>
<ul <ul
...@@ -287,14 +298,16 @@ export default function SettingModelDeployment(props) { ...@@ -287,14 +298,16 @@ export default function SettingModelDeployment(props) {
}} }}
> >
<li>{t('访问 io.net 控制台的 API Keys 页面')}</li> <li>{t('访问 io.net 控制台的 API Keys 页面')}</li>
<li>{t('创建或选择密钥时,将 Project 设置为 io.cloud')}</li> <li>
{t('创建或选择密钥时,将 Project 设置为 io.cloud')}
</li>
<li>{t('复制生成的密钥并粘贴到此处')}</li> <li>{t('复制生成的密钥并粘贴到此处')}</li>
</ul> </ul>
</div> </div>
<Button <Button
icon={<ArrowUpRight size={16} />} icon={<ArrowUpRight size={16} />}
type="primary" type='primary'
theme="solid" theme='solid'
style={{ width: '100%' }} style={{ width: '100%' }}
onClick={() => onClick={() =>
window.open('https://ai.io.net/ai/api-keys', '_blank') window.open('https://ai.io.net/ai/api-keys', '_blank')
...@@ -308,7 +321,7 @@ export default function SettingModelDeployment(props) { ...@@ -308,7 +321,7 @@ export default function SettingModelDeployment(props) {
</Card> </Card>
<Row> <Row>
<Button size='default' type="primary" onClick={onSubmit}> <Button size='default' type='primary' onClick={onSubmit}>
{t('保存设置')} {t('保存设置')}
</Button> </Button>
</Row> </Row>
......
...@@ -73,6 +73,7 @@ const RULE_TEMPLATES = { ...@@ -73,6 +73,7 @@ const RULE_TEMPLATES = {
key_sources: [{ type: 'gjson', path: 'prompt_cache_key' }], key_sources: [{ type: 'gjson', path: 'prompt_cache_key' }],
value_regex: '', value_regex: '',
ttl_seconds: 0, ttl_seconds: 0,
skip_retry_on_failure: false,
include_using_group: true, include_using_group: true,
include_rule_name: true, include_rule_name: true,
}, },
...@@ -83,6 +84,7 @@ const RULE_TEMPLATES = { ...@@ -83,6 +84,7 @@ const RULE_TEMPLATES = {
key_sources: [{ type: 'gjson', path: 'metadata.user_id' }], key_sources: [{ type: 'gjson', path: 'metadata.user_id' }],
value_regex: '', value_regex: '',
ttl_seconds: 0, ttl_seconds: 0,
skip_retry_on_failure: false,
include_using_group: true, include_using_group: true,
include_rule_name: true, include_rule_name: true,
}, },
...@@ -112,6 +114,7 @@ const RULES_JSON_PLACEHOLDER = `[ ...@@ -112,6 +114,7 @@ const RULES_JSON_PLACEHOLDER = `[
], ],
"value_regex": "^[-0-9A-Za-z._:]{1,128}$", "value_regex": "^[-0-9A-Za-z._:]{1,128}$",
"ttl_seconds": 600, "ttl_seconds": 600,
"skip_retry_on_failure": false,
"include_using_group": true, "include_using_group": true,
"include_rule_name": true "include_rule_name": true
} }
...@@ -153,7 +156,12 @@ const normalizeKeySource = (src) => { ...@@ -153,7 +156,12 @@ const normalizeKeySource = (src) => {
const type = (src?.type || '').trim(); const type = (src?.type || '').trim();
const key = (src?.key || '').trim(); const key = (src?.key || '').trim();
const path = (src?.path || '').trim(); const path = (src?.path || '').trim();
return { type, key, path };
if (type === 'gjson') {
return { type, key: '', path };
}
return { type, key, path: '' };
}; };
const makeUniqueName = (existingNames, baseName) => { const makeUniqueName = (existingNames, baseName) => {
...@@ -229,6 +237,7 @@ export default function SettingsChannelAffinity(props) { ...@@ -229,6 +237,7 @@ export default function SettingsChannelAffinity(props) {
user_agent_include_text: (r.user_agent_include || []).join('\n'), user_agent_include_text: (r.user_agent_include || []).join('\n'),
value_regex: r.value_regex || '', value_regex: r.value_regex || '',
ttl_seconds: Number(r.ttl_seconds || 0), ttl_seconds: Number(r.ttl_seconds || 0),
skip_retry_on_failure: !!r.skip_retry_on_failure,
include_using_group: r.include_using_group ?? true, include_using_group: r.include_using_group ?? true,
include_rule_name: r.include_rule_name ?? true, include_rule_name: r.include_rule_name ?? true,
}; };
...@@ -523,6 +532,7 @@ export default function SettingsChannelAffinity(props) { ...@@ -523,6 +532,7 @@ export default function SettingsChannelAffinity(props) {
key_sources: [{ type: 'gjson', path: '' }], key_sources: [{ type: 'gjson', path: '' }],
value_regex: '', value_regex: '',
ttl_seconds: 0, ttl_seconds: 0,
skip_retry_on_failure: false,
include_using_group: true, include_using_group: true,
include_rule_name: true, include_rule_name: true,
}; };
...@@ -583,6 +593,9 @@ export default function SettingsChannelAffinity(props) { ...@@ -583,6 +593,9 @@ export default function SettingsChannelAffinity(props) {
ttl_seconds: Number(values.ttl_seconds || 0), ttl_seconds: Number(values.ttl_seconds || 0),
include_using_group: !!values.include_using_group, include_using_group: !!values.include_using_group,
include_rule_name: !!values.include_rule_name, include_rule_name: !!values.include_rule_name,
...(values.skip_retry_on_failure
? { skip_retry_on_failure: true }
: {}),
...(userAgentInclude.length > 0 ...(userAgentInclude.length > 0
? { user_agent_include: userAgentInclude } ? { user_agent_include: userAgentInclude }
: {}), : {}),
...@@ -1041,6 +1054,18 @@ export default function SettingsChannelAffinity(props) { ...@@ -1041,6 +1054,18 @@ export default function SettingsChannelAffinity(props) {
</Text> </Text>
</Col> </Col>
</Row> </Row>
<Row gutter={16}>
<Col xs={24} sm={12}>
<Form.Switch
field='skip_retry_on_failure'
label={t('失败后不重试')}
/>
<Text type='tertiary' size='small'>
{t('开启后,若该规则命中且请求失败,将不会切换渠道重试。')}
</Text>
</Col>
</Row>
</Collapse.Panel> </Collapse.Panel>
</Collapse> </Collapse>
......
...@@ -172,7 +172,9 @@ export default function SettingsCreditLimit(props) { ...@@ -172,7 +172,9 @@ export default function SettingsCreditLimit(props) {
<Form.Switch <Form.Switch
label={t('对免费模型启用预消耗')} label={t('对免费模型启用预消耗')}
field={'quota_setting.enable_free_model_pre_consume'} field={'quota_setting.enable_free_model_pre_consume'}
extraText={t('开启后,对免费模型(倍率为0,或者价格为0)的模型也会预消耗额度')} extraText={t(
'开启后,对免费模型(倍率为0,或者价格为0)的模型也会预消耗额度',
)}
onChange={(value) => onChange={(value) =>
setInputs({ setInputs({
...inputs, ...inputs,
......
...@@ -18,13 +18,7 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -18,13 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useState, useRef } from 'react';
import { import { Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui';
Button,
Col,
Form,
Row,
Spin,
} from '@douyinfe/semi-ui';
import { import {
compareObjects, compareObjects,
API, API,
...@@ -46,7 +40,8 @@ export default function SettingsMonitoring(props) { ...@@ -46,7 +40,8 @@ export default function SettingsMonitoring(props) {
AutomaticEnableChannelEnabled: false, AutomaticEnableChannelEnabled: false,
AutomaticDisableKeywords: '', AutomaticDisableKeywords: '',
AutomaticDisableStatusCodes: '401', AutomaticDisableStatusCodes: '401',
AutomaticRetryStatusCodes: '100-199,300-399,401-407,409-499,500-503,505-523,525-599', AutomaticRetryStatusCodes:
'100-199,300-399,401-407,409-499,500-503,505-523,525-599',
'monitor_setting.auto_test_channel_enabled': false, 'monitor_setting.auto_test_channel_enabled': false,
'monitor_setting.auto_test_channel_minutes': 10, 'monitor_setting.auto_test_channel_minutes': 10,
}); });
......
...@@ -252,7 +252,11 @@ export default function SettingsSidebarModulesAdmin(props) { ...@@ -252,7 +252,11 @@ export default function SettingsSidebarModulesAdmin(props) {
modules: [ modules: [
{ key: 'channel', title: t('渠道管理'), description: t('API渠道配置') }, { key: 'channel', title: t('渠道管理'), description: t('API渠道配置') },
{ key: 'models', title: t('模型管理'), description: t('AI模型配置') }, { key: 'models', title: t('模型管理'), description: t('AI模型配置') },
{ key: 'deployment', title: t('模型部署'), description: t('模型部署管理') }, {
key: 'deployment',
title: t('模型部署'),
description: t('模型部署管理'),
},
{ {
key: 'redemption', key: 'redemption',
title: t('兑换码管理'), title: t('兑换码管理'),
......
...@@ -205,7 +205,10 @@ export default function GroupRatioSettings(props) { ...@@ -205,7 +205,10 @@ export default function GroupRatioSettings(props) {
}, },
]} ]}
onChange={(value) => onChange={(value) =>
setInputs({ ...inputs, 'group_ratio_setting.group_special_usable_group': value }) setInputs({
...inputs,
'group_ratio_setting.group_special_usable_group': value,
})
} }
/> />
</Col> </Col>
......
...@@ -26,12 +26,12 @@ const UserAgreement = () => { ...@@ -26,12 +26,12 @@ const UserAgreement = () => {
return ( return (
<DocumentRenderer <DocumentRenderer
apiEndpoint="/api/user-agreement" apiEndpoint='/api/user-agreement'
title={t('用户协议')} title={t('用户协议')}
cacheKey="user_agreement" cacheKey='user_agreement'
emptyMessage={t('加载用户协议内容失败...')} emptyMessage={t('加载用户协议内容失败...')}
/> />
); );
}; };
export default UserAgreement; export default UserAgreement;
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment