feat(omo): add model/variant dropdowns from enabled providers

Replace model text inputs with Select dropdowns sourced from enabled
OpenCode providers, add thinking-level variant selection, and prevent
auto-enabling newly added OMO providers.
This commit is contained in:
YoVinchen
2026-02-08 23:07:49 +08:00
Unverified
parent 5490a540f1
commit da27e22f77
4 changed files with 386 additions and 30 deletions
+2 -4
View File
@@ -166,10 +166,8 @@ impl ProviderService {
if matches!(app_type, AppType::OpenCode) {
// OMO providers use exclusive mode and write to dedicated config file.
if provider.category.as_deref() == Some("omo") {
state
.db
.set_omo_provider_current(app_type.as_str(), &provider.id)?;
crate::services::OmoService::write_config_to_file(state)?;
// Do not auto-enable newly added OMO providers.
// Users must explicitly switch/apply an OMO provider to activate it.
return Ok(true);
}
write_live_snapshot(&app_type, &provider)?;
+252 -24
View File
@@ -5,6 +5,13 @@ import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Plus,
Trash2,
@@ -34,6 +41,8 @@ const ADVANCED_PLACEHOLDER = `{
}`;
interface OmoFormFieldsProps {
modelOptions: Array<{ value: string; label: string }>;
modelVariantsMap?: Record<string, string[]>;
agents: Record<string, Record<string, unknown>>;
onAgentsChange: (agents: Record<string, Record<string, unknown>>) => void;
categories: Record<string, Record<string, unknown>>;
@@ -49,15 +58,20 @@ type BuiltinModelDef = Pick<
OmoAgentDef | OmoCategoryDef,
"key" | "display" | "descZh" | "descEn" | "recommended"
>;
type ModelOption = { value: string; label: string };
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 getAdvancedStr(config: Record<string, unknown> | undefined): string {
if (!config) return "";
const adv: Record<string, unknown> = {};
for (const [k, v] of Object.entries(config)) {
if (k !== "model") adv[k] = v;
if (k !== "model" && k !== "variant") adv[k] = v;
}
return Object.keys(adv).length > 0 ? JSON.stringify(adv, null, 2) : "";
}
@@ -96,6 +110,8 @@ function mergeCustomModelsIntoStore(
}
export function OmoFormFields({
modelOptions,
modelVariantsMap = {},
agents,
onAgentsChange,
categories,
@@ -158,6 +174,144 @@ export function OmoFormFields({
[categories, onCategoriesChange],
);
const buildEffectiveModelOptions = useCallback(
(currentModel: string): ModelOption[] => {
if (!currentModel) return modelOptions;
if (modelOptions.some((item) => item.value === currentModel)) {
return modelOptions;
}
return [
{
value: currentModel,
label: isZh
? `${currentModel}(当前值,未启用)`
: `${currentModel} (current value, not enabled)`,
},
...modelOptions,
];
},
[isZh, modelOptions],
);
const resolveRecommendedModel = useCallback(
(recommended?: string): string | undefined => {
if (!recommended || modelOptions.length === 0) return undefined;
const exact = modelOptions.find((item) => item.value === recommended);
if (exact) return exact.value;
const bySuffix = modelOptions.find((item) =>
item.value.endsWith(`/${recommended}`),
);
return bySuffix?.value;
},
[modelOptions],
);
const renderModelSelect = (
currentModel: string,
onChange: (value: string) => void,
placeholder?: string,
) => {
const options = buildEffectiveModelOptions(currentModel);
return (
<Select
value={currentModel || EMPTY_MODEL_VALUE}
onValueChange={(value) =>
onChange(value === EMPTY_MODEL_VALUE ? "" : value)
}
>
<SelectTrigger className="flex-1 h-8 text-sm">
<SelectValue
placeholder={
placeholder || (isZh ? "选择已启用模型" : "Select enabled model")
}
/>
</SelectTrigger>
<SelectContent className="max-h-72">
<SelectItem value={EMPTY_MODEL_VALUE}>
{isZh ? "(清空)" : "(Clear)"}
</SelectItem>
{options.length === 0 ? (
<SelectItem value={UNAVAILABLE_MODEL_VALUE} disabled>
{isZh ? "暂无已启用模型" : "No enabled models"}
</SelectItem>
) : (
options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
);
};
const buildEffectiveVariantOptions = useCallback(
(currentModel: string, currentVariant: string): string[] => {
const variantKeys = modelVariantsMap[currentModel] || [];
if (!currentVariant || variantKeys.includes(currentVariant)) {
return variantKeys;
}
return [currentVariant, ...variantKeys];
},
[modelVariantsMap],
);
const renderVariantSelect = (
currentModel: string,
currentVariant: string,
onChange: (value: string) => void,
) => {
const variantOptions = buildEffectiveVariantOptions(
currentModel,
currentVariant,
);
const hasModel = Boolean(currentModel);
const firstIsUnavailable =
Boolean(currentVariant) &&
!(modelVariantsMap[currentModel] || []).includes(currentVariant);
return (
<Select
value={currentVariant || EMPTY_VARIANT_VALUE}
onValueChange={(value) =>
onChange(value === EMPTY_VARIANT_VALUE ? "" : value)
}
disabled={!hasModel}
>
<SelectTrigger className="w-32 h-8 text-xs shrink-0">
<SelectValue placeholder="variant" />
</SelectTrigger>
<SelectContent className="max-h-72">
<SelectItem value={EMPTY_VARIANT_VALUE}>
{isZh ? "(默认)" : "(Default)"}
</SelectItem>
{!hasModel ? (
<SelectItem value={UNAVAILABLE_VARIANT_VALUE} disabled>
{isZh ? "先选择模型" : "Select model first"}
</SelectItem>
) : variantOptions.length === 0 ? (
<SelectItem value={UNAVAILABLE_VARIANT_VALUE} disabled>
{isZh ? "该模型无思考等级" : "No variants for model"}
</SelectItem>
) : (
variantOptions.map((variant, index) => (
<SelectItem key={`${variant}-${index}`} value={variant}>
{firstIsUnavailable && index === 0
? isZh
? `${variant}(当前值,未启用)`
: `${variant} (current value, unavailable)`
: variant}
</SelectItem>
))
)}
</SelectContent>
</Select>
);
};
const handleModelChange = (
key: string,
model: string,
@@ -165,12 +319,25 @@ export function OmoFormFields({
setter: (v: Record<string, Record<string, unknown>>) => void,
) => {
if (model.trim()) {
setter({ ...store, [key]: { ...store[key], model } });
const nextEntry: Record<string, unknown> = {
...(store[key] || {}),
model,
};
const currentVariant =
typeof nextEntry.variant === "string" ? nextEntry.variant : "";
if (currentVariant) {
const validVariants = modelVariantsMap[model] || [];
if (!validVariants.includes(currentVariant)) {
delete nextEntry.variant;
}
}
setter({ ...store, [key]: nextEntry });
} else {
const existing = store[key];
if (existing) {
const adv = { ...existing };
delete adv.model;
delete adv.variant;
if (Object.keys(adv).length > 0) {
setter({ ...store, [key]: adv });
} else {
@@ -182,6 +349,31 @@ export function OmoFormFields({
}
};
const handleVariantChange = (
key: string,
variant: string,
store: Record<string, Record<string, unknown>>,
setter: (v: Record<string, Record<string, unknown>>) => void,
) => {
const existing = store[key];
if (variant.trim()) {
setter({ ...store, [key]: { ...existing, variant } });
return;
}
if (!existing) return;
const nextEntry = { ...existing };
delete nextEntry.variant;
if (Object.keys(nextEntry).length > 0) {
setter({ ...store, [key]: nextEntry });
return;
}
const next = { ...store };
delete next[key];
setter(next);
};
const handleAdvancedChange = (
key: string,
rawJson: string,
@@ -189,9 +381,16 @@ export function OmoFormFields({
setter: (v: Record<string, Record<string, unknown>>) => void,
): boolean => {
const currentModel = (store[key]?.model as string) || "";
const currentVariant = (store[key]?.variant as string) || "";
if (!rawJson.trim()) {
if (currentModel) {
setter({ ...store, [key]: { model: currentModel } });
if (currentModel || currentVariant) {
setter({
...store,
[key]: {
...(currentModel ? { model: currentModel } : {}),
...(currentVariant ? { variant: currentVariant } : {}),
},
});
} else {
const next = { ...store };
delete next[key];
@@ -206,11 +405,15 @@ export function OmoFormFields({
parsed !== null &&
!Array.isArray(parsed)
) {
const parsedAdvanced = { ...(parsed as Record<string, unknown>) };
delete parsedAdvanced.model;
delete parsedAdvanced.variant;
setter({
...store,
[key]: {
...(currentModel ? { model: currentModel } : {}),
...parsed,
...(currentVariant ? { variant: currentVariant } : {}),
...parsedAdvanced,
},
});
return true;
@@ -300,7 +503,7 @@ export function OmoFormFields({
}
}}
placeholder={ADVANCED_PLACEHOLDER}
className="font-mono text-xs min-h-[80px]"
className="font-mono text-xs min-h-[130px] py-3"
/>
{showHint && (
<p className="text-[10px] text-muted-foreground mt-1">
@@ -313,12 +516,22 @@ export function OmoFormFields({
);
const handleFillAllRecommended = () => {
if (modelOptions.length === 0) {
toast.warning(
isZh
? "当前没有可用的已启用模型,请先启用并配置 OpenCode 模型"
: "No enabled models available. Configure and enable OpenCode models first.",
);
return;
}
const updatedAgents = { ...agents };
for (const agentDef of OMO_BUILTIN_AGENTS) {
if (agentDef.recommended && !updatedAgents[agentDef.key]?.model) {
const recommendedValue = resolveRecommendedModel(agentDef.recommended);
if (recommendedValue && !updatedAgents[agentDef.key]?.model) {
updatedAgents[agentDef.key] = {
...updatedAgents[agentDef.key],
model: agentDef.recommended,
model: recommendedValue,
};
}
}
@@ -326,10 +539,11 @@ export function OmoFormFields({
const updatedCategories = { ...categories };
for (const catDef of OMO_BUILTIN_CATEGORIES) {
if (catDef.recommended && !updatedCategories[catDef.key]?.model) {
const recommendedValue = resolveRecommendedModel(catDef.recommended);
if (recommendedValue && !updatedCategories[catDef.key]?.model) {
updatedCategories[catDef.key] = {
...updatedCategories[catDef.key],
model: catDef.recommended,
model: recommendedValue,
};
}
}
@@ -399,6 +613,7 @@ export function OmoFormFields({
const key = def.key;
const currentModel = (store[key]?.model as string) || "";
const currentVariant = (store[key]?.variant as string) || "";
const advStr = getAdvancedStr(store[key]);
const draftValue = drafts[key] ?? advStr;
const isExpanded = expanded[key] ?? false;
@@ -412,14 +627,14 @@ export function OmoFormFields({
{isZh ? def.descZh : def.descEn}
</div>
</div>
<Input
value={currentModel}
onChange={(e) =>
handleModelChange(key, e.target.value, store, setter)
}
placeholder={def.recommended || "model-name"}
className="flex-1 h-8 text-sm"
/>
{renderModelSelect(
currentModel,
(value) => handleModelChange(key, value, store, setter),
def.recommended,
)}
{renderVariantSelect(currentModel, currentVariant, (value) =>
handleVariantChange(key, value, store, setter),
)}
<Button
type="button"
variant={isExpanded ? "secondary" : "ghost"}
@@ -476,6 +691,10 @@ export function OmoFormFields({
: "category key";
const key = item.key || `${emptyKeyPrefix}${index}`;
const currentVariant =
item.key && typeof store[item.key]?.variant === "string"
? (store[item.key]?.variant as string) || ""
: "";
const advStr = item.key ? getAdvancedStr(store[item.key]) : "";
const draftValue = drafts[key] ?? advStr;
const isExpanded = expanded[key] ?? false;
@@ -499,12 +718,15 @@ export function OmoFormFields({
placeholder={keyPlaceholder}
className="w-32 shrink-0 h-8 text-sm text-primary"
/>
<Input
value={item.model}
onChange={(e) => updateCustom({ model: e.target.value })}
placeholder="model-name"
className="flex-1 h-8 text-sm"
/>
{renderModelSelect(
item.model,
(value) => updateCustom({ model: value }),
"model-name",
)}
{renderVariantSelect(item.model, currentVariant, (value) => {
if (!item.key) return;
handleVariantChange(item.key, value, store, setter);
})}
<Button
type="button"
variant={isExpanded ? "secondary" : "ghost"}
@@ -687,6 +909,12 @@ export function OmoFormFields({
{isZh
? `已配置 ${configuredAgentCount} 个 Agent${configuredCategoryCount} 个 Category · 点击 ⚙ 展开高级参数`
: `${configuredAgentCount} agents, ${configuredCategoryCount} categories configured · Click ⚙ for advanced params`}
<span className="ml-1">
·{" "}
{isZh
? `可选已启用模型 ${modelOptions.length}`
: `${modelOptions.length} enabled models available`}
</span>
{localFilePath && (
<span className="ml-1 text-primary/70">
· {isZh ? "来源:" : "from:"}{" "}
@@ -173,7 +173,11 @@ function TagListEditor({
{values.length > 0 && (
<div className="flex flex-wrap gap-1">
{values.map((v, i) => (
<Badge key={`${v}-${i}`} variant="secondary" className="text-xs gap-1">
<Badge
key={`${v}-${i}`}
variant="secondary"
className="text-xs gap-1"
>
{v}
<button
type="button"
+127 -1
View File
@@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button";
import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
import type { AppId } from "@/lib/api";
import { providersApi, type AppId } from "@/lib/api";
import type {
ProviderCategory,
ProviderMeta,
@@ -579,6 +579,130 @@ export function ProviderForm({
(k) => k !== providerId,
);
}, [opencodeProvidersData?.providers, providerId]);
const [enabledOpencodeProviderIds, setEnabledOpencodeProviderIds] = useState<
string[]
>([]);
useEffect(() => {
let active = true;
if (!isOmoCategory) {
setEnabledOpencodeProviderIds([]);
return () => {
active = false;
};
}
(async () => {
try {
const ids = await providersApi.getOpenCodeLiveProviderIds();
if (active) {
setEnabledOpencodeProviderIds(ids);
}
} catch (error) {
console.error("Failed to load OpenCode live provider ids:", error);
if (active) {
setEnabledOpencodeProviderIds([]);
}
}
})();
return () => {
active = false;
};
}, [isOmoCategory]);
const omoModelOptions = useMemo(() => {
if (!isOmoCategory) return [];
const allProviders = opencodeProvidersData?.providers;
if (!allProviders) return [];
const enabledSet = new Set(enabledOpencodeProviderIds);
if (enabledSet.size === 0) return [];
const dedupedOptions = new Map<string, string>();
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 modelName =
typeof model.name === "string" && model.name.trim()
? model.name
: modelId;
const providerDisplayName =
typeof provider.name === "string" && provider.name.trim()
? provider.name
: providerKey;
const value = `${providerKey}/${modelId}`;
const label = `${providerDisplayName} / ${modelName} (${modelId})`;
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<string, string[]> = {};
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)
) {
continue;
}
const variantKeys = Object.keys(rawVariants).filter(Boolean);
if (variantKeys.length === 0) {
continue;
}
variantsMap[`${providerKey}/${modelId}`] = variantKeys;
}
}
return variantsMap;
}, [
isOmoCategory,
opencodeProvidersData?.providers,
enabledOpencodeProviderIds,
]);
const initialOmoSettings =
appId === "opencode" && initialData?.category === "omo"
@@ -1477,6 +1601,8 @@ export function ProviderForm({
{appId === "opencode" && category === "omo" && (
<OmoFormFields
modelOptions={omoModelOptions}
modelVariantsMap={omoModelVariantsMap}
agents={omoAgents}
onAgentsChange={setOmoAgents}
categories={omoCategories}