Compare commits

...

13 Commits

Author SHA1 Message Date
CaIon
1e25bf700d Merge remote-tracking branch 'origin/alpha' into alpha 2025-07-05 14:14:48 +08:00
CaIon
30fb349d91 feat(endpoint types): add support for image generation models in endpoint type handling 2025-07-05 14:14:40 +08:00
t0ng7u
d40fb68500 📊 feat(detail): add model consumption trend & call ranking charts
Introduce two new visualizations to the “Model Data Analysis” panel:

1. Model Consumption Trend (line chart)
   • Added `spec_model_line` state and legend support.
   • Calculates per-model counts over time and updates via `updateChartData`.
2. Model Call Ranking (bar chart)
   • Added `spec_rank_bar` state with `seriesField` and legend enabled.
   • Ranks models by total call count.

Additional changes:
• Extended tab navigation with two new `TabPane`s and adjusted chart rendering logic.
• Swapped icons/texts to match new chart purposes.
• Reused existing color mapping to ensure consistent palette.

No breaking changes; UI now offers richer insights into model usage patterns.
2025-07-05 00:37:05 +08:00
t0ng7u
3049ad47e5 🔢 feat(user-edit): replace add-quota input with Semi-UI InputNumber
Summary:
• Imported InputNumber from @douyinfe/semi-ui.
• Swapped plain Input for InputNumber in “Add Quota” modal.
• Added UX tweaks: full-width styling, showClear, step = 500 000.
• Initialized addQuotaLocal to an empty string so the field starts blank.
• Adjusted state handling and kept quota calculation logic unchanged.

This improves numeric input accuracy and overall user experience without breaking existing functionality.
2025-07-05 00:03:12 +08:00
t0ng7u
8945a3a2dd 🖼️ style(RatioSync): remove the useless rounded-full style 2025-07-04 23:49:34 +08:00
t0ng7u
d191eef657 🐛 fix: fix the header height calculation issue in the custom HTML styles on the homepage 2025-07-04 23:42:46 +08:00
CaIon
6ac7878863 🔧 refactor(endpoint types): comment out unused endpoint types in constants 2025-07-04 15:53:46 +08:00
t0ng7u
c0a23ffa62 🎨 refactor(EditTagModal): tidy imports & enhance state-sync on open
Motivation
• Remove unused UI components to keep the bundle lean and silence linter warnings.
• Ensure every time the side-sheet opens it reflects the latest tag data, avoiding stale form values (e.g., model / group mismatches).

Key Changes
1. UI Imports
   – Dropped `Input`, `Select`, `TextArea` from `@douyinfe/semi-ui` (unused in Form-based version).
2. State Reset & Form Sync
   – On `visible` or `tag` change:
     • Refresh model & group options.
     • Reset `inputs` to clean defaults (`originInputs`) carrying the current `tag`.
     • Pre-fill Form through `formApiRef` to keep controlled fields aligned.
3. Minor Cleanup
   – Added inline comment clarifying local state reset purpose.

Result
Opening the “Edit Tag” side-sheet now always displays accurate data without residual selections, and build output is cleaner due to removed dead imports.
2025-07-04 06:14:15 +08:00
t0ng7u
7d691f362d refactor(EditChannel&EditToken): refactor Channel & Token edit pages with Semi Form and UX enhancements
Overview
• Migrated both `EditChannel.js` and `EditToken.js` to fully leverage Semi UI `Form.*` components, removing legacy `Input/Select/TextArea` + manual labels.
• Unified data-loading strategy: when the drawer becomes visible we load (or reset) data via `props.visible + id` effect and `formApi.setValues()`, guaranteeing fields are always populated; form resets on close.
• Fixed blank-form bug when opening the same record twice.

Key improvements
1. Validation
   • `type`, `models` always required.
   • `key` required only while creating (not on edit).
2. Batch key creation
   • Checkbox moved into `extraText`; hidden when editing or when channel type = 41.
3. Layout & UI
   • `Row / Col` (12 + 12) for “Priority” and “Weight”.
   • Placeholders revised; model selector now shows creation hint; removed obsolete banner.
   • Help / extraText used for long hints, template buttons (`model_mapping`, `status_code_mapping`, `param_override`, etc.), and API address notice.
   • Added `showClear`, `min`, rounded card class names for consistency.
4. Reusable helpers
   • `batchAllowed`, `batchExtra` utilities.
   • `getInitValues()` + centralized `inputs`→form synchronization.
5. Token editor aligned to the same pattern (`props.visiable` watcher).

Result
Cleaner code, consistent UX, instant field population on every open, and clearer validation/error feedback across both editors.
2025-07-04 05:36:10 +08:00
t0ng7u
bf577b8937 🔌 feat(api): extend endpoint type support & expose in pricing UI
* backend
  - constant/endpoint_type.go
    • Add EndpointTypeMidjourney, EndpointTypeSuno, EndpointTypeKling, EndpointTypeJimeng.
  - common/endpoint_type.go
    • Map Midjourney / MidjourneyPlus, SunoAPI, Kling, Jimeng channel types to the new endpoint types.

* frontend
  - ModelPricing.js
    • Add “Supported Endpoint Type” column.
    • Implement renderSupportedEndpoints with `stringToColor` for consistent tag colors.

These changes allow `/api/pricing` and model lists to return accurate
`supported_endpoint_types` covering all non-OpenAI providers and display
them clearly in the UI.

No breaking changes.
2025-07-04 03:15:34 +08:00
Calcium-Ion
819290c9b8 Merge pull request #1314 from vickyyd/main
修复使用gemini-balance作为上游时,测试gemini2.5pro模型时出现的错误问题
2025-07-03 15:53:32 +08:00
CaIon
22e8b46159 feat: make TopN field in RerankRequest optional in JSON serialization 2025-07-03 15:45:32 +08:00
kikii16
efc8457770 修复gemini-balance测试gemini2.5pro的错误问题 2025-06-29 13:36:19 +08:00
20 changed files with 865 additions and 738 deletions

View File

@@ -27,9 +27,6 @@
<a href="https://goreportcard.com/report/github.com/Calcium-Ion/new-api">
<img src="https://goreportcard.com/badge/github.com/Calcium-Ion/new-api" alt="GoReportCard">
</a>
<a href="https://coderabbit.ai">
<img src="https://img.shields.io/coderabbit/prs/github/QuantumNous/new-api?utm_source=oss&utm_medium=github&utm_campaign=QuantumNous%2Fnew-api&labelColor=171717&color=FF570A&link=https%3A%2F%2Fcoderabbit.ai&label=CodeRabbit+Reviews" alt="CodeRabbit Pull Request Reviews">
</a>
</p>
</div>

View File

@@ -8,6 +8,14 @@ func GetEndpointTypesByChannelType(channelType int, modelName string) []constant
switch channelType {
case constant.ChannelTypeJina:
endpointTypes = []constant.EndpointType{constant.EndpointTypeJinaRerank}
//case constant.ChannelTypeMidjourney, constant.ChannelTypeMidjourneyPlus:
// endpointTypes = []constant.EndpointType{constant.EndpointTypeMidjourney}
//case constant.ChannelTypeSunoAPI:
// endpointTypes = []constant.EndpointType{constant.EndpointTypeSuno}
//case constant.ChannelTypeKling:
// endpointTypes = []constant.EndpointType{constant.EndpointTypeKling}
//case constant.ChannelTypeJimeng:
// endpointTypes = []constant.EndpointType{constant.EndpointTypeJimeng}
case constant.ChannelTypeAws:
fallthrough
case constant.ChannelTypeAnthropic:
@@ -25,5 +33,9 @@ func GetEndpointTypesByChannelType(channelType int, modelName string) []constant
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI}
}
}
if IsImageGenerationModel(modelName) {
// add to first
endpointTypes = append([]constant.EndpointType{constant.EndpointTypeImageGeneration}, endpointTypes...)
}
return endpointTypes
}

View File

@@ -9,11 +9,32 @@ var (
"o3-deep-research",
"o4-mini-deep-research",
}
ImageGenerationModels = []string{
"dall-e-3",
"dall-e-2",
"gpt-image-1",
"prefix:imagen-",
"flux-",
"flux.1-",
}
)
func IsOpenAIResponseOnlyModel(modelName string) bool {
for _, m := range OpenAIResponseOnlyModels {
if strings.Contains(m, modelName) {
if strings.Contains(modelName, m) {
return true
}
}
return false
}
func IsImageGenerationModel(modelName string) bool {
modelName = strings.ToLower(modelName)
for _, m := range ImageGenerationModels {
if strings.Contains(modelName, m) {
return true
}
if strings.HasPrefix(m, "prefix:") && strings.HasPrefix(modelName, strings.TrimPrefix(m, "prefix:")) {
return true
}
}

View File

@@ -3,9 +3,14 @@ package constant
type EndpointType string
const (
EndpointTypeOpenAI EndpointType = "openai"
EndpointTypeOpenAIResponse EndpointType = "openai-response"
EndpointTypeAnthropic EndpointType = "anthropic"
EndpointTypeGemini EndpointType = "gemini"
EndpointTypeJinaRerank EndpointType = "jina-rerank"
EndpointTypeOpenAI EndpointType = "openai"
EndpointTypeOpenAIResponse EndpointType = "openai-response"
EndpointTypeAnthropic EndpointType = "anthropic"
EndpointTypeGemini EndpointType = "gemini"
EndpointTypeJinaRerank EndpointType = "jina-rerank"
EndpointTypeImageGeneration EndpointType = "image-generation"
//EndpointTypeMidjourney EndpointType = "midjourney-proxy"
//EndpointTypeSuno EndpointType = "suno-proxy"
//EndpointTypeKling EndpointType = "kling"
//EndpointTypeJimeng EndpointType = "jimeng"
)

View File

@@ -202,7 +202,7 @@ func buildTestRequest(model string) *dto.GeneralOpenAIRequest {
testRequest.MaxTokens = 50
}
} else if strings.Contains(model, "gemini") {
testRequest.MaxTokens = 300
testRequest.MaxTokens = 3000
} else {
testRequest.MaxTokens = 10
}

View File

@@ -4,7 +4,7 @@ type RerankRequest struct {
Documents []any `json:"documents"`
Query string `json:"query"`
Model string `json:"model"`
TopN int `json:"top_n"`
TopN int `json:"top_n,omitempty"`
ReturnDocuments *bool `json:"return_documents,omitempty"`
MaxChunkPerDoc int `json:"max_chunk_per_doc,omitempty"`
OverLapTokens int `json:"overlap_tokens,omitempty"`

View File

@@ -3,6 +3,7 @@ package jina
var ModelList = []string{
"jina-clip-v1",
"jina-reranker-v2-base-multilingual",
"jina-reranker-m0",
}
var ChannelName = "jina"

View File

@@ -78,12 +78,15 @@ func RerankHelper(c *gin.Context, relayMode int) (openaiErr *dto.OpenAIErrorWith
return service.OpenAIErrorWrapperLocal(err, "json_marshal_failed", http.StatusInternalServerError)
}
requestBody := bytes.NewBuffer(jsonData)
statusCodeMappingStr := c.GetString("status_code_mapping")
if common.DebugEnabled {
println(fmt.Sprintf("Rerank request body: %s", requestBody.String()))
}
resp, err := adaptor.DoRequest(c, relayInfo, requestBody)
if err != nil {
return service.OpenAIErrorWrapper(err, "do_request_failed", http.StatusInternalServerError)
}
statusCodeMappingStr := c.GetString("status_code_mapping")
var httpResp *http.Response
if resp != nil {
httpResp = resp.(*http.Response)

View File

@@ -172,9 +172,6 @@ func CountTokenChatRequest(info *relaycommon.RelayInfo, request dto.GeneralOpenA
}
}
toolTokens := CountTokenInput(countStr, request.Model)
if err != nil {
return 0, err
}
tkm += 8
tkm += toolTokens
}
@@ -195,9 +192,6 @@ func CountTokenClaudeRequest(request dto.ClaudeRequest, model string) (int, erro
// Count tokens in system message
if request.System != "" {
systemTokens := CountTokenInput(request.System, model)
if err != nil {
return 0, err
}
tkm += systemTokens
}

View File

@@ -197,7 +197,6 @@ const ChannelSelectorModal = forwardRef(({
value={searchText}
onChange={setSearchText}
showClear
className="!rounded-full"
/>
<Table

View File

@@ -1,5 +1,5 @@
import React, { useContext, useEffect, useRef, useMemo, useState } from 'react';
import { API, copy, showError, showInfo, showSuccess, getModelCategories, renderModelTag } from '../../helpers';
import { API, copy, showError, showInfo, showSuccess, getModelCategories, renderModelTag, stringToColor } from '../../helpers';
import { useTranslation } from 'react-i18next';
import {
@@ -106,6 +106,26 @@ const ModelPricing = () => {
) : null;
}
function renderSupportedEndpoints(endpoints) {
if (!endpoints || endpoints.length === 0) {
return null;
}
return (
<Space wrap>
{endpoints.map((endpoint, idx) => (
<Tag
key={endpoint}
color={stringToColor(endpoint)}
size='large'
shape='circle'
>
{endpoint}
</Tag>
))}
</Space>
);
}
const columns = [
{
title: t('可用性'),
@@ -120,6 +140,13 @@ const ModelPricing = () => {
},
defaultSortOrder: 'descend',
},
{
title: t('可用端点类型'),
dataIndex: 'supported_endpoint_types',
render: (text, record, index) => {
return renderSupportedEndpoints(text);
},
},
{
title: t('模型名称'),
dataIndex: 'model_name',
@@ -499,7 +526,7 @@ const ModelPricing = () => {
<div className="flex items-center">
<AlertCircle size={14} className="mr-1.5 flex-shrink-0" />
<span className="truncate">
{t('未登录,使用默认分组倍率')}: {groupRatio['default']}
{t('未登录,使用默认分组倍率')}{groupRatio['default']}
</span>
</div>
)}

View File

@@ -119,7 +119,7 @@ const UsersTable = () => {
<Tooltip content={remark} position="top" showArrow>
<Tag color='white' size='large' shape='circle' className="!text-xs">
<div className="flex items-center gap-1">
<div className="w-2 h-2 flex-shrink-0" style={{ backgroundColor: '#10b981' }} />
<div className="w-2 h-2 flex-shrink-0 rounded-full" style={{ backgroundColor: '#10b981' }} />
{displayRemark}
</div>
</Tag>

View File

@@ -876,7 +876,7 @@
"加载token失败": "Failed to load token",
"配置聊天": "Configure chat",
"模型消耗分布": "Model consumption distribution",
"模型调用次数占比": "Proportion of model calls",
"模型调用次数占比": "Model call ratio",
"用户消耗分布": "User consumption distribution",
"时间粒度": "Time granularity",
"天": "day",
@@ -1119,6 +1119,10 @@
"平均TPM": "Average TPM",
"消耗分布": "Consumption distribution",
"调用次数分布": "Models call distribution",
"消耗趋势": "Consumption trend",
"模型消耗趋势": "Model consumption trend",
"调用次数排行": "Models call ranking",
"模型调用次数排行": "Model call ranking",
"添加渠道": "Add channel",
"测试所有通道": "Test all channels",
"删除禁用通道": "Delete disabled channels",
@@ -1143,8 +1147,8 @@
"默认测试模型": "Default Test Model",
"不填则为模型列表第一个": "First model in list if empty",
"是否自动禁用(仅当自动禁用开启时有效),关闭后不会自动禁用该渠道": "Auto-disable (only effective when auto-disable is enabled). When turned off, this channel will not be automatically disabled",
"状态码复写(仅影响本地判断,不修改返回到上游的状态码)": "Status Code Override (only affects local judgment, does not modify status code returned upstream)",
"此项可选用于复写返回的状态码比如将claude渠道的400错误复写为500用于重试请勿滥用该功能例如": "Optional, used to override returned status codes, e.g. rewriting Claude channel's 400 error to 500 (for retry). Do not abuse this feature. Example:",
"状态码复写": "Status Code Override",
"此项可选,用于复写返回的状态码,仅影响本地判断,不修改返回到上游的状态码,比如将claude渠道的400错误复写为500用于重试请勿滥用该功能例如": "Optional, used to override returned status codes, only affects local judgment, does not modify status code returned upstream, e.g. rewriting Claude channel's 400 error to 500 (for retry). Do not abuse this feature. Example:",
"渠道标签": "Channel Tag",
"渠道优先级": "Channel Priority",
"渠道权重": "Channel Weight",
@@ -1199,7 +1203,7 @@
"添加用户": "Add user",
"角色": "Role",
"已绑定的 Telegram 账户": "Bound Telegram account",
"新额度": "New quota",
"新额度": "New quota: ",
"需要添加的额度(支持负数)": "Need to add quota (supports negative numbers)",
"此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改": "Read-only, user's personal settings, and cannot be modified directly",
"请输入新的密码,最短 8 位": "Please enter a new password, at least 8 characterss",
@@ -1750,5 +1754,7 @@
"批量创建时会在名称后自动添加随机后缀": "When creating in batches, a random suffix will be automatically added to the name",
"额度必须大于0": "Quota must be greater than 0",
"生成数量必须大于0": "Generation quantity must be greater than 0",
"创建后可在编辑渠道时获取上游模型列表": "After creation, you can get the upstream model list when editing the channel"
"创建后可在编辑渠道时获取上游模型列表": "After creation, you can get the upstream model list when editing the channel",
"可用端点类型": "Supported endpoint types",
"未登录,使用默认分组倍率:": "Not logged in, using default group ratio: "
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import {
API,
showError,
@@ -11,15 +11,13 @@ import {
SideSheet,
Space,
Button,
Input,
Typography,
Spin,
Select,
Banner,
TextArea,
Card,
Tag,
Avatar,
Form,
} from '@douyinfe/semi-ui';
import {
IconSave,
@@ -53,9 +51,14 @@ const EditTagModal = (props) => {
models: [],
};
const [inputs, setInputs] = useState(originInputs);
const formApiRef = useRef(null);
const getInitValues = () => ({ ...originInputs });
const handleInputChange = (name, value) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
if (formApiRef.current) {
formApiRef.current.setValue(name, value);
}
if (name === 'type') {
let localModels = [];
switch (value) {
@@ -133,27 +136,25 @@ const EditTagModal = (props) => {
}
};
const handleSave = async () => {
const handleSave = async (values) => {
setLoading(true);
let data = {
tag: tag,
};
if (inputs.model_mapping !== null && inputs.model_mapping !== '') {
if (inputs.model_mapping !== '' && !verifyJSON(inputs.model_mapping)) {
const formVals = values || formApiRef.current?.getValues() || {};
let data = { tag };
if (formVals.model_mapping) {
if (!verifyJSON(formVals.model_mapping)) {
showInfo('模型映射必须是合法的 JSON 格式!');
setLoading(false);
return;
}
data.model_mapping = inputs.model_mapping;
data.model_mapping = formVals.model_mapping;
}
if (inputs.groups.length > 0) {
data.groups = inputs.groups.join(',');
if (formVals.groups && formVals.groups.length > 0) {
data.groups = formVals.groups.join(',');
}
if (inputs.models.length > 0) {
data.models = inputs.models.join(',');
if (formVals.models && formVals.models.length > 0) {
data.models = formVals.models.join(',');
}
data.new_tag = inputs.new_tag;
// check have any change
data.new_tag = formVals.new_tag;
if (
data.model_mapping === undefined &&
data.groups === undefined &&
@@ -202,7 +203,7 @@ const EditTagModal = (props) => {
const res = await API.get(`/api/channel/tag/models?tag=${tag}`);
if (res?.data?.success) {
const models = res.data.data ? res.data.data.split(',') : [];
setInputs((inputs) => ({ ...inputs, models: models }));
handleInputChange('models', models);
} else {
showError(res.data.message);
}
@@ -213,19 +214,32 @@ const EditTagModal = (props) => {
}
};
fetchModels().then();
fetchGroups().then();
fetchTagModels().then();
if (formApiRef.current) {
formApiRef.current.setValues({
...getInitValues(),
tag: tag,
new_tag: tag,
});
}
setInputs({
...originInputs,
tag: tag,
new_tag: tag,
});
fetchModels().then();
fetchGroups().then();
fetchTagModels().then(); // Call the new function
}, [visible, tag]); // Add tag to dependency array
}, [visible, tag]);
useEffect(() => {
if (formApiRef.current) {
formApiRef.current.setValues(inputs);
}
}, [inputs]);
const addCustomModels = () => {
if (customModel.trim() === '') return;
// 使用逗号分隔字符串,然后去除每个模型名称前后的空格
const modelArray = customModel.split(',').map((model) => model.trim());
let localModels = [...inputs.models];
@@ -233,11 +247,9 @@ const EditTagModal = (props) => {
const addedModels = [];
modelArray.forEach((model) => {
// 检查模型是否已存在,且模型名称非空
if (model && !localModels.includes(model)) {
localModels.push(model); // 添加到模型列表
localModels.push(model);
localModelOptions.push({
// 添加到下拉选项
key: model,
text: model,
value: model,
@@ -246,7 +258,6 @@ const EditTagModal = (props) => {
}
});
// 更新状态值
setModelOptions(localModelOptions);
setCustomModel('');
handleInputChange('models', localModels);
@@ -283,7 +294,7 @@ const EditTagModal = (props) => {
<Space>
<Button
theme="solid"
onClick={handleSave}
onClick={() => formApiRef.current?.submitForm()}
loading={loading}
icon={<IconSave />}
>
@@ -302,146 +313,128 @@ const EditTagModal = (props) => {
}
closeIcon={null}
>
<Spin spinning={loading}>
<div className="p-2">
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
{/* Header: Tag Info */}
<div className="flex items-center mb-2">
<Avatar size="small" color="blue" className="mr-2 shadow-md">
<IconBookmark size={16} />
</Avatar>
<div>
<Text className="text-lg font-medium">{t('标签信息')}</Text>
<div className="text-xs text-gray-600">{t('标签的基本配置')}</div>
</div>
</div>
<Form
key={tag || 'edit'}
initValues={getInitValues()}
getFormApi={(api) => (formApiRef.current = api)}
onSubmit={handleSave}
>
{() => (
<Spin spinning={loading}>
<div className="p-2">
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
{/* Header: Tag Info */}
<div className="flex items-center mb-2">
<Avatar size="small" color="blue" className="mr-2 shadow-md">
<IconBookmark size={16} />
</Avatar>
<div>
<Text className="text-lg font-medium">{t('标签信息')}</Text>
<div className="text-xs text-gray-600">{t('标签的基本配置')}</div>
</div>
</div>
<Banner
type="warning"
description={t('所有编辑均为覆盖操作,留空则不更改')}
className="!rounded-lg mb-4"
/>
<div className="space-y-4">
<div>
<Text strong className="block mb-2">{t('标签名称')}</Text>
<Input
value={inputs.new_tag}
onChange={(value) => setInputs({ ...inputs, new_tag: value })}
placeholder={t('请输入新标签,留空则解散标签')}
/>
</div>
</div>
</Card>
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
{/* Header: Model Config */}
<div className="flex items-center mb-2">
<Avatar size="small" color="purple" className="mr-2 shadow-md">
<IconCode size={16} />
</Avatar>
<div>
<Text className="text-lg font-medium">{t('模型配置')}</Text>
<div className="text-xs text-gray-600">{t('模型选择和映射设置')}</div>
</div>
</div>
<div className="space-y-4">
<div>
<Text strong className="block mb-2">{t('模型')}</Text>
<Banner
type="info"
description={t('当前模型列表为该标签下所有渠道模型列表最长的一个,并非所有渠道的并集,请注意可能导致某些渠道模型丢失。')}
type="warning"
description={t('所有编辑均为覆盖操作,留空则不更改')}
className="!rounded-lg mb-4"
/>
<Select
placeholder={t('请选择该渠道所支持的模型,留空则不更改')}
name='models'
multiple
filter
searchPosition='dropdown'
onChange={(value) => handleInputChange('models', value)}
value={inputs.models}
optionList={modelOptions}
/>
</div>
<div>
<Input
addonAfter={
<Button type='primary' onClick={addCustomModels} className="!rounded-r-lg">
{t('填入')}
</Button>
}
placeholder={t('输入自定义模型名称')}
value={customModel}
onChange={(value) => setCustomModel(value.trim())}
/>
</div>
<div className="space-y-4">
<Form.Input
field='new_tag'
label={t('标签名称')}
placeholder={t('请输入新标签,留空则解散标签')}
onChange={(value) => handleInputChange('new_tag', value)}
/>
</div>
</Card>
<div>
<Text strong className="block mb-2">{t('模型重定向')}</Text>
<TextArea
placeholder={t('此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,留空则不更改')}
name='model_mapping'
onChange={(value) => handleInputChange('model_mapping', value)}
autosize
value={inputs.model_mapping}
/>
<Space className="mt-2">
<Text
className="!text-semi-color-primary cursor-pointer"
onClick={() => handleInputChange('model_mapping', JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2))}
>
{t('填入模板')}
</Text>
<Text
className="!text-semi-color-primary cursor-pointer"
onClick={() => handleInputChange('model_mapping', JSON.stringify({}, null, 2))}
>
{t('清空重定向')}
</Text>
<Text
className="!text-semi-color-primary cursor-pointer"
onClick={() => handleInputChange('model_mapping', '')}
>
{t('不更改')}
</Text>
</Space>
</div>
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
{/* Header: Model Config */}
<div className="flex items-center mb-2">
<Avatar size="small" color="purple" className="mr-2 shadow-md">
<IconCode size={16} />
</Avatar>
<div>
<Text className="text-lg font-medium">{t('模型配置')}</Text>
<div className="text-xs text-gray-600">{t('模型选择和映射设置')}</div>
</div>
</div>
<div className="space-y-4">
<Banner
type="info"
description={t('当前模型列表为该标签下所有渠道模型列表最长的一个,并非所有渠道的并集,请注意可能导致某些渠道模型丢失。')}
className="!rounded-lg mb-4"
/>
<Form.Select
field='models'
label={t('模型')}
placeholder={t('请选择该渠道所支持的模型,留空则不更改')}
multiple
filter
searchPosition='dropdown'
optionList={modelOptions}
style={{ width: '100%' }}
onChange={(value) => handleInputChange('models', value)}
/>
<Form.Input
field='custom_model'
label={t('自定义模型名称')}
placeholder={t('输入自定义模型名称')}
onChange={(value) => setCustomModel(value.trim())}
suffix={<Button size='small' type='primary' onClick={addCustomModels}>{t('填入')}</Button>}
/>
<Form.TextArea
field='model_mapping'
label={t('模型重定向')}
placeholder={t('此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,留空则不更改')}
autosize
onChange={(value) => handleInputChange('model_mapping', value)}
extraText={(
<Space>
<Text className="!text-semi-color-primary cursor-pointer" onClick={() => handleInputChange('model_mapping', JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2))}>{t('填入模板')}</Text>
<Text className="!text-semi-color-primary cursor-pointer" onClick={() => handleInputChange('model_mapping', JSON.stringify({}, null, 2))}>{t('清空重定向')}</Text>
<Text className="!text-semi-color-primary cursor-pointer" onClick={() => handleInputChange('model_mapping', '')}>{t('不更改')}</Text>
</Space>
)}
/>
</div>
</Card>
<Card className="!rounded-2xl shadow-sm border-0">
{/* Header: Group Settings */}
<div className="flex items-center mb-2">
<Avatar size="small" color="green" className="mr-2 shadow-md">
<IconUser size={16} />
</Avatar>
<div>
<Text className="text-lg font-medium">{t('分组设置')}</Text>
<div className="text-xs text-gray-600">{t('用户分组配置')}</div>
</div>
</div>
<div className="space-y-4">
<Form.Select
field='groups'
label={t('分组')}
placeholder={t('请选择可以使用该渠道的分组,留空则不更改')}
multiple
allowAdditions
additionLabel={t('请在系统设置页面编辑分组倍率以添加新的分组:')}
optionList={groupOptions}
style={{ width: '100%' }}
onChange={(value) => handleInputChange('groups', value)}
/>
</div>
</Card>
</div>
</Card>
<Card className="!rounded-2xl shadow-sm border-0">
{/* Header: Group Settings */}
<div className="flex items-center mb-2">
<Avatar size="small" color="green" className="mr-2 shadow-md">
<IconUser size={16} />
</Avatar>
<div>
<Text className="text-lg font-medium">{t('分组设置')}</Text>
<div className="text-xs text-gray-600">{t('用户分组配置')}</div>
</div>
</div>
<div className="space-y-4">
<div>
<Text strong className="block mb-2">{t('分组')}</Text>
<Select
placeholder={t('请选择可以使用该渠道的分组,留空则不更改')}
name='groups'
multiple
allowAdditions
additionLabel={t('请在系统设置页面编辑分组倍率以添加新的分组:')}
onChange={(value) => handleInputChange('groups', value)}
value={inputs.groups}
optionList={groupOptions}
/>
</div>
</div>
</Card>
</div>
</Spin>
</Spin>
)}
</Form>
</SideSheet>
);
};

View File

@@ -366,6 +366,86 @@ const Detail = (props) => {
},
});
// 模型消耗趋势折线图
const [spec_model_line, setSpecModelLine] = useState({
type: 'line',
data: [
{
id: 'lineData',
values: [],
},
],
xField: 'Time',
yField: 'Count',
seriesField: 'Model',
legends: {
visible: true,
selectMode: 'single',
},
title: {
visible: true,
text: t('模型消耗趋势'),
subtext: '',
},
tooltip: {
mark: {
content: [
{
key: (datum) => datum['Model'],
value: (datum) => renderNumber(datum['Count']),
},
],
},
},
color: {
specified: modelColorMap,
},
});
// 模型调用次数排行柱状图
const [spec_rank_bar, setSpecRankBar] = useState({
type: 'bar',
data: [
{
id: 'rankData',
values: [],
},
],
xField: 'Model',
yField: 'Count',
seriesField: 'Model',
legends: {
visible: true,
selectMode: 'single',
},
title: {
visible: true,
text: t('模型调用次数排行'),
subtext: '',
},
bar: {
state: {
hover: {
stroke: '#000',
lineWidth: 1,
},
},
},
tooltip: {
mark: {
content: [
{
key: (datum) => datum['Model'],
value: (datum) => renderNumber(datum['Count']),
},
],
},
},
color: {
specified: modelColorMap,
},
});
// ========== Hooks - Memoized Values ==========
const performanceMetrics = useMemo(() => {
const timeDiff = (Date.parse(end_timestamp) - Date.parse(start_timestamp)) / 60000;
@@ -853,6 +933,46 @@ const Detail = (props) => {
'barData'
);
// ===== 模型调用次数折线图 =====
let modelLineData = [];
chartTimePoints.forEach((time) => {
const timeData = Array.from(uniqueModels).map((model) => {
const key = `${time}-${model}`;
const aggregated = aggregatedData.get(key);
return {
Time: time,
Model: model,
Count: aggregated?.count || 0,
};
});
modelLineData.push(...timeData);
});
modelLineData.sort((a, b) => a.Time.localeCompare(b.Time));
// ===== 模型调用次数排行柱状图 =====
const rankData = Array.from(modelTotals)
.map(([model, count]) => ({
Model: model,
Count: count,
}))
.sort((a, b) => b.Count - a.Count);
updateChartSpec(
setSpecModelLine,
modelLineData,
`${t('总计')}${renderNumber(totalTimes)}`,
newModelColors,
'lineData'
);
updateChartSpec(
setSpecRankBar,
rankData,
`${t('总计')}${renderNumber(totalTimes)}`,
newModelColors,
'rankData'
);
setPieData(newPieData);
setLineData(newLineData);
setConsumeQuota(totalQuota);
@@ -1122,28 +1242,53 @@ const Detail = (props) => {
{t('消耗分布')}
</span>
} itemKey="1" />
<TabPane tab={
<span>
<IconPulse />
{t('消耗趋势')}
</span>
} itemKey="2" />
<TabPane tab={
<span>
<IconPieChart2Stroked />
{t('调用次数分布')}
</span>
} itemKey="2" />
} itemKey="3" />
<TabPane tab={
<span>
<IconHistogram />
{t('调用次数排行')}
</span>
} itemKey="4" />
</Tabs>
</div>
}
>
<div style={{ height: 400 }}>
{activeChartTab === '1' ? (
{activeChartTab === '1' && (
<VChart
spec={spec_line}
option={CHART_CONFIG}
/>
) : (
)}
{activeChartTab === '2' && (
<VChart
spec={spec_model_line}
option={CHART_CONFIG}
/>
)}
{activeChartTab === '3' && (
<VChart
spec={spec_pie}
option={CHART_CONFIG}
/>
)}
{activeChartTab === '4' && (
<VChart
spec={spec_rank_bar}
option={CHART_CONFIG}
/>
)}
</div>
</Card>

View File

@@ -272,10 +272,7 @@ const Home = () => {
className="w-full h-screen border-none"
/>
) : (
<div
className="text-base md:text-lg p-4 md:p-6 lg:p-8 overflow-x-hidden max-w-6xl mx-auto"
dangerouslySetInnerHTML={{ __html: homePageContent }}
></div>
<div className="mt-[64px]" dangerouslySetInnerHTML={{ __html: homePageContent }} />
)}
</div>
)}

View File

@@ -373,7 +373,7 @@ export default function UpstreamRatioSync(props) {
<div className="flex flex-col md:flex-row gap-2 w-full md:w-auto order-2 md:order-1">
<Button
icon={<RefreshCcw size={14} />}
className="!rounded-full w-full md:w-auto mt-2"
className="w-full md:w-auto mt-2"
onClick={() => {
setModalVisible(true);
if (allChannels.length === 0) {
@@ -393,7 +393,7 @@ export default function UpstreamRatioSync(props) {
type='secondary'
onClick={applySync}
disabled={!hasSelections}
className="!rounded-full w-full md:w-auto mt-2"
className="w-full md:w-auto mt-2"
>
{t('应用同步')}
</Button>
@@ -406,7 +406,7 @@ export default function UpstreamRatioSync(props) {
placeholder={t('搜索模型名称')}
value={searchKeyword}
onChange={setSearchKeyword}
className="!rounded-full w-full sm:w-64"
className="w-full sm:w-64"
showClear
/>
@@ -414,7 +414,7 @@ export default function UpstreamRatioSync(props) {
placeholder={t('按倍率类型筛选')}
value={ratioTypeFilter}
onChange={setRatioTypeFilter}
className="!rounded-full w-full sm:w-48"
className="w-full sm:w-48"
showClear
onClear={() => setRatioTypeFilter('')}
>
@@ -704,7 +704,6 @@ export default function UpstreamRatioSync(props) {
scroll={{ x: 'max-content' }}
size='middle'
loading={loading || syncLoading}
className="rounded-xl overflow-hidden"
/>
);
};

View File

@@ -139,14 +139,24 @@ const EditToken = (props) => {
if (formApiRef.current) {
if (!isEdit) {
formApiRef.current.setValues(getInitValues());
} else {
loadToken();
}
}
loadModels();
loadGroups();
}, [props.editingToken.id]);
useEffect(() => {
if (props.visiable) {
if (isEdit) {
loadToken();
} else {
formApiRef.current?.setValues(getInitValues());
}
} else {
formApiRef.current?.reset();
}
}, [props.visiable, props.editingToken.id]);
const generateRandomSuffix = () => {
const characters =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';

View File

@@ -22,6 +22,7 @@ import {
Row,
Col,
Input,
InputNumber,
} from '@douyinfe/semi-ui';
import {
IconUser,
@@ -39,7 +40,7 @@ const EditUser = (props) => {
const userId = props.editingUser.id;
const [loading, setLoading] = useState(true);
const [addQuotaModalOpen, setIsModalOpen] = useState(false);
const [addQuotaLocal, setAddQuotaLocal] = useState('0');
const [addQuotaLocal, setAddQuotaLocal] = useState('');
const [groupOptions, setGroupOptions] = useState([]);
const formApiRef = useRef(null);
@@ -254,7 +255,6 @@ const EditUser = (props) => {
field='quota'
label={t('剩余额度')}
placeholder={t('请输入新的剩余额度')}
min={0}
step={500000}
extraText={renderQuotaWithPrompt(values.quota || 0)}
rules={[{ required: true, message: t('请输入额度') }]}
@@ -328,18 +328,19 @@ const EditUser = (props) => {
const current = formApiRef.current?.getValue('quota') || 0;
return (
<Text type='secondary' className='block mb-2'>
{`${t('新额度')}${renderQuota(current)} + ${renderQuota(addQuotaLocal)} = ${renderQuota(current + parseInt(addQuotaLocal || 0))}`}
{`${t('新额度')}${renderQuota(current)} + ${renderQuota(addQuotaLocal)} = ${renderQuota(current + parseInt(addQuotaLocal || 0))}`}
</Text>
);
})()
}
</div>
<Input
<InputNumber
placeholder={t('需要添加的额度(支持负数)')}
type='number'
value={addQuotaLocal}
onChange={setAddQuotaLocal}
style={{ width: '100%' }}
showClear
step={500000}
/>
</Modal>
</>