Merge branch 'Yidadaa:main' into main

This commit is contained in:
Wei Xia 2023-04-23 14:44:11 +08:00 committed by GitHub
commit d09550b4cb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 239 additions and 213 deletions

View File

@ -102,7 +102,7 @@ We recommend that you follow the steps below to re-deploy:
### Enable Automatic Updates
After forking the project, due to the limitations imposed by Github, you need to manually enable Workflows and Upstream Sync Action on the Actions page of the forked project. Once enabled, automatic updates will be scheduled every hour:
After forking the project, due to the limitations imposed by GitHub, you need to manually enable Workflows and Upstream Sync Action on the Actions page of the forked project. Once enabled, automatic updates will be scheduled every hour:
![Automatic Updates](./docs/images/enable-actions.jpg)
@ -110,7 +110,7 @@ After forking the project, due to the limitations imposed by Github, you need to
### Manually Updating Code
If you want to update instantly, you can check out the [Github documentation](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork) to learn how to synchronize a forked project with upstream code.
If you want to update instantly, you can check out the [GitHub documentation](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork) to learn how to synchronize a forked project with upstream code.
You can star or watch this project or follow author to get release notifictions in time.

View File

@ -32,6 +32,7 @@ import {
useAccessStore,
Theme,
ModelType,
useAppConfig,
} from "../store";
import {
@ -69,7 +70,7 @@ const Emoji = dynamic(async () => (await import("emoji-picker-react")).Emoji, {
});
export function Avatar(props: { role: Message["role"]; model?: ModelType }) {
const config = useChatStore((state) => state.config);
const config = useAppConfig();
if (props.role !== "user") {
return (
@ -285,7 +286,7 @@ function PromptToast(props: {
}
function useSubmitHandler() {
const config = useChatStore((state) => state.config);
const config = useAppConfig();
const submitKey = config.submitKey;
const shouldSubmit = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@ -361,16 +362,16 @@ export function ChatActions(props: {
scrollToBottom: () => void;
hitBottom: boolean;
}) {
const chatStore = useChatStore();
const config = useAppConfig();
// switch themes
const theme = chatStore.config.theme;
const theme = config.theme;
function nextTheme() {
const themes = [Theme.Auto, Theme.Light, Theme.Dark];
const themeIndex = themes.indexOf(theme);
const nextIndex = (themeIndex + 1) % themes.length;
const nextTheme = themes[nextIndex];
chatStore.updateConfig((config) => (config.theme = nextTheme));
config.update((config) => (config.theme = nextTheme));
}
// stop all responses
@ -428,7 +429,8 @@ export function Chat() {
state.currentSession(),
state.currentSessionIndex,
]);
const fontSize = useChatStore((state) => state.config.fontSize);
const config = useAppConfig();
const fontSize = config.fontSize;
const inputRef = useRef<HTMLTextAreaElement>(null);
const [userInput, setUserInput] = useState("");
@ -492,7 +494,7 @@ export function Chat() {
// clear search results
if (n === 0) {
setPromptHints([]);
} else if (!chatStore.config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
} else if (!config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
// check if need to trigger auto completion
if (text.startsWith("/")) {
let searchText = text.slice(1);
@ -543,7 +545,7 @@ export function Chat() {
}
};
const findLastUesrIndex = (messageId: number) => {
const findLastUserIndex = (messageId: number) => {
// find last user input message and resend
let lastUserMessageIndex: number | null = null;
for (let i = 0; i < session.messages.length; i += 1) {
@ -566,14 +568,14 @@ export function Chat() {
};
const onDelete = (botMessageId: number) => {
const userIndex = findLastUesrIndex(botMessageId);
const userIndex = findLastUserIndex(botMessageId);
if (userIndex === null) return;
deleteMessage(userIndex);
};
const onResend = (botMessageId: number) => {
// find last user input message and resend
const userIndex = findLastUesrIndex(botMessageId);
const userIndex = findLastUserIndex(botMessageId);
if (userIndex === null) return;
setIsLoading(true);
@ -583,8 +585,6 @@ export function Chat() {
inputRef.current?.focus();
};
const config = useChatStore((state) => state.config);
const context: RenderMessage[] = session.context.slice();
const accessStore = useAccessStore();
@ -692,10 +692,10 @@ export function Chat() {
{!isMobileScreen && (
<div className={styles["window-action-button"]}>
<IconButton
icon={chatStore.config.tightBorder ? <MinIcon /> : <MaxIcon />}
icon={config.tightBorder ? <MinIcon /> : <MaxIcon />}
bordered
onClick={() => {
chatStore.updateConfig(
config.update(
(config) => (config.tightBorder = !config.tightBorder),
);
}}

View File

@ -313,6 +313,10 @@
.chat-message {
display: flex;
flex-direction: row;
&:last-child {
animation: slide-in ease 0.3s;
}
}
.chat-message-user {
@ -325,7 +329,6 @@
display: flex;
flex-direction: column;
align-items: flex-start;
animation: slide-in ease 0.3s;
&:hover {
.chat-message-top-actions {

View File

@ -2,14 +2,13 @@
require("../polyfill");
import { useState, useEffect } from "react";
import { useState, useEffect, StyleHTMLAttributes } from "react";
import styles from "./home.module.scss";
import BotIcon from "../icons/bot.svg";
import LoadingIcon from "../icons/three-dots.svg";
import { useChatStore } from "../store";
import { getCSSVar, useMobileScreen } from "../utils";
import { Chat } from "./chat";
@ -23,6 +22,8 @@ import {
Route,
useLocation,
} from "react-router-dom";
import { SideBar } from "./sidebar";
import { useAppConfig } from "../store/config";
export function Loading(props: { noLogo?: boolean }) {
return (
@ -37,12 +38,8 @@ const Settings = dynamic(async () => (await import("./settings")).Settings, {
loading: () => <Loading noLogo />,
});
const SideBar = dynamic(async () => (await import("./sidebar")).SideBar, {
loading: () => <Loading noLogo />,
});
export function useSwitchTheme() {
const config = useChatStore((state) => state.config);
const config = useAppConfig();
useEffect(() => {
document.body.classList.remove("light");
@ -83,7 +80,7 @@ const useHasHydrated = () => {
};
function WideScreen() {
const config = useChatStore((state) => state.config);
const config = useAppConfig();
return (
<div

View File

@ -23,6 +23,7 @@ import {
useUpdateStore,
useAccessStore,
ModalConfigValidator,
useAppConfig,
} from "../store";
import { Avatar } from "./chat";
@ -180,14 +181,13 @@ function PasswordInput(props: HTMLProps<HTMLInputElement>) {
export function Settings() {
const navigate = useNavigate();
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const [config, updateConfig, resetConfig, clearAllData, clearSessions] =
useChatStore((state) => [
state.config,
state.updateConfig,
state.resetConfig,
state.clearAllData,
state.clearSessions,
]);
const config = useAppConfig();
const updateConfig = config.update;
const resetConfig = config.reset;
const [clearAllData, clearSessions] = useChatStore((state) => [
state.clearAllData,
state.clearSessions,
]);
const updateStore = useUpdateStore();
const [checkingUpdate, setCheckingUpdate] = useState(false);
@ -645,7 +645,7 @@ export function Settings() {
value={config.modelConfig.presence_penalty?.toFixed(1)}
min="-2"
max="2"
step="0.5"
step="0.1"
onChange={(e) => {
updateConfig(
(config) =>

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from "react";
import { useEffect, useRef } from "react";
import styles from "./home.module.scss";
@ -10,7 +10,7 @@ import AddIcon from "../icons/add.svg";
import CloseIcon from "../icons/close.svg";
import Locale from "../locales";
import { useChatStore } from "../store";
import { useAppConfig, useChatStore } from "../store";
import {
MAX_SIDEBAR_WIDTH,
@ -20,16 +20,20 @@ import {
REPO_URL,
} from "../constant";
import { HashRouter as Router, Link, useNavigate } from "react-router-dom";
import { Link, useNavigate } from "react-router-dom";
import { useMobileScreen } from "../utils";
import { ChatList } from "./chat-list";
import dynamic from "next/dynamic";
const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, {
loading: () => null,
});
function useDragSideBar() {
const limit = (x: number) => Math.min(MAX_SIDEBAR_WIDTH, x);
const chatStore = useChatStore();
const config = useAppConfig();
const startX = useRef(0);
const startDragWidth = useRef(chatStore.config.sidebarWidth ?? 300);
const startDragWidth = useRef(config.sidebarWidth ?? 300);
const lastUpdateTime = useRef(Date.now());
const handleMouseMove = useRef((e: MouseEvent) => {
@ -39,11 +43,11 @@ function useDragSideBar() {
lastUpdateTime.current = Date.now();
const d = e.clientX - startX.current;
const nextWidth = limit(startDragWidth.current + d);
chatStore.updateConfig((config) => (config.sidebarWidth = nextWidth));
config.update((config) => (config.sidebarWidth = nextWidth));
});
const handleMouseUp = useRef(() => {
startDragWidth.current = chatStore.config.sidebarWidth ?? 300;
startDragWidth.current = config.sidebarWidth ?? 300;
window.removeEventListener("mousemove", handleMouseMove.current);
window.removeEventListener("mouseup", handleMouseUp.current);
});
@ -56,15 +60,15 @@ function useDragSideBar() {
};
const isMobileScreen = useMobileScreen();
const shouldNarrow =
!isMobileScreen && chatStore.config.sidebarWidth < MIN_SIDEBAR_WIDTH;
!isMobileScreen && config.sidebarWidth < MIN_SIDEBAR_WIDTH;
useEffect(() => {
const barWidth = shouldNarrow
? NARROW_SIDEBAR_WIDTH
: limit(chatStore.config.sidebarWidth ?? 300);
: limit(config.sidebarWidth ?? 300);
const sideBarWidth = isMobileScreen ? "100vw" : `${barWidth}px`;
document.documentElement.style.setProperty("--sidebar-width", sideBarWidth);
}, [chatStore.config.sidebarWidth, isMobileScreen, shouldNarrow]);
}, [config.sidebarWidth, isMobileScreen, shouldNarrow]);
return {
onDragMouseDown,

View File

@ -2,7 +2,7 @@ import styles from "./ui-lib.module.scss";
import LoadingIcon from "../icons/three-dots.svg";
import CloseIcon from "../icons/close.svg";
import { createRoot } from "react-dom/client";
import React from "react";
import React, { useEffect } from "react";
export function Popover(props: {
children: JSX.Element;
@ -64,6 +64,21 @@ interface ModalProps {
onClose?: () => void;
}
export function Modal(props: ModalProps) {
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
props.onClose?.();
}
};
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className={styles["modal-container"]}>
<div className={styles["modal-header"]}>

View File

@ -1,4 +1,4 @@
import { SubmitKey } from "../store/app";
import { SubmitKey } from "../store/config";
const cn = {
WIP: "该功能仍在开发中……",

View File

@ -1,4 +1,4 @@
import { SubmitKey } from "../store/app";
import { SubmitKey } from "../store/config";
import type { LocaleType } from "./index";
const de: LocaleType = {

View File

@ -1,4 +1,4 @@
import { SubmitKey } from "../store/app";
import { SubmitKey } from "../store/config";
import type { LocaleType } from "./index";
const en: LocaleType = {

View File

@ -1,4 +1,4 @@
import { SubmitKey } from "../store/app";
import { SubmitKey } from "../store/config";
import type { LocaleType } from "./index";
const es: LocaleType = {

View File

@ -1,4 +1,4 @@
import { SubmitKey } from "../store/app";
import { SubmitKey } from "../store/config";
import type { LocaleType } from "./index";
const it: LocaleType = {

View File

@ -1,4 +1,4 @@
import { SubmitKey } from "../store/app";
import { SubmitKey } from "../store/config";
const jp = {
WIP: "この機能は開発中です……",

View File

@ -1,4 +1,4 @@
import { SubmitKey } from "../store/app";
import { SubmitKey } from "../store/config";
import type { LocaleType } from "./index";
const tr: LocaleType = {

View File

@ -1,4 +1,4 @@
import { SubmitKey } from "../store/app";
import { SubmitKey } from "../store/config";
import type { LocaleType } from "./index";
const tw: LocaleType = {

View File

@ -4,6 +4,7 @@ import {
ModelConfig,
ModelType,
useAccessStore,
useAppConfig,
useChatStore,
} from "./store";
import { showToast } from "./components/ui-lib";
@ -27,7 +28,7 @@ const makeRequestParam = (
sendMessages = sendMessages.filter((m) => m.role !== "assistant");
}
const modelConfig = { ...useChatStore.getState().config.modelConfig };
const modelConfig = { ...useAppConfig.getState().modelConfig };
// @yidadaa: wont send max_tokens, because it is nonsense for Muggles
// @ts-expect-error
@ -149,6 +150,7 @@ export async function requestChatStream(
options?: {
filterBot?: boolean;
modelConfig?: ModelConfig;
model?: ModelType;
onMessage: (message: string, done: boolean) => void;
onError: (error: Error, statusCode?: number) => void;
onController?: (controller: AbortController) => void;
@ -157,6 +159,7 @@ export async function requestChatStream(
const req = makeRequestParam(messages, {
stream: true,
filterBot: options?.filterBot,
model: options?.model,
});
console.log("[Request] ", req);

View File

@ -11,6 +11,7 @@ import { isMobileScreen, trimTopic } from "../utils";
import Locale from "../locales";
import { showToast } from "../components/ui-lib";
import { ModelType, useAppConfig } from "./config";
export type Message = ChatCompletionResponseMessage & {
date: string;
@ -30,133 +31,8 @@ export function createMessage(override: Partial<Message>): Message {
};
}
export enum SubmitKey {
Enter = "Enter",
CtrlEnter = "Ctrl + Enter",
ShiftEnter = "Shift + Enter",
AltEnter = "Alt + Enter",
MetaEnter = "Meta + Enter",
}
export enum Theme {
Auto = "auto",
Dark = "dark",
Light = "light",
}
export interface ChatConfig {
historyMessageCount: number; // -1 means all
compressMessageLengthThreshold: number;
sendBotMessages: boolean; // send bot's message or not
submitKey: SubmitKey;
avatar: string;
fontSize: number;
theme: Theme;
tightBorder: boolean;
sendPreviewBubble: boolean;
sidebarWidth: number;
disablePromptHint: boolean;
modelConfig: {
model: ModelType;
temperature: number;
max_tokens: number;
presence_penalty: number;
};
}
export type ModelConfig = ChatConfig["modelConfig"];
export const ROLES: Message["role"][] = ["system", "user", "assistant"];
const ENABLE_GPT4 = true;
export const ALL_MODELS = [
{
name: "gpt-4",
available: ENABLE_GPT4,
},
{
name: "gpt-4-0314",
available: ENABLE_GPT4,
},
{
name: "gpt-4-32k",
available: ENABLE_GPT4,
},
{
name: "gpt-4-32k-0314",
available: ENABLE_GPT4,
},
{
name: "gpt-3.5-turbo",
available: true,
},
{
name: "gpt-3.5-turbo-0301",
available: true,
},
] as const;
export type ModelType = (typeof ALL_MODELS)[number]["name"];
export function limitNumber(
x: number,
min: number,
max: number,
defaultValue: number,
) {
if (typeof x !== "number" || isNaN(x)) {
return defaultValue;
}
return Math.min(max, Math.max(min, x));
}
export function limitModel(name: string) {
return ALL_MODELS.some((m) => m.name === name && m.available)
? name
: ALL_MODELS[4].name;
}
export const ModalConfigValidator = {
model(x: string) {
return limitModel(x) as ModelType;
},
max_tokens(x: number) {
return limitNumber(x, 0, 32000, 2000);
},
presence_penalty(x: number) {
return limitNumber(x, -2, 2, 0);
},
temperature(x: number) {
return limitNumber(x, 0, 2, 1);
},
};
const DEFAULT_CONFIG: ChatConfig = {
historyMessageCount: 4,
compressMessageLengthThreshold: 1000,
sendBotMessages: true as boolean,
submitKey: SubmitKey.CtrlEnter as SubmitKey,
avatar: "1f603",
fontSize: 14,
theme: Theme.Auto as Theme,
tightBorder: false,
sendPreviewBubble: true,
sidebarWidth: 300,
disablePromptHint: false,
modelConfig: {
model: "gpt-3.5-turbo",
temperature: 1,
max_tokens: 2000,
presence_penalty: 0,
},
};
export interface ChatStat {
tokenCount: number;
wordCount: number;
@ -202,7 +78,6 @@ function createEmptySession(): ChatSession {
}
interface ChatStore {
config: ChatConfig;
sessions: ChatSession[];
currentSessionIndex: number;
clearSessions: () => void;
@ -226,9 +101,6 @@ interface ChatStore {
getMessagesWithMemory: () => Message[];
getMemoryPrompt: () => Message;
getConfig: () => ChatConfig;
resetConfig: () => void;
updateConfig: (updater: (config: ChatConfig) => void) => void;
clearAllData: () => void;
}
@ -243,9 +115,6 @@ export const useChatStore = create<ChatStore>()(
(set, get) => ({
sessions: [createEmptySession()],
currentSessionIndex: 0,
config: {
...DEFAULT_CONFIG,
},
clearSessions() {
set(() => ({
@ -254,20 +123,6 @@ export const useChatStore = create<ChatStore>()(
}));
},
resetConfig() {
set(() => ({ config: { ...DEFAULT_CONFIG } }));
},
getConfig() {
return get().config;
},
updateConfig(updater) {
const config = get().config;
updater(config);
set(() => ({ config }));
},
selectSession(index: number) {
set({
currentSessionIndex: index,
@ -390,7 +245,7 @@ export const useChatStore = create<ChatStore>()(
role: "assistant",
streaming: true,
id: userMessage.id! + 1,
model: get().config.modelConfig.model,
model: useAppConfig.getState().modelConfig.model,
});
// get recent messages
@ -443,8 +298,8 @@ export const useChatStore = create<ChatStore>()(
controller,
);
},
filterBot: !get().config.sendBotMessages,
modelConfig: get().config.modelConfig,
filterBot: !useAppConfig.getState().sendBotMessages,
modelConfig: useAppConfig.getState().modelConfig,
});
},
@ -460,7 +315,7 @@ export const useChatStore = create<ChatStore>()(
getMessagesWithMemory() {
const session = get().currentSession();
const config = get().config;
const config = useAppConfig.getState();
const messages = session.messages.filter((msg) => !msg.isError);
const n = messages.length;
@ -545,14 +400,14 @@ export const useChatStore = create<ChatStore>()(
});
}
const config = get().config;
const config = useAppConfig.getState();
let toBeSummarizedMsgs = session.messages.slice(
session.lastSummarizeIndex,
);
const historyMsgLength = countMessages(toBeSummarizedMsgs);
if (historyMsgLength > get().config?.modelConfig?.max_tokens ?? 4000) {
if (historyMsgLength > config?.modelConfig?.max_tokens ?? 4000) {
const n = toBeSummarizedMsgs.length;
toBeSummarizedMsgs = toBeSummarizedMsgs.slice(
Math.max(0, n - config.historyMessageCount),
@ -583,6 +438,7 @@ export const useChatStore = create<ChatStore>()(
}),
{
filterBot: false,
model: "gpt-3.5-turbo",
onMessage(message, done) {
session.memoryPrompt = message;
if (done) {

135
app/store/config.ts Normal file
View File

@ -0,0 +1,135 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
export enum SubmitKey {
Enter = "Enter",
CtrlEnter = "Ctrl + Enter",
ShiftEnter = "Shift + Enter",
AltEnter = "Alt + Enter",
MetaEnter = "Meta + Enter",
}
export enum Theme {
Auto = "auto",
Dark = "dark",
Light = "light",
}
const DEFAULT_CONFIG = {
historyMessageCount: 4,
compressMessageLengthThreshold: 1000,
sendBotMessages: true as boolean,
submitKey: SubmitKey.CtrlEnter as SubmitKey,
avatar: "1f603",
fontSize: 14,
theme: Theme.Auto as Theme,
tightBorder: false,
sendPreviewBubble: true,
sidebarWidth: 300,
disablePromptHint: false,
modelConfig: {
model: "gpt-3.5-turbo" as ModelType,
temperature: 1,
max_tokens: 2000,
presence_penalty: 0,
},
};
export type ChatConfig = typeof DEFAULT_CONFIG;
export type ChatConfigStore = ChatConfig & {
reset: () => void;
update: (updater: (config: ChatConfig) => void) => void;
};
export type ModelConfig = ChatConfig["modelConfig"];
const ENABLE_GPT4 = true;
export const ALL_MODELS = [
{
name: "gpt-4",
available: ENABLE_GPT4,
},
{
name: "gpt-4-0314",
available: ENABLE_GPT4,
},
{
name: "gpt-4-32k",
available: ENABLE_GPT4,
},
{
name: "gpt-4-32k-0314",
available: ENABLE_GPT4,
},
{
name: "gpt-3.5-turbo",
available: true,
},
{
name: "gpt-3.5-turbo-0301",
available: true,
},
] as const;
export type ModelType = (typeof ALL_MODELS)[number]["name"];
export function limitNumber(
x: number,
min: number,
max: number,
defaultValue: number,
) {
if (typeof x !== "number" || isNaN(x)) {
return defaultValue;
}
return Math.min(max, Math.max(min, x));
}
export function limitModel(name: string) {
return ALL_MODELS.some((m) => m.name === name && m.available)
? name
: ALL_MODELS[4].name;
}
export const ModalConfigValidator = {
model(x: string) {
return limitModel(x) as ModelType;
},
max_tokens(x: number) {
return limitNumber(x, 0, 32000, 2000);
},
presence_penalty(x: number) {
return limitNumber(x, -2, 2, 0);
},
temperature(x: number) {
return limitNumber(x, 0, 2, 1);
},
};
const CONFIG_KEY = "app-config";
export const useAppConfig = create<ChatConfigStore>()(
persist(
(set, get) => ({
...DEFAULT_CONFIG,
reset() {
set(() => ({ ...DEFAULT_CONFIG }));
},
update(updater) {
const config = { ...get() };
updater(config);
set(() => config);
},
}),
{
name: CONFIG_KEY,
},
),
);

View File

@ -1,3 +1,4 @@
export * from "./app";
export * from "./update";
export * from "./access";
export * from "./config";

View File

@ -10,10 +10,20 @@ const RAW_EN_URL = "f/awesome-chatgpt-prompts/main/prompts.csv";
const EN_URL = MIRRORF_FILE_URL + RAW_EN_URL;
const FILE = "./public/prompts.json";
const timeoutPromise = (timeout) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error('Request timeout'));
}, timeout);
});
};
async function fetchCN() {
console.log("[Fetch] fetching cn prompts...");
try {
const raw = await (await fetch(CN_URL)).json();
// const raw = await (await fetch(CN_URL)).json();
const response = await Promise.race([fetch(CN_URL), timeoutPromise(5000)]);
const raw = await response.json();
return raw.map((v) => [v.act, v.prompt]);
} catch (error) {
console.error("[Fetch] failed to fetch cn prompts", error);
@ -24,13 +34,15 @@ async function fetchCN() {
async function fetchEN() {
console.log("[Fetch] fetching en prompts...");
try {
const raw = await (await fetch(EN_URL)).text();
// const raw = await (await fetch(EN_URL)).text();
const response = await Promise.race([fetch(EN_URL), timeoutPromise(5000)]);
const raw = await response.text();
return raw
.split("\n")
.slice(1)
.map((v) => v.split('","').map((v) => v.replace('"', "")));
} catch (error) {
console.error("[Fetch] failed to fetch cn prompts", error);
console.error("[Fetch] failed to fetch en prompts", error);
return [];
}
}

View File

@ -29,13 +29,13 @@ esac
if ! command -v node >/dev/null || ! command -v git >/dev/null || ! command -v yarn >/dev/null; then
case "$(uname -s)" in
Linux)
if [[ "$(cat /etc/*-release | grep '^ID=')" = "ID=\"ubuntu\"" ]]; then
if [[ "$(cat /etc/*-release | grep '^ID=')" = "ID=ubuntu" ]]; then
sudo apt-get update
sudo apt-get -y install nodejs git yarn
elif [[ "$(cat /etc/*-release | grep '^ID=')" = "ID=\"centos\"" ]]; then
elif [[ "$(cat /etc/*-release | grep '^ID=')" = "ID=centos" ]]; then
sudo yum -y install epel-release
sudo yum -y install nodejs git yarn
elif [[ "$(cat /etc/*-release | grep '^ID=')" = "ID=\"arch\"" ]]; then
elif [[ "$(cat /etc/*-release | grep '^ID=')" = "ID=arch" ]]; then
sudo pacman -Syu -y
sudo pacman -S -y nodejs git yarn
else