mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
feat(providers): add model discovery panel inside the models section
- New useModelDiscovery hook fans out to modelsApi.fetchGemini / fetchV1 / fetchClaude / fetchModels based on brand and surfaces loading, error and the normalized model list - New ModelDiscoveryPanel renders an inline search, select-all and per-model checkboxes with an Already added marker for entries that already exist in form.models - BaseProviderForm: show a Fetch from endpoint button at the top of the models section for the supported brands, with apply merging the picked names into form.models while skipping duplicates and replacing the empty placeholder row - Add i18n keys (en/zh-CN/zh-TW/ru) for the discovery toolbar, list states and apply count
This commit is contained in:
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCheckCircle2,
|
||||
IconDownload,
|
||||
IconLoader2,
|
||||
IconPlus,
|
||||
IconX,
|
||||
@@ -27,6 +28,8 @@ import {
|
||||
type ConnectivityErrorMessages,
|
||||
type ConnectivityState,
|
||||
} from './useConnectivityTest';
|
||||
import { useModelDiscovery } from './useModelDiscovery';
|
||||
import { ModelDiscoveryPanel } from './ModelDiscoveryPanel';
|
||||
import styles from './sharedForm.module.scss';
|
||||
|
||||
export interface BaseProviderFormHandle {
|
||||
@@ -202,8 +205,9 @@ export function BaseProviderForm({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fallbackApiKey = useMemo(() => {
|
||||
if (brand !== 'claude' || mode !== 'edit' || !resource) return '';
|
||||
return (resource.raw as ProviderKeyConfig | undefined)?.apiKey ?? '';
|
||||
if (mode !== 'edit' || !resource) return '';
|
||||
if (brand === 'openaiCompatibility') return '';
|
||||
return (resource.raw as { apiKey?: string } | undefined)?.apiKey ?? '';
|
||||
}, [brand, mode, resource]);
|
||||
|
||||
const connectivityMessages = useMemo<ConnectivityErrorMessages>(
|
||||
@@ -233,6 +237,66 @@ export function BaseProviderForm({
|
||||
connectivityMessages
|
||||
);
|
||||
|
||||
const discovery = useModelDiscovery({
|
||||
brand,
|
||||
baseUrl: form.baseUrl,
|
||||
formHeaders: form.headers,
|
||||
apiKeyEntries: form.apiKeyEntries,
|
||||
apiKey: form.apiKey,
|
||||
fallbackApiKey,
|
||||
});
|
||||
const [discoveryOpen, setDiscoveryOpen] = useState(false);
|
||||
|
||||
const existingModelNames = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
form.models.forEach((m) => {
|
||||
const name = (m.name ?? '').trim();
|
||||
if (name) set.add(name);
|
||||
});
|
||||
return set;
|
||||
}, [form.models]);
|
||||
|
||||
const openDiscovery = () => {
|
||||
setDiscoveryOpen(true);
|
||||
if (!discovery.loading && !discovery.hasFetched) {
|
||||
void discovery.fetch();
|
||||
}
|
||||
};
|
||||
|
||||
const closeDiscovery = () => {
|
||||
setDiscoveryOpen(false);
|
||||
};
|
||||
|
||||
const applyDiscoveredModels = (names: string[]) => {
|
||||
if (!names.length) return;
|
||||
setForm((prev) => {
|
||||
const seen = new Set<string>();
|
||||
const next: ModelEntryInput[] = [];
|
||||
prev.models.forEach((entry) => {
|
||||
const trimmed = (entry.name ?? '').trim();
|
||||
if (trimmed) {
|
||||
if (seen.has(trimmed)) return;
|
||||
seen.add(trimmed);
|
||||
}
|
||||
next.push(entry);
|
||||
});
|
||||
// If the existing list is just an empty placeholder row, drop it.
|
||||
const placeholderIdx = next.findIndex(
|
||||
(it) => !(it.name ?? '').trim() && !(it.alias ?? '').trim()
|
||||
);
|
||||
if (placeholderIdx !== -1 && names.length > 0) {
|
||||
next.splice(placeholderIdx, 1);
|
||||
}
|
||||
names.forEach((name) => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed || seen.has(trimmed)) return;
|
||||
seen.add(trimmed);
|
||||
next.push({ name: trimmed, alias: '' });
|
||||
});
|
||||
return { ...prev, models: next };
|
||||
});
|
||||
};
|
||||
|
||||
const updateField = <K extends keyof ProviderEntryFormInput>(
|
||||
key: K,
|
||||
value: ProviderEntryFormInput[K]
|
||||
@@ -723,6 +787,34 @@ export function BaseProviderForm({
|
||||
{descriptor.supportsModels ? (
|
||||
<Collapsible label={t('providersPage.form.modelsSection')}>
|
||||
<div className={styles.entriesList}>
|
||||
{discovery.available ? (
|
||||
<div className={styles.entriesToolbar}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.connectivityBtn}
|
||||
onClick={openDiscovery}
|
||||
disabled={mutating}
|
||||
>
|
||||
<IconDownload size={14} />
|
||||
<span>{t('providersPage.discovery.openButton')}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{discovery.available && discoveryOpen ? (
|
||||
<ModelDiscoveryPanel
|
||||
loading={discovery.loading}
|
||||
error={discovery.error}
|
||||
models={discovery.models}
|
||||
hasFetched={discovery.hasFetched}
|
||||
existingNames={existingModelNames}
|
||||
mutating={mutating}
|
||||
onApply={(names) => {
|
||||
applyDiscoveredModels(names);
|
||||
}}
|
||||
onReload={() => void discovery.fetch()}
|
||||
onClose={closeDiscovery}
|
||||
/>
|
||||
) : null}
|
||||
{modelsList.map((entry, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
IconLoader2,
|
||||
IconRefreshCw,
|
||||
IconSearch,
|
||||
} from '@/components/ui/icons';
|
||||
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
|
||||
import type { ModelInfo } from '@/utils/models';
|
||||
import styles from './sharedForm.module.scss';
|
||||
|
||||
interface ModelDiscoveryPanelProps {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
models: ModelInfo[];
|
||||
hasFetched: boolean;
|
||||
existingNames: Set<string>;
|
||||
mutating?: boolean;
|
||||
onApply: (names: string[]) => void;
|
||||
onReload: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ModelDiscoveryPanel({
|
||||
loading,
|
||||
error,
|
||||
models,
|
||||
hasFetched,
|
||||
existingNames,
|
||||
mutating,
|
||||
onApply,
|
||||
onReload,
|
||||
onClose,
|
||||
}: ModelDiscoveryPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState('');
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return models;
|
||||
return models.filter((m) =>
|
||||
`${m.name} ${m.alias ?? ''}`.toLowerCase().includes(q)
|
||||
);
|
||||
}, [models, search]);
|
||||
|
||||
const selectable = useMemo(
|
||||
() => filtered.filter((m) => !existingNames.has(m.name)),
|
||||
[filtered, existingNames]
|
||||
);
|
||||
|
||||
const allSelectableChecked =
|
||||
selectable.length > 0 && selectable.every((m) => selected.has(m.name));
|
||||
|
||||
const toggle = (name: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
if (allSelectableChecked) {
|
||||
setSelected(new Set());
|
||||
} else {
|
||||
setSelected(new Set(selectable.map((m) => m.name)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
const names = Array.from(selected).filter((n) => !existingNames.has(n));
|
||||
if (!names.length) return;
|
||||
onApply(names);
|
||||
setSelected(new Set());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.discoveryPanel}>
|
||||
<div className={styles.discoveryToolbar}>
|
||||
<div className={styles.discoverySearchWrap}>
|
||||
<span className={styles.discoverySearchIcon} aria-hidden="true">
|
||||
<IconSearch size={14} />
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
className={styles.discoverySearch}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('providersPage.discovery.searchPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.connectivityBtn}
|
||||
onClick={onReload}
|
||||
disabled={loading}
|
||||
aria-label={t('providersPage.discovery.reload')}
|
||||
>
|
||||
{loading ? (
|
||||
<span className={`${styles.statusIcon} ${styles.statusIconLoading}`}>
|
||||
<IconLoader2 size={14} />
|
||||
</span>
|
||||
) : (
|
||||
<IconRefreshCw size={14} />
|
||||
)}
|
||||
<span>{t('providersPage.discovery.reload')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && !models.length ? (
|
||||
<div className={styles.discoveryEmpty}>
|
||||
{t('providersPage.discovery.loading')}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className={styles.connectivityError}>{error}</div>
|
||||
) : hasFetched && !models.length ? (
|
||||
<div className={styles.discoveryEmpty}>
|
||||
{t('providersPage.discovery.empty')}
|
||||
</div>
|
||||
) : models.length ? (
|
||||
<>
|
||||
<div className={styles.discoveryBatchRow}>
|
||||
<SelectionCheckbox
|
||||
checked={allSelectableChecked}
|
||||
onChange={toggleAll}
|
||||
disabled={selectable.length === 0}
|
||||
label={
|
||||
<span className={styles.discoveryBatchLabel}>
|
||||
{allSelectableChecked
|
||||
? t('providersPage.discovery.clearAll')
|
||||
: t('providersPage.discovery.selectAll')}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<span className={styles.discoveryCount}>
|
||||
{t('providersPage.discovery.selectedCount', {
|
||||
selected: selected.size,
|
||||
total: selectable.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<ul className={styles.discoveryList}>
|
||||
{filtered.map((m) => {
|
||||
const existing = existingNames.has(m.name);
|
||||
return (
|
||||
<li
|
||||
key={m.name}
|
||||
className={
|
||||
existing
|
||||
? `${styles.discoveryItem} ${styles.discoveryItemExisting}`
|
||||
: styles.discoveryItem
|
||||
}
|
||||
>
|
||||
{existing ? (
|
||||
<>
|
||||
<span className={styles.discoveryName}>{m.name}</span>
|
||||
<span className={styles.discoveryAddedTag}>
|
||||
{t('providersPage.discovery.alreadyAdded')}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<SelectionCheckbox
|
||||
checked={selected.has(m.name)}
|
||||
onChange={() => toggle(m.name)}
|
||||
label={
|
||||
<span className={styles.discoveryName}>{m.name}</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.discoveryEmpty}>
|
||||
{t('providersPage.discovery.notLoaded')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.discoveryFooter}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.connectivityBtnGhost}
|
||||
onClick={onClose}
|
||||
disabled={mutating}
|
||||
>
|
||||
{t('providersPage.discovery.close')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.discoveryApplyBtn}
|
||||
onClick={handleApply}
|
||||
disabled={mutating || selected.size === 0}
|
||||
>
|
||||
{t('providersPage.discovery.apply', { count: selected.size })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -317,6 +317,170 @@
|
||||
color: var(--destructive-color);
|
||||
}
|
||||
|
||||
.discoveryPanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.discoveryToolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.discoverySearchWrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.discoverySearchIcon {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--muted-foreground);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.discoverySearch {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 6px 10px 6px 30px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px var(--primary-10);
|
||||
}
|
||||
}
|
||||
|
||||
.discoveryEmpty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.discoveryBatchRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.discoveryBatchLabel {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.discoveryCount {
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.discoveryList {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.discoveryItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.discoveryItemExisting {
|
||||
background: var(--muted-bg);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.discoveryName {
|
||||
font-family: $font-mono;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.discoveryAddedTag {
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.discoveryFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.discoveryApplyBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 30px;
|
||||
padding: 0 14px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid transparent;
|
||||
background: var(--primary-color);
|
||||
color: var(--primary-contrast);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color $transition-fast;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { modelsApi } from '@/services/api';
|
||||
import { buildHeaderObject } from '@/utils/headers';
|
||||
import type { ModelInfo } from '@/utils/models';
|
||||
import type { ApiKeyEntryInput, ProviderBrand } from '../../types';
|
||||
|
||||
export const MODEL_DISCOVERY_BRANDS: ReadonlyArray<ProviderBrand> = [
|
||||
'gemini',
|
||||
'codex',
|
||||
'claude',
|
||||
'openaiCompatibility',
|
||||
];
|
||||
|
||||
export const isModelDiscoveryBrand = (brand: ProviderBrand): boolean =>
|
||||
MODEL_DISCOVERY_BRANDS.includes(brand);
|
||||
|
||||
const parseHeadersText = (text: string): Record<string, string> => {
|
||||
const out: Record<string, string> = {};
|
||||
String(text ?? '')
|
||||
.split(/\n+/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.forEach((line) => {
|
||||
const sep = line.indexOf(':');
|
||||
if (sep <= 0) return;
|
||||
const key = line.slice(0, sep).trim();
|
||||
const value = line.slice(sep + 1).trim();
|
||||
if (!key) return;
|
||||
out[key] = value;
|
||||
});
|
||||
return out;
|
||||
};
|
||||
|
||||
const toErrorMessage = (err: unknown): string => {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (typeof err === 'string') return err;
|
||||
return '';
|
||||
};
|
||||
|
||||
export interface UseModelDiscoveryArgs {
|
||||
brand: ProviderBrand;
|
||||
baseUrl: string;
|
||||
formHeaders: Array<{ key: string; value: string }>;
|
||||
apiKeyEntries?: ApiKeyEntryInput[];
|
||||
apiKey?: string;
|
||||
fallbackApiKey?: string;
|
||||
}
|
||||
|
||||
export interface UseModelDiscoveryResult {
|
||||
available: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
models: ModelInfo[];
|
||||
hasFetched: boolean;
|
||||
fetch: () => Promise<void>;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function useModelDiscovery(
|
||||
args: UseModelDiscoveryArgs
|
||||
): UseModelDiscoveryResult {
|
||||
const { brand, baseUrl, formHeaders, apiKeyEntries, apiKey, fallbackApiKey } =
|
||||
args;
|
||||
|
||||
const available = isModelDiscoveryBrand(brand);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [hasFetched, setHasFetched] = useState(false);
|
||||
|
||||
const fetch = useCallback(async () => {
|
||||
if (!available) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const baseHeaders = buildHeaderObject(formHeaders);
|
||||
let next: ModelInfo[] = [];
|
||||
if (brand === 'gemini') {
|
||||
const key = (apiKey ?? '').trim() || (fallbackApiKey ?? '').trim();
|
||||
next = await modelsApi.fetchGeminiModelsViaApiCall(
|
||||
baseUrl,
|
||||
key,
|
||||
baseHeaders
|
||||
);
|
||||
} else if (brand === 'codex') {
|
||||
const key = (apiKey ?? '').trim() || (fallbackApiKey ?? '').trim();
|
||||
next = await modelsApi.fetchV1ModelsViaApiCall(
|
||||
baseUrl,
|
||||
key,
|
||||
baseHeaders
|
||||
);
|
||||
} else if (brand === 'claude') {
|
||||
const key = (apiKey ?? '').trim() || (fallbackApiKey ?? '').trim();
|
||||
next = await modelsApi.fetchClaudeModelsViaApiCall(
|
||||
baseUrl,
|
||||
key,
|
||||
baseHeaders
|
||||
);
|
||||
} else if (brand === 'openaiCompatibility') {
|
||||
const firstEntry = (apiKeyEntries ?? []).find((e) =>
|
||||
(e.apiKey ?? '').trim()
|
||||
);
|
||||
const entryKey = (firstEntry?.apiKey ?? '').trim();
|
||||
const entryHeaders = parseHeadersText(firstEntry?.headersText ?? '');
|
||||
const headers = { ...baseHeaders, ...entryHeaders };
|
||||
next = await modelsApi.fetchModelsViaApiCall(baseUrl, entryKey, headers);
|
||||
}
|
||||
setModels(next ?? []);
|
||||
setHasFetched(true);
|
||||
} catch (err) {
|
||||
setModels([]);
|
||||
setError(toErrorMessage(err) || 'Failed to fetch models');
|
||||
setHasFetched(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [available, apiKey, apiKeyEntries, baseUrl, brand, fallbackApiKey, formHeaders]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setModels([]);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
setHasFetched(false);
|
||||
}, []);
|
||||
|
||||
return { available, loading, error, models, hasFetched, fetch, reset };
|
||||
}
|
||||
@@ -1611,6 +1611,20 @@
|
||||
"timeout": "Timed out after {{seconds}}s",
|
||||
"requestFailed": "Request failed"
|
||||
},
|
||||
"discovery": {
|
||||
"openButton": "Fetch from endpoint",
|
||||
"searchPlaceholder": "Search models",
|
||||
"reload": "Reload",
|
||||
"loading": "Loading models…",
|
||||
"empty": "No models returned by the endpoint",
|
||||
"notLoaded": "Click Reload to fetch models",
|
||||
"selectAll": "Select all",
|
||||
"clearAll": "Clear selection",
|
||||
"selectedCount": "{{selected}} / {{total}}",
|
||||
"alreadyAdded": "Already added",
|
||||
"close": "Close",
|
||||
"apply": "Apply ({{count}})"
|
||||
},
|
||||
"modelCatalog": {
|
||||
"summaryTitle": "Model catalog",
|
||||
"openAction": "Open catalog",
|
||||
|
||||
@@ -1608,6 +1608,20 @@
|
||||
"timeout": "Таймаут после {{seconds}} с",
|
||||
"requestFailed": "Запрос не выполнен"
|
||||
},
|
||||
"discovery": {
|
||||
"openButton": "Получить с конечной точки",
|
||||
"searchPlaceholder": "Поиск моделей",
|
||||
"reload": "Обновить",
|
||||
"loading": "Загрузка моделей…",
|
||||
"empty": "Конечная точка не вернула моделей",
|
||||
"notLoaded": "Нажмите Обновить, чтобы загрузить модели",
|
||||
"selectAll": "Выбрать все",
|
||||
"clearAll": "Снять выбор",
|
||||
"selectedCount": "{{selected}} / {{total}}",
|
||||
"alreadyAdded": "Уже добавлено",
|
||||
"close": "Закрыть",
|
||||
"apply": "Применить ({{count}})"
|
||||
},
|
||||
"modelCatalog": {
|
||||
"summaryTitle": "Каталог моделей",
|
||||
"openAction": "Открыть",
|
||||
|
||||
@@ -1611,6 +1611,20 @@
|
||||
"timeout": "请求超过 {{seconds}} 秒",
|
||||
"requestFailed": "请求失败"
|
||||
},
|
||||
"discovery": {
|
||||
"openButton": "从端点拉取",
|
||||
"searchPlaceholder": "搜索模型",
|
||||
"reload": "重新加载",
|
||||
"loading": "正在加载模型…",
|
||||
"empty": "端点未返回任何模型",
|
||||
"notLoaded": "点击重新加载以获取模型",
|
||||
"selectAll": "全选",
|
||||
"clearAll": "清空选择",
|
||||
"selectedCount": "{{selected}} / {{total}}",
|
||||
"alreadyAdded": "已添加",
|
||||
"close": "关闭",
|
||||
"apply": "应用 ({{count}})"
|
||||
},
|
||||
"modelCatalog": {
|
||||
"summaryTitle": "模型目录",
|
||||
"openAction": "打开目录",
|
||||
|
||||
@@ -1637,6 +1637,20 @@
|
||||
"timeout": "請求超過 {{seconds}} 秒",
|
||||
"requestFailed": "請求失敗"
|
||||
},
|
||||
"discovery": {
|
||||
"openButton": "從端點拉取",
|
||||
"searchPlaceholder": "搜尋模型",
|
||||
"reload": "重新載入",
|
||||
"loading": "正在載入模型…",
|
||||
"empty": "端點未回傳任何模型",
|
||||
"notLoaded": "點擊重新載入以取得模型",
|
||||
"selectAll": "全選",
|
||||
"clearAll": "清空選擇",
|
||||
"selectedCount": "{{selected}} / {{total}}",
|
||||
"alreadyAdded": "已新增",
|
||||
"close": "關閉",
|
||||
"apply": "套用 ({{count}})"
|
||||
},
|
||||
"modelCatalog": {
|
||||
"summaryTitle": "模型目錄",
|
||||
"openAction": "開啟目錄",
|
||||
|
||||
Reference in New Issue
Block a user