From caba5f51ec383bfa22b4337b749a9c8a03c964f7 Mon Sep 17 00:00:00 2001 From: Dex Miller Date: Thu, 12 Feb 2026 21:30:59 +0800 Subject: [PATCH] feat(omo): improve agent model selection UX and fix lowercase keys (#1004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(omo): use lowercase keys for builtin agent definitions OMO config schema expects all agent keys to be lowercase. Updated OMO_BUILTIN_AGENTS keys (Sisyphus → sisyphus, Hephaestus → hephaestus, etc.) and aligned Rust test fixtures accordingly. * feat(omo): add i18n support and tooltips for agent/category descriptions * feat(omo): add preset model variants for thinking level support Add OPENCODE_PRESET_MODEL_VARIANTS constant with variant definitions for Google, OpenAI, and Anthropic models. The omoModelVariantsMap builder now falls back to presets when config-defined variants are absent, enabling the variant selector for supported models. * feat(omo): replace model select with searchable combobox and improve fallback handling * feat(omo): enrich preset model defaults and metadata fallback * fix(omo): preserve custom fields and align otherFields import/validation * fix: resolve omo clippy warnings and include app update --- package.json | 1 + pnpm-lock.yaml | 39 ++ src-tauri/src/commands/misc.rs | 2 +- src-tauri/src/services/omo.rs | 86 +++- src/App.tsx | 6 +- .../providers/forms/OmoFormFields.tsx | 356 ++++++++++++---- .../providers/forms/ProviderForm.tsx | 284 +++++++++---- src/components/ui/command.tsx | 103 +++++ src/components/ui/popover.tsx | 28 ++ src/config/opencodeProviderPresets.ts | 383 +++++++++++++++++- src/i18n/locales/en.json | 63 ++- src/i18n/locales/ja.json | 63 ++- src/i18n/locales/zh.json | 65 ++- src/types/omo.ts | 112 ++--- ...mFields.mergeCustomModelsIntoStore.test.ts | 111 +++++ tests/utils/omoConfig.test.ts | 50 +++ 16 files changed, 1511 insertions(+), 241 deletions(-) create mode 100644 src/components/ui/command.tsx create mode 100644 src/components/ui/popover.tsx create mode 100644 tests/components/OmoFormFields.mergeCustomModelsIntoStore.test.ts create mode 100644 tests/utils/omoConfig.test.ts diff --git a/package.json b/package.json index 1bf48564..14744119 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65db8493..d82cfdc3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: '@radix-ui/react-label': specifier: ^2.1.7 version: 2.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-popover': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-scroll-area': specifier: ^1.2.10 version: 1.2.10(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1048,6 +1051,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-popover@1.1.15': + resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-popper@1.2.8': resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} peerDependencies: @@ -3890,6 +3906,29 @@ snapshots: '@types/react': 18.3.23 '@types/react-dom': 18.3.7(@types/react@18.3.23) + '@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.1(@types/react@18.3.23)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + '@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@floating-ui/react-dom': 2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index db229776..e7c21471 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -323,7 +323,7 @@ fn scan_cli_version(tool: &str) -> (Option, Option) { let mut search_paths: Vec = vec![ home.join(".local/bin"), // Native install (official recommended) home.join(".npm-global/bin"), - home.join("n/bin"), // n version manager + home.join("n/bin"), // n version manager home.join(".volta/bin"), // Volta package manager ]; diff --git a/src-tauri/src/services/omo.rs b/src-tauri/src/services/omo.rs index 45ed9479..2e9cf490 100644 --- a/src-tauri/src/services/omo.rs +++ b/src-tauri/src/services/omo.rs @@ -286,10 +286,22 @@ impl OmoService { let obj = Self::read_jsonc_object(&actual_path)?; + Ok(Self::build_local_file_data_from_obj( + &obj, + actual_path.to_string_lossy().to_string(), + last_modified, + )) + } + + fn build_local_file_data_from_obj( + obj: &Map, + file_path: String, + last_modified: Option, + ) -> OmoLocalFileData { let agents = obj.get("agents").cloned(); let categories = obj.get("categories").cloned(); - let other = Self::extract_other_fields(&obj); + let other = Self::extract_other_fields(obj); let other_fields = if other.is_empty() { None } else { @@ -297,16 +309,17 @@ impl OmoService { }; let mut global = OmoGlobalConfig::default(); - Self::merge_global_from_obj(&obj, &mut global); + Self::merge_global_from_obj(obj, &mut global); + global.other_fields = other_fields.clone(); - Ok(OmoLocalFileData { + OmoLocalFileData { agents, categories, other_fields, global, - file_path: actual_path.to_string_lossy().to_string(), + file_path, last_modified, - }) + } } fn strip_jsonc_comments(input: &str) -> String { @@ -400,7 +413,7 @@ mod tests { ..Default::default() }; let agents = Some(serde_json::json!({ - "Sisyphus": { "model": "claude-opus-4-5" } + "sisyphus": { "model": "claude-opus-4-5" } })); let categories = None; let other_fields = None; @@ -411,7 +424,7 @@ mod tests { assert_eq!(obj["$schema"], "https://example.com/schema.json"); assert_eq!(obj["disabled_agents"], serde_json::json!(["explore"])); assert!(obj.contains_key("agents")); - assert_eq!(obj["agents"]["Sisyphus"]["model"], "claude-opus-4-5"); + assert_eq!(obj["agents"]["sisyphus"]["model"], "claude-opus-4-5"); } #[test] @@ -422,7 +435,7 @@ mod tests { ..Default::default() }; let agents = Some(serde_json::json!({ - "Sisyphus": { "model": "claude-opus-4-5" } + "sisyphus": { "model": "claude-opus-4-5" } })); let categories = None; let other_fields = None; @@ -434,4 +447,61 @@ mod tests { assert!(!obj.contains_key("disabled_agents")); assert!(obj.contains_key("agents")); } + + #[test] + fn test_build_local_file_data_keeps_unknown_top_level_fields_in_global() { + let obj = serde_json::json!({ + "$schema": "https://example.com/schema.json", + "disabled_agents": ["oracle"], + "agents": { + "sisyphus": { "model": "claude-opus-4-6" } + }, + "categories": { + "code": { "model": "gpt-5.3" } + }, + "custom_top_level": { + "enabled": true + } + }); + let obj_map = obj.as_object().unwrap().clone(); + + let data = OmoService::build_local_file_data_from_obj( + &obj_map, + "/tmp/oh-my-opencode.jsonc".to_string(), + None, + ); + + assert_eq!( + data.global.schema_url.as_deref(), + Some("https://example.com/schema.json") + ); + assert_eq!(data.global.disabled_agents, vec!["oracle".to_string()]); + + assert_eq!( + data.other_fields, + Some(serde_json::json!({ + "custom_top_level": { "enabled": true } + })) + ); + assert_eq!(data.global.other_fields, data.other_fields); + } + + #[test] + fn test_merge_config_ignores_non_object_other_fields() { + let global = OmoGlobalConfig { + other_fields: Some(serde_json::json!(["global_non_object"])), + ..Default::default() + }; + let agents = None; + let categories = None; + let other_fields = Some(serde_json::json!("profile_non_object")); + let profile_data = (agents, categories, other_fields, true); + + let merged = OmoService::merge_config(&global, Some(&profile_data)); + let obj = merged.as_object().unwrap(); + + assert!(!obj.contains_key("0")); + assert!(!obj.contains_key("global_non_object")); + assert!(!obj.contains_key("profile_non_object")); + } } diff --git a/src/App.tsx b/src/App.tsx index 9f758f8b..4f0d4778 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -141,7 +141,11 @@ function App() { // Fallback from sessions view when switching to an app without session support useEffect(() => { - if (currentView === "sessions" && activeApp !== "claude" && activeApp !== "codex") { + if ( + currentView === "sessions" && + activeApp !== "claude" && + activeApp !== "codex" + ) { setCurrentView("providers"); } }, [activeApp, currentView]); diff --git a/src/components/providers/forms/OmoFormFields.tsx b/src/components/providers/forms/OmoFormFields.tsx index 2ee356a3..b4b859ba 100644 --- a/src/components/providers/forms/OmoFormFields.tsx +++ b/src/components/providers/forms/OmoFormFields.tsx @@ -12,6 +12,19 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; import { Plus, Trash2, @@ -21,6 +34,10 @@ import { Settings, FolderInput, Loader2, + HelpCircle, + Check, + ChevronsUpDown, + X, } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; @@ -43,6 +60,13 @@ const ADVANCED_PLACEHOLDER = `{ interface OmoFormFieldsProps { modelOptions: Array<{ value: string; label: string }>; modelVariantsMap?: Record; + presetMetaMap?: Record< + string, + { + options?: Record; + limit?: { context?: number; output?: number }; + } + >; agents: Record>; onAgentsChange: (agents: Record>) => void; categories: Record>; @@ -53,19 +77,149 @@ interface OmoFormFieldsProps { onOtherFieldsStrChange: (value: string) => void; } -type CustomModelItem = { key: string; model: string }; +export type CustomModelItem = { + key: string; + model: string; + sourceKey?: string; +}; type BuiltinModelDef = Pick< OmoAgentDef | OmoCategoryDef, - "key" | "display" | "descZh" | "descEn" | "recommended" + "key" | "display" | "descKey" | "recommended" | "tooltipKey" >; type ModelOption = { value: string; label: string }; +function DeferredKeyInput({ + value, + onCommit, + placeholder, + className, +}: { + value: string; + onCommit: (value: string) => void; + placeholder?: string; + className?: string; +}) { + const [draft, setDraft] = useState(value); + + useEffect(() => { + setDraft(value); + }, [value]); + + return ( + setDraft(e.target.value)} + onBlur={() => { + if (draft !== value) { + onCommit(draft); + } + }} + placeholder={placeholder} + className={className} + /> + ); +} + const BUILTIN_AGENT_KEYS = new Set(OMO_BUILTIN_AGENTS.map((a) => a.key)); const BUILTIN_CATEGORY_KEYS = new Set(OMO_BUILTIN_CATEGORIES.map((c) => c.key)); -const EMPTY_MODEL_VALUE = "__cc_switch_omo_model_empty__"; -const UNAVAILABLE_MODEL_VALUE = "__cc_switch_omo_model_unavailable__"; const EMPTY_VARIANT_VALUE = "__cc_switch_omo_variant_empty__"; -const UNAVAILABLE_VARIANT_VALUE = "__cc_switch_omo_variant_unavailable__"; + +function ModelCombobox({ + value, + options, + recommended, + onChange, +}: { + value: string; + options: ModelOption[]; + recommended?: string; + onChange: (value: string) => void; +}) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + + const selectedLabel = options.find((o) => o.value === value)?.label; + + const selectModelText = t("omo.selectModel", { + defaultValue: "Select configured model", + }); + const placeholderText = recommended + ? `${selectModelText} (${t("omo.recommendedHint", { model: recommended, defaultValue: "Recommended: {{model}}" })})` + : selectModelText; + + return ( + + + + + + + + + + {t("omo.noEnabledModels", { + defaultValue: "No configured models", + })} + + + {options.map((option) => ( + { + onChange(option.value); + setOpen(false); + }} + > + + {option.label} + + ))} + + + + + + ); +} function getAdvancedStr(config: Record | undefined): string { if (!config) return ""; @@ -86,24 +240,58 @@ function collectCustomModels( customs.push({ key: k, model: ((v as Record).model as string) || "", + sourceKey: k, }); } } return customs; } -function mergeCustomModelsIntoStore( +export function mergeCustomModelsIntoStore( store: Record>, builtinKeys: Set, customs: CustomModelItem[], + modelVariantsMap: Record, ): Record> { - const updated = { ...store }; - for (const key of Object.keys(updated)) { - if (!builtinKeys.has(key)) delete updated[key]; + const updated: Record> = {}; + + for (const [key, value] of Object.entries(store)) { + if (builtinKeys.has(key)) { + updated[key] = { ...value }; + } } + for (const custom of customs) { - if (custom.key.trim()) { - updated[custom.key] = { ...updated[custom.key], model: custom.model }; + const targetKey = custom.key.trim(); + if (!targetKey) continue; + + const sourceKey = (custom.sourceKey || targetKey).trim(); + const sourceEntry = store[sourceKey] ?? store[targetKey]; + const nextEntry = { + ...(updated[targetKey] || {}), + ...(sourceEntry || {}), + }; + + if (custom.model.trim()) { + nextEntry.model = custom.model; + const currentVariant = + typeof nextEntry.variant === "string" ? nextEntry.variant : ""; + if (currentVariant) { + const validVariants = modelVariantsMap[custom.model] || []; + if (!validVariants.includes(currentVariant)) { + delete nextEntry.variant; + } + } + updated[targetKey] = nextEntry; + continue; + } + + delete nextEntry.model; + delete nextEntry.variant; + if (Object.keys(nextEntry).length > 0) { + updated[targetKey] = nextEntry; + } else { + delete updated[targetKey]; } } return updated; @@ -112,6 +300,7 @@ function mergeCustomModelsIntoStore( export function OmoFormFields({ modelOptions, modelVariantsMap = {}, + presetMetaMap: _presetMetaMap = {}, agents, onAgentsChange, categories, @@ -119,8 +308,7 @@ export function OmoFormFields({ otherFieldsStr, onOtherFieldsStrChange, }: OmoFormFieldsProps) { - const { t, i18n } = useTranslation(); - const isZh = i18n.language?.startsWith("zh"); + const { t } = useTranslation(); const [mainAgentsOpen, setMainAgentsOpen] = useState(true); const [subAgentsOpen, setSubAgentsOpen] = useState(true); @@ -159,19 +347,29 @@ export function OmoFormFields({ const syncCustomAgents = useCallback( (customs: CustomModelItem[]) => { onAgentsChange( - mergeCustomModelsIntoStore(agents, BUILTIN_AGENT_KEYS, customs), + mergeCustomModelsIntoStore( + agents, + BUILTIN_AGENT_KEYS, + customs, + modelVariantsMap, + ), ); }, - [agents, onAgentsChange], + [agents, onAgentsChange, modelVariantsMap], ); const syncCustomCategories = useCallback( (customs: CustomModelItem[]) => { onCategoriesChange( - mergeCustomModelsIntoStore(categories, BUILTIN_CATEGORY_KEYS, customs), + mergeCustomModelsIntoStore( + categories, + BUILTIN_CATEGORY_KEYS, + customs, + modelVariantsMap, + ), ); }, - [categories, onCategoriesChange], + [categories, onCategoriesChange, modelVariantsMap], ); const buildEffectiveModelOptions = useCallback( @@ -212,43 +410,16 @@ export function OmoFormFields({ const renderModelSelect = ( currentModel: string, onChange: (value: string) => void, - placeholder?: string, + recommended?: string, ) => { const options = buildEffectiveModelOptions(currentModel); return ( - + ); }; @@ -268,11 +439,21 @@ export function OmoFormFields({ currentVariant: string, onChange: (value: string) => void, ) => { + const hasModel = Boolean(currentModel); + const modelVariantKeys = hasModel + ? modelVariantsMap[currentModel] || [] + : []; + const hasVariants = modelVariantKeys.length > 0; + const shouldShow = hasModel && (hasVariants || Boolean(currentVariant)); + + if (!shouldShow) { + return null; + } + const variantOptions = buildEffectiveVariantOptions( currentModel, currentVariant, ); - const hasModel = Boolean(currentModel); const firstIsUnavailable = Boolean(currentVariant) && !(modelVariantsMap[currentModel] || []).includes(currentVariant); @@ -283,9 +464,8 @@ export function OmoFormFields({ onValueChange={(value) => onChange(value === EMPTY_VARIANT_VALUE ? "" : value) } - disabled={!hasModel} > - + {t("omo.defaultWrapped", { defaultValue: "(Default)" })} - {!hasModel ? ( - - {t("omo.selectModelFirst", { - defaultValue: "Select model first", - })} + {variantOptions.map((variant, index) => ( + + {firstIsUnavailable && index === 0 + ? t("omo.currentValueUnavailable", { + value: variant, + defaultValue: "{{value}} (current value, unavailable)", + }) + : variant} - ) : variantOptions.length === 0 ? ( - - {t("omo.noVariantsForModel", { - defaultValue: "No variants for model", - })} - - ) : ( - variantOptions.map((variant, index) => ( - - {firstIsUnavailable && index === 0 - ? t("omo.currentValueUnavailable", { - value: variant, - defaultValue: "{{value}} (current value, unavailable)", - }) - : variant} - - )) - )} + ))} ); @@ -536,7 +702,7 @@ export function OmoFormFields({ toast.warning( t("omo.noEnabledModelsWarning", { defaultValue: - "No enabled models available. Configure and enable OpenCode models first.", + "No configured models available. Configure OpenCode models first.", }), ); return; @@ -641,9 +807,17 @@ export function OmoFormFields({
-
{def.display}
+
+ {def.display} + + + + {t(def.tooltipKey)} + + +
- {isZh ? def.descZh : def.descEn} + {t(def.descKey)}
{renderModelSelect( @@ -727,16 +901,14 @@ export function OmoFormFields({ className="border-b border-border/30 last:border-b-0" >
- updateCustom({ key: e.target.value })} + onCommit={(value) => updateCustom({ key: value })} placeholder={keyPlaceholder} className="w-32 shrink-0 h-8 text-sm text-primary" /> - {renderModelSelect( - item.model, - (value) => updateCustom({ model: value }), - t("omo.modelNamePlaceholder", { defaultValue: "model-name" }), + {renderModelSelect(item.model, (value) => + updateCustom({ model: value }), )} {renderVariantSelect(item.model, currentVariant, (value) => { if (!item.key) return; @@ -877,11 +1049,17 @@ export function OmoFormFields({ const addCustomModel = (scope: AdvancedScope) => { if (scope === "agent") { - setCustomAgents((prev) => [...prev, { key: "", model: "" }]); + setCustomAgents((prev) => [ + ...prev, + { key: "", model: "", sourceKey: "" }, + ]); setSubAgentsOpen(true); return; } - setCustomCategories((prev) => [...prev, { key: "", model: "" }]); + setCustomCategories((prev) => [ + ...prev, + { key: "", model: "", sourceKey: "" }, + ]); setCategoriesOpen(true); }; @@ -931,7 +1109,7 @@ export function OmoFormFields({ ·{" "} {t("omo.enabledModelsCount", { count: modelOptions.length, - defaultValue: "{{count}} enabled models available", + defaultValue: "{{count}} configured models available", })} {localFilePath && ( diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index b60e18a8..381a7d68 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -31,6 +31,7 @@ import { } from "@/config/geminiProviderPresets"; import { opencodeProviderPresets, + OPENCODE_PRESET_MODEL_VARIANTS, type OpenCodeProviderPreset, } from "@/config/opencodeProviderPresets"; import { OpenCodeFormFields } from "./OpenCodeFormFields"; @@ -53,7 +54,7 @@ import { type OmoGlobalConfigFieldsRef } from "./OmoGlobalConfigFields"; import { OmoCommonConfigEditor } from "./OmoCommonConfigEditor"; import * as configApi from "@/lib/api/config"; import type { OmoGlobalConfig } from "@/types/omo"; -import { mergeOmoConfigPreview } from "@/types/omo"; +import { mergeOmoConfigPreview, parseOmoOtherFieldsObject } from "@/types/omo"; import { ProviderAdvancedConfig, type PricingModelSourceOption, @@ -112,21 +113,25 @@ const isKnownOpencodeOptionKey = (key: string) => function parseOpencodeConfig( settingsConfig?: Record, ): OpenCodeProviderConfig { + const normalize = ( + parsed: Partial, + ): OpenCodeProviderConfig => ({ + npm: parsed.npm || OPENCODE_DEFAULT_NPM, + options: + parsed.options && typeof parsed.options === "object" + ? (parsed.options as OpenCodeProviderConfig["options"]) + : {}, + models: + parsed.models && typeof parsed.models === "object" + ? (parsed.models as Record) + : {}, + }); + try { const parsed = JSON.parse( settingsConfig ? JSON.stringify(settingsConfig) : OPENCODE_DEFAULT_CONFIG, ) as Partial; - return { - npm: parsed.npm || OPENCODE_DEFAULT_NPM, - options: - parsed.options && typeof parsed.options === "object" - ? (parsed.options as OpenCodeProviderConfig["options"]) - : {}, - models: - parsed.models && typeof parsed.models === "object" - ? (parsed.models as Record) - : {}, - }; + return normalize(parsed); } catch { return { npm: OPENCODE_DEFAULT_NPM, @@ -136,6 +141,25 @@ function parseOpencodeConfig( } } +function parseOpencodeConfigStrict( + settingsConfig?: Record, +): OpenCodeProviderConfig { + const parsed = JSON.parse( + settingsConfig ? JSON.stringify(settingsConfig) : OPENCODE_DEFAULT_CONFIG, + ) as Partial; + return { + npm: parsed.npm || OPENCODE_DEFAULT_NPM, + options: + parsed.options && typeof parsed.options === "object" + ? (parsed.options as OpenCodeProviderConfig["options"]) + : {}, + models: + parsed.models && typeof parsed.models === "object" + ? (parsed.models as Record) + : {}, + }; +} + function toOpencodeExtraOptions( options: OpenCodeProviderConfig["options"], ): Record { @@ -195,8 +219,10 @@ function buildOmoProfilePreview( } if (otherFieldsStr.trim()) { try { - const other = JSON.parse(otherFieldsStr); - Object.assign(profileOnly, other); + const other = parseOmoOtherFieldsObject(otherFieldsStr); + if (other) { + Object.assign(profileOnly, other); + } } catch {} } return profileOnly; @@ -580,19 +606,24 @@ export function ProviderForm({ ); }, [opencodeProvidersData?.providers, providerId]); const [enabledOpencodeProviderIds, setEnabledOpencodeProviderIds] = useState< - string[] - >([]); + string[] | null + >(null); + const [omoLiveIdsLoadFailed, setOmoLiveIdsLoadFailed] = useState(false); + const lastOmoModelSourceWarningRef = useRef(""); useEffect(() => { let active = true; - if (!isOmoCategory) { - setEnabledOpencodeProviderIds([]); + setEnabledOpencodeProviderIds(null); + setOmoLiveIdsLoadFailed(false); return () => { active = false; }; } + setEnabledOpencodeProviderIds(null); + setOmoLiveIdsLoadFailed(false); + (async () => { try { const ids = await providersApi.getOpenCodeLiveProviderIds(); @@ -600,9 +631,13 @@ export function ProviderForm({ setEnabledOpencodeProviderIds(ids); } } catch (error) { - console.error("Failed to load OpenCode live provider ids:", error); + console.warn( + "[OMO_MODEL_SOURCE_LIVE_IDS_FAILED] failed to load live provider ids", + error, + ); if (active) { - setEnabledOpencodeProviderIds([]); + setOmoLiveIdsLoadFailed(true); + setEnabledOpencodeProviderIds(null); } } })(); @@ -612,23 +647,71 @@ export function ProviderForm({ }; }, [isOmoCategory]); - const omoModelOptions = useMemo(() => { - if (!isOmoCategory) return []; + const omoModelBuild = useMemo(() => { + const empty = { + options: [] as Array<{ value: string; label: string }>, + variantsMap: {} as Record, + presetMetaMap: {} as Record< + string, + { + options?: Record; + limit?: { context?: number; output?: number }; + } + >, + parseFailedProviders: [] as string[], + usedFallbackSource: false, + }; + if (!isOmoCategory) { + return empty; + } const allProviders = opencodeProvidersData?.providers; - if (!allProviders) return []; + if (!allProviders) { + return empty; + } - const enabledSet = new Set(enabledOpencodeProviderIds); - if (enabledSet.size === 0) return []; + const shouldFilterByLive = !omoLiveIdsLoadFailed; + if (shouldFilterByLive && enabledOpencodeProviderIds === null) { + return empty; + } + const liveSet = + shouldFilterByLive && enabledOpencodeProviderIds + ? new Set(enabledOpencodeProviderIds) + : null; const dedupedOptions = new Map(); + const variantsMap: Record = {}; + const presetMetaMap: Record< + string, + { + options?: Record; + limit?: { context?: number; output?: number }; + } + > = {}; + const parseFailedProviders: string[] = []; for (const [providerKey, provider] of Object.entries(allProviders)) { - if (provider.category === "omo" || !enabledSet.has(providerKey)) { + if (provider.category === "omo") { + continue; + } + if (liveSet && !liveSet.has(providerKey)) { continue; } - const parsedConfig = parseOpencodeConfig(provider.settingsConfig); + let parsedConfig: OpenCodeProviderConfig; + try { + parsedConfig = parseOpencodeConfigStrict(provider.settingsConfig); + } catch (error) { + parseFailedProviders.push(providerKey); + console.warn( + "[OMO_MODEL_SOURCE_PARSE_FAILED] failed to parse provider settings", + { + providerKey, + error, + }, + ); + continue; + } for (const [modelId, model] of Object.entries( parsedConfig.models || {}, )) { @@ -645,63 +728,107 @@ export function ProviderForm({ if (!dedupedOptions.has(value)) { dedupedOptions.set(value, label); } - } - } - return Array.from(dedupedOptions.entries()) - .map(([value, label]) => ({ value, label })) - .sort((a, b) => a.label.localeCompare(b.label, "zh-CN")); - }, [ - isOmoCategory, - opencodeProvidersData?.providers, - enabledOpencodeProviderIds, - ]); - const omoModelVariantsMap = useMemo(() => { - const variantsMap: Record = {}; - if (!isOmoCategory) { - return variantsMap; - } - - const allProviders = opencodeProvidersData?.providers; - if (!allProviders) { - return variantsMap; - } - - const enabledSet = new Set(enabledOpencodeProviderIds); - if (enabledSet.size === 0) { - return variantsMap; - } - - for (const [providerKey, provider] of Object.entries(allProviders)) { - if (provider.category === "omo" || !enabledSet.has(providerKey)) { - continue; - } - - const parsedConfig = parseOpencodeConfig(provider.settingsConfig); - for (const [modelId, model] of Object.entries( - parsedConfig.models || {}, - )) { const rawVariants = model.variants; if ( - !rawVariants || - typeof rawVariants !== "object" || - Array.isArray(rawVariants) + rawVariants && + typeof rawVariants === "object" && + !Array.isArray(rawVariants) ) { - continue; + const variantKeys = Object.keys(rawVariants).filter(Boolean); + if (variantKeys.length > 0) { + variantsMap[value] = variantKeys; + } } - const variantKeys = Object.keys(rawVariants).filter(Boolean); - if (variantKeys.length === 0) { - continue; + } + + // Preset fallback: for models without config-defined variants, + // check if the npm package has preset variant definitions. + // Also collect preset metadata (options, limit) for enrichment. + const presetModels = OPENCODE_PRESET_MODEL_VARIANTS[parsedConfig.npm]; + if (presetModels) { + for (const modelId of Object.keys(parsedConfig.models || {})) { + const fullKey = `${providerKey}/${modelId}`; + const preset = presetModels.find((p) => p.id === modelId); + if (!preset) continue; + + // Variant fallback + if (!variantsMap[fullKey] && preset.variants) { + const presetKeys = Object.keys(preset.variants).filter(Boolean); + if (presetKeys.length > 0) { + variantsMap[fullKey] = presetKeys; + } + } + + // Collect preset metadata for model enrichment + const meta: (typeof presetMetaMap)[string] = {}; + if (preset.options) meta.options = preset.options; + if (preset.contextLimit || preset.outputLimit) { + meta.limit = {}; + if (preset.contextLimit) meta.limit.context = preset.contextLimit; + if (preset.outputLimit) meta.limit.output = preset.outputLimit; + } + if (Object.keys(meta).length > 0) { + presetMetaMap[fullKey] = meta; + } } - variantsMap[`${providerKey}/${modelId}`] = variantKeys; } } - return variantsMap; + return { + options: Array.from(dedupedOptions.entries()) + .map(([value, label]) => ({ value, label })) + .sort((a, b) => a.label.localeCompare(b.label, "zh-CN")), + variantsMap, + presetMetaMap, + parseFailedProviders, + usedFallbackSource: omoLiveIdsLoadFailed, + }; }, [ isOmoCategory, opencodeProvidersData?.providers, enabledOpencodeProviderIds, + omoLiveIdsLoadFailed, + ]); + const omoModelOptions = omoModelBuild.options; + const omoModelVariantsMap = omoModelBuild.variantsMap; + const omoPresetMetaMap = omoModelBuild.presetMetaMap; + + useEffect(() => { + if (!isOmoCategory) return; + const failed = omoModelBuild.parseFailedProviders; + const fallback = omoModelBuild.usedFallbackSource; + if (failed.length === 0 && !fallback) return; + + const signature = `${fallback ? "fallback:" : ""}${failed + .slice() + .sort() + .join(",")}`; + if (lastOmoModelSourceWarningRef.current === signature) return; + lastOmoModelSourceWarningRef.current = signature; + + if (failed.length > 0) { + toast.warning( + t("omo.modelSourcePartialWarning", { + count: failed.length, + defaultValue: + "Some provider model configs are invalid and were skipped.", + }), + ); + } + if (fallback) { + toast.warning( + t("omo.modelSourceFallbackWarning", { + defaultValue: + "Failed to load live provider state. Falling back to configured providers.", + }), + ); + } + }, [ + isOmoCategory, + omoModelBuild.parseFailedProviders, + omoModelBuild.usedFallbackSource, + t, ]); const initialOmoSettings = @@ -1078,7 +1205,19 @@ export function ProviderForm({ } if (omoOtherFieldsStr.trim()) { try { - omoConfig.otherFields = JSON.parse(omoOtherFieldsStr); + const otherFields = parseOmoOtherFieldsObject(omoOtherFieldsStr); + if (!otherFields) { + toast.error( + t("omo.jsonMustBeObject", { + field: t("omo.otherFields", { + defaultValue: "Other Config", + }), + defaultValue: "{{field}} must be a JSON object", + }), + ); + return; + } + omoConfig.otherFields = otherFields; } catch { toast.error( t("omo.invalidJson", { @@ -1603,6 +1742,7 @@ export function ProviderForm({ , + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Command.displayName = CommandPrimitive.displayName; + +const CommandInput = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)); +CommandInput.displayName = CommandPrimitive.Input.displayName; + +const CommandList = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandList.displayName = CommandPrimitive.List.displayName; + +const CommandEmpty = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)); +CommandEmpty.displayName = CommandPrimitive.Empty.displayName; + +const CommandGroup = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandGroup.displayName = CommandPrimitive.Group.displayName; + +const CommandItem = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandItem.displayName = CommandPrimitive.Item.displayName; + +export { + Command, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, +}; diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx new file mode 100644 index 00000000..9f00cbca --- /dev/null +++ b/src/components/ui/popover.tsx @@ -0,0 +1,28 @@ +import * as React from "react"; +import * as PopoverPrimitive from "@radix-ui/react-popover"; +import { cn } from "@/lib/utils"; + +const Popover = PopoverPrimitive.Root; +const PopoverTrigger = PopoverPrimitive.Trigger; +const PopoverAnchor = PopoverPrimitive.Anchor; + +const PopoverContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, align = "start", sideOffset = 4, ...props }, ref) => ( + + + +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; diff --git a/src/config/opencodeProviderPresets.ts b/src/config/opencodeProviderPresets.ts index 26e71a2a..c41ce2b2 100644 --- a/src/config/opencodeProviderPresets.ts +++ b/src/config/opencodeProviderPresets.ts @@ -24,6 +24,384 @@ export const opencodeNpmPackages = [ { value: "@ai-sdk/google", label: "Google (Gemini)" }, ] as const; +export interface PresetModelVariant { + id: string; + name?: string; + contextLimit?: number; + outputLimit?: number; + modalities?: { input: string[]; output: string[] }; + options?: Record; + variants?: Record>; +} + +export const OPENCODE_PRESET_MODEL_VARIANTS: Record< + string, + PresetModelVariant[] +> = { + "@ai-sdk/openai-compatible": [ + { + id: "MiniMax-M2.1", + name: "MiniMax M2.1", + contextLimit: 204800, + outputLimit: 131072, + modalities: { input: ["text"], output: ["text"] }, + }, + { + id: "glm-4.7", + name: "GLM 4.7", + contextLimit: 204800, + outputLimit: 131072, + modalities: { input: ["text"], output: ["text"] }, + }, + { + id: "kimi-k2.5", + name: "Kimi K2.5", + contextLimit: 262144, + outputLimit: 262144, + modalities: { input: ["text", "image", "video"], output: ["text"] }, + }, + ], + "@ai-sdk/google": [ + { + id: "gemini-2.5-flash-lite", + name: "Gemini 2.5 Flash Lite", + contextLimit: 1048576, + outputLimit: 65536, + modalities: { + input: ["text", "image", "pdf", "video", "audio"], + output: ["text"], + }, + variants: { + auto: { + thinkingConfig: { includeThoughts: true, thinkingBudget: -1 }, + }, + "no-thinking": { thinkingConfig: { thinkingBudget: 0 } }, + }, + }, + { + id: "gemini-3-flash-preview", + name: "Gemini 3 Flash Preview", + contextLimit: 1048576, + outputLimit: 65536, + modalities: { + input: ["text", "image", "pdf", "video", "audio"], + output: ["text"], + }, + variants: { + minimal: { + thinkingConfig: { includeThoughts: true, thinkingLevel: "minimal" }, + }, + low: { + thinkingConfig: { includeThoughts: true, thinkingLevel: "low" }, + }, + medium: { + thinkingConfig: { includeThoughts: true, thinkingLevel: "medium" }, + }, + high: { + thinkingConfig: { includeThoughts: true, thinkingLevel: "high" }, + }, + }, + }, + { + id: "gemini-3-pro-preview", + name: "Gemini 3 Pro Preview", + contextLimit: 1048576, + outputLimit: 65536, + modalities: { + input: ["text", "image", "pdf", "video", "audio"], + output: ["text"], + }, + variants: { + low: { + thinkingConfig: { includeThoughts: true, thinkingLevel: "low" }, + }, + high: { + thinkingConfig: { includeThoughts: true, thinkingLevel: "high" }, + }, + }, + }, + ], + "@ai-sdk/openai": [ + { + id: "gpt-5", + name: "GPT-5", + contextLimit: 400000, + outputLimit: 128000, + modalities: { input: ["text", "image"], output: ["text"] }, + variants: { + low: { + reasoningEffort: "low", + reasoningSummary: "auto", + textVerbosity: "low", + }, + medium: { + reasoningEffort: "medium", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + high: { + reasoningEffort: "high", + reasoningSummary: "auto", + textVerbosity: "high", + }, + }, + }, + { + id: "gpt-5.1", + name: "GPT-5.1", + contextLimit: 400000, + outputLimit: 272000, + modalities: { input: ["text", "image"], output: ["text"] }, + variants: { + low: { + reasoningEffort: "low", + reasoningSummary: "auto", + textVerbosity: "low", + }, + medium: { + reasoningEffort: "medium", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + high: { + reasoningEffort: "high", + reasoningSummary: "auto", + textVerbosity: "high", + }, + }, + }, + { + id: "gpt-5.1-codex", + name: "GPT-5.1 Codex", + contextLimit: 400000, + outputLimit: 128000, + modalities: { input: ["text", "image"], output: ["text"] }, + options: { include: ["reasoning.encrypted_content"], store: false }, + variants: { + low: { + reasoningEffort: "low", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + medium: { + reasoningEffort: "medium", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + high: { + reasoningEffort: "high", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + }, + }, + { + id: "gpt-5.1-codex-max", + name: "GPT-5.1 Codex Max", + contextLimit: 400000, + outputLimit: 128000, + modalities: { input: ["text", "image"], output: ["text"] }, + options: { include: ["reasoning.encrypted_content"], store: false }, + variants: { + low: { + reasoningEffort: "low", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + medium: { + reasoningEffort: "medium", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + high: { + reasoningEffort: "high", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + xhigh: { + reasoningEffort: "xhigh", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + }, + }, + { + id: "gpt-5.2", + name: "GPT-5.2", + contextLimit: 400000, + outputLimit: 128000, + modalities: { input: ["text", "image"], output: ["text"] }, + variants: { + low: { + reasoningEffort: "low", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + medium: { + reasoningEffort: "medium", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + high: { + reasoningEffort: "high", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + xhigh: { + reasoningEffort: "xhigh", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + }, + }, + { + id: "gpt-5.2-codex", + name: "GPT-5.2 Codex", + contextLimit: 400000, + outputLimit: 128000, + modalities: { input: ["text", "image"], output: ["text"] }, + options: { include: ["reasoning.encrypted_content"], store: false }, + variants: { + low: { + reasoningEffort: "low", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + medium: { + reasoningEffort: "medium", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + high: { + reasoningEffort: "high", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + xhigh: { + reasoningEffort: "xhigh", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + }, + }, + { + id: "gpt-5.3-codex", + name: "GPT-5.3 Codex", + contextLimit: 400000, + outputLimit: 128000, + modalities: { input: ["text", "image"], output: ["text"] }, + options: { include: ["reasoning.encrypted_content"], store: false }, + variants: { + low: { + reasoningEffort: "low", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + medium: { + reasoningEffort: "medium", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + high: { + reasoningEffort: "high", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + xhigh: { + reasoningEffort: "xhigh", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + }, + }, + ], + "@ai-sdk/anthropic": [ + { + id: "claude-sonnet-4-5-20250929", + name: "Claude Sonnet 4.5", + contextLimit: 200000, + outputLimit: 64000, + modalities: { input: ["text", "image", "pdf"], output: ["text"] }, + variants: { + low: { effort: "low" }, + medium: { effort: "medium" }, + high: { effort: "high" }, + }, + }, + { + id: "claude-opus-4-5-20251101", + name: "Claude Opus 4.5", + contextLimit: 200000, + outputLimit: 64000, + modalities: { input: ["text", "image", "pdf"], output: ["text"] }, + variants: { + low: { thinking: { budgetTokens: 5000, type: "enabled" } }, + medium: { thinking: { budgetTokens: 13000, type: "enabled" } }, + high: { thinking: { budgetTokens: 18000, type: "enabled" } }, + }, + }, + { + id: "claude-opus-4-6", + name: "Claude Opus 4.6", + contextLimit: 1000000, + outputLimit: 128000, + modalities: { input: ["text", "image", "pdf"], output: ["text"] }, + variants: { + low: { effort: "low" }, + medium: { effort: "medium" }, + high: { effort: "high" }, + max: { effort: "max" }, + }, + }, + { + id: "claude-haiku-4-5-20251001", + name: "Claude Haiku 4.5", + contextLimit: 200000, + outputLimit: 64000, + modalities: { input: ["text", "image", "pdf"], output: ["text"] }, + }, + { + id: "gemini-claude-opus-4-5-thinking", + name: "Antigravity - Claude Opus 4.5", + contextLimit: 200000, + outputLimit: 64000, + modalities: { input: ["text", "image", "pdf"], output: ["text"] }, + variants: { + low: { effort: "low" }, + medium: { effort: "medium" }, + high: { effort: "high" }, + }, + }, + { + id: "gemini-claude-sonnet-4-5-thinking", + name: "Antigravity - Claude Sonnet 4.5", + contextLimit: 200000, + outputLimit: 64000, + modalities: { input: ["text", "image", "pdf"], output: ["text"] }, + variants: { + low: { thinking: { budgetTokens: 5000, type: "enabled" } }, + medium: { thinking: { budgetTokens: 13000, type: "enabled" } }, + high: { thinking: { budgetTokens: 18000, type: "enabled" } }, + }, + }, + ], +}; + +/** + * Look up preset metadata for a model by npm package and model ID. + * Returns enrichment fields (options, limit, modalities) that can be + * merged into a model definition when the user's config doesn't already + * provide them. + */ +export function getPresetModelDefaults( + npm: string, + modelId: string, +): PresetModelVariant | undefined { + const models = OPENCODE_PRESET_MODEL_VARIANTS[npm]; + if (!models) return undefined; + return models.find((m) => m.id === modelId); +} + export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ { name: "DeepSeek", @@ -664,7 +1042,10 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, models: { "gpt-5.2": { name: "GPT-5.2" }, - "gpt-5.2-codex": { name: "GPT-5.2 Codex" }, + "gpt-5.2-codex": { + name: "GPT-5.2 Codex", + options: { include: ["reasoning.encrypted_content"], store: false }, + }, }, }, category: "third_party", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 05489ed9..d5bd4632 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1572,16 +1572,21 @@ "clearWrapped": "(Clear)", "defaultWrapped": "(Default)", "variantPlaceholder": "variant", - "selectEnabledModel": "Select enabled model", + "selectEnabledModel": "Select configured model", + "selectModel": "Select configured model", + "recommendedHint": "Recommended: {{model}}", + "searchModel": "Search model...", "selectModelFirst": "Select model first", - "noEnabledModels": "No enabled models", + "noEnabledModels": "No configured models", "noVariantsForModel": "No variants for model", - "currentValueNotEnabled": "{{value}} (current value, not enabled)", + "currentValueNotEnabled": "{{value}} (current value, not configured)", "currentValueUnavailable": "{{value}} (current value, unavailable)", "advancedLabel": "Advanced", "advancedJsonInvalid": "Advanced JSON is invalid", "advancedJsonHint": "temperature, top_p, budgetTokens, prompt_append, permission, etc. Leave empty for defaults", - "noEnabledModelsWarning": "No enabled models available. Configure and enable OpenCode models first.", + "noEnabledModelsWarning": "No configured models available. Configure OpenCode models first.", + "modelSourcePartialWarning": "Some provider model configs are invalid and were skipped.", + "modelSourceFallbackWarning": "Failed to load live provider state. Falling back to configured providers.", "importLocalReplaceSuccess": "Imported local file and replaced Agents/Categories/Other Fields", "importLocalFailed": "Failed to read local file: {{error}}", "agentKeyPlaceholder": "agent key", @@ -1592,7 +1597,7 @@ "modelConfiguration": "Model Configuration", "fillRecommended": "Fill Recommended", "configSummary": "{{agents}} agents, {{categories}} categories configured · Click ⚙ for advanced params", - "enabledModelsCount": "{{count}} enabled models available", + "enabledModelsCount": "{{count}} configured models available", "source": "from:", "otherFieldsJson": "Other Fields (JSON)", "searchOrType": "Search or type custom value...", @@ -1618,6 +1623,52 @@ "advancedExperimental": "Experimental Features", "advancedBackgroundTask": "Background Tasks", "advancedBrowserAutomation": "Browser Automation", - "advancedClaudeCode": "Claude Code" + "advancedClaudeCode": "Claude Code", + "agentDesc": { + "sisyphus": "Main orchestrator", + "hephaestus": "Autonomous deep worker", + "prometheus": "Strategic planner", + "atlas": "Task manager", + "oracle": "Strategic advisor", + "librarian": "Multi-repo researcher", + "explore": "Fast code search", + "multimodalLooker": "Media analyzer", + "metis": "Pre-plan analysis advisor", + "momus": "Plan reviewer", + "sisyphusJunior": "Delegated task executor" + }, + "agentTooltip": { + "sisyphus": "Main orchestrator responsible for task planning, delegation and parallel execution. Uses extended thinking (32k budget) and drives workflows through TODO lists to ensure task completion.", + "atlas": "Main orchestrator (holds TODO list) responsible for task distribution and coordination during execution phase. Delegates to specialized agents rather than completing all work directly.", + "prometheus": "Strategic planner that collects requirements through interview mode and creates detailed work plans. Can only read/write Markdown files in .sisyphus/ directory, never writes code directly.", + "hephaestus": "Autonomous deep worker (\"legitimate craftsman\") inspired by AmpCode deep mode. Goal-oriented execution that launches 2-5 explore/librarian agents in parallel for research before taking action.", + "oracle": "Architecture decision and debugging advisor. Read-only consulting agent providing excellent logical reasoning and deep analysis. Cannot write files or delegate tasks.", + "librarian": "Multi-repository analysis and documentation retrieval expert. Deeply understands codebases and provides evidence-based answers. Excels at finding official documentation and open-source implementation examples.", + "explore": "Fast codebase exploration and context grep expert. Uses lightweight models for high-speed search. The scout for understanding code structure.", + "multimodalLooker": "Visual content expert that analyzes PDFs, images, charts and other non-text media, extracting information and insights from them.", + "metis": "Plan consultant that performs pre-analysis before planning. Identifies hidden intents, ambiguities and AI failure points to prevent over-engineering.", + "momus": "Plan reviewer that validates plan clarity, verifiability and completeness with high precision. Rejects and requests revisions until plans are perfect.", + "sisyphusJunior": "Category-generated executor, automatically created via category parameters. Focuses on executing assigned tasks and cannot re-delegate, preventing infinite delegation loops." + }, + "categoryDesc": { + "visualEngineering": "Visual/frontend engineering", + "ultrabrain": "Ultra thinking", + "deep": "Deep work", + "artistry": "Creative/artistic", + "quick": "Quick response", + "unspecifiedLow": "General low tier", + "unspecifiedHigh": "General high tier", + "writing": "Writing" + }, + "categoryTooltip": { + "visualEngineering": "Frontend and visual engineering category for UI/UX design, styling, animation and interface implementation. Defaults to Gemini 3 Pro model.", + "ultrabrain": "Deep logical reasoning category for complex architectural decisions requiring extensive analysis. Defaults to GPT-5.3 Codex ultra-high reasoning variant.", + "deep": "Deep autonomous problem-solving category with goal-oriented execution. Conducts thorough research before action, suitable for difficult problems requiring deep understanding.", + "artistry": "Highly creative and artistic task category that inspires novel ideas and creative solutions. Defaults to Gemini 3 Pro max variant.", + "quick": "Lightweight task category for single-file modifications, typo fixes, and simple adjustments. Defaults to Claude Haiku 4.5 fast model.", + "unspecifiedLow": "Uncategorized low-effort task category for tasks that don't fit other categories with small workload. Defaults to Claude Sonnet 4.5.", + "unspecifiedHigh": "Uncategorized high-effort task category for tasks that don't fit other categories with large workload. Defaults to Claude Opus 4.6 max variant.", + "writing": "Writing category for documentation, prose and technical writing. Defaults to Gemini 3 Flash fast generation model." + } } } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 8675570c..908d45f4 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1553,16 +1553,21 @@ "clearWrapped": "(クリア)", "defaultWrapped": "(デフォルト)", "variantPlaceholder": "variant", - "selectEnabledModel": "有効なモデルを選択", + "selectEnabledModel": "設定済みモデルを選択", + "selectModel": "設定済みモデルを選択", + "recommendedHint": "推奨: {{model}}", + "searchModel": "モデルを検索...", "selectModelFirst": "先にモデルを選択", - "noEnabledModels": "有効なモデルがありません", + "noEnabledModels": "設定済みモデルがありません", "noVariantsForModel": "このモデルには思考レベルがありません", - "currentValueNotEnabled": "{{value}} (現在値・未有効)", + "currentValueNotEnabled": "{{value}} (現在値・未設定)", "currentValueUnavailable": "{{value}} (現在値・利用不可)", "advancedLabel": "詳細", "advancedJsonInvalid": "詳細 JSON が不正です", "advancedJsonHint": "temperature, top_p, budgetTokens, prompt_append, permission など。空欄でデフォルトを使用します", - "noEnabledModelsWarning": "利用可能な有効モデルがありません。先に OpenCode モデルを有効化してください。", + "noEnabledModelsWarning": "利用可能な設定済みモデルがありません。先に OpenCode モデルを設定してください。", + "modelSourcePartialWarning": "一部プロバイダーのモデル設定が不正なため、候補から除外しました。", + "modelSourceFallbackWarning": "live プロバイダー状態の取得に失敗したため、設定済みプロバイダーへフォールバックしました。", "importLocalReplaceSuccess": "ローカルファイルから読み込み、Agents/Categories/Other Fields を置き換えました", "importLocalFailed": "ローカルファイルの読み込みに失敗しました: {{error}}", "agentKeyPlaceholder": "agent キー", @@ -1573,7 +1578,7 @@ "modelConfiguration": "モデル設定", "fillRecommended": "推奨を入力", "configSummary": "{{agents}} 個の Agent、{{categories}} 個の Category を設定済み · ⚙ で詳細を展開", - "enabledModelsCount": "有効モデル {{count}} 件", + "enabledModelsCount": "設定済みモデル {{count}} 件", "source": "出典:", "otherFieldsJson": "その他のフィールド (JSON)", "searchOrType": "検索またはカスタム値を入力...", @@ -1599,6 +1604,52 @@ "advancedExperimental": "実験的機能", "advancedBackgroundTask": "バックグラウンドタスク", "advancedBrowserAutomation": "ブラウザ自動化", - "advancedClaudeCode": "Claude Code" + "advancedClaudeCode": "Claude Code", + "agentDesc": { + "sisyphus": "メインオーケストレーター", + "hephaestus": "自律型ディープワーカー", + "prometheus": "戦略プランナー", + "atlas": "タスクマネージャー", + "oracle": "戦略アドバイザー", + "librarian": "マルチリポジトリ研究員", + "explore": "高速コード検索", + "multimodalLooker": "メディアアナライザー", + "metis": "計画前分析アドバイザー", + "momus": "プランレビュアー", + "sisyphusJunior": "委任タスクエグゼキューター" + }, + "agentTooltip": { + "sisyphus": "メインオーケストレーター。タスクの計画、委任、並列実行を担当。拡張思考(32k バジェット)を使用し、TODO リストでワークフローを駆動してタスク完了を確保します。", + "atlas": "メインオーケストレーター(TODO リスト保持)。実行フェーズでのタスク配分と調整を担当。すべての作業を直接行うのではなく、専門エージェントに委任します。", + "prometheus": "戦略プランナー。インタビューモードで要件を収集し、詳細な作業計画を策定。.sisyphus/ ディレクトリ内の Markdown ファイルの読み書きのみ可能で、直接コードを書くことはありません。", + "hephaestus": "自律型ディープワーカー(「正当な職人」)。AmpCode ディープモードに着想を得た目標指向の実行を行い、行動前に 2-5 個の探索/ライブラリアンエージェントを並列起動して調査します。", + "oracle": "アーキテクチャ決定とデバッグのアドバイザー。読み取り専用のコンサルティングエージェントで、優れた論理的推論と深い分析を提供。ファイルの書き込みやタスクの委任はできません。", + "librarian": "マルチリポジトリ分析とドキュメント検索の専門家。コードベースを深く理解し、エビデンスに基づく回答を提供。公式ドキュメントやオープンソース実装例の検索に長けています。", + "explore": "高速コードベース探索とコンテキスト grep の専門家。軽量モデルを使用した高速検索で、コード構造を理解するための先鋒です。", + "multimodalLooker": "ビジュアルコンテンツの専門家。PDF、画像、グラフなどの非テキストメディアを分析し、情報とインサイトを抽出します。", + "metis": "プランコンサルタント。計画前に事前分析を行い、隠れた意図、曖昧な点、AI の失敗ポイントを特定して、過剰エンジニアリングを防止します。", + "momus": "プランレビュアー。計画の明確さ、検証可能性、完全性を高精度で検証。計画が完璧になるまで却下と修正を要求します。", + "sisyphusJunior": "カテゴリ生成のエグゼキューター。category パラメータにより自動生成され、割り当てられたタスクの実行に専念し、再委任はできません。無限委任ループを防止します。" + }, + "categoryDesc": { + "visualEngineering": "ビジュアル/フロントエンド工学", + "ultrabrain": "超深度思考", + "deep": "ディープワーク", + "artistry": "クリエイティブ/芸術", + "quick": "クイックレスポンス", + "unspecifiedLow": "汎用ロースペック", + "unspecifiedHigh": "汎用ハイスペック", + "writing": "ライティング" + }, + "categoryTooltip": { + "visualEngineering": "フロントエンドとビジュアルエンジニアリングカテゴリ。UI/UX デザイン、スタイリング、アニメーション、インターフェース実装に特化。デフォルトで Gemini 3 Pro モデルを使用。", + "ultrabrain": "ディープロジック推論カテゴリ。広範な分析が必要な複雑なアーキテクチャ決定に使用。デフォルトで GPT-5.3 Codex の超高推論バリアントを使用。", + "deep": "ディープ自律問題解決カテゴリ。目標指向の実行で、行動前に徹底的な調査を実施。深い理解が必要な難問に適しています。", + "artistry": "高度にクリエイティブで芸術的なタスクカテゴリ。斬新なアイデアとクリエイティブなソリューションを促進。デフォルトで Gemini 3 Pro の max バリアントを使用。", + "quick": "軽量タスクカテゴリ。単一ファイルの修正、タイポ修正、簡単な調整などの些細な作業に使用。デフォルトで Claude Haiku 4.5 高速モデルを使用。", + "unspecifiedLow": "未分類の低作業量タスクカテゴリ。他のカテゴリに該当せず作業量が小さいタスクに適用。デフォルトで Claude Sonnet 4.5 を使用。", + "unspecifiedHigh": "未分類の高作業量タスクカテゴリ。他のカテゴリに該当せず作業量が大きいタスクに適用。デフォルトで Claude Opus 4.6 の max バリアントを使用。", + "writing": "ライティングカテゴリ。ドキュメント、散文、技術文書に特化。デフォルトで Gemini 3 Flash 高速生成モデルを使用。" + } } } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 350ff83e..e325e9bb 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1572,16 +1572,21 @@ "clearWrapped": "(清空)", "defaultWrapped": "(默认)", "variantPlaceholder": "思考等级", - "selectEnabledModel": "选择已启用模型", + "selectEnabledModel": "选择已配置模型", + "selectModel": "选择已配置模型", + "recommendedHint": "推荐: {{model}}", + "searchModel": "搜索模型...", "selectModelFirst": "先选择模型", - "noEnabledModels": "暂无已启用模型", + "noEnabledModels": "暂无已配置模型", "noVariantsForModel": "该模型无思考等级", - "currentValueNotEnabled": "{{value}}(当前值,未启用)", - "currentValueUnavailable": "{{value}}(当前值,未启用)", + "currentValueNotEnabled": "{{value}}(当前值,未配置)", + "currentValueUnavailable": "{{value}}(当前值,不可用)", "advancedLabel": "高级参数", "advancedJsonInvalid": "高级参数 JSON 无效", "advancedJsonHint": "temperature, top_p, budgetTokens, prompt_append, permission 等,留空使用默认值", - "noEnabledModelsWarning": "当前没有可用的已启用模型,请先启用并配置 OpenCode 模型", + "noEnabledModelsWarning": "当前没有可用的已配置模型,请先配置 OpenCode 模型", + "modelSourcePartialWarning": "部分供应商模型配置无效,已自动跳过。", + "modelSourceFallbackWarning": "读取 live 供应商状态失败,已回退到已配置供应商列表。", "importLocalReplaceSuccess": "已从本地文件导入并覆盖 Agent/Category/Other Fields", "importLocalFailed": "读取本地文件失败: {{error}}", "agentKeyPlaceholder": "agent 键名", @@ -1592,7 +1597,7 @@ "modelConfiguration": "模型配置", "fillRecommended": "填充推荐", "configSummary": "已配置 {{agents}} 个 Agent,{{categories}} 个 Category · 点击 ⚙ 展开高级参数", - "enabledModelsCount": "可选已启用模型 {{count}} 个", + "enabledModelsCount": "可选已配置模型 {{count}} 个", "source": "来源:", "otherFieldsJson": "其他字段 (JSON)", "searchOrType": "搜索或输入自定义值...", @@ -1618,6 +1623,52 @@ "advancedExperimental": "实验性功能", "advancedBackgroundTask": "后台任务", "advancedBrowserAutomation": "浏览器自动化", - "advancedClaudeCode": "Claude Code" + "advancedClaudeCode": "Claude Code", + "agentDesc": { + "sisyphus": "主编排者", + "hephaestus": "自主深度工作者", + "prometheus": "战略规划者", + "atlas": "任务管理者", + "oracle": "战略顾问", + "librarian": "多仓库研究员", + "explore": "快速代码搜索", + "multimodalLooker": "媒体分析器", + "metis": "规划前分析顾问", + "momus": "计划审查者", + "sisyphusJunior": "委托任务执行器" + }, + "agentTooltip": { + "sisyphus": "主编排器,负责任务规划、委派与并行执行,使用扩展思考(32k 预算),通过 TODO 驱动工作流确保任务完成。", + "atlas": "主编排器(持有 TODO 列表),负责执行阶段的任务分发与协调,不直接完成所有工作而是委派给专业代理。", + "prometheus": "战略规划师,通过访谈模式收集需求并制定详细工作计划,仅能在 .sisyphus/ 目录读写 Markdown 文件,从不直接写代码。", + "hephaestus": "自主深度工作者(「合法工匠」),受 AmpCode 深度模式启发,目标导向执行,行动前会并行启动 2-5 个探索/图书管理员代理进行研究。", + "oracle": "架构决策与调试顾问,只读咨询代理,提供出色的逻辑推理和深度分析,不能写文件或委派任务。", + "librarian": "多仓库分析与文档检索专家,深度理解代码库并提供基于证据的答案,擅长查找官方文档和开源实现示例。", + "explore": "快速代码库探索与上下文 grep 专家,使用轻量级模型进行高速搜索,是理解代码结构的先锋。", + "multimodalLooker": "视觉内容专家,分析 PDF、图片、图表等非文本媒体,提取其中的信息与洞察。", + "metis": "计划顾问,在规划前进行预分析,识别隐藏意图、模糊点和 AI 失败点,防止过度工程化。", + "momus": "计划评审员,高精度验证计划的清晰度、可验证性和完整性,拒绝并要求修订直到计划完美。", + "sisyphusJunior": "类别生成的执行器,由 category 参数自动生成,专注于执行分配的任务且不能再委派,防止无限委派循环。" + }, + "categoryDesc": { + "visualEngineering": "视觉/前端工程", + "ultrabrain": "超级思考", + "deep": "深度工作", + "artistry": "创意/文艺", + "quick": "快速响应", + "unspecifiedLow": "通用低配", + "unspecifiedHigh": "通用高配", + "writing": "写作" + }, + "categoryTooltip": { + "visualEngineering": "前端与视觉工程类别,专注于 UI/UX 设计、样式、动画和界面实现,默认使用 Gemini 3 Pro 模型。", + "ultrabrain": "深度逻辑推理类别,用于需要广泛分析的复杂架构决策,默认使用 GPT-5.3 Codex 的超高推理变体。", + "deep": "深度自主问题解决类别,目标导向执行,行动前进行彻底研究,适用于需要深度理解的棘手问题。", + "artistry": "高度创意与艺术性任务类别,激发新颖想法和创造性解决方案,默认使用 Gemini 3 Pro 的最大变体。", + "quick": "轻量任务类别,用于单文件修改、错别字修复、简单调整等琐碎工作,默认使用 Claude Haiku 4.5 快速模型。", + "unspecifiedLow": "未归类低工作量任务类别,适用于不适合其他类别且工作量较小的任务,默认使用 Claude Sonnet 4.5。", + "unspecifiedHigh": "未归类高工作量任务类别,适用于不适合其他类别且工作量较大的任务,默认使用 Claude Opus 4.6 的最大变体。", + "writing": "写作类别,专注于文档、散文和技术写作,默认使用 Gemini 3 Flash 快速生成模型。" + } } } diff --git a/src/types/omo.ts b/src/types/omo.ts index df9102d6..8b605922 100644 --- a/src/types/omo.ts +++ b/src/types/omo.ts @@ -27,8 +27,8 @@ export interface OmoLocalFileData { export interface OmoAgentDef { key: string; display: string; - descZh: string; - descEn: string; + descKey: string; + tooltipKey: string; recommended?: string; group: "main" | "sub"; } @@ -36,97 +36,97 @@ export interface OmoAgentDef { export interface OmoCategoryDef { key: string; display: string; - descZh: string; - descEn: string; + descKey: string; + tooltipKey: string; recommended?: string; } export const OMO_BUILTIN_AGENTS: OmoAgentDef[] = [ { - key: "Sisyphus", + key: "sisyphus", display: "Sisyphus", - descZh: "主编排者", - descEn: "Main orchestrator", + descKey: "omo.agentDesc.sisyphus", + tooltipKey: "omo.agentTooltip.sisyphus", recommended: "claude-opus-4-6", group: "main", }, { - key: "Hephaestus", + key: "hephaestus", display: "Hephaestus", - descZh: "自主深度工作者", - descEn: "Autonomous deep worker", + descKey: "omo.agentDesc.hephaestus", + tooltipKey: "omo.agentTooltip.hephaestus", recommended: "gpt-5.3-codex", group: "main", }, { - key: "Prometheus", + key: "prometheus", display: "Prometheus", - descZh: "战略规划者", - descEn: "Strategic planner", + descKey: "omo.agentDesc.prometheus", + tooltipKey: "omo.agentTooltip.prometheus", recommended: "claude-opus-4-6", group: "main", }, { - key: "Atlas", + key: "atlas", display: "Atlas", - descZh: "任务管理者", - descEn: "Task manager", + descKey: "omo.agentDesc.atlas", + tooltipKey: "omo.agentTooltip.atlas", recommended: "kimi-k2.5", group: "main", }, { key: "oracle", display: "Oracle", - descZh: "战略顾问", - descEn: "Strategic advisor", + descKey: "omo.agentDesc.oracle", + tooltipKey: "omo.agentTooltip.oracle", recommended: "gpt-5.3", group: "sub", }, { key: "librarian", display: "Librarian", - descZh: "多仓库研究员", - descEn: "Multi-repo researcher", + descKey: "omo.agentDesc.librarian", + tooltipKey: "omo.agentTooltip.librarian", recommended: "glm-4.7", group: "sub", }, { key: "explore", display: "Explore", - descZh: "快速代码搜索", - descEn: "Fast code search", + descKey: "omo.agentDesc.explore", + tooltipKey: "omo.agentTooltip.explore", recommended: "grok-code-fast-1", group: "sub", }, { key: "multimodal-looker", display: "Multimodal-Looker", - descZh: "媒体分析器", - descEn: "Media analyzer", + descKey: "omo.agentDesc.multimodalLooker", + tooltipKey: "omo.agentTooltip.multimodalLooker", recommended: "gemini-3-flash", group: "sub", }, { - key: "Metis", + key: "metis", display: "Metis", - descZh: "规划前分析顾问", - descEn: "Pre-plan analysis advisor", + descKey: "omo.agentDesc.metis", + tooltipKey: "omo.agentTooltip.metis", recommended: "claude-opus-4-6", group: "sub", }, { - key: "Momus", + key: "momus", display: "Momus", - descZh: "计划审查者", - descEn: "Plan reviewer", + descKey: "omo.agentDesc.momus", + tooltipKey: "omo.agentTooltip.momus", recommended: "gpt-5.3", group: "sub", }, { - key: "Sisyphus-Junior", + key: "sisyphus-junior", display: "Sisyphus-Junior", - descZh: "委托任务执行器", - descEn: "Delegated task executor", + descKey: "omo.agentDesc.sisyphusJunior", + tooltipKey: "omo.agentTooltip.sisyphusJunior", group: "sub", }, ]; @@ -135,57 +135,57 @@ export const OMO_BUILTIN_CATEGORIES: OmoCategoryDef[] = [ { key: "visual-engineering", display: "Visual Engineering", - descZh: "视觉/前端工程", - descEn: "Visual/frontend engineering", + descKey: "omo.categoryDesc.visualEngineering", + tooltipKey: "omo.categoryTooltip.visualEngineering", recommended: "gemini-3-pro", }, { key: "ultrabrain", display: "Ultrabrain", - descZh: "超级思考", - descEn: "Ultra thinking", + descKey: "omo.categoryDesc.ultrabrain", + tooltipKey: "omo.categoryTooltip.ultrabrain", recommended: "claude-opus-4-6", }, { key: "deep", display: "Deep", - descZh: "深度工作", - descEn: "Deep work", + descKey: "omo.categoryDesc.deep", + tooltipKey: "omo.categoryTooltip.deep", recommended: "gpt-5.3-codex", }, { key: "artistry", display: "Artistry", - descZh: "创意/文艺", - descEn: "Creative/artistic", + descKey: "omo.categoryDesc.artistry", + tooltipKey: "omo.categoryTooltip.artistry", recommended: "claude-opus-4-6", }, { key: "quick", display: "Quick", - descZh: "快速响应", - descEn: "Quick response", + descKey: "omo.categoryDesc.quick", + tooltipKey: "omo.categoryTooltip.quick", recommended: "gemini-3-flash", }, { key: "unspecified-low", display: "Unspecified Low", - descZh: "通用低配", - descEn: "General low tier", + descKey: "omo.categoryDesc.unspecifiedLow", + tooltipKey: "omo.categoryTooltip.unspecifiedLow", recommended: "gemini-3-flash", }, { key: "unspecified-high", display: "Unspecified High", - descZh: "通用高配", - descEn: "General high tier", + descKey: "omo.categoryDesc.unspecifiedHigh", + tooltipKey: "omo.categoryTooltip.unspecifiedHigh", recommended: "gpt-5.3-codex", }, { key: "writing", display: "Writing", - descZh: "写作", - descEn: "Writing", + descKey: "omo.categoryDesc.writing", + tooltipKey: "omo.categoryTooltip.writing", recommended: "claude-opus-4-6", }, ]; @@ -316,6 +316,17 @@ export const OMO_CLAUDE_CODE_PLACEHOLDER = `{ "plugins": true }`; +export function parseOmoOtherFieldsObject( + raw: string, +): Record | undefined { + if (!raw.trim()) return undefined; + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return undefined; + } + return parsed as Record; +} + export function mergeOmoConfigPreview( global: OmoGlobalConfig, agents: Record>, @@ -351,7 +362,8 @@ export function mergeOmoConfigPreview( if (Object.keys(agents).length > 0) result["agents"] = agents; if (Object.keys(categories).length > 0) result["categories"] = categories; try { - const other = JSON.parse(otherFieldsStr || "{}"); + const other = parseOmoOtherFieldsObject(otherFieldsStr); + if (!other) return result; for (const [k, v] of Object.entries(other)) { result[k] = v; } diff --git a/tests/components/OmoFormFields.mergeCustomModelsIntoStore.test.ts b/tests/components/OmoFormFields.mergeCustomModelsIntoStore.test.ts new file mode 100644 index 00000000..b77b79e6 --- /dev/null +++ b/tests/components/OmoFormFields.mergeCustomModelsIntoStore.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { + mergeCustomModelsIntoStore, + type CustomModelItem, +} from "@/components/providers/forms/OmoFormFields"; + +describe("mergeCustomModelsIntoStore", () => { + it("保留自定义项高级字段,并在模型变更时仅按需清理非法 variant", () => { + const store = { + sisyphus: { model: "builtin-model" }, + "custom-agent": { + model: "model-a", + variant: "fast", + temperature: 0.2, + permission: { edit: "allow" }, + }, + }; + const customs: CustomModelItem[] = [ + { key: "custom-agent", model: "model-b", sourceKey: "custom-agent" }, + ]; + + const merged = mergeCustomModelsIntoStore( + store, + new Set(["sisyphus"]), + customs, + { "model-b": ["precise"] }, + ); + + expect(merged.sisyphus).toEqual({ model: "builtin-model" }); + expect(merged["custom-agent"]).toEqual({ + model: "model-b", + temperature: 0.2, + permission: { edit: "allow" }, + }); + }); + + it("重命名自定义 key 时迁移原有 variant 和高级字段", () => { + const store = { + sisyphus: { model: "builtin-model" }, + "custom-agent-old": { + model: "model-a", + variant: "fast", + maxTokens: 8192, + }, + }; + const customs: CustomModelItem[] = [ + { + key: "custom-agent-new", + sourceKey: "custom-agent-old", + model: "model-a", + }, + ]; + + const merged = mergeCustomModelsIntoStore( + store, + new Set(["sisyphus"]), + customs, + { "model-a": ["fast", "balanced"] }, + ); + + expect(merged["custom-agent-old"]).toBeUndefined(); + expect(merged["custom-agent-new"]).toEqual({ + model: "model-a", + variant: "fast", + maxTokens: 8192, + }); + }); + + it("custom 列表为空时移除旧自定义项但保留内置项", () => { + const store = { + sisyphus: { model: "builtin-model" }, + hephaestus: { model: "builtin-model-2" }, + "custom-agent": { model: "model-a", temperature: 0.3 }, + }; + + const merged = mergeCustomModelsIntoStore( + store, + new Set(["sisyphus", "hephaestus"]), + [], + {}, + ); + + expect(merged).toEqual({ + sisyphus: { model: "builtin-model" }, + hephaestus: { model: "builtin-model-2" }, + }); + }); + + it("清空 model 时保留高级字段并移除 model/variant", () => { + const store = { + sisyphus: { model: "builtin-model" }, + "custom-agent": { + model: "model-a", + variant: "fast", + temperature: 0.7, + }, + }; + const customs: CustomModelItem[] = [ + { key: "custom-agent", model: "", sourceKey: "custom-agent" }, + ]; + + const merged = mergeCustomModelsIntoStore( + store, + new Set(["sisyphus"]), + customs, + { "model-a": ["fast"] }, + ); + + expect(merged["custom-agent"]).toEqual({ temperature: 0.7 }); + }); +}); diff --git a/tests/utils/omoConfig.test.ts b/tests/utils/omoConfig.test.ts new file mode 100644 index 00000000..5a5de6c9 --- /dev/null +++ b/tests/utils/omoConfig.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { + mergeOmoConfigPreview, + parseOmoOtherFieldsObject, + type OmoGlobalConfig, +} from "@/types/omo"; + +const EMPTY_GLOBAL: OmoGlobalConfig = { + id: "global", + disabledAgents: [], + disabledMcps: [], + disabledHooks: [], + disabledSkills: [], + updatedAt: "2026-01-01T00:00:00.000Z", +}; + +describe("parseOmoOtherFieldsObject", () => { + it("解析对象 JSON", () => { + expect(parseOmoOtherFieldsObject('{ "foo": 1 }')).toEqual({ foo: 1 }); + }); + + it("数组/字符串返回 undefined", () => { + expect(parseOmoOtherFieldsObject('["a"]')).toBeUndefined(); + expect(parseOmoOtherFieldsObject('"hello"')).toBeUndefined(); + }); + + it("非法 JSON 抛出异常", () => { + expect(() => parseOmoOtherFieldsObject("{")).toThrow(); + }); +}); + +describe("mergeOmoConfigPreview", () => { + it("只合并 otherFields 的对象值,忽略数组", () => { + const mergedFromArray = mergeOmoConfigPreview( + EMPTY_GLOBAL, + {}, + {}, + '["a", "b"]', + ); + expect(mergedFromArray).toEqual({}); + + const mergedFromObject = mergeOmoConfigPreview( + EMPTY_GLOBAL, + {}, + {}, + '{ "foo": "bar" }', + ); + expect(mergedFromObject).toEqual({ foo: "bar" }); + }); +});