feat: Jan Hub Revamp (#4491)

* feat: model hub revamp UI

* chore: model description - consistent markdown css

* chore: add model versions dropdown

* chore: integrate APIs - model sources

* chore: update model display name

* chore: lint fix

* chore: page transition animation

* feat: model search dropdown - deeplink

* chore: bump cortex version

* chore: add remote model sources

* chore: model download state

* chore: fix model metadata label

* chore: polish model detail page markdown

* test: fix test cases

* chore: initialize default Hub model sources

* chore: fix model stats

* chore: clean up click outside and inside hooks

* feat: change hub banner

* chore: lint fix

* chore: fix css long model id
This commit is contained in:
Louis 2025-01-28 22:23:25 +07:00 committed by GitHub
parent 261b44d906
commit 83f090826e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
97 changed files with 3202 additions and 874 deletions

View File

@ -1,10 +1,13 @@
import { BaseExtension, ExtensionTypeEnum } from '../extension' import { BaseExtension, ExtensionTypeEnum } from '../extension'
import { Model, ModelInterface, OptionType } from '../../types' import { Model, ModelInterface, ModelSource, OptionType } from '../../types'
/** /**
* Model extension for managing models. * Model extension for managing models.
*/ */
export abstract class ModelExtension extends BaseExtension implements ModelInterface { export abstract class ModelExtension
extends BaseExtension
implements ModelInterface
{
/** /**
* Model extension type. * Model extension type.
*/ */
@ -25,4 +28,16 @@ export abstract class ModelExtension extends BaseExtension implements ModelInter
abstract updateModel(modelInfo: Partial<Model>): Promise<Model> abstract updateModel(modelInfo: Partial<Model>): Promise<Model>
abstract deleteModel(model: string): Promise<void> abstract deleteModel(model: string): Promise<void>
abstract isModelLoaded(model: string): Promise<boolean> abstract isModelLoaded(model: string): Promise<boolean>
/**
* Get model sources
*/
abstract getSources(): Promise<ModelSource[]>
/**
* Add a model source
*/
abstract addSource(source: string): Promise<void>
/**
* Delete a model source
*/
abstract deleteSource(source: string): Promise<void>
} }

View File

@ -2,3 +2,4 @@ export * from './modelEntity'
export * from './modelInterface' export * from './modelInterface'
export * from './modelEvent' export * from './modelEvent'
export * from './modelImport' export * from './modelImport'
export * from './modelSource'

View File

@ -1,5 +1,6 @@
import { Model } from './modelEntity' import { Model } from './modelEntity'
import { OptionType } from './modelImport' import { OptionType } from './modelImport'
import { ModelSource } from './modelSource'
/** /**
* Model extension for managing models. * Model extension for managing models.
@ -50,4 +51,17 @@ export interface ModelInterface {
name?: string, name?: string,
optionType?: OptionType optionType?: OptionType
): Promise<void> ): Promise<void>
/**
* Get model sources
*/
getSources(): Promise<ModelSource[]>
/**
* Add a model source
*/
addSource(source: string): Promise<void>
/**
* Delete a model source
*/
deleteSource(source: string): Promise<void>
} }

View File

@ -0,0 +1,67 @@
/**
* GGUF Metadata of the model source
*/
export interface GGUF {
architecture: string
bos_token: string
chat_template: string
context_length: number
eos_token: string
total: number
}
/**
* Card Metadata of the model source
*/
export interface CardData {
license: string
pipeline_tag: string
}
/**
* Model Metadata of the model source
*/
export interface Metadata {
author: string
cardData: CardData
createdAt: string
description: string
disabled: boolean
downloads: number
gated: boolean
gguf: GGUF
id: string
inference: string
lastModified: string
likes: number
modelId: string
pipeline_tag: string
private: boolean
sha: string
siblings: Array<{
rfilename: string
size: number
}>
spaces: string[]
tags: string[]
usedStorage: number
apiKey?: string
}
/**
* Model source sibling information
*/
export interface ModelSibling {
id: string
size: number
}
/**
* Model source object
*/
export interface ModelSource {
id: string
metadata: Metadata
models: ModelSibling[]
type?: string
}

View File

@ -17,7 +17,7 @@ test('explores hub', async ({ hubPage }) => {
await hubPage.navigateByMenu() await hubPage.navigateByMenu()
await hubPage.verifyContainerVisible() await hubPage.verifyContainerVisible()
await hubPage.scrollToBottom() await hubPage.scrollToBottom()
const useModelBtn = page.getByTestId(/^use-model-btn-.*/).first() const useModelBtn = page.getByTestId(/^setup-btn/).first()
await expect(useModelBtn).toBeVisible({ await expect(useModelBtn).toBeVisible({
timeout: TIMEOUT, timeout: TIMEOUT,

View File

@ -1,33 +1,18 @@
import { expect } from '@playwright/test' import { expect } from '@playwright/test'
import { page, test, TIMEOUT } from '../config/fixtures' import { page, test, TIMEOUT } from '../config/fixtures'
test('Select GPT model from Hub and Chat with Invalid API Key', async ({ test('show onboarding screen without any threads created or models downloaded', async () => {
hubPage, await page.getByTestId('Thread').first().click({
}) => { timeout: TIMEOUT,
await hubPage.navigateByMenu() })
await hubPage.verifyContainerVisible()
// Select the first GPT model
await page
.locator('[data-testid^="use-model-btn"][data-testid*="gpt"]')
.first()
.click()
await page.getByTestId('txt-input-chat').fill('dummy value')
const denyButton = page.locator('[data-testid="btn-deny-product-analytics"]') const denyButton = page.locator('[data-testid="btn-deny-product-analytics"]')
if ((await denyButton.count()) > 0) { if ((await denyButton.count()) > 0) {
await denyButton.click({ force: true }) await denyButton.click({ force: true })
} else {
await page.getByTestId('btn-send-chat').click({ force: true })
} }
await page.waitForFunction( const onboardScreen = page.getByTestId('onboard-screen')
() => { await expect(onboardScreen).toBeVisible({
const loaders = document.querySelectorAll('[data-testid$="loader"]') timeout: TIMEOUT,
return !loaders.length })
},
{ timeout: TIMEOUT }
)
}) })

View File

@ -1 +1 @@
1.0.9-rc4 1.0.9-rc5

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,6 @@
import { defineConfig } from 'rolldown' import { defineConfig } from 'rolldown'
import settingJson from './resources/settings.json' with { type: 'json' } import settingJson from './resources/settings.json' with { type: 'json' }
import modelSources from './resources/default.json' with { type: 'json' }
export default defineConfig({ export default defineConfig({
input: 'src/index.ts', input: 'src/index.ts',
@ -12,5 +13,6 @@ export default defineConfig({
SETTINGS: JSON.stringify(settingJson), SETTINGS: JSON.stringify(settingJson),
API_URL: JSON.stringify('http://127.0.0.1:39291'), API_URL: JSON.stringify('http://127.0.0.1:39291'),
SOCKET_URL: JSON.stringify('ws://127.0.0.1:39291'), SOCKET_URL: JSON.stringify('ws://127.0.0.1:39291'),
DEFAULT_MODEL_SOURCES: JSON.stringify(modelSources),
}, },
}) })

View File

@ -2,6 +2,7 @@ declare const NODE: string
declare const API_URL: string declare const API_URL: string
declare const SOCKET_URL: string declare const SOCKET_URL: string
declare const SETTINGS: SettingComponentProps[] declare const SETTINGS: SettingComponentProps[]
declare const DEFAULT_MODEL_SOURCES: any
interface Core { interface Core {
api: APIFunctions api: APIFunctions

View File

@ -1,6 +1,6 @@
import PQueue from 'p-queue' import PQueue from 'p-queue'
import ky from 'ky' import ky from 'ky'
import { extractModelLoadParams, Model } from '@janhq/core' import { extractModelLoadParams, Model, ModelSource } from '@janhq/core'
import { extractInferenceParams } from '@janhq/core' import { extractInferenceParams } from '@janhq/core'
/** /**
* cortex.cpp Model APIs interface * cortex.cpp Model APIs interface
@ -19,9 +19,12 @@ interface ICortexAPI {
updateModel(model: object): Promise<void> updateModel(model: object): Promise<void>
cancelModelPull(model: string): Promise<void> cancelModelPull(model: string): Promise<void>
configs(body: { [key: string]: any }): Promise<void> configs(body: { [key: string]: any }): Promise<void>
getSources(): Promise<ModelSource[]>
addSource(source: string): Promise<void>
deleteSource(source: string): Promise<void>
} }
type ModelList = { type Data = {
data: any[] data: any[]
} }
@ -53,7 +56,7 @@ export class CortexAPI implements ICortexAPI {
*/ */
getModels(): Promise<Model[]> { getModels(): Promise<Model[]> {
return this.queue return this.queue
.add(() => ky.get(`${API_URL}/v1/models?limit=-1`).json<ModelList>()) .add(() => ky.get(`${API_URL}/v1/models?limit=-1`).json<Data>())
.then((e) => .then((e) =>
typeof e === 'object' ? e.data.map((e) => this.transformModel(e)) : [] typeof e === 'object' ? e.data.map((e) => this.transformModel(e)) : []
) )
@ -148,6 +151,47 @@ export class CortexAPI implements ICortexAPI {
.catch(() => false) .catch(() => false)
} }
// BEGIN - Model Sources
/**
* Get model sources
* @param model
*/
async getSources(): Promise<ModelSource[]> {
return this.queue
.add(() => ky.get(`${API_URL}/v1/models/sources`).json<Data>())
.then((e) => (typeof e === 'object' ? (e.data as ModelSource[]) : []))
.catch(() => [])
}
/**
* Add a model source
* @param model
*/
async addSource(source: string): Promise<any> {
return this.queue.add(() =>
ky.post(`${API_URL}/v1/models/sources`, {
json: {
source,
},
})
)
}
/**
* Delete a model source
* @param model
*/
async deleteSource(source: string): Promise<any> {
return this.queue.add(() =>
ky.delete(`${API_URL}/v1/models/sources`, {
json: {
source,
},
})
)
}
// END - Model Sources
/** /**
* Do health check on cortex.cpp * Do health check on cortex.cpp
* @returns * @returns

View File

@ -11,6 +11,7 @@ import {
events, events,
DownloadEvent, DownloadEvent,
OptionType, OptionType,
ModelSource,
} from '@janhq/core' } from '@janhq/core'
import { CortexAPI } from './cortex' import { CortexAPI } from './cortex'
import { scanModelsFolder } from './legacy/model-json' import { scanModelsFolder } from './legacy/model-json'
@ -243,6 +244,35 @@ export default class JanModelExtension extends ModelExtension {
return this.cortexAPI.importModel(model, modelPath, name, option) return this.cortexAPI.importModel(model, modelPath, name, option)
} }
// BEGIN - Model Sources
/**
* Get model sources
* @param model
*/
async getSources(): Promise<ModelSource[]> {
const sources = await this.cortexAPI.getSources()
return sources.concat(
DEFAULT_MODEL_SOURCES.filter((e) => !sources.some((x) => x.id === e.id))
)
}
/**
* Add a model source
* @param model
*/
async addSource(source: string): Promise<any> {
return this.cortexAPI.addSource(source)
}
/**
* Delete a model source
* @param model
*/
async deleteSource(source: string): Promise<any> {
return this.cortexAPI.deleteSource(source)
}
// END - Model Sources
/** /**
* Check model status * Check model status
* @param model * @param model

View File

@ -30,6 +30,7 @@
"dependencies": { "dependencies": {
"@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0", "@radix-ui/react-select": "^2.0.0",

View File

@ -0,0 +1,45 @@
import React, { Fragment, PropsWithChildren, ReactNode } from 'react'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import './styles.scss'
import { twMerge } from 'tailwind-merge'
type Props = {
options?: { name: ReactNode; value: string; suffix?: ReactNode }[]
className?: string
onValueChanged?: (value: string) => void
}
const Dropdown = (props: PropsWithChildren & Props) => {
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>{props.children}</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className={twMerge(props.className, 'DropdownMenuContent')}
sideOffset={0}
align="end"
>
{props.options?.map((e, i) => (
<Fragment key={e.value}>
{i !== 0 && (
<DropdownMenu.Separator className="DropdownMenuSeparator" />
)}
<DropdownMenu.Item
className="DropdownMenuItem"
onClick={() => props.onValueChanged?.(e.value)}
>
{e.name}
<div />
{e.suffix}
</DropdownMenu.Item>
</Fragment>
))}
<DropdownMenu.Arrow className="DropdownMenuArrow" />
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
)
}
export { Dropdown }

View File

@ -0,0 +1,154 @@
.DropdownMenuContent,
.DropdownMenuSubContent {
min-width: 220px;
background-color: white;
border-radius: 6px;
overflow: hidden;
padding: 0px;
box-shadow:
0px 10px 38px -10px rgba(22, 23, 24, 0.35),
0px 10px 20px -15px rgba(22, 23, 24, 0.2);
animation-duration: 400ms;
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
will-change: transform, opacity;
}
.DropdownMenuContent[data-side='top'],
.DropdownMenuSubContent[data-side='top'] {
animation-name: slideDownAndFade;
}
.DropdownMenuContent[data-side='right'],
.DropdownMenuSubContent[data-side='right'] {
animation-name: slideLeftAndFade;
}
.DropdownMenuContent[data-side='bottom'],
.DropdownMenuSubContent[data-side='bottom'] {
animation-name: slideUpAndFade;
}
.DropdownMenuContent[data-side='left'],
.DropdownMenuSubContent[data-side='left'] {
animation-name: slideRightAndFade;
}
.DropdownMenuItem {
padding: 14px;
cursor: pointer;
outline: none;
flex: 1;
display: flex;
justify-content: space-between; /* Distribute space between children */
align-items: center; /* Optional: Align items vertically */
gap: 16px;
}
.DropdownMenuCheckboxItem,
.DropdownMenuRadioItem,
.DropdownMenuSubTrigger {
font-size: 13px;
line-height: 1;
border-radius: 3px;
display: flex;
align-items: center;
height: 25px;
padding: 0 0;
position: relative;
padding-left: 25px;
user-select: none;
outline: none;
}
.DropdownMenuItem[data-disabled],
.DropdownMenuCheckboxItem[data-disabled],
.DropdownMenuRadioItem[data-disabled],
.DropdownMenuSubTrigger[data-disabled] {
pointer-events: none;
}
.DropdownMenuItem[data-highlighted],
.DropdownMenuCheckboxItem[data-highlighted],
.DropdownMenuRadioItem[data-highlighted],
.DropdownMenuSubTrigger[data-highlighted] {
background-color: hsla(var(--secondary-bg));
}
.DropdownMenuSeparator {
height: 1px;
width: '100%';
background-color: hsla(var(--secondary-bg));
}
.DropdownMenuItem::hover {
background-color: hsla(var(--secondary-bg));
}
.DropdownMenuLabel {
padding-left: 25px;
font-size: 12px;
line-height: 25px;
color: var(--mauve-11);
}
.DropdownMenuItemIndicator {
position: absolute;
left: 0;
width: 25px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.DropdownMenuArrow {
fill: white;
}
.RightSlot {
margin-left: auto;
padding-left: 20px;
color: var(--mauve-11);
}
[data-highlighted] > .RightSlot {
color: white;
}
[data-disabled] .RightSlot {
color: var(--mauve-8);
}
@keyframes slideUpAndFade {
from {
opacity: 0;
transform: translateY(2px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideRightAndFade {
from {
opacity: 0;
transform: translateX(-2px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideDownAndFade {
from {
opacity: 0;
transform: translateY(-2px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideLeftAndFade {
from {
opacity: 0;
transform: translateX(2px);
}
to {
opacity: 1;
transform: translateX(0);
}
}

View File

@ -1,11 +1,7 @@
import React, { ReactNode } from 'react' import React from 'react'
import * as SelectPrimitive from '@radix-ui/react-select' import * as SelectPrimitive from '@radix-ui/react-select'
import { import { CheckIcon, ChevronDownIcon } from '@radix-ui/react-icons'
CheckIcon,
ChevronDownIcon,
ChevronUpIcon,
} from '@radix-ui/react-icons'
import './styles.scss' import './styles.scss'
import { twMerge } from 'tailwind-merge' import { twMerge } from 'tailwind-merge'

View File

@ -15,6 +15,7 @@ jest.mock('./core/Select/styles.scss', () => ({}))
jest.mock('./core/TextArea/styles.scss', () => ({})) jest.mock('./core/TextArea/styles.scss', () => ({}))
jest.mock('./core/Tabs/styles.scss', () => ({})) jest.mock('./core/Tabs/styles.scss', () => ({}))
jest.mock('./core/Accordion/styles.scss', () => ({})) jest.mock('./core/Accordion/styles.scss', () => ({}))
jest.mock('./core/Dropdown/styles.scss', () => ({}))
describe('Exports', () => { describe('Exports', () => {
it('exports all components and hooks', () => { it('exports all components and hooks', () => {

View File

@ -12,6 +12,7 @@ export * from './core/Select'
export * from './core/TextArea' export * from './core/TextArea'
export * from './core/Tabs' export * from './core/Tabs'
export * from './core/Accordion' export * from './core/Accordion'
export * from './core/Dropdown'
export * from './hooks/useClipboard' export * from './hooks/useClipboard'
export * from './hooks/usePageLeave' export * from './hooks/usePageLeave'

View File

@ -6,7 +6,7 @@ import { useActiveModel } from '@/hooks/useActiveModel'
import { useGetEngines } from '@/hooks/useEngineManagement' import { useGetEngines } from '@/hooks/useEngineManagement'
import { toGibibytes } from '@/utils/converter' import { toGigabytes } from '@/utils/converter'
import { isLocalEngine } from '@/utils/modelEngine' import { isLocalEngine } from '@/utils/modelEngine'
@ -34,7 +34,7 @@ const TableActiveModel = () => {
<td className="px-4 py-2"> <td className="px-4 py-2">
<Badge theme="secondary"> <Badge theme="secondary">
{activeModel.metadata?.size {activeModel.metadata?.size
? toGibibytes(activeModel.metadata?.size) ? toGigabytes(activeModel.metadata?.size)
: '-'} : '-'}
</Badge> </Badge>
</td> </td>

View File

@ -16,7 +16,7 @@ import useGetSystemResources from '@/hooks/useGetSystemResources'
import { usePath } from '@/hooks/usePath' import { usePath } from '@/hooks/usePath'
import { toGibibytes } from '@/utils/converter' import { toGigabytes } from '@/utils/converter'
import { utilizedMemory } from '@/utils/memory' import { utilizedMemory } from '@/utils/memory'
@ -134,8 +134,8 @@ const SystemMonitor = () => {
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<h6 className="font-bold">Memory</h6> <h6 className="font-bold">Memory</h6>
<span> <span>
{toGibibytes(usedRam, { hideUnit: true })}/ {toGigabytes(usedRam, { hideUnit: true })}/
{toGibibytes(totalRam, { hideUnit: true })} GB {toGigabytes(totalRam, { hideUnit: true })} GB
</span> </span>
</div> </div>
<div className="flex items-center gap-x-4"> <div className="flex items-center gap-x-4">

View File

@ -21,7 +21,6 @@ import { SUCCESS_SET_NEW_DESTINATION } from '@/screens/Settings/Advanced/DataFol
import CancelModelImportModal from '@/screens/Settings/CancelModelImportModal' import CancelModelImportModal from '@/screens/Settings/CancelModelImportModal'
import ChooseWhatToImportModal from '@/screens/Settings/ChooseWhatToImportModal' import ChooseWhatToImportModal from '@/screens/Settings/ChooseWhatToImportModal'
import EditModelInfoModal from '@/screens/Settings/EditModelInfoModal' import EditModelInfoModal from '@/screens/Settings/EditModelInfoModal'
import HuggingFaceRepoDetailModal from '@/screens/Settings/HuggingFaceRepoDetailModal'
import ImportModelOptionModal from '@/screens/Settings/ImportModelOptionModal' import ImportModelOptionModal from '@/screens/Settings/ImportModelOptionModal'
import ImportingModelModal from '@/screens/Settings/ImportingModelModal' import ImportingModelModal from '@/screens/Settings/ImportingModelModal'
import SelectingModelModal from '@/screens/Settings/SelectingModelModal' import SelectingModelModal from '@/screens/Settings/SelectingModelModal'
@ -148,7 +147,6 @@ const BaseLayout = () => {
{importModelStage === 'CONFIRM_CANCEL' && <CancelModelImportModal />} {importModelStage === 'CONFIRM_CANCEL' && <CancelModelImportModal />}
<ChooseWhatToImportModal /> <ChooseWhatToImportModal />
<InstallingExtensionModal /> <InstallingExtensionModal />
<HuggingFaceRepoDetailModal />
{showProductAnalyticPrompt && ( {showProductAnalyticPrompt && (
<div className="fixed bottom-4 z-50 m-4 max-w-full rounded-xl border border-[hsla(var(--app-border))] bg-[hsla(var(--app-bg))] p-6 shadow-2xl sm:bottom-8 sm:right-4 sm:m-0 sm:max-w-[400px]"> <div className="fixed bottom-4 z-50 m-4 max-w-full rounded-xl border border-[hsla(var(--app-border))] bg-[hsla(var(--app-bg))] p-6 shadow-2xl sm:bottom-8 sm:right-4 sm:m-0 sm:max-w-[400px]">
<div className="mb-4 flex items-center gap-x-2"> <div className="mb-4 flex items-center gap-x-2">

View File

@ -0,0 +1,53 @@
import { motion } from 'framer-motion'
const Spinner = ({ size = 40, strokeWidth = 4 }) => {
const radius = size / 2 - strokeWidth
const circumference = 2 * Math.PI * radius
return (
<motion.svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
style={{ overflow: 'visible' }}
animate={{ rotate: 360 }}
transition={{
repeat: Infinity,
duration: 2, // Adjust for desired speed
ease: 'linear',
}}
>
{/* Static background circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="#e0e0e0"
strokeWidth={strokeWidth}
fill="none"
/>
{/* Smooth animated arc */}
<motion.circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="currentColor"
strokeWidth={strokeWidth}
fill="none"
strokeDasharray={circumference}
strokeDashoffset={circumference * 0.9} // Adjusted offset for smooth arc
animate={{
strokeDashoffset: [circumference, circumference * 0.1], // Continuous motion
}}
transition={{
repeat: Infinity,
duration: 1.5, // Adjust for animation speed
ease: 'easeInOut', // Smooth easing
}}
strokeLinecap="round" // For a rounded end
/>
</motion.svg>
)
}
export default Spinner

View File

@ -1,7 +1,5 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import { Model } from '@janhq/core'
import { Modal, Button, Progress, ModalClose } from '@janhq/joi' import { Modal, Button, Progress, ModalClose } from '@janhq/joi'
import { useAtomValue, useSetAtom } from 'jotai' import { useAtomValue, useSetAtom } from 'jotai'
@ -16,22 +14,22 @@ import {
import { formatDownloadPercentage } from '@/utils/converter' import { formatDownloadPercentage } from '@/utils/converter'
type Props = { type Props = {
model: Model modelId: string
isFromList?: boolean isFromList?: boolean
} }
const ModalCancelDownload = ({ model, isFromList }: Props) => { const ModalCancelDownload = ({ modelId, isFromList }: Props) => {
const { abortModelDownload } = useDownloadModel() const { abortModelDownload } = useDownloadModel()
const removeDownloadState = useSetAtom(removeDownloadStateAtom) const removeDownloadState = useSetAtom(removeDownloadStateAtom)
const allDownloadStates = useAtomValue(modelDownloadStateAtom) const allDownloadStates = useAtomValue(modelDownloadStateAtom)
const downloadState = allDownloadStates[model.id] const downloadState = allDownloadStates[modelId]
const cancelText = `Cancel ${formatDownloadPercentage(downloadState?.percent ?? 0)}` const cancelText = `Cancel ${formatDownloadPercentage(downloadState?.percent ?? 0)}`
const onAbortDownloadClick = useCallback(() => { const onAbortDownloadClick = useCallback(() => {
removeDownloadState(model.id) removeDownloadState(modelId)
abortModelDownload(downloadState?.modelId ?? model.id) abortModelDownload(downloadState?.modelId ?? modelId)
}, [downloadState, abortModelDownload, removeDownloadState, model]) }, [downloadState, abortModelDownload, removeDownloadState, modelId])
return ( return (
<Modal <Modal
@ -42,7 +40,11 @@ const ModalCancelDownload = ({ model, isFromList }: Props) => {
{cancelText} {cancelText}
</Button> </Button>
) : ( ) : (
<Button variant="soft"> <Button
className="text-[hsla(var(--primary-bg))]"
variant="soft"
theme="ghost"
>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<span className="inline-block">Cancel</span> <span className="inline-block">Cancel</span>
<Progress <Progress

View File

@ -0,0 +1,99 @@
import { useCallback, useMemo } from 'react'
import { Button, Tooltip } from '@janhq/joi'
import { useAtomValue, useSetAtom } from 'jotai'
import { MainViewState } from '@/constants/screens'
import { useCreateNewThread } from '@/hooks/useCreateNewThread'
import useDownloadModel from '@/hooks/useDownloadModel'
import ModalCancelDownload from '../ModalCancelDownload'
import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
import { assistantsAtom } from '@/helpers/atoms/Assistant.atom'
import {
downloadedModelsAtom,
getDownloadingModelAtom,
} from '@/helpers/atoms/Model.atom'
interface Props {
id: string
theme?: 'primary' | 'ghost' | 'icon' | 'destructive' | undefined
variant?: 'solid' | 'soft' | 'outline' | undefined
}
const ModelDownloadButton = ({ id, theme, variant }: Props) => {
const { downloadModel } = useDownloadModel()
const downloadingModels = useAtomValue(getDownloadingModelAtom)
const downloadedModels = useAtomValue(downloadedModelsAtom)
const assistants = useAtomValue(assistantsAtom)
const setMainViewState = useSetAtom(mainViewStateAtom)
const { requestCreateNewThread } = useCreateNewThread()
const isDownloaded = useMemo(
() => downloadedModels.some((md) => md.id === id),
[downloadedModels, id]
)
const isDownloading = useMemo(
() => downloadingModels.some((md) => md === id),
[downloadingModels, id]
)
const onDownloadClick = useCallback(() => {
downloadModel(id)
}, [id, downloadModel])
const onUseModelClick = useCallback(async () => {
const downloadedModel = downloadedModels.find((e) => e.id === id)
if (downloadedModel)
await requestCreateNewThread(assistants[0], downloadedModel)
setMainViewState(MainViewState.Thread)
}, [
assistants,
downloadedModels,
setMainViewState,
requestCreateNewThread,
id,
])
const defaultButton = (
<Button
theme={theme ? theme : 'primary'}
variant={variant ? variant : 'solid'}
onClick={(e) => {
e.stopPropagation()
onDownloadClick()
}}
>
Download
</Button>
)
const downloadingButton = <ModalCancelDownload modelId={id} />
const downloadedButton = (
<Tooltip
trigger={
<Button
onClick={onUseModelClick}
data-testid={`use-model-btn-${id}`}
variant="outline"
theme="ghost"
className="min-w-[70px]"
>
Use
</Button>
}
content="Threads are disabled while the server is running"
/>
)
return (
<>
{isDownloading
? downloadingButton
: isDownloaded
? downloadedButton
: defaultButton}
</>
)
}
export default ModelDownloadButton

View File

@ -37,7 +37,7 @@ import useRecommendedModel from '@/hooks/useRecommendedModel'
import useUpdateModelParameters from '@/hooks/useUpdateModelParameters' import useUpdateModelParameters from '@/hooks/useUpdateModelParameters'
import { formatDownloadPercentage, toGibibytes } from '@/utils/converter' import { formatDownloadPercentage, toGigabytes } from '@/utils/converter'
import { manualRecommendationModel } from '@/utils/model' import { manualRecommendationModel } from '@/utils/model'
import { getLogoEngine } from '@/utils/modelEngine' import { getLogoEngine } from '@/utils/modelEngine'
@ -481,13 +481,13 @@ const ModelDropdown = ({
{model.name} {model.name}
</p> </p>
<ModelLabel <ModelLabel
metadata={model.metadata} size={model.metadata?.size}
compact compact
/> />
</div> </div>
<div className="flex items-center gap-2 text-[hsla(var(--text-tertiary))]"> <div className="flex items-center gap-2 text-[hsla(var(--text-tertiary))]">
<span className="font-medium"> <span className="font-medium">
{toGibibytes(model.metadata?.size)} {toGigabytes(model.metadata?.size)}
</span> </span>
{!isDownloading ? ( {!isDownloading ? (
<DownloadCloudIcon <DownloadCloudIcon
@ -577,14 +577,14 @@ const ModelDropdown = ({
{model.name} {model.name}
</p> </p>
<ModelLabel <ModelLabel
metadata={model.metadata} size={model.metadata?.size}
compact compact
/> />
</div> </div>
<div className="flex items-center gap-2 text-[hsla(var(--text-tertiary))]"> <div className="flex items-center gap-2 text-[hsla(var(--text-tertiary))]">
{!isDownloaded && ( {!isDownloaded && (
<span className="font-medium"> <span className="font-medium">
{toGibibytes(model.metadata?.size)} {toGigabytes(model.metadata?.size)}
</span> </span>
)} )}
{!isDownloading && !isDownloaded ? ( {!isDownloading && !isDownloaded ? (

View File

@ -36,46 +36,6 @@ describe('ModelLabel', () => {
jest.clearAllMocks() jest.clearAllMocks()
}) })
it('renders NotEnoughMemoryLabel when minimumRamModel is greater than totalRam', async () => {
mockUseAtomValue
.mockReturnValueOnce(0)
.mockReturnValueOnce(0)
.mockReturnValueOnce(0)
mockUseActiveModel.mockReturnValue({
activeModel: { metadata: { size: 0 } },
})
mockUseSettings.mockReturnValue({ settings: { run_mode: 'cpu' } })
render(<ModelLabel {...defaultProps} />)
await waitFor(() => {
expect(screen.getByText('Not enough RAM')).toBeDefined()
})
})
it('renders SlowOnYourDeviceLabel when minimumRamModel is less than totalRam but greater than availableRam', async () => {
mockUseAtomValue
.mockReturnValueOnce(100)
.mockReturnValueOnce(50)
.mockReturnValueOnce(10)
mockUseActiveModel.mockReturnValue({
activeModel: { metadata: { size: 0 } },
})
mockUseSettings.mockReturnValue({ settings: { run_mode: 'cpu' } })
const props = {
...defaultProps,
metadata: {
...defaultProps.metadata,
size: 50,
},
}
render(<ModelLabel {...props} />)
await waitFor(() => {
expect(screen.getByText('Slow on your device')).toBeDefined()
})
})
it('renders nothing when minimumRamModel is less than availableRam', () => { it('renders nothing when minimumRamModel is less than availableRam', () => {
mockUseAtomValue mockUseAtomValue
.mockReturnValueOnce(100) .mockReturnValueOnce(100)

View File

@ -1,7 +1,5 @@
import React from 'react' import React from 'react'
import { ModelMetadata } from '@janhq/core'
import { Badge } from '@janhq/joi'
import { useAtomValue } from 'jotai' import { useAtomValue } from 'jotai'
import { useActiveModel } from '@/hooks/useActiveModel' import { useActiveModel } from '@/hooks/useActiveModel'
@ -19,18 +17,11 @@ import {
} from '@/helpers/atoms/SystemBar.atom' } from '@/helpers/atoms/SystemBar.atom'
type Props = { type Props = {
metadata: ModelMetadata size?: number
compact?: boolean compact?: boolean
} }
const UnsupportedModel = () => {
return (
<Badge className="space-x-1 rounded-md" theme="warning">
<span>Coming Soon</span>
</Badge>
)
}
const ModelLabel = ({ metadata, compact }: Props) => { const ModelLabel = ({ size, compact }: Props) => {
const { activeModel } = useActiveModel() const { activeModel } = useActiveModel()
const totalRam = useAtomValue(totalRamAtom) const totalRam = useAtomValue(totalRamAtom)
const usedRam = useAtomValue(usedRamAtom) const usedRam = useAtomValue(usedRamAtom)
@ -59,11 +50,7 @@ const ModelLabel = ({ metadata, compact }: Props) => {
return null return null
} }
return metadata?.tags?.includes('Coming Soon') ? ( return getLabel(size ?? 0)
<UnsupportedModel />
) : (
getLabel(metadata?.size ?? 0)
)
} }
export default React.memo(ModelLabel) export default React.memo(ModelLabel)

View File

@ -1,18 +1,16 @@
import React, { ChangeEvent, useCallback, useState } from 'react' import React, { ChangeEvent, useCallback, useState, useRef } from 'react'
import { Input } from '@janhq/joi' import { Input } from '@janhq/joi'
import { useSetAtom } from 'jotai'
import { SearchIcon } from 'lucide-react' import { SearchIcon } from 'lucide-react'
import { useDebouncedCallback } from 'use-debounce' import { useDebouncedCallback } from 'use-debounce'
import { toaster } from '@/containers/Toast'
import { useGetHFRepoData } from '@/hooks/useGetHFRepoData'
import { import {
importHuggingFaceModelStageAtom, useGetModelSources,
importingHuggingFaceRepoDataAtom, useModelSourcesMutation,
} from '@/helpers/atoms/HuggingFace.atom' } from '@/hooks/useModelSource'
import Spinner from '../Loader/Spinner'
type Props = { type Props = {
onSearchLocal?: (searchText: string) => void onSearchLocal?: (searchText: string) => void
@ -20,37 +18,28 @@ type Props = {
const ModelSearch = ({ onSearchLocal }: Props) => { const ModelSearch = ({ onSearchLocal }: Props) => {
const [searchText, setSearchText] = useState('') const [searchText, setSearchText] = useState('')
const { getHfRepoData } = useGetHFRepoData() const [isSearching, setSearching] = useState(false)
const { mutate } = useGetModelSources()
const setImportingHuggingFaceRepoData = useSetAtom( const { addModelSource } = useModelSourcesMutation()
importingHuggingFaceRepoDataAtom const inputRef = useRef<HTMLInputElement | null>(null)
)
const setImportHuggingFaceModelStage = useSetAtom(
importHuggingFaceModelStageAtom
)
const debounced = useDebouncedCallback(async () => { const debounced = useDebouncedCallback(async () => {
if (searchText.indexOf('/') === -1) { if (searchText.indexOf('/') === -1) {
// If we don't find / in the text, perform a local search // If we don't find / in the text, perform a local search
onSearchLocal?.(searchText) onSearchLocal?.(searchText)
return return
} }
// Attempt to search local
onSearchLocal?.(searchText)
try { setSearching(true)
const data = await getHfRepoData(searchText) // Attempt to search model source
setImportingHuggingFaceRepoData(data) addModelSource(searchText)
setImportHuggingFaceModelStage('REPO_DETAIL') .then(() => mutate())
} catch (err) { .then(() => onSearchLocal?.(searchText))
let errMessage = 'Unexpected Error' .catch((e) => {
if (err instanceof Error) { console.debug(e)
errMessage = err.message
}
toaster({
title: errMessage,
type: 'error',
}) })
console.error(err) .finally(() => setSearching(false))
}
}, 300) }, 300)
const onSearchChanged = useCallback( const onSearchChanged = useCallback(
@ -80,13 +69,24 @@ const ModelSearch = ({ onSearchLocal }: Props) => {
return ( return (
<Input <Input
prefixIcon={<SearchIcon size={16} />} ref={inputRef}
placeholder="Search or paste Hugging Face URL" prefixIcon={
isSearching ? (
<Spinner size={16} strokeWidth={2} />
) : (
<SearchIcon size={16} />
)
}
placeholder="Search or enter Hugging Face URL"
onChange={onSearchChanged} onChange={onSearchChanged}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
value={searchText} value={searchText}
clearable={searchText.length > 0} clearable={searchText.length > 0}
onClear={onClear} onClear={onClear}
className="border-0 bg-[hsla(var(--app-bg))]"
onClick={() => {
onSearchLocal?.(inputRef.current?.value ?? '')
}}
/> />
) )
} }

View File

@ -4,25 +4,21 @@ import { useSetAtom } from 'jotai'
import { useDebouncedCallback } from 'use-debounce' import { useDebouncedCallback } from 'use-debounce'
import { useGetHFRepoData } from '@/hooks/useGetHFRepoData' import { MainViewState } from '@/constants/screens'
import { useModelSourcesMutation } from '@/hooks/useModelSource'
import { loadingModalInfoAtom } from '../LoadingModal' import { loadingModalInfoAtom } from '../LoadingModal'
import { toaster } from '../Toast' import { toaster } from '../Toast'
import { import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
importHuggingFaceModelStageAtom, import { modelDetailAtom } from '@/helpers/atoms/Model.atom'
importingHuggingFaceRepoDataAtom,
} from '@/helpers/atoms/HuggingFace.atom'
const DeepLinkListener: React.FC = () => { const DeepLinkListener: React.FC = () => {
const { getHfRepoData } = useGetHFRepoData() const { addModelSource } = useModelSourcesMutation()
const setLoadingInfo = useSetAtom(loadingModalInfoAtom) const setLoadingInfo = useSetAtom(loadingModalInfoAtom)
const setImportingHuggingFaceRepoData = useSetAtom( const setMainView = useSetAtom(mainViewStateAtom)
importingHuggingFaceRepoDataAtom const setModelDetail = useSetAtom(modelDetailAtom)
)
const setImportHuggingFaceModelStage = useSetAtom(
importHuggingFaceModelStageAtom
)
const handleDeepLinkAction = useDebouncedCallback( const handleDeepLinkAction = useDebouncedCallback(
async (deepLinkAction: DeepLinkAction) => { async (deepLinkAction: DeepLinkAction) => {
@ -38,17 +34,17 @@ const DeepLinkListener: React.FC = () => {
try { try {
setLoadingInfo({ setLoadingInfo({
title: 'Getting Hugging Face models', title: 'Getting Hugging Face model details',
message: 'Please wait..', message: 'Please wait..',
}) })
const data = await getHfRepoData(deepLinkAction.resource) await addModelSource(deepLinkAction.resource)
setImportingHuggingFaceRepoData(data)
setImportHuggingFaceModelStage('REPO_DETAIL')
setLoadingInfo(undefined) setLoadingInfo(undefined)
setMainView(MainViewState.Hub)
setModelDetail(deepLinkAction.resource)
} catch (err) { } catch (err) {
setLoadingInfo(undefined) setLoadingInfo(undefined)
toaster({ toaster({
title: 'Failed to get Hugging Face models', title: 'Failed to get Hugging Face model details',
description: err instanceof Error ? err.message : 'Unexpected Error', description: err instanceof Error ? err.message : 'Unexpected Error',
type: 'error', type: 'error',
}) })

View File

@ -30,3 +30,15 @@ export const copyOverInstructionEnabledAtom = atomWithStorage(
COPY_OVER_INSTRUCTION_ENABLED, COPY_OVER_INSTRUCTION_ENABLED,
false false
) )
/**
* App Hub Banner configured image
*/
export const appBannerHubAtom = atomWithStorage<string>(
'appBannerHub',
'./images/HubBanner/banner-8.jpg',
undefined,
{
getOnInit: true,
}
)

View File

@ -1,14 +0,0 @@
import { importHuggingFaceModelStageAtom } from './HuggingFace.atom';
import { importingHuggingFaceRepoDataAtom } from './HuggingFace.atom';
test('importHuggingFaceModelStageAtom should have initial value of NONE', () => {
const result = importHuggingFaceModelStageAtom.init;
expect(result).toBe('NONE');
});
test('importingHuggingFaceRepoDataAtom should have initial value of undefined', () => {
const result = importingHuggingFaceRepoDataAtom.init;
expect(result).toBeUndefined();
});

View File

@ -1,12 +0,0 @@
import { HuggingFaceRepoData } from '@janhq/core'
import { atom } from 'jotai'
// modals
export type ImportHuggingFaceModelStage = 'NONE' | 'REPO_DETAIL'
export const importingHuggingFaceRepoDataAtom = atom<
HuggingFaceRepoData | undefined
>(undefined)
export const importHuggingFaceModelStageAtom =
atom<ImportHuggingFaceModelStage>('NONE')

View File

@ -60,6 +60,11 @@ export const showEngineListModelAtom = atom<string[]>([
InferenceEngine.cortex_tensorrtllm, InferenceEngine.cortex_tensorrtllm,
]) ])
/**
* Atom to store the current model detail page of a certain model id
*/
export const modelDetailAtom = atom<string | undefined>(undefined)
/// End Models Atom /// End Models Atom
/// Model Download Atom /// Model Download Atom

View File

@ -10,12 +10,17 @@ import {
EngineEvent, EngineEvent,
Model, Model,
ModelEvent, ModelEvent,
ModelSource,
ModelSibling,
} from '@janhq/core' } from '@janhq/core'
import { useAtom } from 'jotai' import { useAtom, useAtomValue } from 'jotai'
import { atomWithStorage } from 'jotai/utils' import { atomWithStorage } from 'jotai/utils'
import useSWR from 'swr' import useSWR from 'swr'
import { getDescriptionByEngine, getTitleByEngine } from '@/utils/modelEngine'
import { extensionManager } from '@/extension/ExtensionManager' import { extensionManager } from '@/extension/ExtensionManager'
import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom'
export const releasedEnginesCacheAtom = atomWithStorage<{ export const releasedEnginesCacheAtom = atomWithStorage<{
data: EngineReleased[] data: EngineReleased[]
@ -415,3 +420,39 @@ export const addRemoteEngineModel = async (name: string, engine: string) => {
throw error throw error
} }
} }
/**
* Remote model sources
* @returns A Promise that resolves to an object of model sources.
*/
export const useGetEngineModelSources = () => {
const { engines } = useGetEngines()
const downloadedModels = useAtomValue(downloadedModelsAtom)
return {
sources: Object.entries(engines ?? {})
?.filter((e) => e?.[1]?.[0]?.type === 'remote')
.map(
([key, values]) =>
({
id: key,
models: (
downloadedModels.filter((e) => e.engine === values[0]?.engine) ??
[]
).map(
(e) =>
({
id: e.id,
size: e.metadata?.size,
}) as unknown as ModelSibling
),
metadata: {
id: getTitleByEngine(key as InferenceEngine),
description: getDescriptionByEngine(key as InferenceEngine),
apiKey: values[0]?.api_key,
},
type: 'cloud',
}) as unknown as ModelSource
),
}
}

View File

@ -0,0 +1,72 @@
import { useMemo } from 'react'
import { ExtensionTypeEnum, ModelExtension } from '@janhq/core'
import useSWR from 'swr'
import { extensionManager } from '@/extension/ExtensionManager'
/**
* @returns A Promise that resolves to an object of model sources.
*/
export function useGetModelSources() {
const extension = useMemo(
() => extensionManager.get<ModelExtension>(ExtensionTypeEnum.Model),
[]
)
const {
data: sources,
error,
mutate,
} = useSWR(
extension ? 'getSources' : null,
() =>
extension?.getSources().then((e) =>
e.map((m) => ({
...m,
models: m.models.sort((a, b) => a.size - b.size),
}))
),
{
revalidateOnFocus: false,
revalidateOnReconnect: true,
}
)
return { sources, error, mutate }
}
export const useModelSourcesMutation = () => {
const extension = useMemo(
() => extensionManager.get<ModelExtension>(ExtensionTypeEnum.Model),
[]
)
/**
* Add a new model source
* @returns A Promise that resolves to intall of engine.
*/
const addModelSource = async (source: string) => {
try {
// Call the extension's method
return await extension?.addSource(source)
} catch (error) {
console.error('Failed to install engine variant:', error)
throw error
}
}
/**
* Delete a new model source
* @returns A Promise that resolves to intall of engine.
*/
const deleteModelSource = async (source: string) => {
try {
// Call the extension's method
return await extension?.deleteSource(source)
} catch (error) {
console.error('Failed to install engine variant:', error)
throw error
}
}
return { addModelSource, deleteModelSource }
}

View File

@ -32,12 +32,15 @@ const useModels = () => {
const getData = useCallback(() => { const getData = useCallback(() => {
const getDownloadedModels = async () => { const getDownloadedModels = async () => {
const localModels = (await getModels()).map((e) => ({ const localModels = (await getModels())
.map((e) => ({
...e, ...e,
name: ModelManager.instance().models.get(e.id)?.name ?? e.name ?? e.id, name:
ModelManager.instance().models.get(e.id)?.name ?? e.name ?? e.id,
metadata: metadata:
ModelManager.instance().models.get(e.id)?.metadata ?? e.metadata, ModelManager.instance().models.get(e.id)?.metadata ?? e.metadata,
})) }))
.filter((e) => !('status' in e) || e.status !== 'downloadable')
const remoteModels = ModelManager.instance() const remoteModels = ModelManager.instance()
.models.values() .models.values()

View File

@ -40,5 +40,5 @@ const config = {
// module.exports = createJestConfig(config) // module.exports = createJestConfig(config)
module.exports = async () => ({ module.exports = async () => ({
...(await createJestConfig(config)()), ...(await createJestConfig(config)()),
transformIgnorePatterns: ['/node_modules/(?!(layerr|nanoid|@uppy|preact)/)'], transformIgnorePatterns: ['/node_modules/(?!((.*))/)'],
}) })

View File

@ -48,6 +48,7 @@
"rehype-highlight": "^7.0.1", "rehype-highlight": "^7.0.1",
"rehype-highlight-code-lines": "^1.0.4", "rehype-highlight-code-lines": "^1.0.4",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"sass": "^1.69.4", "sass": "^1.69.4",
"slate": "latest", "slate": "latest",

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

View File

@ -1,20 +1,14 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import { Model } from '@janhq/core' import { ModelSource } from '@janhq/core'
import { Button, Badge, Tooltip } from '@janhq/joi'
import { Button, Tooltip, Dropdown, Badge } from '@janhq/joi'
import { useAtomValue, useSetAtom } from 'jotai' import { useAtomValue, useSetAtom } from 'jotai'
import { ChevronDownIcon } from 'lucide-react' import { ChevronDownIcon } from 'lucide-react'
import { twMerge } from 'tailwind-merge'
import ModalCancelDownload from '@/containers/ModalCancelDownload' import ModalCancelDownload from '@/containers/ModalCancelDownload'
import ModelLabel from '@/containers/ModelLabel'
import { toaster } from '@/containers/Toast'
import { MainViewState } from '@/constants/screens' import { MainViewState } from '@/constants/screens'
import { useCreateNewThread } from '@/hooks/useCreateNewThread' import { useCreateNewThread } from '@/hooks/useCreateNewThread'
@ -22,7 +16,9 @@ import useDownloadModel from '@/hooks/useDownloadModel'
import { useSettings } from '@/hooks/useSettings' import { useSettings } from '@/hooks/useSettings'
import { toGibibytes } from '@/utils/converter' import { toGigabytes } from '@/utils/converter'
import { extractModelName } from '@/utils/modelSource'
import { mainViewStateAtom } from '@/helpers/atoms/App.atom' import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
import { assistantsAtom } from '@/helpers/atoms/Assistant.atom' import { assistantsAtom } from '@/helpers/atoms/Assistant.atom'
@ -32,25 +28,25 @@ import {
downloadedModelsAtom, downloadedModelsAtom,
getDownloadingModelAtom, getDownloadingModelAtom,
} from '@/helpers/atoms/Model.atom' } from '@/helpers/atoms/Model.atom'
import { selectedSettingAtom } from '@/helpers/atoms/Setting.atom'
import { import {
nvidiaTotalVramAtom, nvidiaTotalVramAtom,
totalRamAtom, totalRamAtom,
} from '@/helpers/atoms/SystemBar.atom' } from '@/helpers/atoms/SystemBar.atom'
type Props = { type Props = {
model: Model model: ModelSource
onClick: () => void onSelectedModel: () => void
open: string
} }
const ModelItemHeader = ({ model, onClick, open }: Props) => { const ModelItemHeader = ({ model, onSelectedModel }: Props) => {
const { downloadModel } = useDownloadModel() const { downloadModel } = useDownloadModel()
const downloadingModels = useAtomValue(getDownloadingModelAtom) const downloadingModels = useAtomValue(getDownloadingModelAtom)
const downloadedModels = useAtomValue(downloadedModelsAtom) const downloadedModels = useAtomValue(downloadedModelsAtom)
const setSelectedSetting = useSetAtom(selectedSettingAtom)
const { requestCreateNewThread } = useCreateNewThread() const { requestCreateNewThread } = useCreateNewThread()
const totalRam = useAtomValue(totalRamAtom) const totalRam = useAtomValue(totalRamAtom)
const { settings } = useSettings() const { settings } = useSettings()
// const [imageLoaded, setImageLoaded] = useState(true)
const nvidiaTotalVram = useAtomValue(nvidiaTotalVramAtom) const nvidiaTotalVram = useAtomValue(nvidiaTotalVramAtom)
const setMainViewState = useSetAtom(mainViewStateAtom) const setMainViewState = useSetAtom(mainViewStateAtom)
@ -64,36 +60,68 @@ const ModelItemHeader = ({ model, onClick, open }: Props) => {
const assistants = useAtomValue(assistantsAtom) const assistants = useAtomValue(assistantsAtom)
const onDownloadClick = useCallback(() => { const onDownloadClick = useCallback(() => {
downloadModel(model.sources[0].url, model.id, model.name) downloadModel(model.models?.[0].id)
}, [model, downloadModel]) }, [model, downloadModel])
const isDownloaded = downloadedModels.find((md) => md.id === model.id) != null const isDownloaded = downloadedModels.some((md) =>
model.models.some((m) => m.id === md.id)
let downloadButton = (
<Button
onClick={(e) => {
e.stopPropagation()
onDownloadClick()
}}
>
Download
</Button>
) )
const isDownloading = downloadingModels.some((md) => md === model.id) let downloadButton = (
<div className="group flex h-8 cursor-pointer items-center justify-center rounded-md bg-[hsla(var(--primary-bg))]">
<div
className="flex h-full items-center rounded-l-md duration-200 hover:backdrop-brightness-75"
onClick={onDownloadClick}
>
<span className="mx-4 font-medium text-white">Download</span>
</div>
<Dropdown
className="z-50 min-w-[240px]"
options={model.models?.map((e) => ({
name: (
<div className="flex space-x-2">
<span className="line-clamp-1 max-w-[340px] font-normal">
{e.id}
</span>
<Badge
theme="secondary"
className="inline-flex w-[60px] items-center font-medium"
>
<span>Default</span>
</Badge>
</div>
),
value: e.id,
suffix: toGigabytes(e.size),
}))}
onValueChanged={(e) => downloadModel(e)}
>
<div className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-r-md border-l border-blue-500 duration-200 hover:backdrop-brightness-75">
<ChevronDownIcon size={14} color="white" />
</div>
</Dropdown>
</div>
)
const isDownloading = downloadingModels.some((md) =>
model.models.some((m) => m.id === md)
)
const onUseModelClick = useCallback(async () => { const onUseModelClick = useCallback(async () => {
if (assistants.length === 0) { const downloadedModel = downloadedModels.find((e) =>
toaster({ model.models.some((m) => m.id === e.id)
title: 'No assistant available.', )
description: `Could not use Model ${model.name} as no assistant is available.`, if (downloadedModel) {
type: 'error', await requestCreateNewThread(assistants[0], downloadedModel)
})
return
}
await requestCreateNewThread(assistants[0], model)
setMainViewState(MainViewState.Thread) setMainViewState(MainViewState.Thread)
}, [assistants, model, requestCreateNewThread, setMainViewState]) }
}, [
assistants,
model,
requestCreateNewThread,
setMainViewState,
downloadedModels,
])
if (isDownloaded) { if (isDownloaded) {
downloadButton = ( downloadButton = (
@ -104,6 +132,7 @@ const ModelItemHeader = ({ model, onClick, open }: Props) => {
disabled={serverEnabled} disabled={serverEnabled}
data-testid={`use-model-btn-${model.id}`} data-testid={`use-model-btn-${model.id}`}
variant="outline" variant="outline"
theme="ghost"
className="min-w-[98px]" className="min-w-[98px]"
> >
Use Use
@ -114,54 +143,54 @@ const ModelItemHeader = ({ model, onClick, open }: Props) => {
/> />
) )
} else if (isDownloading) { } else if (isDownloading) {
downloadButton = <ModalCancelDownload model={model} /> downloadButton = (
<ModalCancelDownload
modelId={
downloadingModels.find((e) => model.models.some((m) => m.id === e)) ??
model.id
}
/>
)
} }
return ( return (
<div <div className="mb-2 rounded-t-md bg-[hsla(var(--app-bg))]">
className="cursor-pointer rounded-t-md bg-[hsla(var(--app-bg))]" <div className="flex items-center justify-between py-2">
onClick={onClick} <div className="group flex cursor-pointer items-center gap-2">
<span
className="line-clamp-1 text-base font-medium capitalize group-hover:text-blue-500 group-hover:underline"
onClick={onSelectedModel}
> >
<div className="flex items-center justify-between px-4 py-2"> {extractModelName(model.metadata?.id)}
<div className="flex items-center gap-2">
<span className="line-clamp-1 text-base font-semibold">
{model.name}
</span> </span>
<EngineBadge engine={model.engine} />
</div> </div>
<div className="inline-flex items-center space-x-2"> <div className="inline-flex items-center space-x-2">
<div className="hidden items-center sm:inline-flex"> <div className="hidden items-center sm:inline-flex">
<span className="mr-4 font-semibold"> <span className="mr-4 text-sm font-light text-[hsla(var(--text-secondary))]">
{toGibibytes(model.metadata?.size)} {toGigabytes(model.models?.[0]?.size)}
</span> </span>
<ModelLabel metadata={model.metadata} />
</div> </div>
{downloadButton} {model.type !== 'cloud' ? (
<ChevronDownIcon downloadButton
className={twMerge( ) : (
'h-5 w-5 flex-none', <>
open === model.id && 'rotate-180' {!model.metadata?.apiKey?.length && (
<Button
data-testid="setup-btn"
onClick={() => {
setSelectedSetting(model.id)
setMainViewState(MainViewState.Settings)
}}
>
Set Up
</Button>
)}
</>
)} )}
/>
</div> </div>
</div> </div>
</div> </div>
) )
} }
type EngineBadgeProps = {
engine: string
}
const EngineBadge = ({ engine }: EngineBadgeProps) => {
const title = 'TensorRT-LLM'
switch (engine) {
case 'nitro-tensorrt-llm':
return <Badge title={title}>{title}</Badge>
default:
return null
}
}
export default ModelItemHeader export default ModelItemHeader

View File

@ -1,98 +1,76 @@
import { useState } from 'react' import Markdown from 'react-markdown'
import { Model } from '@janhq/core' import Image from 'next/image'
import { Badge } from '@janhq/joi'
import { twMerge } from 'tailwind-merge' import { ModelSource } from '@janhq/core'
import { DownloadIcon, FileJson } from 'lucide-react'
import ModelLabel from '@/containers/ModelLabel' import ModelLabel from '@/containers/ModelLabel'
import ModelItemHeader from '@/screens/Hub/ModelList/ModelHeader' import ModelItemHeader from '@/screens/Hub/ModelList/ModelHeader'
import { toGibibytes } from '@/utils/converter' import { toGigabytes } from '@/utils/converter'
import { extractDescription } from '@/utils/modelSource'
import '@/styles/components/model.scss'
type Props = { type Props = {
model: Model model: ModelSource
onSelectedModel: () => void
} }
const ModelItem: React.FC<Props> = ({ model }) => { const ModelItem: React.FC<Props> = ({ model, onSelectedModel }) => {
const [open, setOpen] = useState('')
const handleToggle = () => {
if (open === model.id) {
setOpen('')
} else {
setOpen(model.id)
}
}
return ( return (
<div className="mb-6 flex flex-col overflow-hidden rounded-xl border border-[hsla(var(--app-border))]"> <div className="mb-6 flex w-full flex-col overflow-hidden border-b border-[hsla(var(--app-border))] py-4">
<ModelItemHeader model={model} onClick={handleToggle} open={open} /> <ModelItemHeader model={model} onSelectedModel={onSelectedModel} />
{open === model.id && (
<div className="flex"> <div className="flex w-full">
<div className="flex w-full flex-col border-t border-[hsla(var(--app-border))] p-4 "> <div className="flex w-full flex-col ">
<div className="my-2 inline-flex items-center sm:hidden"> <div className="my-2 inline-flex items-center sm:hidden">
<span className="mr-4 font-semibold"> <span className="mr-4">{toGigabytes(model.models?.[0]?.size)}</span>
{toGibibytes(model.metadata?.size)} <ModelLabel size={model.models?.[0]?.size} />
</span>
<ModelLabel metadata={model.metadata} />
</div> </div>
<div className="mb-6 flex flex-col gap-1"> <div className="flex flex-col">
<span className="font-semibold">About</span> <Markdown className="md-short-desc line-clamp-3 max-w-full overflow-hidden font-light text-[hsla(var(--text-secondary))]">
<p className="text-[hsla(var(--text-secondary))]"> {extractDescription(model.metadata?.description) || '-'}
{model.description || '-'} </Markdown>
</p>
</div> </div>
<div className="flex flex-col gap-y-4 sm:flex-row sm:gap-x-10 sm:gap-y-0"> <div className="mb-6 flex flex-row divide-x">
<div> {model.metadata?.author && (
<span className="font-semibold ">Author</span>
<p <p
className="mt-2 line-clamp-1 font-medium text-[hsla(var(--text-secondary))]" className="font-regular mt-3 line-clamp-1 flex flex-row pr-4 capitalize text-[hsla(var(--text-secondary))]"
title={model.metadata?.author} title={model.metadata?.author}
> >
{model.id?.includes('huggingface.co') && (
<>
<Image
src={'icons/huggingFace.svg'}
width={16}
height={16}
className="mr-2"
alt=""
/>{' '}
</>
)}{' '}
{model.metadata?.author} {model.metadata?.author}
</p> </p>
</div>
<div>
<span className="mb-1 font-semibold ">Model ID</span>
<p
className="mt-2 line-clamp-1 font-medium text-[hsla(var(--text-secondary))]"
title={model.id}
>
{model.id}
</p>
</div>
<div>
<span className="mb-1 font-semibold ">Tags</span>
<div className="mt-2 flex flex-wrap gap-x-1 gap-y-1">
{model.metadata?.tags?.map((tag: string) => (
<Badge key={tag} title={tag} variant="soft">
{tag}
</Badge>
))}
</div>
</div>
</div>
</div>
<div className="hidden w-48 flex-shrink-0 border-l border-t border-[hsla(var(--app-border))] p-4">
<div>
<span className="font-semibold ">Format</span>
<p
className={twMerge(
'mt-2 font-medium',
!model.format?.includes(' ') &&
!model.format?.includes('-') &&
'uppercase'
)} )}
> {model.models?.length > 0 && (
{model.format} <p className="font-regular mt-3 line-clamp-1 flex flex-row items-center pl-4 pr-4 text-[hsla(var(--text-secondary))] first:pl-0">
<FileJson size={16} className="mr-2" />
{model.models?.length}{' '}
{model.type === 'cloud' ? 'models' : 'versions'}
</p> </p>
</div>
</div>
</div>
)} )}
{model.metadata?.downloads > 0 && (
<p className="font-regular mt-3 line-clamp-1 flex flex-row items-center px-4 text-[hsla(var(--text-secondary))]">
<DownloadIcon size={16} className="mr-2" />
{model.metadata?.downloads}
</p>
)}
</div>
</div>
</div>
</div> </div>
) )
} }

View File

@ -1,53 +1,22 @@
import { useMemo } from 'react' import { ModelSource } from '@janhq/core'
import { Model } from '@janhq/core'
import { useAtomValue } from 'jotai'
import { useGetEngines } from '@/hooks/useEngineManagement'
import ModelItem from '@/screens/Hub/ModelList/ModelItem' import ModelItem from '@/screens/Hub/ModelList/ModelItem'
import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom'
type Props = { type Props = {
models: Model[] models: ModelSource[]
onSelectedModel: (model: ModelSource) => void
} }
const ModelList = ({ models }: Props) => { const ModelList = ({ models, onSelectedModel }: Props) => {
const downloadedModels = useAtomValue(downloadedModelsAtom)
const { engines } = useGetEngines()
const sortedModels: Model[] = useMemo(() => {
const featuredModels: Model[] = []
const remoteModels: Model[] = []
const localModels: Model[] = []
const remainingModels: Model[] = []
models.forEach((m) => {
if (m.metadata?.tags?.includes('Featured')) {
featuredModels.push(m)
} else if (engines?.[m.engine]?.[0]?.type === 'remote') {
remoteModels.push(m)
} else if (downloadedModels.map((m) => m.id).includes(m.id)) {
localModels.push(m)
} else {
remainingModels.push(m)
}
})
featuredModels.sort((m1, m2) => m1.metadata?.size - m2.metadata?.size)
localModels.sort((m1, m2) => m1.metadata?.size - m2.metadata?.size)
remainingModels.sort((m1, m2) => m1.metadata?.size - m2.metadata?.size)
remoteModels.sort((m1, m2) => m1.name.localeCompare(m2.name))
return [
...featuredModels,
...localModels,
...remainingModels,
...remoteModels,
]
}, [models, downloadedModels, engines])
return ( return (
<div className="relative h-full w-full flex-shrink-0"> <div className="relative h-full w-full flex-shrink-0">
{sortedModels?.map((model) => <ModelItem key={model.id} model={model} />)} {models.map((model) => (
<ModelItem
key={model.id}
model={model}
onSelectedModel={() => onSelectedModel(model)}
/>
))}
</div> </div>
) )
} }

View File

@ -0,0 +1,206 @@
import Image from 'next/image'
import { ModelSource } from '@janhq/core'
import { Badge, Button, ScrollArea } from '@janhq/joi'
import { useSetAtom } from 'jotai'
import {
ArrowLeftIcon,
DownloadIcon,
FileJson,
SettingsIcon,
} from 'lucide-react'
import '@/styles/components/marked.scss'
import ModelDownloadButton from '@/containers/ModelDownloadButton'
import { MainViewState } from '@/constants/screens'
import { MarkdownTextMessage } from '@/screens/Thread/ThreadCenterPanel/TextMessage/MarkdownTextMessage'
import { toGigabytes } from '@/utils/converter'
import { extractModelName } from '@/utils/modelSource'
import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
import { selectedSettingAtom } from '@/helpers/atoms/Setting.atom'
type Props = {
model: ModelSource
onGoBack: () => void
}
const ModelPage = ({ model, onGoBack }: Props) => {
const setSelectedSetting = useSetAtom(selectedSettingAtom)
const setMainViewState = useSetAtom(mainViewStateAtom)
return (
<ScrollArea data-testid="hub-container-test-id" className="h-full w-full">
<div className="flex h-full w-full justify-center">
<div className="flex w-full max-w-[800px] flex-col ">
<div className="sticky top-0 flex h-12 items-center bg-[hsla(var(--app-bg))] px-4">
<div className="flex items-center gap-2">
<button
onClick={onGoBack}
className="flex items-center gap-1 text-sm text-[hsla(var(--text-secondary))] hover:text-[hsla(var(--text-primary))]"
>
<ArrowLeftIcon size={16} />
<span>Back</span>
</button>
</div>
</div>
<div className="p-4">
{/* Header */}
<div className="flex items-center justify-between py-2">
<span className="line-clamp-1 text-base font-medium capitalize group-hover:text-blue-500 group-hover:underline">
{extractModelName(model.metadata.id)}
</span>
<div className="inline-flex items-center space-x-2">
{model.type !== 'cloud' ? (
<ModelDownloadButton id={model.models?.[0].id} />
) : (
<>
{!model.metadata?.apiKey?.length ? (
<Button
onClick={() => {
setSelectedSetting(model.id)
setMainViewState(MainViewState.Settings)
}}
>
Set Up
</Button>
) : (
<Button
theme="ghost"
variant="outline"
className="w-8 p-0"
onClick={() => {
setSelectedSetting(model.id)
setMainViewState(MainViewState.Settings)
}}
>
<SettingsIcon
size={18}
className="text-[hsla(var(--text-secondary))]"
/>
</Button>
)}
</>
)}
</div>
</div>
<div className="mb-6 flex flex-row divide-x">
{model.metadata?.author && (
<p
className="font-regular mt-3 line-clamp-1 flex flex-row pr-4 capitalize text-[hsla(var(--text-secondary))]"
title={model.metadata?.author}
>
{model.id?.includes('huggingface.co') && (
<>
<Image
src={'icons/huggingFace.svg'}
width={16}
height={16}
className="mr-2"
alt=""
/>{' '}
</>
)}
{model.metadata?.author}
</p>
)}
{model.models?.length > 0 && (
<p className="font-regular mt-3 line-clamp-1 flex flex-row items-center pl-4 pr-4 text-[hsla(var(--text-secondary))] first:pl-0">
<FileJson size={16} className="mr-2" />
{model.models?.length}{' '}
{model.type === 'cloud' ? 'models' : 'versions'}
</p>
)}
{model.metadata?.downloads > 0 && (
<p className="font-regular mt-3 line-clamp-1 flex flex-row items-center px-4 text-[hsla(var(--text-secondary))]">
<DownloadIcon size={16} className="mr-2" />
{model.metadata?.downloads}
</p>
)}
</div>
{/* Table of versions */}
<div className="mt-8 flex w-full flex-col items-start justify-between sm:flex-row">
<div className="w-full flex-shrink-0 rounded-lg border border-[hsla(var(--app-border))] text-[hsla(var(--text-secondary))]">
<table className="w-full p-4">
<thead className="bg-[hsla(var(--tertiary-bg))]">
<tr>
<th className="flex-1 px-6 py-3 text-left text-sm font-semibold">
{model.type !== 'cloud' ? 'Version' : 'Models'}
</th>
{model.type !== 'cloud' && (
<>
<th className="max-w-32 hidden px-6 py-3 text-left text-sm font-semibold sm:table-cell">
Format
</th>
<th className="max-w-32 hidden px-6 py-3 text-left text-sm font-semibold sm:table-cell">
Size
</th>
</>
)}
<th className="w-[120px]"></th>
</tr>
</thead>
<tbody>
{model.models?.map((item, i) => {
return (
<tr
key={item.id}
className="border-t border-[hsla(var(--app-border))] font-medium text-[hsla(var(--text-secondary))]"
>
<td className="flex items-center space-x-4 px-6 py-4 text-black">
<span className="line-clamp-1">
{item.id?.split(':')?.pop()}
</span>
{i === 0 && model.type !== 'cloud' && (
<Badge
theme="secondary"
className="inline-flex w-[60px] items-center font-medium"
>
<span>Default</span>
</Badge>
)}
</td>
{model.type !== 'cloud' && (
<>
<td className="hidden px-6 py-4 sm:table-cell">
GGUF
</td>
<td className="hidden px-6 py-4 text-[hsla(var(--text-secondary))] sm:table-cell">
{toGigabytes(item.size)}
</td>
</>
)}
<td className="pr-4 text-right text-black">
{(model.type !== 'cloud' ||
(model.metadata?.apiKey?.length ?? 0) > 0) && (
<ModelDownloadButton
id={item.id}
theme={i === 0 ? 'primary' : 'ghost'}
variant={i === 0 ? 'solid' : 'outline'}
/>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
{/* README */}
<div className="mt-8 flex w-full flex-col items-start justify-between sm:flex-row">
<MarkdownTextMessage
text={model.metadata?.description ?? ''}
className="markdown-content h-full w-full text-[hsla(var(--text-secondary))]"
/>
</div>
</div>
</div>
</div>
</ScrollArea>
)
}
export default ModelPage

View File

@ -1,102 +1,416 @@
import { useCallback, useState } from 'react' /* eslint-disable @typescript-eslint/naming-convention */
import { useCallback, useMemo, useRef, useState, useEffect } from 'react'
import { useDropzone } from 'react-dropzone'
import Image from 'next/image' import Image from 'next/image'
import { ScrollArea, Button, Select } from '@janhq/joi' import { ModelSource } from '@janhq/core'
import { useAtomValue, useSetAtom } from 'jotai' import { ScrollArea, Button, Select, Tabs, useClickOutside } from '@janhq/joi'
import { UploadIcon } from 'lucide-react' import { motion as m } from 'framer-motion'
import { useAtom, useSetAtom } from 'jotai'
import { ImagePlusIcon, UploadCloudIcon, UploadIcon } from 'lucide-react'
import { twMerge } from 'tailwind-merge'
import BlankState from '@/containers/BlankState'
import CenterPanelContainer from '@/containers/CenterPanelContainer' import CenterPanelContainer from '@/containers/CenterPanelContainer'
import ModelSearch from '@/containers/ModelSearch' import ModelSearch from '@/containers/ModelSearch'
import { useGetEngineModelSources } from '@/hooks/useEngineManagement'
import { setImportModelStageAtom } from '@/hooks/useImportModel' import { setImportModelStageAtom } from '@/hooks/useImportModel'
import {
useGetModelSources,
useModelSourcesMutation,
} from '@/hooks/useModelSource'
import ModelList from '@/screens/Hub/ModelList' import ModelList from '@/screens/Hub/ModelList'
import { import { extractModelRepo } from '@/utils/modelSource'
configuredModelsAtom, import { fuzzySearch } from '@/utils/search'
downloadedModelsAtom,
} from '@/helpers/atoms/Model.atom' import ModelPage from './ModelPage'
import { appBannerHubAtom } from '@/helpers/atoms/App.atom'
import { modelDetailAtom } from '@/helpers/atoms/Model.atom'
const sortMenus = [ const sortMenus = [
{ {
name: 'All Models', name: 'Most downloaded',
value: 'all-models', value: 'most-downloaded',
}, },
{ {
name: 'Featured', name: 'Newest',
value: 'featured', value: 'newest',
},
]
const filterOptions = [
{
name: 'All',
value: 'all',
}, },
{ {
name: 'Downloaded', name: 'On-device',
value: 'downloaded', value: 'on-device',
},
{
name: 'Cloud',
value: 'cloud',
}, },
] ]
const HubScreen = () => { const HubScreen = () => {
const configuredModels = useAtomValue(configuredModelsAtom) const { sources } = useGetModelSources()
const downloadedModels = useAtomValue(downloadedModelsAtom) const { sources: remoteModelSources } = useGetEngineModelSources()
const [searchValue, setsearchValue] = useState('') const { addModelSource } = useModelSourcesMutation()
const [sortSelected, setSortSelected] = useState('all-models') const [searchValue, setSearchValue] = useState('')
const [sortSelected, setSortSelected] = useState('newest')
const [filterOption, setFilterOption] = useState('all')
const [hubBannerOption, setHubBannerOption] = useState('gallery')
const [showHubBannerSetting, setShowHubBannerSetting] = useState(false)
const [appBannerHub, setAppBannerHub] = useAtom(appBannerHubAtom)
const [selectedModel, setSelectedModel] = useState<ModelSource | undefined>(
undefined
)
const [modelDetail, setModelDetail] = useAtom(modelDetailAtom)
const setImportModelStage = useSetAtom(setImportModelStageAtom) const setImportModelStage = useSetAtom(setImportModelStageAtom)
const dropdownRef = useRef<HTMLDivElement>(null)
const imageInputRef = useRef<HTMLInputElement>(null)
const hubBannerSettingRef = useRef<HTMLDivElement>(null)
const filteredModels = configuredModels.filter((x) => { const searchedModels = useMemo(
if (sortSelected === 'downloaded') { () =>
return ( searchValue.length
x.name.toLowerCase().includes(searchValue.toLowerCase()) && ? (sources?.filter((e) =>
downloadedModels.some((y) => y.id === x.id) fuzzySearch(
searchValue.replaceAll(' ', '').toLowerCase(),
e.id.toLowerCase()
) )
} else if (sortSelected === 'featured') { ) ?? [])
return ( : [],
x.name.toLowerCase().includes(searchValue.toLowerCase()) && [sources, searchValue]
x.metadata?.tags?.includes('Featured')
) )
const sortedModels = useMemo(() => {
if (!sources) return []
return sources.sort((a, b) => {
if (sortSelected === 'most-downloaded') {
return b.metadata.downloads - a.metadata.downloads
} else { } else {
return x.name.toLowerCase().includes(searchValue.toLowerCase()) return (
new Date(b.metadata.createdAt).getTime() -
new Date(a.metadata.createdAt).getTime()
)
} }
}) })
}, [sortSelected, sources])
useEffect(() => {
if (modelDetail) {
setSelectedModel(sources?.find((e) => e.id === modelDetail))
setModelDetail(undefined)
}
}, [modelDetail, sources, setModelDetail, addModelSource])
useEffect(() => {
if (selectedModel) {
// Try add the model source again to update it's data
addModelSource(selectedModel.id).catch(console.debug)
}
}, [sources, selectedModel, addModelSource, setSelectedModel])
useClickOutside(
() => {
setSearchValue('')
},
null,
[dropdownRef.current]
)
useClickOutside(
() => {
setShowHubBannerSetting(false)
},
null,
[hubBannerSettingRef.current]
)
const onImportModelClick = useCallback(() => { const onImportModelClick = useCallback(() => {
setImportModelStage('SELECTING_MODEL') setImportModelStage('SELECTING_MODEL')
}, [setImportModelStage]) }, [setImportModelStage])
const onSearchUpdate = useCallback((input: string) => { const onSearchUpdate = useCallback((input: string) => {
setsearchValue(input) setSearchValue(input)
}, []) }, [])
const setBannerHubImage = (image: string) => {
setShowHubBannerSetting(false)
setAppBannerHub(image)
}
/**
* Handles the change event of the extension file input element by setting the file name state.
* Its to be used to display the extension file name of the selected file.
* @param event - The change event object.
*/
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
const fileType = file.type
if (!fileType.startsWith('image/')) {
alert('Please upload an image file.')
return
}
const reader = new FileReader()
reader.onload = () => {
// FileReader result is already in a valid Base64 format
setBannerHubImage(reader.result as string)
}
reader.readAsDataURL(file)
}
const { isDragActive, getRootProps } = useDropzone({
noClick: true,
multiple: true,
accept: {
'image/jpeg': ['.jpeg'],
'image/png': ['.png'],
'image/jpg': ['.jpg'],
},
onDrop: (files) => {
const reader = new FileReader()
reader.onload = () => {
// FileReader result is already in a valid Base64 format
setBannerHubImage(reader.result as string)
}
reader.readAsDataURL(files[0])
},
})
return ( return (
<CenterPanelContainer> <CenterPanelContainer>
<ScrollArea data-testid="hub-container-test-id" className="h-full w-full"> <m.div
key={selectedModel?.id}
initial={{ opacity: 0, y: -8 }}
className="h-full"
animate={{
opacity: 1,
y: 0,
transition: {
duration: 0.25,
},
}}
>
{!selectedModel && (
<ScrollArea
data-testid="hub-container-test-id"
className="h-full w-full"
>
<>
<div className="relative h-40 p-4 sm:h-auto"> <div className="relative h-40 p-4 sm:h-auto">
<div className="group">
<Image <Image
src="./images/hub-banner.png" src={appBannerHub}
alt="Hub Banner" alt="Hub Banner"
width={800} width={800}
height={800} height={800}
className="h-full w-full rounded-lg object-cover" className="h-60 w-full rounded-lg object-cover"
/> />
<div className="absolute left-1/2 top-1/2 mx-auto w-4/5 -translate-x-1/2 -translate-y-1/2 rounded-xl bg-[hsla(var(--app-bg))] p-4 sm:w-1/2"> <div
<div className="flex flex-col items-center justify-between gap-2 sm:flex-row"> className={twMerge(
<div className="w-full"> 'invisible absolute bottom-8 right-8 cursor-pointer opacity-0 transition-opacity',
<ModelSearch onSearchLocal={onSearchUpdate} /> 'duration-300 group-hover:visible group-hover:opacity-100',
showHubBannerSetting && '!visible !opacity-100'
)}
>
<div
className="h-full w-full rounded-lg border-2 border-[hsla(var(--app-border))] bg-white p-2"
onClick={() =>
setShowHubBannerSetting(!showHubBannerSetting)
}
>
<ImagePlusIcon size={16} />
</div> </div>
<div className="flex-shrink-0"> <div
<Button onClick={onImportModelClick}> className={twMerge(
'absolute right-0 z-20 mt-2 w-[350px] overflow-hidden rounded-lg border border-[hsla(var(--app-border))] bg-[hsla(var(--app-bg))] shadow-sm',
showHubBannerSetting ? 'flex' : 'hidden'
)}
ref={hubBannerSettingRef}
>
<div className="h-full w-full">
<div className="mb-2 p-2 pb-0">
<Tabs
options={[
{ name: 'Gallery', value: 'gallery' },
{ name: 'Upload', value: 'upload' },
]}
tabStyle="segmented"
value={hubBannerOption as string}
onValueChange={(value) => setHubBannerOption(value)}
/>
</div>
{hubBannerOption === 'gallery' && (
<ScrollArea className="h-[350px] w-full">
{Array.from({ length: 30 }, (_, i) => i + 1).map(
(e) => {
return (
<div
key={e}
className="mb-2 h-20 w-full "
onClick={() =>
setBannerHubImage(
`./images/HubBanner/banner-${e}.jpg`
)
}
>
<Image
className="ml-2 mr-2 h-20 w-[334px] overflow-hidden rounded-lg border-b border-[hsla(var(--app-border))] bg-[hsla(var(--app-bg))] object-cover"
width={920}
height={200}
alt="banner-img"
src={`./images/HubBanner/banner-${e}.jpg`}
/>
</div>
)
}
)}
</ScrollArea>
)}
{hubBannerOption === 'upload' && (
<div
className={`m-2 flex h-[172px] w-full cursor-pointer items-center justify-center rounded-md border`}
onClick={() => {
imageInputRef.current?.click()
}}
{...getRootProps()}
>
<div className="flex flex-col items-center justify-center">
<div className="mx-auto inline-flex h-10 w-10 items-center justify-center rounded-full bg-gray-200">
<UploadCloudIcon
size={24}
className={
isDragActive
? 'text-[hsla(var(--primary-bg))]'
: 'text-[hsla(var(--text-secondary))]'
}
/>
</div>
<div className="mt-4 text-center">
{!isDragActive && (
<>
<span className="text-primary font-bold text-[hsla(var(--primary-bg))]">
Click to upload &nbsp;
</span>
<span className="text-[hsla(var(--text-secondary))]">
or drag and drop
</span>
<p className="text-[hsla(var(--text-secondary))]">
Image size: 920x200
</p>
</>
)}
{isDragActive && (
<span className="text-primary font-bold text-[hsla(var(--primary-bg))]">
Drop here
</span>
)}
<input
type="file"
className="hidden"
ref={imageInputRef}
value=""
onChange={handleFileChange}
accept="image/png, image/jpeg, image/jpg"
/>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
<div className="absolute left-1/2 top-1/2 z-10 mx-auto w-4/5 -translate-x-1/2 -translate-y-1/2 rounded-xl sm:w-2/6">
<div className="flex flex-col items-center justify-between gap-2 sm:flex-row">
<div className="w-full" ref={dropdownRef}>
<ModelSearch onSearchLocal={onSearchUpdate} />
<div
className={twMerge(
'invisible absolute mt-2 w-full overflow-hidden rounded-lg border border-[hsla(var(--app-border))] bg-[hsla(var(--app-bg))] shadow-lg',
searchedModels.length > 0 && 'visible'
)}
>
{searchedModels.map((model) => (
<div
key={model.id}
className="z-10 flex cursor-pointer items-center space-x-2 px-4 py-2 hover:bg-[hsla(var(--dropdown-menu-hover-bg))]"
onClick={(e) => {
setSelectedModel(model)
e.stopPropagation()
}}
>
<span className="text-bold flex flex-row text-[hsla(var(--app-text-primary))]">
{searchValue.includes('huggingface.co') && (
<>
<Image
src={'icons/huggingFace.svg'}
width={16}
height={16}
className="mr-2"
alt=""
/>{' '}
</>
)}
{extractModelRepo(model.id)}
</span>
</div>
))}
</div>
</div>
</div>
</div>
<div className="absolute right-8 top-8 flex-shrink-0 rounded-md bg-[hsla(var(--app-bg))]">
<Button
onClick={onImportModelClick}
variant="solid"
theme="ghost"
>
<UploadIcon size={16} className="mr-2" /> <UploadIcon size={16} className="mr-2" />
<span>Import Model</span> <span>Import</span>
</Button> </Button>
</div> </div>
</div> </div>
</div> <div className="mt-8 p-4 py-0 sm:px-16">
</div>
<div className="p-4 py-0 sm:px-16">
{!filteredModels.length ? (
<BlankState title="No search results found" />
) : (
<> <>
<div className="flex flex-row">
<div className="flex w-full flex-col items-start justify-between gap-4 py-4 first:pt-0 sm:flex-row">
<div className="flex items-center gap-x-2">
{filterOptions.map((e) => (
<div
key={e.value}
className={twMerge(
'rounded-md border duration-200 hover:border-gray-200 hover:bg-gray-200',
e.value === filterOption
? 'border-gray-200 bg-gray-200'
: 'border-[hsla(var(--app-border))] bg-[hsla(var(--app-bg))]'
)}
>
<Button
theme={'ghost'}
variant={'soft'}
onClick={() => setFilterOption(e.value)}
>
{e.name}
</Button>
</div>
))}
</div>
</div>
<div className="mb-4 flex w-full justify-end"> <div className="mb-4 flex w-full justify-end">
<Select <Select
value={sortSelected} value={sortSelected}
@ -106,11 +420,34 @@ const HubScreen = () => {
options={sortMenus} options={sortMenus}
/> />
</div> </div>
<ModelList models={filteredModels} />
</>
)}
</div> </div>
{(filterOption === 'on-device' || filterOption === 'all') && (
<ModelList
models={sortedModels}
onSelectedModel={(model) => setSelectedModel(model)}
/>
)}
{(filterOption === 'cloud' || filterOption === 'all') && (
<ModelList
models={remoteModelSources}
onSelectedModel={(model) => setSelectedModel(model)}
/>
)}
</>
</div>
</>
</ScrollArea> </ScrollArea>
)}
{selectedModel && (
<ModelPage
model={selectedModel}
onGoBack={() => {
setSearchValue('')
setSelectedModel(undefined)
}}
/>
)}
</m.div>
</CenterPanelContainer> </CenterPanelContainer>
) )
} }

View File

@ -17,7 +17,7 @@ import useImportModel, {
setImportModelStageAtom, setImportModelStageAtom,
} from '@/hooks/useImportModel' } from '@/hooks/useImportModel'
import { toGibibytes } from '@/utils/converter' import { toGigabytes } from '@/utils/converter'
import { openFileTitle } from '@/utils/titleUtils' import { openFileTitle } from '@/utils/titleUtils'
@ -126,7 +126,7 @@ const EditModelInfoModal = () => {
<div className="flex flex-1 flex-col"> <div className="flex flex-1 flex-col">
<p>{editingModel.name}</p> <p>{editingModel.name}</p>
<div className="flex flex-row"> <div className="flex flex-row">
<span className="mr-2">{toGibibytes(editingModel.size)}</span> <span className="mr-2">{toGigabytes(editingModel.size)}</span>
<div className="flex flex-row space-x-1"> <div className="flex flex-row space-x-1">
<span className="font-semibold">Format:</span> <span className="font-semibold">Format:</span>
<span className="font-normal"> <span className="font-normal">

View File

@ -6,7 +6,7 @@ import { Button, Switch } from '@janhq/joi'
import { useAtom, useSetAtom } from 'jotai' import { useAtom, useSetAtom } from 'jotai'
import { SettingsIcon } from 'lucide-react' import { SettingsIcon } from 'lucide-react'
import { getTitleByEngine } from '@/utils/modelEngine' import { getDescriptionByEngine, getTitleByEngine } from '@/utils/modelEngine'
import { showSettingActiveRemoteEngineAtom } from '@/helpers/atoms/Extension.atom' import { showSettingActiveRemoteEngineAtom } from '@/helpers/atoms/Extension.atom'
import { selectedSettingAtom } from '@/helpers/atoms/Setting.atom' import { selectedSettingAtom } from '@/helpers/atoms/Setting.atom'
@ -48,10 +48,7 @@ const RemoteEngineItems = ({
</h6> </h6>
</div> </div>
<div className="mt-2 w-full font-medium leading-relaxed text-[hsla(var(--text-secondary))]"> <div className="mt-2 w-full font-medium leading-relaxed text-[hsla(var(--text-secondary))]">
<p> <p>{getDescriptionByEngine(engine as InferenceEngine)}</p>
Access models from {getTitleByEngine(engine as InferenceEngine)}{' '}
via their API.
</p>
</div> </div>
</div> </div>

View File

@ -1,52 +0,0 @@
import { useMemo } from 'react'
import { ScrollArea } from '@janhq/joi'
import { useAtomValue } from 'jotai'
import ModelDownloadRow from '../ModelDownloadRow'
import { importingHuggingFaceRepoDataAtom } from '@/helpers/atoms/HuggingFace.atom'
const ModelDownloadList = () => {
const importingHuggingFaceRepoData = useAtomValue(
importingHuggingFaceRepoDataAtom
)
const ggufModels = useMemo(
() =>
importingHuggingFaceRepoData?.siblings.filter(
(e) => e.downloadUrl && e.rfilename.endsWith('.gguf')
),
[importingHuggingFaceRepoData]
)
if (!importingHuggingFaceRepoData) return null
if (!ggufModels || ggufModels.length === 0) {
return <div>No available GGUF model</div>
}
return (
<div className="flex h-[500px] flex-1 flex-col">
<h1 className="mb-3 font-semibold">Available Versions</h1>
<ScrollArea className="w-full lg:h-full lg:flex-1">
{ggufModels.map((model, index) => {
if (!model.downloadUrl) return null
return (
<ModelDownloadRow
repoData={importingHuggingFaceRepoData}
downloadUrl={model.downloadUrl}
key={model.rfilename}
index={index}
fileName={model.rfilename}
fileSize={model.fileSize}
quantization={model.quantization}
/>
)
})}
</ScrollArea>
</div>
)
}
export default ModelDownloadList

View File

@ -1,154 +0,0 @@
import { useCallback } from 'react'
import { DownloadState, HuggingFaceRepoData, Quantization } from '@janhq/core'
import { Badge, Button, Progress } from '@janhq/joi'
import { useAtomValue, useSetAtom } from 'jotai'
import { twMerge } from 'tailwind-merge'
import { MainViewState } from '@/constants/screens'
import { useCreateNewThread } from '@/hooks/useCreateNewThread'
import useDownloadModel from '@/hooks/useDownloadModel'
import { modelDownloadStateAtom } from '@/hooks/useDownloadState'
import { formatDownloadPercentage, toGibibytes } from '@/utils/converter'
import { normalizeModelId } from '@/utils/model'
import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
import { assistantsAtom } from '@/helpers/atoms/Assistant.atom'
import { importHuggingFaceModelStageAtom } from '@/helpers/atoms/HuggingFace.atom'
import {
downloadedModelsAtom,
getDownloadingModelAtom,
} from '@/helpers/atoms/Model.atom'
type Props = {
index: number
repoData: HuggingFaceRepoData
downloadUrl: string
fileName: string
fileSize?: number
quantization?: Quantization
}
const ModelDownloadRow: React.FC<Props> = ({
downloadUrl,
fileName,
fileSize = 0,
quantization,
}) => {
const downloadedModels = useAtomValue(downloadedModelsAtom)
const { downloadModel, abortModelDownload } = useDownloadModel()
const allDownloadStates = useAtomValue(modelDownloadStateAtom)
const downloadState: DownloadState | undefined = allDownloadStates[fileName]
const downloadingModels = useAtomValue(getDownloadingModelAtom)
const { requestCreateNewThread } = useCreateNewThread()
const setMainViewState = useSetAtom(mainViewStateAtom)
const assistants = useAtomValue(assistantsAtom)
const downloadedModel = downloadedModels.find((md) => md.id === fileName)
const isDownloading = downloadingModels.some((md) => md === fileName)
const setHfImportingStage = useSetAtom(importHuggingFaceModelStageAtom)
const onAbortDownloadClick = useCallback(() => {
if (downloadUrl) {
abortModelDownload(normalizeModelId(downloadUrl))
}
}, [downloadUrl, abortModelDownload])
const onDownloadClick = useCallback(async () => {
if (downloadUrl) {
downloadModel(
downloadUrl,
normalizeModelId(downloadUrl),
normalizeModelId(downloadUrl)
)
}
}, [downloadUrl, downloadModel])
const onUseModelClick = useCallback(async () => {
if (assistants.length === 0) {
alert('No assistant available')
return
}
await requestCreateNewThread(assistants[0], downloadedModel)
setMainViewState(MainViewState.Thread)
setHfImportingStage('NONE')
}, [
assistants,
downloadedModel,
requestCreateNewThread,
setMainViewState,
setHfImportingStage,
])
if (!downloadUrl) {
return null
}
return (
<div className="flex flex-col gap-4 rounded border border-[hsla(var(--app-border))] p-3 md:flex-row md:items-center md:justify-between xl:w-full">
<div className="flex max-w-[50%] justify-between">
<div className="flex min-w-[280px] max-w-[280px]">
{quantization && (
<Badge variant="soft" className="mr-1">
{quantization}
</Badge>
)}
<h1
className={twMerge(
'mr-5 line-clamp-1 font-medium text-[hsla(var(--text-secondary))]'
)}
title={fileName}
>
{fileName}
</h1>
</div>
<div className="md:min-w-[90px] md:max-w-[90px]">
<Badge theme="secondary" className="ml-4 hidden md:flex">
{toGibibytes(fileSize)}
</Badge>
</div>
</div>
{downloadedModel ? (
<Button
variant="soft"
className="min-w-[98px]"
onClick={onUseModelClick}
data-testid={`use-model-btn-${downloadUrl}`}
>
Use
</Button>
) : isDownloading ? (
<Button variant="soft">
<div className="flex items-center space-x-2">
<span className="inline-block" onClick={onAbortDownloadClick}>
Cancel
</span>
<Progress
className="inline-block h-2 w-[80px]"
value={
formatDownloadPercentage(downloadState?.percent, {
hidePercentage: true,
}) as number
}
/>
<span className="tabular-nums">
{formatDownloadPercentage(downloadState?.percent)}
</span>
</div>
</Button>
) : (
<Button onClick={onDownloadClick}>Download</Button>
)}
</div>
)
}
export default ModelDownloadRow

View File

@ -1,101 +0,0 @@
import { ReactNode, useMemo, memo } from 'react'
import { Badge } from '@janhq/joi'
import { useAtomValue } from 'jotai'
import { Download } from 'lucide-react'
import { importingHuggingFaceRepoDataAtom } from '@/helpers/atoms/HuggingFace.atom'
const ModelSegmentInfo = () => {
const importingHuggingFaceRepoData = useAtomValue(
importingHuggingFaceRepoDataAtom
)
const { author, modelName, downloads, modelUrl } = useMemo(() => {
const cardData = importingHuggingFaceRepoData?.cardData
const author = (cardData?.['model_creator'] ?? 'N/A') as string
const modelName = (cardData?.['model_name'] ??
importingHuggingFaceRepoData?.id ??
'N/A') as string
const modelUrl = importingHuggingFaceRepoData?.modelUrl ?? 'N/A'
const downloads = importingHuggingFaceRepoData?.downloads ?? 0
return {
author,
modelName,
modelUrl,
downloads,
}
}, [importingHuggingFaceRepoData])
if (!importingHuggingFaceRepoData) return null
return (
<div className="flex w-full flex-col space-y-4 lg:w-1/3">
<HeaderInfo title={'Model ID'}>
<h1 className="font-medium text-zinc-500 dark:text-gray-300">
{modelName}
</h1>
</HeaderInfo>
<HeaderInfo title={'Model URL'}>
<a
href={modelUrl}
target="_blank"
className="line-clamp-1 font-medium text-[hsla(var(--app-link))] hover:underline"
>
{modelUrl}
</a>
</HeaderInfo>
<div className="flex justify-between space-x-4">
<div className="flex-1">
<HeaderInfo title="Author">
<h1 className="font-medium text-[hsla(var(--text-secondary))]">
{author}
</h1>
</HeaderInfo>
</div>
<div className="flex-1">
<HeaderInfo title="Downloads">
<div className="flex flex-row items-center space-x-1.5">
<Download size={16} />
<span className="font-medium text-zinc-500 dark:text-gray-300">
{downloads}
</span>
</div>
</HeaderInfo>
</div>
</div>
<HeaderInfo title="Tags">
<div className="mt-2 flex h-14 flex-wrap gap-x-1 gap-y-1 overflow-auto lg:h-auto">
{importingHuggingFaceRepoData.tags.map((tag) => (
<Badge variant="soft" key={tag} title={tag} className="mt-1">
<span className="line-clamp-1">{tag}</span>
</Badge>
))}
</div>
</HeaderInfo>
</div>
)
}
type HeaderInfoProps = {
title: string
children: ReactNode
}
const HeaderInfo = ({ title, children }: HeaderInfoProps) => {
return (
<div className="flex flex-col space-y-2">
<h1 className="font-semibold">{title}</h1>
{children}
</div>
)
}
export default memo(ModelSegmentInfo)

View File

@ -1,53 +0,0 @@
import { useCallback, useMemo } from 'react'
import { Modal } from '@janhq/joi'
import { useAtom } from 'jotai'
import ModelDownloadList from './ModelDownloadList'
import ModelSegmentInfo from './ModelSegmentInfo'
import {
importHuggingFaceModelStageAtom,
importingHuggingFaceRepoDataAtom,
} from '@/helpers/atoms/HuggingFace.atom'
const HuggingFaceRepoDetailModal = () => {
const [hfImportState, setHfImportState] = useAtom(
importHuggingFaceModelStageAtom
)
const [importingHuggingFaceRepoData, setImportingHuggingFaceRepoData] =
useAtom(importingHuggingFaceRepoDataAtom)
const onOpenChange = useCallback(() => {
setImportingHuggingFaceRepoData(undefined)
setHfImportState('NONE')
}, [setHfImportState, setImportingHuggingFaceRepoData])
const open = useMemo(() => {
return (
hfImportState === 'REPO_DETAIL' && importingHuggingFaceRepoData != null
)
}, [hfImportState, importingHuggingFaceRepoData])
if (!importingHuggingFaceRepoData) return null
return (
<Modal
open={open}
onOpenChange={onOpenChange}
title={importingHuggingFaceRepoData.id}
fullPage
content={
<div className="flex h-full w-full flex-col">
<div className="flex flex-col gap-4 lg:flex-row">
<ModelSegmentInfo />
<ModelDownloadList />
</div>
</div>
}
/>
)
}
export default HuggingFaceRepoDetailModal

View File

@ -7,7 +7,7 @@ import { AlertCircle } from 'lucide-react'
import { setImportModelStageAtom } from '@/hooks/useImportModel' import { setImportModelStageAtom } from '@/hooks/useImportModel'
import { toGibibytes } from '@/utils/converter' import { toGigabytes } from '@/utils/converter'
import { editingModelIdAtom } from '../EditModelInfoModal' import { editingModelIdAtom } from '../EditModelInfoModal'
import ImportInProgressIcon from '../ImportInProgressIcon' import ImportInProgressIcon from '../ImportInProgressIcon'
@ -32,7 +32,7 @@ const ImportingModelItem = ({ model }: Props) => {
if (model.status === 'FAILED') { if (model.status === 'FAILED') {
return 'Failed' return 'Failed'
} else { } else {
return toGibibytes(model.size) return toGigabytes(model.size)
} }
}, [model.status, model.size]) }, [model.status, model.size])

View File

@ -16,7 +16,7 @@ import useDeleteModel from '@/hooks/useDeleteModel'
import { useGetEngines } from '@/hooks/useEngineManagement' import { useGetEngines } from '@/hooks/useEngineManagement'
import { toGibibytes } from '@/utils/converter' import { toGigabytes } from '@/utils/converter'
import { isLocalEngine } from '@/utils/modelEngine' import { isLocalEngine } from '@/utils/modelEngine'
@ -80,7 +80,7 @@ const MyModelList = ({ model }: Props) => {
<div className="flex gap-x-4"> <div className="flex gap-x-4">
<div className="md:min-w-[90px] md:max-w-[90px]"> <div className="md:min-w-[90px] md:max-w-[90px]">
<Badge theme="secondary" className="sm:mr-8"> <Badge theme="secondary" className="sm:mr-8">
{model.metadata?.size ? toGibibytes(model.metadata?.size) : '-'} {model.metadata?.size ? toGigabytes(model.metadata?.size) : '-'}
</Badge> </Badge>
</div> </div>

View File

@ -26,7 +26,7 @@ import { modelDownloadStateAtom } from '@/hooks/useDownloadState'
import { useGetEngines } from '@/hooks/useEngineManagement' import { useGetEngines } from '@/hooks/useEngineManagement'
import { formatDownloadPercentage, toGibibytes } from '@/utils/converter' import { formatDownloadPercentage, toGigabytes } from '@/utils/converter'
import { manualRecommendationModel } from '@/utils/model' import { manualRecommendationModel } from '@/utils/model'
import { import {
getLogoEngine, getLogoEngine,
@ -112,7 +112,10 @@ const OnDeviceStarterScreen = ({ isShowStarterScreen }: Props) => {
return ( return (
<CenterPanelContainer isShowStarterScreen={isShowStarterScreen}> <CenterPanelContainer isShowStarterScreen={isShowStarterScreen}>
<ScrollArea className="flex h-full w-full items-center"> <ScrollArea
className="flex h-full w-full items-center"
data-testid="onboard-screen"
>
<div className="relative mt-4 flex h-full w-full flex-col items-center justify-center"> <div className="relative mt-4 flex h-full w-full flex-col items-center justify-center">
<div className="mx-auto flex h-full w-3/4 flex-col items-center justify-center py-16 text-center"> <div className="mx-auto flex h-full w-3/4 flex-col items-center justify-center py-16 text-center">
<LogoMark <LogoMark
@ -162,11 +165,11 @@ const OnDeviceStarterScreen = ({ isShowStarterScreen }: Props) => {
> >
{model.name} {model.name}
</p> </p>
<ModelLabel metadata={model.metadata} compact /> <ModelLabel size={model.metadata?.size} compact />
</div> </div>
<div className="flex items-center gap-2 text-[hsla(var(--text-tertiary))]"> <div className="flex items-center gap-2 text-[hsla(var(--text-tertiary))]">
<span className="font-medium"> <span className="font-medium">
{toGibibytes(model.metadata?.size)} {toGigabytes(model.metadata?.size)}
</span> </span>
{!isDownloading ? ( {!isDownloading ? (
<DownloadCloudIcon <DownloadCloudIcon
@ -259,7 +262,7 @@ const OnDeviceStarterScreen = ({ isShowStarterScreen }: Props) => {
</div> </div>
))} ))}
<span className="text-[hsla(var(--text-secondary))]"> <span className="text-[hsla(var(--text-secondary))]">
{toGibibytes(featModel.metadata?.size)} {toGigabytes(featModel.metadata?.size)}
</span> </span>
</div> </div>
) : ( ) : (
@ -278,7 +281,7 @@ const OnDeviceStarterScreen = ({ isShowStarterScreen }: Props) => {
Download Download
</Button> </Button>
<span className="text-[hsla(var(--text-secondary))]"> <span className="text-[hsla(var(--text-secondary))]">
{toGibibytes(featModel.metadata?.size)} {toGigabytes(featModel.metadata?.size)}
</span> </span>
</div> </div>
)} )}

View File

@ -6,7 +6,7 @@ import { XIcon } from 'lucide-react'
import { currentPromptAtom, fileUploadAtom } from '@/containers/Providers/Jotai' import { currentPromptAtom, fileUploadAtom } from '@/containers/Providers/Jotai'
import { toGibibytes } from '@/utils/converter' import { toGigabytes } from '@/utils/converter'
import Icon from './Icon' import Icon from './Icon'
@ -30,7 +30,7 @@ const FileUploadPreview = () => {
{fileUpload?.file.name.replaceAll(/[-._]/g, ' ')} {fileUpload?.file.name.replaceAll(/[-._]/g, ' ')}
</h6> </h6>
<p className="text-[hsla(var(--text-secondary)]"> <p className="text-[hsla(var(--text-secondary)]">
{toGibibytes(fileUpload?.file.size)} {toGigabytes(fileUpload?.file.size)}
</p> </p>
</div> </div>

View File

@ -2,7 +2,7 @@ import { memo } from 'react'
import { usePath } from '@/hooks/usePath' import { usePath } from '@/hooks/usePath'
import { toGibibytes } from '@/utils/converter' import { toGigabytes } from '@/utils/converter'
import Icon from '../FileUploadPreview/Icon' import Icon from '../FileUploadPreview/Icon'
@ -31,7 +31,7 @@ const DocMessage = ({
</h6> </h6>
<p className="text-[hsla(var(--text-secondary)] line-clamp-1 overflow-hidden truncate"> <p className="text-[hsla(var(--text-secondary)] line-clamp-1 overflow-hidden truncate">
{metadata && 'size' in metadata {metadata && 'size' in metadata
? toGibibytes(Number(metadata.size)) ? toGigabytes(Number(metadata.size))
: id} : id}
</p> </p>
</div> </div>

View File

@ -5,10 +5,12 @@ import React, { memo } from 'react'
import Markdown from 'react-markdown' import Markdown from 'react-markdown'
import { PluggableList } from 'react-markdown/lib'
import rehypeHighlight from 'rehype-highlight' import rehypeHighlight from 'rehype-highlight'
import rehypeHighlightCodeLines from 'rehype-highlight-code-lines' import rehypeHighlightCodeLines from 'rehype-highlight-code-lines'
import rehypeKatex from 'rehype-katex' import rehypeKatex from 'rehype-katex'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math' import remarkMath from 'remark-math'
@ -18,8 +20,15 @@ import { useClipboard } from '@/hooks/useClipboard'
import { getLanguageFromExtension } from '@/utils/codeLanguageExtension' import { getLanguageFromExtension } from '@/utils/codeLanguageExtension'
interface Props {
text: string
isUser?: boolean
className?: string
renderKatex?: boolean
}
export const MarkdownTextMessage = memo( export const MarkdownTextMessage = memo(
({ text, isUser }: { id: string; text: string; isUser: boolean }) => { ({ text, isUser, className, renderKatex = true }: Props) => {
const clipboard = useClipboard({ timeout: 1000 }) const clipboard = useClipboard({ timeout: 1000 })
// Escapes headings // Escapes headings
@ -202,13 +211,16 @@ export const MarkdownTextMessage = memo(
return ( return (
<> <>
<Markdown <Markdown
remarkPlugins={[remarkMath]} className={className}
rehypePlugins={[ remarkPlugins={[remarkMath, remarkGfm]}
[rehypeKatex, { throwOnError: false }], rehypePlugins={
[
rehypeHighlight, rehypeHighlight,
renderKatex ? [rehypeKatex, { throwOnError: false }] : undefined,
[rehypeHighlightCodeLines, { showLineNumbers: true }], [rehypeHighlightCodeLines, { showLineNumbers: true }],
wrapCodeBlocksWithoutVisit, wrapCodeBlocksWithoutVisit,
]} ].filter((e) => !!e) as PluggableList
}
> >
{preprocessMarkdown(text)} {preprocessMarkdown(text)}
</Markdown> </Markdown>

View File

@ -144,11 +144,7 @@ const MessageContainer: React.FC<
)} )}
dir="ltr" dir="ltr"
> >
<MarkdownTextMessage <MarkdownTextMessage text={text} isUser={isUser} />
id={props.id}
text={text}
isUser={isUser}
/>
</div> </div>
)} )}
</> </>

View File

@ -0,0 +1,91 @@
/* Headings */
.markdown-content h1,
.markdown-content h2,
.markdown-content h3,
.markdown-content h4,
.markdown-content h5,
.markdown-content h6 {
margin-top: 1rem;
margin-bottom: 0.5rem;
font-weight: medium;
line-height: 1.2;
color: black;
}
.markdown-content h1 {
font-size: 1.25rem;
padding-bottom: 0.3rem;
}
.markdown-content h2 {
font-size: 1rem;
padding-bottom: 0.2rem;
}
.markdown-content h3 {
font-size: 0.875rem;
}
.markdown-content h4 {
font-size: 0.7rem;
}
.markdown-content h5 {
font-size: 0.6rem;
}
.markdown-content h6 {
font-size: 0.5rem;
color: #555;
}
.markdown-content pre code {
background: none;
padding: 0;
color: inherit;
}
.markdown-content pre {
background: #272822;
color: #f8f8f2;
padding: 1rem;
border-radius: 5px;
overflow-x: auto;
font-size: 0.9rem;
margin: 1rem 0;
}
/* Tables */
.markdown-content table {
width: 100%;
font-size: 14px;
border-collapse: separate;
border-spacing: 0px;
border: solid hsla(var(--app-border)) 1px;
border-radius: 8px;
overflow: hidden;
margin-top: 16px;
margin-bottom: 16px;
}
.markdown-content th,
.markdown-content td {
padding-left: 24px;
padding-top: 12px;
padding-bottom: 12px;
text-align: left;
border: 0;
}
.markdown-content th {
color: hsla(var(--text-secondary));
background-color: #f4f4f4;
}
.markdown-content td {
border-top: solid hsla(var(--app-border)) 1px;
}
.markdown-content hr {
margin: 16px 0px;
}

View File

@ -0,0 +1,3 @@
.md-short-desc hr {
visibility: hidden;
}

View File

@ -1,4 +1,4 @@
export const toGibibytes = ( export const toGigabytes = (
input: number, input: number,
options?: { hideUnit?: boolean } options?: { hideUnit?: boolean }
) => { ) => {
@ -24,7 +24,7 @@ export const formatDownloadPercentage = (
export const formatDownloadSpeed = (input: number | undefined) => { export const formatDownloadSpeed = (input: number | undefined) => {
if (!input) return '0B/s' if (!input) return '0B/s'
return toGibibytes(input) + '/s' return toGigabytes(input) + '/s'
} }
export const formatTwoDigits = (input: number) => { export const formatTwoDigits = (input: number) => {

View File

@ -64,6 +64,29 @@ export const getTitleByEngine = (engine: InferenceEngine) => {
} }
} }
export const getDescriptionByEngine = (engine: InferenceEngine) => {
switch (engine) {
case InferenceEngine.anthropic:
return 'Creator of Claude AI models renowned for exceptional reasoning, analysis, and coding capabilities. Claude models combine high intelligence with strong safety and ethics, making them ideal for complex tasks from research to technical writing.'
case InferenceEngine.cohere:
return 'Enterprise-focused language models designed for business needs. Specializing in reliable content generation, summarization, and semantic analysis with consistent quality and scalable performance.'
case InferenceEngine.groq:
return 'An innovative AI infrastructure provider revolutionizing model speed. Their custom-built hardware platform delivers ultra-fast inference while maintaining high-quality outputs.'
case InferenceEngine.martian:
return 'Production-first language models balancing performance with practicality. Built for reliable deployment and consistent results across diverse real-world applications.'
case InferenceEngine.mistral:
return 'A breakthrough in open-source language models. Their instruction-tuned models like Mixtral and Mistral combine competitive performance with efficient resource usage across reasoning, coding, and general tasks.'
case InferenceEngine.nvidia:
return 'Industry leaders in AI hardware and software solutions. Their models leverage deep GPU expertise to deliver high-performance AI capabilities optimized for enterprise use.'
case InferenceEngine.openai:
return 'Creator of GPT models that set industry benchmarks. Their models excel across text, code, and image generation, consistently setting new standards for AI capabilities.'
case InferenceEngine.openrouter:
return 'A unified platform aggregating top AI models from various providers. Simplifies AI deployment by offering seamless access to multiple services through standardized integration.'
default:
return `Access models from ${engine} via their API.`
}
}
export const priorityEngine = [ export const priorityEngine = [
InferenceEngine.cortex_llamacpp, InferenceEngine.cortex_llamacpp,
InferenceEngine.cortex_onnx, InferenceEngine.cortex_onnx,

31
web/utils/modelSource.ts Normal file
View File

@ -0,0 +1,31 @@
/**
* This utility is to extract cortexso model description from README.md file
* @returns
*/
export const extractDescription = (text?: string) => {
if (!text) return text
const overviewPattern = /(?:##\s*Overview\s*\n)([\s\S]*?)(?=\n\s*##|$)/
const matches = text?.match(overviewPattern)
if (matches && matches[1]) {
return matches[1].trim()
}
return text?.slice(0, 500).trim()
}
/**
* Extract model name from repo path, e.g. cortexso/tinyllama -> tinyllama
* @param modelId
* @returns
*/
export const extractModelName = (model?: string) => {
return model?.split('/')[1] ?? model
}
/**
* Extract model name from repo path, e.g. https://huggingface.co/cortexso/tinyllama -> cortexso/tinyllama
* @param modelId
* @returns
*/
export const extractModelRepo = (model?: string) => {
return model?.replace('https://huggingface.co/', '')
}

23
web/utils/search.ts Normal file
View File

@ -0,0 +1,23 @@
/**
* Searches for a query in a list of data using a fuzzy search algorithm.
*/
export function fuzzySearch(needle: string, haystack: string) {
const hlen = haystack.length
const nlen = needle.length
if (nlen > hlen) {
return false
}
if (nlen === hlen) {
return needle === haystack
}
outer: for (let i = 0, j = 0; i < nlen; i++) {
const nch = needle.charCodeAt(i)
while (j < hlen) {
if (haystack.charCodeAt(j++) === nch) {
continue outer
}
}
return false
}
return true
}

View File

@ -1,9 +0,0 @@
import { generateThreadId } from './thread';
test('shouldGenerateThreadIdWithCorrectFormat', () => {
const assistantId = 'assistant123';
const threadId = generateThreadId(assistantId);
const regex = /^assistant123_\d{10}$/;
expect(threadId).toMatch(regex);
});

View File

@ -1,3 +0,0 @@
export const generateThreadId = (assistantId: string) => {
return `${assistantId}_${(Date.now() / 1000).toFixed(0)}`
}