feat:支持以html预览prompt log

This commit is contained in:
SengokuCola
2026-04-04 15:27:57 +08:00
parent fd59724e5c
commit 4b5ba4579c
2 changed files with 437 additions and 1 deletions

View File

@@ -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( show_jargon_prompt: bool = Field(
default=False, default=False,
json_schema_extra={ json_schema_extra={

View File

@@ -3,6 +3,8 @@
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import html
import json
from base64 import b64decode from base64 import b64decode
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
@@ -51,6 +53,19 @@ class _MessageRenderResult:
class PromptCLIVisualizer: class PromptCLIVisualizer:
"""负责构建 CLI 下 prompt 展示所需的所有可视化组件。""" """负责构建 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 @staticmethod
def _get_role_badge_style(role: str) -> str: def _get_role_badge_style(role: str) -> str:
if role == "system": if role == "system":
@@ -121,6 +136,16 @@ class PromptCLIVisualizer:
normalized = file_path.as_posix() normalized = file_path.as_posix()
return f"file:///{quote(normalized, safe='/:')}" 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 @staticmethod
def _build_official_image_path(image_format: str, image_base64: str) -> Path | None: def _build_official_image_path(image_format: str, image_base64: str) -> Path | None:
normalized_format = PromptCLIVisualizer._normalize_image_format(image_format) normalized_format = PromptCLIVisualizer._normalize_image_format(image_format)
@@ -209,6 +234,36 @@ class PromptCLIVisualizer:
return Pretty(content, expand_all=True) 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 @classmethod
def format_tool_call_for_display(cls, tool_call: Any) -> Dict[str, Any]: def format_tool_call_for_display(cls, tool_call: Any) -> Dict[str, Any]:
if isinstance(tool_call, dict): if isinstance(tool_call, dict):
@@ -238,6 +293,379 @@ class PromptCLIVisualizer:
padding=(0, 1), 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"<pre>{html.escape(content)}</pre>"
if isinstance(content, list):
parts: List[str] = []
for item in content:
if isinstance(item, str):
parts.append(f"<pre>{html.escape(item)}</pre>")
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"<pre>{html.escape(item['text'])}</pre>")
continue
parts.append(f"<pre>{html.escape(json.dumps(item, ensure_ascii=False, indent=2, default=str))}</pre>")
return "".join(parts) if parts else "<pre></pre>"
if content is None:
return "<pre></pre>"
return f"<pre>{html.escape(json.dumps(content, ensure_ascii=False, indent=2, default=str))}</pre>"
@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 (
"<div class='image-card'>"
f"<div class='image-meta'>图片 image/{html.escape(normalized_format)} {html.escape(size_text)}</div>"
"</div>"
)
file_uri, file_path = path_result
return (
"<div class='image-card'>"
f"<div class='image-meta'>图片 image/{html.escape(normalized_format)} {html.escape(size_text)}</div>"
f"<div class='image-path'>{html.escape(str(file_path))}</div>"
f"<a class='image-link' href='{html.escape(file_uri, quote=True)}'>打开图片</a>"
"</div>"
)
@classmethod
def _build_html_role_class(cls, role: str) -> str:
return {
"system": "system",
"user": "user",
"assistant": "assistant",
"tool": "tool",
}.get(role, "unknown")
@classmethod
def _build_prompt_viewer_html(
cls,
messages: list[dict[str, Any]],
*,
request_kind: str,
selection_reason: str,
) -> str:
panel_title, _ = cls.get_request_panel_style(request_kind)
message_cards: List[str] = []
for index, message in enumerate(messages, start=1):
raw_role = message.get("role", "unknown")
role = raw_role.value if hasattr(raw_role, "value") else str(raw_role)
role_label = cls._get_role_badge_label(role)
role_class = cls._build_html_role_class(role)
content_html = cls._render_message_content_html(message.get("content"))
tool_call_id = message.get("tool_call_id")
tool_call_html = ""
if tool_call_id:
tool_call_html = (
"<div class='tool-call-id'>"
"<span class='tool-call-label'>工具调用 ID</span>"
f"<code>{html.escape(str(tool_call_id))}</code>"
"</div>"
)
tool_panels = ""
raw_tool_calls = message.get("tool_calls") or []
if isinstance(raw_tool_calls, list) and raw_tool_calls:
tool_items = []
for tool_call_index, tool_call in enumerate(raw_tool_calls, start=1):
normalized_tool_call = cls.format_tool_call_for_display(tool_call)
tool_items.append(
"<div class='tool-panel'>"
f"<div class='tool-panel-title'>工具调用 #{index}.{tool_call_index}</div>"
f"<pre>{html.escape(json.dumps(normalized_tool_call, ensure_ascii=False, indent=2, default=str))}</pre>"
"</div>"
)
tool_panels = "".join(tool_items)
message_cards.append(
"<section class='message-card'>"
"<div class='message-head'>"
f"<span class='role-badge {role_class}'>{html.escape(role_label)}</span>"
f"<span class='message-index'>#{index}</span>"
"</div>"
f"<div class='message-content'>{content_html}</div>"
f"{tool_call_html}"
f"{tool_panels}"
"</section>"
)
subtitle_html = ""
if selection_reason.strip():
subtitle_html = f"<div class='subtitle'>{html.escape(selection_reason)}</div>"
return f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html.escape(panel_title)}</title>
<style>
:root {{
--bg: #f5f7fb;
--card: #ffffff;
--border: #d7dfeb;
--text: #18212f;
--muted: #5b6878;
--system: #1d4ed8;
--user: #16a34a;
--assistant: #ca8a04;
--tool: #c026d3;
--unknown: #475569;
--shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
background:
radial-gradient(circle at top left, rgba(29, 78, 216, 0.12), transparent 28%),
radial-gradient(circle at top right, rgba(192, 38, 211, 0.10), transparent 26%),
var(--bg);
color: var(--text);
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
}}
.page {{
width: min(1200px, calc(100vw - 40px));
margin: 24px auto 40px;
}}
.hero {{
background: linear-gradient(135deg, #ffffff 0%, #eef4ff 100%);
border: 1px solid var(--border);
border-radius: 20px;
box-shadow: var(--shadow);
padding: 20px 24px;
margin-bottom: 18px;
}}
.title {{
font-size: 26px;
font-weight: 700;
letter-spacing: 0.02em;
}}
.subtitle {{
margin-top: 10px;
color: var(--muted);
white-space: pre-wrap;
}}
.message-card {{
background: var(--card);
border: 1px solid var(--border);
border-radius: 18px;
box-shadow: var(--shadow);
padding: 16px 18px;
margin-bottom: 14px;
}}
.message-head {{
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}}
.role-badge {{
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 5px 12px;
color: #fff;
font-size: 13px;
font-weight: 700;
}}
.role-badge.system {{ background: var(--system); }}
.role-badge.user {{ background: var(--user); }}
.role-badge.assistant {{ background: var(--assistant); color: #1f2937; }}
.role-badge.tool {{ background: var(--tool); }}
.role-badge.unknown {{ background: var(--unknown); }}
.message-index {{
color: var(--muted);
font-size: 13px;
font-weight: 600;
}}
.message-content pre,
.tool-panel pre {{
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 13px;
line-height: 1.55;
color: #1e293b;
}}
.tool-call-id {{
margin-top: 12px;
color: var(--tool);
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}}
.tool-call-label {{
font-weight: 700;
}}
.tool-call-id code {{
background: #faf5ff;
border: 1px solid #e9d5ff;
border-radius: 8px;
padding: 3px 8px;
}}
.tool-panel {{
margin-top: 12px;
background: #fcf4ff;
border: 1px solid #f0d7fb;
border-radius: 14px;
padding: 12px 14px;
}}
.tool-panel-title {{
color: #a21caf;
font-size: 13px;
font-weight: 700;
margin-bottom: 8px;
}}
.image-card {{
background: #f8fafc;
border: 1px solid #dbe4f0;
border-radius: 14px;
padding: 12px 14px;
margin: 8px 0;
}}
.image-meta {{
color: #a21caf;
font-weight: 700;
}}
.image-path {{
margin-top: 6px;
color: var(--muted);
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
word-break: break-all;
}}
.image-link {{
display: inline-block;
margin-top: 8px;
color: #0f766e;
font-weight: 700;
text-decoration: none;
}}
.image-link:hover {{ text-decoration: underline; }}
</style>
</head>
<body>
<main class="page">
<header class="hero">
<div class="title">{html.escape(panel_title)}</div>
{subtitle_html}
</header>
{''.join(message_cards)}
</main>
</body>
</html>"""
@classmethod
def build_prompt_access_panel(
cls,
messages: list[Any],
*,
request_kind: str,
selection_reason: str,
image_display_mode: Literal["legacy", "path_link"],
) -> Panel:
"""构建用于查看完整 prompt 的入口面板。"""
base_path = cls._build_prompt_dump_base_path(messages)
prompt_dump_path = base_path.with_suffix(".txt")
prompt_dump_path.write_text(cls._build_prompt_dump_text(messages), encoding="utf-8")
viewer_messages: list[dict[str, Any]] = []
for message in messages:
if isinstance(message, dict):
viewer_messages.append(dict(message))
continue
normalized_message = {
"content": getattr(message, "content", None),
"role": getattr(getattr(message, "role", "unknown"), "value", getattr(message, "role", "unknown")),
}
tool_call_id = getattr(message, "tool_call_id", None)
if tool_call_id:
normalized_message["tool_call_id"] = tool_call_id
tool_calls = getattr(message, "tool_calls", None)
if tool_calls:
normalized_message["tool_calls"] = [
cls.format_tool_call_for_display(tool_call) for tool_call in tool_calls
]
viewer_messages.append(normalized_message)
viewer_html_path = base_path.with_suffix(".html")
viewer_html_path.write_text(
cls._build_prompt_viewer_html(
viewer_messages,
request_kind=request_kind,
selection_reason=selection_reason,
),
encoding="utf-8",
)
viewer_uri = cls._build_file_uri(viewer_html_path)
dump_uri = cls._build_file_uri(prompt_dump_path)
body = Group(
Text(f"富文本预览:{viewer_html_path}", style="bold green"),
Text(f"原始文本备份:{prompt_dump_path}", style="magenta"),
Text.from_markup(f"[link={viewer_uri}]点击在浏览器打开富文本 Prompt 视图[/link]", style="bold green"),
Text.from_markup(f"[link={dump_uri}]点击直接打开 Prompt 文本[/link]", style="cyan"),
)
return Panel(
body,
title=Text(" Prompt 查看入口 ", style="bold white on blue"),
border_style="blue",
padding=(0, 1),
)
@classmethod @classmethod
def _render_message_panel(cls, message: Any, index: int, settings: PromptImageDisplaySettings) -> _MessageRenderResult: def _render_message_panel(cls, message: Any, index: int, settings: PromptImageDisplaySettings) -> _MessageRenderResult:
if isinstance(message, dict): if isinstance(message, dict):
@@ -257,7 +685,6 @@ class PromptCLIVisualizer:
parts: List[RenderableType] = [] parts: List[RenderableType] = []
if content not in (None, "", []): if content not in (None, "", []):
parts.append(Text(" 内容 ", style="bold cyan"))
parts.append(cls._render_message_content(content, settings)) parts.append(cls._render_message_content(content, settings))
if tool_call_id: if tool_call_id: