File size: 6,309 Bytes
d0eee72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
"""Turn whatever someone posted into something a template can loop over.

Agent traces arrive in a handful of shapes. We recognise the common message-list
dialects (OpenAI chat completions, Anthropic messages, and the LangChain-ish
variants that mostly look like them) and fall back to pretty-printed JSON or
plain text. Nothing here trusts its input: every value ends up escaped by the
Jinja autoescaper, and unknown block types are shown as JSON rather than
guessed at.
"""

from __future__ import annotations

import json
from typing import Any

ROLE_ALIASES = {
    "human": "user",
    "ai": "assistant",
    "model": "assistant",
    "function": "tool",
}

KNOWN_ROLES = {"system", "developer", "user", "assistant", "tool"}


def _pretty(value: Any) -> str:
    if isinstance(value, str):
        return value
    return json.dumps(value, indent=2, ensure_ascii=False, default=str)


def _blocks_from_content(content: Any) -> list[dict[str, Any]]:
    """Normalise a message's ``content`` into a list of display blocks."""
    if content is None:
        return []
    if isinstance(content, str):
        return [{"type": "text", "text": content}] if content.strip() else []
    if isinstance(content, dict):
        content = [content]
    if not isinstance(content, list):
        return [{"type": "json", "text": _pretty(content)}]

    blocks: list[dict[str, Any]] = []
    for item in content:
        if isinstance(item, str):
            blocks.append({"type": "text", "text": item})
            continue
        if not isinstance(item, dict):
            blocks.append({"type": "json", "text": _pretty(item)})
            continue

        kind = item.get("type")
        if kind == "text":
            blocks.append({"type": "text", "text": item.get("text", "")})
        elif kind == "thinking":
            blocks.append(
                {"type": "thinking", "text": item.get("thinking") or item.get("text", "")}
            )
        elif kind == "tool_use":
            blocks.append(
                {
                    "type": "tool_use",
                    "name": item.get("name", "tool"),
                    "text": _pretty(item.get("input", {})),
                }
            )
        elif kind == "tool_result":
            blocks.append(
                {
                    "type": "tool_result",
                    "name": item.get("tool_use_id", ""),
                    "text": _pretty(item.get("content", "")),
                    "error": bool(item.get("is_error")),
                }
            )
        elif kind in ("image", "image_url"):
            # Never render remote images: it would leak the secret URL through
            # the Referer header and defeat the whole point.
            blocks.append({"type": "note", "text": "[image omitted]"})
        else:
            blocks.append({"type": "json", "text": _pretty(item)})
    return blocks


def _normalize_message(raw: Any) -> dict[str, Any]:
    if not isinstance(raw, dict):
        return {"role": "unknown", "blocks": [{"type": "json", "text": _pretty(raw)}]}

    role = str(raw.get("role") or raw.get("type") or "unknown").lower()
    role = ROLE_ALIASES.get(role, role)
    blocks = _blocks_from_content(raw.get("content"))

    # OpenAI-style tool calls live beside content, not inside it.
    for call in raw.get("tool_calls") or []:
        if not isinstance(call, dict):
            continue
        fn = call.get("function") or {}
        args = fn.get("arguments")
        if isinstance(args, str):
            try:
                args = json.loads(args)
            except (ValueError, TypeError):
                pass  # leave the raw string; malformed args are worth seeing
        blocks.append(
            {
                "type": "tool_use",
                "name": fn.get("name") or call.get("name") or "tool",
                "text": _pretty(args if args is not None else {}),
            }
        )

    if role == "tool" and not blocks and raw.get("name"):
        blocks.append({"type": "note", "text": str(raw["name"])})

    # Anthropic puts tool results in a *user* turn. Labelling that column "user"
    # is technically faithful and completely misleading to read, so show what
    # the turn actually contains.
    if role == "user" and blocks and all(b["type"] == "tool_result" for b in blocks):
        role = "tool"

    return {
        "role": role,
        "role_class": role if role in KNOWN_ROLES else "unknown",
        "name": raw.get("name") or "",
        "blocks": blocks,
    }


def _find_messages(payload: Any) -> list | None:
    """Locate a message list in a few common wrapper shapes."""
    if isinstance(payload, list) and payload and isinstance(payload[0], dict):
        if any(k in payload[0] for k in ("role", "type", "content")):
            return payload
        return None
    if isinstance(payload, dict):
        for key in ("messages", "trace", "conversation", "history", "turns", "steps"):
            value = payload.get(key)
            if isinstance(value, list) and value:
                return value
    return None


def normalize(payload: Any) -> dict[str, Any]:
    """Return ``{"kind": ..., ...}`` describing how to display ``payload``."""
    if isinstance(payload, str):
        return {"kind": "text", "text": payload}

    messages = _find_messages(payload)
    if messages is not None:
        meta = {}
        if isinstance(payload, dict):
            meta = {
                k: v
                for k, v in payload.items()
                if k
                not in ("messages", "trace", "conversation", "history", "turns", "steps")
                and isinstance(v, (str, int, float, bool))
            }
        return {
            "kind": "messages",
            "messages": [_normalize_message(m) for m in messages],
            "meta": meta,
        }

    return {"kind": "json", "text": _pretty(payload)}


def summarize(payload: Any) -> str:
    """One-line description for the page title / header."""
    messages = _find_messages(payload)
    if messages is not None:
        return f"{len(messages)} message{'s' if len(messages) != 1 else ''}"
    if isinstance(payload, str):
        lines = payload.count("\n") + 1
        return f"{lines} line{'s' if lines != 1 else ''}"
    return "JSON document"