Merge main

This commit is contained in:
Daniel 2023-11-17 18:08:21 +08:00
commit 22f1f7628a
48 changed files with 787 additions and 984 deletions

View File

@ -1,6 +1,6 @@
# Jan - Personal AI # Jan - Own Your AI
![Project Cover](https://github.com/janhq/jan/assets/89722390/be4f07ef-13df-4621-8f25-b861f1d5b7b3) ![Jan banner](https://github.com/janhq/jan/assets/89722390/35daac7d-b895-487c-a6ac-6663daaad78e)
<p align="center"> <p align="center">
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section --> <!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
@ -21,7 +21,7 @@
> ⚠️ **Jan is currently in Development**: Expect breaking changes and bugs! > ⚠️ **Jan is currently in Development**: Expect breaking changes and bugs!
Jan is a free, open-source alternative to OpenAI that runs on your personal computer. Jan is a free, open-source alternative to OpenAI's platform that runs on a local folder of open-format files.
**Jan runs on any hardware.** From PCs to multi-GPU clusters, Jan supports universal architectures: **Jan runs on any hardware.** From PCs to multi-GPU clusters, Jan supports universal architectures:

View File

@ -12,44 +12,16 @@ export enum EventName {
OnDownloadError = "onDownloadError", OnDownloadError = "onDownloadError",
} }
export type MessageHistory = {
role: string;
content: string;
};
/**
* The `NewMessageRequest` type defines the shape of a new message request object.
*/
export type NewMessageRequest = {
id?: string;
conversationId?: string;
user?: string;
avatar?: string;
message?: string;
createdAt?: string;
updatedAt?: string;
history?: MessageHistory[];
};
/**
* The `NewMessageRequest` type defines the shape of a new message request object.
*/
export type NewMessageResponse = {
id?: string;
conversationId?: string;
user?: string;
avatar?: string;
message?: string;
createdAt?: string;
updatedAt?: string;
};
/** /**
* Adds an observer for an event. * Adds an observer for an event.
* *
* @param eventName The name of the event to observe. * @param eventName The name of the event to observe.
* @param handler The handler function to call when the event is observed. * @param handler The handler function to call when the event is observed.
*/ */
const on: (eventName: string, handler: Function) => void = (eventName, handler) => { const on: (eventName: string, handler: Function) => void = (
eventName,
handler
) => {
window.corePlugin?.events?.on(eventName, handler); window.corePlugin?.events?.on(eventName, handler);
}; };
@ -59,7 +31,10 @@ const on: (eventName: string, handler: Function) => void = (eventName, handler)
* @param eventName The name of the event to stop observing. * @param eventName The name of the event to stop observing.
* @param handler The handler function to call when the event is observed. * @param handler The handler function to call when the event is observed.
*/ */
const off: (eventName: string, handler: Function) => void = (eventName, handler) => { const off: (eventName: string, handler: Function) => void = (
eventName,
handler
) => {
window.corePlugin?.events?.off(eventName, handler); window.corePlugin?.events?.off(eventName, handler);
}; };

View File

@ -34,4 +34,4 @@ export { fs } from "./fs";
* Plugin base module export. * Plugin base module export.
* @module * @module
*/ */
export { JanPlugin, PluginType } from "./plugin"; export * from "./plugin";

View File

@ -1,5 +1,5 @@
import { Thread } from "../index";
import { JanPlugin } from "../plugin"; import { JanPlugin } from "../plugin";
import { Conversation } from "../types/index";
/** /**
* Abstract class for conversational plugins. * Abstract class for conversational plugins.
@ -17,10 +17,10 @@ export abstract class ConversationalPlugin extends JanPlugin {
/** /**
* Saves a conversation. * Saves a conversation.
* @abstract * @abstract
* @param {Conversation} conversation - The conversation to save. * @param {Thread} conversation - The conversation to save.
* @returns {Promise<void>} A promise that resolves when the conversation is saved. * @returns {Promise<void>} A promise that resolves when the conversation is saved.
*/ */
abstract saveConversation(conversation: Conversation): Promise<void>; abstract saveConversation(conversation: Thread): Promise<void>;
/** /**
* Deletes a conversation. * Deletes a conversation.

View File

@ -1,4 +1,4 @@
import { NewMessageRequest } from "../events"; import { MessageRequest } from "../index";
import { JanPlugin } from "../plugin"; import { JanPlugin } from "../plugin";
/** /**
@ -21,5 +21,5 @@ export abstract class InferencePlugin extends JanPlugin {
* @param data - The data for the inference request. * @param data - The data for the inference request.
* @returns The result of the inference request. * @returns The result of the inference request.
*/ */
abstract inferenceRequest(data: NewMessageRequest): Promise<any>; abstract inferenceRequest(data: MessageRequest): Promise<any>;
} }

View File

@ -1,140 +1,183 @@
export interface Conversation {
id: string;
modelId?: string;
botId?: string;
name: string;
message?: string;
summary?: string;
createdAt?: string;
updatedAt?: string;
messages: Message[];
lastMessage?: string;
}
export interface Message {
id: string;
message?: string;
user?: string;
createdAt?: string;
updatedAt?: string;
}
export interface RawMessage {
id?: string;
conversationId?: string;
user?: string;
avatar?: string;
message?: string;
createdAt?: string;
updatedAt?: string;
}
export interface Model {
/**
* Combination of owner and model name.
* Being used as file name. MUST be unique.
*/
id: string;
name: string;
quantMethod: string;
bits: number;
size: number;
maxRamRequired: number;
usecase: string;
downloadLink: string;
modelFile?: string;
/**
* For tracking download info
*/
startDownloadAt?: number;
finishDownloadAt?: number;
productId: string;
productName: string;
shortDescription: string;
longDescription: string;
avatarUrl: string;
author: string;
version: string;
modelUrl: string;
createdAt: number;
updatedAt?: number;
status: string;
releaseDate: number;
tags: string[];
}
export interface ModelCatalog {
id: string;
name: string;
shortDescription: string;
avatarUrl: string;
longDescription: string;
author: string;
version: string;
modelUrl: string;
createdAt: number;
updatedAt?: number;
status: string;
releaseDate: number;
tags: string[];
availableVersions: ModelVersion[];
}
/** /**
* Model type which will be stored in the database * Message Request and Response
*/ * ============================
export type ModelVersion = { * */
/**
* Combination of owner and model name.
* Being used as file name. Should be unique.
*/
id: string;
name: string;
quantMethod: string;
bits: number;
size: number;
maxRamRequired: number;
usecase: string;
downloadLink: string;
productId: string;
/**
* For tracking download state
*/
startDownloadAt?: number;
finishDownloadAt?: number;
};
export interface ChatMessage { /**
id: string; * The role of the author of this message.
conversationId: string; * @data_transfer_object
messageType: MessageType; */
messageSenderType: MessageSenderType; export enum ChatCompletionRole {
senderUid: string; System = "system",
senderName: string; Assistant = "assistant",
senderAvatarUrl: string;
text: string | undefined;
imageUrls?: string[] | undefined;
createdAt: number;
status: MessageStatus;
}
export enum MessageType {
Text = "Text",
Image = "Image",
ImageWithText = "ImageWithText",
Error = "Error",
}
export enum MessageSenderType {
Ai = "assistant",
User = "user", User = "user",
} }
/**
* The `MessageRequest` type defines the shape of a new message request object.
* @data_transfer_object
*/
export type ChatCompletionMessage = {
/** The contents of the message. **/
content?: string;
/** The role of the author of this message. **/
role: ChatCompletionRole;
};
/**
* The `MessageRequest` type defines the shape of a new message request object.
* @data_transfer_object
*/
export type MessageRequest = {
id?: string;
/** The thread id of the message request. **/
threadId?: string;
/** Messages for constructing a chat completion request **/
messages?: ChatCompletionMessage[];
};
/**
* Thread and Message
* ========================
* */
/**
* The status of the message.
* @data_transfer_object
*/
export enum MessageStatus { export enum MessageStatus {
/** Message is fully loaded. **/
Ready = "ready", Ready = "ready",
/** Message is not fully loaded. **/
Pending = "pending", Pending = "pending",
} }
/**
export type ConversationState = { * The `ThreadMessage` type defines the shape of a thread's message object.
hasMore: boolean; * @stored
waitingForResponse: boolean; */
error?: Error; export type ThreadMessage = {
/** Unique identifier for the message, generated by default using the ULID method. **/
id?: string;
/** Thread id, default is a ulid. **/
threadId?: string;
/** The role of the author of this message. **/
role?: ChatCompletionRole;
/** The content of this message. **/
content?: string;
/** The status of this message. **/
status: MessageStatus;
/** The timestamp indicating when this message was created, represented in ISO 8601 format. **/
createdAt?: string;
};
/**
* The `Thread` type defines the shape of a thread object.
* @stored
*/
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[];
/** The timestamp indicating when this thread was created, represented in ISO 8601 format. **/
createdAt?: string;
/** 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;
}
/**
* 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.*/
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[];
}
/**
* Model type of the presentation object which will be presented to the user
* @data_transfer_object
*/
export interface ModelCatalog {
/** The unique id of the model.*/
id: string;
/** The name of the model.*/
name: string;
/** The avatar url of the model.*/
avatarUrl: string;
/** The short description of the model.*/
shortDescription: string;
/** The long description of the model.*/
longDescription: string;
/** The author name of the model.*/
author: string;
/** The version of the model.*/
version: string;
/** The origin url of the model repo.*/
modelUrl: string;
/** The timestamp indicating when this model was released.*/
releaseDate: number;
/** The tags attached to the model description **/
tags: string[];
/** The available versions of this model to download. */
availableVersions: ModelVersion[];
}
/**
* Model type which will be present a version of ModelCatalog
* @data_transfer_object
*/
export type ModelVersion = {
/** The name of this model version.*/
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;
}; };

View File

@ -2,79 +2,188 @@
title: "Assistants" title: "Assistants"
--- ---
Assistants can use models and tools. :::warning
- Jan's `Assistants` are even more powerful than OpenAI due to customizable code in `index.js` Draft Specification: functionality has not been implemented yet.
> OpenAI Equivalent: https://platform.openai.com/docs/api-reference/assistants Feedback: [HackMD: Assistants Spec](https://hackmd.io/KKAznzZvS668R6Vmyf8fCg)
## Assistant Object :::
- `assistant.json`
- Equivalent to: https://platform.openai.com/docs/api-reference/assistants/object ## User Stories
_Users can chat with an assistant_
- [Wireframes - show asst object properties]
- See [Threads Spec](https://hackmd.io/BM_8o_OCQ-iLCYhunn2Aug)
_Users can use Jan - the default assistant_
- [Wireframes here - show model picker]
- See [Default Jan Object](#Default-Jan-Example)
_Users can create an assistant from scratch_
- [Wireframes here - show create asst flow]
- Users can select any model for an assistant. See Model Spec
_Users can create an assistant from an existing assistant_
- [Wireframes showing asst edit mode]
## Jan Assistant Object
- A `Jan Assistant Object` is a "representation of an assistant"
- Objects are defined by `assistant-uuid.json` files in `json` format
- Objects are designed to be compatible with `OpenAI Assistant Objects` with additional properties needed to run on our infrastructure.
- ALL object properties are optional, i.e. users should be able to use an assistant declared by an empty `json` file.
| Property | Type | Description | Validation |
| ------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------- |
| `object` | enum: `model`, `assistant`, `thread`, `message` | The Jan Object type | Defaults to `assistant` |
| `name` | string | A vanity name. | Defaults to filename |
| `description` | string | A vanity description. | Max `n` chars. Defaults to `""` |
| `models` | array | A list of Model Objects that the assistant can use. | Defaults to ALL models |
| `metadata` | map | This can be useful for storing additional information about the object in a structured format. | Defaults to `{}` |
| `tools` | array | TBA. | TBA |
| `files` | array | TBA. | TBA |
### Generic Example
```json ```json
{ // janroot/assistants/example/example.json
// Jan specific properties "name": "Homework Helper",
"avatar": "https://lala.png"
"thread_location": "ROOT/threads" // Default to root (optional field)
// TODO: add moar
// OpenAI compatible properties: https://platform.openai.com/docs/api-reference/assistants // Option 1 (default): all models in janroot/models are available via Model Picker
"id": "asst_abc123", "models": [],
"object": "assistant",
"created_at": 1698984975, // Option 2: creator can configure custom parameters on existing models in `janroot/models` &&
"name": "Math Tutor", // Option 3: creator can package a custom model with the assistant
"description": null, "models": [{ ...modelObject1 }, { ...modelObject2 }],
"model": reference model.json,
"instructions": reference model.json,
"tools": [
{
"type": "rag"
}
],
"file_ids": [],
"metadata": {}
}
``` ```
## Assistants API ### Default Jan Example
- _TODO_: What would modifying Assistant do? (doesn't mutate `index.js`?) - Every user install has a default "Jan Assistant" declared below.
> Q: can we omit most properties in `jan.json`? It's all defaults anyway.
```sh ```json
GET https://api.openai.com/v1/assistants # List // janroot/assistants/jan/jan.json
POST https://api.openai.com/v1/assistants # C "description": "Use Jan to chat with all models",
GET https://api.openai.com/v1/assistants/{assistant_id} # R
POST https://api.openai.com/v1/assistants/{assistant_id} # U
DELETE https://api.openai.com/v1/assistants/{assistant_id} # D
``` ```
## Assistants Filesystem ## Filesystem
- Everything needed to represent & run an assistant is packaged into an `Assistant folder`.
- The folder is standalone and can be easily zipped, imported, and exported, e.g. to Github.
- The folder always contains an `Assistant Object`, declared in an `assistant-uuid.json`.
- The folder and file must share the same name: `assistant-uuid`
- In the future, the folder will contain all of the resources an assistant needs to run, e.g. custom model binaries, pdf files, custom code, etc.
```sh ```sh
/assistants janroot/
/jan assistants/
assistant.json # Assistant configs (see below) jan/ # Assistant Folder
jan.json # Assistant Object
homework-helper/ # Assistant Folder
homework-helper.json # Assistant Object
```
# For any custom code ### Custom Code
package.json # Import npm modules
# e.g. Langchain, Llamaindex
/src # Supporting files (needs better name)
index.js # Entrypoint
process.js # For electron IPC processes (needs better name)
# `/threads` at root level > Not in scope yet. Sharing as a preview only.
# `/models` at root level
/shakespeare - Assistants can call custom code in the future
assistant.json - Custom code extends beyond `function calling` to any features that can be implemented in `/src`
model.json # Creator chooses model and settings
```sh
example/ # Assistant Folder
example.json # Assistant Object
package.json package.json
/src src/
index.js index.ts
process.js helpers.ts
/threads # Assistants remember conversations in the future
/models # Users can upload custom models
/finetuned-model
``` ```
### Knowledge Files
> Not in scope yet. Sharing as a preview only
- Assistants can do `retrieval` in future
```sh
example/ # Assistant Folder
example.json # Assistant Object
files/
```
## Jan API
### Assistant API Object
#### `GET /v1/assistants/{assistant_id}`
- The `Jan Assistant Object` maps into the `OpenAI Assistant Object`.
- Properties marked with `*` are compatible with the [OpenAI `assistant` object](https://platform.openai.com/docs/api-reference/assistants)
- Note: The `Jan Assistant Object` has additional properties when retrieved via its API endpoint.
- https://platform.openai.com/docs/api-reference/assistants/getAssistant
| Property | Type | Public Description | Jan Assistant Object (`a`) Property |
| ---------------- | -------------- | ------------------------------------------------------------------------- | ----------------------------------- |
| `id`\* | string | Assistant uuid, also the name of the Jan Assistant Object file: `id.json` | `json` filename |
| `object`\* | string | Always "assistant" | `a.object` |
| `created_at`\* | integer | Timestamp when assistant was created. | `a.json` creation time |
| `name`\* | string or null | A display name | `a.name` or `id` |
| `description`\* | string or null | A description | `a.description` |
| `model`\* | string | Text | `a.models[0].name` |
| `instructions`\* | string or null | Text | `a.models[0].parameters.prompt` |
| `tools`\* | array | TBA | `a.tools` |
| `file_ids`\* | array | TBA | `a.files` |
| `metadata`\* | map | TBA | `a.metadata` |
| `models` | array | TBA | `a.models` |
### Create Assistant
#### `POST /v1/assistants`
- https://platform.openai.com/docs/api-reference/assistants/createAssistant
### Retrieve Assistant
#### `GET v1/assistants/{assistant_id}`
- https://platform.openai.com/docs/api-reference/assistants/getAssistant
### Modify Assistant
#### `POST v1/assistants/{assistant_id}`
- https://platform.openai.com/docs/api-reference/assistants/modifyAssistant
### Delete Assistant
#### `DELETE v1/assistants/{assistant_id}`
- https://platform.openai.com/docs/api-reference/assistants/deleteAssistant
### List Assistants
#### `GET v1/assistants`
- https://platform.openai.com/docs/api-reference/assistants/listAssistants
### CRUD Assistant.Models
- This is a Jan-only endpoint, since Jan supports the ModelPicker, i.e. an `assistant` can be created to run with many `models`.
#### `POST /v1/assistants/{assistant_id}/models`
#### `GET /v1/assistants/{assistant_id}/models`
#### `GET /v1/assistants/{assistant_id}/models/{model_id}`
#### `DELETE /v1/assistants/{assistant_id}/models`
Note: There's no need to implement `Modify Assistant.Models`

View File

@ -2,6 +2,12 @@
title: "Chats" title: "Chats"
--- ---
:::warning
Draft Specification: functionality has not been implemented yet.
:::
Chats are essentially inference requests to a model Chats are essentially inference requests to a model
> OpenAI Equivalent: https://platform.openai.com/docs/api-reference/chat > OpenAI Equivalent: https://platform.openai.com/docs/api-reference/chat

View File

@ -2,6 +2,12 @@
title: "Files" title: "Files"
--- ---
:::warning
Draft Specification: functionality has not been implemented yet.
:::
Files can be used by `threads`, `assistants` and `fine-tuning` Files can be used by `threads`, `assistants` and `fine-tuning`
> Equivalent to: https://platform.openai.com/docs/api-reference/files > Equivalent to: https://platform.openai.com/docs/api-reference/files

View File

@ -2,6 +2,14 @@
title: "Messages" title: "Messages"
--- ---
:::warning
Draft Specification: functionality has not been implemented yet.
Feedback: [HackMD: Threads Spec](https://hackmd.io/BM_8o_OCQ-iLCYhunn2Aug)
:::
Messages are within `threads` and capture additional metadata. Messages are within `threads` and capture additional metadata.
- Equivalent to: https://platform.openai.com/docs/api-reference/messages - Equivalent to: https://platform.openai.com/docs/api-reference/messages

View File

@ -1,4 +1,16 @@
# Model Specs ---
title: "Models"
---
:::warning
Draft Specification: functionality has not been implemented yet.
Feedback: [HackMD: Models Spec](https://hackmd.io/ulO3uB1AQCqLa5SAAMFOQw)
:::
Models are AI models like Llama and Mistral
> OpenAI Equivalent: https://platform.openai.com/docs/api-reference/models > OpenAI Equivalent: https://platform.openai.com/docs/api-reference/models

View File

@ -2,52 +2,133 @@
title: "Threads" title: "Threads"
--- ---
Threads contain `messages` history with assistants. Messages in a thread share context. :::warning
- Note: For now, threads "lock the model" after a `message` is sent Draft Specification: functionality has not been implemented yet.
- When a new `thread` is created with Jan, users can choose the models
- Users can still edit model parameters/system prompts
- Note: future Assistants may customize this behavior
- Note: Assistants will be able to specify default thread location in the future
- Jan uses root-level threads, to allow for future multi-assistant threads
- Assistant Y may store threads in its own folder, to allow for [long-term assistant memory](https://github.com/janhq/jan/issues/344)
> OpenAI Equivalent: https://platform.openai.com/docs/api-reference/threads Feedback: [HackMD: Threads Spec](https://hackmd.io/BM_8o_OCQ-iLCYhunn2Aug)
## Thread Object :::
- `thread.json` ## User Stories
- Equivalent to: https://platform.openai.com/docs/api-reference/threads/object
_Users can chat with an assistant in a thread_
- See [Messages Spec]
_Users can change model in a new thread_
- Wireframes here
_Users can change model parameters in a thread_
- Wireframes of
_Users can delete all thread history_
- Wireframes of settings page.
## Jan Thread Object
- A `Jan Thread Object` is a "representation of a conversation thread" between an `assistant` and the user
- Objects are defined by `thread-uuid.json` files in `json` format
- Objects are designed to be compatible with `OpenAI Thread Objects` with additional properties needed to run on our infrastructure.
- Objects contain a `models` field, to track when the user overrides the assistant's default model parameters.
| Property | Type | Description | Validation |
| ---------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ |
| `object` | enum: `model`, `assistant`, `thread`, `message` | The Jan Object type | Defaults to `thread` |
| `models` | array | An array of Jan Model Objects. Threads can "override" an assistant's model run parameters. Thread-level model parameters are directly saved in the `thread.models` property! (see Models spec) | Defaults to `assistant.models` |
| `messages` | array | An array of Jan Message Objects. (see Messages spec) | Defaults to `[]` |
| `metadata` | map | Useful for storing additional information about the object in a structured format. | Defaults to `{}` |
### Generic Example
```json ```json
{ // janroot/threads/jan_1700123404.json
// Jan specific properties: "messages": [
"summary": "HCMC restaurant recommendations", {...message0}, {...message1}
"messages": {see below} ],
"metadata": {
// OpenAI compatible properties: https://platform.openai.com/docs/api-reference/threads) "summary": "funny physics joke",
"id": "thread_abc123", },
"object": "thread",
"created_at": 1698107661,
"metadata": {}
}
``` ```
## Threads API ## Filesystem
- Equivalent to: https://platform.openai.com/docs/api-reference/threads - `Jan Thread Objects`' `json` files always has the naming schema: `assistant_uuid` + `unix_time_thread_created_at. See below.
- Threads are all saved in the `janroot/threads` folder in a flat folder structure.
```sh= - The folder is standalone and can be easily zipped, exported, and cleared.
POST https://localhost:1337/v1/threads/{thread_id} # Create thread
GET https://localhost:1337/v1/threads/{thread_id} # Get thread
DELETE https://localhost:1337/v1/models/{thread_id} # Delete thread
```
## Threads Filesystem
```sh ```sh
/assistants janroot/
/homework-helper threads/
/threads # context is "permanently remembered" by assistant in future conversations jan_1700123404.json
/threads # context is only retained within a single thread homework_helper_700120003.json
``` ```
## Jan API
### Thread API Object
#### `GET /v1/threads/{thread_id}`
- The `Jan Thread Object` maps into the `OpenAI Thread Object`.
- Properties marked with `*` are compatible with the [OpenAI `thread` object](https://platform.openai.com/docs/api-reference/threads)
- Note: The `Jan Thread Object` has additional properties when retrieved via its API endpoint.
- https://platform.openai.com/docs/api-reference/threads/getThread
| Property | Type | Public Description | Jan Thread Object (`t`) Property |
| -------------- | ------- | ------------------------------------------------------------------- | -------------------------------- |
| `id`\* | string | Thread uuid, also the name of the Jan Thread Object file: `id.json` | `json` filename |
| `object`\* | string | Always "thread" | `t.object` |
| `created_at`\* | integer | | `json` file creation time |
| `metadata`\* | map | | `t.metadata` |
| `models` | array | | `t.models` |
| `messages` | array | | `t.messages` |
### Create Thread
#### `POST /v1/threads`
- https://platform.openai.com/docs/api-reference/threads/createThread
### Retrieve Thread
#### `GET v1/threads/{thread_id}`
- https://platform.openai.com/docs/api-reference/threads/getThread
### Modify Thread
#### `POST v1/threads/{thread_id}`
- https://platform.openai.com/docs/api-reference/threads/modifyThread
### Delete Thread
#### `DELETE v1/threads/{thread_id}`
- https://platform.openai.com/docs/api-reference/threads/deleteThread
### List Threads
> This is a Jan-only endpoint, not supported by OAI yet.
#### `GET v1/threads`
### Get & Modify `Thread.Models`
> This is a Jan-only endpoint, not supported by OAI yet.
#### `GET v1/threads/{thread_id}/models`
#### `POST v1/threads/{thread_id}/models/{model_id}`
- Since users can change model parameters in an existing thread
### List `Thread.Messages`
> This is a Jan-only endpoint, not supported by OAI yet.
#### `GET v1/threads/{thread_id}/messages`

View File

@ -1,6 +1,6 @@
import { PluginType, fs } from '@janhq/core' import { PluginType, fs } from '@janhq/core'
import { ConversationalPlugin } from '@janhq/core/lib/plugins' import { ConversationalPlugin } from '@janhq/core/lib/plugins'
import { Conversation } from '@janhq/core/lib/types' import { Thread } from '@janhq/core/lib/types'
import { join } from 'path' import { join } from 'path'
/** /**
@ -35,7 +35,7 @@ export default class JSONConversationalPlugin implements ConversationalPlugin {
/** /**
* Returns a Promise that resolves to an array of Conversation objects. * Returns a Promise that resolves to an array of Conversation objects.
*/ */
async getConversations(): Promise<Conversation[]> { async getConversations(): Promise<Thread[]> {
try { try {
const convoIds = await this.getConversationDocs() const convoIds = await this.getConversationDocs()
@ -46,7 +46,7 @@ export default class JSONConversationalPlugin implements ConversationalPlugin {
const convos = promiseResults const convos = promiseResults
.map((result) => { .map((result) => {
if (result.status === 'fulfilled') { if (result.status === 'fulfilled') {
return JSON.parse(result.value) as Conversation return JSON.parse(result.value) as Thread
} }
}) })
.filter((convo) => convo != null) .filter((convo) => convo != null)
@ -66,7 +66,7 @@ export default class JSONConversationalPlugin implements ConversationalPlugin {
* Saves a Conversation object to a Markdown file. * Saves a Conversation object to a Markdown file.
* @param conversation The Conversation object to save. * @param conversation The Conversation object to save.
*/ */
saveConversation(conversation: Conversation): Promise<void> { saveConversation(conversation: Thread): Promise<void> {
return fs return fs
.mkdir(`${JSONConversationalPlugin._homeDir}/${conversation.id}`) .mkdir(`${JSONConversationalPlugin._homeDir}/${conversation.id}`)
.then(() => .then(() =>

View File

@ -1,39 +0,0 @@
{
"name": "@janhq/conversational-plugin",
"version": "1.0.7",
"description": "Conversational Plugin - Stores jan app conversations",
"main": "dist/index.js",
"author": "Jan <service@jan.ai>",
"requiredVersion": "^0.3.1",
"license": "MIT",
"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"
},
"exports": {
".": "./dist/index.js",
"./main": "./dist/module.js"
},
"devDependencies": {
"cpx": "^1.5.0",
"rimraf": "^3.0.2",
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4"
},
"dependencies": {
"@janhq/core": "file:../../core",
"ts-loader": "^9.5.0"
},
"engines": {
"node": ">=18.0.0"
},
"files": [
"dist/*",
"package.json",
"README.md"
],
"bundleDependencies": []
}

View File

@ -1,214 +0,0 @@
import { PluginType, fs } from "@janhq/core";
import { ConversationalPlugin } from "@janhq/core/lib/plugins";
import { Message, Conversation } from "@janhq/core/lib/types";
/**
* JanConversationalPlugin is a ConversationalPlugin implementation that provides
* functionality for managing conversations.
*/
export default class JanConversationalPlugin implements ConversationalPlugin {
/**
* Returns the type of the plugin.
*/
type(): PluginType {
return PluginType.Conversational;
}
/**
* Called when the plugin is loaded.
*/
onLoad() {
console.debug("JanConversationalPlugin loaded");
fs.mkdir("conversations");
}
/**
* Called when the plugin is unloaded.
*/
onUnload() {
console.debug("JanConversationalPlugin unloaded");
}
/**
* Returns a Promise that resolves to an array of Conversation objects.
*/
getConversations(): Promise<Conversation[]> {
return this.getConversationDocs().then((conversationIds) =>
Promise.all(
conversationIds.map((conversationId) =>
this.loadConversationFromMarkdownFile(
`conversations/${conversationId}/${conversationId}.md`
)
)
).then((conversations) =>
conversations.sort(
(a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
)
)
);
}
/**
* Saves a Conversation object to a Markdown file.
* @param conversation The Conversation object to save.
*/
saveConversation(conversation: Conversation): Promise<void> {
return this.writeMarkdownToFile(conversation);
}
/**
* Deletes a conversation with the specified ID.
* @param conversationId The ID of the conversation to delete.
*/
deleteConversation(conversationId: string): Promise<void> {
return fs.rmdir(`conversations/${conversationId}`);
}
/**
* 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.
* @private
*/
private async getConversationDocs(): Promise<string[]> {
return fs.listFiles("conversations").then((files: string[]) => {
return Promise.all(files.filter((file) => file.startsWith("jan-")));
});
}
/**
* Parses a Markdown string and returns a Conversation object.
* @param markdown The Markdown string to parse.
* @private
*/
private parseConversationMarkdown(markdown: string): Conversation {
const conversation: Conversation = {
id: "",
name: "",
messages: [],
};
var currentMessage: Message | undefined = undefined;
for (const line of markdown.split("\n")) {
const trimmedLine = line.trim();
if (trimmedLine.startsWith("- id:")) {
conversation.id = trimmedLine.replace("- id:", "").trim();
} else if (trimmedLine.startsWith("- modelId:")) {
conversation.modelId = trimmedLine.replace("- modelId:", "").trim();
} else if (trimmedLine.startsWith("- name:")) {
conversation.name = trimmedLine.replace("- name:", "").trim();
} else if (trimmedLine.startsWith("- lastMessage:")) {
conversation.message = trimmedLine.replace("- lastMessage:", "").trim();
} else if (trimmedLine.startsWith("- summary:")) {
conversation.summary = trimmedLine.replace("- summary:", "").trim();
} else if (
trimmedLine.startsWith("- createdAt:") &&
currentMessage === undefined
) {
conversation.createdAt = trimmedLine.replace("- createdAt:", "").trim();
} else if (trimmedLine.startsWith("- updatedAt:")) {
conversation.updatedAt = trimmedLine.replace("- updatedAt:", "").trim();
} else if (trimmedLine.startsWith("- botId:")) {
conversation.botId = trimmedLine.replace("- botId:", "").trim();
} else if (trimmedLine.startsWith("- user:")) {
if (currentMessage)
currentMessage.user = trimmedLine.replace("- user:", "").trim();
} else if (trimmedLine.startsWith("- createdAt:")) {
if (currentMessage)
currentMessage.createdAt = trimmedLine
.replace("- createdAt:", "")
.trim();
currentMessage.updatedAt = currentMessage.createdAt;
} else if (trimmedLine.startsWith("- message:")) {
if (currentMessage)
currentMessage.message = trimmedLine.replace("- message:", "").trim();
} else if (trimmedLine.startsWith("- Message ")) {
const messageMatch = trimmedLine.match(/- Message (m-\d+):/);
if (messageMatch) {
if (currentMessage) {
conversation.messages.push(currentMessage);
}
currentMessage = { id: messageMatch[1] };
}
} else if (
currentMessage?.message &&
!trimmedLine.startsWith("## Messages")
) {
currentMessage.message = currentMessage.message + "\n" + line.trim();
} else if (trimmedLine.startsWith("## Messages")) {
currentMessage = undefined;
}
}
if (currentMessage) {
conversation.messages.push(currentMessage);
}
return conversation;
}
/**
* Loads a Conversation object from a Markdown file.
* @param filePath The path to the Markdown file.
* @private
*/
private async loadConversationFromMarkdownFile(
filePath: string
): Promise<Conversation | undefined> {
try {
const markdown: string = await fs.readFile(filePath);
return this.parseConversationMarkdown(markdown);
} catch (err) {
return undefined;
}
}
/**
* Generates a Markdown string from a Conversation object.
* @param conversation The Conversation object to generate Markdown from.
* @private
*/
private generateMarkdown(conversation: Conversation): string {
// Generate the Markdown content based on the Conversation object
const conversationMetadata = `
- id: ${conversation.id}
- modelId: ${conversation.modelId}
- name: ${conversation.name}
- lastMessage: ${conversation.message}
- summary: ${conversation.summary}
- createdAt: ${conversation.createdAt}
- updatedAt: ${conversation.updatedAt}
- botId: ${conversation.botId}
`;
const messages = conversation.messages.map(
(message) => `
- Message ${message.id}:
- createdAt: ${message.createdAt}
- user: ${message.user}
- message: ${message.message?.trim()}
`
);
return `## Conversation Metadata
${conversationMetadata}
## Messages
${messages.map((msg) => msg.trim()).join("\n")}
`;
}
/**
* Writes a Conversation object to a Markdown file.
* @param conversation The Conversation object to write to a Markdown file.
* @private
*/
private async writeMarkdownToFile(conversation: Conversation) {
// Generate the Markdown content
const markdownContent = this.generateMarkdown(conversation);
await fs.mkdir(`conversations/${conversation.id}`);
// Write the content to a Markdown file
await fs.writeFile(
`conversations/${conversation.id}/${conversation.id}.md`,
markdownContent
);
}
}

View File

@ -1,14 +0,0 @@
{
"compilerOptions": {
"target": "es2016",
"module": "ES6",
"moduleResolution": "node",
"outDir": "./dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": false,
"skipLibCheck": true,
"rootDir": "./src"
},
"include": ["./src"]
}

View File

@ -1,31 +0,0 @@
const path = require("path");
const webpack = require("webpack");
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({})],
resolve: {
extensions: [".ts", ".js"],
},
// Do not minify the output, otherwise it breaks the class registration
optimization: {
minimize: false,
},
// Add loaders and other configuration as needed for your project
};

View File

@ -7,10 +7,13 @@
*/ */
import { import {
ChatCompletionMessage,
ChatCompletionRole,
EventName, EventName,
MessageHistory, MessageRequest,
NewMessageRequest, MessageStatus,
PluginType, PluginType,
ThreadMessage,
events, events,
executeOnMain, executeOnMain,
} from "@janhq/core"; } from "@janhq/core";
@ -70,29 +73,19 @@ export default class JanInferencePlugin implements InferencePlugin {
/** /**
* Makes a single response inference request. * Makes a single response inference request.
* @param {NewMessageRequest} data - The data for the inference request. * @param {MessageRequest} data - The data for the inference request.
* @returns {Promise<any>} A promise that resolves with the inference response. * @returns {Promise<any>} A promise that resolves with the inference response.
*/ */
async inferenceRequest(data: NewMessageRequest): Promise<any> { async inferenceRequest(data: MessageRequest): Promise<any> {
const message = { const message = {
...data, ...data,
message: "", message: "",
user: "assistant", user: "assistant",
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; };
const prompts: [MessageHistory] = [
{
role: "user",
content: data.message,
},
];
const recentMessages = data.history ?? prompts;
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {
requestInference([ requestInference(data.messages ?? []).subscribe({
...recentMessages,
{ role: "user", content: data.message },
]).subscribe({
next: (content) => { next: (content) => {
message.message = content; message.message = content;
}, },
@ -108,37 +101,33 @@ export default class JanInferencePlugin implements InferencePlugin {
/** /**
* Handles a new message request by making an inference request and emitting events. * Handles a new message request by making an inference request and emitting events.
* @param {NewMessageRequest} data - The data for the new message request. * @param {MessageRequest} data - The data for the new message request.
*/ */
private async handleMessageRequest(data: NewMessageRequest) { private async handleMessageRequest(data: MessageRequest) {
const prompts: [MessageHistory] = [ const message: ThreadMessage = {
{ threadId: data.threadId,
role: "user", content: "",
content: data.message, role: ChatCompletionRole.Assistant,
},
];
const recentMessages = data.history ?? prompts;
const message = {
...data,
message: "",
user: "assistant",
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
id: ulid(), id: ulid(),
status: MessageStatus.Pending,
}; };
events.emit(EventName.OnNewMessageResponse, message); events.emit(EventName.OnNewMessageResponse, message);
requestInference(recentMessages).subscribe({ requestInference(data.messages).subscribe({
next: (content) => { next: (content) => {
message.message = content; message.content = content;
events.emit(EventName.OnMessageResponseUpdate, message); events.emit(EventName.OnMessageResponseUpdate, message);
}, },
complete: async () => { complete: async () => {
message.message = message.message.trim(); message.content = message.content.trim();
message.status = MessageStatus.Ready;
events.emit(EventName.OnMessageResponseFinished, message); events.emit(EventName.OnMessageResponseFinished, message);
}, },
error: async (err) => { error: async (err) => {
message.message = message.content =
message.message.trim() + "\n" + "Error occurred: " + err.message; message.content.trim() + "\n" + "Error occurred: " + err.message;
message.status = MessageStatus.Ready;
events.emit(EventName.OnMessageResponseUpdate, message); events.emit(EventName.OnMessageResponseUpdate, message);
}, },
}); });

View File

@ -42,7 +42,7 @@ export default class JanModelPlugin implements ModelPlugin {
*/ */
async downloadModel(model: Model): Promise<void> { async downloadModel(model: Model): Promise<void> {
// create corresponding directory // create corresponding directory
const directoryPath = join(JanModelPlugin._homeDir, model.productName) const directoryPath = join(JanModelPlugin._homeDir, model.name)
await fs.mkdir(directoryPath) await fs.mkdir(directoryPath)
// path to model binary // path to model binary
@ -72,7 +72,7 @@ export default class JanModelPlugin implements ModelPlugin {
* @returns A Promise that resolves when the model is saved. * @returns A Promise that resolves when the model is saved.
*/ */
async saveModel(model: Model): Promise<void> { async saveModel(model: Model): Promise<void> {
const directoryPath = join(JanModelPlugin._homeDir, model.productName) const directoryPath = join(JanModelPlugin._homeDir, model.name)
const jsonFilePath = join(directoryPath, `${model.id}.json`) const jsonFilePath = join(directoryPath, `${model.id}.json`)
try { try {

View File

@ -32,9 +32,9 @@ export default function ModalCancelDownload({
const { modelDownloadStateAtom } = useDownloadState() const { modelDownloadStateAtom } = useDownloadState()
useGetPerformanceTag() useGetPerformanceTag()
const downloadAtom = useMemo( const downloadAtom = useMemo(
() => atom((get) => get(modelDownloadStateAtom)[suitableModel.id]), () => atom((get) => get(modelDownloadStateAtom)[suitableModel.name]),
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[suitableModel.id] [suitableModel.name]
) )
const downloadState = useAtomValue(downloadAtom) const downloadState = useAtomValue(downloadAtom)

View File

@ -4,38 +4,31 @@ import { ReactNode, useEffect, useRef } from 'react'
import { import {
events, events,
EventName, EventName,
NewMessageResponse, ThreadMessage,
PluginType, PluginType,
ChatMessage, MessageStatus,
} from '@janhq/core' } from '@janhq/core'
import { Conversation, Message, MessageStatus } from '@janhq/core'
import { ConversationalPlugin, ModelPlugin } from '@janhq/core/lib/plugins' import { ConversationalPlugin, ModelPlugin } from '@janhq/core/lib/plugins'
import { useAtomValue, useSetAtom } from 'jotai' import { useAtomValue, useSetAtom } from 'jotai'
import { useDownloadState } from '@/hooks/useDownloadState' import { useDownloadState } from '@/hooks/useDownloadState'
import { useGetDownloadedModels } from '@/hooks/useGetDownloadedModels' import { useGetDownloadedModels } from '@/hooks/useGetDownloadedModels'
import { toChatMessage } from '@/utils/message'
import { import {
addNewMessageAtom, addNewMessageAtom,
chatMessages, chatMessages,
updateMessageAtom, updateMessageAtom,
} from '@/helpers/atoms/ChatMessage.atom' } from '@/helpers/atoms/ChatMessage.atom'
import { import {
updateConversationAtom,
updateConversationWaitingForResponseAtom, updateConversationWaitingForResponseAtom,
userConversationsAtom, userConversationsAtom,
} from '@/helpers/atoms/Conversation.atom' } from '@/helpers/atoms/Conversation.atom'
import { downloadingModelsAtom } from '@/helpers/atoms/Model.atom' import { downloadingModelsAtom } from '@/helpers/atoms/Model.atom'
import { pluginManager } from '@/plugin' import { pluginManager } from '@/plugin'
let currentConversation: Conversation | undefined = undefined
export default function EventHandler({ children }: { children: ReactNode }) { export default function EventHandler({ children }: { children: ReactNode }) {
const addNewMessage = useSetAtom(addNewMessageAtom) const addNewMessage = useSetAtom(addNewMessageAtom)
const updateMessage = useSetAtom(updateMessageAtom) const updateMessage = useSetAtom(updateMessageAtom)
const updateConversation = useSetAtom(updateConversationAtom)
const { setDownloadState, setDownloadStateSuccess } = useDownloadState() const { setDownloadState, setDownloadStateSuccess } = useDownloadState()
const { downloadedModels, setDownloadedModels } = useGetDownloadedModels() const { downloadedModels, setDownloadedModels } = useGetDownloadedModels()
@ -52,92 +45,55 @@ export default function EventHandler({ children }: { children: ReactNode }) {
convoRef.current = conversations convoRef.current = conversations
}, [messages, conversations]) }, [messages, conversations])
async function handleNewMessageResponse(message: NewMessageResponse) { async function handleNewMessageResponse(message: ThreadMessage) {
if (message.conversationId) { if (message.threadId) {
const convo = convoRef.current.find((e) => e.id == message.conversationId) const convo = convoRef.current.find((e) => e.id == message.threadId)
if (!convo) return if (!convo) return
const newResponse = toChatMessage(message) addNewMessage(message)
addNewMessage(newResponse)
} }
} }
async function handleMessageResponseUpdate( async function handleMessageResponseUpdate(messageResponse: ThreadMessage) {
messageResponse: NewMessageResponse
) {
if ( if (
messageResponse.conversationId && messageResponse.threadId &&
messageResponse.id && messageResponse.id &&
messageResponse.message messageResponse.content
) { ) {
updateMessage( updateMessage(
messageResponse.id, messageResponse.id,
messageResponse.conversationId, messageResponse.threadId,
messageResponse.message, messageResponse.content,
MessageStatus.Pending MessageStatus.Pending
) )
} }
if (messageResponse.conversationId) {
if (
!currentConversation ||
currentConversation.id !== messageResponse.conversationId
) {
if (convoRef.current && messageResponse.conversationId)
currentConversation = convoRef.current.find(
(e) => e.id == messageResponse.conversationId
)
} }
if (currentConversation) { async function handleMessageResponseFinished(messageResponse: ThreadMessage) {
const updatedConv: Conversation = { if (!messageResponse.threadId || !convoRef.current) return
...currentConversation, updateConvWaiting(messageResponse.threadId, false)
lastMessage: messageResponse.message,
}
updateConversation(updatedConv)
}
}
}
async function handleMessageResponseFinished(
messageResponse: NewMessageResponse
) {
if (!messageResponse.conversationId || !convoRef.current) return
updateConvWaiting(messageResponse.conversationId, false)
if ( if (
messageResponse.conversationId && messageResponse.threadId &&
messageResponse.id && messageResponse.id &&
messageResponse.message messageResponse.content
) { ) {
updateMessage( updateMessage(
messageResponse.id, messageResponse.id,
messageResponse.conversationId, messageResponse.threadId,
messageResponse.message, messageResponse.content,
MessageStatus.Ready MessageStatus.Ready
) )
} }
const convo = convoRef.current.find( const thread = convoRef.current.find(
(e) => e.id == messageResponse.conversationId (e) => e.id == messageResponse.threadId
)
if (convo) {
const messagesData = (messagesRef.current ?? [])[convo.id].map<Message>(
(e: ChatMessage) => ({
id: e.id,
message: e.text,
user: e.senderUid,
updatedAt: new Date(e.createdAt).toISOString(),
createdAt: new Date(e.createdAt).toISOString(),
})
) )
if (thread) {
pluginManager pluginManager
.get<ConversationalPlugin>(PluginType.Conversational) .get<ConversationalPlugin>(PluginType.Conversational)
?.saveConversation({ ?.saveConversation({
...convo, ...thread,
id: convo.id ?? '', id: thread.id ?? '',
name: convo.name ?? '', messages: messagesRef.current[thread.id] ?? [],
message: convo.lastMessage ?? '',
messages: messagesData,
}) })
} }
} }
@ -149,7 +105,7 @@ export default function EventHandler({ children }: { children: ReactNode }) {
function handleDownloadSuccess(state: any) { function handleDownloadSuccess(state: any) {
if (state && state.fileName && state.success === true) { if (state && state.fileName && state.success === true) {
state.fileName = state.fileName.replace('models/', '') state.fileName = state.fileName.split('/').pop() ?? ''
setDownloadStateSuccess(state.fileName) setDownloadStateSuccess(state.fileName)
const model = models.find((e) => e.id === state.fileName) const model = models.find((e) => e.id === state.fileName)
if (model) if (model)

View File

@ -27,6 +27,11 @@ export default function EventListenerWrapper({ children }: PropsWithChildren) {
const { setDownloadedModels, downloadedModels } = useGetDownloadedModels() const { setDownloadedModels, downloadedModels } = useGetDownloadedModels()
const { setDownloadState, setDownloadStateSuccess, setDownloadStateFailed } = const { setDownloadState, setDownloadStateSuccess, setDownloadStateFailed } =
useDownloadState() useDownloadState()
const downloadedModelRef = useRef(downloadedModels)
useEffect(() => {
downloadedModelRef.current = downloadedModels
}, [downloadedModels])
useEffect(() => { useEffect(() => {
if (window && window.electronAPI) { if (window && window.electronAPI) {
@ -60,7 +65,7 @@ export default function EventListenerWrapper({ children }: PropsWithChildren) {
.get<ModelPlugin>(PluginType.Model) .get<ModelPlugin>(PluginType.Model)
?.saveModel(model) ?.saveModel(model)
.then(() => { .then(() => {
setDownloadedModels([...downloadedModels, model]) setDownloadedModels([...downloadedModelRef.current, model])
}) })
} }
} }

View File

@ -1,17 +1,21 @@
import { ChatMessage, MessageStatus } from '@janhq/core' import { MessageStatus, ThreadMessage } from '@janhq/core'
import { atom } from 'jotai' import { atom } from 'jotai'
import { getActiveConvoIdAtom } from './Conversation.atom' import {
conversationStatesAtom,
getActiveConvoIdAtom,
updateThreadStateLastMessageAtom,
} from './Conversation.atom'
/** /**
* Stores all chat messages for all conversations * Stores all chat messages for all conversations
*/ */
export const chatMessages = atom<Record<string, ChatMessage[]>>({}) export const chatMessages = atom<Record<string, ThreadMessage[]>>({})
/** /**
* Return the chat messages for the current active conversation * Return the chat messages for the current active conversation
*/ */
export const getCurrentChatMessagesAtom = atom<ChatMessage[]>((get) => { export const getCurrentChatMessagesAtom = atom<ThreadMessage[]>((get) => {
const activeConversationId = get(getActiveConvoIdAtom) const activeConversationId = get(getActiveConvoIdAtom)
if (!activeConversationId) return [] if (!activeConversationId) return []
const messages = get(chatMessages)[activeConversationId] const messages = get(chatMessages)[activeConversationId]
@ -20,11 +24,11 @@ export const getCurrentChatMessagesAtom = atom<ChatMessage[]>((get) => {
export const setCurrentChatMessagesAtom = atom( export const setCurrentChatMessagesAtom = atom(
null, null,
(get, set, messages: ChatMessage[]) => { (get, set, messages: ThreadMessage[]) => {
const currentConvoId = get(getActiveConvoIdAtom) const currentConvoId = get(getActiveConvoIdAtom)
if (!currentConvoId) return if (!currentConvoId) return
const newData: Record<string, ChatMessage[]> = { const newData: Record<string, ThreadMessage[]> = {
...get(chatMessages), ...get(chatMessages),
} }
newData[currentConvoId] = messages newData[currentConvoId] = messages
@ -34,8 +38,8 @@ export const setCurrentChatMessagesAtom = atom(
export const setConvoMessagesAtom = atom( export const setConvoMessagesAtom = atom(
null, null,
(get, set, messages: ChatMessage[], convoId: string) => { (get, set, messages: ThreadMessage[], convoId: string) => {
const newData: Record<string, ChatMessage[]> = { const newData: Record<string, ThreadMessage[]> = {
...get(chatMessages), ...get(chatMessages),
} }
newData[convoId] = messages newData[convoId] = messages
@ -48,14 +52,14 @@ export const setConvoMessagesAtom = atom(
*/ */
export const addOldMessagesAtom = atom( export const addOldMessagesAtom = atom(
null, null,
(get, set, newMessages: ChatMessage[]) => { (get, set, newMessages: ThreadMessage[]) => {
const currentConvoId = get(getActiveConvoIdAtom) const currentConvoId = get(getActiveConvoIdAtom)
if (!currentConvoId) return if (!currentConvoId) return
const currentMessages = get(chatMessages)[currentConvoId] ?? [] const currentMessages = get(chatMessages)[currentConvoId] ?? []
const updatedMessages = [...currentMessages, ...newMessages] const updatedMessages = [...currentMessages, ...newMessages]
const newData: Record<string, ChatMessage[]> = { const newData: Record<string, ThreadMessage[]> = {
...get(chatMessages), ...get(chatMessages),
} }
newData[currentConvoId] = updatedMessages newData[currentConvoId] = updatedMessages
@ -65,23 +69,25 @@ export const addOldMessagesAtom = atom(
export const addNewMessageAtom = atom( export const addNewMessageAtom = atom(
null, null,
(get, set, newMessage: ChatMessage) => { (get, set, newMessage: ThreadMessage) => {
const currentConvoId = get(getActiveConvoIdAtom) const currentConvoId = get(getActiveConvoIdAtom)
if (!currentConvoId) return if (!currentConvoId) return
const currentMessages = get(chatMessages)[currentConvoId] ?? [] const currentMessages = get(chatMessages)[currentConvoId] ?? []
const updatedMessages = [newMessage, ...currentMessages] const updatedMessages = [newMessage, ...currentMessages]
const newData: Record<string, ChatMessage[]> = { const newData: Record<string, ThreadMessage[]> = {
...get(chatMessages), ...get(chatMessages),
} }
newData[currentConvoId] = updatedMessages newData[currentConvoId] = updatedMessages
set(chatMessages, newData) set(chatMessages, newData)
// Update thread last message
set(updateThreadStateLastMessageAtom, currentConvoId, newMessage.content)
} }
) )
export const deleteConversationMessage = atom(null, (get, set, id: string) => { export const deleteConversationMessage = atom(null, (get, set, id: string) => {
const newData: Record<string, ChatMessage[]> = { const newData: Record<string, ThreadMessage[]> = {
...get(chatMessages), ...get(chatMessages),
} }
newData[id] = [] newData[id] = []
@ -101,15 +107,17 @@ export const updateMessageAtom = atom(
const messages = get(chatMessages)[conversationId] ?? [] const messages = get(chatMessages)[conversationId] ?? []
const message = messages.find((e) => e.id === id) const message = messages.find((e) => e.id === id)
if (message) { if (message) {
message.text = text message.content = text
message.status = status message.status = status
const updatedMessages = [...messages] const updatedMessages = [...messages]
const newData: Record<string, ChatMessage[]> = { const newData: Record<string, ThreadMessage[]> = {
...get(chatMessages), ...get(chatMessages),
} }
newData[conversationId] = updatedMessages newData[conversationId] = updatedMessages
set(chatMessages, newData) set(chatMessages, newData)
// Update thread last message
set(updateThreadStateLastMessageAtom, conversationId, text)
} }
} }
) )
@ -130,14 +138,14 @@ export const updateLastMessageAsReadyAtom = atom(
if (!messageToUpdate) return if (!messageToUpdate) return
const index = currentMessages.indexOf(messageToUpdate) const index = currentMessages.indexOf(messageToUpdate)
const updatedMsg: ChatMessage = { const updatedMsg: ThreadMessage = {
...messageToUpdate, ...messageToUpdate,
status: MessageStatus.Ready, status: MessageStatus.Ready,
text: text, content: text,
} }
currentMessages[index] = updatedMsg currentMessages[index] = updatedMsg
const newData: Record<string, ChatMessage[]> = { const newData: Record<string, ThreadMessage[]> = {
...get(chatMessages), ...get(chatMessages),
} }
newData[currentConvoId] = currentMessages newData[currentConvoId] = currentMessages

View File

@ -1,6 +1,8 @@
import { Conversation, ConversationState } from '@janhq/core' import { Thread } from '@janhq/core'
import { atom } from 'jotai' import { atom } from 'jotai'
import { ThreadState } from '@/types/conversation'
/** /**
* Stores the current active conversation id. * Stores the current active conversation id.
*/ */
@ -19,11 +21,8 @@ export const waitingToSendMessage = atom<boolean | undefined>(undefined)
/** /**
* Stores all conversation states for the current user * Stores all conversation states for the current user
*/ */
export const conversationStatesAtom = atom<Record<string, ConversationState>>( export const conversationStatesAtom = atom<Record<string, ThreadState>>({})
{} export const currentConvoStateAtom = atom<ThreadState | undefined>((get) => {
)
export const currentConvoStateAtom = atom<ConversationState | undefined>(
(get) => {
const activeConvoId = get(activeConversationIdAtom) const activeConvoId = get(activeConversationIdAtom)
if (!activeConvoId) { if (!activeConvoId) {
console.debug('Active convo id is undefined') console.debug('Active convo id is undefined')
@ -31,11 +30,10 @@ export const currentConvoStateAtom = atom<ConversationState | undefined>(
} }
return get(conversationStatesAtom)[activeConvoId] return get(conversationStatesAtom)[activeConvoId]
} })
)
export const addNewConversationStateAtom = atom( export const addNewConversationStateAtom = atom(
null, null,
(get, set, conversationId: string, state: ConversationState) => { (get, set, conversationId: string, state: ThreadState) => {
const currentState = { ...get(conversationStatesAtom) } const currentState = { ...get(conversationStatesAtom) }
currentState[conversationId] = state currentState[conversationId] = state
set(conversationStatesAtom, currentState) set(conversationStatesAtom, currentState)
@ -73,16 +71,28 @@ export const updateConversationHasMoreAtom = atom(
} }
) )
export const updateThreadStateLastMessageAtom = atom(
null,
(get, set, conversationId: string, lastMessage?: string) => {
const currentState = { ...get(conversationStatesAtom) }
currentState[conversationId] = {
...currentState[conversationId],
lastMessage,
}
set(conversationStatesAtom, currentState)
}
)
export const updateConversationAtom = atom( export const updateConversationAtom = atom(
null, null,
(get, set, conversation: Conversation) => { (get, set, conversation: Thread) => {
const id = conversation.id const id = conversation.id
if (!id) return if (!id) return
const convo = get(userConversationsAtom).find((c) => c.id === id) const convo = get(userConversationsAtom).find((c) => c.id === id)
if (!convo) return if (!convo) return
const newConversations: Conversation[] = get(userConversationsAtom).map( const newConversations: Thread[] = get(userConversationsAtom).map((c) =>
(c) => (c.id === id ? conversation : c) c.id === id ? conversation : c
) )
// sort new conversations based on updated at // sort new conversations based on updated at
@ -99,7 +109,7 @@ export const updateConversationAtom = atom(
/** /**
* Stores all conversations for the current user * Stores all conversations for the current user
*/ */
export const userConversationsAtom = atom<Conversation[]>([]) export const userConversationsAtom = atom<Thread[]>([])
export const currentConversationAtom = atom<Conversation | undefined>((get) => export const currentConversationAtom = atom<Thread | undefined>((get) =>
get(userConversationsAtom).find((c) => c.id === get(getActiveConvoIdAtom)) get(userConversationsAtom).find((c) => c.id === get(getActiveConvoIdAtom))
) )

View File

@ -1,4 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { join } from 'path'
import { PluginType } from '@janhq/core' import { PluginType } from '@janhq/core'
import { InferencePlugin } from '@janhq/core/lib/plugins' import { InferencePlugin } from '@janhq/core/lib/plugins'
import { Model } from '@janhq/core/lib/types' import { Model } from '@janhq/core/lib/types'
@ -10,7 +12,6 @@ import { toaster } from '@/containers/Toast'
import { useGetDownloadedModels } from './useGetDownloadedModels' import { useGetDownloadedModels } from './useGetDownloadedModels'
import { pluginManager } from '@/plugin' import { pluginManager } from '@/plugin'
import { join } from 'path'
const activeAssistantModelAtom = atom<Model | undefined>(undefined) const activeAssistantModelAtom = atom<Model | undefined>(undefined)
@ -43,7 +44,7 @@ export function useActiveModel() {
const currentTime = Date.now() const currentTime = Date.now()
console.debug('Init model: ', modelId) console.debug('Init model: ', modelId)
const path = join('models', model.productName, modelId) const path = join('models', model.name, modelId)
const res = await initModel(path) const res = await initModel(path)
if (res?.error) { if (res?.error) {
const errorMessage = `${res.error}` const errorMessage = `${res.error}`

View File

@ -1,5 +1,5 @@
import { PluginType } from '@janhq/core' import { PluginType } from '@janhq/core'
import { Conversation, Model } from '@janhq/core' import { Thread, Model } from '@janhq/core'
import { ConversationalPlugin } from '@janhq/core/lib/plugins' import { ConversationalPlugin } from '@janhq/core/lib/plugins'
import { useAtom, useSetAtom } from 'jotai' import { useAtom, useSetAtom } from 'jotai'
@ -20,11 +20,11 @@ export const useCreateConversation = () => {
const addNewConvoState = useSetAtom(addNewConversationStateAtom) const addNewConvoState = useSetAtom(addNewConversationStateAtom)
const requestCreateConvo = async (model: Model) => { const requestCreateConvo = async (model: Model) => {
const conversationName = model.name const summary = model.name
const mappedConvo: Conversation = { const mappedConvo: Thread = {
id: generateConversationId(), id: generateConversationId(),
modelId: model.id, modelId: model.id,
name: conversationName, summary,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
messages: [], messages: [],
@ -37,11 +37,7 @@ export const useCreateConversation = () => {
pluginManager pluginManager
.get<ConversationalPlugin>(PluginType.Conversational) .get<ConversationalPlugin>(PluginType.Conversational)
?.saveConversation({ ?.saveConversation(mappedConvo)
...mappedConvo,
name: mappedConvo.name ?? '',
messages: [],
})
setUserConversations([mappedConvo, ...userConversations]) setUserConversations([mappedConvo, ...userConversations])
setActiveConvoId(mappedConvo.id) setActiveConvoId(mappedConvo.id)
} }

View File

@ -1,3 +1,5 @@
import { join } from 'path'
import { PluginType } from '@janhq/core' import { PluginType } from '@janhq/core'
import { ModelPlugin } from '@janhq/core/lib/plugins' import { ModelPlugin } from '@janhq/core/lib/plugins'
import { Model } from '@janhq/core/lib/types' import { Model } from '@janhq/core/lib/types'
@ -7,13 +9,12 @@ import { toaster } from '@/containers/Toast'
import { useGetDownloadedModels } from '@/hooks/useGetDownloadedModels' import { useGetDownloadedModels } from '@/hooks/useGetDownloadedModels'
import { pluginManager } from '@/plugin/PluginManager' import { pluginManager } from '@/plugin/PluginManager'
import { join } from 'path'
export default function useDeleteModel() { export default function useDeleteModel() {
const { setDownloadedModels, downloadedModels } = useGetDownloadedModels() const { setDownloadedModels, downloadedModels } = useGetDownloadedModels()
const deleteModel = async (model: Model) => { const deleteModel = async (model: Model) => {
const path = join('models', model.productName, model.id) const path = join('models', model.name, model.id)
await pluginManager.get<ModelPlugin>(PluginType.Model)?.deleteModel(path) await pluginManager.get<ModelPlugin>(PluginType.Model)?.deleteModel(path)
// reload models // reload models

View File

@ -7,6 +7,7 @@ import { useAtom } from 'jotai'
import { useDownloadState } from './useDownloadState' import { useDownloadState } from './useDownloadState'
import { downloadingModelsAtom } from '@/helpers/atoms/Model.atom' import { downloadingModelsAtom } from '@/helpers/atoms/Model.atom'
import { pluginManager } from '@/plugin/PluginManager' import { pluginManager } from '@/plugin/PluginManager'
export default function useDownloadModel() { export default function useDownloadModel() {
@ -20,28 +21,24 @@ export default function useDownloadModel() {
modelVersion: ModelVersion modelVersion: ModelVersion
): Model => { ): Model => {
return { return {
// eslint-disable-next-line @typescript-eslint/naming-convention /**
id: modelVersion.id, * Id will be used for the model file name
name: modelVersion.name, * Should be the version name
quantMethod: modelVersion.quantMethod, */
id: modelVersion.name,
name: model.name,
quantizationName: modelVersion.quantizationName,
bits: modelVersion.bits, bits: modelVersion.bits,
size: modelVersion.size, size: modelVersion.size,
maxRamRequired: modelVersion.maxRamRequired, maxRamRequired: modelVersion.maxRamRequired,
usecase: modelVersion.usecase, usecase: modelVersion.usecase,
downloadLink: modelVersion.downloadLink, downloadLink: modelVersion.downloadLink,
startDownloadAt: modelVersion.startDownloadAt,
finishDownloadAt: modelVersion.finishDownloadAt,
productId: model.id,
productName: model.name,
shortDescription: model.shortDescription, shortDescription: model.shortDescription,
longDescription: model.longDescription, longDescription: model.longDescription,
avatarUrl: model.avatarUrl, avatarUrl: model.avatarUrl,
author: model.author, author: model.author,
version: model.version, version: model.version,
modelUrl: model.modelUrl, modelUrl: model.modelUrl,
createdAt: new Date(model.createdAt).getTime(),
updatedAt: new Date(model.updatedAt ?? '').getTime(),
status: '',
releaseDate: -1, releaseDate: -1,
tags: model.tags, tags: model.tags,
} }
@ -53,7 +50,7 @@ export default function useDownloadModel() {
) => { ) => {
// set an initial download state // set an initial download state
setDownloadState({ setDownloadState({
modelId: modelVersion.id, modelId: modelVersion.name,
time: { time: {
elapsed: 0, elapsed: 0,
remaining: 0, remaining: 0,
@ -64,10 +61,9 @@ export default function useDownloadModel() {
total: 0, total: 0,
transferred: 0, transferred: 0,
}, },
fileName: modelVersion.id, fileName: modelVersion.name,
}) })
modelVersion.startDownloadAt = Date.now()
const assistantModel = assistanModel(model, modelVersion) const assistantModel = assistanModel(model, modelVersion)
setDownloadingModels([...downloadingModels, assistantModel]) setDownloadingModels([...downloadingModels, assistantModel])

View File

@ -1,8 +1,11 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Model, Conversation } from '@janhq/core'
import { Model, Thread } from '@janhq/core'
import { useAtomValue } from 'jotai' import { useAtomValue } from 'jotai'
import { useActiveModel } from './useActiveModel' import { useActiveModel } from './useActiveModel'
import { useGetDownloadedModels } from './useGetDownloadedModels' import { useGetDownloadedModels } from './useGetDownloadedModels'
import { currentConversationAtom } from '@/helpers/atoms/Conversation.atom' import { currentConversationAtom } from '@/helpers/atoms/Conversation.atom'
export default function useGetInputState() { export default function useGetInputState() {
@ -12,7 +15,7 @@ export default function useGetInputState() {
const { downloadedModels } = useGetDownloadedModels() const { downloadedModels } = useGetDownloadedModels()
const handleInputState = ( const handleInputState = (
convo: Conversation | undefined, convo: Thread | undefined,
currentModel: Model | undefined currentModel: Model | undefined
) => { ) => {
if (convo == null) return if (convo == null) return

View File

@ -1,16 +1,14 @@
import { PluginType, ChatMessage, ConversationState } from '@janhq/core' import { PluginType, Thread } from '@janhq/core'
import { ConversationalPlugin } from '@janhq/core/lib/plugins' import { ConversationalPlugin } from '@janhq/core/lib/plugins'
import { Conversation } from '@janhq/core/lib/types'
import { useSetAtom } from 'jotai' import { useSetAtom } from 'jotai'
import { toChatMessage } from '@/utils/message'
import { setConvoMessagesAtom } from '@/helpers/atoms/ChatMessage.atom' import { setConvoMessagesAtom } from '@/helpers/atoms/ChatMessage.atom'
import { import {
conversationStatesAtom, conversationStatesAtom,
userConversationsAtom, userConversationsAtom,
} from '@/helpers/atoms/Conversation.atom' } from '@/helpers/atoms/Conversation.atom'
import { pluginManager } from '@/plugin/PluginManager' import { pluginManager } from '@/plugin/PluginManager'
import { ThreadState } from '@/types/conversation'
const useGetUserConversations = () => { const useGetUserConversations = () => {
const setConversationStates = useSetAtom(conversationStatesAtom) const setConversationStates = useSetAtom(conversationStatesAtom)
@ -19,19 +17,17 @@ const useGetUserConversations = () => {
const getUserConversations = async () => { const getUserConversations = async () => {
try { try {
const convos: Conversation[] | undefined = await pluginManager const convos: Thread[] | undefined = await pluginManager
.get<ConversationalPlugin>(PluginType.Conversational) .get<ConversationalPlugin>(PluginType.Conversational)
?.getConversations() ?.getConversations()
const convoStates: Record<string, ConversationState> = {} const convoStates: Record<string, ThreadState> = {}
convos?.forEach((convo) => { convos?.forEach((convo) => {
convoStates[convo.id ?? ''] = { convoStates[convo.id ?? ''] = {
hasMore: true, hasMore: true,
waitingForResponse: false, waitingForResponse: false,
lastMessage: convo.messages[0]?.content ?? '',
} }
setConvoMessages( setConvoMessages(convo.messages, convo.id ?? '')
convo.messages.map<ChatMessage>((msg) => toChatMessage(msg)),
convo.id ?? ''
)
}) })
setConversationStates(convoStates) setConversationStates(convoStates)
setConversations(convos ?? []) setConversations(convos ?? [])

View File

@ -1,18 +1,21 @@
import { import {
ChatCompletionMessage,
ChatCompletionRole,
EventName, EventName,
MessageHistory, MessageRequest,
NewMessageRequest, MessageStatus,
PluginType, PluginType,
Thread,
ThreadMessage,
events, events,
ChatMessage,
Message,
Conversation,
MessageSenderType,
} from '@janhq/core' } from '@janhq/core'
import { ConversationalPlugin, InferencePlugin } from '@janhq/core/lib/plugins' import { ConversationalPlugin, InferencePlugin } from '@janhq/core/lib/plugins'
import { useAtom, useAtomValue, useSetAtom } from 'jotai' import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import { currentPromptAtom } from '@/containers/Providers/Jotai'
import { ulid } from 'ulid' import { ulid } from 'ulid'
import { currentPromptAtom } from '@/containers/Providers/Jotai'
import { import {
addNewMessageAtom, addNewMessageAtom,
getCurrentChatMessagesAtom, getCurrentChatMessagesAtom,
@ -23,7 +26,6 @@ import {
updateConversationWaitingForResponseAtom, updateConversationWaitingForResponseAtom,
} from '@/helpers/atoms/Conversation.atom' } from '@/helpers/atoms/Conversation.atom'
import { pluginManager } from '@/plugin/PluginManager' import { pluginManager } from '@/plugin/PluginManager'
import { toChatMessage } from '@/utils/message'
export default function useSendChatMessage() { export default function useSendChatMessage() {
const currentConvo = useAtomValue(currentConversationAtom) const currentConvo = useAtomValue(currentConversationAtom)
@ -35,7 +37,7 @@ export default function useSendChatMessage() {
let timeout: NodeJS.Timeout | undefined = undefined let timeout: NodeJS.Timeout | undefined = undefined
function updateConvSummary(newMessage: NewMessageRequest) { function updateConvSummary(newMessage: MessageRequest) {
if (timeout) { if (timeout) {
clearTimeout(timeout) clearTimeout(timeout)
} }
@ -46,13 +48,19 @@ export default function useSendChatMessage() {
currentConvo.summary === '' || currentConvo.summary === '' ||
currentConvo.summary.startsWith('Prompt:') currentConvo.summary.startsWith('Prompt:')
) { ) {
const summaryMsg: ChatCompletionMessage = {
role: ChatCompletionRole.User,
content:
'summary this conversation in 5 words, the response should just include the summary',
}
// Request convo summary // Request convo summary
setTimeout(async () => { setTimeout(async () => {
newMessage.message =
'summary this conversation in 5 words, the response should just include the summary'
const result = await pluginManager const result = await pluginManager
.get<InferencePlugin>(PluginType.Inference) .get<InferencePlugin>(PluginType.Inference)
?.inferenceRequest(newMessage) ?.inferenceRequest({
...newMessage,
messages: newMessage.messages?.concat([summaryMsg]),
})
if ( if (
result?.message && result?.message &&
@ -68,15 +76,7 @@ export default function useSendChatMessage() {
.get<ConversationalPlugin>(PluginType.Conversational) .get<ConversationalPlugin>(PluginType.Conversational)
?.saveConversation({ ?.saveConversation({
...updatedConv, ...updatedConv,
name: updatedConv.name ?? '', messages: currentMessages,
message: updatedConv.lastMessage ?? '',
messages: currentMessages.map<Message>((e: ChatMessage) => ({
id: e.id,
message: e.text,
user: e.senderUid,
updatedAt: new Date(e.createdAt).toISOString(),
createdAt: new Date(e.createdAt).toISOString(),
})),
}) })
} }
}, 1000) }, 1000)
@ -85,64 +85,60 @@ export default function useSendChatMessage() {
} }
const sendChatMessage = async () => { const sendChatMessage = async () => {
const convoId = currentConvo?.id const threadId = currentConvo?.id
if (!convoId) { if (!threadId) {
console.error('No conversation id') console.error('No conversation id')
return return
} }
setCurrentPrompt('') setCurrentPrompt('')
updateConvWaiting(convoId, true) updateConvWaiting(threadId, true)
const prompt = currentPrompt.trim() const prompt = currentPrompt.trim()
const messageHistory: MessageHistory[] = currentMessages const messages: ChatCompletionMessage[] = currentMessages
.map((msg) => ({ .map<ChatCompletionMessage>((msg) => ({
role: msg.senderUid, role: msg.role ?? ChatCompletionRole.User,
content: msg.text ?? '', content: msg.content ?? '',
})) }))
.reverse() .reverse()
.concat([ .concat([
{ {
role: MessageSenderType.User, role: ChatCompletionRole.User,
content: prompt, content: prompt,
} as MessageHistory, } as ChatCompletionMessage,
]) ])
const newMessage: NewMessageRequest = { const messageRequest: MessageRequest = {
id: ulid(), id: ulid(),
conversationId: convoId, threadId: threadId,
message: prompt, messages,
user: MessageSenderType.User,
createdAt: new Date().toISOString(),
history: messageHistory,
} }
const newChatMessage = toChatMessage(newMessage) const threadMessage: ThreadMessage = {
addNewMessage(newChatMessage) id: messageRequest.id,
threadId: messageRequest.threadId,
content: prompt,
role: ChatCompletionRole.User,
createdAt: new Date().toISOString(),
status: MessageStatus.Ready,
}
addNewMessage(threadMessage)
// delay randomly from 50 - 100ms // delay randomly from 50 - 100ms
// to prevent duplicate message id // to prevent duplicate message id
const delay = Math.floor(Math.random() * 50) + 50 const delay = Math.floor(Math.random() * 50) + 50
await new Promise((resolve) => setTimeout(resolve, delay)) await new Promise((resolve) => setTimeout(resolve, delay))
events.emit(EventName.OnNewMessageRequest, newMessage) events.emit(EventName.OnNewMessageRequest, messageRequest)
if (!currentConvo?.summary && currentConvo) { if (!currentConvo?.summary && currentConvo) {
const updatedConv: Conversation = { const updatedConv: Thread = {
...currentConvo, ...currentConvo,
lastMessage: prompt,
summary: `Prompt: ${prompt}`, summary: `Prompt: ${prompt}`,
} }
updateConversation(updatedConv) updateConversation(updatedConv)
} else if (currentConvo) {
const updatedConv: Conversation = {
...currentConvo,
lastMessage: prompt,
} }
updateConversation(updatedConv) updateConvSummary(messageRequest)
}
updateConvSummary(newMessage)
} }
return { return {

View File

@ -10,7 +10,7 @@ const ChatBody: React.FC = () => {
return ( return (
<div className="flex h-full w-full flex-col-reverse overflow-y-auto"> <div className="flex h-full w-full flex-col-reverse overflow-y-auto">
{messages.map((message) => ( {messages.map((message) => (
<ChatItem message={message} key={message.id} /> <ChatItem {...message} key={message.id} />
))} ))}
</div> </div>
) )

View File

@ -1,24 +1,14 @@
import React, { forwardRef } from 'react' import React, { forwardRef } from 'react'
import { ChatMessage } from '@janhq/core'
import SimpleTextMessage from '../SimpleTextMessage'
type Props = { import { ThreadMessage } from '@janhq/core'
message: ChatMessage
} import SimpleTextMessage from '../SimpleTextMessage'
type Ref = HTMLDivElement type Ref = HTMLDivElement
const ChatItem = forwardRef<Ref, Props>(({ message }, ref) => ( const ChatItem = forwardRef<Ref, ThreadMessage>((message, ref) => (
<div ref={ref} className="py-4 even:bg-secondary dark:even:bg-secondary/20"> <div ref={ref} className="py-4 even:bg-secondary dark:even:bg-secondary/20">
<SimpleTextMessage <SimpleTextMessage {...message} />
status={message.status}
key={message.id}
avatarUrl={message.senderAvatarUrl}
senderName={message.senderName}
createdAt={message.createdAt}
senderType={message.messageSenderType}
text={message.text}
/>
</div> </div>
)) ))

View File

@ -1,6 +1,6 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { Conversation, Model } from '@janhq/core/lib/types' import { Thread, Model } from '@janhq/core'
import { Button } from '@janhq/uikit' import { Button } from '@janhq/uikit'
import { motion as m } from 'framer-motion' import { motion as m } from 'framer-motion'
import { useAtomValue, useSetAtom } from 'jotai' import { useAtomValue, useSetAtom } from 'jotai'
@ -17,6 +17,7 @@ import useGetUserConversations from '@/hooks/useGetUserConversations'
import { displayDate } from '@/utils/datetime' import { displayDate } from '@/utils/datetime'
import { import {
conversationStatesAtom,
getActiveConvoIdAtom, getActiveConvoIdAtom,
setActiveConvoIdAtom, setActiveConvoIdAtom,
userConversationsAtom, userConversationsAtom,
@ -24,6 +25,7 @@ import {
export default function HistoryList() { export default function HistoryList() {
const conversations = useAtomValue(userConversationsAtom) const conversations = useAtomValue(userConversationsAtom)
const threadStates = useAtomValue(conversationStatesAtom)
const { getUserConversations } = useGetUserConversations() const { getUserConversations } = useGetUserConversations()
const { activeModel, startModel } = useActiveModel() const { activeModel, startModel } = useActiveModel()
const { requestCreateConvo } = useCreateConversation() const { requestCreateConvo } = useCreateConversation()
@ -41,7 +43,7 @@ export default function HistoryList() {
return return
} }
const handleActiveModel = async (convo: Conversation) => { const handleActiveModel = async (convo: Thread) => {
if (convo.modelId == null) { if (convo.modelId == null) {
console.debug('modelId is undefined') console.debug('modelId is undefined')
return return
@ -83,6 +85,7 @@ export default function HistoryList() {
</div> </div>
) : ( ) : (
conversations.map((convo, i) => { conversations.map((convo, i) => {
const lastMessage = threadStates[convo.id]?.lastMessage
return ( return (
<div <div
key={i} key={i}
@ -90,15 +93,18 @@ export default function HistoryList() {
'relative flex cursor-pointer flex-col border-b border-border px-4 py-2 hover:bg-secondary/20', 'relative flex cursor-pointer flex-col border-b border-border px-4 py-2 hover:bg-secondary/20',
activeConvoId === convo.id && 'bg-secondary-10' activeConvoId === convo.id && 'bg-secondary-10'
)} )}
onClick={() => handleActiveModel(convo as Conversation)} onClick={() => handleActiveModel(convo as Thread)}
> >
<p className="mb-1 line-clamp-1 text-xs leading-5"> <p className="mb-1 line-clamp-1 text-xs leading-5">
{convo.updatedAt && {convo.updatedAt &&
displayDate(new Date(convo.updatedAt).getTime())} displayDate(new Date(convo.updatedAt).getTime())}
</p> </p>
<h2 className="line-clamp-1">{convo.summary ?? convo.name}</h2> <h2 className="line-clamp-1">{convo.summary}</h2>
<p className="mt-1 line-clamp-2 text-xs"> <p className="mt-1 line-clamp-2 text-xs">
{convo?.lastMessage ?? 'No new message'} {/* TODO: Check latest message update */}
{lastMessage && lastMessage.length > 0
? lastMessage
: 'No new message'}
</p> </p>
{activeModel && activeConvoId === convo.id && ( {activeModel && activeConvoId === convo.id && (
<m.div <m.div

View File

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */
import React, { useState } from 'react' import React from 'react'
import { MessageSenderType, MessageStatus } from '@janhq/core' import { ChatCompletionRole, ThreadMessage } from '@janhq/core'
import hljs from 'highlight.js' import hljs from 'highlight.js'
import { Marked } from 'marked' import { Marked } from 'marked'
@ -16,15 +16,6 @@ import BubbleLoader from '@/containers/Loader/Bubble'
import { displayDate } from '@/utils/datetime' import { displayDate } from '@/utils/datetime'
type Props = {
avatarUrl: string
senderName: string
createdAt: number
senderType: MessageSenderType
status: MessageStatus
text?: string
}
const marked = new Marked( const marked = new Marked(
markedHighlight({ markedHighlight({
langPrefix: 'hljs', langPrefix: 'hljs',
@ -50,16 +41,9 @@ const marked = new Marked(
} }
) )
const SimpleTextMessage: React.FC<Props> = ({ const SimpleTextMessage: React.FC<ThreadMessage> = (props) => {
senderName, const parsedText = marked.parse(props.content ?? '')
senderType, const isUser = props.role === ChatCompletionRole.User
createdAt,
// will use status as streaming text
// status,
text = '',
}) => {
const parsedText = marked.parse(text)
const isUser = senderType === 'user'
return ( return (
<div className="mx-auto rounded-xl px-4 lg:w-3/4"> <div className="mx-auto rounded-xl px-4 lg:w-3/4">
@ -70,12 +54,12 @@ const SimpleTextMessage: React.FC<Props> = ({
)} )}
> >
{!isUser && <LogoMark width={20} />} {!isUser && <LogoMark width={20} />}
<div className="text-sm font-extrabold ">{senderName}</div> <div className="text-sm font-extrabold capitalize">{props.role}</div>
<p className="text-xs font-medium">{displayDate(createdAt)}</p> <p className="text-xs font-medium">{displayDate(props.createdAt)}</p>
</div> </div>
<div className={twMerge('w-full')}> <div className={twMerge('w-full')}>
{text === '' ? ( {!props.content || props.content === '' ? (
<BubbleLoader /> <BubbleLoader />
) : ( ) : (
<> <>

View File

@ -126,7 +126,7 @@ const ChatScreen = () => {
{isEnableChat && currentConvo && ( {isEnableChat && currentConvo && (
<div className="h-[53px] flex-shrink-0 border-b border-border bg-background p-4"> <div className="h-[53px] flex-shrink-0 border-b border-border bg-background p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span>{currentConvo?.name ?? ''}</span> <span>{currentConvo?.summary ?? ''}</span>
<div <div
className={twMerge( className={twMerge(
'flex items-center space-x-3', 'flex items-center space-x-3',

View File

@ -33,7 +33,7 @@ const ExploreModelItem = forwardRef<HTMLDivElement, Props>(({ model }, ref) => {
return null return null
} }
const { quantMethod, bits, maxRamRequired, usecase } = suitableModel const { quantizationName, bits, maxRamRequired, usecase } = suitableModel
return ( return (
<div <div
@ -73,7 +73,9 @@ const ExploreModelItem = forwardRef<HTMLDivElement, Props>(({ model }, ref) => {
<span className="font-semibold">Version</span> <span className="font-semibold">Version</span>
<div className="mt-2 flex space-x-2"> <div className="mt-2 flex space-x-2">
<Badge themes="outline">v{model.version}</Badge> <Badge themes="outline">v{model.version}</Badge>
{quantMethod && <Badge themes="outline">{quantMethod}</Badge>} {quantizationName && (
<Badge themes="outline">{quantizationName}</Badge>
)}
<Badge themes="outline">{`${bits} Bits`}</Badge> <Badge themes="outline">{`${bits} Bits`}</Badge>
</div> </div>
</div> </div>
@ -105,7 +107,7 @@ const ExploreModelItem = forwardRef<HTMLDivElement, Props>(({ model }, ref) => {
<ModelVersionList <ModelVersionList
model={model} model={model}
versions={model.availableVersions} versions={model.availableVersions}
recommendedVersion={suitableModel?.id ?? ''} recommendedVersion={suitableModel?.name ?? ''}
/> />
)} )}
</div> </div>

View File

@ -35,8 +35,8 @@ const ExploreModelItemHeader: React.FC<Props> = ({
const { performanceTag, title, getPerformanceForModel } = const { performanceTag, title, getPerformanceForModel } =
useGetPerformanceTag() useGetPerformanceTag()
const downloadAtom = useMemo( const downloadAtom = useMemo(
() => atom((get) => get(modelDownloadStateAtom)[suitableModel.id]), () => atom((get) => get(modelDownloadStateAtom)[suitableModel.name]),
[suitableModel.id] [suitableModel.name]
) )
const downloadState = useAtomValue(downloadAtom) const downloadState = useAtomValue(downloadAtom)
const { setMainViewState } = useMainViewState() const { setMainViewState } = useMainViewState()
@ -51,8 +51,9 @@ const ExploreModelItemHeader: React.FC<Props> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [exploreModel, suitableModel]) }, [exploreModel, suitableModel])
// TODO: Comparing between Model Id and Version Name?
const isDownloaded = const isDownloaded =
downloadedModels.find((model) => model.id === suitableModel.id) != null downloadedModels.find((model) => model.id === suitableModel.name) != null
let downloadButton = ( let downloadButton = (
<Button onClick={() => onDownloadClick()}> <Button onClick={() => onDownloadClick()}>

View File

@ -1,4 +1,5 @@
import { ModelCatalog } from '@janhq/core/lib/types' import { ModelCatalog } from '@janhq/core/lib/types'
import ExploreModelItem from '@/screens/ExploreModels/ExploreModelItem' import ExploreModelItem from '@/screens/ExploreModels/ExploreModelItem'
type Props = { type Props = {
@ -7,7 +8,9 @@ type Props = {
const ExploreModelList: React.FC<Props> = ({ models }) => ( const ExploreModelList: React.FC<Props> = ({ models }) => (
<div className="relative h-full w-full flex-shrink-0"> <div className="relative h-full w-full flex-shrink-0">
{models?.map((item, i) => <ExploreModelItem key={item.id} model={item} />)} {models?.map((item, i) => (
<ExploreModelItem key={item.name + '/' + item.id} model={item} />
))}
</div> </div>
) )

View File

@ -1,15 +1,20 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
import { ModelCatalog, ModelVersion } from '@janhq/core/lib/types' import { ModelCatalog, ModelVersion } from '@janhq/core/lib/types'
import { Button } from '@janhq/uikit' import { Button } from '@janhq/uikit'
import { Badge } from '@janhq/uikit' import { Badge } from '@janhq/uikit'
import { atom, useAtomValue } from 'jotai' import { atom, useAtomValue } from 'jotai'
import ModalCancelDownload from '@/containers/ModalCancelDownload' import ModalCancelDownload from '@/containers/ModalCancelDownload'
import { MainViewState } from '@/constants/screens' import { MainViewState } from '@/constants/screens'
import useDownloadModel from '@/hooks/useDownloadModel' import useDownloadModel from '@/hooks/useDownloadModel'
import { useDownloadState } from '@/hooks/useDownloadState' import { useDownloadState } from '@/hooks/useDownloadState'
import { useGetDownloadedModels } from '@/hooks/useGetDownloadedModels' import { useGetDownloadedModels } from '@/hooks/useGetDownloadedModels'
import { useMainViewState } from '@/hooks/useMainViewState' import { useMainViewState } from '@/hooks/useMainViewState'
import { toGigabytes } from '@/utils/converter' import { toGigabytes } from '@/utils/converter'
type Props = { type Props = {
@ -23,13 +28,13 @@ const ModelVersionItem: React.FC<Props> = ({ model, modelVersion }) => {
const { downloadedModels } = useGetDownloadedModels() const { downloadedModels } = useGetDownloadedModels()
const { setMainViewState } = useMainViewState() const { setMainViewState } = useMainViewState()
const isDownloaded = const isDownloaded =
downloadedModels.find((model) => model.id === modelVersion.id) != null downloadedModels.find((model) => model.id === modelVersion.name) != null
const { modelDownloadStateAtom, downloadStates } = useDownloadState() const { modelDownloadStateAtom, downloadStates } = useDownloadState()
const downloadAtom = useMemo( const downloadAtom = useMemo(
() => atom((get) => get(modelDownloadStateAtom)[modelVersion.id ?? '']), () => atom((get) => get(modelDownloadStateAtom)[modelVersion.name ?? '']),
[modelVersion.id] [modelVersion.name]
) )
const downloadState = useAtomValue(downloadAtom) const downloadState = useAtomValue(downloadAtom)

View File

@ -17,10 +17,10 @@ export default function ModelVersionList({
<div className="pt-4"> <div className="pt-4">
{versions.map((item) => ( {versions.map((item) => (
<ModelVersionItem <ModelVersionItem
key={item.id} key={item.name}
model={model} model={model}
modelVersion={item} modelVersion={item}
isRecommended={item.id === recommendedVersion} isRecommended={item.name === recommendedVersion}
/> />
))} ))}
</div> </div>

View File

@ -76,7 +76,7 @@ const MyModelsScreen = () => {
<h2 className="mb-1 font-medium capitalize"> <h2 className="mb-1 font-medium capitalize">
{model.author} {model.author}
</h2> </h2>
<p className="line-clamp-1">{model.productName}</p> <p className="line-clamp-1">{model.name}</p>
<div className="mt-2 flex items-center gap-2"> <div className="mt-2 flex items-center gap-2">
<Badge themes="secondary">v{model.version}</Badge> <Badge themes="secondary">v{model.version}</Badge>
<Badge themes="outline">GGUF</Badge> <Badge themes="outline">GGUF</Badge>
@ -101,7 +101,7 @@ const MyModelsScreen = () => {
<ModalTitle>Are you sure?</ModalTitle> <ModalTitle>Are you sure?</ModalTitle>
</ModalHeader> </ModalHeader>
<p className="leading-relaxed"> <p className="leading-relaxed">
Delete model {model.productName}, v{model.version},{' '} Delete model {model.name}, v{model.version},{' '}
{toGigabytes(model.size)}. {toGigabytes(model.size)}.
</p> </p>
<ModalFooter> <ModalFooter>

6
web/types/conversation.d.ts vendored Normal file
View File

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

View File

@ -1,53 +0,0 @@
type Task =
| 'text-classification'
| 'token-classification'
| 'table-question-answering'
| 'question-answering'
| 'zero-shot-classification'
| 'translation'
| 'summarization'
| 'conversational'
| 'feature-extraction'
| 'text-generation'
| 'text2text-generation'
| 'fill-mask'
| 'sentence-similarity'
| 'text-to-speech'
| 'automatic-speech-recognition'
| 'audio-to-audio'
| 'audio-classification'
| 'voice-activity-detection'
| 'depth-estimation'
| 'image-classification'
| 'object-detection'
| 'image-segmentation'
| 'text-to-image'
| 'image-to-text'
| 'image-to-image'
| 'unconditional-image-generation'
| 'video-classification'
| 'reinforcement-learning'
| 'robotics'
| 'tabular-classification'
| 'tabular-regression'
| 'tabular-to-text'
| 'table-to-text'
| 'multiple-choice'
| 'text-retrieval'
| 'time-series-forecasting'
| 'visual-question-answering'
| 'document-question-answering'
| 'zero-shot-image-classification'
| 'graph-ml'
| 'other'
type SearchModelParamHf = {
search?: {
owner?: string
task?: Task
}
credentials?: {
accessToken: string
}
limit: number
}

11
web/types/users.d.ts vendored
View File

@ -1,11 +0,0 @@
export enum Role {
User = 'user',
Assistant = 'assistant',
}
type User = {
id: string
displayName: string
avatarUrl: string
email?: string
}

View File

@ -3,11 +3,11 @@ export const isToday = (timestamp: number) => {
return today.setHours(0, 0, 0, 0) == new Date(timestamp).setHours(0, 0, 0, 0) return today.setHours(0, 0, 0, 0) == new Date(timestamp).setHours(0, 0, 0, 0)
} }
export const displayDate = (timestamp?: number) => { export const displayDate = (timestamp?: string | number | Date) => {
if (!timestamp) return 'N/A' if (!timestamp) return 'N/A'
let displayDate = new Date(timestamp).toLocaleString() let displayDate = new Date(timestamp).toLocaleString()
if (isToday(timestamp)) { if (typeof timestamp === 'number' && isToday(timestamp)) {
displayDate = new Date(timestamp).toLocaleTimeString(undefined, { displayDate = new Date(timestamp).toLocaleTimeString(undefined, {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',

View File

@ -7,18 +7,37 @@ export const dummyModel: ModelCatalog = {
shortDescription: 'TinyLlama-1.1B-Chat-v0.3-GGUF', shortDescription: 'TinyLlama-1.1B-Chat-v0.3-GGUF',
longDescription: 'https://huggingface.co/aladar/TinyLLama-v0-GGUF/tree/main', longDescription: 'https://huggingface.co/aladar/TinyLLama-v0-GGUF/tree/main',
avatarUrl: '', avatarUrl: '',
status: '',
releaseDate: Date.now(), releaseDate: Date.now(),
author: 'aladar', author: 'aladar',
version: '1.0.0', version: '1.0.0',
modelUrl: 'aladar/TinyLLama-v0-GGUF', modelUrl: 'aladar/TinyLLama-v0-GGUF',
tags: ['freeform', 'tags'], tags: ['freeform', 'tags'],
createdAt: 0,
availableVersions: [ availableVersions: [
{ {
id: 'tinyllama-1.1b-chat-v0.3.Q2_K.gguf', name: 'TinyLLama-v0.Q8_0.gguf',
name: 'tinyllama-1.1b-chat-v0.3.Q2_K.gguf', quantizationName: '',
quantMethod: '', bits: 2,
size: 5816320,
maxRamRequired: 256000000,
usecase:
'smallest, significant quality loss - not recommended for most purposes',
downloadLink:
'https://huggingface.co/aladar/TinyLLama-v0-GGUF/resolve/main/TinyLLama-v0.Q8_0.gguf',
},
{
name: 'TinyLLama-v0.f16.gguf',
quantizationName: '',
bits: 2,
size: 10240000,
maxRamRequired: 256000000,
usecase:
'smallest, significant quality loss - not recommended for most purposes',
downloadLink:
'https://huggingface.co/aladar/TinyLLama-v0-GGUF/resolve/main/TinyLLama-v0.f16.gguf',
},
{
name: 'TinyLLama-v0.f32.gguf',
quantizationName: '',
bits: 2, bits: 2,
size: 19660000, size: 19660000,
maxRamRequired: 256000000, maxRamRequired: 256000000,
@ -26,6 +45,6 @@ export const dummyModel: ModelCatalog = {
'smallest, significant quality loss - not recommended for most purposes', 'smallest, significant quality loss - not recommended for most purposes',
downloadLink: downloadLink:
'https://huggingface.co/aladar/TinyLLama-v0-GGUF/resolve/main/TinyLLama-v0.f32.gguf', 'https://huggingface.co/aladar/TinyLLama-v0-GGUF/resolve/main/TinyLLama-v0.f32.gguf',
} as ModelVersion, },
], ],
} }

View File

@ -1,48 +0,0 @@
import {
ChatMessage,
Message,
MessageSenderType,
MessageStatus,
MessageType,
NewMessageResponse,
RawMessage,
} from '@janhq/core'
export const toChatMessage = (
m: RawMessage | Message | NewMessageResponse,
conversationId?: string
): ChatMessage => {
const createdAt = new Date(m.createdAt ?? '').getTime()
const imageUrls: string[] = []
const imageUrl = undefined
if (imageUrl) {
imageUrls.push(imageUrl)
}
const messageType = MessageType.Text
const messageSenderType =
m.user === 'user' ? MessageSenderType.User : MessageSenderType.Ai
const content = m.message ?? ''
const senderName = m.user === 'user' ? 'You' : 'Assistant'
return {
id: (m.id ?? 0).toString(),
conversationId: (
(m as RawMessage | NewMessageResponse)?.conversationId ??
conversationId ??
0
).toString(),
messageType: messageType,
messageSenderType: messageSenderType,
senderUid: m.user?.toString() || '0',
senderName: senderName,
senderAvatarUrl:
m.user === 'user' ? 'icons/avatar.svg' : 'icons/app_icon.svg',
text: content,
imageUrls: imageUrls,
createdAt: createdAt,
status: MessageStatus.Ready,
}
}