Merge remote-tracking branch 'origin/main' into website

This commit is contained in:
lloydzhou 2024-10-15 17:29:42 +08:00
commit 37e48b431d
27 changed files with 2287 additions and 196 deletions

View File

@ -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
View 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

View File

@ -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);

View File

@ -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);

View File

@ -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),

View File

@ -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("");

View File

@ -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 {

View File

@ -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>
)}
</> </>
); );
} }

View File

@ -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

View File

@ -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 />

View File

@ -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
View File

@ -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,

View File

@ -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: "生成此面具的直达链接",

View File

@ -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",

View File

@ -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,

View File

@ -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

View File

@ -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 = {

View File

@ -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]>;

View File

@ -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();
} }
} }
}); });

View File

@ -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",
});
}

View File

@ -250,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
View 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
View File

@ -0,0 +1,2 @@
// Learn more: https://github.com/testing-library/jest-dom
import "@testing-library/jest-dom";

View File

@ -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"
} }

View File

@ -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
View 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);
});
});

1970
yarn.lock

File diff suppressed because it is too large Load Diff