feat(agents): add agent configuration and thinking aliases

This commit is contained in:
lzt404 committed 2026-07-17 21:47:44 +08:00
1 parent 24b4d1f74e
commit 41efeebcbb
16 files changed
+8154 -224

No files matched your search

+783
View File
@@ -0,0 +1,783 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ComponentType,
type KeyboardEvent,
} from 'react';
import { invoke } from '@tauri-apps/api/core';
import {
AlertTriangle,
BadgeCheck,
Bot,
Check,
ChevronDown,
LoaderCircle,
Play,
RefreshCw,
Search,
Sparkles,
X,
} from 'lucide-react';
import claudeIcon from '../assets/icons/claude.svg';
import codexIcon from '../assets/icons/codex.svg';
import hermesIcon from '../assets/icons/hermes.png';
import openclawIcon from '../assets/icons/openclaw.svg';
import opencodeIcon from '../assets/icons/opencode.svg';
import {
agentModelAlias,
filterAgentModels,
findAgentModel,
resolveAgentModelSelection,
} from '../services/agentModelPicker';
import type { ModelOption } from '../services/modelService';
type AgentClientId =
| 'claude-code'
| 'claude-desktop'
| 'codex'
| 'opencode'
| 'openclaw'
| 'hermes';
type AgentModificationState = 'inactive' | 'active' | 'conflict' | 'recovery';
type AgentConfigStatus = {
id: AgentClientId;
name: string;
supportedPlatform: boolean;
installed: boolean;
executablePath: string | null;
launchTargets: AgentLaunchTarget[];
version: string | null;
configValid: boolean;
configured: boolean;
currentModel: string | null;
modificationEnabled: boolean;
modificationState: AgentModificationState;
backupAvailable: boolean;
appliedModel: string | null;
warnings: string[];
error: string | null;
};
type AgentLaunchTarget = {
id: string;
label: string;
detail: string;
};
type AgentConfigActionResult = {
outcome: 'enabled' | 'disabled' | 'updated' | 'restore-conflict';
enabled: boolean;
model: string | null;
changedFiles: string[];
conflictFiles: string[];
};
type AgentDefinition = {
id: AgentClientId;
name: string;
icon?: string;
Icon?: ComponentType<{ size?: number; 'aria-hidden'?: boolean }>;
description: string;
};
const agentDefinitions: AgentDefinition[] = [
{
id: 'claude-code',
name: 'Claude Code',
icon: claudeIcon,
description: '使用 CPA 的 Anthropic 兼容接口',
},
{
id: 'claude-desktop',
name: 'Claude Desktop',
icon: claudeIcon,
description: '使用 CPA 3P 推理网关,仅支持 Windows 和 macOS',
},
{
id: 'codex',
name: 'Codex',
icon: codexIcon,
description: '使用 CPA Responses 接口',
},
{
id: 'opencode',
name: 'OpenCode',
icon: opencodeIcon,
description: '使用 CPA OpenAI Compatible provider',
},
{
id: 'openclaw',
name: 'OpenClaw',
icon: openclawIcon,
description: '使用 CPA 模型供应商和默认模型',
},
{
id: 'hermes',
name: 'Hermes Agent',
icon: hermesIcon,
description: '使用 CPA custom provider',
},
];
const AGENT_MODEL_SELECTIONS_KEY = 'cpa-gui.agent-model-selections.v1';
const readAgentModelSelections = (): Partial<Record<AgentClientId, string>> => {
if (typeof window === 'undefined') return {};
try {
const payload = window.localStorage.getItem(AGENT_MODEL_SELECTIONS_KEY);
if (!payload) return {};
const parsed = JSON.parse(payload) as Record<string, unknown>;
return agentDefinitions.reduce<Partial<Record<AgentClientId, string>>>((result, agent) => {
const value = parsed[agent.id];
if (typeof value === 'string' && value.trim()) result[agent.id] = value.trim();
return result;
}, {});
} catch {
return {};
}
};
const writeAgentModelSelections = (
selections: Partial<Record<AgentClientId, string>>,
) => {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(AGENT_MODEL_SELECTIONS_KEY, JSON.stringify(selections));
} catch {
// Local storage can be unavailable in hardened webviews; the in-memory selection still works.
}
};
const reconcileAgentModelSelections = (
current: Partial<Record<AgentClientId, string>>,
models: ModelOption[],
) => {
return agentDefinitions.reduce<Partial<Record<AgentClientId, string>>>((result, agent) => {
const existing = current[agent.id] ?? '';
result[agent.id] = resolveAgentModelSelection(models, existing);
return result;
}, {});
};
function AgentMark({ definition, size = 26 }: { definition: AgentDefinition; size?: number }) {
if (definition.icon) {
return <img src={definition.icon} alt="" className="provider-logo" />;
}
const Icon = definition.Icon ?? Bot;
return <Icon size={size} aria-hidden />;
}
const listStatusText = (status: AgentConfigStatus | undefined) => {
if (!status) return '正在检测';
if (!status.supportedPlatform) return '当前平台不支持';
if (!status.installed) return '未检测到安装';
if (status.modificationState === 'conflict') return '配置冲突 · 等待恢复';
if (status.modificationState === 'recovery') return '操作未完成 · 可恢复';
if (status.modificationEnabled) return `已修改 · ${status.appliedModel ?? '未记录模型'}`;
return status.version ? `已安装 · ${status.version}` : '已安装 · 保持原配置';
};
type AgentModelPickerProps = {
models: ModelOption[];
value: string;
loading: boolean;
error: string;
disabled: boolean;
onChange: (value: string) => void;
onRefresh: () => void;
};
function AgentModelPicker({
models,
value,
loading,
error,
disabled,
onChange,
onRefresh,
}: AgentModelPickerProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const [activeIndex, setActiveIndex] = useState(0);
const rootRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const visibleModels = useMemo(() => filterAgentModels(models, search), [models, search]);
const choices = useMemo(
() => visibleModels.map((model) => ({ name: model.name, alias: model.alias ?? '' })),
[visibleModels],
);
const selectedAlias = agentModelAlias(models, value);
useEffect(() => {
if (!open) return undefined;
const close = (event: MouseEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', close);
return () => document.removeEventListener('mousedown', close);
}, [open]);
useEffect(() => {
if (!open) return;
setSearch('');
const selectedIndex = filterAgentModels(models, '').findIndex(
(model) => model.name.toLocaleLowerCase() === value.trim().toLocaleLowerCase(),
);
setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0);
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);
};
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();
moveActive(1);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
moveActive(-1);
} else if (event.key === 'Enter' && 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}>
<button
type="button"
className="agent-model-trigger"
aria-haspopup="listbox"
aria-expanded={open}
disabled={disabled}
onClick={() => setOpen((current) => !current)}
onKeyDown={(event) => {
if (!open && ['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(event.key)) {
event.preventDefault();
setOpen(true);
}
}}
>
<span>
<strong title={value || undefined}>
{value || (loading ? '正在读取模型' : error ? '获取模型失败' : models.length ? '选择模型' : '无可选模型')}
</strong>
{selectedAlias ? <small title={selectedAlias}>{selectedAlias}</small> : null}
</span>
<ChevronDown size={17} aria-hidden />
</button>
{open ? (
<div className="agent-model-dropdown">
<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="搜索模型名称或别名"
role="combobox"
aria-controls="agent-model-listbox"
aria-expanded="true"
/>
{search ? (
<button
type="button"
className="icon-button quiet"
onClick={() => {
setSearch('');
setActiveIndex(0);
searchRef.current?.focus();
}}
title="清空搜索"
>
<X size={14} />
</button>
) : null}
<button type="button" className="icon-button quiet" onClick={onRefresh} disabled={loading} title="刷新模型">
<RefreshCw size={14} className={loading ? 'spin' : ''} />
</button>
</div>
<div className="agent-model-list" id="agent-model-listbox" role="listbox">
{loading && models.length === 0 ? (
<div className="agent-model-empty"><LoaderCircle size={18} className="spin" />正在获取模型</div>
) : error && models.length === 0 ? (
<div className="agent-model-empty error"><strong>获取模型失败</strong><span>{error}</span></div>
) : choices.length === 0 ? (
<div className="agent-model-empty">
<strong>{search.trim() ? '没有匹配的模型' : '暂时没有可用模型'}</strong>
<span>{search.trim() ? '尝试其他关键词' : '请先接入可用模型后刷新'}</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 || '可用模型'}</small>
</span>
{selected ? <Check size={16} aria-hidden /> : null}
</button>
);
})}
</div>
<div className="agent-model-dropdown-footer">
<span>{models.length} 个可用模型</span>
{error && models.length > 0 ? <span className="error">刷新失败,显示上次结果</span> : null}
</div>
</div>
) : null}
</div>
);
}
export function AgentsPage() {
const [selected, setSelected] = useState<AgentClientId>('claude-code');
const [statuses, setStatuses] = useState<AgentConfigStatus[]>([]);
const [models, setModels] = useState<ModelOption[]>([]);
const [modelByClient, setModelByClient] = useState<Partial<Record<AgentClientId, string>>>(
readAgentModelSelections,
);
const [launchTargetByClient, setLaunchTargetByClient] = useState<Partial<Record<AgentClientId, string>>>({});
const [loading, setLoading] = useState(true);
const [modelLoading, setModelLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [modelError, setModelError] = useState('');
const [notice, setNotice] = useState('');
const [restoreConflict, setRestoreConflict] = useState<string[] | null>(null);
const loadStatuses = useCallback(async () => {
const nextStatuses = await invoke<AgentConfigStatus[]>('get_agent_config_statuses');
setStatuses(nextStatuses);
}, []);
const loadModels = useCallback(async () => {
setModelLoading(true);
setModelError('');
try {
const nextModels = await invoke<ModelOption[]>('get_agent_models');
setModels(nextModels);
setModelByClient((current) => {
const next = reconcileAgentModelSelections(current, nextModels);
writeAgentModelSelections(next);
return next;
});
} catch (requestError) {
setModelError(String(requestError));
} finally {
setModelLoading(false);
}
}, []);
const refresh = useCallback(async () => {
setLoading(true);
setError('');
try {
await Promise.all([loadStatuses(), loadModels()]);
} catch (requestError) {
setError(String(requestError));
} finally {
setLoading(false);
}
}, [loadModels, loadStatuses]);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
setLaunchTargetByClient((current) => agentDefinitions.reduce<Partial<Record<AgentClientId, string>>>(
(next, definition) => {
const targets = statuses.find((status) => status.id === definition.id)?.launchTargets ?? [];
const previous = current[definition.id] ?? '';
next[definition.id] = targets.some((target) => target.id === previous)
? previous
: targets[0]?.id ?? '';
return next;
},
{},
));
}, [statuses]);
const activeDefinition = agentDefinitions.find((agent) => agent.id === selected)
?? agentDefinitions[0];
const activeStatus = statuses.find((status) => status.id === selected) ?? null;
const savedSelectedModel = modelByClient[selected] ?? '';
const selectedModelOption = findAgentModel(models, savedSelectedModel);
const selectedModel = selectedModelOption?.name ?? '';
const activeLaunchTargets = activeStatus?.launchTargets ?? [];
const selectedLaunchTargetId = launchTargetByClient[selected] ?? activeLaunchTargets[0]?.id ?? '';
const selectedLaunchTarget = activeLaunchTargets.find(
(target) => target.id === selectedLaunchTargetId,
) ?? activeLaunchTargets[0] ?? null;
const appliedModel = activeStatus?.appliedModel ?? activeStatus?.currentModel ?? '';
const draftChanged = Boolean(
activeStatus?.modificationEnabled
&& selectedModel.trim()
&& selectedModel.trim() !== appliedModel.trim(),
);
const canEnable = Boolean(
activeStatus?.supportedPlatform
&& activeStatus.installed
&& activeStatus.configValid
&& !modelLoading
&& selectedModelOption,
);
const canLaunch = Boolean(
activeStatus?.supportedPlatform
&& activeStatus.installed
&& selectedLaunchTarget,
);
const refreshModels = () => {
void loadModels();
};
const selectModel = (value: string) => {
const model = findAgentModel(models, value);
if (!model) return;
setModelByClient((current) => {
const next = { ...current, [selected]: model.name };
writeAgentModelSelections(next);
return next;
});
};
const requireSelectedModel = () => {
if (modelLoading) {
setError('模型列表仍在加载,请稍后再试');
return null;
}
if (models.length === 0) {
setError(modelError || '当前没有可选模型,无法修改智能体配置');
return null;
}
const model = findAgentModel(models, selectedModel);
if (!model) {
setError('当前选择已不在可用模型列表中,请刷新模型后重新选择');
return null;
}
return model.name;
};
const setModificationEnabled = async (enabled: boolean, forceRestore = false) => {
const model = enabled ? requireSelectedModel() : selectedModel.trim();
if (enabled && !model) return;
setBusy(true);
setError('');
setNotice('');
try {
const result = await invoke<AgentConfigActionResult>('set_agent_config_enabled', {
client: selected,
model: model ?? '',
enabled,
forceRestore,
});
if (result.outcome === 'restore-conflict') {
setRestoreConflict(result.conflictFiles);
return;
}
setRestoreConflict(null);
setNotice(result.enabled
? `${activeDefinition.name} 已启用配置修改,请重启客户端`
: `${activeDefinition.name} 的原配置已恢复,请重启客户端`);
await loadStatuses();
} catch (requestError) {
setError(String(requestError));
} finally {
setBusy(false);
}
};
const launchAgent = async () => {
setBusy(true);
setError('');
setNotice('');
try {
let appliedBeforeLaunch = false;
const shouldSyncCodexCatalog = Boolean(
selected === 'codex' && activeStatus?.modificationEnabled && selectedModelOption,
);
if (draftChanged || shouldSyncCodexCatalog) {
if (activeStatus?.modificationState !== 'active') {
throw new Error('当前配置存在冲突或恢复任务,暂时不能应用新模型');
}
const model = requireSelectedModel();
if (!model) return;
await invoke<AgentConfigActionResult>('update_agent_config', {
client: selected,
model,
});
appliedBeforeLaunch = true;
}
await invoke('launch_agent', { client: selected, target: selectedLaunchTarget?.id });
setNotice(appliedBeforeLaunch
? draftChanged
? `${activeDefinition.name} 已应用所选模型并启动`
: `${activeDefinition.name} 已同步模型目录并启动`
: `${activeDefinition.name} 已启动`);
if (appliedBeforeLaunch) await loadStatuses();
} catch (requestError) {
setError(String(requestError));
} finally {
setBusy(false);
}
};
const statusLabel = loading
? '检测中'
: !activeStatus?.supportedPlatform
? '平台不支持'
: !activeStatus.installed
? '未安装'
: activeStatus.modificationState === 'conflict'
? '配置冲突'
: activeStatus.modificationState === 'recovery'
? '需要恢复'
: activeStatus.modificationEnabled
? '已修改配置'
: '保持原配置';
const statusTone = activeStatus?.modificationState === 'conflict'
|| activeStatus?.modificationState === 'recovery'
|| !activeStatus?.supportedPlatform
? 'error'
: activeStatus?.modificationEnabled
? 'success'
: '';
return (
<section className="page management-page agents-page">
<header className="management-header">
<div>
<span>Agent Clients</span>
<h1>智能体配置</h1>
</div>
<button type="button" className="secondary-button compact-button" onClick={() => void refresh()} disabled={loading || busy}>
<RefreshCw size={16} className={loading ? 'spin' : ''} />
重新检测
</button>
</header>
<div className="agent-feedback-slot" 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="agent-workbench">
<aside className="panel agent-client-list">
<div className="agent-list-heading">
<Bot size={18} />
<div><strong>本机客户端</strong><span>选择需要管理的智能体</span></div>
</div>
<div className="agent-list-items">
{agentDefinitions.map((agent) => {
const status = statuses.find((item) => item.id === agent.id);
return (
<button
type="button"
className={selected === agent.id ? 'active' : ''}
key={agent.id}
onClick={() => setSelected(agent.id)}
disabled={busy}
>
<span className="agent-client-icon"><AgentMark definition={agent} /></span>
<span><strong>{agent.name}</strong><small>{listStatusText(status)}</small></span>
<i
className={status?.modificationEnabled ? 'configured' : status?.installed ? 'installed' : ''}
aria-hidden="true"
/>
</button>
);
})}
</div>
</aside>
<section className="panel agent-config-panel">
<div className="agent-config-heading">
<div className="agent-config-title">
<span className="agent-logo"><AgentMark definition={activeDefinition} size={24} /></span>
<div><h2>{activeDefinition.name}</h2><span>{activeDefinition.description}</span></div>
</div>
<span className={`state-pill ${statusTone}`}>{statusLabel}</span>
</div>
<div className="agent-config-message-slot">
{activeStatus?.error ? <div className="management-alert error">{activeStatus.error}</div> : null}
{activeStatus?.warnings.length && !activeStatus.error ? (
<div className="agent-warning-line">{activeStatus.warnings.join(';')}</div>
) : null}
</div>
<div className="agent-status-grid">
<div>
<span><BadgeCheck size={14} />安装状态</span>
<strong>{activeStatus?.installed ? '已检测到客户端' : '未检测到客户端'}</strong>
</div>
<div>
<span>客户端版本</span>
<strong title={activeStatus?.version ?? undefined}>{activeStatus?.version ?? '未获取'}</strong>
</div>
</div>
<section className="agent-model-section">
<div className="agent-section-heading">
<div><strong>使用模型</strong><span>只能选择内核当前实际开放的模型</span></div>
{draftChanged ? <span className="agent-pending-badge">尚未应用</span> : null}
</div>
<AgentModelPicker
models={models}
value={selectedModel}
loading={modelLoading}
error={modelError}
disabled={busy || !activeStatus?.installed || !activeStatus.supportedPlatform}
onChange={selectModel}
onRefresh={refreshModels}
/>
<span className="agent-model-hint">
{modelLoading
? '正在读取可用模型'
: modelError && models.length === 0
? modelError
: models.length === 0
? '无可选模型,不能开启或更新配置'
: activeStatus?.modificationEnabled
? `当前已应用:${appliedModel || '未记录模型'}`
: `${models.length} 个可用模型,首次默认选择第一项`}
</span>
</section>
<section className={`agent-modification-switch ${activeStatus?.modificationEnabled ? 'enabled' : ''}`}>
<div>
<strong>修改智能体配置</strong>
<span>{activeStatus?.modificationEnabled
? activeStatus.modificationState === 'conflict'
? '配置已被外部修改,关闭时需要确认恢复'
: activeStatus.modificationState === 'recovery'
? '上次操作未完成,关闭开关可尝试恢复原配置'
: '原配置已备份,当前由 CPA 管理'
: '开启后先备份原配置,再写入 CPA 配置'}</span>
</div>
<label className="switch-control" title="修改智能体配置">
<input
type="checkbox"
checked={Boolean(activeStatus?.modificationEnabled)}
disabled={busy || (!activeStatus?.modificationEnabled && !canEnable)}
onChange={(event) => void setModificationEnabled(event.currentTarget.checked)}
/>
<span className="switch-track" />
</label>
</section>
<div className="agent-config-footer">
<div>
{activeStatus?.modificationEnabled ? <Check size={16} /> : <Sparkles size={16} />}
<span>{activeStatus?.modificationEnabled
? draftChanged
? '模型选择已变化,启动时会先应用新模型'
: '关闭开关会恢复开启前的原始配置;可直接启动智能体'
: !activeStatus?.supportedPlatform
? '当前系统无法配置该客户端'
: !activeStatus.installed
? '请先安装客户端并重新检测'
: activeStatus.launchTargets.length === 0
? '检测到配置文件,但没有找到客户端命令'
: !activeStatus.configValid
? '原配置格式异常,无法安全修改'
: '未开启修改配置时,将使用客户端原配置启动'}</span>
</div>
<div className="agent-launch-actions">
{activeLaunchTargets.length > 1 ? (
<div className="agent-launch-targets" aria-label="Codex 启动方式">
{activeLaunchTargets.map((target) => (
<button
type="button"
className={target.id === selectedLaunchTarget?.id ? 'active' : ''}
key={target.id}
onClick={() => setLaunchTargetByClient((current) => ({
...current,
[selected]: target.id,
}))}
disabled={busy}
title={target.detail}
>
{target.label.replace('Codex ', '')}
</button>
))}
</div>
) : null}
<button
type="button"
className="primary-button"
onClick={() => void launchAgent()}
disabled={
busy
|| !canLaunch
|| (draftChanged && activeStatus?.modificationState !== 'active')
}
title={selectedLaunchTarget?.detail}
>
{busy ? <LoaderCircle size={16} className="spin" /> : <Play size={16} />}
{busy ? '启动中' : selectedLaunchTarget ? `启动 ${selectedLaunchTarget.label}` : '无法启动'}
</button>
</div>
</div>
</section>
</div>
{restoreConflict ? (
<div className="config-dialog-backdrop">
<section className="config-dialog agent-restore-dialog" role="alertdialog" aria-modal="true" aria-labelledby="agent-restore-title">
<div className="config-dialog-heading">
<div><AlertTriangle size={19} /><h2 id="agent-restore-title">配置已发生外部变化</h2></div>
</div>
<p>
开启修改配置后,有 {restoreConflict.length} 个配置文件又被其他程序修改。
继续恢复会使用开启前的备份覆盖这些变化。
</p>
<div className="config-dialog-actions two-actions">
<button type="button" className="secondary-button" onClick={() => setRestoreConflict(null)} disabled={busy}>取消</button>
<button type="button" className="danger-button" onClick={() => void setModificationEnabled(false, true)} disabled={busy}>
{busy ? <LoaderCircle size={16} className="spin" /> : null}
确认强制恢复
</button>
</div>
</section>
</div>
) : null}
</section>
);
}
+117 -68
View File
@@ -44,7 +44,6 @@ export type ProviderCategory = ProviderSection | 'deepseek';
export const DEEPSEEK_BASE_URL = 'https://api.deepseek.com';
export const OPENAI_THINKING_LEVELS = ['low', 'medium', 'high', 'xhigh'] as const;
export const DEEPSEEK_THINKING_LEVELS = OPENAI_THINKING_LEVELS;
export type ThinkingLevel = (typeof OPENAI_THINKING_LEVELS)[number];
type ProviderDefinition = {
id: ProviderCategory;
@@ -81,7 +80,7 @@ export type ProviderDraft = {
disableCooling?: boolean;
websockets?: boolean;
testModel?: string;
thinkingLevels?: ThinkingLevel[];
thinkingLevels?: string[];
disabled?: boolean;
cloakMode?: string;
cloakStrictMode?: boolean;
@@ -275,6 +274,9 @@ export const modelSelectionForDiscovery = (
);
};
export const allModelSelectionForDiscovery = (models: ModelOption[]) =>
new Set(models.map((model) => model.name.trim().toLowerCase()).filter(Boolean));
const mergeOpenAiApiKeyEntries = (current: unknown, apiKey: string) => {
const entries = Array.isArray(current) ? current.filter(isRecord) : [];
const keys = apiKey
@@ -295,19 +297,19 @@ const mergeOpenAiApiKeyEntries = (current: unknown, apiKey: string) => {
});
};
const thinkingLevelsFromModels = (models: ModelOption[]): ThinkingLevel[] => {
const levels = new Set<ThinkingLevel>();
const thinkingLevelsFromModels = (models: ModelOption[]): string[] => {
const levels: string[] = [];
models.forEach((model) => {
const configured = model.thinking?.levels;
if (!Array.isArray(configured)) return;
configured.forEach((level) => {
const normalized = String(level).trim().toLowerCase();
if (OPENAI_THINKING_LEVELS.includes(normalized as ThinkingLevel)) {
levels.add(normalized as ThinkingLevel);
if (normalized && !levels.includes(normalized)) {
levels.push(normalized);
}
});
});
return OPENAI_THINKING_LEVELS.filter((level) => levels.has(level));
return levels;
};
const draftFromRow = (row: ProviderRow): ProviderDraft => ({
@@ -377,15 +379,14 @@ const deepSeekDefaultModels = (): ModelOption[] => [
export const createProviderDraft = (category: ProviderCategory): ProviderDraft => {
const draft = emptyProviderDraft();
const withThinking = definitionFor(category).openAi
? { ...draft, thinkingLevels: [...OPENAI_THINKING_LEVELS] }
: draft;
if (category !== 'deepseek') return withThinking;
if (category === 'openai-compatibility') return { ...draft, thinkingLevels: [] };
if (category !== 'deepseek') return draft;
return {
...withThinking,
...draft,
name: 'DeepSeek',
baseUrl: DEEPSEEK_BASE_URL,
models: deepSeekDefaultModels(),
thinkingLevels: [...DEEPSEEK_THINKING_LEVELS],
};
};
@@ -394,7 +395,9 @@ export const applyProviderPreset = (
draft: ProviderDraft,
): ProviderDraft => {
if (!definitionFor(category).openAi || draft.thinkingLevels === undefined) return draft;
const levels = draft.thinkingLevels;
const levels = category === 'deepseek'
? [...DEEPSEEK_THINKING_LEVELS]
: draft.thinkingLevels;
return {
...draft,
models: draft.models.map((model) => {
@@ -625,7 +628,10 @@ export function ApiAccessPage() {
const openEdit = (row: ProviderRow) => {
setEditingRow(row);
setDialogDraft(draftFromRow(row));
const draft = draftFromRow(row);
setDialogDraft(activeCategory === 'deepseek'
? { ...draft, thinkingLevels: [...DEEPSEEK_THINKING_LEVELS] }
: draft);
setDialogOpen(true);
};
@@ -652,10 +658,11 @@ export function ApiAccessPage() {
return false;
}
let baseUrl = preparedDraft.baseUrl.trim();
let providerHeaders: Record<string, string> = {};
try {
if (baseUrl) baseUrl = normalizeBaseUrl(baseUrl);
if (baseUrlRequired && !baseUrl) throw new Error(`${definition.label} 接入必须填写 Base URL`);
parseProviderHeaders(preparedDraft.headersText ?? '');
providerHeaders = parseProviderHeaders(preparedDraft.headersText ?? '');
} catch (requestError) {
setError(String(requestError));
return false;
@@ -663,6 +670,23 @@ export function ApiAccessPage() {
setBusy(true);
setError('');
try {
let draftToSave = { ...preparedDraft, baseUrl };
if (definition.openAi && draftToSave.models.length === 0) {
const fetchedModels = await fetchModels(
'openai',
baseUrl,
parsedApiKeys[0],
editingRow?.authIndex,
providerHeaders,
);
if (fetchedModels.length === 0) {
throw new Error('没有发现可放行的模型,请确认 Base URL 和 API 密钥');
}
draftToSave = applyProviderPreset(activeCategory, {
...draftToSave,
models: fetchedModels,
});
}
const latestConfig = await managementApi.get('/config');
const current = sectionRecordsFromConfig(latestConfig, activeSection);
let nextList: Record<string, unknown>[];
@@ -676,7 +700,7 @@ export function ApiAccessPage() {
}
const nextRecord = buildProviderRecord(
activeSection,
{ ...preparedDraft, baseUrl },
draftToSave,
current[targetIndex],
);
nextList = current.map((record, index) =>
@@ -692,7 +716,7 @@ export function ApiAccessPage() {
if (duplicate) throw new Error('相同的接入配置已经存在');
nextList = [
...current,
buildProviderRecord(activeSection, { ...preparedDraft, baseUrl }),
buildProviderRecord(activeSection, draftToSave),
];
}
@@ -846,7 +870,6 @@ export function ApiAccessPage() {
<div className="provider-row-main">
<div className="provider-row-title">
<strong title={row.name}>{row.name}</strong>
{row.disabled ? <span className="state-pill error">已停用</span> : <span className="state-pill success">已启用</span>}
</div>
<code title={definitionFor(row.section).openAi ? `${row.apiKeys.length} 个密钥` : undefined}>
{definitionFor(row.section).openAi && row.apiKeys.length > 1
@@ -861,9 +884,19 @@ export function ApiAccessPage() {
{row.authIndex ? <span title={row.authIndex}>运行时凭据 {row.authIndex.slice(0, 8)}…</span> : null}
</div>
<div className="provider-row-actions">
<button type="button" className="icon-button quiet" onClick={() => void toggleProvider(row)} disabled={busy} title={row.disabled ? '启用' : '停用'}>
{row.disabled ? <Check size={16} /> : <X size={16} />}
</button>
<label className="provider-enabled-control" title={row.disabled ? '启用接入' : '停用接入'}>
<span>{row.disabled ? '已停用' : '已启用'}</span>
<span className="switch-control">
<input
type="checkbox"
checked={!row.disabled}
onChange={() => void toggleProvider(row)}
disabled={busy}
aria-label={`${row.name} 接入${row.disabled ? '启用' : '停用'}开关`}
/>
<span className="switch-track" />
</span>
</label>
<button type="button" className="icon-button quiet" onClick={() => openEdit(row)} disabled={busy} title="编辑">
<Edit3 size={16} />
</button>
@@ -917,7 +950,7 @@ function ApiProviderDialog({
const [discoveredModels, setDiscoveredModels] = useState<ModelOption[]>([]);
const [modelDiscoveryOpen, setModelDiscoveryOpen] = useState(false);
const [modelSearch, setModelSearch] = useState('');
const [modelSelectionApplied, setModelSelectionApplied] = useState(false);
const [thinkingLevelInput, setThinkingLevelInput] = useState('');
const [selectedModelNames, setSelectedModelNames] = useState<Set<string>>(
() => new Set(initialDraft.models.map((model) => model.name.toLowerCase())),
);
@@ -956,19 +989,28 @@ function ApiProviderDialog({
setDraft((current) => ({ ...current, [field]: value }));
};
const toggleThinkingLevel = (level: ThinkingLevel) => {
const addThinkingLevel = () => {
const level = thinkingLevelInput.trim().toLowerCase();
if (!level) return;
setDraft((current) => {
const selected = new Set(current.thinkingLevels ?? []);
if (selected.has(level)) selected.delete(level);
else selected.add(level);
const levels = current.thinkingLevels ?? [];
if (levels.some((item) => item.toLowerCase() === level)) return current;
return {
...current,
thinkingLevels: OPENAI_THINKING_LEVELS.filter((item) => selected.has(item)),
thinkingLevels: [...levels, level],
};
});
setThinkingLevelInput('');
};
const discoverModels = async (initializeSelection = false) => {
const removeThinkingLevel = (level: string) => {
setDraft((current) => ({
...current,
thinkingLevels: (current.thinkingLevels ?? []).filter((item) => item !== level),
}));
};
const discoverModels = async () => {
const baseUrlRequired =
activeSection === 'codex-api-key' || activeSection === 'openai-compatibility';
if (baseUrlRequired && !draft.baseUrl.trim()) {
@@ -998,18 +1040,7 @@ function ApiProviderDialog({
{ ...draft, models: fetchedModels },
).models;
setDiscoveredModels(models);
if (initializeSelection) {
setSelectedModelNames(
!editingRow && !modelSelectionApplied
? new Set(models.map((model) => model.name.toLowerCase()))
: modelSelectionForDiscovery(
activeSection,
draft.models,
models,
draft.excludedModelsText ?? '',
),
);
}
setSelectedModelNames(allModelSelectionForDiscovery(models));
if (!models.length) setModelError('未发现可用模型');
} catch (requestError) {
setDiscoveredModels([]);
@@ -1029,7 +1060,7 @@ function ApiProviderDialog({
setModelSearch('');
setSelectedModelNames(new Set(draft.models.map((model) => model.name.toLowerCase())));
setModelDiscoveryOpen(true);
void discoverModels(true);
void discoverModels();
};
const toggleModelSelection = (model: ModelOption) => {
@@ -1055,13 +1086,9 @@ function ApiProviderDialog({
};
const applyModelSelection = () => {
const useProviderDefault = activeSection !== 'openai-compatibility'
&& draft.models.length === 0
&& discoveredModels.length > 0
&& discoveredModels.every((model) => selectedModelNames.has(model.name.toLowerCase()));
const models = useProviderDefault
? []
: modelOptions.filter((model) => selectedModelNames.has(model.name.toLowerCase()));
const models = modelOptions.filter((model) =>
selectedModelNames.has(model.name.toLowerCase()),
);
setDraft((current) => ({
...current,
models,
@@ -1073,7 +1100,6 @@ function ApiProviderDialog({
selectedModelNames,
),
}));
setModelSelectionApplied(true);
setModelDiscoveryOpen(false);
};
@@ -1089,14 +1115,14 @@ function ApiProviderDialog({
: hasModelExclusions
? '已配置模型限制'
: activeSection === 'openai-compatibility'
? '获取模型后默认全部开放'
? '保存时默认开放全部模型'
: '使用上游默认模型';
const modelSummaryDetail = draft.models.length > 0
? draft.models.slice(0, 3).map((model) => model.name).join('、')
: hasModelExclusions
? '未勾选的模型不会出现在开放列表'
: activeSection === 'openai-compatibility'
? '可在模型列表中取消不希望开放的项目'
? '保存接入时会自动获取模型,也可提前取消不需要的项目'
: '当前开放上游全部模型';
return (
@@ -1133,28 +1159,51 @@ function ApiProviderDialog({
</div>
</div>
) : null}
{definition.openAi ? (
{activeCategory === 'deepseek' ? (
<div className="thinking-level-config">
<div className="thinking-level-heading">
<strong>内置思考等级</strong>
<span>自动应用到当前开放的全部模型</span>
</div>
<div className="thinking-level-tags readonly">
{DEEPSEEK_THINKING_LEVELS.map((level) => <span key={level}>{level}</span>)}
</div>
</div>
) : activeCategory === 'openai-compatibility' ? (
<div className="thinking-level-config">
<div className="thinking-level-heading">
<strong>思考等级</strong>
<span>应用到当前开放的全部模型</span>
<span>按上游支持情况自行添加</span>
</div>
<div className="thinking-level-options">
{OPENAI_THINKING_LEVELS.map((level) => {
const checked = draft.thinkingLevels?.includes(level) ?? false;
return (
<label className={checked ? 'selected' : ''} key={level}>
<input
type="checkbox"
checked={checked}
onChange={() => toggleThinkingLevel(level)}
/>
<span>{level}</span>
</label>
);
})}
<div className="thinking-level-entry">
<input
value={thinkingLevelInput}
onChange={(event) => setThinkingLevelInput(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
addThinkingLevel();
}
}}
placeholder="例如 low、medium 或自定义等级"
/>
<button type="button" className="secondary-button compact-button" onClick={addThinkingLevel} disabled={!thinkingLevelInput.trim()}>
<Plus size={14} />添加
</button>
</div>
{(draft.thinkingLevels?.length ?? 0) > 0 ? (
<div className="thinking-level-tags">
{draft.thinkingLevels?.map((level) => (
<span key={level}>
{level}
<button type="button" onClick={() => removeThinkingLevel(level)} title={`删除 ${level}`} aria-label={`删除思考等级 ${level}`}>
<X size={12} />
</button>
</span>
))}
</div>
) : <small className="thinking-level-empty">未添加时不写入 thinking.levels</small>}
</div>
</div>
) : null}
<div className="model-config-card">
<div className="model-config-heading">
+1
View File
@@ -528,6 +528,7 @@ export function AuthFileManagementPage() {
<span>共 {oauthModels.length} 个 · 已开放 {openOauthModelNames.size} 个</span>
<div>
<button type="button" className="secondary-button compact-button" onClick={toggleAllVisibleOauthModels} disabled={oauthModelLoading || visibleOauthModels.length === 0}>{allVisibleOauthModelsOpen ? '关闭当前' : '开放当前'}</button>
<button type="button" className="secondary-button compact-button" onClick={() => setOpenOauthModelNames(new Set(oauthModels.map((model) => model.id.toLowerCase())))} disabled={oauthModelLoading || oauthModels.length === 0 || openOauthModelNames.size === oauthModels.length}>全部开放</button>
<button type="button" className="secondary-button compact-button" onClick={() => setOpenOauthModelNames(new Set())} disabled={oauthModelLoading || openOauthModelNames.size === 0}>全部关闭</button>
</div>
</div>
+530
View File
@@ -0,0 +1,530 @@
import {
type KeyboardEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { invoke } from '@tauri-apps/api/core';
import {
ArrowRight,
BrainCircuit,
Check,
GitFork,
LoaderCircle,
Search,
Trash2,
X,
} from 'lucide-react';
type PresetThinkingEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max';
type ThinkingAliasEntry = {
sourceModel: string;
alias: string;
effort: string | null;
provider: string;
kind: string;
};
type ThinkingAliasSource = {
id: string;
model: string;
displayName: string | null;
provider: string;
kind: string;
protocol: string;
};
const effortOptions: { value: PresetThinkingEffort; label: string; hint: string }[] = [
{ value: 'low', label: 'Low', hint: '轻量' },
{ value: 'medium', label: 'Medium', hint: '均衡' },
{ value: 'high', label: 'High', hint: '深入' },
{ value: 'xhigh', label: 'XHigh', hint: '超高' },
{ value: 'max', label: 'Max', hint: '最大' },
];
export const thinkingAliasSourceKindLabel = (kind: string) => {
if (kind === 'codex-oauth') return 'Codex OAuth';
if (kind === 'codex-api') return 'Codex API';
if (kind === 'openai-compatible') return 'OpenAI 兼容';
return '其他来源';
};
const thinkingAliasProviderDetail = (kind: string, provider: string) => (
provider === thinkingAliasSourceKindLabel(kind) ? '内核模型目录' : provider
);
const thinkingAliasSourceDetail = (source: ThinkingAliasSource) => (
thinkingAliasProviderDetail(source.kind, source.provider)
);
export function ThinkingAliasesPage() {
const [entries, setEntries] = useState<ThinkingAliasEntry[]>([]);
const [sources, setSources] = useState<ThinkingAliasSource[]>([]);
const [selectedSourceId, setSelectedSourceId] = useState('');
const [effort, setEffort] = useState('xhigh');
const [customEffortOpen, setCustomEffortOpen] = useState(false);
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<ThinkingAliasEntry[]>('get_thinking_aliases'),
invoke<ThinkingAliasSource[]>('get_thinking_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
.map((source, index) => {
const model = source.model.toLowerCase();
const displayName = (source.displayName ?? '').toLowerCase();
const haystack = `${model} ${displayName} ${source.provider} ${thinkingAliasSourceKindLabel(source.kind)}`
.toLowerCase();
let score = 5;
if (model === query) score = 0;
else if (displayName === query) score = 1;
else if (model.startsWith(query)) score = 2;
else if (displayName.startsWith(query)) score = 3;
else if (haystack.includes(query)) score = 4;
return { source, index, score };
})
.filter((item) => item.score < 5)
.sort((left, right) => left.score - right.score || left.index - right.index)
.map((item) => item.source);
}, [sources, search]);
useEffect(() => {
setActiveSourceIndex(0);
}, [search, sources]);
const chooseSource = (source: ThinkingAliasSource) => {
setSelectedSourceId(source.id);
setModelPickerOpen(false);
setSearch('');
};
const chooseEffort = (nextEffort: string) => {
setEffort(nextEffort);
};
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 normalizedEffort = effort.trim().toLowerCase();
const customEffortSelected = Boolean(
normalizedEffort && !effortOptions.some((option) => option.value === normalizedEffort),
);
const createAlias = async () => {
if (!selectedSource) {
setError('请先选择原模型');
return;
}
const normalizedAlias = alias.trim();
if (!normalizedAlias) {
setError('别名模型不能为空');
return;
}
if (!normalizedEffort) {
setError('思考强度不能为空');
return;
}
setBusyAlias(normalizedAlias);
setBusyAction('create');
setError('');
setNotice('');
try {
const nextEntries = await invoke<ThinkingAliasEntry[]>('create_thinking_alias', {
sourceId: selectedSource.id,
alias: normalizedAlias,
effort: normalizedEffort,
});
setEntries(nextEntries);
setNotice(`已创建 ${normalizedAlias},思考强度固定为 ${normalizedEffort}`);
setAlias('');
} catch (requestError) {
setError(String(requestError));
} finally {
setBusyAlias('');
setBusyAction('');
}
};
const deleteAlias = async (entry: ThinkingAliasEntry) => {
if (!window.confirm(
`确定删除别名模型「${entry.alias}」吗?\n将同时移除对应的思考强度覆盖规则。`,
)) return;
setBusyAlias(entry.alias);
setBusyAction('delete');
setError('');
setNotice('');
try {
const nextEntries = await invoke<ThinkingAliasEntry[]>('delete_thinking_alias', {
alias: entry.alias,
});
setEntries(nextEntries);
setNotice(`已删除 ${entry.alias}`);
} catch (requestError) {
setError(String(requestError));
} finally {
setBusyAlias('');
setBusyAction('');
}
};
return (
<section className="page management-page thinking-alias-page">
<header className="management-header">
<div>
<span>Model Alias</span>
<h1>思考别名</h1>
</div>
</header>
<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-guide">
<BrainCircuit size={20} />
<div className="thinking-alias-guide-copy">
<strong>为原模型新增一个固定思考强度的自定义别名</strong>
<p>别名名称由你填写,客户端调用它时 CPA 才会注入所选思考强度;直接调用原模型时,原有名称、配置和行为全部保持不变。</p>
<span>原模型不受影响</span>
</div>
<div className="thinking-alias-flow" aria-label="思考别名工作流程">
<span><small>原模型</small><code>deepseek-v4-pro</code></span>
<ArrowRight size={14} />
<div>
<span><small>直接调用</small><code>deepseek-v4-pro · 保持不变</code></span>
<span><small>别名调用</small><code>deepseek-v4-pro-max · 固定max强度</code></span>
</div>
</div>
</div>
<div className="thinking-alias-workbench">
<section className="panel thinking-alias-builder">
<div className="thinking-alias-panel-heading">
<span><GitFork size={18} /></span>
<div>
<h2>创建思考别名</h2>
<p>支持 Codex OAuth、Codex API 和 OpenAI 兼容接入</p>
</div>
</div>
<div className="thinking-alias-field thinking-model-field">
<label htmlFor="thinking-model-search">原模型</label>
<div className="thinking-model-picker" ref={modelPickerRef}>
<div className="thinking-model-search">
{loading ? <LoaderCircle size={15} className="spin" /> : <Search size={15} />}
<input
id="thinking-model-search"
role="combobox"
aria-autocomplete="list"
aria-expanded={modelPickerOpen}
aria-controls="thinking-model-options"
aria-activedescendant={modelPickerOpen && filteredSources[activeSourceIndex]
? `thinking-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 ? '正在读取模型' : '搜索并选择原模型'}
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="thinking-model-options"
role="listbox"
aria-label="可用原模型"
>
{filteredSources.length === 0 ? (
<div className="thinking-model-empty">
{sources.length ? '没有匹配的模型或来源' : '当前没有可选模型'}
</div>
) : filteredSources.map((source, index) => {
const selected = source.id === selectedSourceId;
return (
<button
type="button"
role="option"
aria-selected={selected}
id={`thinking-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={thinkingAliasSourceDetail(source)}>
{thinkingAliasSourceDetail(source)}
</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={thinkingAliasSourceDetail(selectedSource)}>
来源:{thinkingAliasSourceDetail(selectedSource)}
</strong>
</div>
) : (
<small className="thinking-model-hint">同名模型会按 Codex OAuth、Codex API 和 OpenAI 兼容来源分别列出</small>
)}
</div>
<div className="thinking-alias-field">
<div className="thinking-field-heading">
<strong>思考强度</strong>
<span>选择常用等级,或输入上游支持的自定义值</span>
</div>
<div className="thinking-effort-options">
{effortOptions.map((option) => (
<button
type="button"
className={effort.trim().toLowerCase() === option.value ? 'active' : ''}
key={option.value}
onClick={() => {
chooseEffort(option.value);
setCustomEffortOpen(false);
}}
disabled={Boolean(busyAlias)}
title={option.hint}
>
{option.label}
</button>
))}
<button
type="button"
className={customEffortSelected ? 'active custom' : 'custom'}
onClick={() => {
if (!customEffortSelected) chooseEffort('');
setCustomEffortOpen(true);
}}
disabled={Boolean(busyAlias)}
title={customEffortSelected
? `自定义等级:${normalizedEffort}`
: '输入模型提供商支持的其他思考等级'}
>
{customEffortSelected ? normalizedEffort : '自定义'}
</button>
</div>
{customEffortOpen ? <div className="thinking-effort-custom">
<input
id="thinking-effort-custom"
value={customEffortSelected ? effort : ''}
onChange={(event) => chooseEffort(event.currentTarget.value)}
placeholder="例如 minimal、auto、ultra 或厂商等级"
maxLength={64}
spellCheck={false}
autoFocus
disabled={Boolean(busyAlias)}
/>
<button
type="button"
className="icon-button quiet"
onClick={() => setCustomEffortOpen(false)}
title="收起自定义输入"
aria-label="收起自定义输入"
>
<X size={14} />
</button>
<small>仅填写目标模型实际支持的等级名称</small>
</div> : null}
</div>
<div className="thinking-alias-field">
<div className="thinking-field-heading">
<strong>别名模型名称</strong>
<span>由你填写,仅新增入口,不会改名原模型</span>
</div>
<input
id="thinking-alias-name"
className="thinking-alias-input"
value={alias}
onChange={(event) => setAlias(event.currentTarget.value)}
placeholder={selectedSource
? `例如 ${selectedSource.model}-${normalizedEffort || 'high'}`
: '先选择原模型,再填写客户端使用的别名'}
disabled={Boolean(busyAlias)}
/>
</div>
<div className="thinking-alias-preview">
<BrainCircuit size={18} />
<div>
<span>{selectedSource?.model || '未选择模型'} <ArrowRight size={13} /> {alias || '请输入别名模型名称'}</span>
<code>{selectedSource?.protocol === 'openai' ? 'reasoning_effort' : 'reasoning.effort'} = {normalizedEffort || '未填写'}</code>
</div>
</div>
<button
type="button"
className="primary-button thinking-alias-create"
onClick={() => void createAlias()}
disabled={loading || Boolean(busyAlias) || !selectedSource || !effort.trim() || !alias.trim()}
>
{busyAction === 'create' ? <LoaderCircle size={16} className="spin" /> : <GitFork size={16} />}
{busyAction === 'create' ? '创建中' : '创建别名'}
</button>
</section>
<section className="panel thinking-alias-list-panel">
<div className="thinking-alias-list-heading">
<div>
<h2>已创建别名</h2>
<span>每个别名都保留原模型、来源和固定思考强度</span>
</div>
<strong>{entries.length}</strong>
</div>
<div className="thinking-alias-list">
{loading ? (
<div className="management-loading"><LoaderCircle size={20} className="spin" />读取配置中</div>
) : entries.length === 0 ? (
<div className="management-empty">
<GitFork size={25} />
<strong>尚未创建思考别名</strong>
<span>从左侧选择模型和强度即可创建</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={thinkingAliasProviderDetail(entry.kind, entry.provider)}>
{thinkingAliasProviderDetail(entry.kind, entry.provider)}
</span>
</small>
</div>
<ArrowRight size={14} />
<strong title={entry.alias}>{entry.alias}</strong>
</div>
<span className={`thinking-effort-badge ${entry.effort ? '' : 'missing'}`}>
{entry.effort ?? '未绑定强度'}
</span>
<button
type="button"
className="icon-button danger"
onClick={() => void deleteAlias(entry)}
disabled={Boolean(busyAlias)}
title={`删除 ${entry.alias}`}
>
{busyAction === 'delete' && busyAlias === entry.alias
? <LoaderCircle size={15} className="spin" />
: <Trash2 size={15} />}
</button>
</article>
))}
</div>
</section>
</div>
</section>
);
}