feat(aliases): generate default names and improve Fast toggle

This commit is contained in:
lzt404 committed 2026-08-03 21:33:48 +08:00
1 parent 6000b3159b
commit d698510473
7 files changed
+254 -36

No files matched your search

+6
View File
@@ -1,6 +1,10 @@
import type { MessageKey } from './resources';
export const jaOverrides = {
'apiAccess.models.add': 'カスタムモデルを追加',
'apiAccess.models.namePlaceholder': 'モデル名(例: gpt-4o)',
'apiAccess.models.aliasPlaceholder': 'モデル別名(任意)',
'apiAccess.models.remove': 'モデルを削除',
'app.language': '言語',
'app.theme.light': 'ライトモード',
'app.theme.dark': 'ダークモード',
@@ -206,6 +210,7 @@ export const jaOverrides = {
'aliases.error.emptyEffort': '推論レベルを入力してください',
'aliases.error.emptyOptions': '推論レベルを設定するか、Fast を有効にしてください',
'aliases.error.unsupportedEffort': 'このモデルでは推論レベルを設定できないため、Fast オプションのみ使用できます',
'aliases.error.unsupportedFast': 'このモデルでは Fast を利用できません',
'aliases.created': '{alias} を作成し、推論レベルを {effort} に固定しました',
'aliases.createdCombined': '{alias} を作成し、推論レベル {effort} と Fast を有効にしました',
'aliases.deleteConfirm': 'エイリアスモデル「{alias}」を削除しますか?\n対応する推論レベルと Fast の上書きルールも削除されます。',
@@ -237,6 +242,7 @@ export const jaOverrides = {
'aliases.customHelp': '対象モデルが実際に対応するレベル名のみ入力してください',
'aliases.aliasName.title': 'エイリアスモデル名',
'aliases.aliasName.description': '新しい入口のみを追加し、元のモデル名は変更しません',
'aliases.aliasName.autoDescription': 'モデルと選択したオプションから自動生成します。元のモデル名は変更せず編集できます',
'aliases.aliasName.example': '例:{model}-{effort}',
'aliases.aliasName.selectFirst': '先に元のモデルを選択し、クライアントで使用するエイリアスを入力してください',
'aliases.notSelected': 'モデル未選択',
+12
View File
@@ -4,6 +4,10 @@ import { jaOverrides } from './ja';
export type MessageVariables = Record<string, string | number>;
export const zhCN = {
'apiAccess.models.add': '添加自定义模型',
'apiAccess.models.namePlaceholder': '模型名称,例如 gpt-4o',
'apiAccess.models.aliasPlaceholder': '模型别名(可选)',
'apiAccess.models.remove': '删除模型',
'app.language': '语言',
'app.theme.light': '日间模式',
'app.theme.dark': '夜间模式',
@@ -209,6 +213,7 @@ export const zhCN = {
'aliases.error.emptyEffort': '思考强度不能为空',
'aliases.error.emptyOptions': '请至少设置思考强度或启用 Fast',
'aliases.error.unsupportedEffort': '该模型不支持设置思考强度,只能使用 Fast 选项',
'aliases.error.unsupportedFast': '该模型当前不支持 Fast 选项',
'aliases.created': '已创建 {alias},思考强度固定为 {effort}',
'aliases.createdCombined': '已创建 {alias},思考强度为 {effort},同时启用 Fast',
'aliases.deleteConfirm': '确定删除别名模型“{alias}”吗?\n将同时移除对应的思考强度和 Fast 覆盖规则。',
@@ -240,6 +245,7 @@ export const zhCN = {
'aliases.customHelp': '仅填写目标模型实际支持的等级名称',
'aliases.aliasName.title': '别名模型名称',
'aliases.aliasName.description': '由你填写,仅新增入口,不会改名原模型',
'aliases.aliasName.autoDescription': '根据原模型和已选选项自动生成,可按需修改;不会改名原模型',
'aliases.aliasName.example': '例如 {model}-{effort}',
'aliases.aliasName.selectFirst': '先选择原模型,再填写客户端使用的别名',
'aliases.notSelected': '未选择模型',
@@ -956,6 +962,10 @@ export const zhCN = {
export type MessageKey = keyof typeof zhCN;
export const en: Record<MessageKey, string> = {
'apiAccess.models.add': 'Add Custom Model',
'apiAccess.models.namePlaceholder': 'Model name, for example gpt-4o',
'apiAccess.models.aliasPlaceholder': 'Model alias (optional)',
'apiAccess.models.remove': 'Remove model',
'app.language': 'Language',
'app.theme.light': 'Light mode',
'app.theme.dark': 'Dark mode',
@@ -1161,6 +1171,7 @@ export const en: Record<MessageKey, string> = {
'aliases.error.emptyEffort': 'The reasoning effort cannot be empty',
'aliases.error.emptyOptions': 'Set a reasoning effort or enable Fast',
'aliases.error.unsupportedEffort': 'This model does not support a reasoning-effort override; only Fast can be used',
'aliases.error.unsupportedFast': 'Fast is not available for this model',
'aliases.created': 'Created {alias} with reasoning effort fixed to {effort}',
'aliases.createdCombined': 'Created {alias} with reasoning effort {effort} and Fast enabled',
'aliases.deleteConfirm': 'Delete the model alias “{alias}”?\nIts reasoning-effort and Fast overrides will also be removed.',
@@ -1192,6 +1203,7 @@ export const en: Record<MessageKey, string> = {
'aliases.customHelp': 'Enter only a level name supported by the target model',
'aliases.aliasName.title': 'Model Alias',
'aliases.aliasName.description': 'Adds another entry point without renaming the original model',
'aliases.aliasName.autoDescription': 'Generated from the model and selected options; edit it as needed without renaming the original',
'aliases.aliasName.example': 'For example: {model}-{effort}',
'aliases.aliasName.selectFirst': 'Select the original model, then enter the alias clients will use',
'aliases.notSelected': 'No model selected',
+84 -10
View File
@@ -244,8 +244,12 @@ export const stripResponseFields = (record: Record<string, unknown>) => {
const mergeModelRecords = (current: unknown, selected: ModelOption[]) => {
const existing = Array.isArray(current) ? current : [];
return selected.map((model) => {
const seen = new Set<string>();
return selected.reduce<Record<string, unknown>[]>((models, model) => {
const name = model.name.trim();
const key = name.toLowerCase();
if (!name || seen.has(key)) return models;
seen.add(key);
const matched = existing.find(
(item) => isRecord(item) && readString(item, 'name').toLowerCase() === name.toLowerCase(),
);
@@ -255,8 +259,9 @@ const mergeModelRecords = (current: unknown, selected: ModelOption[]) => {
if (alias && alias !== name) next.alias = alias;
else delete next.alias;
if (model.thinking) next.thinking = { ...model.thinking };
return next;
});
models.push(next);
return models;
}, []);
};
export const exclusionsForModelSelection = (
@@ -765,6 +770,10 @@ export function ApiAccessPage() {
activeCategory,
applyProviderPreset(activeCategory, nextDraft),
);
const preparedDraftForSave = {
...preparedDraft,
models: preparedDraft.models.filter((model) => model.name.trim()),
};
const baseUrlRequired = definition.openAi || definition.section === 'codex-api-key';
const parsedApiKeys = preparedDraft.apiKey
.split(/\r?\n/)
@@ -801,7 +810,7 @@ export function ApiAccessPage() {
setBusy(true);
setError('');
try {
let draftToSave = { ...preparedDraft, baseUrl };
let draftToSave = { ...preparedDraftForSave, baseUrl };
if (definition.openAi && draftToSave.models.length === 0) {
const fetchedModels = await fetchModels(
'openai',
@@ -1324,6 +1333,11 @@ function ApiProviderDialog({
return Array.from(options.values());
}, [discoveredModels, draft.models]);
const configuredModels = useMemo(
() => draft.models.filter((model) => model.name.trim()),
[draft.models],
);
const visibleModelOptions = useMemo(() => {
const query = modelSearch.trim().toLowerCase();
if (!query) return modelOptions;
@@ -1349,6 +1363,29 @@ function ApiProviderDialog({
setDraft((current) => ({ ...current, [field]: value }));
};
const updateModel = (index: number, patch: Partial<ModelOption>) => {
setDraft((current) => ({
...current,
models: current.models.map((model, modelIndex) =>
modelIndex === index ? { ...model, ...patch } : model,
),
}));
};
const addModel = () => {
setDraft((current) => ({
...current,
models: [...current.models, { name: '', alias: '' }],
}));
};
const removeModel = (index: number) => {
setDraft((current) => ({
...current,
models: current.models.filter((_, modelIndex) => modelIndex !== index),
}));
};
const addThinkingLevel = () => {
const level = thinkingLevelInput.trim().toLowerCase();
if (!level) return;
@@ -1400,7 +1437,11 @@ function ApiProviderDialog({
{ ...draft, models: fetchedModels },
).models;
setDiscoveredModels(models);
setSelectedModelNames(allModelSelectionForDiscovery(models));
setSelectedModelNames(new Set(
[...models, ...draft.models]
.map((model) => model.name.trim().toLowerCase())
.filter(Boolean),
));
if (!models.length) setModelError(t('apiAccess.error.noAvailableModels'));
} catch (requestError) {
setDiscoveredModels([]);
@@ -1470,15 +1511,15 @@ function ApiProviderDialog({
const hasModelExclusions = activeSection !== 'openai-compatibility'
&& Boolean(draft.excludedModelsText?.trim());
const modelSummaryTitle = draft.models.length > 0
? t('apiAccess.models.selected', { count: draft.models.length })
const modelSummaryTitle = configuredModels.length > 0
? t('apiAccess.models.selected', { count: configuredModels.length })
: hasModelExclusions
? t('apiAccess.models.restricted')
: activeSection === 'openai-compatibility'
? t('apiAccess.models.autoAll')
: t('apiAccess.models.upstreamDefault');
const modelSummaryDetail = draft.models.length > 0
? draft.models.slice(0, 3).map((model) => model.name).join('、')
const modelSummaryDetail = configuredModels.length > 0
? configuredModels.slice(0, 3).map((model) => model.name).join('、')
: hasModelExclusions
? t('apiAccess.models.hiddenHint')
: activeSection === 'openai-compatibility'
@@ -1570,10 +1611,43 @@ function ApiProviderDialog({
<RefreshCw size={15} />{t('apiAccess.models.fetch')}
</button>
</div>
<div className={`model-config-summary ${draft.models.length || hasModelExclusions ? 'has-models' : ''}`}>
<div className={`model-config-summary ${configuredModels.length || hasModelExclusions ? 'has-models' : ''}`}>
<strong>{modelSummaryTitle}</strong>
<span>{modelSummaryDetail}</span>
</div>
<div className="model-config-entries">
{draft.models.map((model, index) => (
<div className="model-config-entry" key={index}>
<input
value={model.name}
onChange={(event) => updateModel(index, { name: event.currentTarget.value })}
placeholder={t('apiAccess.models.namePlaceholder')}
aria-label={t('apiAccess.models.namePlaceholder')}
disabled={busy}
/>
<input
value={model.alias ?? ''}
onChange={(event) => updateModel(index, { alias: event.currentTarget.value })}
placeholder={t('apiAccess.models.aliasPlaceholder')}
aria-label={t('apiAccess.models.aliasPlaceholder')}
disabled={busy}
/>
<button
type="button"
className="icon-button quiet danger"
onClick={() => removeModel(index)}
disabled={busy}
title={t('apiAccess.models.remove')}
aria-label={t('apiAccess.models.remove')}
>
<Trash2 size={14} />
</button>
</div>
))}
<button type="button" className="secondary-button compact-button model-config-add" onClick={addModel} disabled={busy}>
<Plus size={14} />{t('apiAccess.models.add')}
</button>
</div>
{modelError && !modelDiscoveryOpen ? <small className="model-picker-error">{modelError}</small> : null}
</div>
<label><span>{t('apiAccess.field.priority')}</span><input inputMode="numeric" value={draft.priority} onChange={(event) => updateTextField('priority', event.currentTarget.value.replace(/\D/g, ''))} placeholder={t('common.optional')} /></label>
+54 -13
View File
@@ -53,6 +53,7 @@ type ThinkingAliasSource = {
type ModelAliasSource = ThinkingAliasSource & {
supportsReasoning: boolean;
supportsFast: boolean;
};
const effortOptions = [
@@ -96,16 +97,29 @@ export const combineModelAliasSources = (
speedSources: ThinkingAliasSource[],
): ModelAliasSource[] => {
const reasoningSourceIds = new Set(thinkingSources.map((source) => source.id));
const speedSourceIds = new Set(speedSources.map((source) => source.id));
const sources = new Map<string, ModelAliasSource>();
[...speedSources, ...thinkingSources].forEach((source) => {
sources.set(source.id, {
...source,
supportsReasoning: reasoningSourceIds.has(source.id),
supportsFast: speedSourceIds.has(source.id),
});
});
return [...sources.values()];
};
export const defaultModelAlias = (
model: string | null | undefined,
effort: string,
fast: boolean,
) => {
const normalizedModel = model?.trim() ?? '';
const normalizedEffort = effort.trim().toLowerCase();
if (!normalizedModel || (!normalizedEffort && !fast)) return '';
return `${normalizedModel}${normalizedEffort ? `-${normalizedEffort}` : ''}${fast ? '-fast' : ''}`;
};
export const thinkingAliasSourceKindLabel = (kind: string) => {
if (kind === 'codex-oauth') return 'Codex OAuth';
if (kind === 'codex-api') return 'Codex API';
@@ -138,6 +152,7 @@ export function ThinkingAliasesPage() {
const [modelPickerOpen, setModelPickerOpen] = useState(false);
const [activeSourceIndex, setActiveSourceIndex] = useState(0);
const modelPickerRef = useRef<HTMLDivElement>(null);
const generatedAliasRef = useRef('');
const [loading, setLoading] = useState(true);
const [busyAlias, setBusyAlias] = useState('');
const [busyAction, setBusyAction] = useState<'create' | 'delete' | ''>('');
@@ -236,9 +251,14 @@ export function ThinkingAliasesPage() {
setSelectedSourceId(source.id);
if (!source.supportsReasoning) {
setEffort('');
setFastEnabled(true);
setCustomEffortOpen(false);
setFastEnabled(source.supportsFast);
} else {
setEffort((current) => current || 'xhigh');
setFastEnabled(false);
}
setCustomEffortOpen(false);
setAlias('');
generatedAliasRef.current = '';
setModelPickerOpen(false);
setSearch('');
};
@@ -274,11 +294,22 @@ export function ThinkingAliasesPage() {
};
const normalizedEffort = effort.trim().toLowerCase();
const defaultAlias = defaultModelAlias(selectedSource?.model, normalizedEffort, fastEnabled);
const customEffortSelected = Boolean(
normalizedEffort
&& !effortOptions.some((option) => option.value === normalizedEffort),
);
useEffect(() => {
setAlias((current) => {
const currentValue = current.trim();
const canReplace = !currentValue || currentValue === generatedAliasRef.current;
if (!canReplace) return current;
generatedAliasRef.current = defaultAlias;
return defaultAlias;
});
}, [defaultAlias]);
const createAlias = async () => {
if (!selectedSource) {
setError(t('aliases.error.selectModel'));
@@ -293,6 +324,10 @@ export function ThinkingAliasesPage() {
setError(t('aliases.error.emptyOptions'));
return;
}
if (fastEnabled && !selectedSource.supportsFast) {
setError(t('aliases.error.unsupportedFast'));
return;
}
if (normalizedEffort && !selectedSource.supportsReasoning) {
setError(t('aliases.error.unsupportedEffort'));
return;
@@ -549,23 +584,29 @@ export function ThinkingAliasesPage() {
<strong>{t('aliases.fast.title')}</strong>
<span>{t('aliases.fast.description')}</span>
</div>
<button
type="button"
className={`thinking-speed-toggle${fastEnabled ? ' active' : ''}`}
aria-pressed={fastEnabled}
onClick={() => setFastEnabled((current) => !current)}
disabled={Boolean(busyAlias)}
>
<span>Fast</span>
<small>{fastEnabled ? t('aliases.fast.enabled') : t('aliases.fast.disabled')}</small>
</button>
<label className={`thinking-fast-option${fastEnabled ? ' active' : ''}`}>
<span className="thinking-fast-option-copy">
<span><Zap size={15} /> Fast</span>
<small>{fastEnabled ? t('aliases.fast.enabled') : t('aliases.fast.disabled')}</small>
</span>
<span className="switch-control thinking-fast-switch">
<input
type="checkbox"
checked={fastEnabled}
onChange={(event) => setFastEnabled(event.currentTarget.checked)}
disabled={Boolean(busyAlias) || !selectedSource || !selectedSource.supportsFast}
aria-label={t('aliases.fast.title')}
/>
<span className="switch-track" />
</span>
</label>
</div>
<div className="thinking-alias-section-divider" aria-hidden="true" />
<div className="thinking-alias-field">
<div className="thinking-field-heading">
<strong>{t('aliases.aliasName.title')}</strong>
<span>{t('aliases.aliasName.description')}</span>
<span>{t('aliases.aliasName.autoDescription')}</span>
</div>
<input
id="thinking-alias-name"
+70 -13
View File
@@ -2895,6 +2895,32 @@ textarea:focus-visible {
font-size: var(--font-size-meta);
}
.model-config-entries {
display: grid;
gap: 6px;
}
.model-config-entry {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr) auto;
align-items: center;
gap: 6px;
}
.model-config-entry input {
width: 100%;
min-width: 0;
}
.model-config-entry .icon-button {
width: 32px;
height: 32px;
}
.model-config-add {
justify-self: start;
}
.model-picker-error {
color: var(--theme-9f2d20);
font-size: var(--font-size-meta);
@@ -4280,39 +4306,70 @@ textarea:focus-visible {
opacity: 0.45;
}
.thinking-speed-toggle {
.thinking-fast-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
min-height: 42px;
padding: 7px 10px;
min-height: 48px;
padding: 8px 10px;
border: 1px solid var(--theme-d5d2cb);
border-radius: 7px;
background: var(--theme-fff);
color: var(--theme-7a746c);
cursor: pointer;
transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
}
.thinking-speed-toggle span {
font-size: var(--font-size-section);
font-weight: 800;
.thinking-fast-option:hover {
border-color: var(--theme-d7a447);
}
.thinking-speed-toggle small {
color: var(--theme-9a938a);
font-size: var(--font-size-label);
}
.thinking-speed-toggle.active {
.thinking-fast-option.active {
border-color: var(--theme-d7a447);
background: var(--theme-fff8eb);
color: var(--theme-805a24);
}
.thinking-speed-toggle.active small {
.thinking-fast-option-copy {
display: grid;
min-width: 0;
gap: 2px;
}
.thinking-fast-option-copy > span {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: var(--font-size-section);
font-weight: 800;
}
.thinking-fast-option-copy small {
color: var(--theme-9a938a);
font-size: var(--font-size-label);
}
.thinking-fast-option.active .thinking-fast-option-copy small {
color: var(--theme-805a24);
}
.thinking-fast-option:has(input:disabled) {
cursor: not-allowed;
opacity: 0.55;
}
.thinking-fast-switch {
width: 42px;
height: 23px;
}
.thinking-fast-switch input:checked + .switch-track {
border-color: var(--theme-d7a447);
background: var(--theme-d7a447);
}
.thinking-effort-custom {
display: grid;
grid-template-columns: minmax(0, 1fr) 30px;
+16
View File
@@ -18,6 +18,22 @@ import {
stripResponseFields,
} from '../src/pages/ApiAccessPage';
it('saves non-empty custom model names and removes duplicate or blank entries', () => {
const result = buildProviderRecord('openai-compatibility', {
name: 'custom-provider',
apiKey: 'provider-key',
baseUrl: 'https://api.example.com',
priority: '',
models: [
{ name: ' custom-model ', alias: ' Custom Alias ' },
{ name: ' ' },
{ name: 'CUSTOM-MODEL', alias: 'duplicate' },
],
});
expect(result.models).toEqual([{ name: 'custom-model', alias: 'Custom Alias' }]);
});
describe('API 接入配置合并', () => {
it('固定使用 Codex、OpenAI、DeepSeek、Claude、Gemini 顺序且不包含 Vertex', () => {
expect(providerSectionOrder).toEqual([
+12
View File
@@ -2,9 +2,19 @@ import { describe, expect, test } from 'bun:test';
import {
combineModelAliasEntries,
combineModelAliasSources,
defaultModelAlias,
thinkingAliasSourceKindLabel,
} from '../src/pages/ThinkingAliasesPage';
describe('模型别名默认名称', () => {
test('按思考强度和 Fast 选项生成可编辑的默认名称', () => {
expect(defaultModelAlias('gpt-5.6-sol', 'XHigh', false)).toBe('gpt-5.6-sol-xhigh');
expect(defaultModelAlias('gpt-5.6-sol', '', true)).toBe('gpt-5.6-sol-fast');
expect(defaultModelAlias('gpt-5.6-sol', 'xhigh', true)).toBe('gpt-5.6-sol-xhigh-fast');
expect(defaultModelAlias('gpt-5.6-sol', '', false)).toBe('');
});
});
describe('思考别名', () => {
test('区分同名模型的接入来源', () => {
expect(thinkingAliasSourceKindLabel('codex-oauth')).toBe('Codex OAuth');
@@ -73,6 +83,8 @@ describe('统一模型别名列表', () => {
expect(sources).toHaveLength(2);
expect(sources.find((source) => source.id === 'reasoning-and-fast')?.supportsReasoning).toBe(true);
expect(sources.find((source) => source.id === 'reasoning-and-fast')?.supportsFast).toBe(true);
expect(sources.find((source) => source.id === 'fast-only')?.supportsReasoning).toBe(false);
expect(sources.find((source) => source.id === 'fast-only')?.supportsFast).toBe(true);
});
});