mirror of
https://github.com/zhenxun-org/zhenxun_bot.git
synced 2025-12-14 21:52:56 +08:00
✨ 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:
parent
e7f3c210df
commit
74a9f3a843
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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}")
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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", {}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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}' 已成功重载。")
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user