Compare commits

...

2 Commits

Author SHA1 Message Date
arvinxx cad4ead93b update 2025-10-16 21:59:48 +08:00
arvinxx 9eb8b27996 wip add model detail 2025-10-16 21:59:48 +08:00
8 changed files with 338 additions and 29 deletions
+4
View File
@@ -117,3 +117,7 @@ CLAUDE.local.md
prd
GEMINI.md
test-results
prd
GEMINI.md
@@ -48,7 +48,14 @@ const ModelSelect = memo(() => {
const options = useMemo<SelectProps['options']>(() => {
const getImageModels = (provider: EnabledProviderWithModels) => {
const modelOptions = provider.children.map((model) => ({
label: <ModelItemRender {...model} {...model.abilities} showInfoTag={false} />,
label: (
<ModelItemRender
{...model}
{...model.abilities}
showInfoTag={false}
provider={provider.name}
/>
),
provider: provider.id,
value: `${provider.id}/${model.id}`,
}));
@@ -0,0 +1,240 @@
import { ModelIcon } from '@lobehub/icons';
import { Icon, Tag } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { ArrowDownToDot, ArrowUpFromDot, BookUp2Icon, CircleFadingArrowUp } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { ChatModelCard } from '@/types/llm';
import { formatPriceByCurrency, formatTokenNumber } from '@/utils/format';
import {
getAudioInputUnitRate,
getCachedTextInputUnitRate,
getTextInputUnitRate,
getTextOutputUnitRate,
getWriteCacheInputUnitRate,
} from '@/utils/pricing';
import { ModelInfoTags } from './index';
const useStyles = createStyles(({ css, token }) => ({
card: css`
width: 280px;
padding: 16px;
background: ${token.colorBgElevated};
border: 1px solid ${token.colorBorderSecondary};
border-radius: 8px;
box-shadow: ${token.boxShadowTertiary};
`,
header: css`
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid ${token.colorBorderSecondary};
`,
title: css`
font-size: 14px;
font-weight: 600;
color: ${token.colorText};
margin-bottom: 4px;
line-height: 1.3;
`,
provider: css`
font-size: 11px;
color: ${token.colorTextTertiary};
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 500;
`,
infoGrid: css`
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 16px;
margin: 12px 0;
`,
infoItem: css`
display: flex;
flex-direction: column;
gap: 4px;
`,
infoLabel: css`
font-size: 10px;
color: ${token.colorTextTertiary};
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 500;
`,
infoValue: css`
font-size: 13px;
color: ${token.colorText};
font-weight: 500;
display: flex;
align-items: center;
gap: 4px;
`,
pricingGrid: css`
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-top: 12px;
`,
pricingItem: css`
background: ${token.colorFillQuaternary};
border-radius: 6px;
padding: 8px 6px;
text-align: center;
border: 1px solid ${token.colorBorder};
`,
pricingLabel: css`
font-size: 9px;
color: ${token.colorTextTertiary};
text-transform: uppercase;
letter-spacing: 0.3px;
margin-bottom: 2px;
font-weight: 500;
`,
pricingValue: css`
font-size: 11px;
color: ${token.colorText};
font-weight: 600;
font-family: ${token.fontFamilyCode};
`,
abilities: css`
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid ${token.colorBorderSecondary};
`,
description: css`
font-size: 11px;
color: ${token.colorTextSecondary};
line-height: 1.4;
margin-top: 8px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
`,
}));
interface ModelHoverCardProps extends ChatModelCard {
provider?: string;
}
export const ModelHoverCard = memo<ModelHoverCardProps>(({ provider, ...model }) => {
const { t } = useTranslation('components');
const { styles } = useStyles();
// Format pricing information
const getPricingData = () => {
if (!model.pricing) return null;
const inputRate = getTextInputUnitRate(model.pricing);
const outputRate = getTextOutputUnitRate(model.pricing);
const cachedInputRate = getCachedTextInputUnitRate(model.pricing);
return {
input: inputRate ? formatPriceByCurrency(inputRate, model.pricing.currency) : null,
output: outputRate ? formatPriceByCurrency(outputRate, model.pricing.currency) : null,
cached: cachedInputRate
? formatPriceByCurrency(cachedInputRate, model.pricing.currency)
: null,
};
};
const pricing = getPricingData();
return (
<div className={styles.card}>
{/* Header */}
<div className={styles.header}>
<Flexbox align="center" gap={8} horizontal>
<ModelIcon model={model.id} size={20} />
<Flexbox flex={1}>
<div className={styles.title}>{model.displayName || model.id}</div>
{provider && <div className={styles.provider}>{provider}</div>}
</Flexbox>
</Flexbox>
</div>
{/* Info Grid */}
<div className={styles.infoGrid}>
{/* Context Window */}
{typeof model.contextWindowTokens === 'number' && (
<div className={styles.infoItem}>
<div className={styles.infoLabel}>{t('ModelSelect.contextWindow')}</div>
<div className={styles.infoValue}>
{model.contextWindowTokens === 0 ? (
<span></span>
) : (
formatTokenNumber(model.contextWindowTokens)
)}
</div>
</div>
)}
{/* Max Output */}
{model.maxOutput && (
<div className={styles.infoItem}>
<div className={styles.infoLabel}>{t('ModelSelect.maxOutput').toUpperCase()}</div>
<div className={styles.infoValue}>{formatTokenNumber(model.maxOutput)}</div>
</div>
)}
{/* Released Date */}
{model.releasedAt && (
<div className={styles.infoItem}>
<div className={styles.infoLabel}>{t('ModelSelect.releasedAt').toUpperCase()}</div>
<div className={styles.infoValue}>
{new Date(model.releasedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
})}
</div>
</div>
)}
{/* Model Type */}
{model.type && (
<div className={styles.infoItem}>
<div className={styles.infoLabel}>{t('ModelSelect.type').toUpperCase()}</div>
<div className={styles.infoValue}>{model.type.toUpperCase()}</div>
</div>
)}
</div>
{/* Pricing */}
{pricing && (pricing.input || pricing.output || pricing.cached) && (
<div className={styles.pricingGrid}>
{pricing.input && (
<div className={styles.pricingItem}>
<div className={styles.pricingLabel}>INPUT</div>
<div className={styles.pricingValue}>${pricing.input}</div>
</div>
)}
{pricing.output && (
<div className={styles.pricingItem}>
<div className={styles.pricingLabel}>OUTPUT</div>
<div className={styles.pricingValue}>${pricing.output}</div>
</div>
)}
{pricing.cached && (
<div className={styles.pricingItem}>
<div className={styles.pricingLabel}>CACHED</div>
<div className={styles.pricingValue}>${pricing.cached}</div>
</div>
)}
</div>
)}
{/* Abilities */}
<div className={styles.abilities}>
<ModelInfoTags {...model} contextWindowTokens={model.contextWindowTokens} />
</div>
{/* Description */}
{model.description && <div className={styles.description}>{model.description}</div>}
</div>
);
});
ModelHoverCard.displayName = 'ModelHoverCard';
+55 -25
View File
@@ -1,6 +1,7 @@
import { ChatModelCard } from '@lobechat/types';
import { IconAvatarProps, ModelIcon, ProviderIcon } from '@lobehub/icons';
import { Avatar, Icon, Tag, Text, Tooltip } from '@lobehub/ui';
import { Popover } from 'antd';
import { createStyles, useResponsive } from 'antd-style';
import {
Infinity,
@@ -21,6 +22,8 @@ import { Flexbox } from 'react-layout-kit';
import { AiProviderSourceType } from '@/types/aiProvider';
import { formatTokenNumber } from '@/utils/format';
import { ModelHoverCard } from './ModelHoverCard';
export const TAG_CLASSNAME = 'lobe-model-info-tags';
const useStyles = createStyles(({ css, token }) => ({
@@ -174,39 +177,66 @@ export const ModelInfoTags = memo<ModelInfoTagsProps>(
);
interface ModelItemRenderProps extends ChatModelCard {
provider?: string;
showInfoTag?: boolean;
}
export const ModelItemRender = memo<ModelItemRenderProps>(({ showInfoTag = true, ...model }) => {
const { mobile } = useResponsive();
return (
<Flexbox
align={'center'}
gap={32}
horizontal
justify={'space-between'}
style={{
minWidth: mobile ? '100%' : undefined,
overflow: 'hidden',
position: 'relative',
width: mobile ? '80vw' : 'auto',
}}
>
export const ModelItemRender = memo<ModelItemRenderProps>(
({ showInfoTag = true, provider, ...model }) => {
const { mobile } = useResponsive();
const content = (
<Flexbox
align={'center'}
gap={8}
gap={32}
horizontal
style={{ flexShrink: 1, minWidth: 0, overflow: 'hidden' }}
justify={'space-between'}
style={{
minWidth: mobile ? '100%' : undefined,
overflow: 'hidden',
position: 'relative',
width: mobile ? '80vw' : 'auto',
}}
>
<ModelIcon model={model.id} size={20} />
<Text style={mobile ? { maxWidth: '60vw', overflowX: 'auto', whiteSpace: 'nowrap' } : {}}>
{model.displayName || model.id}
</Text>
<Flexbox
align={'center'}
gap={8}
horizontal
style={{ flexShrink: 1, minWidth: 0, overflow: 'hidden' }}
>
<ModelIcon model={model.id} size={20} />
<Text style={mobile ? { maxWidth: '60vw', overflowX: 'auto', whiteSpace: 'nowrap' } : {}}>
{model.displayName || model.id}
</Text>
</Flexbox>
{showInfoTag && <ModelInfoTags {...model} />}
</Flexbox>
{showInfoTag && <ModelInfoTags {...model} />}
</Flexbox>
);
});
);
// Only show hover card on desktop and when we have meaningful information to show
const shouldShowHoverCard =
!mobile &&
(model.description ||
model.pricing ||
typeof model.contextWindowTokens === 'number' ||
model.releasedAt);
if (shouldShowHoverCard) {
return (
<Popover
arrow={false}
content={<ModelHoverCard {...model} provider={provider} />}
mouseEnterDelay={0.5}
placement="right"
>
{content}
</Popover>
);
}
return content;
},
);
interface ProviderItemRenderProps {
logo?: string;
@@ -37,7 +37,14 @@ const ModelSelect = memo<ModelSelectProps>(({ value, onChange, ...rest }) => {
provider.children
.filter((model) => !!model.abilities.functionCall)
.map((model) => ({
label: <ModelItemRender {...model} {...model.abilities} showInfoTag={false} />,
label: (
<ModelItemRender
{...model}
{...model.abilities}
provider={provider.name}
showInfoTag={false}
/>
),
provider: provider.id,
value: `${provider.id}/${model.id}`,
}));
+8 -1
View File
@@ -42,7 +42,14 @@ const ModelSelect = memo<ModelSelectProps>(({ value, onChange, showAbility = tru
const options = useMemo<SelectProps['options']>(() => {
const getChatModels = (provider: EnabledProviderWithModels) =>
provider.children.map((model) => ({
label: <ModelItemRender {...model} {...model.abilities} showInfoTag={showAbility} />,
label: (
<ModelItemRender
{...model}
{...model.abilities}
showInfoTag={showAbility}
provider={provider.name}
/>
),
provider: provider.id,
value: `${provider.id}/${model.id}`,
}));
+1 -1
View File
@@ -63,7 +63,7 @@ const ModelSwitchPanel = memo<IProps>(({ children, onOpenChange, open }) => {
const getModelItems = (provider: EnabledProviderWithModels) => {
const items = provider.children.map((model) => ({
key: menuKey(provider.id, model.id),
label: <ModelItemRender {...model} {...model.abilities} />,
label: <ModelItemRender {...model} {...model.abilities} provider={provider.name} />,
onClick: async () => {
await updateAgentConfig({ model: model.id, provider: provider.id });
},
+14
View File
@@ -108,6 +108,7 @@ export default {
unlimited: '无限制',
},
ModelSelect: {
contextWindow: '上下文长度',
featureTag: {
custom: '自定义模型,默认设定同时支持函数调用与视觉识别,请根据实际情况验证上述能力的可用性',
file: '该模型支持上传文件读取与识别',
@@ -119,7 +120,20 @@ export default {
video: '该模型支持视频识别',
vision: '该模型支持视觉识别',
},
maxOutput: '最大输出',
pricing: {
audioInput: '音频输入',
cachedInput: '缓存输入',
input: '输入',
output: '输出',
title: '价格',
writeCacheInput: '写入缓存输入',
},
releasedAt: '发布时间',
removed: '该模型不在列表中,若取消选中将会自动移除',
tokens: '{{tokens}} tokens',
type: '类型',
unlimited: '无限制',
},
ModelSwitchPanel: {
emptyModel: '没有启用的模型,请前往设置开启',