refactor: add markdown frontend
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
"""Render Anthropic ``Message`` (and individual user turns) into markdown.
|
||||
|
||||
The renderer is one-way: it produces the human-facing artifact in the
|
||||
vault. The parser strips tool/thinking callouts when reshaping the file
|
||||
for backend replay — so what we write here is purely for the human
|
||||
reader (and for the cross-frontend logger, which materializes turns
|
||||
from other frontends).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from anthropic.types import Message, TextBlock, ThinkingBlock, ToolUseBlock
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
|
||||
from anthropic.types import MessageParam
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FENCE",
|
||||
"USER_SCAFFOLD",
|
||||
"append_to_body",
|
||||
"render_assistant_message",
|
||||
"render_user_text",
|
||||
"summarize_tool_input",
|
||||
]
|
||||
|
||||
|
||||
# Empty ``### User:`` block appended after each assistant reply so the
|
||||
# human has an obvious place to type the next turn. Parser drops empty
|
||||
# user blocks, so this doesn't re-trigger dispatch on its own.
|
||||
USER_SCAFFOLD = "### User:\n"
|
||||
|
||||
|
||||
# Default 4-backtick fence so tool results that contain literal ```` ``` ````
|
||||
# don't collide. JSON inputs use 3 backticks because they almost never
|
||||
# contain ``` and we get language syntax highlighting in Obsidian for free.
|
||||
FENCE = "````"
|
||||
|
||||
# Used when a tool input mentions a path/file we can dangle in the
|
||||
# callout title — purely cosmetic.
|
||||
_PATH_KEYS = ("path", "file", "filename", "url", "command", "query")
|
||||
|
||||
|
||||
def render_user_text(content: str) -> str:
|
||||
r"""Render one user-spoken turn as ``### User:\n\n<text>``."""
|
||||
return f"### User:\n\n{content.strip()}\n"
|
||||
|
||||
|
||||
def render_assistant_message(message: Message) -> str:
|
||||
"""Render an assistant ``Message`` (with content blocks) into a turn block.
|
||||
|
||||
Blocks render in their original order:
|
||||
|
||||
* ``ThinkingBlock`` → ``> [!thinking]-`` collapsed callout
|
||||
* ``TextBlock`` → plain text (the spoken answer)
|
||||
* ``ToolUseBlock`` → ``> [!tool]- <name>`` callout with the ``input``
|
||||
JSON quoted inside. Tool *results* are not persisted — see
|
||||
module docstring on ``parser.py`` for why.
|
||||
|
||||
Blank lines separate adjacent blocks; trailing newline guarantees
|
||||
the next ``---`` / ``### User:`` marker lands on its own line.
|
||||
"""
|
||||
parts: list[str] = ["### Assistant:", ""]
|
||||
for block in message.content:
|
||||
parts.extend(_render_block(block))
|
||||
parts.append("")
|
||||
return "\n".join(parts).rstrip() + "\n"
|
||||
|
||||
|
||||
def render_user_param(param: MessageParam) -> str:
|
||||
"""Render a ``MessageParam`` user message into a ``### User:`` block.
|
||||
|
||||
Used by the cross-frontend logger when materializing turns from
|
||||
other frontends. Tool_result blocks in the content list are dropped
|
||||
silently — the markdown view doesn't track them (see ``parser.py``).
|
||||
"""
|
||||
content = param.get("content", "")
|
||||
if isinstance(content, str):
|
||||
text = content
|
||||
else:
|
||||
# The Anthropic SDK types ``content`` as a union of typed-dict
|
||||
# *Param classes plus pydantic block models — both shapes appear
|
||||
# in practice (raw incoming JSON yields dicts, SDK-built params
|
||||
# yield BaseModels). Treat each entry as a dict-like and pull
|
||||
# ``text`` opportunistically.
|
||||
chunks = [
|
||||
str(blk.get("text", ""))
|
||||
for blk in content
|
||||
if isinstance(blk, dict) and blk.get("type") == "text"
|
||||
]
|
||||
text = "\n\n".join(chunks)
|
||||
return render_user_text(text)
|
||||
|
||||
|
||||
def append_to_body(existing: str, new_block: str) -> str:
|
||||
"""Append ``new_block`` to ``existing`` with a decorative HR separator.
|
||||
|
||||
Preserves the original body verbatim (whitespace, callouts, any
|
||||
formatting the human added). The HR is purely visual: parser ignores
|
||||
it.
|
||||
"""
|
||||
head = existing.rstrip()
|
||||
if head:
|
||||
return f"{head}\n\n---\n\n{new_block}"
|
||||
return new_block
|
||||
|
||||
|
||||
def summarize_tool_input(name: str, tool_input: object) -> str:
|
||||
"""Build the ``[!tool]- <summary>`` title string.
|
||||
|
||||
Tries to pick a single salient field (``path``, ``command``, etc.)
|
||||
from the input dict so the collapsed callout shows something
|
||||
meaningful in Obsidian. Falls back to just the tool name.
|
||||
"""
|
||||
if not isinstance(tool_input, dict):
|
||||
return name
|
||||
# Anthropic ``ToolUseBlock.input`` is typed as ``object`` — the
|
||||
# SDK's runtime value is always a JSON dict (str→Any), so a local
|
||||
# cast keeps the rest of the function readable without sprinkling
|
||||
# per-line type narrowing on every ``.get`` call.
|
||||
d = cast("dict[str, Any]", tool_input)
|
||||
for key in _PATH_KEYS:
|
||||
value = d.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
short = value if len(value) <= 60 else value[:57] + "..."
|
||||
return f"{name} · {short}"
|
||||
return name
|
||||
|
||||
|
||||
# ---- internals ---------------------------------------------------------
|
||||
|
||||
|
||||
def _render_block(block: object) -> Iterable[str]:
|
||||
if isinstance(block, TextBlock):
|
||||
text = (block.text or "").strip()
|
||||
if text:
|
||||
yield text
|
||||
return
|
||||
if isinstance(block, ThinkingBlock):
|
||||
yield from _render_thinking(block.thinking or "")
|
||||
return
|
||||
if isinstance(block, ToolUseBlock):
|
||||
yield from _render_tool_use(block)
|
||||
return
|
||||
# Unknown block type — skip silently rather than corrupting the file.
|
||||
|
||||
|
||||
def _render_thinking(text: str) -> Iterable[str]:
|
||||
yield "> [!thinking]-"
|
||||
for line in text.strip().splitlines() or [""]:
|
||||
yield f"> {line}" if line else ">"
|
||||
|
||||
|
||||
def _render_tool_use(block: ToolUseBlock) -> Iterable[str]:
|
||||
title = summarize_tool_input(block.name, block.input)
|
||||
yield f"> [!tool]- {title}"
|
||||
yield "> **input:**"
|
||||
yield "> ```json"
|
||||
pretty = json.dumps(block.input, indent=2, ensure_ascii=False, sort_keys=True)
|
||||
for line in pretty.splitlines():
|
||||
yield f"> {line}" if line else ">"
|
||||
yield "> ```"
|
||||
|
||||
|
||||
def adaptive_fence(content: str) -> str:
|
||||
"""Return a backtick fence at least one longer than the longest run in ``content``.
|
||||
|
||||
Currently unused (tool *results* aren't persisted yet) — kept here
|
||||
so when result capture lands the rendering side already has the
|
||||
primitive.
|
||||
"""
|
||||
longest = 0
|
||||
for match in re.finditer(r"`+", content):
|
||||
longest = max(longest, len(match.group(0)))
|
||||
return "`" * max(3, longest + 1)
|
||||
Reference in New Issue
Block a user