Files
mai-bot/src/maisaka/debug_viewer.py
2026-03-13 11:45:26 +08:00

195 lines
6.6 KiB
Python

"""
MaiSaka Debug Viewer — 在独立命令行窗口中显示每次 LLM 调用的完整 Prompt。
由主进程自动启动,通过 TCP socket 接收数据。
"""
import socket
import struct
import json
import sys
from rich.console import Console
from rich.panel import Panel
from rich import box
console = Console()
ROLE_STYLES = {
"system": ("📋", "bold blue"),
"user": ("👤", "bold green"),
"assistant": ("🤖", "bold magenta"),
"tool": ("🔧", "bold yellow"),
}
def recv_exact(conn: socket.socket, n: int) -> bytes | None:
"""精确接收 n 字节数据。"""
data = b""
while len(data) < n:
chunk = conn.recv(n - len(data))
if not chunk:
return None
data += chunk
return data
def format_message(idx: int, msg: dict) -> str:
"""格式化单条消息用于终端展示。"""
try:
role = str(msg.get("role", "?")) if msg.get("role") else "?"
content = str(msg.get("content", "")) if msg.get("content") else ""
tool_calls = msg.get("tool_calls", []) or []
tool_call_id = str(msg.get("tool_call_id", "")) if msg.get("tool_call_id") else ""
icon, style = ROLE_STYLES.get(role, ("", "white"))
parts: list[str] = []
# 消息头
header = f"[{style}]{icon} [{idx}] {role}[/{style}]"
if tool_call_id:
header += f" [dim](tool_call_id: {tool_call_id})[/dim]"
parts.append(header)
# 正文
if content:
display = (
content
if len(content) <= 3000
else (content[:3000] + f"\n[dim]... (截断, 共 {len(content)} 字符)[/dim]")
)
parts.append(display)
# 工具调用
if isinstance(tool_calls, list):
for tc in tool_calls:
if not isinstance(tc, dict):
continue
func = tc.get("function", {})
if not isinstance(func, dict):
continue
name = func.get("name", "?")
args = func.get("arguments", "")
if isinstance(args, str) and len(args) > 500:
args = args[:500] + "..."
parts.append(f" [yellow]→ tool_call: {name}({args})[/yellow]")
return "\n".join(parts)
except Exception:
return f"[red]消息 [{idx}] 格式化错误[/red]"
def main():
port = int(sys.argv[1]) if len(sys.argv) > 1 else 19876
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("127.0.0.1", port))
server.listen(1)
console.print(
Panel(
f"[bold cyan]MaiSaka Debug Viewer[/bold cyan]\n[dim]监听端口: {port} 等待主进程连接...[/dim]",
box=box.DOUBLE_EDGE,
border_style="cyan",
)
)
conn, _ = server.accept()
console.print("[green]✓ 已连接到主进程[/green]\n")
call_count = 0
try:
while True:
# 读 4 字节长度前缀
length_bytes = recv_exact(conn, 4)
if not length_bytes:
break
length = struct.unpack(">I", length_bytes)[0]
# 读取 payload
payload_bytes = recv_exact(conn, length)
if not payload_bytes:
break
call_count += 1
try:
payload = json.loads(payload_bytes.decode("utf-8"))
except json.JSONDecodeError as e:
console.print(f"\n[red]JSON 解析错误: {e}[/red]")
console.print(f"[dim]原始数据: {payload_bytes[:200]}...[/dim]")
continue
try:
label = payload.get("label", "LLM Call")
messages = payload.get("messages", [])
tools = payload.get("tools")
response = payload.get("response")
# ── 标题栏 ──
console.print(f"\n{'' * 90}")
console.print(
f"[bold yellow]#{call_count} {label}[/bold yellow] [dim]({len(messages)} messages)[/dim]"
)
console.print(f"{'' * 90}")
# ── 逐条消息 ──
for i, msg in enumerate(messages):
console.print(format_message(i, msg))
if i < len(messages) - 1:
console.print("[dim]─ ─ ─[/dim]")
# ── tools 信息 ──
if tools:
tool_names = [t.get("function", {}).get("name", "?") for t in tools]
console.print(f"\n[dim]可用工具: {', '.join(tool_names)}[/dim]")
except Exception as e:
console.print(f"\n[red]数据处理错误: {e}[/red]")
console.print(f"[dim]Payload: {payload}[/dim]")
continue
# ── 响应结果 ──
if response:
try:
console.print("\n[bold cyan]📤 LLM 响应:[/bold cyan]")
resp_content = response.get("content", "")
if resp_content:
display = (
resp_content
if len(str(resp_content)) <= 3000
else (
str(resp_content)[:3000] + f"\n[dim]... (截断, 共 {len(str(resp_content))} 字符)[/dim]"
)
)
console.print(Panel(display, border_style="cyan", padding=(0, 1)))
resp_tool_calls = response.get("tool_calls", [])
if resp_tool_calls:
for tc in resp_tool_calls:
func = tc.get("function", {})
name = func.get("name", "?")
args = func.get("arguments", "")
if isinstance(args, str) and len(args) > 300:
args = args[:300] + "..."
console.print(f" [cyan]→ tool_call: {name}({args})[/cyan]")
except Exception as e:
console.print(f"\n[red]响应解析错误: {e}[/red]")
console.print(f"[dim]原始数据: {response}[/dim]")
console.print(f"[dim]{'' * 90}[/dim]")
except (ConnectionResetError, ConnectionAbortedError):
pass
finally:
conn.close()
server.close()
console.print("\n[red]连接已断开[/red]")
input("按 Enter 关闭窗口...")
if __name__ == "__main__":
main()