refactor(image): reuse the model picker
This commit is contained in:
@@ -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.
|
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
|
## 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.
|
1. Add the image provider credential under **Settings → Models** if it is not already configured.
|
||||||
2. Open **Settings → Image**.
|
2. Open **Settings → Image**.
|
||||||
3. Select the provider and image model, then enable image generation.
|
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**
|
**Manual config**
|
||||||
|
|
||||||
@@ -394,7 +394,7 @@ Use the reference image. Keep the same robot and composition, change the palette
|
|||||||
|
|
||||||
| Symptom | Check |
|
| 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.<provider>.apiKey`; if using `${VAR_NAME}`, confirm the environment variable is visible to the gateway process |
|
| Missing API key error | Configure `providers.<provider>.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` |
|
| `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 |
|
| 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 |
|
||||||
|
|||||||
@@ -1799,6 +1799,7 @@ class ModelScopeImageGenerationClient(ImageGenerationProvider):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
provider_name = "modelscope"
|
provider_name = "modelscope"
|
||||||
|
model_options = ("Qwen/Qwen-Image-2512",)
|
||||||
missing_key_message = (
|
missing_key_message = (
|
||||||
"ModelScope API key is not configured. Set providers.modelscope.apiKey."
|
"ModelScope API key is not configured. Set providers.modelscope.apiKey."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -101,6 +101,21 @@ def test_settings_payload_includes_relocated_capabilities(
|
|||||||
assert payload["observability"]["configured"] is True
|
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(
|
def test_update_api_settings_requires_key_for_network_access(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
|||||||
@@ -1841,6 +1841,7 @@ export function SettingsView({
|
|||||||
case "image":
|
case "image":
|
||||||
return (
|
return (
|
||||||
<ImageGenerationSettings
|
<ImageGenerationSettings
|
||||||
|
token={token}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
form={imageGenerationForm}
|
form={imageGenerationForm}
|
||||||
dirty={imageGenerationDirty}
|
dirty={imageGenerationDirty}
|
||||||
@@ -3535,6 +3536,7 @@ function ProvidersSettings({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ImageGenerationSettings({
|
function ImageGenerationSettings({
|
||||||
|
token,
|
||||||
settings,
|
settings,
|
||||||
form,
|
form,
|
||||||
dirty,
|
dirty,
|
||||||
@@ -3547,6 +3549,7 @@ function ImageGenerationSettings({
|
|||||||
isRestarting,
|
isRestarting,
|
||||||
requiresRestartPending,
|
requiresRestartPending,
|
||||||
}: {
|
}: {
|
||||||
|
token: string;
|
||||||
settings: SettingsPayload;
|
settings: SettingsPayload;
|
||||||
form: ImageGenerationSettingsUpdate;
|
form: ImageGenerationSettingsUpdate;
|
||||||
dirty: boolean;
|
dirty: boolean;
|
||||||
@@ -3574,11 +3577,6 @@ function ImageGenerationSettings({
|
|||||||
IMAGE_SIZE_OPTIONS.map((value) => ({ name: value, label: value })),
|
IMAGE_SIZE_OPTIONS.map((value) => ({ name: value, label: value })),
|
||||||
form.defaultImageSize,
|
form.defaultImageSize,
|
||||||
);
|
);
|
||||||
const modelOptions = (selectedProvider?.models ?? []).map((model) => ({
|
|
||||||
name: model,
|
|
||||||
label: model,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const selectProvider = (provider: string) => {
|
const selectProvider = (provider: string) => {
|
||||||
const nextProvider = settings.image_generation.providers.find((row) => row.name === provider);
|
const nextProvider = settings.image_generation.providers.find((row) => row.name === provider);
|
||||||
onChangeForm((prev) => ({
|
onChangeForm((prev) => ({
|
||||||
@@ -3648,9 +3646,13 @@ function ImageGenerationSettings({
|
|||||||
title={tx("settings.rows.imageModel", "Image model")}
|
title={tx("settings.rows.imageModel", "Image model")}
|
||||||
description={tx("settings.help.imageModel", "Model name sent to the selected image provider.")}
|
description={tx("settings.help.imageModel", "Model name sent to the selected image provider.")}
|
||||||
>
|
>
|
||||||
<EditableOptionPicker
|
<ModelIdPicker
|
||||||
options={modelOptions}
|
token={token}
|
||||||
|
settings={settings}
|
||||||
|
provider={form.provider}
|
||||||
|
models={selectedProvider?.models ?? []}
|
||||||
value={form.model}
|
value={form.model}
|
||||||
|
showProviderLogos={showBrandLogos}
|
||||||
emptyLabel={tx("settings.image.selectModel", "Select image model")}
|
emptyLabel={tx("settings.image.selectModel", "Select image model")}
|
||||||
searchPlaceholder={tx(
|
searchPlaceholder={tx(
|
||||||
"settings.image.searchOrTypeModel",
|
"settings.image.searchOrTypeModel",
|
||||||
@@ -3660,7 +3662,6 @@ function ImageGenerationSettings({
|
|||||||
"settings.image.typeModelId",
|
"settings.image.typeModelId",
|
||||||
"Type the model ID supported by this provider.",
|
"Type the model ID supported by this provider.",
|
||||||
)}
|
)}
|
||||||
useCustomLabel={tx("settings.models.useCustomModel", "Use")}
|
|
||||||
onChange={(model) => onChangeForm((prev) => ({ ...prev, model }))}
|
onChange={(model) => onChangeForm((prev) => ({ ...prev, model }))}
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
@@ -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 (
|
|
||||||
<DropdownMenu
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(nextOpen) => {
|
|
||||||
setOpen(nextOpen);
|
|
||||||
if (!nextOpen) setQuery("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"h-8 w-[min(300px,70vw)] justify-between rounded-full border-input bg-background px-3 text-[13px] font-normal shadow-none",
|
|
||||||
"hover:bg-accent/55 focus-visible:ring-2 focus-visible:ring-ring",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className={cn("truncate", !value && "text-muted-foreground")}>
|
|
||||||
{value || emptyLabel}
|
|
||||||
</span>
|
|
||||||
<ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
align="end"
|
|
||||||
className="w-[320px] max-w-[calc(100vw-2rem)] p-1.5"
|
|
||||||
>
|
|
||||||
<div className="p-1 pb-1.5">
|
|
||||||
<div className="relative">
|
|
||||||
<Search
|
|
||||||
className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
autoFocus
|
|
||||||
value={query}
|
|
||||||
onChange={(event) => 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]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{visibleOptions.length ? (
|
|
||||||
<div className="max-h-[14rem] overflow-y-auto pr-0.5 scrollbar-thin scrollbar-track-transparent">
|
|
||||||
{visibleOptions.map((option) => {
|
|
||||||
const selected = option.name === value;
|
|
||||||
return (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={option.name}
|
|
||||||
onSelect={() => 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",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="min-w-0 truncate">{option.label}</span>
|
|
||||||
{selected ? <Check className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : !customCandidate ? (
|
|
||||||
<div className="px-3 py-2 text-[11px] leading-4 text-muted-foreground">
|
|
||||||
{emptyMessage}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{customCandidate && !exactMatch ? (
|
|
||||||
<>
|
|
||||||
{visibleOptions.length ? <DropdownMenuSeparator /> : null}
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => selectValue(customCandidate)}
|
|
||||||
className="flex cursor-default items-center gap-2 rounded-[12px] px-2 py-1.5 text-[12px] focus:bg-muted/85"
|
|
||||||
>
|
|
||||||
<span className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-muted/80 text-muted-foreground">
|
|
||||||
<Pencil className="h-3 w-3" aria-hidden />
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 truncate">
|
|
||||||
{useCustomLabel}{" "}
|
|
||||||
<span className="font-medium text-foreground">“{customCandidate}”</span>
|
|
||||||
</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ModelIdPicker({
|
function ModelIdPicker({
|
||||||
token,
|
token,
|
||||||
settings,
|
settings,
|
||||||
provider,
|
provider,
|
||||||
|
models,
|
||||||
value,
|
value,
|
||||||
showProviderLogos,
|
showProviderLogos,
|
||||||
|
emptyLabel,
|
||||||
|
searchPlaceholder,
|
||||||
|
emptyMessage,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
token: string;
|
token: string;
|
||||||
settings: SettingsPayload;
|
settings: SettingsPayload;
|
||||||
provider: string;
|
provider: string;
|
||||||
|
models?: string[];
|
||||||
value: string;
|
value: string;
|
||||||
showProviderLogos: boolean;
|
showProviderLogos: boolean;
|
||||||
|
emptyLabel?: string;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
emptyMessage?: string;
|
||||||
onChange: (model: string) => void;
|
onChange: (model: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -7889,19 +7767,25 @@ function ModelIdPicker({
|
|||||||
const effectiveProvider =
|
const effectiveProvider =
|
||||||
provider === "auto" ? settings.agent.resolved_provider ?? provider : provider;
|
provider === "auto" ? settings.agent.resolved_provider ?? provider : provider;
|
||||||
const hasConcreteProvider = Boolean(effectiveProvider && effectiveProvider !== "auto");
|
const hasConcreteProvider = Boolean(effectiveProvider && effectiveProvider !== "auto");
|
||||||
|
const hasStaticModels = models !== undefined;
|
||||||
const providerRow = settingsProviderRow(settings, effectiveProvider);
|
const providerRow = settingsProviderRow(settings, effectiveProvider);
|
||||||
const providerConfigured = settingsProviderConfigured(settings, effectiveProvider);
|
const providerConfigured = settingsProviderConfigured(settings, effectiveProvider);
|
||||||
const providerRequiresConfiguration = hasConcreteProvider && !providerConfigured;
|
const providerRequiresConfiguration =
|
||||||
|
!hasStaticModels && hasConcreteProvider && !providerConfigured;
|
||||||
const providerHasBuiltinModels = providerRow?.model_catalog === "builtin";
|
const providerHasBuiltinModels = providerRow?.model_catalog === "builtin";
|
||||||
const providerUsesManualModelIds =
|
const providerUsesManualModelIds =
|
||||||
|
!hasStaticModels &&
|
||||||
hasConcreteProvider &&
|
hasConcreteProvider &&
|
||||||
providerConfigured &&
|
providerConfigured &&
|
||||||
providerRow?.auth_type === "oauth" &&
|
providerRow?.auth_type === "oauth" &&
|
||||||
!providerHasBuiltinModels;
|
!providerHasBuiltinModels;
|
||||||
const canFetchModels =
|
const canFetchModels =
|
||||||
|
!hasStaticModels &&
|
||||||
hasConcreteProvider && providerConfigured && !providerUsesManualModelIds;
|
hasConcreteProvider && providerConfigured && !providerUsesManualModelIds;
|
||||||
const normalizedQuery = query.trim().toLowerCase();
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
const providerModels = payload?.models ?? [];
|
const providerModels: ProviderModelsPayload["models"] = hasStaticModels
|
||||||
|
? (models?.map((id) => ({ id })) ?? [])
|
||||||
|
: (payload?.models ?? []);
|
||||||
const visibleModels = providerModels
|
const visibleModels = providerModels
|
||||||
.filter((model) => {
|
.filter((model) => {
|
||||||
if (!normalizedQuery) return true;
|
if (!normalizedQuery) return true;
|
||||||
@@ -7917,8 +7801,10 @@ function ModelIdPicker({
|
|||||||
canFetchModels && (!defersModelList || hasDeferredSearchQuery);
|
canFetchModels && (!defersModelList || hasDeferredSearchQuery);
|
||||||
const waitingForModelSearch =
|
const waitingForModelSearch =
|
||||||
open && canFetchModels && defersModelList && !hasDeferredSearchQuery;
|
open && canFetchModels && defersModelList && !hasDeferredSearchQuery;
|
||||||
const hasModelList = payload?.status === "available";
|
const hasModelList = hasStaticModels || payload?.status === "available";
|
||||||
const showModels = Boolean(hasModelList && payload && (!isCatalog || normalizedQuery));
|
const showModels = Boolean(
|
||||||
|
hasModelList && (hasStaticModels || (payload && (!isCatalog || normalizedQuery))),
|
||||||
|
);
|
||||||
const customCandidate = query.trim();
|
const customCandidate = query.trim();
|
||||||
const allowCustomModel = !providerRequiresConfiguration;
|
const allowCustomModel = !providerRequiresConfiguration;
|
||||||
const exactQueryMatch = providerModels.some((model) => model.id === customCandidate);
|
const exactQueryMatch = providerModels.some((model) => model.id === customCandidate);
|
||||||
@@ -8023,7 +7909,7 @@ function ModelIdPicker({
|
|||||||
value ? "text-foreground" : "text-muted-foreground",
|
value ? "text-foreground" : "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{value || tx("settings.models.selectModel", "Select model")}
|
{value || emptyLabel || tx("settings.models.selectModel", "Select model")}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
<ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
@@ -8042,8 +7928,19 @@ function ModelIdPicker({
|
|||||||
<Input
|
<Input
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
onKeyDown={(event) => event.stopPropagation()}
|
onKeyDown={(event) => {
|
||||||
placeholder={tx("settings.models.searchModels", "Search or type model ID")}
|
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]"
|
className="h-8 rounded-full pl-8 pr-3 text-[12px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -8053,6 +7950,10 @@ function ModelIdPicker({
|
|||||||
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||||
{tx("settings.models.providerNotConfigured", "Configure this provider before loading models.")}
|
{tx("settings.models.providerNotConfigured", "Configure this provider before loading models.")}
|
||||||
</div>
|
</div>
|
||||||
|
) : hasStaticModels && !providerModels.length ? (
|
||||||
|
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||||
|
{emptyMessage || tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
|
||||||
|
</div>
|
||||||
) : providerUsesManualModelIds ? (
|
) : providerUsesManualModelIds ? (
|
||||||
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||||
{tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
|
{tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
|
||||||
|
|||||||
Reference in New Issue
Block a user