mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-14 09:11:54 +00:00
fix(weixin): preserve native markdown rendering
- stop rewriting markdown tables, headings, and links before delivery - keep markdown table blocks and headings together during chunking - update Weixin tests and docs for native markdown rendering Closes #10308
This commit is contained in:
parent
498fc6780e
commit
6ee65b4d61
3 changed files with 35 additions and 53 deletions
|
|
@ -623,42 +623,31 @@ def _rewrite_table_block_for_weixin(lines: List[str]) -> str:
|
|||
def _normalize_markdown_blocks(content: str) -> str:
|
||||
lines = content.splitlines()
|
||||
result: List[str] = []
|
||||
i = 0
|
||||
in_code_block = False
|
||||
blank_run = 0
|
||||
|
||||
while i < len(lines):
|
||||
line = lines[i].rstrip()
|
||||
fence_match = _FENCE_RE.match(line.strip())
|
||||
if fence_match:
|
||||
for raw_line in lines:
|
||||
line = raw_line.rstrip()
|
||||
if _FENCE_RE.match(line.strip()):
|
||||
in_code_block = not in_code_block
|
||||
result.append(line)
|
||||
i += 1
|
||||
blank_run = 0
|
||||
continue
|
||||
|
||||
if in_code_block:
|
||||
result.append(line)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if (
|
||||
i + 1 < len(lines)
|
||||
and "|" in lines[i]
|
||||
and _TABLE_RULE_RE.match(lines[i + 1].rstrip())
|
||||
):
|
||||
table_lines = [lines[i].rstrip(), lines[i + 1].rstrip()]
|
||||
i += 2
|
||||
while i < len(lines) and "|" in lines[i]:
|
||||
table_lines.append(lines[i].rstrip())
|
||||
i += 1
|
||||
result.append(_rewrite_table_block_for_weixin(table_lines))
|
||||
if not line.strip():
|
||||
blank_run += 1
|
||||
if blank_run <= 1:
|
||||
result.append("")
|
||||
continue
|
||||
|
||||
result.append(_MARKDOWN_LINK_RE.sub(r"\1 (\2)", _rewrite_headers_for_weixin(line)))
|
||||
i += 1
|
||||
blank_run = 0
|
||||
result.append(line)
|
||||
|
||||
normalized = "\n".join(item.rstrip() for item in result)
|
||||
normalized = re.sub(r"\n{3,}", "\n\n", normalized)
|
||||
return normalized.strip()
|
||||
return "\n".join(result).strip()
|
||||
|
||||
|
||||
def _split_markdown_blocks(content: str) -> List[str]:
|
||||
|
|
@ -704,8 +693,8 @@ def _split_delivery_units_for_weixin(content: str) -> List[str]:
|
|||
|
||||
Weixin can render Markdown, but chat readability is better when top-level
|
||||
line breaks become separate messages. Keep fenced code blocks intact and
|
||||
attach indented continuation lines to the previous top-level line so
|
||||
transformed tables/lists do not get torn apart.
|
||||
attach indented continuation lines to the previous top-level line so nested
|
||||
list items do not get torn apart.
|
||||
"""
|
||||
units: List[str] = []
|
||||
|
||||
|
|
@ -747,7 +736,9 @@ def _looks_like_chatty_line_for_weixin(line: str) -> bool:
|
|||
return False
|
||||
if line.startswith((" ", "\t")):
|
||||
return False
|
||||
if stripped.startswith((">", "-", "*", "【")):
|
||||
if stripped.startswith((">", "-", "*", "【", "#", "|")):
|
||||
return False
|
||||
if _TABLE_RULE_RE.match(stripped):
|
||||
return False
|
||||
if re.match(r"^\*\*[^*]+\*\*$", stripped):
|
||||
return False
|
||||
|
|
@ -757,10 +748,12 @@ def _looks_like_chatty_line_for_weixin(line: str) -> bool:
|
|||
|
||||
|
||||
def _looks_like_heading_line_for_weixin(line: str) -> bool:
|
||||
"""Return True when a short line behaves like a plain-text heading."""
|
||||
"""Return True when a short line behaves like a heading."""
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
return False
|
||||
if _HEADER_RE.match(stripped):
|
||||
return True
|
||||
return len(stripped) <= 24 and stripped.endswith((":", ":"))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -24,17 +24,14 @@ def _make_adapter() -> WeixinAdapter:
|
|||
|
||||
|
||||
class TestWeixinFormatting:
|
||||
def test_format_message_preserves_markdown_and_rewrites_headers(self):
|
||||
def test_format_message_preserves_markdown(self):
|
||||
adapter = _make_adapter()
|
||||
|
||||
content = "# Title\n\n## Plan\n\nUse **bold** and [docs](https://example.com)."
|
||||
|
||||
assert (
|
||||
adapter.format_message(content)
|
||||
== "【Title】\n\n**Plan**\n\nUse **bold** and docs (https://example.com)."
|
||||
)
|
||||
assert adapter.format_message(content) == content
|
||||
|
||||
def test_format_message_rewrites_markdown_tables(self):
|
||||
def test_format_message_preserves_markdown_tables(self):
|
||||
adapter = _make_adapter()
|
||||
|
||||
content = (
|
||||
|
|
@ -44,19 +41,14 @@ class TestWeixinFormatting:
|
|||
"| Retries | 3 |\n"
|
||||
)
|
||||
|
||||
assert adapter.format_message(content) == (
|
||||
"- Setting: Timeout\n"
|
||||
" Value: 30s\n"
|
||||
"- Setting: Retries\n"
|
||||
" Value: 3"
|
||||
)
|
||||
assert adapter.format_message(content) == content.strip()
|
||||
|
||||
def test_format_message_preserves_fenced_code_blocks(self):
|
||||
adapter = _make_adapter()
|
||||
|
||||
content = "## Snippet\n\n```python\nprint('hi')\n```"
|
||||
|
||||
assert adapter.format_message(content) == "**Snippet**\n\n```python\nprint('hi')\n```"
|
||||
assert adapter.format_message(content) == content
|
||||
|
||||
def test_format_message_returns_empty_string_for_none(self):
|
||||
adapter = _make_adapter()
|
||||
|
|
@ -102,7 +94,7 @@ class TestWeixinChunking:
|
|||
content = adapter.format_message("## 结论\n这是正文")
|
||||
chunks = adapter._split_text(content)
|
||||
|
||||
assert chunks == ["**结论**\n这是正文"]
|
||||
assert chunks == ["## 结论\n这是正文"]
|
||||
|
||||
def test_split_text_keeps_short_reformatted_table_in_single_chunk(self):
|
||||
adapter = _make_adapter()
|
||||
|
|
@ -378,16 +370,13 @@ class TestWeixinRemoteMediaSafety:
|
|||
|
||||
|
||||
class TestWeixinMarkdownLinks:
|
||||
"""Markdown links should be converted to plaintext since WeChat can't render them."""
|
||||
"""Markdown links should be preserved so WeChat can render them natively."""
|
||||
|
||||
def test_format_message_converts_markdown_links_to_plain_text(self):
|
||||
def test_format_message_preserves_markdown_links(self):
|
||||
adapter = _make_adapter()
|
||||
|
||||
content = "Check [the docs](https://example.com) and [GitHub](https://github.com) for details"
|
||||
assert (
|
||||
adapter.format_message(content)
|
||||
== "Check the docs (https://example.com) and GitHub (https://github.com) for details"
|
||||
)
|
||||
assert adapter.format_message(content) == content
|
||||
|
||||
def test_format_message_preserves_links_inside_code_blocks(self):
|
||||
adapter = _make_adapter()
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ The adapter will restore saved credentials, connect to the iLink API, and begin
|
|||
- **Media support** — images, video, files, and voice messages
|
||||
- **AES-128-ECB encrypted CDN** — automatic encryption/decryption for all media transfers
|
||||
- **Context token persistence** — disk-backed reply continuity across restarts
|
||||
- **Markdown formatting** — headers, tables, and code blocks are reformatted for WeChat readability
|
||||
- **Markdown formatting** — preserves Markdown, including headers, tables, and code blocks, so WeChat clients that support Markdown can render it natively
|
||||
- **Smart message chunking** — messages stay as a single bubble when under the limit; only oversized payloads split at logical boundaries
|
||||
- **Typing indicators** — shows "typing…" status in the WeChat client while the agent processes
|
||||
- **SSRF protection** — outbound media URLs are validated before download
|
||||
|
|
@ -206,12 +206,12 @@ This ensures reply continuity even after gateway restarts.
|
|||
|
||||
## Markdown Formatting
|
||||
|
||||
WeChat's personal chat does not natively render full Markdown. The adapter reformats content for better readability:
|
||||
WeChat clients connected through the iLink Bot API can render Markdown directly, so the adapter preserves Markdown instead of rewriting it:
|
||||
|
||||
- **Headers** (`# Title`) → converted to `【Title】` (level 1) or `**Title**` (level 2+)
|
||||
- **Tables** → reformatted as labeled key-value lists (e.g., `- Column: Value`)
|
||||
- **Code fences** → preserved as-is (WeChat renders these adequately)
|
||||
- **Excessive blank lines** → collapsed to double newlines
|
||||
- **Headers** stay as Markdown headings (`#`, `##`, ...)
|
||||
- **Tables** stay as Markdown tables
|
||||
- **Code fences** stay as fenced code blocks
|
||||
- **Excessive blank lines** are collapsed to double newlines outside fenced code blocks
|
||||
|
||||
## Message Chunking
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue