feat(codex): add customizable model catalog editor

- add per-model context, reasoning, modality, visibility, and parallel tool settings
- persist customizations separately and apply them during catalog generation
- support restoring individual or all models to official fallback templates
- expose full fallback reasoning levels while keeping Fast available
- add localized UI, validation, documentation, and regression tests
This commit is contained in:
lzt404 committed 2026-09-05 18:05:57 +08:00
1 parent c370c17ff0
commit 673a322c00
13 files changed
+1671 -8

No files matched your search

+45
View File
@@ -1,6 +1,51 @@
import type { MessageKey } from './locales/zh-CN';
export const jaOverrides = {
'agents.catalog.button': 'モデル一覧の設定',
'agents.catalog.title': 'Codex モデル一覧',
'agents.catalog.subtitle': '利用可能なモデルのパラメーターを編集し、生成される Codex モデル一覧に保存します。',
'agents.catalog.search': 'モデル名または ID を検索',
'agents.catalog.official': '公式テンプレート',
'agents.catalog.fallback': '標準テンプレート',
'agents.catalog.customized': 'カスタマイズ済み',
'agents.catalog.reload': '再読み込み',
'agents.catalog.loading': 'モデル一覧を読み込み中…',
'agents.catalog.empty': '利用可能なモデルがありません。コアに接続し、モデルの提供元を設定してください。',
'agents.catalog.displayName': '表示名',
'agents.catalog.description': 'モデルの説明',
'agents.catalog.context': 'コンテキストウィンドウ(トークン)',
'agents.catalog.maximum': '最大コンテキストウィンドウ(トークン)',
'agents.catalog.percent': '有効コンテキスト比率(%)',
'agents.catalog.compact': '自動圧縮のしきい値(トークン)',
'agents.catalog.automatic': '空欄で自動設定',
'agents.catalog.reasoning': '選択可能な推論レベル',
'agents.catalog.defaultReasoning': '既定の推論レベル',
'agents.catalog.clientDefault': 'クライアントに任せる',
'agents.catalog.modalities': '入力タイプ',
'agents.catalog.text': 'テキスト',
'agents.catalog.image': '画像',
'agents.catalog.visible': 'Codex のモデル一覧に表示',
'agents.catalog.parallel': '並列ツール呼び出しを許可',
'agents.catalog.capabilityHint': 'Fast は常に選択可能です。推論・画像・ツール機能の動作は上流モデルの対応状況によります。',
'agents.catalog.resetModel': 'このテンプレートを復元',
'agents.catalog.resetAll': 'すべて公式テンプレートに復元',
'agents.catalog.resetHint': '専用テンプレートがあるモデルは公式設定に、それ以外はアプリの標準設定に戻ります。保存すると反映されます。',
'agents.catalog.restored': '下書きをテンプレートに戻しました。保存すると反映されます。',
'agents.catalog.saved': '保存しました。管理対象の一覧が同期されます。Codex を再起動して変更を読み込んでください。',
'agents.catalog.syncFailed': '設定は保存しましたが、一覧の同期に失敗しました:{error}',
'agents.catalog.changed': 'モデル一覧またはテンプレートが更新されました。再読み込みしてから編集してください。',
'agents.catalog.unsaved': '未保存の変更があります。',
'agents.catalog.discard': '変更を破棄',
'agents.catalog.discardHint': '閉じると、保存していないモデルパラメーターの変更は破棄されます。',
'agents.catalog.keepEditing': '編集を続ける',
'agents.catalog.saveHint': '変更はこの端末に保存され、アプリの再起動後も保持されます。',
'agents.catalog.invalidText': '表示名を入力してください。名前と説明はそれぞれ 4000 文字以内にしてください。',
'agents.catalog.invalidContext': 'コンテキストウィンドウには有効な正の整数を入力してください。',
'agents.catalog.invalidMaximum': '最大コンテキストはコンテキストウィンドウ以上にしてください。',
'agents.catalog.invalidPercent': '有効コンテキスト比率は 1~100 の整数にしてください。',
'agents.catalog.invalidCompact': '自動圧縮のしきい値はコンテキストウィンドウ以下の正の整数にしてください。',
'agents.catalog.invalidReasoning': '既定の推論レベルは選択肢に含め、レベルを重複させないでください。',
'agents.catalog.invalidModalities': '入力タイプを少なくとも 1 つ選択してください。',
'apiAccess.models.add': 'カスタムモデルを追加',
'apiAccess.models.namePlaceholder': 'モデル名(例: gpt-4o)',
'apiAccess.models.aliasPlaceholder': 'モデル別名(任意)',
+45
View File
@@ -1,6 +1,51 @@
import type { MessageKey } from './zh-CN';
export const en: Record<MessageKey, string> = {
'agents.catalog.button': 'Model list settings',
'agents.catalog.title': 'Codex model catalog',
'agents.catalog.subtitle': 'Customize parameters for currently available models and save them to the generated Codex catalog.',
'agents.catalog.search': 'Search model names or IDs',
'agents.catalog.official': 'Official template',
'agents.catalog.fallback': 'Default template',
'agents.catalog.customized': 'Customized',
'agents.catalog.reload': 'Reload',
'agents.catalog.loading': 'Loading models…',
'agents.catalog.empty': 'No models are available. Connect the core and configure a model source first.',
'agents.catalog.displayName': 'Display name',
'agents.catalog.description': 'Description',
'agents.catalog.context': 'Context window (tokens)',
'agents.catalog.maximum': 'Maximum context window (tokens)',
'agents.catalog.percent': 'Effective context (%)',
'agents.catalog.compact': 'Auto-compaction threshold (tokens)',
'agents.catalog.automatic': 'Leave blank for automatic',
'agents.catalog.reasoning': 'Available reasoning levels',
'agents.catalog.defaultReasoning': 'Default reasoning level',
'agents.catalog.clientDefault': 'Let the client decide',
'agents.catalog.modalities': 'Input types',
'agents.catalog.text': 'Text',
'agents.catalog.image': 'Image',
'agents.catalog.visible': 'Show in the Codex model list',
'agents.catalog.parallel': 'Allow parallel tool calls',
'agents.catalog.capabilityHint': 'Fast is always available. Reasoning, image and tool capabilities depend on the upstream model.',
'agents.catalog.resetModel': 'Restore this template',
'agents.catalog.resetAll': 'Restore all official templates',
'agents.catalog.resetHint': 'Models with dedicated templates return to official settings; other models return to application defaults. Save to apply.',
'agents.catalog.restored': 'Templates restored in the draft. Save to apply.',
'agents.catalog.saved': 'Saved. Managed catalogs are synchronized; restart Codex to load the changes.',
'agents.catalog.syncFailed': 'Settings saved, but catalog synchronization failed: {error}',
'agents.catalog.changed': 'The model list or templates have changed. Reload before editing again.',
'agents.catalog.unsaved': 'You have unsaved changes.',
'agents.catalog.discard': 'Discard changes',
'agents.catalog.discardHint': 'Closing now discards the model parameter changes that have not been saved.',
'agents.catalog.keepEditing': 'Keep editing',
'agents.catalog.saveHint': 'Changes are saved locally and survive application restarts.',
'agents.catalog.invalidText': 'Enter a display name. Names and descriptions must not exceed 4,000 characters.',
'agents.catalog.invalidContext': 'Context windows must be positive safe integers.',
'agents.catalog.invalidMaximum': 'Maximum context must be at least the context window.',
'agents.catalog.invalidPercent': 'Effective context must be an integer from 1 to 100.',
'agents.catalog.invalidCompact': 'The compaction threshold must be a positive integer no greater than the context window.',
'agents.catalog.invalidReasoning': 'The default reasoning level must be among the selected levels, with no duplicate levels.',
'agents.catalog.invalidModalities': 'Select at least one input type.',
'apiAccess.models.add': 'Add Custom Model',
'apiAccess.models.namePlaceholder': 'Model name, for example gpt-4o',
'apiAccess.models.aliasPlaceholder': 'Model alias (optional)',
+45
View File
@@ -1,4 +1,49 @@
export const zhCN = {
'agents.catalog.button': '模型列表设置',
'agents.catalog.title': 'Codex 模型列表',
'agents.catalog.subtitle': '自定义当前可用模型的参数,保存后用于生成 Codex 模型列表。',
'agents.catalog.search': '搜索模型名称或 ID',
'agents.catalog.official': '官方模板',
'agents.catalog.fallback': '默认模板',
'agents.catalog.customized': '已自定义',
'agents.catalog.reload': '重新加载',
'agents.catalog.loading': '正在读取模型列表…',
'agents.catalog.empty': '当前没有可用模型,请先连接内核并配置模型来源。',
'agents.catalog.displayName': '显示名称',
'agents.catalog.description': '模型说明',
'agents.catalog.context': '上下文窗口(tokens)',
'agents.catalog.maximum': '最大上下文窗口(tokens)',
'agents.catalog.percent': '有效上下文比例(%)',
'agents.catalog.compact': '自动压缩阈值(tokens)',
'agents.catalog.automatic': '留空使用自动设置',
'agents.catalog.reasoning': '可选思考等级',
'agents.catalog.defaultReasoning': '默认思考等级',
'agents.catalog.clientDefault': '由客户端决定',
'agents.catalog.modalities': '输入类型',
'agents.catalog.text': '文本',
'agents.catalog.image': '图片',
'agents.catalog.visible': '在 Codex 模型列表中显示',
'agents.catalog.parallel': '允许并行工具调用',
'agents.catalog.capabilityHint': 'Fast 始终可选;思考等级、图片与工具能力是否生效取决于上游模型。',
'agents.catalog.resetModel': '恢复此模型模板',
'agents.catalog.resetAll': '全部恢复官方模板',
'agents.catalog.resetHint': '有专属模板的模型恢复官方配置,其余恢复软件默认配置。恢复操作保存后生效。',
'agents.catalog.restored': '模板已恢复到草稿,点击保存后生效。',
'agents.catalog.saved': '已保存。已托管的模型目录会同步更新,重启 Codex 后加载新配置。',
'agents.catalog.syncFailed': '设置已保存,但目录同步失败:{error}',
'agents.catalog.changed': '模型列表或模板已更新,请重新加载后再编辑。',
'agents.catalog.unsaved': '有尚未保存的修改。',
'agents.catalog.discard': '放弃修改',
'agents.catalog.discardHint': '关闭后,本次尚未保存的模型参数修改会丢失。',
'agents.catalog.keepEditing': '继续编辑',
'agents.catalog.saveHint': '修改保存在本机,重新启动软件后仍然有效。',
'agents.catalog.invalidText': '请填写显示名称,名称和说明均不能超过 4000 个字符。',
'agents.catalog.invalidContext': '上下文窗口必须为有效的正整数。',
'agents.catalog.invalidMaximum': '最大上下文窗口不能小于上下文窗口。',
'agents.catalog.invalidPercent': '有效上下文比例必须为 1 到 100 的整数。',
'agents.catalog.invalidCompact': '自动压缩阈值必须为正整数,且不能超过上下文窗口。',
'agents.catalog.invalidReasoning': '默认思考等级必须位于可选等级中,且等级不能重复。',
'agents.catalog.invalidModalities': '请至少选择一种输入类型。',
'apiAccess.models.add': '添加自定义模型',
'apiAccess.models.namePlaceholder': '模型名称,例如 gpt-4o',
'apiAccess.models.aliasPlaceholder': '模型别名(可选)',
+19
View File
@@ -22,6 +22,7 @@ import {
Play,
RefreshCw,
Search,
SlidersHorizontal,
Trash2,
Terminal,
Wrench,
@@ -59,6 +60,7 @@ import {
import type { ModelOption } from '../services/modelService';
import { getCurrentLocale, translate, useI18n } from '../i18n';
import { CodexSessionsPanel } from './CodexSessionsPanel';
import { CodexModelCatalogDialog } from './CodexModelCatalogDialog';
type AgentClientId =
| 'claude-code'
@@ -670,6 +672,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
const [launchError, setLaunchError] = useState('');
const [launchDirectoryDialogOpen, setLaunchDirectoryDialogOpen] = useState(false);
const [codexCatalogDialogOpen, setCodexCatalogDialogOpen] = useState(false);
const [launchDirectory, setLaunchDirectory] = useState('');
const [launchDirectoryTarget, setLaunchDirectoryTarget] = useState<AgentLaunchTarget | null>(null);
const [launchDirectoryError, setLaunchDirectoryError] = useState('');
@@ -1944,6 +1947,15 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
<span className="switch-track" />
</span>
</label>
<button
type="button"
className="secondary-button agent-codex-catalog-button"
onClick={() => setCodexCatalogDialogOpen(true)}
disabled={busy}
>
<SlidersHorizontal size={16} />
{t('agents.catalog.button')}
</button>
</div>
) : null}
<div className={`agent-modification-buttons ${selected === 'codex' ? 'codex' : ''}`}>
@@ -2099,6 +2111,13 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
</section>
</div>
{codexCatalogDialogOpen ? (
<CodexModelCatalogDialog
onClose={() => setCodexCatalogDialogOpen(false)}
onSaved={() => loadModels('codex', selectedModel)}
/>
) : null}
{launchDirectoryDialogOpen ? (
<div className="config-dialog-backdrop" onMouseDown={(event) => {
if (event.currentTarget === event.target && !busy) {
+317
View File
@@ -0,0 +1,317 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { Check, LoaderCircle, RefreshCw, RotateCcw, Search, X } from 'lucide-react';
import { useI18n } from '../i18n';
import {
cloneCodexModelConfiguration,
codexReasoningEfforts,
sameCodexModelConfiguration,
toggleCodexReasoningLevel,
validateCodexModelConfiguration,
type CodexCatalogEditorModel,
type CodexCatalogEditorSaveResult,
type CodexCatalogEditorSnapshot,
type CodexModelConfiguration,
type CodexReasoningEffort,
} from '../services/codexModelCatalog';
type CodexModelCatalogDialogProps = {
onClose: () => void;
onSaved: () => void | Promise<void>;
};
const cloneModel = (model: CodexCatalogEditorModel): CodexCatalogEditorModel => ({
...model,
configuration: cloneCodexModelConfiguration(model.configuration),
defaults: cloneCodexModelConfiguration(model.defaults),
});
const cloneModels = (snapshot: CodexCatalogEditorSnapshot) => snapshot.models.map(cloneModel);
export function CodexModelCatalogDialog({ onClose, onSaved }: CodexModelCatalogDialogProps) {
const { t } = useI18n();
const [snapshot, setSnapshot] = useState<CodexCatalogEditorSnapshot | null>(null);
const [models, setModels] = useState<CodexCatalogEditorModel[]>([]);
const [selectedSlug, setSelectedSlug] = useState('');
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [discardConfirmOpen, setDiscardConfirmOpen] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError('');
setNotice('');
try {
const next = await invoke<CodexCatalogEditorSnapshot>('get_codex_model_catalog_editor');
setSnapshot(next);
setModels(cloneModels(next));
setSelectedSlug((current) => next.models.some((model) => model.slug === current)
? current
: next.models[0]?.slug ?? '');
} catch (requestError) {
setError(String(requestError));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const dirty = useMemo(() => {
if (!snapshot || snapshot.models.length !== models.length) return false;
return models.some((model) => {
const saved = snapshot.models.find((candidate) => candidate.slug === model.slug);
return !saved || !sameCodexModelConfiguration(model.configuration, saved.configuration);
});
}, [models, snapshot]);
const filteredModels = useMemo(() => {
const query = search.trim().toLocaleLowerCase();
if (!query) return models;
return models.filter((model) => model.slug.toLocaleLowerCase().includes(query)
|| model.configuration.display_name.toLocaleLowerCase().includes(query));
}, [models, search]);
const activeModel = models.find((model) => model.slug === selectedSlug) ?? null;
const activeCustomized = activeModel
? !sameCodexModelConfiguration(activeModel.configuration, activeModel.defaults)
: false;
const updateActive = (update: (configuration: CodexModelConfiguration) => CodexModelConfiguration) => {
setModels((current) => current.map((model) => model.slug === selectedSlug
? { ...model, configuration: update(model.configuration) }
: model));
setError('');
setNotice('');
};
const updateField = <Key extends keyof CodexModelConfiguration,>(
field: Key,
value: CodexModelConfiguration[Key],
) => updateActive((current) => ({ ...current, [field]: value }));
const toggleReasoning = (
effort: CodexReasoningEffort,
enabled: boolean,
defaults: CodexModelConfiguration['supported_reasoning_levels'],
) => updateActive((current) => toggleCodexReasoningLevel(current, effort, enabled, defaults));
const toggleModality = (modality: 'text' | 'image', enabled: boolean) => updateActive((current) => ({
...current,
input_modalities: (['text', 'image'] as const).filter((candidate) => candidate === modality
? enabled
: current.input_modalities.includes(candidate)),
}));
const requestClose = () => {
if (saving) return;
if (dirty) setDiscardConfirmOpen(true);
else onClose();
};
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
if (discardConfirmOpen) setDiscardConfirmOpen(false);
else requestClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
});
const restoreModel = () => {
if (!activeModel) return;
updateActive(() => cloneCodexModelConfiguration(activeModel.defaults));
setNotice(t('agents.catalog.restored'));
};
const restoreAll = () => {
setModels((current) => current.map((model) => ({
...model,
configuration: cloneCodexModelConfiguration(model.defaults),
})));
setError('');
setNotice(t('agents.catalog.restored'));
};
const save = async () => {
if (!snapshot || saving) return;
for (const model of models) {
const validationKey = validateCodexModelConfiguration(model.configuration);
if (validationKey) {
setSelectedSlug(model.slug);
setError(t(validationKey));
setNotice('');
return;
}
}
setSaving(true);
setError('');
setNotice('');
try {
const result = await invoke<CodexCatalogEditorSaveResult>('save_codex_model_catalog_editor', {
request: {
revision: snapshot.revision,
models: models.map((model) => ({
slug: model.slug,
configuration: model.configuration,
})),
},
});
setSnapshot(result.snapshot);
setModels(cloneModels(result.snapshot));
setNotice(result.synchronizationError
? t('agents.catalog.syncFailed', { error: result.synchronizationError })
: t('agents.catalog.saved'));
await onSaved();
} catch (requestError) {
const message = String(requestError);
setError(message.includes('CODEX_MODEL_CATALOG_CHANGED')
? t('agents.catalog.changed')
: message);
} finally {
setSaving(false);
}
};
return (
<div className="config-dialog-backdrop codex-catalog-backdrop" onMouseDown={(event) => {
if (event.currentTarget === event.target) requestClose();
}}>
<section className="config-dialog codex-catalog-dialog" role="dialog" aria-modal="true" aria-labelledby="codex-catalog-title">
<header className="config-dialog-heading codex-catalog-heading">
<div>
<h2 id="codex-catalog-title">{t('agents.catalog.title')}</h2>
<p>{t('agents.catalog.subtitle')}</p>
</div>
<button type="button" className="icon-button quiet" onClick={requestClose} disabled={saving} aria-label={t('common.close')}>
<X size={18} />
</button>
</header>
<div className="codex-catalog-body">
<aside className="codex-catalog-sidebar">
<div className="codex-catalog-search">
<Search size={15} aria-hidden />
<input value={search} onChange={(event) => setSearch(event.currentTarget.value)} placeholder={t('agents.catalog.search')} />
</div>
<div className="codex-catalog-models" role="listbox" aria-label={t('agents.catalog.title')}>
{loading ? (
<div className="codex-catalog-state"><LoaderCircle size={18} className="spin" />{t('agents.catalog.loading')}</div>
) : filteredModels.length ? filteredModels.map((model) => {
const customized = !sameCodexModelConfiguration(model.configuration, model.defaults);
return (
<button
type="button"
role="option"
aria-selected={model.slug === selectedSlug}
className={model.slug === selectedSlug ? 'active' : ''}
key={model.slug}
onClick={() => setSelectedSlug(model.slug)}
>
<strong>{model.configuration.display_name}</strong>
<span>{model.slug}</span>
<small>
{model.hasOfficialTemplate ? t('agents.catalog.official') : t('agents.catalog.fallback')}
{customized ? ` · ${t('agents.catalog.customized')}` : ''}
</small>
</button>
);
}) : <div className="codex-catalog-state">{t('agents.catalog.empty')}</div>}
</div>
<button type="button" className="secondary-button codex-catalog-reload" onClick={() => void load()} disabled={loading || saving || dirty}>
<RefreshCw size={15} className={loading ? 'spin' : ''} />
{t('agents.catalog.reload')}
</button>
</aside>
<main className="codex-catalog-editor">
{activeModel ? (
<>
<div className="codex-catalog-model-heading">
<div>
<h3>{activeModel.slug}</h3>
<span className={activeCustomized ? 'customized' : ''}>
{activeModel.hasOfficialTemplate ? t('agents.catalog.official') : t('agents.catalog.fallback')}
{activeCustomized ? ` · ${t('agents.catalog.customized')}` : ''}
</span>
</div>
<button type="button" className="secondary-button compact-button" onClick={restoreModel} disabled={!activeCustomized || saving}>
<RotateCcw size={15} />{t('agents.catalog.resetModel')}
</button>
</div>
<div className="codex-catalog-form">
<label><span>{t('agents.catalog.displayName')}</span><input value={activeModel.configuration.display_name} onChange={(event) => updateField('display_name', event.currentTarget.value)} /></label>
<label className="wide"><span>{t('agents.catalog.description')}</span><textarea value={activeModel.configuration.description ?? ''} onChange={(event) => updateField('description', event.currentTarget.value || null)} /></label>
<label><span>{t('agents.catalog.context')}</span><input type="number" min="1" value={Number.isFinite(activeModel.configuration.context_window) ? activeModel.configuration.context_window : ''} onChange={(event) => updateField('context_window', event.currentTarget.value ? Number(event.currentTarget.value) : Number.NaN)} /></label>
<label><span>{t('agents.catalog.maximum')}</span><input type="number" min="1" value={Number.isFinite(activeModel.configuration.max_context_window) ? activeModel.configuration.max_context_window : ''} onChange={(event) => updateField('max_context_window', event.currentTarget.value ? Number(event.currentTarget.value) : Number.NaN)} /></label>
<label><span>{t('agents.catalog.percent')}</span><input type="number" min="1" max="100" value={Number.isFinite(activeModel.configuration.effective_context_window_percent) ? activeModel.configuration.effective_context_window_percent : ''} onChange={(event) => updateField('effective_context_window_percent', event.currentTarget.value ? Number(event.currentTarget.value) : Number.NaN)} /></label>
<label><span>{t('agents.catalog.compact')}</span><input type="number" min="1" placeholder={t('agents.catalog.automatic')} value={activeModel.configuration.auto_compact_token_limit ?? ''} onChange={(event) => updateField('auto_compact_token_limit', event.currentTarget.value ? Number(event.currentTarget.value) : null)} /></label>
<fieldset className="wide codex-catalog-choice-group">
<legend>{t('agents.catalog.reasoning')}</legend>
<div className="codex-catalog-chips">
{codexReasoningEfforts.map((effort) => {
const checked = activeModel.configuration.supported_reasoning_levels.some((level) => level.effort === effort);
return <label key={effort} className={checked ? 'selected' : ''}><input type="checkbox" checked={checked} onChange={(event) => toggleReasoning(effort, event.currentTarget.checked, activeModel.defaults.supported_reasoning_levels)} /><span>{checked ? <Check size={13} /> : null}{effort}</span></label>;
})}
</div>
</fieldset>
<label><span>{t('agents.catalog.defaultReasoning')}</span><select value={activeModel.configuration.default_reasoning_level ?? ''} onChange={(event) => updateField('default_reasoning_level', (event.currentTarget.value || null) as CodexReasoningEffort | null)}><option value="">{t('agents.catalog.clientDefault')}</option>{activeModel.configuration.supported_reasoning_levels.map((level) => <option key={level.effort} value={level.effort}>{level.effort}</option>)}</select></label>
<fieldset className="codex-catalog-choice-group">
<legend>{t('agents.catalog.modalities')}</legend>
<div className="codex-catalog-checkboxes">
{(['text', 'image'] as const).map((modality) => <label key={modality}><input type="checkbox" checked={activeModel.configuration.input_modalities.includes(modality)} onChange={(event) => toggleModality(modality, event.currentTarget.checked)} />{t(`agents.catalog.${modality}`)}</label>)}
</div>
</fieldset>
<label className="codex-catalog-switch"><input type="checkbox" checked={activeModel.configuration.visibility === 'list'} onChange={(event) => updateField('visibility', event.currentTarget.checked ? 'list' : 'hide')} /><span>{t('agents.catalog.visible')}</span></label>
<label className="codex-catalog-switch"><input type="checkbox" checked={activeModel.configuration.supports_parallel_tool_calls} onChange={(event) => updateField('supports_parallel_tool_calls', event.currentTarget.checked)} /><span>{t('agents.catalog.parallel')}</span></label>
</div>
<p className="codex-catalog-hint">{t('agents.catalog.capabilityHint')}</p>
</>
) : !loading ? <div className="codex-catalog-state">{t('agents.catalog.empty')}</div> : null}
</main>
</div>
<footer className="codex-catalog-footer">
<div>
{error ? <span className="agent-inline-message error" role="alert">{error}</span> : null}
{!error && notice ? <span className="agent-inline-message" role="status">{notice}</span> : null}
{!error && !notice ? <span>{dirty ? t('agents.catalog.unsaved') : t('agents.catalog.saveHint')}</span> : null}
</div>
<div>
<button type="button" className="secondary-button" onClick={restoreAll} disabled={!models.some((model) => !sameCodexModelConfiguration(model.configuration, model.defaults)) || saving}>
<RotateCcw size={15} />{t('agents.catalog.resetAll')}
</button>
<button type="button" className="secondary-button" onClick={requestClose} disabled={saving}>{t('common.cancel')}</button>
<button type="button" className="primary-button" onClick={() => void save()} disabled={!snapshot || !dirty || saving}>
{saving ? <LoaderCircle size={16} className="spin" /> : null}{saving ? t('common.saving') : t('common.save')}
</button>
</div>
</footer>
{discardConfirmOpen ? (
<div className="codex-catalog-confirm">
<div role="alertdialog" aria-modal="true">
<strong>{t('agents.catalog.unsaved')}</strong>
<span>{t('agents.catalog.discardHint')}</span>
<div>
<button type="button" className="secondary-button" onClick={() => setDiscardConfirmOpen(false)}>{t('agents.catalog.keepEditing')}</button>
<button type="button" className="danger-button" onClick={onClose}>{t('agents.catalog.discard')}</button>
</div>
</div>
</div>
) : null}
</section>
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
import type { MessageKey } from '../i18n/resources';
export const codexReasoningEfforts = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'] as const;
export type CodexReasoningEffort = typeof codexReasoningEfforts[number];
export type CodexReasoningLevel = {
effort: CodexReasoningEffort;
description: string;
};
export type CodexModelConfiguration = {
display_name: string;
description: string | null;
context_window: number;
max_context_window: number;
effective_context_window_percent: number;
auto_compact_token_limit: number | null;
default_reasoning_level: CodexReasoningEffort | null;
supported_reasoning_levels: CodexReasoningLevel[];
input_modalities: Array<'text' | 'image'>;
visibility: 'list' | 'hide' | 'none';
supports_parallel_tool_calls: boolean;
};
export type CodexCatalogEditorModel = {
slug: string;
hasOfficialTemplate: boolean;
customized: boolean;
configuration: CodexModelConfiguration;
defaults: CodexModelConfiguration;
};
export type CodexCatalogEditorSnapshot = {
revision: string;
models: CodexCatalogEditorModel[];
};
export type CodexCatalogEditorSaveResult = {
snapshot: CodexCatalogEditorSnapshot;
synchronizationError: string | null;
};
export function cloneCodexModelConfiguration(configuration: CodexModelConfiguration): CodexModelConfiguration {
return {
...configuration,
supported_reasoning_levels: configuration.supported_reasoning_levels.map((level) => ({ ...level })),
input_modalities: [...configuration.input_modalities],
};
}
export function sameCodexModelConfiguration(left: CodexModelConfiguration, right: CodexModelConfiguration): boolean {
return left.display_name === right.display_name
&& left.description === right.description
&& Object.is(left.context_window, right.context_window)
&& Object.is(left.max_context_window, right.max_context_window)
&& Object.is(left.effective_context_window_percent, right.effective_context_window_percent)
&& Object.is(left.auto_compact_token_limit, right.auto_compact_token_limit)
&& left.default_reasoning_level === right.default_reasoning_level
&& left.visibility === right.visibility
&& left.supports_parallel_tool_calls === right.supports_parallel_tool_calls
&& left.input_modalities.join(',') === right.input_modalities.join(',')
&& left.supported_reasoning_levels.length === right.supported_reasoning_levels.length
&& left.supported_reasoning_levels.every((level, index) => level.effort === right.supported_reasoning_levels[index].effort
&& level.description === right.supported_reasoning_levels[index].description);
}
export function toggleCodexReasoningLevel(
configuration: CodexModelConfiguration,
effort: CodexReasoningEffort,
enabled: boolean,
defaults: CodexReasoningLevel[],
): CodexModelConfiguration {
const levels = new Map(configuration.supported_reasoning_levels.map((level) => [level.effort, level]));
if (enabled) {
levels.set(effort, levels.get(effort) ?? defaults.find((level) => level.effort === effort)
?? { effort, description: `${effort} reasoning effort` });
} else {
levels.delete(effort);
}
const supported = codexReasoningEfforts.flatMap((level) => levels.has(level) ? [{ ...levels.get(level)! }] : []);
const currentDefault = configuration.default_reasoning_level;
const nextDefault = currentDefault && !levels.has(currentDefault)
? levels.has('medium') ? 'medium' : supported[0]?.effort ?? null
: currentDefault;
return { ...configuration, supported_reasoning_levels: supported, default_reasoning_level: nextDefault };
}
export function validateCodexModelConfiguration(configuration: CodexModelConfiguration): MessageKey | null {
if (!configuration.display_name.trim() || configuration.display_name.length > 4_000
|| (configuration.description?.length ?? 0) > 4_000) return 'agents.catalog.invalidText';
if (![configuration.context_window, configuration.max_context_window].every((value) => Number.isSafeInteger(value) && value > 0)) {
return 'agents.catalog.invalidContext';
}
if (configuration.max_context_window < configuration.context_window) return 'agents.catalog.invalidMaximum';
const percent = configuration.effective_context_window_percent;
if (!Number.isInteger(percent) || percent < 1 || percent > 100) return 'agents.catalog.invalidPercent';
const compact = configuration.auto_compact_token_limit;
if (compact !== null && (!Number.isSafeInteger(compact) || compact < 1 || compact > configuration.context_window)) {
return 'agents.catalog.invalidCompact';
}
const levels = configuration.supported_reasoning_levels.map((level) => level.effort);
if (new Set(levels).size !== levels.length || levels.some((level) => !codexReasoningEfforts.includes(level))
|| (configuration.default_reasoning_level !== null && !levels.includes(configuration.default_reasoning_level))) {
return 'agents.catalog.invalidReasoning';
}
if (!configuration.input_modalities.length) return 'agents.catalog.invalidModalities';
return null;
}
+425
View File
@@ -6647,6 +6647,431 @@ textarea:focus-visible {
padding: 10px 12px;
}
.agent-codex-catalog-button {
width: 100%;
min-height: 54px;
height: auto;
padding: 10px 12px;
white-space: normal;
}
.codex-catalog-backdrop {
z-index: 90;
}
.codex-catalog-dialog {
position: relative;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: 14px;
width: min(1080px, calc(100vw - 40px));
height: min(780px, calc(100vh - 40px));
padding: 20px;
}
.codex-catalog-heading {
align-items: flex-start;
}
.codex-catalog-heading > div {
display: grid;
gap: 4px;
}
.codex-catalog-heading p {
margin: 0;
color: var(--theme-716b61);
font-size: var(--font-size-label);
font-weight: 500;
}
.codex-catalog-body {
display: grid;
grid-template-columns: 250px minmax(0, 1fr);
min-height: 0;
overflow: hidden;
border: 1px solid var(--theme-d5d2cb);
border-radius: 9px;
background: var(--theme-ffffff);
}
.codex-catalog-sidebar {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: 10px;
min-height: 0;
padding: 12px;
border-right: 1px solid var(--theme-d5d2cb);
background: var(--theme-faf8f4);
}
.codex-catalog-search {
display: flex;
align-items: center;
gap: 7px;
height: 38px;
padding: 0 10px;
border: 1px solid var(--theme-d5d2cb);
border-radius: 7px;
background: var(--theme-ffffff);
color: var(--theme-716b61);
}
.codex-catalog-search input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--theme-2d2a26);
}
.codex-catalog-models {
display: grid;
align-content: start;
gap: 6px;
min-height: 0;
overflow: auto;
}
.codex-catalog-models > button {
display: grid;
gap: 2px;
width: 100%;
padding: 9px 10px;
border: 1px solid transparent;
border-radius: 7px;
background: transparent;
color: var(--theme-2d2a26);
text-align: left;
}
.codex-catalog-models > button:hover {
background: var(--theme-ffffff);
}
.codex-catalog-models > button.active {
border-color: var(--theme-9db8a5);
background: var(--theme-f3f8f4);
}
.codex-catalog-models strong,
.codex-catalog-models span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.codex-catalog-models span,
.codex-catalog-models small {
color: var(--theme-716b61);
font-size: var(--font-size-meta);
}
.codex-catalog-models small {
color: var(--theme-2f6b3f);
}
.codex-catalog-reload {
width: 100%;
}
.codex-catalog-editor {
min-width: 0;
min-height: 0;
overflow: auto;
padding: 18px;
}
.codex-catalog-model-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.codex-catalog-model-heading > div {
display: grid;
gap: 3px;
min-width: 0;
}
.codex-catalog-model-heading h3 {
margin: 0;
overflow-wrap: anywhere;
color: var(--theme-2d2a26);
font-size: var(--font-size-section-title);
}
.codex-catalog-model-heading span {
color: var(--theme-716b61);
font-size: var(--font-size-meta);
}
.codex-catalog-model-heading span.customized {
color: var(--theme-2f6b3f);
}
.codex-catalog-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.codex-catalog-form > label {
display: grid;
align-content: start;
gap: 7px;
color: var(--theme-5f5a53);
font-size: var(--font-size-label);
font-weight: 650;
}
.codex-catalog-form > label.wide,
.codex-catalog-form > fieldset.wide {
grid-column: 1 / -1;
}
.codex-catalog-form input[type='text'],
.codex-catalog-form input[type='number'],
.codex-catalog-form input:not([type]),
.codex-catalog-form select,
.codex-catalog-form textarea {
width: 100%;
min-width: 0;
padding: 9px 10px;
border: 1px solid var(--theme-d5d2cb);
border-radius: 7px;
outline: 0;
background: var(--theme-ffffff);
color: var(--theme-2d2a26);
font: inherit;
font-weight: 500;
}
.codex-catalog-form input[type='text'],
.codex-catalog-form input[type='number'],
.codex-catalog-form input:not([type]),
.codex-catalog-form select {
height: 40px;
}
.codex-catalog-form textarea {
min-height: 70px;
resize: vertical;
}
.codex-catalog-form input:focus,
.codex-catalog-form select:focus,
.codex-catalog-form textarea:focus {
border-color: var(--theme-8d8377);
box-shadow: 0 0 0 3px rgba(45, 42, 38, 0.08);
}
.codex-catalog-choice-group {
min-width: 0;
margin: 0;
padding: 11px;
border: 1px solid var(--theme-d5d2cb);
border-radius: 8px;
}
.codex-catalog-choice-group legend {
padding: 0 5px;
color: var(--theme-5f5a53);
font-size: var(--font-size-label);
font-weight: 650;
}
.codex-catalog-chips,
.codex-catalog-checkboxes {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.codex-catalog-chips label,
.codex-catalog-checkboxes label {
cursor: pointer;
user-select: none;
}
.codex-catalog-chips input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.codex-catalog-chips span {
display: inline-flex;
align-items: center;
gap: 4px;
min-height: 30px;
padding: 5px 9px;
border: 1px solid var(--theme-d5d2cb);
border-radius: 999px;
background: var(--theme-faf8f4);
color: var(--theme-5f5a53);
font-size: var(--font-size-meta);
font-weight: 650;
}
.codex-catalog-chips label.selected span {
border-color: var(--theme-9db8a5);
background: var(--theme-f3f8f4);
color: var(--theme-2f6b3f);
}
.codex-catalog-checkboxes label {
display: flex;
align-items: center;
gap: 6px;
color: var(--theme-5f5a53);
font-size: var(--font-size-label);
}
.codex-catalog-switch {
grid-template-columns: auto minmax(0, 1fr) !important;
align-items: center;
min-height: 42px;
padding: 9px 11px;
border: 1px solid var(--theme-d5d2cb);
border-radius: 8px;
background: var(--theme-faf8f4);
}
.codex-catalog-switch input {
width: 16px !important;
height: 16px !important;
}
.codex-catalog-hint {
margin: 14px 0 0;
color: var(--theme-716b61);
font-size: var(--font-size-meta);
line-height: var(--line-height-relaxed);
}
.codex-catalog-state {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 90px;
padding: 14px;
color: var(--theme-716b61);
font-size: var(--font-size-label);
text-align: center;
}
.codex-catalog-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.codex-catalog-footer > div:first-child {
min-width: 0;
color: var(--theme-716b61);
font-size: var(--font-size-meta);
}
.codex-catalog-footer > div:last-child {
display: flex;
flex: none;
gap: 8px;
}
.codex-catalog-confirm {
position: absolute;
inset: 0;
z-index: 2;
display: grid;
place-items: center;
padding: 20px;
border-radius: inherit;
background: rgba(45, 42, 38, 0.35);
backdrop-filter: blur(2px);
}
.codex-catalog-confirm > div {
display: grid;
gap: 12px;
width: min(420px, 100%);
padding: 20px;
border: 1px solid var(--theme-d5d2cb);
border-radius: 9px;
background: var(--theme-fffdf8);
box-shadow: 0 18px 48px rgba(45, 42, 38, 0.24);
}
.codex-catalog-confirm span {
color: var(--theme-716b61);
font-size: var(--font-size-label);
}
.codex-catalog-confirm > div > div {
display: flex;
justify-content: flex-end;
gap: 8px;
}
@media (max-width: 760px) {
.codex-catalog-dialog {
width: calc(100vw - 28px);
height: calc(100vh - 28px);
padding: 14px;
}
.codex-catalog-body {
grid-template-columns: 1fr;
grid-template-rows: 190px minmax(0, 1fr);
}
.codex-catalog-sidebar {
grid-template-columns: minmax(0, 1fr) auto;
grid-template-rows: auto minmax(0, 1fr);
border-right: 0;
border-bottom: 1px solid var(--theme-d5d2cb);
}
.codex-catalog-models {
grid-column: 1 / -1;
}
.codex-catalog-reload {
width: auto;
}
.codex-catalog-editor {
padding: 14px;
}
.codex-catalog-form {
grid-template-columns: 1fr;
}
.codex-catalog-form > label.wide,
.codex-catalog-form > fieldset.wide {
grid-column: auto;
}
.codex-catalog-footer {
align-items: stretch;
flex-direction: column;
}
.codex-catalog-footer > div:last-child {
display: grid;
grid-template-columns: 1fr 1fr;
}
.codex-catalog-footer > div:last-child > button:first-child {
grid-column: 1 / -1;
}
}
.agent-codex-options .agent-section-heading strong,
.agent-codex-options > .agent-oauth-configuration {
font-size: var(--font-size-meta);