@lobehub/chat 1.68.3 → 1.68.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +3 -3
  3. package/README.zh-CN.md +14 -17
  4. package/changelog/v1.json +18 -0
  5. package/docs/usage/providers/azureai.mdx +69 -0
  6. package/docs/usage/providers/azureai.zh-CN.mdx +69 -0
  7. package/docs/usage/providers/deepseek.mdx +3 -3
  8. package/docs/usage/providers/deepseek.zh-CN.mdx +5 -4
  9. package/docs/usage/providers/jina.mdx +51 -0
  10. package/docs/usage/providers/jina.zh-CN.mdx +51 -0
  11. package/docs/usage/providers/lmstudio.mdx +75 -0
  12. package/docs/usage/providers/lmstudio.zh-CN.mdx +75 -0
  13. package/docs/usage/providers/nvidia.mdx +55 -0
  14. package/docs/usage/providers/nvidia.zh-CN.mdx +55 -0
  15. package/docs/usage/providers/ppio.mdx +7 -7
  16. package/docs/usage/providers/ppio.zh-CN.mdx +6 -6
  17. package/docs/usage/providers/sambanova.mdx +50 -0
  18. package/docs/usage/providers/sambanova.zh-CN.mdx +50 -0
  19. package/docs/usage/providers/tencentcloud.mdx +49 -0
  20. package/docs/usage/providers/tencentcloud.zh-CN.mdx +49 -0
  21. package/docs/usage/providers/vertexai.mdx +59 -0
  22. package/docs/usage/providers/vertexai.zh-CN.mdx +59 -0
  23. package/docs/usage/providers/vllm.mdx +98 -0
  24. package/docs/usage/providers/vllm.zh-CN.mdx +98 -0
  25. package/docs/usage/providers/volcengine.mdx +47 -0
  26. package/docs/usage/providers/volcengine.zh-CN.mdx +48 -0
  27. package/locales/ar/chat.json +29 -0
  28. package/locales/ar/models.json +48 -0
  29. package/locales/ar/providers.json +3 -0
  30. package/locales/bg-BG/chat.json +29 -0
  31. package/locales/bg-BG/models.json +48 -0
  32. package/locales/bg-BG/providers.json +3 -0
  33. package/locales/de-DE/chat.json +29 -0
  34. package/locales/de-DE/models.json +48 -0
  35. package/locales/de-DE/providers.json +3 -0
  36. package/locales/en-US/chat.json +29 -0
  37. package/locales/en-US/models.json +48 -0
  38. package/locales/en-US/providers.json +3 -3
  39. package/locales/es-ES/chat.json +29 -0
  40. package/locales/es-ES/models.json +48 -0
  41. package/locales/es-ES/providers.json +3 -0
  42. package/locales/fa-IR/chat.json +29 -0
  43. package/locales/fa-IR/models.json +48 -0
  44. package/locales/fa-IR/providers.json +3 -0
  45. package/locales/fr-FR/chat.json +29 -0
  46. package/locales/fr-FR/models.json +48 -0
  47. package/locales/fr-FR/providers.json +3 -0
  48. package/locales/it-IT/chat.json +29 -0
  49. package/locales/it-IT/models.json +48 -0
  50. package/locales/it-IT/providers.json +3 -0
  51. package/locales/ja-JP/chat.json +29 -0
  52. package/locales/ja-JP/models.json +48 -0
  53. package/locales/ja-JP/providers.json +3 -0
  54. package/locales/ko-KR/chat.json +29 -0
  55. package/locales/ko-KR/models.json +48 -0
  56. package/locales/ko-KR/providers.json +3 -0
  57. package/locales/nl-NL/chat.json +29 -0
  58. package/locales/nl-NL/models.json +48 -0
  59. package/locales/nl-NL/providers.json +3 -0
  60. package/locales/pl-PL/chat.json +29 -0
  61. package/locales/pl-PL/models.json +48 -0
  62. package/locales/pl-PL/providers.json +3 -0
  63. package/locales/pt-BR/chat.json +29 -0
  64. package/locales/pt-BR/models.json +48 -0
  65. package/locales/pt-BR/providers.json +3 -0
  66. package/locales/ru-RU/chat.json +29 -0
  67. package/locales/ru-RU/models.json +48 -0
  68. package/locales/ru-RU/providers.json +3 -0
  69. package/locales/tr-TR/chat.json +29 -0
  70. package/locales/tr-TR/models.json +48 -0
  71. package/locales/tr-TR/providers.json +3 -0
  72. package/locales/vi-VN/chat.json +29 -0
  73. package/locales/vi-VN/models.json +48 -0
  74. package/locales/vi-VN/providers.json +3 -0
  75. package/locales/zh-CN/chat.json +29 -0
  76. package/locales/zh-CN/models.json +51 -3
  77. package/locales/zh-CN/providers.json +3 -4
  78. package/locales/zh-TW/chat.json +29 -0
  79. package/locales/zh-TW/models.json +48 -0
  80. package/locales/zh-TW/providers.json +3 -0
  81. package/package.json +1 -1
  82. package/packages/web-crawler/src/crawImpl/__test__/jina.test.ts +169 -0
  83. package/packages/web-crawler/src/crawImpl/naive.ts +29 -3
  84. package/packages/web-crawler/src/utils/errorType.ts +7 -0
  85. package/scripts/serverLauncher/startServer.js +11 -7
  86. package/src/config/modelProviders/index.ts +1 -1
  87. package/src/config/modelProviders/ppio.ts +1 -1
  88. package/src/features/Conversation/Extras/Assistant.tsx +12 -20
  89. package/src/features/Conversation/Extras/Usage/UsageDetail/ModelCard.tsx +130 -0
  90. package/src/features/Conversation/Extras/Usage/UsageDetail/TokenProgress.tsx +71 -0
  91. package/src/features/Conversation/Extras/Usage/UsageDetail/index.tsx +146 -0
  92. package/src/features/Conversation/Extras/Usage/UsageDetail/tokens.ts +94 -0
  93. package/src/features/Conversation/Extras/Usage/index.tsx +40 -0
  94. package/src/libs/agent-runtime/utils/streams/anthropic.test.ts +14 -0
  95. package/src/libs/agent-runtime/utils/streams/anthropic.ts +25 -0
  96. package/src/libs/agent-runtime/utils/streams/openai.test.ts +100 -10
  97. package/src/libs/agent-runtime/utils/streams/openai.ts +30 -4
  98. package/src/libs/agent-runtime/utils/streams/protocol.ts +4 -0
  99. package/src/locales/default/chat.ts +30 -1
  100. package/src/server/routers/tools/search.ts +1 -1
  101. package/src/store/aiInfra/slices/aiModel/initialState.ts +3 -1
  102. package/src/store/aiInfra/slices/aiModel/selectors.test.ts +1 -0
  103. package/src/store/aiInfra/slices/aiModel/selectors.ts +5 -0
  104. package/src/store/aiInfra/slices/aiProvider/action.ts +3 -1
  105. package/src/store/chat/slices/aiChat/actions/generateAIChat.ts +5 -1
  106. package/src/store/chat/slices/message/action.ts +3 -0
  107. package/src/store/global/initialState.ts +1 -0
  108. package/src/store/global/selectors/systemStatus.ts +2 -0
  109. package/src/types/message/base.ts +18 -0
  110. package/src/types/message/chat.ts +4 -3
  111. package/src/utils/fetch/fetchSSE.ts +24 -1
  112. package/src/utils/format.ts +3 -1
@@ -0,0 +1,169 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { jina } from '../jina';
4
+
5
+ describe('jina crawler', () => {
6
+ const mockFetch = vi.fn();
7
+ global.fetch = mockFetch;
8
+
9
+ beforeEach(() => {
10
+ vi.resetAllMocks();
11
+ });
12
+
13
+ it('should crawl url successfully', async () => {
14
+ const mockResponse = {
15
+ ok: true,
16
+ json: () =>
17
+ Promise.resolve({
18
+ code: 200,
19
+ data: {
20
+ content: 'test content',
21
+ description: 'test description',
22
+ siteName: 'test site',
23
+ title: 'test title',
24
+ },
25
+ }),
26
+ };
27
+
28
+ mockFetch.mockResolvedValue(mockResponse);
29
+
30
+ const result = await jina('https://example.com', {
31
+ apiKey: 'test-key',
32
+ filterOptions: {},
33
+ });
34
+
35
+ expect(mockFetch).toHaveBeenCalledWith('https://r.jina.ai/https://example.com', {
36
+ headers: {
37
+ 'Accept': 'application/json',
38
+ 'Authorization': 'Bearer test-key',
39
+ 'x-send-from': 'LobeChat Community',
40
+ },
41
+ });
42
+
43
+ expect(result).toEqual({
44
+ content: 'test content',
45
+ contentType: 'text',
46
+ description: 'test description',
47
+ length: 12,
48
+ siteName: 'test site',
49
+ title: 'test title',
50
+ url: 'https://example.com',
51
+ });
52
+ });
53
+
54
+ it('should use JINA_READER_API_KEY from env if apiKey not provided', async () => {
55
+ process.env.JINA_READER_API_KEY = 'env-reader-key';
56
+
57
+ const mockResponse = {
58
+ ok: true,
59
+ json: () =>
60
+ Promise.resolve({
61
+ code: 200,
62
+ data: {
63
+ content: 'test content',
64
+ },
65
+ }),
66
+ };
67
+
68
+ mockFetch.mockResolvedValue(mockResponse);
69
+
70
+ await jina('https://example.com', { filterOptions: {} });
71
+
72
+ expect(mockFetch).toHaveBeenCalledWith('https://r.jina.ai/https://example.com', {
73
+ headers: {
74
+ 'Accept': 'application/json',
75
+ 'Authorization': 'Bearer env-reader-key',
76
+ 'x-send-from': 'LobeChat Community',
77
+ },
78
+ });
79
+
80
+ delete process.env.JINA_READER_API_KEY;
81
+ });
82
+
83
+ it('should use JINA_API_KEY from env if apiKey and JINA_READER_API_KEY not provided', async () => {
84
+ process.env.JINA_API_KEY = 'env-key';
85
+
86
+ const mockResponse = {
87
+ ok: true,
88
+ json: () =>
89
+ Promise.resolve({
90
+ code: 200,
91
+ data: {
92
+ content: 'test content',
93
+ },
94
+ }),
95
+ };
96
+
97
+ mockFetch.mockResolvedValue(mockResponse);
98
+
99
+ await jina('https://example.com', { filterOptions: {} });
100
+
101
+ expect(mockFetch).toHaveBeenCalledWith('https://r.jina.ai/https://example.com', {
102
+ headers: {
103
+ 'Accept': 'application/json',
104
+ 'Authorization': 'Bearer env-key',
105
+ 'x-send-from': 'LobeChat Community',
106
+ },
107
+ });
108
+
109
+ delete process.env.JINA_API_KEY;
110
+ });
111
+
112
+ it('should send empty Authorization header if no api key provided', async () => {
113
+ const mockResponse = {
114
+ ok: true,
115
+ json: () =>
116
+ Promise.resolve({
117
+ code: 200,
118
+ data: {
119
+ content: 'test content',
120
+ },
121
+ }),
122
+ };
123
+
124
+ mockFetch.mockResolvedValue(mockResponse);
125
+
126
+ await jina('https://example.com', { filterOptions: {} });
127
+
128
+ expect(mockFetch).toHaveBeenCalledWith('https://r.jina.ai/https://example.com', {
129
+ headers: {
130
+ 'Accept': 'application/json',
131
+ 'Authorization': '',
132
+ 'x-send-from': 'LobeChat Community',
133
+ },
134
+ });
135
+ });
136
+
137
+ it('should return undefined if response is not ok', async () => {
138
+ mockFetch.mockResolvedValue({ ok: false });
139
+
140
+ const result = await jina('https://example.com', { filterOptions: {} });
141
+
142
+ expect(result).toBeUndefined();
143
+ });
144
+
145
+ it('should return undefined if response code is not 200', async () => {
146
+ const mockResponse = {
147
+ ok: true,
148
+ json: () =>
149
+ Promise.resolve({
150
+ code: 400,
151
+ message: 'Bad Request',
152
+ }),
153
+ };
154
+
155
+ mockFetch.mockResolvedValue(mockResponse);
156
+
157
+ const result = await jina('https://example.com', { filterOptions: {} });
158
+
159
+ expect(result).toBeUndefined();
160
+ });
161
+
162
+ it('should return undefined if fetch throws error', async () => {
163
+ mockFetch.mockRejectedValue(new Error('Network error'));
164
+
165
+ const result = await jina('https://example.com', { filterOptions: {} });
166
+
167
+ expect(result).toBeUndefined();
168
+ });
169
+ });
@@ -1,5 +1,5 @@
1
1
  import { CrawlImpl, CrawlSuccessResult } from '../type';
2
- import { NetworkConnectionError, PageNotFoundError } from '../utils/errorType';
2
+ import { NetworkConnectionError, PageNotFoundError, TimeoutError } from '../utils/errorType';
3
3
  import { htmlToMarkdown } from '../utils/htmlToMarkdown';
4
4
 
5
5
  const mixinHeaders = {
@@ -31,15 +31,41 @@ const mixinHeaders = {
31
31
  'sec-fetch-user': '?1',
32
32
  };
33
33
 
34
+ const TIMEOUT_CONTROL = 10_000;
35
+
36
+ const withTimeout = <T>(promise: Promise<T>, ms: number): Promise<T> => {
37
+ const controller = new AbortController();
38
+ const timeoutPromise = new Promise<T>((_, reject) => {
39
+ setTimeout(() => {
40
+ controller.abort();
41
+ reject(new TimeoutError(`Request timeout after ${ms}ms`));
42
+ }, ms);
43
+ });
44
+
45
+ return Promise.race([promise, timeoutPromise]);
46
+ };
47
+
34
48
  export const naive: CrawlImpl = async (url, { filterOptions }) => {
35
49
  let res: Response;
36
50
 
37
51
  try {
38
- res = await fetch(url, { headers: mixinHeaders });
52
+ res = await withTimeout(
53
+ fetch(url, {
54
+ headers: mixinHeaders,
55
+ signal: new AbortController().signal,
56
+ }),
57
+ TIMEOUT_CONTROL,
58
+ );
39
59
  } catch (e) {
40
- if ((e as Error).message === 'fetch failed') {
60
+ const error = e as Error;
61
+ if (error.message === 'fetch failed') {
41
62
  throw new NetworkConnectionError();
42
63
  }
64
+
65
+ if (error instanceof TimeoutError) {
66
+ throw error;
67
+ }
68
+
43
69
  throw e;
44
70
  }
45
71
 
@@ -10,3 +10,10 @@ export class NetworkConnectionError extends Error {
10
10
  this.name = 'NetworkConnectionError';
11
11
  }
12
12
  }
13
+
14
+ export class TimeoutError extends Error {
15
+ constructor(message: string) {
16
+ super(message);
17
+ this.name = 'TimeoutError';
18
+ }
19
+ }
@@ -74,20 +74,24 @@ const runProxyChainsConfGenerator = async (url) => {
74
74
 
75
75
  let ip = isValidIP(host, 4) ? host : await resolveHostIP(host, 4);
76
76
 
77
- const configContent = `
78
- localnet 127.0.0.0/255.0.0.0
79
- localnet 10.0.0.0/255.0.0.0
80
- localnet 172.16.0.0/255.240.0.0
81
- localnet 192.168.0.0/255.255.0.0
82
- localnet ::1/128
77
+ const proxyDNSConfig = process.env.ENABLE_PROXY_DNS === '1' ? `
83
78
  proxy_dns
84
79
  remote_dns_subnet 224
80
+ `.trim() : '';
81
+
82
+ const configContent = `
83
+ localnet 127.0.0.0/8
84
+ localnet 10.0.0.0/8
85
+ localnet 172.16.0.0/12
86
+ localnet 192.168.0.0/16
87
+ localnet ::/127
88
+ ${proxyDNSConfig}
85
89
  strict_chain
86
90
  tcp_connect_time_out 8000
87
91
  tcp_read_time_out 15000
88
92
  [ProxyList]
89
93
  ${protocol} ${ip} ${port} ${user} ${pass}
90
- `.trim();
94
+ `.replace(/\n{2,}/g, '\n').trim();
91
95
 
92
96
  await fs.writeFile(PROXYCHAINS_CONF_PATH, configContent);
93
97
  console.log(`✅ ProxyChains: All outgoing traffic routed via ${url}.`);
@@ -107,12 +107,12 @@ export const DEFAULT_MODEL_PROVIDER_LIST = [
107
107
  GoogleProvider,
108
108
  VertexAIProvider,
109
109
  DeepSeekProvider,
110
- PPIOProvider,
111
110
  HuggingFaceProvider,
112
111
  OpenRouterProvider,
113
112
  CloudflareProvider,
114
113
  GithubProvider,
115
114
  NovitaProvider,
115
+ PPIOProvider,
116
116
  NvidiaProvider,
117
117
  TogetherAIProvider,
118
118
  FireworksAIProvider,
@@ -243,7 +243,7 @@ const PPIO: ModelProviderCard = {
243
243
  sdkType: 'openai',
244
244
  showModelFetcher: true,
245
245
  },
246
- url: 'https://ppinfra.com/?utm_source=github_lobe-chat&utm_medium=github_readme&utm_campaign=link',
246
+ url: 'https://ppinfra.com/user/register?invited_by=RQIMOC',
247
247
  };
248
248
 
249
249
  export default PPIO;
@@ -1,9 +1,7 @@
1
- import { ModelTag } from '@lobehub/icons';
2
1
  import { memo } from 'react';
3
2
  import { Flexbox } from 'react-layout-kit';
4
3
 
5
- import { useAgentStore } from '@/store/agent';
6
- import { agentSelectors } from '@/store/agent/slices/chat';
4
+ import { LOADING_FLAT } from '@/const/message';
7
5
  import { useChatStore } from '@/store/chat';
8
6
  import { chatSelectors } from '@/store/chat/selectors';
9
7
  import { ChatMessage } from '@/types/message';
@@ -12,34 +10,28 @@ import { RenderMessageExtra } from '../types';
12
10
  import ExtraContainer from './ExtraContainer';
13
11
  import TTS from './TTS';
14
12
  import Translate from './Translate';
13
+ import Usage from './Usage';
15
14
 
16
15
  export const AssistantMessageExtra: RenderMessageExtra = memo<ChatMessage>(
17
- ({ extra, id, content }) => {
18
- const model = useAgentStore(agentSelectors.currentAgentModel);
16
+ ({ extra, id, content, metadata, tools }) => {
19
17
  const loading = useChatStore(chatSelectors.isMessageGenerating(id));
20
18
 
21
- const showModelTag = extra?.fromModel && model !== extra?.fromModel;
22
- const showTranslate = !!extra?.translate;
23
- const showTTS = !!extra?.tts;
24
-
25
- const showExtra = showModelTag || showTranslate || showTTS;
26
-
27
- if (!showExtra) return;
28
-
29
19
  return (
30
- <Flexbox gap={8} style={{ marginTop: 8 }}>
31
- {showModelTag && (
32
- <div>
33
- <ModelTag model={extra?.fromModel as string} />
34
- </div>
20
+ <Flexbox gap={8} style={{ marginTop: !!tools?.length ? 8 : 4 }}>
21
+ {content !== LOADING_FLAT && extra?.fromModel && (
22
+ <Usage
23
+ metadata={metadata || {}}
24
+ model={extra?.fromModel}
25
+ provider={extra.fromProvider!}
26
+ />
35
27
  )}
36
28
  <>
37
- {extra?.tts && (
29
+ {!!extra?.tts && (
38
30
  <ExtraContainer>
39
31
  <TTS content={content} id={id} loading={loading} {...extra?.tts} />
40
32
  </ExtraContainer>
41
33
  )}
42
- {extra?.translate && (
34
+ {!!extra?.translate && (
43
35
  <ExtraContainer>
44
36
  <Translate id={id} loading={loading} {...extra?.translate} />
45
37
  </ExtraContainer>
@@ -0,0 +1,130 @@
1
+ import { ModelIcon } from '@lobehub/icons';
2
+ import { Icon, Tooltip } from '@lobehub/ui';
3
+ import { Segmented } from 'antd';
4
+ import { createStyles } from 'antd-style';
5
+ import { ArrowDownToDot, ArrowUpFromDot, CircleFadingArrowUp } from 'lucide-react';
6
+ import { memo } from 'react';
7
+ import { useTranslation } from 'react-i18next';
8
+ import { Flexbox } from 'react-layout-kit';
9
+
10
+ import { useGlobalStore } from '@/store/global';
11
+ import { systemStatusSelectors } from '@/store/global/selectors';
12
+ import { LobeDefaultAiModelListItem } from '@/types/aiModel';
13
+ import { ModelPriceCurrency } from '@/types/llm';
14
+ import { formatPriceByCurrency } from '@/utils/format';
15
+
16
+ export const useStyles = createStyles(({ css, token }) => {
17
+ return {
18
+ container: css`
19
+ font-size: 12px;
20
+ `,
21
+ desc: css`
22
+ line-height: 12px;
23
+ color: ${token.colorTextDescription};
24
+ `,
25
+ pricing: css`
26
+ font-size: 12px;
27
+ color: ${token.colorTextSecondary};
28
+ `,
29
+ };
30
+ });
31
+
32
+ interface ModelCardProps extends LobeDefaultAiModelListItem {
33
+ provider: string;
34
+ }
35
+
36
+ const ModelCard = memo<ModelCardProps>(({ pricing, id, provider, displayName }) => {
37
+ const { t } = useTranslation('chat');
38
+ const { styles } = useStyles();
39
+
40
+ const isShowCredit = useGlobalStore(systemStatusSelectors.isShowCredit) && !!pricing;
41
+ const updateSystemStatus = useGlobalStore((s) => s.updateSystemStatus);
42
+
43
+ const inputPrice = formatPriceByCurrency(pricing?.input, pricing?.currency as ModelPriceCurrency);
44
+ const cachedInputPrice = formatPriceByCurrency(
45
+ pricing?.cachedInput,
46
+ pricing?.currency as ModelPriceCurrency,
47
+ );
48
+ const outputPrice = formatPriceByCurrency(
49
+ pricing?.output,
50
+ pricing?.currency as ModelPriceCurrency,
51
+ );
52
+ return (
53
+ <>
54
+ <Flexbox
55
+ align={'center'}
56
+ className={styles.container}
57
+ flex={1}
58
+ gap={40}
59
+ horizontal
60
+ justify={'space-between'}
61
+ >
62
+ <Flexbox align={'center'} gap={8} horizontal>
63
+ <ModelIcon model={id} size={22} />
64
+ <Flexbox flex={1} gap={2} style={{ minWidth: 0 }}>
65
+ <Flexbox align={'center'} gap={8} horizontal style={{ lineHeight: '12px' }}>
66
+ {displayName || id}
67
+ </Flexbox>
68
+ <span className={styles.desc}>{provider}</span>
69
+ </Flexbox>
70
+ </Flexbox>
71
+ {!!pricing && (
72
+ <Flexbox>
73
+ <Segmented
74
+ onChange={(value) => {
75
+ updateSystemStatus({ isShowCredit: value === 'credit' });
76
+ }}
77
+ options={[
78
+ { label: 'Token', value: 'token' },
79
+ {
80
+ label: (
81
+ <Tooltip title={t('messages.modelCard.creditTooltip')}>
82
+ {t('messages.modelCard.credit')}
83
+ </Tooltip>
84
+ ),
85
+ value: 'credit',
86
+ },
87
+ ]}
88
+ size={'small'}
89
+ value={isShowCredit ? 'credit' : 'token'}
90
+ />
91
+ </Flexbox>
92
+ )}
93
+ </Flexbox>
94
+ {isShowCredit && (
95
+ <Flexbox horizontal justify={'space-between'}>
96
+ <div />
97
+ <Flexbox align={'center'} className={styles.pricing} gap={8} horizontal>
98
+ {t('messages.modelCard.creditPricing')}:
99
+ {pricing?.cachedInput && (
100
+ <Tooltip
101
+ title={t('messages.modelCard.pricing.inputCachedTokens', {
102
+ amount: cachedInputPrice,
103
+ })}
104
+ >
105
+ <Flexbox gap={2} horizontal>
106
+ <Icon icon={CircleFadingArrowUp} />
107
+ {cachedInputPrice}
108
+ </Flexbox>
109
+ </Tooltip>
110
+ )}
111
+ <Tooltip title={t('messages.modelCard.pricing.inputTokens', { amount: inputPrice })}>
112
+ <Flexbox gap={2} horizontal>
113
+ <Icon icon={ArrowUpFromDot} />
114
+ {inputPrice}
115
+ </Flexbox>
116
+ </Tooltip>
117
+ <Tooltip title={t('messages.modelCard.pricing.outputTokens', { amount: outputPrice })}>
118
+ <Flexbox gap={2} horizontal>
119
+ <Icon icon={ArrowDownToDot} />
120
+ {outputPrice}
121
+ </Flexbox>
122
+ </Tooltip>
123
+ </Flexbox>
124
+ </Flexbox>
125
+ )}
126
+ </>
127
+ );
128
+ });
129
+
130
+ export default ModelCard;
@@ -0,0 +1,71 @@
1
+ import { useTheme } from 'antd-style';
2
+ import numeral from 'numeral';
3
+ import { memo } from 'react';
4
+ import { Flexbox } from 'react-layout-kit';
5
+
6
+
7
+ export interface TokenProgressItem {
8
+ color: string;
9
+ id: string;
10
+ title: string;
11
+ value: number;
12
+ }
13
+
14
+ interface TokenProgressProps {
15
+ data: TokenProgressItem[];
16
+ showIcon?: boolean;
17
+ }
18
+
19
+ const format = (number: number) => numeral(number).format('0,0');
20
+
21
+ const TokenProgress = memo<TokenProgressProps>(({ data, showIcon }) => {
22
+ const theme = useTheme();
23
+ const total = data.reduce((acc, item) => acc + item.value, 0);
24
+
25
+ return (
26
+ <Flexbox gap={8} style={{ position: 'relative' }} width={'100%'}>
27
+ <Flexbox
28
+ height={6}
29
+ horizontal
30
+ style={{
31
+ background: total === 0 ? theme.colorFill : undefined,
32
+ borderRadius: 3,
33
+ overflow: 'hidden',
34
+ position: 'relative',
35
+ }}
36
+ width={'100%'}
37
+ >
38
+ {data.map((item) => (
39
+ <Flexbox
40
+ height={'100%'}
41
+ key={item.id}
42
+ style={{ background: item.color, flex: item.value }}
43
+ />
44
+ ))}
45
+ </Flexbox>
46
+ <Flexbox>
47
+ {data.map((item) => (
48
+ <Flexbox align={'center'} gap={4} horizontal justify={'space-between'} key={item.id}>
49
+ <Flexbox align={'center'} gap={4} horizontal>
50
+ {showIcon && (
51
+ <div
52
+ style={{
53
+ background: item.color,
54
+ borderRadius: '50%',
55
+ flex: 'none',
56
+ height: 6,
57
+ width: 6,
58
+ }}
59
+ />
60
+ )}
61
+ <div style={{ color: theme.colorTextSecondary }}>{item.title}</div>
62
+ </Flexbox>
63
+ <div style={{ fontWeight: 500 }}>{format(item.value)}</div>
64
+ </Flexbox>
65
+ ))}
66
+ </Flexbox>
67
+ </Flexbox>
68
+ );
69
+ });
70
+
71
+ export default TokenProgress;
@@ -0,0 +1,146 @@
1
+ import { Icon } from '@lobehub/ui';
2
+ import { Divider, Popover } from 'antd';
3
+ import { useTheme } from 'antd-style';
4
+ import { BadgeCent, CoinsIcon } from 'lucide-react';
5
+ import { memo } from 'react';
6
+ import { useTranslation } from 'react-i18next';
7
+ import { Center, Flexbox } from 'react-layout-kit';
8
+
9
+ import { aiModelSelectors, useAiInfraStore } from '@/store/aiInfra';
10
+ import { useGlobalStore } from '@/store/global';
11
+ import { systemStatusSelectors } from '@/store/global/selectors';
12
+ import { ModelTokensUsage } from '@/types/message';
13
+ import { formatNumber } from '@/utils/format';
14
+
15
+ import ModelCard from './ModelCard';
16
+ import TokenProgress, { TokenProgressItem } from './TokenProgress';
17
+ import { getDetailsToken } from './tokens';
18
+
19
+ interface TokenDetailProps {
20
+ model: string;
21
+ provider: string;
22
+ usage: ModelTokensUsage;
23
+ }
24
+
25
+ const TokenDetail = memo<TokenDetailProps>(({ usage, model, provider }) => {
26
+ const { t } = useTranslation('chat');
27
+ const theme = useTheme();
28
+
29
+ const modelCard = useAiInfraStore(aiModelSelectors.getModelCard(model, provider));
30
+ const isShowCredit = useGlobalStore(systemStatusSelectors.isShowCredit) && !!modelCard?.pricing;
31
+
32
+ const detailTokens = getDetailsToken(usage, modelCard);
33
+ const inputDetails = [
34
+ !!detailTokens.inputAudio && {
35
+ color: theme.cyan9,
36
+ id: 'reasoning',
37
+ title: t('messages.tokenDetails.inputAudio'),
38
+ value: isShowCredit ? detailTokens.inputAudio.credit : detailTokens.inputAudio.token,
39
+ },
40
+ !!detailTokens.inputText && {
41
+ color: theme.green,
42
+ id: 'inputText',
43
+ title: t('messages.tokenDetails.inputText'),
44
+ value: isShowCredit ? detailTokens.inputText.credit : detailTokens.inputText.token,
45
+ },
46
+ ].filter(Boolean) as TokenProgressItem[];
47
+
48
+ const outputDetails = [
49
+ !!detailTokens.reasoning && {
50
+ color: theme.pink,
51
+ id: 'reasoning',
52
+ title: t('messages.tokenDetails.reasoning'),
53
+ value: isShowCredit ? detailTokens.reasoning.credit : detailTokens.reasoning.token,
54
+ },
55
+ !!detailTokens.outputAudio && {
56
+ color: theme.cyan9,
57
+ id: 'outputAudio',
58
+ title: t('messages.tokenDetails.outputAudio'),
59
+ value: isShowCredit ? detailTokens.outputAudio.credit : detailTokens.outputAudio.token,
60
+ },
61
+ !!detailTokens.outputText && {
62
+ color: theme.green,
63
+ id: 'outputText',
64
+ title: t('messages.tokenDetails.outputText'),
65
+ value: isShowCredit ? detailTokens.outputText.credit : detailTokens.outputText.token,
66
+ },
67
+ ].filter(Boolean) as TokenProgressItem[];
68
+
69
+ const totalDetail = [
70
+ !!detailTokens.cachedInput && {
71
+ color: theme.orange,
72
+ id: 'cachedInput',
73
+ title: t('messages.tokenDetails.inputCached'),
74
+ value: isShowCredit ? detailTokens.cachedInput.credit : detailTokens.cachedInput.token,
75
+ },
76
+ !!detailTokens.uncachedInput && {
77
+ color: theme.colorFill,
78
+
79
+ id: 'uncachedInput',
80
+ title: t('messages.tokenDetails.inputUncached'),
81
+ value: isShowCredit ? detailTokens.uncachedInput.credit : detailTokens.uncachedInput.token,
82
+ },
83
+ !!detailTokens.totalOutput && {
84
+ color: theme.colorSuccess,
85
+ id: 'output',
86
+ title: t('messages.tokenDetails.output'),
87
+ value: isShowCredit ? detailTokens.totalOutput.credit : detailTokens.totalOutput.token,
88
+ },
89
+ ].filter(Boolean) as TokenProgressItem[];
90
+
91
+ const displayTotal =
92
+ isShowCredit && !!detailTokens.totalTokens
93
+ ? formatNumber(detailTokens.totalTokens.credit)
94
+ : formatNumber(usage.totalTokens);
95
+
96
+ return (
97
+ <Popover
98
+ arrow={false}
99
+ content={
100
+ <Flexbox gap={20} style={{ minWidth: 200 }}>
101
+ {modelCard && <ModelCard {...modelCard} provider={provider} />}
102
+ {inputDetails.length > 1 && (
103
+ <>
104
+ <Flexbox align={'center'} gap={4} horizontal justify={'space-between'} width={'100%'}>
105
+ <div style={{ color: theme.colorTextDescription }}>
106
+ {t('messages.tokenDetails.inputTitle')}
107
+ </div>
108
+ </Flexbox>
109
+ <TokenProgress data={inputDetails} showIcon />
110
+ </>
111
+ )}
112
+ {outputDetails.length > 1 && (
113
+ <>
114
+ <Flexbox align={'center'} gap={4} horizontal justify={'space-between'} width={'100%'}>
115
+ <div style={{ color: theme.colorTextDescription }}>
116
+ {t('messages.tokenDetails.outputTitle')}
117
+ </div>
118
+ </Flexbox>
119
+ <TokenProgress data={outputDetails} showIcon />
120
+ </>
121
+ )}
122
+
123
+ <Flexbox>
124
+ <TokenProgress data={totalDetail} showIcon />
125
+ <Divider style={{ marginBlock: 8 }} />
126
+ <Flexbox align={'center'} gap={4} horizontal justify={'space-between'}>
127
+ <div style={{ color: theme.colorTextSecondary }}>
128
+ {t('messages.tokenDetails.total')}
129
+ </div>
130
+ <div style={{ fontWeight: 500 }}>{displayTotal}</div>
131
+ </Flexbox>
132
+ </Flexbox>
133
+ </Flexbox>
134
+ }
135
+ placement={'top'}
136
+ trigger={['hover', 'click']}
137
+ >
138
+ <Center gap={2} horizontal style={{ cursor: 'default' }}>
139
+ <Icon icon={isShowCredit ? BadgeCent : CoinsIcon} />
140
+ {displayTotal}
141
+ </Center>
142
+ </Popover>
143
+ );
144
+ });
145
+
146
+ export default TokenDetail;