mirror of
				https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
				synced 2025-11-04 16:23:41 +08:00 
			
		
		
		
	Merge remote-tracking branch 'origin/main' into feature/aarch64
This commit is contained in:
		
							
								
								
									
										4
									
								
								.github/workflows/deploy_preview.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/deploy_preview.yml
									
									
									
									
										vendored
									
									
								
							@@ -3,9 +3,7 @@ name: VercelPreviewDeployment
 | 
				
			|||||||
on:
 | 
					on:
 | 
				
			||||||
  pull_request_target:
 | 
					  pull_request_target:
 | 
				
			||||||
    types:
 | 
					    types:
 | 
				
			||||||
      - opened
 | 
					      - review_requested
 | 
				
			||||||
      - synchronize
 | 
					 | 
				
			||||||
      - reopened
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
env:
 | 
					env:
 | 
				
			||||||
  VERCEL_TEAM: ${{ secrets.VERCEL_TEAM }}
 | 
					  VERCEL_TEAM: ${{ secrets.VERCEL_TEAM }}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										39
									
								
								.github/workflows/test.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								.github/workflows/test.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,39 @@
 | 
				
			|||||||
 | 
					name: Run Tests
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					on:
 | 
				
			||||||
 | 
					  push:
 | 
				
			||||||
 | 
					    branches:
 | 
				
			||||||
 | 
					      - main
 | 
				
			||||||
 | 
					    tags:
 | 
				
			||||||
 | 
					      - "!*"
 | 
				
			||||||
 | 
					  pull_request:
 | 
				
			||||||
 | 
					    types:
 | 
				
			||||||
 | 
					      - review_requested
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					jobs:
 | 
				
			||||||
 | 
					  test:
 | 
				
			||||||
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    steps:
 | 
				
			||||||
 | 
					      - name: Checkout repository
 | 
				
			||||||
 | 
					        uses: actions/checkout@v4
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Set up Node.js
 | 
				
			||||||
 | 
					        uses: actions/setup-node@v3
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          node-version: 18
 | 
				
			||||||
 | 
					          cache: "yarn"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Cache node_modules
 | 
				
			||||||
 | 
					        uses: actions/cache@v4
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          path: node_modules
 | 
				
			||||||
 | 
					          key: ${{ runner.os }}-node_modules-${{ hashFiles('**/yarn.lock') }}
 | 
				
			||||||
 | 
					          restore-keys: |
 | 
				
			||||||
 | 
					            ${{ runner.os }}-node_modules-
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Install dependencies
 | 
				
			||||||
 | 
					        run: yarn install
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Run Jest tests
 | 
				
			||||||
 | 
					        run: yarn test:ci
 | 
				
			||||||
@@ -7,21 +7,25 @@ import {
 | 
				
			|||||||
  LLMUsage,
 | 
					  LLMUsage,
 | 
				
			||||||
  SpeechOptions,
 | 
					  SpeechOptions,
 | 
				
			||||||
} from "../api";
 | 
					} from "../api";
 | 
				
			||||||
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
 | 
					import {
 | 
				
			||||||
 | 
					  useAccessStore,
 | 
				
			||||||
 | 
					  useAppConfig,
 | 
				
			||||||
 | 
					  useChatStore,
 | 
				
			||||||
 | 
					  usePluginStore,
 | 
				
			||||||
 | 
					  ChatMessageTool,
 | 
				
			||||||
 | 
					} from "@/app/store";
 | 
				
			||||||
 | 
					import { stream } from "@/app/utils/chat";
 | 
				
			||||||
import { getClientConfig } from "@/app/config/client";
 | 
					import { getClientConfig } from "@/app/config/client";
 | 
				
			||||||
import { GEMINI_BASE_URL } from "@/app/constant";
 | 
					import { GEMINI_BASE_URL } from "@/app/constant";
 | 
				
			||||||
import Locale from "../../locales";
 | 
					
 | 
				
			||||||
import {
 | 
					 | 
				
			||||||
  EventStreamContentType,
 | 
					 | 
				
			||||||
  fetchEventSource,
 | 
					 | 
				
			||||||
} from "@fortaine/fetch-event-source";
 | 
					 | 
				
			||||||
import { prettyObject } from "@/app/utils/format";
 | 
					 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  getMessageTextContent,
 | 
					  getMessageTextContent,
 | 
				
			||||||
  getMessageImages,
 | 
					  getMessageImages,
 | 
				
			||||||
  isVisionModel,
 | 
					  isVisionModel,
 | 
				
			||||||
} from "@/app/utils";
 | 
					} from "@/app/utils";
 | 
				
			||||||
import { preProcessImageContent } from "@/app/utils/chat";
 | 
					import { preProcessImageContent } from "@/app/utils/chat";
 | 
				
			||||||
 | 
					import { nanoid } from "nanoid";
 | 
				
			||||||
 | 
					import { RequestPayload } from "./openai";
 | 
				
			||||||
import { fetch } from "@/app/utils/stream";
 | 
					import { fetch } from "@/app/utils/stream";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export class GeminiProApi implements LLMApi {
 | 
					export class GeminiProApi implements LLMApi {
 | 
				
			||||||
@@ -178,115 +182,81 @@ export class GeminiProApi implements LLMApi {
 | 
				
			|||||||
      );
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (shouldStream) {
 | 
					      if (shouldStream) {
 | 
				
			||||||
        let responseText = "";
 | 
					        const [tools, funcs] = usePluginStore
 | 
				
			||||||
        let remainText = "";
 | 
					          .getState()
 | 
				
			||||||
        let finished = false;
 | 
					          .getAsTools(
 | 
				
			||||||
 | 
					            useChatStore.getState().currentSession().mask?.plugin || [],
 | 
				
			||||||
 | 
					          );
 | 
				
			||||||
 | 
					        return stream(
 | 
				
			||||||
 | 
					          chatPath,
 | 
				
			||||||
 | 
					          requestPayload,
 | 
				
			||||||
 | 
					          getHeaders(),
 | 
				
			||||||
 | 
					          // @ts-ignore
 | 
				
			||||||
 | 
					          [{ functionDeclarations: tools.map((tool) => tool.function) }],
 | 
				
			||||||
 | 
					          funcs,
 | 
				
			||||||
 | 
					          controller,
 | 
				
			||||||
 | 
					          // parseSSE
 | 
				
			||||||
 | 
					          (text: string, runTools: ChatMessageTool[]) => {
 | 
				
			||||||
 | 
					            // console.log("parseSSE", text, runTools);
 | 
				
			||||||
 | 
					            const chunkJson = JSON.parse(text);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        const finish = () => {
 | 
					            const functionCall = chunkJson?.candidates
 | 
				
			||||||
          if (!finished) {
 | 
					              ?.at(0)
 | 
				
			||||||
            finished = true;
 | 
					              ?.content.parts.at(0)?.functionCall;
 | 
				
			||||||
            options.onFinish(responseText + remainText);
 | 
					            if (functionCall) {
 | 
				
			||||||
          }
 | 
					              const { name, args } = functionCall;
 | 
				
			||||||
        };
 | 
					              runTools.push({
 | 
				
			||||||
 | 
					                id: nanoid(),
 | 
				
			||||||
        // animate response to make it looks smooth
 | 
					                type: "function",
 | 
				
			||||||
        function animateResponseText() {
 | 
					                function: {
 | 
				
			||||||
          if (finished || controller.signal.aborted) {
 | 
					                  name,
 | 
				
			||||||
            responseText += remainText;
 | 
					                  arguments: JSON.stringify(args), // utils.chat call function, using JSON.parse
 | 
				
			||||||
            finish();
 | 
					                },
 | 
				
			||||||
            return;
 | 
					              });
 | 
				
			||||||
          }
 | 
					            }
 | 
				
			||||||
 | 
					            return chunkJson?.candidates?.at(0)?.content.parts.at(0)?.text;
 | 
				
			||||||
          if (remainText.length > 0) {
 | 
					          },
 | 
				
			||||||
            const fetchCount = Math.max(1, Math.round(remainText.length / 60));
 | 
					          // processToolMessage, include tool_calls message and tool call results
 | 
				
			||||||
            const fetchText = remainText.slice(0, fetchCount);
 | 
					          (
 | 
				
			||||||
            responseText += fetchText;
 | 
					            requestPayload: RequestPayload,
 | 
				
			||||||
            remainText = remainText.slice(fetchCount);
 | 
					            toolCallMessage: any,
 | 
				
			||||||
            options.onUpdate?.(responseText, fetchText);
 | 
					            toolCallResult: any[],
 | 
				
			||||||
          }
 | 
					          ) => {
 | 
				
			||||||
 | 
					            // @ts-ignore
 | 
				
			||||||
          requestAnimationFrame(animateResponseText);
 | 
					            requestPayload?.contents?.splice(
 | 
				
			||||||
        }
 | 
					              // @ts-ignore
 | 
				
			||||||
 | 
					              requestPayload?.contents?.length,
 | 
				
			||||||
        // start animaion
 | 
					              0,
 | 
				
			||||||
        animateResponseText();
 | 
					              {
 | 
				
			||||||
 | 
					                role: "model",
 | 
				
			||||||
        controller.signal.onabort = finish;
 | 
					                parts: toolCallMessage.tool_calls.map(
 | 
				
			||||||
 | 
					                  (tool: ChatMessageTool) => ({
 | 
				
			||||||
        fetchEventSource(chatPath, {
 | 
					                    functionCall: {
 | 
				
			||||||
          fetch: fetch as any,
 | 
					                      name: tool?.function?.name,
 | 
				
			||||||
          ...chatPayload,
 | 
					                      args: JSON.parse(tool?.function?.arguments as string),
 | 
				
			||||||
          async onopen(res) {
 | 
					                    },
 | 
				
			||||||
            clearTimeout(requestTimeoutId);
 | 
					                  }),
 | 
				
			||||||
            const contentType = res.headers.get("content-type");
 | 
					                ),
 | 
				
			||||||
            console.log(
 | 
					              },
 | 
				
			||||||
              "[Gemini] request response content type: ",
 | 
					              // @ts-ignore
 | 
				
			||||||
              contentType,
 | 
					              ...toolCallResult.map((result) => ({
 | 
				
			||||||
 | 
					                role: "function",
 | 
				
			||||||
 | 
					                parts: [
 | 
				
			||||||
 | 
					                  {
 | 
				
			||||||
 | 
					                    functionResponse: {
 | 
				
			||||||
 | 
					                      name: result.name,
 | 
				
			||||||
 | 
					                      response: {
 | 
				
			||||||
 | 
					                        name: result.name,
 | 
				
			||||||
 | 
					                        content: result.content, // TODO just text content...
 | 
				
			||||||
 | 
					                      },
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                  },
 | 
				
			||||||
 | 
					                ],
 | 
				
			||||||
 | 
					              })),
 | 
				
			||||||
            );
 | 
					            );
 | 
				
			||||||
 | 
					 | 
				
			||||||
            if (contentType?.startsWith("text/plain")) {
 | 
					 | 
				
			||||||
              responseText = await res.clone().text();
 | 
					 | 
				
			||||||
              return finish();
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            if (
 | 
					 | 
				
			||||||
              !res.ok ||
 | 
					 | 
				
			||||||
              !res.headers
 | 
					 | 
				
			||||||
                .get("content-type")
 | 
					 | 
				
			||||||
                ?.startsWith(EventStreamContentType) ||
 | 
					 | 
				
			||||||
              res.status !== 200
 | 
					 | 
				
			||||||
            ) {
 | 
					 | 
				
			||||||
              const responseTexts = [responseText];
 | 
					 | 
				
			||||||
              let extraInfo = await res.clone().text();
 | 
					 | 
				
			||||||
              try {
 | 
					 | 
				
			||||||
                const resJson = await res.clone().json();
 | 
					 | 
				
			||||||
                extraInfo = prettyObject(resJson);
 | 
					 | 
				
			||||||
              } catch {}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
              if (res.status === 401) {
 | 
					 | 
				
			||||||
                responseTexts.push(Locale.Error.Unauthorized);
 | 
					 | 
				
			||||||
              }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
              if (extraInfo) {
 | 
					 | 
				
			||||||
                responseTexts.push(extraInfo);
 | 
					 | 
				
			||||||
              }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
              responseText = responseTexts.join("\n\n");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
              return finish();
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
          },
 | 
					          },
 | 
				
			||||||
          onmessage(msg) {
 | 
					          options,
 | 
				
			||||||
            if (msg.data === "[DONE]" || finished) {
 | 
					        );
 | 
				
			||||||
              return finish();
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
            const text = msg.data;
 | 
					 | 
				
			||||||
            try {
 | 
					 | 
				
			||||||
              const json = JSON.parse(text);
 | 
					 | 
				
			||||||
              const delta = apiClient.extractMessage(json);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
              if (delta) {
 | 
					 | 
				
			||||||
                remainText += delta;
 | 
					 | 
				
			||||||
              }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
              const blockReason = json?.promptFeedback?.blockReason;
 | 
					 | 
				
			||||||
              if (blockReason) {
 | 
					 | 
				
			||||||
                // being blocked
 | 
					 | 
				
			||||||
                console.log(`[Google] [Safety Ratings] result:`, blockReason);
 | 
					 | 
				
			||||||
              }
 | 
					 | 
				
			||||||
            } catch (e) {
 | 
					 | 
				
			||||||
              console.error("[Request] parse error", text, msg);
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
          },
 | 
					 | 
				
			||||||
          onclose() {
 | 
					 | 
				
			||||||
            finish();
 | 
					 | 
				
			||||||
          },
 | 
					 | 
				
			||||||
          onerror(e) {
 | 
					 | 
				
			||||||
            options.onError?.(e);
 | 
					 | 
				
			||||||
            throw e;
 | 
					 | 
				
			||||||
          },
 | 
					 | 
				
			||||||
          openWhenHidden: true,
 | 
					 | 
				
			||||||
        });
 | 
					 | 
				
			||||||
      } else {
 | 
					      } else {
 | 
				
			||||||
        const res = await fetch(chatPath, chatPayload);
 | 
					        const res = await fetch(chatPath, chatPayload);
 | 
				
			||||||
        clearTimeout(requestTimeoutId);
 | 
					        clearTimeout(requestTimeoutId);
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -352,7 +352,7 @@ export class ChatGPTApi implements LLMApi {
 | 
				
			|||||||
        // make a fetch request
 | 
					        // make a fetch request
 | 
				
			||||||
        const requestTimeoutId = setTimeout(
 | 
					        const requestTimeoutId = setTimeout(
 | 
				
			||||||
          () => controller.abort(),
 | 
					          () => controller.abort(),
 | 
				
			||||||
          isDalle3 || isO1 ? REQUEST_TIMEOUT_MS * 2 : REQUEST_TIMEOUT_MS, // dalle3 using b64_json is slow.
 | 
					          isDalle3 || isO1 ? REQUEST_TIMEOUT_MS * 4 : REQUEST_TIMEOUT_MS, // dalle3 using b64_json is slow.
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        const res = await fetch(chatPath, chatPayload);
 | 
					        const res = await fetch(chatPath, chatPayload);
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -11,6 +11,7 @@ import Logo from "../icons/logo.svg";
 | 
				
			|||||||
import { useMobileScreen } from "@/app/utils";
 | 
					import { useMobileScreen } from "@/app/utils";
 | 
				
			||||||
import BotIcon from "../icons/bot.svg";
 | 
					import BotIcon from "../icons/bot.svg";
 | 
				
			||||||
import { getClientConfig } from "../config/client";
 | 
					import { getClientConfig } from "../config/client";
 | 
				
			||||||
 | 
					import { PasswordInput } from "./ui-lib";
 | 
				
			||||||
import LeftIcon from "@/app/icons/left.svg";
 | 
					import LeftIcon from "@/app/icons/left.svg";
 | 
				
			||||||
import { safeLocalStorage } from "@/app/utils";
 | 
					import { safeLocalStorage } from "@/app/utils";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
@@ -60,36 +61,43 @@ export function AuthPage() {
 | 
				
			|||||||
      <div className={styles["auth-title"]}>{Locale.Auth.Title}</div>
 | 
					      <div className={styles["auth-title"]}>{Locale.Auth.Title}</div>
 | 
				
			||||||
      <div className={styles["auth-tips"]}>{Locale.Auth.Tips}</div>
 | 
					      <div className={styles["auth-tips"]}>{Locale.Auth.Tips}</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <input
 | 
					      <PasswordInput
 | 
				
			||||||
        className={styles["auth-input"]}
 | 
					        style={{ marginTop: "3vh", marginBottom: "3vh" }}
 | 
				
			||||||
        type="password"
 | 
					        aria={Locale.Settings.ShowPassword}
 | 
				
			||||||
        placeholder={Locale.Auth.Input}
 | 
					        aria-label={Locale.Auth.Input}
 | 
				
			||||||
        value={accessStore.accessCode}
 | 
					        value={accessStore.accessCode}
 | 
				
			||||||
 | 
					        type="text"
 | 
				
			||||||
 | 
					        placeholder={Locale.Auth.Input}
 | 
				
			||||||
        onChange={(e) => {
 | 
					        onChange={(e) => {
 | 
				
			||||||
          accessStore.update(
 | 
					          accessStore.update(
 | 
				
			||||||
            (access) => (access.accessCode = e.currentTarget.value),
 | 
					            (access) => (access.accessCode = e.currentTarget.value),
 | 
				
			||||||
          );
 | 
					          );
 | 
				
			||||||
        }}
 | 
					        }}
 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      {!accessStore.hideUserApiKey ? (
 | 
					      {!accessStore.hideUserApiKey ? (
 | 
				
			||||||
        <>
 | 
					        <>
 | 
				
			||||||
          <div className={styles["auth-tips"]}>{Locale.Auth.SubTips}</div>
 | 
					          <div className={styles["auth-tips"]}>{Locale.Auth.SubTips}</div>
 | 
				
			||||||
          <input
 | 
					          <PasswordInput
 | 
				
			||||||
            className={styles["auth-input"]}
 | 
					            style={{ marginTop: "3vh", marginBottom: "3vh" }}
 | 
				
			||||||
            type="password"
 | 
					            aria={Locale.Settings.ShowPassword}
 | 
				
			||||||
            placeholder={Locale.Settings.Access.OpenAI.ApiKey.Placeholder}
 | 
					            aria-label={Locale.Settings.Access.OpenAI.ApiKey.Placeholder}
 | 
				
			||||||
            value={accessStore.openaiApiKey}
 | 
					            value={accessStore.openaiApiKey}
 | 
				
			||||||
 | 
					            type="text"
 | 
				
			||||||
 | 
					            placeholder={Locale.Settings.Access.OpenAI.ApiKey.Placeholder}
 | 
				
			||||||
            onChange={(e) => {
 | 
					            onChange={(e) => {
 | 
				
			||||||
              accessStore.update(
 | 
					              accessStore.update(
 | 
				
			||||||
                (access) => (access.openaiApiKey = e.currentTarget.value),
 | 
					                (access) => (access.openaiApiKey = e.currentTarget.value),
 | 
				
			||||||
              );
 | 
					              );
 | 
				
			||||||
            }}
 | 
					            }}
 | 
				
			||||||
          />
 | 
					          />
 | 
				
			||||||
          <input
 | 
					          <PasswordInput
 | 
				
			||||||
            className={styles["auth-input-second"]}
 | 
					            style={{ marginTop: "3vh", marginBottom: "3vh" }}
 | 
				
			||||||
            type="password"
 | 
					            aria={Locale.Settings.ShowPassword}
 | 
				
			||||||
            placeholder={Locale.Settings.Access.Google.ApiKey.Placeholder}
 | 
					            aria-label={Locale.Settings.Access.Google.ApiKey.Placeholder}
 | 
				
			||||||
            value={accessStore.googleApiKey}
 | 
					            value={accessStore.googleApiKey}
 | 
				
			||||||
 | 
					            type="text"
 | 
				
			||||||
 | 
					            placeholder={Locale.Settings.Access.Google.ApiKey.Placeholder}
 | 
				
			||||||
            onChange={(e) => {
 | 
					            onChange={(e) => {
 | 
				
			||||||
              accessStore.update(
 | 
					              accessStore.update(
 | 
				
			||||||
                (access) => (access.googleApiKey = e.currentTarget.value),
 | 
					                (access) => (access.googleApiKey = e.currentTarget.value),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -115,11 +115,14 @@ import { getClientConfig } from "../config/client";
 | 
				
			|||||||
import { useAllModels } from "../utils/hooks";
 | 
					import { useAllModels } from "../utils/hooks";
 | 
				
			||||||
import { MultimodalContent } from "../client/api";
 | 
					import { MultimodalContent } from "../client/api";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const localStorage = safeLocalStorage();
 | 
					 | 
				
			||||||
import { ClientApi } from "../client/api";
 | 
					import { ClientApi } from "../client/api";
 | 
				
			||||||
import { createTTSPlayer } from "../utils/audio";
 | 
					import { createTTSPlayer } from "../utils/audio";
 | 
				
			||||||
import { MsEdgeTTS, OUTPUT_FORMAT } from "../utils/ms_edge_tts";
 | 
					import { MsEdgeTTS, OUTPUT_FORMAT } from "../utils/ms_edge_tts";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { isEmpty } from "lodash-es";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const localStorage = safeLocalStorage();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const ttsPlayer = createTTSPlayer();
 | 
					const ttsPlayer = createTTSPlayer();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
 | 
					const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
 | 
				
			||||||
@@ -1015,7 +1018,7 @@ function _Chat() {
 | 
				
			|||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const doSubmit = (userInput: string) => {
 | 
					  const doSubmit = (userInput: string) => {
 | 
				
			||||||
    if (userInput.trim() === "") return;
 | 
					    if (userInput.trim() === "" && isEmpty(attachImages)) return;
 | 
				
			||||||
    const matchCommand = chatCommands.match(userInput);
 | 
					    const matchCommand = chatCommands.match(userInput);
 | 
				
			||||||
    if (matchCommand.matched) {
 | 
					    if (matchCommand.matched) {
 | 
				
			||||||
      setUserInput("");
 | 
					      setUserInput("");
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -140,6 +140,9 @@
 | 
				
			|||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
  justify-content: space-between;
 | 
					  justify-content: space-between;
 | 
				
			||||||
  align-items: center;
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  &-narrow {
 | 
				
			||||||
 | 
					    justify-content: center;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.sidebar-logo {
 | 
					.sidebar-logo {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -169,6 +169,12 @@ export function PreCode(props: { children: any }) {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function CustomCode(props: { children: any; className?: string }) {
 | 
					function CustomCode(props: { children: any; className?: string }) {
 | 
				
			||||||
 | 
					  const chatStore = useChatStore();
 | 
				
			||||||
 | 
					  const session = chatStore.currentSession();
 | 
				
			||||||
 | 
					  const config = useAppConfig();
 | 
				
			||||||
 | 
					  const enableCodeFold =
 | 
				
			||||||
 | 
					    session.mask?.enableCodeFold !== false && config.enableCodeFold;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const ref = useRef<HTMLPreElement>(null);
 | 
					  const ref = useRef<HTMLPreElement>(null);
 | 
				
			||||||
  const [collapsed, setCollapsed] = useState(true);
 | 
					  const [collapsed, setCollapsed] = useState(true);
 | 
				
			||||||
  const [showToggle, setShowToggle] = useState(false);
 | 
					  const [showToggle, setShowToggle] = useState(false);
 | 
				
			||||||
@@ -184,25 +190,30 @@ function CustomCode(props: { children: any; className?: string }) {
 | 
				
			|||||||
  const toggleCollapsed = () => {
 | 
					  const toggleCollapsed = () => {
 | 
				
			||||||
    setCollapsed((collapsed) => !collapsed);
 | 
					    setCollapsed((collapsed) => !collapsed);
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					  const renderShowMoreButton = () => {
 | 
				
			||||||
 | 
					    if (showToggle && enableCodeFold && collapsed) {
 | 
				
			||||||
 | 
					      return (
 | 
				
			||||||
 | 
					        <div className={`show-hide-button ${collapsed ? "collapsed" : "expanded"}`}>
 | 
				
			||||||
 | 
					          <button onClick={toggleCollapsed}>{Locale.NewChat.More}</button>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return null;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <>
 | 
					    <>
 | 
				
			||||||
      <code
 | 
					      <code
 | 
				
			||||||
        className={props?.className}
 | 
					        className={props?.className}
 | 
				
			||||||
        ref={ref}
 | 
					        ref={ref}
 | 
				
			||||||
        style={{
 | 
					        style={{
 | 
				
			||||||
          maxHeight: collapsed ? "400px" : "none",
 | 
					          maxHeight: enableCodeFold && collapsed ? "400px" : "none",
 | 
				
			||||||
          overflowY: "hidden",
 | 
					          overflowY: "hidden",
 | 
				
			||||||
        }}
 | 
					        }}
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        {props.children}
 | 
					        {props.children}
 | 
				
			||||||
      </code>
 | 
					      </code>
 | 
				
			||||||
      {showToggle && collapsed && (
 | 
					
 | 
				
			||||||
        <div
 | 
					      {renderShowMoreButton()}
 | 
				
			||||||
          className={`show-hide-button ${collapsed ? "collapsed" : "expanded"}`}
 | 
					 | 
				
			||||||
        >
 | 
					 | 
				
			||||||
          <button onClick={toggleCollapsed}>{Locale.NewChat.More}</button>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      )}
 | 
					 | 
				
			||||||
    </>
 | 
					    </>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -183,6 +183,23 @@ export function MaskConfig(props: {
 | 
				
			|||||||
            ></input>
 | 
					            ></input>
 | 
				
			||||||
          </ListItem>
 | 
					          </ListItem>
 | 
				
			||||||
        )}
 | 
					        )}
 | 
				
			||||||
 | 
					        {globalConfig.enableCodeFold && (
 | 
				
			||||||
 | 
					          <ListItem
 | 
				
			||||||
 | 
					            title={Locale.Mask.Config.CodeFold.Title}
 | 
				
			||||||
 | 
					            subTitle={Locale.Mask.Config.CodeFold.SubTitle}
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            <input
 | 
				
			||||||
 | 
					              aria-label={Locale.Mask.Config.CodeFold.Title}
 | 
				
			||||||
 | 
					              type="checkbox"
 | 
				
			||||||
 | 
					              checked={props.mask.enableCodeFold !== false}
 | 
				
			||||||
 | 
					              onChange={(e) => {
 | 
				
			||||||
 | 
					                props.updateMask((mask) => {
 | 
				
			||||||
 | 
					                  mask.enableCodeFold = e.currentTarget.checked;
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					              }}
 | 
				
			||||||
 | 
					            ></input>
 | 
				
			||||||
 | 
					          </ListItem>
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        {!props.shouldSyncFromGlobal ? (
 | 
					        {!props.shouldSyncFromGlobal ? (
 | 
				
			||||||
          <ListItem
 | 
					          <ListItem
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -49,7 +49,7 @@ import Locale, {
 | 
				
			|||||||
  changeLang,
 | 
					  changeLang,
 | 
				
			||||||
  getLang,
 | 
					  getLang,
 | 
				
			||||||
} from "../locales";
 | 
					} from "../locales";
 | 
				
			||||||
import { copyToClipboard } from "../utils";
 | 
					import { copyToClipboard, clientUpdate, semverCompare } from "../utils";
 | 
				
			||||||
import Link from "next/link";
 | 
					import Link from "next/link";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Anthropic,
 | 
					  Anthropic,
 | 
				
			||||||
@@ -585,7 +585,7 @@ export function Settings() {
 | 
				
			|||||||
  const [checkingUpdate, setCheckingUpdate] = useState(false);
 | 
					  const [checkingUpdate, setCheckingUpdate] = useState(false);
 | 
				
			||||||
  const currentVersion = updateStore.formatVersion(updateStore.version);
 | 
					  const currentVersion = updateStore.formatVersion(updateStore.version);
 | 
				
			||||||
  const remoteId = updateStore.formatVersion(updateStore.remoteVersion);
 | 
					  const remoteId = updateStore.formatVersion(updateStore.remoteVersion);
 | 
				
			||||||
  const hasNewVersion = currentVersion !== remoteId;
 | 
					  const hasNewVersion = semverCompare(currentVersion, remoteId) === -1;
 | 
				
			||||||
  const updateUrl = getClientConfig()?.isApp ? RELEASE_URL : UPDATE_URL;
 | 
					  const updateUrl = getClientConfig()?.isApp ? RELEASE_URL : UPDATE_URL;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  function checkUpdate(force = false) {
 | 
					  function checkUpdate(force = false) {
 | 
				
			||||||
@@ -1357,9 +1357,17 @@ export function Settings() {
 | 
				
			|||||||
            {checkingUpdate ? (
 | 
					            {checkingUpdate ? (
 | 
				
			||||||
              <LoadingIcon />
 | 
					              <LoadingIcon />
 | 
				
			||||||
            ) : hasNewVersion ? (
 | 
					            ) : hasNewVersion ? (
 | 
				
			||||||
              <Link href={updateUrl} target="_blank" className="link">
 | 
					              clientConfig?.isApp ? (
 | 
				
			||||||
                {Locale.Settings.Update.GoToUpdate}
 | 
					                <IconButton
 | 
				
			||||||
              </Link>
 | 
					                  icon={<ResetIcon></ResetIcon>}
 | 
				
			||||||
 | 
					                  text={Locale.Settings.Update.GoToUpdate}
 | 
				
			||||||
 | 
					                  onClick={() => clientUpdate()}
 | 
				
			||||||
 | 
					                />
 | 
				
			||||||
 | 
					              ) : (
 | 
				
			||||||
 | 
					                <Link href={updateUrl} target="_blank" className="link">
 | 
				
			||||||
 | 
					                  {Locale.Settings.Update.GoToUpdate}
 | 
				
			||||||
 | 
					                </Link>
 | 
				
			||||||
 | 
					              )
 | 
				
			||||||
            ) : (
 | 
					            ) : (
 | 
				
			||||||
              <IconButton
 | 
					              <IconButton
 | 
				
			||||||
                icon={<ResetIcon></ResetIcon>}
 | 
					                icon={<ResetIcon></ResetIcon>}
 | 
				
			||||||
@@ -1509,6 +1517,22 @@ export function Settings() {
 | 
				
			|||||||
              }
 | 
					              }
 | 
				
			||||||
            ></input>
 | 
					            ></input>
 | 
				
			||||||
          </ListItem>
 | 
					          </ListItem>
 | 
				
			||||||
 | 
					          <ListItem
 | 
				
			||||||
 | 
					            title={Locale.Mask.Config.CodeFold.Title}
 | 
				
			||||||
 | 
					            subTitle={Locale.Mask.Config.CodeFold.SubTitle}
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            <input
 | 
				
			||||||
 | 
					              aria-label={Locale.Mask.Config.CodeFold.Title}
 | 
				
			||||||
 | 
					              type="checkbox"
 | 
				
			||||||
 | 
					              checked={config.enableCodeFold}
 | 
				
			||||||
 | 
					              data-testid="enable-code-fold-checkbox"
 | 
				
			||||||
 | 
					              onChange={(e) =>
 | 
				
			||||||
 | 
					                updateConfig(
 | 
				
			||||||
 | 
					                  (config) => (config.enableCodeFold = e.currentTarget.checked),
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            ></input>
 | 
				
			||||||
 | 
					          </ListItem>
 | 
				
			||||||
        </List>
 | 
					        </List>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <SyncItems />
 | 
					        <SyncItems />
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -165,11 +165,17 @@ export function SideBarHeader(props: {
 | 
				
			|||||||
  subTitle?: string | React.ReactNode;
 | 
					  subTitle?: string | React.ReactNode;
 | 
				
			||||||
  logo?: React.ReactNode;
 | 
					  logo?: React.ReactNode;
 | 
				
			||||||
  children?: React.ReactNode;
 | 
					  children?: React.ReactNode;
 | 
				
			||||||
 | 
					  shouldNarrow?: boolean;
 | 
				
			||||||
}) {
 | 
					}) {
 | 
				
			||||||
  const { title, subTitle, logo, children } = props;
 | 
					  const { title, subTitle, logo, children, shouldNarrow } = props;
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <Fragment>
 | 
					    <Fragment>
 | 
				
			||||||
      <div className={styles["sidebar-header"]} data-tauri-drag-region>
 | 
					      <div
 | 
				
			||||||
 | 
					        className={`${styles["sidebar-header"]} ${
 | 
				
			||||||
 | 
					          shouldNarrow ? styles["sidebar-header-narrow"] : ""
 | 
				
			||||||
 | 
					        }`}
 | 
				
			||||||
 | 
					        data-tauri-drag-region
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
        <div className={styles["sidebar-title-container"]}>
 | 
					        <div className={styles["sidebar-title-container"]}>
 | 
				
			||||||
          <div className={styles["sidebar-title"]} data-tauri-drag-region>
 | 
					          <div className={styles["sidebar-title"]} data-tauri-drag-region>
 | 
				
			||||||
            {title}
 | 
					            {title}
 | 
				
			||||||
@@ -227,6 +233,7 @@ export function SideBar(props: { className?: string }) {
 | 
				
			|||||||
        title="NextChat"
 | 
					        title="NextChat"
 | 
				
			||||||
        subTitle="Build your own AI assistant."
 | 
					        subTitle="Build your own AI assistant."
 | 
				
			||||||
        logo={<ChatGptIcon />}
 | 
					        logo={<ChatGptIcon />}
 | 
				
			||||||
 | 
					        shouldNarrow={shouldNarrow}
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        <div className={styles["sidebar-header-bar"]}>
 | 
					        <div className={styles["sidebar-header-bar"]}>
 | 
				
			||||||
          <IconButton
 | 
					          <IconButton
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										7
									
								
								app/global.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								app/global.d.ts
									
									
									
									
										vendored
									
									
								
							@@ -26,6 +26,13 @@ declare interface Window {
 | 
				
			|||||||
      isPermissionGranted(): Promise<boolean>;
 | 
					      isPermissionGranted(): Promise<boolean>;
 | 
				
			||||||
      sendNotification(options: string | Options): void;
 | 
					      sendNotification(options: string | Options): void;
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					    updater: {
 | 
				
			||||||
 | 
					      checkUpdate(): Promise<UpdateResult>;
 | 
				
			||||||
 | 
					      installUpdate(): Promise<void>;
 | 
				
			||||||
 | 
					      onUpdaterEvent(
 | 
				
			||||||
 | 
					        handler: (status: UpdateStatusResult) => void,
 | 
				
			||||||
 | 
					      ): Promise<UnlistenFn>;
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
    http: {
 | 
					    http: {
 | 
				
			||||||
      fetch<T>(
 | 
					      fetch<T>(
 | 
				
			||||||
        url: string,
 | 
					        url: string,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -205,6 +205,8 @@ const cn = {
 | 
				
			|||||||
      IsChecking: "正在检查更新...",
 | 
					      IsChecking: "正在检查更新...",
 | 
				
			||||||
      FoundUpdate: (x: string) => `发现新版本:${x}`,
 | 
					      FoundUpdate: (x: string) => `发现新版本:${x}`,
 | 
				
			||||||
      GoToUpdate: "前往更新",
 | 
					      GoToUpdate: "前往更新",
 | 
				
			||||||
 | 
					      Success: "更新成功!",
 | 
				
			||||||
 | 
					      Failed: "更新失败",
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    SendKey: "发送键",
 | 
					    SendKey: "发送键",
 | 
				
			||||||
    Theme: "主题",
 | 
					    Theme: "主题",
 | 
				
			||||||
@@ -495,8 +497,8 @@ const cn = {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    Model: "模型 (model)",
 | 
					    Model: "模型 (model)",
 | 
				
			||||||
    CompressModel: {
 | 
					    CompressModel: {
 | 
				
			||||||
      Title: "压缩模型",
 | 
					      Title: "对话摘要模型",
 | 
				
			||||||
      SubTitle: "用于压缩历史记录的模型",
 | 
					      SubTitle: "用于压缩历史记录、生成对话标题的模型",
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    Temperature: {
 | 
					    Temperature: {
 | 
				
			||||||
      Title: "随机性 (temperature)",
 | 
					      Title: "随机性 (temperature)",
 | 
				
			||||||
@@ -665,6 +667,10 @@ const cn = {
 | 
				
			|||||||
        Title: "启用Artifacts",
 | 
					        Title: "启用Artifacts",
 | 
				
			||||||
        SubTitle: "启用之后可以直接渲染HTML页面",
 | 
					        SubTitle: "启用之后可以直接渲染HTML页面",
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
 | 
					      CodeFold: {
 | 
				
			||||||
 | 
					        Title: "启用代码折叠",
 | 
				
			||||||
 | 
					        SubTitle: "启用之后可以自动折叠/展开过长的代码块",
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
      Share: {
 | 
					      Share: {
 | 
				
			||||||
        Title: "分享此面具",
 | 
					        Title: "分享此面具",
 | 
				
			||||||
        SubTitle: "生成此面具的直达链接",
 | 
					        SubTitle: "生成此面具的直达链接",
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -207,6 +207,8 @@ const en: LocaleType = {
 | 
				
			|||||||
      IsChecking: "Checking update...",
 | 
					      IsChecking: "Checking update...",
 | 
				
			||||||
      FoundUpdate: (x: string) => `Found new version: ${x}`,
 | 
					      FoundUpdate: (x: string) => `Found new version: ${x}`,
 | 
				
			||||||
      GoToUpdate: "Update",
 | 
					      GoToUpdate: "Update",
 | 
				
			||||||
 | 
					      Success: "Update Successful.",
 | 
				
			||||||
 | 
					      Failed: "Update Failed.",
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    SendKey: "Send Key",
 | 
					    SendKey: "Send Key",
 | 
				
			||||||
    Theme: "Theme",
 | 
					    Theme: "Theme",
 | 
				
			||||||
@@ -500,8 +502,8 @@ const en: LocaleType = {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    Model: "Model",
 | 
					    Model: "Model",
 | 
				
			||||||
    CompressModel: {
 | 
					    CompressModel: {
 | 
				
			||||||
      Title: "Compression Model",
 | 
					      Title: "Summary Model",
 | 
				
			||||||
      SubTitle: "Model used to compress history",
 | 
					      SubTitle: "Model used to compress history and generate title",
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    Temperature: {
 | 
					    Temperature: {
 | 
				
			||||||
      Title: "Temperature",
 | 
					      Title: "Temperature",
 | 
				
			||||||
@@ -675,6 +677,11 @@ const en: LocaleType = {
 | 
				
			|||||||
        Title: "Enable Artifacts",
 | 
					        Title: "Enable Artifacts",
 | 
				
			||||||
        SubTitle: "Can render HTML page when enable artifacts.",
 | 
					        SubTitle: "Can render HTML page when enable artifacts.",
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
 | 
					      CodeFold: {
 | 
				
			||||||
 | 
					        Title: "Enable CodeFold",
 | 
				
			||||||
 | 
					        SubTitle:
 | 
				
			||||||
 | 
					          "Automatically collapse/expand overly long code blocks when CodeFold is enabled",
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
      Share: {
 | 
					      Share: {
 | 
				
			||||||
        Title: "Share This Mask",
 | 
					        Title: "Share This Mask",
 | 
				
			||||||
        SubTitle: "Generate a link to this mask",
 | 
					        SubTitle: "Generate a link to this mask",
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -8,12 +8,12 @@ const tw = {
 | 
				
			|||||||
  Error: {
 | 
					  Error: {
 | 
				
			||||||
    Unauthorized: isApp
 | 
					    Unauthorized: isApp
 | 
				
			||||||
      ? `😆 對話遇到了一些問題,不用慌:
 | 
					      ? `😆 對話遇到了一些問題,不用慌:
 | 
				
			||||||
    \\ 1️⃣ 想要零配置開箱即用,[點擊這裡立刻開啟對話 🚀](${SAAS_CHAT_UTM_URL})
 | 
					    \\ 1️⃣ 想要無須設定開箱即用,[點選這裡立刻開啟對話 🚀](${SAAS_CHAT_UTM_URL})
 | 
				
			||||||
    \\ 2️⃣ 如果你想消耗自己的 OpenAI 資源,點擊[這裡](/#/settings)修改設定 ⚙️`
 | 
					    \\ 2️⃣ 如果你想消耗自己的 OpenAI 資源,點選[這裡](/#/settings)修改設定 ⚙️`
 | 
				
			||||||
      : `😆 對話遇到了一些問題,不用慌:
 | 
					      : `😆 對話遇到了一些問題,不用慌:
 | 
				
			||||||
    \ 1️⃣ 想要零配置開箱即用,[點擊這裡立刻開啟對話 🚀](${SAAS_CHAT_UTM_URL})
 | 
					    \ 1️⃣ 想要無須設定開箱即用,[點選這裡立刻開啟對話 🚀](${SAAS_CHAT_UTM_URL})
 | 
				
			||||||
    \ 2️⃣ 如果你正在使用私有部署版本,點擊[這裡](/#/auth)輸入訪問秘鑰 🔑
 | 
					    \ 2️⃣ 如果你正在使用私有部署版本,點選[這裡](/#/auth)輸入存取金鑰 🔑
 | 
				
			||||||
    \ 3️⃣ 如果你想消耗自己的 OpenAI 資源,點擊[這裡](/#/settings)修改設定 ⚙️
 | 
					    \ 3️⃣ 如果你想消耗自己的 OpenAI 資源,點選[這裡](/#/settings)修改設定 ⚙️
 | 
				
			||||||
 `,
 | 
					 `,
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -25,9 +25,9 @@ const tw = {
 | 
				
			|||||||
    Confirm: "確認",
 | 
					    Confirm: "確認",
 | 
				
			||||||
    Later: "稍候再說",
 | 
					    Later: "稍候再說",
 | 
				
			||||||
    Return: "返回",
 | 
					    Return: "返回",
 | 
				
			||||||
    SaasTips: "配置太麻煩,想要立即使用",
 | 
					    SaasTips: "設定太麻煩,想要立即使用",
 | 
				
			||||||
    TopTips:
 | 
					    TopTips:
 | 
				
			||||||
      "🥳 NextChat AI 首發優惠,立刻解鎖 OpenAI o1, GPT-4o, Claude-3.5 等最新大模型",
 | 
					      "🥳 NextChat AI 首發優惠,立刻解鎖 OpenAI o1, GPT-4o, Claude-3.5 等最新的大型語言模型",
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  ChatItem: {
 | 
					  ChatItem: {
 | 
				
			||||||
    ChatItemCount: (count: number) => `${count} 則對話`,
 | 
					    ChatItemCount: (count: number) => `${count} 則對話`,
 | 
				
			||||||
@@ -53,8 +53,8 @@ const tw = {
 | 
				
			|||||||
      PinToastAction: "檢視",
 | 
					      PinToastAction: "檢視",
 | 
				
			||||||
      Delete: "刪除",
 | 
					      Delete: "刪除",
 | 
				
			||||||
      Edit: "編輯",
 | 
					      Edit: "編輯",
 | 
				
			||||||
      RefreshTitle: "刷新標題",
 | 
					      RefreshTitle: "重新整理標題",
 | 
				
			||||||
      RefreshToast: "已發送刷新標題請求",
 | 
					      RefreshToast: "已傳送重新整理標題請求",
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    Commands: {
 | 
					    Commands: {
 | 
				
			||||||
      new: "新建聊天",
 | 
					      new: "新建聊天",
 | 
				
			||||||
@@ -95,10 +95,10 @@ const tw = {
 | 
				
			|||||||
    IsContext: "預設提示詞",
 | 
					    IsContext: "預設提示詞",
 | 
				
			||||||
    ShortcutKey: {
 | 
					    ShortcutKey: {
 | 
				
			||||||
      Title: "鍵盤快捷方式",
 | 
					      Title: "鍵盤快捷方式",
 | 
				
			||||||
      newChat: "打開新聊天",
 | 
					      newChat: "開啟新聊天",
 | 
				
			||||||
      focusInput: "聚焦輸入框",
 | 
					      focusInput: "聚焦輸入框",
 | 
				
			||||||
      copyLastMessage: "複製最後一個回覆",
 | 
					      copyLastMessage: "複製最後一個回覆",
 | 
				
			||||||
      copyLastCode: "複製最後一個代碼塊",
 | 
					      copyLastCode: "複製最後一個程式碼區塊",
 | 
				
			||||||
      showShortcutKey: "顯示快捷方式",
 | 
					      showShortcutKey: "顯示快捷方式",
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
@@ -174,9 +174,9 @@ const tw = {
 | 
				
			|||||||
      SubTitle: "聊天內容的字型大小",
 | 
					      SubTitle: "聊天內容的字型大小",
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    FontFamily: {
 | 
					    FontFamily: {
 | 
				
			||||||
      Title: "聊天字體",
 | 
					      Title: "聊天字型",
 | 
				
			||||||
      SubTitle: "聊天內容的字體,若置空則應用全局默認字體",
 | 
					      SubTitle: "聊天內容的字型,若留空則套用全域預設字型",
 | 
				
			||||||
      Placeholder: "字體名稱",
 | 
					      Placeholder: "字型名稱",
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    InjectSystemPrompts: {
 | 
					    InjectSystemPrompts: {
 | 
				
			||||||
      Title: "匯入系統提示",
 | 
					      Title: "匯入系統提示",
 | 
				
			||||||
@@ -301,8 +301,8 @@ const tw = {
 | 
				
			|||||||
        Title: "使用 NextChat AI",
 | 
					        Title: "使用 NextChat AI",
 | 
				
			||||||
        Label: "(性價比最高的方案)",
 | 
					        Label: "(性價比最高的方案)",
 | 
				
			||||||
        SubTitle:
 | 
					        SubTitle:
 | 
				
			||||||
          "由 NextChat 官方維護,零配置開箱即用,支持 OpenAI o1、GPT-4o、Claude-3.5 等最新大模型",
 | 
					          "由 NextChat 官方維護,無須設定開箱即用,支援 OpenAI o1、GPT-4o、Claude-3.5 等最新的大型語言模型",
 | 
				
			||||||
        ChatNow: "立刻對話",
 | 
					        ChatNow: "立刻開始對話",
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      AccessCode: {
 | 
					      AccessCode: {
 | 
				
			||||||
@@ -485,18 +485,18 @@ const tw = {
 | 
				
			|||||||
    },
 | 
					    },
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  SearchChat: {
 | 
					  SearchChat: {
 | 
				
			||||||
    Name: "搜索",
 | 
					    Name: "搜尋",
 | 
				
			||||||
    Page: {
 | 
					    Page: {
 | 
				
			||||||
      Title: "搜索聊天記錄",
 | 
					      Title: "搜尋聊天記錄",
 | 
				
			||||||
      Search: "輸入搜索關鍵詞",
 | 
					      Search: "輸入搜尋關鍵詞",
 | 
				
			||||||
      NoResult: "沒有找到結果",
 | 
					      NoResult: "沒有找到結果",
 | 
				
			||||||
      NoData: "沒有數據",
 | 
					      NoData: "沒有資料",
 | 
				
			||||||
      Loading: "加載中",
 | 
					      Loading: "載入中",
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      SubTitle: (count: number) => `找到 ${count} 條結果`,
 | 
					      SubTitle: (count: number) => `找到 ${count} 條結果`,
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    Item: {
 | 
					    Item: {
 | 
				
			||||||
      View: "查看",
 | 
					      View: "檢視",
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  NewChat: {
 | 
					  NewChat: {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -372,22 +372,16 @@ export const useChatStore = createPersistStore(
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        if (attachImages && attachImages.length > 0) {
 | 
					        if (attachImages && attachImages.length > 0) {
 | 
				
			||||||
          mContent = [
 | 
					          mContent = [
 | 
				
			||||||
            {
 | 
					            ...(userContent
 | 
				
			||||||
              type: "text",
 | 
					              ? [{ type: "text" as const, text: userContent }]
 | 
				
			||||||
              text: userContent,
 | 
					              : []),
 | 
				
			||||||
            },
 | 
					            ...attachImages.map((url) => ({
 | 
				
			||||||
 | 
					              type: "image_url" as const,
 | 
				
			||||||
 | 
					              image_url: { url },
 | 
				
			||||||
 | 
					            })),
 | 
				
			||||||
          ];
 | 
					          ];
 | 
				
			||||||
          mContent = mContent.concat(
 | 
					 | 
				
			||||||
            attachImages.map((url) => {
 | 
					 | 
				
			||||||
              return {
 | 
					 | 
				
			||||||
                type: "image_url",
 | 
					 | 
				
			||||||
                image_url: {
 | 
					 | 
				
			||||||
                  url: url,
 | 
					 | 
				
			||||||
                },
 | 
					 | 
				
			||||||
              };
 | 
					 | 
				
			||||||
            }),
 | 
					 | 
				
			||||||
          );
 | 
					 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        let userMessage: ChatMessage = createMessage({
 | 
					        let userMessage: ChatMessage = createMessage({
 | 
				
			||||||
          role: "user",
 | 
					          role: "user",
 | 
				
			||||||
          content: mContent,
 | 
					          content: mContent,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -52,6 +52,8 @@ export const DEFAULT_CONFIG = {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  enableArtifacts: true, // show artifacts config
 | 
					  enableArtifacts: true, // show artifacts config
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  enableCodeFold: true, // code fold config
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  disablePromptHint: false,
 | 
					  disablePromptHint: false,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  dontShowMaskSplashScreen: false, // dont show splash screen when create chat
 | 
					  dontShowMaskSplashScreen: false, // dont show splash screen when create chat
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -19,6 +19,7 @@ export type Mask = {
 | 
				
			|||||||
  builtin: boolean;
 | 
					  builtin: boolean;
 | 
				
			||||||
  plugin?: string[];
 | 
					  plugin?: string[];
 | 
				
			||||||
  enableArtifacts?: boolean;
 | 
					  enableArtifacts?: boolean;
 | 
				
			||||||
 | 
					  enableCodeFold?: boolean;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const DEFAULT_MASK_STATE = {
 | 
					export const DEFAULT_MASK_STATE = {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -151,7 +151,7 @@ export const usePromptStore = createPersistStore(
 | 
				
			|||||||
      if (typeof window === "undefined") {
 | 
					      if (typeof window === "undefined") {
 | 
				
			||||||
        return;
 | 
					        return;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
      
 | 
					
 | 
				
			||||||
      const PROMPT_URL = "./prompts.json";
 | 
					      const PROMPT_URL = "./prompts.json";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      type PromptList = Array<[string, string]>;
 | 
					      type PromptList = Array<[string, string]>;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,6 +6,7 @@ import {
 | 
				
			|||||||
} from "../constant";
 | 
					} from "../constant";
 | 
				
			||||||
import { getClientConfig } from "../config/client";
 | 
					import { getClientConfig } from "../config/client";
 | 
				
			||||||
import { createPersistStore } from "../utils/store";
 | 
					import { createPersistStore } from "../utils/store";
 | 
				
			||||||
 | 
					import { clientUpdate } from "../utils";
 | 
				
			||||||
import ChatGptIcon from "../icons/chatgpt.png";
 | 
					import ChatGptIcon from "../icons/chatgpt.png";
 | 
				
			||||||
import Locale from "../locales";
 | 
					import Locale from "../locales";
 | 
				
			||||||
import { ClientApi } from "../client/api";
 | 
					import { ClientApi } from "../client/api";
 | 
				
			||||||
@@ -119,6 +120,7 @@ export const useUpdateStore = createPersistStore(
 | 
				
			|||||||
                          icon: `${ChatGptIcon.src}`,
 | 
					                          icon: `${ChatGptIcon.src}`,
 | 
				
			||||||
                          sound: "Default",
 | 
					                          sound: "Default",
 | 
				
			||||||
                        });
 | 
					                        });
 | 
				
			||||||
 | 
					                        clientUpdate();
 | 
				
			||||||
                      }
 | 
					                      }
 | 
				
			||||||
                    }
 | 
					                    }
 | 
				
			||||||
                  });
 | 
					                  });
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										37
									
								
								app/utils.ts
									
									
									
									
									
								
							
							
						
						
									
										37
									
								
								app/utils.ts
									
									
									
									
									
								
							@@ -285,6 +285,9 @@ export function showPlugins(provider: ServiceProvider, model: string) {
 | 
				
			|||||||
  if (provider == ServiceProvider.Anthropic && !model.includes("claude-2")) {
 | 
					  if (provider == ServiceProvider.Anthropic && !model.includes("claude-2")) {
 | 
				
			||||||
    return true;
 | 
					    return true;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					  if (provider == ServiceProvider.Google && !model.includes("vision")) {
 | 
				
			||||||
 | 
					    return true;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
  return false;
 | 
					  return false;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -383,3 +386,37 @@ export function getOperationId(operation: {
 | 
				
			|||||||
    `${operation.method.toUpperCase()}${operation.path.replaceAll("/", "_")}`
 | 
					    `${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",
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -223,6 +223,11 @@ export function stream(
 | 
				
			|||||||
            )
 | 
					            )
 | 
				
			||||||
              .then((res) => {
 | 
					              .then((res) => {
 | 
				
			||||||
                let content = res.data || res?.statusText;
 | 
					                let content = res.data || res?.statusText;
 | 
				
			||||||
 | 
					                // hotfix #5614
 | 
				
			||||||
 | 
					                content =
 | 
				
			||||||
 | 
					                  typeof content === "string"
 | 
				
			||||||
 | 
					                    ? content
 | 
				
			||||||
 | 
					                    : JSON.stringify(content);
 | 
				
			||||||
                if (res.status >= 300) {
 | 
					                if (res.status >= 300) {
 | 
				
			||||||
                  return Promise.reject(content);
 | 
					                  return Promise.reject(content);
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
@@ -245,6 +250,7 @@ export function stream(
 | 
				
			|||||||
                return e.toString();
 | 
					                return e.toString();
 | 
				
			||||||
              })
 | 
					              })
 | 
				
			||||||
              .then((content) => ({
 | 
					              .then((content) => ({
 | 
				
			||||||
 | 
					                name: tool.function.name,
 | 
				
			||||||
                role: "tool",
 | 
					                role: "tool",
 | 
				
			||||||
                content,
 | 
					                content,
 | 
				
			||||||
                tool_call_id: tool.id,
 | 
					                tool_call_id: tool.id,
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										21
									
								
								jest.config.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								jest.config.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,21 @@
 | 
				
			|||||||
 | 
					import type { Config } from "jest";
 | 
				
			||||||
 | 
					import nextJest from "next/jest.js";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const createJestConfig = nextJest({
 | 
				
			||||||
 | 
					  // Provide the path to your Next.js app to load next.config.js and .env files in your test environment
 | 
				
			||||||
 | 
					  dir: "./",
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Add any custom config to be passed to Jest
 | 
				
			||||||
 | 
					const config: Config = {
 | 
				
			||||||
 | 
					  coverageProvider: "v8",
 | 
				
			||||||
 | 
					  testEnvironment: "jsdom",
 | 
				
			||||||
 | 
					  testMatch: ["**/*.test.js", "**/*.test.ts", "**/*.test.jsx", "**/*.test.tsx"],
 | 
				
			||||||
 | 
					  setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"],
 | 
				
			||||||
 | 
					  moduleNameMapper: {
 | 
				
			||||||
 | 
					    "^@/(.*)$": "<rootDir>/$1",
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
 | 
				
			||||||
 | 
					export default createJestConfig(config);
 | 
				
			||||||
							
								
								
									
										2
									
								
								jest.setup.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								jest.setup.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,2 @@
 | 
				
			|||||||
 | 
					// Learn more: https://github.com/testing-library/jest-dom
 | 
				
			||||||
 | 
					import "@testing-library/jest-dom";
 | 
				
			||||||
							
								
								
									
										12
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								package.json
									
									
									
									
									
								
							@@ -15,7 +15,9 @@
 | 
				
			|||||||
    "app:build": "yarn mask && yarn tauri build",
 | 
					    "app:build": "yarn mask && yarn tauri build",
 | 
				
			||||||
    "prompts": "node ./scripts/fetch-prompts.mjs",
 | 
					    "prompts": "node ./scripts/fetch-prompts.mjs",
 | 
				
			||||||
    "prepare": "husky install",
 | 
					    "prepare": "husky install",
 | 
				
			||||||
    "proxy-dev": "sh ./scripts/init-proxy.sh && proxychains -f ./scripts/proxychains.conf yarn dev"
 | 
					    "proxy-dev": "sh ./scripts/init-proxy.sh && proxychains -f ./scripts/proxychains.conf yarn dev",
 | 
				
			||||||
 | 
					    "test": "jest --watch",
 | 
				
			||||||
 | 
					    "test:ci": "jest --ci"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "dependencies": {
 | 
					  "dependencies": {
 | 
				
			||||||
    "@fortaine/fetch-event-source": "^3.0.6",
 | 
					    "@fortaine/fetch-event-source": "^3.0.6",
 | 
				
			||||||
@@ -54,6 +56,9 @@
 | 
				
			|||||||
  "devDependencies": {
 | 
					  "devDependencies": {
 | 
				
			||||||
    "@tauri-apps/api": "^1.6.0",
 | 
					    "@tauri-apps/api": "^1.6.0",
 | 
				
			||||||
    "@tauri-apps/cli": "1.5.11",
 | 
					    "@tauri-apps/cli": "1.5.11",
 | 
				
			||||||
 | 
					    "@testing-library/jest-dom": "^6.4.8",
 | 
				
			||||||
 | 
					    "@testing-library/react": "^16.0.0",
 | 
				
			||||||
 | 
					    "@types/jest": "^29.5.13",
 | 
				
			||||||
    "@types/js-yaml": "4.0.9",
 | 
					    "@types/js-yaml": "4.0.9",
 | 
				
			||||||
    "@types/lodash-es": "^4.17.12",
 | 
					    "@types/lodash-es": "^4.17.12",
 | 
				
			||||||
    "@types/node": "^20.11.30",
 | 
					    "@types/node": "^20.11.30",
 | 
				
			||||||
@@ -69,8 +74,11 @@
 | 
				
			|||||||
    "eslint-plugin-prettier": "^5.1.3",
 | 
					    "eslint-plugin-prettier": "^5.1.3",
 | 
				
			||||||
    "eslint-plugin-unused-imports": "^3.2.0",
 | 
					    "eslint-plugin-unused-imports": "^3.2.0",
 | 
				
			||||||
    "husky": "^8.0.0",
 | 
					    "husky": "^8.0.0",
 | 
				
			||||||
 | 
					    "jest": "^29.7.0",
 | 
				
			||||||
 | 
					    "jest-environment-jsdom": "^29.7.0",
 | 
				
			||||||
    "lint-staged": "^13.2.2",
 | 
					    "lint-staged": "^13.2.2",
 | 
				
			||||||
    "prettier": "^3.0.2",
 | 
					    "prettier": "^3.0.2",
 | 
				
			||||||
 | 
					    "ts-node": "^10.9.2",
 | 
				
			||||||
    "tsx": "^4.16.0",
 | 
					    "tsx": "^4.16.0",
 | 
				
			||||||
    "typescript": "5.2.2",
 | 
					    "typescript": "5.2.2",
 | 
				
			||||||
    "watch": "^1.0.2",
 | 
					    "watch": "^1.0.2",
 | 
				
			||||||
@@ -80,4 +88,4 @@
 | 
				
			|||||||
    "lint-staged/yaml": "^2.2.2"
 | 
					    "lint-staged/yaml": "^2.2.2"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "packageManager": "yarn@1.22.19"
 | 
					  "packageManager": "yarn@1.22.19"
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -99,7 +99,7 @@
 | 
				
			|||||||
      "endpoints": [
 | 
					      "endpoints": [
 | 
				
			||||||
        "https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/releases/latest/download/latest.json"
 | 
					        "https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/releases/latest/download/latest.json"
 | 
				
			||||||
      ],
 | 
					      ],
 | 
				
			||||||
      "dialog": false,
 | 
					      "dialog": true,
 | 
				
			||||||
      "windows": {
 | 
					      "windows": {
 | 
				
			||||||
        "installMode": "passive"
 | 
					        "installMode": "passive"
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										9
									
								
								test/sum-module.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								test/sum-module.test.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,9 @@
 | 
				
			|||||||
 | 
					function sum(a: number, b: number) {
 | 
				
			||||||
 | 
					  return a + b;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					describe("sum module", () => {
 | 
				
			||||||
 | 
					  test("adds 1 + 2 to equal 3", () => {
 | 
				
			||||||
 | 
					    expect(sum(1, 2)).toBe(3);
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
		Reference in New Issue
	
	Block a user