refactor: update model management components and enhance provider functionality

This commit is contained in:
Junyan Qin
2026-01-01 14:58:06 +08:00
parent fb73da8735
commit b4773c4e48
25 changed files with 1444 additions and 2209 deletions

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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<string, string | number | boolean> {
const obj: Record<string, string | number | boolean> = {};
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<string, { llm: LLMModel[]; embedding: EmbeddingModel[] }>
Record<string, ProviderModels>
>({});
const [loadingProviders, setLoadingProviders] = useState<Set<string>>(
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<string | null>(null);
const [editingEmbeddingId, setEditingEmbeddingId] = useState<string | null>(
null,
);
const [editingProviderId, setEditingProviderId] = useState<string | null>(
null,
);
// Popover states
const [addModelPopoverOpen, setAddModelPopoverOpen] = useState<string | null>(
null,
);
const [editModelPopoverOpen, setEditModelPopoverOpen] = useState<
string | null
>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(
null,
);
// Form states
const [isSubmitting, setIsSubmitting] = useState(false);
const [isTesting, setIsTesting] = useState(false);
const [testResult, setTestResult] = useState<TestResult | null>(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 (
<Card key={provider.uuid} className="mb-2">
<Collapsible
open={isExpanded}
onOpenChange={() => toggleProvider(provider.uuid)}
>
<CardHeader className="px-4 pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 flex-1">
{isLangBotModels ? (
<div className="w-9 h-9 rounded-lg overflow-hidden flex-shrink-0">
<img
src={langbotIcon.src}
alt="LangBot"
className="w-full h-full object-cover"
/>
</div>
) : (
<img
src={httpClient.getProviderRequesterIconURL(
provider.requester,
)}
alt={provider.name}
className="h-9 w-9 rounded-lg"
/>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<CardTitle className="text-base">
{isLangBotModels
? provider.name
: getRequesterLabel(provider.requester)}
</CardTitle>
<Badge variant="outline" className="text-xs">
{t('models.modelsCount', { count: totalModels })}
</Badge>
</div>
<p className="text-xs text-muted-foreground truncate">
{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])}
</>
)}
</p>
</div>
</div>
<div className="flex items-center gap-1 ml-2">
{isLangBotModels && accountType !== 'space' && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleSpaceLogin();
}}
>
<LogIn className="h-4 w-4 mr-1" />
{t('models.loginWithSpace')}
</Button>
)}
{isLangBotModels &&
accountType === 'space' &&
spaceCredits !== null && (
<div className="flex items-center gap-1 border rounded-md px-2 h-8 text-sm mr-2">
<span>
{(spaceCredits / 5000).toFixed(2)} {t('models.credits')}
</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={(e) => {
e.stopPropagation();
window.open(
`${systemInfo.cloud_service_url}/profile?tab=billing`,
'_blank',
);
}}
>
<Plus className="h-3 w-3" />
</Button>
</div>
)}
{!isLangBotModels && (
<>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
handleEditProvider(provider.uuid);
}}
>
<Settings className="h-4 w-4" />
</Button>
{canDelete && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
handleDeleteProvider(provider.uuid);
}}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</>
)}
</div>
</div>
<CollapsibleTrigger className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground cursor-pointer mt-2">
{isExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
<span>
{isExpanded
? t('models.collapseModels')
: t('models.expandModels')}
</span>
</CollapsibleTrigger>
</CardHeader>
<CollapsibleContent>
<CardContent className="px-4">
{isLoading ? (
<p className="text-sm text-muted-foreground text-center py-4">
{t('common.loading')}...
</p>
) : models ? (
<div className="space-y-2">
{models.llm.map((model) => (
<div
key={model.uuid}
className="flex items-center justify-between py-2 px-3 rounded-md border bg-background hover:bg-accent cursor-pointer"
onClick={() => handleEditLLM(model.uuid)}
>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">
{model.name}
</span>
<Badge variant="secondary" className="text-xs">
{t('models.chat')}
</Badge>
{model.abilities?.includes('vision') && (
<Badge variant="outline" className="text-xs gap-1">
<Eye className="h-3 w-3" />
</Badge>
)}
{model.abilities?.includes('func_call') && (
<Badge variant="outline" className="text-xs gap-1">
<Wrench className="h-3 w-3" />
</Badge>
)}
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
handleDeleteLLM(model.uuid, provider.uuid);
}}
>
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
</Button>
</div>
))}
{models.embedding.map((model) => (
<div
key={model.uuid}
className="flex items-center justify-between py-2 px-3 rounded-md border bg-background hover:bg-accent cursor-pointer"
onClick={() => handleEditEmbedding(model.uuid)}
>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{model.name}
</span>
<Badge variant="secondary" className="text-xs">
{t('models.embedding')}
</Badge>
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
handleDeleteEmbedding(model.uuid, provider.uuid);
}}
>
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
</Button>
</div>
))}
{models.llm.length === 0 && models.embedding.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
{t('models.noModels')}
</p>
)}
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-4">
{t('models.noModels')}
</p>
)}
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
<ProviderCard
key={provider.uuid}
provider={provider}
isLangBotModels={isLangBotModels}
isExpanded={expandedProviders.has(provider.uuid)}
isLoading={loadingProviders.has(provider.uuid)}
models={providerModels[provider.uuid]}
accountType={accountType}
spaceCredits={spaceCredits}
requesterNameList={requesterNameList}
addModelPopoverOpen={addModelPopoverOpen}
editModelPopoverOpen={editModelPopoverOpen}
deleteConfirmOpen={deleteConfirmOpen}
onToggle={() => 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 (
<>
<Dialog
open={open}
onOpenChange={(newOpen) => {
if (
!newOpen &&
(llmFormOpen || embeddingFormOpen || providerFormOpen)
)
return;
if (!newOpen && providerFormOpen) return;
onOpenChange(newOpen);
}}
>
@@ -532,84 +454,50 @@ export default function ModelsDialog({
<div className="flex-1 flex flex-col overflow-hidden px-6 pb-6 mt-0">
{/* Fixed LangBot Models Card */}
<div className="flex-shrink-0">{renderLangBotModelsCard()}</div>
<div className="flex-shrink-0">
{langbotProvider && renderProviderCard(langbotProvider, true)}
</div>
{/* Add Model Button */}
{/* Add Provider Button */}
<div className="flex-shrink-0 mb-3 flex justify-between items-center">
<span className="text-sm text-muted-foreground">
{t('models.providerCount', { count: otherProviders.length })}
{otherProviders.length === 0
? t('models.addProviderHint')
: t('models.providerCount', { count: otherProviders.length })}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline">
<Plus className="h-4 w-4 mr-1" />
{t('models.addModel')}
<ChevronDown className="h-4 w-4 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleCreateLLM}>
<MessageSquareText className="h-4 w-4 mr-2" />
{t('models.addLLMModel')}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCreateEmbedding}>
<Cpu className="h-4 w-4 mr-2" />
{t('models.addEmbeddingModel')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
size="sm"
variant="outline"
onClick={handleCreateProvider}
>
<Plus className="h-4 w-4 mr-1" />
{t('models.addProvider')}
</Button>
</div>
{/* Scrollable Provider List */}
<div className="flex-1 overflow-auto">
{otherProviders.map((p) => renderProviderCard(p))}
{otherProviders.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Boxes className="h-12 w-12 mb-3 opacity-50" />
<p className="text-sm">{t('models.noProviders')}</p>
</div>
) : (
otherProviders.map((p) => renderProviderCard(p))
)}
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={llmFormOpen} onOpenChange={setLLMFormOpen}>
<DialogContent className="w-[700px] max-h-[90vh] overflow-y-auto p-6">
<DialogHeader>
<DialogTitle>
{editingLLMId ? t('models.editModel') : t('models.createModel')}
</DialogTitle>
</DialogHeader>
<LLMForm
editMode={!!editingLLMId}
initLLMId={editingLLMId || undefined}
providers={providers}
onFormSubmit={handleFormClose}
onFormCancel={() => setLLMFormOpen(false)}
onLLMDeleted={handleFormClose}
/>
</DialogContent>
</Dialog>
<Dialog open={embeddingFormOpen} onOpenChange={setEmbeddingFormOpen}>
<DialogContent className="w-[700px] max-h-[90vh] overflow-y-auto p-6">
<DialogHeader>
<DialogTitle>
{editingEmbeddingId
? t('embedding.editModel')
: t('embedding.createModel')}
</DialogTitle>
</DialogHeader>
<EmbeddingForm
editMode={!!editingEmbeddingId}
initEmbeddingId={editingEmbeddingId || undefined}
providers={providers}
onFormSubmit={handleFormClose}
onFormCancel={() => setEmbeddingFormOpen(false)}
onEmbeddingDeleted={handleFormClose}
/>
</DialogContent>
</Dialog>
<Dialog open={providerFormOpen} onOpenChange={setProviderFormOpen}>
<DialogContent className="w-[600px] p-6">
<DialogHeader>
<DialogTitle>{t('models.editProvider')}</DialogTitle>
<DialogTitle>
{editingProviderId
? t('models.editProvider')
: t('models.addProvider')}
</DialogTitle>
</DialogHeader>
<ProviderForm
providerId={editingProviderId || undefined}

View File

@@ -1,6 +0,0 @@
export interface IChooseRequesterEntity {
label: string;
value: string;
provider_category?: string;
description?: string;
}

View File

@@ -1,7 +0,0 @@
export interface ICreateEmbeddingField {
name: string;
model_provider: string;
url: string;
api_key: string;
extra_args?: string[];
}

View File

@@ -1,8 +0,0 @@
export interface ICreateLLMField {
name: string;
model_provider: string;
url: string;
api_key: string;
abilities: string[];
extra_args: string[];
}

View File

@@ -1,128 +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;
}
.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%;
}

View File

@@ -1,55 +0,0 @@
import styles from './EmbeddingCard.module.css';
import { EmbeddingCardVO } from './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>
);
}

View File

@@ -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;
}
}

View File

@@ -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<z.infer<typeof formSchema>>({
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<string | null>(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<typeof formSchema>) {
const extraArgsObj: Record<string, string | number | boolean> = {};
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<string, unknown> = {
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<string, unknown>) {
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<string, unknown>) {
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<string, string | number | boolean> = {};
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<string, unknown>;
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 (
<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-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('models.modelName')}
<span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Input {...field} placeholder="text-embedding-3-small" />
</FormControl>
<FormDescription>
{t('models.modelProviderDescription')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div>
<FormLabel>{t('models.provider')}</FormLabel>
<Tabs
value={providerMode}
onValueChange={(v) => setProviderMode(v as 'existing' | 'new')}
className="mt-2"
>
<TabsList>
<TabsTrigger value="existing">
{t('models.existingProvider')}
</TabsTrigger>
<TabsTrigger value="new">{t('models.newProvider')}</TabsTrigger>
</TabsList>
<TabsContent value="existing" className="mt-3">
<FormField
control={form.control}
name="provider_uuid"
render={({ field }) => (
<FormItem>
<Select
onValueChange={field.onChange}
value={field.value}
>
<SelectTrigger className="bg-background">
<SelectValue
placeholder={t('models.selectProvider')}
/>
</SelectTrigger>
<SelectContent>
{providers
.filter(
(p) => p.requester !== 'space-chat-completions',
)
.map((p) => (
<SelectItem key={p.uuid} value={p.uuid}>
{p.name} ({p.base_url || 'default'})
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
<TabsContent value="new" className="mt-3 space-y-4">
<FormField
control={form.control}
name="new_provider_requester"
render={({ field }) => (
<FormItem>
<FormLabel>{t('models.requester')}</FormLabel>
<Select
onValueChange={(v) => {
field.onChange(v);
const req = requesterList.find((r) => r.value === v);
if (req)
form.setValue('new_provider_url', req.defaultUrl);
}}
value={field.value}
>
<SelectTrigger className="bg-background">
<SelectValue
placeholder={t('models.selectRequester')}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>
{t('models.modelManufacturer')}
</SelectLabel>
{requesterList
.filter(
(r) =>
r.category === 'manufacturer' &&
r.value !== 'space-chat-completions',
)
.map((r) => (
<SelectItem key={r.value} value={r.value}>
{r.label}
</SelectItem>
))}
</SelectGroup>
<SelectGroup>
<SelectLabel>
{t('models.aggregationPlatform')}
</SelectLabel>
{requesterList
.filter(
(r) =>
r.category === 'maas' &&
r.value !== 'space-chat-completions',
)
.map((r) => (
<SelectItem key={r.value} value={r.value}>
{r.label}
</SelectItem>
))}
</SelectGroup>
<SelectGroup>
<SelectLabel>
{t('models.selfDeployed')}
</SelectLabel>
{requesterList
.filter(
(r) =>
r.category === 'self-hosted' &&
r.value !== 'space-chat-completions',
)
.map((r) => (
<SelectItem key={r.value} value={r.value}>
{r.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="new_provider_url"
render={({ field }) => (
<FormItem>
<FormLabel>{t('models.requestURL')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="new_provider_api_key"
render={({ field }) => (
<FormItem>
<FormLabel>{t('models.apiKey')}</FormLabel>
<FormControl>
<Input {...field} type="password" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
</Tabs>
</div>
<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={(v) => updateExtraArg(index, 'type', v)}
>
<SelectTrigger className="w-[120px] bg-background">
<SelectValue />
</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"
variant="ghost"
size="icon"
onClick={() => removeExtraArg(index)}
>
<span className="text-red-500">×</span>
</Button>
</div>
))}
<Button type="button" variant="outline" onClick={addExtraArg}>
{t('models.addParameter')}
</Button>
</div>
<FormDescription>
{t('embedding.extraParametersDescription')}
</FormDescription>
</FormItem>
{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={testModel}
disabled={modelTesting}
>
{t('common.test')}
</Button>
<Button type="button" variant="outline" onClick={onFormCancel}>
{t('common.cancel')}
</Button>
</DialogFooter>
</form>
</Form>
</div>
);
}

View File

@@ -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%;
}

View File

@@ -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: (
<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>
);
}

View File

@@ -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;
}
}

View File

@@ -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<z.infer<typeof formSchema>>({
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<string | null>(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<typeof formSchema>) {
const extraArgsObj: Record<string, string | number | boolean> = {};
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<string, unknown> = {
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<string, unknown>) {
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<string, unknown>) {
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<string, string | number | boolean> = {};
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<string, unknown>;
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 (
<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-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('models.modelName')}
<span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Input {...field} placeholder="gpt-4o" />
</FormControl>
<FormDescription>
{t('models.modelProviderDescription')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div>
<FormLabel>{t('models.provider')}</FormLabel>
<Tabs
value={providerMode}
onValueChange={(v) => setProviderMode(v as 'existing' | 'new')}
className="mt-2"
>
<TabsList>
<TabsTrigger value="existing">
{t('models.existingProvider')}
</TabsTrigger>
<TabsTrigger value="new">{t('models.newProvider')}</TabsTrigger>
</TabsList>
<TabsContent value="existing" className="mt-3">
<FormField
control={form.control}
name="provider_uuid"
render={({ field }) => (
<FormItem>
<Select
onValueChange={field.onChange}
value={field.value}
>
<SelectTrigger className="bg-background">
<SelectValue
placeholder={t('models.selectProvider')}
/>
</SelectTrigger>
<SelectContent>
{providers
.filter(
(p) => p.requester !== 'space-chat-completions',
)
.map((p) => (
<SelectItem key={p.uuid} value={p.uuid}>
{p.name} ({p.base_url || 'default'})
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
<TabsContent value="new" className="mt-3 space-y-4">
<FormField
control={form.control}
name="new_provider_requester"
render={({ field }) => (
<FormItem>
<FormLabel>{t('models.requester')}</FormLabel>
<Select
onValueChange={(v) => {
field.onChange(v);
const req = requesterList.find((r) => r.value === v);
if (req)
form.setValue('new_provider_url', req.defaultUrl);
}}
value={field.value}
>
<SelectTrigger className="bg-background">
<SelectValue
placeholder={t('models.selectRequester')}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>
{t('models.modelManufacturer')}
</SelectLabel>
{requesterList
.filter(
(r) =>
r.category === 'manufacturer' &&
r.value !== 'space-chat-completions',
)
.map((r) => (
<SelectItem key={r.value} value={r.value}>
{r.label}
</SelectItem>
))}
</SelectGroup>
<SelectGroup>
<SelectLabel>
{t('models.aggregationPlatform')}
</SelectLabel>
{requesterList
.filter(
(r) =>
r.category === 'maas' &&
r.value !== 'space-chat-completions',
)
.map((r) => (
<SelectItem key={r.value} value={r.value}>
{r.label}
</SelectItem>
))}
</SelectGroup>
<SelectGroup>
<SelectLabel>
{t('models.selfDeployed')}
</SelectLabel>
{requesterList
.filter(
(r) =>
r.category === 'self-hosted' &&
r.value !== 'space-chat-completions',
)
.map((r) => (
<SelectItem key={r.value} value={r.value}>
{r.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="new_provider_url"
render={({ field }) => (
<FormItem>
<FormLabel>{t('models.requestURL')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="new_provider_api_key"
render={({ field }) => (
<FormItem>
<FormLabel>{t('models.apiKey')}</FormLabel>
<FormControl>
<Input {...field} type="password" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
</Tabs>
</div>
<FormField
control={form.control}
name="abilities"
render={() => (
<FormItem>
<FormLabel>{t('models.abilities')}</FormLabel>
<FormDescription>
{t('models.selectModelAbilities')}
</FormDescription>
{abilityOptions.map((item) => (
<FormField
key={item.value}
control={form.control}
name="abilities"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-2 space-y-0">
<FormControl>
<Checkbox
checked={field.value?.includes(item.value)}
onCheckedChange={(checked) => {
if (checked) {
field.onChange([
...(field.value || []),
item.value,
]);
} else {
field.onChange(
field.value?.filter(
(v: string) => v !== item.value,
),
);
}
}}
/>
</FormControl>
<FormLabel className="font-normal">
{item.label}
</FormLabel>
</FormItem>
)}
/>
))}
</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={(v) => updateExtraArg(index, 'type', v)}
>
<SelectTrigger className="w-[120px] bg-background">
<SelectValue />
</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"
variant="ghost"
size="icon"
onClick={() => removeExtraArg(index)}
>
<span className="text-red-500">×</span>
</Button>
</div>
))}
<Button type="button" variant="outline" onClick={addExtraArg}>
{t('models.addParameter')}
</Button>
</div>
<FormDescription>
{t('llm.extraParametersDescription')}
</FormDescription>
</FormItem>
{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={testModel}
disabled={modelTesting}
>
{t('common.test')}
</Button>
<Button type="button" variant="outline" onClick={onFormCancel}>
{t('common.cancel')}
</Button>
</DialogFooter>
</form>
</Form>
</div>
);
}

View File

@@ -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);
}
}}

View File

@@ -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<void>;
onTestModel: (
name: string,
modelType: ModelType,
abilities: string[],
extraArgs: ExtraArg[],
) => Promise<void>;
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<ModelType>('llm');
const [name, setName] = useState('');
const [abilities, setAbilities] = useState<string[]>([]);
const [extraArgs, setExtraArgs] = useState<ExtraArg[]>([]);
// 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 (
<Popover
open={isOpen}
onOpenChange={(open) => (open ? onOpen() : onClose())}
>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 text-xs"
onClick={(e) => e.stopPropagation()}
>
<Plus className="h-3 w-3 mr-1" />
{t('models.addModel')}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-80"
align="end"
onClick={(e) => e.stopPropagation()}
>
<Tabs value={tab} onValueChange={(v) => setTab(v as ModelType)}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="llm">
<MessageSquareText className="h-4 w-4 mr-1" />
{t('models.chat')}
</TabsTrigger>
<TabsTrigger value="embedding">
<Cpu className="h-4 w-4 mr-1" />
{t('models.embedding')}
</TabsTrigger>
</TabsList>
<TabsContent value="llm" className="space-y-3 mt-3">
<div className="space-y-2">
<Label>{t('models.modelName')}</Label>
<Input
placeholder={t('models.modelName')}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>{t('models.abilities')}</Label>
<div className="flex gap-4">
<div className="flex items-center gap-2">
<Checkbox
id="add-vision"
checked={abilities.includes('vision')}
onCheckedChange={(checked) =>
toggleAbility('vision', checked as boolean)
}
/>
<Label htmlFor="add-vision" className="text-sm">
<Eye className="h-3 w-3 inline mr-1" />
{t('models.visionAbility')}
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="add-func-call"
checked={abilities.includes('func_call')}
onCheckedChange={(checked) =>
toggleAbility('func_call', checked as boolean)
}
/>
<Label htmlFor="add-func-call" className="text-sm">
<Wrench className="h-3 w-3 inline mr-1" />
{t('models.functionCallAbility')}
</Label>
</div>
</div>
</div>
<ExtraArgsEditor args={extraArgs} onChange={setExtraArgs} />
<div className="flex gap-2">
<Button
className="flex-1"
size="sm"
onClick={handleAdd}
disabled={isSubmitting || isTesting}
>
{isSubmitting ? t('common.saving') : t('common.add')}
</Button>
<Button
className="flex-1"
size="sm"
variant="outline"
onClick={handleTest}
disabled={isSubmitting || isTesting}
>
{isTesting ? (
t('common.loading')
) : testResult?.success ? (
<>
<Check className="h-4 w-4 mr-1 text-green-500" />
{(testResult.duration / 1000).toFixed(1)}s
</>
) : (
t('common.test')
)}
</Button>
</div>
</TabsContent>
<TabsContent value="embedding" className="space-y-3 mt-3">
<div className="space-y-2">
<Label>{t('models.modelName')}</Label>
<Input
placeholder={t('models.modelName')}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<ExtraArgsEditor args={extraArgs} onChange={setExtraArgs} />
<div className="flex gap-2">
<Button
className="flex-1"
size="sm"
onClick={handleAdd}
disabled={isSubmitting || isTesting}
>
{isSubmitting ? t('common.saving') : t('common.add')}
</Button>
<Button
className="flex-1"
size="sm"
variant="outline"
onClick={handleTest}
disabled={isSubmitting || isTesting}
>
{isTesting ? (
t('common.loading')
) : testResult?.success ? (
<>
<Check className="h-4 w-4 mr-1 text-green-500" />
{(testResult.duration / 1000).toFixed(1)}s
</>
) : (
t('common.test')
)}
</Button>
</div>
</TabsContent>
</Tabs>
</PopoverContent>
</Popover>
);
}

View File

@@ -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 (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>{t('models.extraParameters')}</Label>
{!disabled && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 text-xs"
onClick={handleAdd}
>
<Plus className="h-3 w-3 mr-1" />
{t('models.addParameter')}
</Button>
)}
</div>
{args.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('common.none')}</p>
) : (
args.map((arg, index) => (
<div key={index} className="flex gap-2 items-center">
<Input
placeholder={t('models.keyName')}
value={arg.key}
className="flex-1"
disabled={disabled}
onChange={(e) => handleUpdate(index, 'key', e.target.value)}
/>
<Select
value={arg.type}
disabled={disabled}
onValueChange={(value) => handleUpdate(index, 'type', value)}
>
<SelectTrigger className="w-24">
<SelectValue />
</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}
className="flex-1"
disabled={disabled}
onChange={(e) => handleUpdate(index, 'value', e.target.value)}
/>
{!disabled && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 flex-shrink-0"
onClick={() => handleRemove(index)}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))
)}
</div>
);
}

View File

@@ -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<void>;
onTestModel: (
name: string,
abilities: string[],
extraArgs: ExtraArg[],
) => Promise<void>;
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<string[]>(
modelType === 'llm' ? (model as LLMModel).abilities || [] : [],
);
const [editExtraArgs, setEditExtraArgs] = useState<ExtraArg[]>(
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 (
<Popover
open={isEditOpen}
onOpenChange={(open) => {
if (open) {
onOpenEditModel(model.uuid);
} else {
onCloseEditModel();
}
}}
>
<PopoverTrigger asChild>
<div className="flex items-center justify-between py-2 px-3 rounded-md border bg-background hover:bg-accent cursor-pointer">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{model.name}</span>
<Badge variant="secondary" className="text-xs">
{modelType === 'llm' ? t('models.chat') : t('models.embedding')}
</Badge>
{modelType === 'llm' &&
(model as LLMModel).abilities?.includes('vision') && (
<Badge variant="outline" className="text-xs gap-1">
<Eye className="h-3 w-3" />
</Badge>
)}
{modelType === 'llm' &&
(model as LLMModel).abilities?.includes('func_call') && (
<Badge variant="outline" className="text-xs gap-1">
<Wrench className="h-3 w-3" />
</Badge>
)}
</div>
{!isLangBotModels && (
<Popover
open={isDeleteOpen}
onOpenChange={(open) =>
open ? onOpenDeleteConfirm(model.uuid) : onCloseDeleteConfirm()
}
>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
}}
>
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-64"
align="end"
onClick={(e) => e.stopPropagation()}
>
<div className="space-y-3">
<p className="text-sm">{t('models.deleteConfirmation')}</p>
<div className="flex gap-2 justify-end">
<Button
variant="outline"
size="sm"
onClick={() => onCloseDeleteConfirm()}
>
{t('common.cancel')}
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => {
onDeleteModel();
onCloseDeleteConfirm();
}}
>
{t('common.delete')}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)}
</div>
</PopoverTrigger>
<PopoverContent className="w-80" align="start">
<div className="space-y-3">
<div className="space-y-2">
<Label>{t('models.modelName')}</Label>
<Input
placeholder={t('models.modelName')}
value={editName}
onChange={(e) => setEditName(e.target.value)}
disabled={isLangBotModels}
/>
</div>
{modelType === 'llm' && (
<div className="space-y-2">
<Label>{t('models.abilities')}</Label>
<div className="flex gap-4">
<div className="flex items-center gap-2">
<Checkbox
id={`edit-vision-${model.uuid}`}
checked={editAbilities.includes('vision')}
disabled={isLangBotModels}
onCheckedChange={(checked) =>
toggleAbility('vision', checked as boolean)
}
/>
<Label
htmlFor={`edit-vision-${model.uuid}`}
className="text-sm"
>
<Eye className="h-3 w-3 inline mr-1" />
{t('models.visionAbility')}
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id={`edit-func-call-${model.uuid}`}
checked={editAbilities.includes('func_call')}
disabled={isLangBotModels}
onCheckedChange={(checked) =>
toggleAbility('func_call', checked as boolean)
}
/>
<Label
htmlFor={`edit-func-call-${model.uuid}`}
className="text-sm"
>
<Wrench className="h-3 w-3 inline mr-1" />
{t('models.functionCallAbility')}
</Label>
</div>
</div>
</div>
)}
<ExtraArgsEditor
args={editExtraArgs}
onChange={setEditExtraArgs}
disabled={isLangBotModels}
/>
<div className="flex gap-2">
{!isLangBotModels && (
<Button
className="flex-1"
size="sm"
onClick={handleSave}
disabled={isSubmitting || isTesting}
>
{isSubmitting ? t('common.saving') : t('common.save')}
</Button>
)}
<Button
className={isLangBotModels ? 'w-full' : 'flex-1'}
size="sm"
variant="outline"
onClick={handleTest}
disabled={isSubmitting || isTesting}
>
{isTesting ? (
t('common.loading')
) : testResult?.success ? (
<>
<Check className="h-4 w-4 mr-1 text-green-500" />
{(testResult.duration / 1000).toFixed(1)}s
</>
) : (
t('common.test')
)}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -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<void>;
onOpenEditModel: (modelId: string) => void;
onCloseEditModel: () => void;
onUpdateModel: (
modelId: string,
modelType: ModelType,
name: string,
abilities: string[],
extraArgs: ExtraArg[],
) => Promise<void>;
onOpenDeleteConfirm: (modelId: string) => void;
onCloseDeleteConfirm: () => void;
onDeleteModel: (modelId: string, modelType: ModelType) => Promise<void>;
onTestModel: (
name: string,
modelType: ModelType,
abilities: string[],
extraArgs: ExtraArg[],
) => Promise<void>;
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 (
<Card className="mb-2">
<Collapsible open={isExpanded} onOpenChange={onToggle}>
<CardHeader className="py-0 px-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 flex-1">
{isLangBotModels ? (
<div className="w-9 h-9 rounded-lg overflow-hidden flex-shrink-0">
<img
src={langbotIcon.src}
alt="LangBot"
className="w-full h-full object-cover"
/>
</div>
) : (
<img
src={httpClient.getProviderRequesterIconURL(
provider.requester,
)}
alt={provider.name}
className="h-9 w-9 rounded-lg"
/>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<CardTitle className="text-base">
{isLangBotModels
? provider.name
: getRequesterLabel(provider.requester)}
</CardTitle>
<Badge variant="outline" className="text-xs">
{t('models.modelsCount', { count: totalModels })}
</Badge>
</div>
<p className="text-xs text-muted-foreground truncate">
{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])}
</>
)}
</p>
</div>
</div>
<div className="flex items-center gap-1 ml-2">
{isLangBotModels && accountType !== 'space' && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
onSpaceLogin();
}}
>
<LogIn className="h-4 w-4 mr-1" />
{t('models.loginWithSpace')}
</Button>
)}
{isLangBotModels &&
accountType === 'space' &&
spaceCredits !== null && (
<div className="flex items-center gap-1 border rounded-md px-2 h-8 text-sm mr-2">
<span>
{(spaceCredits / 5000).toFixed(2)} {t('models.credits')}
</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={(e) => {
e.stopPropagation();
window.open(
`${systemInfo.cloud_service_url}/profile?tab=billing`,
'_blank',
);
}}
>
<Plus className="h-3 w-3" />
</Button>
</div>
)}
{!isLangBotModels && (
<>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
onEditProvider();
}}
>
<Settings className="h-4 w-4" />
</Button>
{canDelete && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
onDeleteProvider();
}}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</>
)}
</div>
</div>
<div className="flex items-center justify-between mt-2">
{totalModels > 0 ? (
<CollapsibleTrigger className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground cursor-pointer">
{isExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
<span>
{isExpanded
? t('models.collapseModels')
: t('models.expandModels')}
</span>
</CollapsibleTrigger>
) : (
<div />
)}
{!isLangBotModels && (
<AddModelPopover
providerUuid={provider.uuid}
isOpen={addModelPopoverOpen === provider.uuid}
onOpen={onOpenAddModel}
onClose={onCloseAddModel}
onAddModel={onAddModel}
onTestModel={onTestModel}
isSubmitting={isSubmitting}
isTesting={isTesting}
testResult={testResult}
onResetTestResult={onResetTestResult}
/>
)}
</div>
</CardHeader>
<CollapsibleContent>
<CardContent className="px-4 mt-2">
{isLoading ? (
<p className="text-sm text-muted-foreground text-center py-4">
{t('common.loading')}...
</p>
) : models ? (
<div className="space-y-2">
{models.llm.map((model) => (
<ModelItem
key={model.uuid}
model={model}
modelType="llm"
providerUuid={provider.uuid}
isLangBotModels={isLangBotModels}
editModelPopoverOpen={editModelPopoverOpen}
deleteConfirmOpen={deleteConfirmOpen}
onOpenEditModel={onOpenEditModel}
onCloseEditModel={onCloseEditModel}
onOpenDeleteConfirm={onOpenDeleteConfirm}
onCloseDeleteConfirm={onCloseDeleteConfirm}
onDeleteModel={() => 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) => (
<ModelItem
key={model.uuid}
model={model}
modelType="embedding"
providerUuid={provider.uuid}
isLangBotModels={isLangBotModels}
editModelPopoverOpen={editModelPopoverOpen}
deleteConfirmOpen={deleteConfirmOpen}
onOpenEditModel={onOpenEditModel}
onCloseEditModel={onCloseEditModel}
onOpenDeleteConfirm={onOpenDeleteConfirm}
onCloseDeleteConfirm={onCloseDeleteConfirm}
onDeleteModel={() => 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 && (
<p className="text-sm text-muted-foreground text-center py-4">
{t('models.noModels')}
</p>
)}
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-4">
{t('models.noModels')}
</p>
)}
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
);
}

View File

@@ -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';

View File

@@ -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<void>;
onTest: (
name: string,
abilities: string[],
extraArgs: ExtraArg[],
) => Promise<void>;
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<void>;
onOpenEditModel: (modelId: string) => void;
onCloseEditModel: () => void;
onUpdateModel: (
modelId: string,
modelType: ModelType,
name: string,
abilities: string[],
extraArgs: ExtraArg[],
) => Promise<void>;
onOpenDeleteConfirm: (modelId: string) => void;
onCloseDeleteConfirm: () => void;
onDeleteModel: (modelId: string, modelType: ModelType) => Promise<void>;
onTestModel: (
name: string,
modelType: ModelType,
abilities: string[],
extraArgs: ExtraArg[],
) => Promise<void>;
isSubmitting: boolean;
isTesting: boolean;
testResult: TestResult | null;
onResetTestResult: () => void;
}
export const LANGBOT_MODELS_PROVIDER_REQUESTER = 'space-chat-completions';

View File

@@ -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',

View File

@@ -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: 'プロバイダータイプは必須です',

View File

@@ -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: '供应商类型不能为空',

View File

@@ -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: '供應商類型不能為空',