feat(web,api): provider selection and model form foundation (#2831)
* feat: improve model configuration workflows
Add model catalog browsing, provider registry with form validation,
model fetch/test dialogs, and enhanced model management UI.
- Add model catalog API and catalog-dialog component for browsing saved models
- Add provider-registry with auto-populated form fields per provider
- Add provider-combobox, fetch-models-dialog, test-model-dialog components
- Add model-validation for provider-aware model ID validation
- Add command and popover UI components
- Enhance edit-model-sheet with tool schema transform support
- Add anthropic to protocolMetaByName for correct default API base
- Apply NormalizeBaseURL to anthropic provider for consistent URL handling
- Add i18n keys for new model management features (en/zh)
* fix(web): prevent auto-fetch when API key is missing in fetch models dialog
When a provider requires an API key but none is set, the dialog now shows
the warning without triggering a doomed fetch attempt. Fetch is deferred
until the user provides a key.
* fix(web): add credential warning for catalog imports from remote providers
When importing models from a catalog entry whose provider requires an API
key, a yellow warning banner now informs users that credentials will need
to be configured after import.
* feat(web,api): test connection with real connectivity verification and unsaved form values
Add POST /api/models/test-inline endpoint that performs actual network
probes (GET /models) instead of just checking config. Frontend Test
Connection now uses current form values (not saved state) and is
available in both Add and Edit model flows.
* style(web): apply linter formatting across model config components
Normalize quote style, import ordering, and class name ordering as
reported by the project linter.
* fix(web,api): fix edit test connection false negative and gate fetch for unsupported providers
- handleTestInlineModel now accepts optional model_index to fall back to stored credentials when api_key is empty, fixing false negatives when testing edited models
- Add supportsFetch to provider registry and FETCHABLE_PROVIDER_KEYS derived set
- Gate Fetch Models button to only show for OpenAI-compatible and Ollama providers
- Add backend guard in handleFetchModels to reject unsupported providers with clear error
* fix: address review feedback on model config workflow
- Send explicit {} for empty extra_body/custom_headers fields so the
backend clears stored values instead of preserving them
- Merge backend provider_options with frontend PROVIDERS registry so
the provider picker reflects backend-supported providers and policy
fields (create_allowed, default_auth_method, auth_method_locked)
- Render provider combobox popover inside the sheet scroll container
to fix wheel events scrolling the sheet instead of the provider list
* feat(web,api): add provider selection, model form foundation, and validation
Split from PR #2752 (part 1 of 3).
Backend:
- CRUD model endpoints (list/add/update/delete/set-default)
- Provider metadata with default API bases and model provider options
- Model ID validation and normalization
- Anthropic default API base normalization
Frontend:
- Provider registry with metadata, labels, icons, and aliases
- Provider combobox with backend option merging
- Model field validation with provider-aware checks
- Redesigned add/edit model sheets with provider selection
- Dynamic imports for fetch/catalog/test dialogs (coming in PR2/PR3)
- i18n support for model configuration UI
This commit is contained in:
parent
7dc78425d1
commit
d2c0b69243
29 changed files with 2510 additions and 903 deletions
|
|
@ -747,6 +747,21 @@ func (c *ModelConfig) Validate() error {
|
|||
if _, err := providercommon.NormalizeToolSchemaTransform(c.ToolSchemaTransform); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Reject whitespace in model identifier
|
||||
if strings.ContainsAny(c.Model, " \t\n\r") {
|
||||
return fmt.Errorf("model identifier contains whitespace")
|
||||
}
|
||||
|
||||
// Reject leading slash
|
||||
if strings.HasPrefix(c.Model, "/") {
|
||||
return fmt.Errorf("model identifier must not start with /")
|
||||
}
|
||||
|
||||
// Reject consecutive slashes
|
||||
if strings.Contains(c.Model, "//") {
|
||||
return fmt.Errorf("model identifier must not contain //")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import (
|
|||
anthropicmessages "github.com/sipeed/picoclaw/pkg/providers/anthropic_messages"
|
||||
"github.com/sipeed/picoclaw/pkg/providers/azure"
|
||||
"github.com/sipeed/picoclaw/pkg/providers/bedrock"
|
||||
"github.com/sipeed/picoclaw/pkg/providers/common"
|
||||
)
|
||||
|
||||
type protocolMeta struct {
|
||||
|
|
@ -60,6 +61,8 @@ var protocolMetaByName = map[string]protocolMeta{
|
|||
"longcat": {defaultAPIBase: "https://api.longcat.chat/openai"},
|
||||
"modelscope": {defaultAPIBase: "https://api-inference.modelscope.cn/v1"},
|
||||
"mimo": {defaultAPIBase: "https://api.xiaomimimo.com/v1"},
|
||||
"anthropic": {defaultAPIBase: "https://api.anthropic.com/v1"},
|
||||
"anthropic-messages": {defaultAPIBase: "https://api.anthropic.com/v1"},
|
||||
}
|
||||
|
||||
// createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store.
|
||||
|
|
@ -318,10 +321,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
|||
return finalizeProviderFromConfig(provider, modelID, cfg)
|
||||
}
|
||||
// Use API key with HTTP API
|
||||
apiBase := cfg.APIBase
|
||||
if apiBase == "" {
|
||||
apiBase = "https://api.anthropic.com/v1"
|
||||
}
|
||||
apiBase := common.NormalizeBaseURL(cfg.APIBase, "https://api.anthropic.com/v1", true)
|
||||
if cfg.APIKey() == "" {
|
||||
return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -434,8 +434,11 @@ func modelProbeAPIBase(m *config.ModelConfig) string {
|
|||
}
|
||||
|
||||
protocol := modelProtocol(m)
|
||||
if providers.IsEmptyAPIKeyAllowedForProtocol(protocol) {
|
||||
return providers.DefaultAPIBaseForProtocol(protocol)
|
||||
|
||||
// Resolve the default API base for any known protocol so that probes
|
||||
// work even when the config stores only a provider without an explicit api_base.
|
||||
if defaultBase := providers.DefaultAPIBaseForProtocol(protocol); defaultBase != "" {
|
||||
return normalizeModelProbeAPIBase(defaultBase)
|
||||
}
|
||||
|
||||
switch protocol {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@tabler/icons-react": "^3.43.0",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@tanstack/react-query": "^5.99.0",
|
||||
|
|
@ -25,6 +26,7 @@
|
|||
"@tanstack/react-router-devtools": "^1.166.13",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dayjs": "^1.11.20",
|
||||
"highlight.js": "^11.11.1",
|
||||
"i18next": "^26.0.10",
|
||||
|
|
|
|||
24
web/frontend/pnpm-lock.yaml
generated
24
web/frontend/pnpm-lock.yaml
generated
|
|
@ -11,6 +11,9 @@ importers:
|
|||
'@fontsource-variable/inter':
|
||||
specifier: ^5.2.8
|
||||
version: 5.2.8
|
||||
'@radix-ui/react-popover':
|
||||
specifier: ^1.1.15
|
||||
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
'@tabler/icons-react':
|
||||
specifier: ^3.43.0
|
||||
version: 3.43.0(react@19.2.5)
|
||||
|
|
@ -32,6 +35,9 @@ importers:
|
|||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
cmdk:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
dayjs:
|
||||
specifier: ^1.11.20
|
||||
version: 1.11.20
|
||||
|
|
@ -2038,6 +2044,12 @@ packages:
|
|||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
cmdk@1.1.1:
|
||||
resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==}
|
||||
peerDependencies:
|
||||
react: ^18 || ^19 || ^19.0.0-rc
|
||||
react-dom: ^18 || ^19 || ^19.0.0-rc
|
||||
|
||||
code-block-writer@13.0.3:
|
||||
resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==}
|
||||
|
||||
|
|
@ -5976,6 +5988,18 @@ snapshots:
|
|||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
|
||||
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
react: 19.2.5
|
||||
react-dom: 19.2.5(react@19.2.5)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
|
||||
code-block-writer@13.0.3: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export interface ModelInfo {
|
|||
extra_body?: Record<string, unknown>
|
||||
custom_headers?: Record<string, string>
|
||||
// Meta
|
||||
enabled: boolean
|
||||
available: boolean
|
||||
status: "available" | "unconfigured" | "unreachable"
|
||||
is_default: boolean
|
||||
|
|
@ -58,7 +59,13 @@ const BASE_URL = ""
|
|||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await launcherFetch(`${BASE_URL}${path}`, options)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API error: ${res.status} ${res.statusText}`)
|
||||
let detail = ""
|
||||
try {
|
||||
detail = await res.text()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new Error(detail || `API error: ${res.status} ${res.statusText}`)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
|
@ -107,4 +114,97 @@ export async function setDefaultModel(
|
|||
return response
|
||||
}
|
||||
|
||||
export interface TestModelResponse {
|
||||
success: boolean
|
||||
latency_ms: number
|
||||
status: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export async function testModel(index: number): Promise<TestModelResponse> {
|
||||
return request<TestModelResponse>(`/api/models/${index}/test`, {
|
||||
method: "POST",
|
||||
})
|
||||
}
|
||||
|
||||
export interface TestModelInlineRequest {
|
||||
provider: string
|
||||
model: string
|
||||
api_base?: string
|
||||
api_key?: string
|
||||
auth_method?: string
|
||||
model_index?: number
|
||||
}
|
||||
|
||||
export async function testModelInline(
|
||||
params: TestModelInlineRequest,
|
||||
): Promise<TestModelResponse> {
|
||||
return request<TestModelResponse>("/api/models/test-inline", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
}
|
||||
|
||||
export interface UpstreamModel {
|
||||
id: string
|
||||
owned_by?: string
|
||||
}
|
||||
|
||||
export interface FetchModelsRequest {
|
||||
provider: string
|
||||
api_key?: string
|
||||
api_base?: string
|
||||
}
|
||||
|
||||
export interface FetchModelsResponse {
|
||||
models: UpstreamModel[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export async function fetchUpstreamModels(
|
||||
req: FetchModelsRequest,
|
||||
): Promise<FetchModelsResponse> {
|
||||
return request<FetchModelsResponse>("/api/models/fetch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(req),
|
||||
})
|
||||
}
|
||||
|
||||
// --- Model Catalog API ---
|
||||
|
||||
export interface CatalogModel {
|
||||
id: string
|
||||
owned_by?: string
|
||||
extra?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface CatalogEntry {
|
||||
id: string
|
||||
provider: string
|
||||
api_base: string
|
||||
api_key_mask: string
|
||||
models: CatalogModel[]
|
||||
fetched_at: string
|
||||
}
|
||||
|
||||
interface CatalogListResponse {
|
||||
entries: CatalogEntry[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export async function getCatalogs(): Promise<CatalogListResponse> {
|
||||
return request<CatalogListResponse>("/api/models/catalog")
|
||||
}
|
||||
|
||||
export async function deleteCatalog(id: string): Promise<void> {
|
||||
await request<Record<string, never>>(
|
||||
`/api/models/catalog/${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export type { ModelsListResponse, ModelActionResponse }
|
||||
|
|
|
|||
|
|
@ -66,10 +66,7 @@ export function WebSearchGeneralSettings({
|
|||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t(
|
||||
"pages.agent.tools.web_search.proxy",
|
||||
"Proxy Configuration",
|
||||
)}
|
||||
label={t("pages.agent.tools.web_search.proxy", "Proxy Configuration")}
|
||||
description={t(
|
||||
"pages.agent.tools.web_search.proxy_description",
|
||||
"Optional global HTTP/S proxy for underlying web requests.",
|
||||
|
|
|
|||
|
|
@ -84,10 +84,7 @@ function ProviderCard({
|
|||
const { t } = useTranslation()
|
||||
const apiKeyPlaceholder = maskedSecretPlaceholder(
|
||||
settings.api_key_set ? `${providerId}-configured` : "",
|
||||
t(
|
||||
"pages.agent.tools.web_search.api_key_placeholder",
|
||||
"Enter API key...",
|
||||
),
|
||||
t("pages.agent.tools.web_search.api_key_placeholder", "Enter API key..."),
|
||||
)
|
||||
|
||||
const updateSettings = (
|
||||
|
|
@ -167,7 +164,10 @@ function ProviderCard({
|
|||
>
|
||||
<div className="ml-8 flex max-w-xl flex-col gap-5">
|
||||
<ProviderField
|
||||
label={t("pages.agent.tools.web_search.max_results", "Max Results")}
|
||||
label={t(
|
||||
"pages.agent.tools.web_search.max_results",
|
||||
"Max Results",
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
|
|
|
|||
|
|
@ -128,7 +128,10 @@ export function ChatComposer({
|
|||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{contextUsage && (
|
||||
<ContextUsageRing usage={contextUsage} onDetailClick={onContextDetail} />
|
||||
<ContextUsageRing
|
||||
usage={contextUsage}
|
||||
onDetailClick={onContextDetail}
|
||||
/>
|
||||
)}
|
||||
{canInput ? (
|
||||
<Tooltip delayDuration={700}>
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ export function ContextUsageRing({
|
|||
: "pointer-events-none scale-95 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="bg-popover absolute -bottom-1.5 right-3 h-3 w-3 rotate-45 border-r border-b" />
|
||||
<div className="bg-popover absolute right-3 -bottom-1.5 h-3 w-3 rotate-45 border-r border-b" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
4
web/frontend/src/components/models/catalog-dialog.tsx
Normal file
4
web/frontend/src/components/models/catalog-dialog.tsx
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// Placeholder: full implementation added in PR2 (Fetch Models & Saved Catalogs)
|
||||
export function CatalogDialog() {
|
||||
return null
|
||||
}
|
||||
|
|
@ -1,10 +1,15 @@
|
|||
import { IconLoader2 } from "@tabler/icons-react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import {
|
||||
IconDownload,
|
||||
IconLoader2,
|
||||
IconPlugConnected,
|
||||
} from "@tabler/icons-react"
|
||||
import { type ComponentType, useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import {
|
||||
type ModelInfo,
|
||||
type ModelProviderOption,
|
||||
getCatalogs,
|
||||
setDefaultModel,
|
||||
updateModel,
|
||||
} from "@/api/models"
|
||||
|
|
@ -16,15 +21,9 @@ import {
|
|||
KeyInput,
|
||||
SwitchCardField,
|
||||
} from "@/components/shared-form"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
|
|
@ -37,14 +36,10 @@ import { Textarea } from "@/components/ui/textarea"
|
|||
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
|
||||
import { refreshGatewayState } from "@/store/gateway"
|
||||
|
||||
import {
|
||||
findProviderOption,
|
||||
getProviderDefaultAPIBase,
|
||||
getProviderDefaultAuthMethod,
|
||||
getProviderLabel,
|
||||
getSortedProviderOptions,
|
||||
isProviderAuthMethodLocked,
|
||||
} from "./provider-label"
|
||||
import { type FieldValidation, validateModelField } from "./model-validation"
|
||||
import { ProviderCombobox } from "./provider-combobox"
|
||||
import { getProviderKey } from "./provider-label"
|
||||
import { FETCHABLE_PROVIDER_KEYS, PROVIDER_API_BASES, PROVIDER_MAP } from "./provider-registry"
|
||||
|
||||
interface EditForm {
|
||||
provider: string
|
||||
|
|
@ -66,10 +61,40 @@ interface EditForm {
|
|||
|
||||
interface EditModelSheetProps {
|
||||
model: ModelInfo | null
|
||||
providerOptions: ModelProviderOption[]
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
providerOptions?: ModelProviderOption[]
|
||||
}
|
||||
|
||||
function normalizeApiBase(value: string): string {
|
||||
return value.trim().replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
function getNextApiBaseForProviderChange(
|
||||
currentApiBase: string,
|
||||
currentProvider: string,
|
||||
nextProvider: string,
|
||||
): string {
|
||||
const normalizedCurrentApiBase = normalizeApiBase(currentApiBase)
|
||||
const currentDefaultApiBase = normalizeApiBase(
|
||||
PROVIDER_API_BASES[currentProvider] || "",
|
||||
)
|
||||
const nextDefaultApiBase = PROVIDER_API_BASES[nextProvider] || ""
|
||||
|
||||
if (!normalizedCurrentApiBase) {
|
||||
return nextDefaultApiBase
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedCurrentApiBase &&
|
||||
currentDefaultApiBase &&
|
||||
normalizedCurrentApiBase === currentDefaultApiBase
|
||||
) {
|
||||
return nextDefaultApiBase
|
||||
}
|
||||
|
||||
return currentApiBase
|
||||
}
|
||||
|
||||
function buildInitialEditForm(model: ModelInfo): EditForm {
|
||||
|
|
@ -98,10 +123,10 @@ function buildInitialEditForm(model: ModelInfo): EditForm {
|
|||
|
||||
export function EditModelSheet({
|
||||
model,
|
||||
providerOptions,
|
||||
open,
|
||||
onClose,
|
||||
onSaved,
|
||||
providerOptions,
|
||||
}: EditModelSheetProps) {
|
||||
const { t } = useTranslation()
|
||||
const [form, setForm] = useState<EditForm>({
|
||||
|
|
@ -124,43 +149,30 @@ export function EditModelSheet({
|
|||
const [saving, setSaving] = useState(false)
|
||||
const [setAsDefault, setSetAsDefault] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [modelValidation, setModelValidation] =
|
||||
useState<FieldValidation | null>(null)
|
||||
const [testOpen, setTestOpen] = useState(false)
|
||||
const [fetchOpen, setFetchOpen] = useState(false)
|
||||
const [fetchedModels, setFetchedModels] = useState<string[]>([])
|
||||
const [catalogModels, setCatalogModels] = useState<string[]>([])
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Dynamic imports for dialogs added in later PRs
|
||||
const [FetchModelsDialogComp, setFetchModelsDialogComp] = useState<ComponentType<{
|
||||
open: boolean; onClose: () => void; onFill: (models: string[]) => void;
|
||||
provider: string; apiKey: string; apiBase: string;
|
||||
}> | null>(null)
|
||||
const [TestModelDialogComp, setTestModelDialogComp] = useState<ComponentType<{
|
||||
model: unknown; open: boolean; onClose: () => void;
|
||||
inlineParams: { provider: string; model: string; apiBase: string; apiKey: string; authMethod: string; modelIndex?: number };
|
||||
}> | null>(null)
|
||||
useEffect(() => {
|
||||
import("./fetch-models-dialog").then((m) => setFetchModelsDialogComp(() => m.FetchModelsDialog)).catch(() => {})
|
||||
import("./test-model-dialog").then((m) => setTestModelDialogComp(() => m.TestModelDialog)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const initialForm = model ? buildInitialEditForm(model) : null
|
||||
const sortedProviderOptions = useMemo(
|
||||
() => getSortedProviderOptions(providerOptions),
|
||||
[providerOptions],
|
||||
)
|
||||
const currentProviderID = model
|
||||
? (findProviderOption(model.provider, providerOptions)?.id ??
|
||||
model.provider?.trim().toLowerCase() ??
|
||||
"")
|
||||
: ""
|
||||
const selectedProviderOption = findProviderOption(
|
||||
form.provider,
|
||||
providerOptions,
|
||||
)
|
||||
const authMethodLocked = isProviderAuthMethodLocked(
|
||||
form.provider,
|
||||
providerOptions,
|
||||
)
|
||||
const defaultAuthMethod = getProviderDefaultAuthMethod(
|
||||
form.provider,
|
||||
providerOptions,
|
||||
)
|
||||
const effectiveAuthMethod = (
|
||||
authMethodLocked ? defaultAuthMethod : form.authMethod
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
const providerError = selectedProviderOption
|
||||
? ""
|
||||
: t("models.field.providerInvalid")
|
||||
const defaultModelAllowed =
|
||||
selectedProviderOption?.default_model_allowed !== false
|
||||
const willClearDefaultOnSave =
|
||||
model?.is_default === true && defaultModelAllowed === false
|
||||
const apiBasePlaceholder =
|
||||
getProviderDefaultAPIBase(form.provider, providerOptions) ||
|
||||
"https://api.example.com/v1"
|
||||
const isDirty =
|
||||
model != null &&
|
||||
(JSON.stringify(form) !== JSON.stringify(initialForm) ||
|
||||
|
|
@ -168,73 +180,141 @@ export function EditModelSheet({
|
|||
|
||||
useEffect(() => {
|
||||
if (model) {
|
||||
const initialForm = buildInitialEditForm(model)
|
||||
const option = findProviderOption(initialForm.provider, providerOptions)
|
||||
if (option?.auth_method_locked && !initialForm.authMethod) {
|
||||
initialForm.authMethod = option.default_auth_method ?? ""
|
||||
}
|
||||
setForm(initialForm)
|
||||
setSetAsDefault(model.is_default && model.default_model_allowed !== false)
|
||||
setForm(buildInitialEditForm(model))
|
||||
setSetAsDefault(model.is_default)
|
||||
setError("")
|
||||
setModelValidation(null)
|
||||
setFetchedModels([])
|
||||
setCatalogModels([])
|
||||
// Load matching catalog models
|
||||
const providerKey = getProviderKey(model.provider || undefined)
|
||||
const apiBase = (model.api_base ?? "").trim().replace(/\/+$/, "")
|
||||
getCatalogs()
|
||||
.then((res) => {
|
||||
const matched = (res.entries || []).filter((e) => {
|
||||
const ep = getProviderKey(e.provider || undefined)
|
||||
const eb = (e.api_base ?? "").trim().replace(/\/+$/, "")
|
||||
return ep === providerKey && eb === apiBase
|
||||
})
|
||||
const ids = matched.flatMap((e) => e.models.map((m) => m.id))
|
||||
const unique = [...new Set(ids)]
|
||||
if (unique.length > 0) setCatalogModels(unique)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
}, [model, providerOptions])
|
||||
}, [model])
|
||||
|
||||
const setField =
|
||||
(key: keyof EditForm) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
if (error) {
|
||||
setError("")
|
||||
}
|
||||
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
setForm((f) => ({ ...f, [key]: e.target.value }))
|
||||
}
|
||||
|
||||
const setProvider = (value: string) => {
|
||||
if (error) {
|
||||
setError("")
|
||||
const debouncedValidateModel = useCallback(
|
||||
(value: string, provider: string) => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => {
|
||||
const result = validateModelField(value, provider || undefined)
|
||||
setModelValidation(result)
|
||||
}, 300)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const handleModelChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value
|
||||
setForm((f) => ({ ...f, modelId: value }))
|
||||
debouncedValidateModel(value, form.provider)
|
||||
}
|
||||
|
||||
const handleProviderChange = (provider: string) => {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
provider,
|
||||
apiBase: getNextApiBaseForProviderChange(f.apiBase, f.provider, provider),
|
||||
}))
|
||||
if (form.modelId) {
|
||||
debouncedValidateModel(form.modelId, provider)
|
||||
}
|
||||
setForm((f) => {
|
||||
const previousOption = findProviderOption(f.provider, providerOptions)
|
||||
const nextOption = findProviderOption(value, providerOptions)
|
||||
let authMethod = f.authMethod
|
||||
if (nextOption?.auth_method_locked) {
|
||||
authMethod = nextOption.default_auth_method ?? ""
|
||||
} else if (
|
||||
previousOption?.auth_method_locked &&
|
||||
f.authMethod === (previousOption.default_auth_method ?? "")
|
||||
) {
|
||||
authMethod = ""
|
||||
}
|
||||
return { ...f, provider: value, authMethod }
|
||||
})
|
||||
const nextOption = findProviderOption(value, providerOptions)
|
||||
if (nextOption?.default_model_allowed === false) {
|
||||
const allowed = providerOptions?.find((o) => o.id === provider)?.default_model_allowed ?? false
|
||||
if (!allowed) {
|
||||
setSetAsDefault(false)
|
||||
}
|
||||
}
|
||||
|
||||
const applyFix = () => {
|
||||
if (modelValidation?.fix) {
|
||||
setForm((f) => ({ ...f, modelId: modelValidation.fix! }))
|
||||
setModelValidation(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCommonModel = (modelId: string) => {
|
||||
setForm((f) => ({ ...f, modelId }))
|
||||
setModelValidation(null)
|
||||
}
|
||||
|
||||
const handleFetchFill = (models: string[]) => {
|
||||
setFetchedModels(models)
|
||||
if (models.length >= 1) {
|
||||
setForm((f) => ({ ...f, modelId: models[0] }))
|
||||
setModelValidation(null)
|
||||
}
|
||||
}
|
||||
|
||||
const providerDef = PROVIDER_MAP.get(form.provider)
|
||||
const commonModels = providerDef?.commonModels || []
|
||||
const defaultModelAllowed = form.provider
|
||||
? (providerOptions?.find((o) => o.id === form.provider)?.default_model_allowed ?? false)
|
||||
: false
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!model) return
|
||||
if (!selectedProviderOption) {
|
||||
setError(providerError)
|
||||
return
|
||||
}
|
||||
if (!form.modelId.trim()) {
|
||||
setError(t("models.add.errorRequired"))
|
||||
return
|
||||
}
|
||||
if (modelValidation?.level === "error") return
|
||||
|
||||
let extraBody: Record<string, unknown> | undefined
|
||||
let customHeaders: Record<string, string> | undefined
|
||||
try {
|
||||
if (form.extraBody.trim()) {
|
||||
extraBody = JSON.parse(form.extraBody.trim())
|
||||
} else {
|
||||
extraBody = {}
|
||||
}
|
||||
} catch {
|
||||
setError(
|
||||
t("models.field.extraBody") + ": " + t("models.field.invalidJson"),
|
||||
)
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (form.customHeaders.trim()) {
|
||||
customHeaders = JSON.parse(form.customHeaders.trim())
|
||||
} else {
|
||||
customHeaders = {}
|
||||
}
|
||||
} catch {
|
||||
setError(
|
||||
t("models.field.customHeaders") + ": " + t("models.field.invalidJson"),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setError("")
|
||||
try {
|
||||
const modelId = form.modelId.trim()
|
||||
const provider = form.provider.trim()
|
||||
await updateModel(model.index, {
|
||||
model_name: model.model_name,
|
||||
provider: form.provider.trim(),
|
||||
model: form.modelId.trim(),
|
||||
provider: provider,
|
||||
model: modelId,
|
||||
api_base: form.apiBase || undefined,
|
||||
api_key: form.apiKey || undefined,
|
||||
proxy: form.proxy || undefined,
|
||||
auth_method: authMethodLocked
|
||||
? defaultAuthMethod || undefined
|
||||
: form.authMethod || undefined,
|
||||
auth_method: form.authMethod || undefined,
|
||||
connect_mode: form.connectMode || undefined,
|
||||
workspace: form.workspace || undefined,
|
||||
rpm: form.rpm ? Number(form.rpm) : undefined,
|
||||
|
|
@ -244,12 +324,8 @@ export function EditModelSheet({
|
|||
: undefined,
|
||||
thinking_level: form.thinkingLevel || undefined,
|
||||
tool_schema_transform: form.toolSchemaTransform.trim() || undefined,
|
||||
extra_body: form.extraBody.trim()
|
||||
? JSON.parse(form.extraBody.trim())
|
||||
: {},
|
||||
custom_headers: form.customHeaders.trim()
|
||||
? JSON.parse(form.customHeaders.trim())
|
||||
: {},
|
||||
extra_body: extraBody,
|
||||
custom_headers: customHeaders,
|
||||
})
|
||||
if (setAsDefault && !model.is_default) {
|
||||
await setDefaultModel(model.model_name)
|
||||
|
|
@ -270,7 +346,7 @@ export function EditModelSheet({
|
|||
}
|
||||
}
|
||||
|
||||
const isOAuth = effectiveAuthMethod === "oauth"
|
||||
const isOAuth = model?.auth_method === "oauth"
|
||||
const hasSavedAPIKey = Boolean(model?.api_key)
|
||||
const apiKeyPlaceholder = hasSavedAPIKey
|
||||
? maskedSecretPlaceholder(
|
||||
|
|
@ -280,267 +356,374 @@ export function EditModelSheet({
|
|||
: t("models.field.apiKeyPlaceholder")
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex flex-col gap-0 p-0 data-[side=right]:!w-full data-[side=right]:sm:!w-[560px] data-[side=right]:sm:!max-w-[560px]"
|
||||
>
|
||||
<SheetHeader className="border-b-muted border-b px-6 py-5">
|
||||
<SheetTitle className="text-base">
|
||||
{t("models.edit.title", { name: model?.model_name })}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="font-mono text-xs">
|
||||
{model?.model}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex flex-col gap-0 p-0 data-[side=right]:!w-full data-[side=right]:sm:!w-[560px] data-[side=right]:sm:!max-w-[560px]"
|
||||
>
|
||||
<SheetHeader className="border-b-muted border-b px-6 py-5">
|
||||
<SheetTitle className="text-base">
|
||||
{t("models.edit.title", { name: model?.model_name })}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="font-mono text-xs">
|
||||
{model?.model}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="space-y-5 px-6 py-5">
|
||||
<Field
|
||||
label={t("models.field.provider")}
|
||||
hint={t("models.field.providerHint")}
|
||||
error={providerError}
|
||||
required
|
||||
>
|
||||
<Select
|
||||
value={selectedProviderOption?.id}
|
||||
onValueChange={setProvider}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto" ref={scrollContainerRef}>
|
||||
<div className="space-y-5 px-6 py-5">
|
||||
<Field
|
||||
label={t("models.field.provider")}
|
||||
hint={t("models.field.providerHint")}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full"
|
||||
aria-invalid={!!providerError}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={t("models.field.providerPlaceholder")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sortedProviderOptions.map((option) => (
|
||||
<SelectItem
|
||||
key={option.id}
|
||||
value={option.id}
|
||||
disabled={
|
||||
!option.create_allowed &&
|
||||
option.id !== currentProviderID
|
||||
}
|
||||
<ProviderCombobox
|
||||
value={form.provider}
|
||||
onChange={handleProviderChange}
|
||||
placeholder={t("models.field.providerPlaceholder")}
|
||||
backendOptions={providerOptions}
|
||||
containerRef={scrollContainerRef}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("models.add.modelId")}
|
||||
hint={t("models.add.modelIdHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.modelId}
|
||||
onChange={handleModelChange}
|
||||
placeholder={
|
||||
providerDef
|
||||
? `${commonModels[0] || "model-name"}`
|
||||
: t("models.add.modelIdPlaceholder")
|
||||
}
|
||||
className="font-mono text-sm"
|
||||
aria-invalid={!!error || modelValidation?.level === "error"}
|
||||
/>
|
||||
{modelValidation && modelValidation.messageKey && (
|
||||
<div
|
||||
className={`flex items-center gap-2 text-xs ${
|
||||
modelValidation.level === "error"
|
||||
? "text-destructive"
|
||||
: modelValidation.level === "warning"
|
||||
? "text-yellow-600 dark:text-yellow-500"
|
||||
: "text-green-600 dark:text-green-500"
|
||||
}`}
|
||||
>
|
||||
<span>
|
||||
{t(
|
||||
modelValidation.messageKey,
|
||||
modelValidation.messageParams,
|
||||
)}
|
||||
</span>
|
||||
{modelValidation.fix && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={applyFix}
|
||||
className="text-primary underline hover:no-underline"
|
||||
>
|
||||
{t("common.fix")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{commonModels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{commonModels.map((m) => (
|
||||
<Badge
|
||||
key={m}
|
||||
variant="secondary"
|
||||
className="hover:bg-secondary/80 cursor-pointer font-mono text-xs"
|
||||
onClick={() => handleCommonModel(m)}
|
||||
>
|
||||
{m}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{catalogModels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{catalogModels.map((m) => (
|
||||
<Badge
|
||||
key={m}
|
||||
variant={form.modelId === m ? "default" : "outline"}
|
||||
className="cursor-pointer font-mono text-xs"
|
||||
onClick={() => handleCommonModel(m)}
|
||||
>
|
||||
{m}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{fetchedModels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{fetchedModels.map((m) => (
|
||||
<Badge
|
||||
key={m}
|
||||
variant={form.modelId === m ? "default" : "outline"}
|
||||
className="cursor-pointer font-mono text-xs"
|
||||
onClick={() => handleCommonModel(m)}
|
||||
>
|
||||
{m}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{form.provider && FETCHABLE_PROVIDER_KEYS.has(form.provider) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setFetchOpen(true)}
|
||||
disabled={!FetchModelsDialogComp}
|
||||
>
|
||||
{getProviderLabel(option.id)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("models.add.modelId")}
|
||||
hint={t("models.add.modelIdHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.modelId}
|
||||
onChange={setField("modelId")}
|
||||
placeholder={t("models.add.modelIdPlaceholder")}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{!isOAuth && (
|
||||
<Field
|
||||
label={t("models.field.apiKey")}
|
||||
hint={hasSavedAPIKey ? t("models.edit.apiKeyHint") : undefined}
|
||||
>
|
||||
<KeyInput
|
||||
value={form.apiKey}
|
||||
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
|
||||
placeholder={apiKeyPlaceholder}
|
||||
/>
|
||||
<IconDownload className="size-3" />
|
||||
{t("models.fetch.title")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field
|
||||
label={t("models.field.apiBase")}
|
||||
hint={isOAuth ? t("models.edit.oauthNote") : undefined}
|
||||
>
|
||||
<Input
|
||||
value={form.apiBase}
|
||||
onChange={setField("apiBase")}
|
||||
placeholder={apiBasePlaceholder}
|
||||
disabled={isOAuth}
|
||||
/>
|
||||
</Field>
|
||||
{!isOAuth && (
|
||||
<Field
|
||||
label={t("models.field.apiKey")}
|
||||
hint={
|
||||
hasSavedAPIKey ? t("models.edit.apiKeyHint") : undefined
|
||||
}
|
||||
>
|
||||
<KeyInput
|
||||
value={form.apiKey}
|
||||
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
|
||||
placeholder={apiKeyPlaceholder}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<SwitchCardField
|
||||
label={t("models.defaultOnSave.label")}
|
||||
hint={
|
||||
willClearDefaultOnSave
|
||||
? t("models.defaultOnSave.clearOnSave")
|
||||
: defaultModelAllowed
|
||||
? t("models.defaultOnSave.description")
|
||||
: t("models.defaultOnSave.unsupportedProvider")
|
||||
}
|
||||
checked={setAsDefault}
|
||||
onCheckedChange={setSetAsDefault}
|
||||
disabled={!defaultModelAllowed}
|
||||
/>
|
||||
|
||||
<AdvancedSection>
|
||||
<Field
|
||||
label={t("models.field.proxy")}
|
||||
hint={t("models.field.proxyHint")}
|
||||
label={t("models.field.apiBase")}
|
||||
hint={isOAuth ? t("models.edit.oauthNote") : undefined}
|
||||
>
|
||||
<Input
|
||||
value={form.proxy}
|
||||
onChange={setField("proxy")}
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
value={form.apiBase}
|
||||
onChange={setField("apiBase")}
|
||||
placeholder="https://api.example.com/v1"
|
||||
disabled={isOAuth}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("models.field.authMethod")}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setTestOpen(true)}
|
||||
disabled={!model || !TestModelDialogComp}
|
||||
>
|
||||
<IconPlugConnected className="size-4" />
|
||||
{t("models.test.testConnection")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("models.defaultOnSave.label")}
|
||||
hint={
|
||||
authMethodLocked
|
||||
? t("models.field.authMethodManagedHint")
|
||||
: t("models.field.authMethodHint")
|
||||
!defaultModelAllowed
|
||||
? t("models.defaultOnSave.unsupportedProvider")
|
||||
: t("models.defaultOnSave.description")
|
||||
}
|
||||
>
|
||||
<Input
|
||||
value={authMethodLocked ? defaultAuthMethod : form.authMethod}
|
||||
onChange={setField("authMethod")}
|
||||
placeholder="oauth"
|
||||
disabled={authMethodLocked}
|
||||
/>
|
||||
</Field>
|
||||
checked={setAsDefault}
|
||||
onCheckedChange={setSetAsDefault}
|
||||
disabled={!defaultModelAllowed}
|
||||
/>
|
||||
|
||||
<Field
|
||||
label={t("models.field.connectMode")}
|
||||
hint={t("models.field.connectModeHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.connectMode}
|
||||
onChange={setField("connectMode")}
|
||||
placeholder="stdio"
|
||||
/>
|
||||
</Field>
|
||||
<AdvancedSection>
|
||||
<Field
|
||||
label={t("models.field.proxy")}
|
||||
hint={t("models.field.proxyHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.proxy}
|
||||
onChange={setField("proxy")}
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("models.field.workspace")}
|
||||
hint={t("models.field.workspaceHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.workspace}
|
||||
onChange={setField("workspace")}
|
||||
placeholder="/path/to/workspace"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("models.field.authMethod")}
|
||||
hint={t("models.field.authMethodHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.authMethod}
|
||||
onChange={setField("authMethod")}
|
||||
placeholder="oauth"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("models.field.requestTimeout")}
|
||||
hint={t("models.field.requestTimeoutHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.requestTimeout}
|
||||
onChange={setField("requestTimeout")}
|
||||
placeholder="60"
|
||||
type="number"
|
||||
min={0}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("models.field.connectMode")}
|
||||
hint={t("models.field.connectModeHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.connectMode}
|
||||
onChange={setField("connectMode")}
|
||||
placeholder="stdio"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("models.field.rpm")}
|
||||
hint={t("models.field.rpmHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.rpm}
|
||||
onChange={setField("rpm")}
|
||||
placeholder="60"
|
||||
type="number"
|
||||
min={0}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("models.field.workspace")}
|
||||
hint={t("models.field.workspaceHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.workspace}
|
||||
onChange={setField("workspace")}
|
||||
placeholder="/path/to/workspace"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("models.field.thinkingLevel")}
|
||||
hint={t("models.field.thinkingLevelHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.thinkingLevel}
|
||||
onChange={setField("thinkingLevel")}
|
||||
placeholder="off"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("models.field.requestTimeout")}
|
||||
hint={t("models.field.requestTimeoutHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.requestTimeout}
|
||||
onChange={setField("requestTimeout")}
|
||||
placeholder="60"
|
||||
type="number"
|
||||
min={0}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("models.field.maxTokensField")}
|
||||
hint={t("models.field.maxTokensFieldHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.maxTokensField}
|
||||
onChange={setField("maxTokensField")}
|
||||
placeholder="max_completion_tokens"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("models.field.rpm")}
|
||||
hint={t("models.field.rpmHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.rpm}
|
||||
onChange={setField("rpm")}
|
||||
placeholder="60"
|
||||
type="number"
|
||||
min={0}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("models.field.toolSchemaTransform")}
|
||||
hint={t("models.field.toolSchemaTransformHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.toolSchemaTransform}
|
||||
onChange={setField("toolSchemaTransform")}
|
||||
placeholder="google"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("models.field.thinkingLevel")}
|
||||
hint={t("models.field.thinkingLevelHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.thinkingLevel}
|
||||
onChange={setField("thinkingLevel")}
|
||||
placeholder="off"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("models.field.extraBody")}
|
||||
hint={t("models.field.extraBodyHint")}
|
||||
>
|
||||
<Textarea
|
||||
value={form.extraBody}
|
||||
onChange={setField("extraBody")}
|
||||
placeholder='{"key": "value"}'
|
||||
rows={3}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("models.field.maxTokensField")}
|
||||
hint={t("models.field.maxTokensFieldHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.maxTokensField}
|
||||
onChange={setField("maxTokensField")}
|
||||
placeholder="max_completion_tokens"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("models.field.customHeaders")}
|
||||
hint={t("models.field.customHeadersHint")}
|
||||
>
|
||||
<Textarea
|
||||
value={form.customHeaders}
|
||||
onChange={setField("customHeaders")}
|
||||
placeholder='{"X-Source": "coding-plan"}'
|
||||
rows={3}
|
||||
/>
|
||||
</Field>
|
||||
</AdvancedSection>
|
||||
<Field
|
||||
label={t("models.field.extraBody")}
|
||||
hint={t("models.field.extraBodyHint")}
|
||||
>
|
||||
<Textarea
|
||||
value={form.extraBody}
|
||||
onChange={setField("extraBody")}
|
||||
placeholder='{"key": "value"}'
|
||||
rows={3}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{error && (
|
||||
<p className="text-destructive bg-destructive/10 rounded-md px-3 py-2 text-sm">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Field
|
||||
label={t("models.field.customHeaders")}
|
||||
hint={t("models.field.customHeadersHint")}
|
||||
>
|
||||
<Textarea
|
||||
value={form.customHeaders}
|
||||
onChange={setField("customHeaders")}
|
||||
placeholder='{"X-Source": "coding-plan"}'
|
||||
rows={3}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("models.field.toolSchemaTransform")}
|
||||
hint={t("models.field.toolSchemaTransformHint")}
|
||||
>
|
||||
<Input
|
||||
value={form.toolSchemaTransform}
|
||||
onChange={setField("toolSchemaTransform")}
|
||||
placeholder="google"
|
||||
/>
|
||||
</Field>
|
||||
</AdvancedSection>
|
||||
|
||||
{error && (
|
||||
<p className="text-destructive bg-destructive/10 rounded-md px-3 py-2 text-sm">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="border-t-muted border-t px-6 py-4">
|
||||
{isDirty && (
|
||||
<ConfigChangeNotice
|
||||
kind="save"
|
||||
title={t("common.saveChangesTitle")}
|
||||
description={t("models.unsavedPrompt")}
|
||||
/>
|
||||
)}
|
||||
<Button variant="ghost" onClick={onClose} disabled={saving}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!isDirty || saving}>
|
||||
{saving && <IconLoader2 className="size-4 animate-spin" />}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<SheetFooter className="border-t-muted border-t px-6 py-4">
|
||||
{isDirty && (
|
||||
<ConfigChangeNotice
|
||||
kind="save"
|
||||
title={t("common.saveChangesTitle")}
|
||||
description={t("models.unsavedPrompt")}
|
||||
/>
|
||||
)}
|
||||
<Button variant="ghost" onClick={onClose} disabled={saving}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={
|
||||
!isDirty || saving || modelValidation?.level === "error"
|
||||
}
|
||||
>
|
||||
{saving && <IconLoader2 className="size-4 animate-spin" />}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{TestModelDialogComp && (
|
||||
<TestModelDialogComp
|
||||
model={model}
|
||||
open={testOpen}
|
||||
onClose={() => setTestOpen(false)}
|
||||
inlineParams={{
|
||||
provider: form.provider,
|
||||
model: form.modelId,
|
||||
apiBase: form.apiBase,
|
||||
apiKey: form.apiKey,
|
||||
authMethod: form.authMethod,
|
||||
modelIndex: model?.index,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{FetchModelsDialogComp && (
|
||||
<FetchModelsDialogComp
|
||||
open={fetchOpen}
|
||||
onClose={() => setFetchOpen(false)}
|
||||
onFill={handleFetchFill}
|
||||
provider={form.provider}
|
||||
apiKey={form.apiKey}
|
||||
apiBase={form.apiBase}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
// Placeholder: full implementation added in PR2 (Fetch Models & Saved Catalogs)
|
||||
export function FetchModelsDialog() {
|
||||
return null
|
||||
}
|
||||
114
web/frontend/src/components/models/model-validation.ts
Normal file
114
web/frontend/src/components/models/model-validation.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* Real-time model field validation utilities.
|
||||
* All checks are pure frontend, no network required.
|
||||
*
|
||||
* Messages use i18n keys with interpolation params — callers must
|
||||
* translate them via t(key, params).
|
||||
*/
|
||||
import {
|
||||
KNOWN_PROVIDER_KEYS,
|
||||
PROVIDER_ALIASES,
|
||||
findClosestProvider,
|
||||
} from "./provider-registry"
|
||||
|
||||
export type ValidationLevel = "error" | "warning" | "success"
|
||||
|
||||
export interface FieldValidation {
|
||||
level: ValidationLevel
|
||||
messageKey: string
|
||||
messageParams?: Record<string, string>
|
||||
fix?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a model identifier string with optional provider context.
|
||||
* Returns validation result with optional one-click fix suggestion.
|
||||
*/
|
||||
export function validateModelField(
|
||||
input: string,
|
||||
selectedProvider?: string,
|
||||
): FieldValidation {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) return { level: "success", messageKey: "" }
|
||||
|
||||
// Hard errors
|
||||
if (/\s/.test(trimmed)) {
|
||||
return {
|
||||
level: "error",
|
||||
messageKey: "models.validation.whitespace",
|
||||
fix: trimmed.replace(/\s+/g, "/"),
|
||||
}
|
||||
}
|
||||
if (trimmed.startsWith("/")) {
|
||||
return {
|
||||
level: "error",
|
||||
messageKey: "models.validation.leadingSlash",
|
||||
fix: trimmed.replace(/^\/+/, ""),
|
||||
}
|
||||
}
|
||||
if (trimmed.includes("//")) {
|
||||
return {
|
||||
level: "error",
|
||||
messageKey: "models.validation.consecutiveSlash",
|
||||
fix: trimmed.replace(/\/+/g, "/"),
|
||||
}
|
||||
}
|
||||
|
||||
const slashIdx = trimmed.indexOf("/")
|
||||
if (slashIdx === -1) {
|
||||
// No provider prefix — when a provider is already selected,
|
||||
// the model ID is provider-local and needs no prefix.
|
||||
if (selectedProvider) {
|
||||
return {
|
||||
level: "success",
|
||||
messageKey: "models.validation.parsed",
|
||||
messageParams: { provider: selectedProvider, model: trimmed },
|
||||
}
|
||||
}
|
||||
return {
|
||||
level: "warning",
|
||||
messageKey: "models.validation.defaultToOpenAI",
|
||||
fix: `openai/${trimmed}`,
|
||||
}
|
||||
}
|
||||
|
||||
const provider = trimmed.slice(0, slashIdx)
|
||||
const model = trimmed.slice(slashIdx + 1)
|
||||
if (!model) {
|
||||
return { level: "error", messageKey: "models.validation.emptyModel" }
|
||||
}
|
||||
|
||||
if (!KNOWN_PROVIDER_KEYS.has(provider)) {
|
||||
// Check aliases
|
||||
const alias = PROVIDER_ALIASES[provider]
|
||||
if (alias) {
|
||||
return {
|
||||
level: "warning",
|
||||
messageKey: "models.validation.shouldUse",
|
||||
messageParams: { provider, alias },
|
||||
fix: `${alias}/${model}`,
|
||||
}
|
||||
}
|
||||
// Typo check
|
||||
const closest = findClosestProvider(provider)
|
||||
if (closest) {
|
||||
return {
|
||||
level: "warning",
|
||||
messageKey: "models.validation.didYouMean",
|
||||
messageParams: { closest },
|
||||
fix: `${closest}/${model}`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
level: "warning",
|
||||
messageKey: "models.validation.unknownProvider",
|
||||
messageParams: { provider },
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
level: "success",
|
||||
messageKey: "models.validation.parsed",
|
||||
messageParams: { provider, model },
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,10 @@
|
|||
import { IconLoader2, IconPlus, IconStar } from "@tabler/icons-react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import {
|
||||
IconDatabase,
|
||||
IconLoader2,
|
||||
IconPlus,
|
||||
IconStar,
|
||||
} from "@tabler/icons-react"
|
||||
import { type ComponentType, useCallback, useEffect, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
|
|
@ -17,11 +22,8 @@ import { refreshGatewayState } from "@/store/gateway"
|
|||
import { AddModelSheet } from "./add-model-sheet"
|
||||
import { DeleteModelDialog } from "./delete-model-dialog"
|
||||
import { EditModelSheet } from "./edit-model-sheet"
|
||||
import {
|
||||
PROVIDER_PRIORITY,
|
||||
getProviderKey,
|
||||
getProviderLabel,
|
||||
} from "./provider-label"
|
||||
import { getProviderKey, getProviderLabel } from "./provider-label"
|
||||
import { PROVIDER_PRIORITY } from "./provider-registry"
|
||||
import { ProviderSection } from "./provider-section"
|
||||
|
||||
interface ProviderGroup {
|
||||
|
|
@ -35,19 +37,27 @@ interface ProviderGroup {
|
|||
export function ModelsPage() {
|
||||
const { t } = useTranslation()
|
||||
const [models, setModels] = useState<ModelInfo[]>([])
|
||||
const [providerOptions, setProviderOptions] = useState<ModelProviderOption[]>(
|
||||
[],
|
||||
)
|
||||
const [providerOptions, setProviderOptions] = useState<
|
||||
ModelProviderOption[]
|
||||
>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [fetchError, setFetchError] = useState("")
|
||||
|
||||
const [editingModel, setEditingModel] = useState<ModelInfo | null>(null)
|
||||
const [deletingModel, setDeletingModel] = useState<ModelInfo | null>(null)
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [catalogOpen, setCatalogOpen] = useState(false)
|
||||
const [settingDefaultIndex, setSettingDefaultIndex] = useState<number | null>(
|
||||
null,
|
||||
)
|
||||
const addDisabled = loading || providerOptions.length === 0
|
||||
|
||||
// Dynamic import for CatalogDialog (added in PR2)
|
||||
const [CatalogDialogComp, setCatalogDialogComp] = useState<ComponentType<{
|
||||
open: boolean; onClose: () => void; onModelAdded: () => void;
|
||||
}> | null>(null)
|
||||
useEffect(() => {
|
||||
import("./catalog-dialog").then((m) => setCatalogDialogComp(() => m.CatalogDialog)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const fetchModels = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -60,7 +70,7 @@ export function ModelsPage() {
|
|||
return a.model_name.localeCompare(b.model_name)
|
||||
})
|
||||
setModels(sorted)
|
||||
setProviderOptions(data.provider_options ?? [])
|
||||
setProviderOptions(data.provider_options || [])
|
||||
setFetchError("")
|
||||
} catch (e) {
|
||||
setFetchError(e instanceof Error ? e.message : t("models.loadError"))
|
||||
|
|
@ -145,9 +155,13 @@ export function ModelsPage() {
|
|||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={addDisabled}
|
||||
onClick={() => setAddOpen(true)}
|
||||
onClick={() => setCatalogOpen(true)}
|
||||
disabled={!CatalogDialogComp}
|
||||
>
|
||||
<IconDatabase className="size-4" />
|
||||
{t("models.catalog.button")}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setAddOpen(true)}>
|
||||
<IconPlus className="size-4" />
|
||||
{t("models.add.button")}
|
||||
</Button>
|
||||
|
|
@ -200,18 +214,18 @@ export function ModelsPage() {
|
|||
|
||||
<EditModelSheet
|
||||
model={editingModel}
|
||||
providerOptions={providerOptions}
|
||||
open={editingModel !== null}
|
||||
onClose={() => setEditingModel(null)}
|
||||
onSaved={fetchModels}
|
||||
providerOptions={providerOptions}
|
||||
/>
|
||||
|
||||
<AddModelSheet
|
||||
open={addOpen}
|
||||
providerOptions={providerOptions}
|
||||
onClose={() => setAddOpen(false)}
|
||||
onSaved={fetchModels}
|
||||
existingModelNames={models.map((model) => model.model_name)}
|
||||
providerOptions={providerOptions}
|
||||
/>
|
||||
|
||||
<DeleteModelDialog
|
||||
|
|
@ -219,6 +233,14 @@ export function ModelsPage() {
|
|||
onClose={() => setDeletingModel(null)}
|
||||
onDeleted={fetchModels}
|
||||
/>
|
||||
|
||||
{CatalogDialogComp && (
|
||||
<CatalogDialogComp
|
||||
open={catalogOpen}
|
||||
onClose={() => setCatalogOpen(false)}
|
||||
onModelAdded={fetchModels}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
225
web/frontend/src/components/models/provider-combobox.tsx
Normal file
225
web/frontend/src/components/models/provider-combobox.tsx
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import { IconCheck, IconChevronDown } from "@tabler/icons-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import { ProviderIcon } from "./provider-icon"
|
||||
import {
|
||||
type MergedProvider,
|
||||
PROVIDERS,
|
||||
mergeWithBackendOptions,
|
||||
} from "./provider-registry"
|
||||
import type { ModelProviderOption } from "@/api/models"
|
||||
|
||||
interface ProviderComboboxProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
backendOptions?: ModelProviderOption[]
|
||||
/** When true, only show providers with create_allowed from the backend. */
|
||||
filterCreateAllowed?: boolean
|
||||
/** Container element for the popover portal. Use to avoid scroll conflicts inside dialogs/sheets. */
|
||||
containerRef?: React.RefObject<HTMLElement | null>
|
||||
}
|
||||
|
||||
export function ProviderCombobox({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
backendOptions,
|
||||
filterCreateAllowed,
|
||||
containerRef,
|
||||
}: ProviderComboboxProps) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [customMode, setCustomMode] = useState(false)
|
||||
const [customValue, setCustomValue] = useState("")
|
||||
const [containerEl, setContainerEl] = useState<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setContainerEl(containerRef?.current ?? null)
|
||||
}, [containerRef])
|
||||
|
||||
const allProviders: MergedProvider[] = backendOptions
|
||||
? mergeWithBackendOptions(backendOptions)
|
||||
: [...PROVIDERS]
|
||||
.sort((a, b) => b.priority - a.priority)
|
||||
.map((p) => ({
|
||||
...p,
|
||||
createAllowed: true,
|
||||
defaultModelAllowed: false,
|
||||
}))
|
||||
const visible = filterCreateAllowed
|
||||
? allProviders.filter((p) => p.createAllowed)
|
||||
: allProviders
|
||||
const allKeys = new Set(allProviders.map((p) => p.key))
|
||||
const selected = allProviders.find((p) => p.key === value)
|
||||
const isCustom = value && !allKeys.has(value)
|
||||
|
||||
const handleSelect = (currentValue: string) => {
|
||||
if (currentValue === "__custom__") {
|
||||
setCustomMode(true)
|
||||
setCustomValue(isCustom ? value : "")
|
||||
return
|
||||
}
|
||||
onChange(currentValue === value ? "" : currentValue)
|
||||
setCustomMode(false)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleCustomConfirm = () => {
|
||||
const trimmed = customValue.trim()
|
||||
if (trimmed) {
|
||||
onChange(trimmed)
|
||||
}
|
||||
setCustomMode(false)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
setOpen(v)
|
||||
if (!v) setCustomMode(false)
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
{selected ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
providerKey={selected.key}
|
||||
providerLabel={selected.label}
|
||||
/>
|
||||
{selected.labelZh || selected.label}
|
||||
</span>
|
||||
) : isCustom ? (
|
||||
<span className="flex items-center gap-2 font-mono text-sm">
|
||||
{value}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{placeholder || t("models.combobox.selectProvider")}
|
||||
</span>
|
||||
)}
|
||||
<IconChevronDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" container={containerEl}>
|
||||
{customMode ? (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
<Input
|
||||
value={customValue}
|
||||
onChange={(e) => setCustomValue(e.target.value)}
|
||||
placeholder={t("models.combobox.customPlaceholder")}
|
||||
className="h-8 font-mono text-sm"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleCustomConfirm()
|
||||
if (e.key === "Escape") {
|
||||
setCustomMode(false)
|
||||
setOpen(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 flex-1 text-xs"
|
||||
onClick={() => {
|
||||
setCustomMode(false)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 flex-1 text-xs"
|
||||
onClick={handleCustomConfirm}
|
||||
disabled={!customValue.trim()}
|
||||
>
|
||||
{t("common.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Command>
|
||||
<CommandInput placeholder={t("models.combobox.searchProvider")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("models.combobox.noProvider")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{visible.map((provider) => (
|
||||
<CommandItem
|
||||
key={provider.key}
|
||||
value={provider.key}
|
||||
keywords={[
|
||||
provider.label,
|
||||
provider.labelZh || "",
|
||||
...(provider.aliases || []),
|
||||
]}
|
||||
onSelect={handleSelect}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
providerKey={provider.key}
|
||||
providerLabel={provider.label}
|
||||
/>
|
||||
<span>{provider.labelZh || provider.label}</span>
|
||||
{provider.isLocal && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("models.combobox.local")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<IconCheck
|
||||
className={cn(
|
||||
"ml-auto size-4",
|
||||
value === provider.key ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
<CommandItem
|
||||
value="__custom__"
|
||||
keywords={["custom", "自定义"]}
|
||||
onSelect={handleSelect}
|
||||
>
|
||||
<span className="text-muted-foreground italic">
|
||||
{t("models.combobox.custom")}
|
||||
</span>
|
||||
{isCustom && (
|
||||
<IconCheck className="ml-auto size-4 opacity-100" />
|
||||
)}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,57 +1,6 @@
|
|||
import { useMemo, useState } from "react"
|
||||
|
||||
const PROVIDER_ICON_SLUGS: Record<string, string> = {
|
||||
openai: "openai",
|
||||
elevenlabs: "elevenlabs",
|
||||
anthropic: "anthropic",
|
||||
azure: "microsoftazure",
|
||||
gemini: "googlegemini",
|
||||
deepseek: "deepseek",
|
||||
"qwen-portal": "alibabacloud",
|
||||
"qwen-intl": "alibabacloud",
|
||||
groq: "groq",
|
||||
openrouter: "openrouter",
|
||||
nvidia: "nvidia",
|
||||
cerebras: "cerebras",
|
||||
volcengine: "bytedance",
|
||||
"github-copilot": "githubcopilot",
|
||||
ollama: "ollama",
|
||||
mistral: "mistralai",
|
||||
zhipu: "zhipu",
|
||||
}
|
||||
|
||||
const PROVIDER_DOMAINS: Record<string, string> = {
|
||||
openai: "openai.com",
|
||||
elevenlabs: "elevenlabs.io",
|
||||
anthropic: "anthropic.com",
|
||||
azure: "azure.com",
|
||||
gemini: "gemini.google.com",
|
||||
deepseek: "deepseek.com",
|
||||
"qwen-portal": "qwenlm.ai",
|
||||
"qwen-intl": "alibabacloud.com",
|
||||
moonshot: "moonshot.ai",
|
||||
groq: "groq.com",
|
||||
openrouter: "openrouter.ai",
|
||||
nvidia: "nvidia.com",
|
||||
cerebras: "cerebras.ai",
|
||||
volcengine: "volcengine.com",
|
||||
shengsuanyun: "shengsuanyun.com",
|
||||
antigravity: "antigravity.google",
|
||||
"github-copilot": "github.com",
|
||||
ollama: "ollama.com",
|
||||
lmstudio: "lmstudio.ai",
|
||||
mistral: "mistral.ai",
|
||||
avian: "avian.io",
|
||||
vllm: "vllm.ai",
|
||||
zhipu: "zhipuai.cn",
|
||||
zai: "z.ai",
|
||||
mimo: "xiaomi.com",
|
||||
venice: "venice.ai",
|
||||
vivgrid: "vivgrid.com",
|
||||
minimax: "minimaxi.com",
|
||||
longcat: "longcat.chat",
|
||||
modelscope: "modelscope.cn",
|
||||
}
|
||||
import { PROVIDER_DOMAINS, PROVIDER_ICON_SLUGS } from "./provider-registry"
|
||||
|
||||
interface ProviderIconProps {
|
||||
providerKey: string
|
||||
|
|
@ -82,7 +31,7 @@ export function ProviderIcon({
|
|||
|
||||
if (!iconUrl || loadFailed) {
|
||||
return (
|
||||
<span className="inline-flex size-4 shrink-0 items-center justify-center rounded-sm border border-black/10 bg-white text-[9px] font-semibold text-black/70 dark:border-white/20 dark:text-black/70">
|
||||
<span className="inline-flex size-4 shrink-0 items-center justify-center rounded-sm border border-black/10 bg-white text-[9px] font-semibold text-black/70 dark:border-white/20 dark:text-white/70">
|
||||
{initial}
|
||||
</span>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,98 +1,4 @@
|
|||
import type { ModelProviderOption } from "@/api/models"
|
||||
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
openai: "OpenAI",
|
||||
bedrock: "AWS Bedrock",
|
||||
elevenlabs: "ElevenLabs ASR",
|
||||
anthropic: "Anthropic",
|
||||
"anthropic-messages": "Anthropic Messages",
|
||||
azure: "Azure OpenAI",
|
||||
gemini: "Google Gemini",
|
||||
deepseek: "DeepSeek",
|
||||
"coding-plan": "Alibaba Coding Plan",
|
||||
"coding-plan-anthropic": "Alibaba Coding Plan (Anthropic)",
|
||||
"qwen-portal": "Qwen (阿里云)",
|
||||
"qwen-intl": "Qwen International",
|
||||
"qwen-us": "Qwen US",
|
||||
moonshot: "Moonshot (月之暗面)",
|
||||
groq: "Groq",
|
||||
openrouter: "OpenRouter",
|
||||
nvidia: "NVIDIA",
|
||||
cerebras: "Cerebras",
|
||||
volcengine: "Volcengine (火山引擎)",
|
||||
shengsuanyun: "ShengsuanYun (神算云)",
|
||||
antigravity: "Google Code Assist",
|
||||
"github-copilot": "GitHub Copilot",
|
||||
"claude-cli": "Claude CLI (local)",
|
||||
"codex-cli": "Codex CLI (local)",
|
||||
ollama: "Ollama (local)",
|
||||
lmstudio: "LM Studio (local)",
|
||||
litellm: "LiteLLM",
|
||||
mistral: "Mistral AI",
|
||||
avian: "Avian",
|
||||
vllm: "VLLM (local)",
|
||||
zhipu: "Zhipu AI (智谱)",
|
||||
zai: "Z.ai",
|
||||
mimo: "Xiaomi MiMo",
|
||||
venice: "Venice AI",
|
||||
vivgrid: "Vivgrid",
|
||||
minimax: "MiniMax",
|
||||
longcat: "LongCat",
|
||||
modelscope: "ModelScope (魔搭社区)",
|
||||
novita: "Novita AI",
|
||||
}
|
||||
|
||||
const PROVIDER_ALIASES: Record<string, string> = {
|
||||
qwen: "qwen-portal",
|
||||
"qwen-international": "qwen-intl",
|
||||
"dashscope-intl": "qwen-intl",
|
||||
"z.ai": "zai",
|
||||
"z-ai": "zai",
|
||||
google: "gemini",
|
||||
"google-antigravity": "antigravity",
|
||||
}
|
||||
|
||||
export const PROVIDER_PRIORITY: Record<string, number> = {
|
||||
volcengine: 0,
|
||||
openai: 1,
|
||||
gemini: 2,
|
||||
anthropic: 3,
|
||||
bedrock: 4,
|
||||
elevenlabs: 5,
|
||||
"anthropic-messages": 6,
|
||||
zhipu: 7,
|
||||
deepseek: 8,
|
||||
openrouter: 9,
|
||||
"qwen-portal": 10,
|
||||
"qwen-intl": 11,
|
||||
"qwen-us": 12,
|
||||
moonshot: 13,
|
||||
groq: 14,
|
||||
"coding-plan": 15,
|
||||
"coding-plan-anthropic": 16,
|
||||
"github-copilot": 17,
|
||||
antigravity: 18,
|
||||
nvidia: 19,
|
||||
cerebras: 20,
|
||||
shengsuanyun: 21,
|
||||
venice: 22,
|
||||
vivgrid: 23,
|
||||
minimax: 24,
|
||||
longcat: 25,
|
||||
modelscope: 26,
|
||||
mistral: 27,
|
||||
avian: 28,
|
||||
novita: 29,
|
||||
azure: 30,
|
||||
litellm: 31,
|
||||
ollama: 32,
|
||||
vllm: 33,
|
||||
lmstudio: 34,
|
||||
"claude-cli": 35,
|
||||
"codex-cli": 36,
|
||||
zai: 37,
|
||||
mimo: 38,
|
||||
}
|
||||
import { PROVIDER_ALIASES, PROVIDER_LABELS } from "./provider-registry"
|
||||
|
||||
export function getProviderKey(provider?: string): string {
|
||||
const normalized = provider?.trim().toLowerCase()
|
||||
|
|
@ -105,44 +11,4 @@ export function getProviderLabel(provider?: string): string {
|
|||
return PROVIDER_LABELS[prefix] ?? prefix
|
||||
}
|
||||
|
||||
export function findProviderOption(
|
||||
provider: string | undefined,
|
||||
options: ModelProviderOption[],
|
||||
): ModelProviderOption | undefined {
|
||||
const providerKey = getProviderKey(provider)
|
||||
return options.find((option) => option.id === providerKey)
|
||||
}
|
||||
|
||||
export function getProviderDefaultAPIBase(
|
||||
provider: string | undefined,
|
||||
options: ModelProviderOption[],
|
||||
): string {
|
||||
return findProviderOption(provider, options)?.default_api_base ?? ""
|
||||
}
|
||||
|
||||
export function getSortedProviderOptions(
|
||||
options: ModelProviderOption[],
|
||||
): ModelProviderOption[] {
|
||||
return [...options].sort((a, b) => {
|
||||
const aPriority = PROVIDER_PRIORITY[a.id] ?? Number.MAX_SAFE_INTEGER
|
||||
const bPriority = PROVIDER_PRIORITY[b.id] ?? Number.MAX_SAFE_INTEGER
|
||||
if (aPriority !== bPriority) {
|
||||
return aPriority - bPriority
|
||||
}
|
||||
return getProviderLabel(a.id).localeCompare(getProviderLabel(b.id))
|
||||
})
|
||||
}
|
||||
|
||||
export function getProviderDefaultAuthMethod(
|
||||
provider: string | undefined,
|
||||
options: ModelProviderOption[],
|
||||
): string {
|
||||
return findProviderOption(provider, options)?.default_auth_method ?? ""
|
||||
}
|
||||
|
||||
export function isProviderAuthMethodLocked(
|
||||
provider: string | undefined,
|
||||
options: ModelProviderOption[],
|
||||
): boolean {
|
||||
return findProviderOption(provider, options)?.auth_method_locked === true
|
||||
}
|
||||
export { PROVIDER_LABELS, PROVIDER_ALIASES }
|
||||
|
|
|
|||
520
web/frontend/src/components/models/provider-registry.ts
Normal file
520
web/frontend/src/components/models/provider-registry.ts
Normal file
|
|
@ -0,0 +1,520 @@
|
|||
/**
|
||||
* Unified provider registry — single source of truth for all provider metadata.
|
||||
* All consumer files (provider-label, provider-icon, models-page, add/edit sheets)
|
||||
* should derive their data from this registry.
|
||||
*/
|
||||
|
||||
import type { ModelProviderOption } from "@/api/models"
|
||||
|
||||
export interface ProviderDefinition {
|
||||
key: string
|
||||
label: string
|
||||
labelZh?: string
|
||||
iconSlug?: string
|
||||
domain?: string
|
||||
defaultApiBase?: string
|
||||
requiresApiKey: boolean
|
||||
isLocal: boolean
|
||||
priority: number
|
||||
commonModels?: string[]
|
||||
aliases?: string[]
|
||||
/** Whether this provider supports the OpenAI-compatible /models listing endpoint. */
|
||||
supportsFetch?: boolean
|
||||
}
|
||||
|
||||
export const PROVIDERS: ProviderDefinition[] = [
|
||||
{
|
||||
key: "openai",
|
||||
label: "OpenAI",
|
||||
iconSlug: "openai",
|
||||
domain: "openai.com",
|
||||
defaultApiBase: "https://api.openai.com/v1",
|
||||
requiresApiKey: true,
|
||||
isLocal: false,
|
||||
priority: 100,
|
||||
commonModels: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "o1", "o3-mini"],
|
||||
aliases: ["gpt"],
|
||||
supportsFetch: true,
|
||||
},
|
||||
{
|
||||
key: "anthropic",
|
||||
label: "Anthropic",
|
||||
iconSlug: "anthropic",
|
||||
domain: "anthropic.com",
|
||||
defaultApiBase: "https://api.anthropic.com/v1",
|
||||
requiresApiKey: true,
|
||||
isLocal: false,
|
||||
priority: 95,
|
||||
commonModels: [
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-haiku-4-20250414",
|
||||
"claude-3-5-sonnet-20241022",
|
||||
],
|
||||
aliases: ["claude"],
|
||||
},
|
||||
{
|
||||
key: "gemini",
|
||||
label: "Google Gemini",
|
||||
iconSlug: "googlegemini",
|
||||
domain: "gemini.google.com",
|
||||
defaultApiBase: "https://generativelanguage.googleapis.com/v1beta",
|
||||
requiresApiKey: true,
|
||||
isLocal: false,
|
||||
priority: 90,
|
||||
commonModels: ["gemini-2.0-flash", "gemini-2.5-pro", "gemini-1.5-flash"],
|
||||
aliases: ["google"],
|
||||
},
|
||||
{
|
||||
key: "deepseek",
|
||||
label: "DeepSeek",
|
||||
iconSlug: "deepseek",
|
||||
domain: "deepseek.com",
|
||||
defaultApiBase: "https://api.deepseek.com/v1",
|
||||
requiresApiKey: true,
|
||||
isLocal: false,
|
||||
priority: 85,
|
||||
commonModels: ["deepseek-chat", "deepseek-reasoner"],
|
||||
supportsFetch: true,
|
||||
},
|
||||
{
|
||||
key: "openrouter",
|
||||
label: "OpenRouter",
|
||||
iconSlug: "openrouter",
|
||||
domain: "openrouter.ai",
|
||||
defaultApiBase: "https://openrouter.ai/api/v1",
|
||||
requiresApiKey: true,
|
||||
isLocal: false,
|
||||
priority: 80,
|
||||
commonModels: [
|
||||
"openai/gpt-4o",
|
||||
"anthropic/claude-sonnet-4",
|
||||
"google/gemini-2.0-flash",
|
||||
],
|
||||
supportsFetch: true,
|
||||
},
|
||||
{
|
||||
key: "qwen-portal",
|
||||
label: "Qwen",
|
||||
labelZh: "Qwen (阿里云)",
|
||||
iconSlug: "alibabacloud",
|
||||
domain: "qwenlm.ai",
|
||||
defaultApiBase: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
requiresApiKey: true,
|
||||
isLocal: false,
|
||||
priority: 75,
|
||||
commonModels: ["qwen-max", "qwen-plus", "qwen-turbo"],
|
||||
aliases: ["qwen"],
|
||||
supportsFetch: true,
|
||||
},
|
||||
{
|
||||
key: "qwen-intl",
|
||||
label: "Qwen International",
|
||||
iconSlug: "alibabacloud",
|
||||
domain: "alibabacloud.com",
|
||||
defaultApiBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
|
||||
requiresApiKey: true,
|
||||
isLocal: false,
|
||||
priority: 74,
|
||||
commonModels: ["qwen-max", "qwen-plus", "qwen-turbo"],
|
||||
aliases: ["qwen-international", "dashscope-intl"],
|
||||
supportsFetch: true,
|
||||
},
|
||||
{
|
||||
key: "moonshot",
|
||||
label: "Moonshot",
|
||||
labelZh: "Moonshot (月之暗面)",
|
||||
domain: "moonshot.ai",
|
||||
defaultApiBase: "https://api.moonshot.cn/v1",
|
||||
requiresApiKey: true,
|
||||
isLocal: false,
|
||||
priority: 70,
|
||||
commonModels: ["moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k"],
|
||||
supportsFetch: true,
|
||||
},
|
||||
{
|
||||
key: "volcengine",
|
||||
label: "Volcengine",
|
||||
labelZh: "Volcengine (火山引擎)",
|
||||
iconSlug: "bytedance",
|
||||
domain: "volcengine.com",
|
||||
defaultApiBase: "https://ark.cn-beijing.volces.com/api/v3",
|
||||
requiresApiKey: true,
|
||||
isLocal: false,
|
||||
priority: 69,
|
||||
commonModels: ["doubao-1.5-pro", "doubao-1.5-lite"],
|
||||
supportsFetch: true,
|
||||
},
|
||||
{
|
||||
key: "zhipu",
|
||||
label: "Zhipu AI",
|
||||
labelZh: "Zhipu AI (智谱)",
|
||||
iconSlug: "zhipu",
|
||||
domain: "zhipuai.cn",
|
||||
defaultApiBase: "https://open.bigmodel.cn/api/paas/v4",
|
||||
requiresApiKey: true,
|
||||
isLocal: false,
|
||||
priority: 68,
|
||||
commonModels: ["glm-4-plus", "glm-4-flash"],
|
||||
supportsFetch: true,
|
||||
},
|
||||
{
|
||||
key: "groq",
|
||||
label: "Groq",
|
||||
iconSlug: "groq",
|
||||
domain: "groq.com",
|
||||
defaultApiBase: "https://api.groq.com/openai/v1",
|
||||
requiresApiKey: true,
|
||||
isLocal: false,
|
||||
priority: 65,
|
||||
commonModels: ["llama-3.3-70b-versatile", "mixtral-8x7b-32768"],
|
||||
supportsFetch: true,
|
||||
},
|
||||
{
|
||||
key: "mistral",
|
||||
label: "Mistral AI",
|
||||
iconSlug: "mistralai",
|
||||
domain: "mistral.ai",
|
||||
defaultApiBase: "https://api.mistral.ai/v1",
|
||||
requiresApiKey: true,
|
||||
isLocal: false,
|
||||
priority: 64,
|
||||
commonModels: ["mistral-large-latest", "mistral-small-latest"],
|
||||
supportsFetch: true,
|
||||
},
|
||||
{
|
||||
key: "nvidia",
|
||||
label: "NVIDIA",
|
||||
iconSlug: "nvidia",
|
||||
domain: "nvidia.com",
|
||||
defaultApiBase: "https://integrate.api.nvidia.com/v1",
|
||||
requiresApiKey: true,
|
||||
isLocal: false,
|
||||
priority: 63,
|
||||
commonModels: ["meta/llama-3.1-405b-instruct"],
|
||||
supportsFetch: true,
|
||||
},
|
||||
{
|
||||
key: "cerebras",
|
||||
label: "Cerebras",
|
||||
iconSlug: "cerebras",
|
||||
domain: "cerebras.ai",
|
||||
defaultApiBase: "https://api.cerebras.ai/v1",
|
||||
requiresApiKey: true,
|
||||
isLocal: false,
|
||||
priority: 62,
|
||||
commonModels: ["llama3.1-8b", "llama3.1-70b"],
|
||||
supportsFetch: true,
|
||||
},
|
||||
{
|
||||
key: "azure",
|
||||
label: "Azure OpenAI",
|
||||
iconSlug: "microsoftazure",
|
||||
domain: "azure.com",
|
||||
requiresApiKey: true,
|
||||
isLocal: false,
|
||||
priority: 61,
|
||||
commonModels: ["gpt-4o", "gpt-4o-mini"],
|
||||
},
|
||||
{
|
||||
key: "github-copilot",
|
||||
label: "GitHub Copilot",
|
||||
iconSlug: "githubcopilot",
|
||||
domain: "github.com",
|
||||
requiresApiKey: false,
|
||||
isLocal: true,
|
||||
priority: 55,
|
||||
},
|
||||
{
|
||||
key: "antigravity",
|
||||
label: "Google Code Assist",
|
||||
domain: "antigravity.google",
|
||||
requiresApiKey: false,
|
||||
isLocal: false,
|
||||
priority: 54,
|
||||
},
|
||||
{
|
||||
key: "ollama",
|
||||
label: "Ollama",
|
||||
labelZh: "Ollama (本地)",
|
||||
iconSlug: "ollama",
|
||||
domain: "ollama.com",
|
||||
defaultApiBase: "http://localhost:11434/v1",
|
||||
requiresApiKey: false,
|
||||
isLocal: true,
|
||||
priority: 50,
|
||||
commonModels: ["llama3", "mistral", "codellama", "qwen2.5"],
|
||||
supportsFetch: true,
|
||||
},
|
||||
{
|
||||
key: "vllm",
|
||||
label: "VLLM",
|
||||
labelZh: "VLLM (本地)",
|
||||
domain: "vllm.ai",
|
||||
defaultApiBase: "http://localhost:8000/v1",
|
||||
requiresApiKey: false,
|
||||
isLocal: true,
|
||||
priority: 49,
|
||||
supportsFetch: true,
|
||||
},
|
||||
{
|
||||
key: "lmstudio",
|
||||
label: "LM Studio",
|
||||
labelZh: "LM Studio (本地)",
|
||||
domain: "lmstudio.ai",
|
||||
defaultApiBase: "http://localhost:1234/v1",
|
||||
requiresApiKey: false,
|
||||
isLocal: true,
|
||||
priority: 48,
|
||||
supportsFetch: true,
|
||||
},
|
||||
{
|
||||
key: "venice",
|
||||
label: "Venice AI",
|
||||
iconSlug: "venice",
|
||||
domain: "venice.ai",
|
||||
defaultApiBase: "https://api.venice.ai/api/v1",
|
||||
requiresApiKey: true,
|
||||
isLocal: false,
|
||||
priority: 45,
|
||||
supportsFetch: true,
|
||||
},
|
||||
{
|
||||
key: "shengsuanyun",
|
||||
label: "ShengsuanYun",
|
||||
labelZh: "ShengsuanYun (神算云)",
|
||||
domain: "shengsuanyun.com",
|
||||
defaultApiBase: "https://router.shengsuanyun.com/api/v1",
|
||||
requiresApiKey: true,
|
||||
isLocal: false,
|
||||
priority: 44,
|
||||
supportsFetch: true,
|
||||
},
|
||||
{
|
||||
key: "vivgrid",
|
||||
label: "Vivgrid",
|
||||
domain: "vivgrid.com",
|
||||
defaultApiBase: "https://api.vivgrid.com/v1",
|
||||
requiresApiKey: true,
|
||||
isLocal: false,
|
||||
priority: 43,
|
||||
supportsFetch: true,
|
||||
},
|
||||
{
|
||||
key: "minimax",
|
||||
label: "MiniMax",
|
||||
domain: "minimaxi.com",
|
||||
defaultApiBase: "https://api.minimaxi.com/v1",
|
||||
requiresApiKey: true,
|
||||
isLocal: false,
|
||||
priority: 42,
|
||||
supportsFetch: true,
|
||||
},
|
||||
{
|
||||
key: "longcat",
|
||||
label: "LongCat",
|
||||
domain: "longcat.chat",
|
||||
defaultApiBase: "https://api.longcat.chat/openai",
|
||||
requiresApiKey: true,
|
||||
isLocal: false,
|
||||
priority: 41,
|
||||
supportsFetch: true,
|
||||
},
|
||||
{
|
||||
key: "modelscope",
|
||||
label: "ModelScope",
|
||||
labelZh: "ModelScope (魔搭社区)",
|
||||
domain: "modelscope.cn",
|
||||
defaultApiBase: "https://api-inference.modelscope.cn/v1",
|
||||
requiresApiKey: true,
|
||||
isLocal: false,
|
||||
priority: 40,
|
||||
supportsFetch: true,
|
||||
},
|
||||
{
|
||||
key: "mimo",
|
||||
label: "Xiaomi MiMo",
|
||||
iconSlug: "xiaomi",
|
||||
domain: "xiaomi.com",
|
||||
defaultApiBase: "https://api.xiaomimimo.com/v1",
|
||||
requiresApiKey: true,
|
||||
isLocal: false,
|
||||
priority: 39,
|
||||
supportsFetch: true,
|
||||
},
|
||||
{
|
||||
key: "avian",
|
||||
label: "Avian",
|
||||
domain: "avian.io",
|
||||
defaultApiBase: "https://api.avian.io/v1",
|
||||
requiresApiKey: true,
|
||||
isLocal: false,
|
||||
priority: 38,
|
||||
supportsFetch: true,
|
||||
},
|
||||
{
|
||||
key: "zai",
|
||||
label: "Z.ai",
|
||||
domain: "z.ai",
|
||||
defaultApiBase: "https://api.z.ai/api/coding/paas/v4",
|
||||
requiresApiKey: true,
|
||||
isLocal: false,
|
||||
priority: 37,
|
||||
aliases: ["z.ai", "z-ai"],
|
||||
supportsFetch: true,
|
||||
},
|
||||
{
|
||||
key: "novita",
|
||||
label: "Novita AI",
|
||||
domain: "novita.ai",
|
||||
defaultApiBase: "https://api.novita.ai/openai",
|
||||
requiresApiKey: true,
|
||||
isLocal: false,
|
||||
priority: 36,
|
||||
supportsFetch: true,
|
||||
},
|
||||
{
|
||||
key: "litellm",
|
||||
label: "LiteLLM",
|
||||
domain: "litellm.ai",
|
||||
defaultApiBase: "http://localhost:4000/v1",
|
||||
requiresApiKey: true,
|
||||
isLocal: false,
|
||||
priority: 35,
|
||||
supportsFetch: true,
|
||||
},
|
||||
]
|
||||
|
||||
// ── Derived data for consumers ───────────────────────────────────────────────
|
||||
|
||||
export const PROVIDER_MAP = new Map(PROVIDERS.map((p) => [p.key, p]))
|
||||
|
||||
export const PROVIDER_LABELS: Record<string, string> = Object.fromEntries(
|
||||
PROVIDERS.map((p) => [p.key, p.labelZh || p.label]),
|
||||
)
|
||||
|
||||
export const PROVIDER_ALIASES: Record<string, string> = Object.fromEntries(
|
||||
PROVIDERS.flatMap((p) => (p.aliases || []).map((a) => [a, p.key])),
|
||||
)
|
||||
|
||||
export const KNOWN_PROVIDER_KEYS = new Set(PROVIDERS.map((p) => p.key))
|
||||
|
||||
export const FETCHABLE_PROVIDER_KEYS = new Set(
|
||||
PROVIDERS.filter((p) => p.supportsFetch).map((p) => p.key),
|
||||
)
|
||||
|
||||
export const PROVIDER_ICON_SLUGS: Record<string, string> = Object.fromEntries(
|
||||
PROVIDERS.filter((p) => p.iconSlug).map((p) => [p.key, p.iconSlug!]),
|
||||
)
|
||||
|
||||
export const PROVIDER_DOMAINS: Record<string, string> = Object.fromEntries(
|
||||
PROVIDERS.filter((p) => p.domain).map((p) => [p.key, p.domain!]),
|
||||
)
|
||||
|
||||
export const PROVIDER_PRIORITY: Record<string, number> = Object.fromEntries(
|
||||
PROVIDERS.map((p) => [p.key, p.priority]),
|
||||
)
|
||||
|
||||
export const PROVIDER_API_BASES: Record<string, string> = Object.fromEntries(
|
||||
PROVIDERS.filter((p) => p.defaultApiBase).map((p) => [
|
||||
p.key,
|
||||
p.defaultApiBase!,
|
||||
]),
|
||||
)
|
||||
|
||||
/**
|
||||
* Find the closest known provider key by edit distance.
|
||||
* Returns the key if distance <= 2, otherwise undefined.
|
||||
*/
|
||||
export function findClosestProvider(input: string): string | undefined {
|
||||
const lower = input.toLowerCase()
|
||||
let best: string | undefined
|
||||
let bestDist = 3 // only accept distance <= 2
|
||||
|
||||
for (const key of KNOWN_PROVIDER_KEYS) {
|
||||
const dist = editDistance(lower, key)
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist
|
||||
best = key
|
||||
}
|
||||
}
|
||||
// Also check aliases
|
||||
for (const alias of Object.keys(PROVIDER_ALIASES)) {
|
||||
const dist = editDistance(lower, alias)
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist
|
||||
best = PROVIDER_ALIASES[alias]
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
function editDistance(a: string, b: string): number {
|
||||
const m = a.length
|
||||
const n = b.length
|
||||
const dp: number[][] = Array.from({ length: m + 1 }, () =>
|
||||
new Array(n + 1).fill(0),
|
||||
)
|
||||
for (let i = 0; i <= m; i++) dp[i][0] = i
|
||||
for (let j = 0; j <= n; j++) dp[0][j] = j
|
||||
for (let i = 1; i <= m; i++) {
|
||||
for (let j = 1; j <= n; j++) {
|
||||
dp[i][j] =
|
||||
a[i - 1] === b[j - 1]
|
||||
? dp[i - 1][j - 1]
|
||||
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
|
||||
}
|
||||
}
|
||||
return dp[m][n]
|
||||
}
|
||||
|
||||
// ── Backend options merge ────────────────────────────────────────────────────
|
||||
|
||||
export interface MergedProvider extends ProviderDefinition {
|
||||
createAllowed: boolean
|
||||
defaultModelAllowed: boolean
|
||||
defaultAuthMethod?: string
|
||||
authMethodLocked?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the frontend PROVIDERS registry with backend provider_options.
|
||||
* Frontend provides presentation data (labels, icons, priority, etc.).
|
||||
* Backend provides authoritative availability and policy fields.
|
||||
*/
|
||||
export function mergeWithBackendOptions(
|
||||
backendOptions: ModelProviderOption[],
|
||||
): MergedProvider[] {
|
||||
const backendMap = new Map(backendOptions.map((o) => [o.id, o]))
|
||||
const merged: MergedProvider[] = []
|
||||
|
||||
// Start with frontend providers, enriched with backend policy
|
||||
for (const p of PROVIDERS) {
|
||||
const backend = backendMap.get(p.key)
|
||||
merged.push({
|
||||
...p,
|
||||
createAllowed: backend?.create_allowed ?? false,
|
||||
defaultModelAllowed: backend?.default_model_allowed ?? false,
|
||||
defaultAuthMethod: backend?.default_auth_method,
|
||||
authMethodLocked: backend?.auth_method_locked,
|
||||
})
|
||||
if (backend) backendMap.delete(p.key)
|
||||
}
|
||||
|
||||
// Add providers only known to the backend
|
||||
for (const [key, backend] of backendMap) {
|
||||
merged.push({
|
||||
key,
|
||||
label: key,
|
||||
requiresApiKey: !backend.empty_api_key_allowed,
|
||||
isLocal: backend.empty_api_key_allowed,
|
||||
priority: 0,
|
||||
createAllowed: backend.create_allowed,
|
||||
defaultModelAllowed: backend.default_model_allowed,
|
||||
defaultAuthMethod: backend.default_auth_method,
|
||||
authMethodLocked: backend.auth_method_locked,
|
||||
defaultApiBase: backend.default_api_base || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return merged.sort((a, b) => b.priority - a.priority)
|
||||
}
|
||||
|
|
@ -57,7 +57,7 @@ export function ProviderSection({
|
|||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{models.map((model) => (
|
||||
<ModelCard
|
||||
key={model.index}
|
||||
key={model.model_name}
|
||||
model={model}
|
||||
onEdit={onEdit}
|
||||
onSetDefault={onSetDefault}
|
||||
|
|
|
|||
4
web/frontend/src/components/models/test-model-dialog.tsx
Normal file
4
web/frontend/src/components/models/test-model-dialog.tsx
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// Placeholder: full implementation added in PR3 (Test Connection)
|
||||
export function TestModelDialog() {
|
||||
return null
|
||||
}
|
||||
|
|
@ -93,7 +93,12 @@ interface KeyInputProps {
|
|||
className?: string
|
||||
}
|
||||
|
||||
export function KeyInput({ value, onChange, placeholder, className }: KeyInputProps) {
|
||||
export function KeyInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
className,
|
||||
}: KeyInputProps) {
|
||||
const [show, setShow] = useState(false)
|
||||
|
||||
return (
|
||||
|
|
|
|||
149
web/frontend/src/components/ui/command.tsx
Normal file
149
web/frontend/src/components/ui/command.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Command.displayName = CommandPrimitive.displayName
|
||||
|
||||
const CommandDialog = ({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog>) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"max-h-[300px] overflow-y-auto overflow-x-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandList.displayName = CommandPrimitive.List.displayName
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||
|
||||
const CommandShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CommandShortcut.displayName = "CommandShortcut"
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
31
web/frontend/src/components/ui/popover.tsx
Normal file
31
web/frontend/src/components/ui/popover.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ComponentRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & {
|
||||
container?: HTMLElement | null
|
||||
}
|
||||
>(({ className, align = "center", sideOffset = 4, container, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal container={container}>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import { useEffect } from "react"
|
||||
|
||||
import githubDarkCss from "highlight.js/styles/github-dark.css?inline"
|
||||
import githubLightCss from "highlight.js/styles/github.css?inline"
|
||||
import { useEffect } from "react"
|
||||
|
||||
const THEME_STYLE_ID = "hljs-theme-style"
|
||||
const THEME_STYLE_OWNER_ATTR = "data-picoclaw-highlight-theme"
|
||||
|
|
@ -17,8 +16,9 @@ function getOrCreateThemeStyleElement(): HTMLStyleElement {
|
|||
return managedStyleElement
|
||||
}
|
||||
|
||||
const existingStyleElement =
|
||||
document.querySelector<HTMLStyleElement>(ID_THEME_STYLE_SELECTOR)
|
||||
const existingStyleElement = document.querySelector<HTMLStyleElement>(
|
||||
ID_THEME_STYLE_SELECTOR,
|
||||
)
|
||||
if (existingStyleElement) {
|
||||
existingStyleElement.setAttribute(
|
||||
THEME_STYLE_OWNER_ATTR,
|
||||
|
|
|
|||
|
|
@ -129,10 +129,12 @@
|
|||
},
|
||||
"common": {
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"reset": "Reset",
|
||||
"confirm": "Confirm",
|
||||
"fix": "Fix",
|
||||
"saveChangesTitle": "You have unsaved configuration changes",
|
||||
"restartRequiredTitle": "Gateway restart required",
|
||||
"restartRequiredDesc": "The latest {{name}} configuration has been saved. Restart the gateway for it to take effect."
|
||||
|
|
@ -236,8 +238,7 @@
|
|||
"setting": "Setting as default...",
|
||||
"unavailable": "Cannot set unavailable model as default",
|
||||
"isDefault": "Already the default model",
|
||||
"isVirtual": "Cannot set virtual model as default",
|
||||
"unsupportedProvider": "This provider is ASR-only and cannot be the default chat model"
|
||||
"isVirtual": "Cannot set virtual model as default"
|
||||
},
|
||||
"deleteDisabled": {
|
||||
"isDefault": "Cannot delete the default model"
|
||||
|
|
@ -246,8 +247,7 @@
|
|||
"defaultOnSave": {
|
||||
"label": "Default Model",
|
||||
"description": "Automatically set this model as default after saving.",
|
||||
"unsupportedProvider": "This provider can be saved in model_list, but it cannot be used as the default chat model.",
|
||||
"clearOnSave": "Saving this ASR-only model will clear the current default chat model selection."
|
||||
"unsupportedProvider": "This provider can be saved in the model list but cannot be used as the default chat model."
|
||||
},
|
||||
"add": {
|
||||
"button": "Add Model",
|
||||
|
|
@ -258,7 +258,7 @@
|
|||
"modelNameHint": "A short name used to identify this model in conversations.",
|
||||
"modelId": "Model Identifier",
|
||||
"modelIdPlaceholder": "e.g. gpt-4o or openai/gpt-4o",
|
||||
"modelIdHint": "This field is sent as the canonical model ID for the selected Provider. If the model ID itself contains slashes, such as openai/gpt-5.4, it is preserved as-is instead of being split again.",
|
||||
"modelIdHint": "If Provider is not specified, values such as openai/gpt-4o are interpreted using the provider/model format. If Provider is specified, this field is treated as the canonical model ID and is not parsed for a provider prefix.",
|
||||
"errorRequired": "This field is required.",
|
||||
"errorDuplicateModelName": "Model alias already exists. Please use a different name.",
|
||||
"saveError": "Failed to add model",
|
||||
|
|
@ -275,9 +275,9 @@
|
|||
},
|
||||
"field": {
|
||||
"provider": "Provider",
|
||||
"providerPlaceholder": "Select a provider",
|
||||
"providerHint": "Choose a Provider from the backend catalog. The Model Identifier field is interpreted as that Provider's canonical model ID.",
|
||||
"providerInvalid": "The current Provider is invalid. Select a supported Provider.",
|
||||
"providerPlaceholder": "e.g. openai",
|
||||
"providerHint": "Optional. If specified, this value is used as the effective provider, and Model Identifier is interpreted as the canonical model ID.",
|
||||
"selectProviderFirst": "Select a provider first",
|
||||
"apiBase": "API Base URL",
|
||||
"apiKey": "API Key",
|
||||
"apiKeyPlaceholder": "Enter your API key",
|
||||
|
|
@ -286,7 +286,6 @@
|
|||
"proxyHint": "Optional. e.g. http://127.0.0.1:7890",
|
||||
"authMethod": "Auth Method",
|
||||
"authMethodHint": "Authentication method: oauth, token. Leave blank for API key auth.",
|
||||
"authMethodManagedHint": "This Provider manages its authentication mode automatically.",
|
||||
"connectMode": "Connect Mode",
|
||||
"connectModeHint": "Connection mode for CLI-based providers: stdio or grpc.",
|
||||
"workspace": "Workspace Path",
|
||||
|
|
@ -304,7 +303,8 @@
|
|||
"extraBody": "Extra Body",
|
||||
"extraBodyHint": "Additional JSON fields to inject into the request body, e.g. {\"reasoning_split\": true}.",
|
||||
"customHeaders": "Custom Headers",
|
||||
"customHeadersHint": "Additional HTTP headers to inject into every request, e.g. {\"X-Source\": \"coding-plan\"}."
|
||||
"customHeadersHint": "Additional HTTP headers to inject into every request, e.g. {\"X-Source\": \"coding-plan\"}.",
|
||||
"invalidJson": "Invalid JSON format"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Configure {{name}}",
|
||||
|
|
@ -312,6 +312,77 @@
|
|||
"oauthNote": "This provider uses OAuth — no API key required.",
|
||||
"saveError": "Failed to save",
|
||||
"saveSuccess": "Model configuration saved."
|
||||
},
|
||||
"fetch": {
|
||||
"title": "Fetch Available Models",
|
||||
"description": "Fetch model list from the upstream provider.",
|
||||
"providerLabel": "Provider:",
|
||||
"needApiKey": "Please enter an API Key first to fetch models.",
|
||||
"fetching": "Fetching models...",
|
||||
"retry": "Retry",
|
||||
"filterPlaceholder": "Filter models...",
|
||||
"found": "Found {{count}} model",
|
||||
"found_plural": "Found {{count}} models",
|
||||
"shown": "({{count}} shown)",
|
||||
"selectAll": "Select All",
|
||||
"deselectAll": "Deselect All",
|
||||
"fill": "Fill {{count}} Selected Model",
|
||||
"fill_plural": "Fill {{count}} Selected Models",
|
||||
"failed": "Failed to fetch models"
|
||||
},
|
||||
"catalog": {
|
||||
"button": "Saved Catalogs",
|
||||
"title": "Saved Model Catalogs",
|
||||
"description": "Previously fetched model lists, stored per API key. Select models to add to your configuration.",
|
||||
"loading": "Loading catalogs...",
|
||||
"empty": "No saved catalogs yet. Fetch models from a provider to save a catalog.",
|
||||
"filterPlaceholder": "Filter models...",
|
||||
"models": "models",
|
||||
"fetchedAt": "Fetched",
|
||||
"delete": "Delete catalog",
|
||||
"refresh": "Refresh from upstream",
|
||||
"found": "Found {{count}} model",
|
||||
"found_plural": "Found {{count}} models",
|
||||
"selectAll": "Select All",
|
||||
"deselectAll": "Deselect All",
|
||||
"addSelected": "Add {{count}} Selected",
|
||||
"addSuccess": "Added {{count}} model(s) to configuration.",
|
||||
"needApiKey": "These models require an API key. You'll need to configure credentials after import."
|
||||
},
|
||||
"test": {
|
||||
"title": "Test Model Connectivity",
|
||||
"description": "Verify that the model endpoint is reachable and configured correctly.",
|
||||
"modelLabel": "Model:",
|
||||
"identifierLabel": "Identifier:",
|
||||
"endpointLabel": "Endpoint:",
|
||||
"testConnection": "Test Connection",
|
||||
"testing": "Testing connection...",
|
||||
"success": "Connection successful",
|
||||
"responseTime": "Response time: {{ms}}ms",
|
||||
"failed": "Connection failed",
|
||||
"status": "Status: {{status}}",
|
||||
"testFailed": "Test failed",
|
||||
"testAgain": "Test Again"
|
||||
},
|
||||
"validation": {
|
||||
"whitespace": "Model identifier cannot contain whitespace",
|
||||
"leadingSlash": "Should not start with /",
|
||||
"consecutiveSlash": "Should not contain consecutive /",
|
||||
"useProvider": "Will use \"{{provider}}\" as provider",
|
||||
"defaultToOpenAI": "No provider specified, defaults to OpenAI",
|
||||
"emptyModel": "Model name cannot be empty",
|
||||
"shouldUse": "\"{{provider}}\" should use \"{{alias}}\"",
|
||||
"didYouMean": "Did you mean \"{{closest}}\"?",
|
||||
"unknownProvider": "Unknown provider \"{{provider}}\"",
|
||||
"parsed": "provider={{provider}}, model={{model}}"
|
||||
},
|
||||
"combobox": {
|
||||
"selectProvider": "Select provider...",
|
||||
"searchProvider": "Search provider...",
|
||||
"noProvider": "No provider found.",
|
||||
"local": "local",
|
||||
"custom": "Custom provider...",
|
||||
"customPlaceholder": "Enter provider name..."
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
|
|
|
|||
|
|
@ -244,7 +244,8 @@
|
|||
},
|
||||
"defaultOnSave": {
|
||||
"label": "Modelo Padrão",
|
||||
"description": "Definir automaticamente este modelo como padrão após salvar."
|
||||
"description": "Definir automaticamente este modelo como padrão após salvar.",
|
||||
"unsupportedProvider": "Este provedor pode ser salvo na lista de modelos, mas não pode ser usado como modelo de chat padrão."
|
||||
},
|
||||
"add": {
|
||||
"button": "Adicionar Modelo",
|
||||
|
|
|
|||
|
|
@ -129,13 +129,15 @@
|
|||
},
|
||||
"common": {
|
||||
"cancel": "取消",
|
||||
"close": "关闭",
|
||||
"save": "保存",
|
||||
"saving": "保存中...",
|
||||
"reset": "重置",
|
||||
"confirm": "确认",
|
||||
"saveChangesTitle": "有未保存的配置更改",
|
||||
"restartRequiredTitle": "需要重启服务",
|
||||
"restartRequiredDesc": "{{name}} 的最新配置已保存。重启服务后才能正式生效。"
|
||||
"restartRequiredDesc": "{{name}} 的最新配置已保存。重启服务后才能正式生效。",
|
||||
"fix": "修复"
|
||||
},
|
||||
"labels": {
|
||||
"loading": "加载中..."
|
||||
|
|
@ -304,7 +306,9 @@
|
|||
"extraBody": "Extra Body",
|
||||
"extraBodyHint": "要注入到请求体中的额外 JSON 字段,例如 {\"reasoning_split\": true}。",
|
||||
"customHeaders": "Custom Headers",
|
||||
"customHeadersHint": "要注入到每个请求中的额外 HTTP Headers,例如 {\"X-Source\": \"coding-plan\"}。"
|
||||
"customHeadersHint": "要注入到每个请求中的额外 HTTP Headers,例如 {\"X-Source\": \"coding-plan\"}。",
|
||||
"selectProviderFirst": "请先选择服务商",
|
||||
"invalidJson": "JSON 格式不正确"
|
||||
},
|
||||
"edit": {
|
||||
"title": "配置 {{name}}",
|
||||
|
|
@ -312,6 +316,74 @@
|
|||
"oauthNote": "该服务商使用 OAuth 认证,无需 API Key。",
|
||||
"saveError": "保存失败",
|
||||
"saveSuccess": "模型配置已保存。"
|
||||
},
|
||||
"fetch": {
|
||||
"title": "获取可用模型",
|
||||
"description": "从上游服务商获取模型列表。",
|
||||
"providerLabel": "服务商:",
|
||||
"needApiKey": "请先输入 API Key 再获取模型。",
|
||||
"fetching": "正在获取模型...",
|
||||
"retry": "重试",
|
||||
"filterPlaceholder": "筛选模型...",
|
||||
"found": "已找到 {{count}} 个模型",
|
||||
"shown": "(显示 {{count}} 个)",
|
||||
"selectAll": "全选",
|
||||
"deselectAll": "取消全选",
|
||||
"fill": "填充 {{count}} 个选中的模型",
|
||||
"failed": "获取模型失败"
|
||||
},
|
||||
"catalog": {
|
||||
"button": "已保存目录",
|
||||
"title": "已保存的模型目录",
|
||||
"description": "之前获取的模型列表,按 API Key 分别存储。选择模型以添加到配置中。",
|
||||
"loading": "正在加载目录...",
|
||||
"empty": "暂无已保存的模型目录。从服务商获取模型后将自动保存。",
|
||||
"filterPlaceholder": "筛选模型...",
|
||||
"models": "个模型",
|
||||
"fetchedAt": "获取于",
|
||||
"delete": "删除目录",
|
||||
"refresh": "从上游刷新",
|
||||
"found": "共 {{count}} 个模型",
|
||||
"selectAll": "全选",
|
||||
"deselectAll": "取消全选",
|
||||
"addSelected": "添加 {{count}} 个选中模型",
|
||||
"addSuccess": "已添加 {{count}} 个模型到配置中。",
|
||||
"needApiKey": "这些模型需要 API Key。导入后需要配置凭证才能使用。"
|
||||
},
|
||||
"test": {
|
||||
"title": "测试模型连通性",
|
||||
"description": "验证模型端点是否可达且配置正确。",
|
||||
"modelLabel": "模型:",
|
||||
"identifierLabel": "标识符:",
|
||||
"endpointLabel": "端点:",
|
||||
"testConnection": "测试连接",
|
||||
"testing": "正在测试连接...",
|
||||
"success": "连接成功",
|
||||
"responseTime": "响应时间:{{ms}}ms",
|
||||
"failed": "连接失败",
|
||||
"status": "状态:{{status}}",
|
||||
"testFailed": "测试失败",
|
||||
"testAgain": "重新测试"
|
||||
},
|
||||
"validation": {
|
||||
"whitespace": "模型标识符不能包含空格",
|
||||
"leadingSlash": "不应以 / 开头",
|
||||
"consecutiveSlash": "不应包含连续的 /",
|
||||
"useProvider": "将使用 \"{{provider}}\" 作为服务商",
|
||||
"defaultToOpenAI": "未指定服务商,默认使用 OpenAI",
|
||||
"emptyModel": "模型名称不能为空",
|
||||
"shouldUse": "\"{{provider}}\" 应使用 \"{{alias}}\"",
|
||||
"didYouMean": "您是否想输入 \"{{closest}}\"?",
|
||||
"unknownProvider": "未知服务商 \"{{provider}}\"",
|
||||
"parsed": "服务商={{provider}},模型={{model}}"
|
||||
},
|
||||
"combobox": {
|
||||
"selectProvider": "选择服务商...",
|
||||
"searchProvider": "搜索服务商...",
|
||||
"noProvider": "未找到服务商。",
|
||||
"local": "本地",
|
||||
"custom": "自定义服务商...",
|
||||
"customPlaceholder": "输入服务商名称..."
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue