Files
nanobot/nanobot/utils/artifacts.py
T
Xubin RenandGitHub 57d5276da1 feat(webui): upgrade settings and sidebar controls (#3906)
* 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
2026-05-19 22:42:38 +08:00

123 lines
3.9 KiB
Python

"""Artifact persistence helpers for generated media."""
from __future__ import annotations
import base64
import binascii
import json
import re
import uuid
from datetime import datetime
from pathlib import Path, PurePosixPath
from typing import Any
from nanobot.config.paths import get_media_dir
from nanobot.utils.helpers import detect_image_mime, ensure_dir
_DATA_IMAGE_RE = re.compile(r"^data:(image/[A-Za-z0-9.+-]+);base64,(.*)$", re.DOTALL)
_MIME_EXTENSIONS = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/webp": ".webp",
"image/gif": ".gif",
}
class ArtifactError(ValueError):
"""Raised when an artifact cannot be safely decoded or stored."""
def decode_image_data_url(data_url: str) -> tuple[bytes, str]:
"""Decode a base64 image data URL and return ``(bytes, mime)``."""
match = _DATA_IMAGE_RE.match(data_url.strip())
if match is None:
raise ArtifactError("expected a base64 image data URL")
declared_mime, encoded = match.groups()
try:
raw = base64.b64decode(encoded, validate=True)
except binascii.Error as exc:
raise ArtifactError("invalid base64 image payload") from exc
detected_mime = detect_image_mime(raw)
if detected_mime is None:
raise ArtifactError("unsupported or unrecognized image data")
if declared_mime != detected_mime:
declared_mime = detected_mime
return raw, declared_mime
def _safe_relative_dir(save_dir: str) -> Path:
normalized = save_dir.replace("\\", "/").strip("/")
if not normalized:
raise ArtifactError("save_dir must not be empty")
rel = PurePosixPath(normalized)
if rel.is_absolute() or any(part in {"", ".", ".."} for part in rel.parts):
raise ArtifactError("save_dir must be a safe relative path")
return Path(*rel.parts)
def _artifact_root(save_dir: str) -> Path:
media_root = get_media_dir().resolve()
root = (media_root / _safe_relative_dir(save_dir)).resolve()
try:
root.relative_to(media_root)
except ValueError as exc:
raise ArtifactError("artifact directory escapes media root") from exc
return root
def store_generated_image_artifact(
data_url: str,
*,
prompt: str,
model: str,
source_images: list[str] | None = None,
save_dir: str = "generated",
provider: str = "openrouter",
created_at: datetime | None = None,
) -> dict[str, Any]:
"""Persist a generated image and sidecar metadata under the media root."""
raw, mime = decode_image_data_url(data_url)
ext = _MIME_EXTENSIONS.get(mime)
if ext is None:
raise ArtifactError(f"unsupported image MIME type: {mime}")
now = created_at or datetime.now().astimezone()
day_dir = ensure_dir(_artifact_root(save_dir) / now.strftime("%Y-%m-%d"))
artifact_id = f"img_{uuid.uuid4().hex[:12]}"
image_path = day_dir / f"{artifact_id}{ext}"
metadata_path = day_dir / f"{artifact_id}.json"
image_path.write_bytes(raw)
metadata: dict[str, Any] = {
"id": artifact_id,
"path": str(image_path),
"mime": mime,
"prompt": prompt,
"model": model,
"provider": provider,
"source_images": list(source_images or []),
"created_at": now.isoformat(),
}
metadata_path.write_text(
json.dumps(metadata, ensure_ascii=False, indent=2),
encoding="utf-8",
)
return metadata
def generated_image_tool_result(artifacts: list[dict[str, Any]]) -> str:
"""Return the compact structured result exposed to the LLM."""
return json.dumps(
{
"artifacts": artifacts,
"next_step": (
"Use these artifact paths as reference_images for follow-up edits. "
"Call the message tool with the artifact paths in the media parameter "
"to deliver the images to the user. Keep raw paths internal unless the "
"user asks for debug details."
),
},
ensure_ascii=False,
)