mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
refactor: update model management components and enhance provider functionality
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export interface IChooseRequesterEntity {
|
||||
label: string;
|
||||
value: string;
|
||||
provider_category?: string;
|
||||
description?: string;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export interface ICreateEmbeddingField {
|
||||
name: string;
|
||||
model_provider: string;
|
||||
url: string;
|
||||
api_key: string;
|
||||
extra_args?: string[];
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export interface ICreateLLMField {
|
||||
name: string;
|
||||
model_provider: string;
|
||||
url: string;
|
||||
api_key: string;
|
||||
abilities: string[];
|
||||
extra_args: string[];
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
102
web/src/app/home/components/models-dialog/types.ts
Normal file
102
web/src/app/home/components/models-dialog/types.ts
Normal 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';
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'プロバイダータイプは必須です',
|
||||
|
||||
@@ -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: '供应商类型不能为空',
|
||||
|
||||
@@ -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: '供應商類型不能為空',
|
||||
|
||||
Reference in New Issue
Block a user