Compare commits

...
Author SHA1 Message Date
yxxandClaude Code c9c5f81d49 fix(export): 增加文档级组合复杂度预算与并发渲染限制
针对 PR 审阅 P1「组合复杂度仍可长时间占满导出线程」与 P3「EXPORT_OUTPUT_TOO_LARGE 误标 HTTP 413」:

- plot: FunctionPlot 记录整块 AST 节点数(node_count),parser 累计
- html: 单篇文档累计节点预算 _MAX_TOTAL_PLOT_NODES=8000,超限回退占位
- service: 并发渲染信号量 MAX_CONCURRENT_RENDERS=2,超限额任务排队等待
- docs: 错误码区分同步 HTTP 错误与异步任务错误,EXPORT_OUTPUT_TOO_LARGE 由
  error_code 返回而非 HTTP 413
- 补充节点预算与并发限制两条回归测试(全量 627 通过)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-06 14:01:29 +08:00
yxxandClaude Code 124024a547 fix(export): 为函数图像与导出产物增加资源上限
针对 PR 审阅「函数数量没有限制,可能生成数百 MB 的 SVG」:

- parser: 单块 function-plot 表达式上限 _MAX_EXPRESSIONS=16,超限整块回退
- html: 单篇文档函数图像上限 _MAX_FUNCTION_PLOTS=16,超出回退源码占位
- service: 输入源 MAX_MARKDOWN_CHARS、产物 MAX_EXPORT_BYTES,超限分别
  拒绝创建或标记 failed(EXPORT_OUTPUT_TOO_LARGE)
- 补充 4 条回归测试与文档说明

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-05 23:31:53 +08:00
yxxandClaude Code b87f94551b fix(plot): 修复复审问题(2 P2 + 1 P3)
- P2 复杂表达式绕过异常回退:解析与渲染共同纳入局部异常回退;
  AST 深度/节点数上限拦截 RecursionError
- P2 极端有限范围生成 nan SVG:校验坐标跨度有限且 >0,回退安全范围;
  _polyline 拒绝非有限像素坐标
- P3 更新接口契约文档:function-plot 静态 SVG 已实现

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-05 22:49:01 +08:00
yxxandClaude Code 50d7fb4c7d Merge origin/main into feat/export-service
同步 main(054f704),解决 contracts.py / main.py / README.md / 技术栈说明 的合并冲突。
- contracts.py:保留 pydantic 多行导入并新增 RequestOverride
- main.py:合并 lifespan(导出孤儿清理 + 转写/本地模型生命周期)
- README.md / 技术栈说明:文档取 main 最新版本

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-05 21:44:20 +08:00
yxxandClaude Code 04f36524b1 fix(plot): 修复函数图像审阅问题(1 P1 + 2 P2)
- P1 浮点刻度死循环:_ticks 改为有上限的整数索引推进并校验步长推进
- P2 求值异常:白名单函数校验参数数量;负数底非整数指数按断点处理;采样容错复数
- P2 无效纵轴范围:退化/非有限 range 丢弃并自动采样重算;渲染异常回退占位不阻断导出
- 补 6 个回归测试

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-05 21:14:50 +08:00
Kronecker 054f704c8b Merge pull request 'feat: 完善主题导入、手账工作区与笔记元数据管理' (#27) from feat/theme-import-and-paper-workspace into main
Reviewed-on: #27
2026-09-05 19:42:00 +08:00
admin 02dd585a4e fix: 修复笔记 YAML 标签保存与索引重建一致性 2026-09-05 19:40:14 +08:00
admin 8692910508 fix(frontend): 修复纸页刷新宽度并完善侧栏和图表主题 2026-09-05 19:23:21 +08:00
admin a63f6c57e0 feat(frontend): 完善工作区导航与笔记属性并适配手帐主题 2026-09-05 19:11:45 +08:00
admin d5b1050a86 feat(frontend): 完善主题导入与手帐工作区并修复 Mermaid 预览 2026-09-05 18:42:14 +08:00
Kronecker 311ea4a8ac Merge pull request 'Feat/frontend phase2 themes trace mermaid' (#26) from feat/frontend-phase2-themes-trace-mermaid into main
Reviewed-on: #26
2026-09-05 17:47:08 +08:00
yxxandClaude Code f49d1245a1 feat(export): 函数图像静态渲染(function-plot → SVG)
- 新增 app/plot 包:白名单表达式解析(ast 无 eval)+ FunctionPlot 模型 + 静态 SVG 渲染
- HtmlExporter 的 function_plot 节点解析并内嵌 SVG,解析失败回退占位并转诊断
- 新增 test_plot.py(13 个测试)覆盖表达式安全、指令解析、SVG 输出与导出链路集成

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 22:41:35 +08:00
yxxandClaude Code 64af1f5165 fix(export): 修复 PR #17 审阅问题(1 P1 + 5 P2)
- P1 链接/图片 URL 协议白名单校验,危险协议降级为纯文本 + warning
- P2 图片 AST 字段映射(src=attrs.url,alt 取 children 文本)
- P2 原始 HTML 块转义保留,正文不丢失 + warning
- P2 过期/淘汰/重启清理导出产物文件
- P2 解析与渲染移入 asyncio.to_thread,运行中取消生效
- P2 function-plot 围栏别名补全
- 回归测试覆盖全部修复

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 22:24:16 +08:00
yxx 7eae7fba00 Merge remote-tracking branch 'origin/main' into feat/export-service 2026-09-04 10:59:52 +08:00
yxx 5c2441464d feat(export): 交付 Markdown → HTML 导出服务
实现 Export Service 完整生命周期:mistune AST → Document AST → HtmlExporter 渲染完整 HTML5,异步任务注册表 + 取消 + 24h 产物过期。新增 5 个 /api/exports 端点与 15 项测试;pdf/docx 与函数图像静态渲染留待后续 PR。
2026-09-04 09:02:33 +08:00
57 changed files with 4238 additions and 194 deletions
+2
View File
@@ -18,6 +18,8 @@ backend/.env
# 运行期生成的 SQLite 索引(vault 下的 Markdown 测试数据需提交)
backend/data/*.db*
backend/data/credentials/
# 运行期导出的 HTML/PDF/DOCX 产物(不提交)
backend/data/exports/
# 阶段验收笔记(验收用,不提交)
backend/data/vault/验收/
# 本机 MCP 配置、授权状态及服务器工作目录不得提交。
+39
View File
@@ -134,3 +134,42 @@ pnpm build
- 前端不直接访问 SQLite 或厂商模型协议;持久数据通过 FastAPI 服务读写。
- 接口或数据结构变化时,同一提交同步更新前后端类型、契约和开发说明。
- 当前行为以代码、测试和运行中的 `/openapi.json` 为准;规划能力必须在文档中明确标注。
## 主题包与仓库发布(临时规范)
主题页支持本地文件及 HTTP(S) 文件直链导入。两种入口均先解析、校验并展示清单和 CSS,用户点击安装后才写入本地存储。安装不会自动启用主题。
### 单文件
使用 UTF-8 编码,扩展名 `.theme``.yaml``.yml`。内容为 YAML 清单、一行 `---`、完整 CSS。可参考 `frontend/src/assets/themes/paper-moments.theme`
### ZIP
一个 ZIP 只包含一个主题。清单命名为 `theme.yaml``theme.yml``manifest.yaml``manifest.yml`,可以放在顶层,也可以放在仓库压缩包的子目录中。
```text
my-theme/
theme.yaml
styles/
theme.css
```
```yaml
theme_id: my-theme
name: My Theme
version: 1.0.0
author: your-name
min_app_version: 0.2.0
is_dark: false
css_entry: styles/theme.css
```
`css_entry` 相对于清单目录解析,不允许绝对路径、反斜杠及 `..`。CSS 应以 `[data-theme="my-theme"]` 限定主题样式。也支持仅包含一个 `.theme` 文件的 ZIP。
目前安装持久化的是清单和 CSS,不会托管 ZIP 内的图片、字体等资源;需要这些资源时请将它们内嵌为 CSS data URL。禁止 `@import` 和脚本表达式。
### URL 与社区仓库
发布主题仓库时可提供原始 `.theme` 文件链接或 ZIP 发布附件直链,不要使用仓库 HTML 浏览页面地址。下载请求不携带 Cookie 或 HTTP 登录信息,服务器需允许应用来源的 CORS 请求;暂不支持私有仓库认证。
下载和本地文件限制为 5 MB;ZIP 解压总大小限制为 10 MB,最多 100 个条目。URL 下载超时为 30 秒。取消导入会取消下载,过期请求不会替换当前待安装主题。更新时递增清单版本号,并保持 `theme_id` 稳定。
+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, model_validator
from pydantic import (
BaseModel,
ConfigDict,
Field,
SecretStr,
field_validator,
model_validator,
)
from app.request_overrides import RequestOverride
@@ -1273,3 +1280,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 → 具体格式产物。"""
+296
View File
@@ -0,0 +1,296 @@
"""HtmlExporterDocument AST → 完整 HTML5 文档(内嵌基础 CSS)。
mermaid 等无法静态表达的节点渲染为占位代码块并记 warning,不静默丢失;function_plot
解析为静态 SVG 内嵌(解析失败回退占位并转诊断);严重内容缺失由 service 层以
EXPORT_UNSUPPORTED_CONTENT 判定,本层只负责逐节点渲染。
"""
from __future__ import annotations
import html
from datetime import datetime
from urllib.parse import urlparse
from app.contracts import ExportOptions
from app.export.document import Document, DocumentNode, ExportResult
from app.plot.parser import parse_source
from app.plot.render import render_svg
_MERMAID_WARNING = "mermaid 需前端渲染,已保留为占位代码块"
_RAW_HTML_WARNING = "原始 HTML 已按纯文本转义保留"
# 链接/图片地址允许的协议;无 scheme 的相对地址视为安全,其余协议一律降级
_ALLOWED_URL_SCHEMES = frozenset({"http", "https", "mailto"})
# 单篇文档允许的函数图像数量上限,超出部分回退占位,防止多图块并发采样耗尽内存/线程
_MAX_FUNCTION_PLOTS = 16
# 单篇文档允许的函数图像累计 AST 节点预算,超出部分回退占位,防止组合复杂度(多图块
# × 多表达式 × 深表达式)在采样求值时长时间占满 CPU
_MAX_TOTAL_PLOT_NODES = 8000
def _safe_url(url: str) -> str | None:
"""校验 URL 协议;安全返回原串,不安全返回 None。"""
url = url.strip()
if not url:
return None
scheme = urlparse(url).scheme.lower()
if scheme and scheme not in _ALLOWED_URL_SCHEMES:
return None
return url
_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; }
figure.function-plot { margin: 1em 0; text-align: center; }
figure.function-plot svg { max-width: 100%; height: auto; }
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 文档。"""
def render(self, document: Document, options: ExportOptions) -> ExportResult:
"""同步渲染;CPU 密集,调用方应放入线程执行,避免阻塞事件循环。"""
self._options = options
self._plot_count = 0
self._plot_nodes = 0
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
)
async def export(self, document: Document, options: ExportOptions) -> ExportResult:
"""契约要求的 async 接口;渲染本身同步,直接转发到 render。"""
return self.render(document, options)
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>'
@staticmethod
def _format_plot_diagnostic(diag) -> str:
loc = f"(第 {diag.line} 行)" if diag.line else ""
return f"函数图像:{diag.message}{loc}"
def _render_function_plot(self, node: DocumentNode, warnings: list[str]) -> str:
# 文档级数量上限:超出部分直接回退占位,不解析不采样,防止海量图像耗尽资源
self._plot_count += 1
if self._plot_count > _MAX_FUNCTION_PLOTS:
warnings.append(
f"函数图像:文档内函数图像数量超过上限 {_MAX_FUNCTION_PLOTS},已回退为源码占位"
)
return f'<pre class="function-plot">{html.escape(node.text)}</pre>'
# 解析与渲染共同纳入局部异常回退:单个图像失败只回退占位 + warning,
# 绝不阻断整篇导出(含复杂表达式触发的 RecursionError 等异常)。
try:
parsed = parse_source(node.text)
for diag in parsed.diagnostics:
warnings.append(self._format_plot_diagnostic(diag))
if parsed.plot is None:
return f'<pre class="function-plot">{html.escape(node.text)}</pre>'
# 文档级累计复杂度预算:超出后回退占位,不再采样求值
if self._plot_nodes + parsed.plot.node_count > _MAX_TOTAL_PLOT_NODES:
warnings.append(
f"函数图像:文档内函数图像累计复杂度超过上限 {_MAX_TOTAL_PLOT_NODES} 节点,已回退为源码占位"
)
return f'<pre class="function-plot">{html.escape(node.text)}</pre>'
self._plot_nodes += parsed.plot.node_count
rendered = render_svg(parsed.plot)
except Exception as exc:
warnings.append(f"函数图像:解析或渲染失败,已回退占位({exc}")
return f'<pre class="function-plot">{html.escape(node.text)}</pre>'
warnings.extend(rendered.warnings)
return f'<figure class="function-plot">{rendered.content}</figure>'
def _render_math_block(self, node: DocumentNode, warnings: list[str]) -> str:
return f'<div class="math-block">$${html.escape(node.text)}$$</div>'
def _render_html_block(self, node: DocumentNode, warnings: list[str]) -> str:
# 原始 HTML 不可信,转义为纯文本展示,保证正文不丢且无注入风险
warnings.append(_RAW_HTML_WARNING)
return f'<div class="raw-html">{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:
inner = self._render_children(node.children, warnings)
href = str(node.attributes.get("href") or "")
safe_href = _safe_url(href)
if safe_href is None:
# 危险协议(如 javascript:)降级为纯文本,不输出可点击链接
warnings.append(f"链接协议不安全,已降级为纯文本:{href!r}")
return inner
title = str(node.attributes.get("title") or "")
attrs = [f'href="{html.escape(safe_href)}"']
if title:
attrs.append(f'title="{html.escape(title)}"')
return f"<a {' '.join(attrs)}>{inner}</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 = str(node.attributes.get("src") or "")
alt = str(node.attributes.get("alt") or "")
safe_src = _safe_url(src)
if safe_src is None:
# 危险协议(如 data:/javascript:)跳过图片,仅输出 alt 文本
warnings.append(f"图片地址不安全,已跳过:{src!r}")
return html.escape(alt) if alt else ""
title = str(node.attributes.get("title") or "")
attrs = [f'src="{html.escape(safe_src)}"', f'alt="{html.escape(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>"
+229
View File
@@ -0,0 +1,229 @@
"""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", "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
if kind == "block_html":
# 原始 HTML 块降级为纯文本节点,由 HtmlExporter 转义并记 warning,避免静默丢失正文
return DocumentNode(
type="html_block", node_id=self.next_id(), text=token.get("raw", "")
)
# 未知块级 token 保守保留原文;映射为带 text 子节点的 paragraph,避免被渲染层丢弃
raw = token.get("raw", "")
if raw:
return DocumentNode(
type="paragraph",
node_id=self.next_id(),
children=[DocumentNode(type="text", 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":
# mistune 图片 tokensrc 在 attrs.urlalt 来自 children 的文本,title 在 attrs.title
attrs = token.get("attrs", {})
alt = "".join(
child.get("raw", "")
for child in token.get("children", [])
if child.get("type") == "text"
)
attributes = {"src": attrs.get("url", "")}
if alt:
attributes["alt"] = 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
)
+339
View File
@@ -0,0 +1,339 @@
"""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, ExportResult
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
# 输入源(note / markdown)统一大小上限,防止未保存预览或超长笔记塞爆内存/产物
MAX_MARKDOWN_CHARS = 200_000
# 最终导出产物大小上限,防止超大 HTML 耗尽内存/磁盘
MAX_EXPORT_BYTES = 20 * 1024 * 1024 # 20 MB
# 并发渲染上限:解析/渲染是 CPU 密集的同步工作,限制同时执行的任务数,
# 防止大量任务同时占满工作线程与内存
MAX_CONCURRENT_RENDERS = 2
_render_slots = asyncio.Semaphore(MAX_CONCURRENT_RENDERS)
# 产物有效期
FILE_TTL = timedelta(hours=24)
_INVALID_FILE_CHARS = re.compile(r'[\\/:*?"<>|]')
class ExportCancelled(Exception):
"""导出在渲染前被取消时抛出,用于标记 cancelled。"""
class ExportTooLarge(Exception):
"""导出产物超过大小上限时抛出,用于标记 failed 并携带专用错误码。"""
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 _export_path(job_id: str) -> Path:
return get_settings().exports_path / f"{job_id}.html"
def _delete_file(job_id: str) -> None:
"""删除导出产物文件;文件不存在时忽略。"""
try:
_export_path(job_id).unlink(missing_ok=True)
except OSError:
logger.warning("Failed to delete export file: %s", job_id)
def cleanup_orphan_files() -> int:
"""清理 exports 目录下无对应内存任务的孤立产物(服务重启后调用)。"""
exports_dir = get_settings().exports_path
if not exports_dir.is_dir():
return 0
removed = 0
for path in exports_dir.glob("*.html"):
if path.stem not in _jobs:
try:
path.unlink()
removed += 1
except OSError:
logger.warning("Failed to delete orphan export file: %s", path)
return removed
def _render_document(document: Document, options: ExportOptions) -> ExportResult:
"""同步渲染辅助,供 asyncio.to_thread 调用;每次新建实例避免跨线程复用。"""
return HtmlExporter().render(document, options)
def _forget(job_id: str) -> None:
_jobs.pop(job_id, None)
_tasks.pop(job_id, None)
_cancel_flags.pop(job_id, None)
_delete_file(job_id)
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},
)
if len(note.markdown) > MAX_MARKDOWN_CHARS:
raise ApiError(
400,
"EXPORT_OPTIONS_INVALID",
f"note source exceeds {MAX_MARKDOWN_CHARS} characters",
{"size": len(note.markdown), "limit": MAX_MARKDOWN_CHARS},
)
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:
# 并发渲染限额:解析/渲染是 CPU 密集的同步工作,用信号量限制同时执行的任务数,
# 超出限额的任务在此排队等待,避免大量任务同时占满工作线程与内存
async with _render_slots:
# 让出一次,使「创建后立即取消」的 queued 任务能及时进入 cancelled
await asyncio.sleep(0)
if cancel_event.is_set():
raise ExportCancelled()
# 解析与渲染都是 CPU 密集的同步工作,放入线程执行避免阻塞事件循环,
# 使运行中的取消能在渲染边界生效;写文件前再次检查取消。
document = await asyncio.to_thread(parse_document, markdown)
document.attributes["title"] = title
if metadata:
document.attributes["metadata"] = metadata
result = await asyncio.to_thread(_render_document, document, options)
if cancel_event.is_set():
raise ExportCancelled()
if len(result.content) > MAX_EXPORT_BYTES:
raise ExportTooLarge()
out_dir = get_settings().exports_path
out_dir.mkdir(parents=True, exist_ok=True)
path = _export_path(job_id)
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 ExportTooLarge:
_jobs[job_id] = _jobs[job_id].model_copy(
update={
"status": ExportStatus.failed,
"error": "Export output exceeds size limit.",
"error_code": "EXPORT_OUTPUT_TOO_LARGE",
"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():
_forget(job_id) # 过期即清理内存记录与产物文件
raise ApiError(410, "EXPORT_FILE_EXPIRED", "export file has expired", {"job_id": job_id})
return _export_path(job_id)
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)
+24 -12
View File
@@ -20,7 +20,6 @@ from app.errors import ApiError
from app.textutils import count_tokens
_HEADING_RE = re.compile(r"^(#{1,6})[ \t]+(.*?)\s*$")
_FRONTMATTER_KEY_RE = re.compile(r"^([A-Za-z0-9_-]+)\s*:\s*(.*)$")
_FENCE_RE = re.compile(r"^[ \t]{0,3}(`{3,}|~{3,})(?:[^`]*)$")
@@ -261,16 +260,29 @@ def _embedding_policy(markdown: str) -> bool:
return value.value.lower() in {"true", "yes", "on"}
def _extract_frontmatter(markdown: str) -> dict[str, str]:
"""极简 frontmatter 解析,只提取 key: value 行。"""
def _extract_frontmatter(markdown: str) -> dict[str, str | list[str]]:
"""Read YAML scalars and tag sequences without constructing arbitrary objects."""
header = _frontmatter(markdown)
if header is None:
return {}
meta: dict[str, str] = {}
for line in header[0].splitlines():
m = _FRONTMATTER_KEY_RE.match(line)
if m:
meta[m.group(1).lower()] = m.group(2).strip()
try:
node = yaml.compose(header[0], Loader=yaml.SafeLoader)
except yaml.YAMLError as exc:
raise ApiError(422, "INVALID_EMBEDDING_POLICY", "Frontmatter YAML 无效,无法确认本地索引策略。") from exc
meta: dict[str, str | list[str]] = {}
if not isinstance(node, yaml.MappingNode):
return meta # The policy validation below handles unsupported documents.
for key, value in node.value:
if not isinstance(key, yaml.ScalarNode):
continue
name = key.value.lower()
if name not in {"title", "tags"}:
continue
if isinstance(value, yaml.ScalarNode):
# Keep lexical values: YAML 1.1 would otherwise turn tags like on/yes into booleans.
meta[name] = "" if value.tag == "tag:yaml.org,2002:null" else value.value
elif name == "tags" and isinstance(value, yaml.SequenceNode):
meta[name] = [item.value for item in value.value if isinstance(item, yaml.ScalarNode)]
return meta
@@ -282,10 +294,10 @@ def _first_heading(markdown: str) -> str | None:
return None
def _parse_tags(raw: str | None) -> list[str]:
def _parse_tags(raw: str | list[str] | None) -> list[str]:
if isinstance(raw, list):
return raw
if not raw:
return []
raw = raw.strip()
if raw.startswith("[") and raw.endswith("]"):
raw = raw[1:-1]
return [t.strip().strip("'\"") for t in raw.split(",") if t.strip()]
return [t.strip() for t in raw.split(",") if t.strip()]
+4
View File
@@ -8,6 +8,7 @@ from starlette.exceptions import HTTPException as StarletteHttpException
from app.config import get_settings
from app.container import container
from app.errors import ApiError, api_error_handler, http_error_handler, validation_error_handler
from app.export import service as export_service
from app.routes import router as api_router
from app.media_routes import router as media_router
from app.local_model_routes import router as local_model_router
@@ -20,6 +21,8 @@ settings = get_settings()
@asynccontextmanager
async def lifespan(_: FastAPI):
# 重启后内存注册表为空,清理上一次运行遗留的导出产物,避免磁盘垃圾堆积。
export_service.cleanup_orphan_files()
from app.services import transcription_service
transcription_service.recover_interrupted()
try:
@@ -31,6 +34,7 @@ async def lifespan(_: FastAPI):
from app.local_models import manager
for _, key in list(manager._downloads):
await manager.cancel_download(key)
# 第三方 MCP Server 必须跟随 AI Core 退出,不能遗留孤儿进程。
container.plugins.shutdown()
container.mcp_servers.shutdown()
+7
View File
@@ -0,0 +1,7 @@
"""Function Plot:函数图像的白名单表达式解析与静态 SVG 渲染。
模块划分
- model.py FunctionPlot 等内部数据模型不进 contracts.py Document AST
- parser.py function-plot 源码与表达式解析ast 白名单绝不 eval/exec
- render.py FunctionPlot 渲染为内嵌 SVG纯几何 + <text>无脚本
"""
+57
View File
@@ -0,0 +1,57 @@
"""Function Plot 内部数据模型。
契约 §12.2 FunctionPlot 结构与 §10.4 StaticRenderResult 只在导出链路的后端内部
流转不进入 HTTP 契约因此与 Document AST 一样放在独立包内不进 contracts.py
"""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
class FunctionPlotExpression(BaseModel):
"""单条函数表达式;expression 为数学表达式文本(不含 ``y =`` 前缀)。"""
expression: str
label: str | None = None
color: str | None = None
class PlotAxes(BaseModel):
xlabel: str | None = None
ylabel: str | None = None
grid: bool = True
class FunctionPlot(BaseModel):
version: int = 1
expressions: list[FunctionPlotExpression]
domain: tuple[float, float] = (-10.0, 10.0)
range: tuple[float, float] | None = None
axes: PlotAxes = Field(default_factory=PlotAxes)
# 该块所有表达式 AST 节点数之和,供导出器做文档级累计复杂度预算
node_count: int = 0
class PlotDiagnostic(BaseModel):
severity: Literal["warning", "error"]
code: str
message: str
line: int | None = None
class FunctionPlotParseResult(BaseModel):
"""解析结果:任一表达式 error 时 plot 为 None(整块回退占位),仅 warning 时 plot 有效。"""
plot: FunctionPlot | None = None
diagnostics: list[PlotDiagnostic] = Field(default_factory=list)
class StaticRenderResult(BaseModel):
content: str
mime_type: str = "image/svg+xml"
width: int
height: int
warnings: list[str] = Field(default_factory=list)
+412
View File
@@ -0,0 +1,412 @@
"""Function Plot 表达式解析:白名单数学语法,绝不执行 eval / 函数构造器 / 属性访问。
安全模型先用 ``ast.parse(mode='eval')`` 把表达式变成纯 AST这一步不执行任何代码
再逐节点白名单校验只允许数字变量 ``x``常量 ``pi/e``白名单函数调用与四则/
运算最后用递归解释器直接计算数值全程不 ``compile``/``exec`` 字符串
"""
from __future__ import annotations
import ast
import math
import re
from typing import NoReturn
from app.plot.model import (
FunctionPlot,
FunctionPlotExpression,
FunctionPlotParseResult,
PlotAxes,
PlotDiagnostic,
)
# 白名单函数(ln 是 log 的别名);abs 用内置函数,其余映射到 math
_FUNCTION_IMPL: dict[str, object] = {
"sin": math.sin,
"cos": math.cos,
"tan": math.tan,
"asin": math.asin,
"acos": math.acos,
"atan": math.atan,
"sinh": math.sinh,
"cosh": math.cosh,
"tanh": math.tanh,
"exp": math.exp,
"log": math.log,
"ln": math.log,
"log10": math.log10,
"log2": math.log2,
"sqrt": math.sqrt,
"abs": abs,
}
_FUNCTIONS = frozenset(_FUNCTION_IMPL)
_CONSTANTS: dict[str, float] = {"pi": math.pi, "e": math.e}
_ALLOWED_BINOPS = (ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Pow)
_ALLOWED_UNARY = (ast.UAdd, ast.USub)
_DIRECTIVE_KEYS = frozenset({"domain", "range", "xlabel", "ylabel", "grid"})
_NUMBER_RE = re.compile(r"^(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$")
# 表达式复杂度上限:深层嵌套或海量节点在递归校验/求值时会触发 RecursionError
# 用白名单校验提前拦截,保证失败走正常诊断路径而不是异常逃逸出导出链路。
_MAX_AST_DEPTH = 200
_MAX_AST_NODES = 1000
# 单块 function-plot 允许的表达式数量上限,防止海量表达式导致超大 SVG 与海量采样求值
_MAX_EXPRESSIONS = 16
class PlotParseError(Exception):
"""表达式解析/校验失败,携带可定位诊断。"""
def __init__(self, diagnostic: PlotDiagnostic) -> None:
super().__init__(diagnostic.message)
self.diagnostic = diagnostic
def _unsafe(message: str) -> NoReturn:
raise PlotParseError(
PlotDiagnostic(severity="error", code="FUNCTION_PLOT_EXPRESSION_UNSAFE", message=message)
)
def _is_number(tok: str) -> bool:
return bool(_NUMBER_RE.match(tok))
def _tokenize(s: str) -> list[str]:
"""把预处理后的表达式切成数字/标识符/运算符/括号 token。"""
tokens: list[str] = []
i = 0
n = len(s)
while i < n:
ch = s[i]
if ch.isspace():
i += 1
continue
if ch.isdigit() or ch == ".":
j = i
while j < n and (s[j].isdigit() or s[j] == "."):
j += 1
# 科学计数法:数字后紧跟 e/E[+-]数字 视为同一数字
if j < n and s[j] in "eE":
k = j + 1
if k < n and s[k] in "+-":
k += 1
if k < n and s[k].isdigit():
while k < n and s[k].isdigit():
k += 1
j = k
tokens.append(s[i:j])
i = j
continue
if ch.isalpha() or ch == "_":
j = i
while j < n and (s[j].isalnum() or s[j] == "_"):
j += 1
tokens.append(s[i:j])
i = j
continue
if ch == "*" and i + 1 < n and s[i + 1] == "*":
tokens.append("**")
i += 2
continue
tokens.append(ch)
i += 1
return tokens
def _is_value_end(tok: str) -> bool:
"""该 token 之后允许补乘号(数字/右括号/变量 x/常量)。"""
return tok == ")" or _is_number(tok) or tok == "x" or tok in _CONSTANTS
def _is_value_start(tok: str) -> bool:
"""该 token 可作为乘号右侧起点(左括号/数字/任意标识符,含函数名)。"""
return tok == "(" or _is_number(tok) or (tok and (tok[0].isalpha() or tok[0] == "_"))
def _insert_implicit_multiplication(s: str) -> str:
"""补隐式乘法:2x、2(x+1)、(x+1)(x-1)、x sin(x) 等;函数名后的 ``(`` 是调用不补。"""
tokens = _tokenize(s)
out: list[str] = []
prev: str | None = None
for tok in tokens:
if prev is not None and _is_value_end(prev) and _is_value_start(tok):
out.append("*")
out.append(tok)
prev = tok
return "".join(out)
def _preprocess(expr: str) -> str:
"""``^`` 视为幂,补隐式乘法后再交给 ast.parse。"""
return _insert_implicit_multiplication(expr.replace("^", "**"))
def _check_node(node: ast.AST, depth: int = 0, counter: list[int] | None = None) -> None:
"""白名单校验:任何越界节点都抛 FUNCTION_PLOT_EXPRESSION_UNSAFE。
同时限制 AST 深度与节点总数避免超长/超深表达式在递归校验或求值时触发
RecursionError 而绕过解析失败路径
"""
if counter is None:
counter = [0]
if depth > _MAX_AST_DEPTH:
_unsafe(f"表达式嵌套过深(超过 {_MAX_AST_DEPTH} 层)")
counter[0] += 1
if counter[0] > _MAX_AST_NODES:
_unsafe(f"表达式过于复杂(节点数超过 {_MAX_AST_NODES}")
if isinstance(node, ast.Constant):
if isinstance(node.value, bool) or not isinstance(node.value, (int, float)):
_unsafe(f"不支持的常量 {node.value!r}")
return
if isinstance(node, ast.Name):
if node.id == "x" or node.id in _CONSTANTS:
return
_unsafe(f"未知标识符 {node.id!r}")
if isinstance(node, ast.BinOp):
if not isinstance(node.op, _ALLOWED_BINOPS):
_unsafe(f"不支持的运算符 {type(node.op).__name__}")
_check_node(node.left, depth + 1, counter)
_check_node(node.right, depth + 1, counter)
return
if isinstance(node, ast.UnaryOp):
if not isinstance(node.op, _ALLOWED_UNARY):
_unsafe(f"不支持的运算符 {type(node.op).__name__}")
_check_node(node.operand, depth + 1, counter)
return
if isinstance(node, ast.Call):
if not isinstance(node.func, ast.Name) or node.func.id not in _FUNCTIONS:
_unsafe(f"不支持的函数调用 {ast.dump(node.func)!r}")
if node.keywords:
_unsafe("函数调用不支持关键字参数")
# 白名单内所有函数均恰取 1 个参数,提前校验避免求值期 TypeError
if len(node.args) != 1:
_unsafe(f"{node.func.id} 需要 1 个参数,实际 {len(node.args)}")
for arg in node.args:
_check_node(arg, depth + 1, counter)
return
_unsafe(f"不支持的语法 {type(node).__name__}")
def parse_expression(expr: str) -> ast.Expression:
"""把数学表达式解析为已通过白名单校验的 AST(可直接交给 evaluate)。"""
preprocessed = _preprocess(expr)
try:
tree = ast.parse(preprocessed, mode="eval")
except SyntaxError as exc:
raise PlotParseError(
PlotDiagnostic(
severity="error",
code="FUNCTION_PLOT_PARSE_FAILED",
message=f"表达式语法错误:{exc.msg}",
)
) from exc
except RecursionError as exc:
# 极深嵌套可能在 ast.parse 阶段就触发 RecursionError,转为可定位诊断
raise PlotParseError(
PlotDiagnostic(
severity="error",
code="FUNCTION_PLOT_PARSE_FAILED",
message="表达式嵌套过深,无法解析",
)
) from exc
_check_node(tree.body)
return tree
def _count_nodes(node: ast.AST) -> int:
"""统计已通过校验的表达式 AST 节点数,供文档级累计复杂度预算使用。"""
counter = [0]
_check_node(node, counter=counter)
return counter[0]
def evaluate(expr_ast: ast.Expression, x: float) -> float:
"""递归解释已校验 AST 得到数值,全程不编译/执行代码。"""
return _eval_node(expr_ast.body, x)
def _eval_node(node: ast.AST, x: float) -> float:
if isinstance(node, ast.Constant):
return float(node.value)
if isinstance(node, ast.Name):
return x if node.id == "x" else _CONSTANTS[node.id]
if isinstance(node, ast.BinOp):
left = _eval_node(node.left, x)
right = _eval_node(node.right, x)
if isinstance(node.op, ast.Add):
return left + right
if isinstance(node.op, ast.Sub):
return left - right
if isinstance(node.op, ast.Mult):
return left * right
if isinstance(node.op, ast.Div):
return left / right
# 负数底 + 非整数指数会得到复数,数学绘图不支持,抛 ValueError 让采样点作为断点处理
if left < 0 and not right.is_integer():
raise ValueError("negative base with fractional exponent")
return left**right
if isinstance(node, ast.UnaryOp):
value = _eval_node(node.operand, x)
return -value if isinstance(node.op, ast.USub) else value
if isinstance(node, ast.Call):
args = [_eval_node(arg, x) for arg in node.args]
return _FUNCTION_IMPL[node.func.id](*args) # type: ignore[operator]
raise ValueError("unreachable node")
def _strip_comment(line: str) -> str:
return line.split("#", 1)[0].strip()
def _parse_pair(value: str) -> tuple[float, float]:
"""解析 ``min, max`` / ``min max`` 数值对。"""
parts = [p for p in re.split(r"[,\s]+", value.strip()) if p]
if len(parts) != 2:
raise ValueError("需要两个数值")
return float(parts[0]), float(parts[1])
def _parse_directive(line: str) -> tuple[str, str] | None:
"""指令行形如 ``key: value``(表达式不含冒号,冒号是可靠判别)。"""
if ":" not in line or "=" in line:
return None
key, _, value = line.partition(":")
key = key.strip().lower()
if not key or " " in key:
return None
return key, value.strip()
def parse_source(source: str) -> FunctionPlotParseResult:
"""把 function-plot fenced block 源码解析为 FunctionPlot + 诊断。"""
diagnostics: list[PlotDiagnostic] = []
expressions: list[FunctionPlotExpression] = []
domain: tuple[float, float] = (-10.0, 10.0)
range_: tuple[float, float] | None = None
xlabel: str | None = None
ylabel: str | None = None
grid: bool = True
has_error = False
total_nodes = 0
for lineno, raw_line in enumerate(source.splitlines(), start=1):
line = raw_line.strip()
if not line or line.startswith("#"):
continue
directive = _parse_directive(line)
if directive is not None:
key, value = directive
if key == "domain":
try:
domain = _parse_pair(value)
except ValueError:
diagnostics.append(
PlotDiagnostic(
severity="warning",
code="FUNCTION_PLOT_PARSE_FAILED",
message=f"domain 需要两个数值,已忽略:{value!r}",
line=lineno,
)
)
elif key == "range":
try:
range_ = _parse_pair(value)
except ValueError:
diagnostics.append(
PlotDiagnostic(
severity="warning",
code="FUNCTION_PLOT_PARSE_FAILED",
message=f"range 需要两个数值,已忽略:{value!r}",
line=lineno,
)
)
elif key == "xlabel":
xlabel = value or None
elif key == "ylabel":
ylabel = value or None
elif key == "grid":
grid = value.lower() in ("true", "1", "yes", "on")
else:
diagnostics.append(
PlotDiagnostic(
severity="warning",
code="FUNCTION_PLOT_PARSE_FAILED",
message=f"未知指令 {key!r} 已忽略",
line=lineno,
)
)
continue
# 表达式行:y = <expr> 或裸 <expr>
expr_text = _strip_comment(line)
if not expr_text:
continue
if "=" in expr_text:
lhs, _, rhs = expr_text.partition("=")
if lhs.strip().lower() not in ("y", ""):
diagnostics.append(
PlotDiagnostic(
severity="error",
code="FUNCTION_PLOT_PARSE_FAILED",
message="表达式应形如 'y = <expr>'",
line=lineno,
)
)
has_error = True
continue
expr_text = rhs.strip()
if not expr_text:
diagnostics.append(
PlotDiagnostic(
severity="error",
code="FUNCTION_PLOT_PARSE_FAILED",
message="表达式为空",
line=lineno,
)
)
has_error = True
continue
try:
tree = parse_expression(expr_text)
except PlotParseError as exc:
exc.diagnostic.line = lineno
diagnostics.append(exc.diagnostic)
has_error = True
continue
total_nodes += _count_nodes(tree.body)
expressions.append(FunctionPlotExpression(expression=expr_text))
# 表达式数量超限:整块回退并提前终止,避免对海量表达式做采样求值
if len(expressions) > _MAX_EXPRESSIONS:
diagnostics.append(
PlotDiagnostic(
severity="error",
code="FUNCTION_PLOT_TOO_MANY_EXPRESSIONS",
message=f"表达式数量超过上限 {_MAX_EXPRESSIONS},已回退为源码占位",
)
)
return FunctionPlotParseResult(plot=None, diagnostics=diagnostics)
if has_error:
return FunctionPlotParseResult(plot=None, diagnostics=diagnostics)
if not expressions:
diagnostics.append(
PlotDiagnostic(
severity="error",
code="FUNCTION_PLOT_PARSE_FAILED",
message="没有找到任何函数表达式",
)
)
return FunctionPlotParseResult(plot=None, diagnostics=diagnostics)
plot = FunctionPlot(
expressions=expressions,
domain=domain,
range=range_,
axes=PlotAxes(xlabel=xlabel, ylabel=ylabel, grid=grid),
node_count=total_nodes,
)
return FunctionPlotParseResult(plot=plot, diagnostics=diagnostics)
+258
View File
@@ -0,0 +1,258 @@
"""Function Plot → 静态 SVG 渲染。
只输出纯几何与 <text> SVG script/foreignObject/内联事件可安全内嵌 HTML
所有文本与颜色都经过转义/校验不把用户输入直接拼进标记
"""
from __future__ import annotations
import html
import math
import re
from typing import Callable
from app.plot.model import FunctionPlot, StaticRenderResult
from app.plot.parser import PlotParseError, evaluate, parse_expression
_WIDTH = 640
_HEIGHT = 480
_MARGIN = 52 # 四周留白,放轴刻度与标签
_SAMPLES = 400
_PALETTE = ["#0969da", "#d1242f", "#1a7f37", "#8250df", "#bf8700", "#e36209"]
_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{3,8}$")
def _safe_color(color: str | None, fallback: str) -> str:
return color.strip() if color and _COLOR_RE.match(color.strip()) else fallback
def _valid_span(lo: float, hi: float) -> bool:
"""范围跨度有效:端点有限、跨度有限且大于零。
端点相减可能溢出为 ``inf`` ``-1e308`` ``1e308``需单独校验跨度
否则后续坐标换算会生成含 ``nan`` SVG
"""
span = hi - lo
return math.isfinite(lo) and math.isfinite(hi) and math.isfinite(span) and span > 0
def _fmt_num(v: float) -> str:
if v == 0:
return "0"
if abs(v) >= 1e6 or abs(v) < 1e-6:
return f"{v:.2e}"
return f"{v:.6g}"
def _nice_step(span: float, target_ticks: int = 6) -> float:
raw = abs(span) / target_ticks
if not math.isfinite(raw) or raw <= 0:
return 1.0 # 兜底步长,避免 span 为 0/inf 时产生非法刻度
mag = 10 ** math.floor(math.log10(raw))
for m in (1, 2, 5, 10):
if raw <= m * mag:
return m * mag
return 10 * mag
def _ticks(lo: float, hi: float, step: float) -> list[float]:
# 防御:非法步长直接返回空,避免除零
if not math.isfinite(step) or step <= 0:
return []
first = math.ceil(lo / step) * step
values: list[float] = []
v = first
# 有上限的整数索引推进 + 步长推进校验,防止浮点精度导致 v+step==v 的死循环
for _ in range(1000):
if v > hi + step * 1e-9:
break
values.append(v)
nxt = v + step
if nxt <= v:
break # 步长小于当前数值的浮点精度,已无法推进
v = nxt
return values
def _compute_range(
fns: list[tuple[object, object]],
xmin: float,
xmax: float,
) -> tuple[float, float]:
"""采样确定 y 范围;取有限样本的 min/max 加 5% 余量。"""
ys: list[float] = []
for _expr, tree in fns:
for i in range(_SAMPLES + 1):
x = xmin + (xmax - xmin) * i / _SAMPLES
try:
y = evaluate(tree, x) # type: ignore[arg-type]
except (ValueError, ZeroDivisionError, OverflowError, TypeError):
continue
# 复数等非实数结果直接跳过,不参与范围统计
if isinstance(y, (int, float)) and math.isfinite(y):
ys.append(y)
if not ys:
return -10.0, 10.0
lo, hi = min(ys), max(ys)
if lo == hi:
lo -= 1.0
hi += 1.0
pad = (hi - lo) * 0.05
return lo - pad, hi + pad
def _polyline(
tree: object,
xmin: float,
xmax: float,
sx: Callable[[float], float],
sy: Callable[[float], float],
color: str,
) -> str:
"""采样并把非有限点处断开成多段 polyline,避免画穿渐近线。"""
segments: list[str] = []
points: list[str] = []
for i in range(_SAMPLES + 1):
x = xmin + (xmax - xmin) * i / _SAMPLES
try:
y = evaluate(tree, x) # type: ignore[arg-type]
except (ValueError, ZeroDivisionError, OverflowError, TypeError):
y = math.nan
if not isinstance(y, (int, float)) or not math.isfinite(y):
if points:
segments.append(f'<polyline points="{" ".join(points)}" fill="none" stroke="{color}"/>')
points = []
continue
px = sx(x)
py = sy(y)
# 映射后的坐标必须有限:显式 range 下极端 y 值可能让像素坐标溢出为 inf
if not (math.isfinite(px) and math.isfinite(py)):
if points:
segments.append(f'<polyline points="{" ".join(points)}" fill="none" stroke="{color}"/>')
points = []
continue
points.append(f"{px:.2f},{py:.2f}")
if points:
segments.append(f'<polyline points="{" ".join(points)}" fill="none" stroke="{color}"/>')
return "".join(segments)
def _grid(
xmin: float,
xmax: float,
ymin: float,
ymax: float,
sx: Callable[[float], float],
sy: Callable[[float], float],
) -> str:
parts: list[str] = []
for x in _ticks(xmin, xmax, _nice_step(xmax - xmin)):
parts.append(f'<line x1="{sx(x):.2f}" y1="{sy(ymin):.2f}" x2="{sx(x):.2f}" y2="{sy(ymax):.2f}" stroke="#eaeef2"/>')
for y in _ticks(ymin, ymax, _nice_step(ymax - ymin)):
parts.append(f'<line x1="{sx(xmin):.2f}" y1="{sy(y):.2f}" x2="{sx(xmax):.2f}" y2="{sy(y):.2f}" stroke="#eaeef2"/>')
return "".join(parts)
def _axes(
xmin: float,
xmax: float,
ymin: float,
ymax: float,
sx: Callable[[float], float],
sy: Callable[[float], float],
) -> str:
parts: list[str] = []
# 坐标轴:过原点则画在原点,否则贴边,保证始终有参照系
x_axis_y = 0.0 if ymin <= 0 <= ymax else ymin
y_axis_x = 0.0 if xmin <= 0 <= xmax else xmin
parts.append(
f'<line x1="{sx(xmin):.2f}" y1="{sy(x_axis_y):.2f}" x2="{sx(xmax):.2f}" y2="{sy(x_axis_y):.2f}" stroke="#57606a"/>'
)
parts.append(
f'<line x1="{sx(y_axis_x):.2f}" y1="{sy(ymin):.2f}" x2="{sx(y_axis_x):.2f}" y2="{sy(ymax):.2f}" stroke="#57606a"/>'
)
# x 轴刻度数字(画在轴下方)
for x in _ticks(xmin, xmax, _nice_step(xmax - xmin)):
parts.append(
f'<text x="{sx(x):.2f}" y="{sy(x_axis_y) + 14:.2f}" text-anchor="middle" font-size="10" fill="#57606a">{html.escape(_fmt_num(x))}</text>'
)
# y 轴刻度数字(画在轴左侧)
for y in _ticks(ymin, ymax, _nice_step(ymax - ymin)):
parts.append(
f'<text x="{sx(y_axis_x) - 6:.2f}" y="{sy(y) + 3:.2f}" text-anchor="end" font-size="10" fill="#57606a">{html.escape(_fmt_num(y))}</text>'
)
return "".join(parts)
def _labels(plot: FunctionPlot, sx: Callable[[float], float], sy: Callable[[float], float]) -> str:
parts: list[str] = []
if plot.axes.xlabel:
parts.append(
f'<text x="{(_WIDTH / 2):.2f}" y="{_HEIGHT - 10:.2f}" text-anchor="middle" font-size="12" fill="#1f2328">{html.escape(plot.axes.xlabel)}</text>'
)
if plot.axes.ylabel:
parts.append(
f'<text x="16" y="{(_HEIGHT / 2):.2f}" text-anchor="middle" font-size="12" fill="#1f2328" transform="rotate(-90 16 {_HEIGHT / 2:.2f})">{html.escape(plot.axes.ylabel)}</text>'
)
return "".join(parts)
def render_svg(plot: FunctionPlot) -> StaticRenderResult:
"""把已解析的 FunctionPlot 渲染为内嵌 SVG。"""
warnings: list[str] = []
xmin, xmax = plot.domain
if not _valid_span(xmin, xmax):
warnings.append("domain 无效,回退到 [-10, 10]")
xmin, xmax = -10.0, 10.0
# 重新解析并编译表达式(parse_source 已校验,这里异常只在模型被绕过时触发)
fns: list[tuple[object, object]] = []
for expr in plot.expressions:
try:
tree = parse_expression(expr.expression)
except PlotParseError as exc:
warnings.append(f"表达式无法渲染,已跳过:{expr.expression}{exc.diagnostic.message}")
continue
fns.append((expr, tree))
# 纵轴范围:显式 range 有效则用之;无效(退化/非有限/跨度溢出)丢弃并自动采样重算
if plot.range is not None:
lo, hi = float(plot.range[0]), float(plot.range[1])
if _valid_span(lo, hi):
ymin, ymax = lo, hi
else:
warnings.append("range 无效,改用自动范围")
ymin, ymax = _compute_range(fns, xmin, xmax)
else:
ymin, ymax = _compute_range(fns, xmin, xmax)
# 最终防线:自动范围在极端样本下也可能溢出,坐标映射前必须保证跨度有限且大于零
if not _valid_span(ymin, ymax):
warnings.append("y 范围跨度无法表示,回退到 [-10, 10]")
ymin, ymax = -10.0, 10.0
def sx(x: float) -> float:
return _MARGIN + (x - xmin) / (xmax - xmin) * (_WIDTH - 2 * _MARGIN)
def sy(y: float) -> float:
return _HEIGHT - _MARGIN - (y - ymin) / (ymax - ymin) * (_HEIGHT - 2 * _MARGIN)
parts: list[str] = [
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {_WIDTH} {_HEIGHT}" role="img">'
]
if plot.axes.grid:
parts.append(_grid(xmin, xmax, ymin, ymax, sx, sy))
parts.append(_axes(xmin, xmax, ymin, ymax, sx, sy))
for i, (expr, tree) in enumerate(fns):
color = _safe_color(expr.color, _PALETTE[i % len(_PALETTE)])
parts.append(_polyline(tree, xmin, xmax, sx, sy, color))
parts.append(_labels(plot, sx, sy))
parts.append("</svg>")
return StaticRenderResult(
content="".join(parts),
width=_WIDTH,
height=_HEIGHT,
warnings=warnings,
)
+85 -1
View File
@@ -6,7 +6,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
@@ -53,6 +53,11 @@ from app.contracts import (
ModelRoutingResponse,
SpeakerMatchRequest,
SpeakerMatchResult,
ExportFormat,
ExportJob,
ExportJobListResponse,
ExportRequest,
ExportStatus,
Note,
NoteCreateRequest,
NoteListResponse,
@@ -101,8 +106,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
@@ -1468,3 +1475,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",
+485
View File
@@ -0,0 +1,485 @@
"""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,
ExportJob,
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
def test_parse_document_image_maps_src_alt_title() -> None:
doc = parse_document('![替代文本](https://a.b/img.png "标题")')
img = doc.children[0].children[0]
assert img.type == "image"
assert img.attributes["src"] == "https://a.b/img.png"
assert img.attributes["alt"] == "替代文本"
assert img.attributes["title"] == "标题"
def test_parse_document_function_plot_dash_alias() -> None:
doc = parse_document("```function-plot\ny = x^2\n```")
assert doc.children[0].type == "function_plot"
assert doc.children[0].text == "y = x^2"
# --------------------------------------------------------------------------- #
# 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_rejects_unsafe_link_protocol() -> None:
result = asyncio.run(
HtmlExporter().export(parse_document("[点我](javascript:alert(1))"), ExportOptions())
)
html = result.content.decode("utf-8")
assert "javascript:" not in html
assert "点我" in html
assert any("不安全" in w for w in result.warnings)
def test_html_exporter_rejects_unsafe_image_protocol() -> None:
result = asyncio.run(
HtmlExporter().export(parse_document("![alt](data:text/html,<script>)"), ExportOptions())
)
html = result.content.decode("utf-8")
assert "data:" not in html
assert "<img" not in html
assert "alt" in html
assert any("不安全" in w for w in result.warnings)
def test_html_exporter_preserves_raw_html_block() -> None:
result = asyncio.run(
HtmlExporter().export(parse_document("<div>重要正文</div>"), ExportOptions())
)
html = result.content.decode("utf-8")
assert "重要正文" in html
assert "<div>" not in html
assert "&lt;div&gt;重要正文&lt;/div&gt;" in html
assert any("原始 HTML" 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())
path = get_settings().exports_path / f"{job_id}.html"
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"
assert not path.exists() # 过期即清理产物文件
assert export_service.get_export(job_id) is None # 内存记录一并清理
def test_export_eviction_deletes_file() -> None:
finished = _create_and_wait(_markdown_request("# 淘汰"))
victim_path = get_settings().exports_path / f"{finished.job_id}.html"
assert victim_path.exists()
# 塞满 MAX_JOBS 个终态任务,下一次 create 会淘汰最旧的终态(finished 最先插入)
for i in range(export_service.MAX_JOBS):
export_service._jobs[f"export_fake_{i}"] = ExportJob(
job_id=f"export_fake_{i}",
status=ExportStatus.completed,
format=ExportFormat.html,
created_at=datetime.now(timezone.utc),
)
_create_and_wait(_markdown_request("# 触发淘汰"))
assert not victim_path.exists()
def test_cleanup_orphan_files() -> None:
exports_dir = get_settings().exports_path
exports_dir.mkdir(parents=True, exist_ok=True)
orphan = exports_dir / "export_orphan.html"
orphan.write_text("stale", encoding="utf-8")
finished = _create_and_wait(_markdown_request("# 保留"))
keep_path = exports_dir / f"{finished.job_id}.html"
assert keep_path.exists()
removed = export_service.cleanup_orphan_files()
assert removed >= 1
assert not orphan.exists()
assert keep_path.exists() # 仍在注册表中的任务文件保留
def test_export_cancel_during_running(monkeypatch) -> None:
import threading
import time
real_parse = parse_document
started = threading.Event()
def slow_parse(markdown: str):
started.set()
time.sleep(0.1)
return real_parse(markdown)
monkeypatch.setattr(export_service, "parse_document", slow_parse)
async def _go():
job = await export_service.create_export(_markdown_request("# 运行中取消"))
while not started.is_set():
await asyncio.sleep(0)
export_service.cancel_export(job.job_id)
return await export_service.wait_for_export(job.job_id)
finished = asyncio.run(_go())
assert finished.status == ExportStatus.cancelled
assert finished.file is None
assert not (get_settings().exports_path / f"{finished.job_id}.html").exists()
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)
# --------------------------------------------------------------------------- #
# 审阅回归:资源上限
# --------------------------------------------------------------------------- #
def test_export_note_source_size_limit(monkeypatch) -> None:
# P1note 源超出 MAX_MARKDOWN_CHARS 应在创建期拒绝,不进入后台渲染
from app.services import note_service
monkeypatch.setattr(export_service, "MAX_MARKDOWN_CHARS", 10)
async def _go():
note = await note_service.create_note(
title="超长笔记", markdown="a" * 20, folder="导出", tags=[]
)
return await export_service.create_export(
ExportRequest(
source=ExportSource(type=ExportSourceType.note, note_id=note.note_id),
format=ExportFormat.html,
)
)
with pytest.raises(ApiError) as exc:
asyncio.run(_go())
assert exc.value.status_code == 400
assert exc.value.code == "EXPORT_OPTIONS_INVALID"
def test_export_output_too_large(monkeypatch) -> None:
# P1:产物超出 MAX_EXPORT_BYTES 应标记 failed 且不落盘
monkeypatch.setattr(export_service, "MAX_EXPORT_BYTES", 10)
finished = _create_and_wait(_markdown_request("# 产物超限"))
assert finished.status == ExportStatus.failed
assert finished.error_code == "EXPORT_OUTPUT_TOO_LARGE"
assert finished.file is None
assert not (get_settings().exports_path / f"{finished.job_id}.html").exists()
def test_export_limits_concurrent_rendering(monkeypatch) -> None:
# P1:并发渲染受 MAX_CONCURRENT_RENDERS 限制,大量任务不会同时占满工作线程
import threading
import time
real_render = export_service._render_document
active = 0
peak = 0
lock = threading.Lock()
def slow_render(document, options):
nonlocal active, peak
with lock:
active += 1
peak = max(peak, active)
time.sleep(0.05)
with lock:
active -= 1
return real_render(document, options)
monkeypatch.setattr(export_service, "_render_document", slow_render)
async def _go():
jobs = [
await export_service.create_export(_markdown_request(f"# t{i}"))
for i in range(6)
]
return [await export_service.wait_for_export(j.job_id) for j in jobs]
finished = asyncio.run(_go())
assert all(j.status == ExportStatus.completed for j in finished)
assert peak <= export_service.MAX_CONCURRENT_RENDERS
+46
View File
@@ -0,0 +1,46 @@
import asyncio
from datetime import datetime, timezone
import pytest
from app.contracts import IndexRebuildRequest
from app.knowledge.parser import parse_note
from app.services import index_service, note_service
@pytest.mark.parametrize(('header', 'expected'), [
('tags:\n- python\n- rust', ['python', 'rust']),
('tags:\n - python\n - rust', ['python', 'rust']),
('"tags": ["a,b", "quote\\\"tag", "path\\\\tag"] # comment', ['a,b', 'quote"tag', 'path\\tag']),
('tags: [on, yes, "true", "001"]', ['on', 'yes', 'true', '001']),
('tags: python, rust', ['python', 'rust']),
('tags: []', []),
('tags: null', []),
])
def test_yaml_tags_are_parsed_as_complete_values(header, expected):
now = datetime.now(timezone.utc)
note = parse_note(
markdown=f'---\ntitle: "Demo: YAML"\n{header}\n---\n# Body',
file_path='demo.md', folder='', created_at=now, updated_at=now,
)
assert note.tags == expected
assert note.title == 'Demo: YAML'
def test_saved_metadata_survives_full_index_rebuild():
async def scenario():
note = await note_service.create_note(title='Demo', markdown='# Body', folder=None, tags=['old'])
for tags, yaml_tags in [
(['python', 'a,b', 'on'], '\n - python\n - a,b\n - on'),
([], ' []'),
]:
markdown = f'---\ntitle: "Demo: updated"\ntags:{yaml_tags}\n---\n# Body\n'
saved = await note_service.update_note(note.note_id, markdown=markdown, tags=tags)
assert saved.tags == tags
job = await index_service.rebuild(IndexRebuildRequest())
assert job.status == 'completed'
restored = await note_service.get_note(note.note_id)
assert restored.tags == tags
assert restored.title == 'Demo: updated'
assert restored.markdown == markdown
asyncio.run(scenario())
+285
View File
@@ -0,0 +1,285 @@
"""Function Plot 的解析与静态 SVG 渲染测试。
覆盖 parser 的白名单表达式/隐式乘法/函数/常量拒绝项属性访问任意调用等
parse_source 指令与回退以及 render SVG 输出与 HTML 导出链路集成
"""
from __future__ import annotations
import asyncio
import math
import pytest
from app.contracts import ExportOptions
from app.export.exporters.html import HtmlExporter
from app.export.markdown import parse_document
from app.plot.parser import PlotParseError, evaluate, parse_expression, parse_source
from app.plot.render import render_svg
# --------------------------------------------------------------------------- #
# 表达式解析
# --------------------------------------------------------------------------- #
def test_parse_expression_power_and_implicit_multiplication() -> None:
assert evaluate(parse_expression("x^2"), 3) == 9.0
assert evaluate(parse_expression("2^3"), 0) == 8.0
assert evaluate(parse_expression("2x+1"), 3) == 7.0
assert evaluate(parse_expression("2(x+1)"), 3) == 8.0
assert evaluate(parse_expression("(x+1)(x-1)"), 3) == 8.0
def test_parse_expression_functions_and_constants() -> None:
assert evaluate(parse_expression("sin(0)"), 0) == 0.0
assert math.isclose(evaluate(parse_expression("sin(pi/2)"), 0), 1.0)
assert math.isclose(evaluate(parse_expression("ln(e)"), 0), 1.0)
assert evaluate(parse_expression("abs(-3)"), 0) == 3.0
def test_parse_expression_rejects_unsafe() -> None:
unsafe = [
"os.system('x')",
"__import__('os')",
"foo(x)",
"eval('x')",
"x[0]",
"x.attr",
"lambda: 1",
]
for expr in unsafe:
with pytest.raises(PlotParseError) as exc:
parse_expression(expr)
assert exc.value.diagnostic.code == "FUNCTION_PLOT_EXPRESSION_UNSAFE", expr
def test_parse_expression_syntax_error() -> None:
with pytest.raises(PlotParseError) as exc:
parse_expression("x +")
assert exc.value.diagnostic.code == "FUNCTION_PLOT_PARSE_FAILED"
# --------------------------------------------------------------------------- #
# fenced 源码解析
# --------------------------------------------------------------------------- #
def test_parse_source_directives() -> None:
result = parse_source("domain: 0, 10\nrange: -1, 1\nxlabel: x\ngrid: false\ny = x^2")
assert result.plot is not None
assert result.plot.domain == (0.0, 10.0)
assert result.plot.range == (-1.0, 1.0)
assert result.plot.axes.xlabel == "x"
assert result.plot.axes.grid is False
assert len(result.plot.expressions) == 1
assert result.plot.expressions[0].expression == "x^2"
def test_parse_source_bare_and_multi_expression() -> None:
result = parse_source("x^2\nsin(x)")
assert result.plot is not None
assert [e.expression for e in result.plot.expressions] == ["x^2", "sin(x)"]
def test_parse_source_unknown_directive_warns() -> None:
result = parse_source("foo: bar\ny = x")
assert result.plot is not None # 未知指令仅 warning,不阻断
assert any(d.severity == "warning" for d in result.diagnostics)
def test_parse_source_error_returns_no_plot() -> None:
result = parse_source("y = os.system('x')")
assert result.plot is None
assert any(d.severity == "error" for d in result.diagnostics)
# --------------------------------------------------------------------------- #
# SVG 渲染
# --------------------------------------------------------------------------- #
def test_render_svg_contains_polyline_and_axes() -> None:
plot = parse_source("y = x^2").plot
rendered = render_svg(plot)
svg = rendered.content
assert "<svg" in svg
assert "<polyline" in svg
assert "<line" in svg # 坐标轴/网格
assert "<script" not in svg
assert rendered.width == 640
assert rendered.height == 480
def test_render_svg_multiple_functions() -> None:
plot = parse_source("y = x^2\ny = sin(x)").plot
rendered = render_svg(plot)
assert rendered.content.count("<polyline") >= 2
def test_render_svg_labels() -> None:
plot = parse_source("xlabel: 时间\nylabel: 数值\ny = x").plot
rendered = render_svg(plot)
assert "时间" in rendered.content
assert "数值" in rendered.content
# --------------------------------------------------------------------------- #
# HTML 导出链路集成
# --------------------------------------------------------------------------- #
def test_html_exporter_embeds_function_plot_svg() -> None:
md = "```function-plot\ny = x^2\n```"
result = asyncio.run(HtmlExporter().export(parse_document(md), ExportOptions()))
html = result.content.decode("utf-8")
assert '<figure class="function-plot">' in html
assert "<svg" in html
assert "<polyline" in html
def test_html_exporter_function_plot_fallback_on_error() -> None:
md = "```function-plot\ny = os.system('x')\n```"
result = asyncio.run(HtmlExporter().export(parse_document(md), ExportOptions()))
html = result.content.decode("utf-8")
assert '<pre class="function-plot">' in html
assert "<svg" not in html
assert any("函数图像" in w for w in result.warnings)
# --------------------------------------------------------------------------- #
# 审阅回归:浮点刻度 / 求值异常 / 无效范围
# --------------------------------------------------------------------------- #
def test_render_svg_huge_domain_ticks_bounded() -> None:
# P1:巨大 domain 下步长受浮点精度限制无法推进,刻度应有限而非死循环
plot = parse_source("domain: 10000000000000000, 10000000000000002\nrange: -1, 1\ny = 0").plot
rendered = render_svg(plot)
assert "<svg" in rendered.content
def test_parse_expression_rejects_wrong_arg_count() -> None:
# P2sin() / sin(1, 2) 应在解析期拒绝,而非求值期 TypeError
with pytest.raises(PlotParseError):
parse_expression("sin()")
with pytest.raises(PlotParseError):
parse_expression("sin(1, 2)")
def test_render_svg_nonreal_samples_are_break_points() -> None:
# P2:x^0.5 在负数域产生复数,应作为断点处理,正半轴仍可绘制
plot = parse_source("domain: -4, 4\ny = x^0.5").plot
rendered = render_svg(plot)
assert "<polyline" in rendered.content
def test_render_svg_invalid_range_falls_back() -> None:
# P2:退化 range(1, 1)应丢弃并自动采样,而非 ZeroDivisionError
plot = parse_source("range: 1, 1\ny = x").plot
rendered = render_svg(plot)
assert "<polyline" in rendered.content
assert any("range" in w for w in rendered.warnings)
def test_render_svg_nonfinite_range_falls_back() -> None:
# P2:非有限 range 端点应丢弃并自动采样
plot = parse_source("range: nan, 1\ny = x").plot
rendered = render_svg(plot)
assert "<polyline" in rendered.content
def test_html_exporter_function_plot_render_error_falls_back(monkeypatch) -> None:
# P2:渲染异常不阻断整篇导出,回退占位并记 warning
import app.export.exporters.html as html_mod
def boom(plot):
raise RuntimeError("boom")
monkeypatch.setattr(html_mod, "render_svg", boom)
md = "```function-plot\ny = x\n```"
result = asyncio.run(HtmlExporter().export(parse_document(md), ExportOptions()))
html = result.content.decode("utf-8")
assert '<pre class="function-plot">' in html
assert any("渲染失败" in w for w in result.warnings)
# --------------------------------------------------------------------------- #
# 审阅回归:复杂表达式 / 极端数值范围
# --------------------------------------------------------------------------- #
def test_parse_expression_rejects_excessive_depth() -> None:
# P2:超长加法链的 AST 深度超限,应拒绝为 PlotParseError 而非触发 RecursionError
expr = "+".join(["1"] * 300)
with pytest.raises(PlotParseError) as exc:
parse_expression(expr)
assert exc.value.diagnostic.code == "FUNCTION_PLOT_EXPRESSION_UNSAFE"
def test_parse_expression_rejects_excessive_nodes() -> None:
# P2:浅层但节点超限的表达式(满二叉树)应被节点数上限拦截
def balanced(depth: int) -> str:
if depth == 0:
return "x"
return f"({balanced(depth - 1)}+{balanced(depth - 1)})"
expr = balanced(10) # ~2047 个节点,深度仅 ~10
with pytest.raises(PlotParseError) as exc:
parse_expression(expr)
assert exc.value.diagnostic.code == "FUNCTION_PLOT_EXPRESSION_UNSAFE"
def test_html_exporter_function_plot_deep_expression_falls_back() -> None:
# P2:复杂表达式解析失败应回退占位,不阻断整篇导出
expr = "+".join(["1"] * 300)
md = f"```function-plot\ny = {expr}\n```"
result = asyncio.run(HtmlExporter().export(parse_document(md), ExportOptions()))
html = result.content.decode("utf-8")
assert '<pre class="function-plot">' in html
assert "<svg" not in html
assert any("函数图像" in w for w in result.warnings)
def test_render_svg_extreme_domain_no_nan() -> None:
# P2:有限但跨度溢出的 domain 应回退安全范围,SVG 不得含 nan/inf
plot = parse_source("domain: -1e308, 1e308\nrange: -1, 1\ny = 0").plot
rendered = render_svg(plot)
assert "<svg" in rendered.content
assert "nan" not in rendered.content
assert "inf" not in rendered.content
assert any("domain" in w for w in rendered.warnings)
def test_render_svg_extreme_range_no_nan() -> None:
# P2:有限但跨度溢出的 range 应回退自动范围,SVG 不得含 nan/inf
plot = parse_source("domain: -1, 1\nrange: -1e308, 1e308\ny = x").plot
rendered = render_svg(plot)
assert "<svg" in rendered.content
assert "nan" not in rendered.content
assert "inf" not in rendered.content
assert any("range" in w for w in rendered.warnings)
# --------------------------------------------------------------------------- #
# 审阅回归:函数/图像数量上限
# --------------------------------------------------------------------------- #
def test_parse_source_rejects_too_many_expressions() -> None:
# P1:单块表达式数量超限应整块回退,避免海量采样求值
source = "\n".join(f"y = x + {i}" for i in range(50))
result = parse_source(source)
assert result.plot is None
assert any(d.code == "FUNCTION_PLOT_TOO_MANY_EXPRESSIONS" for d in result.diagnostics)
def test_html_exporter_limits_function_plot_count() -> None:
# P1:文档内函数图像数量超限,超出部分回退占位,不耗尽资源
blocks = "\n\n".join("```function-plot\ny = x\n```" for _ in range(20))
result = asyncio.run(HtmlExporter().export(parse_document(blocks), ExportOptions()))
html = result.content.decode("utf-8")
# 上限 16:前 16 个渲染为 SVG,其余 4 个回退占位
assert html.count('<figure class="function-plot">') == 16
assert html.count('<pre class="function-plot">') == 4
assert any("函数图像数量超过上限" in w for w in result.warnings)
def test_html_exporter_limits_total_plot_nodes(monkeypatch) -> None:
# P1:文档级累计 AST 节点预算超限后,后续图像回退占位,防止组合复杂度耗尽 CPU
import app.export.exporters.html as html_mod
monkeypatch.setattr(html_mod, "_MAX_TOTAL_PLOT_NODES", 5)
# 第一个图块 y=x(1 节点)在预算内;第二个图块 y=x+x+x+x(7 节点)累计超限
md = "```function-plot\ny = x\n```\n\n```function-plot\ny = x + x + x + x\n```"
result = asyncio.run(HtmlExporter().export(parse_document(md), ExportOptions()))
html = result.content.decode("utf-8")
assert html.count('<figure class="function-plot">') == 1
assert html.count('<pre class="function-plot">') == 1
assert any("累计复杂度" in w for w in result.warnings)
+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" },
+2
View File
@@ -27,6 +27,7 @@
- [后端接口契约](contracts/后端接口契约-开发版.md)
- [第二阶段接口契约](contracts/第二阶段接口契约-开发版.md)
- [前端页面需求说明](contracts/前端页面需求说明-开发版.md)
- [Tauri / Rust 桌面客户端需求说明(第三阶段,计划)](contracts/Tauri-Rust桌面客户端需求说明-第三阶段.md)
运行中的后端以 `/openapi.json` 为机器可读事实来源。接口契约用于描述设计意图、联调约束和实现状态;两者不一致时,应先确认代码行为,再在同一个 PR 中同步修正文档或实现。
@@ -37,6 +38,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)
@@ -2365,7 +2365,7 @@ Quality
└── Retrieval 参数调优
Content Output
├── Markdown → HTML / PDF / DOCX
├── Markdown → HTML(已实现)/ PDF / DOCX(暂缓)
├── Mermaid 编辑、预览与静态导出
└── Function Plot 解析、预览与静态导出
@@ -2376,7 +2376,7 @@ Frontend Extension
└── Plugin Settings UI
```
上述列表描述第二阶段技术范围。多模态、MCP Bridge、Plugin Command/Settings、RAG BenchmarkProvider 增强已经实现;Agent Benchmark、内容导出、Mermaid/函数图像完整编辑导出及社区主题包仍以各自开发说明的状态为准。每项功能必须继续经过现有 Service、Contract、Permission 和 Adapter 边界,不因 Demo 需要在 Vue 组件、Router 或 Agent Runtime 中直接绑定第三方协议。
上述列表描述第二阶段技术范围。多模态、MCP Bridge、Plugin Command/Settings、RAG BenchmarkProvider 增强与 Markdown → HTML 导出已经实现;Agent Benchmark、PDF/DOCX 导出、Mermaid/函数图像完整编辑导出及社区主题包仍以各自开发说明的状态为准。每项功能必须继续经过现有 Service、Contract、Permission 和 Adapter 边界,不因 Demo 需要在 Vue 组件、Router 或 Agent Runtime 中直接绑定第三方协议。
第三阶段处理:
@@ -0,0 +1,69 @@
# Tauri / Rust 桌面客户端需求说明(第三阶段)
状态:需求预留,尚未实现桌面客户端。本文不表示已有可调用的 Tauri Command 或可发布安装包。
基线日期:2026-09-05。
## 1. 目标与边界
第三阶段在现有 Vue 编辑器和 FastAPI AI Core 上接入 Tauri 2 / Rust Host,提供原生窗口、菜单、多 Vault 文件管理、安全凭据存储和 Sidecar 生命周期管理。
- Vue 负责页面、编辑事务、主题和交互状态;通过既有 Service 边界调用能力,不在组件中散布平台判断。
- Rust Host 负责系统能力、路径权限、原生菜单事件及受控进程生命周期。
- FastAPI AI Core 保留笔记解析、索引、检索、模型和 Agent 业务职责;同一文件不得同时由 Host 和 AI Core 无协调地写入。
- Web 模式保留可运行能力;桌面专有功能通过能力检测显隐,不用无响应按钮假装已实现。
架构依据:[技术栈说明](../architecture/AI笔记软件技术栈说明-团队版-v2.3.md)、[前端页面需求](前端页面需求说明-开发版.md)、[第二阶段接口契约](第二阶段接口契约-开发版.md)。
## 2. 顶部菜单与元数据格式一键导入
### 2.1 入口预留
桌面客户端顶部菜单栏的 **段落 → 导入为笔记属性…** 预留元数据格式导入功能,与标题、正文、列表等段落操作归组。它处理笔记内容中的元数据,不是主题包安装入口。
建议稳定的前端命令标识为 `editor.import-note-properties`,仅为设计标识,尚未注册为 Tauri IPC。原生菜单和编辑器命令面板应分发同一命令,避免两套转换逻辑。快捷键待第三阶段统一分配,不抢占现有编辑快捷键。
### 2.2 输入与转换规则
1. 无选区时识别当前笔记开头的属性块;有选区时只处理完整的属性块。无活动笔记、加载中、只读或冲突状态下禁用操作,并提供原因。
2. 支持标准 YAML frontmatter,以及历史编辑器产生的 `***` 开头、`title:` / `tags:` 字段、横线结尾的兼容形式。普通分隔线、代码块和包含冒号的正文不得被误判。
3. 将识别成功的内容规范化到文件头唯一的 `---` frontmatter 中,正文中的旧属性块仅在转换成功后移除。
4. 写作模式显示独立标题和可编辑标签;源码模式显示真实 `title` / `tags` 字段。标签必须进入现有保存和索引链路,能被标签筛选使用,不能只创建装饰性标签元素。
5. 保留未知属性及其类型,特别是 `embedding_local_only` 等行为配置。复杂 YAML 不得用正则拆分后静默丢弃;无法无损处理时说明原因,并保留原文供源码编辑。
6. 标签支持字符串、逗号分隔值和 YAML 列表,去重并保留顺序;中文、空格、转义字符须正确往返。空标签与删除标签有明确语义。
7. 已存在 frontmatter 时合并到同一个属性块;字段值冲突时展示差异供用户选择,禁止静默覆盖。重复执行不重复添加标签或属性块。
### 2.3 编辑与保存行为
- 无歧义转换一次菜单操作完成,并构成一个可撤销的编辑事务;转换失败不得改变文档或保存状态。
- 转换作用于当前内存文档,不先从磁盘读取旧内容覆盖未保存编辑。操作绑定文件标识和文档版本,异步处理期间切换文件或继续编辑时,应取消或重新校验。
- 成功后进入现有脏状态和自动保存流程。磁盘保存失败显示可重试状态,撤销/重做同时恢复正文、属性及标签。
- 属性块不进入正文大纲;标题跳转、引用定位仍使用完整原文件的正确偏移。写作/源码切换、保存后重开不得改变属性语义。
- 当前分支的 `frontend/src/features/editor/noteMetadata.ts` 仅是简单属性块展示与标签编辑基础;桌面阶段需补齐完整解析、合并冲突、单事务撤销和原生菜单分发,不能直接视为本节已经验收。
## 3. 桌面基础需求
| 模块 | 第三阶段要求 | 验收要点 |
| --- | --- | --- |
| 窗口与菜单 | 原生窗口控制、顶部菜单、焦点分发、关闭前未保存处理 | 菜单操作针对活动编辑器;多窗口不串文档;取消关闭保留编辑 |
| Vault 与文件系统 | 原生目录选择、多 Vault、最近打开、文件监听、路径规范化 | 未授权目录不可访问;重命名同步树和打开文件;外部修改不静默覆盖 |
| 写入与恢复 | 原子写入、版本/内容摘要校验、失败重试和异常退出恢复 | 不产生半写文件;并发保存不覆盖新版本;恢复流程可验证 |
| AI Core Sidecar | 启停、健康检查、日志、崩溃恢复、退出清理 | 不残留进程;不可用时显示原因;本地通信有访问控制 |
| 凭据 | 按既有架构接入 Stronghold/平台安全存储,制定开发凭据迁移方案 | 前端只持有凭据引用;不回显密钥;失败可恢复且不丢凭据 |
| MCP 与插件 | 按已冻结的 Host 沙箱契约落实文件、网络和子进程授权 | 沿用审批边界,不因桌面集成默认放开权限 |
| 主题 | 复用主题包校验;原生文件选择和下载适配共用检查流程 | 导入不自动启用;安装失败可恢复;ZIP 路径和资源限制继续有效 |
| 外观与导航 | 继承主题、代码配色、相对纸页宽度、文件/大纲切换 | 窗口缩放、高 DPI、深浅主题下无截断;键盘导航完整 |
| 发布 | Windows、macOS、Linux 构建与安装验证;签名、升级及回滚方案 | 未准备好签名和回滚前不启用自动更新;平台差异有说明 |
云同步服务、移动端和主题社区服务端不因本文自动纳入第三阶段必交范围;需要单独确认范围与接口。
## 4. 开发顺序与验收
1. 冻结 Host 能力与 Service 适配接口,明确每类数据的写入责任方及权限模型。
2. 接入窗口、菜单与编辑命令路由,完成“段落 → 导入为笔记属性…”的编辑器事务。
3. 接入 Vault、文件监听、冲突处理、Sidecar 和凭据迁移。
4. 完成平台测试、安装包和升级恢复验收。
元数据导入专项测试至少覆盖:标准/历史格式、普通正文误判、代码围栏、未知字段、复杂 YAML、同名字段冲突、重复导入、中文标签、撤销重做、未保存文档、处理中切换文件、保存失败、重开后标签检索,以及写作/源码模式的大纲与引用偏移。
第三阶段实现 PR 必须补充实际 Command 名称、输入输出类型、错误码、平台差异和测试证据;在此之前本文所有 Host 能力均标为计划实现。
@@ -68,11 +68,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 预览和导出复用 |
@@ -1080,6 +1080,8 @@ VECTOR_INDEX_REBUILD_REQUIRED
## 10. Export Service
> 实现状态:HTML 导出已实现(`backend/app/export/`),`pdf`/`docx` 暂缓——请求这两个格式返回 `EXPORT_FORMAT_UNSUPPORTED``function-plot` 已支持静态 SVG 内嵌(`backend/app/plot/`),解析或渲染失败时回退为源码占位并记录 warning;Mermaid 目前仍以占位代码块保留并记 warning。
### 10.1 创建导出任务
`POST /api/exports`,返回 `202 ExportJob`
@@ -1090,7 +1092,7 @@ VECTOR_INDEX_REBUILD_REQUIRED
"type": "note",
"note_id": "note_123"
},
"format": "pdf",
"format": "html",
"options": {
"theme_id": "light",
"include_title": true,
@@ -1101,7 +1103,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`
响应:
@@ -1109,11 +1111,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
@@ -1129,14 +1132,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"
@@ -1225,6 +1228,7 @@ EXPORT_RENDER_FAILED
EXPORT_UNSUPPORTED_CONTENT
EXPORT_JOB_NOT_FOUND
EXPORT_FILE_EXPIRED
EXPORT_OUTPUT_TOO_LARGE
```
---
+106
View File
@@ -0,0 +1,106 @@
# Export 开发说明
> 所属模块:Export Service(后端,负责人 yxx)。交付「多格式文档导出」:Markdown → HTML 的完整生命周期与 function-plot 静态 SVG 渲染;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``function_plot` 解析为静态 SVG 内嵌(解析/渲染失败或超限时回退 `<pre class="function-plot">` 占位并记 warning),`mermaid` 无法静态表达,渲染为占位 `<pre class="mermaid">` 并记 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)。
## 资源上限
为防止超大输入或海量函数图像耗尽内存/线程,导出链路内置以下上限:
- 输入源(`note``markdown`)统一限制 `MAX_MARKDOWN_CHARS = 200_000` 字符,超限返回 `EXPORT_OPTIONS_INVALID`
- 单个 `function-plot` 图块最多 16 条表达式,超限整块回退占位并记结构化诊断 `FUNCTION_PLOT_TOO_MANY_EXPRESSIONS`
- 单篇文档最多 16 个函数图像,超出部分回退占位并记 warning。
- 单篇文档累计函数图像 AST 节点预算 `_MAX_TOTAL_PLOT_NODES = 8000`,超出部分回退占位并记 warning,防止多图块 × 多表达式 × 深表达式组合在采样求值时长时间占满 CPU。
- 并发渲染上限 `MAX_CONCURRENT_RENDERS = 2`,解析/渲染是 CPU 密集工作,超出限额的任务在内存中排队等待渲染槽位,避免大量任务同时占满工作线程与内存。
- 最终产物大小上限 `MAX_EXPORT_BYTES = 20 MB`,超限任务标记 failed 并返回 `EXPORT_OUTPUT_TOO_LARGE`
## 错误码
错误分两类:**同步错误**在创建/查询请求的 HTTP 响应里直接返回对应状态码;**异步任务错误**在创建时已返回 `202`,后续轮询 `GET /api/exports/{job_id}` 仍返回 `200`,错误通过任务状态与 `error_code` 字段暴露,**不映射 HTTP 状态码**。
同步错误:
```text
EXPORT_SOURCE_NOT_FOUND 404
EXPORT_FORMAT_UNSUPPORTED 400
EXPORT_OPTIONS_INVALID 400
EXPORT_UNSUPPORTED_CONTENT 422(预留)
EXPORT_JOB_NOT_FOUND 404
EXPORT_FILE_EXPIRED 410
EXPORT_CAPACITY_EXCEEDED 429
```
异步任务错误(轮询返回 `200`,字段形如 `{"status": "failed", "error_code": "..."}`):
```text
EXPORT_RENDER_FAILED
EXPORT_OUTPUT_TOO_LARGE
```
## 测试
```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 内)。
- 函数图像交互预览与缩放(前端 JS Renderer 负责,后端仅提供静态 SVG)。
- 代码语法高亮(当前仅 CSS class 占位)。
+3 -1
View File
@@ -35,12 +35,14 @@
"@vueuse/core": "^14.0.0",
"codemirror": "^6.0.0",
"dompurify": "^3.4.14",
"fflate": "^0.8.3",
"marked": "^15.0.0",
"mermaid": "^11.17.2",
"pinia": "^4.0.0",
"shiki": "^4.4.3",
"vue": "^3.5.0",
"vue-router": "^5.0.0"
"vue-router": "^5.0.0",
"yaml": "^2.9.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
+38 -26
View File
@@ -80,6 +80,9 @@ importers:
dompurify:
specifier: ^3.4.14
version: 3.4.14
fflate:
specifier: ^0.8.3
version: 0.8.3
marked:
specifier: ^15.0.0
version: 15.0.12
@@ -97,14 +100,17 @@ importers:
version: 3.5.42(typescript@5.9.3)
vue-router:
specifier: ^5.0.0
version: 5.3.0(@vue/compiler-sfc@3.5.42)(esbuild@0.25.12)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)))(rollup@4.63.1)(vite@6.4.3(@types/node@22.20.1))(vue@3.5.42(typescript@5.9.3))
version: 5.3.0(@vue/compiler-sfc@3.5.42)(esbuild@0.25.12)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)))(rollup@4.63.1)(vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3))
yaml:
specifier: ^2.9.0
version: 2.9.0
devDependencies:
'@types/node':
specifier: ^22.0.0
version: 22.20.1
'@vitejs/plugin-vue':
specifier: ^5.0.0
version: 5.2.4(vite@6.4.3(@types/node@22.20.1))(vue@3.5.42(typescript@5.9.3))
version: 5.2.4(vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3))
'@vue/test-utils':
specifier: ^2.5.0
version: 2.5.0(@vue/compiler-dom@3.5.42)(@vue/server-renderer@3.5.42)(vue@3.5.42(typescript@5.9.3))
@@ -116,10 +122,10 @@ importers:
version: 5.9.3
vite:
specifier: ^6.0.0
version: 6.4.3(@types/node@22.20.1)
version: 6.4.3(@types/node@22.20.1)(yaml@2.9.0)
vitest:
specifier: ^4.1.11
version: 4.1.11(@types/node@22.20.1)(happy-dom@20.11.15)(vite@6.4.3(@types/node@22.20.1))
version: 4.1.11(@types/node@22.20.1)(happy-dom@20.11.15)(vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0))
vue-tsc:
specifier: ^2.0.0
version: 2.2.12(typescript@5.9.3)
@@ -954,11 +960,6 @@ packages:
'@volar/typescript@2.4.15':
resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
'@vue-macros/common@3.1.4':
resolution: {integrity: sha512-/5Fv+6DgIcM9ajY05ZmKBv+LMX1M9A0X+IUwDRVdt67ciw8OV9bvG2r34p3RiEadlsQybjhKPRKNXDC8Bp23cw==}
@@ -1392,6 +1393,9 @@ packages:
picomatch:
optional: true
fflate@0.8.3:
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -2149,6 +2153,11 @@ packages:
utf-8-validate:
optional: true
yaml@2.9.0:
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
engines: {node: '>= 14.6'}
hasBin: true
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
@@ -3258,9 +3267,9 @@ snapshots:
d3-selection: 3.0.0
d3-transition: 3.0.1(d3-selection@3.0.0)
'@vitejs/plugin-vue@5.2.4(vite@6.4.3(@types/node@22.20.1))(vue@3.5.42(typescript@5.9.3))':
'@vitejs/plugin-vue@5.2.4(vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3))':
dependencies:
vite: 6.4.3(@types/node@22.20.1)
vite: 6.4.3(@types/node@22.20.1)(yaml@2.9.0)
vue: 3.5.42(typescript@5.9.3)
'@vitest/expect@4.1.11':
@@ -3272,13 +3281,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.1
'@vitest/mocker@4.1.11(vite@6.4.3(@types/node@22.20.1))':
'@vitest/mocker@4.1.11(vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0))':
dependencies:
'@vitest/spy': 4.1.11
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 6.4.3(@types/node@22.20.1)
vite: 6.4.3(@types/node@22.20.1)(yaml@2.9.0)
'@vitest/pretty-format@4.1.11':
dependencies:
@@ -3310,13 +3319,11 @@ snapshots:
'@volar/source-map@2.4.15': {}
'@volar/typescript@2.4.15(typescript@5.9.3)':
'@volar/typescript@2.4.15':
dependencies:
'@volar/language-core': 2.4.15
path-browserify: 1.0.1
vscode-uri: 3.2.0
optionalDependencies:
typescript: 5.9.3
'@vue-macros/common@3.1.4(vue@3.5.42(typescript@5.9.3))':
dependencies:
@@ -3805,6 +3812,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.7
fflate@0.8.3: {}
fsevents@2.3.3:
optional: true
@@ -4679,7 +4688,7 @@ snapshots:
pathe: 2.0.3
picomatch: 4.0.7
unplugin@3.3.0(esbuild@0.25.12)(rollup@4.63.1)(vite@6.4.3(@types/node@22.20.1)):
unplugin@3.3.0(esbuild@0.25.12)(rollup@4.63.1)(vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0)):
dependencies:
'@jridgewell/remapping': 2.3.5
picomatch: 4.0.7
@@ -4687,7 +4696,7 @@ snapshots:
optionalDependencies:
esbuild: 0.25.12
rollup: 4.63.1
vite: 6.4.3(@types/node@22.20.1)
vite: 6.4.3(@types/node@22.20.1)(yaml@2.9.0)
uuid@14.0.2: {}
@@ -4701,7 +4710,7 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite@6.4.3(@types/node@22.20.1):
vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0):
dependencies:
esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.7)
@@ -4712,11 +4721,12 @@ snapshots:
optionalDependencies:
'@types/node': 22.20.1
fsevents: 2.3.3
yaml: 2.9.0
vitest@4.1.11(@types/node@22.20.1)(happy-dom@20.11.15)(vite@6.4.3(@types/node@22.20.1)):
vitest@4.1.11(@types/node@22.20.1)(happy-dom@20.11.15)(vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0)):
dependencies:
'@vitest/expect': 4.1.11
'@vitest/mocker': 4.1.11(vite@6.4.3(@types/node@22.20.1))
'@vitest/mocker': 4.1.11(vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0))
'@vitest/pretty-format': 4.1.11
'@vitest/runner': 4.1.11
'@vitest/snapshot': 4.1.11
@@ -4733,7 +4743,7 @@ snapshots:
tinyexec: 1.3.0
tinyglobby: 0.2.17
tinyrainbow: 3.1.1
vite: 6.4.3(@types/node@22.20.1)
vite: 6.4.3(@types/node@22.20.1)(yaml@2.9.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.20.1
@@ -4745,7 +4755,7 @@ snapshots:
vue-component-type-helpers@3.3.11: {}
vue-router@5.3.0(@vue/compiler-sfc@3.5.42)(esbuild@0.25.12)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)))(rollup@4.63.1)(vite@6.4.3(@types/node@22.20.1))(vue@3.5.42(typescript@5.9.3)):
vue-router@5.3.0(@vue/compiler-sfc@3.5.42)(esbuild@0.25.12)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)))(rollup@4.63.1)(vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3)):
dependencies:
'@vue-macros/common': 3.1.4(vue@3.5.42(typescript@5.9.3))
'@vue/devtools-api': 8.2.1
@@ -4761,13 +4771,13 @@ snapshots:
picomatch: 4.0.7
scule: 1.3.0
tinyglobby: 0.2.17
unplugin: 3.3.0(esbuild@0.25.12)(rollup@4.63.1)(vite@6.4.3(@types/node@22.20.1))
unplugin: 3.3.0(esbuild@0.25.12)(rollup@4.63.1)(vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0))
unplugin-utils: 0.3.2
vue: 3.5.42(typescript@5.9.3)
optionalDependencies:
'@vue/compiler-sfc': 3.5.42
pinia: 4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3))
vite: 6.4.3(@types/node@22.20.1)
vite: 6.4.3(@types/node@22.20.1)(yaml@2.9.0)
transitivePeerDependencies:
- '@farmfe/core'
- '@rspack/core'
@@ -4780,7 +4790,7 @@ snapshots:
vue-tsc@2.2.12(typescript@5.9.3):
dependencies:
'@volar/typescript': 2.4.15(typescript@5.9.3)
'@volar/typescript': 2.4.15
'@vue/language-core': 2.2.12(typescript@5.9.3)
typescript: 5.9.3
@@ -4807,4 +4817,6 @@ snapshots:
ws@8.21.3: {}
yaml@2.9.0: {}
zwitch@2.0.4: {}
@@ -0,0 +1,268 @@
theme_id: paper-moments
name: 纸间时光 · Paper Moments
version: 1.4.1
author: NotesAgent
description: 奶油纸张、手帐虚线与粉蓝胶带,把每天的灵感好好收藏。
min_app_version: 0.2.0
is_dark: false
css_entry: theme.css
license: MIT
---
[data-theme="paper-moments"] {
color-scheme: light;
--color-background-primary: #faf7ee;
--color-background-secondary: #f3eee3;
--color-background-tertiary: #ece5d7;
--color-background-hover: #f1e5da;
--color-background-active: #ecdbd2;
--color-background-overlay: rgba(65, 55, 45, .35);
--color-surface-primary: #fffdf5;
--color-surface-secondary: #f7f1e5;
--color-surface-elevated: #fffdf7;
--color-text-primary: #493f35;
--color-text-secondary: #6e6053;
--color-text-tertiary: #7d6b5e;
--color-text-inverse: #fffdf5;
--color-text-link: #875343;
--color-text-disabled: #9c9081;
--color-accent-primary: #875343;
--color-accent-primary-hover: #704334;
--color-accent-primary-active: #5e382b;
--color-accent-secondary: #a77a67;
--color-accent-soft: #f3e1d8;
--color-accent-soft-hover: #ecd3c7;
--color-border-default: #b5a693;
--color-border-subtle: #ded5c5;
--color-border-focus: #875343;
--color-border-disabled: #e2dacc;
--color-success: #526849;
--color-success-soft: #e5ecd9;
--color-warning: #806323;
--color-warning-soft: #faf0cb;
--color-error: #a0423c;
--color-error-soft: #f8e2dc;
--color-info: #456671;
--color-info-soft: #e1eef0;
--color-markdown-grid: #ded5c5;
--color-markdown-marker: #a77a67;
--color-markdown-table-header: #eee7d7;
--shadow-sm: 2px 3px 0 #e5ded0;
--shadow-md: 3px 4px 0 #dae5df, 6px 7px 0 #f0d8cf;
--shadow-lg: 4px 5px 0 #dae5df, 8px 9px 0 #f0d8cf;
--shadow-xl: 5px 6px 0 #dae5df, 10px 11px 0 #f0d8cf, 0 18px 42px #493f3520;
}
[data-theme="paper-moments"] body,
[data-theme="paper-moments"] .feature-page,
[data-theme="paper-moments"] .main-content {
background-color: var(--color-background-primary);
background-image: radial-gradient(#b5a69350 .8px, transparent .8px);
background-size: 20px 20px;
}
[data-theme="paper-moments"] .feature-header {
flex-wrap: wrap;
position: relative;
padding: 24px;
margin-top: 12px;
border: 1px solid #685949;
outline: 1px dashed #b5a693;
outline-offset: -8px;
border-radius: 12px 5px 12px 5px;
background: #fffdf5;
box-shadow: var(--shadow-md);
}
[data-theme="paper-moments"] .feature-header::before,
[data-theme="paper-moments"] .editor-preview::before {
content: '';
position: absolute;
top: -10px;
left: 42%;
width: 86px;
height: 22px;
background: repeating-linear-gradient(45deg, #c5dfe0b0 0 8px, #daeceba0 8px 16px);
transform: rotate(-3deg);
pointer-events: none;
}
[data-theme="paper-moments"] .feature-header h1,
[data-theme="paper-moments"] .panel-title,
[data-theme="paper-moments"] .preview-heading h3 {
color: #875343;
font-family: Georgia, 'Noto Serif SC', 'Songti SC', SimSun, serif;
letter-spacing: .04em;
}
[data-theme="paper-moments"] .panel,
[data-theme="paper-moments"] .item-card {
border-color: #b5a693;
border-radius: 8px;
box-shadow: var(--shadow-sm);
}
[data-theme="paper-moments"] .theme-card:nth-child(3n + 1) { background: #f8e9e3; }
[data-theme="paper-moments"] .theme-card:nth-child(3n + 2) { background: #e8f0f0; }
[data-theme="paper-moments"] .theme-card:nth-child(3n) { background: #fbf3d8; }
[data-theme="paper-moments"] .editor-preview {
position: relative;
border: 1px solid #685949;
border-radius: 4px 14px 4px 10px;
background-color: #fffef8;
background-image: linear-gradient(90deg, transparent 20px, #e9cfc780 20px 22px, transparent 22px), repeating-linear-gradient(transparent 0 31px, #b6c7bd55 31px 32px);
box-shadow: 4px 5px 0 #e3e9d7;
}
[data-theme="paper-moments"] .editor-preview::before {
background: repeating-linear-gradient(45deg, #e7bcb3b0 0 8px, #f2d4cba0 8px 16px);
}
[data-theme="paper-moments"] .modal { border-color: #685949; border-radius: 12px; }
[data-theme="paper-moments"] .upload-area { background: #fbf6e7; }
[data-theme="paper-moments"] .button-secondary { background: #fff9e5; }
[data-theme="paper-moments"] .workspace-view,
[data-theme="paper-moments"] .visual-editor {
background: radial-gradient(#b5a69355 .8px, transparent .8px) 0 0 / 20px 20px #f3eee3;
}
[data-theme="paper-moments"] .secondary-sidebar {
background: #fff9e9;
border-right: 1px dashed #b5a693;
}
[data-theme="paper-moments"] .primary-sidebar { background: #f1e9dc; }
[data-theme="paper-moments"] .file-tree-panel { background: #fff9e9; }
[data-theme="paper-moments"] .workspace-tabs { background: #e5eeee; border-bottom: 1px dashed #b5a693; }
[data-theme="paper-moments"] .workspace-tabs button[aria-selected="true"] { background: #f8e9e3; color: #875343; box-shadow: inset 0 -2px #a77a67; }
[data-theme="paper-moments"] .outline-filename { border-bottom: 1px dashed #b5a693; }
[data-theme="paper-moments"] .file-tree-panel .toolbar,
[data-theme="paper-moments"] .sidebar-header { background: #e5eeee; border-bottom: 1px dashed #b5a693; }
[data-theme="paper-moments"] .editor-header { background: #f8e9e3; border-bottom: 1px solid #b5a693; }
[data-theme="paper-moments"] .markdown-toolbar { background: #fff9e9; border-bottom: 1px dashed #b5a693; }
[data-theme="paper-moments"] .milkdown-host { padding: 30px 24px 40px; }
[data-theme="paper-moments"] .milkdown-host .milkdown { background: transparent; }
[data-theme="paper-moments"] .visual-editor .milkdown-host .ProseMirror {
width: 90%;
max-width: none;
position: relative;
min-height: calc(100vh - 220px);
padding: 44px 40px 60px 52px;
border: 1px solid #685949;
border-radius: 8px 16px 8px 8px;
outline: 1px dashed #c5b9a7;
outline-offset: -10px;
background: linear-gradient(90deg, transparent 32px, #e9cfc7 32px 34px, transparent 34px), #fffef8;
box-shadow: 6px 6px 0 #d8e6e2, 12px 12px 0 #f0d8cf;
}
[data-theme="paper-moments"] .milkdown-host .ProseMirror::before {
content: '';
position: absolute;
top: -11px;
left: calc(50% - 48px);
width: 96px;
height: 24px;
background: repeating-linear-gradient(45deg, #e7bcb3c0 0 8px, #f2d4cbc0 8px 16px);
transform: rotate(-3deg);
pointer-events: none;
}
[data-theme="paper-moments"] .milkdown-host .ProseMirror > p {
background-image: repeating-linear-gradient(transparent 0 calc(1lh - 1px), #b6c7bd55 calc(1lh - 1px) 1lh);
}
[data-theme="paper-moments"] .milkdown-host .ProseMirror > :is(h1, h2, h3) { color: #875343; }
[data-theme="paper-moments"] .editor-pane.source { margin: 20px; width: calc(100% - 40px); border: 1px solid #b5a693; border-radius: 8px; background: #fffef8; box-shadow: var(--shadow-md); }
@media (max-width: 720px) {
[data-theme="paper-moments"] .milkdown-host { padding: 20px 12px 28px; }
[data-theme="paper-moments"] .visual-editor .milkdown-host .ProseMirror { width: 100%; padding: 30px 18px 40px 38px; }
}
/* Warm neutral surfaces preserve the contrast of the selected Shiki palette. */
[data-theme="paper-moments"][data-code-theme="github-light"] {
--color-code-background: #f1ecdf;
--color-code-text: #302b25;
--color-code-muted: #6d6256;
--color-code-border: #b1a18b;
}
[data-theme="paper-moments"][data-code-theme="github-dark"] {
--color-code-background: #282723;
--color-code-text: #f1e9da;
--color-code-muted: #bdb19f;
--color-code-border: #786b59;
}
[data-theme="paper-moments"] .milkdown-host .milkdown-code-block {
position: relative;
padding-top: 34px;
padding-bottom: 30px;
border-color: var(--color-code-border);
box-shadow: 3px 4px 0 #d8cebd;
}
[data-theme="paper-moments"] .milkdown-code-block::before {
content: '';
position: absolute;
top: 15px;
left: 18px;
width: 10px;
height: 10px;
border-radius: 50%;
background: #c77768;
box-shadow: 18px 0 0 #c9a65d, 36px 0 0 #819b75;
pointer-events: none;
}
[data-theme="paper-moments"] .milkdown-code-block::after {
content: attr(data-language-label);
position: absolute;
right: 18px;
bottom: 9px;
max-width: calc(100% - 36px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-code-muted);
font: 600 12px/1.4 var(--font-ui-mono);
pointer-events: none;
}
[data-theme="paper-moments"] .milkdown-code-block .tools { margin-left: 72px; }
[data-theme="paper-moments"] .milkdown-code-block .cm-activeLine,
[data-theme="paper-moments"] .milkdown-code-block .cm-activeLineGutter { background: color-mix(in srgb, var(--color-code-text) 7%, transparent); }
[data-theme="paper-moments"] .note-metadata {
position: relative;
width: 90%;
margin: 8px auto 30px;
padding: 24px 30px;
border: 1px solid #887460;
border-radius: 8px 14px 8px 8px;
outline: 1px dashed #c5b9a7;
outline-offset: -8px;
background: linear-gradient(110deg, #fffdf5, #fbf5e4);
box-shadow: 4px 5px 0 #d8e6e2, 8px 9px 0 #f0d8cf;
}
[data-theme="paper-moments"] .note-metadata::before {
content: '';
position: absolute;
top: -10px;
right: 36px;
width: 78px;
height: 22px;
background: repeating-linear-gradient(45deg, #c5dfe0c0 0 8px, #daecebb0 8px 16px);
transform: rotate(3deg);
pointer-events: none;
}
[data-theme="paper-moments"] .metadata-caption { color: #806b58; letter-spacing: .12em; }
[data-theme="paper-moments"] .note-metadata h1 {
margin: 12px 0 18px;
color: #875343;
font-family: Georgia, 'Noto Serif SC', 'Songti SC', SimSun, serif;
font-size: clamp(20px, 2vw, 28px);
line-height: 1.4;
}
[data-theme="paper-moments"] .metadata-tags { padding-top: 14px; border-top: 1px dashed #c5b9a7; gap: 8px; }
[data-theme="paper-moments"] .metadata-tag { border: 1px solid #d6b5a8; border-radius: 5px; background: #f5e3da; color: #704b3d; }
[data-theme="paper-moments"] .metadata-tag:nth-of-type(2n + 1) { border-color: #b5cdcf; background: #e5eeee; color: #456671; }
[data-theme="paper-moments"] .metadata-tag button { border-radius: 3px; cursor: pointer; }
[data-theme="paper-moments"] .metadata-tag button:hover { background: #ffffff80; }
[data-theme="paper-moments"] .metadata-tags input { border-color: #b5a693; background: #fffdf580; }
[data-theme="paper-moments"] .metadata-tags form button { padding: 4px 10px; border: 1px solid #b5a693; border-radius: 5px; background: #f7edce; color: #704b3d; cursor: pointer; }
[data-theme="paper-moments"] .metadata-tags button:focus-visible { outline: 2px solid #875343; outline-offset: 2px; }
@media (max-width: 720px) {
[data-theme="paper-moments"] .note-metadata { width: 100%; padding: 22px 18px; }
}
@@ -11,11 +11,11 @@ let renderVersion = 0
const diagramTheme = computed<'light' | 'dark'>(() => (themeStore.isDark ? 'dark' : 'light'))
// Mermaid SVG CSS
watch([() => props.source, diagramTheme], async ([source, theme]) => {
watch([() => props.source, diagramTheme, () => themeStore.currentThemeId], async ([source, theme]) => {
const version = ++renderVersion
const result = await renderMarkdown(source, { theme })
if (version === renderVersion) html.value = result
}, { immediate: true })
}, { immediate: true, flush: 'post' })
</script>
<template>
@@ -13,7 +13,7 @@ const emit = defineEmits<{
(e: 'rendered', info: { width: number; height: number }): void
}>()
const { mermaidTheme } = useMermaidTheme()
const { mermaidTheme, themeId } = useMermaidTheme()
const svgHtml = ref('')
const isLoading = ref(true)
const hasError = ref(false)
@@ -52,7 +52,7 @@ async function doRender() {
onMounted(doRender)
watch(() => [props.source, mermaidTheme.value], () => { scale.value = 1; doRender() })
watch(() => [props.source, mermaidTheme.value, themeId.value], () => { scale.value = 1; doRender() }, { flush: 'post' })
function zoomIn() { scale.value = Math.min(scale.value * 1.2, 5) }
function zoomOut() { scale.value = Math.max(scale.value / 1.2, 0.2) }
@@ -0,0 +1,23 @@
// @vitest-environment happy-dom
import { expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import SecondarySidebar from './SecondarySidebar.vue'
it('resizes by keyboard, clamps bounds and restores the saved width', async () => {
localStorage.removeItem('workspace-sidebar-width')
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] })
await router.push('/')
const options = { props: { component: 'file-tree' }, global: { plugins: [router], stubs: { FileTreePanel: true } } }
let wrapper = mount(SecondarySidebar, options)
await wrapper.get('[role="separator"]').trigger('keydown', { key: 'ArrowRight' })
expect(localStorage.getItem('workspace-sidebar-width')).toBe('288')
wrapper.unmount()
wrapper = mount(SecondarySidebar, options)
await wrapper.vm.$nextTick()
expect(wrapper.get('aside').attributes('style')).toContain('288px')
await wrapper.get('[role="separator"]').trigger('keydown', { key: 'Home' })
expect(wrapper.get('aside').attributes('style')).toContain('200px')
wrapper.unmount()
localStorage.removeItem('workspace-sidebar-width')
})
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import FileTreePanel from '@/features/workspace/FileTreePanel.vue'
import ConversationListPanel from '@/features/chat/ConversationListPanel.vue'
import RunListPanel from '@/features/agent/RunListPanel.vue'
@@ -15,6 +15,40 @@ const props = defineProps<{
const route = useRoute()
const routeName = computed(() => route.name as string)
const sidebar = ref<HTMLElement | null>(null)
const width = ref(272)
const maxWidth = ref(520)
let dragging = false
function saveWidth() { try { localStorage.setItem('workspace-sidebar-width', String(width.value)) } catch { /* Keep resizing available when storage is unavailable. */ } }
function clampWidth(value: number) { return Math.max(200, Math.min(maxWidth.value, value)) }
function updateBounds() {
maxWidth.value = Math.max(200, Math.min(520, window.innerWidth - (sidebar.value?.getBoundingClientRect().left ?? 0) - 320))
width.value = clampWidth(width.value)
}
function beginResize(event: PointerEvent) {
if (event.button !== 0) return
event.preventDefault()
updateBounds()
dragging = true
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
}
function resize(event: PointerEvent) {
if (dragging) width.value = clampWidth(event.clientX - (sidebar.value?.getBoundingClientRect().left ?? 0))
}
function endResize() { if (dragging) { dragging = false; saveWidth() } }
function resizeWithKeyboard(event: KeyboardEvent) {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return
event.preventDefault()
updateBounds()
width.value = event.key === 'Home' ? 200 : event.key === 'End' ? maxWidth.value : clampWidth(width.value + (event.key === 'ArrowLeft' ? -16 : 16))
saveWidth()
}
onMounted(() => {
try { const saved = Number(localStorage.getItem('workspace-sidebar-width')); if (saved >= 200 && Number.isFinite(saved)) width.value = saved } catch { /* Use default width. */ }
updateBounds()
window.addEventListener('resize', updateBounds)
})
onBeforeUnmount(() => { endResize(); window.removeEventListener('resize', updateBounds) })
const sidebarTitle = computed(() => {
const titles: Record<string, string> = {
@@ -32,15 +66,15 @@ const showSkillToggle = computed(() => routeName.value === 'skills' || routeName
</script>
<template>
<aside class="secondary-sidebar">
<div class="sidebar-header">
<aside ref="sidebar" class="secondary-sidebar" :style="component === 'file-tree' ? { width: `${width}px` } : undefined">
<div v-if="component !== 'file-tree'" class="sidebar-header">
<h3 class="sidebar-title">{{ sidebarTitle }}</h3>
<div v-if="showSkillToggle" class="sidebar-tabs">
<router-link to="/extensions/skills" class="tab" :class="{ active: routeName === 'skills' }">Skill</router-link>
<router-link to="/extensions/plugins" class="tab" :class="{ active: routeName === 'plugins' }">Plugin</router-link>
</div>
</div>
<div class="sidebar-content">
<div class="sidebar-content" :class="{ 'file-sidebar-content': component === 'file-tree' }">
<FileTreePanel v-if="component === 'file-tree'" />
<ConversationListPanel v-else-if="component === 'conversation-list'" />
<RunListPanel v-else-if="component === 'run-list'" />
@@ -48,11 +82,13 @@ const showSkillToggle = computed(() => routeName.value === 'skills' || routeName
<TaskFiltersPanel v-else-if="component === 'task-filters'" />
<ExtensionListPanel v-else-if="component === 'extension-list'" />
</div>
<div v-if="component === 'file-tree'" class="sidebar-resizer" role="separator" aria-orientation="vertical" :aria-label="t('调整文件侧栏宽度', 'Resize file sidebar')" :aria-valuenow="width" :aria-valuemin="200" :aria-valuemax="maxWidth" tabindex="0" @pointerdown="beginResize" @pointermove="resize" @pointerup="endResize" @pointercancel="endResize" @lostpointercapture="endResize" @keydown="resizeWithKeyboard" @dblclick="width = clampWidth(272); saveWidth()" />
</aside>
</template>
<style scoped>
.secondary-sidebar {
position: relative;
width: var(--sidebar-secondary-width);
background: var(--color-surface-secondary);
border-right: 1px solid var(--color-border-default);
@@ -111,5 +147,8 @@ const showSkillToggle = computed(() => routeName.value === 'skills' || routeName
overflow-x: hidden;
scrollbar-gutter: stable;
}
.sidebar-resizer { position: absolute; top: 0; bottom: 0; right: -3px; width: 6px; z-index: 20; cursor: col-resize; touch-action: none; }
.sidebar-resizer:hover, .sidebar-resizer:focus-visible { background: var(--color-accent-secondary); outline: none; }
.file-sidebar-content { min-height: 0; overflow: hidden; scrollbar-gutter: auto; }
</style>
+11 -1
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useEditorStore } from '@/stores/editor'
import { useSettingsStore } from '@/stores/settings'
import { useThemeStore } from '@/stores/theme'
@@ -7,6 +8,15 @@ import VisualMarkdownEditor from './VisualMarkdownEditor.vue'
const editorStore = useEditorStore()
const settingsStore = useSettingsStore()
const themeStore = useThemeStore()
const sourceEditor = ref<HTMLTextAreaElement | null>(null)
watch(() => editorStore.headingRequest, request => {
const input = sourceEditor.value
if (!request || !input || request.path !== editorStore.currentFilePath) return
input.focus()
input.setSelectionRange(request.offset, request.offset)
const lines = input.value.slice(0, request.offset).split('\n').length - 1
input.scrollTop = lines * (parseFloat(getComputedStyle(input).lineHeight) || 24)
})
function updateContent(event: Event) {
editorStore.updateContent((event.target as HTMLTextAreaElement).value)
editorStore.scheduleAutoSave(settingsStore.autoSaveInterval)
@@ -16,7 +26,7 @@ function updateContent(event: Event) {
<template>
<VisualMarkdownEditor v-if="editorStore.mode === 'wysiwyg'" :key="`${editorStore.currentFilePath ?? 'empty'}:${themeStore.resolvedCodeBlockTheme}:${settingsStore.language}`"
:initial-content="editorStore.content" />
<textarea v-else class="editor-pane source" :value="editorStore.content" :spellcheck="settingsStore.spellCheck"
<textarea v-else ref="sourceEditor" class="editor-pane source" :value="editorStore.content" :spellcheck="settingsStore.spellCheck"
:lang="settingsStore.language" :aria-label="settingsStore.language === 'en' ? 'Markdown source editor' : 'Markdown 源码编辑器'" @input="updateContent" />
</template>
@@ -9,6 +9,10 @@ import { indentWithTab } from '@codemirror/commands'
import { shikiEditorTheme, shikiLanguages, renderCodeLanguage } from './shikiCodeMirror'
import './language-icons.css'
import { installLanguagePickerPopover } from './languagePickerPopover'
import { installCodeBlockLabels } from './codeBlockLabels'
import { createMermaidPreview } from './mermaidPreview'
import { splitNoteMetadata, updateMetadataTags } from './noteMetadata'
import { getMarkdown } from '@milkdown/kit/utils'
import {
createCodeBlockCommand,
toggleEmphasisCommand,
@@ -33,6 +37,22 @@ import '@milkdown/crepe/theme/common/style.css'
import '@milkdown/crepe/theme/frame.css'
const props = defineProps<{ initialContent: string }>()
const metadata = ref(splitNoteMetadata(props.initialContent))
const tagDraft = ref('')
function setTags(tags: string[]) {
if (!metadata.value || !crepe) return
const prefix = updateMetadataTags(metadata.value, tags)
const body = crepe.editor.action(getMarkdown())
metadata.value = splitNoteMetadata(prefix + body)
editorStore.updateContent(prefix + body)
editorStore.scheduleAutoSave(settingsStore.autoSaveInterval)
}
function addTags() {
const tags = tagDraft.value.split(/[,]/).map(tag => tag.trim()).filter(tag => tag && !/[\r\n"\\]/.test(tag))
if (!tags.length || !metadata.value) return
setTags([...metadata.value.tags, ...tags])
tagDraft.value = ''
}
const editorStore = useEditorStore()
const settingsStore = useSettingsStore()
const themeStore = useThemeStore()
@@ -41,6 +61,23 @@ const loading = ref(true)
const fontSizeInput = ref(16)
let crepe: Crepe | null = null
let disposeLanguagePicker: (() => void) | undefined
let disposeCodeLabels: (() => void) | undefined
const diagramPreviews = new Map<string, { source: string; apply: (value: HTMLElement) => void }>()
function renderDiagram(source: string, apply: (value: HTMLElement) => void) {
for (const [id, entry] of diagramPreviews) {
if (entry.apply === apply) diagramPreviews.delete(id)
}
const element = createMermaidPreview(source, themeStore.isDark, apply)
diagramPreviews.set(element.id, { source, apply })
return element
}
watch(() => themeStore.currentThemeId, () => {
const current = [...diagramPreviews.entries()]
diagramPreviews.clear()
for (const [id, entry] of current) {
if (editorRoot.value?.querySelector(`[id="${id}"]`)) entry.apply(renderDiagram(entry.source, entry.apply))
}
}, { flush: 'post' })
function applyProofingPreferences() {
const editable = editorRoot.value?.querySelector<HTMLElement>('.ProseMirror')
@@ -120,12 +157,14 @@ function applyFontSizeValue() {
onMounted(async () => {
crepe = new Crepe({
root: editorRoot.value,
defaultValue: props.initialContent,
defaultValue: metadata.value?.body ?? props.initialContent,
features: { [Crepe.Feature.TopBar]: false },
featureConfigs: {
[Crepe.Feature.Placeholder]: { text: t('开始记录你的想法…', 'Start writing your thoughts…') },
[Crepe.Feature.CodeMirror]: {
previewOnlyByDefault: false,
previewOnlyByDefault: true,
previewToggleText: previewOnly => previewOnly ? t('编辑', 'Edit') : t('预览', 'Preview'),
previewLabel: t('图表预览', 'Preview'),
searchPlaceholder: t('搜索语言', 'Search languages'),
noResultText: t('没有匹配的语言', 'No matching language'),
copyText: t('复制', 'Copy'),
@@ -182,26 +221,44 @@ onMounted(async () => {
...config,
languages: shikiLanguages(themeStore.resolvedCodeBlockTheme),
renderLanguage: renderCodeLanguage,
renderPreview: (language, content, applyPreview) => language.trim().toLowerCase() === 'mermaid'
? renderDiagram(content, applyPreview)
: config.renderPreview(language, content, applyPreview),
extensions: [basicSetup, keymap.of([indentWithTab]), shikiEditorTheme(themeStore.resolvedCodeBlockTheme)],
})))
crepe.editor.use(fontSizeMarkdownPlugin)
crepe.on((listener) => {
listener.markdownUpdated((_ctx, markdown, previousMarkdown) => {
// /
if (markdown === previousMarkdown || markdown === editorStore.content) return
editorStore.updateContent(markdown)
const fullMarkdown = (metadata.value?.prefix ?? '') + markdown
if (markdown === previousMarkdown || fullMarkdown === editorStore.content) return
editorStore.updateContent(fullMarkdown)
editorStore.scheduleAutoSave(settingsStore.autoSaveInterval)
})
})
await crepe.create()
if (editorRoot.value) disposeLanguagePicker = installLanguagePickerPopover(editorRoot.value)
if (editorRoot.value) disposeCodeLabels = installCodeBlockLabels(editorRoot.value)
applyProofingPreferences()
loading.value = false
})
watch([() => settingsStore.spellCheck, () => settingsStore.language], applyProofingPreferences)
watch(() => editorStore.headingRequest, request => {
if (!request || request.path !== editorStore.currentFilePath || !crepe) return
crepe.editor.action(ctx => {
const view = ctx.get(editorViewCtx)
let index = 0
view.state.doc.forEach((node, offset) => {
if (node.type.name !== 'heading') return
if (index++ !== request.index) return
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, offset + 1)).scrollIntoView())
view.focus()
})
})
})
onBeforeUnmount(() => { disposeLanguagePicker?.(); void crepe?.destroy() })
onBeforeUnmount(() => { diagramPreviews.clear(); disposeCodeLabels?.(); disposeLanguagePicker?.(); void crepe?.destroy() })
defineExpose({ getEditor: () => crepe?.editor })
</script>
@@ -244,7 +301,18 @@ defineExpose({ getEditor: () => crepe?.editor })
<button type="button" :title="t('插入链接', 'Insert link')" :aria-label="t('插入链接', 'Insert link')" @pointerdown.prevent="applyLink"><AppIcon :icon="Link" :size="17" /></button>
</div>
<div v-if="loading" class="editor-loading">{{ t('正在加载编辑器', 'Loading editor') }}</div>
<div ref="editorRoot" class="milkdown-host" :class="{ loading }" />
<div class="milkdown-host" :class="{ loading }">
<section v-if="metadata" class="note-metadata" :aria-label="t('笔记属性', 'Note properties')">
<span class="metadata-caption">{{ t('笔记属性', 'Note properties') }}</span>
<h1 v-if="metadata.title">{{ metadata.title }}</h1>
<div class="metadata-tags">
<span class="metadata-label">{{ t('标签', 'Tags') }}</span>
<span v-for="tag in metadata.tags" :key="tag" class="metadata-tag"><span>{{ tag }}</span><button type="button" :aria-label="`${t('移除标签', 'Remove tag')} ${tag}`" @click="setTags(metadata.tags.filter(item => item !== tag))">×</button></span>
<form @submit.prevent="addTags"><input v-model="tagDraft" :aria-label="t('添加标签', 'Add tag')" :placeholder="t('+ 添加标签', '+ Add tag')" /><button v-if="tagDraft.trim()" type="submit">{{ t('添加', 'Add') }}</button></form>
</div>
</section>
<div ref="editorRoot" />
</div>
</div>
</template>
@@ -273,6 +341,20 @@ defineExpose({ getEditor: () => crepe?.editor })
.toolbar-divider { width: 1px; height: 20px; margin: 0 var(--space-xs); background: var(--color-border-default); }
.milkdown-host { flex: 1; min-height: 0; overflow: auto; color: var(--color-text-primary); }
.milkdown-host.loading { visibility: hidden; }
.note-metadata { box-sizing: border-box; width: 90%; margin: 0 auto 20px; padding: 20px 24px; border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-primary); }
.metadata-caption { color: var(--color-text-secondary); font-size: var(--font-size-xs); }
.note-metadata h1 { margin: 10px 0 16px; font-size: 24px; color: var(--color-text-primary); overflow-wrap: anywhere; }
.metadata-tags { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.metadata-label { margin-right: 4px; color: var(--color-text-secondary); font-size: var(--font-size-sm); }
.metadata-tag { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; padding: 4px 8px; border-radius: var(--radius-full); background: var(--color-accent-soft); color: var(--color-accent-primary); font-size: var(--font-size-sm); }
.metadata-tag > span { overflow-wrap: anywhere; min-width: 0; }
.metadata-tag button { color: inherit; padding: 0 3px; }
.metadata-tags form { display: flex; gap: 6px; }
.metadata-tags input { width: 110px; padding: 5px 8px; border: 1px dashed var(--color-border-default); border-radius: var(--radius-sm); background: transparent; color: var(--color-text-primary); }
.metadata-tags input:focus { outline: 2px solid var(--color-border-focus); }
.milkdown-host :deep(.editor-mermaid-preview) { padding: 20px; overflow: auto; background: var(--color-surface-primary); color: var(--color-text-primary); }
.milkdown-host :deep(.editor-mermaid-preview svg) { display: block; max-width: 100%; height: auto; margin: auto; }
.milkdown-host :deep(.editor-mermaid-preview.has-error) { color: var(--color-error); white-space: pre-wrap; }
.editor-loading { padding: var(--space-xl); color: var(--color-text-tertiary); }
.milkdown-host :deep(.milkdown) {
min-height: 100%;
@@ -0,0 +1,18 @@
// @vitest-environment happy-dom
import { expect, it } from 'vitest'
import { installCodeBlockLabels } from './codeBlockLabels'
it('keeps footer labels in sync when the language changes and stops after disposal', async () => {
const root = document.createElement('div')
root.innerHTML = '<div class="milkdown-code-block"><button class="language-button">Python</button></div>'
const dispose = installCodeBlockLabels(root)
const block = root.firstElementChild as HTMLElement
expect(block.dataset.languageLabel).toBe('Python')
block.querySelector('button')!.textContent = 'TypeScript'
await new Promise(resolve => setTimeout(resolve, 0))
expect(block.dataset.languageLabel).toBe('TypeScript')
dispose()
block.querySelector('button')!.textContent = 'Rust'
await new Promise(resolve => setTimeout(resolve, 0))
expect(block.dataset.languageLabel).toBe('TypeScript')
})
@@ -0,0 +1,11 @@
/** Mirror the live picker label for theme decorations without changing Markdown. */
export function installCodeBlockLabels(root: HTMLElement): () => void {
const sync = () => root.querySelectorAll<HTMLElement>('.milkdown-code-block').forEach(block => {
const label = block.querySelector('.language-button')?.textContent?.trim() || 'Plain text'
if (block.dataset.languageLabel !== label) block.dataset.languageLabel = label
})
const observer = new MutationObserver(sync)
observer.observe(root, { subtree: true, childList: true, characterData: true })
sync()
return () => observer.disconnect()
}
@@ -0,0 +1,37 @@
// @vitest-environment happy-dom
import { expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { renderMermaid } from '@/services/mermaidService'
import { createMermaidPreview } from './mermaidPreview'
vi.mock('@/services/mermaidService', () => ({ renderMermaid: vi.fn() }))
it('renders SVG with the requested theme and keeps async revisions isolated', async () => {
let finish!: (value: any) => void
vi.mocked(renderMermaid).mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
vi.mocked(renderMermaid).mockResolvedValueOnce({ svg: '<svg><text>new</text></svg>', warnings: [], width: 10, height: 10 })
const oldPublish = vi.fn()
const latestPublish = vi.fn()
const old = createMermaidPreview('graph TD; A-->B', false, oldPublish)
const latest = createMermaidPreview('graph TD; A-->C', true, latestPublish)
document.body.append(latest.cloneNode(true))
await flushPromises()
finish({ svg: '<svg><text>old</text></svg>', warnings: [] })
await flushPromises()
expect(latest.querySelector('svg')?.textContent).toBe('new')
expect(old.querySelector('svg')?.textContent).toBe('old')
expect(oldPublish).not.toHaveBeenCalled()
expect(latestPublish).toHaveBeenCalledWith(latest)
expect(latestPublish.mock.calls[0]![0]).not.toBe(latest)
document.getElementById(latest.id)?.remove()
expect(renderMermaid).toHaveBeenLastCalledWith('graph TD; A-->C', { theme: 'dark' })
})
it('shows syntax errors as text without executing markup', async () => {
vi.mocked(renderMermaid).mockResolvedValueOnce({ svg: '', warnings: ['<img src=x onerror=alert(1)>'], width: 0, height: 0 })
const preview = createMermaidPreview('invalid', false, vi.fn())
await flushPromises()
expect(preview.classList.contains('has-error')).toBe(true)
expect(preview.querySelector('img')).toBeNull()
expect(preview.textContent).toContain('点击编辑')
})
@@ -0,0 +1,39 @@
import { nextTick } from 'vue'
import { renderMermaid } from '@/services/mermaidService'
import { t } from '@/i18n'
let previewId = 0
export function createMermaidPreview(source: string, dark: boolean, applyPreview: (value: HTMLElement) => void): HTMLElement {
// Each revision owns its element, so a slow render cannot replace newer content.
const container = document.createElement('div')
container.className = 'editor-mermaid-preview'
container.id = `editor-mermaid-preview-${++previewId}`
container.setAttribute('aria-live', 'polite')
container.textContent = t('正在渲染图表…', 'Rendering diagram…')
const publish = async () => {
await nextTick()
// Milkdown sanitizes and copies this element. Publish only if its revision
// still exists; edits, language changes and unmounts remove the old marker.
const visible = document.getElementById(container.id)
if (visible) {
// PreviewPanel copies HTML instead of retaining the supplied element.
// Update the current copy through Milkdown's reactive callback.
applyPreview(container.cloneNode(true) as HTMLElement)
}
}
void renderMermaid(source, { theme: dark ? 'dark' : 'light' }).then(result => {
if (result.warnings.length) {
container.classList.add('has-error')
container.textContent = `${t('图表语法有误,可点击编辑修改:', 'Diagram syntax error. Choose Edit to fix:')} ${result.warnings.join('\n')}`
void publish()
return
}
// Mermaid runs in strict mode; Milkdown sanitizes the preview before insertion.
container.innerHTML = result.svg
void publish()
}).catch(() => {
container.textContent = t('图表渲染失败,请点击编辑检查源码。', 'Unable to render diagram. Choose Edit to inspect the source.')
void publish()
})
return container
}
@@ -0,0 +1,54 @@
import { expect, it } from 'vitest'
import { parseDocument } from 'yaml'
import { splitNoteMetadata, updateMetadataTags } from './noteMetadata'
it('renders legacy properties and saves real frontmatter without losing other fields', () => {
const note = '***\n\ntitle: Python\ntags: python, 编程\nembedding_local_only: true\n----------------\n\n# 正文\n'
const metadata = splitNoteMetadata(note)!
expect(metadata.tags).toEqual(['python', '编程'])
expect(metadata.body).toBe('\n# 正文\n')
const prefix = updateMetadataTags(metadata, ['编程', '学习', '学习'])
expect(prefix).toContain('embedding_local_only: true')
expect(prefix.startsWith('---\n')).toBe(true)
expect(splitNoteMetadata(prefix + metadata.body)!.tags).toEqual(['编程', '学习'])
})
it('does not mistake ordinary Markdown for metadata', () => {
expect(splitNoteMetadata('---\nA paragraph\n---\n')).toBeNull()
})
it.each(['- python\n- rust', ' - python\n - rust', '[python, rust]'])('replaces the complete YAML tag list: %s', (list) => {
const metadata = splitNoteMetadata(`---\ntitle: Demo\ntags:\n${list.startsWith('[') ? ' ' : ''}${list}\nextra:\n enabled: true # keep this\n---\n# Body\n`)!
expect(metadata.tags).toEqual(['python', 'rust'])
const prefix = updateMetadataTags(metadata, [...metadata.tags, 'new'])
const updated = splitNoteMetadata(prefix + metadata.body)!
expect(updated.tags).toEqual(['python', 'rust', 'new'])
expect(updated.body).toBe('# Body\n')
const document = parseDocument(updated.yaml)
expect(document.errors).toEqual([])
expect(document.toJS().extra).toEqual({ enabled: true })
expect(prefix).toContain('# keep this')
expect(splitNoteMetadata(updateMetadataTags(updated, []))!.tags).toEqual([])
})
it('preserves quoted commas, escapes, multiline titles and nested properties', () => {
const tags = ['a,b', 'quote"tag', 'path\\tag', 'true']
const metadata = splitNoteMetadata(`---\ntitle: |\n A multiline\n title\ntags: ${JSON.stringify(tags)}\nextra: {count: 2, enabled: false}\n---\n正文`)!
expect(metadata.tags).toEqual(tags)
const updated = splitNoteMetadata(updateMetadataTags(metadata, tags) + metadata.body)!
expect(updated.tags).toEqual(tags)
expect(updated.title).toBe(metadata.title)
expect(parseDocument(updated.yaml).toJS().extra).toEqual({ count: 2, enabled: false })
})
it('preserves document encoding markers and tag anchors', () => {
const metadata = splitNoteMetadata('\uFEFF---\r\ntitle: Demo\r\ntags: &labels [python]\r\nrelated: *labels\r\n---\r\nBody')!
const prefix = updateMetadataTags(metadata, ['rust'])
expect(prefix.startsWith('\uFEFF---\r\n')).toBe(true)
expect(prefix.replace(/\r\n/g, '')).not.toContain('\n')
expect(parseDocument(splitNoteMetadata(prefix)!.yaml).toJS().related).toEqual(['rust'])
})
it.each(['tags: [broken', 'tags: [one]\ntags: [two]', 'tags: {nested: value}', 'tags: [1, true]', 'tags: [&label python]\nother: *label'])('leaves invalid or unsupported tag data in source mode: %s', (yaml) => {
expect(splitNoteMetadata(`---\ntitle: Demo\n${yaml}\n---\nBody`)).toBeNull()
})
@@ -0,0 +1,2 @@
export { splitNoteMetadata, updateMetadataTags } from '@/utils/noteMetadata'
export type { NoteMetadata } from '@/utils/noteMetadata'
@@ -16,10 +16,15 @@ const previewDocument = computed(() => {
style.textContent = `${tokensCss}\n${getCommunityThemePreviewCss(props.themeId)}\nbody { margin:0; padding:24px; background:var(--color-background-primary); color:var(--color-text-primary); font:16px/1.6 system-ui; } article { padding:20px; border:1px solid var(--color-border-default); border-radius:8px; background:var(--color-surface-primary); } p { color:var(--color-text-secondary); } button { padding:8px 16px; border:0; border-radius:6px; background:var(--color-accent-primary); color:white; }`
doc.head.append(style)
const article = doc.createElement('article')
article.className = 'panel'
const header = doc.createElement('header'); header.className = 'feature-header'
const heading = doc.createElement('h1'); heading.textContent = theme.value?.name ?? props.themeId
header.append(heading)
const journal = doc.createElement('section'); journal.className = 'editor-preview'; journal.style.cssText = 'padding:24px;margin:28px 0;'
const text = doc.createElement('p'); text.textContent = t('知识的价值不只在于保存,更在于被重新发现和使用。', 'Knowledge gains value when it can be rediscovered and used.')
const button = doc.createElement('button'); button.textContent = t('示例按钮', 'Example button')
article.append(heading, text, button); doc.body.append(article)
journal.append(text)
article.append(header, journal, button); doc.body.append(article)
return '<!doctype html>' + doc.documentElement.outerHTML
})
</script>
@@ -5,10 +5,62 @@ import { createPinia, setActivePinia } from 'pinia'
import ThemesView from './ThemesView.vue'
import { useThemeStore } from '@/stores/theme'
import { mockCommunityThemes, getCommunityThemePreviewCss } from '@/services/themePackageService'
import paperPackage from '@/assets/themes/paper-moments.theme?raw'
let wrapper: VueWrapper
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
afterEach(() => { wrapper?.unmount(); vi.useRealTimers() })
afterEach(() => { useThemeStore().applyTheme('light'); wrapper?.unmount(); vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.useRealTimers() })
it('downloads a URL for inspection without automatically installing it', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(paperPackage)))
wrapper = mount(ThemesView, { global: { stubs: { MarkdownContent: true } } })
await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '导入主题')!.trigger('click')
await wrapper.get('#theme-package-url').setValue('https://example.com/paper.theme')
await wrapper.get('.url-import').trigger('submit')
await vi.waitFor(() => expect(useThemeStore().pendingInspection?.compatible).toBe(true))
expect(useThemeStore().isThemeInstalled('paper-moments')).toBe(false)
expect(wrapper.get('.inspection-result').text()).toContain('纸间时光')
})
it('ignores a URL response after the dialog is cancelled', async () => {
let respond!: (response: Response) => void
vi.stubGlobal('fetch', vi.fn(() => new Promise(resolve => { respond = resolve })))
wrapper = mount(ThemesView, { global: { stubs: { MarkdownContent: true } } })
await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '导入主题')!.trigger('click')
await wrapper.get('#theme-package-url').setValue('https://example.com/paper.theme')
await wrapper.get('.url-import').trigger('submit')
await wrapper.get('.import-modal .inline-actions button').trigger('click')
respond(new Response(paperPackage))
await flushPromises()
expect(useThemeStore().pendingInspection).toBeNull()
expect(wrapper.find('.import-modal').exists()).toBe(false)
})
it('opens the file picker from the styled button and imports the actual paper theme', async () => {
const store = useThemeStore()
wrapper = mount(ThemesView, { global: { stubs: { MarkdownContent: true } } })
await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '导入主题')!.trigger('click')
const input = wrapper.get<HTMLInputElement>('input[type="file"]')
const click = vi.spyOn(input.element, 'click').mockImplementation(() => {})
await wrapper.get('.upload-area .button-primary').trigger('click')
expect(click).toHaveBeenCalledOnce()
Object.defineProperty(input.element, 'files', { value: [new File([paperPackage], 'paper-moments.theme', { type: 'text/plain' })] })
await input.trigger('change')
await vi.waitFor(() => expect(store.pendingInspection?.compatible).toBe(true))
expect(store.pendingInspection!.warnings).toEqual([])
await wrapper.get('.import-modal .inline-actions .button-primary').trigger('click')
await flushPromises()
expect(store.isThemeInstalled('paper-moments')).toBe(true)
expect(localStorage.getItem('installed-themes-css-paper-moments')).toBe(getCommunityThemePreviewCss('paper-moments'))
expect(document.getElementById('theme-style-paper-moments')).toBeNull()
store.applyTheme('paper-moments')
expect(document.getElementById('theme-style-paper-moments')!.textContent).toBe(getCommunityThemePreviewCss('paper-moments'))
store.applyTheme('light')
expect(document.getElementById('theme-style-paper-moments')).toBeNull()
})
it.each(mockCommunityThemes)('previews uninstalled $theme_id using its actual CSS without changing the active theme', async theme => {
const store = useThemeStore()
+83 -34
View File
@@ -1,19 +1,58 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
import MarkdownContent from '@/components/common/MarkdownContent.vue'
import { useThemeStore } from '@/stores/theme'
import { mockCommunityThemes } from '@/services/themePackageService'
import { mockCommunityThemes, decodeThemePackage, fetchThemePackage, inspectThemePackage, MAX_THEME_BYTES } from '@/services/themePackageService'
import type { ThemePackageInspection } from '@/contracts'
import { t } from '@/i18n'
import CommunityThemePreview from './CommunityThemePreview.vue'
import paperMomentsUrl from '@/assets/themes/paper-moments.theme?url'
const themeStore = useThemeStore()
const activeTab = ref<'installed' | 'community'>('installed')
const showImportDialog = ref(false)
const fileInput = ref<HTMLInputElement | null>(null)
const previewThemeId = ref<string | null>(null)
const communityPreviewId = ref<string | null>(null)
const actionError = ref('')
const importUrl = ref('')
const importing = ref(false)
let importGeneration = 0
let downloadController: AbortController | undefined
function resetImport() {
importGeneration++
downloadController?.abort()
importing.value = false
themeStore.pendingInspection = null
themeStore.importError = null
actionError.value = ''
}
function closeImport() { resetImport(); showImportDialog.value = false }
function openImport() { resetImport(); showImportDialog.value = true }
onBeforeUnmount(resetImport)
async function importPackage(load: () => Promise<string>) {
resetImport()
const generation = importGeneration
importing.value = true
try {
const result = await inspectThemePackage(await load())
if (generation !== importGeneration) return
themeStore.pendingInspection = result
if (!result.compatible) actionError.value = result.warnings[0] ?? '主题包无法解析'
} catch (error) {
if (generation === importGeneration) actionError.value = error instanceof Error ? error.message : '导入失败'
} finally { if (generation === importGeneration) importing.value = false }
}
function importFromUrl() {
void importPackage(() => {
downloadController = new AbortController()
return fetchThemePackage(importUrl.value, downloadController.signal)
})
}
const shikiPreview = `\`\`\`typescript
const notes = await search('本地优先')
@@ -30,23 +69,16 @@ function handleFileImport(event: Event) {
const file = input.files?.[0]
input.value = ''
if (!file) return
actionError.value = ''
const reader = new FileReader()
reader.onload = async () => {
try {
const result = await themeStore.inspectThemePackage(String(reader.result ?? ''))
if (result.compatible) {
previewThemeId.value = result.manifest.theme_id
} else {
actionError.value = result.warnings[0] ?? '主题包无法解析'
}
} catch (error) {
actionError.value = error instanceof Error ? error.message : '导入失败'
}
}
reader.onerror = () => { actionError.value = '文件读取失败' }
// YAML + --- + CSS
reader.readAsText(file)
void importPackage(async () => {
if (file.size > MAX_THEME_BYTES) throw new Error('主题包不能超过 5 MB')
const bytes = await new Promise<ArrayBuffer>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as ArrayBuffer)
reader.onerror = () => reject(new Error('文件读取失败'))
reader.readAsArrayBuffer(file)
})
return decodeThemePackage(new Uint8Array(bytes))
})
}
async function confirmInstall(inspection: ThemePackageInspection) {
@@ -90,7 +122,7 @@ onMounted(() => {
<p>浏览导入和管理主题打造你的知识工作流</p>
</div>
<div class="inline-actions">
<button class="button-secondary" @click="showImportDialog = true">导入主题</button>
<button class="button-secondary" @click="openImport">导入主题</button>
<button class="button-secondary" @click="themeStore.resetToDefault()">{{ t('恢复默认', 'Reset defaults') }}</button>
</div>
</header>
@@ -124,7 +156,7 @@ onMounted(() => {
:class="{ selected: themeStore.currentThemeId === theme.theme_id }"
@click="themeStore.applyTheme(theme.theme_id)"
>
<div class="theme-preview" :class="theme.is_dark ? 'preview-dark' : (theme.theme_id === 'sepia' ? 'preview-sepia' : 'preview-light')">
<div class="theme-preview" :class="theme.theme_id === 'paper-moments' ? 'preview-paper' : theme.is_dark ? 'preview-dark' : (theme.theme_id === 'sepia' ? 'preview-sepia' : 'preview-light')">
<span></span><span></span><span></span><div></div>
</div>
<div class="theme-info">
@@ -150,7 +182,7 @@ onMounted(() => {
:key="theme.theme_id"
class="item-card theme-card"
>
<div class="theme-preview" :class="theme.is_dark ? 'preview-dark' : 'preview-light'">
<div class="theme-preview" :class="theme.theme_id === 'paper-moments' ? 'preview-paper' : theme.is_dark ? 'preview-dark' : 'preview-light'">
<span></span><span></span><span></span><div></div>
</div>
<div class="theme-info">
@@ -165,14 +197,15 @@ onMounted(() => {
<span v-for="tag in theme.tags" :key="tag" class="tag">{{ tag }}</span>
</div>
<div class="theme-actions">
<a v-if="theme.theme_id === 'paper-moments'" class="button-secondary small" :href="paperMomentsUrl" download="paper-moments.theme">下载主题包</a>
<button
v-if="themeStore.isThemeInstalled(theme.theme_id)"
v-if="themeStore.allThemes.some(installed => installed.theme_id === theme.theme_id && installed.version === theme.version)"
class="button-secondary small"
@click="themeStore.applyTheme(theme.theme_id)"
>启用</button>
<template v-else>
<button class="button-secondary small" @click="previewCommunity(theme.theme_id)">预览</button>
<button class="button-primary small" @click="installFromCommunity(theme.theme_id)">安装</button>
<button class="button-primary small" @click="installFromCommunity(theme.theme_id)">{{ themeStore.isThemeInstalled(theme.theme_id) ? '更新' : '安装' }}</button>
</template>
</div>
</article>
@@ -193,11 +226,12 @@ onMounted(() => {
</div>
</div>
<div v-if="showImportDialog" class="modal-backdrop" @click.self="showImportDialog = false">
<div v-if="showImportDialog" class="modal-backdrop" @click.self="closeImport">
<div class="modal import-modal">
<span class="badge info">主题导入</span>
<h2>导入主题包</h2>
<p class="subtle">单文件主题包YAML 清单 + 一行 <code>---</code> + 主题 CSS安装前会校验清单 CSS 安全性</p>
<p class="subtle">选择本地文件或粘贴主题包直链支持单文件主题 ZIP安装前会校验清单 CSS</p>
<p v-if="actionError" class="error-banner" role="alert">{{ actionError }}</p>
<div v-if="themeStore.pendingInspection?.compatible" class="inspection-result">
<div class="inspect-head">
@@ -222,13 +256,21 @@ onMounted(() => {
</div>
<div v-else class="upload-area">
<input type="file" accept=".yaml,.yml,.theme" @change="handleFileImport" />
<p>点击选择主题包文件</p>
<p class="subtle">支持 .yaml / .yml / .themeZIP 需要 Host 端解压暂不支持</p>
<input ref="fileInput" class="theme-file-input" type="file" accept=".yaml,.yml,.theme,.zip" tabindex="-1" aria-label="主题包文件" @change="handleFileImport" />
<button type="button" class="button-primary" :disabled="importing" @click="fileInput?.click()">选择主题包文件</button>
<p>从本地导入你喜欢的主题</p>
<p class="subtle">支持 .yaml / .yml / .theme / .zip最大 5 MB</p>
<form class="url-import" @submit.prevent="importFromUrl">
<label for="theme-package-url"> URL 导入</label>
<input id="theme-package-url" v-model="importUrl" class="input" type="url" required placeholder="https://example.com/theme.zip" :disabled="importing" />
<button class="button-secondary" type="submit" :disabled="importing">{{ importing ? '正在读取…' : '下载并校验' }}</button>
<p class="subtle">请使用文件直链远程服务器需允许跨域访问</p>
</form>
</div>
<div class="inline-actions">
<button class="button-secondary" @click="showImportDialog = false">取消</button>
<button v-if="themeStore.pendingInspection?.compatible" class="button-secondary" @click="resetImport">重新选择</button>
<button class="button-secondary" @click="closeImport">取消</button>
<button
v-if="themeStore.pendingInspection?.compatible"
class="button-primary"
@@ -262,9 +304,16 @@ onMounted(() => {
.preview-sepia { background: #fbf3df; border-color: #ddcfad; }
.preview-sepia span { background: #d8c69c; }
.preview-sepia div { background: #f4e8ca; }
.preview-paper { background: #fffdf5; border: 1px dashed #8b7865; box-shadow: 3px 3px 0 #d8e6e2, 6px 6px 0 #f0d8cf; }
.preview-paper span { background: #efd8d0; }
.preview-paper span:nth-child(2) { background: #d8e7e8; }
.preview-paper span:nth-child(3) { background: #f6e9b8; }
.preview-paper div { border: 1px solid #b5a693; background: repeating-linear-gradient(#fffef8 0 14px, #dce4db 14px 15px); }
.theme-actions a { text-decoration: none; }
.theme-info { display: flex; justify-content: space-between; gap: var(--space-md); align-items: flex-start; }
.theme-info strong { display: block; margin-bottom: 2px; }
.theme-info > .badge { flex-shrink: 0; white-space: nowrap; }
.theme-tags { display: flex; flex-wrap: wrap; gap: 4px; }
.tag {
@@ -347,10 +396,10 @@ onMounted(() => {
transition: border-color var(--motion-fast);
}
.upload-area:hover { border-color: var(--color-accent-secondary); }
.upload-area input {
display: block;
margin: 0 auto var(--space-md);
}
.url-import { display: grid; gap: 10px; margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--color-border-default); text-align: left; }
.url-import .input { width: 100%; min-width: 0; }
.upload-area .theme-file-input { display: none; }
.upload-area > button { margin-bottom: var(--space-md); }
.upload-area p { color: var(--color-text-secondary); }
.inspection-result {
@@ -48,6 +48,77 @@ afterEach(() => {
})
describe('FileTreePanel file switching', () => {
it('expands every nested folder from the toolbar', async () => {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/workspace', component: { template: '<div />' } }] })
await router.push('/workspace')
const store = useWorkspaceStore()
store.fileTree = [{ id: 'a', name: 'A', path: '/a', type: 'folder', is_open: false, children: [{ id: 'b', name: 'B', path: '/a/b', type: 'folder', is_open: false }] }]
wrapper = mount(FileTreePanel, { global: { plugins: [router] } })
await wrapper.get('[aria-label="全部展开文件夹"]').trigger('click')
expect(store.fileTree[0]!.is_open).toBe(true)
expect(store.fileTree[0]!.children![0]!.is_open).toBe(true)
})
it('switches full-height panels using tabs and preserves the file search', async () => {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/workspace', component: { template: '<div />' } }] })
await router.push('/workspace')
wrapper = mount(FileTreePanel, { attachTo: document.body, global: { plugins: [router] } })
expect(wrapper.get('#workspace-files-panel').isVisible()).toBe(true)
expect(wrapper.get('#workspace-outline-panel').isVisible()).toBe(false)
await wrapper.get('.file-tree-panel').trigger('wheel', { deltaY: -50 })
await wrapper.get('.file-search input').setValue('笔记')
await wrapper.get('#workspace-outline-tab').trigger('click')
expect(wrapper.get('#workspace-files-panel').isVisible()).toBe(false)
expect(wrapper.get('#workspace-outline-panel').isVisible()).toBe(true)
expect(wrapper.get('#workspace-outline-tab').attributes('aria-selected')).toBe('true')
await wrapper.get('#workspace-outline-tab').trigger('keydown', { key: 'ArrowLeft' })
expect(wrapper.get('#workspace-files-panel').isVisible()).toBe(true)
expect((wrapper.get('.file-search input').element as HTMLInputElement).value).toBe('笔记')
})
it('reveals search on upward wheel and filters without changing folder state', async () => {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/workspace', component: { template: '<div />' } }] })
await router.push('/workspace')
const store = useWorkspaceStore()
await store.openVault('C:/vault')
store.toggleFolder('/数据结构')
wrapper = mount(FileTreePanel, { global: { plugins: [router] } })
expect(wrapper.find('.file-search').exists()).toBe(false)
await wrapper.get('.file-tree-panel').trigger('wheel', { deltaY: -50 })
await wrapper.get('.file-search input').setValue('红黑')
expect(wrapper.findAll('.tree-node').map(node => node.text())).toEqual(['数据结构', '红黑树.md'])
expect(store.fileTree[0]!.is_open).toBe(false)
await wrapper.get('.file-tree-panel').trigger('wheel', { deltaY: 50 })
expect(wrapper.find('.file-search').exists()).toBe(true)
})
it('creates a folder through the file context menu in its containing directory', async () => {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/workspace', component: { template: '<div />' } }] })
await router.push('/workspace')
await useWorkspaceStore().openVault('C:/vault')
const create = vi.spyOn(workspaceService, 'createFolder').mockResolvedValue({ id: 'new', name: '子目录', path: '/数据结构/子目录', type: 'folder' })
wrapper = mount(FileTreePanel, { attachTo: document.body, global: { plugins: [router] } })
await wrapper.findAll('.tree-node').find(node => node.text().includes('红黑树'))!.trigger('contextmenu')
const button = [...document.querySelectorAll<HTMLButtonElement>('.context-menu button')].find(item => item.textContent === '新建文件夹')!
button.click()
await wrapper.vm.$nextTick()
await wrapper.get('.new-item input').setValue('子目录')
await wrapper.get('.new-item').trigger('submit')
expect(create).toHaveBeenCalledWith('/数据结构', '子目录')
})
it('collapses nested headings and requests navigation to a duplicate heading', async () => {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/workspace', component: { template: '<div />' } }] })
await router.push('/workspace')
const store = useEditorStore()
store.currentFilePath = '/note.md'
store.content = '# 标题\n\n## 子标题\n\n# 标题\n'
wrapper = mount(FileTreePanel, { global: { plugins: [router] } })
await wrapper.get('#workspace-outline-tab').trigger('click')
expect(wrapper.findAll('.outline-title')).toHaveLength(3)
await wrapper.get('.outline-row button[aria-expanded]').trigger('click')
expect(wrapper.findAll('.outline-title')).toHaveLength(2)
await wrapper.findAll('.outline-title')[1]!.trigger('click')
expect(store.headingRequest).toEqual({ index: 2, offset: store.content.lastIndexOf('# 标题'), path: '/note.md' })
})
it('switches both workspace selection and editor content on consecutive clicks', async () => {
const router = createRouter({
history: createMemoryHistory(),
+159 -15
View File
@@ -1,12 +1,13 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { noteOutline } from './outline'
import { useRouter } from 'vue-router'
import type { FileNode } from '@/contracts'
import * as workspaceService from '@/services/workspaceService'
import { useEditorStore } from '@/stores/editor'
import { useWorkspaceStore } from '@/stores/workspace'
import FileTreeNode from './FileTreeNode.vue'
import { DocumentAdd, FolderAdd } from '@element-plus/icons-vue'
import { Document, DocumentAdd, FolderAdd, ArrowRight } from '@element-plus/icons-vue'
import AppIcon from '@/components/common/AppIcon.vue'
import { t } from '@/i18n'
@@ -22,6 +23,70 @@ const selectedFolderPath = ref(
)
const contextTarget = ref<FileNode | null>(null)
const contextMenuPosition = ref({ x: 0, y: 0 })
const searchVisible = ref(false)
const activeTab = ref<'files' | 'outline'>('files')
function switchTab(tab: 'files' | 'outline') { activeTab.value = tab; closeContextMenu() }
function navigateTabs(event: KeyboardEvent) {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return
event.preventDefault()
switchTab(event.key === 'Home' ? 'files' : event.key === 'End' ? 'outline' : activeTab.value === 'files' ? 'outline' : 'files')
const parent = (event.target as HTMLElement).parentElement
void nextTick(() => parent?.querySelector<HTMLButtonElement>('[aria-selected="true"]')?.focus())
}
const searchQuery = ref('')
const searchFocused = ref(false)
const createInput = ref<HTMLInputElement | null>(null)
const createError = ref('')
const creating = ref(false)
const outline = computed(() => noteOutline(editorStore.content))
const collapsedHeadings = ref(new Set<number>())
const visibleHeadings = computed(() => {
let hiddenBelow = 7
return outline.value.filter(heading => {
if (heading.level > hiddenBelow) return false
hiddenBelow = collapsedHeadings.value.has(heading.index) ? heading.level : 7
return true
})
})
const hasChildren = (index: number) => {
const position = outline.value.findIndex(heading => heading.index === index)
return (outline.value[position + 1]?.level ?? 0) > (outline.value[position]?.level ?? 6)
}
function toggleHeading(index: number) {
const next = new Set(collapsedHeadings.value)
if (next.has(index)) next.delete(index); else next.add(index)
collapsedHeadings.value = next
}
watch(() => editorStore.content, () => { collapsedHeadings.value = new Set() })
const filteredTree = computed(() => {
const query = searchQuery.value.trim().toLocaleLowerCase()
if (!query) return workspaceStore.fileTree
const filter = (nodes: FileNode[]): FileNode[] => nodes.flatMap(node => {
if (node.name.toLocaleLowerCase().includes(query)) return [{ ...node, is_open: true }]
const children = filter(node.children ?? [])
return children.length ? [{ ...node, children, is_open: true }] : []
})
return filter(workspaceStore.fileTree)
})
function expandAllFiles() {
const expand = (nodes: FileNode[]) => nodes.forEach(node => {
if (node.type === 'folder') { node.is_open = true; expand(node.children ?? []) }
})
expand(workspaceStore.fileTree)
}
let lastScrollTop = 0
function revealSearch(event: WheelEvent) {
if (activeTab.value !== 'files') return
if (event.deltaY < 0) searchVisible.value = true
else if (event.deltaY > 0 && !searchFocused.value && !searchQuery.value) searchVisible.value = false
}
function onTreeScroll(event: Event) {
const top = (event.target as HTMLElement).scrollTop
if (top < lastScrollTop) searchVisible.value = true
else if (top > lastScrollTop && !searchFocused.value && !searchQuery.value) searchVisible.value = false
lastScrollTop = top
closeContextMenu()
}
watch(() => workspaceStore.activeFilePath, (path) => {
if (!path) return
@@ -30,14 +95,23 @@ watch(() => workspaceStore.activeFilePath, (path) => {
})
function beginCreate(type: 'file' | 'folder', parent = '/') {
if (creating.value) return
closeContextMenu()
createError.value = ''
newItemType.value = type
newItemName.value = ''
parentPath.value = parent
void nextTick(() => createInput.value?.focus())
}
async function createItem() {
const rawName = newItemName.value.trim()
if (!rawName || !newItemType.value) return
if (creating.value) return
if (/[\\/]/.test(rawName) || ['.', '..'].includes(rawName)) { createError.value = t('请输入有效名称,不要包含路径分隔符', 'Enter a name without path separators'); return }
creating.value = true
createError.value = ''
try {
if (newItemType.value === 'file') {
const name = rawName.endsWith('.md') ? rawName : `${rawName}.md`
const file = await workspaceService.createFile(parentPath.value, name, `# ${rawName}\n\n`)
@@ -55,6 +129,8 @@ async function createItem() {
}
newItemType.value = null
newItemName.value = ''
} catch (error) { createError.value = error instanceof Error ? error.message : t('创建失败', 'Creation failed') }
finally { creating.value = false }
}
async function openNode(node: FileNode) {
@@ -84,7 +160,7 @@ function openContextMenu(event: MouseEvent, node: FileNode) {
selectedTreePath.value = node.path
selectedFolderPath.value = node.type === 'folder' ? node.path : containingFolder(node.path)
contextTarget.value = node
contextMenuPosition.value = { x: event.clientX, y: event.clientY }
contextMenuPosition.value = { x: Math.max(8, Math.min(event.clientX, window.innerWidth - 170)), y: Math.max(8, Math.min(event.clientY, window.innerHeight - 170)) }
}
function closeContextMenu() { contextTarget.value = null }
@@ -136,38 +212,106 @@ function containingFolder(path: string): string {
</script>
<template>
<section class="file-tree-panel" @click="closeContextMenu">
<section class="file-tree-panel" @click="closeContextMenu" @keydown.esc="closeContextMenu" @wheel.passive="revealSearch">
<div class="workspace-tabs" role="tablist" :aria-label="t('工作区导航', 'Workspace navigation')" @keydown="navigateTabs">
<button id="workspace-files-tab" role="tab" aria-controls="workspace-files-panel" :aria-selected="activeTab === 'files'" :tabindex="activeTab === 'files' ? 0 : -1" @click="switchTab('files')">{{ t('文件', 'Files') }}</button>
<button id="workspace-outline-tab" role="tab" aria-controls="workspace-outline-panel" :aria-selected="activeTab === 'outline'" :tabindex="activeTab === 'outline' ? 0 : -1" @click="switchTab('outline')">{{ t('大纲', 'Outline') }}</button>
</div>
<div v-show="activeTab === 'files'" id="workspace-files-panel" class="files-panel" role="tabpanel" aria-labelledby="workspace-files-tab">
<div class="toolbar">
<button type="button" :title="t('新建笔记', 'New note')" :aria-label="t('新建笔记', 'New note')" @click.stop="beginCreate('file', selectedFolderPath)"><AppIcon :icon="DocumentAdd" /></button>
<button type="button" :title="t('新建文件夹', 'New folder')" :aria-label="t('新建文件夹', 'New folder')" @click.stop="beginCreate('folder', selectedFolderPath)"><AppIcon :icon="FolderAdd" /></button>
<button type="button" :aria-label="t('搜索文件', 'Search files')" :aria-expanded="searchVisible" @click="searchVisible = !searchVisible">{{ t('搜索', 'Search') }}</button>
<button type="button" :aria-label="t('全部展开文件夹', 'Expand all folders')" @click="expandAllFiles">{{ t('全部展开', 'Expand all') }}</button>
</div>
<div v-if="searchVisible || searchQuery || searchFocused" class="file-search">
<input v-model="searchQuery" type="search" :placeholder="t('搜索文件或文件夹…', 'Search files or folders…')" :aria-label="t('搜索文件或文件夹', 'Search files or folders')" @focus="searchFocused = true" @blur="searchFocused = false" />
</div>
<form v-if="newItemType" class="new-item" @submit.prevent="createItem">
<input v-model="newItemName" :placeholder="newItemType === 'file' ? t('笔记名称', 'Note name') : t('文件夹名称', 'Folder name')" autofocus />
<button type="submit">{{ t('创建', 'Create') }}</button>
<button type="button" @click="newItemType = null">{{ t('取消', 'Cancel') }}</button>
<input ref="createInput" v-model="newItemName" :disabled="creating" :placeholder="newItemType === 'file' ? t('笔记名称', 'Note name') : t('文件夹名称', 'Folder name')" />
<button type="submit" :disabled="creating">{{ t('创建', 'Create') }}</button>
<button type="button" :disabled="creating" @click="newItemType = null">{{ t('取消', 'Cancel') }}</button>
</form>
<div class="tree">
<FileTreeNode v-for="node in workspaceStore.fileTree" :key="node.id" :node="node"
<p v-if="createError" class="create-error" role="alert">{{ createError }}</p>
<div class="tree" @scroll.passive="onTreeScroll" @contextmenu.self="openContextMenu($event, { id: 'root', name: '/', path: '/', type: 'folder' })">
<FileTreeNode v-for="node in filteredTree" :key="node.id" :node="node"
:active-path="selectedTreePath" @open="openNode" @context-menu="openContextMenu" />
<p v-if="searchQuery && !filteredTree.length" class="subtle">{{ t('没有匹配的文件', 'No matching files') }}</p>
</div>
</div>
<div v-show="activeTab === 'outline'" id="workspace-outline-panel" class="outline-panel" role="tabpanel" aria-labelledby="workspace-outline-tab">
<div class="outline-document">
<span class="outline-document-icon"><AppIcon :icon="Document" :size="18" /></span>
<div class="outline-document-info">
<p class="outline-filename" :title="editorStore.currentFilePath ?? ''">{{ editorStore.currentFilePath?.split('/').pop() ?? t('未打开笔记', 'No note open') }}</p>
<span class="outline-meta">{{ t('文档目录', 'Contents') }} · {{ outline.length }} {{ t('个标题', 'headings') }}</span>
</div>
</div>
<div v-if="outline.length" class="outline-controls">
<span>{{ t('目录', 'Contents') }}</span>
<button :title="t('展开全部标题', 'Expand all headings')" @click="collapsedHeadings = new Set()">{{ t('全部展开', 'Expand all') }}</button>
</div>
<nav class="outline-list" :aria-label="t('当前笔记大纲', 'Current note outline')">
<div v-for="heading in visibleHeadings" :key="heading.index" class="outline-row" :class="{ 'is-selected': editorStore.headingRequest?.path === editorStore.currentFilePath && editorStore.headingRequest?.index === heading.index, 'is-nested': heading.level > 1 }" :style="{ marginLeft: `${(heading.level - 1) * 10}px` }">
<button v-if="hasChildren(heading.index)" class="outline-toggle" :aria-label="t('折叠或展开标题', 'Toggle heading')" :aria-expanded="!collapsedHeadings.has(heading.index)" @click="toggleHeading(heading.index)"><AppIcon :icon="ArrowRight" :size="10" /></button>
<span v-else class="outline-spacer" />
<button class="outline-title" :title="heading.title" :aria-current="editorStore.headingRequest?.path === editorStore.currentFilePath && editorStore.headingRequest?.index === heading.index ? 'location' : undefined" @click="editorStore.jumpToHeading(heading.index, heading.offset)"><span class="outline-text">{{ heading.title }}</span><span class="outline-level" aria-hidden="true">H{{ heading.level }}</span></button>
</div>
<div v-if="!outline.length" class="outline-empty"><AppIcon :icon="Document" :size="28" /><strong>{{ t('还没有目录', 'No outline yet') }}</strong><p>{{ t('在笔记中添加标题,即可在这里浏览和跳转。', 'Add headings to your note to navigate here.') }}</p></div>
</nav>
</div>
<Teleport to="body">
<div v-if="contextTarget" class="context-menu"
:style="{ left: `${contextMenuPosition.x}px`, top: `${contextMenuPosition.y}px` }" @click.stop>
<button @click="renameTarget">{{ t('重命名', 'Rename') }}</button>
<button class="danger" @click="deleteTarget">{{ t('删除', 'Delete') }}</button>
<button @click="beginCreate('file', selectedFolderPath)">{{ t('新建文件', 'New file') }}</button>
<button @click="beginCreate('folder', selectedFolderPath)">{{ t('新建文件夹', 'New folder') }}</button>
<button v-if="contextTarget.path !== '/'" @click="renameTarget">{{ t('重命名', 'Rename') }}</button>
<button v-if="contextTarget.path !== '/'" class="danger" @click="deleteTarget">{{ t('删除', 'Delete') }}</button>
</div>
</Teleport>
</section>
</template>
<style scoped>
.file-tree-panel { height: 100%; }
.file-tree-panel { height: 100%; min-height: 0; display: flex; flex-direction: column; background: var(--color-surface-secondary); color: var(--color-text-primary); }
.workspace-tabs { display: flex; flex-shrink: 0; gap: 4px; padding: 8px; border-bottom: 1px solid var(--color-border-default); background: var(--color-background-secondary); }
.workspace-tabs button { flex: 1; min-height: 34px; font-weight: 600; color: var(--color-text-secondary); }
.workspace-tabs button[aria-selected="true"] { background: var(--color-accent-soft); color: var(--color-accent-primary); box-shadow: inset 0 -2px var(--color-accent-primary); }
.files-panel { display: flex; flex: 1; min-height: 0; flex-direction: column; }
.file-tree-panel button:focus-visible, .context-menu button:focus-visible { outline: 2px solid var(--color-border-focus); outline-offset: -2px; }
.file-search { padding: 8px; }
.file-search input { width: 100%; box-sizing: border-box; padding: 6px 8px; border: 1px solid var(--color-border-default); border-radius: var(--radius-sm); background: var(--color-surface-primary); color: var(--color-text-primary); }
.create-error { padding: 8px; color: var(--color-error); }
.outline-panel { flex: 1; min-height: 0; overflow: auto; }
.outline-document { display: flex; align-items: center; gap: 10px; margin: 12px 10px; padding: 12px 10px; border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); background: var(--color-surface-primary); box-shadow: var(--shadow-sm); }
.outline-document-icon { display: grid; place-items: center; flex-shrink: 0; width: 32px; height: 36px; border-radius: var(--radius-sm); background: var(--color-accent-soft); color: var(--color-accent-primary); }
.outline-document-info { min-width: 0; }
.outline-filename { margin: 0 0 4px; padding: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--color-text-primary); font-size: var(--font-size-sm); font-weight: 600; border: 0; }
.outline-meta { font-size: var(--font-size-xs); color: var(--color-text-secondary); }
.outline-controls { display: flex; align-items: center; justify-content: space-between; padding: 4px 12px 8px; color: var(--color-text-secondary); font-size: var(--font-size-xs); }
.outline-controls button { color: var(--color-accent-primary); font-size: inherit; }
.outline-list { padding: 0 10px 16px; }
.outline-row { position: relative; display: flex; align-items: center; min-height: 34px; margin-bottom: 2px; padding: 0 6px 0 2px; border: 1px solid transparent; border-radius: var(--radius-sm); transition: background-color var(--motion-fast); }
.outline-row:hover { background: var(--color-background-hover); }
.outline-row.is-selected { background: var(--color-accent-soft); box-shadow: inset 2px 0 var(--color-accent-primary); }
.outline-spacer, .outline-toggle { width: 18px; flex-shrink: 0; }
.outline-row .outline-toggle { display: grid; place-items: center; padding: 4px 0; color: var(--color-text-secondary); }
.outline-toggle[aria-expanded="true"] :deep(svg) { transform: rotate(90deg); }
.outline-row .outline-title { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; padding: 6px 2px; text-align: left; background: transparent; }
.outline-level { flex-shrink: 0; color: var(--color-text-tertiary); font: 400 10px/18px var(--font-ui-mono); }
.outline-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--font-size-sm); line-height: 20px; }
.is-selected .outline-text { color: var(--color-accent-primary); font-weight: 600; }
.is-selected .outline-level { color: var(--color-accent-primary); }
.outline-empty { display: grid; justify-items: center; gap: 10px; padding: 32px 16px; text-align: center; color: var(--color-text-secondary); }
.outline-empty strong { color: var(--color-text-primary); font-size: var(--font-size-sm); }
.outline-empty p { margin: 0; font-size: var(--font-size-xs); line-height: 1.7; }
.toolbar { display: flex; gap: var(--space-xs); padding: var(--space-sm); border-bottom: 1px solid var(--color-border-subtle); }
button { border: 0; border-radius: var(--radius-sm); padding: var(--space-xs) var(--space-sm); background: transparent; color: inherit; cursor: pointer; }
button:hover { background: var(--color-background-secondary); }
button:hover { background: var(--color-background-hover); }
.new-item { display: flex; gap: var(--space-xs); padding: var(--space-sm); }
.new-item input { min-width: 0; flex: 1; }
.tree { padding: var(--space-xs); }
.new-item input { min-width: 0; flex: 1; padding: 6px 8px; border: 1px solid var(--color-border-default); border-radius: var(--radius-sm); background: var(--color-surface-primary); color: var(--color-text-primary); }
.file-search input:focus, .new-item input:focus { outline: 2px solid var(--color-border-focus); outline-offset: 1px; }
.tree { padding: var(--space-xs); flex: 1; min-height: 80px; overflow: auto; }
.context-menu { position: fixed; z-index: 1000; display: grid; min-width: 130px; padding: var(--space-xs); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-background-primary); box-shadow: var(--shadow-md); }
.context-menu button { text-align: left; }
.context-menu .danger { color: var(--color-error); }
@@ -0,0 +1,14 @@
import { expect, it } from 'vitest'
import { noteOutline } from './outline'
it('hides legacy metadata while preserving editor heading positions', () => {
const source = '***\n\ntitle: Python\ntags: python\n---\n\n# Variables\n'
expect(noteOutline(source)).toEqual([{ index: 0, level: 1, title: 'Variables', offset: source.indexOf('# Variables') }])
})
it('keeps duplicate headings distinct and skips code fences', () => {
const source = '# Same\n\n```md\n# Not a heading\n```\n\n## Same\n\nSetext\n---\n'
expect(noteOutline(source).map(h => [h.index, h.level, h.title, source.slice(h.offset, h.offset + 2)])).toEqual([
[0, 1, 'Same', '# '], [1, 2, 'Same', '##'], [2, 2, 'Setext', 'Se'],
])
})
@@ -0,0 +1,20 @@
import { marked } from 'marked'
import { splitNoteMetadata } from '../editor/noteMetadata'
export interface OutlineHeading { index: number; level: number; title: string; offset: number }
export function noteOutline(source: string): OutlineHeading[] {
const headings: OutlineHeading[] = []
const metadata = splitNoteMetadata(source)
let offset = metadata?.prefix.length ?? 0
let headingIndex = 0
for (const token of marked.lexer(metadata?.body ?? source)) {
const start = source.indexOf(token.raw, offset)
if (token.type === 'heading') {
const index = headingIndex++
headings.push({ index, level: token.depth, title: token.text.replace(/[*_`]/g, ''), offset: Math.max(0, start) })
}
if (start >= 0) offset = start + token.raw.length
}
return headings
}
+38 -24
View File
@@ -1,31 +1,45 @@
import mermaid from 'mermaid'
import { ref, watch } from 'vue'
import { computed } from 'vue'
import { useThemeStore } from '@/stores/theme'
let initialized = false
let initTheme: 'light' | 'dark' = 'light'
export function mermaidThemeVariables(dark: boolean) {
const style = typeof document === 'undefined' ? null : getComputedStyle(document.documentElement)
const color = (name: string, fallback: string) => style?.getPropertyValue(`--color-${name}`).trim() || fallback
const text = color('text-primary', dark ? '#e6edf3' : '#1f2328')
const border = color('border-default', dark ? '#484f58' : '#d0d7de')
const surface = color('surface-primary', dark ? '#161b22' : '#ffffff')
const primary = color('accent-soft', dark ? '#30363d' : '#eef0ff')
const line = color('text-secondary', dark ? '#b1bac4' : '#656d76')
return {
darkMode: dark, background: surface, primaryColor: primary, primaryTextColor: text, primaryBorderColor: border,
secondaryColor: color('info-soft', primary), secondaryTextColor: text, secondaryBorderColor: border,
tertiaryColor: color('success-soft', primary), tertiaryTextColor: text, tertiaryBorderColor: border,
textColor: text, lineColor: line, mainBkg: primary, nodeBorder: border,
clusterBkg: surface, clusterBorder: border, edgeLabelBackground: surface,
actorBkg: primary, actorBorder: border, actorTextColor: text, actorLineColor: line,
signalColor: line, signalTextColor: text, labelBoxBkgColor: surface, labelBoxBorderColor: border, labelTextColor: text,
noteBkgColor: color('warning-soft', primary), noteTextColor: text, noteBorderColor: border,
activationBkgColor: primary, activationBorderColor: border,
}
}
function ensureInitialized(theme: 'light' | 'dark') {
if (!initialized) {
mermaid.initialize({
startOnLoad: false,
theme: theme === 'dark' ? 'dark' : 'default',
theme: 'base',
themeVariables: mermaidThemeVariables(theme === 'dark'),
securityLevel: 'strict',
fontFamily: 'var(--font-ui-sans)',
flowchart: { useMaxWidth: true, htmlLabels: true },
sequence: { useMaxWidth: true },
gantt: { useMaxWidth: true },
})
initialized = true
initTheme = theme
return
}
if (initTheme !== theme) {
mermaid.initialize({
theme: theme === 'dark' ? 'dark' : 'default',
})
initTheme = theme
}
}
let queue: Promise<unknown> = Promise.resolve()
function serialized<T>(work: () => Promise<T>): Promise<T> {
const result = queue.then(work)
queue = result.catch(() => {})
return result
}
export interface MermaidRenderResult {
@@ -43,7 +57,11 @@ export interface MermaidParseError {
let renderCounter = 0
export async function renderMermaid(
export function renderMermaid(source: string, options: { theme?: 'light' | 'dark'; mode?: 'interactive' | 'static' } = {}): Promise<MermaidRenderResult> {
return serialized(() => renderMermaidNow(source, options))
}
async function renderMermaidNow(
source: string,
options: { theme?: 'light' | 'dark'; mode?: 'interactive' | 'static' } = {}
): Promise<MermaidRenderResult> {
@@ -106,18 +124,14 @@ function escapeXml(str: string): string {
export function useMermaidTheme() {
const themeStore = useThemeStore()
const mermaidTheme = ref<'light' | 'dark'>(themeStore.isDark ? 'dark' : 'light')
watch(() => themeStore.isDark, (isDark) => {
mermaidTheme.value = isDark ? 'dark' : 'light'
ensureInitialized(mermaidTheme.value)
})
return { mermaidTheme }
const mermaidTheme = computed<'light' | 'dark'>(() => themeStore.isDark ? 'dark' : 'light')
const themeId = computed(() => themeStore.currentThemeId)
return { mermaidTheme, themeId }
}
export async function validateMermaid(source: string): Promise<{ valid: boolean; error?: MermaidParseError }> {
try {
ensureInitialized('light')
await mermaid.parse(source)
await serialized(async () => { ensureInitialized('light'); await mermaid.parse(source) })
return { valid: true }
} catch (error) {
const message = error instanceof Error ? error.message : '未知错误'
@@ -0,0 +1,22 @@
// @vitest-environment happy-dom
import { afterEach, expect, it, vi } from 'vitest'
import mermaid from 'mermaid'
import { mermaidThemeVariables, renderMermaid } from './mermaidService'
vi.mock('mermaid', () => ({ default: { initialize: vi.fn(), render: vi.fn().mockResolvedValue({ svg: '<svg viewBox="0 0 10 10"></svg>' }) } }))
afterEach(() => { document.documentElement.removeAttribute('style'); vi.clearAllMocks() })
it('uses the current theme tokens for nodes, actors, text and lines', () => {
document.documentElement.style.setProperty('--color-accent-soft', '#f3e1d8')
document.documentElement.style.setProperty('--color-text-primary', '#493f35')
const theme = mermaidThemeVariables(false)
expect(theme.primaryColor).toBe('#f3e1d8')
expect(theme.actorBkg).toBe('#f3e1d8')
expect(theme.primaryTextColor).toBe('#493f35')
expect(theme.actorTextColor).toBe('#493f35')
})
it('keeps explicit diagram styling and initializes base palette on each render', async () => {
const source = 'graph TD; A-->B; style A fill:#f9f'
await renderMermaid(source)
expect(mermaid.initialize).toHaveBeenCalledWith(expect.objectContaining({ theme: 'base', securityLevel: 'strict', themeVariables: expect.any(Object) }))
expect(mermaid.render).toHaveBeenCalledWith(expect.any(String), source)
})
+34
View File
@@ -0,0 +1,34 @@
// @vitest-environment happy-dom
import { afterEach, expect, it, vi } from 'vitest'
import { strToU8, zipSync } from 'fflate'
import { decodeThemePackage, fetchThemePackage, inspectThemePackage, MAX_THEME_BYTES } from './themePackageService'
import paper from '@/assets/themes/paper-moments.theme?raw'
afterEach(() => vi.unstubAllGlobals())
it('reads ZIP manifests under repository folders and validates the bundled CSS', async () => {
const [yaml, css] = paper.split('\n---\n')
const zip = zipSync({ 'repo-main/theme.yaml': strToU8(yaml!), 'repo-main/theme.css': strToU8(css!) })
const result = await inspectThemePackage(await decodeThemePackage(zip))
expect(result.compatible).toBe(true)
expect(result.css).toBe(css!.trim())
})
it('accepts a zipped single-file theme', async () => {
expect(await decodeThemePackage(zipSync({ 'paper.theme': strToU8(paper) }))).toBe(paper)
})
it('rejects unsafe paths, ambiguous manifests and oversized input', async () => {
await expect(decodeThemePackage(zipSync({ '../paper.theme': strToU8(paper) }))).rejects.toThrow('非法')
await expect(decodeThemePackage(zipSync({ 'theme.yaml': strToU8(paper), 'manifest.yml': strToU8(paper) }))).rejects.toThrow('多个')
await expect(decodeThemePackage(new Uint8Array(MAX_THEME_BYTES + 1))).rejects.toThrow('5 MB')
})
it('uses the same ZIP parser for URL downloads without sending credentials', async () => {
const fetcher = vi.fn().mockResolvedValue(new Response(zipSync({ 'paper.theme': strToU8(paper) })))
vi.stubGlobal('fetch', fetcher)
expect(await fetchThemePackage('https://example.com/theme.zip')).toBe(paper)
expect(fetcher).toHaveBeenCalledWith('https://example.com/theme.zip', expect.objectContaining({ credentials: 'omit' }))
await expect(fetchThemePackage('file:///theme.zip')).rejects.toThrow('HTTP(S)')
})
it('reports HTTP and streaming size failures', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response('', { status: 404 })).mockResolvedValueOnce(new Response(new Uint8Array(MAX_THEME_BYTES + 1))))
await expect(fetchThemePackage('https://example.com/theme')).rejects.toThrow('404')
await expect(fetchThemePackage('https://example.com/theme')).rejects.toThrow('5 MB')
})
+89 -49
View File
@@ -1,7 +1,83 @@
import type { InstalledTheme, ThemeManifest, ThemePackageInspection } from '@/contracts'
import paperMomentsPackage from '@/assets/themes/paper-moments.theme?raw'
const STORAGE_KEY = 'installed-themes'
const ACTIVE_CUSTOM_KEY = 'active-custom-theme'
export const MAX_THEME_BYTES = 5 * 1024 * 1024
/** Normalize all transports to the existing single-file inspection format. */
export async function decodeThemePackage(bytes: Uint8Array): Promise<string> {
if (bytes.length > MAX_THEME_BYTES) throw new Error('主题包不能超过 5 MB')
const decode = (data: Uint8Array) => new TextDecoder('utf-8', { fatal: true }).decode(data)
if (bytes[0] !== 0x50 || bytes[1] !== 0x4b) return decode(bytes)
const { unzipSync } = await import('fflate')
let total = 0
let count = 0
const names = new Set<string>()
const safePath = (path: string) => path.length > 0 && !path.startsWith('/') && !path.includes('\\') && !path.includes(':') && !path.split('/').some(part => part === '..' || part === '.')
const files = unzipSync(bytes, { filter: file => {
if (!safePath(file.name) || names.has(file.name)) throw new Error('ZIP 包含非法或重复路径')
names.add(file.name)
total += file.originalSize
if (++count > 100 || total > 10 * 1024 * 1024) throw new Error('ZIP 解压内容不能超过 10 MB 或 100 个文件')
return !file.name.endsWith('/')
} })
const entries = Object.keys(files)
const manifests = entries.filter(name => /(^|\/)(theme|manifest)\.ya?ml$/i.test(name))
if (!manifests.length) {
const single = entries.filter(name => name.endsWith('.theme'))
if (single.length !== 1) throw new Error('ZIP 需要唯一的 theme.yaml / manifest.yaml,或一个 .theme 文件')
return decode(files[single[0]!]!)
}
if (manifests.length !== 1) throw new Error('ZIP 中存在多个主题清单,请每包只放一个主题')
const manifestPath = manifests[0]!
const yaml = decode(files[manifestPath]!)
const manifest = inspectYamlContent(yaml)
if (!safePath(manifest.css_entry)) throw new Error('css_entry 必须是包内相对路径')
const base = manifestPath.slice(0, manifestPath.lastIndexOf('/') + 1)
const css = files[base + manifest.css_entry]
if (!css) throw new Error(`ZIP 中找不到 CSS 文件:${manifest.css_entry}`)
return `${yaml}\n---\n${decode(css)}`
}
export async function fetchThemePackage(urlText: string, signal?: AbortSignal): Promise<string> {
const url = new URL(urlText.trim())
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('请输入不含账号密码的 HTTP(S) 主题包直链')
const controller = new AbortController()
const abort = () => controller.abort()
signal?.addEventListener('abort', abort, { once: true })
if (signal?.aborted) abort()
const timeout = setTimeout(abort, 30000)
try {
const response = await fetch(url.href, { signal: controller.signal, credentials: 'omit', referrerPolicy: 'no-referrer' })
if (!response.ok) throw new Error(`下载失败:HTTP ${response.status}`)
if (Number(response.headers.get('content-length')) > MAX_THEME_BYTES) throw new Error('主题包不能超过 5 MB')
if (!response.body) throw new Error('下载内容为空')
const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let size = 0
try {
while (true) {
const { value, done } = await reader.read()
if (done) break
size += value.length
if (size > MAX_THEME_BYTES) throw new Error('主题包不能超过 5 MB')
chunks.push(value)
}
} finally { await reader.cancel() }
const bytes = new Uint8Array(size)
let offset = 0
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length }
return await decodeThemePackage(bytes)
} catch (error) {
if (controller.signal.aborted) throw new Error('下载已取消或超时,请重试')
if (error instanceof TypeError) throw new Error('无法下载,请检查直链及服务器是否允许跨域访问(CORS)')
throw error
} finally {
clearTimeout(timeout)
signal?.removeEventListener('abort', abort)
}
}
function loadStoredThemes(): InstalledTheme[] {
try {
@@ -113,8 +189,7 @@ function inspectYamlContent(yamlText: string): ThemeManifest {
* ---
* [data-theme="my-theme"] { --color-... }
*
* ZIP
*
* ZIP decodeThemePackage
*/
export function parseThemePackage(packageData: string): { manifestText: string; css: string } {
if (looksLikeZip(packageData)) {
@@ -146,19 +221,19 @@ function looksLikeZip(data: string): boolean {
}
export async function selectThemePackage(): Promise<string | null> {
return new Promise((resolve) => {
return new Promise((resolve, reject) => {
const input = document.createElement('input')
input.type = 'file'
// 只接受能在浏览器里解析的单文件主题;ZIP 需要 Host 端解压,暂不支持。
input.accept = '.yaml,.yml,.theme'
input.accept = '.yaml,.yml,.theme,.zip'
input.multiple = false
input.onchange = () => {
const file = input.files?.[0]
if (!file) { resolve(null); return }
if (file.size > MAX_THEME_BYTES) { reject(new Error('主题包不能超过 5 MB')); return }
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onload = () => { void decodeThemePackage(new Uint8Array(reader.result as ArrayBuffer)).then(resolve, reject) }
reader.onerror = () => resolve(null)
reader.readAsText(file)
reader.readAsArrayBuffer(file)
}
input.oncancel = () => resolve(null)
input.click()
@@ -280,7 +355,10 @@ export function setActiveCustomTheme(themeId: string | null) {
else localStorage.removeItem(ACTIVE_CUSTOM_KEY)
}
const paperMoments = parseThemePackage(paperMomentsPackage)
export const mockCommunityThemes: ThemeManifest[] = [
{ ...inspectYamlContent(paperMoments.manifestText), tags: ['浅色', '手帐', '纸张'] },
{
theme_id: 'ocean-blue',
name: 'Ocean Blue',
@@ -293,18 +371,6 @@ export const mockCommunityThemes: ThemeManifest[] = [
tags: ['浅色', '蓝色', '阅读'],
license: 'MIT',
},
{
theme_id: 'forest-green',
name: 'Forest Green',
version: '1.0.1',
author: 'nature-collection',
description: '森林绿色护眼主题',
min_app_version: '0.2.0',
is_dark: false,
css_entry: 'theme.css',
tags: ['浅色', '绿色', '护眼'],
license: 'MIT',
},
{
theme_id: 'midnight-purple',
name: 'Midnight Purple',
@@ -317,39 +383,12 @@ export const mockCommunityThemes: ThemeManifest[] = [
tags: ['深色', '紫色', '极客'],
license: 'Apache-2.0',
},
{
theme_id: 'solarized-light',
name: 'Solarized Light',
version: '1.1.0',
author: 'solarized',
description: '经典 Solarized 浅色主题',
min_app_version: '0.1.0',
is_dark: false,
css_entry: 'theme.css',
tags: ['浅色', '经典', '阅读'],
license: 'MIT',
},
{
theme_id: 'dracula',
name: 'Dracula',
version: '3.0.0',
author: 'dracula-theme',
description: '流行的 Dracula 暗色主题',
min_app_version: '0.2.0',
is_dark: true,
css_entry: 'theme.css',
tags: ['深色', '紫色', '高对比'],
license: 'MIT',
},
]
function buildCommunityThemeCss(themeId: string, isDark: boolean, accent: string): string {
function buildCommunityThemeCss(themeId: string, isDark: boolean): string {
const palettes: Record<string, { primary: string; soft: string; hover: string }> = {
'ocean-blue': { primary: '#0077b6', soft: '#e0f0fa', hover: '#005f92' },
'forest-green': { primary: '#2d6a4f', soft: '#e8f5ec', hover: '#1b4332' },
'midnight-purple': { primary: '#9d4edd', soft: '#2b1a3e', hover: '#7b2cbf' },
'solarized-light': { primary: '#b58900', soft: '#fdf6e3', hover: '#8a6d0b' },
'dracula': { primary: '#bd93f9', soft: '#2d2a3e', hover: '#a77bf5' },
}
const p = palettes[themeId] ?? palettes['ocean-blue']
if (isDark) {
@@ -415,12 +454,13 @@ function buildCommunityThemeCss(themeId: string, isDark: boolean, accent: string
export async function installCommunityTheme(themeId: string): Promise<InstalledTheme> {
const themeManifest = mockCommunityThemes.find((t) => t.theme_id === themeId)
if (!themeManifest) throw new Error('THEME_PACKAGE_NOT_FOUND')
const css = buildCommunityThemeCss(themeId, themeManifest.is_dark, themeManifest.theme_id)
const css = getCommunityThemePreviewCss(themeId)
return installTheme(themeManifest, css)
}
export function getCommunityThemePreviewCss(themeId: string): string {
if (themeId === 'paper-moments') return paperMoments.css
const t = mockCommunityThemes.find((m) => m.theme_id === themeId)
if (!t) return ''
return buildCommunityThemeCss(themeId, t.is_dark, themeId)
return buildCommunityThemeCss(themeId, t.is_dark)
}
@@ -50,6 +50,25 @@ afterEach(() => {
})
describe('workspaceService backend adapter', () => {
it.each([
['tags:\n- python\n- rust', { tags: ['python', 'rust'] }],
['tags: []', { tags: [] }],
['tags:', { tags: [] }],
['tags: ["a,b", rust]', { tags: ['a,b', 'rust'] }],
['title: Demo', {}],
['tags: [broken', {}],
])('saves explicit metadata tags with the same Markdown snapshot: %s', async (yaml, tagPayload) => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockImplementation(async (input) => String(input) === '/api/workspace/open'
? jsonResponse(workspaceSnapshot) : jsonResponse({}))
await workspaceService.openVault('C:\\data\\vault')
const markdown = `---\n${yaml}\n---\n# Body\n`
await workspaceService.saveFileContent('/课程/操作系统.md', markdown)
const patchCall = fetchMock.mock.calls.find(([, init]) => init?.method === 'PATCH')
expect(String(patchCall?.[0])).toBe('/api/notes/note-os')
expect(JSON.parse(String(patchCall?.[1]?.body))).toEqual({ markdown, ...tagPayload })
})
it('opens the configured Vault and reads/saves Markdown through Note API', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockImplementation(async (input, init) => {
+7 -1
View File
@@ -9,6 +9,7 @@ import type {
import apiClient from './apiClient'
import { t } from '@/i18n'
import * as noteService from './noteService'
import { splitNoteMetadata } from '@/utils/noteMetadata'
/** Web 联调只连接 AI Core 配置的单一 Vault;多 Vault 选择由 Tauri Host 接管。 */
export interface VaultInfo {
@@ -122,7 +123,12 @@ export async function getNoteId(filePath: string): Promise<string> {
}
export async function saveFileContent(filePath: string, content: string): Promise<void> {
await noteService.updateNote(await requireNoteId(filePath), { markdown: content })
const metadata = splitNoteMetadata(content)
await noteService.updateNote(await requireNoteId(filePath), {
markdown: content,
// Explicit [] clears the index; absent tags retain API-managed tags.
...(metadata?.hasTags ? { tags: metadata.tags } : {}),
})
}
export async function createFile(
+6
View File
@@ -13,6 +13,10 @@ export const useEditorStore = defineStore('editor', () => {
const currentFilePath = ref<string | null>(null)
const highlightBlockId = ref<string | null>(null)
const cursorPosition = ref({ line: 0, column: 0 })
const headingRequest = ref<{ index: number; offset: number; path: string | null } | null>(null)
function jumpToHeading(index: number, offset: number) {
headingRequest.value = { index, offset, path: currentFilePath.value }
}
const wordCount = computed(() => {
const text = content.value.replace(/[#*`>\-_\[\]()!]/g, '')
@@ -144,6 +148,8 @@ export const useEditorStore = defineStore('editor', () => {
}
return {
headingRequest,
jumpToHeading,
mode,
content,
saveStatus,
+54
View File
@@ -0,0 +1,54 @@
import { isMap, isScalar, isSeq, parseDocument } from 'yaml'
export interface NoteMetadata {
prefix: string
yaml: string
body: string
title: string
tags: string[]
hasTags: boolean
}
function parseProperties(yaml: string) {
const document = parseDocument(yaml)
// Unsupported YAML stays available in source mode without partial rewriting.
if (document.errors.length || document.warnings.length || !isMap(document.contents)) return null
return document
}
export function splitNoteMetadata(source: string): NoteMetadata | null {
const match = source.match(/^\uFEFF?(---|\*\*\*)[ \t]*\r?\n([\s\S]*?)\r?\n(?:-{3,}|\.\.\.)[ \t]*(?:\r?\n|$)/)
if (!match) return null
const yaml = match[2]!
const document = parseProperties(yaml)
if (!document || (!document.has('title') && !document.has('tags'))) return null
const title = document.get('title') ?? ''
if (typeof title !== 'string') return null
const tagNode = document.get('tags', true)
let tags: string[] = []
if (isSeq(tagNode)) {
// Do not remove anchored list items that other properties may reference.
if (!tagNode.items.every(item => isScalar(item) && typeof item.value === 'string' && !item.anchor)) return null
tags = tagNode.items.map(item => (item as { value: string }).value)
} else if (isScalar(tagNode)) {
if (typeof tagNode.value === 'string') tags = tagNode.value.split(',').map(tag => tag.trim()).filter(Boolean)
else if (tagNode.value !== null) return null
} else if (tagNode !== undefined) return null
return { prefix: match[0], yaml, body: source.slice(match[0].length), title, tags, hasTags: document.has('tags') }
}
export function updateMetadataTags(metadata: NoteMetadata, tags: string[]): string {
const document = parseProperties(metadata.yaml)
if (!document) throw new Error('Invalid note metadata')
const previous = document.get('tags', true)
const replacement = document.createNode([...new Set(tags)])
if (isScalar(previous) || isSeq(previous)) {
replacement.anchor = previous.anchor
replacement.comment = previous.comment
replacement.commentBefore = previous.commentBefore
}
document.set('tags', replacement)
const newline = metadata.prefix.includes('\r\n') ? '\r\n' : '\n'
const prefix = `---\n${document.toString()}---\n`.replace(/\n/g, newline)
return (metadata.prefix.startsWith('\uFEFF') ? '\uFEFF' : '') + prefix
}