Compare commits

...
Author SHA1 Message Date
yxx 6c14047899 Merge remote-tracking branch 'origin/main' into feat/export-service 2026-09-06 17:48:25 +08:00
yxxandClaude Code 780e24a399 fix(export): 修复引用块正文丢失、嵌套列表顺序与排队取消
- 引用块直接子节点为块级节点,PDF/DOCX 改为逐个渲染并继承缩进/颜色,
  不再交给行内渲染器导致正文丢失
- PDF 嵌套列表先输出父级正文再输出子列表,修复顺序颠倒
- 等待渲染槽位期间保持 queued 并监听取消,取消即时生效
- DOCX 列表项补处理直接 text 子节点,避免正文被块级渲染器丢弃
- 补充引用块/嵌套列表/排队取消的结构内容回归测试
- 接口契约同步 html/pdf/docx 三格式均已实现,移除 EXPORT_FORMAT_UNSUPPORTED

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-06 17:48:18 +08:00
yxx dffafce8b9 Merge remote-tracking branch 'origin/main' into feat/export-service
# Conflicts:
#	.gitignore
#	backend/app/main.py
2026-09-06 17:22:57 +08:00
Kronecker e29cc427e4 Merge pull request 'feat(editor): 添加文档滚动导航并统一六主题 Markdown 行为样式' (#35) from perf/frontend-chunk-loading into main
Reviewed-on: #35
2026-09-06 17:21:12 +08:00
admin 174e723545 fix(themes): share code line layout with isolated previews 2026-09-06 17:20:12 +08:00
admin f0fe8f2629 feat(editor): add scroll navigation and update theme Markdown behavior 2026-09-06 17:15:41 +08:00
yxxandClaude Code 406dd42571 feat(export): 新增 PDF/DOCX 导出与 StaticRenderer 内部契约
- 新增 PdfExporter(reportlab)与 DocxExporter(python-docx),实现与
  HtmlExporter 一致的同步 render + 异步 export,v1 文本优先(标题/段落/
  行内强调与链接/列表/引用/表格/代码块/数学文本),function_plot 与 mermaid
  保留源码占位并记 warning。
- service 层加 _EXPORTERS 注册表按格式分发,删除 format!=html 硬限制,
  扩展名/MIME/产物清理泛化到 html/pdf/docx 三种格式。
- 新增 app/plot/renderer.py:StaticRenderRequest + StaticRenderer Protocol +
  FunctionPlotStaticRenderer + MermaidStaticRenderer;HtmlExporter 改经
  FunctionPlotStaticRenderer 消费,去除对 render_svg 的直接依赖。
- 补齐 PDF/DOCX 魔法字节、CJK 字体、占位 warning 与 StaticRenderer 契约测试。
- 更新 Export开发说明.md。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-06 17:04:58 +08:00
Kronecker 3dcd469bc0 Merge pull request 'Perf/frontend chunk loading优化长文渲染、后台运行与向量检索,补齐运行日志和并发一致性' (#34) from perf/frontend-chunk-loading into main
Reviewed-on: #34
2026-09-06 17:01:01 +08:00
admin f32971d32e fix: coordinate vector migration with concurrent searches and note saves 2026-09-06 16:57:37 +08:00
admin b03b168920 perf: persist model-isolated sqlite-vec indexes and index new notes incrementally 2026-09-06 16:34:26 +08:00
admin 3b9490e3fb fix: stabilize background operations and large embedding results 2026-09-06 16:26:17 +08:00
yxx 966a94cad8 Merge remote-tracking branch 'origin/main' into feat/export-service
# Conflicts:
#	backend/app/routes.py
2026-09-06 16:19:37 +08:00
admin 874e916106 perf(editor): reduce long-document decoration work and fix fold navigation 2026-09-06 14:30:56 +08:00
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
Kronecker 5a3546b3ac Merge pull request 'Perf/frontend chunk loading优化前端构建加载,完善 Markdown 预设、警告框、章节折叠与外部文件刷新' (#32) from perf/frontend-chunk-loading into main
Reviewed-on: #32
2026-09-06 13:42:57 +08:00
admin 750e17212e fix(editor): recover missing files and synchronize section toggle 2026-09-06 13:29:19 +08:00
admin d4ba08b944 docs(vault): 更新产品文档和演示导航内容
更新RAG检索增强文档,添加换行标记以改善显示格式。

优化功能演示导航表格布局,调整列宽使内容更易读。

清理多个演示文档的多余空行,统一文档格式规范。
2026-09-06 12:59:22 +08:00
admin 8ad1db33f7 feat(editor): add markdown presets, heading folding and external file refresh 2026-09-06 12:57:09 +08:00
admin 415efc4444 feat(editor): add themed callouts and desktop command boundary 2026-09-06 11:33:19 +08:00
admin fcc319d5e1 perf(frontend): defer mermaid and split editor dependencies 2026-09-06 10:42:12 +08:00
admin 64af068df4 docs: document background indexing and merged phase two behavior 2026-09-06 03:06:40 +08:00
Kronecker b265a5528a Merge pull request 'Feat/model usage charts and paper cards完善第二阶段功能与验收:后台索引、图表交互、扩展社区及第三阶段规划' (#31) from feat/model-usage-charts-and-paper-cards into main
Reviewed-on: #31
2026-09-06 03:02:15 +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
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
182 changed files with 20301 additions and 322 deletions
+3
View File
@@ -18,6 +18,9 @@ backend/.env
# 运行期生成的 SQLite 索引(vault 下的 Markdown 测试数据需提交)
backend/data/*.db*
backend/data/credentials/
# 运行期导出的 HTML/PDF/DOCX 产物(不提交)
backend/data/exports/
backend/data/logs/
# 阶段验收笔记(验收用,不提交)
backend/data/vault/验收/
# 本机 MCP 配置、授权状态及服务器工作目录不得提交。
+17 -1
View File
@@ -4,7 +4,7 @@
NotesAgent 是本地优先的 AI 笔记与知识库项目。当前可运行形态为 Vue/Vite Web 前端与 FastAPI AI CoreMarkdown 和附件保存在本地 Vault,SQLite 管理元数据、全文索引、向量空间、搜索历史、AI 会话、任务、Agent Trace、多模态任务及运行诊断。AI 对话已接入知识库检索,会话与消息由后端持久化并供 Web 和桌面客户端共用。
截至 2026-09-05,第一阶段及第二阶段 A~F 的工程范围已经合并到 `main`。当前已完成真实 Workspace、混合检索与知识库问答、Agent/Tool/Permission、Skill/Plugin、MCP 配置与调用、模型提供商与路由、RAG Benchmark,以及本地 Embedding、音频转写和片段级声纹聚类。Tauri/Rust Host、Stronghold、原生多 Vault 文件系统、生产级 MCP 沙箱和 Sync Server 尚未接入。
截至 2026-09-06,第一阶段及第二阶段 A~F 的工程范围已经合并到 `main`。当前已完成真实 Workspace、混合检索与知识库问答、Agent/Tool/Permission、Skill/Plugin、MCP 配置与调用、模型提供商与路由、RAG Benchmark,以及本地 Embedding、音频转写和片段级声纹聚类。Tauri/Rust Host、Stronghold、原生多 Vault 文件系统、生产级 MCP 沙箱和 Sync Server 尚未接入。
## 目录
@@ -26,8 +26,24 @@ NotesAgent/
- 多模态:API 优先,未配置或响应无效时回退本地;`local_only` 禁止远程调用。任务、修订、事件、来源和回退原因写入 SQLite。
- 模型运行:默认 CPU,可选 CUDA 12.8 组件;固定模型 revision,按需启动独立子进程,交互检索优先排队,CUDA 初始化或显存失败时用同一冻结配置在 CPU 重试一次。
- 可观测性:输入、输出、缓存命中、推理 Token 与音频用量卡片;本地运行诊断保留最近 200 条,不保存正文、文件路径、密钥或异常全文。
- 运行日志:统一查看向量/模型错误、Agent、任务与 HTTP 操作;独立后台存储最近 20,000 条,支持错误码/关联 ID 筛选和游标分页。入口无需打开 Vault,详见 [后台运行日志与压力问题修复](docs/development/后台运行日志与压力问题修复.md)。
- 界面偏好:设置页可即时切换全局中文/英文界面,并控制由系统词典提供的编辑器拼写检查;偏好目前保存于 Web 端设备配置,后续由 Tauri 配置存储接管。
## 第二阶段最新合并(2026-09-06
PR #31 已合并。工作区打开与 HTTP 保存不再等待向量推理;正文和全文索引先可用,向量随后后台更新。“已保存”与“向量就绪”是两个独立状态。Skill / Plugin 支持 ZIP 安装与本地安装状态恢复,并已提供功能示例包;远程社区仍是第三阶段计划。
新增开发说明:
- [工作区后台索引与保存](docs/development/工作区后台索引与保存开发说明.md):状态、并发、恢复和验证。
- [模型隔离向量索引与增量登记](docs/development/模型隔离向量索引与增量登记.md):持久化 sqlite-vec 空间、旧向量复用、外部新增文件增量计算与检索性能验证。
- [Mermaid 预览与缩放](docs/development/Mermaid预览与缩放开发说明.md):大图适配、鼠标缩放和文字裁切修复。
- [扩展安装持久化与社区包](docs/development/扩展安装持久化与社区包开发说明.md):安装边界和示例包验证。
- [模型上下文管理](docs/development/模型上下文管理.md):全局人设、预算估算和摘要限制。
- [第三阶段实施规划](docs/architecture/第三阶段实施规划.md)Tauri Rust 容器、各社区与 Sync Server。
代码基线 `a5c44c4` 的验证结果为后端 621 项、前端 345 项测试通过,前端生产构建通过。这是该提交的回归记录,不表示全部真实厂商及设备场景完成专项验收。
## 本地模型
| 能力 | 当前模型 | 许可 | 说明 |
+6
View File
@@ -101,3 +101,9 @@ uv run pytest
- [阶段 FEmbedding 与知识库问题](../docs/retrospectives/阶段F-Embedding与知识库问题与解决方案.md)
机器可读接口以运行中的 `/openapi.json` 为准。
## 工作区保存与扩展恢复(2026-09-06)
HTTP 保存先写正文、元数据及 FTS,再调度后台向量更新;打开 Vault 的向量计算也不再阻塞入口。手动全量重建接口仍等待完成。待处理标记持久化,重新打开 Vault 可恢复处理;任务详情不是完整持久化队列。
实现与验证见 [工作区后台索引与保存](../docs/development/工作区后台索引与保存开发说明.md)。扩展安装日志、ZIP 限制和社区包测试见 [扩展安装持久化与社区包](../docs/development/扩展安装持久化与社区包开发说明.md)。
+51
View File
@@ -0,0 +1,51 @@
"""Serialize and batch durable Trace writes off the asyncio event loop."""
import asyncio
from contextvars import copy_context
class AsyncTraceWriter:
def __init__(self, repository):
self.repository = repository
self.queue = asyncio.Queue(maxsize=1024)
self.worker = None
async def submit(self, operation, *args):
future = asyncio.get_running_loop().create_future()
await self.queue.put((operation, args, future))
if self.worker is None or self.worker.done():
self.worker = asyncio.create_task(self._drain())
# Cancellation must not let an older snapshot commit after cancellation.
cancelled = False
while not future.done():
try:
await asyncio.shield(future)
except asyncio.CancelledError:
cancelled = True
future.result()
return cancelled
async def _drain(self):
while not self.queue.empty():
batch = []
while len(batch) < 64 and not self.queue.empty():
batch.append(self.queue.get_nowait())
try:
work = asyncio.get_running_loop().run_in_executor(
None, copy_context().run, self.repository.write_batch, [(op, args) for op, args, _ in batch])
# asyncio.run/shutdown may cancel every Task simultaneously. The
# executor Future survives; finish it and release all waiters.
while not work.done():
try:
await asyncio.shield(work)
except asyncio.CancelledError:
pass
work.result()
except Exception as exc:
for _, _, future in batch:
future.set_exception(exc)
else:
for _, _, future in batch:
future.set_result(None)
finally:
for _ in batch:
self.queue.task_done()
+110 -59
View File
@@ -4,6 +4,8 @@ from __future__ import annotations
import asyncio
import json
from app.agent.async_trace import AsyncTraceWriter
from app.operation_logs import log_event, agent_run_id
from collections.abc import AsyncIterator
from dataclasses import dataclass, field
from datetime import datetime, timezone
@@ -65,6 +67,9 @@ class RunRecord:
subscribers: set[asyncio.Queue[AgentEvent]] = field(default_factory=set)
task: asyncio.Task[None] | None = None
next_sequence: int = 0
publish_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
cancel_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
persisted_run: AgentRun | None = None
class AgentRuntime:
@@ -84,6 +89,7 @@ class AgentRuntime:
self.skills = skills
self.trace_repository = trace_repository or AgentTraceRepository()
self._records: dict[str, RunRecord] = {}
self._writer = AsyncTraceWriter(self.trace_repository)
async def create_run(self, request: AgentRunCreateRequest) -> AgentRun:
self._prune_records()
@@ -122,19 +128,25 @@ class AgentRuntime:
skill_config=skill_config,
allowed_tools=allowed_tools,
)
self.trace_repository.create_run(
run,
request,
self._config_snapshot(record),
)
# Reserve capacity before yielding to concurrent creators.
self._records[run.run_id] = record
try:
cancelled = await self._writer.submit('create', run.model_copy(deep=True), request.model_copy(deep=True), self._config_snapshot(record))
except BaseException:
self._records.pop(run.run_id, None)
raise
record.persisted_run = run.model_copy(deep=True)
log_event('agent', 'run.created', run_id=run.run_id, provider_id=run.provider_id, model=run.model)
if cancelled:
await self._finish_cancelled(record)
raise asyncio.CancelledError
record.task = asyncio.create_task(self._execute(record), name=run.run_id)
return run.model_copy(deep=True)
def get_run(self, run_id: str) -> AgentRun:
record = self._records.get(run_id)
if record is not None:
return record.run.model_copy(deep=True)
return (record.persisted_run or record.run).model_copy(deep=True)
run = self.trace_repository.recover_interrupted(run_id)
if run is None:
raise AgentRunNotFoundError(run_id)
@@ -145,7 +157,7 @@ class AgentRuntime:
recovered = [
self.trace_repository.recover_interrupted(item.run_id) or item
if item.run_id not in self._records
else self._records[item.run_id].run.model_copy(deep=True)
else (self._records[item.run_id].persisted_run or self._records[item.run_id].run).model_copy(deep=True)
for item in items
]
return recovered, total
@@ -154,25 +166,24 @@ class AgentRuntime:
record = self._records.get(run_id)
if record is None:
return self.get_run(run_id)
if record.run.status in TERMINAL_STATUSES:
return record.run.model_copy(deep=True)
record.run.cancelled = True
record.run.status = AgentRunStatus.cancelled
record.run.updated_at = datetime.now(timezone.utc)
self.permissions.cancel_run(run_id)
self._publish(record, AgentEventType.run_cancelled, {})
async with record.cancel_lock:
if record.task and not record.task.done():
if record.run.status not in TERMINAL_STATUSES:
record.task.cancel()
return record.run.model_copy(deep=True)
self.permissions.cancel_run(run_id)
await asyncio.gather(record.task, return_exceptions=True)
if record.run.status not in TERMINAL_STATUSES:
await self._finish_cancelled(record)
return (record.persisted_run or record.run).model_copy(deep=True)
def resolve_permission(self, run_id: str, request_id: str, decision: str) -> bool:
async def resolve_permission(self, run_id: str, request_id: str, decision: str) -> bool:
record = self._records.get(run_id)
if record is None:
return False
ticket = self.permissions.get_ticket(run_id, request_id)
resolved = self.permissions.resolve(run_id, request_id, decision)
if resolved:
self._publish(
await self._publish(
record,
AgentEventType.permission_resolved,
{
@@ -189,23 +200,24 @@ class AgentRuntime:
record = self._records.get(run_id)
run = self.get_run(run_id)
if record is None:
for event in self.trace_repository.list_events(
for event in await asyncio.to_thread(self.trace_repository.list_events,
run_id, after_sequence=after_sequence
):
yield event
return
# 先注册订阅再读持久化历史;同一事件循环内没有 await,不会丢失交界事件
# 先注册再异步读取历史;历史与实时队列的交界用 sequence 去重
queue: asyncio.Queue[AgentEvent] = asyncio.Queue()
record.subscribers.add(queue)
history = self.trace_repository.list_events(
run_id, after_sequence=after_sequence
)
last_sequence = after_sequence
try:
history = await asyncio.to_thread(self.trace_repository.list_events,
run_id, after_sequence=after_sequence)
for event in history:
last_sequence = event.sequence
yield event
if event.event in {AgentEventType.run_completed, AgentEventType.run_failed, AgentEventType.run_cancelled}:
return
if run.status in TERMINAL_STATUSES:
return
while True:
@@ -232,7 +244,7 @@ class AgentRuntime:
await asyncio.shield(record.task)
except asyncio.CancelledError:
pass
return record.run.model_copy(deep=True)
return (record.persisted_run or record.run).model_copy(deep=True)
def get_trace(
self, run_id: str, *, after_sequence: int, limit: int
@@ -246,23 +258,35 @@ class AgentRuntime:
return trace
async def _execute(self, record: RunRecord) -> None:
token = agent_run_id.set(record.run.run_id)
try:
async with asyncio.timeout(record.request.run_timeout_seconds):
await self._run_loop(record)
except asyncio.CancelledError:
if record.run.status != AgentRunStatus.cancelled:
self._finish_cancelled(record)
await self._finish_cancelled(record)
except TimeoutError:
self._fail(record, "AGENT_TIMEOUT", "Agent run exceeded its timeout.")
await self._fail(record, "AGENT_TIMEOUT", "Agent run exceeded its timeout.")
except ProviderError as exc:
self._fail(record, exc.code, exc.message)
await self._fail(record, exc.code, exc.message)
except Exception as exc:
self._fail(record, "AGENT_FAILED", str(exc))
log_event('agent', 'execution.failed', level='ERROR', error=exc, run_id=record.run.run_id)
await self._fail(record, "AGENT_FAILED", str(exc))
finally:
self.permissions.cancel_run(record.run.run_id)
agent_run_id.reset(token)
async def shutdown(self) -> None:
results = await asyncio.gather(*(self.cancel(run_id) for run_id in list(self._records)), return_exceptions=True)
for result in results:
if isinstance(result, BaseException):
log_event('agent', 'shutdown.failed', level='ERROR', error=result)
await self._writer.queue.join()
async def _run_loop(self, record: RunRecord) -> None:
record.run.status = AgentRunStatus.running
record.run.updated_at = datetime.now(timezone.utc)
self._publish(
await self._publish(
record,
AgentEventType.run_started,
{"provider_id": record.request.provider_id, "model": record.request.model},
@@ -277,7 +301,7 @@ class AgentRuntime:
record.run.updated_at = datetime.now(timezone.utc)
model_call_id = f"model_call_{uuid4().hex}"
started_at = perf_counter()
self._publish(
await self._publish(
record,
AgentEventType.model_call_started,
{
@@ -299,7 +323,7 @@ class AgentRuntime:
)
)
except Exception as exc:
self._publish(
await self._publish(
record,
AgentEventType.model_call_failed,
{
@@ -309,7 +333,7 @@ class AgentRuntime:
},
)
raise
self._publish(
await self._publish(
record,
AgentEventType.model_call_completed,
{
@@ -322,7 +346,7 @@ class AgentRuntime:
},
)
record.run.token_usage += turn.input_tokens + turn.output_tokens
self._publish(
await self._publish(
record,
AgentEventType.usage,
{"token_usage": record.run.token_usage},
@@ -331,12 +355,12 @@ class AgentRuntime:
record.request.token_budget is not None
and record.run.token_usage > record.request.token_budget
):
self._fail(record, "TOKEN_BUDGET_EXCEEDED", "Agent token budget exceeded.")
await self._fail(record, "TOKEN_BUDGET_EXCEEDED", "Agent token budget exceeded.")
return
if turn.tool_calls:
if len(turn.tool_calls) > MAX_TOOL_CALLS_PER_TURN:
self._fail(
await self._fail(
record,
"TOO_MANY_TOOL_CALLS",
f"Provider requested more than {MAX_TOOL_CALLS_PER_TURN} tools in one turn.",
@@ -360,10 +384,17 @@ class AgentRuntime:
async with semaphore:
return await self._execute_tool(record, call, model_call_id)
results = await asyncio.gather(*(execute(call) for call in calls))
executions = [asyncio.create_task(execute(call)) for call in calls]
try:
results = await asyncio.gather(*executions)
finally:
for execution in executions:
if not execution.done():
execution.cancel()
await asyncio.gather(*executions, return_exceptions=True)
for call, result in zip(calls, results):
record.run.tool_results.append(result)
self._collect_citations(record, result)
await self._collect_citations(record, result)
messages.append(
Message(
role=MessageRole.tool,
@@ -376,20 +407,20 @@ class AgentRuntime:
if turn.text is not None:
record.run.output = turn.text
self._publish(record, AgentEventType.text_delta, {"text": turn.text})
await self._publish(record, AgentEventType.text_delta, {"text": turn.text})
record.run.status = AgentRunStatus.completed
record.run.updated_at = datetime.now(timezone.utc)
self._publish(
await self._publish(
record,
AgentEventType.run_completed,
{"output": turn.text, "token_usage": record.run.token_usage},
)
return
self._fail(record, "EMPTY_MODEL_RESPONSE", "Provider returned no text or tool call.")
await self._fail(record, "EMPTY_MODEL_RESPONSE", "Provider returned no text or tool call.")
return
self._fail(record, "MAX_STEPS_EXCEEDED", "Agent reached its maximum step count.")
await self._fail(record, "MAX_STEPS_EXCEEDED", "Agent reached its maximum step count.")
async def _execute_tool(
self, record: RunRecord, call: ToolCall, parent_model_call_id: str
@@ -397,7 +428,7 @@ class AgentRuntime:
started_at = perf_counter()
call_data = call.model_dump(mode="json")
call_data["parent_model_call_id"] = parent_model_call_id
self._publish(record, AgentEventType.tool_call, call_data)
await self._publish(record, AgentEventType.tool_call, call_data)
try:
registered = self.tools.get(call.name)
except ToolNotFoundError:
@@ -411,7 +442,7 @@ class AgentRuntime:
error_code="TOOL_NOT_ALLOWED",
error_message="Tool is not included in allowed_tools.",
)
self._publish_tool_result(
await self._publish_tool_result(
record, result, parent_model_call_id, started_at
)
return result
@@ -425,7 +456,7 @@ class AgentRuntime:
error_code="NETWORK_NOT_ALLOWED",
error_message="Agent run does not allow network tools.",
)
self._publish_tool_result(
await self._publish_tool_result(
record, result, parent_model_call_id, started_at
)
return result
@@ -436,7 +467,7 @@ class AgentRuntime:
# 运行状态必须在等待期间可见,前端才能展示并处理权限确认卡片。
ticket = self.permissions.create_ticket(record.run.run_id, permission)
record.run.status = AgentRunStatus.waiting_permission
self._publish(
await self._publish(
record,
AgentEventType.permission_required,
{
@@ -458,13 +489,18 @@ class AgentRuntime:
error_code="PERMISSION_TIMEOUT",
error_message="Tool permission confirmation timed out.",
)
self._publish_tool_result(
await self._publish_tool_result(
record, result, parent_model_call_id, started_at
)
return result
record.run.status = AgentRunStatus.running
record.run.updated_at = datetime.now(timezone.utc)
self.trace_repository.save_run(record.run)
async with record.publish_lock:
snapshot = record.run.model_copy(deep=True)
cancelled = await self._writer.submit('save', snapshot)
record.persisted_run = snapshot
if cancelled:
raise asyncio.CancelledError
result = (
await self._invoke_tool(record, call)
if decision in {"allow_once", "allow_session"}
@@ -473,10 +509,10 @@ class AgentRuntime:
else:
result = await self._invoke_tool(record, call)
self._publish_tool_result(record, result, parent_model_call_id, started_at)
await self._publish_tool_result(record, result, parent_model_call_id, started_at)
return result
def _publish_tool_result(
async def _publish_tool_result(
self,
record: RunRecord,
result: ToolResult,
@@ -486,7 +522,7 @@ class AgentRuntime:
data = result.model_dump(mode="json")
data["parent_model_call_id"] = parent_model_call_id
data["duration_ms"] = int((perf_counter() - started_at) * 1000)
self._publish(record, AgentEventType.tool_result, data)
await self._publish(record, AgentEventType.tool_result, data)
async def _invoke_tool(self, record: RunRecord, call: ToolCall) -> ToolResult:
try:
@@ -519,28 +555,30 @@ class AgentRuntime:
error_message="Tool permission was denied.",
)
def _finish_cancelled(self, record: RunRecord) -> None:
async def _finish_cancelled(self, record: RunRecord) -> None:
record.run.cancelled = True
record.run.status = AgentRunStatus.cancelled
record.run.updated_at = datetime.now(timezone.utc)
self._publish(record, AgentEventType.run_cancelled, {})
await self._publish(record, AgentEventType.run_cancelled, {})
def _fail(self, record: RunRecord, code: str, message: str) -> None:
if record.run.status in TERMINAL_STATUSES:
async def _fail(self, record: RunRecord, code: str, message: str) -> None:
log_event('agent', 'run.error', level='ERROR', run_id=record.run.run_id, error_code=code)
if record.persisted_run and record.persisted_run.status in TERMINAL_STATUSES:
return
record.run.status = AgentRunStatus.failed
record.run.error_code = code
record.run.error_message = message
record.run.updated_at = datetime.now(timezone.utc)
self._publish(
await self._publish(
record,
AgentEventType.run_failed,
{"code": code, "message": message},
)
def _publish(
async def _publish(
self, record: RunRecord, event_type: AgentEventType, data: dict[str, object]
) -> None:
async with record.publish_lock:
sanitized = sanitize_trace_value(data)
assert isinstance(sanitized, dict)
event = AgentEvent(
@@ -550,14 +588,27 @@ class AgentRuntime:
data=sanitized,
timestamp=datetime.now(timezone.utc),
)
snapshot = record.run.model_copy(deep=True)
try:
cancelled = await self._writer.submit('event', snapshot, event)
except Exception as exc:
log_event('agent', 'trace.write_failed', level='ERROR', error=exc, run_id=record.run.run_id)
raise
record.next_sequence += 1
record.persisted_run = snapshot
record.events.append(event)
self.trace_repository.append_event(record.run, event)
log_event('agent', event_type.value,
level='ERROR' if event_type.value.endswith('Failed') or data.get('success') is False else 'INFO',
run_id=record.run.run_id, provider_id=record.run.provider_id, model=record.run.model,
sequence=event.sequence, step=record.run.current_step, status=snapshot.status.value,
tool=data.get('name'), error_code=data.get('code') or data.get('error_code'))
# 内存只保留实时订阅窗口;完整审计轨迹由 SQLite 保存。
if len(record.events) > MAX_EVENTS_PER_RUN:
del record.events[: len(record.events) - MAX_EVENTS_PER_RUN]
for queue in record.subscribers:
queue.put_nowait(event)
if cancelled:
raise asyncio.CancelledError
@staticmethod
def _request_metadata(record: RunRecord) -> dict[str, object]:
@@ -583,7 +634,7 @@ class AgentRuntime:
"metadata": record.request.metadata,
}
def _collect_citations(self, record: RunRecord, result: ToolResult) -> None:
async def _collect_citations(self, record: RunRecord, result: ToolResult) -> None:
if not result.success or not isinstance(result.output, dict):
return
items = result.output.get("items")
@@ -601,7 +652,7 @@ class AgentRuntime:
continue
known.add(citation.citation_id)
record.run.citations.append(citation)
self._publish(record, AgentEventType.citation, citation.model_dump(mode="json"))
await self._publish(record, AgentEventType.citation, citation.model_dump(mode="json"))
def _get_record(self, run_id: str) -> RunRecord:
try:
@@ -618,7 +669,7 @@ class AgentRuntime:
(
record
for record in self._records.values()
if record.run.status in TERMINAL_STATUSES
if record.run.status in TERMINAL_STATUSES and (record.task is None or record.task.done())
),
key=lambda record: record.run.updated_at,
)
+27 -8
View File
@@ -6,6 +6,7 @@ SQLite 中的事件是 SSE、前端 Trace 和 Benchmark 的共同事实来源。
from __future__ import annotations
from contextlib import nullcontext
import json
import re
from datetime import datetime, timezone
@@ -94,15 +95,30 @@ def sanitize_trace_value(
class AgentTraceRepository:
def write_batch(self, jobs):
conn = connect()
try:
with transaction(conn):
for operation, args in jobs:
if operation == 'create':
self.create_run(*args, _conn=conn)
elif operation == 'save':
self.save_run(*args, _conn=conn)
else:
self.append_event(*args, _conn=conn)
finally:
conn.close()
def create_run(
self,
run: AgentRun,
request: AgentRunCreateRequest,
config_snapshot: dict[str, Any],
*, _conn=None,
) -> None:
conn = connect()
conn = _conn or connect()
try:
with transaction(conn):
with transaction(conn) if _conn is None else nullcontext():
conn.execute(
"""
INSERT INTO agent_runs(
@@ -126,22 +142,24 @@ class AgentTraceRepository:
),
)
finally:
if _conn is None:
conn.close()
def save_run(self, run: AgentRun) -> None:
conn = connect()
def save_run(self, run: AgentRun, *, _conn=None) -> None:
conn = _conn or connect()
try:
with transaction(conn):
with transaction(conn) if _conn is None else nullcontext():
self._update_run(conn, run)
finally:
if _conn is None:
conn.close()
def append_event(self, run: AgentRun, event: AgentEvent) -> None:
def append_event(self, run: AgentRun, event: AgentEvent, *, _conn=None) -> None:
"""在同一事务中保存最新 Run 和事件;复写同一序号时保持幂等。"""
conn = connect()
conn = _conn or connect()
try:
with transaction(conn):
with transaction(conn) if _conn is None else nullcontext():
self._update_run(conn, run)
conn.execute(
"""
@@ -158,6 +176,7 @@ class AgentTraceRepository:
),
)
finally:
if _conn is None:
conn.close()
def get_run(self, run_id: str) -> AgentRun | None:
+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"))),
)
+99 -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
@@ -116,6 +123,7 @@ class NoteUpdateRequest(Contract):
title: str | None = None
markdown: str | None = None
tags: list[str] | None = None
expected_content_hash: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$")
class NoteMoveRequest(Contract):
@@ -1131,6 +1139,11 @@ class TranscriptNoteRequest(Contract):
class IndexStatus(Contract):
running_jobs: int = 0
active_searches: int = 0
completed_searches: int = 0
failed_searches: int = 0
cancelled_searches: int = 0
vector_refresh_required: bool = False
total_notes: int = 0
total_blocks: int = 0
@@ -1301,3 +1314,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)
+4
View File
@@ -25,6 +25,10 @@ class ApiError(Exception):
async def api_error_handler(_: Request, exc: ApiError) -> JSONResponse:
from app.operation_logs import log_event
log_event('api', 'operation.failed', level='ERROR' if exc.status_code >= 500 else 'WARNING',
error=exc, status=exc.status_code,
**{key: value for key, value in exc.details.items() if key in {'run_id', 'task_id', 'note_id', 'job_id', 'provider_id'}})
body = ErrorResponse(
error=ErrorDetail(code=exc.code, message=exc.message, details=exc.details)
)
+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 → 具体格式产物。"""
+37
View File
@@ -0,0 +1,37 @@
"""导出器共享工具:URL 协议校验与占位 warning 文案。
html / pdf / docx 三个导出器共用同一套安全规则,避免各写一份导致行为漂移。
"""
from __future__ import annotations
from datetime import datetime
from urllib.parse import urlparse
# 链接/图片地址允许的协议;无 scheme 的相对地址视为安全,其余协议一律降级
ALLOWED_URL_SCHEMES = frozenset({"http", "https", "mailto"})
MERMAID_WARNING = "mermaid 需前端渲染,已保留为占位代码块"
RAW_HTML_WARNING = "原始 HTML 已按纯文本转义保留"
# PDF/DOCX 暂不支持静态渲染函数图像,统一回退源码占位
PLOT_PLACEHOLDER_WARNING = "函数图像:该格式暂不支持静态渲染,已保留为源码占位"
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
def format_meta_value(value: object) -> str:
"""把元数据值转成可读文本:datetime 转 ISO、列表用逗号连接。"""
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, list):
return ", ".join(str(item) for item in value)
return str(value)
+341
View File
@@ -0,0 +1,341 @@
"""DocxExporterDocument AST → DOCXpython-docx)。
v1 为文本优先:标题/段落/行内强调与链接/列表/引用/表格/代码块/数学文本均可导出;
function_plot 与 mermaid 保留源码占位并记 warning。中文字体通过 Normal 样式挂载
w:eastAsia=宋体,保证 Word 打开时中文正常显示;bold/italic 由 Word 原生渲染。
"""
from __future__ import annotations
from io import BytesIO
from docx import Document as DocxDocument
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.opc.constants import RELATIONSHIP_TYPE
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Inches, Mm, Pt, RGBColor
from app.contracts import ExportOptions
from app.export.document import Document, DocumentNode, ExportResult
from app.export.exporters._common import (
MERMAID_WARNING,
PLOT_PLACEHOLDER_WARNING,
RAW_HTML_WARNING,
format_meta_value,
safe_url,
)
_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
_HEADING_SIZES = {1: 20, 2: 16, 3: 14, 4: 12, 5: 11, 6: 10.5}
def _plain_text(children: list[DocumentNode]) -> str:
"""递归拼接行内节点的纯文本,供标题/链接文字等需要纯文本处使用。"""
parts: list[str] = []
for child in children:
if child.type == "text":
parts.append(child.text)
elif child.children:
parts.append(_plain_text(child.children))
elif child.text:
parts.append(child.text)
return "".join(parts)
class DocxExporter:
"""实现 DocumentExporter:递归渲染 Document AST 为 DOCX 字节流。"""
def render(self, document: Document, options: ExportOptions) -> ExportResult:
"""同步渲染;CPU 密集,调用方应放入线程执行,避免阻塞事件循环。"""
self._doc = DocxDocument()
self._configure_normal_style()
self._configure_page(options)
warnings: list[str] = []
self._render_header(document, options, warnings)
self._render_children(document.children, warnings)
buf = BytesIO()
self._doc.save(buf)
return ExportResult(content=buf.getvalue(), mime_type=_MIME, warnings=warnings)
async def export(self, document: Document, options: ExportOptions) -> ExportResult:
"""契约要求的 async 接口;渲染本身同步,直接转发到 render。"""
return self.render(document, options)
def _configure_normal_style(self) -> None:
"""Normal 样式挂载 CJK 字体;拉丁用 Calibri,中文用宋体。"""
style = self._doc.styles["Normal"]
style.font.name = "Calibri"
style.font.size = Pt(11)
rfonts = style.element.get_or_add_rPr().get_or_add_rFonts()
rfonts.set(qn("w:eastAsia"), "宋体")
def _configure_page(self, options: ExportOptions) -> None:
section = self._doc.sections[0]
size = (options.page_size or "A4").lower()
if size == "a4":
section.page_width = Mm(210)
section.page_height = Mm(297)
elif size == "letter":
section.page_width = Inches(8.5)
section.page_height = Inches(11)
# --- 文档头部 ---
def _render_header(self, document: Document, options: ExportOptions, warnings: list[str]) -> None:
title = str(document.attributes.get("title") or "")
if options.include_title and title:
p = self._doc.add_paragraph()
run = p.add_run(title)
run.bold = True
run.font.size = Pt(22)
p.paragraph_format.space_after = Pt(12)
if options.include_metadata:
metadata = document.attributes.get("metadata")
if metadata:
for key, value in metadata.items():
p = self._doc.add_paragraph()
run = p.add_run(f"{key}: {format_meta_value(value)}")
run.font.size = Pt(9)
run.font.color.rgb = RGBColor(0x57, 0x60, 0x6A)
# --- 块级 ---
def _render_children(self, children: list[DocumentNode], warnings: list[str]) -> None:
for child in children:
self._render_block(child, warnings)
def _render_block(self, node: DocumentNode, warnings: list[str]) -> None:
handler = getattr(self, f"_block_{node.type}", None)
if handler is not None:
handler(node, warnings)
else:
warnings.append(f"无法表示的节点类型已跳过:{node.type}")
def _block_heading(self, node: DocumentNode, warnings: list[str]) -> None:
level = max(1, min(6, int(node.attributes.get("level", 1))))
p = self._doc.add_paragraph()
run = p.add_run(_plain_text(node.children))
run.bold = True
run.font.size = Pt(_HEADING_SIZES[level])
p.paragraph_format.space_before = Pt(14 if level <= 2 else 10)
p.paragraph_format.space_after = Pt(6)
def _block_paragraph(self, node: DocumentNode, warnings: list[str]) -> None:
p = self._doc.add_paragraph()
self._render_inline(p, node.children, warnings)
def _block_blockquote(self, node: DocumentNode, warnings: list[str]) -> None:
# 引用块的直接子节点是块级节点(paragraph/list 等),不能交给行内渲染器,
# 否则正文会被当作「无法表示的行内节点」丢弃;逐个渲染并继承引用缩进/颜色。
for child in node.children:
if child.type == "paragraph":
p = self._doc.add_paragraph()
self._render_inline(p, child.children, warnings)
p.paragraph_format.left_indent = Pt(16)
for run in p.runs:
run.font.color.rgb = RGBColor(0x57, 0x60, 0x6A)
elif child.type == "list":
self._block_list(child, warnings, level=1, color=RGBColor(0x57, 0x60, 0x6A))
else:
self._render_block(child, warnings)
def _block_list(
self,
node: DocumentNode,
warnings: list[str],
level: int = 0,
color: RGBColor | None = None,
) -> None:
ordered = bool(node.attributes.get("ordered"))
for index, item in enumerate(node.children, start=1):
self._block_list_item(item, warnings, ordered, index, level, color)
def _block_list_item(
self,
item: DocumentNode,
warnings: list[str],
ordered: bool,
index: int,
level: int,
color: RGBColor | None = None,
) -> None:
if item.attributes.get("task"):
marker = "" if item.attributes.get("checked") else ""
else:
marker = f"{index}. " if ordered else ""
indent = Pt(18 + 18 * level)
first = True
for child in item.children:
if child.type == "list":
self._block_list(child, warnings, level + 1, color)
continue
p = self._doc.add_paragraph()
p.paragraph_format.left_indent = indent
if first:
self._add_run(p, marker)
first = False
if child.children:
# 段落或行内容器(strong/link 等):渲染其行内子节点
self._render_inline(p, child.children, warnings)
else:
# 直接行内叶子节点(text 等):拼进段落,不能交给块级渲染器(会丢弃正文)
self._add_run(p, child.text or "")
if color is not None:
for run in p.runs:
run.font.color.rgb = color
def _block_table(self, node: DocumentNode, warnings: list[str]) -> None:
rows = node.children
ncols = max((len(r.children) for r in rows), default=0)
if not rows or ncols == 0:
return
table = self._doc.add_table(rows=len(rows), cols=ncols)
table.style = "Table Grid"
for ri, row in enumerate(rows):
head = bool(row.attributes.get("head"))
for ci in range(ncols):
cell = table.cell(ri, ci)
p = cell.paragraphs[0]
if ci < len(row.children):
self._render_inline(p, row.children[ci].children, warnings, bold=head)
def _block_code_block(self, node: DocumentNode, warnings: list[str]) -> None:
lines = node.text.split("\n")
p = self._doc.add_paragraph()
self._shade_paragraph(p)
p.paragraph_format.left_indent = Pt(8)
p.paragraph_format.right_indent = Pt(8)
p.paragraph_format.space_before = Pt(6)
p.paragraph_format.space_after = Pt(8)
for i, line in enumerate(lines):
run = p.add_run(line)
run.font.name = "Consolas"
run.font.size = Pt(10)
if i < len(lines) - 1:
run.add_break()
def _block_thematic_break(self, node: DocumentNode, warnings: list[str]) -> None:
p = self._doc.add_paragraph()
pPr = p._p.get_or_add_pPr()
pBdr = OxmlElement("w:pBdr")
bottom = OxmlElement("w:bottom")
bottom.set(qn("w:val"), "single")
bottom.set(qn("w:sz"), "6")
bottom.set(qn("w:space"), "1")
bottom.set(qn("w:color"), "D0D7DE")
pBdr.append(bottom)
pPr.append(pBdr)
def _block_mermaid(self, node: DocumentNode, warnings: list[str]) -> None:
warnings.append(MERMAID_WARNING)
self._block_code_block(node, warnings)
def _block_function_plot(self, node: DocumentNode, warnings: list[str]) -> None:
warnings.append(PLOT_PLACEHOLDER_WARNING)
self._block_code_block(node, warnings)
def _block_math_block(self, node: DocumentNode, warnings: list[str]) -> None:
p = self._doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
p.add_run(f"$${node.text}$$")
def _block_html_block(self, node: DocumentNode, warnings: list[str]) -> None:
# 原始 HTML 不可信,按纯文本保留正文
warnings.append(RAW_HTML_WARNING)
self._doc.add_paragraph(node.text)
# --- 行内(写入 run ---
def _render_inline(
self,
paragraph,
children: list[DocumentNode],
warnings: list[str],
bold: bool = False,
italic: bool = False,
) -> None:
for child in children:
self._render_inline_node(paragraph, child, warnings, bold, italic)
def _render_inline_node(
self, paragraph, node: DocumentNode, warnings: list[str], bold: bool, italic: bool
) -> None:
t = node.type
if t == "text":
self._add_run(paragraph, node.text, bold=bold, italic=italic)
elif t == "strong":
self._render_inline(paragraph, node.children, warnings, bold=True, italic=italic)
elif t == "emphasis":
self._render_inline(paragraph, node.children, warnings, bold=bold, italic=True)
elif t == "codespan":
self._add_run(paragraph, node.text, code=True)
elif t == "link":
inner = _plain_text(node.children)
href = str(node.attributes.get("href") or "")
safe_href = safe_url(href)
if safe_href is None:
warnings.append(f"链接协议不安全,已降级为纯文本:{href!r}")
self._render_inline(paragraph, node.children, warnings, bold, italic)
else:
self._add_hyperlink(paragraph, safe_href, inner)
elif t == "image":
src = str(node.attributes.get("src") or "")
alt = str(node.attributes.get("alt") or "")
if safe_url(src) is None:
warnings.append(f"图片地址不安全,已跳过:{src!r}")
else:
warnings.append("图片未内嵌到 DOCX,已用替代文本表示")
if alt:
self._add_run(paragraph, alt)
elif t == "math_inline":
self._add_run(paragraph, f"\\({node.text}\\)")
elif t == "linebreak":
self._add_run(paragraph, "").add_break()
else:
warnings.append(f"无法表示的行内节点已跳过:{t}")
def _add_run(self, paragraph, text: str, bold: bool = False, italic: bool = False, code: bool = False):
run = paragraph.add_run(text)
run.bold = bold
run.italic = italic
if code:
run.font.name = "Consolas"
run.font.size = Pt(10)
return run
def _add_hyperlink(self, paragraph, url: str, text: str) -> None:
"""写入可点击的超链接 runpython-docx 无公开 API,需手写 w:hyperlink)。"""
part = paragraph.part
r_id = part.relate_to(url, RELATIONSHIP_TYPE.HYPERLINK, is_external=True)
hyperlink = OxmlElement("w:hyperlink")
hyperlink.set(qn("r:id"), r_id)
run = OxmlElement("w:r")
rPr = OxmlElement("w:rPr")
rFonts = OxmlElement("w:rFonts")
rFonts.set(qn("w:ascii"), "Calibri")
rFonts.set(qn("w:hAnsi"), "Calibri")
rFonts.set(qn("w:eastAsia"), "宋体")
rPr.append(rFonts)
color = OxmlElement("w:color")
color.set(qn("w:val"), "0969DA")
rPr.append(color)
u = OxmlElement("w:u")
u.set(qn("w:val"), "single")
rPr.append(u)
run.append(rPr)
t = OxmlElement("w:t")
t.text = text
t.set(qn("xml:space"), "preserve")
run.append(t)
hyperlink.append(run)
paragraph._p.append(hyperlink)
def _shade_paragraph(self, paragraph, fill: str = "F2F2F2") -> None:
"""给段落加浅灰底纹,用于代码块占位。"""
pPr = paragraph._p.get_or_add_pPr()
shd = OxmlElement("w:shd")
shd.set(qn("w:val"), "clear")
shd.set(qn("w:color"), "auto")
shd.set(qn("w:fill"), fill)
pPr.append(shd)
+299
View File
@@ -0,0 +1,299 @@
"""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.renderer import FunctionPlotStaticRenderer, StaticRenderRequest
_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
self._plot_renderer = FunctionPlotStaticRenderer()
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:
request = StaticRenderRequest(
kind="function_plot", source=node.text, theme=self._options.theme_id
)
parsed = self._plot_renderer.parse(request)
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 = self._plot_renderer.render_plot(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>"
+329
View File
@@ -0,0 +1,329 @@
"""PdfExporterDocument AST → PDFreportlab platypus)。
v1 为文本优先:标题/段落/行内强调与链接/列表/引用/表格/代码块/数学文本均可导出;
function_plot 与 mermaid 保留源码占位并记 warning。中文字体用 reportlab 内置
STSong-Light CID 字体,避免外部字体依赖。CID 字体无独立 bold/italic 字重,
故行内强调退化为普通文本(内容不丢、样式简化),标题靠字号区分层级。
"""
from __future__ import annotations
import html as _html
from io import BytesIO
from reportlab.lib.enums import TA_CENTER
from reportlab.lib.pagesizes import A4, letter
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.units import mm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.cidfonts import UnicodeCIDFont
from reportlab.platypus import (
Paragraph,
Preformatted,
SimpleDocTemplate,
Spacer,
Table,
TableStyle,
)
from reportlab.platypus.flowables import HRFlowable
from app.contracts import ExportOptions
from app.export.document import Document, DocumentNode, ExportResult
from app.export.exporters._common import (
MERMAID_WARNING,
PLOT_PLACEHOLDER_WARNING,
RAW_HTML_WARNING,
format_meta_value,
safe_url,
)
_FONT = "STSong-Light"
pdfmetrics.registerFont(UnicodeCIDFont(_FONT))
_MIME = "application/pdf"
_PAGE_SIZES = {"a4": A4, "letter": letter}
# 标题字号随层级递减;标题不依赖粗体(CID 无粗体字重),靠字号拉开层级
_HEADING_SIZES = {1: 20, 2: 16, 3: 14, 4: 12, 5: 11, 6: 10.5}
# 引用块文字颜色,与 HtmlExporter 的引用灰一致
_QUOTE_COLOR = "#57606a"
def _make_styles() -> dict[str, ParagraphStyle]:
body = ParagraphStyle(
"pdf-body",
fontName=_FONT,
fontSize=10.5,
leading=16,
spaceAfter=6,
)
title = ParagraphStyle("pdf-title", parent=body, fontSize=22, leading=28, spaceAfter=12)
quote = ParagraphStyle(
"pdf-quote",
parent=body,
leftIndent=14,
textColor="#57606a",
spaceBefore=4,
spaceAfter=6,
)
code = ParagraphStyle(
"pdf-code",
parent=body,
fontSize=9,
leading=12,
leftIndent=6,
rightIndent=6,
backColor="#f6f8fa",
borderColor="#d0d7de",
borderWidth=0.5,
borderPadding=6,
spaceBefore=4,
spaceAfter=8,
)
math = ParagraphStyle("pdf-math", parent=body, alignment=TA_CENTER, spaceBefore=6)
cell = ParagraphStyle("pdf-cell", parent=body, fontSize=10, leading=14, spaceAfter=0)
cell_head = ParagraphStyle(
"pdf-cell-head", parent=cell, textColor="#1f2328", fontSize=10
)
meta = ParagraphStyle("pdf-meta", parent=body, fontSize=8.5, leading=13, textColor="#57606a")
styles: dict[str, ParagraphStyle] = {
"body": body,
"title": title,
"quote": quote,
"code": code,
"math": math,
"cell": cell,
"cell_head": cell_head,
"meta": meta,
}
for level, size in _HEADING_SIZES.items():
styles[f"h{level}"] = ParagraphStyle(
f"pdf-h{level}",
parent=body,
fontSize=size,
leading=size * 1.4,
spaceBefore=14 if level <= 2 else 10,
spaceAfter=6,
)
return styles
class PdfExporter:
"""实现 DocumentExporter:递归渲染 Document AST 为 PDF 字节流。"""
def render(self, document: Document, options: ExportOptions) -> ExportResult:
"""同步渲染;CPU 密集,调用方应放入线程执行,避免阻塞事件循环。"""
self._styles = _make_styles()
warnings: list[str] = []
page = _PAGE_SIZES.get((options.page_size or "A4").lower(), A4)
buf = BytesIO()
doc = SimpleDocTemplate(
buf,
pagesize=page,
leftMargin=20 * mm,
rightMargin=20 * mm,
topMargin=18 * mm,
bottomMargin=18 * mm,
title=str(document.attributes.get("title") or "") or None,
)
story: list = []
self._render_header(document, options, story)
self._render_children(document.children, story, warnings)
doc.build(story)
return ExportResult(content=buf.getvalue(), mime_type=_MIME, warnings=warnings)
async def export(self, document: Document, options: ExportOptions) -> ExportResult:
"""契约要求的 async 接口;渲染本身同步,直接转发到 render。"""
return self.render(document, options)
# --- 文档头部 ---
def _render_header(self, document: Document, options: ExportOptions, story: list) -> None:
title = str(document.attributes.get("title") or "")
if options.include_title and title:
story.append(Paragraph(_html.escape(title), self._styles["title"]))
if options.include_metadata:
metadata = document.attributes.get("metadata")
if metadata:
for key, value in metadata.items():
text = f"{_html.escape(str(key))}: {_html.escape(format_meta_value(value))}"
story.append(Paragraph(text, self._styles["meta"]))
# --- 块级 ---
def _render_children(self, children: list[DocumentNode], story: list, warnings: list[str]) -> None:
for child in children:
self._render_block(child, story, warnings)
def _render_block(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
handler = getattr(self, f"_block_{node.type}", None)
if handler is not None:
handler(node, story, warnings)
else:
warnings.append(f"无法表示的节点类型已跳过:{node.type}")
def _block_heading(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
level = max(1, min(6, int(node.attributes.get("level", 1))))
inline = self._render_inline(node.children, warnings)
story.append(Paragraph(inline, self._styles[f"h{level}"]))
def _block_paragraph(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
story.append(Paragraph(self._render_inline(node.children, warnings), self._styles["body"]))
def _block_blockquote(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
# 引用块的直接子节点是块级节点(paragraph/list 等),不能交给行内渲染器,
# 否则正文会被当作「无法表示的行内节点」丢弃;逐个渲染并继承引用缩进/颜色。
for child in node.children:
if child.type == "paragraph":
story.append(
Paragraph(self._render_inline(child.children, warnings), self._styles["quote"])
)
elif child.type == "list":
self._block_list(child, story, warnings, indent=14, color=_QUOTE_COLOR)
else:
self._render_block(child, story, warnings)
def _block_list(
self,
node: DocumentNode,
story: list,
warnings: list[str],
indent: int = 14,
color: str | None = None,
) -> None:
ordered = bool(node.attributes.get("ordered"))
for index, item in enumerate(node.children, start=1):
self._block_list_item(item, story, warnings, ordered, index, indent, color)
def _block_list_item(
self,
item: DocumentNode,
story: list,
warnings: list[str],
ordered: bool,
index: int,
indent: int,
color: str | None = None,
) -> None:
if item.attributes.get("task"):
marker = "" if item.attributes.get("checked") else ""
else:
marker = f"{index}. " if ordered else ""
style_kwargs: dict = dict(
parent=self._styles["body"],
leftIndent=indent,
firstLineIndent=-7,
spaceAfter=2,
)
if color:
style_kwargs["textColor"] = color
style = ParagraphStyle(f"pdf-li-{indent}-{color or 'normal'}", **style_kwargs)
# 先收集父级正文、后处理嵌套列表:保证「父级文字在前、子列表在后」的阅读顺序,
# 而不是在循环里遇到嵌套列表就立刻递归输出(那会把子列表排到父级前面)。
parts: list[str] = []
nested: list[DocumentNode] = []
for child in item.children:
if child.type == "list":
nested.append(child)
elif child.type == "paragraph":
parts.append(self._render_inline(child.children, warnings))
elif child.children:
parts.append(self._render_inline(child.children, warnings))
else:
parts.append(_html.escape(child.text))
story.append(Paragraph(marker + "<br/>".join(parts), style))
for child_list in nested:
self._block_list(child_list, story, warnings, indent + 14, color)
def _block_table(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
rows = node.children
if not rows:
return
data: list[list[Paragraph]] = []
head_row_count = 0
for row in rows:
head = bool(row.attributes.get("head"))
if head:
head_row_count += 1
cells = [
Paragraph(
self._render_inline(cell.children, warnings),
self._styles["cell_head" if cell.attributes.get("head") else "cell"],
)
for cell in row.children
]
data.append(cells)
table = Table(data, repeatRows=head_row_count)
commands = [
("GRID", (0, 0), (-1, -1), 0.5, "#d0d7de"),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
]
if head_row_count:
commands.append(("BACKGROUND", (0, 0), (-1, head_row_count - 1), "#f6f8fa"))
table.setStyle(TableStyle(commands))
story.append(table)
def _block_code_block(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
story.append(Preformatted(node.text, self._styles["code"]))
def _block_thematic_break(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
story.append(Spacer(1, 4))
story.append(HRFlowable(width="100%", color="#d0d7de", thickness=0.5))
story.append(Spacer(1, 6))
def _block_mermaid(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
warnings.append(MERMAID_WARNING)
story.append(Preformatted(node.text, self._styles["code"]))
def _block_function_plot(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
warnings.append(PLOT_PLACEHOLDER_WARNING)
story.append(Preformatted(node.text, self._styles["code"]))
def _block_math_block(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
story.append(Paragraph(f"$${_html.escape(node.text)}$$", self._styles["math"]))
def _block_html_block(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
# 原始 HTML 不可信,按纯文本保留正文
warnings.append(RAW_HTML_WARNING)
story.append(Paragraph(_html.escape(node.text), self._styles["body"]))
# --- 行内(产出 reportlab Paragraph 标记文本) ---
def _render_inline(self, children: list[DocumentNode], warnings: list[str]) -> str:
return "".join(self._render_inline_node(child, warnings) for child in children)
def _render_inline_node(self, node: DocumentNode, warnings: list[str]) -> str:
t = node.type
if t == "text":
return _html.escape(node.text)
if t in ("strong", "emphasis"):
return self._render_inline(node.children, warnings)
if t == "codespan":
return f'<font size="9">{_html.escape(node.text)}</font>'
if t == "link":
inner = self._render_inline(node.children, warnings)
href = str(node.attributes.get("href") or "")
safe_href = safe_url(href)
if safe_href is None:
warnings.append(f"链接协议不安全,已降级为纯文本:{href!r}")
return inner
return f'<a href="{_html.escape(safe_href)}">{inner}</a>'
if t == "image":
src = str(node.attributes.get("src") or "")
alt = str(node.attributes.get("alt") or "")
if safe_url(src) is None:
warnings.append(f"图片地址不安全,已跳过:{src!r}")
else:
warnings.append("图片未内嵌到 PDF,已用替代文本表示")
return _html.escape(alt) if alt else ""
if t == "math_inline":
return f"\\({_html.escape(node.text)}\\)"
if t == "linebreak":
return "<br/>"
warnings.append(f"无法表示的行内节点已跳过:{t}")
return ""
+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
)
+391
View File
@@ -0,0 +1,391 @@
"""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.docx import DocxExporter
from app.export.exporters.html import HtmlExporter
from app.export.exporters.pdf import PdfExporter
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'[\\/:*?"<>|]')
# 格式 → 导出器;新增格式只需在此登记,路由与任务模型无需改动
_EXPORTERS: dict[ExportFormat, type] = {
ExportFormat.html: HtmlExporter,
ExportFormat.pdf: PdfExporter,
ExportFormat.docx: DocxExporter,
}
# 格式 → 文件扩展名(用于落盘文件名与产物清理)
_EXTENSIONS: dict[ExportFormat, str] = {
ExportFormat.html: ".html",
ExportFormat.pdf: ".pdf",
ExportFormat.docx: ".docx",
}
def _extension_for(format: ExportFormat) -> str:
return _EXTENSIONS[format]
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, ext: str) -> Path:
return get_settings().exports_path / f"{job_id}{ext}"
def _delete_file(job_id: str, ext: str) -> None:
"""删除导出产物文件;文件不存在时忽略。"""
try:
_export_path(job_id, ext).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 ext in _EXTENSIONS.values():
for path in exports_dir.glob(f"*{ext}"):
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, format: ExportFormat) -> ExportResult:
"""按 format 分发到对应导出器;每次新建实例避免跨线程复用。"""
exporter_cls = _EXPORTERS[format]
return exporter_cls().render(document, options)
def _forget(job_id: str) -> None:
job = _jobs.get(job_id)
ext = _extension_for(job.format) if job is not None else ".html"
_jobs.pop(job_id, None)
_tasks.pop(job_id, None)
_cancel_flags.pop(job_id, None)
_delete_file(job_id, ext)
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 渲染。"""
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, request.format, markdown, title, metadata, request.options)
)
return job
async def _acquire_render_slot(cancel_event: asyncio.Event) -> bool:
"""等待渲染槽位,同时响应取消:拿到槽位返回 True,被取消返回 False。
等待期间任务保持 queued;取消即时生效,不必等前面的渲染完成。
"""
while True:
if cancel_event.is_set():
return False
acquire = asyncio.create_task(_render_slots.acquire())
cancel_wait = asyncio.create_task(cancel_event.wait())
done, pending = await asyncio.wait(
(acquire, cancel_wait), return_when=asyncio.FIRST_COMPLETED
)
if acquire in done:
# 拿到槽位;收掉仍在等待取消标志的任务(不释放刚拿到的槽位)
for task in pending:
task.cancel()
await asyncio.gather(*pending, return_exceptions=True)
return True
# 取消先到:取消尚未完成的 acquireSemaphore.acquire 取消不会递减计数)
acquire.cancel()
cancel_wait.cancel()
await asyncio.gather(acquire, cancel_wait, return_exceptions=True)
return False
async def _execute(
job_id: str,
format: ExportFormat,
markdown: str,
title: str,
metadata: dict | None,
options: ExportOptions,
) -> None:
"""后台渲染:排队 → 解析 → 导出 → 写文件 → 挂载产物元信息。"""
cancel_event = _cancel_flags[job_id]
acquired = False
try:
# 并发渲染限额:解析/渲染是 CPU 密集的同步工作,用信号量限制同时执行的任务数。
# 等待槽位期间保持 queued 并同时监听取消,取消即时生效,不必等前面的渲染完成。
if not await _acquire_render_slot(cancel_event):
raise ExportCancelled()
acquired = True
# 拿到槽位后才进入 running
_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),
}
)
# 让出一次,使「创建后立即取消」的 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, format)
if cancel_event.is_set():
raise ExportCancelled()
if len(result.content) > MAX_EXPORT_BYTES:
raise ExportTooLarge()
ext = _extension_for(format)
out_dir = get_settings().exports_path
out_dir.mkdir(parents=True, exist_ok=True)
path = _export_path(job_id, ext)
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)}{ext}",
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:
if acquired:
_render_slots.release()
_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, _extension_for(job.format))
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)
+11
View File
@@ -0,0 +1,11 @@
"""Bound embedding result frames so large notes do not exceed pipe line limits."""
import json
def response_lines(response, operation):
if operation == 'embedding' and 'result' in response and 'error_code' not in response:
vectors = response['result']
for offset in range(0, len(vectors), 128):
yield json.dumps({'embedding_offset': offset, 'embedding_chunk': vectors[offset:offset + 128]}, allow_nan=False) + '\n'
response = {**response, 'result': [], 'embedding_count': len(vectors)}
yield json.dumps(response, ensure_ascii=False, allow_nan=False) + '\n'
+16 -1
View File
@@ -189,15 +189,30 @@ class Runtime:
await process.stdin.drain()
process.stdin.close()
final = None
vectors = []
while line := await process.stdout.readline():
message = json.loads(line)
if "progress" in message:
if "embedding_chunk" in message:
chunk = message['embedding_chunk']
if (operation != 'embedding' or not isinstance(chunk, list)
or message.get('embedding_offset') != len(vectors)
or len(vectors) + len(chunk) > len(payload.get('texts', []))):
raise ProviderError('LOCAL_MODEL_INVALID_RESPONSE', '本地向量传输顺序或数量无效。')
vectors.extend(chunk)
elif "progress" in message:
callback = runtime_progress.get()
if callback:
callback(message)
else:
final = message
await process.wait()
if isinstance(final, dict) and 'embedding_count' in final:
if (final['embedding_count'] != len(vectors)
or len(vectors) != len(payload.get('texts', []))):
raise ProviderError('LOCAL_MODEL_INVALID_RESPONSE', '本地向量传输不完整。')
final['result'] = vectors
elif vectors:
raise ProviderError('LOCAL_MODEL_INVALID_RESPONSE', '本地向量传输缺少结束标记。')
return final
try:
result = await asyncio.wait_for(receive(), config.timeout_seconds)
+3 -1
View File
@@ -216,4 +216,6 @@ if __name__ == "__main__":
response = {"error_code": "LOCAL_INFERENCE_FAILED", "message": "本地推理失败,请检查媒体格式、模型和设备配置。"}
if "error_code" in response:
response["diagnostics"] = {"requested_device": request["config"]["device"], "actual_device": request.get("_actual_device", "unknown")}
sys.stdout.buffer.write((json.dumps(response, ensure_ascii=False, allow_nan=False) + "\n").encode("utf-8"))
from protocol import response_lines
for line in response_lines(response, request['operation']):
sys.stdout.buffer.write(line.encode('utf-8'))
+11
View File
@@ -0,0 +1,11 @@
from fastapi import APIRouter, Query
from app.operation_logs import get_store
router = APIRouter(prefix='/api/logs', tags=['Diagnostics'])
@router.get('')
def list_logs(limit: int = Query(50, ge=1, le=200), before: int | None = Query(None, ge=1),
level: str = Query('', pattern='^(|INFO|WARNING|ERROR|CRITICAL)$'),
source: str = Query('', max_length=100), q: str = Query('', max_length=200)):
return get_store().query(limit=limit, before=before, level=level, source=source, q=q)
+40
View File
@@ -1,4 +1,7 @@
from contextlib import asynccontextmanager
import asyncio
from time import perf_counter
from uuid import uuid4
from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
@@ -8,23 +11,31 @@ 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
from app.usage_routes import router as usage_router
from app.provider_preview_routes import router as provider_preview_router
from app.schemas import HealthResponse, ServiceStatusResponse
from app.log_routes import router as log_router
from app.operation_logs import install_logging, log_event, request_id, shutdown_logging
settings = get_settings()
@asynccontextmanager
async def lifespan(_: FastAPI):
install_logging()
log_event('system', 'service.started')
# 重启后内存注册表为空,清理上一次运行遗留的导出产物,避免磁盘垃圾堆积。
export_service.cleanup_orphan_files()
from app.services import transcription_service
transcription_service.recover_interrupted()
try:
yield
finally:
await container.agent.shutdown()
from app.services import index_service
await index_service.shutdown()
await transcription_service.shutdown()
@@ -33,8 +44,11 @@ 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()
log_event('system', 'service.stopped')
await asyncio.to_thread(shutdown_logging)
app = FastAPI(
@@ -60,6 +74,32 @@ app.include_router(media_router)
app.include_router(local_model_router)
app.include_router(usage_router)
app.include_router(provider_preview_router)
app.include_router(log_router)
@app.middleware('http')
async def operation_log(request, call_next):
token = request_id.set(uuid4().hex)
started = perf_counter()
status = 500
failure = None
try:
response = await call_next(request)
status = response.status_code
response.headers['X-Request-ID'] = request_id.get()
return response
except Exception as exc:
failure = exc
raise
finally:
# Do not record query strings, request/response bodies or arbitrary URLs.
route = getattr(request.scope.get('route'), 'path', 'unmatched')
if not route.startswith('/api/logs') and (request.method not in {'GET', 'HEAD', 'OPTIONS'} or status >= 400 or perf_counter() - started > 1):
log_event('http', 'request.finished', level='ERROR' if status >= 500 else 'WARNING' if status >= 400 else 'INFO',
error=failure, method=request.method, route=route, status=status,
duration_ms=round((perf_counter() - started) * 1000, 2),
**{k: v for k, v in request.path_params.items() if k in {'run_id', 'task_id', 'note_id', 'job_id', 'provider_id'}})
request_id.reset(token)
@app.get("/health", response_model=HealthResponse, tags=["System"])
+186
View File
@@ -0,0 +1,186 @@
"""Bounded, asynchronous operational diagnostics, separate from business/Trace data.
Only explicitly allowed metadata is stored. Never store prompts, tool arguments,
provider response bodies or raw exception messages in this diagnostic channel.
"""
from __future__ import annotations
import json
import logging
import math
import queue
import re
import sqlite3
import threading
import traceback
from contextvars import ContextVar
from contextlib import closing
from datetime import datetime, timezone
from pathlib import Path
from app.config import get_settings
request_id: ContextVar[str] = ContextVar('log_request_id', default='')
agent_run_id: ContextVar[str] = ContextVar('log_agent_run_id', default='')
_allowed = {'run_id', 'task_id', 'note_id', 'job_id', 'provider_id', 'model',
'device', 'error_code', 'error_type', 'status', 'duration_ms', 'count',
'step', 'sequence', 'tool', 'method', 'route', 'request_id', 'fallback',
'frames', 'source', 'changed_fields'}
_safe = re.compile(r'[^\w .:/@{}\[\],()=+\-]', re.UNICODE)
def metadata(values: dict) -> dict:
result = {}
for key, value in values.items():
if key not in _allowed or value is None:
continue
if isinstance(value, (int, float, bool)):
if not isinstance(value, float) or math.isfinite(value):
result[key] = value
else:
text = str(value)
text = re.sub(r'(?i)(?:bearer\s+\S+|sk-[\w-]+)', '[REDACTED]', text)
result[key] = _safe.sub('', text)[:500]
return result
class LogStore:
def __init__(self, path: Path, *, retain: int = 20_000):
self.path = path
self.retain = retain
self.queue: queue.Queue = queue.Queue(maxsize=4096)
self.dropped = 0
self.failed = 0
self.closed = False
self.state_lock = threading.Lock()
self.thread = threading.Thread(target=self._write, name='operation-logs', daemon=True)
path.parent.mkdir(parents=True, exist_ok=True)
with closing(self._connect()) as conn, conn:
conn.execute('CREATE TABLE IF NOT EXISTS logs (id INTEGER PRIMARY KEY, timestamp TEXT NOT NULL, level TEXT NOT NULL, source TEXT NOT NULL, event TEXT NOT NULL, details TEXT NOT NULL)')
conn.execute('CREATE INDEX IF NOT EXISTS logs_level_id ON logs(level, id)')
conn.execute('CREATE INDEX IF NOT EXISTS logs_source_id ON logs(source, id)')
self.thread.start()
def _connect(self):
conn = sqlite3.connect(self.path, timeout=5)
conn.row_factory = sqlite3.Row
return conn
def emit(self, level: str, source: str, event: str, details: dict):
row = (datetime.now(timezone.utc).isoformat(), level, source[:100], event[:160], json.dumps(metadata(details), ensure_ascii=False))
with self.state_lock:
if self.closed:
return
try:
self.queue.put_nowait(row)
except queue.Full:
self.dropped += 1
def _write(self):
while True:
first = self.queue.get()
batch = [first]
while len(batch) < 128:
try:
batch.append(self.queue.get_nowait())
except queue.Empty:
break
stop = None in batch
rows = [row for row in batch if row is not None]
try:
if rows:
with closing(self._connect()) as conn, conn:
conn.executemany('INSERT INTO logs(timestamp,level,source,event,details) VALUES(?,?,?,?,?)', rows)
conn.execute('DELETE FROM logs WHERE id <= (SELECT id FROM logs ORDER BY id DESC LIMIT 1 OFFSET ?)', (self.retain,))
except Exception:
self.failed += len(rows)
finally:
for _ in batch:
self.queue.task_done()
if stop:
return
def query(self, *, limit=50, before=None, level='', source='', q=''):
clauses, args = [], []
for column, value in [('level', level), ('source', source)]:
if value:
clauses.append(f'{column} = ?')
args.append(value)
if before is not None:
clauses.append('id < ?')
args.append(before)
if q:
clauses.append('(instr(event, ?) > 0 OR instr(details, ?) > 0)')
args += [q, q]
where = ' WHERE ' + ' AND '.join(clauses) if clauses else ''
with closing(self._connect()) as conn, conn:
rows = conn.execute('SELECT * FROM logs' + where + ' ORDER BY id DESC LIMIT ?', (*args, limit + 1)).fetchall()
sources = [row[0] for row in conn.execute('SELECT DISTINCT source FROM logs ORDER BY source')]
items = [{**dict(row), 'details': json.loads(row['details'])} for row in rows[:limit]]
return {'items': items, 'next_cursor': items[-1]['id'] if len(rows) > limit else None,
'sources': sources, 'pending': self.queue.qsize(), 'dropped': self.dropped,
'write_failures': self.failed, 'retention': self.retain}
def close(self):
with self.state_lock:
if self.closed:
return
self.closed = True
self.queue.put(None)
self.thread.join(timeout=15)
_store: LogStore | None = None
_lock = threading.Lock()
def get_store() -> LogStore:
global _store
path = get_settings().data_dir / 'logs' / 'operations.sqlite3'
with _lock:
if _store is None or _store.path != path or _store.closed:
if _store is not None and not _store.closed:
_store.close()
_store = LogStore(path)
return _store
def log_event(module: str, event: str, *, level='INFO', error: BaseException | None = None, **details):
if request_id.get():
details.setdefault('request_id', request_id.get())
if agent_run_id.get():
details.setdefault('run_id', agent_run_id.get())
if error:
details['error_type'] = type(error).__name__
details.setdefault('error_code', getattr(error, 'code', None))
details['frames'] = '; '.join(f'{Path(f.filename).name}:{f.lineno}:{f.name}' for f in traceback.extract_tb(error.__traceback__)[-8:])
try:
get_store().emit(level, module, event, details)
except Exception:
# Logging must not turn a successful save/run into a business failure.
logging.getLogger('operation_log_storage').error('Operational log storage unavailable')
class ApplicationLogHandler(logging.Handler):
def emit(self, record):
if record.name == 'operation_log_storage' or getattr(record, '_notes_operation_logged', False):
return
record._notes_operation_logged = True
# Legacy log messages can include note text/credentials, even in f-strings.
# Preserve source location and error class; structured call sites carry IDs.
log_event(record.name, 'application.warning' if record.levelno < 40 else 'application.error',
level=record.levelname, error=record.exc_info[1] if record.exc_info else None,
frames=f'{Path(record.pathname).name}:{record.lineno}:{record.funcName}')
def install_logging():
# Uvicorn's default logger stops propagation before the root logger.
for name in ('', 'uvicorn'):
logger = logging.getLogger(name)
if not any(isinstance(h, ApplicationLogHandler) for h in logger.handlers):
logger.addHandler(ApplicationLogHandler(level=logging.WARNING))
def shutdown_logging():
if _store is not None and not _store.closed:
_store.close()
+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,
)
+66
View File
@@ -0,0 +1,66 @@
"""StaticRenderer 内部契约(契约 §10.4)。
静态可视化抽象为统一请求/协议导出器只面向 StaticRenderer不再直接调用
``render_svg`` 等具体实现后端当前仅能静态渲染函数图像Mermaid 后端无渲染能力
返回占位结果交前端渲染
"""
from __future__ import annotations
from typing import Literal, Protocol
from pydantic import BaseModel, Field
from app.plot.model import FunctionPlot, FunctionPlotParseResult, StaticRenderResult
from app.plot.parser import parse_source
from app.plot.render import render_svg
class StaticRenderRequest(BaseModel):
"""一次静态渲染请求;source_hash 供缓存/去重,theme 供主题化渲染。"""
kind: Literal["function_plot", "mermaid"]
source: str
source_hash: str = ""
theme: str | None = None
width: int | None = None
height: int | None = None
class StaticRenderer(Protocol):
"""静态渲染器协议:请求 → 渲染结果(content 为可直接内嵌的标记)。"""
def render(self, request: StaticRenderRequest) -> StaticRenderResult: ...
class FunctionPlotStaticRenderer:
"""函数图像渲染器:parse_source 解析 → render_svg 输出内嵌 SVG。
``parse`` ``render_plot`` 拆开供导出器在渲染前先拿 node_count 做文档级
累计复杂度预算并消费解析诊断
"""
def parse(self, request: StaticRenderRequest) -> FunctionPlotParseResult:
return parse_source(request.source)
def render(self, request: StaticRenderRequest) -> StaticRenderResult:
parsed = self.parse(request)
if parsed.plot is None:
raise ValueError("function-plot source has no valid plot")
return self.render_plot(parsed.plot)
def render_plot(self, plot: FunctionPlot) -> StaticRenderResult:
return render_svg(plot)
class MermaidStaticRenderer:
"""Mermaid 后端无渲染能力:返回空占位结果,交前端渲染。"""
def render(self, request: StaticRenderRequest) -> StaticRenderResult:
return StaticRenderResult(
content="",
mime_type="text/plain",
width=0,
height=0,
warnings=["mermaid 需前端渲染,已保留为占位代码块"],
)
+30
View File
@@ -0,0 +1,30 @@
"""Process-local retrieval activity, shared by search, RAG and Agent callers."""
import asyncio
from functools import wraps
active = 0
completed = 0
failed = 0
cancelled = 0
def track_search(operation):
@wraps(operation)
async def wrapped(self, request):
global active, completed, failed, cancelled
if request.mode == 'fts':
return await operation(self, request)
active += 1
try:
result = await operation(self, request)
completed += 1
return result
except asyncio.CancelledError:
cancelled += 1
raise
except Exception:
failed += 1
raise
finally:
active -= 1
return wrapped
+2
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
from datetime import datetime, timezone
from app import repository
from app.retrieval.activity import track_search
from app.contracts import (
Citation,
PageMeta,
@@ -52,6 +53,7 @@ class RetrievalEngine:
# remain authoritative, including monkeypatches on the singleton.
self._routed_defaults = (embedding, vector_store) if route_embeddings else None
@track_search
async def search(self, request: SearchRequest) -> SearchResponse:
if request.mode == SearchMode.fts:
return self._search_fts(request)
+69 -47
View File
@@ -2,15 +2,14 @@
The runtime's model_id is the authoritative space ID (including provider URL,
endpoint, model and dimensions); equal dimensions alone never imply compatibility.
This phase uses a lazy, rebuildable SQLite side table instead of a schema migration.
Search scans only current blocks in one database snapshot and requires complete
coverage. Cosine ranking costs O(blocks * dimensions) with an O(top_k) heap; this
small-vault implementation should become a per-space ANN index at larger scale.
Durable vectors are reused to build per-space/dimension sqlite-vec indexes lazily.
Native exact KNN avoids Python JSON decoding and dot products on every search.
Coverage checks and ranking share one transaction.
"""
from __future__ import annotations
import heapq
import asyncio
import json
import logging
import math
@@ -20,9 +19,11 @@ from typing import Protocol
from app.database.db import connect, transaction
from app.errors import ApiError
from app.operation_logs import log_event
from app.retrieval.vectorstore import VectorHit
from app.retrieval.provenance import record_embedding
from app.retrieval.hybrid import rrf_fuse
from app.retrieval import space_index
logger = logging.getLogger(__name__)
@@ -101,6 +102,8 @@ async def embed_remote(texts: list[str], *, accept_local=False, strict=False, lo
source=result.source,
)
except Exception as exc:
log_event('vectors', 'embedding.failed', level='ERROR' if strict else 'WARNING', error=exc,
count=len(texts), fallback='none' if strict else 'local_index')
# Avoid logging provider exceptions containing credentials or note text.
record_embedding(fallback_reason="REMOTE_EMBEDDING_UNAVAILABLE")
logger.warning("Remote embedding unavailable (%s); using local index", type(exc).__name__)
@@ -118,9 +121,15 @@ def _ensure_table(conn: sqlite3.Connection) -> None:
block_id TEXT NOT NULL REFERENCES blocks(block_id) ON DELETE CASCADE,
dimensions INTEGER NOT NULL CHECK (dimensions > 0),
vector TEXT NOT NULL,
PRIMARY KEY (space_id, block_id)
PRIMARY KEY (space_id, dimensions, block_id)
)
""")
primary = [row[1] for row in sorted(conn.execute('PRAGMA table_info(routed_block_vectors)'), key=lambda row: row[5]) if row[5]]
if primary == ['space_id', 'block_id']:
conn.execute('CREATE TABLE routed_block_vectors_upgrade (space_id TEXT NOT NULL, block_id TEXT NOT NULL REFERENCES blocks(block_id) ON DELETE CASCADE, dimensions INTEGER NOT NULL CHECK(dimensions>0), vector TEXT NOT NULL, PRIMARY KEY(space_id,dimensions,block_id))')
conn.execute('INSERT INTO routed_block_vectors_upgrade SELECT * FROM routed_block_vectors')
conn.execute('DROP TABLE routed_block_vectors')
conn.execute('ALTER TABLE routed_block_vectors_upgrade RENAME TO routed_block_vectors')
conn.execute("""
CREATE INDEX IF NOT EXISTS routed_block_vectors_block_id
ON routed_block_vectors(block_id)
@@ -146,13 +155,14 @@ def store_remote(
conn.executemany(
"""INSERT INTO routed_block_vectors (space_id, block_id, dimensions, vector)
VALUES (?, ?, ?, ?)
ON CONFLICT (space_id, block_id) DO UPDATE SET
ON CONFLICT (space_id, dimensions, block_id) DO UPDATE SET
dimensions = excluded.dimensions, vector = excluded.vector""",
[
(batch.space_id, block_id, batch.dimensions, json.dumps(vector, allow_nan=False))
for block_id, vector in zip(block_ids, batch.vectors)
],
)
space_index.upsert(conn, block_ids, batch)
except BaseException:
conn.execute("ROLLBACK TO routed_vectors_write")
raise
@@ -180,6 +190,50 @@ async def search_remote(query: str, *, top_k: int, accept_local=False, strict=Fa
if batch is None:
return None
if not await _prepare_for_search([batch], strict):
return None
return await asyncio.to_thread(_search_space, batch, top_k, strict)
async def _prepare_indexes(batches):
from app.services.coordination import vault_mutation_lock
def prepare(check_only=False):
conn = connect()
try:
if check_only:
return space_index.is_ready(conn, batches)
space_index.prepare(conn, batches)
finally:
conn.close()
if await asyncio.to_thread(prepare, True):
return
# Share the cooperative gate with saves: never block the event loop on a
# SQLite write lock while a migration owns it in another thread.
async with vault_mutation_lock():
work = asyncio.create_task(asyncio.to_thread(prepare))
cancelled = False
while not work.done():
try:
await asyncio.shield(work)
except asyncio.CancelledError:
cancelled = True
work.result()
if cancelled:
raise asyncio.CancelledError
async def _prepare_for_search(batches, strict):
try:
await _prepare_indexes(batches)
return True
except Exception as exc:
record_embedding(fallback_reason='REMOTE_INDEX_UNAVAILABLE')
if strict:
raise ApiError(409, 'SEMANTIC_INDEX_UNAVAILABLE', '向量索引准备失败,请检查索引状态。') from exc
return False
def _search_space(batch, top_k, strict):
record_embedding(attempted_space={"model_id": batch.space_id, "dimensions": batch.dimensions})
try:
conn = connect()
@@ -195,29 +249,7 @@ async def search_remote(query: str, *, top_k: int, accept_local=False, strict=Fa
if strict:
raise ValueError("semantic index missing")
return None
rows = conn.execute(
"""SELECT b.block_id, r.vector
FROM blocks AS b
LEFT JOIN routed_block_vectors AS r
ON r.block_id = b.block_id AND r.space_id = ? AND r.dimensions = ?
ORDER BY b.block_id""",
(batch.space_id, batch.dimensions),
)
def hits():
for row in rows:
if row["vector"] is None:
raise ValueError("remote space has incomplete block coverage")
vector = _unit_vector(json.loads(row["vector"]), batch.dimensions)
score = math.fsum(a * b for a, b in zip(batch.vectors[0], vector))
yield VectorHit(id=row["block_id"], score=max(0.0, min(1.0, score)))
try:
result = heapq.nlargest(top_k, hits(), key=lambda hit: hit.score)
finally:
# Exceptions may retain the generator/traceback; finalize its
# cursor now so a subsequent rebuild can acquire a write lock.
rows.close()
result = space_index.search(conn, batch, top_k)
record_embedding(source=batch.source, model_id=batch.space_id,
dimensions=batch.dimensions, fallback_reason=None)
return result
@@ -241,6 +273,12 @@ async def _search_partitioned(query: str, policies: set[bool], *, top_k: int, st
if batch is None:
return None
batches[policy] = batch
if not await _prepare_for_search(list(batches.values()), strict):
return None
return await asyncio.to_thread(_search_partitions, batches, policies, top_k, strict)
def _search_partitions(batches, policies, top_k, strict):
conn = connect()
try:
with transaction(conn):
@@ -250,23 +288,7 @@ async def _search_partitioned(query: str, policies: set[bool], *, top_k: int, st
raise ValueError("embedding policies changed while querying")
ranked = []
for policy, batch in batches.items():
rows = conn.execute(
"SELECT b.block_id,r.vector FROM blocks b LEFT JOIN routed_block_vectors r "
"ON r.block_id=b.block_id AND r.space_id=? AND r.dimensions=? "
"WHERE b.embedding_local_only=? ORDER BY b.block_id",
(batch.space_id, batch.dimensions, int(policy)),
)
def hits():
for row in rows:
if row['vector'] is None:
raise ValueError("incomplete policy coverage")
vector = _unit_vector(json.loads(row['vector']), batch.dimensions)
score = math.fsum(a * b for a, b in zip(batch.vectors[0], vector))
yield VectorHit(id=row['block_id'], score=max(0.0, min(1.0, score)))
try:
ranked.append(heapq.nlargest(top_k, hits(), key=lambda hit: hit.score))
finally:
rows.close()
ranked.append(space_index.search(conn, batch, top_k, policy))
spaces = [{"source": b.source, "model_id": b.space_id, "dimensions": b.dimensions,
"local_only": policy} for policy, b in batches.items()]
record_embedding(source="mixed" if len({b.source for b in batches.values()}) > 1 else batch.source,
+89
View File
@@ -0,0 +1,89 @@
"""Persistent vec0 indexes derived from durable routed vectors, one per space/dimension."""
import hashlib
import json
import threading
import sqlite_vec
from app.retrieval.vectorstore import VectorHit
_migration_lock = threading.Lock()
def is_ready(conn, batches):
return all(conn.execute('SELECT 1 FROM sqlite_master WHERE name=?',
(table_name(batch.space_id, batch.dimensions),)).fetchone() for batch in batches)
def prepare(conn, batches):
"""Finish lazy writes before opening a search snapshot. Warm searches do not write."""
from app.retrieval.routed_vectors import _ensure_table
batches = list(batches)
if is_ready(conn, batches):
return
# Waiting holds no read transaction, so a concurrent migration can commit.
with _migration_lock:
if is_ready(conn, batches):
return
conn.execute('BEGIN IMMEDIATE')
try:
_ensure_table(conn)
for batch in batches:
ensure(conn, batch.space_id, batch.dimensions)
conn.execute('COMMIT')
except BaseException:
conn.execute('ROLLBACK')
raise
def table_name(space, dimensions):
return 'routed_vec_' + hashlib.sha256(json.dumps([space, dimensions]).encode()).hexdigest()
def ensure(conn, space, dimensions):
from app.retrieval.routed_vectors import _unit_vector
table = table_name(space, dimensions)
if conn.execute('SELECT 1 FROM sqlite_master WHERE name=?', (table,)).fetchone():
return table
if type(dimensions) is not int or not 0 < dimensions <= 8192:
raise ValueError('unsupported vector dimensions')
conn.execute(f'CREATE VIRTUAL TABLE {table} USING vec0(block_id TEXT PRIMARY KEY, embedding float[{dimensions}], local_only INTEGER)')
for row in conn.execute('SELECT r.block_id,r.vector,b.embedding_local_only FROM routed_block_vectors r JOIN blocks b USING(block_id) WHERE r.space_id=? AND r.dimensions=?', (space, dimensions)):
conn.execute(f'INSERT INTO {table}(block_id,embedding,local_only) VALUES (?,?,?)',
(row[0], sqlite_vec.serialize_float32(_unit_vector(json.loads(row[1]), dimensions)), row[2]))
literal = conn.execute('SELECT quote(?)', (space,)).fetchone()[0]
for event in ('DELETE', 'UPDATE'):
conn.execute(f'''CREATE TRIGGER {table}_{event.lower()} AFTER {event} ON routed_block_vectors
WHEN old.space_id={literal} AND old.dimensions={dimensions}
BEGIN DELETE FROM {table} WHERE block_id=old.block_id; END''')
return table
def upsert(conn, block_ids, batch):
from app.retrieval.routed_vectors import _unit_vector
table = ensure(conn, batch.space_id, batch.dimensions)
for block_id, vector in zip(block_ids, batch.vectors):
conn.execute(f'DELETE FROM {table} WHERE block_id=?', (block_id,))
conn.execute(f'INSERT INTO {table}(block_id,embedding,local_only) SELECT block_id,?,embedding_local_only FROM blocks WHERE block_id=?',
(sqlite_vec.serialize_float32(_unit_vector(vector, batch.dimensions)), block_id))
def search(conn, batch, top_k, policy=None):
table = table_name(batch.space_id, batch.dimensions)
# Coverage checks stay relational; no JSON decoding or Python dot products on the hot path.
where = '' if policy is None else ' AND b.embedding_local_only=?'
params = () if policy is None else (int(policy),)
missing = conn.execute(f'''SELECT 1 FROM blocks b LEFT JOIN routed_block_vectors r
ON r.block_id=b.block_id AND r.space_id=? AND r.dimensions=?
WHERE r.block_id IS NULL{where} LIMIT 1''', (batch.space_id, batch.dimensions, *params)).fetchone()
expected = conn.execute('SELECT COUNT(*) FROM blocks' + ('' if policy is None else ' WHERE embedding_local_only=?'), params).fetchone()[0]
actual = conn.execute(f'SELECT COUNT(*) FROM {table}' + ('' if policy is None else ' WHERE local_only=?'), params).fetchone()[0]
if missing or actual != expected:
raise ValueError('incomplete vector space coverage')
if top_k <= 0:
return []
rows = conn.execute(f'SELECT block_id,distance FROM {table} WHERE embedding MATCH ? AND k=?'
+ ('' if policy is None else ' AND local_only=?'),
(sqlite_vec.serialize_float32(batch.vectors[0]), top_k, *params)).fetchall()
return [VectorHit(id=row[0], score=max(0.0, min(1.0, 1 - row[1] ** 2 / 2))) for row in rows]
+99 -11
View File
@@ -6,11 +6,12 @@ from datetime import datetime, timezone
from uuid import uuid4
from fastapi import APIRouter, Header, Query, Request
from fastapi.responses import StreamingResponse
from fastapi.responses import FileResponse, StreamingResponse
from app.agent import AgentCapacityError, AgentRunNotFoundError
from app.container import container
from app.config import get_settings
from app.operation_logs import log_event
from app.extensions.archive import MAX_ZIP_BYTES, install_zip
from app.services.persona_settings import PersonaSettings, load_persona, save_persona
from app.contracts import (
@@ -56,6 +57,11 @@ from app.contracts import (
ModelRoutingResponse,
SpeakerMatchRequest,
SpeakerMatchResult,
ExportFormat,
ExportJob,
ExportJobListResponse,
ExportRequest,
ExportStatus,
Note,
NoteCreateRequest,
NoteListResponse,
@@ -104,9 +110,11 @@ 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.services.persona_settings import PersonaSettings, load_persona, save_persona
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
@@ -225,7 +233,7 @@ async def open_workspace(request: WorkspaceOpenRequest) -> WorkspaceSnapshot:
@router.get("/workspace/tree", response_model=list[WorkspaceEntry], tags=["Workspace"])
async def get_workspace_tree() -> list[WorkspaceEntry]:
return workspace_service.get_workspace_tree()
return await workspace_service.refresh_workspace_tree()
@router.post("/workspace/folders", response_model=WorkspaceEntry, tags=["Workspace"])
@@ -286,7 +294,8 @@ async def get_note(note_id: str) -> Note:
@router.patch("/notes/{note_id}", response_model=Note, tags=["Notes"])
async def update_note(note_id: str, request: NoteUpdateRequest) -> Note:
return await note_service.update_note(
note_id, title=request.title, markdown=request.markdown, tags=request.tags, defer_vectors=True
note_id, title=request.title, markdown=request.markdown, tags=request.tags,
expected_content_hash=request.expected_content_hash, defer_vectors=True
)
@@ -454,11 +463,15 @@ async def chat(request: ChatRequest) -> StreamingResponse:
usage = {"input_tokens": input_tokens, "output_tokens": output_tokens,
"total_tokens": input_tokens + output_tokens}
elif event.event == ModelEventType.error:
log_event('chat', 'model.error', level='ERROR', provider_id=request.provider_id,
model=request.model, error_code=event.data.get('code'))
if assistant_content:
assistant_content += "\n\n"
assistant_content += str(event.data.get("message", "Model generation failed."))
yield as_sse(event.event.value, event.model_dump_json())
except Exception as exc:
log_event('chat', 'chat.failed', level='ERROR', error=exc,
provider_id=request.provider_id, model=request.model)
failure_message = exc.message if isinstance(exc, ApiError) else "知识库检索或模型生成失败,请检查服务状态。"
if assistant_content:
assistant_content += "\n\n"
@@ -497,7 +510,7 @@ async def chat(request: ChatRequest) -> StreamingResponse:
async def list_agent_runs(
limit: int = Query(default=50, ge=1, le=100), offset: int = Query(default=0, ge=0)
) -> AgentRunListResponse:
items, total = container.agent.list_runs(limit=limit, offset=offset)
items, total = await asyncio.to_thread(container.agent.list_runs, limit=limit, offset=offset)
return AgentRunListResponse(
items=items,
page=PageMeta(total=total, limit=limit, offset=offset),
@@ -602,7 +615,7 @@ async def get_agent_trace(
limit: int = Query(default=200, ge=1, le=500),
) -> AgentTraceResponse:
try:
return container.agent.get_trace(
return await asyncio.to_thread(container.agent.get_trace,
run_id, after_sequence=after_sequence, limit=limit
)
except AgentRunNotFoundError as exc:
@@ -623,7 +636,7 @@ async def decide_agent_permission(
run_id: str, request_id: str, request: PermissionDecisionRequest
) -> OperationResponse:
agent_run_or_404(run_id)
if not container.agent.resolve_permission(run_id, request_id, request.decision):
if not await container.agent.resolve_permission(run_id, request_id, request.decision):
raise ApiError(
404,
"PERMISSION_REQUEST_NOT_FOUND",
@@ -1222,7 +1235,7 @@ async def test_provider(request: ProviderTestRequest) -> ProviderTestResponse:
async def list_tasks(
limit: int = Query(default=50, ge=1, le=100), offset: int = Query(default=0, ge=0)
) -> TaskListResponse:
items, total = task_service.list_tasks(limit=limit, offset=offset)
items, total = await asyncio.to_thread(task_service.list_tasks, limit=limit, offset=offset)
return TaskListResponse(
items=items, page=PageMeta(total=total, limit=limit, offset=offset)
)
@@ -1230,12 +1243,12 @@ async def list_tasks(
@router.post("/tasks", response_model=Task, tags=["Tasks"])
async def create_task(request: TaskCreateRequest) -> Task:
return task_service.create_task(**request.model_dump())
return await task_service.write_in_background(task_service.create_task, **request.model_dump())
@router.get("/tasks/{task_id}", response_model=Task, tags=["Tasks"])
async def get_task(task_id: str) -> Task:
task = task_service.get_task(task_id)
task = await asyncio.to_thread(task_service.get_task, task_id)
if task is None:
raise ApiError(
404, "RESOURCE_NOT_FOUND", "task not found", {"task_id": task_id}
@@ -1245,7 +1258,7 @@ async def get_task(task_id: str) -> Task:
@router.patch("/tasks/{task_id}", response_model=Task, tags=["Tasks"])
async def update_task(task_id: str, request: TaskUpdateRequest) -> Task:
return task_service.update_task(task_id, request.model_dump(exclude_unset=True))
return await task_service.write_in_background(task_service.update_task, task_id, request.model_dump(exclude_unset=True))
@router.delete(
@@ -1254,7 +1267,7 @@ async def update_task(task_id: str, request: TaskUpdateRequest) -> Task:
tags=["Tasks"],
)
async def delete_task(task_id: str) -> OperationResponse:
if not task_service.delete_task(task_id):
if not await task_service.write_in_background(task_service.delete_task, task_id):
raise ApiError(
404, "RESOURCE_NOT_FOUND", "task not found", {"task_id": task_id}
)
@@ -1501,6 +1514,81 @@ async def get_benchmark_report(run_id: str) -> BenchmarkReport:
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."
)
@router.get("/settings/persona", response_model=PersonaSettings, tags=["Settings"])
+9 -2
View File
@@ -1,7 +1,14 @@
import asyncio
from functools import wraps
from weakref import WeakKeyDictionary
_vault_mutation_lock = asyncio.Lock()
_vault_locks = WeakKeyDictionary()
def vault_mutation_lock():
# Service/test lifecycle restarts must not reuse a lock bound to a closed loop.
loop = asyncio.get_running_loop()
return _vault_locks.setdefault(loop, asyncio.Lock())
def serialized_vault_mutation(operation):
@@ -9,7 +16,7 @@ def serialized_vault_mutation(operation):
@wraps(operation)
async def wrapped(*args, **kwargs):
async with _vault_mutation_lock:
async with vault_mutation_lock():
return await operation(*args, **kwargs)
return wrapped
+43 -8
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
import logging
from app.operation_logs import log_event
from datetime import datetime, timezone
from pathlib import Path
@@ -16,7 +17,7 @@ from app.errors import ApiError
from app.knowledge.parser import parse_note
from app.services.note_service import index_note, prepare_note_index
from app.database.db import connect, transaction
from app.services.coordination import _vault_mutation_lock
from app.services.coordination import vault_mutation_lock
from app.retrieval.vectorstore import SqliteVecStore
from app.local_models.runtime import LocalEmbedding
from app.services import note_service
@@ -25,6 +26,7 @@ vector_store = SqliteVecStore()
_jobs: dict[str, IndexJob] = {}
_active_job_id: str | None = None
_active_scope: str | None = None
_last_completed_at: datetime | None = None
_last_error: str | None = None
MAX_JOBS = 100
@@ -33,6 +35,7 @@ _logger = logging.getLogger(__name__)
def _remember_job(job: IndexJob) -> None:
log_event('vectors', 'index.' + job.status, job_id=job.job_id, status=job.status)
_jobs[job.job_id] = job
while len(_jobs) > MAX_JOBS:
oldest = next(iter(_jobs))
@@ -64,7 +67,7 @@ def _scan_vault() -> list[tuple[str, str, str, datetime, datetime]]:
async def rebuild(request: IndexRebuildRequest) -> IndexJob:
global _active_job_id, _last_completed_at, _last_error
global _active_job_id, _active_scope, _last_completed_at, _last_error
if _active_job_id is not None:
raise ApiError(409, "INDEX_BUSY", "索引正在后台计算,请稍后重试。")
job_id = "job_" + uuid4().hex[:12]
@@ -82,6 +85,7 @@ async def rebuild(request: IndexRebuildRequest) -> IndexJob:
saved_paths = {record.file_path: record for record in saved_records.values() if record is not None}
_active_job_id = job_id
_active_scope = 'all'
_last_error = None
_remember_job(IndexJob(
job_id=job_id, status="running", scope=request.scope,
@@ -112,7 +116,7 @@ async def rebuild(request: IndexRebuildRequest) -> IndexJob:
prepared_notes.append((parsed, prepared))
# All network/model awaits precede the transaction. The concrete SQLite
# methods below complete synchronously despite their async interfaces.
async with _vault_mutation_lock:
async with vault_mutation_lock():
if _scan_vault() != docs or saved_records != {key: repository.get_note_record(key) for key in _pending_notes()}:
raise ApiError(409, "INDEX_SNAPSHOT_CHANGED", "笔记在计算期间发生变化,稍后重新计算。")
conn = connect()
@@ -148,6 +152,7 @@ async def rebuild(request: IndexRebuildRequest) -> IndexJob:
finally:
conn.close()
except BaseException as exc:
log_event('vectors', 'index.failed', level='WARNING' if isinstance(exc, asyncio.CancelledError) else 'ERROR', error=exc, job_id=job_id)
_remember_job(IndexJob(
job_id=job_id, status="failed", scope=request.scope,
created_at=datetime.now(timezone.utc),
@@ -156,6 +161,7 @@ async def rebuild(request: IndexRebuildRequest) -> IndexJob:
raise
finally:
_active_job_id = None
_active_scope = None
job = IndexJob(job_id=job_id, status="completed", scope=request.scope, created_at=datetime.now(timezone.utc))
_remember_job(job)
@@ -166,16 +172,26 @@ async def rebuild(request: IndexRebuildRequest) -> IndexJob:
def get_status() -> IndexStatus:
from app.retrieval import activity
counts = repository.stats()
vector_refresh_required = repository.get_index_meta().get('workspace_vectors_pending') == '1' or bool(_pending_notes())
workspace_pending = repository.get_index_meta().get('workspace_vectors_pending') == '1'
notes_pending = len(_pending_notes())
vector_refresh_required = workspace_pending or bool(notes_pending)
running = int(_active_job_id is not None)
# An entire-vault rebuild is one job, not one job per block/note.
pending = 1 if running and _active_scope == 'all' else (1 + running if workspace_pending else max(notes_pending, running))
activity_fields = dict(running_jobs=running, active_searches=activity.active,
completed_searches=activity.completed, failed_searches=activity.failed,
cancelled_searches=activity.cancelled)
if _active_job_id is not None:
return IndexStatus(status="running", pending_jobs=0, active_job_id=_active_job_id, vector_refresh_required=vector_refresh_required,
return IndexStatus(**activity_fields, status="running", pending_jobs=pending, active_job_id=_active_job_id, vector_refresh_required=vector_refresh_required,
total_notes=counts["notes"], total_blocks=counts["blocks"])
return IndexStatus(
**activity_fields,
vector_refresh_required=vector_refresh_required,
total_notes=counts["notes"], total_blocks=counts["blocks"],
status="failed" if _last_error else "idle",
pending_jobs=0,
pending_jobs=pending,
last_completed_at=_last_completed_at,
error_message=_last_error,
)
@@ -227,7 +243,7 @@ def _pending_notes() -> list[str]:
async def _refresh_saved_note(note_id: str) -> None:
global _active_job_id, _last_error, _last_completed_at
global _active_job_id, _active_scope, _last_error, _last_completed_at
record = repository.get_note_record(note_id)
key = f'note_vectors_pending:{note_id}'
if record is None:
@@ -240,13 +256,14 @@ async def _refresh_saved_note(note_id: str) -> None:
parsed.title = record.title
job_id = 'job_' + uuid4().hex[:12]
_active_job_id = job_id
_active_scope = 'note'
_last_error = None
_remember_job(IndexJob(job_id=job_id, status='running', scope='all', created_at=datetime.now(timezone.utc)))
try:
prepared = await prepare_note_index(parsed, strict=True)
if isinstance(note_service.embedding, LocalEmbedding) and parsed.blocks and prepared[1] is None:
raise ApiError(503, "EMBEDDING_UNAVAILABLE", "笔记已保存,后台向量计算未完成。")
async with _vault_mutation_lock:
async with vault_mutation_lock():
current = repository.get_note_record(note_id)
if current != record or note_service._read_markdown(record.file_path) != markdown:
# Another save or rename won the race; leave the durable queue entry intact.
@@ -254,6 +271,14 @@ async def _refresh_saved_note(note_id: str) -> None:
conn = connect()
try:
with transaction(conn):
existing_ids = {row[0] for row in conn.execute('SELECT block_id FROM blocks WHERE note_id=?', (note_id,))}
if existing_ids != {block.block_id for block in parsed.blocks}:
# An external editor changed a newly registered note while inference ran.
# Reconcile that note only; the snapshot check above protects newer saves.
parsed.title = parse_note(markdown=markdown, file_path=record.file_path,
folder=record.folder, tags=record.tags, created_at=record.created_at,
updated_at=record.updated_at, note_id=note_id).title
await index_note(parsed, prepared=prepared, conn=conn)
# Write only vectors: metadata and FTS already represent the saved revision.
vectors, remote = prepared
from app.retrieval.vectorstore import VectorRecord
@@ -261,14 +286,24 @@ async def _refresh_saved_note(note_id: str) -> None:
await vector_store.upsert([VectorRecord(id=b.block_id, vector=v)
for b, v in zip(parsed.blocks, vectors)], conn=conn)
routed_vectors.store_remote(conn, [b.block_id for b in parsed.blocks], remote)
if isinstance(note_service.embedding, LocalEmbedding) and parsed.blocks:
from app.retrieval.space_index import table_name
if remote is None:
raise ApiError(503, 'EMBEDDING_UNAVAILABLE', '笔记已保存,向量计算未完成。')
table = table_name(remote.space_id, remote.dimensions)
missing = conn.execute(f'SELECT 1 FROM blocks b LEFT JOIN {table} v ON v.block_id=b.block_id WHERE b.note_id=? AND v.block_id IS NULL LIMIT 1', (note_id,)).fetchone()
if missing:
raise ApiError(500, 'SEMANTIC_INDEX_WRITE_FAILED', '向量写入未完成,保留待处理标记。')
repository.set_index_meta({key: '0'}, conn=conn)
finally:
conn.close()
_last_completed_at = datetime.now(timezone.utc)
_remember_job(IndexJob(job_id=job_id, status='completed', scope='all', created_at=_last_completed_at))
except BaseException as exc:
log_event('vectors', 'index.failed', level='WARNING' if isinstance(exc, asyncio.CancelledError) else 'ERROR', error=exc, job_id=job_id)
_last_error = str(exc) or '后台向量计算已中断,笔记已保存。'
_remember_job(IndexJob(job_id=job_id, status='failed', scope='all', created_at=datetime.now(timezone.utc)))
raise
finally:
_active_job_id = None
_active_scope = None
@@ -19,6 +19,13 @@ def connection():
def record(**values):
from app.operation_logs import log_event
log_event('models', 'model.' + str(values.get('operation', 'inference')),
level='ERROR' if values.get('status') == 'failed' else 'WARNING' if values.get('status') == 'fallback' else 'INFO',
model=values.get('model'), source=values.get('source'), status=values.get('status'),
device=values.get('actual_device') or values.get('attempted_device'),
error_code=values.get('error_code'), fallback=values.get('fallback_reason'),
duration_ms=round(values.get('elapsed_seconds', 0) * 1000, 2))
safe = {key: value[:240] for key, value in values.items() if key in TEXT and isinstance(value, str)}
safe.update({key: value for key, value in values.items()
if key in NUMBERS and type(value) in (float, int) and math.isfinite(value) and value >= 0})
+29
View File
@@ -2,11 +2,37 @@ from __future__ import annotations
from datetime import datetime, timezone
from uuid import uuid4
import asyncio
from contextvars import copy_context
from functools import partial
from weakref import WeakKeyDictionary
from app import repository
from app.contracts import Task, TaskStatus
from app.database.db import connect, transaction
from app.errors import ApiError
from app.operation_logs import log_event
_write_locks = WeakKeyDictionary()
async def write_in_background(operation, *args, **kwargs):
# SQLite has one writer. Queue cooperatively instead of letting many worker
# threads fight over the file lock and starve unrelated model work.
loop = asyncio.get_running_loop()
lock = _write_locks.setdefault(loop, asyncio.Lock())
async with lock:
work = loop.run_in_executor(None, copy_context().run, partial(operation, *args, **kwargs))
cancelled = False
while not work.done():
try:
await asyncio.shield(work)
except asyncio.CancelledError:
cancelled = True
result = work.result()
if cancelled:
raise asyncio.CancelledError
return result
def _now() -> datetime:
@@ -49,6 +75,7 @@ def create_task(
),
)
row = conn.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
log_event('tasks', 'task.created', task_id=task_id, note_id=note_id, status='todo')
return _task_from_row(row)
finally:
conn.close()
@@ -111,6 +138,7 @@ def update_task(task_id: str, values: dict[str, object]) -> Task:
params,
)
row = conn.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
log_event('tasks', 'task.updated', task_id=task_id, status=row['status'], changed_fields=','.join(values))
return _task_from_row(row)
finally:
conn.close()
@@ -121,6 +149,7 @@ def delete_task(task_id: str) -> bool:
try:
with transaction(conn):
cursor = conn.execute("DELETE FROM tasks WHERE task_id = ?", (task_id,))
log_event('tasks', 'task.deleted' if cursor.rowcount else 'task.not_found', task_id=task_id)
return cursor.rowcount > 0
finally:
conn.close()
+5
View File
@@ -98,6 +98,11 @@ class UsageAttempt:
reasoning_tokens=first("output_tokens_details.reasoning_tokens", "completion_tokens_details.reasoning_tokens"))
def persist(self):
from app.operation_logs import log_event
log_event('providers', 'model.request_finished', level='INFO' if self.completed else 'WARNING',
provider_id=self.provider_id, model=self.model, run_id=self.run_id,
request_id=self.request_id, source=self.source,
status='completed' if self.completed else 'incomplete')
try:
with closing(connection()) as conn:
conn.execute("INSERT OR REPLACE INTO model_usage VALUES (?,?,?,?,?,?,?,?,?,?,?)", (
+9 -1
View File
@@ -105,6 +105,14 @@ def get_workspace_tree() -> list[WorkspaceEntry]:
return _tree(get_settings().vault_path.resolve(), locations)
async def refresh_workspace_tree() -> list[WorkspaceEntry]:
"""Observe external creates/deletes without waiting for vector inference."""
if get_workspace_info().requires_refresh:
await _register_workspace_files()
index_service.schedule_workspace_rebuild()
return get_workspace_tree()
async def open_workspace(requested_path: str | None) -> WorkspaceSnapshot:
"""打开只登记文件与全文索引,不让 Embedding 或厂商网络阻塞工作区。"""
@@ -153,7 +161,7 @@ async def _register_workspace_files() -> None:
created_at=parsed.created_at, updated_at=parsed.updated_at, blocks=parsed.blocks)
conn.execute('UPDATE blocks SET embedding_local_only=? WHERE note_id=?', (int(parsed.embedding_local_only), parsed.note_id))
if prepared:
repository.set_index_meta({'workspace_vectors_pending': '1'}, conn=conn)
repository.set_index_meta({f'note_vectors_pending:{parsed.note_id}': '1' for parsed in prepared}, conn=conn)
finally:
conn.close()
@@ -16,3 +16,5 @@ tags: RAG, 产品
粗排后使用 Reranker 对候选块重新打分,提升相关性。
<br />
@@ -2,7 +2,6 @@
title: 功能演示导航
tags: 演示, 入门
---
# 功能演示导航
这组笔记用于在真实工作区查看 Markdown、代码高亮、图表和检索效果。文中的项目、日期和数据均为演示内容。
@@ -10,12 +9,13 @@ tags: 演示, 入门
## 建议阅读顺序
| 笔记 | 可以查看的功能 |
| --- | --- |
| ----------------------------- | ---------------------- |
| 01 Markdown 与大纲 | 元数据、标题层级、列表、引用、表格与行内代码 |
| 02 多语言代码与公式 | Shiki 语言配色、代码块标签、数学公式 |
| 03 Mermaid 图表集 | 六种常用图型、主题颜色和大图查看 |
| 04 星灯项目资料 | 全文搜索、知识库问答与引用定位 |
| 05 Skill 与 Plugin 操作样例 | 扩展安装、选区命令和只读笔记检查 |
| [06 警告框与提示框](06%20警告框与提示框.md) | 类型与别名、标题、折叠、嵌套和主题配色 |
## 工作区操作
@@ -35,3 +35,4 @@ tags: 演示, 入门
- [ ] 在已配置模型后进行一次带知识库检索的问答。
> 上述清单供体验时自行勾选,不是自动验收结果。模型调用可能产生费用,图表与代码示例本身不会执行代码。
@@ -2,7 +2,6 @@
title: 星灯资料站项目简报
tags: 演示, 星灯项目, 检索
---
# 星灯资料站
星灯资料站是本组演示中的虚构项目,目标是为一个读书小组建立离线可用的学习资料目录。项目代号为 ST-27。
@@ -38,3 +37,4 @@ tags: 演示, 星灯项目, 检索
最后一个问题在本笔记中没有答案。检查回答是否说明资料不足,而不是编造负责人。其他问题可以对照正文并点击引用定位核实。
> 新建笔记需要完成索引后才能参与检索。没有模型配置时,也可以先在搜索页使用项目名、代号或独特检索词查找原文。
@@ -0,0 +1,150 @@
---
title: 警告框与提示框演示
tags: 演示, Markdown, 警告框, 主题
---
# 警告框与提示框
本页展示 GitHub 警告框和 Obsidian 提示框的类型、标题、折叠、嵌套及正文格式。打开工作区写作模式查看效果;切换源码模式查看原始语法。
## 五种常用警告框
> [!NOTE]
> 记录补充信息:这份笔记中的内容都是功能演示,不会执行代码或调用模型。
> [!TIP] 小技巧:快速插入
> 点击编辑器顶部的“提示框”选择器,选择类型后替换模板内容。
> [!IMPORTANT] 保存与显示状态
> 点击标题展开或收起,只改变本次显示状态。要修改默认状态,请在源码中的类型标记后添加 `+``-`
> [!WARNING] 修改前保留原文
> 在演示笔记中练习时,可以先复制一段内容;需要恢复时使用撤销。
> [!CAUTION] 需要重点关注的说明
> `CAUTION``WARNING` 使用同一警告配色。提示框是笔记内容,不是应用报错弹窗。
## 更多类型
> [!ABSTRACT] 本页摘要
> 类型区分语义,标题说明重点,正文保留详细信息。
> [!INFO] 环境信息
> 警告框的边框、标题和背景随主题变化。
> [!TODO] 待办
> - [ ] 展开下方折叠示例。
> - [ ] 切换深色主题。
> - [ ] 保存后重新打开本页。
> [!SUCCESS] 已完成
> 本段展示成功状态,不代表自动测试或实际任务已经完成。
> [!QUESTION] 可以嵌套吗?
> 可以。增加一级引用符号即可在提示框中嵌入另一个提示框。
> [!FAILURE] 未达到预期
> 示例:资料中缺少日期,需要补充后再归档。
> [!DANGER] 风险提示
> 示例:不要把唯一一份原始资料直接覆盖为整理结果。
> [!BUG] 问题记录
> 示例:发现显示异常时,记录主题、操作步骤和对应 Markdown 源码。
> [!EXAMPLE] 示例
> 将提示内容写成一句明确的说明,比只写“注意”更容易理解。
> [!QUOTE] 摘录
> 一条笔记既要保留结论,也要保留形成结论的依据。
## 默认展开与默认折叠
> [!TIP]+ 默认展开:点击标题试试
> 类型后的 `+` 表示默认展开。点击标题可收起,再次点击可展开。
> [!WARNING]- 默认折叠:点击查看内容
> 你已经展开了这段说明。类型后的 `-` 表示重新渲染时默认收起。
>
> 正文可以包含 **加粗**、*斜体*、~~删除线~~ 和 `行内代码`
## 嵌套与混合格式
> [!INFO]+ 一次资料整理
> 先整理来源,再检查缺漏。
>
> 1. 收集原始资料。
> 2. 按主题分组。
> 3. 为尚未确认的内容添加说明。
>
> > [!SUCCESS] 已收集
> > 原始笔记、会议纪要和参考链接已放入同一文件夹。
>
> > [!WARNING]- 尚待确认
> > 一条资料缺少发布日期,需要补充来源。
>
> | 项目 | 状态 |
> | --- | --- |
> | 原始资料 | 已归档 |
> | 日期核对 | 待补充 |
>
> ```python
> notes = ["原始资料", "整理结果"]
> print(len(notes))
> ```
>
> 行内公式:$a^2 + b^2 = c^2$。
## 类型别名
别名不区分大小写。下面的表格列出兼容关系。
| 类型 | 别名 |
| --- | --- |
| abstract | summary、tldr |
| tip | hint |
| success | check、done |
| question | help、faq |
| warning | caution、attention |
| failure | fail、missing |
| danger | error |
| quote | cite |
> [!summary] 摘要别名
> 这段使用 `summary`,外观与 `abstract` 一致。
> [!check] 成功别名
> 这段使用 `check`,外观与 `success` 一致。
> [!custom-demo] 未知类型的回退
> 自定义类型暂时使用 note 外观,源文件中的类型名仍然保留。
## 语法对照
以下围栏中的内容应当保持为代码,不渲染成警告框。
```markdown
> [!NOTE] 自定义标题
> 正文内容。
> [!WARNING]- 默认折叠
> 点击标题查看正文。
> [!TIP]+ 默认展开
> 默认可见的正文。
```
普通行内代码也保持原样:`[!WARNING]`
> 这是一段普通引用,没有提示类型标记,因此不应显示为警告框。
## 主题与保存体验清单
- [ ] 在浅色、深色、护眼主题下区分信息、成功、警告与危险颜色。
- [ ] 使用纸间时光,查看纸张虚线边框和嵌套层次。
- [ ] 使用 Ocean Blue 与 Midnight Purple,检查标题和正文是否清晰。
- [ ] 点击折叠标题,并使用 Tab、Enter 或空格体验键盘操作。
- [ ] 在源码模式修改一个类型或标题,再切回写作模式。
- [ ] 保存并重新打开,确认类型、标题、正文与默认折叠状态保持一致。
这是一份手动体验清单,未勾选不表示功能失败。桌面容器的原生格式快捷键与元数据转换仍属于第三阶段规划。
@@ -0,0 +1,958 @@
# 长文渲染压力测试
## 第 1 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 1
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 2 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 2
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 3 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 3
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 4 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 4
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 5 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 5
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 6 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 6
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 7 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 7
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 8 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 8
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
> [!TIP] 验收提示
> 内容需要保留,折叠后仍可展开。
| 项目 | 状态 |
| --- | --- |
| 渲染 | 待验证 |
```javascript
const note = { title: "长文测试", ready: true };
console.log(note);
```
## 第 9 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 9
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 10 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 10
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 11 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 11
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 12 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 12
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 13 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 13
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 14 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 14
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 15 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 15
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 16 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 16
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
> [!TIP] 验收提示
> 内容需要保留,折叠后仍可展开。
| 项目 | 状态 |
| --- | --- |
| 渲染 | 待验证 |
```javascript
const note = { title: "长文测试", ready: true };
console.log(note);
```
## 第 17 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 17
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 18 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 18
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 19 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 19
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 20 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 20
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 21 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 21
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 22 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 22
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 23 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 23
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 24 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 24
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
> [!TIP] 验收提示
> 内容需要保留,折叠后仍可展开。
| 项目 | 状态 |
| --- | --- |
| 渲染 | 待验证 |
```javascript
const note = { title: "长文测试", ready: true };
console.log(note);
```
## 第 25 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 25
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 26 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 26
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 27 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 27
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 28 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 28
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 29 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 29
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 30 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 30
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 31 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 31
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 32 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 32
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
> [!TIP] 验收提示
> 内容需要保留,折叠后仍可展开。
| 项目 | 状态 |
| --- | --- |
| 渲染 | 待验证 |
```javascript
const note = { title: "长文测试", ready: true };
console.log(note);
```
## 第 33 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 33
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 34 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 34
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 35 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 35
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 36 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 36
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 37 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 37
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 38 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 38
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 39 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 39
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 40 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 40
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
> [!TIP] 验收提示
> 内容需要保留,折叠后仍可展开。
| 项目 | 状态 |
| --- | --- |
| 渲染 | 待验证 |
```javascript
const note = { title: "长文测试", ready: true };
console.log(note);
```
## 第 41 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 41
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 42 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 42
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 43 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 43
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 44 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 44
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 45 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 45
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 46 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 46
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 47 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 47
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 48 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 48
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
> [!TIP] 验收提示
> 内容需要保留,折叠后仍可展开。
| 项目 | 状态 |
| --- | --- |
| 渲染 | 待验证 |
```javascript
const note = { title: "长文测试", ready: true };
console.log(note);
```
## 第 49 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 49
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 50 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 50
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 51 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 51
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 52 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 52
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 53 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 53
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 54 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 54
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 55 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 55
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 56 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 56
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
> [!TIP] 验收提示
> 内容需要保留,折叠后仍可展开。
| 项目 | 状态 |
| --- | --- |
| 渲染 | 待验证 |
```javascript
const note = { title: "长文测试", ready: true };
console.log(note);
```
## 第 57 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 57
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 58 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 58
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 59 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 59
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 60 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 60
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 61 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 61
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 62 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 62
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 63 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 63
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 64 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 64
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
> [!TIP] 验收提示
> 内容需要保留,折叠后仍可展开。
| 项目 | 状态 |
| --- | --- |
| 渲染 | 待验证 |
```javascript
const note = { title: "长文测试", ready: true };
console.log(note);
```
## 第 65 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 65
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 66 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 66
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 67 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 67
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 68 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 68
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 69 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 69
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 70 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 70
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 71 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 71
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 72 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 72
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
> [!TIP] 验收提示
> 内容需要保留,折叠后仍可展开。
| 项目 | 状态 |
| --- | --- |
| 渲染 | 待验证 |
```javascript
const note = { title: "长文测试", ready: true };
console.log(note);
```
## 第 73 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 73
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 74 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 74
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 75 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 75
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 76 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 76
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 77 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 77
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 78 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 78
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 79 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 79
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 80 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 80
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
> [!TIP] 验收提示
> 内容需要保留,折叠后仍可展开。
| 项目 | 状态 |
| --- | --- |
| 渲染 | 待验证 |
```javascript
const note = { title: "长文测试", ready: true };
console.log(note);
```
## 第 81 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 81
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 82 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 82
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 83 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 83
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 84 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 84
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 85 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 85
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 86 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 86
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 87 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 87
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 88 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 88
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
> [!TIP] 验收提示
> 内容需要保留,折叠后仍可展开。
| 项目 | 状态 |
| --- | --- |
| 渲染 | 待验证 |
```javascript
const note = { title: "长文测试", ready: true };
console.log(note);
```
## 第 89 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 89
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 90 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 90
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 91 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 91
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 92 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 92
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 93 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 93
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 94 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 94
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 95 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 95
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 96 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 96
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
> [!TIP] 验收提示
> 内容需要保留,折叠后仍可展开。
| 项目 | 状态 |
| --- | --- |
| 渲染 | 待验证 |
```javascript
const note = { title: "长文测试", ready: true };
console.log(note);
```
## 第 97 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 97
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 98 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 98
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 99 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 99
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 100 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 100
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 第 101 节:知识整理
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
### 小结 101
重点包含 **强调文字**`inlineCode` 和 [链接](https://example.com)。
## 文末校验
结束标记:长文内容完整。
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3
View File
@@ -9,8 +9,11 @@ dependencies = [
"fastapi>=0.116,<1.0",
"httpx>=0.28,<1.0",
"jsonschema>=4.25,<5.0",
"mistune>=3.0,<4.0",
"python-docx>=1.1,<2.0",
"pyyaml>=6.0,<7.0",
"referencing>=0.36,<1.0",
"reportlab>=4.0,<5.0",
"sqlite-vec>=0.1.9",
"uvicorn[standard]>=0.35,<1.0",
]
+250
View File
@@ -0,0 +1,250 @@
"""Offline Agent/runtime and task API load test; all state lives in a temporary directory."""
from __future__ import annotations
import argparse
import asyncio
import json
import inspect
import math
import os
import pathlib
import platform
import sys
import tempfile
import time
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
def stats(values):
values = sorted(values)
return {"count": len(values), "median_ms": round(values[len(values)//2], 2),
"p95_ms": round(values[min(len(values)-1, math.ceil(len(values)*.95)-1)], 2),
"max_ms": round(values[-1], 2)} if values else {"count": 0}
async def main(output):
# Set before importing any app modules: container has import-time initialization.
with tempfile.TemporaryDirectory(prefix="notes-agent-task-stress-") as directory:
root = pathlib.Path(directory)
os.environ.update(APP_DATA_DIR=str(root / 'data'), APP_DB_PATH=str(root / 'app.db'),
APP_VAULT_PATH=str(root / 'vault'))
from app.container import container
from app.contracts import AgentRunCreateRequest, AgentRunStatus
from app.agent.runtime import AgentRuntime, AgentCapacityError
from app.agent.permissions import PermissionMode
from app.main import app
import httpx
report = {"python": platform.python_version(), "platform": platform.platform(),
"provider": "mock with 50 ms injected delay per model turn; no network", "results": []}
def save(name, value):
report['results'].append({"scenario": name, **value})
output.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8')
print(json.dumps(report['results'][-1], ensure_ascii=False), flush=True)
async def measured(operation):
delays = []
async def heartbeat():
while True:
start = time.perf_counter()
await asyncio.sleep(.01)
delays.append(max(0, (time.perf_counter()-start)*1000-10))
pulse = asyncio.create_task(heartbeat())
await asyncio.sleep(0)
start = time.perf_counter()
try:
result = await operation()
await asyncio.sleep(.02)
return {**result, "elapsed_ms": round((time.perf_counter()-start)*1000, 2),
"event_loop_lag": stats(delays)}
finally:
pulse.cancel()
await asyncio.gather(pulse, return_exceptions=True)
adapter = container.providers.get('mock').adapter
original = adapter.complete
async def delayed(request):
await asyncio.sleep(.05)
return await original(request)
adapter.complete = delayed
runtime = container.agent
for concurrency in (1, 10, 50, 200):
async def batch():
durations, sequences, statuses = [], [], []
semaphore = asyncio.Semaphore(concurrency)
async def one(index):
async with semaphore:
start = time.perf_counter()
created = await runtime.create_run(AgentRunCreateRequest(
input=f'/tool system.echo {{"text":"pressure-{index}"}}',
provider_id='mock', model='mock-1', allowed_tools=['system.echo']))
events = [event async for event in runtime.events(created.run_id)]
done = await runtime.wait(created.run_id)
durations.append((time.perf_counter()-start)*1000)
statuses.append(done.status.value)
sequence = [event.sequence for event in events]
sequences.append(sequence == list(range(len(sequence))))
assert done.status == AgentRunStatus.completed
assert done.tool_results[0].output == {"text": f"pressure-{index}"}
replay = [event.sequence async for event in runtime.events(created.run_id, after_sequence=2)]
assert replay == sequence[3:]
return created.run_id
ids = await asyncio.gather(*(one(i) for i in range(max(20, concurrency))))
assert all(sequences)
recovered = AgentRuntime(container.providers, container.tools, container.permissions,
trace_repository=runtime.trace_repository)
assert all(recovered.get_run(run_id).status == AgentRunStatus.completed for run_id in ids)
assert not any(record.subscribers for record in runtime._records.values())
return {"concurrency": concurrency, "runs": len(ids), "latency": stats(durations),
"completed": statuses.count('completed'), "ordered_events_and_replay": True,
"terminal_recovery": True, "retained_records": len(runtime._records)}
save('agent_tool_runs', await measured(batch))
# Hold model calls so all 200 records remain active while testing admission.
gate = asyncio.Event()
async def blocked(request):
await gate.wait()
return await original(request)
adapter.complete = blocked
async def capacity():
ids = [(await runtime.create_run(AgentRunCreateRequest(input='capacity', provider_id='mock', model='mock-1'))).run_id for _ in range(200)]
rejected = False
try:
await runtime.create_run(AgentRunCreateRequest(input='overflow', provider_id='mock', model='mock-1'))
except AgentCapacityError:
rejected = True
await asyncio.sleep(0)
latencies = []
for run_id in ids:
start = time.perf_counter()
await runtime.cancel(run_id)
latencies.append((time.perf_counter()-start)*1000)
states = await asyncio.gather(*(runtime.wait(run_id) for run_id in ids))
assert rejected and all(run.status == AgentRunStatus.cancelled for run in states)
assert all(not record.task or record.task.done() for record in runtime._records.values())
return {"active_limit": 200, "overflow_rejected": rejected, "cancelled": len(states), "cancel_latency": stats(latencies)}
save('capacity_and_cancel', await measured(capacity))
adapter.complete = delayed
async def permissions():
tool = container.tools.get('system.echo')
previous = tool.definition.permission
tool.definition.permission = 'stress.confirm'
container.permissions.policy.set_rule('stress.confirm', PermissionMode.confirm)
async def one(index):
created = await runtime.create_run(AgentRunCreateRequest(input='/tool system.echo {"text":"permission"}',
provider_id='mock', model='mock-1', allowed_tools=['system.echo'], tool_timeout_seconds=30))
stream = runtime.events(created.run_id)
try:
async for event in stream:
if event.event.value == 'PermissionRequired':
if index % 2: await runtime.cancel(created.run_id)
else: assert await runtime.resolve_permission(created.run_id, str(event.data['request_id']), 'allow')
break
finally:
await stream.aclose()
return (await runtime.wait(created.run_id)).status.value
try:
states = await asyncio.wait_for(asyncio.gather(*(one(i) for i in range(20))), 60)
assert states.count('completed') == states.count('cancelled') == 10
assert not any(record.subscribers for record in runtime._records.values())
return {"runs": 20, "approved_completed": 10, "cancelled_waiting_permission": 10, "subscribers_released": True}
finally:
tool.definition.permission = previous
save('permission_wait', await measured(permissions))
async def failures():
active = 0
async def injected(request):
text = request.messages[-1].content
if text == 'inject-provider-error': raise RuntimeError('injected offline provider failure')
if text == 'inject-model-timeout': await asyncio.sleep(60)
return await delayed(request)
tool = container.tools.get('system.echo')
executor = tool.executor
async def slow_tool(arguments, context):
nonlocal active
active += 1
try:
if arguments.text == 'slow': await asyncio.sleep(60)
value = executor(arguments, context)
return await value if inspect.isawaitable(value) else value
finally: active -= 1
adapter.complete = injected
tool.executor = slow_tool
async def one(index):
mode = index % 4
text = ['normal', 'inject-provider-error', 'inject-model-timeout', '/tool system.echo {"text":"slow"}'][mode]
created = await runtime.create_run(AgentRunCreateRequest(input=text, provider_id='mock', model='mock-1',
allowed_tools=['system.echo'], run_timeout_seconds=1 if mode == 2 else 30, tool_timeout_seconds=1))
result = await runtime.wait(created.run_id)
if mode == 0: assert result.status == AgentRunStatus.completed
elif mode == 1: assert result.status == AgentRunStatus.failed and result.error_code == 'AGENT_FAILED'
elif mode == 2: assert result.status == AgentRunStatus.failed and result.error_code == 'AGENT_TIMEOUT'
else: assert result.tool_results[0].error_code == 'TOOL_TIMEOUT'
try:
await asyncio.wait_for(asyncio.gather(*(one(i) for i in range(20))), 45)
assert active == 0
return {"runs": 20, "success": 5, "provider_errors": 5, "model_timeouts": 5,
"tool_timeouts": 5, "remaining_tool_executors": active}
finally:
adapter.complete = delayed
tool.executor = executor
save('failure_and_timeout_isolation', await measured(failures))
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://stress.local') as client:
for count in (100, 1000):
async def tasks():
timings = {name: [] for name in ('create', 'update', 'list', 'delete')}
async def call(method, path, **kwargs):
start = time.perf_counter()
response = await client.request(method, path, **kwargs)
response.raise_for_status()
return response.json(), (time.perf_counter()-start)*1000
semaphore = asyncio.Semaphore(20)
async def create(index):
async with semaphore:
body, duration = await call('POST', '/api/tasks', json={'title': f'压测任务 {index}', 'description': '独立测试数据'})
timings['create'].append(duration)
return body['task_id']
ids = await asyncio.gather(*(create(i) for i in range(count)))
first, _ = await call('GET', '/api/tasks')
seen = []
for offset in range(0, count, 100):
body, duration = await call('GET', f'/api/tasks?limit=100&offset={offset}')
timings['list'].append(duration)
seen.extend(item['task_id'] for item in body['items'])
assert len(set(seen)) == count and set(seen) == set(ids)
async def change(run_id):
async with semaphore:
body, duration = await call('PATCH', f'/api/tasks/{run_id}', json={'status': 'done'})
assert body['status'] == 'done'
timings['update'].append(duration)
_, duration = await call('DELETE', f'/api/tasks/{run_id}')
timings['delete'].append(duration)
await asyncio.gather(*(change(run_id) for run_id in ids))
final, _ = await call('GET', '/api/tasks')
assert final['page']['total'] == 0
return {"tasks": count, "client_concurrency": 20, "latencies": {key: stats(value) for key, value in timings.items()},
"default_page_count": len(first['items']), "default_total": first['page']['total'],
"pagination_complete": True, "final_total": 0}
save('task_api_crud', await measured(tasks))
report['database_bytes'] = (root / 'app.db').stat().st_size
report['complete'] = True
output.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8')
container.plugins.shutdown()
container.mcp_servers.shutdown()
from app.operation_logs import shutdown_logging
await asyncio.to_thread(shutdown_logging)
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--output', type=pathlib.Path, required=True)
args = parser.parse_args()
args.output.parent.mkdir(parents=True, exist_ok=True)
asyncio.run(main(args.output))
+96
View File
@@ -0,0 +1,96 @@
"""Real loopback HTTP task load with a separate, temporary Uvicorn process."""
import argparse
import asyncio
import json
import math
import os
from pathlib import Path
import socket
import subprocess
import sys
import tempfile
from time import perf_counter
import httpx
def stats(values):
values = sorted(values)
return {'count': len(values), 'p95_ms': round(values[math.ceil(len(values)*.95)-1], 2),
'max_ms': round(values[-1], 2)} if values else {'count': 0}
async def main(args):
with tempfile.TemporaryDirectory(prefix='notes-task-http-') as directory:
root = Path(directory)
env = {**os.environ, 'APP_DATA_DIR': str(root/'data'), 'APP_DB_PATH': str(root/'app.db'), 'APP_VAULT_PATH': str(root/'vault')}
with socket.socket() as sock:
sock.bind(('127.0.0.1', 0)); port = sock.getsockname()[1]
process = subprocess.Popen([sys.executable, '-m', 'uvicorn', 'app.main:app', '--host', '127.0.0.1', '--port', str(port), '--log-level', 'error'],
cwd=Path(__file__).resolve().parents[1], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0)
try:
async with httpx.AsyncClient(base_url=f'http://127.0.0.1:{port}', timeout=30) as client:
for _ in range(150):
if process.poll() is not None: raise RuntimeError('Isolated Uvicorn exited')
try:
(await client.get('/health')).raise_for_status(); break
except httpx.HTTPError: await asyncio.sleep(.1)
else: raise TimeoutError('Isolated Uvicorn startup')
sem = asyncio.Semaphore(args.concurrency)
timings = {key: [] for key in ('create', 'update', 'list', 'delete', 'health')}
errors = []
async def request(method, path, kind, **kwargs):
start = perf_counter()
response = await client.request(method, path, **kwargs)
timings[kind].append((perf_counter()-start)*1000)
response.raise_for_status()
return response.json()
async def health():
while True:
try: await request('GET', '/health', 'health')
except httpx.HTTPError as error: errors.append(type(error).__name__)
await asyncio.sleep(.05)
heartbeat = asyncio.create_task(health())
start = perf_counter()
try:
async def create(index):
async with sem:
return (await request('POST', '/api/tasks', 'create', json={'title': f'HTTP 压测 {index}'}))['task_id']
ids = await asyncio.gather(*(create(i) for i in range(args.count)))
seen = []
for offset in range(0, args.count, 100):
page = await request('GET', f'/api/tasks?limit=100&offset={offset}', 'list')
seen.extend(item['task_id'] for item in page['items'])
assert set(seen) == set(ids) and len(seen) == args.count
async def change(task_id):
async with sem:
updated = await request('PATCH', f'/api/tasks/{task_id}', 'update', json={'status': 'done'})
assert updated['status'] == 'done'
await request('DELETE', f'/api/tasks/{task_id}', 'delete')
await asyncio.gather(*(change(task_id) for task_id in ids))
remaining = await request('GET', '/api/tasks', 'list')
assert remaining['page']['total'] == 0
finally:
heartbeat.cancel(); await asyncio.gather(heartbeat, return_exceptions=True)
report = {'transport': 'real loopback HTTP, separate Uvicorn process', 'tasks': args.count,
'concurrency': args.concurrency, 'elapsed_ms': round((perf_counter()-start)*1000, 2),
'latencies': {key: stats(value) for key,value in timings.items()}, 'health_errors': errors,
'pagination_complete': True, 'final_total': 0}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8')
print(json.dumps(report, ensure_ascii=False))
finally:
process.terminate()
try: process.wait(timeout=10)
except subprocess.TimeoutExpired: process.kill(); process.wait()
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--count', type=int, default=1000)
parser.add_argument('--concurrency', type=int, default=20)
args = parser.parse_args()
if args.count < 1 or args.concurrency < 1: parser.error('count and concurrency must be positive')
asyncio.run(main(args))
+64
View File
@@ -0,0 +1,64 @@
"""Synthetic, isolated exact-search comparison; does not access the user Vault."""
import heapq
import json
import math
import random
import sqlite3
import statistics
import sys
import tempfile
import time
from pathlib import Path
import sqlite_vec
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.retrieval import space_index
from app.retrieval.routed_vectors import RemoteEmbeddings, _unit_vector
def main():
rng = random.Random(42)
count, dimensions = 4000, 384
with tempfile.TemporaryDirectory(prefix='notes-vec-bench-') as temporary:
conn = sqlite3.connect(Path(temporary) / 'vectors.db')
conn.row_factory = sqlite3.Row
conn.enable_load_extension(True)
sqlite_vec.load(conn)
conn.enable_load_extension(False)
conn.execute('CREATE TABLE blocks(block_id TEXT PRIMARY KEY, embedding_local_only INTEGER)')
conn.execute('CREATE TABLE routed_block_vectors(space_id TEXT,block_id TEXT,dimensions INTEGER,vector TEXT,PRIMARY KEY(space_id,dimensions,block_id))')
vectors = [_unit_vector([rng.uniform(-1, 1) for _ in range(dimensions)], dimensions) for _ in range(count)]
conn.executemany('INSERT INTO blocks VALUES (?,0)', [(str(i),) for i in range(count)])
conn.executemany('INSERT INTO routed_block_vectors VALUES (?,?,?,?)', [('benchmark', str(i), dimensions, json.dumps(v)) for i, v in enumerate(vectors)])
start = time.perf_counter()
space_index.ensure(conn, 'benchmark', dimensions)
migration_ms = (time.perf_counter() - start) * 1000
conn.commit()
query = vectors[0]
batch = RemoteEmbeddings('benchmark', dimensions, [query])
def legacy():
def hits():
for row in conn.execute('SELECT block_id,vector FROM routed_block_vectors'):
vector = _unit_vector(json.loads(row[1]), dimensions)
yield row[0], max(0., min(1., math.fsum(a*b for a,b in zip(query,vector))))
return heapq.nlargest(20, hits(), key=lambda hit:hit[1])
def native():
return [(hit.id,hit.score) for hit in space_index.search(conn,batch,20)]
measurements = {}
results = {}
for name, operation in [('python_json_scan', legacy), ('sqlite_vec',native)]:
elapsed = []
for _ in range(5):
start = time.perf_counter()
results[name] = operation()
elapsed.append((time.perf_counter()-start)*1000)
measurements[name] = {'median_ms':statistics.median(elapsed), 'samples_ms':elapsed}
assert [hit[0] for hit in results['python_json_scan']] == [hit[0] for hit in results['sqlite_vec']]
print(json.dumps({'blocks':count,'dimensions':dimensions,'top_k':20,'migration_ms':migration_ms,
'same_top_k':True,'measurements':measurements},indent=2))
conn.close()
if __name__ == '__main__':
main()
+4 -3
View File
@@ -112,7 +112,7 @@ def test_permission_confirmation_resumes_agent() -> None:
break
assert request_id is not None
assert container.agent.resolve_permission(
assert await container.agent.resolve_permission(
created.run_id, request_id, "allow_once"
)
completed = await container.agent.wait(created.run_id)
@@ -370,8 +370,9 @@ def test_cancelling_permission_wait_cancels_run() -> None:
)
async with asyncio.timeout(2):
while container.agent.get_run(created.run_id).status != AgentRunStatus.waiting_permission:
await asyncio.sleep(0)
async for event in container.agent.events(created.run_id):
if event.event == AgentEventType.permission_required:
break
cancelled = await container.agent.cancel(created.run_id)
await container.agent.wait(created.run_id)
+711
View File
@@ -0,0 +1,711 @@
"""Export Service 的单元与端到端测试。
沿用 conftest 隔离机制APP_DATA_DIR / DB / Vault / exports 目录都落在临时目录
不读写真实数据导出采用创建即 queued + 后台 Task 执行的异步模型测试在同一
事件循环内创建并等待后台任务结束得到终态 ExportJob 后再断言
"""
from __future__ import annotations
import asyncio
import base64
import re
import zlib
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():
"""清空内存注册表,避免跨用例的任务/取消标志互相污染。
每个用例经 `asyncio.run()` 使用独立事件循环模块级 Semaphore 会绑定到首个
循环跨用例复用会触发bound to a different event loop此处每例重建槽位
"""
export_service._jobs.clear()
export_service._tasks.clear()
export_service._cancel_flags.clear()
export_service._render_slots = asyncio.Semaphore(export_service.MAX_CONCURRENT_RENDERS)
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
# --------------------------------------------------------------------------- #
# PDF / DOCX 导出
# --------------------------------------------------------------------------- #
def test_export_pdf_completes_with_pdf_magic_bytes() -> None:
finished = _create_and_wait(_markdown_request(MD, format=ExportFormat.pdf))
assert finished.status == ExportStatus.completed
assert finished.file is not None
assert finished.file.mime_type == "application/pdf"
assert finished.file.file_name.endswith(".pdf")
path = get_settings().exports_path / f"{finished.job_id}.pdf"
assert path.exists()
assert path.read_bytes()[:4] == b"%PDF"
def test_export_docx_completes_with_zip_magic_bytes() -> None:
finished = _create_and_wait(_markdown_request(MD, format=ExportFormat.docx))
assert finished.status == ExportStatus.completed
assert finished.file is not None
assert finished.file.mime_type == (
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
)
assert finished.file.file_name.endswith(".docx")
path = get_settings().exports_path / f"{finished.job_id}.docx"
assert path.exists()
assert path.read_bytes()[:2] == b"PK"
def test_pdf_exporter_marks_plot_and_mermaid_as_placeholders() -> None:
from app.export.exporters.pdf import PdfExporter
md = "```mermaid\ngraph LR\n```\n\n```function_plot\ny = x\n```"
result = asyncio.run(PdfExporter().export(parse_document(md), ExportOptions()))
assert result.content[:4] == b"%PDF"
assert any("mermaid" in w for w in result.warnings)
assert any("函数图像" in w for w in result.warnings)
def test_docx_exporter_marks_plot_and_mermaid_as_placeholders() -> None:
from app.export.exporters.docx import DocxExporter
md = "```mermaid\ngraph LR\n```\n\n```function_plot\ny = x\n```"
result = asyncio.run(DocxExporter().export(parse_document(md), ExportOptions()))
assert result.content[:2] == b"PK"
assert any("mermaid" in w for w in result.warnings)
assert any("函数图像" in w for w in result.warnings)
def test_pdf_exporter_embeds_cjk_font() -> None:
from app.export.exporters.pdf import PdfExporter
doc = parse_document("# 进程调度\n\n一些中文正文。")
doc.attributes["title"] = "操作系统复习"
result = asyncio.run(PdfExporter().export(doc, ExportOptions(include_title=True)))
assert result.content[:4] == b"%PDF"
# 中文字体通过 STSong-Light CID 字体嵌入,PDF 内应引用该 BaseFont
assert b"STSong-Light" in result.content
def test_docx_exporter_contains_cjk_text() -> None:
import zipfile
from io import BytesIO
from app.export.exporters.docx import DocxExporter
doc = parse_document("# 进程调度\n\n一些中文正文。")
result = asyncio.run(DocxExporter().export(doc, ExportOptions()))
with zipfile.ZipFile(BytesIO(result.content)) as zf:
xml = zf.read("word/document.xml")
assert "进程调度".encode("utf-8") in xml
def _pdf_unescape(raw: bytes) -> bytes:
"""反转义 PDF 字符串字面量(八进制转义与 \n \r \t 等)。"""
out = bytearray()
i = 0
n = len(raw)
while i < n:
b = raw[i]
if b == 0x5C and i + 1 < n: # 反斜杠转义
nxt = raw[i + 1]
if 0x30 <= nxt <= 0x37: # 八进制(如 \000
j = i + 1
digits = bytearray()
while j < n and j < i + 4 and 0x30 <= raw[j] <= 0x37:
digits.append(raw[j])
j += 1
out.append(int(digits.decode(), 8) & 0xFF)
i = j
continue
simple = {0x6E: 0x0A, 0x72: 0x0D, 0x74: 0x09, 0x62: 0x08, 0x66: 0x0C}
out.append(simple.get(nxt, nxt))
i += 2
continue
out.append(b)
i += 1
return bytes(out)
def _extract_pdf_text(content: bytes) -> str:
"""从 PDF 内容流提取文本(仅测试断言用,非完整 PDF 文本提取)。
reportlab CID 字体按 UTF-16BE高位 0x00编码字符串写为 \000 前缀的八进制
转义这里解码 ASCII85+flate 内容流反转义字符串并去掉 0x00 还原 ASCII 正文
"""
chunks: list[str] = []
for m in re.finditer(rb"stream\r?\n(.*?)endstream", content, re.DOTALL):
raw = m.group(1).strip()
if raw.endswith(b"~>"):
raw = raw[:-2]
try:
dec = zlib.decompress(base64.a85decode(raw))
except Exception:
try:
dec = zlib.decompress(raw)
except Exception:
dec = raw
for sm in re.finditer(rb"\(((?:[^()\\]|\\.)*)\)\s*Tj", dec):
text = _pdf_unescape(sm.group(1))
if text.count(0) > len(text) // 4:
text = text.replace(b"\x00", b"")
chunks.append(text.decode("latin-1"))
return "".join(chunks)
# --------------------------------------------------------------------------- #
# 审阅回归:结构内容验证(不只校验魔法字节,还验证产物正文)
# --------------------------------------------------------------------------- #
def test_pdf_blockquote_preserves_content() -> None:
from app.export.exporters.pdf import PdfExporter
# P2:引用块正文不能因「把块级子节点交给行内渲染器」而丢失
result = asyncio.run(
PdfExporter().export(parse_document("> quoted **content**"), ExportOptions())
)
text = _extract_pdf_text(result.content)
assert "quoted" in text
assert "content" in text
assert not any("无法表示" in w for w in result.warnings)
def test_docx_blockquote_preserves_content() -> None:
import zipfile
from io import BytesIO
from app.export.exporters.docx import DocxExporter
result = asyncio.run(
DocxExporter().export(parse_document("> quoted **content**"), ExportOptions())
)
with zipfile.ZipFile(BytesIO(result.content)) as zf:
xml = zf.read("word/document.xml").decode("utf-8")
assert "quoted" in xml
assert "content" in xml
assert not any("无法表示" in w for w in result.warnings)
def test_pdf_nested_list_parent_before_child() -> None:
from app.export.exporters.pdf import PdfExporter
# P2:嵌套列表输出顺序颠倒——父级正文应在子列表之前
result = asyncio.run(
PdfExporter().export(parse_document("- parent\n - child"), ExportOptions())
)
text = _extract_pdf_text(result.content)
assert text.index("parent") < text.index("child")
def test_docx_nested_list_parent_before_child() -> None:
import zipfile
from io import BytesIO
from app.export.exporters.docx import DocxExporter
result = asyncio.run(
DocxExporter().export(parse_document("- parent\n - child"), ExportOptions())
)
with zipfile.ZipFile(BytesIO(result.content)) as zf:
xml = zf.read("word/document.xml").decode("utf-8")
assert xml.index("parent") < xml.index("child")
def test_export_cancel_queued_job_waiting_for_slot(monkeypatch) -> None:
# P2:等待渲染槽位的任务取消后应立即进入 cancelled,不必等前面的渲染完成
import threading
real_render = export_service._render_document
release = threading.Event()
entered = 0
lock = threading.Lock()
def blocking_render(document, options, format):
nonlocal entered
with lock:
entered += 1
release.wait(timeout=5)
return real_render(document, options, format)
monkeypatch.setattr(export_service, "_render_document", blocking_render)
async def _go():
a = await export_service.create_export(_markdown_request("# a"))
b = await export_service.create_export(_markdown_request("# b"))
# 等 a/b 两个任务都拿到槽位并阻塞在渲染里
for _ in range(2000):
if entered >= 2:
break
await asyncio.sleep(0.001)
c = await export_service.create_export(_markdown_request("# c"))
await asyncio.sleep(0.01) # 让 c 进入排队等待槽位
export_service.cancel_export(c.job_id)
finished_c = await export_service.wait_for_export(c.job_id)
release.set() # 放行前面的任务,避免测试挂起
await asyncio.gather(
export_service.wait_for_export(a.job_id),
export_service.wait_for_export(b.job_id),
)
return finished_c
finished = asyncio.run(_go())
assert finished.status == ExportStatus.cancelled
assert finished.file is None
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, format):
nonlocal active, peak
with lock:
active += 1
peak = max(peak, active)
time.sleep(0.05)
with lock:
active -= 1
return real_render(document, options, format)
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
+68
View File
@@ -0,0 +1,68 @@
import asyncio
from types import SimpleNamespace
import pytest
from app.retrieval import activity
from app.services import index_service
@pytest.fixture(autouse=True)
def reset_activity(monkeypatch):
for field in ('active', 'completed', 'failed', 'cancelled'):
monkeypatch.setattr(activity, field, 0)
monkeypatch.setattr(index_service, '_active_job_id', None)
monkeypatch.setattr(index_service, '_active_scope', None)
monkeypatch.setattr(index_service, '_last_error', None)
monkeypatch.setattr(index_service.repository, 'stats', lambda: {'notes': 16, 'blocks': 3787})
monkeypatch.setattr(index_service.repository, 'get_index_meta', lambda: {})
def test_pending_rebuild_and_incremental_jobs(monkeypatch):
meta = {'workspace_vectors_pending': '1', 'note_vectors_pending:a': '1', 'note_vectors_pending:b': '1'}
monkeypatch.setattr(index_service.repository, 'get_index_meta', lambda: meta)
status = index_service.get_status()
assert status.vector_refresh_required and status.pending_jobs == 1
assert status.running_jobs == 0
monkeypatch.setattr(index_service, '_active_job_id', 'job_test')
monkeypatch.setattr(index_service, '_active_scope', 'all')
status = index_service.get_status()
assert (status.status, status.pending_jobs, status.running_jobs) == ('running', 1, 1)
monkeypatch.setattr(index_service, '_active_scope', 'note')
assert index_service.get_status().pending_jobs == 2
meta.pop('workspace_vectors_pending')
assert index_service.get_status().pending_jobs == 2
monkeypatch.setattr(index_service, '_active_job_id', None)
monkeypatch.setattr(index_service, '_last_error', 'failed')
status = index_service.get_status()
assert (status.status, status.pending_jobs, status.running_jobs) == ('failed', 2, 0)
meta.clear()
assert index_service.get_status().pending_jobs == 0
def test_search_activity_covers_completion_failure_and_cancellation():
async def scenario():
gate = asyncio.Event()
@activity.track_search
async def search(_self, request):
await gate.wait()
if request.query == 'fail':
raise ValueError('failure')
return 'ok'
tasks = [asyncio.create_task(search(None, SimpleNamespace(mode=mode, query=query)))
for mode, query in [('vector', 'ok'), ('hybrid', 'fail'), ('vector', 'cancel'), ('fts', 'ok')]]
await asyncio.sleep(0)
assert index_service.get_status().active_searches == 3
tasks[2].cancel()
with pytest.raises(asyncio.CancelledError):
await tasks[2]
gate.set()
results = await asyncio.gather(*tasks, return_exceptions=True)
assert results[0] == results[3] == 'ok'
status = index_service.get_status()
assert (status.active_searches, status.completed_searches, status.failed_searches, status.cancelled_searches) == (0, 1, 1, 1)
assert status.pending_jobs == 0
asyncio.run(scenario())
+36
View File
@@ -12,6 +12,42 @@ from app.local_models.runtime import Runtime
from app.providers.base import ProviderError
@pytest.mark.parametrize('threaded', [False, True])
def test_large_embedding_result_crosses_pipe_limit_without_truncation(monkeypatch, tmp_path, threaded):
import app.local_models.runtime as module
import app.local_models.process as process_module
from app.local_models.protocol import response_lines
vector = [0.012345678901234567] * 384
result = {'result': [vector] * 2111}
assert len(json.dumps(result).encode()) > 16 * 1024 * 1024
assert max(map(len, response_lines(result, 'embedding'))) < 16 * 1024 * 1024
worker = tmp_path / 'large_worker.py'
protocol_dir = Path(module.__file__).parent
worker.write_text(
'import sys,json\n'
f'sys.path.insert(0, {str(protocol_dir)!r})\n'
'from protocol import response_lines\n'
'request=json.load(sys.stdin)\n'
'vector=[0.012345678901234567]*384\n'
'for line in response_lines({"result":[vector]*len(request["payload"]["texts"])}, "embedding"):\n'
' sys.stdout.write(line)\n', encoding='utf-8')
monkeypatch.setattr(module, 'read_state', lambda key: {'status': 'installed'})
monkeypatch.setattr(module, 'interpreter', lambda *_: Path(sys.executable))
original_async = asyncio.create_subprocess_exec
original_threaded = process_module.ThreadedProcess
async def spawn(*args, **kwargs):
if threaded:
raise NotImplementedError
return await original_async(sys.executable, str(worker), **kwargs)
monkeypatch.setattr(module.asyncio, 'create_subprocess_exec', spawn)
monkeypatch.setattr(process_module, 'ThreadedProcess',
lambda args, **kwargs: original_threaded((sys.executable, str(worker)), **kwargs))
actual = asyncio.run(Runtime().infer('bekko', 'embedding', {'texts': ['test'] * 2111}))
assert actual == result['result']
def test_download_resumes_partial_and_checks_digest(monkeypatch):
payload = b'verified-model-weights'
entry = {'path':'model.safetensors','size':len(payload),'hash':hashlib.sha256(payload).hexdigest(),
+151
View File
@@ -0,0 +1,151 @@
import asyncio
import json
import logging
import threading
from pathlib import Path
from app.operation_logs import LogStore, ApplicationLogHandler, get_store, log_event, shutdown_logging
def test_logs_persist_filter_cursor_and_retention(tmp_path):
store = LogStore(tmp_path / 'logs.db', retain=3)
try:
for i in range(6):
store.emit('ERROR' if i % 2 else 'INFO', 'vectors', 'embedding.failed', {'run_id': f'run_{i}'})
store.queue.join()
first = store.query(limit=2)
assert len(first['items']) == 2 and first['next_cursor']
assert len(store.query(before=first['next_cursor'])['items']) == 1
assert len(store.query(level='ERROR')['items']) == 2
assert len(store.query(q='run_5')['items']) == 1
assert not store.query(source='tasks')['items']
finally:
store.close()
reopened = LogStore(tmp_path / 'logs.db', retain=3)
try:
assert len(reopened.query()['items']) == 3
finally:
reopened.close()
def test_logs_exclude_content_and_legacy_exception_messages():
try:
log_event('vectors', 'embedding.failed', level='ERROR',
error=ValueError('private note and secret'), model='embedding-v1',
prompt='private note', arguments={'api_key': 'secret'}, api_key='secret')
handler = ApplicationLogHandler()
record = logging.LogRecord('app.sample', logging.ERROR, __file__, 1,
'private note and secret %s', ('credentials',), None)
handler.emit(record)
handler.emit(record) # a logger propagated to another installed handler
store = get_store()
store.queue.join()
data = json.dumps(store.query())
assert len(store.query()['items']) == 2
assert 'private note' not in data and 'credentials' not in data and 'api_key' not in data
assert 'ValueError' in data and 'embedding-v1' in data
finally:
shutdown_logging()
def test_http_log_correlates_task_operations_without_body():
import httpx
from app.main import app
async def scenario():
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client:
response = await client.post('/api/tasks', json={'title': 'private title'})
assert response.status_code == 200
rid = response.headers['x-request-id']
store = get_store()
await asyncio.to_thread(store.queue.join)
result = (await client.get('/api/logs', params={'q': rid})).json()
assert len(result['items']) >= 2
assert 'private title' not in json.dumps(result)
assert any(item['event'] == 'task.created' for item in result['items'])
assert (await client.get('/api/logs', params={'limit': 201})).status_code == 422
try:
asyncio.run(scenario())
finally:
shutdown_logging()
def test_trace_writer_batches_off_loop_and_survives_cancel():
from app.agent.async_trace import AsyncTraceWriter
started, release = threading.Event(), threading.Event()
class Repository:
def write_batch(self, jobs):
assert threading.current_thread() is not threading.main_thread()
started.set()
assert release.wait(2)
self.jobs = jobs
async def scenario():
repo = Repository()
writer = AsyncTraceWriter(repo)
pending = asyncio.create_task(writer.submit('save', 'snapshot'))
while not started.is_set():
await asyncio.sleep(.001)
pending.cancel()
writer.worker.cancel() # simultaneous application shutdown
await asyncio.sleep(.005)
assert not pending.done()
release.set()
assert await asyncio.wait_for(pending, 2) is True
assert repo.jobs == [('save', ('snapshot',))]
assert writer.queue.empty()
asyncio.run(scenario())
def test_trace_write_failure_is_reported_and_next_submission_recovers():
from app.agent.async_trace import AsyncTraceWriter
class Repository:
fail = True
def write_batch(self, jobs):
if self.fail:
self.fail = False
raise OSError('disk unavailable')
async def scenario():
import pytest
writer = AsyncTraceWriter(Repository())
with pytest.raises(OSError):
await writer.submit('save', 'first')
assert not await writer.submit('save', 'second')
await writer.queue.join()
asyncio.run(scenario())
def test_log_write_failure_does_not_stall_queue(tmp_path, monkeypatch):
store = LogStore(tmp_path / 'failed.db')
try:
def broken():
raise OSError('disk unavailable')
monkeypatch.setattr(store, '_connect', broken)
store.emit('ERROR', 'vectors', 'embedding.failed', {'duration_ms': float('nan')})
store.queue.join()
assert store.failed == 1
finally:
store.close()
def test_cancelled_task_write_keeps_its_slot_until_commit():
from app.services.task_service import write_in_background
started, release = threading.Event(), threading.Event()
order = []
def first():
started.set()
assert release.wait(2)
order.append('first')
async def scenario():
import pytest
pending = asyncio.create_task(write_in_background(first))
while not started.is_set():
await asyncio.sleep(.001)
pending.cancel()
second = asyncio.create_task(write_in_background(lambda: order.append('second')))
await asyncio.sleep(.01)
assert order == []
release.set()
with pytest.raises(asyncio.CancelledError):
await pending
await second
assert order == ['first', 'second']
asyncio.run(scenario())
+324
View File
@@ -0,0 +1,324 @@
"""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
from app.plot.renderer import (
FunctionPlotStaticRenderer,
MermaidStaticRenderer,
StaticRenderRequest,
)
# --------------------------------------------------------------------------- #
# 表达式解析
# --------------------------------------------------------------------------- #
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.plot.renderer as renderer_mod
def boom(plot):
raise RuntimeError("boom")
monkeypatch.setattr(renderer_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)
# --------------------------------------------------------------------------- #
# StaticRenderer 内部契约(§10.4
# --------------------------------------------------------------------------- #
def test_function_plot_static_renderer_renders_svg() -> None:
renderer = FunctionPlotStaticRenderer()
result = renderer.render(StaticRenderRequest(kind="function_plot", source="y = x^2"))
assert "<svg" in result.content
assert "<polyline" in result.content
assert result.mime_type == "image/svg+xml"
assert result.width == 640
assert result.height == 480
def test_function_plot_static_renderer_parse_exposes_node_count() -> None:
renderer = FunctionPlotStaticRenderer()
parsed = renderer.parse(StaticRenderRequest(kind="function_plot", source="y = x + x"))
assert parsed.plot is not None
assert parsed.plot.node_count > 0
def test_function_plot_static_renderer_raises_on_no_plot() -> None:
renderer = FunctionPlotStaticRenderer()
request = StaticRenderRequest(kind="function_plot", source="y = os.system('x')")
with pytest.raises(ValueError):
renderer.render(request)
def test_mermaid_static_renderer_returns_placeholder() -> None:
renderer = MermaidStaticRenderer()
result = renderer.render(StaticRenderRequest(kind="mermaid", source="graph LR"))
assert result.content == ""
assert any("mermaid" in w for w in result.warnings)
+155
View File
@@ -66,6 +66,161 @@ async def seed():
return apple, banana
def test_native_spaces_isolate_dimensions_and_reuse_without_json_scan(runtime, monkeypatch):
from app.retrieval import space_index
async def scenario():
apple, banana = await seed()
ids = [b.block_id for note in (apple, banana) for b in note.blocks]
conn = connect()
try:
with transaction(conn):
routed_vectors.store_remote(conn, ids, routed_vectors.RemoteEmbeddings('space-a', 4, [[1., 0., 0., 0.]] * len(ids)))
assert conn.execute('SELECT COUNT(DISTINCT dimensions) FROM routed_block_vectors').fetchone()[0] == 2
finally:
conn.close()
# A new connection uses the persistent native index, without reading vector JSON.
def forbidden(*args, **kwargs):
raise AssertionError('query decoded stored JSON')
monkeypatch.setattr(space_index.json, 'loads', forbidden)
hits = await routed_vectors.search_remote('apple orchard', top_k=2, strict=True)
assert len(hits) == 2
assert hits[0].id == apple.blocks[0].block_id
asyncio.run(scenario())
def test_legacy_vectors_migrate_without_document_embedding(runtime):
from app.retrieval import space_index
async def scenario():
apple, banana = await seed()
conn = connect()
table = space_index.table_name('space-a', 3)
try:
with transaction(conn):
conn.execute(f'DROP TRIGGER {table}_delete')
conn.execute(f'DROP TRIGGER {table}_update')
conn.execute(f'DROP TABLE {table}')
conn.execute('ALTER TABLE routed_block_vectors RENAME TO saved_vectors')
conn.execute('CREATE TABLE routed_block_vectors(space_id TEXT,block_id TEXT REFERENCES blocks(block_id) ON DELETE CASCADE,dimensions INTEGER,vector TEXT,PRIMARY KEY(space_id,block_id))')
conn.execute('INSERT INTO routed_block_vectors SELECT * FROM saved_vectors')
conn.execute('DROP TABLE saved_vectors')
finally:
conn.close()
runtime.calls.clear()
hits = await routed_vectors.search_remote('apple orchard', top_k=2, strict=True)
assert len(hits) == 2
assert runtime.calls == [['apple orchard']]
asyncio.run(scenario())
@pytest.mark.parametrize('partitioned', [False, True])
def test_concurrent_first_search_serializes_migration_and_warm_search_is_read_only(runtime, monkeypatch, partitioned):
import threading
from app.retrieval import space_index
async def scenario():
await seed()
table = space_index.table_name('space-a', 3)
conn = connect()
try:
with transaction(conn):
conn.execute(f'DROP TRIGGER {table}_delete')
conn.execute(f'DROP TRIGGER {table}_update')
conn.execute(f'DROP TABLE {table}')
finally:
conn.close()
entered, release, second = threading.Event(), threading.Event(), threading.Event()
original_ensure, original_prepare = space_index.ensure, space_index.prepare
calls = []
def ensure(*args):
calls.append(1)
entered.set()
assert release.wait(5)
return original_ensure(*args)
def prepare(*args):
if entered.is_set():
second.set()
return original_prepare(*args)
monkeypatch.setattr(space_index, 'ensure', ensure)
monkeypatch.setattr(space_index, 'prepare', prepare)
batch = routed_vectors.RemoteEmbeddings('space-a', 3, [[1., 0., 0.]])
async def search():
if entered.is_set():
second.set()
await routed_vectors._prepare_indexes([batch])
if partitioned:
return await asyncio.to_thread(routed_vectors._search_partitions, {False: batch}, {False}, 2, True)
return await asyncio.to_thread(routed_vectors._search_space, batch, 2, True)
tasks = []
try:
tasks.append(asyncio.create_task(search()))
assert await asyncio.to_thread(entered.wait, 5)
tasks.append(asyncio.create_task(search()))
assert await asyncio.to_thread(second.wait, 5)
release.set()
first, other = await asyncio.gather(*tasks)
assert first == other and len(first) == 2
assert len(calls) == 1
# Prepared indexes are reusable even with SQLite query_only enforced.
original_connect = routed_vectors.connect
def read_only():
connection = original_connect()
connection.execute('PRAGMA query_only=ON')
return connection
monkeypatch.setattr(routed_vectors, 'connect', read_only)
assert await search() == first
finally:
release.set()
await asyncio.gather(*tasks, return_exceptions=True)
asyncio.run(scenario())
@pytest.mark.parametrize('cancel_search', [False, True])
def test_save_waits_for_migration_even_when_search_is_cancelled(runtime, monkeypatch, cancel_search):
import threading
from app.retrieval import space_index
async def scenario():
apple, _ = await seed()
table = space_index.table_name('space-a', 3)
conn = connect()
try:
with transaction(conn):
conn.execute(f'DROP TRIGGER {table}_delete')
conn.execute(f'DROP TRIGGER {table}_update')
conn.execute(f'DROP TABLE {table}')
finally:
conn.close()
entered, release = threading.Event(), threading.Event()
original = space_index.ensure
def slow(*args):
entered.set()
assert release.wait(5)
return original(*args)
monkeypatch.setattr(space_index, 'ensure', slow)
# Keep the subsequent vector job queued; test saving and its durable marker.
monkeypatch.setattr(index_service, 'schedule_workspace_rebuild', lambda: None)
query = asyncio.create_task(routed_vectors.search_remote('apple orchard', top_k=2, strict=True))
save = None
try:
assert await asyncio.to_thread(entered.wait, 5)
if cancel_search:
query.cancel()
save = asyncio.create_task(note_service.update_note(apple.note_id, markdown='Saved during migration', defer_vectors=True))
await asyncio.sleep(0.02)
assert not save.done()
release.set()
saved = await asyncio.wait_for(save, 5)
assert saved.markdown == 'Saved during migration'
assert (await note_service.get_note(apple.note_id)).markdown == saved.markdown
assert repository.get_index_meta()[f'note_vectors_pending:{apple.note_id}'] == '1'
# Query may observe the saved revision's pending index, but saving must succeed.
result = (await asyncio.gather(query, return_exceptions=True))[0]
if cancel_search:
assert isinstance(result, asyncio.CancelledError)
finally:
release.set()
await asyncio.gather(*([query, save] if save else [query]), return_exceptions=True)
asyncio.run(scenario())
@pytest.mark.parametrize("outcome", ["api", "api_failure", "missing_space"])
def test_benchmark_reports_actual_embedding_and_fallback(runtime, outcome):
from app.benchmarks import service
+31
View File
@@ -114,3 +114,34 @@ def test_workspace_openapi_paths_are_published() -> None:
"/api/workspace/folders/delete",
"/api/notes/{note_id}/rename",
} <= paths.keys()
def test_external_files_are_registered_and_removed_without_vector_wait(monkeypatch) -> None:
from app.services import index_service
scheduled = []
monkeypatch.setattr(index_service, 'schedule_workspace_rebuild', lambda: scheduled.append(True))
vault = get_settings().vault_path
vault.mkdir(parents=True, exist_ok=True)
external = vault / 'external.md'
external.write_text('# External\n', encoding='utf-8')
tree = asyncio.run(get_workspace_tree())
assert tree[0].note_id is not None
external.rename(vault / 'renamed.md')
tree = asyncio.run(get_workspace_tree())
assert [item.name for item in tree] == ['renamed.md']
(vault / 'renamed.md').unlink()
assert asyncio.run(get_workspace_tree()) == []
assert len(scheduled) == 3
def test_save_rejects_external_content_change() -> None:
import hashlib
from app.contracts import NoteUpdateRequest
from app.routes import update_note
original = '# Original\n'
note = asyncio.run(create_note(NoteCreateRequest(title='Conflict', markdown=original)))
disk = get_settings().vault_path / note.file_path
disk.write_text('# External\n', encoding='utf-8')
with pytest.raises(ApiError) as error:
asyncio.run(update_note(note.note_id, NoteUpdateRequest(markdown='# Editor\n', expected_content_hash=hashlib.sha256(original.encode()).hexdigest())))
assert error.value.code == 'NOTE_CONTENT_CONFLICT'
assert disk.read_text(encoding='utf-8') == '# External\n'
@@ -5,6 +5,33 @@ from app.config import get_settings
from app.services import index_service, workspace_service
def test_external_new_note_does_not_rebuild_existing_notes(monkeypatch):
from app.services import note_service
async def scenario():
await note_service.create_note(title='Existing', markdown='Keep existing vectors', folder=None, tags=[])
calls = []
original = index_service.prepare_note_index
async def record(parsed, **kwargs):
calls.append(parsed.file_path)
return await original(parsed, **kwargs)
async def forbidden(*args, **kwargs):
raise AssertionError('full rebuild should not run')
monkeypatch.setattr(index_service, 'prepare_note_index', record)
monkeypatch.setattr(index_service, 'rebuild', forbidden)
path = get_settings().vault_path / 'external.md'
path.write_text('# External\n\nNew content', encoding='utf-8')
try:
await workspace_service.refresh_workspace_tree()
await index_service._background_task
assert calls == ['external.md']
assert not index_service.get_status().vector_refresh_required
await workspace_service.open_workspace(None)
assert calls == ['external.md']
finally:
await index_service.shutdown()
asyncio.run(scenario())
def test_open_returns_before_vectors_and_deduplicates_background(monkeypatch):
async def scenario():
started, release = asyncio.Event(), asyncio.Event()
+405
View File
@@ -149,6 +149,153 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/b6/034f6802e9c3f6418966cfabb7db8c9252cc2429c5098f41cc43af804149/charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30", size = 363585, upload-time = "2026-08-15T08:16:46.646Z" },
{ url = "https://files.pythonhosted.org/packages/d5/fa/6a7e2a7c4b5451912b8c417732df79574354443592a88d616de03da66ae5/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488", size = 251189, upload-time = "2026-08-15T08:16:48.287Z" },
{ url = "https://files.pythonhosted.org/packages/a4/c8/ab42b07cfd82e919f427fcfaa7c41abae8242833ad1aad66d42bae40b669/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22", size = 239724, upload-time = "2026-08-15T08:16:49.67Z" },
{ url = "https://files.pythonhosted.org/packages/e7/80/b9348b5d3041209f98b4cdad7655766369233f1d533f4f4f7558e9717bec/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731", size = 280078, upload-time = "2026-08-15T08:16:51.228Z" },
{ url = "https://files.pythonhosted.org/packages/82/38/083a24028304bc85bb9e376fed801178423dcbb67495f73b6ea0624e1894/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c", size = 276650, upload-time = "2026-08-15T08:16:52.625Z" },
{ url = "https://files.pythonhosted.org/packages/0d/35/731ac04aa0a097fc1c97f0994c375bdb230c6c96619db794208fe664e9ce/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8", size = 262325, upload-time = "2026-08-15T08:16:54.085Z" },
{ url = "https://files.pythonhosted.org/packages/f5/28/c2028e7021fb89c6e56868ed0e387b8e9aa811abdd2ab3208d6578d2c930/charset_normalizer-3.5.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486", size = 261140, upload-time = "2026-08-15T08:16:55.604Z" },
{ url = "https://files.pythonhosted.org/packages/28/f0/0c0ceec6d98b7daa62e361e418135d59685811d79ba11529aad5cdf15e84/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f", size = 252791, upload-time = "2026-08-15T08:16:57.103Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3e/48f4cd187b1c33189d86039e9cbe4f92c05454175504b44ff81806d4d1bf/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c", size = 240730, upload-time = "2026-08-15T08:16:58.418Z" },
{ url = "https://files.pythonhosted.org/packages/42/85/f9e22af69af67c54cce42be9455d9c81294f918b4ccc454db01f66efcac2/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18", size = 280791, upload-time = "2026-08-15T08:16:59.918Z" },
{ url = "https://files.pythonhosted.org/packages/fd/4c/9044135f42127630b6fa742feb51256353f6ab87a78f2fdd1de3de955a7f/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5", size = 259598, upload-time = "2026-08-15T08:17:01.421Z" },
{ url = "https://files.pythonhosted.org/packages/ba/ed/1dd7cfebb4e75812934c49ca3b79757d11948053f7937ab7070c151f3c55/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b", size = 278217, upload-time = "2026-08-15T08:17:02.782Z" },
{ url = "https://files.pythonhosted.org/packages/bf/eb/239c84503cc9e3ba6eb34686a24bc66e84f3924efdd7e38e751a19f6bc10/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6", size = 263417, upload-time = "2026-08-15T08:17:04.216Z" },
{ url = "https://files.pythonhosted.org/packages/37/ab/4e4510e1e288478e2c8333131d1c1382382ba8cd2165053c79e39d1da961/charset_normalizer-3.5.1-cp311-cp311-win32.whl", hash = "sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b", size = 181774, upload-time = "2026-08-15T08:17:05.58Z" },
{ url = "https://files.pythonhosted.org/packages/e3/57/32f0ccea59e8612057c61d6fd22ef2cb63cca93c9fe594094919696ac170/charset_normalizer-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9", size = 206653, upload-time = "2026-08-15T08:17:07.075Z" },
{ url = "https://files.pythonhosted.org/packages/17/d4/b65c433fc521e58b5f54293982a5e51c05cb5f2dd3f1c7a6acb65b75324e/charset_normalizer-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10", size = 185630, upload-time = "2026-08-15T08:17:08.502Z" },
{ url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" },
{ url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" },
{ url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" },
{ url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" },
{ url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" },
{ url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" },
{ url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" },
{ url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" },
{ url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" },
{ url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" },
{ url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" },
{ url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" },
{ url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" },
{ url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" },
{ url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" },
{ url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" },
{ url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" },
{ url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" },
{ url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" },
{ url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" },
{ url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" },
{ url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" },
{ url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" },
{ url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" },
{ url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" },
{ url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" },
{ url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" },
{ url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" },
{ url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" },
{ url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" },
{ url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" },
{ url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" },
{ url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" },
{ url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" },
{ url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" },
{ url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" },
{ url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" },
{ url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" },
{ url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" },
{ url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" },
{ url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" },
{ url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" },
{ url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" },
{ url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" },
{ url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" },
{ url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" },
{ url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" },
{ url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" },
{ url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" },
{ url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" },
{ url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" },
{ url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" },
{ url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" },
{ url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" },
{ url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" },
{ url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" },
{ url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" },
{ url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" },
{ url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" },
{ url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" },
{ url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" },
{ url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" },
{ url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" },
{ url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" },
{ url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" },
{ url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" },
{ url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" },
{ url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" },
{ url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" },
{ url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" },
{ url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" },
{ url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" },
{ url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" },
{ url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" },
{ url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" },
{ url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" },
{ url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" },
{ url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" },
{ url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" },
{ url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" },
{ url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" },
{ url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" },
{ url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" },
{ url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" },
{ url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" },
{ url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" },
{ url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" },
{ url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" },
{ url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" },
{ url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" },
{ url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" },
{ url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" },
{ url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" },
{ url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" },
{ url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" },
{ url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" },
{ url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" },
{ url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" },
{ url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" },
{ url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" },
{ url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" },
{ url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" },
{ url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" },
{ url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" },
{ url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" },
{ url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" },
{ url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" },
{ url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" },
{ url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" },
{ url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" },
{ url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" },
{ url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" },
{ url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" },
{ url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" },
{ url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" },
{ url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" },
{ url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" },
{ url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" },
{ url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" },
{ url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" },
]
[[package]]
name = "click"
version = "8.5.0"
@@ -364,6 +511,147 @@ 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 = "lxml"
version = "6.1.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/23/ad/28ecd7cb894d172f3c9c80a075eeeb2017ac62e3632cee05a5f9493547eb/lxml-6.1.3.tar.gz", hash = "sha256:45222d94ddd511536f3b2f7d9deae3b2339b4ce0f075f1ca25703b07cad9dd21", size = 4211198, upload-time = "2026-09-02T14:48:02.287Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/96/f1/95133bde7af7afb1f5ba6090b674d826b7a518318bba54bbbb633b27865a/lxml-6.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c66f858b82497173f73366795fc6ee8171620e75a338506d6b2e7bc16f5fca11", size = 8563141, upload-time = "2026-09-02T14:46:42.334Z" },
{ url = "https://files.pythonhosted.org/packages/80/54/5a79ee2181ac773ee13e48205411845feec69e1c3d097e985c1343171712/lxml-6.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:032a0a97eed428bd143c75a11118238546424ceb2fa311cca5f073aa44658dc4", size = 4613690, upload-time = "2026-09-02T14:46:45.253Z" },
{ url = "https://files.pythonhosted.org/packages/ab/29/8c24672f56807f119312f073f24204368574bd16b384ede861b5104b3a2b/lxml-6.1.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4a579dfb9c835f8ab47f4b8ed33440cbc75b806b73297208e6ec2a33e903740b", size = 4935630, upload-time = "2026-09-02T14:46:48.071Z" },
{ url = "https://files.pythonhosted.org/packages/71/69/ce2436d854c848c19fc9287143991f3fc76b8b4e9a0dbba8452e51dff264/lxml-6.1.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:49fbc2682a9306135b7ec49e93f97f9c26689b9b7f96ed2742d8d6497e994d13", size = 5079033, upload-time = "2026-09-02T14:46:50.483Z" },
{ url = "https://files.pythonhosted.org/packages/91/ec/b66f66f6499ad800265d57540b51e6632e3232d3526f42f2f8fd4b14e0ea/lxml-6.1.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea2c01cdb16dc12156e455007c406dfaaece0c89aa4ba0e3b47586779f951d41", size = 5012298, upload-time = "2026-09-02T14:46:52.603Z" },
{ url = "https://files.pythonhosted.org/packages/94/2a/25d128872f4d51753542bfc3feb482c2ea7c8a2d6d81a0bc5c6a00779ed4/lxml-6.1.3-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:527195c188d7d0af748cd48d220ab8cdc5cb99be3d49ac4d9be7324d8abf9bc0", size = 5211431, upload-time = "2026-09-02T14:46:54.722Z" },
{ url = "https://files.pythonhosted.org/packages/75/b2/0a41bbef074a556110f84fafb6d8c2998293c7d3bfbe1ce74515bc65393b/lxml-6.1.3-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:20384c2bbcbf87180c8c61eb60869699c1ec0cd09b62cfd13804022d860b0867", size = 5343417, upload-time = "2026-09-02T14:46:57.46Z" },
{ url = "https://files.pythonhosted.org/packages/7b/cd/16116c3f91791aeeeab1cbe6e7eb6e646f127be7b0158b262eb526a21a0c/lxml-6.1.3-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:424aa5657141d306ba9ad1baab4b2c0a0719040075ee6c66aee9bb2dea2b5054", size = 4673219, upload-time = "2026-09-02T14:46:59.604Z" },
{ url = "https://files.pythonhosted.org/packages/dd/bb/4dff849f443ef70221676aec938bc41e8bae6430aa2ca13b041319e14b98/lxml-6.1.3-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4736e6c87e603146d8949d8501da621ad20c31015060d3fcf95ace2859f3e3e6", size = 5281246, upload-time = "2026-09-02T14:47:02.375Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ac/4aa7dd059420bfd35278c7fe819e9d319ee36a0453b7bbde1907a7832d91/lxml-6.1.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6374e9e382e5a98c9c5e66d41b357b470da1c54bce30f17f9dc4bcc58436cc1c", size = 5055451, upload-time = "2026-09-02T14:47:05.883Z" },
{ url = "https://files.pythonhosted.org/packages/de/44/20d90cf6f4234de9cd9eeb4f519419885fdb087fa80d073c7b57be342021/lxml-6.1.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:22eec57e26c418cde02c051ce9914a365e52a7f135a565c6f0480242aeebab48", size = 4722694, upload-time = "2026-09-02T14:47:08.461Z" },
{ url = "https://files.pythonhosted.org/packages/f0/0e/6bee12325e53dd6613fe1e107def07583b6182ade03e94bfef8976622e44/lxml-6.1.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8753b8d51dbc86fd335ee31fcf7f3658e9f5c016d4edfb23f76ad295f4b8c9d0", size = 5269179, upload-time = "2026-09-02T14:47:10.647Z" },
{ url = "https://files.pythonhosted.org/packages/e4/5d/54d269ce5cd0787c0424d9cef449ee794d4097725d13dd2acd6181c44e9c/lxml-6.1.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:207dfc3d47cf0e575e643bbc140dacc8863b39abaa1e5307cd64c7f2365b8a12", size = 5235559, upload-time = "2026-09-02T14:47:13.932Z" },
{ url = "https://files.pythonhosted.org/packages/e4/f7/5a3095f187f1bec293591616a1677781acc265c5b313c009f8a19c471a09/lxml-6.1.3-cp311-cp311-win32.whl", hash = "sha256:18293f8a8d8b6a8e71ef37706b659e3846a4261232158167b1ddf35f6994f633", size = 3600377, upload-time = "2026-09-02T14:47:15.957Z" },
{ url = "https://files.pythonhosted.org/packages/45/5a/15531a0d307c96282fe8b639b3d74e8bd783e4ab4cb2b0781146ac4161b8/lxml-6.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:7ae4949f212a53b007dbc355884fda122545c5764a54256c9217e419a62a6559", size = 4032700, upload-time = "2026-09-02T14:47:18.566Z" },
{ url = "https://files.pythonhosted.org/packages/12/f9/8de76314955545ceaaa7c0305017b8aaa217905dee59c62c0e2c1e44a68f/lxml-6.1.3-cp311-cp311-win_arm64.whl", hash = "sha256:2123e5aa075ac20d23c7af489255efd129cbfe190dbe88fd42598cc9df3199b6", size = 3674431, upload-time = "2026-09-02T14:47:22.186Z" },
{ url = "https://files.pythonhosted.org/packages/dd/1f/a180b57d9eeabaab77f9d5aa30356898ea749c4795596a8f66d1eb6bef2e/lxml-6.1.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c0710ac085a157b593c38fbcacd950f15c4afa8e2057527185875ab302752bc", size = 8602094, upload-time = "2026-09-02T14:47:26.054Z" },
{ url = "https://files.pythonhosted.org/packages/a8/25/070c92013a1c029a602b03560d68772313d918268667fa993da7961759c9/lxml-6.1.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:623c8799c17128753c65699f1c3aa32402657393a9ad6db09ed8b98ddf76611d", size = 4638308, upload-time = "2026-09-02T14:47:29.587Z" },
{ url = "https://files.pythonhosted.org/packages/1e/1c/722e88883173097a1a375153e3c2447eba3060d0231522cf6596e99f4195/lxml-6.1.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f683dc6300317700025e41d89a43e0276692ded16113a3c43eab704d605c58e5", size = 4939696, upload-time = "2026-09-02T14:47:32.997Z" },
{ url = "https://files.pythonhosted.org/packages/db/36/aa413bc214dc4f785ad2b2ddd8cc99aae7062d49ab155e91e6011af00daf/lxml-6.1.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:379f8a75cf6eb7eef0af074b55f49ab73b868388a98de14646abcdfa4564bb11", size = 5105247, upload-time = "2026-09-02T14:47:36.734Z" },
{ url = "https://files.pythonhosted.org/packages/a3/a0/a1f7f1313795bfec67b77f01ef3b1128d49f2d7f66a8413fa55d47f4e25f/lxml-6.1.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b37772102d44bb6628186accca3a121b1fa3a6b3d97518a8c29a5229ca4c0d0a", size = 5011915, upload-time = "2026-09-02T14:47:39.846Z" },
{ url = "https://files.pythonhosted.org/packages/b9/78/840e7e3f1d0cc7a5cfac5d8505b97e25b6427fd774ac4bae672aaebfb4b5/lxml-6.1.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddcf547bea2aee967d6a77779376a45e77e610e8465147a1f3d7e20d539d6e32", size = 5638175, upload-time = "2026-09-02T14:47:43.644Z" },
{ url = "https://files.pythonhosted.org/packages/0a/20/e022dbc6b4753a9bc9fc5fb28a27163430c1731b9913997f6544c1b2518c/lxml-6.1.3-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:909f4e927bb051f7740d6367285fc60cdcfdaf0258c2dba4ff5ba7eadadc250c", size = 5244675, upload-time = "2026-09-02T14:47:47.635Z" },
{ url = "https://files.pythonhosted.org/packages/99/83/82cde81d2b5eb38d1539fdfdf318abdd014a7e604f4df01c9cd3deb18f2a/lxml-6.1.3-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:a5c18810318303ce9afb3f95e2ddb54834f96fa699a8600433fd5a93dcf44c56", size = 5358205, upload-time = "2026-09-02T14:47:50.306Z" },
{ url = "https://files.pythonhosted.org/packages/d2/a1/f3b057371c8cb29f2a9c9c44ea320592446e40b74a4b0af68c3d8e65bc73/lxml-6.1.3-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:3e42265103fb385d8642a78672edf376c6f7e1d3598a7a4f9cb1278f2f6b5f6f", size = 4704495, upload-time = "2026-09-02T14:47:53.251Z" },
{ url = "https://files.pythonhosted.org/packages/1a/a4/230eb28be5d412152ffc3c679b51fe1aeede5a53f3a8eb6e9748f2f4754f/lxml-6.1.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:21402998e4b78e7cce237d2788841aaa21ac9a4d1574d04dc2d12ee41ae807b5", size = 5255117, upload-time = "2026-09-02T14:47:55.963Z" },
{ url = "https://files.pythonhosted.org/packages/a3/18/1969f56763af24ce42ea156007b0b2d73fddea552e283b2010416394f0f4/lxml-6.1.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:38fc4e4e4e084e0bd491949482527d406788045c546d4f8789e93fc527b91385", size = 5054424, upload-time = "2026-09-02T14:47:58.131Z" },
{ url = "https://files.pythonhosted.org/packages/f4/d4/2a90acc1f6fabaa3a8db9340437822bd8d041b205d626a4b3e8621aaa390/lxml-6.1.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5609efdb0d3c95499c00046bc53648b3482ec2175b5503d6e611b3f0555dc71d", size = 4785572, upload-time = "2026-09-02T14:48:01.029Z" },
{ url = "https://files.pythonhosted.org/packages/a5/1e/b90e845b1dcd0f2f3f26b98283d857f25909223aacd265eee032c34ab8b1/lxml-6.1.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:97ce49699d87ebf8aad631b55d65b33219a4f1bfefbbf5bff19dc9af160aeaf9", size = 5656516, upload-time = "2026-09-02T14:48:03.419Z" },
{ url = "https://files.pythonhosted.org/packages/eb/ab/0a1b802c57f3fba5c4efd77d5c6b78adaa8f7b681f0c90456b140fe8bf6c/lxml-6.1.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:48542c9acba9ff9450bd18d871d2c2c8787fdb283572b623d206f1b927cd7d9e", size = 5245982, upload-time = "2026-09-02T14:48:06.109Z" },
{ url = "https://files.pythonhosted.org/packages/da/ee/2c016fbceb3778137459292538d9dfa7e3ad9070fe409c15254ddd90d2cc/lxml-6.1.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c55e71a9b1db1f107efb60da49c093689b74c5c31a708e5379e2fd9439d4fbb5", size = 5267340, upload-time = "2026-09-02T14:48:08.374Z" },
{ url = "https://files.pythonhosted.org/packages/9c/b1/736d18fd6f0835761923b7bac1f0c27d60c1200384e9093f05d8c5100525/lxml-6.1.3-cp312-cp312-win32.whl", hash = "sha256:b3ff39654f0ce6ebd4db154211136dbe7e8157bcc3bed2344c87f32c7c6ecb6c", size = 3602606, upload-time = "2026-09-02T14:48:10.384Z" },
{ url = "https://files.pythonhosted.org/packages/3a/5b/6ed903e4e6278a020c8a6f0dbbe78030d041840a6b4a64ea441a1e414077/lxml-6.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:3e9a00d1c2c30936f7add097c41afc5da6556c580909104aafd382cac92a855c", size = 4005999, upload-time = "2026-09-02T14:48:12.51Z" },
{ url = "https://files.pythonhosted.org/packages/e4/1b/7bcebb7b6332cb3ae85e9c13b139adb6f23f75c71d84041c56a5005d9a29/lxml-6.1.3-cp312-cp312-win_arm64.whl", hash = "sha256:1aeca87830c4fe649dcf93fe2b059525b71c72587f21be4ae4af7103082a79fa", size = 3666631, upload-time = "2026-09-02T14:48:14.567Z" },
{ url = "https://files.pythonhosted.org/packages/52/05/3ef45db776baea068044c799bbba68f3ca00a440c0e930a17c572f3d9639/lxml-6.1.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3a48093cdb058a93af842ede9703520e810b05dcd0fc6d7190a06376c3bfb6bd", size = 8590357, upload-time = "2026-09-02T14:48:17.413Z" },
{ url = "https://files.pythonhosted.org/packages/8c/a5/eee2fc77eee5ea68e4a4334b1def1781a3beaeefd3d98e81b4a38dc447b7/lxml-6.1.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:887c021d9a977cff89cb273047c1352997b772a8908a25c21836861f69b92be1", size = 4632616, upload-time = "2026-09-02T14:48:20.745Z" },
{ url = "https://files.pythonhosted.org/packages/35/42/df27b56848acd29d8a720acc28977911aab36f2a09df4208d5502e887415/lxml-6.1.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:611a51e61c92f62345a50b0035df6fc0d678f9299f33728826d831598862f59d", size = 4936186, upload-time = "2026-09-02T14:48:22.94Z" },
{ url = "https://files.pythonhosted.org/packages/ab/8d/8a7b91df0b54d09d25f5f44885d6b3e0a6d6643a8c070191580318d20c42/lxml-6.1.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b477912f42c5c33405a10c759d22f80cf5af043ae02d95b9d8e5e5bc555739ed", size = 5093324, upload-time = "2026-09-02T14:48:25.132Z" },
{ url = "https://files.pythonhosted.org/packages/c6/7e/8f340ddcd43790332fb0de8a26628d571a492da3300cd191821698407c96/lxml-6.1.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cffe18571ccc51d742cd08cbb3f8b756de9311d18c7ea98f5d92f37b8fb60c2", size = 4998850, upload-time = "2026-09-02T14:48:27.394Z" },
{ url = "https://files.pythonhosted.org/packages/c5/c1/9c5bb572f1f09ec9e4322bd4a4e9f4ad48347fc56ef94cf4df58a5279dc8/lxml-6.1.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75cc6569e86be5785b6188ef1642670c6adbc984e81ec35e224842ecd9eefcc8", size = 5626813, upload-time = "2026-09-02T14:48:29.61Z" },
{ url = "https://files.pythonhosted.org/packages/ac/7d/8bf1fd8bae8247743968bb76d027a1ac5bd2c4b44495fba6a71b30d10706/lxml-6.1.3-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d85dfab42dd672f87a7f76e9de7172962aee69fa12044f0d6e1a23cbd53fb80e", size = 5232385, upload-time = "2026-09-02T14:48:31.969Z" },
{ url = "https://files.pythonhosted.org/packages/7b/2e/6cef69ed81cb7df0d03b0dd09d08e6e2cf5061a743ff6f42f0b741548e9b/lxml-6.1.3-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:42632b4024ab24a6b488f559ac851312509888b6b80ae2aa11cf29a646a0d245", size = 5347088, upload-time = "2026-09-02T14:48:34.13Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e1/8e5fd8ddc8c7d685badb0f2db149e3c9da84eefc2827c01c658df2c4e3cb/lxml-6.1.3-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:febd35ef45f603c2d74b74655efdbf45e14f55fc0aef4ac82b663ca829b283e0", size = 4707227, upload-time = "2026-09-02T14:48:36.62Z" },
{ url = "https://files.pythonhosted.org/packages/7a/7e/00041382a11be40a88bf405ebff11c8efabd3de79f2691e1638b1c47a8a0/lxml-6.1.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a43b3bdf11e477dc7770609d3477316f974354dfc8425d596f64f471cc8daf6e", size = 5240208, upload-time = "2026-09-02T14:48:38.893Z" },
{ url = "https://files.pythonhosted.org/packages/fd/fe/316538b5cff0936fa63d45d421c655730fcbb5a28dcac728c175083002bc/lxml-6.1.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d582042c69857c364e8153de6e18e0da9b7b515a6a8113caf69a6ec8e0520f2", size = 5050271, upload-time = "2026-09-02T14:48:41.213Z" },
{ url = "https://files.pythonhosted.org/packages/c9/91/455bcccb3ac725373007344d351151810cd19762d1673b64b811f4359a42/lxml-6.1.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8e49a646acfab83c68974f4aa1d0a2acca9e88d7d627ae0fc13201b14b76d310", size = 4780433, upload-time = "2026-09-02T14:48:43.779Z" },
{ url = "https://files.pythonhosted.org/packages/cb/f6/580440e2f52cf00bba5c5e1080bfa88cdfcde73be71a11d95170ddbb663f/lxml-6.1.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0dee106e9aa97fb00541b1ed7827070564d0549c3d3fba8920e6b20fd980f748", size = 5645928, upload-time = "2026-09-02T14:48:46.187Z" },
{ url = "https://files.pythonhosted.org/packages/f6/dc/d123c1f244306543d545f62443f794959e4f1ea709fe100f8740d514e74a/lxml-6.1.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:dd5e90f34cffcfed97f36cf066325773d2b6021c60c29942e53a18b028501b1d", size = 5231184, upload-time = "2026-09-02T14:48:48.691Z" },
{ url = "https://files.pythonhosted.org/packages/c3/3c/fe55b2bd5c6113c906511cd88f6a470195c5fbff1124f19970ab706c3477/lxml-6.1.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d9b3e7d71bf6acff341233417abbdface29c647e3113892d9aaedc02eb4aa2bc", size = 5255814, upload-time = "2026-09-02T14:48:50.948Z" },
{ url = "https://files.pythonhosted.org/packages/e7/a7/485df55acf55dc35e4ca89d2f48f03889e5a3241826b18b85102b32ce9d8/lxml-6.1.3-cp313-cp313-win32.whl", hash = "sha256:160fcf381f76c3aeac28a756bec44f48942a8f7245a87aa28e3a523b4d90cd87", size = 3602214, upload-time = "2026-09-02T14:48:53.236Z" },
{ url = "https://files.pythonhosted.org/packages/c0/28/e46a7702bd95e9043291f7c3539b6184cba66f96cea9936f20939b284eeb/lxml-6.1.3-cp313-cp313-win_amd64.whl", hash = "sha256:e477aca0bc0d19f3b4ae9e4f2a1cfd687c31bf772d78734910658186b40b2477", size = 4004091, upload-time = "2026-09-02T14:48:55.699Z" },
{ url = "https://files.pythonhosted.org/packages/8a/1d/154c78e20479a43916e63f19cb720d83f44f024b03228be44c92d9a97b24/lxml-6.1.3-cp313-cp313-win_arm64.whl", hash = "sha256:b1cc980905221a5d8b3c476330730b3adb40ff80add71ffbdb6215ba055656f1", size = 3665468, upload-time = "2026-09-02T14:48:57.703Z" },
{ url = "https://files.pythonhosted.org/packages/0c/15/fc75a70b0af6021d0ea16811f1fc71cc42cd06ce90fe10f007a69b2eed84/lxml-6.1.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2bec13085dc8ef48a3fe62f7dfcacfeda2c785cdf19cc8eeda2bb9ed081da165", size = 8609725, upload-time = "2026-09-02T14:49:00.156Z" },
{ url = "https://files.pythonhosted.org/packages/84/ef/398fcf9018f881ec9aeaafae1ddd6586dfb13314a35d35e899de373dcae0/lxml-6.1.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4f4db7c7e954d289d71878938348b3d91b904a3e8210a11939359fb758a58e7d", size = 4639629, upload-time = "2026-09-02T14:49:02.81Z" },
{ url = "https://files.pythonhosted.org/packages/a7/2d/49b6a6ad7ce8f64b07b9fe852ff0c6d3fcbb26db61bee4f63d4120180a1c/lxml-6.1.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2cae5d5c90a62d9139c512a0cb1aad1d182b022b5740daea2617eb5bf7fc658e", size = 4965074, upload-time = "2026-09-02T14:49:05.133Z" },
{ url = "https://files.pythonhosted.org/packages/66/bc/6230cf80e4331c33383b0b6b73dc31a393dd76edd4cb73d761de5123034d/lxml-6.1.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c6c0c13128a32eb04a51357e56a094e13aa8e6d3d1884de2e9ae923f6915e1a8", size = 5099355, upload-time = "2026-09-02T14:49:07.343Z" },
{ url = "https://files.pythonhosted.org/packages/ac/cf/d1143d9b7717e07a82f158a1fc9ce6e581fdad1226734950af869e3ffde4/lxml-6.1.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2221e88679d1351e9a40aaee54bc65679b9795bbd0160bc3d5e36b163344eb75", size = 5036795, upload-time = "2026-09-02T14:49:09.65Z" },
{ url = "https://files.pythonhosted.org/packages/31/6f/194bb00ffb89712c30f5a7e1b8e685590e140fad6c8261fec172c09a3dc0/lxml-6.1.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cfb398886a7eb4c719161c3efcff2a1248febc53a4d8e5072d2d8a87fed84ac9", size = 5658740, upload-time = "2026-09-02T14:49:11.9Z" },
{ url = "https://files.pythonhosted.org/packages/e9/44/27e3cee3dcdb3b7bc09727b642bdbfcd098490ea77df04611db9060d7722/lxml-6.1.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7eb78ba28b187e1e9203a55c60fcf70df2d22cb205fe6d51b9383d6097419f0", size = 5245991, upload-time = "2026-09-02T14:49:14.154Z" },
{ url = "https://files.pythonhosted.org/packages/ca/e9/8312560579fc980bbd2233a8a673cc46f7d613d3633f2bf08a21e8f4ad13/lxml-6.1.3-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:ea6b1e9105b4b24a34c722432d9fb578f9ed83af21fa1abda639011e0f22bbb6", size = 5354136, upload-time = "2026-09-02T14:49:16.459Z" },
{ url = "https://files.pythonhosted.org/packages/74/d8/eda60f4f73a9c780b5d6e1175484f66e6c81a2c93346e2906a1fec9c7a02/lxml-6.1.3-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:e8b17e23df3e827a69d25af70990ca2420e92668aaffaeeb3cd2351d7916a023", size = 4704379, upload-time = "2026-09-02T14:49:19.032Z" },
{ url = "https://files.pythonhosted.org/packages/ba/c8/c9cc60057be78ac34bd2b842e45e6e88edbfe5e532e82c3b82381b7aab49/lxml-6.1.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b7c37339d7e75cab9a123a04248e243cefefb302ad6db566ea0c77cbcde421e", size = 5258676, upload-time = "2026-09-02T14:49:21.306Z" },
{ url = "https://files.pythonhosted.org/packages/41/7b/66894008fee8d1785b8db129747ae963fd427b68f456918df7f2f24a8b98/lxml-6.1.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:83e3a51e7933db700a0da0db31849db3a24022d9970da9bb73001e1d0326fd92", size = 5090069, upload-time = "2026-09-02T14:49:23.562Z" },
{ url = "https://files.pythonhosted.org/packages/8b/31/c1b60404859f4c3cd1f41f29c65a24e25cea78fde822d9574a21f66810be/lxml-6.1.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9bde9ae026a55b9a192078dfa6e27dd0ca4a050171ab6272e92f97b757dfdf48", size = 4741958, upload-time = "2026-09-02T14:49:26.037Z" },
{ url = "https://files.pythonhosted.org/packages/23/b8/6285f0cf546f14da2554cabdeaf7c2c2ff3190c74807f0de2e8810a786f9/lxml-6.1.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1a635e837b50a1819bebfedaac5916498ea024120969da8790500148fb0a894d", size = 5683245, upload-time = "2026-09-02T14:49:28.438Z" },
{ url = "https://files.pythonhosted.org/packages/d3/f6/2168cab44336dcb15fed0f0b78577225b83297cdf0dee349c95420c3dcb0/lxml-6.1.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d0c5c362bc94f1929dc7e96e715bbe7bd17037f802e6d8f0d1545df9133c0559", size = 5246087, upload-time = "2026-09-02T14:49:30.955Z" },
{ url = "https://files.pythonhosted.org/packages/f5/89/32f5de69a0a31f30e6164981851f87b37ecb2c4ee838e504b88d49d4818e/lxml-6.1.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c59e4265608da6a041f54646ecc0c9ecdbb19aaf14c4c684bb6c2114998cc415", size = 5269352, upload-time = "2026-09-02T14:49:33.502Z" },
{ url = "https://files.pythonhosted.org/packages/a2/a1/741d952ed3a7ef7a50055c6415aec3f067015e97f72f4389ce77b09657ba/lxml-6.1.3-cp314-cp314-win32.whl", hash = "sha256:2e62c569ec7531b679b184cbfe335c501c1d13c4b363560013019962eb630e6d", size = 3662783, upload-time = "2026-09-02T14:50:23.751Z" },
{ url = "https://files.pythonhosted.org/packages/0f/bc/5811cc73cac05e324e05ba9b0924e1a163a317a167ede8a9c748b11db30a/lxml-6.1.3-cp314-cp314-win_amd64.whl", hash = "sha256:66299564c046bc7e0cc5de5106601eae907e9fa5904cd68a323380a8502f7861", size = 4073951, upload-time = "2026-09-02T14:50:26.348Z" },
{ url = "https://files.pythonhosted.org/packages/92/18/3768c8b01ac3a9bed1914715e6011711b00e2a11628ffa6f7fa37f8e0269/lxml-6.1.3-cp314-cp314-win_arm64.whl", hash = "sha256:ebd054ad1737a68fb7c5c073d405cef2b88bb824e294de3b4a4e995b47f0e376", size = 3749279, upload-time = "2026-09-02T14:50:28.749Z" },
{ url = "https://files.pythonhosted.org/packages/72/38/84684784738d9451db2b330de2483f496690c3a5c642071df24135739b37/lxml-6.1.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5a143e6207579de8baeded4eaac9134413200359f1969d636f0bfb98ee8c3c8f", size = 8860296, upload-time = "2026-09-02T14:49:36.346Z" },
{ url = "https://files.pythonhosted.org/packages/24/b7/fc4c50bb1b38e864010ea396046cabe85129bf9e65b11edcfbc37d356241/lxml-6.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a1cec0f99b9b914d39176347a93b7610dc09324491aee1cbc57cd291a41a1d55", size = 4755190, upload-time = "2026-09-02T14:49:39.872Z" },
{ url = "https://files.pythonhosted.org/packages/94/e2/ee9aa6ed2b666b2db1f6f7fd48964ff9da39ebe827ef5eac0ab881f639d9/lxml-6.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f6b9d2aad499c769ee8287609ab0e6de99d8bcea99c6e6c2e64945259fd52fb2", size = 4979517, upload-time = "2026-09-02T14:49:42.153Z" },
{ url = "https://files.pythonhosted.org/packages/29/e3/e7763d1661b283ddd4fa36f91b9a497db6b8d2aff55028b16c7f642e0755/lxml-6.1.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a23fefdb345b2d4d0ff2860571b5ff9a89a28b6a120f720e8fb0324d346626", size = 5115270, upload-time = "2026-09-02T14:49:44.493Z" },
{ url = "https://files.pythonhosted.org/packages/2d/cd/22205d5b4d177e3f4156f780412426ee7c7f8107809f119f0dcc40fa51e3/lxml-6.1.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:545ccc14fb05485f48b4439ec35beb16d5b5280eb6c81c658bd4707a2a119414", size = 5032449, upload-time = "2026-09-02T14:49:46.841Z" },
{ url = "https://files.pythonhosted.org/packages/da/43/06a4626c3bb79ef8c501b674afab8100d64e798665bb2a97d1c960636a49/lxml-6.1.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:93476b6514b373fc6ca67d26c442784f7807c86f00635bfe79f935c3eab2af17", size = 5603325, upload-time = "2026-09-02T14:49:49.664Z" },
{ url = "https://files.pythonhosted.org/packages/d0/9c/733682a0c2de9f5779ba207bbb3f3f6be8c6bda863fc01739b186b38783a/lxml-6.1.3-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8db38ff3fb7aee7d6a82ae4da2eef1178656fe1216841fbd24870062a9d60473", size = 5229023, upload-time = "2026-09-02T14:49:52.447Z" },
{ url = "https://files.pythonhosted.org/packages/c6/8a/e69cdaca3fd33a647942925664f01b20908d41a6968c182305be9c38fb11/lxml-6.1.3-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:25f4118c438f96bb466e83108506d03d5c31b1bd2387e83e5b070bda6ded9c37", size = 5317811, upload-time = "2026-09-02T14:49:55.25Z" },
{ url = "https://files.pythonhosted.org/packages/2e/b2/0c397588174403c2ab68fc464abf97e03e7324f9c6cb6a99023104707195/lxml-6.1.3-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:1beb0f9909b26cee938df9ba56b15252a84429b1fc30ce6fca161390b9789a70", size = 4646516, upload-time = "2026-09-02T14:49:57.761Z" },
{ url = "https://files.pythonhosted.org/packages/56/7e/cfea25afafbe49db8b225764f7f74bb37c2a7f5e717d917d3d4a5e098ed4/lxml-6.1.3-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3a27ac6c780c8b8a1cd231b58407634cafc1c4cc28cd6c7141362df0f36351e7", size = 5240626, upload-time = "2026-09-02T14:50:00.279Z" },
{ url = "https://files.pythonhosted.org/packages/a1/75/7a587771bb52ebb0e2c57b6dbe9fd96a70fbb54d72ddd97d54c5f8ec18d5/lxml-6.1.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a1932d7ce78a561367512c594fe66eac2b2ec9b9264cfd9b5f950622f4a116e2", size = 5086619, upload-time = "2026-09-02T14:50:03.245Z" },
{ url = "https://files.pythonhosted.org/packages/1e/01/94c0ebe6d831861542d251e038052e52bf6d33f1d18f1cfffdc82851065a/lxml-6.1.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:7d0f5976aa2701996f759b30172925829867547bb073af0ae67d1307a0f0262c", size = 4758828, upload-time = "2026-09-02T14:50:05.873Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f1/938d67bd0e5b1fdfa52be28aefdffbad57e1f6b8e921c2aab88542c75f40/lxml-6.1.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e7ce578aa8a80910a72a8ca0bbea3baae10100827249001999726a788456d8", size = 5627083, upload-time = "2026-09-02T14:50:08.555Z" },
{ url = "https://files.pythonhosted.org/packages/d8/65/4e51522f6c214650db0abb7b16ccd11b1238b8a05a8d59aa4ebed59c9f67/lxml-6.1.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d97c5227621af74b111882a290b10f371780a38eef9d9e730408fba2259b52fb", size = 5235170, upload-time = "2026-09-02T14:50:11.255Z" },
{ url = "https://files.pythonhosted.org/packages/92/c2/e73d19365665f6b16ef84df21199befc3b06e4c539046ad2d9595f6fb9ea/lxml-6.1.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:da707f14ea3c35ee463d50acd596d6488e4b2b4ae7cf77a5bf93f55c023d63e8", size = 5252273, upload-time = "2026-09-02T14:50:13.782Z" },
{ url = "https://files.pythonhosted.org/packages/48/a9/7f386c84c9fe2854e1ca6e231c285e1c8f392971ac353c6865e6ec49faff/lxml-6.1.3-cp314-cp314t-win32.whl", hash = "sha256:9efe56a68179f3adc4de41861c9358931db03837c48dd5e1c78077b84dd07f3a", size = 3902712, upload-time = "2026-09-02T14:50:16.171Z" },
{ url = "https://files.pythonhosted.org/packages/82/a6/8a3eb793f7900ef01c7f99e6f5fcbcfbdff35251cfaef66b32a4c16352d6/lxml-6.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c9389b3784b56c58d933b5e0aecdf28f901b073ff385358d8a7d40907f6e14b2", size = 4400979, upload-time = "2026-09-02T14:50:18.621Z" },
{ url = "https://files.pythonhosted.org/packages/cc/c4/3807bea283b4fe9e9d9f5dde46a73df91178472b335d2778e10b2a37aa22/lxml-6.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32a409be3190b088f960ac92bfedfbef2f86c49ff940765e1548177592d20026", size = 3823401, upload-time = "2026-09-02T14:50:21.119Z" },
{ url = "https://files.pythonhosted.org/packages/e1/8e/4614fcd65496054cfb7172662f3576a59200278739506433b8c241ea422a/lxml-6.1.3-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:6ea2f13dce778ca072ccee598bca46a092ce192e8fd907b6c1f0e52c800529a0", size = 8609378, upload-time = "2026-09-02T14:50:31.772Z" },
{ url = "https://files.pythonhosted.org/packages/f2/51/2cdce3c65fa99a6195dd8fbd512d33407c1000ad99f63e0a285b63d7a8eb/lxml-6.1.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:c581b1d68b3845fb86c6b2983e755b29bf001461c59fa411d2c26a911b6559a9", size = 4640022, upload-time = "2026-09-02T14:50:34.41Z" },
{ url = "https://files.pythonhosted.org/packages/52/09/0b30084e9eb1c546a4be3d9c56df70058d116b1a320400a59b0f7da87bf0/lxml-6.1.3-cp315-cp315-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e01125896585139453cab8cb235893644d8815d7509520da95ae3ee8d1c1f79", size = 5037928, upload-time = "2026-09-02T14:50:37.007Z" },
{ url = "https://files.pythonhosted.org/packages/b8/0e/5c37275a3e361f6138dc06db748ea565c1fe8a5f4ee5e2ddd80047c81a89/lxml-6.1.3-cp315-cp315-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:290f66b97ede0e552e1cb44a0fd8a74f9753ee635b50830a0b122fb72788d015", size = 5661932, upload-time = "2026-09-02T14:50:39.777Z" },
{ url = "https://files.pythonhosted.org/packages/70/c5/b71ffb289b15e2642e2a3cf6d468c44da39ea119061a99e5b05e3d10f217/lxml-6.1.3-cp315-cp315-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73fc05988ed20809450474ba760a87c8ad4e455fc09783c02195e56ec634b41a", size = 5249209, upload-time = "2026-09-02T14:50:42.141Z" },
{ url = "https://files.pythonhosted.org/packages/81/ea/9910da149a23932f9301652e57661cd9e42b0df18f12be21159b7255f92b/lxml-6.1.3-cp315-cp315-manylinux_2_31_armv7l.whl", hash = "sha256:dc3a44689eea43eab836e5c98a8ab015dc2419987d1ea6eafc7c590cdff86bed", size = 4704543, upload-time = "2026-09-02T14:50:44.634Z" },
{ url = "https://files.pythonhosted.org/packages/76/07/9290329cd188c62e22021f79df04ee0cc33d9a93b0d38bd65ccd452ad9d0/lxml-6.1.3-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:209c3ccbfe35a04ac6d24f0611f9d1cbf8025d49991b14acd935236234d6c156", size = 5261298, upload-time = "2026-09-02T14:50:47.301Z" },
{ url = "https://files.pythonhosted.org/packages/c9/0c/aba78bd3401cd99b73a0aed8e2b9b43e14be94fab3603d4bbc8a62365f2a/lxml-6.1.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2f5b2a2b9811b853b39bfa41367c6d78747b8e3e80e07fc5a24aae295c1a4d7d", size = 5090453, upload-time = "2026-09-02T14:50:49.952Z" },
{ url = "https://files.pythonhosted.org/packages/8d/dc/fa4426c3355aa0216cbeb3911495b5f65a26e0df85859a89928fe28f0396/lxml-6.1.3-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:6a406d0b3cb207b0fa460ed4dc93e866f44f105da0169361cb18ff998a44c7f0", size = 4744709, upload-time = "2026-09-02T14:50:52.394Z" },
{ url = "https://files.pythonhosted.org/packages/be/2b/224fe7918658ab7c532ac2412f3c1eb28f71e6364fb07566262d0cc6a7b6/lxml-6.1.3-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:53258656846f5c48996b882fb4b135885e088a3ad3d96b4bc0530f95124d1f69", size = 5685802, upload-time = "2026-09-02T14:50:55.043Z" },
{ url = "https://files.pythonhosted.org/packages/21/44/7d480819b9adcae5f84dd8ac529132c6b7a578544398225cd20321adcd91/lxml-6.1.3-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:aa633613ff907ea91b9b0489a1f0da1b8725d8c6ccec6b77e8a1c9c235044bb0", size = 5249019, upload-time = "2026-09-02T14:50:57.985Z" },
{ url = "https://files.pythonhosted.org/packages/72/83/385a267ea1b6b283f2249dd827ef360a295e9db14e13ef4665a120c60d64/lxml-6.1.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:90f709b9accab6b2e4d14f5c8718203877a0486bcb3afd74d8b539ecd1e961d4", size = 5271886, upload-time = "2026-09-02T14:51:01.667Z" },
{ url = "https://files.pythonhosted.org/packages/d8/0d/f967b0eb172ae876855a402d6d9b11fa86e3e0c89ca9bbfeadf7ffbfa719/lxml-6.1.3-cp315-cp315-win32.whl", hash = "sha256:b4fc6b03b9d9d90557274f571ab30e7fbbfc527955536935d96f98b6817a86e4", size = 3662894, upload-time = "2026-09-02T14:51:45.173Z" },
{ url = "https://files.pythonhosted.org/packages/f4/48/d8a8c4160a29e663109ad520bac2deb37fcd014756d024561e8bc3e611ec/lxml-6.1.3-cp315-cp315-win_amd64.whl", hash = "sha256:33cadd956b667997e4de1635fce9541f2e8ede2038fcde8cf55aa14d571d1bad", size = 4074626, upload-time = "2026-09-02T14:51:47.77Z" },
{ url = "https://files.pythonhosted.org/packages/25/20/3e1395d34d19f9254625d0b567b81cf70d37d3417be074f4d63b94a2be3c/lxml-6.1.3-cp315-cp315-win_arm64.whl", hash = "sha256:8a330c0ee5fa318c7b5cbbaad882baeca3f570357e7eb25ab34bf31008150758", size = 3749495, upload-time = "2026-09-02T14:51:50.663Z" },
{ url = "https://files.pythonhosted.org/packages/8f/c6/7465ffd9c43883526a382df6fa4846c9d8d419214f7effbf65270e795471/lxml-6.1.3-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:0bf5a3e397df2ec4258eb5eea4c1ac6cf013ca1abd04a176903bff20a70021fe", size = 8857677, upload-time = "2026-09-02T14:51:05.109Z" },
{ url = "https://files.pythonhosted.org/packages/ed/eb/1f3a917e299df43c8162c3e6f64fc2cea3bcf277910f35bff5b8e5d39901/lxml-6.1.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:13d22c0d57355366b393936acf6b98a5e0edeadddd3fccbc6a846c50a76b8741", size = 4754522, upload-time = "2026-09-02T14:51:08.137Z" },
{ url = "https://files.pythonhosted.org/packages/d7/f9/f81b4bdb6efb7a596be29603d8758154d00a5f545db9f3cef9d9041c8f64/lxml-6.1.3-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad7617727a96d189bd6f979d0fadf765198c7934e85f4edaba9bf3ad919a300", size = 5033744, upload-time = "2026-09-02T14:51:10.633Z" },
{ url = "https://files.pythonhosted.org/packages/c8/0f/26d9bfaacb319c86e0eca8a1a0bf1130d36a7afbd318883e23caea63763d/lxml-6.1.3-cp315-cp315t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cae82b5ca24b0c2beedb269f6e2a96f466acd926879ab00ae19f1a65cbf9ffb0", size = 5615269, upload-time = "2026-09-02T14:51:13.357Z" },
{ url = "https://files.pythonhosted.org/packages/5d/90/73675f3f4141350ed65d6fec533b107d4e802c5caa340cf111771edd86e0/lxml-6.1.3-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:69cafd61aea04ebb3502c93c2aaa568b12931ca0802231e0b5de76bf8b6e74bd", size = 5236280, upload-time = "2026-09-02T14:51:16.051Z" },
{ url = "https://files.pythonhosted.org/packages/fd/be/ed260767e7977de463a0f91f3f4fffcab85c0a2a024a21ffe1fa442c2c79/lxml-6.1.3-cp315-cp315t-manylinux_2_31_armv7l.whl", hash = "sha256:dc205732d593118cf701d986f40e9de7801bb2e371cb189ddbda9b7348f4d97e", size = 4650718, upload-time = "2026-09-02T14:51:19.102Z" },
{ url = "https://files.pythonhosted.org/packages/d0/fd/e9839d03b1e767f2725cf7d7d81b80d5f3f9fdc10ad8827e2479311b046e/lxml-6.1.3-cp315-cp315t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88e719b9437f148f7e1465df845c758dd1598618cbea3a2fd1e61a715542f2b2", size = 5243376, upload-time = "2026-09-02T14:51:21.606Z" },
{ url = "https://files.pythonhosted.org/packages/34/a5/4606e347e2788c301f677004aa83e28d24da9fe663a24380122af57be6fc/lxml-6.1.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:40983eabefd13da003e68170928c7acc011f0d095eefce5871a3c71c9385fb9a", size = 5092340, upload-time = "2026-09-02T14:51:24.21Z" },
{ url = "https://files.pythonhosted.org/packages/ea/99/3314a8661cdf30f493c55a87db283961dfaae08451976a2ca418958e1804/lxml-6.1.3-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:fad67b12ffe0f71e02b4932b04883cbc76a9072bbd30731409d3523cf058b011", size = 4758768, upload-time = "2026-09-02T14:51:26.813Z" },
{ url = "https://files.pythonhosted.org/packages/30/58/3bdc577f78ea8b7d72d39a84506f7001d5b28728f43e5b84891e3b7d9a4a/lxml-6.1.3-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6cd11e7550d89e551a87dcec30f04b1fca32e86b68708aa01a4daa455d8605e5", size = 5649546, upload-time = "2026-09-02T14:51:29.453Z" },
{ url = "https://files.pythonhosted.org/packages/6a/e4/652633de1a2395949ebb7a8fc7d089aba12a2b45f0fefbc9d29e3e3ab3cf/lxml-6.1.3-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ca0ec532ad2f5ba1e5ec120ac157769c57f01855b3d8bf37213f5d88abd9ba0a", size = 5234874, upload-time = "2026-09-02T14:51:32.262Z" },
{ url = "https://files.pythonhosted.org/packages/65/a6/c4581d171de30449304b4859bbd3607e9b40da13c0f88b68e6097c8d785e/lxml-6.1.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e99e09ab7741f1281e2677f4c0058c7f5267d182530b09c87e4f6aa26adf3887", size = 5260043, upload-time = "2026-09-02T14:51:34.841Z" },
{ url = "https://files.pythonhosted.org/packages/b8/d7/ed6ee6186a89e69ca4ea9658b2a278f46a5efe8b5d4db56c7197f18653fe/lxml-6.1.3-cp315-cp315t-win32.whl", hash = "sha256:ace1d2c83b2bd24db5940600541140e87a325e119cb32d5fa9ad720d7e76648e", size = 3901093, upload-time = "2026-09-02T14:51:37.234Z" },
{ url = "https://files.pythonhosted.org/packages/67/9d/11d10257a4a048d04195d638bb61f0246ce2448eb05f682bcbab25a257a8/lxml-6.1.3-cp315-cp315t-win_amd64.whl", hash = "sha256:b49638355ea3bebba70da783ccbc630fd72afa16bc46c54474bfa1f9a915bbc6", size = 4395446, upload-time = "2026-09-02T14:51:39.884Z" },
{ url = "https://files.pythonhosted.org/packages/f8/b7/44edd7de434181c582892e68d1ffe6775ca403ce14aea07cb5a218a936cf/lxml-6.1.3-cp315-cp315t-win_arm64.whl", hash = "sha256:5a721a98c649855963811b59b55755b30566e7f7fc40bdc9803d66dee9f811cf", size = 3822836, upload-time = "2026-09-02T14:51:42.471Z" },
{ url = "https://files.pythonhosted.org/packages/ec/c1/2433176de263cc3f51fd2c303f993d5bb7f1da3139a0f7d168116c0bfa7a/lxml-6.1.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:d2765c18ce303149ee804b1f3dad11232726dd0a702d73a15cf19179ac8cc962", size = 3942969, upload-time = "2026-09-02T14:46:36.55Z" },
{ url = "https://files.pythonhosted.org/packages/7c/71/de7759096f480180fd9e43ff7c017860e2d2a9a43741ab093cbdf1820f07/lxml-6.1.3-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d5a748d12dd9b535e0a130f60dae9ddf0adafbabe61e7864f55c7436c84547a", size = 4213008, upload-time = "2026-09-02T14:46:38.784Z" },
{ url = "https://files.pythonhosted.org/packages/b8/9b/c2d09af47a34fa6c0c27473083812b449a411680bd04bbe609cde291ddc8/lxml-6.1.3-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:41096ec0740a58dad03d3ae0c7486d306d20becefb13ceb1649835ab3eb64167", size = 4322012, upload-time = "2026-09-02T14:46:41.031Z" },
{ url = "https://files.pythonhosted.org/packages/68/f3/bf56fee0403ebd995be8e78ec9aca566016487d1b3cbf755ebea8ccffbdb/lxml-6.1.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:415e3a115c0d510e329020012834d1c0aa1c581ee53a218603e38abbc1dea70a", size = 4257402, upload-time = "2026-09-02T14:46:43.134Z" },
{ url = "https://files.pythonhosted.org/packages/1c/1d/6da9cc086a20d9dd6bcbf7c5d9575f0331cca9a05e67dab02d15e828170b/lxml-6.1.3-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20428910dae17a1a93152a3ff2c0441d2f4932992c0797d65651dd0561f1792f", size = 4410889, upload-time = "2026-09-02T14:46:46.975Z" },
{ url = "https://files.pythonhosted.org/packages/03/5c/91fe48856f9f8089be3096fa4dbe4b3fb5526f3bf3e852ea9497f399cb9f/lxml-6.1.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bc8dd3d9c93e70c3df974a201ac2958b6d77b465d813c51d1f15fa8e645763ae", size = 3511258, upload-time = "2026-09-02T14:46:49.046Z" },
]
[[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,8 +661,11 @@ dependencies = [
{ name = "fastapi" },
{ name = "httpx" },
{ name = "jsonschema" },
{ name = "mistune" },
{ name = "python-docx" },
{ name = "pyyaml" },
{ name = "referencing" },
{ name = "reportlab" },
{ name = "sqlite-vec" },
{ name = "uvicorn", extra = ["standard"] },
]
@@ -390,8 +681,11 @@ 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 = "python-docx", specifier = ">=1.1,<2.0" },
{ name = "pyyaml", specifier = ">=6.0,<7.0" },
{ name = "referencing", specifier = ">=0.36,<1.0" },
{ name = "reportlab", specifier = ">=4.0,<5.0" },
{ name = "sqlite-vec", specifier = ">=0.1.9" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.35,<1.0" },
]
@@ -408,6 +702,91 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pillow"
version = "12.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" },
{ url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" },
{ url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" },
{ url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" },
{ url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" },
{ url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" },
{ url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" },
{ url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" },
{ url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" },
{ url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" },
{ url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" },
{ url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" },
{ url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" },
{ url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" },
{ url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" },
{ url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" },
{ url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" },
{ url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" },
{ url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" },
{ url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" },
{ url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" },
{ url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" },
{ url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" },
{ url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" },
{ url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" },
{ url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" },
{ url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" },
{ url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" },
{ url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" },
{ url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" },
{ url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" },
{ url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" },
{ url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" },
{ url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" },
{ url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" },
{ url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" },
{ url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" },
{ url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" },
{ url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" },
{ url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" },
{ url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" },
{ url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" },
{ url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" },
{ url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" },
{ url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" },
{ url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" },
{ url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" },
{ url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" },
{ url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" },
{ url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" },
{ url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" },
{ url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" },
{ url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" },
{ url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" },
{ url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" },
{ url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" },
{ url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" },
{ url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" },
{ url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" },
{ url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" },
{ url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" },
{ url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" },
{ url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" },
{ url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" },
{ url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" },
{ url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" },
{ url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" },
{ url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" },
{ url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" },
{ url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" },
{ url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" },
{ url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" },
{ url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" },
{ url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -568,6 +947,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
]
[[package]]
name = "python-docx"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "lxml" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.3"
@@ -646,6 +1038,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
]
[[package]]
name = "reportlab"
version = "4.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "charset-normalizer" },
{ name = "pillow" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4d/3f/b3861b7e40c9d66f4a04e018958d681d16b948bfd1963c962d43a8c23f66/reportlab-4.5.1.tar.gz", hash = "sha256:9fdf68f4de9171ec66acb4a5feed8f8ca2af43479e707a6fbb0daa75d88e5494", size = 3939748, upload-time = "2026-05-12T10:14:13.663Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/45/ea7fad10122440de6e845568d106bffdc456ca0e8a1d8ae10b46016087e4/reportlab-4.5.1-py3-none-any.whl", hash = "sha256:06fce8cb56c83307cfa4909cdf4e6a2ddbb44e5d6ef4d2edca896d7e9769f091", size = 1957812, upload-time = "2026-05-12T10:14:10.622Z" },
]
[[package]]
name = "rpds-py"
version = "2026.6.3"
+21 -1
View File
@@ -2,7 +2,7 @@
本目录集中保存团队开发期间需要长期维护的架构、接口、实现、协作和问题复盘文档。文档按用途分类,避免设计约束、开发记录与故障复盘混放。
当前文档基线为 2026-09-05:第一阶段和第二阶段 A~F 工程范围已经合并到 `main`,当前可运行形态仍为 Vue/Vite Web 前端与 FastAPI AI Core。Tauri/Rust Host、Stronghold、原生多 Vault 文件系统、生产级 MCP 沙箱和 Sync Server 尚未接入。
当前文档基线为 2026-09-06:第一阶段和第二阶段 A~F 工程范围已经合并到 `main`,当前可运行形态仍为 Vue/Vite Web 前端与 FastAPI AI Core。Tauri/Rust Host、Stronghold、原生多 Vault 文件系统、生产级 MCP 沙箱和 Sync Server 尚未接入。
仓库入口文档:[项目 README](../README.md)、[前端 README](../frontend/README.md)、[后端 README](../backend/README.md)。
@@ -34,11 +34,25 @@
## development:开发说明
- [前端构建分块优化开发说明](development/前端构建分块优化开发说明.md)
- [工作区后台索引与保存开发说明](development/工作区后台索引与保存开发说明.md)
- [模型隔离向量索引与增量登记](development/模型隔离向量索引与增量登记.md)
- [Mermaid 预览与缩放开发说明](development/Mermaid预览与缩放开发说明.md)
- [扩展安装持久化与社区包开发说明](development/扩展安装持久化与社区包开发说明.md)
- [模型上下文管理](development/模型上下文管理.md)
- [Markdown 渲染检查](development/Markdown渲染检查.md)
- [警告框与桌面编辑命令开发说明](development/警告框与桌面编辑命令开发说明.md)
- [标题折叠与样式开发说明](development/标题折叠与样式开发说明.md)
- [主题组件覆盖检查](development/主题组件覆盖检查.md)
- [第二阶段补充验收工具](development/第二阶段补充验收工具.md)
- [多模态管线与模型运行开发说明](development/多模态管线与模型运行开发说明.md)
- [阶段 F 收尾验收记录](development/阶段F收尾验收记录.md)
- [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)
@@ -81,3 +95,9 @@
- 问题复盘至少写清原因、后果、解决思路、实际方案和验证结果。
- `.local-plans/` 只保存个人或阶段性的本地计划,不属于正式团队文档,不应提交到远程仓库。
- 文档中的“计划实现”和“已经实现”必须明确区分;实现状态以代码、测试和运行时契约为准。
- [Markdown 语法预设与外部文件刷新](development/Markdown语法预设与外部文件刷新.md)
- [长文渲染优化与压测报告](development/长文渲染优化与压测报告.md)
- [Agent 与任务压测报告](development/Agent与任务压测报告.md)
- [后台运行日志与压力问题修复](development/后台运行日志与压力问题修复.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 中直接绑定第三方协议。
第三阶段处理:
@@ -21,7 +21,9 @@
桌面客户端顶部菜单栏的 **段落 → 导入为笔记属性…** 预留元数据格式导入功能,与标题、正文、列表等段落操作归组。它处理笔记内容中的元数据,不是主题包安装入口。
建议稳定的前端命令标识为 `editor.import-note-properties`仅为设计标识,尚未注册为 Tauri IPC。原生菜单和编辑器命令面板应分发同一命令,避免两套转换逻辑。快捷键待第三阶段统一分配,不抢占现有编辑快捷键。
稳定的前端命令标识为 `editor.import-note-properties`已进入前端 v1 命令目录,但属性转换处理器与 Tauri IPC 尚未实现。原生菜单和编辑器命令面板应分发同一命令,避免两套转换逻辑。快捷键待第三阶段统一分配,不抢占现有编辑快捷键。
Markdown 格式(含警告框)与元数据共用 `editorCommandService` 的能力查询和命令分发接口,详见 [警告框与桌面编辑命令开发说明](../development/警告框与桌面编辑命令开发说明.md)。Host 根据 supported / enabled 显隐或禁用菜单,不能把已预留的命令 ID 当作已可执行能力;原生快捷键不绕过活动文档、只读和冲突检查。
### 2.2 输入与转换规则
+22 -1
View File
@@ -42,7 +42,7 @@ Web 联调阶段只暴露后端通过 `APP_VAULT_PATH` 配置的单一 Vault
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| GET | `/api/workspace` | 获取当前 Vault、文件数和索引同步状态 |
| POST | `/api/workspace/open` | 打开配置的 Vault磁盘路径集变化时重建索引 |
| POST | `/api/workspace/open` | 打开配置的 Vault;路径集变化时先登记文件与 FTS,再调度后台向量更新 |
| GET | `/api/workspace/tree` | 获取真实 Markdown 文件和目录树 |
| POST | `/api/workspace/folders` | 新建目录 |
| POST | `/api/workspace/folders/rename` | 重命名目录并同步 Note 路径 |
@@ -198,3 +198,24 @@ RunCancelled
- `GET /api/index/status` 额外返回 `total_notes: int``total_blocks: int`,来自当前 SQLite 索引;未建立内容索引时为 0。
- `GET /api/permissions/policy` 返回 `Record<string, "allow" | "confirm" | "deny">`,值取自后端当前生效的 PermissionPolicy。此接口只读,不提供全局修改能力,运行时权限确认仍使用既有 Agent permission endpoint。
## 2026-09-06:后台索引补充
- `POST /api/workspace/open` 返回可使用的 WorkspaceSnapshot,不等待向量推理。
- 外部新增文件登记后按笔记持久化后台向量任务,不再因此设置全库重建标记;已存在的全库待处理标记仍继续执行。模型空间与维度的持久化 sqlite-vec 索引从已有向量转换,接口响应结构不变。实现与性能验证见 [模型隔离向量索引与增量登记](../development/模型隔离向量索引与增量登记.md)。
- `PATCH /api/notes/{note_id}` 成功代表正文、元数据和 FTS 已保存;后台向量失败不撤销这次保存。
- `GET /api/index/status` 新增 `vector_refresh_required: boolean`,表示工作区或笔记存在向量待处理标记。该字段不是进度百分比;任务失败时也可为 true。
- `pending_jobs` 返回真实未完成索引任务数(包含运行中),不再固定为 0。全库待重建或运行中的全量重建计一个任务;逐笔记刷新按待处理标记计数。`running_jobs` 返回当前运行数。失败后保留的待重建标记仍计入未完成数。
- `active_searches``completed_searches``failed_searches``cancelled_searches` 分别表示向量/混合检索的进行中、完成、失败、取消次数,覆盖搜索、对话和 Agent 经统一检索引擎发起的调用,排除纯 FTS;混合检索回退 FTS 后成功仍计完成。计数仅保存在当前服务进程内,重启归零,与索引队列互相独立。
- 设置与搜索页每秒轮询状态,其他页面空闲时每 5 秒轮询;不是事件推送。短检索可能无法观察到进行中状态,但完成/失败计数会保留。设置页与底部状态栏复用状态标签,未知字段显示“未获取”。
- `POST /api/index/rebuild` 仍仅支持全量重建,并等待结果;不要将上述异步语义推广到所有索引 API。
状态、恢复限制与验证见 [工作区后台索引与保存开发说明](../development/工作区后台索引与保存开发说明.md)。
## 2026-09-06:统一运行日志
`GET /api/logs` 返回独立持久化的后台操作日志,无需打开 Vault。参数:`limit` 默认 50、最大 200`before` 为上一页 next_cursor`level` 为 INFO/WARNING/ERROR/CRITICAL 或空;`source` 按模块精确匹配;`q` 在事件名和脱敏元数据中做字面搜索。
返回 `items: [{id,timestamp,level,source,event,details}]``next_cursor`(无后续页时 null)、`sources``pending``dropped``write_failures``retention`。日志按 ID 倒序,保留最近 20,000 条。响应头 `X-Request-ID` 与后台日志关联。不得依赖日志记录笔记正文、工具参数、凭据或原始异常消息。
队列、错误处理、字段白名单和验证方法见 [后台运行日志与压力问题修复](../development/后台运行日志与压力问题修复.md)。
@@ -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 | 创建导出任务;`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` | 已实现 | 取消导出任务 |
| Theme | Host Contract | `ThemePackageService` | 计划新增 | 导入、预览、启停和卸载主题包 |
| Renderer | 内部 Contract | `StaticRenderer` | 计划新增 | Mermaid/Function Plot 预览和导出复用 |
@@ -1080,6 +1080,8 @@ VECTOR_INDEX_REBUILD_REQUIRED
## 10. Export Service
> 实现状态:HTML / PDF / DOCX 导出均已实现(`backend/app/export/`),`format` 支持 `html`/`pdf`/`docx` 三格式。`function-plot` 已支持静态 SVG 内嵌(`backend/app/plot/`),解析或渲染失败时回退为源码占位并记录 warning;Mermaid 目前仍以占位代码块保留并记 warning。PDF/DOCX 为文本优先 v1`function-plot` 与 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`,三格式均已实现
响应:
@@ -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"
@@ -1219,12 +1222,12 @@ interface StaticRenderResult {
```text
EXPORT_SOURCE_NOT_FOUND
EXPORT_FORMAT_UNSUPPORTED
EXPORT_OPTIONS_INVALID
EXPORT_RENDER_FAILED
EXPORT_UNSUPPORTED_CONTENT
EXPORT_JOB_NOT_FOUND
EXPORT_FILE_EXPIRED
EXPORT_OUTPUT_TOO_LARGE
```
---
@@ -0,0 +1,156 @@
# Agent 与任务压测报告
> 日期:2026-09-06。范围:当前第二阶段 Agent Runtime、任务 API、任务列表及 Trace 组件。测试在 Windows 11、Python 3.12.6、独立无头 Chrome 下执行,前端为 Vite 开发模式,纸间时光版本 1.8.1。
## 1. 结论
功能正确性检查通过:Agent 工具调用、事件序号与回放、终态持久化恢复、容量拒绝、取消、权限等待、故障隔离,以及任务增删改查和分页均得到预期结果。性能仍有三项明确问题:
| 优先级 | 问题 | 本轮证据 | 建议方向 |
| --- | --- | --- | --- |
| P1 | 任务列表未加载后续页;Agent 历史列表也没有后续分页入口 | 100、1000 条任务的实际页面均只请求 `/api/tasks` 并显示 50 条;Agent store 同样仅调用默认分页一次,接口默认 50 | 增加服务端筛选与分页/加载更多,显示总量,防止统计仅基于已加载项 |
| P1 | 高并发 Agent 阻塞事件循环 | 200 并发的完成延迟 P95 约 10.55 秒;进程内心跳最大延迟约 3.56 秒 | 分析同步持久化,设计有界执行调度及顺序写入,保留事件落盘、终态与回放一致性 |
| P1 | 大 Trace 的树形筛选长时间阻塞 | 2000 事件树形搜索约 483617 ms10000 事件约 25.91 秒 | 为匹配的 sequence/tool/model ID 建 Set,避免逐节点重复遍历事件;再评估分段显示及增量更新 |
本次新增的是可复现压测与报告,没有把上述生产问题标记为已修复。页面滚动与树形搜索是不同瓶颈:10000 事件的滚动 P95 约 40 ms,并不能说明搜索交互也流畅。
## 2. 隔离与方法
- 后端脚本在导入任何 app 模块前,将 `APP_DATA_DIR``APP_DB_PATH``APP_VAULT_PATH` 指向临时目录,结束后清理;不读写用户 Vault、现有任务及 Provider 凭据。
- Agent 使用内置 Mock Provider,每轮模型调用注入 50 ms 异步等待,正常样本包含一次 `system.echo` 工具调用和两轮模型调用;不调用真实厂商,也不衡量回答质量或真实模型吞吐。
- 进程内运行直接调用真实 Runtime、SQLite 和任务 ASGI 路由。任务 HTTP 复核另起独立 Uvicorn 进程,用真实回环连接执行,避免将 ASGI 驱动的连续无让出执行误解为网络服务延迟。
- 前端挂载真实 TasksView、TraceTimeline 和 Pinia store。压测页拦截全部 fetch,不向真实后端发出请求。任务接口夹具保留默认 50 条分页,先测实际加载条数,再注入完整列表测试渲染上限,两者分开记录。
- Trace 由确定性模型开始/完成、工具调用/结果事件组成,带调用与父节点 ID;测量首次渲染、时间线过滤、树形切换、匹配全部节点的树形搜索。
- 滚动用 CDP 派发 120 次滚轮事件,记录真实滚动容器及 `maxScrollTop`,防止对未滚动页面统计帧间隔。Trace 测试容器显式解除应用根节点的裁剪,由独立滚动区承担真实 Agent 页滚动容器的职责。
本轮每个前端配置测一次;开发编译、后台负载、缓存和浏览器调度都会影响结果,数据用于发现瓶颈,不作为生产环境 SLA。首次渲染计时不包含模块下载,包含 Vue 更新及两个 animation frame。
## 3. Agent 后端
### 3.1 正常工具调用与回放
| 并发数 | 运行数 | 完成延迟中位数(ms) | 完成延迟 P95(ms) | 场景心跳最大延迟(ms) |
| ---: | ---: | ---: | ---: | ---: |
| 1 | 20 | 146.20 | 170.13 | 26.84 |
| 10 | 20 | 405.75 | 505.74 | 136.71 |
| 50 | 50 | 2234.45 | 2304.42 | 798.84 |
| 200 | 200 | 10262.47 | 10554.52 | 3559.97 |
290 个正常运行全部完成,工具返回内容逐项一致;实时订阅事件序号连续,`after_sequence=2` 回放与原事件后缀一致。新的 Runtime 实例可以从 SQLite 恢复这些终态运行。订阅者集合最终清空,进程内保留记录不超过 200。
“并发”是同时提交的协程数,不代表独立 CPU 工作线程。当前 queued 是启动前状态,Runtime 并没有把超额活跃运行无限排入执行队列。场景心跳还包含提交、持久化回放与恢复校验,不能将其全部归为模型执行耗时;50、200 并发场景的心跳采样数仅 8 个,表中因此列最大值而非宣称稳定分位数。
代码定位:`AgentRuntime._publish()` 同步调用 `AgentTraceRepository.append_event()`,后者逐事件连接 SQLite 并提交事务;`connect()` 还加载扩展和检查迁移。这是需要进一步拆分测量的阻塞路径,本轮未完成各子步骤 CPU/磁盘成本归因。
### 3.2 容量、取消与故障注入
- 保持 200 个模型调用活跃,第 201 次创建得到 `AgentCapacityError`;随后取消全部 200 个运行,全部进入 cancelled,后台任务均结束。
- 20 个运行等待权限:10 个允许后完成,10 个等待中取消,订阅者均释放。
- 20 个混合运行:5 个正常完成、5 个 Provider 异常、5 个模型超时、5 个工具超时,均获得预期状态或错误码;工具执行器剩余数量为 0。
- 正常回放和故障场景合计创建 530 个运行,临时数据库最终约 2.95 MB。工具超时被记录为 ToolResult 错误,后续 Mock 模型仍可能完成运行,不将它误算为整个 Run 必然失败。
这里的“恢复”是新 Runtime 读取终态持久化数据,未模拟 OS 强杀时的写入中断。SSE 通过 Runtime 的事件生成器验证序号及断点回放,没有进行真实网络慢读者/断网压力测试。
## 4. 任务 API
进程内分别对 100、1000 条任务执行创建、逐页读取、更新为 done、删除,所有 ID 完整,最终总量为 0。默认接口每页 50 条,显式每页 100 条遍历可以取回全部数据。
ASGITransport 的 1000 条突发操作中,心跳出现约 14 秒延迟:测试客户端与同步路由处于同一事件循环,连续就绪协程缺少网络等待,不能将其视作真实 HTTP 健康检查延迟。因此增加独立 Uvicorn + 回环 HTTP 复核:
| 操作 | 数量 | P95ms | 最大值(ms |
| --- | ---: | ---: | ---: |
| 创建 | 1000 | 116.45 | 134.91 |
| 更新 | 1000 | 159.29 | 193.37 |
| 删除 | 1000 | 156.43 | 190.10 |
| 分页/收尾查询 | 11 | 20.96 | 20.96 |
| 并行健康检查 | 117 | 157.00 | 195.07 |
HTTP 客户端并发 20,总耗时约 18.31 秒;健康检查零失败,分页无重复/遗漏,最终任务数为 0。此处是本机单次结果,未覆盖跨网络、长期持久负载或多进程同时写同一数据库。
## 5. 前端
### 5.1 任务列表
100、1000 条数据的实际加载阶段都只有一次 `/api/tasks` 请求,store 中均为 50 条。以下是额外注入完整 1000 条数据后的结果,不是生产页面当前可以加载 1000 条的证明。
| 主题 | 完整列表渲染(ms) | 完成状态筛选(ms) |
| --- | ---: | ---: |
| 默认浅色 | 117.4 | 44.1 |
| 默认深色 | 137.8 | 47.8 |
| 纸间时光 1.8.1 | 150.0 | 59.5 |
1000 条列表约 11007 个 DOM 节点,实际滚动最远达到 81000 px;筛选 done 得到 334 条,与夹具期望一致。100 条样本筛选得到 34 条,也一致。默认浅色的 1000 条滚动 P95 约 30.2 ms,纸间时光约 50.1 ms;样本数少,暂不据此认定新的单一 CSS 根因。
### 5.2 Agent Trace
| 事件数/主题 | 首次渲染(ms) | 时间线搜索(ms) | 树形切换(ms) | 树形全匹配搜索(ms) |
| --- | ---: | ---: | ---: | ---: |
| 2000 / 默认浅色 | 283.8 | 97.4 | 64.0 | 617.2 |
| 2000 / 默认深色 | 289.0 | 74.5 | 62.9 | 483.0 |
| 2000 / 纸间时光 | 337.3 | 86.3 | 62.1 | 540.1 |
| 10000 / 纸间时光 | 1803.9 | 582.8 | 317.5 | 25914.8 |
200 事件的树形搜索约 20–29 ms。2000 事件时间线约 20542 个 DOM 节点,10000 事件约 102542 个。查询“压力测试 1”分别命中 444、4444 条事件,与直接检查输入数据的结果一致;树形搜索“压力测试”匹配所有事件,会自动展开匹配子树。
代码定位:`TraceTimeline.filteredTree` 为每个节点调用 `filteredEvents.some(...)`。节点数和匹配事件数一起增加时,会产生近似二次增长的匹配工作,随后还需构造并渲染展开子树。建议先用匹配 ID 集合消除嵌套扫描,再测 DOM 更新成本;不要仅用防抖隐藏单次 25 秒阻塞。
10000 是扩大数据规模的诊断测试,高于 Runtime 默认内存事件保留量 2000;它直接向组件传入事件,不代表当前单次 API 页或实时 store 默认会收到这一数量。也未覆盖逐事件 SSE 增量刷新的端到端成本。
## 6. 复现与原始数据
```powershell
# 后端:独立临时数据,离线 Mock
backend/.venv/Scripts/python.exe backend/scripts/agent-task-stress.py --output .local-plans/agent-task-backend.json
# 真实 HTTP:独立 Uvicorn、随机回环端口
backend/.venv/Scripts/python.exe backend/scripts/task-http-stress.py --count 1000 --concurrency 20 --output .local-plans/task-http.json
# 前端:先启动独立 Vite,另一个终端执行后续命令
npm --prefix frontend run dev -- --port 5175 --strictPort
backend/.venv/Scripts/python.exe frontend/tests/performance/run-stress.py --url 'http://127.0.0.1:5175/tests/performance/agent-task.html?kind=tasks&theme=paper-moments' --scroll --sizes 100 1000 --runs 1 --output .local-plans/task-ui.json
backend/.venv/Scripts/python.exe frontend/tests/performance/run-stress.py --url 'http://127.0.0.1:5175/tests/performance/agent-task.html?kind=trace&theme=paper-moments' --scroll --sizes 200 2000 10000 --runs 1 --output .local-plans/trace-ui.json
```
URL 的 theme 可换为 light、dark。测试应串行运行,避免不同负载相互争用;10k Trace 树形筛选可能长时间占用测试浏览器。浏览器使用临时用户目录,不接管用户当前浏览器。
- [Agent 与进程内任务 API 数据](performance/2026-09-06-agent-task-backend.json)
- [真实 HTTP 任务数据](performance/2026-09-06-task-http.json)
- [前端 13 组样本数据](performance/2026-09-06-agent-task-ui.json)
- [前端压测工具](../../frontend/tests/performance/README.md)
附加回归:`test_agent_core.py``test_api.py` 共 26 项通过。脚本中的状态、序号、输出、分页与筛选断言均通过。未提交或推送本次材料。
## 7. 修复后复测(2026-09-06
以上保留首次压测基线。本节对应后台 Trace 批量写入、任务后台写入、完整列表加载、Trace 集合匹配/分页和统一日志接入后的实现。样本开启新操作日志,仍只调用隔离 Mock。
| 项目 | 修复前 | 修复后 |
| --- | ---: | ---: |
| 200 并发 Agent 完成 P95 | 10,554.52 ms | 1,233.12 ms |
| 200 并发测量区间事件循环最大延迟 | 3,559.97 ms | 509.00 ms |
| 纸间时光 10k Trace 首次渲染 | 1,803.9 ms | 94.4 ms |
| 纸间时光 10k Trace 树形全匹配筛选 | 25,914.8 ms | 189.2 ms |
| 纸间时光 10k Trace 首屏 DOM 节点 | 约 102,542 | 1,852 |
| 1,000 条任务首次实际加载 | 50 条 | 1,000 条(10 次 API 请求) |
Agent 测量区间还包含同步读回、校验和重启恢复,最大心跳延迟不是纯执行阶段指标。290 次普通运行、200 个容量/取消场景、20 个权限场景、20 个失败/超时场景全部通过,订阅者和工具执行器释放。不能声称消除了所有主线程工作。
Trace 搜索检查完整数据,只有渲染分页。10k 样本滚动 frame gap P95 为 10.1 ms,无 longtask;时间线筛选 284.9 ms,树形切换 100.9 ms。这是单次本机样本,不代表所有硬件与实时 SSE 输入。
1,000 条任务界面每页 100 条;筛选后数据总数 334,当前页 100。筛选 18.7 ms,滚动 frame gap P95 为 30 ms,无 longtask。断言同时检查完整数据量和分页渲染数量。
真实 HTTP 测试 1,000 条任务、20 并发,CRUD 全部成功,分页完整,最终任务数 0:
| 指标 | 修复前 | 修复后 |
| --- | ---: | ---: |
| health P95 | 157.00 ms | 38.64 ms |
| 创建 P95 | 116.45 ms | 157.68 ms |
| 更新 P95 | 159.29 ms | 208.68 ms |
| 删除 P95 | 156.43 ms | 205.45 ms |
| 总耗时 | 18.31 s | 24.51 s |
后台排队和操作记录改善了无关请求的响应,但这组样本写入吞吐下降,不能描述为 CRUD 全面提速。后续可以评估任务事务批量化与连接初始化开销;本次没有降低 SQLite 持久化级别。
- [Agent / 进程内任务复测](performance/2026-09-06-agent-task-backend-fixed.json)
- [真实 HTTP 任务复测](performance/2026-09-06-task-http-fixed.json)
- [Trace 浏览器复测](performance/2026-09-06-agent-trace-ui-fixed.json)
- [任务浏览器复测](performance/2026-09-06-task-ui-fixed.json)
- [日志架构与验收说明](后台运行日志与压力问题修复.md)
+130
View File
@@ -0,0 +1,130 @@
# Export 开发说明
> 所属模块:Export Service(后端,负责人 yxx)。交付「多格式文档导出」:Markdown → HTML / PDF / DOCX 的完整生命周期与 function-plot 静态 SVG 渲染。契约对应 [第二阶段接口契约 §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
│ ├── _common.py 共享工具(URL 协议校验 + 占位 warning 文案 + 元数据格式化)
│ ├── html.py HtmlExporterDocument AST → 完整 HTML5
│ ├── pdf.py PdfExporterDocument AST → PDFreportlab
│ └── docx.py DocxExporterDocument AST → DOCXpython-docx
└── service.py ExportService(注册表 + 后台渲染 + 取消 + 产物生命周期)
backend/app/plot/
├── parser.py 函数图像表达式解析(白名单 AST)
├── render.py FunctionPlot → 静态 SVG
└── renderer.py StaticRenderer 内部契约(§10.4
```
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` 三种,经 `service._EXPORTERS` 注册表按格式分发到对应导出器。
## 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``FunctionPlotStaticRenderer` 解析为静态 SVG 内嵌(解析/渲染失败或超限时回退 `<pre class="function-plot">` 占位并记 warning),`mermaid` 无法静态表达,渲染为占位 `<pre class="mermaid">` 并记 warning,均不静默丢失;`code_theme` 仅作为代码容器 class,不引入 JS 高亮库。无法表示的节点统一 `warnings.append(...)` 跳过。
## StaticRenderer 内部契约(§10.4
函数图像与 Mermaid 的静态渲染统一收敛到 `app/plot/renderer.py`
- `StaticRenderRequest``kind` / `source` / `source_hash` / `theme` / `width` / `height`)是统一的渲染请求载体,`source_hash` 供缓存/去重,`theme` 供主题化渲染。
- `StaticRenderer` Protocol 定义 `render(request) -> StaticRenderResult`,导出器只面向协议,不直接调用 `render_svg`
- `FunctionPlotStaticRenderer` 委托 `parse_source` 解析 + `render_svg` 输出内嵌 SVG`parse``render_plot` 拆开,供导出器在渲染前先拿 `node_count` 做文档级累计复杂度预算。
- `MermaidStaticRenderer` 后端无 Mermaid 渲染能力,返回空占位结果并记 warning,交由前端渲染。
## PDF / DOCX 导出器(v1 文本优先)
`PdfExporter`reportlab platypus)与 `DocxExporter`python-docx)实现与 HtmlExporter 一致的同步 `render(document, options) -> ExportResult` + 异步 `export`。v1 为文本优先,覆盖标题/段落/行内强调与链接/列表/引用/表格/代码块/数学文本;`function_plot``mermaid` 保留源码占位并记 warning(与现有 Mermaid 处理一致)。
- PDF 中文字体用 reportlab 内置 `STSong-Light` CID 字体,无外部字体依赖;CID 字体无独立 bold/italic 字重,行内强调退化为普通文本(内容不丢、样式简化),标题靠字号区分层级。
- DOCX 通过 Normal 样式挂载 `w:eastAsia=宋体` 保证中文显示,bold/italic 由 Word 原生渲染;链接写入可点击的 `w:hyperlink` run。
- 扩展名/MIMEhtml→`.html`/`text/html`pdf→`.pdf`/`application/pdf`docx→`.docx`/`application/vnd.openxmlformats-officedocument.wordprocessingml.document`;路由 `FileResponse``mime_type` + `file_name` 通用化,无需改路由。
## 运行生命周期
`queued → running → completed | failed | cancelled`
- 创建时校验:`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}{ext}``ext` 由格式决定),下载 `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_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/DOCX 魔法字节与 CJK 字体、引用块正文与嵌套列表顺序等结构内容回归、排队任务取消、未知 note、取消、list/get、过期 410)与 `ExportSource` 契约校验。`tests/test_plot.py` 覆盖表达式解析/求值、SVG 渲染与 `StaticRenderer` 契约(函数图像渲染、Mermaid 占位)。
## 范围外(后续 PR
- PDF 内嵌函数图像与 Mermaid 渲染(v1 仅源码占位)。
- 函数图像交互预览与缩放(前端 JS Renderer 负责,后端仅提供静态 SVG)。
- 代码语法高亮(当前仅 CSS class 占位)。
@@ -235,3 +235,9 @@ rag.search
- RAG Benchmark 已建立:`POST /api/benchmarks/rag/runs` 创建即返回 queued、后台 Task 执行,
通过 SSE 实时推送进度,报告含逐 Case 结果与 `total_cases` / `successful_cases` / `failed_cases` / `failure_rate`
- Agent Benchmark 暂缓,待 Agent Runtime 完成后交付。
## 2026-09-06 实现补充
上文的 MVP 同步说明是早期基线。当前打开 Vault 和 HTTP PATCH 保存已拆分即时元数据 / FTS 与后台向量计算;手动全量重建仍等待完成。生产 Embedding 使用实际模型和隔离向量空间,HashEmbedding 仅用于测试。
后续维护以 [工作区后台索引与保存开发说明](工作区后台索引与保存开发说明.md) 的实现边界、状态和验证步骤为准。
+9 -1
View File
@@ -30,6 +30,14 @@
| 自定义字号 span | 装饰渲染 | 清理后 HTML | 既有字号标记测试 |
| 原始 HTML | 编辑器按自身 HTML 节点规则保留 | 清理后展示,脚本及事件属性移除 | 新增安全 HTML 测试 |
源码模式展示 Markdown 原文,不隐藏反引号、星号和围栏。脚注、定义列表、Wiki 双链、Obsidian callout、图表以外的自定义围栏等未作为独立渲染扩展启用,不在“已支持”范围内。
源码模式展示 Markdown 原文,不隐藏反引号、星号和围栏。脚注、定义列表、Wiki 双链、图表以外的自定义围栏等未作为独立渲染扩展启用,不在“已支持”范围内。
2026-09-06 补充:GitHub alerts 与 Obsidian callout 已在工作区和静态预览接入,包含常用类型/别名、自定义标题、嵌套与折叠。语法、命令接口与验证方法见 [警告框与桌面编辑命令开发说明](警告框与桌面编辑命令开发说明.md)。
自动检查覆盖解析、DOM 输出、部分编辑交互、保存往返和主题变量。尚未完成所有浏览器、所有输入法及每个主题的逐页截图比对;不能据此宣称像素级视觉验收通过。测试使用隔离样例,没有修改用户笔记。
## 2026-09-06 Mermaid 补充
补齐大图居中与完整适配、显示宽度缩放基准、滚轮速度限制、鼠标位置补偿及重开滚动位置清理。修复 Milkdown 段落内边距挤出 foreignObject 标签框的问题,浏览器核对 1、many、contains 完整显示。
实现原理、测试命令和视觉复核步骤见 [Mermaid 预览与缩放开发说明](Mermaid预览与缩放开发说明.md)。
@@ -0,0 +1,48 @@
# Markdown 语法预设与外部文件刷新
日期:2026-09-06。
## 1. 设置入口与范围
“设置 → 编辑器 → Markdown 语法预设”管理语法与编辑行为,独立于“标题样式”的字号、字体和字重设置。
支持 ATX/Setext 标题、无序列表标记、有序列表递增、代码围栏、裸链接识别、数学公式、警告框、Mermaid、代码行号、自动换行、缩进和工具栏新建代码块的默认语言。Setext 只作用于 H1/H2。提供扩展、GitHub 和基础三组内置配置,支持最多 20 个命名自定义预设,同名保存替换旧配置。
配置保存在本机 localStorage 的 `markdown-preferences`,读取时校验。静态预览即时应用;写作编辑器在下次打开时应用,避免切换设置重建正在编辑的文档。写作模式保存会统一整篇正文的语法标记,源码模式保留手写语法。关闭扩展后对应内容按普通 Markdown/代码展示。
本次不是完整复制 Typora:未提供上下标、高亮、智能标点、physics 包及导出公式选项。基础配置仍支持现有 GFM 表格等功能。
## 2. 主题与折叠
六个主题共用语义颜色和表单控件。内置浅色、深色、护眼更新为 1.3.0;纸间时光 1.8.0Ocean Blue 1.5.0Midnight Purple 2.3.0。社区预览增加语法控件和标题折叠样本。
标题箭头使用统一 CSS 形状,默认隐藏,悬停标题或键盘聚焦按钮时显示;无悬停能力的触摸设备保持可见。用户自定义标题外观独立于主题配色。
## 3. 外部文件刷新与保存保护
Web 当前采用串行后台轮询:前一次完成后间隔两秒,在窗口聚焦时也检查。隐藏页面暂停读取,卸载移除监听。此机制不是原生文件事件监听;第三阶段可由 Tauri 文件事件替代。
`GET /workspace/tree` 检查磁盘新增、删除和重命名,为新文件登记元数据及全文索引,向量任务继续后台执行。前端保留文件夹展开状态,并丢弃过期请求响应。读取失败保留旧树并显示重试入口。
当前打开且未修改的文件检测到外部正文变化后更新编辑器;存在本地编辑时保留缓冲区,停止自动保存并提示冲突。重新加载磁盘版本需要用户确认。原文件删除或移动后,隐藏不可用的重新加载入口,提供下载当前 Markdown 副本和确认关闭笔记;取消关闭或确认期间正文改变时保留缓冲区。关闭后可重新选择其他文件,不再阻塞导航。`PATCH /notes/{note_id}` 新增可选 `expected_content_hash`(原始正文 UTF-8 SHA-256,64 位小写十六进制),保存前校验,不匹配返回 `NOTE_CONTENT_CONFLICT`,防止覆盖外部修改。
范围限制:现有文件的外部正文修改会刷新当前编辑器,但本轮目录检查不会据此重建其搜索索引;可通过重建索引同步检索内容。
## 4. 验证方法
```powershell
cd frontend
npm run test
npm run build
cd ..
backend/.venv/Scripts/python.exe -m pytest backend/tests/test_workspace.py backend/tests/test_workspace_background.py -q -p no:cacheprovider
```
手工验证:
1. 保存并重新应用命名预设,刷新后确认保留;重新打开笔记,检查 Setext、列表和代码围栏的源码。
2. 切换六个主题,在社区预览和工作区检查控件、警告框、标题箭头;移出标题后箭头隐藏,Tab 聚焦仍可操作。
3. 在系统文件管理器新增、重命名、删除 Markdown 文件,保持前端可见,确认树自动更新且目录展开状态保留。
4. 分别在正文未修改、有未保存编辑时从外部修改同一文件,验证自动加载与冲突保护;拒绝重新加载应保留编辑内容。
本次自动验证覆盖预设持久化、解析选项隔离、写作语法输出、标题折叠、树刷新竞态、外部文件登记及保存冲突。
@@ -0,0 +1,46 @@
# Mermaid 预览与缩放开发说明
> 本文档用于前端开发和图表渲染联调。
>
> 更新日期:2026-09-06。代码基线:`a5c44c4`
## 当前实现
工作区和静态 Markdown 通过 `mermaidService.ts` 串行渲染,使用主题变量与 Mermaid strict 模式。工作区由 `mermaidPreview.ts` 发布当前版本预览;`DiagramInteractions.vue` 提供缩放和大图交互。
- 行内按钮在悬停或键盘焦点进入时显示;触屏保留操作入口。
- 行内中键点击 SVG 后进入滚轮缩放,移动鼠标或窗口失焦退出。
- 大图直接使用滚轮缩放,缩放范围为 20%~500%。
- 行内首次缩放以实际显示宽度为基准,避免受容器限制的图表跳回原始宽度。
- 大图打开时根据原始比例与可用窗口宽高确定 100% 基准,清除旧滚动位置并居中;100% 不一定等于 SVG 原始像素宽度。
- 滚轮增量按时间和 delta 限制,宽度变化使用 180ms 过渡;支持减少动态效果偏好。
- 过渡期间根据鼠标位置补偿内外滚动容器。受滚动边界限制时,不能保证鼠标锚点在任意位置绝对不动。
## 文字裁切的原因与修复
Mermaid 的 HTML 标签放在固定尺寸的 SVG `foreignObject` 中。Milkdown 的正文段落规则会给内部 p 增加上下各 4px 内边距,导致关系标注超出文字框。工作区现在对图表内部 p 清除段落内边距和外边距,并继承图表行高、字重。
大图复制 SVG 时保留 `foreignObject`,先清理嵌入 HTML,再清理整个 SVG,移除脚本及事件属性。不能为了保留标签而关闭安全清理。
类图未声明方法时,其方法区为空是正常结构。排查时应先对照源码,再检查 DOM 标签、尺寸和裁切范围。
## 验证方法
在 frontend 目录执行:
```powershell
pnpm exec vitest run src/components/common/DiagramInteractions.spec.ts src/features/editor/diagramIntegration.spec.ts src/features/editor/mermaidPreview.spec.ts
pnpm exec vue-tsc -b
```
使用 Vault 中“功能演示/03 Mermaid 图表集.md”手动检查:
1. 检查流程图、时序图、类图、状态图、ER 图和甘特图。
2. 类图的 1、many、contains 应完整显示;查看内部 p 的 padding 应为 0。
3. 甘特图大图初始应完整适配;放大后可通过滚动条查看超出部分。
4. 连续滚轮输入不应首次跳大;移动鼠标后行内滚轮模式退出。
5. 关闭并重开大图,不保留上次滚动位置。
浏览器实测中,关系标注文字高度约 16.5px,对应 16.5px 的 SVG 标签框;contains 约 24px,对应 24px 框。该结果不替代所有主题、字体、浏览器的视觉验收。
相关格式范围见 [Markdown 渲染检查](Markdown渲染检查.md)。
@@ -115,3 +115,9 @@ pnpm build
阶段 D 测试覆盖注册/注销生命周期、位置过滤、参数与 Context 校验、上下文裁剪、设置影响命令执行、声明式 Secret Resolver 与越权拒绝、真实 MCP Command Target 与 Agent Tool 隔离、必填 Secret 传递、外部 Schema 引用拒绝、定长 Secret Reference、篡改引用的跨命名空间阻断、Secret 删除与卸载失败回滚、Provider/通用凭据命名空间隔离、五类设置字段、Schema 版本冲突、Secret 密文与清理、损坏存储、空 Command 列表等无效贡献文件、OpenAPI 路径、前端 Service 请求格式、Host 状态展示和动态 Secret 表单。
生产构建仍会报告现有大 Chunk 警告,不影响构建成功;该问题属于前端按路由和 Markdown 依赖拆包的后续性能任务。
## 2026-09-06 安装状态补充
ZIP 导入和安装日志已接入。安装路径、包摘要、启用状态及 Plugin 授权可以在本地恢复;用户目录安装源与受管理 ZIP 解压目录使用不同卸载边界。功能示例包已入库,远程社区服务仍属计划。
详见 [扩展安装持久化与社区包开发说明](扩展安装持久化与社区包开发说明.md)。
@@ -0,0 +1,230 @@
{
"python": "3.12.6",
"platform": "Windows-11-10.0.26100-SP0",
"provider": "mock with 50 ms injected delay per model turn; no network",
"results": [
{
"scenario": "agent_tool_runs",
"concurrency": 1,
"runs": 20,
"latency": {
"count": 20,
"median_ms": 150.79,
"p95_ms": 169.57,
"max_ms": 178.52
},
"completed": 20,
"ordered_events_and_replay": true,
"terminal_recovery": true,
"retained_records": 20,
"elapsed_ms": 3047.59,
"event_loop_lag": {
"count": 995,
"median_ms": 0,
"p95_ms": 5.65,
"max_ms": 21.83
}
},
{
"scenario": "agent_tool_runs",
"concurrency": 10,
"runs": 20,
"latency": {
"count": 20,
"median_ms": 187.48,
"p95_ms": 214.02,
"max_ms": 214.58
},
"completed": 20,
"ordered_events_and_replay": true,
"terminal_recovery": true,
"retained_records": 40,
"elapsed_ms": 514.15,
"event_loop_lag": {
"count": 192,
"median_ms": 0,
"p95_ms": 4.82,
"max_ms": 20.02
}
},
{
"scenario": "agent_tool_runs",
"concurrency": 50,
"runs": 50,
"latency": {
"count": 50,
"median_ms": 364.9,
"p95_ms": 366.99,
"max_ms": 367.3
},
"completed": 50,
"ordered_events_and_replay": true,
"terminal_recovery": true,
"retained_records": 90,
"elapsed_ms": 526.56,
"event_loop_lag": {
"count": 172,
"median_ms": 0,
"p95_ms": 4.57,
"max_ms": 69.89
}
},
{
"scenario": "agent_tool_runs",
"concurrency": 200,
"runs": 200,
"latency": {
"count": 200,
"median_ms": 1118.63,
"p95_ms": 1233.12,
"max_ms": 1237.31
},
"completed": 200,
"ordered_events_and_replay": true,
"terminal_recovery": true,
"retained_records": 200,
"elapsed_ms": 1839.2,
"event_loop_lag": {
"count": 470,
"median_ms": 0,
"p95_ms": 2.6,
"max_ms": 509.0
}
},
{
"scenario": "capacity_and_cancel",
"active_limit": 200,
"overflow_rejected": true,
"cancelled": 200,
"cancel_latency": {
"count": 200,
"median_ms": 7.71,
"p95_ms": 10.8,
"max_ms": 17.37
},
"elapsed_ms": 3613.48,
"event_loop_lag": {
"count": 1613,
"median_ms": 0,
"p95_ms": 0,
"max_ms": 7.86
}
},
{
"scenario": "permission_wait",
"runs": 20,
"approved_completed": 10,
"cancelled_waiting_permission": 10,
"subscribers_released": true,
"elapsed_ms": 388.83,
"event_loop_lag": {
"count": 72,
"median_ms": 0,
"p95_ms": 9.9,
"max_ms": 15.27
}
},
{
"scenario": "failure_and_timeout_isolation",
"runs": 20,
"success": 5,
"provider_errors": 5,
"model_timeouts": 5,
"tool_timeouts": 5,
"remaining_tool_executors": 0,
"elapsed_ms": 1295.29,
"event_loop_lag": {
"count": 152,
"median_ms": 0,
"p95_ms": 7.2,
"max_ms": 17.24
}
},
{
"scenario": "task_api_crud",
"tasks": 100,
"client_concurrency": 20,
"latencies": {
"create": {
"count": 100,
"median_ms": 214.09,
"p95_ms": 264.51,
"max_ms": 309.99
},
"update": {
"count": 100,
"median_ms": 221.48,
"p95_ms": 403.74,
"max_ms": 495.1
},
"list": {
"count": 1,
"median_ms": 7.92,
"p95_ms": 7.92,
"max_ms": 7.92
},
"delete": {
"count": 100,
"median_ms": 232.9,
"p95_ms": 468.48,
"max_ms": 475.63
}
},
"default_page_count": 50,
"default_total": 100,
"pagination_complete": true,
"final_total": 0,
"elapsed_ms": 3673.22,
"event_loop_lag": {
"count": 2337,
"median_ms": 0,
"p95_ms": 0,
"max_ms": 118.92
}
},
{
"scenario": "task_api_crud",
"tasks": 1000,
"client_concurrency": 20,
"latencies": {
"create": {
"count": 1000,
"median_ms": 200.87,
"p95_ms": 237.75,
"max_ms": 376.8
},
"update": {
"count": 1000,
"median_ms": 220.04,
"p95_ms": 260.19,
"max_ms": 300.07
},
"list": {
"count": 10,
"median_ms": 7.89,
"p95_ms": 9.61,
"max_ms": 9.61
},
"delete": {
"count": 1000,
"median_ms": 218.1,
"p95_ms": 266.23,
"max_ms": 316.14
}
},
"default_page_count": 50,
"default_total": 1000,
"pagination_complete": true,
"final_total": 0,
"elapsed_ms": 32562.93,
"event_loop_lag": {
"count": 24893,
"median_ms": 0,
"p95_ms": 0,
"max_ms": 91.85
}
}
],
"database_bytes": 2981888,
"complete": true
}
@@ -0,0 +1,230 @@
{
"python": "3.12.6",
"platform": "Windows-11-10.0.26100-SP0",
"provider": "mock with 50 ms injected delay per model turn; no network",
"results": [
{
"scenario": "agent_tool_runs",
"concurrency": 1,
"runs": 20,
"latency": {
"count": 20,
"median_ms": 146.2,
"p95_ms": 170.13,
"max_ms": 171.23
},
"completed": 20,
"ordered_events_and_replay": true,
"terminal_recovery": true,
"retained_records": 20,
"elapsed_ms": 3041.03,
"event_loop_lag": {
"count": 190,
"median_ms": 5.64,
"p95_ms": 16.81,
"max_ms": 26.84
}
},
{
"scenario": "agent_tool_runs",
"concurrency": 10,
"runs": 20,
"latency": {
"count": 20,
"median_ms": 405.75,
"p95_ms": 505.74,
"max_ms": 539.28
},
"completed": 20,
"ordered_events_and_replay": true,
"terminal_recovery": true,
"retained_records": 40,
"elapsed_ms": 1045.19,
"event_loop_lag": {
"count": 15,
"median_ms": 57.63,
"p95_ms": 136.71,
"max_ms": 136.71
}
},
{
"scenario": "agent_tool_runs",
"concurrency": 50,
"runs": 50,
"latency": {
"count": 50,
"median_ms": 2234.45,
"p95_ms": 2304.42,
"max_ms": 2307.54
},
"completed": 50,
"ordered_events_and_replay": true,
"terminal_recovery": true,
"retained_records": 90,
"elapsed_ms": 2552.49,
"event_loop_lag": {
"count": 8,
"median_ms": 345.24,
"p95_ms": 798.84,
"max_ms": 798.84
}
},
{
"scenario": "agent_tool_runs",
"concurrency": 200,
"runs": 200,
"latency": {
"count": 200,
"median_ms": 10262.47,
"p95_ms": 10554.52,
"max_ms": 10584.03
},
"completed": 200,
"ordered_events_and_replay": true,
"terminal_recovery": true,
"retained_records": 200,
"elapsed_ms": 11442.28,
"event_loop_lag": {
"count": 8,
"median_ms": 1647.87,
"p95_ms": 3559.97,
"max_ms": 3559.97
}
},
{
"scenario": "capacity_and_cancel",
"active_limit": 200,
"overflow_rejected": true,
"cancelled": 200,
"cancel_latency": {
"count": 200,
"median_ms": 4.32,
"p95_ms": 5.36,
"max_ms": 8.27
},
"elapsed_ms": 3645.04,
"event_loop_lag": {
"count": 3,
"median_ms": 14.04,
"p95_ms": 3610.52,
"max_ms": 3610.52
}
},
{
"scenario": "permission_wait",
"runs": 20,
"approved_completed": 10,
"cancelled_waiting_permission": 10,
"subscribers_released": true,
"elapsed_ms": 971.64,
"event_loop_lag": {
"count": 11,
"median_ms": 49.09,
"p95_ms": 314.46,
"max_ms": 314.46
}
},
{
"scenario": "failure_and_timeout_isolation",
"runs": 20,
"success": 5,
"provider_errors": 5,
"model_timeouts": 5,
"tool_timeouts": 5,
"remaining_tool_executors": 0,
"elapsed_ms": 1531.14,
"event_loop_lag": {
"count": 83,
"median_ms": 5.17,
"p95_ms": 27.82,
"max_ms": 255.96
}
},
{
"scenario": "task_api_crud",
"tasks": 100,
"client_concurrency": 20,
"latencies": {
"create": {
"count": 100,
"median_ms": 5.37,
"p95_ms": 34.16,
"max_ms": 61.99
},
"update": {
"count": 100,
"median_ms": 7.22,
"p95_ms": 35.0,
"max_ms": 96.33
},
"list": {
"count": 1,
"median_ms": 3.73,
"p95_ms": 3.73,
"max_ms": 3.73
},
"delete": {
"count": 100,
"median_ms": 5.44,
"p95_ms": 14.57,
"max_ms": 36.43
}
},
"default_page_count": 50,
"default_total": 100,
"pagination_complete": true,
"final_total": 0,
"elapsed_ms": 2532.22,
"event_loop_lag": {
"count": 4,
"median_ms": 776.23,
"p95_ms": 1712.81,
"max_ms": 1712.81
}
},
{
"scenario": "task_api_crud",
"tasks": 1000,
"client_concurrency": 20,
"latencies": {
"create": {
"count": 1000,
"median_ms": 5.18,
"p95_ms": 32.23,
"max_ms": 43.29
},
"update": {
"count": 1000,
"median_ms": 6.51,
"p95_ms": 14.72,
"max_ms": 90.27
},
"list": {
"count": 10,
"median_ms": 5.48,
"p95_ms": 6.18,
"max_ms": 6.18
},
"delete": {
"count": 1000,
"median_ms": 4.76,
"p95_ms": 8.42,
"max_ms": 97.64
}
},
"default_page_count": 50,
"default_total": 1000,
"pagination_complete": true,
"final_total": 0,
"elapsed_ms": 21242.37,
"event_loop_lag": {
"count": 4,
"median_ms": 6932.7,
"p95_ms": 14260.46,
"max_ms": 14260.46
}
}
],
"database_bytes": 2949120,
"complete": true
}
@@ -0,0 +1,706 @@
[
{
"kind": "tasks",
"theme": "light",
"size": 100,
"initialRenderMs": 33.099999994039536,
"requests": [
"/api/tasks"
],
"initialTaskCount": 50,
"fullListRenderMs": 79.09999999403954,
"fullListIsInjected": true,
"domNodes": 1107,
"frameGapsMs": {
"median": 10,
"p95": 10.100000000000364,
"max": 69.49999999403951
},
"frames": 407,
"longTasks": [],
"scrollTop": 0,
"scrollHeight": 11702,
"maxScrollTop": 10845,
"filteredCount": 34,
"expectedFilteredCount": 34,
"filterMs": 16.299999982118607,
"repeat": 1
},
{
"kind": "tasks",
"theme": "light",
"size": 1000,
"initialRenderMs": 4.4000000059604645,
"requests": [
"/api/tasks"
],
"initialTaskCount": 50,
"fullListRenderMs": 117.39999997615814,
"fullListIsInjected": true,
"domNodes": 11007,
"frameGapsMs": {
"median": 20.099999999999454,
"p95": 30.199999999999818,
"max": 50
},
"frames": 353,
"longTasks": [],
"scrollTop": 54000,
"scrollHeight": 115652,
"maxScrollTop": 81000,
"filteredCount": 334,
"expectedFilteredCount": 334,
"filterMs": 44.10000002384186,
"repeat": 1
},
{
"kind": "tasks",
"theme": "dark",
"size": 100,
"initialRenderMs": 30.900000005960464,
"requests": [
"/api/tasks"
],
"initialTaskCount": 50,
"fullListRenderMs": 78.2999999821186,
"fullListIsInjected": true,
"domNodes": 1107,
"frameGapsMs": {
"median": 10,
"p95": 10.100000000000364,
"max": 49.69999998807907
},
"frames": 411,
"longTasks": [],
"scrollTop": 0,
"scrollHeight": 11702,
"maxScrollTop": 10845,
"filteredCount": 34,
"expectedFilteredCount": 34,
"filterMs": 16.099999994039536,
"repeat": 1
},
{
"kind": "tasks",
"theme": "dark",
"size": 1000,
"initialRenderMs": 5,
"requests": [
"/api/tasks"
],
"initialTaskCount": 50,
"fullListRenderMs": 137.7999999821186,
"fullListIsInjected": true,
"domNodes": 11007,
"frameGapsMs": {
"median": 20,
"p95": 30.199999999999818,
"max": 40.19999999999982
},
"frames": 360,
"longTasks": [],
"scrollTop": 54000,
"scrollHeight": 115652,
"maxScrollTop": 81000,
"filteredCount": 334,
"expectedFilteredCount": 334,
"filterMs": 47.79999998211861,
"repeat": 1
},
{
"kind": "tasks",
"theme": "paper-moments",
"size": 100,
"initialRenderMs": 38.099999994039536,
"requests": [
"/api/tasks"
],
"initialTaskCount": 50,
"fullListRenderMs": 116.09999999403954,
"fullListIsInjected": true,
"domNodes": 1107,
"frameGapsMs": {
"median": 10,
"p95": 20,
"max": 59.900000000000034
},
"frames": 387,
"longTasks": [],
"scrollTop": 0,
"scrollHeight": 11764,
"maxScrollTop": 10907,
"filteredCount": 34,
"expectedFilteredCount": 34,
"filterMs": 11.600000023841858,
"repeat": 1
},
{
"kind": "tasks",
"theme": "paper-moments",
"size": 1000,
"initialRenderMs": 5.800000011920929,
"requests": [
"/api/tasks"
],
"initialTaskCount": 50,
"fullListRenderMs": 150,
"fullListIsInjected": true,
"domNodes": 11007,
"frameGapsMs": {
"median": 10.100000000000364,
"p95": 50.100000000000364,
"max": 80
},
"frames": 368,
"longTasks": [],
"scrollTop": 54000,
"scrollHeight": 115714,
"maxScrollTop": 81000,
"filteredCount": 334,
"expectedFilteredCount": 334,
"filterMs": 59.5,
"repeat": 1
},
{
"kind": "trace",
"theme": "light",
"size": 200,
"initialRenderMs": 75.10000002384186,
"requests": [],
"domNodes": 2092,
"scrollContainers": [
{
"node": "HTML",
"height": 905,
"scrollHeight": 905,
"overflow": "hidden",
"position": "static"
},
{
"node": "BODY",
"height": 905,
"scrollHeight": 905,
"overflow": "hidden",
"position": "static"
},
{
"node": "viewport",
"height": 905,
"scrollHeight": 18853,
"overflow": "auto",
"position": "static"
},
{
"node": "app",
"height": 18853,
"scrollHeight": 18853,
"overflow": "visible",
"position": "static"
},
{
"node": "trace-visualization",
"height": 18805,
"scrollHeight": 18805,
"overflow": "visible",
"position": "static"
},
{
"node": "timeline-view",
"height": 16692,
"scrollHeight": 16692,
"overflow": "visible",
"position": "static"
},
{
"node": "timeline",
"height": 16692,
"scrollHeight": 16692,
"overflow": "visible",
"position": "relative"
}
],
"frameGapsMs": {
"median": 10,
"p95": 10.100000000000364,
"max": 40.00000000000003
},
"frames": 411,
"longTasks": [],
"scrollTop": 0,
"scrollHeight": 18853,
"maxScrollTop": 17948,
"filteredDomNodes": 436,
"filteredCount": 44,
"expectedFilteredCount": 44,
"filterMs": 16,
"treeSwitchMs": 32.099999994039536,
"treeFilterMs": 27.900000005960464,
"treeFilteredDomNodes": 689,
"repeat": 1
},
{
"kind": "trace",
"theme": "light",
"size": 2000,
"initialRenderMs": 283.7999999821186,
"requests": [],
"domNodes": 20542,
"scrollContainers": [
{
"node": "HTML",
"height": 905,
"scrollHeight": 905,
"overflow": "hidden",
"position": "static"
},
{
"node": "BODY",
"height": 905,
"scrollHeight": 905,
"overflow": "hidden",
"position": "static"
},
{
"node": "viewport",
"height": 905,
"scrollHeight": 184678,
"overflow": "auto",
"position": "static"
},
{
"node": "app",
"height": 184678,
"scrollHeight": 184678,
"overflow": "visible",
"position": "static"
},
{
"node": "trace-visualization",
"height": 184630,
"scrollHeight": 184630,
"overflow": "visible",
"position": "static"
},
{
"node": "timeline-view",
"height": 166992,
"scrollHeight": 166992,
"overflow": "visible",
"position": "static"
},
{
"node": "timeline",
"height": 166992,
"scrollHeight": 166992,
"overflow": "visible",
"position": "relative"
}
],
"frameGapsMs": {
"median": 10,
"p95": 10.100000000000364,
"max": 20.100000000000023
},
"frames": 491,
"longTasks": [],
"scrollTop": 54000,
"scrollHeight": 184678,
"maxScrollTop": 81000,
"filteredDomNodes": 4036,
"filteredCount": 444,
"expectedFilteredCount": 444,
"filterMs": 97.40000000596046,
"treeSwitchMs": 64,
"treeFilterMs": 617.1999999880791,
"treeFilteredDomNodes": 6539,
"repeat": 1
},
{
"kind": "trace",
"theme": "dark",
"size": 200,
"initialRenderMs": 81.5,
"requests": [],
"domNodes": 2092,
"scrollContainers": [
{
"node": "HTML",
"height": 905,
"scrollHeight": 905,
"overflow": "hidden",
"position": "static"
},
{
"node": "BODY",
"height": 905,
"scrollHeight": 905,
"overflow": "hidden",
"position": "static"
},
{
"node": "viewport",
"height": 905,
"scrollHeight": 18853,
"overflow": "auto",
"position": "static"
},
{
"node": "app",
"height": 18853,
"scrollHeight": 18853,
"overflow": "visible",
"position": "static"
},
{
"node": "trace-visualization",
"height": 18805,
"scrollHeight": 18805,
"overflow": "visible",
"position": "static"
},
{
"node": "timeline-view",
"height": 16692,
"scrollHeight": 16692,
"overflow": "visible",
"position": "static"
},
{
"node": "timeline",
"height": 16692,
"scrollHeight": 16692,
"overflow": "visible",
"position": "relative"
}
],
"frameGapsMs": {
"median": 10,
"p95": 10.100000000000364,
"max": 30
},
"frames": 415,
"longTasks": [],
"scrollTop": 0,
"scrollHeight": 18853,
"maxScrollTop": 17948,
"filteredDomNodes": 436,
"filteredCount": 44,
"expectedFilteredCount": 44,
"filterMs": 16.69999998807907,
"treeSwitchMs": 31.099999994039536,
"treeFilterMs": 28.900000005960464,
"treeFilteredDomNodes": 689,
"repeat": 1
},
{
"kind": "trace",
"theme": "dark",
"size": 2000,
"initialRenderMs": 289,
"requests": [],
"domNodes": 20542,
"scrollContainers": [
{
"node": "HTML",
"height": 905,
"scrollHeight": 905,
"overflow": "hidden",
"position": "static"
},
{
"node": "BODY",
"height": 905,
"scrollHeight": 905,
"overflow": "hidden",
"position": "static"
},
{
"node": "viewport",
"height": 905,
"scrollHeight": 184678,
"overflow": "auto",
"position": "static"
},
{
"node": "app",
"height": 184678,
"scrollHeight": 184678,
"overflow": "visible",
"position": "static"
},
{
"node": "trace-visualization",
"height": 184630,
"scrollHeight": 184630,
"overflow": "visible",
"position": "static"
},
{
"node": "timeline-view",
"height": 166992,
"scrollHeight": 166992,
"overflow": "visible",
"position": "static"
},
{
"node": "timeline",
"height": 166992,
"scrollHeight": 166992,
"overflow": "visible",
"position": "relative"
}
],
"frameGapsMs": {
"median": 10,
"p95": 10.100000000000364,
"max": 20.200000000000045
},
"frames": 487,
"longTasks": [],
"scrollTop": 54000,
"scrollHeight": 184678,
"maxScrollTop": 81000,
"filteredDomNodes": 4036,
"filteredCount": 444,
"expectedFilteredCount": 444,
"filterMs": 74.5,
"treeSwitchMs": 62.900000005960464,
"treeFilterMs": 483,
"treeFilteredDomNodes": 6539,
"repeat": 1
},
{
"kind": "trace",
"theme": "paper-moments",
"size": 200,
"initialRenderMs": 119.59999999403954,
"requests": [],
"domNodes": 2092,
"scrollContainers": [
{
"node": "HTML",
"height": 905,
"scrollHeight": 905,
"overflow": "hidden",
"position": "static"
},
{
"node": "BODY",
"height": 905,
"scrollHeight": 905,
"overflow": "hidden",
"position": "static"
},
{
"node": "viewport",
"height": 905,
"scrollHeight": 18853,
"overflow": "auto",
"position": "static"
},
{
"node": "app",
"height": 18853,
"scrollHeight": 18853,
"overflow": "visible",
"position": "static"
},
{
"node": "trace-visualization",
"height": 18805,
"scrollHeight": 18805,
"overflow": "visible",
"position": "static"
},
{
"node": "timeline-view",
"height": 16692,
"scrollHeight": 16692,
"overflow": "visible",
"position": "static"
},
{
"node": "timeline",
"height": 16692,
"scrollHeight": 16692,
"overflow": "visible",
"position": "relative"
}
],
"frameGapsMs": {
"median": 10,
"p95": 20,
"max": 89.99999999999997
},
"frames": 394,
"longTasks": [],
"scrollTop": 0,
"scrollHeight": 18853,
"maxScrollTop": 17948,
"filteredDomNodes": 436,
"filteredCount": 44,
"expectedFilteredCount": 44,
"filterMs": 19.700000017881393,
"treeSwitchMs": 40.10000002384186,
"treeFilterMs": 19.899999976158142,
"treeFilteredDomNodes": 689,
"repeat": 1
},
{
"kind": "trace",
"theme": "paper-moments",
"size": 2000,
"initialRenderMs": 337.2999999821186,
"requests": [],
"domNodes": 20542,
"scrollContainers": [
{
"node": "HTML",
"height": 905,
"scrollHeight": 905,
"overflow": "hidden",
"position": "static"
},
{
"node": "BODY",
"height": 905,
"scrollHeight": 905,
"overflow": "hidden",
"position": "static"
},
{
"node": "viewport",
"height": 905,
"scrollHeight": 184678,
"overflow": "auto",
"position": "static"
},
{
"node": "app",
"height": 184678,
"scrollHeight": 184678,
"overflow": "visible",
"position": "static"
},
{
"node": "trace-visualization",
"height": 184630,
"scrollHeight": 184630,
"overflow": "visible",
"position": "static"
},
{
"node": "timeline-view",
"height": 166992,
"scrollHeight": 166992,
"overflow": "visible",
"position": "static"
},
{
"node": "timeline",
"height": 166992,
"scrollHeight": 166992,
"overflow": "visible",
"position": "relative"
}
],
"frameGapsMs": {
"median": 10,
"p95": 20.100000000000023,
"max": 30.100000000000023
},
"frames": 416,
"longTasks": [],
"scrollTop": 54000,
"scrollHeight": 184678,
"maxScrollTop": 81000,
"filteredDomNodes": 4036,
"filteredCount": 444,
"expectedFilteredCount": 444,
"filterMs": 86.2999999821186,
"treeSwitchMs": 62.099999994039536,
"treeFilterMs": 540.0999999940395,
"treeFilteredDomNodes": 6539,
"repeat": 1
},
{
"kind": "trace",
"theme": "paper-moments",
"size": 10000,
"initialRenderMs": 1803.9000000059605,
"requests": [],
"domNodes": 102542,
"scrollContainers": [
{
"node": "HTML",
"height": 905,
"scrollHeight": 905,
"overflow": "hidden",
"position": "static"
},
{
"node": "BODY",
"height": 905,
"scrollHeight": 905,
"overflow": "hidden",
"position": "static"
},
{
"node": "viewport",
"height": 905,
"scrollHeight": 921678,
"overflow": "auto",
"position": "static"
},
{
"node": "app",
"height": 921678,
"scrollHeight": 921678,
"overflow": "visible",
"position": "static"
},
{
"node": "trace-visualization",
"height": 921630,
"scrollHeight": 921630,
"overflow": "visible",
"position": "static"
},
{
"node": "timeline-view",
"height": 834992,
"scrollHeight": 834992,
"overflow": "visible",
"position": "static"
},
{
"node": "timeline",
"height": 834992,
"scrollHeight": 834992,
"overflow": "visible",
"position": "relative"
}
],
"frameGapsMs": {
"median": 20.100000000000364,
"p95": 40,
"max": 100
},
"frames": 357,
"longTasks": [
82,
53
],
"scrollTop": 54000,
"scrollHeight": 921678,
"maxScrollTop": 81000,
"filteredDomNodes": 40036,
"filteredCount": 4444,
"expectedFilteredCount": 4444,
"filterMs": 582.7999999821186,
"treeSwitchMs": 317.5,
"treeFilterMs": 25914.80000001192,
"treeFilteredDomNodes": 32539,
"repeat": 1
}
]
@@ -0,0 +1,156 @@
[
{
"kind": "trace",
"theme": "paper-moments",
"size": 2000,
"initialRenderMs": 117.19999998807907,
"requests": [],
"domNodes": 1852,
"scrollContainers": [
{
"node": "HTML",
"height": 905,
"scrollHeight": 905,
"overflow": "hidden",
"position": "static"
},
{
"node": "BODY",
"height": 905,
"scrollHeight": 905,
"overflow": "hidden",
"position": "static"
},
{
"node": "viewport",
"height": 905,
"scrollHeight": 17214,
"overflow": "auto",
"position": "static"
},
{
"node": "app",
"height": 17214,
"scrollHeight": 17214,
"overflow": "visible",
"position": "static"
},
{
"node": "trace-visualization",
"height": 17166,
"scrollHeight": 17166,
"overflow": "visible",
"position": "static"
},
{
"node": "timeline-view",
"height": 16692,
"scrollHeight": 16692,
"overflow": "visible",
"position": "static"
},
{
"node": "timeline",
"height": 16692,
"scrollHeight": 16692,
"overflow": "visible",
"position": "relative"
}
],
"frameGapsMs": {
"median": 10,
"p95": 10.100000000000364,
"max": 80.09999999999997
},
"frames": 408,
"longTasks": [],
"scrollTop": 0,
"scrollHeight": 17214,
"maxScrollTop": 16309,
"filteredDomNodes": 1844,
"filteredCount": 200,
"expectedFilteredCount": 200,
"filterMs": 71.90000000596046,
"treeSwitchMs": 51.400000005960464,
"treeFilterMs": 48.70000001788139,
"treeFilteredDomNodes": 1343,
"repeat": 1
},
{
"kind": "trace",
"theme": "paper-moments",
"size": 10000,
"initialRenderMs": 94.40000000596046,
"requests": [],
"domNodes": 1852,
"scrollContainers": [
{
"node": "HTML",
"height": 905,
"scrollHeight": 905,
"overflow": "hidden",
"position": "static"
},
{
"node": "BODY",
"height": 905,
"scrollHeight": 905,
"overflow": "hidden",
"position": "static"
},
{
"node": "viewport",
"height": 905,
"scrollHeight": 17214,
"overflow": "auto",
"position": "static"
},
{
"node": "app",
"height": 17214,
"scrollHeight": 17214,
"overflow": "visible",
"position": "static"
},
{
"node": "trace-visualization",
"height": 17166,
"scrollHeight": 17166,
"overflow": "visible",
"position": "static"
},
{
"node": "timeline-view",
"height": 16692,
"scrollHeight": 16692,
"overflow": "visible",
"position": "static"
},
{
"node": "timeline",
"height": 16692,
"scrollHeight": 16692,
"overflow": "visible",
"position": "relative"
}
],
"frameGapsMs": {
"median": 10,
"p95": 10.100000000000364,
"max": 50.09999999999991
},
"frames": 414,
"longTasks": [],
"scrollTop": 0,
"scrollHeight": 17214,
"maxScrollTop": 16309,
"filteredDomNodes": 1844,
"filteredCount": 200,
"expectedFilteredCount": 200,
"filterMs": 284.90000000596046,
"treeSwitchMs": 100.90000000596046,
"treeFilterMs": 189.19999998807907,
"treeFilteredDomNodes": 1343,
"repeat": 1
}
]
@@ -0,0 +1,380 @@
[
{
"requestedHan": 25000,
"hanCharacters": 25632,
"sourceCharacters": 35728,
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36",
"viewport": [
1424,
905
],
"openMs": 379.09999999403954,
"domNodes": 2110,
"selectionMs": {
"median": 0.4000000059604645,
"p95": 0.7000000178813934,
"max": 1.300000011920929
},
"insertMs": {
"median": 6.300000011920929,
"p95": 9.599999994039536,
"max": 10.400000005960464
},
"foldMs": {
"median": 42.900000005960464,
"p95": 45.599999994039536,
"max": 45.599999994039536
},
"integrity": true,
"previewMs": [
176.30000001192093,
22.69999998807907,
19.80000001192093
],
"previewNodes": 1058,
"longTasks": {
"count": 4,
"median": 91,
"p95": 359,
"max": 359
},
"heapUsedBytes": 36560189,
"repeat": 1
},
{
"requestedHan": 25000,
"hanCharacters": 25632,
"sourceCharacters": 35728,
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36",
"viewport": [
1424,
905
],
"openMs": 218.59999999403954,
"domNodes": 2110,
"selectionMs": {
"median": 0.29999998211860657,
"p95": 0.5,
"max": 0.5
},
"insertMs": {
"median": 4.799999982118607,
"p95": 6.9000000059604645,
"max": 7.699999988079071
},
"foldMs": {
"median": 45.30000001192093,
"p95": 79,
"max": 79
},
"integrity": true,
"previewMs": [
112.90000000596046,
27.80000001192093,
25.399999976158142
],
"previewNodes": 1058,
"longTasks": {
"count": 5,
"median": 65,
"p95": 198,
"max": 198
},
"heapUsedBytes": 78394355,
"repeat": 2
},
{
"requestedHan": 25000,
"hanCharacters": 25632,
"sourceCharacters": 35728,
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36",
"viewport": [
1424,
905
],
"openMs": 207.59999999403954,
"domNodes": 2110,
"selectionMs": {
"median": 0.4000000059604645,
"p95": 0.5999999940395355,
"max": 0.699999988079071
},
"insertMs": {
"median": 4.600000023841858,
"p95": 5.9000000059604645,
"max": 6.800000011920929
},
"foldMs": {
"median": 35.900000005960464,
"p95": 45.400000005960464,
"max": 45.400000005960464
},
"integrity": true,
"previewMs": [
98.30000001192093,
29.69999998807907,
29.80000001192093
],
"previewNodes": 1058,
"longTasks": {
"count": 4,
"median": 59,
"p95": 184,
"max": 184
},
"heapUsedBytes": 72765231,
"repeat": 3
},
{
"requestedHan": 60000,
"hanCharacters": 61172,
"sourceCharacters": 85716,
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36",
"viewport": [
1424,
905
],
"openMs": 394.30000001192093,
"domNodes": 4720,
"selectionMs": {
"median": 0.4000000059604645,
"p95": 0.6000000238418579,
"max": 0.699999988079071
},
"insertMs": {
"median": 6.300000011920929,
"p95": 7.799999982118607,
"max": 13.599999994039536
},
"foldMs": {
"median": 95.80000001192093,
"p95": 145.5,
"max": 145.5
},
"integrity": true,
"previewMs": [
180.59999999403954,
61.20000001788139,
50
],
"previewNodes": 2560,
"longTasks": {
"count": 10,
"median": 103,
"p95": 358,
"max": 358
},
"heapUsedBytes": 73234300,
"repeat": 1
},
{
"requestedHan": 60000,
"hanCharacters": 61172,
"sourceCharacters": 85716,
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36",
"viewport": [
1424,
905
],
"openMs": 447.5,
"domNodes": 4720,
"selectionMs": {
"median": 0.4000000059604645,
"p95": 0.5999999940395355,
"max": 0.5999999940395355
},
"insertMs": {
"median": 6.699999988079071,
"p95": 12.699999988079071,
"max": 14.5
},
"foldMs": {
"median": 99.5,
"p95": 110.40000000596046,
"max": 110.40000000596046
},
"integrity": true,
"previewMs": [
153,
44.900000005960464,
49.400000005960464
],
"previewNodes": 2560,
"longTasks": {
"count": 9,
"median": 113,
"p95": 410,
"max": 410
},
"heapUsedBytes": 97026266,
"repeat": 2
},
{
"requestedHan": 60000,
"hanCharacters": 61172,
"sourceCharacters": 85716,
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36",
"viewport": [
1424,
905
],
"openMs": 492.69999998807907,
"domNodes": 4720,
"selectionMs": {
"median": 0.5,
"p95": 0.6000000238418579,
"max": 1
},
"insertMs": {
"median": 7.199999988079071,
"p95": 9.599999994039536,
"max": 10.100000023841858
},
"foldMs": {
"median": 115.80000001192093,
"p95": 149.7000000178814,
"max": 149.7000000178814
},
"integrity": true,
"previewMs": [
141.39999997615814,
67.5,
61.900000005960464
],
"previewNodes": 2560,
"longTasks": {
"count": 11,
"median": 109,
"p95": 445,
"max": 445
},
"heapUsedBytes": 88746335,
"repeat": 3
},
{
"requestedHan": 120000,
"hanCharacters": 122322,
"sourceCharacters": 171613,
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36",
"viewport": [
1424,
905
],
"openMs": 893.4000000059605,
"domNodes": 9139,
"selectionMs": {
"median": 0.7000000178813934,
"p95": 1.2000000178813934,
"max": 2.399999976158142
},
"insertMs": {
"median": 18,
"p95": 28.900000005960464,
"max": 31.5
},
"foldMs": {
"median": 217,
"p95": 292.90000000596046,
"max": 292.90000000596046
},
"integrity": true,
"previewMs": [
203.10000002384186,
98.7999999821186,
92.30000001192093
],
"previewNodes": 5117,
"longTasks": {
"count": 13,
"median": 199,
"p95": 796,
"max": 796
},
"heapUsedBytes": 113890741,
"repeat": 1
},
{
"requestedHan": 120000,
"hanCharacters": 122322,
"sourceCharacters": 171613,
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36",
"viewport": [
1424,
905
],
"openMs": 869.0999999940395,
"domNodes": 9139,
"selectionMs": {
"median": 0.7000000178813934,
"p95": 1.300000011920929,
"max": 3.4000000059604645
},
"insertMs": {
"median": 16.69999998807907,
"p95": 22.599999994039536,
"max": 22.80000001192093
},
"foldMs": {
"median": 220.5,
"p95": 263.09999999403954,
"max": 263.09999999403954
},
"integrity": true,
"previewMs": [
207.39999997615814,
98.7000000178814,
90.2999999821186
],
"previewNodes": 5117,
"longTasks": {
"count": 14,
"median": 205,
"p95": 780,
"max": 780
},
"heapUsedBytes": 125636820,
"repeat": 2
},
{
"requestedHan": 120000,
"hanCharacters": 122322,
"sourceCharacters": 171613,
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36",
"viewport": [
1424,
905
],
"openMs": 845.9000000059605,
"domNodes": 9139,
"selectionMs": {
"median": 0.699999988079071,
"p95": 1.0999999940395355,
"max": 2.9000000059604645
},
"insertMs": {
"median": 18.69999998807907,
"p95": 27.19999998807907,
"max": 35
},
"foldMs": {
"median": 205.7000000178814,
"p95": 263.90000000596046,
"max": 263.90000000596046
},
"integrity": true,
"previewMs": [
213.09999999403954,
100,
91.5
],
"previewNodes": 5117,
"longTasks": {
"count": 13,
"median": 191,
"p95": 768,
"max": 768
},
"heapUsedBytes": 86940861,
"repeat": 3
}
]
@@ -0,0 +1,380 @@
[
{
"requestedHan": 25000,
"hanCharacters": 25632,
"sourceCharacters": 35728,
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36",
"viewport": [
1424,
905
],
"openMs": 398,
"domNodes": 2110,
"selectionMs": {
"median": 1.0999999940395355,
"p95": 1.9000000059604645,
"max": 1.9000000059604645
},
"insertMs": {
"median": 5.300000011920929,
"p95": 8.100000023841858,
"max": 8.800000011920929
},
"foldMs": {
"median": 58.70000001788139,
"p95": 70.19999998807907,
"max": 70.19999998807907
},
"integrity": true,
"previewMs": [
230.7999999821186,
45.5,
39.70000001788139
],
"previewNodes": 1058,
"longTasks": {
"count": 5,
"median": 84,
"p95": 372,
"max": 372
},
"heapUsedBytes": 42428845,
"repeat": 1
},
{
"requestedHan": 25000,
"hanCharacters": 25632,
"sourceCharacters": 35728,
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36",
"viewport": [
1424,
905
],
"openMs": 272.09999999403954,
"domNodes": 2110,
"selectionMs": {
"median": 1,
"p95": 2.0999999940395355,
"max": 2.399999976158142
},
"insertMs": {
"median": 5.699999988079071,
"p95": 7,
"max": 7.300000011920929
},
"foldMs": {
"median": 48.900000005960464,
"p95": 55,
"max": 55
},
"integrity": true,
"previewMs": [
157,
37.69999998807907,
40
],
"previewNodes": 1058,
"longTasks": {
"count": 6,
"median": 63,
"p95": 245,
"max": 245
},
"heapUsedBytes": 62409024,
"repeat": 2
},
{
"requestedHan": 25000,
"hanCharacters": 25632,
"sourceCharacters": 35728,
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36",
"viewport": [
1424,
905
],
"openMs": 224.90000000596046,
"domNodes": 2110,
"selectionMs": {
"median": 0.7000000178813934,
"p95": 1.4000000059604645,
"max": 2
},
"insertMs": {
"median": 4.399999976158142,
"p95": 6.4000000059604645,
"max": 8
},
"foldMs": {
"median": 39.099999994039536,
"p95": 44.10000002384186,
"max": 44.10000002384186
},
"integrity": true,
"previewMs": [
139,
46.099999994039536,
39.900000005960464
],
"previewNodes": 1058,
"longTasks": {
"count": 4,
"median": 92,
"p95": 200,
"max": 200
},
"heapUsedBytes": 35903770,
"repeat": 3
},
{
"requestedHan": 60000,
"hanCharacters": 61172,
"sourceCharacters": 85716,
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36",
"viewport": [
1424,
905
],
"openMs": 458.39999997615814,
"domNodes": 4720,
"selectionMs": {
"median": 2.9000000059604645,
"p95": 5.699999988079071,
"max": 6.0999999940395355
},
"insertMs": {
"median": 7.5999999940395355,
"p95": 9.699999988079071,
"max": 12.400000005960464
},
"foldMs": {
"median": 120.30000001192093,
"p95": 160.09999999403954,
"max": 160.09999999403954
},
"integrity": true,
"previewMs": [
173.5,
65.60000002384186,
70.09999999403954
],
"previewNodes": 2560,
"longTasks": {
"count": 12,
"median": 110,
"p95": 415,
"max": 415
},
"heapUsedBytes": 60644006,
"repeat": 1
},
{
"requestedHan": 60000,
"hanCharacters": 61172,
"sourceCharacters": 85716,
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36",
"viewport": [
1424,
905
],
"openMs": 649.6999999880791,
"domNodes": 4720,
"selectionMs": {
"median": 2.699999988079071,
"p95": 5.699999988079071,
"max": 5.800000011920929
},
"insertMs": {
"median": 7.5,
"p95": 9.099999994039536,
"max": 10.699999988079071
},
"foldMs": {
"median": 116,
"p95": 149.09999999403954,
"max": 149.09999999403954
},
"integrity": true,
"previewMs": [
209.30000001192093,
80.90000000596046,
88.7999999821186
],
"previewNodes": 2560,
"longTasks": {
"count": 12,
"median": 116,
"p95": 586,
"max": 586
},
"heapUsedBytes": 64379838,
"repeat": 2
},
{
"requestedHan": 60000,
"hanCharacters": 61172,
"sourceCharacters": 85716,
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36",
"viewport": [
1424,
905
],
"openMs": 519.7000000178814,
"domNodes": 4720,
"selectionMs": {
"median": 2.9000000059604645,
"p95": 4.9000000059604645,
"max": 5.5
},
"insertMs": {
"median": 9.200000017881393,
"p95": 11.700000017881393,
"max": 11.900000005960464
},
"foldMs": {
"median": 118.40000000596046,
"p95": 134.5,
"max": 134.5
},
"integrity": true,
"previewMs": [
198.5,
84.80000001192093,
91.59999999403954
],
"previewNodes": 2560,
"longTasks": {
"count": 11,
"median": 119,
"p95": 469,
"max": 469
},
"heapUsedBytes": 43218088,
"repeat": 3
},
{
"requestedHan": 120000,
"hanCharacters": 122322,
"sourceCharacters": 171613,
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36",
"viewport": [
1424,
905
],
"openMs": 894.6000000238419,
"domNodes": 9139,
"selectionMs": {
"median": 7.9000000059604645,
"p95": 11.599999994039536,
"max": 24.700000017881393
},
"insertMs": {
"median": 18.19999998807907,
"p95": 22.19999998807907,
"max": 30.799999982118607
},
"foldMs": {
"median": 247.59999999403954,
"p95": 256.59999999403954,
"max": 256.59999999403954
},
"integrity": true,
"previewMs": [
294.2999999821186,
134.09999999403954,
129.90000000596046
],
"previewNodes": 5117,
"longTasks": {
"count": 14,
"median": 207,
"p95": 821,
"max": 821
},
"heapUsedBytes": 60930415,
"repeat": 1
},
{
"requestedHan": 120000,
"hanCharacters": 122322,
"sourceCharacters": 171613,
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36",
"viewport": [
1424,
905
],
"openMs": 875.3999999761581,
"domNodes": 9139,
"selectionMs": {
"median": 9.199999988079071,
"p95": 15.199999988079071,
"max": 15.900000005960464
},
"insertMs": {
"median": 20.599999994039536,
"p95": 23.19999998807907,
"max": 26.5
},
"foldMs": {
"median": 244.09999999403954,
"p95": 269,
"max": 269
},
"integrity": true,
"previewMs": [
272.2999999821186,
146.30000001192093,
157.40000000596046
],
"previewNodes": 5117,
"longTasks": {
"count": 14,
"median": 192,
"p95": 785,
"max": 785
},
"heapUsedBytes": 31982024,
"repeat": 2
},
{
"requestedHan": 120000,
"hanCharacters": 122322,
"sourceCharacters": 171613,
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36",
"viewport": [
1424,
905
],
"openMs": 842.3000000119209,
"domNodes": 9139,
"selectionMs": {
"median": 8,
"p95": 11.900000005960464,
"max": 15.199999988079071
},
"insertMs": {
"median": 18.900000005960464,
"p95": 20.099999994039536,
"max": 21.700000017881393
},
"foldMs": {
"median": 212.5,
"p95": 240.5,
"max": 240.5
},
"integrity": true,
"previewMs": [
253.7000000178814,
129.19999998807907,
172
],
"previewNodes": 5117,
"longTasks": {
"count": 14,
"median": 191,
"p95": 762,
"max": 762
},
"heapUsedBytes": 49460811,
"repeat": 3
}
]
@@ -0,0 +1,122 @@
[
{
"requestedHan": 25000,
"theme": "paper-moments",
"frameGapsMs": {
"median": 10,
"p95": 20.100000000000364,
"max": 110.09999999999991
},
"frames": 380,
"framesOver25ms": 14,
"longTasks": [
97
],
"scrollTop": 6111,
"scrollHeight": 33908,
"foldedScrollTop": 0,
"caretAfterFold": 1,
"repeat": 1
},
{
"requestedHan": 25000,
"theme": "paper-moments",
"frameGapsMs": {
"median": 10,
"p95": 20.100000000000136,
"max": 50
},
"frames": 374,
"framesOver25ms": 15,
"longTasks": [
57
],
"scrollTop": 6111,
"scrollHeight": 33908,
"foldedScrollTop": 0,
"caretAfterFold": 1,
"repeat": 2
},
{
"requestedHan": 60000,
"theme": "paper-moments",
"frameGapsMs": {
"median": 30.100000000000364,
"p95": 50,
"max": 100
},
"frames": 267,
"framesOver25ms": 210,
"longTasks": [
81,
84
],
"scrollTop": 52492,
"scrollHeight": 80287,
"foldedScrollTop": 0,
"caretAfterFold": 1,
"repeat": 1
},
{
"requestedHan": 60000,
"theme": "paper-moments",
"frameGapsMs": {
"median": 30,
"p95": 40.100000000000364,
"max": 100
},
"frames": 279,
"framesOver25ms": 213,
"longTasks": [
77,
59
],
"scrollTop": 52493,
"scrollHeight": 80287,
"foldedScrollTop": 0,
"caretAfterFold": 1,
"repeat": 2
},
{
"requestedHan": 120000,
"theme": "paper-moments",
"frameGapsMs": {
"median": 70,
"p95": 100,
"max": 130
},
"frames": 264,
"framesOver25ms": 229,
"longTasks": [
84,
52,
89,
58
],
"scrollTop": 53157,
"scrollHeight": 159554,
"foldedScrollTop": 0,
"caretAfterFold": 1,
"repeat": 1
},
{
"requestedHan": 120000,
"theme": "paper-moments",
"frameGapsMs": {
"median": 70.09999999999854,
"p95": 100,
"max": 130.10000000000036
},
"frames": 261,
"framesOver25ms": 230,
"longTasks": [
83,
53
],
"scrollTop": 53195,
"scrollHeight": 159554,
"foldedScrollTop": 0,
"caretAfterFold": 1,
"repeat": 2
}
]
@@ -0,0 +1,119 @@
[
{
"requestedHan": 25000,
"theme": "paper-moments",
"frameGapsMs": {
"median": 10,
"p95": 30.09999999999991,
"max": 120.10000000000002
},
"frames": 356,
"framesOver25ms": 45,
"longTasks": [
116
],
"scrollTop": 6075,
"scrollHeight": 33871,
"foldedScrollTop": 0,
"caretAfterFold": 1,
"repeat": 1
},
{
"requestedHan": 25000,
"theme": "paper-moments",
"frameGapsMs": {
"median": 10,
"p95": 30,
"max": 80.10000000000002
},
"frames": 369,
"framesOver25ms": 29,
"longTasks": [
93
],
"scrollTop": 6111,
"scrollHeight": 33908,
"foldedScrollTop": 0,
"caretAfterFold": 1,
"repeat": 2
},
{
"requestedHan": 60000,
"theme": "paper-moments",
"frameGapsMs": {
"median": 40,
"p95": 59.900000000001455,
"max": 100.09999999999991
},
"frames": 264,
"framesOver25ms": 221,
"longTasks": [
92
],
"scrollTop": 52345,
"scrollHeight": 80177,
"foldedScrollTop": 0,
"caretAfterFold": 1,
"repeat": 1
},
{
"requestedHan": 60000,
"theme": "paper-moments",
"frameGapsMs": {
"median": 40,
"p95": 50.100000000000364,
"max": 130.10000000000036
},
"frames": 265,
"framesOver25ms": 229,
"longTasks": [
84,
109
],
"scrollTop": 52344,
"scrollHeight": 80177,
"foldedScrollTop": 0,
"caretAfterFold": 1,
"repeat": 2
},
{
"requestedHan": 120000,
"theme": "paper-moments",
"frameGapsMs": {
"median": 70,
"p95": 90.10000000000036,
"max": 170.19999999999982
},
"frames": 260,
"framesOver25ms": 235,
"longTasks": [
89,
77
],
"scrollTop": 53200,
"scrollHeight": 159554,
"foldedScrollTop": 0,
"caretAfterFold": 1,
"repeat": 1
},
{
"requestedHan": 120000,
"theme": "paper-moments",
"frameGapsMs": {
"median": 70,
"p95": 90.10000000000036,
"max": 140.10000000000036
},
"frames": 259,
"framesOver25ms": 228,
"longTasks": [
92,
57
],
"scrollTop": 53196,
"scrollHeight": 159554,
"foldedScrollTop": 0,
"caretAfterFold": 1,
"repeat": 2
}
]
@@ -0,0 +1,150 @@
{
"light": {
"requestedHan": 120000,
"theme": "light",
"variant": "default",
"frameGapsMs": {
"median": 10,
"p95": 30,
"max": 90.09999999999991
},
"frames": 403,
"framesOver25ms": 29,
"longTasks": [
89
],
"scrollTop": 54000,
"scrollHeight": 168515,
"foldedScrollTop": 0,
"caretAfterFold": 1,
"repeat": 1
},
"dark": {
"requestedHan": 120000,
"theme": "dark",
"variant": "default",
"frameGapsMs": {
"median": 10,
"p95": 30,
"max": 90
},
"frames": 408,
"framesOver25ms": 27,
"longTasks": [
88
],
"scrollTop": 54000,
"scrollHeight": 168515,
"foldedScrollTop": 0,
"caretAfterFold": 1,
"repeat": 1
},
"fixed": [
{
"requestedHan": 120000,
"theme": "paper-moments",
"variant": "default",
"frameGapsMs": {
"median": 10,
"p95": 30.100000000000136,
"max": 100
},
"frames": 422,
"framesOver25ms": 31,
"longTasks": [
99
],
"scrollTop": 54037,
"scrollHeight": 160247,
"foldedScrollTop": 0,
"caretAfterFold": 1,
"repeat": 1
},
{
"requestedHan": 120000,
"theme": "paper-moments",
"variant": "default",
"frameGapsMs": {
"median": 10,
"p95": 30,
"max": 90
},
"frames": 427,
"framesOver25ms": 31,
"longTasks": [
84,
58
],
"scrollTop": 54000,
"scrollHeight": 160211,
"foldedScrollTop": 0,
"caretAfterFold": 1,
"repeat": 2
},
{
"requestedHan": 120000,
"theme": "paper-moments",
"variant": "default",
"frameGapsMs": {
"median": 10,
"p95": 30.09999999999991,
"max": 90.09999999999991
},
"frames": 422,
"framesOver25ms": 29,
"longTasks": [
85
],
"scrollTop": 54037,
"scrollHeight": 160247,
"foldedScrollTop": 0,
"caretAfterFold": 1,
"repeat": 3
}
],
"outlineDisabled": [
{
"requestedHan": 120000,
"theme": "paper-moments",
"variant": "no-outline",
"frameGapsMs": {
"median": 10,
"p95": 30.099999999999454,
"max": 100
},
"frames": 418,
"framesOver25ms": 31,
"longTasks": [
101
],
"scrollTop": 54037,
"scrollHeight": 160247,
"foldedScrollTop": 0,
"caretAfterFold": 1,
"repeat": 1
},
{
"requestedHan": 120000,
"theme": "paper-moments",
"variant": "no-outline",
"frameGapsMs": {
"median": 10,
"p95": 30,
"max": 99.89999999999964
},
"frames": 413,
"framesOver25ms": 32,
"longTasks": [
87,
50,
52,
52
],
"scrollTop": 53965,
"scrollHeight": 160174,
"foldedScrollTop": 0,
"caretAfterFold": 1,
"repeat": 2
}
]
}
@@ -0,0 +1,66 @@
[
{
"requestedHan": 120000,
"theme": "paper-moments",
"frameGapsMs": {
"median": 70.29999999999927,
"p95": 100.20000000000073,
"max": 210.20000000000073
},
"frames": 308,
"framesOver25ms": 286,
"longTasks": [
86,
54,
56,
62,
50,
58,
53,
52,
58,
100,
107,
96,
60,
99,
89,
136,
101,
98,
101,
90,
66,
120,
74,
99,
58
],
"scrollTop": 53127,
"scrollHeight": 159554,
"foldedScrollTop": 0,
"caretAfterFold": 1,
"repeat": 1
},
{
"requestedHan": 120000,
"theme": "paper-moments",
"frameGapsMs": {
"median": 70,
"p95": 90,
"max": 150
},
"frames": 261,
"framesOver25ms": 235,
"longTasks": [
84,
67,
52
],
"scrollTop": 53233,
"scrollHeight": 159554,
"foldedScrollTop": 0,
"caretAfterFold": 1,
"repeat": 2
}
]
@@ -0,0 +1,29 @@
{
"blocks": 4000,
"dimensions": 384,
"top_k": 20,
"migration_ms": 1329.5076999929734,
"same_top_k": true,
"measurements": {
"python_json_scan": {
"median_ms": 1289.638800022658,
"samples_ms": [
1289.638800022658,
1238.6701999930665,
1115.6752999813762,
1436.7559000093024,
1833.9683999947738
]
},
"sqlite_vec": {
"median_ms": 19.673499977216125,
"samples_ms": [
29.709700000239536,
17.876600002637133,
35.13619999284856,
19.673499977216125,
18.28439999371767
]
}
}
}
@@ -0,0 +1,36 @@
{
"transport": "real loopback HTTP, separate Uvicorn process",
"tasks": 1000,
"concurrency": 20,
"elapsed_ms": 24509.81,
"latencies": {
"create": {
"count": 1000,
"p95_ms": 157.68,
"max_ms": 186.7
},
"update": {
"count": 1000,
"p95_ms": 208.68,
"max_ms": 259.91
},
"list": {
"count": 11,
"p95_ms": 23.39,
"max_ms": 23.39
},
"delete": {
"count": 1000,
"p95_ms": 205.45,
"max_ms": 279.12
},
"health": {
"count": 375,
"p95_ms": 38.64,
"max_ms": 164.76
}
},
"health_errors": [],
"pagination_complete": true,
"final_total": 0
}
@@ -0,0 +1,36 @@
{
"transport": "real loopback HTTP, separate Uvicorn process",
"tasks": 1000,
"concurrency": 20,
"elapsed_ms": 18312.92,
"latencies": {
"create": {
"count": 1000,
"p95_ms": 116.45,
"max_ms": 134.91
},
"update": {
"count": 1000,
"p95_ms": 159.29,
"max_ms": 193.37
},
"list": {
"count": 11,
"p95_ms": 20.96,
"max_ms": 20.96
},
"delete": {
"count": 1000,
"p95_ms": 156.43,
"max_ms": 190.1
},
"health": {
"count": 117,
"p95_ms": 157.0,
"max_ms": 195.07
}
},
"health_errors": [],
"pagination_complete": true,
"final_total": 0
}
@@ -0,0 +1,70 @@
[
{
"kind": "tasks",
"theme": "paper-moments",
"size": 1000,
"initialRenderMs": 173,
"requests": [
"/api/tasks?limit=100&offset=0",
"/api/tasks?limit=100&offset=100",
"/api/tasks?limit=100&offset=200",
"/api/tasks?limit=100&offset=300",
"/api/tasks?limit=100&offset=400",
"/api/tasks?limit=100&offset=500",
"/api/tasks?limit=100&offset=600",
"/api/tasks?limit=100&offset=700",
"/api/tasks?limit=100&offset=800",
"/api/tasks?limit=100&offset=900"
],
"initialTaskCount": 1000,
"fullListRenderMs": 85.39999997615814,
"fullListIsInjected": true,
"renderedTasks": 100,
"domNodes": 1111,
"scrollContainers": [
{
"node": "HTML",
"height": 905,
"scrollHeight": 905,
"overflow": "hidden",
"position": "static"
},
{
"node": "BODY",
"height": 905,
"scrollHeight": 905,
"overflow": "hidden",
"position": "static"
},
{
"node": "viewport",
"height": 905,
"scrollHeight": 905,
"overflow": "auto",
"position": "static"
},
{
"node": "app",
"height": 905,
"scrollHeight": 905,
"overflow": "hidden",
"position": "static"
}
],
"frameGapsMs": {
"median": 10,
"p95": 30,
"max": 50.10000000000002
},
"frames": 384,
"longTasks": [],
"scrollTop": 0,
"scrollHeight": 11798,
"maxScrollTop": 10941,
"filteredCount": 100,
"totalFilteredCount": 334,
"expectedFilteredCount": 100,
"filterMs": 18.69999998807907,
"repeat": 1
}
]
@@ -1,5 +1,28 @@
# 主题组件覆盖检查(2026-09-05)
## 2026-09-06Markdown 行为与文档滚动适配
| 主题 | 当前版本 |
| --- | --- |
| Light / Dark / Sepia | 1.4.0 |
| 纸间时光 | 1.9.0 |
| Ocean Blue | 1.6.0 |
| Midnight Purple | 2.4.0 |
新增共享 `frontend/src/styles/markdown-behavior.css`,由应用入口及隔离主题预览同时加载。六个主题通过语义变量提供链接悬停与键盘焦点、任务复选框、引用与分隔线、文字选区、表格选区、标题折叠状态、警告框折叠状态和文档滚动按钮配色。标题箭头沿用悬停或键盘聚焦时显示的规则;表格选区采用透明叠色,避免遮住单元格文字。纸间时光保留长文轻量背景实现。
只读 Markdown(包括 AI 对话)现在同步代码行号、自动换行和缩进宽度设置。语法是否启用仍由 Markdown 预设与解析器决定;ATX / Setext 等源码写法解析成同级标题后使用相同主题样式,不用 CSS 模拟语法。主题预览新增 H4–H6、嵌套列表、任务列表、链接、行内代码、折叠警告框、带行号长代码及滚动按钮样例。
工作区滚动按钮固定在编辑区域右下角,适用于写作和源码模式:顶部只显示到底部、底部只显示到顶部、中间同时显示;空文档或没有可滚动内容时隐藏。滚动状态通过被动监听与动画帧合并更新,尺寸、内容和折叠变化会重新计算,减少动画偏好开启时直接跳转。
验证方法:
1. 运行 `cd frontend` 后执行 `npm test``npm run build`,检查主题变量覆盖、六主题预览矩阵、主题升级、Markdown 预设和滚动按钮回归。
2. 本地启动前端,访问 `tests/visual/index.html?theme=light`,依次切换表中六个主题,检查新增样例的链接焦点、复选框、折叠态、代码换行及行号。社区主题已安装旧版本时,应在主题页更新后再检查。
3. 工作区分别打开空笔记、短笔记、长笔记,切换写作与源码模式,在顶部、中间、底部验证按钮数量和跳转方向;调整窗口尺寸、折叠标题后再次检查。
本次自动检查覆盖仓库内六主题的结构和样式变量,不包含用户自行导入的任意第三方 CSS,也不等同于所有浏览器的逐页视觉验收。
本次检查仓库内 3 个内置主题和 3 个社区预设,共扫描 105 个前端源文件的组件与语义样式变量。用户自行导入的第三方 CSS 不在仓库中,不据此声称已验收。
## 范围与结果
@@ -0,0 +1,43 @@
# 前端构建分块优化开发说明
> 更新日期:2026-09-06。基于 main 的 64af068 进行构建对比;以下结果为本地生产构建,不是网络耗时或性能评分。
## 当前方案
- Mermaid 由静态导入改成第一次渲染或校验图表时动态导入。复用加载 Promise,加载失败清除缓存以允许重试,主题初始化与渲染继续串行执行。
- CodeMirror 基础模块、ProseMirror 和 Milkdown 分组缓存。仅显式选中的模块进入手动分组,不吸收所有传递依赖。
- CodeMirror 语言解析器、全部 Shiki 语法和 Mermaid 图型继续按需加载。不能将所有语言打入 editor vendor,否则会反而增加编辑器的首次加载量。
- KaTeX 按实际安装版本分组,避免合并项目版本和依赖内版本。不强制升级第三方依赖的数学解析器。
- 启用 Vite manifest,并提供 build:report 脚本用于持续比较入口静态依赖。
## 构建观察
数值按十进制 kB 计算。静态闭包包括入口 JS 与递归 imports,去重后求和;不包含 CSS、字体、运行时动态导入或浏览器缓存。gzip 为每个 JS 文件压缩后求和,不代表服务器必然启用该压缩。
| 检查项 | 优化前 | 优化后 |
| --- | --- | --- |
| 聊天页静态 JS 闭包 | 约 1563.5 kB | 约 894 kB |
| 聊天页闭包 gzip | 约 451.6 kB | 约 292 kB |
| VisualMarkdownEditor 单包 | 约 1145.8 kB | 约 20 kB,核心依赖转入独立块 |
| 首屏静态 JS 闭包 | 约 409.7 kB | 约 409 kB,基本不变 |
组件单包缩小不等于整个编辑器只需要 20 kB。编辑器仍需要加载基础框架、核心依赖和实际用到的语法。此次主要减少普通 Markdown 页对 Mermaid 的提前加载,并改善模块缓存边界;未测量真实启动耗时,不能声称首屏提速比例。
## 验证方法
在 frontend 目录执行:
```powershell
pnpm build
pnpm build:report
pnpm test
pnpm exec vite preview --host 127.0.0.1 --port 4173
```
build:report 读取 dist/.vite/manifest.json,输出入口闭包大小与最大的 15 个 JS 块。对比时保存相同构建环境的两份输出;更新依赖后需重新测量。
本轮前端 345 项测试通过,类型检查及生产构建通过。已在生产预览打开工作区和真实 Mermaid 示例笔记,确认编辑器与 SVG 加载,未发现控制台 error。Shiki 全语言覆盖仍由既有语言测试检查。
## 保留的大块与边界
C++、Emacs Lisp 等语法、Oniguruma WASM、部分 Mermaid 图型及 Mermaid 核心仍可能超过 500 kB。这些资源保留按需加载,不裁减语言支持,也不提高警告阈值来隐藏问题。首次打开复杂图表或对应语言仍有加载成本;后续可基于真实请求和设备数据评估 Worker、资源预热及库版本升级。
@@ -0,0 +1,101 @@
# 后台运行日志与压力问题修复
## 1. 范围与入口
本次针对 Agent 与任务压测发现的历史记录截断、同步数据库写入阻塞事件循环、长 Trace 树形筛选过慢进行修复,并增加统一运行日志。
主导航的「日志」打开 `/logs`。知识库选择页也有入口;无需成功打开 Vault 即可查看。需要 AI Core 正常提供 HTTP 服务。后台未启动时,页面显示连接错误,不伪造历史结果。
页面提供级别、模块、事件名/错误码/关联 ID 筛选及详情展开。日志列表高度受限,按时间从旧到新显示,默认每 5 秒获取新日志并跟随到底部。向上滚动时暂停跟随,抵达顶部自动加载更早日志并保持阅读位置;可一键回到最新。隐藏页面和卸载后停止轮询。界面最多保留当前浏览窗口的 500 条,继续读取旧日志时移出另一端的记录,后台保留窗口不受影响。所有组件使用已有主题变量、卡片、按钮、下拉框及 `ui-disclosure` 展开样式。
## 2. 日志覆盖
| 模块 | 记录内容 | 定位信息 |
| --- | --- | --- |
| vectors | 后台索引状态、向量调用失败、是否使用本地索引回退 | job_id、错误码、异常类型、调用位置、批次数量 |
| models | 本地模型/远程模型运行诊断、CUDA/CPU、失败与回退 | 模型、设备、状态、错误码、耗时 |
| agent | 创建运行、模型轮次、工具调用与结果、权限决策、完成/失败/取消 | run_id、Provider、模型、步骤、事件序号、工具名 |
| tasks | 创建、修改、删除及不存在的删除目标 | task_id、关联 note_id、修改字段名、状态 |
| providers / chat | 模型请求完成或未完整结束、流式模型错误、对话失败 | Provider、模型、request_id、run_id、错误码 |
| http / api | HTTP 写操作、失败请求、超过一秒的请求及业务错误 | HTTP 方法、路由模板、状态码、耗时、request_id、资源 ID |
| system / Python logger | 服务启动与停止,以及应用、服务器和依赖库的 warning/error | 模块、代码位置、异常类型 |
`X-Request-ID` 响应头可与日志中的 request_id 对照。请求上下文通过 ContextVar 传播到后台任务和线程;Agent 内部工具触发的日志还带 run_id。查询日志自身以及正常短轮询不生成 HTTP 操作日志,避免轮询放大日志量。
这里只记录操作诊断,不替代完整 Agent Trace、Token 用量台账和模型诊断表。旧代码 logger 的自由文本可能包含笔记或厂商返回,因此桥接时只记录级别、模块、代码位置和异常类型;需要业务错误码的地方使用结构化接口。
## 3. 存储、容量与隐私
- 位置:`APP_DATA_DIR/logs/operations.sqlite3`,与业务数据库独立,应用重启后可继续查看。
- 保留最近 20,000 条;每批写入时淘汰更早的记录。不是按天永久归档。
- 后台线程批量写入,队列最多 4,096 条,一批最多 128 条。队列满时丢弃新诊断记录并计数,绝不阻塞保存或模型推理。
- 日志页面显示待写入数量、队列溢出和写入失败数量。计数属于当前进程;重启后重置。存储不可读时显示请求失败。
- 正常退出先停止 Agent 和其他后台工作,再排空日志队列;强制终止进程可能丢失尚未写入的队列内容。
- 不记录请求/响应正文、系统提示词、工具参数/输出、笔记标题或正文、凭据、原始异常消息、查询字符串。元数据按白名单筛选并限长,Bearer 和 `sk-` 凭据形式额外脱敏。
- API 仅返回保留窗口内的诊断数据,不提供任意文件读取和日志删除接口。
## 4. 开发接口
```python
from app.operation_logs import log_event
log_event('vectors', 'embedding.failed', level='ERROR', error=exc,
model=model_id, job_id=job_id, error_code='EMBEDDING_UNAVAILABLE',
fallback='local_index')
```
第一个参数是模块名。`source` 是可选的元数据字段,例如 `local``api`,不要与模块名混淆。事件名应使用开发时定义的短常量。不要把正文、URL、异常消息或用户输入拼入事件名。
`GET /api/logs` 参数:
| 参数 | 规则 |
| --- | --- |
| limit | 默认 50,范围 1200 |
| before | 上页 next_cursor;正整数,返回更早的 ID |
| level | 空或 INFO / WARNING / ERROR / CRITICAL |
| source | 模块名精确匹配,最多 100 字符 |
| q | 在事件名及脱敏元数据中做字面子串搜索,最多 200 字符 |
返回 `items``next_cursor``sources``pending``dropped``write_failures``retention`。按递增 ID 的倒序分页,插入新日志不改变已经翻到的旧页边界。没有匹配记录时 items 为空、next_cursor 为 null。
## 5. 压力问题的修复方式
Agent Trace 写入由独立异步队列调度到线程,最多合并 64 个写入为一个 SQLite 事务。提交成功后才更新对外可见快照和广播 SSE。创建记录在第一次 await 前占用运行名额,避免并发创建突破 200 个活动运行上限。取消等待正在提交的快照结束,再写终态;服务关闭会等待 Agent 和 Trace 队列。数据库失败不会被当成成功提交。
任务 HTTP 写操作在协程中排队,然后在线程执行;读操作也在线程执行,减少文件锁争用和主事件循环阻塞。SQLite 仍是单写者,不意味着任务写入支持无限并发。
前端读取任务和 Agent 列表的后续 API 页,不再只显示第一页的 50 条。任务筛选和数量基于已获取的完整列表,界面每页显示 100 个任务;运行侧栏每页 50 条。列表刷新失败保留上次结果。列表读取使用现有 offset 协议,不是跨多页的事务快照;其他客户端同时修改数据时可以刷新重新获取。
Trace 搜索先生成事件序号、模型调用 ID、工具调用 ID 的集合,再一次遍历调用树。匹配子节点保留父节点。搜索作用于完整已加载的历史,每页只渲染 200 个事件或树节点。工具统计按工具名和状态汇总,避免在底部再次生成数千个调用卡片。
## 6. 验证方法
后端回归覆盖日志持久化、重启、保留窗口、筛选与游标、正文排除、HTTP 关联 ID,以及后台 Trace 写入同时取消时仍释放等待方。业务回归覆盖 Agent/任务、模型路由、本地模型、用量统计和对话。
```powershell
cd backend
.venv/Scripts/python.exe -m pytest tests/test_agent_core.py tests/test_api.py tests/test_operation_logs.py tests/test_model_routing.py tests/test_local_models.py tests/test_usage_overrides.py tests/test_chat_history.py tests/test_chat_context.py -q
```
运行测试前应将 APP_DATA_DIR、APP_DB_PATH 和 APP_VAULT_PATH 指向临时目录,避免模块导入时初始化真实扩展。pytest 的 fixture 会继续为每个测试隔离数据。
前端执行 `npm --prefix frontend test``npm --prefix frontend run build`。新增用例验证后续任务/Agent 页完整加载、Trace 全历史搜索与分页、日志筛选/游标、自动跟随与上滚暂停、顶部历史加载、存储异常提示及卸载后停止刷新。
2026-09-06 实测:后端全量 627 项通过;随后增加的 3 项写入失败/取消回归也通过,最后相关接口回归 21 项通过。前端全量 413 项通过,生产构建通过。构建仍提示已有部分 Markdown/Mermaid 依赖块大于 500 kB;后端测试保留已有 Starlette TestClient 弃用提示。纸间时光日志页已用隔离合成数据检查截图,运行中的本机 `/api/logs` 也已确认可返回结果。
离线并发压测与真实本机 HTTP 压测复现方式、前后数据见 [Agent 与任务压测报告](Agent与任务压测报告.md)。压测只使用隔离数据和 Mock Provider,不消耗用户厂商额度。
手工验收建议:
1. 创建、修改和删除一个测试任务,按返回的 task_id 或 X-Request-ID 筛选日志。
2. 运行含工具调用的 Agent,批准或取消权限请求;按 run_id 检查创建、权限、工具结果和终态。
3. 在隔离配置中请求未安装的本地模型,查看 models/vectors 的错误码与回退信息,不应出现输入文本。
4. 重启 AI Core,确认已提交日志仍可查看;退出 Vault 后从入口页打开日志。
5. 切换默认浅色、深色、纸间时光主题,检查筛选栏、错误徽标和详情展开;窄窗口下应换行且详情不撑出页面。
# 2026-09-06:大批量本地向量传输修复
全量重建包含 2111 个文本块时,本地模型可能成功完成推理,但整批向量被写成一行 JSON。接收端单行限制为 16 MiBasyncio 管道拒绝超长行,Windows 线程管道则读到不完整 JSON,最终被包装成 `LOCAL_MODEL_INVALID_RESPONSE`。这类错误不表示 CUDA 显存不足,也不应通过切换 CPU 处理。
Embedding 响应改为每 128 条向量一个 JSON 帧,结束帧携带总数。接收端检查偏移连续性、请求数量和结束标记;缺块时明确失败,不把部分向量写入索引。模型仍只加载一次,推理批大小不变。语音等其他响应保持原协议。
验证方法:在隔离数据目录运行 `tests/test_local_models.py`,分别使用 asyncio 与 Windows 线程管道传输 2111 × 384 的结果,确认旧格式超过 16 MiB,而分帧可完整接收。该文件 7 项测试通过。另使用本机 Bekko 权重、CUDA 对 2111 条合成文本做真实推理:返回 2111 条 384 维向量,设备 `cuda:0`,耗时约 12.55 秒;旧格式 JSON 为 17,886,180 字节。该验收不修改用户笔记、索引或运行配置;用户原始长文档的全库重建仍需在服务加载修复后重试。
@@ -0,0 +1,67 @@
# 工作区后台索引与保存开发说明
> 本文档用于团队开发和联调,说明 Web Workspace 的打开、保存与后台向量更新边界。
>
> 更新日期:2026-09-06。代码基线:`a5c44c4`,已随 PR #31 合并到 `main`
## 当前实现
打开知识库和保存正文不再等待 Embedding 推理。Markdown 是正文载体;SQLite 元数据和 FTS 可先使用,向量结果随后更新。这里的后台任务是 AI Core 进程内的 asyncio 任务,不是独立队列服务,也不是 Tauri 后台服务。
| 操作 | 请求完成前 | 后台阶段 |
| --- | --- | --- |
| 打开 Vault | 校验配置路径;路径集合变化时登记新文件、删除失效记录并返回真实文件树 | 标记需要更新时自动全量计算向量 |
| HTTP PATCH 保存笔记 | 写正文、解析元数据、更新 FTS、清理旧向量并持久化待处理标记 | 按笔记计算向量,核对版本后写入 |
| 手动全量重建 | HTTP 请求仍等待全量重建结果 | 推理期间不持有 Vault 写锁,提交阶段原子替换 |
`note_service.update_note``defer_vectors` 默认仍为 `False`HTTP PATCH 路由显式传入 `True`。创建、移动及其他内部调用不能据此宣称已经全部后台化。
## 代码入口
| 文件 | 职责 |
| --- | --- |
| `backend/app/services/workspace_service.py` | 文件登记、打开工作区、触发后台任务 |
| `backend/app/services/note_service.py` | 正文保存、即时元数据与全文索引 |
| `backend/app/services/index_service.py` | 后台任务去重、全量与单笔记向量更新、状态与关闭 |
| `backend/app/services/coordination.py` | Vault 写入互斥 |
| `frontend/src/features/vault/VaultEntry.vue` | 等待提示、超时错误及重试 |
| `frontend/src/stores/editor.ts` | 保存快照、状态及连续输入补存 |
| `frontend/src/components/common/AppShell.vue` | 每轮请求结束后间隔 5 秒刷新索引状态 |
## 一致性与恢复
- 文件登记和保存的待处理标记与索引写入使用同一 SQLite 事务。
- `workspace_vectors_pending=1` 表示工作区全量向量需要更新;`note_vectors_pending:<note_id>=1` 表示该笔记有待处理工作。
- 模型推理在写锁外执行;提交前重新核对磁盘快照或笔记记录。版本不一致时不写入旧结果,后台循环重新处理。
- 保存接口成功后,“已保存”代表正文和全文索引已写入,不代表向量已经就绪。向量不可用期间,语义检索可能尚未覆盖刚保存的内容。
- 模型失败保留待处理标记,正文不会因后台失败回滚。重新打开 Vault 可再次调度;不是保证持续重试的生产任务队列。
- 关闭后端会取消进程内后台任务;持久化标记保留。任务详情、活动任务 ID 和错误信息仍主要保存在内存,重启后不会保留完整任务历史。
- 前端保存期间继续输入时,当前请求完成后仍保持 dirty,并安排后续自动保存;落盘失败显示 save_failed。
## 状态与接口
`GET /api/index/status` 新增 `vector_refresh_required: boolean`,表示仍有待处理标记。它与 `status` 一起使用:失败、等待和运行中都可能需要向量更新。`pending_jobs` 不是百分比或剩余笔记数。
打开 Vault 请求超时为 15 秒;诊断和状态请求为 10 秒。超时表示前端停止等待,不保证服务端操作已经取消,不应因此认定保存成功或失败。
## 验证方法
在 backend 目录执行:
```powershell
uv run pytest tests/test_workspace.py tests/test_workspace_background.py -q -p no:cacheprovider
```
在 frontend 目录执行:
```powershell
pnpm exec vitest run src/services/apiClient.spec.ts src/stores/editorSave.spec.ts
```
回归覆盖:模型等待期间仍可打开工作区和创建目录、重复打开去重、快照变化重算、连续保存保留最新标题和标签、模型失败不回滚正文、重新打开恢复处理、请求超时及自动补存。
手动联调请使用测试 Vault:新增 Markdown 后打开;计算期间修改并保存两次;重新打开确认最后内容;在隔离配置中模拟模型不可用,核对正文保留与索引错误分别显示。
## 当前限制
路径登记按磁盘与数据库的路径集合判断变化,不等同于完整外部文件监听。多进程后台调度、生产任务队列、完整失败历史和原生文件监听属于后续工作。不要把当前进程内互斥用于推断多实例安全性。
@@ -0,0 +1,45 @@
# 扩展安装持久化与社区包开发说明
> 本文档用于 Skill、Plugin 安装及社区示例包开发。
>
> 更新日期:2026-09-06。代码基线:`a5c44c4`;社区服务仍处于第三阶段规划。
## 当前实现
`backend/app/extensions/archive.py` 解析上传 ZIP`installed.py` 为运行时增加本地安装日志。`extension-installations.sqlite3` 位于 data_dir 下,记录包路径、内容摘要、启用状态、插件授权及受管理目录;成功解压的包保存在 `extension-packages`,供后续命令读取。
| 环节 | 行为 |
| --- | --- |
| 安装 ZIP | 最大 10 MiB,解压总量最大 50 MiB,最多 2048 条目 |
| 路径检查 | 拒绝目录穿越、链接、特殊文件、加密条目、重复及大小写冲突路径 |
| 包布局 | 根目录或唯一顶层目录包含 skill.yaml / plugin.yaml |
| 重启恢复 | 校验目录和内容摘要后恢复安装状态;插件恢复授权 |
| 包内容变化 | 不自动启用变化后的包,需重新安装并检查权限 |
| 卸载 | 受管理 ZIP 目录可清理;用户目录安装源不删除 |
安装持久化不等于操作系统级沙箱、签名验证或远程社区服务上线。生产隔离、社区发布治理与签名机制仍按第三阶段规划推进。
## 社区示例
源码与打包入口位于 `backend/extensions/community/``note-reviewer` 为笔记审阅 Skill`markdown-workbench` 为可执行 Plugin。功能、安装和测试输入见该目录 README 与各包 README。
重新打包:
```powershell
cd backend
uv run python extensions/community/build_packages.py
```
仅在需要更新社区示例产物时执行,并核对 dist 中 ZIP 和 index.json 是否与源码一致。
## 验证方法
在 backend 目录执行:
```powershell
uv run pytest tests/test_extension_archive.py tests/test_installed_extensions.py tests/test_community_packages.py -q -p no:cacheprovider
```
手动使用测试数据目录安装示例包,启用并调用;重启后端后确认状态恢复。对包副本修改内容再重启,应出现恢复失败提示。卸载目录安装包时,原目录应继续存在。
生产社区规划见 [第三阶段实施规划](../architecture/第三阶段实施规划.md),命令和设置接口见 [Plugin Command 与 Settings 开发说明](Plugin-Command与Settings开发说明.md)。
@@ -0,0 +1,52 @@
# 标题折叠与样式开发说明
日期:2026-09-06。范围为工作区写作模式的章节折叠,以及正文标题外观偏好。
## 1. 章节边界与交互
H1–H6 标题旁在悬停时显示统一折叠箭头;键盘聚焦也显示,触摸设备保持可见。章节从标题之后开始,结束于同一容器内下一个同级或更高级标题;末尾没有后续内容的标题不显示按钮。引用等容器中的标题只影响所在容器,不折叠外部正文。
- 工具栏使用单个按钮:有可见章节展开时显示“全部折叠”,否则显示“全部展开”。父章节隐藏的子章节不影响按钮判断,其自身折叠状态仍保留。无可折叠章节时按钮禁用。
- 折叠父章节不会清空子章节的折叠状态。
- 折叠时若选区在将隐藏的正文中,光标先移到标题。
- 从大纲、查找或键盘跳到隐藏内容时,展开包含目标的章节,避免隐藏光标。
- 折叠只影响当前编辑器视图,不修改 Markdown,不触发文档脏状态,也不占用撤销历史。重开文件恢复展开;源码模式与静态预览不进行章节折叠。
实现位于 `headingFolding.ts`:插件状态保存标题位置,通过事务映射跟随文档编辑;删除或改成正文的标题会从状态中清理。Decoration 隐藏完整块,widget 提供可聚焦的折叠按钮。章节范围按标题栈计算,并按不可变文档缓存;隐藏范围合并后遍历节点,避免每个节点重复扫描全部标题。
## 2. 标题样式设置
入口为“设置 → 编辑器 → 标题样式”,主题页“编辑器外观”中也提供同一组件。
- 默认跟随当前主题,不覆盖主题字号、字体与粗细。
- 启用自定义后,分别调整 H1–H6 字号(12–72 px)和字重(400–800 的五个档位)。
- 标题字体可跟随正文,或使用系统衬线、无衬线、等宽字体。
- 面板即时预览,工作区正文和静态 Markdown 预览使用同一偏好。
- 不影响笔记属性栏的标题、侧栏大纲字号或页面标题,不改写源文件中的标题级别。
- “恢复跟随主题”清除自定义覆盖;主题页“恢复默认”也重置标题设置。
偏好由 `headingAppearance` store 保存到本机 `editor-heading-appearance`。读取时校验字体枚举、字重与字号范围;应用 CSS 前再次规范化,避免空输入、无效存储或异常大数影响布局。刷新后恢复设置,切换主题保留自定义偏好。用户显式启用的覆盖只作用于 Markdown 标题,优先于主题规则;颜色继续跟随主题。
## 3. 桌面命令预留
既有 `editorCommandService` v1 增加三个可执行 ID
| 命令 | 行为 |
| --- | --- |
| editor.heading.toggle-fold | 切换选区所在章节的折叠状态 |
| editor.heading.fold-all | 折叠所有有内容的章节 |
| editor.heading.unfold-all | 展开所有章节 |
均不需要参数,沿用活动文档、模式与冲突检查。原生快捷键仍由第三阶段容器绑定,此处不注册系统级快捷键。
## 4. 验证
```sh
cd frontend
npm run test -- src/features/editor/headingFolding.spec.ts src/features/editor/VisualMarkdownEditor.spec.ts src/features/editor/HeadingStyleSettings.spec.ts src/stores/headingAppearance.spec.ts
npm run build
```
自动检查覆盖章节边界、嵌套容器、隐藏选区迁移、父子折叠状态、大纲目标展开、序列化保持、设置保存恢复、输入校验和面板恢复默认。
手动检查:打开“功能演示 / 01 Markdown 与大纲”,依次折叠 H2 与 H1,再从大纲跳转;确认隐藏内容重新显示。打开设置调整 H1 字号与 H2 粗细,返回笔记检查外观,刷新后检查偏好仍在;关闭自定义后依次切换六个主题核对主题原有样式。
@@ -0,0 +1,30 @@
# 模型隔离向量索引与增量登记
## 目标与实现
向量仍保存在本地 `app.db`,重启复用。`routed_block_vectors` 主键升级为 `(space_id, dimensions, block_id)`,同维度不同模型、同模型不同维度均独立存储。每个空间建立以模型标识和维度的 SHA-256 命名的 `vec0` 虚拟表,动态表名不包含厂商输入。
首次访问旧空间时,在事务中将已有 JSON 向量转换为归一化 float32 索引,不重新调用 Embedding 模型。随后检索由 sqlite-vec 执行精确 KNN,避免每次在 Python 中解码所有向量、计算点积。该实现是精确搜索,不是 ANN;依据归一化向量的欧氏距离换算余弦分数。参见 [sqlite-vec KNN 文档](https://alexgarcia.xyz/sqlite-vec/features/knn.html)。
覆盖检查和 KNN 使用同一事务。向量更新、删除及笔记级联删除通过触发器清理派生索引,新向量与原始向量在同一保存点写入。索引缺失或不完整仍明确失败/按已有策略回退,不混入其他模型结果。本地专用笔记按 `local_only` 元数据过滤,不同空间的结果继续使用 RRF 融合。
数据库搜索及旧空间转换在线程中运行,不阻塞异步服务事件循环。首次转换仍会占用 SQLite 写事务;超大库应进一步评估迁移耗时。新增空间保留原空间索引,暂不自动清理历史空间。
首次转换使用进程内迁移锁串行执行,在打开检索读快照之前以 `BEGIN IMMEDIATE` 获取写事务;等待迁移时不持有读事务,避免多个搜索从读锁升级写锁发生冲突。迁移完成后二次检查即可复用。普通检索和隐私分区检索共用该流程,已有索引的检索仅执行读取。
首次转换同时通过 Vault 的异步写入锁与笔记保存、后台索引协调,避免保存事务读取旧 Block 后与迁移争抢写锁。等待是异步的;取消检索时,仍等待迁移线程结束后才释放锁。已就绪的空间直接走只读检查,不进入写入队列。写入锁按事件循环生命周期创建,避免重启后复用已关闭循环的锁。
## 外部新增文件
文件树检测到新增 Markdown 后先登记元数据与全文索引,然后为每条新笔记持久化 `note_vectors_pending:<note_id>`,逐笔记在后台计算。不再因新增文件设置全库重建标记;已存在的全库待处理标记仍保留,以免跳过之前未完成的任务。
新增文件在推理期间再次变化时校验快照并重试,只同步该笔记。写入失败保留待处理标记。手动“重建全部”仍执行全库重建,正常重新打开已完成的工作区不再入队。
## 验证
- 隔离数据库运行后端全套测试:637 项通过;最终覆盖检查优化另运行检索及后台工作区回归,96 项通过。
- 新增用例验证同模型不同维度并存、重新连接复用且查询不解码向量 JSON、旧表迁移只计算查询向量,以及外部新增文件不触发全量重建。
- 并发迁移回归用同步事件暂停第一个请求的转换,同时发起第二个请求;验证普通/隐私分区路径均只迁移一次、两个请求结果一致,并开启 SQLite `query_only` 验证后续检索不会写入。相关检索与后台工作区测试 91 项通过。
- 并发保存回归暂停迁移后发起实际 `update_note(..., defer_vectors=True)`,验证保存等待、事件循环仍可运行、放行迁移后正文成功落盘并保留新内容的向量待处理标记;另覆盖搜索被取消时不得提前放行保存。
- `backend/scripts/vector-index-benchmark.py` 使用临时数据库、固定随机种子,比较 4000 条 384 维向量的 top-20,旧新路径结果顺序一致。5 次采样中位数:Python 扫描约 1289.64 mssqlite-vec 约 19.67 ms;首次转换约 1329.51 ms。该结果只测向量检索,不包含查询 Embedding、重排与 HTTP 耗时,不代表端到端加速比例。
- 原始结果:[2026-09-06-sqlite-vec-search.json](performance/2026-09-06-sqlite-vec-search.json)。基准可用后端虚拟环境 Python 直接执行上述脚本,不读写用户 Vault。
@@ -0,0 +1,90 @@
# 警告框与桌面编辑命令开发说明
日期:2026-09-06。范围为前端渲染与命令边界;不包含 Tauri IPC、原生菜单或系统级快捷键注册。
## 1. 格式与渲染
使用引用块语法。GitHub 的 NOTE、TIP、IMPORTANT、WARNING、CAUTION 均可渲染;同时支持 Obsidian 的常用类型、别名、标题、嵌套与折叠。
```markdown
> [!WARNING]- 自定义标题
> 提示正文,支持 **粗体**`行内代码`、列表等 Markdown。
>
> > [!TIP]+ 嵌套提示
> > 展开内容
```
`+` / `-` 时不可折叠;`+` 默认展开,`-` 默认折叠。点击折叠只改变本次显示状态,不自动改写源文件中的默认状态。标题作为文本显示,不执行 HTML。未知类型使用 note 外观并保留类型名。
| 规范类型 | 兼容别名 |
| --- | --- |
| note | — |
| abstract | summary、tldr |
| info、todo | — |
| tip | hint |
| important | — |
| success | check、done |
| question | help、faq |
| warning | caution、attention |
| failure | fail、missing |
| danger | error |
| bug、example | — |
| quote | cite |
工作区顶部“提示框”选择器可插入模板。编辑器保留原生 blockquote 文档节点,以 NodeView 展示标题与折叠按钮,装饰隐藏标记;选区进入标记时显示原文以便修改。序列化仅取消引用首行提示标记的转义,避免保存后退回普通引用。代码中的标记不转换。写作模式会规范化 Markdown 转义;需要永久展示字面标记时使用行内代码或围栏代码。
静态预览使用 marked 的 blockquote renderer,折叠使用原生 details / summary,内容仍经过 DOMPurify。两个入口共享 `callouts.ts``callouts.css`;颜色继承主题的 info、warning、success、error、surface 和 text 变量,不另存固定浅色配色。因此已有主题和符合主题规范的导入主题均可继承。
## 2. 桌面命令边界 v1
入口:`frontend/src/services/editorCommandService.ts`
- `editorCommandVersion`:当前版本 1。
- `getEditorCommandCapabilities()`:返回每个稳定命令 ID 的 supported 与 enabled。预留 ID 不等于已经实现。
- `executeEditorCommand(id, params?)`:返回 `{ ok: true }``{ ok: false, reason }`。reason 为 unsupported、unavailable、invalid-params、failed。
- `registerEditorCommands(target)`:由活动编辑器注册处理器,返回注销函数。旧组件注销不清除替代组件的注册。
成功表示处理器接受并执行了命令;具体格式操作仍遵循编辑器的选区规则。命令不直接写磁盘,变更进入既有脏状态、撤销与自动保存链路。无活动文件、源码模式、只读、冲突、编辑器加载中或文件已切换时禁用当前处理器。
| 已接入 ID(统一加 editor. 前缀) | params |
| --- | --- |
| bold、italic、ordered-list、bullet-list、inline-code、code-block、inline-math、math-block、paragraph | 无 |
| heading | 整数 16 |
| font-size | 有限数值 896,单位 px;作用于选区 |
| insert-markdown | 非空 Markdown 字符串,最多 100000 字符 |
| callout | `{ type, title?, body?, fold? }`;fold 为空串、+ 或 -;标题不可换行 |
目录还预留删除线、任务列表、引用、Mermaid、链接、图片、表格、分隔线、硬换行、引用式链接、HTML、撤销/重做,以及 `editor.import-note-properties``editor.metadata.edit``editor.metadata.title``editor.metadata.tags`。这些单独的处理器尚未接入,返回 unsupported;现阶段复杂格式可通过 insert-markdown 插入。元数据不得通过普通正文插入接口冒充属性导入。
第三阶段由 Host 将原生菜单/快捷键映射到上述 ID。快捷键表需支持平台差异与用户改键,过滤表单、输入法组合与弹窗焦点;不要重复绑定浏览器和 Milkdown 已有按键。宿主只能调用允许的命令,不执行任意脚本。元数据处理器须按已有桌面需求完成无损 YAML 合并、版本检查、冲突提示及属性/正文一并撤销后才可标记 supported。
## 3. 验证方法
在 frontend 运行:
```sh
npm run test -- src/utils/callouts.spec.ts src/services/editorCommandService.spec.ts src/features/editor/VisualMarkdownEditor.spec.ts
npm run build
```
- callouts:逐个类型与别名、大小写、嵌套、默认折叠、空正文、未知类型、标题注入、普通引用和代码排除。
- 编辑器:初始解析、直接输入标记、按钮折叠、序列化往返、命令参数校验和冲突禁用。
- 命令服务:能力查询、无活动目标、未知命令、未实现命令和旧目标注销隔离。
- 启动开发服务器,访问 `/tests/visual/callouts.html?theme=paper-moments`;依次替换 light、dark、sepia、ocean-blue、midnight-purple。左右分别是工作区和静态预览,核对边框、标题、正文、嵌套与折叠;缩窄窗口检查换行。
此项不宣称支持所有 Markdown 方言;脚注、定义列表、Wiki 双链及 `:::` 等其他警告框语法仍需独立扩展。原生快捷键和元数据转换属于第三阶段验收。
## 4. 六主题适配补充
主题新增 `--color-callout-info/success/warning/danger/important/quote` 六个语义变量。每种类型继续使用共享组件结构,标题、边框与淡色背景取同一语义配色。未知导入主题未定义这些变量时,默认继承其已有状态色。折叠标题补齐悬停、键盘焦点与不可折叠标题状态。
| 主题 | 本次版本 | 外观 |
| --- | --- | --- |
| light、dark、sepia | 1.2.0 | 分别使用浅色、深色、暖色配色 |
| paper-moments | 1.7.0 | 纸张边框、虚线内描边和轻投影;嵌套取消重复投影 |
| ocean-blue | 1.4.0 | 海蓝与低亮度状态色 |
| midnight-purple | 2.2.0 | 深色表面与高亮度状态色 |
修正工作区基础选择器覆盖类型颜色的问题,默认色使用低优先级规则。社区预览 iframe 同步载入共享 callouts CSS,并展示 14 种规范类型、默认展开/折叠及嵌套样例。主题安装与预览使用同一 CSS 来源,纸间时光下载包的清单和样式同步更新;已安装旧版可通过主题页既有更新入口升级。
验证增加六主题 × 六配色在实际 8% 混色背景上的标题对比度检查(至少 4.5:1),工作区选择器覆盖回归,以及六主题预览结构检查。此检查针对不透明 sRGB 配色,不代替每个平台的字体与截图验收。
@@ -0,0 +1,110 @@
# 长文渲染优化与压测报告
日期:2026-09-06。基线为 `750e172`,结果为本次未提交的渲染优化;基线和优化版使用相同样本、浏览器参数及操作序列。
## 1. 测试环境与范围
Windows、Intel Core i7-13620H、Headless Chrome 152,视口 1424 × 905,Vite 开发服务。每种规模重开测试页面三次,独立测试浏览器,不读写真实笔记。耗时基于 performance.now;打开指标不包含模块网络加载。样本含标题、表格、代码块、提示框和行内格式。
实际汉字数为 25632、61172、122322Markdown 总字符数以原始 JSON 为准。不是仅以文件字节数代替“字数”。共完成优化前后 18 轮浏览器运行,所有运行的插入内容与文末完整性检查通过。
## 2. 本次优化
- 标题装饰按不可变文档和折叠状态弱缓存;光标移动不改变折叠状态时复用结果。没有折叠范围时不遍历全文生成隐藏装饰。
- 文档变更后用位置 Set 验证标题映射,避免对每个已折叠标题反复扫描所有标题。
- 字号装饰按不可变文档弱缓存;提示框标记位置按文档缓存,光标移动只判断标记是否处于编辑范围。
- 静态代码高亮增加 LRU,最多 64 项、累计 100 万个字符串字符;超过预算四分之一的单块不缓存。缓存同时包含深浅主题颜色,最终输出继续净化。缓存只在进程内,不写入存储。
弱缓存随文档释放,不用全局 Map 持有历史文档;高亮缓存有明确上限。
## 3. 对比结果
单位 ms。打开取三轮中位数;选区、插入、折叠取各轮 P95 的中位数;热预览取六次热运行中位数。P95 样本量较少,仅用于本机前后比较。
| 实际汉字数 | 打开 前→后 | 光标移动 P95 前→后 | 插入 P95 前→后 | 折叠/展开 P95 前→后 | 热预览 前→后 |
| --- | --- | --- | --- | --- | --- |
| 25632 | 272.1 → 218.6 | 1.9 → 0.6 | 7.0 → 6.9 | 55.0 → 45.6 | 40.0 → 26.6 |
| 61172 | 519.7 → 447.5 | 5.7 → 0.6 | 9.7 → 9.6 | 149.1 → 145.5 | 82.9 → 55.6 |
| 122322 | 875.4 → 869.1 | 11.9 → 1.2 | 22.2 → 27.2 | 256.6 → 263.9 | 140.2 → 95.5 |
主要收益是减少光标移动的重复工作,以及重复代码块/重复预览的高亮开销。2.5 万汉字样本完成全部操作,但不以此宣称所有文档都能维持 60 FPS。
12 万汉字的整篇折叠仍超过 250 ms,插入 P95 本轮反而增加,不能声称打字性能全面提升;全量 DOM 更新、编辑器自身处理与浏览器调度仍需进一步分项剖析。打开仍有长任务,缓存没有解决虚拟化问题。
## 4. 原始数据与复现
- [优化前 JSON](performance/2026-09-06-long-document-baseline.json)
- [优化后 JSON](performance/2026-09-06-long-document-after.json)
- [压测脚本与指标说明](../../frontend/tests/performance/README.md)
```powershell
npm --prefix frontend run test
npm --prefix frontend run build
npm --prefix frontend run dev -- --port 5175 --strictPort
# 另一个终端:
backend/.venv/Scripts/python.exe frontend/tests/performance/run-stress.py --url http://127.0.0.1:5175/tests/performance/stress.html --runs 3 --output .local-plans/stress-results.json
```
新增回归检查确认光标移动复用标题装饰、正文编辑使缓存失效、折叠与展开不残留隐藏内容。前端 402 项测试通过。
## 5. 边界与后续
本次测量是浏览器中的程序化编辑事务,不是输入法、实际键盘到屏幕显示、滚动帧率或生产包启动测试。样本代码块存在重复,LRU 收益不能直接外推到全部唯一代码块的文档。堆内存只采样,不作为内存泄漏结论。
后续应针对 12 万字的输入和整篇折叠采集独立 CPU trace,评估装饰的增量映射及可见区域渲染;在输入法、表格与代码块编辑验收之前,不直接给 contenteditable 应用跳过布局或整篇虚拟化。
## 6. 手动压测反馈后的滚动与定位修复
用户实测:2.56 万字正常,6 万字快速滚动轻微停顿,12 万字明显卡顿;从长文末尾全部折叠没有回到顶层。
本轮将“全部折叠”设为一次性把光标移到首个标题并将编辑区滚动到顶部,单个标题折叠保留原有定位行为。回归用例包含 100 个同级顶层标题,从文末触发后检查光标、scrollTop 与正文不变,避免只有一个 H1 的样本掩盖问题。
语言标签改为只同步变动的代码块,语言菜单只在打开时响应滚动定位,并合并同一帧的重复事件;监听祖先滚动,卸载时清理监听及待执行帧。
新增独立 CDP 滚轮测试,在相同的有限高度 flex 容器、纸间时光主题下,每档运行两次。下表为每次帧间隔 P95 的中位数,单位 ms,越小越好。这不是原先编辑事务基准的延续,也不是生产环境 FPS。
| 请求汉字数 | 修改前 | 修改后 |
| --- | ---: | ---: |
| 25000 | 30.05 | 20.10 |
| 60000 | 55.00 | 45.05 |
| 120000 | 90.10 | 100.00 |
两档较短样本有改善,12 万字没有改善,仍未达到流畅滚动的目标。CPU 采样显示滚入新代码块时的 CodeMirror 初始化、选区测量及 DOM 观察回调占用明显;不能仅凭此认定唯一原因。代码块布局隔离实验未带来收益,已撤回,未引入可能影响编辑、选区或布局的虚拟化。
- [滚轮基线数据](performance/2026-09-06-scroll-baseline.json)
- [滚轮修改后数据](performance/2026-09-06-scroll-after.json)
下一步需要评估代码块按交互初始化或编辑器级虚拟化,并同时验证输入、选区、搜索定位和折叠;当前不将 12 万字滚动问题标记为完成。
本轮前端 405 项测试通过,涵盖折叠定位、语言标签增量同步、菜单祖先滚动定位和卸载清理。
## 7. CodeMirror 高亮复用实验
在上轮提交 `874e916` 的基础上继续尝试:同一语言及主题的 CodeMirror LanguageSupport 内缓存高亮装饰,代码块离屏销毁后重新创建时,相同正文不再重复分词及构建装饰。缓存最多 32 项、64000 个源字符,超过 16000 字符的单块不缓存,使用 LRU 淘汰;语言支持释放时缓存一并释放。编辑后的内容按新正文计算,不复用旧位置。
回归测试通过统计分词调用验证跨视图复用、正文编辑重新计算和超限淘汰。原有深浅主题切换与编辑测试继续验证显示内容。
12 万字继续测量两次,帧间隔 P95 分别约 100.2 ms、90 ms,二者中位数约 95.1 ms;上一轮约 100 ms。样本重复代码较多,结果仍在明显波动范围内,不能宣称解决滚动卡顿。此优化减少确定可重复的高亮工作,但下一步仍须处理创建 CodeMirror 和布局测量的成本。
- [本轮滚轮原始数据](performance/2026-09-06-scroll-token-cache.json)
## 8. 主题对照与纸间时光 1.8.1 修复
用户补充:同一份 12 万字文档在默认浅色、深色下不卡顿。因此重新进行主题对照,之前将 CodeMirror 作为主要优化方向的判断不足;CPU 采样中的布局及选区测量耗时不能单独证明编辑器是主要原因。
保持相同文档和滚轮脚本,默认浅色、深色各测一次,P95 均约 30 ms。纸间时光 1.8.0 仅禁用整篇 `.ProseMirror` 的虚线 outline 后,两次 P95 约 30.1、30 ms;相比此前约 90–100 ms,构成明确的样式消融证据。主题与 CodeMirror 的交互仍可能影响布局,但不需要先用编辑器虚拟化解决这个差异。
修复将贯穿长文的虚线轮廓改为四条小尺寸渐变平铺背景,保留纸张、缝线、胶带、段落横线和叠纸阴影。独立伪元素虚线 border 的实验反而更慢,已撤回。正式主题版本升为 1.8.1。
| 主题或实验 | 次数 | 帧间隔 P95(ms) |
| --- | ---: | --- |
| 默认浅色 | 1 | 30 |
| 默认深色 | 1 | 30 |
| 1.8.0 仅禁用整篇 outline | 2 | 30.1、30 |
| 1.8.1 平铺缝线 | 3 | 30.1、30、30.1 |
1.8.1 三次测试的帧间隔中位数均为 10 ms,全部折叠后的 scrollTop 均为 0,光标位置均为 1。已检查视口截图,手账装饰保留。这里的结果说明本轮样本与环境下,纸间时光恢复到接近默认主题的滚动表现,不代表所有设备、所有复杂文档均无长任务。
已安装主题保存在本地,不会自动被新版资源覆盖。刷新前端后,到“主题 → 社区主题 → 纸间时光”点击“更新”,确认版本 1.8.1 再进行手动复测。
- [主题对比原始数据](performance/2026-09-06-scroll-theme-comparison.json)
+18
View File
@@ -41,6 +41,10 @@ pnpm dev
编辑器使用 Milkdown/Crepe 与 CodeMirror 6Markdown 展示使用 marked、DOMPurify 和 Shiki。Provider logo 位于 `src/assets/providers`,授权与来源说明随目录保存。
写作模式支持按标题折叠章节及全部展开/折叠;“设置 → 编辑器 → 标题样式”可按 H1–H6 设置字号、粗细与标题字体。设置本地保存,不改写 Markdown;详见 [标题折叠与样式开发说明](../docs/development/标题折叠与样式开发说明.md)。
工作区和静态预览支持 GitHub alerts / Obsidian callout 的类型、别名、标题、嵌套与折叠。桌面快捷键使用预留的 v1 编辑命令边界,尚未接入 Tauri 原生快捷键与元数据转换处理器;见 [警告框与桌面编辑命令开发说明](../docs/development/警告框与桌面编辑命令开发说明.md)。
语言设置会即时更新主导航、页面标题和各功能页面,并同步更新文档与编辑器的 `lang`。拼写检查使用浏览器或桌面 WebView 提供的本地词典,开关会即时作用于可视化 Markdown、源码编辑器以及普通文本输入;JSON、密码等结构化或敏感输入保持关闭。
## 数据边界
@@ -78,3 +82,17 @@ pnpm build
- 异步页面需要处理加载、空数据、后端错误、重复提交和迟到响应。
- 功能行为或契约变化时,同一提交同步更新测试和相关文档。
- 页面需求见[前端页面需求说明](../docs/contracts/前端页面需求说明-开发版.md),后端行为以运行时 `/openapi.json` 为准。
## 保存状态与图表联调(2026-09-06)
“已保存”表示正文与全文索引请求成功;向量可能仍在后台计算。状态栏定期刷新索引状态;保存期间继续输入会补存。Vault 入口具有超时、错误与重试提示。
Mermaid 大图打开时适配窗口,支持平滑滚轮缩放和鼠标位置补偿;行内中键启用滚轮控制,移动鼠标退出。标签段落样式与正文隔离,避免 foreignObject 内文字裁切。
开发和验证方法见 [后台索引与保存](../docs/development/工作区后台索引与保存开发说明.md)、[Mermaid 预览与缩放](../docs/development/Mermaid预览与缩放开发说明.md)。
## 构建体积检查
执行 `pnpm build` 后运行 `pnpm build:report`,查看入口静态 JS 依赖与大块清单。分组策略、统计口径及保留的大资源见 [前端构建分块优化开发说明](../docs/development/前端构建分块优化开发说明.md)。
Markdown 语法预设、主题适配和外部文件刷新规则见 [开发说明](../docs/development/Markdown语法预设与外部文件刷新.md)。
+2 -1
View File
@@ -8,7 +8,8 @@
"build": "vue-tsc -b && vite build",
"test": "vitest run",
"preview": "vite preview",
"type-check": "vue-tsc --noEmit"
"type-check": "vue-tsc --noEmit",
"build:report": "node scripts/build-size.mjs"
},
"dependencies": {
"@codemirror/commands": "6.11.0",
+19
View File
@@ -0,0 +1,19 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { gzipSync } from 'node:zlib'
const root = fileURLToPath(new URL('../dist/', import.meta.url))
const manifest = JSON.parse(readFileSync(resolve(root, '.vite/manifest.json'), 'utf8'))
const files = new Map(Object.values(manifest).filter(x => x.file.endsWith('.js')).map(x => [x.file, x]))
const sizes = file => { const bytes = readFileSync(resolve(root, file)); return { bytes: bytes.length, gzip: gzipSync(bytes).length } }
const closure = key => {
const seen = new Set()
function visit(k) { if (seen.has(k)) return; seen.add(k); for (const i of manifest[k]?.imports ?? []) visit(i) }
visit(key)
return [...new Set([...seen].map(k => manifest[k]?.file).filter(f => f?.endsWith('.js')))]
}
const entries = Object.entries(manifest).filter(([key, value]) => value.isEntry || /(?:WorkspaceView|VisualMarkdownEditor|VaultEntry|ChatView)\.vue$/.test(key)).map(([key]) => {
const files = closure(key)
return { entry: key, files, bytes: files.reduce((n, f) => n + sizes(f).bytes, 0), gzip: files.reduce((n, f) => n + sizes(f).gzip, 0) }
})
console.log(JSON.stringify({ entries, largest: [...files.keys()].map(file => ({ file, ...sizes(file) })).sort((a,b) => b.bytes-a.bytes).slice(0,15), chunks: files.size }, null, 2))
+29 -4
View File
@@ -1,6 +1,6 @@
theme_id: paper-moments
name: 纸间时光 · Paper Moments
version: 1.6.2
version: 1.9.0
author: NotesAgent
description: 奶油纸张、手帐虚线与粉蓝胶带,把每天的灵感好好收藏。
min_app_version: 0.2.0
@@ -9,6 +9,15 @@ css_entry: theme.css
license: MIT
---
[data-theme="paper-moments"] {
--color-markdown-selection: #ecd3c7;
--color-editor-scroll-background: #fff9e9;
--color-editor-scroll-text: #875343;
--color-callout-info: #406b7b;
--color-callout-success: #536f43;
--color-callout-warning: #875f25;
--color-callout-danger: #a34e42;
--color-callout-important: #805c7e;
--color-callout-quote: #6e6053;
color-scheme: light;
--color-background-primary: #faf7ee;
--color-background-secondary: #f3eee3;
@@ -149,9 +158,13 @@ license: MIT
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;
/* Small repeated tiles retain the stitching without a document-height dashed outline. */
background:
linear-gradient(#c5b9a7 50%, transparent 50%) 10px 0 / 1px 8px repeat-y,
linear-gradient(#c5b9a7 50%, transparent 50%) calc(100% - 10px) 0 / 1px 8px repeat-y,
linear-gradient(90deg, #c5b9a7 50%, transparent 50%) 0 10px / 8px 1px repeat-x,
linear-gradient(90deg, #c5b9a7 50%, transparent 50%) 0 calc(100% - 10px) / 8px 1px repeat-x,
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 {
@@ -322,3 +335,15 @@ license: MIT
/* Nested choices retain a quiet paper border without repeating tape/shadows. */
[data-theme="paper-moments"] .surface-nested { border: 1px dashed #c5b9a7; background: #fffdf5; border-radius: 6px; }
[data-theme="paper-moments"] .surface-nested.selected { border-color: var(--color-accent-primary); background: var(--color-accent-soft); }
/* Callout paper: no tape over titles, no repeated shadows in nested blocks. */
[data-theme="paper-moments"] .markdown-callout,
[data-theme="paper-moments"] .milkdown .ProseMirror blockquote.markdown-callout {
border-radius: 10px 3px 10px 3px;
outline: 1px dashed color-mix(in srgb, var(--callout-color) 30%, transparent);
outline-offset: -6px;
box-shadow: 3px 3px 0 color-mix(in srgb, var(--callout-color) 12%, transparent);
padding: 14px 18px;
}
[data-theme="paper-moments"] .markdown-callout .markdown-callout { box-shadow: none; }
[data-theme="paper-moments"] .markdown-callout > .callout-title { font-family: Georgia, 'Noto Serif SC', 'Songti SC', SimSun, serif; }

Some files were not shown because too many files have changed in this diff Show More