From 5d6e9aa8c64344fc6203d6aaf8cf8ba0cd6f0d76 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 22 Nov 2023 05:32:09 +0700 Subject: [PATCH 1/2] refactor: model plugin to follow new specs Signed-off-by: James --- core/src/fs.ts | 20 ++ core/src/plugin.ts | 1 + core/src/plugins/assistant.ts | 14 ++ core/src/plugins/conversational.ts | 30 ++- core/src/plugins/index.ts | 5 + core/src/plugins/inference.ts | 6 +- core/src/plugins/model.ts | 4 +- core/src/types/index.ts | 231 +++++++++++++----- electron/handlers/fs.ts | 44 +++- electron/preload.ts | 13 +- plugins/assistant-plugin/README.md | 77 ++++++ plugins/assistant-plugin/package.json | 33 +++ .../assistant-plugin/src/@types/global.d.ts | 1 + plugins/assistant-plugin/src/index.ts | 107 ++++++++ plugins/assistant-plugin/tsconfig.json | 14 ++ plugins/assistant-plugin/webpack.config.js | 38 +++ plugins/conversational-json/src/index.ts | 152 ++++++++---- plugins/inference-plugin/src/helpers/sse.ts | 7 +- plugins/inference-plugin/src/index.ts | 68 ++++-- plugins/inference-plugin/src/module.ts | 38 +-- .../model-plugin/src/helpers/modelParser.ts | 58 +++-- plugins/model-plugin/src/index.ts | 25 +- web/containers/CardSidebar/index.tsx | 90 +++++++ web/containers/DropdownListSidebar/index.tsx | 104 ++++++++ web/containers/ItemCardSidebar/index.tsx | 20 ++ web/containers/JanImage/index.tsx | 33 --- .../Layout/TopBar/CommandSearch/index.tsx | 13 +- web/containers/ModalCancelDownload/index.tsx | 4 +- web/containers/Providers/EventHandler.tsx | 67 +++-- web/helpers/atoms/ChatMessage.atom.ts | 86 ++----- web/helpers/atoms/Conversation.atom.ts | 89 +++---- web/helpers/atoms/Model.atom.ts | 1 - web/hooks/useActiveModel.ts | 23 +- web/hooks/useCreateConversation.ts | 47 ---- web/hooks/useCreateNewThread.ts | 90 +++++++ web/hooks/useDeleteConversation.ts | 84 +++---- web/hooks/useDeleteModel.ts | 10 +- web/hooks/useDownloadModel.ts | 48 +--- web/hooks/useGetAllThreads.ts | 42 ++++ web/hooks/useGetAssistants.ts | 32 +++ web/hooks/useGetDownloadedModels.ts | 22 +- web/hooks/useGetInputState.ts | 56 ----- web/hooks/useGetMostSuitableModelVersion.ts | 10 +- web/hooks/useGetPerformanceTag.ts | 6 +- web/hooks/useGetUserConversations.ts | 44 ---- web/hooks/useSendChatMessage.ts | 129 +++++++--- web/hooks/useSetActiveThread.ts | 37 +++ web/screens/Chat/ChatBody/index.tsx | 5 +- web/screens/Chat/HistoryList/index.tsx | 111 --------- web/screens/Chat/Sidebar/index.tsx | 119 +++++++++ web/screens/Chat/SimpleTextMessage/index.tsx | 14 +- web/screens/Chat/ThreadList/index.tsx | 91 +++++++ web/screens/Chat/index.tsx | 109 +++------ .../ExploreModels/ExploreModelItem/index.tsx | 26 +- .../ExploreModelItemHeader/index.tsx | 14 +- .../ExploreModels/ModelVersionItem/index.tsx | 46 ++-- .../ExploreModels/ModelVersionList/index.tsx | 15 +- web/screens/MyModels/index.tsx | 14 +- web/types/conversation.d.ts | 6 - web/utils/conversation.ts | 4 +- web/utils/dummy.ts | 119 +++++++-- 61 files changed, 1874 insertions(+), 992 deletions(-) create mode 100644 core/src/plugins/assistant.ts create mode 100644 plugins/assistant-plugin/README.md create mode 100644 plugins/assistant-plugin/package.json create mode 100644 plugins/assistant-plugin/src/@types/global.d.ts create mode 100644 plugins/assistant-plugin/src/index.ts create mode 100644 plugins/assistant-plugin/tsconfig.json create mode 100644 plugins/assistant-plugin/webpack.config.js create mode 100644 web/containers/CardSidebar/index.tsx create mode 100644 web/containers/DropdownListSidebar/index.tsx create mode 100644 web/containers/ItemCardSidebar/index.tsx delete mode 100644 web/containers/JanImage/index.tsx delete mode 100644 web/hooks/useCreateConversation.ts create mode 100644 web/hooks/useCreateNewThread.ts create mode 100644 web/hooks/useGetAllThreads.ts create mode 100644 web/hooks/useGetAssistants.ts delete mode 100644 web/hooks/useGetInputState.ts delete mode 100644 web/hooks/useGetUserConversations.ts create mode 100644 web/hooks/useSetActiveThread.ts delete mode 100644 web/screens/Chat/HistoryList/index.tsx create mode 100644 web/screens/Chat/Sidebar/index.tsx create mode 100644 web/screens/Chat/ThreadList/index.tsx delete mode 100644 web/types/conversation.d.ts diff --git a/core/src/fs.ts b/core/src/fs.ts index 2c94a2ce82..460e6bf22e 100644 --- a/core/src/fs.ts +++ b/core/src/fs.ts @@ -55,6 +55,23 @@ const rmdir: (path: string) => Promise = (path) => const deleteFile: (path: string) => Promise = (path) => window.coreAPI?.deleteFile(path) ?? window.electronAPI?.deleteFile(path); +/** + * Appends data to a file at the specified path. + * @param path path to the file + * @param data data to append + */ +const appendFile: (path: string, data: string) => Promise = (path, data) => + window.coreAPI?.appendFile(path, data) ?? + window.electronAPI?.appendFile(path, data); + +const readLineByLine: (path: string) => Promise = (path) => + window.coreAPI?.readLineByLine(path) ?? + window.electronAPI?.readLineByLine(path); + +const openFileExplorer: (path: string) => Promise = (path) => + window.coreAPI?.openFileExplorer(path) ?? + window.electronAPI?.openFileExplorer(path); + export const fs = { isDirectory, writeFile, @@ -63,4 +80,7 @@ export const fs = { mkdir, rmdir, deleteFile, + appendFile, + readLineByLine, + openFileExplorer, }; diff --git a/core/src/plugin.ts b/core/src/plugin.ts index 96bfcbe94f..046c8bf5eb 100644 --- a/core/src/plugin.ts +++ b/core/src/plugin.ts @@ -4,6 +4,7 @@ export enum PluginType { Preference = "preference", SystemMonitoring = "systemMonitoring", Model = "model", + Assistant = "assistant", } export abstract class JanPlugin { diff --git a/core/src/plugins/assistant.ts b/core/src/plugins/assistant.ts new file mode 100644 index 0000000000..577a9cdec4 --- /dev/null +++ b/core/src/plugins/assistant.ts @@ -0,0 +1,14 @@ +import { Assistant } from "../index"; +import { JanPlugin } from "../plugin"; + +/** + * Abstract class for assistant plugins. + * @extends JanPlugin + */ +export abstract class AssistantPlugin extends JanPlugin { + abstract createAssistant(assistant: Assistant): Promise; + + abstract deleteAssistant(assistant: Assistant): Promise; + + abstract getAssistants(): Promise; +} diff --git a/core/src/plugins/conversational.ts b/core/src/plugins/conversational.ts index ebeb773331..dc0d68367c 100644 --- a/core/src/plugins/conversational.ts +++ b/core/src/plugins/conversational.ts @@ -1,32 +1,36 @@ -import { Thread } from "../index"; +import { Thread, ThreadMessage } from "../index"; import { JanPlugin } from "../plugin"; /** - * Abstract class for conversational plugins. + * Abstract class for Thread plugins. * @abstract * @extends JanPlugin */ export abstract class ConversationalPlugin extends JanPlugin { /** - * Returns a list of conversations. + * Returns a list of thread. * @abstract - * @returns {Promise} A promise that resolves to an array of conversations. + * @returns {Promise} A promise that resolves to an array of threads. */ - abstract getConversations(): Promise; + abstract getThreads(): Promise; /** - * Saves a conversation. + * Saves a thread. * @abstract - * @param {Thread} conversation - The conversation to save. - * @returns {Promise} A promise that resolves when the conversation is saved. + * @param {Thread} thread - The thread to save. + * @returns {Promise} A promise that resolves when the thread is saved. */ - abstract saveConversation(conversation: Thread): Promise; + abstract saveThread(thread: Thread): Promise; /** - * Deletes a conversation. + * Deletes a thread. * @abstract - * @param {string} conversationId - The ID of the conversation to delete. - * @returns {Promise} A promise that resolves when the conversation is deleted. + * @param {string} threadId - The ID of the thread to delete. + * @returns {Promise} A promise that resolves when the thread is deleted. */ - abstract deleteConversation(conversationId: string): Promise; + abstract deleteThread(threadId: string): Promise; + + abstract addNewMessage(message: ThreadMessage): Promise; + + abstract getAllMessages(threadId: string): Promise; } diff --git a/core/src/plugins/index.ts b/core/src/plugins/index.ts index 5072819d78..a8b464da73 100644 --- a/core/src/plugins/index.ts +++ b/core/src/plugins/index.ts @@ -14,6 +14,11 @@ export { InferencePlugin } from "./inference"; */ export { MonitoringPlugin } from "./monitoring"; +/** + * Assistant plugin for managing assistants. + */ +export { AssistantPlugin } from "./assistant"; + /** * Model plugin for managing models. */ diff --git a/core/src/plugins/inference.ts b/core/src/plugins/inference.ts index 6fc93ed37e..8cbf2717e1 100644 --- a/core/src/plugins/inference.ts +++ b/core/src/plugins/inference.ts @@ -1,4 +1,4 @@ -import { MessageRequest, ThreadMessage } from "../index"; +import { MessageRequest, ModelSettingParams, ThreadMessage } from "../index"; import { JanPlugin } from "../plugin"; /** @@ -7,9 +7,9 @@ import { JanPlugin } from "../plugin"; export abstract class InferencePlugin extends JanPlugin { /** * Initializes the model for the plugin. - * @param modelFileName - The name of the file containing the model. + * @param modelId - The ID of the model to initialize. */ - abstract initModel(modelFileName: string): Promise; + abstract initModel(modelId: string, settings?: ModelSettingParams): Promise; /** * Stops the model for the plugin. diff --git a/core/src/plugins/model.ts b/core/src/plugins/model.ts index 6ef158ec42..9d263aaa1b 100644 --- a/core/src/plugins/model.ts +++ b/core/src/plugins/model.ts @@ -26,10 +26,10 @@ export abstract class ModelPlugin extends JanPlugin { /** * Deletes a model. - * @param filePath - The file path of the model to delete. + * @param modelId - The ID of the model to delete. * @returns A Promise that resolves when the model has been deleted. */ - abstract deleteModel(filePath: string): Promise; + abstract deleteModel(modelId: string): Promise; /** * Saves a model. diff --git a/core/src/types/index.ts b/core/src/types/index.ts index dd227081a7..7caa14d33d 100644 --- a/core/src/types/index.ts +++ b/core/src/types/index.ts @@ -30,10 +30,19 @@ export type ChatCompletionMessage = { */ export type MessageRequest = { id?: string; + /** The thread id of the message request. **/ - threadId?: string; + threadId: string; + + /** + * The assistant id of the message request. + */ + assistantId?: string; + /** Messages for constructing a chat completion request **/ messages?: ChatCompletionMessage[]; + + parameters?: ModelRuntimeParam; }; /** @@ -57,17 +66,39 @@ export enum MessageStatus { */ export type ThreadMessage = { /** Unique identifier for the message, generated by default using the ULID method. **/ - id?: string; + id: string; + object: string; /** Thread id, default is a ulid. **/ - threadId?: string; + thread_id: string; /** The role of the author of this message. **/ - role?: ChatCompletionRole; + assistant_id?: string; + // TODO: comment + role: ChatCompletionRole; /** The content of this message. **/ - content?: string; + content: ThreadContent[]; /** The status of this message. **/ status: MessageStatus; /** The timestamp indicating when this message was created, represented in ISO 8601 format. **/ - createdAt?: string; + created: number; + + updated: number; + + metadata?: Record; +}; + +export enum ContentType { + Text = "text", + Image = "image", +} + +export type ThreadContent = { + type: ContentType; + text: ContentValue; +}; + +export type ContentValue = { + value: string; + annotations: string[]; }; /** @@ -77,60 +108,136 @@ export type ThreadMessage = { export interface Thread { /** Unique identifier for the thread, generated by default using the ULID method. **/ id: string; - /** The summary of this thread. **/ - summary?: string; - /** The messages of this thread. **/ - messages: ThreadMessage[]; + + object: string; + /** The title of this thread. **/ + title: string; + + assistants: ThreadAssistantInfo[]; + // if the thread has been init will full assistant info + isFinishInit: boolean; /** The timestamp indicating when this thread was created, represented in ISO 8601 format. **/ - createdAt?: string; + created: number; /** The timestamp indicating when this thread was updated, represented in ISO 8601 format. **/ - updatedAt?: string; + updated: number; - /** - * @deprecated This field is deprecated and should not be used. - * Read from model file instead. - */ - modelId?: string; + metadata?: Record; } +export type ThreadAssistantInfo = { + assistant_id: string; + assistant_name: string; + model: ModelInfo; +}; + +export type ModelInfo = { + id: string; + settings: ModelSettingParams; + parameters: ModelRuntimeParam; +}; + +export type ThreadState = { + hasMore: boolean; + waitingForResponse: boolean; + error?: Error; + lastMessage?: string; +}; + /** * Model type defines the shape of a model object. * @stored */ export interface Model { - /** Combination of owner and model name.*/ + /** + * The type of the object. + * Default: "model" + */ + object: string; + + /** + * The version of the model. + */ + version: string; + + /** + * The model download source. It can be an external url or a local filepath. + */ + source_url: string; + + /** + * The model identifier, which can be referenced in the API endpoints. + */ id: string; - /** The name of the model.*/ + + /** + * Human-readable name that is used for UI. + */ name: string; - /** Quantization method name.*/ - quantizationName: string; - /** The the number of bits represents a number.*/ - bits: number; - /** The size of the model file in bytes.*/ + + /** + * The organization that owns the model (you!) + * Default: "you" + */ + owned_by: string; + + /** + * The Unix timestamp (in seconds) for when the model was created + */ + created: number; + + /** + * Default: "A cool model from Huggingface" + */ + description: string; + + /** + * The model state. + * Default: "to_download" + * Enum: "to_download" "downloading" "ready" "running" + */ + state: ModelState; + + settings: ModelSettingParams; + + parameters: ModelRuntimeParam; + + /** + * Metadata of the model. + */ + metadata: ModelMetadata; +} + +export enum ModelState { + ToDownload = "to_download", + Downloading = "downloading", + Ready = "ready", + Running = "running", +} + +export type ModelSettingParams = { + ctx_len: number; + ngl: number; + embedding: boolean; + n_parallel: number; +}; + +export type ModelRuntimeParam = { + temperature: number; + token_limit: number; + top_k: number; + top_p: number; + stream: boolean; +}; + +export type ModelMetadata = { + engine: string; + quantization: string; size: number; - /** The maximum RAM required to run the model in bytes.*/ + binaries: string[]; maxRamRequired: number; - /** The use case of the model.*/ - usecase: string; - /** The download link of the model.*/ - downloadLink: string; - /** The short description of the model.*/ - shortDescription: string; - /** The long description of the model.*/ - longDescription: string; - /** The avatar url of the model.*/ - avatarUrl: string; - /** The author name of the model.*/ author: string; - /** The version of the model.*/ - version: string; - /** The origin url of the model repo.*/ - modelUrl: string; - /** The timestamp indicating when this model was released.*/ - releaseDate: number; - /** The tags attached to the model description */ - tags: string[]; -} + avatarUrl: string; +}; /** * Model type of the presentation object which will be presented to the user @@ -159,25 +266,21 @@ export interface ModelCatalog { tags: string[]; /** The available versions of this model to download. */ - availableVersions: ModelVersion[]; + availableVersions: Model[]; } -/** - * Model type which will be present a version of ModelCatalog - * @data_transfer_object - */ -export type ModelVersion = { - /** The name of this model version.*/ + +export type Assistant = { + avatar: string; + thread_location: string | undefined; + + id: string; + object: string; + created_at: number; name: string; - /** The quantization method name.*/ - quantizationName: string; - /** The the number of bits represents a number.*/ - bits: number; - /** The size of the model file in bytes.*/ - size: number; - /** The maximum RAM required to run the model in bytes.*/ - maxRamRequired: number; - /** The use case of the model.*/ - usecase: string; - /** The download link of the model.*/ - downloadLink: string; + description: string; + model: string; + instructions: string; + tools: any; + file_ids: string[]; + metadata?: Record; }; diff --git a/electron/handlers/fs.ts b/electron/handlers/fs.ts index c1e8a85e4d..6ed67b2de6 100644 --- a/electron/handlers/fs.ts +++ b/electron/handlers/fs.ts @@ -1,6 +1,7 @@ import { app, ipcMain } from 'electron' import * as fs from 'fs' import { join } from 'path' +import readline from 'readline' /** * Handles file system operations. @@ -97,7 +98,7 @@ export function handleFsIPCs() { */ ipcMain.handle('rmdir', async (event, path: string): Promise => { return new Promise((resolve, reject) => { - fs.rmdir(join(userSpacePath, path), { recursive: true }, (err) => { + fs.rm(join(userSpacePath, path), { recursive: true }, (err) => { if (err) { reject(err) } else { @@ -153,4 +154,45 @@ export function handleFsIPCs() { return result }) + + /** + * Appends data to a file in the user data directory. + * @param event - The event object. + * @param path - The path of the file to append to. + * @param data - The data to append to the file. + * @returns A promise that resolves when the file has been written. + */ + ipcMain.handle('appendFile', async (_event, path: string, data: string) => { + return new Promise((resolve, reject) => { + fs.appendFile(join(userSpacePath, path), data, 'utf8', (err) => { + if (err) { + reject(err) + } else { + resolve(data) + } + }) + }) + }) + + ipcMain.handle('readLineByLine', async (_event, path: string) => { + const fullPath = join(userSpacePath, path) + + return new Promise((res, rej) => { + try { + const readInterface = readline.createInterface({ + input: fs.createReadStream(fullPath), + }) + const lines: any = [] + readInterface + .on('line', function (line) { + lines.push(line) + }) + .on('close', function () { + res(lines) + }) + } catch (err) { + rej(err) + } + }) + }) } diff --git a/electron/preload.ts b/electron/preload.ts index dfba13bd12..a72d6a5cb0 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -38,6 +38,7 @@ * @property {Function} readFile - Reads the file at the given path. * @property {Function} writeFile - Writes the given data to the file at the given path. * @property {Function} listFiles - Lists the files in the directory at the given path. + * @property {Function} appendFile - Appends the given data to the file at the given path. * @property {Function} mkdir - Creates a directory at the given path. * @property {Function} rmdir - Removes a directory at the given path recursively. * @property {Function} installRemotePlugin - Installs the remote plugin with the given name. @@ -58,7 +59,7 @@ import { useFacade } from './core/plugin/facade' useFacade() -const { contextBridge, ipcRenderer } = require('electron') +const { contextBridge, ipcRenderer, shell } = require('electron') contextBridge.exposeInMainWorld('electronAPI', { invokePluginFunc: (plugin: any, method: any, ...args: any[]) => @@ -88,7 +89,8 @@ contextBridge.exposeInMainWorld('electronAPI', { deleteFile: (filePath: string) => ipcRenderer.invoke('deleteFile', filePath), - isDirectory: (filePath: string) => ipcRenderer.invoke('isDirectory', filePath), + isDirectory: (filePath: string) => + ipcRenderer.invoke('isDirectory', filePath), getUserSpace: () => ipcRenderer.invoke('getUserSpace'), @@ -99,10 +101,17 @@ contextBridge.exposeInMainWorld('electronAPI', { listFiles: (path: string) => ipcRenderer.invoke('listFiles', path), + appendFile: (path: string, data: string) => + ipcRenderer.invoke('appendFile', path, data), + + readLineByLine: (path: string) => ipcRenderer.invoke('readLineByLine', path), + mkdir: (path: string) => ipcRenderer.invoke('mkdir', path), rmdir: (path: string) => ipcRenderer.invoke('rmdir', path), + openFileExplorer: (path: string) => shell.openPath(path), + installRemotePlugin: (pluginName: string) => ipcRenderer.invoke('installRemotePlugin', pluginName), diff --git a/plugins/assistant-plugin/README.md b/plugins/assistant-plugin/README.md new file mode 100644 index 0000000000..16cde13924 --- /dev/null +++ b/plugins/assistant-plugin/README.md @@ -0,0 +1,77 @@ +# Jan Assistant plugin + +Created using Jan app example + +# Create a Jan Plugin using Typescript + +Use this template to bootstrap the creation of a TypeScript Jan plugin. 🚀 + +## Create Your Own Plugin + +To create your own plugin, you can use this repository as a template! Just follow the below instructions: + +1. Click the Use this template button at the top of the repository +2. Select Create a new repository +3. Select an owner and name for your new repository +4. Click Create repository +5. Clone your new repository + +## Initial Setup + +After you've cloned the repository to your local machine or codespace, you'll need to perform some initial setup steps before you can develop your plugin. + +> [!NOTE] +> +> You'll need to have a reasonably modern version of +> [Node.js](https://nodejs.org) handy. If you are using a version manager like +> [`nodenv`](https://github.com/nodenv/nodenv) or +> [`nvm`](https://github.com/nvm-sh/nvm), you can run `nodenv install` in the +> root of your repository to install the version specified in +> [`package.json`](./package.json). Otherwise, 20.x or later should work! + +1. :hammer_and_wrench: Install the dependencies + + ```bash + npm install + ``` + +1. :building_construction: Package the TypeScript for distribution + + ```bash + npm run bundle + ``` + +1. :white_check_mark: Check your artifact + + There will be a tgz file in your plugin directory now + +## Update the Plugin Metadata + +The [`package.json`](package.json) file defines metadata about your plugin, such as +plugin name, main entry, description and version. + +When you copy this repository, update `package.json` with the name, description for your plugin. + +## Update the Plugin Code + +The [`src/`](./src/) directory is the heart of your plugin! This contains the +source code that will be run when your plugin extension functions are invoked. You can replace the +contents of this directory with your own code. + +There are a few things to keep in mind when writing your plugin code: + +- Most Jan Plugin Extension functions are processed asynchronously. + In `index.ts`, you will see that the extension function will return a `Promise`. + + ```typescript + import { core } from "@janhq/core"; + + function onStart(): Promise { + return core.invokePluginFunc(MODULE_PATH, "run", 0); + } + ``` + + For more information about the Jan Plugin Core module, see the + [documentation](https://github.com/janhq/jan/blob/main/core/README.md). + +So, what are you waiting for? Go ahead and start customizing your plugin! diff --git a/plugins/assistant-plugin/package.json b/plugins/assistant-plugin/package.json new file mode 100644 index 0000000000..1a850beb6d --- /dev/null +++ b/plugins/assistant-plugin/package.json @@ -0,0 +1,33 @@ +{ + "name": "@janhq/assistant-plugin", + "version": "1.0.9", + "description": "Assistant", + "icon": "https://raw.githubusercontent.com/tailwindlabs/heroicons/88e98b0c2b458553fbadccddc2d2f878edc0387b/src/20/solid/cpu-chip.svg", + "main": "dist/index.js", + "module": "dist/module.js", + "author": "Jan ", + "license": "AGPL-3.0", + "url": "/plugins/assistant-plugin/index.js", + "activationPoints": [ + "init" + ], + "scripts": { + "build": "tsc -b . && webpack --config webpack.config.js", + "build:publish": "rimraf *.tgz --glob && npm run build && npm pack && cpx *.tgz ../../electron/core/pre-install" + }, + "devDependencies": { + "rimraf": "^3.0.2", + "webpack": "^5.88.2", + "webpack-cli": "^5.1.4" + }, + "dependencies": { + "@janhq/core": "file:../../core", + "path-browserify": "^1.0.1", + "ts-loader": "^9.5.0" + }, + "files": [ + "dist/*", + "package.json", + "README.md" + ] +} diff --git a/plugins/assistant-plugin/src/@types/global.d.ts b/plugins/assistant-plugin/src/@types/global.d.ts new file mode 100644 index 0000000000..3b45ccc5ad --- /dev/null +++ b/plugins/assistant-plugin/src/@types/global.d.ts @@ -0,0 +1 @@ +declare const MODULE: string; diff --git a/plugins/assistant-plugin/src/index.ts b/plugins/assistant-plugin/src/index.ts new file mode 100644 index 0000000000..a286b04bda --- /dev/null +++ b/plugins/assistant-plugin/src/index.ts @@ -0,0 +1,107 @@ +import { PluginType, fs, Assistant } from "@janhq/core"; +import { AssistantPlugin } from "@janhq/core/lib/plugins"; +import { join } from "path"; + +export default class JanAssistantPlugin implements AssistantPlugin { + private static readonly _homeDir = "assistants"; + + type(): PluginType { + return PluginType.Assistant; + } + + onLoad(): void { + // making the assistant directory + fs.mkdir(JanAssistantPlugin._homeDir).then(() => { + this.createJanAssistant(); + }); + } + + /** + * Called when the plugin is unloaded. + */ + onUnload(): void {} + + async createAssistant(assistant: Assistant): Promise { + // assuming that assistants/ directory is already created in the onLoad above + + // TODO: check if the directory already exists, then ignore creation for now + + const assistantDir = join(JanAssistantPlugin._homeDir, assistant.id); + await fs.mkdir(assistantDir); + + // store the assistant metadata json + const assistantMetadataPath = join(assistantDir, "assistant.json"); + try { + await fs.writeFile( + assistantMetadataPath, + JSON.stringify(assistant, null, 2) + ); + } catch (err) { + console.error(err); + } + } + + async getAssistants(): Promise { + // get all the assistant directories + // get all the assistant metadata json + const results: Assistant[] = []; + const allFileName: string[] = await fs.listFiles( + JanAssistantPlugin._homeDir + ); + for (const fileName of allFileName) { + const filePath = join(JanAssistantPlugin._homeDir, fileName); + const isDirectory = await fs.isDirectory(filePath); + if (!isDirectory) { + // if not a directory, ignore + continue; + } + + const jsonFiles: string[] = (await fs.listFiles(filePath)).filter( + (file: string) => file === "assistant.json" + ); + + if (jsonFiles.length !== 1) { + // has more than one assistant file -> ignore + continue; + } + + const assistant: Assistant = JSON.parse( + await fs.readFile(join(filePath, jsonFiles[0])) + ); + + results.push(assistant); + } + + return results; + } + + async deleteAssistant(assistant: Assistant): Promise { + if (assistant.id === "jan") { + return Promise.reject("Cannot delete Jan Assistant"); + } + + // remove the directory + const assistantDir = join(JanAssistantPlugin._homeDir, assistant.id); + await fs.rmdir(assistantDir); + return Promise.resolve(); + } + + private async createJanAssistant(): Promise { + const janAssistant: Assistant = { + avatar: "", + thread_location: undefined, // TODO: make this property ? + id: "jan", + object: "assistant", // TODO: maybe we can set default value for this? + created_at: Date.now(), + name: "Jan Assistant", + description: "Just Jan Assistant", + model: "*", + instructions: "Your name is Jan.", + tools: undefined, + file_ids: [], + metadata: undefined, + }; + + await this.createAssistant(janAssistant); + } +} diff --git a/plugins/assistant-plugin/tsconfig.json b/plugins/assistant-plugin/tsconfig.json new file mode 100644 index 0000000000..2477d58ce5 --- /dev/null +++ b/plugins/assistant-plugin/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "es2016", + "module": "ES6", + "moduleResolution": "node", + "outDir": "./dist", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": false, + "skipLibCheck": true, + "rootDir": "./src" + }, + "include": ["./src"] +} diff --git a/plugins/assistant-plugin/webpack.config.js b/plugins/assistant-plugin/webpack.config.js new file mode 100644 index 0000000000..74d16fc8e7 --- /dev/null +++ b/plugins/assistant-plugin/webpack.config.js @@ -0,0 +1,38 @@ +const path = require("path"); +const webpack = require("webpack"); +const packageJson = require("./package.json"); + +module.exports = { + experiments: { outputModule: true }, + entry: "./src/index.ts", // Adjust the entry point to match your project's main file + mode: "production", + module: { + rules: [ + { + test: /\.tsx?$/, + use: "ts-loader", + exclude: /node_modules/, + }, + ], + }, + output: { + filename: "index.js", // Adjust the output file name as needed + path: path.resolve(__dirname, "dist"), + library: { type: "module" }, // Specify ESM output format + }, + plugins: [ + new webpack.DefinePlugin({ + MODULE: JSON.stringify(`${packageJson.name}/${packageJson.module}`), + }), + ], + resolve: { + extensions: [".ts", ".js"], + fallback: { + path: require.resolve("path-browserify"), + }, + }, + optimization: { + minimize: false, + }, + // Add loaders and other configuration as needed for your project +}; diff --git a/plugins/conversational-json/src/index.ts b/plugins/conversational-json/src/index.ts index 94082bb453..5a3f05a1ac 100644 --- a/plugins/conversational-json/src/index.ts +++ b/plugins/conversational-json/src/index.ts @@ -1,14 +1,16 @@ import { PluginType, fs } from '@janhq/core' import { ConversationalPlugin } from '@janhq/core/lib/plugins' -import { Thread } from '@janhq/core/lib/types' +import { Thread, ThreadMessage } from '@janhq/core/lib/types' import { join } from 'path' /** * JSONConversationalPlugin is a ConversationalPlugin implementation that provides - * functionality for managing conversations. + * functionality for managing threads. */ export default class JSONConversationalPlugin implements ConversationalPlugin { private static readonly _homeDir = 'threads' + private static readonly _threadInfoFileName = 'thread.json' + private static readonly _threadMessagesFileName = 'messages.jsonl' /** * Returns the type of the plugin. @@ -35,13 +37,11 @@ export default class JSONConversationalPlugin implements ConversationalPlugin { /** * Returns a Promise that resolves to an array of Conversation objects. */ - async getConversations(): Promise { + async getThreads(): Promise { try { - const convoIds = await this.getConversationDocs() + const threadDirs = await this.getValidThreadDirs() - const promises = convoIds.map((conversationId) => { - return this.readConvo(conversationId) - }) + const promises = threadDirs.map((dirName) => this.readThread(dirName)) const promiseResults = await Promise.allSettled(promises) const convos = promiseResults .map((result) => { @@ -51,10 +51,9 @@ export default class JSONConversationalPlugin implements ConversationalPlugin { }) .filter((convo) => convo != null) convos.sort( - (a, b) => - new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + (a, b) => new Date(b.updated).getTime() - new Date(a.updated).getTime() ) - console.debug('getConversations: ', JSON.stringify(convos, null, 2)) + console.debug('getThreads', JSON.stringify(convos, null, 2)) return convos } catch (error) { console.error(error) @@ -63,55 +62,124 @@ export default class JSONConversationalPlugin implements ConversationalPlugin { } /** - * Saves a Conversation object to a Markdown file. - * @param conversation The Conversation object to save. + * Saves a Thread object to a json file. + * @param thread The Thread object to save. */ - saveConversation(conversation: Thread): Promise { - return fs - .mkdir(`${JSONConversationalPlugin._homeDir}/${conversation.id}`) - .then(() => - fs.writeFile( - join( - JSONConversationalPlugin._homeDir, - conversation.id, - `${conversation.id}.json` - ), - JSON.stringify(conversation) - ) + async saveThread(thread: Thread): Promise { + try { + const threadDirPath = join(JSONConversationalPlugin._homeDir, thread.id) + const threadJsonPath = join( + threadDirPath, + JSONConversationalPlugin._threadInfoFileName ) + await fs.mkdir(threadDirPath) + await fs.writeFile(threadJsonPath, JSON.stringify(thread, null, 2)) + Promise.resolve() + } catch (err) { + Promise.reject(err) + } } /** - * Deletes a conversation with the specified ID. - * @param conversationId The ID of the conversation to delete. + * Delete a thread with the specified ID. + * @param threadId The ID of the thread to delete. */ - deleteConversation(conversationId: string): Promise { - return fs.rmdir( - join(JSONConversationalPlugin._homeDir, `${conversationId}`) - ) + deleteThread(threadId: string): Promise { + return fs.rmdir(join(JSONConversationalPlugin._homeDir, `${threadId}`)) + } + + async addNewMessage(message: ThreadMessage): Promise { + try { + const threadDirPath = join( + JSONConversationalPlugin._homeDir, + message.thread_id + ) + const threadMessagePath = join( + threadDirPath, + JSONConversationalPlugin._threadMessagesFileName + ) + await fs.mkdir(threadDirPath) + await fs.appendFile(threadMessagePath, JSON.stringify(message) + '\n') + Promise.resolve() + } catch (err) { + Promise.reject(err) + } } /** - * A promise builder for reading a conversation from a file. - * @param convoId the conversation id we are reading from. - * @returns data of the conversation + * A promise builder for reading a thread from a file. + * @param threadDirName the thread dir we are reading from. + * @returns data of the thread */ - private async readConvo(convoId: string): Promise { + private async readThread(threadDirName: string): Promise { return fs.readFile( - join(JSONConversationalPlugin._homeDir, convoId, `${convoId}.json`) + join( + JSONConversationalPlugin._homeDir, + threadDirName, + JSONConversationalPlugin._threadInfoFileName + ) ) } /** - * Returns a Promise that resolves to an array of conversation IDs. - * The conversation IDs are the names of the Markdown files in the "conversations" directory. + * Returns a Promise that resolves to an array of thread directories. * @private */ - private async getConversationDocs(): Promise { - return fs - .listFiles(JSONConversationalPlugin._homeDir) - .then((files: string[]) => { - return Promise.all(files.filter((file) => file.startsWith('jan-'))) + private async getValidThreadDirs(): Promise { + const fileInsideThread: string[] = await fs.listFiles( + JSONConversationalPlugin._homeDir + ) + + const threadDirs: string[] = [] + for (let i = 0; i < fileInsideThread.length; i++) { + const path = join(JSONConversationalPlugin._homeDir, fileInsideThread[i]) + const isDirectory = await fs.isDirectory(path) + if (!isDirectory) { + console.debug(`Ignore ${path} because it is not a directory`) + continue + } + + const isHavingThreadInfo = (await fs.listFiles(path)).includes( + JSONConversationalPlugin._threadInfoFileName + ) + if (!isHavingThreadInfo) { + console.debug(`Ignore ${path} because it does not have thread info`) + continue + } + + threadDirs.push(fileInsideThread[i]) + } + return threadDirs + } + + async getAllMessages(threadId: string): Promise { + try { + const threadDirPath = join(JSONConversationalPlugin._homeDir, threadId) + const isDir = await fs.isDirectory(threadDirPath) + if (!isDir) { + throw Error(`${threadDirPath} is not directory`) + } + + const files: string[] = await fs.listFiles(threadDirPath) + if (!files.includes(JSONConversationalPlugin._threadMessagesFileName)) { + throw Error(`${threadDirPath} not contains message file`) + } + + const messageFilePath = join( + threadDirPath, + JSONConversationalPlugin._threadMessagesFileName + ) + + const result = await fs.readLineByLine(messageFilePath) + + const messages: ThreadMessage[] = [] + result.forEach((line: string) => { + messages.push(JSON.parse(line) as ThreadMessage) }) + return messages + } catch (err) { + console.error(err) + return [] + } } } diff --git a/plugins/inference-plugin/src/helpers/sse.ts b/plugins/inference-plugin/src/helpers/sse.ts index 9877512218..f427e443c9 100644 --- a/plugins/inference-plugin/src/helpers/sse.ts +++ b/plugins/inference-plugin/src/helpers/sse.ts @@ -4,7 +4,10 @@ import { Observable } from "rxjs"; * @param recentMessages - An array of recent messages to use as context for the inference. * @returns An Observable that emits the generated response as a string. */ -export function requestInference(recentMessages: any[], controller?: AbortController): Observable { +export function requestInference( + recentMessages: any[], + controller?: AbortController +): Observable { return new Observable((subscriber) => { const requestBody = JSON.stringify({ messages: recentMessages, @@ -20,7 +23,7 @@ export function requestInference(recentMessages: any[], controller?: AbortContro "Access-Control-Allow-Origin": "*", }, body: requestBody, - signal: controller?.signal + signal: controller?.signal, }) .then(async (response) => { const stream = response.body; diff --git a/plugins/inference-plugin/src/index.ts b/plugins/inference-plugin/src/index.ts index 167ce0626f..7a1f851866 100644 --- a/plugins/inference-plugin/src/index.ts +++ b/plugins/inference-plugin/src/index.ts @@ -8,19 +8,22 @@ import { ChatCompletionRole, + ContentType, EventName, MessageRequest, MessageStatus, + ModelSettingParams, PluginType, + ThreadContent, ThreadMessage, events, executeOnMain, + getUserSpace, } from "@janhq/core"; import { InferencePlugin } from "@janhq/core/lib/plugins"; import { requestInference } from "./helpers/sse"; import { ulid } from "ulid"; import { join } from "path"; -import { getUserSpace } from "@janhq/core"; /** * A class that implements the InferencePlugin interface from the @janhq/core package. @@ -56,14 +59,20 @@ export default class JanInferencePlugin implements InferencePlugin { /** * Initializes the model with the specified file name. - * @param {string} modelFileName - The file name of the model file. + * @param {string} modelId - The ID of the model to initialize. * @returns {Promise} A promise that resolves when the model is initialized. */ - async initModel(modelFileName: string): Promise { + async initModel( + modelId: string, + settings?: ModelSettingParams + ): Promise { const userSpacePath = await getUserSpace(); - const modelFullPath = join(userSpacePath, modelFileName); + const modelFullPath = join(userSpacePath, "models", modelId, modelId); - return executeOnMain(MODULE, "initModel", modelFullPath); + return executeOnMain(MODULE, "initModel", { + modelFullPath, + settings, + }); } /** @@ -89,18 +98,21 @@ export default class JanInferencePlugin implements InferencePlugin { * @returns {Promise} A promise that resolves with the inference response. */ async inferenceRequest(data: MessageRequest): Promise { + const timestamp = Date.now(); const message: ThreadMessage = { - threadId: data.threadId, - content: "", - createdAt: new Date().toISOString(), + thread_id: data.threadId, + created: timestamp, + updated: timestamp, status: MessageStatus.Ready, + id: "", + role: ChatCompletionRole.Assistant, + object: "thread.message", + content: [], }; return new Promise(async (resolve, reject) => { requestInference(data.messages ?? []).subscribe({ - next: (content) => { - message.content = content; - }, + next: (_content) => {}, complete: async () => { resolve(message); }, @@ -121,33 +133,49 @@ export default class JanInferencePlugin implements InferencePlugin { data: MessageRequest, instance: JanInferencePlugin ) { + const timestamp = Date.now(); const message: ThreadMessage = { - threadId: data.threadId, - content: "", - role: ChatCompletionRole.Assistant, - createdAt: new Date().toISOString(), id: ulid(), + thread_id: data.threadId, + assistant_id: data.assistantId, + role: ChatCompletionRole.Assistant, + content: [], status: MessageStatus.Pending, + created: timestamp, + updated: timestamp, + object: "thread.message", }; events.emit(EventName.OnNewMessageResponse, message); + console.log(JSON.stringify(data, null, 2)); instance.isCancelled = false; instance.controller = new AbortController(); requestInference(data.messages, instance.controller).subscribe({ next: (content) => { - message.content = content; + const messageContent: ThreadContent = { + type: ContentType.Text, + text: { + value: content.trim(), + annotations: [], + }, + }; + message.content = [messageContent]; events.emit(EventName.OnMessageResponseUpdate, message); }, complete: async () => { - message.content = message.content.trim(); message.status = MessageStatus.Ready; events.emit(EventName.OnMessageResponseFinished, message); }, error: async (err) => { - message.content = - message.content.trim() + - (instance.isCancelled ? "" : "\n" + "Error occurred: " + err.message); + const messageContent: ThreadContent = { + type: ContentType.Text, + text: { + value: "Error occurred: " + err.message, + annotations: [], + }, + }; + message.content = [messageContent]; message.status = MessageStatus.Ready; events.emit(EventName.OnMessageResponseUpdate, message); }, diff --git a/plugins/inference-plugin/src/module.ts b/plugins/inference-plugin/src/module.ts index 74cc9d89ce..b90cba740e 100644 --- a/plugins/inference-plugin/src/module.ts +++ b/plugins/inference-plugin/src/module.ts @@ -35,10 +35,20 @@ interface InitModelResponse { * TODO: Should pass absolute of the model file instead of just the name - So we can modurize the module.ts to npm package * TODO: Should it be startModel instead? */ -function initModel(modelFile: string): Promise { +function initModel(wrapper: any): Promise { // 1. Check if the model file exists - currentModelFile = modelFile; - log.info("Started to load model " + modelFile); + currentModelFile = wrapper.modelFullPath; + log.info("Started to load model " + wrapper.modelFullPath); + + const settings = { + llama_model_path: currentModelFile, + ctx_len: 2048, + ngl: 100, + cont_batching: false, + embedding: false, // Always enable embedding mode on + ...wrapper.settings, + }; + log.info(`Load model settings: ${JSON.stringify(settings, null, 2)}`); return ( // 1. Check if the port is used, if used, attempt to unload model / kill nitro process @@ -47,12 +57,12 @@ function initModel(modelFile: string): Promise { // 2. Spawn the Nitro subprocess .then(spawnNitroProcess) // 4. Load the model into the Nitro subprocess (HTTP POST request) - .then(loadLLMModel) + .then(() => loadLLMModel(settings)) // 5. Check if the model is loaded successfully .then(validateModelStatus) .catch((err) => { log.error("error: " + JSON.stringify(err)); - return { error: err, modelFile }; + return { error: err, currentModelFile }; }) ); } @@ -61,22 +71,14 @@ function initModel(modelFile: string): Promise { * Loads a LLM model into the Nitro subprocess by sending a HTTP POST request. * @returns A Promise that resolves when the model is loaded successfully, or rejects with an error message if the model is not found or fails to load. */ -function loadLLMModel(): Promise { - const config = { - llama_model_path: currentModelFile, - ctx_len: 2048, - ngl: 100, - cont_batching: false, - embedding: false, // Always enable embedding mode on - }; - +function loadLLMModel(settings): Promise { // Load model config return fetchRetry(NITRO_HTTP_LOAD_MODEL_URL, { method: "POST", headers: { "Content-Type": "application/json", }, - body: JSON.stringify(config), + body: JSON.stringify(settings), retries: 3, retryDelay: 500, }).catch((err) => { @@ -151,7 +153,7 @@ function checkAndUnloadNitro() { "Content-Type": "application/json", }, }).catch((err) => { - console.log(err); + console.error(err); // Fallback to kill the port return killSubprocess(); }); @@ -195,7 +197,7 @@ async function spawnNitroProcess(): Promise { // Handle subprocess output subprocess.stdout.on("data", (data) => { - console.log(`stdout: ${data}`); + console.debug(`stdout: ${data}`); }); subprocess.stderr.on("data", (data) => { @@ -204,7 +206,7 @@ async function spawnNitroProcess(): Promise { }); subprocess.on("close", (code) => { - console.log(`child process exited with code ${code}`); + console.debug(`child process exited with code ${code}`); subprocess = null; reject(`Nitro process exited. ${code ?? ""}`); }); diff --git a/plugins/model-plugin/src/helpers/modelParser.ts b/plugins/model-plugin/src/helpers/modelParser.ts index 242dc7e637..3a397fb7da 100644 --- a/plugins/model-plugin/src/helpers/modelParser.ts +++ b/plugins/model-plugin/src/helpers/modelParser.ts @@ -1,32 +1,46 @@ import { ModelCatalog } from '@janhq/core' -export function parseToModel(schema: ModelSchema): ModelCatalog { +export const parseToModel = (modelGroup): ModelCatalog => { const modelVersions = [] - schema.versions.forEach((v) => { - const version = { + modelGroup.versions.forEach((v) => { + const model = { + object: 'model', + version: modelGroup.version, + source_url: v.downloadLink, + id: v.name, name: v.name, - quantMethod: v.quantMethod, - bits: v.bits, - size: v.size, - maxRamRequired: v.maxRamRequired, - usecase: v.usecase, - downloadLink: v.downloadLink, + owned_by: 'you', + created: 0, + description: modelGroup.longDescription, + state: 'to_download', + settings: v.settings, + parameters: v.parameters, + metadata: { + engine: '', + quantization: v.quantMethod, + size: v.size, + binaries: [], + maxRamRequired: v.maxRamRequired, + author: modelGroup.author, + avatarUrl: modelGroup.avatarUrl, + }, } - modelVersions.push(version) + modelVersions.push(model) }) - const model: ModelCatalog = { - id: schema.id, - name: schema.name, - shortDescription: schema.shortDescription, - avatarUrl: schema.avatarUrl, - author: schema.author, - version: schema.version, - modelUrl: schema.modelUrl, - tags: schema.tags, - longDescription: schema.longDescription, - releaseDate: 0, + const modelCatalog: ModelCatalog = { + id: modelGroup.id, + name: modelGroup.name, + avatarUrl: modelGroup.avatarUrl, + shortDescription: modelGroup.shortDescription, + longDescription: modelGroup.longDescription, + author: modelGroup.author, + version: modelGroup.version, + modelUrl: modelGroup.modelUrl, + releaseDate: modelGroup.createdAt, + tags: modelGroup.tags, availableVersions: modelVersions, } - return model + + return modelCatalog } diff --git a/plugins/model-plugin/src/index.ts b/plugins/model-plugin/src/index.ts index f3da471a7a..c6a4687dc6 100644 --- a/plugins/model-plugin/src/index.ts +++ b/plugins/model-plugin/src/index.ts @@ -9,6 +9,8 @@ import { join } from 'path' */ export default class JanModelPlugin implements ModelPlugin { private static readonly _homeDir = 'models' + private static readonly _modelMetadataFileName = 'model.json' + /** * Implements type from JanPlugin. * @override @@ -42,12 +44,12 @@ export default class JanModelPlugin implements ModelPlugin { */ async downloadModel(model: Model): Promise { // create corresponding directory - const directoryPath = join(JanModelPlugin._homeDir, model.name) + const directoryPath = join(JanModelPlugin._homeDir, model.id) await fs.mkdir(directoryPath) // path to model binary const path = join(directoryPath, model.id) - downloadFile(model.downloadLink, path) + downloadFile(model.source_url, path) } /** @@ -68,12 +70,10 @@ export default class JanModelPlugin implements ModelPlugin { * @param filePath - The path to the model file to delete. * @returns A Promise that resolves when the model is deleted. */ - async deleteModel(filePath: string): Promise { + async deleteModel(modelId: string): Promise { try { - await Promise.allSettled([ - fs.deleteFile(filePath), - fs.deleteFile(`${filePath}.json`), - ]) + const dirPath = join(JanModelPlugin._homeDir, modelId) + await fs.rmdir(dirPath) } catch (err) { console.error(err) } @@ -85,11 +85,14 @@ export default class JanModelPlugin implements ModelPlugin { * @returns A Promise that resolves when the model is saved. */ async saveModel(model: Model): Promise { - const directoryPath = join(JanModelPlugin._homeDir, model.name) - const jsonFilePath = join(directoryPath, `${model.id}.json`) + const jsonFilePath = join( + JanModelPlugin._homeDir, + model.id, + JanModelPlugin._modelMetadataFileName + ) try { - await fs.writeFile(jsonFilePath, JSON.stringify(model)) + await fs.writeFile(jsonFilePath, JSON.stringify(model, null, 2)) } catch (err) { console.error(err) } @@ -111,7 +114,7 @@ export default class JanModelPlugin implements ModelPlugin { } const jsonFiles: string[] = (await fs.listFiles(modelDirPath)).filter( - (file: string) => file.endsWith('.json') + (fileName: string) => fileName === JanModelPlugin._modelMetadataFileName ) for (const json of jsonFiles) { diff --git a/web/containers/CardSidebar/index.tsx b/web/containers/CardSidebar/index.tsx new file mode 100644 index 0000000000..61f3c20e51 --- /dev/null +++ b/web/containers/CardSidebar/index.tsx @@ -0,0 +1,90 @@ +import { ReactNode, useState } from 'react' +import { Fragment } from 'react' +import { Menu, Transition } from '@headlessui/react' +import { + ChevronDownIcon, + EllipsisVerticalIcon, +} from '@heroicons/react/20/solid' + +interface Props { + children: ReactNode + title: string + onRevealInFinderClick: (type: string) => void + onViewJsonClick: (type: string) => void +} + +function classNames(...classes: any) { + return classes.filter(Boolean).join(' ') +} + +export default function CardSidebar({ + children, + title, + onRevealInFinderClick, + onViewJsonClick, +}: Props) { + const [show, setShow] = useState(true) + + return ( + + ) +} diff --git a/web/containers/DropdownListSidebar/index.tsx b/web/containers/DropdownListSidebar/index.tsx new file mode 100644 index 0000000000..1cab4ce0df --- /dev/null +++ b/web/containers/DropdownListSidebar/index.tsx @@ -0,0 +1,104 @@ +import { Fragment, useEffect, useState } from 'react' +import { Listbox, Transition } from '@headlessui/react' +import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/20/solid' +import { getDownloadedModels } from '@/hooks/useGetDownloadedModels' +import { Model } from '@janhq/core/lib/types' +import { atom, useSetAtom } from 'jotai' + +function classNames(...classes: any) { + return classes.filter(Boolean).join(' ') +} + +export const selectedModelAtom = atom(undefined) + +export default function DropdownListSidebar() { + const [downloadedModels, setDownloadedModels] = useState([]) + const [selected, setSelected] = useState() + const setSelectedModel = useSetAtom(selectedModelAtom) + + useEffect(() => { + getDownloadedModels().then((downloadedModels) => { + setDownloadedModels(downloadedModels) + + if (downloadedModels.length > 0) { + setSelected(downloadedModels[0]) + setSelectedModel(downloadedModels[0]) + } + }) + }, []) + + if (!selected) return null + + return ( + { + setSelected(model) + setSelectedModel(model) + }} + > + {({ open }) => ( + <> +
+ + {selected.name} + + + + + + + {downloadedModels.map((model) => ( + + 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 }) => ( + <> + + {model.name} + + + {selected ? ( + + + ) : null} + + )} + + ))} + + +
+ + )} +
+ ) +} diff --git a/web/containers/ItemCardSidebar/index.tsx b/web/containers/ItemCardSidebar/index.tsx new file mode 100644 index 0000000000..00ab8d1e48 --- /dev/null +++ b/web/containers/ItemCardSidebar/index.tsx @@ -0,0 +1,20 @@ +type Props = { + title: string + description?: string +} + +export default function ItemCardSidebar({ description, title }: Props) { + return ( +
+
+ {title} +
+ +
+ ) +} diff --git a/web/containers/JanImage/index.tsx b/web/containers/JanImage/index.tsx deleted file mode 100644 index a3b11cff48..0000000000 --- a/web/containers/JanImage/index.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react' - -type Props = { - imageUrl: string - className?: string - alt?: string - width?: number - height?: number -} - -const JanImage: React.FC = ({ - imageUrl, - className = '', - alt = '', - width, - height, -}) => { - const [attempt, setAttempt] = React.useState(0) - - return ( - {alt} setAttempt(attempt + 1)} - /> - ) -} - -export default JanImage diff --git a/web/containers/Layout/TopBar/CommandSearch/index.tsx b/web/containers/Layout/TopBar/CommandSearch/index.tsx index d5eeff3fdd..55ee5910cb 100644 --- a/web/containers/Layout/TopBar/CommandSearch/index.tsx +++ b/web/containers/Layout/TopBar/CommandSearch/index.tsx @@ -26,9 +26,13 @@ import { FeatureToggleContext } from '@/context/FeatureToggle' import { MainViewState } from '@/constants/screens' import { useMainViewState } from '@/hooks/useMainViewState' +import { useSetAtom } from 'jotai' +import { showRightSideBarAtom } from '@/screens/Chat/Sidebar' export default function CommandSearch() { const { setMainViewState } = useMainViewState() + const [open, setOpen] = useState(false) + const setShowRightSideBar = useSetAtom(showRightSideBarAtom) const menus = [ { @@ -61,8 +65,6 @@ export default function CommandSearch() { }, ] - const [open, setOpen] = useState(false) - useEffect(() => { const down = (e: KeyboardEvent) => { if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { @@ -120,6 +122,13 @@ export default function CommandSearch() { + ) } diff --git a/web/containers/ModalCancelDownload/index.tsx b/web/containers/ModalCancelDownload/index.tsx index 2b5538e9bc..4ac2509377 100644 --- a/web/containers/ModalCancelDownload/index.tsx +++ b/web/containers/ModalCancelDownload/index.tsx @@ -1,8 +1,8 @@ import { useMemo } from 'react' +import { Model } from '@janhq/core/lib/types' import { PluginType } from '@janhq/core' import { ModelPlugin } from '@janhq/core/lib/plugins' -import { ModelVersion } from '@janhq/core/lib/types' import { Modal, @@ -25,7 +25,7 @@ import { downloadingModelsAtom } from '@/helpers/atoms/Model.atom' import { pluginManager } from '@/plugin' type Props = { - suitableModel: ModelVersion + suitableModel: Model isFromList?: boolean } diff --git a/web/containers/Providers/EventHandler.tsx b/web/containers/Providers/EventHandler.tsx index 2c2ad3bd7c..78a392b09b 100644 --- a/web/containers/Providers/EventHandler.tsx +++ b/web/containers/Providers/EventHandler.tsx @@ -21,7 +21,7 @@ import { } from '@/helpers/atoms/ChatMessage.atom' import { updateConversationWaitingForResponseAtom, - userConversationsAtom, + threadsAtom, } from '@/helpers/atoms/Conversation.atom' import { downloadingModelsAtom } from '@/helpers/atoms/Model.atom' import { pluginManager } from '@/plugin' @@ -36,7 +36,7 @@ export default function EventHandler({ children }: { children: ReactNode }) { const updateConvWaiting = useSetAtom(updateConversationWaitingForResponseAtom) const models = useAtomValue(downloadingModelsAtom) const messages = useAtomValue(chatMessages) - const conversations = useAtomValue(userConversationsAtom) + const conversations = useAtomValue(threadsAtom) const messagesRef = useRef(messages) const convoRef = useRef(conversations) @@ -46,55 +46,48 @@ export default function EventHandler({ children }: { children: ReactNode }) { }, [messages, conversations]) async function handleNewMessageResponse(message: ThreadMessage) { - if (message.threadId) { - const convo = convoRef.current.find((e) => e.id == message.threadId) - if (!convo) return - addNewMessage(message) - } + addNewMessage(message) } - async function handleMessageResponseUpdate(messageResponse: ThreadMessage) { - if ( - messageResponse.threadId && - messageResponse.id && - messageResponse.content - ) { - updateMessage( - messageResponse.id, - messageResponse.threadId, - messageResponse.content, - MessageStatus.Pending - ) - } + + async function handleMessageResponseUpdate(message: ThreadMessage) { + updateMessage( + message.id, + message.thread_id, + message.content, + MessageStatus.Pending + ) } - async function handleMessageResponseFinished(messageResponse: ThreadMessage) { - if (!messageResponse.threadId || !convoRef.current) return - updateConvWaiting(messageResponse.threadId, false) + async function handleMessageResponseFinished(message: ThreadMessage) { + if (!convoRef.current) return + updateConvWaiting(message.thread_id, false) - if ( - messageResponse.threadId && - messageResponse.id && - messageResponse.content - ) { + if (message.id && message.content) { updateMessage( - messageResponse.id, - messageResponse.threadId, - messageResponse.content, + message.id, + message.thread_id, + message.content, MessageStatus.Ready ) } - const thread = convoRef.current.find( - (e) => e.id == messageResponse.threadId - ) + const thread = convoRef.current.find((e) => e.id == message.thread_id) if (thread) { + const messageContent = message.content[0]?.text.value ?? '' + const metadata = { + ...thread.metadata, + lastMessage: messageContent, + } pluginManager .get(PluginType.Conversational) - ?.saveConversation({ + ?.saveThread({ ...thread, - id: thread.id ?? '', - messages: messagesRef.current[thread.id] ?? [], + metadata, }) + + pluginManager + .get(PluginType.Conversational) + ?.addNewMessage(message) } } diff --git a/web/helpers/atoms/ChatMessage.atom.ts b/web/helpers/atoms/ChatMessage.atom.ts index 3ccf337e8c..2cd0474c09 100644 --- a/web/helpers/atoms/ChatMessage.atom.ts +++ b/web/helpers/atoms/ChatMessage.atom.ts @@ -1,15 +1,18 @@ -import { ChatCompletionRole, MessageStatus, ThreadMessage } from '@janhq/core' +import { + ChatCompletionRole, + MessageStatus, + ThreadContent, + ThreadMessage, +} from '@janhq/core' import { atom } from 'jotai' import { - conversationStatesAtom, - currentConversationAtom, - getActiveConvoIdAtom, + getActiveThreadIdAtom, updateThreadStateLastMessageAtom, } from './Conversation.atom' /** - * Stores all chat messages for all conversations + * Stores all chat messages for all threads */ export const chatMessages = atom>({}) @@ -17,33 +20,19 @@ export const chatMessages = atom>({}) * Return the chat messages for the current active conversation */ export const getCurrentChatMessagesAtom = atom((get) => { - const activeConversationId = get(getActiveConvoIdAtom) - if (!activeConversationId) return [] - const messages = get(chatMessages)[activeConversationId] + const activeThreadId = get(getActiveThreadIdAtom) + if (!activeThreadId) return [] + const messages = get(chatMessages)[activeThreadId] return messages ?? [] }) -export const setCurrentChatMessagesAtom = atom( - null, - (get, set, messages: ThreadMessage[]) => { - const currentConvoId = get(getActiveConvoIdAtom) - if (!currentConvoId) return - - const newData: Record = { - ...get(chatMessages), - } - newData[currentConvoId] = messages - set(chatMessages, newData) - } -) - export const setConvoMessagesAtom = atom( null, - (get, set, messages: ThreadMessage[], convoId: string) => { + (get, set, threadId: string, messages: ThreadMessage[]) => { const newData: Record = { ...get(chatMessages), } - newData[convoId] = messages + newData[threadId] = messages set(chatMessages, newData) } ) @@ -54,7 +43,7 @@ export const setConvoMessagesAtom = atom( export const addOldMessagesAtom = atom( null, (get, set, newMessages: ThreadMessage[]) => { - const currentConvoId = get(getActiveConvoIdAtom) + const currentConvoId = get(getActiveThreadIdAtom) if (!currentConvoId) return const currentMessages = get(chatMessages)[currentConvoId] ?? [] @@ -71,19 +60,19 @@ export const addOldMessagesAtom = atom( export const addNewMessageAtom = atom( null, (get, set, newMessage: ThreadMessage) => { - const currentConvoId = get(getActiveConvoIdAtom) - if (!currentConvoId) return + const threadId = get(getActiveThreadIdAtom) + if (!threadId) return - const currentMessages = get(chatMessages)[currentConvoId] ?? [] - const updatedMessages = [newMessage, ...currentMessages] + const currentMessages = get(chatMessages)[threadId] ?? [] + const updatedMessages = [...currentMessages, newMessage] const newData: Record = { ...get(chatMessages), } - newData[currentConvoId] = updatedMessages + newData[threadId] = updatedMessages set(chatMessages, newData) // Update thread last message - set(updateThreadStateLastMessageAtom, currentConvoId, newMessage.content) + set(updateThreadStateLastMessageAtom, threadId, newMessage.content) } ) @@ -107,7 +96,7 @@ export const deleteMessage = atom(null, (get, set, id: string) => { const newData: Record = { ...get(chatMessages), } - const threadId = get(currentConversationAtom)?.id + const threadId = get(getActiveThreadIdAtom) if (threadId) { newData[threadId] = newData[threadId].filter((e) => e.id !== id) set(chatMessages, newData) @@ -121,7 +110,7 @@ export const updateMessageAtom = atom( set, id: string, conversationId: string, - text: string, + text: ThreadContent[], status: MessageStatus ) => { const messages = get(chatMessages)[conversationId] ?? [] @@ -141,34 +130,3 @@ export const updateMessageAtom = atom( } } ) - -/** - * 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: ThreadMessage = { - ...messageToUpdate, - status: MessageStatus.Ready, - content: text, - } - - currentMessages[index] = updatedMsg - const newData: Record = { - ...get(chatMessages), - } - newData[currentConvoId] = currentMessages - set(chatMessages, newData) - } -) diff --git a/web/helpers/atoms/Conversation.atom.ts b/web/helpers/atoms/Conversation.atom.ts index 60748e038b..aaae32a8f4 100644 --- a/web/helpers/atoms/Conversation.atom.ts +++ b/web/helpers/atoms/Conversation.atom.ts @@ -1,115 +1,102 @@ -import { Thread } from '@janhq/core' +import { Thread, ThreadContent, ThreadState } from '@janhq/core' import { atom } from 'jotai' -import { ThreadState } from '@/types/conversation' - /** * Stores the current active conversation id. */ -const activeConversationIdAtom = atom(undefined) +const activeThreadIdAtom = atom(undefined) -export const getActiveConvoIdAtom = atom((get) => get(activeConversationIdAtom)) +export const getActiveThreadIdAtom = atom((get) => get(activeThreadIdAtom)) -export const setActiveConvoIdAtom = atom( +export const setActiveThreadIdAtom = atom( null, - (_get, set, convoId: string | undefined) => { - set(activeConversationIdAtom, convoId) - } + (_get, set, convoId: string | undefined) => set(activeThreadIdAtom, convoId) ) export const waitingToSendMessage = atom(undefined) + /** - * Stores all conversation states for the current user + * Stores all thread states for the current user */ -export const conversationStatesAtom = atom>({}) -export const currentConvoStateAtom = atom((get) => { - const activeConvoId = get(activeConversationIdAtom) +export const threadStatesAtom = atom>({}) +export const activeThreadStateAtom = atom((get) => { + const activeConvoId = get(activeThreadIdAtom) if (!activeConvoId) { console.debug('Active convo id is undefined') return undefined } - return get(conversationStatesAtom)[activeConvoId] + return get(threadStatesAtom)[activeConvoId] }) -export const addNewConversationStateAtom = atom( - null, - (get, set, conversationId: string, state: ThreadState) => { - 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) } + const currentState = { ...get(threadStatesAtom) } currentState[conversationId] = { ...currentState[conversationId], waitingForResponse, error: undefined, } - set(conversationStatesAtom, currentState) + set(threadStatesAtom, currentState) } ) export const updateConversationErrorAtom = atom( null, (get, set, conversationId: string, error?: Error) => { - const currentState = { ...get(conversationStatesAtom) } + const currentState = { ...get(threadStatesAtom) } currentState[conversationId] = { ...currentState[conversationId], error, } - set(conversationStatesAtom, currentState) + set(threadStatesAtom, currentState) } ) export const updateConversationHasMoreAtom = atom( null, (get, set, conversationId: string, hasMore: boolean) => { - const currentState = { ...get(conversationStatesAtom) } + const currentState = { ...get(threadStatesAtom) } currentState[conversationId] = { ...currentState[conversationId], hasMore } - set(conversationStatesAtom, currentState) + set(threadStatesAtom, currentState) } ) export const updateThreadStateLastMessageAtom = atom( null, - (get, set, conversationId: string, lastMessage?: string) => { - const currentState = { ...get(conversationStatesAtom) } - currentState[conversationId] = { - ...currentState[conversationId], + (get, set, threadId: string, lastContent?: ThreadContent[]) => { + const currentState = { ...get(threadStatesAtom) } + const lastMessage = lastContent?.[0]?.text?.value ?? '' + currentState[threadId] = { + ...currentState[threadId], lastMessage, } - set(conversationStatesAtom, currentState) + set(threadStatesAtom, currentState) } ) -export const updateConversationAtom = atom( +export const updateThreadAtom = atom( null, - (get, set, conversation: Thread) => { - const id = conversation.id - if (!id) return - const convo = get(userConversationsAtom).find((c) => c.id === id) - if (!convo) return - - const newConversations: Thread[] = get(userConversationsAtom).map((c) => - c.id === id ? conversation : c + (get, set, updatedThread: Thread) => { + const threads: Thread[] = get(threadsAtom).map((c) => + c.id === updatedThread.id ? updatedThread : c ) - // sort new conversations based on updated at - newConversations.sort((a, b) => { - const aDate = new Date(a.updatedAt ?? 0) - const bDate = new Date(b.updatedAt ?? 0) + // sort new threads based on updated at + threads.sort((thread1, thread2) => { + const aDate = new Date(thread1.updated ?? 0) + const bDate = new Date(thread2.updated ?? 0) return bDate.getTime() - aDate.getTime() }) - set(userConversationsAtom, newConversations) + set(threadsAtom, threads) } ) /** - * Stores all conversations for the current user + * Stores all threads for the current user */ -export const userConversationsAtom = atom([]) -export const currentConversationAtom = atom((get) => - get(userConversationsAtom).find((c) => c.id === get(getActiveConvoIdAtom)) +export const threadsAtom = atom([]) + +export const activeThreadAtom = atom((get) => + get(threadsAtom).find((c) => c.id === get(getActiveThreadIdAtom)) ) diff --git a/web/helpers/atoms/Model.atom.ts b/web/helpers/atoms/Model.atom.ts index 0f1c7de4cf..d829b3b31b 100644 --- a/web/helpers/atoms/Model.atom.ts +++ b/web/helpers/atoms/Model.atom.ts @@ -2,6 +2,5 @@ import { Model } from '@janhq/core/lib/types' import { atom } from 'jotai' export const stateModel = atom({ state: 'start', loading: false, model: '' }) -export const selectedModelAtom = atom(undefined) export const activeAssistantModelAtom = atom(undefined) export const downloadingModelsAtom = atom([]) diff --git a/web/hooks/useActiveModel.ts b/web/hooks/useActiveModel.ts index 5da30ecf71..4c95762149 100644 --- a/web/hooks/useActiveModel.ts +++ b/web/hooks/useActiveModel.ts @@ -1,24 +1,18 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { join } from 'path' - import { PluginType } from '@janhq/core' import { InferencePlugin } from '@janhq/core/lib/plugins' -import { Model } from '@janhq/core/lib/types' - +import { Model, ModelSettingParams } from '@janhq/core/lib/types' import { atom, useAtom } from 'jotai' - import { toaster } from '@/containers/Toast' - import { useGetDownloadedModels } from './useGetDownloadedModels' - import { pluginManager } from '@/plugin' -const activeAssistantModelAtom = atom(undefined) +const activeModelAtom = atom(undefined) const stateModelAtom = atom({ state: 'start', loading: false, model: '' }) export function useActiveModel() { - const [activeModel, setActiveModel] = useAtom(activeAssistantModelAtom) + const [activeModel, setActiveModel] = useAtom(activeModelAtom) const [stateModel, setStateModel] = useAtom(stateModelAtom) const { downloadedModels } = useGetDownloadedModels() @@ -30,6 +24,7 @@ export function useActiveModel() { console.debug(`Model ${modelId} is already initialized. Ignore..`) return } + // TODO: incase we have multiple assistants, the configuration will be from assistant setActiveModel(undefined) @@ -52,8 +47,7 @@ export function useActiveModel() { const currentTime = Date.now() console.debug('Init model: ', modelId) - const path = join('models', model.name, modelId) - const res = await initModel(path) + const res = await initModel(modelId, model?.settings) if (res && res.error && res.modelFile === stateModel.model) { const errorMessage = `${res.error}` alert(errorMessage) @@ -98,8 +92,11 @@ export function useActiveModel() { return { activeModel, startModel, stopModel, stateModel } } -const initModel = async (modelId: string): Promise => { +const initModel = async ( + modelId: string, + settings?: ModelSettingParams +): Promise => { return pluginManager .get(PluginType.Inference) - ?.initModel(modelId) + ?.initModel(modelId, settings) } diff --git a/web/hooks/useCreateConversation.ts b/web/hooks/useCreateConversation.ts deleted file mode 100644 index 5f42ee7cea..0000000000 --- a/web/hooks/useCreateConversation.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { PluginType } from '@janhq/core' -import { Thread, Model } from '@janhq/core' -import { ConversationalPlugin } from '@janhq/core/lib/plugins' -import { useAtom, useSetAtom } from 'jotai' - -import { generateConversationId } from '@/utils/conversation' - -import { - userConversationsAtom, - setActiveConvoIdAtom, - addNewConversationStateAtom, -} from '@/helpers/atoms/Conversation.atom' -import { pluginManager } from '@/plugin' - -export const useCreateConversation = () => { - const [userConversations, setUserConversations] = useAtom( - userConversationsAtom - ) - const setActiveConvoId = useSetAtom(setActiveConvoIdAtom) - const addNewConvoState = useSetAtom(addNewConversationStateAtom) - - const requestCreateConvo = async (model: Model) => { - const mappedConvo: Thread = { - id: generateConversationId(), - modelId: model.id, - summary: model.name, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - messages: [], - } - - addNewConvoState(mappedConvo.id, { - hasMore: true, - waitingForResponse: false, - }) - - await pluginManager - .get(PluginType.Conversational) - ?.saveConversation(mappedConvo) - setUserConversations([mappedConvo, ...userConversations]) - setActiveConvoId(mappedConvo.id) - } - - return { - requestCreateConvo, - } -} diff --git a/web/hooks/useCreateNewThread.ts b/web/hooks/useCreateNewThread.ts new file mode 100644 index 0000000000..c397b329c5 --- /dev/null +++ b/web/hooks/useCreateNewThread.ts @@ -0,0 +1,90 @@ +import { + Assistant, + Thread, + ThreadAssistantInfo, + ThreadState, +} from '@janhq/core/lib/types' +import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai' +import { generateThreadId } from '@/utils/conversation' +import { + threadsAtom, + setActiveThreadIdAtom, + threadStatesAtom, +} from '@/helpers/atoms/Conversation.atom' + +const createNewThreadAtom = atom(null, (get, set, newThread: Thread) => { + // create thread state for this new thread + const currentState = { ...get(threadStatesAtom) } + + const threadState: ThreadState = { + hasMore: false, + waitingForResponse: false, + } + currentState[newThread.id] = threadState + set(threadStatesAtom, currentState) + + // add the new thread on top of the thread list to the state + const threads = get(threadsAtom) + set(threadsAtom, [newThread, ...threads]) +}) + +export const useCreateNewThread = () => { + const createNewThread = useSetAtom(createNewThreadAtom) + const setActiveThreadId = useSetAtom(setActiveThreadIdAtom) + const [threadStates, setThreadStates] = useAtom(threadStatesAtom) + const threads = useAtomValue(threadsAtom) + + const requestCreateNewThread = async (assistant: Assistant) => { + const unfinishedThreads = threads.filter((t) => t.isFinishInit === false) + if (unfinishedThreads.length > 0) { + return + } + + const createdAt = Date.now() + const assistantInfo: ThreadAssistantInfo = { + assistant_id: assistant.id, + assistant_name: assistant.name, + model: { + id: '*', + settings: { + ctx_len: 0, + ngl: 0, + embedding: false, + n_parallel: 0, + }, + parameters: { + temperature: 0, + token_limit: 0, + top_k: 0, + top_p: 0, + stream: false, + }, + }, + } + const threadId = generateThreadId(assistant.id) + const thread: Thread = { + id: threadId, + object: 'thread', + title: '', + assistants: [assistantInfo], + created: createdAt, + updated: createdAt, + isFinishInit: false, + } + + // TODO: move isFinishInit here + const threadState: ThreadState = { + hasMore: false, + waitingForResponse: false, + lastMessage: undefined, + } + setThreadStates({ ...threadStates, [threadId]: threadState }) + // add the new thread on top of the thread list to the state + createNewThread(thread) + setActiveThreadId(thread.id) + } + + return { + requestCreateNewThread, + } +} diff --git a/web/hooks/useDeleteConversation.ts b/web/hooks/useDeleteConversation.ts index f54fda928c..d793f6788d 100644 --- a/web/hooks/useDeleteConversation.ts +++ b/web/hooks/useDeleteConversation.ts @@ -1,4 +1,4 @@ -import { ChatCompletionRole, PluginType } from '@janhq/core' +import { PluginType } from '@janhq/core' import { ConversationalPlugin } from '@janhq/core/lib/plugins' import { useAtom, useAtomValue, useSetAtom } from 'jotai' @@ -13,74 +13,70 @@ import { useActiveModel } from './useActiveModel' import { cleanConversationMessages, deleteConversationMessage, - getCurrentChatMessagesAtom, } from '@/helpers/atoms/ChatMessage.atom' import { - userConversationsAtom, - getActiveConvoIdAtom, - setActiveConvoIdAtom, + threadsAtom, + getActiveThreadIdAtom, + setActiveThreadIdAtom, } from '@/helpers/atoms/Conversation.atom' -export default function useDeleteConversation() { +export default function useDeleteThread() { const { activeModel } = useActiveModel() - const [userConversations, setUserConversations] = useAtom( - userConversationsAtom - ) + const [userConversations, setUserConversations] = useAtom(threadsAtom) const setCurrentPrompt = useSetAtom(currentPromptAtom) - const activeConvoId = useAtomValue(getActiveConvoIdAtom) + const activeThreadId = useAtomValue(getActiveThreadIdAtom) - const setActiveConvoId = useSetAtom(setActiveConvoIdAtom) + const setActiveConvoId = useSetAtom(setActiveThreadIdAtom) const deleteMessages = useSetAtom(deleteConversationMessage) const cleanMessages = useSetAtom(cleanConversationMessages) - const currentMessages = useAtomValue(getCurrentChatMessagesAtom) const cleanConvo = async () => { - if (activeConvoId) { + if (activeThreadId) { const currentConversation = userConversations.filter( - (c) => c.id === activeConvoId + (c) => c.id === activeThreadId )[0] - cleanMessages(activeConvoId) + cleanMessages(activeThreadId) if (currentConversation) await pluginManager .get(PluginType.Conversational) - ?.saveConversation({ + ?.saveThread({ ...currentConversation, - id: activeConvoId, - messages: currentMessages.filter( - (e) => e.role === ChatCompletionRole.System - ), + id: activeThreadId, }) } } - const deleteConvo = async () => { - if (activeConvoId) { - try { - await pluginManager - .get(PluginType.Conversational) - ?.deleteConversation(activeConvoId) - const currentConversations = userConversations.filter( - (c) => c.id !== activeConvoId - ) - setUserConversations(currentConversations) - deleteMessages(activeConvoId) - setCurrentPrompt('') - toaster({ - title: 'Chat successfully deleted.', - description: `Chat with ${activeModel?.name} has been successfully deleted.`, - }) - if (currentConversations.length > 0) { - setActiveConvoId(currentConversations[0].id) - } else { - setActiveConvoId(undefined) - } - } catch (err) { - console.error(err) + + const deleteThread = async () => { + if (!activeThreadId) { + alert('No active thread') + return + } + try { + await pluginManager + .get(PluginType.Conversational) + ?.deleteThread(activeThreadId) + const currentConversations = userConversations.filter( + (c) => c.id !== activeThreadId + ) + setUserConversations(currentConversations) + deleteMessages(activeThreadId) + setCurrentPrompt('') + toaster({ + title: 'Chat successfully deleted.', + description: `Chat with ${activeModel?.name} has been successfully deleted.`, + }) + if (currentConversations.length > 0) { + setActiveConvoId(currentConversations[0].id) + } else { + setActiveConvoId(undefined) } + } catch (err) { + console.error(err) } } return { cleanConvo, - deleteConvo, + deleteThread, } } diff --git a/web/hooks/useDeleteModel.ts b/web/hooks/useDeleteModel.ts index c3b03c61ab..6dada49be4 100644 --- a/web/hooks/useDeleteModel.ts +++ b/web/hooks/useDeleteModel.ts @@ -1,21 +1,17 @@ -import { join } from 'path' - import { PluginType } from '@janhq/core' import { ModelPlugin } from '@janhq/core/lib/plugins' import { Model } from '@janhq/core/lib/types' - import { toaster } from '@/containers/Toast' - import { useGetDownloadedModels } from '@/hooks/useGetDownloadedModels' - import { pluginManager } from '@/plugin/PluginManager' export default function useDeleteModel() { const { setDownloadedModels, downloadedModels } = useGetDownloadedModels() const deleteModel = async (model: Model) => { - const path = join('models', model.name, model.id) - await pluginManager.get(PluginType.Model)?.deleteModel(path) + await pluginManager + .get(PluginType.Model) + ?.deleteModel(model.id) // reload models setDownloadedModels(downloadedModels.filter((e) => e.id !== model.id)) diff --git a/web/hooks/useDownloadModel.ts b/web/hooks/useDownloadModel.ts index bbe48f3972..7689d1c668 100644 --- a/web/hooks/useDownloadModel.ts +++ b/web/hooks/useDownloadModel.ts @@ -1,6 +1,6 @@ import { PluginType } from '@janhq/core' import { ModelPlugin } from '@janhq/core/lib/plugins' -import { Model, ModelCatalog, ModelVersion } from '@janhq/core/lib/types' +import { Model } from '@janhq/core/lib/types' import { useAtom } from 'jotai' @@ -16,41 +16,10 @@ export default function useDownloadModel() { downloadingModelsAtom ) - const assistanModel = ( - model: ModelCatalog, - modelVersion: ModelVersion - ): Model => { - return { - /** - * Id will be used for the model file name - * Should be the version name - */ - id: modelVersion.name, - name: model.name, - quantizationName: modelVersion.quantizationName, - bits: modelVersion.bits, - size: modelVersion.size, - maxRamRequired: modelVersion.maxRamRequired, - usecase: modelVersion.usecase, - downloadLink: modelVersion.downloadLink, - shortDescription: model.shortDescription, - longDescription: model.longDescription, - avatarUrl: model.avatarUrl, - author: model.author, - version: model.version, - modelUrl: model.modelUrl, - releaseDate: -1, - tags: model.tags, - } - } - - const downloadModel = async ( - model: ModelCatalog, - modelVersion: ModelVersion - ) => { + const downloadModel = async (model: Model) => { // set an initial download state setDownloadState({ - modelId: modelVersion.name, + modelId: model.id, time: { elapsed: 0, remaining: 0, @@ -61,16 +30,11 @@ export default function useDownloadModel() { total: 0, transferred: 0, }, - fileName: modelVersion.name, + fileName: model.id, }) - const assistantModel = assistanModel(model, modelVersion) - - setDownloadingModels([...downloadingModels, assistantModel]) - - await pluginManager - .get(PluginType.Model) - ?.downloadModel(assistantModel) + setDownloadingModels([...downloadingModels, model]) + await pluginManager.get(PluginType.Model)?.downloadModel(model) } return { diff --git a/web/hooks/useGetAllThreads.ts b/web/hooks/useGetAllThreads.ts new file mode 100644 index 0000000000..8f9a1a39f3 --- /dev/null +++ b/web/hooks/useGetAllThreads.ts @@ -0,0 +1,42 @@ +import { PluginType, ThreadState } from '@janhq/core' +import { ConversationalPlugin } from '@janhq/core/lib/plugins' +import { useSetAtom } from 'jotai' +import { + threadStatesAtom, + threadsAtom, +} from '@/helpers/atoms/Conversation.atom' +import { pluginManager } from '@/plugin/PluginManager' + +const useGetAllThreads = () => { + const setConversationStates = useSetAtom(threadStatesAtom) + const setConversations = useSetAtom(threadsAtom) + + const getAllThreads = async () => { + try { + const threads = await pluginManager + .get(PluginType.Conversational) + ?.getThreads() + const threadStates: Record = {} + threads?.forEach((thread) => { + if (thread.id != null) { + const lastMessage = thread.metadata?.lastMessage ?? '' + threadStates[thread.id] = { + hasMore: true, + waitingForResponse: false, + lastMessage, + } + } + }) + setConversationStates(threadStates) + setConversations(threads ?? []) + } catch (error) { + console.error(error) + } + } + + return { + getAllThreads, + } +} + +export default useGetAllThreads diff --git a/web/hooks/useGetAssistants.ts b/web/hooks/useGetAssistants.ts new file mode 100644 index 0000000000..c899a46b85 --- /dev/null +++ b/web/hooks/useGetAssistants.ts @@ -0,0 +1,32 @@ +import { pluginManager } from '@/plugin/PluginManager' +import { Assistant, PluginType } from '@janhq/core' +import { AssistantPlugin } from '@janhq/core/lib/plugins' +import { useEffect, useState } from 'react' + +const getAssistants = async (): Promise => { + return ( + pluginManager.get(PluginType.Assistant)?.getAssistants() ?? + [] + ) +} + +/** + * Hooks for get assistants + * + * @returns assistants + */ +export default function useGetAssistants() { + const [assistants, setAssistants] = useState([]) + + useEffect(() => { + getAssistants() + .then((data) => { + setAssistants(data) + }) + .catch((err) => { + console.error(err) + }) + }, []) + + return { assistants } +} diff --git a/web/hooks/useGetDownloadedModels.ts b/web/hooks/useGetDownloadedModels.ts index d9621230f1..c51b62ea88 100644 --- a/web/hooks/useGetDownloadedModels.ts +++ b/web/hooks/useGetDownloadedModels.ts @@ -1,22 +1,11 @@ -import { useEffect } from 'react' - -const downloadedModelAtom = atom([]) +import { useEffect, useState } from 'react' import { PluginType } from '@janhq/core' import { ModelPlugin } from '@janhq/core/lib/plugins' import { Model } from '@janhq/core/lib/types' -import { atom, useAtom } from 'jotai' - import { pluginManager } from '@/plugin/PluginManager' export function useGetDownloadedModels() { - const [downloadedModels, setDownloadedModels] = useAtom(downloadedModelAtom) - - async function getDownloadedModels(): Promise { - const models = await pluginManager - .get(PluginType.Model) - ?.getDownloadedModels() - return models ?? [] - } + const [downloadedModels, setDownloadedModels] = useState([]) useEffect(() => { getDownloadedModels().then((downloadedModels) => { @@ -26,3 +15,10 @@ export function useGetDownloadedModels() { return { downloadedModels, setDownloadedModels } } + +export async function getDownloadedModels(): Promise { + const models = await pluginManager + .get(PluginType.Model) + ?.getDownloadedModels() + return models ?? [] +} diff --git a/web/hooks/useGetInputState.ts b/web/hooks/useGetInputState.ts deleted file mode 100644 index 2ecd36cd7a..0000000000 --- a/web/hooks/useGetInputState.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { useEffect, useState } from 'react' - -import { Model, Thread } from '@janhq/core' -import { useAtomValue } from 'jotai' - -import { useActiveModel } from './useActiveModel' -import { useGetDownloadedModels } from './useGetDownloadedModels' - -import { currentConversationAtom } from '@/helpers/atoms/Conversation.atom' - -export default function useGetInputState() { - const [inputState, setInputState] = useState('loading') - const currentThread = useAtomValue(currentConversationAtom) - const { activeModel } = useActiveModel() - const { downloadedModels } = useGetDownloadedModels() - - const handleInputState = ( - thread: Thread | undefined, - currentModel: Model | undefined - ) => { - if (thread == null) return - if (currentModel == null) { - setInputState('loading') - return - } - - // check if convo model id is in downloaded models - const isModelAvailable = downloadedModels.some( - (model) => model.id === thread.modelId - ) - - if (!isModelAvailable) { - // can't find model in downloaded models - setInputState('model-not-found') - return - } - - if (thread.modelId !== currentModel.id) { - // in case convo model and active model is different, - // ask user to init the required model - setInputState('model-mismatch') - return - } - - setInputState('available') - } - - useEffect(() => { - handleInputState(currentThread, activeModel) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - return { inputState, currentThread } -} - -type InputType = 'available' | 'loading' | 'model-mismatch' | 'model-not-found' diff --git a/web/hooks/useGetMostSuitableModelVersion.ts b/web/hooks/useGetMostSuitableModelVersion.ts index 1e053f1a43..87e2d2f4ef 100644 --- a/web/hooks/useGetMostSuitableModelVersion.ts +++ b/web/hooks/useGetMostSuitableModelVersion.ts @@ -1,19 +1,19 @@ import { useState } from 'react' -import { ModelVersion } from '@janhq/core/lib/types' +import { Model } from '@janhq/core/lib/types' import { useAtomValue } from 'jotai' import { totalRamAtom } from '@/helpers/atoms/SystemBar.atom' export default function useGetMostSuitableModelVersion() { - const [suitableModel, setSuitableModel] = useState() + const [suitableModel, setSuitableModel] = useState() const totalRam = useAtomValue(totalRamAtom) - const getMostSuitableModelVersion = async (modelVersions: ModelVersion[]) => { + const getMostSuitableModelVersion = async (modelVersions: Model[]) => { // find the model version with the highest required RAM that is still below the user's RAM by 80% const modelVersion = modelVersions.reduce((prev, current) => { - if (current.maxRamRequired > prev.maxRamRequired) { - if (current.maxRamRequired < totalRam * 0.8) { + if (current.metadata.maxRamRequired > prev.metadata.maxRamRequired) { + if (current.metadata.maxRamRequired < totalRam * 0.8) { return current } } diff --git a/web/hooks/useGetPerformanceTag.ts b/web/hooks/useGetPerformanceTag.ts index 435f82c4c6..17839f3697 100644 --- a/web/hooks/useGetPerformanceTag.ts +++ b/web/hooks/useGetPerformanceTag.ts @@ -1,4 +1,4 @@ -import { ModelVersion } from '@janhq/core/lib/types' +import { Model } from '@janhq/core/lib/types' import { ModelPerformance, TagType } from '@/constants/tagType' @@ -9,10 +9,10 @@ import { ModelPerformance, TagType } from '@/constants/tagType' export default function useGetPerformanceTag() { async function getPerformanceForModel( - modelVersion: ModelVersion, + model: Model, totalRam: number ): Promise<{ title: string; performanceTag: TagType }> { - const requiredRam = modelVersion.maxRamRequired + const requiredRam = model.metadata.maxRamRequired const performanceTag = calculateRamPerformance(requiredRam, totalRam) let title = '' diff --git a/web/hooks/useGetUserConversations.ts b/web/hooks/useGetUserConversations.ts deleted file mode 100644 index 3a6e4eb550..0000000000 --- a/web/hooks/useGetUserConversations.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { PluginType, Thread } from '@janhq/core' -import { ConversationalPlugin } from '@janhq/core/lib/plugins' -import { useSetAtom } from 'jotai' - -import { setConvoMessagesAtom } from '@/helpers/atoms/ChatMessage.atom' -import { - conversationStatesAtom, - userConversationsAtom, -} from '@/helpers/atoms/Conversation.atom' -import { pluginManager } from '@/plugin/PluginManager' -import { ThreadState } from '@/types/conversation' - -const useGetUserConversations = () => { - const setConversationStates = useSetAtom(conversationStatesAtom) - const setConversations = useSetAtom(userConversationsAtom) - const setConvoMessages = useSetAtom(setConvoMessagesAtom) - - const getUserConversations = async () => { - try { - const convos: Thread[] | undefined = await pluginManager - .get(PluginType.Conversational) - ?.getConversations() - const convoStates: Record = {} - convos?.forEach((convo) => { - convoStates[convo.id ?? ''] = { - hasMore: true, - waitingForResponse: false, - lastMessage: convo.messages[0]?.content ?? '', - } - setConvoMessages(convo.messages, convo.id ?? '') - }) - setConversationStates(convoStates) - setConversations(convos ?? []) - } catch (error) { - console.error(error) - } - } - - return { - getUserConversations, - } -} - -export default useGetUserConversations diff --git a/web/hooks/useSendChatMessage.ts b/web/hooks/useSendChatMessage.ts index ad03abfb69..7d0eacc4b5 100644 --- a/web/hooks/useSendChatMessage.ts +++ b/web/hooks/useSendChatMessage.ts @@ -1,10 +1,12 @@ import { ChatCompletionMessage, ChatCompletionRole, + ContentType, EventName, MessageRequest, MessageStatus, PluginType, + Thread, ThreadMessage, events, } from '@janhq/core' @@ -22,29 +24,30 @@ import { getCurrentChatMessagesAtom, } from '@/helpers/atoms/ChatMessage.atom' import { - currentConversationAtom, - updateConversationAtom, + activeThreadAtom, + updateThreadAtom, updateConversationWaitingForResponseAtom, } from '@/helpers/atoms/Conversation.atom' import { pluginManager } from '@/plugin/PluginManager' +import { selectedModelAtom } from '@/containers/DropdownListSidebar' export default function useSendChatMessage() { - const currentConvo = useAtomValue(currentConversationAtom) + const activeThread = useAtomValue(activeThreadAtom) const addNewMessage = useSetAtom(addNewMessageAtom) - const updateConversation = useSetAtom(updateConversationAtom) + const updateThread = useSetAtom(updateThreadAtom) const updateConvWaiting = useSetAtom(updateConversationWaitingForResponseAtom) const [currentPrompt, setCurrentPrompt] = useAtom(currentPromptAtom) + const currentMessages = useAtomValue(getCurrentChatMessagesAtom) const { activeModel } = useActiveModel() + const selectedModel = useAtomValue(selectedModelAtom) - function updateConvSummary(newMessage: MessageRequest) { + function updateThreadTitle(newMessage: MessageRequest) { if ( - currentConvo && + activeThread && newMessage.messages && - newMessage.messages.length >= 2 && - (!currentConvo.summary || - currentConvo.summary === '' || - currentConvo.summary === activeModel?.name) + newMessage.messages.length > 2 && + (activeThread.title === '' || activeThread.title === activeModel?.name) ) { const summaryMsg: ChatCompletionMessage = { role: ChatCompletionRole.User, @@ -60,70 +63,118 @@ export default function useSendChatMessage() { messages: newMessage.messages?.slice(0, -1).concat([summaryMsg]), }) .catch(console.error) + const content = result?.content[0]?.text.value.trim() if ( - currentConvo && - currentConvo.id === newMessage.threadId && - result?.content && - result?.content?.trim().length > 0 && - result.content.split(' ').length <= 20 + activeThread && + activeThread.id === newMessage.threadId && + content && + content.length > 0 && + content.split(' ').length <= 20 ) { - const updatedConv = { - ...currentConvo, - summary: result.content, + const updatedConv: Thread = { + ...activeThread, + title: content, } - updateConversation(updatedConv) + updateThread(updatedConv) pluginManager .get(PluginType.Conversational) - ?.saveConversation({ - ...updatedConv, - messages: currentMessages, - }) + ?.saveThread(updatedConv) } }, 1000) } } const sendChatMessage = async () => { - const threadId = currentConvo?.id - if (!threadId) { - console.error('No conversation id') + if (!currentPrompt || currentPrompt.trim().length === 0) { + return + } + if (!activeThread) { + console.error('No active thread') return } - setCurrentPrompt('') - updateConvWaiting(threadId, true) + if (!activeThread.isFinishInit) { + if (!selectedModel) { + alert('Please select a model') + return + } + const assistantId = activeThread.assistants[0].assistant_id ?? '' + const assistantName = activeThread.assistants[0].assistant_name ?? '' + const updatedThread: Thread = { + ...activeThread, + isFinishInit: true, + title: `${activeThread.assistants[0].assistant_name} with ${selectedModel.name}`, + assistants: [ + { + assistant_id: assistantId, + assistant_name: assistantName, + model: { + id: selectedModel.id, + settings: selectedModel.settings, + parameters: selectedModel.parameters, + }, + }, + ], + } + + updateThread(updatedThread) + + pluginManager + .get(PluginType.Conversational) + ?.saveThread(updatedThread) + } + + updateConvWaiting(activeThread.id, true) const prompt = currentPrompt.trim() + setCurrentPrompt('') + const messages: ChatCompletionMessage[] = currentMessages .map((msg) => ({ - role: msg.role ?? ChatCompletionRole.User, - content: msg.content ?? '', + role: msg.role, + content: msg.content[0]?.text.value ?? '', })) - .reverse() .concat([ { role: ChatCompletionRole.User, content: prompt, } as ChatCompletionMessage, ]) + console.debug(`Sending messages: ${JSON.stringify(messages, null, 2)}`) + const msgId = ulid() const messageRequest: MessageRequest = { - id: ulid(), - threadId: threadId, + id: msgId, + threadId: activeThread.id, messages, + parameters: activeThread.assistants[0].model.parameters, } - + const timestamp = Date.now() const threadMessage: ThreadMessage = { - id: messageRequest.id, - threadId: messageRequest.threadId, - content: prompt, + id: msgId, + thread_id: activeThread.id, role: ChatCompletionRole.User, - createdAt: new Date().toISOString(), status: MessageStatus.Ready, + created: timestamp, + updated: timestamp, + object: 'thread.message', + content: [ + { + type: ContentType.Text, + text: { + value: prompt, + annotations: [], + }, + }, + ], } + addNewMessage(threadMessage) + await pluginManager + .get(PluginType.Conversational) + ?.addNewMessage(threadMessage) events.emit(EventName.OnNewMessageRequest, messageRequest) - updateConvSummary(messageRequest) + updateThreadTitle(messageRequest) } return { diff --git a/web/hooks/useSetActiveThread.ts b/web/hooks/useSetActiveThread.ts new file mode 100644 index 0000000000..1137b6cbb9 --- /dev/null +++ b/web/hooks/useSetActiveThread.ts @@ -0,0 +1,37 @@ +import { setConvoMessagesAtom } from '@/helpers/atoms/ChatMessage.atom' +import { + getActiveThreadIdAtom, + setActiveThreadIdAtom, +} from '@/helpers/atoms/Conversation.atom' +import { pluginManager } from '@/plugin' +import { PluginType, Thread } from '@janhq/core' +import { ConversationalPlugin } from '@janhq/core/lib/plugins' +import { useAtomValue, useSetAtom } from 'jotai' + +export default function useSetActiveThread() { + const activeThreadId = useAtomValue(getActiveThreadIdAtom) + const setActiveThreadId = useSetAtom(setActiveThreadIdAtom) + const setThreadMessage = useSetAtom(setConvoMessagesAtom) + + const setActiveThread = async (thread: Thread) => { + if (activeThreadId === thread.id) { + console.debug('Thread already active') + return + } + + if (!thread.isFinishInit) { + console.debug('Thread not finish init') + return + } + + // load the corresponding messages + const messages = await pluginManager + .get(PluginType.Conversational) + ?.getAllMessages(thread.id) + setThreadMessage(thread.id, messages ?? []) + + setActiveThreadId(thread.id) + } + + return { activeThreadId, setActiveThread } +} diff --git a/web/screens/Chat/ChatBody/index.tsx b/web/screens/Chat/ChatBody/index.tsx index b34b4fae16..a477f75dc6 100644 --- a/web/screens/Chat/ChatBody/index.tsx +++ b/web/screens/Chat/ChatBody/index.tsx @@ -4,11 +4,14 @@ import ChatInstruction from '../ChatInstruction' import ChatItem from '../ChatItem' import { getCurrentChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom' +import { useActiveModel } from '@/hooks/useActiveModel' +import { activeThreadAtom } from '@/helpers/atoms/Conversation.atom' +import { useEffect } from 'react' const ChatBody: React.FC = () => { const messages = useAtomValue(getCurrentChatMessagesAtom) return ( -
+
{messages.map((message) => ( ))} diff --git a/web/screens/Chat/HistoryList/index.tsx b/web/screens/Chat/HistoryList/index.tsx deleted file mode 100644 index a6f4919dca..0000000000 --- a/web/screens/Chat/HistoryList/index.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { useEffect } from 'react' - -import { Thread, Model } from '@janhq/core' -import { Button } from '@janhq/uikit' -import { motion as m } from 'framer-motion' -import { useAtomValue, useSetAtom } from 'jotai' - -import { GalleryHorizontalEndIcon } from 'lucide-react' - -import { twMerge } from 'tailwind-merge' - -import { useActiveModel } from '@/hooks/useActiveModel' -import { useCreateConversation } from '@/hooks/useCreateConversation' -import useGetUserConversations from '@/hooks/useGetUserConversations' - -import { displayDate } from '@/utils/datetime' - -import { - conversationStatesAtom, - getActiveConvoIdAtom, - setActiveConvoIdAtom, - userConversationsAtom, -} from '@/helpers/atoms/Conversation.atom' - -export default function HistoryList() { - const conversations = useAtomValue(userConversationsAtom) - const threadStates = useAtomValue(conversationStatesAtom) - const { getUserConversations } = useGetUserConversations() - const { activeModel } = useActiveModel() - const { requestCreateConvo } = useCreateConversation() - const activeConvoId = useAtomValue(getActiveConvoIdAtom) - const setActiveConvoId = useSetAtom(setActiveConvoIdAtom) - - useEffect(() => { - getUserConversations() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - const handleClickConversation = () => { - if (activeModel) requestCreateConvo(activeModel as Model) - return - } - - const handleActiveModel = async (convo: Thread) => { - if (convo.modelId == null) { - console.debug('modelId is undefined') - return - } - if (activeConvoId !== convo.id) { - setActiveConvoId(convo.id) - } - } - - return ( -
-
- -
- - {conversations.length === 0 ? ( -
- -

No Chat History

-

Get started by creating a new chat

-
- ) : ( - conversations.map((convo, i) => { - const lastMessage = threadStates[convo.id]?.lastMessage - return ( -
handleActiveModel(convo as Thread)} - > -

- {convo.updatedAt && - displayDate(new Date(convo.updatedAt).getTime())} -

-

{convo.summary}

-

- {/* TODO: Check latest message update */} - {lastMessage && lastMessage.length > 0 - ? lastMessage - : 'No new message'} -

- {activeModel && activeConvoId === convo.id && ( - - )} -
- ) - }) - )} -
- ) -} diff --git a/web/screens/Chat/Sidebar/index.tsx b/web/screens/Chat/Sidebar/index.tsx new file mode 100644 index 0000000000..2274f1df91 --- /dev/null +++ b/web/screens/Chat/Sidebar/index.tsx @@ -0,0 +1,119 @@ +import CardSidebar from '@/containers/CardSidebar' +import ItemCardSidebar from '@/containers/ItemCardSidebar' +import DropdownListSidebar, { + selectedModelAtom, +} from '@/containers/DropdownListSidebar' +import { atom, useAtom, useAtomValue } from 'jotai' +import { activeThreadAtom } from '@/helpers/atoms/Conversation.atom' +import { fs } from '@janhq/core' +import { join } from 'path' + +export const showRightSideBarAtom = atom(false) + +export default function Sidebar() { + const showing = useAtomValue(showRightSideBarAtom) + const activeThread = useAtomValue(activeThreadAtom) + const selectedModel = useAtomValue(selectedModelAtom) + + const onReviewInFinderClick = async (type: string) => { + if (!activeThread) return + if (!activeThread.isFinishInit) { + alert('Thread is not ready') + return + } + + const userSpace = await fs.getUserSpace() + let filePath = undefined + switch (type) { + case 'Thread': + filePath = join('threads', activeThread.id) + break + case 'Model': + if (!selectedModel) return + filePath = join('models', selectedModel.id) + break + case 'Assistant': + const assistantId = activeThread.assistants[0]?.id + if (!assistantId) return + filePath = join('assistants', assistantId) + break + default: + break + } + + if (!filePath) return + + const fullPath = join(userSpace, filePath) + console.log(fullPath) + fs.openFileExplorer(fullPath) + } + + const onViewJsonClick = async (type: string) => { + if (!activeThread) return + if (!activeThread.isFinishInit) { + alert('Thread is not ready') + return + } + + const userSpace = await fs.getUserSpace() + let filePath = undefined + switch (type) { + case 'Thread': + filePath = join('threads', activeThread.id, 'thread.json') + break + case 'Model': + if (!selectedModel) return + filePath = join('models', selectedModel.id, 'model.json') + break + case 'Assistant': + const assistantId = activeThread.assistants[0]?.id + if (!assistantId) return + filePath = join('assistants', assistantId, 'assistant.json') + break + default: + break + } + + if (!filePath) return + + const fullPath = join(userSpace, filePath) + console.log(fullPath) + fs.openFileExplorer(fullPath) + } + + return ( +
+
+ + + + + + + + + + +
+
+ ) +} diff --git a/web/screens/Chat/SimpleTextMessage/index.tsx b/web/screens/Chat/SimpleTextMessage/index.tsx index 9bfaf9d1c3..456866a538 100644 --- a/web/screens/Chat/SimpleTextMessage/index.tsx +++ b/web/screens/Chat/SimpleTextMessage/index.tsx @@ -50,7 +50,12 @@ const marked = new Marked( ) const SimpleTextMessage: React.FC = (props) => { - const parsedText = marked.parse(props.content ?? '') + let text = '' + if (props.content && props.content.length > 0) { + text = props.content[0]?.text?.value ?? '' + } + + const parsedText = marked.parse(text) const isUser = props.role === ChatCompletionRole.User const isSystem = props.role === ChatCompletionRole.System const [tokenCount, setTokenCount] = useState(0) @@ -66,7 +71,8 @@ const SimpleTextMessage: React.FC = (props) => { const currentTimestamp = new Date().getTime() // Get current time in milliseconds if (!lastTimestamp) { // If this is the first update, just set the lastTimestamp and return - if (props.content !== '') setLastTimestamp(currentTimestamp) + if (props.content[0]?.text?.value !== '') + setLastTimestamp(currentTimestamp) return } @@ -89,7 +95,7 @@ const SimpleTextMessage: React.FC = (props) => { > {!isUser && !isSystem && }
{props.role}
-

{displayDate(props.createdAt)}

+

{displayDate(props.created)}

= (props) => {
{props.status === MessageStatus.Pending && - (!props.content || props.content === '') ? ( + (!props.content[0] || props.content[0].text.value === '') ? ( ) : ( <> diff --git a/web/screens/Chat/ThreadList/index.tsx b/web/screens/Chat/ThreadList/index.tsx new file mode 100644 index 0000000000..d5af77875c --- /dev/null +++ b/web/screens/Chat/ThreadList/index.tsx @@ -0,0 +1,91 @@ +import { Button } from '@janhq/uikit' +import { motion as m } from 'framer-motion' +import { useAtomValue } from 'jotai' +import { GalleryHorizontalEndIcon } from 'lucide-react' +import { twMerge } from 'tailwind-merge' +import { useCreateNewThread } from '@/hooks/useCreateNewThread' +import { displayDate } from '@/utils/datetime' +import { + threadStatesAtom, + threadsAtom, +} from '@/helpers/atoms/Conversation.atom' +import useGetAssistants from '@/hooks/useGetAssistants' +import useSetActiveThread from '@/hooks/useSetActiveThread' +import useGetAllThreads from '@/hooks/useGetAllThreads' +import { useEffect } from 'react' + +export default function ThreadList() { + const threads = useAtomValue(threadsAtom) + const threadStates = useAtomValue(threadStatesAtom) + const { requestCreateNewThread } = useCreateNewThread() + const { assistants } = useGetAssistants() + const { getAllThreads } = useGetAllThreads() + + const { activeThreadId, setActiveThread: onThreadClick } = + useSetActiveThread() + + useEffect(() => { + getAllThreads() + }, []) + + const onCreateConversationClick = async () => { + if (assistants.length === 0) { + alert('No assistant available') + return + } + + requestCreateNewThread(assistants[0]) + } + + return ( +
+
+ +
+ + {threads.length === 0 ? ( +
+ +

No Chat History

+

Get started by creating a new chat

+
+ ) : ( + threads.map((thread, i) => { + const lastMessage = threadStates[thread.id]?.lastMessage ?? '' + return ( +
onThreadClick(thread)} + > +

+ {thread.updated && + displayDate(new Date(thread.updated).getTime())} +

+

{thread.title}

+

{lastMessage}

+ {activeThreadId === thread.id && ( + + )} +
+ ) + }) + )} +
+ ) +} diff --git a/web/screens/Chat/index.tsx b/web/screens/Chat/index.tsx index 633c699cc9..e7dc8d96c4 100644 --- a/web/screens/Chat/index.tsx +++ b/web/screens/Chat/index.tsx @@ -1,6 +1,5 @@ import { Fragment, useContext, useEffect, useRef, useState } from 'react' -import { Model } from '@janhq/core/lib/types' import { Button, Badge, Textarea } from '@janhq/uikit' import { useAtom, useAtomValue } from 'jotai' @@ -19,108 +18,73 @@ import { FeatureToggleContext } from '@/context/FeatureToggle' import { MainViewState } from '@/constants/screens' import { useActiveModel } from '@/hooks/useActiveModel' - -import { useCreateConversation } from '@/hooks/useCreateConversation' -import useDeleteConversation from '@/hooks/useDeleteConversation' +import useDeleteThread from '@/hooks/useDeleteConversation' import { useGetDownloadedModels } from '@/hooks/useGetDownloadedModels' - -import useGetUserConversations from '@/hooks/useGetUserConversations' import { useMainViewState } from '@/hooks/useMainViewState' import useSendChatMessage from '@/hooks/useSendChatMessage' import ChatBody from '@/screens/Chat/ChatBody' -import HistoryList from '@/screens/Chat/HistoryList' +import ThreadList from '@/screens/Chat/ThreadList' import { - currentConversationAtom, - getActiveConvoIdAtom, - userConversationsAtom, + activeThreadAtom, + getActiveThreadIdAtom, + threadsAtom, waitingToSendMessage, } from '@/helpers/atoms/Conversation.atom' -import { currentConvoStateAtom } from '@/helpers/atoms/Conversation.atom' +import { activeThreadStateAtom } from '@/helpers/atoms/Conversation.atom' +import Sidebar from './Sidebar' const ChatScreen = () => { - const currentConvo = useAtomValue(currentConversationAtom) + const currentConvo = useAtomValue(activeThreadAtom) const { downloadedModels } = useGetDownloadedModels() - const { deleteConvo, cleanConvo } = useDeleteConversation() + const { deleteThread, cleanConvo } = useDeleteThread() const { activeModel, stateModel } = useActiveModel() const { setMainViewState } = useMainViewState() const [currentPrompt, setCurrentPrompt] = useAtom(currentPromptAtom) - const currentConvoState = useAtomValue(currentConvoStateAtom) + const currentConvoState = useAtomValue(activeThreadStateAtom) const { sendChatMessage } = useSendChatMessage() const isWaitingForResponse = currentConvoState?.waitingForResponse ?? false const disabled = currentPrompt.trim().length === 0 || isWaitingForResponse - const activeConversationId = useAtomValue(getActiveConvoIdAtom) + + const activeThreadId = useAtomValue(getActiveThreadIdAtom) const [isWaitingToSend, setIsWaitingToSend] = useAtom(waitingToSendMessage) - const { requestCreateConvo } = useCreateConversation() - const { getUserConversations } = useGetUserConversations() - const conversations = useAtomValue(userConversationsAtom) + const conversations = useAtomValue(threadsAtom) const isEnableChat = (currentConvo && activeModel) || conversations.length > 0 + const [isModelAvailable, setIsModelAvailable] = useState( - downloadedModels.some((x) => x.id === currentConvo?.modelId) + true + // downloadedModels.some((x) => x.id === currentConvo?.modelId) ) const textareaRef = useRef(null) - const { startModel } = useActiveModel() const modelRef = useRef(activeModel) useEffect(() => { modelRef.current = activeModel }, [activeModel]) - useEffect(() => { - getUserConversations() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - const handleMessageChange = (e: React.ChangeEvent) => { + const onPromptChange = (e: React.ChangeEvent) => { setCurrentPrompt(e.target.value) } - useEffect(() => { - setIsModelAvailable( - downloadedModels.some((x) => x.id === currentConvo?.modelId) - ) - }, [currentConvo, downloadedModels]) - - const handleSendMessage = async () => { - if (!activeModel || activeModel.id !== currentConvo?.modelId) { - const model = downloadedModels.find((e) => e.id === currentConvo?.modelId) - - // Model is available to start - if (model != null) { - toaster({ - title: 'Message queued.', - description: 'It will be sent once the model is done loading.', - }) - startModel(model.id).then(() => { - setTimeout(() => { - if (modelRef?.current?.id === currentConvo?.modelId) - sendChatMessage() - }, 300) - }) - } - return - } - if (activeConversationId) { - sendChatMessage() - } else { - setIsWaitingToSend(true) - await requestCreateConvo(activeModel as Model) - } - } + // useEffect(() => { + // setIsModelAvailable( + // downloadedModels.some((x) => x.id === currentConvo?.modelId) + // ) + // }, [currentConvo, downloadedModels]) useEffect(() => { - if (isWaitingToSend && activeConversationId) { + if (isWaitingToSend && activeThreadId) { setIsWaitingToSend(false) sendChatMessage() } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [waitingToSendMessage, activeConversationId]) + }, [waitingToSendMessage, activeThreadId]) useEffect(() => { if (textareaRef.current !== null) { @@ -136,11 +100,11 @@ const ChatScreen = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentPrompt]) - const handleKeyDown = async (e: React.KeyboardEvent) => { + const onKeyDown = async (e: React.KeyboardEvent) => { if (e.key === 'Enter') { if (!e.shiftKey) { e.preventDefault() - handleSendMessage() + sendChatMessage() } } } @@ -148,14 +112,14 @@ const ChatScreen = () => { return (
- +
{isEnableChat && currentConvo && (
- {currentConvo?.summary ?? ''} + {currentConvo.title}
{ themes="secondary" className="relative z-10" size="sm" - onClick={() => { + onClick={() => setMainViewState(MainViewState.ExploreModels) - }} + } > Download Model @@ -182,8 +146,8 @@ const ChatScreen = () => { deleteConvo()} - /> + onClick={() => deleteThread()} + />
@@ -225,25 +189,24 @@ const ChatScreen = () => {