diff --git a/src/maisaka/display/preview_path_utils.py b/src/maisaka/display/preview_path_utils.py new file mode 100644 index 00000000..88ea359a --- /dev/null +++ b/src/maisaka/display/preview_path_utils.py @@ -0,0 +1,58 @@ +"""Maisaka Prompt 预览路径工具。""" + +from __future__ import annotations + +from pathlib import Path +from urllib.parse import quote + +import re + +from src.chat.message_receive.chat_manager import chat_manager + + +REPO_ROOT = Path(__file__).parent.parent.parent.parent.absolute().resolve() +SAFE_NAME_PATTERN = re.compile(r"[^A-Za-z0-9._-]+") + + +def normalize_preview_name(value: str) -> str: + normalized_value = SAFE_NAME_PATTERN.sub("_", str(value or "").strip()).strip("._") + if normalized_value: + return normalized_value + return "unknown" + + +def normalize_platform_name(platform: str) -> str: + normalized_platform = str(platform or "").strip().lower() + platform_aliases = { + "telegram": "tg", + } + return normalize_preview_name(platform_aliases.get(normalized_platform, normalized_platform)) + + +def build_preview_chat_dir_name(chat_id: str) -> str: + session = chat_manager.get_session_by_session_id(chat_id) + if session is not None: + platform = normalize_platform_name(session.platform) + if session.is_group_session and session.group_id: + return f"{platform}_group_{normalize_preview_name(session.group_id)}" + if session.user_id: + return f"{platform}_private_{normalize_preview_name(session.user_id)}" + + normalized_chat_id = normalize_preview_name(chat_id) + if normalized_chat_id != "unknown": + return normalized_chat_id + return "unknown_chat" + + +def build_display_path(file_path: Path) -> str: + """构造用于展示的路径,项目内文件优先显示相对路径。""" + resolved_path = file_path.resolve() + try: + return resolved_path.relative_to(REPO_ROOT).as_posix() + except ValueError: + return resolved_path.as_posix() + + +def build_file_uri(file_path: Path) -> str: + normalized = file_path.resolve().as_posix() + return f"file:///{quote(normalized, safe='/:')}" diff --git a/src/maisaka/display/prompt_cli_renderer.py b/src/maisaka/display/prompt_cli_renderer.py index eeca0a5a..9de08cec 100644 --- a/src/maisaka/display/prompt_cli_renderer.py +++ b/src/maisaka/display/prompt_cli_renderer.py @@ -7,7 +7,6 @@ from dataclasses import dataclass from enum import Enum from pathlib import Path from typing import Any, Dict, List, Literal -from urllib.parse import quote import hashlib import html @@ -27,10 +26,10 @@ from .display_utils import ( get_role_badge_label as get_shared_role_badge_label, get_role_badge_style as get_shared_role_badge_style, ) +from .preview_path_utils import build_display_path, build_file_uri, REPO_ROOT from .prompt_preview_logger import PromptPreviewLogger -PROJECT_ROOT = Path(__file__).parent.parent.parent.absolute().resolve() -DATA_IMAGE_DIR = PROJECT_ROOT / "data" / "images" +DATA_IMAGE_DIR = REPO_ROOT / "data" / "images" class PromptImageDisplayMode(str, Enum): @@ -115,11 +114,6 @@ class PromptCLIVisualizer: digest = hashlib.sha256(image_base64.encode("utf-8")).hexdigest() return root / f"{digest}.{image_format}" - @staticmethod - def _build_file_uri(file_path: Path) -> str: - normalized = file_path.resolve().as_posix() - return f"file:///{quote(normalized, safe='/:')}" - @staticmethod def _build_official_image_path(image_format: str, image_base64: str) -> Path | None: normalized_format = PromptCLIVisualizer._normalize_image_format(image_format) @@ -140,7 +134,7 @@ class PromptCLIVisualizer: normalized_format = PromptCLIVisualizer._normalize_image_format(image_format) or "bin" official_path = PromptCLIVisualizer._build_official_image_path(image_format, image_base64) if official_path is not None: - return PromptCLIVisualizer._build_file_uri(official_path), official_path + return build_file_uri(official_path), official_path try: image_bytes = b64decode(image_base64) @@ -153,7 +147,7 @@ class PromptCLIVisualizer: path.write_bytes(image_bytes) except Exception: return None - return PromptCLIVisualizer._build_file_uri(path), path + return build_file_uri(path), path @classmethod def _render_image_item(cls, image_format: str, image_base64: str, settings: PromptImageDisplaySettings) -> Panel: @@ -169,8 +163,9 @@ class PromptCLIVisualizer: path_result = cls._build_image_file_link(image_format, image_base64) if path_result is not None: file_uri, file_path = path_result + display_path = build_display_path(file_path) preview_parts: List[RenderableType] = [ - Text(f"图片格式 image/{normalized_format} {size_text} 路径:{file_path}", style="magenta") + Text(f"图片格式 image/{normalized_format} {size_text} 路径:{display_path}", style="magenta") ] preview_parts.append(Text.from_markup(f"[link={file_uri}]点击打开图片[/link]", style="cyan")) @@ -437,17 +432,44 @@ class PromptCLIVisualizer: ) file_uri, file_path = path_result + display_path = build_display_path(file_path) return ( "
" f"
图片 image/{html.escape(normalized_format)} {html.escape(size_text)}
" f"" f"图片预览" "" - f"
{html.escape(str(file_path))}
" + f"
{html.escape(display_path)}
" f"打开图片" "
" ) + @staticmethod + def _build_preview_access_body( + *, + viewer_label: str, + viewer_path: Path, + viewer_link_text: str, + dump_label: str, + dump_path: Path, + dump_link_text: str, + ) -> RenderableType: + viewer_uri = build_file_uri(viewer_path) + dump_uri = build_file_uri(dump_path) + viewer_display_path = build_display_path(viewer_path) + dump_display_path = build_display_path(dump_path) + + return Group( + Text.from_markup( + f"[bold green]{viewer_label}:{viewer_display_path}[/bold green] " + f"[link={viewer_uri}]{viewer_link_text}[/link]" + ), + Text.from_markup( + f"[magenta]{dump_label}:{dump_display_path}[/magenta] " + f"[cyan][link={dump_uri}]{dump_link_text}[/link][/cyan]" + ), + ) + @classmethod def _build_html_role_class(cls, role: str) -> str: return { @@ -823,18 +845,13 @@ class PromptCLIVisualizer: ) viewer_html_path = saved_paths[".html"] prompt_dump_path = saved_paths[".txt"] - viewer_uri = cls._build_file_uri(viewer_html_path) - dump_uri = cls._build_file_uri(prompt_dump_path) - - body = Group( - Text.from_markup( - f"[bold green]html预览:{viewer_html_path}[/bold green] " - f"[link={viewer_uri}]在浏览器打开 Prompt [/link]" - ), - Text.from_markup( - f"[magenta]原始文本:{prompt_dump_path}[/magenta] " - f"[cyan][link={dump_uri}]点击打开 Prompt 文本[/link][/cyan]" - ), + body = cls._build_preview_access_body( + viewer_label="html预览", + viewer_path=viewer_html_path, + viewer_link_text="在浏览器打开 Prompt", + dump_label="原始文本", + dump_path=prompt_dump_path, + dump_link_text="点击打开 Prompt 文本", ) return body @@ -989,18 +1006,13 @@ class PromptCLIVisualizer: ) viewer_html_path = saved_paths[".html"] text_dump_path = saved_paths[".txt"] - viewer_uri = cls._build_file_uri(viewer_html_path) - dump_uri = cls._build_file_uri(text_dump_path) - - body = Group( - Text.from_markup( - f"[bold green]富文本预览:{viewer_html_path}[/bold green] " - f"[link={viewer_uri}]点击在浏览器打开富文本 Prompt 视图[/link]" - ), - Text.from_markup( - f"[magenta]原始文本备份:{text_dump_path}[/magenta] " - f"[cyan][link={dump_uri}]点击直接打开 Prompt 文本[/link][/cyan]" - ), + body = cls._build_preview_access_body( + viewer_label="富文本预览", + viewer_path=viewer_html_path, + viewer_link_text="点击在浏览器打开富文本 Prompt 视图", + dump_label="原始文本备份", + dump_path=text_dump_path, + dump_link_text="点击直接打开 Prompt 文本", ) return body diff --git a/src/maisaka/display/prompt_preview_logger.py b/src/maisaka/display/prompt_preview_logger.py index 2b8cee86..8cda0ebf 100644 --- a/src/maisaka/display/prompt_preview_logger.py +++ b/src/maisaka/display/prompt_preview_logger.py @@ -2,11 +2,11 @@ from __future__ import annotations -import re import time from pathlib import Path from typing import Dict -from uuid import uuid4 + +from .preview_path_utils import build_preview_chat_dir_name, normalize_preview_name class PromptPreviewLogger: @@ -15,14 +15,16 @@ class PromptPreviewLogger: _BASE_DIR = Path("logs") / "maisaka_prompt" _MAX_PREVIEW_GROUPS_PER_CHAT = 1024 _TRIM_COUNT = 100 - _SAFE_NAME_PATTERN = re.compile(r"[^A-Za-z0-9._-]+") @classmethod - def _normalize_chat_id(cls, chat_id: str) -> str: - normalized_chat_id = cls._SAFE_NAME_PATTERN.sub("_", str(chat_id or "").strip()).strip("._") - if normalized_chat_id: - return normalized_chat_id - return "unknown_chat" + def _build_file_stem(cls, chat_dir: Path) -> str: + base_stem = str(int(time.time() * 1000)) + candidate_stem = base_stem + suffix_index = 1 + while any((chat_dir / f"{candidate_stem}{suffix}").exists() for suffix in (".html", ".txt")): + candidate_stem = f"{base_stem}_{suffix_index}" + suffix_index += 1 + return candidate_stem @classmethod def save_preview_files( @@ -33,10 +35,10 @@ class PromptPreviewLogger: ) -> Dict[str, Path]: """保存同一份 Prompt 预览的多个文件并执行超量清理。""" - normalized_category = cls._normalize_chat_id(category) - chat_dir = (cls._BASE_DIR / normalized_category / cls._normalize_chat_id(chat_id)).resolve() + normalized_category = normalize_preview_name(category) + chat_dir = (cls._BASE_DIR / normalized_category / build_preview_chat_dir_name(chat_id)).resolve() chat_dir.mkdir(parents=True, exist_ok=True) - stem = f"{int(time.time() * 1000)}_{uuid4().hex[:8]}" + stem = cls._build_file_stem(chat_dir) saved_paths: Dict[str, Path] = {} try: for suffix, content in files.items(): diff --git a/src/maisaka/reasoning_engine.py b/src/maisaka/reasoning_engine.py index 6e1a0087..b30008ab 100644 --- a/src/maisaka/reasoning_engine.py +++ b/src/maisaka/reasoning_engine.py @@ -14,7 +14,7 @@ from src.chat.message_receive.message import SessionMessage from src.common.data_models.message_component_data_model import EmojiComponent, ImageComponent, MessageSequence from src.common.logger import get_logger from src.common.prompt_i18n import load_prompt -from src.config.config import global_config +from src.config.config import config_manager, global_config from src.core.tooling import ToolExecutionContext, ToolExecutionResult, ToolInvocation, ToolSpec from src.llm_models.exceptions import ReqAbortException from src.llm_models.payload_content.tool_option import ToolCall @@ -738,10 +738,47 @@ class MaisakaReasoningEngine: planner_prefix: str, ) -> MessageSequence: message_sequence = build_prefixed_message_sequence(message.raw_message, planner_prefix) - if global_config.visual.multimodal_planner: + if self._resolve_enable_visual_planner(): await self._hydrate_visual_components(message_sequence.components) return message_sequence + @staticmethod + def _resolve_enable_visual_planner() -> bool: + planner_mode = global_config.visual.planner_mode + planner_task_config = config_manager.get_model_config().model_task_config.planner + models_by_name = {model.name: model for model in config_manager.get_model_config().models} + + if planner_mode == "text": + return False + + planner_models: list[str] = list(planner_task_config.model_list) + missing_models = [model_name for model_name in planner_models if model_name not in models_by_name] + non_visual_models = [ + model_name for model_name in planner_models if model_name in models_by_name and not models_by_name[model_name].visual + ] + + if planner_mode == "multimodal": + if missing_models: + raise ValueError( + "planner_mode=multimodal,但 planner 任务存在未定义的模型:" + f"{', '.join(missing_models)}" + ) + if non_visual_models: + raise ValueError( + "planner_mode=multimodal,但 planner 任务存在未开启 visual 的模型:" + f"{', '.join(non_visual_models)}" + ) + return True + + if missing_models: + logger.warning( + "planner_mode=auto 时发现 planner 任务存在未定义模型:" + f"{', '.join(missing_models)},将退化为纯文本 planner" + ) + return False + + return bool(planner_models) and not non_visual_models + async def _hydrate_visual_components(self, planner_components: list[object]) -> None: """在 Maisaka 真正需要图片或表情时,按需回填二进制数据。""" load_tasks: list[asyncio.Task[None]] = []