Merge branch 'main' of https://github.com/janhq/jan into 768-fs-adapter-calling-server-api-for-handling-fs

This commit is contained in:
Linh Tran 2023-12-04 21:38:06 +07:00
commit e5885d8772
106 changed files with 1712 additions and 1021 deletions

View File

@ -1,4 +1,4 @@
name: Jan Build Electron App Nightly
name: Jan Build Electron App Nightly or Manual
on:
schedule:
@ -173,8 +173,9 @@ jobs:
name: jan-linux-amd64-${{ steps.version_update.outputs.new_version }}.deb
path: ./electron/dist/*.deb
noti-discord:
noti-discord-nightly:
needs: [build-macos, build-windows-x64, build-linux-x64]
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
steps:
- name: Notify Discord
@ -183,3 +184,15 @@ jobs:
args: "Nightly build artifact: https://github.com/janhq/jan/actions/runs/{{ GITHUB_RUN_ID }}"
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
noti-discord-manual:
needs: [build-macos, build-windows-x64, build-linux-x64]
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- name: Notify Discord
uses: Ilshidur/action-discord@master
with:
args: "Manual build artifact: https://github.com/janhq/jan/actions/runs/{{ GITHUB_RUN_ID }}"
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}

View File

@ -55,23 +55,17 @@ As Jan is development mode, you might get stuck on a broken build.
To reset your installation:
1. Delete Jan from your `/Applications` folder
1. **Remove Jan from your Applications folder and Cache folder**
1. Delete Application data:
```sh
# Newer versions
rm -rf /Users/$(whoami)/Library/Application\ Support/jan
# Versions 0.2.0 and older
rm -rf /Users/$(whoami)/Library/Application\ Support/jan-electron
```
1. Clear Application cache:
```sh
rm -rf /Users/$(whoami)/Library/Caches/jan*
```bash
make clean
```
1. Use the following commands to remove any dangling backend processes:
This will remove all build artifacts and cached files:
- Delete Jan from your `/Applications` folder
- Clear Application cache in `/Users/$(whoami)/Library/Caches/jan`
2. Use the following commands to remove any dangling backend processes:
```sh
ps aux | grep nitro
@ -124,6 +118,22 @@ make build
This will build the app MacOS m1/m2 for production (with code signing already done) and put the result in `dist` folder.
## Nightly Build
Nightly build is a process where the software is built automatically every night. This helps in detecting and fixing bugs early in the development cycle. The process for this project is defined in [`.github/workflows/jan-electron-build-nightly.yml`](.github/workflows/jan-electron-build-nightly.yml)
You can join our Discord server [here](https://discord.gg/FTk2MvZwJH) and go to channel [github-jan](https://discordapp.com/channels/1107178041848909847/1148534730359308298) to monitor the build process.
The nightly build is triggered at 2:00 AM UTC every day.
The nightly build can be downloaded from the url notified in the Discord channel. Please access the url from the browser and download the build artifacts from there.
## Manual Build
Manual build is a process where the software is built manually by the developers. This is usually done when a new feature is implemented or a bug is fixed. The process for this project is defined in [`.github/workflows/jan-electron-build-nightly.yml`](.github/workflows/jan-electron-build-nightly.yml)
It is similar to the nightly build process, except that it is triggered manually by the developers.
## Acknowledgements
Jan builds on top of other open-source projects:

View File

@ -143,6 +143,7 @@ export type ThreadAssistantInfo = {
assistant_id: string;
assistant_name: string;
model: ModelInfo;
instructions?: string;
};
/**
@ -288,13 +289,13 @@ export type Assistant = {
/** Represents the name of the object. */
name: string;
/** Represents the description of the object. */
description: string;
description?: string;
/** Represents the model of the object. */
model: string;
/** Represents the instructions for the object. */
instructions: string;
instructions?: string;
/** Represents the tools associated with the object. */
tools: any;
tools?: any;
/** Represents the file identifiers associated with the object. */
file_ids: string[];
/** Represents the metadata of the object. */

View File

@ -1,5 +1,7 @@
---
title: About Jan
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords: [Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee]
---
Jan believes in the need for an open source AI ecosystem, and are building the infra and tooling to allow open source AIs to compete on a level playing field with proprietary ones.

View File

@ -1,5 +1,7 @@
---
title: Community
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords: [Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee]
---
- [ ] Social media links

View File

@ -1,3 +1,5 @@
---
title: Build an Assistant
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords: [Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee]
---

View File

@ -1,5 +1,7 @@
---
title: Extending Jan
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords: [Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee]
---
## Overview

View File

@ -1,3 +1,5 @@
---
title: Model Management
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords: [Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee]
---

View File

@ -1,3 +1,5 @@
---
title: Build a Module
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords: [Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee]
---

View File

@ -1,5 +1,7 @@
---
title: API Server
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords: [Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee]
---
:::warning

View File

@ -1,3 +1,5 @@
---
title: Build a Theme
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords: [Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee]
---

View File

@ -1,3 +1,5 @@
---
title: Build a Tool
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords: [Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee]
---

View File

@ -1,5 +1,7 @@
---
title: Engineering
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords: [Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee]
---
## Connecting to Rigs

View File

@ -1,6 +1,8 @@
---
title: Onboarding Checklist
slug: /handbook
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords: [Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee]
---
# Welcome

View File

@ -1,5 +1,7 @@
---
title: Hardware Examples
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords: [Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee]
---
## Add your own example

View File

@ -1,5 +1,7 @@
---
title: From Source
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords: [Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee]
---
# Install Jan from Source

View File

@ -1,5 +1,7 @@
---
title: Linux
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords: [Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee]
---
# Installing Jan on Linux

View File

@ -1,5 +1,7 @@
---
title: Mac
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords: [Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee]
---
# Installing Jan on MacOS

View File

@ -1,5 +1,7 @@
---
title: Overview
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords: [Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee]
---
Getting up and running open-source AI models on your own computer with Jan is quick and easy. Jan is lightweight and can run on a variety of hardware and platform versions. Specific requirements tailored to your platform are outlined below.

View File

@ -1,5 +1,7 @@
---
title: Windows
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords: [Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee]
---
# Installing Jan on Windows

View File

@ -1,5 +1,7 @@
---
title: How Jan Works
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords: [Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee]
---
- Local Filesystem

View File

@ -1,6 +1,8 @@
---
title: Introduction
slug: /docs
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords: [Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee]
---
Jan is a ChatGPT-alternative that runs on your own computer, with a [local API server](/api).

View File

@ -1,5 +1,7 @@
---
title: Quickstart
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords: [Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee]
---
- Write in the style of comics, explanation

View File

@ -1,6 +1,8 @@
---
title: Architecture
slug: /specs
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords: [Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee]
---
:::warning

View File

@ -1,6 +1,8 @@
---
title: "Assistants"
slug: /specs/assistants
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords: [Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee]
---
:::caution

View File

@ -1,6 +1,8 @@
---
title: Chats
slug: /specs/chats
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords: [Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee]
---
:::caution

View File

@ -0,0 +1,60 @@
---
title: Engine
slug: /specs/engine
---
:::caution
Currently Under Development
:::
## Overview
In the Jan application, engines serve as primary entities with the following capabilities:
- Engine will be installed through `inference-extensions`.
- Models will depend on engines to do [inference](https://en.wikipedia.org/wiki/Inference_engine).
- Engine configuration and required metadata will be stored in a json file.
## Folder Structure
- Default parameters for engines are stored in JSON files located in the `/engines` folder.
- These parameter files are named uniquely with `engine_id`.
- Engines are referenced directly using `engine_id` in the `model.json` file.
```yaml
jan/
engines/
nitro.json
openai.json
.....
```
## Engine Default Parameter Files
- Each inference engine requires default parameters to function in cases where user-provided parameters are absent.
- These parameters are stored in JSON files, structured as simple key-value pairs.
### Example
Here is an example of an engine file for `engine_id` `nitro`:
```js
{
"ctx_len": 512,
"ngl": 100,
"embedding": false,
"n_parallel": 1,
"cont_batching": false
"prompt_template": "<|im_start|>system\n{system_message}<|im_end|>\n<|im_start|>user\n{prompt}<|im_end|>\n<|im_start|>assistant"
}
```
For detailed engine parameters, refer to: [Nitro's Model Settings](https://nitro.jan.ai/features/load-unload#table-of-parameters)
## Adding an Engine
- Engine parameter files are automatically generated upon installing an `inference-extension` in the Jan application.
---

View File

@ -1,6 +1,8 @@
---
title: "Files"
slug: /specs/files
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords: [Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee]
---
:::warning

View File

@ -1,6 +1,8 @@
---
title: "Fine-tuning"
slug: /specs/finetuning
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords: [Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee]
---
Todo: @hiro

View File

@ -1,6 +1,8 @@
---
title: Messages
slug: /specs/messages
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords: [Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee]
---
:::caution

View File

@ -1,6 +1,8 @@
---
title: Models
slug: /specs/models
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords: [Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee]
---
:::caution
@ -51,9 +53,9 @@ jan/ # Jan root folder
Here's a standard example `model.json` for a GGUF model.
- `source_url`: https://huggingface.co/TheBloke/zephyr-7B-beta-GGUF/.
```js
{
"id": "zephyr-7b", // Defaults to foldername
"object": "model", // Defaults to "model"
"source_url": "https://huggingface.co/TheBloke/zephyr-7B-beta-GGUF/blob/main/zephyr-7b-beta.Q4_K_M.gguf",
@ -62,15 +64,16 @@ Here's a standard example `model.json` for a GGUF model.
"version": "1", // Defaults to 1
"created": 1231231, // Defaults to file creation time
"description": null, // Defaults to null
"state": enum[null, "downloading", "ready", "starting", "stopping", ...]
"state": enum[null, "ready"]
"format": "ggufv3", // Defaults to "ggufv3"
"settings": { // Models are initialized with settings
"ctx_len": 2048,
"engine": "nitro", // engine_id specified in jan/engine folder
"engine_parameters": { // Engine parameters inside model.json can override
"ctx_len": 2048, // the value inside the base engine.json
"ngl": 100,
"embedding": true,
"n_parallel": 4,
},
"parameters": { // Models are called parameters
"model_parameters": { // Models are called parameters
"stream": true,
"max_tokens": 2048,
"stop": ["<endofstring>"], // This usually can be left blank, only used with specific need from model author
@ -83,9 +86,10 @@ Here's a standard example `model.json` for a GGUF model.
"assets": [ // Defaults to current dir
"file://.../zephyr-7b-q4_k_m.bin",
]
}
```
The model settings in the example can be found at: [Nitro's model settings](https://nitro.jan.ai/features/load-unload#table-of-parameters)
The engine parameters in the example can be found at: [Nitro's model settings](https://nitro.jan.ai/features/load-unload#table-of-parameters)
The model parameters in the example can be found at: [Nitro's model parameters](https://nitro.jan.ai/api-reference#tag/Chat-Completion)

View File

@ -1,6 +1,8 @@
---
title: Prompts
slug: /specs/prompts
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords: [Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee]
---
- [ ] /prompts folder

View File

@ -1,6 +1,8 @@
---
title: Threads
slug: /specs/threads
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords: [Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee]
---
:::caution

View File

@ -1,5 +1,7 @@
---
title: File-based Approach
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords: [Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee]
---
:::warning

View File

@ -1,5 +1,7 @@
---
title: Jan (Assistant)
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords: [Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee]
---
## Jan: a "global" assistant

View File

@ -1,6 +1,8 @@
---
title: Chat
slug: /specs/chat
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords: [Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee]
---
## Overview

View File

@ -1,6 +1,8 @@
---
title: Hub
slug: /specs/hub
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords: [Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee]
---
## Overview

View File

@ -1,6 +1,8 @@
---
title: Settings
slug: /specs/settings
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords: [Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee]
---
## Overview

View File

@ -1,6 +1,8 @@
---
title: System Monitor
slug: /specs/system-monitor
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords: [Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee]
---
## Overview

View File

@ -1,5 +1,7 @@
---
title: User Interface
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords: [Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee]
---
:::warning

View File

@ -38,6 +38,8 @@ const config = {
mermaid: true,
},
noIndex: false,
// Plugins we added
plugins: [
"docusaurus-plugin-sass",
@ -140,15 +142,44 @@ const config = {
metadata: [
{ name: 'description', content: 'Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.' },
{ name: 'keywords', content: 'Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee' },
{ name: 'robots', content: 'index, follow' },
{ property: 'og:title', content: 'Run your own AI | Jan' },
{ property: 'og:description', content: 'Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.' },
{ property: 'og:image', content: 'https://jan.ai/img/jan-social-card.png' },
{ property: 'og:type', content: 'website' },
{ property: 'twitter:card', content: 'summary_large_image' },
{ property: 'twitter:site', content: '@janhq_' },
{ property: 'twitter:title', content: 'Run your own AI | Jan' },
{ property: 'twitter:description', content: 'Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.' },
{ property: 'twitter:image', content: 'https://jan.ai/img/jan-social-card.png' },
],
headTags: [
// Declare a <link> preconnect tag
{
tagName: 'link',
attributes: {
rel: 'preconnect',
href: 'https://jan.ai/',
},
},
// Declare some json-ld structured data
{
tagName: 'script',
attributes: {
type: 'application/ld+json',
},
innerHTML: JSON.stringify({
'@context': 'https://schema.org/',
'@type': 'localAI',
name: 'Jan',
description: "Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.",
keywords: "Jan, ChatGPT alternative, on-premises AI, local API server, local AI, llm, conversational AI, no-subscription fee",
applicationCategory: "BusinessApplication",
operatingSystem: "Multiple",
url: 'https://jan.ai/',
}),
},
],
navbar: {
title: "Jan",
logo: {

View File

@ -81,6 +81,7 @@ const sidebars = {
items: [
"specs/engineering/chats",
"specs/engineering/models",
"specs/engineering/engine",
"specs/engineering/threads",
"specs/engineering/messages",
"specs/engineering/assistants",

View File

@ -19,7 +19,7 @@ export default function Home() {
<AnnoncementBanner />
<Layout
title={`${siteConfig.tagline}`}
description="Jan runs Large Language Models locally on Windows, Mac and Linux. Available on Desktop and Cloud-Native."
description="Jan is a ChatGPT-alternative that runs on your own computer, with a local API server."
>
<main className="bg-gray-50 dark:bg-gray-950/95 relative">
<div className="relative">

2
docs/static/robots.txt vendored Normal file
View File

@ -0,0 +1,2 @@
User-Agent: *
Allow: /

View File

@ -1,15 +1,15 @@
import { BrowserWindow } from "electron";
import { BrowserWindow } from 'electron'
/**
* Manages the current window instance.
*/
export class WindowManager {
public static instance: WindowManager = new WindowManager();
public currentWindow?: BrowserWindow;
public static instance: WindowManager = new WindowManager()
public currentWindow?: BrowserWindow
constructor() {
if (WindowManager.instance) {
return WindowManager.instance;
return WindowManager.instance
}
}
@ -21,17 +21,17 @@ export class WindowManager {
createWindow(options?: Electron.BrowserWindowConstructorOptions | undefined) {
this.currentWindow = new BrowserWindow({
width: 1200,
minWidth: 800,
minWidth: 1200,
height: 800,
show: false,
trafficLightPosition: {
x: 10,
y: 15,
},
titleBarStyle: "hidden",
vibrancy: "sidebar",
titleBarStyle: 'hidden',
vibrancy: 'sidebar',
...options,
});
return this.currentWindow;
})
return this.currentWindow
}
}

View File

@ -1,41 +1,41 @@
import { _electron as electron } from "playwright";
import { ElectronApplication, Page, expect, test } from "@playwright/test";
import { _electron as electron } from 'playwright'
import { ElectronApplication, Page, expect, test } from '@playwright/test'
import {
findLatestBuild,
parseElectronApp,
stubDialog,
} from "electron-playwright-helpers";
} from 'electron-playwright-helpers'
let electronApp: ElectronApplication;
let page: Page;
let electronApp: ElectronApplication
let page: Page
test.beforeAll(async () => {
process.env.CI = "e2e";
process.env.CI = 'e2e'
const latestBuild = findLatestBuild("dist");
expect(latestBuild).toBeTruthy();
const latestBuild = findLatestBuild('dist')
expect(latestBuild).toBeTruthy()
// parse the packaged Electron app and find paths and other info
const appInfo = parseElectronApp(latestBuild);
expect(appInfo).toBeTruthy();
const appInfo = parseElectronApp(latestBuild)
expect(appInfo).toBeTruthy()
electronApp = await electron.launch({
args: [appInfo.main], // main file from package.json
executablePath: appInfo.executable, // path to the Electron executable
});
await stubDialog(electronApp, "showMessageBox", { response: 1 });
})
await stubDialog(electronApp, 'showMessageBox', { response: 1 })
page = await electronApp.firstWindow();
});
page = await electronApp.firstWindow()
})
test.afterAll(async () => {
await electronApp.close();
await page.close();
});
await electronApp.close()
await page.close()
})
test("explores models", async () => {
await page.getByTestId("Explore Models").first().click();
await page.getByTestId("testid-explore-models").isVisible();
test('explores models', async () => {
await page.getByTestId('Hub').first().click()
await page.getByTestId('testid-explore-models').isVisible()
// More test cases here...
});
})

View File

@ -1,55 +1,55 @@
import { _electron as electron } from "playwright";
import { ElectronApplication, Page, expect, test } from "@playwright/test";
import { _electron as electron } from 'playwright'
import { ElectronApplication, Page, expect, test } from '@playwright/test'
import {
findLatestBuild,
parseElectronApp,
stubDialog,
} from "electron-playwright-helpers";
} from 'electron-playwright-helpers'
let electronApp: ElectronApplication;
let page: Page;
let electronApp: ElectronApplication
let page: Page
test.beforeAll(async () => {
process.env.CI = "e2e";
process.env.CI = 'e2e'
const latestBuild = findLatestBuild("dist");
expect(latestBuild).toBeTruthy();
const latestBuild = findLatestBuild('dist')
expect(latestBuild).toBeTruthy()
// parse the packaged Electron app and find paths and other info
const appInfo = parseElectronApp(latestBuild);
expect(appInfo).toBeTruthy();
expect(appInfo.asar).toBe(true);
expect(appInfo.executable).toBeTruthy();
expect(appInfo.main).toBeTruthy();
expect(appInfo.name).toBe("jan");
expect(appInfo.packageJson).toBeTruthy();
expect(appInfo.packageJson.name).toBe("jan");
expect(appInfo.platform).toBeTruthy();
expect(appInfo.platform).toBe(process.platform);
expect(appInfo.resourcesDir).toBeTruthy();
const appInfo = parseElectronApp(latestBuild)
expect(appInfo).toBeTruthy()
expect(appInfo.asar).toBe(true)
expect(appInfo.executable).toBeTruthy()
expect(appInfo.main).toBeTruthy()
expect(appInfo.name).toBe('jan')
expect(appInfo.packageJson).toBeTruthy()
expect(appInfo.packageJson.name).toBe('jan')
expect(appInfo.platform).toBeTruthy()
expect(appInfo.platform).toBe(process.platform)
expect(appInfo.resourcesDir).toBeTruthy()
electronApp = await electron.launch({
args: [appInfo.main], // main file from package.json
executablePath: appInfo.executable, // path to the Electron executable
});
await stubDialog(electronApp, "showMessageBox", { response: 1 });
})
await stubDialog(electronApp, 'showMessageBox', { response: 1 })
page = await electronApp.firstWindow();
});
page = await electronApp.firstWindow()
})
test.afterAll(async () => {
await electronApp.close();
await page.close();
});
await electronApp.close()
await page.close()
})
test("renders the home page", async () => {
expect(page).toBeDefined();
test('renders the home page', async () => {
expect(page).toBeDefined()
// Welcome text is available
const welcomeText = await page
.getByTestId("testid-welcome-title")
.getByTestId('testid-welcome-title')
.first()
.isVisible();
expect(welcomeText).toBe(false);
});
.isVisible()
expect(welcomeText).toBe(false)
})

View File

@ -1,41 +0,0 @@
import { _electron as electron } from "playwright";
import { ElectronApplication, Page, expect, test } from "@playwright/test";
import {
findLatestBuild,
parseElectronApp,
stubDialog,
} from "electron-playwright-helpers";
let electronApp: ElectronApplication;
let page: Page;
test.beforeAll(async () => {
process.env.CI = "e2e";
const latestBuild = findLatestBuild("dist");
expect(latestBuild).toBeTruthy();
// parse the packaged Electron app and find paths and other info
const appInfo = parseElectronApp(latestBuild);
expect(appInfo).toBeTruthy();
electronApp = await electron.launch({
args: [appInfo.main], // main file from package.json
executablePath: appInfo.executable, // path to the Electron executable
});
await stubDialog(electronApp, "showMessageBox", { response: 1 });
page = await electronApp.firstWindow();
});
test.afterAll(async () => {
await electronApp.close();
await page.close();
});
test("shows my models", async () => {
await page.getByTestId("My Models").first().click();
await page.getByTestId("testid-my-models").isVisible();
// More test cases here...
});

View File

@ -1,43 +1,43 @@
import { _electron as electron } from "playwright";
import { ElectronApplication, Page, expect, test } from "@playwright/test";
import { _electron as electron } from 'playwright'
import { ElectronApplication, Page, expect, test } from '@playwright/test'
import {
findLatestBuild,
parseElectronApp,
stubDialog,
} from "electron-playwright-helpers";
} from 'electron-playwright-helpers'
let electronApp: ElectronApplication;
let page: Page;
let electronApp: ElectronApplication
let page: Page
test.beforeAll(async () => {
process.env.CI = "e2e";
process.env.CI = 'e2e'
const latestBuild = findLatestBuild("dist");
expect(latestBuild).toBeTruthy();
const latestBuild = findLatestBuild('dist')
expect(latestBuild).toBeTruthy()
// parse the packaged Electron app and find paths and other info
const appInfo = parseElectronApp(latestBuild);
expect(appInfo).toBeTruthy();
const appInfo = parseElectronApp(latestBuild)
expect(appInfo).toBeTruthy()
electronApp = await electron.launch({
args: [appInfo.main], // main file from package.json
executablePath: appInfo.executable, // path to the Electron executable
});
await stubDialog(electronApp, "showMessageBox", { response: 1 });
})
await stubDialog(electronApp, 'showMessageBox', { response: 1 })
page = await electronApp.firstWindow();
});
page = await electronApp.firstWindow()
})
test.afterAll(async () => {
await electronApp.close();
await page.close();
});
await electronApp.close()
await page.close()
})
test("renders left navigation panel", async () => {
test('renders left navigation panel', async () => {
// Chat section should be there
const chatSection = await page.getByTestId("Chat").first().isVisible();
expect(chatSection).toBe(false);
const chatSection = await page.getByTestId('Chat').first().isVisible()
expect(chatSection).toBe(false)
// Home actions
/* Disable unstable feature tests
@ -45,7 +45,10 @@ test("renders left navigation panel", async () => {
** Enable back when it is whitelisted
*/
const myModelsBtn = await page.getByTestId("My Models").first().isEnabled();
const settingsBtn = await page.getByTestId("Settings").first().isEnabled();
expect([myModelsBtn, settingsBtn].filter((e) => !e).length).toBe(0);
});
const systemMonitorBtn = await page
.getByTestId('System Monitor')
.first()
.isEnabled()
const settingsBtn = await page.getByTestId('Settings').first().isEnabled()
expect([systemMonitorBtn, settingsBtn].filter((e) => !e).length).toBe(0)
})

View File

@ -1,40 +1,40 @@
import { _electron as electron } from "playwright";
import { ElectronApplication, Page, expect, test } from "@playwright/test";
import { _electron as electron } from 'playwright'
import { ElectronApplication, Page, expect, test } from '@playwright/test'
import {
findLatestBuild,
parseElectronApp,
stubDialog,
} from "electron-playwright-helpers";
} from 'electron-playwright-helpers'
let electronApp: ElectronApplication;
let page: Page;
let electronApp: ElectronApplication
let page: Page
test.beforeAll(async () => {
process.env.CI = "e2e";
process.env.CI = 'e2e'
const latestBuild = findLatestBuild("dist");
expect(latestBuild).toBeTruthy();
const latestBuild = findLatestBuild('dist')
expect(latestBuild).toBeTruthy()
// parse the packaged Electron app and find paths and other info
const appInfo = parseElectronApp(latestBuild);
expect(appInfo).toBeTruthy();
const appInfo = parseElectronApp(latestBuild)
expect(appInfo).toBeTruthy()
electronApp = await electron.launch({
args: [appInfo.main], // main file from package.json
executablePath: appInfo.executable, // path to the Electron executable
});
await stubDialog(electronApp, "showMessageBox", { response: 1 });
})
await stubDialog(electronApp, 'showMessageBox', { response: 1 })
page = await electronApp.firstWindow();
});
page = await electronApp.firstWindow()
})
test.afterAll(async () => {
await electronApp.close();
await page.close();
});
await electronApp.close()
await page.close()
})
test("shows settings", async () => {
await page.getByTestId("Settings").first().click();
await page.getByTestId("testid-setting-description").isVisible();
});
test('shows settings', async () => {
await page.getByTestId('Settings').first().click()
await page.getByTestId('testid-setting-description').isVisible()
})

View File

@ -0,0 +1,41 @@
import { _electron as electron } from 'playwright'
import { ElectronApplication, Page, expect, test } from '@playwright/test'
import {
findLatestBuild,
parseElectronApp,
stubDialog,
} from 'electron-playwright-helpers'
let electronApp: ElectronApplication
let page: Page
test.beforeAll(async () => {
process.env.CI = 'e2e'
const latestBuild = findLatestBuild('dist')
expect(latestBuild).toBeTruthy()
// parse the packaged Electron app and find paths and other info
const appInfo = parseElectronApp(latestBuild)
expect(appInfo).toBeTruthy()
electronApp = await electron.launch({
args: [appInfo.main], // main file from package.json
executablePath: appInfo.executable, // path to the Electron executable
})
await stubDialog(electronApp, 'showMessageBox', { response: 1 })
page = await electronApp.firstWindow()
})
test.afterAll(async () => {
await electronApp.close()
await page.close()
})
test('shows system monitor', async () => {
await page.getByTestId('System Monitor').first().click()
await page.getByTestId('testid-system-monitor').isVisible()
// More test cases here...
})

View File

@ -89,12 +89,12 @@ export default class JanAssistantExtension implements AssistantExtension {
private async createJanAssistant(): Promise<void> {
const janAssistant: Assistant = {
avatar: "",
thread_location: undefined, // TODO: make this property ?
thread_location: undefined,
id: "jan",
object: "assistant", // TODO: maybe we can set default value for this?
created_at: Date.now(),
name: "Jan Assistant",
description: "Just Jan Assistant",
name: "Jan",
description: "A default assistant that can use all downloaded models",
model: "*",
instructions: "Your name is Jan.",
tools: undefined,

View File

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

View File

@ -1 +1 @@
0.1.17
0.1.20

View File

@ -30,11 +30,14 @@
"build:uikit": "yarn workspace @janhq/uikit install && yarn workspace @janhq/uikit build",
"build:core": "cd core && yarn install && yarn run build",
"build:web": "yarn workspace jan-web build && cpx \"web/out/**\" \"electron/renderer/\"",
"build:electron": "yarn workspace jan build && cpx \"models/**\" \"electron/models/\"",
"build:electron": "cpx \"models/**\" \"electron/models/\" && yarn workspace jan build",
"build:electron:test": "yarn workspace jan build:test",
"build:extensions": "rimraf ./electron/pre-install/*.tgz && concurrently --kill-others-on-fail \"cd ./extensions/conversational-extension && npm install && npm run build:publish\" \"cd ./extensions/inference-extension && npm install && npm run build:publish\" \"cd ./extensions/model-extension && npm install && npm run build:publish\" \"cd ./extensions/monitoring-extension && npm install && npm run build:publish\" \"cd ./extensions/assistant-extension && npm install && npm run build:publish\"",
"build:extensions:windows": "rimraf ./electron/pre-install/*.tgz && powershell -command \"$jobs = Get-ChildItem -Path './extensions' -Directory | ForEach-Object { Start-Job -Name ($_.Name) -ScriptBlock { param($_dir); try { Set-Location $_dir; npm install; npm run build:publish; Write-Output 'Build successful in ' + $_dir } catch { Write-Error 'Error in ' + $_dir; throw } } -ArgumentList $_.FullName }; $jobs | Wait-Job; $jobs | ForEach-Object { Receive-Job -Job $_ -Keep } | ForEach-Object { Write-Host $_ }; $failed = $jobs | Where-Object { $_.State -ne 'Completed' -or $_.ChildJobs[0].JobStateInfo.State -ne 'Completed' }; if ($failed) { Exit 1 }\"",
"build:extensions:linux": "rimraf ./electron/pre-install/*.tgz && find ./extensions -mindepth 1 -maxdepth 1 -type d -print0 | xargs -0 -n 1 -P 4 -I {} sh -c 'cd {} && npm install && npm run build:publish'",
"build:extensions:darwin": "rimraf ./electron/pre-install/*.tgz && find ./extensions -mindepth 1 -maxdepth 1 -type d -print0 | xargs -0 -n 1 -P 4 -I {} sh -c 'cd {} && npm install && npm run build:publish'",
"build:extensions": "run-script-os",
"build:test": "yarn build:web && yarn workspace jan build:test",
"build": "yarn build:web && yarn workspace jan build",
"build": "yarn build:web && yarn build:electron",
"build:publish": "yarn build:web && yarn workspace jan build:publish"
},
"devDependencies": {

View File

@ -20,9 +20,11 @@
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-context": "^1.0.1",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-progress": "^1.0.3",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-toast": "^1.1.5",

View File

@ -6,7 +6,7 @@
}
&-success {
@apply border-transparent bg-green-500 text-green-900 hover:bg-green-500/80;
@apply border-transparent bg-green-100 text-green-600;
}
&-secondary {

View File

@ -25,7 +25,7 @@
}
&-list-item {
@apply text-foreground aria-selected:bg-primary relative flex cursor-pointer select-none items-center rounded-md px-2 py-2 text-sm outline-none;
@apply text-foreground aria-selected:bg-secondary relative flex cursor-pointer select-none items-center rounded-md px-2 py-2 text-sm outline-none;
}
&-empty {

View File

@ -10,3 +10,4 @@ export * from './tooltip'
export * from './modal'
export * from './command'
export * from './textarea'
export * from './select'

View File

@ -9,7 +9,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
return (
<input
type={type}
className={twMerge('input test', className)}
className={twMerge('input', className)}
ref={ref}
{...props}
/>

View File

@ -14,6 +14,7 @@
@import './modal/styles.scss';
@import './command/styles.scss';
@import './textarea/styles.scss';
@import './select/styles.scss';
.animate-spin {
animation: spin 1s linear infinite;
@ -104,7 +105,3 @@
--secondary-foreground: 210 20% 98%;
}
}
:is(p) {
@apply text-muted-foreground;
}

139
uikit/src/select/index.tsx Normal file
View File

@ -0,0 +1,139 @@
'use client'
import * as React from 'react'
import {
CaretSortIcon,
// CheckIcon,
ChevronDownIcon,
ChevronUpIcon,
} from '@radix-ui/react-icons'
import * as SelectPrimitive from '@radix-ui/react-select'
import { twMerge } from 'tailwind-merge'
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={twMerge('select', className)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<CaretSortIcon className="select-caret" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={twMerge('select-scroll-up-button', className)}
{...props}
>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={twMerge('select-scroll-down-button', className)}
{...props}
>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={twMerge(
'select-content',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={twMerge(
'select-trigger-viewport',
position === 'popper' && 'w-full'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={twMerge('select-label', className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={twMerge('select-item', className)}
{...props}
>
{/* <span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span> */}
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@ -0,0 +1,31 @@
.select {
@apply ring-offset-background placeholder:text-muted-foreground border-border flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1;
&-caret {
@apply h-4 w-4 opacity-50;
}
&-scroll-up-button {
@apply flex cursor-default items-center justify-center py-1;
}
&-scroll-down-button {
@apply flex cursor-default items-center justify-center py-1;
}
&-label {
@apply px-2 py-1.5 text-sm font-semibold;
}
&-item {
@apply hover:bg-secondary relative my-1 block w-full cursor-pointer select-none items-center rounded-sm px-4 py-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50;
}
&-trigger-viewport {
@apply w-full py-1;
}
&-content {
@apply bg-background border-border relative z-50 mt-1 block max-h-96 w-full min-w-[8rem] overflow-hidden rounded-md border shadow-md;
}
}

View File

@ -15,7 +15,7 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: PropsWithChildren) {
return (
<html lang="en" suppressHydrationWarning>
<body className="bg-background/50 font-sans text-sm antialiased">
<body className="bg-white font-sans text-sm antialiased dark:bg-background/50">
<div className="title-bar" />
<Providers>{children}</Providers>
</body>

View File

@ -8,29 +8,25 @@ import { useMainViewState } from '@/hooks/useMainViewState'
import ChatScreen from '@/screens/Chat'
import ExploreModelsScreen from '@/screens/ExploreModels'
import MyModelsScreen from '@/screens/MyModels'
import SettingsScreen from '@/screens/Settings'
import WelcomeScreen from '@/screens/Welcome'
import SystemMonitorScreen from '@/screens/SystemMonitor'
export default function Page() {
const { mainViewState } = useMainViewState()
let children = null
switch (mainViewState) {
case MainViewState.Welcome:
children = <WelcomeScreen />
break
case MainViewState.ExploreModels:
case MainViewState.Hub:
children = <ExploreModelsScreen />
break
case MainViewState.MyModels:
children = <MyModelsScreen />
case MainViewState.Settings:
children = <SettingsScreen />
break
case MainViewState.Setting:
children = <SettingsScreen />
case MainViewState.SystemMonitor:
children = <SystemMonitorScreen />
break
default:

View File

@ -1,7 +1,7 @@
export enum MainViewState {
Welcome,
ExploreModels,
Hub,
MyModels,
Setting,
Chat,
Settings,
Thread,
SystemMonitor,
}

View File

@ -1,13 +1,15 @@
import { ReactNode, useState } from 'react'
import { Fragment } from 'react'
import { ReactNode, useState, useRef } from 'react'
import { Menu, Transition } from '@headlessui/react'
import {
ChevronDownIcon,
EllipsisVerticalIcon,
} from '@heroicons/react/20/solid'
MoreVerticalIcon,
FolderOpenIcon,
Code2Icon,
} from 'lucide-react'
import { twMerge } from 'tailwind-merge'
import { useClickOutside } from '@/hooks/useClickOutside'
interface Props {
children: ReactNode
title: string
@ -21,65 +23,75 @@ export default function CardSidebar({
onViewJsonClick,
}: Props) {
const [show, setShow] = useState(true)
const [more, setMore] = useState(false)
const [menu, setMenu] = useState<HTMLDivElement | null>(null)
const [toggle, setToggle] = useState<HTMLDivElement | null>(null)
useClickOutside(() => setMore(false), null, [menu, toggle])
return (
<div className="flex w-full flex-col">
<div className="flex items-center rounded-lg border border-border">
<div
className={twMerge(
'flex w-full flex-col rounded-lg border border-border',
show && 'border border-border'
)}
>
<div
className={twMerge(
'relative flex items-center rounded-t-md bg-zinc-200 dark:bg-zinc-600/10',
show && 'border-b border-border'
)}
>
<button
onClick={() => setShow(!show)}
className="flex w-full flex-1 items-center py-2"
className="flex w-full flex-1 items-center space-x-2 px-3 py-2"
>
<ChevronDownIcon
className={`h-5 w-5 flex-none text-gray-400 ${
show && 'rotate-180'
}`}
className={twMerge(
'h-5 w-5 flex-none rotate-180 text-gray-400',
show && 'rotate-0'
)}
/>
<span className="text-xs uppercase">{title}</span>
<span className="font-bold">{title}</span>
</button>
<Menu as="div" className="relative flex-none">
<Menu.Button className="-m-2.5 block p-2.5 text-gray-500 hover:text-gray-900">
<span className="sr-only">Open options</span>
<EllipsisVerticalIcon className="h-5 w-5" aria-hidden="true" />
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
<div
ref={setToggle}
className="cursor-pointer bg-zinc-200 p-2 dark:bg-zinc-600/10"
onClick={() => setMore(!more)}
>
<MoreVerticalIcon className="h-5 w-5" />
</div>
{more && (
<div
className="absolute right-0 top-8 z-20 w-52 overflow-hidden rounded-lg border border-border bg-background shadow-lg"
ref={setMenu}
>
<Menu.Items className="absolute right-0 z-10 mt-2 w-32 origin-top-right rounded-md bg-white py-2 shadow-lg ring-1 ring-gray-900/5 focus:outline-none">
<Menu.Item>
{({ active }) => (
<a
onClick={() => onRevealInFinderClick(title)}
className={twMerge(
active ? 'bg-gray-50' : '',
'block cursor-pointer px-3 py-1 text-xs leading-6 text-gray-900'
)}
>
Reveal in finder
</a>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<a
onClick={() => onViewJsonClick(title)}
className={twMerge(
active ? 'bg-gray-50' : '',
'block cursor-pointer px-3 py-1 text-xs leading-6 text-gray-900'
)}
>
View a JSON
</a>
)}
</Menu.Item>
</Menu.Items>
</Transition>
</Menu>
<div
className="flex cursor-pointer items-center space-x-2 px-4 py-2 hover:bg-secondary"
onClick={() => {
onRevealInFinderClick(title)
setMore(false)
}}
>
<FolderOpenIcon size={16} className="text-muted-foreground" />
<span className="text-bold text-black dark:text-muted-foreground">
Reveal in Finder
</span>
</div>
<div
className="flex cursor-pointer items-center space-x-2 px-4 py-2 hover:bg-secondary"
onClick={() => {
onViewJsonClick(title)
setMore(false)
}}
>
<Code2Icon size={16} className="text-muted-foreground" />
<span className="text-bold text-black dark:text-muted-foreground">
View as JSON
</span>
</div>
</div>
)}
</div>
{show && <div className="flex flex-col gap-2 p-2">{children}</div>}
</div>

View File

@ -1,104 +1,114 @@
import { Fragment, useEffect, useState } from 'react'
import { Listbox, Transition } from '@headlessui/react'
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/20/solid'
import { useEffect, useState } from 'react'
import { Model } from '@janhq/core'
import { atom, useSetAtom } from 'jotai'
import {
Button,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@janhq/uikit'
import { atom, useAtomValue, useSetAtom } from 'jotai'
import { MonitorIcon } from 'lucide-react'
import { twMerge } from 'tailwind-merge'
import { MainViewState } from '@/constants/screens'
import { getDownloadedModels } from '@/hooks/useGetDownloadedModels'
import { useMainViewState } from '@/hooks/useMainViewState'
import { toGigabytes } from '@/utils/converter'
import { activeThreadAtom } from '@/helpers/atoms/Conversation.atom'
export const selectedModelAtom = atom<Model | undefined>(undefined)
export default function DropdownListSidebar() {
const [downloadedModels, setDownloadedModels] = useState<Model[]>([])
const [selected, setSelected] = useState<Model | undefined>()
const setSelectedModel = useSetAtom(selectedModelAtom)
const activeThread = useAtomValue(activeThreadAtom)
const [selected, setSelected] = useState<Model | undefined>()
const { setMainViewState } = useMainViewState()
useEffect(() => {
getDownloadedModels().then((downloadedModels) => {
setDownloadedModels(downloadedModels)
if (downloadedModels.length > 0) {
setSelected(downloadedModels[0])
setSelectedModel(downloadedModels[0])
setSelected(
downloadedModels.filter(
(x) => x.id === activeThread?.assistants[0].model.id
)[0] || downloadedModels[0]
)
setSelectedModel(
downloadedModels.filter(
(x) => x.id === activeThread?.assistants[0].model.id
)[0] || downloadedModels[0]
)
}
})
}, [])
if (!selected) return null
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeThread])
return (
<Listbox
value={selected}
onChange={(model) => {
setSelected(model)
setSelectedModel(model)
<Select
value={selected?.id}
onValueChange={(value) => {
setSelected(downloadedModels.filter((x) => x.id === value)[0])
setSelectedModel(downloadedModels.filter((x) => x.id === value)[0])
}}
>
{({ open }) => (
<>
<div className="relative mt-2">
<Listbox.Button className="relative w-full cursor-default rounded-md bg-white py-1.5 pl-3 pr-10 text-left text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6">
<span className="block truncate">{selected.name}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon
className="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</Listbox.Button>
<Transition
show={open}
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
{downloadedModels.map((model) => (
<Listbox.Option
key={model.id}
className={({ active }) =>
twMerge(
active ? 'bg-indigo-600 text-white' : 'text-gray-900',
'relative cursor-default select-none py-2 pl-3 pr-9'
)
}
value={model}
>
{({ selected, active }) => (
<>
<span
className={twMerge(
selected ? 'font-semibold' : 'font-normal',
'block truncate'
)}
>
{model.name}
</span>
{selected ? (
<span
className={twMerge(
active ? 'text-white' : 'text-indigo-600',
'absolute inset-y-0 right-0 flex items-center pr-4'
)}
>
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
<SelectTrigger className="w-full">
<SelectValue placeholder="Choose model to start">
{downloadedModels.filter((x) => x.id === selected?.id)[0]?.name}
</SelectValue>
</SelectTrigger>
<SelectContent className="right-5 block w-full min-w-[300px] pr-0">
<div className="flex w-full items-center space-x-2 px-4 py-2">
<MonitorIcon size={20} className="text-muted-foreground" />
<span>Local</span>
</div>
<div className="border-b border-border" />
{downloadedModels.length === 0 ? (
<div className="px-4 py-2">
<p>{`Oops, you don't have a model yet.`}</p>
</div>
</>
)}
</Listbox>
) : (
<SelectGroup>
{downloadedModels.map((x, i) => {
return (
<SelectItem
key={i}
value={x.id}
className={twMerge(x.id === selected?.id && 'bg-secondary')}
>
<div className="flex w-full justify-between">
<span className="line-clamp-1 block">{x.name}</span>
<span className="font-bold text-muted-foreground">
{toGigabytes(x.metadata.size)}
</span>
</div>
</SelectItem>
)
})}
</SelectGroup>
)}
<div className="border-b border-border" />
<div className="w-full px-4 py-2">
<Button
block
className="bg-blue-100 font-bold text-blue-600 hover:bg-blue-100 hover:text-blue-600"
onClick={() => setMainViewState(MainViewState.Hub)}
>
Explore The Hub
</Button>
</div>
</SelectContent>
</Select>
)
}

View File

@ -12,18 +12,14 @@ import {
ModalTrigger,
} from '@janhq/uikit'
import { useAtomValue } from 'jotai'
import { useDownloadState } from '@/hooks/useDownloadState'
import { formatDownloadPercentage } from '@/utils/converter'
import { extensionManager } from '@/extension'
import { downloadingModelsAtom } from '@/helpers/atoms/Model.atom'
export default function DownloadingState() {
const { downloadStates } = useDownloadState()
const models = useAtomValue(downloadingModelsAtom)
const totalCurrentProgress = downloadStates
.map((a) => a.size.transferred + a.size.transferred)

View File

@ -30,7 +30,7 @@ const BottomBar = () => {
const { downloadStates } = useDownloadState()
return (
<div className="fixed bottom-0 left-16 z-20 flex h-12 w-[calc(100%-64px)] items-center justify-between border-t border-border bg-background/50 px-3">
<div className="fixed bottom-0 left-16 z-20 flex h-12 w-[calc(100%-64px)] items-center justify-between border-t border-border bg-background/80 px-3">
<div className="flex flex-shrink-0 items-center gap-x-2">
<div className="flex items-center space-x-2">
{progress && progress > 0 ? (
@ -49,7 +49,7 @@ const BottomBar = () => {
name="Active model:"
value={
activeModel?.id || (
<Badge themes="outline">
<Badge themes="outline" className="pl-1">
<ShortCut menu="E" />
&nbsp; to show your model
</Badge>
@ -63,7 +63,7 @@ const BottomBar = () => {
<Button
size="sm"
themes="outline"
onClick={() => setMainViewState(MainViewState.ExploreModels)}
onClick={() => setMainViewState(MainViewState.Hub)}
>
Download your first model
</Button>

View File

@ -1,5 +1,3 @@
import { useContext } from 'react'
import {
Tooltip,
TooltipContent,
@ -11,9 +9,8 @@ import { motion as m } from 'framer-motion'
import {
MessageCircleIcon,
SettingsIcon,
DatabaseIcon,
CpuIcon,
BookOpenIcon,
MonitorIcon,
LayoutGridIcon,
} from 'lucide-react'
import { twMerge } from 'tailwind-merge'
@ -34,36 +31,51 @@ export default function RibbonNav() {
const primaryMenus = [
{
name: 'Getting Started',
icon: <BookOpenIcon size={20} className="flex-shrink-0" />,
state: MainViewState.Welcome,
name: 'Thread',
icon: (
<MessageCircleIcon
size={20}
className="flex-shrink-0 text-muted-foreground"
/>
),
state: MainViewState.Thread,
},
{
name: 'Chat',
icon: <MessageCircleIcon size={20} className="flex-shrink-0" />,
state: MainViewState.Chat,
name: 'Hub',
icon: (
<LayoutGridIcon
size={20}
className="flex-shrink-0 text-muted-foreground"
/>
),
state: MainViewState.Hub,
},
]
const secondaryMenus = [
{
name: 'Explore Models',
icon: <CpuIcon size={20} className="flex-shrink-0" />,
state: MainViewState.ExploreModels,
},
{
name: 'My Models',
icon: <DatabaseIcon size={20} className="flex-shrink-0" />,
state: MainViewState.MyModels,
name: 'System Monitor',
icon: (
<MonitorIcon
size={20}
className="flex-shrink-0 text-muted-foreground"
/>
),
state: MainViewState.SystemMonitor,
},
{
name: 'Settings',
icon: <SettingsIcon size={20} className="flex-shrink-0" />,
state: MainViewState.Setting,
icon: (
<SettingsIcon
size={20}
className="flex-shrink-0 text-muted-foreground"
/>
),
state: MainViewState.Settings,
},
]
return (
<div className="relative top-12 flex h-[calc(100%-48px)] w-16 flex-shrink-0 flex-col border-r border-border py-4">
<div className="relative top-12 flex h-[calc(100%-48px)] w-16 flex-shrink-0 flex-col border-r border-border bg-background py-4">
<div className="mt-2 flex h-full w-full flex-col items-center justify-between">
<div className="flex h-full w-full flex-col items-center justify-between">
<div>
@ -90,7 +102,7 @@ export default function RibbonNav() {
</div>
{isActive && (
<m.div
className="absolute inset-0 left-0 h-full w-full rounded-md bg-primary/50"
className="absolute inset-0 left-0 h-full w-full rounded-md bg-gray-200 dark:bg-secondary"
layoutId="active-state-primary"
/>
)}
@ -126,7 +138,7 @@ export default function RibbonNav() {
</div>
{isActive && (
<m.div
className="absolute inset-0 left-0 h-full w-full rounded-md bg-primary/50"
className="absolute inset-0 left-0 h-full w-full rounded-md bg-gray-200 dark:bg-secondary"
layoutId="active-state-secondary"
/>
)}

View File

@ -85,12 +85,12 @@ export default function CommandListDownloadedModel() {
<CommandGroup heading="Find another model">
<CommandItem
onSelect={() => {
setMainViewState(MainViewState.ExploreModels)
setMainViewState(MainViewState.Hub)
setOpen(false)
}}
>
<CpuIcon size={16} className="mr-3 text-muted-foreground" />
<span>Explore Models</span>
<span>Explore The Hub</span>
</CommandItem>
</CommandGroup>
</CommandList>

View File

@ -1,7 +1,6 @@
import { Fragment, useState, useEffect } from 'react'
import {
Button,
CommandModal,
CommandEmpty,
CommandGroup,
@ -11,14 +10,7 @@ import {
CommandList,
} from '@janhq/uikit'
import { useAtomValue, useSetAtom } from 'jotai'
import {
MessageCircleIcon,
SettingsIcon,
DatabaseIcon,
CpuIcon,
BookOpenIcon,
} from 'lucide-react'
import { MessageCircleIcon, SettingsIcon, LayoutGridIcon } from 'lucide-react'
import ShortCut from '@/containers/Shortcut'
@ -26,43 +18,27 @@ import { MainViewState } from '@/constants/screens'
import { useMainViewState } from '@/hooks/useMainViewState'
import { showRightSideBarAtom } from '@/screens/Chat/Sidebar'
import { activeThreadAtom } from '@/helpers/atoms/Conversation.atom'
export default function CommandSearch() {
const { setMainViewState } = useMainViewState()
const [open, setOpen] = useState(false)
const setShowRightSideBar = useSetAtom(showRightSideBarAtom)
const activeThread = useAtomValue(activeThreadAtom)
const menus = [
{
name: 'Getting Started',
icon: <BookOpenIcon size={16} className="mr-3 text-muted-foreground" />,
state: MainViewState.Welcome,
},
{
name: 'Chat',
icon: (
<MessageCircleIcon size={16} className="mr-3 text-muted-foreground" />
),
state: MainViewState.Chat,
state: MainViewState.Thread,
},
{
name: 'Explore Models',
icon: <CpuIcon size={16} className="mr-3 text-muted-foreground" />,
state: MainViewState.ExploreModels,
},
{
name: 'My Models',
icon: <DatabaseIcon size={16} className="mr-3 text-muted-foreground" />,
state: MainViewState.MyModels,
name: 'Hub',
icon: <LayoutGridIcon size={16} className="mr-3 text-muted-foreground" />,
state: MainViewState.Hub,
},
{
name: 'Settings',
icon: <SettingsIcon size={16} className="mr-3 text-muted-foreground" />,
state: MainViewState.Setting,
state: MainViewState.Settings,
shortcut: <ShortCut menu="," />,
},
]
@ -75,7 +51,7 @@ export default function CommandSearch() {
}
if (e.key === ',' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
setMainViewState(MainViewState.Setting)
setMainViewState(MainViewState.Settings)
}
}
document.addEventListener('keydown', down)
@ -85,7 +61,8 @@ export default function CommandSearch() {
return (
<Fragment>
<div className="relative">
{/* Temporary disable view search input until we have proper UI placement, but we keep function cmd + K for showing list page */}
{/* <div className="relative">
<Button
themes="outline"
className="unset-drag h-8 w-[300px] justify-start text-left text-xs font-normal text-muted-foreground focus:ring-0"
@ -96,8 +73,7 @@ export default function CommandSearch() {
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<ShortCut menu="K" />
</div>
</div>
</div> */}
<CommandModal open={open} onOpenChange={setOpen}>
<CommandInput placeholder="Type a command or search..." />
<CommandList>
@ -124,15 +100,6 @@ export default function CommandSearch() {
</CommandGroup>
</CommandList>
</CommandModal>
{activeThread && (
<Button
themes="outline"
className="unset-drag justify-start text-left text-xs font-normal text-muted-foreground focus:ring-0"
onClick={() => setShowRightSideBar((show) => !show)}
>
Toggle right
</Button>
)}
</Fragment>
)
}

View File

@ -1,21 +1,86 @@
import { useAtomValue, useSetAtom } from 'jotai'
import { PanelLeftIcon, PenSquareIcon, PanelRightIcon } from 'lucide-react'
import CommandListDownloadedModel from '@/containers/Layout/TopBar/CommandListDownloadedModel'
import CommandSearch from '@/containers/Layout/TopBar/CommandSearch'
import { MainViewState } from '@/constants/screens'
import { useCreateNewThread } from '@/hooks/useCreateNewThread'
import useGetAssistants from '@/hooks/useGetAssistants'
import { useMainViewState } from '@/hooks/useMainViewState'
import { showRightSideBarAtom } from '@/screens/Chat/Sidebar'
import { activeThreadAtom } from '@/helpers/atoms/Conversation.atom'
const TopBar = () => {
const { viewStateName } = useMainViewState()
const activeThread = useAtomValue(activeThreadAtom)
const { mainViewState } = useMainViewState()
const { requestCreateNewThread } = useCreateNewThread()
const { assistants } = useGetAssistants()
const setShowRightSideBar = useSetAtom(showRightSideBarAtom)
const titleScreen = (viewStateName: MainViewState) => {
switch (viewStateName) {
case MainViewState.Thread:
return activeThread ? activeThread?.title : 'New Thread'
default:
return MainViewState[viewStateName]?.replace(/([A-Z])/g, ' $1').trim()
}
}
const onCreateConversationClick = async () => {
if (assistants.length === 0) {
alert('No assistant available')
return
}
requestCreateNewThread(assistants[0])
}
return (
<div className="fixed left-0 top-0 z-50 flex h-12 w-full border-b border-border bg-background/50">
<div className="fixed left-0 top-0 z-50 flex h-12 w-full border-b border-border bg-background/80 backdrop-blur-md">
{mainViewState === MainViewState.Thread && (
<div className="absolute left-16 h-full w-60 border-r border-border" />
)}
<div className="relative left-16 flex w-[calc(100%-64px)] items-center justify-between space-x-4 pl-6 pr-2">
<div>
<span className="font-medium">
{viewStateName.replace(/([A-Z])/g, ' $1').trim()}
</span>
</div>
{mainViewState === MainViewState.Thread ? (
<div className="unset-drag flex space-x-8">
<div className="flex w-52 justify-between">
<div className="cursor-pointer">
<PanelLeftIcon
size={20}
className="invisible text-muted-foreground"
/>
</div>
<div
className="cursor-pointer pr-2"
onClick={onCreateConversationClick}
>
<PenSquareIcon size={20} className="text-muted-foreground" />
</div>
</div>
<span className="text-sm font-bold">
{titleScreen(mainViewState)}
</span>
{activeThread && (
<div
className="unset-drag absolute right-4 cursor-pointer"
onClick={() => setShowRightSideBar((show) => !show)}
>
<PanelRightIcon size={20} className="text-muted-foreground" />
</div>
)}
</div>
) : (
<div>
<span className="text-sm font-bold">
{titleScreen(mainViewState)}
</span>
</div>
)}
<CommandSearch />
{/* Command without trigger interface */}
<CommandListDownloadedModel />
</div>
</div>

View File

@ -35,7 +35,6 @@ export default function ModalCancelDownload({ model, isFromList }: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
[model.id]
)
const models = useAtomValue(downloadingModelsAtom)
const downloadState = useAtomValue(downloadAtom)
const cancelText = `Cancel ${formatDownloadPercentage(downloadState.percent)}`

View File

@ -14,7 +14,7 @@ export default function ShortCut(props: { menu: string }) {
}
return (
<div className="inline-flex items-center justify-center rounded-md bg-secondary px-1 py-0.5 text-xs font-bold text-muted-foreground">
<div className="inline-flex items-center justify-center rounded-full bg-secondary px-1 py-0.5 text-xs font-bold text-muted-foreground">
<p>{getSymbol(os) + ' + ' + menu}</p>
</div>
)

View File

@ -16,7 +16,7 @@ export function toaster(props: Props) {
return (
<div
className={twMerge(
'pointer-events-auto relative flex min-w-[200px] max-w-[350px] gap-x-4 rounded-lg border border-border bg-background px-4 py-3',
'unset-drag relative flex min-w-[200px] max-w-[350px] gap-x-4 rounded-lg border border-border bg-background px-4 py-3',
t.visible ? 'animate-enter' : 'animate-leave',
type === 'success' && 'bg-primary text-primary-foreground'
)}

View File

@ -88,7 +88,7 @@ export const cleanConversationMessages = atom(null, (get, set, id: string) => {
const newData: Record<string, ThreadMessage[]> = {
...get(chatMessages),
}
newData[id] = newData[id].filter((e) => e.role === ChatCompletionRole.System)
newData[id] = newData[id]?.filter((e) => e.role === ChatCompletionRole.System)
set(chatMessages, newData)
})

View File

@ -1,3 +1,6 @@
import { atom } from 'jotai'
export const totalRamAtom = atom<number>(0)
export const usedRamAtom = atom<number>(0)
export const cpuUsageAtom = atom<number>(0)

View File

@ -0,0 +1,42 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useEffect, useRef } from 'react'
const DEFAULT_EVENTS = ['mousedown', 'touchstart']
export function useClickOutside<T extends HTMLElement = any>(
handler: () => void,
events?: string[] | null,
nodes?: (HTMLElement | null)[]
) {
const ref = useRef<T>()
useEffect(() => {
const listener = (event: any) => {
const { target } = event ?? {}
if (Array.isArray(nodes)) {
const shouldIgnore =
target?.hasAttribute('data-ignore-outside-clicks') ||
(!document.body.contains(target) && target.tagName !== 'HTML')
const shouldTrigger = nodes.every(
(node) => !!node && !event.composedPath().includes(node)
)
shouldTrigger && !shouldIgnore && handler()
} else if (ref.current && !ref.current.contains(target)) {
handler()
}
}
;(events || DEFAULT_EVENTS).forEach((fn) =>
document.addEventListener(fn, listener)
)
return () => {
;(events || DEFAULT_EVENTS).forEach((fn) =>
document.removeEventListener(fn, listener)
)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ref, handler, nodes])
return ref
}

View File

@ -40,7 +40,6 @@ export const useCreateNewThread = () => {
const setActiveThreadId = useSetAtom(setActiveThreadIdAtom)
const [threadStates, setThreadStates] = useAtom(threadStatesAtom)
const threads = useAtomValue(threadsAtom)
const activeThread = useAtomValue(activeThreadAtom)
const updateThread = useSetAtom(updateThreadAtom)
const requestCreateNewThread = async (assistant: Assistant) => {
@ -69,6 +68,7 @@ export const useCreateNewThread = () => {
stream: false,
},
},
instructions: assistant.instructions,
}
const threadId = generateThreadId(assistant.id)
const thread: Thread = {
@ -93,20 +93,18 @@ export const useCreateNewThread = () => {
setActiveThreadId(thread.id)
}
function updateThreadTitle(title: string) {
if (!activeThread) return
const updatedConv: Thread = {
...activeThread,
title,
function updateThreadMetadata(thread: Thread) {
const updatedThread: Thread = {
...thread,
}
updateThread(updatedConv)
updateThread(updatedThread)
extensionManager
.get<ConversationalExtension>(ExtensionType.Conversational)
?.saveThread(updatedConv)
?.saveThread(updatedThread)
}
return {
requestCreateNewThread,
updateThreadTitle,
updateThreadMetadata,
}
}

View File

@ -17,7 +17,6 @@ import {
} from '@/helpers/atoms/ChatMessage.atom'
import {
threadsAtom,
getActiveThreadIdAtom,
setActiveThreadIdAtom,
} from '@/helpers/atoms/Conversation.atom'
@ -25,14 +24,13 @@ export default function useDeleteThread() {
const { activeModel } = useActiveModel()
const [threads, setThreads] = useAtom(threadsAtom)
const setCurrentPrompt = useSetAtom(currentPromptAtom)
const activeThreadId = useAtomValue(getActiveThreadIdAtom)
const messages = useAtomValue(getCurrentChatMessagesAtom)
const setActiveConvoId = useSetAtom(setActiveThreadIdAtom)
const deleteMessages = useSetAtom(deleteConversationMessage)
const cleanMessages = useSetAtom(cleanConversationMessages)
const cleanThread = async () => {
const cleanThread = async (activeThreadId: string) => {
if (activeThreadId) {
const thread = threads.filter((c) => c.id === activeThreadId)[0]
cleanMessages(activeThreadId)
@ -46,7 +44,7 @@ export default function useDeleteThread() {
}
}
const deleteThread = async () => {
const deleteThread = async (activeThreadId: string) => {
if (!activeThreadId) {
alert('No active thread')
return
@ -60,8 +58,8 @@ export default function useDeleteThread() {
deleteMessages(activeThreadId)
setCurrentPrompt('')
toaster({
title: 'Chat successfully deleted.',
description: `Chat with ${activeModel?.name} has been successfully deleted.`,
title: 'Thread successfully deleted.',
description: `Thread with ${activeModel?.name} has been successfully deleted.`,
})
if (availableThreads.length > 0) {
setActiveConvoId(availableThreads[0].id)

View File

@ -1,6 +1,6 @@
import { Model, ExtensionType, ModelExtension } from '@janhq/core'
import { useAtom, useAtomValue } from 'jotai'
import { useAtom } from 'jotai'
import { useDownloadState } from './useDownloadState'

View File

@ -6,12 +6,18 @@ import { MonitoringExtension } from '@janhq/core'
import { useSetAtom } from 'jotai'
import { extensionManager } from '@/extension/ExtensionManager'
import { totalRamAtom } from '@/helpers/atoms/SystemBar.atom'
import {
cpuUsageAtom,
totalRamAtom,
usedRamAtom,
} from '@/helpers/atoms/SystemBar.atom'
export default function useGetSystemResources() {
const [ram, setRam] = useState<number>(0)
const [cpu, setCPU] = useState<number>(0)
const setTotalRam = useSetAtom(totalRamAtom)
const setUsedRam = useSetAtom(usedRamAtom)
const setCpuUsage = useSetAtom(cpuUsageAtom)
const getSystemResources = async () => {
if (
@ -27,10 +33,12 @@ export default function useGetSystemResources() {
const ram =
(resourceInfor?.mem?.active ?? 0) / (resourceInfor?.mem?.total ?? 1)
if (resourceInfor?.mem?.active) setUsedRam(resourceInfor.mem.active)
if (resourceInfor?.mem?.total) setTotalRam(resourceInfor.mem.total)
setRam(Math.round(ram * 100))
setCPU(Math.round(currentLoadInfor?.currentLoad ?? 0))
setCpuUsage(Math.round(currentLoadInfor?.currentLoad ?? 0))
}
useEffect(() => {
@ -45,6 +53,7 @@ export default function useGetSystemResources() {
// clean up interval
return () => clearInterval(intervalId)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return {

View File

@ -2,7 +2,7 @@ import { atom, useAtom } from 'jotai'
import { MainViewState } from '@/constants/screens'
const currentMainViewState = atom<MainViewState>(MainViewState.Welcome)
const currentMainViewState = atom<MainViewState>(MainViewState.Thread)
export function useMainViewState() {
const [mainViewState, setMainViewState] = useAtom(currentMainViewState)

View File

@ -1,3 +1,5 @@
import { useState } from 'react'
import {
ChatCompletionMessage,
ChatCompletionRole,
@ -10,7 +12,7 @@ import {
ThreadMessage,
events,
} from '@janhq/core'
import { ConversationalExtension, InferenceExtension } from '@janhq/core'
import { ConversationalExtension } from '@janhq/core'
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import { ulid } from 'ulid'
@ -44,6 +46,48 @@ export default function useSendChatMessage() {
const { activeModel } = useActiveModel()
const selectedModel = useAtomValue(selectedModelAtom)
const { startModel } = useActiveModel()
const [queuedMessage, setQueuedMessage] = useState(false)
const resendChatMessage = async () => {
if (!activeThread) {
console.error('No active thread')
return
}
updateThreadWaiting(activeThread.id, true)
const messages: ChatCompletionMessage[] = [
activeThread.assistants[0]?.instructions,
]
.map<ChatCompletionMessage>((instructions) => {
const systemMessage: ChatCompletionMessage = {
role: ChatCompletionRole.System,
content: instructions,
}
return systemMessage
})
.concat(
currentMessages.map<ChatCompletionMessage>((msg) => ({
role: msg.role,
content: msg.content[0]?.text.value ?? '',
}))
)
const messageRequest: MessageRequest = {
id: ulid(),
messages: messages,
threadId: activeThread.id,
}
const modelId = selectedModel?.id ?? activeThread.assistants[0].model.id
if (activeModel?.id !== modelId) {
setQueuedMessage(true)
await startModel(modelId)
setQueuedMessage(false)
}
events.emit(EventName.OnMessageSent, messageRequest)
}
const sendChatMessage = async () => {
if (!currentPrompt || currentPrompt.trim().length === 0) {
@ -61,14 +105,15 @@ export default function useSendChatMessage() {
}
const assistantId = activeThread.assistants[0].assistant_id ?? ''
const assistantName = activeThread.assistants[0].assistant_name ?? ''
const instructions = activeThread.assistants[0].instructions ?? ''
const updatedThread: Thread = {
...activeThread,
isFinishInit: true,
title: `${activeThread.assistants[0].assistant_name} with ${selectedModel.name}`,
assistants: [
{
assistant_id: assistantId,
assistant_name: assistantName,
instructions: instructions,
model: {
id: selectedModel.id,
settings: selectedModel.settings,
@ -90,18 +135,29 @@ export default function useSendChatMessage() {
const prompt = currentPrompt.trim()
setCurrentPrompt('')
const messages: ChatCompletionMessage[] = currentMessages
.map<ChatCompletionMessage>((msg) => ({
role: msg.role,
content: msg.content[0]?.text.value ?? '',
}))
.concat([
{
role: ChatCompletionRole.User,
content: prompt,
} as ChatCompletionMessage,
])
console.debug(`Sending messages: ${JSON.stringify(messages, null, 2)}`)
const messages: ChatCompletionMessage[] = [
activeThread.assistants[0]?.instructions,
]
.map<ChatCompletionMessage>((instructions) => {
const systemMessage: ChatCompletionMessage = {
role: ChatCompletionRole.System,
content: instructions,
}
return systemMessage
})
.concat(
currentMessages
.map<ChatCompletionMessage>((msg) => ({
role: msg.role,
content: msg.content[0]?.text.value ?? '',
}))
.concat([
{
role: ChatCompletionRole.User,
content: prompt,
} as ChatCompletionMessage,
])
)
const msgId = ulid()
const messageRequest: MessageRequest = {
id: msgId,
@ -136,17 +192,18 @@ export default function useSendChatMessage() {
?.addNewMessage(threadMessage)
const modelId = selectedModel?.id ?? activeThread.assistants[0].model.id
if (activeModel?.id !== modelId) {
toaster({
title: 'Message queued.',
description: 'It will be sent once the model is done loading',
})
setQueuedMessage(true)
await startModel(modelId)
setQueuedMessage(false)
}
events.emit(EventName.OnMessageSent, messageRequest)
}
return {
sendChatMessage,
resendChatMessage,
queuedMessage,
}
}

View File

@ -33,6 +33,7 @@
"react-dom": "18.2.0",
"react-hook-form": "^7.47.0",
"react-hot-toast": "^2.4.1",
"react-scroll-to-bottom": "^4.2.0",
"react-toastify": "^9.1.3",
"sass": "^1.69.4",
"tailwind-merge": "^2.0.0",
@ -48,6 +49,7 @@
"@types/node": "20.8.10",
"@types/react": "18.2.34",
"@types/react-dom": "18.2.14",
"@types/react-scroll-to-bottom": "^4.2.4",
"@types/uuid": "^9.0.6",
"@typescript-eslint/eslint-plugin": "^6.8.0",
"@typescript-eslint/parser": "^6.8.0",

View File

@ -1,17 +1,65 @@
import { Fragment } from 'react'
import ScrollToBottom from 'react-scroll-to-bottom'
import { Button } from '@janhq/uikit'
import { useAtomValue } from 'jotai'
import LogoMark from '@/containers/Brand/Logo/Mark'
import { MainViewState } from '@/constants/screens'
import { useGetDownloadedModels } from '@/hooks/useGetDownloadedModels'
import { useMainViewState } from '@/hooks/useMainViewState'
import ChatItem from '../ChatItem'
import { getCurrentChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom'
const ChatBody: React.FC = () => {
const messages = useAtomValue(getCurrentChatMessagesAtom)
const { downloadedModels } = useGetDownloadedModels()
const { setMainViewState } = useMainViewState()
if (downloadedModels.length === 0)
return (
<div className="mx-auto flex h-full w-3/4 flex-col items-center justify-center text-center">
<LogoMark
className="mx-auto mb-4 animate-wave"
width={56}
height={56}
/>
<h1 className="text-2xl font-bold">Welcome!</h1>
<p className="mt-1 text-base">You need to download your first model</p>
<Button
className="mt-4"
onClick={() => setMainViewState(MainViewState.Hub)}
>
Explore The Hub
</Button>
</div>
)
return (
<div className="flex h-full w-full flex-col overflow-y-auto">
{messages.map((message) => (
<ChatItem {...message} key={message.id} />
))}
</div>
<Fragment>
{messages.length === 0 ? (
<div className="mx-auto flex h-full w-3/4 flex-col items-center justify-center text-center">
<LogoMark
className="mx-auto mb-4 animate-wave"
width={56}
height={56}
/>
<p className="mt-1 text-base font-medium">How can I help you?</p>
</div>
) : (
<ScrollToBottom className="flex h-full w-full flex-col">
{messages.map((message) => (
<ChatItem {...message} key={message.id} />
))}
</ScrollToBottom>
)}
</Fragment>
)
}

View File

@ -7,10 +7,7 @@ import SimpleTextMessage from '../SimpleTextMessage'
type Ref = HTMLDivElement
const ChatItem = forwardRef<Ref, ThreadMessage>((message, ref) => (
<div
ref={ref}
className="relative py-4 first:pb-14 even:bg-secondary dark:even:bg-secondary/20"
>
<div ref={ref} className="relative py-4">
<SimpleTextMessage {...message} />
</div>
))

View File

@ -1,18 +1,13 @@
import { useMemo } from 'react'
import {
ChatCompletionRole,
ChatCompletionMessage,
EventName,
MessageRequest,
MessageStatus,
ExtensionType,
ThreadMessage,
events,
} from '@janhq/core'
import { ConversationalExtension, InferenceExtension } from '@janhq/core'
import { atom, useAtomValue, useSetAtom } from 'jotai'
import { RefreshCcw, ClipboardCopy, Trash2Icon, StopCircle } from 'lucide-react'
import { useAtomValue, useSetAtom } from 'jotai'
import { RefreshCcw, Copy, Trash2Icon, StopCircle } from 'lucide-react'
import { twMerge } from 'tailwind-merge'
@ -23,22 +18,16 @@ import {
deleteMessageAtom,
getCurrentChatMessagesAtom,
} from '@/helpers/atoms/ChatMessage.atom'
import {
activeThreadAtom,
threadStatesAtom,
} from '@/helpers/atoms/Conversation.atom'
import { activeThreadAtom } from '@/helpers/atoms/Conversation.atom'
import useSendChatMessage from '@/hooks/useSendChatMessage'
const MessageToolbar = ({ message }: { message: ThreadMessage }) => {
const deleteMessage = useSetAtom(deleteMessageAtom)
const thread = useAtomValue(activeThreadAtom)
const messages = useAtomValue(getCurrentChatMessagesAtom)
const threadStateAtom = useMemo(
() => atom((get) => get(threadStatesAtom)[thread?.id ?? '']),
[thread?.id]
)
const threadState = useAtomValue(threadStateAtom)
const { resendChatMessage } = useSendChatMessage()
const stopInference = async () => {
const onStopInferenceClick = async () => {
await extensionManager
.get<InferenceExtension>(ExtensionType.Inference)
?.stopInference()
@ -50,18 +39,25 @@ const MessageToolbar = ({ message }: { message: ThreadMessage }) => {
}, 300)
}
const onDeleteClick = async () => {
deleteMessage(message.id ?? '')
if (thread) {
await extensionManager
.get<ConversationalExtension>(ExtensionType.Conversational)
?.writeMessages(
thread.id,
messages.filter((msg) => msg.id !== message.id)
)
}
}
return (
<div
className={twMerge(
'flex-row items-center',
threadState.waitingForResponse ? 'hidden' : 'flex'
)}
>
<div className={twMerge('flex flex-row items-center')}>
<div className="flex overflow-hidden rounded-md border border-border bg-background/20">
{message.status === MessageStatus.Pending && (
<div
className="cursor-pointer border-r border-border px-2 py-2 hover:bg-background/80"
onClick={() => stopInference()}
onClick={onStopInferenceClick}
>
<StopCircle size={14} />
</div>
@ -70,20 +66,7 @@ const MessageToolbar = ({ message }: { message: ThreadMessage }) => {
message.id === messages[messages.length - 1]?.id && (
<div
className="cursor-pointer border-r border-border px-2 py-2 hover:bg-background/80"
onClick={() => {
const messageRequest: MessageRequest = {
id: message.id ?? '',
messages: messages.slice(0, -1).map((e) => {
const msg: ChatCompletionMessage = {
role: e.role,
content: e.content[0].text.value,
}
return msg
}),
threadId: message.thread_id ?? '',
}
events.emit(EventName.OnMessageSent, messageRequest)
}}
onClick={resendChatMessage}
>
<RefreshCcw size={14} />
</div>
@ -97,20 +80,11 @@ const MessageToolbar = ({ message }: { message: ThreadMessage }) => {
})
}}
>
<ClipboardCopy size={14} />
<Copy size={14} />
</div>
<div
className="cursor-pointer px-2 py-2 hover:bg-background/80"
onClick={async () => {
deleteMessage(message.id ?? '')
if (thread)
await extensionManager
.get<ConversationalExtension>(ExtensionType.Conversational)
?.writeMessages(
thread.id,
messages.filter((msg) => msg.id !== message.id)
)
}}
onClick={onDeleteClick}
>
<Trash2Icon size={14} />
</div>

View File

@ -1,30 +1,35 @@
import { join } from 'path'
import { getUserSpace, openFileExplorer } from '@janhq/core'
import { Input, Textarea } from '@janhq/uikit'
import { atom, useAtomValue } from 'jotai'
import { twMerge } from 'tailwind-merge'
import LogoMark from '@/containers/Brand/Logo/Mark'
import CardSidebar from '@/containers/CardSidebar'
import DropdownListSidebar, {
selectedModelAtom,
} from '@/containers/DropdownListSidebar'
import ItemCardSidebar from '@/containers/ItemCardSidebar'
import { useCreateNewThread } from '@/hooks/useCreateNewThread'
import { activeThreadAtom } from '@/helpers/atoms/Conversation.atom'
export const showRightSideBarAtom = atom<boolean>(false)
export const showRightSideBarAtom = atom<boolean>(true)
export default function Sidebar() {
const showing = useAtomValue(showRightSideBarAtom)
const activeThread = useAtomValue(activeThreadAtom)
const selectedModel = useAtomValue(selectedModelAtom)
const { updateThreadTitle } = useCreateNewThread()
const { updateThreadMetadata } = useCreateNewThread()
const onReviewInFinderClick = async (type: string) => {
if (!activeThread) return
if (!activeThread.isFinishInit) {
alert('Thread is not ready')
alert('Thread is not started yet')
return
}
@ -56,7 +61,7 @@ export default function Sidebar() {
const onViewJsonClick = async (type: string) => {
if (!activeThread) return
if (!activeThread.isFinishInit) {
alert('Thread is not ready')
alert('Thread is not started yet')
return
}
@ -87,44 +92,104 @@ export default function Sidebar() {
return (
<div
className={`h-full overflow-x-hidden border-l border-border duration-300 ease-linear ${
showing ? 'w-80' : 'w-0'
}`}
className={twMerge(
'h-full flex-shrink-0 overflow-x-hidden border-l border-border bg-background transition-all duration-100 dark:bg-background/20',
showing
? 'w-80 translate-x-0 opacity-100'
: 'w-0 translate-x-full opacity-0'
)}
>
<div className="flex flex-col gap-1 p-2">
<div
className={twMerge(
'flex flex-col gap-4 p-4 delay-200',
showing ? 'animate-enter opacity-100' : 'opacity-0'
)}
>
<CardSidebar
title="Thread"
onRevealInFinderClick={onReviewInFinderClick}
onViewJsonClick={onViewJsonClick}
>
<ItemCardSidebar
description={activeThread?.id}
title="Thread ID"
disabled
/>
<ItemCardSidebar
title="Thread title"
description={activeThread?.title}
onChange={(title) => updateThreadTitle(title ?? '')}
/>
<div className="flex flex-col space-y-4 p-2">
<div>
<label
id="thread-title"
className="mb-2 inline-block font-bold text-gray-600 dark:text-gray-300"
>
Title
</label>
<Input
id="thread-title"
value={activeThread?.title}
onChange={(e) => {
if (activeThread)
updateThreadMetadata({
...activeThread,
title: e.target.value || '',
})
}}
/>
</div>
<div className="flex flex-col">
<label
id="thread-title"
className="mb-2 inline-block font-bold text-gray-600 dark:text-gray-300"
>
Threads ID
</label>
<span className="text-xs text-muted-foreground">
{activeThread?.id || '-'}
</span>
</div>
</div>
</CardSidebar>
<CardSidebar
title="Assistant"
onRevealInFinderClick={onReviewInFinderClick}
onViewJsonClick={onViewJsonClick}
>
<ItemCardSidebar
description={activeThread?.assistants[0].assistant_name ?? ''}
title="Assistant"
disabled
/>
<div className="flex flex-col space-y-4 p-2">
<div className="flex items-center space-x-2">
<LogoMark width={24} height={24} />
<span className="font-bold capitalize">
{activeThread?.assistants[0].assistant_name ?? '-'}
</span>
</div>
<div>
<label
id="thread-title"
className="mb-2 inline-block font-bold text-gray-600 dark:text-gray-300"
>
Instructions
</label>
<Textarea
id="assistant-instructions"
placeholder="Eg. You are a helpful assistant."
value={activeThread?.assistants[0].instructions ?? ''}
onChange={(e) => {
if (activeThread)
updateThreadMetadata({
...activeThread,
assistants: [
{
...activeThread.assistants[0],
instructions: e.target.value || '',
},
],
})
}}
/>
</div>
</div>
</CardSidebar>
<CardSidebar
title="Model"
onRevealInFinderClick={onReviewInFinderClick}
onViewJsonClick={onViewJsonClick}
>
<DropdownListSidebar />
<div className="p-2">
<DropdownListSidebar />
</div>
</CardSidebar>
</div>
</div>

View File

@ -84,26 +84,57 @@ const SimpleTextMessage: React.FC<ThreadMessage> = (props) => {
}, [props.content])
return (
<div className="group relative mx-auto rounded-xl px-4 lg:w-3/4">
<div className="group relative mx-auto rounded-xl px-8">
<div
className={twMerge(
'mb-2 flex items-center justify-start gap-x-2',
!isUser && 'mt-2'
)}
>
{!isUser && !isSystem && <LogoMark width={20} />}
<div className="text-sm font-extrabold capitalize">{props.role}</div>
<p className="text-xs font-medium">{displayDate(props.created)}</p>
{!isUser && !isSystem && <LogoMark width={28} />}
{isUser && (
<div className="flex h-8 w-8 items-center justify-center rounded-full border border-border">
<svg
width="12"
height="16"
viewBox="0 0 12 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 0.497864C4.34315 0.497864 3 1.84101 3 3.49786C3 5.15472 4.34315 6.49786 6 6.49786C7.65685 6.49786 9 5.15472 9 3.49786C9 1.84101 7.65685 0.497864 6 0.497864ZM9.75 7.99786L2.24997 7.99787C1.00734 7.99787 0 9.00527 0 10.2479C0 11.922 0.688456 13.2633 1.81822 14.1701C2.93013 15.0625 4.42039 15.4979 6 15.4979C7.57961 15.4979 9.06987 15.0625 10.1818 14.1701C11.3115 13.2633 12 11.922 12 10.2479C12 9.00522 10.9926 7.99786 9.75 7.99786Z"
fill="#9CA3AF"
/>
</svg>
</div>
)}
<div
className={twMerge(
'text-sm font-extrabold capitalize',
isUser && 'text-gray-500'
)}
>
{props.role}
</div>
<p className="text-xs font-medium text-gray-400">
{displayDate(props.created)}
</p>
<div
className={twMerge(
'absolute right-0 cursor-pointer transition-all',
messages[messages.length - 1]?.id === props.id
? 'absolute -bottom-10 left-4'
: 'hidden group-hover:flex'
messages[messages.length - 1]?.id === props.id && !isUser
? 'absolute -bottom-10 right-8'
: 'hidden group-hover:absolute group-hover:-top-2 group-hover:right-8 group-hover:flex'
)}
>
<MessageToolbar message={props} />
</div>
{messages[messages.length - 1]?.id === props.id &&
(props.status === MessageStatus.Pending || tokenSpeed > 0) && (
<p className="absolute right-8 text-xs font-medium text-foreground">
Token Speed: {Number(tokenSpeed).toFixed(2)}/s
</p>
)}
</div>
<div className={twMerge('w-full')}>
@ -115,7 +146,9 @@ const SimpleTextMessage: React.FC<ThreadMessage> = (props) => {
<div
className={twMerge(
'message flex flex-grow flex-col gap-y-2 text-[15px] font-normal leading-relaxed',
isUser && 'whitespace-pre-wrap break-words'
isUser
? 'whitespace-pre-wrap break-words'
: 'rounded-xl bg-secondary p-4'
)}
// eslint-disable-next-line @typescript-eslint/naming-convention
dangerouslySetInnerHTML={{ __html: parsedText }}
@ -123,11 +156,6 @@ const SimpleTextMessage: React.FC<ThreadMessage> = (props) => {
</>
)}
</div>
{(props.status === MessageStatus.Pending || tokenSpeed > 0) && (
<p className="mt-2 text-xs font-medium text-foreground">
Token Speed: {Number(tokenSpeed).toFixed(2)}/s
</p>
)}
</div>
)
}

View File

@ -1,21 +1,28 @@
import { useEffect } from 'react'
import { Button } from '@janhq/uikit'
import { motion as m } from 'framer-motion'
import { useAtomValue } from 'jotai'
import { GalleryHorizontalEndIcon } from 'lucide-react'
import {
GalleryHorizontalEndIcon,
MoreVerticalIcon,
Trash2Icon,
Paintbrush,
} from 'lucide-react'
import { twMerge } from 'tailwind-merge'
import { useCreateNewThread } from '@/hooks/useCreateNewThread'
import useDeleteThread from '@/hooks/useDeleteConversation'
import useGetAllThreads from '@/hooks/useGetAllThreads'
import useGetAssistants from '@/hooks/useGetAssistants'
import useGetAssistants from '@/hooks/useGetAssistants'
import { useGetDownloadedModels } from '@/hooks/useGetDownloadedModels'
import useSetActiveThread from '@/hooks/useSetActiveThread'
import { displayDate } from '@/utils/datetime'
import {
activeThreadAtom,
threadStatesAtom,
threadsAtom,
} from '@/helpers/atoms/Conversation.atom'
@ -23,69 +30,101 @@ import {
export default function ThreadList() {
const threads = useAtomValue(threadsAtom)
const threadStates = useAtomValue(threadStatesAtom)
const { requestCreateNewThread } = useCreateNewThread()
const { assistants } = useGetAssistants()
const { getAllThreads } = useGetAllThreads()
const { assistants } = useGetAssistants()
const { requestCreateNewThread } = useCreateNewThread()
const activeThread = useAtomValue(activeThreadAtom)
const { deleteThread, cleanThread } = useDeleteThread()
const { downloadedModels } = useGetDownloadedModels()
const { activeThreadId, setActiveThread: onThreadClick } =
useSetActiveThread()
useEffect(() => {
getAllThreads()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const onCreateConversationClick = async () => {
if (assistants.length === 0) {
alert('No assistant available')
return
useEffect(() => {
if (
downloadedModels.length !== 0 &&
threads.length === 0 &&
assistants.length !== 0 &&
!activeThread
) {
requestCreateNewThread(assistants[0])
}
requestCreateNewThread(assistants[0])
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assistants, threads, downloadedModels, activeThread])
return (
<div>
<div className="sticky top-0 z-20 flex flex-col border-b border-border bg-background px-4 py-3">
<Button
size="sm"
themes="secondary"
onClick={onCreateConversationClick}
>
Create New Chat
</Button>
</div>
<div className="px-3 py-4">
{threads.length === 0 ? (
<div className="px-4 py-8 text-center">
<GalleryHorizontalEndIcon
size={26}
className="mx-auto mb-3 text-muted-foreground"
/>
<h2 className="font-semibold">No Chat History</h2>
<p className="mt-1 text-xs">Get started by creating a new chat</p>
<h2 className="font-semibold">No Thread History</h2>
</div>
) : (
threads.map((thread, i) => {
const lastMessage = threadStates[thread.id]?.lastMessage ?? ''
const lastMessage =
threadStates[thread.id]?.lastMessage ?? 'No new message'
return (
<div
key={i}
className={twMerge(
'relative flex cursor-pointer flex-col border-b border-border px-4 py-2 hover:bg-secondary/20',
activeThreadId === thread.id && 'bg-secondary-10'
`group/message relative mb-1 flex cursor-pointer flex-col transition-all hover:rounded-lg hover:bg-gray-100 hover:dark:bg-secondary/50`
)}
onClick={() => onThreadClick(thread)}
>
<p className="mb-1 line-clamp-1 text-xs leading-5">
{thread.updated &&
displayDate(new Date(thread.updated).getTime())}
</p>
<h2 className="line-clamp-1">{thread.title}</h2>
<p className="mt-1 line-clamp-2 text-xs">{lastMessage}</p>
<div className="relative z-10 p-4 py-4">
<div className="flex justify-between">
<h2 className="line-clamp-1 font-bold">{thread.title}</h2>
<p className="mb-1 line-clamp-1 text-xs leading-5 text-muted-foreground">
{thread.updated &&
displayDate(new Date(thread.updated).getTime())}
</p>
</div>
<p className="mt-1 line-clamp-1 text-xs text-gray-700 group-hover/message:max-w-[160px] dark:text-gray-300">
{lastMessage || 'No new message'}
</p>
</div>
<div
className={twMerge(
`group/icon invisible absolute bottom-2 right-2 z-20 rounded-lg p-1 text-muted-foreground hover:bg-gray-200 group-hover/message:visible hover:dark:bg-secondary`
)}
>
<MoreVerticalIcon />
<div className="invisible absolute right-0 z-20 w-40 overflow-hidden rounded-lg border border-border bg-background shadow-lg group-hover/icon:visible">
<div
className="flex cursor-pointer items-center space-x-2 px-4 py-2 hover:bg-secondary"
onClick={() => cleanThread(thread.id)}
>
<Paintbrush size={16} className="text-muted-foreground" />
<span className="text-bold text-black dark:text-muted-foreground">
Clean thread
</span>
</div>
<div
className="flex cursor-pointer items-center space-x-2 px-4 py-2 hover:bg-secondary"
onClick={() => deleteThread(thread.id)}
>
<Trash2Icon size={16} className="text-muted-foreground" />
<span className="text-bold text-black dark:text-muted-foreground">
Delete thread
</span>
</div>
</div>
</div>
{/* {messages.length > 0 && (
)} */}
{activeThreadId === thread.id && (
<m.div
className="absolute right-0 top-0 h-full w-1 bg-primary/50"
layoutId="active-convo"
className="absolute inset-0 left-0 h-full w-full rounded-lg bg-gray-100 p-4 dark:bg-secondary/50"
layoutId="active-thread"
/>
)}
</div>

View File

@ -1,20 +1,18 @@
import { Fragment, useEffect, useRef, useState } from 'react'
import { ChangeEvent, Fragment, KeyboardEvent, useEffect, useRef } from 'react'
import { Button, Badge, Textarea } from '@janhq/uikit'
import { Button, Textarea } from '@janhq/uikit'
import { useAtom, useAtomValue } from 'jotai'
import { Trash2Icon, Paintbrush } from 'lucide-react'
import { twMerge } from 'tailwind-merge'
import { currentPromptAtom } from '@/containers/Providers/Jotai'
import LogoMark from '@/containers/Brand/Logo/Mark'
import ShortCut from '@/containers/Shortcut'
import { currentPromptAtom } from '@/containers/Providers/Jotai'
import { MainViewState } from '@/constants/screens'
import { useActiveModel } from '@/hooks/useActiveModel'
import useDeleteThread from '@/hooks/useDeleteConversation'
import { useGetDownloadedModels } from '@/hooks/useGetDownloadedModels'
import { useMainViewState } from '@/hooks/useMainViewState'
@ -25,39 +23,34 @@ import ChatBody from '@/screens/Chat/ChatBody'
import ThreadList from '@/screens/Chat/ThreadList'
import Sidebar from './Sidebar'
import Sidebar, { showRightSideBarAtom } from './Sidebar'
import {
activeThreadAtom,
getActiveThreadIdAtom,
threadsAtom,
waitingToSendMessage,
} from '@/helpers/atoms/Conversation.atom'
import { activeThreadStateAtom } from '@/helpers/atoms/Conversation.atom'
const ChatScreen = () => {
const currentConvo = useAtomValue(activeThreadAtom)
const activeThread = useAtomValue(activeThreadAtom)
const { downloadedModels } = useGetDownloadedModels()
const { deleteThread, cleanThread } = useDeleteThread()
const { activeModel, stateModel } = useActiveModel()
const { setMainViewState } = useMainViewState()
const [currentPrompt, setCurrentPrompt] = useAtom(currentPromptAtom)
const currentConvoState = useAtomValue(activeThreadStateAtom)
const { sendChatMessage } = useSendChatMessage()
const isWaitingForResponse = currentConvoState?.waitingForResponse ?? false
const activeThreadState = useAtomValue(activeThreadStateAtom)
const { sendChatMessage, queuedMessage } = useSendChatMessage()
const isWaitingForResponse = activeThreadState?.waitingForResponse ?? false
const disabled = currentPrompt.trim().length === 0 || isWaitingForResponse
const activeThreadId = useAtomValue(getActiveThreadIdAtom)
const [isWaitingToSend, setIsWaitingToSend] = useAtom(waitingToSendMessage)
const conversations = useAtomValue(threadsAtom)
const isEnableChat = (currentConvo && activeModel) || conversations.length > 0
const [isModelAvailable, setIsModelAvailable] = useState(
true
// downloadedModels.some((x) => x.id === currentConvo?.modelId)
)
const showing = useAtomValue(showRightSideBarAtom)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const modelRef = useRef(activeModel)
@ -101,50 +94,20 @@ const ChatScreen = () => {
}
return (
<div className="flex h-full">
<div className="flex h-full w-64 flex-shrink-0 flex-col overflow-y-auto border-r border-border">
<div className="flex h-full w-full">
<div className="flex h-full w-60 flex-shrink-0 flex-col overflow-y-auto border-r border-border bg-background dark:bg-background/50">
<ThreadList />
</div>
<div className="relative flex h-full w-[calc(100%-256px)] flex-col bg-muted/10">
<div
className={twMerge(
'relative flex h-full flex-col bg-background',
activeThread && activeThreadId && showing
? 'w-[calc(100%-560px)]'
: 'w-full'
)}
>
<div className="flex h-full w-full flex-col justify-between">
{isEnableChat && currentConvo && (
<div className="h-[53px] flex-shrink-0 border-b border-border bg-background p-4">
<div className="flex items-center justify-between">
<span>{currentConvo.title}</span>
<div
className={twMerge(
'flex items-center space-x-3',
!isModelAvailable && '-mt-1'
)}
>
{!isModelAvailable && (
<Button
themes="secondary"
className="relative z-10"
size="sm"
onClick={() =>
setMainViewState(MainViewState.ExploreModels)
}
>
Download Model
</Button>
)}
<Paintbrush
size={16}
className="cursor-pointer text-muted-foreground"
onClick={() => cleanThread()}
/>
<Trash2Icon
size={16}
className="cursor-pointer text-muted-foreground"
onClick={() => deleteThread()}
/>
</div>
</div>
</div>
)}
{isEnableChat ? (
{activeThread ? (
<div className="flex h-full w-full overflow-y-auto overflow-x-hidden">
<ChatBody />
</div>
@ -152,43 +115,57 @@ const ChatScreen = () => {
<div className="mx-auto mt-8 flex h-full w-3/4 flex-col items-center justify-center text-center">
{downloadedModels.length === 0 && (
<Fragment>
<h1 className="text-lg font-medium">{`Oops, you don't have a Model`}</h1>
<p className="mt-1">{`Lets download your first model.`}</p>
<LogoMark
className="mx-auto mb-4 animate-wave"
width={56}
height={56}
/>
<h1 className="text-2xl font-bold">Welcome!</h1>
<p className="mt-1 text-base">
You need to download your first model
</p>
<Button
className="mt-4"
onClick={() =>
setMainViewState(MainViewState.ExploreModels)
}
onClick={() => setMainViewState(MainViewState.Hub)}
>
Explore Models
Explore The Hub
</Button>
</Fragment>
)}
{!activeModel && downloadedModels.length > 0 && (
<Fragment>
<h1 className="text-lg font-medium">{`You dont have any actively running models`}</h1>
<p className="mt-1">{`Please start a downloaded model to use this feature.`}</p>
<Badge className="mt-4" themes="outline">
<ShortCut menu="E" />
&nbsp; to show your model
</Badge>
</Fragment>
)}
</div>
)}
<div className="mx-auto flex w-full flex-shrink-0 items-center justify-center space-x-4 p-4 lg:w-3/4">
{stateModel.loading && (
<div className="mb-1 mt-2 py-2 text-center">
<span className="rounded-lg border border-border px-4 py-2 shadow-lg">
Starting model {stateModel.model}
</span>
</div>
)}
{queuedMessage && (
<div className="my-2 py-2 text-center">
<span className="rounded-lg border border-border px-4 py-2 shadow-lg">
Message queued. It can be sent once the model has started
</span>
</div>
)}
<div className="mx-auto flex w-full flex-shrink-0 items-end justify-center space-x-4 px-8 py-4">
<Textarea
className="min-h-10 h-10 max-h-16 resize-none pr-20"
className="min-h-10 h-10 max-h-[400px] resize-none pr-20"
ref={textareaRef}
onKeyDown={(e) => onKeyDown(e)}
placeholder="Type your message ..."
disabled={stateModel.loading || !currentConvo}
onKeyDown={(e: KeyboardEvent<HTMLTextAreaElement>) =>
onKeyDown(e)
}
placeholder="Enter your message..."
disabled={stateModel.loading || !activeThread}
value={currentPrompt}
onChange={(e) => onPromptChange(e)}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) =>
onPromptChange(e)
}
/>
<Button
size="lg"
disabled={disabled || stateModel.loading || !currentConvo}
disabled={disabled || stateModel.loading || !activeThread}
themes={'primary'}
onClick={sendChatMessage}
>
@ -197,7 +174,8 @@ const ChatScreen = () => {
</div>
</div>
</div>
<Sidebar />
{/* Sidebar */}
{activeThreadId && activeThread && <Sidebar />}
</div>
)
}

View File

@ -61,7 +61,7 @@ const ExploreModelItemHeader: React.FC<Props> = ({ model }) => {
setMainViewState(MainViewState.MyModels)
}}
>
View Downloaded Model
Use
</Button>
)
}

View File

@ -57,7 +57,7 @@ const ModelVersionItem: React.FC<Props> = ({ model }) => {
setMainViewState(MainViewState.MyModels)
}}
>
View Downloaded Model
Use
</Button>
)
}

View File

@ -11,7 +11,7 @@ const ExploreModelsScreen = () => {
if (loading) return <Loader description="loading ..." />
return (
<div className="flex h-full w-full overflow-y-auto">
<div className="flex h-full w-full overflow-y-auto bg-background">
<div className="h-full w-full p-4">
<div className="h-full" data-test-id="testid-explore-models">
<ScrollArea>

View File

@ -1,77 +0,0 @@
import { Button } from '@janhq/uikit'
import {
Modal,
ModalTrigger,
ModalContent,
ModalHeader,
ModalTitle,
Progress,
} from '@janhq/uikit'
import { DatabaseIcon } from 'lucide-react'
import { MainViewState } from '@/constants/screens'
import { useDownloadState } from '@/hooks/useDownloadState'
import { useMainViewState } from '@/hooks/useMainViewState'
import { formatDownloadPercentage } from '@/utils/converter'
export default function BlankStateMyModel() {
const { setMainViewState } = useMainViewState()
const { downloadStates } = useDownloadState()
return (
<div className="flex h-full items-center justify-center px-4">
<div className="text-center">
<DatabaseIcon size={32} className="mx-auto text-muted-foreground" />
<div className="mt-4">
<h1 className="text-xl font-bold leading-snug">{`Oops, you don't have a model yet.`}</h1>
<p className="mt-1 text-base">
{downloadStates.length > 0
? `Downloading model ... `
: `Lets download your first model`}
</p>
{downloadStates?.length > 0 && (
<Modal>
<ModalTrigger asChild>
<Button themes="outline" className="mr-2 mt-6">
<span>Downloading {downloadStates.length} model(s)</span>
</Button>
</ModalTrigger>
<ModalContent>
<ModalHeader>
<ModalTitle>Downloading model</ModalTitle>
</ModalHeader>
{downloadStates.map((item, i) => {
return (
<div className="pt-2" key={i}>
<Progress
className="mb-2 h-2"
value={
formatDownloadPercentage(item?.percent, {
hidePercentage: true,
}) as number
}
/>
<div className="flex items-center justify-between">
<p>{item?.modelId}</p>
<span>{formatDownloadPercentage(item?.percent)}</span>
</div>
</div>
)
})}
</ModalContent>
</Modal>
)}
<Button
className="mt-6"
onClick={() => setMainViewState(MainViewState.ExploreModels)}
>
Explore Models
</Button>
</div>
</div>
</div>
)
}

View File

@ -1,180 +0,0 @@
import { Fragment } from 'react'
import {
Badge,
Avatar,
AvatarFallback,
AvatarImage,
Button,
Modal,
ModalTrigger,
ScrollArea,
ModalClose,
ModalFooter,
ModalContent,
ModalHeader,
ModalTitle,
} from '@janhq/uikit'
import { MainViewState } from '@/constants/screens'
import { useActiveModel } from '@/hooks/useActiveModel'
import useDeleteModel from '@/hooks/useDeleteModel'
import { useDownloadState } from '@/hooks/useDownloadState'
import { useGetDownloadedModels } from '@/hooks/useGetDownloadedModels'
import { useMainViewState } from '@/hooks/useMainViewState'
import BlankStateMyModel from '@/screens/MyModels/BlankState'
import { toGigabytes } from '@/utils/converter'
const MyModelsScreen = () => {
const { downloadedModels } = useGetDownloadedModels()
const { downloadStates } = useDownloadState()
const { setMainViewState } = useMainViewState()
const { deleteModel } = useDeleteModel()
const { activeModel, startModel, stopModel, stateModel } = useActiveModel()
if (downloadedModels.length === 0) return <BlankStateMyModel />
const onModelActionClick = (modelId: string) => {
if (activeModel && activeModel.id === modelId) {
stopModel(modelId)
} else {
startModel(modelId)
}
}
return (
<div className="flex h-full w-full">
<ScrollArea className="h-full w-full">
<div className="p-4" data-test-id="testid-my-models">
<div className="grid grid-cols-2 gap-4 lg:grid-cols-3">
{downloadedModels.map((model, i) => {
const isActiveModel = stateModel.model === model.id
return (
<div
key={i}
className="rounded-lg border border-border bg-background p-4"
>
<Fragment>
<div className="flex items-start gap-x-4">
<div className="inline-flex rounded-full border border-border p-1">
<Avatar className="h-8 w-8">
<AvatarImage alt={model.metadata.author} />
<AvatarFallback>
{model.metadata.author.charAt(0)}
</AvatarFallback>
</Avatar>
</div>
<div>
<h2 className="mb-1 font-medium capitalize">
{model.metadata.author}
</h2>
<p className="line-clamp-1">{model.name}</p>
<div className="mt-2 flex items-center gap-2">
<Badge themes="secondary">v{model.version}</Badge>
<Badge themes="outline">GGUF</Badge>
<Badge themes="outline">
{toGigabytes(model.metadata.size)}
</Badge>
</div>
<p className="mt-2 line-clamp-2 break-all">
{model.description}
</p>
</div>
</div>
<div className="mt-4 flex items-center justify-center gap-x-2 gap-y-4 border-t border-border pt-4">
<Modal>
<ModalTrigger asChild>
<Button themes="ghost" block>
Delete
</Button>
</ModalTrigger>
<ModalContent>
<ModalHeader>
<ModalTitle>Are you sure?</ModalTitle>
</ModalHeader>
<p className="leading-relaxed">
Delete model {model.name}, v{model.version},{' '}
{toGigabytes(model.metadata.size)}.
</p>
<ModalFooter>
<div className="flex gap-x-2">
<ModalClose asChild>
<Button themes="ghost">No</Button>
</ModalClose>
<ModalClose asChild>
<Button
themes="danger"
onClick={() =>
setTimeout(async () => {
await stopModel(model.id)
deleteModel(model)
}, 500)
}
>
Yes
</Button>
</ModalClose>
</div>
</ModalFooter>
</ModalContent>
</Modal>
<Button
block
themes={
isActiveModel && stateModel.state === 'stop'
? 'danger'
: 'primary'
}
className="capitalize"
loading={isActiveModel ? stateModel.loading : false}
onClick={() => onModelActionClick(model.id)}
>
{isActiveModel ? stateModel.state : 'Start'}
&nbsp;Model
</Button>
</div>
</Fragment>
</div>
)
})}
<div className="rounded-lg border border-border bg-background p-4 hover:border-primary/60">
<div className="flex h-full flex-col justify-between">
<div>
<h2 className="text-lg font-medium">Download more models?</h2>
<p className="mt-2 leading-relaxed">
You have <span>{downloadedModels.length}</span> model(s)
downloaded.&nbsp;
{downloadStates.length > 0 && (
<span>
And {downloadStates.length} downloading progress.
</span>
)}
</p>
</div>
<div className="mt-4 flex items-end justify-center gap-4 border-t border-border pt-4">
<Button
themes="secondary"
block
onClick={() =>
setMainViewState(MainViewState.ExploreModels)
}
>
Explore Models
</Button>
</div>
</div>
</div>
</div>
</div>
</ScrollArea>
</div>
)
}
export default MyModelsScreen

View File

@ -3,7 +3,7 @@
import React, { useState, useEffect, useRef, useContext } from 'react'
import { Switch, Button } from '@janhq/uikit'
import { Button } from '@janhq/uikit'
import Loader from '@/containers/Loader'

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