From d698510473bb80d794ddc07b655ac0a8ab944d05 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Mon, 3 Aug 2026 21:33:48 +0800 Subject: [PATCH 01/10] feat(aliases): generate default names and improve Fast toggle --- src/i18n/ja.ts | 6 ++ src/i18n/resources.ts | 12 ++++ src/pages/ApiAccessPage.tsx | 94 +++++++++++++++++++++++++++---- src/pages/ThinkingAliasesPage.tsx | 67 +++++++++++++++++----- src/styles.css | 83 ++++++++++++++++++++++----- tests/providerConfig.test.ts | 16 ++++++ tests/thinkingAliases.test.ts | 12 ++++ 7 files changed, 254 insertions(+), 36 deletions(-) diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 2690ceb..9ceee71 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -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': 'モデル未選択', diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts index c9bbc33..b5768ca 100644 --- a/src/i18n/resources.ts +++ b/src/i18n/resources.ts @@ -4,6 +4,10 @@ import { jaOverrides } from './ja'; export type MessageVariables = Record; 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 = { + '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 = { '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 = { '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', diff --git a/src/pages/ApiAccessPage.tsx b/src/pages/ApiAccessPage.tsx index f479aaf..4c3fd51 100644 --- a/src/pages/ApiAccessPage.tsx +++ b/src/pages/ApiAccessPage.tsx @@ -244,8 +244,12 @@ export const stripResponseFields = (record: Record) => { const mergeModelRecords = (current: unknown, selected: ModelOption[]) => { const existing = Array.isArray(current) ? current : []; - return selected.map((model) => { + const seen = new Set(); + return selected.reduce[]>((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) => { + 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({ {t('apiAccess.models.fetch')} -
+
{modelSummaryTitle} {modelSummaryDetail}
+
+ {draft.models.map((model, index) => ( +
+ updateModel(index, { name: event.currentTarget.value })} + placeholder={t('apiAccess.models.namePlaceholder')} + aria-label={t('apiAccess.models.namePlaceholder')} + disabled={busy} + /> + updateModel(index, { alias: event.currentTarget.value })} + placeholder={t('apiAccess.models.aliasPlaceholder')} + aria-label={t('apiAccess.models.aliasPlaceholder')} + disabled={busy} + /> + +
+ ))} + +
{modelError && !modelDiscoveryOpen ? {modelError} : null}
diff --git a/src/pages/ThinkingAliasesPage.tsx b/src/pages/ThinkingAliasesPage.tsx index 5c16f71..f3f1ed0 100644 --- a/src/pages/ThinkingAliasesPage.tsx +++ b/src/pages/ThinkingAliasesPage.tsx @@ -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(); [...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(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() { {t('aliases.fast.title')} {t('aliases.fast.description')} - +