diff --git a/design-system/packages/ui/README.md b/design-system/packages/ui/README.md index 9557d785c4..efaf6b3bc4 100644 --- a/design-system/packages/ui/README.md +++ b/design-system/packages/ui/README.md @@ -446,10 +446,14 @@ padding uses `space.component.inline` and icon-to-text spacing uses `space.1`. An absolute-positioned search host must reserve that same search-row height. The built-in clear action uses the shared square xs quiet-button hover feedback. +Use `Input size="xs" shape="pill"` for inline capsule editing. Its height, +padding, and metadata type match compact `Button size="xs"` capsules; keep +capsule placement and input width in the consumer layout. + Choose `size` explicitly when composing form rows: selectors default to `md`, -while `Input` defaults to `sm`. Except for SearchField's dedicated row contract, -the shared `control.height.sm/md/lg` tokens and -active density own the actual heights; consumers must not replace them with +while `Input` defaults to `sm`. For ordinary `sm/md/lg` fields, apart from +SearchField's dedicated row contract, the shared `control.height.sm/md/lg` +tokens and active density own the actual heights; consumers must not replace them with page-level heights or padding overrides. Picker bodies stay single-line and token-sized, with labels and validation messages outside that height. Select keeps its in-flow anchor mounted when the unified popup covers it, so opening @@ -651,7 +655,7 @@ Dialog titles use 24px bold type with their own 29px line box and normal trackin Extra-large (`xl`) dialogs have an 800px maximum width and continue shrinking within the viewport gutter. Provider editing uses the floating footer; small workspace creation retains its attached footer and existing button/input sizes. The Lab workspace pattern uses local sample paths and callbacks only. -Keep `Dialog` and `Sheet` mounted and set `open={false}` to close them. They retain the last committed children during the exit animation, with interaction disabled, so clearing an owner selection does not collapse the surface. Reopening uses the latest children and cancels the pending exit. Owners that conditionally mount an editor can remove it in `onExitComplete`, which runs once after the surface unmounts. +Keep `Dialog` and `Sheet` mounted and set `open={false}` to close them. They retain the last committed children, size, placement, classes, and styles during the exit animation, with interaction disabled, so clearing an owner selection does not resize the surface. Reopening uses the latest presentation and cancels the pending exit. Owners that conditionally mount an editor can remove it in `onExitComplete`, which runs once after the surface unmounts. PageHeader `md` uses the settings title with a primary 15px description; `display` uses the welcome heading and medium 17px introduction with a 12px gap. ActionCard uses 12px padding, section-heading typography (15px semibold), and a primary 13px single-line action description. Its inset outline does not inflate the 62px medium minimum height; longer content keeps the independent sibling actions and OverflowText behavior. diff --git a/design-system/packages/ui/src/components/Combobox/Combobox.tsx b/design-system/packages/ui/src/components/Combobox/Combobox.tsx index 70647c5382..e3f73cb43f 100644 --- a/design-system/packages/ui/src/components/Combobox/Combobox.tsx +++ b/design-system/packages/ui/src/components/Combobox/Combobox.tsx @@ -79,6 +79,8 @@ export interface MultiSelectProps extends PickerCommonProps { defaultValue?: readonly ComboboxValue[]; maxVisibleTags?: number; onValueChange?: (value: ComboboxValue[]) => void; + /** Replaces selected tags with a compact, caller-localized summary. */ + selectionSummary?: ReactNode; showSelectAll?: boolean; value?: readonly ComboboxValue[]; } @@ -153,6 +155,7 @@ const CollectionPicker = forwardRef(function Collec delete (rootProps as Record).onValueChange; delete (rootProps as Record).value; delete (rootProps as Record).maxVisibleTags; + delete (rootProps as Record).selectionSummary; delete (rootProps as Record).showSelectAll; const divProps = rootProps as HTMLAttributes; @@ -164,6 +167,7 @@ const CollectionPicker = forwardRef(function Collec const maxVisibleTags = multiple ? (props as MultiSelectProps).maxVisibleTags ?? 3 : 0; + const selectionSummary = multiple ? (props as MultiSelectProps).selectionSummary : undefined; const showSelectAll = multiple && ((props as MultiSelectProps).showSelectAll ?? false); const placeholder = placeholderProp ?? designSystem.messages.selectPlaceholder; @@ -624,6 +628,7 @@ const CollectionPicker = forwardRef(function Collec ) : null; const singleOption = selectedOptions[0]; + const showTags = multiple && hasValue && selectionSummary == null; return (
(function Collec aria-hidden={resolvedOpen || undefined} className={styles.control} data-openbitfun-part="control" - data-tags={multiple && hasValue ? "true" : "false"} + data-tags={showTags ? "true" : "false"} > - {multiple && hasValue && ( + {showTags && ( {selectedOptions.slice(0, Math.max(1, maxVisibleTags)).map((option) => ( @@ -702,7 +707,7 @@ const CollectionPicker = forwardRef(function Collec {!hasValue ? ( {placeholder} ) : multiple ? ( - {selectedOptions.map((option) => option.label).join(", ")} + {selectionSummary ?? selectedOptions.map((option) => option.label).join(", ")} ) : ( {singleOption?.leading && ( diff --git a/design-system/packages/ui/src/components/Dialog/Dialog.tsx b/design-system/packages/ui/src/components/Dialog/Dialog.tsx index 66461a43e9..89285e0199 100644 --- a/design-system/packages/ui/src/components/Dialog/Dialog.tsx +++ b/design-system/packages/ui/src/components/Dialog/Dialog.tsx @@ -99,6 +99,7 @@ const OverlaySurface = forwardRef(function preventScroll = true, role = "dialog", size, + style, trapFocus = true, ...surfaceProps }, forwardedRef) { @@ -110,20 +111,22 @@ const OverlaySurface = forwardRef(function const titleId = `openbitfun-dialog-title-${useId()}`; const descriptionId = `openbitfun-dialog-description-${useId()}`; const { present, state } = usePresence(open, EXIT_DURATION_MS); - const lastOpenChildren = useRef(null); + const presentation = { children, className, overlayProps, placement, size, style }; + const lastOpenPresentation = useRef(null); const wasPresent = useRef(present); // Owners often clear selection or form state in the same update as closing. - // Keep the last committed content until the exit finishes to preserve geometry. + // Retain the content and its geometry together until the exit finishes. useLayoutEffect(() => { - if (open) lastOpenChildren.current = children; - else if (!present) lastOpenChildren.current = null; - }, [children, open, present]); + if (open) lastOpenPresentation.current = { children, className, overlayProps, placement, size, style }; + else if (!present) lastOpenPresentation.current = null; + }, [children, className, open, overlayProps, placement, present, size, style]); useEffect(() => { const completed = wasPresent.current && !present && !open; wasPresent.current = present; if (completed) onExitComplete?.(); }, [onExitComplete, open, present]); - const renderedChildren = open ? children : lastOpenChildren.current; + const rendered = open ? presentation : lastOpenPresentation.current ?? presentation; + const renderedChildren = rendered.children; const hasTitle = containsType(renderedChildren, DialogTitle); const hasDescription = containsType(renderedChildren, DialogDescription); @@ -169,12 +172,12 @@ const OverlaySurface = forwardRef(function return (
@@ -186,14 +189,15 @@ const OverlaySurface = forwardRef(function aria-label={ariaLabel} aria-labelledby={ariaLabelledBy ?? (!ariaLabel && hasTitle ? titleId : undefined)} aria-modal="true" - className={classNames(styles.surface, className)} + className={classNames(styles.surface, rendered.className)} data-openbitfun-component={kind} data-openbitfun-part="surface" - data-placement={placement} - data-size={size} + data-placement={rendered.placement} + data-size={rendered.size} data-state={exiting ? "exiting" : "open"} ref={setSurfaceRef} role={role} + style={rendered.style} tabIndex={-1} > {renderedChildren} diff --git a/design-system/packages/ui/src/components/Input/Input.meta.ts b/design-system/packages/ui/src/components/Input/Input.meta.ts index ffe75d96a3..d6a60c59f4 100644 --- a/design-system/packages/ui/src/components/Input/Input.meta.ts +++ b/design-system/packages/ui/src/components/Input/Input.meta.ts @@ -12,7 +12,8 @@ export const inputMeta = { { name: "leading", type: "ReactNode" }, { name: "trailing", type: "ReactNode" }, { defaultValue: "false", name: "hideNativePasswordReveal", type: "boolean" }, - { defaultValue: "sm", name: "size", type: "sm | md | lg" }, + { defaultValue: "rounded", name: "shape", type: "rounded | pill" }, + { defaultValue: "sm", name: "size", type: "xs | sm | md | lg" }, { defaultValue: "false", name: "readOnly", type: "boolean" }, { defaultValue: "false", name: "invalid", type: "boolean" }, { defaultValue: "false", name: "disabled", type: "boolean" }, @@ -35,7 +36,12 @@ export const inputMeta = { "control.height.sm", "control.height.md", "control.height.lg", + "control.button.xsHeight", + "control.button.xsPaddingInline", + "control.button.xsLeadingIconSize", + "type.meta.fontSize", "type.label.md.fontSize", "radius.base", + "radius.pill", ], } as const satisfies ComponentMeta; diff --git a/design-system/packages/ui/src/components/Input/Input.module.css b/design-system/packages/ui/src/components/Input/Input.module.css index 8a956e5fb4..f702775e0e 100644 --- a/design-system/packages/ui/src/components/Input/Input.module.css +++ b/design-system/packages/ui/src/components/Input/Input.module.css @@ -42,6 +42,16 @@ cursor: not-allowed; } + .field[data-size="xs"] { + --_field-height: var(--openbitfun-control-button-xs-height); + --_field-icon-size: var(--openbitfun-control-button-xs-leading-icon-size); + padding-inline: var(--openbitfun-control-button-xs-padding-inline); + } + + .field[data-shape="pill"] { + border-radius: var(--openbitfun-radius-pill); + } + .field[data-size="md"] { --_field-height: var(--openbitfun-control-height-md); --_field-icon-size: 16px; @@ -79,6 +89,14 @@ opacity: 1; } + .field[data-size="xs"] .input { + font-family: var(--openbitfun-type-meta-font-family); + font-size: var(--openbitfun-type-meta-font-size); + font-weight: var(--openbitfun-type-meta-font-weight); + line-height: var(--openbitfun-type-meta-line-height); + letter-spacing: var(--openbitfun-type-meta-letter-spacing); + } + .input:disabled { cursor: not-allowed; } diff --git a/design-system/packages/ui/src/components/Input/Input.tsx b/design-system/packages/ui/src/components/Input/Input.tsx index c309f25037..772961a6a8 100644 --- a/design-system/packages/ui/src/components/Input/Input.tsx +++ b/design-system/packages/ui/src/components/Input/Input.tsx @@ -18,7 +18,8 @@ export interface InputProps invalid?: boolean; leading?: ReactNode; onValueChange?: (value: string) => void; - size?: "sm" | "md" | "lg"; + shape?: "rounded" | "pill"; + size?: "xs" | "sm" | "md" | "lg"; trailing?: ReactNode; } @@ -34,6 +35,7 @@ export const Input = forwardRef(function Input({ onCompositionStart, onKeyDown, onValueChange, + shape = "rounded", size = "sm", trailing, type = "text", @@ -57,6 +59,7 @@ export const Input = forwardRef(function Input({ data-disabled={disabled ? "true" : "false"} data-field-surface={fieldSurface} data-invalid={isInvalid ? "true" : "false"} + data-shape={shape} data-size={size} > {leading !== undefined && leading !== null && ( diff --git a/design-system/packages/ui/src/components/MultiSelect/MultiSelect.meta.ts b/design-system/packages/ui/src/components/MultiSelect/MultiSelect.meta.ts index f886fe7a76..40d9e8e7e8 100644 --- a/design-system/packages/ui/src/components/MultiSelect/MultiSelect.meta.ts +++ b/design-system/packages/ui/src/components/MultiSelect/MultiSelect.meta.ts @@ -12,6 +12,7 @@ export const multiSelectMeta = { { name: "onCreateValue", type: "(input: string) => ComboboxValue | void" }, { defaultValue: "false", name: "clearable", type: "boolean" }, { defaultValue: "3", name: "maxVisibleTags", type: "number" }, + { name: "selectionSummary", type: "ReactNode" }, { defaultValue: "false", name: "showSelectAll", type: "boolean" }, { defaultValue: "md", name: "size", type: "sm | md | lg" }, ], diff --git a/design-system/packages/ui/tests/input.test.mjs b/design-system/packages/ui/tests/input.test.mjs index c189571e29..820d52334c 100644 --- a/design-system/packages/ui/tests/input.test.mjs +++ b/design-system/packages/ui/tests/input.test.mjs @@ -50,6 +50,19 @@ test("Input exposes invalid, disabled, and size independently", () => { assert.match(markup, /disabled=""/); }); +test("Input supports compact capsule editing without leaking shape or size to the native input", async () => { + const markup = renderToStaticMarkup( + createElement(Input, { size: "xs", shape: "pill", "aria-label": "Tag", defaultValue: "Planning" }), + ); + assert.match(markup, /data-size="xs"/); + assert.match(markup, /data-shape="pill"/); + assert.match(markup, /]*aria-label="Tag"/); + assert.doesNotMatch(markup, /]*\s(?:shape|size)=/); + const styles = await readFile(new URL("../src/components/Input/Input.module.css", import.meta.url), "utf8"); + assert.match(styles, /\[data-size="xs"\][\s\S]*?--_field-height: var\(--openbitfun-control-button-xs-height\)/); + assert.match(styles, /\[data-shape="pill"\][\s\S]*?border-radius: var\(--openbitfun-radius-pill\)/); +}); + test("Input styles consume semantic field and status tokens", async () => { const styles = await readFile(new URL("../dist/styles.css", import.meta.url), "utf8"); diff --git a/src/web-ui/src/app/scenes/settings/pages/ai/ModelDiscoveryPicker.test.tsx b/src/web-ui/src/app/scenes/settings/pages/ai/ModelDiscoveryPicker.test.tsx new file mode 100644 index 0000000000..87ff4396ba --- /dev/null +++ b/src/web-ui/src/app/scenes/settings/pages/ai/ModelDiscoveryPicker.test.tsx @@ -0,0 +1,153 @@ +// @vitest-environment jsdom + +import React, { act, useState, type ComponentProps } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { Button, Dialog, DialogBody, DialogHeader, DialogHeading, DialogTitle } from '@openbitfun/ui'; +import { i18nService } from '@/infrastructure/i18n'; +import { ModelDiscoveryPicker } from './ModelDiscoveryPicker'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +type PickerProps = ComponentProps; +const models = ['alpha', 'beta', 'gamma'].map(value => ({ label: value, value })); + +describe('model discovery capsules', () => { + let host: HTMLDivElement; + let root: Root; + let changes: string[][]; + let refreshes: number; + let editorClosures: boolean[]; + const copy = (key: string, options?: Record) => ( + String(i18nService.getI18nInstance().t(key, { ns: 'settings/models', ...options })) + ); + const trigger = () => document.querySelector('[data-testid="settings-model-select"]')!; + const popup = () => document.querySelector('[data-testid="settings-model-discovery-dialog"]:not([aria-hidden="true"])'); + const capsules = () => [...document.querySelectorAll('[data-testid="settings-model-option"]')]; + const search = () => popup()!.querySelector('input')!; + const click = (element: HTMLElement) => act(() => { element.focus(); element.click(); }); + const escape = () => act(() => { + document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); + }); + const settleExit = () => act(async () => { await new Promise(resolve => setTimeout(resolve, 220)); }); + + function Picker(props: Partial) { + const [open, setOpen] = useState(false); + const [value, setValue] = useState(['custom-model', 'ALPHA']); + return { refreshes += 1; }} + onValueChange={next => { changes.push(next); setValue(next); }} + {...props} + />; + } + + function Editor() { + const [open, setOpen] = useState(true); + return { editorClosures.push(next); setOpen(next); }}> + Provider + + ; + } + + beforeAll(async () => { await Promise.all(['settings/models', 'components'].map(ns => i18nService.loadNamespace(ns))); }); + beforeEach(() => { + changes = []; + refreshes = 0; + editorClosures = []; + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + }); + afterEach(() => { + act(() => root.unmount()); + host.remove(); + }); + + it('opens quick add as a primary action and keeps discovery results separate from selected custom models', async () => { + act(() => root.render()); + expect(popup()).toBeNull(); + expect(trigger().textContent).toBe(copy('providerSelection.quickAdd')); + expect(trigger().getAttribute('data-openbitfun-variant')).toBe('primary'); + click(trigger()); + expect(popup()!.getAttribute('aria-modal')).toBe('true'); + expect(popup()!.getAttribute('data-openbitfun-component')).toBe('dialog'); + expect(popup()!.textContent).toContain(copy('providerSelection.fetchedModels', { count: '3' })); + expect(capsules().map(button => button.textContent)).toEqual(['alpha', 'beta', 'gamma']); + expect(capsules()[0].getAttribute('aria-pressed')).toBe('true'); + click(capsules()[2]); + expect(changes.at(-1)).toEqual(['custom-model', 'ALPHA', 'gamma']); + expect(popup()).not.toBeNull(); + expect(capsules()[2].getAttribute('aria-pressed')).toBe('true'); + click(capsules()[0]); + expect(changes.at(-1)).toEqual(['custom-model', 'gamma']); + expect(capsules()).toHaveLength(3); + + act(() => { + search().focus(); + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(search(), 'BETA'); + search().dispatchEvent(new Event('input', { bubbles: true })); + }); + expect(capsules().map(button => button.textContent)).toEqual(['beta']); + escape(); + expect(popup()).toBeNull(); + await settleExit(); + expect(document.activeElement).toBe(trigger()); + click(trigger()); + expect(search().value).toBe(''); + expect(capsules()).toHaveLength(3); + }); + + it('keeps modal focus and dismissal inside the model list without closing the provider editor', async () => { + act(() => root.render()); + click(trigger()); + expect(document.activeElement).toBe(search()); + act(() => capsules()[1].dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))); + click(capsules()[1]); + expect(editorClosures).toEqual([]); + expect(popup()).not.toBeNull(); + escape(); + expect(popup()).toBeNull(); + await settleExit(); + expect(document.activeElement).toBe(trigger()); + expect(editorClosures).toEqual([]); + + click(trigger()); + const other = document.querySelector('[data-testid="other-field"]')!; + act(() => other.focus()); + expect(popup()!.contains(document.activeElement)).toBe(true); + const backdrop = popup()!.closest('[data-openbitfun-part="overlay"]')!; + act(() => backdrop.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))); + expect(popup()).toBeNull(); + expect(editorClosures).toEqual([]); + await settleExit(); + expect(document.activeElement).toBe(trigger()); + escape(); + expect(editorClosures).toEqual([false]); + }); + + it('labels preset fallback honestly and keeps refresh available after loading or an empty response', () => { + act(() => root.render()); + expect(trigger().textContent).toBe(copy('providerSelection.quickAdd')); + click(trigger()); + expect(popup()!.textContent).toContain(copy('providerSelection.presetModels', { count: '3' })); + const refresh = () => document.querySelector('[data-testid="settings-model-refresh-btn"]')!; + click(refresh()); + expect(refreshes).toBe(1); + + act(() => root.render()); + expect(refresh().disabled).toBe(true); + act(() => root.render()); + expect(capsules()).toHaveLength(0); + expect(popup()!.textContent).toContain('No models returned'); + expect(refresh().disabled).toBe(false); + click(refresh()); + expect(refreshes).toBe(2); + + act(() => root.render()); + expect(capsules()).toHaveLength(3); + expect(capsules()[0].getAttribute('aria-pressed')).toBe('true'); + expect(changes).toEqual([]); + }); +}); diff --git a/src/web-ui/src/app/scenes/settings/pages/ai/ModelDiscoveryPicker.tsx b/src/web-ui/src/app/scenes/settings/pages/ai/ModelDiscoveryPicker.tsx new file mode 100644 index 0000000000..84bb21a839 --- /dev/null +++ b/src/web-ui/src/app/scenes/settings/pages/ai/ModelDiscoveryPicker.tsx @@ -0,0 +1,173 @@ +import { useEffect, useId, useRef, useState } from 'react'; +import { + Button, + Dialog, + DialogBody, + DialogClose, + DialogDescription, + DialogFooter, + DialogHeader, + DialogHeaderActions, + DialogHeading, + DialogTitle, + Icon, + IconButton, + ScrollArea, + SearchField, +} from '@openbitfun/ui'; +import { useI18n } from '@/infrastructure/i18n'; + +export interface ModelDiscoveryOption { + label: string; + value: string; + description?: string; + source?: string; +} + +interface ModelDiscoveryPickerProps { + options: readonly ModelDiscoveryOption[]; + value: readonly string[]; + open: boolean; + loading: boolean; + fetched: boolean; + hint: string | null; + error: boolean; + invalid: boolean; + onOpenChange: (open: boolean) => void; + onRefresh: () => void; + onValueChange: (value: string[]) => void; +} + +export function ModelDiscoveryPicker({ + options, + value, + open, + loading, + fetched, + hint, + error, + invalid, + onOpenChange, + onRefresh, + onValueChange, +}: ModelDiscoveryPickerProps) { + const { t, formatNumber } = useI18n('settings/models'); + const [query, setQuery] = useState(''); + const searchRef = useRef(null); + const id = useId(); + useEffect(() => { + if (!open) setQuery(''); + }, [open]); + + const count = formatNumber(options.length); + const resultLabel = options.length > 0 + ? t(fetched ? 'providerSelection.fetchedModels' : 'providerSelection.presetModels', { count }) + : t('form.modelSelection'); + const normalizedQuery = query.trim().toLowerCase(); + const visibleOptions = options.filter(option => ( + !normalizedQuery + || `${option.label} ${option.value}`.toLowerCase().includes(normalizedQuery) + )); + const selected = new Set(value.map(name => name.trim().toLowerCase())); + + return ( +
+ + + + + {t('providerSelection.quickAdd')} + {resultLabel} + + + } + aria-label={t('providerSelection.refreshModels')} + title={t('providerSelection.refreshModels')} + data-testid="settings-model-refresh-btn" + loading={loading} + onClick={onRefresh} + /> + + + + + {options.length > 0 && ( + setQuery(event.target.value)} + aria-label={t('providerSelection.searchModels')} + placeholder={t('providerSelection.searchModels')} + size="sm" + /> + )} + {hint && ( + + {hint} + + )} + +
+ {visibleOptions.map(option => { + const modelName = String(option.value); + const lookupKey = modelName.trim().toLowerCase(); + const isSelected = selected.has(lookupKey); + return ( + + ); + })} + {visibleOptions.length === 0 && !loading && (options.length > 0 || !hint) && ( + + {t(options.length > 0 ? 'pool.noMatches' : 'providerSelection.noPresetModels')} + + )} +
+
+
+ + + +
+
+ ); +} diff --git a/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.presentation.test.ts b/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.presentation.test.ts index 936b9a025e..e980dc565b 100644 --- a/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.presentation.test.ts +++ b/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.presentation.test.ts @@ -12,17 +12,16 @@ const styles = readFileSync( ); describe('ModelSettingsPage presentation', () => { - it('uses intentional separators instead of Unicode replacement characters', () => { + it('does not render Unicode replacement characters', () => { expect(source).not.toContain('\uFFFD'); - expect(source.match(/\{' · '\}/g)).toHaveLength(2); }); it('declares required model fields structurally instead of embedding asterisks in label copy', () => { expect(source).not.toMatch(/label=\{`[^`]*\*[^`]*`\}/); - expect(source.match(/'); }); diff --git a/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.scss b/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.scss index 85df8e7b60..b23adfbb4b 100644 --- a/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.scss +++ b/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.scss @@ -603,10 +603,27 @@ } } + &__context-window-row.openbitfun-config-page-row { + row-gap: var(--openbitfun-space-1); + + > .openbitfun-config-page-row__meta { + display: contents; + + > .openbitfun-config-page-row__description { + grid-column: 1 / -1; + order: 1; + max-inline-size: none; + margin: 0; + } + } + } + &__context-window-warning { + display: flex; grid-column: 1 / -1; + gap: var(--openbitfun-space-1); width: 100%; - font-size: var(--openbitfun-type-label-sm-font-size); + font-size: var(--openbitfun-type-meta-font-size); line-height: var(--openbitfun-type-meta-line-height); } @@ -1167,6 +1184,7 @@ margin: 0 auto; &--modal { + container: model-editor / inline-size; max-width: 100%; border: none; border-radius: 0; @@ -1176,11 +1194,19 @@ flex-direction: column; gap: 0; - .openbitfun-model-settings__edit-section { + .openbitfun-model-settings__edit-section:not(.openbitfun-model-settings__connection-section) { .openbitfun-config-page-row:not(.openbitfun-config-page-row--multiline):not(.openbitfun-model-settings__toggle-row), .openbitfun-config-page-row--multiline.openbitfun-config-page-row--wide { - grid-template-columns: minmax(0, 2fr) minmax(0, 8fr) !important; + grid-template-columns: 96px minmax(0, 1fr) !important; gap: var(--openbitfun-space-4); + + &.openbitfun-model-settings__context-window-row { + row-gap: var(--openbitfun-space-1); + } + + &.openbitfun-model-settings__model-selection-row { + row-gap: var(--openbitfun-space-2); + } } } } @@ -1197,7 +1223,7 @@ } .openbitfun-config-page-row--multiline.openbitfun-config-page-row--wide { - grid-template-columns: minmax(0, 2fr) minmax(0, 8fr) !important; + grid-template-columns: 96px minmax(0, 1fr) !important; gap: var(--openbitfun-space-4); } @@ -1240,6 +1266,164 @@ } } + &__connection-section > .openbitfun-config-page-section__body { + display: grid; + grid-template-columns: 112px minmax(0, 1.6fr) 96px minmax(0, 1fr); + column-gap: var(--openbitfun-space-4); + } + + &__connection-section &__connection-row, + &__connection-section &__endpoint-row { + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; + column-gap: var(--openbitfun-space-4); + min-width: 0; + padding: var(--openbitfun-space-3) var(--openbitfun-space-4); + } + + &__connection-row > .openbitfun-config-page-row { + grid-column: span 2; + grid-template-columns: subgrid; + column-gap: var(--openbitfun-space-4); + min-width: 0; + padding: 0; + border-top: 0; + } + + &__connection-row > .openbitfun-config-page-row + .openbitfun-config-page-row { + border-top: 0; + } + + @media (hover: hover) and (pointer: fine) { + @container model-editor (width > 560px) { + &__connection-row:hover { + background: var(--openbitfun-component-config-page-row-hover-background); + + > .openbitfun-config-page-row:hover { + background: transparent; + } + } + } + } + + &__connection-row--single > .openbitfun-config-page-row { + grid-column: 1 / -1; + } + + &__connection-row--single > .openbitfun-config-page-row, + &__connection-section &__endpoint-row { + > .openbitfun-config-page-row__control { + grid-column: 2 / -1; + } + } + + &__connection-row + &__connection-row, + &__connection-row + &__endpoint-row { + border-top: var(--openbitfun-border-width-default) solid var(--openbitfun-component-config-page-divider); + } + + &__connection-value { + color: var(--openbitfun-color-content-secondary); + font-size: var(--openbitfun-type-body-sm-font-size); + line-height: var(--openbitfun-type-body-sm-line-height); + } + + &__provider-identity { + display: flex; + align-items: center; + gap: var(--openbitfun-space-2); + min-width: 0; + + > [data-openbitfun-component='status-pill'] { + flex: 0 0 auto; + } + } + + &__connection-row--single &__provider-identity { + min-block-size: var(--openbitfun-control-height-sm); + } + + &__connection-section &__subscription-account-row { + row-gap: var(--openbitfun-space-1); + } + + &__subscription-account { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: var(--openbitfun-space-4); + } + + &__subscription-account-heading { + display: flex; + align-items: center; + gap: var(--openbitfun-space-2); + min-width: 0; + + > [data-openbitfun-component='status-pill'] { + flex: 0 0 auto; + } + } + + &__subscription-account-name { + min-width: 0; + max-width: 100%; + color: var(--openbitfun-color-content-primary); + font-size: var(--openbitfun-type-body-sm-font-size); + line-height: var(--openbitfun-type-body-sm-line-height); + } + + &__subscription-account-hint { + grid-column: 2 / -1; + min-width: 0; + color: var(--openbitfun-color-content-secondary); + font-size: var(--openbitfun-type-support-font-size); + line-height: var(--openbitfun-type-support-line-height); + overflow-wrap: anywhere; + } + + &__subscription-account-actions { + display: flex; + align-items: center; + gap: var(--openbitfun-space-2); + } + + &__subscription-management { + padding-inline: var(--openbitfun-space-6); + } + + &__endpoint-row, + &__model-selection-row { + > .openbitfun-config-page-row__meta { + display: flex; + align-items: center; + min-block-size: var(--openbitfun-control-height-sm); + } + } + + &__endpoint-inputs { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 2.2fr); + gap: var(--openbitfun-space-3); + min-width: 0; + + > * { + width: 100%; + min-width: 0; + } + + > :only-child { + grid-column: 1 / -1; + } + } + + &__advanced-settings { + margin: 0 var(--openbitfun-space-4) var(--openbitfun-space-3); + padding-top: var(--openbitfun-space-2); + border-top: var(--openbitfun-border-width-default) solid var(--openbitfun-component-config-page-divider); + } + &__input-visibility-toggle { inline-size: auto; block-size: auto; @@ -1324,8 +1508,7 @@ min-width: 0; } - .resolved-url__value, - .openbitfun-model-settings__selected-models-empty { + .resolved-url__value { font-size: var(--openbitfun-type-label-md-font-size); font-family: var(--openbitfun-type-body-sm-font-family); line-height: var(--openbitfun-type-body-sm-line-height); @@ -1341,244 +1524,158 @@ &__resolved-url-value { user-select: text; + overflow-wrap: anywhere; } &__model-picker-row { - display: grid; - grid-template-columns: subgrid; - gap: var(--openbitfun-space-2); - width: 100%; - align-items: start; - } - - &__model-picker-value { - display: flex; + display: inline-flex; align-items: center; - gap: var(--openbitfun-space-2); min-width: 0; - width: 100%; - color: var(--openbitfun-color-content-primary); - } - - &__model-picker-value-text { - min-width: 0; - flex: 1; - overflow: hidden; - white-space: nowrap; + max-width: 100%; } - &__model-picker-value-more { - flex-shrink: 0; - color: var(--openbitfun-color-content-secondary); - white-space: nowrap; + &__model-discovery-dialog { + block-size: min(420px, calc(100vh - 2 * var(--openbitfun-overlay-dialog-viewport-gutter))); } - &__manual-model-entry { - display: grid; - grid-template-columns: subgrid; - gap: var(--openbitfun-space-2); - width: 100%; - align-items: start; + &__model-discovery-body { + gap: var(--openbitfun-space-3); + overflow: hidden; - > .openbitfun-model-settings__manual-model-add { - width: auto; - align-self: stretch; + > .openbitfun-model-settings__model-fetch-hint, + > [data-openbitfun-component='search-field'] { + flex-shrink: 0; } } - &__selected-models-list { - display: flex; - flex-direction: column; - gap: var(--openbitfun-space-2); - } - - &__selected-models-empty { - display: flex; - align-items: center; - width: 100%; - min-height: 35px; - padding: 0 12px; - border: none; - border-radius: var(--openbitfun-radius-sm); - color: var(--openbitfun-color-content-muted); - font-size: var(--openbitfun-type-label-md-font-size); - background: var(--openbitfun-color-surface-raised); - box-sizing: border-box; + &__model-discovery-results { + flex: 1 1 auto; + // Leave room for capsule focus outlines within the scrolling viewport. + padding: var(--openbitfun-space-1); } - &__selected-model-row { + &__model-discovery-list { display: flex; - flex-direction: column; - overflow: hidden; - border: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); - border-radius: var(--openbitfun-layout-field-group-radius); - background: var(--openbitfun-color-surface-tertiary); - font-family: var(--openbitfun-type-body-sm-font-family); - } - - &__selected-model-head { - padding: var(--openbitfun-space-3) var(--openbitfun-space-4); - - &--toggleable { - cursor: pointer; - user-select: none; - - &:hover { - background: var(--openbitfun-color-action-neutral-surface-hover); - } + flex-wrap: wrap; + align-items: flex-start; + gap: var(--openbitfun-space-2); - &:focus-visible { - outline: var(--openbitfun-focus-width) solid var(--openbitfun-color-focus-ring); - outline-offset: calc(-1 * var(--openbitfun-focus-width)); - } + > [data-openbitfun-component='button'] { + max-inline-size: 100%; } } - &__selected-model-head-title, - &__selected-model-head-top { + &__model-selection-actions { display: flex; + flex-wrap: wrap; align-items: center; + justify-content: flex-end; gap: var(--openbitfun-space-2); - min-width: 0; + min-block-size: var(--openbitfun-control-height-sm); } - &__selected-model-head-title { - justify-content: space-between; + &__manual-model-input { + flex: 0 1 24ch; + width: 24ch; + min-width: 0; + max-width: 100%; } - &__selected-model-head-top { - flex: 1; + &__selected-models-list { + display: flex; flex-wrap: wrap; - } - - &__selected-model-head-bottom { + align-items: flex-start; + grid-column: 1 / -1; min-width: 0; - margin-top: var(--openbitfun-space-1); - padding-left: calc(20px + var(--openbitfun-space-2)); + gap: var(--openbitfun-space-2); } - &__selected-model-toggle { - display: inline-flex; - align-items: center; - justify-content: center; - width: 20px; - flex-shrink: 0; + &__selected-models-empty { color: var(--openbitfun-color-content-muted); + font-size: var(--openbitfun-type-support-font-size); + line-height: var(--openbitfun-type-support-line-height); } - &__selected-model-name { + &__model-capsule { + position: relative; + display: inline-flex; + flex: 0 1 auto; + align-items: center; + width: auto; min-width: 0; - font-size: var(--openbitfun-type-label-md-font-size); - font-weight: var(--openbitfun-type-label-selected-font-weight); - color: var(--openbitfun-color-content-primary); - line-height: var(--openbitfun-type-label-md-line-height); - overflow-wrap: anywhere; - } - - &__selected-model-unsaved { - flex-shrink: 0; - } + max-width: 100%; - &__selected-model-summary { - display: block; - font-size: var(--openbitfun-type-meta-font-size); - color: var(--openbitfun-color-content-muted); - white-space: normal; - line-height: var(--openbitfun-type-meta-line-height); - font-family: var(--openbitfun-type-body-sm-font-family); - } + &:hover, + &:focus-within { + .openbitfun-model-settings__model-capsule-dismiss { + opacity: 1; + pointer-events: auto; + } - &__selected-model-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--openbitfun-space-4); - padding: var(--openbitfun-space-4); - border-top: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); + .openbitfun-model-settings__model-capsule-edit > [data-openbitfun-part='content'] { + clip-path: inset(0 calc(var(--openbitfun-control-icon-button-xs-size) - var(--openbitfun-control-button-xs-padding-inline)) 0 0); + } + } } - &__selected-model-grid--with-format { - grid-template-columns: repeat(3, minmax(0, 1fr)); + &__model-capsule-edit { + flex: 1 1 auto; + min-width: 0; } - &__selected-model-route, - &__selected-model-tags { - grid-column: 1 / -1; + &__model-capsule-dismiss { + position: absolute; + inset-block-start: 50%; + inset-inline-end: var(--openbitfun-border-width-default); + transform: translateY(-50%); + opacity: 0; + pointer-events: none; + transition: opacity var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); } &__selected-model-tags { display: grid; + grid-column: 1 / -1; gap: var(--openbitfun-space-4); min-width: 0; } - &__selected-model-field { + &__tag-editor { + display: flex; + align-items: center; + gap: var(--openbitfun-space-2); + min-block-size: var(--openbitfun-control-height-sm); min-width: 0; - align-content: start; - - [data-openbitfun-part='label-row'] { - min-height: var(--openbitfun-space-6); - } + width: 100%; } - &__reasoning-summary { - grid-column: 1 / -1; + &__tag-list { display: flex; + flex: 1 1 auto; + flex-wrap: wrap; + justify-content: flex-end; align-items: center; - gap: var(--openbitfun-space-3); + gap: var(--openbitfun-space-2); min-width: 0; - padding: var(--openbitfun-space-3) 0 0; - border: 0; - border-top: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); - border-radius: 0; - background: transparent; - color: var(--openbitfun-color-content-primary); - font: inherit; - text-align: left; - cursor: pointer; - &:hover .openbitfun-model-settings__reasoning-summary-action { - color: var(--openbitfun-color-accent-default); + .openbitfun-model-settings__model-capsule { + min-inline-size: min(100%, calc(4 * var(--openbitfun-control-icon-button-xs-size))); } - &:focus-visible { - outline: var(--openbitfun-focus-width) solid var(--openbitfun-color-focus-ring); - outline-offset: var(--openbitfun-focus-offset); + .openbitfun-model-settings__model-capsule-edit > [data-openbitfun-part='content'] { + flex: 1 1 auto; + justify-content: center; } } - &__reasoning-summary-icon { - display: inline-flex; - flex-shrink: 0; - align-items: center; - color: var(--openbitfun-color-content-secondary); + &__tag-add { + flex: 0 0 auto; } - &__reasoning-summary-content { - display: flex; - flex: 1; - flex-direction: column; - gap: var(--openbitfun-space-1); + &__tag-input { + width: 14ch; min-width: 0; - overflow-wrap: anywhere; - - strong { - color: var(--openbitfun-color-content-primary); - font-size: var(--openbitfun-type-label-md-font-size); - font-weight: var(--openbitfun-type-label-selected-font-weight); - line-height: var(--openbitfun-type-label-md-line-height); - } - - span { - color: var(--openbitfun-color-content-muted); - font-size: var(--openbitfun-type-label-sm-font-size); - line-height: var(--openbitfun-type-body-sm-line-height); - } - } - - &__reasoning-summary-action { - flex-shrink: 0; - color: var(--openbitfun-color-content-secondary); - font-size: var(--openbitfun-type-label-sm-font-size); + max-width: 100%; } &__model-fetch-hint { @@ -1589,14 +1686,11 @@ line-height: var(--openbitfun-type-body-sm-line-height); } - &__model-selection-row > .openbitfun-config-page-row__control > .openbitfun-model-settings__control-stack { - display: grid; - grid-template-columns: minmax(0, 1fr) max-content; - gap: var(--openbitfun-space-3) var(--openbitfun-space-2); + &__model-selection-row > .openbitfun-config-page-row__control { + display: contents; - > * { - grid-column: 1 / -1; - min-width: 0; + > .openbitfun-model-settings__control-stack { + display: contents; } } @@ -1632,18 +1726,52 @@ } &__editor-dialog { - max-block-size: min( + block-size: min( 640px, calc(100vh - 2 * var(--openbitfun-overlay-dialog-viewport-gutter)) ); } + &__editor-heading-group { + flex: 1 1 auto; + } + + &__model-editor-header { + align-items: center; + + [data-openbitfun-part='heading'] { + justify-content: center; + min-block-size: var(--openbitfun-control-icon-button-xs-size); + } + + [data-openbitfun-part='title'] { + color: var(--openbitfun-color-content-primary); + font-family: var(--openbitfun-type-label-selected-font-family); + font-size: var(--openbitfun-type-label-selected-font-size); + font-weight: var(--openbitfun-type-label-selected-font-weight); + line-height: var(--openbitfun-type-action-row-line-height); + letter-spacing: var(--openbitfun-type-label-selected-letter-spacing); + } + } + &__form--modal { display: flex; flex-direction: column; flex: 0 0 auto; } + &__form--single-model { + container-name: config-panel; + + .openbitfun-model-settings__form-content { + gap: var(--openbitfun-space-4); + } + } + + &__single-model-advanced-content { + padding-inline: 0; + } + &__form-content { padding-inline: var(--openbitfun-space-6); display: flex; @@ -2228,10 +2356,6 @@ padding-top: var(--openbitfun-space-3); } - &__selected-model-grid { - grid-template-columns: 1fr; - } - &__proxy-panel { gap: var(--openbitfun-space-3); @@ -2432,6 +2556,7 @@ } .openbitfun-model-settings__model-actions, + .openbitfun-model-settings__tag-editor .openbitfun-model-settings__model-capsule-dismiss, .openbitfun-model-settings__pool-card-controls, .openbitfun-model-settings__provider-group-list .openbitfun-collection-item__details-toggle { transition: none; @@ -2439,6 +2564,17 @@ } @media (hover: none), (pointer: coarse) { + .openbitfun-model-settings__tag-editor .openbitfun-model-settings__model-capsule-dismiss { + position: static; + transform: none; + opacity: 1; + pointer-events: auto; + } + + .openbitfun-model-settings__tag-editor .openbitfun-model-settings__model-capsule-edit > [data-openbitfun-part='content'] { + clip-path: none; + } + .openbitfun-model-settings__pool-card-controls { opacity: 1; pointer-events: auto; @@ -2467,8 +2603,8 @@ } } -@media (max-width: 640px) { - .openbitfun-model-settings__form--modal .openbitfun-model-settings__edit-section { +@container model-editor (max-width: 560px) { + .openbitfun-model-settings__form--modal .openbitfun-model-settings__edit-section:not(.openbitfun-model-settings__connection-section) { .openbitfun-config-page-row:not(.openbitfun-config-page-row--multiline):not(.openbitfun-model-settings__toggle-row), .openbitfun-config-page-row--multiline.openbitfun-config-page-row--wide { grid-template-columns: minmax(0, 1fr) !important; @@ -2479,11 +2615,65 @@ grid-column: 1; } } -} -@media (max-width: 640px) { - .openbitfun-model-settings__selected-model-grid, - .openbitfun-model-settings__model-selection-row > .openbitfun-config-page-row__control > .openbitfun-model-settings__control-stack { + .openbitfun-model-settings__form-content { + padding-inline: var(--openbitfun-space-4); + } + + .openbitfun-model-settings__connection-section > .openbitfun-config-page-section__body { + grid-template-columns: minmax(0, 1fr); + } + + .openbitfun-model-settings__connection-row > .openbitfun-config-page-row { + grid-column: 1 / -1; + padding-block: var(--openbitfun-space-3); + } + + .openbitfun-model-settings__connection-row--single > .openbitfun-config-page-row, + .openbitfun-model-settings__connection-section .openbitfun-model-settings__endpoint-row { + > .openbitfun-config-page-row__control { + grid-column: 1; + } + } + + .openbitfun-model-settings__form--modal .openbitfun-model-settings__edit-section .openbitfun-config-page-row { + row-gap: var(--openbitfun-space-2); + } + + .openbitfun-model-settings__connection-section .openbitfun-model-settings__connection-row { + padding-block: 0; + } + + .openbitfun-model-settings__connection-row > .openbitfun-config-page-row + .openbitfun-config-page-row { + border-top: var(--openbitfun-border-width-default) solid var(--openbitfun-component-config-page-divider); + } + + .openbitfun-model-settings__endpoint-inputs { + grid-template-columns: minmax(0, 1fr); + } + + .openbitfun-model-settings__resolved-url { + flex-wrap: wrap; + } + + .openbitfun-model-settings__subscription-account { grid-template-columns: minmax(0, 1fr); + gap: var(--openbitfun-space-3); + } + + .openbitfun-model-settings__connection-section .openbitfun-model-settings__subscription-account-row { + padding-block-end: var(--openbitfun-space-3); + + > .openbitfun-config-page-row { + padding-block-end: 0; + } + } + + .openbitfun-model-settings__subscription-account-hint { + grid-column: 1 / -1; + } + + .openbitfun-model-settings__subscription-account-actions { + justify-content: flex-end; } } diff --git a/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.tsx b/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.tsx index f2c50caf2f..076e40bbec 100644 --- a/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.tsx +++ b/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.tsx @@ -1,4 +1,5 @@ import '@/app/scenes/settings/pages/ai/ModelSettingsPage.scss'; +import { ModelDiscoveryPicker, type ModelDiscoveryOption } from './ModelDiscoveryPicker'; import { ModelPoolCardActions } from './ModelPoolCardActions'; import { aiApi, systemAPI } from '@/infrastructure/api'; import type { @@ -24,8 +25,14 @@ import { stableJson, } from '@/infrastructure/config/components/modelConnectionTestPlan'; import { ModelDiscoveryCoordinator } from '@/infrastructure/config/components/modelDiscoveryCoordinator'; +import { + getModelEditorRequestSettings, + updateModelEditorRequestSettings, + type ModelEditorRequestSettings, +} from '@/infrastructure/config/components/modelEditorRequestSettings'; import { isOpenCodeApiKeyConfig, isOpenCodeZenOAuth, openCodeZenModels, resolveOpenCodeModelRoute, savedOpenCodeApiKeyRoute } from '@/infrastructure/config/components/openCodeApiKeyRouting'; import ReasoningConfigPanel, { type ReasoningConfigApplyResult } from '@/infrastructure/config/components/ReasoningConfigPanel'; +import { getSubscriptionAccountState } from '@/infrastructure/config/components/subscriptionAccountState'; import { preferredSubscriptionLoginMethod, settleSubscriptionLoginStart, @@ -87,16 +94,16 @@ import { Dialog, DialogBody, DialogClose, + DialogDescription, DialogFooter, DialogHeader, DialogHeading, DialogTitle, Disclosure, - Field, + FieldGroup, Icon, IconButton, Input, - MultiSelect, NumberInput, OverflowText, ScrollArea, @@ -105,6 +112,7 @@ import { StatusPill, Switch, Textarea, + ToolbarGroup, Tooltip, type ComboboxOption, } from '@openbitfun/ui'; @@ -130,10 +138,12 @@ interface SelectedModelDraft { manualRequestFormat?: string; category: ModelCategory; contextWindow: number; + contextWindowEdited?: boolean; maxTokens?: number; /** Undefined preserves the latest saved tags until the user edits this model. */ userTags?: string[]; reasoning: ReasoningConfig; + requestSettings: ModelEditorRequestSettings; reasoningProjectionCatalog?: ReasoningCatalogBinding; reasoningProjectionSnapshot?: { catalog: ReasoningCatalogBinding; @@ -212,7 +222,8 @@ function createModelDraft( const reasoning = overrides?.reasoning ?? canonicalReasoningConfig(baseConfig as AIModelConfigType); return { - key: overrides?.key ?? overrides?.configId ?? baseConfig?.id ?? trimmedModelName, + key: overrides?.key ?? overrides?.configId ?? baseConfig?.id + ?? `model-draft:${Date.now()}:${Math.random().toString(36).slice(2, 9)}`, configId: overrides?.configId ?? baseConfig?.id, modelName: trimmedModelName, manualRequestFormat: overrides?.manualRequestFormat ?? baseConfig?.provider, @@ -220,6 +231,7 @@ function createModelDraft( contextWindow: overrides?.contextWindow ?? baseConfig?.context_window ?? 300000, maxTokens: overrides?.maxTokens ?? baseConfig?.max_tokens, reasoning, + requestSettings: overrides?.requestSettings ?? getModelEditorRequestSettings(baseConfig), reasoningProjectionCatalog: overrides?.reasoningProjectionCatalog ?? reasoning.catalog, }; } @@ -264,24 +276,6 @@ function normalizeProviderModelNameList( return out; } -/** Compact display for context/output token counts (e.g. 200000 -> "200K", 1000000 -> "1M"). */ -function formatTokenCountShort(n: number): string { - if (!Number.isFinite(n) || n < 0) { - return String(n); - } - if (n >= 1_000_000) { - const m = n / 1_000_000; - const s = m % 1 === 0 ? `${m}` : m.toFixed(1).replace(/\.0$/, ''); - return `${s}M`; - } - if (n >= 1_000) { - const k = n / 1_000; - const s = k % 1 === 0 ? `${k}` : k.toFixed(1).replace(/\.0$/, ''); - return `${s}K`; - } - return String(n); -} - function parseOptionalPositiveIntegerInput(value: string): number | null | undefined { const trimmed = value.trim(); if (trimmed === '') { @@ -392,6 +386,7 @@ function modelDraftHasUnsavedChanges( draft.contextWindow !== (persisted.context_window || 300000) || draft.maxTokens !== persisted.max_tokens || stableJson(draft.reasoning) !== stableJson(canonicalReasoningConfig(persisted)) || + stableJson(draft.requestSettings) !== stableJson(getModelEditorRequestSettings(persisted)) || (draft.userTags !== undefined && stableJson(draft.userTags) !== stableJson(getModelUserTags(persisted))) ); } @@ -470,6 +465,7 @@ const ModelSettingsPage: React.FC = () => { const [isFetchingRemoteModels, setIsFetchingRemoteModels] = useState(false); const [remoteModelsError, setRemoteModelsError] = useState(null); const [hasAttemptedRemoteFetch, setHasAttemptedRemoteFetch] = useState(false); + const [modelPickerOpen, setModelPickerOpen] = useState(false); const [selectedModelDrafts, setSelectedModelDrafts] = useState([]); const [showModelValidation, setShowModelValidation] = useState(false); useEffect(() => { setShowModelValidation(false); }, [editingTargetKey, isEditing]); @@ -481,13 +477,59 @@ const ModelSettingsPage: React.FC = () => { }; const [editingProviderModelIds, setEditingProviderModelIds] = useState>(new Set()); + const providerIdentityLocked = !!editingConfig?.id || editingProviderModelIds.size > 0; + const configuredProvider = aiModels.find(model => editingConfig?.id + ? model.id === editingConfig.id + : editingProviderModelIds.has(model.id || '')); const [manualModelInput, setManualModelInput] = useState(''); - const [expandedModelCards, setExpandedModelCards] = useState>(new Set()); + const [isAddingCustomModel, setIsAddingCustomModel] = useState(false); + const manualModelInputActiveRef = React.useRef(false); + const manualModelAddButtonRef = React.useRef(null); + const [managingSubscriptionProvider, setManagingSubscriptionProvider] = useState(null); + const subscriptionManageButtonRef = React.useRef(null); + const subscriptionManagementRef = React.useRef(null); + const returnSubscriptionFocusRef = React.useRef(false); + const [modelPanelDraftKey, setModelPanelDraftKey] = useState(null); + const modelPanelInitialRef = React.useRef<{ draft: SelectedModelDraft; advancedOpen: boolean } | null>(null); + const modelCapsuleRefs = React.useRef(new Map()); + const returnModelFocusKeyRef = React.useRef(null); + const modelPanelDraft = modelPanelDraftKey + ? selectedModelDrafts.find(draft => draft.key === modelPanelDraftKey) + : undefined; + const singleModelEditorDraft = modelPanelDraft || (editingConfig?.id ? selectedModelDrafts[0] : undefined); const [reasoningPanelDraftKey, setReasoningPanelDraftKey] = useState(null); const reasoningPanelInitialRef = React.useRef | null>(null); + const resetEditorPanels = useCallback(() => { + setModelPickerOpen(false); + manualModelInputActiveRef.current = false; + setIsAddingCustomModel(false); + setManualModelInput(''); + setManagingSubscriptionProvider(null); + returnSubscriptionFocusRef.current = false; + setModelPanelDraftKey(null); + modelPanelInitialRef.current = null; + returnModelFocusKeyRef.current = null; + setReasoningPanelDraftKey(null); + reasoningPanelInitialRef.current = null; + }, []); + useEffect(() => { + const draftKey = returnModelFocusKeyRef.current; + if (isEditing && !modelPanelDraftKey && !reasoningPanelDraftKey && draftKey) { + modelCapsuleRefs.current.get(draftKey)?.focus(); + returnModelFocusKeyRef.current = null; + } + }, [isEditing, modelPanelDraftKey, reasoningPanelDraftKey]); + useEffect(() => { + if (isEditing && managingSubscriptionProvider) { + subscriptionManagementRef.current?.focus(); + } else if (isEditing && returnSubscriptionFocusRef.current) { + subscriptionManageButtonRef.current?.focus(); + returnSubscriptionFocusRef.current = false; + } + }, [isEditing, managingSubscriptionProvider]); const [subscriptionAccounts, setSubscriptionAccounts] = useState([]); const subscriptionRefreshesRef = React.useRef(new Set()); const [refreshingSubscriptionProviders, setRefreshingSubscriptionProviders] = useState>(new Set()); @@ -528,14 +570,6 @@ const ModelSettingsPage: React.FC = () => { [t] ); - const categoryCompactLabels = useMemo>( - () => ({ - general_chat: t('categoryIcons.general_chat'), - multimodal: t('categoryIcons.multimodal'), - speech_recognition: t('categoryIcons.speech_recognition'), - }), - [t] - ); const parsedStreamIdleTimeout = useMemo( () => parseOptionalPositiveIntegerInput(streamIdleTimeoutInput), [streamIdleTimeoutInput] @@ -774,6 +808,7 @@ const ModelSettingsPage: React.FC = () => { const editingModalHasUnsavedChanges = useMemo(() => { if (!editingConfig) return false; + if (isAddingCustomModel && manualModelInput.trim()) return true; const persistedModels = editingConfig.id ? aiModels.filter(model => model.id === editingConfig.id) : aiModels.filter(model => editingProviderModelIds.has(model.id || '')); @@ -790,7 +825,7 @@ const ModelSettingsPage: React.FC = () => { || Array.from(persistedIds).some(id => !draftIds.has(id)); return providerFieldsChanged || draftFieldsChanged || selectionChanged; - }, [aiModels, editingConfig, editingProviderModelIds, selectedModelDrafts]); + }, [aiModels, editingConfig, editingProviderModelIds, isAddingCustomModel, manualModelInput, selectedModelDrafts]); const createDraftsFromConfigs = (configs: AIModelConfigType[]) => ( configs.map(config => createModelDraft(config.model_name, config, { @@ -802,6 +837,7 @@ const ModelSettingsPage: React.FC = () => { ); const resetRemoteModelDiscovery = useCallback(() => { + setModelPickerOpen(false); setRemoteModelOptions([]); setIsFetchingRemoteModels(false); setRemoteModelsError(null); @@ -868,7 +904,7 @@ const ModelSettingsPage: React.FC = () => { ...existingDraft, modelName, configId, - key: configId ?? modelName, + key: existingDraft.key, }; } @@ -911,9 +947,9 @@ const ModelSettingsPage: React.FC = () => { }); }; - const updateModelDraft = (modelName: string, updates: Partial) => { + const updateModelDraft = (draftKey: string, updates: Partial) => { setSelectedModelDrafts(prevDrafts => prevDrafts.map(draft => ( - draft.modelName === modelName ? { ...draft, ...updates } : draft + draft.key === draftKey ? { ...draft, ...updates } : draft ))); }; @@ -940,34 +976,60 @@ const ModelSettingsPage: React.FC = () => { return undefined; }; - const toggleSelectedModelCardExpanded = useCallback((draftKey: string) => { - setExpandedModelCards(prev => { - const next = new Set(prev); - if (next.has(draftKey)) next.delete(draftKey); - else next.add(draftKey); - return next; - }); - }, []); + const openModelPanel = (draft: SelectedModelDraft) => { + modelPanelInitialRef.current = { + draft, + advancedOpen: showAdvancedSettings, + }; + setModelPanelDraftKey(draft.key); + setShowModelValidation(false); + const settings = draft.requestSettings; + setShowAdvancedSettings( + !!settings.skip_ssl_verify + || settings.custom_request_body_mode === 'trim' + || !!settings.custom_request_body?.trim() + || Object.keys(settings.custom_headers || {}).length > 0, + ); + }; - const onSelectedModelHeadKeyDown = useCallback( - (e: React.KeyboardEvent, draftKey: string) => { - if (e.key !== 'Enter' && e.key !== ' ') return; - e.preventDefault(); - toggleSelectedModelCardExpanded(draftKey); - }, - [toggleSelectedModelCardExpanded] - ); + const returnToProviderEditor = () => { + returnModelFocusKeyRef.current = modelPanelDraftKey; + setShowAdvancedSettings(modelPanelInitialRef.current?.advancedOpen ?? false); + setModelPanelDraftKey(null); + modelPanelInitialRef.current = null; + setShowModelValidation(false); + }; - const removeSelectedModelDraft = (modelName: string) => { - const removed = selectedModelDrafts.find(d => d.modelName === modelName); - if (removed) { - setExpandedModelCards(prev => { - const next = new Set(prev); - next.delete(removed.key); - return next; - }); + const finishModelPanel = () => { + if (!modelPanelDraft) return; + setShowModelValidation(true); + const modelName = modelPanelDraft.modelName.trim(); + if (!modelName) { + notification.warning(t('messages.missingFields', { fields: t('form.modelName') })); + return; + } + if (selectedModelDrafts.some(draft => draft.key !== modelPanelDraft.key + && modelNameLookupKey(draft.modelName) === modelNameLookupKey(modelName))) { + notification.warning(t('messages.duplicateModelNameUnderProvider')); + return; } + if (modelPanelDraft.contextWindow < 32000) { + notification.warning(t('messages.contextWindowTooSmall')); + return; + } + updateModelDraft(modelPanelDraft.key, { modelName }); + returnToProviderEditor(); + }; + + const cancelModelPanel = () => { + const initial = modelPanelInitialRef.current; + if (initial && initial.draft.key === modelPanelDraftKey) { + updateModelDraft(initial.draft.key, initial.draft); + } + returnToProviderEditor(); + }; + const removeSelectedModelDraft = (modelName: string) => { const remainingModelNames = selectedModelDrafts .filter(draft => draft.modelName !== modelName) .map(draft => draft.modelName); @@ -975,7 +1037,23 @@ const ModelSettingsPage: React.FC = () => { syncSelectedModelDrafts(remainingModelNames, editingConfig || undefined, !!editingConfig?.id); }; + const startManualModelDraft = () => { + setModelPickerOpen(false); + setManualModelInput(''); + manualModelInputActiveRef.current = true; + setIsAddingCustomModel(true); + }; + + const cancelManualModelDraft = () => { + manualModelInputActiveRef.current = false; + setIsAddingCustomModel(false); + setManualModelInput(''); + }; + const addManualModelDraft = () => { + if (!manualModelInputActiveRef.current || isEditorSaving) return; + // Enter unmounts the input and may also trigger blur; apply the draft only once. + cancelManualModelDraft(); const trimmedModelName = manualModelInput.trim(); if (!trimmedModelName) return; @@ -985,7 +1063,6 @@ const ModelSettingsPage: React.FC = () => { if (alreadyInDrafts) { notification.info(t('providerSelection.modelAlreadyInList')); - setManualModelInput(''); return; } @@ -997,7 +1074,6 @@ const ModelSettingsPage: React.FC = () => { ]); syncSelectedModelDrafts(nextModelNames, editingConfig || undefined, !!editingConfig?.id); - setManualModelInput(''); }; const buildModelDiscoveryConfig = (config: Partial): AIModelConfigType | null => { @@ -1115,6 +1191,7 @@ const ModelSettingsPage: React.FC = () => { }; const handleModelSelectionOpenChange = (isOpen: boolean) => { + setModelPickerOpen(isOpen); if (!isOpen || !editingConfig || isFetchingRemoteModels) return; const authType = editingConfig.auth?.type ?? 'api_key'; if (authType === 'api_key' && !editingConfig.api_key?.trim()) return; @@ -1137,8 +1214,9 @@ const ModelSettingsPage: React.FC = () => { setDraftConflictConfirmOpen(true); return; } + resetEditorPanels(); open(); - }, [editingConfig, editingModalHasUnsavedChanges, editingTargetKey, isEditing]); + }, [editingConfig, editingModalHasUnsavedChanges, editingTargetKey, isEditing, resetEditorPanels]); const handleCreateNew = () => { requestEditorOpen('new-provider', () => { @@ -1635,6 +1713,7 @@ const ModelSettingsPage: React.FC = () => { custom_request_body_mode: config.custom_request_body_mode, auth: config.auth || { type: 'api_key' }, }); + if (config.auth?.type === 'subscription') void refreshSubscriptionAccounts(); setSelectedModelDrafts(createDraftsFromConfigs(configuredProviderModels)); setShowAdvancedSettings( !!config.skip_ssl_verify || @@ -1731,6 +1810,17 @@ const ModelSettingsPage: React.FC = () => { if (editorSavingRef.current) return false; if (!editingConfig) return false; + if (providerIdentityLocked && !configuredProvider) { + notification.error(t('messages.providerRemoved')); + return false; + } + // Persist the saved identity, including legacy API-key configs without auth. + // Removing controls alone must not allow a retained draft to migrate a service. + const providerConfig = configuredProvider ? { + ...editingConfig, + name: getProviderDisplayName(configuredProvider), + auth: configuredProvider.auth || { type: 'api_key' as const }, + } : editingConfig; setShowModelValidation(true); const missingFields = [ missingModelFields.name && t('form.configName'), @@ -1749,13 +1839,18 @@ const ModelSettingsPage: React.FC = () => { editorSavingRef.current = true; setIsEditorSaving(true); try { - const providerName = editingConfig.name?.trim() || ''; - const baseUrl = editingConfig.base_url?.trim() || ''; + const providerName = providerConfig.name?.trim() || ''; + const baseUrl = providerConfig.base_url?.trim() || ''; if (!hasHttpUrlScheme(baseUrl)) { notification.warning(t('messages.invalidBaseUrlScheme')); return false; } - const draftsToSave = dedupeSelectedModelDraftsByModelName(selectedModelDrafts); + const draftsToSave = dedupeSelectedModelDraftsByModelName(selectedModelDrafts) + .map(draft => ({ ...draft, modelName: draft.modelName.trim() })); + if (draftsToSave.length !== selectedModelDrafts.length) { + notification.warning(t('messages.duplicateModelNameUnderProvider')); + return false; + } if (draftsToSave.some(draft => draft.contextWindow < 32000)) { notification.warning(t('messages.contextWindowTooSmall')); return false; @@ -1776,8 +1871,8 @@ const ModelSettingsPage: React.FC = () => { notification.warning(t('messages.invalidReasoningPresets')); return false; } - const existingProviderInstanceId = getProviderInstanceId(editingConfig); - const isProviderGroupEdit = !editingConfig.id && editingProviderModelIds.size > 0; + const existingProviderInstanceId = getProviderInstanceId(providerConfig); + const isProviderGroupEdit = !providerConfig.id && editingProviderModelIds.size > 0; const providerInstanceId = existingProviderInstanceId || generateProviderInstanceId(); const providerGroupModelIds = isProviderGroupEdit ? editingProviderModelIds @@ -1788,23 +1883,23 @@ const ModelSettingsPage: React.FC = () => { .filter((id): id is string => Boolean(id)) ); let apiKeyModels = remoteModelOptions; - const zenOAuth = isOpenCodeZenOAuth(editingConfig); - const routeConfig = editingConfig; + const zenOAuth = isOpenCodeZenOAuth(providerConfig); + const routeConfig = providerConfig; if (zenOAuth && apiKeyModels.length === 0) { const scope = getActiveSurfaceScope(); const account = await aiApi.refreshSubscriptionAccount('opencode'); if (!scope.isCurrent()) return false; apiKeyModels = openCodeZenModels(account.api_offerings ?? []); } - if (isOpenCodeApiKeyConfig(editingConfig) && apiKeyModels.length === 0) { + if (isOpenCodeApiKeyConfig(providerConfig) && apiKeyModels.length === 0) { const scope = getActiveSurfaceScope(); - const discoveryConfig = buildModelDiscoveryConfig(editingConfig); + const discoveryConfig = buildModelDiscoveryConfig(providerConfig); if (!discoveryConfig) throw new Error(t('providerSelection.fillApiKeyBeforeFetch')); apiKeyModels = await aiApi.listModelsByConfig(discoveryConfig); if (!scope.isCurrent()) return false; } const configsToSave: AIModelConfigType[] = draftsToSave.map((draft) => { - const id = editingConfig.id + const id = providerConfig.id || draft.configId || allocateModelConfigId(draft.modelName, allocatedConfigIds); allocatedConfigIds.add(id); @@ -1813,13 +1908,13 @@ const ModelSettingsPage: React.FC = () => { && apiKeyModels.some(model => model.id === draft.modelName.trim())) { throw new Error(t('providerSelection.modelRoutingUnavailable')); } - const existingModel = isOpenCodeApiKeyConfig(editingConfig) - ? aiModels.find(model => model.id === (draft.configId || editingConfig.id) + const existingModel = isOpenCodeApiKeyConfig(providerConfig) + ? aiModels.find(model => model.id === (draft.configId || providerConfig.id) && model.model_name === draft.modelName && model.base_url === baseUrl) : undefined; const format = apiKeyRoute?.format || (isOpenCodeApiKeyConfig(routeConfig) ? draft.manualRequestFormat : undefined) - || existingModel?.provider || editingConfig.provider || 'openai'; + || existingModel?.provider || providerConfig.provider || 'openai'; const modelBaseUrl = apiKeyRoute?.base_url || baseUrl; return { id, @@ -1830,10 +1925,10 @@ const ModelSettingsPage: React.FC = () => { format, draft.modelName ), - api_key: editingConfig.api_key || '', + api_key: providerConfig.api_key || '', model_name: draft.modelName, provider: format, - enabled: editingConfig.enabled ?? true, + enabled: providerConfig.enabled ?? true, context_window: draft.contextWindow, max_tokens: draft.maxTokens, category: resolveModelCategory( @@ -1848,29 +1943,24 @@ const ModelSettingsPage: React.FC = () => { format ) ), - recommended_for: editingConfig.recommended_for || [], + recommended_for: providerConfig.recommended_for || [], metadata: { - ...(editingConfig.metadata || {}), + ...(providerConfig.metadata || {}), [PROVIDER_INSTANCE_METADATA_KEY]: providerInstanceId, }, reasoning: draft.reasoning, - inline_think_in_text: editingConfig.inline_think_in_text ?? true, - custom_headers: editingConfig.custom_headers, - custom_headers_mode: editingConfig.custom_headers_mode, - skip_ssl_verify: editingConfig.skip_ssl_verify ?? false, - custom_request_body: editingConfig.custom_request_body, - custom_request_body_mode: editingConfig.custom_request_body_mode, - auth: editingConfig.auth || { type: 'api_key' }, + ...draft.requestSettings, + auth: providerConfig.auth || { type: 'api_key' }, }; }); let previousModelsBeforeSave: AIModelConfigType[] = []; const updatedModels = await configManager.updateConfig('ai.models', current => { previousModelsBeforeSave = current; - if (editingConfig.id) { - if (!current.some(model => model.id === editingConfig.id)) { + if (providerConfig.id) { + if (!current.some(model => model.id === providerConfig.id)) { throw new Error('The model was removed while it was being edited'); } - return current.map(model => model.id === editingConfig.id + return current.map(model => model.id === providerConfig.id ? { ...model, ...preserveModelAnnotations(model, configsToSave[0], draftsToSave[0].userTags) } : model); } @@ -1900,6 +1990,7 @@ const ModelSettingsPage: React.FC = () => { setSelectedProviderId(null); setEditingProviderModelIds(new Set()); setSelectedModelDrafts([]); + resetEditorPanels(); setEditingTargetKey(null); setDraftCloseConfirmOpen(false); setDraftConflictConfirmOpen(false); @@ -1929,8 +2020,15 @@ const ModelSettingsPage: React.FC = () => { } }; + const finishSubscriptionManagement = () => { + if (loggingInProvider) return; + returnSubscriptionFocusRef.current = true; + setManagingSubscriptionProvider(null); + }; + const closeEditingModal = () => { resetRemoteModelDiscovery(); + resetEditorPanels(); setSelectedModelDrafts([]); setEditingProviderModelIds(new Set()); setManualModelInput(''); @@ -1942,11 +2040,9 @@ const ModelSettingsPage: React.FC = () => { setEditingTargetKey(null); setProviderQuery(''); setShowAllProviders(false); - setReasoningPanelDraftKey(null); setDraftCloseConfirmOpen(false); setDraftConflictConfirmOpen(false); pendingEditorOpenRef.current = null; - reasoningPanelInitialRef.current = null; }; const inspectModelReferenceCount = async (modelIds: string[]): Promise => { @@ -2488,7 +2584,15 @@ const ModelSettingsPage: React.FC = () => { const zenOAuth = isOpenCodeZenOAuth(editingConfig); const routeConfig = editingConfig; const automaticOpenCodeRouting = zenOAuth || isOpenCodeApiKeyConfig(routeConfig); - const isProviderScopedEditing = !editingConfig.id; + const isProviderScopedEditing = !editingConfig.id && !modelPanelDraft; + const requestSettings = singleModelEditorDraft?.requestSettings + ?? getModelEditorRequestSettings(editingConfig); + const updateRequestSettings = (updates: Partial) => { + setSelectedModelDrafts(drafts => updateModelEditorRequestSettings(drafts, updates, singleModelEditorDraft?.key)); + if (!modelPanelDraft) { + setEditingConfig(previous => previous ? { ...previous, ...updates } : previous); + } + }; const catalogProvider = selectedProviderId ? modelCatalog?.provider_catalog?.providers.find(provider => provider.id === selectedProviderId) : undefined; @@ -2507,7 +2611,7 @@ const ModelSettingsPage: React.FC = () => { || normalizedEndpoint.startsWith(`${normalizedEditingBaseUrl}/`) || normalizedEditingBaseUrl.startsWith(`${normalizedEndpoint}/`); })?.id; - const catalogModelOptions: ComboboxOption[] = (catalogProvider?.models || []) + const catalogModelOptions: ModelDiscoveryOption[] = (catalogProvider?.models || []) .filter(model => ( !selectedEndpointId || !model.endpoint_ids?.length @@ -2517,23 +2621,13 @@ const ModelSettingsPage: React.FC = () => { label: model.display_name || model.id, value: model.id, description: model.display_name && model.display_name !== model.id ? model.id : undefined, - testId: 'settings-model-option', - testAttributes: { - 'data-model-id': model.id, - 'data-model-name': model.id, - 'data-model-source': model.source, - }, + source: model.source, })); - const fetchedOrPresetModelOptions: ComboboxOption[] = remoteModelOptions.length > 0 + const fetchedOrPresetModelOptions: ModelDiscoveryOption[] = remoteModelOptions.length > 0 ? remoteModelOptions.map(model => ({ label: model.display_name || model.id, value: model.id, description: model.display_name && model.display_name !== model.id ? model.id : undefined, - testId: 'settings-model-option', - testAttributes: { - 'data-model-id': model.id, - 'data-model-name': model.id, - }, })) : editingConfig.auth?.type === 'subscription' ? [] @@ -2542,27 +2636,7 @@ const ModelSettingsPage: React.FC = () => { : (currentTemplate?.models || []).map(model => ({ label: model, value: model, - testId: 'settings-model-option', - testAttributes: { - 'data-model-id': model, - 'data-model-name': model, - }, })); - const selectedModelOptions: ComboboxOption[] = selectedModelDrafts.map(draft => ({ - label: draft.modelName, - value: draft.modelName, - testId: 'settings-model-option', - testAttributes: { - 'data-model-id': draft.modelName, - 'data-model-name': draft.modelName, - }, - })); - const availableModelOptions: ComboboxOption[] = Array.from( - new Map( - [...fetchedOrPresetModelOptions, ...selectedModelOptions] - .map(option => [String(option.value), option] as const) - ).values() - ); const modelFetchHint = isFetchingRemoteModels ? t('providerSelection.fetchingModels') : remoteModelsError @@ -2618,237 +2692,236 @@ const ModelSettingsPage: React.FC = () => { }); }; - const renderSelectedModelRows = () => { - if (selectedModelDrafts.length === 0) { - return ( -
{ + const labelAction = options.hint ? ( + + } + /> + + ) : undefined; + + return ( + {label}{labelAction} + ) : label} + description={options.description} + className={options.className} + align="center" + > + {control} + + ); + }; + + const renderModelFields = (draft: SelectedModelDraft) => { + const reasoningProjection = resolveDraftReasoningProjection(draft); + const catalogHasModel = remoteModelOptions.some(model => model.id === draft.modelName.trim()); + const canEditManualRoute = remoteModelOptions.length > 0 && !catalogHasModel && !isFetchingRemoteModels; + const savedModelId = draft.configId || editingConfig.id; + const savedModel = savedModelId ? aiModels.find(model => model.id === savedModelId) : undefined; + const modelRoute = resolveOpenCodeModelRoute(routeConfig, draft.modelName, remoteModelOptions) + || (!catalogHasModel && !canEditManualRoute ? savedOpenCodeApiKeyRoute( + { ...routeConfig, id: savedModelId }, + draft.modelName, + savedModel, + ) : undefined); + const manualFormat = draft.manualRequestFormat || editingConfig.provider || 'openai'; + const contextWarning = draft.contextWindowEdited && draft.contextWindow > LONG_CONTEXT_WARNING_THRESHOLD_TOKENS ? ( + + + {t('form.contextWindowLongWarning')} + + ) : undefined; + const editReasoning = () => { + reasoningPanelInitialRef.current = { + key: draft.key, + reasoning: cloneReasoningConfig(draft.reasoning), + reasoningProjectionCatalog: draft.reasoningProjectionCatalog, + reasoningProjectionSnapshot: draft.reasoningProjectionSnapshot, + }; + setReasoningPanelDraftKey(draft.key); + }; + + return ( + <> + {automaticOpenCodeRouting && renderModelField( + t('form.provider'), + modelRoute ? ( + {requestFormatLabelMap[modelRoute.format] || modelRoute.format} + ) : canEditManualRoute ? ( + updateModelDraft(draft.modelName, { manualRequestFormat: String(value) })} - options={requestFormatOptions.filter(option => ['openai', 'responses', 'anthropic'].includes(String(option.value)))} - size="sm" - /> - ) : ( - {t(catalogHasModel ? 'providerSelection.modelRoutingUnavailable' : 'providerSelection.modelRoutingPending')} - )} - - - )} - - updateModelDraft(draft.modelName, { category: value as ModelCategory })} - options={categoryOptions} - size="sm" - className="openbitfun-model-settings__selected-model-category-select" - /> - - - } - /> - - )} - > - i18nService.formatNumber(value, { useGrouping: true, maximumFractionDigits: 0 })} - onValueChange={(value) => updateModelDraft(draft.modelName, { contextWindow: value })} - min={32000} - max={2000000} - step={1000} - size="sm" - disableWheel - /> - - {automaticOpenCodeRouting && (modelRoute || canEditManualRoute) && ( - - - {modelRoute?.request_url || resolveRequestUrl(editingConfig.base_url || '', manualFormat, draft.modelName)} - - - )} - {draft.contextWindow > LONG_CONTEXT_WARNING_THRESHOLD_TOKENS && ( -
- - {t('form.contextWindowLongWarning')} -
- )} - updateModelDraft(draft.modelName, { userTags })} - /> - -
- )} -
- ); - })} + {draft.modelName} + + + removeSelectedModelDraft(draft.modelName)} + disabled={isEditorSaving} + icon={} + /> + +
+ ))} + {isAddingCustomModel && ( + setManualModelInput(event.target.value)} + onBlur={addManualModelDraft} + onKeyDown={event => { + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + if (event.key !== 'Enter' && event.key !== 'Escape') return; + event.preventDefault(); + event.stopPropagation(); + if (event.key === 'Enter') addManualModelDraft(); + else cancelManualModelDraft(); + requestAnimationFrame(() => manualModelAddButtonRef.current?.focus()); + }} + /> + )} ); }; const authType = editingConfig.auth?.type || 'api_key'; const authIsSubscription = authType === 'subscription'; + const showSubscriptionAuthTag = authIsSubscription && providerIdentityLocked; const selectedSubscriptionProvider: SubscriptionProvider | undefined = editingConfig.auth?.type === 'subscription' ? editingConfig.auth.provider : undefined; const legacyOpenCode = selectedSubscriptionProvider === 'opencode' && !zenOAuth; @@ -2865,10 +2938,54 @@ const ModelSettingsPage: React.FC = () => { const matchedSubscription = selectedSubscriptionProvider ? subscriptionAccounts.find((account) => account.provider === selectedSubscriptionProvider) : undefined; + const subscriptionState = getSubscriptionAccountState( + matchedSubscription, isLoadingSubscriptions, subscriptionLoadError, + ); + const subscriptionStatusLabels = { + loading: t('subscriptionAuth.accountState.loading'), + loadFailed: t('subscriptionAuth.accountState.loadFailed'), + unavailable: t('subscriptionAuth.accountState.unavailable'), + vaultUnavailable: t('subscriptionAuth.accountState.vaultUnavailable'), + reauthenticationRequired: t('subscriptionAuth.accountState.reauthenticationRequired'), + disconnected: t('subscriptionAuth.notSignedIn'), + refreshRequired: t('subscriptionAuth.accountState.refreshRequired'), + connected: t('subscriptionAuth.accountState.connected'), + }; + const subscriptionAccountLabel = matchedSubscription?.account?.trim() + || (subscriptionState === 'loading' + ? t('subscriptionAuth.loadingAccount') + : matchedSubscription?.connected + ? t('subscriptionAuth.accountIdentityUnavailable') + : t('subscriptionAuth.accountUnavailable')); + const subscriptionAccountHint = legacyOpenCode + ? t(providerIdentityLocked ? 'subscriptionAuth.openCodeLegacyConnection' : 'subscriptionAuth.openCodeRemoved') + : subscriptionState === 'loadFailed' + ? t('acquisition.loadFailed') + : subscriptionState === 'unavailable' + ? t('subscriptionAuth.serviceUnavailable') + : subscriptionState === 'vaultUnavailable' + ? t('subscriptionAuth.vaultUnavailable') + : subscriptionState === 'reauthenticationRequired' + ? t('subscriptionAuth.reauthenticationRequired') + : subscriptionState === 'refreshRequired' + ? t('subscriptionAuth.credentialExpired') + : subscriptionState === 'disconnected' + ? t('subscriptionAuth.signInToUse') + : subscriptionState === 'connected' && matchedSubscription?.expires_at != null + ? t('subscriptionAuth.credentialExpiresAt', { + time: i18nService.formatDate(new Date(matchedSubscription.expires_at * 1000), { + dateStyle: 'short', timeStyle: 'short', + }), + }) + : null; const renderAuthRow = () => ( - -
+ + {providerIdentityLocked ? ( + + {t(authIsSubscription ? 'subscriptionAuth.accountAuthentication' : 'subscriptionAuth.options.apiKey')} + + ) : ( { ); - return ( - <> -
-
- - {isFromTemplate ? ( - <> - - setEditingConfig(prev => ({ ...prev, name: e.target.value }))} - placeholder={t('form.configNamePlaceholder')} - size="sm" - /> - - {renderAuthRow()} - {!authIsSubscription && renderApiKeyRow(t('form.apiKey'))} - {!authIsSubscription && ( - <> - -
- {currentTemplate?.baseUrlOptions && currentTemplate.baseUrlOptions.length > 0 && ( - opt.url === editingConfig.base_url) ? editingConfig.base_url : ''} - onValueChange={(value) => { - const selectedOption = currentTemplate.baseUrlOptions!.find(opt => opt.url === value); - const newProvider = selectedOption?.format || editingConfig.provider || 'openai'; - resetRemoteModelDiscovery(); - setEditingConfig(prev => ({ - ...prev, - base_url: value as string, - request_url: resolveRequestUrl(value as string, newProvider, editingConfig.model_name || ''), - provider: newProvider - })); - }} - placeholder={t('form.baseUrl')} - options={currentTemplate.baseUrlOptions.map(opt => ({ label: opt.note || opt.url, value: opt.url, description: `${opt.format.toUpperCase()} · ${opt.url}` }))} - size="sm" - /> - )} - { - resetRemoteModelDiscovery(); - setEditingConfig(prev => ({ - ...prev, - base_url: e.target.value, - request_url: resolveRequestUrl(e.target.value, prev?.provider || 'openai', prev?.model_name || '') - })); - }} - onFocus={(e) => e.target.select()} - placeholder={currentTemplate?.baseUrl} - size="sm" - /> - {editingConfig.base_url && !automaticOpenCodeRouting && ( -
- {t('form.resolvedUrlLabel')} - - {previewRequestUrl(editingConfig.base_url, editingConfig.provider || 'openai', selectedModelDrafts.length === 1 ? selectedModelDrafts[0].modelName : undefined)} - -
- )} -
-
- {!automaticOpenCodeRouting && ( - - setManualModelInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - addManualModelDraft(); - } - }} - placeholder={t('providerSelection.inputModelName')} - size="sm" - /> - -
- {modelFetchHint && ( - - {modelFetchHint} - - )} - {renderSelectedModelRows()} -
- - - ) : ( - <> - {isProviderScopedEditing && ( - <> - + const renderSingleModelEditor = () => { + const draft = singleModelEditorDraft; + if (!draft) return null; + + return ( + + + updateModelDraft(draft.key, { modelName: event.target.value })} + size="sm" + /> + + {renderModelFields(draft)} + + ); + }; + + const renderAdvancedSettings = () => ( + !authIsSubscription && ( + + {showAdvancedSettings && ( + + {(editingConfig.provider === 'openai' || editingConfig.provider === 'anthropic') && ( + + updateRequestSettings({ inline_think_in_text: e.target.checked })} + /> + + )} + + + {t('advancedSettings.skipSslVerify.warning')} + + ) : undefined} + align="center" + className="openbitfun-model-settings__toggle-row" + > + updateRequestSettings({ skip_ssl_verify: e.target.checked })} + /> + + + + {t('advancedSettings.customHeaders.label')} + + {t('advancedSettings.customHeaders.hint')} + + {(requestSettings.custom_headers_mode || 'merge') === 'replace' + ? t('advancedSettings.customHeaders.modeReplaceHint') + : t('advancedSettings.customHeaders.modeMergeHint')} + + + )} + placement="top" + > + + + + + + + + + + + + + + + )} + multiline + className="openbitfun-model-settings__custom-headers-row" + > +
+
+ {Object.entries(requestSettings.custom_headers || {}).map(([key, value], index) => ( +
setEditingConfig(prev => ({ ...prev, name: e.target.value }))} - placeholder={t('form.configNamePlaceholder')} + value={key} + onChange={(e) => { const nh = { ...requestSettings.custom_headers }; const ov = nh[key]; delete nh[key]; if (e.target.value) nh[e.target.value] = ov; updateRequestSettings({ custom_headers: nh }); }} + placeholder={t('advancedSettings.customHeaders.keyPlaceholder')} + className="openbitfun-model-settings__header-key" size="sm" /> - - {renderAuthRow()} - {!authIsSubscription && renderApiKeyRow(t('form.apiKey'))} - {!authIsSubscription && ( - <> - -
- { - resetRemoteModelDiscovery(); - setEditingConfig(prev => ({ - ...prev, - base_url: e.target.value, - request_url: resolveRequestUrl(e.target.value, prev?.provider || 'openai', prev?.model_name || '') - })); - }} - onFocus={(e) => e.target.select()} - placeholder={'https://open.bigmodel.cn/api/paas/v4/chat/completions'} - size="sm" - /> - {editingConfig.base_url && !automaticOpenCodeRouting && ( -
- {t('form.resolvedUrlLabel')} - - {previewRequestUrl(editingConfig.base_url, editingConfig.provider || 'openai', selectedModelDrafts.length === 1 ? selectedModelDrafts[0].modelName : undefined)} - -
- )} -
-
- {!automaticOpenCodeRouting && ( - - { const nh = { ...requestSettings.custom_headers }; nh[key] = e.target.value; updateRequestSettings({ custom_headers: nh }); }} + placeholder={t('advancedSettings.customHeaders.valuePlaceholder')} + className="openbitfun-model-settings__header-value" + size="sm" + /> + + { const nh = { ...requestSettings.custom_headers }; delete nh[key]; updateRequestSettings({ custom_headers: Object.keys(nh).length > 0 ? nh : undefined }); }} + icon={} /> + +
+ ))} + +
+
+
+ + + {t('advancedSettings.customRequestBody.label')} + + {t('advancedSettings.customRequestBody.hint')} + {getCustomRequestBodyModeHint(editingConfig.provider, requestSettings.custom_request_body_mode)} + )} + placement="top" + > + + + + + + + -
-
- setManualModelInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - addManualModelDraft(); - } - }} - placeholder={t('providerSelection.inputModelName')} + + + + + + + )} + multiline + className="openbitfun-model-settings__custom-request-body-row" + > +
+