style(usage): show reasoning effort under the model name

Drop the separate reasoning-effort column from request details and render the raw effort, or auto, directly beneath the model name. Include it in the model tooltip, reset saved layouts that still use the old default column, and keep the field in CSV export.
This commit is contained in:
lzt404 committed 2026-10-06 01:22:41 +08:00
1 parent 56a88ccb6f
commit 6e34850780
22 files changed
+1130 -384

No files matched your search

+341
View File
@@ -0,0 +1,341 @@
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
type KeyboardEvent,
} from 'react';
import { Check, ChevronDown, LoaderCircle, RefreshCw, Search, X } from 'lucide-react';
import {
agentModelAlias,
filterAgentModels,
findAgentModel,
} from '../services/agentModelPicker';
import type { ModelOption } from '../services/modelService';
import { useI18n } from '../i18n';
export type AgentModelPickerProps = {
models: ModelOption[];
value: string;
loading?: boolean;
error?: string;
disabled?: boolean;
onChange: (value: string) => void;
onRefresh?: () => void;
allowCustomValue?: boolean;
editable?: { label: string; placeholder: string; maxLength: number };
menuClassName?: string;
};
type AgentModelDropdownLayout = {
top: number;
left: number;
width: number;
height: number;
};
export function AgentModelPicker({
models,
value,
loading = false,
error = '',
disabled = false,
onChange,
onRefresh,
allowCustomValue = false,
editable,
menuClassName = '',
}: AgentModelPickerProps) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const [activeIndex, setActiveIndex] = useState(0);
const [dropdownLayout, setDropdownLayout] = useState<AgentModelDropdownLayout | null>(null);
const [dialogOverlay, setDialogOverlay] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const valueRef = useRef<HTMLInputElement>(null);
const listboxId = useId();
const visibleModels = useMemo(
() => editable && !search.trim() ? models : filterAgentModels(models, search),
[models, search, editable],
);
const choices = useMemo(() => {
const options = visibleModels.map((model) => ({ name: model.name, alias: model.alias ?? '' }));
if (allowCustomValue && search.trim() && !findAgentModel(models, search)) {
options.push({ name: search.trim(), alias: t('agents.model.useCustom') });
}
return options;
}, [visibleModels, allowCustomValue, search, models, t]);
const selectedModel = findAgentModel(models, value);
const selectedName = selectedModel?.name ?? (allowCustomValue || editable ? value.trim() : '');
const selectedAlias = selectedName ? agentModelAlias(models, selectedName) : '';
const updateDropdownLayout = useCallback(() => {
const root = rootRef.current;
if (!root) return;
const rect = root.getBoundingClientRect();
const dialog = root.closest('dialog');
const footer = dialog?.querySelector('.credential-settings-footer');
const heading = dialog?.querySelector('.credential-settings-heading');
const frame = dialog?.getBoundingClientRect();
const footerTop = footer?.getBoundingClientRect().top;
const headingBottom = heading?.getBoundingClientRect().bottom;
const edgeGap = 12;
const triggerGap = 6;
const preferredHeight = 282;
const minimumHeight = 150;
const bounds = {
top: (headingBottom ?? frame?.top ?? 0) + edgeGap,
bottom: (footerTop ?? frame?.bottom ?? window.innerHeight) - edgeGap,
left: (frame?.left ?? 0) + edgeGap,
right: (frame?.right ?? window.innerWidth) - edgeGap,
};
const scroller = dialog?.querySelector('.credential-settings-body');
if (scroller) {
const view = scroller.getBoundingClientRect();
if (rect.bottom < view.top + 4 || rect.top > view.bottom - 4) {
setOpen(false);
return;
}
}
const spaceBelow = Math.max(0, bounds.bottom - rect.bottom - triggerGap);
const spaceAbove = Math.max(0, rect.top - triggerGap - bounds.top);
const placeAbove = spaceBelow < minimumHeight && spaceAbove > spaceBelow;
const availableHeight = placeAbove ? spaceAbove : spaceBelow;
const limit = Math.max(0, bounds.bottom - bounds.top);
const height = Math.min(preferredHeight, limit, Math.max(Math.min(minimumHeight, limit), availableHeight));
const width = Math.min(rect.width, Math.max(0, bounds.right - bounds.left));
const left = Math.min(Math.max(bounds.left, rect.left), Math.max(bounds.left, bounds.right - width));
const desiredTop = placeAbove ? rect.top - triggerGap - height : rect.bottom + triggerGap;
const top = Math.min(Math.max(bounds.top, desiredTop), Math.max(bounds.top, bounds.bottom - height));
setDropdownLayout({ top, left, width, height });
}, []);
useLayoutEffect(() => {
if (!open) {
setDropdownLayout(null);
setDialogOverlay(false);
return undefined;
}
const inDialog = Boolean(rootRef.current?.closest('dialog'));
if (inDialog !== dialogOverlay) {
setDialogOverlay(inDialog);
return undefined;
}
updateDropdownLayout();
const menu = menuRef.current;
if (dialogOverlay && menu && !menu.matches(':popover-open')) menu.showPopover();
const onScroll = (event: Event) => {
if (menuRef.current?.contains(event.target as Node)) return;
updateDropdownLayout();
};
window.addEventListener('resize', updateDropdownLayout);
window.addEventListener('scroll', onScroll, true);
return () => {
window.removeEventListener('resize', updateDropdownLayout);
window.removeEventListener('scroll', onScroll, true);
};
}, [open, dialogOverlay, updateDropdownLayout]);
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;
if (!editable) setSearch('');
const selectedIndex = (editable ? visibleModels : filterAgentModels(models, '')).findIndex(
(model) => model.name.toLocaleLowerCase() === value.trim().toLocaleLowerCase(),
);
setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0);
if (!editable) requestAnimationFrame(() => searchRef.current?.focus());
}, [open]);
useEffect(() => {
setActiveIndex((current) => Math.min(current, Math.max(choices.length - 1, 0)));
}, [choices.length]);
const choose = (name: string) => {
onChange(name);
setOpen(false);
if (editable) valueRef.current?.focus();
};
const moveActive = (offset: number) => {
if (choices.length === 0) return;
setActiveIndex((current) => (current + offset + choices.length) % choices.length);
};
const handleSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowDown') {
event.preventDefault();
if (!open) setOpen(true);
else moveActive(1);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
if (!open) setOpen(true);
else moveActive(-1);
} else if (event.key === 'Enter' && open && choices[activeIndex]) {
event.preventDefault();
choose(choices[activeIndex].name);
} else if (event.key === 'Escape') {
event.preventDefault();
setOpen(false);
}
};
return (
<div className={`agent-model-picker ${open ? 'open' : ''}`} ref={rootRef}
onMouseDown={(event) => {
if (event.button === 0 && event.currentTarget.contains(document.activeElement)
&& event.target instanceof Element && event.target.closest('button')) {
event.preventDefault();
}
}}
onBlur={(event) => {
const next = event.relatedTarget as Node | null;
if (!event.currentTarget.contains(next) && !menuRef.current?.contains(next)) setOpen(false);
}}>
{editable ? (
<div className="agent-model-trigger agent-model-editable-trigger">
<input ref={valueRef} type="text" value={value} aria-label={editable.label}
placeholder={editable.placeholder} maxLength={editable.maxLength} disabled={disabled}
spellCheck={false} autoComplete="off" role="combobox" aria-autocomplete="list"
aria-controls={listboxId} aria-expanded={open}
onClick={() => { setSearch(''); setOpen(true); }} onKeyDown={handleSearchKeyDown}
onChange={(event) => {
onChange(event.currentTarget.value);
setSearch(event.currentTarget.value);
setActiveIndex(0);
setOpen(true);
}} />
<button type="button" className="icon-button quiet" aria-label={editable.label}
aria-haspopup="listbox" aria-expanded={open} aria-controls={listboxId}
disabled={disabled} onClick={() => { setSearch(''); setOpen((current) => !current); }}>
<ChevronDown size={17} aria-hidden />
</button>
</div>
) : <button
type="button"
className="agent-model-trigger"
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listboxId}
disabled={disabled}
onClick={() => setOpen((current) => !current)}
onKeyDown={(event) => {
if (!open && ['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(event.key)) {
event.preventDefault();
setOpen(true);
}
}}
>
<span>
<strong title={selectedName || undefined}>
{selectedName || (loading ? t('agents.model.loading') : error ? t('agents.model.loadFailed') : models.length ? t('agents.model.select') : t('agents.model.none'))}
</strong>
{selectedAlias ? <small title={selectedAlias}>{selectedAlias}</small> : null}
</span>
<ChevronDown size={17} aria-hidden />
</button>}
{open ? (
<div
ref={menuRef}
popover={dialogOverlay ? 'manual' : undefined}
className={`agent-model-dropdown${editable ? ' agent-model-dropdown-editable' : ''}${menuClassName ? ` ${menuClassName}` : ''}`}
style={dropdownLayout
? { top: dropdownLayout.top, left: dropdownLayout.left, width: dropdownLayout.width, height: dropdownLayout.height }
: { top: 0, left: 0, width: 0, height: 0, visibility: 'hidden' }}
>
{!editable ? <div className="agent-model-search">
<Search size={15} aria-hidden />
<input
ref={searchRef}
value={search}
onChange={(event) => {
setSearch(event.currentTarget.value);
setActiveIndex(0);
}}
onKeyDown={handleSearchKeyDown}
placeholder={t('agents.model.search')}
role="combobox"
aria-controls={listboxId}
aria-expanded="true"
/>
{search ? (
<button
type="button"
className="icon-button quiet"
onClick={() => {
setSearch('');
setActiveIndex(0);
searchRef.current?.focus();
}}
title={t('agents.model.clearSearch')}
aria-label={t('agents.model.clearSearch')}
>
<X size={14} aria-hidden="true" />
</button>
) : null}
{onRefresh ? <button type="button" className="icon-button quiet" onClick={onRefresh} disabled={loading} title={t('agents.model.refresh')} aria-label={t('agents.model.refresh')}>
<RefreshCw size={14} className={loading ? 'spin' : ''} aria-hidden="true" />
</button> : null}
</div> : null}
<div className="agent-model-list" id={listboxId} role="listbox">
{loading && models.length === 0 && choices.length === 0 ? (
<div className="agent-model-empty"><LoaderCircle size={18} className="spin" />{t('agents.model.fetching')}</div>
) : error && models.length === 0 && choices.length === 0 ? (
<div className="agent-model-empty error"><strong>{t('agents.model.loadFailed')}</strong><span>{error}</span></div>
) : choices.length === 0 ? (
<div className="agent-model-empty">
{editable ? <span>{t('agents.claudeDesktopMapping.customAliasHint')}</span> : <>
<strong>{search.trim() ? t('agents.model.noMatch') : t('agents.model.unavailable')}</strong>
<span>{search.trim() ? t('agents.model.tryKeywords') : t('agents.model.connectFirst')}</span>
</>}
</div>
) : choices.map((choice, index) => {
const selected = choice.name.toLocaleLowerCase() === value.trim().toLocaleLowerCase();
return (
<button
type="button"
role="option"
aria-selected={selected}
className={`agent-model-option ${selected ? 'selected' : ''} ${index === activeIndex ? 'active' : ''}`}
key={choice.name}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => choose(choice.name)}
>
<span>
<strong title={choice.name}>{choice.name}</strong>
<small>{choice.alias || t('agents.model.available')}</small>
</span>
{selected ? <Check size={16} aria-hidden /> : null}
</button>
);
})}
</div>
<div className="agent-model-dropdown-footer">
<span>{t(editable ? 'agents.claudeDesktopMapping.suggestionCount' : 'agents.model.count', { count: models.length })}</span>
{error && models.length > 0 ? <span className="error">{t('agents.model.stale')}</span> : null}
</div>
</div>
) : null}
</div>
);
}
+61 -7
View File
@@ -1,6 +1,8 @@
.credential-settings-dialog {
width: min(760px, calc(100vw - 32px));
width: min(820px, calc(100vw - 32px));
max-height: min(900px, calc(100dvh - 40px));
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
border: 1px solid var(--border-color);
@@ -10,17 +12,17 @@
box-shadow: 0 24px 80px #0004;
}
.credential-settings-dialog::backdrop { background: #0b122080; backdrop-filter: blur(3px); }
.credential-settings-dialog form { display: flex; flex-direction: column; max-height: min(898px, calc(100dvh - 42px)); min-height: 0; }
.credential-settings-dialog form { display: flex; flex: 1 1 auto; flex-direction: column; width: 100%; min-height: 0; }
.credential-settings-heading, .credential-settings-footer { flex-shrink: 0; }
.credential-settings-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 20px 24px; border-bottom: 1px solid var(--border-color); }
.credential-settings-heading > div { min-width: 0; }
.credential-settings-heading h2 { display: flex; align-items: center; gap: 9px; margin: 0; font-size: var(--font-size-dialog-title); line-height: var(--line-height-tight); }
.credential-settings-heading p { margin: 6px 0 0; color: var(--text-tertiary); font-size: var(--font-size-meta); overflow-wrap: anywhere; }
.credential-settings-body { overflow-y: auto; min-height: 0; padding: 22px 24px; overscroll-behavior: contain; }
.credential-settings-body { overflow: auto; flex: 1 1 auto; min-height: 0; padding: 22px 24px 28px; overscroll-behavior: contain; }
.credential-settings-body fieldset { border: 0; padding: 0; margin: 0; min-width: 0; }
.credential-settings-section + .credential-settings-section { margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--border-color); }
.credential-settings-section h3 { margin: 0 0 15px; font-size: var(--font-size-section); line-height: var(--line-height-tight); }
.credential-settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px 20px; }
.credential-settings-section + .credential-settings-section { margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--border-color); }
.credential-settings-section h3 { margin: 0 0 8px; font-size: var(--font-size-section); line-height: var(--line-height-tight); }
.credential-settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 16px; }
.credential-settings-field { display: grid; align-content: start; gap: 7px; min-width: 0; font-size: var(--font-size-label); }
.credential-settings-field > span { font-weight: var(--font-weight-medium); }
.credential-settings-field + .credential-settings-field { margin-top: 16px; }
@@ -40,7 +42,7 @@
.credential-settings-models label { display: flex; align-items: flex-start; gap: 8px; overflow-wrap: anywhere; }
.credential-settings-models input { accent-color: var(--primary-color); margin-top: 3px; }
.credential-settings-models span { min-width: 0; }
.credential-settings-footer { padding: 16px 24px; border-top: 1px solid var(--border-color); }
.credential-settings-footer { position: relative; z-index: 1; flex: 0 0 auto; padding: 16px 24px; border-top: 1px solid var(--border-color); background: var(--bg-card); }
.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); }
@@ -51,3 +53,55 @@
.credential-settings-grid { grid-template-columns: minmax(0, 1fr); }
.credential-settings-heading, .credential-settings-body, .credential-settings-footer { padding: 16px; }
}
.credential-settings-lead { margin: 0 0 10px; color: var(--text-tertiary); font-size: var(--font-size-meta); line-height: var(--line-height-body); }
.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-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; }
.credential-header-row input { min-width: 0; }
@media (max-width: 560px) {
.credential-header-row { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 560px) {
.credential-alias-fields { grid-template-columns: minmax(0, 1fr); }
}
+16 -20
View File
@@ -6,22 +6,23 @@ import { managementApi } from '../services/managementApi';
import { loadAuthFileSettings, saveAuthFileSettings, type AuthFileSettingsDraft, type BooleanOverride } from '../services/authFileSettings';
import { modelMatchesRule, normalizeOAuthExcludedRules, oauthModelCandidates, oauthModelsFromPayload, setOAuthModelsExcluded, type OAuthModelDefinition } from '../services/oauthModels';
import './AuthFileSettingsDialog.css';
import { StructuredConfigEditor, useConfigText } from './StructuredConfigEditor';
import { credentialAdvancedShape } from '../services/credentialAdvancedSettings';
import { CredentialAdvancedFields, CredentialHeadersEditor, credentialProviderKey } from './CredentialAdvancedFields';
import { modelSearchText, type ModelOption } from '../services/modelService';
export function AuthFileSettingsDialog({ name, onClose, onSaved }: {
export function AuthFileSettingsDialog({ name, provider = '', onClose, onSaved }: {
name: string;
provider?: string;
onClose: () => void;
onSaved: () => void;
}) {
const { t } = useI18n();
const tx = useConfigText();
const id = useId();
const dialogRef = useRef<HTMLDialogElement>(null);
const savingRef = useRef(false);
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('');
@@ -57,7 +58,10 @@ export function AuthFileSettingsDialog({ name, onClose, onSaved }: {
if (active) setError(reason instanceof Error ? reason.message : String(reason));
}).finally(() => { if (active) setLoading(false); });
void managementApi.get('/credentials/models', { name }).then((payload) => {
if (active) setModels(oauthModelsFromPayload(payload));
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); });
@@ -92,8 +96,10 @@ export function AuthFileSettingsDialog({ name, onClose, onSaved }: {
}
};
const rules = normalizeOAuthExcludedRules((draft?.excluded_models ?? '').split(/\r?\n/));
const candidates = oauthModelCandidates(models, rules).filter((model) =>
`${model.id} ${model.displayName ?? ''}`.toLowerCase().includes(search.trim().toLowerCase()));
const query = search.trim().toLowerCase();
const candidates = oauthModelCandidates(models, rules).filter((model) => modelSearchText({ name: model.id, displayName: model.displayName }).includes(query));
const resolvedProvider = credentialProviderKey(name, provider);
const showWebsockets = resolvedProvider === 'codex' || resolvedProvider === 'xai' || draft?.websockets === 'true' || draft?.websockets === 'false';
const textField = (key: 'prefix' | 'proxy_url' | 'priority' | 'weight') => (
<label className="credential-settings-field">
<span>{t(`authFiles.settings.${key}`)} <code>{key}</code></span>
@@ -142,7 +148,7 @@ export function AuthFileSettingsDialog({ name, onClose, onSaved }: {
<fieldset disabled={saving}>
<section className="credential-settings-section">
<h3>{t('authFiles.settings.routing')}</h3>
<div className="credential-settings-grid">{textField('prefix')}{textField('proxy_url')}{textField('priority')}{textField('weight')}{booleanField('disable_cooling')}{booleanField('websockets')}</div>
<div className="credential-settings-grid">{textField('prefix')}{textField('proxy_url')}{textField('priority')}{textField('weight')}{booleanField('disable_cooling')}{showWebsockets ? booleanField('websockets') : null}</div>
</section>
<section className="credential-settings-section">
<h3>{t('authFiles.settings.excluded_models')} <code>excluded_models</code></h3>
@@ -169,20 +175,10 @@ export function AuthFileSettingsDialog({ name, onClose, onSaved }: {
</div>
</details>
</section>
<section className="credential-settings-section">
<details>
<summary>{tx({ zh: '模型别名与 Claude 兼容设置', en: 'Model aliases and Claude compatibility', ja: 'モデル別名と Claude 互換性' })}</summary>
<p><small>{tx({ zh: '这里的别名仅作用于此凭据,优先于全局别名。伪装与请求指纹适用于 Claude 及兼容委派凭据;未设置的字段继续使用默认行为。', en: 'Aliases apply only to this credential and take precedence over global aliases. Cloaking and fingerprints apply to Claude and compatible delegated credentials. Unset fields retain default behavior.', ja: '別名はこの認証情報にのみ適用され、全体の別名より優先されます。偽装とフィンガープリントは Claude と互換の委譲認証情報に適用します。' })}</small></p>
<StructuredConfigEditor shape={credentialAdvancedShape} value={draft.advanced} onChange={value => update('advanced', value as Record<string, unknown>)} disabled={saving} id={`${id}-advanced`} />
</details>
</section>
<CredentialAdvancedFields name={name} provider={provider} advanced={draft.advanced} models={pickerModels} modelsLoading={catalogLoading} modelsError={catalogError} disabled={saving} onChange={(advanced) => update('advanced', advanced)} />
<section className="credential-settings-section">
<h3>{t('authFiles.settings.additional')}</h3>
<label className="credential-settings-field">
<span>{t('authFiles.settings.headers')} <code>headers</code></span>
<textarea className="credential-settings-json" rows={5} value={draft.headers} onChange={(event) => update('headers', event.currentTarget.value)} spellCheck={false} autoComplete="off" />
<small>{t('authFiles.settings.headersHint')}</small>
</label>
<CredentialHeadersEditor value={draft.headers} disabled={saving} onChange={(headers) => update('headers', headers)} />
<label className="credential-settings-field">
<span>{t('authFiles.settings.note')} <code>note</code></span>
<textarea rows={2} value={draft.note} onChange={(event) => update('note', event.currentTarget.value)} />
+235
View File
@@ -0,0 +1,235 @@
import { useState } from 'react';
import { Plus, Undo2, X } from 'lucide-react';
import { AgentModelPicker } from './AgentModelPicker';
import type { ModelOption } from '../services/modelService';
import { useI18n } from '../i18n';
import type { MessageKey } from '../i18n/resources';
import { isRecord } from '../services/managementApi';
import { normalizeOAuthProvider } from '../services/authFiles';
type Advanced = Record<string, unknown>;
type AliasRow = Record<string, unknown>;
const claudeKeys = ['cloak_mode', 'cloak_strict_mode', 'cloak_cache_user_id', 'cloak_sensitive_words', 'fingerprint_profile'] as const;
export function credentialProviderKey(name: string, provider = ''): string {
const explicit = normalizeOAuthProvider(provider);
if (explicit) return explicit;
const prefix = name.trim().toLowerCase().replace(/\.json$/i, '').split('-')[0] ?? '';
return normalizeOAuthProvider(prefix);
}
export function showsClaudeCredentialSettings(provider: string, advanced: Advanced): boolean {
return provider === 'claude' || claudeKeys.some((key) => advanced[key] !== undefined);
}
function withoutKey(advanced: Advanced, key: string, value: unknown): Advanced {
const next = { ...advanced };
if (value === undefined) delete next[key];
else next[key] = value;
return next;
}
function textValue(value: unknown): string {
return typeof value === 'string' ? value : '';
}
function wordsValue(value: unknown): string {
if (Array.isArray(value)) return value.filter((word): word is string => typeof word === 'string').join(', ');
return typeof value === 'string' ? value : '';
}
function aliasRows(value: unknown): AliasRow[] {
return Array.isArray(value) ? value.filter(isRecord) : [];
}
export function CredentialAdvancedFields({
name, provider = '', advanced, models = [], modelsLoading = false, modelsError = '', onChange, disabled = false,
}: {
name: string;
provider?: string;
advanced: Advanced;
models?: ModelOption[];
modelsLoading?: boolean;
modelsError?: string;
onChange: (advanced: Advanced) => void;
disabled?: boolean;
}) {
const { t } = useI18n();
const resolvedProvider = credentialProviderKey(name, provider);
const showClaude = showsClaudeCredentialSettings(resolvedProvider, advanced);
const aliases = aliasRows(advanced.model_aliases);
const aliasesSet = Array.isArray(advanced.model_aliases);
const [wordsDraft, setWordsDraft] = useState<string | 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 }>) => {
const current = textValue(advanced[key]);
return (
<label className="credential-settings-field">
<span>{t(label)}</span>
<select value={options.some((option) => option.value === current) ? current : ''} disabled={disabled} onChange={(event) => setField(key, event.currentTarget.value || undefined)}>
<option value="">{t('authFiles.settings.inherit')}</option>
{options.map((option) => <option key={option.value} value={option.value}>{t(option.label)}</option>)}
</select>
</label>
);
};
const boolChoice = (label: MessageKey, key: 'cloak_strict_mode' | 'cloak_cache_user_id') => (
<label className="credential-settings-field">
<span>{t(label)}</span>
<select value={tri(advanced[key])} disabled={disabled} onChange={(event) => setField(key, event.currentTarget.value === '' ? undefined : event.currentTarget.value === 'true')}>
<option value="">{t('authFiles.settings.inherit')}</option>
<option value="true">{t('common.enable')}</option>
<option value="false">{t('common.disable')}</option>
</select>
</label>
);
const updateAlias = (index: number, patch: AliasRow) => {
const next = aliases.map((row, rowIndex) => {
if (rowIndex !== index) return row;
const merged = { ...row, ...patch };
Object.entries(patch).forEach(([key, value]) => { if (value === undefined) delete merged[key]; });
return merged;
});
setField('model_aliases', next);
};
return (
<>
<section className="credential-settings-section">
<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>
</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>
<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> : <p className="credential-settings-lead">{t(aliasesSet ? 'authFiles.settings.aliasCleared' : 'authFiles.settings.aliasInherited')}</p>}
<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>
{aliasesSet ? <button type="button" className="secondary-button compact-button" disabled={disabled} onClick={() => setField('model_aliases', undefined)}><Undo2 size={14} />{t('authFiles.settings.aliasInherit')}</button> : null}
</div>
</section>
{showClaude ? <section className="credential-settings-section">
<h3>{t('authFiles.settings.claude')}</h3>
<p className="credential-settings-lead">{t('authFiles.settings.claudeHint')}</p>
<div className="credential-settings-grid">
{choice('authFiles.settings.cloak_mode', 'cloak_mode', [
{ value: 'auto', label: 'authFiles.settings.cloak_mode.auto' },
{ value: 'always', label: 'authFiles.settings.cloak_mode.always' },
{ value: 'never', label: 'authFiles.settings.cloak_mode.never' },
])}
{boolChoice('authFiles.settings.cloak_strict_mode', 'cloak_strict_mode')}
{boolChoice('authFiles.settings.cloak_cache_user_id', 'cloak_cache_user_id')}
{choice('authFiles.settings.fingerprint_profile', 'fingerprint_profile', [
{ value: 'claude-code-cli', label: 'authFiles.settings.fingerprint_profile.claude-code-cli' },
{ value: 'oauth-cli', label: 'authFiles.settings.fingerprint_profile.oauth-cli' },
])}
<label className="credential-settings-field credential-settings-span">
<span>{t('authFiles.settings.cloak_sensitive_words')}</span>
<input value={wordsDraft ?? wordsValue(advanced.cloak_sensitive_words)} disabled={disabled} autoComplete="off" spellCheck={false}
placeholder={t('authFiles.settings.cloakWordsPlaceholder')}
onChange={(event) => {
const text = event.currentTarget.value;
setWordsDraft(text);
const words = text.split(',').map((word) => word.trim()).filter(Boolean);
setField('cloak_sensitive_words', words.length ? words : undefined);
}}
onBlur={() => setWordsDraft(null)} />
<small>{t('authFiles.settings.cloakWordsHint')}</small>
</label>
</div>
</section> : null}
<section className="credential-settings-section">
<div className="credential-settings-grid">
<label className="credential-settings-field">
<span>{t('authFiles.settings.timezone')}</span>
<input value={textValue(advanced.timezone)} disabled={disabled} autoComplete="off" spellCheck={false} placeholder="Asia/Shanghai"
onChange={(event) => setField('timezone', event.currentTarget.value)}
onBlur={(event) => setField('timezone', event.currentTarget.value.trim() || undefined)} />
<small>{t('authFiles.settings.timezoneHint')}</small>
</label>
</div>
</section>
</>
);
}
export function headerEntries(value: string): Array<{ name: string; value: string }> | null {
let parsed: unknown;
try { parsed = JSON.parse(value.trim() || '{}'); } catch { return null; }
if (!isRecord(parsed) || Object.values(parsed).some((item) => typeof item !== 'string')) return null;
return Object.entries(parsed).map(([name, item]) => ({ name, value: String(item) }));
}
export function CredentialHeadersEditor({ value, onChange, disabled = false }: {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
}) {
const { t } = useI18n();
const parsed = headerEntries(value);
const [rows, setRows] = useState<Array<{ name: string; value: string }> | null>(null);
const visible = rows ?? parsed ?? [];
const write = (next: Array<{ name: string; value: string }>) => {
setRows(next);
const headers: Record<string, string> = {};
next.forEach((row) => {
if (row.name.trim() || row.value.trim()) headers[row.name] = row.value;
});
onChange(JSON.stringify(headers, null, 2));
};
if (parsed === null) {
return (
<label className="credential-settings-field">
<span>{t('authFiles.settings.headers')} <code>headers</code></span>
<textarea className="credential-settings-json" rows={5} value={value} disabled={disabled} spellCheck={false} autoComplete="off" onChange={(event) => onChange(event.currentTarget.value)} />
<small>{t('authFiles.settings.headersHint')}</small>
</label>
);
}
return (
<div className="credential-settings-field">
<span>{t('authFiles.settings.headers')}</span>
{visible.length ? <div className="credential-header-list">
{visible.map((row, index) => <div className="credential-header-row" key={index}>
<input aria-label={t('authFiles.settings.headerName')} value={row.name} disabled={disabled} autoComplete="off" spellCheck={false} placeholder={t('authFiles.settings.headerName')}
onChange={(event) => write(visible.map((item, itemIndex) => itemIndex === index ? { ...item, name: event.currentTarget.value } : item))} />
<input aria-label={t('authFiles.settings.headerValue')} value={row.value} disabled={disabled} autoComplete="off" spellCheck={false} placeholder={t('authFiles.settings.headerValue')}
onChange={(event) => write(visible.map((item, itemIndex) => itemIndex === index ? { ...item, value: event.currentTarget.value } : item))} />
<button type="button" className="icon-button quiet" disabled={disabled} aria-label={t('authFiles.settings.headerRemove', { name: row.name || String(index + 1) })}
onClick={() => write(visible.filter((_, itemIndex) => itemIndex !== index))}><X size={15} /></button>
</div>)}
</div> : <p className="credential-settings-lead">{t('authFiles.settings.headersEmpty')}</p>}
<div className="credential-settings-inline-actions">
<button type="button" className="secondary-button compact-button" disabled={disabled} onClick={() => write([...visible, { name: '', value: '' }])}><Plus size={14} />{t('authFiles.settings.headerAdd')}</button>
</div>
<small>{t('authFiles.settings.headersRowHint')}</small>
</div>
);
}