diff --git a/src/langbot/pkg/provider/modelmgr/requesters/chatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/chatcmpl.py index beb45936..97e61c89 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/chatcmpl.py +++ b/src/langbot/pkg/provider/modelmgr/requesters/chatcmpl.py @@ -4,7 +4,7 @@ import asyncio import typing import openai -import openai.types.chat.chat_completion as chat_completion +import openai.types.chat.chat_completion as chat_completion_module import httpx from .. import errors, requester @@ -35,7 +35,7 @@ class OpenAIChatCompletions(requester.ProviderAPIRequester): self, args: dict, extra_body: dict = {}, - ) -> chat_completion.ChatCompletion: + ) -> chat_completion_module.ChatCompletion: return await self.client.chat.completions.create(**args, extra_body=extra_body) async def _req_stream( @@ -48,9 +48,12 @@ class OpenAIChatCompletions(requester.ProviderAPIRequester): async def _make_msg( self, - chat_completion: chat_completion.ChatCompletion, + chat_completion: chat_completion_module.ChatCompletion, remove_think: bool = False, ) -> provider_message.Message: + if not isinstance(chat_completion, chat_completion_module.ChatCompletion): + raise TypeError(f'Expected ChatCompletion, got {type(chat_completion).__name__}: {chat_completion[:16]}') + chatcmpl_message = chat_completion.choices[0].message.model_dump() # 确保 role 字段存在且不为 None diff --git a/web/src/app/home/components/models-dialog/LLMConfig.module.css b/web/src/app/home/components/models-dialog/LLMConfig.module.css deleted file mode 100644 index ce6c689a..00000000 --- a/web/src/app/home/components/models-dialog/LLMConfig.module.css +++ /dev/null @@ -1,19 +0,0 @@ -.modelListContainer { - width: 100%; - padding-left: 0.8rem; - padding-right: 0.8rem; - display: grid; - grid-template-columns: repeat(auto-fill, minmax(24rem, 1fr)); - gap: 2rem; - justify-items: stretch; - align-items: start; -} - -.emptyContainer { - width: 100%; - height: 100%; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; -} diff --git a/web/src/app/home/components/models-dialog/ModelsDialog.tsx b/web/src/app/home/components/models-dialog/ModelsDialog.tsx index 10def6ba..2d67dafc 100644 --- a/web/src/app/home/components/models-dialog/ModelsDialog.tsx +++ b/web/src/app/home/components/models-dialog/ModelsDialog.tsx @@ -1,24 +1,9 @@ 'use client'; import { useState, useEffect } from 'react'; -import { - Plus, - MessageSquareText, - Cpu, - ChevronDown, - ChevronRight, - Trash2, - Settings, - LogIn, - Eye, - Wrench, -} from 'lucide-react'; -import { httpClient, systemInfo } from '@/app/infra/http/HttpClient'; -import { - LLMModel, - EmbeddingModel, - ModelProvider, -} from '@/app/infra/entities/api'; +import { Plus, Boxes } from 'lucide-react'; +import { httpClient } from '@/app/infra/http/HttpClient'; +import { ModelProvider } from '@/app/infra/entities/api'; import { Dialog, DialogContent, @@ -26,33 +11,37 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from '@/components/ui/collapsible'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; import { extractI18nObject } from '@/i18n/I18nProvider'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; -import LLMForm from './component/llm-form/LLMForm'; -import EmbeddingForm from './component/embedding-form/EmbeddingForm'; import ProviderForm from './component/provider-form/ProviderForm'; -import langbotIcon from '@/app/assets/langbot-logo.webp'; +import { ProviderCard } from './components'; +import { + ExtraArg, + ModelType, + TestResult, + ProviderModels, + LANGBOT_MODELS_PROVIDER_REQUESTER, +} from './types'; interface ModelsDialogProps { open: boolean; onOpenChange: (open: boolean) => void; } -const LANGBOT_MODELS_PROVIDER_REQUESTER = 'space-chat-completions'; +function convertExtraArgsToObject( + args: ExtraArg[], +): Record { + const obj: Record = {}; + args.forEach((arg) => { + if (arg.key.trim()) { + if (arg.type === 'number') obj[arg.key] = Number(arg.value); + else if (arg.type === 'boolean') obj[arg.key] = arg.value === 'true'; + else obj[arg.key] = arg.value; + } + }); + return obj; +} export default function ModelsDialog({ open, @@ -69,28 +58,49 @@ export default function ModelsDialog({ new Set(), ); const [providerModels, setProviderModels] = useState< - Record + Record >({}); const [loadingProviders, setLoadingProviders] = useState>( new Set(), ); - // Form modals - const [llmFormOpen, setLLMFormOpen] = useState(false); - const [embeddingFormOpen, setEmbeddingFormOpen] = useState(false); + // Provider form modal const [providerFormOpen, setProviderFormOpen] = useState(false); - const [editingLLMId, setEditingLLMId] = useState(null); - const [editingEmbeddingId, setEditingEmbeddingId] = useState( - null, - ); const [editingProviderId, setEditingProviderId] = useState( null, ); + // Popover states + const [addModelPopoverOpen, setAddModelPopoverOpen] = useState( + null, + ); + const [editModelPopoverOpen, setEditModelPopoverOpen] = useState< + string | null + >(null); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState( + null, + ); + + // Form states + const [isSubmitting, setIsSubmitting] = useState(false); + const [isTesting, setIsTesting] = useState(false); + const [testResult, setTestResult] = useState(null); + const [requesterNameList, setRequesterNameList] = useState< { label: string; value: string }[] >([]); + // Track if providers have been loaded initially + const [providersLoaded, setProvidersLoaded] = useState(false); + + // Separate LangBot Models provider + const langbotProvider = providers.find( + (p) => p.requester === LANGBOT_MODELS_PROVIDER_REQUESTER, + ); + const otherProviders = providers.filter( + (p) => p.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER, + ); + useEffect(() => { if (open) { loadUserInfo(); @@ -99,6 +109,18 @@ export default function ModelsDialog({ } }, [open]); + // Auto-expand LangBot Models when no external providers exist + useEffect(() => { + if (providersLoaded && langbotProvider && otherProviders.length === 0) { + if (!expandedProviders.has(langbotProvider.uuid)) { + setExpandedProviders(new Set([langbotProvider.uuid])); + if (!providerModels[langbotProvider.uuid]) { + loadProviderModels(langbotProvider.uuid); + } + } + } + }, [providersLoaded, providers]); + async function loadUserInfo() { try { const userInfo = await httpClient.getUserInfo(); @@ -130,16 +152,19 @@ export default function ModelsDialog({ try { const resp = await httpClient.getModelProviders(); setProviders(resp.providers); + setProvidersLoaded(true); } catch (err) { console.error('Failed to load providers', err); toast.error(t('models.loadError')); } } - async function loadProviderModels(providerUuid: string) { + async function loadProviderModels(providerUuid: string, silent = false) { if (loadingProviders.has(providerUuid)) return; - setLoadingProviders((prev) => new Set(prev).add(providerUuid)); + if (!silent) { + setLoadingProviders((prev) => new Set(prev).add(providerUuid)); + } try { const [llmResp, embeddingResp] = await Promise.all([ httpClient.getProviderLLMModels(providerUuid), @@ -155,11 +180,13 @@ export default function ModelsDialog({ } catch (err) { console.error('Failed to load models', err); } finally { - setLoadingProviders((prev) => { - const next = new Set(prev); - next.delete(providerUuid); - return next; - }); + if (!silent) { + setLoadingProviders((prev) => { + const next = new Set(prev); + next.delete(providerUuid); + return next; + }); + } } } @@ -178,24 +205,9 @@ export default function ModelsDialog({ }); } - function handleCreateLLM() { - setEditingLLMId(null); - setLLMFormOpen(true); - } - - function handleCreateEmbedding() { - setEditingEmbeddingId(null); - setEmbeddingFormOpen(true); - } - - function handleEditLLM(modelId: string) { - setEditingLLMId(modelId); - setLLMFormOpen(true); - } - - function handleEditEmbedding(modelId: string) { - setEditingEmbeddingId(modelId); - setEmbeddingFormOpen(true); + function handleCreateProvider() { + setEditingProviderId(null); + setProviderFormOpen(true); } function handleEditProvider(providerId: string) { @@ -213,315 +225,225 @@ export default function ModelsDialog({ } } - async function handleDeleteLLM(modelId: string, providerUuid: string) { + function handleSpaceLogin() { + window.location.href = '/auth/space'; + } + + async function handleAddModel( + providerUuid: string, + modelType: ModelType, + name: string, + abilities: string[], + extraArgs: ExtraArg[], + ) { + if (!name.trim()) { + toast.error(t('models.modelNameRequired')); + return; + } + setIsSubmitting(true); try { - await httpClient.deleteProviderLLMModel(modelId); - toast.success(t('models.deleteSuccess')); - loadProviderModels(providerUuid); - loadProviders(); // Refresh counts + const extraArgsObj = convertExtraArgsToObject(extraArgs); + + if (modelType === 'llm') { + await httpClient.createProviderLLMModel({ + name, + provider_uuid: providerUuid, + abilities, + extra_args: extraArgsObj, + } as never); + } else { + await httpClient.createProviderEmbeddingModel({ + name, + provider_uuid: providerUuid, + extra_args: extraArgsObj, + } as never); + } + setAddModelPopoverOpen(null); + loadProviderModels(providerUuid, true); + loadProviders(); } catch (err) { - toast.error(t('models.deleteError') + (err as Error).message); + toast.error(t('models.createError') + (err as Error).message); + } finally { + setIsSubmitting(false); } } - async function handleDeleteEmbedding(modelId: string, providerUuid: string) { + async function handleUpdateModel( + providerUuid: string, + modelId: string, + modelType: ModelType, + name: string, + abilities: string[], + extraArgs: ExtraArg[], + ) { + if (!name.trim()) { + toast.error(t('models.modelNameRequired')); + return; + } + setIsSubmitting(true); try { - await httpClient.deleteProviderEmbeddingModel(modelId); + const extraArgsObj = convertExtraArgsToObject(extraArgs); + + if (modelType === 'llm') { + await httpClient.updateProviderLLMModel(modelId, { + name, + provider_uuid: providerUuid, + abilities, + extra_args: extraArgsObj, + } as never); + } else { + await httpClient.updateProviderEmbeddingModel(modelId, { + name, + provider_uuid: providerUuid, + extra_args: extraArgsObj, + } as never); + } + setEditModelPopoverOpen(null); + loadProviderModels(providerUuid, true); + loadProviders(); + } catch (err) { + toast.error(t('models.saveError') + (err as Error).message); + } finally { + setIsSubmitting(false); + } + } + + async function handleDeleteModel( + providerUuid: string, + modelId: string, + modelType: ModelType, + ) { + try { + if (modelType === 'llm') { + await httpClient.deleteProviderLLMModel(modelId); + } else { + await httpClient.deleteProviderEmbeddingModel(modelId); + } toast.success(t('models.deleteSuccess')); - loadProviderModels(providerUuid); + loadProviderModels(providerUuid, true); loadProviders(); } catch (err) { toast.error(t('models.deleteError') + (err as Error).message); } } - function handleSpaceLogin() { - window.location.href = '/auth/space'; + async function handleTestModel( + providerUuid: string, + name: string, + modelType: ModelType, + abilities: string[], + extraArgs: ExtraArg[], + ) { + setIsTesting(true); + setTestResult(null); + const startTime = Date.now(); + try { + const extraArgsObj = convertExtraArgsToObject(extraArgs); + + // Get the provider info + const provider = providers.find((p) => p.uuid === providerUuid); + const providerData = { + requester: provider?.requester || '', + base_url: provider?.base_url || '', + api_keys: provider?.api_keys || [], + }; + + if (modelType === 'llm') { + await httpClient.testLLMModel('_', { + uuid: '', + name, + provider_uuid: '', + provider: providerData, + abilities, + extra_args: extraArgsObj, + } as never); + } else { + await httpClient.testEmbeddingModel('_', { + uuid: '', + name, + provider_uuid: '', + provider: providerData, + extra_args: extraArgsObj, + } as never); + } + const duration = Date.now() - startTime; + setTestResult({ success: true, duration }); + } catch (err) { + toast.error(t('models.testError') + ': ' + (err as Error).message); + setTestResult(null); + } finally { + setIsTesting(false); + } } - function getRequesterLabel(requester: string) { - return ( - requesterNameList.find((r) => r.value === requester)?.label || requester - ); + function handleFormClose() { + setProviderFormOpen(false); + loadProviders(); + // Refresh expanded providers + expandedProviders.forEach((uuid) => loadProviderModels(uuid)); } - function maskApiKey(key: string): string { - if (!key) return ''; - if (key.length <= 8) return '****'; - return `${key.slice(0, 4)}...${key.slice(-4)}`; - } - - // Separate LangBot Models provider - const langbotProvider = providers.find( - (p) => p.requester === LANGBOT_MODELS_PROVIDER_REQUESTER, - ); - const otherProviders = providers.filter( - (p) => p.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER, - ); - function renderProviderCard( provider: ModelProvider, isLangBotModels: boolean = false, ) { - const isExpanded = expandedProviders.has(provider.uuid); - const isLoading = loadingProviders.has(provider.uuid); - const models = providerModels[provider.uuid]; - const canDelete = - !isLangBotModels && - (provider.llm_count || 0) === 0 && - (provider.embedding_count || 0) === 0; - const totalModels = - (provider.llm_count || 0) + (provider.embedding_count || 0); - return ( - - toggleProvider(provider.uuid)} - > - -
-
- {isLangBotModels ? ( -
- LangBot -
- ) : ( - {provider.name} - )} -
-
- - {isLangBotModels - ? provider.name - : getRequesterLabel(provider.requester)} - - - {t('models.modelsCount', { count: totalModels })} - -
-

- {isLangBotModels ? ( - t('models.langbotModelsDescription') - ) : ( - <> - {provider.base_url} - {provider.base_url && - provider.api_keys?.length > 0 && - ' · '} - {provider.api_keys?.length > 0 && - maskApiKey(provider.api_keys[0])} - - )} -

-
-
-
- {isLangBotModels && accountType !== 'space' && ( - - )} - {isLangBotModels && - accountType === 'space' && - spaceCredits !== null && ( -
- - {(spaceCredits / 5000).toFixed(2)} {t('models.credits')} - - -
- )} - {!isLangBotModels && ( - <> - - {canDelete && ( - - )} - - )} -
-
- - {isExpanded ? ( - - ) : ( - - )} - - {isExpanded - ? t('models.collapseModels') - : t('models.expandModels')} - - -
- - - {isLoading ? ( -

- {t('common.loading')}... -

- ) : models ? ( -
- {models.llm.map((model) => ( -
handleEditLLM(model.uuid)} - > -
- - {model.name} - - - {t('models.chat')} - - {model.abilities?.includes('vision') && ( - - - - )} - {model.abilities?.includes('func_call') && ( - - - - )} -
- -
- ))} - {models.embedding.map((model) => ( -
handleEditEmbedding(model.uuid)} - > -
- - {model.name} - - - {t('models.embedding')} - -
- -
- ))} - {models.llm.length === 0 && models.embedding.length === 0 && ( -

- {t('models.noModels')} -

- )} -
- ) : ( -

- {t('models.noModels')} -

- )} -
-
-
-
+ toggleProvider(provider.uuid)} + onEditProvider={() => handleEditProvider(provider.uuid)} + onDeleteProvider={() => handleDeleteProvider(provider.uuid)} + onSpaceLogin={handleSpaceLogin} + onOpenAddModel={() => setAddModelPopoverOpen(provider.uuid)} + onCloseAddModel={() => setAddModelPopoverOpen(null)} + onAddModel={(modelType, name, abilities, extraArgs) => + handleAddModel(provider.uuid, modelType, name, abilities, extraArgs) + } + onOpenEditModel={(modelId) => setEditModelPopoverOpen(modelId)} + onCloseEditModel={() => setEditModelPopoverOpen(null)} + onUpdateModel={(modelId, modelType, name, abilities, extraArgs) => + handleUpdateModel( + provider.uuid, + modelId, + modelType, + name, + abilities, + extraArgs, + ) + } + onOpenDeleteConfirm={(modelId) => setDeleteConfirmOpen(modelId)} + onCloseDeleteConfirm={() => setDeleteConfirmOpen(null)} + onDeleteModel={(modelId, modelType) => + handleDeleteModel(provider.uuid, modelId, modelType) + } + onTestModel={(name, modelType, abilities, extraArgs) => + handleTestModel(provider.uuid, name, modelType, abilities, extraArgs) + } + isSubmitting={isSubmitting} + isTesting={isTesting} + testResult={testResult} + onResetTestResult={() => setTestResult(null)} + /> ); } - // Virtual LangBot Models card if not exists - function renderLangBotModelsCard() { - if (langbotProvider) { - return renderProviderCard(langbotProvider, true); - } - } - - function handleFormClose() { - setLLMFormOpen(false); - setEmbeddingFormOpen(false); - setProviderFormOpen(false); - loadProviders(); - // Refresh expanded providers - expandedProviders.forEach((uuid) => loadProviderModels(uuid)); - } - return ( <> { - if ( - !newOpen && - (llmFormOpen || embeddingFormOpen || providerFormOpen) - ) - return; + if (!newOpen && providerFormOpen) return; onOpenChange(newOpen); }} > @@ -532,84 +454,50 @@ export default function ModelsDialog({
{/* Fixed LangBot Models Card */} -
{renderLangBotModelsCard()}
+
+ {langbotProvider && renderProviderCard(langbotProvider, true)} +
- {/* Add Model Button */} + {/* Add Provider Button */}
- {t('models.providerCount', { count: otherProviders.length })} + {otherProviders.length === 0 + ? t('models.addProviderHint') + : t('models.providerCount', { count: otherProviders.length })} - - - - - - - - {t('models.addLLMModel')} - - - - {t('models.addEmbeddingModel')} - - - +
{/* Scrollable Provider List */}
- {otherProviders.map((p) => renderProviderCard(p))} + {otherProviders.length === 0 ? ( +
+ +

{t('models.noProviders')}

+
+ ) : ( + otherProviders.map((p) => renderProviderCard(p)) + )}
- - - - - {editingLLMId ? t('models.editModel') : t('models.createModel')} - - - setLLMFormOpen(false)} - onLLMDeleted={handleFormClose} - /> - - - - - - - - {editingEmbeddingId - ? t('embedding.editModel') - : t('embedding.createModel')} - - - setEmbeddingFormOpen(false)} - onEmbeddingDeleted={handleFormClose} - /> - - - - {t('models.editProvider')} + + {editingProviderId + ? t('models.editProvider') + : t('models.addProvider')} + -
- icon - -
- {/* 名称 */} -
- {cardVO.name} -
- {/* 厂商 */} -
- - - - - {cardVO.providerLabel} - -
- {/* baseURL */} - {cardVO.baseURL && ( -
- - - - {cardVO.baseURL} -
- )} -
-
- - ); -} diff --git a/web/src/app/home/components/models-dialog/component/embedding-card/EmbeddingCardVO.ts b/web/src/app/home/components/models-dialog/component/embedding-card/EmbeddingCardVO.ts deleted file mode 100644 index f6d960f6..00000000 --- a/web/src/app/home/components/models-dialog/component/embedding-card/EmbeddingCardVO.ts +++ /dev/null @@ -1,23 +0,0 @@ -export interface IEmbeddingCardVO { - id: string; - iconURL: string; - name: string; - providerLabel: string; - baseURL: string; -} - -export class EmbeddingCardVO implements IEmbeddingCardVO { - id: string; - iconURL: string; - providerLabel: string; - name: string; - baseURL: string; - - constructor(props: IEmbeddingCardVO) { - this.id = props.id; - this.iconURL = props.iconURL; - this.providerLabel = props.providerLabel; - this.name = props.name; - this.baseURL = props.baseURL; - } -} diff --git a/web/src/app/home/components/models-dialog/component/embedding-form/EmbeddingForm.tsx b/web/src/app/home/components/models-dialog/component/embedding-form/EmbeddingForm.tsx deleted file mode 100644 index 3e7d942e..00000000 --- a/web/src/app/home/components/models-dialog/component/embedding-form/EmbeddingForm.tsx +++ /dev/null @@ -1,597 +0,0 @@ -import { useEffect, useState } from 'react'; -import { httpClient } from '@/app/infra/http/HttpClient'; -import { ModelProvider } from '@/app/infra/entities/api'; - -import { zodResolver } from '@hookform/resolvers/zod'; -import { useForm } from 'react-hook-form'; -import { z } from 'zod'; -import { useTranslation } from 'react-i18next'; - -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogFooter, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@/components/ui/form'; -import { Input } from '@/components/ui/input'; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { toast } from 'sonner'; -import { extractI18nObject } from '@/i18n/I18nProvider'; -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; -import { AlertCircle } from 'lucide-react'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; - -const getFormSchema = (t: (key: string) => string) => - z.object({ - name: z.string().min(1, { message: t('models.modelNameRequired') }), - provider_uuid: z.string().optional(), - new_provider_requester: z.string().optional(), - new_provider_url: z.string().optional(), - new_provider_api_key: z.string().optional(), - extra_args: z - .array( - z.object({ - key: z.string(), - type: z.enum(['string', 'number', 'boolean']), - value: z.string(), - }), - ) - .optional(), - }); - -interface EmbeddingFormProps { - editMode: boolean; - initEmbeddingId?: string; - providers: ModelProvider[]; - onFormSubmit: () => void; - onFormCancel: () => void; - onEmbeddingDeleted: () => void; -} - -export default function EmbeddingForm({ - editMode, - initEmbeddingId, - providers, - onFormSubmit, - onFormCancel, - onEmbeddingDeleted, -}: EmbeddingFormProps) { - const { t } = useTranslation(); - const formSchema = getFormSchema(t); - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - name: '', - provider_uuid: '', - new_provider_requester: '', - new_provider_url: '', - new_provider_api_key: '', - extra_args: [], - }, - }); - - const [extraArgs, setExtraArgs] = useState< - { key: string; type: 'string' | 'number' | 'boolean'; value: string }[] - >([]); - const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false); - const [modelTesting, setModelTesting] = useState(false); - const [testErrorMessage, setTestErrorMessage] = useState(null); - const [providerMode, setProviderMode] = useState<'existing' | 'new'>( - 'existing', - ); - - const [requesterList, setRequesterList] = useState< - { label: string; value: string; category: string; defaultUrl: string }[] - >([]); - - useEffect(() => { - loadRequesters(); - if (editMode && initEmbeddingId) { - loadModel(initEmbeddingId); - } - }, [editMode, initEmbeddingId]); - - async function loadRequesters() { - const resp = await httpClient.getProviderRequesters('text-embedding'); - setRequesterList( - resp.requesters.map((item) => ({ - label: extractI18nObject(item.label), - value: item.name, - category: item.spec.provider_category || 'manufacturer', - defaultUrl: - item.spec.config - .find((c) => c.name === 'base_url') - ?.default?.toString() || '', - })), - ); - } - - async function loadModel(id: string) { - const resp = await httpClient.getProviderEmbeddingModel(id); - const model = resp.model; - - form.setValue('name', model.name); - form.setValue('provider_uuid', model.provider_uuid); - - if (model.extra_args) { - const args = Object.entries(model.extra_args).map(([key, value]) => { - let type: 'string' | 'number' | 'boolean' = 'string'; - if (typeof value === 'number') type = 'number'; - else if (typeof value === 'boolean') type = 'boolean'; - return { key, type, value: String(value) }; - }); - setExtraArgs(args); - form.setValue('extra_args', args); - } - - setProviderMode('existing'); - } - - function handleFormSubmit(values: z.infer) { - const extraArgsObj: Record = {}; - values.extra_args?.forEach((arg) => { - if (arg.type === 'number') extraArgsObj[arg.key] = Number(arg.value); - else if (arg.type === 'boolean') - extraArgsObj[arg.key] = arg.value === 'true'; - else extraArgsObj[arg.key] = arg.value; - }); - - const modelData: Record = { - name: values.name, - extra_args: extraArgsObj, - }; - - if (providerMode === 'existing' && values.provider_uuid) { - modelData.provider_uuid = values.provider_uuid; - } else if (providerMode === 'new') { - modelData.provider = { - requester: values.new_provider_requester, - base_url: values.new_provider_url, - api_keys: values.new_provider_api_key - ? [values.new_provider_api_key] - : [], - }; - } - - if (editMode && initEmbeddingId) { - updateModel(initEmbeddingId, modelData); - } else { - createModel(modelData); - } - } - - async function createModel(data: Record) { - try { - await httpClient.createProviderEmbeddingModel(data as never); - toast.success(t('models.createSuccess')); - onFormSubmit(); - } catch (err) { - toast.error(t('models.createError') + (err as Error).message); - } - } - - async function updateModel(id: string, data: Record) { - try { - await httpClient.updateProviderEmbeddingModel(id, data as never); - toast.success(t('models.saveSuccess')); - onFormSubmit(); - } catch (err) { - toast.error(t('models.saveError') + (err as Error).message); - } - } - - async function deleteModel() { - if (!initEmbeddingId) return; - try { - await httpClient.deleteProviderEmbeddingModel(initEmbeddingId); - toast.success(t('models.deleteSuccess')); - onEmbeddingDeleted(); - } catch (err) { - toast.error(t('models.deleteError') + (err as Error).message); - } - } - - async function testModel() { - setModelTesting(true); - setTestErrorMessage(null); - - const values = form.getValues(); - const extraArgsObj: Record = {}; - values.extra_args?.forEach((arg) => { - if (arg.type === 'number') extraArgsObj[arg.key] = Number(arg.value); - else if (arg.type === 'boolean') - extraArgsObj[arg.key] = arg.value === 'true'; - else extraArgsObj[arg.key] = arg.value; - }); - - let provider: Record; - if (providerMode === 'existing' && values.provider_uuid) { - const p = providers.find((p) => p.uuid === values.provider_uuid); - provider = { - requester: p?.requester || '', - base_url: p?.base_url || '', - api_keys: p?.api_keys || [], - }; - } else { - provider = { - requester: values.new_provider_requester, - base_url: values.new_provider_url, - api_keys: values.new_provider_api_key - ? [values.new_provider_api_key] - : [], - }; - } - - try { - await httpClient.testEmbeddingModel('_', { - uuid: '', - name: values.name, - provider_uuid: '', - provider, - extra_args: extraArgsObj, - } as never); - toast.success(t('models.testSuccess')); - } catch (err) { - setTestErrorMessage((err as Error).message || t('models.testError')); - } finally { - setModelTesting(false); - } - } - - const addExtraArg = () => { - const newArgs = [ - ...extraArgs, - { key: '', type: 'string' as const, value: '' }, - ]; - setExtraArgs(newArgs); - form.setValue('extra_args', newArgs); - }; - - const updateExtraArg = ( - index: number, - field: 'key' | 'type' | 'value', - value: string, - ) => { - const newArgs = [...extraArgs]; - newArgs[index] = { ...newArgs[index], [field]: value }; - setExtraArgs(newArgs); - form.setValue('extra_args', newArgs); - }; - - const removeExtraArg = (index: number) => { - const newArgs = extraArgs.filter((_, i) => i !== index); - setExtraArgs(newArgs); - form.setValue('extra_args', newArgs); - }; - - return ( -
- - - - {t('common.confirmDelete')} - - - {t('models.deleteConfirmation')} - - - - - - - - -
- - ( - - - {t('models.modelName')} - * - - - - - - {t('models.modelProviderDescription')} - - - - )} - /> - -
- {t('models.provider')} - setProviderMode(v as 'existing' | 'new')} - className="mt-2" - > - - - {t('models.existingProvider')} - - {t('models.newProvider')} - - - - ( - - - - - )} - /> - - - - ( - - {t('models.requester')} - - - - )} - /> - - ( - - {t('models.requestURL')} - - - - - - )} - /> - - ( - - {t('models.apiKey')} - - - - - - )} - /> - - -
- - - {t('models.extraParameters')} -
- {extraArgs.map((arg, index) => ( -
- - updateExtraArg(index, 'key', e.target.value) - } - /> - - - updateExtraArg(index, 'value', e.target.value) - } - /> - -
- ))} - -
- - {t('embedding.extraParametersDescription')} - -
- - {testErrorMessage && ( - - - {t('models.testError')} - - {testErrorMessage} - - - )} - - - {editMode && ( - - )} - - - - - - -
- ); -} diff --git a/web/src/app/home/components/models-dialog/component/llm-card/LLMCard.module.css b/web/src/app/home/components/models-dialog/component/llm-card/LLMCard.module.css deleted file mode 100644 index c6eed0b7..00000000 --- a/web/src/app/home/components/models-dialog/component/llm-card/LLMCard.module.css +++ /dev/null @@ -1,173 +0,0 @@ -.cardContainer { - width: 100%; - height: 10rem; - background-color: #fff; - border-radius: 10px; - box-shadow: 0px 2px 2px 0 rgba(0, 0, 0, 0.2); - padding: 1.2rem; - cursor: pointer; - transition: all 0.2s ease; -} - -:global(.dark) .cardContainer { - background-color: #1f1f22; - box-shadow: 0; -} - -.cardContainer:hover { - box-shadow: 0px 2px 8px 0 rgba(0, 0, 0, 0.1); -} - -:global(.dark) .cardContainer:hover { - box-shadow: 0; -} - -.iconBasicInfoContainer { - width: 100%; - height: 100%; - display: flex; - flex-direction: row; - gap: 0.8rem; - user-select: none; - /* background-color: aqua; */ -} - -.iconImage { - width: 3.8rem; - height: 3.8rem; - margin: 0.2rem; - border-radius: 8%; -} - -.basicInfoContainer { - display: flex; - flex-direction: column; - gap: 0.2rem; - min-width: 0; - width: 100%; -} - -.basicInfoText { - font-size: 1.4rem; - font-weight: bold; - color: #1a1a1a; -} - -:global(.dark) .basicInfoText { - color: #f0f0f0; -} - -.providerContainer { - display: flex; - flex-direction: row; - align-items: center; - justify-content: flex-start; - gap: 0.2rem; -} - -.providerIcon { - width: 1.2rem; - height: 1.2rem; - margin-top: 0.2rem; - color: #626262; -} - -:global(.dark) .providerIcon { - color: #a0a0a0; -} - -.providerLabel { - font-size: 1.2rem; - font-weight: 600; - color: #626262; -} - -:global(.dark) .providerLabel { - color: #a0a0a0; -} - -.baseURLContainer { - display: flex; - flex-direction: row; - align-items: center; - justify-content: flex-start; - gap: 0.2rem; - width: calc(100% - 3rem); -} - -.baseURLIcon { - width: 1.2rem; - height: 1.2rem; - color: #626262; -} - -:global(.dark) .baseURLIcon { - color: #a0a0a0; -} - -.baseURLText { - font-size: 1rem; - width: 100%; - color: #626262; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - max-width: 100%; -} - -:global(.dark) .baseURLText { - color: #a0a0a0; -} - -.abilitiesContainer { - display: flex; - flex-direction: row; - align-items: center; - justify-content: flex-start; - gap: 0.4rem; -} - -.abilityBadge { - display: flex; - flex-direction: row; - align-items: center; - justify-content: flex-start; - gap: 0.2rem; - height: 1.5rem; - padding: 0.5rem; - border-radius: 0.8rem; - background-color: #66baff80; -} - -:global(.dark) .abilityBadge { - background-color: rgba(34, 136, 238, 0.3); -} - -.abilityIcon { - width: 1rem; - height: 1rem; - color: #2288ee; -} - -:global(.dark) .abilityIcon { - color: #66baff; -} - -.abilityLabel { - font-size: 0.8rem; - font-weight: 400; - color: #2288ee; -} - -:global(.dark) .abilityLabel { - color: #66baff; -} - -.bigText { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - font-size: 1.4rem; - font-weight: bold; - max-width: 100%; -} diff --git a/web/src/app/home/components/models-dialog/component/llm-card/LLMCard.tsx b/web/src/app/home/components/models-dialog/component/llm-card/LLMCard.tsx deleted file mode 100644 index 5cca970a..00000000 --- a/web/src/app/home/components/models-dialog/component/llm-card/LLMCard.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import styles from './LLMCard.module.css'; -import { LLMCardVO } from './LLMCardVO'; -import { useTranslation } from 'react-i18next'; - -function AbilityBadges(abilities: string[]) { - const { t } = useTranslation(); - const abilityBadges = { - vision: ( -
- - - - - {t('models.visionAbility')} - -
- ), - func_call: ( -
- - - - - {t('models.functionCallAbility')} - -
- ), - }; - - return abilities.map((ability) => { - return abilityBadges[ability as keyof typeof abilityBadges]; - }); -} - -export default function LLMCard({ cardVO }: { cardVO: LLMCardVO }) { - return ( -
-
- icon - -
- {/* 名称 */} -
- {cardVO.name} -
- {/* 厂商 */} -
- - - - - {cardVO.providerLabel} - -
- {/* baseURL */} -
- - - - {cardVO.baseURL} -
- {/* 能力 */} -
- {AbilityBadges(cardVO.abilities)} -
-
-
-
- ); -} diff --git a/web/src/app/home/components/models-dialog/component/llm-card/LLMCardVO.ts b/web/src/app/home/components/models-dialog/component/llm-card/LLMCardVO.ts deleted file mode 100644 index 274cede1..00000000 --- a/web/src/app/home/components/models-dialog/component/llm-card/LLMCardVO.ts +++ /dev/null @@ -1,26 +0,0 @@ -export interface ILLMCardVO { - id: string; - iconURL: string; - name: string; - providerLabel: string; - baseURL: string; - abilities: string[]; -} - -export class LLMCardVO implements ILLMCardVO { - id: string; - iconURL: string; - providerLabel: string; - name: string; - baseURL: string; - abilities: string[]; - - constructor(props: ILLMCardVO) { - this.id = props.id; - this.iconURL = props.iconURL; - this.providerLabel = props.providerLabel; - this.name = props.name; - this.baseURL = props.baseURL; - this.abilities = props.abilities; - } -} diff --git a/web/src/app/home/components/models-dialog/component/llm-form/LLMForm.tsx b/web/src/app/home/components/models-dialog/component/llm-form/LLMForm.tsx deleted file mode 100644 index 7d816d3a..00000000 --- a/web/src/app/home/components/models-dialog/component/llm-form/LLMForm.tsx +++ /dev/null @@ -1,655 +0,0 @@ -import { useEffect, useState } from 'react'; -import { httpClient } from '@/app/infra/http/HttpClient'; -import { ModelProvider } from '@/app/infra/entities/api'; - -import { zodResolver } from '@hookform/resolvers/zod'; -import { useForm } from 'react-hook-form'; -import { z } from 'zod'; -import { useTranslation } from 'react-i18next'; - -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogFooter, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@/components/ui/form'; -import { Input } from '@/components/ui/input'; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { Checkbox } from '@/components/ui/checkbox'; -import { toast } from 'sonner'; -import { extractI18nObject } from '@/i18n/I18nProvider'; -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; -import { AlertCircle } from 'lucide-react'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; - -const getFormSchema = (t: (key: string) => string) => - z.object({ - name: z.string().min(1, { message: t('models.modelNameRequired') }), - provider_uuid: z.string().optional(), - // New provider fields - new_provider_requester: z.string().optional(), - new_provider_url: z.string().optional(), - new_provider_api_key: z.string().optional(), - abilities: z.array(z.string()), - extra_args: z - .array( - z.object({ - key: z.string(), - type: z.enum(['string', 'number', 'boolean']), - value: z.string(), - }), - ) - .optional(), - }); - -interface LLMFormProps { - editMode: boolean; - initLLMId?: string; - providers: ModelProvider[]; - onFormSubmit: () => void; - onFormCancel: () => void; - onLLMDeleted: () => void; -} - -export default function LLMForm({ - editMode, - initLLMId, - providers, - onFormSubmit, - onFormCancel, - onLLMDeleted, -}: LLMFormProps) { - const { t } = useTranslation(); - const formSchema = getFormSchema(t); - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - name: '', - provider_uuid: '', - new_provider_requester: '', - new_provider_url: '', - new_provider_api_key: '', - abilities: [], - extra_args: [], - }, - }); - - const [extraArgs, setExtraArgs] = useState< - { key: string; type: 'string' | 'number' | 'boolean'; value: string }[] - >([]); - const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false); - const [modelTesting, setModelTesting] = useState(false); - const [testErrorMessage, setTestErrorMessage] = useState(null); - const [providerMode, setProviderMode] = useState<'existing' | 'new'>( - 'existing', - ); - - const [requesterList, setRequesterList] = useState< - { label: string; value: string; category: string; defaultUrl: string }[] - >([]); - - const abilityOptions = [ - { label: t('models.visionAbility'), value: 'vision' }, - { label: t('models.functionCallAbility'), value: 'func_call' }, - ]; - - useEffect(() => { - loadRequesters(); - if (editMode && initLLMId) { - loadModel(initLLMId); - } - }, [editMode, initLLMId]); - - async function loadRequesters() { - const resp = await httpClient.getProviderRequesters('llm'); - setRequesterList( - resp.requesters.map((item) => ({ - label: extractI18nObject(item.label), - value: item.name, - category: item.spec.provider_category || 'manufacturer', - defaultUrl: - item.spec.config - .find((c) => c.name === 'base_url') - ?.default?.toString() || '', - })), - ); - } - - async function loadModel(id: string) { - const resp = await httpClient.getProviderLLMModel(id); - const model = resp.model; - - form.setValue('name', model.name); - form.setValue('provider_uuid', model.provider_uuid); - form.setValue('abilities', model.abilities || []); - - if (model.extra_args) { - const args = Object.entries(model.extra_args).map(([key, value]) => { - let type: 'string' | 'number' | 'boolean' = 'string'; - if (typeof value === 'number') type = 'number'; - else if (typeof value === 'boolean') type = 'boolean'; - return { key, type, value: String(value) }; - }); - setExtraArgs(args); - form.setValue('extra_args', args); - } - - setProviderMode('existing'); - } - - function handleFormSubmit(values: z.infer) { - const extraArgsObj: Record = {}; - values.extra_args?.forEach((arg) => { - if (arg.type === 'number') extraArgsObj[arg.key] = Number(arg.value); - else if (arg.type === 'boolean') - extraArgsObj[arg.key] = arg.value === 'true'; - else extraArgsObj[arg.key] = arg.value; - }); - - const modelData: Record = { - name: values.name, - abilities: values.abilities, - extra_args: extraArgsObj, - }; - - if (providerMode === 'existing' && values.provider_uuid) { - modelData.provider_uuid = values.provider_uuid; - } else if (providerMode === 'new') { - modelData.provider = { - requester: values.new_provider_requester, - base_url: values.new_provider_url, - api_keys: values.new_provider_api_key - ? [values.new_provider_api_key] - : [], - }; - } - - if (editMode && initLLMId) { - updateModel(initLLMId, modelData); - } else { - createModel(modelData); - } - } - - async function createModel(data: Record) { - try { - await httpClient.createProviderLLMModel(data as never); - toast.success(t('models.createSuccess')); - onFormSubmit(); - } catch (err) { - toast.error(t('models.createError') + (err as Error).message); - } - } - - async function updateModel(id: string, data: Record) { - try { - await httpClient.updateProviderLLMModel(id, data as never); - toast.success(t('models.saveSuccess')); - onFormSubmit(); - } catch (err) { - toast.error(t('models.saveError') + (err as Error).message); - } - } - - async function deleteModel() { - if (!initLLMId) return; - try { - await httpClient.deleteProviderLLMModel(initLLMId); - toast.success(t('models.deleteSuccess')); - onLLMDeleted(); - } catch (err) { - toast.error(t('models.deleteError') + (err as Error).message); - } - } - - async function testModel() { - setModelTesting(true); - setTestErrorMessage(null); - - const values = form.getValues(); - const extraArgsObj: Record = {}; - values.extra_args?.forEach((arg) => { - if (arg.type === 'number') extraArgsObj[arg.key] = Number(arg.value); - else if (arg.type === 'boolean') - extraArgsObj[arg.key] = arg.value === 'true'; - else extraArgsObj[arg.key] = arg.value; - }); - - let provider: Record; - if (providerMode === 'existing' && values.provider_uuid) { - const p = providers.find((p) => p.uuid === values.provider_uuid); - provider = { - requester: p?.requester || '', - base_url: p?.base_url || '', - api_keys: p?.api_keys || [], - }; - } else { - provider = { - requester: values.new_provider_requester, - base_url: values.new_provider_url, - api_keys: values.new_provider_api_key - ? [values.new_provider_api_key] - : [], - }; - } - - try { - await httpClient.testLLMModel('_', { - uuid: '', - name: values.name, - provider_uuid: '', - provider, - abilities: values.abilities, - extra_args: extraArgsObj, - } as never); - toast.success(t('models.testSuccess')); - } catch (err) { - setTestErrorMessage((err as Error).message || t('models.testError')); - } finally { - setModelTesting(false); - } - } - - const addExtraArg = () => { - const newArgs = [ - ...extraArgs, - { key: '', type: 'string' as const, value: '' }, - ]; - setExtraArgs(newArgs); - form.setValue('extra_args', newArgs); - }; - - const updateExtraArg = ( - index: number, - field: 'key' | 'type' | 'value', - value: string, - ) => { - const newArgs = [...extraArgs]; - newArgs[index] = { ...newArgs[index], [field]: value }; - setExtraArgs(newArgs); - form.setValue('extra_args', newArgs); - }; - - const removeExtraArg = (index: number) => { - const newArgs = extraArgs.filter((_, i) => i !== index); - setExtraArgs(newArgs); - form.setValue('extra_args', newArgs); - }; - - return ( -
- - - - {t('common.confirmDelete')} - - - {t('models.deleteConfirmation')} - - - - - - - - -
- - ( - - - {t('models.modelName')} - * - - - - - - {t('models.modelProviderDescription')} - - - - )} - /> - -
- {t('models.provider')} - setProviderMode(v as 'existing' | 'new')} - className="mt-2" - > - - - {t('models.existingProvider')} - - {t('models.newProvider')} - - - - ( - - - - - )} - /> - - - - ( - - {t('models.requester')} - - - - )} - /> - - ( - - {t('models.requestURL')} - - - - - - )} - /> - - ( - - {t('models.apiKey')} - - - - - - )} - /> - - -
- - ( - - {t('models.abilities')} - - {t('models.selectModelAbilities')} - - {abilityOptions.map((item) => ( - ( - - - { - if (checked) { - field.onChange([ - ...(field.value || []), - item.value, - ]); - } else { - field.onChange( - field.value?.filter( - (v: string) => v !== item.value, - ), - ); - } - }} - /> - - - {item.label} - - - )} - /> - ))} - - )} - /> - - - {t('models.extraParameters')} -
- {extraArgs.map((arg, index) => ( -
- - updateExtraArg(index, 'key', e.target.value) - } - /> - - - updateExtraArg(index, 'value', e.target.value) - } - /> - -
- ))} - -
- - {t('llm.extraParametersDescription')} - -
- - {testErrorMessage && ( - - - {t('models.testError')} - - {testErrorMessage} - - - )} - - - {editMode && ( - - )} - - - - - - -
- ); -} diff --git a/web/src/app/home/components/models-dialog/component/provider-form/ProviderForm.tsx b/web/src/app/home/components/models-dialog/component/provider-form/ProviderForm.tsx index 70afb369..022a44db 100644 --- a/web/src/app/home/components/models-dialog/component/provider-form/ProviderForm.tsx +++ b/web/src/app/home/components/models-dialog/component/provider-form/ProviderForm.tsx @@ -155,7 +155,9 @@ export default function ProviderForm({ onValueChange={(v) => { field.onChange(v); const req = requesterList.find((r) => r.value === v); - if (req && !form.getValues('base_url')) { + // Auto-fill default URL when creating new provider + // or when base_url is empty in edit mode + if (req && (!providerId || !form.getValues('base_url'))) { form.setValue('base_url', req.defaultUrl); } }} diff --git a/web/src/app/home/components/models-dialog/components/AddModelPopover.tsx b/web/src/app/home/components/models-dialog/components/AddModelPopover.tsx new file mode 100644 index 00000000..0afb438c --- /dev/null +++ b/web/src/app/home/components/models-dialog/components/AddModelPopover.tsx @@ -0,0 +1,235 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Plus, MessageSquareText, Cpu, Eye, Wrench, Check } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { useTranslation } from 'react-i18next'; +import { ExtraArg, ModelType, TestResult } from '../types'; +import ExtraArgsEditor from './ExtraArgsEditor'; + +interface AddModelPopoverProps { + providerUuid: string; + isOpen: boolean; + onOpen: () => void; + onClose: () => void; + onAddModel: ( + modelType: ModelType, + name: string, + abilities: string[], + extraArgs: ExtraArg[], + ) => Promise; + onTestModel: ( + name: string, + modelType: ModelType, + abilities: string[], + extraArgs: ExtraArg[], + ) => Promise; + isSubmitting: boolean; + isTesting: boolean; + testResult: TestResult | null; + onResetTestResult: () => void; +} + +export default function AddModelPopover({ + providerUuid, + isOpen, + onOpen, + onClose, + onAddModel, + onTestModel, + isSubmitting, + isTesting, + testResult, + onResetTestResult, +}: AddModelPopoverProps) { + const { t } = useTranslation(); + + const [tab, setTab] = useState('llm'); + const [name, setName] = useState(''); + const [abilities, setAbilities] = useState([]); + const [extraArgs, setExtraArgs] = useState([]); + + // Reset form when popover opens + useEffect(() => { + if (isOpen) { + setTab('llm'); + setName(''); + setAbilities([]); + setExtraArgs([]); + onResetTestResult(); + } + }, [isOpen]); + + const handleAdd = async () => { + await onAddModel(tab, name, abilities, extraArgs); + }; + + const handleTest = async () => { + await onTestModel(name, tab, tab === 'llm' ? abilities : [], extraArgs); + }; + + const toggleAbility = (ability: string, checked: boolean) => { + if (checked) { + setAbilities([...abilities, ability]); + } else { + setAbilities(abilities.filter((a) => a !== ability)); + } + }; + + return ( + (open ? onOpen() : onClose())} + > + + + + e.stopPropagation()} + > + setTab(v as ModelType)}> + + + + {t('models.chat')} + + + + {t('models.embedding')} + + + + +
+ + setName(e.target.value)} + /> +
+
+ +
+
+ + toggleAbility('vision', checked as boolean) + } + /> + +
+
+ + toggleAbility('func_call', checked as boolean) + } + /> + +
+
+
+ +
+ + +
+
+ + +
+ + setName(e.target.value)} + /> +
+ +
+ + +
+
+
+
+
+ ); +} diff --git a/web/src/app/home/components/models-dialog/components/ExtraArgsEditor.tsx b/web/src/app/home/components/models-dialog/components/ExtraArgsEditor.tsx new file mode 100644 index 00000000..e17a229e --- /dev/null +++ b/web/src/app/home/components/models-dialog/components/ExtraArgsEditor.tsx @@ -0,0 +1,114 @@ +'use client'; + +import { Plus, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useTranslation } from 'react-i18next'; +import { ExtraArg } from '../types'; + +interface ExtraArgsEditorProps { + args: ExtraArg[]; + onChange: (args: ExtraArg[]) => void; + disabled?: boolean; +} + +export default function ExtraArgsEditor({ + args, + onChange, + disabled = false, +}: ExtraArgsEditorProps) { + const { t } = useTranslation(); + + const handleAdd = () => { + onChange([...args, { key: '', type: 'string', value: '' }]); + }; + + const handleRemove = (index: number) => { + onChange(args.filter((_, i) => i !== index)); + }; + + const handleUpdate = ( + index: number, + field: keyof ExtraArg, + value: string, + ) => { + const newArgs = [...args]; + newArgs[index] = { ...newArgs[index], [field]: value }; + onChange(newArgs); + }; + + return ( +
+
+ + {!disabled && ( + + )} +
+ {args.length === 0 ? ( +

{t('common.none')}

+ ) : ( + args.map((arg, index) => ( +
+ handleUpdate(index, 'key', e.target.value)} + /> + + handleUpdate(index, 'value', e.target.value)} + /> + {!disabled && ( + + )} +
+ )) + )} +
+ ); +} diff --git a/web/src/app/home/components/models-dialog/components/ModelItem.tsx b/web/src/app/home/components/models-dialog/components/ModelItem.tsx new file mode 100644 index 00000000..113e5cff --- /dev/null +++ b/web/src/app/home/components/models-dialog/components/ModelItem.tsx @@ -0,0 +1,294 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Trash2, Eye, Wrench, Check } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { useTranslation } from 'react-i18next'; +import { LLMModel, EmbeddingModel } from '@/app/infra/entities/api'; +import { ExtraArg, ModelType, TestResult } from '../types'; +import ExtraArgsEditor from './ExtraArgsEditor'; + +interface ModelItemProps { + model: LLMModel | EmbeddingModel; + modelType: ModelType; + providerUuid: string; + isLangBotModels: boolean; + editModelPopoverOpen: string | null; + deleteConfirmOpen: string | null; + onOpenEditModel: (modelId: string) => void; + onCloseEditModel: () => void; + onOpenDeleteConfirm: (modelId: string) => void; + onCloseDeleteConfirm: () => void; + onDeleteModel: () => void; + onUpdateModel: ( + name: string, + abilities: string[], + extraArgs: ExtraArg[], + ) => Promise; + onTestModel: ( + name: string, + abilities: string[], + extraArgs: ExtraArg[], + ) => Promise; + isSubmitting: boolean; + isTesting: boolean; + testResult: TestResult | null; + onResetTestResult: () => void; +} + +function convertExtraArgsToArray(extraArgs?: object): ExtraArg[] { + if (!extraArgs) return []; + return Object.entries(extraArgs).map(([key, value]) => { + let type: 'string' | 'number' | 'boolean' = 'string'; + if (typeof value === 'number') type = 'number'; + else if (typeof value === 'boolean') type = 'boolean'; + return { key, type, value: String(value) }; + }); +} + +export default function ModelItem({ + model, + modelType, + providerUuid, + isLangBotModels, + editModelPopoverOpen, + deleteConfirmOpen, + onOpenEditModel, + onCloseEditModel, + onOpenDeleteConfirm, + onCloseDeleteConfirm, + onDeleteModel, + onUpdateModel, + onTestModel, + isSubmitting, + isTesting, + testResult, + onResetTestResult, +}: ModelItemProps) { + const { t } = useTranslation(); + + const [editName, setEditName] = useState(model.name); + const [editAbilities, setEditAbilities] = useState( + modelType === 'llm' ? (model as LLMModel).abilities || [] : [], + ); + const [editExtraArgs, setEditExtraArgs] = useState( + convertExtraArgsToArray(model.extra_args), + ); + + const isEditOpen = editModelPopoverOpen === model.uuid; + const isDeleteOpen = deleteConfirmOpen === model.uuid; + + // Reset form when popover opens + useEffect(() => { + if (isEditOpen) { + setEditName(model.name); + setEditAbilities( + modelType === 'llm' ? (model as LLMModel).abilities || [] : [], + ); + setEditExtraArgs(convertExtraArgsToArray(model.extra_args)); + onResetTestResult(); + } + }, [isEditOpen]); + + const handleSave = async () => { + await onUpdateModel(editName, editAbilities, editExtraArgs); + }; + + const handleTest = async () => { + await onTestModel(editName, editAbilities, editExtraArgs); + }; + + const toggleAbility = (ability: string, checked: boolean) => { + if (checked) { + setEditAbilities([...editAbilities, ability]); + } else { + setEditAbilities(editAbilities.filter((a) => a !== ability)); + } + }; + + return ( + { + if (open) { + onOpenEditModel(model.uuid); + } else { + onCloseEditModel(); + } + }} + > + +
+
+ {model.name} + + {modelType === 'llm' ? t('models.chat') : t('models.embedding')} + + {modelType === 'llm' && + (model as LLMModel).abilities?.includes('vision') && ( + + + + )} + {modelType === 'llm' && + (model as LLMModel).abilities?.includes('func_call') && ( + + + + )} +
+ {!isLangBotModels && ( + + open ? onOpenDeleteConfirm(model.uuid) : onCloseDeleteConfirm() + } + > + + + + e.stopPropagation()} + > +
+

{t('models.deleteConfirmation')}

+
+ + +
+
+
+
+ )} +
+
+ +
+
+ + setEditName(e.target.value)} + disabled={isLangBotModels} + /> +
+ + {modelType === 'llm' && ( +
+ +
+
+ + toggleAbility('vision', checked as boolean) + } + /> + +
+
+ + toggleAbility('func_call', checked as boolean) + } + /> + +
+
+
+ )} + + + +
+ {!isLangBotModels && ( + + )} + +
+
+
+
+ ); +} diff --git a/web/src/app/home/components/models-dialog/components/ProviderCard.tsx b/web/src/app/home/components/models-dialog/components/ProviderCard.tsx new file mode 100644 index 00000000..1076c032 --- /dev/null +++ b/web/src/app/home/components/models-dialog/components/ProviderCard.tsx @@ -0,0 +1,371 @@ +'use client'; + +import { + Plus, + ChevronDown, + ChevronRight, + Trash2, + Settings, + LogIn, +} from 'lucide-react'; +import { httpClient, systemInfo } from '@/app/infra/http/HttpClient'; +import { + ModelProvider, + LLMModel, + EmbeddingModel, +} from '@/app/infra/entities/api'; +import { Button } from '@/components/ui/button'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { useTranslation } from 'react-i18next'; +import langbotIcon from '@/app/assets/langbot-logo.webp'; +import { ExtraArg, ModelType, TestResult, ProviderModels } from '../types'; +import ModelItem from './ModelItem'; +import AddModelPopover from './AddModelPopover'; + +interface ProviderCardProps { + provider: ModelProvider; + isLangBotModels?: boolean; + isExpanded: boolean; + isLoading: boolean; + models?: ProviderModels; + accountType: 'local' | 'space'; + spaceCredits: number | null; + requesterNameList: { label: string; value: string }[]; + // Popover states + addModelPopoverOpen: string | null; + editModelPopoverOpen: string | null; + deleteConfirmOpen: string | null; + // Handlers + onToggle: () => void; + onEditProvider: () => void; + onDeleteProvider: () => void; + onSpaceLogin: () => void; + onOpenAddModel: () => void; + onCloseAddModel: () => void; + onAddModel: ( + modelType: ModelType, + name: string, + abilities: string[], + extraArgs: ExtraArg[], + ) => Promise; + onOpenEditModel: (modelId: string) => void; + onCloseEditModel: () => void; + onUpdateModel: ( + modelId: string, + modelType: ModelType, + name: string, + abilities: string[], + extraArgs: ExtraArg[], + ) => Promise; + onOpenDeleteConfirm: (modelId: string) => void; + onCloseDeleteConfirm: () => void; + onDeleteModel: (modelId: string, modelType: ModelType) => Promise; + onTestModel: ( + name: string, + modelType: ModelType, + abilities: string[], + extraArgs: ExtraArg[], + ) => Promise; + isSubmitting: boolean; + isTesting: boolean; + testResult: TestResult | null; + onResetTestResult: () => void; +} + +function maskApiKey(key: string): string { + if (!key) return ''; + if (key.length <= 8) return '****'; + return `${key.slice(0, 4)}...${key.slice(-4)}`; +} + +export default function ProviderCard({ + provider, + isLangBotModels = false, + isExpanded, + isLoading, + models, + accountType, + spaceCredits, + requesterNameList, + addModelPopoverOpen, + editModelPopoverOpen, + deleteConfirmOpen, + onToggle, + onEditProvider, + onDeleteProvider, + onSpaceLogin, + onOpenAddModel, + onCloseAddModel, + onAddModel, + onOpenEditModel, + onCloseEditModel, + onUpdateModel, + onOpenDeleteConfirm, + onCloseDeleteConfirm, + onDeleteModel, + onTestModel, + isSubmitting, + isTesting, + testResult, + onResetTestResult, +}: ProviderCardProps) { + const { t } = useTranslation(); + + const canDelete = + !isLangBotModels && + (provider.llm_count || 0) === 0 && + (provider.embedding_count || 0) === 0; + const totalModels = + (provider.llm_count || 0) + (provider.embedding_count || 0); + + const getRequesterLabel = (requester: string) => { + return ( + requesterNameList.find((r) => r.value === requester)?.label || requester + ); + }; + + return ( + + + +
+
+ {isLangBotModels ? ( +
+ LangBot +
+ ) : ( + {provider.name} + )} +
+
+ + {isLangBotModels + ? provider.name + : getRequesterLabel(provider.requester)} + + + {t('models.modelsCount', { count: totalModels })} + +
+

+ {isLangBotModels ? ( + t('models.langbotModelsDescription') + ) : ( + <> + {provider.base_url} + {provider.base_url && + provider.api_keys?.length > 0 && + ' · '} + {provider.api_keys?.length > 0 && + maskApiKey(provider.api_keys[0])} + + )} +

+
+
+
+ {isLangBotModels && accountType !== 'space' && ( + + )} + {isLangBotModels && + accountType === 'space' && + spaceCredits !== null && ( +
+ + {(spaceCredits / 5000).toFixed(2)} {t('models.credits')} + + +
+ )} + {!isLangBotModels && ( + <> + + {canDelete && ( + + )} + + )} +
+
+
+ {totalModels > 0 ? ( + + {isExpanded ? ( + + ) : ( + + )} + + {isExpanded + ? t('models.collapseModels') + : t('models.expandModels')} + + + ) : ( +
+ )} + {!isLangBotModels && ( + + )} +
+ + + + {isLoading ? ( +

+ {t('common.loading')}... +

+ ) : models ? ( +
+ {models.llm.map((model) => ( + onDeleteModel(model.uuid, 'llm')} + onUpdateModel={(name, abilities, extraArgs) => + onUpdateModel( + model.uuid, + 'llm', + name, + abilities, + extraArgs, + ) + } + onTestModel={(name, abilities, extraArgs) => + onTestModel(name, 'llm', abilities, extraArgs) + } + isSubmitting={isSubmitting} + isTesting={isTesting} + testResult={testResult} + onResetTestResult={onResetTestResult} + /> + ))} + {models.embedding.map((model) => ( + onDeleteModel(model.uuid, 'embedding')} + onUpdateModel={(name, abilities, extraArgs) => + onUpdateModel( + model.uuid, + 'embedding', + name, + abilities, + extraArgs, + ) + } + onTestModel={(name, abilities, extraArgs) => + onTestModel(name, 'embedding', abilities, extraArgs) + } + isSubmitting={isSubmitting} + isTesting={isTesting} + testResult={testResult} + onResetTestResult={onResetTestResult} + /> + ))} + {models.llm.length === 0 && models.embedding.length === 0 && ( +

+ {t('models.noModels')} +

+ )} +
+ ) : ( +

+ {t('models.noModels')} +

+ )} +
+
+ + + ); +} diff --git a/web/src/app/home/components/models-dialog/components/index.ts b/web/src/app/home/components/models-dialog/components/index.ts new file mode 100644 index 00000000..4e0a41cf --- /dev/null +++ b/web/src/app/home/components/models-dialog/components/index.ts @@ -0,0 +1,4 @@ +export { default as ExtraArgsEditor } from './ExtraArgsEditor'; +export { default as ModelItem } from './ModelItem'; +export { default as AddModelPopover } from './AddModelPopover'; +export { default as ProviderCard } from './ProviderCard'; diff --git a/web/src/app/home/components/models-dialog/types.ts b/web/src/app/home/components/models-dialog/types.ts new file mode 100644 index 00000000..15217269 --- /dev/null +++ b/web/src/app/home/components/models-dialog/types.ts @@ -0,0 +1,102 @@ +import { + LLMModel, + EmbeddingModel, + ModelProvider, +} from '@/app/infra/entities/api'; + +export type ExtraArg = { + key: string; + type: 'string' | 'number' | 'boolean'; + value: string; +}; + +export type ModelType = 'llm' | 'embedding'; + +export interface ProviderModels { + llm: LLMModel[]; + embedding: EmbeddingModel[]; +} + +export interface TestResult { + success: boolean; + duration: number; +} + +export interface ModelItemProps { + model: LLMModel | EmbeddingModel; + modelType: ModelType; + providerUuid: string; + isLangBotModels: boolean; + isEditOpen: boolean; + isDeleteOpen: boolean; + onEditOpen: () => void; + onEditClose: () => void; + onDeleteOpen: () => void; + onDeleteClose: () => void; + onDelete: () => void; + onUpdate: ( + name: string, + abilities: string[], + extraArgs: ExtraArg[], + ) => Promise; + onTest: ( + name: string, + abilities: string[], + extraArgs: ExtraArg[], + ) => Promise; + isSubmitting: boolean; + isTesting: boolean; + testResult: TestResult | null; +} + +export interface ProviderCardProps { + provider: ModelProvider; + isLangBotModels?: boolean; + isExpanded: boolean; + isLoading: boolean; + models?: ProviderModels; + accountType: 'local' | 'space'; + spaceCredits: number | null; + requesterNameList: { label: string; value: string }[]; + // Popover states + addModelPopoverOpen: string | null; + editModelPopoverOpen: string | null; + deleteConfirmOpen: string | null; + // Handlers + onToggle: () => void; + onEditProvider: () => void; + onDeleteProvider: () => void; + onSpaceLogin: () => void; + onOpenAddModel: () => void; + onCloseAddModel: () => void; + onAddModel: ( + modelType: ModelType, + name: string, + abilities: string[], + extraArgs: ExtraArg[], + ) => Promise; + onOpenEditModel: (modelId: string) => void; + onCloseEditModel: () => void; + onUpdateModel: ( + modelId: string, + modelType: ModelType, + name: string, + abilities: string[], + extraArgs: ExtraArg[], + ) => Promise; + onOpenDeleteConfirm: (modelId: string) => void; + onCloseDeleteConfirm: () => void; + onDeleteModel: (modelId: string, modelType: ModelType) => Promise; + onTestModel: ( + name: string, + modelType: ModelType, + abilities: string[], + extraArgs: ExtraArg[], + ) => Promise; + isSubmitting: boolean; + isTesting: boolean; + testResult: TestResult | null; + onResetTestResult: () => void; +} + +export const LANGBOT_MODELS_PROVIDER_REQUESTER = 'space-chat-completions'; diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index ba134975..cc6847fa 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -122,6 +122,7 @@ const enUS = { 'Webhooks allow LangBot to push person and group message events to external systems', actions: 'Actions', apiKeyCreatedMessage: 'Please copy this API key.', + none: 'None', }, notFound: { title: 'Page not found', @@ -206,6 +207,9 @@ const enUS = { loginToUseModels: 'Login with Space to use cloud models', noModels: 'No models configured', editProvider: 'Edit Provider', + addProvider: 'Add Provider', + addProviderHint: 'Add providers to use models from other sources', + noProviders: 'No providers yet', providerName: 'Provider Name', providerNameRequired: 'Provider name is required', requesterRequired: 'Provider type is required', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index d9c18db7..ccf20861 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -124,6 +124,7 @@ const jaJP = { 'Webhook を使用すると、LangBot は個人メッセージとグループメッセージイベントを外部システムにプッシュできます', actions: 'アクション', apiKeyCreatedMessage: 'この API キーをコピーしてください。', + none: 'なし', }, notFound: { title: 'ページが見つかりません', @@ -211,6 +212,10 @@ const jaJP = { loginToUseModels: 'Space でログインしてクラウドモデルを使用', noModels: 'モデルがありません', editProvider: 'プロバイダーを編集', + addProvider: 'プロバイダーを追加', + addProviderHint: + '他のソースのモデルを使用するにはプロバイダーを追加してください', + noProviders: 'プロバイダーがありません', providerName: 'プロバイダー名', providerNameRequired: 'プロバイダー名は必須です', requesterRequired: 'プロバイダータイプは必須です', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index b1355b37..295a95b3 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -115,6 +115,7 @@ const zhHans = { webhookHint: 'Webhook 允许 LangBot 将个人消息和群消息事件推送到外部系统', actions: '操作', apiKeyCreatedMessage: '请复制此 API 密钥。', + none: '无', }, notFound: { title: '页面不存在', @@ -182,7 +183,7 @@ const zhHans = { spaceModelReadOnly: 'Space 模型为只读', noSpaceModels: '暂无 Space 模型。点击同步按钮从 Space 获取模型。', noLocalModels: '暂无本地模型。点击创建按钮添加模型。', - providerCount: '共 {{count}} 个供应商', + providerCount: '共 {{count}} 个自定义供应商', // 供应商结构新增键 addModel: '添加模型', addLLMModel: '添加对话模型', @@ -199,6 +200,9 @@ const zhHans = { loginToUseModels: '通过 Space 登录以使用云端模型', noModels: '暂无模型', editProvider: '编辑供应商', + addProvider: '添加供应商', + addProviderHint: '添加自定义供应商以使用其他来源的模型', + noProviders: '暂无自定义供应商', providerName: '供应商名称', providerNameRequired: '供应商名称不能为空', requesterRequired: '供应商类型不能为空', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index e0bd4363..da6db88e 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -115,6 +115,7 @@ const zhHant = { webhookHint: 'Webhook 允許 LangBot 將個人訊息和群組訊息事件推送到外部系統', actions: '操作', apiKeyCreatedMessage: '請複製此 API 金鑰。', + none: '無', }, notFound: { title: '頁面不存在', @@ -198,6 +199,9 @@ const zhHant = { loginToUseModels: '使用 Space 登入以使用雲端模型', noModels: '暫無模型', editProvider: '編輯供應商', + addProvider: '新增供應商', + addProviderHint: '新增供應商以使用其他來源的模型', + noProviders: '暫無供應商', providerName: '供應商名稱', providerNameRequired: '供應商名稱不能為空', requesterRequired: '供應商類型不能為空',