render.js 50 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698
  1. import i18next from 'i18next';
  2. import { Modal, Tag, Typography } from '@douyinfe/semi-ui';
  3. import { copy, showSuccess } from './utils';
  4. import { MOBILE_BREAKPOINT } from '../hooks/useIsMobile.js';
  5. import { visit } from 'unist-util-visit';
  6. import {
  7. OpenAI,
  8. Claude,
  9. Gemini,
  10. Moonshot,
  11. Zhipu,
  12. Qwen,
  13. DeepSeek,
  14. Minimax,
  15. Wenxin,
  16. Spark,
  17. Midjourney,
  18. Hunyuan,
  19. Cohere,
  20. Cloudflare,
  21. Ai360,
  22. Yi,
  23. Jina,
  24. Mistral,
  25. XAI,
  26. Ollama,
  27. Doubao,
  28. Suno,
  29. Xinference,
  30. OpenRouter,
  31. Dify,
  32. Coze,
  33. SiliconCloud,
  34. FastGPT,
  35. Kling,
  36. Jimeng,
  37. } from '@lobehub/icons';
  38. import {
  39. LayoutDashboard,
  40. TerminalSquare,
  41. MessageSquare,
  42. Key,
  43. BarChart3,
  44. Image as ImageIcon,
  45. CheckSquare,
  46. CreditCard,
  47. Layers,
  48. Gift,
  49. User,
  50. Settings,
  51. CircleUser,
  52. } from 'lucide-react';
  53. // 侧边栏图标颜色映射
  54. export const sidebarIconColors = {
  55. dashboard: '#10B981', // 绿色
  56. terminal: '#10B981', // 绿色
  57. message: '#06B6D4', // 青色
  58. key: '#3B82F6', // 蓝色
  59. chart: '#F59E0B', // 琥珀色
  60. image: '#EC4899', // 粉色
  61. check: '#F59E0B', // 琥珀色
  62. credit: '#F97316', // 橙色
  63. layers: '#EF4444', // 红色
  64. gift: '#F43F5E', // 玫红色
  65. user: '#10B981', // 绿色
  66. settings: '#F97316', // 橙色
  67. };
  68. // 获取侧边栏Lucide图标组件
  69. export function getLucideIcon(key, selected = false) {
  70. const size = 16;
  71. const strokeWidth = 2;
  72. const commonProps = {
  73. size,
  74. strokeWidth,
  75. className: `transition-colors duration-200 ${selected ? 'transition-transform duration-200 scale-105' : ''}`,
  76. };
  77. // 根据不同的key返回不同的图标
  78. switch (key) {
  79. case 'detail':
  80. return (
  81. <LayoutDashboard
  82. {...commonProps}
  83. color={selected ? sidebarIconColors.dashboard : 'currentColor'}
  84. />
  85. );
  86. case 'playground':
  87. return (
  88. <TerminalSquare
  89. {...commonProps}
  90. color={selected ? sidebarIconColors.terminal : 'currentColor'}
  91. />
  92. );
  93. case 'chat':
  94. return (
  95. <MessageSquare
  96. {...commonProps}
  97. color={selected ? sidebarIconColors.message : 'currentColor'}
  98. />
  99. );
  100. case 'token':
  101. return (
  102. <Key
  103. {...commonProps}
  104. color={selected ? sidebarIconColors.key : 'currentColor'}
  105. />
  106. );
  107. case 'log':
  108. return (
  109. <BarChart3
  110. {...commonProps}
  111. color={selected ? sidebarIconColors.chart : 'currentColor'}
  112. />
  113. );
  114. case 'midjourney':
  115. return (
  116. <ImageIcon
  117. {...commonProps}
  118. color={selected ? sidebarIconColors.image : 'currentColor'}
  119. />
  120. );
  121. case 'task':
  122. return (
  123. <CheckSquare
  124. {...commonProps}
  125. color={selected ? sidebarIconColors.check : 'currentColor'}
  126. />
  127. );
  128. case 'topup':
  129. return (
  130. <CreditCard
  131. {...commonProps}
  132. color={selected ? sidebarIconColors.credit : 'currentColor'}
  133. />
  134. );
  135. case 'channel':
  136. return (
  137. <Layers
  138. {...commonProps}
  139. color={selected ? sidebarIconColors.layers : 'currentColor'}
  140. />
  141. );
  142. case 'redemption':
  143. return (
  144. <Gift
  145. {...commonProps}
  146. color={selected ? sidebarIconColors.gift : 'currentColor'}
  147. />
  148. );
  149. case 'user':
  150. case 'personal':
  151. return (
  152. <User
  153. {...commonProps}
  154. color={selected ? sidebarIconColors.user : 'currentColor'}
  155. />
  156. );
  157. case 'setting':
  158. return (
  159. <Settings
  160. {...commonProps}
  161. color={selected ? sidebarIconColors.settings : 'currentColor'}
  162. />
  163. );
  164. default:
  165. return (
  166. <CircleUser
  167. {...commonProps}
  168. color={selected ? sidebarIconColors.user : 'currentColor'}
  169. />
  170. );
  171. }
  172. }
  173. // 获取模型分类
  174. export const getModelCategories = (() => {
  175. let categoriesCache = null;
  176. let lastLocale = null;
  177. return (t) => {
  178. const currentLocale = i18next.language;
  179. if (categoriesCache && lastLocale === currentLocale) {
  180. return categoriesCache;
  181. }
  182. categoriesCache = {
  183. all: {
  184. label: t('全部模型'),
  185. icon: null,
  186. filter: () => true,
  187. },
  188. openai: {
  189. label: 'OpenAI',
  190. icon: <OpenAI />,
  191. filter: (model) =>
  192. model.model_name.toLowerCase().includes('gpt') ||
  193. model.model_name.toLowerCase().includes('dall-e') ||
  194. model.model_name.toLowerCase().includes('whisper') ||
  195. model.model_name.toLowerCase().includes('tts') ||
  196. model.model_name.toLowerCase().includes('text-') ||
  197. model.model_name.toLowerCase().includes('babbage') ||
  198. model.model_name.toLowerCase().includes('davinci') ||
  199. model.model_name.toLowerCase().includes('curie') ||
  200. model.model_name.toLowerCase().includes('ada') ||
  201. model.model_name.toLowerCase().includes('o1') ||
  202. model.model_name.toLowerCase().includes('o3') ||
  203. model.model_name.toLowerCase().includes('o4'),
  204. },
  205. anthropic: {
  206. label: 'Anthropic',
  207. icon: <Claude.Color />,
  208. filter: (model) => model.model_name.toLowerCase().includes('claude'),
  209. },
  210. gemini: {
  211. label: 'Gemini',
  212. icon: <Gemini.Color />,
  213. filter: (model) => model.model_name.toLowerCase().includes('gemini'),
  214. },
  215. moonshot: {
  216. label: 'Moonshot',
  217. icon: <Moonshot />,
  218. filter: (model) => model.model_name.toLowerCase().includes('moonshot'),
  219. },
  220. zhipu: {
  221. label: t('智谱'),
  222. icon: <Zhipu.Color />,
  223. filter: (model) =>
  224. model.model_name.toLowerCase().includes('chatglm') ||
  225. model.model_name.toLowerCase().includes('glm-'),
  226. },
  227. qwen: {
  228. label: t('通义千问'),
  229. icon: <Qwen.Color />,
  230. filter: (model) => model.model_name.toLowerCase().includes('qwen'),
  231. },
  232. deepseek: {
  233. label: 'DeepSeek',
  234. icon: <DeepSeek.Color />,
  235. filter: (model) => model.model_name.toLowerCase().includes('deepseek'),
  236. },
  237. minimax: {
  238. label: 'MiniMax',
  239. icon: <Minimax.Color />,
  240. filter: (model) => model.model_name.toLowerCase().includes('abab'),
  241. },
  242. baidu: {
  243. label: t('文心一言'),
  244. icon: <Wenxin.Color />,
  245. filter: (model) => model.model_name.toLowerCase().includes('ernie'),
  246. },
  247. xunfei: {
  248. label: t('讯飞星火'),
  249. icon: <Spark.Color />,
  250. filter: (model) => model.model_name.toLowerCase().includes('spark'),
  251. },
  252. midjourney: {
  253. label: 'Midjourney',
  254. icon: <Midjourney />,
  255. filter: (model) => model.model_name.toLowerCase().includes('mj_'),
  256. },
  257. tencent: {
  258. label: t('腾讯混元'),
  259. icon: <Hunyuan.Color />,
  260. filter: (model) => model.model_name.toLowerCase().includes('hunyuan'),
  261. },
  262. cohere: {
  263. label: 'Cohere',
  264. icon: <Cohere.Color />,
  265. filter: (model) => model.model_name.toLowerCase().includes('command'),
  266. },
  267. cloudflare: {
  268. label: 'Cloudflare',
  269. icon: <Cloudflare.Color />,
  270. filter: (model) => model.model_name.toLowerCase().includes('@cf/'),
  271. },
  272. ai360: {
  273. label: t('360智脑'),
  274. icon: <Ai360.Color />,
  275. filter: (model) => model.model_name.toLowerCase().includes('360'),
  276. },
  277. yi: {
  278. label: t('零一万物'),
  279. icon: <Yi.Color />,
  280. filter: (model) => model.model_name.toLowerCase().includes('yi'),
  281. },
  282. jina: {
  283. label: 'Jina',
  284. icon: <Jina />,
  285. filter: (model) => model.model_name.toLowerCase().includes('jina'),
  286. },
  287. mistral: {
  288. label: 'Mistral AI',
  289. icon: <Mistral.Color />,
  290. filter: (model) => model.model_name.toLowerCase().includes('mistral'),
  291. },
  292. xai: {
  293. label: 'xAI',
  294. icon: <XAI />,
  295. filter: (model) => model.model_name.toLowerCase().includes('grok'),
  296. },
  297. llama: {
  298. label: 'Llama',
  299. icon: <Ollama />,
  300. filter: (model) => model.model_name.toLowerCase().includes('llama'),
  301. },
  302. doubao: {
  303. label: t('豆包'),
  304. icon: <Doubao.Color />,
  305. filter: (model) => model.model_name.toLowerCase().includes('doubao'),
  306. },
  307. };
  308. lastLocale = currentLocale;
  309. return categoriesCache;
  310. };
  311. })();
  312. /**
  313. * 根据渠道类型返回对应的厂商图标
  314. * @param {number} channelType - 渠道类型值
  315. * @returns {JSX.Element|null} - 对应的厂商图标组件
  316. */
  317. export function getChannelIcon(channelType) {
  318. const iconSize = 14;
  319. switch (channelType) {
  320. case 1: // OpenAI
  321. case 3: // Azure OpenAI
  322. return <OpenAI size={iconSize} />;
  323. case 2: // Midjourney Proxy
  324. case 5: // Midjourney Proxy Plus
  325. return <Midjourney size={iconSize} />;
  326. case 36: // Suno API
  327. return <Suno size={iconSize} />;
  328. case 4: // Ollama
  329. return <Ollama size={iconSize} />;
  330. case 14: // Anthropic Claude
  331. case 33: // AWS Claude
  332. return <Claude.Color size={iconSize} />;
  333. case 41: // Vertex AI
  334. return <Gemini.Color size={iconSize} />;
  335. case 34: // Cohere
  336. return <Cohere.Color size={iconSize} />;
  337. case 39: // Cloudflare
  338. return <Cloudflare.Color size={iconSize} />;
  339. case 43: // DeepSeek
  340. return <DeepSeek.Color size={iconSize} />;
  341. case 15: // 百度文心千帆
  342. case 46: // 百度文心千帆V2
  343. return <Wenxin.Color size={iconSize} />;
  344. case 17: // 阿里通义千问
  345. return <Qwen.Color size={iconSize} />;
  346. case 18: // 讯飞星火认知
  347. return <Spark.Color size={iconSize} />;
  348. case 16: // 智谱 ChatGLM
  349. case 26: // 智谱 GLM-4V
  350. return <Zhipu.Color size={iconSize} />;
  351. case 24: // Google Gemini
  352. case 11: // Google PaLM2
  353. return <Gemini.Color size={iconSize} />;
  354. case 47: // Xinference
  355. return <Xinference.Color size={iconSize} />;
  356. case 25: // Moonshot
  357. return <Moonshot size={iconSize} />;
  358. case 20: // OpenRouter
  359. return <OpenRouter size={iconSize} />;
  360. case 19: // 360 智脑
  361. return <Ai360.Color size={iconSize} />;
  362. case 23: // 腾讯混元
  363. return <Hunyuan.Color size={iconSize} />;
  364. case 31: // 零一万物
  365. return <Yi.Color size={iconSize} />;
  366. case 35: // MiniMax
  367. return <Minimax.Color size={iconSize} />;
  368. case 37: // Dify
  369. return <Dify.Color size={iconSize} />;
  370. case 38: // Jina
  371. return <Jina size={iconSize} />;
  372. case 40: // SiliconCloud
  373. return <SiliconCloud.Color size={iconSize} />;
  374. case 42: // Mistral AI
  375. return <Mistral.Color size={iconSize} />;
  376. case 45: // 字节火山方舟、豆包通用
  377. return <Doubao.Color size={iconSize} />;
  378. case 48: // xAI
  379. return <XAI size={iconSize} />;
  380. case 49: // Coze
  381. return <Coze size={iconSize} />;
  382. case 50: // 可灵 Kling
  383. return <Kling.Color size={iconSize} />;
  384. case 51: // 即梦 Jimeng
  385. return <Jimeng.Color size={iconSize} />;
  386. case 8: // 自定义渠道
  387. case 22: // 知识库:FastGPT
  388. return <FastGPT.Color size={iconSize} />;
  389. case 21: // 知识库:AI Proxy
  390. case 44: // 嵌入模型:MokaAI M3E
  391. default:
  392. return null; // 未知类型或自定义渠道不显示图标
  393. }
  394. }
  395. // 颜色列表
  396. const colors = [
  397. 'amber',
  398. 'blue',
  399. 'cyan',
  400. 'green',
  401. 'grey',
  402. 'indigo',
  403. 'light-blue',
  404. 'lime',
  405. 'orange',
  406. 'pink',
  407. 'purple',
  408. 'red',
  409. 'teal',
  410. 'violet',
  411. 'yellow',
  412. ];
  413. // 基础10色色板 (N ≤ 10)
  414. const baseColors = [
  415. '#1664FF', // 主色
  416. '#1AC6FF',
  417. '#FF8A00',
  418. '#3CC780',
  419. '#7442D4',
  420. '#FFC400',
  421. '#304D77',
  422. '#B48DEB',
  423. '#009488',
  424. '#FF7DDA',
  425. ];
  426. // 扩展20色色板 (10 < N ≤ 20)
  427. const extendedColors = [
  428. '#1664FF',
  429. '#B2CFFF',
  430. '#1AC6FF',
  431. '#94EFFF',
  432. '#FF8A00',
  433. '#FFCE7A',
  434. '#3CC780',
  435. '#B9EDCD',
  436. '#7442D4',
  437. '#DDC5FA',
  438. '#FFC400',
  439. '#FAE878',
  440. '#304D77',
  441. '#8B959E',
  442. '#B48DEB',
  443. '#EFE3FF',
  444. '#009488',
  445. '#59BAA8',
  446. '#FF7DDA',
  447. '#FFCFEE',
  448. ];
  449. // 模型颜色映射
  450. export const modelColorMap = {
  451. 'dall-e': 'rgb(147,112,219)', // 深紫色
  452. // 'dall-e-2': 'rgb(147,112,219)', // 介于紫色和蓝色之间的色调
  453. 'dall-e-3': 'rgb(153,50,204)', // 介于紫罗兰和洋红之间的色调
  454. 'gpt-3.5-turbo': 'rgb(184,227,167)', // 浅绿色
  455. // 'gpt-3.5-turbo-0301': 'rgb(131,220,131)', // 亮绿色
  456. 'gpt-3.5-turbo-0613': 'rgb(60,179,113)', // 海洋绿
  457. 'gpt-3.5-turbo-1106': 'rgb(32,178,170)', // 浅海洋绿
  458. 'gpt-3.5-turbo-16k': 'rgb(149,252,206)', // 淡橙色
  459. 'gpt-3.5-turbo-16k-0613': 'rgb(119,255,214)', // 淡桃
  460. 'gpt-3.5-turbo-instruct': 'rgb(175,238,238)', // 粉蓝色
  461. 'gpt-4': 'rgb(135,206,235)', // 天蓝色
  462. // 'gpt-4-0314': 'rgb(70,130,180)', // 钢蓝色
  463. 'gpt-4-0613': 'rgb(100,149,237)', // 矢车菊蓝
  464. 'gpt-4-1106-preview': 'rgb(30,144,255)', // 道奇蓝
  465. 'gpt-4-0125-preview': 'rgb(2,177,236)', // 深天蓝
  466. 'gpt-4-turbo-preview': 'rgb(2,177,255)', // 深天蓝
  467. 'gpt-4-32k': 'rgb(104,111,238)', // 中紫色
  468. // 'gpt-4-32k-0314': 'rgb(90,105,205)', // 暗灰蓝色
  469. 'gpt-4-32k-0613': 'rgb(61,71,139)', // 暗蓝灰色
  470. 'gpt-4-all': 'rgb(65,105,225)', // 皇家蓝
  471. 'gpt-4-gizmo-*': 'rgb(0,0,255)', // 纯蓝色
  472. 'gpt-4-vision-preview': 'rgb(25,25,112)', // 午夜蓝
  473. 'text-ada-001': 'rgb(255,192,203)', // 粉红色
  474. 'text-babbage-001': 'rgb(255,160,122)', // 浅珊瑚色
  475. 'text-curie-001': 'rgb(219,112,147)', // 苍紫罗兰色
  476. // 'text-davinci-002': 'rgb(199,21,133)', // 中紫罗兰红色
  477. 'text-davinci-003': 'rgb(219,112,147)', // 苍紫罗兰色(与Curie相同,表示同一个系列)
  478. 'text-davinci-edit-001': 'rgb(255,105,180)', // 热粉色
  479. 'text-embedding-ada-002': 'rgb(255,182,193)', // 浅粉红
  480. 'text-embedding-v1': 'rgb(255,174,185)', // 浅粉红色(略有区别)
  481. 'text-moderation-latest': 'rgb(255,130,171)', // 强粉色
  482. 'text-moderation-stable': 'rgb(255,160,122)', // 浅珊瑚色(与Babbage相同,表示同一类功能)
  483. 'tts-1': 'rgb(255,140,0)', // 深橙色
  484. 'tts-1-1106': 'rgb(255,165,0)', // 橙色
  485. 'tts-1-hd': 'rgb(255,215,0)', // 金色
  486. 'tts-1-hd-1106': 'rgb(255,223,0)', // 金黄色(略有区别)
  487. 'whisper-1': 'rgb(245,245,220)', // 米色
  488. 'claude-3-opus-20240229': 'rgb(255,132,31)', // 橙红色
  489. 'claude-3-sonnet-20240229': 'rgb(253,135,93)', // 橙色
  490. 'claude-3-haiku-20240307': 'rgb(255,175,146)', // 浅橙色
  491. 'claude-2.1': 'rgb(255,209,190)', // 浅橙色(略有区别)
  492. };
  493. export function modelToColor(modelName) {
  494. // 1. 如果模型在预定义的 modelColorMap 中,使用预定义颜色
  495. if (modelColorMap[modelName]) {
  496. return modelColorMap[modelName];
  497. }
  498. // 2. 生成一个稳定的数字作为索引
  499. let hash = 0;
  500. for (let i = 0; i < modelName.length; i++) {
  501. hash = (hash << 5) - hash + modelName.charCodeAt(i);
  502. hash = hash & hash; // Convert to 32-bit integer
  503. }
  504. hash = Math.abs(hash);
  505. // 3. 根据模型名称长度选择不同的色板
  506. const colorPalette = modelName.length > 10 ? extendedColors : baseColors;
  507. // 4. 使用hash值选择颜色
  508. const index = hash % colorPalette.length;
  509. return colorPalette[index];
  510. }
  511. export function stringToColor(str) {
  512. let sum = 0;
  513. for (let i = 0; i < str.length; i++) {
  514. sum += str.charCodeAt(i);
  515. }
  516. let i = sum % colors.length;
  517. return colors[i];
  518. }
  519. // 渲染带有模型图标的标签
  520. export function renderModelTag(modelName, options = {}) {
  521. const {
  522. color,
  523. size = 'default',
  524. shape = 'circle',
  525. onClick,
  526. suffixIcon,
  527. } = options;
  528. const categories = getModelCategories(i18next.t);
  529. let icon = null;
  530. for (const [key, category] of Object.entries(categories)) {
  531. if (key !== 'all' && category.filter({ model_name: modelName })) {
  532. icon = category.icon;
  533. break;
  534. }
  535. }
  536. return (
  537. <Tag
  538. color={color || stringToColor(modelName)}
  539. prefixIcon={icon}
  540. suffixIcon={suffixIcon}
  541. size={size}
  542. shape={shape}
  543. onClick={onClick}
  544. >
  545. {modelName}
  546. </Tag>
  547. );
  548. }
  549. export function renderText(text, limit) {
  550. if (text.length > limit) {
  551. return text.slice(0, limit - 3) + '...';
  552. }
  553. return text;
  554. }
  555. /**
  556. * Render group tags based on the input group string
  557. * @param {string} group - The input group string
  558. * @returns {JSX.Element} - The rendered group tags
  559. */
  560. export function renderGroup(group) {
  561. if (group === '') {
  562. return (
  563. <Tag key='default' color='white' shape='circle'>
  564. {i18next.t('用户分组')}
  565. </Tag>
  566. );
  567. }
  568. const tagColors = {
  569. vip: 'yellow',
  570. pro: 'yellow',
  571. svip: 'red',
  572. premium: 'red',
  573. };
  574. const groups = group.split(',').sort();
  575. return (
  576. <span key={group}>
  577. {groups.map((group) => (
  578. <Tag
  579. color={tagColors[group] || stringToColor(group)}
  580. key={group}
  581. shape='circle'
  582. onClick={async (event) => {
  583. event.stopPropagation();
  584. if (await copy(group)) {
  585. showSuccess(i18next.t('已复制:') + group);
  586. } else {
  587. Modal.error({
  588. title: i18next.t('无法复制到剪贴板,请手动复制'),
  589. content: group,
  590. });
  591. }
  592. }}
  593. >
  594. {group}
  595. </Tag>
  596. ))}
  597. </span>
  598. );
  599. }
  600. export function renderRatio(ratio) {
  601. let color = 'green';
  602. if (ratio > 5) {
  603. color = 'red';
  604. } else if (ratio > 3) {
  605. color = 'orange';
  606. } else if (ratio > 1) {
  607. color = 'blue';
  608. }
  609. return (
  610. <Tag color={color}>
  611. {ratio}x {i18next.t('倍率')}
  612. </Tag>
  613. );
  614. }
  615. const measureTextWidth = (
  616. text,
  617. style = {
  618. fontSize: '14px',
  619. fontFamily:
  620. '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
  621. },
  622. containerWidth,
  623. ) => {
  624. const span = document.createElement('span');
  625. span.style.visibility = 'hidden';
  626. span.style.position = 'absolute';
  627. span.style.whiteSpace = 'nowrap';
  628. span.style.fontSize = style.fontSize;
  629. span.style.fontFamily = style.fontFamily;
  630. span.textContent = text;
  631. document.body.appendChild(span);
  632. const width = span.offsetWidth;
  633. document.body.removeChild(span);
  634. return width;
  635. };
  636. export function truncateText(text, maxWidth = 200) {
  637. const isMobileScreen = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`).matches;
  638. if (!isMobileScreen) {
  639. return text;
  640. }
  641. if (!text) return text;
  642. try {
  643. // Handle percentage-based maxWidth
  644. let actualMaxWidth = maxWidth;
  645. if (typeof maxWidth === 'string' && maxWidth.endsWith('%')) {
  646. const percentage = parseFloat(maxWidth) / 100;
  647. // Use window width as fallback container width
  648. actualMaxWidth = window.innerWidth * percentage;
  649. }
  650. const width = measureTextWidth(text);
  651. if (width <= actualMaxWidth) return text;
  652. let left = 0;
  653. let right = text.length;
  654. let result = text;
  655. while (left <= right) {
  656. const mid = Math.floor((left + right) / 2);
  657. const truncated = text.slice(0, mid) + '...';
  658. const currentWidth = measureTextWidth(truncated);
  659. if (currentWidth <= actualMaxWidth) {
  660. result = truncated;
  661. left = mid + 1;
  662. } else {
  663. right = mid - 1;
  664. }
  665. }
  666. return result;
  667. } catch (error) {
  668. console.warn(
  669. 'Text measurement failed, falling back to character count',
  670. error,
  671. );
  672. if (text.length > 20) {
  673. return text.slice(0, 17) + '...';
  674. }
  675. return text;
  676. }
  677. }
  678. export const renderGroupOption = (item) => {
  679. const {
  680. disabled,
  681. selected,
  682. label,
  683. value,
  684. focused,
  685. className,
  686. style,
  687. onMouseEnter,
  688. onClick,
  689. empty,
  690. emptyContent,
  691. ...rest
  692. } = item;
  693. const baseStyle = {
  694. display: 'flex',
  695. justifyContent: 'space-between',
  696. alignItems: 'center',
  697. padding: '8px 16px',
  698. cursor: disabled ? 'not-allowed' : 'pointer',
  699. backgroundColor: focused ? 'var(--semi-color-fill-0)' : 'transparent',
  700. opacity: disabled ? 0.5 : 1,
  701. ...(selected && {
  702. backgroundColor: 'var(--semi-color-primary-light-default)',
  703. }),
  704. '&:hover': {
  705. backgroundColor: !disabled && 'var(--semi-color-fill-1)',
  706. },
  707. };
  708. const handleClick = () => {
  709. if (!disabled && onClick) {
  710. onClick();
  711. }
  712. };
  713. const handleMouseEnter = (e) => {
  714. if (!disabled && onMouseEnter) {
  715. onMouseEnter(e);
  716. }
  717. };
  718. return (
  719. <div
  720. style={baseStyle}
  721. onClick={handleClick}
  722. onMouseEnter={handleMouseEnter}
  723. >
  724. <div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
  725. <Typography.Text strong type={disabled ? 'tertiary' : undefined}>
  726. {value}
  727. </Typography.Text>
  728. <Typography.Text type='secondary' size='small'>
  729. {label}
  730. </Typography.Text>
  731. </div>
  732. {item.ratio && renderRatio(item.ratio)}
  733. </div>
  734. );
  735. };
  736. export function renderNumber(num) {
  737. if (num >= 1000000000) {
  738. return (num / 1000000000).toFixed(1) + 'B';
  739. } else if (num >= 1000000) {
  740. return (num / 1000000).toFixed(1) + 'M';
  741. } else if (num >= 10000) {
  742. return (num / 1000).toFixed(1) + 'k';
  743. } else {
  744. return num;
  745. }
  746. }
  747. export function renderQuotaNumberWithDigit(num, digits = 2) {
  748. if (typeof num !== 'number' || isNaN(num)) {
  749. return 0;
  750. }
  751. let displayInCurrency = localStorage.getItem('display_in_currency');
  752. num = num.toFixed(digits);
  753. if (displayInCurrency) {
  754. return '$' + num;
  755. }
  756. return num;
  757. }
  758. export function renderNumberWithPoint(num) {
  759. if (num === undefined) return '';
  760. num = num.toFixed(2);
  761. if (num >= 100000) {
  762. // Convert number to string to manipulate it
  763. let numStr = num.toString();
  764. // Find the position of the decimal point
  765. let decimalPointIndex = numStr.indexOf('.');
  766. let wholePart = numStr;
  767. let decimalPart = '';
  768. // If there is a decimal point, split the number into whole and decimal parts
  769. if (decimalPointIndex !== -1) {
  770. wholePart = numStr.slice(0, decimalPointIndex);
  771. decimalPart = numStr.slice(decimalPointIndex);
  772. }
  773. // Take the first two and last two digits of the whole number part
  774. let shortenedWholePart = wholePart.slice(0, 2) + '..' + wholePart.slice(-2);
  775. // Return the formatted number
  776. return shortenedWholePart + decimalPart;
  777. }
  778. // If the number is less than 100,000, return it unmodified
  779. return num;
  780. }
  781. export function getQuotaPerUnit() {
  782. let quotaPerUnit = localStorage.getItem('quota_per_unit');
  783. quotaPerUnit = parseFloat(quotaPerUnit);
  784. return quotaPerUnit;
  785. }
  786. export function renderUnitWithQuota(quota) {
  787. let quotaPerUnit = localStorage.getItem('quota_per_unit');
  788. quotaPerUnit = parseFloat(quotaPerUnit);
  789. quota = parseFloat(quota);
  790. return quotaPerUnit * quota;
  791. }
  792. export function getQuotaWithUnit(quota, digits = 6) {
  793. let quotaPerUnit = localStorage.getItem('quota_per_unit');
  794. quotaPerUnit = parseFloat(quotaPerUnit);
  795. return (quota / quotaPerUnit).toFixed(digits);
  796. }
  797. export function renderQuotaWithAmount(amount) {
  798. let displayInCurrency = localStorage.getItem('display_in_currency');
  799. displayInCurrency = displayInCurrency === 'true';
  800. if (displayInCurrency) {
  801. return '$' + amount;
  802. } else {
  803. return renderNumber(renderUnitWithQuota(amount));
  804. }
  805. }
  806. export function renderQuota(quota, digits = 2) {
  807. let quotaPerUnit = localStorage.getItem('quota_per_unit');
  808. let displayInCurrency = localStorage.getItem('display_in_currency');
  809. quotaPerUnit = parseFloat(quotaPerUnit);
  810. displayInCurrency = displayInCurrency === 'true';
  811. if (displayInCurrency) {
  812. return '$' + (quota / quotaPerUnit).toFixed(digits);
  813. }
  814. return renderNumber(quota);
  815. }
  816. function isValidGroupRatio(ratio) {
  817. return Number.isFinite(ratio) && ratio !== -1;
  818. }
  819. /**
  820. * Helper function to get effective ratio and label
  821. * @param {number} groupRatio - The default group ratio
  822. * @param {number} user_group_ratio - The user-specific group ratio
  823. * @returns {Object} - Object containing { ratio, label, useUserGroupRatio }
  824. */
  825. function getEffectiveRatio(groupRatio, user_group_ratio) {
  826. const useUserGroupRatio = isValidGroupRatio(user_group_ratio);
  827. const ratioLabel = useUserGroupRatio
  828. ? i18next.t('专属倍率')
  829. : i18next.t('分组倍率');
  830. const effectiveRatio = useUserGroupRatio ? user_group_ratio : groupRatio;
  831. return {
  832. ratio: effectiveRatio,
  833. label: ratioLabel,
  834. useUserGroupRatio: useUserGroupRatio
  835. };
  836. }
  837. export function renderModelPrice(
  838. inputTokens,
  839. completionTokens,
  840. modelRatio,
  841. modelPrice = -1,
  842. completionRatio,
  843. groupRatio,
  844. user_group_ratio,
  845. cacheTokens = 0,
  846. cacheRatio = 1.0,
  847. image = false,
  848. imageRatio = 1.0,
  849. imageOutputTokens = 0,
  850. webSearch = false,
  851. webSearchCallCount = 0,
  852. webSearchPrice = 0,
  853. fileSearch = false,
  854. fileSearchCallCount = 0,
  855. fileSearchPrice = 0,
  856. audioInputSeperatePrice = false,
  857. audioInputTokens = 0,
  858. audioInputPrice = 0,
  859. ) {
  860. const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio);
  861. groupRatio = effectiveGroupRatio;
  862. if (modelPrice !== -1) {
  863. return i18next.t(
  864. '模型价格:${{price}} * {{ratioType}}:{{ratio}} = ${{total}}',
  865. {
  866. price: modelPrice,
  867. ratio: groupRatio,
  868. total: modelPrice * groupRatio,
  869. ratioType: ratioLabel,
  870. },
  871. );
  872. } else {
  873. if (completionRatio === undefined) {
  874. completionRatio = 0;
  875. }
  876. let inputRatioPrice = modelRatio * 2.0;
  877. let completionRatioPrice = modelRatio * 2.0 * completionRatio;
  878. let cacheRatioPrice = modelRatio * 2.0 * cacheRatio;
  879. let imageRatioPrice = modelRatio * 2.0 * imageRatio;
  880. // Calculate effective input tokens (non-cached + cached with ratio applied)
  881. let effectiveInputTokens =
  882. inputTokens - cacheTokens + cacheTokens * cacheRatio;
  883. // Handle image tokens if present
  884. if (image && imageOutputTokens > 0) {
  885. effectiveInputTokens =
  886. inputTokens - imageOutputTokens + imageOutputTokens * imageRatio;
  887. }
  888. if (audioInputTokens > 0) {
  889. effectiveInputTokens -= audioInputTokens;
  890. }
  891. let price =
  892. (effectiveInputTokens / 1000000) * inputRatioPrice * groupRatio +
  893. (audioInputTokens / 1000000) * audioInputPrice * groupRatio +
  894. (completionTokens / 1000000) * completionRatioPrice * groupRatio +
  895. (webSearchCallCount / 1000) * webSearchPrice * groupRatio +
  896. (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio;
  897. return (
  898. <>
  899. <article>
  900. <p>
  901. {i18next.t('输入价格:${{price}} / 1M tokens{{audioPrice}}', {
  902. price: inputRatioPrice,
  903. audioPrice: audioInputSeperatePrice
  904. ? `,音频 $${audioInputPrice} / 1M tokens`
  905. : '',
  906. })}
  907. </p>
  908. <p>
  909. {i18next.t(
  910. '输出价格:${{price}} * {{completionRatio}} = ${{total}} / 1M tokens (补全倍率: {{completionRatio}})',
  911. {
  912. price: inputRatioPrice,
  913. total: completionRatioPrice,
  914. completionRatio: completionRatio,
  915. },
  916. )}
  917. </p>
  918. {cacheTokens > 0 && (
  919. <p>
  920. {i18next.t(
  921. '缓存价格:${{price}} * {{cacheRatio}} = ${{total}} / 1M tokens (缓存倍率: {{cacheRatio}})',
  922. {
  923. price: inputRatioPrice,
  924. total: inputRatioPrice * cacheRatio,
  925. cacheRatio: cacheRatio,
  926. },
  927. )}
  928. </p>
  929. )}
  930. {image && imageOutputTokens > 0 && (
  931. <p>
  932. {i18next.t(
  933. '图片输入价格:${{price}} * {{ratio}} = ${{total}} / 1M tokens (图片倍率: {{imageRatio}})',
  934. {
  935. price: imageRatioPrice,
  936. ratio: groupRatio,
  937. total: imageRatioPrice * groupRatio,
  938. imageRatio: imageRatio,
  939. },
  940. )}
  941. </p>
  942. )}
  943. {webSearch && webSearchCallCount > 0 && (
  944. <p>
  945. {i18next.t('Web搜索价格:${{price}} / 1K 次', {
  946. price: webSearchPrice,
  947. })}
  948. </p>
  949. )}
  950. {fileSearch && fileSearchCallCount > 0 && (
  951. <p>
  952. {i18next.t('文件搜索价格:${{price}} / 1K 次', {
  953. price: fileSearchPrice,
  954. })}
  955. </p>
  956. )}
  957. <p></p>
  958. <p>
  959. {(() => {
  960. // 构建输入部分描述
  961. let inputDesc = '';
  962. if (image && imageOutputTokens > 0) {
  963. inputDesc = i18next.t(
  964. '(输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * ${{price}}',
  965. {
  966. nonImageInput: inputTokens - imageOutputTokens,
  967. imageInput: imageOutputTokens,
  968. imageRatio: imageRatio,
  969. price: inputRatioPrice,
  970. },
  971. );
  972. } else if (cacheTokens > 0) {
  973. inputDesc = i18next.t(
  974. '(输入 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}}',
  975. {
  976. nonCacheInput: inputTokens - cacheTokens,
  977. cacheInput: cacheTokens,
  978. price: inputRatioPrice,
  979. cachePrice: cacheRatioPrice,
  980. },
  981. );
  982. } else if (audioInputSeperatePrice && audioInputTokens > 0) {
  983. inputDesc = i18next.t(
  984. '(输入 {{nonAudioInput}} tokens / 1M tokens * ${{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * ${{audioPrice}}',
  985. {
  986. nonAudioInput: inputTokens - audioInputTokens,
  987. audioInput: audioInputTokens,
  988. price: inputRatioPrice,
  989. audioPrice: audioInputPrice,
  990. },
  991. );
  992. } else {
  993. inputDesc = i18next.t(
  994. '(输入 {{input}} tokens / 1M tokens * ${{price}}',
  995. {
  996. input: inputTokens,
  997. price: inputRatioPrice,
  998. },
  999. );
  1000. }
  1001. // 构建输出部分描述
  1002. const outputDesc = i18next.t(
  1003. '输出 {{completion}} tokens / 1M tokens * ${{compPrice}}) * {{ratioType}} {{ratio}}',
  1004. {
  1005. completion: completionTokens,
  1006. compPrice: completionRatioPrice,
  1007. ratio: groupRatio,
  1008. ratioType: ratioLabel,
  1009. },
  1010. );
  1011. // 构建额外服务描述
  1012. const extraServices = [
  1013. webSearch && webSearchCallCount > 0
  1014. ? i18next.t(
  1015. ' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
  1016. {
  1017. count: webSearchCallCount,
  1018. price: webSearchPrice,
  1019. ratio: groupRatio,
  1020. ratioType: ratioLabel,
  1021. },
  1022. )
  1023. : '',
  1024. fileSearch && fileSearchCallCount > 0
  1025. ? i18next.t(
  1026. ' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
  1027. {
  1028. count: fileSearchCallCount,
  1029. price: fileSearchPrice,
  1030. ratio: groupRatio,
  1031. ratioType: ratioLabel,
  1032. },
  1033. )
  1034. : '',
  1035. ].join('');
  1036. return i18next.t(
  1037. '{{inputDesc}} + {{outputDesc}}{{extraServices}} = ${{total}}',
  1038. {
  1039. inputDesc,
  1040. outputDesc,
  1041. extraServices,
  1042. total: price.toFixed(6),
  1043. },
  1044. );
  1045. })()}
  1046. </p>
  1047. <p>{i18next.t('仅供参考,以实际扣费为准')}</p>
  1048. </article>
  1049. </>
  1050. );
  1051. }
  1052. }
  1053. export function renderLogContent(
  1054. modelRatio,
  1055. completionRatio,
  1056. modelPrice = -1,
  1057. groupRatio,
  1058. user_group_ratio,
  1059. image = false,
  1060. imageRatio = 1.0,
  1061. webSearch = false,
  1062. webSearchCallCount = 0,
  1063. fileSearch = false,
  1064. fileSearchCallCount = 0,
  1065. ) {
  1066. const { ratio, label: ratioLabel, useUserGroupRatio: useUserGroupRatio } = getEffectiveRatio(groupRatio, user_group_ratio);
  1067. if (modelPrice !== -1) {
  1068. return i18next.t('模型价格 ${{price}},{{ratioType}} {{ratio}}', {
  1069. price: modelPrice,
  1070. ratioType: ratioLabel,
  1071. ratio,
  1072. });
  1073. } else {
  1074. if (image) {
  1075. return i18next.t(
  1076. '模型倍率 {{modelRatio}},输出倍率 {{completionRatio}},图片输入倍率 {{imageRatio}},{{ratioType}} {{ratio}}',
  1077. {
  1078. modelRatio: modelRatio,
  1079. completionRatio: completionRatio,
  1080. imageRatio: imageRatio,
  1081. ratioType: ratioLabel,
  1082. ratio,
  1083. },
  1084. );
  1085. } else if (webSearch) {
  1086. return i18next.t(
  1087. '模型倍率 {{modelRatio}},输出倍率 {{completionRatio}},{{ratioType}} {{ratio}},Web 搜索调用 {{webSearchCallCount}} 次',
  1088. {
  1089. modelRatio: modelRatio,
  1090. completionRatio: completionRatio,
  1091. ratioType: ratioLabel,
  1092. ratio,
  1093. webSearchCallCount,
  1094. },
  1095. );
  1096. } else {
  1097. return i18next.t(
  1098. '模型倍率 {{modelRatio}},输出倍率 {{completionRatio}},{{ratioType}} {{ratio}}',
  1099. {
  1100. modelRatio: modelRatio,
  1101. completionRatio: completionRatio,
  1102. ratioType: ratioLabel,
  1103. ratio,
  1104. },
  1105. );
  1106. }
  1107. }
  1108. }
  1109. export function renderModelPriceSimple(
  1110. modelRatio,
  1111. modelPrice = -1,
  1112. groupRatio,
  1113. user_group_ratio,
  1114. cacheTokens = 0,
  1115. cacheRatio = 1.0,
  1116. image = false,
  1117. imageRatio = 1.0,
  1118. ) {
  1119. const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio);
  1120. groupRatio = effectiveGroupRatio;
  1121. if (modelPrice !== -1) {
  1122. return i18next.t('价格:${{price}} * {{ratioType}}:{{ratio}}', {
  1123. price: modelPrice,
  1124. ratioType: ratioLabel,
  1125. ratio: groupRatio,
  1126. });
  1127. } else {
  1128. if (image && cacheTokens !== 0) {
  1129. return i18next.t(
  1130. '模型: {{ratio}} * {{ratioType}}: {{groupRatio}} * 缓存倍率: {{cacheRatio}} * 图片输入倍率: {{imageRatio}}',
  1131. {
  1132. ratio: modelRatio,
  1133. ratioType: ratioLabel,
  1134. groupRatio: groupRatio,
  1135. cacheRatio: cacheRatio,
  1136. imageRatio: imageRatio,
  1137. },
  1138. );
  1139. } else if (image) {
  1140. return i18next.t(
  1141. '模型: {{ratio}} * {{ratioType}}: {{groupRatio}} * 图片输入倍率: {{imageRatio}}',
  1142. {
  1143. ratio: modelRatio,
  1144. ratioType: ratioLabel,
  1145. groupRatio: groupRatio,
  1146. imageRatio: imageRatio,
  1147. },
  1148. );
  1149. } else if (cacheTokens !== 0) {
  1150. return i18next.t(
  1151. '模型: {{ratio}} * 分组: {{groupRatio}} * 缓存: {{cacheRatio}}',
  1152. {
  1153. ratio: modelRatio,
  1154. groupRatio: groupRatio,
  1155. cacheRatio: cacheRatio,
  1156. },
  1157. );
  1158. } else {
  1159. return i18next.t('模型: {{ratio}} * {{ratioType}}:{{groupRatio}}', {
  1160. ratio: modelRatio,
  1161. ratioType: ratioLabel,
  1162. groupRatio: groupRatio,
  1163. });
  1164. }
  1165. }
  1166. }
  1167. export function renderAudioModelPrice(
  1168. inputTokens,
  1169. completionTokens,
  1170. modelRatio,
  1171. modelPrice = -1,
  1172. completionRatio,
  1173. audioInputTokens,
  1174. audioCompletionTokens,
  1175. audioRatio,
  1176. audioCompletionRatio,
  1177. groupRatio,
  1178. user_group_ratio,
  1179. cacheTokens = 0,
  1180. cacheRatio = 1.0,
  1181. ) {
  1182. const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio);
  1183. groupRatio = effectiveGroupRatio;
  1184. // 1 ratio = $0.002 / 1K tokens
  1185. if (modelPrice !== -1) {
  1186. return i18next.t(
  1187. '模型价格:${{price}} * {{ratioType}}:{{ratio}} = ${{total}}',
  1188. {
  1189. price: modelPrice,
  1190. ratio: groupRatio,
  1191. total: modelPrice * groupRatio,
  1192. ratioType: ratioLabel,
  1193. },
  1194. );
  1195. } else {
  1196. if (completionRatio === undefined) {
  1197. completionRatio = 0;
  1198. }
  1199. // try toFixed audioRatio
  1200. audioRatio = parseFloat(audioRatio).toFixed(6);
  1201. // 这里的 *2 是因为 1倍率=0.002刀,请勿删除
  1202. let inputRatioPrice = modelRatio * 2.0;
  1203. let completionRatioPrice = modelRatio * 2.0 * completionRatio;
  1204. let cacheRatioPrice = modelRatio * 2.0 * cacheRatio;
  1205. // Calculate effective input tokens (non-cached + cached with ratio applied)
  1206. const effectiveInputTokens =
  1207. inputTokens - cacheTokens + cacheTokens * cacheRatio;
  1208. let textPrice =
  1209. (effectiveInputTokens / 1000000) * inputRatioPrice * groupRatio +
  1210. (completionTokens / 1000000) * completionRatioPrice * groupRatio;
  1211. let audioPrice =
  1212. (audioInputTokens / 1000000) * inputRatioPrice * audioRatio * groupRatio +
  1213. (audioCompletionTokens / 1000000) *
  1214. inputRatioPrice *
  1215. audioRatio *
  1216. audioCompletionRatio *
  1217. groupRatio;
  1218. let price = textPrice + audioPrice;
  1219. return (
  1220. <>
  1221. <article>
  1222. <p>
  1223. {i18next.t('提示价格:${{price}} / 1M tokens', {
  1224. price: inputRatioPrice,
  1225. })}
  1226. </p>
  1227. <p>
  1228. {i18next.t(
  1229. '补全价格:${{price}} * {{completionRatio}} = ${{total}} / 1M tokens (补全倍率: {{completionRatio}})',
  1230. {
  1231. price: inputRatioPrice,
  1232. total: completionRatioPrice,
  1233. completionRatio: completionRatio,
  1234. },
  1235. )}
  1236. </p>
  1237. {cacheTokens > 0 && (
  1238. <p>
  1239. {i18next.t(
  1240. '缓存价格:${{price}} * {{cacheRatio}} = ${{total}} / 1M tokens (缓存倍率: {{cacheRatio}})',
  1241. {
  1242. price: inputRatioPrice,
  1243. total: inputRatioPrice * cacheRatio,
  1244. cacheRatio: cacheRatio,
  1245. },
  1246. )}
  1247. </p>
  1248. )}
  1249. <p>
  1250. {i18next.t(
  1251. '音频提示价格:${{price}} * {{audioRatio}} = ${{total}} / 1M tokens (音频倍率: {{audioRatio}})',
  1252. {
  1253. price: inputRatioPrice,
  1254. total: inputRatioPrice * audioRatio,
  1255. audioRatio: audioRatio,
  1256. },
  1257. )}
  1258. </p>
  1259. <p>
  1260. {i18next.t(
  1261. '音频补全价格:${{price}} * {{audioRatio}} * {{audioCompRatio}} = ${{total}} / 1M tokens (音频补全倍率: {{audioCompRatio}})',
  1262. {
  1263. price: inputRatioPrice,
  1264. total: inputRatioPrice * audioRatio * audioCompletionRatio,
  1265. audioRatio: audioRatio,
  1266. audioCompRatio: audioCompletionRatio,
  1267. },
  1268. )}
  1269. </p>
  1270. <p>
  1271. {cacheTokens > 0
  1272. ? i18next.t(
  1273. '文字提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
  1274. {
  1275. nonCacheInput: inputTokens - cacheTokens,
  1276. cacheInput: cacheTokens,
  1277. cachePrice: inputRatioPrice * cacheRatio,
  1278. price: inputRatioPrice,
  1279. completion: completionTokens,
  1280. compPrice: completionRatioPrice,
  1281. total: textPrice.toFixed(6),
  1282. },
  1283. )
  1284. : i18next.t(
  1285. '文字提示 {{input}} tokens / 1M tokens * ${{price}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
  1286. {
  1287. input: inputTokens,
  1288. price: inputRatioPrice,
  1289. completion: completionTokens,
  1290. compPrice: completionRatioPrice,
  1291. total: textPrice.toFixed(6),
  1292. },
  1293. )}
  1294. </p>
  1295. <p>
  1296. {i18next.t(
  1297. '音频提示 {{input}} tokens / 1M tokens * ${{audioInputPrice}} + 音频补全 {{completion}} tokens / 1M tokens * ${{audioCompPrice}} = ${{total}}',
  1298. {
  1299. input: audioInputTokens,
  1300. completion: audioCompletionTokens,
  1301. audioInputPrice: audioRatio * inputRatioPrice,
  1302. audioCompPrice:
  1303. audioRatio * audioCompletionRatio * inputRatioPrice,
  1304. total: audioPrice.toFixed(6),
  1305. },
  1306. )}
  1307. </p>
  1308. <p>
  1309. {i18next.t(
  1310. '总价:文字价格 {{textPrice}} + 音频价格 {{audioPrice}} = ${{total}}',
  1311. {
  1312. total: price.toFixed(6),
  1313. textPrice: textPrice.toFixed(6),
  1314. audioPrice: audioPrice.toFixed(6),
  1315. },
  1316. )}
  1317. </p>
  1318. <p>{i18next.t('仅供参考,以实际扣费为准')}</p>
  1319. </article>
  1320. </>
  1321. );
  1322. }
  1323. }
  1324. export function renderQuotaWithPrompt(quota, digits) {
  1325. let displayInCurrency = localStorage.getItem('display_in_currency');
  1326. displayInCurrency = displayInCurrency === 'true';
  1327. if (displayInCurrency) {
  1328. return (
  1329. i18next.t('等价金额:') + renderQuota(quota, digits)
  1330. );
  1331. }
  1332. return '';
  1333. }
  1334. export function renderClaudeModelPrice(
  1335. inputTokens,
  1336. completionTokens,
  1337. modelRatio,
  1338. modelPrice = -1,
  1339. completionRatio,
  1340. groupRatio,
  1341. user_group_ratio,
  1342. cacheTokens = 0,
  1343. cacheRatio = 1.0,
  1344. cacheCreationTokens = 0,
  1345. cacheCreationRatio = 1.0,
  1346. ) {
  1347. const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio);
  1348. groupRatio = effectiveGroupRatio;
  1349. if (modelPrice !== -1) {
  1350. return i18next.t(
  1351. '模型价格:${{price}} * {{ratioType}}:{{ratio}} = ${{total}}',
  1352. {
  1353. price: modelPrice,
  1354. ratioType: ratioLabel,
  1355. ratio: groupRatio,
  1356. total: modelPrice * groupRatio,
  1357. },
  1358. );
  1359. } else {
  1360. if (completionRatio === undefined) {
  1361. completionRatio = 0;
  1362. }
  1363. const completionRatioValue = completionRatio || 0;
  1364. const inputRatioPrice = modelRatio * 2.0;
  1365. const completionRatioPrice = modelRatio * 2.0 * completionRatioValue;
  1366. let cacheRatioPrice = (modelRatio * 2.0 * cacheRatio).toFixed(2);
  1367. let cacheCreationRatioPrice = modelRatio * 2.0 * cacheCreationRatio;
  1368. // Calculate effective input tokens (non-cached + cached with ratio applied + cache creation with ratio applied)
  1369. const nonCachedTokens = inputTokens;
  1370. const effectiveInputTokens =
  1371. nonCachedTokens +
  1372. cacheTokens * cacheRatio +
  1373. cacheCreationTokens * cacheCreationRatio;
  1374. let price =
  1375. (effectiveInputTokens / 1000000) * inputRatioPrice * groupRatio +
  1376. (completionTokens / 1000000) * completionRatioPrice * groupRatio;
  1377. return (
  1378. <>
  1379. <article>
  1380. <p>
  1381. {i18next.t('提示价格:${{price}} / 1M tokens', {
  1382. price: inputRatioPrice,
  1383. })}
  1384. </p>
  1385. <p>
  1386. {i18next.t(
  1387. '补全价格:${{price}} * {{ratio}} = ${{total}} / 1M tokens',
  1388. {
  1389. price: inputRatioPrice,
  1390. ratio: completionRatio,
  1391. total: completionRatioPrice,
  1392. },
  1393. )}
  1394. </p>
  1395. {cacheTokens > 0 && (
  1396. <p>
  1397. {i18next.t(
  1398. '缓存价格:${{price}} * {{ratio}} = ${{total}} / 1M tokens (缓存倍率: {{cacheRatio}})',
  1399. {
  1400. price: inputRatioPrice,
  1401. ratio: cacheRatio,
  1402. total: cacheRatioPrice,
  1403. cacheRatio: cacheRatio,
  1404. },
  1405. )}
  1406. </p>
  1407. )}
  1408. {cacheCreationTokens > 0 && (
  1409. <p>
  1410. {i18next.t(
  1411. '缓存创建价格:${{price}} * {{ratio}} = ${{total}} / 1M tokens (缓存创建倍率: {{cacheCreationRatio}})',
  1412. {
  1413. price: inputRatioPrice,
  1414. ratio: cacheCreationRatio,
  1415. total: cacheCreationRatioPrice,
  1416. cacheCreationRatio: cacheCreationRatio,
  1417. },
  1418. )}
  1419. </p>
  1420. )}
  1421. <p></p>
  1422. <p>
  1423. {cacheTokens > 0 || cacheCreationTokens > 0
  1424. ? i18next.t(
  1425. '提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * ${{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}',
  1426. {
  1427. nonCacheInput: nonCachedTokens,
  1428. cacheInput: cacheTokens,
  1429. cacheRatio: cacheRatio,
  1430. cacheCreationInput: cacheCreationTokens,
  1431. cacheCreationRatio: cacheCreationRatio,
  1432. cachePrice: cacheRatioPrice,
  1433. cacheCreationPrice: cacheCreationRatioPrice,
  1434. price: inputRatioPrice,
  1435. completion: completionTokens,
  1436. compPrice: completionRatioPrice,
  1437. ratio: groupRatio,
  1438. ratioType: ratioLabel,
  1439. total: price.toFixed(6),
  1440. },
  1441. )
  1442. : i18next.t(
  1443. '提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}',
  1444. {
  1445. input: inputTokens,
  1446. price: inputRatioPrice,
  1447. completion: completionTokens,
  1448. compPrice: completionRatioPrice,
  1449. ratio: groupRatio,
  1450. ratioType: ratioLabel,
  1451. total: price.toFixed(6),
  1452. },
  1453. )}
  1454. </p>
  1455. <p>{i18next.t('仅供参考,以实际扣费为准')}</p>
  1456. </article>
  1457. </>
  1458. );
  1459. }
  1460. }
  1461. export function renderClaudeLogContent(
  1462. modelRatio,
  1463. completionRatio,
  1464. modelPrice = -1,
  1465. groupRatio,
  1466. user_group_ratio,
  1467. cacheRatio = 1.0,
  1468. cacheCreationRatio = 1.0,
  1469. ) {
  1470. const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio);
  1471. groupRatio = effectiveGroupRatio;
  1472. if (modelPrice !== -1) {
  1473. return i18next.t('模型价格 ${{price}},{{ratioType}} {{ratio}}', {
  1474. price: modelPrice,
  1475. ratioType: ratioLabel,
  1476. ratio: groupRatio,
  1477. });
  1478. } else {
  1479. return i18next.t(
  1480. '模型倍率 {{modelRatio}},输出倍率 {{completionRatio}},缓存倍率 {{cacheRatio}},缓存创建倍率 {{cacheCreationRatio}},{{ratioType}} {{ratio}}',
  1481. {
  1482. modelRatio: modelRatio,
  1483. completionRatio: completionRatio,
  1484. cacheRatio: cacheRatio,
  1485. cacheCreationRatio: cacheCreationRatio,
  1486. ratioType: ratioLabel,
  1487. ratio: groupRatio,
  1488. },
  1489. );
  1490. }
  1491. }
  1492. export function renderClaudeModelPriceSimple(
  1493. modelRatio,
  1494. modelPrice = -1,
  1495. groupRatio,
  1496. user_group_ratio,
  1497. cacheTokens = 0,
  1498. cacheRatio = 1.0,
  1499. cacheCreationTokens = 0,
  1500. cacheCreationRatio = 1.0,
  1501. ) {
  1502. const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio);
  1503. groupRatio = effectiveGroupRatio;
  1504. if (modelPrice !== -1) {
  1505. return i18next.t('价格:${{price}} * {{ratioType}}:{{ratio}}', {
  1506. price: modelPrice,
  1507. ratioType: ratioLabel,
  1508. ratio: groupRatio,
  1509. });
  1510. } else {
  1511. if (cacheTokens !== 0 || cacheCreationTokens !== 0) {
  1512. return i18next.t(
  1513. '模型: {{ratio}} * {{ratioType}}: {{groupRatio}} * 缓存: {{cacheRatio}}',
  1514. {
  1515. ratio: modelRatio,
  1516. ratioType: ratioLabel,
  1517. groupRatio: groupRatio,
  1518. cacheRatio: cacheRatio,
  1519. cacheCreationRatio: cacheCreationRatio,
  1520. },
  1521. );
  1522. } else {
  1523. return i18next.t('模型: {{ratio}} * {{ratioType}}: {{groupRatio}}', {
  1524. ratio: modelRatio,
  1525. ratioType: ratioLabel,
  1526. groupRatio: groupRatio,
  1527. });
  1528. }
  1529. }
  1530. }
  1531. /**
  1532. * rehype 插件:将段落等文本节点拆分为逐词 <span>,并添加淡入动画 class。
  1533. * 仅在流式渲染阶段使用,避免已渲染文字重复动画。
  1534. */
  1535. export function rehypeSplitWordsIntoSpans(options = {}) {
  1536. const { previousContentLength = 0 } = options;
  1537. return (tree) => {
  1538. let currentCharCount = 0; // 当前已处理的字符数
  1539. visit(tree, 'element', (node) => {
  1540. if (
  1541. ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'strong'].includes(
  1542. node.tagName,
  1543. ) &&
  1544. node.children
  1545. ) {
  1546. const newChildren = [];
  1547. node.children.forEach((child) => {
  1548. if (child.type === 'text') {
  1549. try {
  1550. // 使用 Intl.Segmenter 精准拆分中英文及标点
  1551. const segmenter = new Intl.Segmenter('zh', {
  1552. granularity: 'word',
  1553. });
  1554. const segments = segmenter.segment(child.value);
  1555. Array.from(segments)
  1556. .map((seg) => seg.segment)
  1557. .filter(Boolean)
  1558. .forEach((word) => {
  1559. const wordStartPos = currentCharCount;
  1560. const wordEndPos = currentCharCount + word.length;
  1561. // 判断这个词是否是新增的(在 previousContentLength 之后)
  1562. const isNewContent = wordStartPos >= previousContentLength;
  1563. newChildren.push({
  1564. type: 'element',
  1565. tagName: 'span',
  1566. properties: {
  1567. className: isNewContent ? ['animate-fade-in'] : [],
  1568. },
  1569. children: [{ type: 'text', value: word }],
  1570. });
  1571. currentCharCount = wordEndPos;
  1572. });
  1573. } catch (_) {
  1574. // Fallback:如果浏览器不支持 Segmenter
  1575. const textStartPos = currentCharCount;
  1576. const isNewContent = textStartPos >= previousContentLength;
  1577. if (isNewContent) {
  1578. // 新内容,添加动画
  1579. newChildren.push({
  1580. type: 'element',
  1581. tagName: 'span',
  1582. properties: {
  1583. className: ['animate-fade-in'],
  1584. },
  1585. children: [{ type: 'text', value: child.value }],
  1586. });
  1587. } else {
  1588. // 旧内容,不添加动画
  1589. newChildren.push(child);
  1590. }
  1591. currentCharCount += child.value.length;
  1592. }
  1593. } else {
  1594. newChildren.push(child);
  1595. }
  1596. });
  1597. node.children = newChildren;
  1598. }
  1599. });
  1600. };
  1601. }