diff --git a/zhenxun/services/llm/adapters/base.py b/zhenxun/services/llm/adapters/base.py index 01e7d7c7..67888816 100644 --- a/zhenxun/services/llm/adapters/base.py +++ b/zhenxun/services/llm/adapters/base.py @@ -35,7 +35,7 @@ class ResponseData(BaseModel): """响应数据封装 - 支持所有高级功能""" text: str - image_bytes: bytes | None = None + images: list[bytes] | None = None usage_info: dict[str, Any] | None = None raw_response: dict[str, Any] | None = None tool_calls: list[LLMToolCall] | None = None @@ -246,17 +246,17 @@ class BaseAdapter(ABC): if content: content = content.strip() - image_bytes: bytes | None = None + images_bytes: list[bytes] = [] if content and content.startswith("{") and content.endswith("}"): try: content_json = json.loads(content) if "b64_json" in content_json: - image_bytes = base64.b64decode(content_json["b64_json"]) + images_bytes.append(base64.b64decode(content_json["b64_json"])) content = "[图片已生成]" elif "data" in content_json and isinstance( content_json["data"], str ): - image_bytes = base64.b64decode(content_json["data"]) + images_bytes.append(base64.b64decode(content_json["data"])) content = "[图片已生成]" except (json.JSONDecodeError, KeyError, binascii.Error): @@ -273,7 +273,7 @@ class BaseAdapter(ABC): if url_str.startswith("data:image/png;base64,"): try: 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 "[图片已生成]" except (IndexError, binascii.Error) as e: logger.warning(f"解析OpenRouter Base64图片数据失败: {e}") @@ -316,7 +316,7 @@ class BaseAdapter(ABC): text=final_text, tool_calls=parsed_tool_calls, usage_info=usage_info, - image_bytes=image_bytes, + images=images_bytes if images_bytes else None, raw_response=response_json, ) diff --git a/zhenxun/services/llm/adapters/gemini.py b/zhenxun/services/llm/adapters/gemini.py index 75498f49..b3fb109d 100644 --- a/zhenxun/services/llm/adapters/gemini.py +++ b/zhenxun/services/llm/adapters/gemini.py @@ -408,7 +408,7 @@ class GeminiAdapter(BaseAdapter): parts = content_data.get("parts", []) text_content = "" - image_bytes: bytes | None = None + images_bytes: list[bytes] = [] parsed_tool_calls: list["LLMToolCall"] | None = None thought_summary_parts = [] answer_parts = [] @@ -423,10 +423,7 @@ class GeminiAdapter(BaseAdapter): elif "inlineData" in part: inline_data = part["inlineData"] if "data" in inline_data: - image_bytes = base64.b64decode(inline_data["data"]) - answer_parts.append( - f"[图片已生成: {inline_data.get('mimeType', 'image')}]" - ) + images_bytes.append(base64.b64decode(inline_data["data"])) elif "functionCall" in part: if parsed_tool_calls is None: @@ -494,7 +491,7 @@ class GeminiAdapter(BaseAdapter): return ResponseData( text=text_content, tool_calls=parsed_tool_calls, - image_bytes=image_bytes, + images=images_bytes if images_bytes else None, usage_info=usage_info, raw_response=response_json, grounding_metadata=grounding_metadata_obj, diff --git a/zhenxun/services/llm/api.py b/zhenxun/services/llm/api.py index 2e0932a6..23da7f1b 100644 --- a/zhenxun/services/llm/api.py +++ b/zhenxun/services/llm/api.py @@ -339,7 +339,7 @@ async def _generate_image_from_message( response = await model_instance.generate_response(messages, config=config) - if not response.image_bytes: + if not response.images: error_text = response.text or "模型未返回图片数据。" logger.warning(f"图片生成调用未返回图片,返回文本内容: {error_text}") diff --git a/zhenxun/services/llm/manager.py b/zhenxun/services/llm/manager.py index c6f8384e..dbe6d675 100644 --- a/zhenxun/services/llm/manager.py +++ b/zhenxun/services/llm/manager.py @@ -5,12 +5,12 @@ LLM 模型管理器 """ import hashlib -import json import time from typing import Any from zhenxun.configs.config import Config from zhenxun.services.log import logger +from zhenxun.utils.pydantic_compat import dump_json_safely from .config import validate_override_params from .config.providers import AI_CONFIG_GROUP, PROVIDERS_CONFIG_KEY, get_ai_config @@ -43,7 +43,7 @@ def _make_cache_key( ) -> 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}" return hashlib.md5(key_data.encode()).hexdigest() diff --git a/zhenxun/services/llm/service.py b/zhenxun/services/llm/service.py index 4708020c..5b95bdf4 100644 --- a/zhenxun/services/llm/service.py +++ b/zhenxun/services/llm/service.py @@ -13,6 +13,7 @@ from pydantic import BaseModel from zhenxun.services.log import logger from zhenxun.utils.log_sanitizer import sanitize_for_logging +from zhenxun.utils.pydantic_compat import dump_json_safely from .adapters.base import RequestData from .config import LLMGenerationConfig @@ -194,13 +195,15 @@ class LLMModel(LLMModelBase): sanitized_body = sanitize_for_logging( 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}") http_response = await http_client.post( request_data.url, 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}") @@ -394,7 +397,7 @@ class LLMModel(LLMModelBase): return LLMResponse( text=response_data.text, usage_info=response_data.usage_info, - image_bytes=response_data.image_bytes, + images=response_data.images, raw_response=response_data.raw_response, tool_calls=response_tool_calls if response_tool_calls else None, code_executions=response_data.code_executions, @@ -424,7 +427,7 @@ class LLMModel(LLMModelBase): policy = config.validation_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: usage_metadata = parsed_data.raw_response.get( "usageMetadata", {} diff --git a/zhenxun/services/llm/types/content.py b/zhenxun/services/llm/types/content.py index bd32e32b..2ceee5a4 100644 --- a/zhenxun/services/llm/types/content.py +++ b/zhenxun/services/llm/types/content.py @@ -425,7 +425,7 @@ class LLMResponse(BaseModel): """LLM 响应""" text: str - image_bytes: bytes | None = None + images: list[bytes] | None = None usage_info: dict[str, Any] | None = None raw_response: dict[str, Any] | None = None tool_calls: list[Any] | None = None diff --git a/zhenxun/services/renderer/service.py b/zhenxun/services/renderer/service.py index 61a8650c..0e2a1413 100644 --- a/zhenxun/services/renderer/service.py +++ b/zhenxun/services/renderer/service.py @@ -217,16 +217,17 @@ class RendererService: context.processed_components.add(component_id) component_path_base = str(component.template_name) + variant = getattr(component, "variant", None) manifest = await context.theme_manager.get_template_manifest( - component_path_base + component_path_base, skin=variant ) style_paths_to_load = [] - if manifest and manifest.styles: + if manifest and "styles" in manifest: styles = ( - [manifest.styles] - if isinstance(manifest.styles, str) - else manifest.styles + [manifest["styles"]] + if isinstance(manifest["styles"], str) + else manifest["styles"] ) for style_path in styles: 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.filters.update(context.theme_manager.jinja_env.filters) temp_env.globals["asset"] = ( context.theme_manager._create_standalone_asset_loader(template_dir) ) @@ -431,10 +433,11 @@ class RendererService: component_render_options = {} manifest_options = {} + variant = getattr(component, "variant", None) 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.update(manifest_options) @@ -557,6 +560,8 @@ class RendererService: await self.initialize() 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") await self._theme_manager.load_theme(current_theme_name) logger.info(f"主题 '{current_theme_name}' 已成功重载。") diff --git a/zhenxun/services/renderer/theme.py b/zhenxun/services/renderer/theme.py index 1740739b..410176b2 100644 --- a/zhenxun/services/renderer/theme.py +++ b/zhenxun/services/renderer/theme.py @@ -1,11 +1,11 @@ from __future__ import annotations +import asyncio from collections.abc import Callable import os from pathlib import Path from typing import TYPE_CHECKING, Any -import aiofiles from jinja2 import ( ChoiceLoader, Environment, @@ -21,7 +21,6 @@ import ujson as json from zhenxun.configs.path_config import THEMES_PATH from zhenxun.services.log import logger -from zhenxun.services.renderer.models import TemplateManifest from zhenxun.services.renderer.protocols import Renderable from zhenxun.services.renderer.registry import asset_registry from zhenxun.utils.pydantic_compat import model_dump @@ -32,6 +31,20 @@ if TYPE_CHECKING: 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): """ 一个自定义的 Jinja2 环境,重写了 join_path 方法以支持模板间的相对路径引用。 @@ -151,14 +164,42 @@ class ResourceResolver: def resolve_asset_uri(self, asset_path: str, current_template_name: str) -> str: """解析资源路径,实现完整的回退逻辑,并返回可用的URI。""" - if not self.theme_manager.current_theme: + if ( + not self.theme_manager.current_theme + or not self.theme_manager.jinja_env.loader + ): 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]] = [] - 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( self._search_paths_for_relative_asset( - asset_path[2:], current_template_name + relative_part, current_template_name ) ) else: @@ -209,6 +250,9 @@ class ThemeManager: 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]: """扫描主题目录并返回所有可用的主题名称。""" if not THEMES_PATH.is_dir(): @@ -377,16 +421,26 @@ class ThemeManager: logger.error(f"指定的模板文件路径不存在: '{component_path_base}'", e=e) raise e - entrypoint_filename = "main.html" - manifest = await self.get_template_manifest(component_path_base) - if manifest and manifest.entrypoint: - entrypoint_filename = manifest.entrypoint + base_manifest = await self.get_template_manifest(component_path_base) + + skin_to_use = variant or (base_manifest.get("skin") if base_manifest else None) + + 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 = [] - if variant: + if skin_to_use: 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}") @@ -410,28 +464,88 @@ class ThemeManager: logger.error(err_msg) raise TemplateNotFound(err_msg) - async def get_template_manifest( - self, component_path: str - ) -> TemplateManifest | None: - """ - 查找并解析组件的 manifest.json 文件。 - """ - manifest_path_str = f"{component_path}/manifest.json" + async def _load_single_manifest(self, path_str: str) -> dict[str, Any] | None: + """从指定路径加载单个 manifest.json 文件。""" + normalized_path = path_str.replace("\\", "/") + manifest_path_str = f"{normalized_path}/manifest.json" if not self.jinja_env.loader: return None try: - _, full_path, _ = self.jinja_env.loader.get_source( + source, filepath, _ = self.jinja_env.loader.get_source( self.jinja_env, manifest_path_str ) - if full_path and Path(full_path).exists(): - async with aiofiles.open(full_path, encoding="utf-8") as f: - manifest_data = json.loads(await f.read()) - return TemplateManifest(**manifest_data) + logger.debug(f"找到清单文件: '{manifest_path_str}' (从 '{filepath}' 加载)") + return json.loads(source) except TemplateNotFound: + logger.trace(f"未找到清单文件: '{manifest_path_str}'") 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( self, style_name: str, context: "RenderContext" diff --git a/zhenxun/utils/common_utils.py b/zhenxun/utils/common_utils.py index afc44f94..d7b14efc 100644 --- a/zhenxun/utils/common_utils.py +++ b/zhenxun/utils/common_utils.py @@ -126,12 +126,15 @@ class SqlUtils: def format_usage_for_markdown(text: str) -> str: """ 智能地将Python多行字符串转换为适合Markdown渲染的格式。 - - 将单个换行符替换为Markdown的硬换行(行尾加两个空格)。 + - 在列表、标题等块级元素前自动插入换行,确保正确解析。 + - 将段落内的单个换行符替换为Markdown的硬换行(行尾加两个空格)。 - 保留两个或更多的连续换行符,使其成为Markdown的段落分隔。 """ if not text: return "" - text = re.sub(r"\n{2,}", "<>", text) - text = text.replace("\n", " \n") - text = text.replace("<>", "\n\n") + + text = re.sub(r"([^\n])\n(\s*[-*] |\s*#+\s|\s*>)", r"\1\n\n\2", text) + + text = re.sub(r"(? V: from pydantic import TypeAdapter # type: ignore 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)