* 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
165 lines
5.9 KiB
TypeScript
165 lines
5.9 KiB
TypeScript
import { IconArrowUp, IconPhotoPlus, IconX } from "@tabler/icons-react"
|
|
import type { KeyboardEvent } from "react"
|
|
import { useTranslation } from "react-i18next"
|
|
import TextareaAutosize from "react-textarea-autosize"
|
|
|
|
import { ContextUsageRing } from "@/components/chat/context-usage-ring"
|
|
import { Button } from "@/components/ui/button"
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip"
|
|
import { cn } from "@/lib/utils"
|
|
import type { ChatAttachment, ContextUsage } from "@/store/chat"
|
|
|
|
export type ChatInputDisabledReason =
|
|
| "gatewayUnknown"
|
|
| "gatewayStarting"
|
|
| "gatewayRestarting"
|
|
| "gatewayStopping"
|
|
| "gatewayStopped"
|
|
| "gatewayError"
|
|
| "websocketConnecting"
|
|
| "websocketDisconnected"
|
|
| "websocketError"
|
|
| "noDefaultModel"
|
|
|
|
interface ChatComposerProps {
|
|
input: string
|
|
attachments: ChatAttachment[]
|
|
onInputChange: (value: string) => void
|
|
onAddImages: () => void
|
|
onRemoveAttachment: (index: number) => void
|
|
onSend: () => void
|
|
onContextDetail?: () => void
|
|
inputDisabledReason: ChatInputDisabledReason | null
|
|
canSend: boolean
|
|
contextUsage?: ContextUsage
|
|
}
|
|
|
|
export function ChatComposer({
|
|
input,
|
|
attachments,
|
|
onInputChange,
|
|
onAddImages,
|
|
onRemoveAttachment,
|
|
onSend,
|
|
onContextDetail,
|
|
inputDisabledReason,
|
|
canSend,
|
|
contextUsage,
|
|
}: ChatComposerProps) {
|
|
const { t } = useTranslation()
|
|
const canInput = inputDisabledReason === null
|
|
const disabledMessage =
|
|
inputDisabledReason === null
|
|
? null
|
|
: t(`chat.disabledPlaceholder.${inputDisabledReason}`)
|
|
const placeholder = disabledMessage ?? t("chat.placeholder")
|
|
|
|
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (e.nativeEvent.isComposing) return
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault()
|
|
onSend()
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="before:bg-background pointer-events-none relative z-10 -mt-[24px] shrink-0 overflow-y-auto px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] [scrollbar-gutter:stable] before:pointer-events-none before:absolute before:inset-x-0 before:top-[24px] before:bottom-0 before:content-[''] md:px-8 md:pb-8 lg:px-24 xl:px-48">
|
|
<div className="bg-card border-border/60 pointer-events-auto relative mx-auto flex max-w-[1000px] flex-col rounded-2xl border p-3 shadow-sm">
|
|
{attachments.length > 0 && (
|
|
<div className="mb-3 flex flex-wrap gap-2 px-2">
|
|
{attachments.map((attachment, index) => (
|
|
<div
|
|
key={`${attachment.url}-${index}`}
|
|
className="bg-background relative h-20 w-20 overflow-hidden rounded-xl border"
|
|
>
|
|
<img
|
|
src={attachment.url}
|
|
alt={attachment.filename || t("chat.uploadedImage")}
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => onRemoveAttachment(index)}
|
|
className="bg-background/85 text-foreground absolute top-1 right-1 inline-flex h-6 w-6 items-center justify-center rounded-full border shadow-sm transition hover:bg-white"
|
|
aria-label={t("chat.removeImage")}
|
|
title={t("chat.removeImage")}
|
|
>
|
|
<IconX className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<TextareaAutosize
|
|
value={input}
|
|
onChange={(e) => onInputChange(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder={placeholder}
|
|
disabled={!canInput}
|
|
title={disabledMessage || undefined}
|
|
className={cn(
|
|
"placeholder:text-muted-foreground/50 max-h-[200px] min-h-[64px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none transition-colors focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent",
|
|
!canInput && "cursor-not-allowed",
|
|
)}
|
|
minRows={1}
|
|
maxRows={8}
|
|
/>
|
|
|
|
<div className="mt-2 flex items-center justify-between px-1">
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="text-muted-foreground hover:text-foreground h-8 w-8 rounded-full"
|
|
onClick={onAddImages}
|
|
disabled={!canInput}
|
|
aria-label={t("chat.attachImage")}
|
|
title={t("chat.attachImage")}
|
|
>
|
|
<IconPhotoPlus className="size-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
{contextUsage && (
|
|
<ContextUsageRing
|
|
usage={contextUsage}
|
|
onDetailClick={onContextDetail}
|
|
/>
|
|
)}
|
|
{canInput ? (
|
|
<Tooltip delayDuration={700}>
|
|
<TooltipTrigger asChild>
|
|
<span tabIndex={!canSend ? 0 : undefined}>
|
|
<Button
|
|
type="button"
|
|
size="icon"
|
|
className="size-8 rounded-full bg-violet-500 text-white transition-transform hover:bg-violet-600 active:scale-95"
|
|
onClick={onSend}
|
|
disabled={!canSend}
|
|
aria-label={t("chat.sendMessage")}
|
|
>
|
|
<IconArrowUp className="size-4" />
|
|
</Button>
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent
|
|
className="border-border/70 bg-muted text-foreground border text-center whitespace-pre-line shadow-lg shadow-black/10 dark:shadow-black/30"
|
|
arrowClassName="bg-muted fill-muted"
|
|
>
|
|
{t("chat.sendHint")}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|