mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
d2c0b69243
* 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
252 lines
6.5 KiB
TypeScript
252 lines
6.5 KiB
TypeScript
import { IconChevronDown, IconEye, IconEyeOff } from "@tabler/icons-react"
|
|
import { type ReactNode, useState } from "react"
|
|
import { useTranslation } from "react-i18next"
|
|
|
|
import {
|
|
FieldDescription,
|
|
FieldLabel,
|
|
Field as UiField,
|
|
} from "@/components/ui/field"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Switch } from "@/components/ui/switch"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
type FieldLayout = "default" | "setting-row"
|
|
|
|
interface FieldProps {
|
|
label: string
|
|
hint?: string
|
|
error?: string
|
|
required?: boolean
|
|
children: ReactNode
|
|
layout?: FieldLayout
|
|
controlClassName?: string
|
|
}
|
|
|
|
export function Field({
|
|
label,
|
|
hint,
|
|
error,
|
|
required,
|
|
children,
|
|
layout = "default",
|
|
controlClassName,
|
|
}: FieldProps) {
|
|
if (layout === "setting-row") {
|
|
return (
|
|
<div className="flex flex-col gap-4 py-4 md:grid md:grid-cols-[280px_minmax(0,1fr)] md:items-center md:gap-8">
|
|
<div className="w-full min-w-0">
|
|
<FieldLabel className="leading-relaxed break-words whitespace-normal">
|
|
{label}
|
|
{required && <span className="text-destructive ml-1">*</span>}
|
|
</FieldLabel>
|
|
{hint && (
|
|
<FieldDescription className="mt-1 text-xs leading-relaxed break-words whitespace-normal">
|
|
{hint}
|
|
</FieldDescription>
|
|
)}
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
"w-full md:max-w-[28rem] md:justify-self-end",
|
|
controlClassName,
|
|
)}
|
|
>
|
|
{children}
|
|
</div>
|
|
{error && (
|
|
<FieldDescription className="text-destructive text-xs leading-normal md:col-start-2 md:justify-self-end">
|
|
{error}
|
|
</FieldDescription>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<UiField className="gap-2.5">
|
|
<div className="space-y-1">
|
|
<FieldLabel>
|
|
{label}
|
|
{required && <span className="text-destructive ml-1">*</span>}
|
|
</FieldLabel>
|
|
{hint && (
|
|
<FieldDescription className="text-xs leading-normal">
|
|
{hint}
|
|
</FieldDescription>
|
|
)}
|
|
</div>
|
|
{children}
|
|
{error && (
|
|
<FieldDescription className="text-destructive text-xs leading-normal">
|
|
{error}
|
|
</FieldDescription>
|
|
)}
|
|
</UiField>
|
|
)
|
|
}
|
|
|
|
interface KeyInputProps {
|
|
value: string
|
|
onChange: (v: string) => void
|
|
placeholder?: string
|
|
className?: string
|
|
}
|
|
|
|
export function KeyInput({
|
|
value,
|
|
onChange,
|
|
placeholder,
|
|
className,
|
|
}: KeyInputProps) {
|
|
const [show, setShow] = useState(false)
|
|
|
|
return (
|
|
<div className="relative">
|
|
<Input
|
|
type={show ? "text" : "password"}
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={placeholder}
|
|
className={cn("pr-10", className)}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShow((v) => !v)}
|
|
tabIndex={-1}
|
|
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 -translate-y-1/2 transition-colors"
|
|
>
|
|
{show ? (
|
|
<IconEyeOff className="size-4" />
|
|
) : (
|
|
<IconEye className="size-4" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
interface SwitchCardFieldProps {
|
|
label: string
|
|
hint?: string
|
|
error?: string
|
|
checked: boolean
|
|
onCheckedChange: (checked: boolean) => void
|
|
ariaLabel?: string
|
|
disabled?: boolean
|
|
children?: ReactNode
|
|
layout?: FieldLayout
|
|
transparent?: boolean
|
|
}
|
|
|
|
export function SwitchCardField({
|
|
label,
|
|
hint,
|
|
error,
|
|
checked,
|
|
onCheckedChange,
|
|
ariaLabel,
|
|
disabled,
|
|
children,
|
|
layout = "default",
|
|
transparent,
|
|
}: SwitchCardFieldProps) {
|
|
if (layout === "setting-row") {
|
|
return (
|
|
<div className="flex flex-col gap-4 py-4 md:grid md:grid-cols-[280px_minmax(0,1fr)] md:items-center md:gap-8">
|
|
<div className="w-full min-w-0">
|
|
<p className="text-sm leading-relaxed font-medium break-words whitespace-normal">
|
|
{label}
|
|
</p>
|
|
{hint && (
|
|
<p className="text-muted-foreground mt-1 text-xs leading-relaxed break-words whitespace-normal">
|
|
{hint}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center md:justify-self-end">
|
|
<Switch
|
|
checked={checked}
|
|
onCheckedChange={onCheckedChange}
|
|
disabled={disabled}
|
|
aria-label={ariaLabel ?? label}
|
|
/>
|
|
</div>
|
|
{children && (
|
|
<div className="mt-1 flex w-full justify-end md:col-start-2">
|
|
<div className="w-full md:max-w-[28rem]">{children}</div>
|
|
</div>
|
|
)}
|
|
{error && (
|
|
<p className="text-destructive text-xs leading-normal md:col-start-2 md:justify-self-end">
|
|
{error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
transparent ? "py-1" : "border-border/60 rounded-lg border px-4 py-3",
|
|
)}
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium">{label}</p>
|
|
{hint && (
|
|
<p className="text-muted-foreground mt-0.5 text-xs leading-normal">
|
|
{hint}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<Switch
|
|
checked={checked}
|
|
onCheckedChange={onCheckedChange}
|
|
disabled={disabled}
|
|
aria-label={ariaLabel ?? label}
|
|
/>
|
|
</div>
|
|
{children && <div className="mt-4">{children}</div>}
|
|
{error && (
|
|
<p className="text-destructive mt-2 text-xs leading-normal">{error}</p>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
interface AdvancedSectionProps {
|
|
children: ReactNode
|
|
}
|
|
|
|
export function AdvancedSection({ children }: AdvancedSectionProps) {
|
|
const { t } = useTranslation()
|
|
const [open, setOpen] = useState(false)
|
|
|
|
return (
|
|
<div className="border-border/50 rounded-lg border">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((v) => !v)}
|
|
className="hover:bg-muted/40 flex w-full items-center justify-between rounded-lg px-4 py-3 transition-colors"
|
|
>
|
|
<span className="text-muted-foreground text-sm">
|
|
{t("models.advanced.toggle")}
|
|
</span>
|
|
<IconChevronDown
|
|
className={[
|
|
"text-muted-foreground size-4 transition-transform duration-200",
|
|
open ? "rotate-180" : "",
|
|
].join(" ")}
|
|
/>
|
|
</button>
|
|
{open && (
|
|
<div className="border-border/30 space-y-5 border-t px-4 pt-4 pb-4">
|
|
{children}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|