mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-19 16:12:30 +00:00
* feat(settings): expand settings api payload * feat(webui): build app-style settings center * feat(webui): add centered chat search dialog * fix(webui): shorten chat search label * fix(webui): center dialog entrance animation * fix(webui): simplify chat search results * fix(webui): tighten mobile settings navigation * feat(webui): persist sidebar state * feat(webui): add sidebar organization controls * refactor(webui): organize backend helpers * refactor(webui): remove utils compatibility shims * refactor(session): move shared webui helpers out of webui package * feat(webui): add image generation settings * style(webui): refine settings overview layout * fix(webui): localize settings zh-CN copy * style(webui): add settings status indicators * feat(webui): show sidebar run indicators * fix(webui): persist sidebar run indicators * fix(webui): highlight settings pending status * fix(webui): align settings test with provider update * fix(utils): preserve legacy webui helper imports
610 lines
23 KiB
Python
610 lines
23 KiB
Python
"""Settings REST helpers for the WebUI HTTP surface.
|
|
|
|
The WebSocket channel owns transport/authentication. This module owns the
|
|
settings payload shape and the allowlisted config mutations exposed to WebUI.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from nanobot.config.loader import get_config_path, load_config, save_config
|
|
from nanobot.providers.image_generation import (
|
|
get_image_gen_provider,
|
|
image_gen_provider_names,
|
|
)
|
|
from nanobot.providers.registry import PROVIDERS, find_by_name
|
|
|
|
QueryParams = dict[str, list[str]]
|
|
|
|
_WEB_SEARCH_PROVIDER_OPTIONS: tuple[dict[str, str], ...] = (
|
|
{"name": "duckduckgo", "label": "DuckDuckGo", "credential": "none"},
|
|
{"name": "brave", "label": "Brave Search", "credential": "api_key"},
|
|
{"name": "tavily", "label": "Tavily", "credential": "api_key"},
|
|
{"name": "searxng", "label": "SearXNG", "credential": "base_url"},
|
|
{"name": "jina", "label": "Jina", "credential": "api_key"},
|
|
{"name": "kagi", "label": "Kagi", "credential": "api_key"},
|
|
{"name": "olostep", "label": "Olostep", "credential": "api_key"},
|
|
)
|
|
_WEB_SEARCH_PROVIDER_BY_NAME = {
|
|
provider["name"]: provider for provider in _WEB_SEARCH_PROVIDER_OPTIONS
|
|
}
|
|
|
|
_IMAGE_GENERATION_ASPECT_RATIOS = {
|
|
"1:1",
|
|
"3:4",
|
|
"9:16",
|
|
"4:3",
|
|
"16:9",
|
|
"3:2",
|
|
"2:3",
|
|
"21:9",
|
|
}
|
|
|
|
|
|
class WebUISettingsError(ValueError):
|
|
"""User-facing settings validation failure."""
|
|
|
|
def __init__(self, message: str, *, status: int = 400) -> None:
|
|
super().__init__(message)
|
|
self.message = message
|
|
self.status = status
|
|
|
|
|
|
def _query_first(query: QueryParams, key: str) -> str | None:
|
|
values = query.get(key)
|
|
return values[0] if values else None
|
|
|
|
|
|
def _query_first_alias(query: QueryParams, snake: str, camel: str) -> str | None:
|
|
value = _query_first(query, snake)
|
|
return _query_first(query, camel) if value is None else value
|
|
|
|
|
|
def _mask_secret_hint(secret: str | None) -> str | None:
|
|
if not secret:
|
|
return None
|
|
if len(secret) <= 8:
|
|
return "••••"
|
|
return f"{secret[:4]}••••{secret[-4:]}"
|
|
|
|
|
|
def _provider_requires_api_key(spec: Any) -> bool:
|
|
if spec.backend == "azure_openai":
|
|
return True
|
|
if spec.is_local or spec.is_direct:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _provider_configured_for_settings(spec: Any, provider_config: Any) -> bool:
|
|
if _provider_requires_api_key(spec):
|
|
return bool(provider_config.api_key)
|
|
return bool(
|
|
provider_config.api_key
|
|
or provider_config.api_base
|
|
or getattr(provider_config, "region", None)
|
|
or getattr(provider_config, "profile", None)
|
|
)
|
|
|
|
|
|
def _parse_bool(value: str, field: str) -> bool:
|
|
normalized = value.strip().lower()
|
|
if normalized not in {"1", "0", "true", "false", "yes", "no"}:
|
|
raise WebUISettingsError(f"{field} must be boolean")
|
|
return normalized in {"1", "true", "yes"}
|
|
|
|
|
|
def _image_generation_provider_rows(config: Any) -> list[dict[str, Any]]:
|
|
rows: list[dict[str, Any]] = []
|
|
for name in image_gen_provider_names():
|
|
spec = find_by_name(name)
|
|
provider_config = getattr(config.providers, name, None)
|
|
configured = (
|
|
_provider_configured_for_settings(spec, provider_config)
|
|
if spec is not None and provider_config is not None
|
|
else bool(getattr(provider_config, "api_key", None))
|
|
)
|
|
rows.append(
|
|
{
|
|
"name": name,
|
|
"label": spec.label if spec is not None else name,
|
|
"configured": configured,
|
|
"api_key_hint": _mask_secret_hint(
|
|
getattr(provider_config, "api_key", None)
|
|
),
|
|
"api_base": getattr(provider_config, "api_base", None),
|
|
"default_api_base": (
|
|
spec.default_api_base if spec and spec.default_api_base else None
|
|
),
|
|
}
|
|
)
|
|
return rows
|
|
|
|
|
|
def settings_payload(*, requires_restart: bool = False) -> dict[str, Any]:
|
|
config = load_config()
|
|
defaults = config.agents.defaults
|
|
active_preset_name = defaults.model_preset or "default"
|
|
try:
|
|
effective_preset = config.resolve_preset()
|
|
except Exception:
|
|
effective_preset = config.resolve_default_preset()
|
|
active_preset_name = "default"
|
|
|
|
provider_name = (
|
|
config.get_provider_name(effective_preset.model, preset=effective_preset)
|
|
or effective_preset.provider
|
|
)
|
|
provider = config.get_provider(effective_preset.model, preset=effective_preset)
|
|
selected_provider = provider_name
|
|
if effective_preset.provider != "auto":
|
|
spec = find_by_name(effective_preset.provider)
|
|
selected_provider = spec.name if spec else provider_name
|
|
|
|
providers = []
|
|
for spec in PROVIDERS:
|
|
provider_config = getattr(config.providers, spec.name, None)
|
|
if provider_config is None or spec.is_oauth:
|
|
continue
|
|
providers.append(
|
|
{
|
|
"name": spec.name,
|
|
"label": spec.label,
|
|
"configured": _provider_configured_for_settings(spec, provider_config),
|
|
"api_key_required": _provider_requires_api_key(spec),
|
|
"api_key_hint": _mask_secret_hint(provider_config.api_key),
|
|
"api_base": provider_config.api_base,
|
|
"default_api_base": spec.default_api_base or None,
|
|
}
|
|
)
|
|
|
|
search_config = config.tools.web.search
|
|
image_config = config.tools.image_generation
|
|
search_provider = (
|
|
search_config.provider
|
|
if search_config.provider in _WEB_SEARCH_PROVIDER_BY_NAME
|
|
else "duckduckgo"
|
|
)
|
|
image_providers = _image_generation_provider_rows(config)
|
|
selected_image_provider = next(
|
|
(
|
|
provider
|
|
for provider in image_providers
|
|
if provider["name"] == image_config.provider
|
|
),
|
|
None,
|
|
)
|
|
model_presets = [
|
|
{
|
|
"name": "default",
|
|
"label": "Default",
|
|
"active": active_preset_name == "default",
|
|
"is_default": True,
|
|
"model": defaults.model,
|
|
"provider": defaults.provider,
|
|
"max_tokens": defaults.max_tokens,
|
|
"context_window_tokens": defaults.context_window_tokens,
|
|
"temperature": defaults.temperature,
|
|
"reasoning_effort": defaults.reasoning_effort,
|
|
}
|
|
]
|
|
for name, preset in config.model_presets.items():
|
|
model_presets.append(
|
|
{
|
|
"name": name,
|
|
"label": name,
|
|
"active": active_preset_name == name,
|
|
"is_default": False,
|
|
"model": preset.model,
|
|
"provider": preset.provider,
|
|
"max_tokens": preset.max_tokens,
|
|
"context_window_tokens": preset.context_window_tokens,
|
|
"temperature": preset.temperature,
|
|
"reasoning_effort": preset.reasoning_effort,
|
|
}
|
|
)
|
|
|
|
exec_config = config.tools.exec
|
|
return {
|
|
"agent": {
|
|
"model": effective_preset.model,
|
|
"provider": selected_provider,
|
|
"resolved_provider": provider_name,
|
|
"has_api_key": bool(provider and provider.api_key),
|
|
"model_preset": active_preset_name,
|
|
"max_tokens": effective_preset.max_tokens,
|
|
"context_window_tokens": effective_preset.context_window_tokens,
|
|
"temperature": effective_preset.temperature,
|
|
"reasoning_effort": effective_preset.reasoning_effort,
|
|
"timezone": defaults.timezone,
|
|
"bot_name": defaults.bot_name,
|
|
"bot_icon": defaults.bot_icon,
|
|
"tool_hint_max_length": defaults.tool_hint_max_length,
|
|
},
|
|
"model_presets": model_presets,
|
|
"providers": providers,
|
|
"web_search": {
|
|
"provider": search_provider,
|
|
"api_key_hint": _mask_secret_hint(search_config.api_key),
|
|
"base_url": search_config.base_url or None,
|
|
"max_results": search_config.max_results,
|
|
"timeout": search_config.timeout,
|
|
"providers": list(_WEB_SEARCH_PROVIDER_OPTIONS),
|
|
},
|
|
"web": {
|
|
"enable": config.tools.web.enable,
|
|
"proxy": config.tools.web.proxy,
|
|
"user_agent": config.tools.web.user_agent,
|
|
"search": {
|
|
"max_results": search_config.max_results,
|
|
"timeout": search_config.timeout,
|
|
},
|
|
"fetch": {
|
|
"use_jina_reader": config.tools.web.fetch.use_jina_reader,
|
|
},
|
|
},
|
|
"image_generation": {
|
|
"enabled": image_config.enabled,
|
|
"provider": image_config.provider,
|
|
"provider_configured": bool(
|
|
selected_image_provider and selected_image_provider["configured"]
|
|
),
|
|
"model": image_config.model,
|
|
"default_aspect_ratio": image_config.default_aspect_ratio,
|
|
"default_image_size": image_config.default_image_size,
|
|
"max_images_per_turn": image_config.max_images_per_turn,
|
|
"save_dir": image_config.save_dir,
|
|
"providers": image_providers,
|
|
},
|
|
"runtime": {
|
|
"config_path": str(get_config_path().expanduser()),
|
|
"workspace_path": str(config.workspace_path),
|
|
"gateway_host": config.gateway.host,
|
|
"gateway_port": config.gateway.port,
|
|
"heartbeat": {
|
|
"enabled": config.gateway.heartbeat.enabled,
|
|
"interval_s": config.gateway.heartbeat.interval_s,
|
|
"keep_recent_messages": config.gateway.heartbeat.keep_recent_messages,
|
|
},
|
|
"dream": {
|
|
"schedule": defaults.dream.describe_schedule(),
|
|
"max_batch_size": defaults.dream.max_batch_size,
|
|
"max_iterations": defaults.dream.max_iterations,
|
|
"annotate_line_ages": defaults.dream.annotate_line_ages,
|
|
},
|
|
"unified_session": defaults.unified_session,
|
|
},
|
|
"advanced": {
|
|
"restrict_to_workspace": config.tools.restrict_to_workspace,
|
|
"ssrf_whitelist_count": len(config.tools.ssrf_whitelist),
|
|
"mcp_server_count": len(config.tools.mcp_servers),
|
|
"exec_enabled": exec_config.enable,
|
|
"exec_sandbox": exec_config.sandbox or None,
|
|
"exec_path_append_set": bool(exec_config.path_append),
|
|
},
|
|
"requires_restart": requires_restart,
|
|
}
|
|
|
|
|
|
def update_agent_settings(query: QueryParams) -> dict[str, Any]:
|
|
config = load_config()
|
|
defaults = config.agents.defaults
|
|
changed = False
|
|
restart_required = False
|
|
|
|
if "model_preset" in query or "modelPreset" in query:
|
|
preset = (_query_first_alias(query, "model_preset", "modelPreset") or "").strip()
|
|
preset_value = None if not preset or preset == "default" else preset
|
|
if preset_value is not None and preset_value not in config.model_presets:
|
|
raise WebUISettingsError("unknown model preset")
|
|
if defaults.model_preset != preset_value:
|
|
defaults.model_preset = preset_value
|
|
changed = True
|
|
|
|
model = _query_first(query, "model")
|
|
if model is not None:
|
|
model = model.strip()
|
|
if not model:
|
|
raise WebUISettingsError("model is required")
|
|
if defaults.model != model:
|
|
defaults.model = model
|
|
changed = True
|
|
|
|
provider = _query_first(query, "provider")
|
|
if provider is not None:
|
|
provider = provider.strip()
|
|
if not provider:
|
|
raise WebUISettingsError("provider is required")
|
|
spec = find_by_name(provider)
|
|
if spec is None:
|
|
raise WebUISettingsError("unknown provider")
|
|
provider_config = getattr(config.providers, provider, None)
|
|
if (
|
|
provider_config is None
|
|
or not _provider_configured_for_settings(spec, provider_config)
|
|
):
|
|
raise WebUISettingsError("provider is not configured")
|
|
if defaults.provider != provider:
|
|
defaults.provider = provider
|
|
changed = True
|
|
|
|
timezone = _query_first(query, "timezone")
|
|
if timezone is not None:
|
|
timezone = timezone.strip()
|
|
if not timezone:
|
|
raise WebUISettingsError("timezone is required")
|
|
try:
|
|
ZoneInfo(timezone)
|
|
except Exception:
|
|
raise WebUISettingsError("invalid timezone") from None
|
|
if defaults.timezone != timezone:
|
|
defaults.timezone = timezone
|
|
changed = True
|
|
restart_required = True
|
|
|
|
bot_name = _query_first_alias(query, "bot_name", "botName")
|
|
if bot_name is not None:
|
|
bot_name = bot_name.strip()
|
|
if not bot_name:
|
|
raise WebUISettingsError("bot_name is required")
|
|
if defaults.bot_name != bot_name:
|
|
defaults.bot_name = bot_name
|
|
changed = True
|
|
restart_required = True
|
|
|
|
bot_icon = _query_first_alias(query, "bot_icon", "botIcon")
|
|
if bot_icon is not None:
|
|
bot_icon = bot_icon.strip()
|
|
if defaults.bot_icon != bot_icon:
|
|
defaults.bot_icon = bot_icon
|
|
changed = True
|
|
restart_required = True
|
|
|
|
tool_hint_max_length = _query_first_alias(
|
|
query,
|
|
"tool_hint_max_length",
|
|
"toolHintMaxLength",
|
|
)
|
|
if tool_hint_max_length is not None:
|
|
try:
|
|
parsed = int(tool_hint_max_length)
|
|
except ValueError:
|
|
raise WebUISettingsError("tool_hint_max_length must be an integer") from None
|
|
if parsed < 20 or parsed > 500:
|
|
raise WebUISettingsError("tool_hint_max_length must be between 20 and 500")
|
|
if defaults.tool_hint_max_length != parsed:
|
|
defaults.tool_hint_max_length = parsed
|
|
changed = True
|
|
restart_required = True
|
|
|
|
if changed:
|
|
save_config(config)
|
|
return settings_payload(requires_restart=restart_required)
|
|
|
|
|
|
def update_provider_settings(query: QueryParams) -> dict[str, Any]:
|
|
provider_name = (_query_first(query, "provider") or "").strip()
|
|
if not provider_name:
|
|
raise WebUISettingsError("provider is required")
|
|
spec = find_by_name(provider_name)
|
|
if spec is None or spec.is_oauth:
|
|
raise WebUISettingsError("unknown provider")
|
|
|
|
config = load_config()
|
|
provider_config = getattr(config.providers, spec.name, None)
|
|
if provider_config is None:
|
|
raise WebUISettingsError("unknown provider")
|
|
|
|
changed = False
|
|
if "api_key" in query or "apiKey" in query:
|
|
api_key = _query_first_alias(query, "api_key", "apiKey")
|
|
api_key = (api_key or "").strip() or None
|
|
if provider_config.api_key != api_key:
|
|
provider_config.api_key = api_key
|
|
changed = True
|
|
|
|
if "api_base" in query or "apiBase" in query:
|
|
api_base = _query_first_alias(query, "api_base", "apiBase")
|
|
api_base = (api_base or "").strip() or None
|
|
if provider_config.api_base != api_base:
|
|
provider_config.api_base = api_base
|
|
changed = True
|
|
|
|
if changed:
|
|
save_config(config)
|
|
image_config = config.tools.image_generation
|
|
restart_required = (
|
|
changed
|
|
and image_config.enabled
|
|
and image_config.provider == spec.name
|
|
and get_image_gen_provider(spec.name) is not None
|
|
)
|
|
return settings_payload(requires_restart=restart_required)
|
|
|
|
|
|
def update_web_search_settings(query: QueryParams) -> dict[str, Any]:
|
|
provider_name = (_query_first(query, "provider") or "").strip().lower()
|
|
provider_option = _WEB_SEARCH_PROVIDER_BY_NAME.get(provider_name)
|
|
if provider_option is None:
|
|
raise WebUISettingsError("unknown web search provider")
|
|
|
|
config = load_config()
|
|
search_config = config.tools.web.search
|
|
web_config = config.tools.web
|
|
previous_provider = search_config.provider
|
|
changed = False
|
|
restart_required = False
|
|
|
|
def set_search_value(attr: str, value: object) -> None:
|
|
nonlocal changed
|
|
if getattr(search_config, attr) != value:
|
|
setattr(search_config, attr, value)
|
|
changed = True
|
|
|
|
def set_fetch_value(attr: str, value: object) -> None:
|
|
nonlocal changed
|
|
if getattr(web_config.fetch, attr) != value:
|
|
setattr(web_config.fetch, attr, value)
|
|
changed = True
|
|
|
|
if search_config.provider != provider_name:
|
|
search_config.provider = provider_name
|
|
changed = True
|
|
|
|
credential = provider_option["credential"]
|
|
if credential == "none":
|
|
set_search_value("api_key", "")
|
|
set_search_value("base_url", "")
|
|
elif credential == "base_url":
|
|
base_url = _query_first_alias(query, "base_url", "baseUrl")
|
|
base_url = base_url.strip() if base_url is not None else None
|
|
if not base_url and previous_provider == provider_name and search_config.base_url:
|
|
base_url = search_config.base_url
|
|
if not base_url:
|
|
raise WebUISettingsError("base_url is required")
|
|
set_search_value("base_url", base_url)
|
|
set_search_value("api_key", "")
|
|
else:
|
|
api_key = _query_first_alias(query, "api_key", "apiKey")
|
|
api_key = api_key.strip() if api_key is not None else None
|
|
if not api_key and previous_provider == provider_name and search_config.api_key:
|
|
api_key = search_config.api_key
|
|
if not api_key:
|
|
raise WebUISettingsError("api_key is required")
|
|
set_search_value("api_key", api_key)
|
|
set_search_value("base_url", "")
|
|
|
|
max_results = _query_first_alias(query, "max_results", "maxResults")
|
|
if max_results is not None:
|
|
try:
|
|
parsed = int(max_results)
|
|
except ValueError:
|
|
raise WebUISettingsError("max_results must be an integer") from None
|
|
if parsed < 1 or parsed > 10:
|
|
raise WebUISettingsError("max_results must be between 1 and 10")
|
|
set_search_value("max_results", parsed)
|
|
|
|
timeout = _query_first(query, "timeout")
|
|
if timeout is not None:
|
|
try:
|
|
parsed_timeout = int(timeout)
|
|
except ValueError:
|
|
raise WebUISettingsError("timeout must be an integer") from None
|
|
if parsed_timeout < 1 or parsed_timeout > 120:
|
|
raise WebUISettingsError("timeout must be between 1 and 120")
|
|
set_search_value("timeout", parsed_timeout)
|
|
|
|
use_jina_reader = _query_first_alias(query, "use_jina_reader", "useJinaReader")
|
|
if use_jina_reader is not None:
|
|
normalized = use_jina_reader.strip().lower()
|
|
if normalized not in {"1", "0", "true", "false", "yes", "no"}:
|
|
raise WebUISettingsError("use_jina_reader must be boolean")
|
|
previous_jina_reader = web_config.fetch.use_jina_reader
|
|
set_fetch_value("use_jina_reader", normalized in {"1", "true", "yes"})
|
|
if web_config.fetch.use_jina_reader != previous_jina_reader:
|
|
restart_required = True
|
|
|
|
if changed:
|
|
save_config(config)
|
|
return settings_payload(requires_restart=restart_required)
|
|
|
|
|
|
def update_image_generation_settings(query: QueryParams) -> dict[str, Any]:
|
|
config = load_config()
|
|
image_config = config.tools.image_generation
|
|
changed = False
|
|
|
|
provider_name = _query_first(query, "provider")
|
|
if provider_name is not None:
|
|
provider_name = provider_name.strip().lower()
|
|
if not provider_name:
|
|
raise WebUISettingsError("image generation provider is required")
|
|
if get_image_gen_provider(provider_name) is None:
|
|
raise WebUISettingsError("unknown image generation provider")
|
|
if image_config.provider != provider_name:
|
|
image_config.provider = provider_name
|
|
changed = True
|
|
|
|
enabled = _query_first(query, "enabled")
|
|
if enabled is not None:
|
|
parsed_enabled = _parse_bool(enabled, "enabled")
|
|
if image_config.enabled != parsed_enabled:
|
|
image_config.enabled = parsed_enabled
|
|
changed = True
|
|
|
|
model = _query_first(query, "model")
|
|
if model is not None:
|
|
model = model.strip()
|
|
if not model:
|
|
raise WebUISettingsError("image generation model is required")
|
|
if len(model) > 200:
|
|
raise WebUISettingsError("image generation model is too long")
|
|
if image_config.model != model:
|
|
image_config.model = model
|
|
changed = True
|
|
|
|
default_aspect_ratio = _query_first_alias(
|
|
query,
|
|
"default_aspect_ratio",
|
|
"defaultAspectRatio",
|
|
)
|
|
if default_aspect_ratio is not None:
|
|
default_aspect_ratio = default_aspect_ratio.strip()
|
|
if default_aspect_ratio not in _IMAGE_GENERATION_ASPECT_RATIOS:
|
|
raise WebUISettingsError("unsupported image generation aspect ratio")
|
|
if image_config.default_aspect_ratio != default_aspect_ratio:
|
|
image_config.default_aspect_ratio = default_aspect_ratio
|
|
changed = True
|
|
|
|
default_image_size = _query_first_alias(
|
|
query,
|
|
"default_image_size",
|
|
"defaultImageSize",
|
|
)
|
|
if default_image_size is not None:
|
|
default_image_size = default_image_size.strip()
|
|
if not default_image_size:
|
|
raise WebUISettingsError("default image size is required")
|
|
if len(default_image_size) > 32 or not all(
|
|
char.isascii() and (char.isalnum() or char in {"x", "X", ":", "-", "_"})
|
|
for char in default_image_size
|
|
):
|
|
raise WebUISettingsError("unsupported image generation size")
|
|
if image_config.default_image_size != default_image_size:
|
|
image_config.default_image_size = default_image_size
|
|
changed = True
|
|
|
|
max_images_per_turn = _query_first_alias(
|
|
query,
|
|
"max_images_per_turn",
|
|
"maxImagesPerTurn",
|
|
)
|
|
if max_images_per_turn is not None:
|
|
try:
|
|
parsed_max = int(max_images_per_turn)
|
|
except ValueError:
|
|
raise WebUISettingsError("max_images_per_turn must be an integer") from None
|
|
if parsed_max < 1 or parsed_max > 8:
|
|
raise WebUISettingsError("max_images_per_turn must be between 1 and 8")
|
|
if image_config.max_images_per_turn != parsed_max:
|
|
image_config.max_images_per_turn = parsed_max
|
|
changed = True
|
|
|
|
if image_config.enabled:
|
|
selected_provider = next(
|
|
(
|
|
provider
|
|
for provider in _image_generation_provider_rows(config)
|
|
if provider["name"] == image_config.provider
|
|
),
|
|
None,
|
|
)
|
|
if not selected_provider or not selected_provider["configured"]:
|
|
raise WebUISettingsError("image generation provider is not configured")
|
|
|
|
if changed:
|
|
save_config(config)
|
|
return settings_payload(requires_restart=changed)
|