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 { BaseExtension, ExtensionTypeEnum } from '../extension'
|
||||||
import { Model, ModelInterface, OptionType } from '../../types'
|
import { Model, ModelInterface, ModelSource, OptionType } from '../../types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Model extension for managing models.
|
* Model extension for managing models.
|
||||||
*/
|
*/
|
||||||
export abstract class ModelExtension extends BaseExtension implements ModelInterface {
|
export abstract class ModelExtension
|
||||||
|
extends BaseExtension
|
||||||
|
implements ModelInterface
|
||||||
|
{
|
||||||
/**
|
/**
|
||||||
* Model extension type.
|
* Model extension type.
|
||||||
*/
|
*/
|
||||||
@ -25,4 +28,16 @@ export abstract class ModelExtension extends BaseExtension implements ModelInter
|
|||||||
abstract updateModel(modelInfo: Partial<Model>): Promise<Model>
|
abstract updateModel(modelInfo: Partial<Model>): Promise<Model>
|
||||||
abstract deleteModel(model: string): Promise<void>
|
abstract deleteModel(model: string): Promise<void>
|
||||||
abstract isModelLoaded(model: string): Promise<boolean>
|
abstract isModelLoaded(model: string): Promise<boolean>
|
||||||
|
/**
|
||||||
|
* Get model sources
|
||||||
|
*/
|
||||||
|
abstract getSources(): Promise<ModelSource[]>
|
||||||
|
/**
|
||||||
|
* Add a model source
|
||||||
|
*/
|
||||||
|
abstract addSource(source: string): Promise<void>
|
||||||
|
/**
|
||||||
|
* Delete a model source
|
||||||
|
*/
|
||||||
|
abstract deleteSource(source: string): Promise<void>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,3 +2,4 @@ export * from './modelEntity'
|
|||||||
export * from './modelInterface'
|
export * from './modelInterface'
|
||||||
export * from './modelEvent'
|
export * from './modelEvent'
|
||||||
export * from './modelImport'
|
export * from './modelImport'
|
||||||
|
export * from './modelSource'
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Model } from './modelEntity'
|
import { Model } from './modelEntity'
|
||||||
import { OptionType } from './modelImport'
|
import { OptionType } from './modelImport'
|
||||||
|
import { ModelSource } from './modelSource'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Model extension for managing models.
|
* Model extension for managing models.
|
||||||
@ -50,4 +51,17 @@ export interface ModelInterface {
|
|||||||
name?: string,
|
name?: string,
|
||||||
optionType?: OptionType
|
optionType?: OptionType
|
||||||
): Promise<void>
|
): Promise<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get model sources
|
||||||
|
*/
|
||||||
|
getSources(): Promise<ModelSource[]>
|
||||||
|
/**
|
||||||
|
* Add a model source
|
||||||
|
*/
|
||||||
|
addSource(source: string): Promise<void>
|
||||||
|
/**
|
||||||
|
* Delete a model source
|
||||||
|
*/
|
||||||
|
deleteSource(source: string): Promise<void>
|
||||||
}
|
}
|
||||||
|
|||||||
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.navigateByMenu()
|
||||||
await hubPage.verifyContainerVisible()
|
await hubPage.verifyContainerVisible()
|
||||||
await hubPage.scrollToBottom()
|
await hubPage.scrollToBottom()
|
||||||
const useModelBtn = page.getByTestId(/^use-model-btn-.*/).first()
|
const useModelBtn = page.getByTestId(/^setup-btn/).first()
|
||||||
|
|
||||||
await expect(useModelBtn).toBeVisible({
|
await expect(useModelBtn).toBeVisible({
|
||||||
timeout: TIMEOUT,
|
timeout: TIMEOUT,
|
||||||
|
|||||||
@ -1,33 +1,18 @@
|
|||||||
import { expect } from '@playwright/test'
|
import { expect } from '@playwright/test'
|
||||||
import { page, test, TIMEOUT } from '../config/fixtures'
|
import { page, test, TIMEOUT } from '../config/fixtures'
|
||||||
|
|
||||||
test('Select GPT model from Hub and Chat with Invalid API Key', async ({
|
test('show onboarding screen without any threads created or models downloaded', async () => {
|
||||||
hubPage,
|
await page.getByTestId('Thread').first().click({
|
||||||
}) => {
|
timeout: TIMEOUT,
|
||||||
await hubPage.navigateByMenu()
|
})
|
||||||
await hubPage.verifyContainerVisible()
|
|
||||||
|
|
||||||
// Select the first GPT model
|
|
||||||
await page
|
|
||||||
.locator('[data-testid^="use-model-btn"][data-testid*="gpt"]')
|
|
||||||
.first()
|
|
||||||
.click()
|
|
||||||
|
|
||||||
await page.getByTestId('txt-input-chat').fill('dummy value')
|
|
||||||
|
|
||||||
const denyButton = page.locator('[data-testid="btn-deny-product-analytics"]')
|
const denyButton = page.locator('[data-testid="btn-deny-product-analytics"]')
|
||||||
|
|
||||||
if ((await denyButton.count()) > 0) {
|
if ((await denyButton.count()) > 0) {
|
||||||
await denyButton.click({ force: true })
|
await denyButton.click({ force: true })
|
||||||
} else {
|
|
||||||
await page.getByTestId('btn-send-chat').click({ force: true })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await page.waitForFunction(
|
const onboardScreen = page.getByTestId('onboard-screen')
|
||||||
() => {
|
await expect(onboardScreen).toBeVisible({
|
||||||
const loaders = document.querySelectorAll('[data-testid$="loader"]')
|
timeout: TIMEOUT,
|
||||||
return !loaders.length
|
})
|
||||||
},
|
|
||||||
{ timeout: TIMEOUT }
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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 { defineConfig } from 'rolldown'
|
||||||
import settingJson from './resources/settings.json' with { type: 'json' }
|
import settingJson from './resources/settings.json' with { type: 'json' }
|
||||||
|
import modelSources from './resources/default.json' with { type: 'json' }
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
input: 'src/index.ts',
|
input: 'src/index.ts',
|
||||||
@ -12,5 +13,6 @@ export default defineConfig({
|
|||||||
SETTINGS: JSON.stringify(settingJson),
|
SETTINGS: JSON.stringify(settingJson),
|
||||||
API_URL: JSON.stringify('http://127.0.0.1:39291'),
|
API_URL: JSON.stringify('http://127.0.0.1:39291'),
|
||||||
SOCKET_URL: JSON.stringify('ws://127.0.0.1:39291'),
|
SOCKET_URL: JSON.stringify('ws://127.0.0.1:39291'),
|
||||||
|
DEFAULT_MODEL_SOURCES: JSON.stringify(modelSources),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@ -2,6 +2,7 @@ declare const NODE: string
|
|||||||
declare const API_URL: string
|
declare const API_URL: string
|
||||||
declare const SOCKET_URL: string
|
declare const SOCKET_URL: string
|
||||||
declare const SETTINGS: SettingComponentProps[]
|
declare const SETTINGS: SettingComponentProps[]
|
||||||
|
declare const DEFAULT_MODEL_SOURCES: any
|
||||||
|
|
||||||
interface Core {
|
interface Core {
|
||||||
api: APIFunctions
|
api: APIFunctions
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import PQueue from 'p-queue'
|
import PQueue from 'p-queue'
|
||||||
import ky from 'ky'
|
import ky from 'ky'
|
||||||
import { extractModelLoadParams, Model } from '@janhq/core'
|
import { extractModelLoadParams, Model, ModelSource } from '@janhq/core'
|
||||||
import { extractInferenceParams } from '@janhq/core'
|
import { extractInferenceParams } from '@janhq/core'
|
||||||
/**
|
/**
|
||||||
* cortex.cpp Model APIs interface
|
* cortex.cpp Model APIs interface
|
||||||
@ -19,9 +19,12 @@ interface ICortexAPI {
|
|||||||
updateModel(model: object): Promise<void>
|
updateModel(model: object): Promise<void>
|
||||||
cancelModelPull(model: string): Promise<void>
|
cancelModelPull(model: string): Promise<void>
|
||||||
configs(body: { [key: string]: any }): Promise<void>
|
configs(body: { [key: string]: any }): Promise<void>
|
||||||
|
getSources(): Promise<ModelSource[]>
|
||||||
|
addSource(source: string): Promise<void>
|
||||||
|
deleteSource(source: string): Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModelList = {
|
type Data = {
|
||||||
data: any[]
|
data: any[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,7 +56,7 @@ export class CortexAPI implements ICortexAPI {
|
|||||||
*/
|
*/
|
||||||
getModels(): Promise<Model[]> {
|
getModels(): Promise<Model[]> {
|
||||||
return this.queue
|
return this.queue
|
||||||
.add(() => ky.get(`${API_URL}/v1/models?limit=-1`).json<ModelList>())
|
.add(() => ky.get(`${API_URL}/v1/models?limit=-1`).json<Data>())
|
||||||
.then((e) =>
|
.then((e) =>
|
||||||
typeof e === 'object' ? e.data.map((e) => this.transformModel(e)) : []
|
typeof e === 'object' ? e.data.map((e) => this.transformModel(e)) : []
|
||||||
)
|
)
|
||||||
@ -148,6 +151,47 @@ export class CortexAPI implements ICortexAPI {
|
|||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BEGIN - Model Sources
|
||||||
|
/**
|
||||||
|
* Get model sources
|
||||||
|
* @param model
|
||||||
|
*/
|
||||||
|
async getSources(): Promise<ModelSource[]> {
|
||||||
|
return this.queue
|
||||||
|
.add(() => ky.get(`${API_URL}/v1/models/sources`).json<Data>())
|
||||||
|
.then((e) => (typeof e === 'object' ? (e.data as ModelSource[]) : []))
|
||||||
|
.catch(() => [])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a model source
|
||||||
|
* @param model
|
||||||
|
*/
|
||||||
|
async addSource(source: string): Promise<any> {
|
||||||
|
return this.queue.add(() =>
|
||||||
|
ky.post(`${API_URL}/v1/models/sources`, {
|
||||||
|
json: {
|
||||||
|
source,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a model source
|
||||||
|
* @param model
|
||||||
|
*/
|
||||||
|
async deleteSource(source: string): Promise<any> {
|
||||||
|
return this.queue.add(() =>
|
||||||
|
ky.delete(`${API_URL}/v1/models/sources`, {
|
||||||
|
json: {
|
||||||
|
source,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// END - Model Sources
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Do health check on cortex.cpp
|
* Do health check on cortex.cpp
|
||||||
* @returns
|
* @returns
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
events,
|
events,
|
||||||
DownloadEvent,
|
DownloadEvent,
|
||||||
OptionType,
|
OptionType,
|
||||||
|
ModelSource,
|
||||||
} from '@janhq/core'
|
} from '@janhq/core'
|
||||||
import { CortexAPI } from './cortex'
|
import { CortexAPI } from './cortex'
|
||||||
import { scanModelsFolder } from './legacy/model-json'
|
import { scanModelsFolder } from './legacy/model-json'
|
||||||
@ -243,6 +244,35 @@ export default class JanModelExtension extends ModelExtension {
|
|||||||
return this.cortexAPI.importModel(model, modelPath, name, option)
|
return this.cortexAPI.importModel(model, modelPath, name, option)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BEGIN - Model Sources
|
||||||
|
/**
|
||||||
|
* Get model sources
|
||||||
|
* @param model
|
||||||
|
*/
|
||||||
|
async getSources(): Promise<ModelSource[]> {
|
||||||
|
const sources = await this.cortexAPI.getSources()
|
||||||
|
return sources.concat(
|
||||||
|
DEFAULT_MODEL_SOURCES.filter((e) => !sources.some((x) => x.id === e.id))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a model source
|
||||||
|
* @param model
|
||||||
|
*/
|
||||||
|
async addSource(source: string): Promise<any> {
|
||||||
|
return this.cortexAPI.addSource(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a model source
|
||||||
|
* @param model
|
||||||
|
*/
|
||||||
|
async deleteSource(source: string): Promise<any> {
|
||||||
|
return this.cortexAPI.deleteSource(source)
|
||||||
|
}
|
||||||
|
// END - Model Sources
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check model status
|
* Check model status
|
||||||
* @param model
|
* @param model
|
||||||
|
|||||||
@ -30,6 +30,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-accordion": "^1.1.2",
|
"@radix-ui/react-accordion": "^1.1.2",
|
||||||
"@radix-ui/react-dialog": "^1.0.5",
|
"@radix-ui/react-dialog": "^1.0.5",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||||
"@radix-ui/react-icons": "^1.3.0",
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||||
"@radix-ui/react-select": "^2.0.0",
|
"@radix-ui/react-select": "^2.0.0",
|
||||||
|
|||||||
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 * as SelectPrimitive from '@radix-ui/react-select'
|
||||||
import {
|
import { CheckIcon, ChevronDownIcon } from '@radix-ui/react-icons'
|
||||||
CheckIcon,
|
|
||||||
ChevronDownIcon,
|
|
||||||
ChevronUpIcon,
|
|
||||||
} from '@radix-ui/react-icons'
|
|
||||||
|
|
||||||
import './styles.scss'
|
import './styles.scss'
|
||||||
import { twMerge } from 'tailwind-merge'
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|||||||
@ -15,6 +15,7 @@ jest.mock('./core/Select/styles.scss', () => ({}))
|
|||||||
jest.mock('./core/TextArea/styles.scss', () => ({}))
|
jest.mock('./core/TextArea/styles.scss', () => ({}))
|
||||||
jest.mock('./core/Tabs/styles.scss', () => ({}))
|
jest.mock('./core/Tabs/styles.scss', () => ({}))
|
||||||
jest.mock('./core/Accordion/styles.scss', () => ({}))
|
jest.mock('./core/Accordion/styles.scss', () => ({}))
|
||||||
|
jest.mock('./core/Dropdown/styles.scss', () => ({}))
|
||||||
|
|
||||||
describe('Exports', () => {
|
describe('Exports', () => {
|
||||||
it('exports all components and hooks', () => {
|
it('exports all components and hooks', () => {
|
||||||
|
|||||||
@ -12,6 +12,7 @@ export * from './core/Select'
|
|||||||
export * from './core/TextArea'
|
export * from './core/TextArea'
|
||||||
export * from './core/Tabs'
|
export * from './core/Tabs'
|
||||||
export * from './core/Accordion'
|
export * from './core/Accordion'
|
||||||
|
export * from './core/Dropdown'
|
||||||
|
|
||||||
export * from './hooks/useClipboard'
|
export * from './hooks/useClipboard'
|
||||||
export * from './hooks/usePageLeave'
|
export * from './hooks/usePageLeave'
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { useActiveModel } from '@/hooks/useActiveModel'
|
|||||||
|
|
||||||
import { useGetEngines } from '@/hooks/useEngineManagement'
|
import { useGetEngines } from '@/hooks/useEngineManagement'
|
||||||
|
|
||||||
import { toGibibytes } from '@/utils/converter'
|
import { toGigabytes } from '@/utils/converter'
|
||||||
|
|
||||||
import { isLocalEngine } from '@/utils/modelEngine'
|
import { isLocalEngine } from '@/utils/modelEngine'
|
||||||
|
|
||||||
@ -34,7 +34,7 @@ const TableActiveModel = () => {
|
|||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<Badge theme="secondary">
|
<Badge theme="secondary">
|
||||||
{activeModel.metadata?.size
|
{activeModel.metadata?.size
|
||||||
? toGibibytes(activeModel.metadata?.size)
|
? toGigabytes(activeModel.metadata?.size)
|
||||||
: '-'}
|
: '-'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import useGetSystemResources from '@/hooks/useGetSystemResources'
|
|||||||
|
|
||||||
import { usePath } from '@/hooks/usePath'
|
import { usePath } from '@/hooks/usePath'
|
||||||
|
|
||||||
import { toGibibytes } from '@/utils/converter'
|
import { toGigabytes } from '@/utils/converter'
|
||||||
|
|
||||||
import { utilizedMemory } from '@/utils/memory'
|
import { utilizedMemory } from '@/utils/memory'
|
||||||
|
|
||||||
@ -134,8 +134,8 @@ const SystemMonitor = () => {
|
|||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<h6 className="font-bold">Memory</h6>
|
<h6 className="font-bold">Memory</h6>
|
||||||
<span>
|
<span>
|
||||||
{toGibibytes(usedRam, { hideUnit: true })}/
|
{toGigabytes(usedRam, { hideUnit: true })}/
|
||||||
{toGibibytes(totalRam, { hideUnit: true })} GB
|
{toGigabytes(totalRam, { hideUnit: true })} GB
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-x-4">
|
<div className="flex items-center gap-x-4">
|
||||||
|
|||||||
@ -21,7 +21,6 @@ import { SUCCESS_SET_NEW_DESTINATION } from '@/screens/Settings/Advanced/DataFol
|
|||||||
import CancelModelImportModal from '@/screens/Settings/CancelModelImportModal'
|
import CancelModelImportModal from '@/screens/Settings/CancelModelImportModal'
|
||||||
import ChooseWhatToImportModal from '@/screens/Settings/ChooseWhatToImportModal'
|
import ChooseWhatToImportModal from '@/screens/Settings/ChooseWhatToImportModal'
|
||||||
import EditModelInfoModal from '@/screens/Settings/EditModelInfoModal'
|
import EditModelInfoModal from '@/screens/Settings/EditModelInfoModal'
|
||||||
import HuggingFaceRepoDetailModal from '@/screens/Settings/HuggingFaceRepoDetailModal'
|
|
||||||
import ImportModelOptionModal from '@/screens/Settings/ImportModelOptionModal'
|
import ImportModelOptionModal from '@/screens/Settings/ImportModelOptionModal'
|
||||||
import ImportingModelModal from '@/screens/Settings/ImportingModelModal'
|
import ImportingModelModal from '@/screens/Settings/ImportingModelModal'
|
||||||
import SelectingModelModal from '@/screens/Settings/SelectingModelModal'
|
import SelectingModelModal from '@/screens/Settings/SelectingModelModal'
|
||||||
@ -148,7 +147,6 @@ const BaseLayout = () => {
|
|||||||
{importModelStage === 'CONFIRM_CANCEL' && <CancelModelImportModal />}
|
{importModelStage === 'CONFIRM_CANCEL' && <CancelModelImportModal />}
|
||||||
<ChooseWhatToImportModal />
|
<ChooseWhatToImportModal />
|
||||||
<InstallingExtensionModal />
|
<InstallingExtensionModal />
|
||||||
<HuggingFaceRepoDetailModal />
|
|
||||||
{showProductAnalyticPrompt && (
|
{showProductAnalyticPrompt && (
|
||||||
<div className="fixed bottom-4 z-50 m-4 max-w-full rounded-xl border border-[hsla(var(--app-border))] bg-[hsla(var(--app-bg))] p-6 shadow-2xl sm:bottom-8 sm:right-4 sm:m-0 sm:max-w-[400px]">
|
<div className="fixed bottom-4 z-50 m-4 max-w-full rounded-xl border border-[hsla(var(--app-border))] bg-[hsla(var(--app-bg))] p-6 shadow-2xl sm:bottom-8 sm:right-4 sm:m-0 sm:max-w-[400px]">
|
||||||
<div className="mb-4 flex items-center gap-x-2">
|
<div className="mb-4 flex items-center gap-x-2">
|
||||||
|
|||||||
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 { useCallback } from 'react'
|
||||||
|
|
||||||
import { Model } from '@janhq/core'
|
|
||||||
|
|
||||||
import { Modal, Button, Progress, ModalClose } from '@janhq/joi'
|
import { Modal, Button, Progress, ModalClose } from '@janhq/joi'
|
||||||
|
|
||||||
import { useAtomValue, useSetAtom } from 'jotai'
|
import { useAtomValue, useSetAtom } from 'jotai'
|
||||||
@ -16,22 +14,22 @@ import {
|
|||||||
import { formatDownloadPercentage } from '@/utils/converter'
|
import { formatDownloadPercentage } from '@/utils/converter'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
model: Model
|
modelId: string
|
||||||
isFromList?: boolean
|
isFromList?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const ModalCancelDownload = ({ model, isFromList }: Props) => {
|
const ModalCancelDownload = ({ modelId, isFromList }: Props) => {
|
||||||
const { abortModelDownload } = useDownloadModel()
|
const { abortModelDownload } = useDownloadModel()
|
||||||
const removeDownloadState = useSetAtom(removeDownloadStateAtom)
|
const removeDownloadState = useSetAtom(removeDownloadStateAtom)
|
||||||
const allDownloadStates = useAtomValue(modelDownloadStateAtom)
|
const allDownloadStates = useAtomValue(modelDownloadStateAtom)
|
||||||
const downloadState = allDownloadStates[model.id]
|
const downloadState = allDownloadStates[modelId]
|
||||||
|
|
||||||
const cancelText = `Cancel ${formatDownloadPercentage(downloadState?.percent ?? 0)}`
|
const cancelText = `Cancel ${formatDownloadPercentage(downloadState?.percent ?? 0)}`
|
||||||
|
|
||||||
const onAbortDownloadClick = useCallback(() => {
|
const onAbortDownloadClick = useCallback(() => {
|
||||||
removeDownloadState(model.id)
|
removeDownloadState(modelId)
|
||||||
abortModelDownload(downloadState?.modelId ?? model.id)
|
abortModelDownload(downloadState?.modelId ?? modelId)
|
||||||
}, [downloadState, abortModelDownload, removeDownloadState, model])
|
}, [downloadState, abortModelDownload, removeDownloadState, modelId])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@ -42,7 +40,11 @@ const ModalCancelDownload = ({ model, isFromList }: Props) => {
|
|||||||
{cancelText}
|
{cancelText}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="soft">
|
<Button
|
||||||
|
className="text-[hsla(var(--primary-bg))]"
|
||||||
|
variant="soft"
|
||||||
|
theme="ghost"
|
||||||
|
>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<span className="inline-block">Cancel</span>
|
<span className="inline-block">Cancel</span>
|
||||||
<Progress
|
<Progress
|
||||||
|
|||||||
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 useUpdateModelParameters from '@/hooks/useUpdateModelParameters'
|
||||||
|
|
||||||
import { formatDownloadPercentage, toGibibytes } from '@/utils/converter'
|
import { formatDownloadPercentage, toGigabytes } from '@/utils/converter'
|
||||||
|
|
||||||
import { manualRecommendationModel } from '@/utils/model'
|
import { manualRecommendationModel } from '@/utils/model'
|
||||||
import { getLogoEngine } from '@/utils/modelEngine'
|
import { getLogoEngine } from '@/utils/modelEngine'
|
||||||
@ -481,13 +481,13 @@ const ModelDropdown = ({
|
|||||||
{model.name}
|
{model.name}
|
||||||
</p>
|
</p>
|
||||||
<ModelLabel
|
<ModelLabel
|
||||||
metadata={model.metadata}
|
size={model.metadata?.size}
|
||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-[hsla(var(--text-tertiary))]">
|
<div className="flex items-center gap-2 text-[hsla(var(--text-tertiary))]">
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{toGibibytes(model.metadata?.size)}
|
{toGigabytes(model.metadata?.size)}
|
||||||
</span>
|
</span>
|
||||||
{!isDownloading ? (
|
{!isDownloading ? (
|
||||||
<DownloadCloudIcon
|
<DownloadCloudIcon
|
||||||
@ -577,14 +577,14 @@ const ModelDropdown = ({
|
|||||||
{model.name}
|
{model.name}
|
||||||
</p>
|
</p>
|
||||||
<ModelLabel
|
<ModelLabel
|
||||||
metadata={model.metadata}
|
size={model.metadata?.size}
|
||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-[hsla(var(--text-tertiary))]">
|
<div className="flex items-center gap-2 text-[hsla(var(--text-tertiary))]">
|
||||||
{!isDownloaded && (
|
{!isDownloaded && (
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{toGibibytes(model.metadata?.size)}
|
{toGigabytes(model.metadata?.size)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!isDownloading && !isDownloaded ? (
|
{!isDownloading && !isDownloaded ? (
|
||||||
|
|||||||
@ -36,46 +36,6 @@ describe('ModelLabel', () => {
|
|||||||
jest.clearAllMocks()
|
jest.clearAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders NotEnoughMemoryLabel when minimumRamModel is greater than totalRam', async () => {
|
|
||||||
mockUseAtomValue
|
|
||||||
.mockReturnValueOnce(0)
|
|
||||||
.mockReturnValueOnce(0)
|
|
||||||
.mockReturnValueOnce(0)
|
|
||||||
mockUseActiveModel.mockReturnValue({
|
|
||||||
activeModel: { metadata: { size: 0 } },
|
|
||||||
})
|
|
||||||
mockUseSettings.mockReturnValue({ settings: { run_mode: 'cpu' } })
|
|
||||||
|
|
||||||
render(<ModelLabel {...defaultProps} />)
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Not enough RAM')).toBeDefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders SlowOnYourDeviceLabel when minimumRamModel is less than totalRam but greater than availableRam', async () => {
|
|
||||||
mockUseAtomValue
|
|
||||||
.mockReturnValueOnce(100)
|
|
||||||
.mockReturnValueOnce(50)
|
|
||||||
.mockReturnValueOnce(10)
|
|
||||||
mockUseActiveModel.mockReturnValue({
|
|
||||||
activeModel: { metadata: { size: 0 } },
|
|
||||||
})
|
|
||||||
mockUseSettings.mockReturnValue({ settings: { run_mode: 'cpu' } })
|
|
||||||
|
|
||||||
const props = {
|
|
||||||
...defaultProps,
|
|
||||||
metadata: {
|
|
||||||
...defaultProps.metadata,
|
|
||||||
size: 50,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
render(<ModelLabel {...props} />)
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Slow on your device')).toBeDefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders nothing when minimumRamModel is less than availableRam', () => {
|
it('renders nothing when minimumRamModel is less than availableRam', () => {
|
||||||
mockUseAtomValue
|
mockUseAtomValue
|
||||||
.mockReturnValueOnce(100)
|
.mockReturnValueOnce(100)
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import { ModelMetadata } from '@janhq/core'
|
|
||||||
import { Badge } from '@janhq/joi'
|
|
||||||
import { useAtomValue } from 'jotai'
|
import { useAtomValue } from 'jotai'
|
||||||
|
|
||||||
import { useActiveModel } from '@/hooks/useActiveModel'
|
import { useActiveModel } from '@/hooks/useActiveModel'
|
||||||
@ -19,18 +17,11 @@ import {
|
|||||||
} from '@/helpers/atoms/SystemBar.atom'
|
} from '@/helpers/atoms/SystemBar.atom'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
metadata: ModelMetadata
|
size?: number
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
}
|
}
|
||||||
const UnsupportedModel = () => {
|
|
||||||
return (
|
|
||||||
<Badge className="space-x-1 rounded-md" theme="warning">
|
|
||||||
<span>Coming Soon</span>
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ModelLabel = ({ metadata, compact }: Props) => {
|
const ModelLabel = ({ size, compact }: Props) => {
|
||||||
const { activeModel } = useActiveModel()
|
const { activeModel } = useActiveModel()
|
||||||
const totalRam = useAtomValue(totalRamAtom)
|
const totalRam = useAtomValue(totalRamAtom)
|
||||||
const usedRam = useAtomValue(usedRamAtom)
|
const usedRam = useAtomValue(usedRamAtom)
|
||||||
@ -59,11 +50,7 @@ const ModelLabel = ({ metadata, compact }: Props) => {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return metadata?.tags?.includes('Coming Soon') ? (
|
return getLabel(size ?? 0)
|
||||||
<UnsupportedModel />
|
|
||||||
) : (
|
|
||||||
getLabel(metadata?.size ?? 0)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default React.memo(ModelLabel)
|
export default React.memo(ModelLabel)
|
||||||
|
|||||||
@ -1,18 +1,16 @@
|
|||||||
import React, { ChangeEvent, useCallback, useState } from 'react'
|
import React, { ChangeEvent, useCallback, useState, useRef } from 'react'
|
||||||
|
|
||||||
import { Input } from '@janhq/joi'
|
import { Input } from '@janhq/joi'
|
||||||
import { useSetAtom } from 'jotai'
|
|
||||||
import { SearchIcon } from 'lucide-react'
|
import { SearchIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { useDebouncedCallback } from 'use-debounce'
|
import { useDebouncedCallback } from 'use-debounce'
|
||||||
|
|
||||||
import { toaster } from '@/containers/Toast'
|
|
||||||
|
|
||||||
import { useGetHFRepoData } from '@/hooks/useGetHFRepoData'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
importHuggingFaceModelStageAtom,
|
useGetModelSources,
|
||||||
importingHuggingFaceRepoDataAtom,
|
useModelSourcesMutation,
|
||||||
} from '@/helpers/atoms/HuggingFace.atom'
|
} from '@/hooks/useModelSource'
|
||||||
|
|
||||||
|
import Spinner from '../Loader/Spinner'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onSearchLocal?: (searchText: string) => void
|
onSearchLocal?: (searchText: string) => void
|
||||||
@ -20,37 +18,28 @@ type Props = {
|
|||||||
|
|
||||||
const ModelSearch = ({ onSearchLocal }: Props) => {
|
const ModelSearch = ({ onSearchLocal }: Props) => {
|
||||||
const [searchText, setSearchText] = useState('')
|
const [searchText, setSearchText] = useState('')
|
||||||
const { getHfRepoData } = useGetHFRepoData()
|
const [isSearching, setSearching] = useState(false)
|
||||||
|
const { mutate } = useGetModelSources()
|
||||||
const setImportingHuggingFaceRepoData = useSetAtom(
|
const { addModelSource } = useModelSourcesMutation()
|
||||||
importingHuggingFaceRepoDataAtom
|
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||||
)
|
|
||||||
const setImportHuggingFaceModelStage = useSetAtom(
|
|
||||||
importHuggingFaceModelStageAtom
|
|
||||||
)
|
|
||||||
|
|
||||||
const debounced = useDebouncedCallback(async () => {
|
const debounced = useDebouncedCallback(async () => {
|
||||||
if (searchText.indexOf('/') === -1) {
|
if (searchText.indexOf('/') === -1) {
|
||||||
// If we don't find / in the text, perform a local search
|
// If we don't find / in the text, perform a local search
|
||||||
onSearchLocal?.(searchText)
|
onSearchLocal?.(searchText)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Attempt to search local
|
||||||
|
onSearchLocal?.(searchText)
|
||||||
|
|
||||||
try {
|
setSearching(true)
|
||||||
const data = await getHfRepoData(searchText)
|
// Attempt to search model source
|
||||||
setImportingHuggingFaceRepoData(data)
|
addModelSource(searchText)
|
||||||
setImportHuggingFaceModelStage('REPO_DETAIL')
|
.then(() => mutate())
|
||||||
} catch (err) {
|
.then(() => onSearchLocal?.(searchText))
|
||||||
let errMessage = 'Unexpected Error'
|
.catch((e) => {
|
||||||
if (err instanceof Error) {
|
console.debug(e)
|
||||||
errMessage = err.message
|
|
||||||
}
|
|
||||||
toaster({
|
|
||||||
title: errMessage,
|
|
||||||
type: 'error',
|
|
||||||
})
|
})
|
||||||
console.error(err)
|
.finally(() => setSearching(false))
|
||||||
}
|
|
||||||
}, 300)
|
}, 300)
|
||||||
|
|
||||||
const onSearchChanged = useCallback(
|
const onSearchChanged = useCallback(
|
||||||
@ -80,13 +69,24 @@ const ModelSearch = ({ onSearchLocal }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
prefixIcon={<SearchIcon size={16} />}
|
ref={inputRef}
|
||||||
placeholder="Search or paste Hugging Face URL"
|
prefixIcon={
|
||||||
|
isSearching ? (
|
||||||
|
<Spinner size={16} strokeWidth={2} />
|
||||||
|
) : (
|
||||||
|
<SearchIcon size={16} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="Search or enter Hugging Face URL"
|
||||||
onChange={onSearchChanged}
|
onChange={onSearchChanged}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
value={searchText}
|
value={searchText}
|
||||||
clearable={searchText.length > 0}
|
clearable={searchText.length > 0}
|
||||||
onClear={onClear}
|
onClear={onClear}
|
||||||
|
className="border-0 bg-[hsla(var(--app-bg))]"
|
||||||
|
onClick={() => {
|
||||||
|
onSearchLocal?.(inputRef.current?.value ?? '')
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,25 +4,21 @@ import { useSetAtom } from 'jotai'
|
|||||||
|
|
||||||
import { useDebouncedCallback } from 'use-debounce'
|
import { useDebouncedCallback } from 'use-debounce'
|
||||||
|
|
||||||
import { useGetHFRepoData } from '@/hooks/useGetHFRepoData'
|
import { MainViewState } from '@/constants/screens'
|
||||||
|
|
||||||
|
import { useModelSourcesMutation } from '@/hooks/useModelSource'
|
||||||
|
|
||||||
import { loadingModalInfoAtom } from '../LoadingModal'
|
import { loadingModalInfoAtom } from '../LoadingModal'
|
||||||
import { toaster } from '../Toast'
|
import { toaster } from '../Toast'
|
||||||
|
|
||||||
import {
|
import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
|
||||||
importHuggingFaceModelStageAtom,
|
import { modelDetailAtom } from '@/helpers/atoms/Model.atom'
|
||||||
importingHuggingFaceRepoDataAtom,
|
|
||||||
} from '@/helpers/atoms/HuggingFace.atom'
|
|
||||||
|
|
||||||
const DeepLinkListener: React.FC = () => {
|
const DeepLinkListener: React.FC = () => {
|
||||||
const { getHfRepoData } = useGetHFRepoData()
|
const { addModelSource } = useModelSourcesMutation()
|
||||||
const setLoadingInfo = useSetAtom(loadingModalInfoAtom)
|
const setLoadingInfo = useSetAtom(loadingModalInfoAtom)
|
||||||
const setImportingHuggingFaceRepoData = useSetAtom(
|
const setMainView = useSetAtom(mainViewStateAtom)
|
||||||
importingHuggingFaceRepoDataAtom
|
const setModelDetail = useSetAtom(modelDetailAtom)
|
||||||
)
|
|
||||||
const setImportHuggingFaceModelStage = useSetAtom(
|
|
||||||
importHuggingFaceModelStageAtom
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleDeepLinkAction = useDebouncedCallback(
|
const handleDeepLinkAction = useDebouncedCallback(
|
||||||
async (deepLinkAction: DeepLinkAction) => {
|
async (deepLinkAction: DeepLinkAction) => {
|
||||||
@ -38,17 +34,17 @@ const DeepLinkListener: React.FC = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setLoadingInfo({
|
setLoadingInfo({
|
||||||
title: 'Getting Hugging Face models',
|
title: 'Getting Hugging Face model details',
|
||||||
message: 'Please wait..',
|
message: 'Please wait..',
|
||||||
})
|
})
|
||||||
const data = await getHfRepoData(deepLinkAction.resource)
|
await addModelSource(deepLinkAction.resource)
|
||||||
setImportingHuggingFaceRepoData(data)
|
|
||||||
setImportHuggingFaceModelStage('REPO_DETAIL')
|
|
||||||
setLoadingInfo(undefined)
|
setLoadingInfo(undefined)
|
||||||
|
setMainView(MainViewState.Hub)
|
||||||
|
setModelDetail(deepLinkAction.resource)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setLoadingInfo(undefined)
|
setLoadingInfo(undefined)
|
||||||
toaster({
|
toaster({
|
||||||
title: 'Failed to get Hugging Face models',
|
title: 'Failed to get Hugging Face model details',
|
||||||
description: err instanceof Error ? err.message : 'Unexpected Error',
|
description: err instanceof Error ? err.message : 'Unexpected Error',
|
||||||
type: 'error',
|
type: 'error',
|
||||||
})
|
})
|
||||||
|
|||||||
@ -30,3 +30,15 @@ export const copyOverInstructionEnabledAtom = atomWithStorage(
|
|||||||
COPY_OVER_INSTRUCTION_ENABLED,
|
COPY_OVER_INSTRUCTION_ENABLED,
|
||||||
false
|
false
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App Hub Banner configured image
|
||||||
|
*/
|
||||||
|
export const appBannerHubAtom = atomWithStorage<string>(
|
||||||
|
'appBannerHub',
|
||||||
|
'./images/HubBanner/banner-8.jpg',
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
getOnInit: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@ -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,
|
InferenceEngine.cortex_tensorrtllm,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atom to store the current model detail page of a certain model id
|
||||||
|
*/
|
||||||
|
export const modelDetailAtom = atom<string | undefined>(undefined)
|
||||||
|
|
||||||
/// End Models Atom
|
/// End Models Atom
|
||||||
/// Model Download Atom
|
/// Model Download Atom
|
||||||
|
|
||||||
|
|||||||
@ -10,12 +10,17 @@ import {
|
|||||||
EngineEvent,
|
EngineEvent,
|
||||||
Model,
|
Model,
|
||||||
ModelEvent,
|
ModelEvent,
|
||||||
|
ModelSource,
|
||||||
|
ModelSibling,
|
||||||
} from '@janhq/core'
|
} from '@janhq/core'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom, useAtomValue } from 'jotai'
|
||||||
import { atomWithStorage } from 'jotai/utils'
|
import { atomWithStorage } from 'jotai/utils'
|
||||||
import useSWR from 'swr'
|
import useSWR from 'swr'
|
||||||
|
|
||||||
|
import { getDescriptionByEngine, getTitleByEngine } from '@/utils/modelEngine'
|
||||||
|
|
||||||
import { extensionManager } from '@/extension/ExtensionManager'
|
import { extensionManager } from '@/extension/ExtensionManager'
|
||||||
|
import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom'
|
||||||
|
|
||||||
export const releasedEnginesCacheAtom = atomWithStorage<{
|
export const releasedEnginesCacheAtom = atomWithStorage<{
|
||||||
data: EngineReleased[]
|
data: EngineReleased[]
|
||||||
@ -415,3 +420,39 @@ export const addRemoteEngineModel = async (name: string, engine: string) => {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remote model sources
|
||||||
|
* @returns A Promise that resolves to an object of model sources.
|
||||||
|
*/
|
||||||
|
export const useGetEngineModelSources = () => {
|
||||||
|
const { engines } = useGetEngines()
|
||||||
|
const downloadedModels = useAtomValue(downloadedModelsAtom)
|
||||||
|
|
||||||
|
return {
|
||||||
|
sources: Object.entries(engines ?? {})
|
||||||
|
?.filter((e) => e?.[1]?.[0]?.type === 'remote')
|
||||||
|
.map(
|
||||||
|
([key, values]) =>
|
||||||
|
({
|
||||||
|
id: key,
|
||||||
|
models: (
|
||||||
|
downloadedModels.filter((e) => e.engine === values[0]?.engine) ??
|
||||||
|
[]
|
||||||
|
).map(
|
||||||
|
(e) =>
|
||||||
|
({
|
||||||
|
id: e.id,
|
||||||
|
size: e.metadata?.size,
|
||||||
|
}) as unknown as ModelSibling
|
||||||
|
),
|
||||||
|
metadata: {
|
||||||
|
id: getTitleByEngine(key as InferenceEngine),
|
||||||
|
description: getDescriptionByEngine(key as InferenceEngine),
|
||||||
|
apiKey: values[0]?.api_key,
|
||||||
|
},
|
||||||
|
type: 'cloud',
|
||||||
|
}) as unknown as ModelSource
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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 getData = useCallback(() => {
|
||||||
const getDownloadedModels = async () => {
|
const getDownloadedModels = async () => {
|
||||||
const localModels = (await getModels()).map((e) => ({
|
const localModels = (await getModels())
|
||||||
...e,
|
.map((e) => ({
|
||||||
name: ModelManager.instance().models.get(e.id)?.name ?? e.name ?? e.id,
|
...e,
|
||||||
metadata:
|
name:
|
||||||
ModelManager.instance().models.get(e.id)?.metadata ?? e.metadata,
|
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()
|
const remoteModels = ModelManager.instance()
|
||||||
.models.values()
|
.models.values()
|
||||||
|
|||||||
@ -40,5 +40,5 @@ const config = {
|
|||||||
// module.exports = createJestConfig(config)
|
// module.exports = createJestConfig(config)
|
||||||
module.exports = async () => ({
|
module.exports = async () => ({
|
||||||
...(await createJestConfig(config)()),
|
...(await createJestConfig(config)()),
|
||||||
transformIgnorePatterns: ['/node_modules/(?!(layerr|nanoid|@uppy|preact)/)'],
|
transformIgnorePatterns: ['/node_modules/(?!((.*))/)'],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -48,6 +48,7 @@
|
|||||||
"rehype-highlight": "^7.0.1",
|
"rehype-highlight": "^7.0.1",
|
||||||
"rehype-highlight-code-lines": "^1.0.4",
|
"rehype-highlight-code-lines": "^1.0.4",
|
||||||
"rehype-katex": "^7.0.1",
|
"rehype-katex": "^7.0.1",
|
||||||
|
"remark-gfm": "^4.0.0",
|
||||||
"remark-math": "^6.0.0",
|
"remark-math": "^6.0.0",
|
||||||
"sass": "^1.69.4",
|
"sass": "^1.69.4",
|
||||||
"slate": "latest",
|
"slate": "latest",
|
||||||
|
|||||||
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 { useCallback } from 'react'
|
||||||
|
|
||||||
import { Model } from '@janhq/core'
|
import { ModelSource } from '@janhq/core'
|
||||||
import { Button, Badge, Tooltip } from '@janhq/joi'
|
|
||||||
|
import { Button, Tooltip, Dropdown, Badge } from '@janhq/joi'
|
||||||
|
|
||||||
import { useAtomValue, useSetAtom } from 'jotai'
|
import { useAtomValue, useSetAtom } from 'jotai'
|
||||||
|
|
||||||
import { ChevronDownIcon } from 'lucide-react'
|
import { ChevronDownIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { twMerge } from 'tailwind-merge'
|
|
||||||
|
|
||||||
import ModalCancelDownload from '@/containers/ModalCancelDownload'
|
import ModalCancelDownload from '@/containers/ModalCancelDownload'
|
||||||
|
|
||||||
import ModelLabel from '@/containers/ModelLabel'
|
|
||||||
|
|
||||||
import { toaster } from '@/containers/Toast'
|
|
||||||
|
|
||||||
import { MainViewState } from '@/constants/screens'
|
import { MainViewState } from '@/constants/screens'
|
||||||
|
|
||||||
import { useCreateNewThread } from '@/hooks/useCreateNewThread'
|
import { useCreateNewThread } from '@/hooks/useCreateNewThread'
|
||||||
@ -22,7 +16,9 @@ import useDownloadModel from '@/hooks/useDownloadModel'
|
|||||||
|
|
||||||
import { useSettings } from '@/hooks/useSettings'
|
import { useSettings } from '@/hooks/useSettings'
|
||||||
|
|
||||||
import { toGibibytes } from '@/utils/converter'
|
import { toGigabytes } from '@/utils/converter'
|
||||||
|
|
||||||
|
import { extractModelName } from '@/utils/modelSource'
|
||||||
|
|
||||||
import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
|
import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
|
||||||
import { assistantsAtom } from '@/helpers/atoms/Assistant.atom'
|
import { assistantsAtom } from '@/helpers/atoms/Assistant.atom'
|
||||||
@ -32,25 +28,25 @@ import {
|
|||||||
downloadedModelsAtom,
|
downloadedModelsAtom,
|
||||||
getDownloadingModelAtom,
|
getDownloadingModelAtom,
|
||||||
} from '@/helpers/atoms/Model.atom'
|
} from '@/helpers/atoms/Model.atom'
|
||||||
|
import { selectedSettingAtom } from '@/helpers/atoms/Setting.atom'
|
||||||
import {
|
import {
|
||||||
nvidiaTotalVramAtom,
|
nvidiaTotalVramAtom,
|
||||||
totalRamAtom,
|
totalRamAtom,
|
||||||
} from '@/helpers/atoms/SystemBar.atom'
|
} from '@/helpers/atoms/SystemBar.atom'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
model: Model
|
model: ModelSource
|
||||||
onClick: () => void
|
onSelectedModel: () => void
|
||||||
open: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ModelItemHeader = ({ model, onClick, open }: Props) => {
|
const ModelItemHeader = ({ model, onSelectedModel }: Props) => {
|
||||||
const { downloadModel } = useDownloadModel()
|
const { downloadModel } = useDownloadModel()
|
||||||
const downloadingModels = useAtomValue(getDownloadingModelAtom)
|
const downloadingModels = useAtomValue(getDownloadingModelAtom)
|
||||||
const downloadedModels = useAtomValue(downloadedModelsAtom)
|
const downloadedModels = useAtomValue(downloadedModelsAtom)
|
||||||
|
const setSelectedSetting = useSetAtom(selectedSettingAtom)
|
||||||
const { requestCreateNewThread } = useCreateNewThread()
|
const { requestCreateNewThread } = useCreateNewThread()
|
||||||
const totalRam = useAtomValue(totalRamAtom)
|
const totalRam = useAtomValue(totalRamAtom)
|
||||||
const { settings } = useSettings()
|
const { settings } = useSettings()
|
||||||
// const [imageLoaded, setImageLoaded] = useState(true)
|
|
||||||
|
|
||||||
const nvidiaTotalVram = useAtomValue(nvidiaTotalVramAtom)
|
const nvidiaTotalVram = useAtomValue(nvidiaTotalVramAtom)
|
||||||
const setMainViewState = useSetAtom(mainViewStateAtom)
|
const setMainViewState = useSetAtom(mainViewStateAtom)
|
||||||
@ -64,36 +60,68 @@ const ModelItemHeader = ({ model, onClick, open }: Props) => {
|
|||||||
const assistants = useAtomValue(assistantsAtom)
|
const assistants = useAtomValue(assistantsAtom)
|
||||||
|
|
||||||
const onDownloadClick = useCallback(() => {
|
const onDownloadClick = useCallback(() => {
|
||||||
downloadModel(model.sources[0].url, model.id, model.name)
|
downloadModel(model.models?.[0].id)
|
||||||
}, [model, downloadModel])
|
}, [model, downloadModel])
|
||||||
|
|
||||||
const isDownloaded = downloadedModels.find((md) => md.id === model.id) != null
|
const isDownloaded = downloadedModels.some((md) =>
|
||||||
|
model.models.some((m) => m.id === md.id)
|
||||||
let downloadButton = (
|
|
||||||
<Button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
onDownloadClick()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Download
|
|
||||||
</Button>
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const isDownloading = downloadingModels.some((md) => md === model.id)
|
let downloadButton = (
|
||||||
|
<div className="group flex h-8 cursor-pointer items-center justify-center rounded-md bg-[hsla(var(--primary-bg))]">
|
||||||
|
<div
|
||||||
|
className="flex h-full items-center rounded-l-md duration-200 hover:backdrop-brightness-75"
|
||||||
|
onClick={onDownloadClick}
|
||||||
|
>
|
||||||
|
<span className="mx-4 font-medium text-white">Download</span>
|
||||||
|
</div>
|
||||||
|
<Dropdown
|
||||||
|
className="z-50 min-w-[240px]"
|
||||||
|
options={model.models?.map((e) => ({
|
||||||
|
name: (
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<span className="line-clamp-1 max-w-[340px] font-normal">
|
||||||
|
{e.id}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
theme="secondary"
|
||||||
|
className="inline-flex w-[60px] items-center font-medium"
|
||||||
|
>
|
||||||
|
<span>Default</span>
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
value: e.id,
|
||||||
|
suffix: toGigabytes(e.size),
|
||||||
|
}))}
|
||||||
|
onValueChanged={(e) => downloadModel(e)}
|
||||||
|
>
|
||||||
|
<div className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-r-md border-l border-blue-500 duration-200 hover:backdrop-brightness-75">
|
||||||
|
<ChevronDownIcon size={14} color="white" />
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const isDownloading = downloadingModels.some((md) =>
|
||||||
|
model.models.some((m) => m.id === md)
|
||||||
|
)
|
||||||
|
|
||||||
const onUseModelClick = useCallback(async () => {
|
const onUseModelClick = useCallback(async () => {
|
||||||
if (assistants.length === 0) {
|
const downloadedModel = downloadedModels.find((e) =>
|
||||||
toaster({
|
model.models.some((m) => m.id === e.id)
|
||||||
title: 'No assistant available.',
|
)
|
||||||
description: `Could not use Model ${model.name} as no assistant is available.`,
|
if (downloadedModel) {
|
||||||
type: 'error',
|
await requestCreateNewThread(assistants[0], downloadedModel)
|
||||||
})
|
setMainViewState(MainViewState.Thread)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
await requestCreateNewThread(assistants[0], model)
|
}, [
|
||||||
setMainViewState(MainViewState.Thread)
|
assistants,
|
||||||
}, [assistants, model, requestCreateNewThread, setMainViewState])
|
model,
|
||||||
|
requestCreateNewThread,
|
||||||
|
setMainViewState,
|
||||||
|
downloadedModels,
|
||||||
|
])
|
||||||
|
|
||||||
if (isDownloaded) {
|
if (isDownloaded) {
|
||||||
downloadButton = (
|
downloadButton = (
|
||||||
@ -104,6 +132,7 @@ const ModelItemHeader = ({ model, onClick, open }: Props) => {
|
|||||||
disabled={serverEnabled}
|
disabled={serverEnabled}
|
||||||
data-testid={`use-model-btn-${model.id}`}
|
data-testid={`use-model-btn-${model.id}`}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
theme="ghost"
|
||||||
className="min-w-[98px]"
|
className="min-w-[98px]"
|
||||||
>
|
>
|
||||||
Use
|
Use
|
||||||
@ -114,54 +143,54 @@ const ModelItemHeader = ({ model, onClick, open }: Props) => {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
} else if (isDownloading) {
|
} else if (isDownloading) {
|
||||||
downloadButton = <ModalCancelDownload model={model} />
|
downloadButton = (
|
||||||
|
<ModalCancelDownload
|
||||||
|
modelId={
|
||||||
|
downloadingModels.find((e) => model.models.some((m) => m.id === e)) ??
|
||||||
|
model.id
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="mb-2 rounded-t-md bg-[hsla(var(--app-bg))]">
|
||||||
className="cursor-pointer rounded-t-md bg-[hsla(var(--app-bg))]"
|
<div className="flex items-center justify-between py-2">
|
||||||
onClick={onClick}
|
<div className="group flex cursor-pointer items-center gap-2">
|
||||||
>
|
<span
|
||||||
<div className="flex items-center justify-between px-4 py-2">
|
className="line-clamp-1 text-base font-medium capitalize group-hover:text-blue-500 group-hover:underline"
|
||||||
<div className="flex items-center gap-2">
|
onClick={onSelectedModel}
|
||||||
<span className="line-clamp-1 text-base font-semibold">
|
>
|
||||||
{model.name}
|
{extractModelName(model.metadata?.id)}
|
||||||
</span>
|
</span>
|
||||||
<EngineBadge engine={model.engine} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="inline-flex items-center space-x-2">
|
<div className="inline-flex items-center space-x-2">
|
||||||
<div className="hidden items-center sm:inline-flex">
|
<div className="hidden items-center sm:inline-flex">
|
||||||
<span className="mr-4 font-semibold">
|
<span className="mr-4 text-sm font-light text-[hsla(var(--text-secondary))]">
|
||||||
{toGibibytes(model.metadata?.size)}
|
{toGigabytes(model.models?.[0]?.size)}
|
||||||
</span>
|
</span>
|
||||||
<ModelLabel metadata={model.metadata} />
|
|
||||||
</div>
|
</div>
|
||||||
{downloadButton}
|
{model.type !== 'cloud' ? (
|
||||||
<ChevronDownIcon
|
downloadButton
|
||||||
className={twMerge(
|
) : (
|
||||||
'h-5 w-5 flex-none',
|
<>
|
||||||
open === model.id && 'rotate-180'
|
{!model.metadata?.apiKey?.length && (
|
||||||
)}
|
<Button
|
||||||
/>
|
data-testid="setup-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedSetting(model.id)
|
||||||
|
setMainViewState(MainViewState.Settings)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Set Up
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type EngineBadgeProps = {
|
|
||||||
engine: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const EngineBadge = ({ engine }: EngineBadgeProps) => {
|
|
||||||
const title = 'TensorRT-LLM'
|
|
||||||
|
|
||||||
switch (engine) {
|
|
||||||
case 'nitro-tensorrt-llm':
|
|
||||||
return <Badge title={title}>{title}</Badge>
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ModelItemHeader
|
export default ModelItemHeader
|
||||||
|
|||||||
@ -1,98 +1,76 @@
|
|||||||
import { useState } from 'react'
|
import Markdown from 'react-markdown'
|
||||||
|
|
||||||
import { Model } from '@janhq/core'
|
import Image from 'next/image'
|
||||||
import { Badge } from '@janhq/joi'
|
|
||||||
|
|
||||||
import { twMerge } from 'tailwind-merge'
|
import { ModelSource } from '@janhq/core'
|
||||||
|
|
||||||
|
import { DownloadIcon, FileJson } from 'lucide-react'
|
||||||
|
|
||||||
import ModelLabel from '@/containers/ModelLabel'
|
import ModelLabel from '@/containers/ModelLabel'
|
||||||
|
|
||||||
import ModelItemHeader from '@/screens/Hub/ModelList/ModelHeader'
|
import ModelItemHeader from '@/screens/Hub/ModelList/ModelHeader'
|
||||||
|
|
||||||
import { toGibibytes } from '@/utils/converter'
|
import { toGigabytes } from '@/utils/converter'
|
||||||
|
import { extractDescription } from '@/utils/modelSource'
|
||||||
|
import '@/styles/components/model.scss'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
model: Model
|
model: ModelSource
|
||||||
|
onSelectedModel: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const ModelItem: React.FC<Props> = ({ model }) => {
|
const ModelItem: React.FC<Props> = ({ model, onSelectedModel }) => {
|
||||||
const [open, setOpen] = useState('')
|
|
||||||
|
|
||||||
const handleToggle = () => {
|
|
||||||
if (open === model.id) {
|
|
||||||
setOpen('')
|
|
||||||
} else {
|
|
||||||
setOpen(model.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-6 flex flex-col overflow-hidden rounded-xl border border-[hsla(var(--app-border))]">
|
<div className="mb-6 flex w-full flex-col overflow-hidden border-b border-[hsla(var(--app-border))] py-4">
|
||||||
<ModelItemHeader model={model} onClick={handleToggle} open={open} />
|
<ModelItemHeader model={model} onSelectedModel={onSelectedModel} />
|
||||||
{open === model.id && (
|
|
||||||
<div className="flex">
|
|
||||||
<div className="flex w-full 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="hidden w-48 flex-shrink-0 border-l border-t border-[hsla(var(--app-border))] p-4">
|
<div className="flex w-full">
|
||||||
<div>
|
<div className="flex w-full flex-col ">
|
||||||
<span className="font-semibold ">Format</span>
|
<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
|
<p
|
||||||
className={twMerge(
|
className="font-regular mt-3 line-clamp-1 flex flex-row pr-4 capitalize text-[hsla(var(--text-secondary))]"
|
||||||
'mt-2 font-medium',
|
title={model.metadata?.author}
|
||||||
!model.format?.includes(' ') &&
|
|
||||||
!model.format?.includes('-') &&
|
|
||||||
'uppercase'
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{model.format}
|
{model.id?.includes('huggingface.co') && (
|
||||||
|
<>
|
||||||
|
<Image
|
||||||
|
src={'icons/huggingFace.svg'}
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
className="mr-2"
|
||||||
|
alt=""
|
||||||
|
/>{' '}
|
||||||
|
</>
|
||||||
|
)}{' '}
|
||||||
|
{model.metadata?.author}
|
||||||
</p>
|
</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>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,53 +1,22 @@
|
|||||||
import { useMemo } from 'react'
|
import { ModelSource } from '@janhq/core'
|
||||||
|
|
||||||
import { Model } from '@janhq/core'
|
|
||||||
|
|
||||||
import { useAtomValue } from 'jotai'
|
|
||||||
|
|
||||||
import { useGetEngines } from '@/hooks/useEngineManagement'
|
|
||||||
|
|
||||||
import ModelItem from '@/screens/Hub/ModelList/ModelItem'
|
import ModelItem from '@/screens/Hub/ModelList/ModelItem'
|
||||||
|
|
||||||
import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
models: Model[]
|
models: ModelSource[]
|
||||||
|
onSelectedModel: (model: ModelSource) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const ModelList = ({ models }: Props) => {
|
const ModelList = ({ models, onSelectedModel }: Props) => {
|
||||||
const downloadedModels = useAtomValue(downloadedModelsAtom)
|
|
||||||
const { engines } = useGetEngines()
|
|
||||||
const sortedModels: Model[] = useMemo(() => {
|
|
||||||
const featuredModels: Model[] = []
|
|
||||||
const remoteModels: Model[] = []
|
|
||||||
const localModels: Model[] = []
|
|
||||||
const remainingModels: Model[] = []
|
|
||||||
models.forEach((m) => {
|
|
||||||
if (m.metadata?.tags?.includes('Featured')) {
|
|
||||||
featuredModels.push(m)
|
|
||||||
} else if (engines?.[m.engine]?.[0]?.type === 'remote') {
|
|
||||||
remoteModels.push(m)
|
|
||||||
} else if (downloadedModels.map((m) => m.id).includes(m.id)) {
|
|
||||||
localModels.push(m)
|
|
||||||
} else {
|
|
||||||
remainingModels.push(m)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
featuredModels.sort((m1, m2) => m1.metadata?.size - m2.metadata?.size)
|
|
||||||
localModels.sort((m1, m2) => m1.metadata?.size - m2.metadata?.size)
|
|
||||||
remainingModels.sort((m1, m2) => m1.metadata?.size - m2.metadata?.size)
|
|
||||||
remoteModels.sort((m1, m2) => m1.name.localeCompare(m2.name))
|
|
||||||
return [
|
|
||||||
...featuredModels,
|
|
||||||
...localModels,
|
|
||||||
...remainingModels,
|
|
||||||
...remoteModels,
|
|
||||||
]
|
|
||||||
}, [models, downloadedModels, engines])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full w-full flex-shrink-0">
|
<div className="relative h-full w-full flex-shrink-0">
|
||||||
{sortedModels?.map((model) => <ModelItem key={model.id} model={model} />)}
|
{models.map((model) => (
|
||||||
|
<ModelItem
|
||||||
|
key={model.id}
|
||||||
|
model={model}
|
||||||
|
onSelectedModel={() => onSelectedModel(model)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
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 Image from 'next/image'
|
||||||
|
|
||||||
import { ScrollArea, Button, Select } from '@janhq/joi'
|
import { ModelSource } from '@janhq/core'
|
||||||
|
|
||||||
import { useAtomValue, useSetAtom } from 'jotai'
|
import { ScrollArea, Button, Select, Tabs, useClickOutside } from '@janhq/joi'
|
||||||
import { UploadIcon } from 'lucide-react'
|
import { motion as m } from 'framer-motion'
|
||||||
|
|
||||||
|
import { useAtom, useSetAtom } from 'jotai'
|
||||||
|
import { ImagePlusIcon, UploadCloudIcon, UploadIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
import BlankState from '@/containers/BlankState'
|
|
||||||
import CenterPanelContainer from '@/containers/CenterPanelContainer'
|
import CenterPanelContainer from '@/containers/CenterPanelContainer'
|
||||||
import ModelSearch from '@/containers/ModelSearch'
|
import ModelSearch from '@/containers/ModelSearch'
|
||||||
|
|
||||||
|
import { useGetEngineModelSources } from '@/hooks/useEngineManagement'
|
||||||
import { setImportModelStageAtom } from '@/hooks/useImportModel'
|
import { setImportModelStageAtom } from '@/hooks/useImportModel'
|
||||||
|
|
||||||
|
import {
|
||||||
|
useGetModelSources,
|
||||||
|
useModelSourcesMutation,
|
||||||
|
} from '@/hooks/useModelSource'
|
||||||
|
|
||||||
import ModelList from '@/screens/Hub/ModelList'
|
import ModelList from '@/screens/Hub/ModelList'
|
||||||
|
|
||||||
import {
|
import { extractModelRepo } from '@/utils/modelSource'
|
||||||
configuredModelsAtom,
|
import { fuzzySearch } from '@/utils/search'
|
||||||
downloadedModelsAtom,
|
|
||||||
} from '@/helpers/atoms/Model.atom'
|
import ModelPage from './ModelPage'
|
||||||
|
|
||||||
|
import { appBannerHubAtom } from '@/helpers/atoms/App.atom'
|
||||||
|
import { modelDetailAtom } from '@/helpers/atoms/Model.atom'
|
||||||
|
|
||||||
const sortMenus = [
|
const sortMenus = [
|
||||||
{
|
{
|
||||||
name: 'All Models',
|
name: 'Most downloaded',
|
||||||
value: 'all-models',
|
value: 'most-downloaded',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Featured',
|
name: 'Newest',
|
||||||
value: 'featured',
|
value: 'newest',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const filterOptions = [
|
||||||
|
{
|
||||||
|
name: 'All',
|
||||||
|
value: 'all',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Downloaded',
|
name: 'On-device',
|
||||||
value: 'downloaded',
|
value: 'on-device',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Cloud',
|
||||||
|
value: 'cloud',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const HubScreen = () => {
|
const HubScreen = () => {
|
||||||
const configuredModels = useAtomValue(configuredModelsAtom)
|
const { sources } = useGetModelSources()
|
||||||
const downloadedModels = useAtomValue(downloadedModelsAtom)
|
const { sources: remoteModelSources } = useGetEngineModelSources()
|
||||||
const [searchValue, setsearchValue] = useState('')
|
const { addModelSource } = useModelSourcesMutation()
|
||||||
const [sortSelected, setSortSelected] = useState('all-models')
|
const [searchValue, setSearchValue] = useState('')
|
||||||
|
const [sortSelected, setSortSelected] = useState('newest')
|
||||||
|
const [filterOption, setFilterOption] = useState('all')
|
||||||
|
const [hubBannerOption, setHubBannerOption] = useState('gallery')
|
||||||
|
const [showHubBannerSetting, setShowHubBannerSetting] = useState(false)
|
||||||
|
const [appBannerHub, setAppBannerHub] = useAtom(appBannerHubAtom)
|
||||||
|
const [selectedModel, setSelectedModel] = useState<ModelSource | undefined>(
|
||||||
|
undefined
|
||||||
|
)
|
||||||
|
const [modelDetail, setModelDetail] = useAtom(modelDetailAtom)
|
||||||
const setImportModelStage = useSetAtom(setImportModelStageAtom)
|
const setImportModelStage = useSetAtom(setImportModelStageAtom)
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
const imageInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const hubBannerSettingRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const filteredModels = configuredModels.filter((x) => {
|
const searchedModels = useMemo(
|
||||||
if (sortSelected === 'downloaded') {
|
() =>
|
||||||
return (
|
searchValue.length
|
||||||
x.name.toLowerCase().includes(searchValue.toLowerCase()) &&
|
? (sources?.filter((e) =>
|
||||||
downloadedModels.some((y) => y.id === x.id)
|
fuzzySearch(
|
||||||
)
|
searchValue.replaceAll(' ', '').toLowerCase(),
|
||||||
} else if (sortSelected === 'featured') {
|
e.id.toLowerCase()
|
||||||
return (
|
)
|
||||||
x.name.toLowerCase().includes(searchValue.toLowerCase()) &&
|
) ?? [])
|
||||||
x.metadata?.tags?.includes('Featured')
|
: [],
|
||||||
)
|
[sources, searchValue]
|
||||||
} else {
|
)
|
||||||
return x.name.toLowerCase().includes(searchValue.toLowerCase())
|
|
||||||
|
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(() => {
|
const onImportModelClick = useCallback(() => {
|
||||||
setImportModelStage('SELECTING_MODEL')
|
setImportModelStage('SELECTING_MODEL')
|
||||||
}, [setImportModelStage])
|
}, [setImportModelStage])
|
||||||
|
|
||||||
const onSearchUpdate = useCallback((input: string) => {
|
const onSearchUpdate = useCallback((input: string) => {
|
||||||
setsearchValue(input)
|
setSearchValue(input)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const setBannerHubImage = (image: string) => {
|
||||||
|
setShowHubBannerSetting(false)
|
||||||
|
setAppBannerHub(image)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the change event of the extension file input element by setting the file name state.
|
||||||
|
* Its to be used to display the extension file name of the selected file.
|
||||||
|
* @param event - The change event object.
|
||||||
|
*/
|
||||||
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
const fileType = file.type
|
||||||
|
if (!fileType.startsWith('image/')) {
|
||||||
|
alert('Please upload an image file.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
// FileReader result is already in a valid Base64 format
|
||||||
|
setBannerHubImage(reader.result as string)
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { isDragActive, getRootProps } = useDropzone({
|
||||||
|
noClick: true,
|
||||||
|
multiple: true,
|
||||||
|
accept: {
|
||||||
|
'image/jpeg': ['.jpeg'],
|
||||||
|
'image/png': ['.png'],
|
||||||
|
'image/jpg': ['.jpg'],
|
||||||
|
},
|
||||||
|
onDrop: (files) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
// FileReader result is already in a valid Base64 format
|
||||||
|
setBannerHubImage(reader.result as string)
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(files[0])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CenterPanelContainer>
|
<CenterPanelContainer>
|
||||||
<ScrollArea data-testid="hub-container-test-id" className="h-full w-full">
|
<m.div
|
||||||
<div className="relative h-40 p-4 sm:h-auto">
|
key={selectedModel?.id}
|
||||||
<Image
|
initial={{ opacity: 0, y: -8 }}
|
||||||
src="./images/hub-banner.png"
|
className="h-full"
|
||||||
alt="Hub Banner"
|
animate={{
|
||||||
width={800}
|
opacity: 1,
|
||||||
height={800}
|
y: 0,
|
||||||
className="h-full w-full rounded-lg object-cover"
|
transition: {
|
||||||
/>
|
duration: 0.25,
|
||||||
<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} />
|
{!selectedModel && (
|
||||||
</div>
|
<ScrollArea
|
||||||
<div className="flex-shrink-0">
|
data-testid="hub-container-test-id"
|
||||||
<Button onClick={onImportModelClick}>
|
className="h-full w-full"
|
||||||
<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" />
|
|
||||||
) : (
|
|
||||||
<>
|
<>
|
||||||
<div className="mb-4 flex w-full justify-end">
|
<div className="relative h-40 p-4 sm:h-auto">
|
||||||
<Select
|
<div className="group">
|
||||||
value={sortSelected}
|
<Image
|
||||||
onValueChange={(value) => {
|
src={appBannerHub}
|
||||||
setSortSelected(value)
|
alt="Hub Banner"
|
||||||
}}
|
width={800}
|
||||||
options={sortMenus}
|
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>
|
</div>
|
||||||
<ModelList models={filteredModels} />
|
|
||||||
</>
|
</>
|
||||||
)}
|
</ScrollArea>
|
||||||
</div>
|
)}
|
||||||
</ScrollArea>
|
{selectedModel && (
|
||||||
|
<ModelPage
|
||||||
|
model={selectedModel}
|
||||||
|
onGoBack={() => {
|
||||||
|
setSearchValue('')
|
||||||
|
setSelectedModel(undefined)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</m.div>
|
||||||
</CenterPanelContainer>
|
</CenterPanelContainer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import useImportModel, {
|
|||||||
setImportModelStageAtom,
|
setImportModelStageAtom,
|
||||||
} from '@/hooks/useImportModel'
|
} from '@/hooks/useImportModel'
|
||||||
|
|
||||||
import { toGibibytes } from '@/utils/converter'
|
import { toGigabytes } from '@/utils/converter'
|
||||||
|
|
||||||
import { openFileTitle } from '@/utils/titleUtils'
|
import { openFileTitle } from '@/utils/titleUtils'
|
||||||
|
|
||||||
@ -126,7 +126,7 @@ const EditModelInfoModal = () => {
|
|||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
<p>{editingModel.name}</p>
|
<p>{editingModel.name}</p>
|
||||||
<div className="flex flex-row">
|
<div className="flex flex-row">
|
||||||
<span className="mr-2">{toGibibytes(editingModel.size)}</span>
|
<span className="mr-2">{toGigabytes(editingModel.size)}</span>
|
||||||
<div className="flex flex-row space-x-1">
|
<div className="flex flex-row space-x-1">
|
||||||
<span className="font-semibold">Format:</span>
|
<span className="font-semibold">Format:</span>
|
||||||
<span className="font-normal">
|
<span className="font-normal">
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { Button, Switch } from '@janhq/joi'
|
|||||||
import { useAtom, useSetAtom } from 'jotai'
|
import { useAtom, useSetAtom } from 'jotai'
|
||||||
import { SettingsIcon } from 'lucide-react'
|
import { SettingsIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { getTitleByEngine } from '@/utils/modelEngine'
|
import { getDescriptionByEngine, getTitleByEngine } from '@/utils/modelEngine'
|
||||||
|
|
||||||
import { showSettingActiveRemoteEngineAtom } from '@/helpers/atoms/Extension.atom'
|
import { showSettingActiveRemoteEngineAtom } from '@/helpers/atoms/Extension.atom'
|
||||||
import { selectedSettingAtom } from '@/helpers/atoms/Setting.atom'
|
import { selectedSettingAtom } from '@/helpers/atoms/Setting.atom'
|
||||||
@ -48,10 +48,7 @@ const RemoteEngineItems = ({
|
|||||||
</h6>
|
</h6>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 w-full font-medium leading-relaxed text-[hsla(var(--text-secondary))]">
|
<div className="mt-2 w-full font-medium leading-relaxed text-[hsla(var(--text-secondary))]">
|
||||||
<p>
|
<p>{getDescriptionByEngine(engine as InferenceEngine)}</p>
|
||||||
Access models from {getTitleByEngine(engine as InferenceEngine)}{' '}
|
|
||||||
via their API.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
/* eslint-disable react/no-unescaped-entities */
|
/* eslint-disable react/no-unescaped-entities */
|
||||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
|
|
||||||
import React, { useCallback, useRef, useState, useEffect } from 'react'
|
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 { setImportModelStageAtom } from '@/hooks/useImportModel'
|
||||||
|
|
||||||
import { toGibibytes } from '@/utils/converter'
|
import { toGigabytes } from '@/utils/converter'
|
||||||
|
|
||||||
import { editingModelIdAtom } from '../EditModelInfoModal'
|
import { editingModelIdAtom } from '../EditModelInfoModal'
|
||||||
import ImportInProgressIcon from '../ImportInProgressIcon'
|
import ImportInProgressIcon from '../ImportInProgressIcon'
|
||||||
@ -32,7 +32,7 @@ const ImportingModelItem = ({ model }: Props) => {
|
|||||||
if (model.status === 'FAILED') {
|
if (model.status === 'FAILED') {
|
||||||
return 'Failed'
|
return 'Failed'
|
||||||
} else {
|
} else {
|
||||||
return toGibibytes(model.size)
|
return toGigabytes(model.size)
|
||||||
}
|
}
|
||||||
}, [model.status, model.size])
|
}, [model.status, model.size])
|
||||||
|
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import useDeleteModel from '@/hooks/useDeleteModel'
|
|||||||
|
|
||||||
import { useGetEngines } from '@/hooks/useEngineManagement'
|
import { useGetEngines } from '@/hooks/useEngineManagement'
|
||||||
|
|
||||||
import { toGibibytes } from '@/utils/converter'
|
import { toGigabytes } from '@/utils/converter'
|
||||||
|
|
||||||
import { isLocalEngine } from '@/utils/modelEngine'
|
import { isLocalEngine } from '@/utils/modelEngine'
|
||||||
|
|
||||||
@ -80,7 +80,7 @@ const MyModelList = ({ model }: Props) => {
|
|||||||
<div className="flex gap-x-4">
|
<div className="flex gap-x-4">
|
||||||
<div className="md:min-w-[90px] md:max-w-[90px]">
|
<div className="md:min-w-[90px] md:max-w-[90px]">
|
||||||
<Badge theme="secondary" className="sm:mr-8">
|
<Badge theme="secondary" className="sm:mr-8">
|
||||||
{model.metadata?.size ? toGibibytes(model.metadata?.size) : '-'}
|
{model.metadata?.size ? toGigabytes(model.metadata?.size) : '-'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -26,7 +26,7 @@ import { modelDownloadStateAtom } from '@/hooks/useDownloadState'
|
|||||||
|
|
||||||
import { useGetEngines } from '@/hooks/useEngineManagement'
|
import { useGetEngines } from '@/hooks/useEngineManagement'
|
||||||
|
|
||||||
import { formatDownloadPercentage, toGibibytes } from '@/utils/converter'
|
import { formatDownloadPercentage, toGigabytes } from '@/utils/converter'
|
||||||
import { manualRecommendationModel } from '@/utils/model'
|
import { manualRecommendationModel } from '@/utils/model'
|
||||||
import {
|
import {
|
||||||
getLogoEngine,
|
getLogoEngine,
|
||||||
@ -112,7 +112,10 @@ const OnDeviceStarterScreen = ({ isShowStarterScreen }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<CenterPanelContainer isShowStarterScreen={isShowStarterScreen}>
|
<CenterPanelContainer isShowStarterScreen={isShowStarterScreen}>
|
||||||
<ScrollArea className="flex h-full w-full items-center">
|
<ScrollArea
|
||||||
|
className="flex h-full w-full items-center"
|
||||||
|
data-testid="onboard-screen"
|
||||||
|
>
|
||||||
<div className="relative mt-4 flex h-full w-full flex-col items-center justify-center">
|
<div className="relative mt-4 flex h-full w-full flex-col items-center justify-center">
|
||||||
<div className="mx-auto flex h-full w-3/4 flex-col items-center justify-center py-16 text-center">
|
<div className="mx-auto flex h-full w-3/4 flex-col items-center justify-center py-16 text-center">
|
||||||
<LogoMark
|
<LogoMark
|
||||||
@ -162,11 +165,11 @@ const OnDeviceStarterScreen = ({ isShowStarterScreen }: Props) => {
|
|||||||
>
|
>
|
||||||
{model.name}
|
{model.name}
|
||||||
</p>
|
</p>
|
||||||
<ModelLabel metadata={model.metadata} compact />
|
<ModelLabel size={model.metadata?.size} compact />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-[hsla(var(--text-tertiary))]">
|
<div className="flex items-center gap-2 text-[hsla(var(--text-tertiary))]">
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{toGibibytes(model.metadata?.size)}
|
{toGigabytes(model.metadata?.size)}
|
||||||
</span>
|
</span>
|
||||||
{!isDownloading ? (
|
{!isDownloading ? (
|
||||||
<DownloadCloudIcon
|
<DownloadCloudIcon
|
||||||
@ -259,7 +262,7 @@ const OnDeviceStarterScreen = ({ isShowStarterScreen }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<span className="text-[hsla(var(--text-secondary))]">
|
<span className="text-[hsla(var(--text-secondary))]">
|
||||||
{toGibibytes(featModel.metadata?.size)}
|
{toGigabytes(featModel.metadata?.size)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -278,7 +281,7 @@ const OnDeviceStarterScreen = ({ isShowStarterScreen }: Props) => {
|
|||||||
Download
|
Download
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-[hsla(var(--text-secondary))]">
|
<span className="text-[hsla(var(--text-secondary))]">
|
||||||
{toGibibytes(featModel.metadata?.size)}
|
{toGigabytes(featModel.metadata?.size)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { XIcon } from 'lucide-react'
|
|||||||
|
|
||||||
import { currentPromptAtom, fileUploadAtom } from '@/containers/Providers/Jotai'
|
import { currentPromptAtom, fileUploadAtom } from '@/containers/Providers/Jotai'
|
||||||
|
|
||||||
import { toGibibytes } from '@/utils/converter'
|
import { toGigabytes } from '@/utils/converter'
|
||||||
|
|
||||||
import Icon from './Icon'
|
import Icon from './Icon'
|
||||||
|
|
||||||
@ -30,7 +30,7 @@ const FileUploadPreview = () => {
|
|||||||
{fileUpload?.file.name.replaceAll(/[-._]/g, ' ')}
|
{fileUpload?.file.name.replaceAll(/[-._]/g, ' ')}
|
||||||
</h6>
|
</h6>
|
||||||
<p className="text-[hsla(var(--text-secondary)]">
|
<p className="text-[hsla(var(--text-secondary)]">
|
||||||
{toGibibytes(fileUpload?.file.size)}
|
{toGigabytes(fileUpload?.file.size)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { memo } from 'react'
|
|||||||
|
|
||||||
import { usePath } from '@/hooks/usePath'
|
import { usePath } from '@/hooks/usePath'
|
||||||
|
|
||||||
import { toGibibytes } from '@/utils/converter'
|
import { toGigabytes } from '@/utils/converter'
|
||||||
|
|
||||||
import Icon from '../FileUploadPreview/Icon'
|
import Icon from '../FileUploadPreview/Icon'
|
||||||
|
|
||||||
@ -31,7 +31,7 @@ const DocMessage = ({
|
|||||||
</h6>
|
</h6>
|
||||||
<p className="text-[hsla(var(--text-secondary)] line-clamp-1 overflow-hidden truncate">
|
<p className="text-[hsla(var(--text-secondary)] line-clamp-1 overflow-hidden truncate">
|
||||||
{metadata && 'size' in metadata
|
{metadata && 'size' in metadata
|
||||||
? toGibibytes(Number(metadata.size))
|
? toGigabytes(Number(metadata.size))
|
||||||
: id}
|
: id}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -5,10 +5,12 @@ import React, { memo } from 'react'
|
|||||||
|
|
||||||
import Markdown from 'react-markdown'
|
import Markdown from 'react-markdown'
|
||||||
|
|
||||||
|
import { PluggableList } from 'react-markdown/lib'
|
||||||
import rehypeHighlight from 'rehype-highlight'
|
import rehypeHighlight from 'rehype-highlight'
|
||||||
import rehypeHighlightCodeLines from 'rehype-highlight-code-lines'
|
import rehypeHighlightCodeLines from 'rehype-highlight-code-lines'
|
||||||
|
|
||||||
import rehypeKatex from 'rehype-katex'
|
import rehypeKatex from 'rehype-katex'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
|
|
||||||
import remarkMath from 'remark-math'
|
import remarkMath from 'remark-math'
|
||||||
|
|
||||||
@ -18,8 +20,15 @@ import { useClipboard } from '@/hooks/useClipboard'
|
|||||||
|
|
||||||
import { getLanguageFromExtension } from '@/utils/codeLanguageExtension'
|
import { getLanguageFromExtension } from '@/utils/codeLanguageExtension'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
text: string
|
||||||
|
isUser?: boolean
|
||||||
|
className?: string
|
||||||
|
renderKatex?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export const MarkdownTextMessage = memo(
|
export const MarkdownTextMessage = memo(
|
||||||
({ text, isUser }: { id: string; text: string; isUser: boolean }) => {
|
({ text, isUser, className, renderKatex = true }: Props) => {
|
||||||
const clipboard = useClipboard({ timeout: 1000 })
|
const clipboard = useClipboard({ timeout: 1000 })
|
||||||
|
|
||||||
// Escapes headings
|
// Escapes headings
|
||||||
@ -202,13 +211,16 @@ export const MarkdownTextMessage = memo(
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Markdown
|
<Markdown
|
||||||
remarkPlugins={[remarkMath]}
|
className={className}
|
||||||
rehypePlugins={[
|
remarkPlugins={[remarkMath, remarkGfm]}
|
||||||
[rehypeKatex, { throwOnError: false }],
|
rehypePlugins={
|
||||||
rehypeHighlight,
|
[
|
||||||
[rehypeHighlightCodeLines, { showLineNumbers: true }],
|
rehypeHighlight,
|
||||||
wrapCodeBlocksWithoutVisit,
|
renderKatex ? [rehypeKatex, { throwOnError: false }] : undefined,
|
||||||
]}
|
[rehypeHighlightCodeLines, { showLineNumbers: true }],
|
||||||
|
wrapCodeBlocksWithoutVisit,
|
||||||
|
].filter((e) => !!e) as PluggableList
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{preprocessMarkdown(text)}
|
{preprocessMarkdown(text)}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
|
|||||||
@ -144,11 +144,7 @@ const MessageContainer: React.FC<
|
|||||||
)}
|
)}
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
>
|
>
|
||||||
<MarkdownTextMessage
|
<MarkdownTextMessage text={text} isUser={isUser} />
|
||||||
id={props.id}
|
|
||||||
text={text}
|
|
||||||
isUser={isUser}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
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,
|
input: number,
|
||||||
options?: { hideUnit?: boolean }
|
options?: { hideUnit?: boolean }
|
||||||
) => {
|
) => {
|
||||||
@ -24,7 +24,7 @@ export const formatDownloadPercentage = (
|
|||||||
|
|
||||||
export const formatDownloadSpeed = (input: number | undefined) => {
|
export const formatDownloadSpeed = (input: number | undefined) => {
|
||||||
if (!input) return '0B/s'
|
if (!input) return '0B/s'
|
||||||
return toGibibytes(input) + '/s'
|
return toGigabytes(input) + '/s'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const formatTwoDigits = (input: number) => {
|
export const formatTwoDigits = (input: number) => {
|
||||||
|
|||||||
@ -64,6 +64,29 @@ export const getTitleByEngine = (engine: InferenceEngine) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getDescriptionByEngine = (engine: InferenceEngine) => {
|
||||||
|
switch (engine) {
|
||||||
|
case InferenceEngine.anthropic:
|
||||||
|
return 'Creator of Claude AI models renowned for exceptional reasoning, analysis, and coding capabilities. Claude models combine high intelligence with strong safety and ethics, making them ideal for complex tasks from research to technical writing.'
|
||||||
|
case InferenceEngine.cohere:
|
||||||
|
return 'Enterprise-focused language models designed for business needs. Specializing in reliable content generation, summarization, and semantic analysis with consistent quality and scalable performance.'
|
||||||
|
case InferenceEngine.groq:
|
||||||
|
return 'An innovative AI infrastructure provider revolutionizing model speed. Their custom-built hardware platform delivers ultra-fast inference while maintaining high-quality outputs.'
|
||||||
|
case InferenceEngine.martian:
|
||||||
|
return 'Production-first language models balancing performance with practicality. Built for reliable deployment and consistent results across diverse real-world applications.'
|
||||||
|
case InferenceEngine.mistral:
|
||||||
|
return 'A breakthrough in open-source language models. Their instruction-tuned models like Mixtral and Mistral combine competitive performance with efficient resource usage across reasoning, coding, and general tasks.'
|
||||||
|
case InferenceEngine.nvidia:
|
||||||
|
return 'Industry leaders in AI hardware and software solutions. Their models leverage deep GPU expertise to deliver high-performance AI capabilities optimized for enterprise use.'
|
||||||
|
case InferenceEngine.openai:
|
||||||
|
return 'Creator of GPT models that set industry benchmarks. Their models excel across text, code, and image generation, consistently setting new standards for AI capabilities.'
|
||||||
|
case InferenceEngine.openrouter:
|
||||||
|
return 'A unified platform aggregating top AI models from various providers. Simplifies AI deployment by offering seamless access to multiple services through standardized integration.'
|
||||||
|
default:
|
||||||
|
return `Access models from ${engine} via their API.`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const priorityEngine = [
|
export const priorityEngine = [
|
||||||
InferenceEngine.cortex_llamacpp,
|
InferenceEngine.cortex_llamacpp,
|
||||||
InferenceEngine.cortex_onnx,
|
InferenceEngine.cortex_onnx,
|
||||||
|
|||||||
31
web/utils/modelSource.ts
Normal file
@ -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)}`
|
|
||||||
}
|
|
||||||