feat: revamp thread screen (#802)
* Make thread screen as default screen * Blank state when user have not any model * Cleanup topbar thread screen * Improve style right panel * Add instructions right panel * Styling thread list history * Resolve conflict * Default title new thread * Fix trigger panel sidebar * Make default right panel false when no activethread * Fix CI test * chore: assistant instruction with system prompt * Fix title and blank state explore the hub * Claenup style thread screen and add buble message for assitant * Remove unused import * Styling more menus on thread list and right panel, and make max height textarea 400 pixel * Finished revamp ui thread * Finished system monitor UI * Style box running models * Make animate right panel more smooth * Add status arround textarea for starting model info * Temporary disable hide left panel * chore: system resource monitoring update * copy nits * chore: typo * Reverse icon chevron accordion * Move my models into setting page --------- Co-authored-by: Louis <louis@jan.ai> Co-authored-by: 0xSage <n@pragmatic.vc>
This commit is contained in:
parent
e5a440fc8f
commit
424b00338e
@ -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. */
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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...
|
||||
});
|
||||
})
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
@ -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...
|
||||
});
|
||||
@ -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)
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
41
electron/tests/system-monitor.e2e.spec.ts
Normal file
41
electron/tests/system-monitor.e2e.spec.ts
Normal 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...
|
||||
})
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -10,3 +10,4 @@ export * from './tooltip'
|
||||
export * from './modal'
|
||||
export * from './command'
|
||||
export * from './textarea'
|
||||
export * from './select'
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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
139
uikit/src/select/index.tsx
Normal 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,
|
||||
}
|
||||
31
uikit/src/select/styles.scss
Normal file
31
uikit/src/select/styles.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
export enum MainViewState {
|
||||
Welcome,
|
||||
ExploreModels,
|
||||
Hub,
|
||||
MyModels,
|
||||
Setting,
|
||||
Chat,
|
||||
Settings,
|
||||
Thread,
|
||||
SystemMonitor,
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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" />
|
||||
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>
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)}`
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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'
|
||||
)}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
42
web/hooks/useClickOutside.ts
Normal file
42
web/hooks/useClickOutside.ts
Normal 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
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Model, ExtensionType, ModelExtension } from '@janhq/core'
|
||||
|
||||
import { useAtom, useAtomValue } from 'jotai'
|
||||
import { useAtom } from 'jotai'
|
||||
|
||||
import { useDownloadState } from './useDownloadState'
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,7 @@ export default function useSendChatMessage() {
|
||||
const { activeModel } = useActiveModel()
|
||||
const selectedModel = useAtomValue(selectedModelAtom)
|
||||
const { startModel } = useActiveModel()
|
||||
const [queuedMessage, setQueuedMessage] = useState(false)
|
||||
|
||||
const sendChatMessage = async () => {
|
||||
if (!currentPrompt || currentPrompt.trim().length === 0) {
|
||||
@ -61,14 +64,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 +94,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 +151,17 @@ 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,
|
||||
queuedMessage,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
))
|
||||
|
||||
@ -1,7 +1,4 @@
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import {
|
||||
ChatCompletionRole,
|
||||
ChatCompletionMessage,
|
||||
EventName,
|
||||
MessageRequest,
|
||||
@ -11,8 +8,8 @@ import {
|
||||
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,21 +20,17 @@ import {
|
||||
deleteMessageAtom,
|
||||
getCurrentChatMessagesAtom,
|
||||
} from '@/helpers/atoms/ChatMessage.atom'
|
||||
import {
|
||||
activeThreadAtom,
|
||||
threadStatesAtom,
|
||||
} from '@/helpers/atoms/Conversation.atom'
|
||||
import { activeThreadAtom } from '@/helpers/atoms/Conversation.atom'
|
||||
|
||||
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 threadStateAtom = useMemo(
|
||||
// () => atom((get) => get(threadStatesAtom)[thread?.id ?? '']),
|
||||
// [thread?.id]
|
||||
// )
|
||||
// const threadState = useAtomValue(threadStateAtom)
|
||||
const stopInference = async () => {
|
||||
await extensionManager
|
||||
.get<InferenceExtension>(ExtensionType.Inference)
|
||||
@ -51,12 +44,7 @@ const MessageToolbar = ({ message }: { message: ThreadMessage }) => {
|
||||
}
|
||||
|
||||
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
|
||||
@ -97,7 +85,7 @@ const MessageToolbar = ({ message }: { message: ThreadMessage }) => {
|
||||
})
|
||||
}}
|
||||
>
|
||||
<ClipboardCopy size={14} />
|
||||
<Copy size={14} />
|
||||
</div>
|
||||
<div
|
||||
className="cursor-pointer px-2 py-2 hover:bg-background/80"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">{`Let’s 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 don’t 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" />
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -61,7 +61,7 @@ const ExploreModelItemHeader: React.FC<Props> = ({ model }) => {
|
||||
setMainViewState(MainViewState.MyModels)
|
||||
}}
|
||||
>
|
||||
View Downloaded Model
|
||||
Use
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@ -57,7 +57,7 @@ const ModelVersionItem: React.FC<Props> = ({ model }) => {
|
||||
setMainViewState(MainViewState.MyModels)
|
||||
}}
|
||||
>
|
||||
View Downloaded Model
|
||||
Use
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 ... `
|
||||
: `Let’s 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>
|
||||
)
|
||||
}
|
||||
@ -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'}
|
||||
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.
|
||||
{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
|
||||
@ -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'
|
||||
|
||||
|
||||
137
web/screens/Settings/Models/Row.tsx
Normal file
137
web/screens/Settings/Models/Row.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Model } from '@janhq/core'
|
||||
import { Badge } from '@janhq/uikit'
|
||||
|
||||
import {
|
||||
MoreVerticalIcon,
|
||||
Trash2Icon,
|
||||
PlayIcon,
|
||||
StopCircleIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { useActiveModel } from '@/hooks/useActiveModel'
|
||||
import { useClickOutside } from '@/hooks/useClickOutside'
|
||||
|
||||
import useDeleteModel from '@/hooks/useDeleteModel'
|
||||
|
||||
import { toGigabytes } from '@/utils/converter'
|
||||
|
||||
type RowModelProps = {
|
||||
data: Model
|
||||
}
|
||||
|
||||
export default function RowModel(props: RowModelProps) {
|
||||
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])
|
||||
|
||||
const { activeModel, startModel, stopModel, stateModel } = useActiveModel()
|
||||
const { deleteModel } = useDeleteModel()
|
||||
|
||||
const isActiveModel = stateModel.model === props.data.id
|
||||
|
||||
const onModelActionClick = (modelId: string) => {
|
||||
if (activeModel && activeModel.id === modelId) {
|
||||
stopModel(modelId)
|
||||
} else {
|
||||
startModel(modelId)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="relative border-b border-border last:border-none">
|
||||
<td className="px-6 py-4 font-bold">{props.data.name}</td>
|
||||
<td className="px-6 py-4 font-bold">{props.data.id}</td>
|
||||
<td className="px-6 py-4">
|
||||
<Badge themes="secondary">
|
||||
{toGigabytes(props.data.metadata.size)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<Badge themes="secondary">v{props.data.version}</Badge>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{stateModel.loading && stateModel.model === props.data.id ? (
|
||||
<Badge
|
||||
className="inline-flex items-center space-x-2"
|
||||
themes="secondary"
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full bg-gray-500" />
|
||||
<span className="capitalize">
|
||||
{stateModel.state === 'start' ? 'Starting...' : 'Stopping...'}
|
||||
</span>
|
||||
</Badge>
|
||||
) : activeModel && activeModel.id === props.data.id ? (
|
||||
<Badge
|
||||
themes="success"
|
||||
className="inline-flex items-center space-x-2"
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full bg-green-500" />
|
||||
<span>Active</span>
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
themes="secondary"
|
||||
className="inline-flex items-center space-x-2"
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full bg-gray-500" />
|
||||
<span>Inactive</span>
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-center">
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
ref={setToggle}
|
||||
onClick={() => {
|
||||
setMore(!more)
|
||||
}}
|
||||
>
|
||||
<MoreVerticalIcon className="h-5 w-5" />
|
||||
</div>
|
||||
{more && (
|
||||
<div
|
||||
className="absolute right-4 top-10 z-20 w-52 overflow-hidden rounded-lg border border-border bg-background py-2 shadow-lg"
|
||||
ref={setMenu}
|
||||
>
|
||||
<div
|
||||
className="flex cursor-pointer items-center space-x-2 px-4 py-2 hover:bg-secondary"
|
||||
onClick={() => {
|
||||
onModelActionClick(props.data.id)
|
||||
setMore(false)
|
||||
}}
|
||||
>
|
||||
{activeModel && activeModel.id === props.data.id ? (
|
||||
<StopCircleIcon size={16} className="text-muted-foreground" />
|
||||
) : (
|
||||
<PlayIcon size={16} className="text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-bold capitalize text-black dark:text-muted-foreground">
|
||||
{isActiveModel ? stateModel.state : 'Start'}
|
||||
Model
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="flex cursor-pointer items-center space-x-2 px-4 py-2 hover:bg-secondary"
|
||||
onClick={() => {
|
||||
setTimeout(async () => {
|
||||
await stopModel(props.data.id)
|
||||
deleteModel(props.data)
|
||||
}, 500)
|
||||
setMore(false)
|
||||
}}
|
||||
>
|
||||
<Trash2Icon size={16} className="text-muted-foreground" />
|
||||
<span className="text-bold text-black dark:text-muted-foreground">
|
||||
Delete Model
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
65
web/screens/Settings/Models/index.tsx
Normal file
65
web/screens/Settings/Models/index.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Input } from '@janhq/uikit'
|
||||
|
||||
import { SearchIcon } from 'lucide-react'
|
||||
|
||||
import { useGetDownloadedModels } from '@/hooks/useGetDownloadedModels'
|
||||
|
||||
import RowModel from './Row'
|
||||
|
||||
const Column = ['Name', 'Model ID', 'Size', 'Version', 'Status', '']
|
||||
|
||||
export default function Models() {
|
||||
const { downloadedModels } = useGetDownloadedModels()
|
||||
const [searchValue, setsearchValue] = useState('')
|
||||
|
||||
const filteredDownloadedModels = downloadedModels.filter((x) => {
|
||||
return x.name.toLowerCase().includes(searchValue.toLowerCase())
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border shadow-sm">
|
||||
<div className="px-6 py-5">
|
||||
<div className="relative w-1/3">
|
||||
<SearchIcon
|
||||
size={20}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Search"
|
||||
className="pl-8"
|
||||
onChange={(e) => {
|
||||
setsearchValue(e.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<table className="w-full px-8">
|
||||
<thead className="w-full border-b border-border bg-secondary">
|
||||
<tr>
|
||||
{Column.map((col, i) => {
|
||||
return (
|
||||
<th
|
||||
key={i}
|
||||
className="px-6 py-2 text-left font-normal last:text-center"
|
||||
>
|
||||
{col}
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredDownloadedModels
|
||||
? filteredDownloadedModels.map((x, i) => {
|
||||
return <RowModel key={i} data={x} />
|
||||
})
|
||||
: null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -12,6 +12,8 @@ import AppearanceOptions from '@/screens/Settings/Appearance'
|
||||
import ExtensionCatalog from '@/screens/Settings/CoreExtensions/ExtensionsCatalog'
|
||||
import PreferenceExtensions from '@/screens/Settings/CoreExtensions/PreferenceExtensions'
|
||||
|
||||
import Models from '@/screens/Settings/Models'
|
||||
|
||||
import { formatExtensionsName } from '@/utils/converter'
|
||||
|
||||
const SettingsScreen = () => {
|
||||
@ -24,7 +26,7 @@ const SettingsScreen = () => {
|
||||
const menu = ['Appearance']
|
||||
|
||||
if (typeof window !== 'undefined' && window.electronAPI) {
|
||||
menu.push('Core Extensions')
|
||||
menu.push('Extensions')
|
||||
}
|
||||
menu.push('Advanced')
|
||||
setMenus(menu)
|
||||
@ -40,7 +42,7 @@ const SettingsScreen = () => {
|
||||
|
||||
const handleShowOptions = (menu: string) => {
|
||||
switch (menu) {
|
||||
case 'Core Extensions':
|
||||
case 'Extensions':
|
||||
return <ExtensionCatalog />
|
||||
|
||||
case 'Appearance':
|
||||
@ -49,6 +51,9 @@ const SettingsScreen = () => {
|
||||
case 'Advanced':
|
||||
return <Advanced />
|
||||
|
||||
case 'Models':
|
||||
return <Models />
|
||||
|
||||
default:
|
||||
return (
|
||||
<PreferenceExtensions
|
||||
@ -61,13 +66,13 @@ const SettingsScreen = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<div className="flex h-full bg-background">
|
||||
<div className="flex h-full w-64 flex-shrink-0 flex-col overflow-y-auto border-r border-border">
|
||||
<ScrollArea className="h-full w-full">
|
||||
<div className="p-4">
|
||||
<div className="flex-shrink-0">
|
||||
<label className="font-bold uppercase text-muted-foreground">
|
||||
Options
|
||||
General
|
||||
</label>
|
||||
<div className="mt-2 font-medium">
|
||||
{menus.map((menu, i) => {
|
||||
@ -135,6 +140,38 @@ const SettingsScreen = () => {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
<label className="font-bold uppercase text-muted-foreground">
|
||||
Core extensions
|
||||
</label>
|
||||
<div className="mt-2 font-medium">
|
||||
<div className="relative my-0.5 block py-1.5">
|
||||
<div
|
||||
onClick={() => {
|
||||
setActiveStaticMenu('Models')
|
||||
setActivePreferenceExtension('')
|
||||
}}
|
||||
className="block w-full cursor-pointer"
|
||||
>
|
||||
<span
|
||||
className={twMerge(
|
||||
activePreferenceExtension === 'Models' &&
|
||||
'relative z-10'
|
||||
)}
|
||||
>
|
||||
Models
|
||||
</span>
|
||||
</div>
|
||||
{activeStaticMenu === 'Models' && (
|
||||
<m.div
|
||||
className="absolute inset-0 -left-3 h-full w-[calc(100%+24px)] rounded-md bg-primary/50"
|
||||
layoutId="active-static-core-extentions"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
117
web/screens/SystemMonitor/index.tsx
Normal file
117
web/screens/SystemMonitor/index.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
import { ScrollArea, Progress, Badge, Button } from '@janhq/uikit'
|
||||
|
||||
import { useAtomValue } from 'jotai'
|
||||
|
||||
import { useActiveModel } from '@/hooks/useActiveModel'
|
||||
|
||||
import { toGigabytes } from '@/utils/converter'
|
||||
|
||||
import {
|
||||
cpuUsageAtom,
|
||||
totalRamAtom,
|
||||
usedRamAtom,
|
||||
} from '@/helpers/atoms/SystemBar.atom'
|
||||
|
||||
const Column = ['Name', 'Model ID', 'Size', 'Version', 'Action']
|
||||
|
||||
export default function SystemMonitorScreen() {
|
||||
const totalRam = useAtomValue(totalRamAtom)
|
||||
const usedRam = useAtomValue(usedRamAtom)
|
||||
const cpuUsage = useAtomValue(cpuUsageAtom)
|
||||
const { activeModel, stateModel, stopModel } = useActiveModel()
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full bg-background dark:bg-background/50">
|
||||
<ScrollArea className="h-full w-full">
|
||||
<div className="h-full p-8" data-test-id="testid-system-monitor">
|
||||
<div className="grid grid-cols-2 gap-8 lg:grid-cols-3">
|
||||
<div className="rounded-xl border border-border px-8 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-base font-bold uppercase">
|
||||
ram ({Math.round((usedRam / totalRam) * 100)}%)
|
||||
</h4>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{toGigabytes(usedRam)} GB of {toGigabytes(totalRam)} GB used
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Progress
|
||||
className="mb-2 h-10 rounded-md"
|
||||
value={Math.round((usedRam / totalRam) * 100)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border px-8 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-base font-bold uppercase">
|
||||
cpu ({cpuUsage}%)
|
||||
</h4>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{cpuUsage}% of 100%
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Progress className="mb-2 h-10 rounded-md" value={cpuUsage} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeModel && (
|
||||
<div className="mt-8 overflow-hidden rounded-xl border border-border shadow-sm">
|
||||
<div className="px-6 py-5">
|
||||
<h4 className="text-base font-medium">Running Models</h4>
|
||||
</div>
|
||||
<div className="relative overflow-x-auto shadow-md">
|
||||
<table className="w-full px-8">
|
||||
<thead className="w-full border-b border-border bg-secondary">
|
||||
<tr>
|
||||
{Column.map((col, i) => {
|
||||
return (
|
||||
<th
|
||||
key={i}
|
||||
className="px-6 py-2 text-left font-normal last:text-center"
|
||||
>
|
||||
{col}
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="px-6 py-2 font-bold">
|
||||
{activeModel.name}
|
||||
</td>
|
||||
<td className="px-6 py-2 font-bold">{activeModel.id}</td>
|
||||
<td className="px-6 py-2">
|
||||
<Badge themes="secondary">
|
||||
{toGigabytes(activeModel.metadata.size)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-6 py-2">
|
||||
<Badge themes="secondary">v{activeModel.version}</Badge>
|
||||
</td>
|
||||
<td className="px-6 py-2 text-center">
|
||||
<Button
|
||||
block
|
||||
themes={
|
||||
stateModel.state === 'stop' ? 'danger' : 'primary'
|
||||
}
|
||||
className="w-16"
|
||||
loading={stateModel.loading}
|
||||
onClick={() => stopModel(activeModel.id)}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,74 +0,0 @@
|
||||
import { Fragment } from 'react'
|
||||
|
||||
import { Badge, Button } from '@janhq/uikit'
|
||||
|
||||
import LogoMark from '@/containers/Brand/Logo/Mark'
|
||||
|
||||
import ShortCut from '@/containers/Shortcut'
|
||||
|
||||
import { MainViewState } from '@/constants/screens'
|
||||
|
||||
import { useActiveModel } from '@/hooks/useActiveModel'
|
||||
import { useGetDownloadedModels } from '@/hooks/useGetDownloadedModels'
|
||||
|
||||
import { useMainViewState } from '@/hooks/useMainViewState'
|
||||
|
||||
const WelcomeScreen = () => {
|
||||
const { downloadedModels } = useGetDownloadedModels()
|
||||
const { activeModel } = useActiveModel()
|
||||
const { setMainViewState } = useMainViewState()
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center px-4">
|
||||
<div className="text-center">
|
||||
<LogoMark
|
||||
className="mx-auto mb-4 animate-wave"
|
||||
width={56}
|
||||
height={56}
|
||||
/>
|
||||
|
||||
{downloadedModels.length === 0 && !activeModel && (
|
||||
<Fragment>
|
||||
<h1
|
||||
data-testid="testid-welcome-title"
|
||||
className="text-2xl font-bold"
|
||||
>
|
||||
Welcome to Jan
|
||||
</h1>
|
||||
<p className="mt-1">{`Let’s download your first model`}</p>
|
||||
<Button
|
||||
className="mt-4"
|
||||
onClick={() => setMainViewState(MainViewState.ExploreModels)}
|
||||
>
|
||||
Explore Models
|
||||
</Button>
|
||||
</Fragment>
|
||||
)}
|
||||
{downloadedModels.length >= 1 && !activeModel && (
|
||||
<Fragment>
|
||||
<h1 className="mt-2 text-lg font-medium">{`You don’t 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" />
|
||||
to show your model
|
||||
</Badge>
|
||||
</Fragment>
|
||||
)}
|
||||
{downloadedModels.length >= 1 && activeModel && (
|
||||
<Fragment>
|
||||
<h1 className="mt-2 text-lg font-medium">{`Your Model is Active`}</h1>
|
||||
<p className="mt-1">{`You are ready to converse.`}</p>
|
||||
<Button
|
||||
className="mt-4"
|
||||
onClick={() => setMainViewState(MainViewState.Chat)}
|
||||
>
|
||||
Start a conversation
|
||||
</Button>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WelcomeScreen
|
||||
@ -1,8 +1,12 @@
|
||||
.message {
|
||||
@apply text-black dark:text-gray-300;
|
||||
ul,
|
||||
ol {
|
||||
list-style: auto;
|
||||
padding-left: 24px;
|
||||
}
|
||||
@apply text-muted-foreground;
|
||||
}
|
||||
|
||||
button[class*='react-scroll-to-bottom--'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user