diff --git a/electron/core/plugins/data-plugin/module.ts b/electron/core/plugins/data-plugin/module.ts index ce0e64770..3f01fa7be 100644 --- a/electron/core/plugins/data-plugin/module.ts +++ b/electron/core/plugins/data-plugin/module.ts @@ -168,7 +168,7 @@ function getFinishedDownloadModels() { const query = `SELECT * FROM models WHERE finish_download_at != -1 ORDER BY finish_download_at DESC`; db.all(query, (err: Error, row: any) => { - res(row); + res(row.map((item: any) => parseToProduct(item))); }); db.close(); }); @@ -184,6 +184,7 @@ function deleteDownloadModel(modelId: string) { const stmt = db.prepare("DELETE FROM models WHERE id = ?"); stmt.run(modelId); stmt.finalize(); + res(modelId); }); db.close(); @@ -352,7 +353,7 @@ function deleteConversation(id: any) { ); deleteMessages.run(id); deleteMessages.finalize(); - res([]); + res(id); }); db.close(); @@ -373,6 +374,31 @@ function getConversationMessages(conversation_id: any) { }); } +function parseToProduct(row: any) { + const product = { + id: row.id, + slug: row.slug, + name: row.name, + description: row.description, + avatarUrl: row.avatar_url, + longDescription: row.long_description, + technicalDescription: row.technical_description, + author: row.author, + version: row.version, + modelUrl: row.model_url, + nsfw: row.nsfw, + greeting: row.greeting, + type: row.type, + inputs: row.inputs, + outputs: row.outputs, + createdAt: new Date(row.created_at), + updatedAt: new Date(row.updated_at), + fileName: row.file_name, + downloadUrl: row.download_url, + }; + return product; +} + module.exports = { init, getConversations, diff --git a/electron/core/plugins/inference-plugin/module.js b/electron/core/plugins/inference-plugin/module.js index ea8e097a8..44205277b 100644 --- a/electron/core/plugins/inference-plugin/module.js +++ b/electron/core/plugins/inference-plugin/module.js @@ -2,18 +2,9 @@ const path = require("path"); const { app, dialog } = require("electron"); const { spawn } = require("child_process"); const fs = require("fs"); -var exec = require("child_process").exec; let subprocess = null; -process.on("exit", () => { - // Perform cleanup tasks here - console.log("kill subprocess on exit"); - if (subprocess) { - subprocess.kill(); - } -}); - async function initModel(product) { // fileName fallback if (!product.fileName) { @@ -63,7 +54,7 @@ async function initModel(product) { : path.join(binaryFolder, "nitro"); // Execute the binary - subprocess = spawn(binaryPath, [configFilePath], {cwd: binaryFolder}); + subprocess = spawn(binaryPath, [configFilePath], { cwd: binaryFolder }); // Handle subprocess output subprocess.stdout.on("data", (data) => { @@ -80,7 +71,7 @@ async function initModel(product) { }); } -function killSubprocess() { +function dispose() { if (subprocess) { subprocess.kill(); subprocess = null; @@ -92,5 +83,5 @@ function killSubprocess() { module.exports = { initModel, - killSubprocess, + dispose, }; diff --git a/electron/main.ts b/electron/main.ts index 9b8d42a92..b4c1eab5a 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,30 +1,42 @@ -import { - app, - BrowserWindow, - screen as electronScreen, - ipcMain, - dialog, - shell, -} from "electron"; +import { app, BrowserWindow, ipcMain, dialog, shell } from "electron"; import { readdirSync } from "fs"; import { resolve, join, extname } from "path"; import { rmdir, unlink, createWriteStream } from "fs"; -import isDev = require("electron-is-dev"); import { init } from "./core/plugin-manager/pluginMgr"; +import { setupMenu } from "./utils/menu"; +import { dispose } from "./utils/disposable"; + +const isDev = require("electron-is-dev"); +const request = require("request"); +const progress = require("request-progress"); const { autoUpdater } = require("electron-updater"); const Store = require("electron-store"); -// @ts-ignore -import request = require("request"); -// @ts-ignore -import progress = require("request-progress"); +const requiredModules: Record = {}; let mainWindow: BrowserWindow | undefined = undefined; -const store = new Store(); -autoUpdater.autoDownload = false; -autoUpdater.autoInstallOnAppQuit = true; +app + .whenReady() + .then(migratePlugins) + .then(setupPlugins) + .then(setupMenu) + .then(handleIPCs) + .then(handleAppUpdates) + .then(createMainWindow) + .then(() => { + app.on("activate", () => { + if (!BrowserWindow.getAllWindows().length) { + createMainWindow(); + } + }); + }); -const createMainWindow = () => { +app.on("window-all-closed", () => { + dispose(requiredModules); + app.quit(); +}); + +function createMainWindow() { mainWindow = new BrowserWindow({ width: 1200, height: 800, @@ -37,26 +49,6 @@ const createMainWindow = () => { }, }); - ipcMain.handle( - "invokePluginFunc", - async (_event, modulePath, method, ...args) => { - const module = join(app.getPath("userData"), "plugins", modulePath); - return await import(/* webpackIgnore: true */ module) - .then((plugin) => { - if (typeof plugin[method] === "function") { - return plugin[method](...args); - } else { - console.log(plugin[method]); - console.error(`Function "${method}" does not exist in the module.`); - } - }) - .then((res) => { - return res; - }) - .catch((err) => console.log(err)); - } - ); - const startURL = isDev ? "http://localhost:3000" : `file://${join(__dirname, "../renderer/index.html")}`; @@ -69,139 +61,143 @@ const createMainWindow = () => { }); if (isDev) mainWindow.webContents.openDevTools(); -}; +} -app - .whenReady() - .then(migratePlugins) - .then(() => { - createMainWindow(); - setupPlugins(); - autoUpdater.checkForUpdates(); - - ipcMain.handle("basePlugins", async (event) => { - const basePluginPath = join( - __dirname, - "../", - isDev ? "/core/pre-install" : "../app.asar.unpacked/core/pre-install" - ); - return readdirSync(basePluginPath) - .filter((file) => extname(file) === ".tgz") - .map((file) => join(basePluginPath, file)); +function handleAppUpdates() { + /*New Update Available*/ + autoUpdater.on("update-available", async (_info: any) => { + const action = await dialog.showMessageBox({ + message: `Update available. Do you want to download the latest update?`, + buttons: ["Download", "Later"], }); + if (action.response === 0) await autoUpdater.downloadUpdate(); + }); - ipcMain.handle("pluginPath", async (event) => { - return join(app.getPath("userData"), "plugins"); + /*App Update Completion Message*/ + autoUpdater.on("update-downloaded", async (_info: any) => { + mainWindow?.webContents.send("APP_UPDATE_COMPLETE", {}); + const action = await dialog.showMessageBox({ + message: `Update downloaded. Please restart the application to apply the updates.`, + buttons: ["Restart", "Later"], }); - ipcMain.handle("appVersion", async (event) => { - return app.getVersion(); - }); - ipcMain.handle("openExternalUrl", async (event, url) => { - shell.openExternal(url); + if (action.response === 0) { + autoUpdater.quitAndInstall(); + } + }); + + /*App Update Error */ + autoUpdater.on("error", (info: any) => { + dialog.showMessageBox({ message: info.message }); + mainWindow?.webContents.send("APP_UPDATE_ERROR", {}); + }); + + /*App Update Progress */ + autoUpdater.on("download-progress", (progress: any) => { + console.log("app update progress: ", progress.percent); + mainWindow?.webContents.send("APP_UPDATE_PROGRESS", { + percent: progress.percent, }); + }); + autoUpdater.autoDownload = false; + autoUpdater.autoInstallOnAppQuit = true; + autoUpdater.checkForUpdates(); +} - /** - * Used to delete a file from the user data folder - */ - ipcMain.handle("deleteFile", async (_event, filePath) => { - const userDataPath = app.getPath("userData"); - const fullPath = join(userDataPath, filePath); +function handleIPCs() { + ipcMain.handle( + "invokePluginFunc", + async (_event, modulePath, method, ...args) => { + const module = require(/* webpackIgnore: true */ join( + app.getPath("userData"), + "plugins", + modulePath + )); + requiredModules[modulePath] = module; - let result = "NULL"; - unlink(fullPath, function (err) { - if (err && err.code == "ENOENT") { - result = `File not exist: ${err}`; - } else if (err) { - result = `File delete error: ${err}`; - } else { - result = "File deleted successfully"; - } - console.log( - `Delete file ${filePath} from ${fullPath} result: ${result}` - ); - }); - - return result; - }); - - /** - * Used to download a file from a given url - */ - ipcMain.handle("downloadFile", async (_event, url, fileName) => { - const userDataPath = app.getPath("userData"); - const destination = resolve(userDataPath, fileName); - - progress(request(url), {}) - .on("progress", function (state: any) { - mainWindow?.webContents.send("FILE_DOWNLOAD_UPDATE", { - ...state, - fileName, - }); - }) - .on("error", function (err: Error) { - mainWindow?.webContents.send("FILE_DOWNLOAD_ERROR", { - fileName, - err, - }); - }) - .on("end", function () { - mainWindow?.webContents.send("FILE_DOWNLOAD_COMPLETE", { - fileName, - }); - }) - .pipe(createWriteStream(destination)); - }); - - app.on("activate", () => { - if (!BrowserWindow.getAllWindows().length) { - createMainWindow(); + if (typeof module[method] === "function") { + return module[method](...args); + } else { + console.log(module[method]); + console.error(`Function "${method}" does not exist in the module.`); } + } + ); + + ipcMain.handle("basePlugins", async (_event) => { + const basePluginPath = join( + __dirname, + "../", + isDev ? "/core/pre-install" : "../app.asar.unpacked/core/pre-install" + ); + return readdirSync(basePluginPath) + .filter((file) => extname(file) === ".tgz") + .map((file) => join(basePluginPath, file)); + }); + + ipcMain.handle("pluginPath", async (_event) => { + return join(app.getPath("userData"), "plugins"); + }); + ipcMain.handle("appVersion", async (_event) => { + return app.getVersion(); + }); + ipcMain.handle("openExternalUrl", async (_event, url) => { + shell.openExternal(url); + }); + + /** + * Used to delete a file from the user data folder + */ + ipcMain.handle("deleteFile", async (_event, filePath) => { + const userDataPath = app.getPath("userData"); + const fullPath = join(userDataPath, filePath); + + let result = "NULL"; + unlink(fullPath, function (err) { + if (err && err.code == "ENOENT") { + result = `File not exist: ${err}`; + } else if (err) { + result = `File delete error: ${err}`; + } else { + result = "File deleted successfully"; + } + console.log(`Delete file ${filePath} from ${fullPath} result: ${result}`); }); + + return result; }); -/*New Update Available*/ -autoUpdater.on("update-available", async (info: any) => { - const action = await dialog.showMessageBox({ - message: `Update available. Do you want to download the latest update?`, - buttons: ["Download", "Later"], + /** + * Used to download a file from a given url + */ + ipcMain.handle("downloadFile", async (_event, url, fileName) => { + const userDataPath = app.getPath("userData"); + const destination = resolve(userDataPath, fileName); + + progress(request(url), {}) + .on("progress", function (state: any) { + mainWindow?.webContents.send("FILE_DOWNLOAD_UPDATE", { + ...state, + fileName, + }); + }) + .on("error", function (err: Error) { + mainWindow?.webContents.send("FILE_DOWNLOAD_ERROR", { + fileName, + err, + }); + }) + .on("end", function () { + mainWindow?.webContents.send("FILE_DOWNLOAD_COMPLETE", { + fileName, + }); + }) + .pipe(createWriteStream(destination)); }); - if (action.response === 0) await autoUpdater.downloadUpdate(); -}); - -/*App Update Completion Message*/ -autoUpdater.on("update-downloaded", async (info: any) => { - mainWindow?.webContents.send("APP_UPDATE_COMPLETE", {}); - const action = await dialog.showMessageBox({ - message: `Update downloaded. Please restart the application to apply the updates.`, - buttons: ["Restart", "Later"], - }); - if (action.response === 0) { - autoUpdater.quitAndInstall(); - } -}); - -/*App Update Error */ -autoUpdater.on("error", (info: any) => { - dialog.showMessageBox({ message: info.message }); - mainWindow?.webContents.send("APP_UPDATE_ERROR", {}); -}); - -/*App Update Progress */ -autoUpdater.on("download-progress", (progress: any) => { - console.log("app update progress: ", progress.percent); - mainWindow?.webContents.send("APP_UPDATE_PROGRESS", { - percent: progress.percent, - }); -}); - -app.on("window-all-closed", () => { - if (process.platform !== "darwin") { - app.quit(); - } -}); +} function migratePlugins() { return new Promise((resolve) => { + const store = new Store(); if (store.get("migrated_version") !== app.getVersion()) { console.log("start migration:", store.get("migrated_version")); const userDataPath = app.getPath("userData"); @@ -217,12 +213,12 @@ function migratePlugins() { resolve(undefined); } }); -}; +} function setupPlugins() { init({ // Function to check from the main process that user wants to install a plugin - confirmInstall: async (plugins: string[]) => { + confirmInstall: async (_plugins: string[]) => { return true; }, // Path to install plugin to diff --git a/electron/package.json b/electron/package.json index 6a79e442a..f254dea78 100644 --- a/electron/package.json +++ b/electron/package.json @@ -11,7 +11,7 @@ "files": [ "renderer/**/*", "build/*.{js,map}", - "build/core/plugin-manager/**/*", + "build/**/*.{js,map}", "core/pre-install" ], "asarUnpack": [ diff --git a/electron/utils/disposable.ts b/electron/utils/disposable.ts new file mode 100644 index 000000000..462f7e3e5 --- /dev/null +++ b/electron/utils/disposable.ts @@ -0,0 +1,8 @@ +export function dispose(requiredModules: Record) { + for (const key in requiredModules) { + const module = requiredModules[key]; + if (typeof module["dispose"] === "function") { + module["dispose"](); + } + } +} diff --git a/electron/utils/menu.ts b/electron/utils/menu.ts new file mode 100644 index 000000000..65e009aef --- /dev/null +++ b/electron/utils/menu.ts @@ -0,0 +1,111 @@ +// @ts-nocheck +const { app, Menu, dialog } = require("electron"); +const isMac = process.platform === "darwin"; +const { autoUpdater } = require("electron-updater"); +import { compareSemanticVersions } from "./versionDiff"; + +const template: (Electron.MenuItemConstructorOptions | Electron.MenuItem)[] = [ + ...(isMac + ? [ + { + label: app.name, + submenu: [ + { role: "about" }, + { + label: "Check for Updates...", + click: () => + autoUpdater.checkForUpdatesAndNotify().then((e) => { + if ( + !e || + compareSemanticVersions( + app.getVersion(), + e.updateInfo.version + ) >= 0 + ) + dialog.showMessageBox({ + message: `There are currently no updates available.`, + }); + }), + }, + { type: "separator" }, + { role: "services" }, + { type: "separator" }, + { role: "hide" }, + { role: "hideOthers" }, + { role: "unhide" }, + { type: "separator" }, + { role: "quit" }, + ], + }, + ] + : []), + { + label: "Edit", + submenu: [ + { role: "undo" }, + { role: "redo" }, + { type: "separator" }, + { role: "cut" }, + { role: "copy" }, + { role: "paste" }, + ...(isMac + ? [ + { role: "pasteAndMatchStyle" }, + { role: "delete" }, + { role: "selectAll" }, + { type: "separator" }, + { + label: "Speech", + submenu: [{ role: "startSpeaking" }, { role: "stopSpeaking" }], + }, + ] + : [{ role: "delete" }, { type: "separator" }, { role: "selectAll" }]), + ], + }, + { + label: "View", + submenu: [ + { role: "reload" }, + { role: "forceReload" }, + { role: "toggleDevTools" }, + { type: "separator" }, + { role: "resetZoom" }, + { role: "zoomIn" }, + { role: "zoomOut" }, + { type: "separator" }, + { role: "togglefullscreen" }, + ], + }, + { + label: "Window", + submenu: [ + { role: "minimize" }, + { role: "zoom" }, + ...(isMac + ? [ + { type: "separator" }, + { role: "front" }, + { type: "separator" }, + { role: "window" }, + ] + : [{ role: "close" }]), + ], + }, + { + role: "help", + submenu: [ + { + label: "Learn More", + click: async () => { + const { shell } = require("electron"); + await shell.openExternal("https://jan.ai/"); + }, + }, + ], + }, +]; + +export const setupMenu = () => { + const menu = Menu.buildFromTemplate(template); + Menu.setApplicationMenu(menu); +}; diff --git a/electron/utils/versionDiff.ts b/electron/utils/versionDiff.ts new file mode 100644 index 000000000..25934e87f --- /dev/null +++ b/electron/utils/versionDiff.ts @@ -0,0 +1,21 @@ +export const compareSemanticVersions = (a: string, b: string) => { + + // 1. Split the strings into their parts. + const a1 = a.split('.'); + const b1 = b.split('.'); + // 2. Contingency in case there's a 4th or 5th version + const len = Math.min(a1.length, b1.length); + // 3. Look through each version number and compare. + for (let i = 0; i < len; i++) { + const a2 = +a1[ i ] || 0; + const b2 = +b1[ i ] || 0; + + if (a2 !== b2) { + return a2 > b2 ? 1 : -1; + } + } + + // 4. We hit this if the all checked versions so far are equal + // + return b1.length - a1.length; +}; \ No newline at end of file diff --git a/web/app/_components/ActiveModelTable/index.tsx b/web/app/_components/ActiveModelTable/index.tsx new file mode 100644 index 000000000..e2e3e791e --- /dev/null +++ b/web/app/_components/ActiveModelTable/index.tsx @@ -0,0 +1,19 @@ +import { useAtomValue } from "jotai"; +import React, { Fragment } from "react"; +import ModelTable from "../ModelTable"; +import { currentProductAtom } from "@/_helpers/atoms/Model.atom"; + +const ActiveModelTable: React.FC = () => { + const activeModel = useAtomValue(currentProductAtom); + + if (!activeModel) return null; + + return ( + +

Active Model(s)

+ +
+ ); +}; + +export default ActiveModelTable; diff --git a/web/app/_components/AvailableModelCard/index.tsx b/web/app/_components/AvailableModelCard/index.tsx index 64fc0f979..9f6a25741 100644 --- a/web/app/_components/AvailableModelCard/index.tsx +++ b/web/app/_components/AvailableModelCard/index.tsx @@ -2,9 +2,8 @@ import { Product } from "@/_models/Product"; import DownloadModelContent from "../DownloadModelContent"; import ModelDownloadButton from "../ModelDownloadButton"; import ModelDownloadingButton from "../ModelDownloadingButton"; -import ViewModelDetailButton from "../ViewModelDetailButton"; import { useAtomValue } from "jotai"; -import { modelDownloadStateAtom } from "@/_helpers/JotaiWrapper"; +import { modelDownloadStateAtom } from "@/_helpers/atoms/DownloadState.atom"; type Props = { product: Product; @@ -36,8 +35,6 @@ const AvailableModelCard: React.FC = ({ } } - const handleViewDetails = () => {}; - const downloadButton = isDownloading ? (
diff --git a/web/app/_components/BasicPromptAccessories/index.tsx b/web/app/_components/BasicPromptAccessories/index.tsx index 6d57bdfde..332b9625e 100644 --- a/web/app/_components/BasicPromptAccessories/index.tsx +++ b/web/app/_components/BasicPromptAccessories/index.tsx @@ -1,9 +1,9 @@ "use client"; -import { showingAdvancedPromptAtom } from "@/_helpers/JotaiWrapper"; import { useSetAtom } from "jotai"; import SecondaryButton from "../SecondaryButton"; import SendButton from "../SendButton"; +import { showingAdvancedPromptAtom } from "@/_helpers/atoms/Modal.atom"; const BasicPromptAccessories: React.FC = () => { const setShowingAdvancedPrompt = useSetAtom(showingAdvancedPromptAtom); diff --git a/web/app/_components/BasicPromptButton/index.tsx b/web/app/_components/BasicPromptButton/index.tsx index 3a332e022..898c38d10 100644 --- a/web/app/_components/BasicPromptButton/index.tsx +++ b/web/app/_components/BasicPromptButton/index.tsx @@ -1,7 +1,7 @@ import React from "react"; import { useSetAtom } from "jotai"; import { ChevronLeftIcon } from "@heroicons/react/24/outline"; -import { showingAdvancedPromptAtom } from "@/_helpers/JotaiWrapper"; +import { showingAdvancedPromptAtom } from "@/_helpers/atoms/Modal.atom"; const BasicPromptButton: React.FC = () => { const setShowingAdvancedPrompt = useSetAtom(showingAdvancedPromptAtom); diff --git a/web/app/_components/BasicPromptInput/index.tsx b/web/app/_components/BasicPromptInput/index.tsx index ad23c3f95..7ff99d540 100644 --- a/web/app/_components/BasicPromptInput/index.tsx +++ b/web/app/_components/BasicPromptInput/index.tsx @@ -1,22 +1,45 @@ "use client"; import { currentPromptAtom } from "@/_helpers/JotaiWrapper"; +import { getActiveConvoIdAtom } from "@/_helpers/atoms/Conversation.atom"; +import { selectedModelAtom } from "@/_helpers/atoms/Model.atom"; +import useCreateConversation from "@/_hooks/useCreateConversation"; +import useInitModel from "@/_hooks/useInitModel"; import useSendChatMessage from "@/_hooks/useSendChatMessage"; -import { useAtom } from "jotai"; +import { useAtom, useAtomValue } from "jotai"; +import { ChangeEvent } from "react"; const BasicPromptInput: React.FC = () => { + const activeConversationId = useAtomValue(getActiveConvoIdAtom); + const selectedModel = useAtomValue(selectedModelAtom); const [currentPrompt, setCurrentPrompt] = useAtom(currentPromptAtom); const { sendChatMessage } = useSendChatMessage(); + const { requestCreateConvo } = useCreateConversation(); - const handleMessageChange = (event: any) => { + const { initModel } = useInitModel(); + + const handleMessageChange = (event: ChangeEvent) => { setCurrentPrompt(event.target.value); }; - const handleKeyDown = (event: any) => { + const handleKeyDown = async ( + event: React.KeyboardEvent + ) => { if (event.key === "Enter") { if (!event.shiftKey) { - event.preventDefault(); - sendChatMessage(); + if (activeConversationId) { + event.preventDefault(); + sendChatMessage(); + } else { + if (!selectedModel) { + console.log("No model selected"); + return; + } + + await requestCreateConvo(selectedModel); + await initModel(selectedModel); + sendChatMessage(); + } } } }; diff --git a/web/app/_components/ChatBody/index.tsx b/web/app/_components/ChatBody/index.tsx index 4ec776e78..16f739f77 100644 --- a/web/app/_components/ChatBody/index.tsx +++ b/web/app/_components/ChatBody/index.tsx @@ -4,14 +4,12 @@ import React, { useCallback, useRef, useState } from "react"; import ChatItem from "../ChatItem"; import { ChatMessage } from "@/_models/ChatMessage"; import useChatMessages from "@/_hooks/useChatMessages"; -import { - chatMessages, - getActiveConvoIdAtom, - showingTyping, -} from "@/_helpers/JotaiWrapper"; +import { showingTyping } from "@/_helpers/JotaiWrapper"; import { useAtomValue } from "jotai"; import { selectAtom } from "jotai/utils"; import LoadingIndicator from "../LoadingIndicator"; +import { getActiveConvoIdAtom } from "@/_helpers/atoms/Conversation.atom"; +import { chatMessages } from "@/_helpers/atoms/ChatMessage.atom"; const ChatBody: React.FC = () => { const activeConversationId = useAtomValue(getActiveConvoIdAtom) ?? ""; diff --git a/web/app/_components/ChatBody/renderChatMessage.tsx b/web/app/_components/ChatBody/renderChatMessage.tsx index cebaa62ee..f0b640cd5 100644 --- a/web/app/_components/ChatBody/renderChatMessage.tsx +++ b/web/app/_components/ChatBody/renderChatMessage.tsx @@ -4,7 +4,7 @@ import SimpleTextMessage from "../SimpleTextMessage"; import { ChatMessage, MessageType } from "@/_models/ChatMessage"; import StreamTextMessage from "../StreamTextMessage"; import { useAtomValue } from "jotai"; -import { currentStreamingMessageAtom } from "@/_helpers/JotaiWrapper"; +import { currentStreamingMessageAtom } from "@/_helpers/atoms/ChatMessage.atom"; export default function renderChatMessage({ id, diff --git a/web/app/_components/ChatContainer/index.tsx b/web/app/_components/ChatContainer/index.tsx index 46e740f90..9a002ab13 100644 --- a/web/app/_components/ChatContainer/index.tsx +++ b/web/app/_components/ChatContainer/index.tsx @@ -1,11 +1,16 @@ "use client"; import { useAtomValue } from "jotai"; -import { MainViewState, getMainViewStateAtom } from "@/_helpers/JotaiWrapper"; import { ReactNode } from "react"; -import ModelManagement from "../ModelManagement"; import Welcome from "../WelcomeContainer"; import { Preferences } from "../Preferences"; +import MyModelContainer from "../MyModelContainer"; +import ExploreModelContainer from "../ExploreModelContainer"; +import { + MainViewState, + getMainViewStateAtom, +} from "@/_helpers/atoms/MainView.atom"; +import EmptyChatContainer from "../EmptyChatContainer"; type Props = { children: ReactNode; @@ -15,12 +20,15 @@ export default function ChatContainer({ children }: Props) { const viewState = useAtomValue(getMainViewStateAtom); switch (viewState) { + case MainViewState.ConversationEmptyModel: + return case MainViewState.ExploreModel: - return ; + return ; case MainViewState.Setting: return ; case MainViewState.ResourceMonitor: case MainViewState.MyModel: + return ; case MainViewState.Welcome: return ; default: diff --git a/web/app/_components/CompactLogo/index.tsx b/web/app/_components/CompactLogo/index.tsx index 5c20183d0..0035d004f 100644 --- a/web/app/_components/CompactLogo/index.tsx +++ b/web/app/_components/CompactLogo/index.tsx @@ -1,7 +1,7 @@ import React from "react"; import JanImage from "../JanImage"; -import { setActiveConvoIdAtom } from "@/_helpers/JotaiWrapper"; import { useSetAtom } from "jotai"; +import { setActiveConvoIdAtom } from "@/_helpers/atoms/Conversation.atom"; const CompactLogo: React.FC = () => { const setActiveConvoId = useSetAtom(setActiveConvoIdAtom); diff --git a/web/app/_components/ConfirmDeleteConversationModal/index.tsx b/web/app/_components/ConfirmDeleteConversationModal/index.tsx index baa7ac471..4904bc454 100644 --- a/web/app/_components/ConfirmDeleteConversationModal/index.tsx +++ b/web/app/_components/ConfirmDeleteConversationModal/index.tsx @@ -1,4 +1,4 @@ -import { showConfirmDeleteConversationModalAtom } from "@/_helpers/JotaiWrapper"; +import { showConfirmDeleteConversationModalAtom } from "@/_helpers/atoms/Modal.atom"; import useDeleteConversation from "@/_hooks/useDeleteConversation"; import { Dialog, Transition } from "@headlessui/react"; import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; diff --git a/web/app/_components/ConfirmDeleteModelModal/index.tsx b/web/app/_components/ConfirmDeleteModelModal/index.tsx index 76c862b0f..451eddb60 100644 --- a/web/app/_components/ConfirmDeleteModelModal/index.tsx +++ b/web/app/_components/ConfirmDeleteModelModal/index.tsx @@ -1,17 +1,13 @@ import React, { Fragment } from "react"; import { Dialog, Transition } from "@headlessui/react"; import { QuestionMarkCircleIcon } from "@heroicons/react/24/outline"; -import { showConfirmDeleteModalAtom } from "@/_helpers/JotaiWrapper"; import { useAtom } from "jotai"; -import useSignOut from "@/_hooks/useSignOut"; +import { showConfirmDeleteModalAtom } from "@/_helpers/atoms/Modal.atom"; const ConfirmDeleteModelModal: React.FC = () => { const [show, setShow] = useAtom(showConfirmDeleteModalAtom); - const { signOut } = useSignOut(); - const onLogOutClick = () => { - signOut().then(() => setShow(false)); - }; + const onConfirmDelete = () => {}; return ( @@ -65,7 +61,7 @@ const ConfirmDeleteModelModal: React.FC = () => { diff --git a/web/app/_components/ConfirmSignOutModal/index.tsx b/web/app/_components/ConfirmSignOutModal/index.tsx index 13a2c8687..2acc8acd9 100644 --- a/web/app/_components/ConfirmSignOutModal/index.tsx +++ b/web/app/_components/ConfirmSignOutModal/index.tsx @@ -1,9 +1,9 @@ import React, { Fragment } from "react"; import { Dialog, Transition } from "@headlessui/react"; import { QuestionMarkCircleIcon } from "@heroicons/react/24/outline"; -import { showConfirmSignOutModalAtom } from "@/_helpers/JotaiWrapper"; import { useAtom } from "jotai"; import useSignOut from "@/_hooks/useSignOut"; +import { showConfirmSignOutModalAtom } from "@/_helpers/atoms/Modal.atom"; const ConfirmSignOutModal: React.FC = () => { const [show, setShow] = useAtom(showConfirmSignOutModalAtom); diff --git a/web/app/_components/DownloadedModelCard/index.tsx b/web/app/_components/DownloadedModelCard/index.tsx index 5aa7a7625..94d4526d3 100644 --- a/web/app/_components/DownloadedModelCard/index.tsx +++ b/web/app/_components/DownloadedModelCard/index.tsx @@ -1,8 +1,5 @@ import { Product } from "@/_models/Product"; import DownloadModelContent from "../DownloadModelContent"; -import ViewModelDetailButton from "../ViewModelDetailButton"; -import { executeSerial } from "@/_services/pluginService"; -import { InfereceService } from "../../../shared/coreService"; type Props = { product: Product; @@ -17,28 +14,22 @@ const DownloadedModelCard: React.FC = ({ isRecommend, required, onDeleteClick, -}) => { - - const handleViewDetails = () => {}; - - return ( -
-
- -
- -
+}) => ( +
+
+ +
+
- {/* */}
- ); -}; +
+); export default DownloadedModelCard; diff --git a/web/app/_components/DownloadedModelTable/index.tsx b/web/app/_components/DownloadedModelTable/index.tsx new file mode 100644 index 000000000..72ab2350a --- /dev/null +++ b/web/app/_components/DownloadedModelTable/index.tsx @@ -0,0 +1,20 @@ +import React, { Fragment } from "react"; +import SearchBar from "../SearchBar"; +import ModelTable from "../ModelTable"; +import { useGetDownloadedModels } from "@/_hooks/useGetDownloadedModels"; + +const DownloadedModelTable: React.FC = () => { + const { downloadedModels } = useGetDownloadedModels(); + + return ( + +

Downloaded Models

+
+ +
+ +
+ ); +}; + +export default DownloadedModelTable; diff --git a/web/app/_components/EmptyChatContainer/index.tsx b/web/app/_components/EmptyChatContainer/index.tsx new file mode 100644 index 000000000..caeff5655 --- /dev/null +++ b/web/app/_components/EmptyChatContainer/index.tsx @@ -0,0 +1,14 @@ +import React from "react"; +import SelectModels from "../ModelSelector"; +import InputToolbar from "../InputToolbar"; + +const EmptyChatContainer: React.FC = () => ( +
+
+ +
+ +
+); + +export default EmptyChatContainer; diff --git a/web/app/_components/ExploreModelContainer/index.tsx b/web/app/_components/ExploreModelContainer/index.tsx new file mode 100644 index 000000000..d07745ffa --- /dev/null +++ b/web/app/_components/ExploreModelContainer/index.tsx @@ -0,0 +1,55 @@ +import useGetAvailableModels from "@/_hooks/useGetAvailableModels"; +import ExploreModelItem from "../ExploreModelItem"; +import HeaderTitle from "../HeaderTitle"; +import SearchBar from "../SearchBar"; +import SimpleCheckbox from "../SimpleCheckbox"; +import SimpleTag, { TagType } from "../SimpleTag"; + +const tags = [ + "Roleplay", + "Llama", + "Story", + "Casual", + "Professional", + "CodeLlama", + "Coding", +]; +const checkboxs = ["GGUF", "TensorRT", "Meow", "JigglyPuff"]; + +const ExploreModelContainer: React.FC = () => { + const { allAvailableModels } = useGetAvailableModels(); + + return ( +
+ + +
+
+

Tags

+ +
+ {tags.map((item) => ( + + ))} +
+
+
+ {checkboxs.map((item) => ( + + ))} +
+
+
+

Results

+
+ {allAvailableModels.map((item) => ( + + ))} +
+
+
+
+ ); +}; + +export default ExploreModelContainer; diff --git a/web/app/_components/ExploreModelItem/index.tsx b/web/app/_components/ExploreModelItem/index.tsx new file mode 100644 index 000000000..35d1de650 --- /dev/null +++ b/web/app/_components/ExploreModelItem/index.tsx @@ -0,0 +1,101 @@ +"use client"; + +import ExploreModelItemHeader from "../ExploreModelItemHeader"; +import ModelVersionList from "../ModelVersionList"; +import { useMemo, useState } from "react"; +import { Product } from "@/_models/Product"; +import SimpleTag, { TagType } from "../SimpleTag"; +import { displayDate } from "@/_utils/datetime"; +import useDownloadModel from "@/_hooks/useDownloadModel"; +import { atom, useAtomValue } from "jotai"; +import { modelDownloadStateAtom } from "@/_helpers/atoms/DownloadState.atom"; + +type Props = { + model: Product; +}; + +const ExploreModelItem: React.FC = ({ model }) => { + const downloadAtom = useMemo( + () => atom((get) => get(modelDownloadStateAtom)[model.fileName ?? ""]), + [model.fileName ?? ""] + ); + const downloadState = useAtomValue(downloadAtom); + const { downloadModel } = useDownloadModel(); + const [show, setShow] = useState(false); + + return ( +
+ downloadModel(model)} + /> +
+
+
+
+
+ Model Format +
+
+ GGUF +
+
+
+
+ Hardware Compatibility +
+
+ +
+
+
+
+
+
+ Release Date +
+
+ {displayDate(model.releaseDate)} +
+
+
+
+ Expected Performance +
+ +
+
+
+
+ About + + {model.longDescription} + +
+
+ Tags +
+
+ {show && } + +
+ ); +}; + +export default ExploreModelItem; diff --git a/web/app/_components/ExploreModelItemHeader/index.tsx b/web/app/_components/ExploreModelItemHeader/index.tsx new file mode 100644 index 000000000..83a73d97d --- /dev/null +++ b/web/app/_components/ExploreModelItemHeader/index.tsx @@ -0,0 +1,44 @@ +import SimpleTag, { TagType } from "../SimpleTag"; +import PrimaryButton from "../PrimaryButton"; +import { formatDownloadPercentage, toGigabytes } from "@/_utils/converter"; +import { DownloadState } from "@/_models/DownloadState"; +import SecondaryButton from "../SecondaryButton"; + +type Props = { + name: string; + total: number; + status: TagType; + downloadState?: DownloadState; + onDownloadClick?: () => void; +}; + +const ExploreModelItemHeader: React.FC = ({ + name, + status, + total, + downloadState, + onDownloadClick, +}) => ( +
+
+ {name} + +
+ {downloadState != null ? ( + {}} + /> + ) : ( + onDownloadClick?.()} + /> + )} +
+); + +export default ExploreModelItemHeader; diff --git a/web/app/_components/HamburgerButton/index.tsx b/web/app/_components/HamburgerButton/index.tsx index 2efd41bf8..d3e775631 100644 --- a/web/app/_components/HamburgerButton/index.tsx +++ b/web/app/_components/HamburgerButton/index.tsx @@ -1,6 +1,6 @@ "use client"; -import { showingMobilePaneAtom } from "@/_helpers/JotaiWrapper"; +import { showingMobilePaneAtom } from "@/_helpers/atoms/Modal.atom"; import { Bars3Icon } from "@heroicons/react/24/outline"; import { useSetAtom } from "jotai"; import React from "react"; diff --git a/web/app/_components/HistoryItem/index.tsx b/web/app/_components/HistoryItem/index.tsx index 2402dcaa0..f243fbd32 100644 --- a/web/app/_components/HistoryItem/index.tsx +++ b/web/app/_components/HistoryItem/index.tsx @@ -1,22 +1,20 @@ import React from "react"; import JanImage from "../JanImage"; -import { - MainViewState, - activeModel, - conversationStatesAtom, - currentProductAtom, - getActiveConvoIdAtom, - setActiveConvoIdAtom, - setMainViewStateAtom, -} from "@/_helpers/JotaiWrapper"; import { useAtomValue, useSetAtom } from "jotai"; import Image from "next/image"; import { Conversation } from "@/_models/Conversation"; -import { DataService, InfereceService } from "../../../shared/coreService"; +import { DataService } from "../../../shared/coreService"; +import { executeSerial } from "../../../../electron/core/plugin-manager/execution/extension-manager"; import { - execute, - executeSerial, -} from "../../../../electron/core/plugin-manager/execution/extension-manager"; + conversationStatesAtom, + getActiveConvoIdAtom, + setActiveConvoIdAtom, +} from "@/_helpers/atoms/Conversation.atom"; +import { + setMainViewStateAtom, + MainViewState, +} from "@/_helpers/atoms/MainView.atom"; +import useInitModel from "@/_hooks/useInitModel"; type Props = { conversation: Conversation; @@ -36,23 +34,20 @@ const HistoryItem: React.FC = ({ const activeConvoId = useAtomValue(getActiveConvoIdAtom); const setActiveConvoId = useSetAtom(setActiveConvoIdAtom); const isSelected = activeConvoId === conversation.id; - const setActiveModel = useSetAtom(activeModel); - const setActiveProduct = useSetAtom(currentProductAtom); + + const { initModel } = useInitModel(); + const onClick = async () => { - const convoModel = await executeSerial( + const model = await executeSerial( DataService.GET_MODEL_BY_ID, conversation.model_id ); - if (!convoModel) { + if (!model) { alert( `Model ${conversation.model_id} not found! Please re-download the model first.` ); } else { - setActiveProduct(convoModel) - executeSerial(InfereceService.INIT_MODEL, convoModel) - .then(() => console.info(`Init model success`)) - .catch((err) => console.log(`Init model error ${err}`)); - setActiveModel(convoModel.name); + initModel(model); } if (activeConvoId !== conversation.id) { setMainViewState(MainViewState.Conversation); diff --git a/web/app/_components/HistoryList/index.tsx b/web/app/_components/HistoryList/index.tsx index e3f79cbf7..391b5504b 100644 --- a/web/app/_components/HistoryList/index.tsx +++ b/web/app/_components/HistoryList/index.tsx @@ -2,9 +2,10 @@ import HistoryItem from "../HistoryItem"; import { useEffect, useState } from "react"; import ExpandableHeader from "../ExpandableHeader"; import { useAtomValue } from "jotai"; -import { searchAtom, userConversationsAtom } from "@/_helpers/JotaiWrapper"; +import { searchAtom } from "@/_helpers/JotaiWrapper"; import useGetUserConversations from "@/_hooks/useGetUserConversations"; import SidebarEmptyHistory from "../SidebarEmptyHistory"; +import { userConversationsAtom } from "@/_helpers/atoms/Conversation.atom"; const HistoryList: React.FC = () => { const conversations = useAtomValue(userConversationsAtom); diff --git a/web/app/_components/InputToolbar/index.tsx b/web/app/_components/InputToolbar/index.tsx index 8fbafff0d..d8c48b764 100644 --- a/web/app/_components/InputToolbar/index.tsx +++ b/web/app/_components/InputToolbar/index.tsx @@ -2,8 +2,8 @@ import BasicPromptInput from "../BasicPromptInput"; import BasicPromptAccessories from "../BasicPromptAccessories"; -import { showingAdvancedPromptAtom } from "@/_helpers/JotaiWrapper"; import { useAtomValue } from "jotai"; +import { showingAdvancedPromptAtom } from "@/_helpers/atoms/Modal.atom"; const InputToolbar: React.FC = () => { const showingAdvancedPrompt = useAtomValue(showingAdvancedPromptAtom); diff --git a/web/app/_components/JanLogo/index.tsx b/web/app/_components/JanLogo/index.tsx index 93f68bfa9..3fc20f5e4 100644 --- a/web/app/_components/JanLogo/index.tsx +++ b/web/app/_components/JanLogo/index.tsx @@ -1,4 +1,4 @@ -import { setActiveConvoIdAtom } from "@/_helpers/JotaiWrapper"; +import { setActiveConvoIdAtom } from "@/_helpers/atoms/Conversation.atom"; import { useSetAtom } from "jotai"; import Image from "next/image"; import React from "react"; diff --git a/web/app/_components/LeftContainer/index.tsx b/web/app/_components/LeftContainer/index.tsx index be48a75be..df525ce49 100644 --- a/web/app/_components/LeftContainer/index.tsx +++ b/web/app/_components/LeftContainer/index.tsx @@ -3,10 +3,12 @@ import SidebarFooter from "../SidebarFooter"; import SidebarHeader from "../SidebarHeader"; import SidebarMenu from "../SidebarMenu"; import HistoryList from "../HistoryList"; +import NewChatButton from "../NewChatButton"; const LeftContainer: React.FC = () => (
+ diff --git a/web/app/_components/MenuHeader/index.tsx b/web/app/_components/MenuHeader/index.tsx index 95e0be017..851af41c1 100644 --- a/web/app/_components/MenuHeader/index.tsx +++ b/web/app/_components/MenuHeader/index.tsx @@ -3,7 +3,7 @@ import { Popover, Transition } from "@headlessui/react"; import { Fragment } from "react"; // import useGetCurrentUser from "@/_hooks/useGetCurrentUser"; import { useSetAtom } from "jotai"; -import { showConfirmSignOutModalAtom } from "@/_helpers/JotaiWrapper"; +import { showConfirmSignOutModalAtom } from "@/_helpers/atoms/Modal.atom"; export const MenuHeader: React.FC = () => { const setShowConfirmSignOutModal = useSetAtom(showConfirmSignOutModalAtom); diff --git a/web/app/_components/MobileMenuPane/index.tsx b/web/app/_components/MobileMenuPane/index.tsx index 4394e0daa..27dff1a52 100644 --- a/web/app/_components/MobileMenuPane/index.tsx +++ b/web/app/_components/MobileMenuPane/index.tsx @@ -2,8 +2,8 @@ import React, { useRef } from "react"; import { Dialog } from "@headlessui/react"; import { XMarkIcon } from "@heroicons/react/24/outline"; import Image from "next/image"; -import { showingMobilePaneAtom } from "@/_helpers/JotaiWrapper"; import { useAtom } from "jotai"; +import { showingMobilePaneAtom } from "@/_helpers/atoms/Modal.atom"; const MobileMenuPane: React.FC = () => { const [show, setShow] = useAtom(showingMobilePaneAtom); diff --git a/web/app/_components/ModelActionButton/index.tsx b/web/app/_components/ModelActionButton/index.tsx new file mode 100644 index 000000000..394b3ef2a --- /dev/null +++ b/web/app/_components/ModelActionButton/index.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import PrimaryButton from "../PrimaryButton"; + +export enum ModelActionType { + Start = "Start", + Stop = "Stop", +} + +type ModelActionStyle = { + title: string; + backgroundColor: string; + textColor: string; +}; + +const modelActionMapper: Record = { + [ModelActionType.Start]: { + title: "Start", + backgroundColor: "bg-blue-500 hover:bg-blue-600", + textColor: "text-white", + }, + [ModelActionType.Stop]: { + title: "Stop", + backgroundColor: "bg-red-500 hover:bg-red-600", + textColor: "text-white", + }, +}; + +type Props = { + type: ModelActionType; + onActionClick: (type: ModelActionType) => void; +}; + +const ModelActionButton: React.FC = ({ type, onActionClick }) => { + const styles = modelActionMapper[type]; + const onClick = () => { + onActionClick(type); + }; + + return ( + + + + ); +}; + +export default ModelActionButton; diff --git a/web/app/_components/ModelActionMenu/index.tsx b/web/app/_components/ModelActionMenu/index.tsx new file mode 100644 index 000000000..8fc3df274 --- /dev/null +++ b/web/app/_components/ModelActionMenu/index.tsx @@ -0,0 +1,35 @@ +import { Menu, Transition } from "@headlessui/react"; +import { EllipsisVerticalIcon } from "@heroicons/react/20/solid"; +import { Fragment } from "react"; + +type Props = { + onDeleteClick: () => void; +}; + +const ModelActionMenu: React.FC = ({ onDeleteClick }) => { + return ( + + + Open options + + + + + + + + + + ); +}; + +export default ModelActionMenu; diff --git a/web/app/_components/ModelDownloadingButton/index.tsx b/web/app/_components/ModelDownloadingButton/index.tsx index ff30e6642..d01b58022 100644 --- a/web/app/_components/ModelDownloadingButton/index.tsx +++ b/web/app/_components/ModelDownloadingButton/index.tsx @@ -1,3 +1,5 @@ +import { toGigabytes } from "@/_utils/converter"; + type Props = { total: number; value: number; @@ -18,16 +20,4 @@ const ModelDownloadingButton: React.FC = ({ total, value }) => { ); }; -const toGigabytes = (input: number) => { - if (input > 1024 ** 3) { - return (input / 1000 ** 3).toFixed(2) + "GB"; - } else if (input > 1024 ** 2) { - return (input / 1000 ** 2).toFixed(2) + "MB"; - } else if (input > 1024) { - return (input / 1000).toFixed(2) + "KB"; - } else { - return input + "B"; - } -}; - export default ModelDownloadingButton; diff --git a/web/app/_components/ModelListContainer/index.tsx b/web/app/_components/ModelListContainer/index.tsx index 27b3f0a0f..52de3a2cf 100644 --- a/web/app/_components/ModelListContainer/index.tsx +++ b/web/app/_components/ModelListContainer/index.tsx @@ -1,92 +1,36 @@ "use client"; -import { useEffect, useState } from "react"; -import { execute, executeSerial } from "@/_services/pluginService"; -import { - DataService, - ModelManagementService, -} from "../../../shared/coreService"; import { useAtomValue } from "jotai"; -import { - modelDownloadStateAtom, - searchingModelText, -} from "@/_helpers/JotaiWrapper"; +import { searchingModelText } from "@/_helpers/JotaiWrapper"; import { Product } from "@/_models/Product"; import DownloadedModelCard from "../DownloadedModelCard"; import AvailableModelCard from "../AvailableModelCard"; +import useDeleteModel from "@/_hooks/useDeleteModel"; +import useGetAvailableModels from "@/_hooks/useGetAvailableModels"; +import useDownloadModel from "@/_hooks/useDownloadModel"; const ModelListContainer: React.FC = () => { - const [downloadedModels, setDownloadedModels] = useState([]); - const [availableModels, setAvailableModels] = useState([]); - const downloadState = useAtomValue(modelDownloadStateAtom); const searchText = useAtomValue(searchingModelText); + const { deleteModel } = useDeleteModel(); + const { downloadModel } = useDownloadModel(); - useEffect(() => { - const getDownloadedModels = async () => { - const avails = await executeSerial( - ModelManagementService.GET_AVAILABLE_MODELS - ); - - const downloaded: Product[] = await executeSerial( - ModelManagementService.GET_DOWNLOADED_MODELS - ); - - const downloadedSucessfullyModels: Product[] = []; - const availableOrDownloadingModels: Product[] = avails; - - downloaded.forEach((item) => { - if (item.fileName && downloadState[item.fileName] == null) { - downloadedSucessfullyModels.push(item); - } else { - availableOrDownloadingModels.push(item); - } - }); - - setAvailableModels(availableOrDownloadingModels); - setDownloadedModels(downloadedSucessfullyModels); - }; - getDownloadedModels(); - }, [downloadState]); + const { + availableModels, + downloadedModels, + getAvailableModelExceptDownloaded, + } = useGetAvailableModels(); const onDeleteClick = async (product: Product) => { - execute(DataService.DELETE_DOWNLOAD_MODEL, product.id); - await executeSerial(ModelManagementService.DELETE_MODEL, product.fileName); - const getDownloadedModels = async () => { - const avails = await executeSerial( - ModelManagementService.GET_AVAILABLE_MODELS - ); - - const downloaded: Product[] = await executeSerial( - ModelManagementService.GET_DOWNLOADED_MODELS - ); - - const downloadedSucessfullyModels: Product[] = []; - const availableOrDownloadingModels: Product[] = avails; - - downloaded.forEach((item) => { - if (item.fileName && downloadState[item.fileName] == null) { - downloadedSucessfullyModels.push(item); - } else { - availableOrDownloadingModels.push(item); - } - }); - - setAvailableModels(availableOrDownloadingModels); - setDownloadedModels(downloadedSucessfullyModels); - }; - getDownloadedModels(); + await deleteModel(product); + await getAvailableModelExceptDownloaded(); }; - const onDownloadClick = async (product: Product) => { - await executeSerial(DataService.STORE_MODEL, product); - await executeSerial(ModelManagementService.DOWNLOAD_MODEL, { - downloadUrl: product.downloadUrl, - fileName: product.fileName, - }); + const onDownloadClick = async (model: Product) => { + await downloadModel(model); }; return ( -
+
{downloadedModels diff --git a/web/app/_components/ModelMenu/index.tsx b/web/app/_components/ModelMenu/index.tsx index f8113e001..006042317 100644 --- a/web/app/_components/ModelMenu/index.tsx +++ b/web/app/_components/ModelMenu/index.tsx @@ -1,12 +1,10 @@ "use client"; import { useAtomValue, useSetAtom } from "jotai"; -import { - currentProductAtom, - showConfirmDeleteConversationModalAtom, -} from "@/_helpers/JotaiWrapper"; import { PlusIcon, TrashIcon } from "@heroicons/react/24/outline"; import useCreateConversation from "@/_hooks/useCreateConversation"; +import { showConfirmDeleteConversationModalAtom } from "@/_helpers/atoms/Modal.atom"; +import { currentProductAtom } from "@/_helpers/atoms/Model.atom"; const ModelMenu: React.FC = () => { const currentProduct = useAtomValue(currentProductAtom); diff --git a/web/app/_components/ModelRow/index.tsx b/web/app/_components/ModelRow/index.tsx new file mode 100644 index 000000000..7c98b6edd --- /dev/null +++ b/web/app/_components/ModelRow/index.tsx @@ -0,0 +1,78 @@ +import React from "react"; +import { Product } from "@/_models/Product"; +import Image from "next/image"; +import { ModelStatus, ModelStatusComponent } from "../ModelStatusComponent"; +import ModelActionMenu from "../ModelActionMenu"; +import { useAtomValue } from "jotai"; +import ModelActionButton, { ModelActionType } from "../ModelActionButton"; +import useStartStopModel from "@/_hooks/useStartStopModel"; +import useDeleteModel from "@/_hooks/useDeleteModel"; +import { currentProductAtom } from "@/_helpers/atoms/Model.atom"; + +type Props = { + model: Product; +}; + +const ModelRow: React.FC<Props> = ({ model }) => { + const { startModel } = useStartStopModel(); + const activeModel = useAtomValue(currentProductAtom); + const { deleteModel } = useDeleteModel(); + + let status = ModelStatus.Installed; + if (activeModel && activeModel.id === model.id) { + status = ModelStatus.Active; + } + + let actionButtonType = ModelActionType.Start; + if (activeModel && activeModel.id === model.id) { + actionButtonType = ModelActionType.Stop; + } + + const onModelActionClick = (action: ModelActionType) => { + if (action === ModelActionType.Start) { + startModel(model.id); + } + }; + + const onDeleteClick = () => { + deleteModel(model); + }; + + return ( + <tr + className="border-b border-gray-200 last:border-b-0 last:rounded-lg" + key={model.id} + > + <td className="flex flex-col whitespace-nowrap px-6 py-4 text-sm font-medium text-gray-900"> + {model.name} + <span className="text-gray-500 font-normal">{model.version}</span> + </td> + <td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500"> + <div className="flex flex-col justify-start"> + <span>{model.format}</span> + {model.accelerated && ( + <span className="flex items-center text-gray-500 text-sm font-normal gap-0.5"> + <Image src={"/icons/flash.svg"} width={20} height={20} alt="" /> + GPU Accelerated + </span> + )} + </div> + </td> + <td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500"> + {model.totalSize} + </td> + <td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500"> + <ModelStatusComponent status={status} /> + </td> + <ModelActionButton + type={actionButtonType} + onActionClick={onModelActionClick} + /> + <td className="relative whitespace-nowrap px-6 py-4 w-fit text-right text-sm font-medium"> + <ModelActionMenu onDeleteClick={onDeleteClick} /> + </td> + </tr> + ); +}; + +export default ModelRow; diff --git a/web/app/_components/ModelSelector/index.tsx b/web/app/_components/ModelSelector/index.tsx new file mode 100644 index 000000000..5564788bf --- /dev/null +++ b/web/app/_components/ModelSelector/index.tsx @@ -0,0 +1,118 @@ +import { Fragment, useEffect } from "react"; +import { Listbox, Transition } from "@headlessui/react"; +import { CheckIcon, ChevronUpDownIcon } from "@heroicons/react/20/solid"; +import { useGetDownloadedModels } from "@/_hooks/useGetDownloadedModels"; +import { Product } from "@/_models/Product"; +import { useAtom } from "jotai"; +import { selectedModelAtom } from "@/_helpers/atoms/Model.atom"; + +function classNames(...classes: any) { + return classes.filter(Boolean).join(" "); +} + +const SelectModels: React.FC = () => { + const { downloadedModels } = useGetDownloadedModels(); + const [selectedModel, setSelectedModel] = useAtom(selectedModelAtom); + + useEffect(() => { + if (downloadedModels && downloadedModels.length > 0) { + onModelSelected(downloadedModels[0]); + } + }, [downloadedModels]); + + const onModelSelected = (model: Product) => { + setSelectedModel(model); + }; + + if (!selectedModel) { + return <div>You have not downloaded any model!</div>; + } + + return ( + <Listbox value={selectedModel} onChange={onModelSelected}> + {({ open }) => ( + <div className="w-[461px]"> + <Listbox.Label className="block text-sm font-medium leading-6 text-gray-900"> + Select a Model: + </Listbox.Label> + <div className="relative mt-[19px]"> + <Listbox.Button className="relative w-full cursor-default rounded-md bg-white py-1.5 pl-3 pr-10 text-left text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 sm:text-sm sm:leading-6"> + <span className="flex items-center"> + <img + src={selectedModel.avatarUrl} + alt="" + className="h-5 w-5 flex-shrink-0 rounded-full" + /> + <span className="ml-3 block truncate"> + {selectedModel.name} + </span> + </span> + <span className="pointer-events-none absolute inset-y-0 right-0 ml-3 flex items-center pr-2"> + <ChevronUpDownIcon + className="h-5 w-5 text-gray-400" + aria-hidden="true" + /> + </span> + </Listbox.Button> + + <Transition + show={open} + as={Fragment} + leave="transition ease-in duration-100" + leaveFrom="opacity-100" + leaveTo="opacity-0" + > + <Listbox.Options className="absolute z-10 mt-1 max-h-[188px] w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"> + {downloadedModels.map((model) => ( + <Listbox.Option + key={model.id} + className={({ active }) => + classNames( + active ? "bg-indigo-600 text-white" : "text-gray-900", + "relative cursor-default select-none py-2 pl-3 pr-9" + ) + } + value={model} + > + {({ selected, active }) => ( + <> + <div className="flex items-center"> + <img + src={model.avatarUrl} + alt="" + className="h-5 w-5 flex-shrink-0 rounded-full" + /> + <span + className={classNames( + selected ? "font-semibold" : "font-normal", + "ml-3 block truncate" + )} + > + {model.name} + </span> + </div> + + {selected ? ( + <span + className={classNames( + active ? "text-white" : "text-indigo-600", + "absolute inset-y-0 right-0 flex items-center pr-4" + )} + > + <CheckIcon className="h-5 w-5" aria-hidden="true" /> + </span> + ) : null} + </> + )} + </Listbox.Option> + ))} + </Listbox.Options> + </Transition> + </div> + </div> + )} + </Listbox> + ); +}; + +export default SelectModels; diff --git a/web/app/_components/ModelStatusComponent/index.tsx b/web/app/_components/ModelStatusComponent/index.tsx new file mode 100644 index 000000000..e13ce2020 --- /dev/null +++ b/web/app/_components/ModelStatusComponent/index.tsx @@ -0,0 +1,46 @@ +import React from "react"; + +export type ModelStatusType = { + title: string; + textColor: string; + backgroundColor: string; +}; + +export enum ModelStatus { + Installed, + Active, + RunningInNitro, +} + +export const ModelStatusMapper: Record<ModelStatus, ModelStatusType> = { + [ModelStatus.Installed]: { + title: "Installed", + textColor: "text-black", + backgroundColor: "bg-gray-100", + }, + [ModelStatus.Active]: { + title: "Active", + textColor: "text-black", + backgroundColor: "bg-green-100", + }, + [ModelStatus.RunningInNitro]: { + title: "Running in Nitro", + textColor: "text-black", + backgroundColor: "bg-green-100", + }, +}; + +type Props = { + status: ModelStatus; +}; + +export const ModelStatusComponent: React.FC<Props> = ({ status }) => { + const statusType = ModelStatusMapper[status]; + return ( + <div + className={`rounded-[10px] py-0.5 px-[10px] w-fit text-xs font-medium ${statusType.backgroundColor}`} + > + {statusType.title} + </div> + ); +}; diff --git a/web/app/_components/ModelTable/index.tsx b/web/app/_components/ModelTable/index.tsx new file mode 100644 index 000000000..688ef6c34 --- /dev/null +++ b/web/app/_components/ModelTable/index.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { Product } from "@/_models/Product"; +import ModelRow from "../ModelRow"; +import ModelTableHeader from "../ModelTableHeader"; + +type Props = { + models: Product[]; +}; + +const tableHeaders = ["MODEL", "FORMAT", "SIZE", "STATUS", "ACTIONS"]; + +const ModelTable: React.FC<Props> = ({ models }) => ( + <div className="flow-root inline-block border rounded-lg border-gray-200 min-w-full align-middle shadow-lg"> + <table className="min-w-full"> + <thead className="bg-gray-50 border-b border-gray-200"> + <tr className="rounded-t-lg"> + {tableHeaders.map((item) => ( + <ModelTableHeader key={item} title={item} /> + ))} + <th scope="col" className="relative px-6 py-3 w-fit"> + <span className="sr-only">Edit</span> + </th> + </tr> + </thead> + <tbody> + {models.map((model) => ( + <ModelRow key={model.id} model={model} /> + ))} + </tbody> + </table> + </div> +); + +export default React.memo(ModelTable); diff --git a/web/app/_components/ModelTableHeader/index.tsx b/web/app/_components/ModelTableHeader/index.tsx new file mode 100644 index 000000000..b335888ee --- /dev/null +++ b/web/app/_components/ModelTableHeader/index.tsx @@ -0,0 +1,16 @@ +import React from "react"; + +type Props = { + title: string; +}; + +const ModelTableHeader: React.FC<Props> = ({ title }) => ( + <th + scope="col" + className="px-6 py-3 text-left first:rounded-tl-lg last:rounded-tr-lg text-xs font-medium uppercase tracking-wide text-gray-500" + > + {title} + </th> +); + +export default React.memo(ModelTableHeader); diff --git a/web/app/_components/ModelVersionItem/index.tsx b/web/app/_components/ModelVersionItem/index.tsx new file mode 100644 index 000000000..355128bba --- /dev/null +++ b/web/app/_components/ModelVersionItem/index.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { toGigabytes } from "@/_utils/converter"; +import Image from "next/image"; + +type Props = { + title: string; + totalSizeInByte: number; +}; + +const ModelVersionItem: React.FC<Props> = ({ title, totalSizeInByte }) => ( + <div className="flex justify-between items-center gap-4 pl-[13px] pt-[13px] pr-[17px] pb-3 border-t border-gray-200 first:border-t-0"> + <div className="flex items-center gap-4"> + <Image src={"/icons/app_icon.svg"} width={14} height={20} alt="" /> + <span className="font-sm text-gray-900">{title}</span> + </div> + <div className="flex items-center gap-4"> + <div className="px-[10px] py-0.5 bg-gray-200 text-xs font-medium rounded"> + {toGigabytes(totalSizeInByte)} + </div> + <button className="text-indigo-600 text-sm font-medium">Download</button> + </div> + </div> +); + +export default ModelVersionItem; diff --git a/web/app/_components/ModelVersionList/index.tsx b/web/app/_components/ModelVersionList/index.tsx new file mode 100644 index 000000000..f70d38a76 --- /dev/null +++ b/web/app/_components/ModelVersionList/index.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import ModelVersionItem from "../ModelVersionItem"; + +const data = [ + { + name: "Q4_K_M.gguf", + total: 5600, + }, + { + name: "Q4_K_M.gguf", + total: 5600, + }, + { + name: "Q4_K_M.gguf", + total: 5600, + }, +]; + +const ModelVersionList: React.FC = () => { + return ( + <div className="px-4 py-5 border-t border-gray-200"> + <div className="text-sm font-medium text-gray-500"> + Available Versions + </div> + <div className="border border-gray-200 rounded-lg overflow-hidden"> + {data.map((item, index) => ( + <ModelVersionItem + key={index} + title={item.name} + totalSizeInByte={item.total} + /> + ))} + </div> + </div> + ); +}; + +export default ModelVersionList; diff --git a/web/app/_components/MonitorBar/index.tsx b/web/app/_components/MonitorBar/index.tsx index 5f98f6add..35fd6d820 100644 --- a/web/app/_components/MonitorBar/index.tsx +++ b/web/app/_components/MonitorBar/index.tsx @@ -1,42 +1,22 @@ import ProgressBar from "../ProgressBar"; import SystemItem from "../SystemItem"; import { useAtomValue } from "jotai"; -import { - activeModel, - appDownloadProgress, - getSystemBarVisibilityAtom, -} from "@/_helpers/JotaiWrapper"; +import { appDownloadProgress } from "@/_helpers/JotaiWrapper"; import { useEffect, useState } from "react"; import { executeSerial } from "../../../../electron/core/plugin-manager/execution/extension-manager"; import { SystemMonitoringService } from "../../../shared/coreService"; +import { getSystemBarVisibilityAtom } from "@/_helpers/atoms/SystemBar.atom"; +import { currentProductAtom } from "@/_helpers/atoms/Model.atom"; const MonitorBar: React.FC = () => { const show = useAtomValue(getSystemBarVisibilityAtom); const progress = useAtomValue(appDownloadProgress); - const modelName = useAtomValue(activeModel); + const activeModel = useAtomValue(currentProductAtom); const [ram, setRam] = useState<number>(0); const [gpu, setGPU] = useState<number>(0); const [cpu, setCPU] = useState<number>(0); const [version, setVersion] = useState<string>(""); - const data = [ - { - name: "CPU", - total: 1400, - used: 750, - }, - { - name: "Ram", - total: 16000, - used: 4500, - }, - { - name: "VRAM", - total: 1400, - used: 1300, - }, - ]; - useEffect(() => { const getSystemResources = async () => { const resourceInfor = await executeSerial( @@ -77,8 +57,8 @@ const MonitorBar: React.FC = () => { <SystemItem name="CPU" value={`${cpu}%`} /> <SystemItem name="Mem" value={`${ram}%`} /> - {modelName && modelName.length > 0 && ( - <SystemItem name="Active Models" value={"1"} /> + {activeModel && ( + <SystemItem name={`Active model: ${activeModel.name}`} value={"1"} /> )} <span className="text-gray-900 text-sm">v{version}</span> </div> diff --git a/web/app/_components/MyModelContainer/index.tsx b/web/app/_components/MyModelContainer/index.tsx new file mode 100644 index 000000000..905df627c --- /dev/null +++ b/web/app/_components/MyModelContainer/index.tsx @@ -0,0 +1,13 @@ +import HeaderTitle from "../HeaderTitle"; +import DownloadedModelTable from "../DownloadedModelTable"; +import ActiveModelTable from "../ActiveModelTable"; + +const MyModelContainer: React.FC = () => ( + <div className="flex flex-col w-full h-full pl-[63px] pr-[89px] pt-[60px]"> + <HeaderTitle title="My Models" /> + <ActiveModelTable /> + <DownloadedModelTable /> + </div> +); + +export default MyModelContainer; diff --git a/web/app/_components/NewChatButton/index.tsx b/web/app/_components/NewChatButton/index.tsx new file mode 100644 index 000000000..3fc7de8e8 --- /dev/null +++ b/web/app/_components/NewChatButton/index.tsx @@ -0,0 +1,39 @@ +"use client"; + +import React from "react"; +import SecondaryButton from "../SecondaryButton"; +import { useAtomValue, useSetAtom } from "jotai"; +import { + MainViewState, + setMainViewStateAtom, +} from "@/_helpers/atoms/MainView.atom"; +import { currentProductAtom } from "@/_helpers/atoms/Model.atom"; +import useCreateConversation from "@/_hooks/useCreateConversation"; +import useInitModel from "@/_hooks/useInitModel"; +import { Product } from "@/_models/Product"; + +const NewChatButton: React.FC = () => { + const activeModel = useAtomValue(currentProductAtom); + const setMainView = useSetAtom(setMainViewStateAtom); + const { requestCreateConvo } = useCreateConversation(); + const { initModel } = useInitModel(); + + const onClick = () => { + if (!activeModel) { + setMainView(MainViewState.ConversationEmptyModel); + } else { + createConversationAndInitModel(activeModel); + } + }; + + const createConversationAndInitModel = async (model: Product) => { + await requestCreateConvo(model); + await initModel(model); + }; + + return ( + <SecondaryButton title={"New Chat"} onClick={onClick} className="my-5" /> + ); +}; + +export default NewChatButton; diff --git a/web/app/_components/RightContainer/index.tsx b/web/app/_components/RightContainer/index.tsx index 61aa47b6b..adf5d4b62 100644 --- a/web/app/_components/RightContainer/index.tsx +++ b/web/app/_components/RightContainer/index.tsx @@ -1,11 +1,9 @@ import ChatContainer from "../ChatContainer"; -import Header from "../Header"; import MainChat from "../MainChat"; import MonitorBar from "../MonitorBar"; const RightContainer = () => ( <div className="flex flex-col flex-1 h-screen"> - <Header /> <ChatContainer> <MainChat /> </ChatContainer> diff --git a/web/app/_components/SearchBar/index.tsx b/web/app/_components/SearchBar/index.tsx index 6932ec1ca..a6354549e 100644 --- a/web/app/_components/SearchBar/index.tsx +++ b/web/app/_components/SearchBar/index.tsx @@ -2,11 +2,15 @@ import { searchAtom } from "@/_helpers/JotaiWrapper"; import { MagnifyingGlassIcon } from "@heroicons/react/24/outline"; import { useSetAtom } from "jotai"; -const SearchBar: React.FC = () => { - const setText = useSetAtom(searchAtom); +type Props = { + placeholder?: string; +}; +const SearchBar: React.FC<Props> = ({ placeholder }) => { + const setText = useSetAtom(searchAtom); + let placeholderText = placeholder ? placeholder : "Search (⌘K)"; return ( - <div className="relative mx-3 mt-3 flex items-center"> + <div className="relative mt-3 flex items-center"> <div className="absolute top-0 left-2 h-full flex items-center"> <MagnifyingGlassIcon width={16} @@ -19,7 +23,7 @@ const SearchBar: React.FC = () => { type="text" name="search" id="search" - placeholder="Search (⌘K)" + placeholder={placeholderText} onChange={(e) => setText(e.target.value)} className="block w-full rounded-md border-0 py-1.5 pl-8 pr-14 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" /> diff --git a/web/app/_components/SecondaryButton/index.tsx b/web/app/_components/SecondaryButton/index.tsx index 8de14302d..b4abda6cd 100644 --- a/web/app/_components/SecondaryButton/index.tsx +++ b/web/app/_components/SecondaryButton/index.tsx @@ -2,14 +2,20 @@ type Props = { title: string; onClick: () => void; disabled?: boolean; + className?: string; }; -const SecondaryButton: React.FC<Props> = ({ title, onClick, disabled }) => ( +const SecondaryButton: React.FC<Props> = ({ + title, + onClick, + disabled, + className, +}) => ( <button disabled={disabled} type="button" onClick={onClick} - className="rounded-full bg-white px-2.5 py-1 text-xs font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" + className={`rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 ${className}`} > {title} </button> diff --git a/web/app/_components/SendButton/index.tsx b/web/app/_components/SendButton/index.tsx index 07c41fdf0..8b6cac21b 100644 --- a/web/app/_components/SendButton/index.tsx +++ b/web/app/_components/SendButton/index.tsx @@ -1,7 +1,5 @@ -import { - currentConvoStateAtom, - currentPromptAtom, -} from "@/_helpers/JotaiWrapper"; +import { currentPromptAtom } from "@/_helpers/JotaiWrapper"; +import { currentConvoStateAtom } from "@/_helpers/atoms/Conversation.atom"; import useSendChatMessage from "@/_hooks/useSendChatMessage"; import { useAtom, useAtomValue } from "jotai"; import Image from "next/image"; diff --git a/web/app/_components/SidebarButton/index.tsx b/web/app/_components/SidebarButton/index.tsx deleted file mode 100644 index 8db7a386b..000000000 --- a/web/app/_components/SidebarButton/index.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import Image from "next/image"; - -type Props = { - callback?: () => void; - className?: string; - icon: string; - width: number; - height: number; - title: string; -}; - -export const SidebarButton: React.FC<Props> = ({ - callback, - height, - icon, - className, - width, - title, -}) => ( - <button onClick={callback} className={className}> - <Image src={icon} width={width} height={height} alt="" /> - <span>{title}</span> - </button> -); diff --git a/web/app/_components/SidebarEmptyHistory/index.tsx b/web/app/_components/SidebarEmptyHistory/index.tsx index af1da8998..ca6cea543 100644 --- a/web/app/_components/SidebarEmptyHistory/index.tsx +++ b/web/app/_components/SidebarEmptyHistory/index.tsx @@ -1,28 +1,56 @@ import Image from "next/image"; -import { SidebarButton } from "../SidebarButton"; -import { executeSerial } from "../../../../electron/core/plugin-manager/execution/extension-manager"; -import { DataService } from "../../../shared/coreService"; import useCreateConversation from "@/_hooks/useCreateConversation"; +import PrimaryButton from "../PrimaryButton"; +import { useAtomValue, useSetAtom } from "jotai"; +import { useGetDownloadedModels } from "@/_hooks/useGetDownloadedModels"; +import { useEffect, useState } from "react"; +import { + MainViewState, + setMainViewStateAtom, +} from "@/_helpers/atoms/MainView.atom"; +import { currentProductAtom } from "@/_helpers/atoms/Model.atom"; +import useInitModel from "@/_hooks/useInitModel"; +import { Product } from "@/_models/Product"; + +enum ActionButton { + DownloadModel = "Download a Model", + StartChat = "Start a Conversation", +} const SidebarEmptyHistory: React.FC = () => { + const { downloadedModels } = useGetDownloadedModels(); + const activeModel = useAtomValue(currentProductAtom); + const setMainView = useSetAtom(setMainViewStateAtom); const { requestCreateConvo } = useCreateConversation(); - const startChat = async () => { - // Host - if (window && !window.electronAPI) { - // requestCreateConvo(); // TODO: get model id from somewhere - } - // Electron - const downloadedModels = await executeSerial( - DataService.GET_FINISHED_DOWNLOAD_MODELS - ); - if (!downloadedModels || downloadedModels?.length === 0) { - alert( - "Seems like there is no model downloaded yet. Please download a model first." - ); + const [action, setAction] = useState(ActionButton.DownloadModel); + + const { initModel } = useInitModel(); + + useEffect(() => { + if (downloadedModels.length > 0) { + setAction(ActionButton.StartChat); } else { - requestCreateConvo(downloadedModels[0]); + setAction(ActionButton.DownloadModel); + } + }, [downloadedModels]); + + const onClick = () => { + if (action === ActionButton.DownloadModel) { + setMainView(MainViewState.ExploreModel); + } else { + if (!activeModel) { + setMainView(MainViewState.ConversationEmptyModel); + } else { + createConversationAndInitModel(activeModel); + } } }; + + const createConversationAndInitModel = async (model: Product) => { + await requestCreateConvo(model); + await initModel(model); + }; + return ( <div className="flex flex-col items-center py-10 gap-3"> <Image @@ -32,22 +60,11 @@ const SidebarEmptyHistory: React.FC = () => { alt="" /> <div className="flex flex-col items-center gap-6"> - <div> - <div className="text-center text-gray-900 text-sm"> - No Chat History - </div> - <div className="text-center text-gray-500 text-sm"> - Get started by creating a new chat. - </div> + <div className="text-center text-gray-900 text-sm">No Chat History</div> + <div className="text-center text-gray-500 text-sm"> + Get started by creating a new chat. </div> - <SidebarButton - callback={startChat} - className="flex items-center border bg-blue-600 rounded-lg py-[9px] pl-[15px] pr-[17px] gap-2 text-white font-medium text-sm" - height={14} - icon="icons/Icon_plus.svg" - title="New chat" - width={14} - /> + <PrimaryButton title={action} onClick={onClick} /> </div> </div> ); diff --git a/web/app/_components/SidebarFooter/index.tsx b/web/app/_components/SidebarFooter/index.tsx index 44bcc9f49..97bab2748 100644 --- a/web/app/_components/SidebarFooter/index.tsx +++ b/web/app/_components/SidebarFooter/index.tsx @@ -1,27 +1,21 @@ import React from "react"; -import { SidebarButton } from "../SidebarButton"; +import SecondaryButton from "../SecondaryButton"; const SidebarFooter: React.FC = () => ( <div className="flex justify-between items-center gap-2"> - <SidebarButton - className="flex items-center border border-gray-200 rounded-lg p-2 gap-3 flex-1 justify-center text-gray-900 font-medium text-sm" - height={24} - icon="icons/discord.svg" - title="Discord" - width={24} - callback={() => { - window.electronAPI?.openExternalUrl("https://discord.gg/AsJ8krTT3N"); - }} + <SecondaryButton + title={"Discord"} + onClick={() => + window.electronAPI?.openExternalUrl("https://discord.gg/AsJ8krTT3N") + } + className="flex-1" /> - <SidebarButton - className="flex items-center border border-gray-200 rounded-lg p-2 gap-3 flex-1 justify-center text-gray-900 font-medium text-sm" - height={24} - icon="icons/unicorn_twitter.svg" - title="Twitter" - width={24} - callback={() => { - window.electronAPI?.openExternalUrl("https://twitter.com/jan_dotai"); - }} + <SecondaryButton + title={"Discord"} + onClick={() => + window.electronAPI?.openExternalUrl("https://twitter.com/jan_dotai") + } + className="flex-1" /> </div> ); diff --git a/web/app/_components/SidebarMenu/index.tsx b/web/app/_components/SidebarMenu/index.tsx index 8ea9f9de7..2e772d427 100644 --- a/web/app/_components/SidebarMenu/index.tsx +++ b/web/app/_components/SidebarMenu/index.tsx @@ -1,69 +1,41 @@ -import { - MainViewState, - getMainViewStateAtom, - setMainViewStateAtom, -} from "@/_helpers/JotaiWrapper"; -import classNames from "classnames"; -import { useAtomValue, useSetAtom } from "jotai"; -import Image from "next/image"; +import React from "react"; +import SidebarMenuItem from "../SidebarMenuItem"; +import { MainViewState } from "@/_helpers/atoms/MainView.atom"; -const SidebarMenu: React.FC = () => { - const currentState = useAtomValue(getMainViewStateAtom); - const setMainViewState = useSetAtom(setMainViewStateAtom); +const menu = [ + { + name: "Explore Models", + icon: "Search_gray", + state: MainViewState.ExploreModel, + }, + { + name: "My Models", + icon: "ViewGrid", + state: MainViewState.MyModel, + }, + { + name: "Settings", + icon: "Cog", + state: MainViewState.Setting, + }, +]; - const menu = [ - { - name: "Explore Models", - icon: "Search_gray", - state: MainViewState.ExploreModel, - }, - { - name: "My Models", - icon: "ViewGrid", - state: MainViewState.MyModel, - }, - { - name: "Settings", - icon: "Cog", - state: MainViewState.Setting, - }, - ]; - - const onMenuClick = (state: MainViewState) => { - if (state === currentState) return; - setMainViewState(state); - }; - - return ( - <div className="flex flex-col"> - <div className="text-gray-500 text-xs font-semibold py-2 pl-2 pr-3"> - Your Configurations - </div> - <ul role="list" className="-mx-2 mt-2 space-y-1"> - {menu.map((item) => ( - <li key={item.name}> - <button - onClick={() => onMenuClick(item.state)} - className={classNames( - currentState === item.state - ? "bg-gray-50 text-indigo-600" - : "text-gray-600 hover:text-indigo-600 hover:bg-gray-50", - "group flex gap-x-3 rounded-md text-base py-2 px-3 w-full" - )} - > - <Image - src={`icons/${item.icon}.svg`} - width={24} - height={24} - alt="" - /> - <span className="truncate">{item.name}</span> - </button> - </li> - ))} - </ul> +const SidebarMenu: React.FC = () => ( + <div className="flex flex-col"> + <div className="text-gray-500 text-xs font-semibold py-2 pl-2 pr-3"> + Your Configurations </div> - ); -}; + <ul role="list" className="-mx-2 mt-2 space-y-1 mb-2"> + {menu.map((item) => ( + <SidebarMenuItem + title={item.name} + viewState={item.state} + iconName={item.icon} + key={item.name} + /> + ))} + </ul> + </div> +); -export default SidebarMenu; +export default React.memo(SidebarMenu); diff --git a/web/app/_components/SidebarMenuItem/index.tsx b/web/app/_components/SidebarMenuItem/index.tsx new file mode 100644 index 000000000..2fbc50a5d --- /dev/null +++ b/web/app/_components/SidebarMenuItem/index.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { useAtomValue, useSetAtom } from "jotai"; +import Image from "next/image"; +import { + MainViewState, + getMainViewStateAtom, + setMainViewStateAtom, +} from "@/_helpers/atoms/MainView.atom"; + +type Props = { + title: string; + viewState: MainViewState; + iconName: string; +}; + +const SidebarMenuItem: React.FC<Props> = ({ title, viewState, iconName }) => { + const currentState = useAtomValue(getMainViewStateAtom); + const setMainViewState = useSetAtom(setMainViewStateAtom); + + let className = + "text-gray-600 hover:text-indigo-600 hover:bg-gray-50 group flex gap-x-3 rounded-md text-base py-2 px-3 w-full"; + if (currentState == viewState) { + className = + "bg-gray-100 text-indigo-600 group flex gap-x-3 rounded-md text-base py-2 px-3 w-full"; + } + + const onClick = () => { + setMainViewState(viewState); + }; + + return ( + <li key={title}> + <button onClick={onClick} className={className}> + <Image src={`icons/${iconName}.svg`} width={24} height={24} alt="" /> + <span className="truncate">{title}</span> + </button> + </li> + ); +}; + +export default SidebarMenuItem; diff --git a/web/app/_components/SimpleCheckbox/index.tsx b/web/app/_components/SimpleCheckbox/index.tsx new file mode 100644 index 000000000..1a313ac9f --- /dev/null +++ b/web/app/_components/SimpleCheckbox/index.tsx @@ -0,0 +1,22 @@ +type Props = { + name: string; +}; + +const SimpleCheckbox: React.FC<Props> = ({ name }) => ( + <div className="relative flex items-center gap-[11px]"> + <div className="flex h-6 items-center"> + <input + id="offers" + aria-describedby="offers-description" + name="offers" + type="checkbox" + className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600" + /> + </div> + <div className="text-xs"> + <label htmlFor="offers">{name}</label> + </div> + </div> +); + +export default SimpleCheckbox; diff --git a/web/app/_components/SimpleTag/index.tsx b/web/app/_components/SimpleTag/index.tsx new file mode 100644 index 000000000..7bd3f5e4d --- /dev/null +++ b/web/app/_components/SimpleTag/index.tsx @@ -0,0 +1,89 @@ +import React from "react"; + +export enum TagType { + Roleplay = "Roleplay", + Llama = "Llama", + Story = "Story", + Casual = "Casual", + Professional = "Professional", + CodeLlama = "CodeLlama", + Coding = "Coding", + + // Positive + Recommended = "Recommended", + Compatible = "Compatible", + + // Neutral + SlowOnDevice = "This model will be slow on your device", + + // Negative + InsufficientRam = "Insufficient RAM", + Incompatible = "Incompatible with your device", + TooLarge = "This model is too large for your device", + + // Performance + Medium = "Medium", + BalancedQuality = "Balanced Quality", +} + +const tagStyleMapper: Record<TagType, string> = { + [TagType.Roleplay]: "bg-red-100 text-red-800", + [TagType.Llama]: "bg-green-100 text-green-800", + [TagType.Story]: "bg-blue-100 text-blue-800", + [TagType.Casual]: "bg-yellow-100 text-yellow-800", + [TagType.Professional]: "text-indigo-800 bg-indigo-100", + [TagType.CodeLlama]: "bg-pink-100 text-pink-800", + [TagType.Coding]: "text-purple-800 bg-purple-100", + + [TagType.Recommended]: + "text-green-700 ring-1 ring-inset ring-green-600/20 bg-green-50", + [TagType.Compatible]: + "bg-red-50 ext-red-700 ring-1 ring-inset ring-red-600/10", + + [TagType.SlowOnDevice]: + "bg-yellow-50 text-yellow-800 ring-1 ring-inset ring-yellow-600/20", + + [TagType.Incompatible]: + "bg-red-50 ext-red-700 ring-1 ring-inset ring-red-600/10", + [TagType.InsufficientRam]: + "bg-red-50 ext-red-700 ring-1 ring-inset ring-red-600/10", + [TagType.TooLarge]: "bg-red-50 ext-red-700 ring-1 ring-inset ring-red-600/10", + + [TagType.Medium]: "bg-yellow-100 text-yellow-800", + [TagType.BalancedQuality]: "bg-yellow-100 text-yellow-800", +}; + +type Props = { + title: string; + type: TagType; + clickable?: boolean; + onClick?: () => void; +}; + +const SimpleTag: React.FC<Props> = ({ + onClick, + clickable = true, + title, + type, +}) => { + if (!clickable) { + return ( + <div + className={`px-[10px] py-0.5 rounded text-xs font-medium ${tagStyleMapper[type]}`} + > + {title} + </div> + ); + } + + return ( + <button + onClick={onClick} + className={`px-[10px] py-0.5 rounded text-xs font-medium ${tagStyleMapper[type]}`} + > + {title} x + </button> + ); +}; + +export default React.memo(SimpleTag); diff --git a/web/app/_components/StreamTextMessage/index.tsx b/web/app/_components/StreamTextMessage/index.tsx index 0dd621cce..e313e12aa 100644 --- a/web/app/_components/StreamTextMessage/index.tsx +++ b/web/app/_components/StreamTextMessage/index.tsx @@ -4,7 +4,7 @@ import { TextCode } from "../TextCode"; import { getMessageCode } from "@/_utils/message"; import Image from "next/image"; import { useAtomValue } from "jotai"; -import { currentStreamingMessageAtom } from "@/_helpers/JotaiWrapper"; +import { currentStreamingMessageAtom } from "@/_helpers/atoms/ChatMessage.atom"; type Props = { id: string; diff --git a/web/app/_components/UserToolbar/index.tsx b/web/app/_components/UserToolbar/index.tsx index 7cbf3430d..bc1b7e845 100644 --- a/web/app/_components/UserToolbar/index.tsx +++ b/web/app/_components/UserToolbar/index.tsx @@ -1,6 +1,6 @@ "use client"; -import { currentConversationAtom } from "@/_helpers/JotaiWrapper"; +import { currentConversationAtom } from "@/_helpers/atoms/Conversation.atom"; import { useAtomValue } from "jotai"; import Image from "next/image"; diff --git a/web/app/_components/WelcomeContainer/index.tsx b/web/app/_components/WelcomeContainer/index.tsx index 7124a0dfd..4ab1097c1 100644 --- a/web/app/_components/WelcomeContainer/index.tsx +++ b/web/app/_components/WelcomeContainer/index.tsx @@ -1,7 +1,10 @@ import Image from "next/image"; -import { SidebarButton } from "../SidebarButton"; import { useSetAtom } from "jotai"; -import { MainViewState, setMainViewStateAtom } from "@/_helpers/JotaiWrapper"; +import { + setMainViewStateAtom, + MainViewState, +} from "@/_helpers/atoms/MainView.atom"; +import SecondaryButton from "../SecondaryButton"; const Welcome: React.FC = () => { const setMainViewState = useSetAtom(setMainViewStateAtom); @@ -15,13 +18,9 @@ const Welcome: React.FC = () => { <br /> let’s download your first model </span> - <SidebarButton - callback={() => setMainViewState(MainViewState.ExploreModel)} - className="flex flex-row-reverse items-center rounded-lg gap-2 px-3 py-2 text-xs font-medium border border-gray-200" - icon={"icons/app_icon.svg"} - title="Explore models" - height={16} - width={16} + <SecondaryButton + title={"Explore models"} + onClick={() => setMainViewState(MainViewState.ExploreModel)} /> </div> </div> diff --git a/web/app/_helpers/EventListenerWrapper.tsx b/web/app/_helpers/EventListenerWrapper.tsx index 74c4b9c8c..8f334d50f 100644 --- a/web/app/_helpers/EventListenerWrapper.tsx +++ b/web/app/_helpers/EventListenerWrapper.tsx @@ -1,15 +1,15 @@ "use client"; -import { useAtom, useSetAtom } from "jotai"; +import { useSetAtom } from "jotai"; import { ReactNode, useEffect } from "react"; -import { - appDownloadProgress, - setDownloadStateAtom, - setDownloadStateSuccessAtom, -} from "./JotaiWrapper"; +import { appDownloadProgress } from "./JotaiWrapper"; import { DownloadState } from "@/_models/DownloadState"; import { execute } from "../../../electron/core/plugin-manager/execution/extension-manager"; import { DataService } from "../../shared/coreService"; +import { + setDownloadStateAtom, + setDownloadStateSuccessAtom, +} from "./atoms/DownloadState.atom"; type Props = { children: ReactNode; diff --git a/web/app/_helpers/JotaiWrapper.tsx b/web/app/_helpers/JotaiWrapper.tsx index f840e59ee..63e4a321c 100644 --- a/web/app/_helpers/JotaiWrapper.tsx +++ b/web/app/_helpers/JotaiWrapper.tsx @@ -1,9 +1,5 @@ "use client"; -import { ChatMessage, MessageStatus } from "@/_models/ChatMessage"; -import { Conversation, ConversationState } from "@/_models/Conversation"; -import { DownloadState } from "@/_models/DownloadState"; -import { Product } from "@/_models/Product"; import { Provider, atom } from "jotai"; import { ReactNode } from "react"; @@ -15,286 +11,11 @@ export default function JotaiWrapper({ children }: Props) { return <Provider>{children}</Provider>; } -const activeConversationIdAtom = atom<string | undefined>(undefined); -export const getActiveConvoIdAtom = atom((get) => - get(activeConversationIdAtom) -); -export const setActiveConvoIdAtom = atom( - null, - (_get, set, convoId: string | undefined) => { - if (convoId) { - console.log(`set active convo id to ${convoId}`); - set(setMainViewStateAtom, MainViewState.Conversation); - } - set(activeConversationIdAtom, convoId); - } -); - export const currentPromptAtom = atom<string>(""); -export const showingAdvancedPromptAtom = atom<boolean>(false); -export const showingProductDetailAtom = atom<boolean>(false); -export const showingMobilePaneAtom = atom<boolean>(false); export const showingTyping = atom<boolean>(false); export const appDownloadProgress = atom<number>(-1); -export const activeModel = atom<string | undefined>(undefined); export const searchingModelText = atom<string>(""); -/** - * Stores all conversations for the current user - */ -export const userConversationsAtom = atom<Conversation[]>([]); -export const currentConversationAtom = atom<Conversation | undefined>((get) => - get(userConversationsAtom).find((c) => c.id === get(activeConversationIdAtom)) -); -export const setConvoUpdatedAtAtom = atom(null, (get, set, convoId: string) => { - const convo = get(userConversationsAtom).find((c) => c.id === convoId); - if (!convo) return; - const newConvo: Conversation = { - ...convo, - updated_at: new Date().toISOString(), - }; - const newConversations: Conversation[] = get(userConversationsAtom).map((c) => - c.id === convoId ? newConvo : c - ); - - set(userConversationsAtom, newConversations); -}); - -export const currentStreamingMessageAtom = atom<ChatMessage | undefined>( - undefined -); - -export const setConvoLastImageAtom = atom( - null, - (get, set, convoId: string, lastImageUrl: string) => { - const convo = get(userConversationsAtom).find((c) => c.id === convoId); - if (!convo) return; - const newConvo: Conversation = { ...convo }; - const newConversations: Conversation[] = get(userConversationsAtom).map( - (c) => (c.id === convoId ? newConvo : c) - ); - - set(userConversationsAtom, newConversations); - } -); - -/** - * Stores all conversation states for the current user - */ -export const conversationStatesAtom = atom<Record<string, ConversationState>>( - {} -); -export const currentConvoStateAtom = atom<ConversationState | undefined>( - (get) => { - const activeConvoId = get(activeConversationIdAtom); - if (!activeConvoId) { - console.log("active convo id is undefined"); - return undefined; - } - - return get(conversationStatesAtom)[activeConvoId]; - } -); -export const addNewConversationStateAtom = atom( - null, - (get, set, conversationId: string, state: ConversationState) => { - const currentState = { ...get(conversationStatesAtom) }; - currentState[conversationId] = state; - set(conversationStatesAtom, currentState); - } -); -export const updateConversationWaitingForResponseAtom = atom( - null, - (get, set, conversationId: string, waitingForResponse: boolean) => { - const currentState = { ...get(conversationStatesAtom) }; - currentState[conversationId] = { - ...currentState[conversationId], - waitingForResponse, - }; - set(conversationStatesAtom, currentState); - } -); -export const updateConversationHasMoreAtom = atom( - null, - (get, set, conversationId: string, hasMore: boolean) => { - const currentState = { ...get(conversationStatesAtom) }; - currentState[conversationId] = { ...currentState[conversationId], hasMore }; - set(conversationStatesAtom, currentState); - } -); - -/** - * Stores all chat messages for all conversations - */ -export const chatMessages = atom<Record<string, ChatMessage[]>>({}); -export const currentChatMessagesAtom = atom<ChatMessage[]>((get) => { - const activeConversationId = get(activeConversationIdAtom); - if (!activeConversationId) return []; - return get(chatMessages)[activeConversationId] ?? []; -}); - -export const addOldMessagesAtom = atom( - null, - (get, set, newMessages: ChatMessage[]) => { - const currentConvoId = get(activeConversationIdAtom); - if (!currentConvoId) return; - - const currentMessages = get(chatMessages)[currentConvoId] ?? []; - const updatedMessages = [...currentMessages, ...newMessages]; - - const newData: Record<string, ChatMessage[]> = { - ...get(chatMessages), - }; - newData[currentConvoId] = updatedMessages; - set(chatMessages, newData); - } -); -export const addNewMessageAtom = atom( - null, - (get, set, newMessage: ChatMessage) => { - const currentConvoId = get(activeConversationIdAtom); - if (!currentConvoId) return; - - const currentMessages = get(chatMessages)[currentConvoId] ?? []; - const updatedMessages = [newMessage, ...currentMessages]; - - const newData: Record<string, ChatMessage[]> = { - ...get(chatMessages), - }; - newData[currentConvoId] = updatedMessages; - set(chatMessages, newData); - } -); - -export const deleteConversationMessage = atom(null, (get, set, id: string) => { - const newData: Record<string, ChatMessage[]> = { - ...get(chatMessages), - }; - newData[id] = []; - set(chatMessages, newData); -}); - -export const updateMessageAtom = atom( - null, - (get, set, id: string, conversationId: string, text: string) => { - const messages = get(chatMessages)[conversationId] ?? []; - const message = messages.find((e) => e.id === id); - if (message) { - message.text = text; - const updatedMessages = [...messages]; - - const newData: Record<string, ChatMessage[]> = { - ...get(chatMessages), - }; - newData[conversationId] = updatedMessages; - set(chatMessages, newData); - } - } -); -/** - * For updating the status of the last AI message that is pending - */ -export const updateLastMessageAsReadyAtom = atom( - null, - (get, set, id, text: string) => { - const currentConvoId = get(activeConversationIdAtom); - if (!currentConvoId) return; - - const currentMessages = get(chatMessages)[currentConvoId] ?? []; - const messageToUpdate = currentMessages.find((e) => e.id === id); - - // if message is not found, do nothing - if (!messageToUpdate) return; - - const index = currentMessages.indexOf(messageToUpdate); - const updatedMsg: ChatMessage = { - ...messageToUpdate, - status: MessageStatus.Ready, - text: text, - }; - - currentMessages[index] = updatedMsg; - const newData: Record<string, ChatMessage[]> = { - ...get(chatMessages), - }; - newData[currentConvoId] = currentMessages; - set(chatMessages, newData); - } -); - -export const currentProductAtom = atom<Product | undefined>(undefined); - export const searchAtom = atom<string>(""); - -// modal atoms -export const showConfirmDeleteConversationModalAtom = atom(false); -export const showConfirmSignOutModalAtom = atom(false); -export const showConfirmDeleteModalAtom = atom(false); - -export type FileDownloadStates = { - [key: string]: DownloadState; -}; - -// main view state -export enum MainViewState { - Welcome, - ExploreModel, - MyModel, - ResourceMonitor, - Setting, - Conversation, -} - -const systemBarVisibilityAtom = atom<boolean>(true); -export const getSystemBarVisibilityAtom = atom((get) => - get(systemBarVisibilityAtom) -); - -const currentMainViewStateAtom = atom<MainViewState>(MainViewState.Welcome); -export const getMainViewStateAtom = atom((get) => - get(currentMainViewStateAtom) -); - -export const setMainViewStateAtom = atom( - null, - (_get, set, state: MainViewState) => { - if (state !== MainViewState.Conversation) { - set(activeConversationIdAtom, undefined); - } - const showSystemBar = state !== MainViewState.Conversation; - set(systemBarVisibilityAtom, showSystemBar); - set(currentMainViewStateAtom, state); - } -); - -// download states -export const modelDownloadStateAtom = atom<FileDownloadStates>({}); - -export const setDownloadStateAtom = atom( - null, - (get, set, state: DownloadState) => { - const currentState = { ...get(modelDownloadStateAtom) }; - console.debug( - `current download state for ${state.fileName} is ${JSON.stringify(state)}` - ); - currentState[state.fileName] = state; - set(modelDownloadStateAtom, currentState); - } -); - -export const setDownloadStateSuccessAtom = atom( - null, - (get, set, fileName: string) => { - const currentState = { ...get(modelDownloadStateAtom) }; - const state = currentState[fileName]; - if (!state) { - console.error(`Cannot find download state for ${fileName}`); - return; - } - - delete currentState[fileName]; - set(modelDownloadStateAtom, currentState); - } -); diff --git a/web/app/_helpers/atoms/ChatMessage.atom.ts b/web/app/_helpers/atoms/ChatMessage.atom.ts new file mode 100644 index 000000000..08ec0a367 --- /dev/null +++ b/web/app/_helpers/atoms/ChatMessage.atom.ts @@ -0,0 +1,109 @@ +import { ChatMessage, MessageStatus } from "@/_models/ChatMessage"; +import { atom } from "jotai"; +import { getActiveConvoIdAtom } from "./Conversation.atom"; + +/** + * Stores all chat messages for all conversations + */ +export const chatMessages = atom<Record<string, ChatMessage[]>>({}); + +export const currentChatMessagesAtom = atom<ChatMessage[]>((get) => { + const activeConversationId = get(getActiveConvoIdAtom); + if (!activeConversationId) return []; + return get(chatMessages)[activeConversationId] ?? []; +}); + +export const addOldMessagesAtom = atom( + null, + (get, set, newMessages: ChatMessage[]) => { + const currentConvoId = get(getActiveConvoIdAtom); + if (!currentConvoId) return; + + const currentMessages = get(chatMessages)[currentConvoId] ?? []; + const updatedMessages = [...currentMessages, ...newMessages]; + + const newData: Record<string, ChatMessage[]> = { + ...get(chatMessages), + }; + newData[currentConvoId] = updatedMessages; + set(chatMessages, newData); + } +); + +export const addNewMessageAtom = atom( + null, + (get, set, newMessage: ChatMessage) => { + const currentConvoId = get(getActiveConvoIdAtom); + if (!currentConvoId) return; + + const currentMessages = get(chatMessages)[currentConvoId] ?? []; + const updatedMessages = [newMessage, ...currentMessages]; + + const newData: Record<string, ChatMessage[]> = { + ...get(chatMessages), + }; + newData[currentConvoId] = updatedMessages; + set(chatMessages, newData); + } +); + +export const deleteConversationMessage = atom(null, (get, set, id: string) => { + const newData: Record<string, ChatMessage[]> = { + ...get(chatMessages), + }; + newData[id] = []; + set(chatMessages, newData); +}); + +export const updateMessageAtom = atom( + null, + (get, set, id: string, conversationId: string, text: string) => { + const messages = get(chatMessages)[conversationId] ?? []; + const message = messages.find((e) => e.id === id); + if (message) { + message.text = text; + const updatedMessages = [...messages]; + + const newData: Record<string, ChatMessage[]> = { + ...get(chatMessages), + }; + newData[conversationId] = updatedMessages; + set(chatMessages, newData); + } + } +); + +/** + * For updating the status of the last AI message that is pending + */ +export const updateLastMessageAsReadyAtom = atom( + null, + (get, set, id, text: string) => { + const currentConvoId = get(getActiveConvoIdAtom); + if (!currentConvoId) return; + + const currentMessages = get(chatMessages)[currentConvoId] ?? []; + const messageToUpdate = currentMessages.find((e) => e.id === id); + + // if message is not found, do nothing + if (!messageToUpdate) return; + + const index = currentMessages.indexOf(messageToUpdate); + const updatedMsg: ChatMessage = { + ...messageToUpdate, + status: MessageStatus.Ready, + text: text, + }; + + currentMessages[index] = updatedMsg; + const newData: Record<string, ChatMessage[]> = { + ...get(chatMessages), + }; + newData[currentConvoId] = currentMessages; + set(chatMessages, newData); + } +); + +export const currentStreamingMessageAtom = atom<ChatMessage | undefined>( + undefined +); diff --git a/web/app/_helpers/atoms/Conversation.atom.ts b/web/app/_helpers/atoms/Conversation.atom.ts new file mode 100644 index 000000000..7f1b312c9 --- /dev/null +++ b/web/app/_helpers/atoms/Conversation.atom.ts @@ -0,0 +1,104 @@ +import { atom } from "jotai"; +import { MainViewState, setMainViewStateAtom } from "./MainView.atom"; +import { Conversation, ConversationState } from "@/_models/Conversation"; + +/** + * Stores the current active conversation id. + */ +const activeConversationIdAtom = atom<string | undefined>(undefined); + +export const getActiveConvoIdAtom = atom((get) => + get(activeConversationIdAtom) +); + +export const setActiveConvoIdAtom = atom( + null, + (_get, set, convoId: string | undefined) => { + if (convoId) { + console.debug(`Set active conversation id: ${convoId}`); + set(setMainViewStateAtom, MainViewState.Conversation); + } + + set(activeConversationIdAtom, convoId); + } +); + +/** + * Stores all conversation states for the current user + */ +export const conversationStatesAtom = atom<Record<string, ConversationState>>( + {} +); +export const currentConvoStateAtom = atom<ConversationState | undefined>( + (get) => { + const activeConvoId = get(activeConversationIdAtom); + if (!activeConvoId) { + console.log("active convo id is undefined"); + return undefined; + } + + return get(conversationStatesAtom)[activeConvoId]; + } +); +export const addNewConversationStateAtom = atom( + null, + (get, set, conversationId: string, state: ConversationState) => { + const currentState = { ...get(conversationStatesAtom) }; + currentState[conversationId] = state; + set(conversationStatesAtom, currentState); + } +); +export const updateConversationWaitingForResponseAtom = atom( + null, + (get, set, conversationId: string, waitingForResponse: boolean) => { + const currentState = { ...get(conversationStatesAtom) }; + currentState[conversationId] = { + ...currentState[conversationId], + waitingForResponse, + }; + set(conversationStatesAtom, currentState); + } +); +export const updateConversationHasMoreAtom = atom( + null, + (get, set, conversationId: string, hasMore: boolean) => { + const currentState = { ...get(conversationStatesAtom) }; + currentState[conversationId] = { ...currentState[conversationId], hasMore }; + set(conversationStatesAtom, currentState); + } +); + +/** + * Stores all conversations for the current user + */ +export const userConversationsAtom = atom<Conversation[]>([]); +export const currentConversationAtom = atom<Conversation | undefined>((get) => + get(userConversationsAtom).find((c) => c.id === get(getActiveConvoIdAtom)) +); +export const setConvoUpdatedAtAtom = atom(null, (get, set, convoId: string) => { + const convo = get(userConversationsAtom).find((c) => c.id === convoId); + if (!convo) return; + const newConvo: Conversation = { + ...convo, + updated_at: new Date().toISOString(), + }; + const newConversations: Conversation[] = get(userConversationsAtom).map((c) => + c.id === convoId ? newConvo : c + ); + + set(userConversationsAtom, newConversations); +}); + +export const setConvoLastImageAtom = atom( + null, + (get, set, convoId: string, lastImageUrl: string) => { + const convo = get(userConversationsAtom).find((c) => c.id === convoId); + if (!convo) return; + const newConvo: Conversation = { ...convo }; + const newConversations: Conversation[] = get(userConversationsAtom).map( + (c) => (c.id === convoId ? newConvo : c) + ); + + set(userConversationsAtom, newConversations); + } +); diff --git a/web/app/_helpers/atoms/DownloadState.atom.ts b/web/app/_helpers/atoms/DownloadState.atom.ts new file mode 100644 index 000000000..d0491b454 --- /dev/null +++ b/web/app/_helpers/atoms/DownloadState.atom.ts @@ -0,0 +1,32 @@ +import { DownloadState } from "@/_models/DownloadState"; +import { atom } from "jotai"; + +// download states +export const modelDownloadStateAtom = atom<Record<string, DownloadState>>({}); + +export const setDownloadStateAtom = atom( + null, + (get, set, state: DownloadState) => { + const currentState = { ...get(modelDownloadStateAtom) }; + console.debug( + `current download state for ${state.fileName} is ${JSON.stringify(state)}` + ); + currentState[state.fileName] = state; + set(modelDownloadStateAtom, currentState); + } +); + +export const setDownloadStateSuccessAtom = atom( + null, + (get, set, fileName: string) => { + const currentState = { ...get(modelDownloadStateAtom) }; + const state = currentState[fileName]; + if (!state) { + console.error(`Cannot find download state for ${fileName}`); + return; + } + + delete currentState[fileName]; + set(modelDownloadStateAtom, currentState); + } +); diff --git a/web/app/_helpers/atoms/MainView.atom.ts b/web/app/_helpers/atoms/MainView.atom.ts new file mode 100644 index 000000000..45d267542 --- /dev/null +++ b/web/app/_helpers/atoms/MainView.atom.ts @@ -0,0 +1,54 @@ +import { atom } from "jotai"; +import { setActiveConvoIdAtom } from "./Conversation.atom"; +import { systemBarVisibilityAtom } from "./SystemBar.atom"; + +export enum MainViewState { + Welcome, + ExploreModel, + MyModel, + ResourceMonitor, + Setting, + Conversation, + + /** + * When user wants to create new conversation but haven't selected a model yet. + */ + ConversationEmptyModel, +} + +/** + * Stores the current main view state. Default is Welcome. + */ +const currentMainViewStateAtom = atom<MainViewState>(MainViewState.Welcome); + +/** + * Getter for current main view state. + */ +export const getMainViewStateAtom = atom((get) => + get(currentMainViewStateAtom) +); + +/** + * Setter for current main view state. + */ +export const setMainViewStateAtom = atom( + null, + (get, set, state: MainViewState) => { + // return if the state is already set + if (get(getMainViewStateAtom) === state) return; + + if (state !== MainViewState.Conversation) { + // clear active conversation id if main view state is not Conversation + set(setActiveConvoIdAtom, undefined); + } + + const showSystemBar = + state !== MainViewState.Conversation && + state !== MainViewState.ConversationEmptyModel; + + // show system bar if state is not Conversation nor ConversationEmptyModel + set(systemBarVisibilityAtom, showSystemBar); + + set(currentMainViewStateAtom, state); + } +); diff --git a/web/app/_helpers/atoms/Modal.atom.ts b/web/app/_helpers/atoms/Modal.atom.ts new file mode 100644 index 000000000..54a8336f3 --- /dev/null +++ b/web/app/_helpers/atoms/Modal.atom.ts @@ -0,0 +1,8 @@ +import { atom } from "jotai"; + +export const showConfirmDeleteConversationModalAtom = atom(false); +export const showConfirmSignOutModalAtom = atom(false); +export const showConfirmDeleteModalAtom = atom(false); +export const showingAdvancedPromptAtom = atom<boolean>(false); +export const showingProductDetailAtom = atom<boolean>(false); +export const showingMobilePaneAtom = atom<boolean>(false); diff --git a/web/app/_helpers/atoms/Model.atom.ts b/web/app/_helpers/atoms/Model.atom.ts new file mode 100644 index 000000000..053f03ac6 --- /dev/null +++ b/web/app/_helpers/atoms/Model.atom.ts @@ -0,0 +1,6 @@ +import { Product } from "@/_models/Product"; +import { atom } from "jotai"; + +export const currentProductAtom = atom<Product | undefined>(undefined); + +export const selectedModelAtom = atom<Product | undefined>(undefined); diff --git a/web/app/_helpers/atoms/SystemBar.atom.ts b/web/app/_helpers/atoms/SystemBar.atom.ts new file mode 100644 index 000000000..fc1b777d4 --- /dev/null +++ b/web/app/_helpers/atoms/SystemBar.atom.ts @@ -0,0 +1,7 @@ +import { atom } from "jotai"; + +export const systemBarVisibilityAtom = atom<boolean>(true); + +export const getSystemBarVisibilityAtom = atom((get) => + get(systemBarVisibilityAtom) +); diff --git a/web/app/_hooks/useChatMessages.ts b/web/app/_hooks/useChatMessages.ts index 8400e6ea3..073a57ea4 100644 --- a/web/app/_hooks/useChatMessages.ts +++ b/web/app/_hooks/useChatMessages.ts @@ -1,14 +1,14 @@ -import { - addOldMessagesAtom, - conversationStatesAtom, - currentConversationAtom, - updateConversationHasMoreAtom, -} from "@/_helpers/JotaiWrapper"; import { ChatMessage, RawMessage, toChatMessage } from "@/_models/ChatMessage"; import { executeSerial } from "@/_services/pluginService"; import { useAtomValue, useSetAtom } from "jotai"; import { useEffect, useState } from "react"; import { DataService } from "../../shared/coreService"; +import { addOldMessagesAtom } from "@/_helpers/atoms/ChatMessage.atom"; +import { + currentConversationAtom, + conversationStatesAtom, + updateConversationHasMoreAtom, +} from "@/_helpers/atoms/Conversation.atom"; /** * Custom hooks to get chat messages for current(active) conversation diff --git a/web/app/_hooks/useCreateConversation.ts b/web/app/_hooks/useCreateConversation.ts index ffe68ba49..fcff74956 100644 --- a/web/app/_hooks/useCreateConversation.ts +++ b/web/app/_hooks/useCreateConversation.ts @@ -1,25 +1,22 @@ -// import useGetCurrentUser from "./useGetCurrentUser"; import { useAtom, useSetAtom } from "jotai"; -import { - activeModel, - addNewConversationStateAtom, - currentProductAtom, - setActiveConvoIdAtom, - userConversationsAtom, -} from "@/_helpers/JotaiWrapper"; import { Conversation } from "@/_models/Conversation"; import { executeSerial } from "@/_services/pluginService"; -import { DataService, InfereceService } from "../../shared/coreService"; +import { DataService } from "../../shared/coreService"; import { Product } from "@/_models/Product"; +import { + userConversationsAtom, + setActiveConvoIdAtom, + addNewConversationStateAtom, +} from "@/_helpers/atoms/Conversation.atom"; +import useInitModel from "./useInitModel"; const useCreateConversation = () => { + const { initModel } = useInitModel(); const [userConversations, setUserConversations] = useAtom( userConversationsAtom ); const setActiveConvoId = useSetAtom(setActiveConvoIdAtom); const addNewConvoState = useSetAtom(addNewConversationStateAtom); - const setActiveModel = useSetAtom(activeModel); - const setActiveProduct = useSetAtom(currentProductAtom); const requestCreateConvo = async (model: Product) => { const conv: Conversation = { @@ -30,9 +27,7 @@ const useCreateConversation = () => { name: "Conversation", }; const id = await executeSerial(DataService.CREATE_CONVERSATION, conv); - await executeSerial(InfereceService.INIT_MODEL, model); - setActiveProduct(model); - setActiveModel(model.name); + await initModel(model); const mappedConvo: Conversation = { id, diff --git a/web/app/_hooks/useDeleteConversation.ts b/web/app/_hooks/useDeleteConversation.ts index 79fdad93c..df1cbd96c 100644 --- a/web/app/_hooks/useDeleteConversation.ts +++ b/web/app/_hooks/useDeleteConversation.ts @@ -1,15 +1,17 @@ -import { - currentPromptAtom, - deleteConversationMessage, - getActiveConvoIdAtom, - setActiveConvoIdAtom, - showingAdvancedPromptAtom, - showingProductDetailAtom, - userConversationsAtom, -} from "@/_helpers/JotaiWrapper"; +import { currentPromptAtom } from "@/_helpers/JotaiWrapper"; import { execute } from "@/_services/pluginService"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { DataService } from "../../shared/coreService"; +import { deleteConversationMessage } from "@/_helpers/atoms/ChatMessage.atom"; +import { + userConversationsAtom, + getActiveConvoIdAtom, + setActiveConvoIdAtom, +} from "@/_helpers/atoms/Conversation.atom"; +import { + showingProductDetailAtom, + showingAdvancedPromptAtom, +} from "@/_helpers/atoms/Modal.atom"; export default function useDeleteConversation() { const [userConversations, setUserConversations] = useAtom( diff --git a/web/app/_hooks/useDeleteModel.ts b/web/app/_hooks/useDeleteModel.ts new file mode 100644 index 000000000..450806bd2 --- /dev/null +++ b/web/app/_hooks/useDeleteModel.ts @@ -0,0 +1,12 @@ +import { execute, executeSerial } from "@/_services/pluginService"; +import { DataService, ModelManagementService } from "../../shared/coreService"; +import { Product } from "@/_models/Product"; + +export default function useDeleteModel() { + const deleteModel = async (model: Product) => { + execute(DataService.DELETE_DOWNLOAD_MODEL, model.id); + await executeSerial(ModelManagementService.DELETE_MODEL, model.fileName); + }; + + return { deleteModel }; +} diff --git a/web/app/_hooks/useDownloadModel.ts b/web/app/_hooks/useDownloadModel.ts new file mode 100644 index 000000000..d29697a7d --- /dev/null +++ b/web/app/_hooks/useDownloadModel.ts @@ -0,0 +1,17 @@ +import { executeSerial } from "@/_services/pluginService"; +import { DataService, ModelManagementService } from "../../shared/coreService"; +import { Product } from "@/_models/Product"; + +export default function useDownloadModel() { + const downloadModel = async (model: Product) => { + await executeSerial(DataService.STORE_MODEL, model); + await executeSerial(ModelManagementService.DOWNLOAD_MODEL, { + downloadUrl: model.downloadUrl, + fileName: model.fileName, + }); + }; + + return { + downloadModel, + }; +} diff --git a/web/app/_hooks/useGetAvailableModels.ts b/web/app/_hooks/useGetAvailableModels.ts new file mode 100644 index 000000000..b10cc617b --- /dev/null +++ b/web/app/_hooks/useGetAvailableModels.ts @@ -0,0 +1,54 @@ +import { Product } from "@/_models/Product"; +import { executeSerial } from "@/_services/pluginService"; +import { ModelManagementService } from "../../shared/coreService"; +import { useEffect, useState } from "react"; +import { getModelFiles } from "./useGetDownloadedModels"; +import { useAtomValue } from "jotai"; +import { modelDownloadStateAtom } from "@/_helpers/atoms/DownloadState.atom"; + +export default function useGetAvailableModels() { + const downloadState = useAtomValue(modelDownloadStateAtom); + const [allAvailableModels, setAllAvailableModels] = useState<Product[]>([]); + const [availableModels, setAvailableModels] = useState<Product[]>([]); + const [downloadedModels, setDownloadedModels] = useState<Product[]>([]); + + const getAvailableModelExceptDownloaded = async () => { + const avails = await getAvailableModels(); + const downloaded = await getModelFiles(); + + setAllAvailableModels(avails); + const availableOrDownloadingModels: Product[] = avails; + const successfullDownloadModels: Product[] = []; + + downloaded.forEach((item) => { + if (item.fileName && downloadState[item.fileName] == null) { + // if not downloading, consider as downloaded + successfullDownloadModels.push(item); + } else { + availableOrDownloadingModels.push(item); + } + }); + + setAvailableModels(availableOrDownloadingModels); + setDownloadedModels(successfullDownloadModels); + }; + + useEffect(() => { + getAvailableModelExceptDownloaded(); + }, []); + + return { + allAvailableModels, + availableModels, + downloadedModels, + getAvailableModelExceptDownloaded, + }; +} + +export async function getAvailableModels(): Promise<Product[]> { + const avails: Product[] = await executeSerial( + ModelManagementService.GET_AVAILABLE_MODELS + ); + + return avails ?? []; +} diff --git a/web/app/_hooks/useGetDownloadedModels.ts b/web/app/_hooks/useGetDownloadedModels.ts new file mode 100644 index 000000000..90cfa00a4 --- /dev/null +++ b/web/app/_hooks/useGetDownloadedModels.ts @@ -0,0 +1,30 @@ +import { Product } from "@/_models/Product"; +import { useEffect, useState } from "react"; +import { executeSerial } from "../../../electron/core/plugin-manager/execution/extension-manager"; +import { DataService, ModelManagementService } from "../../shared/coreService"; + +export function useGetDownloadedModels() { + const [downloadedModels, setDownloadedModels] = useState<Product[]>([]); + + useEffect(() => { + getDownloadedModels().then((downloadedModels) => { + setDownloadedModels(downloadedModels); + }); + }, []); + + return { downloadedModels }; +} + +export async function getDownloadedModels(): Promise<Product[]> { + const downloadedModels: Product[] = await executeSerial( + DataService.GET_FINISHED_DOWNLOAD_MODELS + ); + return downloadedModels ?? []; +} + +export async function getModelFiles(): Promise<Product[]> { + const downloadedModels: Product[] = await executeSerial( + ModelManagementService.GET_DOWNLOADED_MODELS + ); + return downloadedModels ?? []; +} diff --git a/web/app/_hooks/useGetUserConversations.ts b/web/app/_hooks/useGetUserConversations.ts index f315e197a..e939395ba 100644 --- a/web/app/_hooks/useGetUserConversations.ts +++ b/web/app/_hooks/useGetUserConversations.ts @@ -1,11 +1,11 @@ import { Conversation, ConversationState } from "@/_models/Conversation"; import { useSetAtom } from "jotai"; +import { executeSerial } from "@/_services/pluginService"; +import { DataService } from "../../shared/coreService"; import { conversationStatesAtom, userConversationsAtom, -} from "@/_helpers/JotaiWrapper"; -import { executeSerial } from "@/_services/pluginService"; -import { DataService } from "../../shared/coreService"; +} from "@/_helpers/atoms/Conversation.atom"; const useGetUserConversations = () => { const setConversationStates = useSetAtom(conversationStatesAtom); diff --git a/web/app/_hooks/useInitModel.ts b/web/app/_hooks/useInitModel.ts new file mode 100644 index 000000000..18f8d7b87 --- /dev/null +++ b/web/app/_hooks/useInitModel.ts @@ -0,0 +1,25 @@ +import { Product } from "@/_models/Product"; +import { executeSerial } from "@/_services/pluginService"; +import { InfereceService } from "../../shared/coreService"; +import { useAtom } from "jotai"; +import { currentProductAtom } from "@/_helpers/atoms/Model.atom"; + +export default function useInitModel() { + const [activeModel, setActiveModel] = useAtom(currentProductAtom); + + const initModel = async (model: Product) => { + if (activeModel && activeModel.id === model.id) { + console.debug(`Model ${model.id} is already init. Ignore..`); + return; + } + try { + await executeSerial(InfereceService.INIT_MODEL, model); + console.debug(`Init model ${model.name} successfully!`); + setActiveModel(model); + } catch (err) { + console.error(`Init model ${model.name} failed: ${err}`); + } + }; + + return { initModel }; +} diff --git a/web/app/_hooks/useSendChatMessage.ts b/web/app/_hooks/useSendChatMessage.ts index f5bca3f96..ccc037d2f 100644 --- a/web/app/_hooks/useSendChatMessage.ts +++ b/web/app/_hooks/useSendChatMessage.ts @@ -1,14 +1,4 @@ -import { - addNewMessageAtom, - chatMessages, - currentConversationAtom, - currentPromptAtom, - currentStreamingMessageAtom, - getActiveConvoIdAtom, - showingTyping, - updateMessageAtom, -} from "@/_helpers/JotaiWrapper"; - +import { currentPromptAtom, showingTyping } from "@/_helpers/JotaiWrapper"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { selectAtom } from "jotai/utils"; import { DataService, InfereceService } from "../../shared/coreService"; @@ -19,6 +9,16 @@ import { } from "@/_models/ChatMessage"; import { executeSerial } from "@/_services/pluginService"; import { useCallback } from "react"; +import { + addNewMessageAtom, + updateMessageAtom, + chatMessages, + currentStreamingMessageAtom, +} from "@/_helpers/atoms/ChatMessage.atom"; +import { + currentConversationAtom, + getActiveConvoIdAtom, +} from "@/_helpers/atoms/Conversation.atom"; export default function useSendChatMessage() { const currentConvo = useAtomValue(currentConversationAtom); @@ -50,9 +50,9 @@ export default function useSendChatMessage() { const newChatMessage = await toChatMessage(newMessage); addNewMessage(newChatMessage); - + const messageHistory = chatMessagesHistory ?? []; const recentMessages = [ - ...chatMessagesHistory.sort((a, b) => parseInt(a.id) - parseInt(b.id)), + ...messageHistory.sort((a, b) => parseInt(a.id) - parseInt(b.id)), newChatMessage, ] .slice(-10) diff --git a/web/app/_hooks/useStartStopModel.ts b/web/app/_hooks/useStartStopModel.ts new file mode 100644 index 000000000..2201e8fce --- /dev/null +++ b/web/app/_hooks/useStartStopModel.ts @@ -0,0 +1,20 @@ +import { executeSerial } from "@/_services/pluginService"; +import { DataService } from "../../shared/coreService"; +import useInitModel from "./useInitModel"; + +export default function useStartStopModel() { + const { initModel } = useInitModel(); + + const startModel = async (modelId: string) => { + const model = await executeSerial(DataService.GET_MODEL_BY_ID, modelId); + if (!model) { + alert(`Model ${modelId} not found! Please re-download the model first.`); + } else { + await initModel(model); + } + }; + + const stopModel = async (modelId: string) => {}; + + return { startModel, stopModel }; +} diff --git a/web/app/_models/Product.ts b/web/app/_models/Product.ts index 1845b6e36..dad227bd3 100644 --- a/web/app/_models/Product.ts +++ b/web/app/_models/Product.ts @@ -27,4 +27,10 @@ export interface Product { updatedAt?: number; fileName?: string; downloadUrl?: string; + + accelerated: boolean; // TODO: add this in the database + totalSize: number; // TODO: add this in the database + format: string; // TODO: add this in the database // GGUF or something else + status: string; // TODO: add this in the database // Downloaded, Active + releaseDate: number; // TODO: add this in the database } diff --git a/web/app/_utils/converter.ts b/web/app/_utils/converter.ts new file mode 100644 index 000000000..8db0ce9b7 --- /dev/null +++ b/web/app/_utils/converter.ts @@ -0,0 +1,15 @@ +export const toGigabytes = (input: number) => { + if (input > 1024 ** 3) { + return (input / 1000 ** 3).toFixed(2) + "GB"; + } else if (input > 1024 ** 2) { + return (input / 1000 ** 2).toFixed(2) + "MB"; + } else if (input > 1024) { + return (input / 1000).toFixed(2) + "KB"; + } else { + return input + "B"; + } +}; + +export const formatDownloadPercentage = (input: number) => { + return (input * 100).toFixed(2) + "%"; +};