|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import React from 'react'; |
|
|
import { |
|
|
Avatar, |
|
|
Space, |
|
|
Tag, |
|
|
Tooltip, |
|
|
Popover, |
|
|
Typography, |
|
|
} from '@douyinfe/semi-ui'; |
|
|
import { |
|
|
timestamp2string, |
|
|
renderGroup, |
|
|
renderQuota, |
|
|
stringToColor, |
|
|
getLogOther, |
|
|
renderModelTag, |
|
|
renderClaudeLogContent, |
|
|
renderLogContent, |
|
|
renderModelPriceSimple, |
|
|
renderAudioModelPrice, |
|
|
renderClaudeModelPrice, |
|
|
renderModelPrice, |
|
|
} from '../../../helpers'; |
|
|
import { IconHelpCircle } from '@douyinfe/semi-icons'; |
|
|
import { Route } from 'lucide-react'; |
|
|
|
|
|
const colors = [ |
|
|
'amber', |
|
|
'blue', |
|
|
'cyan', |
|
|
'green', |
|
|
'grey', |
|
|
'indigo', |
|
|
'light-blue', |
|
|
'lime', |
|
|
'orange', |
|
|
'pink', |
|
|
'purple', |
|
|
'red', |
|
|
'teal', |
|
|
'violet', |
|
|
'yellow', |
|
|
]; |
|
|
|
|
|
|
|
|
function renderType(type, t) { |
|
|
switch (type) { |
|
|
case 1: |
|
|
return ( |
|
|
<Tag color='cyan' shape='circle'> |
|
|
{t('充值')} |
|
|
</Tag> |
|
|
); |
|
|
case 2: |
|
|
return ( |
|
|
<Tag color='lime' shape='circle'> |
|
|
{t('消费')} |
|
|
</Tag> |
|
|
); |
|
|
case 3: |
|
|
return ( |
|
|
<Tag color='orange' shape='circle'> |
|
|
{t('管理')} |
|
|
</Tag> |
|
|
); |
|
|
case 4: |
|
|
return ( |
|
|
<Tag color='purple' shape='circle'> |
|
|
{t('系统')} |
|
|
</Tag> |
|
|
); |
|
|
case 5: |
|
|
return ( |
|
|
<Tag color='red' shape='circle'> |
|
|
{t('错误')} |
|
|
</Tag> |
|
|
); |
|
|
default: |
|
|
return ( |
|
|
<Tag color='grey' shape='circle'> |
|
|
{t('未知')} |
|
|
</Tag> |
|
|
); |
|
|
} |
|
|
} |
|
|
|
|
|
function renderIsStream(bool, t) { |
|
|
if (bool) { |
|
|
return ( |
|
|
<Tag color='blue' shape='circle'> |
|
|
{t('流')} |
|
|
</Tag> |
|
|
); |
|
|
} else { |
|
|
return ( |
|
|
<Tag color='purple' shape='circle'> |
|
|
{t('非流')} |
|
|
</Tag> |
|
|
); |
|
|
} |
|
|
} |
|
|
|
|
|
function renderUseTime(type, t) { |
|
|
const time = parseInt(type); |
|
|
if (time < 101) { |
|
|
return ( |
|
|
<Tag color='green' shape='circle'> |
|
|
{' '} |
|
|
{time} s{' '} |
|
|
</Tag> |
|
|
); |
|
|
} else if (time < 300) { |
|
|
return ( |
|
|
<Tag color='orange' shape='circle'> |
|
|
{' '} |
|
|
{time} s{' '} |
|
|
</Tag> |
|
|
); |
|
|
} else { |
|
|
return ( |
|
|
<Tag color='red' shape='circle'> |
|
|
{' '} |
|
|
{time} s{' '} |
|
|
</Tag> |
|
|
); |
|
|
} |
|
|
} |
|
|
|
|
|
function renderFirstUseTime(type, t) { |
|
|
let time = parseFloat(type) / 1000.0; |
|
|
time = time.toFixed(1); |
|
|
if (time < 3) { |
|
|
return ( |
|
|
<Tag color='green' shape='circle'> |
|
|
{' '} |
|
|
{time} s{' '} |
|
|
</Tag> |
|
|
); |
|
|
} else if (time < 10) { |
|
|
return ( |
|
|
<Tag color='orange' shape='circle'> |
|
|
{' '} |
|
|
{time} s{' '} |
|
|
</Tag> |
|
|
); |
|
|
} else { |
|
|
return ( |
|
|
<Tag color='red' shape='circle'> |
|
|
{' '} |
|
|
{time} s{' '} |
|
|
</Tag> |
|
|
); |
|
|
} |
|
|
} |
|
|
|
|
|
function renderModelName(record, copyText, t) { |
|
|
let other = getLogOther(record.other); |
|
|
let modelMapped = |
|
|
other?.is_model_mapped && |
|
|
other?.upstream_model_name && |
|
|
other?.upstream_model_name !== ''; |
|
|
if (!modelMapped) { |
|
|
return renderModelTag(record.model_name, { |
|
|
onClick: (event) => { |
|
|
copyText(event, record.model_name).then((r) => {}); |
|
|
}, |
|
|
}); |
|
|
} else { |
|
|
return ( |
|
|
<> |
|
|
<Space vertical align={'start'}> |
|
|
<Popover |
|
|
content={ |
|
|
<div style={{ padding: 10 }}> |
|
|
<Space vertical align={'start'}> |
|
|
<div className='flex items-center'> |
|
|
<Typography.Text strong style={{ marginRight: 8 }}> |
|
|
{t('请求并计费模型')}: |
|
|
</Typography.Text> |
|
|
{renderModelTag(record.model_name, { |
|
|
onClick: (event) => { |
|
|
copyText(event, record.model_name).then((r) => {}); |
|
|
}, |
|
|
})} |
|
|
</div> |
|
|
<div className='flex items-center'> |
|
|
<Typography.Text strong style={{ marginRight: 8 }}> |
|
|
{t('实际模型')}: |
|
|
</Typography.Text> |
|
|
{renderModelTag(other.upstream_model_name, { |
|
|
onClick: (event) => { |
|
|
copyText(event, other.upstream_model_name).then( |
|
|
(r) => {}, |
|
|
); |
|
|
}, |
|
|
})} |
|
|
</div> |
|
|
</Space> |
|
|
</div> |
|
|
} |
|
|
> |
|
|
{renderModelTag(record.model_name, { |
|
|
onClick: (event) => { |
|
|
copyText(event, record.model_name).then((r) => {}); |
|
|
}, |
|
|
suffixIcon: ( |
|
|
<Route |
|
|
style={{ width: '0.9em', height: '0.9em', opacity: 0.75 }} |
|
|
/> |
|
|
), |
|
|
})} |
|
|
</Popover> |
|
|
</Space> |
|
|
</> |
|
|
); |
|
|
} |
|
|
} |
|
|
|
|
|
export const getLogsColumns = ({ |
|
|
t, |
|
|
COLUMN_KEYS, |
|
|
copyText, |
|
|
showUserInfoFunc, |
|
|
isAdminUser, |
|
|
}) => { |
|
|
return [ |
|
|
{ |
|
|
key: COLUMN_KEYS.TIME, |
|
|
title: t('时间'), |
|
|
dataIndex: 'timestamp2string', |
|
|
}, |
|
|
{ |
|
|
key: COLUMN_KEYS.CHANNEL, |
|
|
title: t('渠道'), |
|
|
dataIndex: 'channel', |
|
|
render: (text, record, index) => { |
|
|
let isMultiKey = false; |
|
|
let multiKeyIndex = -1; |
|
|
let other = getLogOther(record.other); |
|
|
if (other?.admin_info) { |
|
|
let adminInfo = other.admin_info; |
|
|
if (adminInfo?.is_multi_key) { |
|
|
isMultiKey = true; |
|
|
multiKeyIndex = adminInfo.multi_key_index; |
|
|
} |
|
|
} |
|
|
|
|
|
return isAdminUser && |
|
|
(record.type === 0 || record.type === 2 || record.type === 5) ? ( |
|
|
<Space> |
|
|
<Tooltip content={record.channel_name || t('未知渠道')}> |
|
|
<span> |
|
|
<Tag |
|
|
color={colors[parseInt(text) % colors.length]} |
|
|
shape='circle' |
|
|
> |
|
|
{text} |
|
|
</Tag> |
|
|
</span> |
|
|
</Tooltip> |
|
|
{isMultiKey && ( |
|
|
<Tag color='white' shape='circle'> |
|
|
{multiKeyIndex} |
|
|
</Tag> |
|
|
)} |
|
|
</Space> |
|
|
) : null; |
|
|
}, |
|
|
}, |
|
|
{ |
|
|
key: COLUMN_KEYS.USERNAME, |
|
|
title: t('用户'), |
|
|
dataIndex: 'username', |
|
|
render: (text, record, index) => { |
|
|
return isAdminUser ? ( |
|
|
<div> |
|
|
<Avatar |
|
|
size='extra-small' |
|
|
color={stringToColor(text)} |
|
|
style={{ marginRight: 4 }} |
|
|
onClick={(event) => { |
|
|
event.stopPropagation(); |
|
|
showUserInfoFunc(record.user_id); |
|
|
}} |
|
|
> |
|
|
{typeof text === 'string' && text.slice(0, 1)} |
|
|
</Avatar> |
|
|
{text} |
|
|
</div> |
|
|
) : ( |
|
|
<></> |
|
|
); |
|
|
}, |
|
|
}, |
|
|
{ |
|
|
key: COLUMN_KEYS.TOKEN, |
|
|
title: t('令牌'), |
|
|
dataIndex: 'token_name', |
|
|
render: (text, record, index) => { |
|
|
return record.type === 0 || record.type === 2 || record.type === 5 ? ( |
|
|
<div> |
|
|
<Tag |
|
|
color='grey' |
|
|
shape='circle' |
|
|
onClick={(event) => { |
|
|
copyText(event, text); |
|
|
}} |
|
|
> |
|
|
{' '} |
|
|
{t(text)}{' '} |
|
|
</Tag> |
|
|
</div> |
|
|
) : ( |
|
|
<></> |
|
|
); |
|
|
}, |
|
|
}, |
|
|
{ |
|
|
key: COLUMN_KEYS.GROUP, |
|
|
title: t('分组'), |
|
|
dataIndex: 'group', |
|
|
render: (text, record, index) => { |
|
|
if (record.type === 0 || record.type === 2 || record.type === 5) { |
|
|
if (record.group) { |
|
|
return <>{renderGroup(record.group)}</>; |
|
|
} else { |
|
|
let other = null; |
|
|
try { |
|
|
other = JSON.parse(record.other); |
|
|
} catch (e) { |
|
|
console.error( |
|
|
`Failed to parse record.other: "${record.other}".`, |
|
|
e, |
|
|
); |
|
|
} |
|
|
if (other === null) { |
|
|
return <></>; |
|
|
} |
|
|
if (other.group !== undefined) { |
|
|
return <>{renderGroup(other.group)}</>; |
|
|
} else { |
|
|
return <></>; |
|
|
} |
|
|
} |
|
|
} else { |
|
|
return <></>; |
|
|
} |
|
|
}, |
|
|
}, |
|
|
{ |
|
|
key: COLUMN_KEYS.TYPE, |
|
|
title: t('类型'), |
|
|
dataIndex: 'type', |
|
|
render: (text, record, index) => { |
|
|
return <>{renderType(text, t)}</>; |
|
|
}, |
|
|
}, |
|
|
{ |
|
|
key: COLUMN_KEYS.MODEL, |
|
|
title: t('模型'), |
|
|
dataIndex: 'model_name', |
|
|
render: (text, record, index) => { |
|
|
return record.type === 0 || record.type === 2 || record.type === 5 ? ( |
|
|
<>{renderModelName(record, copyText, t)}</> |
|
|
) : ( |
|
|
<></> |
|
|
); |
|
|
}, |
|
|
}, |
|
|
{ |
|
|
key: COLUMN_KEYS.USE_TIME, |
|
|
title: t('用时/首字'), |
|
|
dataIndex: 'use_time', |
|
|
render: (text, record, index) => { |
|
|
if (!(record.type === 2 || record.type === 5)) { |
|
|
return <></>; |
|
|
} |
|
|
if (record.is_stream) { |
|
|
let other = getLogOther(record.other); |
|
|
return ( |
|
|
<> |
|
|
<Space> |
|
|
{renderUseTime(text, t)} |
|
|
{renderFirstUseTime(other?.frt, t)} |
|
|
{renderIsStream(record.is_stream, t)} |
|
|
</Space> |
|
|
</> |
|
|
); |
|
|
} else { |
|
|
return ( |
|
|
<> |
|
|
<Space> |
|
|
{renderUseTime(text, t)} |
|
|
{renderIsStream(record.is_stream, t)} |
|
|
</Space> |
|
|
</> |
|
|
); |
|
|
} |
|
|
}, |
|
|
}, |
|
|
{ |
|
|
key: COLUMN_KEYS.PROMPT, |
|
|
title: t('输入'), |
|
|
dataIndex: 'prompt_tokens', |
|
|
render: (text, record, index) => { |
|
|
return record.type === 0 || record.type === 2 || record.type === 5 ? ( |
|
|
<>{<span> {text} </span>}</> |
|
|
) : ( |
|
|
<></> |
|
|
); |
|
|
}, |
|
|
}, |
|
|
{ |
|
|
key: COLUMN_KEYS.COMPLETION, |
|
|
title: t('输出'), |
|
|
dataIndex: 'completion_tokens', |
|
|
render: (text, record, index) => { |
|
|
return parseInt(text) > 0 && |
|
|
(record.type === 0 || record.type === 2 || record.type === 5) ? ( |
|
|
<>{<span> {text} </span>}</> |
|
|
) : ( |
|
|
<></> |
|
|
); |
|
|
}, |
|
|
}, |
|
|
{ |
|
|
key: COLUMN_KEYS.COST, |
|
|
title: t('花费'), |
|
|
dataIndex: 'quota', |
|
|
render: (text, record, index) => { |
|
|
return record.type === 0 || record.type === 2 || record.type === 5 ? ( |
|
|
<>{renderQuota(text, 6)}</> |
|
|
) : ( |
|
|
<></> |
|
|
); |
|
|
}, |
|
|
}, |
|
|
{ |
|
|
key: COLUMN_KEYS.IP, |
|
|
title: ( |
|
|
<div className='flex items-center gap-1'> |
|
|
{t('IP')} |
|
|
<Tooltip |
|
|
content={t( |
|
|
'只有当用户设置开启IP记录时,才会进行请求和错误类型日志的IP记录', |
|
|
)} |
|
|
> |
|
|
<IconHelpCircle className='text-gray-400 cursor-help' /> |
|
|
</Tooltip> |
|
|
</div> |
|
|
), |
|
|
dataIndex: 'ip', |
|
|
render: (text, record, index) => { |
|
|
return (record.type === 2 || record.type === 5) && text ? ( |
|
|
<Tooltip content={text}> |
|
|
<span> |
|
|
<Tag |
|
|
color='orange' |
|
|
shape='circle' |
|
|
onClick={(event) => { |
|
|
copyText(event, text); |
|
|
}} |
|
|
> |
|
|
{text} |
|
|
</Tag> |
|
|
</span> |
|
|
</Tooltip> |
|
|
) : ( |
|
|
<></> |
|
|
); |
|
|
}, |
|
|
}, |
|
|
{ |
|
|
key: COLUMN_KEYS.RETRY, |
|
|
title: t('重试'), |
|
|
dataIndex: 'retry', |
|
|
render: (text, record, index) => { |
|
|
if (!(record.type === 2 || record.type === 5)) { |
|
|
return <></>; |
|
|
} |
|
|
let content = t('渠道') + `:${record.channel}`; |
|
|
if (record.other !== '') { |
|
|
let other = JSON.parse(record.other); |
|
|
if (other === null) { |
|
|
return <></>; |
|
|
} |
|
|
if (other.admin_info !== undefined) { |
|
|
if ( |
|
|
other.admin_info.use_channel !== null && |
|
|
other.admin_info.use_channel !== undefined && |
|
|
other.admin_info.use_channel !== '' |
|
|
) { |
|
|
let useChannel = other.admin_info.use_channel; |
|
|
let useChannelStr = useChannel.join('->'); |
|
|
content = t('渠道') + `:${useChannelStr}`; |
|
|
} |
|
|
} |
|
|
} |
|
|
return isAdminUser ? <div>{content}</div> : <></>; |
|
|
}, |
|
|
}, |
|
|
{ |
|
|
key: COLUMN_KEYS.DETAILS, |
|
|
title: t('详情'), |
|
|
dataIndex: 'content', |
|
|
fixed: 'right', |
|
|
render: (text, record, index) => { |
|
|
let other = getLogOther(record.other); |
|
|
if (other == null || record.type !== 2) { |
|
|
return ( |
|
|
<Typography.Paragraph |
|
|
ellipsis={{ |
|
|
rows: 2, |
|
|
showTooltip: { |
|
|
type: 'popover', |
|
|
opts: { style: { width: 240 } }, |
|
|
}, |
|
|
}} |
|
|
style={{ maxWidth: 240 }} |
|
|
> |
|
|
{text} |
|
|
</Typography.Paragraph> |
|
|
); |
|
|
} |
|
|
let content = other?.claude |
|
|
? renderModelPriceSimple( |
|
|
other.model_ratio, |
|
|
other.model_price, |
|
|
other.group_ratio, |
|
|
other?.user_group_ratio, |
|
|
other.cache_tokens || 0, |
|
|
other.cache_ratio || 1.0, |
|
|
other.cache_creation_tokens || 0, |
|
|
other.cache_creation_ratio || 1.0, |
|
|
other.cache_creation_tokens_5m || 0, |
|
|
other.cache_creation_ratio_5m || other.cache_creation_ratio || 1.0, |
|
|
other.cache_creation_tokens_1h || 0, |
|
|
other.cache_creation_ratio_1h || other.cache_creation_ratio || 1.0, |
|
|
false, |
|
|
1.0, |
|
|
other?.is_system_prompt_overwritten, |
|
|
'claude', |
|
|
) |
|
|
: renderModelPriceSimple( |
|
|
other.model_ratio, |
|
|
other.model_price, |
|
|
other.group_ratio, |
|
|
other?.user_group_ratio, |
|
|
other.cache_tokens || 0, |
|
|
other.cache_ratio || 1.0, |
|
|
0, |
|
|
1.0, |
|
|
0, |
|
|
1.0, |
|
|
0, |
|
|
1.0, |
|
|
false, |
|
|
1.0, |
|
|
other?.is_system_prompt_overwritten, |
|
|
'openai', |
|
|
); |
|
|
return ( |
|
|
<Typography.Paragraph |
|
|
ellipsis={{ |
|
|
rows: 3, |
|
|
}} |
|
|
style={{ maxWidth: 240, whiteSpace: 'pre-line' }} |
|
|
> |
|
|
{content} |
|
|
</Typography.Paragraph> |
|
|
); |
|
|
}, |
|
|
}, |
|
|
]; |
|
|
}; |
|
|
|