Merge branch 'main' into docs/label-api-reference

This commit is contained in:
Hieu 2023-11-30 10:36:01 +09:00 committed by GitHub
commit a38901b2c8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
102 changed files with 2368 additions and 1361 deletions

View File

@ -121,22 +121,10 @@ jobs:
env:
VERSION_TAG: ${{ steps.tag.outputs.tag }}
- name: Build uikit
- name: Build app
shell: cmd
run: |
cd uikit
yarn config set network-timeout 300000
yarn install
yarn build
- name: Install yarn dependencies
shell: powershell
run: |
yarn config set network-timeout 300000
yarn build:core
yarn install
$env:NITRO_VERSION = Get-Content .\plugins\inference-plugin\nitro\version.txt; echo $env:NITRO_VERSION
yarn build:plugins
yarn build
make build
- name: Windows Code Sign with AzureSignTool
run: |

View File

@ -15,14 +15,11 @@ endif
# Installs yarn dependencies and builds core and plugins
install-and-build: build-uikit
ifeq ($(OS),Windows_NT)
powershell -Command "yarn config set network-timeout 300000; \
$$env:NITRO_VERSION = Get-Content .\\plugins\\inference-plugin\\nitro\\version.txt; \
Write-Output \"Nitro version: $$env:NITRO_VERSION\"; yarn build:core; yarn install; yarn build:plugins"
else
yarn config set network-timeout 300000
endif
yarn build:core
yarn install
yarn build:plugins
endif
dev: install-and-build
yarn dev
@ -47,8 +44,21 @@ build: install-and-build
clean:
ifeq ($(OS),Windows_NT)
powershell -Command "Get-ChildItem -Path . -Include node_modules, .next, dist -Recurse -Directory | Remove-Item -Recurse -Force"
rmdir /s /q "%USERPROFILE%\AppData\Roaming\jan"
rmdir /s /q "%USERPROFILE%\AppData\Roaming\jan-electron"
rmdir /s /q "%USERPROFILE%\AppData\Local\jan*"
else ifeq ($(shell uname -s),Linux)
find . -name "node_modules" -type d -prune -exec rm -rf '{}' +
find . -name ".next" -type d -exec rm -rf '{}' +
find . -name "dist" -type d -exec rm -rf '{}' +
rm -rf "~/.config/jan"
rm -rf "~/.config/jan-electron"
rm -rf "~/.cache/jan*"
else
find . -name "node_modules" -type d -prune -exec rm -rf '{}' +
find . -name ".next" -type d -exec rm -rf '{}' +
find . -name "dist" -type d -exec rm -rf '{}' +
rm -rf ~/Library/Application\ Support/jan
rm -rf ~/Library/Application\ Support/jan-electron
rm -rf ~/Library/Caches/jan*
endif

View File

@ -12,20 +12,7 @@ const executeOnMain: (
method: string,
...args: any[]
) => Promise<any> = (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<any> = (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,15 @@ const invokePluginFunc: (
const downloadFile: (url: string, fileName: string) => Promise<any> = (
url,
fileName
) =>
window.coreAPI?.downloadFile(url, fileName) ??
window.electronAPI?.downloadFile(url, fileName);
) => window.coreAPI?.downloadFile(url, fileName);
/**
* @deprecated This object is deprecated and should not be used.
* Use fs module instead.
* Aborts the download of a specific file.
* @param {string} fileName - The name of the file whose download is to be aborted.
* @returns {Promise<any>} A promise that resolves when the download has been aborted.
*/
const deleteFile: (path: string) => Promise<any> = (path) =>
window.coreAPI?.deleteFile(path) ?? window.electronAPI?.deleteFile(path);
const abortDownload: (fileName: string) => Promise<any> = (fileName) =>
window.coreAPI?.abortDownload(fileName);
/**
* Retrieves the path to the app data directory using the `coreAPI` object.
@ -58,11 +44,18 @@ const appDataPath: () => Promise<any> = () => window.coreAPI?.appDataPath();
* Gets the user space path.
* @returns {Promise<any>} A Promise that resolves with the user space path.
*/
const getUserSpace = (): Promise<string> =>
window.coreAPI?.getUserSpace() ?? window.electronAPI?.getUserSpace();
const getUserSpace = (): Promise<string> => 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<any>} A promise that resolves when the file explorer is opened.
*/
const openFileExplorer: (path: string) => Promise<any> = (path) =>
window.coreAPI?.openFileExplorer(path);
/**
* Register extension point function type definition
*/
export type RegisterExtensionPoint = (
extensionName: string,
@ -71,27 +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,
deleteFile,
appDataPath,
getUserSpace,
};
/**
* Functions exports
*/
export {
invokePluginFunc,
executeOnMain,
downloadFile,
deleteFile,
abortDownload,
appDataPath,
getUserSpace,
openFileExplorer,
};

View File

@ -5,8 +5,7 @@
* @returns {Promise<any>} A Promise that resolves when the file is written successfully.
*/
const writeFile: (path: string, data: string) => Promise<any> = (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<any> = (path, data) =>
* @returns {boolean} A boolean indicating whether the path is a directory.
*/
const isDirectory = (path: string): Promise<boolean> =>
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<boolean> =>
* @returns {Promise<any>} A Promise that resolves with the contents of the file.
*/
const readFile: (path: string) => Promise<any> = (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<any> = (path) =>
* @returns {Promise<any>} A Promise that resolves with the contents of the directory.
*/
const listFiles: (path: string) => Promise<any> = (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<any> = (path) =>
* @returns {Promise<any>} A Promise that resolves when the directory is created successfully.
*/
const mkdir: (path: string) => Promise<any> = (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<any> = (path) =>
* @returns {Promise<any>} A Promise that resolves when the directory is removed successfully.
*/
const rmdir: (path: string) => Promise<any> = (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<any>} A Promise that resolves when the file is deleted.
*/
const deleteFile: (path: string) => Promise<any> = (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<any> = (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<any>} A promise that resolves to the lines of the file.
*/
const readLineByLine: (path: string) => Promise<any> = (path) =>
window.coreAPI?.readLineByLine(path);
export const fs = {
isDirectory,
@ -63,4 +78,6 @@ export const fs = {
mkdir,
rmdir,
deleteFile,
appendFile,
readLineByLine,
};

View File

@ -1,34 +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 } 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.

View File

@ -4,6 +4,7 @@ export enum PluginType {
Preference = "preference",
SystemMonitoring = "systemMonitoring",
Model = "model",
Assistant = "assistant",
}
export abstract class JanPlugin {

View File

@ -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<void>} A promise that resolves when the assistant has been created.
*/
abstract createAssistant(assistant: Assistant): Promise<void>;
/**
* Deletes an existing assistant.
* @param {Assistant} assistant - The assistant object to be deleted.
* @returns {Promise<void>} A promise that resolves when the assistant has been deleted.
*/
abstract deleteAssistant(assistant: Assistant): Promise<void>;
/**
* Retrieves all existing assistants.
* @returns {Promise<Assistant[]>} A promise that resolves to an array of all assistants.
*/
abstract getAssistants(): Promise<Assistant[]>;
}

View File

@ -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<any[]>} A promise that resolves to an array of conversations.
* @returns {Promise<Thread[]>} A promise that resolves to an array of threads.
*/
abstract getConversations(): Promise<any[]>;
abstract getThreads(): Promise<Thread[]>;
/**
* Saves a conversation.
* Saves a thread.
* @abstract
* @param {Thread} conversation - The conversation to save.
* @returns {Promise<void>} A promise that resolves when the conversation is saved.
* @param {Thread} thread - The thread to save.
* @returns {Promise<void>} A promise that resolves when the thread is saved.
*/
abstract saveConversation(conversation: Thread): Promise<void>;
abstract saveThread(thread: Thread): Promise<void>;
/**
* Deletes a conversation.
* Deletes a thread.
* @abstract
* @param {string} conversationId - The ID of the conversation to delete.
* @returns {Promise<void>} A promise that resolves when the conversation is deleted.
* @param {string} threadId - The ID of the thread to delete.
* @returns {Promise<void>} A promise that resolves when the thread is deleted.
*/
abstract deleteConversation(conversationId: string): Promise<void>;
abstract deleteThread(threadId: string): Promise<void>;
/**
* Adds a new message to the thread.
* @param {ThreadMessage} message - The message to be added.
* @returns {Promise<void>} A promise that resolves when the message has been added.
*/
abstract addNewMessage(message: ThreadMessage): Promise<void>;
/**
* 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<void>} A promise that resolves when the messages have been written.
*/
abstract writeMessages(
threadId: string,
messages: ThreadMessage[]
): Promise<void>;
/**
* Retrieves all messages from a specific thread.
* @param {string} threadId - The ID of the thread to retrieve the messages from.
* @returns {Promise<ThreadMessage[]>} A promise that resolves to an array of messages from the thread.
*/
abstract getAllMessages(threadId: string): Promise<ThreadMessage[]>;
}

View File

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

View File

@ -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<void>;
abstract initModel(modelId: string, settings?: ModelSettingParams): Promise<void>;
/**
* Stops the model for the plugin.

View File

@ -16,12 +16,19 @@ export abstract class ModelPlugin extends JanPlugin {
*/
abstract downloadModel(model: Model): Promise<void>;
/**
* Cancels the download of a specific model.
* @param {string} modelId - The ID of the model to cancel the download for.
* @returns {Promise<void>} A promise that resolves when the download has been cancelled.
*/
abstract cancelModelDownload(modelId: string): Promise<void>;
/**
* 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<void>;
abstract deleteModel(modelId: string): Promise<void>;
/**
* Saves a model.

View File

@ -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<string, unknown>;
};
/**
* 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,61 +119,167 @@ 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<string, unknown>;
}
/**
* 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.*/
id: string;
/** The name of the model.*/
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.*/
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;
/** 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.*/
/**
* The type of the object.
* Default: "model"
*/
object: 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[];
/**
* 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;
/**
* Human-readable name that is used for UI.
*/
name: string;
/**
* 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;
binaries: string[];
maxRamRequired: number;
author: string;
avatarUrl: string;
};
/**
* Model type of the presentation object which will be presented to the user
* @data_transfer_object
@ -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<string, unknown>;
};

View File

@ -1,5 +1,6 @@
---
title: Architecture
slug: /specs
---
:::warning
@ -10,35 +11,28 @@ This page is still under construction, and should be read as a scratchpad
## Overview
- Jan built a modular infrastructure on top of Electron, in order to support extensions and AI functionality.
- Jan is largely built on top of its own modules.
- Jan has a modular architecture and is largely built on top of its own modules.
- Jan uses a local [file-based approach](/specs/file-based) for data persistence.
## Modules
Modules are low level, system services. It is similar to OS kernel modules, in that `modules` provide abstractions to device level, basic functionality like the filesystem, device system, databases, AI inference engines, etc.
## Pluggable Modules
Jan exports modules that mirror OpenAIs, exposing similar APIs and objects:
- Modules are modular, atomic implementations of a single OpenAI-compatible endpoint
- Modules can be swapped out for alternate implementations
- The default `messages` module persists messages in thread-specific `.json`
- `messages-postgresql` uses Postgres for production-grade cloud-native environments
| Jan Module | Description | API Docs |
| ---------- | ------------- | ---------------------------- |
| Chat | Inference | [/chat](/api/chat) |
| Models | Models | [/model](/api/model) |
| Assistants | Apps | [/assistant](/api/assistant) |
| Threads | Conversations | [/thread](/api/thread) |
| Messages | Messages | [/message](/api/message) |
<!-- TODO: link npm modules -->
- Jan currently supports an Electron-based [Desktop UI](https://github.com/janhq/jan) and a C++ inference engine called [Nitro](https://nitro.jan.ai/docs/).
## Extensions
Extensions are feature level services that include both UI and logic implementation.
Jan has an Extensions API inspired by VSCode. In fact, most of Jan's core services are built as extensions.
<!-- TODO[@linh]: add all of @linh's specs here -->
Jan supports the following OpenAI compatible extensions:
| Jan Module | Description | API Docs |
| ---------- | ------------- | --------------------------------------------- |
| Chat | Inference | [/chats](/api-reference/#tag/Chat-Completion) |
| Models | Models | [/models](/api-reference/#tag/Models) |
| Assistants | Apps | [/assistants](/api-reference/#tag/Assistants) |
| Threads | Conversations | [/threads](/api-reference/#tag/Threads) |
| Messages | Messages | [/messages](/api-reference/#tag/Messages) |
<!-- TODO: link npm modules -->
## Modules
Modules are low level, system services. It is similar to OS kernel modules. Modules provide abstractions to basic, device level functionality like working with the filesystem, device system, databases, AI inference engines, etc.
Jan follows the [dependency inversion principle](https://en.wikipedia.org/wiki/Dependency_inversion_principle) such that `modules` expose the interfaces that `extensions` can then implement.

View File

@ -1,5 +1,6 @@
---
title: "Assistants"
slug: /specs/assistants
---
:::caution
@ -14,7 +15,7 @@ In Jan, assistants are `primary` entities with the following capabilities:
- Assistants can use `models`, `tools`, handle and emit `events`, and invoke `custom code`.
- Users can create custom assistants with saved `model` settings and parameters.
- An [OpenAI Assistants API](https://platform.openai.com/docs/api-reference/assistants) compatible endpoint at `localhost:3000/v1/assistants`.
- An [OpenAI Assistants API](https://platform.openai.com/docs/api-reference/assistants) compatible endpoint at `localhost:1337/v1/assistants`.
- Jan ships with a default assistant called "Jan" that lets you use all models.
## Folder Structure
@ -49,6 +50,7 @@ In Jan, assistants are `primary` entities with the following capabilities:
"models": [ // Defaults to "*" all models
{ ...model_0 }
],
"instructions": "Be concise", // A system prompt for the assistant
"events": [], // Defaults to "*"
"metadata": {}, // Defaults to {}
// "tools": [], // Coming soon

View File

@ -1,5 +1,6 @@
---
title: Chats
slug: /specs/chats
---
:::caution
@ -13,7 +14,7 @@ This is currently under development.
In Jan, `chats` are LLM responses in the form of OpenAI compatible `chat completion objects`.
- Models take a list of messages and return a model-generated response as output.
- An [OpenAI Chat API](https://platform.openai.com/docs/api-reference/chat) compatible endpoint at `localhost:3000/v1/chats`.
- An [OpenAI Chat API](https://platform.openai.com/docs/api-reference/chat) compatible endpoint at `localhost:1337/v1/chats`.
## Folder Structure

View File

@ -1,5 +1,6 @@
---
title: "Files"
slug: /specs/files
---
:::warning

View File

@ -0,0 +1,6 @@
---
title: "Fine-tuning"
slug: /specs/finetuning
---
Todo: @hiro

View File

@ -1,5 +1,6 @@
---
title: Messages
slug: /specs/messages
---
:::caution

View File

@ -1,5 +1,6 @@
---
title: Models
slug: /specs/models
---
:::caution
@ -13,7 +14,7 @@ This is currently under development.
In Jan, models are primary entities with the following capabilities:
- Users can import, configure, and run models locally.
- An [OpenAI Model API](https://platform.openai.com/docs/api-reference/models) compatible endpoint at `localhost:3000/v1/models`.
- An [OpenAI Model API](https://platform.openai.com/docs/api-reference/models) compatible endpoint at `localhost:1337/v1/models`.
- Supported model formats: `ggufv3`, and more.
## Folder Structure
@ -64,17 +65,19 @@ Here's a standard example `model.json` for a GGUF model.
"state": enum[null, "downloading", "ready", "starting", "stopping", ...]
"format": "ggufv3", // Defaults to "ggufv3"
"settings": { // Models are initialized with settings
"ctx_len": "2048",
"ngl": "100",
"embedding": "true",
"n_parallel": "4",
"ctx_len": 2048,
"ngl": 100,
"embedding": true,
"n_parallel": 4,
},
"parameters": { // Models are called parameters
"temperature": "0.7",
"token_limit": "2048",
"top_k": "0",
"top_p": "1",
"stream": "true"
"stream": true,
"max_tokens": 2048,
"stop": ["<endofstring>"], // This usually can be left blank, only used with specific need from model author
"frequency_penalty": 0,
"presence_penalty": 0,
"temperature": 0.7,
"top_p": 0.95
},
"metadata": {}, // Defaults to {}
"assets": [ // Defaults to current dir
@ -82,6 +85,10 @@ Here's a standard example `model.json` for a GGUF model.
]
```
The model settings in the example can be found at: [Nitro's model settings](https://nitro.jan.ai/features/load-unload#table-of-parameters)
The model parameters in the example can be found at: [Nitro's model parameters](https://nitro.jan.ai/api-reference#tag/Chat-Completion)
## API Reference
Jan's Model API is compatible with [OpenAI's Models API](https://platform.openai.com/docs/api-reference/models), with additional methods for managing and running models locally.

View File

@ -1,7 +1,8 @@
---
title: Prompts
slug: /specs/prompts
---
- [ ] /prompts folder
- [ ] How to add to prompts
- [ ] Assistants can have suggested Prompts
- [ ] Assistants can have suggested Prompts

View File

@ -1,5 +1,6 @@
---
title: Threads
slug: /specs/threads
---
:::caution

View File

@ -1,4 +0,0 @@
---
title: "Fine-tuning"
---
Todo: @hiro

View File

@ -1,3 +0,0 @@
---
title: Home
---

View File

@ -1,3 +0,0 @@
---
title: Hub
---

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 629 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View File

@ -0,0 +1,17 @@
---
title: Chat
slug: /specs/chat
---
## Overview
A home screen for users to chat with [assistants](/specs/assistants) via conversation [threads](/specs/threads).
![alt text](../img/chat-screen.png)
## User Stories
<!-- Can also be used as a QA Checklist -->
- Users can chat with `Jan` the default assistant
- Users can customize chat settings like model parameters via both the GUI & `thread.json`

View File

@ -0,0 +1,18 @@
---
title: Hub
slug: /specs/hub
---
## Overview
The Hub is like a store for everything, where users can discover and download models, assistants, and more.
![alt text](../img/hub-screen.png)
## User Stories
<!-- Can also be used as a QA Checklist -->
- Users can discover recommended models (Jan ships with a few preconfigured `model.json` files)
- Users can download models suitable for their devices, e.g. compatible with their RAM
- Users can download models via a HuggingFace URL (coming soon)

View File

@ -0,0 +1,32 @@
---
title: Settings
slug: /specs/settings
---
## Overview
A settings page for users to add extensions, configure model settings, change app appearance, add keyboard shortcuts, and a plethora of other personalizations.
![alt text](../img/settings-screen.png)
## User Stories
<!-- Can also be used as a QA Checklist -->
### General Settings
- Users can customize `port` number
- Users can customize `janroot` folder location
### Extensions Settings
- Users can add, delete, and configure extensions
### Model Settings
- Users can configure default model parameters and settings
- Users can delete models
### Appearance
- Users can set color themes and dark/light modes

View File

@ -0,0 +1,17 @@
---
title: System Monitor
slug: /specs/system-monitor
---
## Overview
An activity screen to monitor system health and running models.
![alt text](../img/system-screen.png)
## User Stories
<!-- Can also be used as a QA Checklist -->
- Users can see disk and ram utilization
- Users can start and stop models based on system health

View File

@ -1,5 +0,0 @@
---
title: Settings
---
- [ ] .jan folder in jan root

View File

@ -1,3 +0,0 @@
---
title: System Monitor
---

View File

@ -66,10 +66,10 @@ const sidebars = {
collapsible: true,
collapsed: false,
items: [
"specs/home",
"specs/hub",
"specs/system-monitor",
"specs/settings",
"specs/product/chat",
"specs/product/hub",
"specs/product/system-monitor",
"specs/product/settings",
],
},
@ -79,16 +79,16 @@ const sidebars = {
collapsible: true,
collapsed: false,
items: [
"specs/chats",
"specs/models",
"specs/threads",
"specs/messages",
"specs/assistants",
// "specs/files",
// "specs/jan",
// "specs/fine-tuning",
// "specs/settings",
// "specs/prompts",
"specs/engineering/chats",
"specs/engineering/models",
"specs/engineering/threads",
"specs/engineering/messages",
"specs/engineering/assistants",
// "specs/engineering/files",
// "specs/engineering/jan",
// "specs/engineering/fine-tuning",
// "specs/engineering/settings",
// "specs/engineering/prompts",
],
},
],

View File

@ -29,7 +29,8 @@ export function handleAppIPCs() {
* @param _event - The IPC event object.
*/
ipcMain.handle("openAppDirectory", async (_event) => {
shell.openPath(app.getPath("userData"));
const userSpacePath = join(app.getPath('home'), 'jan')
shell.openPath(userSpacePath);
});
/**

View File

@ -34,22 +34,7 @@ export function handleDownloaderIPCs() {
ipcMain.handle('abortDownload', async (_event, fileName) => {
const rq = DownloadManager.instance.networkRequests[fileName]
DownloadManager.instance.networkRequests[fileName] = undefined
const userDataPath = app.getPath('userData')
const fullPath = join(userDataPath, fileName)
rq?.abort()
let result = 'NULL'
unlink(fullPath, function (err) {
if (err && err.code == 'ENOENT') {
result = `File not exist: ${err}`
} else if (err) {
result = `File delete error: ${err}`
} else {
result = 'File deleted successfully'
}
console.debug(
`Delete file ${fileName} from ${fullPath} result: ${result}`
)
})
})
/**

View File

@ -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<void> => {
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)
}
})
})
}

View File

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

View File

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

View File

@ -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<any>`.
```typescript
import { core } from "@janhq/core";
function onStart(): Promise<any> {
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!

View File

@ -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 <service@jan.ai>",
"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"
]
}

View File

@ -0,0 +1 @@
declare const MODULE: string;

View File

@ -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<void> {
// 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<Assistant[]> {
// 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<void> {
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<void> {
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);
}
}

View File

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

View File

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

View File

@ -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<Thread[]> {
async getThreads(): Promise<Thread[]> {
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<void> {
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<void> {
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<void> {
return fs.rmdir(
join(JSONConversationalPlugin._homeDir, `${conversationId}`)
)
deleteThread(threadId: string): Promise<void> {
return fs.rmdir(join(JSONConversationalPlugin._homeDir, `${threadId}`))
}
async addNewMessage(message: ThreadMessage): Promise<void> {
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<void> {
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<any> {
private async readThread(threadDirName: string): Promise<any> {
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<string[]> {
return fs
.listFiles(JSONConversationalPlugin._homeDir)
.then((files: string[]) => {
return Promise.all(files.filter((file) => file.startsWith('jan-')))
private async getValidThreadDirs(): Promise<string[]> {
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<ThreadMessage[]> {
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 []
}
}
}

View File

@ -0,0 +1,4 @@
@echo off
set /p NITRO_VERSION=<./nitro/version.txt
.\node_modules\.bin\download https://github.com/janhq/nitro/releases/download/v%NITRO_VERSION%/nitro-%NITRO_VERSION%-win-amd64-cuda.zip -e --strip 1 -o ./nitro/win-cuda
.\node_modules\.bin\download https://github.com/janhq/nitro/releases/download/v%NITRO_VERSION%/nitro-%NITRO_VERSION%-win-amd64.zip -e --strip 1 -o ./nitro/win-cpu

View File

@ -1 +1 @@
0.1.11
0.1.17

View File

@ -13,9 +13,9 @@
],
"scripts": {
"build": "tsc -b . && webpack --config webpack.config.js",
"downloadnitro:linux": "NITRO_VERSION=$(cat ./nitro/version.txt) && download https://github.com/janhq/nitro/releases/download/v${NITRO_VERSION}/nitro-${NITRO_VERSION}-linux-amd64.zip -e --strip 1 -o ./nitro/linux-cpu && chmod +x ./nitro/linux-cpu/nitro && chmod +x ./nitro/linux-start.sh && download https://github.com/janhq/nitro/releases/download/v${NITRO_VERSION}/nitro-${NITRO_VERSION}-linux-amd64-cuda.zip -e --strip 1 -o ./nitro/linux-cuda && chmod +x ./nitro/linux-cuda/nitro && chmod +x ./nitro/linux-start.sh",
"downloadnitro:darwin": "NITRO_VERSION=$(cat ./nitro/version.txt) && download https://github.com/janhq/nitro/releases/download/v${NITRO_VERSION}/nitro-${NITRO_VERSION}-mac-arm64.zip -e --strip 1 -o ./nitro/mac-arm64 && chmod +x ./nitro/mac-arm64/nitro && download https://github.com/janhq/nitro/releases/download/v${NITRO_VERSION}/nitro-${NITRO_VERSION}-mac-amd64.zip -e --strip 1 -o ./nitro/mac-x64 && chmod +x ./nitro/mac-x64/nitro",
"downloadnitro:win32": "download https://github.com/janhq/nitro/releases/download/v%NITRO_VERSION%/nitro-%NITRO_VERSION%-win-amd64.zip -e --strip 1 -o ./nitro/win-cpu && download https://github.com/janhq/nitro/releases/download/v%NITRO_VERSION%/nitro-%NITRO_VERSION%-win-amd64-cuda.zip -e --strip 1 -o ./nitro/win-cuda",
"downloadnitro:linux": "NITRO_VERSION=$(cat ./nitro/version.txt) && download https://github.com/janhq/nitro/releases/download/v${NITRO_VERSION}/nitro-${NITRO_VERSION}-linux-amd64.tar.gz -e --strip 1 -o ./nitro/linux-cpu && chmod +x ./nitro/linux-cpu/nitro && chmod +x ./nitro/linux-start.sh && download https://github.com/janhq/nitro/releases/download/v${NITRO_VERSION}/nitro-${NITRO_VERSION}-linux-amd64-cuda.tar.gz -e --strip 1 -o ./nitro/linux-cuda && chmod +x ./nitro/linux-cuda/nitro && chmod +x ./nitro/linux-start.sh",
"downloadnitro:darwin": "NITRO_VERSION=$(cat ./nitro/version.txt) && download https://github.com/janhq/nitro/releases/download/v${NITRO_VERSION}/nitro-${NITRO_VERSION}-mac-arm64.tar.gz -e --strip 1 -o ./nitro/mac-arm64 && chmod +x ./nitro/mac-arm64/nitro && download https://github.com/janhq/nitro/releases/download/v${NITRO_VERSION}/nitro-${NITRO_VERSION}-mac-amd64.tar.gz -e --strip 1 -o ./nitro/mac-x64 && chmod +x ./nitro/mac-x64/nitro",
"downloadnitro:win32": "download.bat",
"downloadnitro": "run-script-os",
"build:publish:darwin": "rimraf *.tgz --glob && npm run build && npm run downloadnitro && ../../.github/scripts/auto-sign.sh && cpx \"nitro/**\" \"dist/nitro\" && npm pack && cpx *.tgz ../../electron/core/pre-install",
"build:publish:win32": "rimraf *.tgz --glob && npm run build && npm run downloadnitro && cpx \"nitro/**\" \"dist/nitro\" && npm pack && cpx *.tgz ../../electron/core/pre-install",

View File

@ -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<string> {
export function requestInference(
recentMessages: any[],
controller?: AbortController
): Observable<string> {
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;

View File

@ -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<void>} A promise that resolves when the model is initialized.
*/
async initModel(modelFileName: string): Promise<void> {
async initModel(
modelId: string,
settings?: ModelSettingParams
): Promise<void> {
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<any>} A promise that resolves with the inference response.
*/
async inferenceRequest(data: MessageRequest): Promise<ThreadMessage> {
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);
},

View File

@ -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<InitModelResponse> {
function initModel(wrapper: any): Promise<InitModelResponse> {
// 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<InitModelResponse> {
// 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<InitModelResponse> {
* 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<Response> {
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<Response> {
// 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<void> {
// 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<void> {
});
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 ?? ""}`);
});

View File

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

View File

@ -1,4 +1,4 @@
import { PluginType, fs, downloadFile } from '@janhq/core'
import { PluginType, fs, downloadFile, abortDownload } from '@janhq/core'
import { ModelPlugin } from '@janhq/core/lib/plugins'
import { Model, ModelCatalog } from '@janhq/core/lib/types'
import { parseToModel } from './helpers/modelParser'
@ -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,25 @@ export default class JanModelPlugin implements ModelPlugin {
*/
async downloadModel(model: Model): Promise<void> {
// 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)
}
/**
* Cancels the download of a specific machine learning model.
* @param {string} modelId - The ID of the model whose download is to be cancelled.
* @returns {Promise<void>} A promise that resolves when the download has been cancelled.
*/
async cancelModelDownload(modelId: string): Promise<void> {
return abortDownload(join(JanModelPlugin._homeDir, modelId, modelId)).then(
() => {
fs.rmdir(join(JanModelPlugin._homeDir, modelId))
}
)
}
/**
@ -55,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<void> {
async deleteModel(modelId: string): Promise<void> {
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)
}
@ -72,11 +85,14 @@ export default class JanModelPlugin implements ModelPlugin {
* @returns A Promise that resolves when the model is saved.
*/
async saveModel(model: Model): Promise<void> {
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)
}
@ -98,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) {

View File

@ -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 (
<div className="flex w-full flex-col">
<div className="flex items-center rounded-lg border border-border">
<button
onClick={() => setShow(!show)}
className="flex w-full flex-1 items-center py-2"
>
<ChevronDownIcon
className={`h-5 w-5 flex-none text-gray-400 ${
show && 'rotate-180'
}`}
/>
<span className="text-xs uppercase">{title}</span>
</button>
<Menu as="div" className="relative flex-none">
<Menu.Button className="-m-2.5 block p-2.5 text-gray-500 hover:text-gray-900">
<span className="sr-only">Open options</span>
<EllipsisVerticalIcon className="h-5 w-5" aria-hidden="true" />
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute right-0 z-10 mt-2 w-32 origin-top-right rounded-md bg-white py-2 shadow-lg ring-1 ring-gray-900/5 focus:outline-none">
<Menu.Item>
{({ active }) => (
<a
onClick={() => onRevealInFinderClick(title)}
className={twMerge(
active ? 'bg-gray-50' : '',
'block cursor-pointer px-3 py-1 text-xs leading-6 text-gray-900'
)}
>
Reveal in finder
</a>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<a
onClick={() => onViewJsonClick(title)}
className={twMerge(
active ? 'bg-gray-50' : '',
'block cursor-pointer px-3 py-1 text-xs leading-6 text-gray-900'
)}
>
View a JSON
</a>
)}
</Menu.Item>
</Menu.Items>
</Transition>
</Menu>
</div>
{show && <div className="flex flex-col gap-2 p-2">{children}</div>}
</div>
)
}

View File

@ -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<Model | undefined>(undefined)
export default function DropdownListSidebar() {
const [downloadedModels, setDownloadedModels] = useState<Model[]>([])
const [selected, setSelected] = useState<Model | undefined>()
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 (
<Listbox
value={selected}
onChange={(model) => {
setSelected(model)
setSelectedModel(model)
}}
>
{({ open }) => (
<>
<div className="relative mt-2">
<Listbox.Button className="relative w-full cursor-default rounded-md bg-white py-1.5 pl-3 pr-10 text-left text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6">
<span className="block truncate">{selected.name}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon
className="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</Listbox.Button>
<Transition
show={open}
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
{downloadedModels.map((model) => (
<Listbox.Option
key={model.id}
className={({ active }) =>
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 }) => (
<>
<span
className={twMerge(
selected ? 'font-semibold' : 'font-normal',
'block truncate'
)}
>
{model.name}
</span>
{selected ? (
<span
className={twMerge(
active ? 'text-white' : 'text-indigo-600',
'absolute inset-y-0 right-0 flex items-center pr-4'
)}
>
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)
}

View File

@ -0,0 +1,20 @@
type Props = {
title: string
description?: string
}
export default function ItemCardSidebar({ description, title }: Props) {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span>{title}</span>
</div>
<input
value={description}
type="text"
className="block w-full rounded-md border-0 px-1 py-1.5 text-white shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
placeholder=""
/>
</div>
)
}

View File

@ -1,33 +0,0 @@
import React from 'react'
type Props = {
imageUrl: string
className?: string
alt?: string
width?: number
height?: number
}
const JanImage: React.FC<Props> = ({
imageUrl,
className = '',
alt = '',
width,
height,
}) => {
const [attempt, setAttempt] = React.useState(0)
return (
<img
width={width}
height={height}
src={imageUrl}
alt={alt}
className={className}
key={attempt}
onError={() => setAttempt(attempt + 1)}
/>
)
}
export default JanImage

View File

@ -1,5 +1,7 @@
import { Fragment } from 'react'
import { PluginType } from '@janhq/core'
import { ModelPlugin } from '@janhq/core/lib/plugins'
import {
Progress,
Modal,
@ -10,12 +12,18 @@ import {
ModalTrigger,
} from '@janhq/uikit'
import { useAtomValue } from 'jotai'
import { useDownloadState } from '@/hooks/useDownloadState'
import { formatDownloadPercentage } from '@/utils/converter'
import { downloadingModelsAtom } from '@/helpers/atoms/Model.atom'
import { pluginManager } from '@/plugin'
export default function DownloadingState() {
const { downloadStates } = useDownloadState()
const models = useAtomValue(downloadingModelsAtom)
const totalCurrentProgress = downloadStates
.map((a) => a.size.transferred + a.size.transferred)
@ -67,11 +75,17 @@ export default function DownloadingState() {
<Button
themes="outline"
size="sm"
onClick={() =>
window.coreAPI?.abortDownload(
`models/${item?.fileName}`
)
}
onClick={() => {
if (item?.fileName) {
const model = models.find(
(e) => e.id === item?.fileName
)
if (!model) return
pluginManager
.get<ModelPlugin>(PluginType.Model)
?.cancelModelDownload(item.modelId)
}
}}
>
Cancel
</Button>

View File

@ -20,15 +20,12 @@ import { twMerge } from 'tailwind-merge'
import LogoMark from '@/containers/Brand/Logo/Mark'
import { FeatureToggleContext } from '@/context/FeatureToggle'
import { MainViewState } from '@/constants/screens'
import { useMainViewState } from '@/hooks/useMainViewState'
export default function RibbonNav() {
const { mainViewState, setMainViewState } = useMainViewState()
const { experimentalFeatureEnabed } = useContext(FeatureToggleContext)
const onMenuClick = (state: MainViewState) => {
if (mainViewState === state) return
@ -49,8 +46,6 @@ export default function RibbonNav() {
]
const secondaryMenus = [
// Add menu if experimental feature
...(experimentalFeatureEnabed ? [] : []),
{
name: 'Explore Models',
icon: <CpuIcon size={20} className="flex-shrink-0" />,

View File

@ -11,6 +11,7 @@ import {
CommandList,
} from '@janhq/uikit'
import { useSetAtom } from 'jotai'
import {
MessageCircleIcon,
SettingsIcon,
@ -27,9 +28,12 @@ import { MainViewState } from '@/constants/screens'
import { useMainViewState } from '@/hooks/useMainViewState'
import { showRightSideBarAtom } from '@/screens/Chat/Sidebar'
export default function CommandSearch() {
const { experimentalFeatureEnabed } = useContext(FeatureToggleContext)
const { setMainViewState } = useMainViewState()
const [open, setOpen] = useState(false)
const setShowRightSideBar = useSetAtom(showRightSideBarAtom)
const menus = [
{
@ -44,8 +48,6 @@ export default function CommandSearch() {
),
state: MainViewState.Chat,
},
// Added experimental feature here
...(experimentalFeatureEnabed ? [] : []),
{
name: 'Explore Models',
icon: <CpuIcon size={16} className="mr-3 text-muted-foreground" />,
@ -64,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)) {
@ -123,6 +123,13 @@ export default function CommandSearch() {
</CommandGroup>
</CommandList>
</CommandModal>
<Button
themes="outline"
className="unset-drag justify-start text-left text-xs font-normal text-muted-foreground focus:ring-0"
onClick={() => setShowRightSideBar((show) => !show)}
>
Toggle right
</Button>
</Fragment>
)
}

View File

@ -1,6 +1,8 @@
import { useMemo } from 'react'
import { ModelVersion } from '@janhq/core/lib/types'
import { PluginType } from '@janhq/core'
import { ModelPlugin } from '@janhq/core/lib/plugins'
import { Model } from '@janhq/core/lib/types'
import {
Modal,
@ -19,8 +21,11 @@ import { useDownloadState } from '@/hooks/useDownloadState'
import { formatDownloadPercentage } from '@/utils/converter'
import { downloadingModelsAtom } from '@/helpers/atoms/Model.atom'
import { pluginManager } from '@/plugin'
type Props = {
suitableModel: ModelVersion
suitableModel: Model
isFromList?: boolean
}
@ -34,6 +39,7 @@ export default function ModalCancelDownload({
// eslint-disable-next-line react-hooks/exhaustive-deps
[suitableModel.name]
)
const models = useAtomValue(downloadingModelsAtom)
const downloadState = useAtomValue(downloadAtom)
return (
@ -66,10 +72,15 @@ export default function ModalCancelDownload({
<Button
themes="danger"
onClick={() => {
if (downloadState?.fileName)
window.coreAPI?.abortDownload(
`models/${downloadState?.fileName}`
if (downloadState?.fileName) {
const model = models.find(
(e) => e.id === downloadState?.fileName
)
if (!model) return
pluginManager
.get<ModelPlugin>(PluginType.Model)
?.cancelModelDownload(downloadState.modelId)
}
}}
>
Yes

View File

@ -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)
}
}
async function handleMessageResponseUpdate(messageResponse: ThreadMessage) {
if (
messageResponse.threadId &&
messageResponse.id &&
messageResponse.content
) {
updateMessage(
messageResponse.id,
messageResponse.threadId,
messageResponse.content,
MessageStatus.Pending
)
}
addNewMessage(message)
}
async function handleMessageResponseFinished(messageResponse: ThreadMessage) {
if (!messageResponse.threadId || !convoRef.current) return
updateConvWaiting(messageResponse.threadId, false)
async function handleMessageResponseUpdate(message: ThreadMessage) {
updateMessage(
message.id,
message.thread_id,
message.content,
MessageStatus.Pending
)
}
if (
messageResponse.threadId &&
messageResponse.id &&
messageResponse.content
) {
async function handleMessageResponseFinished(message: ThreadMessage) {
updateConvWaiting(message.thread_id, false)
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<ConversationalPlugin>(PluginType.Conversational)
?.saveConversation({
?.saveThread({
...thread,
id: thread.id ?? '',
messages: messagesRef.current[thread.id] ?? [],
metadata,
})
pluginManager
.get<ConversationalPlugin>(PluginType.Conversational)
?.addNewMessage(message)
}
}
function handleDownloadUpdate(state: any) {
if (!state) return
state.fileName = state.fileName.split('/').pop() ?? ''
setDownloadState(state)
}

View File

@ -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<Record<string, ThreadMessage[]>>({})
@ -17,33 +20,19 @@ export const chatMessages = atom<Record<string, ThreadMessage[]>>({})
* Return the chat messages for the current active conversation
*/
export const getCurrentChatMessagesAtom = atom<ThreadMessage[]>((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<string, ThreadMessage[]> = {
...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<string, ThreadMessage[]> = {
...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<string, ThreadMessage[]> = {
...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<string, ThreadMessage[]> = {
...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<string, ThreadMessage[]> = {
...get(chatMessages),
}
newData[currentConvoId] = currentMessages
set(chatMessages, newData)
}
)

View File

@ -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<string | undefined>(undefined)
const activeThreadIdAtom = atom<string | undefined>(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<boolean | undefined>(undefined)
/**
* Stores all conversation states for the current user
* Stores all thread states for the current user
*/
export const conversationStatesAtom = atom<Record<string, ThreadState>>({})
export const currentConvoStateAtom = atom<ThreadState | undefined>((get) => {
const activeConvoId = get(activeConversationIdAtom)
export const threadStatesAtom = atom<Record<string, ThreadState>>({})
export const activeThreadStateAtom = atom<ThreadState | undefined>((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<Thread[]>([])
export const currentConversationAtom = atom<Thread | undefined>((get) =>
get(userConversationsAtom).find((c) => c.id === get(getActiveConvoIdAtom))
export const threadsAtom = atom<Thread[]>([])
export const activeThreadAtom = atom<Thread | undefined>((get) =>
get(threadsAtom).find((c) => c.id === get(getActiveThreadIdAtom))
)

View File

@ -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<Model | undefined>(undefined)
export const activeAssistantModelAtom = atom<Model | undefined>(undefined)
export const downloadingModelsAtom = atom<Model[]>([])

View File

@ -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<Model | undefined>(undefined)
const activeModelAtom = atom<Model | undefined>(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()
@ -27,9 +24,10 @@ export function useActiveModel() {
(activeModel && activeModel.id === modelId) ||
(stateModel.model === modelId && stateModel.loading)
) {
console.debug(`Model ${modelId} is already init. Ignore..`)
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)
@ -64,13 +61,13 @@ export function useActiveModel() {
}))
} else {
console.debug(
`Init model ${modelId} successfully!, take ${
`Model ${modelId} successfully initialized! Took ${
Date.now() - currentTime
}ms`
)
setActiveModel(model)
toaster({
title: 'Success start a Model',
title: 'Success!',
description: `Model ${modelId} has been started.`,
})
setStateModel(() => ({
@ -89,7 +86,7 @@ export function useActiveModel() {
setActiveModel(undefined)
setStateModel({ state: 'start', loading: false, model: '' })
toaster({
title: 'Success stop a Model',
title: 'Success!',
description: `Model ${modelId} has been stopped.`,
})
}, 500)
@ -98,8 +95,11 @@ export function useActiveModel() {
return { activeModel, startModel, stopModel, stateModel }
}
const initModel = async (modelId: string): Promise<any> => {
const initModel = async (
modelId: string,
settings?: ModelSettingParams
): Promise<any> => {
return pluginManager
.get<InferencePlugin>(PluginType.Inference)
?.initModel(modelId)
?.initModel(modelId, settings)
}

View File

@ -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<ConversationalPlugin>(PluginType.Conversational)
?.saveConversation(mappedConvo)
setUserConversations([mappedConvo, ...userConversations])
setActiveConvoId(mappedConvo.id)
}
return {
requestCreateConvo,
}
}

View File

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

View File

@ -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<ConversationalPlugin>(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<ConversationalPlugin>(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<ConversationalPlugin>(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,
}
}

View File

@ -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<ModelPlugin>(PluginType.Model)?.deleteModel(path)
await pluginManager
.get<ModelPlugin>(PluginType.Model)
?.deleteModel(model.id)
// reload models
setDownloadedModels(downloadedModels.filter((e) => e.id !== model.id))

View File

@ -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<ModelPlugin>(PluginType.Model)
?.downloadModel(assistantModel)
setDownloadingModels([...downloadingModels, model])
await pluginManager.get<ModelPlugin>(PluginType.Model)?.downloadModel(model)
}
return {

View File

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

View File

@ -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<ConversationalPlugin>(PluginType.Conversational)
?.getThreads()
const threadStates: Record<string, ThreadState> = {}
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

View File

@ -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<Assistant[]> => {
return (
pluginManager.get<AssistantPlugin>(PluginType.Assistant)?.getAssistants() ??
[]
)
}
/**
* Hooks for get assistants
*
* @returns assistants
*/
export default function useGetAssistants() {
const [assistants, setAssistants] = useState<Assistant[]>([])
useEffect(() => {
getAssistants()
.then((data) => {
setAssistants(data)
})
.catch((err) => {
console.error(err)
})
}, [])
return { assistants }
}

View File

@ -1,22 +1,17 @@
import { useEffect } from 'react'
const downloadedModelAtom = atom<Model[]>([])
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<Model[]>([])
async function getDownloadedModels(): Promise<Model[]> {
const models = await pluginManager
.get<ModelPlugin>(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<Model[]> {
const models = await pluginManager
.get<ModelPlugin>(PluginType.Model)
?.getDownloadedModels()
return models ?? []
}

View File

@ -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<InputType>('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'

View File

@ -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<ModelVersion | undefined>()
const [suitableModel, setSuitableModel] = useState<Model | undefined>()
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
}
}

View File

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

View File

@ -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<ConversationalPlugin>(PluginType.Conversational)
?.getConversations()
const convoStates: Record<string, ThreadState> = {}
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

View File

@ -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,34 +27,35 @@ 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,
content:
'summary this conversation in less than 5 words, the response should just include the summary',
'Summarize this conversation in less than 5 words, the response should just include the summary',
}
// Request convo summary
setTimeout(async () => {
@ -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<ConversationalPlugin>(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<ConversationalPlugin>(PluginType.Conversational)
?.saveThread(updatedThread)
}
updateConvWaiting(activeThread.id, true)
const prompt = currentPrompt.trim()
setCurrentPrompt('')
const messages: ChatCompletionMessage[] = currentMessages
.map<ChatCompletionMessage>((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)
addNewMessage(threadMessage)
updateThreadTitle(messageRequest)
await pluginManager
.get<ConversationalPlugin>(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 {

View File

@ -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<ConversationalPlugin>(PluginType.Conversational)
?.getAllMessages(thread.id)
setThreadMessage(thread.id, messages ?? [])
setActiveThreadId(thread.id)
}
return { activeThreadId, setActiveThread }
}

View File

@ -1,25 +1,16 @@
import { useContext } from 'react'
import { useAtomValue } from 'jotai'
import { FeatureToggleContext } from '@/context/FeatureToggle'
import ChatInstruction from '../ChatInstruction'
import ChatItem from '../ChatItem'
import { getCurrentChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom'
const ChatBody: React.FC = () => {
const messages = useAtomValue(getCurrentChatMessagesAtom)
const { experimentalFeatureEnabed } = useContext(FeatureToggleContext)
return (
<div className="flex h-full w-full flex-col-reverse overflow-y-auto">
<div className="flex h-full w-full flex-col overflow-y-auto">
{messages.map((message) => (
<ChatItem {...message} key={message.id} />
))}
{experimentalFeatureEnabed && messages.length === 0 && (
<ChatInstruction />
)}
</div>
)
}

View File

@ -1,70 +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 (
<div className="mx-auto mb-20 flex flex-col space-y-2">
{!isSettingInstruction && activeConvoId && (
<>
<p>
What does this Assistant do? How does it behave? What should it
avoid doing?
</p>
<Button
themes={'outline'}
className="w-32"
onClick={() => setIsSettingInstruction(true)}
>
Give Instruction
</Button>
</>
)}
{isSettingInstruction && (
<div className="space-y-4">
<Textarea
placeholder={`Enter your instructions`}
onChange={(e) => {
setInstruction(e.target.value)
}}
className="h-24"
/>
<Button
themes={'outline'}
className="w-32"
onClick={() => setSystemPrompt(instruction)}
>
Set Instruction
</Button>
</div>
)}
</div>
)
}
export default ChatInstruction

View File

@ -1,111 +0,0 @@
import { useEffect } from 'react'
import { Thread, Model } from '@janhq/core'
import { Button } from '@janhq/uikit'
import { motion as m } from 'framer-motion'
import { useAtomValue, useSetAtom } from 'jotai'
import { GalleryHorizontalEndIcon } from 'lucide-react'
import { twMerge } from 'tailwind-merge'
import { useActiveModel } from '@/hooks/useActiveModel'
import { useCreateConversation } from '@/hooks/useCreateConversation'
import useGetUserConversations from '@/hooks/useGetUserConversations'
import { displayDate } from '@/utils/datetime'
import {
conversationStatesAtom,
getActiveConvoIdAtom,
setActiveConvoIdAtom,
userConversationsAtom,
} from '@/helpers/atoms/Conversation.atom'
export default function HistoryList() {
const conversations = useAtomValue(userConversationsAtom)
const threadStates = useAtomValue(conversationStatesAtom)
const { getUserConversations } = useGetUserConversations()
const { activeModel } = useActiveModel()
const { requestCreateConvo } = useCreateConversation()
const activeConvoId = useAtomValue(getActiveConvoIdAtom)
const setActiveConvoId = useSetAtom(setActiveConvoIdAtom)
useEffect(() => {
getUserConversations()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleClickConversation = () => {
if (activeModel) requestCreateConvo(activeModel as Model)
return
}
const handleActiveModel = async (convo: Thread) => {
if (convo.modelId == null) {
console.debug('modelId is undefined')
return
}
if (activeConvoId !== convo.id) {
setActiveConvoId(convo.id)
}
}
return (
<div>
<div className="sticky top-0 z-20 flex flex-col border-b border-border bg-background px-4 py-3">
<Button
size="sm"
themes="secondary"
onClick={handleClickConversation}
disabled={!activeModel}
>
Create New Chat
</Button>
</div>
{conversations.length === 0 ? (
<div className="px-4 py-8 text-center">
<GalleryHorizontalEndIcon
size={26}
className="mx-auto mb-3 text-muted-foreground"
/>
<h2 className="font-semibold">No Chat History</h2>
<p className="mt-1 text-xs">Get started by creating a new chat</p>
</div>
) : (
conversations.map((convo, i) => {
const lastMessage = threadStates[convo.id]?.lastMessage
return (
<div
key={i}
className={twMerge(
'relative flex cursor-pointer flex-col border-b border-border px-4 py-2 hover:bg-secondary/20',
activeConvoId === convo.id && 'bg-secondary-10'
)}
onClick={() => handleActiveModel(convo as Thread)}
>
<p className="mb-1 line-clamp-1 text-xs leading-5">
{convo.updatedAt &&
displayDate(new Date(convo.updatedAt).getTime())}
</p>
<h2 className="line-clamp-1">{convo.summary}</h2>
<p className="mt-1 line-clamp-2 text-xs">
{/* TODO: Check latest message update */}
{lastMessage && lastMessage.length > 0
? lastMessage
: 'No new message'}
</p>
{activeModel && activeConvoId === convo.id && (
<m.div
className="absolute right-0 top-0 h-full w-1 bg-primary/50"
layoutId="active-convo"
/>
)}
</div>
)
})
)}
</div>
)
}

View File

@ -1,3 +1,5 @@
import { useMemo } from 'react'
import {
ChatCompletionRole,
ChatCompletionMessage,
@ -9,22 +11,33 @@ import {
events,
} from '@janhq/core'
import { ConversationalPlugin, InferencePlugin } from '@janhq/core/lib/plugins'
import { useAtomValue, useSetAtom } from 'jotai'
import { atom, useAtomValue, useSetAtom } from 'jotai'
import { RefreshCcw, ClipboardCopy, Trash2Icon, StopCircle } from 'lucide-react'
import { twMerge } from 'tailwind-merge'
import { toaster } from '@/containers/Toast'
import {
deleteMessage,
deleteMessageAtom,
getCurrentChatMessagesAtom,
} from '@/helpers/atoms/ChatMessage.atom'
import { currentConversationAtom } from '@/helpers/atoms/Conversation.atom'
import {
activeThreadAtom,
threadStatesAtom,
} from '@/helpers/atoms/Conversation.atom'
import { pluginManager } from '@/plugin'
const MessageToolbar = ({ message }: { message: ThreadMessage }) => {
const deleteAMessage = useSetAtom(deleteMessage)
const thread = useAtomValue(currentConversationAtom)
const deleteMessage = useSetAtom(deleteMessageAtom)
const thread = useAtomValue(activeThreadAtom)
const messages = useAtomValue(getCurrentChatMessagesAtom)
const threadStateAtom = useMemo(
() => atom((get) => get(threadStatesAtom)[thread?.id ?? '']),
[thread?.id]
)
const threadState = useAtomValue(threadStateAtom)
const stopInference = async () => {
await pluginManager
.get<InferencePlugin>(PluginType.Inference)
@ -33,8 +46,14 @@ const MessageToolbar = ({ message }: { message: ThreadMessage }) => {
events.emit(EventName.OnMessageResponseFinished, message)
}, 300)
}
return (
<div className="flex flex-row items-center">
<div
className={twMerge(
'flex-row items-center',
threadState.waitingForResponse ? 'hidden' : 'flex'
)}
>
<div className="flex overflow-hidden rounded-md border border-border bg-background/20">
{message.status === MessageStatus.Pending && (
<div
@ -45,25 +64,20 @@ const MessageToolbar = ({ message }: { message: ThreadMessage }) => {
</div>
)}
{message.status !== MessageStatus.Pending &&
message.id === messages[0]?.id && (
message.id === messages[messages.length - 1]?.id && (
<div
className="cursor-pointer border-r border-border px-2 py-2 hover:bg-background/80"
onClick={() => {
const messageRequest: MessageRequest = {
id: message.id ?? '',
messages: messages
.slice(1, messages.length)
.reverse()
.map((e) => {
return {
content: e.content,
role: e.role,
} as ChatCompletionMessage
}),
threadId: message.threadId ?? '',
}
if (message.role === ChatCompletionRole.Assistant) {
deleteAMessage(message.id ?? '')
messages: messages.slice(0, -1).map((e) => {
const msg: ChatCompletionMessage = {
role: e.role,
content: e.content[0].text.value,
}
return msg
}),
threadId: message.thread_id ?? '',
}
events.emit(EventName.OnNewMessageRequest, messageRequest)
}}
@ -74,7 +88,7 @@ const MessageToolbar = ({ message }: { message: ThreadMessage }) => {
<div
className="cursor-pointer border-r border-border px-2 py-2 hover:bg-background/80"
onClick={() => {
navigator.clipboard.writeText(message.content ?? '')
navigator.clipboard.writeText(message.content[0]?.text?.value ?? '')
toaster({
title: 'Copied to clipboard',
})
@ -85,14 +99,14 @@ const MessageToolbar = ({ message }: { message: ThreadMessage }) => {
<div
className="cursor-pointer px-2 py-2 hover:bg-background/80"
onClick={async () => {
deleteAMessage(message.id ?? '')
deleteMessage(message.id ?? '')
if (thread)
await pluginManager
.get<ConversationalPlugin>(PluginType.Conversational)
?.saveConversation({
...thread,
messages: messages.filter((e) => e.id !== message.id),
})
?.writeMessages(
thread.id,
messages.filter((msg) => msg.id !== message.id)
)
}}
>
<Trash2Icon size={14} />

View File

@ -0,0 +1,122 @@
import { join } from 'path'
import { getUserSpace, openFileExplorer } from '@janhq/core'
import { atom, useAtomValue } from 'jotai'
import CardSidebar from '@/containers/CardSidebar'
import DropdownListSidebar, {
selectedModelAtom,
} from '@/containers/DropdownListSidebar'
import ItemCardSidebar from '@/containers/ItemCardSidebar'
import { activeThreadAtom } from '@/helpers/atoms/Conversation.atom'
export const showRightSideBarAtom = atom<boolean>(false)
export default function Sidebar() {
const showing = useAtomValue(showRightSideBarAtom)
const activeThread = useAtomValue(activeThreadAtom)
const selectedModel = useAtomValue(selectedModelAtom)
const onReviewInFinderClick = async (type: string) => {
if (!activeThread) return
if (!activeThread.isFinishInit) {
alert('Thread is not ready')
return
}
const userSpace = await getUserSpace()
let filePath = undefined
const assistantId = activeThread.assistants[0]?.assistant_id
switch (type) {
case 'Thread':
filePath = join('threads', activeThread.id)
break
case 'Model':
if (!selectedModel) return
filePath = join('models', selectedModel.id)
break
case 'Assistant':
if (!assistantId) return
filePath = join('assistants', assistantId)
break
default:
break
}
if (!filePath) return
const fullPath = join(userSpace, filePath)
console.log(fullPath)
openFileExplorer(fullPath)
}
const onViewJsonClick = async (type: string) => {
if (!activeThread) return
if (!activeThread.isFinishInit) {
alert('Thread is not ready')
return
}
const userSpace = await getUserSpace()
let filePath = undefined
const assistantId = activeThread.assistants[0]?.assistant_id
switch (type) {
case 'Thread':
filePath = join('threads', activeThread.id, 'thread.json')
break
case 'Model':
if (!selectedModel) return
filePath = join('models', selectedModel.id, 'model.json')
break
case 'Assistant':
if (!assistantId) return
filePath = join('assistants', assistantId, 'assistant.json')
break
default:
break
}
if (!filePath) return
const fullPath = join(userSpace, filePath)
console.log(fullPath)
openFileExplorer(fullPath)
}
return (
<div
className={`h-full overflow-x-hidden border-l border-border duration-300 ease-linear ${
showing ? 'w-80' : 'w-0'
}`}
>
<div className="flex flex-col gap-1 p-2">
<CardSidebar
title="Thread"
onRevealInFinderClick={onReviewInFinderClick}
onViewJsonClick={onViewJsonClick}
>
<ItemCardSidebar description={activeThread?.id} title="Thread ID" />
<ItemCardSidebar title="Thread title" />
</CardSidebar>
<CardSidebar
title="Assistant"
onRevealInFinderClick={onReviewInFinderClick}
onViewJsonClick={onViewJsonClick}
>
<ItemCardSidebar
description={activeThread?.assistants[0].assistant_name ?? ''}
title="Assistant"
/>
</CardSidebar>
<CardSidebar
title="Model"
onRevealInFinderClick={onReviewInFinderClick}
onViewJsonClick={onViewJsonClick}
>
<DropdownListSidebar />
</CardSidebar>
</div>
</div>
)
}

View File

@ -16,8 +16,6 @@ import LogoMark from '@/containers/Brand/Logo/Mark'
import BubbleLoader from '@/containers/Loader/Bubble'
import { FeatureToggleContext } from '@/context/FeatureToggle'
import { displayDate } from '@/utils/datetime'
import MessageToolbar from '../MessageToolbar'
@ -50,8 +48,12 @@ const marked = new Marked(
)
const SimpleTextMessage: React.FC<ThreadMessage> = (props) => {
const { experimentalFeatureEnabed } = useContext(FeatureToggleContext)
const parsedText = marked.parse(props.content ?? '')
let text = ''
if (props.content && props.content.length > 0) {
text = props.content[0]?.text?.value ?? ''
}
const parsedText = marked.parse(text)
const isUser = props.role === ChatCompletionRole.User
const isSystem = props.role === ChatCompletionRole.System
const [tokenCount, setTokenCount] = useState(0)
@ -61,13 +63,14 @@ const SimpleTextMessage: React.FC<ThreadMessage> = (props) => {
const messages = useAtomValue(getCurrentChatMessagesAtom)
useEffect(() => {
if (props.status === MessageStatus.Ready || !experimentalFeatureEnabed) {
if (props.status === MessageStatus.Ready) {
return
}
const currentTimestamp = new Date().getTime() // Get current time in milliseconds
if (!lastTimestamp) {
// If this is the first update, just set the lastTimestamp and return
if (props.content !== '') setLastTimestamp(currentTimestamp)
if (props.content[0]?.text?.value !== '')
setLastTimestamp(currentTimestamp)
return
}
@ -90,24 +93,22 @@ const SimpleTextMessage: React.FC<ThreadMessage> = (props) => {
>
{!isUser && !isSystem && <LogoMark width={20} />}
<div className="text-sm font-extrabold capitalize">{props.role}</div>
<p className="text-xs font-medium">{displayDate(props.createdAt)}</p>
{experimentalFeatureEnabed && (
<div
className={twMerge(
'absolute right-0 cursor-pointer transition-all',
messages[0].id === props.id
? 'absolute -bottom-10 left-4'
: 'hidden group-hover:flex'
)}
>
<MessageToolbar message={props} />
</div>
)}
<p className="text-xs font-medium">{displayDate(props.created)}</p>
<div
className={twMerge(
'absolute right-0 cursor-pointer transition-all',
messages[messages.length - 1]?.id === props.id
? 'absolute -bottom-10 left-4'
: 'hidden group-hover:flex'
)}
>
<MessageToolbar message={props} />
</div>
</div>
<div className={twMerge('w-full')}>
{props.status === MessageStatus.Pending &&
(!props.content || props.content === '') ? (
(!props.content[0] || props.content[0].text.value === '') ? (
<BubbleLoader />
) : (
<>
@ -122,12 +123,11 @@ const SimpleTextMessage: React.FC<ThreadMessage> = (props) => {
</>
)}
</div>
{experimentalFeatureEnabed &&
(props.status === MessageStatus.Pending || tokenSpeed > 0) && (
<p className="mt-2 text-xs font-medium text-foreground">
Token Speed: {Number(tokenSpeed).toFixed(2)}/s
</p>
)}
{(props.status === MessageStatus.Pending || tokenSpeed > 0) && (
<p className="mt-2 text-xs font-medium text-foreground">
Token Speed: {Number(tokenSpeed).toFixed(2)}/s
</p>
)}
</div>
)
}

View File

@ -0,0 +1,97 @@
import { useEffect } from 'react'
import { Button } from '@janhq/uikit'
import { motion as m } from 'framer-motion'
import { useAtomValue } from 'jotai'
import { GalleryHorizontalEndIcon } from 'lucide-react'
import { twMerge } from 'tailwind-merge'
import { useCreateNewThread } from '@/hooks/useCreateNewThread'
import useGetAllThreads from '@/hooks/useGetAllThreads'
import useGetAssistants from '@/hooks/useGetAssistants'
import useSetActiveThread from '@/hooks/useSetActiveThread'
import { displayDate } from '@/utils/datetime'
import {
threadStatesAtom,
threadsAtom,
} from '@/helpers/atoms/Conversation.atom'
export default function ThreadList() {
const threads = useAtomValue(threadsAtom)
const threadStates = useAtomValue(threadStatesAtom)
const { requestCreateNewThread } = useCreateNewThread()
const { assistants } = useGetAssistants()
const { getAllThreads } = useGetAllThreads()
const { activeThreadId, setActiveThread: onThreadClick } =
useSetActiveThread()
useEffect(() => {
getAllThreads()
}, [])
const onCreateConversationClick = async () => {
if (assistants.length === 0) {
alert('No assistant available')
return
}
requestCreateNewThread(assistants[0])
}
return (
<div>
<div className="sticky top-0 z-20 flex flex-col border-b border-border bg-background px-4 py-3">
<Button
size="sm"
themes="secondary"
onClick={onCreateConversationClick}
>
Create New Chat
</Button>
</div>
{threads.length === 0 ? (
<div className="px-4 py-8 text-center">
<GalleryHorizontalEndIcon
size={26}
className="mx-auto mb-3 text-muted-foreground"
/>
<h2 className="font-semibold">No Chat History</h2>
<p className="mt-1 text-xs">Get started by creating a new chat</p>
</div>
) : (
threads.map((thread, i) => {
const lastMessage = threadStates[thread.id]?.lastMessage ?? ''
return (
<div
key={i}
className={twMerge(
'relative flex cursor-pointer flex-col border-b border-border px-4 py-2 hover:bg-secondary/20',
activeThreadId === thread.id && 'bg-secondary-10'
)}
onClick={() => onThreadClick(thread)}
>
<p className="mb-1 line-clamp-1 text-xs leading-5">
{thread.updated &&
displayDate(new Date(thread.updated).getTime())}
</p>
<h2 className="line-clamp-1">{thread.title}</h2>
<p className="mt-1 line-clamp-2 text-xs">{lastMessage}</p>
{activeThreadId === thread.id && (
<m.div
className="absolute right-0 top-0 h-full w-1 bg-primary/50"
layoutId="active-convo"
/>
)}
</div>
)
})
)}
</div>
)
}

View File

@ -1,6 +1,5 @@
import { Fragment, useContext, useEffect, useRef, useState } from 'react'
import { Fragment, useEffect, useRef, useState } from 'react'
import { Model } from '@janhq/core/lib/types'
import { Button, Badge, Textarea } from '@janhq/uikit'
import { useAtom, useAtomValue } from 'jotai'
@ -12,117 +11,71 @@ import { currentPromptAtom } from '@/containers/Providers/Jotai'
import ShortCut from '@/containers/Shortcut'
import { toaster } from '@/containers/Toast'
import { FeatureToggleContext } from '@/context/FeatureToggle'
import { MainViewState } from '@/constants/screens'
import { useActiveModel } from '@/hooks/useActiveModel'
import { useCreateConversation } from '@/hooks/useCreateConversation'
import useDeleteConversation from '@/hooks/useDeleteConversation'
import useDeleteThread from '@/hooks/useDeleteConversation'
import { useGetDownloadedModels } from '@/hooks/useGetDownloadedModels'
import useGetUserConversations from '@/hooks/useGetUserConversations'
import { useMainViewState } from '@/hooks/useMainViewState'
import useSendChatMessage from '@/hooks/useSendChatMessage'
import ChatBody from '@/screens/Chat/ChatBody'
import HistoryList from '@/screens/Chat/HistoryList'
import ThreadList from '@/screens/Chat/ThreadList'
import Sidebar from './Sidebar'
import {
currentConversationAtom,
getActiveConvoIdAtom,
userConversationsAtom,
activeThreadAtom,
getActiveThreadIdAtom,
threadsAtom,
waitingToSendMessage,
} from '@/helpers/atoms/Conversation.atom'
import { currentConvoStateAtom } from '@/helpers/atoms/Conversation.atom'
import { activeThreadStateAtom } from '@/helpers/atoms/Conversation.atom'
const ChatScreen = () => {
const currentConvo = useAtomValue(currentConversationAtom)
const currentConvo = useAtomValue(activeThreadAtom)
const { downloadedModels } = useGetDownloadedModels()
const { deleteConvo, cleanConvo } = useDeleteConversation()
const { deleteThread, cleanThread } = useDeleteThread()
const { activeModel, stateModel } = useActiveModel()
const { setMainViewState } = useMainViewState()
const [currentPrompt, setCurrentPrompt] = useAtom(currentPromptAtom)
const currentConvoState = useAtomValue(currentConvoStateAtom)
const currentConvoState = useAtomValue(activeThreadStateAtom)
const { sendChatMessage } = useSendChatMessage()
const isWaitingForResponse = currentConvoState?.waitingForResponse ?? false
const disabled = currentPrompt.trim().length === 0 || isWaitingForResponse
const activeConversationId = useAtomValue(getActiveConvoIdAtom)
const [isWaitingToSend, setIsWaitingToSend] = useAtom(waitingToSendMessage)
const { requestCreateConvo } = useCreateConversation()
const { getUserConversations } = useGetUserConversations()
const conversations = useAtomValue(userConversationsAtom)
const isEnableChat = (currentConvo && activeModel) || conversations.length > 0
const [isModelAvailable, setIsModelAvailable] = useState(
downloadedModels.some((x) => x.id === currentConvo?.modelId)
)
const { experimentalFeatureEnabed } = useContext(FeatureToggleContext)
const activeThreadId = useAtomValue(getActiveThreadIdAtom)
const [isWaitingToSend, setIsWaitingToSend] = useAtom(waitingToSendMessage)
const conversations = useAtomValue(threadsAtom)
const isEnableChat = (currentConvo && activeModel) || conversations.length > 0
const [isModelAvailable, setIsModelAvailable] = useState(
true
// downloadedModels.some((x) => x.id === currentConvo?.modelId)
)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const { startModel } = useActiveModel()
const modelRef = useRef(activeModel)
useEffect(() => {
modelRef.current = activeModel
}, [activeModel])
useEffect(() => {
getUserConversations()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleMessageChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const onPromptChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setCurrentPrompt(e.target.value)
}
useEffect(() => {
setIsModelAvailable(
downloadedModels.some((x) => x.id === currentConvo?.modelId)
)
}, [currentConvo, downloadedModels])
const handleSendMessage = async () => {
if (!activeModel || activeModel.id !== currentConvo?.modelId) {
const model = downloadedModels.find((e) => e.id === currentConvo?.modelId)
// Model is available to start
if (model != null) {
toaster({
title: 'Message queued.',
description: 'It will be sent once the model is done loading.',
})
startModel(model.id).then(() => {
setTimeout(() => {
if (modelRef?.current?.id === currentConvo?.modelId)
sendChatMessage()
}, 300)
})
}
return
}
if (activeConversationId) {
sendChatMessage()
} else {
setIsWaitingToSend(true)
await requestCreateConvo(activeModel as Model)
}
}
useEffect(() => {
if (isWaitingToSend && activeConversationId) {
if (isWaitingToSend && activeThreadId) {
setIsWaitingToSend(false)
sendChatMessage()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [waitingToSendMessage, activeConversationId])
}, [waitingToSendMessage, activeThreadId])
useEffect(() => {
if (textareaRef.current !== null) {
@ -138,11 +91,11 @@ const ChatScreen = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPrompt])
const handleKeyDown = async (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const onKeyDown = async (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter') {
if (!e.shiftKey) {
e.preventDefault()
handleSendMessage()
sendChatMessage()
}
}
}
@ -150,14 +103,14 @@ const ChatScreen = () => {
return (
<div className="flex h-full">
<div className="flex h-full w-64 flex-shrink-0 flex-col overflow-y-auto border-r border-border">
<HistoryList />
<ThreadList />
</div>
<div className="relative flex h-full w-[calc(100%-256px)] flex-col bg-muted/10">
<div className="flex h-full w-full flex-col justify-between">
{isEnableChat && currentConvo && (
<div className="h-[53px] flex-shrink-0 border-b border-border bg-background p-4">
<div className="flex items-center justify-between">
<span>{currentConvo?.summary ?? ''}</span>
<span>{currentConvo.title}</span>
<div
className={twMerge(
'flex items-center space-x-3',
@ -169,27 +122,23 @@ const ChatScreen = () => {
themes="secondary"
className="relative z-10"
size="sm"
onClick={() => {
onClick={() =>
setMainViewState(MainViewState.ExploreModels)
}}
}
>
Download Model
</Button>
)}
{experimentalFeatureEnabed && (
<Paintbrush
size={16}
className="cursor-pointer text-muted-foreground"
onClick={() => cleanConvo()}
/>
)}
{
<Trash2Icon
size={16}
className="cursor-pointer text-muted-foreground"
onClick={() => deleteConvo()}
/>
}
<Paintbrush
size={16}
className="cursor-pointer text-muted-foreground"
onClick={() => cleanThread()}
/>
<Trash2Icon
size={16}
className="cursor-pointer text-muted-foreground"
onClick={() => deleteThread()}
/>
</div>
</div>
</div>
@ -203,8 +152,8 @@ const ChatScreen = () => {
<div className="mx-auto mt-8 flex h-full w-3/4 flex-col items-center justify-center text-center">
{downloadedModels.length === 0 && (
<Fragment>
<h1 className="text-lg font-medium">{`Ups, you don't have a Model`}</h1>
<p className="mt-1">{`lets download your first model.`}</p>
<h1 className="text-lg font-medium">{`Oops, you don't have a Model`}</h1>
<p className="mt-1">{`Lets download your first model.`}</p>
<Button
className="mt-4"
onClick={() =>
@ -218,7 +167,7 @@ const ChatScreen = () => {
{!activeModel && downloadedModels.length > 0 && (
<Fragment>
<h1 className="text-lg font-medium">{`You dont have any actively running models`}</h1>
<p className="mt-1">{`Please start a downloaded model in My Models page to use this feature.`}</p>
<p className="mt-1">{`Please start a downloaded model to use this feature.`}</p>
<Badge className="mt-4" themes="outline">
<ShortCut menu="E" />
&nbsp; to show your model
@ -231,25 +180,24 @@ const ChatScreen = () => {
<Textarea
className="min-h-10 h-10 max-h-16 resize-none pr-20"
ref={textareaRef}
onKeyDown={(e) => handleKeyDown(e)}
onKeyDown={(e) => onKeyDown(e)}
placeholder="Type your message ..."
disabled={stateModel.loading || !currentConvo}
value={currentPrompt}
onChange={(e) => {
handleMessageChange(e)
}}
onChange={(e) => onPromptChange(e)}
/>
<Button
size="lg"
disabled={disabled || stateModel.loading || !currentConvo}
themes={'primary'}
onClick={handleSendMessage}
onClick={sendChatMessage}
>
Send
</Button>
</div>
</div>
</div>
<Sidebar />
</div>
)
}

View File

@ -33,8 +33,6 @@ const ExploreModelItem = forwardRef<HTMLDivElement, Props>(({ model }, ref) => {
return null
}
const { quantizationName, bits, maxRamRequired, usecase } = suitableModel
return (
<div
ref={ref}
@ -58,19 +56,15 @@ const ExploreModelItem = forwardRef<HTMLDivElement, Props>(({ model }, ref) => {
<div>
<span className="mb-1 font-semibold">Compatibility</span>
<div className="mt-1 flex gap-2">
<Badge
themes="secondary"
className="line-clamp-1 max-w-[400px] lg:line-clamp-none lg:max-w-none"
title={usecase}
>
{usecase}
</Badge>
<Badge
themes="secondary"
className="line-clamp-1 lg:line-clamp-none"
title={`${toGigabytes(maxRamRequired)} RAM required`}
title={`${toGigabytes(
suitableModel.metadata.maxRamRequired
)} RAM required`}
>
{toGigabytes(maxRamRequired)} RAM required
{toGigabytes(suitableModel.metadata.maxRamRequired)} RAM
required
</Badge>
</div>
</div>
@ -81,10 +75,11 @@ const ExploreModelItem = forwardRef<HTMLDivElement, Props>(({ model }, ref) => {
<span className="font-semibold">Version</span>
<div className="mt-2 flex space-x-2">
<Badge themes="outline">v{model.version}</Badge>
{quantizationName && (
<Badge themes="outline">{quantizationName}</Badge>
{suitableModel.metadata.quantization && (
<Badge themes="outline">
{suitableModel.metadata.quantization}
</Badge>
)}
<Badge themes="outline">{`${bits} Bits`}</Badge>
</div>
</div>
<div>
@ -113,8 +108,7 @@ const ExploreModelItem = forwardRef<HTMLDivElement, Props>(({ model }, ref) => {
{show && (
<ModelVersionList
model={model}
versions={model.availableVersions}
models={model.availableVersions}
recommendedVersion={suitableModel?.name ?? ''}
/>
)}

View File

@ -1,7 +1,7 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { useCallback, useEffect, useMemo, useState } from 'react'
import { ModelCatalog, ModelVersion } from '@janhq/core/lib/types'
import { Model, ModelCatalog } from '@janhq/core/lib/types'
import { Badge, Button } from '@janhq/uikit'
import { atom, useAtomValue } from 'jotai'
@ -23,7 +23,7 @@ import { toGigabytes } from '@/utils/converter'
import { totalRamAtom } from '@/helpers/atoms/SystemBar.atom'
type Props = {
suitableModel: ModelVersion
suitableModel: Model
exploreModel: ModelCatalog
}
@ -48,7 +48,7 @@ const ExploreModelItemHeader: React.FC<Props> = ({
const { setMainViewState } = useMainViewState()
const calculatePerformance = useCallback(
(suitableModel: ModelVersion) => async () => {
(suitableModel: Model) => async () => {
const { title, performanceTag } = await getPerformanceForModel(
suitableModel,
totalRam
@ -64,9 +64,9 @@ const ExploreModelItemHeader: React.FC<Props> = ({
}, [suitableModel])
const onDownloadClick = useCallback(() => {
downloadModel(exploreModel, suitableModel)
downloadModel(suitableModel)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [exploreModel, suitableModel])
}, [suitableModel])
// TODO: Comparing between Model Id and Version Name?
const isDownloaded =
@ -74,8 +74,8 @@ const ExploreModelItemHeader: React.FC<Props> = ({
let downloadButton = (
<Button onClick={() => onDownloadClick()}>
{suitableModel.size
? `Download (${toGigabytes(suitableModel.size)})`
{suitableModel.metadata.size
? `Download (${toGigabytes(suitableModel.metadata.size)})`
: 'Download'}
</Button>
)

View File

@ -1,9 +1,8 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useMemo } from 'react'
import { ModelCatalog, ModelVersion } from '@janhq/core/lib/types'
import { Button, Badge } from '@janhq/uikit'
import { Model } from '@janhq/core/lib/types'
import { Badge, Button } from '@janhq/uikit'
import { atom, useAtomValue } from 'jotai'
import ModalCancelDownload from '@/containers/ModalCancelDownload'
@ -18,28 +17,29 @@ import { useMainViewState } from '@/hooks/useMainViewState'
import { toGigabytes } from '@/utils/converter'
type Props = {
model: ModelCatalog
modelVersion: ModelVersion
model: Model
isRecommended: boolean
}
const ModelVersionItem: React.FC<Props> = ({ model, modelVersion }) => {
const ModelVersionItem: React.FC<Props> = ({ model }) => {
const { downloadModel } = useDownloadModel()
const { downloadedModels } = useGetDownloadedModels()
const { setMainViewState } = useMainViewState()
const isDownloaded =
downloadedModels.find((model) => model.id === modelVersion.name) != null
downloadedModels.find(
(downloadedModel) => downloadedModel.id === model.id
) != null
const { modelDownloadStateAtom, downloadStates } = useDownloadState()
const downloadAtom = useMemo(
() => atom((get) => get(modelDownloadStateAtom)[modelVersion.name ?? '']),
[modelVersion.name]
() => atom((get) => get(modelDownloadStateAtom)[model.id ?? '']),
[model.id]
)
const downloadState = useAtomValue(downloadAtom)
const onDownloadClick = () => {
downloadModel(model, modelVersion)
downloadModel(model)
}
let downloadButton = (
@ -63,36 +63,26 @@ const ModelVersionItem: React.FC<Props> = ({ model, modelVersion }) => {
}
if (downloadState != null && downloadStates.length > 0) {
downloadButton = (
<ModalCancelDownload suitableModel={modelVersion} isFromList />
)
downloadButton = <ModalCancelDownload suitableModel={model} isFromList />
}
const { maxRamRequired, usecase } = modelVersion
return (
<div className="flex items-center justify-between gap-4 border-t border-border pb-3 pl-3 pr-4 pt-3 first:border-t-0">
<div className="flex items-center gap-2">
<span className="line-clamp-1 flex-1" title={modelVersion.name}>
{modelVersion.name}
<span className="line-clamp-1 flex-1" title={model.name}>
{model.name}
</span>
</div>
<div className="flex items-center gap-4">
<div className="flex justify-end gap-2">
<Badge
themes="secondary"
className="line-clamp-1 max-w-[240px] lg:line-clamp-none lg:max-w-none"
title={usecase}
>
{usecase}
</Badge>
<Badge
themes="secondary"
className="line-clamp-1"
title={`${toGigabytes(maxRamRequired)} RAM required`}
>{`${toGigabytes(maxRamRequired)} RAM required`}</Badge>
<Badge themes="secondary">{toGigabytes(modelVersion.size)}</Badge>
title={`${toGigabytes(model.metadata.maxRamRequired)} RAM required`}
>{`${toGigabytes(
model.metadata.maxRamRequired
)} RAM required`}</Badge>
<Badge themes="secondary">{toGigabytes(model.metadata.size)}</Badge>
</div>
{downloadButton}
</div>

View File

@ -1,26 +1,23 @@
import { ModelCatalog, ModelVersion } from '@janhq/core/lib/types'
import { Model } from '@janhq/core/lib/types'
import ModelVersionItem from '../ModelVersionItem'
type Props = {
model: ModelCatalog
versions: ModelVersion[]
models: Model[]
recommendedVersion: string
}
export default function ModelVersionList({
model,
versions,
models,
recommendedVersion,
}: Props) {
return (
<div className="pt-4">
{versions.map((item) => (
{models.map((model) => (
<ModelVersionItem
key={item.name}
key={model.name}
model={model}
modelVersion={item}
isRecommended={item.name === recommendedVersion}
isRecommended={model.name === recommendedVersion}
/>
))}
</div>

View File

@ -25,17 +25,17 @@ export default function BlankStateMyModel() {
<div className="text-center">
<DatabaseIcon size={32} className="mx-auto text-muted-foreground" />
<div className="mt-4">
<h1 className="text-xl font-bold leading-snug">{`Ups, You don't have a model.`}</h1>
<h1 className="text-xl font-bold leading-snug">{`Oops, you don't have a model yet.`}</h1>
<p className="mt-1 text-base">
{downloadStates.length > 0
? `Downloading model ... `
: `lets download your first model`}
: `Lets download your first model`}
</p>
{downloadStates?.length > 0 && (
<Modal>
<ModalTrigger asChild>
<Button themes="outline" className="mr-2 mt-6">
<span>{downloadStates.length} Downloading model</span>
<span>Downloading {downloadStates.length} model(s)</span>
</Button>
</ModalTrigger>
<ModalContent>

View File

@ -64,28 +64,28 @@ const MyModelsScreen = () => {
<div className="inline-flex rounded-full border border-border p-1">
<Avatar className="h-8 w-8">
<AvatarImage
src={model.avatarUrl}
alt={model.author}
src={model.metadata.avatarUrl}
alt={model.metadata.author}
/>
<AvatarFallback>
{model.author.charAt(0)}
{model.metadata.author.charAt(0)}
</AvatarFallback>
</Avatar>
</div>
<div>
<h2 className="mb-1 font-medium capitalize">
{model.author}
{model.metadata.author}
</h2>
<p className="line-clamp-1">{model.name}</p>
<div className="mt-2 flex items-center gap-2">
<Badge themes="secondary">v{model.version}</Badge>
<Badge themes="outline">GGUF</Badge>
<Badge themes="outline">
{toGigabytes(model.size)}
{toGigabytes(model.metadata.size)}
</Badge>
</div>
<p className="mt-2 line-clamp-2 break-all">
{model.longDescription}
{model.description}
</p>
</div>
</div>
@ -102,7 +102,7 @@ const MyModelsScreen = () => {
</ModalHeader>
<p className="leading-relaxed">
Delete model {model.name}, v{model.version},{' '}
{toGigabytes(model.size)}.
{toGigabytes(model.metadata.size)}.
</p>
<ModalFooter>
<div className="flex gap-x-2">
@ -149,7 +149,7 @@ const MyModelsScreen = () => {
<div className="rounded-lg border border-border bg-background p-4 hover:border-primary/60">
<div className="flex h-full flex-col justify-between">
<div>
<h2 className="text-lg font-medium">Download more model?</h2>
<h2 className="text-lg font-medium">Download more models?</h2>
<p className="mt-2 leading-relaxed">
You have <span>{downloadedModels.length}</span> model(s)
downloaded.&nbsp;

View File

@ -52,7 +52,7 @@ const PreferencePlugins = (props: Props) => {
}
toaster({
title: formatPluginsName(pluginName),
description: 'Success update preferences',
description: 'Successfully updated preferences',
})
}

View File

@ -35,7 +35,7 @@ const WelcomeScreen = () => {
>
Welcome to Jan
</h1>
<p className="mt-1">{`lets download your first model`}</p>
<p className="mt-1">{`Lets download your first model`}</p>
<Button
className="mt-4"
onClick={() => setMainViewState(MainViewState.ExploreModels)}
@ -47,7 +47,7 @@ const WelcomeScreen = () => {
{downloadedModels.length >= 1 && !activeModel && (
<Fragment>
<h1 className="mt-2 text-lg font-medium">{`You dont have any actively running models`}</h1>
<p className="mt-1">{`Please start a downloaded model in My Models page to use this feature.`}</p>
<p className="mt-1">{`Please start a downloaded model to use this feature.`}</p>
<Badge className="mt-4" themes="outline">
<ShortCut menu="E" />
&nbsp; to show your model
@ -57,7 +57,7 @@ const WelcomeScreen = () => {
{downloadedModels.length >= 1 && activeModel && (
<Fragment>
<h1 className="mt-2 text-lg font-medium">{`Your Model is Active`}</h1>
<p className="mt-1">{`You are ready to start conversations.`}</p>
<p className="mt-1">{`You are ready to converse.`}</p>
<Button
className="mt-4"
onClick={() => setMainViewState(MainViewState.Chat)}

View File

@ -1,6 +0,0 @@
export type ThreadState = {
hasMore: boolean
waitingForResponse: boolean
error?: Error
lastMessage?: string
}

Some files were not shown because too many files have changed in this diff Show More