From 4b5ba4579cb727faf108e8a76ca08f763b5c4362 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Sat, 4 Apr 2026 15:27:57 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E6=94=AF=E6=8C=81=E4=BB=A5html?= =?UTF-8?q?=E9=A2=84=E8=A7=88prompt=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config/official_configs.py | 9 + src/maisaka/prompt_cli_renderer.py | 429 ++++++++++++++++++++++++++++- 2 files changed, 437 insertions(+), 1 deletion(-) diff --git a/src/config/official_configs.py b/src/config/official_configs.py index ee92763a..3b3e58e8 100644 --- a/src/config/official_configs.py +++ b/src/config/official_configs.py @@ -1042,6 +1042,15 @@ class DebugConfig(ConfigBase): ) """是否显示回复器推理""" + fold_maisaka_thinking: bool = Field( + default=True, + json_schema_extra={ + "x-widget": "switch", + "x-icon": "minimize-2", + }, + ) + """是否折叠 Maisaka 的 prompt 展示入口""" + show_jargon_prompt: bool = Field( default=False, json_schema_extra={ diff --git a/src/maisaka/prompt_cli_renderer.py b/src/maisaka/prompt_cli_renderer.py index 0897fd1b..c60cbafd 100644 --- a/src/maisaka/prompt_cli_renderer.py +++ b/src/maisaka/prompt_cli_renderer.py @@ -3,6 +3,8 @@ from __future__ import annotations import hashlib +import html +import json from base64 import b64decode from dataclasses import dataclass from enum import Enum @@ -51,6 +53,19 @@ class _MessageRenderResult: class PromptCLIVisualizer: """负责构建 CLI 下 prompt 展示所需的所有可视化组件。""" + PROMPT_DUMP_DIR = Path(tempfile.gettempdir()) / "maisaka_prompt_dumps" + + @staticmethod + def get_request_panel_style(request_kind: str) -> tuple[str, str]: + """返回不同请求类型对应的标题与边框颜色。""" + + normalized_kind = str(request_kind or "planner").strip().lower() + if normalized_kind == "timing_gate": + return "MaiSaka 大模型请求 - Timing Gate 子代理", "bright_magenta" + if normalized_kind == "sub_agent": + return "MaiSaka 大模型请求 - 子代理", "bright_blue" + return "MaiSaka 大模型请求 - 对话单步", "cyan" + @staticmethod def _get_role_badge_style(role: str) -> str: if role == "system": @@ -121,6 +136,16 @@ class PromptCLIVisualizer: normalized = file_path.as_posix() return f"file:///{quote(normalized, safe='/:')}" + @classmethod + def _build_prompt_dump_base_path(cls, messages: list[Any]) -> Path: + cls.PROMPT_DUMP_DIR.mkdir(parents=True, exist_ok=True) + try: + payload = json.dumps(messages, ensure_ascii=False, default=str) + except Exception: + payload = repr(messages) + digest = hashlib.sha256(payload.encode("utf-8")).hexdigest() + return cls.PROMPT_DUMP_DIR / digest + @staticmethod def _build_official_image_path(image_format: str, image_base64: str) -> Path | None: normalized_format = PromptCLIVisualizer._normalize_image_format(image_format) @@ -209,6 +234,36 @@ class PromptCLIVisualizer: return Pretty(content, expand_all=True) + @classmethod + def _serialize_message_content_for_dump(cls, content: Any) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + parts: List[str] = [] + for item in content: + if isinstance(item, str): + parts.append(item) + continue + if isinstance(item, tuple) and len(item) == 2: + image_format, image_base64 = item + approx_size = max(0, len(str(image_base64)) * 3 // 4) + parts.append(f"[图片 image/{image_format} {approx_size} B]") + continue + if isinstance(item, dict) and item.get("type") == "text" and isinstance(item.get("text"), str): + parts.append(item["text"]) + continue + try: + parts.append(json.dumps(item, ensure_ascii=False, indent=2, default=str)) + except Exception: + parts.append(str(item)) + return "\n".join(part for part in parts if part).strip() + if content is None: + return "" + try: + return json.dumps(content, ensure_ascii=False, indent=2, default=str) + except Exception: + return str(content) + @classmethod def format_tool_call_for_display(cls, tool_call: Any) -> Dict[str, Any]: if isinstance(tool_call, dict): @@ -238,6 +293,379 @@ class PromptCLIVisualizer: padding=(0, 1), ) + @classmethod + def _build_prompt_dump_text(cls, messages: list[Any]) -> str: + sections: List[str] = [] + for index, message in enumerate(messages, start=1): + if isinstance(message, dict): + raw_role = message.get("role", "unknown") + content = message.get("content") + tool_call_id = message.get("tool_call_id") + tool_calls = message.get("tool_calls") or [] + else: + raw_role = getattr(message, "role", "unknown") + content = getattr(message, "content", None) + tool_call_id = getattr(message, "tool_call_id", None) + tool_calls = getattr(message, "tool_calls", None) or [] + + role = raw_role.value if hasattr(raw_role, "value") else str(raw_role) + block_lines = [f"[{index}] role={role}"] + if tool_call_id: + block_lines.append(f"tool_call_id={tool_call_id}") + + normalized_content = cls._serialize_message_content_for_dump(content) + if normalized_content: + block_lines.append("") + block_lines.append(normalized_content) + + if tool_calls: + block_lines.append("") + block_lines.append("tool_calls:") + for tool_call in tool_calls: + normalized_tool_call = cls.format_tool_call_for_display(tool_call) + block_lines.append(json.dumps(normalized_tool_call, ensure_ascii=False, indent=2, default=str)) + + sections.append("\n".join(block_lines).strip()) + + return "\n\n" + ("\n\n" + ("=" * 80) + "\n\n").join(sections) if sections else "[空 Prompt]" + + @classmethod + def _render_message_content_html(cls, content: Any) -> str: + if isinstance(content, str): + return f"
{html.escape(content)}"
+
+ if isinstance(content, list):
+ parts: List[str] = []
+ for item in content:
+ if isinstance(item, str):
+ parts.append(f"{html.escape(item)}")
+ continue
+ if isinstance(item, tuple) and len(item) == 2:
+ image_format, image_base64 = item
+ image_html = cls._render_image_item_html(str(image_format), str(image_base64))
+ parts.append(image_html)
+ continue
+ if isinstance(item, dict) and item.get("type") == "text" and isinstance(item.get("text"), str):
+ parts.append(f"{html.escape(item['text'])}")
+ continue
+ parts.append(f"{html.escape(json.dumps(item, ensure_ascii=False, indent=2, default=str))}")
+ return "".join(parts) if parts else ""
+
+ if content is None:
+ return ""
+
+ return f"{html.escape(json.dumps(content, ensure_ascii=False, indent=2, default=str))}"
+
+ @classmethod
+ def _render_image_item_html(cls, image_format: str, image_base64: str) -> str:
+ normalized_format = cls._normalize_image_format(image_format)
+ approx_size = max(0, len(image_base64) * 3 // 4)
+ size_text = f"{approx_size / 1024:.1f} KB" if approx_size >= 1024 else f"{approx_size} B"
+ path_result = cls._build_image_file_link(image_format, image_base64)
+ if path_result is None:
+ return (
+ "{html.escape(str(tool_call_id))}"
+ "{html.escape(json.dumps(normalized_tool_call, ensure_ascii=False, indent=2, default=str))}"
+ "