jan/web-app/src/routes/settings/providers/$providerName.tsx

635 lines
26 KiB
TypeScript

/* eslint-disable react-hooks/exhaustive-deps */
import { Card, CardItem } from '@/containers/Card'
import HeaderPage from '@/containers/HeaderPage'
import SettingsMenu from '@/containers/SettingsMenu'
import { useModelProvider } from '@/hooks/useModelProvider'
import { cn, getProviderTitle } from '@/lib/utils'
import { open } from '@tauri-apps/plugin-dialog'
import {
getActiveModels,
pullModel,
startModel,
stopAllModels,
stopModel,
} from '@/services/models'
import {
createFileRoute,
Link,
useNavigate,
useParams,
useSearch,
} from '@tanstack/react-router'
import { useTranslation } from '@/i18n/react-i18next-compat'
import Capabilities from '@/containers/Capabilities'
import { DynamicControllerSetting } from '@/containers/dynamicControllerSetting'
import { RenderMarkdown } from '@/containers/RenderMarkdown'
import { DialogEditModel } from '@/containers/dialogs/EditModel'
import { DialogAddModel } from '@/containers/dialogs/AddModel'
import { ModelSetting } from '@/containers/ModelSetting'
import { DialogDeleteModel } from '@/containers/dialogs/DeleteModel'
import Joyride, { CallBackProps, STATUS } from 'react-joyride'
import { CustomTooltipJoyRide } from '@/containers/CustomeTooltipJoyRide'
import { route } from '@/constants/routes'
import DeleteProvider from '@/containers/dialogs/DeleteProvider'
import { updateSettings, fetchModelsFromProvider } from '@/services/providers'
import { Button } from '@/components/ui/button'
import { IconFolderPlus, IconLoader, IconRefresh } from '@tabler/icons-react'
import { getProviders } from '@/services/providers'
import { toast } from 'sonner'
import { useEffect, useState } from 'react'
import { predefinedProviders } from '@/consts/providers'
import { useModelLoad } from '@/hooks/useModelLoad'
import { useLlamacppDevices } from '@/hooks/useLlamacppDevices'
// as route.threadsDetail
export const Route = createFileRoute('/settings/providers/$providerName')({
component: ProviderDetail,
validateSearch: (search: Record<string, unknown>): { step?: string } => {
// validate and parse the search params into a typed state
return {
step: String(search?.step),
}
},
})
function ProviderDetail() {
const { t } = useTranslation()
const { setModelLoadError } = useModelLoad()
const steps = [
{
target: '.first-step-setup-remote-provider',
title: t('providers:joyride.chooseProviderTitle'),
disableBeacon: true,
content: t('providers:joyride.chooseProviderContent'),
},
{
target: '.second-step-setup-remote-provider',
title: t('providers:joyride.getApiKeyTitle'),
disableBeacon: true,
content: t('providers:joyride.getApiKeyContent'),
},
{
target: '.third-step-setup-remote-provider',
title: t('providers:joyride.insertApiKeyTitle'),
disableBeacon: true,
content: t('providers:joyride.insertApiKeyContent'),
},
]
const { step } = useSearch({ from: Route.id })
const [activeModels, setActiveModels] = useState<string[]>([])
const [loadingModels, setLoadingModels] = useState<string[]>([])
const [refreshingModels, setRefreshingModels] = useState(false)
const { providerName } = useParams({ from: Route.id })
const { getProviderByName, setProviders, updateProvider } = useModelProvider()
const provider = getProviderByName(providerName)
const isSetup = step === 'setup_remote_provider'
const navigate = useNavigate()
// Check if llamacpp provider needs backend configuration
const needsBackendConfig =
provider?.provider === 'llamacpp' &&
provider.settings?.some(
(setting) =>
setting.key === 'version_backend' &&
(setting.controller_props.value === 'none' ||
setting.controller_props.value === '' ||
!setting.controller_props.value)
)
useEffect(() => {
// Initial data fetch
getActiveModels().then((models) => setActiveModels(models || []))
// Set up interval for real-time updates
const intervalId = setInterval(() => {
getActiveModels().then((models) => setActiveModels(models || []))
}, 5000)
return () => clearInterval(intervalId)
}, [setActiveModels])
// Auto-refresh provider settings to get updated backend configuration
const refreshSettings = async () => {
if (!provider) return
try {
// Refresh providers to get updated settings from the extension
const updatedProviders = await getProviders()
setProviders(updatedProviders)
} catch (error) {
console.error('Failed to refresh settings:', error)
}
}
// Auto-refresh settings when provider changes or when llamacpp needs backend config
useEffect(() => {
if (provider && needsBackendConfig) {
// Auto-refresh every 3 seconds when backend is being configured
const intervalId = setInterval(refreshSettings, 3000)
return () => clearInterval(intervalId)
}
}, [provider, needsBackendConfig])
// Note: settingsChanged event is now handled globally in GlobalEventHandler
// This ensures all screens receive the event intermediately
// Auto-refresh models for non-predefined providers
useEffect(() => {
if (
provider &&
provider.provider !== 'llamacpp' &&
!predefinedProviders.some((p) => p.provider === provider.provider) &&
provider.base_url
) {
// Auto-refresh models every 10 seconds for remote providers
const intervalId = setInterval(() => {
handleRefreshModels()
}, 10000)
return () => clearInterval(intervalId)
}
}, [provider])
const handleJoyrideCallback = (data: CallBackProps) => {
const { status } = data
if (status === STATUS.FINISHED) {
navigate({
to: route.home,
})
}
}
const handleRefreshModels = async () => {
if (!provider || !provider.base_url) {
toast.error(t('providers:models'), {
description: t('providers:refreshModelsError'),
})
return
}
setRefreshingModels(true)
try {
const modelIds = await fetchModelsFromProvider(provider)
// Create new models from the fetched IDs
const newModels: Model[] = modelIds.map((id) => ({
id,
model: id,
name: id,
capabilities: ['completion'], // Default capability
version: '1.0',
}))
// Filter out models that already exist
const existingModelIds = provider.models.map((m) => m.id)
const modelsToAdd = newModels.filter(
(model) => !existingModelIds.includes(model.id)
)
if (modelsToAdd.length > 0) {
// Update the provider with new models
const updatedModels = [...provider.models, ...modelsToAdd]
updateProvider(providerName, {
...provider,
models: updatedModels,
})
toast.success(t('providers:models'), {
description: t('providers:refreshModelsSuccess', {
count: modelsToAdd.length,
provider: provider.provider,
}),
})
} else {
toast.success(t('providers:models'), {
description: t('providers:noNewModels'),
})
}
} catch (error) {
console.error(
t('providers:refreshModelsFailed', { provider: provider.provider }),
error
)
toast.error(t('providers:models'), {
description: t('providers:refreshModelsFailed', {
provider: provider.provider,
}),
})
} finally {
setRefreshingModels(false)
}
}
const handleStartModel = (modelId: string) => {
// Add model to loading state
setLoadingModels((prev) => [...prev, modelId])
if (provider)
startModel(provider, modelId)
.then(() => {
setActiveModels((prevModels) => [...prevModels, modelId])
})
.catch((error) => {
console.error('Error starting model:', error)
setModelLoadError(`${error.message}`)
})
.finally(() => {
// Remove model from loading state
setLoadingModels((prev) => prev.filter((id) => id !== modelId))
})
}
const handleStopModel = (modelId: string) => {
stopModel(modelId)
.then(() => {
setActiveModels((prevModels) =>
prevModels.filter((model) => model !== modelId)
)
})
.catch((error) => {
console.error('Error stopping model:', error)
})
}
return (
<>
<Joyride
run={isSetup}
floaterProps={{
hideArrow: true,
}}
steps={steps}
tooltipComponent={CustomTooltipJoyRide}
spotlightPadding={0}
continuous={true}
showSkipButton={true}
hideCloseButton={true}
spotlightClicks={true}
disableOverlay={IS_LINUX}
disableOverlayClose={true}
callback={handleJoyrideCallback}
locale={{
back: t('providers:joyride.back'),
close: t('providers:joyride.close'),
last: t('providers:joyride.last'),
next: t('providers:joyride.next'),
skip: t('providers:joyride.skip'),
}}
/>
<div className="flex flex-col h-full">
<HeaderPage>
<h1 className="font-medium">{t('common:settings')}</h1>
</HeaderPage>
<div className="flex h-full w-full">
<SettingsMenu />
<div className="p-4 w-full h-[calc(100%-32px)] overflow-y-auto">
<div className="flex flex-col justify-between gap-4 gap-y-3 w-full">
<div className="flex items-center justify-between">
<h1 className="font-medium text-base">
{getProviderTitle(providerName)}
</h1>
</div>
<div
className={cn(
'flex flex-col gap-3',
provider &&
provider.provider === 'llamacpp' &&
'flex-col-reverse'
)}
>
{/* Settings */}
<Card>
{provider?.settings.map((setting, settingIndex) => {
// Use the DynamicController component
const actionComponent = (
<div className="mt-2">
{needsBackendConfig &&
setting.key === 'version_backend' ? (
<div className="flex items-center gap-1 text-sm text-main-view-fg/70">
<IconLoader size={16} className="animate-spin" />
<span>loading</span>
</div>
) : (
<DynamicControllerSetting
controllerType={setting.controller_type}
controllerProps={setting.controller_props}
className={cn(
setting.key === 'api-key' &&
'third-step-setup-remote-provider',
setting.key === 'device' && 'hidden'
)}
onChange={(newValue) => {
if (provider) {
const newSettings = [...provider.settings]
// Handle different value types by forcing the type
// Use type assertion to bypass type checking
;(
newSettings[settingIndex]
.controller_props as {
value: string | boolean | number
}
).value = newValue
// Create update object with updated settings
const updateObj: Partial<ModelProvider> = {
settings: newSettings,
}
// Check if this is an API key or base URL setting and update the corresponding top-level field
const settingKey = setting.key
if (
settingKey === 'api-key' &&
typeof newValue === 'string'
) {
updateObj.api_key = newValue
} else if (
settingKey === 'base-url' &&
typeof newValue === 'string'
) {
updateObj.base_url = newValue
}
// Reset device setting to empty when backend version changes
if (settingKey === 'version_backend') {
const deviceSettingIndex =
newSettings.findIndex(
(s) => s.key === 'device'
)
if (deviceSettingIndex !== -1) {
;(
newSettings[deviceSettingIndex]
.controller_props as {
value: string
}
).value = ''
}
// Reset llamacpp device activations when backend version changes
if (providerName === 'llamacpp') {
// Refresh devices to update activation status from provider settings
const { fetchDevices } =
useLlamacppDevices.getState()
fetchDevices()
}
}
updateSettings(
providerName,
updateObj.settings ?? []
)
updateProvider(providerName, {
...provider,
...updateObj,
})
stopAllModels()
}
}}
/>
)}
</div>
)
return (
<CardItem
key={settingIndex}
title={setting.title}
className={cn(setting.key === 'device' && 'hidden')}
column={
setting.controller_type === 'input' &&
setting.controller_props.type !== 'number'
? true
: false
}
description={
<RenderMarkdown
className="![>p]:text-main-view-fg/70 select-none"
content={setting.description}
components={{
// Make links open in a new tab
a: ({ ...props }) => {
return (
<a
{...props}
target="_blank"
rel="noopener noreferrer"
className={cn(
setting.key === 'api-key' &&
'second-step-setup-remote-provider'
)}
/>
)
},
p: ({ ...props }) => (
<p {...props} className="!mb-0" />
),
}}
/>
}
actions={actionComponent}
/>
)
})}
<DeleteProvider provider={provider} />
</Card>
{/* Models */}
<Card
header={
<div className="flex items-center justify-between mb-4">
<h1 className="text-main-view-fg font-medium text-base">
{t('providers:models')}
</h1>
<div className="flex items-center gap-2">
{provider && provider.provider !== 'llamacpp' && (
<>
{!predefinedProviders.some(
(p) => p.provider === provider.provider
) && (
<Button
variant="link"
size="sm"
className="hover:no-underline"
onClick={handleRefreshModels}
disabled={refreshingModels}
>
<div className="cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/15 bg-main-view-fg/10 transition-all duration-200 ease-in-out px-1.5 py-1 gap-1">
{refreshingModels ? (
<IconLoader
size={18}
className="text-main-view-fg/50 animate-spin"
/>
) : (
<IconRefresh
size={18}
className="text-main-view-fg/50"
/>
)}
<span className="text-main-view-fg/70">
{refreshingModels
? t('providers:refreshing')
: t('providers:refresh')}
</span>
</div>
</Button>
)}
<DialogAddModel provider={provider} />
</>
)}
{provider && provider.provider === 'llamacpp' && (
<Button
variant="link"
size="sm"
className="hover:no-underline"
onClick={async () => {
const selectedFile = await open({
multiple: false,
directory: false,
filters: [
{
name: 'GGUF',
extensions: ['gguf'],
},
],
})
// If the dialog returns a file path, extract just the file name
const fileName =
typeof selectedFile === 'string'
? selectedFile.split(/[\\/]/).pop()
: undefined
if (selectedFile && fileName) {
try {
await pullModel(fileName, selectedFile)
} catch (error) {
console.error(
t('providers:importModelError'),
error
)
} finally {
// Refresh the provider to update the models list
getProviders().then(setProviders)
toast.success(t('providers:import'), {
id: `import-model-${provider.provider}`,
description: t(
'providers:importModelSuccess',
{ provider: provider.provider }
),
})
}
}
}}
>
<div className="cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/15 bg-main-view-fg/10 transition-all duration-200 ease-in-out p-1.5 py-1 gap-1 -mr-2">
<IconFolderPlus
size={18}
className="text-main-view-fg/50"
/>
<span className="text-main-view-fg/70">
{t('providers:import')}
</span>
</div>
</Button>
)}
</div>
</div>
}
>
{provider?.models.length ? (
provider?.models.map((model, modelIndex) => {
const capabilities = model.capabilities || []
return (
<CardItem
key={modelIndex}
title={
<div className="flex items-center gap-2">
<h1
className="font-medium line-clamp-1"
title={model.id}
>
{model.id}
</h1>
<Capabilities capabilities={capabilities} />
</div>
}
actions={
<div className="flex items-center gap-1">
<DialogEditModel
provider={provider}
modelId={model.id}
/>
{model.settings && (
<ModelSetting
provider={provider}
model={model}
/>
)}
<DialogDeleteModel
provider={provider}
modelId={model.id}
/>
{provider && provider.provider === 'llamacpp' && (
<div className="ml-2">
{activeModels.some(
(activeModel) => activeModel === model.id
) ? (
<Button
size="sm"
variant="destructive"
onClick={() => handleStopModel(model.id)}
>
{t('providers:stop')}
</Button>
) : (
<Button
size="sm"
disabled={loadingModels.includes(
model.id
)}
onClick={() => handleStartModel(model.id)}
>
{loadingModels.includes(model.id) ? (
<div className="flex items-center gap-2">
<IconLoader
size={16}
className="animate-spin"
/>
</div>
) : (
t('providers:start')
)}
</Button>
)}
</div>
)}
</div>
}
/>
)
})
) : (
<div className="-mt-2">
<div className="flex items-center gap-2 text-main-view-fg/80">
<h6 className="font-medium text-base">
{t('providers:noModelFound')}
</h6>
</div>
<p className="text-main-view-fg/70 mt-1 text-xs leading-relaxed">
{t('providers:noModelFoundDesc')}
&nbsp;
<Link to={route.hub.index}>{t('common:hub')}</Link>
</p>
</div>
)}
</Card>
</div>
</div>
</div>
</div>
</div>
</>
)
}