render.js 51 KB

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