render.js 42 KB

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