feat: implement models dialog for managing LLM and embedding models with dynamic URL handling

This commit is contained in:
Junyan Qin
2025-12-25 20:54:00 +08:00
parent 10ee30695a
commit 7479545339
16 changed files with 175 additions and 115 deletions
@@ -6,7 +6,7 @@ import {
SidebarChild,
SidebarChildVO,
} from '@/app/home/components/home-sidebar/HomeSidebarChild';
import { useRouter, usePathname } from 'next/navigation';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import { sidebarConfigList } from '@/app/home/components/home-sidebar/sidbarConfigList';
import langbotIcon from '@/app/assets/langbot-logo.webp';
import { systemInfo } from '@/app/infra/http/HttpClient';
@@ -36,6 +36,7 @@ import { Badge } from '@/components/ui/badge';
import PasswordChangeDialog from '@/app/home/components/password-change-dialog/PasswordChangeDialog';
import ApiIntegrationDialog from '@/app/home/components/api-integration-dialog/ApiIntegrationDialog';
import NewVersionDialog from '@/app/home/components/new-version-dialog/NewVersionDialog';
import ModelsDialog from '@/app/home/components/models-dialog/ModelsDialog';
import { GitHubRelease } from '@/app/infra/http/CloudServiceClient';
// Compare two version strings, returns true if v1 > v2
@@ -67,11 +68,19 @@ export default function HomeSidebar({
// 路由相关
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
// 路由被动变化时处理
useEffect(() => {
handleRouteChange(pathname);
}, [pathname]);
// 检查 URL 参数,自动打开模型对话框
useEffect(() => {
if (searchParams.get('action') === 'showModelSettings') {
setModelsDialogOpen(true);
}
}, [searchParams]);
const [selectedChild, setSelectedChild] = useState<SidebarChildVO>();
const { theme, setTheme } = useTheme();
const { t } = useTranslation();
@@ -85,6 +94,24 @@ export default function HomeSidebar({
);
const [hasNewVersion, setHasNewVersion] = useState(false);
const [versionDialogOpen, setVersionDialogOpen] = useState(false);
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
// 处理模型对话框的打开和关闭,同时更新 URL
function handleModelsDialogChange(open: boolean) {
setModelsDialogOpen(open);
if (open) {
const params = new URLSearchParams(searchParams.toString());
params.set('action', 'showModelSettings');
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
} else {
const params = new URLSearchParams(searchParams.toString());
params.delete('action');
const newUrl = params.toString()
? `${pathname}?${params.toString()}`
: pathname;
router.replace(newUrl, { scroll: false });
}
}
useEffect(() => {
initSelect();
@@ -252,6 +279,21 @@ export default function HomeSidebar({
</div>
)}
<SidebarChild
onClick={() => handleModelsDialogChange(true)}
isSelected={false}
icon={
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M10.6144 17.7956C10.277 18.5682 9.20776 18.5682 8.8704 17.7956L7.99275 15.7854C7.21171 13.9966 5.80589 12.5726 4.0523 11.7942L1.63658 10.7219C.868536 10.381.868537 9.26368 1.63658 8.92276L3.97685 7.88394C5.77553 7.08552 7.20657 5.60881 7.97427 3.75892L8.8633 1.61673C9.19319.821767 10.2916.821765 10.6215 1.61673L11.5105 3.75894C12.2782 5.60881 13.7092 7.08552 15.5079 7.88394L17.8482 8.92276C18.6162 9.26368 18.6162 10.381 17.8482 10.7219L15.4325 11.7942C13.6789 12.5726 12.2731 13.9966 11.492 15.7854L10.6144 17.7956ZM4.53956 9.82234C6.8254 10.837 8.68402 12.5048 9.74238 14.7996 10.8008 12.5048 12.6594 10.837 14.9452 9.82234 12.6321 8.79557 10.7676 7.04647 9.74239 4.71088 8.71719 7.04648 6.85267 8.79557 4.53956 9.82234ZM19.4014 22.6899 19.6482 22.1242C20.0882 21.1156 20.8807 20.3125 21.8695 19.8732L22.6299 19.5353C23.0412 19.3526 23.0412 18.7549 22.6299 18.5722L21.9121 18.2532C20.8978 17.8026 20.0911 16.9698 19.6586 15.9269L19.4052 15.3156C19.2285 14.8896 18.6395 14.8896 18.4628 15.3156L18.2094 15.9269C17.777 16.9698 16.9703 17.8026 15.956 18.2532L15.2381 18.5722C14.8269 18.7549 14.8269 19.3526 15.2381 19.5353L15.9985 19.8732C16.9874 20.3125 17.7798 21.1156 18.2198 22.1242L18.4667 22.6899C18.6473 23.104 19.2207 23.104 19.4014 22.6899ZM18.3745 19.0469 18.937 18.4883 19.4878 19.0469 18.937 19.5898 18.3745 19.0469Z"></path>
</svg>
}
name={t('models.title')}
/>
<Popover
open={popoverOpen}
onOpenChange={(open) => {
@@ -414,6 +456,10 @@ export default function HomeSidebar({
onOpenChange={setVersionDialogOpen}
release={latestRelease}
/>
<ModelsDialog
open={modelsDialogOpen}
onOpenChange={handleModelsDialogChange}
/>
</div>
);
}
@@ -27,27 +27,6 @@ export const sidebarConfigList = [
zh_Hans: 'https://docs.langbot.app/zh/deploy/platforms/readme.html',
},
}),
new SidebarChildVO({
id: 'models',
name: t('models.title'),
icon: (
<svg
className={`${styles.sidebarChildIcon}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M10.6144 17.7956C10.277 18.5682 9.20776 18.5682 8.8704 17.7956L7.99275 15.7854C7.21171 13.9966 5.80589 12.5726 4.0523 11.7942L1.63658 10.7219C.868536 10.381.868537 9.26368 1.63658 8.92276L3.97685 7.88394C5.77553 7.08552 7.20657 5.60881 7.97427 3.75892L8.8633 1.61673C9.19319.821767 10.2916.821765 10.6215 1.61673L11.5105 3.75894C12.2782 5.60881 13.7092 7.08552 15.5079 7.88394L17.8482 8.92276C18.6162 9.26368 18.6162 10.381 17.8482 10.7219L15.4325 11.7942C13.6789 12.5726 12.2731 13.9966 11.492 15.7854L10.6144 17.7956ZM4.53956 9.82234C6.8254 10.837 8.68402 12.5048 9.74238 14.7996 10.8008 12.5048 12.6594 10.837 14.9452 9.82234 12.6321 8.79557 10.7676 7.04647 9.74239 4.71088 8.71719 7.04648 6.85267 8.79557 4.53956 9.82234ZM19.4014 22.6899 19.6482 22.1242C20.0882 21.1156 20.8807 20.3125 21.8695 19.8732L22.6299 19.5353C23.0412 19.3526 23.0412 18.7549 22.6299 18.5722L21.9121 18.2532C20.8978 17.8026 20.0911 16.9698 19.6586 15.9269L19.4052 15.3156C19.2285 14.8896 18.6395 14.8896 18.4628 15.3156L18.2094 15.9269C17.777 16.9698 16.9703 17.8026 15.956 18.2532L15.2381 18.5722C14.8269 18.7549 14.8269 19.3526 15.2381 19.5353L15.9985 19.8732C16.9874 20.3125 17.7798 21.1156 18.2198 22.1242L18.4667 22.6899C18.6473 23.104 19.2207 23.104 19.4014 22.6899ZM18.3745 19.0469 18.937 18.4883 19.4878 19.0469 18.937 19.5898 18.3745 19.0469Z"></path>
</svg>
),
route: '/home/models',
description: t('models.description'),
helpLink: {
en_US: 'https://docs.langbot.app/en/deploy/models/readme.html',
zh_Hans: 'https://docs.langbot.app/zh/deploy/models/readme.html',
},
}),
new SidebarChildVO({
id: 'pipelines',
name: t('pipelines.title'),
@@ -0,0 +1,19 @@
.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;
}
@@ -0,0 +1,303 @@
'use client';
import { useState, useEffect } from 'react';
import { Plus, MessageSquareText, Cpu, Info } from 'lucide-react';
import { LLMCardVO } from './component/llm-card/LLMCardVO';
import LLMCard from './component/llm-card/LLMCard';
import LLMForm from './component/llm-form/LLMForm';
import { httpClient } from '@/app/infra/http/HttpClient';
import { LLMModel } from '@/app/infra/entities/api';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { EmbeddingCardVO } from './component/embedding-card/EmbeddingCardVO';
import EmbeddingCard from './component/embedding-card/EmbeddingCard';
import EmbeddingForm from './component/embedding-form/EmbeddingForm';
interface ModelsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export default function ModelsDialog({
open,
onOpenChange,
}: ModelsDialogProps) {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<string>('llm');
const [cardList, setCardList] = useState<LLMCardVO[]>([]);
const [modalOpen, setModalOpen] = useState<boolean>(false);
const [isEditForm, setIsEditForm] = useState(false);
const [nowSelectedLLM, setNowSelectedLLM] = useState<LLMCardVO | null>(null);
const [embeddingCardList, setEmbeddingCardList] = useState<EmbeddingCardVO[]>(
[],
);
const [embeddingModalOpen, setEmbeddingModalOpen] = useState<boolean>(false);
const [isEditEmbeddingForm, setIsEditEmbeddingForm] = useState(false);
const [nowSelectedEmbedding, setNowSelectedEmbedding] =
useState<EmbeddingCardVO | null>(null);
useEffect(() => {
if (open) {
getLLMModelList();
getEmbeddingModelList();
}
}, [open]);
async function getLLMModelList() {
const requesterNameListResp = await httpClient.getProviderRequesters('llm');
const requesterNameList = requesterNameListResp.requesters.map((item) => {
return {
label: extractI18nObject(item.label),
value: item.name,
};
});
httpClient
.getProviderLLMModels()
.then((resp) => {
const llmModelList: LLMCardVO[] = resp.models.map((model: LLMModel) => {
return new LLMCardVO({
id: model.uuid,
iconURL: httpClient.getProviderRequesterIconURL(model.requester),
name: model.name,
providerLabel:
requesterNameList.find((item) => item.value === model.requester)
?.label || model.requester.substring(0, 10),
baseURL: model.requester_config?.base_url,
abilities: model.abilities || [],
});
});
setCardList(llmModelList);
})
.catch((err) => {
console.error('get LLM model list error', err);
toast.error(t('models.getModelListError') + err.message);
});
}
function selectLLM(cardVO: LLMCardVO) {
setIsEditForm(true);
setNowSelectedLLM(cardVO);
setModalOpen(true);
}
function handleCreateModelClick() {
setIsEditForm(false);
setNowSelectedLLM(null);
setModalOpen(true);
}
function selectEmbedding(cardVO: EmbeddingCardVO) {
setIsEditEmbeddingForm(true);
setNowSelectedEmbedding(cardVO);
setEmbeddingModalOpen(true);
}
function handleCreateEmbeddingModelClick() {
setIsEditEmbeddingForm(false);
setNowSelectedEmbedding(null);
setEmbeddingModalOpen(true);
}
async function getEmbeddingModelList() {
const requesterNameListResp =
await httpClient.getProviderRequesters('text-embedding');
const requesterNameList = requesterNameListResp.requesters.map((item) => {
return {
label: extractI18nObject(item.label),
value: item.name,
};
});
httpClient
.getProviderEmbeddingModels()
.then((resp) => {
const embeddingModelList: EmbeddingCardVO[] = resp.models.map(
(model: {
uuid: string;
requester: string;
name: string;
requester_config?: { base_url?: string };
}) => {
return new EmbeddingCardVO({
id: model.uuid,
iconURL: httpClient.getProviderRequesterIconURL(model.requester),
name: model.name,
providerLabel:
requesterNameList.find((item) => item.value === model.requester)
?.label || model.requester.substring(0, 10),
baseURL: model.requester_config?.base_url || '',
});
},
);
setEmbeddingCardList(embeddingModelList);
})
.catch((err) => {
console.error('get Embedding model list error', err);
toast.error(t('embedding.getModelListError') + err.message);
});
}
return (
<>
<Dialog
open={open}
onOpenChange={(newOpen) => {
if (!newOpen && (modalOpen || embeddingModalOpen)) {
return;
}
onOpenChange(newOpen);
}}
>
<DialogContent className="overflow-hidden p-0 !max-w-[80vw] h-[75vh] flex flex-col">
<DialogHeader className="px-6 pt-6 pb-0">
<DialogTitle>{t('models.title')}</DialogTitle>
</DialogHeader>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="w-full flex-1 flex flex-col overflow-hidden px-6 pb-6"
>
<div className="flex flex-row justify-between items-center mb-2 mt-4">
<TabsList className="shadow-md py-5 bg-[#f0f0f0] dark:bg-[#2a2a2e]">
<TabsTrigger value="llm" className="px-6 py-4 cursor-pointer">
<MessageSquareText className="h-4 w-4 mr-1.5" />
{t('llm.llmModels')}
</TabsTrigger>
<TabsTrigger
value="embedding"
className="px-6 py-4 cursor-pointer"
>
<Cpu className="h-4 w-4 mr-1.5" />
{t('embedding.embeddingModels')}
</TabsTrigger>
</TabsList>
<Button
size="sm"
onClick={
activeTab === 'llm'
? handleCreateModelClick
: handleCreateEmbeddingModelClick
}
>
<Plus className="h-4 w-4 mr-1" />
{activeTab === 'llm'
? t('models.createModel')
: t('embedding.createModel')}
</Button>
</div>
<div className="mb-3 flex items-center">
<Info className="h-4 w-4 mr-1.5 text-muted-foreground" />
{activeTab === 'llm' ? (
<p className="text-sm text-muted-foreground flex items-center">
{t('llm.description')}
</p>
) : (
<p className="text-sm text-muted-foreground flex items-center">
{t('embedding.description')}
</p>
)}
</div>
<TabsContent value="llm" className="flex-1 overflow-auto mt-0">
<div className="w-full grid grid-cols-[repeat(auto-fill,minmax(20rem,1fr))] gap-4">
{cardList.map((cardVO) => {
return (
<div
key={cardVO.id}
onClick={() => {
selectLLM(cardVO);
}}
>
<LLMCard cardVO={cardVO}></LLMCard>
</div>
);
})}
</div>
</TabsContent>
<TabsContent
value="embedding"
className="flex-1 overflow-auto mt-0"
>
<div className="w-full grid grid-cols-[repeat(auto-fill,minmax(20rem,1fr))] gap-4">
{embeddingCardList.map((cardVO) => {
return (
<div
key={cardVO.id}
onClick={() => {
selectEmbedding(cardVO);
}}
>
<EmbeddingCard cardVO={cardVO}></EmbeddingCard>
</div>
);
})}
</div>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="w-[700px] p-6">
<DialogHeader>
<DialogTitle>
{isEditForm ? t('models.editModel') : t('models.createModel')}
</DialogTitle>
</DialogHeader>
<LLMForm
editMode={isEditForm}
initLLMId={nowSelectedLLM?.id}
onFormSubmit={() => {
setModalOpen(false);
getLLMModelList();
}}
onFormCancel={() => {
setModalOpen(false);
}}
onLLMDeleted={() => {
setModalOpen(false);
getLLMModelList();
}}
/>
</DialogContent>
</Dialog>
<Dialog open={embeddingModalOpen} onOpenChange={setEmbeddingModalOpen}>
<DialogContent className="w-[700px] p-6">
<DialogHeader>
<DialogTitle>
{isEditEmbeddingForm
? t('embedding.editModel')
: t('embedding.createModel')}
</DialogTitle>
</DialogHeader>
<EmbeddingForm
editMode={isEditEmbeddingForm}
initEmbeddingId={nowSelectedEmbedding?.id}
onFormSubmit={() => {
setEmbeddingModalOpen(false);
getEmbeddingModelList();
}}
onFormCancel={() => {
setEmbeddingModalOpen(false);
}}
onEmbeddingDeleted={() => {
setEmbeddingModalOpen(false);
getEmbeddingModelList();
}}
/>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,6 @@
export interface IChooseRequesterEntity {
label: string;
value: string;
provider_category?: string;
description?: string;
}
@@ -0,0 +1,7 @@
export interface ICreateEmbeddingField {
name: string;
model_provider: string;
url: string;
api_key: string;
extra_args?: string[];
}
@@ -0,0 +1,8 @@
export interface ICreateLLMField {
name: string;
model_provider: string;
url: string;
api_key: string;
abilities: string[];
extra_args: string[];
}
@@ -0,0 +1,128 @@
.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;
}
.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;
}
.bigText {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 1.4rem;
font-weight: bold;
max-width: 100%;
}
@@ -0,0 +1,55 @@
import styles from './EmbeddingCard.module.css';
import { EmbeddingCardVO } from '@/app/home/models/component/embedding-card/EmbeddingCardVO';
export default function EmbeddingCard({ cardVO }: { cardVO: EmbeddingCardVO }) {
return (
<div className={`${styles.cardContainer}`}>
<div className={`${styles.iconBasicInfoContainer}`}>
<img
className={`${styles.iconImage}`}
src={cardVO.iconURL}
alt="icon"
/>
<div className={`${styles.basicInfoContainer}`}>
{/* 名称 */}
<div className={`${styles.basicInfoText} ${styles.bigText}`}>
{cardVO.name}
</div>
{/* 厂商 */}
<div className={`${styles.providerContainer}`}>
<svg
className={`${styles.providerIcon}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="36"
height="36"
fill="currentColor"
>
<path d="M21 13.2422V20H22V22H2V20H3V13.2422C1.79401 12.435 1 11.0602 1 9.5C1 8.67286 1.22443 7.87621 1.63322 7.19746L4.3453 2.5C4.52393 2.1906 4.85406 2 5.21132 2H18.7887C19.1459 2 19.4761 2.1906 19.6547 2.5L22.3575 7.18172C22.7756 7.87621 23 8.67286 23 9.5C23 11.0602 22.206 12.435 21 13.2422ZM19 13.9725C18.8358 13.9907 18.669 14 18.5 14C17.2409 14 16.0789 13.478 15.25 12.6132C14.4211 13.478 13.2591 14 12 14C10.7409 14 9.5789 13.478 8.75 12.6132C7.9211 13.478 6.75911 14 5.5 14C5.331 14 5.16417 13.9907 5 13.9725V20H19V13.9725ZM5.78865 4L3.35598 8.21321C3.12409 8.59843 3 9.0389 3 9.5C3 10.8807 4.11929 12 5.5 12C6.53096 12 7.44467 11.3703 7.82179 10.4295C8.1574 9.59223 9.3426 9.59223 9.67821 10.4295C10.0553 11.3703 10.969 12 12 12C13.031 12 13.9447 11.3703 14.3218 10.4295C14.6574 9.59223 15.8426 9.59223 16.1782 10.4295C16.5553 11.3703 17.469 12 18.5 12C19.8807 12 21 10.8807 21 9.5C21 9.0389 20.8759 8.59843 20.6347 8.19746L18.2113 4H5.78865Z"></path>
</svg>
<span className={`${styles.providerLabel}`}>
{cardVO.providerLabel}
</span>
</div>
{/* baseURL */}
{cardVO.baseURL && (
<div className={`${styles.baseURLContainer}`}>
<svg
className={`${styles.baseURLIcon}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="36"
height="36"
fill="rgba(98,98,98,1)"
>
<path d="M13.0607 8.11097L14.4749 9.52518C17.2086 12.2589 17.2086 16.691 14.4749 19.4247L14.1214 19.7782C11.3877 22.5119 6.95555 22.5119 4.22188 19.7782C1.48821 17.0446 1.48821 12.6124 4.22188 9.87874L5.6361 11.293C3.68348 13.2456 3.68348 16.4114 5.6361 18.364C7.58872 20.3166 10.7545 20.3166 12.7072 18.364L13.0607 18.0105C15.0133 16.0578 15.0133 12.892 13.0607 10.9394L11.6465 9.52518L13.0607 8.11097ZM19.7782 14.1214L18.364 12.7072C20.3166 10.7545 20.3166 7.58872 18.364 5.6361C16.4114 3.68348 13.2456 3.68348 11.293 5.6361L10.9394 5.98965C8.98678 7.94227 8.98678 11.1081 10.9394 13.0607L12.3536 14.4749L10.9394 15.8891L9.52518 14.4749C6.79151 11.7413 6.79151 7.30911 9.52518 4.57544L9.87874 4.22188C12.6124 1.48821 17.0446 1.48821 19.7782 4.22188C22.5119 6.95555 22.5119 11.3877 19.7782 14.1214Z"></path>
</svg>
<span className={`${styles.baseURLText}`}>{cardVO.baseURL}</span>
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,23 @@
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;
}
}
@@ -0,0 +1,651 @@
import { ICreateEmbeddingField } from '@/app/home/models/component/ICreateEmbeddingField';
import { useEffect, useState } from 'react';
import { IChooseRequesterEntity } from '@/app/home/models/component/ChooseRequesterEntity';
import { httpClient } from '@/app/infra/http/HttpClient';
import { EmbeddingModel } from '@/app/infra/entities/api';
import { UUID } from 'uuidjs';
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';
const getExtraArgSchema = (t: (key: string) => string) =>
z
.object({
key: z.string().min(1, { message: t('models.keyNameRequired') }),
type: z.enum(['string', 'number', 'boolean']),
value: z.string(),
})
.superRefine((data, ctx) => {
if (data.type === 'number' && isNaN(Number(data.value))) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t('models.mustBeValidNumber'),
path: ['value'],
});
}
if (
data.type === 'boolean' &&
data.value !== 'true' &&
data.value !== 'false'
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t('models.mustBeTrueOrFalse'),
path: ['value'],
});
}
});
const getFormSchema = (t: (key: string) => string) =>
z.object({
name: z.string().min(1, { message: t('models.modelNameRequired') }),
model_provider: z
.string()
.min(1, { message: t('models.modelProviderRequired') }),
url: z.string().optional(),
api_key: z.string().optional(),
extra_args: z.array(getExtraArgSchema(t)).optional(),
});
export default function EmbeddingForm({
editMode,
initEmbeddingId,
onFormSubmit,
onFormCancel,
onEmbeddingDeleted,
}: {
editMode: boolean;
initEmbeddingId?: string;
onFormSubmit: () => void;
onFormCancel: () => void;
onEmbeddingDeleted: () => void;
}) {
const { t } = useTranslation();
const formSchema = getFormSchema(t);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
model_provider: '',
url: '',
api_key: '',
extra_args: [],
},
});
const [extraArgs, setExtraArgs] = useState<
{ key: string; type: 'string' | 'number' | 'boolean'; value: string }[]
>([]);
const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false);
const [requesterNameList, setRequesterNameList] = useState<
IChooseRequesterEntity[]
>([]);
const [requesterDefaultURLList, setRequesterDefaultURLList] = useState<
string[]
>([]);
const [modelTesting, setModelTesting] = useState(false);
const [testErrorMessage, setTestErrorMessage] = useState<string | null>(null);
const [currentModelProvider, setCurrentModelProvider] = useState('');
useEffect(() => {
initEmbeddingModelFormComponent().then(() => {
if (editMode && initEmbeddingId) {
getEmbeddingConfig(initEmbeddingId).then((val) => {
form.setValue('name', val.name);
form.setValue('model_provider', val.model_provider);
setCurrentModelProvider(val.model_provider);
form.setValue('url', val.url);
form.setValue('api_key', val.api_key);
if (val.extra_args) {
const args = val.extra_args.map((arg) => {
const [key, value] = arg.split(':');
let type: 'string' | 'number' | 'boolean' = 'string';
if (!isNaN(Number(value))) {
type = 'number';
} else if (value === 'true' || value === 'false') {
type = 'boolean';
}
return {
key,
type,
value,
};
});
setExtraArgs(args);
form.setValue('extra_args', args);
}
});
} else {
form.reset();
}
});
}, []);
const addExtraArg = () => {
setExtraArgs([...extraArgs, { key: '', type: 'string', value: '' }]);
};
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);
};
async function initEmbeddingModelFormComponent() {
const requesterNameList =
await httpClient.getProviderRequesters('text-embedding');
setRequesterNameList(
requesterNameList.requesters.map((item) => {
return {
label: extractI18nObject(item.label),
value: item.name,
provider_category: item.spec.provider_category || 'manufacturer',
description: extractI18nObject(item.description) || undefined,
};
}),
);
setRequesterDefaultURLList(
requesterNameList.requesters.map((item) => {
const config = item.spec.config;
for (let i = 0; i < config.length; i++) {
if (config[i].name == 'base_url') {
return config[i].default?.toString() || '';
}
}
return '';
}),
);
}
async function getEmbeddingConfig(
id: string,
): Promise<ICreateEmbeddingField> {
const embeddingModel = await httpClient.getProviderEmbeddingModel(id);
const fakeExtraArgs = [];
const extraArgs = embeddingModel.model.extra_args as Record<string, string>;
for (const key in extraArgs) {
fakeExtraArgs.push(`${key}:${extraArgs[key]}`);
}
return {
name: embeddingModel.model.name,
model_provider: embeddingModel.model.requester,
url: embeddingModel.model.requester_config?.base_url,
api_key: embeddingModel.model.api_keys[0],
extra_args: fakeExtraArgs,
};
}
function handleFormSubmit(value: z.infer<typeof formSchema>) {
const extraArgsObj: Record<string, string | number | boolean> = {};
value.extra_args?.forEach(
(arg: { key: string; type: string; value: string }) => {
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 embeddingModel: EmbeddingModel = {
uuid: editMode ? initEmbeddingId || '' : UUID.generate(),
name: value.name,
description: '',
requester: value.model_provider,
requester_config: {
base_url: value.url || '',
timeout: 120,
},
extra_args: extraArgsObj,
api_keys: value.api_key ? [value.api_key] : [],
};
if (editMode) {
onSaveEdit(embeddingModel).then(() => {
form.reset();
});
} else {
onCreateEmbedding(embeddingModel).then(() => {
form.reset();
});
}
}
async function onCreateEmbedding(embeddingModel: EmbeddingModel) {
try {
await httpClient.createProviderEmbeddingModel(embeddingModel);
onFormSubmit();
toast.success(t('models.createSuccess'));
} catch (err) {
toast.error(t('models.createError') + (err as Error).message);
}
}
async function onSaveEdit(embeddingModel: EmbeddingModel) {
try {
await httpClient.updateProviderEmbeddingModel(
initEmbeddingId || '',
embeddingModel,
);
onFormSubmit();
toast.success(t('models.saveSuccess'));
} catch (err) {
toast.error(t('models.saveError') + (err as Error).message);
}
}
function deleteModel() {
if (initEmbeddingId) {
httpClient
.deleteProviderEmbeddingModel(initEmbeddingId)
.then(() => {
onEmbeddingDeleted();
toast.success(t('models.deleteSuccess'));
})
.catch((err) => {
toast.error(t('models.deleteError') + err.message);
});
}
}
function testEmbeddingModelInForm() {
setModelTesting(true);
setTestErrorMessage(null);
const extraArgsObj: Record<string, string | number | boolean> = {};
form
.getValues('extra_args')
?.forEach((arg: { key: string; type: string; value: string }) => {
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 apiKey = form.getValues('api_key');
httpClient
.testEmbeddingModel('_', {
uuid: '',
name: form.getValues('name'),
description: '',
requester: form.getValues('model_provider'),
requester_config: {
base_url: form.getValues('url') ?? '',
timeout: 120,
},
api_keys: apiKey ? [apiKey] : [],
extra_args: extraArgsObj,
})
.then(() => {
toast.success(t('models.testSuccess'));
setTestErrorMessage(null);
})
.catch((err: { message?: string }) => {
setTestErrorMessage(err?.message || t('models.testError'));
})
.finally(() => {
setModelTesting(false);
});
}
return (
<div>
<Dialog
open={showDeleteConfirmModal}
onOpenChange={setShowDeleteConfirmModal}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
</DialogHeader>
<DialogDescription>
{t('models.deleteConfirmation')}
</DialogDescription>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowDeleteConfirmModal(false)}
>
{t('common.cancel')}
</Button>
<Button
variant="destructive"
onClick={() => {
deleteModel();
setShowDeleteConfirmModal(false);
}}
>
{t('common.confirmDelete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleFormSubmit)}
className="space-y-8"
>
<div className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('models.modelName')}
<span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
<FormDescription>
{t('models.modelProviderDescription')}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="model_provider"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('models.modelProvider')}
<span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Select
onValueChange={(value) => {
field.onChange(value);
setCurrentModelProvider(value);
const index = requesterNameList.findIndex(
(item) => item.value === value,
);
if (index !== -1) {
form.setValue('url', requesterDefaultURLList[index]);
}
}}
value={field.value}
>
<SelectTrigger className="w-[180px] bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue
placeholder={t('models.selectModelProvider')}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>{t('models.builtin')}</SelectLabel>
{requesterNameList
.filter(
(item) => item.provider_category === 'builtin',
)
.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
<SelectGroup>
<SelectLabel>
{t('models.modelManufacturer')}
</SelectLabel>
{requesterNameList
.filter(
(item) =>
item.provider_category === 'manufacturer',
)
.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
<SelectGroup>
<SelectLabel>
{t('models.aggregationPlatform')}
</SelectLabel>
{requesterNameList
.filter((item) => item.provider_category === 'maas')
.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
<SelectGroup>
<SelectLabel>{t('models.selfDeployed')}</SelectLabel>
{requesterNameList
.filter(
(item) =>
item.provider_category === 'self-hosted',
)
.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
{currentModelProvider &&
requesterNameList.find(
(item) => item.value === currentModelProvider,
)?.description && (
<FormDescription>
{
requesterNameList.find(
(item) => item.value === currentModelProvider,
)?.description
}
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
{!['seekdb-embedding'].includes(currentModelProvider) && (
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>{t('models.requestURL')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{!['ollama-chat', 'seekdb-embedding'].includes(
currentModelProvider,
) && (
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel>{t('models.apiKey')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormItem>
<FormLabel>{t('models.extraParameters')}</FormLabel>
<div className="space-y-2">
{extraArgs.map((arg, index) => (
<div key={index} className="flex gap-2">
<Input
placeholder={t('models.keyName')}
value={arg.key}
onChange={(e) =>
updateExtraArg(index, 'key', e.target.value)
}
/>
<Select
value={arg.type}
onValueChange={(value) =>
updateExtraArg(index, 'type', value)
}
>
<SelectTrigger className="w-[120px] bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('models.type')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="string">
{t('models.string')}
</SelectItem>
<SelectItem value="number">
{t('models.number')}
</SelectItem>
<SelectItem value="boolean">
{t('models.boolean')}
</SelectItem>
</SelectContent>
</Select>
<Input
placeholder={t('models.value')}
value={arg.value}
onChange={(e) =>
updateExtraArg(index, 'value', e.target.value)
}
/>
<button
type="button"
className="p-2 hover:bg-gray-100 rounded"
onClick={() => removeExtraArg(index)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-5 h-5 text-red-500"
>
<path d="M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z"></path>
</svg>
</button>
</div>
))}
<Button type="button" variant="outline" onClick={addExtraArg}>
{t('models.addParameter')}
</Button>
</div>
<FormDescription>
{t('embedding.extraParametersDescription')}
</FormDescription>
<FormMessage />
</FormItem>
</div>
{testErrorMessage && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>{t('models.testError')}</AlertTitle>
<AlertDescription className="break-all">
{testErrorMessage}
</AlertDescription>
</Alert>
)}
<DialogFooter>
{editMode && (
<Button
type="button"
variant="destructive"
onClick={() => setShowDeleteConfirmModal(true)}
>
{t('common.delete')}
</Button>
)}
<Button type="submit">
{editMode ? t('common.save') : t('common.submit')}
</Button>
<Button
type="button"
variant="outline"
onClick={() => testEmbeddingModelInForm()}
disabled={modelTesting}
>
{t('common.test')}
</Button>
<Button
type="button"
variant="outline"
onClick={() => onFormCancel()}
>
{t('common.cancel')}
</Button>
</DialogFooter>
</form>
</Form>
</div>
);
}
@@ -0,0 +1,173 @@
.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%;
}
@@ -0,0 +1,98 @@
import styles from './LLMCard.module.css';
import { LLMCardVO } from '@/app/home/models/component/llm-card/LLMCardVO';
import { useTranslation } from 'react-i18next';
function AbilityBadges(abilities: string[]) {
const { t } = useTranslation();
const abilityBadges = {
vision: (
<div key="vision" className={`${styles.abilityBadge}`}>
<svg
className={`${styles.abilityIcon}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2ZM12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4ZM12 7C14.7614 7 17 9.23858 17 12C17 14.7614 14.7614 17 12 17C9.23858 17 7 14.7614 7 12C7 11.4872 7.07719 10.9925 7.22057 10.5268C7.61175 11.3954 8.48527 12 9.5 12C10.8807 12 12 10.8807 12 9.5C12 8.48527 11.3954 7.61175 10.5269 7.21995C10.9925 7.07719 11.4872 7 12 7Z"></path>
</svg>
<span className={`${styles.abilityLabel}`}>
{t('models.visionAbility')}
</span>
</div>
),
func_call: (
<div key="func_call" className={`${styles.abilityBadge}`}>
<svg
className={`${styles.abilityIcon}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M5.32943 3.27158C6.56252 2.8332 7.9923 3.10749 8.97927 4.09446C10.1002 5.21537 10.3019 6.90741 9.5843 8.23385L20.293 18.9437L18.8788 20.3579L8.16982 9.64875C6.84325 10.3669 5.15069 10.1654 4.02952 9.04421C3.04227 8.05696 2.7681 6.62665 3.20701 5.39332L5.44373 7.63C6.02952 8.21578 6.97927 8.21578 7.56505 7.63C8.15084 7.04421 8.15084 6.09446 7.56505 5.50868L5.32943 3.27158ZM15.6968 5.15512L18.8788 3.38736L20.293 4.80157L18.5252 7.98355L16.7574 8.3371L14.6361 10.4584L13.2219 9.04421L15.3432 6.92289L15.6968 5.15512ZM8.97927 13.2868L10.3935 14.7011L5.09018 20.0044C4.69966 20.3949 4.06649 20.3949 3.67597 20.0044C3.31334 19.6417 3.28744 19.0699 3.59826 18.6774L3.67597 18.5902L8.97927 13.2868Z"></path>
</svg>
<span className={`${styles.abilityLabel}`}>
{t('models.functionCallAbility')}
</span>
</div>
),
};
return abilities.map((ability) => {
return abilityBadges[ability as keyof typeof abilityBadges];
});
}
export default function LLMCard({ cardVO }: { cardVO: LLMCardVO }) {
return (
<div className={`${styles.cardContainer}`}>
<div className={`${styles.iconBasicInfoContainer}`}>
<img
className={`${styles.iconImage}`}
src={cardVO.iconURL}
alt="icon"
/>
<div className={`${styles.basicInfoContainer}`}>
{/* 名称 */}
<div className={`${styles.basicInfoText} ${styles.bigText}`}>
{cardVO.name}
</div>
{/* 厂商 */}
<div className={`${styles.providerContainer}`}>
<svg
className={`${styles.providerIcon}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="36"
height="36"
fill="currentColor"
>
<path d="M21 13.2422V20H22V22H2V20H3V13.2422C1.79401 12.435 1 11.0602 1 9.5C1 8.67286 1.22443 7.87621 1.63322 7.19746L4.3453 2.5C4.52393 2.1906 4.85406 2 5.21132 2H18.7887C19.1459 2 19.4761 2.1906 19.6547 2.5L22.3575 7.18172C22.7756 7.87621 23 8.67286 23 9.5C23 11.0602 22.206 12.435 21 13.2422ZM19 13.9725C18.8358 13.9907 18.669 14 18.5 14C17.2409 14 16.0789 13.478 15.25 12.6132C14.4211 13.478 13.2591 14 12 14C10.7409 14 9.5789 13.478 8.75 12.6132C7.9211 13.478 6.75911 14 5.5 14C5.331 14 5.16417 13.9907 5 13.9725V20H19V13.9725ZM5.78865 4L3.35598 8.21321C3.12409 8.59843 3 9.0389 3 9.5C3 10.8807 4.11929 12 5.5 12C6.53096 12 7.44467 11.3703 7.82179 10.4295C8.1574 9.59223 9.3426 9.59223 9.67821 10.4295C10.0553 11.3703 10.969 12 12 12C13.031 12 13.9447 11.3703 14.3218 10.4295C14.6574 9.59223 15.8426 9.59223 16.1782 10.4295C16.5553 11.3703 17.469 12 18.5 12C19.8807 12 21 10.8807 21 9.5C21 9.0389 20.8759 8.59843 20.6347 8.19746L18.2113 4H5.78865Z"></path>
</svg>
<span className={`${styles.providerLabel}`}>
{cardVO.providerLabel}
</span>
</div>
{/* baseURL */}
<div className={`${styles.baseURLContainer}`}>
<svg
className={`${styles.baseURLIcon}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="36"
height="36"
fill="rgba(98,98,98,1)"
>
<path d="M13.0607 8.11097L14.4749 9.52518C17.2086 12.2589 17.2086 16.691 14.4749 19.4247L14.1214 19.7782C11.3877 22.5119 6.95555 22.5119 4.22188 19.7782C1.48821 17.0446 1.48821 12.6124 4.22188 9.87874L5.6361 11.293C3.68348 13.2456 3.68348 16.4114 5.6361 18.364C7.58872 20.3166 10.7545 20.3166 12.7072 18.364L13.0607 18.0105C15.0133 16.0578 15.0133 12.892 13.0607 10.9394L11.6465 9.52518L13.0607 8.11097ZM19.7782 14.1214L18.364 12.7072C20.3166 10.7545 20.3166 7.58872 18.364 5.6361C16.4114 3.68348 13.2456 3.68348 11.293 5.6361L10.9394 5.98965C8.98678 7.94227 8.98678 11.1081 10.9394 13.0607L12.3536 14.4749L10.9394 15.8891L9.52518 14.4749C6.79151 11.7413 6.79151 7.30911 9.52518 4.57544L9.87874 4.22188C12.6124 1.48821 17.0446 1.48821 19.7782 4.22188C22.5119 6.95555 22.5119 11.3877 19.7782 14.1214Z"></path>
</svg>
<span className={`${styles.baseURLText}`}>{cardVO.baseURL}</span>
</div>
{/* 能力 */}
<div className={`${styles.abilitiesContainer}`}>
{AbilityBadges(cardVO.abilities)}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,26 @@
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;
}
}
@@ -0,0 +1,698 @@
import { ICreateLLMField } from '@/app/home/models/component/ICreateLLMField';
import { useEffect, useState } from 'react';
import { IChooseRequesterEntity } from '@/app/home/models/component/ChooseRequesterEntity';
import { httpClient } from '@/app/infra/http/HttpClient';
import { LLMModel } from '@/app/infra/entities/api';
import { UUID } from 'uuidjs';
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';
const getExtraArgSchema = (t: (key: string) => string) =>
z
.object({
key: z.string().min(1, { message: t('models.keyNameRequired') }),
type: z.enum(['string', 'number', 'boolean']),
value: z.string(),
})
.superRefine((data, ctx) => {
if (data.type === 'number' && isNaN(Number(data.value))) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t('models.mustBeValidNumber'),
path: ['value'],
});
}
if (
data.type === 'boolean' &&
data.value !== 'true' &&
data.value !== 'false'
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t('models.mustBeTrueOrFalse'),
path: ['value'],
});
}
});
const getFormSchema = (t: (key: string) => string) =>
z.object({
name: z.string().min(1, { message: t('models.modelNameRequired') }),
model_provider: z
.string()
.min(1, { message: t('models.modelProviderRequired') }),
url: z.string().min(1, { message: t('models.requestURLRequired') }),
api_key: z.string().optional(),
abilities: z.array(z.string()),
extra_args: z.array(getExtraArgSchema(t)).optional(),
});
export default function LLMForm({
editMode,
initLLMId,
onFormSubmit,
onFormCancel,
onLLMDeleted,
}: {
editMode: boolean;
initLLMId?: string;
onFormSubmit: () => void;
onFormCancel: () => void;
onLLMDeleted: () => void;
}) {
const { t } = useTranslation();
const formSchema = getFormSchema(t);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
model_provider: '',
url: '',
api_key: '',
abilities: [],
extra_args: [],
},
});
const [extraArgs, setExtraArgs] = useState<
{ key: string; type: 'string' | 'number' | 'boolean'; value: string }[]
>([]);
const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false);
const abilityOptions: { label: string; value: string }[] = [
{
label: t('models.visionAbility'),
value: 'vision',
},
{
label: t('models.functionCallAbility'),
value: 'func_call',
},
];
const [requesterNameList, setRequesterNameList] = useState<
IChooseRequesterEntity[]
>([]);
const [requesterDefaultURLList, setRequesterDefaultURLList] = useState<
string[]
>([]);
const [modelTesting, setModelTesting] = useState(false);
const [testErrorMessage, setTestErrorMessage] = useState<string | null>(null);
const [currentModelProvider, setCurrentModelProvider] = useState('');
useEffect(() => {
initLLMModelFormComponent().then(() => {
if (editMode && initLLMId) {
getLLMConfig(initLLMId).then((val) => {
form.setValue('name', val.name);
form.setValue('model_provider', val.model_provider);
setCurrentModelProvider(val.model_provider);
form.setValue('url', val.url);
form.setValue('api_key', val.api_key);
form.setValue(
'abilities',
val.abilities as ('vision' | 'func_call')[],
);
// 转换extra_args为新格式
if (val.extra_args) {
const args = val.extra_args.map((arg) => {
const [key, value] = arg.split(':');
let type: 'string' | 'number' | 'boolean' = 'string';
if (!isNaN(Number(value))) {
type = 'number';
} else if (value === 'true' || value === 'false') {
type = 'boolean';
}
return {
key,
type,
value,
};
});
setExtraArgs(args);
form.setValue('extra_args', args);
}
});
} else {
form.reset();
}
});
}, []);
const addExtraArg = () => {
setExtraArgs([...extraArgs, { key: '', type: 'string', value: '' }]);
};
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);
};
async function initLLMModelFormComponent() {
const requesterNameList = await httpClient.getProviderRequesters('llm');
setRequesterNameList(
requesterNameList.requesters.map((item) => {
return {
label: extractI18nObject(item.label),
value: item.name,
provider_category: item.spec.provider_category || 'manufacturer',
};
}),
);
setRequesterDefaultURLList(
requesterNameList.requesters.map((item) => {
const config = item.spec.config;
for (let i = 0; i < config.length; i++) {
if (config[i].name == 'base_url') {
return config[i].default?.toString() || '';
}
}
return '';
}),
);
}
async function getLLMConfig(id: string): Promise<ICreateLLMField> {
const llmModel = await httpClient.getProviderLLMModel(id);
const fakeExtraArgs = [];
const extraArgs = llmModel.model.extra_args as Record<string, string>;
for (const key in extraArgs) {
fakeExtraArgs.push(`${key}:${extraArgs[key]}`);
}
return {
name: llmModel.model.name,
model_provider: llmModel.model.requester,
url: llmModel.model.requester_config?.base_url,
api_key: llmModel.model.api_keys[0],
abilities: llmModel.model.abilities || [],
extra_args: fakeExtraArgs,
};
}
function handleFormSubmit(value: z.infer<typeof formSchema>) {
const extraArgsObj: Record<string, string | number | boolean> = {};
value.extra_args?.forEach(
(arg: { key: string; type: string; value: string }) => {
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 llmModel: LLMModel = {
uuid: editMode ? initLLMId || '' : UUID.generate(),
name: value.name,
description: '',
requester: value.model_provider,
requester_config: {
base_url: value.url,
timeout: 120,
},
extra_args: extraArgsObj,
api_keys: value.api_key ? [value.api_key] : [],
abilities: value.abilities,
};
if (editMode) {
onSaveEdit(llmModel).then(() => {
form.reset();
});
} else {
onCreateLLM(llmModel).then(() => {
form.reset();
});
}
}
async function onCreateLLM(llmModel: LLMModel) {
try {
await httpClient.createProviderLLMModel(llmModel);
onFormSubmit();
toast.success(t('models.createSuccess'));
} catch (err) {
toast.error(t('models.createError') + (err as Error).message);
}
}
async function onSaveEdit(llmModel: LLMModel) {
try {
await httpClient.updateProviderLLMModel(initLLMId || '', llmModel);
onFormSubmit();
toast.success(t('models.saveSuccess'));
} catch (err) {
toast.error(t('models.saveError') + (err as Error).message);
}
}
function deleteModel() {
if (initLLMId) {
httpClient
.deleteProviderLLMModel(initLLMId)
.then(() => {
onLLMDeleted();
toast.success(t('models.deleteSuccess'));
})
.catch((err) => {
toast.error(t('models.deleteError') + err.message);
});
}
}
function testLLMModelInForm() {
setModelTesting(true);
setTestErrorMessage(null);
const extraArgsObj: Record<string, string | number | boolean> = {};
form
.getValues('extra_args')
?.forEach((arg: { key: string; type: string; value: string }) => {
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 apiKey = form.getValues('api_key');
httpClient
.testLLMModel('_', {
uuid: '',
name: form.getValues('name'),
description: '',
requester: form.getValues('model_provider'),
requester_config: {
base_url: form.getValues('url'),
timeout: 120,
},
api_keys: apiKey ? [apiKey] : [],
abilities: form.getValues('abilities'),
extra_args: extraArgsObj,
})
.then(() => {
toast.success(t('models.testSuccess'));
setTestErrorMessage(null);
})
.catch((err: { message?: string }) => {
setTestErrorMessage(err?.message || t('models.testError'));
})
.finally(() => {
setModelTesting(false);
});
}
return (
<div>
<Dialog
open={showDeleteConfirmModal}
onOpenChange={setShowDeleteConfirmModal}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
</DialogHeader>
<DialogDescription>
{t('models.deleteConfirmation')}
</DialogDescription>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowDeleteConfirmModal(false)}
>
{t('common.cancel')}
</Button>
<Button
variant="destructive"
onClick={() => {
deleteModel();
setShowDeleteConfirmModal(false);
}}
>
{t('common.confirmDelete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleFormSubmit)}
className="space-y-8"
>
<div className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('models.modelName')}
<span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
<FormDescription>
{t('models.modelProviderDescription')}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="model_provider"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('models.modelProvider')}
<span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Select
onValueChange={(value) => {
field.onChange(value);
setCurrentModelProvider(value);
const index = requesterNameList.findIndex(
(item) => item.value === value,
);
if (index !== -1) {
form.setValue('url', requesterDefaultURLList[index]);
}
}}
value={field.value}
>
<SelectTrigger className="w-[180px] bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue
placeholder={t('models.selectModelProvider')}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>
{t('models.modelManufacturer')}
</SelectLabel>
{requesterNameList
.filter(
(item) =>
item.provider_category === 'manufacturer',
)
.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
<SelectGroup>
<SelectLabel>
{t('models.aggregationPlatform')}
</SelectLabel>
{requesterNameList
.filter((item) => item.provider_category === 'maas')
.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
<SelectGroup>
<SelectLabel>{t('models.selfDeployed')}</SelectLabel>
{requesterNameList
.filter(
(item) =>
item.provider_category === 'self-hosted',
)
.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('models.requestURL')}
<span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{!['lmstudio-chat-completions', 'ollama-chat'].includes(
currentModelProvider,
) && (
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel>{t('models.apiKey')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="abilities"
render={() => (
<FormItem>
<FormLabel>{t('models.abilities')}</FormLabel>
<div className="mb-0">
<FormDescription>
{t('models.selectModelAbilities')}
</FormDescription>
</div>
{abilityOptions.map((item) => (
<FormField
key={item.value}
control={form.control}
name="abilities"
render={({ field }) => {
return (
<FormItem
key={item.value}
className="flex flex-row items-start space-x-1 space-y-0"
>
<FormControl>
<Checkbox
checked={
Array.isArray(field.value) &&
field.value?.includes(item.value)
}
onCheckedChange={(checked) => {
return checked
? field.onChange([
...(field.value || []),
item.value,
])
: field.onChange(
field.value?.filter(
(value: string) =>
value !== item.value,
),
);
}}
/>
</FormControl>
<FormLabel className="font-normal">
{item.label}
</FormLabel>
</FormItem>
);
}}
/>
))}
<FormMessage />
</FormItem>
)}
/>
<FormItem>
<FormLabel>{t('models.extraParameters')}</FormLabel>
<div className="space-y-2">
{extraArgs.map((arg, index) => (
<div key={index} className="flex gap-2">
<Input
placeholder={t('models.keyName')}
value={arg.key}
onChange={(e) =>
updateExtraArg(index, 'key', e.target.value)
}
/>
<Select
value={arg.type}
onValueChange={(value) =>
updateExtraArg(index, 'type', value)
}
>
<SelectTrigger className="w-[120px] bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('models.type')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="string">
{t('models.string')}
</SelectItem>
<SelectItem value="number">
{t('models.number')}
</SelectItem>
<SelectItem value="boolean">
{t('models.boolean')}
</SelectItem>
</SelectContent>
</Select>
<Input
placeholder={t('models.value')}
value={arg.value}
onChange={(e) =>
updateExtraArg(index, 'value', e.target.value)
}
/>
<button
type="button"
className="p-2 hover:bg-gray-100 rounded"
onClick={() => removeExtraArg(index)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-5 h-5 text-red-500"
>
<path d="M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z"></path>
</svg>
</button>
</div>
))}
<Button type="button" variant="outline" onClick={addExtraArg}>
{t('models.addParameter')}
</Button>
</div>
<FormDescription>
{t('llm.extraParametersDescription')}
</FormDescription>
<FormMessage />
</FormItem>
</div>
{testErrorMessage && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>{t('models.testError')}</AlertTitle>
<AlertDescription className="break-all">
{testErrorMessage}
</AlertDescription>
</Alert>
)}
<DialogFooter>
{editMode && (
<Button
type="button"
variant="destructive"
onClick={() => setShowDeleteConfirmModal(true)}
>
{t('common.delete')}
</Button>
)}
<Button type="submit">
{editMode ? t('common.save') : t('common.submit')}
</Button>
<Button
type="button"
variant="outline"
onClick={() => testLLMModelInForm()}
disabled={modelTesting}
>
{t('common.test')}
</Button>
<Button
type="button"
variant="outline"
onClick={() => onFormCancel()}
>
{t('common.cancel')}
</Button>
</DialogFooter>
</form>
</Form>
</div>
);
}