mirror of
				https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
				synced 2025-11-04 16:23:41 +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",
 | 
						|
  });
 | 
						|
}
 |