Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51086c3ba6 | |||
| 7a4c213b65 | |||
| 610853e9c8 | |||
| 62daf16b19 | |||
| d19ab54e32 | |||
| 8f0f0c0d27 | |||
| 9bf32ef581 | |||
| 9469c4973c | |||
| 4949d986c7 | |||
| 949d462534 | |||
| fc2e2c1aff | |||
| e7506ee9cf | |||
| 13fd901d17 | |||
| bce87295b6 | |||
| 0ac7406db8 | |||
| ef9c5b3acb | |||
| c9529d00d5 | |||
| f73da57acb | |||
| 8a1e437ce9 | |||
| 607d5fc25e |
+1
-1
@@ -59,7 +59,7 @@
|
||||
# 设置 Dify 渠道是否输出工作流和节点信息到客户端
|
||||
# DIFY_DEBUG=true
|
||||
# 设置流式一次回复的超时时间
|
||||
# STREAMING_TIMEOUT=90
|
||||
# STREAMING_TIMEOUT=120
|
||||
|
||||
|
||||
# 节点类型
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@ This version supports multiple models, please refer to [API Documentation-Relay
|
||||
For detailed configuration instructions, please refer to [Installation Guide-Environment Variables Configuration](https://docs.newapi.pro/installation/environment-variables):
|
||||
|
||||
- `GENERATE_DEFAULT_TOKEN`: Whether to generate initial tokens for newly registered users, default is `false`
|
||||
- `STREAMING_TIMEOUT`: Streaming response timeout, default is 60 seconds
|
||||
- `STREAMING_TIMEOUT`: Streaming response timeout, default is 120 seconds
|
||||
- `DIFY_DEBUG`: Whether to output workflow and node information for Dify channels, default is `true`
|
||||
- `FORCE_STREAM_OPTION`: Whether to override client stream_options parameter, default is `true`
|
||||
- `GET_MEDIA_TOKEN`: Whether to count image tokens, default is `true`
|
||||
|
||||
@@ -103,7 +103,7 @@ New API提供了丰富的功能,详细特性请参考[特性说明](https://do
|
||||
详细配置说明请参考[安装指南-环境变量配置](https://docs.newapi.pro/installation/environment-variables):
|
||||
|
||||
- `GENERATE_DEFAULT_TOKEN`:是否为新注册用户生成初始令牌,默认为 `false`
|
||||
- `STREAMING_TIMEOUT`:流式回复超时时间,默认60秒
|
||||
- `STREAMING_TIMEOUT`:流式回复超时时间,默认120秒
|
||||
- `DIFY_DEBUG`:Dify渠道是否输出工作流和节点信息,默认 `true`
|
||||
- `FORCE_STREAM_OPTION`:是否覆盖客户端stream_options参数,默认 `true`
|
||||
- `GET_MEDIA_TOKEN`:是否统计图片token,默认 `true`
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ var ErrorLogEnabled bool
|
||||
//}
|
||||
|
||||
func InitEnv() {
|
||||
StreamingTimeout = common.GetEnvOrDefault("STREAMING_TIMEOUT", 60)
|
||||
StreamingTimeout = common.GetEnvOrDefault("STREAMING_TIMEOUT", 120)
|
||||
DifyDebug = common.GetEnvOrDefaultBool("DIFY_DEBUG", true)
|
||||
MaxFileDownloadMB = common.GetEnvOrDefault("MAX_FILE_DOWNLOAD_MB", 20)
|
||||
// ForceStreamOption 覆盖请求参数,强制返回usage信息
|
||||
|
||||
+124
-21
@@ -40,6 +40,17 @@ type OpenAIModelsResponse struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
func parseStatusFilter(statusParam string) int {
|
||||
switch strings.ToLower(statusParam) {
|
||||
case "enabled", "1":
|
||||
return common.ChannelStatusEnabled
|
||||
case "disabled", "0":
|
||||
return 0
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
func GetAllChannels(c *gin.Context) {
|
||||
p, _ := strconv.Atoi(c.Query("p"))
|
||||
pageSize, _ := strconv.Atoi(c.Query("page_size"))
|
||||
@@ -52,6 +63,9 @@ func GetAllChannels(c *gin.Context) {
|
||||
channelData := make([]*model.Channel, 0)
|
||||
idSort, _ := strconv.ParseBool(c.Query("id_sort"))
|
||||
enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode"))
|
||||
statusParam := c.Query("status")
|
||||
// statusFilter: -1 all, 1 enabled, 0 disabled (include auto & manual)
|
||||
statusFilter := parseStatusFilter(statusParam)
|
||||
// type filter
|
||||
typeStr := c.Query("type")
|
||||
typeFilter := -1
|
||||
@@ -64,42 +78,75 @@ func GetAllChannels(c *gin.Context) {
|
||||
var total int64
|
||||
|
||||
if enableTagMode {
|
||||
// tag 分页:先分页 tag,再取各 tag 下 channels
|
||||
tags, err := model.GetPaginatedTags((p-1)*pageSize, pageSize)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
for _, tag := range tags {
|
||||
if tag != nil && *tag != "" {
|
||||
tagChannel, err := model.GetChannelsByTag(*tag, idSort)
|
||||
if err == nil {
|
||||
channelData = append(channelData, tagChannel...)
|
||||
}
|
||||
if tag == nil || *tag == "" {
|
||||
continue
|
||||
}
|
||||
tagChannels, err := model.GetChannelsByTag(*tag, idSort)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
filtered := make([]*model.Channel, 0)
|
||||
for _, ch := range tagChannels {
|
||||
if statusFilter == common.ChannelStatusEnabled && ch.Status != common.ChannelStatusEnabled {
|
||||
continue
|
||||
}
|
||||
if statusFilter == 0 && ch.Status == common.ChannelStatusEnabled {
|
||||
continue
|
||||
}
|
||||
if typeFilter >= 0 && ch.Type != typeFilter {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, ch)
|
||||
}
|
||||
channelData = append(channelData, filtered...)
|
||||
}
|
||||
// 计算 tag 总数用于分页
|
||||
total, _ = model.CountAllTags()
|
||||
} else if typeFilter >= 0 {
|
||||
channels, err := model.GetChannelsByType((p-1)*pageSize, pageSize, idSort, typeFilter)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
channelData = channels
|
||||
total, _ = model.CountChannelsByType(typeFilter)
|
||||
} else {
|
||||
channels, err := model.GetAllChannels((p-1)*pageSize, pageSize, false, idSort)
|
||||
baseQuery := model.DB.Model(&model.Channel{})
|
||||
if typeFilter >= 0 {
|
||||
baseQuery = baseQuery.Where("type = ?", typeFilter)
|
||||
}
|
||||
if statusFilter == common.ChannelStatusEnabled {
|
||||
baseQuery = baseQuery.Where("status = ?", common.ChannelStatusEnabled)
|
||||
} else if statusFilter == 0 {
|
||||
baseQuery = baseQuery.Where("status != ?", common.ChannelStatusEnabled)
|
||||
}
|
||||
|
||||
baseQuery.Count(&total)
|
||||
|
||||
order := "priority desc"
|
||||
if idSort {
|
||||
order = "id desc"
|
||||
}
|
||||
|
||||
err := baseQuery.Order(order).Limit(pageSize).Offset((p-1)*pageSize).Omit("key").Find(&channelData).Error
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
channelData = channels
|
||||
total, _ = model.CountAllChannels()
|
||||
}
|
||||
|
||||
// calculate type counts
|
||||
typeCounts, _ := model.CountChannelsGroupByType()
|
||||
countQuery := model.DB.Model(&model.Channel{})
|
||||
if statusFilter == common.ChannelStatusEnabled {
|
||||
countQuery = countQuery.Where("status = ?", common.ChannelStatusEnabled)
|
||||
} else if statusFilter == 0 {
|
||||
countQuery = countQuery.Where("status != ?", common.ChannelStatusEnabled)
|
||||
}
|
||||
var results []struct {
|
||||
Type int64
|
||||
Count int64
|
||||
}
|
||||
_ = countQuery.Select("type, count(*) as count").Group("type").Find(&results).Error
|
||||
typeCounts := make(map[int64]int64)
|
||||
for _, r := range results {
|
||||
typeCounts[r.Type] = r.Count
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
@@ -199,6 +246,8 @@ func SearchChannels(c *gin.Context) {
|
||||
keyword := c.Query("keyword")
|
||||
group := c.Query("group")
|
||||
modelKeyword := c.Query("model")
|
||||
statusParam := c.Query("status")
|
||||
statusFilter := parseStatusFilter(statusParam)
|
||||
idSort, _ := strconv.ParseBool(c.Query("id_sort"))
|
||||
enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode"))
|
||||
channelData := make([]*model.Channel, 0)
|
||||
@@ -231,17 +280,71 @@ func SearchChannels(c *gin.Context) {
|
||||
channelData = channels
|
||||
}
|
||||
|
||||
if statusFilter == common.ChannelStatusEnabled || statusFilter == 0 {
|
||||
filtered := make([]*model.Channel, 0, len(channelData))
|
||||
for _, ch := range channelData {
|
||||
if statusFilter == common.ChannelStatusEnabled && ch.Status != common.ChannelStatusEnabled {
|
||||
continue
|
||||
}
|
||||
if statusFilter == 0 && ch.Status == common.ChannelStatusEnabled {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, ch)
|
||||
}
|
||||
channelData = filtered
|
||||
}
|
||||
|
||||
// calculate type counts for search results
|
||||
typeCounts := make(map[int64]int64)
|
||||
for _, channel := range channelData {
|
||||
typeCounts[int64(channel.Type)]++
|
||||
}
|
||||
|
||||
typeParam := c.Query("type")
|
||||
typeFilter := -1
|
||||
if typeParam != "" {
|
||||
if tp, err := strconv.Atoi(typeParam); err == nil {
|
||||
typeFilter = tp
|
||||
}
|
||||
}
|
||||
|
||||
if typeFilter >= 0 {
|
||||
filtered := make([]*model.Channel, 0, len(channelData))
|
||||
for _, ch := range channelData {
|
||||
if ch.Type == typeFilter {
|
||||
filtered = append(filtered, ch)
|
||||
}
|
||||
}
|
||||
channelData = filtered
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("p", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
total := len(channelData)
|
||||
startIdx := (page - 1) * pageSize
|
||||
if startIdx > total {
|
||||
startIdx = total
|
||||
}
|
||||
endIdx := startIdx + pageSize
|
||||
if endIdx > total {
|
||||
endIdx = total
|
||||
}
|
||||
|
||||
pagedData := channelData[startIdx:endIdx]
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": gin.H{
|
||||
"items": channelData,
|
||||
"items": pagedData,
|
||||
"total": total,
|
||||
"type_counts": typeCounts,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -501,13 +501,13 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
|
||||
} else if strings.HasPrefix(name, "gemini-2.0") {
|
||||
return 4, true
|
||||
} else if strings.HasPrefix(name, "gemini-2.5-pro") { // 移除preview来增加兼容性,这里假设正式版的倍率和preview一致
|
||||
return 8, true
|
||||
return 8, false
|
||||
} else if strings.HasPrefix(name, "gemini-2.5-flash") { // 处理不同的flash模型倍率
|
||||
if strings.HasPrefix(name, "gemini-2.5-flash-preview") {
|
||||
if strings.HasSuffix(name, "-nothinking") {
|
||||
return 4, true
|
||||
return 4, false
|
||||
}
|
||||
return 3.5 / 0.15, true
|
||||
return 3.5 / 0.15, false
|
||||
}
|
||||
if strings.HasPrefix(name, "gemini-2.5-flash-lite-preview") {
|
||||
return 4, true
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
Tags,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../../constants/index.js';
|
||||
import { CHANNEL_OPTIONS, ITEMS_PER_PAGE, MODEL_TABLE_PAGE_SIZE } from '../../constants/index.js';
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
@@ -40,7 +40,8 @@ import {
|
||||
Card,
|
||||
Form,
|
||||
Tabs,
|
||||
TabPane
|
||||
TabPane,
|
||||
Select,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
@@ -62,7 +63,7 @@ import {
|
||||
IconCopy,
|
||||
IconSmallTriangleRight
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { loadChannelModels } from '../../helpers/index.js';
|
||||
import { loadChannelModels, isMobile, copy } from '../../helpers';
|
||||
import EditTagModal from '../../pages/Channel/EditTagModal.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTableCompactMode } from '../../hooks/useTableCompactMode';
|
||||
@@ -189,6 +190,11 @@ const ChannelsTable = () => {
|
||||
const [visibleColumns, setVisibleColumns] = useState({});
|
||||
const [showColumnSelector, setShowColumnSelector] = useState(false);
|
||||
|
||||
// 状态筛选 all / enabled / disabled
|
||||
const [statusFilter, setStatusFilter] = useState(
|
||||
localStorage.getItem('channel-status-filter') || 'all'
|
||||
);
|
||||
|
||||
// Load saved column preferences from localStorage
|
||||
useEffect(() => {
|
||||
const savedColumns = localStorage.getItem('channels-table-columns');
|
||||
@@ -678,9 +684,11 @@ const ChannelsTable = () => {
|
||||
const [modelSearchKeyword, setModelSearchKeyword] = useState('');
|
||||
const [modelTestResults, setModelTestResults] = useState({});
|
||||
const [testingModels, setTestingModels] = useState(new Set());
|
||||
const [selectedModelKeys, setSelectedModelKeys] = useState([]);
|
||||
const [isBatchTesting, setIsBatchTesting] = useState(false);
|
||||
const [testQueue, setTestQueue] = useState([]);
|
||||
const [isProcessingQueue, setIsProcessingQueue] = useState(false);
|
||||
const [modelTablePage, setModelTablePage] = useState(1);
|
||||
const [activeTypeKey, setActiveTypeKey] = useState('all');
|
||||
const [typeCounts, setTypeCounts] = useState({});
|
||||
const requestCounter = useRef(0);
|
||||
@@ -691,6 +699,7 @@ const ChannelsTable = () => {
|
||||
searchGroup: '',
|
||||
searchModel: '',
|
||||
};
|
||||
const allSelectingRef = useRef(false);
|
||||
|
||||
// Filter columns based on visibility settings
|
||||
const getVisibleColumns = () => {
|
||||
@@ -867,12 +876,30 @@ const ChannelsTable = () => {
|
||||
setChannels(channelDates);
|
||||
};
|
||||
|
||||
const loadChannels = async (page, pageSize, idSort, enableTagMode, typeKey = activeTypeKey) => {
|
||||
const loadChannels = async (
|
||||
page,
|
||||
pageSize,
|
||||
idSort,
|
||||
enableTagMode,
|
||||
typeKey = activeTypeKey,
|
||||
statusF,
|
||||
) => {
|
||||
if (statusF === undefined) statusF = statusFilter;
|
||||
|
||||
const { searchKeyword, searchGroup, searchModel } = getFormValues();
|
||||
if (searchKeyword !== '' || searchGroup !== '' || searchModel !== '') {
|
||||
setLoading(true);
|
||||
await searchChannels(enableTagMode, typeKey, statusF, page, pageSize, idSort);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const reqId = ++requestCounter.current; // 记录当前请求序号
|
||||
setLoading(true);
|
||||
const typeParam = (!enableTagMode && typeKey !== 'all') ? `&type=${typeKey}` : '';
|
||||
const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : '';
|
||||
const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
|
||||
const res = await API.get(
|
||||
`/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}`,
|
||||
`/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}${statusParam}`,
|
||||
);
|
||||
if (res === undefined || reqId !== requestCounter.current) {
|
||||
return;
|
||||
@@ -923,7 +950,7 @@ const ChannelsTable = () => {
|
||||
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
||||
await loadChannels(activePage, pageSize, idSort, enableTagMode);
|
||||
} else {
|
||||
await searchChannels(enableTagMode);
|
||||
await searchChannels(enableTagMode, activeTypeKey, statusFilter, activePage, pageSize, idSort);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1029,7 +1056,7 @@ const ChannelsTable = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 获取表单值的辅助函数,确保所有值都是字符串
|
||||
// 获取表单值的辅助函数
|
||||
const getFormValues = () => {
|
||||
const formValues = formApi ? formApi.getValues() : {};
|
||||
return {
|
||||
@@ -1039,27 +1066,35 @@ const ChannelsTable = () => {
|
||||
};
|
||||
};
|
||||
|
||||
const searchChannels = async (enableTagMode) => {
|
||||
const searchChannels = async (
|
||||
enableTagMode,
|
||||
typeKey = activeTypeKey,
|
||||
statusF = statusFilter,
|
||||
page = 1,
|
||||
pageSz = pageSize,
|
||||
sortFlag = idSort,
|
||||
) => {
|
||||
const { searchKeyword, searchGroup, searchModel } = getFormValues();
|
||||
|
||||
setSearching(true);
|
||||
try {
|
||||
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
||||
await loadChannels(activePage - 1, pageSize, idSort, enableTagMode);
|
||||
await loadChannels(page, pageSz, sortFlag, enableTagMode, typeKey, statusF);
|
||||
return;
|
||||
}
|
||||
|
||||
const typeParam = (!enableTagMode && activeTypeKey !== 'all') ? `&type=${activeTypeKey}` : '';
|
||||
const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : '';
|
||||
const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
|
||||
const res = await API.get(
|
||||
`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}`,
|
||||
`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${sortFlag}&tag_mode=${enableTagMode}&p=${page}&page_size=${pageSz}${typeParam}${statusParam}`,
|
||||
);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
const { items = [], type_counts = {} } = data;
|
||||
const { items = [], total = 0, type_counts = {} } = data;
|
||||
const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0);
|
||||
setTypeCounts({ ...type_counts, all: sumAll });
|
||||
setChannelFormat(items, enableTagMode);
|
||||
setActivePage(1);
|
||||
setChannelCount(total);
|
||||
setActivePage(page);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
@@ -1099,7 +1134,22 @@ const ChannelsTable = () => {
|
||||
const processTestQueue = async () => {
|
||||
if (!isProcessingQueue || testQueue.length === 0) return;
|
||||
|
||||
const { channel, model } = testQueue[0];
|
||||
const { channel, model, indexInFiltered } = testQueue[0];
|
||||
|
||||
// 自动翻页到正在测试的模型所在页
|
||||
if (currentTestChannel && currentTestChannel.id === channel.id) {
|
||||
let pageNo;
|
||||
if (indexInFiltered !== undefined) {
|
||||
pageNo = Math.floor(indexInFiltered / MODEL_TABLE_PAGE_SIZE) + 1;
|
||||
} else {
|
||||
const filteredModelsList = currentTestChannel.models
|
||||
.split(',')
|
||||
.filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase()));
|
||||
const modelIdx = filteredModelsList.indexOf(model);
|
||||
pageNo = modelIdx !== -1 ? Math.floor(modelIdx / MODEL_TABLE_PAGE_SIZE) + 1 : 1;
|
||||
}
|
||||
setModelTablePage(pageNo);
|
||||
}
|
||||
|
||||
try {
|
||||
setTestingModels(prev => new Set([...prev, model]));
|
||||
@@ -1162,16 +1212,22 @@ const ChannelsTable = () => {
|
||||
|
||||
setIsBatchTesting(true);
|
||||
|
||||
const models = currentTestChannel.models
|
||||
// 重置分页到第一页
|
||||
setModelTablePage(1);
|
||||
|
||||
const filteredModels = currentTestChannel.models
|
||||
.split(',')
|
||||
.filter((model) =>
|
||||
model.toLowerCase().includes(modelSearchKeyword.toLowerCase())
|
||||
model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
|
||||
);
|
||||
|
||||
setTestQueue(models.map(model => ({
|
||||
channel: currentTestChannel,
|
||||
model
|
||||
})));
|
||||
setTestQueue(
|
||||
filteredModels.map((model, idx) => ({
|
||||
channel: currentTestChannel,
|
||||
model,
|
||||
indexInFiltered: idx, // 记录在过滤列表中的顺序
|
||||
})),
|
||||
);
|
||||
setIsProcessingQueue(true);
|
||||
};
|
||||
|
||||
@@ -1185,6 +1241,8 @@ const ChannelsTable = () => {
|
||||
} else {
|
||||
setShowModelTestModal(false);
|
||||
setModelSearchKeyword('');
|
||||
setSelectedModelKeys([]);
|
||||
setModelTablePage(1);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1265,32 +1323,31 @@ const ChannelsTable = () => {
|
||||
};
|
||||
|
||||
let pageData = channels;
|
||||
if (activeTypeKey !== 'all') {
|
||||
const typeVal = parseInt(activeTypeKey);
|
||||
if (!isNaN(typeVal)) {
|
||||
pageData = pageData.filter((ch) => {
|
||||
if (ch.children !== undefined) {
|
||||
return ch.children.some((c) => c.type === typeVal);
|
||||
}
|
||||
return ch.type === typeVal;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
const { searchKeyword, searchGroup, searchModel } = getFormValues();
|
||||
setActivePage(page);
|
||||
loadChannels(page, pageSize, idSort, enableTagMode).then(() => { });
|
||||
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
||||
loadChannels(page, pageSize, idSort, enableTagMode).then(() => { });
|
||||
} else {
|
||||
searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageSizeChange = async (size) => {
|
||||
localStorage.setItem('page-size', size + '');
|
||||
setPageSize(size);
|
||||
setActivePage(1);
|
||||
loadChannels(1, size, idSort, enableTagMode)
|
||||
.then()
|
||||
.catch((reason) => {
|
||||
showError(reason);
|
||||
});
|
||||
const { searchKeyword, searchGroup, searchModel } = getFormValues();
|
||||
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
||||
loadChannels(1, size, idSort, enableTagMode)
|
||||
.then()
|
||||
.catch((reason) => {
|
||||
showError(reason);
|
||||
});
|
||||
} else {
|
||||
searchChannels(enableTagMode, activeTypeKey, statusFilter, 1, size, idSort);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchGroups = async () => {
|
||||
@@ -1471,6 +1528,7 @@ const ChannelsTable = () => {
|
||||
<div className="flex flex-col md:flex-row justify-between gap-4">
|
||||
<div className="flex flex-wrap md:flex-nowrap items-center gap-2 w-full md:w-auto order-2 md:order-1">
|
||||
<Button
|
||||
size='small'
|
||||
disabled={!enableBatchDelete}
|
||||
theme='light'
|
||||
type='danger'
|
||||
@@ -1487,6 +1545,7 @@ const ChannelsTable = () => {
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size='small'
|
||||
disabled={!enableBatchDelete}
|
||||
theme='light'
|
||||
type='primary'
|
||||
@@ -1497,11 +1556,13 @@ const ChannelsTable = () => {
|
||||
</Button>
|
||||
|
||||
<Dropdown
|
||||
size='small'
|
||||
trigger='click'
|
||||
render={
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item>
|
||||
<Button
|
||||
size='small'
|
||||
theme='light'
|
||||
type='warning'
|
||||
className="!rounded-full w-full"
|
||||
@@ -1520,6 +1581,7 @@ const ChannelsTable = () => {
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item>
|
||||
<Button
|
||||
size='small'
|
||||
theme='light'
|
||||
type='secondary'
|
||||
className="!rounded-full w-full"
|
||||
@@ -1538,6 +1600,7 @@ const ChannelsTable = () => {
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item>
|
||||
<Button
|
||||
size='small'
|
||||
theme='light'
|
||||
type='danger'
|
||||
className="!rounded-full w-full"
|
||||
@@ -1556,6 +1619,7 @@ const ChannelsTable = () => {
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item>
|
||||
<Button
|
||||
size='small'
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
className="!rounded-full w-full"
|
||||
@@ -1575,15 +1639,15 @@ const ChannelsTable = () => {
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
>
|
||||
<Button theme='light' type='tertiary' icon={<IconSetting />} className="!rounded-full w-full md:w-auto">
|
||||
<Button size='small' theme='light' type='tertiary' className="!rounded-full w-full md:w-auto">
|
||||
{t('批量操作')}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
|
||||
<Button
|
||||
size='small'
|
||||
theme='light'
|
||||
type='secondary'
|
||||
icon={<IconDescend />}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
onClick={() => setCompactMode(!compactMode)}
|
||||
>
|
||||
@@ -1597,11 +1661,17 @@ const ChannelsTable = () => {
|
||||
{t('使用ID排序')}
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
size='small'
|
||||
checked={idSort}
|
||||
onChange={(v) => {
|
||||
localStorage.setItem('id-sort', v + '');
|
||||
setIdSort(v);
|
||||
loadChannels(activePage, pageSize, v, enableTagMode);
|
||||
const { searchKeyword, searchGroup, searchModel } = getFormValues();
|
||||
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
||||
loadChannels(activePage, pageSize, v, enableTagMode);
|
||||
} else {
|
||||
searchChannels(enableTagMode, activeTypeKey, statusFilter, activePage, pageSize, v);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -1611,6 +1681,7 @@ const ChannelsTable = () => {
|
||||
{t('开启批量操作')}
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
size='small'
|
||||
checked={enableBatchDelete}
|
||||
onChange={(v) => {
|
||||
localStorage.setItem('enable-batch-delete', v + '');
|
||||
@@ -1624,6 +1695,7 @@ const ChannelsTable = () => {
|
||||
{t('标签聚合模式')}
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
size='small'
|
||||
checked={enableTagMode}
|
||||
onChange={(v) => {
|
||||
localStorage.setItem('enable-tag-mode', v + '');
|
||||
@@ -1633,6 +1705,27 @@ const ChannelsTable = () => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 状态筛选器 */}
|
||||
<div className="flex items-center justify-between w-full md:w-auto">
|
||||
<Typography.Text strong className="mr-2">
|
||||
{t('状态筛选')}
|
||||
</Typography.Text>
|
||||
<Select
|
||||
size='small'
|
||||
value={statusFilter}
|
||||
onChange={(v) => {
|
||||
localStorage.setItem('channel-status-filter', v);
|
||||
setStatusFilter(v);
|
||||
setActivePage(1);
|
||||
loadChannels(1, pageSize, idSort, enableTagMode, activeTypeKey, v);
|
||||
}}
|
||||
>
|
||||
<Select.Option value="all">{t('全部')}</Select.Option>
|
||||
<Select.Option value="enabled">{t('已启用')}</Select.Option>
|
||||
<Select.Option value="disabled">{t('已禁用')}</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1641,6 +1734,7 @@ const ChannelsTable = () => {
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
|
||||
<div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
|
||||
<Button
|
||||
size='small'
|
||||
theme='light'
|
||||
type='primary'
|
||||
icon={<IconPlus />}
|
||||
@@ -1656,9 +1750,9 @@ const ChannelsTable = () => {
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size='small'
|
||||
theme='light'
|
||||
type='primary'
|
||||
icon={<IconRefresh />}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
onClick={refresh}
|
||||
>
|
||||
@@ -1666,9 +1760,9 @@ const ChannelsTable = () => {
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size='small'
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
icon={<IconSetting />}
|
||||
onClick={() => setShowColumnSelector(true)}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
>
|
||||
@@ -1690,9 +1784,10 @@ const ChannelsTable = () => {
|
||||
>
|
||||
<div className="relative w-full md:w-64">
|
||||
<Form.Input
|
||||
size='small'
|
||||
field="searchKeyword"
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('搜索渠道的 ID,名称,密钥和API地址 ...')}
|
||||
placeholder={t('渠道ID,名称,密钥,API地址')}
|
||||
className="!rounded-full"
|
||||
showClear
|
||||
pure
|
||||
@@ -1700,6 +1795,7 @@ const ChannelsTable = () => {
|
||||
</div>
|
||||
<div className="w-full md:w-48">
|
||||
<Form.Input
|
||||
size='small'
|
||||
field="searchModel"
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('模型关键字')}
|
||||
@@ -1708,8 +1804,9 @@ const ChannelsTable = () => {
|
||||
pure
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full md:w-48">
|
||||
<div className="w-full md:w-32">
|
||||
<Form.Select
|
||||
size='small'
|
||||
field="searchGroup"
|
||||
placeholder={t('选择分组')}
|
||||
optionList={[
|
||||
@@ -1728,6 +1825,7 @@ const ChannelsTable = () => {
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size='small'
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading || searching}
|
||||
@@ -1736,6 +1834,7 @@ const ChannelsTable = () => {
|
||||
{t('查询')}
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
theme='light'
|
||||
onClick={() => {
|
||||
if (formApi) {
|
||||
@@ -1819,7 +1918,7 @@ const ChannelsTable = () => {
|
||||
}
|
||||
className="rounded-xl overflow-hidden"
|
||||
size="middle"
|
||||
loading={loading}
|
||||
loading={loading || searching}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -1855,13 +1954,73 @@ const ChannelsTable = () => {
|
||||
<Modal
|
||||
title={
|
||||
currentTestChannel && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Typography.Text strong className="!text-[var(--semi-color-text-0)] !text-base">
|
||||
{currentTestChannel.name} {t('渠道的模型测试')}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="tertiary" className="!text-xs flex items-center">
|
||||
{t('共')} {currentTestChannel.models.split(',').length} {t('个模型')}
|
||||
</Typography.Text>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<Typography.Text strong className="!text-[var(--semi-color-text-0)] !text-base">
|
||||
{currentTestChannel.name} {t('渠道的模型测试')}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="tertiary" className="!text-xs flex items-center">
|
||||
{t('共')} {currentTestChannel.models.split(',').length} {t('个模型')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
{/* 搜索与操作按钮 */}
|
||||
<div className="flex items-center justify-end gap-2 w-full">
|
||||
<Input
|
||||
placeholder={t('搜索模型...')}
|
||||
value={modelSearchKeyword}
|
||||
onChange={(v) => {
|
||||
setModelSearchKeyword(v);
|
||||
setModelTablePage(1);
|
||||
}}
|
||||
className="!w-full !rounded-full"
|
||||
prefix={<IconSearch />}
|
||||
showClear
|
||||
/>
|
||||
|
||||
<Button
|
||||
theme='light'
|
||||
icon={<IconCopy />}
|
||||
className="!rounded-full"
|
||||
onClick={() => {
|
||||
if (selectedModelKeys.length === 0) {
|
||||
showError(t('请先选择模型!'));
|
||||
return;
|
||||
}
|
||||
copy(selectedModelKeys.join(',')).then((ok) => {
|
||||
if (ok) {
|
||||
showSuccess(t('已复制 ${count} 个模型').replace('${count}', selectedModelKeys.length));
|
||||
} else {
|
||||
showError(t('复制失败,请手动复制'));
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('复制已选')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
className="!rounded-full"
|
||||
onClick={() => {
|
||||
if (!currentTestChannel) return;
|
||||
const successKeys = currentTestChannel.models
|
||||
.split(',')
|
||||
.filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase()))
|
||||
.filter((m) => {
|
||||
const result = modelTestResults[`${currentTestChannel.id}-${m}`];
|
||||
return result && result.success;
|
||||
});
|
||||
if (successKeys.length === 0) {
|
||||
showInfo(t('暂无成功模型'));
|
||||
}
|
||||
setSelectedModelKeys(successKeys);
|
||||
}}
|
||||
>
|
||||
{t('选择成功')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1911,22 +2070,11 @@ const ChannelsTable = () => {
|
||||
}
|
||||
maskClosable={!isBatchTesting}
|
||||
className="!rounded-lg"
|
||||
size="large"
|
||||
size={isMobile() ? 'full-width' : 'large'}
|
||||
>
|
||||
<div className="max-h-[600px] overflow-y-auto">
|
||||
<div className="model-test-scroll">
|
||||
{currentTestChannel && (
|
||||
<div>
|
||||
<div className="flex items-center justify-end mb-2">
|
||||
<Input
|
||||
placeholder={t('搜索模型...')}
|
||||
value={modelSearchKeyword}
|
||||
onChange={(v) => setModelSearchKeyword(v)}
|
||||
className="w-64 !rounded-full"
|
||||
prefix={<IconSearch />}
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={[
|
||||
{
|
||||
@@ -2000,16 +2148,47 @@ const ChannelsTable = () => {
|
||||
}
|
||||
}
|
||||
]}
|
||||
dataSource={currentTestChannel.models
|
||||
.split(',')
|
||||
.filter((model) =>
|
||||
model.toLowerCase().includes(modelSearchKeyword.toLowerCase())
|
||||
)
|
||||
.map((model) => ({
|
||||
dataSource={(() => {
|
||||
const filtered = currentTestChannel.models
|
||||
.split(',')
|
||||
.filter((model) =>
|
||||
model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
|
||||
);
|
||||
const start = (modelTablePage - 1) * MODEL_TABLE_PAGE_SIZE;
|
||||
const end = start + MODEL_TABLE_PAGE_SIZE;
|
||||
return filtered.slice(start, end).map((model) => ({
|
||||
model,
|
||||
key: model
|
||||
}))}
|
||||
pagination={false}
|
||||
key: model,
|
||||
}));
|
||||
})()}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedModelKeys,
|
||||
onChange: (keys) => {
|
||||
if (allSelectingRef.current) {
|
||||
allSelectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
setSelectedModelKeys(keys);
|
||||
},
|
||||
onSelectAll: (checked) => {
|
||||
const filtered = currentTestChannel.models
|
||||
.split(',')
|
||||
.filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase()));
|
||||
allSelectingRef.current = true;
|
||||
setSelectedModelKeys(checked ? filtered : []);
|
||||
},
|
||||
}}
|
||||
pagination={{
|
||||
currentPage: modelTablePage,
|
||||
pageSize: MODEL_TABLE_PAGE_SIZE,
|
||||
total: currentTestChannel.models
|
||||
.split(',')
|
||||
.filter((model) =>
|
||||
model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
|
||||
).length,
|
||||
showSizeChanger: false,
|
||||
onPageChange: (page) => setModelTablePage(page),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -131,3 +131,5 @@ export const CHANNEL_OPTIONS = [
|
||||
label: '可灵',
|
||||
},
|
||||
];
|
||||
|
||||
export const MODEL_TABLE_PAGE_SIZE = 10;
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
"已成功开始测试所有已启用通道,请刷新页面查看结果。": "Successfully started testing all enabled channels. Please refresh page to view results.",
|
||||
"通道 ${name} 余额更新成功!": "Channel ${name} quota updated successfully!",
|
||||
"已更新完毕所有已启用通道余额!": "Updated quota for all enabled channels!",
|
||||
"搜索渠道的 ID,名称,密钥和API地址 ...": "Search channel ID, name, key and Base URL...",
|
||||
"渠道ID,名称,密钥,API地址": "Channel ID, name, key, Base URL",
|
||||
"名称": "Name",
|
||||
"分组": "Group",
|
||||
"类型": "Type",
|
||||
@@ -428,6 +428,7 @@
|
||||
"填入基础模型": "Fill in the basic model",
|
||||
"填入所有模型": "Fill in all models",
|
||||
"清除所有模型": "Clear all models",
|
||||
"复制所有模型": "Copy all models",
|
||||
"密钥": "Key",
|
||||
"请输入密钥": "Please enter the key",
|
||||
"批量创建": "Batch Create",
|
||||
@@ -1726,5 +1727,21 @@
|
||||
"放大编辑": "Expand editor",
|
||||
"编辑公告内容": "Edit announcement content",
|
||||
"自适应列表": "Adaptive list",
|
||||
"紧凑列表": "Compact list"
|
||||
"紧凑列表": "Compact list",
|
||||
"仅显示矛盾倍率": "Only show conflicting ratios",
|
||||
"矛盾": "Conflict",
|
||||
"确认冲突项修改": "Confirm conflict item modification",
|
||||
"该模型存在固定价格与倍率计费方式冲突,请确认选择": "The model has a fixed price and ratio billing method conflict, please confirm the selection",
|
||||
"当前计费": "Current billing",
|
||||
"修改为": "Modify to",
|
||||
"状态筛选": "Status filter",
|
||||
"没有模型可以复制": "No models to copy",
|
||||
"模型列表已复制到剪贴板": "Model list copied to clipboard",
|
||||
"复制失败": "Copy failed",
|
||||
"复制已选": "Copy selected",
|
||||
"选择成功": "Selection successful",
|
||||
"暂无成功模型": "No successful models",
|
||||
"请先选择模型!": "Please select a model first!",
|
||||
"已复制 ${count} 个模型": "Copied ${count} models",
|
||||
"复制失败,请手动复制": "Copy failed, please copy manually"
|
||||
}
|
||||
@@ -375,6 +375,7 @@ code {
|
||||
}
|
||||
|
||||
/* 隐藏卡片内容区域的滚动条 */
|
||||
.model-test-scroll,
|
||||
.card-content-scroll,
|
||||
.model-settings-scroll,
|
||||
.thinking-content-scroll,
|
||||
@@ -385,6 +386,7 @@ code {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.model-test-scroll::-webkit-scrollbar,
|
||||
.card-content-scroll::-webkit-scrollbar,
|
||||
.model-settings-scroll::-webkit-scrollbar,
|
||||
.thinking-content-scroll::-webkit-scrollbar,
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
Card,
|
||||
Tag,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { getChannelModels } from '../../helpers';
|
||||
import { getChannelModels, copy } from '../../helpers';
|
||||
import {
|
||||
IconSave,
|
||||
IconClose,
|
||||
@@ -111,6 +111,10 @@ const EditChannel = (props) => {
|
||||
const [modalImageUrl, setModalImageUrl] = useState('');
|
||||
const [isModalOpenurl, setIsModalOpenurl] = useState(false);
|
||||
const handleInputChange = (name, value) => {
|
||||
if (name === 'models' && Array.isArray(value)) {
|
||||
value = Array.from(new Set(value.map((m) => (m || '').trim())));
|
||||
}
|
||||
|
||||
if (name === 'base_url' && value.endsWith('/v1')) {
|
||||
Modal.confirm({
|
||||
title: '警告',
|
||||
@@ -265,10 +269,14 @@ const EditChannel = (props) => {
|
||||
const fetchModels = async () => {
|
||||
try {
|
||||
let res = await API.get(`/api/channel/models`);
|
||||
let localModelOptions = res.data.data.map((model) => ({
|
||||
label: model.id,
|
||||
value: model.id,
|
||||
}));
|
||||
const localModelOptions = res.data.data.map((model) => {
|
||||
const id = (model.id || '').trim();
|
||||
return {
|
||||
key: id,
|
||||
label: id,
|
||||
value: id,
|
||||
};
|
||||
});
|
||||
setOriginModelOptions(localModelOptions);
|
||||
setFullModels(res.data.data.map((model) => model.id));
|
||||
setBasicModels(
|
||||
@@ -300,27 +308,29 @@ const EditChannel = (props) => {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 使用 Map 来避免重复,以 value 为键
|
||||
const modelMap = new Map();
|
||||
|
||||
// 先添加原始模型选项
|
||||
originModelOptions.forEach(option => {
|
||||
modelMap.set(option.value, option);
|
||||
});
|
||||
|
||||
// 再添加当前选中的模型(如果不存在)
|
||||
inputs.models.forEach(model => {
|
||||
if (!modelMap.has(model)) {
|
||||
modelMap.set(model, {
|
||||
label: model,
|
||||
value: model,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setModelOptions(Array.from(modelMap.values()));
|
||||
}, [originModelOptions, inputs.models]);
|
||||
useEffect(() => {
|
||||
const modelMap = new Map();
|
||||
|
||||
originModelOptions.forEach(option => {
|
||||
const v = (option.value || '').trim();
|
||||
if (!modelMap.has(v)) {
|
||||
modelMap.set(v, option);
|
||||
}
|
||||
});
|
||||
|
||||
inputs.models.forEach(model => {
|
||||
const v = (model || '').trim();
|
||||
if (!modelMap.has(v)) {
|
||||
modelMap.set(v, {
|
||||
key: v,
|
||||
label: v,
|
||||
value: v,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setModelOptions(Array.from(modelMap.values()));
|
||||
}, [originModelOptions, inputs.models]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchModels().then();
|
||||
@@ -403,7 +413,7 @@ useEffect(() => {
|
||||
localModels.push(model);
|
||||
localModelOptions.push({
|
||||
key: model,
|
||||
text: model,
|
||||
label: model,
|
||||
value: model,
|
||||
});
|
||||
addedModels.push(model);
|
||||
@@ -832,6 +842,25 @@ useEffect(() => {
|
||||
>
|
||||
{t('清除所有模型')}
|
||||
</Button>
|
||||
<Button
|
||||
type='tertiary'
|
||||
onClick={() => {
|
||||
if (inputs.models.length === 0) {
|
||||
showInfo(t('没有模型可以复制'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
copy(inputs.models.join(','));
|
||||
showSuccess(t('模型列表已复制到剪贴板'));
|
||||
} catch (error) {
|
||||
showError(t('复制失败'));
|
||||
}
|
||||
}}
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
>
|
||||
{t('复制所有模型')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
Form,
|
||||
Space,
|
||||
RadioGroup,
|
||||
Radio
|
||||
Radio,
|
||||
Checkbox,
|
||||
Tag
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconDelete,
|
||||
@@ -30,6 +32,7 @@ export default function ModelSettingsVisualEditor(props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pricingMode, setPricingMode] = useState('per-token'); // 'per-token' or 'per-request'
|
||||
const [pricingSubMode, setPricingSubMode] = useState('ratio'); // 'ratio' or 'token-price'
|
||||
const [conflictOnly, setConflictOnly] = useState(false);
|
||||
const formRef = useRef(null);
|
||||
const pageSize = 10;
|
||||
const quotaPerUnit = getQuotaPerUnit();
|
||||
@@ -47,13 +50,19 @@ export default function ModelSettingsVisualEditor(props) {
|
||||
...Object.keys(completionRatio),
|
||||
]);
|
||||
|
||||
const modelData = Array.from(modelNames).map((name) => ({
|
||||
name,
|
||||
price: modelPrice[name] === undefined ? '' : modelPrice[name],
|
||||
ratio: modelRatio[name] === undefined ? '' : modelRatio[name],
|
||||
completionRatio:
|
||||
completionRatio[name] === undefined ? '' : completionRatio[name],
|
||||
}));
|
||||
const modelData = Array.from(modelNames).map((name) => {
|
||||
const price = modelPrice[name] === undefined ? '' : modelPrice[name];
|
||||
const ratio = modelRatio[name] === undefined ? '' : modelRatio[name];
|
||||
const comp = completionRatio[name] === undefined ? '' : completionRatio[name];
|
||||
|
||||
return {
|
||||
name,
|
||||
price,
|
||||
ratio,
|
||||
completionRatio: comp,
|
||||
hasConflict: price !== '' && (ratio !== '' || comp !== ''),
|
||||
};
|
||||
});
|
||||
|
||||
setModels(modelData);
|
||||
} catch (error) {
|
||||
@@ -69,11 +78,13 @@ export default function ModelSettingsVisualEditor(props) {
|
||||
};
|
||||
|
||||
// 在 return 语句之前,先处理过滤和分页逻辑
|
||||
const filteredModels = models.filter((model) =>
|
||||
searchText
|
||||
const filteredModels = models.filter((model) => {
|
||||
const keywordMatch = searchText
|
||||
? model.name.toLowerCase().includes(searchText.toLowerCase())
|
||||
: true,
|
||||
);
|
||||
: true;
|
||||
const conflictMatch = conflictOnly ? model.hasConflict : true;
|
||||
return keywordMatch && conflictMatch;
|
||||
});
|
||||
|
||||
// 然后基于过滤后的数据计算分页数据
|
||||
const pagedData = getPagedData(filteredModels, currentPage, pageSize);
|
||||
@@ -152,6 +163,16 @@ export default function ModelSettingsVisualEditor(props) {
|
||||
title: t('模型名称'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (text, record) => (
|
||||
<span>
|
||||
{text}
|
||||
{record.hasConflict && (
|
||||
<Tag color='red' shape='circle' className='ml-2'>
|
||||
{t('矛盾')}
|
||||
</Tag>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('模型固定价格'),
|
||||
@@ -219,9 +240,13 @@ export default function ModelSettingsVisualEditor(props) {
|
||||
return;
|
||||
}
|
||||
setModels((prev) =>
|
||||
prev.map((model) =>
|
||||
model.name === name ? { ...model, [field]: value } : model,
|
||||
),
|
||||
prev.map((model) => {
|
||||
if (model.name !== name) return model;
|
||||
const updated = { ...model, [field]: value };
|
||||
updated.hasConflict =
|
||||
updated.price !== '' && (updated.ratio !== '' || updated.completionRatio !== '');
|
||||
return updated;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -296,16 +321,18 @@ export default function ModelSettingsVisualEditor(props) {
|
||||
if (existingModelIndex >= 0) {
|
||||
// Update existing model
|
||||
setModels((prev) =>
|
||||
prev.map((model, index) =>
|
||||
index === existingModelIndex
|
||||
? {
|
||||
name: values.name,
|
||||
price: values.price || '',
|
||||
ratio: values.ratio || '',
|
||||
completionRatio: values.completionRatio || '',
|
||||
}
|
||||
: model,
|
||||
),
|
||||
prev.map((model, index) => {
|
||||
if (index !== existingModelIndex) return model;
|
||||
const updated = {
|
||||
name: values.name,
|
||||
price: values.price || '',
|
||||
ratio: values.ratio || '',
|
||||
completionRatio: values.completionRatio || '',
|
||||
};
|
||||
updated.hasConflict =
|
||||
updated.price !== '' && (updated.ratio !== '' || updated.completionRatio !== '');
|
||||
return updated;
|
||||
}),
|
||||
);
|
||||
setVisible(false);
|
||||
showSuccess(t('更新成功'));
|
||||
@@ -317,15 +344,17 @@ export default function ModelSettingsVisualEditor(props) {
|
||||
return;
|
||||
}
|
||||
|
||||
setModels((prev) => [
|
||||
{
|
||||
setModels((prev) => {
|
||||
const newModel = {
|
||||
name: values.name,
|
||||
price: values.price || '',
|
||||
ratio: values.ratio || '',
|
||||
completionRatio: values.completionRatio || '',
|
||||
},
|
||||
...prev,
|
||||
]);
|
||||
};
|
||||
newModel.hasConflict =
|
||||
newModel.price !== '' && (newModel.ratio !== '' || newModel.completionRatio !== '');
|
||||
return [newModel, ...prev];
|
||||
});
|
||||
setVisible(false);
|
||||
showSuccess(t('添加成功'));
|
||||
}
|
||||
@@ -426,7 +455,17 @@ export default function ModelSettingsVisualEditor(props) {
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
style={{ width: 200 }}
|
||||
showClear
|
||||
/>
|
||||
<Checkbox
|
||||
checked={conflictOnly}
|
||||
onChange={(e) => {
|
||||
setConflictOnly(e.target.checked);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
{t('仅显示矛盾倍率')}
|
||||
</Checkbox>
|
||||
</Space>
|
||||
<Table
|
||||
columns={columns}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Table,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Input,
|
||||
Tooltip,
|
||||
Select,
|
||||
Modal,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
import {
|
||||
@@ -17,7 +18,7 @@ import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
} from 'lucide-react';
|
||||
import { API, showError, showSuccess, showWarning, stringToColor } from '../../../helpers';
|
||||
import { API, showError, showSuccess, showWarning, stringToColor, isMobile } from '../../../helpers';
|
||||
import { DEFAULT_ENDPOINT } from '../../../constants';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -26,6 +27,35 @@ import {
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import ChannelSelectorModal from '../../../components/settings/ChannelSelectorModal';
|
||||
|
||||
function ConflictConfirmModal({ t, visible, items, onOk, onCancel }) {
|
||||
const columns = [
|
||||
{ title: t('渠道'), dataIndex: 'channel' },
|
||||
{ title: t('模型'), dataIndex: 'model' },
|
||||
{
|
||||
title: t('当前计费'),
|
||||
dataIndex: 'current',
|
||||
render: (text) => <div style={{ whiteSpace: 'pre-wrap' }}>{text}</div>,
|
||||
},
|
||||
{
|
||||
title: t('修改为'),
|
||||
dataIndex: 'newVal',
|
||||
render: (text) => <div style={{ whiteSpace: 'pre-wrap' }}>{text}</div>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('确认冲突项修改')}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
onOk={onOk}
|
||||
size={isMobile() ? 'full-width' : 'large'}
|
||||
>
|
||||
<Table columns={columns} dataSource={items} pagination={false} size="small" />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UpstreamRatioSync(props) {
|
||||
const { t } = useTranslation();
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
@@ -56,8 +86,16 @@ export default function UpstreamRatioSync(props) {
|
||||
// 倍率类型过滤
|
||||
const [ratioTypeFilter, setRatioTypeFilter] = useState('');
|
||||
|
||||
// 冲突确认弹窗相关
|
||||
const [confirmVisible, setConfirmVisible] = useState(false);
|
||||
const [conflictItems, setConflictItems] = useState([]); // {channel, model, current, newVal, ratioType}
|
||||
|
||||
const channelSelectorRef = React.useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [ratioTypeFilter, searchKeyword]);
|
||||
|
||||
const fetchAllChannels = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -155,15 +193,30 @@ export default function UpstreamRatioSync(props) {
|
||||
}
|
||||
};
|
||||
|
||||
const selectValue = (model, ratioType, value) => {
|
||||
setResolutions(prev => ({
|
||||
...prev,
|
||||
[model]: {
|
||||
...prev[model],
|
||||
[ratioType]: value,
|
||||
},
|
||||
}));
|
||||
};
|
||||
function getBillingCategory(ratioType) {
|
||||
return ratioType === 'model_price' ? 'price' : 'ratio';
|
||||
}
|
||||
|
||||
const selectValue = useCallback((model, ratioType, value) => {
|
||||
const category = getBillingCategory(ratioType);
|
||||
|
||||
setResolutions(prev => {
|
||||
const newModelRes = { ...(prev[model] || {}) };
|
||||
|
||||
Object.keys(newModelRes).forEach((rt) => {
|
||||
if (getBillingCategory(rt) !== category) {
|
||||
delete newModelRes[rt];
|
||||
}
|
||||
});
|
||||
|
||||
newModelRes[ratioType] = value;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[model]: newModelRes,
|
||||
};
|
||||
});
|
||||
}, [setResolutions]);
|
||||
|
||||
const applySync = async () => {
|
||||
const currentRatios = {
|
||||
@@ -173,19 +226,100 @@ export default function UpstreamRatioSync(props) {
|
||||
ModelPrice: JSON.parse(props.options.ModelPrice || '{}'),
|
||||
};
|
||||
|
||||
const conflicts = [];
|
||||
|
||||
const getLocalBillingCategory = (model) => {
|
||||
if (currentRatios.ModelPrice[model] !== undefined) return 'price';
|
||||
if (currentRatios.ModelRatio[model] !== undefined ||
|
||||
currentRatios.CompletionRatio[model] !== undefined ||
|
||||
currentRatios.CacheRatio[model] !== undefined) return 'ratio';
|
||||
return null;
|
||||
};
|
||||
|
||||
const findSourceChannel = (model, ratioType, value) => {
|
||||
if (differences[model] && differences[model][ratioType]) {
|
||||
const upMap = differences[model][ratioType].upstreams || {};
|
||||
const entry = Object.entries(upMap).find(([_, v]) => v === value);
|
||||
if (entry) return entry[0];
|
||||
}
|
||||
return t('未知');
|
||||
};
|
||||
|
||||
Object.entries(resolutions).forEach(([model, ratios]) => {
|
||||
const localCat = getLocalBillingCategory(model);
|
||||
const newCat = 'model_price' in ratios ? 'price' : 'ratio';
|
||||
|
||||
if (localCat && localCat !== newCat) {
|
||||
const currentDesc = localCat === 'price'
|
||||
? `${t('固定价格')} : ${currentRatios.ModelPrice[model]}`
|
||||
: `${t('模型倍率')} : ${currentRatios.ModelRatio[model] ?? '-'}\n${t('补全倍率')} : ${currentRatios.CompletionRatio[model] ?? '-'}`;
|
||||
|
||||
let newDesc = '';
|
||||
if (newCat === 'price') {
|
||||
newDesc = `${t('固定价格')} : ${ratios['model_price']}`;
|
||||
} else {
|
||||
const newModelRatio = ratios['model_ratio'] ?? '-';
|
||||
const newCompRatio = ratios['completion_ratio'] ?? '-';
|
||||
newDesc = `${t('模型倍率')} : ${newModelRatio}\n${t('补全倍率')} : ${newCompRatio}`;
|
||||
}
|
||||
|
||||
const channels = Object.entries(ratios)
|
||||
.map(([rt, val]) => findSourceChannel(model, rt, val))
|
||||
.filter((v, idx, arr) => arr.indexOf(v) === idx)
|
||||
.join(', ');
|
||||
|
||||
conflicts.push({
|
||||
channel: channels,
|
||||
model,
|
||||
current: currentDesc,
|
||||
newVal: newDesc,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (conflicts.length > 0) {
|
||||
setConflictItems(conflicts);
|
||||
setConfirmVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await performSync(currentRatios);
|
||||
};
|
||||
|
||||
const performSync = useCallback(async (currentRatios) => {
|
||||
const finalRatios = {
|
||||
ModelRatio: { ...currentRatios.ModelRatio },
|
||||
CompletionRatio: { ...currentRatios.CompletionRatio },
|
||||
CacheRatio: { ...currentRatios.CacheRatio },
|
||||
ModelPrice: { ...currentRatios.ModelPrice },
|
||||
};
|
||||
|
||||
Object.entries(resolutions).forEach(([model, ratios]) => {
|
||||
const selectedTypes = Object.keys(ratios);
|
||||
const hasPrice = selectedTypes.includes('model_price');
|
||||
const hasRatio = selectedTypes.some(rt => rt !== 'model_price');
|
||||
|
||||
if (hasPrice) {
|
||||
delete finalRatios.ModelRatio[model];
|
||||
delete finalRatios.CompletionRatio[model];
|
||||
delete finalRatios.CacheRatio[model];
|
||||
}
|
||||
if (hasRatio) {
|
||||
delete finalRatios.ModelPrice[model];
|
||||
}
|
||||
|
||||
Object.entries(ratios).forEach(([ratioType, value]) => {
|
||||
const optionKey = ratioType
|
||||
.split('_')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join('');
|
||||
currentRatios[optionKey][model] = parseFloat(value);
|
||||
finalRatios[optionKey][model] = parseFloat(value);
|
||||
});
|
||||
});
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const updates = Object.entries(currentRatios).map(([key, value]) =>
|
||||
const updates = Object.entries(finalRatios).map(([key, value]) =>
|
||||
API.put('/api/option/', {
|
||||
key,
|
||||
value: JSON.stringify(value, null, 2),
|
||||
@@ -225,7 +359,7 @@ export default function UpstreamRatioSync(props) {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [resolutions, props.options, props.refresh]);
|
||||
|
||||
const getCurrentPageData = (dataSource) => {
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
@@ -300,6 +434,10 @@ export default function UpstreamRatioSync(props) {
|
||||
const tmp = [];
|
||||
|
||||
Object.entries(differences).forEach(([model, ratioTypes]) => {
|
||||
const hasPrice = 'model_price' in ratioTypes;
|
||||
const hasOtherRatio = ['model_ratio', 'completion_ratio', 'cache_ratio'].some(rt => rt in ratioTypes);
|
||||
const billingConflict = hasPrice && hasOtherRatio;
|
||||
|
||||
Object.entries(ratioTypes).forEach(([ratioType, diff]) => {
|
||||
tmp.push({
|
||||
key: `${model}_${ratioType}`,
|
||||
@@ -308,6 +446,7 @@ export default function UpstreamRatioSync(props) {
|
||||
current: diff.current,
|
||||
upstreams: diff.upstreams,
|
||||
confidence: diff.confidence || {},
|
||||
billingConflict,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -365,14 +504,25 @@ export default function UpstreamRatioSync(props) {
|
||||
{
|
||||
title: t('倍率类型'),
|
||||
dataIndex: 'ratioType',
|
||||
render: (text) => {
|
||||
render: (text, record) => {
|
||||
const typeMap = {
|
||||
model_ratio: t('模型倍率'),
|
||||
completion_ratio: t('补全倍率'),
|
||||
cache_ratio: t('缓存倍率'),
|
||||
model_price: t('固定价格'),
|
||||
};
|
||||
return <Tag color={stringToColor(text)} shape="circle">{typeMap[text] || text}</Tag>;
|
||||
const baseTag = <Tag color={stringToColor(text)} shape="circle">{typeMap[text] || text}</Tag>;
|
||||
if (record?.billingConflict) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{baseTag}
|
||||
<Tooltip position="top" content={t('该模型存在固定价格与倍率计费方式冲突,请确认选择')}>
|
||||
<AlertTriangle size={14} className="text-yellow-500" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return baseTag;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -440,28 +590,27 @@ export default function UpstreamRatioSync(props) {
|
||||
})();
|
||||
|
||||
const handleBulkSelect = (checked) => {
|
||||
setResolutions((prev) => {
|
||||
const newRes = { ...prev };
|
||||
|
||||
if (checked) {
|
||||
filteredDataSource.forEach((row) => {
|
||||
const upstreamVal = row.upstreams?.[upName];
|
||||
if (upstreamVal !== null && upstreamVal !== undefined && upstreamVal !== 'same') {
|
||||
if (checked) {
|
||||
if (!newRes[row.model]) newRes[row.model] = {};
|
||||
newRes[row.model][row.ratioType] = upstreamVal;
|
||||
} else {
|
||||
if (newRes[row.model]) {
|
||||
delete newRes[row.model][row.ratioType];
|
||||
if (Object.keys(newRes[row.model]).length === 0) {
|
||||
delete newRes[row.model];
|
||||
}
|
||||
}
|
||||
}
|
||||
selectValue(row.model, row.ratioType, upstreamVal);
|
||||
}
|
||||
});
|
||||
|
||||
return newRes;
|
||||
});
|
||||
} else {
|
||||
setResolutions((prev) => {
|
||||
const newRes = { ...prev };
|
||||
filteredDataSource.forEach((row) => {
|
||||
if (newRes[row.model]) {
|
||||
delete newRes[row.model][row.ratioType];
|
||||
if (Object.keys(newRes[row.model]).length === 0) {
|
||||
delete newRes[row.model];
|
||||
}
|
||||
}
|
||||
});
|
||||
return newRes;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -589,6 +738,23 @@ export default function UpstreamRatioSync(props) {
|
||||
channelEndpoints={channelEndpoints}
|
||||
updateChannelEndpoint={updateChannelEndpoint}
|
||||
/>
|
||||
|
||||
<ConflictConfirmModal
|
||||
t={t}
|
||||
visible={confirmVisible}
|
||||
items={conflictItems}
|
||||
onOk={async () => {
|
||||
setConfirmVisible(false);
|
||||
const curRatios = {
|
||||
ModelRatio: JSON.parse(props.options.ModelRatio || '{}'),
|
||||
CompletionRatio: JSON.parse(props.options.CompletionRatio || '{}'),
|
||||
CacheRatio: JSON.parse(props.options.CacheRatio || '{}'),
|
||||
ModelPrice: JSON.parse(props.options.ModelPrice || '{}'),
|
||||
};
|
||||
await performSync(curRatios);
|
||||
}}
|
||||
onCancel={() => setConfirmVisible(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user