From 9f8b75fd7cf3095ac1e29db51e85aaa9f2b24c0f Mon Sep 17 00:00:00 2001 From: Archer <545436317@qq.com> Date: Wed, 11 Jun 2025 18:21:59 +0800 Subject: [PATCH] Ai proxy monitor (#5009) * Time granularity and Table of a single model (#4990) * Aiproxy ModelBoard * Add components LineChartComponent and Make some revisions * Time granularity and Table of a single model * Modify the logic and sort the tables in ascending or descending order * Use theme and present in seconds * Add the channel name section (#5005) * Add components LineChartComponent and Make some revisions * Time granularity and Table of a single model * Modify the logic and sort the tables in ascending or descending order * Add the channel name section * The channel_name is transmitted from the outer layer * Restore the channel * perf: dashboard code * perf: ai proxy monitor * code --------- Co-authored-by: Zhuangzai fa <143257420+ctrlz526@users.noreply.github.com> --- .../zh-cn/docs/development/upgrading/4912.md | 4 +- packages/web/i18n/en/account_model.json | 26 + packages/web/i18n/zh-CN/account_model.json | 28 +- packages/web/i18n/zh-Hant/account_model.json | 29 + projects/app/src/global/aiproxy/type.d.ts | 11 +- .../ModelDashboard/DataTableComponent.tsx | 307 +++++++ .../ModelDashboard/LineChartComponent.tsx | 141 ++-- .../account/model/ModelDashboard/index.tsx | 746 ++++++++++++------ projects/app/src/web/core/ai/channel.ts | 15 +- 9 files changed, 998 insertions(+), 309 deletions(-) create mode 100644 projects/app/src/pageComponents/account/model/ModelDashboard/DataTableComponent.tsx diff --git a/docSite/content/zh-cn/docs/development/upgrading/4912.md b/docSite/content/zh-cn/docs/development/upgrading/4912.md index 6ea79d5c0..3ab12050c 100644 --- a/docSite/content/zh-cn/docs/development/upgrading/4912.md +++ b/docSite/content/zh-cn/docs/development/upgrading/4912.md @@ -9,8 +9,8 @@ weight: 788 ## 🚀 新增内容 -1. AI proxy 服务,支持以图表形式展示模型调用情况。 -1. 商业版支持知识库分块时,LLM 进行自动分段识别。 +1. AI proxy 监控完善,支持以图表/表格形式查看模型调用和性能情况。 +2. 商业版支持知识库分块时,LLM 进行自动分段识别。 ## ⚙️ 优化 diff --git a/packages/web/i18n/en/account_model.json b/packages/web/i18n/en/account_model.json index 7ab2e3b61..6478d1842 100644 --- a/packages/web/i18n/en/account_model.json +++ b/packages/web/i18n/en/account_model.json @@ -3,6 +3,8 @@ "aipoint_usage": "AI points", "all": "All", "api_key": "API key", + "avg_response_time": "Average call time (seconds)", + "avg_ttfb": "Average first word duration (seconds)", "azure": "Azure", "base_url": "Base url", "channel_name": "Channel", @@ -14,14 +16,28 @@ "channel_status_enabled": "Enable", "channel_status_unknown": "unknown", "channel_type": "Manufacturer", + "chart_mode_cumulative": "Cumulative", + "chart_mode_incremental": "Incremental", "clear_model": "Clear the model", "confirm_delete_channel": "Confirm the deletion of the [{{name}}] channel?", "copy_model_id_success": "Copyed model id", "create_channel": "Added channels", + "dashboard_call_trend": "Model Call Trend", + "dashboard_channel": "Channel", + "dashboard_cost_trend": "Cost Consumption", "dashboard_error_calls": "Error Calls", + "dashboard_input_tokens": "Input Tokens", "dashboard_model": "Model", + "dashboard_no_data": "No data available", + "dashboard_output_tokens": "Output Tokens", "dashboard_points": "points", + "dashboard_success_calls": "Success Calls", + "dashboard_token_trend": "Token Usage Trend", "dashboard_token_usage": "Tokens", + "dashboard_total_calls": "Total Calls:", + "dashboard_total_cost": "Total Cost", + "dashboard_total_cost_label": "Total Cost:", + "dashboard_total_tokens": "Total Tokens", "default_url": "Default address", "detail": "Detail", "duration": "Duration", @@ -38,7 +54,9 @@ "mapping": "Model Mapping", "mapping_tip": "A valid Json is required. \nThe model can be mapped when sending a request to the actual address. \nFor example:\n{\n \n \"gpt-4o\": \"gpt-4o-test\"\n\n}\n\nWhen FastGPT requests the gpt-4o model, the gpt-4o-test model is sent to the actual address, instead of gpt-4o.", "maxToken_tip": "Model max_tokens parameter", + "max_rpm": "Max RPM (Requests Per Minute)", "max_temperature_tip": "If the model temperature parameter is not filled in, it means that the model does not support the temperature parameter.", + "max_tpm": "Max TPM (Tokens Per Minute)", "model": "Model", "model_error_rate": "Error rate", "model_error_request_times": "Number of failures", @@ -60,7 +78,15 @@ "selected_model_empty": "Choose at least one model", "start_test": "Batch test {{num}} models", "test_failed": "There are {{num}} models that report errors", + "timespan_day": "Day", + "timespan_hour": "Hour", + "timespan_label": "Time Granularity", + "timespan_minute": "Minute", + "total_call_volume": "Request amount", + "view_chart": "Chart", + "view_table": "Table", "vlm_model": "Vlm", "vlm_model_tip": "Used to generate additional indexing of images in a document in the knowledge base", + "volunme_of_failed_calls": "Error amount", "waiting_test": "Waiting for testing" } diff --git a/packages/web/i18n/zh-CN/account_model.json b/packages/web/i18n/zh-CN/account_model.json index 6a3d1fc95..ba48fad56 100644 --- a/packages/web/i18n/zh-CN/account_model.json +++ b/packages/web/i18n/zh-CN/account_model.json @@ -1,7 +1,10 @@ { "Hunyuan": "腾讯混元", + "aipoint_usage": "积分消耗", "all": "全部", "api_key": "API 密钥", + "avg_response_time": "平均调用时长 (秒)", + "avg_ttfb": "平均首字时长 (秒)", "azure": "微软 Azure", "base_url": "代理地址", "channel_name": "渠道名", @@ -13,15 +16,28 @@ "channel_status_enabled": "启用", "channel_status_unknown": "未知", "channel_type": "厂商", + "chart_mode_cumulative": "累积", + "chart_mode_incremental": "分时", "clear_model": "清空模型", "confirm_delete_channel": "确认删除 【{{name}}】渠道?", "copy_model_id_success": "已复制模型id", "create_channel": "新增渠道", - "aipoint_usage": "积分消耗", + "dashboard_call_trend": "模型调用次数趋势", + "dashboard_channel": "渠道", + "dashboard_cost_trend": "积分消耗", "dashboard_error_calls": "错误次数", + "dashboard_input_tokens": "输入Tokens", "dashboard_model": "模型", + "dashboard_no_data": "暂无数据", + "dashboard_output_tokens": "输出Tokens", "dashboard_points": "积分", + "dashboard_success_calls": "成功次数", + "dashboard_token_trend": "Tokens使用趋势", "dashboard_token_usage": "Tokens 消耗", + "dashboard_total_calls": "总调用次数:", + "dashboard_total_cost": "总成本", + "dashboard_total_cost_label": "总成本:", + "dashboard_total_tokens": "总Tokens", "default_url": "默认地址", "detail": "详情", "duration": "耗时", @@ -38,7 +54,9 @@ "mapping": "模型映射", "mapping_tip": "需填写一个有效 Json。可在向实际地址发送请求时,对模型进行映射。例如:\n{\n \"gpt-4o\": \"gpt-4o-test\"\n}\n当 FastGPT 请求 gpt-4o 模型时,会向实际地址发送 gpt-4o-test 的模型,而不是 gpt-4o。", "maxToken_tip": "模型 max_tokens 参数", + "max_rpm": "最大RPM (每分钟请求数)", "max_temperature_tip": "模型 temperature 参数,不填则代表模型不支持 temperature 参数。", + "max_tpm": "最大TPM (每分钟Token数)", "model": "模型", "model_error_rate": "失败率", "model_error_request_times": "失败次数", @@ -60,7 +78,15 @@ "selected_model_empty": "至少选择一个模型", "start_test": "批量测试{{num}}个模型", "test_failed": "有{{num}}个模型报错", + "timespan_day": "天", + "timespan_hour": "小时", + "timespan_label": "时间颗粒度", + "timespan_minute": "分钟", + "total_call_volume": "调用总量", + "view_chart": "图表", + "view_table": "表格", "vlm_model": "图片理解模型", "vlm_model_tip": "用于知识库中对文档中的图片进行额外的索引生成", + "volunme_of_failed_calls": "调用失败量", "waiting_test": "等待测试" } diff --git a/packages/web/i18n/zh-Hant/account_model.json b/packages/web/i18n/zh-Hant/account_model.json index 702dc925f..7da4e0a90 100644 --- a/packages/web/i18n/zh-Hant/account_model.json +++ b/packages/web/i18n/zh-Hant/account_model.json @@ -3,6 +3,8 @@ "aipoint_usage": "積分消耗", "all": "全部", "api_key": "API 金鑰", + "avg_response_time": "平均調用時長 (秒)", + "avg_ttfb": "平均首字時長 (秒)", "azure": "Azure", "base_url": "代理地址", "channel_name": "管道名稱", @@ -14,11 +16,28 @@ "channel_status_enabled": "啟用", "channel_status_unknown": "未知", "channel_type": "廠商", + "chart_mode_cumulative": "累積", + "chart_mode_incremental": "分時", "clear_model": "清空模型", "confirm_delete_channel": "確認刪除【{{name}}】管道?", "copy_model_id_success": "已復制模型 id", "create_channel": "新增管道", + "dashboard_call_trend": "模型呼叫次數趨勢", + "dashboard_channel": "管道", + "dashboard_cost_trend": "積分消耗", + "dashboard_error_calls": "錯誤次數", + "dashboard_input_tokens": "輸入Tokens", + "dashboard_model": "模型", + "dashboard_no_data": "暫無資料", + "dashboard_output_tokens": "輸出Tokens", + "dashboard_points": "積分", + "dashboard_success_calls": "成功次數", + "dashboard_token_trend": "Tokens使用趨勢", "dashboard_token_usage": "Tokens 消耗", + "dashboard_total_calls": "總呼叫次數:", + "dashboard_total_cost": "總成本", + "dashboard_total_cost_label": "總成本:", + "dashboard_total_tokens": "總Tokens", "default_url": "預設地址", "detail": "詳細資訊", "duration": "耗時", @@ -35,7 +54,9 @@ "mapping": "模型對映", "mapping_tip": "需填寫一個有效 Json。\n可在向實際地址傳送請求時,對模型進行對映。\n例如:\n{\n \n \"gpt-4o\": \"gpt-4o-test\"\n\n}\n\n當 FastGPT 請求 gpt-4o 模型時,會向實際地址傳送 gpt-4o-test 的模型,而不是 gpt-4o。", "maxToken_tip": "模型 max_tokens 參數", + "max_rpm": "最大RPM (每分鐘請求數)", "max_temperature_tip": "模型 temperature 參數,不填則代表模型不支援 temperature 參數。", + "max_tpm": "最大TPM (每分鐘Token數)", "model": "模型", "model_error_rate": "失敗率", "model_error_request_times": "失敗次數", @@ -57,7 +78,15 @@ "selected_model_empty": "至少選擇一個模型", "start_test": "批次測試{{num}}個模型", "test_failed": "有{{num}}個模型報錯", + "timespan_day": "天", + "timespan_hour": "小時", + "timespan_label": "時間顆粒度", + "timespan_minute": "分鐘", + "total_call_volume": "調用總量", + "view_chart": "圖表", + "view_table": "表格", "vlm_model": "圖片理解模型", "vlm_model_tip": "用於知識庫中對文件中的圖片進行額外的索引生成", + "volunme_of_failed_calls": "調用失敗量", "waiting_test": "等待測試" } diff --git a/projects/app/src/global/aiproxy/type.d.ts b/projects/app/src/global/aiproxy/type.d.ts index 78a277daa..3fa1538f5 100644 --- a/projects/app/src/global/aiproxy/type.d.ts +++ b/projects/app/src/global/aiproxy/type.d.ts @@ -54,11 +54,16 @@ export type ChannelLogListItemType = { }; export type DashboardDataItemType = { + channel_id?: number; model: string; - request_count: number; - used_amount: number; - exception_count: number; + request_count?: number; + used_amount?: number; + exception_count?: number; + total_time_milliseconds?: number; + total_ttfb_milliseconds?: number; input_tokens?: number; output_tokens?: number; total_tokens?: number; + max_rpm?: number; + max_tpm?: number; }; diff --git a/projects/app/src/pageComponents/account/model/ModelDashboard/DataTableComponent.tsx b/projects/app/src/pageComponents/account/model/ModelDashboard/DataTableComponent.tsx new file mode 100644 index 000000000..89201021f --- /dev/null +++ b/projects/app/src/pageComponents/account/model/ModelDashboard/DataTableComponent.tsx @@ -0,0 +1,307 @@ +import React, { useMemo, useState } from 'react'; +import { Table, TableContainer, Thead, Tbody, Tr, Th, Td, Button } from '@chakra-ui/react'; +import { formatNumber } from '@fastgpt/global/common/math/tools'; +import { useTranslation } from 'next-i18next'; +import MyBox from '@fastgpt/web/components/common/MyBox'; +import MyIcon from '@fastgpt/web/components/common/Icon'; +import EmptyTip from '@fastgpt/web/components/common/EmptyTip'; +import type { DashboardDataItemType } from '@/global/aiproxy/type.d'; +import { useSystemStore } from '@/web/common/system/useSystemStore'; + +export type DashboardDataEntry = { + timestamp: number; + summary: DashboardDataItemType[]; +}; + +export type DataTableComponentProps = { + data: DashboardDataEntry[]; + filterProps: { + channelId?: string; + model?: string; + }; + channelList: { + label: string; + value: string; + }[]; + modelPriceMap: Map< + string, + { + inputPrice?: number; + outputPrice?: number; + charsPointsPrice?: number; + } + >; + onViewDetail: (model: string) => void; +}; + +type SortFieldType = 'totalCalls' | 'errorCalls' | 'totalCost'; + +const DataTableComponent = ({ + data, + filterProps, + onViewDetail, + channelList, + modelPriceMap +}: DataTableComponentProps) => { + const { t } = useTranslation(); + const { feConfigs } = useSystemStore(); + const [sortField, setSortField] = useState('totalCalls'); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); + + // Create a mapping from channel ID to channel name + const channelIdToNameMap = useMemo(() => { + const map = new Map(); + channelList.forEach((channel) => { + if (channel.value && channel.value !== '') { + const channelId = parseInt(channel.value); + if (!isNaN(channelId)) { + map.set(channelId, channel.label); + } + } + }); + return map; + }, [channelList]); + + // display the channel column + const showChannelColumn = !!filterProps.model; + + const tableData = useMemo(() => { + if (data.length === 0) { + return []; + } + + const rows: { + model: string; + channelName?: string; + totalCalls: number; + errorCalls: number; + totalCost: number; + avgResponseTime: number; + avgTtfb: number; + }[] = []; + + if (showChannelColumn) { + // When a specific model is selected, aggregate the data by channel_id + const channelMap = new Map< + string, + { + model: string; + totalCalls: number; + errorCalls: number; + totalCost: number; + totalResponseTime: number; + totalTtfb: number; + } + >(); + + data.forEach((dayData) => { + const summary = dayData.summary; + + summary.forEach((item: DashboardDataItemType) => { + const channelId = `${item.channel_id!}`; + const existing = channelMap.get(channelId) || { + model: item.model || '-', + totalCalls: 0, + errorCalls: 0, + totalCost: 0, + totalResponseTime: 0, + totalTtfb: 0 + }; + + existing.totalCalls += item.request_count || 0; + existing.errorCalls += item.exception_count || 0; + existing.totalResponseTime += item.total_time_milliseconds || 0; + existing.totalTtfb += item.total_ttfb_milliseconds || 0; + + const modelPricing = modelPriceMap.get(item.model); + if (modelPricing) { + const inputTokens = item.input_tokens || 0; + const outputTokens = item.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; + + existing.totalCost += totalPoints; + } + + channelMap.set(channelId, existing); + }); + }); + + channelMap.forEach((item, channelId) => { + const successCalls = item.totalCalls - item.errorCalls; + + rows.push({ + channelName: channelIdToNameMap.get(parseInt(channelId)) || '', + model: item.model, + totalCalls: item.totalCalls, + errorCalls: item.errorCalls, + totalCost: item.totalCost, + avgResponseTime: successCalls > 0 ? item.totalResponseTime / successCalls / 1000 : 0, + avgTtfb: successCalls > 0 ? item.totalTtfb / successCalls / 1000 : 0 + }); + }); + } else { + // When no specific model is selected, aggregate the data by the model. + const modelMap = new Map< + string, + { + totalCalls: number; + errorCalls: number; + totalCost: number; + totalResponseTime: number; + totalTtfb: number; + } + >(); + + data.forEach((dayData) => { + const summary = dayData.summary; + + summary.forEach((item: DashboardDataItemType) => { + const modelName = item.model || '-'; + const existing = modelMap.get(modelName) || { + totalCalls: 0, + errorCalls: 0, + totalCost: 0, + totalResponseTime: 0, + totalTtfb: 0 + }; + + existing.totalCalls += item.request_count || 0; + existing.errorCalls += item.exception_count || 0; + existing.totalResponseTime += item.total_time_milliseconds || 0; + existing.totalTtfb += item.total_ttfb_milliseconds || 0; + + const modelPricing = modelPriceMap.get(item.model); + if (modelPricing) { + const inputTokens = item.input_tokens || 0; + const outputTokens = item.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; + + existing.totalCost += totalPoints; + } + + modelMap.set(modelName, existing); + }); + }); + + modelMap.forEach((item, modelName) => { + const successCalls = item.totalCalls - item.errorCalls; + rows.push({ + model: modelName, + totalCalls: item.totalCalls, + errorCalls: item.errorCalls, + totalCost: item.totalCost, + avgResponseTime: successCalls > 0 ? item.totalResponseTime / successCalls / 1000 : 0, + avgTtfb: successCalls > 0 ? item.totalTtfb / successCalls / 1000 : 0 + }); + }); + } + + // sort + if (sortField) { + rows.sort((a, b) => { + const aVal = a[sortField]; + const bVal = b[sortField]; + return sortDirection === 'desc' ? bVal - aVal : aVal - bVal; + }); + } + + return rows; + }, [data, showChannelColumn, sortField, modelPriceMap, channelIdToNameMap, sortDirection]); + + const handleSort = (field: SortFieldType) => { + if (sortField === field) { + // Toggle between desc and asc + setSortDirection(sortDirection === 'desc' ? 'asc' : 'desc'); + } else { + // Switch to new field, default to desc + setSortField(field); + setSortDirection('desc'); + } + }; + + const getSortIcon = (field: SortFieldType) => { + if (sortField !== field) return null; + return sortDirection === 'desc' ? '↓' : '↑'; + }; + + return ( + + + + + + + {showChannelColumn && } + + + {feConfigs?.isPlus && ( + + )} + + + + + + + {tableData.map((item, index) => ( + + + {showChannelColumn && } + + + {feConfigs?.isPlus && } + + + + + ))} + +
{t('account_model:dashboard_model')}{t('account_model:dashboard_channel')} handleSort('totalCalls')} + _hover={{ color: 'primary.600' }} + > + {t('account_model:total_call_volume')} {getSortIcon('totalCalls')} + handleSort('errorCalls')} + _hover={{ color: 'primary.600' }} + > + {t('account_model:volunme_of_failed_calls')} {getSortIcon('errorCalls')} + handleSort('totalCost')} + _hover={{ color: 'primary.600' }} + > + {t('account_model:aipoint_usage')} {getSortIcon('totalCost')} + {t('account_model:avg_response_time')}{t('account_model:avg_ttfb')}
{item.model}{item.channelName}{formatNumber(item.totalCalls).toLocaleString()}{formatNumber(item.errorCalls)}{formatNumber(item.totalCost).toLocaleString()} 10 ? 'yellow.700' : ''}> + {item.avgResponseTime > 0 ? `${item.avgResponseTime.toFixed(2)}` : '-'} + {item.avgTtfb > 0 ? `${item.avgTtfb.toFixed(2)}` : '-'} + +
+
+ {tableData.length === 0 && } +
+ ); +}; + +export default DataTableComponent; diff --git a/projects/app/src/pageComponents/account/model/ModelDashboard/LineChartComponent.tsx b/projects/app/src/pageComponents/account/model/ModelDashboard/LineChartComponent.tsx index bafc65c49..9a260947e 100644 --- a/projects/app/src/pageComponents/account/model/ModelDashboard/LineChartComponent.tsx +++ b/projects/app/src/pageComponents/account/model/ModelDashboard/LineChartComponent.tsx @@ -12,10 +12,9 @@ import { } from 'recharts'; import { type NameType, type ValueType } from 'recharts/types/component/DefaultTooltipContent'; import { formatNumber } from '@fastgpt/global/common/math/tools'; - -type XAxisConfig = { - dataKey: string; -}; +import FillRowTabs from '@fastgpt/web/components/common/Tabs/FillRowTabs'; +import { useTranslation } from 'next-i18next'; +import { cloneDeep } from 'lodash'; type LineConfig = { dataKey: string; @@ -29,15 +28,16 @@ type TooltipItem = { dataKey: string; color: string; formatter?: (value: number) => string; - customValue?: (data: any) => number; + customValue?: (data: Record) => number; }; type LineChartComponentProps = { data: Record[]; title: string; - HeaderRightChildren?: React.ReactNode; + HeaderLeftChildren?: React.ReactNode; lines: LineConfig[]; tooltipItems?: TooltipItem[]; + enableCumulative?: boolean; }; const CustomTooltip = ({ @@ -47,48 +47,51 @@ const CustomTooltip = ({ }: 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 = (() => { - const val = item.formatter ? item.formatter(value) : formatNumber(value); - return val.toLocaleString(); - })(); - - return ( - - - {item.label} - {displayValue} - - ); - })} - - ); + if (!active || !data || !tooltipItems) { + return null; } - return null; + + return ( + + + {data.xLabel || data.x} + + {tooltipItems.map((item, index) => { + const value = item.customValue ? item.customValue(data) : data[item.dataKey]; + const displayValue = item.formatter ? item.formatter(value) : formatNumber(value); + + return ( + + + {item.label} + {displayValue.toLocaleString()} + + ); + })} + + ); }; const LineChartComponent = ({ data, title, - HeaderRightChildren, + HeaderLeftChildren, lines, - tooltipItems + tooltipItems, + enableCumulative = true }: LineChartComponentProps) => { const theme = useTheme(); + const { t } = useTranslation(); + const [displayMode, setDisplayMode] = useState<'incremental' | 'cumulative'>('incremental'); + + // Tab list constant + const tabList = useMemo( + () => [ + { label: t('account_model:chart_mode_incremental'), value: 'incremental' as const }, + { label: t('account_model:chart_mode_cumulative'), value: 'cumulative' as const } + ], + [t] + ); // Y-axis number formatter function const formatYAxisNumber = useCallback((value: number): string => { @@ -100,11 +103,35 @@ const LineChartComponent = ({ return value.toString(); }, []); + // Process data based on display mode + const processedData = useMemo(() => { + if (displayMode === 'incremental' || !enableCumulative) { + return data; + } + + // Cumulative mode: accumulate values for each line's dataKey + const cloneData = cloneDeep(data); + + const dataKeys = lines.map((item) => item.dataKey); + + return cloneData.map((item, index) => { + if (index === 0) return item; + + dataKeys.forEach((key) => { + if (typeof item[key] === 'number') { + item[key] += cloneData[index - 1][key]; + } + }); + + return item; + }); + }, [data, displayMode, lines, enableCumulative]); + // Generate gradient definitions - const gradientDefs = useMemo(() => { - return ( + const gradientDefs = useMemo( + () => ( - {lines.map((line, index) => ( + {lines.map((line) => ( ))} - ); - }, [lines]); + ), + [lines] + ); return ( <> @@ -127,33 +155,44 @@ const LineChartComponent = ({ {title} - {HeaderRightChildren && HeaderRightChildren} + + {HeaderLeftChildren} + {enableCumulative && ( + + list={tabList} + py={0.5} + px={2} + value={displayMode} + onChange={setDisplayMode} + /> + )} + {gradientDefs} {tooltipItems && } />} {lines.map((line, index) => ( { + const from = addDays(new Date(), -7); + from.setHours(0, 0, 0, 0); + + const to = new Date(); + to.setHours(23, 59, 59, 999); + + return { from, to }; +}; + +const calculateTimeDiffs = (from: Date, to: Date) => { + const startDate = dayjs(from); + const endDate = dayjs(to); + return { + daysDiff: endDate.diff(startDate, 'day'), + hoursDiff: endDate.diff(startDate, 'hour') + }; +}; + const ModelDashboard = ({ Tab }: { Tab: React.ReactNode }) => { const { t } = useTranslation(); const theme = useTheme(); const { feConfigs } = useSystemStore(); + const [viewMode, setViewMode] = useState<'chart' | 'table'>('chart'); + + // view detail handler + const handleViewDetail = (model: string) => { + setFilterProps({ + ...filterProps, + model + }); + setViewMode('chart'); + }; + const [filterProps, setFilterProps] = useState<{ channelId?: string; model?: string; dateRange: DateRangeType; + timespan: 'minute' | 'hour' | 'day'; }>({ 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; - })() - } + timespan: 'day', + dateRange: getDefaultDateRange() }); // Fetch channel list with "All" option @@ -113,85 +139,9 @@ const ModelDashboard = ({ Tab }: { Tab: React.ReactNode }) => { ...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 - }; - }); - } - - // Auto-fill missing days - if (filterProps.dateRange.from && filterProps.dateRange.to) { - const startDate = dayjs(filterProps.dateRange.from); - const endDate = dayjs(filterProps.dateRange.to); - const daysDiff = endDate.diff(startDate, 'day') + 1; - - // Create complete date list - const completeDateList = Array.from({ length: daysDiff }, (_, i) => - startDate.add(i, 'day') - ); - - // Create a map of existing data by timestamp - const existingDataMap = new Map( - data.map((item) => [dayjs(item.timestamp * 1000).format('YYYY-MM-DD'), item]) - ); - - // Fill missing days with empty data - const completeData = completeDateList.map((date) => { - const dateKey = date.format('YYYY-MM-DD'); - const existingItem = existingDataMap.get(dateKey); - - if (existingItem) { - return existingItem; - } else { - // Create empty data structure for missing days - return { - timestamp: Math.floor(date.valueOf() / 1000), - models: [] - }; - } - }); - - data = completeData; - } - - 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< + // Model price map + const modelPriceMap = useMemo(() => { + const map = new Map< string, { inputPrice?: number; @@ -200,24 +150,188 @@ const ModelDashboard = ({ Tab }: { Tab: React.ReactNode }) => { } >(); systemModelList.forEach((model) => { - modelPriceMap.set(model.model, { + map.set(model.model, { inputPrice: model.inputPrice, outputPrice: model.outputPrice, charsPointsPrice: model.charsPointsPrice }); }); + return map; + }, [systemModelList]); + + const computeTimespan = (daysDiff: number, hoursDiff: number) => { + const options: { label: string; value: 'minute' | 'hour' | 'day' }[] = []; + if (daysDiff <= 1) { + options.push({ label: t('account_model:timespan_minute'), value: 'minute' }); + } + if (daysDiff < 7) { + options.push({ label: t('account_model:timespan_hour'), value: 'hour' }); + } + if (daysDiff >= 1) { + options.push({ label: t('account_model:timespan_day'), value: 'day' }); + } + + const defaultTimespan: 'minute' | 'hour' | 'day' = (() => { + if (hoursDiff < 1) { + return 'minute'; + } else if (daysDiff < 2) { + return 'hour'; + } else { + return 'day'; + } + })(); + + return { options, defaultTimespan }; + }; + const [timespanOptions, setTimespanOptions] = useState(computeTimespan(30, 60).options); + + // Handle date range change with automatic timespan adjustment + const handleDateRangeChange = (dateRange: DateRangeType) => { + const newFilterProps = { ...filterProps, dateRange }; + + // Computed timespan + if (dateRange.from && dateRange.to) { + const { daysDiff, hoursDiff } = calculateTimeDiffs(dateRange.from, dateRange.to); + const { options: newTimespanOptions, defaultTimespan: newDefaultTimespan } = computeTimespan( + daysDiff, + hoursDiff + ); + + setTimespanOptions(newTimespanOptions); + newFilterProps.timespan = newDefaultTimespan; + } + + setFilterProps(newFilterProps); + }; + + // Fetch dashboard data with date range and channel filters + const { data: dashboardData = [], loading: isLoading } = useRequest2( + async () => { + const params = { + channel: filterProps.channelId ? parseInt(filterProps.channelId) : undefined, + model: filterProps.model, + 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: filterProps.timespan + }; + + const data = await getDashboardV2(params); + + // Auto-fill missing periods based on timespan + const startDate = dayjs(filterProps.dateRange.from); + const currentTime = dayjs(); + const endDate = dayjs(filterProps.dateRange.to).isBefore(currentTime) + ? dayjs(filterProps.dateRange.to) + : currentTime; + const timespan = filterProps.timespan; + + const { periodCount } = (() => { + if (timespan === 'minute') { + return { + periodCount: endDate.diff(startDate, 'minute') + 1 + }; + } else if (timespan === 'hour') { + return { + periodCount: endDate.diff(startDate, 'hour') + 1 + }; + } else { + return { + periodCount: endDate.diff(startDate, 'day') + 1 + }; + } + })(); + + // Create complete period list + const completePeriodList = Array.from({ length: periodCount }, (_, i) => + startDate.add(i, timespan) + ); + + // Create a map of existing data by timestamp + const existingDataMap = new Map( + data.map((item) => [dayjs(item.timestamp * 1000).format('YYYY-MM-DD HH:mm'), item]) + ); + + // Fill missing periods with empty data + return completePeriodList.map((period) => { + const periodKey = period.format('YYYY-MM-DD HH:mm'); + const existingItem = existingDataMap.get(periodKey); + + if (existingItem) { + return existingItem; + } else { + // Create empty data structure for missing periods + return { + timestamp: Math.floor(period.valueOf() / 1000), + summary: [] + }; + } + }); + }, + { + manual: false, + refreshDeps: [ + filterProps.channelId, + filterProps.dateRange, + filterProps.model, + filterProps.timespan + ] + } + ); + + // Process chart data - aggregate model calls, token usage and cost data based on timespan + const chartData: ModelDashboardData[] = useMemo(() => { + if (dashboardData.length === 0) { + return []; + } 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); + // Format date based on timespan + const dateFormat = (() => { + if (filterProps.timespan === 'minute') { + return 'HH:mm'; + } else if (filterProps.timespan === 'hour') { + return 'HH:00'; + } else { + return 'MM-DD'; + } + })(); + + const date = dayjs(item.timestamp * 1000).format(dateFormat); + const xLabel = dayjs(item.timestamp * 1000).format('YYYY-MM-DD HH:mm'); + const summary = item.summary || []; + const totalCalls = summary.reduce((acc, model) => acc + (model.request_count || 0), 0); + const errorCalls = summary.reduce((acc, model) => acc + (model.exception_count || 0), 0); const errorRate = totalCalls === 0 ? 0 : 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 inputTokens = summary.reduce((acc, model) => acc + (model.input_tokens || 0), 0); + const outputTokens = summary.reduce((acc, model) => acc + (model?.output_tokens || 0), 0); + const totalTokens = summary.reduce((acc, model) => acc + (model.total_tokens || 0), 0); - const totalCost = item.models.reduce((acc, model) => { + const successCalls = totalCalls - errorCalls; + const avgResponseTime = successCalls + ? summary.reduce((acc, model) => acc + (model.total_time_milliseconds || 0), 0) / + successCalls / + 1000 + : 0; + const avgTtfb = successCalls + ? summary.reduce((acc, model) => acc + (model.total_ttfb_milliseconds || 0), 0) / + successCalls / + 1000 + : 0; + + const maxRpm = filterProps.model + ? summary.reduce((acc, model) => Math.max(acc, model.max_rpm || 0), 0) + : 0; + const maxTpm = filterProps.model + ? summary.reduce((acc, model) => Math.max(acc, model.max_tpm || 0), 0) + : 0; + + const totalCost = summary.reduce((acc, model) => { const modelPricing = modelPriceMap.get(model.model); if (modelPricing) { @@ -239,198 +353,346 @@ const ModelDashboard = ({ Tab }: { Tab: React.ReactNode }) => { return { x: date, + xLabel: xLabel, totalCalls, errorCalls, errorRate, inputTokens, outputTokens, totalTokens, - totalCost + totalCost, + avgResponseTime: Math.round(avgResponseTime * 100) / 100, + avgTtfb: Math.round(avgTtfb * 100) / 100, + maxRpm, + maxTpm }; }); - }, [dashboardData, systemModelList]); + }, [dashboardData, filterProps.model, filterProps.timespan, modelPriceMap]); 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 })} - /> - + + + + {t('common:user.Time')} + + + + + + {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 })} + /> + + + {viewMode === 'chart' && ( + + {t('account_model:timespan_label')} + + + bg={'myGray.50'} + list={timespanOptions} + value={filterProps.timespan} + onChange={(val) => { + setFilterProps({ ...filterProps, timespan: val }); + }} + /> + + + )} + + + list={[ + { + label: t('account_model:view_chart'), + value: 'chart' + }, + { + label: t('account_model:view_table'), + value: 'table' + } + ]} + py={1.5} + px={4} + value={viewMode} + onChange={(val) => setViewMode(val)} + /> - {dashboardData && dashboardData.length > 0 && ( - <> - - - - - + {viewMode === 'chart' ? ( + dashboardData.length > 0 && ( + <> - - - - - - - list={[ + + + setTokensUsageType(val)} /> - } - /> - + + + + + - {feConfigs?.isPlus && ( + list={[ + { + label: t('account_model:all'), + value: 'totalTokens' + }, + { + label: t('account_model:input'), + value: 'inputTokens' + }, + { + label: t('account_model:output'), + value: 'outputTokens' + } + ]} + py={0.5} + px={2} + value={tokensUsageType} + onChange={(val) => setTokensUsageType(val)} + /> + } /> - )} - + + {feConfigs?.isPlus && ( + + + + )} + + + + `${value.toFixed(2)}s` + } + ]} + /> + + + `${value.toFixed(2)}s` + } + ]} + /> + + + + {filterProps?.model && ( + + + + + + + + + )} + + ) + ) : ( + )} diff --git a/projects/app/src/web/core/ai/channel.ts b/projects/app/src/web/core/ai/channel.ts index 0ee267b45..d456f4991 100644 --- a/projects/app/src/web/core/ai/channel.ts +++ b/projects/app/src/web/core/ai/channel.ts @@ -191,22 +191,17 @@ export const getLogDetail = (id: number) => export const getDashboardV2 = (params: { channel?: number; + model?: string; start_timestamp?: number; end_timestamp?: number; - timezone?: string; - timespan?: 'day' | 'hour'; + timezone: string; + timespan: 'day' | 'hour' | 'minute'; }) => GET< { timestamp: number; - models: DashboardDataItemType[]; + summary: DashboardDataItemType[]; }[] - >('/dashboardv2/', { - channel: params.channel, - start_timestamp: params.start_timestamp, - end_timestamp: params.end_timestamp, - timezone: params.timezone || 'Local', - timespan: params.timespan || 'day' - }); + >('/dashboardv2/', params); export { responseSuccess, checkRes, responseError, instance, request };