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:
Faisal Amir 2023-12-04 10:55:47 +07:00 committed by GitHub
parent e5a440fc8f
commit 424b00338e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
60 changed files with 1435 additions and 965 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,5 @@
import { useState } from 'react'
import {
ChatCompletionMessage,
ChatCompletionRole,
@ -10,7 +12,7 @@ import {
ThreadMessage,
events,
} from '@janhq/core'
import { ConversationalExtension, InferenceExtension } from '@janhq/core'
import { ConversationalExtension } from '@janhq/core'
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import { ulid } from 'ulid'
@ -44,6 +46,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,
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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'}
&nbsp;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>
)
}

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

View File

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

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

View File

@ -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">{`Lets 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 dont have any actively running models`}</h1>
<p className="mt-1">{`Please start a downloaded model to use this feature.`}</p>
<Badge className="mt-4" themes="outline">
<ShortCut menu="E" />
&nbsp; to show your model
</Badge>
</Fragment>
)}
{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

View File

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