Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c14047899 | ||
|
|
780e24a399 | ||
|
|
dffafce8b9 | ||
|
|
e29cc427e4 | ||
|
|
174e723545 | ||
|
|
f0fe8f2629 | ||
|
|
406dd42571 | ||
|
|
3dcd469bc0 | ||
|
|
f32971d32e | ||
|
|
b03b168920 | ||
|
|
3b9490e3fb | ||
|
|
966a94cad8 | ||
|
|
874e916106 | ||
|
|
c9c5f81d49 | ||
|
|
5a3546b3ac | ||
|
|
750e17212e | ||
|
|
d4ba08b944 | ||
|
|
8ad1db33f7 | ||
|
|
415efc4444 | ||
|
|
fcc319d5e1 | ||
|
|
64af068df4 | ||
|
|
b265a5528a | ||
|
|
124024a547 | ||
|
|
b87f94551b | ||
|
|
50d7fb4c7d | ||
|
|
04f36524b1 | ||
|
|
054f704c8b | ||
|
|
f49d1245a1 | ||
|
|
64af1f5165 | ||
|
|
7eae7fba00 | ||
|
|
5c2441464d |
@@ -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 配置、授权状态及服务器工作目录不得提交。
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
NotesAgent 是本地优先的 AI 笔记与知识库项目。当前可运行形态为 Vue/Vite Web 前端与 FastAPI AI Core:Markdown 和附件保存在本地 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 项测试通过,前端生产构建通过。这是该提交的回归记录,不表示全部真实厂商及设备场景完成专项验收。
|
||||
|
||||
## 本地模型
|
||||
|
||||
| 能力 | 当前模型 | 许可 | 说明 |
|
||||
|
||||
@@ -101,3 +101,9 @@ uv run pytest
|
||||
- [阶段 F:Embedding 与知识库问题](../docs/retrospectives/阶段F-Embedding与知识库问题与解决方案.md)
|
||||
|
||||
机器可读接口以运行中的 `/openapi.json` 为准。
|
||||
|
||||
## 工作区保存与扩展恢复(2026-09-06)
|
||||
|
||||
HTTP 保存先写正文、元数据及 FTS,再调度后台向量更新;打开 Vault 的向量计算也不再阻塞入口。手动全量重建接口仍等待完成。待处理标记持久化,重新打开 Vault 可恢复处理;任务详情不是完整持久化队列。
|
||||
|
||||
实现与验证见 [工作区后台索引与保存](../docs/development/工作区后台索引与保存开发说明.md)。扩展安装日志、ZIP 限制和社区包测试见 [扩展安装持久化与社区包](../docs/development/扩展安装持久化与社区包开发说明.md)。
|
||||
|
||||
@@ -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()
|
||||
+128
-77
@@ -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, {})
|
||||
if record.task and not record.task.done():
|
||||
record.task.cancel()
|
||||
return record.run.model_copy(deep=True)
|
||||
async with record.cancel_lock:
|
||||
if record.task and not record.task.done():
|
||||
if record.run.status not in TERMINAL_STATUSES:
|
||||
record.task.cancel()
|
||||
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,45 +555,60 @@ 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:
|
||||
sanitized = sanitize_trace_value(data)
|
||||
assert isinstance(sanitized, dict)
|
||||
event = AgentEvent(
|
||||
event=event_type,
|
||||
run_id=record.run.run_id,
|
||||
sequence=record.next_sequence,
|
||||
data=sanitized,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
record.next_sequence += 1
|
||||
record.events.append(event)
|
||||
self.trace_repository.append_event(record.run, event)
|
||||
# 内存只保留实时订阅窗口;完整审计轨迹由 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)
|
||||
async with record.publish_lock:
|
||||
sanitized = sanitize_trace_value(data)
|
||||
assert isinstance(sanitized, dict)
|
||||
event = AgentEvent(
|
||||
event=event_type,
|
||||
run_id=record.run.run_id,
|
||||
sequence=record.next_sequence,
|
||||
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)
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
conn.close()
|
||||
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:
|
||||
conn.close()
|
||||
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,7 +176,8 @@ class AgentTraceRepository:
|
||||
),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
if _conn is None:
|
||||
conn.close()
|
||||
|
||||
def get_run(self, run_id: str) -> AgentRun | None:
|
||||
conn = connect()
|
||||
|
||||
@@ -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"))),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Export Service:多格式文档导出(首批 HTML)。
|
||||
|
||||
模块划分:
|
||||
- document.py Document AST 内部协议 + DocumentExporter Protocol + ExportResult
|
||||
- markdown.py mistune → Document AST 解析
|
||||
- exporters/html.py HtmlExporter(Document AST → HTML5)
|
||||
- service.py 导出任务注册表、后台执行、取消与文件生命周期
|
||||
"""
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""Export 渲染器:Document AST → 具体格式产物。"""
|
||||
@@ -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)
|
||||
@@ -0,0 +1,341 @@
|
||||
"""DocxExporter:Document AST → DOCX(python-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:
|
||||
"""写入可点击的超链接 run(python-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)
|
||||
@@ -0,0 +1,299 @@
|
||||
"""HtmlExporter:Document 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>"
|
||||
@@ -0,0 +1,329 @@
|
||||
"""PdfExporter:Document AST → PDF(reportlab 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 ""
|
||||
@@ -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 图片 token:src 在 attrs.url,alt 来自 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
|
||||
)
|
||||
@@ -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
|
||||
# 取消先到:取消尚未完成的 acquire(Semaphore.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)
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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)
|
||||
@@ -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"])
|
||||
|
||||
@@ -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()
|
||||
@@ -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>,无脚本)
|
||||
"""
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 需前端渲染,已保留为占位代码块"],
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 (?,?,?,?,?,?,?,?,?,?,?)", (
|
||||
|
||||
@@ -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,20 +2,20 @@
|
||||
title: 功能演示导航
|
||||
tags: 演示, 入门
|
||||
---
|
||||
|
||||
# 功能演示导航
|
||||
|
||||
这组笔记用于在真实工作区查看 Markdown、代码高亮、图表和检索效果。文中的项目、日期和数据均为演示内容。
|
||||
|
||||
## 建议阅读顺序
|
||||
|
||||
| 笔记 | 可以查看的功能 |
|
||||
| --- | --- |
|
||||
| 01 Markdown 与大纲 | 元数据、标题层级、列表、引用、表格与行内代码 |
|
||||
| 02 多语言代码与公式 | Shiki 语言配色、代码块标签、数学公式 |
|
||||
| 03 Mermaid 图表集 | 六种常用图型、主题颜色和大图查看 |
|
||||
| 04 星灯项目资料 | 全文搜索、知识库问答与引用定位 |
|
||||
| 05 Skill 与 Plugin 操作样例 | 扩展安装、选区命令和只读笔记检查 |
|
||||
| 笔记 | 可以查看的功能 |
|
||||
| ----------------------------- | ---------------------- |
|
||||
| 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
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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('')
|
||||
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 "<b>原始</b>" 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(""), 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 "<div>重要正文</div>" 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:
|
||||
# P1:note 源超出 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
|
||||
@@ -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())
|
||||
@@ -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(),
|
||||
|
||||
@@ -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())
|
||||
@@ -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:
|
||||
# P2:sin() / 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Generated
+405
@@ -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
@@ -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 Benchmark 和 Provider 增强已经实现;Agent Benchmark、内容导出、Mermaid/函数图像完整编辑导出及社区主题包仍以各自开发说明的状态为准。每项功能必须继续经过现有 Service、Contract、Permission 和 Adapter 边界,不因 Demo 需要在 Vue 组件、Router 或 Agent Runtime 中直接绑定第三方协议。
|
||||
上述列表描述第二阶段技术范围。多模态、MCP Bridge、Plugin Command/Settings、RAG Benchmark、Provider 增强与 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 输入与转换规则
|
||||
|
||||
|
||||
@@ -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 事件树形搜索约 483–617 ms;10000 事件约 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 复核:
|
||||
|
||||
| 操作 | 数量 | P95(ms) | 最大值(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)
|
||||
@@ -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 HtmlExporter(Document AST → 完整 HTML5)
|
||||
│ ├── pdf.py PdfExporter(Document AST → PDF,reportlab)
|
||||
│ └── docx.py DocxExporter(Document AST → DOCX,python-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。
|
||||
- 扩展名/MIME:html→`.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) 的实现边界、状态和验证步骤为准。
|
||||
|
||||
@@ -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.0;Ocean Blue 1.5.0;Midnight 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-06:Markdown 行为与文档滚动适配
|
||||
|
||||
| 主题 | 当前版本 |
|
||||
| --- | --- |
|
||||
| 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,范围 1–200 |
|
||||
| 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 MiB:asyncio 管道拒绝超长行,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 ms,sqlite-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 | 整数 1–6 |
|
||||
| font-size | 有限数值 8–96,单位 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、122322;Markdown 总字符数以原始 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)
|
||||
@@ -41,6 +41,10 @@ pnpm dev
|
||||
|
||||
编辑器使用 Milkdown/Crepe 与 CodeMirror 6;Markdown 展示使用 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)。
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
@@ -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
Reference in New Issue
Block a user