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:
Seefs
2026-02-02 14:37:31 +08:00
committed by GitHub
parent 6c0e9403a2
commit 540cf6c991
61 changed files with 2012 additions and 1004 deletions

View File

@@ -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('');

View File

@@ -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;

View File

@@ -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>
</>

View File

@@ -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}

View File

@@ -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>
)}
</>