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
This commit is contained in:
@@ -39,7 +39,15 @@ import {
|
||||
isPasskeySupported,
|
||||
} from '../../helpers';
|
||||
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 Text from '@douyinfe/semi-ui/lib/es/typography/text';
|
||||
import TelegramLoginButton from 'react-telegram-login';
|
||||
@@ -55,7 +63,7 @@ import WeChatIcon from '../common/logo/WeChatIcon';
|
||||
import LinuxDoIcon from '../common/logo/LinuxDoIcon';
|
||||
import TwoFAVerification from './TwoFAVerification';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SiDiscord }from 'react-icons/si';
|
||||
import { SiDiscord } from 'react-icons/si';
|
||||
|
||||
const LoginForm = () => {
|
||||
let navigate = useNavigate();
|
||||
@@ -126,7 +134,7 @@ const LoginForm = () => {
|
||||
setTurnstileEnabled(true);
|
||||
setTurnstileSiteKey(status.turnstile_site_key);
|
||||
}
|
||||
|
||||
|
||||
// 从 status 获取用户协议和隐私政策的启用状态
|
||||
setHasUserAgreement(status?.user_agreement_enabled || false);
|
||||
setHasPrivacyPolicy(status?.privacy_policy_enabled || false);
|
||||
@@ -514,7 +522,15 @@ const LoginForm = () => {
|
||||
theme='outline'
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={<SiDiscord style={{ color: '#5865F2', width: '20px', height: '20px' }} />}
|
||||
icon={
|
||||
<SiDiscord
|
||||
style={{
|
||||
color: '#5865F2',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onClick={handleDiscordClick}
|
||||
loading={discordLoading}
|
||||
>
|
||||
@@ -626,11 +642,11 @@ const LoginForm = () => {
|
||||
{t('隐私政策')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</Text>
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!status.self_use_mode_enabled && (
|
||||
<div className='mt-6 text-center text-sm'>
|
||||
@@ -746,7 +762,9 @@ const LoginForm = () => {
|
||||
htmlType='submit'
|
||||
onClick={handleSubmit}
|
||||
loading={loginLoading}
|
||||
disabled={(hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms}
|
||||
disabled={
|
||||
(hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms
|
||||
}
|
||||
>
|
||||
{t('继续')}
|
||||
</Button>
|
||||
|
||||
@@ -41,7 +41,7 @@ const isUrl = (content) => {
|
||||
// 检查是否为 HTML 内容
|
||||
const isHtmlContent = (content) => {
|
||||
if (!content || typeof content !== 'string') return false;
|
||||
|
||||
|
||||
// 检查是否包含HTML标签
|
||||
const htmlTagRegex = /<\/?[a-z][\s\S]*>/i;
|
||||
return htmlTagRegex.test(content);
|
||||
@@ -52,16 +52,16 @@ const sanitizeHtml = (html) => {
|
||||
// 创建一个临时元素来解析HTML
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = html;
|
||||
|
||||
|
||||
// 提取样式
|
||||
const styles = Array.from(tempDiv.querySelectorAll('style'))
|
||||
.map(style => style.innerHTML)
|
||||
.map((style) => style.innerHTML)
|
||||
.join('\n');
|
||||
|
||||
|
||||
// 提取body内容,如果没有body标签则使用全部内容
|
||||
const bodyContent = tempDiv.querySelector('body');
|
||||
const content = bodyContent ? bodyContent.innerHTML : html;
|
||||
|
||||
|
||||
return { content, styles };
|
||||
};
|
||||
|
||||
@@ -129,7 +129,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
// 处理HTML样式注入
|
||||
useEffect(() => {
|
||||
const styleId = `document-renderer-styles-${cacheKey}`;
|
||||
|
||||
|
||||
if (htmlStyles) {
|
||||
let styleEl = document.getElementById(styleId);
|
||||
if (!styleEl) {
|
||||
@@ -165,8 +165,12 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
<div className='flex justify-center items-center min-h-screen bg-gray-50'>
|
||||
<Empty
|
||||
title={t('管理员未设置' + title + '内容')}
|
||||
image={<IllustrationConstruction style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={<IllustrationConstructionDark style={{ width: 150, height: 150 }} />}
|
||||
image={
|
||||
<IllustrationConstruction style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
darkModeImage={
|
||||
<IllustrationConstructionDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
className='p-8'
|
||||
/>
|
||||
</div>
|
||||
@@ -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'>
|
||||
<Card className='max-w-md w-full'>
|
||||
<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'>
|
||||
{t('管理员设置了外部链接,点击下方按钮访问')}
|
||||
</p>
|
||||
@@ -202,20 +208,22 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
// 如果是 HTML 内容,直接渲染
|
||||
if (isHtmlContent(content)) {
|
||||
const { content: htmlContent, styles } = sanitizeHtml(content);
|
||||
|
||||
|
||||
// 设置样式(如果有的话)
|
||||
useEffect(() => {
|
||||
if (styles && styles !== htmlStyles) {
|
||||
setHtmlStyles(styles);
|
||||
}
|
||||
}, [content, styles, htmlStyles]);
|
||||
|
||||
|
||||
return (
|
||||
<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='bg-white rounded-lg shadow-sm p-8'>
|
||||
<Title heading={2} className='text-center mb-8'>{title}</Title>
|
||||
<div
|
||||
<Title heading={2} className='text-center mb-8'>
|
||||
{title}
|
||||
</Title>
|
||||
<div
|
||||
className='prose prose-lg max-w-none'
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
/>
|
||||
@@ -230,7 +238,9 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
<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='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'>
|
||||
<MarkdownRenderer content={content} />
|
||||
</div>
|
||||
@@ -240,4 +250,4 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentRenderer;
|
||||
export default DocumentRenderer;
|
||||
|
||||
@@ -136,9 +136,7 @@ const SkeletonWrapper = ({
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title
|
||||
style={{ width, height, borderRadius: 9999 }}
|
||||
/>
|
||||
<Skeleton.Title style={{ width, height, borderRadius: 9999 }} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -186,7 +184,9 @@ const SkeletonWrapper = ({
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title style={{ width: width || 60, height: height || 12 }} />
|
||||
<Skeleton.Title
|
||||
style={{ width: width || 60, height: height || 12 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -221,9 +221,7 @@ const SkeletonWrapper = ({
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title
|
||||
style={{ width: labelWidth, height: TEXT_HEIGHT }}
|
||||
/>
|
||||
<Skeleton.Title style={{ width: labelWidth, height: TEXT_HEIGHT }} />
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -115,8 +115,7 @@ const linkifyHtml = (html) => {
|
||||
if (part.startsWith('<')) return part;
|
||||
return part.replace(
|
||||
linkRegex,
|
||||
(url) =>
|
||||
`<a href="${url}" target="_blank" rel="noreferrer">${url}</a>`,
|
||||
(url) => `<a href="${url}" target="_blank" rel="noreferrer">${url}</a>`,
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
|
||||
@@ -30,64 +30,67 @@ const CustomInputRender = (props) => {
|
||||
detailProps;
|
||||
const containerRef = useRef(null);
|
||||
|
||||
const handlePaste = useCallback(async (e) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
const handlePaste = useCallback(
|
||||
async (e) => {
|
||||
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;
|
||||
}
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
|
||||
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,
|
||||
if (item.type.indexOf('image') !== -1) {
|
||||
e.preventDefault();
|
||||
const file = item.getAsFile();
|
||||
|
||||
if (file) {
|
||||
try {
|
||||
if (!imageEnabled) {
|
||||
Toast.warning({
|
||||
content: t('请先在设置中启用图片功能'),
|
||||
duration: 3,
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
console.error('Failed to read image file:', reader.error);
|
||||
|
||||
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({
|
||||
content: t('粘贴图片失败'),
|
||||
duration: 2,
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} catch (error) {
|
||||
console.error('Failed to paste image:', error);
|
||||
Toast.error({
|
||||
content: t('粘贴图片失败'),
|
||||
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(() => {
|
||||
const container = containerRef.current;
|
||||
|
||||
@@ -140,7 +140,9 @@ const CustomRequestEditor = ({
|
||||
{/* 提示信息 */}
|
||||
<Banner
|
||||
type='warning'
|
||||
description={t('启用此模式后,将使用您自定义的请求体发送API请求,模型配置面板的参数设置将被忽略。')}
|
||||
description={t(
|
||||
'启用此模式后,将使用您自定义的请求体发送API请求,模型配置面板的参数设置将被忽略。',
|
||||
)}
|
||||
icon={<AlertTriangle size={16} />}
|
||||
className='!rounded-lg'
|
||||
closeIcon={null}
|
||||
@@ -201,7 +203,9 @@ const CustomRequestEditor = ({
|
||||
)}
|
||||
|
||||
<Typography.Text className='text-xs text-gray-500 mt-2 block'>
|
||||
{t('请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。')}
|
||||
{t(
|
||||
'请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。',
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -191,10 +191,7 @@ const DebugPanel = ({
|
||||
itemKey='response'
|
||||
>
|
||||
{debugData.sseMessages && debugData.sseMessages.length > 0 ? (
|
||||
<SSEViewer
|
||||
sseData={debugData.sseMessages}
|
||||
title='response'
|
||||
/>
|
||||
<SSEViewer sseData={debugData.sseMessages} title='response' />
|
||||
) : (
|
||||
<CodeViewer
|
||||
content={debugData.response}
|
||||
|
||||
@@ -18,8 +18,22 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { Button, Tooltip, Toast, Collapse, Badge, Typography } from '@douyinfe/semi-ui';
|
||||
import { Copy, ChevronDown, ChevronUp, Zap, CheckCircle, XCircle } from 'lucide-react';
|
||||
import {
|
||||
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 { copy } from '../../helpers';
|
||||
|
||||
@@ -67,19 +81,19 @@ const SSEViewer = ({ sseData }) => {
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = parsedSSEData.length;
|
||||
const errors = parsedSSEData.filter(item => item.error).length;
|
||||
const done = parsedSSEData.filter(item => item.isDone).length;
|
||||
const errors = parsedSSEData.filter((item) => item.error).length;
|
||||
const done = parsedSSEData.filter((item) => item.isDone).length;
|
||||
const valid = total - errors - done;
|
||||
|
||||
return { total, errors, done, valid };
|
||||
}, [parsedSSEData]);
|
||||
|
||||
const handleToggleAll = useCallback(() => {
|
||||
setExpandedKeys(prev => {
|
||||
setExpandedKeys((prev) => {
|
||||
if (prev.length === parsedSSEData.length) {
|
||||
return [];
|
||||
} else {
|
||||
return parsedSSEData.map(item => item.key);
|
||||
return parsedSSEData.map((item) => item.key);
|
||||
}
|
||||
});
|
||||
}, [parsedSSEData]);
|
||||
@@ -87,7 +101,9 @@ const SSEViewer = ({ sseData }) => {
|
||||
const handleCopyAll = useCallback(async () => {
|
||||
try {
|
||||
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');
|
||||
|
||||
await copy(allData);
|
||||
@@ -100,15 +116,20 @@ const SSEViewer = ({ sseData }) => {
|
||||
}
|
||||
}, [parsedSSEData, t]);
|
||||
|
||||
const handleCopySingle = useCallback(async (item) => {
|
||||
try {
|
||||
const textToCopy = item.parsed ? JSON.stringify(item.parsed, null, 2) : item.raw;
|
||||
await copy(textToCopy);
|
||||
Toast.success(t('已复制'));
|
||||
} catch (err) {
|
||||
Toast.error(t('复制失败'));
|
||||
}
|
||||
}, [t]);
|
||||
const handleCopySingle = useCallback(
|
||||
async (item) => {
|
||||
try {
|
||||
const textToCopy = item.parsed
|
||||
? JSON.stringify(item.parsed, null, 2)
|
||||
: item.raw;
|
||||
await copy(textToCopy);
|
||||
Toast.success(t('已复制'));
|
||||
} catch (err) {
|
||||
Toast.error(t('复制失败'));
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const renderSSEItem = (item) => {
|
||||
if (item.isDone) {
|
||||
@@ -158,18 +179,24 @@ const SSEViewer = ({ sseData }) => {
|
||||
{item.parsed?.choices?.[0] && (
|
||||
<div className='flex flex-wrap gap-2 text-xs'>
|
||||
{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 && (
|
||||
<Badge count={t('有 Reasoning')} type='warning' />
|
||||
)}
|
||||
{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 && (
|
||||
<Badge
|
||||
count={`${t('令牌')}: ${item.parsed.usage.prompt_tokens || 0}/${item.parsed.usage.completion_tokens || 0}`}
|
||||
type='tertiary'
|
||||
<Badge
|
||||
count={`${t('令牌')}: ${item.parsed.usage.prompt_tokens || 0}/${item.parsed.usage.completion_tokens || 0}`}
|
||||
type='tertiary'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -194,7 +221,9 @@ const SSEViewer = ({ sseData }) => {
|
||||
<Zap size={16} className='text-blue-500' />
|
||||
<Typography.Text strong>{t('SSE数据流')}</Typography.Text>
|
||||
<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 className='flex items-center gap-2'>
|
||||
@@ -208,14 +237,28 @@ const SSEViewer = ({ sseData }) => {
|
||||
{copied ? t('已复制') : t('复制全部')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content={expandedKeys.length === parsedSSEData.length ? t('全部收起') : t('全部展开')}>
|
||||
<Tooltip
|
||||
content={
|
||||
expandedKeys.length === parsedSSEData.length
|
||||
? t('全部收起')
|
||||
: t('全部展开')
|
||||
}
|
||||
>
|
||||
<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'
|
||||
onClick={handleToggleAll}
|
||||
theme='borderless'
|
||||
>
|
||||
{expandedKeys.length === parsedSSEData.length ? t('收起') : t('展开')}
|
||||
{expandedKeys.length === parsedSSEData.length
|
||||
? t('收起')
|
||||
: t('展开')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -242,11 +285,16 @@ const SSEViewer = ({ sseData }) => {
|
||||
) : (
|
||||
<>
|
||||
<span className='text-gray-600'>
|
||||
{item.parsed?.id || item.parsed?.object || t('SSE 事件')}
|
||||
{item.parsed?.id ||
|
||||
item.parsed?.object ||
|
||||
t('SSE 事件')}
|
||||
</span>
|
||||
{item.parsed?.choices?.[0]?.delta && (
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -68,4 +68,3 @@ export default function HttpStatusCodeRulesInput(props) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ const ModelDeploymentSetting = () => {
|
||||
'model_deployment.ionet.api_key': '',
|
||||
'model_deployment.ionet.enabled': false,
|
||||
};
|
||||
|
||||
|
||||
data.forEach((item) => {
|
||||
if (item.key.endsWith('Enabled') || item.key.endsWith('enabled')) {
|
||||
newInputs[item.key] = toBoolean(item.value);
|
||||
@@ -82,4 +82,4 @@ const ModelDeploymentSetting = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelDeploymentSetting;
|
||||
export default ModelDeploymentSetting;
|
||||
|
||||
@@ -71,7 +71,8 @@ const OperationSetting = () => {
|
||||
AutomaticEnableChannelEnabled: false,
|
||||
AutomaticDisableKeywords: '',
|
||||
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_minutes': 10 /* 签到设置 */,
|
||||
'checkin_setting.enabled': false,
|
||||
|
||||
@@ -378,13 +378,15 @@ const OtherSetting = () => {
|
||||
<Form.TextArea
|
||||
label={t('用户协议')}
|
||||
placeholder={t(
|
||||
'在此输入用户协议内容,支持 Markdown & HTML 代码',
|
||||
'在此输入用户协议内容,支持 Markdown & HTML 代码',
|
||||
)}
|
||||
field={LEGAL_USER_AGREEMENT_KEY}
|
||||
onChange={handleInputChange}
|
||||
style={{ fontFamily: 'JetBrains Mono, Consolas' }}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
helpText={t('填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议')}
|
||||
helpText={t(
|
||||
'填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议',
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
onClick={submitUserAgreement}
|
||||
@@ -401,7 +403,9 @@ const OtherSetting = () => {
|
||||
onChange={handleInputChange}
|
||||
style={{ fontFamily: 'JetBrains Mono, Consolas' }}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
helpText={t('填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策')}
|
||||
helpText={t(
|
||||
'填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策',
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
onClick={submitPrivacyPolicy}
|
||||
|
||||
@@ -57,9 +57,7 @@ const RatioSetting = () => {
|
||||
if (success) {
|
||||
let newInputs = {};
|
||||
data.forEach((item) => {
|
||||
if (
|
||||
item.value.startsWith('{') || item.value.startsWith('[')
|
||||
) {
|
||||
if (item.value.startsWith('{') || item.value.startsWith('[')) {
|
||||
try {
|
||||
item.value = JSON.stringify(JSON.parse(item.value), null, 2);
|
||||
} catch (e) {
|
||||
|
||||
@@ -481,10 +481,14 @@ const SystemSetting = () => {
|
||||
const options = [];
|
||||
|
||||
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 (
|
||||
originInputs['discord.client_secret'] !== inputs['discord.client_secret'] &&
|
||||
originInputs['discord.client_secret'] !==
|
||||
inputs['discord.client_secret'] &&
|
||||
inputs['discord.client_secret'] !== ''
|
||||
) {
|
||||
options.push({
|
||||
@@ -745,8 +749,8 @@ const SystemSetting = () => {
|
||||
rel='noreferrer'
|
||||
>
|
||||
new-api-worker
|
||||
</a>
|
||||
{' '}{t('或其兼容new-api-worker格式的其他版本')}
|
||||
</a>{' '}
|
||||
{t('或其兼容new-api-worker格式的其他版本')}
|
||||
</Text>
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
|
||||
@@ -109,7 +109,9 @@ const renderType = (type, record = {}, t) => {
|
||||
<Tooltip
|
||||
content={
|
||||
<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 && (
|
||||
<div className='text-xs text-gray-500 mt-1'>
|
||||
{t('部署 ID')}: {ionetMeta.deployment_id}
|
||||
|
||||
@@ -19,7 +19,14 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
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';
|
||||
|
||||
const { Text } = Typography;
|
||||
@@ -33,14 +40,21 @@ const CodexOAuthModal = ({ visible, onCancel, onSuccess }) => {
|
||||
const startOAuth = async () => {
|
||||
setLoading(true);
|
||||
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) {
|
||||
console.error('Codex OAuth start failed:', res?.data?.message);
|
||||
throw new Error(t('启动授权失败'));
|
||||
}
|
||||
const url = res?.data?.data?.authorize_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('响应缺少授权链接'));
|
||||
}
|
||||
setAuthorizeUrl(url);
|
||||
@@ -106,7 +120,12 @@ const CodexOAuthModal = ({ visible, onCancel, onSuccess }) => {
|
||||
<Button theme='borderless' onClick={onCancel} disabled={loading}>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
<Button theme='solid' type='primary' onClick={completeOAuth} loading={loading}>
|
||||
<Button
|
||||
theme='solid'
|
||||
type='primary'
|
||||
onClick={completeOAuth}
|
||||
loading={loading}
|
||||
>
|
||||
{t('生成并填入')}
|
||||
</Button>
|
||||
</Space>
|
||||
@@ -141,7 +160,9 @@ const CodexOAuthModal = ({ visible, onCancel, onSuccess }) => {
|
||||
/>
|
||||
|
||||
<Text type='tertiary' size='small'>
|
||||
{t('说明:生成结果是可直接粘贴到渠道密钥里的 JSON(包含 access_token / refresh_token / account_id)。')}
|
||||
{t(
|
||||
'说明:生成结果是可直接粘贴到渠道密钥里的 JSON(包含 access_token / refresh_token / account_id)。',
|
||||
)}
|
||||
</Text>
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
@@ -18,7 +18,14 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
const { Text } = Typography;
|
||||
@@ -134,7 +141,12 @@ const CodexUsageView = ({ t, record, payload, onCopy, onRefresh }) => {
|
||||
</Text>
|
||||
<div className='flex items-center gap-2'>
|
||||
{statusTag}
|
||||
<Button size='small' type='tertiary' theme='borderless' onClick={onRefresh}>
|
||||
<Button
|
||||
size='small'
|
||||
type='tertiary'
|
||||
theme='borderless'
|
||||
onClick={onRefresh}
|
||||
>
|
||||
{tt('刷新')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -243,7 +255,12 @@ const CodexUsageLoader = ({ t, record, initialPayload, onCopy }) => {
|
||||
<div className='flex flex-col gap-3'>
|
||||
<Text type='danger'>{tt('获取用量失败')}</Text>
|
||||
<div className='flex justify-end'>
|
||||
<Button size='small' type='primary' theme='outline' onClick={fetchUsage}>
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={fetchUsage}
|
||||
>
|
||||
{tt('刷新')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -2000,171 +2000,180 @@ const EditChannelModal = (props) => {
|
||||
autoComplete='new-password'
|
||||
onChange={(value) => handleInputChange('key', value)}
|
||||
disabled={isIonetLocked}
|
||||
extraText={
|
||||
<div className='flex items-center gap-2 flex-wrap'>
|
||||
{isEdit &&
|
||||
isMultiKeyChannel &&
|
||||
keyMode === 'append' && (
|
||||
<Text type='warning' size='small'>
|
||||
{t(
|
||||
'追加模式:新密钥将添加到现有密钥列表的末尾',
|
||||
)}
|
||||
</Text>
|
||||
extraText={
|
||||
<div className='flex items-center gap-2 flex-wrap'>
|
||||
{isEdit &&
|
||||
isMultiKeyChannel &&
|
||||
keyMode === 'append' && (
|
||||
<Text type='warning' size='small'>
|
||||
{t(
|
||||
'追加模式:新密钥将添加到现有密钥列表的末尾',
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
{isEdit && (
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={handleShow2FAModal}
|
||||
>
|
||||
{t('查看密钥')}
|
||||
</Button>
|
||||
)}
|
||||
{isEdit && (
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={handleShow2FAModal}
|
||||
>
|
||||
{t('查看密钥')}
|
||||
</Button>
|
||||
)}
|
||||
{batchExtra}
|
||||
</div>
|
||||
}
|
||||
showClear
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{inputs.type === 57 ? (
|
||||
<>
|
||||
<Form.TextArea
|
||||
field='key'
|
||||
label={
|
||||
isEdit
|
||||
? t('密钥(编辑模式下,保存的密钥不会显示)')
|
||||
: t('密钥')
|
||||
}
|
||||
placeholder={t(
|
||||
'请输入 JSON 格式的 OAuth 凭据,例如:\n{\n "access_token": "...",\n "account_id": "..." \n}',
|
||||
)}
|
||||
rules={
|
||||
isEdit
|
||||
? []
|
||||
: [{ required: true, message: t('请输入密钥') }]
|
||||
}
|
||||
autoComplete='new-password'
|
||||
onChange={(value) => handleInputChange('key', value)}
|
||||
disabled={isIonetLocked}
|
||||
extraText={
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Text type='tertiary' size='small'>
|
||||
{t(
|
||||
'仅支持 JSON 对象,必须包含 access_token 与 account_id',
|
||||
)}
|
||||
</Text>
|
||||
{batchExtra}
|
||||
</div>
|
||||
}
|
||||
showClear
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{inputs.type === 57 ? (
|
||||
<>
|
||||
<Form.TextArea
|
||||
field='key'
|
||||
label={
|
||||
isEdit
|
||||
? t('密钥(编辑模式下,保存的密钥不会显示)')
|
||||
: t('密钥')
|
||||
}
|
||||
placeholder={t(
|
||||
'请输入 JSON 格式的 OAuth 凭据,例如:\n{\n "access_token": "...",\n "account_id": "..." \n}',
|
||||
)}
|
||||
rules={
|
||||
isEdit
|
||||
? []
|
||||
: [
|
||||
{
|
||||
required: true,
|
||||
message: t('请输入密钥'),
|
||||
},
|
||||
]
|
||||
}
|
||||
autoComplete='new-password'
|
||||
onChange={(value) =>
|
||||
handleInputChange('key', value)
|
||||
}
|
||||
disabled={isIonetLocked}
|
||||
extraText={
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Text type='tertiary' size='small'>
|
||||
{t(
|
||||
'仅支持 JSON 对象,必须包含 access_token 与 account_id',
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Space wrap spacing='tight'>
|
||||
<Space wrap spacing='tight'>
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={() =>
|
||||
setCodexOAuthModalVisible(true)
|
||||
}
|
||||
disabled={isIonetLocked}
|
||||
>
|
||||
{t('Codex 授权')}
|
||||
</Button>
|
||||
{isEdit && (
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={handleRefreshCodexCredential}
|
||||
loading={codexCredentialRefreshing}
|
||||
disabled={isIonetLocked}
|
||||
>
|
||||
{t('刷新凭证')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={() => formatJsonField('key')}
|
||||
disabled={isIonetLocked}
|
||||
>
|
||||
{t('格式化')}
|
||||
</Button>
|
||||
{isEdit && (
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={handleShow2FAModal}
|
||||
disabled={isIonetLocked}
|
||||
>
|
||||
{t('查看密钥')}
|
||||
</Button>
|
||||
)}
|
||||
{batchExtra}
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
autosize
|
||||
showClear
|
||||
/>
|
||||
|
||||
<CodexOAuthModal
|
||||
visible={codexOAuthModalVisible}
|
||||
onCancel={() => setCodexOAuthModalVisible(false)}
|
||||
onSuccess={handleCodexOAuthGenerated}
|
||||
/>
|
||||
</>
|
||||
) : inputs.type === 41 &&
|
||||
(inputs.vertex_key_type || 'json') === 'json' ? (
|
||||
<>
|
||||
{!batch && (
|
||||
<div className='flex items-center justify-between mb-3'>
|
||||
<Text className='text-sm font-medium'>
|
||||
{t('密钥输入方式')}
|
||||
</Text>
|
||||
<Space>
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={() =>
|
||||
setCodexOAuthModalVisible(true)
|
||||
type={
|
||||
!useManualInput ? 'primary' : 'tertiary'
|
||||
}
|
||||
disabled={isIonetLocked}
|
||||
onClick={() => {
|
||||
setUseManualInput(false);
|
||||
// 切换到文件上传模式时清空手动输入的密钥
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue('key', '');
|
||||
}
|
||||
handleInputChange('key', '');
|
||||
}}
|
||||
>
|
||||
{t('Codex 授权')}
|
||||
{t('文件上传')}
|
||||
</Button>
|
||||
{isEdit && (
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={handleRefreshCodexCredential}
|
||||
loading={codexCredentialRefreshing}
|
||||
disabled={isIonetLocked}
|
||||
>
|
||||
{t('刷新凭证')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={() => formatJsonField('key')}
|
||||
disabled={isIonetLocked}
|
||||
type={
|
||||
useManualInput ? 'primary' : 'tertiary'
|
||||
}
|
||||
onClick={() => {
|
||||
setUseManualInput(true);
|
||||
// 切换到手动输入模式时清空文件上传相关状态
|
||||
setVertexKeys([]);
|
||||
setVertexFileList([]);
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue(
|
||||
'vertex_files',
|
||||
[],
|
||||
);
|
||||
}
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
vertex_files: [],
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{t('格式化')}
|
||||
{t('手动输入')}
|
||||
</Button>
|
||||
{isEdit && (
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={handleShow2FAModal}
|
||||
disabled={isIonetLocked}
|
||||
>
|
||||
{t('查看密钥')}
|
||||
</Button>
|
||||
)}
|
||||
{batchExtra}
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
autosize
|
||||
showClear
|
||||
/>
|
||||
|
||||
<CodexOAuthModal
|
||||
visible={codexOAuthModalVisible}
|
||||
onCancel={() => setCodexOAuthModalVisible(false)}
|
||||
onSuccess={handleCodexOAuthGenerated}
|
||||
/>
|
||||
</>
|
||||
) : inputs.type === 41 &&
|
||||
(inputs.vertex_key_type || 'json') === 'json' ? (
|
||||
<>
|
||||
{!batch && (
|
||||
<div className='flex items-center justify-between mb-3'>
|
||||
<Text className='text-sm font-medium'>
|
||||
{t('密钥输入方式')}
|
||||
</Text>
|
||||
<Space>
|
||||
<Button
|
||||
size='small'
|
||||
type={
|
||||
!useManualInput ? 'primary' : 'tertiary'
|
||||
}
|
||||
onClick={() => {
|
||||
setUseManualInput(false);
|
||||
// 切换到文件上传模式时清空手动输入的密钥
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue('key', '');
|
||||
}
|
||||
handleInputChange('key', '');
|
||||
}}
|
||||
>
|
||||
{t('文件上传')}
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
type={useManualInput ? 'primary' : 'tertiary'}
|
||||
onClick={() => {
|
||||
setUseManualInput(true);
|
||||
// 切换到手动输入模式时清空文件上传相关状态
|
||||
setVertexKeys([]);
|
||||
setVertexFileList([]);
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue(
|
||||
'vertex_files',
|
||||
[],
|
||||
);
|
||||
}
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
vertex_files: [],
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{t('手动输入')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{batch && (
|
||||
<Banner
|
||||
|
||||
@@ -533,7 +533,11 @@ const EditTagModal = (props) => {
|
||||
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
|
||||
{/* Header: Advanced Settings */}
|
||||
<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} />
|
||||
</Avatar>
|
||||
<div>
|
||||
@@ -549,9 +553,7 @@ const EditTagModal = (props) => {
|
||||
field='param_override'
|
||||
label={t('参数覆盖')}
|
||||
placeholder={
|
||||
t(
|
||||
'此项可选,用于覆盖请求参数。不支持覆盖 stream 参数',
|
||||
) +
|
||||
t('此项可选,用于覆盖请求参数。不支持覆盖 stream 参数') +
|
||||
'\n' +
|
||||
t('旧格式(直接覆盖):') +
|
||||
'\n{\n "temperature": 0,\n "max_tokens": 1000\n}' +
|
||||
|
||||
@@ -104,7 +104,9 @@ const ModelSelectModal = ({
|
||||
}, [normalizedRedirectModels, normalizedSelectedSet]);
|
||||
|
||||
const filteredModels = models.filter((m) =>
|
||||
String(m || '').toLowerCase().includes(keyword.toLowerCase()),
|
||||
String(m || '')
|
||||
.toLowerCase()
|
||||
.includes(keyword.toLowerCase()),
|
||||
);
|
||||
|
||||
// 分类模型:新获取的模型和已有模型
|
||||
|
||||
@@ -30,7 +30,7 @@ const ConfirmationDialog = ({
|
||||
type = 'danger',
|
||||
deployment,
|
||||
t,
|
||||
loading = false
|
||||
loading = false,
|
||||
}) => {
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
|
||||
@@ -66,17 +66,17 @@ const ConfirmationDialog = ({
|
||||
okButtonProps={{
|
||||
disabled: !isConfirmed,
|
||||
type: type === 'danger' ? 'danger' : 'primary',
|
||||
loading
|
||||
loading,
|
||||
}}
|
||||
width={480}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Text type="danger" strong>
|
||||
<div className='space-y-4'>
|
||||
<Text type='danger' strong>
|
||||
{t('此操作具有风险,请确认要继续执行')}。
|
||||
</Text>
|
||||
<Text>
|
||||
{t('请输入部署名称以完成二次确认')}:
|
||||
<Text code className="ml-1">
|
||||
<Text code className='ml-1'>
|
||||
{requiredText || t('未知部署')}
|
||||
</Text>
|
||||
</Text>
|
||||
@@ -87,7 +87,7 @@ const ConfirmationDialog = ({
|
||||
autoFocus
|
||||
/>
|
||||
{!isConfirmed && confirmText && (
|
||||
<Text type="danger" size="small">
|
||||
<Text type='danger' size='small'>
|
||||
{t('部署名称不匹配,请检查后重新输入')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -130,9 +130,7 @@ const ExtendDurationModal = ({
|
||||
? details.locations
|
||||
.map((location) =>
|
||||
Number(
|
||||
location?.id ??
|
||||
location?.location_id ??
|
||||
location?.locationId,
|
||||
location?.id ?? location?.location_id ?? location?.locationId,
|
||||
),
|
||||
)
|
||||
.filter((id) => Number.isInteger(id) && id > 0)
|
||||
@@ -181,9 +179,7 @@ const ExtendDurationModal = ({
|
||||
} else {
|
||||
const message = response.data.message || '';
|
||||
setPriceEstimation(null);
|
||||
setPriceError(
|
||||
t('价格计算失败') + (message ? `: ${message}` : ''),
|
||||
);
|
||||
setPriceError(t('价格计算失败') + (message ? `: ${message}` : ''));
|
||||
}
|
||||
} catch (error) {
|
||||
if (costRequestIdRef.current !== requestId) {
|
||||
@@ -192,9 +188,7 @@ const ExtendDurationModal = ({
|
||||
|
||||
const message = error?.response?.data?.message || error.message || '';
|
||||
setPriceEstimation(null);
|
||||
setPriceError(
|
||||
t('价格计算失败') + (message ? `: ${message}` : ''),
|
||||
);
|
||||
setPriceError(t('价格计算失败') + (message ? `: ${message}` : ''));
|
||||
} finally {
|
||||
if (costRequestIdRef.current === requestId) {
|
||||
setCostLoading(false);
|
||||
@@ -269,11 +263,8 @@ const ExtendDurationModal = ({
|
||||
const newTotalTime = `${currentRemainingTime} + ${durationHours}${t('小时')}`;
|
||||
|
||||
const priceData = priceEstimation || {};
|
||||
const breakdown =
|
||||
priceData.price_breakdown || priceData.PriceBreakdown || {};
|
||||
const currencyLabel = (
|
||||
priceData.currency || priceData.Currency || 'USDC'
|
||||
)
|
||||
const breakdown = priceData.price_breakdown || priceData.PriceBreakdown || {};
|
||||
const currencyLabel = (priceData.currency || priceData.Currency || 'USDC')
|
||||
.toString()
|
||||
.toUpperCase();
|
||||
|
||||
@@ -316,7 +307,10 @@ const ExtendDurationModal = ({
|
||||
confirmLoading={loading}
|
||||
okButtonProps={{
|
||||
disabled:
|
||||
!deployment?.id || detailsLoading || !durationHours || durationHours < 1,
|
||||
!deployment?.id ||
|
||||
detailsLoading ||
|
||||
!durationHours ||
|
||||
durationHours < 1,
|
||||
}}
|
||||
width={600}
|
||||
className='extend-duration-modal'
|
||||
@@ -357,9 +351,7 @@ const ExtendDurationModal = ({
|
||||
<p>
|
||||
{t('延长容器时长将会产生额外费用,请确认您有足够的账户余额。')}
|
||||
</p>
|
||||
<p>
|
||||
{t('延长操作一旦确认无法撤销,费用将立即扣除。')}
|
||||
</p>
|
||||
<p>{t('延长操作一旦确认无法撤销,费用将立即扣除。')}</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
@@ -370,7 +362,9 @@ const ExtendDurationModal = ({
|
||||
onValueChange={(values) => {
|
||||
if (values.duration_hours !== undefined) {
|
||||
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) => {
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
<Col span={24} style={{ display: values.group === 'auto' ? 'block' : 'none' }}>
|
||||
<Col
|
||||
span={24}
|
||||
style={{
|
||||
display: values.group === 'auto' ? 'block' : 'none',
|
||||
}}
|
||||
>
|
||||
<Form.Switch
|
||||
field='cross_group_retry'
|
||||
label={t('跨分组重试')}
|
||||
@@ -561,7 +566,9 @@ const EditTokenModal = (props) => {
|
||||
placeholder={t('允许的IP,一行一个,不填写则不限制')}
|
||||
autosize
|
||||
rows={1}
|
||||
extraText={t('请勿过度信任此功能,IP可能被伪造,请配合nginx和cdn等网关使用')}
|
||||
extraText={t(
|
||||
'请勿过度信任此功能,IP可能被伪造,请配合nginx和cdn等网关使用',
|
||||
)}
|
||||
showClear
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
|
||||
@@ -20,6 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import React from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Space,
|
||||
Tag,
|
||||
Tooltip,
|
||||
@@ -71,6 +72,34 @@ function formatRatio(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
|
||||
function renderType(type, t) {
|
||||
switch (type) {
|
||||
@@ -250,6 +279,7 @@ export const getLogsColumns = ({
|
||||
COLUMN_KEYS,
|
||||
copyText,
|
||||
showUserInfoFunc,
|
||||
openChannelAffinityUsageCacheModal,
|
||||
isAdminUser,
|
||||
}) => {
|
||||
return [
|
||||
@@ -532,42 +562,39 @@ export const getLogsColumns = ({
|
||||
return isAdminUser ? (
|
||||
<Space>
|
||||
<div>{content}</div>
|
||||
{affinity ? (
|
||||
<Tooltip
|
||||
content={
|
||||
<div style={{ lineHeight: 1.6 }}>
|
||||
<Typography.Text strong>{t('渠道亲和性')}</Typography.Text>
|
||||
<div>
|
||||
<Typography.Text type='secondary'>
|
||||
{t('规则')}:{affinity.rule_name || '-'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type='secondary'>
|
||||
{t('分组')}:{affinity.selected_group || '-'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type='secondary'>
|
||||
{t('Key')}:
|
||||
{(affinity.key_source || '-') +
|
||||
':' +
|
||||
(affinity.key_path || affinity.key_key || '-') +
|
||||
(affinity.key_fp ? `#${affinity.key_fp}` : '')}
|
||||
</Typography.Text>
|
||||
{affinity ? (
|
||||
<Tooltip
|
||||
content={
|
||||
<div>
|
||||
{buildChannelAffinityTooltip(affinity, t)}
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<Button
|
||||
theme='borderless'
|
||||
size='small'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openChannelAffinityUsageCacheModal?.(affinity);
|
||||
}}
|
||||
>
|
||||
{t('查看详情')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Tag className='channel-affinity-tag' color='cyan' shape='circle'>
|
||||
<span className='channel-affinity-tag-content'>
|
||||
<IconStarStroked style={{ fontSize: 13 }} />
|
||||
{t('优选')}
|
||||
</span>
|
||||
</Tag>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Tag
|
||||
className='channel-affinity-tag'
|
||||
color='cyan'
|
||||
shape='circle'
|
||||
>
|
||||
<span className='channel-affinity-tag-content'>
|
||||
<IconStarStroked style={{ fontSize: 13 }} />
|
||||
{t('优选')}
|
||||
</span>
|
||||
</Tag>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Space>
|
||||
) : (
|
||||
|
||||
@@ -40,6 +40,7 @@ const LogsTable = (logsData) => {
|
||||
handlePageSizeChange,
|
||||
copyText,
|
||||
showUserInfoFunc,
|
||||
openChannelAffinityUsageCacheModal,
|
||||
hasExpandableRows,
|
||||
isAdminUser,
|
||||
t,
|
||||
@@ -53,9 +54,17 @@ const LogsTable = (logsData) => {
|
||||
COLUMN_KEYS,
|
||||
copyText,
|
||||
showUserInfoFunc,
|
||||
openChannelAffinityUsageCacheModal,
|
||||
isAdminUser,
|
||||
});
|
||||
}, [t, COLUMN_KEYS, copyText, showUserInfoFunc, isAdminUser]);
|
||||
}, [
|
||||
t,
|
||||
COLUMN_KEYS,
|
||||
copyText,
|
||||
showUserInfoFunc,
|
||||
openChannelAffinityUsageCacheModal,
|
||||
isAdminUser,
|
||||
]);
|
||||
|
||||
// Filter columns based on visibility settings
|
||||
const getVisibleColumns = () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ import LogsActions from './UsageLogsActions';
|
||||
import LogsFilters from './UsageLogsFilters';
|
||||
import ColumnSelectorModal from './modals/ColumnSelectorModal';
|
||||
import UserInfoModal from './modals/UserInfoModal';
|
||||
import ChannelAffinityUsageCacheModal from './modals/ChannelAffinityUsageCacheModal';
|
||||
import { useLogsData } from '../../../hooks/usage-logs/useUsageLogsData';
|
||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||
import { createCardProPagination } from '../../../helpers/utils';
|
||||
@@ -37,6 +38,7 @@ const LogsPage = () => {
|
||||
{/* Modals */}
|
||||
<ColumnSelectorModal {...logsData} />
|
||||
<UserInfoModal {...logsData} />
|
||||
<ChannelAffinityUsageCacheModal {...logsData} />
|
||||
|
||||
{/* Main Content */}
|
||||
<CardPro
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
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 = ({
|
||||
const onlineFormApiRef = useRef(null);
|
||||
const redeemFormApiRef = useRef(null);
|
||||
const showAmountSkeleton = useMinimumLoadingTime(amountLoading);
|
||||
console.log(' enabled screem ?', enableCreemTopUp, ' products ?', creemProducts);
|
||||
console.log(
|
||||
' enabled screem ?',
|
||||
enableCreemTopUp,
|
||||
' products ?',
|
||||
creemProducts,
|
||||
);
|
||||
return (
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
{/* 卡片头部 */}
|
||||
@@ -503,7 +508,8 @@ const RechargeCard = ({
|
||||
{t('充值额度')}: {product.quota}
|
||||
</div>
|
||||
<div className='text-lg font-semibold text-blue-600'>
|
||||
{product.currency === 'EUR' ? '€' : '$'}{product.price}
|
||||
{product.currency === 'EUR' ? '€' : '$'}
|
||||
{product.price}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -651,7 +651,8 @@ const TopUp = () => {
|
||||
{t('产品名称')}:{selectedCreemProduct.name}
|
||||
</p>
|
||||
<p>
|
||||
{t('价格')}:{selectedCreemProduct.currency === 'EUR' ? '€' : '$'}{selectedCreemProduct.price}
|
||||
{t('价格')}:{selectedCreemProduct.currency === 'EUR' ? '€' : '$'}
|
||||
{selectedCreemProduct.price}
|
||||
</p>
|
||||
<p>
|
||||
{t('充值额度')}:{selectedCreemProduct.quota}
|
||||
|
||||
Reference in New Issue
Block a user