misc.ts 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. // @ts-expect-error this package does not have typing
  2. import TextLineStream from 'textlinestream';
  3. import {
  4. APIMessage,
  5. APIMessageContentPart,
  6. LlamaCppServerProps,
  7. Message,
  8. } from './types';
  9. // ponyfill for missing ReadableStream asyncIterator on Safari
  10. import { asyncIterator } from '@sec-ant/readable-stream/ponyfill/asyncIterator';
  11. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  12. export const isString = (x: any) => !!x.toLowerCase;
  13. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  14. export const isBoolean = (x: any) => x === true || x === false;
  15. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  16. export const isNumeric = (n: any) => !isString(n) && !isNaN(n) && !isBoolean(n);
  17. export const escapeAttr = (str: string) =>
  18. str.replace(/>/g, '>').replace(/"/g, '"');
  19. // wrapper for SSE
  20. export async function* getSSEStreamAsync(fetchResponse: Response) {
  21. if (!fetchResponse.body) throw new Error('Response body is empty');
  22. const lines: ReadableStream<string> = fetchResponse.body
  23. .pipeThrough(new TextDecoderStream())
  24. .pipeThrough(new TextLineStream());
  25. // @ts-expect-error asyncIterator complains about type, but it should work
  26. for await (const line of asyncIterator(lines)) {
  27. //if (isDev) console.log({ line });
  28. if (line.startsWith('data:') && !line.endsWith('[DONE]')) {
  29. const data = JSON.parse(line.slice(5));
  30. yield data;
  31. } else if (line.startsWith('error:')) {
  32. const data = JSON.parse(line.slice(6));
  33. throw new Error(data.message || 'Unknown error');
  34. }
  35. }
  36. }
  37. // copy text to clipboard
  38. export const copyStr = (textToCopy: string) => {
  39. // Navigator clipboard api needs a secure context (https)
  40. if (navigator.clipboard && window.isSecureContext) {
  41. navigator.clipboard.writeText(textToCopy);
  42. } else {
  43. // Use the 'out of viewport hidden text area' trick
  44. const textArea = document.createElement('textarea');
  45. textArea.value = textToCopy;
  46. // Move textarea out of the viewport so it's not visible
  47. textArea.style.position = 'absolute';
  48. textArea.style.left = '-999999px';
  49. document.body.prepend(textArea);
  50. textArea.select();
  51. document.execCommand('copy');
  52. }
  53. };
  54. /**
  55. * filter out redundant fields upon sending to API
  56. * also format extra into text
  57. */
  58. export function normalizeMsgsForAPI(messages: Readonly<Message[]>) {
  59. return messages.map((msg) => {
  60. if (msg.role !== 'user' || !msg.extra) {
  61. return {
  62. role: msg.role,
  63. content: msg.content,
  64. } as APIMessage;
  65. }
  66. // extra content first, then user text message in the end
  67. // this allow re-using the same cache prefix for long context
  68. const contentArr: APIMessageContentPart[] = [];
  69. for (const extra of msg.extra ?? []) {
  70. if (extra.type === 'context') {
  71. contentArr.push({
  72. type: 'text',
  73. text: extra.content,
  74. });
  75. } else if (extra.type === 'textFile') {
  76. contentArr.push({
  77. type: 'text',
  78. text: `File: ${extra.name}\nContent:\n\n${extra.content}`,
  79. });
  80. } else if (extra.type === 'imageFile') {
  81. contentArr.push({
  82. type: 'image_url',
  83. image_url: { url: extra.base64Url },
  84. });
  85. } else if (extra.type === 'audioFile') {
  86. contentArr.push({
  87. type: 'input_audio',
  88. input_audio: {
  89. data: extra.base64Data,
  90. format: /wav/.test(extra.mimeType) ? 'wav' : 'mp3',
  91. },
  92. });
  93. } else {
  94. throw new Error('Unknown extra type');
  95. }
  96. }
  97. // add user message to the end
  98. contentArr.push({
  99. type: 'text',
  100. text: msg.content,
  101. });
  102. return {
  103. role: msg.role,
  104. content: contentArr,
  105. };
  106. }) as APIMessage[];
  107. }
  108. /**
  109. * recommended for DeepsSeek-R1, filter out content between <think> and </think> tags
  110. */
  111. export function filterThoughtFromMsgs(messages: APIMessage[]) {
  112. console.debug({ messages });
  113. return messages.map((msg) => {
  114. if (msg.role !== 'assistant') {
  115. return msg;
  116. }
  117. // assistant message is always a string
  118. const contentStr = msg.content as string;
  119. return {
  120. role: msg.role,
  121. content:
  122. msg.role === 'assistant'
  123. ? contentStr
  124. .split(/<\/think>|<\|end\|>/)
  125. .at(-1)!
  126. .trim()
  127. : contentStr,
  128. } as APIMessage;
  129. });
  130. }
  131. export function classNames(classes: Record<string, boolean>): string {
  132. return Object.entries(classes)
  133. .filter(([_, value]) => value)
  134. .map(([key, _]) => key)
  135. .join(' ');
  136. }
  137. export const delay = (ms: number) =>
  138. new Promise((resolve) => setTimeout(resolve, ms));
  139. export const throttle = <T extends unknown[]>(
  140. callback: (...args: T) => void,
  141. delay: number
  142. ) => {
  143. let isWaiting = false;
  144. return (...args: T) => {
  145. if (isWaiting) {
  146. return;
  147. }
  148. callback(...args);
  149. isWaiting = true;
  150. setTimeout(() => {
  151. isWaiting = false;
  152. }, delay);
  153. };
  154. };
  155. export const cleanCurrentUrl = (removeQueryParams: string[]) => {
  156. const url = new URL(window.location.href);
  157. removeQueryParams.forEach((param) => {
  158. url.searchParams.delete(param);
  159. });
  160. window.history.replaceState({}, '', url.toString());
  161. };
  162. export const getServerProps = async (
  163. baseUrl: string,
  164. apiKey?: string
  165. ): Promise<LlamaCppServerProps> => {
  166. try {
  167. const response = await fetch(`${baseUrl}/props`, {
  168. headers: {
  169. 'Content-Type': 'application/json',
  170. ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
  171. },
  172. });
  173. if (!response.ok) {
  174. throw new Error('Failed to fetch server props');
  175. }
  176. const data = await response.json();
  177. return data as LlamaCppServerProps;
  178. } catch (error) {
  179. console.error('Error fetching server props:', error);
  180. throw error;
  181. }
  182. };