* 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
194 lines
6.1 KiB
Python
194 lines
6.1 KiB
Python
"""Persisted WebUI sidebar workspace state.
|
|
|
|
This state is UI-only metadata, scoped to the active nanobot instance data
|
|
directory (the directory containing the current config.json). It deliberately
|
|
does not modify agent sessions.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from loguru import logger
|
|
|
|
from nanobot.config.paths import get_webui_dir
|
|
|
|
WEBUI_SIDEBAR_STATE_SCHEMA_VERSION = 1
|
|
_MAX_STATE_FILE_BYTES = 256 * 1024
|
|
_MAX_LIST_ITEMS = 2_000
|
|
_MAX_MAP_ITEMS = 2_000
|
|
_MAX_KEY_LEN = 512
|
|
_MAX_TITLE_LEN = 160
|
|
_MAX_TAG_LEN = 40
|
|
_ALLOWED_DENSITIES = {"comfortable", "compact"}
|
|
_ALLOWED_SORTS = {"updated_desc", "created_desc", "title_asc"}
|
|
|
|
|
|
def webui_sidebar_state_path() -> Path:
|
|
return get_webui_dir() / "sidebar-state.json"
|
|
|
|
|
|
def default_webui_sidebar_state() -> dict[str, Any]:
|
|
return {
|
|
"schema_version": WEBUI_SIDEBAR_STATE_SCHEMA_VERSION,
|
|
"pinned_keys": [],
|
|
"archived_keys": [],
|
|
"title_overrides": {},
|
|
"tags_by_key": {},
|
|
"collapsed_groups": {},
|
|
"view": {
|
|
"density": "comfortable",
|
|
"show_previews": False,
|
|
"show_timestamps": False,
|
|
"show_archived": False,
|
|
"sort": "updated_desc",
|
|
},
|
|
"updated_at": None,
|
|
}
|
|
|
|
|
|
def _clean_string(value: Any, *, max_len: int = _MAX_KEY_LEN) -> str | None:
|
|
if not isinstance(value, str):
|
|
return None
|
|
cleaned = value.strip()
|
|
if not cleaned:
|
|
return None
|
|
return cleaned[:max_len]
|
|
|
|
|
|
def _clean_string_list(value: Any, *, max_len: int = _MAX_KEY_LEN) -> list[str]:
|
|
if not isinstance(value, list):
|
|
return []
|
|
out: list[str] = []
|
|
seen: set[str] = set()
|
|
for item in value[:_MAX_LIST_ITEMS]:
|
|
cleaned = _clean_string(item, max_len=max_len)
|
|
if cleaned is None or cleaned in seen:
|
|
continue
|
|
seen.add(cleaned)
|
|
out.append(cleaned)
|
|
return out
|
|
|
|
|
|
def _clean_bool_map(value: Any) -> dict[str, bool]:
|
|
if not isinstance(value, dict):
|
|
return {}
|
|
out: dict[str, bool] = {}
|
|
for key, raw in list(value.items())[:_MAX_MAP_ITEMS]:
|
|
cleaned_key = _clean_string(key)
|
|
if cleaned_key is None:
|
|
continue
|
|
out[cleaned_key] = bool(raw)
|
|
return out
|
|
|
|
|
|
def _clean_title_overrides(value: Any) -> dict[str, str]:
|
|
if not isinstance(value, dict):
|
|
return {}
|
|
out: dict[str, str] = {}
|
|
for key, raw_title in list(value.items())[:_MAX_MAP_ITEMS]:
|
|
cleaned_key = _clean_string(key)
|
|
cleaned_title = _clean_string(raw_title, max_len=_MAX_TITLE_LEN)
|
|
if cleaned_key is None or cleaned_title is None:
|
|
continue
|
|
out[cleaned_key] = cleaned_title
|
|
return out
|
|
|
|
|
|
def _clean_tags_by_key(value: Any) -> dict[str, list[str]]:
|
|
if not isinstance(value, dict):
|
|
return {}
|
|
out: dict[str, list[str]] = {}
|
|
for key, raw_tags in list(value.items())[:_MAX_MAP_ITEMS]:
|
|
cleaned_key = _clean_string(key)
|
|
if cleaned_key is None:
|
|
continue
|
|
tags = _clean_string_list(raw_tags, max_len=_MAX_TAG_LEN)[:12]
|
|
if tags:
|
|
out[cleaned_key] = tags
|
|
return out
|
|
|
|
|
|
def _clean_view(value: Any) -> dict[str, Any]:
|
|
default = default_webui_sidebar_state()["view"]
|
|
if not isinstance(value, dict):
|
|
return dict(default)
|
|
density = value.get("density")
|
|
sort = value.get("sort")
|
|
return {
|
|
"density": density if density in _ALLOWED_DENSITIES else default["density"],
|
|
"show_previews": bool(value.get("show_previews", default["show_previews"])),
|
|
"show_timestamps": bool(value.get("show_timestamps", default["show_timestamps"])),
|
|
"show_archived": bool(value.get("show_archived", default["show_archived"])),
|
|
"sort": sort if sort in _ALLOWED_SORTS else default["sort"],
|
|
}
|
|
|
|
|
|
def normalize_webui_sidebar_state(raw: Any) -> dict[str, Any]:
|
|
"""Return a schema-v1 sidebar state from any older/partial input."""
|
|
if not isinstance(raw, dict):
|
|
raw = {}
|
|
state = default_webui_sidebar_state()
|
|
state["pinned_keys"] = _clean_string_list(raw.get("pinned_keys"))
|
|
state["archived_keys"] = _clean_string_list(raw.get("archived_keys"))
|
|
state["title_overrides"] = _clean_title_overrides(raw.get("title_overrides"))
|
|
state["tags_by_key"] = _clean_tags_by_key(raw.get("tags_by_key"))
|
|
state["collapsed_groups"] = _clean_bool_map(raw.get("collapsed_groups"))
|
|
state["view"] = _clean_view(raw.get("view"))
|
|
updated_at = raw.get("updated_at")
|
|
state["updated_at"] = updated_at if isinstance(updated_at, str) else None
|
|
return state
|
|
|
|
|
|
def read_webui_sidebar_state() -> dict[str, Any]:
|
|
path = webui_sidebar_state_path()
|
|
if not path.is_file():
|
|
return default_webui_sidebar_state()
|
|
try:
|
|
if path.stat().st_size > _MAX_STATE_FILE_BYTES:
|
|
logger.warning("webui sidebar state too large, ignoring: {}", path)
|
|
return default_webui_sidebar_state()
|
|
with open(path, encoding="utf-8") as f:
|
|
raw = json.load(f)
|
|
except (OSError, json.JSONDecodeError) as e:
|
|
logger.warning("read webui sidebar state failed {}: {}", path, e)
|
|
return default_webui_sidebar_state()
|
|
return normalize_webui_sidebar_state(raw)
|
|
|
|
|
|
def write_webui_sidebar_state(raw: dict[str, Any]) -> dict[str, Any]:
|
|
state = normalize_webui_sidebar_state(raw)
|
|
state["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
encoded = json.dumps(
|
|
state,
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
sort_keys=True,
|
|
).encode("utf-8")
|
|
if len(encoded) > _MAX_STATE_FILE_BYTES:
|
|
raise ValueError("sidebar state is too large")
|
|
|
|
path = webui_sidebar_state_path()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = path.with_suffix(".json.tmp")
|
|
with open(tmp, "wb") as f:
|
|
f.write(encoded)
|
|
f.write(b"\n")
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
os.replace(tmp, path)
|
|
try:
|
|
dir_fd = os.open(path.parent, os.O_RDONLY)
|
|
except OSError:
|
|
return state
|
|
try:
|
|
os.fsync(dir_fd)
|
|
finally:
|
|
os.close(dir_fd)
|
|
return state
|
|
|