Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions apps/desktop/src/components/settings/ImageGenerationModelRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,12 @@ export function ImageGenerationModelRow({
}) {
const { t } = useTranslation();
const binding = settings.imageGeneration;
const candidates = imageGenerationBindings(settings.imageGenerationModels, binding);
// An explicit candidate list is the user's selection. Do not put the stored
// default back when they cleared it; only a missing list is the legacy
// single-binding fallback.
const candidates = Array.isArray(settings.imageGenerationModels)
? imageGenerationBindings(settings.imageGenerationModels, null)
: imageGenerationBindings(undefined, binding);
if (candidates.length === 0) return null;

const activeCandidate = binding
Expand All @@ -40,8 +45,8 @@ export function ImageGenerationModelRow({
: undefined;
// The same availability rule that decides whether this binding may stay the
// app default, so the row can never claim a pairing the runtime rejects.
const valid = binding !== null && binding !== undefined &&
imageGenerationBindingAvailable(provider, binding.modelId);
const valid = !!activeCandidate &&
imageGenerationBindingAvailable(provider, activeCandidate.modelId);
const options = candidates.map((candidate) => {
const candidateProvider = providers.find((entry) => entry.id === candidate.providerId);
const available = imageGenerationBindingAvailable(
Expand All @@ -54,28 +59,34 @@ export function ImageGenerationModelRow({
disabled: !available,
};
});
// Disabled rows are not choices. With none left, the summary stays hidden
// instead of showing a checked model the user can no longer pick.
if (!options.some((option) => !option.disabled)) return null;
const checkedId = valid && activeCandidate ? imageModelOptionId(activeCandidate) : "";

return (
<div className="settings-row model-default-row model-image-row">
<div className="settings-row-copy model-default-copy">
<div className="settings-row-title model-default-label">{t("settings.imageModel")}</div>
<div className="settings-row-detail model-default-value">
{valid && provider ? (
{valid && provider && binding ? (
<>
<span className="model-default-provider">{provider.name}</span>
<span className="model-default-sep" aria-hidden>·</span>
<span className="model-default-model font-mono">{binding.modelId}</span>
</>
) : (
<span className="model-default-empty" role="status">{t("settings.imageModelUnavailable")}</span>
<span className="model-default-empty" role="status">
{t(activeCandidate ? "settings.imageModelUnavailable" : "settings.imageModelUnset")}
</span>
)}
</div>
</div>
{onChange && candidates.length > 1 ? (
{onChange && (options.filter((option) => !option.disabled).length > 1 || !checkedId) ? (
<SettingsMenuSelect
className="model-image-selector"
label={t("settings.imageModel")}
value={activeCandidate ? imageModelOptionId(activeCandidate) : ""}
value={checkedId}
options={options}
busy={busy}
onChange={(id) => {
Expand Down
9 changes: 6 additions & 3 deletions apps/desktop/src/components/settings/ProviderSetupDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useTranslation } from "react-i18next";
import {
NAMED_ENDPOINT_PRESETS,
OPENCODE_GO_API_STYLE,
modelIdsMatch,
normalizeApiStyle,
type CatalogApiStyle,
type ModelBinding,
Expand Down Expand Up @@ -267,7 +268,7 @@ export function ProviderSetupDialog({
// existing save path when the image selection did not change.
const imageSelection = imageModelDraft ?? imageModelIds;
const remainingImageModels = imageSelection?.filter((imageModelId) =>
persisted.some((model) => model.id === imageModelId),
persisted.some((model) => modelIdsMatch(model.id, imageModelId)),
);
const imageModelIdsToSave = imageModelDraft !== undefined ||
remainingImageModels?.length !== imageSelection?.length
Expand Down Expand Up @@ -315,8 +316,10 @@ export function ProviderSetupDialog({
const updateImageModelDraft = (id: string, selected: boolean) => {
setImageModelDraft((current) => {
const next = current ?? imageModelIds ?? [];
if (selected) return next.includes(id) ? next : [...next, id];
return next.filter((entry) => entry !== id);
if (selected) {
return next.some((entry) => modelIdsMatch(entry, id)) ? next : [...next, id];
}
return next.filter((entry) => !modelIdsMatch(entry, id));
});
};

Expand Down
16 changes: 10 additions & 6 deletions apps/desktop/src/components/settings/image-generation-default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,13 @@ function cappedImageGenerationCandidates(
*
* The saved provider's selection replaces its own earlier candidates, while the
* candidates of other providers stay listed — minus the rows whose provider no
* longer exists, which nothing can pick or run. The active default moves only
* when explicitly deselected or no longer runnable, and then to the first candidate
* that is, so a newly added provider claims the default exactly when nothing
* else can hold it. When nothing can, the default stays empty rather than
* naming a binding that would fail on the next request.
* The active default moves only when explicitly deselected or no longer runnable,
* and then to the first candidate that is, so a newly added provider claims the
* default exactly when nothing else can hold it. Clearing every image model on
* the provider that holds the default leaves it unchecked instead: another
* provider's candidate stays available, but it is not checked automatically.
* When nothing can run, the default stays empty rather than naming a binding
* that would fail on the next request.
*/
export function planImageGenerationDefaults(
current: ImageGenerationDefaultDraft,
Expand Down Expand Up @@ -131,9 +133,11 @@ export function planImageGenerationDefaults(
const fallback = imageGenerationModels.find((binding) =>
resolvesImageGenerationDefault(binding, providers)
) ?? null;
const clearedActiveProvider =
selected.length === 0 && previous?.providerId === savedProviderId;
return {
imageGenerationModels,
imageGeneration: removedDefaultModel
imageGeneration: removedDefaultModel || clearedActiveProvider
? null
: resolvesImageGenerationDefault(active, providers) ? active : fallback,
};
Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/test/image-generation-default.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -343,3 +343,17 @@ test("saving the active provider preserves a still-selected default", () => {
);
assert.deepEqual(plan.imageGeneration, binding("x", "current"));
});

test("unchecking every image model on the active provider clears the settings check", () => {
const plan = planImageGenerationDefaults(
{
imageGenerationModels: [binding("x", "img-x"), binding("y", "img-y")],
imageGeneration: binding("x", "img-x"),
},
"x",
[],
[provider("x", ["img-x"]), provider("y", ["img-y"])],
);
assert.equal(plan.imageGeneration, null);
assert.deepEqual(plan.imageGenerationModels, [binding("y", "img-y")]);
});
5 changes: 3 additions & 2 deletions apps/desktop/test/model-advanced-capabilities.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ test("the capability row carries no explanatory copy or extra controls", () => {
});


test("image generation selection is an attachment capability and keeps an unavailable row when unbound", () => {
test("image generation selection hides the summary when nothing can be chosen", () => {
assert.match(
pickerSource,
/className="provider-chosen-capability-rows">[\s\S]*?settings\.setImageModel/,
Expand All @@ -90,7 +90,8 @@ test("image generation selection is an attachment capability and keeps an unavai
);
assert.match(pickerSource, /imageModelIds\?\.some\([\s\S]*?modelIdsMatch/);
assert.match(pickerSource, /onImageModelChange\(binding\.id, event\.target\.checked\)/);
assert.match(imageModelRowSource, /imageGenerationBindings\(settings\.imageGenerationModels, binding\)/);
assert.match(imageModelRowSource, /imageGenerationBindings\(settings\.imageGenerationModels, null\)/);
assert.match(imageModelRowSource, /if \(!options\.some\(\(option\) => !option\.disabled\)\) return null;/);
assert.match(imageModelRowSource, /if \(candidates\.length === 0\) return null;/);
assert.match(imageModelRowSource, /imageModelUnavailable/);
});
Expand Down
2 changes: 1 addition & 1 deletion docs/spec/03-runtime/21-image-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ image capabilities were marked or unmarked. It must not claim that an image
model was selected after deselection. Choosing an image default from the
summary menu retains the image-selection confirmation.

Model Advanced exposes **Set as image model** alongside the image and document attachment capabilities in the model capability group, not as a separate control row. The checkbox is multi-select: saving a provider persists every checked model in `imageGenerationModels`; Cancel leaves settings unchanged. Saving candidates does not replace the default conversation model. Unchecking the current image model and saving removes its default binding: the default becomes null, even when another runnable candidate remains. The unmarked model is available for chat again after saving and reopening settings. Saving another provider preserves a still-runnable image default. Below the default model row in the same defaults panel, **Image generation model** shows the current default and offers a menu to choose one from all marked candidates. When no candidate is configured, the summary row is hidden. An existing candidate that is missing, disabled, credential-less or removed displays only **Currently unavailable**. OAuth accounts are not eligible; there is no fallback.
Model Advanced exposes **Set as image model** alongside the image and document attachment capabilities in the model capability group, not as a separate control row. The checkbox is multi-select: saving a provider persists every checked model in `imageGenerationModels`; Cancel leaves settings unchanged. Saving candidates does not replace the default conversation model. Unchecking every image model on the provider that holds the current default clears that default, even when another provider still has a runnable candidate. The Models page drops the check and does not select the other candidate automatically. Unchecking the current model while another model on the same provider stays marked moves the default to the first runnable marked candidate. The unmarked model is available for chat again after saving and reopening settings. Saving another provider preserves a still-runnable image default. Below the default model row in the same defaults panel, **Image generation model** shows the current default and offers a menu to choose one from all marked candidates. When no candidate is configured, or none of them can be selected, the summary row is hidden. An existing candidate that is missing, disabled, credential-less or removed displays only **Currently unavailable** while another marked candidate can still be selected. OAuth accounts are not eligible; there is no fallback.
All marked provider/model pairs are excluded from the default conversation picker, provider quick-default action, and Composer model menu. Other providers with the same model ID remain independent. Existing conversation bindings and history are preserved; a conversation still pinned to any image candidate must select a chat model before sending. Runtime launch rejects every marked image model before inference.

### Provider model removal
Expand Down
2 changes: 1 addition & 1 deletion docs/zh-CN/spec/03-runtime/21-image-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
或“服务已更新”,避免取消标记后仍声称“已设为生图模型”。
从生图摘要菜单选择默认模型时,仍显示生图选择成功提示。

模型高级设置将“设为生图模型”与图片、文档附件能力放在同一能力组中,不再单独占一行;复选框支持多选,保存服务商表单后把所有勾选模型写入 `imageGenerationModels`,取消不会改变设置。保存候选不会替换默认对话模型。取消勾选当前生图模型并保存后,默认生图绑定清空,即使仍有其他可用候选。取消标记的模型恢复为可选对话模型,重新打开设置后仍保持这一状态。保存其他服务商时保留仍可用的默认生图模型。默认模型下方同一面板中的“生图模型”显示当前默认,并提供菜单从所有已标记候选中选择一个。没有候选时隐藏摘要行;已存在但服务商停用、缺少凭据或模型移除的候选仅显示“暂不可用”。OAuth 账户不适用,也不会自动回退。
模型高级设置将“设为生图模型”与图片、文档附件能力放在同一能力组中,不再单独占一行;复选框支持多选,保存服务商表单后把所有勾选模型写入 `imageGenerationModels`,取消不会改变设置。保存候选不会替换默认对话模型。若当前默认生图模型所属服务商的生图勾选被全部取消,则清空该默认,即使其他服务商仍有可运行候选;模型页取消勾选,且不会自动改选其他候选。同一服务商仍保留其他生图勾选时,默认改到第一个可运行的已标记候选。取消标记的模型恢复为可选对话模型,重新打开设置后仍保持这一状态。保存其他服务商时保留仍可用的默认生图模型。默认模型下方同一面板中的“生图模型”显示当前默认,并提供菜单从所有已标记候选中选择一个。没有候选,或没有任何可选项时,隐藏摘要行。仍有其他可选候选时,已存在但服务商停用、缺少凭据或模型移除的当前默认仅显示“暂不可用”。OAuth 账户不适用,也不会自动回退。
所有已标记的服务商/模型组合都会从默认对话模型选择器、服务商快速设为默认操作和 Composer 模型菜单中排除。其他服务商的同名模型独立保留。已有会话绑定和历史不改写;仍绑定任一生图候选的会话必须选择对话模型才能发送,运行时也会在推理前拒绝所有已标记生图模型。

### 移除服务商模型
Expand Down
Loading