feat: Jan Hub Revamp (#4491)

* feat: model hub revamp UI

* chore: model description - consistent markdown css

* chore: add model versions dropdown

* chore: integrate APIs - model sources

* chore: update model display name

* chore: lint fix

* chore: page transition animation

* feat: model search dropdown - deeplink

* chore: bump cortex version

* chore: add remote model sources

* chore: model download state

* chore: fix model metadata label

* chore: polish model detail page markdown

* test: fix test cases

* chore: initialize default Hub model sources

* chore: fix model stats

* chore: clean up click outside and inside hooks

* feat: change hub banner

* chore: lint fix

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react/no-unescaped-entities */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react/no-unescaped-entities */
/* eslint-disable @typescript-eslint/no-unused-vars */
import React, { useCallback, useRef, useState, useEffect } from 'react'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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