mirror of
				https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
				synced 2025-10-28 12:53:45 +08:00 
			
		
		
		
	
		
			
				
	
	
		
			482 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			482 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import { useEffect, useState } from "react";
 | |
| import { showToast } from "./components/ui-lib";
 | |
| import Locale from "./locales";
 | |
| import { RequestMessage } from "./client/api";
 | |
| import {
 | |
|   REQUEST_TIMEOUT_MS,
 | |
|   REQUEST_TIMEOUT_MS_FOR_THINKING,
 | |
|   ServiceProvider,
 | |
| } from "./constant";
 | |
| // import { fetch as tauriFetch, ResponseType } from "@tauri-apps/api/http";
 | |
| import { fetch as tauriStreamFetch } from "./utils/stream";
 | |
| import { VISION_MODEL_REGEXES, EXCLUDE_VISION_MODEL_REGEXES } from "./constant";
 | |
| import { useAccessStore } from "./store";
 | |
| import { ModelSize } from "./typing";
 | |
| 
 | |
| export function trimTopic(topic: string) {
 | |
|   // Fix an issue where double quotes still show in the Indonesian language
 | |
|   // This will remove the specified punctuation from the end of the string
 | |
|   // and also trim quotes from both the start and end if they exist.
 | |
|   return (
 | |
|     topic
 | |
|       // fix for gemini
 | |
|       .replace(/^["“”*]+|["“”*]+$/g, "")
 | |
|       .replace(/[,。!?”“"、,.!?*]*$/, "")
 | |
|   );
 | |
| }
 | |
| 
 | |
| export async function copyToClipboard(text: string) {
 | |
|   try {
 | |
|     if (window.__TAURI__) {
 | |
|       window.__TAURI__.writeText(text);
 | |
|     } else {
 | |
|       await navigator.clipboard.writeText(text);
 | |
|     }
 | |
| 
 | |
|     showToast(Locale.Copy.Success);
 | |
|   } catch (error) {
 | |
|     const textArea = document.createElement("textarea");
 | |
|     textArea.value = text;
 | |
|     document.body.appendChild(textArea);
 | |
|     textArea.focus();
 | |
|     textArea.select();
 | |
|     try {
 | |
|       document.execCommand("copy");
 | |
|       showToast(Locale.Copy.Success);
 | |
|     } catch (error) {
 | |
|       showToast(Locale.Copy.Failed);
 | |
|     }
 | |
|     document.body.removeChild(textArea);
 | |
|   }
 | |
| }
 | |
| 
 | |
| export async function downloadAs(text: string, filename: string) {
 | |
|   if (window.__TAURI__) {
 | |
|     const result = await window.__TAURI__.dialog.save({
 | |
|       defaultPath: `${filename}`,
 | |
|       filters: [
 | |
|         {
 | |
|           name: `${filename.split(".").pop()} files`,
 | |
|           extensions: [`${filename.split(".").pop()}`],
 | |
|         },
 | |
|         {
 | |
|           name: "All Files",
 | |
|           extensions: ["*"],
 | |
|         },
 | |
|       ],
 | |
|     });
 | |
| 
 | |
|     if (result !== null) {
 | |
|       try {
 | |
|         await window.__TAURI__.fs.writeTextFile(result, text);
 | |
|         showToast(Locale.Download.Success);
 | |
|       } catch (error) {
 | |
|         showToast(Locale.Download.Failed);
 | |
|       }
 | |
|     } else {
 | |
|       showToast(Locale.Download.Failed);
 | |
|     }
 | |
|   } else {
 | |
|     const element = document.createElement("a");
 | |
|     element.setAttribute(
 | |
|       "href",
 | |
|       "data:text/plain;charset=utf-8," + encodeURIComponent(text),
 | |
|     );
 | |
|     element.setAttribute("download", filename);
 | |
| 
 | |
|     element.style.display = "none";
 | |
|     document.body.appendChild(element);
 | |
| 
 | |
|     element.click();
 | |
| 
 | |
|     document.body.removeChild(element);
 | |
|   }
 | |
| }
 | |
| 
 | |
| export function readFromFile() {
 | |
|   return new Promise<string>((res, rej) => {
 | |
|     const fileInput = document.createElement("input");
 | |
|     fileInput.type = "file";
 | |
|     fileInput.accept = "application/json";
 | |
| 
 | |
|     fileInput.onchange = (event: any) => {
 | |
|       const file = event.target.files[0];
 | |
|       const fileReader = new FileReader();
 | |
|       fileReader.onload = (e: any) => {
 | |
|         res(e.target.result);
 | |
|       };
 | |
|       fileReader.onerror = (e) => rej(e);
 | |
|       fileReader.readAsText(file);
 | |
|     };
 | |
| 
 | |
|     fileInput.click();
 | |
|   });
 | |
| }
 | |
| 
 | |
| export function isIOS() {
 | |
|   const userAgent = navigator.userAgent.toLowerCase();
 | |
|   return /iphone|ipad|ipod/.test(userAgent);
 | |
| }
 | |
| 
 | |
| export function useWindowSize() {
 | |
|   const [size, setSize] = useState({
 | |
|     width: window.innerWidth,
 | |
|     height: window.innerHeight,
 | |
|   });
 | |
| 
 | |
|   useEffect(() => {
 | |
|     const onResize = () => {
 | |
|       setSize({
 | |
|         width: window.innerWidth,
 | |
|         height: window.innerHeight,
 | |
|       });
 | |
|     };
 | |
| 
 | |
|     window.addEventListener("resize", onResize);
 | |
| 
 | |
|     return () => {
 | |
|       window.removeEventListener("resize", onResize);
 | |
|     };
 | |
|   }, []);
 | |
| 
 | |
|   return size;
 | |
| }
 | |
| 
 | |
| export const MOBILE_MAX_WIDTH = 600;
 | |
| export function useMobileScreen() {
 | |
|   const { width } = useWindowSize();
 | |
| 
 | |
|   return width <= MOBILE_MAX_WIDTH;
 | |
| }
 | |
| 
 | |
| export function isFirefox() {
 | |
|   return (
 | |
|     typeof navigator !== "undefined" && /firefox/i.test(navigator.userAgent)
 | |
|   );
 | |
| }
 | |
| 
 | |
| export function selectOrCopy(el: HTMLElement, content: string) {
 | |
|   const currentSelection = window.getSelection();
 | |
| 
 | |
|   if (currentSelection?.type === "Range") {
 | |
|     return false;
 | |
|   }
 | |
| 
 | |
|   copyToClipboard(content);
 | |
| 
 | |
|   return true;
 | |
| }
 | |
| 
 | |
| function getDomContentWidth(dom: HTMLElement) {
 | |
|   const style = window.getComputedStyle(dom);
 | |
|   const paddingWidth =
 | |
|     parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
 | |
|   const width = dom.clientWidth - paddingWidth;
 | |
|   return width;
 | |
| }
 | |
| 
 | |
| function getOrCreateMeasureDom(id: string, init?: (dom: HTMLElement) => void) {
 | |
|   let dom = document.getElementById(id);
 | |
| 
 | |
|   if (!dom) {
 | |
|     dom = document.createElement("span");
 | |
|     dom.style.position = "absolute";
 | |
|     dom.style.wordBreak = "break-word";
 | |
|     dom.style.fontSize = "14px";
 | |
|     dom.style.transform = "translateY(-200vh)";
 | |
|     dom.style.pointerEvents = "none";
 | |
|     dom.style.opacity = "0";
 | |
|     dom.id = id;
 | |
|     document.body.appendChild(dom);
 | |
|     init?.(dom);
 | |
|   }
 | |
| 
 | |
|   return dom!;
 | |
| }
 | |
| 
 | |
| export function autoGrowTextArea(dom: HTMLTextAreaElement) {
 | |
|   const measureDom = getOrCreateMeasureDom("__measure");
 | |
|   const singleLineDom = getOrCreateMeasureDom("__single_measure", (dom) => {
 | |
|     dom.innerText = "TEXT_FOR_MEASURE";
 | |
|   });
 | |
| 
 | |
|   const width = getDomContentWidth(dom);
 | |
|   measureDom.style.width = width + "px";
 | |
|   measureDom.innerText = dom.value !== "" ? dom.value : "1";
 | |
|   measureDom.style.fontSize = dom.style.fontSize;
 | |
|   measureDom.style.fontFamily = dom.style.fontFamily;
 | |
|   const endWithEmptyLine = dom.value.endsWith("\n");
 | |
|   const height = parseFloat(window.getComputedStyle(measureDom).height);
 | |
|   const singleLineHeight = parseFloat(
 | |
|     window.getComputedStyle(singleLineDom).height,
 | |
|   );
 | |
| 
 | |
|   const rows =
 | |
|     Math.round(height / singleLineHeight) + (endWithEmptyLine ? 1 : 0);
 | |
| 
 | |
|   return rows;
 | |
| }
 | |
| 
 | |
| export function getCSSVar(varName: string) {
 | |
|   return getComputedStyle(document.body).getPropertyValue(varName).trim();
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Detects Macintosh
 | |
|  */
 | |
| export function isMacOS(): boolean {
 | |
|   if (typeof window !== "undefined") {
 | |
|     let userAgent = window.navigator.userAgent.toLocaleLowerCase();
 | |
|     const macintosh = /iphone|ipad|ipod|macintosh/.test(userAgent);
 | |
|     return !!macintosh;
 | |
|   }
 | |
|   return false;
 | |
| }
 | |
| 
 | |
| export function getMessageTextContent(message: RequestMessage) {
 | |
|   if (typeof message.content === "string") {
 | |
|     return message.content;
 | |
|   }
 | |
|   for (const c of message.content) {
 | |
|     if (c.type === "text") {
 | |
|       return c.text ?? "";
 | |
|     }
 | |
|   }
 | |
|   return "";
 | |
| }
 | |
| 
 | |
| export function getMessageTextContentWithoutThinking(message: RequestMessage) {
 | |
|   let content = "";
 | |
| 
 | |
|   if (typeof message.content === "string") {
 | |
|     content = message.content;
 | |
|   } else {
 | |
|     for (const c of message.content) {
 | |
|       if (c.type === "text") {
 | |
|         content = c.text ?? "";
 | |
|         break;
 | |
|       }
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   // Filter out thinking lines (starting with "> ")
 | |
|   return content
 | |
|     .split("\n")
 | |
|     .filter((line) => !line.startsWith("> ") && line.trim() !== "")
 | |
|     .join("\n")
 | |
|     .trim();
 | |
| }
 | |
| 
 | |
| export function getMessageImages(message: RequestMessage): string[] {
 | |
|   if (typeof message.content === "string") {
 | |
|     return [];
 | |
|   }
 | |
|   const urls: string[] = [];
 | |
|   for (const c of message.content) {
 | |
|     if (c.type === "image_url") {
 | |
|       urls.push(c.image_url?.url ?? "");
 | |
|     }
 | |
|   }
 | |
|   return urls;
 | |
| }
 | |
| 
 | |
| export function isVisionModel(model: string) {
 | |
|   const visionModels = useAccessStore.getState().visionModels;
 | |
|   const envVisionModels = visionModels?.split(",").map((m) => m.trim());
 | |
|   if (envVisionModels?.includes(model)) {
 | |
|     return true;
 | |
|   }
 | |
|   return (
 | |
|     !EXCLUDE_VISION_MODEL_REGEXES.some((regex) => regex.test(model)) &&
 | |
|     VISION_MODEL_REGEXES.some((regex) => regex.test(model))
 | |
|   );
 | |
| }
 | |
| 
 | |
| export function isDalle3(model: string) {
 | |
|   return "dall-e-3" === model;
 | |
| }
 | |
| 
 | |
| export function getTimeoutMSByModel(model: string) {
 | |
|   model = model.toLowerCase();
 | |
|   if (
 | |
|     model.startsWith("dall-e") ||
 | |
|     model.startsWith("dalle") ||
 | |
|     model.startsWith("o1") ||
 | |
|     model.startsWith("o3") ||
 | |
|     model.includes("deepseek-r") ||
 | |
|     model.includes("-thinking")
 | |
|   )
 | |
|     return REQUEST_TIMEOUT_MS_FOR_THINKING;
 | |
|   return REQUEST_TIMEOUT_MS;
 | |
| }
 | |
| 
 | |
| export function getModelSizes(model: string): ModelSize[] {
 | |
|   if (isDalle3(model)) {
 | |
|     return ["1024x1024", "1792x1024", "1024x1792"];
 | |
|   }
 | |
|   if (model.toLowerCase().includes("cogview")) {
 | |
|     return [
 | |
|       "1024x1024",
 | |
|       "768x1344",
 | |
|       "864x1152",
 | |
|       "1344x768",
 | |
|       "1152x864",
 | |
|       "1440x720",
 | |
|       "720x1440",
 | |
|     ];
 | |
|   }
 | |
|   return [];
 | |
| }
 | |
| 
 | |
| export function supportsCustomSize(model: string): boolean {
 | |
|   return getModelSizes(model).length > 0;
 | |
| }
 | |
| 
 | |
| export function showPlugins(provider: ServiceProvider, model: string) {
 | |
|   if (
 | |
|     provider == ServiceProvider.OpenAI ||
 | |
|     provider == ServiceProvider.Azure ||
 | |
|     provider == ServiceProvider.Moonshot ||
 | |
|     provider == ServiceProvider.ChatGLM
 | |
|   ) {
 | |
|     return true;
 | |
|   }
 | |
|   if (provider == ServiceProvider.Anthropic && !model.includes("claude-2")) {
 | |
|     return true;
 | |
|   }
 | |
|   if (provider == ServiceProvider.Google && !model.includes("vision")) {
 | |
|     return true;
 | |
|   }
 | |
|   return false;
 | |
| }
 | |
| 
 | |
| export function fetch(
 | |
|   url: string,
 | |
|   options?: Record<string, unknown>,
 | |
| ): Promise<any> {
 | |
|   if (window.__TAURI__) {
 | |
|     return tauriStreamFetch(url, options);
 | |
|   }
 | |
|   return window.fetch(url, options);
 | |
| }
 | |
| 
 | |
| export function adapter(config: Record<string, unknown>) {
 | |
|   const { baseURL, url, params, data: body, ...rest } = config;
 | |
|   const path = baseURL ? `${baseURL}${url}` : url;
 | |
|   const fetchUrl = params
 | |
|     ? `${path}?${new URLSearchParams(params as any).toString()}`
 | |
|     : path;
 | |
|   return fetch(fetchUrl as string, { ...rest, body }).then((res) => {
 | |
|     const { status, headers, statusText } = res;
 | |
|     return res
 | |
|       .text()
 | |
|       .then((data: string) => ({ status, statusText, headers, data }));
 | |
|   });
 | |
| }
 | |
| 
 | |
| export function safeLocalStorage(): {
 | |
|   getItem: (key: string) => string | null;
 | |
|   setItem: (key: string, value: string) => void;
 | |
|   removeItem: (key: string) => void;
 | |
|   clear: () => void;
 | |
| } {
 | |
|   let storage: Storage | null;
 | |
| 
 | |
|   try {
 | |
|     if (typeof window !== "undefined" && window.localStorage) {
 | |
|       storage = window.localStorage;
 | |
|     } else {
 | |
|       storage = null;
 | |
|     }
 | |
|   } catch (e) {
 | |
|     console.error("localStorage is not available:", e);
 | |
|     storage = null;
 | |
|   }
 | |
| 
 | |
|   return {
 | |
|     getItem(key: string): string | null {
 | |
|       if (storage) {
 | |
|         return storage.getItem(key);
 | |
|       } else {
 | |
|         console.warn(
 | |
|           `Attempted to get item "${key}" from localStorage, but localStorage is not available.`,
 | |
|         );
 | |
|         return null;
 | |
|       }
 | |
|     },
 | |
|     setItem(key: string, value: string): void {
 | |
|       if (storage) {
 | |
|         storage.setItem(key, value);
 | |
|       } else {
 | |
|         console.warn(
 | |
|           `Attempted to set item "${key}" in localStorage, but localStorage is not available.`,
 | |
|         );
 | |
|       }
 | |
|     },
 | |
|     removeItem(key: string): void {
 | |
|       if (storage) {
 | |
|         storage.removeItem(key);
 | |
|       } else {
 | |
|         console.warn(
 | |
|           `Attempted to remove item "${key}" from localStorage, but localStorage is not available.`,
 | |
|         );
 | |
|       }
 | |
|     },
 | |
|     clear(): void {
 | |
|       if (storage) {
 | |
|         storage.clear();
 | |
|       } else {
 | |
|         console.warn(
 | |
|           "Attempted to clear localStorage, but localStorage is not available.",
 | |
|         );
 | |
|       }
 | |
|     },
 | |
|   };
 | |
| }
 | |
| 
 | |
| export function getOperationId(operation: {
 | |
|   operationId?: string;
 | |
|   method: string;
 | |
|   path: string;
 | |
| }) {
 | |
|   // pattern '^[a-zA-Z0-9_-]+$'
 | |
|   return (
 | |
|     operation?.operationId ||
 | |
|     `${operation.method.toUpperCase()}${operation.path.replaceAll("/", "_")}`
 | |
|   );
 | |
| }
 | |
| 
 | |
| export function clientUpdate() {
 | |
|   // this a wild for updating client app
 | |
|   return window.__TAURI__?.updater
 | |
|     .checkUpdate()
 | |
|     .then((updateResult) => {
 | |
|       if (updateResult.shouldUpdate) {
 | |
|         window.__TAURI__?.updater
 | |
|           .installUpdate()
 | |
|           .then((result) => {
 | |
|             showToast(Locale.Settings.Update.Success);
 | |
|           })
 | |
|           .catch((e) => {
 | |
|             console.error("[Install Update Error]", e);
 | |
|             showToast(Locale.Settings.Update.Failed);
 | |
|           });
 | |
|       }
 | |
|     })
 | |
|     .catch((e) => {
 | |
|       console.error("[Check Update Error]", e);
 | |
|       showToast(Locale.Settings.Update.Failed);
 | |
|     });
 | |
| }
 | |
| 
 | |
| // https://gist.github.com/iwill/a83038623ba4fef6abb9efca87ae9ccb
 | |
| export function semverCompare(a: string, b: string) {
 | |
|   if (a.startsWith(b + "-")) return -1;
 | |
|   if (b.startsWith(a + "-")) return 1;
 | |
|   return a.localeCompare(b, undefined, {
 | |
|     numeric: true,
 | |
|     sensitivity: "case",
 | |
|     caseFirst: "upper",
 | |
|   });
 | |
| }
 |