feat(core): 支持LLM多图片响应,增强UI主题皮肤系统及优化JSON/Markdown处理 (#2062)

- 【LLM服务】
  - `LLMResponse` 模型现在支持 `images: list[bytes]`,允许模型返回多张图片。
  - LLM适配器 (`base.py`, `gemini.py`) 和 API 层 (`api.py`, `service.py`) 已更新以处理多图片响应。
  - 响应验证逻辑已调整,以检查 `images` 列表而非单个 `image_bytes`。
- 【UI渲染服务】
  - 引入组件“皮肤”(variant)概念,允许为同一组件提供不同视觉风格。
  - 改进了 `manifest.json` 的加载、合并和缓存机制,支持基础清单与皮肤清单的递归合并。
  - `ThemeManager` 现在会缓存已加载的清单,并在主题重载时清除缓存。
  - 增强了资源解析器 (`ResourceResolver`),支持 `@` 命名空间路径和更健壮的相对路径处理。
  - 独立模板现在会继承主 Jinja 环境的过滤器。
- 【工具函数】
  - 引入 `dump_json_safely` 工具函数,用于更安全地序列化包含 Pydantic 模型、枚举等复杂类型的对象为 JSON。
  - LLM 服务中的请求体和缓存键生成已改用 `dump_json_safely`。
  - 优化了 `format_usage_for_markdown` 函数,改进了 Markdown 文本的格式化,确保块级元素前有正确换行,并正确处理段落内硬换行。

Co-authored-by: webjoin111 <455457521@qq.com>
This commit is contained in:
Rumio 2025-10-09 08:50:40 +08:00 committed by GitHub
parent e7f3c210df
commit 74a9f3a843
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 205 additions and 55 deletions

View File

@ -35,7 +35,7 @@ class ResponseData(BaseModel):
"""响应数据封装 - 支持所有高级功能""" """响应数据封装 - 支持所有高级功能"""
text: str text: str
image_bytes: bytes | None = None images: list[bytes] | None = None
usage_info: dict[str, Any] | None = None usage_info: dict[str, Any] | None = None
raw_response: dict[str, Any] | None = None raw_response: dict[str, Any] | None = None
tool_calls: list[LLMToolCall] | None = None tool_calls: list[LLMToolCall] | None = None
@ -246,17 +246,17 @@ class BaseAdapter(ABC):
if content: if content:
content = content.strip() content = content.strip()
image_bytes: bytes | None = None images_bytes: list[bytes] = []
if content and content.startswith("{") and content.endswith("}"): if content and content.startswith("{") and content.endswith("}"):
try: try:
content_json = json.loads(content) content_json = json.loads(content)
if "b64_json" in content_json: if "b64_json" in content_json:
image_bytes = base64.b64decode(content_json["b64_json"]) images_bytes.append(base64.b64decode(content_json["b64_json"]))
content = "[图片已生成]" content = "[图片已生成]"
elif "data" in content_json and isinstance( elif "data" in content_json and isinstance(
content_json["data"], str content_json["data"], str
): ):
image_bytes = base64.b64decode(content_json["data"]) images_bytes.append(base64.b64decode(content_json["data"]))
content = "[图片已生成]" content = "[图片已生成]"
except (json.JSONDecodeError, KeyError, binascii.Error): except (json.JSONDecodeError, KeyError, binascii.Error):
@ -273,7 +273,7 @@ class BaseAdapter(ABC):
if url_str.startswith("data:image/png;base64,"): if url_str.startswith("data:image/png;base64,"):
try: try:
b64_data = url_str.split(",", 1)[1] b64_data = url_str.split(",", 1)[1]
image_bytes = base64.b64decode(b64_data) images_bytes.append(base64.b64decode(b64_data))
content = content if content else "[图片已生成]" content = content if content else "[图片已生成]"
except (IndexError, binascii.Error) as e: except (IndexError, binascii.Error) as e:
logger.warning(f"解析OpenRouter Base64图片数据失败: {e}") logger.warning(f"解析OpenRouter Base64图片数据失败: {e}")
@ -316,7 +316,7 @@ class BaseAdapter(ABC):
text=final_text, text=final_text,
tool_calls=parsed_tool_calls, tool_calls=parsed_tool_calls,
usage_info=usage_info, usage_info=usage_info,
image_bytes=image_bytes, images=images_bytes if images_bytes else None,
raw_response=response_json, raw_response=response_json,
) )

View File

@ -408,7 +408,7 @@ class GeminiAdapter(BaseAdapter):
parts = content_data.get("parts", []) parts = content_data.get("parts", [])
text_content = "" text_content = ""
image_bytes: bytes | None = None images_bytes: list[bytes] = []
parsed_tool_calls: list["LLMToolCall"] | None = None parsed_tool_calls: list["LLMToolCall"] | None = None
thought_summary_parts = [] thought_summary_parts = []
answer_parts = [] answer_parts = []
@ -423,10 +423,7 @@ class GeminiAdapter(BaseAdapter):
elif "inlineData" in part: elif "inlineData" in part:
inline_data = part["inlineData"] inline_data = part["inlineData"]
if "data" in inline_data: if "data" in inline_data:
image_bytes = base64.b64decode(inline_data["data"]) images_bytes.append(base64.b64decode(inline_data["data"]))
answer_parts.append(
f"[图片已生成: {inline_data.get('mimeType', 'image')}]"
)
elif "functionCall" in part: elif "functionCall" in part:
if parsed_tool_calls is None: if parsed_tool_calls is None:
@ -494,7 +491,7 @@ class GeminiAdapter(BaseAdapter):
return ResponseData( return ResponseData(
text=text_content, text=text_content,
tool_calls=parsed_tool_calls, tool_calls=parsed_tool_calls,
image_bytes=image_bytes, images=images_bytes if images_bytes else None,
usage_info=usage_info, usage_info=usage_info,
raw_response=response_json, raw_response=response_json,
grounding_metadata=grounding_metadata_obj, grounding_metadata=grounding_metadata_obj,

View File

@ -339,7 +339,7 @@ async def _generate_image_from_message(
response = await model_instance.generate_response(messages, config=config) response = await model_instance.generate_response(messages, config=config)
if not response.image_bytes: if not response.images:
error_text = response.text or "模型未返回图片数据。" error_text = response.text or "模型未返回图片数据。"
logger.warning(f"图片生成调用未返回图片,返回文本内容: {error_text}") logger.warning(f"图片生成调用未返回图片,返回文本内容: {error_text}")

View File

@ -5,12 +5,12 @@ LLM 模型管理器
""" """
import hashlib import hashlib
import json
import time import time
from typing import Any from typing import Any
from zhenxun.configs.config import Config from zhenxun.configs.config import Config
from zhenxun.services.log import logger from zhenxun.services.log import logger
from zhenxun.utils.pydantic_compat import dump_json_safely
from .config import validate_override_params from .config import validate_override_params
from .config.providers import AI_CONFIG_GROUP, PROVIDERS_CONFIG_KEY, get_ai_config from .config.providers import AI_CONFIG_GROUP, PROVIDERS_CONFIG_KEY, get_ai_config
@ -43,7 +43,7 @@ def _make_cache_key(
) -> str: ) -> str:
"""生成缓存键""" """生成缓存键"""
config_str = ( config_str = (
json.dumps(override_config, sort_keys=True) if override_config else "None" dump_json_safely(override_config, sort_keys=True) if override_config else "None"
) )
key_data = f"{provider_model_name}:{config_str}" key_data = f"{provider_model_name}:{config_str}"
return hashlib.md5(key_data.encode()).hexdigest() return hashlib.md5(key_data.encode()).hexdigest()

View File

@ -13,6 +13,7 @@ from pydantic import BaseModel
from zhenxun.services.log import logger from zhenxun.services.log import logger
from zhenxun.utils.log_sanitizer import sanitize_for_logging from zhenxun.utils.log_sanitizer import sanitize_for_logging
from zhenxun.utils.pydantic_compat import dump_json_safely
from .adapters.base import RequestData from .adapters.base import RequestData
from .config import LLMGenerationConfig from .config import LLMGenerationConfig
@ -194,13 +195,15 @@ class LLMModel(LLMModelBase):
sanitized_body = sanitize_for_logging( sanitized_body = sanitize_for_logging(
request_data.body, context=sanitizer_req_context request_data.body, context=sanitizer_req_context
) )
request_body_str = json.dumps(sanitized_body, ensure_ascii=False, indent=2) request_body_str = dump_json_safely(
sanitized_body, ensure_ascii=False, indent=2
)
logger.debug(f"📦 请求体: {request_body_str}") logger.debug(f"📦 请求体: {request_body_str}")
http_response = await http_client.post( http_response = await http_client.post(
request_data.url, request_data.url,
headers=request_data.headers, headers=request_data.headers,
json=request_data.body, content=dump_json_safely(request_data.body, ensure_ascii=False),
) )
logger.debug(f"📥 响应状态码: {http_response.status_code}") logger.debug(f"📥 响应状态码: {http_response.status_code}")
@ -394,7 +397,7 @@ class LLMModel(LLMModelBase):
return LLMResponse( return LLMResponse(
text=response_data.text, text=response_data.text,
usage_info=response_data.usage_info, usage_info=response_data.usage_info,
image_bytes=response_data.image_bytes, images=response_data.images,
raw_response=response_data.raw_response, raw_response=response_data.raw_response,
tool_calls=response_tool_calls if response_tool_calls else None, tool_calls=response_tool_calls if response_tool_calls else None,
code_executions=response_data.code_executions, code_executions=response_data.code_executions,
@ -424,7 +427,7 @@ class LLMModel(LLMModelBase):
policy = config.validation_policy policy = config.validation_policy
if policy: if policy:
if policy.get("require_image") and not parsed_data.image_bytes: if policy.get("require_image") and not parsed_data.images:
if self.api_type == "gemini" and parsed_data.raw_response: if self.api_type == "gemini" and parsed_data.raw_response:
usage_metadata = parsed_data.raw_response.get( usage_metadata = parsed_data.raw_response.get(
"usageMetadata", {} "usageMetadata", {}

View File

@ -425,7 +425,7 @@ class LLMResponse(BaseModel):
"""LLM 响应""" """LLM 响应"""
text: str text: str
image_bytes: bytes | None = None images: list[bytes] | None = None
usage_info: dict[str, Any] | None = None usage_info: dict[str, Any] | None = None
raw_response: dict[str, Any] | None = None raw_response: dict[str, Any] | None = None
tool_calls: list[Any] | None = None tool_calls: list[Any] | None = None

View File

@ -217,16 +217,17 @@ class RendererService:
context.processed_components.add(component_id) context.processed_components.add(component_id)
component_path_base = str(component.template_name) component_path_base = str(component.template_name)
variant = getattr(component, "variant", None)
manifest = await context.theme_manager.get_template_manifest( manifest = await context.theme_manager.get_template_manifest(
component_path_base component_path_base, skin=variant
) )
style_paths_to_load = [] style_paths_to_load = []
if manifest and manifest.styles: if manifest and "styles" in manifest:
styles = ( styles = (
[manifest.styles] [manifest["styles"]]
if isinstance(manifest.styles, str) if isinstance(manifest["styles"], str)
else manifest.styles else manifest["styles"]
) )
for style_path in styles: for style_path in styles:
full_style_path = str(Path(component_path_base) / style_path).replace( full_style_path = str(Path(component_path_base) / style_path).replace(
@ -383,6 +384,7 @@ class RendererService:
) )
temp_env.globals.update(context.theme_manager.jinja_env.globals) temp_env.globals.update(context.theme_manager.jinja_env.globals)
temp_env.filters.update(context.theme_manager.jinja_env.filters)
temp_env.globals["asset"] = ( temp_env.globals["asset"] = (
context.theme_manager._create_standalone_asset_loader(template_dir) context.theme_manager._create_standalone_asset_loader(template_dir)
) )
@ -431,10 +433,11 @@ class RendererService:
component_render_options = {} component_render_options = {}
manifest_options = {} manifest_options = {}
variant = getattr(component, "variant", None)
if manifest := await context.theme_manager.get_template_manifest( if manifest := await context.theme_manager.get_template_manifest(
component.template_name component.template_name, skin=variant
): ):
manifest_options = manifest.render_options or {} manifest_options = manifest.get("render_options", {})
final_render_options = component_render_options.copy() final_render_options = component_render_options.copy()
final_render_options.update(manifest_options) final_render_options.update(manifest_options)
@ -557,6 +560,8 @@ class RendererService:
await self.initialize() await self.initialize()
assert self._theme_manager is not None, "ThemeManager 未初始化" assert self._theme_manager is not None, "ThemeManager 未初始化"
self._theme_manager._manifest_cache.clear()
logger.debug("已清除UI清单缓存 (manifest cache)。")
current_theme_name = Config.get_config("UI", "THEME", "default") current_theme_name = Config.get_config("UI", "THEME", "default")
await self._theme_manager.load_theme(current_theme_name) await self._theme_manager.load_theme(current_theme_name)
logger.info(f"主题 '{current_theme_name}' 已成功重载。") logger.info(f"主题 '{current_theme_name}' 已成功重载。")

View File

@ -1,11 +1,11 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from collections.abc import Callable from collections.abc import Callable
import os import os
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import aiofiles
from jinja2 import ( from jinja2 import (
ChoiceLoader, ChoiceLoader,
Environment, Environment,
@ -21,7 +21,6 @@ import ujson as json
from zhenxun.configs.path_config import THEMES_PATH from zhenxun.configs.path_config import THEMES_PATH
from zhenxun.services.log import logger from zhenxun.services.log import logger
from zhenxun.services.renderer.models import TemplateManifest
from zhenxun.services.renderer.protocols import Renderable from zhenxun.services.renderer.protocols import Renderable
from zhenxun.services.renderer.registry import asset_registry from zhenxun.services.renderer.registry import asset_registry
from zhenxun.utils.pydantic_compat import model_dump from zhenxun.utils.pydantic_compat import model_dump
@ -32,6 +31,20 @@ if TYPE_CHECKING:
from .config import RESERVED_TEMPLATE_KEYS from .config import RESERVED_TEMPLATE_KEYS
def deep_merge_dict(base: dict, new: dict) -> dict:
"""
递归地将 new 字典合并到 base 字典中
new 字典中的值会覆盖 base 字典中的值
"""
result = base.copy()
for key, value in new.items():
if isinstance(value, dict) and key in result and isinstance(result[key], dict):
result[key] = deep_merge_dict(result[key], value)
else:
result[key] = value
return result
class RelativePathEnvironment(Environment): class RelativePathEnvironment(Environment):
""" """
一个自定义的 Jinja2 环境重写了 join_path 方法以支持模板间的相对路径引用 一个自定义的 Jinja2 环境重写了 join_path 方法以支持模板间的相对路径引用
@ -151,14 +164,42 @@ class ResourceResolver:
def resolve_asset_uri(self, asset_path: str, current_template_name: str) -> str: def resolve_asset_uri(self, asset_path: str, current_template_name: str) -> str:
"""解析资源路径实现完整的回退逻辑并返回可用的URI。""" """解析资源路径实现完整的回退逻辑并返回可用的URI。"""
if not self.theme_manager.current_theme: if (
not self.theme_manager.current_theme
or not self.theme_manager.jinja_env.loader
):
return "" return ""
if asset_path.startswith("@"):
try:
full_asset_path = self.theme_manager.jinja_env.join_path(
asset_path, current_template_name
)
_source, file_abs_path, _uptodate = (
self.theme_manager.jinja_env.loader.get_source(
self.theme_manager.jinja_env, full_asset_path
)
)
if file_abs_path:
logger.debug(
f"Jinja Loader resolved asset '{asset_path}'->'{file_abs_path}'"
)
return Path(file_abs_path).absolute().as_uri()
except TemplateNotFound:
logger.warning(
f"资源文件在命名空间中未找到: '{asset_path}'"
f"(在模板 '{current_template_name}' 中引用)"
)
return ""
search_paths: list[tuple[str, Path]] = [] search_paths: list[tuple[str, Path]] = []
if asset_path.startswith("./"): if asset_path.startswith("./") or asset_path.startswith("../"):
relative_part = (
asset_path[2:] if asset_path.startswith("./") else asset_path
)
search_paths.extend( search_paths.extend(
self._search_paths_for_relative_asset( self._search_paths_for_relative_asset(
asset_path[2:], current_template_name relative_part, current_template_name
) )
) )
else: else:
@ -209,6 +250,9 @@ class ThemeManager:
self.jinja_env.filters["md"] = self._markdown_filter self.jinja_env.filters["md"] = self._markdown_filter
self._manifest_cache: dict[str, Any] = {}
self._manifest_cache_lock = asyncio.Lock()
def list_available_themes(self) -> list[str]: def list_available_themes(self) -> list[str]:
"""扫描主题目录并返回所有可用的主题名称。""" """扫描主题目录并返回所有可用的主题名称。"""
if not THEMES_PATH.is_dir(): if not THEMES_PATH.is_dir():
@ -377,16 +421,26 @@ class ThemeManager:
logger.error(f"指定的模板文件路径不存在: '{component_path_base}'", e=e) logger.error(f"指定的模板文件路径不存在: '{component_path_base}'", e=e)
raise e raise e
entrypoint_filename = "main.html" base_manifest = await self.get_template_manifest(component_path_base)
manifest = await self.get_template_manifest(component_path_base)
if manifest and manifest.entrypoint: skin_to_use = variant or (base_manifest.get("skin") if base_manifest else None)
entrypoint_filename = manifest.entrypoint
final_manifest = await self.get_template_manifest(
component_path_base, skin=skin_to_use
)
logger.debug(f"final_manifest: {final_manifest}")
entrypoint_filename = (
final_manifest.get("entrypoint", "main.html")
if final_manifest
else "main.html"
)
potential_paths = [] potential_paths = []
if variant: if skin_to_use:
potential_paths.append( potential_paths.append(
f"{component_path_base}/skins/{variant}/{entrypoint_filename}" f"{component_path_base}/skins/{skin_to_use}/{entrypoint_filename}"
) )
potential_paths.append(f"{component_path_base}/{entrypoint_filename}") potential_paths.append(f"{component_path_base}/{entrypoint_filename}")
@ -410,28 +464,88 @@ class ThemeManager:
logger.error(err_msg) logger.error(err_msg)
raise TemplateNotFound(err_msg) raise TemplateNotFound(err_msg)
async def get_template_manifest( async def _load_single_manifest(self, path_str: str) -> dict[str, Any] | None:
self, component_path: str """从指定路径加载单个 manifest.json 文件。"""
) -> TemplateManifest | None: normalized_path = path_str.replace("\\", "/")
""" manifest_path_str = f"{normalized_path}/manifest.json"
查找并解析组件的 manifest.json 文件
"""
manifest_path_str = f"{component_path}/manifest.json"
if not self.jinja_env.loader: if not self.jinja_env.loader:
return None return None
try: try:
_, full_path, _ = self.jinja_env.loader.get_source( source, filepath, _ = self.jinja_env.loader.get_source(
self.jinja_env, manifest_path_str self.jinja_env, manifest_path_str
) )
if full_path and Path(full_path).exists(): logger.debug(f"找到清单文件: '{manifest_path_str}' (从 '{filepath}' 加载)")
async with aiofiles.open(full_path, encoding="utf-8") as f: return json.loads(source)
manifest_data = json.loads(await f.read())
return TemplateManifest(**manifest_data)
except TemplateNotFound: except TemplateNotFound:
logger.trace(f"未找到清单文件: '{manifest_path_str}'")
return None return None
return None except json.JSONDecodeError:
logger.warning(f"清单文件 '{manifest_path_str}' 解析失败")
return None
async def _load_and_merge_manifests(
self, component_path: Path | str, skin: str | None = None
) -> dict[str, Any] | None:
"""加载基础和皮肤清单并进行合并。"""
logger.debug(f"开始加载清单: component_path='{component_path}', skin='{skin}'")
base_manifest = await self._load_single_manifest(str(component_path))
if skin:
skin_path = Path(component_path) / "skins" / skin
skin_manifest = await self._load_single_manifest(str(skin_path))
if skin_manifest:
if base_manifest:
merged = deep_merge_dict(base_manifest, skin_manifest)
logger.debug(
f"已合并基础清单和皮肤清单: '{component_path}' + skin '{skin}'"
)
return merged
else:
logger.debug(f"只找到皮肤清单: '{skin_path}'")
return skin_manifest
if base_manifest:
logger.debug(f"只找到基础清单: '{component_path}'")
else:
logger.debug(f"未找到任何清单: '{component_path}'")
return base_manifest
async def get_template_manifest(
self, component_path: str, skin: str | None = None
) -> dict[str, Any] | None:
"""
查找并解析组件的 manifest.json 文件
支持皮肤清单的继承与合并,并带有缓存
Args:
component_path: 组件路径
skin: 皮肤名称(可选)
Returns:
合并后的清单字典,如果不存在则返回 None
"""
cache_key = f"{component_path}:{skin or 'base'}"
if cache_key in self._manifest_cache:
logger.debug(f"清单缓存命中: '{cache_key}'")
return self._manifest_cache[cache_key]
async with self._manifest_cache_lock:
if cache_key in self._manifest_cache:
logger.debug(f"清单缓存命中(锁内): '{cache_key}'")
return self._manifest_cache[cache_key]
manifest = await self._load_and_merge_manifests(component_path, skin)
self._manifest_cache[cache_key] = manifest
logger.debug(f"清单已缓存: '{cache_key}'")
return manifest
async def resolve_markdown_style_path( async def resolve_markdown_style_path(
self, style_name: str, context: "RenderContext" self, style_name: str, context: "RenderContext"

View File

@ -126,12 +126,15 @@ class SqlUtils:
def format_usage_for_markdown(text: str) -> str: def format_usage_for_markdown(text: str) -> str:
""" """
智能地将Python多行字符串转换为适合Markdown渲染的格式 智能地将Python多行字符串转换为适合Markdown渲染的格式
- 将单个换行符替换为Markdown的硬换行行尾加两个空格 - 在列表标题等块级元素前自动插入换行确保正确解析
- 将段落内的单个换行符替换为Markdown的硬换行行尾加两个空格
- 保留两个或更多的连续换行符使其成为Markdown的段落分隔 - 保留两个或更多的连续换行符使其成为Markdown的段落分隔
""" """
if not text: if not text:
return "" return ""
text = re.sub(r"\n{2,}", "<<PARAGRAPH_BREAK>>", text)
text = text.replace("\n", " \n") text = re.sub(r"([^\n])\n(\s*[-*] |\s*#+\s|\s*>)", r"\1\n\n\2", text)
text = text.replace("<<PARAGRAPH_BREAK>>", "\n\n")
text = re.sub(r"(?<!\n)\n(?!\n)", " \n", text)
return text return text

View File

@ -5,10 +5,14 @@ Pydantic V1 & V2 兼容层模块
包括 model_dump, model_copy, model_json_schema, parse_as 包括 model_dump, model_copy, model_json_schema, parse_as
""" """
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Any, TypeVar, get_args, get_origin from typing import Any, TypeVar, get_args, get_origin
from nonebot.compat import PYDANTIC_V2, model_dump from nonebot.compat import PYDANTIC_V2, model_dump
from pydantic import VERSION, BaseModel from pydantic import VERSION, BaseModel
import ujson as json
T = TypeVar("T", bound=BaseModel) T = TypeVar("T", bound=BaseModel)
V = TypeVar("V") V = TypeVar("V")
@ -19,6 +23,7 @@ __all__ = [
"_dump_pydantic_obj", "_dump_pydantic_obj",
"_is_pydantic_type", "_is_pydantic_type",
"compat_computed_field", "compat_computed_field",
"dump_json_safely",
"model_copy", "model_copy",
"model_dump", "model_dump",
"model_json_schema", "model_json_schema",
@ -93,3 +98,26 @@ def parse_as(type_: type[V], obj: Any) -> V:
from pydantic import TypeAdapter # type: ignore from pydantic import TypeAdapter # type: ignore
return TypeAdapter(type_).validate_python(obj) return TypeAdapter(type_).validate_python(obj)
def dump_json_safely(obj: Any, **kwargs) -> str:
"""
安全地将可能包含 Pydantic 特定类型 ( Enum) 的对象序列化为 JSON 字符串
"""
def default_serializer(o):
if isinstance(o, Enum):
return o.value
if isinstance(o, datetime):
return o.isoformat()
if isinstance(o, Path):
return str(o.as_posix())
if isinstance(o, set):
return list(o)
if isinstance(o, BaseModel):
return model_dump(o)
raise TypeError(
f"Object of type {o.__class__.__name__} is not JSON serializable"
)
return json.dumps(obj, default=default_serializer, **kwargs)