From 5cde9425bc73e1164d65e1df262224141b1c06a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A5=8B=E6=96=97=E7=9A=84=E5=B0=8F=E9=AB=98?= Date: Mon, 17 Aug 2026 00:20:11 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/codex_config.rs | 71 ++++- .../codex/CodexRouterWorkspacePage.test.ts | 55 +++- .../codex/CodexRouterWorkspacePage.tsx | 286 +++++++++++++++++- src/lib/codexMultiRouterSync.ts | 7 +- src/types.ts | 2 + 5 files changed, 415 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index 5556af35..020869cc 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -1291,6 +1291,7 @@ struct CodexCatalogModelSpec { input_modalities: Option>, base_instructions: Option, reasoning: Option, + sort_index: Option, } /// 为 Codex 多 Agent 工具的模型说明生成稳定排序键。 @@ -1361,16 +1362,23 @@ fn sort_codex_catalog_specs_for_picker( ) -> Vec { let mut indexed_specs = specs.into_iter().enumerate().collect::>(); indexed_specs.sort_by_key(|(original_index, spec)| { + // 优先级1: 用户设置的 sortIndex(数字越小越靠前) + if let Some(sort_idx) = spec.sort_index { + return (0_u8, sort_idx as usize, *original_index); + } + + // 优先级2: spawn_agent_model_priority 列表 if let Some(priority_index) = codex_spawn_agent_model_priority_index(spawn_agent_model_priority, &spec.model) { - return (0_u8, priority_index, *original_index); + return (1_u8, priority_index, *original_index); } + // 优先级3: 默认供应商排序逻辑 let (provider_rank, fallback_index) = codex_catalog_model_priority_key(spec, *original_index); ( - provider_rank.saturating_add(1), + provider_rank.saturating_add(2), fallback_index, *original_index, ) @@ -1577,6 +1585,12 @@ fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec Vec>(); + + assert_eq!( + ordered, + vec!["deepseek-v4-pro", "qwen3.6", "gpt-5.5", "gpt-5.4"], + "sortIndex must control the complete picker order and leave unranked models available" + ); + } + #[test] /// 未声明 reasoning 的第三方模型不能继承 GPT 档位,但仍必须保留 /// Codex `ModelInfo` 反序列化所需的空 reasoning 数组。缺失该字段会让 @@ -10740,6 +10794,7 @@ openai_base_url = "http://127.0.0.1:15721/v1" input_modalities: None, base_instructions: None, reasoning: None, + sort_index: None, }; let entry = codex_catalog_model_entry( &template, @@ -11125,6 +11180,7 @@ openai_base_url = "http://127.0.0.1:15721/v1" input_modalities: None, base_instructions: None, reasoning: None, + sort_index: None, }]; let config = r#"model_provider = "codex_model_router_v2" @@ -11172,6 +11228,7 @@ base_url = "http://127.0.0.1:15721/v1" input_modalities: None, base_instructions: None, reasoning: None, + sort_index: None, }]; let catalog = json!({ "models": [{ @@ -11255,6 +11312,7 @@ base_url = "http://127.0.0.1:15721/v1" source: Some("builtin".into()), }, ), + sort_index: None, }]; let catalog = codex_model_catalog_from_specs( &specs, @@ -11328,6 +11386,7 @@ base_url = "http://127.0.0.1:15721/v1" input_modalities: None, base_instructions: None, reasoning: None, + sort_index: None, }]; let config = r#"model_provider = "codex_model_router_v2" @@ -11763,6 +11822,7 @@ model_context_window = 262144 input_modalities: None, base_instructions: None, reasoning: None, + sort_index: None, }]; sync_codex_managed_agent_files(&specs, CodexSubagentVersion::V2) @@ -11870,6 +11930,7 @@ model_provider = "custom" input_modalities: None, base_instructions: None, reasoning: None, + sort_index: None, }]; sync_codex_managed_agent_files(&specs, CodexSubagentVersion::V2) @@ -11926,6 +11987,7 @@ model_provider = "custom" input_modalities: None, base_instructions: None, reasoning: None, + sort_index: None, }, ) .collect::>(); @@ -11985,6 +12047,7 @@ model_provider = "custom" input_modalities: None, base_instructions: None, reasoning: None, + sort_index: None, }, ) .collect::>(); @@ -12109,6 +12172,7 @@ model = "handwritten" input_modalities: None, base_instructions: None, reasoning: None, + sort_index: None, }]; sync_codex_managed_agent_files(&specs, CodexSubagentVersion::V2) @@ -12254,6 +12318,7 @@ model_provider = "codex_model_router_v2" input_modalities: None, base_instructions: None, reasoning: None, + sort_index: None, }; let entry = codex_catalog_model_entry( &template, @@ -12308,6 +12373,7 @@ model_provider = "codex_model_router_v2" input_modalities: None, base_instructions: None, reasoning: None, + sort_index: None, }; let entry = codex_catalog_model_entry( &template, @@ -12491,6 +12557,7 @@ max_depth = 2 input_modalities: None, base_instructions: None, reasoning: None, + sort_index: None, }]; let catalog_path = get_codex_model_catalog_path(); diff --git a/src/components/codex/CodexRouterWorkspacePage.test.ts b/src/components/codex/CodexRouterWorkspacePage.test.ts index a2cd3fcd..d02ad5d0 100644 --- a/src/components/codex/CodexRouterWorkspacePage.test.ts +++ b/src/components/codex/CodexRouterWorkspacePage.test.ts @@ -1763,7 +1763,15 @@ describe("Codex MultiRouter workspace route persistence helpers", () => { within(screen.getByRole("tablist")) .getAllByRole("tab") .map((tab) => tab.textContent?.trim()), - ).toEqual(["总览", "模型源", "路由规则", "子 Agent", "状态", "测试发布"]); + ).toEqual([ + "总览", + "模型源", + "路由规则", + "模型排序", + "子 Agent", + "状态", + "测试发布", + ]); expect(screen.queryByText("Sub-Agent 设置")).not.toBeInTheDocument(); await userEvent @@ -2723,6 +2731,51 @@ describe("Codex MultiRouter workspace route persistence helpers", () => { expect(rebuilt.models.map((model) => model.model)).toEqual(["qwen3.6"]); }); + it("preserves the full picker order when routes rebuild the model catalog", () => { + const source: Provider = { + id: "sorted-source", + name: "Sorted Source", + category: "custom", + settingsConfig: { + modelCatalog: { + models: [{ model: "model-a" }, { model: "model-b" }], + }, + }, + }; + const plan: Provider = { + ...createDraftRoutingPlan([source], [source]), + settingsConfig: { + modelCatalog: { + models: [ + { model: "model-a", sortIndex: 1 }, + { model: "model-b", sortIndex: 0 }, + ], + spawnAgentModels: ["model-a"], + }, + }, + }; + + const rebuilt = buildModelCatalogForRoutes( + plan, + [ + { + id: "sorted-route", + enabled: true, + targetProviderId: source.id, + match: { models: ["model-a", "model-b"], prefixes: [] }, + upstream: { apiFormat: "openai_responses" }, + }, + ], + new Map([[source.id, source]]), + ); + + expect(rebuilt.models).toEqual([ + { model: "model-a", sortIndex: 1 }, + { model: "model-b", sortIndex: 0 }, + ]); + expect(rebuilt.spawnAgentModels).toEqual(["model-a", "model-b"]); + }); + it("keeps unsaved route picker enabled draft state across candidate refreshes", () => { const currentEnabledIds = new Set(["openai-route"]); diff --git a/src/components/codex/CodexRouterWorkspacePage.tsx b/src/components/codex/CodexRouterWorkspacePage.tsx index 12a50994..64a93a1b 100644 --- a/src/components/codex/CodexRouterWorkspacePage.tsx +++ b/src/components/codex/CodexRouterWorkspacePage.tsx @@ -140,6 +140,7 @@ export type WorkspaceTab = | "overview" | "sources" | "routes" + | "model-order" | "subagents" | "status" | "test"; @@ -462,6 +463,7 @@ type CodexCatalogModelDraft = { supportsImage?: boolean; supports_image?: boolean; vision?: boolean; + sortIndex?: number; capabilities?: CodexRouteCapabilities; }; @@ -716,6 +718,7 @@ function providerWithFetchedModelCatalog( ? { supports_image: model.supports_image } : {}), ...(model.vision !== undefined ? { vision: model.vision } : {}), + ...(model.sortIndex !== undefined ? { sortIndex: model.sortIndex } : {}), // 模型目录刷新必须保留已有 reasoning 声明(用户手动声明的档位/能力)。 // 否则 /models 拉取重建会把声明清空,导致档位消失(K3/Qwen 均受影响)。 ...(model.reasoning ? { reasoning: model.reasoning } : {}), @@ -1307,6 +1310,7 @@ function catalogDraftFromSourceModel( ? { supports_image: source.supports_image } : {}), ...(source?.vision !== undefined ? { vision: source.vision } : {}), + ...(source?.sortIndex !== undefined ? { sortIndex: source.sortIndex } : {}), ...(capabilities ? { capabilities } : {}), }; } @@ -1786,8 +1790,12 @@ export function buildModelCatalogForRoutes( modelIds.filter((model) => !existingSpawnAgentModels.includes(model)), ) .slice(0, 5); + const orderedModels = Array.from(byModel.entries()).map(([id, model]) => { + const sortIndex = existingModelById.get(id)?.sortIndex; + return sortIndex === undefined ? model : { ...model, sortIndex }; + }); return { - models: Array.from(byModel.values()), + models: orderedModels, spawnAgentModels, }; } @@ -3072,7 +3080,7 @@ export function CodexRouterWorkspacePage({ onValueChange={(value) => setActiveTab(value as WorkspaceTab)} >
- + + + + + + void; +}) { + const queryClient = useQueryClient(); + const [draftModels, setDraftModels] = useState([]); + const [isSaving, setIsSaving] = useState(false); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + const catalog = readCodexModelCatalog(selectedPlan); + const catalogKey = catalog.models + .map((model) => `${model.model ?? ""}:${model.sortIndex ?? ""}`) + .join("\n"); + const hasCustomOrder = catalog.models.some( + (model) => model.sortIndex !== undefined, + ); + const hasChanges = + draftModels.map((model) => model.model).join("\n") !== + catalog.models + .slice() + .sort( + (left, right) => + (left.sortIndex ?? Number.MAX_SAFE_INTEGER) - + (right.sortIndex ?? Number.MAX_SAFE_INTEGER), + ) + .map((model) => model.model) + .join("\n"); + + useEffect(() => { + setDraftModels( + catalog.models + .slice() + .sort( + (left, right) => + (left.sortIndex ?? Number.MAX_SAFE_INTEGER) - + (right.sortIndex ?? Number.MAX_SAFE_INTEGER), + ), + ); + setMessage(null); + setError(null); + }, [selectedPlan?.id, catalogKey]); + + function handleDragEnd(event: DragEndEvent) { + const activeModel = String(event.active.id); + const overModel = event.over ? String(event.over.id) : ""; + if (!overModel || activeModel === overModel) return; + setDraftModels((current) => { + const activeIndex = current.findIndex( + (model) => model.model?.trim() === activeModel, + ); + const overIndex = current.findIndex( + (model) => model.model?.trim() === overModel, + ); + if (activeIndex < 0 || overIndex < 0) return current; + const next = [...current]; + const [moved] = next.splice(activeIndex, 1); + next.splice(overIndex, 0, moved); + return next; + }); + setMessage(null); + setError(null); + } + + async function saveOrder(reset = false) { + if (!selectedPlan) return; + setIsSaving(true); + setMessage(null); + setError(null); + try { + const currentModelCatalog = + selectedPlan.settingsConfig?.modelCatalog && + typeof selectedPlan.settingsConfig.modelCatalog === "object" + ? selectedPlan.settingsConfig.modelCatalog + : {}; + const models = (reset ? catalog.models : draftModels).map( + (model, index) => { + const rest = { ...model }; + delete rest.sortIndex; + return reset ? rest : { ...rest, sortIndex: index }; + }, + ); + const nextProvider: Provider = { + ...selectedPlan, + settingsConfig: { + ...selectedPlan.settingsConfig, + modelCatalog: { + ...currentModelCatalog, + models, + }, + }, + }; + await providersApi.update(nextProvider, "codex"); + setDraftModels(models); + setMessage( + reset + ? "已恢复默认模型顺序;重启 Codex Desktop 后生效。" + : `已保存 ${models.length} 个模型的展示顺序;重启 Codex Desktop 后生效。`, + ); + await queryClient.invalidateQueries({ queryKey: ["providers", "codex"] }); + } catch (saveError) { + setError(`保存模型顺序失败:${workspaceErrorMessage(saveError)}`); + } finally { + setIsSaving(false); + } + } + + if (!selectedPlan) { + return ( + + ); + } + + if (catalog.models.length === 0) { + return ( + + ); + } + + return ( +
+ + + +
+ } + /> + + + model.model?.trim()) + .filter((model): model is string => Boolean(model))} + strategy={verticalListSortingStrategy} + > +
+ {draftModels.map((model, index) => ( + + ))} +
+
+
+ + {message ? ( +

+ {message} +

+ ) : null} + {error ? ( +

{error}

+ ) : null} + + ); +} + /// 路由规则页提供方案选择、规则列表和右侧详情,形成真实的“查/改/删入口”工作流。 function RoutesTab({ routingPlans, @@ -7363,6 +7587,64 @@ function SortableSpawnAgentCandidate({ ); } +function SortableCatalogModel({ + model, + index, +}: { + model: CodexCatalogModel; + index: number; +}) { + const modelId = model.model?.trim() ?? ""; + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: modelId }); + + return ( +
+ + + {index + 1} + +
+
+ {catalogModelLabel(model)} +
+ {model.upstreamModel || model.upstream_model ? ( +
+ {model.upstreamModel ?? model.upstream_model} +
+ ) : null} +
+
+ ); +} + /// 路由方案卡片内容;外层决定是按钮还是静态容器。 function PlanCardContent({ provider, diff --git a/src/lib/codexMultiRouterSync.ts b/src/lib/codexMultiRouterSync.ts index 1385dce8..2d0bf148 100644 --- a/src/lib/codexMultiRouterSync.ts +++ b/src/lib/codexMultiRouterSync.ts @@ -335,7 +335,12 @@ function rebuildPlanModelCatalog( } } - const models = Array.from(byModel.values()); + // 路由同步会重建模型元数据,但全量菜单排序属于 MultiRouter 自身的用户偏好, + // 不能因模型源刷新或规则保存而被覆盖。 + const models = Array.from(byModel.entries()).map(([id, model]) => { + const sortIndex = planCatalogByModel.get(id)?.sortIndex; + return sortIndex === undefined ? model : { ...model, sortIndex }; + }); const existingSpawnAgentModels = Array.isArray( plan.settingsConfig?.modelCatalog?.spawnAgentModels, ) diff --git a/src/types.ts b/src/types.ts index a9de7543..c038aa2e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -334,6 +334,8 @@ export interface CodexCatalogModel { baseInstructions?: string; base_instructions?: string; reasoning?: CodexModelReasoningCapability; + // User-defined picker order. Lower values appear first in Codex. + sortIndex?: number; } export type CodexCacheMode =