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
@ -1,10 +1,13 @@
|
||||
import { BaseExtension, ExtensionTypeEnum } from '../extension'
|
||||
import { Model, ModelInterface, OptionType } from '../../types'
|
||||
import { Model, ModelInterface, ModelSource, OptionType } from '../../types'
|
||||
|
||||
/**
|
||||
* Model extension for managing models.
|
||||
*/
|
||||
export abstract class ModelExtension extends BaseExtension implements ModelInterface {
|
||||
export abstract class ModelExtension
|
||||
extends BaseExtension
|
||||
implements ModelInterface
|
||||
{
|
||||
/**
|
||||
* Model extension type.
|
||||
*/
|
||||
@ -25,4 +28,16 @@ export abstract class ModelExtension extends BaseExtension implements ModelInter
|
||||
abstract updateModel(modelInfo: Partial<Model>): Promise<Model>
|
||||
abstract deleteModel(model: string): Promise<void>
|
||||
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>
|
||||
}
|
||||
|
||||
@ -2,3 +2,4 @@ export * from './modelEntity'
|
||||
export * from './modelInterface'
|
||||
export * from './modelEvent'
|
||||
export * from './modelImport'
|
||||
export * from './modelSource'
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Model } from './modelEntity'
|
||||
import { OptionType } from './modelImport'
|
||||
import { ModelSource } from './modelSource'
|
||||
|
||||
/**
|
||||
* Model extension for managing models.
|
||||
@ -50,4 +51,17 @@ export interface ModelInterface {
|
||||
name?: string,
|
||||
optionType?: OptionType
|
||||
): 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>
|
||||
}
|
||||
|
||||
67
core/src/types/model/modelSource.ts
Normal 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
|
||||
}
|
||||
@ -17,7 +17,7 @@ test('explores hub', async ({ hubPage }) => {
|
||||
await hubPage.navigateByMenu()
|
||||
await hubPage.verifyContainerVisible()
|
||||
await hubPage.scrollToBottom()
|
||||
const useModelBtn = page.getByTestId(/^use-model-btn-.*/).first()
|
||||
const useModelBtn = page.getByTestId(/^setup-btn/).first()
|
||||
|
||||
await expect(useModelBtn).toBeVisible({
|
||||
timeout: TIMEOUT,
|
||||
|
||||
@ -1,33 +1,18 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { page, test, TIMEOUT } from '../config/fixtures'
|
||||
|
||||
test('Select GPT model from Hub and Chat with Invalid API Key', async ({
|
||||
hubPage,
|
||||
}) => {
|
||||
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')
|
||||
|
||||
test('show onboarding screen without any threads created or models downloaded', async () => {
|
||||
await page.getByTestId('Thread').first().click({
|
||||
timeout: TIMEOUT,
|
||||
})
|
||||
const denyButton = page.locator('[data-testid="btn-deny-product-analytics"]')
|
||||
|
||||
if ((await denyButton.count()) > 0) {
|
||||
await denyButton.click({ force: true })
|
||||
} else {
|
||||
await page.getByTestId('btn-send-chat').click({ force: true })
|
||||
}
|
||||
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const loaders = document.querySelectorAll('[data-testid$="loader"]')
|
||||
return !loaders.length
|
||||
},
|
||||
{ timeout: TIMEOUT }
|
||||
)
|
||||
const onboardScreen = page.getByTestId('onboard-screen')
|
||||
await expect(onboardScreen).toBeVisible({
|
||||
timeout: TIMEOUT,
|
||||
})
|
||||
})
|
||||
|
||||
@ -1 +1 @@
|
||||
1.0.9-rc4
|
||||
1.0.9-rc5
|
||||
|
||||
1434
extensions/model-extension/resources/default.json
Normal file
@ -1,5 +1,6 @@
|
||||
import { defineConfig } from 'rolldown'
|
||||
import settingJson from './resources/settings.json' with { type: 'json' }
|
||||
import modelSources from './resources/default.json' with { type: 'json' }
|
||||
|
||||
export default defineConfig({
|
||||
input: 'src/index.ts',
|
||||
@ -12,5 +13,6 @@ export default defineConfig({
|
||||
SETTINGS: JSON.stringify(settingJson),
|
||||
API_URL: JSON.stringify('http://127.0.0.1:39291'),
|
||||
SOCKET_URL: JSON.stringify('ws://127.0.0.1:39291'),
|
||||
DEFAULT_MODEL_SOURCES: JSON.stringify(modelSources),
|
||||
},
|
||||
})
|
||||
|
||||
@ -2,6 +2,7 @@ declare const NODE: string
|
||||
declare const API_URL: string
|
||||
declare const SOCKET_URL: string
|
||||
declare const SETTINGS: SettingComponentProps[]
|
||||
declare const DEFAULT_MODEL_SOURCES: any
|
||||
|
||||
interface Core {
|
||||
api: APIFunctions
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import PQueue from 'p-queue'
|
||||
import ky from 'ky'
|
||||
import { extractModelLoadParams, Model } from '@janhq/core'
|
||||
import { extractModelLoadParams, Model, ModelSource } from '@janhq/core'
|
||||
import { extractInferenceParams } from '@janhq/core'
|
||||
/**
|
||||
* cortex.cpp Model APIs interface
|
||||
@ -19,9 +19,12 @@ interface ICortexAPI {
|
||||
updateModel(model: object): Promise<void>
|
||||
cancelModelPull(model: string): 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[]
|
||||
}
|
||||
|
||||
@ -53,7 +56,7 @@ export class CortexAPI implements ICortexAPI {
|
||||
*/
|
||||
getModels(): Promise<Model[]> {
|
||||
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) =>
|
||||
typeof e === 'object' ? e.data.map((e) => this.transformModel(e)) : []
|
||||
)
|
||||
@ -148,6 +151,47 @@ export class CortexAPI implements ICortexAPI {
|
||||
.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
|
||||
* @returns
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
events,
|
||||
DownloadEvent,
|
||||
OptionType,
|
||||
ModelSource,
|
||||
} from '@janhq/core'
|
||||
import { CortexAPI } from './cortex'
|
||||
import { scanModelsFolder } from './legacy/model-json'
|
||||
@ -243,6 +244,35 @@ export default class JanModelExtension extends ModelExtension {
|
||||
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
|
||||
* @param model
|
||||
|
||||
@ -30,6 +30,7 @@
|
||||
"dependencies": {
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@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-scroll-area": "^1.0.5",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
|
||||
45
joi/src/core/Dropdown/index.tsx
Normal 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 }
|
||||
154
joi/src/core/Dropdown/styles.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -1,11 +1,7 @@
|
||||
import React, { ReactNode } from 'react'
|
||||
import React from 'react'
|
||||
|
||||
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
} from '@radix-ui/react-icons'
|
||||
import { CheckIcon, ChevronDownIcon } from '@radix-ui/react-icons'
|
||||
|
||||
import './styles.scss'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
@ -15,6 +15,7 @@ jest.mock('./core/Select/styles.scss', () => ({}))
|
||||
jest.mock('./core/TextArea/styles.scss', () => ({}))
|
||||
jest.mock('./core/Tabs/styles.scss', () => ({}))
|
||||
jest.mock('./core/Accordion/styles.scss', () => ({}))
|
||||
jest.mock('./core/Dropdown/styles.scss', () => ({}))
|
||||
|
||||
describe('Exports', () => {
|
||||
it('exports all components and hooks', () => {
|
||||
|
||||
@ -12,6 +12,7 @@ export * from './core/Select'
|
||||
export * from './core/TextArea'
|
||||
export * from './core/Tabs'
|
||||
export * from './core/Accordion'
|
||||
export * from './core/Dropdown'
|
||||
|
||||
export * from './hooks/useClipboard'
|
||||
export * from './hooks/usePageLeave'
|
||||
|
||||
@ -6,7 +6,7 @@ import { useActiveModel } from '@/hooks/useActiveModel'
|
||||
|
||||
import { useGetEngines } from '@/hooks/useEngineManagement'
|
||||
|
||||
import { toGibibytes } from '@/utils/converter'
|
||||
import { toGigabytes } from '@/utils/converter'
|
||||
|
||||
import { isLocalEngine } from '@/utils/modelEngine'
|
||||
|
||||
@ -34,7 +34,7 @@ const TableActiveModel = () => {
|
||||
<td className="px-4 py-2">
|
||||
<Badge theme="secondary">
|
||||
{activeModel.metadata?.size
|
||||
? toGibibytes(activeModel.metadata?.size)
|
||||
? toGigabytes(activeModel.metadata?.size)
|
||||
: '-'}
|
||||
</Badge>
|
||||
</td>
|
||||
|
||||
@ -16,7 +16,7 @@ import useGetSystemResources from '@/hooks/useGetSystemResources'
|
||||
|
||||
import { usePath } from '@/hooks/usePath'
|
||||
|
||||
import { toGibibytes } from '@/utils/converter'
|
||||
import { toGigabytes } from '@/utils/converter'
|
||||
|
||||
import { utilizedMemory } from '@/utils/memory'
|
||||
|
||||
@ -134,8 +134,8 @@ const SystemMonitor = () => {
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h6 className="font-bold">Memory</h6>
|
||||
<span>
|
||||
{toGibibytes(usedRam, { hideUnit: true })}/
|
||||
{toGibibytes(totalRam, { hideUnit: true })} GB
|
||||
{toGigabytes(usedRam, { hideUnit: true })}/
|
||||
{toGigabytes(totalRam, { hideUnit: true })} GB
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-4">
|
||||
|
||||
@ -21,7 +21,6 @@ import { SUCCESS_SET_NEW_DESTINATION } from '@/screens/Settings/Advanced/DataFol
|
||||
import CancelModelImportModal from '@/screens/Settings/CancelModelImportModal'
|
||||
import ChooseWhatToImportModal from '@/screens/Settings/ChooseWhatToImportModal'
|
||||
import EditModelInfoModal from '@/screens/Settings/EditModelInfoModal'
|
||||
import HuggingFaceRepoDetailModal from '@/screens/Settings/HuggingFaceRepoDetailModal'
|
||||
import ImportModelOptionModal from '@/screens/Settings/ImportModelOptionModal'
|
||||
import ImportingModelModal from '@/screens/Settings/ImportingModelModal'
|
||||
import SelectingModelModal from '@/screens/Settings/SelectingModelModal'
|
||||
@ -148,7 +147,6 @@ const BaseLayout = () => {
|
||||
{importModelStage === 'CONFIRM_CANCEL' && <CancelModelImportModal />}
|
||||
<ChooseWhatToImportModal />
|
||||
<InstallingExtensionModal />
|
||||
<HuggingFaceRepoDetailModal />
|
||||
{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="mb-4 flex items-center gap-x-2">
|
||||
|
||||
53
web/containers/Loader/Spinner.tsx
Normal 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
|
||||
@ -1,7 +1,5 @@
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { Model } from '@janhq/core'
|
||||
|
||||
import { Modal, Button, Progress, ModalClose } from '@janhq/joi'
|
||||
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
@ -16,22 +14,22 @@ import {
|
||||
import { formatDownloadPercentage } from '@/utils/converter'
|
||||
|
||||
type Props = {
|
||||
model: Model
|
||||
modelId: string
|
||||
isFromList?: boolean
|
||||
}
|
||||
|
||||
const ModalCancelDownload = ({ model, isFromList }: Props) => {
|
||||
const ModalCancelDownload = ({ modelId, isFromList }: Props) => {
|
||||
const { abortModelDownload } = useDownloadModel()
|
||||
const removeDownloadState = useSetAtom(removeDownloadStateAtom)
|
||||
const allDownloadStates = useAtomValue(modelDownloadStateAtom)
|
||||
const downloadState = allDownloadStates[model.id]
|
||||
const downloadState = allDownloadStates[modelId]
|
||||
|
||||
const cancelText = `Cancel ${formatDownloadPercentage(downloadState?.percent ?? 0)}`
|
||||
|
||||
const onAbortDownloadClick = useCallback(() => {
|
||||
removeDownloadState(model.id)
|
||||
abortModelDownload(downloadState?.modelId ?? model.id)
|
||||
}, [downloadState, abortModelDownload, removeDownloadState, model])
|
||||
removeDownloadState(modelId)
|
||||
abortModelDownload(downloadState?.modelId ?? modelId)
|
||||
}, [downloadState, abortModelDownload, removeDownloadState, modelId])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@ -42,7 +40,11 @@ const ModalCancelDownload = ({ model, isFromList }: Props) => {
|
||||
{cancelText}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="soft">
|
||||
<Button
|
||||
className="text-[hsla(var(--primary-bg))]"
|
||||
variant="soft"
|
||||
theme="ghost"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="inline-block">Cancel</span>
|
||||
<Progress
|
||||
|
||||
99
web/containers/ModelDownloadButton/index.tsx
Normal 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
|
||||
@ -37,7 +37,7 @@ import useRecommendedModel from '@/hooks/useRecommendedModel'
|
||||
|
||||
import useUpdateModelParameters from '@/hooks/useUpdateModelParameters'
|
||||
|
||||
import { formatDownloadPercentage, toGibibytes } from '@/utils/converter'
|
||||
import { formatDownloadPercentage, toGigabytes } from '@/utils/converter'
|
||||
|
||||
import { manualRecommendationModel } from '@/utils/model'
|
||||
import { getLogoEngine } from '@/utils/modelEngine'
|
||||
@ -481,13 +481,13 @@ const ModelDropdown = ({
|
||||
{model.name}
|
||||
</p>
|
||||
<ModelLabel
|
||||
metadata={model.metadata}
|
||||
size={model.metadata?.size}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[hsla(var(--text-tertiary))]">
|
||||
<span className="font-medium">
|
||||
{toGibibytes(model.metadata?.size)}
|
||||
{toGigabytes(model.metadata?.size)}
|
||||
</span>
|
||||
{!isDownloading ? (
|
||||
<DownloadCloudIcon
|
||||
@ -577,14 +577,14 @@ const ModelDropdown = ({
|
||||
{model.name}
|
||||
</p>
|
||||
<ModelLabel
|
||||
metadata={model.metadata}
|
||||
size={model.metadata?.size}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[hsla(var(--text-tertiary))]">
|
||||
{!isDownloaded && (
|
||||
<span className="font-medium">
|
||||
{toGibibytes(model.metadata?.size)}
|
||||
{toGigabytes(model.metadata?.size)}
|
||||
</span>
|
||||
)}
|
||||
{!isDownloading && !isDownloaded ? (
|
||||
|
||||
@ -36,46 +36,6 @@ describe('ModelLabel', () => {
|
||||
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', () => {
|
||||
mockUseAtomValue
|
||||
.mockReturnValueOnce(100)
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
import React from 'react'
|
||||
|
||||
import { ModelMetadata } from '@janhq/core'
|
||||
import { Badge } from '@janhq/joi'
|
||||
import { useAtomValue } from 'jotai'
|
||||
|
||||
import { useActiveModel } from '@/hooks/useActiveModel'
|
||||
@ -19,18 +17,11 @@ import {
|
||||
} from '@/helpers/atoms/SystemBar.atom'
|
||||
|
||||
type Props = {
|
||||
metadata: ModelMetadata
|
||||
size?: number
|
||||
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 totalRam = useAtomValue(totalRamAtom)
|
||||
const usedRam = useAtomValue(usedRamAtom)
|
||||
@ -59,11 +50,7 @@ const ModelLabel = ({ metadata, compact }: Props) => {
|
||||
return null
|
||||
}
|
||||
|
||||
return metadata?.tags?.includes('Coming Soon') ? (
|
||||
<UnsupportedModel />
|
||||
) : (
|
||||
getLabel(metadata?.size ?? 0)
|
||||
)
|
||||
return getLabel(size ?? 0)
|
||||
}
|
||||
|
||||
export default React.memo(ModelLabel)
|
||||
|
||||
@ -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 { useSetAtom } from 'jotai'
|
||||
import { SearchIcon } from 'lucide-react'
|
||||
|
||||
import { useDebouncedCallback } from 'use-debounce'
|
||||
|
||||
import { toaster } from '@/containers/Toast'
|
||||
|
||||
import { useGetHFRepoData } from '@/hooks/useGetHFRepoData'
|
||||
|
||||
import {
|
||||
importHuggingFaceModelStageAtom,
|
||||
importingHuggingFaceRepoDataAtom,
|
||||
} from '@/helpers/atoms/HuggingFace.atom'
|
||||
useGetModelSources,
|
||||
useModelSourcesMutation,
|
||||
} from '@/hooks/useModelSource'
|
||||
|
||||
import Spinner from '../Loader/Spinner'
|
||||
|
||||
type Props = {
|
||||
onSearchLocal?: (searchText: string) => void
|
||||
@ -20,37 +18,28 @@ type Props = {
|
||||
|
||||
const ModelSearch = ({ onSearchLocal }: Props) => {
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const { getHfRepoData } = useGetHFRepoData()
|
||||
|
||||
const setImportingHuggingFaceRepoData = useSetAtom(
|
||||
importingHuggingFaceRepoDataAtom
|
||||
)
|
||||
const setImportHuggingFaceModelStage = useSetAtom(
|
||||
importHuggingFaceModelStageAtom
|
||||
)
|
||||
|
||||
const [isSearching, setSearching] = useState(false)
|
||||
const { mutate } = useGetModelSources()
|
||||
const { addModelSource } = useModelSourcesMutation()
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
const debounced = useDebouncedCallback(async () => {
|
||||
if (searchText.indexOf('/') === -1) {
|
||||
// If we don't find / in the text, perform a local search
|
||||
onSearchLocal?.(searchText)
|
||||
return
|
||||
}
|
||||
// Attempt to search local
|
||||
onSearchLocal?.(searchText)
|
||||
|
||||
try {
|
||||
const data = await getHfRepoData(searchText)
|
||||
setImportingHuggingFaceRepoData(data)
|
||||
setImportHuggingFaceModelStage('REPO_DETAIL')
|
||||
} catch (err) {
|
||||
let errMessage = 'Unexpected Error'
|
||||
if (err instanceof Error) {
|
||||
errMessage = err.message
|
||||
}
|
||||
toaster({
|
||||
title: errMessage,
|
||||
type: 'error',
|
||||
setSearching(true)
|
||||
// Attempt to search model source
|
||||
addModelSource(searchText)
|
||||
.then(() => mutate())
|
||||
.then(() => onSearchLocal?.(searchText))
|
||||
.catch((e) => {
|
||||
console.debug(e)
|
||||
})
|
||||
console.error(err)
|
||||
}
|
||||
.finally(() => setSearching(false))
|
||||
}, 300)
|
||||
|
||||
const onSearchChanged = useCallback(
|
||||
@ -80,13 +69,24 @@ const ModelSearch = ({ onSearchLocal }: Props) => {
|
||||
|
||||
return (
|
||||
<Input
|
||||
prefixIcon={<SearchIcon size={16} />}
|
||||
placeholder="Search or paste Hugging Face URL"
|
||||
ref={inputRef}
|
||||
prefixIcon={
|
||||
isSearching ? (
|
||||
<Spinner size={16} strokeWidth={2} />
|
||||
) : (
|
||||
<SearchIcon size={16} />
|
||||
)
|
||||
}
|
||||
placeholder="Search or enter Hugging Face URL"
|
||||
onChange={onSearchChanged}
|
||||
onKeyDown={onKeyDown}
|
||||
value={searchText}
|
||||
clearable={searchText.length > 0}
|
||||
onClear={onClear}
|
||||
className="border-0 bg-[hsla(var(--app-bg))]"
|
||||
onClick={() => {
|
||||
onSearchLocal?.(inputRef.current?.value ?? '')
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -4,25 +4,21 @@ import { useSetAtom } from 'jotai'
|
||||
|
||||
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 { toaster } from '../Toast'
|
||||
|
||||
import {
|
||||
importHuggingFaceModelStageAtom,
|
||||
importingHuggingFaceRepoDataAtom,
|
||||
} from '@/helpers/atoms/HuggingFace.atom'
|
||||
import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
|
||||
import { modelDetailAtom } from '@/helpers/atoms/Model.atom'
|
||||
|
||||
const DeepLinkListener: React.FC = () => {
|
||||
const { getHfRepoData } = useGetHFRepoData()
|
||||
const { addModelSource } = useModelSourcesMutation()
|
||||
const setLoadingInfo = useSetAtom(loadingModalInfoAtom)
|
||||
const setImportingHuggingFaceRepoData = useSetAtom(
|
||||
importingHuggingFaceRepoDataAtom
|
||||
)
|
||||
const setImportHuggingFaceModelStage = useSetAtom(
|
||||
importHuggingFaceModelStageAtom
|
||||
)
|
||||
const setMainView = useSetAtom(mainViewStateAtom)
|
||||
const setModelDetail = useSetAtom(modelDetailAtom)
|
||||
|
||||
const handleDeepLinkAction = useDebouncedCallback(
|
||||
async (deepLinkAction: DeepLinkAction) => {
|
||||
@ -38,17 +34,17 @@ const DeepLinkListener: React.FC = () => {
|
||||
|
||||
try {
|
||||
setLoadingInfo({
|
||||
title: 'Getting Hugging Face models',
|
||||
title: 'Getting Hugging Face model details',
|
||||
message: 'Please wait..',
|
||||
})
|
||||
const data = await getHfRepoData(deepLinkAction.resource)
|
||||
setImportingHuggingFaceRepoData(data)
|
||||
setImportHuggingFaceModelStage('REPO_DETAIL')
|
||||
await addModelSource(deepLinkAction.resource)
|
||||
setLoadingInfo(undefined)
|
||||
setMainView(MainViewState.Hub)
|
||||
setModelDetail(deepLinkAction.resource)
|
||||
} catch (err) {
|
||||
setLoadingInfo(undefined)
|
||||
toaster({
|
||||
title: 'Failed to get Hugging Face models',
|
||||
title: 'Failed to get Hugging Face model details',
|
||||
description: err instanceof Error ? err.message : 'Unexpected Error',
|
||||
type: 'error',
|
||||
})
|
||||
|
||||
@ -30,3 +30,15 @@ export const copyOverInstructionEnabledAtom = atomWithStorage(
|
||||
COPY_OVER_INSTRUCTION_ENABLED,
|
||||
false
|
||||
)
|
||||
|
||||
/**
|
||||
* App Hub Banner configured image
|
||||
*/
|
||||
export const appBannerHubAtom = atomWithStorage<string>(
|
||||
'appBannerHub',
|
||||
'./images/HubBanner/banner-8.jpg',
|
||||
undefined,
|
||||
{
|
||||
getOnInit: true,
|
||||
}
|
||||
)
|
||||
|
||||
@ -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();
|
||||
});
|
||||
@ -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')
|
||||
@ -60,6 +60,11 @@ export const showEngineListModelAtom = atom<string[]>([
|
||||
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
|
||||
/// Model Download Atom
|
||||
|
||||
|
||||
@ -10,12 +10,17 @@ import {
|
||||
EngineEvent,
|
||||
Model,
|
||||
ModelEvent,
|
||||
ModelSource,
|
||||
ModelSibling,
|
||||
} from '@janhq/core'
|
||||
import { useAtom } from 'jotai'
|
||||
import { useAtom, useAtomValue } from 'jotai'
|
||||
import { atomWithStorage } from 'jotai/utils'
|
||||
import useSWR from 'swr'
|
||||
|
||||
import { getDescriptionByEngine, getTitleByEngine } from '@/utils/modelEngine'
|
||||
|
||||
import { extensionManager } from '@/extension/ExtensionManager'
|
||||
import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom'
|
||||
|
||||
export const releasedEnginesCacheAtom = atomWithStorage<{
|
||||
data: EngineReleased[]
|
||||
@ -415,3 +420,39 @@ export const addRemoteEngineModel = async (name: string, engine: string) => {
|
||||
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
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
72
web/hooks/useModelSource.ts
Normal 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 }
|
||||
}
|
||||
@ -32,12 +32,15 @@ const useModels = () => {
|
||||
|
||||
const getData = useCallback(() => {
|
||||
const getDownloadedModels = async () => {
|
||||
const localModels = (await getModels()).map((e) => ({
|
||||
...e,
|
||||
name: ModelManager.instance().models.get(e.id)?.name ?? e.name ?? e.id,
|
||||
metadata:
|
||||
ModelManager.instance().models.get(e.id)?.metadata ?? e.metadata,
|
||||
}))
|
||||
const localModels = (await getModels())
|
||||
.map((e) => ({
|
||||
...e,
|
||||
name:
|
||||
ModelManager.instance().models.get(e.id)?.name ?? e.name ?? e.id,
|
||||
metadata:
|
||||
ModelManager.instance().models.get(e.id)?.metadata ?? e.metadata,
|
||||
}))
|
||||
.filter((e) => !('status' in e) || e.status !== 'downloadable')
|
||||
|
||||
const remoteModels = ModelManager.instance()
|
||||
.models.values()
|
||||
|
||||
@ -40,5 +40,5 @@ const config = {
|
||||
// module.exports = createJestConfig(config)
|
||||
module.exports = async () => ({
|
||||
...(await createJestConfig(config)()),
|
||||
transformIgnorePatterns: ['/node_modules/(?!(layerr|nanoid|@uppy|preact)/)'],
|
||||
transformIgnorePatterns: ['/node_modules/(?!((.*))/)'],
|
||||
})
|
||||
|
||||
@ -48,6 +48,7 @@
|
||||
"rehype-highlight": "^7.0.1",
|
||||
"rehype-highlight-code-lines": "^1.0.4",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
"sass": "^1.69.4",
|
||||
"slate": "latest",
|
||||
|
||||
8
web/public/icons/huggingFace.svg
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
web/public/images/HubBanner/banner-1.jpg
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
web/public/images/HubBanner/banner-10.jpg
Normal file
|
After Width: | Height: | Size: 224 KiB |
BIN
web/public/images/HubBanner/banner-11.jpg
Normal file
|
After Width: | Height: | Size: 253 KiB |
BIN
web/public/images/HubBanner/banner-12.jpg
Normal file
|
After Width: | Height: | Size: 344 KiB |
BIN
web/public/images/HubBanner/banner-13.jpg
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
web/public/images/HubBanner/banner-14.jpg
Normal file
|
After Width: | Height: | Size: 217 KiB |
BIN
web/public/images/HubBanner/banner-15.jpg
Normal file
|
After Width: | Height: | Size: 200 KiB |
BIN
web/public/images/HubBanner/banner-16.jpg
Normal file
|
After Width: | Height: | Size: 226 KiB |
BIN
web/public/images/HubBanner/banner-17.jpg
Normal file
|
After Width: | Height: | Size: 228 KiB |
BIN
web/public/images/HubBanner/banner-18.jpg
Normal file
|
After Width: | Height: | Size: 339 KiB |
BIN
web/public/images/HubBanner/banner-19.jpg
Normal file
|
After Width: | Height: | Size: 418 KiB |
BIN
web/public/images/HubBanner/banner-2.jpg
Normal file
|
After Width: | Height: | Size: 161 KiB |
BIN
web/public/images/HubBanner/banner-20.jpg
Normal file
|
After Width: | Height: | Size: 248 KiB |
BIN
web/public/images/HubBanner/banner-21.jpg
Normal file
|
After Width: | Height: | Size: 227 KiB |
BIN
web/public/images/HubBanner/banner-22.jpg
Normal file
|
After Width: | Height: | Size: 288 KiB |
BIN
web/public/images/HubBanner/banner-23.jpg
Normal file
|
After Width: | Height: | Size: 310 KiB |
BIN
web/public/images/HubBanner/banner-24.jpg
Normal file
|
After Width: | Height: | Size: 294 KiB |
BIN
web/public/images/HubBanner/banner-25.jpg
Normal file
|
After Width: | Height: | Size: 228 KiB |
BIN
web/public/images/HubBanner/banner-26.jpg
Normal file
|
After Width: | Height: | Size: 381 KiB |
BIN
web/public/images/HubBanner/banner-27.jpg
Normal file
|
After Width: | Height: | Size: 193 KiB |
BIN
web/public/images/HubBanner/banner-28.jpg
Normal file
|
After Width: | Height: | Size: 161 KiB |
BIN
web/public/images/HubBanner/banner-29.jpg
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
web/public/images/HubBanner/banner-3.jpg
Normal file
|
After Width: | Height: | Size: 184 KiB |
BIN
web/public/images/HubBanner/banner-30.jpg
Normal file
|
After Width: | Height: | Size: 341 KiB |
BIN
web/public/images/HubBanner/banner-4.jpg
Normal file
|
After Width: | Height: | Size: 211 KiB |
BIN
web/public/images/HubBanner/banner-5.jpg
Normal file
|
After Width: | Height: | Size: 226 KiB |
BIN
web/public/images/HubBanner/banner-6.jpg
Normal file
|
After Width: | Height: | Size: 184 KiB |
BIN
web/public/images/HubBanner/banner-7.jpg
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
web/public/images/HubBanner/banner-8.jpg
Normal file
|
After Width: | Height: | Size: 217 KiB |
BIN
web/public/images/HubBanner/banner-9.jpg
Normal file
|
After Width: | Height: | Size: 203 KiB |
|
Before Width: | Height: | Size: 154 KiB |
@ -1,20 +1,14 @@
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { Model } from '@janhq/core'
|
||||
import { Button, Badge, Tooltip } from '@janhq/joi'
|
||||
import { ModelSource } from '@janhq/core'
|
||||
|
||||
import { Button, Tooltip, Dropdown, Badge } from '@janhq/joi'
|
||||
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
|
||||
import { ChevronDownIcon } from 'lucide-react'
|
||||
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
import ModalCancelDownload from '@/containers/ModalCancelDownload'
|
||||
|
||||
import ModelLabel from '@/containers/ModelLabel'
|
||||
|
||||
import { toaster } from '@/containers/Toast'
|
||||
|
||||
import { MainViewState } from '@/constants/screens'
|
||||
|
||||
import { useCreateNewThread } from '@/hooks/useCreateNewThread'
|
||||
@ -22,7 +16,9 @@ import useDownloadModel from '@/hooks/useDownloadModel'
|
||||
|
||||
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 { assistantsAtom } from '@/helpers/atoms/Assistant.atom'
|
||||
@ -32,25 +28,25 @@ import {
|
||||
downloadedModelsAtom,
|
||||
getDownloadingModelAtom,
|
||||
} from '@/helpers/atoms/Model.atom'
|
||||
import { selectedSettingAtom } from '@/helpers/atoms/Setting.atom'
|
||||
import {
|
||||
nvidiaTotalVramAtom,
|
||||
totalRamAtom,
|
||||
} from '@/helpers/atoms/SystemBar.atom'
|
||||
|
||||
type Props = {
|
||||
model: Model
|
||||
onClick: () => void
|
||||
open: string
|
||||
model: ModelSource
|
||||
onSelectedModel: () => void
|
||||
}
|
||||
|
||||
const ModelItemHeader = ({ model, onClick, open }: Props) => {
|
||||
const ModelItemHeader = ({ model, onSelectedModel }: Props) => {
|
||||
const { downloadModel } = useDownloadModel()
|
||||
const downloadingModels = useAtomValue(getDownloadingModelAtom)
|
||||
const downloadedModels = useAtomValue(downloadedModelsAtom)
|
||||
const setSelectedSetting = useSetAtom(selectedSettingAtom)
|
||||
const { requestCreateNewThread } = useCreateNewThread()
|
||||
const totalRam = useAtomValue(totalRamAtom)
|
||||
const { settings } = useSettings()
|
||||
// const [imageLoaded, setImageLoaded] = useState(true)
|
||||
|
||||
const nvidiaTotalVram = useAtomValue(nvidiaTotalVramAtom)
|
||||
const setMainViewState = useSetAtom(mainViewStateAtom)
|
||||
@ -64,36 +60,68 @@ const ModelItemHeader = ({ model, onClick, open }: Props) => {
|
||||
const assistants = useAtomValue(assistantsAtom)
|
||||
|
||||
const onDownloadClick = useCallback(() => {
|
||||
downloadModel(model.sources[0].url, model.id, model.name)
|
||||
downloadModel(model.models?.[0].id)
|
||||
}, [model, downloadModel])
|
||||
|
||||
const isDownloaded = downloadedModels.find((md) => md.id === model.id) != null
|
||||
|
||||
let downloadButton = (
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDownloadClick()
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
const isDownloaded = downloadedModels.some((md) =>
|
||||
model.models.some((m) => m.id === md.id)
|
||||
)
|
||||
|
||||
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 () => {
|
||||
if (assistants.length === 0) {
|
||||
toaster({
|
||||
title: 'No assistant available.',
|
||||
description: `Could not use Model ${model.name} as no assistant is available.`,
|
||||
type: 'error',
|
||||
})
|
||||
return
|
||||
const downloadedModel = downloadedModels.find((e) =>
|
||||
model.models.some((m) => m.id === e.id)
|
||||
)
|
||||
if (downloadedModel) {
|
||||
await requestCreateNewThread(assistants[0], downloadedModel)
|
||||
setMainViewState(MainViewState.Thread)
|
||||
}
|
||||
await requestCreateNewThread(assistants[0], model)
|
||||
setMainViewState(MainViewState.Thread)
|
||||
}, [assistants, model, requestCreateNewThread, setMainViewState])
|
||||
}, [
|
||||
assistants,
|
||||
model,
|
||||
requestCreateNewThread,
|
||||
setMainViewState,
|
||||
downloadedModels,
|
||||
])
|
||||
|
||||
if (isDownloaded) {
|
||||
downloadButton = (
|
||||
@ -104,6 +132,7 @@ const ModelItemHeader = ({ model, onClick, open }: Props) => {
|
||||
disabled={serverEnabled}
|
||||
data-testid={`use-model-btn-${model.id}`}
|
||||
variant="outline"
|
||||
theme="ghost"
|
||||
className="min-w-[98px]"
|
||||
>
|
||||
Use
|
||||
@ -114,54 +143,54 @@ const ModelItemHeader = ({ model, onClick, open }: Props) => {
|
||||
/>
|
||||
)
|
||||
} else if (isDownloading) {
|
||||
downloadButton = <ModalCancelDownload model={model} />
|
||||
downloadButton = (
|
||||
<ModalCancelDownload
|
||||
modelId={
|
||||
downloadingModels.find((e) => model.models.some((m) => m.id === e)) ??
|
||||
model.id
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="cursor-pointer rounded-t-md bg-[hsla(var(--app-bg))]"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="line-clamp-1 text-base font-semibold">
|
||||
{model.name}
|
||||
<div className="mb-2 rounded-t-md bg-[hsla(var(--app-bg))]">
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<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}
|
||||
>
|
||||
{extractModelName(model.metadata?.id)}
|
||||
</span>
|
||||
<EngineBadge engine={model.engine} />
|
||||
</div>
|
||||
<div className="inline-flex items-center space-x-2">
|
||||
<div className="hidden items-center sm:inline-flex">
|
||||
<span className="mr-4 font-semibold">
|
||||
{toGibibytes(model.metadata?.size)}
|
||||
<span className="mr-4 text-sm font-light text-[hsla(var(--text-secondary))]">
|
||||
{toGigabytes(model.models?.[0]?.size)}
|
||||
</span>
|
||||
<ModelLabel metadata={model.metadata} />
|
||||
</div>
|
||||
{downloadButton}
|
||||
<ChevronDownIcon
|
||||
className={twMerge(
|
||||
'h-5 w-5 flex-none',
|
||||
open === model.id && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
{model.type !== 'cloud' ? (
|
||||
downloadButton
|
||||
) : (
|
||||
<>
|
||||
{!model.metadata?.apiKey?.length && (
|
||||
<Button
|
||||
data-testid="setup-btn"
|
||||
onClick={() => {
|
||||
setSelectedSetting(model.id)
|
||||
setMainViewState(MainViewState.Settings)
|
||||
}}
|
||||
>
|
||||
Set Up
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</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
|
||||
|
||||
@ -1,98 +1,76 @@
|
||||
import { useState } from 'react'
|
||||
import Markdown from 'react-markdown'
|
||||
|
||||
import { Model } from '@janhq/core'
|
||||
import { Badge } from '@janhq/joi'
|
||||
import Image from 'next/image'
|
||||
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { ModelSource } from '@janhq/core'
|
||||
|
||||
import { DownloadIcon, FileJson } from 'lucide-react'
|
||||
|
||||
import ModelLabel from '@/containers/ModelLabel'
|
||||
|
||||
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 = {
|
||||
model: Model
|
||||
model: ModelSource
|
||||
onSelectedModel: () => void
|
||||
}
|
||||
|
||||
const ModelItem: React.FC<Props> = ({ model }) => {
|
||||
const [open, setOpen] = useState('')
|
||||
|
||||
const handleToggle = () => {
|
||||
if (open === model.id) {
|
||||
setOpen('')
|
||||
} else {
|
||||
setOpen(model.id)
|
||||
}
|
||||
}
|
||||
|
||||
const ModelItem: React.FC<Props> = ({ model, onSelectedModel }) => {
|
||||
return (
|
||||
<div className="mb-6 flex flex-col overflow-hidden rounded-xl border border-[hsla(var(--app-border))]">
|
||||
<ModelItemHeader model={model} onClick={handleToggle} open={open} />
|
||||
{open === model.id && (
|
||||
<div className="flex">
|
||||
<div className="flex w-full flex-col border-t border-[hsla(var(--app-border))] p-4 ">
|
||||
<div className="my-2 inline-flex items-center sm:hidden">
|
||||
<span className="mr-4 font-semibold">
|
||||
{toGibibytes(model.metadata?.size)}
|
||||
</span>
|
||||
<ModelLabel metadata={model.metadata} />
|
||||
</div>
|
||||
<div className="mb-6 flex flex-col gap-1">
|
||||
<span className="font-semibold">About</span>
|
||||
<p className="text-[hsla(var(--text-secondary))]">
|
||||
{model.description || '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-4 sm:flex-row sm:gap-x-10 sm:gap-y-0">
|
||||
<div>
|
||||
<span className="font-semibold ">Author</span>
|
||||
<p
|
||||
className="mt-2 line-clamp-1 font-medium text-[hsla(var(--text-secondary))]"
|
||||
title={model.metadata?.author}
|
||||
>
|
||||
{model.metadata?.author}
|
||||
</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="mb-6 flex w-full flex-col overflow-hidden border-b border-[hsla(var(--app-border))] py-4">
|
||||
<ModelItemHeader model={model} onSelectedModel={onSelectedModel} />
|
||||
|
||||
<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>
|
||||
<div className="flex w-full">
|
||||
<div className="flex w-full flex-col ">
|
||||
<div className="my-2 inline-flex items-center sm:hidden">
|
||||
<span className="mr-4">{toGigabytes(model.models?.[0]?.size)}</span>
|
||||
<ModelLabel size={model.models?.[0]?.size} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<Markdown className="md-short-desc line-clamp-3 max-w-full overflow-hidden font-light text-[hsla(var(--text-secondary))]">
|
||||
{extractDescription(model.metadata?.description) || '-'}
|
||||
</Markdown>
|
||||
</div>
|
||||
<div className="mb-6 flex flex-row divide-x">
|
||||
{model.metadata?.author && (
|
||||
<p
|
||||
className={twMerge(
|
||||
'mt-2 font-medium',
|
||||
!model.format?.includes(' ') &&
|
||||
!model.format?.includes('-') &&
|
||||
'uppercase'
|
||||
)}
|
||||
className="font-regular mt-3 line-clamp-1 flex flex-row pr-4 capitalize text-[hsla(var(--text-secondary))]"
|
||||
title={model.metadata?.author}
|
||||
>
|
||||
{model.format}
|
||||
{model.id?.includes('huggingface.co') && (
|
||||
<>
|
||||
<Image
|
||||
src={'icons/huggingFace.svg'}
|
||||
width={16}
|
||||
height={16}
|
||||
className="mr-2"
|
||||
alt=""
|
||||
/>{' '}
|
||||
</>
|
||||
)}{' '}
|
||||
{model.metadata?.author}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,53 +1,22 @@
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { Model } from '@janhq/core'
|
||||
|
||||
import { useAtomValue } from 'jotai'
|
||||
|
||||
import { useGetEngines } from '@/hooks/useEngineManagement'
|
||||
import { ModelSource } from '@janhq/core'
|
||||
|
||||
import ModelItem from '@/screens/Hub/ModelList/ModelItem'
|
||||
|
||||
import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom'
|
||||
|
||||
type Props = {
|
||||
models: Model[]
|
||||
models: ModelSource[]
|
||||
onSelectedModel: (model: ModelSource) => void
|
||||
}
|
||||
|
||||
const ModelList = ({ models }: 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])
|
||||
|
||||
const ModelList = ({ models, onSelectedModel }: Props) => {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
206
web/screens/Hub/ModelPage/index.tsx
Normal 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
|
||||
@ -1,116 +1,453 @@
|
||||
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 { ScrollArea, Button, Select } from '@janhq/joi'
|
||||
import { ModelSource } from '@janhq/core'
|
||||
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { UploadIcon } from 'lucide-react'
|
||||
import { ScrollArea, Button, Select, Tabs, useClickOutside } from '@janhq/joi'
|
||||
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 ModelSearch from '@/containers/ModelSearch'
|
||||
|
||||
import { useGetEngineModelSources } from '@/hooks/useEngineManagement'
|
||||
import { setImportModelStageAtom } from '@/hooks/useImportModel'
|
||||
|
||||
import {
|
||||
useGetModelSources,
|
||||
useModelSourcesMutation,
|
||||
} from '@/hooks/useModelSource'
|
||||
|
||||
import ModelList from '@/screens/Hub/ModelList'
|
||||
|
||||
import {
|
||||
configuredModelsAtom,
|
||||
downloadedModelsAtom,
|
||||
} from '@/helpers/atoms/Model.atom'
|
||||
import { extractModelRepo } from '@/utils/modelSource'
|
||||
import { fuzzySearch } from '@/utils/search'
|
||||
|
||||
import ModelPage from './ModelPage'
|
||||
|
||||
import { appBannerHubAtom } from '@/helpers/atoms/App.atom'
|
||||
import { modelDetailAtom } from '@/helpers/atoms/Model.atom'
|
||||
|
||||
const sortMenus = [
|
||||
{
|
||||
name: 'All Models',
|
||||
value: 'all-models',
|
||||
name: 'Most downloaded',
|
||||
value: 'most-downloaded',
|
||||
},
|
||||
{
|
||||
name: 'Featured',
|
||||
value: 'featured',
|
||||
name: 'Newest',
|
||||
value: 'newest',
|
||||
},
|
||||
]
|
||||
const filterOptions = [
|
||||
{
|
||||
name: 'All',
|
||||
value: 'all',
|
||||
},
|
||||
{
|
||||
name: 'Downloaded',
|
||||
value: 'downloaded',
|
||||
name: 'On-device',
|
||||
value: 'on-device',
|
||||
},
|
||||
{
|
||||
name: 'Cloud',
|
||||
value: 'cloud',
|
||||
},
|
||||
]
|
||||
|
||||
const HubScreen = () => {
|
||||
const configuredModels = useAtomValue(configuredModelsAtom)
|
||||
const downloadedModels = useAtomValue(downloadedModelsAtom)
|
||||
const [searchValue, setsearchValue] = useState('')
|
||||
const [sortSelected, setSortSelected] = useState('all-models')
|
||||
|
||||
const { sources } = useGetModelSources()
|
||||
const { sources: remoteModelSources } = useGetEngineModelSources()
|
||||
const { addModelSource } = useModelSourcesMutation()
|
||||
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 dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const imageInputRef = useRef<HTMLInputElement>(null)
|
||||
const hubBannerSettingRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const filteredModels = configuredModels.filter((x) => {
|
||||
if (sortSelected === 'downloaded') {
|
||||
return (
|
||||
x.name.toLowerCase().includes(searchValue.toLowerCase()) &&
|
||||
downloadedModels.some((y) => y.id === x.id)
|
||||
)
|
||||
} else if (sortSelected === 'featured') {
|
||||
return (
|
||||
x.name.toLowerCase().includes(searchValue.toLowerCase()) &&
|
||||
x.metadata?.tags?.includes('Featured')
|
||||
)
|
||||
} else {
|
||||
return x.name.toLowerCase().includes(searchValue.toLowerCase())
|
||||
const searchedModels = useMemo(
|
||||
() =>
|
||||
searchValue.length
|
||||
? (sources?.filter((e) =>
|
||||
fuzzySearch(
|
||||
searchValue.replaceAll(' ', '').toLowerCase(),
|
||||
e.id.toLowerCase()
|
||||
)
|
||||
) ?? [])
|
||||
: [],
|
||||
[sources, searchValue]
|
||||
)
|
||||
|
||||
const sortedModels = useMemo(() => {
|
||||
if (!sources) return []
|
||||
return sources.sort((a, b) => {
|
||||
if (sortSelected === 'most-downloaded') {
|
||||
return b.metadata.downloads - a.metadata.downloads
|
||||
} else {
|
||||
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(() => {
|
||||
setImportModelStage('SELECTING_MODEL')
|
||||
}, [setImportModelStage])
|
||||
|
||||
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 (
|
||||
<CenterPanelContainer>
|
||||
<ScrollArea data-testid="hub-container-test-id" className="h-full w-full">
|
||||
<div className="relative h-40 p-4 sm:h-auto">
|
||||
<Image
|
||||
src="./images/hub-banner.png"
|
||||
alt="Hub Banner"
|
||||
width={800}
|
||||
height={800}
|
||||
className="h-full 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 className="flex flex-col items-center justify-between gap-2 sm:flex-row">
|
||||
<div className="w-full">
|
||||
<ModelSearch onSearchLocal={onSearchUpdate} />
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<Button onClick={onImportModelClick}>
|
||||
<UploadIcon size={16} className="mr-2" />
|
||||
<span>Import Model</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 py-0 sm:px-16">
|
||||
{!filteredModels.length ? (
|
||||
<BlankState title="No search results found" />
|
||||
) : (
|
||||
<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="mb-4 flex w-full justify-end">
|
||||
<Select
|
||||
value={sortSelected}
|
||||
onValueChange={(value) => {
|
||||
setSortSelected(value)
|
||||
}}
|
||||
options={sortMenus}
|
||||
/>
|
||||
<div className="relative h-40 p-4 sm:h-auto">
|
||||
<div className="group">
|
||||
<Image
|
||||
src={appBannerHub}
|
||||
alt="Hub Banner"
|
||||
width={800}
|
||||
height={800}
|
||||
className="h-60 w-full rounded-lg object-cover"
|
||||
/>
|
||||
<div
|
||||
className={twMerge(
|
||||
'invisible absolute bottom-8 right-8 cursor-pointer opacity-0 transition-opacity',
|
||||
'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
|
||||
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
|
||||
</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" />
|
||||
<span>Import</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 p-4 py-0 sm:px-16">
|
||||
<>
|
||||
<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">
|
||||
<Select
|
||||
value={sortSelected}
|
||||
onValueChange={(value) => {
|
||||
setSortSelected(value)
|
||||
}}
|
||||
options={sortMenus}
|
||||
/>
|
||||
</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>
|
||||
<ModelList models={filteredModels} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</ScrollArea>
|
||||
)}
|
||||
{selectedModel && (
|
||||
<ModelPage
|
||||
model={selectedModel}
|
||||
onGoBack={() => {
|
||||
setSearchValue('')
|
||||
setSelectedModel(undefined)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</m.div>
|
||||
</CenterPanelContainer>
|
||||
)
|
||||
}
|
||||
|
||||
@ -17,7 +17,7 @@ import useImportModel, {
|
||||
setImportModelStageAtom,
|
||||
} from '@/hooks/useImportModel'
|
||||
|
||||
import { toGibibytes } from '@/utils/converter'
|
||||
import { toGigabytes } from '@/utils/converter'
|
||||
|
||||
import { openFileTitle } from '@/utils/titleUtils'
|
||||
|
||||
@ -126,7 +126,7 @@ const EditModelInfoModal = () => {
|
||||
<div className="flex flex-1 flex-col">
|
||||
<p>{editingModel.name}</p>
|
||||
<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">
|
||||
<span className="font-semibold">Format:</span>
|
||||
<span className="font-normal">
|
||||
|
||||
@ -6,7 +6,7 @@ import { Button, Switch } from '@janhq/joi'
|
||||
import { useAtom, useSetAtom } from 'jotai'
|
||||
import { SettingsIcon } from 'lucide-react'
|
||||
|
||||
import { getTitleByEngine } from '@/utils/modelEngine'
|
||||
import { getDescriptionByEngine, getTitleByEngine } from '@/utils/modelEngine'
|
||||
|
||||
import { showSettingActiveRemoteEngineAtom } from '@/helpers/atoms/Extension.atom'
|
||||
import { selectedSettingAtom } from '@/helpers/atoms/Setting.atom'
|
||||
@ -48,10 +48,7 @@ const RemoteEngineItems = ({
|
||||
</h6>
|
||||
</div>
|
||||
<div className="mt-2 w-full font-medium leading-relaxed text-[hsla(var(--text-secondary))]">
|
||||
<p>
|
||||
Access models from {getTitleByEngine(engine as InferenceEngine)}{' '}
|
||||
via their API.
|
||||
</p>
|
||||
<p>{getDescriptionByEngine(engine as InferenceEngine)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable react/no-unescaped-entities */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable react/no-unescaped-entities */
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
|
||||
import React, { useCallback, useRef, useState, useEffect } from 'react'
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -7,7 +7,7 @@ import { AlertCircle } from 'lucide-react'
|
||||
|
||||
import { setImportModelStageAtom } from '@/hooks/useImportModel'
|
||||
|
||||
import { toGibibytes } from '@/utils/converter'
|
||||
import { toGigabytes } from '@/utils/converter'
|
||||
|
||||
import { editingModelIdAtom } from '../EditModelInfoModal'
|
||||
import ImportInProgressIcon from '../ImportInProgressIcon'
|
||||
@ -32,7 +32,7 @@ const ImportingModelItem = ({ model }: Props) => {
|
||||
if (model.status === 'FAILED') {
|
||||
return 'Failed'
|
||||
} else {
|
||||
return toGibibytes(model.size)
|
||||
return toGigabytes(model.size)
|
||||
}
|
||||
}, [model.status, model.size])
|
||||
|
||||
|
||||
@ -16,7 +16,7 @@ import useDeleteModel from '@/hooks/useDeleteModel'
|
||||
|
||||
import { useGetEngines } from '@/hooks/useEngineManagement'
|
||||
|
||||
import { toGibibytes } from '@/utils/converter'
|
||||
import { toGigabytes } from '@/utils/converter'
|
||||
|
||||
import { isLocalEngine } from '@/utils/modelEngine'
|
||||
|
||||
@ -80,7 +80,7 @@ const MyModelList = ({ model }: Props) => {
|
||||
<div className="flex gap-x-4">
|
||||
<div className="md:min-w-[90px] md:max-w-[90px]">
|
||||
<Badge theme="secondary" className="sm:mr-8">
|
||||
{model.metadata?.size ? toGibibytes(model.metadata?.size) : '-'}
|
||||
{model.metadata?.size ? toGigabytes(model.metadata?.size) : '-'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
|
||||
@ -26,7 +26,7 @@ import { modelDownloadStateAtom } from '@/hooks/useDownloadState'
|
||||
|
||||
import { useGetEngines } from '@/hooks/useEngineManagement'
|
||||
|
||||
import { formatDownloadPercentage, toGibibytes } from '@/utils/converter'
|
||||
import { formatDownloadPercentage, toGigabytes } from '@/utils/converter'
|
||||
import { manualRecommendationModel } from '@/utils/model'
|
||||
import {
|
||||
getLogoEngine,
|
||||
@ -112,7 +112,10 @@ const OnDeviceStarterScreen = ({ isShowStarterScreen }: Props) => {
|
||||
|
||||
return (
|
||||
<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="mx-auto flex h-full w-3/4 flex-col items-center justify-center py-16 text-center">
|
||||
<LogoMark
|
||||
@ -162,11 +165,11 @@ const OnDeviceStarterScreen = ({ isShowStarterScreen }: Props) => {
|
||||
>
|
||||
{model.name}
|
||||
</p>
|
||||
<ModelLabel metadata={model.metadata} compact />
|
||||
<ModelLabel size={model.metadata?.size} compact />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[hsla(var(--text-tertiary))]">
|
||||
<span className="font-medium">
|
||||
{toGibibytes(model.metadata?.size)}
|
||||
{toGigabytes(model.metadata?.size)}
|
||||
</span>
|
||||
{!isDownloading ? (
|
||||
<DownloadCloudIcon
|
||||
@ -259,7 +262,7 @@ const OnDeviceStarterScreen = ({ isShowStarterScreen }: Props) => {
|
||||
</div>
|
||||
))}
|
||||
<span className="text-[hsla(var(--text-secondary))]">
|
||||
{toGibibytes(featModel.metadata?.size)}
|
||||
{toGigabytes(featModel.metadata?.size)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
@ -278,7 +281,7 @@ const OnDeviceStarterScreen = ({ isShowStarterScreen }: Props) => {
|
||||
Download
|
||||
</Button>
|
||||
<span className="text-[hsla(var(--text-secondary))]">
|
||||
{toGibibytes(featModel.metadata?.size)}
|
||||
{toGigabytes(featModel.metadata?.size)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -6,7 +6,7 @@ import { XIcon } from 'lucide-react'
|
||||
|
||||
import { currentPromptAtom, fileUploadAtom } from '@/containers/Providers/Jotai'
|
||||
|
||||
import { toGibibytes } from '@/utils/converter'
|
||||
import { toGigabytes } from '@/utils/converter'
|
||||
|
||||
import Icon from './Icon'
|
||||
|
||||
@ -30,7 +30,7 @@ const FileUploadPreview = () => {
|
||||
{fileUpload?.file.name.replaceAll(/[-._]/g, ' ')}
|
||||
</h6>
|
||||
<p className="text-[hsla(var(--text-secondary)]">
|
||||
{toGibibytes(fileUpload?.file.size)}
|
||||
{toGigabytes(fileUpload?.file.size)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import { memo } from 'react'
|
||||
|
||||
import { usePath } from '@/hooks/usePath'
|
||||
|
||||
import { toGibibytes } from '@/utils/converter'
|
||||
import { toGigabytes } from '@/utils/converter'
|
||||
|
||||
import Icon from '../FileUploadPreview/Icon'
|
||||
|
||||
@ -31,7 +31,7 @@ const DocMessage = ({
|
||||
</h6>
|
||||
<p className="text-[hsla(var(--text-secondary)] line-clamp-1 overflow-hidden truncate">
|
||||
{metadata && 'size' in metadata
|
||||
? toGibibytes(Number(metadata.size))
|
||||
? toGigabytes(Number(metadata.size))
|
||||
: id}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -5,10 +5,12 @@ import React, { memo } from 'react'
|
||||
|
||||
import Markdown from 'react-markdown'
|
||||
|
||||
import { PluggableList } from 'react-markdown/lib'
|
||||
import rehypeHighlight from 'rehype-highlight'
|
||||
import rehypeHighlightCodeLines from 'rehype-highlight-code-lines'
|
||||
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
||||
import remarkMath from 'remark-math'
|
||||
|
||||
@ -18,8 +20,15 @@ import { useClipboard } from '@/hooks/useClipboard'
|
||||
|
||||
import { getLanguageFromExtension } from '@/utils/codeLanguageExtension'
|
||||
|
||||
interface Props {
|
||||
text: string
|
||||
isUser?: boolean
|
||||
className?: string
|
||||
renderKatex?: boolean
|
||||
}
|
||||
|
||||
export const MarkdownTextMessage = memo(
|
||||
({ text, isUser }: { id: string; text: string; isUser: boolean }) => {
|
||||
({ text, isUser, className, renderKatex = true }: Props) => {
|
||||
const clipboard = useClipboard({ timeout: 1000 })
|
||||
|
||||
// Escapes headings
|
||||
@ -202,13 +211,16 @@ export const MarkdownTextMessage = memo(
|
||||
return (
|
||||
<>
|
||||
<Markdown
|
||||
remarkPlugins={[remarkMath]}
|
||||
rehypePlugins={[
|
||||
[rehypeKatex, { throwOnError: false }],
|
||||
rehypeHighlight,
|
||||
[rehypeHighlightCodeLines, { showLineNumbers: true }],
|
||||
wrapCodeBlocksWithoutVisit,
|
||||
]}
|
||||
className={className}
|
||||
remarkPlugins={[remarkMath, remarkGfm]}
|
||||
rehypePlugins={
|
||||
[
|
||||
rehypeHighlight,
|
||||
renderKatex ? [rehypeKatex, { throwOnError: false }] : undefined,
|
||||
[rehypeHighlightCodeLines, { showLineNumbers: true }],
|
||||
wrapCodeBlocksWithoutVisit,
|
||||
].filter((e) => !!e) as PluggableList
|
||||
}
|
||||
>
|
||||
{preprocessMarkdown(text)}
|
||||
</Markdown>
|
||||
|
||||
@ -144,11 +144,7 @@ const MessageContainer: React.FC<
|
||||
)}
|
||||
dir="ltr"
|
||||
>
|
||||
<MarkdownTextMessage
|
||||
id={props.id}
|
||||
text={text}
|
||||
isUser={isUser}
|
||||
/>
|
||||
<MarkdownTextMessage text={text} isUser={isUser} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
91
web/styles/components/marked.scss
Normal 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;
|
||||
}
|
||||
3
web/styles/components/model.scss
Normal file
@ -0,0 +1,3 @@
|
||||
.md-short-desc hr {
|
||||
visibility: hidden;
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
export const toGibibytes = (
|
||||
export const toGigabytes = (
|
||||
input: number,
|
||||
options?: { hideUnit?: boolean }
|
||||
) => {
|
||||
@ -24,7 +24,7 @@ export const formatDownloadPercentage = (
|
||||
|
||||
export const formatDownloadSpeed = (input: number | undefined) => {
|
||||
if (!input) return '0B/s'
|
||||
return toGibibytes(input) + '/s'
|
||||
return toGigabytes(input) + '/s'
|
||||
}
|
||||
|
||||
export const formatTwoDigits = (input: number) => {
|
||||
|
||||
@ -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 = [
|
||||
InferenceEngine.cortex_llamacpp,
|
||||
InferenceEngine.cortex_onnx,
|
||||
|
||||
31
web/utils/modelSource.ts
Normal 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
@ -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
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
@ -1,3 +0,0 @@
|
||||
export const generateThreadId = (assistantId: string) => {
|
||||
return `${assistantId}_${(Date.now() / 1000).toFixed(0)}`
|
||||
}
|
||||