feat(webui): refine output timeline and model controls (#4108)
* feat(webui): refine output timeline and composer queue * feat(webui): add provider model picker * fix(webui): polish model settings and heartbeat checks * chore: keep heartbeat changes out of webui pr * refactor(webui): isolate settings routes * fix(providers): align minimax anthropic test * fix(providers): keep minimax anthropic base sdk-compatible * fix(providers): normalize anthropic base urls
This commit is contained in:
+15
-287
@@ -27,7 +27,6 @@ from websockets.exceptions import ConnectionClosed
|
||||
from websockets.http11 import Request as WsRequest
|
||||
from websockets.http11 import Response
|
||||
|
||||
from nanobot.agent.tools.mcp import request_mcp_reload
|
||||
from nanobot.security.workspace_access import (
|
||||
WORKSPACE_SCOPE_METADATA_KEY,
|
||||
WorkspaceScopeError,
|
||||
@@ -45,35 +44,15 @@ from nanobot.utils.media_decode import (
|
||||
save_base64_data_url,
|
||||
)
|
||||
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
|
||||
from nanobot.webui.settings_api import (
|
||||
WebUISettingsError,
|
||||
create_model_configuration,
|
||||
decorate_settings_payload,
|
||||
login_oauth_provider,
|
||||
logout_oauth_provider,
|
||||
runtime_capabilities,
|
||||
settings_payload,
|
||||
update_agent_settings,
|
||||
update_image_generation_settings,
|
||||
update_model_configuration,
|
||||
update_network_safety_settings,
|
||||
update_provider_settings,
|
||||
update_web_search_settings,
|
||||
)
|
||||
from nanobot.webui.cli_apps_api import (
|
||||
cli_apps_action,
|
||||
cli_apps_payload,
|
||||
normalize_cli_app_mentions,
|
||||
)
|
||||
from nanobot.webui.settings_api import runtime_capabilities
|
||||
from nanobot.webui.cli_apps_api import normalize_cli_app_mentions
|
||||
from nanobot.webui.media_api import (
|
||||
serve_signed_media,
|
||||
sign_media_path,
|
||||
sign_or_stage_media_path,
|
||||
)
|
||||
from nanobot.webui.mcp_presets_api import (
|
||||
mcp_presets_settings_action,
|
||||
normalize_mcp_preset_mentions,
|
||||
)
|
||||
from nanobot.webui.mcp_presets_api import normalize_mcp_preset_mentions
|
||||
from nanobot.webui.settings_routes import WebUISettingsRouter
|
||||
from nanobot.webui.sidebar_state import (
|
||||
read_webui_sidebar_state,
|
||||
write_webui_sidebar_state,
|
||||
@@ -88,18 +67,6 @@ from nanobot.webui.workspaces import (
|
||||
WebUIWorkspaceController,
|
||||
)
|
||||
|
||||
_MCP_PRESET_ACTIONS_BY_PATH = {
|
||||
"/api/settings/mcp-presets/enable": "enable",
|
||||
"/api/settings/mcp-presets/remove": "remove",
|
||||
"/api/settings/mcp-presets/test": "test",
|
||||
"/api/settings/mcp-presets/custom": "custom",
|
||||
"/api/settings/mcp-presets/import": "import",
|
||||
"/api/settings/mcp-presets/import-cursor": "import-cursor",
|
||||
"/api/settings/mcp-presets/tools": "tools",
|
||||
}
|
||||
_MCP_VALUES_HEADER = "X-Nanobot-MCP-Values"
|
||||
_MCP_VALUES_HEADER_MAX_BYTES = 64 * 1024
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.session.manager import SessionManager
|
||||
|
||||
@@ -318,34 +285,6 @@ def _parse_query(path_with_query: str) -> dict[str, list[str]]:
|
||||
return _parse_request_path(path_with_query)[1]
|
||||
|
||||
|
||||
def _parse_mcp_settings_query(request: WsRequest) -> dict[str, list[str]]:
|
||||
query = _parse_query(request.path)
|
||||
raw = request.headers.get(_MCP_VALUES_HEADER)
|
||||
if not raw:
|
||||
return query
|
||||
if len(raw.encode("utf-8")) > _MCP_VALUES_HEADER_MAX_BYTES:
|
||||
raise WebUISettingsError("MCP settings payload is too large")
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise WebUISettingsError("invalid MCP settings payload") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise WebUISettingsError("MCP settings payload must be a JSON object")
|
||||
merged = {key: list(values) for key, values in query.items()}
|
||||
for key, value in payload.items():
|
||||
if not isinstance(key, str) or not key:
|
||||
raise WebUISettingsError("MCP settings payload contains an invalid key")
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
else:
|
||||
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||
if text:
|
||||
merged[key] = [text]
|
||||
return merged
|
||||
|
||||
|
||||
def _query_first(query: dict[str, list[str]], key: str) -> str | None:
|
||||
"""Return the first value for *key*, or None."""
|
||||
values = query.get(key)
|
||||
@@ -586,7 +525,16 @@ class WebSocketChannel(BaseChannel):
|
||||
self._runtime_surface,
|
||||
runtime_capabilities_overrides,
|
||||
)
|
||||
self._settings_restart_sections: set[str] = set()
|
||||
self._settings_routes = WebUISettingsRouter(
|
||||
bus=self.bus,
|
||||
logger=self.logger,
|
||||
check_api_token=self._check_api_token,
|
||||
parse_query=_parse_query,
|
||||
json_response=_http_json_response,
|
||||
error_response=_http_error,
|
||||
runtime_surface=self._runtime_surface,
|
||||
runtime_capabilities=self._runtime_capabilities,
|
||||
)
|
||||
self._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
|
||||
# Process-local secret used to HMAC-sign media URLs. The signed URL is
|
||||
# the capability — anyone who holds a valid URL can fetch that one
|
||||
@@ -808,59 +756,7 @@ class WebSocketChannel(BaseChannel):
|
||||
request: WsRequest,
|
||||
got: str,
|
||||
) -> Response | None:
|
||||
if got == "/api/settings":
|
||||
return self._handle_settings(request)
|
||||
|
||||
if got == "/api/settings/update":
|
||||
return self._handle_settings_update(request)
|
||||
|
||||
if got == "/api/settings/model-configurations/create":
|
||||
return self._handle_settings_model_configuration_create(request)
|
||||
|
||||
if got == "/api/settings/model-configurations/update":
|
||||
return self._handle_settings_model_configuration_update(request)
|
||||
|
||||
if got == "/api/settings/provider/update":
|
||||
return self._handle_settings_provider_update(request)
|
||||
|
||||
if got == "/api/settings/provider/oauth-login":
|
||||
return await self._handle_settings_provider_oauth(request, "login")
|
||||
|
||||
if got == "/api/settings/provider/oauth-logout":
|
||||
return await self._handle_settings_provider_oauth(request, "logout")
|
||||
|
||||
if got == "/api/settings/web-search/update":
|
||||
return self._handle_settings_web_search_update(request)
|
||||
|
||||
if got == "/api/settings/image-generation/update":
|
||||
return self._handle_settings_image_generation_update(request)
|
||||
|
||||
if got == "/api/settings/network-safety/update":
|
||||
return self._handle_settings_network_safety_update(request)
|
||||
|
||||
if got == "/api/settings/cli-apps":
|
||||
return self._handle_settings_cli_apps(request)
|
||||
|
||||
if got == "/api/settings/cli-apps/install":
|
||||
return await self._handle_settings_cli_apps_action(request, "install")
|
||||
|
||||
if got == "/api/settings/cli-apps/update":
|
||||
return await self._handle_settings_cli_apps_action(request, "update")
|
||||
|
||||
if got == "/api/settings/cli-apps/uninstall":
|
||||
return await self._handle_settings_cli_apps_action(request, "uninstall")
|
||||
|
||||
if got == "/api/settings/cli-apps/test":
|
||||
return await self._handle_settings_cli_apps_action(request, "test")
|
||||
|
||||
if got == "/api/settings/mcp-presets":
|
||||
return await self._handle_settings_mcp_presets(request)
|
||||
|
||||
mcp_action = _MCP_PRESET_ACTIONS_BY_PATH.get(got)
|
||||
if mcp_action is not None:
|
||||
return await self._handle_settings_mcp_presets(request, mcp_action)
|
||||
|
||||
return None
|
||||
return await self._settings_routes.dispatch(request, got)
|
||||
|
||||
def _dispatch_session_api_route(
|
||||
self,
|
||||
@@ -1019,38 +915,6 @@ class WebSocketChannel(BaseChannel):
|
||||
self._webui_workspaces.payload(controls_available=_is_localhost(connection))
|
||||
)
|
||||
|
||||
def _handle_settings(self, request: WsRequest) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
return _http_json_response(
|
||||
self._with_settings_restart_state(
|
||||
settings_payload(
|
||||
surface=self._runtime_surface,
|
||||
runtime_capability_overrides=self._runtime_capabilities,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def _with_settings_restart_state(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
section: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Keep restart-required state alive for this gateway process."""
|
||||
if section and payload.get("requires_restart"):
|
||||
self._settings_restart_sections.add(section)
|
||||
sections = sorted(self._settings_restart_sections)
|
||||
payload = dict(payload)
|
||||
if sections:
|
||||
payload["requires_restart"] = True
|
||||
return decorate_settings_payload(
|
||||
payload,
|
||||
surface=self._runtime_surface,
|
||||
runtime_capability_overrides=self._runtime_capabilities,
|
||||
restart_required_sections=sections,
|
||||
)
|
||||
|
||||
def _handle_commands(self, request: WsRequest) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
@@ -1083,142 +947,6 @@ class WebSocketChannel(BaseChannel):
|
||||
return _http_error(500, "failed to write sidebar state")
|
||||
return _http_json_response(state)
|
||||
|
||||
def _handle_settings_update(self, request: WsRequest) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
query = _parse_query(request.path)
|
||||
try:
|
||||
payload = update_agent_settings(query)
|
||||
except WebUISettingsError as e:
|
||||
return _http_error(e.status, e.message)
|
||||
return _http_json_response(
|
||||
self._with_settings_restart_state(payload, section="runtime")
|
||||
)
|
||||
|
||||
def _handle_settings_model_configuration_create(self, request: WsRequest) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
query = _parse_query(request.path)
|
||||
try:
|
||||
payload = create_model_configuration(query)
|
||||
except WebUISettingsError as e:
|
||||
return _http_error(e.status, e.message)
|
||||
return _http_json_response(self._with_settings_restart_state(payload))
|
||||
|
||||
def _handle_settings_model_configuration_update(self, request: WsRequest) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
query = _parse_query(request.path)
|
||||
try:
|
||||
payload = update_model_configuration(query)
|
||||
except WebUISettingsError as e:
|
||||
return _http_error(e.status, e.message)
|
||||
return _http_json_response(self._with_settings_restart_state(payload))
|
||||
|
||||
def _handle_settings_provider_update(self, request: WsRequest) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
query = _parse_query(request.path)
|
||||
try:
|
||||
payload = update_provider_settings(query)
|
||||
except WebUISettingsError as e:
|
||||
return _http_error(e.status, e.message)
|
||||
return _http_json_response(self._with_settings_restart_state(payload, section="image"))
|
||||
|
||||
async def _handle_settings_provider_oauth(self, request: WsRequest, action: str) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
query = _parse_query(request.path)
|
||||
try:
|
||||
if action == "login":
|
||||
payload = await asyncio.to_thread(login_oauth_provider, query)
|
||||
else:
|
||||
payload = await asyncio.to_thread(logout_oauth_provider, query)
|
||||
except WebUISettingsError as e:
|
||||
return _http_error(e.status, e.message)
|
||||
return _http_json_response(self._with_settings_restart_state(payload))
|
||||
|
||||
def _handle_settings_web_search_update(self, request: WsRequest) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
query = _parse_query(request.path)
|
||||
try:
|
||||
payload = update_web_search_settings(query)
|
||||
except WebUISettingsError as e:
|
||||
return _http_error(e.status, e.message)
|
||||
return _http_json_response(self._with_settings_restart_state(payload, section="browser"))
|
||||
|
||||
def _handle_settings_image_generation_update(self, request: WsRequest) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
query = _parse_query(request.path)
|
||||
try:
|
||||
payload = update_image_generation_settings(query)
|
||||
except WebUISettingsError as e:
|
||||
return _http_error(e.status, e.message)
|
||||
return _http_json_response(self._with_settings_restart_state(payload, section="image"))
|
||||
|
||||
def _handle_settings_network_safety_update(self, request: WsRequest) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
query = _parse_query(request.path)
|
||||
try:
|
||||
payload = update_network_safety_settings(query)
|
||||
except WebUISettingsError as e:
|
||||
return _http_error(e.status, e.message)
|
||||
return _http_json_response(self._with_settings_restart_state(payload, section="runtime"))
|
||||
|
||||
def _handle_settings_cli_apps(self, request: WsRequest) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
try:
|
||||
payload = cli_apps_payload()
|
||||
except Exception:
|
||||
self.logger.exception("failed to load CLI Apps payload")
|
||||
return _http_error(500, "failed to load CLI Apps")
|
||||
return _http_json_response(payload)
|
||||
|
||||
async def _handle_settings_cli_apps_action(self, request: WsRequest, action: str) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
query = _parse_query(request.path)
|
||||
try:
|
||||
payload = await asyncio.to_thread(cli_apps_action, action, query)
|
||||
except WebUISettingsError as e:
|
||||
return _http_error(e.status, e.message)
|
||||
except Exception as e:
|
||||
status = getattr(e, "status", 500)
|
||||
message = getattr(e, "message", str(e))
|
||||
if status >= 500:
|
||||
self.logger.exception("CLI Apps action '{}' failed", action)
|
||||
return _http_error(status, message)
|
||||
return _http_json_response(payload)
|
||||
|
||||
async def _handle_settings_mcp_presets(
|
||||
self,
|
||||
request: WsRequest,
|
||||
action: str | None = None,
|
||||
) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
try:
|
||||
payload = await mcp_presets_settings_action(
|
||||
action,
|
||||
_parse_mcp_settings_query(request),
|
||||
reload_mcp=lambda: request_mcp_reload(self.bus),
|
||||
)
|
||||
except Exception as e:
|
||||
status = getattr(e, "status", 500)
|
||||
message = getattr(e, "message", str(e))
|
||||
if status >= 500:
|
||||
self.logger.exception("MCP preset action '{}' failed", action or "list")
|
||||
return _http_error(status, message)
|
||||
if action is None:
|
||||
return _http_json_response(payload)
|
||||
return _http_json_response(
|
||||
self._with_settings_restart_state(payload, section="runtime")
|
||||
)
|
||||
|
||||
# -- Session replay, transcript, and signed media ----------------------
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -45,13 +45,21 @@ class AnthropicProvider(LLMProvider):
|
||||
if api_key:
|
||||
client_kw["api_key"] = api_key
|
||||
if api_base:
|
||||
client_kw["base_url"] = api_base
|
||||
client_kw["base_url"] = self._normalize_base_url(api_base)
|
||||
if extra_headers:
|
||||
client_kw["default_headers"] = extra_headers
|
||||
# Keep retries centralized in LLMProvider._run_with_retry to avoid retry amplification.
|
||||
client_kw["max_retries"] = 0
|
||||
self._client = AsyncAnthropic(**client_kw)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_base_url(api_base: str) -> str:
|
||||
"""Anthropic SDK appends /v1 to request paths internally."""
|
||||
normalized = api_base.rstrip("/")
|
||||
if normalized.endswith("/v1"):
|
||||
return normalized[: -len("/v1")]
|
||||
return normalized
|
||||
|
||||
@classmethod
|
||||
def _handle_error(cls, e: Exception) -> LLMResponse:
|
||||
response = getattr(e, "response", None)
|
||||
|
||||
@@ -19,6 +19,7 @@ from nanobot.utils.helpers import (
|
||||
find_legal_message_start,
|
||||
image_placeholder_text,
|
||||
safe_filename,
|
||||
strip_think,
|
||||
)
|
||||
from nanobot.utils.subagent_channel_display import scrub_subagent_announce_body
|
||||
|
||||
@@ -76,6 +77,17 @@ def _message_preview_text(message: dict[str, Any]) -> str:
|
||||
return _text_preview(content)
|
||||
|
||||
|
||||
def _metadata_title(metadata: Any) -> str:
|
||||
if not isinstance(metadata, dict):
|
||||
return ""
|
||||
title = metadata.get("title")
|
||||
if not isinstance(title, str):
|
||||
return ""
|
||||
if metadata.get("title_user_edited") is True:
|
||||
return title
|
||||
return strip_think(title)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Session:
|
||||
"""A conversation session."""
|
||||
@@ -642,7 +654,7 @@ class SessionManager:
|
||||
if data.get("_type") == "metadata":
|
||||
key = data.get("key") or path.stem.replace("_", ":", 1)
|
||||
metadata = data.get("metadata", {})
|
||||
title = metadata.get("title") if isinstance(metadata, dict) else None
|
||||
title = _metadata_title(metadata)
|
||||
preview = ""
|
||||
fallback_preview = ""
|
||||
scanned_records = 0
|
||||
@@ -673,7 +685,7 @@ class SessionManager:
|
||||
"key": key,
|
||||
"created_at": data.get("created_at"),
|
||||
"updated_at": data.get("updated_at"),
|
||||
"title": title if isinstance(title, str) else "",
|
||||
"title": title,
|
||||
"preview": preview,
|
||||
"path": str(path)
|
||||
})
|
||||
@@ -684,11 +696,7 @@ class SessionManager:
|
||||
"key": repaired.key,
|
||||
"created_at": repaired.created_at.isoformat(),
|
||||
"updated_at": repaired.updated_at.isoformat(),
|
||||
"title": (
|
||||
repaired.metadata.get("title")
|
||||
if isinstance(repaired.metadata.get("title"), str)
|
||||
else ""
|
||||
),
|
||||
"title": _metadata_title(repaired.metadata),
|
||||
"preview": next(
|
||||
(
|
||||
text
|
||||
|
||||
@@ -19,7 +19,7 @@ from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMProvider
|
||||
from nanobot.session.goal_state import goal_state_ws_blob
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
from nanobot.utils.helpers import truncate_text
|
||||
from nanobot.utils.helpers import strip_think, truncate_text
|
||||
from nanobot.utils.llm_runtime import LLMRuntime
|
||||
|
||||
WEBUI_SESSION_METADATA_KEY = "webui"
|
||||
@@ -48,6 +48,7 @@ def clean_generated_title(raw: str | None) -> str:
|
||||
return ""
|
||||
text = re.sub(r"^\s*(title|标题)\s*[::]\s*", "", text, flags=re.IGNORECASE)
|
||||
text = text.strip().strip("\"'`“”‘’")
|
||||
text = strip_think(text)
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
text = text.rstrip("。.!!??,,;;:")
|
||||
if len(text) > TITLE_MAX_CHARS:
|
||||
@@ -65,6 +66,9 @@ def _title_inputs(session: Session) -> tuple[str, str]:
|
||||
content = message.get("content")
|
||||
if not isinstance(content, str) or not content.strip():
|
||||
continue
|
||||
content = strip_think(content)
|
||||
if not content:
|
||||
continue
|
||||
if role == "user" and not user_text:
|
||||
user_text = content.strip()
|
||||
elif role == "assistant" and not assistant_text:
|
||||
@@ -89,7 +93,13 @@ async def maybe_generate_webui_title(
|
||||
return False
|
||||
current_title = session.metadata.get(WEBUI_TITLE_METADATA_KEY)
|
||||
if isinstance(current_title, str) and current_title.strip():
|
||||
return False
|
||||
cleaned_current_title = clean_generated_title(current_title)
|
||||
if cleaned_current_title:
|
||||
if cleaned_current_title != current_title:
|
||||
session.metadata[WEBUI_TITLE_METADATA_KEY] = cleaned_current_title
|
||||
sessions.save(session)
|
||||
return False
|
||||
session.metadata.pop(WEBUI_TITLE_METADATA_KEY, None)
|
||||
|
||||
user_text, assistant_text = _title_inputs(session)
|
||||
if not user_text:
|
||||
|
||||
@@ -50,10 +50,17 @@ _MEDIA_ALLOWED_MIMES: frozenset[str] = frozenset({
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
"image/svg+xml",
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
"video/quicktime",
|
||||
})
|
||||
_SVG_MEDIA_HEADERS: tuple[tuple[str, str], ...] = (
|
||||
(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox",
|
||||
),
|
||||
)
|
||||
|
||||
_BYTE_RANGE_RE = re.compile(r"^bytes=(\d*)-(\d*)$")
|
||||
|
||||
@@ -203,6 +210,8 @@ def serve_signed_media(
|
||||
("Cache-Control", "private, max-age=31536000, immutable"),
|
||||
("X-Content-Type-Options", "nosniff"),
|
||||
]
|
||||
if mime == "image/svg+xml":
|
||||
common_headers.extend(_SVG_MEDIA_HEADERS)
|
||||
try:
|
||||
size = candidate.stat().st_size
|
||||
except OSError:
|
||||
|
||||
@@ -6,12 +6,15 @@ settings payload shape and the allowlisted config mutations exposed to WebUI.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from typing import Any, Literal
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
|
||||
from nanobot.config.loader import get_config_path, load_config, save_config
|
||||
from nanobot.config.schema import ModelPresetConfig
|
||||
from nanobot.providers.image_generation import (
|
||||
@@ -87,6 +90,47 @@ _IMAGE_GENERATION_ASPECT_RATIOS = {
|
||||
}
|
||||
_CONTEXT_WINDOW_TOKEN_OPTIONS = {65_536, 262_144}
|
||||
_MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+")
|
||||
_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
||||
|
||||
_MODEL_LIST_UNSUPPORTED_BACKENDS = {
|
||||
"anthropic",
|
||||
"azure_openai",
|
||||
"bedrock",
|
||||
"github_copilot",
|
||||
"openai_codex",
|
||||
}
|
||||
|
||||
_MODEL_LIST_CATALOG_PROVIDERS = {
|
||||
"aihubmix",
|
||||
"byteplus",
|
||||
"byteplus_coding_plan",
|
||||
"huggingface",
|
||||
"novita",
|
||||
"openrouter",
|
||||
"siliconflow",
|
||||
"volcengine",
|
||||
"volcengine_coding_plan",
|
||||
}
|
||||
|
||||
_MODEL_LIST_OFFICIAL_PROVIDERS = {
|
||||
"ant_ling",
|
||||
"dashscope",
|
||||
"deepseek",
|
||||
"gemini",
|
||||
"groq",
|
||||
"longcat",
|
||||
"minimax",
|
||||
"minimax_anthropic",
|
||||
"mistral",
|
||||
"moonshot",
|
||||
"nvidia",
|
||||
"openai",
|
||||
"qianfan",
|
||||
"skywork",
|
||||
"stepfun",
|
||||
"xiaomi_mimo",
|
||||
"zhipu",
|
||||
}
|
||||
|
||||
|
||||
class WebUISettingsError(ValueError):
|
||||
@@ -180,6 +224,25 @@ def _mask_secret_hint(secret: str | None) -> str | None:
|
||||
return f"{secret[:4]}••••{secret[-4:]}"
|
||||
|
||||
|
||||
def _resolve_env_placeholders(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
missing = False
|
||||
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
nonlocal missing
|
||||
env_value = os.environ.get(match.group(1))
|
||||
if env_value is None:
|
||||
missing = True
|
||||
return ""
|
||||
return env_value
|
||||
|
||||
resolved = _ENV_REF_RE.sub(replace, value).strip()
|
||||
if missing and not resolved:
|
||||
return None
|
||||
return resolved or None
|
||||
|
||||
|
||||
def _provider_requires_api_key(spec: Any) -> bool:
|
||||
if spec.backend == "azure_openai":
|
||||
return True
|
||||
@@ -251,6 +314,191 @@ def _provider_configured_for_settings(spec: Any, provider_config: Any) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _model_catalog_kind(spec: Any) -> str:
|
||||
if spec.name in _MODEL_LIST_CATALOG_PROVIDERS:
|
||||
return "catalog"
|
||||
if spec.name in _MODEL_LIST_OFFICIAL_PROVIDERS:
|
||||
return "official"
|
||||
if spec.is_local:
|
||||
return "local"
|
||||
if spec.is_direct:
|
||||
return "custom"
|
||||
if spec.is_gateway:
|
||||
return "catalog"
|
||||
return "official"
|
||||
|
||||
|
||||
def _model_id_from_row(row: Any) -> str | None:
|
||||
if isinstance(row, str):
|
||||
return row.strip() or None
|
||||
if not isinstance(row, dict):
|
||||
return None
|
||||
for key in ("id", "name", "model"):
|
||||
value = row.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _model_context_window(row: Any) -> int | None:
|
||||
if not isinstance(row, dict):
|
||||
return None
|
||||
for key in (
|
||||
"context_window",
|
||||
"context_length",
|
||||
"max_context_length",
|
||||
"max_model_len",
|
||||
"max_input_tokens",
|
||||
):
|
||||
value = row.get(key)
|
||||
if isinstance(value, int) and value > 0:
|
||||
return value
|
||||
if isinstance(value, float) and value > 0:
|
||||
return int(value)
|
||||
return None
|
||||
|
||||
|
||||
def _model_row_payload(row: Any) -> dict[str, Any] | None:
|
||||
model_id = _model_id_from_row(row)
|
||||
if not model_id:
|
||||
return None
|
||||
label: str | None = None
|
||||
owned_by: str | None = None
|
||||
if isinstance(row, dict):
|
||||
raw_label = row.get("display_name") or row.get("label") or row.get("name")
|
||||
if isinstance(raw_label, str) and raw_label.strip() and raw_label.strip() != model_id:
|
||||
label = raw_label.strip()
|
||||
raw_owner = row.get("owned_by") or row.get("owner") or row.get("organization")
|
||||
if isinstance(raw_owner, str) and raw_owner.strip():
|
||||
owned_by = raw_owner.strip()
|
||||
return {
|
||||
"id": model_id,
|
||||
"label": label,
|
||||
"owned_by": owned_by,
|
||||
"context_window": _model_context_window(row),
|
||||
}
|
||||
|
||||
|
||||
def _extract_model_rows(body: Any) -> list[dict[str, Any]]:
|
||||
raw_rows = body.get("data") if isinstance(body, dict) else body
|
||||
if not isinstance(raw_rows, list):
|
||||
return []
|
||||
rows: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for raw_row in raw_rows:
|
||||
row = _model_row_payload(raw_row)
|
||||
if row is None or row["id"] in seen:
|
||||
continue
|
||||
seen.add(row["id"])
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def provider_models_payload(query: QueryParams) -> dict[str, Any]:
|
||||
"""Fetch an OpenAI-compatible provider's model list for Settings.
|
||||
|
||||
The result is advisory only: users can always type a custom model id. This
|
||||
helper deliberately avoids mutating config so probing model lists never
|
||||
changes runtime behavior.
|
||||
"""
|
||||
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:
|
||||
raise WebUISettingsError("unknown provider")
|
||||
|
||||
base_payload: dict[str, Any] = {
|
||||
"provider": spec.name,
|
||||
"label": spec.label,
|
||||
"catalog_kind": _model_catalog_kind(spec),
|
||||
"models": [],
|
||||
"model_count": 0,
|
||||
"message": None,
|
||||
"fetched_at": time.time(),
|
||||
}
|
||||
if (
|
||||
spec.backend in _MODEL_LIST_UNSUPPORTED_BACKENDS
|
||||
and spec.name != "minimax_anthropic"
|
||||
) or spec.is_oauth:
|
||||
return {
|
||||
**base_payload,
|
||||
"status": "unsupported",
|
||||
"catalog_kind": "unsupported",
|
||||
"message": "Model list is not available for this provider. Type a model ID manually.",
|
||||
}
|
||||
|
||||
config = load_config()
|
||||
provider_config = getattr(config.providers, spec.name, None)
|
||||
if provider_config is None:
|
||||
raise WebUISettingsError("unknown provider")
|
||||
|
||||
api_base = _resolve_env_placeholders(provider_config.api_base) or spec.default_api_base
|
||||
if spec.name == "openai" and not api_base:
|
||||
api_base = "https://api.openai.com/v1"
|
||||
if not api_base:
|
||||
return {
|
||||
**base_payload,
|
||||
"status": "missing_api_base",
|
||||
"message": "Configure an API base URL to load models.",
|
||||
}
|
||||
|
||||
api_key = _resolve_env_placeholders(provider_config.api_key)
|
||||
if _provider_requires_api_key(spec) and not api_key:
|
||||
return {
|
||||
**base_payload,
|
||||
"status": "not_configured",
|
||||
"message": "Configure this provider before loading models.",
|
||||
}
|
||||
|
||||
headers = {"Accept": "application/json"}
|
||||
if api_key:
|
||||
if spec.name == "minimax_anthropic":
|
||||
headers["X-Api-Key"] = api_key
|
||||
else:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
models_url = f"{api_base.rstrip('/')}/models"
|
||||
if spec.name == "minimax_anthropic" and not api_base.rstrip("/").endswith("/v1"):
|
||||
models_url = f"{api_base.rstrip('/')}/v1/models"
|
||||
|
||||
try:
|
||||
response = httpx.get(
|
||||
models_url,
|
||||
headers=headers,
|
||||
timeout=10.0,
|
||||
follow_redirects=False,
|
||||
)
|
||||
response.raise_for_status()
|
||||
rows = _extract_model_rows(response.json())
|
||||
except httpx.HTTPStatusError as exc:
|
||||
status = exc.response.status_code
|
||||
if status in {401, 403}:
|
||||
return {
|
||||
**base_payload,
|
||||
"status": "not_configured",
|
||||
"message": "The provider rejected the configured credential.",
|
||||
}
|
||||
return {
|
||||
**base_payload,
|
||||
"status": "error",
|
||||
"message": f"Model list request failed with HTTP {status}.",
|
||||
}
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
return {
|
||||
**base_payload,
|
||||
"status": "error",
|
||||
"message": f"Could not load models: {exc}",
|
||||
}
|
||||
|
||||
return {
|
||||
**base_payload,
|
||||
"status": "available",
|
||||
"models": rows,
|
||||
"model_count": len(rows),
|
||||
}
|
||||
|
||||
|
||||
def _parse_bool(value: str, field: str) -> bool:
|
||||
normalized = value.strip().lower()
|
||||
if normalized not in {"1", "0", "true", "false", "yes", "no"}:
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
"""HTTP route adapter for WebUI Settings APIs.
|
||||
|
||||
Keep WebUI Settings route handlers here, not in ``channels/websocket.py``.
|
||||
The websocket channel owns transport concerns; this module owns WebUI Settings
|
||||
request mapping and response shaping.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from websockets.http11 import Request as WsRequest
|
||||
from websockets.http11 import Response
|
||||
|
||||
from nanobot.agent.tools.mcp import request_mcp_reload
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.webui.cli_apps_api import cli_apps_action, cli_apps_payload
|
||||
from nanobot.webui.mcp_presets_api import mcp_presets_settings_action
|
||||
from nanobot.webui.settings_api import (
|
||||
WebUISettingsError,
|
||||
create_model_configuration,
|
||||
decorate_settings_payload,
|
||||
login_oauth_provider,
|
||||
logout_oauth_provider,
|
||||
provider_models_payload,
|
||||
settings_payload,
|
||||
update_agent_settings,
|
||||
update_image_generation_settings,
|
||||
update_model_configuration,
|
||||
update_network_safety_settings,
|
||||
update_provider_settings,
|
||||
update_web_search_settings,
|
||||
)
|
||||
|
||||
QueryParams = dict[str, list[str]]
|
||||
|
||||
_MCP_VALUES_HEADER = "X-Nanobot-MCP-Values"
|
||||
_MCP_VALUES_HEADER_MAX_BYTES = 64 * 1024
|
||||
|
||||
_MCP_PRESET_ACTIONS_BY_PATH = {
|
||||
"/api/settings/mcp-presets/enable": "enable",
|
||||
"/api/settings/mcp-presets/remove": "remove",
|
||||
"/api/settings/mcp-presets/test": "test",
|
||||
"/api/settings/mcp-presets/custom": "custom",
|
||||
"/api/settings/mcp-presets/import": "import",
|
||||
"/api/settings/mcp-presets/import-cursor": "import-cursor",
|
||||
"/api/settings/mcp-presets/tools": "tools",
|
||||
}
|
||||
|
||||
|
||||
class WebUISettingsRouter:
|
||||
"""Route WebUI Settings HTTP requests behind a transport-neutral boundary."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
bus: MessageBus,
|
||||
logger: Any,
|
||||
check_api_token: Callable[[WsRequest], bool],
|
||||
parse_query: Callable[[str], QueryParams],
|
||||
json_response: Callable[[dict[str, Any]], Response],
|
||||
error_response: Callable[[int, str | None], Response],
|
||||
runtime_surface: str,
|
||||
runtime_capabilities: dict[str, Any],
|
||||
) -> None:
|
||||
self.bus = bus
|
||||
self.logger = logger
|
||||
self._check_api_token = check_api_token
|
||||
self._parse_query = parse_query
|
||||
self._json_response = json_response
|
||||
self._error_response = error_response
|
||||
self._runtime_surface = runtime_surface
|
||||
self._runtime_capabilities = runtime_capabilities
|
||||
self._restart_sections: set[str] = set()
|
||||
|
||||
async def dispatch(self, request: WsRequest, path: str) -> Response | None:
|
||||
if path == "/api/settings":
|
||||
return self._handle_settings(request)
|
||||
if path == "/api/settings/update":
|
||||
return self._handle_settings_update(request)
|
||||
if path == "/api/settings/model-configurations/create":
|
||||
return self._handle_settings_model_configuration_create(request)
|
||||
if path == "/api/settings/model-configurations/update":
|
||||
return self._handle_settings_model_configuration_update(request)
|
||||
if path == "/api/settings/provider/update":
|
||||
return self._handle_settings_provider_update(request)
|
||||
if path == "/api/settings/provider-models":
|
||||
return await self._handle_settings_provider_models(request)
|
||||
if path == "/api/settings/provider/oauth-login":
|
||||
return await self._handle_settings_provider_oauth(request, "login")
|
||||
if path == "/api/settings/provider/oauth-logout":
|
||||
return await self._handle_settings_provider_oauth(request, "logout")
|
||||
if path == "/api/settings/web-search/update":
|
||||
return self._handle_settings_web_search_update(request)
|
||||
if path == "/api/settings/image-generation/update":
|
||||
return self._handle_settings_image_generation_update(request)
|
||||
if path == "/api/settings/network-safety/update":
|
||||
return self._handle_settings_network_safety_update(request)
|
||||
if path == "/api/settings/cli-apps":
|
||||
return self._handle_settings_cli_apps(request)
|
||||
if path == "/api/settings/cli-apps/install":
|
||||
return await self._handle_settings_cli_apps_action(request, "install")
|
||||
if path == "/api/settings/cli-apps/update":
|
||||
return await self._handle_settings_cli_apps_action(request, "update")
|
||||
if path == "/api/settings/cli-apps/uninstall":
|
||||
return await self._handle_settings_cli_apps_action(request, "uninstall")
|
||||
if path == "/api/settings/cli-apps/test":
|
||||
return await self._handle_settings_cli_apps_action(request, "test")
|
||||
if path == "/api/settings/mcp-presets":
|
||||
return await self._handle_settings_mcp_presets(request)
|
||||
mcp_action = _MCP_PRESET_ACTIONS_BY_PATH.get(path)
|
||||
if mcp_action is not None:
|
||||
return await self._handle_settings_mcp_presets(request, mcp_action)
|
||||
return None
|
||||
|
||||
def _query(self, request: WsRequest) -> QueryParams:
|
||||
return self._parse_query(request.path)
|
||||
|
||||
def _authorized(self, request: WsRequest) -> bool:
|
||||
return self._check_api_token(request)
|
||||
|
||||
def _unauthorized(self) -> Response:
|
||||
return self._error_response(401, "Unauthorized")
|
||||
|
||||
def _with_restart_state(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
section: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Keep restart-required state alive for this gateway process."""
|
||||
if section and payload.get("requires_restart"):
|
||||
self._restart_sections.add(section)
|
||||
sections = sorted(self._restart_sections)
|
||||
payload = dict(payload)
|
||||
if sections:
|
||||
payload["requires_restart"] = True
|
||||
return decorate_settings_payload(
|
||||
payload,
|
||||
surface=self._runtime_surface,
|
||||
runtime_capability_overrides=self._runtime_capabilities,
|
||||
restart_required_sections=sections,
|
||||
)
|
||||
|
||||
def _parse_mcp_settings_query(self, request: WsRequest) -> QueryParams:
|
||||
query = self._query(request)
|
||||
raw = request.headers.get(_MCP_VALUES_HEADER)
|
||||
if not raw:
|
||||
return query
|
||||
if len(raw.encode("utf-8")) > _MCP_VALUES_HEADER_MAX_BYTES:
|
||||
raise WebUISettingsError("MCP settings payload is too large")
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise WebUISettingsError("invalid MCP settings payload") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise WebUISettingsError("MCP settings payload must be a JSON object")
|
||||
merged = {key: list(values) for key, values in query.items()}
|
||||
for key, value in payload.items():
|
||||
if not isinstance(key, str) or not key:
|
||||
raise WebUISettingsError("MCP settings payload contains an invalid key")
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
else:
|
||||
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||
if text:
|
||||
merged[key] = [text]
|
||||
return merged
|
||||
|
||||
def _handle_settings(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
return self._json_response(
|
||||
self._with_restart_state(
|
||||
settings_payload(
|
||||
surface=self._runtime_surface,
|
||||
runtime_capability_overrides=self._runtime_capabilities,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def _handle_settings_update(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = update_agent_settings(self._query(request))
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload, section="runtime"))
|
||||
|
||||
def _handle_settings_model_configuration_create(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = create_model_configuration(self._query(request))
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload))
|
||||
|
||||
def _handle_settings_model_configuration_update(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = update_model_configuration(self._query(request))
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload))
|
||||
|
||||
def _handle_settings_provider_update(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = update_provider_settings(self._query(request))
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload, section="image"))
|
||||
|
||||
async def _handle_settings_provider_models(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = await asyncio.to_thread(provider_models_payload, self._query(request))
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
except Exception:
|
||||
self.logger.exception("failed to load provider model list")
|
||||
return self._error_response(500, "failed to load provider model list")
|
||||
return self._json_response(payload)
|
||||
|
||||
async def _handle_settings_provider_oauth(
|
||||
self,
|
||||
request: WsRequest,
|
||||
action: str,
|
||||
) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
query = self._query(request)
|
||||
try:
|
||||
if action == "login":
|
||||
payload = await asyncio.to_thread(login_oauth_provider, query)
|
||||
else:
|
||||
payload = await asyncio.to_thread(logout_oauth_provider, query)
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload))
|
||||
|
||||
def _handle_settings_web_search_update(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = update_web_search_settings(self._query(request))
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload, section="browser"))
|
||||
|
||||
def _handle_settings_image_generation_update(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = update_image_generation_settings(self._query(request))
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload, section="image"))
|
||||
|
||||
def _handle_settings_network_safety_update(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = update_network_safety_settings(self._query(request))
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload, section="runtime"))
|
||||
|
||||
def _handle_settings_cli_apps(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = cli_apps_payload()
|
||||
except Exception:
|
||||
self.logger.exception("failed to load CLI Apps payload")
|
||||
return self._error_response(500, "failed to load CLI Apps")
|
||||
return self._json_response(payload)
|
||||
|
||||
async def _handle_settings_cli_apps_action(
|
||||
self,
|
||||
request: WsRequest,
|
||||
action: str,
|
||||
) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = await asyncio.to_thread(cli_apps_action, action, self._query(request))
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
except Exception as e:
|
||||
status = getattr(e, "status", 500)
|
||||
message = getattr(e, "message", str(e))
|
||||
if status >= 500:
|
||||
self.logger.exception("CLI Apps action '{}' failed", action)
|
||||
return self._error_response(status, message)
|
||||
return self._json_response(payload)
|
||||
|
||||
async def _handle_settings_mcp_presets(
|
||||
self,
|
||||
request: WsRequest,
|
||||
action: str | None = None,
|
||||
) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = await mcp_presets_settings_action(
|
||||
action,
|
||||
self._parse_mcp_settings_query(request),
|
||||
reload_mcp=lambda: request_mcp_reload(self.bus),
|
||||
)
|
||||
except Exception as e:
|
||||
status = getattr(e, "status", 500)
|
||||
message = getattr(e, "message", str(e))
|
||||
if status >= 500:
|
||||
self.logger.exception("MCP preset action '{}' failed", action or "list")
|
||||
return self._error_response(status, message)
|
||||
if action is None:
|
||||
return self._json_response(payload)
|
||||
return self._json_response(self._with_restart_state(payload, section="runtime"))
|
||||
@@ -27,6 +27,7 @@ _INLINE_MARKDOWN_IMAGE_EXTS: frozenset[str] = frozenset({
|
||||
".jpeg",
|
||||
".webp",
|
||||
".gif",
|
||||
".svg",
|
||||
})
|
||||
_INLINE_MARKDOWN_VIDEO_EXTS: frozenset[str] = frozenset({
|
||||
".mp4",
|
||||
@@ -87,7 +88,12 @@ def rewrite_local_markdown_images(
|
||||
|
||||
|
||||
def _media_kind_from_name(name: str) -> str:
|
||||
return "video" if Path(name).suffix.lower() in _INLINE_MARKDOWN_VIDEO_EXTS else "image"
|
||||
ext = Path(name).suffix.lower()
|
||||
if ext in _INLINE_MARKDOWN_IMAGE_EXTS:
|
||||
return "image"
|
||||
if ext in _INLINE_MARKDOWN_VIDEO_EXTS:
|
||||
return "video"
|
||||
return "file"
|
||||
|
||||
|
||||
def webui_transcript_path(session_key: str) -> Path:
|
||||
|
||||
Reference in New Issue
Block a user