From 0b008cffdbe303a326740c313f74f53c6c3b0fea Mon Sep 17 00:00:00 2001 From: israel Date: Sat, 11 Apr 2026 09:51:48 +0100 Subject: [PATCH 1/2] refactor(platform): convert provider dialogs to sidepanels Replace FormDialog-based provider add/edit modals with Sheet-based sidepanels matching the integration panel pattern. Extract inline setup guide markup into a reusable CollapsibleGuide component. Add edit-form validation and clarify read-only provider name field. --- examples/integrations/ai-image/config.json | 2 +- .../ui/data-display/collapsible-guide.tsx | 40 ++ .../integration-credentials-form.tsx | 28 +- .../components/provider-add-dialog.tsx | 346 ---------------- .../components/provider-add-panel.tsx | 391 ++++++++++++++++++ .../components/provider-edit-dialog.tsx | 106 ----- .../components/provider-edit-panel.tsx | 156 +++++++ .../providers/components/providers-table.tsx | 8 +- services/platform/messages/de.json | 2 + services/platform/messages/en.json | 2 + 10 files changed, 602 insertions(+), 479 deletions(-) create mode 100644 services/platform/app/components/ui/data-display/collapsible-guide.tsx delete mode 100644 services/platform/app/features/settings/providers/components/provider-add-dialog.tsx create mode 100644 services/platform/app/features/settings/providers/components/provider-add-panel.tsx delete mode 100644 services/platform/app/features/settings/providers/components/provider-edit-dialog.tsx create mode 100644 services/platform/app/features/settings/providers/components/provider-edit-panel.tsx diff --git a/examples/integrations/ai-image/config.json b/examples/integrations/ai-image/config.json index 2cc1d0458..e6e935dc9 100644 --- a/examples/integrations/ai-image/config.json +++ b/examples/integrations/ai-image/config.json @@ -74,5 +74,5 @@ "metadata": { "modelCapabilities": "Configured model: {{model}}\nModel capabilities:\n- gpt-image-1: sizes [1024x1024, 1024x1536, 1536x1024, auto], supports edit\n- dall-e-3: sizes [1024x1024, 1024x1792, 1792x1024], does NOT support edit\n- dall-e-2: sizes [256x256, 512x512, 1024x1024], supports edit\nIMPORTANT: Only use sizes supported by the configured model. Invalid sizes will cause an error." }, - "setupGuide": "### Configuration Guide\n\n1. **domain** — Your API provider's base URL:\n - OpenAI: `https://api.openai.com`\n - Together AI: `https://api.together.xyz`\n\n2. **accessToken** — Your API key from the provider\n\n3. **model** — The image generation model ID:\n - `gpt-image-1` — OpenAI's latest image model (sizes: 1024x1024, 1024x1536, 1536x1024, auto)\n - `dall-e-3` — DALL-E 3 (sizes: 1024x1024, 1024x1792, 1792x1024, no edit support)\n - `dall-e-2` — DALL-E 2 (sizes: 256x256, 512x512, 1024x1024)" + "setupGuide": "1. **domain** — Your API provider's base URL:\n - OpenAI: `https://api.openai.com`\n - Together AI: `https://api.together.xyz`\n\n2. **accessToken** — Your API key from the provider\n\n3. **model** — The image generation model ID:\n - `gpt-image-1` — OpenAI's latest image model (sizes: 1024x1024, 1024x1536, 1536x1024, auto)\n - `dall-e-3` — DALL-E 3 (sizes: 1024x1024, 1024x1792, 1792x1024, no edit support)\n - `dall-e-2` — DALL-E 2 (sizes: 256x256, 512x512, 1024x1024)" } diff --git a/services/platform/app/components/ui/data-display/collapsible-guide.tsx b/services/platform/app/components/ui/data-display/collapsible-guide.tsx new file mode 100644 index 000000000..5ce0cb61c --- /dev/null +++ b/services/platform/app/components/ui/data-display/collapsible-guide.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { Info } from 'lucide-react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; + +import { markdownWrapperStyles } from '@/app/features/chat/components/message-bubble/markdown-renderer'; +import { cn } from '@/lib/utils/cn'; + +interface CollapsibleGuideProps { + label: string; + content: string; + defaultOpen?: boolean; +} + +export function CollapsibleGuide({ + label, + content, + defaultOpen, +}: CollapsibleGuideProps) { + return ( +
+ + + {label} + +
+ {content} +
+
+ ); +} diff --git a/services/platform/app/features/settings/integrations/components/integration-manage/integration-credentials-form.tsx b/services/platform/app/features/settings/integrations/components/integration-manage/integration-credentials-form.tsx index d953a8195..0fd2c381d 100644 --- a/services/platform/app/features/settings/integrations/components/integration-manage/integration-credentials-form.tsx +++ b/services/platform/app/features/settings/integrations/components/integration-manage/integration-credentials-form.tsx @@ -1,9 +1,8 @@ 'use client'; -import { Info, Loader2, Pencil, Save } from 'lucide-react'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; +import { Loader2, Pencil, Save } from 'lucide-react'; +import { CollapsibleGuide } from '@/app/components/ui/data-display/collapsible-guide'; import { Badge } from '@/app/components/ui/feedback/badge'; import { Input } from '@/app/components/ui/forms/input'; import { Select } from '@/app/components/ui/forms/select'; @@ -12,9 +11,7 @@ import { HStack, Stack } from '@/app/components/ui/layout/layout'; import { Button } from '@/app/components/ui/primitives/button'; import { IconButton } from '@/app/components/ui/primitives/icon-button'; import { Text } from '@/app/components/ui/typography/text'; -import { markdownWrapperStyles } from '@/app/features/chat/components/message-bubble/markdown-renderer'; import { useT } from '@/lib/i18n/client'; -import { cn } from '@/lib/utils/cn'; import { startCase } from '@/lib/utils/string'; import type { Integration } from '../../hooks/use-integration-manage'; @@ -105,24 +102,11 @@ export function IntegrationCredentialsForm({ /> )} - {/* Setup Guide */} {typeof integration.setupGuide === 'string' && ( -
- - - {t('integrations.manageDialog.setupGuide')} - -
- - {integration.setupGuide} - -
-
+ )} diff --git a/services/platform/app/features/settings/providers/components/provider-add-dialog.tsx b/services/platform/app/features/settings/providers/components/provider-add-dialog.tsx deleted file mode 100644 index dca5c3b28..000000000 --- a/services/platform/app/features/settings/providers/components/provider-add-dialog.tsx +++ /dev/null @@ -1,346 +0,0 @@ -'use client'; - -import { zodResolver } from '@hookform/resolvers/zod'; -import { Info, Plus, Trash2 } from 'lucide-react'; -import { useCallback, useMemo } from 'react'; -import { useFieldArray, useForm } from 'react-hook-form'; -import { z } from 'zod/v4'; - -import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; -import { Checkbox } from '@/app/components/ui/forms/checkbox'; -import { Input } from '@/app/components/ui/forms/input'; -import { HStack, Stack } from '@/app/components/ui/layout/layout'; -import { Button } from '@/app/components/ui/primitives/button'; -import { IconButton } from '@/app/components/ui/primitives/icon-button'; -import { Text } from '@/app/components/ui/typography/text'; -import { toast } from '@/app/hooks/use-toast'; -import { useT } from '@/lib/i18n/client'; - -import { useSaveProvider, useSaveProviderSecret } from '../hooks/mutations'; - -const modelTagLiterals = ['chat', 'vision', 'embedding'] as const; - -type FormData = { - name: string; - displayName: string; - baseUrl: string; - apiKey: string; - models: Array<{ - id: string; - displayName: string; - tags: Array<(typeof modelTagLiterals)[number]>; - }>; -}; - -interface ProviderAddDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - organizationId: string; -} - -export function ProviderAddDialog({ - open, - onOpenChange, - organizationId: _organizationId, -}: ProviderAddDialogProps) { - const { t } = useT('settings'); - const { t: tCommon } = useT('common'); - const { mutateAsync: saveProvider } = useSaveProvider(); - const { mutateAsync: saveProviderSecret } = useSaveProviderSecret(); - - const formSchema = useMemo( - () => - z.object({ - name: z - .string() - .min( - 1, - tCommon('validation.required', { - field: t('providers.name'), - }), - ) - .regex(/^[a-z][a-z0-9-]*$/, t('providers.namePatternError')), - displayName: z.string().min( - 1, - tCommon('validation.required', { - field: t('providers.displayName'), - }), - ), - baseUrl: z.string().url( - tCommon('validation.required', { - field: t('providers.baseUrl'), - }), - ), - apiKey: z.string().min( - 1, - tCommon('validation.required', { - field: t('providers.apiKey'), - }), - ), - models: z - .array( - z.object({ - id: z.string().min(1, t('providers.modelIdRequired')), - displayName: z - .string() - .min(1, t('providers.displayNameRequired')), - tags: z - .array(z.enum(modelTagLiterals)) - .min(1, t('providers.tagsRequired')), - }), - ) - .min(1, t('providers.modelsRequired')) - .superRefine((models, ctx) => { - const seen = new Set(); - for (let i = 0; i < models.length; i++) { - const id = models[i].id; - if (id && seen.has(id)) { - ctx.addIssue({ - code: 'custom', - message: t('providers.duplicateModelId'), - path: [i, 'id'], - }); - } - seen.add(id); - } - }), - }), - [t, tCommon], - ); - - const { - register, - control, - handleSubmit, - formState: { isSubmitting, isValid, errors }, - reset, - setValue, - watch, - } = useForm({ - resolver: zodResolver(formSchema), - mode: 'onChange', - defaultValues: { - name: '', - displayName: '', - baseUrl: '', - apiKey: '', - models: [{ id: '', displayName: '', tags: ['chat'] }], - }, - }); - - const { fields, append, remove } = useFieldArray({ - control, - name: 'models', - }); - - const watchedModels = watch('models'); - - const handleTagToggle = useCallback( - ( - modelIndex: number, - tag: (typeof modelTagLiterals)[number], - checked: boolean, - ) => { - const current = watchedModels[modelIndex]?.tags ?? []; - const next = checked - ? [...current, tag] - : current.filter((v) => v !== tag); - setValue(`models.${modelIndex}.tags`, next, { shouldValidate: true }); - }, - [watchedModels, setValue], - ); - - const onSubmit = async (data: FormData) => { - try { - await saveProvider({ - orgSlug: 'default', - providerName: data.name, - config: { - displayName: data.displayName, - baseUrl: data.baseUrl, - models: data.models.map((m) => ({ - id: m.id, - displayName: m.displayName, - tags: m.tags, - })), - }, - }); - await saveProviderSecret({ - orgSlug: 'default', - providerName: data.name, - apiKey: data.apiKey, - }); - toast({ - title: t('providers.created'), - variant: 'success', - }); - reset(); - onOpenChange(false); - } catch (error) { - console.error(error); - toast({ - title: t('providers.createFailed'), - variant: 'destructive', - }); - } - }; - - return ( - { - if (!isOpen) reset(); - onOpenChange(isOpen); - }} - title={t('providers.addProvider')} - submitText={t('providers.addProvider')} - submittingText={tCommon('actions.adding')} - isSubmitting={isSubmitting} - isValid={isValid} - onSubmit={handleSubmit(onSubmit)} - large - > - -
- - - {t('providers.byomGuidance')} - -
- - - - {t('providers.nameHelp')} - - - - - - - - - - - {t('providers.models')} - - - - {errors.models?.root?.message && ( - - {errors.models.root.message} - - )} - - {fields.map((field, index) => ( -
- - - - {t('providers.modelNumber', { number: index + 1 })} - - {fields.length > 1 && ( - remove(index)} - /> - )} - - - - - - - - - {t('providers.tags')} - - - {modelTagLiterals.map((tag) => ( - - ))} - - {errors.models?.[index]?.tags?.message && ( - - {errors.models[index].tags.message} - - )} - - -
- ))} -
-
-
- ); -} diff --git a/services/platform/app/features/settings/providers/components/provider-add-panel.tsx b/services/platform/app/features/settings/providers/components/provider-add-panel.tsx new file mode 100644 index 000000000..12809d622 --- /dev/null +++ b/services/platform/app/features/settings/providers/components/provider-add-panel.tsx @@ -0,0 +1,391 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Loader2, Plus, Trash2, X } from 'lucide-react'; +import { useCallback, useMemo } from 'react'; +import { useFieldArray, useForm } from 'react-hook-form'; +import { z } from 'zod/v4'; + +import { CollapsibleGuide } from '@/app/components/ui/data-display/collapsible-guide'; +import { Checkbox } from '@/app/components/ui/forms/checkbox'; +import { Input } from '@/app/components/ui/forms/input'; +import { HStack, Stack } from '@/app/components/ui/layout/layout'; +import { Sheet } from '@/app/components/ui/overlays/sheet'; +import { Button } from '@/app/components/ui/primitives/button'; +import { IconButton } from '@/app/components/ui/primitives/icon-button'; +import { Text } from '@/app/components/ui/typography/text'; +import { toast } from '@/app/hooks/use-toast'; +import { useT } from '@/lib/i18n/client'; + +import { useSaveProvider, useSaveProviderSecret } from '../hooks/mutations'; + +const modelTagLiterals = ['chat', 'vision', 'embedding'] as const; + +type FormData = { + name: string; + displayName: string; + baseUrl: string; + apiKey: string; + models: Array<{ + id: string; + displayName: string; + tags: Array<(typeof modelTagLiterals)[number]>; + }>; +}; + +interface ProviderAddPanelProps { + open: boolean; + onOpenChange: (open: boolean) => void; + organizationId: string; +} + +export function ProviderAddPanel({ + open, + onOpenChange, + organizationId: _organizationId, +}: ProviderAddPanelProps) { + const { t } = useT('settings'); + const { t: tCommon } = useT('common'); + const { mutateAsync: saveProvider } = useSaveProvider(); + const { mutateAsync: saveProviderSecret } = useSaveProviderSecret(); + + const formSchema = useMemo( + () => + z.object({ + name: z + .string() + .min( + 1, + tCommon('validation.required', { + field: t('providers.name'), + }), + ) + .regex(/^[a-z][a-z0-9-]*$/, t('providers.namePatternError')), + displayName: z.string().min( + 1, + tCommon('validation.required', { + field: t('providers.displayName'), + }), + ), + baseUrl: z.string().url( + tCommon('validation.required', { + field: t('providers.baseUrl'), + }), + ), + apiKey: z.string().min( + 1, + tCommon('validation.required', { + field: t('providers.apiKey'), + }), + ), + models: z + .array( + z.object({ + id: z.string().min(1, t('providers.modelIdRequired')), + displayName: z + .string() + .min(1, t('providers.displayNameRequired')), + tags: z + .array(z.enum(modelTagLiterals)) + .min(1, t('providers.tagsRequired')), + }), + ) + .min(1, t('providers.modelsRequired')) + .superRefine((models, ctx) => { + const seen = new Set(); + for (let i = 0; i < models.length; i++) { + const id = models[i].id; + if (id && seen.has(id)) { + ctx.addIssue({ + code: 'custom', + message: t('providers.duplicateModelId'), + path: [i, 'id'], + }); + } + seen.add(id); + } + }), + }), + [t, tCommon], + ); + + const { + register, + control, + handleSubmit, + formState: { isSubmitting, isValid, errors }, + reset, + setValue, + watch, + } = useForm({ + resolver: zodResolver(formSchema), + mode: 'onChange', + defaultValues: { + name: '', + displayName: '', + baseUrl: '', + apiKey: '', + models: [{ id: '', displayName: '', tags: ['chat'] }], + }, + }); + + const { fields, append, remove } = useFieldArray({ + control, + name: 'models', + }); + + const watchedModels = watch('models'); + + const handleTagToggle = useCallback( + ( + modelIndex: number, + tag: (typeof modelTagLiterals)[number], + checked: boolean, + ) => { + const current = watchedModels[modelIndex]?.tags ?? []; + const next = checked + ? [...current, tag] + : current.filter((v) => v !== tag); + setValue(`models.${modelIndex}.tags`, next, { shouldValidate: true }); + }, + [watchedModels, setValue], + ); + + const handleOpenChange = useCallback( + (isOpen: boolean) => { + if (!isOpen) reset(); + onOpenChange(isOpen); + }, + [reset, onOpenChange], + ); + + const onSubmit = async (data: FormData) => { + try { + await saveProvider({ + orgSlug: 'default', + providerName: data.name, + config: { + displayName: data.displayName, + baseUrl: data.baseUrl, + models: data.models.map((m) => ({ + id: m.id, + displayName: m.displayName, + tags: m.tags, + })), + }, + }); + await saveProviderSecret({ + orgSlug: 'default', + providerName: data.name, + apiKey: data.apiKey, + }); + toast({ + title: t('providers.created'), + variant: 'success', + }); + reset(); + onOpenChange(false); + } catch (error) { + console.error(error); + toast({ + title: t('providers.createFailed'), + variant: 'destructive', + }); + } + }; + + return ( + + + + {t('providers.addProvider')} + + handleOpenChange(false)} + /> + + +
+
+ + + + + + {t('providers.nameHelp')} + + + + + + + + + + + + {t('providers.models')} + + + + + {errors.models?.root?.message && ( + + {errors.models.root.message} + + )} + + {fields.map((field, index) => ( +
+ + + + {t('providers.modelNumber', { number: index + 1 })} + + {fields.length > 1 && ( + remove(index)} + /> + )} + + + + + + + + + {t('providers.tags')} + + + {modelTagLiterals.map((tag) => ( + + ))} + + {errors.models?.[index]?.tags?.message && ( + + {errors.models[index].tags.message} + + )} + + +
+ ))} +
+
+
+ +
+ + + +
+
+
+ ); +} diff --git a/services/platform/app/features/settings/providers/components/provider-edit-dialog.tsx b/services/platform/app/features/settings/providers/components/provider-edit-dialog.tsx deleted file mode 100644 index 76c5c4405..000000000 --- a/services/platform/app/features/settings/providers/components/provider-edit-dialog.tsx +++ /dev/null @@ -1,106 +0,0 @@ -'use client'; - -import { useCallback, useEffect, useState } from 'react'; - -import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; -import { Input } from '@/app/components/ui/forms/input'; -import { Text } from '@/app/components/ui/typography/text'; -import { toast } from '@/app/hooks/use-toast'; -import { useT } from '@/lib/i18n/client'; - -import { useSaveProvider } from '../hooks/mutations'; -import { useReadProvider } from '../hooks/queries'; - -interface ProviderEditDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - providerName: string; -} - -export function ProviderEditDialog({ - open, - onOpenChange, - providerName, -}: ProviderEditDialogProps) { - const { t } = useT('settings'); - const { data } = useReadProvider('default', providerName); - const { mutateAsync: saveProvider, isPending } = useSaveProvider(); - - const [form, setForm] = useState({ - name: '', - displayName: '', - baseUrl: '', - }); - - useEffect(() => { - if (data?.ok) { - setForm({ - name: providerName, - displayName: data.config.displayName, - baseUrl: data.config.baseUrl, - }); - } - }, [data, providerName]); - - const handleSubmit = useCallback( - async (e: React.FormEvent) => { - e.preventDefault(); - if (!data?.ok) return; - try { - await saveProvider({ - orgSlug: 'default', - providerName, - config: { - ...data.config, - displayName: form.displayName, - baseUrl: form.baseUrl, - }, - }); - toast({ title: t('providers.saved'), variant: 'success' }); - onOpenChange(false); - } catch { - toast({ title: t('providers.saveFailed'), variant: 'destructive' }); - } - }, - [data, form, providerName, saveProvider, t, onOpenChange], - ); - - const isDirty = - data?.ok && - (form.displayName !== data.config.displayName || - form.baseUrl !== data.config.baseUrl); - - return ( - - - - {t('providers.nameHelp')} - - - - setForm((f) => ({ ...f, displayName: e.target.value })) - } - placeholder={t('providers.displayNamePlaceholder')} - /> - - setForm((f) => ({ ...f, baseUrl: e.target.value }))} - placeholder={t('providers.baseUrlPlaceholder')} - /> - - ); -} diff --git a/services/platform/app/features/settings/providers/components/provider-edit-panel.tsx b/services/platform/app/features/settings/providers/components/provider-edit-panel.tsx new file mode 100644 index 000000000..4699d43cb --- /dev/null +++ b/services/platform/app/features/settings/providers/components/provider-edit-panel.tsx @@ -0,0 +1,156 @@ +'use client'; + +import { Loader2, X } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; + +import { Input } from '@/app/components/ui/forms/input'; +import { HStack, Stack } from '@/app/components/ui/layout/layout'; +import { Sheet } from '@/app/components/ui/overlays/sheet'; +import { Button } from '@/app/components/ui/primitives/button'; +import { IconButton } from '@/app/components/ui/primitives/icon-button'; +import { Text } from '@/app/components/ui/typography/text'; +import { toast } from '@/app/hooks/use-toast'; +import { useT } from '@/lib/i18n/client'; + +import { useSaveProvider } from '../hooks/mutations'; +import { useReadProvider } from '../hooks/queries'; + +interface ProviderEditPanelProps { + open: boolean; + onOpenChange: (open: boolean) => void; + providerName: string; +} + +export function ProviderEditPanel({ + open, + onOpenChange, + providerName, +}: ProviderEditPanelProps) { + const { t } = useT('settings'); + const { t: tCommon } = useT('common'); + const { data } = useReadProvider('default', providerName); + const { mutateAsync: saveProvider, isPending } = useSaveProvider(); + + const [form, setForm] = useState({ + name: '', + displayName: '', + baseUrl: '', + }); + + useEffect(() => { + if (data?.ok) { + setForm({ + name: providerName, + displayName: data.config.displayName, + baseUrl: data.config.baseUrl, + }); + } + }, [data, providerName]); + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + if (!data?.ok) return; + try { + await saveProvider({ + orgSlug: 'default', + providerName, + config: { + ...data.config, + displayName: form.displayName, + baseUrl: form.baseUrl, + }, + }); + toast({ title: t('providers.saved'), variant: 'success' }); + onOpenChange(false); + } catch { + toast({ title: t('providers.saveFailed'), variant: 'destructive' }); + } + }, + [data, form, providerName, saveProvider, t, onOpenChange], + ); + + const isValid = + form.displayName.trim().length > 0 && form.baseUrl.trim().length > 0; + + const isDirty = + data?.ok && + (form.displayName !== data.config.displayName || + form.baseUrl !== data.config.baseUrl); + + return ( + + + + {t('providers.editProvider')} + + onOpenChange(false)} + /> + + +
+
+ + + + {t('providers.nameReadonlyHelp')} + + + + setForm((f) => ({ ...f, displayName: e.target.value })) + } + placeholder={t('providers.displayNamePlaceholder')} + /> + + + setForm((f) => ({ ...f, baseUrl: e.target.value })) + } + placeholder={t('providers.baseUrlPlaceholder')} + /> + +
+ +
+ + + +
+
+
+ ); +} diff --git a/services/platform/app/features/settings/providers/components/providers-table.tsx b/services/platform/app/features/settings/providers/components/providers-table.tsx index 2e2edb267..311c9f3f7 100644 --- a/services/platform/app/features/settings/providers/components/providers-table.tsx +++ b/services/platform/app/features/settings/providers/components/providers-table.tsx @@ -21,8 +21,8 @@ import { useT } from '@/lib/i18n/client'; import { useDeleteProvider } from '../hooks/mutations'; import { useListProviders } from '../hooks/queries'; import { useProvidersTableConfig } from '../hooks/use-providers-table-config'; -import { ProviderAddDialog } from './provider-add-dialog'; -import { ProviderEditDialog } from './provider-edit-dialog'; +import { ProviderAddPanel } from './provider-add-panel'; +import { ProviderEditPanel } from './provider-edit-panel'; export interface ProviderRow { name: string; @@ -144,14 +144,14 @@ export function ProvidersTable({ organizationId }: ProvidersTableProps) { }} /> - {editProvider && ( - { if (!open) setEditProvider(null); diff --git a/services/platform/messages/de.json b/services/platform/messages/de.json index a303f8430..603d87bc7 100644 --- a/services/platform/messages/de.json +++ b/services/platform/messages/de.json @@ -1478,6 +1478,7 @@ "name": "Anbietername", "namePlaceholder": "z. B. openai", "nameHelp": "Nur Kleinbuchstaben, Zahlen und Bindestriche.", + "nameReadonlyHelp": "Der Anbietername ist eine permanente Kennung und kann nicht geändert werden. Verwende den Anzeigenamen, um die Darstellung anzupassen.", "namePatternError": "Muss mit einem Buchstaben beginnen und darf nur Kleinbuchstaben, Zahlen und Bindestriche enthalten.", "displayNamePlaceholder": "z. B. OpenAI", "descriptionPlaceholder": "z. B. OpenAI GPT-Modelle", @@ -1523,6 +1524,7 @@ "providerActions": "Anbieteraktionen", "saveChanges": "Änderungen speichern", "addModelShort": "+ Modell hinzufügen", + "byomGuidanceTitle": "Erste Schritte", "byomGuidance": "Jeder OpenAI-kompatible Endpunkt funktioniert hier. Setze die Basis-URL auf deinen selbst gehosteten Inferenzserver (z. B. vLLM, Ollama, LocalAI) oder ein Gateway wie OpenRouter, um Modelle wie LLaMA, Mistral, Gemini und mehr zu nutzen.", "tagsRequired": "Mindestens ein Tag ist erforderlich", "modelsRequired": "Mindestens ein Modell ist erforderlich", diff --git a/services/platform/messages/en.json b/services/platform/messages/en.json index 5ef9169d0..9062ea7d6 100644 --- a/services/platform/messages/en.json +++ b/services/platform/messages/en.json @@ -1481,6 +1481,7 @@ "name": "Provider name", "namePlaceholder": "e.g., openai", "nameHelp": "Lowercase letters, numbers, and hyphens only.", + "nameReadonlyHelp": "The provider name is a permanent identifier and cannot be changed. Use display name to customize how it appears.", "namePatternError": "Must start with a letter and contain only lowercase letters, numbers, and hyphens.", "displayNamePlaceholder": "e.g., OpenAI", "descriptionPlaceholder": "e.g., OpenAI GPT models", @@ -1526,6 +1527,7 @@ "providerActions": "Provider actions", "saveChanges": "Save changes", "addModelShort": "+ Add model", + "byomGuidanceTitle": "Getting started", "byomGuidance": "Any OpenAI-compatible endpoint works here. Point the base URL to your self-hosted inference server (e.g. vLLM, Ollama, LocalAI) or a third-party gateway like OpenRouter to use models such as LLaMA, Mistral, Gemini, and more.", "tagsRequired": "At least one tag is required", "modelsRequired": "At least one model is required", From d09eff9b5e68f0243060de8782050eb82287ee02 Mon Sep 17 00:00:00 2001 From: israel Date: Sat, 11 Apr 2026 10:02:16 +0100 Subject: [PATCH 2/2] fix(platform): address CodeRabbit review feedback Use controlled state for CollapsibleGuide to preserve user toggle across rerenders. Add role="alert" to form error messages for screen reader announcements. Fix base URL validation to show proper URL format error instead of "required". Add URL format validation to edit panel. --- .../ui/data-display/collapsible-guide.tsx | 6 +++++- .../components/provider-add-panel.tsx | 21 +++++++++++++------ .../components/provider-edit-panel.tsx | 4 +++- services/platform/messages/de.json | 3 ++- services/platform/messages/en.json | 3 ++- 5 files changed, 27 insertions(+), 10 deletions(-) diff --git a/services/platform/app/components/ui/data-display/collapsible-guide.tsx b/services/platform/app/components/ui/data-display/collapsible-guide.tsx index 5ce0cb61c..8b756af95 100644 --- a/services/platform/app/components/ui/data-display/collapsible-guide.tsx +++ b/services/platform/app/components/ui/data-display/collapsible-guide.tsx @@ -1,6 +1,7 @@ 'use client'; import { Info } from 'lucide-react'; +import { useState } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -18,10 +19,13 @@ export function CollapsibleGuide({ content, defaultOpen, }: CollapsibleGuideProps) { + const [isOpen, setIsOpen] = useState(Boolean(defaultOpen)); + return (
setIsOpen(event.currentTarget.open)} > diff --git a/services/platform/app/features/settings/providers/components/provider-add-panel.tsx b/services/platform/app/features/settings/providers/components/provider-add-panel.tsx index 12809d622..2ca3f127a 100644 --- a/services/platform/app/features/settings/providers/components/provider-add-panel.tsx +++ b/services/platform/app/features/settings/providers/components/provider-add-panel.tsx @@ -67,11 +67,15 @@ export function ProviderAddPanel({ field: t('providers.displayName'), }), ), - baseUrl: z.string().url( - tCommon('validation.required', { - field: t('providers.baseUrl'), - }), - ), + baseUrl: z + .string() + .min( + 1, + tCommon('validation.required', { + field: t('providers.baseUrl'), + }), + ) + .url(tCommon('validation.url')), apiKey: z.string().min( 1, tCommon('validation.required', { @@ -287,7 +291,11 @@ export function ProviderAddPanel({ {errors.models?.root?.message && ( - + {errors.models.root.message} )} @@ -359,6 +367,7 @@ export function ProviderAddPanel({ {errors.models[index].tags.message} diff --git a/services/platform/app/features/settings/providers/components/provider-edit-panel.tsx b/services/platform/app/features/settings/providers/components/provider-edit-panel.tsx index 4699d43cb..734501c65 100644 --- a/services/platform/app/features/settings/providers/components/provider-edit-panel.tsx +++ b/services/platform/app/features/settings/providers/components/provider-edit-panel.tsx @@ -71,7 +71,9 @@ export function ProviderEditPanel({ ); const isValid = - form.displayName.trim().length > 0 && form.baseUrl.trim().length > 0; + form.displayName.trim().length > 0 && + form.baseUrl.trim().length > 0 && + URL.canParse(form.baseUrl.trim()); const isDirty = data?.ok && diff --git a/services/platform/messages/de.json b/services/platform/messages/de.json index 603d87bc7..7e60dcd48 100644 --- a/services/platform/messages/de.json +++ b/services/platform/messages/de.json @@ -135,7 +135,8 @@ "invalidJson": "Ungültiges JSON-Format", "uploadFile": "Bitte lade eine Datei hoch", "unsupportedFileType": "Nicht unterstützter Dateityp", - "schemaValidationFailed": "Schema-Validierung fehlgeschlagen{error, select, empty {} other {: {error}}}" + "schemaValidationFailed": "Schema-Validierung fehlgeschlagen{error, select, empty {} other {: {error}}}", + "url": "Bitte gib eine gültige URL ein" }, "aria": { "close": "Schließen", diff --git a/services/platform/messages/en.json b/services/platform/messages/en.json index 9062ea7d6..d44ffd9bb 100644 --- a/services/platform/messages/en.json +++ b/services/platform/messages/en.json @@ -135,7 +135,8 @@ "invalidJson": "Invalid JSON format", "uploadFile": "Please upload a file", "unsupportedFileType": "Unsupported file type", - "schemaValidationFailed": "Schema validation failed{error, select, empty {} other {: {error}}}" + "schemaValidationFailed": "Schema validation failed{error, select, empty {} other {: {error}}}", + "url": "Please enter a valid URL" }, "aria": { "close": "Close",