fix: 导出文档时把 mermaid mindmap 转成 flowchart LR,外部阅读器清晰可读

- 老版 mermaid 渲染 mindmap 是根居中放射状、粗彩线,外部阅读器里很难读
- 导出接口现把 ```mermaid mindmap 块转换为 flowchart LR 树状图,网页样式不受影响
- 支持 root(( )) 关键字、形状符号、::icon() 与行尾注释的清理

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-10-24 18:51:58 +08:00
co-authored by Claude Code
parent 8cd390b3ed
commit 183d77681c
+79 -1
View File
@@ -1,5 +1,6 @@
import json
import logging
import re
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
@@ -368,7 +369,9 @@ async def export_conversation(conv_id: str, fmt: str = "markdown", db: AsyncSess
lines = [f"# {conv.title or '对话'}", f"书籍: {book.title if book else '未知'}\n"]
for m in msgs:
role_label = "🧑 用户" if m.role == "user" else "🤖 AI"
lines.append(f"## {role_label}\n\n{m.content}\n")
# 把 mermaid mindmap 转成 flowchart LR:老版阅读器渲染 mindmap 是放射状粗线,很难读
content = _transform_mindmaps(m.content)
lines.append(f"## {role_label}\n\n{content}\n")
if m.citations:
try:
citations = json.loads(m.citations)
@@ -379,6 +382,81 @@ async def export_conversation(conv_id: str, fmt: str = "markdown", db: AsyncSess
return {"content": "\n".join(lines), "format": fmt, "title": conv.title}
_FENCE_RE = re.compile(r"```(mermaid)?\s*\n(.*?)\n```", re.DOTALL)
def _transform_mindmaps(text: str) -> str:
"""把 markdown 里的 ```mermaid mindmap 块转换成 flowchart LR(清晰左→右树状)。"""
def _repl(m: re.Match) -> str:
body = m.group(2)
if body.lstrip().startswith("mindmap"):
return f"```mermaid\n{_mindmap_to_flowchart(body)}\n```"
return m.group(0)
return _FENCE_RE.sub(_repl, text)
def _mindmap_to_flowchart(body: str) -> str:
"""mindmap(缩进式)→ flowchart LR(节点 + 边)。
mindmap:
root((主题))
一级分支
二级分支
→ flowchart LR:
n1["主题"]
n1 --> n2["一级分支"]
n2 --> n3["二级分支"]
"""
lines = []
for raw in body.splitlines():
s = raw.strip()
if not s or s == "mindmap":
continue
lines.append(raw)
if not lines:
return "flowchart LR\n n1[空思维导图]"
out = ["flowchart LR"]
stack: list[tuple[int, str]] = [] # (缩进长度, 节点 id)
seq = 0
def _new_id() -> str:
nonlocal seq
seq += 1
return f"n{seq}"
for raw in lines:
m = re.match(r"[ \t]*", raw)
indent_len = len(m.group(0).replace("\t", " "))
text = raw[m.end():].strip()
# 去掉 ::icon(...) 与行尾 # 注释
text = re.sub(r"::icon\([^)]*\)", "", text)
text = re.split(r"\s+#\s*", text)[0].strip()
# 去掉 mindmap 的 root 关键字:root((主题)) 里的 root 只是根标记,标签在形状符号内
if text.startswith("root") and len(text) > 4:
rest = text[4:].lstrip()
if rest and rest[0] in "([{":
text = rest
# 去掉 mindmap 形状符号包裹:(()) / [[ ]] / [ ] / ( ) / { }
for open_, close in (("((", "))"), ("[[", "]]"), ("[", "]"), ("(", ")"), ("{", "}")):
if text.startswith(open_) and text.endswith(close):
text = text[len(open_):-len(close)].strip()
break
label = text.strip() or _new_id()
while stack and stack[-1][0] >= indent_len:
stack.pop()
nid = _new_id()
safe = label.replace("\\", "\\\\").replace('"', '\\"').replace("\n", " ")
out.append(f' {nid}["{safe}"]')
if stack:
out.append(f" {stack[-1][1]} --> {nid}")
stack.append((indent_len, nid))
return "\n".join(out)
def _msg_to_out(m: Message) -> MessageOut:
citations = None
if m.citations: