diff --git a/core/src/core.ts b/core/src/core.ts index a44a11e956..1c4ca1a0a9 100644 --- a/core/src/core.ts +++ b/core/src/core.ts @@ -12,20 +12,7 @@ const executeOnMain: ( method: string, ...args: any[] ) => Promise = (plugin, method, ...args) => - window.coreAPI?.invokePluginFunc(plugin, method, ...args) ?? - window.electronAPI?.invokePluginFunc(plugin, method, ...args); - -/** - * @deprecated This object is deprecated and should not be used. - * Use individual functions instead. - */ -const invokePluginFunc: ( - plugin: string, - method: string, - ...args: any[] -) => Promise = (plugin, method, ...args) => - window.coreAPI?.invokePluginFunc(plugin, method, ...args) ?? - window.electronAPI?.invokePluginFunc(plugin, method, ...args); + window.coreAPI?.invokePluginFunc(plugin, method, ...args) /** * Downloads a file from a URL and saves it to the local file system. @@ -36,16 +23,7 @@ const invokePluginFunc: ( const downloadFile: (url: string, fileName: string) => Promise = ( url, fileName -) => - window.coreAPI?.downloadFile(url, fileName) ?? - window.electronAPI?.downloadFile(url, fileName); - -/** - * @deprecated This object is deprecated and should not be used. - * Use fs module instead. - */ -const deleteFile: (path: string) => Promise = (path) => - window.coreAPI?.deleteFile(path) ?? window.electronAPI?.deleteFile(path); +) => window.coreAPI?.downloadFile(url, fileName); /** * Aborts the download of a specific file. @@ -66,11 +44,18 @@ const appDataPath: () => Promise = () => window.coreAPI?.appDataPath(); * Gets the user space path. * @returns {Promise} A Promise that resolves with the user space path. */ -const getUserSpace = (): Promise => - window.coreAPI?.getUserSpace() ?? window.electronAPI?.getUserSpace(); +const getUserSpace = (): Promise => window.coreAPI?.getUserSpace(); -/** Register extension point function type definition - * +/** + * Opens the file explorer at a specific path. + * @param {string} path - The path to open in the file explorer. + * @returns {Promise} A promise that resolves when the file explorer is opened. + */ +const openFileExplorer: (path: string) => Promise = (path) => + window.coreAPI?.openFileExplorer(path); + +/** + * Register extension point function type definition */ export type RegisterExtensionPoint = ( extensionName: string, @@ -79,29 +64,14 @@ export type RegisterExtensionPoint = ( priority?: number ) => void; -/** - * @deprecated This object is deprecated and should not be used. - * Use individual functions instead. - */ -export const core = { - invokePluginFunc, - executeOnMain, - downloadFile, - abortDownload, - deleteFile, - appDataPath, - getUserSpace, -}; - /** * Functions exports */ export { - invokePluginFunc, executeOnMain, downloadFile, abortDownload, - deleteFile, appDataPath, getUserSpace, + openFileExplorer, }; diff --git a/core/src/fs.ts b/core/src/fs.ts index 2c94a2ce82..3e65518017 100644 --- a/core/src/fs.ts +++ b/core/src/fs.ts @@ -5,8 +5,7 @@ * @returns {Promise} A Promise that resolves when the file is written successfully. */ const writeFile: (path: string, data: string) => Promise = (path, data) => - window.coreAPI?.writeFile(path, data) ?? - window.electronAPI?.writeFile(path, data); + window.coreAPI?.writeFile(path, data); /** * Checks whether the path is a directory. @@ -14,7 +13,7 @@ const writeFile: (path: string, data: string) => Promise = (path, data) => * @returns {boolean} A boolean indicating whether the path is a directory. */ const isDirectory = (path: string): Promise => - window.coreAPI?.isDirectory(path) ?? window.electronAPI?.isDirectory(path); + window.coreAPI?.isDirectory(path); /** * Reads the contents of a file at the specified path. @@ -22,7 +21,7 @@ const isDirectory = (path: string): Promise => * @returns {Promise} A Promise that resolves with the contents of the file. */ const readFile: (path: string) => Promise = (path) => - window.coreAPI?.readFile(path) ?? window.electronAPI?.readFile(path); + window.coreAPI?.readFile(path); /** * List the directory files @@ -30,7 +29,7 @@ const readFile: (path: string) => Promise = (path) => * @returns {Promise} A Promise that resolves with the contents of the directory. */ const listFiles: (path: string) => Promise = (path) => - window.coreAPI?.listFiles(path) ?? window.electronAPI?.listFiles(path); + window.coreAPI?.listFiles(path); /** * Creates a directory at the specified path. @@ -38,7 +37,7 @@ const listFiles: (path: string) => Promise = (path) => * @returns {Promise} A Promise that resolves when the directory is created successfully. */ const mkdir: (path: string) => Promise = (path) => - window.coreAPI?.mkdir(path) ?? window.electronAPI?.mkdir(path); + window.coreAPI?.mkdir(path); /** * Removes a directory at the specified path. @@ -46,14 +45,30 @@ const mkdir: (path: string) => Promise = (path) => * @returns {Promise} A Promise that resolves when the directory is removed successfully. */ const rmdir: (path: string) => Promise = (path) => - window.coreAPI?.rmdir(path) ?? window.electronAPI?.rmdir(path); + window.coreAPI?.rmdir(path); /** * Deletes a file from the local file system. * @param {string} path - The path of the file to delete. * @returns {Promise} A Promise that resolves when the file is deleted. */ const deleteFile: (path: string) => Promise = (path) => - window.coreAPI?.deleteFile(path) ?? window.electronAPI?.deleteFile(path); + window.coreAPI?.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); + +/** + * Reads a file line by line. + * @param {string} path - The path of the file to read. + * @returns {Promise} A promise that resolves to the lines of the file. + */ +const readLineByLine: (path: string) => Promise = (path) => + window.coreAPI?.readLineByLine(path); export const fs = { isDirectory, @@ -63,4 +78,6 @@ export const fs = { mkdir, rmdir, deleteFile, + appendFile, + readLineByLine, }; diff --git a/core/src/index.ts b/core/src/index.ts index f7afccdb00..8d398f8b5f 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -1,40 +1,26 @@ -/** - * @deprecated This object is deprecated and should not be used. - * Use individual functions instead. - */ -export { core, deleteFile, invokePluginFunc } from "./core"; - /** * Core module exports. * @module */ -export { - downloadFile, - executeOnMain, - appDataPath, - getUserSpace, - abortDownload, -} from "./core"; +export * from "./core"; /** - * Events module exports. + * Events events exports. * @module */ -export { events } from "./events"; +export * from "./events"; /** * Events types exports. * @module */ -export * from "./events"; - export * from "./types/index"; /** * Filesystem module exports. * @module */ -export { fs } from "./fs"; +export * from "./fs"; /** * Plugin base module export. 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..296c6c73af --- /dev/null +++ b/core/src/plugins/assistant.ts @@ -0,0 +1,28 @@ +import { Assistant } from "../index"; +import { JanPlugin } from "../plugin"; + +/** + * Abstract class for assistant plugins. + * @extends JanPlugin + */ +export abstract class AssistantPlugin extends JanPlugin { + /** + * Creates a new assistant. + * @param {Assistant} assistant - The assistant object to be created. + * @returns {Promise} A promise that resolves when the assistant has been created. + */ + abstract createAssistant(assistant: Assistant): Promise; + + /** + * Deletes an existing assistant. + * @param {Assistant} assistant - The assistant object to be deleted. + * @returns {Promise} A promise that resolves when the assistant has been deleted. + */ + abstract deleteAssistant(assistant: Assistant): Promise; + + /** + * Retrieves all existing assistants. + * @returns {Promise} A promise that resolves to an array of all assistants. + */ + abstract getAssistants(): Promise; +} diff --git a/core/src/plugins/conversational.ts b/core/src/plugins/conversational.ts index ebeb773331..dc87fdf9b4 100644 --- a/core/src/plugins/conversational.ts +++ b/core/src/plugins/conversational.ts @@ -1,32 +1,57 @@ -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; + + /** + * Adds a new message to the thread. + * @param {ThreadMessage} message - The message to be added. + * @returns {Promise} A promise that resolves when the message has been added. + */ + abstract addNewMessage(message: ThreadMessage): Promise; + + /** + * Writes an array of messages to a specific thread. + * @param {string} threadId - The ID of the thread to write the messages to. + * @param {ThreadMessage[]} messages - The array of messages to be written. + * @returns {Promise} A promise that resolves when the messages have been written. + */ + abstract writeMessages( + threadId: string, + messages: ThreadMessage[] + ): Promise; + + /** + * Retrieves all messages from a specific thread. + * @param {string} threadId - The ID of the thread to retrieve the messages from. + * @returns {Promise} A promise that resolves to an array of messages from the thread. + */ + abstract getAllMessages(threadId: string): Promise; } diff --git a/core/src/plugins/index.ts b/core/src/plugins/index.ts index 5072819d78..4ca712db3a 100644 --- a/core/src/plugins/index.ts +++ b/core/src/plugins/index.ts @@ -14,7 +14,12 @@ export { InferencePlugin } from "./inference"; */ export { MonitoringPlugin } from "./monitoring"; +/** + * Assistant plugin for managing assistants. + */ +export { AssistantPlugin } from "./assistant"; + /** * Model plugin for managing models. */ -export { ModelPlugin } from "./model"; +export { ModelPlugin } from "./model"; \ No newline at end of file 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..53d3d45651 100644 --- a/core/src/plugins/model.ts +++ b/core/src/plugins/model.ts @@ -18,18 +18,17 @@ export abstract class ModelPlugin extends JanPlugin { /** * Cancels the download of a specific model. - * @param {string} name - The name of the model to cancel the download for. * @param {string} modelId - The ID of the model to cancel the download for. * @returns {Promise} A promise that resolves when the download has been cancelled. */ - abstract cancelModelDownload(name: string, modelId: string): Promise; + abstract cancelModelDownload(modelId: string): Promise; /** * 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..15e83772f9 100644 --- a/core/src/types/index.ts +++ b/core/src/types/index.ts @@ -5,7 +5,6 @@ /** * The role of the author of this message. - * @data_transfer_object */ export enum ChatCompletionRole { System = "system", @@ -30,10 +29,20 @@ 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[]; + + /** Runtime parameters for constructing a chat completion request **/ + parameters?: ModelRuntimeParam; }; /** @@ -57,17 +66,50 @@ export enum MessageStatus { */ export type ThreadMessage = { /** Unique identifier for the message, generated by default using the ULID method. **/ - id?: string; + id: string; + /** Object name **/ + 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; + /** The timestamp indicating when this message was created. Represented in Unix time. **/ + created: number; + /** The timestamp indicating when this message was updated. Represented in Unix time. **/ + updated: number; + /** The additional metadata of this message. **/ + metadata?: Record; +}; + +/** + * The content type of the message. + */ +export enum ContentType { + Text = "text", + Image = "image", +} +/** + * The `ThreadContent` type defines the shape of a message's content object + * @data_transfer_object + */ +export type ThreadContent = { + type: ContentType; + text: ContentValue; +}; + +/** + * The `ContentValue` type defines the shape of a content value object + * @data_transfer_object + */ +export type ContentValue = { + value: string; + annotations: string[]; }; /** @@ -77,60 +119,166 @@ 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 name **/ + object: string; + /** The title of this thread. **/ + title: string; + /** Assistants in this thread. **/ + 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; - - /** - * @deprecated This field is deprecated and should not be used. - * Read from model file instead. - */ - modelId?: string; + updated: number; + /** The additional metadata of this thread. **/ + metadata?: Record; } +/** + * Represents the information about an assistant in a thread. + * @stored + */ +export type ThreadAssistantInfo = { + assistant_id: string; + assistant_name: string; + model: ModelInfo; +}; + +/** + * Represents the information about a model. + * @stored + */ +export type ModelInfo = { + id: string; + settings: ModelSettingParams; + parameters: ModelRuntimeParam; +}; + +/** + * Represents the state of a thread. + * @stored + */ +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; + + /** + * The model settings. + */ + settings: ModelSettingParams; + + /** + * The model runtime parameters. + */ + parameters: ModelRuntimeParam; + + /** + * Metadata of the model. + */ + metadata: ModelMetadata; +} + +/** + * The Model transition states. + */ +export enum ModelState { + ToDownload = "to_download", + Downloading = "downloading", + Ready = "ready", + Running = "running", +} + +/** + * The available model settings. + */ +export type ModelSettingParams = { + ctx_len: number; + ngl: number; + embedding: boolean; + n_parallel: number; +}; + +/** + * The available model runtime parameters. + */ +export type ModelRuntimeParam = { + temperature: number; + token_limit: number; + top_k: number; + top_p: number; + stream: boolean; +}; + +/** + * The metadata of the model. + */ +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 @@ -157,27 +305,37 @@ export interface ModelCatalog { releaseDate: number; /** The tags attached to the model description **/ 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 + * Assistant type defines the shape of an assistant object. + * @stored */ -export type ModelVersion = { - /** The name of this model version.*/ +export type Assistant = { + /** Represents the avatar of the user. */ + avatar: string; + /** Represents the location of the thread. */ + thread_location: string | undefined; + /** Represents the unique identifier of the object. */ + id: string; + /** Represents the object. */ + object: string; + /** Represents the creation timestamp of the object. */ + created_at: number; + /** Represents the name of the object. */ 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; + /** Represents the description of the object. */ + description: string; + /** Represents the model of the object. */ + model: string; + /** Represents the instructions for the object. */ + instructions: string; + /** Represents the tools associated with the object. */ + tools: any; + /** Represents the file identifiers associated with the object. */ + file_ids: string[]; + /** Represents the metadata of the object. */ + 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/package.json b/package.json index 036542c38b..cb670a096d 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "build:web": "yarn workspace jan-web build && cpx \"web/out/**\" \"electron/renderer/\"", "build:electron": "yarn workspace jan build", "build:electron:test": "yarn workspace jan build:test", - "build:plugins": "rimraf ./electron/core/pre-install/*.tgz && concurrently --kill-others-on-fail \"cd ./plugins/conversational-json && npm install && npm run build:publish\" \"cd ./plugins/inference-plugin && npm install && npm run build:publish\" \"cd ./plugins/model-plugin && npm install && npm run build:publish\" \"cd ./plugins/monitoring-plugin && npm install && npm run build:publish\"", + "build:plugins": "rimraf ./electron/core/pre-install/*.tgz && concurrently --kill-others-on-fail \"cd ./plugins/conversational-json && npm install && npm run build:publish\" \"cd ./plugins/inference-plugin && npm install && npm run build:publish\" \"cd ./plugins/model-plugin && npm install && npm run build:publish\" \"cd ./plugins/monitoring-plugin && npm install && npm run build:publish\" \"cd ./plugins/assistant-plugin && npm install && npm run build:publish\"", "build:test": "yarn build:web && yarn workspace jan build:test", "build": "yarn build:web && yarn workspace jan build", "build:publish": "yarn build:web && yarn workspace jan build:publish" 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..500fcb5263 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,145 @@ 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) + } + } + + async writeMessages( + threadId: string, + messages: ThreadMessage[] + ): Promise { + try { + const threadDirPath = join(JSONConversationalPlugin._homeDir, threadId) + const threadMessagePath = join( + threadDirPath, + JSONConversationalPlugin._threadMessagesFileName + ) + await fs.mkdir(threadDirPath) + await fs.writeFile( + threadMessagePath, + messages.map((msg) => JSON.stringify(msg)).join('\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..7ca63e7086 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) } /** @@ -55,10 +57,10 @@ export default class JanModelPlugin implements ModelPlugin { * @param {string} modelId - The ID of the model whose download is to be cancelled. * @returns {Promise} A promise that resolves when the download has been cancelled. */ - async cancelModelDownload(name: string, modelId: string): Promise { - return abortDownload(join(JanModelPlugin._homeDir, name, modelId)).then( + async cancelModelDownload(modelId: string): Promise { + return abortDownload(join(JanModelPlugin._homeDir, modelId, modelId)).then( () => { - fs.deleteFile(join(JanModelPlugin._homeDir, name, modelId)) + fs.rmdir(join(JanModelPlugin._homeDir, modelId)) } ) } @@ -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..42f975aafd --- /dev/null +++ b/web/containers/CardSidebar/index.tsx @@ -0,0 +1,87 @@ +import { ReactNode, useState } from 'react' +import { Fragment } from 'react' + +import { Menu, Transition } from '@headlessui/react' +import { + ChevronDownIcon, + EllipsisVerticalIcon, +} from '@heroicons/react/20/solid' +import { twMerge } from 'tailwind-merge' + +interface Props { + children: ReactNode + title: string + onRevealInFinderClick: (type: string) => void + onViewJsonClick: (type: string) => void +} +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..91d194fbdb --- /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 { Model } from '@janhq/core/lib/types' +import { atom, useSetAtom } from 'jotai' +import { twMerge } from 'tailwind-merge' + +import { getDownloadedModels } from '@/hooks/useGetDownloadedModels' + +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) => ( + + twMerge( + 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..b6a7bacbd9 --- /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/BottomBar/DownloadingState/index.tsx b/web/containers/Layout/BottomBar/DownloadingState/index.tsx index 00056f74c4..7eb28d7723 100644 --- a/web/containers/Layout/BottomBar/DownloadingState/index.tsx +++ b/web/containers/Layout/BottomBar/DownloadingState/index.tsx @@ -83,7 +83,7 @@ export default function DownloadingState() { if (!model) return pluginManager .get(PluginType.Model) - ?.cancelModelDownload(model.name, item.fileName) + ?.cancelModelDownload(item.modelId) } }} > diff --git a/web/containers/Layout/TopBar/CommandSearch/index.tsx b/web/containers/Layout/TopBar/CommandSearch/index.tsx index d5eeff3fdd..31896e81f4 100644 --- a/web/containers/Layout/TopBar/CommandSearch/index.tsx +++ b/web/containers/Layout/TopBar/CommandSearch/index.tsx @@ -11,6 +11,7 @@ import { CommandList, } from '@janhq/uikit' +import { useSetAtom } from 'jotai' import { MessageCircleIcon, SettingsIcon, @@ -27,8 +28,12 @@ import { MainViewState } from '@/constants/screens' import { useMainViewState } from '@/hooks/useMainViewState' +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 +66,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 +123,13 @@ export default function CommandSearch() { + ) } diff --git a/web/containers/ModalCancelDownload/index.tsx b/web/containers/ModalCancelDownload/index.tsx index 2b5538e9bc..07d62fa0ce 100644 --- a/web/containers/ModalCancelDownload/index.tsx +++ b/web/containers/ModalCancelDownload/index.tsx @@ -2,7 +2,7 @@ import { useMemo } from 'react' import { PluginType } from '@janhq/core' import { ModelPlugin } from '@janhq/core/lib/plugins' -import { ModelVersion } from '@janhq/core/lib/types' +import { Model } 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 } @@ -79,7 +79,7 @@ export default function ModalCancelDownload({ if (!model) return pluginManager .get(PluginType.Model) - ?.cancelModelDownload(model.name, downloadState.fileName) + ?.cancelModelDownload(downloadState.modelId) } }} > diff --git a/web/containers/Providers/EventHandler.tsx b/web/containers/Providers/EventHandler.tsx index 2c2ad3bd7c..de4a8c9a4b 100644 --- a/web/containers/Providers/EventHandler.tsx +++ b/web/containers/Providers/EventHandler.tsx @@ -16,12 +16,11 @@ import { useGetDownloadedModels } from '@/hooks/useGetDownloadedModels' import { addNewMessageAtom, - chatMessages, updateMessageAtom, } 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' @@ -35,71 +34,60 @@ export default function EventHandler({ children }: { children: ReactNode }) { const updateConvWaiting = useSetAtom(updateConversationWaitingForResponseAtom) const models = useAtomValue(downloadingModelsAtom) - const messages = useAtomValue(chatMessages) - const conversations = useAtomValue(userConversationsAtom) - const messagesRef = useRef(messages) - const convoRef = useRef(conversations) + const threads = useAtomValue(threadsAtom) + const threadsRef = useRef(threads) useEffect(() => { - messagesRef.current = messages - convoRef.current = conversations - }, [messages, conversations]) + threadsRef.current = threads + }, [threads]) 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) { + 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 = threadsRef.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) } } function handleDownloadUpdate(state: any) { if (!state) return + state.fileName = state.fileName.split('/').pop() ?? '' setDownloadState(state) } diff --git a/web/helpers/atoms/ChatMessage.atom.ts b/web/helpers/atoms/ChatMessage.atom.ts index 3ccf337e8c..0aae397b86 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) } ) @@ -103,11 +92,11 @@ export const cleanConversationMessages = atom(null, (get, set, id: string) => { set(chatMessages, newData) }) -export const deleteMessage = atom(null, (get, set, id: string) => { +export const deleteMessageAtom = 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..a1b66e2a80 100644 --- a/web/hooks/useActiveModel.ts +++ b/web/hooks/useActiveModel.ts @@ -1,10 +1,7 @@ /* 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' @@ -13,12 +10,12 @@ 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 +27,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 +50,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 +95,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..6c01baf117 --- /dev/null +++ b/web/hooks/useCreateNewThread.ts @@ -0,0 +1,92 @@ +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: 'New Thread', + 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..ee38b139e2 100644 --- a/web/hooks/useDeleteConversation.ts +++ b/web/hooks/useDeleteConversation.ts @@ -16,71 +16,65 @@ import { 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 [threads, setThreads] = useAtom(threadsAtom) const setCurrentPrompt = useSetAtom(currentPromptAtom) - const activeConvoId = useAtomValue(getActiveConvoIdAtom) + const activeThreadId = useAtomValue(getActiveThreadIdAtom) + const messages = useAtomValue(getCurrentChatMessagesAtom) - 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) { - const currentConversation = userConversations.filter( - (c) => c.id === activeConvoId - )[0] - cleanMessages(activeConvoId) - if (currentConversation) + const cleanThread = async () => { + if (activeThreadId) { + const thread = threads.filter((c) => c.id === activeThreadId)[0] + cleanMessages(activeThreadId) + if (thread) await pluginManager .get(PluginType.Conversational) - ?.saveConversation({ - ...currentConversation, - id: activeConvoId, - messages: currentMessages.filter( - (e) => e.role === ChatCompletionRole.System - ), - }) + ?.writeMessages( + activeThreadId, + messages.filter((msg) => msg.role === ChatCompletionRole.System) + ) } } - 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 availableThreads = threads.filter((c) => c.id !== activeThreadId) + setThreads(availableThreads) + deleteMessages(activeThreadId) + setCurrentPrompt('') + toaster({ + title: 'Chat successfully deleted.', + description: `Chat with ${activeModel?.name} has been successfully deleted.`, + }) + if (availableThreads.length > 0) { + setActiveConvoId(availableThreads[0].id) + } else { + setActiveConvoId(undefined) } + } catch (err) { + console.error(err) } } return { - cleanConvo, - deleteConvo, + cleanThread, + deleteThread, } } diff --git a/web/hooks/useDeleteModel.ts b/web/hooks/useDeleteModel.ts index c3b03c61ab..696943f4ac 100644 --- a/web/hooks/useDeleteModel.ts +++ b/web/hooks/useDeleteModel.ts @@ -1,5 +1,3 @@ -import { join } from 'path' - import { PluginType } from '@janhq/core' import { ModelPlugin } from '@janhq/core/lib/plugins' import { Model } from '@janhq/core/lib/types' @@ -14,8 +12,9 @@ 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/useDownloadState.ts b/web/hooks/useDownloadState.ts index 3af336dd80..a4a4c9f9bf 100644 --- a/web/hooks/useDownloadState.ts +++ b/web/hooks/useDownloadState.ts @@ -10,7 +10,6 @@ const setDownloadStateAtom = atom(null, (get, set, state: DownloadState) => { console.debug( `current download state for ${state.fileName} is ${JSON.stringify(state)}` ) - state.fileName = state.fileName.replace('models/', '') currentState[state.fileName] = state set(modelDownloadStateAtom, currentState) }) diff --git a/web/hooks/useGetAllThreads.ts b/web/hooks/useGetAllThreads.ts new file mode 100644 index 0000000000..e27b9a0391 --- /dev/null +++ b/web/hooks/useGetAllThreads.ts @@ -0,0 +1,43 @@ +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 as string) ?? '' + 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..0d16add285 --- /dev/null +++ b/web/hooks/useGetAssistants.ts @@ -0,0 +1,35 @@ +import { useEffect, useState } from 'react' + +import { Assistant, PluginType } from '@janhq/core' + +import { AssistantPlugin } from '@janhq/core/lib/plugins' + +import { pluginManager } from '@/plugin/PluginManager' + +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..b83918d297 100644 --- a/web/hooks/useGetDownloadedModels.ts +++ b/web/hooks/useGetDownloadedModels.ts @@ -1,22 +1,17 @@ import { useEffect } from 'react' -const downloadedModelAtom = atom([]) 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) +const downloadedModelsAtom = atom([]) - async function getDownloadedModels(): Promise { - const models = await pluginManager - .get(PluginType.Model) - ?.getDownloadedModels() - return models ?? [] - } +export function useGetDownloadedModels() { + const [downloadedModels, setDownloadedModels] = useAtom(downloadedModelsAtom) useEffect(() => { getDownloadedModels().then((downloadedModels) => { @@ -26,3 +21,11 @@ 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..ff811611cb 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' @@ -13,8 +15,11 @@ import { useAtom, useAtomValue, useSetAtom } from 'jotai' import { ulid } from 'ulid' +import { selectedModelAtom } from '@/containers/DropdownListSidebar' import { currentPromptAtom } from '@/containers/Providers/Jotai' +import { toaster } from '@/containers/Toast' + import { useActiveModel } from './useActiveModel' import { @@ -22,29 +27,30 @@ import { getCurrentChatMessagesAtom, } from '@/helpers/atoms/ChatMessage.atom' import { - currentConversationAtom, - updateConversationAtom, + activeThreadAtom, + updateThreadAtom, updateConversationWaitingForResponseAtom, } from '@/helpers/atoms/Conversation.atom' import { pluginManager } from '@/plugin/PluginManager' 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) + const { startModel } = useActiveModel() - 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 +66,123 @@ 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) { + toaster({ title: '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) + updateThreadTitle(messageRequest) + await pluginManager + .get(PluginType.Conversational) + ?.addNewMessage(threadMessage) + + const modelId = selectedModel?.id ?? activeThread.assistants[0].model.id + if (activeModel?.id !== modelId) { + await startModel(modelId) + } events.emit(EventName.OnNewMessageRequest, messageRequest) - updateConvSummary(messageRequest) } return { diff --git a/web/hooks/useSetActiveThread.ts b/web/hooks/useSetActiveThread.ts new file mode 100644 index 0000000000..36e69a14a1 --- /dev/null +++ b/web/hooks/useSetActiveThread.ts @@ -0,0 +1,40 @@ +import { PluginType, Thread } from '@janhq/core' + +import { ConversationalPlugin } from '@janhq/core/lib/plugins' + +import { useAtomValue, useSetAtom } from 'jotai' + +import { setConvoMessagesAtom } from '@/helpers/atoms/ChatMessage.atom' +import { + getActiveThreadIdAtom, + setActiveThreadIdAtom, +} from '@/helpers/atoms/Conversation.atom' +import { pluginManager } from '@/plugin' + +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..10d0086613 100644 --- a/web/screens/Chat/ChatBody/index.tsx +++ b/web/screens/Chat/ChatBody/index.tsx @@ -1,6 +1,5 @@ import { useAtomValue } from 'jotai' -import ChatInstruction from '../ChatInstruction' import ChatItem from '../ChatItem' import { getCurrentChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom' @@ -8,11 +7,10 @@ import { getCurrentChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom' const ChatBody: React.FC = () => { const messages = useAtomValue(getCurrentChatMessagesAtom) return ( -
+
{messages.map((message) => ( ))} - {messages.length === 0 && }
) } diff --git a/web/screens/Chat/ChatInstruction/index.tsx b/web/screens/Chat/ChatInstruction/index.tsx deleted file mode 100644 index 6e7d3ddb64..0000000000 --- a/web/screens/Chat/ChatInstruction/index.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { useState } from 'react' - -import { - ChatCompletionRole, - EventName, - MessageStatus, - ThreadMessage, - events, -} from '@janhq/core' - -import { Button, Textarea } from '@janhq/uikit' -import { useAtomValue } from 'jotai' - -import { getActiveConvoIdAtom } from '@/helpers/atoms/Conversation.atom' - -const ChatInstruction = () => { - const activeConvoId = useAtomValue(getActiveConvoIdAtom) - const [isSettingInstruction, setIsSettingInstruction] = useState(false) - const [instruction, setInstruction] = useState('') - const setSystemPrompt = (instruction: string) => { - const message: ThreadMessage = { - id: 'system-prompt', - content: instruction, - role: ChatCompletionRole.System, - status: MessageStatus.Ready, - createdAt: new Date().toISOString(), - threadId: activeConvoId, - } - events.emit(EventName.OnNewMessageResponse, message) - events.emit(EventName.OnMessageResponseFinished, message) - } - return ( -
-

(Optional) Give your assistant an initial prompt.

- {!isSettingInstruction && activeConvoId && ( - <> - - - )} - {isSettingInstruction && ( -
-