feat(export): 交付 Markdown → HTML 导出服务

实现 Export Service 完整生命周期:mistune AST → Document AST → HtmlExporter 渲染完整 HTML5,异步任务注册表 + 取消 + 24h 产物过期。新增 5 个 /api/exports 端点与 15 项测试;pdf/docx 与函数图像静态渲染留待后续 PR。
This commit is contained in:
yxx
2026-09-04 09:02:33 +08:00
parent 2e496462a9
commit 5c2441464d
18 changed files with 1358 additions and 17 deletions
+2
View File
@@ -14,6 +14,8 @@ backend/.env
# 运行期生成的 SQLite 索引(vault 下的 Markdown 测试数据需提交)
backend/data/*.db*
backend/data/credentials/
# 运行期导出的 HTML/PDF/DOCX 产物(不提交)
backend/data/exports/
# 阶段验收笔记(验收用,不提交)
backend/data/vault/验收/
# 本机 MCP 配置、授权状态及服务器工作目录不得提交。
+2 -2
View File
@@ -2,7 +2,7 @@
> 本文件用于团队开发期间快速配置环境和启动项目,不是正式的项目 README。
> 当前基线:2026-09-03。第一阶段 Web 联调前后端已经完成;第二阶段已完成 Workspace 去 Mock、Agent Trace 持久化与 SSE 恢复、stdio MCP Bridge、隔离 Plugin Host、Plugin Command/Settings,以及独立 MCP Server 配置中心 C.1stdio、Streamable HTTP 与旧 SSE 兼容)。真实音频、Provider 协议增强、Benchmark、导出、主题包、Trace 可视化、Mermaid 与函数图像仍在后续开发;Tauri Host、Stronghold、原生多 Vault 文件系统和 Sync Server 尚未接入。
> 当前基线:2026-09-03。第一阶段 Web 联调前后端已经完成;第二阶段已完成 Workspace 去 Mock、Agent Trace 持久化与 SSE 恢复、stdio MCP Bridge、隔离 Plugin Host、Plugin Command/Settings,以及独立 MCP Server 配置中心 C.1stdio、Streamable HTTP 与旧 SSE 兼容)、RAG Benchmark 与 Markdown → HTML 导出。真实音频、Provider 协议增强、Agent Benchmark、PDF/DOCX 导出、主题包、Trace 可视化、Mermaid 与函数图像仍在后续开发;Tauri Host、Stronghold、原生多 Vault 文件系统和 Sync Server 尚未接入。
## 当前目录
@@ -118,7 +118,7 @@ cd frontend
pnpm test
```
当前回归基线为后端 218 项测试、前端 32 项测试,且 TypeScript 类型检查和生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
当前回归基线为后端 467 项测试、前端 32 项测试,且 TypeScript 类型检查和生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
构建产物位于 `frontend/dist`,该目录不提交到 Git。
+2
View File
@@ -25,6 +25,7 @@ class Settings:
vault_path: Path
attachments_path: Path
benchmark_datasets_path: Path
exports_path: Path
@lru_cache
@@ -45,4 +46,5 @@ def get_settings() -> Settings:
benchmark_datasets_path=Path(
os.getenv("APP_BENCHMARK_DATASETS_PATH", str(data_dir / "benchmarks"))
),
exports_path=Path(os.getenv("APP_EXPORTS_PATH", str(data_dir / "exports"))),
)
+93 -1
View File
@@ -2,7 +2,14 @@ from datetime import datetime
from enum import Enum
from typing import Annotated, Any, Literal
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator
from pydantic import (
BaseModel,
ConfigDict,
Field,
SecretStr,
field_validator,
model_validator,
)
class Contract(BaseModel):
@@ -1160,3 +1167,88 @@ class BenchmarkReport(Contract):
cases: list[RAGCaseResult] = Field(default_factory=list)
error: str | None = None
error_code: str | None = None
# Export(多格式文档导出)
class ExportStatus(str, Enum):
queued = "queued"
running = "running"
completed = "completed"
failed = "failed"
cancelled = "cancelled"
class ExportFormat(str, Enum):
html = "html"
pdf = "pdf"
docx = "docx"
class ExportSourceType(str, Enum):
note = "note"
markdown = "markdown"
class ExportSource(Contract):
"""导出源:note 引用已索引笔记,markdown 用于未保存预览(不持久化)。"""
type: ExportSourceType
note_id: str | None = None
markdown: str | None = None
@model_validator(mode="after")
def _validate_source(self) -> "ExportSource":
if self.type == ExportSourceType.note and not self.note_id:
raise ValueError("note source requires note_id")
if self.type == ExportSourceType.markdown and not self.markdown:
raise ValueError("markdown source requires markdown")
return self
class ExportOptions(Contract):
theme_id: str = "light"
include_title: bool = True
include_metadata: bool = False
page_size: str = "A4"
code_theme: str = "github-light"
class ExportRequest(Contract):
source: ExportSource
format: ExportFormat
options: ExportOptions = Field(default_factory=ExportOptions)
class ExportProgress(Contract):
phase: str
current: int
total: int
percent: float | None = None
message: str | None = None
class ExportFile(Contract):
file_name: str
mime_type: str
size: int
sha256: str
expires_at: datetime
class ExportJob(Contract):
job_id: str
status: ExportStatus
format: ExportFormat
progress: ExportProgress | None = None
file: ExportFile | None = None
warnings: list[str] = Field(default_factory=list)
error: str | None = None
error_code: str | None = None
created_at: datetime
started_at: datetime | None = None
completed_at: datetime | None = None
class ExportJobListResponse(Contract):
items: list[ExportJob] = Field(default_factory=list)
page: PageMeta = Field(default_factory=PageMeta)
+8
View File
@@ -0,0 +1,8 @@
"""Export Service:多格式文档导出(首批 HTML)。
模块划分:
- document.py Document AST 内部协议 + DocumentExporter Protocol + ExportResult
- markdown.py mistune → Document AST 解析
- exporters/html.py HtmlExporterDocument AST → HTML5
- service.py 导出任务注册表、后台执行、取消与文件生命周期
"""
+46
View File
@@ -0,0 +1,46 @@
"""Document AST:导出器的内部中间表示(Internal Protocol,不放入 contracts.py)。
契约 §10.3 规定节点用稳定判别字段 node_id / type / attributes / children / text
类型专有信息统一放 attributes(如 heading 的 level、link 的 href、image 的 src)。
导出器据此递归渲染,对无法表示的节点记 warning,不静默丢弃。
"""
from __future__ import annotations
from typing import Any, Protocol
from pydantic import BaseModel, ConfigDict, Field
from app.contracts import ExportOptions
class DocumentNode(BaseModel):
"""递归文档节点;type 取契约 §10.3 首批 node type 之一。"""
model_config = ConfigDict(extra="forbid")
type: str
node_id: str
attributes: dict[str, Any] = Field(default_factory=dict)
children: list["DocumentNode"] = Field(default_factory=list)
text: str = ""
class Document(DocumentNode):
"""根节点,type 固定为 document。"""
type: str = "document"
class DocumentExporter(Protocol):
"""导出器协议(契约 §10.3):把 Document AST 渲染为指定格式的产物。"""
async def export(self, document: Document, options: ExportOptions) -> "ExportResult": ...
class ExportResult(BaseModel):
model_config = ConfigDict(extra="forbid")
content: bytes
mime_type: str
warnings: list[str] = Field(default_factory=list)
+1
View File
@@ -0,0 +1 @@
"""Export 渲染器:Document AST → 具体格式产物。"""
+216
View File
@@ -0,0 +1,216 @@
"""HtmlExporterDocument AST → 完整 HTML5 文档(内嵌基础 CSS)。
对无法静态表达的节点(mermaid / function_plot)渲染为占位代码块并记 warning,不静默丢失;
严重内容缺失由 service 层以 EXPORT_UNSUPPORTED_CONTENT 判定,本层只负责逐节点渲染。
"""
from __future__ import annotations
import html
from datetime import datetime
from app.contracts import ExportOptions
from app.export.document import Document, DocumentNode, ExportResult
_MERMAID_WARNING = "mermaid 需前端渲染,已保留为占位代码块"
_FUNCTION_PLOT_WARNING = "函数图像渲染将在后续版本提供,已保留为占位代码块"
_BASE_CSS = """
body { margin: 0; background: #f6f7f9; color: #1f2328; font: 15px/1.7 -apple-system, 'Segoe UI', 'Microsoft YaHei', sans-serif; }
article { max-width: 860px; margin: 0 auto; padding: 40px 48px; background: #fff; }
article.theme-dark { background: #0d1117; color: #c9d1d9; }
h1, h2, h3, h4, h5, h6 { line-height: 1.3; margin: 1.4em 0 0.6em; }
h1.title { margin-top: 0; }
p { margin: 0.6em 0; }
a { color: #0969da; }
code { font-family: 'JetBrains Mono', Consolas, monospace; font-size: 0.9em; background: #f0f1f3; padding: 0.15em 0.35em; border-radius: 3px; }
pre { background: #f6f8fa; padding: 14px 16px; border-radius: 6px; overflow-x: auto; }
pre.code-theme-github-dark { background: #0d1117; color: #c9d1d9; }
pre code { background: none; padding: 0; }
pre.mermaid, pre.function-plot { border: 1px dashed #d0d7de; }
blockquote { margin: 0.8em 0; padding: 0.2em 1em; border-left: 4px solid #d0d7de; color: #57606a; }
img { max-width: 100%; }
table { border-collapse: collapse; margin: 0.8em 0; }
th, td { border: 1px solid #d0d7de; padding: 6px 12px; }
th { background: #f6f8fa; }
dl.metadata { font-size: 0.85em; color: #57606a; border-top: 1px solid #eaeef2; border-bottom: 1px solid #eaeef2; padding: 0.6em 0; }
dl.metadata dt { display: inline; font-weight: 600; margin-right: 0.4em; }
dl.metadata dd { display: inline; margin: 0 1.2em 0 0; }
.math, .math-block { overflow-x: auto; padding: 0.4em 0; }
.task-list-item { list-style: none; }
.task-list-item input { margin-right: 0.4em; }
hr { border: none; border-top: 1px solid #d0d7de; margin: 1.4em 0; }
""".strip()
class HtmlExporter:
"""实现 DocumentExporter:递归渲染 Document AST 为完整 HTML5 文档。"""
async def export(self, document: Document, options: ExportOptions) -> ExportResult:
self._options = options
warnings: list[str] = []
body = self._render_children(document.children, warnings)
content = self._assemble(document, options, body, warnings)
return ExportResult(
content=content.encode("utf-8"), mime_type="text/html", warnings=warnings
)
def _assemble(
self, document: Document, options: ExportOptions, body: str, warnings: list[str]
) -> str:
title = str(document.attributes.get("title") or "")
parts = [
"<!doctype html>",
'<html lang="zh-CN">',
"<head>",
'<meta charset="utf-8">',
'<meta name="viewport" content="width=device-width, initial-scale=1">',
]
if title:
parts.append(f"<title>{html.escape(title)}</title>")
parts.append(f"<style>{_BASE_CSS}</style>")
parts.append("</head>")
parts.append("<body>")
parts.append(f'<article class="theme-{html.escape(options.theme_id)}">')
if options.include_title and title:
parts.append(f'<h1 class="title">{html.escape(title)}</h1>')
if options.include_metadata:
metadata = document.attributes.get("metadata")
if metadata:
parts.append(self._render_metadata(metadata))
parts.append(body)
parts.append("</article>")
parts.append("</body>")
parts.append("</html>")
return "\n".join(parts) + "\n"
def _render_metadata(self, metadata: dict) -> str:
entries = ["<dl", ' class="metadata">']
for key, value in metadata.items():
entries.append(f"<dt>{html.escape(str(key))}</dt>")
entries.append(f"<dd>{html.escape(self._fmt_meta_value(value))}</dd>")
entries.append("</dl>")
return "".join(entries)
@staticmethod
def _fmt_meta_value(value: object) -> str:
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, list):
return ", ".join(str(item) for item in value)
return str(value)
def _render_children(self, children: list[DocumentNode], warnings: list[str]) -> str:
return "".join(self._render_node(child, warnings) for child in children)
def _render_node(self, node: DocumentNode, warnings: list[str]) -> str:
handler = getattr(self, f"_render_{node.type}", None)
if handler is not None:
return handler(node, warnings)
warnings.append(f"无法表示的节点类型已跳过:{node.type}")
return ""
# --- 块级 ---
def _render_heading(self, node: DocumentNode, warnings: list[str]) -> str:
level = max(1, min(6, int(node.attributes.get("level", 1))))
return f"<h{level}>{self._render_children(node.children, warnings)}</h{level}>"
def _render_paragraph(self, node: DocumentNode, warnings: list[str]) -> str:
return f"<p>{self._render_children(node.children, warnings)}</p>"
def _render_blockquote(self, node: DocumentNode, warnings: list[str]) -> str:
return f"<blockquote>{self._render_children(node.children, warnings)}</blockquote>"
def _render_list(self, node: DocumentNode, warnings: list[str]) -> str:
tag = "ol" if node.attributes.get("ordered") else "ul"
return f"<{tag}>{self._render_children(node.children, warnings)}</{tag}>"
def _render_list_item(self, node: DocumentNode, warnings: list[str]) -> str:
inner = self._render_children(node.children, warnings)
if node.attributes.get("task"):
checked = " checked" if node.attributes.get("checked") else ""
return (
'<li class="task-list-item">'
f'<input type="checkbox" disabled{checked}>{inner}</li>'
)
return f"<li>{inner}</li>"
def _render_table(self, node: DocumentNode, warnings: list[str]) -> str:
rows = node.children
head_rows = [r for r in rows if r.attributes.get("head")]
body_rows = [r for r in rows if not r.attributes.get("head")]
parts = ["<table>"]
if head_rows:
parts.append("<thead>")
parts.extend(self._render_node(r, warnings) for r in head_rows)
parts.append("</thead>")
if body_rows:
parts.append("<tbody>")
parts.extend(self._render_node(r, warnings) for r in body_rows)
parts.append("</tbody>")
parts.append("</table>")
return "".join(parts)
def _render_table_row(self, node: DocumentNode, warnings: list[str]) -> str:
return f"<tr>{self._render_children(node.children, warnings)}</tr>"
def _render_table_cell(self, node: DocumentNode, warnings: list[str]) -> str:
tag = "th" if node.attributes.get("head") else "td"
return f"<{tag}>{self._render_children(node.children, warnings)}</{tag}>"
def _render_code_block(self, node: DocumentNode, warnings: list[str]) -> str:
lang = str(node.attributes.get("language") or "")
code = html.escape(node.text)
lang_cls = f' class="language-{html.escape(lang)}"' if lang else ""
theme = html.escape(self._options.code_theme)
return f'<pre class="code-theme-{theme}"><code{lang_cls}>{code}</code></pre>'
def _render_thematic_break(self, node: DocumentNode, warnings: list[str]) -> str:
return "<hr>"
def _render_mermaid(self, node: DocumentNode, warnings: list[str]) -> str:
warnings.append(_MERMAID_WARNING)
return f'<pre class="mermaid">{html.escape(node.text)}</pre>'
def _render_function_plot(self, node: DocumentNode, warnings: list[str]) -> str:
warnings.append(_FUNCTION_PLOT_WARNING)
return f'<pre class="function-plot">{html.escape(node.text)}</pre>'
def _render_math_block(self, node: DocumentNode, warnings: list[str]) -> str:
return f'<div class="math-block">$${html.escape(node.text)}$$</div>'
# --- 行内 ---
def _render_text(self, node: DocumentNode, warnings: list[str]) -> str:
return html.escape(node.text)
def _render_emphasis(self, node: DocumentNode, warnings: list[str]) -> str:
return f"<em>{self._render_children(node.children, warnings)}</em>"
def _render_strong(self, node: DocumentNode, warnings: list[str]) -> str:
return f"<strong>{self._render_children(node.children, warnings)}</strong>"
def _render_link(self, node: DocumentNode, warnings: list[str]) -> str:
href = html.escape(str(node.attributes.get("href") or ""))
title = str(node.attributes.get("title") or "")
attrs = [f'href="{href}"']
if title:
attrs.append(f'title="{html.escape(title)}"')
return f"<a {' '.join(attrs)}>{self._render_children(node.children, warnings)}</a>"
def _render_codespan(self, node: DocumentNode, warnings: list[str]) -> str:
return f"<code>{html.escape(node.text)}</code>"
def _render_image(self, node: DocumentNode, warnings: list[str]) -> str:
src = html.escape(str(node.attributes.get("src") or ""))
alt = html.escape(str(node.attributes.get("alt") or ""))
title = str(node.attributes.get("title") or "")
attrs = [f'src="{src}"', f'alt="{alt}"']
if title:
attrs.append(f'title="{html.escape(title)}"')
return f"<img {' '.join(attrs)}>"
def _render_math_inline(self, node: DocumentNode, warnings: list[str]) -> str:
return f"\\({html.escape(node.text)}\\)"
def _render_linebreak(self, node: DocumentNode, warnings: list[str]) -> str:
return "<br>"
+214
View File
@@ -0,0 +1,214 @@
"""Markdown → Document AST:用 mistune 的 ast renderer 产出通用 token,再映射为内部节点。
选用 mistune 内置 'ast' renderer 而非自写 BaseRenderer,是因为 mistune 的行内渲染按
字符串拼接、无法承载结构化子节点;ast renderer 直接给出带 children/attrs/raw 的 token
树,映射层只做 token → DocumentNode 的搬运,不掺入任何 HTML。
"""
from __future__ import annotations
import mistune
from app.export.document import Document, DocumentNode
_PLUGINS = ["table", "math", "url", "task_lists"]
# fenced code 语言分流:命中则转为专用节点,其余按普通代码块
_MERMAID_LANG = "mermaid"
_FUNCTION_PLOT_LANGS = {"function_plot", "functionplot"}
def parse_document(markdown: str) -> Document:
"""把 Markdown 文本解析为 Document AST 根节点。"""
renderer = mistune.create_markdown(renderer="ast", plugins=_PLUGINS)
tokens = renderer(markdown)
mapper = _AstMapper()
return Document(node_id=mapper.next_id(), children=mapper.map_blocks(tokens))
class _AstMapper:
"""token 树 → DocumentNode 树的映射器;node_id 按遍历顺序递增,无需跨请求稳定。"""
def __init__(self) -> None:
self._seq = 0
def next_id(self) -> str:
self._seq += 1
return f"node_{self._seq:03d}"
def map_blocks(self, tokens: list[dict]) -> list[DocumentNode]:
nodes: list[DocumentNode] = []
for token in tokens:
node = self.map_block(token)
if node is not None:
nodes.append(node)
return nodes
def map_block(self, token: dict) -> DocumentNode | None:
kind = token["type"]
if kind == "heading":
return DocumentNode(
type="heading",
node_id=self.next_id(),
attributes={"level": token["attrs"]["level"]},
children=self.map_inline(token.get("children", [])),
)
if kind in ("paragraph", "block_text"):
# block_text 是列表项内的段落块,仍按 paragraph 表达,由 list_item 包裹
return DocumentNode(
type="paragraph",
node_id=self.next_id(),
children=self.map_inline(token.get("children", [])),
)
if kind == "list":
return DocumentNode(
type="list",
node_id=self.next_id(),
attributes={"ordered": bool(token.get("attrs", {}).get("ordered"))},
children=[self.map_list_item(child) for child in token.get("children", [])],
)
if kind == "block_code":
return self._map_code(token)
if kind == "block_quote":
return DocumentNode(
type="blockquote",
node_id=self.next_id(),
children=self.map_blocks(token.get("children", [])),
)
if kind == "table":
return self._map_table(token)
if kind == "block_math":
return DocumentNode(
type="math_block", node_id=self.next_id(), text=token.get("raw", "")
)
if kind == "thematic_break":
return DocumentNode(type="thematic_break", node_id=self.next_id())
if kind == "blank_line":
return None
# 未知块级 token(如 block_html)保守保留原文,避免静默丢失
raw = token.get("raw", "")
if raw:
return DocumentNode(type="paragraph", node_id=self.next_id(), text=raw)
return None
def map_list_item(self, token: dict) -> DocumentNode:
"""列表项:block_text 展平为行内子节点,嵌套 list 保留为子节点。"""
attributes: dict = {}
if token["type"] == "task_list_item":
attributes = {"task": True, "checked": bool(token.get("attrs", {}).get("checked"))}
children: list[DocumentNode] = []
for child in token.get("children", []):
if child["type"] == "block_text":
children.extend(self.map_inline(child.get("children", [])))
elif child["type"] == "list":
children.append(self.map_block(child))
else:
node = self.map_block(child)
if node is not None:
children.append(node)
return DocumentNode(
type="list_item", node_id=self.next_id(), attributes=attributes, children=children
)
def map_inline(self, tokens: list[dict]) -> list[DocumentNode]:
nodes: list[DocumentNode] = []
for token in tokens:
node = self.map_inline_token(token)
if node is not None:
nodes.append(node)
return nodes
def map_inline_token(self, token: dict) -> DocumentNode | None:
kind = token["type"]
if kind == "text":
return DocumentNode(type="text", node_id=self.next_id(), text=token.get("raw", ""))
if kind == "strong":
return DocumentNode(
type="strong", node_id=self.next_id(),
children=self.map_inline(token.get("children", [])),
)
if kind == "emphasis":
return DocumentNode(
type="emphasis", node_id=self.next_id(),
children=self.map_inline(token.get("children", [])),
)
if kind == "link":
attrs = token.get("attrs", {})
attributes = {"href": attrs.get("url", "")}
if attrs.get("title"):
attributes["title"] = attrs["title"]
return DocumentNode(
type="link", node_id=self.next_id(), attributes=attributes,
children=self.map_inline(token.get("children", [])),
)
if kind == "codespan":
return DocumentNode(type="codespan", node_id=self.next_id(), text=token.get("raw", ""))
if kind == "image":
attrs = token.get("attrs", {})
attributes = {"src": attrs.get("src", "")}
if attrs.get("alt"):
attributes["alt"] = attrs["alt"]
if attrs.get("title"):
attributes["title"] = attrs["title"]
return DocumentNode(type="image", node_id=self.next_id(), attributes=attributes)
if kind == "inline_math":
return DocumentNode(
type="math_inline", node_id=self.next_id(), text=token.get("raw", "")
)
if kind == "softbreak":
# HTML 中换行会折叠为空白,软换行按空格表达
return DocumentNode(type="text", node_id=self.next_id(), text=" ")
if kind == "linebreak":
return DocumentNode(type="linebreak", node_id=self.next_id())
# 未知行内 token 保守保留原文
raw = token.get("raw", "")
if raw:
return DocumentNode(type="text", node_id=self.next_id(), text=raw)
return None
def _map_code(self, token: dict) -> DocumentNode:
info = (token.get("attrs", {}).get("info") or "").strip()
lang = info.split()[0].lower() if info else ""
code = token.get("raw", "").rstrip("\n")
if lang == _MERMAID_LANG:
return DocumentNode(type="mermaid", node_id=self.next_id(), text=code)
if lang in _FUNCTION_PLOT_LANGS:
return DocumentNode(type="function_plot", node_id=self.next_id(), text=code)
attributes = {"language": lang} if lang else {}
return DocumentNode(
type="code_block", node_id=self.next_id(), attributes=attributes, text=code
)
def _map_table(self, token: dict) -> DocumentNode:
rows: list[DocumentNode] = []
for child in token.get("children", []):
if child["type"] == "table_head":
rows.append(self._map_table_row(child, head=True))
elif child["type"] == "table_body":
for row in child.get("children", []):
if row["type"] == "table_row":
rows.append(self._map_table_row(row, head=False))
elif child["type"] == "table_row":
rows.append(self._map_table_row(child, head=False))
return DocumentNode(type="table", node_id=self.next_id(), children=rows)
def _map_table_row(self, token: dict, *, head: bool) -> DocumentNode:
cells: list[DocumentNode] = []
for cell in token.get("children", []):
if cell["type"] != "table_cell":
continue
attrs = cell.get("attrs", {})
cell_attributes = {"head": bool(attrs.get("head", head))}
if attrs.get("align"):
cell_attributes["align"] = attrs["align"]
cells.append(
DocumentNode(
type="table_cell",
node_id=self.next_id(),
attributes=cell_attributes,
children=self.map_inline(cell.get("children", [])),
)
)
return DocumentNode(
type="table_row", node_id=self.next_id(), attributes={"head": head}, children=cells
)
+272
View File
@@ -0,0 +1,272 @@
"""Export 服务:任务注册表、后台渲染、取消与产物生命周期。
与 Benchmark 一致采用「创建即返回 queued、后台 Task 异步执行」的内存模型:任务与产物
暂存内存与 exports 目录,不持久化到 SQLite。导出是单阶段渲染,无 SSE 事件流,取消主要
在渲染前/后让出执行权的边界生效;产物带 24h 过期时间,过期后不可下载。
"""
from __future__ import annotations
import asyncio
import hashlib
import logging
import re
from datetime import datetime, timedelta, timezone
from pathlib import Path
from uuid import uuid4
from app.config import get_settings
from app.contracts import (
ExportFile,
ExportFormat,
ExportJob,
ExportOptions,
ExportProgress,
ExportRequest,
ExportSource,
ExportSourceType,
ExportStatus,
)
from app.errors import ApiError
from app.export.document import Document
from app.export.exporters.html import HtmlExporter
from app.export.markdown import parse_document
from app.services import note_service
logger = logging.getLogger(__name__)
_jobs: dict[str, ExportJob] = {}
_tasks: dict[str, asyncio.Task] = {}
_cancel_flags: dict[str, asyncio.Event] = {}
MAX_JOBS = 100
# markdown 源大小上限,防止未保存预览塞爆内存/产物
MAX_MARKDOWN_CHARS = 200_000
# 产物有效期
FILE_TTL = timedelta(hours=24)
_INVALID_FILE_CHARS = re.compile(r'[\\/:*?"<>|]')
class ExportCancelled(Exception):
"""导出在渲染前被取消时抛出,用于标记 cancelled。"""
def _now() -> datetime:
return datetime.now(timezone.utc)
def _safe_download_name(title: str) -> str:
"""清洗标题得到安全的下载文件名;空标题回退到 export。"""
name = _INVALID_FILE_CHARS.sub("_", title).strip() or "export"
return name[:80]
def _forget(job_id: str) -> None:
_jobs.pop(job_id, None)
_tasks.pop(job_id, None)
_cancel_flags.pop(job_id, None)
def _evict_terminal() -> bool:
"""超过容量时淘汰最旧的终态任务;全为活动任务无法淘汰时返回 False。"""
terminal = (ExportStatus.completed, ExportStatus.failed, ExportStatus.cancelled)
while len(_jobs) >= MAX_JOBS:
victim = next((jid for jid, job in _jobs.items() if job.status in terminal), None)
if victim is None:
return False
_forget(victim)
return True
async def _resolve_source(source: ExportSource) -> tuple[str, str, dict | None]:
"""把导出源解析为 (markdown, title, metadata)metadata 仅 note 源提供。"""
if source.type == ExportSourceType.note:
note = await note_service.get_note(source.note_id)
if note is None:
raise ApiError(
404,
"EXPORT_SOURCE_NOT_FOUND",
"note not found",
{"note_id": source.note_id},
)
metadata = {
"file_path": note.file_path,
"tags": note.tags,
"created_at": note.created_at,
"updated_at": note.updated_at,
}
return note.markdown, note.title, metadata
markdown = source.markdown or ""
if not markdown.strip():
raise ApiError(400, "EXPORT_OPTIONS_INVALID", "markdown source must not be empty")
if len(markdown) > MAX_MARKDOWN_CHARS:
raise ApiError(
400,
"EXPORT_OPTIONS_INVALID",
f"markdown source exceeds {MAX_MARKDOWN_CHARS} characters",
{"size": len(markdown), "limit": MAX_MARKDOWN_CHARS},
)
return markdown, "", None
async def create_export(request: ExportRequest) -> ExportJob:
"""创建导出任务,立即返回 queued 的 ExportJob,由后台 Task 渲染。"""
if request.format != ExportFormat.html:
raise ApiError(
400,
"EXPORT_FORMAT_UNSUPPORTED",
"PDF/DOCX 暂未实现,当前仅支持 HTML",
{"format": request.format.value},
)
markdown, title, metadata = await _resolve_source(request.source)
if not _evict_terminal():
raise ApiError(
429,
"EXPORT_CAPACITY_EXCEEDED",
"Export capacity exceeded; wait for active jobs to finish.",
{},
)
job_id = "export_" + uuid4().hex[:12]
job = ExportJob(
job_id=job_id,
status=ExportStatus.queued,
format=request.format,
created_at=_now(),
)
_jobs[job_id] = job
_cancel_flags[job_id] = asyncio.Event()
_tasks[job_id] = asyncio.create_task(
_execute(job_id, markdown, title, metadata, request.options)
)
return job
async def _execute(
job_id: str,
markdown: str,
title: str,
metadata: dict | None,
options: ExportOptions,
) -> None:
"""后台渲染:解析 → 导出 → 写文件 → 挂载产物元信息。"""
cancel_event = _cancel_flags[job_id]
_jobs[job_id] = _jobs[job_id].model_copy(
update={
"status": ExportStatus.running,
"started_at": _now(),
"progress": ExportProgress(phase="rendering", current=0, total=1, percent=0.0),
}
)
try:
# 让出一次,使「创建后立即取消」的 queued 任务能及时进入 cancelled
await asyncio.sleep(0)
if cancel_event.is_set():
raise ExportCancelled()
document = parse_document(markdown)
document.attributes["title"] = title
if metadata:
document.attributes["metadata"] = metadata
exporter = HtmlExporter()
result = await exporter.export(document, options)
if cancel_event.is_set():
raise ExportCancelled()
out_dir = get_settings().exports_path
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / f"{job_id}.html"
path.write_bytes(result.content)
completed_at = _now()
_jobs[job_id] = _jobs[job_id].model_copy(
update={
"status": ExportStatus.completed,
"progress": ExportProgress(
phase="completed", current=1, total=1, percent=1.0
),
"file": ExportFile(
file_name=f"{_safe_download_name(title)}.html",
mime_type=result.mime_type,
size=len(result.content),
sha256=hashlib.sha256(result.content).hexdigest(),
expires_at=completed_at + FILE_TTL,
),
"warnings": result.warnings,
"completed_at": completed_at,
}
)
except ExportCancelled:
_jobs[job_id] = _jobs[job_id].model_copy(
update={
"status": ExportStatus.cancelled,
"completed_at": _now(),
}
)
except Exception as exc: # 渲染失败不拖垮服务,只记日志与项目错误码
logger.exception("Export failed: job_id=%s", job_id)
_jobs[job_id] = _jobs[job_id].model_copy(
update={
"status": ExportStatus.failed,
"error": "Export render failed.",
"error_code": "EXPORT_RENDER_FAILED",
"completed_at": _now(),
}
)
finally:
_cancel_flags.pop(job_id, None)
def list_exports(
status: ExportStatus | None = None,
format: ExportFormat | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[ExportJob], int]:
jobs = list(_jobs.values())
if status is not None:
jobs = [j for j in jobs if j.status == status]
if format is not None:
jobs = [j for j in jobs if j.format == format]
jobs.sort(key=lambda j: j.created_at, reverse=True)
total = len(jobs)
return jobs[offset : offset + limit], total
def get_export(job_id: str) -> ExportJob | None:
return _jobs.get(job_id)
def cancel_export(job_id: str) -> ExportJob | None:
"""取消导出:仅 queued/running 可取消,后台 Task 在让出边界标记 cancelled。"""
job = _jobs.get(job_id)
if job is None:
return None
if job.status in (ExportStatus.queued, ExportStatus.running):
_cancel_flags[job_id].set()
return job
def get_export_file(job_id: str) -> Path:
"""返回可下载产物的存储路径;未完成返回 404、过期返回 410。"""
job = _jobs.get(job_id)
if job is None:
raise ApiError(404, "EXPORT_JOB_NOT_FOUND", "export job not found", {"job_id": job_id})
if job.status != ExportStatus.completed or job.file is None:
raise ApiError(
404, "EXPORT_JOB_NOT_FOUND", "export file not ready", {"job_id": job_id}
)
if job.file.expires_at <= _now():
raise ApiError(410, "EXPORT_FILE_EXPIRED", "export file has expired", {"job_id": job_id})
return get_settings().exports_path / f"{job_id}.html"
async def wait_for_export(job_id: str) -> ExportJob | None:
"""等待后台任务结束(测试/轮询用);无任务时直接返回当前状态。"""
task = _tasks.get(job_id)
if task is not None:
await task
return _jobs.get(job_id)
+85 -1
View File
@@ -5,7 +5,7 @@ from datetime import datetime, timezone
from uuid import uuid4
from fastapi import APIRouter, Header, Query
from fastapi.responses import StreamingResponse
from fastapi.responses import FileResponse, StreamingResponse
from app.agent import AgentCapacityError, AgentRunNotFoundError
from app.container import container
@@ -48,6 +48,11 @@ from app.contracts import (
ModelRoutingResponse,
SpeakerMatchRequest,
SpeakerMatchResult,
ExportFormat,
ExportJob,
ExportJobListResponse,
ExportRequest,
ExportStatus,
Note,
NoteCreateRequest,
NoteListResponse,
@@ -96,8 +101,10 @@ from app.contracts import (
from app.agent import AgentCapacityError, AgentRunNotFoundError
from app.benchmarks import datasets as benchmark_datasets
from app.benchmarks import service as benchmark_service
from app.config import get_settings
from app.container import container
from app.errors import ApiError
from app.export import service as export_service
from app.extensions import ExtensionError
from app.extensions.mcp_registry import McpRegistryError
from app.providers.base import ProviderError
@@ -1310,3 +1317,80 @@ async def get_benchmark_report(run_id: str) -> BenchmarkReport:
404, "BENCHMARK_RUN_NOT_FOUND", "benchmark report not found", {"run_id": run_id}
)
return report
@router.post(
"/exports",
response_model=ExportJob,
status_code=202,
tags=["Export"],
)
async def create_export(request: ExportRequest) -> ExportJob:
return await export_service.create_export(request)
@router.get(
"/exports",
response_model=ExportJobListResponse,
tags=["Export"],
)
async def list_exports(
status: ExportStatus | None = Query(default=None),
format: ExportFormat | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> ExportJobListResponse:
items, total = export_service.list_exports(
status=status, format=format, limit=limit, offset=offset
)
return ExportJobListResponse(
items=items, page=PageMeta(total=total, limit=limit, offset=offset)
)
@router.get(
"/exports/{job_id}",
response_model=ExportJob,
tags=["Export"],
)
async def get_export(job_id: str) -> ExportJob:
job = export_service.get_export(job_id)
if job is None:
raise ApiError(
404, "EXPORT_JOB_NOT_FOUND", "export job not found", {"job_id": job_id}
)
return job
@router.get(
"/exports/{job_id}/file",
tags=["Export"],
)
async def get_export_file(job_id: str) -> FileResponse:
path = export_service.get_export_file(job_id) # 未完成/过期分别抛 404/410
job = export_service.get_export(job_id)
if job is None or job.file is None:
raise ApiError(
404, "EXPORT_JOB_NOT_FOUND", "export file not ready", {"job_id": job_id}
)
return FileResponse(
path=path,
media_type=job.file.mime_type,
filename=job.file.file_name,
)
@router.post(
"/exports/{job_id}/cancel",
response_model=OperationResponse,
tags=["Export"],
)
async def cancel_export(job_id: str) -> OperationResponse:
job = export_service.cancel_export(job_id)
if job is None:
raise ApiError(
404, "EXPORT_JOB_NOT_FOUND", "export job not found", {"job_id": job_id}
)
return OperationResponse(
status="accepted", resource_id=job_id, message="Export cancellation accepted."
)
+1
View File
@@ -9,6 +9,7 @@ dependencies = [
"fastapi>=0.116,<1.0",
"httpx>=0.28,<1.0",
"jsonschema>=4.25,<5.0",
"mistune>=3.0,<4.0",
"pyyaml>=6.0,<7.0",
"referencing>=0.36,<1.0",
"sqlite-vec>=0.1.9",
+303
View File
@@ -0,0 +1,303 @@
"""Export Service 的单元与端到端测试。
沿用 conftest 隔离机制:APP_DATA_DIR / DB / Vault / exports 目录都落在临时目录,
不读写真实数据。导出采用「创建即 queued + 后台 Task 执行」的异步模型,测试在同一
事件循环内创建并等待后台任务结束,得到终态 ExportJob 后再断言。
"""
from __future__ import annotations
import asyncio
from datetime import datetime, timedelta, timezone
import pytest
from pydantic import ValidationError
from app.config import get_settings
from app.contracts import (
ExportFormat,
ExportOptions,
ExportRequest,
ExportSource,
ExportSourceType,
ExportStatus,
)
from app.errors import ApiError
from app.export import service as export_service
from app.export.exporters.html import HtmlExporter
from app.export.markdown import parse_document
MD = """# 进程调度
一些 **加粗** 和 *斜体*[链接](https://a.b) 与 `code`。
- 项目一
- 项目二
```python
print(1)
```
```mermaid
graph LR
```
```function_plot
y = x
```
| a | b |
|---|---|
| 1 | 2 |
行内 $x^2$ 与块级
$$
y = mx + b
$$
"""
@pytest.fixture(autouse=True)
def _reset_export_state():
"""清空内存注册表,避免跨用例的任务/取消标志互相污染。"""
export_service._jobs.clear()
export_service._tasks.clear()
export_service._cancel_flags.clear()
yield
export_service._jobs.clear()
export_service._tasks.clear()
export_service._cancel_flags.clear()
def _create_and_wait(request: ExportRequest) -> object:
"""创建导出并在同一事件循环内等待后台任务结束,返回终态 ExportJob。"""
async def _execute():
job = await export_service.create_export(request)
return await export_service.wait_for_export(job.job_id)
return asyncio.run(_execute())
# --------------------------------------------------------------------------- #
# markdown → Document AST
# --------------------------------------------------------------------------- #
def _types(nodes) -> list[str]:
return [n.type for n in nodes]
def test_parse_document_heading_and_inline() -> None:
doc = parse_document("# 标题\n\n一段 **加粗** 和 [链接](https://a.b)。")
assert doc.type == "document"
heading = doc.children[0]
assert heading.type == "heading"
assert heading.attributes["level"] == 1
para = doc.children[1]
assert para.type == "paragraph"
kinds = _types(para.children)
assert "text" in kinds
assert "strong" in kinds
assert "link" in kinds
link = next(c for c in para.children if c.type == "link")
assert link.attributes["href"] == "https://a.b"
def test_parse_document_list_and_code_fencing() -> None:
doc = parse_document("- a\n- b\n\n```mermaid\ngraph LR\n```\n\n```function_plot\ny=x\n```\n\n```python\nx\n```")
kinds = [c.type for c in doc.children]
assert kinds[0] == "list"
assert kinds[1] == "mermaid"
assert kinds[2] == "function_plot"
assert kinds[3] == "code_block"
code = doc.children[3]
assert code.attributes["language"] == "python"
assert code.text == "x"
def test_parse_document_table_and_math() -> None:
doc = parse_document("| a | b |\n|---|---|\n| 1 | 2 |\n\n$x^2$\n\n$$\ny=mx\n$$")
table = doc.children[0]
assert table.type == "table"
assert table.children[0].type == "table_row"
assert table.children[0].children[0].attributes["head"] is True
# 表格后是「行内数学所在段落」与「块级数学」
kinds = [c.type for c in doc.children[1:]]
assert "paragraph" in kinds
assert "math_block" in kinds
# --------------------------------------------------------------------------- #
# HtmlExporter
# --------------------------------------------------------------------------- #
async def _render(markdown: str, *, title: str = "") -> str:
doc = parse_document(markdown)
doc.attributes["title"] = title
result = await HtmlExporter().export(doc, ExportOptions())
return result.content.decode("utf-8")
def test_html_exporter_renders_basic_nodes_and_escapes() -> None:
html = asyncio.run(_render("# 标题\n\n**加粗** [链接](https://a.b) 与 <b>原始</b>。"))
assert "<h1>标题</h1>" in html
assert "<strong>加粗</strong>" in html
assert '<a href="https://a.b">链接</a>' in html
# 原始 HTML 必须被转义,不能注入文档
assert "&lt;b&gt;原始&lt;/b&gt;" in html
assert "<b>原始</b>" not in html
def test_html_exporter_marks_mermaid_and_function_plot() -> None:
result = asyncio.run(HtmlExporter().export(parse_document("```mermaid\ngraph LR\n```"), ExportOptions()))
html = result.content.decode("utf-8")
assert '<pre class="mermaid">graph LR</pre>' in html
assert any("mermaid" in w for w in result.warnings)
def test_html_exporter_include_title_and_metadata() -> None:
doc = parse_document("正文")
doc.attributes["title"] = "操作系统复习"
doc.attributes["metadata"] = {"tags": ["os", "复习"]}
opts = ExportOptions(include_title=True, include_metadata=True)
result = asyncio.run(HtmlExporter().export(doc, opts))
html = result.content.decode("utf-8")
assert '<h1 class="title">操作系统复习</h1>' in html
assert "os, 复习" in html
# --------------------------------------------------------------------------- #
# ExportService
# --------------------------------------------------------------------------- #
def _markdown_request(markdown: str, *, format: ExportFormat = ExportFormat.html) -> ExportRequest:
return ExportRequest(
source=ExportSource(type=ExportSourceType.markdown, markdown=markdown),
format=format,
)
def test_export_markdown_source_completes_and_writes_file() -> None:
finished = _create_and_wait(_markdown_request(MD))
assert finished.status == ExportStatus.completed
assert finished.file is not None
assert finished.file.mime_type == "text/html"
assert finished.file.size > 0
assert len(finished.file.sha256) == 64
path = get_settings().exports_path / f"{finished.job_id}.html"
assert path.exists()
content = path.read_text(encoding="utf-8")
assert "进程调度" in content
def test_export_note_source_resolves_title_and_metadata() -> None:
from app.services import note_service
async def _go():
note = await note_service.create_note(
title="操作系统复习", markdown="# 进程调度\n\n内容。", folder="导出", tags=["os"]
)
request = ExportRequest(
source=ExportSource(type=ExportSourceType.note, note_id=note.note_id),
format=ExportFormat.html,
options=ExportOptions(include_metadata=True),
)
job = await export_service.create_export(request)
return await export_service.wait_for_export(job.job_id)
finished = asyncio.run(_go())
assert finished.status == ExportStatus.completed
assert finished.file is not None
assert finished.file.file_name == "操作系统复习.html"
content = (get_settings().exports_path / f"{finished.job_id}.html").read_text(encoding="utf-8")
assert "操作系统复习" in content
assert "进程调度" in content
def test_export_pdf_unsupported() -> None:
with pytest.raises(ApiError) as exc:
asyncio.run(
export_service.create_export(_markdown_request("# x", format=ExportFormat.pdf))
)
assert exc.value.status_code == 400
assert exc.value.code == "EXPORT_FORMAT_UNSUPPORTED"
def test_export_unknown_note_404() -> None:
request = ExportRequest(
source=ExportSource(type=ExportSourceType.note, note_id="note_missing"),
format=ExportFormat.html,
)
with pytest.raises(ApiError) as exc:
asyncio.run(export_service.create_export(request))
assert exc.value.status_code == 404
assert exc.value.code == "EXPORT_SOURCE_NOT_FOUND"
def test_export_empty_markdown_invalid() -> None:
with pytest.raises(ApiError) as exc:
asyncio.run(export_service.create_export(_markdown_request(" ")))
assert exc.value.status_code == 400
assert exc.value.code == "EXPORT_OPTIONS_INVALID"
def test_export_cancel_queued_job() -> None:
async def _go():
job = await export_service.create_export(_markdown_request("# x"))
cancelled = export_service.cancel_export(job.job_id)
assert cancelled is not None
return await export_service.wait_for_export(job.job_id)
finished = asyncio.run(_go())
assert finished.status == ExportStatus.cancelled
assert finished.file is None
def test_export_file_expired_410() -> None:
async def _go():
job = await export_service.create_export(_markdown_request("# x"))
finished = await export_service.wait_for_export(job.job_id)
past = datetime.now(timezone.utc) - timedelta(hours=1)
export_service._jobs[job.job_id] = finished.model_copy(
update={"file": finished.file.model_copy(update={"expires_at": past})}
)
return job.job_id
job_id = asyncio.run(_go())
with pytest.raises(ApiError) as exc:
export_service.get_export_file(job_id)
assert exc.value.status_code == 410
assert exc.value.code == "EXPORT_FILE_EXPIRED"
def test_export_list_and_get() -> None:
finished = _create_and_wait(_markdown_request("# 列表测试"))
items, total = export_service.list_exports(limit=50, offset=0)
assert total == 1
assert items[0].job_id == finished.job_id
got = export_service.get_export(finished.job_id)
assert got is not None and got.status == ExportStatus.completed
assert export_service.get_export("export_missing") is None
# --------------------------------------------------------------------------- #
# 契约校验
# --------------------------------------------------------------------------- #
def test_export_source_requires_matching_field() -> None:
with pytest.raises(ValidationError):
ExportSource(type=ExportSourceType.note, note_id=None)
with pytest.raises(ValidationError):
ExportSource(type=ExportSourceType.markdown, markdown=None)
+11
View File
@@ -364,6 +364,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
]
[[package]]
name = "mistune"
version = "3.3.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7b/92/328a294a6de83bacb95bed01f04e0eaff4e3616ee359fc821a5dfc539b02/mistune-3.3.4.tar.gz", hash = "sha256:58b5c96d6fcb61190dfe5fae498d2b2065f99cf61e9649418fd54cf1ada86dfe", size = 121426, upload-time = "2026-07-22T05:22:30.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/77/e4/288365afae98953bc01de09f686f40d8ee84578135aa7767d5d4e60b5278/mistune-3.3.4-py3-none-any.whl", hash = "sha256:ee015381e955e370962968befe1d729ab60fafb6a715ac6751763fbce38c8d4a", size = 66862, upload-time = "2026-07-22T05:22:29.419Z" },
]
[[package]]
name = "notes-agent-backend"
version = "0.1.0"
@@ -373,6 +382,7 @@ dependencies = [
{ name = "fastapi" },
{ name = "httpx" },
{ name = "jsonschema" },
{ name = "mistune" },
{ name = "pyyaml" },
{ name = "referencing" },
{ name = "sqlite-vec" },
@@ -390,6 +400,7 @@ requires-dist = [
{ name = "fastapi", specifier = ">=0.116,<1.0" },
{ name = "httpx", specifier = ">=0.28,<1.0" },
{ name = "jsonschema", specifier = ">=4.25,<5.0" },
{ name = "mistune", specifier = ">=3.0,<4.0" },
{ name = "pyyaml", specifier = ">=6.0,<7.0" },
{ name = "referencing", specifier = ">=0.36,<1.0" },
{ name = "sqlite-vec", specifier = ">=0.1.9" },
+1
View File
@@ -31,6 +31,7 @@
- [AI Core 与 Agent Core 开发说明](development/AI-Core与Agent-Core开发说明.md)
- [Knowledge 与 Retrieval Core 开发说明](development/Knowledge与Retrieval-Core开发说明.md)
- [Benchmark 开发说明](development/Benchmark开发说明.md)
- [Export 开发说明](development/Export开发说明.md)
- [模型提供商与模型发现开发说明](development/模型提供商与模型发现开发说明.md)
- [MCP Bridge 与 Plugin Host 开发说明](development/MCP-Bridge与Plugin-Host开发说明.md)
- [独立 MCP Server 配置中心开发说明](development/独立MCP-Server配置中心开发说明.md)
@@ -2354,7 +2354,7 @@ Quality
└── Retrieval 参数调优
Content Output
├── Markdown → HTML / PDF / DOCX
├── Markdown → HTML(已实现)/ PDF / DOCX(暂缓)
├── Mermaid 编辑、预览与静态导出
└── Function Plot 解析、预览与静态导出
@@ -2365,7 +2365,7 @@ Frontend Extension
└── Plugin Settings UI
```
上述列表描述第二阶段技术范围,其中 stdio MCP Bridge、Plugin Command ContributionPlugin Settings Contribution 后端 Contract 已实现,其余能力以各自开发说明的状态为准。每项功能必须继续经过现有 Service、Contract、Permission 和 Adapter 边界,不因 Demo 需要在 Vue 组件、Router 或 Agent Runtime 中直接绑定第三方协议。
上述列表描述第二阶段技术范围,其中 stdio MCP Bridge、Plugin Command ContributionPlugin Settings Contribution 后端 Contract 与 Markdown → HTML 导出已实现,其余能力以各自开发说明的状态为准。每项功能必须继续经过现有 Service、Contract、Permission 和 Adapter 边界,不因 Demo 需要在 Vue 组件、Router 或 Agent Runtime 中直接绑定第三方协议。
第三阶段处理:
@@ -66,11 +66,11 @@
| Benchmark | POST | `/api/benchmarks/agent/runs` | 暂缓 | 创建 Agent Benchmark(依赖 Agent Runtime 完成后交付) |
| Benchmark | GET | `/api/benchmarks/runs` | 已实现 | 分页获取 Benchmark Run |
| Benchmark | GET/POST | `/api/benchmarks/runs/{run_id}/*` | 计划新增 | 查询、订阅、取消和读取报告 |
| Export | POST | `/api/exports` | 计划新增 | 创建 HTML/PDF/DOCX 导出任务 |
| Export | GET | `/api/exports` | 计划新增 | 分页获取导出任务 |
| Export | GET | `/api/exports/{job_id}` | 计划新增 | 查询导出任务 |
| Export | GET | `/api/exports/{job_id}/file` | 计划新增 | 下载已完成产物 |
| Export | POST | `/api/exports/{job_id}/cancel` | 计划新增 | 取消导出任务 |
| Export | POST | `/api/exports` | 已实现(HTML | 创建导出任务;`pdf`/`docx` 暂缓,返回 `EXPORT_FORMAT_UNSUPPORTED` |
| Export | GET | `/api/exports` | 已实现(HTML | 分页获取导出任务 |
| Export | GET | `/api/exports/{job_id}` | 已实现(HTML | 查询导出任务 |
| Export | GET | `/api/exports/{job_id}/file` | 已实现(HTML | 下载已完成产物 |
| Export | POST | `/api/exports/{job_id}/cancel` | 已实现(HTML | 取消导出任务 |
| Theme | Host Contract | `ThemePackageService` | 计划新增 | 导入、预览、启停和卸载主题包 |
| Renderer | 内部 Contract | `StaticRenderer` | 计划新增 | Mermaid/Function Plot 预览和导出复用 |
@@ -1078,6 +1078,8 @@ VECTOR_INDEX_REBUILD_REQUIRED
## 10. Export Service
> 实现状态:HTML 导出已实现(`backend/app/export/`),`pdf`/`docx` 暂缓——请求这两个格式返回 `EXPORT_FORMAT_UNSUPPORTED`。函数图像与 Mermaid 在 HTML 中以占位代码块保留并记 warning,静态渲染由 §10.4 的 Render Contract 在后续 PR 补齐。
### 10.1 创建导出任务
`POST /api/exports`,返回 `202 ExportJob`
@@ -1088,7 +1090,7 @@ VECTOR_INDEX_REBUILD_REQUIRED
"type": "note",
"note_id": "note_123"
},
"format": "pdf",
"format": "html",
"options": {
"theme_id": "light",
"include_title": true,
@@ -1099,7 +1101,7 @@ VECTOR_INDEX_REBUILD_REQUIRED
}
```
`source.type` 首批支持 `note``markdown``markdown` 来源用于尚未保存的预览,字段大小受限且不持久化到 Trace。`format` 固定为 `html``pdf``docx`
`source.type` 首批支持 `note``markdown``note` 来源通过 `source.note_id` 引用已建索引笔记;`markdown` 来源用于尚未保存的预览,内容放在 `source.markdown` 字段大小限制为 200 000 字符、不持久化到 Trace。`format` 可取 `html``pdf``docx`,但当前仅 `html` 已实现,`pdf`/`docx` 返回 `EXPORT_FORMAT_UNSUPPORTED`
响应:
@@ -1107,11 +1109,12 @@ VECTOR_INDEX_REBUILD_REQUIRED
{
"job_id": "export_123",
"status": "queued",
"format": "pdf",
"format": "html",
"progress": null,
"file": null,
"warnings": [],
"error": null,
"error_code": null,
"created_at": "2026-08-31T10:30:00Z",
"started_at": null,
"completed_at": null
@@ -1127,14 +1130,14 @@ VECTOR_INDEX_REBUILD_REQUIRED
| POST | `/api/exports/{job_id}/cancel` | `OperationResponse` |
| GET | `/api/exports/{job_id}/file` | 文件流 |
下载响应设置正确 `Content-Type`、经过清理的 `Content-Disposition` 文件名和 `Content-Length`。未完成、失败或过期 Job 不返回空文件。
下载响应设置正确 `Content-Type`、经过清理的 `Content-Disposition` 文件名和 `Content-Length`。未完成、失败或过期 Job 不返回空文件:未完成/失败返回 `EXPORT_JOB_NOT_FOUND`404),产物过期(超过 `expires_at`)返回 `EXPORT_FILE_EXPIRED`410
完成 Job 的 file
```json
{
"file_name": "操作系统复习.pdf",
"mime_type": "application/pdf",
"file_name": "操作系统复习.html",
"mime_type": "text/html",
"size": 1048576,
"sha256": "...",
"expires_at": "2026-09-01T10:30:00Z"
+85
View File
@@ -0,0 +1,85 @@
# Export 开发说明
> 所属模块:Export Service(后端,负责人 yxx)。本次交付「多格式文档导出」第一步:Markdown → HTML 的完整生命周期;PDF/DOCX 与函数图像静态渲染在后续 PR 补齐。契约对应 [第二阶段接口契约 §10](../contracts/第二阶段接口契约-开发版.md)。
## 定位
Export Service 把笔记或未保存的 Markdown 文本渲染为可下载的 HTML 文件。采用与 Benchmark 一致的「创建即返回 queued、后台 asyncio.Task 执行」的内存模型,产物带 24h 过期时间,过期后不可下载。导出是轮询式(无 SSE 事件流),客户端通过 `GET /api/exports/{job_id}` 轮询状态,完成后走 `GET /api/exports/{job_id}/file` 下载。
## 模块布局
```text
backend/app/export/
├── __init__.py 包说明
├── document.py Document AST 内部协议 + DocumentExporter Protocol + ExportResult
├── markdown.py mistune 'ast' renderer → Document AST
├── exporters/
│ ├── __init__.py
│ └── html.py HtmlExporterDocument AST → 完整 HTML5
└── service.py ExportService(注册表 + 后台渲染 + 取消 + 产物生命周期)
```
HTTP DTO`ExportStatus` / `ExportFormat` / `ExportSource` / `ExportOptions` / `ExportJob` 等)放在 [app/contracts.py](../../backend/app/contracts.py),与 Benchmark DTO 同层;`DocumentNode` / `ExportResult` 属导出器内部协议,放在 `export/document.py`,不进入 HTTP 契约。
## 接口
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| POST | `/api/exports` | 创建导出任务(202 |
| GET | `/api/exports?status=&format=&limit=&offset=` | 分页获取任务 |
| GET | `/api/exports/{job_id}` | 查询任务状态 |
| GET | `/api/exports/{job_id}/file` | 下载已完成产物 |
| POST | `/api/exports/{job_id}/cancel` | 取消任务 |
`source.type` 支持 `note`(引用已建索引笔记)与 `markdown`(未保存预览,字段为 `source.markdown`,上限 200 000 字符)。当前仅 `format=html` 实现,`pdf`/`docx` 返回 `EXPORT_FORMAT_UNSUPPORTED`
## Markdown → Document AST
解析用 [mistune](https://github.com/lepture/mistune) 的内置 `renderer="ast"`(非自写 `BaseRenderer`),因为 mistune 的行内渲染按字符串拼接、无法承载结构化子节点;ast renderer 直接给出带 `children`/`attrs`/`raw` 的 token 树,`_AstMapper` 只做 token → `DocumentNode` 的搬运,不掺入任何 HTML。插件启用 `table``math``url``task_lists`
fenced code 按语言分流:`mermaid``mermaid` 节点、`function_plot`/`functionplot``function_plot` 节点,其余 → `code_block``attributes.language`)。`node_id` 按遍历顺序 `node_{seq:03d}` 生成,仅渲染内部使用,无需跨请求稳定。
## HtmlExporter
递归渲染 Document AST 为完整 HTML5 文档(`<!doctype html>` + `<head>` 内嵌基础 CSS + `<body>`),标题/正文/元信息文本一律 `html.escape``mermaid``function_plot` 无法静态表达,渲染为占位 `<pre class="mermaid">`/`<pre class="function-plot">` 并记 warning,不静默丢失;`code_theme` 仅作为代码容器 class,不引入 JS 高亮库。无法表示的节点统一 `warnings.append(...)` 跳过。
## 运行生命周期
`queued → running → completed | failed | cancelled`
- 创建时校验:`format` 非 html → `EXPORT_FORMAT_UNSUPPORTED``note` 源不存在 → `EXPORT_SOURCE_NOT_FOUND`404);`markdown` 源为空或超上限 → `EXPORT_OPTIONS_INVALID`
- 内存注册表上限 `MAX_JOBS=100`,超限只淘汰终态任务;满容量且全为活动任务时返回 `EXPORT_CAPACITY_EXCEEDED`429)。
- 后台渲染在解析前后各让出一次执行权,使「创建后立即取消」的 queued 任务能及时进入 cancelled。
- 失败只向公开响应暴露项目错误码与安全消息,详细异常进入日志。
## 产物生命周期
产物写入 `settings.exports_path`(默认 `backend/data/exports/`,可通过 `APP_EXPORTS_PATH` 覆盖,已加入 `.gitignore`),文件名为 `{job_id}.html`,下载 `Content-Disposition``_safe_download_name` 清洗标题得到。`ExportFile` 记录 `sha256``size``expires_at``completed_at + 24h`),过期返回 `EXPORT_FILE_EXPIRED`410)。
## 错误码
```text
EXPORT_SOURCE_NOT_FOUND 404
EXPORT_FORMAT_UNSUPPORTED 400
EXPORT_OPTIONS_INVALID 400
EXPORT_RENDER_FAILED 500
EXPORT_UNSUPPORTED_CONTENT 422
EXPORT_JOB_NOT_FOUND 404
EXPORT_FILE_EXPIRED 410
EXPORT_CAPACITY_EXCEEDED 429
```
## 测试
```powershell
cd backend
uv run pytest -q
```
`tests/test_export.py` 覆盖 Markdown 解析(标题/行内/列表/代码分流/表格/数学)、HTML 渲染(标签 + 转义 + warning)、Service 端到端(note 源与 markdown 源、pdf 拒绝、未知 note、取消、list/get、过期 410)与 `ExportSource` 契约校验。
## 范围外(后续 PR
- PDF / DOCX 导出(`python-docx` 等底层库在 PoC 后冻结,封装在 Exporter Adapter 内)。
- 函数图像绘制(FunctionPlot 结构化模型 + 白名单表达式解析器 + SVG 静态渲染,契约 §10.4/§12)。
- 代码语法高亮(当前仅 CSS class 占位)。