feat(aliases): integrate Fast options into model alias management

This commit is contained in:
lzt404 committed 2026-08-02 14:47:20 +08:00
1 parent d59cee19fa
commit aa8a7cbd84
7 files changed
+203 -505

No files matched your search

+8 -16
View File
@@ -13,7 +13,6 @@ export const jaOverrides = {
'app.nav.kernel': 'コア',
'app.nav.config': '詳細設定',
'app.nav.thinkingAliases': 'モデルエイリアス',
'app.nav.speedAliases': '速度エイリアス',
'app.nav.oauth': 'OAuth',
'app.nav.api': 'API 接続',
'app.nav.authFiles': '認証ファイル',
@@ -207,8 +206,8 @@ export const jaOverrides = {
'aliases.created': '{alias} を作成し、推論レベルを {effort} に固定しました',
'aliases.deleteConfirm': 'エイリアスモデル「{alias}」を削除しますか?\n対応する推論レベルの上書きルールも削除されます。',
'aliases.deleted': '{alias} を削除しました',
'aliases.create.title': '推論エイリアスを作成',
'aliases.create.description': 'Codex OAuth、Codex API、OpenAI 互換接続に対応',
'aliases.create.title': 'モデルエイリアスを作成',
'aliases.create.description': '推論レベルと Fast 速度階層を一か所で設定します',
'aliases.originalModel': '元のモデル',
'aliases.loadingModels': 'モデルを読み込んでいます',
'aliases.searchModel': '元のモデルを検索して選択',
@@ -217,8 +216,9 @@ export const jaOverrides = {
'aliases.noModels': '選択可能なモデルがありません',
'aliases.sourceLabel': 'ソース:{source}',
'aliases.sourceHint': '同名モデルは Codex OAuth、Codex API、OpenAI 互換ソースごとに表示されます',
'aliases.effort.title': '推論レベル',
'aliases.effort.description': '一般的なレベルを選択するか、上流が対応するカスタム値を入力します',
'aliases.effort.title': 'エイリアスオプション',
'aliases.effort.description': '推論レベル、カスタム値、または Fast 速度階層を選択します',
'aliases.fast.description': 'Fast リクエスト階層を固定します(service_tier=priority)',
'aliases.customLevel': 'カスタムレベル:{effort}',
'aliases.customLevelHint': 'モデルプロバイダーが対応する別の推論レベルを入力',
'aliases.custom': 'カスタム',
@@ -235,25 +235,17 @@ export const jaOverrides = {
'aliases.creating': '作成中',
'aliases.create': 'エイリアスを作成',
'aliases.createdList.title': '作成済みエイリアス',
'aliases.createdList.description': '各エイリアスには元のモデル、ソース、固定推論レベルが保持されます',
'aliases.createdList.description': '推論エイリアスと Fast エイリアスを一つの一覧で管理します',
'aliases.loadingConfig': '設定を読み込んでいます',
'aliases.empty.title': '推論エイリアスはまだありません',
'aliases.empty.description': '左側でモデルとレベルを選択して作成できます',
'aliases.empty.title': 'モデルエイリアスはまだありません',
'aliases.empty.description': '左側でモデルとエイリアスオプションを選択して作成できます',
'aliases.unboundEffort': 'レベル未設定',
'aliases.delete': '{alias} を削除',
'speedAliases.created': '{alias} を作成し、速度階層を Fast に固定しました',
'speedAliases.deleted': '{alias} を削除しました',
'speedAliases.deleteConfirm': '速度エイリアス「{alias}」を削除しますか?\n対応する Fast 上書きルールも削除されます。',
'speedAliases.create.title': '速度エイリアスを作成',
'speedAliases.create.description': '常に Fast リクエスト階層を使用するエイリアスを作成します',
'speedAliases.fast.title': 'Fast',
'speedAliases.aliasName.example': '例:{model}-fast',
'speedAliases.creating': '作成中',
'speedAliases.create': 'Fast エイリアスを作成',
'speedAliases.createdList.title': '作成済み速度エイリアス',
'speedAliases.createdList.description': '各エイリアスには元のモデル、プロバイダー、固定 Fast 階層が保持されます',
'speedAliases.empty.title': '速度エイリアスはまだありません',
'speedAliases.empty.description': '左側でモデルを選択して Fast エイリアスを作成できます',
'speedAliases.delete': '{alias} を削除',
'config.routing.roundRobin': 'ラウンドロビン',
'config.routing.fillFirst': '優先充填',
+16 -32
View File
@@ -16,7 +16,6 @@ export const zhCN = {
'app.nav.kernel': '内核',
'app.nav.config': '高级设置',
'app.nav.thinkingAliases': '模型别名',
'app.nav.speedAliases': '速度别名',
'app.nav.oauth': 'OAuth',
'app.nav.api': 'API 接入',
'app.nav.authFiles': '认证文件',
@@ -210,8 +209,8 @@ export const zhCN = {
'aliases.created': '已创建 {alias},思考强度固定为 {effort}',
'aliases.deleteConfirm': '确定删除别名模型“{alias}”吗?\n将同时移除对应的思考强度覆盖规则。',
'aliases.deleted': '已删除 {alias}',
'aliases.create.title': '创建思考别名',
'aliases.create.description': '支持 Codex OAuth、Codex API 和 OpenAI 兼容接入',
'aliases.create.title': '创建模型别名',
'aliases.create.description': '统一设置思考强度或 Fast 速度层级',
'aliases.originalModel': '原模型',
'aliases.loadingModels': '正在读取模型',
'aliases.searchModel': '搜索并选择原模型',
@@ -220,8 +219,9 @@ export const zhCN = {
'aliases.noModels': '当前没有可选模型',
'aliases.sourceLabel': '来源:{source}',
'aliases.sourceHint': '同名模型会按 Codex OAuth、Codex API 和 OpenAI 兼容来源分别列出',
'aliases.effort.title': '思考强度',
'aliases.effort.description': '选择常用等级,或输入上游支持的自定义值',
'aliases.effort.title': '别名选项',
'aliases.effort.description': '选择思考强度、自定义等级或 Fast 速度层级',
'aliases.fast.description': '固定使用 Fast 请求层级(service_tier=priority)',
'aliases.customLevel': '自定义等级:{effort}',
'aliases.customLevelHint': '输入模型提供商支持的其他思考等级',
'aliases.custom': '自定义',
@@ -238,25 +238,17 @@ export const zhCN = {
'aliases.creating': '创建中',
'aliases.create': '创建别名',
'aliases.createdList.title': '已创建别名',
'aliases.createdList.description': '每个别名都保留原模型、来源和固定思考强度',
'aliases.createdList.description': '统一显示思考强度别名和 Fast 速度别名',
'aliases.loadingConfig': '读取配置中',
'aliases.empty.title': '尚未创建思考别名',
'aliases.empty.description': '从左侧选择模型和强度即可创建',
'aliases.empty.title': '尚未创建模型别名',
'aliases.empty.description': '从左侧选择模型和别名选项即可创建',
'aliases.unboundEffort': '未绑定强度',
'aliases.delete': '删除 {alias}',
'speedAliases.created': '已创建 {alias},速度层级固定为 Fast',
'speedAliases.deleted': '已删除 {alias}',
'speedAliases.deleteConfirm': '确定删除速度别名模型“{alias}”吗?\n将同时移除对应的 Fast 覆盖规则。',
'speedAliases.create.title': '创建速度别名',
'speedAliases.create.description': '为别名模型固定启用 Fast 请求层级',
'speedAliases.fast.title': 'Fast',
'speedAliases.aliasName.example': '例如 {model}-fast',
'speedAliases.creating': '创建中',
'speedAliases.create': '创建 Fast 别名',
'speedAliases.createdList.title': '已创建速度别名',
'speedAliases.createdList.description': '每个别名都保留原模型、来源和固定 Fast 层级',
'speedAliases.empty.title': '尚未创建速度别名',
'speedAliases.empty.description': '从左侧选择模型即可创建 Fast 别名',
'speedAliases.delete': '删除 {alias}',
'config.routing.roundRobin': '轮询',
'config.routing.fillFirst': '优先填充',
@@ -905,7 +897,6 @@ export const en: Record<MessageKey, string> = {
'app.nav.kernel': 'Core',
'app.nav.config': 'Advanced Settings',
'app.nav.thinkingAliases': 'Model Aliases',
'app.nav.speedAliases': 'Speed Aliases',
'app.nav.oauth': 'OAuth',
'app.nav.api': 'API Access',
'app.nav.authFiles': 'Auth Files',
@@ -1099,8 +1090,8 @@ export const en: Record<MessageKey, string> = {
'aliases.created': 'Created {alias} with reasoning effort fixed to {effort}',
'aliases.deleteConfirm': 'Delete the model alias “{alias}”?\nIts reasoning-effort override will also be removed.',
'aliases.deleted': 'Deleted {alias}',
'aliases.create.title': 'Create Reasoning Alias',
'aliases.create.description': 'Supports Codex OAuth, Codex API, and OpenAI-compatible connections',
'aliases.create.title': 'Create Model Alias',
'aliases.create.description': 'Configure reasoning effort or the Fast service tier in one place',
'aliases.originalModel': 'Original Model',
'aliases.loadingModels': 'Loading models',
'aliases.searchModel': 'Search and select an original model',
@@ -1109,8 +1100,9 @@ export const en: Record<MessageKey, string> = {
'aliases.noModels': 'No models are currently available',
'aliases.sourceLabel': 'Source: {source}',
'aliases.sourceHint': 'Models with the same name are listed separately for Codex OAuth, Codex API, and OpenAI-compatible sources',
'aliases.effort.title': 'Reasoning Effort',
'aliases.effort.description': 'Choose a common level or enter a custom value supported by the upstream provider',
'aliases.effort.title': 'Alias Option',
'aliases.effort.description': 'Choose reasoning effort, a custom level, or the Fast service tier',
'aliases.fast.description': 'Always use the Fast request tier (service_tier=priority)',
'aliases.customLevel': 'Custom level: {effort}',
'aliases.customLevelHint': 'Enter another reasoning level supported by the model provider',
'aliases.custom': 'Custom',
@@ -1127,25 +1119,17 @@ export const en: Record<MessageKey, string> = {
'aliases.creating': 'Creating',
'aliases.create': 'Create Alias',
'aliases.createdList.title': 'Created Aliases',
'aliases.createdList.description': 'Each alias retains its original model, source, and fixed reasoning effort',
'aliases.createdList.description': 'Reasoning and Fast aliases are managed in one list',
'aliases.loadingConfig': 'Loading configuration',
'aliases.empty.title': 'No reasoning aliases yet',
'aliases.empty.description': 'Select a model and effort on the left to create one',
'aliases.empty.title': 'No model aliases yet',
'aliases.empty.description': 'Select a model and alias option on the left to create one',
'aliases.unboundEffort': 'No effort bound',
'aliases.delete': 'Delete {alias}',
'speedAliases.created': 'Created {alias} with the Fast service tier fixed',
'speedAliases.deleted': 'Deleted {alias}',
'speedAliases.deleteConfirm': 'Delete the speed alias “{alias}”?\nIts Fast service-tier override will also be removed.',
'speedAliases.create.title': 'Create Speed Alias',
'speedAliases.create.description': 'Create an alias that always enables the Fast request tier',
'speedAliases.fast.title': 'Fast',
'speedAliases.aliasName.example': 'For example: {model}-fast',
'speedAliases.creating': 'Creating',
'speedAliases.create': 'Create Fast Alias',
'speedAliases.createdList.title': 'Created Speed Aliases',
'speedAliases.createdList.description': 'Each alias retains its source model, provider, and fixed Fast tier',
'speedAliases.empty.title': 'No speed aliases yet',
'speedAliases.empty.description': 'Select a model on the left to create a Fast alias',
'speedAliases.delete': 'Delete {alias}',
'config.routing.roundRobin': 'Round Robin',
'config.routing.fillFirst': 'Fill First',
+2 -24
View File
@@ -22,7 +22,6 @@ import {
} from 'lucide-react';
import { useCoreRuntime, type CoreStatus } from '../coreRuntime';
import { useI18n } from '../i18n';
import { SpeedAliasesPage } from './SpeedAliasesPage';
import { ThinkingAliasesPage } from './ThinkingAliasesPage';
type CoreConfigSettings = {
@@ -50,7 +49,7 @@ type ConfigAction =
| 'network'
| null;
type NoticeTone = 'success' | 'error';
type ConfigSubpage = 'general' | 'network' | 'aliases' | 'speedAliases';
type ConfigSubpage = 'general' | 'network' | 'aliases';
const ROUTING_OPTIONS = [
{ value: 'round-robin', labelKey: 'config.routing.roundRobin' },
@@ -458,18 +457,6 @@ export function ConfigPanelPage() {
>
{t('app.nav.thinkingAliases')}
</button>
<button
type="button"
id="config-subpage-tab-speed-aliases"
role="tab"
className={activeSubpage === 'speedAliases' ? 'active' : ''}
aria-selected={activeSubpage === 'speedAliases'}
aria-controls="config-subpage-panel-speed-aliases"
tabIndex={activeSubpage === 'speedAliases' ? 0 : -1}
onClick={() => setActiveSubpage('speedAliases')}
>
{t('app.nav.speedAliases')}
</button>
</div>
{activeSubpage === 'general' ? (
@@ -888,7 +875,7 @@ export function ConfigPanelPage() {
</div>
</section>
</div>
) : activeSubpage === 'aliases' ? (
) : (
<div
className="config-subpage-panel"
id="config-subpage-panel-aliases"
@@ -897,15 +884,6 @@ export function ConfigPanelPage() {
>
<ThinkingAliasesPage />
</div>
) : (
<div
className="config-subpage-panel"
id="config-subpage-panel-speed-aliases"
role="tabpanel"
aria-labelledby="config-subpage-tab-speed-aliases"
>
<SpeedAliasesPage />
</div>
)}
{addDialogOpen ? (
-399
View File
@@ -1,399 +0,0 @@
import {
type KeyboardEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { invoke } from '@tauri-apps/api/core';
import {
ArrowRight,
Check,
GitFork,
LoaderCircle,
Search,
Trash2,
Zap,
} from 'lucide-react';
import { useI18n } from '../i18n';
import { thinkingAliasSourceKindLabel } from './ThinkingAliasesPage';
type SpeedAliasEntry = {
sourceModel: string;
alias: string;
serviceTier: string;
provider: string;
kind: string;
};
type SpeedAliasSource = {
id: string;
model: string;
displayName: string | null;
provider: string;
kind: string;
protocol: string;
};
export function SpeedAliasesPage() {
const { t } = useI18n();
const [entries, setEntries] = useState<SpeedAliasEntry[]>([]);
const [sources, setSources] = useState<SpeedAliasSource[]>([]);
const [selectedSourceId, setSelectedSourceId] = useState('');
const [alias, setAlias] = useState('');
const [search, setSearch] = useState('');
const [modelPickerOpen, setModelPickerOpen] = useState(false);
const [activeSourceIndex, setActiveSourceIndex] = useState(0);
const modelPickerRef = useRef<HTMLDivElement>(null);
const [loading, setLoading] = useState(true);
const [busyAlias, setBusyAlias] = useState('');
const [busyAction, setBusyAction] = useState<'create' | 'delete' | ''>('');
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const [nextEntries, nextSources] = await Promise.all([
invoke<SpeedAliasEntry[]>('get_speed_aliases'),
invoke<SpeedAliasSource[]>('get_speed_alias_sources'),
]);
setEntries(nextEntries);
setSources(nextSources);
setSelectedSourceId((current) => (
nextSources.some((source) => source.id === current) ? current : ''
));
} catch (requestError) {
setError(String(requestError));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
if (!modelPickerOpen) return undefined;
const closeOnOutsideClick = (event: PointerEvent) => {
if (!modelPickerRef.current?.contains(event.target as Node)) {
setModelPickerOpen(false);
setSearch('');
}
};
document.addEventListener('pointerdown', closeOnOutsideClick);
return () => document.removeEventListener('pointerdown', closeOnOutsideClick);
}, [modelPickerOpen]);
const selectedSource = useMemo(
() => sources.find((source) => source.id === selectedSourceId) ?? null,
[selectedSourceId, sources],
);
const filteredSources = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return sources;
return sources.filter((source) => {
const haystack = [
source.model,
source.displayName ?? '',
source.provider,
thinkingAliasSourceKindLabel(source.kind),
].join(' ').toLowerCase();
return haystack.includes(query);
});
}, [search, sources]);
useEffect(() => {
setActiveSourceIndex(0);
}, [search, sources]);
const chooseSource = (source: SpeedAliasSource) => {
setSelectedSourceId(source.id);
setModelPickerOpen(false);
setSearch('');
};
const handleModelSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Escape') {
setModelPickerOpen(false);
setSearch('');
event.currentTarget.blur();
return;
}
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
if (!modelPickerOpen) {
setModelPickerOpen(true);
return;
}
if (!filteredSources.length) return;
const direction = event.key === 'ArrowDown' ? 1 : -1;
setActiveSourceIndex((current) => (
(current + direction + filteredSources.length) % filteredSources.length
));
return;
}
if (event.key === 'Enter' && modelPickerOpen && filteredSources[activeSourceIndex]) {
event.preventDefault();
chooseSource(filteredSources[activeSourceIndex]);
}
};
const createAlias = async () => {
if (!selectedSource) {
setError(t('aliases.error.selectModel'));
return;
}
const normalizedAlias = alias.trim();
if (!normalizedAlias) {
setError(t('aliases.error.emptyAlias'));
return;
}
setBusyAlias(normalizedAlias);
setBusyAction('create');
setError('');
setNotice('');
try {
const nextEntries = await invoke<SpeedAliasEntry[]>('create_speed_alias', {
sourceId: selectedSource.id,
alias: normalizedAlias,
});
setEntries(nextEntries);
setNotice(t('speedAliases.created', { alias: normalizedAlias }));
setAlias('');
} catch (requestError) {
setError(String(requestError));
} finally {
setBusyAlias('');
setBusyAction('');
}
};
const deleteAlias = async (entry: SpeedAliasEntry) => {
if (!window.confirm(t('speedAliases.deleteConfirm', { alias: entry.alias }))) return;
setBusyAlias(entry.alias);
setBusyAction('delete');
setError('');
setNotice('');
try {
const nextEntries = await invoke<SpeedAliasEntry[]>('delete_speed_alias', {
alias: entry.alias,
});
setEntries(nextEntries);
setNotice(t('speedAliases.deleted', { alias: entry.alias }));
} catch (requestError) {
setError(String(requestError));
} finally {
setBusyAlias('');
setBusyAction('');
}
};
return (
<section className="page management-page thinking-alias-page">
<div className="thinking-alias-feedback" aria-live="polite">
{error ? <div className="management-alert error">{error}</div> : null}
{!error && notice ? <div className="management-alert success">{notice}</div> : null}
</div>
<div className="thinking-alias-workbench">
<section className="panel thinking-alias-editor-panel">
<div className="thinking-alias-panel-heading">
<span><Zap size={18} /></span>
<div>
<h2>{t('speedAliases.create.title')}</h2>
<p>{t('speedAliases.create.description')}</p>
</div>
</div>
<div className="thinking-alias-field thinking-model-field">
<label htmlFor="speed-model-search">{t('aliases.originalModel')}</label>
<div className="thinking-model-picker" ref={modelPickerRef}>
<div className="thinking-model-search">
{loading ? <LoaderCircle size={15} className="spin" /> : <Search size={15} />}
<input
id="speed-model-search"
role="combobox"
aria-autocomplete="list"
aria-expanded={modelPickerOpen}
aria-controls="speed-model-options"
aria-activedescendant={modelPickerOpen && filteredSources[activeSourceIndex]
? `speed-model-option-${activeSourceIndex}`
: undefined}
value={modelPickerOpen ? search : selectedSource?.model ?? ''}
onFocus={(event) => {
setSearch(selectedSource?.model ?? '');
setModelPickerOpen(true);
event.currentTarget.select();
}}
onChange={(event) => {
setSearch(event.currentTarget.value);
setModelPickerOpen(true);
}}
onKeyDown={handleModelSearchKeyDown}
placeholder={loading ? t('aliases.loadingModels') : t('aliases.searchModel')}
autoComplete="off"
spellCheck={false}
disabled={loading}
/>
{!modelPickerOpen && selectedSource ? (
<span className="thinking-source-kind">
{thinkingAliasSourceKindLabel(selectedSource.kind)}
</span>
) : null}
</div>
{modelPickerOpen ? (
<div
className="thinking-model-list"
id="speed-model-options"
role="listbox"
aria-label={t('aliases.availableModels')}
>
{filteredSources.length === 0 ? (
<div className="thinking-model-empty">
{sources.length ? t('aliases.noMatch') : t('aliases.noModels')}
</div>
) : filteredSources.map((source, index) => {
const selected = source.id === selectedSourceId;
return (
<button
type="button"
role="option"
aria-selected={selected}
id={`speed-model-option-${index}`}
className={`${selected ? 'selected ' : ''}${index === activeSourceIndex ? 'active' : ''}`.trim()}
key={source.id}
onMouseEnter={() => setActiveSourceIndex(index)}
onClick={() => chooseSource(source)}
disabled={Boolean(busyAlias)}
>
<span className="thinking-model-option-copy">
<span>
<strong title={source.model}>{source.model}</strong>
{source.displayName && source.displayName !== source.model
? <small>{source.displayName}</small>
: null}
</span>
<span className="thinking-model-source">
<em>{thinkingAliasSourceKindLabel(source.kind)}</em>
<small title={source.provider}>{source.provider}</small>
</span>
</span>
{selected ? <Check size={15} /> : null}
</button>
);
})}
</div>
) : null}
</div>
{selectedSource ? (
<div className="thinking-model-selection">
<span>{thinkingAliasSourceKindLabel(selectedSource.kind)}</span>
<strong title={selectedSource.provider}>{selectedSource.provider}</strong>
</div>
) : (
<small className="thinking-model-hint">{t('aliases.sourceHint')}</small>
)}
</div>
<div className="thinking-alias-preview">
<Zap size={18} />
<div>
<span>{t('speedAliases.fast.title')}</span>
<code>service_tier = priority</code>
</div>
</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>
</div>
<input
id="speed-alias-name"
className="thinking-alias-input"
value={alias}
onChange={(event) => setAlias(event.currentTarget.value)}
placeholder={selectedSource
? t('speedAliases.aliasName.example', { model: selectedSource.model })
: t('aliases.aliasName.selectFirst')}
disabled={Boolean(busyAlias)}
/>
</div>
<div className="thinking-alias-preview">
<GitFork size={18} />
<div>
<span>{selectedSource?.model || t('aliases.notSelected')} <ArrowRight size={13} /> {alias || t('aliases.enterAlias')}</span>
<code>service_tier = priority</code>
</div>
</div>
<button
type="button"
className="primary-button thinking-alias-create"
onClick={() => void createAlias()}
disabled={loading || Boolean(busyAlias) || !selectedSource || !alias.trim()}
>
{busyAction === 'create' ? <LoaderCircle size={16} className="spin" /> : <Zap size={16} />}
{busyAction === 'create' ? t('speedAliases.creating') : t('speedAliases.create')}
</button>
</section>
<section className="panel thinking-alias-list-panel">
<div className="thinking-alias-list-heading">
<div>
<h2>{t('speedAliases.createdList.title')}</h2>
<span>{t('speedAliases.createdList.description')}</span>
</div>
<strong>{entries.length}</strong>
</div>
<div className="thinking-alias-list">
{loading ? (
<div className="management-loading"><LoaderCircle size={20} className="spin" />{t('aliases.loadingConfig')}</div>
) : entries.length === 0 ? (
<div className="management-empty">
<Zap size={25} />
<strong>{t('speedAliases.empty.title')}</strong>
<span>{t('speedAliases.empty.description')}</span>
</div>
) : entries.map((entry) => (
<article className="thinking-alias-row" key={`${entry.kind}:${entry.provider}:${entry.alias}`}>
<div className="thinking-alias-route">
<div className="thinking-alias-route-source">
<span title={entry.sourceModel}>{entry.sourceModel}</span>
<small>
<em>{thinkingAliasSourceKindLabel(entry.kind)}</em>
<span title={entry.provider}>{entry.provider}</span>
</small>
</div>
<ArrowRight size={14} />
<strong title={entry.alias}>{entry.alias}</strong>
</div>
<span className="thinking-effort-badge">{entry.serviceTier}</span>
<button
type="button"
className="icon-button danger"
onClick={() => void deleteAlias(entry)}
disabled={Boolean(busyAlias)}
title={t('speedAliases.delete', { alias: entry.alias })}
>
{busyAction === 'delete' && busyAlias === entry.alias
? <LoaderCircle size={15} className="spin" />
: <Trash2 size={15} />}
</button>
</article>
))}
</div>
</section>
</div>
</section>
);
}
+127 -32
View File
@@ -16,6 +16,7 @@ import {
Search,
Trash2,
X,
Zap,
} from 'lucide-react';
import { getCurrentLocale, translate, useI18n } from '../i18n';
@@ -29,6 +30,20 @@ type ThinkingAliasEntry = {
kind: string;
};
type SpeedAliasEntry = {
sourceModel: string;
alias: string;
serviceTier: string;
provider: string;
kind: string;
};
type AliasListEntry =
| (ThinkingAliasEntry & { mode: 'reasoning' })
| (SpeedAliasEntry & { mode: 'fast' });
type AliasMode = 'reasoning' | 'fast';
type ThinkingAliasSource = {
id: string;
model: string;
@@ -46,6 +61,17 @@ const effortOptions = [
{ value: 'max', label: 'Max', hintKey: 'aliases.effort.max' },
] as const satisfies ReadonlyArray<{ value: PresetThinkingEffort; label: string; hintKey: string }>;
export const combineModelAliasEntries = (
thinkingEntries: ThinkingAliasEntry[],
speedEntries: SpeedAliasEntry[],
): AliasListEntry[] => [
...thinkingEntries.map((entry) => ({ ...entry, mode: 'reasoning' as const })),
...speedEntries.map((entry) => ({ ...entry, mode: 'fast' as const })),
].sort((left, right) => (
left.provider.localeCompare(right.provider)
|| left.alias.localeCompare(right.alias)
));
export const thinkingAliasSourceKindLabel = (kind: string) => {
if (kind === 'codex-oauth') return 'Codex OAuth';
if (kind === 'codex-api') return 'Codex API';
@@ -65,9 +91,12 @@ const thinkingAliasSourceDetail = (source: ThinkingAliasSource) => (
export function ThinkingAliasesPage() {
const { t } = useI18n();
const [entries, setEntries] = useState<ThinkingAliasEntry[]>([]);
const [sources, setSources] = useState<ThinkingAliasSource[]>([]);
const [thinkingEntries, setThinkingEntries] = useState<ThinkingAliasEntry[]>([]);
const [speedEntries, setSpeedEntries] = useState<SpeedAliasEntry[]>([]);
const [thinkingSources, setThinkingSources] = useState<ThinkingAliasSource[]>([]);
const [speedSources, setSpeedSources] = useState<ThinkingAliasSource[]>([]);
const [selectedSourceId, setSelectedSourceId] = useState('');
const [aliasMode, setAliasMode] = useState<AliasMode>('reasoning');
const [effort, setEffort] = useState('xhigh');
const [customEffortOpen, setCustomEffortOpen] = useState(false);
const [alias, setAlias] = useState('');
@@ -85,14 +114,20 @@ export function ThinkingAliasesPage() {
setLoading(true);
setError('');
try {
const [nextEntries, nextSources] = await Promise.all([
const [nextThinkingEntries, nextThinkingSources, nextSpeedEntries, nextSpeedSources] = await Promise.all([
invoke<ThinkingAliasEntry[]>('get_thinking_aliases'),
invoke<ThinkingAliasSource[]>('get_thinking_alias_sources'),
invoke<SpeedAliasEntry[]>('get_speed_aliases'),
invoke<ThinkingAliasSource[]>('get_speed_alias_sources'),
]);
setEntries(nextEntries);
setSources(nextSources);
setThinkingEntries(nextThinkingEntries);
setThinkingSources(nextThinkingSources);
setSpeedEntries(nextSpeedEntries);
setSpeedSources(nextSpeedSources);
setSelectedSourceId((current) => (
nextSources.some((source) => source.id === current) ? current : ''
[...nextThinkingSources, ...nextSpeedSources].some((source) => source.id === current)
? current
: ''
));
} catch (requestError) {
setError(String(requestError));
@@ -117,6 +152,18 @@ export function ThinkingAliasesPage() {
return () => document.removeEventListener('pointerdown', closeOnOutsideClick);
}, [modelPickerOpen]);
const sources = aliasMode === 'fast' ? speedSources : thinkingSources;
const entries = useMemo(
() => combineModelAliasEntries(thinkingEntries, speedEntries),
[speedEntries, thinkingEntries],
);
useEffect(() => {
setSelectedSourceId((current) => (
sources.some((source) => source.id === current) ? current : ''
));
}, [sources]);
const selectedSource = useMemo(
() => sources.find((source) => source.id === selectedSourceId) ?? null,
[selectedSourceId, sources],
@@ -186,7 +233,9 @@ export function ThinkingAliasesPage() {
const normalizedEffort = effort.trim().toLowerCase();
const customEffortSelected = Boolean(
normalizedEffort && !effortOptions.some((option) => option.value === normalizedEffort),
aliasMode === 'reasoning'
&& normalizedEffort
&& !effortOptions.some((option) => option.value === normalizedEffort),
);
const createAlias = async () => {
@@ -199,7 +248,7 @@ export function ThinkingAliasesPage() {
setError(t('aliases.error.emptyAlias'));
return;
}
if (!normalizedEffort) {
if (aliasMode === 'reasoning' && !normalizedEffort) {
setError(t('aliases.error.emptyEffort'));
return;
}
@@ -208,13 +257,22 @@ export function ThinkingAliasesPage() {
setError('');
setNotice('');
try {
const nextEntries = await invoke<ThinkingAliasEntry[]>('create_thinking_alias', {
sourceId: selectedSource.id,
alias: normalizedAlias,
effort: normalizedEffort,
});
setEntries(nextEntries);
setNotice(t('aliases.created', { alias: normalizedAlias, effort: normalizedEffort }));
if (aliasMode === 'fast') {
const nextEntries = await invoke<SpeedAliasEntry[]>('create_speed_alias', {
sourceId: selectedSource.id,
alias: normalizedAlias,
});
setSpeedEntries(nextEntries);
setNotice(t('speedAliases.created', { alias: normalizedAlias }));
} else {
const nextEntries = await invoke<ThinkingAliasEntry[]>('create_thinking_alias', {
sourceId: selectedSource.id,
alias: normalizedAlias,
effort: normalizedEffort,
});
setThinkingEntries(nextEntries);
setNotice(t('aliases.created', { alias: normalizedAlias, effort: normalizedEffort }));
}
setAlias('');
} catch (requestError) {
setError(String(requestError));
@@ -224,20 +282,30 @@ export function ThinkingAliasesPage() {
}
};
const deleteAlias = async (entry: ThinkingAliasEntry) => {
const deleteAlias = async (entry: AliasListEntry) => {
if (!window.confirm(
t('aliases.deleteConfirm', { alias: entry.alias }),
t(entry.mode === 'fast' ? 'speedAliases.deleteConfirm' : 'aliases.deleteConfirm', {
alias: entry.alias,
}),
)) return;
setBusyAlias(entry.alias);
setBusyAction('delete');
setError('');
setNotice('');
try {
const nextEntries = await invoke<ThinkingAliasEntry[]>('delete_thinking_alias', {
alias: entry.alias,
});
setEntries(nextEntries);
setNotice(t('aliases.deleted', { alias: entry.alias }));
if (entry.mode === 'fast') {
const nextEntries = await invoke<SpeedAliasEntry[]>('delete_speed_alias', {
alias: entry.alias,
});
setSpeedEntries(nextEntries);
setNotice(t('speedAliases.deleted', { alias: entry.alias }));
} else {
const nextEntries = await invoke<ThinkingAliasEntry[]>('delete_thinking_alias', {
alias: entry.alias,
});
setThinkingEntries(nextEntries);
setNotice(t('aliases.deleted', { alias: entry.alias }));
}
} catch (requestError) {
setError(String(requestError));
} finally {
@@ -366,9 +434,10 @@ export function ThinkingAliasesPage() {
{effortOptions.map((option) => (
<button
type="button"
className={effort.trim().toLowerCase() === option.value ? 'active' : ''}
className={aliasMode === 'reasoning' && effort.trim().toLowerCase() === option.value ? 'active' : ''}
key={option.value}
onClick={() => {
setAliasMode('reasoning');
chooseEffort(option.value);
setCustomEffortOpen(false);
}}
@@ -378,10 +447,23 @@ export function ThinkingAliasesPage() {
{option.label}
</button>
))}
<button
type="button"
className={aliasMode === 'fast' ? 'active fast' : 'fast'}
onClick={() => {
setAliasMode('fast');
setCustomEffortOpen(false);
}}
disabled={Boolean(busyAlias)}
title={t('aliases.fast.description')}
>
<Zap size={13} /> Fast
</button>
<button
type="button"
className={customEffortSelected ? 'active custom' : 'custom'}
onClick={() => {
setAliasMode('reasoning');
if (!customEffortSelected) chooseEffort('');
setCustomEffortOpen(true);
}}
@@ -397,7 +479,10 @@ export function ThinkingAliasesPage() {
<input
id="thinking-effort-custom"
value={customEffortSelected ? effort : ''}
onChange={(event) => chooseEffort(event.currentTarget.value)}
onChange={(event) => {
setAliasMode('reasoning');
chooseEffort(event.currentTarget.value);
}}
placeholder={t('aliases.customPlaceholder')}
maxLength={64}
spellCheck={false}
@@ -429,17 +514,21 @@ export function ThinkingAliasesPage() {
value={alias}
onChange={(event) => setAlias(event.currentTarget.value)}
placeholder={selectedSource
? t('aliases.aliasName.example', { model: selectedSource.model, effort: normalizedEffort || 'high' })
? aliasMode === 'fast'
? t('speedAliases.aliasName.example', { model: selectedSource.model })
: t('aliases.aliasName.example', { model: selectedSource.model, effort: normalizedEffort || 'high' })
: t('aliases.aliasName.selectFirst')}
disabled={Boolean(busyAlias)}
/>
</div>
<div className="thinking-alias-preview">
<BrainCircuit size={18} />
{aliasMode === 'fast' ? <Zap size={18} /> : <BrainCircuit size={18} />}
<div>
<span>{selectedSource?.model || t('aliases.notSelected')} <ArrowRight size={13} /> {alias || t('aliases.enterAlias')}</span>
<code>{selectedSource?.protocol === 'openai' ? 'reasoning_effort' : 'reasoning.effort'} = {normalizedEffort || t('aliases.notSet')}</code>
<code>{aliasMode === 'fast'
? 'service_tier = priority'
: `${selectedSource?.protocol === 'openai' ? 'reasoning_effort' : 'reasoning.effort'} = ${normalizedEffort || t('aliases.notSet')}`}</code>
</div>
</div>
@@ -447,9 +536,11 @@ export function ThinkingAliasesPage() {
type="button"
className="primary-button thinking-alias-create"
onClick={() => void createAlias()}
disabled={loading || Boolean(busyAlias) || !selectedSource || !effort.trim() || !alias.trim()}
disabled={loading || Boolean(busyAlias) || !selectedSource || (aliasMode === 'reasoning' && !effort.trim()) || !alias.trim()}
>
{busyAction === 'create' ? <LoaderCircle size={16} className="spin" /> : <GitFork size={16} />}
{busyAction === 'create'
? <LoaderCircle size={16} className="spin" />
: aliasMode === 'fast' ? <Zap size={16} /> : <GitFork size={16} />}
{busyAction === 'create' ? t('aliases.creating') : t('aliases.create')}
</button>
</section>
@@ -487,15 +578,19 @@ export function ThinkingAliasesPage() {
<ArrowRight size={14} />
<strong title={entry.alias}>{entry.alias}</strong>
</div>
<span className={`thinking-effort-badge ${entry.effort ? '' : 'missing'}`}>
{entry.effort ?? t('aliases.unboundEffort')}
<span className={`thinking-effort-badge ${entry.mode === 'fast' ? 'fast' : entry.effort ? '' : 'missing'}`}>
{entry.mode === 'fast'
? t('speedAliases.fast.title')
: entry.effort ?? t('aliases.unboundEffort')}
</span>
<button
type="button"
className="icon-button danger"
onClick={() => void deleteAlias(entry)}
disabled={Boolean(busyAlias)}
title={t('aliases.delete', { alias: entry.alias })}
title={t(entry.mode === 'fast' ? 'speedAliases.delete' : 'aliases.delete', {
alias: entry.alias,
})}
>
{busyAction === 'delete' && busyAlias === entry.alias
? <LoaderCircle size={15} className="spin" />
+20 -1
View File
@@ -4019,7 +4019,7 @@ textarea:focus-visible {
.thinking-effort-options {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 4px;
}
@@ -4051,6 +4051,19 @@ textarea:focus-visible {
border-style: solid;
}
.thinking-effort-options button.fast {
display: flex;
align-items: center;
justify-content: center;
gap: 3px;
}
.thinking-effort-options button.fast.active {
border-color: var(--theme-d7a447);
background: var(--theme-fff8eb);
color: var(--theme-805a24);
}
.thinking-effort-custom {
display: grid;
grid-template-columns: minmax(0, 1fr) 30px;
@@ -4277,6 +4290,12 @@ textarea:focus-visible {
text-transform: none;
}
.thinking-effort-badge.fast {
border-color: var(--theme-d7a447);
background: var(--theme-fff8eb);
color: var(--theme-805a24);
}
@media (min-width: 1081px) {
.thinking-alias-list-panel .thinking-alias-row {
grid-template-columns: minmax(0, 1fr) auto 34px;
+30 -1
View File
@@ -1,5 +1,8 @@
import { describe, expect, test } from 'bun:test';
import { thinkingAliasSourceKindLabel } from '../src/pages/ThinkingAliasesPage';
import {
combineModelAliasEntries,
thinkingAliasSourceKindLabel,
} from '../src/pages/ThinkingAliasesPage';
describe('思考别名', () => {
test('区分同名模型的接入来源', () => {
@@ -9,3 +12,29 @@ describe('思考别名', () => {
expect(thinkingAliasSourceKindLabel('custom')).toBe('其他来源');
});
});
describe('统一模型别名列表', () => {
test('同时显示思考别名和 Fast 速度别名', () => {
const entries = combineModelAliasEntries(
[{
sourceModel: 'gpt-thinking',
alias: 'gpt-high',
effort: 'high',
provider: 'Provider B',
kind: 'openai-compatible',
}],
[{
sourceModel: 'gpt-fast-source',
alias: 'gpt-fast',
serviceTier: 'priority',
provider: 'Provider A',
kind: 'codex-api',
}],
);
expect(entries.map((entry) => [entry.alias, entry.mode])).toEqual([
['gpt-fast', 'fast'],
['gpt-high', 'reasoning'],
]);
});
});