fix(ui): simplify credential aliases and usage filters
Use plain model name and alias inputs for credential aliases, discard unsaved credential settings on close, and replace usage filters with select-only menus.
This commit is contained in:
1 parent
88d58529f1
commit
072d771b3b
13 files changed
+409
-196
No files matched your search
@@ -46,8 +46,6 @@
|
||||
.credential-settings-actions { display: flex; gap: 10px; justify-content: flex-end; }
|
||||
.credential-settings-error { color: var(--failure-badge-text); margin: 0 0 12px; font-size: var(--font-size-caption); overflow-wrap: anywhere; max-height: 90px; overflow-y: auto; }
|
||||
.credential-settings-warning { color: var(--amber-text); }
|
||||
.credential-settings-discard { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; font-size: var(--font-size-caption); }
|
||||
.credential-settings-discard span { flex: 1 1 100%; }
|
||||
.credential-settings-loading { display: flex; align-items: center; justify-content: center; gap: 10px; min-height: 150px; }
|
||||
@media (max-width: 560px) {
|
||||
.credential-settings-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
@@ -58,41 +56,13 @@
|
||||
.credential-settings-span { grid-column: 1 / -1; }
|
||||
.credential-settings-inline-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||||
.credential-alias-list, .credential-header-list { display: grid; gap: 12px; }
|
||||
.credential-alias-card { display: grid; gap: 10px; padding: 12px; border: 1px solid var(--border-color); border-radius: 10px; background: var(--bg-primary); }
|
||||
.credential-alias-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 12px; }
|
||||
.credential-alias-fields .credential-settings-field { margin: 0; }
|
||||
.credential-alias-fields .credential-settings-field:last-child { grid-column: 1 / -1; }
|
||||
.credential-settings-dialog .agent-model-trigger { box-sizing: border-box; min-height: 41px; padding: 0 4px 0 11px; border-color: var(--border-color); border-radius: 7px; background: var(--bg-primary); color: var(--text-primary); }
|
||||
.credential-settings-dialog .agent-model-trigger strong { color: var(--text-primary); }
|
||||
.credential-settings-dialog .agent-model-editable-trigger input { box-sizing: border-box; width: 100%; height: 39px; margin: 0; padding: 0; border: 0; border-radius: 0; outline: 0; background: transparent; box-shadow: none; color: inherit; font: inherit; font-size: inherit; line-height: 1.5; }
|
||||
.credential-settings-dialog .credential-model-menu {
|
||||
position: fixed;
|
||||
z-index: 2;
|
||||
margin: 0;
|
||||
max-height: none;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 16px 40px #0003;
|
||||
}
|
||||
.credential-settings-dialog .credential-model-menu .agent-model-option {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.credential-settings-dialog .credential-model-menu .agent-model-option:hover,
|
||||
.credential-settings-dialog .credential-model-menu .agent-model-option.active {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
.credential-settings-dialog .credential-model-menu .agent-model-option.selected {
|
||||
background: color-mix(in srgb, var(--primary-color) 12%, var(--bg-card));
|
||||
}
|
||||
.credential-settings-dialog .credential-model-menu .agent-model-option small,
|
||||
.credential-settings-dialog .credential-model-menu .agent-model-dropdown-footer {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.credential-alias-card-heading, .credential-header-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.credential-alias-card-heading strong { font-size: var(--font-size-meta); }
|
||||
.credential-alias-card { display: grid; gap: 8px; }
|
||||
.credential-alias-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; gap: 8px; align-items: center; }
|
||||
.credential-alias-row input { min-width: 0; }
|
||||
.credential-alias-row-actions { display: flex; align-items: center; gap: 4px; }
|
||||
.credential-alias-row-actions .is-expanded { transform: rotate(180deg); }
|
||||
.credential-alias-details { display: grid; gap: 10px; padding: 12px; border: 1px solid var(--border-color); border-radius: 10px; background: var(--bg-secondary); }
|
||||
.credential-alias-details .credential-settings-field { margin: 0; }
|
||||
.credential-alias-checks { display: flex; flex-wrap: wrap; gap: 8px 16px; }
|
||||
.credential-alias-checks label { display: flex; align-items: center; gap: 8px; font-size: var(--font-size-meta); }
|
||||
.credential-header-row { display: grid; grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr) auto; }
|
||||
@@ -102,6 +72,7 @@
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.credential-alias-fields { grid-template-columns: minmax(0, 1fr); }
|
||||
.credential-alias-row { grid-template-columns: minmax(0, 1fr) auto; }
|
||||
.credential-alias-row input:first-child { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { loadAuthFileSettings, saveAuthFileSettings, type AuthFileSettingsDraft,
|
||||
import { modelMatchesRule, normalizeOAuthExcludedRules, oauthModelCandidates, oauthModelsFromPayload, setOAuthModelsExcluded, type OAuthModelDefinition } from '../services/oauthModels';
|
||||
import './AuthFileSettingsDialog.css';
|
||||
import { CredentialAdvancedFields, CredentialHeadersEditor, credentialProviderKey } from './CredentialAdvancedFields';
|
||||
import { modelSearchText, type ModelOption } from '../services/modelService';
|
||||
import { modelSearchText } from '../services/modelService';
|
||||
|
||||
export function AuthFileSettingsDialog({ name, provider = '', onClose, onSaved }: {
|
||||
name: string;
|
||||
@@ -22,14 +22,12 @@ export function AuthFileSettingsDialog({ name, provider = '', onClose, onSaved }
|
||||
const [original, setOriginal] = useState<AuthFileSettingsDraft | null>(null);
|
||||
const [draft, setDraft] = useState<AuthFileSettingsDraft | null>(null);
|
||||
const [models, setModels] = useState<OAuthModelDefinition[]>([]);
|
||||
const [pickerModels, setPickerModels] = useState<ModelOption[]>([]);
|
||||
const [catalogError, setCatalogError] = useState('');
|
||||
const [catalogLoading, setCatalogLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [discard, setDiscard] = useState(false);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const dirty = draft !== null && (Boolean(draft.normalizeCloakMetadata?.length) || JSON.stringify(draft) !== JSON.stringify(original));
|
||||
|
||||
@@ -61,7 +59,6 @@ export function AuthFileSettingsDialog({ name, provider = '', onClose, onSaved }
|
||||
const catalog = oauthModelsFromPayload(payload);
|
||||
if (!active) return;
|
||||
setModels(catalog);
|
||||
setPickerModels(catalog.map((model) => ({ name: model.id, displayName: model.displayName })));
|
||||
}).catch((reason: unknown) => {
|
||||
if (active) setCatalogError(reason instanceof Error ? reason.message : String(reason));
|
||||
}).finally(() => { if (active) setCatalogLoading(false); });
|
||||
@@ -70,20 +67,17 @@ export function AuthFileSettingsDialog({ name, provider = '', onClose, onSaved }
|
||||
|
||||
const close = () => {
|
||||
if (savingRef.current) return;
|
||||
if (dirty) setDiscard(true);
|
||||
else onClose();
|
||||
onClose();
|
||||
};
|
||||
const update = <K extends keyof AuthFileSettingsDraft>(key: K, value: AuthFileSettingsDraft[K]) => {
|
||||
setDraft((current) => current ? { ...current, [key]: value } : current);
|
||||
setError('');
|
||||
setDiscard(false);
|
||||
};
|
||||
const save = async () => {
|
||||
if (!draft || !original || savingRef.current) return;
|
||||
savingRef.current = true;
|
||||
setSaving(true);
|
||||
setError('');
|
||||
setDiscard(false);
|
||||
try {
|
||||
const changed = await saveAuthFileSettings(name, original, draft);
|
||||
if (changed) onSaved();
|
||||
@@ -175,7 +169,7 @@ export function AuthFileSettingsDialog({ name, provider = '', onClose, onSaved }
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
<CredentialAdvancedFields name={name} provider={provider} advanced={draft.advanced} models={pickerModels} modelsLoading={catalogLoading} modelsError={catalogError} disabled={saving} onChange={(advanced) => update('advanced', advanced)} />
|
||||
<CredentialAdvancedFields name={name} provider={provider} advanced={draft.advanced} disabled={saving} onChange={(advanced) => update('advanced', advanced)} />
|
||||
<section className="credential-settings-section">
|
||||
<h3>{t('authFiles.settings.additional')}</h3>
|
||||
<CredentialHeadersEditor value={draft.headers} disabled={saving} onChange={(headers) => update('headers', headers)} />
|
||||
@@ -189,7 +183,6 @@ export function AuthFileSettingsDialog({ name, provider = '', onClose, onSaved }
|
||||
</div>
|
||||
<footer className="credential-settings-footer">
|
||||
{error ? <p className="credential-settings-error" role="alert">{error}</p> : null}
|
||||
{discard ? <div className="credential-settings-discard" role="alert"><span>{t('authFiles.settings.unsaved')}</span><button type="button" className="secondary-button compact-button" onClick={() => setDiscard(false)}>{t('authFiles.settings.keepEditing')}</button><button type="button" className="danger-button compact-button" onClick={onClose}>{t('authFiles.settings.discard')}</button></div> : null}
|
||||
<div className="credential-settings-actions">
|
||||
<button type="button" className="secondary-button" disabled={saving} onClick={close}>{t('common.cancel')}</button>
|
||||
{!loading && !draft ? <button type="button" className="primary-button" onClick={() => setAttempt((value) => value + 1)}>{t('common.refresh')}</button> : <button type="submit" className="primary-button" disabled={!draft || loading || saving || !dirty}>{saving ? <LoaderCircle size={16} className="spin" /> : <Check size={16} />}{t(saving ? 'common.saving' : 'common.save')}</button>}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Undo2, X } from 'lucide-react';
|
||||
import { AgentModelPicker } from './AgentModelPicker';
|
||||
import type { ModelOption } from '../services/modelService';
|
||||
import { ChevronDown, Plus, Undo2, X } from 'lucide-react';
|
||||
import { useI18n } from '../i18n';
|
||||
import type { MessageKey } from '../i18n/resources';
|
||||
import { isRecord } from '../services/managementApi';
|
||||
@@ -44,14 +42,11 @@ function aliasRows(value: unknown): AliasRow[] {
|
||||
}
|
||||
|
||||
export function CredentialAdvancedFields({
|
||||
name, provider = '', advanced, models = [], modelsLoading = false, modelsError = '', onChange, disabled = false,
|
||||
name, provider = '', advanced, onChange, disabled = false,
|
||||
}: {
|
||||
name: string;
|
||||
provider?: string;
|
||||
advanced: Advanced;
|
||||
models?: ModelOption[];
|
||||
modelsLoading?: boolean;
|
||||
modelsError?: string;
|
||||
onChange: (advanced: Advanced) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
@@ -61,6 +56,7 @@ export function CredentialAdvancedFields({
|
||||
const aliases = aliasRows(advanced.model_aliases);
|
||||
const aliasesSet = Array.isArray(advanced.model_aliases);
|
||||
const [wordsDraft, setWordsDraft] = useState<string | null>(null);
|
||||
const [expandedAlias, setExpandedAlias] = useState<number | null>(null);
|
||||
const setField = (key: string, value: unknown) => onChange(withoutKey(advanced, key, value));
|
||||
const tri = (value: unknown) => value === true ? 'true' : value === false ? 'false' : '';
|
||||
const choice = (label: MessageKey, key: string, options: ReadonlyArray<{ value: string; label: MessageKey }>) => {
|
||||
@@ -101,34 +97,33 @@ export function CredentialAdvancedFields({
|
||||
<h3>{t('authFiles.settings.aliases')}</h3>
|
||||
<p className="credential-settings-lead">{t('authFiles.settings.aliasesHint')}</p>
|
||||
{aliases.length ? <div className="credential-alias-list">
|
||||
{aliases.map((row, index) => <div className="credential-alias-card" key={index}>
|
||||
<div className="credential-alias-card-heading">
|
||||
<strong>{t('authFiles.settings.aliasItem', { index: index + 1 })}</strong>
|
||||
<button type="button" className="icon-button quiet" disabled={disabled} aria-label={t('authFiles.settings.aliasRemove', { index: index + 1 })} onClick={() => setField('model_aliases', aliases.filter((_, rowIndex) => rowIndex !== index))}><X size={15} /></button>
|
||||
{aliases.map((row, index) => {
|
||||
const expanded = expandedAlias === index;
|
||||
return <div className="credential-alias-card" key={index}>
|
||||
<div className="credential-alias-row">
|
||||
<input aria-label={t('authFiles.settings.aliasUpstream')} placeholder={t('authFiles.settings.aliasUpstream')} value={textValue(row.name)} disabled={disabled} autoComplete="off" spellCheck={false} maxLength={240}
|
||||
onChange={(event) => updateAlias(index, { name: event.currentTarget.value })} />
|
||||
<input aria-label={t('authFiles.settings.aliasClient')} placeholder={t('authFiles.settings.aliasClient')} value={textValue(row.alias)} disabled={disabled} autoComplete="off" spellCheck={false} maxLength={240}
|
||||
onChange={(event) => updateAlias(index, { alias: event.currentTarget.value })} />
|
||||
<div className="credential-alias-row-actions">
|
||||
<button type="button" className="icon-button quiet" disabled={disabled} aria-expanded={expanded} aria-label={expanded ? t('appUpdate.notes.collapse') : t('appUpdate.notes.expand')}
|
||||
onClick={() => setExpandedAlias(expanded ? null : index)}><ChevronDown size={15} className={expanded ? 'is-expanded' : ''} /></button>
|
||||
<button type="button" className="icon-button quiet" disabled={disabled} aria-label={t('authFiles.settings.aliasRemove', { index: index + 1 })}
|
||||
onClick={() => { setExpandedAlias((current) => current === index ? null : current !== null && current > index ? current - 1 : current); setField('model_aliases', aliases.filter((_, rowIndex) => rowIndex !== index)); }}><X size={15} /></button>
|
||||
</div>
|
||||
<div className="credential-alias-fields">
|
||||
<label className="credential-settings-field">
|
||||
<span>{t('authFiles.settings.aliasUpstream')}</span>
|
||||
<AgentModelPicker models={models} value={textValue(row.name)} loading={modelsLoading} error={modelsError} disabled={disabled}
|
||||
editable={{ label: t('authFiles.settings.aliasUpstream'), placeholder: t('authFiles.settings.aliasModelPlaceholder'), maxLength: 240 }}
|
||||
menuClassName="credential-model-menu" onChange={(value) => updateAlias(index, { name: value })} />
|
||||
</label>
|
||||
<label className="credential-settings-field">
|
||||
<span>{t('authFiles.settings.aliasClient')}</span>
|
||||
<AgentModelPicker models={models} value={textValue(row.alias)} loading={modelsLoading} error={modelsError} disabled={disabled}
|
||||
editable={{ label: t('authFiles.settings.aliasClient'), placeholder: t('authFiles.settings.aliasModelPlaceholder'), maxLength: 240 }}
|
||||
menuClassName="credential-model-menu" onChange={(value) => updateAlias(index, { alias: value })} />
|
||||
</label>
|
||||
</div>
|
||||
{expanded ? <div className="credential-alias-details">
|
||||
<label className="credential-settings-field">
|
||||
<span>{t('authFiles.settings.aliasDisplay')}</span>
|
||||
<input value={textValue(row['display-name'])} disabled={disabled} autoComplete="off" spellCheck={false} onChange={(event) => updateAlias(index, { 'display-name': event.currentTarget.value.trim() ? event.currentTarget.value : undefined })} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="credential-alias-checks">
|
||||
<label><input type="checkbox" checked={row.fork === true} disabled={disabled} onChange={(event) => updateAlias(index, { fork: event.currentTarget.checked })} />{t('authFiles.settings.aliasFork')}</label>
|
||||
<label><input type="checkbox" checked={row['force-mapping'] === true} disabled={disabled} onChange={(event) => updateAlias(index, { 'force-mapping': event.currentTarget.checked })} />{t('authFiles.settings.aliasForce')}</label>
|
||||
</div>
|
||||
</div>)}
|
||||
</div> : null}
|
||||
</div>;
|
||||
})}
|
||||
</div> : aliasesSet ? <p className="credential-settings-lead">{t('authFiles.settings.aliasCleared')}</p> : null}
|
||||
<div className="credential-settings-inline-actions">
|
||||
<button type="button" className="secondary-button compact-button" disabled={disabled} onClick={(event) => { const scroller = event.currentTarget.closest('.credential-settings-body'); const top = scroller?.scrollTop ?? 0; setField('model_aliases', [...aliases, { name: '', alias: '' }]); requestAnimationFrame(() => { if (scroller) scroller.scrollTop = top; }); }}><Plus size={14} />{t('authFiles.settings.aliasAdd')}</button>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
.select-menu {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.select-menu-trigger {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, max-content) 16px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
min-height: 34px;
|
||||
padding: 0 10px 0 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.select-menu-trigger:hover:not(:disabled),
|
||||
.select-menu.open .select-menu-trigger {
|
||||
border-color: var(--text-tertiary);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.select-menu-trigger:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.select-menu-trigger strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: inherit;
|
||||
font-size: var(--font-size-meta);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.select-menu-trigger > svg {
|
||||
color: var(--text-tertiary);
|
||||
transition: transform 0.16s ease;
|
||||
}
|
||||
|
||||
.select-menu.open .select-menu-trigger > svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.select-menu-dropdown {
|
||||
position: fixed;
|
||||
z-index: 45;
|
||||
display: grid;
|
||||
min-width: 180px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-card);
|
||||
box-shadow: 0 14px 34px rgba(45, 42, 38, 0.18);
|
||||
}
|
||||
|
||||
.select-menu-list {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.select-menu-option {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 16px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 6px 10px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.select-menu-option span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-meta);
|
||||
}
|
||||
|
||||
.select-menu-option:hover,
|
||||
.select-menu-option.active {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.select-menu-option.selected {
|
||||
background: color-mix(in srgb, var(--primary-color) 12%, var(--bg-card));
|
||||
}
|
||||
|
||||
.select-menu-option > svg {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
} from 'react';
|
||||
import { Check, ChevronDown } from 'lucide-react';
|
||||
|
||||
export type SelectMenuOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type MenuLayout = {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export function SelectMenu({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
disabled = false,
|
||||
}: {
|
||||
value: string;
|
||||
options: SelectMenuOption[];
|
||||
onChange: (value: string) => void;
|
||||
ariaLabel: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [layout, setLayout] = useState<MenuLayout | null>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const listboxId = useId();
|
||||
const selected = options.find((option) => option.value === value) ?? options[0];
|
||||
|
||||
const updateLayout = useCallback(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root) return;
|
||||
const rect = root.getBoundingClientRect();
|
||||
const edgeGap = 12;
|
||||
const triggerGap = 6;
|
||||
const preferredHeight = Math.min(282, Math.max(options.length, 1) * 42 + 18);
|
||||
const minimumHeight = 88;
|
||||
const spaceBelow = Math.max(0, window.innerHeight - edgeGap - rect.bottom - triggerGap);
|
||||
const spaceAbove = Math.max(0, rect.top - triggerGap - edgeGap);
|
||||
const placeAbove = spaceBelow < minimumHeight && spaceAbove > spaceBelow;
|
||||
const available = placeAbove ? spaceAbove : spaceBelow;
|
||||
const height = Math.min(preferredHeight, Math.max(Math.min(minimumHeight, available), available));
|
||||
const width = Math.max(rect.width, 180);
|
||||
const maxLeft = Math.max(edgeGap, window.innerWidth - edgeGap - width);
|
||||
const left = Math.min(Math.max(edgeGap, rect.left), maxLeft);
|
||||
const desiredTop = placeAbove ? rect.top - triggerGap - height : rect.bottom + triggerGap;
|
||||
const top = Math.min(Math.max(edgeGap, desiredTop), Math.max(edgeGap, window.innerHeight - edgeGap - height));
|
||||
setLayout({ top, left, width, height });
|
||||
}, [options.length]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) {
|
||||
setLayout(null);
|
||||
return undefined;
|
||||
}
|
||||
updateLayout();
|
||||
window.addEventListener('resize', updateLayout);
|
||||
window.addEventListener('scroll', updateLayout, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateLayout);
|
||||
window.removeEventListener('scroll', updateLayout, true);
|
||||
};
|
||||
}, [open, updateLayout]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
const close = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (!rootRef.current?.contains(target) && !menuRef.current?.contains(target)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', close);
|
||||
return () => document.removeEventListener('mousedown', close);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const selectedIndex = options.findIndex((option) => option.value === value);
|
||||
setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0);
|
||||
}, [open, options, value]);
|
||||
|
||||
const choose = (next: string) => {
|
||||
onChange(next);
|
||||
setOpen(false);
|
||||
rootRef.current?.querySelector('button')?.focus();
|
||||
};
|
||||
|
||||
const moveActive = (offset: number) => {
|
||||
if (!options.length) return;
|
||||
setActiveIndex((current) => (current + offset + options.length) % options.length);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (!open && ['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
moveActive(1);
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
moveActive(-1);
|
||||
} else if (event.key === 'Enter' && options[activeIndex]) {
|
||||
event.preventDefault();
|
||||
choose(options[activeIndex].value);
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`select-menu ${open ? 'open' : ''}`} ref={rootRef}
|
||||
onBlur={(event) => {
|
||||
const next = event.relatedTarget as Node | null;
|
||||
if (!event.currentTarget.contains(next) && !menuRef.current?.contains(next)) setOpen(false);
|
||||
}}>
|
||||
<button
|
||||
type="button"
|
||||
className="select-menu-trigger"
|
||||
aria-label={ariaLabel}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-controls={listboxId}
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<strong title={selected?.label}>{selected?.label ?? ''}</strong>
|
||||
<ChevronDown size={16} aria-hidden />
|
||||
</button>
|
||||
{open ? (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="select-menu-dropdown"
|
||||
style={layout
|
||||
? { top: layout.top, left: layout.left, width: layout.width, height: layout.height }
|
||||
: { top: 0, left: 0, width: 0, height: 0, visibility: 'hidden' }}
|
||||
>
|
||||
<div className="select-menu-list" id={listboxId} role="listbox" aria-label={ariaLabel}>
|
||||
{options.map((option, index) => {
|
||||
const isSelected = option.value === value;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
className={`select-menu-option${isSelected ? ' selected' : ''}${index === activeIndex ? ' active' : ''}`}
|
||||
key={option.value || option.label}
|
||||
onMouseEnter={() => setActiveIndex(index)}
|
||||
onClick={() => choose(option.value)}
|
||||
>
|
||||
<span title={option.label}>{option.label}</span>
|
||||
{isSelected ? <Check size={15} aria-hidden /> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+5
-9
@@ -82,16 +82,15 @@ export const jaOverrides = {
|
||||
'authFiles.settings.aliases': "モデル別名",
|
||||
'authFiles.settings.aliasesHint': "単一の認証情報だけに適用され、全体の別名より優先されます。追加しなければ既定で全体の別名を使います。",
|
||||
'authFiles.settings.aliasCleared': "この認証情報ではモデル別名を使わない設定です。追加するか、全体の別名に戻せます。",
|
||||
'authFiles.settings.aliasAdd': "別名を追加",
|
||||
'authFiles.settings.aliasAdd': "モデルを追加",
|
||||
'authFiles.settings.aliasInherit': "全体の別名に戻す",
|
||||
'authFiles.settings.aliasItem': "別名 {index}",
|
||||
'authFiles.settings.aliasRemove': "別名 {index} を削除",
|
||||
'authFiles.settings.aliasUpstream': "上流モデル",
|
||||
'authFiles.settings.aliasClient': "クライアント別名",
|
||||
'authFiles.settings.aliasModelPlaceholder': "モデルを検索または入力",
|
||||
'authFiles.settings.aliasUpstream': "モデル名",
|
||||
'authFiles.settings.aliasClient': "モデル別名",
|
||||
'authFiles.settings.aliasDisplay': "表示名",
|
||||
'authFiles.settings.aliasFork': "元のモデルを残す",
|
||||
'authFiles.settings.aliasForce': "応答で別名を使う",
|
||||
'authFiles.settings.aliasFork': "モデル一覧に元のモデルを残す",
|
||||
'authFiles.settings.aliasForce': "応答のモデル名を別名に書き換える",
|
||||
'authFiles.settings.claude': "Claude 互換",
|
||||
'authFiles.settings.claudeHint': "偽装とフィンガープリントは Claude、または既に個別設定がある認証情報に表示します。既定を選ぶと上書きを解除します。",
|
||||
'authFiles.settings.cloak_mode': "リクエスト偽装",
|
||||
@@ -124,9 +123,6 @@ export const jaOverrides = {
|
||||
'authFiles.settings.headers': "カスタムヘッダー",
|
||||
'authFiles.settings.headersHint': "キーと値が文字列の JSON オブジェクト。キーの削除、または {} でヘッダーを解除。",
|
||||
'authFiles.settings.note': "メモ",
|
||||
'authFiles.settings.unsaved': "未保存の変更を破棄しますか?",
|
||||
'authFiles.settings.keepEditing': "編集を続ける",
|
||||
'authFiles.settings.discard': "変更を破棄",
|
||||
'authFiles.settings.invalid.metadata': "設定を読み込めません。有効な JSON オブジェクトではありません。",
|
||||
'authFiles.settings.invalid.headers': "ヘッダーは JSON オブジェクトで、有効かつ重複しない名前と改行のない文字列値が必要です。",
|
||||
'authFiles.settings.invalid.priority': "優先度は有効な整数で指定してください。",
|
||||
|
||||
@@ -82,16 +82,15 @@ export const en: Record<MessageKey, string> = {
|
||||
'authFiles.settings.aliases': "Model aliases",
|
||||
'authFiles.settings.aliasesHint': "Applies only to a single credential and takes precedence over global aliases. Leave this empty to use global aliases by default.",
|
||||
'authFiles.settings.aliasCleared': "This credential explicitly has no model aliases. Add one, or return to global aliases.",
|
||||
'authFiles.settings.aliasAdd': "Add alias",
|
||||
'authFiles.settings.aliasAdd': "Add model",
|
||||
'authFiles.settings.aliasInherit': "Use global aliases",
|
||||
'authFiles.settings.aliasItem': "Alias {index}",
|
||||
'authFiles.settings.aliasRemove': "Remove alias {index}",
|
||||
'authFiles.settings.aliasUpstream': "Upstream model",
|
||||
'authFiles.settings.aliasClient': "Client alias",
|
||||
'authFiles.settings.aliasModelPlaceholder': "Search or enter a model",
|
||||
'authFiles.settings.aliasUpstream': "Model name",
|
||||
'authFiles.settings.aliasClient': "Model alias",
|
||||
'authFiles.settings.aliasDisplay': "Display name",
|
||||
'authFiles.settings.aliasFork': "Keep original model",
|
||||
'authFiles.settings.aliasForce': "Use alias in responses",
|
||||
'authFiles.settings.aliasFork': "Keep the original model in the model list",
|
||||
'authFiles.settings.aliasForce': "Rewrite response model names to the alias",
|
||||
'authFiles.settings.claude': "Claude compatibility",
|
||||
'authFiles.settings.claudeHint': "Cloaking and fingerprints apply to Claude, and stay visible when this credential already overrides them. Choose Default to remove an override.",
|
||||
'authFiles.settings.cloak_mode': "Request cloaking",
|
||||
@@ -124,9 +123,6 @@ export const en: Record<MessageKey, string> = {
|
||||
'authFiles.settings.headers': "Custom headers",
|
||||
'authFiles.settings.headersHint': "Enter a JSON object with string keys and values. Remove keys or use {} to clear headers.",
|
||||
'authFiles.settings.note': "Note",
|
||||
'authFiles.settings.unsaved': "Discard unsaved changes?",
|
||||
'authFiles.settings.keepEditing': "Keep editing",
|
||||
'authFiles.settings.discard': "Discard changes",
|
||||
'authFiles.settings.invalid.metadata': "Cannot read settings: the credential is not a valid JSON object.",
|
||||
'authFiles.settings.invalid.headers': "Headers must be a JSON object with valid, unique header names and string values without line breaks.",
|
||||
'authFiles.settings.invalid.priority': "Priority must be a valid integer.",
|
||||
|
||||
@@ -81,16 +81,15 @@ export const zhCN = {
|
||||
'authFiles.settings.aliases': "模型别名",
|
||||
'authFiles.settings.aliasesHint': "只作用于单个凭证,并优先于全局别名。不添加时默认使用全局别名。",
|
||||
'authFiles.settings.aliasCleared': "已明确不使用模型别名。可添加别名,或改回全局别名。",
|
||||
'authFiles.settings.aliasAdd': "添加别名",
|
||||
'authFiles.settings.aliasAdd': "添加模型",
|
||||
'authFiles.settings.aliasInherit': "改回全局别名",
|
||||
'authFiles.settings.aliasItem': "别名 {index}",
|
||||
'authFiles.settings.aliasRemove': "删除别名 {index}",
|
||||
'authFiles.settings.aliasUpstream': "上游模型",
|
||||
'authFiles.settings.aliasClient': "客户端别名",
|
||||
'authFiles.settings.aliasModelPlaceholder': "搜索或输入模型",
|
||||
'authFiles.settings.aliasDisplay': "展示名称",
|
||||
'authFiles.settings.aliasFork': "保留原始模型",
|
||||
'authFiles.settings.aliasForce': "响应使用别名",
|
||||
'authFiles.settings.aliasUpstream': "模型名称",
|
||||
'authFiles.settings.aliasClient': "模型别名",
|
||||
'authFiles.settings.aliasDisplay': "显示名称",
|
||||
'authFiles.settings.aliasFork': "在模型列表中保留原始模型",
|
||||
'authFiles.settings.aliasForce': "将响应模型名称改写为别名",
|
||||
'authFiles.settings.claude': "Claude 兼容",
|
||||
'authFiles.settings.claudeHint': "请求伪装和指纹只用于 Claude 及已经单独配置过的凭证。选择“默认”即取消这项覆盖。",
|
||||
'authFiles.settings.cloak_mode': "请求伪装",
|
||||
@@ -123,9 +122,6 @@ export const zhCN = {
|
||||
'authFiles.settings.headers': "自定义请求头",
|
||||
'authFiles.settings.headersHint': "填写 JSON 对象,键和值均为字符串。删除键或填写 {} 可清空对应请求头。",
|
||||
'authFiles.settings.note': "备注",
|
||||
'authFiles.settings.unsaved': "有尚未保存的修改,是否放弃?",
|
||||
'authFiles.settings.keepEditing': "继续编辑",
|
||||
'authFiles.settings.discard': "放弃修改",
|
||||
'authFiles.settings.invalid.metadata': "无法读取凭证设置:文件内容不是有效的 JSON 对象。",
|
||||
'authFiles.settings.invalid.headers': "请求头必须是 JSON 对象,键和值为字符串,名称不能重复,值不能包含换行。",
|
||||
'authFiles.settings.invalid.priority': "优先级必须是有效整数。",
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
import { createRefreshScheduler } from '../services/refreshScheduler';
|
||||
import { usageViewScopeKey } from '../services/usageViewScope';
|
||||
import { EventsView, type UsageEventPage } from './UsageEventsView';
|
||||
import { SelectMenu } from '../components/SelectMenu';
|
||||
import { UsageAnalysisView, type UsageAnalysis, type UsageCategory } from './UsageAnalysisView';
|
||||
|
||||
type UsageTab = 'overview' | 'analysis' | 'events' | 'pricing' | 'data-management';
|
||||
@@ -504,99 +505,78 @@ export function UsageRecordsPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const modelOptions = filterOptions(optionsAnalysis.models);
|
||||
const providerOptions = filterOptions(optionsAnalysis.providers);
|
||||
const sourceOptions = filterOptions(optionsAnalysis.sources);
|
||||
const keyOptions = filterOptions(optionsAnalysis.apiKeys);
|
||||
const filterPanel = (
|
||||
<section className="panel usage-filter-panel">
|
||||
<div className="usage-filter-row">
|
||||
<div className="usage-filter-group">
|
||||
<label className="usage-filter-item">
|
||||
<span className="sr-only">{t('usage.filter.timeRange')}</span>
|
||||
<select
|
||||
<div className="usage-filter-item">
|
||||
<SelectMenu
|
||||
value={range}
|
||||
onChange={(event) => {
|
||||
setRange(event.currentTarget.value as UsageRange);
|
||||
ariaLabel={t('usage.filter.timeRange')}
|
||||
onChange={(value) => {
|
||||
setRange(value as UsageRange);
|
||||
setPage(1);
|
||||
}}
|
||||
aria-label={t('usage.filter.timeRange')}
|
||||
>
|
||||
<option value="4h">{t('usage.range.4h')}</option>
|
||||
<option value="24h">{t('usage.range.24h')}</option>
|
||||
<option value="today">{t('usage.range.today')}</option>
|
||||
<option value="7d">{t('usage.range.7d')}</option>
|
||||
<option value="30d">{t('usage.range.30d')}</option>
|
||||
<option value="all">{t('usage.range.all')}</option>
|
||||
<option value="custom">{t('usage.range.custom')}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="usage-filter-item">
|
||||
<span className="sr-only">{t('usage.filter.model')}</span>
|
||||
<select
|
||||
options={[
|
||||
{ value: '4h', label: t('usage.range.4h') },
|
||||
{ value: '24h', label: t('usage.range.24h') },
|
||||
{ value: 'today', label: t('usage.range.today') },
|
||||
{ value: '7d', label: t('usage.range.7d') },
|
||||
{ value: '30d', label: t('usage.range.30d') },
|
||||
{ value: 'all', label: t('usage.range.all') },
|
||||
{ value: 'custom', label: t('usage.range.custom') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="usage-filter-item">
|
||||
<SelectMenu
|
||||
value={model}
|
||||
onChange={(event) => changeFilter(setModel, event.currentTarget.value)}
|
||||
aria-label={t('usage.filter.model')}
|
||||
>
|
||||
<option value="">{t('usage.filter.allModels')}</option>
|
||||
{filterOptions(optionsAnalysis.models).map((item) => (
|
||||
<option value={item.key} key={item.key}>{item.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="usage-filter-item">
|
||||
<span className="sr-only">{t('usage.column.provider')}</span>
|
||||
<select
|
||||
ariaLabel={t('usage.filter.model')}
|
||||
onChange={(value) => changeFilter(setModel, value)}
|
||||
options={[{ value: '', label: t('usage.filter.allModels') }, ...modelOptions.map((item) => ({ value: item.key, label: item.label }))]}
|
||||
/>
|
||||
</div>
|
||||
<div className="usage-filter-item">
|
||||
<SelectMenu
|
||||
value={provider}
|
||||
onChange={(event) => changeFilter(setProvider, event.currentTarget.value)}
|
||||
aria-label={t('usage.column.provider')}
|
||||
>
|
||||
<option value="">{t('usage.filter.allProviders')}</option>
|
||||
{filterOptions(optionsAnalysis.providers).map((item) => (
|
||||
<option value={item.key} key={item.key}>{item.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="usage-filter-item">
|
||||
<span className="sr-only">{t('usage.filter.source')}</span>
|
||||
<select
|
||||
ariaLabel={t('usage.column.provider')}
|
||||
onChange={(value) => changeFilter(setProvider, value)}
|
||||
options={[{ value: '', label: t('usage.filter.allProviders') }, ...providerOptions.map((item) => ({ value: item.key, label: item.label }))]}
|
||||
/>
|
||||
</div>
|
||||
<div className="usage-filter-item">
|
||||
<SelectMenu
|
||||
value={source}
|
||||
onChange={(event) => changeFilter(setSource, event.currentTarget.value)}
|
||||
aria-label={t('usage.filter.source')}
|
||||
>
|
||||
<option value="">{t('usage.filter.allSources')}</option>
|
||||
{filterOptions(optionsAnalysis.sources).map((item) => (
|
||||
<option value={item.key} key={item.key}>{item.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="usage-filter-item">
|
||||
<span className="sr-only">{t('apiAccess.field.key')}</span>
|
||||
<select
|
||||
ariaLabel={t('usage.filter.source')}
|
||||
onChange={(value) => changeFilter(setSource, value)}
|
||||
options={[{ value: '', label: t('usage.filter.allSources') }, ...sourceOptions.map((item) => ({ value: item.key, label: item.label }))]}
|
||||
/>
|
||||
</div>
|
||||
<div className="usage-filter-item">
|
||||
<SelectMenu
|
||||
value={apiKeyHash}
|
||||
onChange={(event) => changeFilter(setApiKeyHash, event.currentTarget.value)}
|
||||
aria-label={t('apiAccess.field.key')}
|
||||
>
|
||||
<option value="">{t('usage.filter.allKeys')}</option>
|
||||
{filterOptions(optionsAnalysis.apiKeys).map((item) => (
|
||||
<option value={item.key} key={item.key}>{item.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="usage-filter-item">
|
||||
<span className="sr-only">{t('usage.filter.result')}</span>
|
||||
<select
|
||||
ariaLabel={t('apiAccess.field.key')}
|
||||
onChange={(value) => changeFilter(setApiKeyHash, value)}
|
||||
options={[{ value: '', label: t('usage.filter.allKeys') }, ...keyOptions.map((item) => ({ value: item.key, label: item.label }))]}
|
||||
/>
|
||||
</div>
|
||||
<div className="usage-filter-item">
|
||||
<SelectMenu
|
||||
value={result}
|
||||
onChange={(event) => changeFilter(setResult, event.currentTarget.value)}
|
||||
aria-label={t('usage.filter.result')}
|
||||
>
|
||||
<option value="all">{t('usage.filter.allResults')}</option>
|
||||
<option value="success">{t('usage.result.success')}</option>
|
||||
<option value="failed">{t('usage.result.failed')}</option>
|
||||
<option value="canceled">{t('usage.result.canceled')}</option>
|
||||
</select>
|
||||
</label>
|
||||
ariaLabel={t('usage.filter.result')}
|
||||
onChange={(value) => changeFilter(setResult, value)}
|
||||
options={[
|
||||
{ value: 'all', label: t('usage.filter.allResults') },
|
||||
{ value: 'success', label: t('usage.result.success') },
|
||||
{ value: 'failed', label: t('usage.result.failed') },
|
||||
{ value: 'canceled', label: t('usage.result.canceled') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -11,10 +11,10 @@ export const credentialAdvancedShape: ConfigShape = { type: 'object', fields: {
|
||||
try { new Intl.DateTimeFormat('en', { timeZone: value as string }); return null; } catch { return { zh: '请输入 IANA 时区,例如 Asia/Shanghai', en: 'Enter an IANA timezone such as Asia/Shanghai', ja: 'Asia/Shanghai などの IANA タイムゾーンを入力してください' }; }
|
||||
} },
|
||||
model_aliases: { type: 'array', optional: true, label: { zh: '仅此凭据的模型别名', en: 'Aliases for this credential', ja: 'この認証情報のモデル別名' }, item: { type: 'object', fields: {
|
||||
name: { ...nonemptyText, label: { zh: '上游模型', en: 'Upstream model', ja: '上流モデル' } },
|
||||
alias: { ...nonemptyText, label: { zh: '客户端别名', en: 'Client alias', ja: 'クライアント別名' } },
|
||||
'display-name': textShape({ zh: '展示名称', en: 'Display name', ja: '表示名' }),
|
||||
fork: boolShape({ zh: '保留原始模型', en: 'Keep original model', ja: '元のモデルを維持' }),
|
||||
'force-mapping': boolShape({ zh: '响应使用别名', en: 'Use alias in responses', ja: '応答で別名を使用' }),
|
||||
name: { ...nonemptyText, label: { zh: '模型名称', en: 'Model name', ja: 'モデル名' } },
|
||||
alias: { ...nonemptyText, label: { zh: '模型别名', en: 'Model alias', ja: 'モデル別名' } },
|
||||
'display-name': textShape({ zh: '显示名称', en: 'Display name', ja: '表示名' }),
|
||||
fork: boolShape({ zh: '在模型列表中保留原始模型', en: 'Keep the original model in the model list', ja: 'モデル一覧に元のモデルを残す' }),
|
||||
'force-mapping': boolShape({ zh: '将响应模型名称改写为别名', en: 'Rewrite response model names to the alias', ja: '応答のモデル名を別名に書き換える' }),
|
||||
} } },
|
||||
} };
|
||||
@@ -8,5 +8,6 @@
|
||||
@import './config-settings.css';
|
||||
@import '../pages/UsageEventsView.css';
|
||||
@import './usage-subpages.css';
|
||||
@import '../components/SelectMenu.css';
|
||||
@import '../pages/AgentClientList.css';
|
||||
@import './workspace-white.css';
|
||||
@@ -12,7 +12,7 @@ const renderAdvanced = (name: string, advanced: Record<string, unknown>, provide
|
||||
describe('credential settings interaction', () => {
|
||||
it('lets a Codex credential edit aliases directly and hides Claude cloaking', () => {
|
||||
const html = renderAdvanced('codex-cbb7bd85-user-team.json', {});
|
||||
expect(html).toContain('Add alias');
|
||||
expect(html).toContain('Add model');
|
||||
expect(html).toContain('Credential timezone');
|
||||
expect(html).toContain('Asia/Shanghai');
|
||||
expect(html).not.toContain('Request cloaking');
|
||||
|
||||
@@ -5,16 +5,19 @@ import { I18nProvider } from '../src/i18n';
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider>
|
||||
<CredentialAdvancedFields name="codex-user.json" advanced={{ model_aliases: [{ name: 'gpt-5', alias: '' }] }}
|
||||
models={[{ name: 'gpt-5', displayName: 'GPT 5' }]} onChange={() => {}} />
|
||||
<CredentialAdvancedFields name="codex-user.json" advanced={{ model_aliases: [{ name: 'gpt-5', alias: 'public-gpt' }] }} onChange={() => {}} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
describe('credential alias model picker', () => {
|
||||
it('uses the searchable model picker for both model fields', () => {
|
||||
expect(html.match(/role="combobox"/g)).toHaveLength(2);
|
||||
describe('credential alias editor', () => {
|
||||
it('uses plain inputs for the upstream model and client alias', () => {
|
||||
expect(html).not.toContain('role="combobox"');
|
||||
expect(html).toContain('aria-label="Model name"');
|
||||
expect(html).toContain('aria-label="Model alias"');
|
||||
expect(html).toContain('value="gpt-5"');
|
||||
expect(html).toContain('Search or enter a model');
|
||||
expect(html).not.toContain('<textarea');
|
||||
expect(html).toContain('value="public-gpt"');
|
||||
expect(html).toContain('aria-label="Expand"');
|
||||
expect(html).not.toContain('Display name');
|
||||
expect(html).not.toContain('Search or enter a model');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user