diff --git a/docSite/content/zh-cn/docs/development/upgrading/4912.md b/docSite/content/zh-cn/docs/development/upgrading/4912.md index 836ecd57d..cfac18765 100644 --- a/docSite/content/zh-cn/docs/development/upgrading/4912.md +++ b/docSite/content/zh-cn/docs/development/upgrading/4912.md @@ -9,6 +9,7 @@ weight: 789 ## 🚀 新增内容 +1. AI proxy 服务,支持以图表形式展示模型调用情况。 1. 商业版支持知识库分块时,LLM 进行自动分段识别。 ## ⚙️ 优化 diff --git a/packages/web/i18n/en/account_model.json b/packages/web/i18n/en/account_model.json index a09ddb92e..7ab2e3b61 100644 --- a/packages/web/i18n/en/account_model.json +++ b/packages/web/i18n/en/account_model.json @@ -1,5 +1,7 @@ { "Hunyuan": "Tencent Hunyuan", + "aipoint_usage": "AI points", + "all": "All", "api_key": "API key", "azure": "Azure", "base_url": "Base url", @@ -16,6 +18,10 @@ "confirm_delete_channel": "Confirm the deletion of the [{{name}}] channel?", "copy_model_id_success": "Copyed model id", "create_channel": "Added channels", + "dashboard_error_calls": "Error Calls", + "dashboard_model": "Model", + "dashboard_points": "points", + "dashboard_token_usage": "Tokens", "default_url": "Default address", "detail": "Detail", "duration": "Duration", @@ -23,6 +29,7 @@ "edit_channel": "Channel configuration", "enable_channel": "Enable", "forbid_channel": "Disabled", + "input": "Input", "key_type": "API key format:", "log": "Call log", "log_detail": "Log details", @@ -33,9 +40,14 @@ "maxToken_tip": "Model max_tokens parameter", "max_temperature_tip": "If the model temperature parameter is not filled in, it means that the model does not support the temperature parameter.", "model": "Model", + "model_error_rate": "Error rate", + "model_error_request_times": "Number of failures", "model_name": "Model name", + "model_request_times": "Request times", "model_test": "Model testing", "model_tokens": "Input/Output tokens", + "monitoring": "Monitoring", + "output": "Output", "request_at": "Request time", "request_duration": "Request duration: {{duration}}s", "retry_times": "Number of retry times", diff --git a/packages/web/i18n/zh-CN/account_model.json b/packages/web/i18n/zh-CN/account_model.json index aa9585d9d..6a3d1fc95 100644 --- a/packages/web/i18n/zh-CN/account_model.json +++ b/packages/web/i18n/zh-CN/account_model.json @@ -1,5 +1,6 @@ { "Hunyuan": "腾讯混元", + "all": "全部", "api_key": "API 密钥", "azure": "微软 Azure", "base_url": "代理地址", @@ -16,6 +17,11 @@ "confirm_delete_channel": "确认删除 【{{name}}】渠道?", "copy_model_id_success": "已复制模型id", "create_channel": "新增渠道", + "aipoint_usage": "积分消耗", + "dashboard_error_calls": "错误次数", + "dashboard_model": "模型", + "dashboard_points": "积分", + "dashboard_token_usage": "Tokens 消耗", "default_url": "默认地址", "detail": "详情", "duration": "耗时", @@ -23,6 +29,7 @@ "edit_channel": "渠道配置", "enable_channel": "启用", "forbid_channel": "禁用", + "input": "输入", "key_type": "API key 格式: ", "log": "调用日志", "log_detail": "日志详情", @@ -33,9 +40,14 @@ "maxToken_tip": "模型 max_tokens 参数", "max_temperature_tip": "模型 temperature 参数,不填则代表模型不支持 temperature 参数。", "model": "模型", + "model_error_rate": "失败率", + "model_error_request_times": "失败次数", "model_name": "模型名", + "model_request_times": "请求次数", "model_test": "模型测试", "model_tokens": "输入/输出 Tokens", + "monitoring": "监控", + "output": "输出", "request_at": "请求时间", "request_duration": "请求时长: {{duration}}s", "retry_times": "重试次数", diff --git a/packages/web/i18n/zh-Hant/account_model.json b/packages/web/i18n/zh-Hant/account_model.json index 704abd16b..702dc925f 100644 --- a/packages/web/i18n/zh-Hant/account_model.json +++ b/packages/web/i18n/zh-Hant/account_model.json @@ -1,5 +1,7 @@ { "Hunyuan": "騰訊混元", + "aipoint_usage": "積分消耗", + "all": "全部", "api_key": "API 金鑰", "azure": "Azure", "base_url": "代理地址", @@ -16,6 +18,7 @@ "confirm_delete_channel": "確認刪除【{{name}}】管道?", "copy_model_id_success": "已復制模型 id", "create_channel": "新增管道", + "dashboard_token_usage": "Tokens 消耗", "default_url": "預設地址", "detail": "詳細資訊", "duration": "耗時", @@ -23,6 +26,7 @@ "edit_channel": "管道設定", "enable_channel": "啟用", "forbid_channel": "停用", + "input": "輸入", "key_type": "API key 格式:", "log": "呼叫日誌", "log_detail": "日誌詳細資訊", @@ -33,9 +37,14 @@ "maxToken_tip": "模型 max_tokens 參數", "max_temperature_tip": "模型 temperature 參數,不填則代表模型不支援 temperature 參數。", "model": "模型", + "model_error_rate": "失敗率", + "model_error_request_times": "失敗次數", "model_name": "模型名", + "model_request_times": "請求次數", "model_test": "模型測試", "model_tokens": "輸入/輸出 Tokens", + "monitoring": "監控", + "output": "輸出", "request_at": "請求時間", "request_duration": "請求時長:{{duration}}s", "retry_times": "重試次數", diff --git a/projects/app/src/global/aiproxy/type.d.ts b/projects/app/src/global/aiproxy/type.d.ts index 2f3e65193..78a277daa 100644 --- a/projects/app/src/global/aiproxy/type.d.ts +++ b/projects/app/src/global/aiproxy/type.d.ts @@ -52,3 +52,13 @@ export type ChannelLogListItemType = { content?: string; retry_times?: number; }; + +export type DashboardDataItemType = { + model: string; + request_count: number; + used_amount: number; + exception_count: number; + input_tokens?: number; + output_tokens?: number; + total_tokens?: number; +}; diff --git a/projects/app/src/pageComponents/account/model/ModelDashboard/LineChartComponent.tsx b/projects/app/src/pageComponents/account/model/ModelDashboard/LineChartComponent.tsx new file mode 100644 index 000000000..915f3a8af --- /dev/null +++ b/projects/app/src/pageComponents/account/model/ModelDashboard/LineChartComponent.tsx @@ -0,0 +1,169 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { Box, HStack, useTheme } from '@chakra-ui/react'; +import { + ResponsiveContainer, + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + type TooltipProps +} from 'recharts'; +import { type NameType, type ValueType } from 'recharts/types/component/DefaultTooltipContent'; +import { formatNumber } from '@fastgpt/global/common/math/tools'; + +type XAxisConfig = { + dataKey: string; +}; + +type LineConfig = { + dataKey: string; + name: string; + color: string; + gradient?: boolean; +}; + +type TooltipItem = { + label: string; + dataKey: string; + color: string; + formatter?: (value: number) => string; + customValue?: (data: any) => number; +}; + +type LineChartComponentProps = { + data: Record[]; + title: string; + HeaderRightChildren?: React.ReactNode; + lines: LineConfig[]; + tooltipItems?: TooltipItem[]; +}; + +const CustomTooltip = ({ + active, + payload, + tooltipItems +}: TooltipProps & { tooltipItems?: TooltipItem[] }) => { + const data = payload?.[0]?.payload; + + if (active && data && tooltipItems) { + return ( + + + {data.x} + + {tooltipItems.map((item, index) => { + const value = (() => { + if (item.customValue) { + return item.customValue(data); + } else { + return data[item.dataKey]; + } + })(); + + const displayValue = item.formatter ? item.formatter(value) : formatNumber(value); + + return ( + + + {item.label} + {displayValue} + + ); + })} + + ); + } + return null; +}; + +const LineChartComponent = ({ + data, + title, + HeaderRightChildren, + lines, + tooltipItems +}: LineChartComponentProps) => { + const theme = useTheme(); + + // Y-axis number formatter function + const formatYAxisNumber = useCallback((value: number): string => { + if (value >= 1000000) { + return value / 1000000 + 'M'; + } else if (value >= 1000) { + return value / 1000 + 'K'; + } + return value.toString(); + }, []); + + // Generate gradient definitions + const gradientDefs = useMemo(() => { + return ( + + {lines.map((line, index) => ( + + + + + ))} + + ); + }, [lines]); + + return ( + <> + + + {title} + + {HeaderRightChildren && HeaderRightChildren} + + + + {gradientDefs} + + + + {tooltipItems && } />} + {lines.map((line, index) => ( + + ))} + + + + ); +}; + +export default LineChartComponent; diff --git a/projects/app/src/pageComponents/account/model/ModelDashboard/index.tsx b/projects/app/src/pageComponents/account/model/ModelDashboard/index.tsx new file mode 100644 index 000000000..e661407b9 --- /dev/null +++ b/projects/app/src/pageComponents/account/model/ModelDashboard/index.tsx @@ -0,0 +1,405 @@ +import React, { useMemo, useState } from 'react'; +import type { BoxProps } from '@chakra-ui/react'; +import { Box, Flex, Grid, HStack, useTheme } from '@chakra-ui/react'; +import { formatNumber } from '@fastgpt/global/common/math/tools'; +import MyBox from '@fastgpt/web/components/common/MyBox'; +import { useRequest2 } from '@fastgpt/web/hooks/useRequest'; +import { useTranslation } from 'next-i18next'; +import { addDays } from 'date-fns'; +import dayjs from 'dayjs'; +import DateRangePicker, { + type DateRangeType +} from '@fastgpt/web/components/common/DateRangePicker'; +import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel'; +import MySelect from '@fastgpt/web/components/common/MySelect'; +import { getChannelList, getDashboardV2 } from '@/web/core/ai/channel'; +import { getSystemModelList } from '@/web/core/ai/config'; +import { getModelProvider } from '@fastgpt/global/core/ai/provider'; +import LineChartComponent from './LineChartComponent'; +import FillRowTabs from '@fastgpt/web/components/common/Tabs/FillRowTabs'; +import { useSystemStore } from '@/web/common/system/useSystemStore'; + +export type ModelDashboardData = { + x: string; + totalCalls: number; + errorCalls: number; + errorRate: number; + inputTokens: number; + outputTokens: number; + totalTokens: number; + totalCost: number; +}; + +const ChartsBoxStyles: BoxProps = { + px: 5, + pt: 4, + pb: 8, + h: '300px', + border: 'base', + borderRadius: 'md', + overflow: 'hidden' +}; + +// Displays model usage statistics, token consumption and cost visualization +const ModelDashboard = ({ Tab }: { Tab: React.ReactNode }) => { + const { t } = useTranslation(); + const theme = useTheme(); + const { feConfigs } = useSystemStore(); + + const [filterProps, setFilterProps] = useState<{ + channelId?: string; + model?: string; + dateRange: DateRangeType; + }>({ + channelId: undefined, + model: undefined, + dateRange: { + from: (() => { + const today = addDays(new Date(), -7); + today.setHours(0, 0, 0, 0); + return today; + })(), + to: (() => { + const today = new Date(); + today.setHours(23, 59, 59, 999); + return today; + })() + } + }); + + // Fetch channel list with "All" option + const { data: channelList = [] } = useRequest2( + async () => { + const res = await getChannelList().then((res) => + res.map((item) => ({ + label: item.name, + value: `${item.id}` + })) + ); + return [ + { + label: t('common:All'), + value: '' + }, + ...res + ]; + }, + { + manual: false + } + ); + + // Get model list filtered by selected channel + const { data: systemModelList = [] } = useRequest2(getSystemModelList, { + manual: false + }); + const modelList = useMemo(() => { + const res = systemModelList + .map((item) => { + const provider = getModelProvider(item.provider); + return { + order: provider.order, + icon: provider.avatar, + label: item.model, + value: item.model + }; + }) + .sort((a, b) => a.order - b.order); + return [ + { + label: t('common:All'), + value: '' + }, + ...res + ]; + }, [systemModelList, t]); + + // Fetch dashboard data with date range and channel filters + const { data: dashboardData = [], loading: isLoading } = useRequest2( + async () => { + const params = { + channel: filterProps.channelId ? parseInt(filterProps.channelId) : 0, + start_timestamp: filterProps.dateRange.from + ? Math.floor(filterProps.dateRange.from.getTime()) + : undefined, + end_timestamp: filterProps.dateRange.to + ? Math.floor(filterProps.dateRange.to.getTime()) + : undefined, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + timespan: 'day' as const + }; + + let data = await getDashboardV2(params); + + if (filterProps.model) { + data = data.map((item) => { + const filterModels = item.models.filter((model) => model.model === filterProps.model); + return { + ...item, + models: filterModels + }; + }); + } + + return data; + }, + { + manual: false, + refreshDeps: [filterProps.channelId, filterProps.dateRange, filterProps.model] + } + ); + + // Process chart data - aggregate daily model calls, token usage and cost data + const chartData: ModelDashboardData[] = useMemo(() => { + if (dashboardData.length === 0) { + return []; + } + + // Model price map + const modelPriceMap = new Map< + string, + { + inputPrice?: number; + outputPrice?: number; + charsPointsPrice?: number; + } + >(); + systemModelList.forEach((model) => { + modelPriceMap.set(model.model, { + inputPrice: model.inputPrice, + outputPrice: model.outputPrice, + charsPointsPrice: model.charsPointsPrice + }); + }); + + return dashboardData.map((item) => { + const date = dayjs(item.timestamp * 1000).format('MM-DD'); + const totalCalls = item.models.reduce((acc, model) => acc + model.request_count, 0); + const errorCalls = item.models.reduce((acc, model) => acc + model.exception_count, 0); + const errorRate = Number((errorCalls / totalCalls).toFixed(2)); + + const inputTokens = item.models.reduce((acc, model) => acc + (model?.input_tokens || 0), 0); + const outputTokens = item.models.reduce((acc, model) => acc + (model?.output_tokens || 0), 0); + const totalTokens = item.models.reduce((acc, model) => acc + (model?.total_tokens || 0), 0); + + const totalCost = item.models.reduce((acc, model) => { + const modelPricing = modelPriceMap.get(model.model); + + if (modelPricing) { + const inputTokens = model.input_tokens || 0; + const outputTokens = model.output_tokens || 0; + const isIOPriceType = + typeof modelPricing.inputPrice === 'number' && modelPricing.inputPrice > 0; + + const totalPoints = isIOPriceType + ? (modelPricing.inputPrice || 0) * (inputTokens / 1000) + + (modelPricing.outputPrice || 0) * (outputTokens / 1000) + : ((modelPricing.charsPointsPrice || 0) * (inputTokens + outputTokens)) / 1000; + + return acc + totalPoints; + } + + return acc; + }, 0); + + return { + x: date, + totalCalls, + errorCalls, + errorRate, + inputTokens, + outputTokens, + totalTokens, + totalCost + }; + }); + }, [dashboardData, systemModelList]); + + const [tokensUsageType, setTokensUsageType] = useState< + 'inputTokens' | 'outputTokens' | 'totalTokens' + >('totalTokens'); + console.log(chartData); + return ( + <> + {Tab} + + + + {t('common:user.Time')} + + setFilterProps({ ...filterProps, dateRange: e })} + /> + + + + {t('account_model:channel_name')} + + + bg={'myGray.50'} + isSearch + list={channelList} + placeholder={t('account_model:select_channel')} + value={filterProps.channelId} + onChange={(val) => setFilterProps({ ...filterProps, channelId: val })} + /> + + + + {t('account_model:model_name')} + + + bg={'myGray.50'} + isSearch + list={modelList} + placeholder={t('account_model:select_model')} + value={filterProps.model} + onChange={(val) => setFilterProps({ ...filterProps, model: val })} + /> + + + + + + {dashboardData && dashboardData.length > 0 && ( + <> + + + + + + + + + + + + + + + + list={[ + { + label: t('account_model:all'), + value: 'totalTokens' + }, + { + label: t('account_model:input'), + value: 'inputTokens' + }, + { + label: t('account_model:output'), + value: 'outputTokens' + } + ]} + py={1} + px={5} + value={tokensUsageType} + onChange={(val) => setTokensUsageType(val)} + /> + } + /> + + + {feConfigs?.isPlus && ( + + + + )} + + )} + + + ); +}; + +export default React.memo(ModelDashboard); diff --git a/projects/app/src/pages/account/model/index.tsx b/projects/app/src/pages/account/model/index.tsx index 5f14b0413..6137a45fb 100644 --- a/projects/app/src/pages/account/model/index.tsx +++ b/projects/app/src/pages/account/model/index.tsx @@ -13,8 +13,9 @@ import { useSystemStore } from '@/web/common/system/useSystemStore'; const ModelConfigTable = dynamic(() => import('@/pageComponents/account/model/ModelConfigTable')); const ChannelTable = dynamic(() => import('@/pageComponents/account/model/Channel')); const ChannelLog = dynamic(() => import('@/pageComponents/account/model/Log')); +const ModelDashboard = dynamic(() => import('@/pageComponents/account/model/ModelDashboard')); -type TabType = 'model' | 'config' | 'channel' | 'channel_log'; +type TabType = 'model' | 'config' | 'channel' | 'channel_log' | 'account_model'; const ModelProvider = () => { const { t } = useTranslation(); @@ -32,7 +33,8 @@ const ModelProvider = () => { ...(feConfigs?.show_aiproxy ? [ { label: t('account:channel'), value: 'channel' }, - { label: t('account_model:log'), value: 'channel_log' } + { label: t('account_model:log'), value: 'channel_log' }, + { label: t('account_model:monitoring'), value: 'account_model' } ] : []) ]} @@ -50,6 +52,7 @@ const ModelProvider = () => { {tab === 'config' && } {tab === 'channel' && } {tab === 'channel_log' && } + {tab === 'account_model' && } ); diff --git a/projects/app/src/pages/api/aiproxy/[...path].ts b/projects/app/src/pages/api/aiproxy/[...path].ts index f63d7500c..a07c5864b 100644 --- a/projects/app/src/pages/api/aiproxy/[...path].ts +++ b/projects/app/src/pages/api/aiproxy/[...path].ts @@ -7,6 +7,11 @@ import { authSystemAdmin } from '@fastgpt/service/support/permission/user/auth'; const baseUrl = process.env.AIPROXY_API_ENDPOINT; const token = process.env.AIPROXY_API_TOKEN; +// 特殊路径映射,标记需要在末尾保留斜杠的路径 +const endPathMap: Record = { + 'api/dashboardv2': true +}; + export default async function handler(req: NextApiRequest, res: NextApiResponse) { try { await authSystemAdmin({ req }); @@ -22,9 +27,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } const queryStr = new URLSearchParams(query).toString(); - const requestPath = queryStr - ? `/${path?.join('/')}?${new URLSearchParams(query).toString()}` - : `/${path?.join('/')}`; + // Determine whether the base path requires a trailing slash. + const basePath = `/${path?.join('/')}${endPathMap[path?.join('/')] ? '/' : ''}`; + const requestPath = queryStr ? `${basePath}?${queryStr}` : basePath; const parsedUrl = new URL(baseUrl); delete req.headers?.cookie; diff --git a/projects/app/src/web/core/ai/channel.ts b/projects/app/src/web/core/ai/channel.ts index cea2b6097..1db9c3e7f 100644 --- a/projects/app/src/web/core/ai/channel.ts +++ b/projects/app/src/web/core/ai/channel.ts @@ -1,10 +1,11 @@ import axios, { type Method, type AxiosResponse } from 'axios'; import { getWebReqUrl } from '@fastgpt/web/common/system/utils'; -import { - type ChannelInfoType, - type ChannelListResponseType, - type ChannelLogListItemType, - type CreateChannelProps +import type { + DashboardDataItemType, + ChannelInfoType, + ChannelListResponseType, + ChannelLogListItemType, + CreateChannelProps } from '@/global/aiproxy/type'; import type { ChannelStatusEnum } from '@/global/aiproxy/constants'; @@ -187,3 +188,23 @@ export const getLogDetail = (id: number) => request_body: string; response_body: string; }>(`/logs/detail/${id}`); + +export const getDashboardV2 = (params: { + channel?: number; + start_timestamp?: number; + end_timestamp?: number; + timezone?: string; + timespan?: 'day' | 'hour'; +}) => + GET< + { + timestamp: number; + models: DashboardDataItemType[]; + }[] + >('/dashboardv2/', { + channel: params.channel, + start_timestamp: params.start_timestamp, + end_timestamp: params.end_timestamp, + timezone: params.timezone || 'Local', + timespan: params.timespan || 'day' + });