diff --git a/docs/image-generation.md b/docs/image-generation.md index a9185eec..bcbb025d 100644 --- a/docs/image-generation.md +++ b/docs/image-generation.md @@ -2,7 +2,7 @@ nanobot can generate and edit images through the `generate_image` tool. Enable the tool in WebUI Settings, then ask for an image normally in chat; the agent decides when to call it and can keep iterating on generated images in the same conversation. -The feature is disabled by default. Open **Settings → Image**, choose a configured provider and model, enable image generation, save, and restart when prompted. If that screen is not available in your installed version, use the manual config below. +The feature is disabled by default. Open **Settings → Image**, choose a configured provider and model, enable image generation, and save. The running gateway applies the change immediately. If that screen is not available in your installed version, use the manual config below. ## Quick Setup @@ -11,7 +11,7 @@ The feature is disabled by default. Open **Settings → Image**, choose a config 1. Add the image provider credential under **Settings → Models** if it is not already configured. 2. Open **Settings → Image**. 3. Select the provider and image model, then enable image generation. -4. Save, restart when prompted, and ask for a simple test image. +4. Save and ask for a simple test image. If the gateway cannot apply the change live, WebUI will prompt you to restart it. **Manual config** @@ -394,7 +394,7 @@ Use the reference image. Keep the same robot and composition, change the palette | Symptom | Check | |---------|-------| -| `generate_image` is not available | Set `tools.imageGeneration.enabled` to `true` and restart the gateway | +| `generate_image` is not available | Enable image generation in **Settings → Image** and save. For manual config changes, restart the gateway | | Missing API key error | Configure `providers..apiKey`; if using `${VAR_NAME}`, confirm the environment variable is visible to the gateway process | | `unsupported image generation provider` | Use `openrouter`, `openai`, `openai_codex`, `custom`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun`, `zhipu`, or `modelscope` | | AIHubMix says `Incorrect model ID` | Use `model: "gpt-image-2-free"`; nanobot expands it to the required `openai/gpt-image-2-free` model path internally | diff --git a/nanobot/providers/image_generation.py b/nanobot/providers/image_generation.py index 58951c34..94b9a75e 100644 --- a/nanobot/providers/image_generation.py +++ b/nanobot/providers/image_generation.py @@ -1799,6 +1799,7 @@ class ModelScopeImageGenerationClient(ImageGenerationProvider): """ provider_name = "modelscope" + model_options = ("Qwen/Qwen-Image-2512",) missing_key_message = ( "ModelScope API key is not configured. Set providers.modelscope.apiKey." ) diff --git a/tests/webui/test_settings_api.py b/tests/webui/test_settings_api.py index 9c8ec2e9..418d4a4e 100644 --- a/tests/webui/test_settings_api.py +++ b/tests/webui/test_settings_api.py @@ -101,6 +101,21 @@ def test_settings_payload_includes_relocated_capabilities( assert payload["observability"]["configured"] is True +def test_settings_payload_exposes_modelscope_image_model( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config.json" + save_config(Config(), config_path) + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + + payload = settings_payload() + providers = {row["name"]: row for row in payload["image_generation"]["providers"]} + + assert providers["modelscope"]["models"] == ["Qwen/Qwen-Image-2512"] + assert providers["modelscope"]["default_model"] == "Qwen/Qwen-Image-2512" + + def test_update_api_settings_requires_key_for_network_access( tmp_path, monkeypatch: pytest.MonkeyPatch, diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 34c15fd9..c8266acb 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -1841,6 +1841,7 @@ export function SettingsView({ case "image": return ( ({ name: value, label: value })), form.defaultImageSize, ); - const modelOptions = (selectedProvider?.models ?? []).map((model) => ({ - name: model, - label: model, - })); - const selectProvider = (provider: string) => { const nextProvider = settings.image_generation.providers.find((row) => row.name === provider); onChangeForm((prev) => ({ @@ -3648,9 +3646,13 @@ function ImageGenerationSettings({ title={tx("settings.rows.imageModel", "Image model")} description={tx("settings.help.imageModel", "Model name sent to the selected image provider.")} > - onChangeForm((prev) => ({ ...prev, model }))} /> @@ -7733,150 +7734,27 @@ function ProviderPicker({ ); } -function EditableOptionPicker({ - options, - value, - emptyLabel, - searchPlaceholder, - emptyMessage, - useCustomLabel, - onChange, -}: { - options: Array<{ name: string; label: string }>; - value: string; - emptyLabel: string; - searchPlaceholder: string; - emptyMessage: string; - useCustomLabel: string; - onChange: (value: string) => void; -}) { - const [open, setOpen] = useState(false); - const [query, setQuery] = useState(""); - const normalizedQuery = query.trim().toLowerCase(); - const customCandidate = query.trim(); - const exactMatch = options.some((option) => option.name === customCandidate); - const visibleOptions = options.filter((option) => - [option.name, option.label].some((field) => field.toLowerCase().includes(normalizedQuery)), - ); - - const selectValue = (nextValue: string) => { - onChange(nextValue); - setQuery(""); - setOpen(false); - }; - - return ( - { - setOpen(nextOpen); - if (!nextOpen) setQuery(""); - }} - > - - - - -
-
- - setQuery(event.target.value)} - onKeyDown={(event) => { - event.stopPropagation(); - if (event.key === "Enter" && customCandidate) { - event.preventDefault(); - selectValue(customCandidate); - } - }} - placeholder={searchPlaceholder} - aria-label={searchPlaceholder} - className="h-8 rounded-full pl-8 pr-3 text-[12px]" - /> -
-
- - {visibleOptions.length ? ( -
- {visibleOptions.map((option) => { - const selected = option.name === value; - return ( - selectValue(option.name)} - className={cn( - "flex cursor-default items-center justify-between gap-2 rounded-[12px] px-2.5 py-2 text-[13px]", - "focus:bg-muted/85 focus:text-foreground", - selected && "bg-muted/80 text-foreground focus:bg-muted", - )} - > - {option.label} - {selected ? : null} - - ); - })} -
- ) : !customCandidate ? ( -
- {emptyMessage} -
- ) : null} - - {customCandidate && !exactMatch ? ( - <> - {visibleOptions.length ? : null} - selectValue(customCandidate)} - className="flex cursor-default items-center gap-2 rounded-[12px] px-2 py-1.5 text-[12px] focus:bg-muted/85" - > - - - - - {useCustomLabel}{" "} - “{customCandidate}” - - - - ) : null} -
-
- ); -} - function ModelIdPicker({ token, settings, provider, + models, value, showProviderLogos, + emptyLabel, + searchPlaceholder, + emptyMessage, onChange, }: { token: string; settings: SettingsPayload; provider: string; + models?: string[]; value: string; showProviderLogos: boolean; + emptyLabel?: string; + searchPlaceholder?: string; + emptyMessage?: string; onChange: (model: string) => void; }) { const { t } = useTranslation(); @@ -7889,19 +7767,25 @@ function ModelIdPicker({ const effectiveProvider = provider === "auto" ? settings.agent.resolved_provider ?? provider : provider; const hasConcreteProvider = Boolean(effectiveProvider && effectiveProvider !== "auto"); + const hasStaticModels = models !== undefined; const providerRow = settingsProviderRow(settings, effectiveProvider); const providerConfigured = settingsProviderConfigured(settings, effectiveProvider); - const providerRequiresConfiguration = hasConcreteProvider && !providerConfigured; + const providerRequiresConfiguration = + !hasStaticModels && hasConcreteProvider && !providerConfigured; const providerHasBuiltinModels = providerRow?.model_catalog === "builtin"; const providerUsesManualModelIds = + !hasStaticModels && hasConcreteProvider && providerConfigured && providerRow?.auth_type === "oauth" && !providerHasBuiltinModels; const canFetchModels = + !hasStaticModels && hasConcreteProvider && providerConfigured && !providerUsesManualModelIds; const normalizedQuery = query.trim().toLowerCase(); - const providerModels = payload?.models ?? []; + const providerModels: ProviderModelsPayload["models"] = hasStaticModels + ? (models?.map((id) => ({ id })) ?? []) + : (payload?.models ?? []); const visibleModels = providerModels .filter((model) => { if (!normalizedQuery) return true; @@ -7917,8 +7801,10 @@ function ModelIdPicker({ canFetchModels && (!defersModelList || hasDeferredSearchQuery); const waitingForModelSearch = open && canFetchModels && defersModelList && !hasDeferredSearchQuery; - const hasModelList = payload?.status === "available"; - const showModels = Boolean(hasModelList && payload && (!isCatalog || normalizedQuery)); + const hasModelList = hasStaticModels || payload?.status === "available"; + const showModels = Boolean( + hasModelList && (hasStaticModels || (payload && (!isCatalog || normalizedQuery))), + ); const customCandidate = query.trim(); const allowCustomModel = !providerRequiresConfiguration; const exactQueryMatch = providerModels.some((model) => model.id === customCandidate); @@ -8023,7 +7909,7 @@ function ModelIdPicker({ value ? "text-foreground" : "text-muted-foreground", )} > - {value || tx("settings.models.selectModel", "Select model")} + {value || emptyLabel || tx("settings.models.selectModel", "Select model")} @@ -8042,8 +7928,19 @@ function ModelIdPicker({ setQuery(event.target.value)} - onKeyDown={(event) => event.stopPropagation()} - placeholder={tx("settings.models.searchModels", "Search or type model ID")} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.key === "Enter" && allowCustomModel && customCandidate) { + event.preventDefault(); + selectModel(customCandidate); + } + }} + placeholder={ + searchPlaceholder || tx("settings.models.searchModels", "Search or type model ID") + } + aria-label={ + searchPlaceholder || tx("settings.models.searchModels", "Search or type model ID") + } className="h-8 rounded-full pl-8 pr-3 text-[12px]" /> @@ -8053,6 +7950,10 @@ function ModelIdPicker({
{tx("settings.models.providerNotConfigured", "Configure this provider before loading models.")}
+ ) : hasStaticModels && !providerModels.length ? ( +
+ {emptyMessage || tx("settings.models.unsupportedModelList", "Type a model ID manually.")} +
) : providerUsesManualModelIds ? (
{tx("settings.models.unsupportedModelList", "Type a model ID manually.")}