Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ca1605dea | ||
|
|
6cd8913d31 | ||
|
|
ca5b52bc8a | ||
|
|
894f220239 | ||
|
|
d1cbc10fc4 | ||
|
|
9f097ea629 | ||
|
|
47c53b6f38 | ||
|
|
0e3f2a7325 | ||
|
|
e667dd55dd | ||
|
|
89df10bc4e | ||
|
|
95095197df | ||
|
|
cc652508ef | ||
|
|
c853add07e | ||
|
|
f91c26451b | ||
|
|
1f93963797 | ||
|
|
4276cb73c2 | ||
|
|
edc41fdade | ||
|
|
f1ac414866 | ||
|
|
4fc26a11e1 | ||
|
|
6c14047899 | ||
|
|
780e24a399 | ||
|
|
dffafce8b9 | ||
|
|
e29cc427e4 | ||
|
|
406dd42571 | ||
|
|
3dcd469bc0 | ||
|
|
966a94cad8 | ||
|
|
c9c5f81d49 | ||
|
|
5a3546b3ac | ||
|
|
124024a547 | ||
|
|
b87f94551b | ||
|
|
50d7fb4c7d | ||
|
|
04f36524b1 | ||
|
|
f49d1245a1 | ||
|
|
64af1f5165 | ||
|
|
7eae7fba00 | ||
|
|
5c2441464d |
@@ -18,6 +18,8 @@ backend/.env
|
||||
# 运行期生成的 SQLite 索引(vault 下的 Markdown 测试数据需提交)
|
||||
backend/data/*.db*
|
||||
backend/data/credentials/
|
||||
# 运行期导出的 HTML/PDF/DOCX 产物(不提交)
|
||||
backend/data/exports/
|
||||
backend/data/logs/
|
||||
# 阶段验收笔记(验收用,不提交)
|
||||
backend/data/vault/验收/
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Notes Agent(暂命名) 团队开发说明
|
||||
|
||||
> 第二阶段收尾(开发分支,2026-09-07):标准 Agent/RAG Benchmark 与报告页、函数图预览、三格式快照导出及真实 Provider/MCP 结果见[实现与验收记录](docs/development/第二阶段收尾实现与验收-2026-09-07.md)。当前分支尚未合并,不更改下文历史 main 基线。
|
||||
|
||||
> 本文件用于团队开发期间快速配置环境、启动项目并了解当前实现状态,不是正式的项目 README。
|
||||
|
||||
NotesAgent 是本地优先的 AI 笔记与知识库项目。当前可运行形态为 Vue/Vite Web 前端与 FastAPI AI Core:Markdown 和附件保存在本地 Vault,SQLite 管理元数据、全文索引、向量空间、搜索历史、AI 会话、任务、Agent Trace、多模态任务及运行诊断。AI 对话已接入知识库检索,会话与消息由后端持久化并供 Web 和桌面客户端共用。
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# NotesAgent Backend
|
||||
|
||||
> 第二阶段收尾:标准 Agent/RAG Benchmark 与报告页、函数图预览、三格式快照导出及真实 Provider/MCP 结果见[实现与验收记录](../docs/development/第二阶段收尾实现与验收-2026-09-07.md)。当前分支尚未合并,不更改下文历史 main 基线。
|
||||
|
||||
NotesAgent Backend 是基于 Python 3.11+、FastAPI、Pydantic v2 和 SQLite 的本地 AI Core / Agent Core,使用 uv 管理 API 依赖和虚拟环境。
|
||||
|
||||
当前实现包含 Knowledge/Retrieval、Chat、Agent、Tool/Permission、Skill/Plugin、MCP、模型提供商、RAG Benchmark、多模态任务、本地模型调度、Token/音频用量和运行诊断。数据持久化位于后端 SQLite 与 Vault;Tauri Sidecar 生命周期、Stronghold 和操作系统级 Plugin 沙箱属于后续桌面阶段。
|
||||
|
||||
@@ -6,7 +6,7 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
from app.contracts import ToolDefinition
|
||||
from app.services import note_service
|
||||
|
||||
Format = Literal['heading', 'paragraph', 'bold', 'italic', 'strikethrough', 'inline-code', 'bullet-list', 'ordered-list', 'task-list', 'blockquote', 'callout', 'code-block', 'mermaid', 'inline-math', 'math-block', 'link', 'image', 'table', 'horizontal-rule', 'hard-break', 'reference-link', 'html', 'metadata']
|
||||
Format = Literal['heading', 'paragraph', 'bold', 'italic', 'strikethrough', 'inline-code', 'bullet-list', 'ordered-list', 'task-list', 'blockquote', 'callout', 'code-block', 'mermaid', 'function-plot', 'inline-math', 'math-block', 'link', 'image', 'table', 'horizontal-rule', 'hard-break', 'reference-link', 'html', 'metadata']
|
||||
CALLOUTS = ['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']
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ def compose(arguments: ComposeArguments, _):
|
||||
elif kind == 'inline-code':
|
||||
marker = '`' * (max([0, *(len(m[0]) for m in re.finditer(r'`+', text))]) + 1)
|
||||
result = marker + ' ' + text.replace('\n', ' ') + ' ' + marker
|
||||
elif kind in ('code-block', 'mermaid'): result = fenced(text, 'mermaid' if kind == 'mermaid' else a.language)
|
||||
elif kind in ('code-block', 'mermaid', 'function-plot'): result = fenced(text, kind if kind != 'code-block' else a.language)
|
||||
elif kind in ('bullet-list', 'ordered-list', 'task-list'):
|
||||
result = '\n'.join((f'{i + 1}. ' if kind == 'ordered-list' else '- [ ] ' if kind == 'task-list' else '- ') + item.replace('\n', '\n ') for i, item in enumerate(a.items))
|
||||
elif kind == 'blockquote': result = '\n'.join('> ' + line for line in text.split('\n'))
|
||||
@@ -90,7 +90,8 @@ def catalog(_, __):
|
||||
from typing import get_args
|
||||
return {'formats': list(get_args(Format)), 'callouts': CALLOUTS,
|
||||
'workflow': 'Use markdown.compose, then notes.create or notes.patch_markdown to persist. Read notes.read.content_hash before patching. metadata composition replaces the frontmatter only when you explicitly patch it; do not prepend duplicate frontmatter.',
|
||||
'rendering': 'Math, Mermaid, callouts and auto-links depend on editor preferences. HTML is sanitized; scripts are not supported. Heading folding, font size, undo and redo are UI state, not Markdown document syntax. Callout collapsed=null is static, true is folded, false is expanded.'}
|
||||
'function_plot': 'Use a function-plot fenced block: domain: -4, 4 followed by y = x^2 and y = sin(x). At most 16 expressions per block, 16 plots and 8000 total AST nodes per exported document. No arbitrary code execution.',
|
||||
'rendering': 'Function plots, Math, Mermaid, callouts and auto-links depend on editor preferences. HTML is sanitized; scripts are not supported. Heading folding, font size, undo and redo are UI state, not Markdown document syntax. Callout collapsed=null is static, true is folded, false is expanded.'}
|
||||
|
||||
|
||||
async def patch(arguments: PatchArguments, _):
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""通过真实 AgentRuntime 执行标准任务评测,不使用脚本化替代运行器。"""
|
||||
import asyncio
|
||||
from time import perf_counter
|
||||
from uuid import uuid4
|
||||
from app.contracts import (AgentBenchmarkRequest, AgentCaseResult, AgentRunCreateRequest,
|
||||
BenchmarkRun, BenchmarkReport, BenchmarkKind, BenchmarkStatus, BenchmarkEvent, BenchmarkEventType)
|
||||
from app.benchmarks import datasets, service
|
||||
from app.errors import ApiError
|
||||
|
||||
INVALID = {'TOOL_NOT_FOUND', 'TOOL_NOT_ALLOWED', 'TOOL_ARGUMENT_INVALID', 'TOOL_VALIDATION_ERROR'}
|
||||
|
||||
def score(case, run, events, latency, repeat):
|
||||
"""按工具选择、参数、结果、输出和引用要求评定单个样本。"""
|
||||
calls = [e.data for e in events if e.event.value == 'ToolCall']
|
||||
# 使用最大二分匹配,避免宽松的参数子集占用唯一能满足更严格预期的调用;
|
||||
# 每个实际调用最多匹配一个预期调用。
|
||||
matched = {}
|
||||
def assign(expected_index, visited):
|
||||
expected = case.expected_tools[expected_index]
|
||||
for call_index, call in enumerate(calls):
|
||||
if call_index in visited or call.get('name') != expected.name:
|
||||
continue
|
||||
arguments = call.get('arguments', {})
|
||||
if not all(key in arguments and arguments[key] == value for key, value in expected.arguments.items()):
|
||||
continue
|
||||
visited.add(call_index)
|
||||
if call_index not in matched or assign(matched[call_index], visited):
|
||||
matched[call_index] = expected_index
|
||||
return True
|
||||
return False
|
||||
accurate = sum(assign(index, set()) for index in range(len(case.expected_tools)))
|
||||
from collections import Counter
|
||||
actual_names = Counter(call.get('name') for call in calls)
|
||||
expected_names = Counter(tool.name for tool in case.expected_tools)
|
||||
selected = sum(min(count, actual_names[name]) for name, count in expected_names.items())
|
||||
results = run.tool_results
|
||||
checks = {
|
||||
'completed': run.status.value == 'completed',
|
||||
'tools_selected': selected == len(case.expected_tools),
|
||||
'tool_arguments': accurate == len(case.expected_tools),
|
||||
'no_extra_calls': len(calls) <= len(case.expected_tools),
|
||||
'tool_results': all(r.success for r in results),
|
||||
'output': all(text.casefold() in (run.output or '').casefold() for text in case.output_contains),
|
||||
'citation': not case.citation_required or bool(run.citations),
|
||||
'tasks_created': case.tasks_created is None or sum(r.success and r.name == 'tasks.create' for r in results) == case.tasks_created,
|
||||
}
|
||||
return AgentCaseResult(case_id=case.case_id, repeat=repeat, agent_run_id=run.run_id,
|
||||
success=all(checks.values()), tool_calls=len(calls), expected_calls=len(case.expected_tools),
|
||||
selected_calls=selected, accurate_calls=accurate, invalid_calls=sum(r.error_code in INVALID for r in results),
|
||||
steps=run.current_step, latency_ms=latency, token_usage=run.token_usage, checks=checks, error_code=run.error_code)
|
||||
|
||||
def aggregate(cases, planned_total=None):
|
||||
"""汇总已执行样本,并让取消后的未执行样本继续计入计划总数。"""
|
||||
total = len(cases) if planned_total is None else planned_total
|
||||
calls = sum(c.tool_calls for c in cases)
|
||||
expected = sum(c.expected_calls for c in cases)
|
||||
# 微平均同时惩罚遗漏和多余调用;完全没有调用要求时准确率记为不适用。
|
||||
denominator = max(calls, expected)
|
||||
return {'total_cases': total, 'evaluated_cases': len(cases), 'task_success_rate': sum(c.success for c in cases)/total if total else 0,
|
||||
'tool_selection_accuracy': sum(c.selected_calls for c in cases)/denominator if denominator else None,
|
||||
'tool_argument_accuracy': sum(c.accurate_calls for c in cases)/denominator if denominator else None,
|
||||
'invalid_tool_call_rate': sum(c.invalid_calls for c in cases)/calls if calls else None,
|
||||
'average_steps': sum(c.steps for c in cases)/total if total else 0,
|
||||
'average_latency_ms': sum(c.latency_ms for c in cases)/total if total else 0,
|
||||
'token_usage': sum(c.token_usage for c in cases), 'tool_calls': calls, 'expected_calls': expected}
|
||||
|
||||
async def create_run(request: AgentBenchmarkRequest):
|
||||
"""冻结数据集与运行配置,并把评测交给后台真实 Agent Runtime。"""
|
||||
from app.container import container
|
||||
from app.providers.registry import ProviderNotFoundError
|
||||
try:
|
||||
provider = container.providers.get(request.provider_id)
|
||||
except ProviderNotFoundError as exc:
|
||||
raise ApiError(404, 'PROVIDER_NOT_FOUND', 'Provider not found or disabled.') from exc
|
||||
is_mock = provider.config.provider_type.value == 'mock'
|
||||
if request.offline and not is_mock:
|
||||
raise ApiError(422, 'BENCHMARK_OFFLINE_PROVIDER_REQUIRED', 'Offline regression only accepts a mock provider.')
|
||||
if is_mock and not request.offline:
|
||||
raise ApiError(422, 'BENCHMARK_REAL_PROVIDER_REQUIRED', 'Select a real provider or explicitly mark offline regression.')
|
||||
dataset = datasets.load_dataset(request.dataset_id, BenchmarkKind.agent)
|
||||
if not service._evict_terminal():
|
||||
raise ApiError(429, 'BENCHMARK_CAPACITY_EXCEEDED', 'Benchmark capacity exceeded.')
|
||||
run_id = 'benchmark_' + uuid4().hex[:12]
|
||||
snapshot = {**request.model_dump(), 'dataset_hash': dataset.content_hash,
|
||||
'dataset_version': dataset.version, 'execution': 'offline' if request.offline else 'real_agent_runtime',
|
||||
'provider_type': provider.config.provider_type, 'scoring_version': '1.0', 'permission_policy': 'runtime_user_decision'}
|
||||
run = BenchmarkRun(run_id=run_id, kind=BenchmarkKind.agent, dataset_id=dataset.dataset_id,
|
||||
dataset_hash=dataset.content_hash, status=BenchmarkStatus.queued, created_at=service._now(), config_snapshot=snapshot)
|
||||
service._runs[run_id] = run
|
||||
service._events[run_id] = []
|
||||
service._subscribers[run_id] = []
|
||||
service._cancel_flags[run_id] = asyncio.Event()
|
||||
service._tasks[run_id] = asyncio.create_task(execute(run_id, request, dataset, container.agent))
|
||||
return run
|
||||
|
||||
async def execute(run_id, request, dataset, runtime):
|
||||
"""顺序执行样本,传播取消信号,并持续发布可订阅的运行事件。"""
|
||||
flag = service._cancel_flags[run_id]
|
||||
results = []; active = None
|
||||
def emit(kind, data):
|
||||
event = BenchmarkEvent(event=kind, run_id=run_id, sequence=len(service._events[run_id]), data=data, timestamp=service._now())
|
||||
service._events[run_id].append(event)
|
||||
for queue in service._subscribers.get(run_id, []): queue.put_nowait(event)
|
||||
status = BenchmarkStatus.completed
|
||||
error = None
|
||||
try:
|
||||
service._runs[run_id] = service._runs[run_id].model_copy(update={'status': BenchmarkStatus.running, 'started_at': service._now()})
|
||||
emit(BenchmarkEventType.run_started, {'dataset_id': dataset.dataset_id})
|
||||
for case in dataset.cases:
|
||||
for repeat in range(request.repeat):
|
||||
if flag.is_set():
|
||||
status = BenchmarkStatus.cancelled; break
|
||||
started = perf_counter()
|
||||
active = await runtime.create_run(AgentRunCreateRequest(input=case.prompt, provider_id=request.provider_id,
|
||||
model=request.model, allowed_tools=case.allowed_tools, max_steps=request.max_steps,
|
||||
token_budget=request.token_budget, run_timeout_seconds=request.timeout_seconds,
|
||||
tool_timeout_seconds=min(30, request.timeout_seconds), allow_network=request.allow_network,
|
||||
metadata={'benchmark_run_id': run_id, 'case_id': case.case_id}))
|
||||
# 样本仍在运行时就暴露真实 Trace 与权限入口,便于界面处理待决授权。
|
||||
service._runs[run_id].config_snapshot['active_agent_run_id'] = active.run_id
|
||||
wait = asyncio.create_task(runtime.wait(active.run_id))
|
||||
cancel = asyncio.create_task(flag.wait())
|
||||
try:
|
||||
done, _ = await asyncio.wait([wait, cancel], return_when=asyncio.FIRST_COMPLETED)
|
||||
if cancel in done:
|
||||
await runtime.cancel(active.run_id)
|
||||
status = BenchmarkStatus.cancelled
|
||||
finished = await wait
|
||||
finally:
|
||||
cancel.cancel(); await asyncio.gather(cancel, return_exceptions=True)
|
||||
events = [event async for event in runtime.events(active.run_id)]
|
||||
result = score(case, finished, events, (perf_counter()-started)*1000, repeat)
|
||||
results.append(result); active = None
|
||||
service._runs[run_id].progress = len(results)/(len(dataset.cases)*request.repeat)
|
||||
emit(BenchmarkEventType.case_completed, result.model_dump(mode='json'))
|
||||
if status == BenchmarkStatus.cancelled: break
|
||||
except asyncio.CancelledError:
|
||||
status = BenchmarkStatus.cancelled
|
||||
except Exception:
|
||||
status = BenchmarkStatus.failed; error = 'BENCHMARK_RUN_FAILED'
|
||||
finally:
|
||||
if active:
|
||||
await runtime.cancel(active.run_id)
|
||||
await runtime.wait(active.run_id)
|
||||
metrics = aggregate(results, len(dataset.cases)*request.repeat)
|
||||
run = service._runs[run_id]
|
||||
service._runs[run_id] = run.model_copy(update={'status':status, 'metrics':metrics, 'completed_at':service._now(), 'error_code':error})
|
||||
service._reports[run_id] = BenchmarkReport(run_id=run_id, kind=BenchmarkKind.agent,
|
||||
dataset_id=dataset.dataset_id, dataset_hash=dataset.content_hash, status=status,
|
||||
config_snapshot=run.config_snapshot, cases=results, metrics=metrics, error_code=error)
|
||||
emit({BenchmarkStatus.completed: BenchmarkEventType.run_completed, BenchmarkStatus.failed: BenchmarkEventType.run_failed,
|
||||
BenchmarkStatus.cancelled: BenchmarkEventType.run_cancelled}[status], {'metrics':metrics, 'error_code':error})
|
||||
service._cancel_flags.pop(run_id, None); service._subscribers.pop(run_id, None)
|
||||
@@ -17,20 +17,20 @@ from app.config import get_settings
|
||||
from app.contracts import (
|
||||
BenchmarkDatasetInfo,
|
||||
BenchmarkKind,
|
||||
RAGDatasetCase,
|
||||
RAGDatasetCase, AgentDatasetCase,
|
||||
)
|
||||
from app.errors import ApiError
|
||||
|
||||
|
||||
@dataclass
|
||||
class RAGDataset:
|
||||
"""内存中的 RAG 数据集:元信息 + 已校验的 Case 列表 + 内容哈希。"""
|
||||
"""内存中的 RAG / Agent 数据集:元信息 + 已校验的 Case 列表 + 内容哈希。"""
|
||||
|
||||
dataset_id: str
|
||||
kind: BenchmarkKind
|
||||
version: str
|
||||
description: str
|
||||
cases: list[RAGDatasetCase] = field(default_factory=list)
|
||||
cases: list[RAGDatasetCase | AgentDatasetCase] = field(default_factory=list)
|
||||
content_hash: str = ""
|
||||
|
||||
|
||||
@@ -104,10 +104,10 @@ def _dataset_from_raw(raw: dict, raw_bytes: bytes, kind: BenchmarkKind) -> RAGDa
|
||||
{"dataset_id": dataset_id},
|
||||
)
|
||||
|
||||
cases: list[RAGDatasetCase] = []
|
||||
cases: list[RAGDatasetCase | AgentDatasetCase] = []
|
||||
for index, case in enumerate(raw_cases):
|
||||
try:
|
||||
parsed = RAGDatasetCase.model_validate(case)
|
||||
parsed = (AgentDatasetCase if kind == BenchmarkKind.agent else RAGDatasetCase).model_validate(case)
|
||||
except ValidationError as exc:
|
||||
raise ApiError(
|
||||
422,
|
||||
@@ -115,6 +115,13 @@ def _dataset_from_raw(raw: dict, raw_bytes: bytes, kind: BenchmarkKind) -> RAGDa
|
||||
f"Dataset case #{index} is invalid.",
|
||||
{"dataset_id": dataset_id, "case_index": index, "errors": exc.errors()},
|
||||
) from exc
|
||||
if kind == BenchmarkKind.agent:
|
||||
if not (parsed.expected_tools or parsed.output_contains or parsed.citation_required or parsed.tasks_created is not None):
|
||||
raise ApiError(422, 'BENCHMARK_DATASET_INVALID', 'Agent case requires objective expectations.')
|
||||
if any(tool.name not in parsed.allowed_tools for tool in parsed.expected_tools):
|
||||
raise ApiError(422, 'BENCHMARK_DATASET_INVALID', 'Expected tools must be allowed.')
|
||||
cases.append(parsed)
|
||||
continue
|
||||
# 每个 Case 至少要声明一个期望 id,否则无法计算命中/召回
|
||||
if not parsed.expected_note_ids and not parsed.expected_block_ids:
|
||||
raise ApiError(
|
||||
@@ -133,6 +140,8 @@ def _dataset_from_raw(raw: dict, raw_bytes: bytes, kind: BenchmarkKind) -> RAGDa
|
||||
)
|
||||
cases.append(parsed)
|
||||
|
||||
if len(cases) > 100 or len({c.case_id for c in cases}) != len(cases):
|
||||
raise ApiError(422, 'BENCHMARK_DATASET_INVALID', 'Dataset case IDs must be unique; maximum 100 cases.')
|
||||
return RAGDataset(
|
||||
dataset_id=dataset_id,
|
||||
kind=kind,
|
||||
|
||||
@@ -86,6 +86,7 @@ async def _evaluate_one(
|
||||
limit=request.retrieval.top_k,
|
||||
include_snippet=False,
|
||||
rrf_k=request.retrieval.rrf_k,
|
||||
fusion=request.retrieval.fusion,
|
||||
rerank=request.retrieval.rerank,
|
||||
rerank_candidates=request.retrieval.rerank_candidates,
|
||||
score_threshold=request.retrieval.score_threshold,
|
||||
|
||||
@@ -352,3 +352,12 @@ async def wait_for_run(run_id: str) -> BenchmarkRun:
|
||||
if task is not None:
|
||||
await task
|
||||
return _runs.get(run_id)
|
||||
|
||||
|
||||
async def shutdown():
|
||||
loop = asyncio.get_running_loop()
|
||||
active = {rid: task for rid, task in _tasks.items() if not task.done() and task.get_loop() is loop}
|
||||
for rid in active:
|
||||
flag = _cancel_flags.get(rid)
|
||||
if flag: flag.set()
|
||||
await asyncio.gather(*active.values(), return_exceptions=True)
|
||||
|
||||
@@ -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"))),
|
||||
)
|
||||
|
||||
+171
-2
@@ -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
|
||||
|
||||
|
||||
@@ -148,6 +155,7 @@ class SearchRequest(Contract):
|
||||
include_snippet: bool = True
|
||||
# 检索调优参数(Benchmark 与 Skill 共用):控制 RRF / 精排 / 候选池 / 分数阈值。
|
||||
# rerank_candidates=None 表示对全部候选精排(保留原有行为),Benchmark 传显式值。
|
||||
fusion: Literal['rrf', 'weighted'] = 'rrf'
|
||||
rrf_k: int = Field(default=60, ge=1)
|
||||
rerank: bool = True
|
||||
rerank_candidates: int | None = Field(default=None, ge=1)
|
||||
@@ -1214,6 +1222,7 @@ class RAGRetrievalConfig(Contract):
|
||||
其余参数透传到 SearchRequest,由检索引擎实际执行。"""
|
||||
|
||||
top_k: int = Field(default=10, ge=1, le=100)
|
||||
fusion: Literal['rrf', 'weighted'] = 'rrf'
|
||||
rrf_k: int = Field(default=60, ge=1)
|
||||
rerank: bool = True
|
||||
rerank_candidates: int = Field(default=20, ge=1)
|
||||
@@ -1322,6 +1331,51 @@ class RAGCaseResult(Contract):
|
||||
error_code: str | None = None
|
||||
|
||||
|
||||
class ExpectedToolCall(Contract):
|
||||
name: str = Field(min_length=1)
|
||||
arguments: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AgentDatasetCase(Contract):
|
||||
case_id: str = Field(min_length=1)
|
||||
prompt: str = Field(min_length=1, max_length=20000)
|
||||
allowed_tools: list[str] = Field(default_factory=list, max_length=30)
|
||||
expected_tools: list[ExpectedToolCall] = Field(default_factory=list, max_length=30)
|
||||
output_contains: list[str] = Field(default_factory=list)
|
||||
citation_required: bool = False
|
||||
tasks_created: int | None = Field(default=None, ge=0, le=20)
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentBenchmarkRequest(Contract):
|
||||
dataset_id: str = Field(min_length=1)
|
||||
provider_id: str
|
||||
model: str = Field(min_length=1)
|
||||
max_steps: int = Field(default=6, ge=1, le=20)
|
||||
timeout_seconds: int = Field(default=90, ge=1, le=300)
|
||||
token_budget: int = Field(default=6000, ge=1, le=30000)
|
||||
repeat: int = Field(default=1, ge=1, le=3)
|
||||
allow_network: bool = False
|
||||
offline: bool = False
|
||||
|
||||
|
||||
class AgentCaseResult(Contract):
|
||||
case_id: str
|
||||
repeat: int
|
||||
agent_run_id: str | None = None
|
||||
success: bool = False
|
||||
tool_calls: int = 0
|
||||
expected_calls: int = 0
|
||||
selected_calls: int = 0
|
||||
accurate_calls: int = 0
|
||||
invalid_calls: int = 0
|
||||
steps: int = 0
|
||||
latency_ms: float = 0
|
||||
token_usage: int = 0
|
||||
checks: dict[str, bool] = Field(default_factory=dict)
|
||||
error_code: str | None = None
|
||||
|
||||
|
||||
class BenchmarkReport(Contract):
|
||||
run_id: str
|
||||
kind: BenchmarkKind
|
||||
@@ -1330,6 +1384,121 @@ class BenchmarkReport(Contract):
|
||||
status: BenchmarkStatus
|
||||
config_snapshot: dict[str, Any] = Field(default_factory=dict)
|
||||
metrics: dict[str, Any] = Field(default_factory=dict)
|
||||
cases: list[RAGCaseResult] = Field(default_factory=list)
|
||||
cases: list[RAGCaseResult | AgentCaseResult] = 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
|
||||
file_path: str | None = Field(default=None, max_length=1024)
|
||||
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 ExportPalette(Contract):
|
||||
page: str = Field(pattern=r'^#[0-9a-fA-F]{6}$')
|
||||
surface: str = Field(pattern=r'^#[0-9a-fA-F]{6}$')
|
||||
text: str = Field(pattern=r'^#[0-9a-fA-F]{6}$')
|
||||
muted: str = Field(pattern=r'^#[0-9a-fA-F]{6}$')
|
||||
code: str = Field(pattern=r'^#[0-9a-fA-F]{6}$')
|
||||
border: str = Field(pattern=r'^#[0-9a-fA-F]{6}$')
|
||||
accent: str = Field(pattern=r'^#[0-9a-fA-F]{6}$')
|
||||
|
||||
|
||||
class ExportOptions(Contract):
|
||||
palette: ExportPalette | None = None
|
||||
theme_id: str = "light"
|
||||
include_title: bool = True
|
||||
include_metadata: bool = False
|
||||
page_size: str = "A4"
|
||||
code_theme: str = "github-light"
|
||||
|
||||
|
||||
class ExportAsset(Contract):
|
||||
kind: Literal['mermaid', 'math_block', 'math_inline', 'image']
|
||||
source_hash: str = Field(pattern=r'^[a-f0-9]{64}$')
|
||||
png_base64: str
|
||||
|
||||
|
||||
class ExportRequest(Contract):
|
||||
print_html: str | None = None
|
||||
assets: list[ExportAsset] = Field(default_factory=list)
|
||||
title: str = Field(default="", max_length=200)
|
||||
source: ExportSource
|
||||
format: ExportFormat
|
||||
options: ExportOptions = Field(default_factory=ExportOptions)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _asset_limits(self) -> "ExportRequest":
|
||||
if self.print_html is not None and self.format != ExportFormat.pdf:
|
||||
raise ValueError("print_html is only supported for PDF")
|
||||
if self.format != ExportFormat.pdf:
|
||||
if len(self.assets) > 64 or any(len(asset.png_base64) > 2800000 for asset in self.assets):
|
||||
raise ValueError("export asset count or size limit exceeded")
|
||||
return self
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -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,145 @@
|
||||
"""处理栅格资源;PDF 不受导出配额限制,但仍执行路径和格式校验。"""
|
||||
import base64
|
||||
import hashlib
|
||||
import threading
|
||||
from io import BytesIO
|
||||
from PIL import Image
|
||||
from app.errors import ApiError
|
||||
|
||||
_math_lock = threading.Lock()
|
||||
|
||||
def enrich_document(document, file_path=None, unlimited=False, options=None, preserve_alpha=False):
|
||||
"""内嵌 Vault 图片和 MathText,并按导出格式应用配额与主题配色。"""
|
||||
from app.config import get_settings
|
||||
from urllib.parse import unquote, urlsplit
|
||||
vault = get_settings().vault_path.resolve()
|
||||
base = (vault / (file_path or '')).parent if file_path else vault
|
||||
from app.export.themes import pdf_palette
|
||||
palette = pdf_palette(options, []) if unlimited and options else None
|
||||
warnings = []
|
||||
count = total = pixels = 0
|
||||
def visit(node):
|
||||
nonlocal count, total, pixels
|
||||
if node.type in {'image','math_block','math_inline'} or node.attributes.get('static_png'):
|
||||
count += 1
|
||||
try:
|
||||
if not unlimited and count > 64: raise ValueError('resource count')
|
||||
if node.attributes.get('static_png'):
|
||||
raw = node.attributes['static_png']
|
||||
elif node.type == 'image':
|
||||
src = str(node.attributes.get('src',''))
|
||||
if urlsplit(src).scheme or src.startswith('//'): raise ValueError('remote image')
|
||||
path = (base / unquote(src)).resolve()
|
||||
if not path.is_relative_to(vault) or path.suffix.lower() not in {'.png','.jpg','.jpeg','.webp'} or (not unlimited and path.stat().st_size > 2_000_000):
|
||||
raise ValueError('image path or budget')
|
||||
raw = path.read_bytes()
|
||||
else:
|
||||
source = node.text
|
||||
depth = 0
|
||||
for char in source:
|
||||
depth += (char == '{') - (char == '}')
|
||||
if not unlimited and depth > 20: raise ValueError('math depth')
|
||||
if (not unlimited and len(source) > 512) or depth != 0: raise ValueError('math budget')
|
||||
from matplotlib.mathtext import math_to_image
|
||||
from matplotlib import rc_context
|
||||
with _math_lock, rc_context({'savefig.transparent': bool(palette)}):
|
||||
out = BytesIO()
|
||||
math_to_image('$'+source+'$', out, dpi=180, format='png', color=palette['text'] if palette else 'black')
|
||||
raw = out.getvalue()
|
||||
with Image.open(BytesIO(raw)) as image:
|
||||
pixels += image.width * image.height
|
||||
if not unlimited and pixels > 16_000_000: raise ValueError('document pixels')
|
||||
if not unlimited and image.width * image.height > 4_000_000: raise ValueError('image dimensions')
|
||||
out = BytesIO()
|
||||
# 透明像素按 PDF 主题表面色合成;打印 HTML 与 Word 使用白色底色。
|
||||
rgba=image.convert('RGBA'); background=Image.new('RGBA',rgba.size,palette['surface'] if palette else 'white')
|
||||
background.alpha_composite(rgba); (rgba if preserve_alpha else background.convert('RGB')).save(out,'PNG')
|
||||
png=out.getvalue();total += len(png)
|
||||
if not unlimited and total > 8_000_000: raise ValueError('resource bytes')
|
||||
node.attributes['static_png']=png
|
||||
except Exception:
|
||||
node.attributes.pop('static_png', None)
|
||||
warnings.append('图片无法内嵌(仅支持 Vault 内 PNG/JPEG/WebP),已保留替代文字' if node.type=='image'
|
||||
else '公式超出 MathText 语法或资源预算,已保留源码' if node.type.startswith('math')
|
||||
else '静态图表超过文档资源预算,已保留源码')
|
||||
for child in node.children: visit(child)
|
||||
for child in document.children: visit(child)
|
||||
return warnings
|
||||
|
||||
def source_hash(source):
|
||||
return hashlib.sha256(source.strip().encode()).hexdigest()
|
||||
|
||||
def validate_assets(assets, unlimited=False):
|
||||
"""校验前端静态资源并解码为 PNG;PDF 仅解除容量限制,不放宽格式要求。"""
|
||||
result = {}
|
||||
total = pixels = 0
|
||||
for asset in assets:
|
||||
try:
|
||||
raw = base64.b64decode(asset.png_base64, validate=True)
|
||||
total += len(raw)
|
||||
if not unlimited and total > 8 * 1024 * 1024:
|
||||
raise ValueError('asset budget')
|
||||
with Image.open(BytesIO(raw)) as image:
|
||||
pixels += image.width * image.height
|
||||
if not unlimited and pixels > 16_000_000: raise ValueError('document pixel budget')
|
||||
if image.format != 'PNG' or (not unlimited and image.width * image.height > 4_000_000):
|
||||
raise ValueError('image budget')
|
||||
image.load()
|
||||
out = BytesIO()
|
||||
rgba = image.convert('RGBA')
|
||||
background = Image.new('RGBA', rgba.size, 'white')
|
||||
background.alpha_composite(rgba)
|
||||
(rgba if unlimited else background.convert('RGB')).save(out, 'PNG')
|
||||
key = (asset.kind, asset.source_hash)
|
||||
if key in result:
|
||||
raise ValueError('duplicate asset')
|
||||
result[key] = out.getvalue()
|
||||
except Exception as exc:
|
||||
raise ApiError(422, 'EXPORT_ASSET_INVALID', 'Invalid PNG or resource budget exceeded.') from exc
|
||||
return result
|
||||
|
||||
def attach_assets(document, assets):
|
||||
"""按资源类型和源码哈希把已验证图片挂载到对应文档节点。"""
|
||||
def visit(node):
|
||||
source = node.attributes.get('src', '') if node.type == 'image' else node.text
|
||||
key = (node.type, source_hash(source))
|
||||
if key in assets:
|
||||
node.attributes['static_png'] = assets[key]
|
||||
for child in node.children:
|
||||
visit(child)
|
||||
for child in document.children:
|
||||
visit(child)
|
||||
|
||||
def plot_png(plot):
|
||||
"""按 SVG/PDF 共用的裁剪几何,以二倍分辨率生成 DOCX 图像。"""
|
||||
from app.plot.render import compute_geometry, _sx, _sy, _fmt_num
|
||||
from PIL import ImageDraw, ImageFont
|
||||
geo = compute_geometry(plot)
|
||||
image = Image.new('RGB', (geo.width * 2, (geo.height + ((len(plot.expressions)+1)//2)*24) * 2), 'white')
|
||||
draw = ImageDraw.Draw(image)
|
||||
from app.export.fonts import FONT_PATH
|
||||
font = ImageFont.truetype(str(FONT_PATH), 20) if FONT_PATH else ImageFont.load_default(size=20)
|
||||
def line(points, color, width=2):
|
||||
draw.line([(x * 2, y * 2) for x, y in points], fill=color, width=width)
|
||||
sx = lambda x: _sx(x, geo.xmin, geo.xmax)
|
||||
sy = lambda y: _sy(y, geo.ymin, geo.ymax)
|
||||
for x in geo.xticks:
|
||||
if geo.grid: line([(sx(x),52),(sx(x),428)], '#d0d7de')
|
||||
draw.text((sx(x)*2, sy(geo.x_axis_y)*2+8), _fmt_num(x), fill='#57606a', font=font)
|
||||
for y in geo.yticks:
|
||||
if geo.grid: line([(52,sy(y)),(588,sy(y))], '#d0d7de')
|
||||
draw.text((max(0,sx(geo.y_axis_x)*2-75),sy(y)*2), _fmt_num(y), fill='#57606a', font=font)
|
||||
line([(52,sy(geo.x_axis_y)),(588,sy(geo.x_axis_y))], '#57606a')
|
||||
line([(sx(geo.y_axis_x),52),(sx(geo.y_axis_x),428)], '#57606a')
|
||||
for segments, color in zip(geo.polylines,geo.colors):
|
||||
for segment in segments:
|
||||
if len(segment)>1: line(segment,color,3)
|
||||
if geo.xlabel:
|
||||
draw.text((geo.width, (geo.height - 18)*2), geo.xlabel, fill='#1f2328', font=font, anchor='mm')
|
||||
if geo.ylabel:
|
||||
# 纵轴标题横排在左上边距,避免 CJK 文本在 Word 中旋转后不可读。
|
||||
draw.text((24, 24), geo.ylabel, fill='#1f2328', font=font)
|
||||
for index, expression in enumerate(plot.expressions):
|
||||
draw.text((48+(index%2)*620,geo.height*2+index//2*48),expression.label or 'y = '+expression.expression,fill=geo.colors[index],font=font)
|
||||
out=BytesIO(); image.save(out,'PNG')
|
||||
return out.getvalue(), geo.warnings
|
||||
@@ -0,0 +1,66 @@
|
||||
"""使用真实浏览器引擎打印应用生成的自包含主题快照。
|
||||
|
||||
子进程隔离 Playwright 在 Windows 上的事件循环与 Uvicorn,并把浏览器生命周期限制在
|
||||
单次导出内。快照禁止脚本、网络和文件加载,字体与图片必须由客户端提前内嵌。
|
||||
"""
|
||||
from pathlib import Path
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from app.export.document import ExportResult
|
||||
|
||||
|
||||
def browser_executable():
|
||||
"""优先使用显式配置,再查找系统已安装的 Chromium 系浏览器。"""
|
||||
configured = os.environ.get('APP_PDF_BROWSER')
|
||||
if configured:
|
||||
return configured
|
||||
for root in (os.environ.get('PROGRAMFILES(X86)', ''), os.environ.get('PROGRAMFILES', ''), os.environ.get('LOCALAPPDATA', '')):
|
||||
if not root:
|
||||
continue
|
||||
for suffix in ('Microsoft/Edge/Application/msedge.exe', 'Google/Chrome/Application/chrome.exe'):
|
||||
candidate = Path(root) / suffix
|
||||
if candidate.is_file():
|
||||
return str(candidate)
|
||||
return next((p for name in ('chromium','chromium-browser','google-chrome','microsoft-edge') if (p := shutil.which(name))), None)
|
||||
|
||||
|
||||
def render_snapshot(snapshot: str, page_size: str) -> ExportResult:
|
||||
"""在隔离子进程中打印快照,避免阻塞或污染服务进程的事件循环。"""
|
||||
with tempfile.TemporaryDirectory(prefix='notes-pdf-') as directory:
|
||||
source = Path(directory) / 'snapshot.html'
|
||||
output = Path(directory) / 'document.pdf'
|
||||
source.write_text(snapshot, encoding='utf-8')
|
||||
process = subprocess.run([sys.executable, '-m', 'app.export.browser_pdf', str(source), str(output), page_size],
|
||||
capture_output=True, text=True, encoding='utf-8', errors='replace',
|
||||
creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0),
|
||||
cwd=Path(__file__).resolve().parents[2])
|
||||
if process.returncode:
|
||||
raise RuntimeError('PDF browser rendering failed: ' + process.stderr[-2000:])
|
||||
return ExportResult(content=output.read_bytes(), mime_type='application/pdf', warnings=[])
|
||||
|
||||
|
||||
def print_snapshot(source: Path, output: Path, page_size: str):
|
||||
"""在离线、禁用 JavaScript 的上下文中将自包含 HTML 打印为 PDF。"""
|
||||
from playwright.sync_api import sync_playwright
|
||||
with sync_playwright() as runtime:
|
||||
browser = runtime.chromium.launch(executable_path=browser_executable(), headless=True)
|
||||
try:
|
||||
context = browser.new_context(java_script_enabled=False, offline=True)
|
||||
context.route('**/*', lambda route: route.abort())
|
||||
page = context.new_page()
|
||||
page.set_default_timeout(0)
|
||||
page.emulate_media(media='screen')
|
||||
csp = "default-src 'none'; script-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'"
|
||||
page.set_content('<meta http-equiv="Content-Security-Policy" content="'+csp+'">'+source.read_text(encoding='utf-8'), wait_until='load', timeout=0)
|
||||
page.evaluate('async () => { await document.fonts.ready; await Promise.all([...document.images].map(image => image.decode().catch(() => {}))); }')
|
||||
page.pdf(path=str(output), format='Letter' if page_size.lower()=='letter' else 'A4',
|
||||
print_background=True, display_header_footer=False, prefer_css_page_size=False)
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print_snapshot(Path(sys.argv[1]), Path(sys.argv[2]), sys.argv[3])
|
||||
@@ -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,78 @@
|
||||
"""导出器共享工具:URL 协议校验、函数图像预算与占位 warning 文案。
|
||||
|
||||
导出器共享 URL 规则;HTML / DOCX 使用文档资源预算,PDF 不使用这些预算。
|
||||
"""
|
||||
|
||||
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 已按纯文本转义保留"
|
||||
# DOCX 暂不支持静态渲染函数图像,统一回退源码占位
|
||||
PLOT_PLACEHOLDER_WARNING = "函数图像:该格式暂不支持静态渲染,已保留为源码占位"
|
||||
|
||||
# 单篇文档允许的函数图像数量上限,超出部分回退占位,防止多图块并发采样耗尽内存/线程
|
||||
MAX_FUNCTION_PLOTS = 16
|
||||
# 单篇文档允许的函数图像累计 AST 节点预算,超出部分回退占位,防止组合复杂度(多图块
|
||||
# × 多表达式 × 深表达式)在采样求值时长时间占满 CPU
|
||||
MAX_TOTAL_PLOT_NODES = 8000
|
||||
|
||||
|
||||
class FunctionPlotBudget:
|
||||
"""函数图像文档级资源预算:数量上限 + 累计 AST 节点上限。
|
||||
|
||||
HTML 与 DOCX 导出器在渲染每个 function-plot 图块前先问预算,超限即回退源码占位,
|
||||
不解析不采样,避免多图块组合复杂度耗尽内存/CPU。
|
||||
"""
|
||||
|
||||
def __init__(self, max_plots: int | None = None, max_total_nodes: int | None = None) -> None:
|
||||
# 默认读模块常量(便于测试 monkeypatch 常量后重新生效)
|
||||
self.max_plots = MAX_FUNCTION_PLOTS if max_plots is None else max_plots
|
||||
self.max_total_nodes = MAX_TOTAL_PLOT_NODES if max_total_nodes is None else max_total_nodes
|
||||
self.count = 0
|
||||
self.total_nodes = 0
|
||||
|
||||
def check_count(self) -> str | None:
|
||||
"""图块数量 +1;超限返回 warning 文案,否则返回 None。"""
|
||||
self.count += 1
|
||||
if self.count > self.max_plots:
|
||||
return f"函数图像:文档内函数图像数量超过上限 {self.max_plots},已回退为源码占位"
|
||||
return None
|
||||
|
||||
def check_nodes(self, node_count: int) -> str | None:
|
||||
"""累计节点预算校验;超限返回 warning 文案(不累加),否则累加并返回 None。"""
|
||||
if self.total_nodes + node_count > self.max_total_nodes:
|
||||
return f"函数图像:文档内函数图像累计复杂度超过上限 {self.max_total_nodes} 节点,已回退为源码占位"
|
||||
self.total_nodes += node_count
|
||||
return None
|
||||
|
||||
|
||||
def format_plot_diagnostic(diag) -> str:
|
||||
"""把解析诊断格式化为面向用户的 warning 文案。"""
|
||||
loc = f"(第 {diag.line} 行)" if diag.line else ""
|
||||
return f"函数图像:{diag.message}{loc}"
|
||||
|
||||
|
||||
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,417 @@
|
||||
"""DocxExporter:Document AST → DOCX(python-docx)。
|
||||
|
||||
标题、段落、列表、表格等使用原生 Word 元素;函数图、已准备的 Mermaid、
|
||||
受支持的公式与 Vault 图片使用静态图片,无法表示的资源保留源码并记 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.themes import CALLOUTS, print_theme_warning
|
||||
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 密集,调用方应放入线程执行,避免阻塞事件循环。"""
|
||||
from app.export.exporters._common import FunctionPlotBudget
|
||||
self._plot_budget = FunctionPlotBudget()
|
||||
self._doc = DocxDocument()
|
||||
self._configure_normal_style()
|
||||
self._configure_page(options)
|
||||
warnings: list[str] = []
|
||||
print_theme_warning(options, warnings, "DOCX")
|
||||
|
||||
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:
|
||||
if node.attributes.get('static_png'):
|
||||
from PIL import Image
|
||||
png = node.attributes['static_png']
|
||||
with Image.open(BytesIO(png)) as image:
|
||||
section = self._doc.sections[-1]
|
||||
available_width = (section.page_width - section.left_margin - section.right_margin) / 914400
|
||||
# 为 Word 外层段落的行高和间距预留空间,避免图片跨出页面。
|
||||
available_height = (section.page_height - section.top_margin - section.bottom_margin) / 914400 - 0.25
|
||||
width = min(5.8, available_width,
|
||||
image.width / (180 if node.type == 'math_block' else 96),
|
||||
available_height * image.width / image.height)
|
||||
self._doc.add_picture(BytesIO(png), width=Inches(width))
|
||||
return
|
||||
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_callout(self, node, warnings):
|
||||
icon, color = CALLOUTS[node.attributes['kind']]
|
||||
p = self._doc.add_paragraph()
|
||||
p.add_run(icon+' ')
|
||||
self._render_inline(p,node.children[0].children,warnings)
|
||||
for run in p.runs:
|
||||
run.bold = True
|
||||
run.font.color.rgb = RGBColor.from_string(color[1:])
|
||||
shading = OxmlElement('w:shd')
|
||||
shading.set(qn('w:fill'),'F6F8FA')
|
||||
p._p.get_or_add_pPr().append(shading)
|
||||
self._render_children(node.children[1:],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
|
||||
if child.type != "paragraph" and hasattr(self, f"_block_{child.type}"):
|
||||
if first:
|
||||
marker_p = self._doc.add_paragraph()
|
||||
marker_p.paragraph_format.left_indent = indent
|
||||
self._add_run(marker_p, marker)
|
||||
first = False
|
||||
before = len(self._doc.paragraphs)
|
||||
before_tables = len(self._doc.tables)
|
||||
self._render_block(child, warnings)
|
||||
for nested_p in self._doc.paragraphs[before:]:
|
||||
current = nested_p.paragraph_format.left_indent or 0
|
||||
nested_p.paragraph_format.left_indent = current + indent
|
||||
for table in self._doc.tables[before_tables:]:
|
||||
table_indent = table._tbl.tblPr.find(qn("w:tblInd"))
|
||||
if table_indent is None:
|
||||
table_indent = OxmlElement("w:tblInd")
|
||||
table._tbl.tblPr.append(table_indent)
|
||||
current_twips = int(table_indent.get(qn("w:w"), "0"))
|
||||
table_indent.set(qn("w:w"), str(current_twips + indent.twips))
|
||||
table_indent.set(qn("w:type"), "dxa")
|
||||
continue
|
||||
p = self._doc.add_paragraph()
|
||||
p.paragraph_format.left_indent = indent
|
||||
if first:
|
||||
self._add_run(p, marker)
|
||||
first = False
|
||||
if child.type == "paragraph":
|
||||
# 块级容器:展开其行内子节点
|
||||
self._render_inline(p, child.children, warnings)
|
||||
else:
|
||||
# 直接行内节点(text/strong/emphasis/link/codespan 等):走行内渲染保留
|
||||
# 语义(加粗/斜体/超链接),不能只渲染其 children 而丢掉格式。
|
||||
self._render_inline_node(p, child, warnings)
|
||||
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:
|
||||
from app.plot.parser import parse_source
|
||||
from app.export.assets import plot_png
|
||||
over = self._plot_budget.check_count()
|
||||
if not over:
|
||||
parsed = parse_source(node.text)
|
||||
warnings.extend(d.message for d in parsed.diagnostics)
|
||||
if parsed.plot:
|
||||
over = self._plot_budget.check_nodes(parsed.plot.node_count)
|
||||
if not over:
|
||||
png, messages = plot_png(parsed.plot)
|
||||
warnings.extend(messages)
|
||||
self._doc.add_picture(BytesIO(png), width=Inches(5.8))
|
||||
return
|
||||
warnings.append(over or '函数图像无法绘制,已保留源码')
|
||||
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 = False,
|
||||
italic: bool = False,
|
||||
) -> None:
|
||||
if node.attributes.get('static_png'):
|
||||
from PIL import Image
|
||||
with Image.open(BytesIO(node.attributes['static_png'])) as image:
|
||||
width = min(5.8, image.width / (180 if node.type.startswith('math') else 96))
|
||||
paragraph.add_run().add_picture(BytesIO(node.attributes['static_png']), width=Inches(width))
|
||||
return
|
||||
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,327 @@
|
||||
"""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.themes import html_theme, CALLOUTS
|
||||
from app.export.document import Document, DocumentNode, ExportResult
|
||||
from app.export.exporters._common import FunctionPlotBudget, format_plot_diagnostic
|
||||
from app.plot.renderer import FunctionPlotStaticRenderer, StaticRenderRequest
|
||||
|
||||
_MERMAID_WARNING = "mermaid 需前端渲染,已保留为占位代码块"
|
||||
_RAW_HTML_WARNING = "原始 HTML 已按纯文本转义保留"
|
||||
|
||||
# 链接/图片地址允许的协议;无 scheme 的相对地址视为安全,其余协议一律降级
|
||||
_ALLOWED_URL_SCHEMES = frozenset({"http", "https", "mailto"})
|
||||
|
||||
|
||||
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: var(--page); color: var(--text); font: 15px/1.7 -apple-system, 'Segoe UI', 'Microsoft YaHei', sans-serif; }
|
||||
article { max-width: 860px; margin: 0 auto; padding: 40px 48px; background: var(--surface); }
|
||||
|
||||
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: var(--accent); }
|
||||
code { font-family: 'JetBrains Mono', Consolas, monospace; font-size: 0.9em; background: var(--code); padding: 0.15em 0.35em; border-radius: 3px; }
|
||||
pre { background: var(--code); padding: 14px 16px; border-radius: 6px; overflow-x: auto; }
|
||||
pre.code-theme-github-light { background: #f6f8fa; color: #1f2328; }
|
||||
pre.code-theme-github-dark { background: #0d1117; color: #c9d1d9; }
|
||||
pre code { background: none; padding: 0; }
|
||||
pre.mermaid, pre.function-plot { border: 1px dashed var(--border); }
|
||||
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 var(--border); color: var(--muted); }
|
||||
img { max-width: 100%; }
|
||||
table { border-collapse: collapse; margin: 0.8em 0; }
|
||||
th, td { border: 1px solid var(--border); padding: 6px 12px; }
|
||||
th { background: var(--code); }
|
||||
dl.metadata { font-size: 0.85em; color: var(--muted); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); 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 var(--border); margin: 1.4em 0; }
|
||||
.callout { --callout:var(--accent); border:1px solid var(--border); border-left:4px solid var(--callout,var(--accent)); border-radius:6px; margin:1em 0; padding:.8em 1em; }
|
||||
.callout-title { display:block; font-weight:bold; color:var(--callout,var(--accent)); }
|
||||
.callout-content { color:var(--text); }
|
||||
.callout[data-kind="warning"], .callout[data-kind="question"] { --callout:#805400; }
|
||||
.callout[data-kind="danger"], .callout[data-kind="failure"], .callout[data-kind="bug"] { --callout:#b42318; }
|
||||
.callout[data-kind="tip"], .callout[data-kind="success"] { --callout:#176f41; }
|
||||
.callout[data-kind="example"], .callout[data-kind="abstract"], .callout[data-kind="important"] { --callout:#7041a0; }
|
||||
.theme-dark .callout, .theme-midnight-purple .callout { --callout:#a5d6ff; }
|
||||
.theme-dark .callout[data-kind="warning"], .theme-midnight-purple .callout[data-kind="warning"], .theme-dark .callout[data-kind="question"], .theme-midnight-purple .callout[data-kind="question"] { --callout:#f2cc60; }
|
||||
.theme-dark .callout[data-kind="danger"], .theme-midnight-purple .callout[data-kind="danger"], .theme-dark .callout[data-kind="failure"], .theme-midnight-purple .callout[data-kind="failure"], .theme-dark .callout[data-kind="bug"], .theme-midnight-purple .callout[data-kind="bug"] { --callout:#ffa198; }
|
||||
.theme-dark .callout[data-kind="tip"], .theme-midnight-purple .callout[data-kind="tip"], .theme-dark .callout[data-kind="success"], .theme-midnight-purple .callout[data-kind="success"] { --callout:#7ee787; }
|
||||
.theme-dark .callout[data-kind="important"], .theme-midnight-purple .callout[data-kind="important"], .theme-dark .callout[data-kind="abstract"], .theme-midnight-purple .callout[data-kind="abstract"], .theme-dark .callout[data-kind="example"], .theme-midnight-purple .callout[data-kind="example"] { --callout:#d2a8ff; }
|
||||
figure.function-plot svg text { fill:var(--muted); }
|
||||
figure.function-plot svg line { stroke:var(--border); }
|
||||
figure.function-plot svg line[stroke="#57606a"] { stroke:var(--muted); }
|
||||
summary.callout-title { cursor:pointer; display:list-item; }
|
||||
.callout { overflow-wrap:anywhere; }
|
||||
""".strip()
|
||||
|
||||
|
||||
class HtmlExporter:
|
||||
"""实现 DocumentExporter:递归渲染 Document AST 为完整 HTML5 文档。"""
|
||||
|
||||
def render(self, document: Document, options: ExportOptions) -> ExportResult:
|
||||
"""同步渲染;CPU 密集,调用方应放入线程执行,避免阻塞事件循环。"""
|
||||
self._options = options
|
||||
self._plot_budget = FunctionPlotBudget()
|
||||
self._plot_renderer = FunctionPlotStaticRenderer()
|
||||
warnings: list[str] = []
|
||||
self._theme_id, self._theme_css = html_theme(options.theme_id, warnings)
|
||||
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>{self._theme_css}{_BASE_CSS}</style>")
|
||||
parts.append("</head>")
|
||||
parts.append("<body>")
|
||||
parts.append(f'<article class="theme-{html.escape(self._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:
|
||||
if node.attributes.get('static_png'):
|
||||
import base64
|
||||
data = base64.b64encode(node.attributes['static_png']).decode()
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
width = ''
|
||||
if node.type.startswith('math'):
|
||||
with Image.open(BytesIO(node.attributes['static_png'])) as image:
|
||||
width = f'width:{image.width*96/180:.1f}px;vertical-align:middle;'
|
||||
return f'<img alt="{html.escape(node.text or node.type)}" src="data:image/png;base64,{data}" style="{width}max-width:100%">'
|
||||
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_callout(self, node, warnings):
|
||||
kind = node.attributes['kind']
|
||||
title = self._render_children(node.children[0].children,warnings)
|
||||
icon = html.escape(CALLOUTS[kind][0])
|
||||
body = self._render_children(node.children[1:],warnings)
|
||||
heading = f'<span aria-hidden="true">{icon}</span> {title}'
|
||||
if node.attributes.get('fold'):
|
||||
opened = ' open' if node.attributes['fold'] == '+' else ''
|
||||
return f'<details class="callout" data-kind="{kind}"{opened}><summary class="callout-title">{heading}</summary><div class="callout-content">{body}</div></details>'
|
||||
return f'<aside class="callout" data-kind="{kind}"><div class="callout-title">{heading}</div><div class="callout-content">{body}</div></aside>'
|
||||
|
||||
def _render_blockquote(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
return f"<blockquote>{self._render_children(node.children, warnings)}</blockquote>"
|
||||
|
||||
def _render_list(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
tag = "ol" if node.attributes.get("ordered") else "ul"
|
||||
return f"<{tag}>{self._render_children(node.children, warnings)}</{tag}>"
|
||||
|
||||
def _render_list_item(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
inner = self._render_children(node.children, warnings)
|
||||
if node.attributes.get("task"):
|
||||
checked = " checked" if node.attributes.get("checked") else ""
|
||||
return (
|
||||
'<li class="task-list-item">'
|
||||
f'<input type="checkbox" disabled{checked}>{inner}</li>'
|
||||
)
|
||||
return f"<li>{inner}</li>"
|
||||
|
||||
def _render_table(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
rows = node.children
|
||||
head_rows = [r for r in rows if r.attributes.get("head")]
|
||||
body_rows = [r for r in rows if not r.attributes.get("head")]
|
||||
parts = ["<table>"]
|
||||
if head_rows:
|
||||
parts.append("<thead>")
|
||||
parts.extend(self._render_node(r, warnings) for r in head_rows)
|
||||
parts.append("</thead>")
|
||||
if body_rows:
|
||||
parts.append("<tbody>")
|
||||
parts.extend(self._render_node(r, warnings) for r in body_rows)
|
||||
parts.append("</tbody>")
|
||||
parts.append("</table>")
|
||||
return "".join(parts)
|
||||
|
||||
def _render_table_row(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
return f"<tr>{self._render_children(node.children, warnings)}</tr>"
|
||||
|
||||
def _render_table_cell(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
tag = "th" if node.attributes.get("head") else "td"
|
||||
return f"<{tag}>{self._render_children(node.children, warnings)}</{tag}>"
|
||||
|
||||
def _render_code_block(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
lang = str(node.attributes.get("language") or "")
|
||||
code = html.escape(node.text)
|
||||
lang_cls = f' class="language-{html.escape(lang)}"' if lang else ""
|
||||
theme = html.escape(self._options.code_theme)
|
||||
return f'<pre class="code-theme-{theme}"><code{lang_cls}>{code}</code></pre>'
|
||||
|
||||
def _render_thematic_break(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
return "<hr>"
|
||||
|
||||
def _render_mermaid(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
warnings.append(_MERMAID_WARNING)
|
||||
return f'<pre class="mermaid">{html.escape(node.text)}</pre>'
|
||||
|
||||
def _render_function_plot(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
# 文档级数量上限:超出部分直接回退占位,不解析不采样,防止海量图像耗尽资源
|
||||
over = self._plot_budget.check_count()
|
||||
if over is not None:
|
||||
warnings.append(over)
|
||||
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(format_plot_diagnostic(diag))
|
||||
if parsed.plot is None:
|
||||
return f'<pre class="function-plot">{html.escape(node.text)}</pre>'
|
||||
# 文档级累计复杂度预算:超出后回退占位,不再采样求值
|
||||
over = self._plot_budget.check_nodes(parsed.plot.node_count)
|
||||
if over is not None:
|
||||
warnings.append(over)
|
||||
return f'<pre class="function-plot">{html.escape(node.text)}</pre>'
|
||||
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)
|
||||
from app.plot.render import theme_svg
|
||||
rendered.content = theme_svg(rendered.content, self._options.theme_id)
|
||||
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,411 @@
|
||||
"""PdfExporter:Document AST → PDF(reportlab platypus)。
|
||||
|
||||
v1 为文本优先:标题/段落/行内强调与链接/列表/引用/表格/代码块/数学文本均可导出;
|
||||
function_plot 内嵌为矢量图(reportlab Drawing),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,
|
||||
Indenter,
|
||||
XPreformatted,
|
||||
SimpleDocTemplate,
|
||||
Spacer,
|
||||
Table,
|
||||
TableStyle,
|
||||
)
|
||||
from reportlab.platypus.flowables import HRFlowable
|
||||
|
||||
from app.contracts import ExportOptions
|
||||
from app.export.themes import CALLOUTS, pdf_palette
|
||||
from app.export.document import Document, DocumentNode, ExportResult
|
||||
from app.export.exporters._common import (
|
||||
MERMAID_WARNING,
|
||||
RAW_HTML_WARNING,
|
||||
format_meta_value,
|
||||
format_plot_diagnostic,
|
||||
safe_url,
|
||||
)
|
||||
from app.plot.render_reportlab import render_drawing
|
||||
from app.plot.renderer import FunctionPlotStaticRenderer, StaticRenderRequest
|
||||
|
||||
from app.export.fonts import FONT as _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(palette) -> dict[str, ParagraphStyle]:
|
||||
body = ParagraphStyle(
|
||||
"pdf-body",
|
||||
fontName=_FONT,
|
||||
textColor=palette["text"],
|
||||
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=palette["muted"],
|
||||
spaceBefore=4,
|
||||
spaceAfter=6,
|
||||
)
|
||||
code = ParagraphStyle(
|
||||
"pdf-code",
|
||||
parent=body,
|
||||
fontSize=9,
|
||||
leading=12,
|
||||
leftIndent=6,
|
||||
rightIndent=6,
|
||||
backColor=palette["code"],
|
||||
borderColor=palette["border"],
|
||||
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=palette["text"], fontSize=10
|
||||
)
|
||||
meta = ParagraphStyle("pdf-meta", parent=body, fontSize=8.5, leading=13, textColor=palette["muted"])
|
||||
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,
|
||||
keepWithNext=True,
|
||||
)
|
||||
return styles
|
||||
|
||||
|
||||
class PdfExporter:
|
||||
"""实现 DocumentExporter:递归渲染 Document AST 为 PDF 字节流。"""
|
||||
|
||||
def render(self, document: Document, options: ExportOptions) -> ExportResult:
|
||||
"""同步渲染;CPU 密集,调用方应放入线程执行,避免阻塞事件循环。"""
|
||||
warnings: list[str] = []
|
||||
self._palette = pdf_palette(options, warnings)
|
||||
self._styles = _make_styles(self._palette)
|
||||
if _FONT == "STSong-Light": warnings.append("PDF 使用 CID 字体,阅读器需提供中文字体;可配置 APP_EXPORT_FONT 嵌入 TrueType 字体")
|
||||
|
||||
page = _PAGE_SIZES.get((options.page_size or "A4").lower(), A4)
|
||||
self._options = options
|
||||
self._plot_renderer = FunctionPlotStaticRenderer()
|
||||
# 内容区宽度(左右各 20mm 边距),供函数图像缩放适配页面
|
||||
self._plot_width = page[0] - 40 * mm - 12
|
||||
self._plot_height = page[1] - 36 * mm - 12
|
||||
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)
|
||||
|
||||
def paint_page(canvas, template):
|
||||
canvas.saveState()
|
||||
canvas.setFillColor(self._palette['page'])
|
||||
canvas.rect(0, 0, page[0], page[1], fill=1, stroke=0)
|
||||
canvas.setFillColor(self._palette['surface'])
|
||||
canvas.roundRect(12*mm, 10*mm, page[0]-24*mm, page[1]-20*mm, 5*mm, fill=1, stroke=0)
|
||||
canvas.restoreState()
|
||||
doc.build(story, onFirstPage=paint_page, onLaterPages=paint_page)
|
||||
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:
|
||||
if node.attributes.get('static_png'):
|
||||
from reportlab.platypus import Image
|
||||
image = Image(BytesIO(node.attributes['static_png']))
|
||||
scale = min(1, self._plot_width / image.imageWidth, self._plot_height / image.imageHeight)
|
||||
image.drawWidth = image.imageWidth * scale
|
||||
image.drawHeight = image.imageHeight * scale
|
||||
story.append(image)
|
||||
return
|
||||
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_callout(self, node, story, warnings):
|
||||
kind = node.attributes['kind']
|
||||
icon, color = CALLOUTS[kind]
|
||||
from reportlab.lib.colors import HexColor
|
||||
background = HexColor(self._palette['code'])
|
||||
if .2126*background.red + .7152*background.green + .0722*background.blue < .5:
|
||||
color = {'#0969da':'#a5d6ff','#7041a0':'#d2a8ff','#176f41':'#7ee787','#805400':'#f2cc60','#b42318':'#ffa198','#57606a':self._palette['muted']}[color]
|
||||
title = self._render_inline(node.children[0].children,warnings)
|
||||
style = ParagraphStyle('callout-'+kind,parent=self._styles['body'],textColor=color,
|
||||
backColor=self._palette['code'],borderColor=color,borderWidth=1,borderPadding=6,spaceBefore=8,spaceAfter=8)
|
||||
story.append(Paragraph(_html.escape(icon)+' '+title,style))
|
||||
self._render_children(node.children[1:],story,warnings)
|
||||
|
||||
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=self._palette['muted'])
|
||||
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)
|
||||
# 按 AST 顺序逐段输出:正文暂存为行内标记文本,遇到嵌套列表先 flush 再递归、
|
||||
# 之后继续后续正文,保持「父段—子列表—后续段」的原始顺序(而不是把所有正文
|
||||
# 都挤到子列表之前)。直接行内节点(text/strong/link 等)走 _render_inline_node,
|
||||
# 保留加粗/链接等语义,不能只渲染其 children 而丢掉格式。
|
||||
parts: list[str] = []
|
||||
first = True
|
||||
|
||||
def flush() -> None:
|
||||
nonlocal first
|
||||
text = "<br/>".join(parts)
|
||||
if first:
|
||||
text = marker + text
|
||||
first = False
|
||||
if text:
|
||||
story.append(Paragraph(text, style))
|
||||
parts.clear()
|
||||
|
||||
for child in item.children:
|
||||
if child.type == "list":
|
||||
flush()
|
||||
self._block_list(child, story, warnings, indent + 14, color)
|
||||
elif child.type == "paragraph":
|
||||
parts.append(self._render_inline(child.children, warnings))
|
||||
elif hasattr(self, f"_block_{child.type}"):
|
||||
flush()
|
||||
# 表格、警告框等块级内容也要保持在列表缩进框内。
|
||||
story.append(Indenter(left=indent))
|
||||
self._render_block(child, story, warnings)
|
||||
story.append(Indenter(left=-indent))
|
||||
else:
|
||||
parts.append(self._render_inline_node(child, warnings))
|
||||
flush()
|
||||
|
||||
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, self._palette["border"]),
|
||||
("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), self._palette["code"]))
|
||||
table.setStyle(TableStyle(commands))
|
||||
story.append(table)
|
||||
|
||||
def _block_code_block(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
|
||||
story.append(XPreformatted(_html.escape(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=self._palette["border"], 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(XPreformatted(_html.escape(node.text), self._styles["code"]))
|
||||
|
||||
def _block_function_plot(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
|
||||
# 解析与渲染共同纳入局部异常回退:单个图像失败只回退占位 + warning,
|
||||
# 绝不阻断整篇导出(含复杂表达式触发的 RecursionError 等异常)。
|
||||
try:
|
||||
request = StaticRenderRequest(
|
||||
kind="function_plot", source=node.text, theme=self._options.theme_id
|
||||
)
|
||||
from app.plot.parser import parse_source
|
||||
parsed = parse_source(request.source, unlimited=True)
|
||||
for diag in parsed.diagnostics:
|
||||
warnings.append(format_plot_diagnostic(diag))
|
||||
if parsed.plot is None:
|
||||
story.append(XPreformatted(_html.escape(node.text), self._styles["code"]))
|
||||
return
|
||||
# Drawing 本身即 Flowable,缩放后追加到 story,与 HTML 视觉一致
|
||||
drawing = render_drawing(parsed.plot, width=self._plot_width, palette=self._palette, unlimited=True, max_height=self._plot_height)
|
||||
story.append(drawing)
|
||||
except Exception as exc:
|
||||
warnings.append(f"函数图像:解析或渲染失败,已回退占位({exc})")
|
||||
story.append(XPreformatted(_html.escape(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:
|
||||
if node.attributes.get('static_png'):
|
||||
import base64
|
||||
from PIL import Image as PILImage
|
||||
raw = node.attributes['static_png']
|
||||
with PILImage.open(BytesIO(raw)) as image:
|
||||
scale = min(.4 if node.type.startswith('math') else 1, 350/image.width, 160/image.height)
|
||||
width, height = image.width*scale, image.height*scale
|
||||
data = base64.b64encode(raw).decode()
|
||||
return f'<img src="data:image/png;base64,{data}" width="{width}" height="{height}" valign="middle"/>'
|
||||
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)}" color="{self._palette["accent"]}">{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,23 @@
|
||||
"""嵌入可用的 CJK TrueType 字体,找不到时保留可移植的 CID 字体回退。"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
from reportlab.pdfbase.cidfonts import UnicodeCIDFont
|
||||
|
||||
def register_font():
|
||||
"""按显式配置、系统字体、Linux 字体的顺序注册 PDF 中文字体。"""
|
||||
candidates = [os.getenv('APP_EXPORT_FONT',''),
|
||||
str(Path(os.getenv('WINDIR','C:/Windows'))/'Fonts/simsun.ttc'),
|
||||
'/usr/share/fonts/truetype/arphic/uming.ttc']
|
||||
for candidate in candidates:
|
||||
if candidate and Path(candidate).is_file():
|
||||
try:
|
||||
pdfmetrics.registerFont(TTFont('NotesExportCJK',candidate,subfontIndex=0))
|
||||
return 'NotesExportCJK', Path(candidate)
|
||||
except Exception:
|
||||
continue
|
||||
pdfmetrics.registerFont(UnicodeCIDFont('STSong-Light'))
|
||||
return 'STSong-Light', None
|
||||
|
||||
FONT, FONT_PATH = register_font()
|
||||
@@ -0,0 +1,260 @@
|
||||
"""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 mistune.plugins.table import table_in_list, table_in_quote
|
||||
import re
|
||||
from copy import deepcopy
|
||||
from app.export.themes import CALLOUTS, ALIASES
|
||||
|
||||
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)
|
||||
table_in_quote(renderer)
|
||||
table_in_list(renderer)
|
||||
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":
|
||||
children = deepcopy(token.get('children', []))
|
||||
first = children[0] if children else {}
|
||||
inline = first.get('children', [])
|
||||
if first.get('type') == 'paragraph' and inline and inline[0].get('type') == 'text':
|
||||
match = re.match(r'^\[!([\w-]+)\]([+-]?)[ \t]*', inline[0].get('raw', ''))
|
||||
if match:
|
||||
name = match[1].lower()
|
||||
name = ALIASES.get(name, name)
|
||||
if name not in CALLOUTS:
|
||||
name = 'note'
|
||||
inline[0]['raw'] = inline[0]['raw'][match.end():]
|
||||
split = next((i for i,t in enumerate(inline) if t['type'] in ('softbreak','linebreak')),len(inline))
|
||||
title = inline[:split]
|
||||
if not any(t.get('raw') or t.get('children') for t in title):
|
||||
title = [{'type':'text','raw':match[1].lower().capitalize()}]
|
||||
first['children'] = inline[split+1:]
|
||||
if not first['children']:
|
||||
children.pop(0)
|
||||
heading = DocumentNode(type='paragraph',node_id=self.next_id(),children=self.map_inline(title))
|
||||
return DocumentNode(type='callout',node_id=self.next_id(),
|
||||
attributes={'kind':name,'fold':match[2]},
|
||||
children=[heading,*self.map_blocks(children)])
|
||||
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 == "inline_html":
|
||||
# 保留行内 HTML 的来源标记,仅供 PDF 资源扫描识别 img;最终 HTML 仍由前端净化。
|
||||
return DocumentNode(type="text", node_id=self.next_id(), text=token.get("raw", ""), attributes={"raw_html": True})
|
||||
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,444 @@
|
||||
"""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, unlimited: bool = False) -> 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 not unlimited and 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 not unlimited and 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, "", {"file_path": source.file_path} if source.file_path else None
|
||||
|
||||
|
||||
async def create_export(request: ExportRequest) -> ExportJob:
|
||||
"""创建导出任务,立即返回 queued 的 ExportJob,由后台 Task 渲染。"""
|
||||
markdown, title, metadata = await _resolve_source(request.source, request.format == ExportFormat.pdf)
|
||||
title = request.title or title
|
||||
from app.export.assets import validate_assets
|
||||
assets = await asyncio.to_thread(validate_assets, request.assets, request.format == ExportFormat.pdf)
|
||||
|
||||
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, assets, request.print_html)
|
||||
)
|
||||
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,
|
||||
assets: dict | None = None,
|
||||
print_html: str | None = None,
|
||||
) -> 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 密集的同步工作,放入线程执行避免阻塞事件循环,
|
||||
# 使运行中的取消能在渲染边界生效;写文件前再次检查取消。
|
||||
if format == ExportFormat.pdf and print_html is not None:
|
||||
from app.export.browser_pdf import render_snapshot
|
||||
result = await asyncio.to_thread(render_snapshot, print_html, options.page_size)
|
||||
else:
|
||||
document = await asyncio.to_thread(parse_document, markdown)
|
||||
document.attributes["title"] = title
|
||||
from app.export.assets import attach_assets
|
||||
attach_assets(document, assets or {})
|
||||
if metadata:
|
||||
document.attributes["metadata"] = metadata
|
||||
|
||||
from app.export.assets import enrich_document
|
||||
resource_warnings = await asyncio.to_thread(enrich_document, document, (metadata or {}).get('file_path'), format == ExportFormat.pdf, options)
|
||||
result = await asyncio.to_thread(_render_document, document, options, format)
|
||||
result.warnings[:0] = resource_warnings
|
||||
if cancel_event.is_set():
|
||||
raise ExportCancelled()
|
||||
if format != ExportFormat.pdf and 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)
|
||||
|
||||
|
||||
async def preview_resources(request: ExportRequest):
|
||||
"""为浏览器渲染器准备通过 Vault 校验的图片和静态函数图。"""
|
||||
import base64
|
||||
from app.export.assets import enrich_document
|
||||
from app.plot.parser import parse_source
|
||||
from app.plot.render import render_svg
|
||||
from app.export.document import Document, DocumentNode
|
||||
from html.parser import HTMLParser
|
||||
markdown, _, metadata = await _resolve_source(request.source, True)
|
||||
def prepare():
|
||||
document = parse_document(markdown)
|
||||
images, plots = [], []
|
||||
class HtmlImages(HTMLParser):
|
||||
# 原始 HTML 只提取 img.src;路径、扩展名和图片格式仍交给 enrich_document 校验。
|
||||
# 行内代码和代码块在 AST 中不是 HTML 节点,因此不会误当作图片资源。
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag == 'img':
|
||||
src = dict(attrs).get('src')
|
||||
if src:
|
||||
visit(DocumentNode(type='image', node_id='html-image', attributes={'src':src}))
|
||||
def visit(node):
|
||||
if node.type == 'html_block' or node.attributes.get('raw_html'):
|
||||
parser = HtmlImages(convert_charrefs=True)
|
||||
parser.feed(node.text)
|
||||
parser.close()
|
||||
if node.type == 'image':
|
||||
warnings = enrich_document(Document(node_id='pdf-resources', children=[node]), (metadata or {}).get('file_path'), True, request.options, preserve_alpha=True)
|
||||
raw = node.attributes.get('static_png')
|
||||
images.append({'source': node.attributes.get('src',''), 'data': 'data:image/png;base64,'+base64.b64encode(raw).decode() if raw else None, 'warnings': warnings})
|
||||
if node.type == 'function_plot':
|
||||
parsed = parse_source(node.text, unlimited=True)
|
||||
result = render_svg(parsed.plot, request.options.theme_id, unlimited=True) if parsed.plot else None
|
||||
plots.append({'source':node.text, 'svg':result.content if result else '', 'warnings':[d.message for d in parsed.diagnostics]+(result.warnings if result else [])})
|
||||
for child in node.children: visit(child)
|
||||
for child in document.children: visit(child)
|
||||
return {'images':images,'plots':plots}
|
||||
return await asyncio.to_thread(prepare)
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Export palettes are fixed data; arbitrary theme CSS is never executed."""
|
||||
PALETTES = {
|
||||
'ocean-blue': ('#edf5fa','#ffffff','#183a50','#46667a','#e6f1f8','#a6c5d9','#086b9c'),
|
||||
'light': ('#f6f7f9','#ffffff','#1f2328','#57606a','#eaeef2','#d0d7de','#0969da'),
|
||||
'dark': ('#010409','#0d1117','#e6edf3','#b1bac4','#21262d','#57606a','#79c0ff'),
|
||||
'sepia': ('#eee5d2','#faf4e6','#463b2d','#6b5943','#eae0cd','#b5a58b','#80532a'),
|
||||
'paper-moments': ('#f4ede0','#fffdf4','#514638','#79654f','#eee7d8','#b8a58f','#8c503b'),
|
||||
'midnight-purple': ('#100c18','#191322','#eee7f8','#c0accf','#30253f','#705a85','#d3a7ff'),
|
||||
}
|
||||
|
||||
def html_theme(theme_id, warnings):
|
||||
if theme_id not in PALETTES:
|
||||
warnings.append(f'HTML 不支持主题 {theme_id},已使用 light 导出配色')
|
||||
theme_id = 'light'
|
||||
names = ('page','surface','text','muted','code','border','accent')
|
||||
return theme_id, ':root{' + ';'.join(f'--{k}:{v}' for k,v in zip(names,PALETTES[theme_id])) + '}'
|
||||
|
||||
def print_theme_warning(options, warnings, format_name):
|
||||
if options.theme_id != 'light':
|
||||
warnings.append(f'{format_name} 使用浅色打印样式,不支持主题 {options.theme_id};需要主题配色请导出 HTML')
|
||||
|
||||
# Semantic type, portable title symbol and contrasting print color.
|
||||
CALLOUTS = {
|
||||
'note': ('i','#0969da'), 'abstract': ('=','#7041a0'),
|
||||
'info': ('i','#0969da'), 'todo': ('[ ]','#0969da'),
|
||||
'tip': ('+','#176f41'), 'success': ('+','#176f41'),
|
||||
'question': ('?','#805400'), 'warning': ('!','#805400'),
|
||||
'failure': ('x','#b42318'), 'danger': ('!','#b42318'),
|
||||
'bug': ('!','#b42318'), 'important': ('!','#7041a0'), 'example': ('*','#7041a0'), 'quote': ('>','#57606a'),
|
||||
}
|
||||
ALIASES = {'summary':'abstract','tldr':'abstract','hint':'tip',
|
||||
'check':'success','done':'success','help':'question','faq':'question',
|
||||
'caution':'warning','attention':'warning','fail':'failure','missing':'failure',
|
||||
'error':'danger','cite':'quote'}
|
||||
|
||||
|
||||
def pdf_palette(options, warnings):
|
||||
if options.palette is not None:
|
||||
return options.palette.model_dump()
|
||||
theme_id = options.theme_id
|
||||
if theme_id not in PALETTES:
|
||||
warnings.append(f'PDF 不支持主题 {theme_id},已使用 light 导出配色')
|
||||
theme_id = 'light'
|
||||
return dict(zip(('page','surface','text','muted','code','border','accent'), PALETTES[theme_id]))
|
||||
@@ -5,6 +5,8 @@ import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import hashlib
|
||||
from collections import OrderedDict
|
||||
from contextlib import closing
|
||||
from contextvars import ContextVar
|
||||
from functools import wraps
|
||||
@@ -239,6 +241,11 @@ class Runtime:
|
||||
|
||||
runtime = Runtime()
|
||||
|
||||
# 对确定性的单文本本地向量做有界内存复用。键包含模型目录、不可变版本和冻结运行配置;
|
||||
# 远程 API 响应以及模型不可用时的回退结果都不进入缓存。
|
||||
_embedding_cache = OrderedDict()
|
||||
_EMBEDDING_CACHE_TTL = 600
|
||||
|
||||
|
||||
class LocalEmbedding:
|
||||
dim = 384
|
||||
@@ -264,9 +271,24 @@ class LocalEmbedding:
|
||||
|
||||
async def embed_documents(self, texts):
|
||||
config = (self._config or configuration()).model_copy(deep=True)
|
||||
from app.retrieval.provenance import record_embedding
|
||||
cache_key = None
|
||||
if len(texts) == 1 and read_state(config.embedding_model)['status'] == 'installed' and interpreter(config).is_file():
|
||||
cache_key = (str(model_path(config.embedding_model).resolve()), config.model_dump_json(),
|
||||
hashlib.sha256(texts[0].encode()).hexdigest())
|
||||
cached = _embedding_cache.get(cache_key)
|
||||
if cached and time.monotonic() - cached[0] < _EMBEDDING_CACHE_TTL:
|
||||
_embedding_cache.move_to_end(cache_key)
|
||||
record_embedding(query_embedding_cache='hit')
|
||||
return [list(cached[1])]
|
||||
record_embedding(query_embedding_cache='miss')
|
||||
token = runtime_context.set(config)
|
||||
try:
|
||||
return await runtime.infer(config.embedding_model, "embedding", {"texts": texts}, priority=embedding_priority.get())
|
||||
vectors = await runtime.infer(config.embedding_model, "embedding", {"texts": texts}, priority=embedding_priority.get())
|
||||
if cache_key and len(vectors) == 1:
|
||||
_embedding_cache[cache_key] = (time.monotonic(), tuple(vectors[0]))
|
||||
while len(_embedding_cache) > 128: _embedding_cache.popitem(last=False)
|
||||
return vectors
|
||||
finally:
|
||||
runtime_context.reset(token)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from starlette.exceptions import HTTPException as StarletteHttpException
|
||||
from app.config import get_settings
|
||||
from app.container import container
|
||||
from app.errors import ApiError, api_error_handler, http_error_handler, validation_error_handler
|
||||
from app.export import service as export_service
|
||||
from app.routes import router as api_router
|
||||
from app.media_routes import router as media_router
|
||||
from app.local_model_routes import router as local_model_router
|
||||
@@ -27,11 +28,15 @@ settings = get_settings()
|
||||
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:
|
||||
from app.benchmarks import service as benchmark_service
|
||||
await benchmark_service.shutdown()
|
||||
await container.agent.shutdown()
|
||||
from app.services import index_service
|
||||
await index_service.shutdown()
|
||||
@@ -41,6 +46,7 @@ async def lifespan(_: FastAPI):
|
||||
from app.local_models import manager
|
||||
for _, key in list(manager._downloads):
|
||||
await manager.cancel_download(key)
|
||||
# 第三方 MCP Server 必须跟随 AI Core 退出,不能遗留孤儿进程。
|
||||
container.plugins.shutdown()
|
||||
container.mcp_servers.shutdown()
|
||||
log_event('system', 'service.stopped')
|
||||
@@ -71,6 +77,8 @@ app.include_router(local_model_router)
|
||||
app.include_router(usage_router)
|
||||
app.include_router(provider_preview_router)
|
||||
app.include_router(log_router)
|
||||
from app.plot_routes import router as plot_router
|
||||
app.include_router(plot_router)
|
||||
|
||||
|
||||
@app.middleware('http')
|
||||
|
||||
@@ -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 内部数据模型。
|
||||
|
||||
FunctionPlot 供预览和导出共享;StaticRenderResult 同时是交互预览端点的响应内容。
|
||||
模型保留在独立包内,由 plot_routes 中的请求与响应类型注册 OpenAPI。
|
||||
"""
|
||||
|
||||
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, unlimited: bool = False) -> None:
|
||||
"""白名单校验:任何越界节点都抛 FUNCTION_PLOT_EXPRESSION_UNSAFE。
|
||||
|
||||
同时限制 AST 深度与节点总数,避免超长/超深表达式在递归校验或求值时触发
|
||||
RecursionError 而绕过解析失败路径。
|
||||
"""
|
||||
if counter is None:
|
||||
counter = [0]
|
||||
if not unlimited and depth > _MAX_AST_DEPTH:
|
||||
_unsafe(f"表达式嵌套过深(超过 {_MAX_AST_DEPTH} 层)")
|
||||
counter[0] += 1
|
||||
if not unlimited and 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, unlimited)
|
||||
_check_node(node.right, depth + 1, counter, unlimited)
|
||||
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, unlimited)
|
||||
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, unlimited)
|
||||
return
|
||||
_unsafe(f"不支持的语法 {type(node).__name__}")
|
||||
|
||||
|
||||
def parse_expression(expr: str, unlimited: bool = False) -> 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, unlimited=unlimited)
|
||||
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, unlimited: bool = False) -> 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, unlimited=unlimited)
|
||||
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 not unlimited and 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,518 @@
|
||||
"""Function Plot → 静态 SVG 渲染 + 共享几何计算。
|
||||
|
||||
只输出纯几何与 <text> 的 SVG(无 script/foreignObject/内联事件),可安全内嵌 HTML。
|
||||
所有文本与颜色都经过转义/校验,不把用户输入直接拼进标记。
|
||||
|
||||
几何计算(范围解析、采样、刻度、非有限点分段)统一收敛到 ``compute_geometry``,
|
||||
返回像素坐标的 ``PlotGeometry``;``render_svg`` 只做 SVG 序列化,reportlab 后端
|
||||
(``render_reportlab.py``)消费同一份几何,保证 PDF 与 SVG 视觉一致。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import math
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
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}$")
|
||||
# 绘图矩形(像素,SVG y-down):曲线与坐标轴所在区域,坐标轴/网格均在此范围内
|
||||
_PLOT_X0 = _MARGIN
|
||||
_PLOT_Y0 = _MARGIN
|
||||
_PLOT_X1 = _WIDTH - _MARGIN
|
||||
_PLOT_Y1 = _HEIGHT - _MARGIN
|
||||
|
||||
|
||||
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 _sx(x: float, xmin: float, xmax: float) -> float:
|
||||
"""数据 x → 像素 x(SVG y-down 约定,原点左上)。"""
|
||||
return _MARGIN + (x - xmin) / (xmax - xmin) * (_WIDTH - 2 * _MARGIN)
|
||||
|
||||
|
||||
def _sy(y: float, ymin: float, ymax: float) -> float:
|
||||
"""数据 y → 像素 y(SVG y-down 约定,原点左上)。"""
|
||||
return _HEIGHT - _MARGIN - (y - ymin) / (ymax - ymin) * (_HEIGHT - 2 * _MARGIN)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlotGeometry:
|
||||
"""已解析的几何:范围、轴位置、刻度、曲线像素点段、标签与 warnings。
|
||||
|
||||
像素坐标统一为 SVG y-down 约定;reportlab 后端(y-up)自行翻转 y。
|
||||
"""
|
||||
|
||||
width: int
|
||||
height: int
|
||||
xmin: float
|
||||
xmax: float
|
||||
ymin: float
|
||||
ymax: float
|
||||
x_axis_y: float # 数据空间里 x 轴所在 y(过原点则 0,否则贴边)
|
||||
y_axis_x: float # 数据空间里 y 轴所在 x(过原点则 0,否则贴边)
|
||||
xticks: list[float]
|
||||
yticks: list[float]
|
||||
polylines: list[list[list[tuple[float, float]]]] # 按表达式分组:段 → 像素点
|
||||
colors: list[str] # 与 polylines 对齐
|
||||
xlabel: str | None
|
||||
ylabel: str | None
|
||||
grid: bool
|
||||
warnings: list[str]
|
||||
|
||||
|
||||
def _clip_segment(
|
||||
p0: tuple[float, float],
|
||||
p1: tuple[float, float],
|
||||
x0: float,
|
||||
y0: float,
|
||||
x1: float,
|
||||
y1: float,
|
||||
) -> tuple[tuple[float, float], tuple[float, float]] | None:
|
||||
"""Liang-Barsky:把线段裁剪到轴对齐矩形 [x0,x1]×[y0,y1],完全在外返回 None。"""
|
||||
dx = p1[0] - p0[0]
|
||||
dy = p1[1] - p0[1]
|
||||
p = (-dx, dx, -dy, dy)
|
||||
q = (p0[0] - x0, x1 - p0[0], p0[1] - y0, y1 - p0[1])
|
||||
u1, u2 = 0.0, 1.0
|
||||
for pk, qk in zip(p, q):
|
||||
if pk == 0:
|
||||
if qk < 0:
|
||||
return None
|
||||
else:
|
||||
r = qk / pk
|
||||
if pk < 0:
|
||||
if r > u2:
|
||||
return None
|
||||
if r > u1:
|
||||
u1 = r
|
||||
else:
|
||||
if r < u1:
|
||||
return None
|
||||
if r < u2:
|
||||
u2 = r
|
||||
if u1 > u2:
|
||||
return None
|
||||
return (p0[0] + u1 * dx, p0[1] + u1 * dy), (p0[0] + u2 * dx, p0[1] + u2 * dy)
|
||||
|
||||
|
||||
def _points_close(
|
||||
a: tuple[float, float], b: tuple[float, float], eps: float = 1e-9
|
||||
) -> bool:
|
||||
return abs(a[0] - b[0]) < eps and abs(a[1] - b[1]) < eps
|
||||
|
||||
|
||||
def _clip_polyline(
|
||||
points: list[tuple[float, float]],
|
||||
x0: float,
|
||||
y0: float,
|
||||
x1: float,
|
||||
y1: float,
|
||||
) -> list[list[tuple[float, float]]]:
|
||||
"""把折线裁剪到矩形,返回若干连续子段;相邻点不衔接处自动断段。"""
|
||||
if not points:
|
||||
return []
|
||||
segments: list[list[tuple[float, float]]] = []
|
||||
current: list[tuple[float, float]] = []
|
||||
for i in range(len(points) - 1):
|
||||
clipped = _clip_segment(points[i], points[i + 1], x0, y0, x1, y1)
|
||||
if clipped is None:
|
||||
if current:
|
||||
segments.append(current)
|
||||
current = []
|
||||
continue
|
||||
a, b = clipped
|
||||
# 共享点被裁剪修改(折线短暂越界后折返)时,a 与上一段末点不衔接,需断段
|
||||
if current and not _points_close(a, current[-1]):
|
||||
segments.append(current)
|
||||
current = []
|
||||
if not current:
|
||||
current.append(a)
|
||||
current.append(b)
|
||||
if current:
|
||||
segments.append(current)
|
||||
return segments
|
||||
|
||||
|
||||
_REFINE_MAX_DEPTH = 24
|
||||
_REFINE_MAX_EVALUATIONS = 256
|
||||
_CURVE_MAX_REFINEMENT_EVALUATIONS = 8192
|
||||
|
||||
|
||||
def _refine_crossing(tree, left, right, ymin, ymax, budget=None):
|
||||
"""Adaptively check both halves of a crossing; None explicitly breaks a path.
|
||||
|
||||
A visible midpoint is not a continuity proof. Accept a visible chord only
|
||||
when its midpoint error is within a quarter pixel; otherwise subdivide both
|
||||
halves. Depth, evaluation and floating-point limits always break unresolved
|
||||
intervals instead of joining them. Entirely off-screen triples can be culled.
|
||||
"""
|
||||
remaining = _REFINE_MAX_EVALUATIONS
|
||||
if budget is None:
|
||||
budget = [_REFINE_MAX_EVALUATIONS]
|
||||
tolerance = (ymax - ymin) / (_PLOT_Y1 - _PLOT_Y0) / 4
|
||||
|
||||
def refine(a, b, depth):
|
||||
nonlocal remaining
|
||||
x = a[0] + (b[0] - a[0]) / 2
|
||||
if depth >= _REFINE_MAX_DEPTH or remaining == 0 or budget[0] == 0 or not a[0] < x < b[0]:
|
||||
return [a, None, b]
|
||||
remaining -= 1
|
||||
budget[0] -= 1
|
||||
try:
|
||||
y = evaluate(tree, x)
|
||||
except (ValueError, ZeroDivisionError, OverflowError, TypeError):
|
||||
y = math.nan
|
||||
if not isinstance(y, (int, float)):
|
||||
y = math.nan
|
||||
mid = (x, y)
|
||||
values = (a[1], y, b[1])
|
||||
if all(math.isfinite(v) for v in values):
|
||||
if max(values) < ymin or min(values) > ymax:
|
||||
return [a, None, b] # No visible chord; do not connect across it.
|
||||
error = abs(y - (a[1] / 2 + b[1] / 2))
|
||||
if any(ymin <= v <= ymax for v in values) and error <= tolerance:
|
||||
return [a, mid, b]
|
||||
# Refine either side of a nonfinite midpoint too: dropping the whole
|
||||
# interval would erase valid branches between the original samples.
|
||||
first = refine(a, mid, depth + 1)
|
||||
second = refine(mid, b, depth + 1)
|
||||
return first + second[1:]
|
||||
|
||||
return refine(left, right, 0)
|
||||
|
||||
|
||||
def _sample_segments(
|
||||
tree: object,
|
||||
xmin: float,
|
||||
xmax: float,
|
||||
ymin: float,
|
||||
ymax: float,
|
||||
warnings: list[str] | None = None,
|
||||
) -> list[list[tuple[float, float]]]:
|
||||
"""采样并映射为像素点段,再裁剪到绘图矩形。
|
||||
|
||||
每个相邻有限采样区间都检查中点,避免端点在可见范围内的渐近线漏判。
|
||||
自适应细分受区间与整条曲线预算限制,未解析区间以断点保守处理。
|
||||
"""
|
||||
segments: list[list[tuple[float, float]]] = []
|
||||
points: list[tuple[float, float]] = []
|
||||
prev_y: float | None = None
|
||||
prev_x = xmin
|
||||
budget = [_CURVE_MAX_REFINEMENT_EVALUATIONS]
|
||||
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(points)
|
||||
points = []
|
||||
prev_y = None
|
||||
continue
|
||||
px = _sx(x, xmin, xmax)
|
||||
py = _sy(y, ymin, ymax)
|
||||
# 映射后的坐标必须有限:显式 range 下极端 y 值可能让像素坐标溢出为 inf
|
||||
if not (math.isfinite(px) and math.isfinite(py)):
|
||||
if points:
|
||||
segments.append(points)
|
||||
points = []
|
||||
prev_y = None
|
||||
continue
|
||||
if prev_y is not None:
|
||||
refined = _refine_crossing(tree, (prev_x, prev_y), (x, y), ymin, ymax, budget)
|
||||
samples = refined[1:] # The previous endpoint is already in points.
|
||||
else:
|
||||
samples = [(x, y)]
|
||||
for sample in samples:
|
||||
mapped = None if sample is None else (
|
||||
_sx(sample[0], xmin, xmax), _sy(sample[1], ymin, ymax)
|
||||
)
|
||||
if mapped is None or not all(math.isfinite(value) for value in mapped):
|
||||
if points:
|
||||
segments.append(points)
|
||||
points = []
|
||||
else:
|
||||
points.append(mapped)
|
||||
prev_y = y
|
||||
prev_x = x
|
||||
if points:
|
||||
segments.append(points)
|
||||
|
||||
if budget[0] == 0 and warnings is not None:
|
||||
warning = "曲线细分达到求值上限,未解析区间已断开;请缩小 domain 后重试"
|
||||
if warning not in warnings:
|
||||
warnings.append(warning)
|
||||
|
||||
# 裁剪到绘图矩形:reportlab 无 SVG viewport 那样的自动裁剪,超出显式 range 的
|
||||
# 曲线会覆盖页面其他内容,故在共享几何层统一裁剪(SVG 也一并收敛到绘图区)。
|
||||
clipped: list[list[tuple[float, float]]] = []
|
||||
for seg in segments:
|
||||
clipped.extend(_clip_polyline(seg, _PLOT_X0, _PLOT_Y0, _PLOT_X1, _PLOT_Y1))
|
||||
return clipped
|
||||
|
||||
|
||||
def compute_geometry(plot: FunctionPlot, unlimited: bool = False) -> PlotGeometry:
|
||||
"""解析并计算几何,供 SVG 与 reportlab 后端复用。"""
|
||||
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, unlimited=unlimited)
|
||||
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
|
||||
|
||||
x_axis_y = 0.0 if ymin <= 0 <= ymax else ymin
|
||||
y_axis_x = 0.0 if xmin <= 0 <= xmax else xmin
|
||||
xticks = _ticks(xmin, xmax, _nice_step(xmax - xmin))
|
||||
yticks = _ticks(ymin, ymax, _nice_step(ymax - ymin))
|
||||
|
||||
polylines: list[list[list[tuple[float, float]]]] = []
|
||||
colors: list[str] = []
|
||||
for i, (expr, tree) in enumerate(fns):
|
||||
color = _safe_color(expr.color, _PALETTE[i % len(_PALETTE)])
|
||||
colors.append(color)
|
||||
polylines.append(_sample_segments(tree, xmin, xmax, ymin, ymax, warnings))
|
||||
|
||||
return PlotGeometry(
|
||||
width=_WIDTH,
|
||||
height=_HEIGHT,
|
||||
xmin=xmin,
|
||||
xmax=xmax,
|
||||
ymin=ymin,
|
||||
ymax=ymax,
|
||||
x_axis_y=x_axis_y,
|
||||
y_axis_x=y_axis_x,
|
||||
xticks=xticks,
|
||||
yticks=yticks,
|
||||
polylines=polylines,
|
||||
colors=colors,
|
||||
xlabel=plot.axes.xlabel,
|
||||
ylabel=plot.axes.ylabel,
|
||||
grid=plot.axes.grid,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
# --- SVG 序列化(与 compute_geometry 共用,保证字节级稳定) ---
|
||||
def _grid_svg(geo: PlotGeometry) -> str:
|
||||
sx = lambda x: _sx(x, geo.xmin, geo.xmax)
|
||||
sy = lambda y: _sy(y, geo.ymin, geo.ymax)
|
||||
parts: list[str] = []
|
||||
for x in geo.xticks:
|
||||
parts.append(
|
||||
f'<line x1="{sx(x):.2f}" y1="{sy(geo.ymin):.2f}" x2="{sx(x):.2f}" '
|
||||
f'y2="{sy(geo.ymax):.2f}" stroke="#eaeef2" class="plot-grid"/>'
|
||||
)
|
||||
for y in geo.yticks:
|
||||
parts.append(
|
||||
f'<line x1="{sx(geo.xmin):.2f}" y1="{sy(y):.2f}" x2="{sx(geo.xmax):.2f}" '
|
||||
f'y2="{sy(y):.2f}" stroke="#eaeef2" class="plot-grid"/>'
|
||||
)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _axes_svg(geo: PlotGeometry) -> str:
|
||||
sx = lambda x: _sx(x, geo.xmin, geo.xmax)
|
||||
sy = lambda y: _sy(y, geo.ymin, geo.ymax)
|
||||
parts: list[str] = []
|
||||
# 坐标轴:过原点则画在原点,否则贴边,保证始终有参照系
|
||||
parts.append(
|
||||
f'<line x1="{sx(geo.xmin):.2f}" y1="{sy(geo.x_axis_y):.2f}" x2="{sx(geo.xmax):.2f}" '
|
||||
f'y2="{sy(geo.x_axis_y):.2f}" stroke="#57606a" class="plot-axis"/>'
|
||||
)
|
||||
parts.append(
|
||||
f'<line x1="{sx(geo.y_axis_x):.2f}" y1="{sy(geo.ymin):.2f}" x2="{sx(geo.y_axis_x):.2f}" '
|
||||
f'y2="{sy(geo.ymax):.2f}" stroke="#57606a" class="plot-axis"/>'
|
||||
)
|
||||
# x 轴刻度数字(画在轴下方)
|
||||
for x in geo.xticks:
|
||||
parts.append(
|
||||
f'<text x="{sx(x):.2f}" y="{sy(geo.x_axis_y) + 14:.2f}" text-anchor="middle" '
|
||||
f'font-size="10" fill="#57606a">{html.escape(_fmt_num(x))}</text>'
|
||||
)
|
||||
# y 轴刻度数字(画在轴左侧)
|
||||
for y in geo.yticks:
|
||||
parts.append(
|
||||
f'<text x="{sx(geo.y_axis_x) - 6:.2f}" y="{sy(y) + 3:.2f}" text-anchor="end" '
|
||||
f'font-size="10" fill="#57606a">{html.escape(_fmt_num(y))}</text>'
|
||||
)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _polylines_svg(geo: PlotGeometry) -> str:
|
||||
parts: list[str] = []
|
||||
for index, (segments, color) in enumerate(zip(geo.polylines, geo.colors)):
|
||||
for seg in segments:
|
||||
points = " ".join(f"{px:.2f},{py:.2f}" for px, py in seg)
|
||||
parts.append(f'<polyline points="{points}" fill="none" stroke="{color}" class="plot-curve-{index % 6}"/>')
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _labels_svg(geo: PlotGeometry) -> str:
|
||||
parts: list[str] = []
|
||||
if geo.xlabel:
|
||||
parts.append(
|
||||
f'<text x="{geo.width / 2:.2f}" y="{geo.height - 10:.2f}" text-anchor="middle" '
|
||||
f'font-size="12" fill="#1f2328">{html.escape(geo.xlabel)}</text>'
|
||||
)
|
||||
if geo.ylabel:
|
||||
parts.append(
|
||||
f'<text x="16" y="{geo.height / 2:.2f}" text-anchor="middle" font-size="12" '
|
||||
f'fill="#1f2328" transform="rotate(-90 16 {geo.height / 2:.2f})">'
|
||||
f'{html.escape(geo.ylabel)}</text>'
|
||||
)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def render_svg(plot: FunctionPlot, theme_id: str = 'light', unlimited: bool = False) -> StaticRenderResult:
|
||||
"""把已解析的 FunctionPlot 渲染为内嵌 SVG。"""
|
||||
geo = compute_geometry(plot, unlimited=unlimited)
|
||||
legend_height = ((len(plot.expressions) + 1) // 2) * 24
|
||||
height = geo.height + legend_height
|
||||
parts: list[str] = [
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {geo.width} {height}" role="img" class="function-plot-svg">'
|
||||
]
|
||||
if geo.grid:
|
||||
parts.append(_grid_svg(geo))
|
||||
parts.append(_axes_svg(geo))
|
||||
parts.append(_polylines_svg(geo))
|
||||
parts.append(_labels_svg(geo))
|
||||
for index, expression in enumerate(plot.expressions):
|
||||
x = 24 + (index % 2) * 310
|
||||
y = geo.height + 18 + (index // 2) * 24
|
||||
label = html.escape(expression.label or ('y = ' + expression.expression))
|
||||
parts.append(f'<text x="{x}" y="{y}" font-size="12" fill="{geo.colors[index]}" class="plot-legend-{index % 6}">{label}</text>')
|
||||
parts.append("</svg>")
|
||||
|
||||
return StaticRenderResult(
|
||||
content=theme_svg("".join(parts), theme_id),
|
||||
width=geo.width,
|
||||
height=height,
|
||||
warnings=geo.warnings,
|
||||
)
|
||||
|
||||
|
||||
def theme_svg(svg: str, theme_id: str) -> str:
|
||||
from app.export.themes import PALETTES
|
||||
palette = PALETTES.get(theme_id, PALETTES['light'])
|
||||
for source, target in [('#eaeef2', palette[5]), ('#57606a', palette[3]), ('#1f2328', palette[2])]:
|
||||
svg = svg.replace(source, target)
|
||||
if theme_id in {'dark', 'midnight-purple'}:
|
||||
for source, target in zip(_PALETTE, ['#79c0ff','#ff9b9b','#7ee787','#d2a8ff','#f2cc60','#ffa657']):
|
||||
svg = svg.replace(source, target)
|
||||
background = '<rect width="100%" height="100%" fill="' + palette[1] + '"/>'
|
||||
if re.search(r'<rect width="100%" height="100%" fill="[^"]*"/>', svg):
|
||||
return re.sub(r'<rect width="100%" height="100%" fill="[^"]*"/>', background, svg, count=1)
|
||||
return svg.replace('role="img" class="function-plot-svg">', 'role="img" class="function-plot-svg">' + background)
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Function Plot → reportlab 矢量 Drawing(供 PDF 内嵌)。
|
||||
|
||||
消费 ``render.compute_geometry`` 的共享几何,产出 ``reportlab.graphics.shapes.Drawing``:
|
||||
网格/坐标轴用 ``Line``、曲线用 ``PolyLine``、刻度数字与轴标签用 ``String``。
|
||||
reportlab 原点在左下(y-up),与 SVG 的 y-down 相反,故对几何里的像素 y 统一翻转;
|
||||
轴标签(ylabel)用 ``Group.rotate`` 旋转为竖向文本。中文字体复用内置 STSong-Light,
|
||||
guarded 注册避免与 pdf.py 重复注册。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from reportlab.graphics.shapes import Drawing, Group, Line, PolyLine, String
|
||||
from reportlab.lib.colors import HexColor
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.cidfonts import UnicodeCIDFont
|
||||
|
||||
from app.plot.model import FunctionPlot
|
||||
from app.plot.render import PlotGeometry, _fmt_num, _sx, _sy, compute_geometry
|
||||
|
||||
from app.export.fonts import FONT as _FONT
|
||||
|
||||
_GRID_COLOR = HexColor("#eaeef2")
|
||||
_AXIS_COLOR = HexColor("#57606a")
|
||||
_LABEL_COLOR = HexColor("#1f2328")
|
||||
_TICK_FONT_SIZE = 10
|
||||
_LABEL_FONT_SIZE = 12
|
||||
|
||||
|
||||
def _build_drawing(geo: PlotGeometry, palette=None) -> Drawing:
|
||||
"""由共享几何构建矢量 Drawing(坐标翻转后仍沿用 SVG 的像素布局)。"""
|
||||
drawing = Drawing(geo.width, geo.height)
|
||||
grid_color = HexColor(palette['border']) if palette else _GRID_COLOR
|
||||
axis_color = HexColor(palette['muted']) if palette else _AXIS_COLOR
|
||||
label_color = HexColor(palette['text']) if palette else _LABEL_COLOR
|
||||
|
||||
# SVG y-down → reportlab y-up:翻转像素 y
|
||||
def sx(x: float) -> float:
|
||||
return _sx(x, geo.xmin, geo.xmax)
|
||||
|
||||
def sy(y: float) -> float:
|
||||
return geo.height - _sy(y, geo.ymin, geo.ymax)
|
||||
|
||||
# 网格
|
||||
if geo.grid:
|
||||
for x in geo.xticks:
|
||||
drawing.add(
|
||||
Line(sx(x), sy(geo.ymin), sx(x), sy(geo.ymax), strokeColor=grid_color, strokeWidth=0.5)
|
||||
)
|
||||
for y in geo.yticks:
|
||||
drawing.add(
|
||||
Line(sx(geo.xmin), sy(y), sx(geo.xmax), sy(y), strokeColor=grid_color, strokeWidth=0.5)
|
||||
)
|
||||
|
||||
# 坐标轴(过原点画在原点,否则贴边,与 SVG 一致)
|
||||
drawing.add(
|
||||
Line(sx(geo.xmin), sy(geo.x_axis_y), sx(geo.xmax), sy(geo.x_axis_y), strokeColor=axis_color, strokeWidth=0.7)
|
||||
)
|
||||
drawing.add(
|
||||
Line(sx(geo.y_axis_x), sy(geo.ymin), sx(geo.y_axis_x), sy(geo.ymax), strokeColor=axis_color, strokeWidth=0.7)
|
||||
)
|
||||
|
||||
# 刻度数字(x 轴下方、y 轴左侧)
|
||||
for x in geo.xticks:
|
||||
drawing.add(
|
||||
String(
|
||||
sx(x), sy(geo.x_axis_y) - 14, _fmt_num(x),
|
||||
fontName=_FONT, fontSize=_TICK_FONT_SIZE, fillColor=axis_color, textAnchor="middle",
|
||||
)
|
||||
)
|
||||
for y in geo.yticks:
|
||||
drawing.add(
|
||||
String(
|
||||
sx(geo.y_axis_x) - 6, sy(y) - 3, _fmt_num(y),
|
||||
fontName=_FONT, fontSize=_TICK_FONT_SIZE, fillColor=axis_color, textAnchor="end",
|
||||
)
|
||||
)
|
||||
|
||||
# 曲线(非有限点处已由几何断成多段)
|
||||
for segments, color in zip(geo.polylines, geo.colors):
|
||||
for seg in segments:
|
||||
flipped = [(px, geo.height - py) for px, py in seg]
|
||||
drawing.add(PolyLine(flipped, strokeColor=HexColor(color), strokeWidth=1.4))
|
||||
|
||||
# 轴标签
|
||||
if geo.xlabel:
|
||||
drawing.add(
|
||||
String(
|
||||
geo.width / 2, 10, geo.xlabel,
|
||||
fontName=_FONT, fontSize=_LABEL_FONT_SIZE, fillColor=label_color, textAnchor="middle",
|
||||
)
|
||||
)
|
||||
if geo.ylabel:
|
||||
# 竖向标签:Group.rotate(90) 在 y-up 坐标下等价于 SVG 的 rotate(-90)。
|
||||
# 文本放在组内局部坐标 (0,0),先平移后旋转得到 T·R(先绕原点旋转、再平移到
|
||||
# 目标位置),避免用绝对坐标定位又用相同坐标当旋转中心造成的重复变换,
|
||||
# 后者会把标签甩到画布之外(负 x 区域)。
|
||||
label = Group()
|
||||
label.add(
|
||||
String(
|
||||
0, 0, geo.ylabel,
|
||||
fontName=_FONT, fontSize=_LABEL_FONT_SIZE, fillColor=label_color, textAnchor="middle",
|
||||
)
|
||||
)
|
||||
label.translate(16, geo.height / 2)
|
||||
label.rotate(90)
|
||||
drawing.add(label)
|
||||
|
||||
return drawing
|
||||
|
||||
|
||||
def render_drawing(plot: FunctionPlot, width: float | None = None, palette=None, unlimited=False, max_height=None) -> Drawing:
|
||||
"""把已解析的 FunctionPlot 渲染为 reportlab Drawing(可直接追加到 platypus story)。
|
||||
|
||||
``width`` 为目标输出宽度(点),用于把 640px 的几何缩放到页面内容宽;省略则按
|
||||
原始尺寸输出。缩放只影响 PDF 渲染,不改动共享几何。
|
||||
"""
|
||||
geo = compute_geometry(plot, unlimited=unlimited)
|
||||
if palette:
|
||||
from reportlab.lib.colors import HexColor as color
|
||||
bg = color(palette['surface'])
|
||||
if .2126*bg.red + .7152*bg.green + .0722*bg.blue < .5:
|
||||
colors = ['#79c0ff','#ff9b9b','#7ee787','#d2a8ff','#f2cc60','#ffa657']
|
||||
geo.colors = [value if plot.expressions[i].color else colors[i % len(colors)] for i,value in enumerate(geo.colors)]
|
||||
drawing = _build_drawing(geo, palette)
|
||||
legend_height = ((len(plot.expressions)+1)//2)*24
|
||||
drawing.height += legend_height
|
||||
for index, expression in enumerate(plot.expressions):
|
||||
drawing.add(String(24+(index%2)*310,geo.height+legend_height-18-(index//2)*24,
|
||||
expression.label or 'y = '+expression.expression,fontName=_FONT,fontSize=12,fillColor=HexColor(geo.colors[index])))
|
||||
if width is not None and width > 0:
|
||||
drawing.renderScale = min(1.0, width / geo.width, max_height / drawing.height if max_height else 1.0)
|
||||
return drawing
|
||||
@@ -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 render_svg(parsed.plot, request.theme or 'light')
|
||||
|
||||
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,36 @@
|
||||
"""交互预览复用导出使用的有界解析器和几何计算。"""
|
||||
import asyncio
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
from app.plot.parser import parse_source
|
||||
from app.plot.render import render_svg
|
||||
from app.plot.model import PlotDiagnostic, StaticRenderResult
|
||||
|
||||
router = APIRouter(prefix='/api/plots', tags=['Function Plot'])
|
||||
_slots = asyncio.Semaphore(2)
|
||||
|
||||
class PlotRequest(BaseModel):
|
||||
source: str = Field(max_length=20000)
|
||||
theme_id: str = Field(default='light', max_length=100)
|
||||
|
||||
class PlotResponse(BaseModel):
|
||||
result: StaticRenderResult | None = None
|
||||
diagnostics: list[PlotDiagnostic] = Field(default_factory=list)
|
||||
node_count: int = 0
|
||||
|
||||
def preview(request):
|
||||
"""同步解析并渲染函数图,供受并发限制的异步路由在线程中调用。"""
|
||||
parsed = parse_source(request.source)
|
||||
if parsed.plot is None:
|
||||
return PlotResponse(diagnostics=parsed.diagnostics)
|
||||
if parsed.plot.node_count > 8000:
|
||||
return PlotResponse(node_count=parsed.plot.node_count, diagnostics=[PlotDiagnostic(
|
||||
severity='error', code='PLOT_BUDGET_EXCEEDED', message='图表累计表达式节点超过 8000 上限')])
|
||||
return PlotResponse(result=render_svg(parsed.plot, request.theme_id),
|
||||
diagnostics=parsed.diagnostics, node_count=parsed.plot.node_count)
|
||||
|
||||
@router.post('/function', response_model=PlotResponse)
|
||||
async def render_function(request: PlotRequest):
|
||||
# 绘图属于 CPU 密集任务,限制并发并移入线程,避免阻塞事件循环。
|
||||
async with _slots:
|
||||
return await asyncio.to_thread(preview, request)
|
||||
@@ -114,7 +114,14 @@ class RetrievalEngine:
|
||||
elif request.mode == SearchMode.vector:
|
||||
candidate_scores = vec_scores
|
||||
else: # hybrid:RRF 融合
|
||||
candidate_scores = rrf_fuse([fts_ranked, vec_ranked], k=request.rrf_k)
|
||||
if request.fusion == 'weighted':
|
||||
# 两路原始分值量纲不同,先各自归一化再等权融合,避免任一路分值范围支配结果。
|
||||
fts_normal = dict(normalize_scores(list(fts_scores.items())))
|
||||
vec_normal = dict(normalize_scores(list(vec_scores.items())))
|
||||
candidate_scores = {bid: .5 * fts_normal.get(bid, 0) + .5 * vec_normal.get(bid, 0)
|
||||
for bid in dict.fromkeys(fts_ranked + vec_ranked)}
|
||||
else:
|
||||
candidate_scores = rrf_fuse([fts_ranked, vec_ranked], k=request.rrf_k)
|
||||
|
||||
if not candidate_scores:
|
||||
return self._empty(request)
|
||||
|
||||
+100
-5
@@ -6,7 +6,7 @@ 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
|
||||
@@ -57,6 +57,11 @@ from app.contracts import (
|
||||
ModelRoutingResponse,
|
||||
SpeakerMatchRequest,
|
||||
SpeakerMatchResult,
|
||||
ExportFormat,
|
||||
ExportJob,
|
||||
ExportJobListResponse,
|
||||
ExportRequest,
|
||||
ExportStatus,
|
||||
Note,
|
||||
NoteCreateRequest,
|
||||
NoteListResponse,
|
||||
@@ -105,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
|
||||
@@ -555,7 +562,7 @@ async def create_agent_run(request: AgentRunCreateRequest) -> AgentRun:
|
||||
tags=["Agent"],
|
||||
)
|
||||
async def get_agent_run(run_id: str) -> AgentRun:
|
||||
return agent_run_or_404(run_id)
|
||||
return await asyncio.to_thread(agent_run_or_404, run_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -564,7 +571,7 @@ async def get_agent_run(run_id: str) -> AgentRun:
|
||||
tags=["Agent"],
|
||||
)
|
||||
async def cancel_agent_run(run_id: str) -> OperationResponse:
|
||||
agent_run_or_404(run_id)
|
||||
await asyncio.to_thread(agent_run_or_404, run_id)
|
||||
run = await container.agent.cancel(run_id)
|
||||
return OperationResponse(
|
||||
status="completed",
|
||||
@@ -589,7 +596,7 @@ async def agent_events(
|
||||
after_sequence: int | None = Query(default=None, ge=-1),
|
||||
last_event_id: str | None = Header(default=None, alias="Last-Event-ID"),
|
||||
) -> StreamingResponse:
|
||||
agent_run_or_404(run_id)
|
||||
await asyncio.to_thread(agent_run_or_404, run_id)
|
||||
cursor = after_sequence
|
||||
if cursor is None and last_event_id is not None:
|
||||
try:
|
||||
@@ -651,7 +658,7 @@ async def get_agent_trace(
|
||||
async def decide_agent_permission(
|
||||
run_id: str, request_id: str, request: PermissionDecisionRequest
|
||||
) -> OperationResponse:
|
||||
agent_run_or_404(run_id)
|
||||
await asyncio.to_thread(agent_run_or_404, run_id)
|
||||
if not await container.agent.resolve_permission(run_id, request_id, request.decision):
|
||||
raise ApiError(
|
||||
404,
|
||||
@@ -1530,6 +1537,86 @@ 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.post("/exports/preview-resources", tags=["Export"])
|
||||
async def export_preview_resources(request: ExportRequest):
|
||||
return await export_service.preview_resources(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"])
|
||||
@@ -1540,3 +1627,11 @@ async def get_global_persona():
|
||||
@router.put("/settings/persona", response_model=PersonaSettings, tags=["Settings"])
|
||||
async def put_global_persona(request: PersonaSettings):
|
||||
return save_persona(request)
|
||||
|
||||
|
||||
from app.contracts import AgentBenchmarkRequest
|
||||
from app.benchmarks import agent as agent_benchmark
|
||||
|
||||
@router.post('/benchmarks/agent/runs', response_model=BenchmarkRun, status_code=202, tags=['Benchmark'])
|
||||
async def create_agent_benchmark(request: AgentBenchmarkRequest):
|
||||
return await agent_benchmark.create_run(request)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"dataset_id": "agent-core-v1",
|
||||
"kind": "agent",
|
||||
"version": "1.0.0",
|
||||
"description": "受限真实 Runtime 工具选择、参数、无需调用和 Markdown 目录基线;不等同于复杂任务验收",
|
||||
"cases": [
|
||||
{
|
||||
"case_id": "arithmetic",
|
||||
"prompt": "必须调用 math.add 计算 17 + 25,并报告结果。",
|
||||
"allowed_tools": [
|
||||
"math.add",
|
||||
"system.echo"
|
||||
],
|
||||
"expected_tools": [
|
||||
{
|
||||
"name": "math.add",
|
||||
"arguments": {
|
||||
"left": 17,
|
||||
"right": 25
|
||||
}
|
||||
}
|
||||
],
|
||||
"output_contains": [
|
||||
"42"
|
||||
],
|
||||
"tags": [
|
||||
"tool-selection",
|
||||
"arguments"
|
||||
]
|
||||
},
|
||||
{
|
||||
"case_id": "echo",
|
||||
"prompt": "调用 system.echo 原样回显字符串 phase2-check,然后回答原文。",
|
||||
"allowed_tools": [
|
||||
"math.add",
|
||||
"system.echo"
|
||||
],
|
||||
"expected_tools": [
|
||||
{
|
||||
"name": "system.echo",
|
||||
"arguments": {
|
||||
"text": "phase2-check"
|
||||
}
|
||||
}
|
||||
],
|
||||
"output_contains": [
|
||||
"phase2-check"
|
||||
],
|
||||
"tags": [
|
||||
"exact-arguments"
|
||||
]
|
||||
},
|
||||
{
|
||||
"case_id": "no-tool",
|
||||
"prompt": "不调用任何工具,只回答:验收就绪",
|
||||
"allowed_tools": [
|
||||
"math.add",
|
||||
"system.echo"
|
||||
],
|
||||
"expected_tools": [],
|
||||
"output_contains": [
|
||||
"验收就绪"
|
||||
],
|
||||
"tags": [
|
||||
"unnecessary-tools"
|
||||
]
|
||||
},
|
||||
{
|
||||
"case_id": "markdown-catalog",
|
||||
"prompt": "使用 markdown.catalog 查询支持的 Markdown 语法,指出函数图像围栏的名称。",
|
||||
"allowed_tools": [
|
||||
"markdown.catalog"
|
||||
],
|
||||
"expected_tools": [
|
||||
{
|
||||
"name": "markdown.catalog",
|
||||
"arguments": {}
|
||||
}
|
||||
],
|
||||
"output_contains": [
|
||||
"function-plot"
|
||||
],
|
||||
"tags": [
|
||||
"markdown",
|
||||
"integration"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"description": "手工编写的中文工程笔记检索集;每篇含关键词与改写问法,混淆主题分别建篇。用于小规模质量对照,不代表生产分布。",
|
||||
"notes": [
|
||||
{"id":"deadlock","title":"死锁的必要条件","text":"死锁需要互斥、占有并等待、不可抢占、循环等待四个条件同时成立。规定所有线程按相同顺序申请锁,可以破坏循环等待条件。","queries":["死锁有哪些必要条件","几个线程各占一把锁并等待对方释放,怎样避免一直卡住"]},
|
||||
{"id":"starvation","title":"饥饿与公平调度","text":"饥饿指某个任务长期得不到资源,即使其他任务仍能运行。优先级老化会逐渐提高等待任务的优先级;公平队列可以减少长期等待。饥饿不等于所有进程互相等待的死锁。","queries":["优先级老化怎样缓解饥饿","系统一直有任务在跑,但一个低优先级任务永远轮不到怎么办"]},
|
||||
{"id":"rrf","title":"RRF 排名融合","text":"RRF 使用每个候选在各通道中的名次进行融合,单通道贡献为 1/(k+rank)。它避免直接比较全文检索与向量余弦相似度的原始分数。k 越大,头部名次的差距越平缓。","queries":["RRF 的融合公式是什么","全文分数和向量分数尺度不同,如何按排名合并结果"]},
|
||||
{"id":"rerank","title":"召回后的重排","text":"重排只重新排列已召回的候选,不能找回不在候选池中的相关段落。扩大候选池可能提高质量,但会增加精排成本。LexicalReranker 根据词面重合打分,不是 Cross-Encoder 神经模型。","queries":["重排能否找回未召回的文档","精排前候选池太小会导致什么问题"]},
|
||||
{"id":"optimistic","title":"笔记的乐观并发控制","text":"保存笔记时携带读取时的内容摘要。服务端比较当前摘要,若已变化则拒绝覆盖并报告冲突。用户应重新加载或合并修改,避免把另一个窗口的新内容静默覆盖。","queries":["保存时为什么比较内容摘要","两个窗口同时修改同一篇笔记,怎样避免后保存者覆盖新内容"]},
|
||||
{"id":"idempotency","title":"重复提交的幂等键","text":"客户端为一次逻辑上传创建唯一幂等键。网络重试使用相同的键和内容,服务端返回原附件编号;同键不同内容必须拒绝,防止错误复用。新的逻辑上传使用新的键。","queries":["幂等键如何处理重复上传","上传成功但响应丢失,重试怎样不生成两个附件"]},
|
||||
{"id":"sse","title":"事件流断线续读","text":"SSE 事件携带递增 sequence。客户端保存最后接收的序号,重连后请求后续事件并去重。终止事件只能出现一次;连接中断本身不代表后台任务被取消。","queries":["SSE 重连如何去重","页面断网后任务仍在运行,如何恢复之前错过的进度"]},
|
||||
{"id":"cancel","title":"后台任务取消边界","text":"取消标志由运行循环和工具边界检查。排队任务可以立即结束;正在同步渲染的工作应在安全边界检查取消,并丢弃产物。取消后不能发布完成事件或允许下载未完成文件。","queries":["导出任务取消后怎样处理产物","用户停止渲染时工作线程还没返回,应当如何收尾"]},
|
||||
{"id":"embedding","title":"向量空间隔离","text":"不同 Embedding 模型或维度产生的向量属于不同空间,不能直接比较。索引按模型、版本、维度隔离;切换模型后需要重建对应索引。向量不可用时的全文回退必须在报告中明确记录。","queries":["Embedding 模型切换后为什么要重建索引","两个模型生成的向量长度一样就能混着搜索吗"]},
|
||||
{"id":"citation","title":"引用定位与块标识","text":"引用记录笔记编号、块编号以及起止偏移。点击引用可定位原文。候选搜索结果不等于回答实际引用的来源;引用质量需要核对正文标记对应的支持性内容。","queries":["引用如何定位到原文","搜索返回十段资料,是否都应该算作回答已引用的来源"]},
|
||||
{"id":"zip","title":"ZIP 安装路径检查","text":"解压前检查每个条目的规范路径,拒绝绝对路径、父目录穿越、符号链接和超出解压大小预算的条目。安装完成保存包摘要,重启时复核,包被修改后重新审查。","queries":["ZIP 安装如何阻止路径穿越","扩展包里有指向安装目录外的文件名,为什么必须拒绝"]},
|
||||
{"id":"plot","title":"函数图像的安全解析","text":"function-plot 围栏支持 y = x^2 和 y = sin(x),可以设置 domain 和 range。解析器只允许数学语法,不执行任意代码。函数采样应限制表达式节点和求值次数,渐近线处断开曲线。","queries":["函数图像怎样处理渐近线","让用户输入公式绘图时如何避免执行任意程序"]}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
{
|
||||
"dataset_id": "rag-phase2-v1",
|
||||
"kind": "rag",
|
||||
"version": "1.0.0",
|
||||
"description": "手工编写的中文工程笔记检索集;每篇含关键词与改写问法,混淆主题分别建篇。用于小规模质量对照,不代表生产分布。",
|
||||
"cases": [
|
||||
{
|
||||
"case_id": "deadlock-0",
|
||||
"query": "死锁有哪些必要条件",
|
||||
"expected_note_ids": [
|
||||
"note_894ec7d0760d0cd6"
|
||||
],
|
||||
"expected_block_ids": [
|
||||
"blk_748b1be4cee7cb9b"
|
||||
],
|
||||
"citation_required": true,
|
||||
"tags": [
|
||||
"keyword",
|
||||
"deadlock"
|
||||
]
|
||||
},
|
||||
{
|
||||
"case_id": "deadlock-1",
|
||||
"query": "几个线程各占一把锁并等待对方释放,怎样避免一直卡住",
|
||||
"expected_note_ids": [
|
||||
"note_894ec7d0760d0cd6"
|
||||
],
|
||||
"expected_block_ids": [
|
||||
"blk_748b1be4cee7cb9b"
|
||||
],
|
||||
"citation_required": true,
|
||||
"tags": [
|
||||
"paraphrase",
|
||||
"deadlock"
|
||||
]
|
||||
},
|
||||
{
|
||||
"case_id": "starvation-0",
|
||||
"query": "优先级老化怎样缓解饥饿",
|
||||
"expected_note_ids": [
|
||||
"note_da790c1c3b905f26"
|
||||
],
|
||||
"expected_block_ids": [
|
||||
"blk_d15c420ab15ba221"
|
||||
],
|
||||
"citation_required": true,
|
||||
"tags": [
|
||||
"keyword",
|
||||
"starvation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"case_id": "starvation-1",
|
||||
"query": "系统一直有任务在跑,但一个低优先级任务永远轮不到怎么办",
|
||||
"expected_note_ids": [
|
||||
"note_da790c1c3b905f26"
|
||||
],
|
||||
"expected_block_ids": [
|
||||
"blk_d15c420ab15ba221"
|
||||
],
|
||||
"citation_required": true,
|
||||
"tags": [
|
||||
"paraphrase",
|
||||
"starvation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"case_id": "rrf-0",
|
||||
"query": "RRF 的融合公式是什么",
|
||||
"expected_note_ids": [
|
||||
"note_1acd666aa1e79f96"
|
||||
],
|
||||
"expected_block_ids": [
|
||||
"blk_abe4534c5c35b694"
|
||||
],
|
||||
"citation_required": true,
|
||||
"tags": [
|
||||
"keyword",
|
||||
"rrf"
|
||||
]
|
||||
},
|
||||
{
|
||||
"case_id": "rrf-1",
|
||||
"query": "全文分数和向量分数尺度不同,如何按排名合并结果",
|
||||
"expected_note_ids": [
|
||||
"note_1acd666aa1e79f96"
|
||||
],
|
||||
"expected_block_ids": [
|
||||
"blk_abe4534c5c35b694"
|
||||
],
|
||||
"citation_required": true,
|
||||
"tags": [
|
||||
"paraphrase",
|
||||
"rrf"
|
||||
]
|
||||
},
|
||||
{
|
||||
"case_id": "rerank-0",
|
||||
"query": "重排能否找回未召回的文档",
|
||||
"expected_note_ids": [
|
||||
"note_df8b1e8216af7f9a"
|
||||
],
|
||||
"expected_block_ids": [
|
||||
"blk_064caf4b9c2e2518"
|
||||
],
|
||||
"citation_required": true,
|
||||
"tags": [
|
||||
"keyword",
|
||||
"rerank"
|
||||
]
|
||||
},
|
||||
{
|
||||
"case_id": "rerank-1",
|
||||
"query": "精排前候选池太小会导致什么问题",
|
||||
"expected_note_ids": [
|
||||
"note_df8b1e8216af7f9a"
|
||||
],
|
||||
"expected_block_ids": [
|
||||
"blk_064caf4b9c2e2518"
|
||||
],
|
||||
"citation_required": true,
|
||||
"tags": [
|
||||
"paraphrase",
|
||||
"rerank"
|
||||
]
|
||||
},
|
||||
{
|
||||
"case_id": "optimistic-0",
|
||||
"query": "保存时为什么比较内容摘要",
|
||||
"expected_note_ids": [
|
||||
"note_04142ad0124ae76d"
|
||||
],
|
||||
"expected_block_ids": [
|
||||
"blk_3f8c19cf88a03805"
|
||||
],
|
||||
"citation_required": true,
|
||||
"tags": [
|
||||
"keyword",
|
||||
"optimistic"
|
||||
]
|
||||
},
|
||||
{
|
||||
"case_id": "optimistic-1",
|
||||
"query": "两个窗口同时修改同一篇笔记,怎样避免后保存者覆盖新内容",
|
||||
"expected_note_ids": [
|
||||
"note_04142ad0124ae76d"
|
||||
],
|
||||
"expected_block_ids": [
|
||||
"blk_3f8c19cf88a03805"
|
||||
],
|
||||
"citation_required": true,
|
||||
"tags": [
|
||||
"paraphrase",
|
||||
"optimistic"
|
||||
]
|
||||
},
|
||||
{
|
||||
"case_id": "idempotency-0",
|
||||
"query": "幂等键如何处理重复上传",
|
||||
"expected_note_ids": [
|
||||
"note_cdc416a180e5099b"
|
||||
],
|
||||
"expected_block_ids": [
|
||||
"blk_b68f6945024f098d"
|
||||
],
|
||||
"citation_required": true,
|
||||
"tags": [
|
||||
"keyword",
|
||||
"idempotency"
|
||||
]
|
||||
},
|
||||
{
|
||||
"case_id": "idempotency-1",
|
||||
"query": "上传成功但响应丢失,重试怎样不生成两个附件",
|
||||
"expected_note_ids": [
|
||||
"note_cdc416a180e5099b"
|
||||
],
|
||||
"expected_block_ids": [
|
||||
"blk_b68f6945024f098d"
|
||||
],
|
||||
"citation_required": true,
|
||||
"tags": [
|
||||
"paraphrase",
|
||||
"idempotency"
|
||||
]
|
||||
},
|
||||
{
|
||||
"case_id": "sse-0",
|
||||
"query": "SSE 重连如何去重",
|
||||
"expected_note_ids": [
|
||||
"note_5cf7aef15e17bd32"
|
||||
],
|
||||
"expected_block_ids": [
|
||||
"blk_f6328254ea8624a1"
|
||||
],
|
||||
"citation_required": true,
|
||||
"tags": [
|
||||
"keyword",
|
||||
"sse"
|
||||
]
|
||||
},
|
||||
{
|
||||
"case_id": "sse-1",
|
||||
"query": "页面断网后任务仍在运行,如何恢复之前错过的进度",
|
||||
"expected_note_ids": [
|
||||
"note_5cf7aef15e17bd32"
|
||||
],
|
||||
"expected_block_ids": [
|
||||
"blk_f6328254ea8624a1"
|
||||
],
|
||||
"citation_required": true,
|
||||
"tags": [
|
||||
"paraphrase",
|
||||
"sse"
|
||||
]
|
||||
},
|
||||
{
|
||||
"case_id": "cancel-0",
|
||||
"query": "导出任务取消后怎样处理产物",
|
||||
"expected_note_ids": [
|
||||
"note_48f96c75ea97d552"
|
||||
],
|
||||
"expected_block_ids": [
|
||||
"blk_efa6b9f6210964c2"
|
||||
],
|
||||
"citation_required": true,
|
||||
"tags": [
|
||||
"keyword",
|
||||
"cancel"
|
||||
]
|
||||
},
|
||||
{
|
||||
"case_id": "cancel-1",
|
||||
"query": "用户停止渲染时工作线程还没返回,应当如何收尾",
|
||||
"expected_note_ids": [
|
||||
"note_48f96c75ea97d552"
|
||||
],
|
||||
"expected_block_ids": [
|
||||
"blk_efa6b9f6210964c2"
|
||||
],
|
||||
"citation_required": true,
|
||||
"tags": [
|
||||
"paraphrase",
|
||||
"cancel"
|
||||
]
|
||||
},
|
||||
{
|
||||
"case_id": "embedding-0",
|
||||
"query": "Embedding 模型切换后为什么要重建索引",
|
||||
"expected_note_ids": [
|
||||
"note_6e13fbe17c9f7a30"
|
||||
],
|
||||
"expected_block_ids": [
|
||||
"blk_dc2b0771ea50c3a4"
|
||||
],
|
||||
"citation_required": true,
|
||||
"tags": [
|
||||
"keyword",
|
||||
"embedding"
|
||||
]
|
||||
},
|
||||
{
|
||||
"case_id": "embedding-1",
|
||||
"query": "两个模型生成的向量长度一样就能混着搜索吗",
|
||||
"expected_note_ids": [
|
||||
"note_6e13fbe17c9f7a30"
|
||||
],
|
||||
"expected_block_ids": [
|
||||
"blk_dc2b0771ea50c3a4"
|
||||
],
|
||||
"citation_required": true,
|
||||
"tags": [
|
||||
"paraphrase",
|
||||
"embedding"
|
||||
]
|
||||
},
|
||||
{
|
||||
"case_id": "citation-0",
|
||||
"query": "引用如何定位到原文",
|
||||
"expected_note_ids": [
|
||||
"note_a0b8289f7f334952"
|
||||
],
|
||||
"expected_block_ids": [
|
||||
"blk_5eac426f6220ca29"
|
||||
],
|
||||
"citation_required": true,
|
||||
"tags": [
|
||||
"keyword",
|
||||
"citation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"case_id": "citation-1",
|
||||
"query": "搜索返回十段资料,是否都应该算作回答已引用的来源",
|
||||
"expected_note_ids": [
|
||||
"note_a0b8289f7f334952"
|
||||
],
|
||||
"expected_block_ids": [
|
||||
"blk_5eac426f6220ca29"
|
||||
],
|
||||
"citation_required": true,
|
||||
"tags": [
|
||||
"paraphrase",
|
||||
"citation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"case_id": "zip-0",
|
||||
"query": "ZIP 安装如何阻止路径穿越",
|
||||
"expected_note_ids": [
|
||||
"note_4a26a95db9b832fc"
|
||||
],
|
||||
"expected_block_ids": [
|
||||
"blk_ce08f371b1dcf2c5"
|
||||
],
|
||||
"citation_required": true,
|
||||
"tags": [
|
||||
"keyword",
|
||||
"zip"
|
||||
]
|
||||
},
|
||||
{
|
||||
"case_id": "zip-1",
|
||||
"query": "扩展包里有指向安装目录外的文件名,为什么必须拒绝",
|
||||
"expected_note_ids": [
|
||||
"note_4a26a95db9b832fc"
|
||||
],
|
||||
"expected_block_ids": [
|
||||
"blk_ce08f371b1dcf2c5"
|
||||
],
|
||||
"citation_required": true,
|
||||
"tags": [
|
||||
"paraphrase",
|
||||
"zip"
|
||||
]
|
||||
},
|
||||
{
|
||||
"case_id": "plot-0",
|
||||
"query": "函数图像怎样处理渐近线",
|
||||
"expected_note_ids": [
|
||||
"note_bfd65995af95a6a6"
|
||||
],
|
||||
"expected_block_ids": [
|
||||
"blk_c4644f51b0853ef2"
|
||||
],
|
||||
"citation_required": true,
|
||||
"tags": [
|
||||
"keyword",
|
||||
"plot"
|
||||
]
|
||||
},
|
||||
{
|
||||
"case_id": "plot-1",
|
||||
"query": "让用户输入公式绘图时如何避免执行任意程序",
|
||||
"expected_note_ids": [
|
||||
"note_bfd65995af95a6a6"
|
||||
],
|
||||
"expected_block_ids": [
|
||||
"blk_c4644f51b0853ef2"
|
||||
],
|
||||
"citation_required": true,
|
||||
"tags": [
|
||||
"paraphrase",
|
||||
"plot"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
# function-plot 功能演示
|
||||
|
||||
这份笔记展示函数图像的写法、编辑刷新、坐标设置和错误反馈。在 NotesAgent 中打开后,切换到「写作」查看图像;「源码」模式可查看和修改下面的代码块。
|
||||
|
||||
## 1. 从一条抛物线开始
|
||||
|
||||
`domain` 设置横轴范围,`range` 设置纵轴显示范围。`xlabel` 和 `ylabel` 设置坐标轴标签。
|
||||
|
||||
```function-plot
|
||||
domain: -4, 4
|
||||
range: -2, 18
|
||||
xlabel: 横坐标 x
|
||||
ylabel: 函数值 y
|
||||
grid: true
|
||||
y = x^2
|
||||
```
|
||||
|
||||
试着将 `y = x^2` 改为 `y = (x-1)^2 + 2`,观察顶点从 `(0, 0)` 移到 `(1, 2)`。修改后切回写作模式即可查看结果。
|
||||
|
||||
## 2. 多函数同图
|
||||
|
||||
一个代码块内每行写一个函数,曲线按顺序分配颜色,并显示对应图例。三角函数的输入单位是弧度。
|
||||
|
||||
```function-plot
|
||||
domain: -6.2832, 6.2832
|
||||
range: -2.2, 2.2
|
||||
xlabel: x / 弧度
|
||||
ylabel: y
|
||||
y = sin(x)
|
||||
y = cos(x)
|
||||
y = 2sin(x)
|
||||
```
|
||||
|
||||
将第三条函数改为 `y = sin(2x)`,比较振幅变化和周期变化。
|
||||
|
||||
## 3. 隐式乘法与交点
|
||||
|
||||
支持 `2x`、`2(x+1)`、`(x+1)(x-1)` 等写法。乘号也可以显式写成 `*`,幂可以使用 `^`。
|
||||
|
||||
```function-plot
|
||||
domain: -3, 4
|
||||
range: -5, 12
|
||||
xlabel: x
|
||||
ylabel: y
|
||||
y = (x+1)(x-1)
|
||||
y = 2x + 1
|
||||
```
|
||||
|
||||
两条曲线的交点满足 `x^2 - 1 = 2x + 1`,横坐标约为 `-0.732` 和 `2.732`。
|
||||
|
||||
## 4. 指数、对数与参考直线
|
||||
|
||||
支持常量 `e`、`pi`,以及 `exp`、`ln`、`log10` 等函数。这里把横轴限定在正数范围,保证对数有定义。
|
||||
|
||||
```function-plot
|
||||
domain: -3, 3
|
||||
range: -3, 8
|
||||
xlabel: x
|
||||
ylabel: y
|
||||
y = exp(x)
|
||||
y = ln(x)
|
||||
y = x
|
||||
```
|
||||
|
||||
超出纵轴显示范围的曲线会被裁切。把 `range` 改为 `-3, 22`,可以查看更完整的指数曲线。
|
||||
|
||||
## 5. 绝对值与平方根
|
||||
|
||||
```function-plot
|
||||
domain: -4, 4
|
||||
range: -0.5, 4.5
|
||||
xlabel: x
|
||||
ylabel: y
|
||||
y = abs(x)
|
||||
y = sqrt(abs(x))
|
||||
```
|
||||
|
||||
这里用 `sqrt(abs(x))`,所以负半轴也有定义;它与 `sqrt(x)` 的定义域不同。
|
||||
|
||||
## 6. 间断点与显示范围
|
||||
|
||||
```function-plot
|
||||
domain: -5, 5
|
||||
range: -5, 5
|
||||
xlabel: x
|
||||
ylabel: y
|
||||
y = 1/x
|
||||
```
|
||||
|
||||
`x = 0` 处无定义,图像应分成左右两支,而不是跨过间断点连线。可缩放或打开大图查看原点附近;曲线是有限采样的可视化,不代替数学定义。
|
||||
|
||||
## 7. 关闭网格
|
||||
|
||||
```function-plot
|
||||
domain: -6, 6
|
||||
range: -0.2, 1.2
|
||||
xlabel: x
|
||||
ylabel: y
|
||||
grid: false
|
||||
y = exp(-x^2/2)
|
||||
```
|
||||
|
||||
将 `grid: false` 改为 `grid: true`,比较有无网格的效果。
|
||||
|
||||
## 8. 错误反馈演示(故意写错)
|
||||
|
||||
下面的 `sinn` 不是支持的函数名,预期显示错误诊断,不生成曲线。这是本节的演示内容。把它改为 `sin` 即可恢复图像;若希望导出一份没有错误警告的文档,请先修正这一行。
|
||||
|
||||
```function-plot
|
||||
domain: -3.14, 3.14
|
||||
y = sinn(x)
|
||||
```
|
||||
|
||||
## 交互与导出检查
|
||||
|
||||
- 将鼠标移到图表区域,试用缩小、放大、重置和大图查看;只读预览也可切换源码。
|
||||
- 在窄窗口中横向滚动函数图,检查坐标刻度和右侧图例。
|
||||
- 依次切换 `light`、`dark`、`sepia`、`paper-moments`、`ocean-blue`、`midnight-purple`;社区主题需先安装并启用。检查背景、网格、文字和曲线的对比度。
|
||||
- 修改第一节函数后不保存,点击编辑器顶部「导出」,分别选择 HTML、PDF、DOCX,验证文件采用点击时的编辑内容。
|
||||
- HTML 保留支持的主题配色;PDF、DOCX 使用浅色打印样式。HTML 的函数图为 SVG,PDF 为矢量图,DOCX 为静态图片。
|
||||
|
||||
## 写法速查
|
||||
|
||||
| 项目 | 示例 |
|
||||
| ----- | ------------------------------------------------- |
|
||||
| 代码块语言 | `function-plot` |
|
||||
| 函数表达式 | `y = x^2 + 2x + 1` |
|
||||
| 横轴范围 | `domain: -5, 5` |
|
||||
| 纵轴范围 | `range: -2, 10`,省略时自动估计 |
|
||||
| 坐标标签 | `xlabel: 时间`、`ylabel: 数值` |
|
||||
| 网格开关 | `grid: true` / `grid: false` |
|
||||
| 数学常量 | `pi`、`e` |
|
||||
| 常用函数 | `sin`、`cos`、`tan`、`sqrt`、`abs`、`exp`、`ln`、`log10` |
|
||||
| 注释 | 单独一行以 `#` 开头 |
|
||||
|
||||
范围端点使用数值,例如 `domain: -3.1416, 3.1416`;表达式中可以使用 `pi`。当前只绘制以 `x` 为自变量的二维函数,不支持任意脚本、参数曲线或三维曲面。
|
||||
|
||||
每个图块最多 16 条表达式;每份导出文档最多 16 个函数图、累计 8000 个表达式节点。本文件包含 7 个正常示例和 1 个有意保留的错误示例。
|
||||
@@ -10,10 +10,15 @@ dependencies = [
|
||||
"httpx>=0.28,<1.0",
|
||||
"jsonschema>=4.25,<5.0",
|
||||
"olefile>=0.47",
|
||||
"mistune>=3.0,<4.0",
|
||||
"python-docx>=1.1,<2.0",
|
||||
"reportlab>=4.0,<5.0",
|
||||
"pyyaml>=6.0,<7.0",
|
||||
"referencing>=0.36,<1.0",
|
||||
"sqlite-vec>=0.1.9",
|
||||
"uvicorn[standard]>=0.35,<1.0",
|
||||
"matplotlib>=3.9,<4",
|
||||
"playwright>=1.55,<2",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -96,11 +96,13 @@ async def main(output):
|
||||
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)
|
||||
recovery_started = time.perf_counter()
|
||||
assert await asyncio.to_thread(lambda: all(recovered.get_run(run_id).status == AgentRunStatus.completed for run_id in ids))
|
||||
recovery_read_ms = (time.perf_counter() - recovery_started) * 1000
|
||||
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)}
|
||||
"terminal_recovery": True, "recovery_read_ms": round(recovery_read_ms,2), "retained_records": len(runtime._records)}
|
||||
save('agent_tool_runs', await measured(batch))
|
||||
|
||||
# Hold model calls so all 200 records remain active while testing admission.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""执行两次有界真实调用完成上下文摘要与回答,不修改已保存配置。"""
|
||||
import argparse, asyncio, json, sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0,str(Path(__file__).resolve().parents[1]))
|
||||
async def main(args):
|
||||
from app.container import container
|
||||
from app.contracts import ModelRequest, ModelContextPolicy
|
||||
from app.providers.factory import ProviderFactory
|
||||
from app.providers.context_budget import prepare_context, estimate
|
||||
from app.providers.base import ProviderError
|
||||
from app.services.usage_service import connection
|
||||
provider=container.providers.get(args.provider)
|
||||
model=provider.config.default_model
|
||||
request=ModelRequest(provider_id=args.provider,model=model,max_tokens=1024,messages=[
|
||||
{'role':'user','content':'项目事实:笔记保存在 Vault,导出使用点击时的快照。'*50},
|
||||
{'role':'assistant','content':'已记录。'}, {'role':'user','content':'请保持中文。'},
|
||||
{'role':'assistant','content':'好的。'}, {'role':'user','content':'笔记保存在什么地方?一句话回答。'}])
|
||||
original=request.model_dump()
|
||||
config=provider.config.model_copy(deep=True)
|
||||
config.context_policies=[ModelContextPolicy(model=model,context_window=8192,output_reserve=512,threshold=.1,mode='detect')]
|
||||
calls=0
|
||||
async def complete(value):
|
||||
nonlocal calls
|
||||
calls+=1
|
||||
return await provider.adapter.complete(value)
|
||||
results={'model':model,'configured_test_window':8192,'vendor_max_context_tested':False}
|
||||
try:
|
||||
try: await prepare_context(request,config,complete)
|
||||
except ProviderError as exc: results['detect']={'error_code':exc.code,'network_calls':calls}
|
||||
config.context_policies[0].mode='compress'
|
||||
config.context_policies[0].prompt='把以下历史资料压缩成一句中文,只保留笔记存储位置和导出快照规则。'
|
||||
prepared=await asyncio.wait_for(prepare_context(request,config,complete),60)
|
||||
turn=await asyncio.wait_for(complete(prepared),60)
|
||||
results['compression']={'passed':'vault' in (turn.text or '').lower(),'before_estimate':estimate(request),
|
||||
'after_estimate':estimate(prepared),'archive_unchanged':request.model_dump()==original,'network_calls':calls,
|
||||
'answer_input_tokens':turn.input_tokens,'answer_output_tokens':turn.output_tokens}
|
||||
with connection() as conn:
|
||||
rows=[json.loads(row[0]) for row in conn.execute('SELECT counters_json FROM model_usage WHERE provider_id=?',(args.provider,))]
|
||||
results['observed_provider_cache']={'requests':len(rows),'reporting_requests':sum(x.get('cache_hit_tokens') is not None for x in rows),
|
||||
'positive_hit_requests':sum((x.get('cache_hit_tokens') or 0)>0 for x in rows),
|
||||
'positive_miss_requests':sum((x.get('cache_miss_tokens') or 0)>0 for x in rows),
|
||||
'scope':'provider reported usage across this isolated acceptance session; not deterministic cache control'}
|
||||
finally:
|
||||
args.output.write_text(json.dumps(results,ensure_ascii=False,indent=2),encoding='utf-8')
|
||||
print(json.dumps(results,ensure_ascii=False))
|
||||
await container.agent.shutdown();container.mcp_servers.shutdown();container.plugins.shutdown()
|
||||
if __name__=='__main__':
|
||||
p=argparse.ArgumentParser();p.add_argument('--provider',required=True);p.add_argument('--output',type=Path,required=True);p.add_argument('--execute',action='store_true');args=p.parse_args()
|
||||
if not args.execute:p.error('--execute required; two requests use existing quota')
|
||||
asyncio.run(main(args))
|
||||
@@ -0,0 +1,56 @@
|
||||
"""显式隔离演示:检索、读取、创建三个任务,再调用真实只读 MCP。
|
||||
|
||||
只批准本次运行产生的 tasks.write 权限票据,需要质量验收用 Vault。
|
||||
脚本仅调用公共 API 契约,不把伪造的完成状态写入 SQLite。
|
||||
"""
|
||||
import argparse, json, time
|
||||
from pathlib import Path
|
||||
from urllib.request import Request,urlopen
|
||||
|
||||
def main(args):
|
||||
def api(path,body=None):
|
||||
req=Request(args.base_url+'/api'+path,data=json.dumps(body).encode() if body is not None else None,
|
||||
headers={'Content-Type':'application/json'})
|
||||
with urlopen(req,timeout=60) as response:return json.load(response)
|
||||
directory=args.data_dir.resolve()
|
||||
if not (directory/'vault/.phase2-fixture').exists():raise SystemExit('Isolated fixture Vault required')
|
||||
core=json.loads((directory/'benchmarks/rag-phase2-v1.json').read_text(encoding='utf-8'))
|
||||
note=next(c for c in core['cases'] if c['case_id']=='deadlock-0')['expected_note_ids'][0]
|
||||
cases=[{'case_id':'retrieval-tasks','prompt':f'按顺序执行:1. 用 rag.search 搜索“死锁”,明确使用 mode=fts;2. 用 notes.read 读取笔记 {note};3. 根据内容用 tasks.create 分别创建且仅创建三个任务,标题严格为“验收-互斥条件”、“验收-循环等待”、“验收-锁顺序”;4. 总结死锁条件并引用搜索来源。请不要调用其他工具,不重复创建。',
|
||||
'allowed_tools':['rag.search','notes.read','tasks.create'],
|
||||
'expected_tools':[{'name':'rag.search','arguments':{'query':'死锁','mode':'fts'}},{'name':'notes.read','arguments':{'note_id':note}}]+
|
||||
[{'name':'tasks.create','arguments':{'title':title}} for title in ['验收-互斥条件','验收-循环等待','验收-锁顺序']],
|
||||
'citation_required':True,'tasks_created':3,'output_contains':['死锁'],'tags':['rag','notes','tasks','permissions']}]
|
||||
tools=api('/tools')['items']; mcp=next((t for t in tools if t['name'].endswith('.web_search') and t['name'].startswith('mcp.')),None)
|
||||
if mcp:
|
||||
cases.append({'case_id':'mcp-search','prompt':f'调用一次 {mcp["name"]},query 严格使用 Python official documentation tutorial。根据工具真实返回给出一句总结。',
|
||||
'allowed_tools':[mcp['name']], 'expected_tools':[{'name':mcp['name'],'arguments':{'query':'Python official documentation tutorial'}}], 'tags':['mcp','real-network']})
|
||||
dataset={'dataset_id':'agent-integration-v1','kind':'agent','version':'1.0.0','description':'Isolated phase2 cross-module Demo; existing MCP binding captured','cases':cases}
|
||||
(directory/'benchmarks/agent-integration-v1.json').write_text(json.dumps(dataset,ensure_ascii=False,indent=2),encoding='utf-8')
|
||||
providers=api('/providers')['items']; provider=next(p for p in providers if p['enabled'] and p['provider_type']!='mock')
|
||||
run=api('/benchmarks/agent/runs',{'dataset_id':dataset['dataset_id'],'provider_id':provider['provider_id'],'model':provider['default_model'],
|
||||
'max_steps':10,'timeout_seconds':150,'token_budget':10000,'allow_network':True})
|
||||
approved=set();deadline=time.monotonic()+360
|
||||
while run['status'] in ['queued','running']:
|
||||
if time.monotonic()>deadline:
|
||||
api('/benchmarks/runs/'+run['run_id']+'/cancel',{});raise RuntimeError('Demo deadline')
|
||||
active=run['config_snapshot'].get('active_agent_run_id')
|
||||
if active:
|
||||
trace=api('/agent/runs/'+active+'/trace')
|
||||
for event in trace['items']:
|
||||
data=event['data'];ticket=data.get('request_id')
|
||||
if event['event']=='PermissionRequired' and data.get('permission')=='tasks.write' and ticket not in approved:
|
||||
api('/agent/runs/'+active+'/permissions/'+ticket,{'decision':'allow_once'});approved.add(ticket)
|
||||
time.sleep(.3);run=api('/benchmarks/runs/'+run['run_id'])
|
||||
report=api('/benchmarks/runs/'+run['run_id']+'/report')
|
||||
report['permission_approvals']=len(approved)
|
||||
report['mcp_present']=bool(mcp)
|
||||
report['tasks']= [{'task_id':t['task_id'],'title':t['title']} for t in api('/tasks')['items'] if t['title'].startswith('验收-')]
|
||||
args.output.write_text(json.dumps(report,ensure_ascii=False,indent=2),encoding='utf-8')
|
||||
print(json.dumps({'metrics':report['metrics'],'cases':report['cases'],'permission_approvals':len(approved)},ensure_ascii=False))
|
||||
|
||||
if __name__=='__main__':
|
||||
p=argparse.ArgumentParser();p.add_argument('--base-url',default='http://127.0.0.1:8017');p.add_argument('--data-dir',type=Path,required=True);p.add_argument('--output',type=Path,required=True);p.add_argument('--execute',action='store_true');args=p.parse_args()
|
||||
if not args.execute:p.error('--execute required; creates three tasks only in the isolated fixture application')
|
||||
if args.base_url not in {'http://127.0.0.1:8017','http://localhost:8017'}:p.error('Use the isolated local acceptance server on port 8017')
|
||||
main(args)
|
||||
@@ -0,0 +1,64 @@
|
||||
"""使用现有配置执行有界的真实协议与 Agent 检查,不输出凭据。
|
||||
|
||||
必须显式传入 --execute;最多发起 5 次直接模型请求和一组 4 样本 Agent 评测,
|
||||
每个样本最多 6 步、6000 Token。不创建配置,也不执行外部写入。
|
||||
"""
|
||||
import argparse, asyncio, json, sys
|
||||
from pathlib import Path
|
||||
from time import perf_counter
|
||||
sys.path.insert(0,str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
async def main(args):
|
||||
from app.container import container
|
||||
from app.contracts import ModelRequest, AgentBenchmarkRequest
|
||||
from app.providers.base import ProviderError
|
||||
from app.benchmarks import agent,service
|
||||
provider=container.providers.get(args.provider)
|
||||
adapter=provider.adapter; model=provider.config.default_model
|
||||
results={'provider_id':args.provider,'protocol':provider.config.provider_type.value,'model':model,'checks':{},
|
||||
'unconfigured_protocols':['openai_responses','anthropic_messages','ollama'],
|
||||
'not_tested':['provider_context_limit','cache_hit_miss','context_compression'],'max_direct_requests':5}
|
||||
def request(prompt='Reply OK only.', **changes):
|
||||
return ModelRequest(provider_id=args.provider,model=model,messages=[{'role':'user','content':prompt}],max_tokens=128,**changes)
|
||||
async def check(name, fn):
|
||||
started=perf_counter()
|
||||
try: results['checks'][name]=await asyncio.wait_for(fn(),60)
|
||||
except ProviderError as exc: results['checks'][name]={'passed':False,'error_code':exc.code}
|
||||
except Exception as exc: results['checks'][name]={'passed':False,'error_type':type(exc).__name__}
|
||||
results['checks'][name]['elapsed_ms']=round((perf_counter()-started)*1000,2)
|
||||
print(name,results['checks'][name],flush=True)
|
||||
async def discover():
|
||||
models=await adapter.list_models();return {'passed':bool(models),'count':len(models)}
|
||||
async def complete():
|
||||
turn=await adapter.complete(request());return {'passed':bool(turn.text),'input_tokens':turn.input_tokens,'output_tokens':turn.output_tokens}
|
||||
async def stream():
|
||||
events=[e async for e in adapter.stream(request())]
|
||||
names=[e.event.value for e in events]
|
||||
return {'passed':names.count('Done')==1 and 'TextDelta' in names,'events':sorted(set(names)),
|
||||
'usage_reported':'Usage' in names,'reasoning_observed':'ThinkingDelta' in names}
|
||||
async def cancel():
|
||||
iterator=adapter.stream(request('List the integers 1 through 1000.'))
|
||||
first=await anext(iterator);started=perf_counter();await iterator.aclose()
|
||||
return {'passed':True,'first_event':first.event.value,'close_ms':(perf_counter()-started)*1000,
|
||||
'scope':'client stream resource close; provider billing cessation not observable'}
|
||||
async def invalid_model():
|
||||
try: await adapter.complete(request().model_copy(update={'model':'notesagent-nonexistent-acceptance-model'}))
|
||||
except ProviderError as exc:return {'passed':True,'error_code':exc.code}
|
||||
return {'passed':False,'reason':'provider accepted unknown model'}
|
||||
try:
|
||||
for name,fn in [('discovery',discover),('normal_chat',complete),('stream_usage_reasoning',stream),('stream_cancel',cancel),('error_mapping',invalid_model)]:await check(name,fn)
|
||||
created=await agent.create_run(AgentBenchmarkRequest(dataset_id='agent-core-v1',provider_id=args.provider,model=model))
|
||||
await service.wait_for_run(created.run_id)
|
||||
results['agent']=service.get_report(created.run_id).model_dump(mode='json')
|
||||
print('agent',results['agent']['metrics'],flush=True)
|
||||
servers=container.mcp_servers.list()
|
||||
results['mcp']=[{'server_id':s.server_id,'name':s.name,'state':s.status.value if hasattr(s.status,'value') else s.status} for s in servers]
|
||||
finally:
|
||||
args.output.parent.mkdir(parents=True,exist_ok=True)
|
||||
args.output.write_text(json.dumps(results,ensure_ascii=False,indent=2,default=str),encoding='utf-8')
|
||||
await container.agent.shutdown();container.mcp_servers.shutdown();container.plugins.shutdown()
|
||||
|
||||
if __name__=='__main__':
|
||||
p=argparse.ArgumentParser();p.add_argument('--provider',required=True);p.add_argument('--output',type=Path,required=True);p.add_argument('--execute',action='store_true');args=p.parse_args()
|
||||
if not args.execute:p.error('--execute required; uses existing provider quota')
|
||||
asyncio.run(main(args))
|
||||
@@ -0,0 +1,69 @@
|
||||
"""在显式隔离的 APP_DATA_DIR 中执行可复现的真实模型质量验证。
|
||||
|
||||
使用应用自身的索引与评测服务,不注入向量或伪造完成记录。
|
||||
运行前必须已有本地权重和运行环境,推理过程不会下载模型。
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
async def main(args):
|
||||
from app.config import get_settings
|
||||
from app.knowledge.parser import parse_note
|
||||
from app.services import index_service
|
||||
from app.benchmarks import service
|
||||
from app.contracts import IndexRebuildRequest, RAGRunRequest
|
||||
settings=get_settings()
|
||||
if not all(os.getenv(name) for name in ['APP_DATA_DIR','APP_DB_PATH','APP_VAULT_PATH']):
|
||||
raise SystemExit('Explicit isolated APP_DATA_DIR/APP_DB_PATH/APP_VAULT_PATH required')
|
||||
fixture=Path(__file__).resolve().parents[1]/'data/benchmarks/corpus/phase2-v1.json'
|
||||
payload=json.loads(fixture.read_text(encoding='utf-8')); cases=[]
|
||||
settings.vault_path.mkdir(parents=True,exist_ok=True)
|
||||
if list(settings.vault_path.glob('*.md')) and not (settings.vault_path/'.phase2-fixture').exists():
|
||||
raise SystemExit('Refusing to overwrite a non-fixture vault')
|
||||
now=datetime(2026,9,7,tzinfo=timezone.utc)
|
||||
for note in payload['notes']:
|
||||
name=note['id']+'.md'; markdown='# '+note['title']+'\n\n'+note['text']+'\n'
|
||||
(settings.vault_path/name).write_text(markdown,encoding='utf-8')
|
||||
parsed=parse_note(markdown=markdown,file_path=name,folder='',created_at=now,updated_at=now)
|
||||
for index,query in enumerate(note['queries']):
|
||||
cases.append({'case_id':note['id']+'-'+str(index),'query':query,'expected_note_ids':[parsed.note_id],
|
||||
'expected_block_ids':[parsed.blocks[-1].block_id],'citation_required':True,
|
||||
'tags':['keyword' if index==0 else 'paraphrase',note['id']]})
|
||||
(settings.vault_path/'.phase2-fixture').touch()
|
||||
dataset={'dataset_id':'rag-phase2-v1','kind':'rag','version':payload['version'],'description':payload['description'],'cases':cases}
|
||||
settings.benchmark_datasets_path.mkdir(parents=True,exist_ok=True)
|
||||
(settings.benchmark_datasets_path/'rag-phase2-v1.json').write_text(json.dumps(dataset,ensure_ascii=False,indent=2),encoding='utf-8')
|
||||
if not args.reuse_index:
|
||||
job=await index_service.rebuild(IndexRebuildRequest())
|
||||
if job.status != 'completed': raise RuntimeError('Index did not complete: '+str(job.status))
|
||||
from app import repository
|
||||
expected_blocks = {bid for case in cases for bid in case['expected_block_ids']}
|
||||
if {hit.block_id for hit in repository.get_block_hits(list(expected_blocks))} != expected_blocks:
|
||||
raise RuntimeError('Frozen corpus does not match the index; rerun without --reuse-index')
|
||||
reports={}
|
||||
for label, mode, fusion, rerank, k in [('fts','fts','rrf',False,60),('vector','vector','rrf',False,60),
|
||||
('hybrid-weighted','hybrid','weighted',False,60),('hybrid-rrf','hybrid','rrf',False,60),
|
||||
('hybrid-rerank','hybrid','rrf',True,60),('rrf-k20','hybrid','rrf',False,20)]:
|
||||
run=await service.create_rag_run(RAGRunRequest(dataset_id='rag-phase2-v1',modes=[mode],repeat=2,
|
||||
retrieval={'top_k':5,'fusion':fusion,'rerank':rerank,'rrf_k':k,'rerank_candidates':20},
|
||||
metadata={'corpus_sha256':hashlib.sha256(fixture.read_bytes()).hexdigest(),'split':'development; no held-out production claim'}))
|
||||
await service.wait_for_run(run.run_id)
|
||||
reports[label]=service.get_report(run.run_id).model_dump(mode='json')
|
||||
print(label, json.dumps(reports[label]['metrics']),flush=True)
|
||||
args.output.parent.mkdir(parents=True,exist_ok=True)
|
||||
args.output.write_text(json.dumps(reports,ensure_ascii=False,indent=2),encoding='utf-8')
|
||||
args.output.parent.mkdir(parents=True,exist_ok=True)
|
||||
args.output.write_text(json.dumps(reports,ensure_ascii=False,indent=2),encoding='utf-8')
|
||||
from app.container import container
|
||||
await container.agent.shutdown(); container.mcp_servers.shutdown(); container.plugins.shutdown()
|
||||
|
||||
if __name__=='__main__':
|
||||
parser=argparse.ArgumentParser(); parser.add_argument('--output',type=Path,required=True); parser.add_argument('--reuse-index',action='store_true')
|
||||
asyncio.run(main(parser.parse_args()))
|
||||
@@ -0,0 +1,959 @@
|
||||
"""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_embeds_function_plot_and_marks_mermaid() -> 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)
|
||||
# function_plot 已内嵌为矢量图,不再产生「函数图像占位」warning
|
||||
assert not any("函数图像" in w for w in result.warnings)
|
||||
# 绘图用 STSong-Light 渲染刻度/标签,字体应嵌入 PDF
|
||||
assert b"STSong-Light" in result.content or b"/FontFile2" in result.content
|
||||
|
||||
|
||||
def test_pdf_exporter_function_plot_fallback_on_error() -> None:
|
||||
from app.export.exporters.pdf import PdfExporter
|
||||
|
||||
# 解析失败(不安全表达式)应回退源码占位并记 warning,不阻断整篇导出
|
||||
md = "```function_plot\ny = os.system('x')\n```"
|
||||
result = asyncio.run(PdfExporter().export(parse_document(md), ExportOptions()))
|
||||
assert result.content[:4] == b"%PDF"
|
||||
assert any("函数图像" in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_pdf_exporter_has_no_function_plot_count_quota() -> None:
|
||||
from app.export.exporters.pdf import PdfExporter
|
||||
|
||||
blocks = "\n\n".join("```function-plot\ny = x\n```" for _ in range(20))
|
||||
result = asyncio.run(PdfExporter().export(parse_document(blocks), ExportOptions()))
|
||||
assert result.content[:4] == b"%PDF"
|
||||
assert not any("函数图像" in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_pdf_exporter_has_no_total_plot_node_quota(monkeypatch) -> None:
|
||||
import app.export.exporters._common as common_mod
|
||||
from app.export.exporters.pdf import PdfExporter
|
||||
|
||||
monkeypatch.setattr(common_mod, "MAX_TOTAL_PLOT_NODES", 5)
|
||||
md = "```function-plot\ny = x\n```\n\n```function-plot\ny = x + x + x + x\n```"
|
||||
result = asyncio.run(PdfExporter().export(parse_document(md), ExportOptions()))
|
||||
assert result.content[:4] == b"%PDF"
|
||||
assert not any("函数图像" in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_docx_exporter_embeds_plot_and_warns_missing_mermaid() -> 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)
|
||||
from zipfile import ZipFile
|
||||
from io import BytesIO
|
||||
with ZipFile(BytesIO(result.content)) as archive:
|
||||
assert any(name.startswith('word/media/') for name in archive.namelist())
|
||||
|
||||
|
||||
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 or b"/FontFile2" 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_pdf_nested_list_mixed_order_preserves_sequence() -> None:
|
||||
from app.export.exporters.pdf import PdfExporter
|
||||
|
||||
# P2:混合列表项(父段—子列表—后续段)应保持原始顺序,不能把所有正文挤到子列表之前
|
||||
result = asyncio.run(
|
||||
PdfExporter().export(parse_document("- parent\n\n - child\n\n after"), ExportOptions())
|
||||
)
|
||||
text = _extract_pdf_text(result.content)
|
||||
assert text.index("parent") < text.index("child") < text.index("after")
|
||||
|
||||
|
||||
def test_pdf_list_item_preserves_inline_semantics() -> None:
|
||||
from app.export.exporters.pdf import PdfExporter
|
||||
|
||||
# P2:列表项内的加粗与链接语义不能被「只渲染 children」而静默丢失
|
||||
result = asyncio.run(
|
||||
PdfExporter().export(parse_document("- **bold** [link](https://example.com)"), ExportOptions())
|
||||
)
|
||||
text = _extract_pdf_text(result.content)
|
||||
assert "bold" in text
|
||||
assert "link" in text
|
||||
# 链接以 PDF 链接注解(/URI)保留,而非降级为纯文本
|
||||
assert b"/URI" in result.content
|
||||
assert b"example.com" in result.content
|
||||
assert not any("链接协议不安全" in w for w in result.warnings)
|
||||
assert not any("无法表示" in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_docx_list_item_preserves_inline_semantics() -> None:
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
|
||||
from app.export.exporters.docx import DocxExporter
|
||||
|
||||
# P2:列表项内的加粗与链接语义应保留(w:b 加粗、w:hyperlink 可点击链接)
|
||||
result = asyncio.run(
|
||||
DocxExporter().export(parse_document("- **bold** [link](https://example.com)"), ExportOptions())
|
||||
)
|
||||
with zipfile.ZipFile(BytesIO(result.content)) as zf:
|
||||
xml = zf.read("word/document.xml").decode("utf-8")
|
||||
rels = zf.read("word/_rels/document.xml.rels").decode("utf-8")
|
||||
assert "<w:b/>" in xml
|
||||
assert "w:hyperlink" in xml
|
||||
assert "example.com" in rels
|
||||
assert not any("链接协议不安全" in w for w in result.warnings)
|
||||
assert not any("无法表示" in w for w in result.warnings)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
from app.export.themes import CALLOUTS, ALIASES, PALETTES
|
||||
|
||||
@pytest.mark.parametrize("theme", list(PALETTES))
|
||||
def test_export_theme_palette(theme):
|
||||
result = HtmlExporter().render(parse_document("`inline`"), ExportOptions(theme_id=theme))
|
||||
text = result.content.decode()
|
||||
assert f"--surface:{PALETTES[theme][1]}" in text
|
||||
assert f"--text:{PALETTES[theme][2]}" in text
|
||||
assert 'pre.code-theme-github-light { background: #f6f8fa; color: #1f2328; }' in text
|
||||
assert not result.warnings
|
||||
|
||||
|
||||
def test_unknown_theme_is_not_injected():
|
||||
result = HtmlExporter().render(parse_document("body"), ExportOptions(theme_id="</style><script>bad</script>"))
|
||||
assert result.warnings
|
||||
assert '<script>' not in result.content.decode()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(CALLOUTS) + list(ALIASES))
|
||||
def test_callout_formats(name):
|
||||
from app.export.exporters.docx import DocxExporter
|
||||
from app.export.exporters.pdf import PdfExporter
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
doc = parse_document(f"> [!{name.upper()}]- **Title**\n> Body `code`\n>\n> - item\n> - second")
|
||||
assert doc.children[0].attributes == {"kind": ALIASES.get(name, name), "fold": "-"}
|
||||
text = HtmlExporter().render(doc, ExportOptions()).content.decode()
|
||||
assert '<details class="callout"' in text and '<strong>Title</strong>' in text
|
||||
assert 'item' in text and '[!' not in text
|
||||
result = DocxExporter().render(doc, ExportOptions(theme_id="dark"))
|
||||
assert len(result.warnings) == 1
|
||||
with ZipFile(BytesIO(result.content)) as z:
|
||||
xml = z.read("word/document.xml").decode()
|
||||
assert all(word in xml for word in ["Title", "Body", "item", "second", "w:shd"])
|
||||
result = PdfExporter().render(doc, ExportOptions(theme_id="sepia"))
|
||||
assert result.content.startswith(b"%PDF")
|
||||
assert not any("浅色打印" in warning for warning in result.warnings)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fold", ["", "+", "-"])
|
||||
def test_callout_fold_and_nested_content(fold):
|
||||
doc = parse_document(f"> [!NOTE]{fold}\n> body\n>\n> > [!TIP] Nested\n> > child")
|
||||
text = HtmlExporter().render(doc, ExportOptions()).content.decode()
|
||||
assert "Note" in text and "Nested" in text and "child" in text
|
||||
assert (' open>' in text) == (fold == "+")
|
||||
assert ("<details" in text) == bool(fold)
|
||||
|
||||
|
||||
def test_callout_code_literal():
|
||||
doc = parse_document("```md\n> [!NOTE] literal\n```\n\n> ordinary quote")
|
||||
assert [n.type for n in doc.children] == ["code_block", "blockquote"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("marker,kind,title", [
|
||||
("[!WARNING]Title", "warning", "Title"),
|
||||
("[!custom-type] Title", "note", "Title"),
|
||||
("[!custom_type]+", "note", "Custom_type"),
|
||||
("[!NOTE]", "note", "Note"),
|
||||
("[!TIP]-**Title**", "tip", "Title"),
|
||||
])
|
||||
def test_export_callout_matches_workspace_syntax(marker, kind, title):
|
||||
doc = parse_document(f"> {marker}\n> Body")
|
||||
assert doc.children[0].type == "callout"
|
||||
assert doc.children[0].attributes["kind"] == kind
|
||||
result = HtmlExporter().render(doc, ExportOptions())
|
||||
assert title in result.content.decode() and "Body" in result.content.decode()
|
||||
assert not result.warnings
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prefix", ["- Parent", "1. Parent", "- [x] Parent"])
|
||||
def test_list_callout_preserves_export_content_and_order(prefix):
|
||||
from app.export.exporters.docx import DocxExporter
|
||||
from app.export.exporters.pdf import PdfExporter
|
||||
from docx import Document as WordDocument
|
||||
from io import BytesIO
|
||||
md = prefix + "\n\n > [!WARNING] NestedTitle\n > NestedBody\n >\n > - Inside\n >\n > > [!TIP] DeepTitle\n > > DeepBody\n\n After\n\n- Sibling"
|
||||
md = md.replace("\n ", "\n ")
|
||||
doc = parse_document(md)
|
||||
result = DocxExporter().render(doc, ExportOptions())
|
||||
assert not result.warnings
|
||||
word = WordDocument(BytesIO(result.content))
|
||||
paragraphs = word.paragraphs
|
||||
text = " ".join(p.text for p in paragraphs)
|
||||
expected = ["Parent", "NestedTitle", "NestedBody", "Inside", "DeepTitle", "DeepBody", "After", "Sibling"]
|
||||
positions = [text.index(part) for part in expected]
|
||||
assert positions == sorted(positions)
|
||||
for p in paragraphs:
|
||||
if any(part in p.text for part in ["NestedTitle", "NestedBody", "DeepBody"]):
|
||||
assert p.paragraph_format.left_indent.pt >= 18
|
||||
result = PdfExporter().render(doc, ExportOptions())
|
||||
assert not result.warnings
|
||||
text = _extract_pdf_text(result.content)
|
||||
positions = [text.index(part) for part in expected]
|
||||
assert positions == sorted(positions)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("container", ["quote", "callout", "list", "list_callout", "callout_list"])
|
||||
def test_container_tables_export_as_tables(container):
|
||||
from app.export.exporters.docx import DocxExporter
|
||||
from app.export.exporters.pdf import PdfExporter
|
||||
from docx import Document as WordDocument
|
||||
from io import BytesIO
|
||||
table = "| HeaderA | HeaderB |\n|---|---|\n| CellA | CellB |"
|
||||
def quote(text):
|
||||
return "\n".join("> " + line for line in text.splitlines())
|
||||
def item(text):
|
||||
return "- Parent\n\n" + "\n".join(" " + line for line in text.splitlines())
|
||||
callout = "[!NOTE] Title\n\n"
|
||||
md = {
|
||||
"quote": quote(table),
|
||||
"callout": quote(callout + table),
|
||||
"list": item(table),
|
||||
"list_callout": item(quote(callout + table)),
|
||||
"callout_list": quote(callout + item(table)),
|
||||
}[container]
|
||||
doc = parse_document(md)
|
||||
html = HtmlExporter().render(doc, ExportOptions())
|
||||
assert not html.warnings
|
||||
assert "<table>" in html.content.decode() and "<th" in html.content.decode()
|
||||
result = DocxExporter().render(doc, ExportOptions())
|
||||
assert not result.warnings
|
||||
word = WordDocument(BytesIO(result.content))
|
||||
assert len(word.tables) == 1
|
||||
assert [[cell.text for cell in row.cells] for row in word.tables[0].rows] == [
|
||||
["HeaderA", "HeaderB"], ["CellA", "CellB"]]
|
||||
exporter = PdfExporter()
|
||||
tables = []
|
||||
render_table = exporter._block_table
|
||||
def capture_table(node, story, warnings):
|
||||
render_table(node, story, warnings)
|
||||
tables.append(story[-1])
|
||||
exporter._block_table = capture_table
|
||||
result = exporter.render(doc, ExportOptions())
|
||||
assert not result.warnings and len(tables) == 1
|
||||
from reportlab.platypus import Table
|
||||
assert isinstance(tables[0], Table)
|
||||
text = _extract_pdf_text(result.content)
|
||||
assert all(value in text for value in ["HeaderA", "HeaderB", "CellA", "CellB"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("depth", [1, 2, 3])
|
||||
def test_docx_nested_table_indent_accumulates_once(depth):
|
||||
from app.export.exporters.docx import DocxExporter
|
||||
from docx import Document as WordDocument
|
||||
from docx.oxml.ns import qn
|
||||
from io import BytesIO
|
||||
md = "| A | B |\n|---|---|\n| x | y |"
|
||||
for level in range(depth):
|
||||
callout = f"[!NOTE] Level{level}\n\n" + md
|
||||
quote = "\n".join("> " + line for line in callout.splitlines())
|
||||
md = "- Parent\n\n" + "\n".join(" " + line for line in quote.splitlines())
|
||||
result = DocxExporter().render(parse_document(md), ExportOptions())
|
||||
assert not result.warnings
|
||||
word = WordDocument(BytesIO(result.content))
|
||||
assert len(word.tables) == 1
|
||||
indents = word.tables[0]._tbl.tblPr.findall(qn("w:tblInd"))
|
||||
assert len(indents) == 1
|
||||
assert indents[0].get(qn("w:type")) == "dxa"
|
||||
assert int(indents[0].get(qn("w:w"))) == 360 * depth
|
||||
title = next(p for p in word.paragraphs if "Level0" in p.text)
|
||||
assert title.paragraph_format.left_indent.twips == 360 * depth
|
||||
assert [[c.text for c in r.cells] for r in word.tables[0].rows] == [["A", "B"], ["x", "y"]]
|
||||
@@ -0,0 +1,68 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from app.contracts import ExportRequest
|
||||
from app.export import service
|
||||
from app.export.document import ExportResult
|
||||
from app.export.browser_pdf import render_snapshot, browser_executable
|
||||
|
||||
|
||||
def test_browser_snapshot_uses_print_pipeline(monkeypatch):
|
||||
calls=[]
|
||||
def render(html,size):
|
||||
calls.append((html,size));return ExportResult(content=b'%PDF-browser',mime_type='application/pdf')
|
||||
monkeypatch.setattr('app.export.browser_pdf.render_snapshot',render)
|
||||
monkeypatch.setattr(service,'parse_document',lambda _:pytest.fail('Browser snapshots must not be reparsed by ReportLab'))
|
||||
async def run():
|
||||
request=ExportRequest(format='pdf',source={'type':'markdown','markdown':'snapshot'},print_html='<style>h1::before{content:"tape"}</style><h1>Note</h1>')
|
||||
job=await service.create_export(request);done=await service.wait_for_export(job.job_id)
|
||||
assert done.status.value=='completed'
|
||||
assert calls==[(request.print_html,'A4')]
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_preview_resources_keeps_vault_boundary_and_plot_quota_removed():
|
||||
async def run():
|
||||
source='\n\n```function-plot\n'+'\n'.join(f'y=x+{i}' for i in range(17))+'\n```'
|
||||
resources=await service.preview_resources(ExportRequest(format='pdf',source={'type':'markdown','markdown':source}))
|
||||
assert resources['images'][0]['data'] is None
|
||||
assert resources['images'][0]['warnings']
|
||||
assert resources['plots'][0]['svg'].startswith('<svg')
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
@pytest.mark.skipif(browser_executable() is None,reason='No installed Chromium browser')
|
||||
def test_browser_prints_css_without_executing_document_scripts(tmp_path):
|
||||
# 若脚本被执行会清空正文;测试同时确认 CSS/字体可用且网络、文件资源保持禁用。
|
||||
html='<style>h1{color:#875343;font-size:37px} h1::before{content:"Theme "}</style><h1>Snapshot</h1><script>document.body.innerHTML="EXECUTED"</script><img src="file:///private.png">'
|
||||
result=render_snapshot(html,'A4')
|
||||
assert result.content.startswith(b'%PDF')
|
||||
assert b'/Subtype /Type0' in result.content or b'/Type /Font' in result.content
|
||||
import shutil, subprocess
|
||||
if shutil.which('pdftotext'):
|
||||
pdf=tmp_path/'snapshot.pdf'; pdf.write_bytes(result.content)
|
||||
text=subprocess.check_output(['pdftotext',str(pdf),'-']).decode('utf-8')
|
||||
assert 'Theme Snapshot' in text
|
||||
assert 'EXECUTED' not in text
|
||||
|
||||
|
||||
@pytest.mark.parametrize('source', ['<div><IMG SRC="assets/a&b.png"></div>', 'inline <img src="assets/a&b.png"/> image'])
|
||||
def test_preview_embeds_html_images(source):
|
||||
from app.config import get_settings
|
||||
from PIL import Image
|
||||
import base64
|
||||
folder=get_settings().vault_path/'notes'/'assets'
|
||||
folder.mkdir(parents=True)
|
||||
Image.new('RGBA',(2,2),(10,20,30,128)).save(folder/'a&b.png')
|
||||
resources=asyncio.run(service.preview_resources(ExportRequest(format='pdf',source={'type':'markdown','markdown':source,'file_path':'notes/test.md'})))
|
||||
image=resources['images'][0]
|
||||
assert image['source']=='assets/a&b.png'
|
||||
assert base64.b64decode(image['data'].split(',')[1]).startswith(b'\x89PNG')
|
||||
assert image['warnings']==[]
|
||||
|
||||
|
||||
def test_html_images_keep_path_validation_and_code_is_not_an_image():
|
||||
source='<img src="../../private.png">\n\ninline <img src="https://example.com/a.png">\n\n`<img src="code.png">`\n\n```html\n<img src="fenced.png">\n```'
|
||||
resources=asyncio.run(service.preview_resources(ExportRequest(format='pdf',source={'type':'markdown','markdown':source})))
|
||||
assert [image['source'] for image in resources['images']]==['../../private.png','https://example.com/a.png']
|
||||
assert all(image['data'] is None and image['warnings'] for image in resources['images'])
|
||||
@@ -0,0 +1,96 @@
|
||||
"""PDF theme and resource policy regressions; no real providers or user files."""
|
||||
import asyncio
|
||||
import base64
|
||||
from io import BytesIO
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from pydantic import ValidationError
|
||||
from app.contracts import ExportAsset, ExportOptions, ExportRequest
|
||||
from app.export.assets import validate_assets, enrich_document, source_hash
|
||||
from app.export.exporters.pdf import PdfExporter
|
||||
from app.export.markdown import parse_document
|
||||
from app.export.themes import PALETTES
|
||||
from app.export import service
|
||||
|
||||
|
||||
def png_asset(size=(40,30), source='graph LR; A-->B'):
|
||||
out=BytesIO(); Image.new('RGBA',size,(0,0,0,0)).save(out,'PNG')
|
||||
return ExportAsset(kind='mermaid',source_hash=source_hash(source),png_base64=base64.b64encode(out.getvalue()).decode())
|
||||
|
||||
|
||||
@pytest.mark.parametrize('theme',list(PALETTES))
|
||||
def test_pdf_theme_colors_are_written_on_every_page(theme):
|
||||
import re, zlib
|
||||
palette=PALETTES[theme]
|
||||
doc=parse_document(('## Section\n\nText body\n\n> Quoted text\n\n```python\nprint(1)\n```\n\n')*30)
|
||||
result=PdfExporter().render(doc,ExportOptions(theme_id=theme))
|
||||
streams=[]
|
||||
for match in re.finditer(rb'stream\r?\n(.*?)endstream',result.content,re.S):
|
||||
try: streams.append(zlib.decompress(base64.a85decode(match[1].strip().removesuffix(b'~>'))))
|
||||
except Exception: pass
|
||||
from reportlab.lib.rl_accel import fp_str
|
||||
command=(fp_str(*[int(palette[0][i:i+2],16)/255 for i in (1,3,5)])+' rg').encode()
|
||||
pages=[s for s in streams if b'BT' in s and b'/F' in s]
|
||||
assert len(pages)>1
|
||||
assert all(command in s for s in pages)
|
||||
assert not any('浅色打印' in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_pdf_accepts_asset_contract_beyond_previous_count_and_size():
|
||||
assets=[png_asset(source=str(i)) for i in range(65)]
|
||||
assets[0]=assets[0].model_copy(update={'png_base64':'A'*2800004})
|
||||
values=dict(source={'type':'markdown','markdown':'content'},assets=assets)
|
||||
ExportRequest(format='pdf',**values)
|
||||
with pytest.raises(ValidationError): ExportRequest(format='html',**values)
|
||||
with pytest.raises(ValidationError): ExportRequest(format='docx',**values)
|
||||
|
||||
|
||||
def test_pdf_large_png_still_requires_valid_format():
|
||||
asset=png_asset((2100,2000))
|
||||
assert validate_assets([asset],unlimited=True)
|
||||
with pytest.raises(Exception): validate_assets([asset])
|
||||
with pytest.raises(Exception): validate_assets([asset.model_copy(update={'png_base64':'invalid'})],unlimited=True)
|
||||
|
||||
|
||||
def test_pdf_embeds_more_than_64_resources_with_theme_background():
|
||||
doc=parse_document(('```mermaid\ngraph LR; A-->B\n```\n\n')*65)
|
||||
from app.export.assets import attach_assets
|
||||
attach_assets(doc,validate_assets([png_asset()],unlimited=True))
|
||||
assert not enrich_document(doc,unlimited=True,options=ExportOptions(theme_id='dark'))
|
||||
assert all('static_png' in node.attributes for node in doc.children)
|
||||
with Image.open(BytesIO(doc.children[-1].attributes['static_png'])) as image:
|
||||
assert image.getpixel((0,0)) == (13,17,23)
|
||||
assert PdfExporter().render(doc,ExportOptions(theme_id='dark')).content.startswith(b'%PDF')
|
||||
|
||||
|
||||
def test_pdf_pipeline_ignores_source_and_output_quotas(monkeypatch):
|
||||
monkeypatch.setattr(service,'MAX_MARKDOWN_CHARS',8)
|
||||
monkeypatch.setattr(service,'MAX_EXPORT_BYTES',8)
|
||||
async def run():
|
||||
job=await service.create_export(ExportRequest(format='pdf',source={'type':'markdown','markdown':'Beyond the previous quota.'}))
|
||||
done=await service.wait_for_export(job.job_id)
|
||||
assert done.status.value=='completed'
|
||||
assert service.get_export_file(job.job_id).stat().st_size>8
|
||||
with pytest.raises(Exception):
|
||||
await service.create_export(ExportRequest(format='html',source={'type':'markdown','markdown':'Beyond the previous quota.'}))
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_pdf_accepts_more_than_16_curves_and_keeps_expression_safety():
|
||||
doc=parse_document('```function-plot\n'+'\n'.join(f'y=x+{i}' for i in range(17))+'\n```')
|
||||
result=PdfExporter().render(doc,ExportOptions(theme_id='dark'))
|
||||
assert not any('函数图像' in w for w in result.warnings)
|
||||
unsafe=PdfExporter().render(parse_document('```function-plot\ny=__import__("os")\n```'),ExportOptions())
|
||||
assert any('函数图像' in w for w in unsafe.warnings)
|
||||
|
||||
|
||||
def test_pdf_custom_palette_and_math_color():
|
||||
palette=dict(zip(('page','surface','text','muted','code','border','accent'),PALETTES['midnight-purple']))
|
||||
options=ExportOptions(theme_id='my-theme',palette=palette)
|
||||
doc=parse_document('Formula $x^2$')
|
||||
assert not enrich_document(doc,unlimited=True,options=options)
|
||||
math=next(n for n in doc.children[0].children if n.type=='math_inline')
|
||||
with Image.open(BytesIO(math.attributes['static_png'])) as image:
|
||||
assert image.getpixel((0,0))==(25,19,34)
|
||||
assert not any('主题' in w for w in PdfExporter().render(doc,options).warnings)
|
||||
with pytest.raises(ValidationError): ExportOptions(palette={**palette,'text':'url(file:///private)'})
|
||||
@@ -0,0 +1,199 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from app.contracts import ExportAsset, ExportRequest, AgentBenchmarkRequest, BenchmarkStatus
|
||||
from app.export import service as exports
|
||||
from app.export.assets import validate_assets, source_hash
|
||||
from app.errors import ApiError
|
||||
|
||||
def asset(source='flowchart LR\n A --> B'):
|
||||
buf=BytesIO(); Image.new('RGB',(60,40),'blue').save(buf,'PNG')
|
||||
return ExportAsset(kind='mermaid', source_hash=source_hash(source), png_base64=base64.b64encode(buf.getvalue()).decode())
|
||||
|
||||
@pytest.mark.parametrize('format',['html','pdf','docx'])
|
||||
def test_static_mermaid_in_export(format):
|
||||
async def run():
|
||||
job=await exports.create_export(ExportRequest(source={'type':'markdown','markdown':'```mermaid\nflowchart LR\n A --> B\n```'},format=format,assets=[asset()],title='snapshot'))
|
||||
finished=await exports.wait_for_export(job.job_id)
|
||||
assert finished.status.value=='completed'
|
||||
assert not any('mermaid' in w for w in finished.warnings)
|
||||
data=exports.get_export_file(job.job_id).read_bytes()
|
||||
if format=='html': assert b'data:image/png;base64,' in data
|
||||
elif format=='pdf': assert b'/Subtype /Image' in data
|
||||
else:
|
||||
with ZipFile(BytesIO(data)) as archive: assert any(n.startswith('word/media/') for n in archive.namelist())
|
||||
asyncio.run(run())
|
||||
|
||||
def test_asset_invalid_and_duplicate():
|
||||
with pytest.raises(ApiError): validate_assets([asset().model_copy(update={'png_base64':'not png'})])
|
||||
with pytest.raises(ApiError): validate_assets([asset(),asset()])
|
||||
|
||||
def test_stale_asset_does_not_replace_source():
|
||||
from app.export.assets import attach_assets
|
||||
from app.export.markdown import parse_document
|
||||
document=parse_document('```mermaid\nflowchart LR\n X --> Y\n```')
|
||||
attach_assets(document,validate_assets([asset()]))
|
||||
assert 'static_png' not in document.children[0].attributes
|
||||
|
||||
@pytest.mark.parametrize('format',['html','pdf','docx'])
|
||||
def test_math_and_local_image_export(format):
|
||||
from app.config import get_settings
|
||||
vault=get_settings().vault_path; vault.mkdir(parents=True)
|
||||
Image.new('RGB',(100,50),'green').save(vault/'figure.png')
|
||||
async def run():
|
||||
job=await exports.create_export(ExportRequest(source={'type':'markdown','file_path':'demo.md',
|
||||
'markdown':'Formula $\\frac{x^2}{2}$\n\n'},format=format))
|
||||
done=await exports.wait_for_export(job.job_id)
|
||||
assert done.status.value=='completed'
|
||||
assert not any('公式' in w or '图片' in w for w in done.warnings)
|
||||
data=exports.get_export_file(job.job_id).read_bytes()
|
||||
if format=='html': assert data.count(b'data:image/png;base64,')==2
|
||||
if format=='docx':
|
||||
with ZipFile(BytesIO(data)) as archive: assert len([n for n in archive.namelist() if n.startswith('word/media/')])==2
|
||||
asyncio.run(run())
|
||||
|
||||
def test_local_image_path_escape_and_tex_fallback():
|
||||
from app.export.assets import enrich_document
|
||||
from app.export.markdown import parse_document
|
||||
document=parse_document('\n\n$\\unknownmacro{x}$')
|
||||
warnings=enrich_document(document,'demo.md')
|
||||
assert len(warnings)==2
|
||||
|
||||
@pytest.mark.parametrize('theme',['light','dark','sepia','paper-moments','ocean-blue','midnight-purple'])
|
||||
def test_function_preview_theme_and_parser(theme):
|
||||
from app.plot_routes import PlotRequest, preview
|
||||
result=preview(PlotRequest(source='y = x^2\ny = sin(x)',theme_id=theme))
|
||||
assert '<polyline' in result.result.content
|
||||
assert 'nan' not in result.result.content
|
||||
assert preview(PlotRequest(source='y = __import__("os")')).result is None
|
||||
|
||||
def test_agent_benchmark_real_runtime_offline_lifecycle():
|
||||
from app.config import get_settings
|
||||
from app.benchmarks import agent, service
|
||||
from app.container import container
|
||||
directory=get_settings().benchmark_datasets_path; directory.mkdir(parents=True,exist_ok=True)
|
||||
(directory/'agent-test.json').write_text(json.dumps({'dataset_id':'agent-test','kind':'agent','version':'1', 'cases':[
|
||||
{'case_id':'hello','prompt':'hello','output_contains':['definitely-absent'],'allowed_tools':[]}
|
||||
]}),encoding='utf-8')
|
||||
async def run():
|
||||
request=AgentBenchmarkRequest(dataset_id='agent-test',provider_id='mock',model='mock-model',offline=True)
|
||||
with pytest.raises(ApiError): await agent.create_run(request.model_copy(update={'offline':False}))
|
||||
created=await agent.create_run(request)
|
||||
done=await service.wait_for_run(created.run_id)
|
||||
assert done.status==BenchmarkStatus.completed
|
||||
report=service.get_report(created.run_id)
|
||||
assert report.metrics['task_success_rate']==0
|
||||
case=report.cases[0]
|
||||
assert case.agent_run_id and container.agent.get_run(case.agent_run_id)
|
||||
assert report.config_snapshot['execution']=='offline'
|
||||
events=service.get_events(created.run_id)
|
||||
assert [e.sequence for e in events]==list(range(len(events)))
|
||||
assert sum(e.event.value.startswith('Run') and e.event.value!='RunStarted' for e in events)==1
|
||||
second=await agent.create_run(request); service.cancel_run(second.run_id)
|
||||
assert (await service.wait_for_run(second.run_id)).status==BenchmarkStatus.cancelled
|
||||
asyncio.run(run())
|
||||
|
||||
def test_agent_score_counts_duplicate_and_invalid_calls():
|
||||
from types import SimpleNamespace as NS
|
||||
from app.contracts import AgentDatasetCase
|
||||
from app.benchmarks.agent import score,aggregate
|
||||
case=AgentDatasetCase(case_id='x',prompt='x',allowed_tools=['math.add'],expected_tools=[{'name':'math.add','arguments':{'left':2}}])
|
||||
events=[NS(event=NS(value='ToolCall'),data={'name':'math.add','arguments':{'left':2}}) for _ in range(2)]
|
||||
run=NS(status=NS(value='completed'),tool_results=[NS(success=False,name='math.add',error_code='TOOL_ARGUMENT_INVALID')],output='',citations=[],run_id='r',current_step=2,token_usage=10,error_code=None)
|
||||
result=score(case,run,events,10,0)
|
||||
assert not result.success
|
||||
assert aggregate([result])['tool_argument_accuracy']==.5
|
||||
assert aggregate([result])['invalid_tool_call_rate']==.5
|
||||
|
||||
def test_local_embedding_cache_is_config_scoped_and_returns_copies(monkeypatch,tmp_path):
|
||||
from app.local_models import runtime as local
|
||||
from app.retrieval.provenance import capture_embedding
|
||||
monkeypatch.setattr(local,'read_state',lambda key:{'status':'installed'})
|
||||
monkeypatch.setattr(local,'interpreter',lambda config=None:Path(__file__))
|
||||
monkeypatch.setattr(local,'model_path',lambda key:tmp_path/key)
|
||||
calls=[]
|
||||
async def infer(*args,**kwargs):
|
||||
calls.append(args);return [[.5]*384]
|
||||
monkeypatch.setattr(local.runtime,'infer',infer)
|
||||
async def run():
|
||||
embedding=local.LocalEmbedding(local.RuntimeConfig())
|
||||
first=await embedding.embed_documents(['query'])
|
||||
first[0][0]=999
|
||||
with capture_embedding() as observation:
|
||||
second=await embedding.embed_documents(['query'])
|
||||
assert second[0][0]==.5 and observation['query_embedding_cache']=='hit'
|
||||
assert len(calls)==1
|
||||
await local.LocalEmbedding(local.RuntimeConfig(version=2)).embed_documents(['query'])
|
||||
assert len(calls)==2
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_preview_http_and_agent_benchmark_validation():
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import app
|
||||
with TestClient(app) as client:
|
||||
response = client.post('/api/plots/function', json={'source':'y = sin(x)', 'theme_id':'dark'})
|
||||
assert response.status_code == 200 and '<polyline' in response.json()['result']['content']
|
||||
assert client.post('/api/plots/function', json={'source':'x'*20001}).status_code == 422
|
||||
bad = client.post('/api/benchmarks/agent/runs', json={
|
||||
'dataset_id':'missing', 'provider_id':'missing', 'model':'missing'})
|
||||
assert bad.status_code == 404
|
||||
assert client.get('/api/benchmarks/runs/missing/report').status_code == 404
|
||||
schema = client.get('/openapi.json').json()
|
||||
assert '/api/benchmarks/agent/runs' in schema['paths']
|
||||
|
||||
|
||||
def test_preview_rejects_aggregate_complexity_before_sampling():
|
||||
from app.plot_routes import PlotRequest, preview
|
||||
source = '\n'.join('y = '+ '+'.join(['(x+x)']*150) for _ in range(16))
|
||||
result = preview(PlotRequest(source=source))
|
||||
assert result.result is None
|
||||
assert result.diagnostics[0].code == 'PLOT_BUDGET_EXCEEDED'
|
||||
|
||||
|
||||
def test_repeated_static_assets_share_document_resource_budget():
|
||||
from app.export.assets import attach_assets, enrich_document
|
||||
from app.export.markdown import parse_document
|
||||
document = parse_document(('```mermaid\nflowchart LR\n A --> B\n```\n\n')*65)
|
||||
attach_assets(document, validate_assets([asset()]))
|
||||
warnings = enrich_document(document)
|
||||
assert sum(bool(node.attributes.get('static_png')) for node in document.children) == 64
|
||||
assert any('预算' in warning for warning in warnings)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('order', [(1, 2), (2, 1)])
|
||||
def test_agent_parameter_matching_is_independent_of_call_order(order):
|
||||
from types import SimpleNamespace as NS
|
||||
from app.benchmarks.agent import score
|
||||
from app.contracts import AgentDatasetCase
|
||||
case = AgentDatasetCase(case_id='overlap', prompt='test', allowed_tools=['math.add'],
|
||||
expected_tools=[{'name':'math.add','arguments':{}}, {'name':'math.add','arguments':{'left':1}}])
|
||||
events = [NS(event=NS(value='ToolCall'), data={'name':'math.add','arguments':{'left':value}}) for value in order]
|
||||
run = NS(status=NS(value='completed'),tool_results=[],output='',citations=[],run_id='test',current_step=1,token_usage=0,error_code=None)
|
||||
result = score(case, run, events, 1, 0)
|
||||
assert result.success and result.accurate_calls == result.selected_calls == 2
|
||||
# Two expectations cannot reuse one matching call.
|
||||
result = score(case, run, events[:1], 1, 0)
|
||||
assert not result.success and result.accurate_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize('page_size', ['A4', 'Letter'])
|
||||
@pytest.mark.parametrize('dimensions', [(200, 2000), (2000, 200)])
|
||||
def test_docx_static_images_fit_both_page_dimensions(page_size, dimensions):
|
||||
from app.export.markdown import parse_document
|
||||
from app.export.exporters.docx import DocxExporter
|
||||
from app.contracts import ExportOptions
|
||||
from docx import Document
|
||||
png = BytesIO(); Image.new('RGB', dimensions, 'white').save(png, 'PNG')
|
||||
document = parse_document('```mermaid\nflowchart TD\n A-->B\n```')
|
||||
document.children[0].attributes['static_png'] = png.getvalue()
|
||||
result = DocxExporter().render(document, ExportOptions(page_size=page_size))
|
||||
word = Document(BytesIO(result.content)); section = word.sections[0]; shape = word.inline_shapes[0]
|
||||
assert shape.width <= section.page_width - section.left_margin - section.right_margin
|
||||
assert shape.height < section.page_height - section.top_margin - section.bottom_margin
|
||||
assert shape.width / shape.height == pytest.approx(dimensions[0] / dimensions[1], rel=1e-5)
|
||||
@@ -0,0 +1,560 @@
|
||||
"""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 == 504 # Includes the legend row.
|
||||
|
||||
|
||||
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._common as common_mod
|
||||
|
||||
monkeypatch.setattr(common_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 == 504 # Includes the legend row.
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 共享几何与 reportlab 后端(PDF 内嵌函数图像)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_compute_geometry_shares_pixel_segments() -> None:
|
||||
from app.plot.render import compute_geometry
|
||||
|
||||
plot = parse_source("y = x^2\ny = sin(x)").plot
|
||||
geo = compute_geometry(plot)
|
||||
assert geo.width == 640
|
||||
assert geo.height == 480
|
||||
assert len(geo.polylines) == 2
|
||||
assert geo.colors == ["#0969da", "#d1242f"]
|
||||
assert geo.xticks and geo.yticks
|
||||
for segments in geo.polylines:
|
||||
assert segments
|
||||
for seg in segments:
|
||||
assert seg
|
||||
for px, py in seg:
|
||||
assert math.isfinite(px) and math.isfinite(py)
|
||||
assert 0 <= px <= geo.width
|
||||
assert 0 <= py <= geo.height
|
||||
|
||||
|
||||
def test_render_reportlab_builds_drawing() -> None:
|
||||
from reportlab.graphics.shapes import Drawing, Group, Line, PolyLine, String
|
||||
|
||||
from app.plot.render_reportlab import render_drawing
|
||||
|
||||
plot = parse_source("xlabel: 时间\nylabel: 数值\ny = x^2").plot
|
||||
drawing = render_drawing(plot, width=480)
|
||||
assert isinstance(drawing, Drawing)
|
||||
assert drawing.renderScale == 0.75 # 480 / 640
|
||||
kinds = {type(c).__name__ for c in drawing.contents}
|
||||
assert {"Line", "PolyLine", "String", "Group"} <= kinds
|
||||
strings = [c for c in drawing.contents if isinstance(c, String)]
|
||||
from app.export.fonts import FONT
|
||||
assert any(s.fontName == FONT for s in strings)
|
||||
assert any(s.text == "时间" for s in strings)
|
||||
# ylabel 在旋转 Group 内
|
||||
groups = [c for c in drawing.contents if isinstance(c, Group)]
|
||||
assert groups
|
||||
group_texts = [s.text for g in groups for s in g.contents if isinstance(s, String)]
|
||||
assert "数值" in group_texts
|
||||
|
||||
|
||||
def test_render_reportlab_curves_are_finite_and_bounded() -> None:
|
||||
from reportlab.graphics.shapes import PolyLine
|
||||
|
||||
from app.plot.render_reportlab import render_drawing
|
||||
|
||||
plot = parse_source("y = x").plot
|
||||
drawing = render_drawing(plot)
|
||||
polylines = [c for c in drawing.contents if isinstance(c, PolyLine)]
|
||||
assert polylines
|
||||
for pl in polylines:
|
||||
pts = pl.points # 扁平 [x0,y0,x1,y1,...]
|
||||
for x, y in zip(pts[0::2], pts[1::2]):
|
||||
assert math.isfinite(x) and math.isfinite(y)
|
||||
assert 0 <= x <= 640
|
||||
assert 0 <= y <= 480
|
||||
|
||||
|
||||
def test_compute_geometry_clips_curves_to_plot_rect() -> None:
|
||||
# P2:显式 range 外的曲线应裁剪到绘图矩形,避免 PDF 中曲线覆盖页面其他内容
|
||||
from app.plot.render import (
|
||||
_PLOT_X0,
|
||||
_PLOT_X1,
|
||||
_PLOT_Y0,
|
||||
_PLOT_Y1,
|
||||
compute_geometry,
|
||||
)
|
||||
|
||||
plot = parse_source("range: -1, 1\ny = 10*x").plot
|
||||
geo = compute_geometry(plot)
|
||||
assert geo.polylines
|
||||
assert any(geo.polylines) # 曲线穿越 range 后在绘图区内仍有可见段
|
||||
for segments in geo.polylines:
|
||||
for seg in segments:
|
||||
assert seg
|
||||
for px, py in seg:
|
||||
assert _PLOT_X0 <= px <= _PLOT_X1
|
||||
assert _PLOT_Y0 <= py <= _PLOT_Y1
|
||||
|
||||
|
||||
def test_render_reportlab_ylabel_within_drawing_bounds() -> None:
|
||||
# P2:纵轴标签旋转后边界应落在 Drawing 范围内,不能甩到负 x 区域
|
||||
from reportlab.graphics.shapes import Group, String
|
||||
|
||||
from app.plot.render_reportlab import render_drawing
|
||||
|
||||
plot = parse_source("ylabel: 数值\ny = x").plot
|
||||
drawing = render_drawing(plot)
|
||||
groups = [c for c in drawing.contents if isinstance(c, Group)]
|
||||
ylabel_groups = [
|
||||
g
|
||||
for g in groups
|
||||
if any(isinstance(s, String) and s.text == "数值" for s in g.contents)
|
||||
]
|
||||
assert ylabel_groups
|
||||
x0, y0, x1, y1 = ylabel_groups[0].getBounds()
|
||||
assert 0 <= x0 <= x1 <= 640
|
||||
assert 0 <= y0 <= y1 <= 480
|
||||
|
||||
|
||||
def test_compute_geometry_breaks_at_asymptote() -> None:
|
||||
# P2:渐近点落在两个采样点之间时,两侧采样仍有限,若不断段会被 Liang-Barsky
|
||||
# 裁剪成贯穿绘图区的伪竖线;这里断言不存在跨越上下边界的伪连接线段。
|
||||
from app.plot.render import _PLOT_Y0, _PLOT_Y1, compute_geometry
|
||||
|
||||
plot = parse_source("domain: -1, 1\nrange: -10, 10\ny = 1/(x-0.013)").plot
|
||||
geo = compute_geometry(plot)
|
||||
assert any(geo.polylines) # 渐近线两侧的曲线分支仍在绘图区内可见
|
||||
full_height = _PLOT_Y1 - _PLOT_Y0
|
||||
for segments in geo.polylines:
|
||||
for seg in segments:
|
||||
# 相邻点垂直跨度若接近整个绘图区高度,即为渐近线伪连接
|
||||
for (_, py0), (_, py1) in zip(seg, seg[1:]):
|
||||
assert abs(py1 - py0) < full_height * 0.5
|
||||
|
||||
|
||||
@pytest.mark.parametrize('slope,root', [(1000, 0.0025), (-1000, 0.0025), (1000000, 0.002731)])
|
||||
def test_steep_continuous_crossing_survives_svg_and_pdf(slope, root):
|
||||
from app.plot.render import compute_geometry, _PLOT_Y0, _PLOT_Y1, _PLOT_X0, _PLOT_X1
|
||||
from app.plot.render_reportlab import render_drawing
|
||||
from reportlab.graphics.shapes import PolyLine
|
||||
plot = parse_source(f'domain: -1, 1\nrange: -1, 1\ny = {slope}*(x-{root})').plot
|
||||
segments = compute_geometry(plot).polylines[0]
|
||||
assert len(segments) == 1
|
||||
points = segments[0]
|
||||
assert min(y for x,y in points) == pytest.approx(_PLOT_Y0)
|
||||
assert max(y for x,y in points) == pytest.approx(_PLOT_Y1)
|
||||
for x,y in points:
|
||||
data_x = (x-_PLOT_X0)/(_PLOT_X1-_PLOT_X0)*2-1
|
||||
data_y = 1-(y-_PLOT_Y0)/(_PLOT_Y1-_PLOT_Y0)*2
|
||||
assert data_y == pytest.approx(slope*(data_x-root),abs=1e-7)
|
||||
assert '<polyline ' in render_svg(plot).content
|
||||
assert any(isinstance(item,PolyLine) for item in render_drawing(plot).contents)
|
||||
|
||||
|
||||
def test_crossing_refinement_has_bounded_work(monkeypatch):
|
||||
import app.plot.render as rendering
|
||||
calls = []
|
||||
def jump(tree, x):
|
||||
calls.append(x)
|
||||
return -2 if x < 0.123456789 else 2
|
||||
monkeypatch.setattr(rendering, 'evaluate', jump)
|
||||
samples = rendering._refine_crossing(None, (0,-2), (1,2), -1,1)
|
||||
assert None in samples
|
||||
assert len(calls) <= rendering._REFINE_MAX_EVALUATIONS
|
||||
|
||||
|
||||
def test_visible_midpoint_does_not_bridge_a_pole():
|
||||
from app.plot.render import compute_geometry, _PLOT_Y0, _PLOT_Y1, _PLOT_X0, _PLOT_X1
|
||||
plot = parse_source('domain: 0, 2\nrange: -1, 1\ny = 1000*(x-0.0025)+0.001/(x-0.001)').plot
|
||||
segments = compute_geometry(plot).polylines[0]
|
||||
assert segments
|
||||
for seg in segments:
|
||||
for px, py in seg:
|
||||
x = (px-_PLOT_X0)/(_PLOT_X1-_PLOT_X0)*2
|
||||
y = 1-(py-_PLOT_Y0)/(_PLOT_Y1-_PLOT_Y0)*2
|
||||
# On the visible branch, 1000*t + .001/t - 1.5 >= .5.
|
||||
assert x > .001
|
||||
assert y >= .5-1e-8
|
||||
assert y == pytest.approx(1000*(x-.0025)+.001/(x-.001),abs=.002)
|
||||
|
||||
|
||||
def test_refined_extreme_samples_never_emit_nonfinite_coordinates():
|
||||
from app.plot.render import compute_geometry
|
||||
from app.plot.render_reportlab import render_drawing
|
||||
from reportlab.graphics.shapes import PolyLine
|
||||
plot = parse_source('domain: 0, 2\nrange: -1e-308, 1e-308\ny = 1e-304*(x-0.00125)-1e308*x*(x-0.005)*(x-0.00125)').plot
|
||||
geo = compute_geometry(plot)
|
||||
for segments in geo.polylines:
|
||||
for seg in segments:
|
||||
assert all(math.isfinite(v) for point in seg for v in point)
|
||||
svg = render_svg(plot).content
|
||||
assert 'nan' not in svg and 'inf' not in svg
|
||||
for shape in render_drawing(plot).contents:
|
||||
if isinstance(shape, PolyLine):
|
||||
assert all(math.isfinite(v) for v in shape.points)
|
||||
|
||||
|
||||
def test_refinement_budget_is_shared_by_both_subtrees(monkeypatch):
|
||||
import app.plot.render as rendering
|
||||
calls = []
|
||||
def oscillate(tree, x):
|
||||
calls.append(x)
|
||||
return .9*math.sin(1e9*x)
|
||||
monkeypatch.setattr(rendering, 'evaluate', oscillate)
|
||||
samples = rendering._refine_crossing(None, (0,-2), (1,2), -1,1)
|
||||
assert len(calls) == rendering._REFINE_MAX_EVALUATIONS
|
||||
assert None in samples # Exhaustion leaves gaps, never unchecked chords.
|
||||
|
||||
|
||||
@pytest.mark.parametrize('factor,pole', [(0.0001,.001),(-0.0001,.001),(.001,.001),(.0001,.0025),(.0001,.00419)])
|
||||
def test_visible_endpoints_do_not_hide_a_pole(factor, pole):
|
||||
from app.plot.render import compute_geometry, _PLOT_X0, _PLOT_X1
|
||||
plot = parse_source(f'domain: 0, 2\nrange: -1, 1\ny = {factor}/(x-{pole})').plot
|
||||
segments = compute_geometry(plot).polylines[0]
|
||||
assert segments
|
||||
left = right = False
|
||||
for segment in segments:
|
||||
xs = [(px-_PLOT_X0)/(_PLOT_X1-_PLOT_X0)*2 for px,py in segment]
|
||||
assert not min(xs) < pole < max(xs)
|
||||
left |= max(xs) < pole
|
||||
right |= min(xs) > pole
|
||||
assert left and right
|
||||
|
||||
|
||||
@pytest.mark.parametrize('expression', ['x', 'x^2', 'sin(x)', 'exp(x)', 'sqrt(x)', 'log(x)'])
|
||||
def test_smooth_and_domain_limited_curves_remain_visible(expression):
|
||||
from app.plot.render import compute_geometry, _PLOT_X0, _PLOT_X1, _PLOT_Y0, _PLOT_Y1
|
||||
plot = parse_source(f'domain: -2, 2\nrange: -2, 5\ny = {expression}').plot
|
||||
geometry = compute_geometry(plot)
|
||||
assert geometry.polylines[0]
|
||||
assert not geometry.warnings
|
||||
for segment in geometry.polylines[0]:
|
||||
for x,y in segment:
|
||||
assert math.isfinite(x) and math.isfinite(y)
|
||||
assert _PLOT_X0-1e-8 <= x <= _PLOT_X1+1e-8
|
||||
assert _PLOT_Y0-1e-8 <= y <= _PLOT_Y1+1e-8
|
||||
|
||||
|
||||
def test_curve_refinement_has_one_shared_budget(monkeypatch):
|
||||
import app.plot.render as rendering
|
||||
calls=[]
|
||||
def oscillate(tree, x):
|
||||
calls.append(x)
|
||||
return .9*math.sin(1e9*x)
|
||||
monkeypatch.setattr(rendering,'evaluate',oscillate)
|
||||
warnings=[]
|
||||
rendering._sample_segments(None,0,2,-1,1,warnings)
|
||||
assert len(calls) <= rendering._SAMPLES+1+rendering._CURVE_MAX_REFINEMENT_EVALUATIONS
|
||||
assert len(warnings)==1
|
||||
Generated
+1061
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,7 @@
|
||||
# NotesAgent 文档索引
|
||||
|
||||
> 第二阶段收尾:标准 Agent/RAG Benchmark 与报告页、函数图预览、三格式快照导出及真实 Provider/MCP 结果见[实现与验收记录](development/第二阶段收尾实现与验收-2026-09-07.md)。当前分支尚未合并,不更改下文历史 main 基线。
|
||||
|
||||
本目录集中保存团队开发期间需要长期维护的架构、接口、实现、协作和问题复盘文档。文档按用途分类,避免设计约束、开发记录与故障复盘混放。
|
||||
|
||||
当前文档基线为 2026-09-06:第一阶段和第二阶段 A~F 工程范围已经合并到 `main`,当前可运行形态仍为 Vue/Vite Web 前端与 FastAPI AI Core。Tauri/Rust Host、Stronghold、原生多 Vault 文件系统、生产级 MCP 沙箱和 Sync Server 尚未接入。
|
||||
@@ -52,6 +54,7 @@
|
||||
- [AI Core 与 Agent Core 开发说明](development/AI-Core与Agent-Core开发说明.md)
|
||||
- [Knowledge 与 Retrieval Core 开发说明](development/Knowledge与Retrieval-Core开发说明.md)
|
||||
- [Benchmark 开发说明](development/Benchmark开发说明.md)
|
||||
- [Export 开发说明](development/Export开发说明.md)
|
||||
- [模型提供商与模型发现开发说明](development/模型提供商与模型发现开发说明.md)
|
||||
- [MCP Bridge 与 Plugin Host 开发说明](development/MCP-Bridge与Plugin-Host开发说明.md)
|
||||
- [独立 MCP Server 配置中心开发说明](development/独立MCP-Server配置中心开发说明.md)
|
||||
|
||||
@@ -2365,7 +2365,7 @@ Quality
|
||||
└── Retrieval 参数调优
|
||||
|
||||
Content Output
|
||||
├── Markdown → HTML / PDF / DOCX
|
||||
├── Markdown → HTML(已实现)/ PDF / DOCX(暂缓)
|
||||
├── Mermaid 编辑、预览与静态导出
|
||||
└── Function Plot 解析、预览与静态导出
|
||||
|
||||
@@ -2376,7 +2376,7 @@ Frontend Extension
|
||||
└── Plugin Settings UI
|
||||
```
|
||||
|
||||
上述列表描述第二阶段技术范围。多模态、MCP Bridge、Plugin Command/Settings、RAG 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 中直接绑定第三方协议。
|
||||
|
||||
第三阶段处理:
|
||||
|
||||
|
||||
@@ -25,9 +25,9 @@
|
||||
| 同步 | 技术栈中已有目标设计;`server sync/` 当前无实现文件 | 协议、独立服务、客户端队列、冲突、设备身份、部署运维 |
|
||||
| 原生桌面 | 已有需求文档 | Tauri 工程和 Rust Host 均需建设,不能将 Web 页面当作桌面交付 |
|
||||
|
||||
本次社区准备包验证为 70 项相关后端测试通过,并在本地 API 上完成真实 ZIP 导入、启用和命令执行;它不是所有第三阶段功能的验收。开发服务器监听新解压 `.py` 会热重载,当前内存安装记录随之丢失;持久化与开发监听排除规则列为首批问题。
|
||||
本次社区准备包验证为 70 项相关后端测试通过,并在本地 API 上完成真实 ZIP 导入、启用和命令执行;它不是所有第三阶段功能的验收。早期开发监听曾使内存安装记录丢失;第二阶段已补齐持久安装库、摘要复核及重启恢复,不再将其列作未实现。第三阶段继续负责升级事务和桌面生命周期。
|
||||
|
||||
第二阶段待验收事项单独保留:目标 Provider 真实账号兼容性、声纹阈值校准、带标注音频质量、逐字对齐和重叠语音。已有约 37 分 16 秒录音的 CUDA 功能闭环,无参考标注,不能报告 WER/CER、DER 达标。杨星萱负责的检索调优、Benchmark、导出和函数图像须由对应负责人确认状态,不因本规划自动判为完成或重新归责。
|
||||
第二阶段待验收事项单独保留:目标 Provider 真实账号兼容性、声纹阈值校准、带标注音频质量、逐字对齐和重叠语音。已有约 37 分 16 秒录音的 CUDA 功能闭环,无参考标注,不能报告 WER/CER、DER 达标。检索调优、Benchmark、导出和函数图像的当前交接结果见[第二阶段收尾验收](../development/第二阶段收尾实现与验收-2026-09-07.md),不因本规划扩大为第三阶段任务。
|
||||
|
||||
## 3. 架构、写入所有权与目录
|
||||
|
||||
@@ -212,7 +212,7 @@ base_revision 不匹配返回 409 类冲突与当前 Revision;界面展示本
|
||||
| --- | --- |
|
||||
| Web 单 Vault → 桌面多 Vault | 识别旧目录,备份元数据,保持 note_id/file_id 对应关系,校验文件摘要和数量;索引可重建,正文不能覆盖 |
|
||||
| Fernet → Stronghold | 按 credential_id 迁移、验证、记录版本,失败重试;迁移完成前保留旧存储,不向 UI 返回明文 |
|
||||
| 内存扩展记录 → 持久化安装库 | 探测用户认可的受管理包、重新校验、不自动继承高权限;重启恢复与包损坏修复必须实测 |
|
||||
| 既有持久化安装库 → 桌面受管理安装库 | 探测用户认可的受管理包、重新校验、不自动继承高权限;重启恢复与包损坏修复必须实测 |
|
||||
| 旧主题/Skill/Plugin → 社区版本 | ID/来源/版本/摘要关联,未知来源标本地;配置迁移保留备份,用户修改包不能静默覆盖 |
|
||||
| 首次绑定同步 | 本地/远端清单对账、显示新增和冲突,不以空 Vault 向另一端下发批量删除;绑定信息可撤销 |
|
||||
| 升级与降级 | Schema 版本门禁,升级前备份;不支持降级的数据库禁止旧客户端写入,提供恢复路径 |
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# 第二阶段团队分工表
|
||||
|
||||
## 2026-09-07 全范围收尾状态
|
||||
|
||||
本轮覆盖三位成员的第二阶段模块(音频质量专项除外),以[实现与验收记录](../development/第二阶段收尾实现与验收-2026-09-07.md)及下方更新后的DoD为当前口径。此前“不含杨侧验收”仅描述当日范围。现有DeepSeek/MiniMax真实链路通过,其他未配置协议保留外部验收项;音频使用Qwen3-ASR/ERes2NetV2技术路径,不声称交付原清单指定模型。
|
||||
|
||||
|
||||
## 2026-09-06 复核修复补充(不含杨侧验收)
|
||||
|
||||
- Agent 增加断点续读、有界重连和手动恢复;连接中断不再隐藏仍在运行任务的取消入口。
|
||||
@@ -776,33 +781,33 @@ Markdown
|
||||
- [x] Plugin Settings Contribution 后端可解析;
|
||||
- [x] Plugin Command 可以显示并从前端执行;
|
||||
- [x] Plugin Settings 可以动态生成设置项并独立提交 Secret;
|
||||
- [ ] Provider Adapter 的 Streaming / Tool Calling / Error Mapping 稳定;
|
||||
- [ ] 完成跨模块接口审阅和第二阶段集成。
|
||||
- [ ] 全部目标Provider真实矩阵;现有DeepSeek Streaming/Tool Calling/Error Mapping已通过,其他协议缺配置;
|
||||
- [x] 完成非音频范围跨模块接口审阅和第二阶段集成;外部协议/DOCX整页视觉边界见验收记录。
|
||||
|
||||
### 杨星萱
|
||||
|
||||
- [ ] RAG Benchmark Dataset 可以稳定运行;
|
||||
- [ ] 能输出 Hit@K、Recall@K、MRR、Latency 等指标;
|
||||
- [ ] Agent Benchmark Framework 可以执行标准 Case;
|
||||
- [ ] Markdown 可以导出 HTML;
|
||||
- [ ] Markdown 可以导出 PDF;
|
||||
- [ ] Markdown 可以导出 DOCX;
|
||||
- [ ] Mermaid 在导出链路中可以保留为静态图;
|
||||
- [ ] 函数图像能够由结构化表达生成;
|
||||
- [ ] 函数图像能够进入预览和导出链路;
|
||||
- [ ] Retrieval 调优结果有 Benchmark 数据支撑。
|
||||
- [x] RAG Benchmark Dataset 可以稳定运行;
|
||||
- [x] 能输出 Hit@K、Recall@K、MRR、Latency 等指标;
|
||||
- [x] Agent Benchmark Framework 可以执行标准 Case;
|
||||
- [x] Markdown 可以导出 HTML;
|
||||
- [x] Markdown 可以导出 PDF;
|
||||
- [x] Markdown 可以导出 DOCX;
|
||||
- [x] Mermaid 在导出链路中可以保留为静态图;
|
||||
- [x] 函数图像能够由结构化表达生成;
|
||||
- [x] 函数图像能够进入预览和导出链路;
|
||||
- [x] Retrieval 调优结果有 Benchmark 数据支撑。
|
||||
|
||||
### 吉海燕
|
||||
|
||||
- [ ] Theme Package 可以导入;
|
||||
- [ ] Theme Manifest 可以校验;
|
||||
- [ ] Theme 可以启用、停用和卸载;
|
||||
- [ ] Agent Trace 可以展示完整 Tool Call 顺序;
|
||||
- [ ] Trace Node 可以查看参数、结果、耗时和错误;
|
||||
- [ ] Markdown Mermaid Code Block 可以渲染;
|
||||
- [ ] Mermaid 支持主题切换;
|
||||
- [ ] Mermaid 渲染错误可以明确展示;
|
||||
- [ ] Mermaid 图能够提供给 Export Service。
|
||||
- [x] Theme Package 可以导入;
|
||||
- [x] Theme Manifest 可以校验;
|
||||
- [x] Theme 可以启用、停用和卸载;
|
||||
- [x] Agent Trace 可以展示完整 Tool Call 顺序;
|
||||
- [x] Trace Node 可以查看参数、结果、耗时和错误;
|
||||
- [x] Markdown Mermaid Code Block 可以渲染;
|
||||
- [x] Mermaid 支持主题切换;
|
||||
- [x] Mermaid 渲染错误可以明确展示;
|
||||
- [x] Mermaid 图能够提供给 Export Service。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -68,13 +68,13 @@
|
||||
| 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 预览和导出复用 |
|
||||
| Renderer | 内部 Contract | `StaticRenderer` | 已实现 | Function Plot 后端静态 SVG 渲染 + PDF 矢量内嵌(共享几何);Mermaid 返回占位;DOCX 保留源码占位 |
|
||||
|
||||
---
|
||||
|
||||
@@ -878,18 +878,19 @@ Dataset 从仓库或受控导入目录注册。API 不接受调用方提交任
|
||||
|
||||
```json
|
||||
{
|
||||
"case_id": "agent-os-review-001",
|
||||
"prompt": "查找死锁内容并创建三个复习任务",
|
||||
"allowed_tools": ["rag.search", "notes.read", "tasks.create"],
|
||||
"expected_tools": ["rag.search", "notes.read", "tasks.create"],
|
||||
"expected_conditions": {
|
||||
"citation_required": true,
|
||||
"tasks_created": 3
|
||||
},
|
||||
"tags": ["os", "write"]
|
||||
"case_id": "agent-add-001",
|
||||
"prompt": "调用 math.add 计算17加25,然后回答42。",
|
||||
"allowed_tools": ["math.add"],
|
||||
"expected_tools": [{"name": "math.add", "arguments": {"left": 17, "right": 25}}],
|
||||
"output_contains": ["42"],
|
||||
"citation_required": false,
|
||||
"tasks_created": null,
|
||||
"tags": ["math"]
|
||||
}
|
||||
```
|
||||
|
||||
每个Dataset最多100例且case_id唯一;Case至少声明工具、输出子串、引用或任务数量之一作为客观断言。预期工具必须属于allowed_tools。参数按声明键的值匹配,重复/额外调用计入错误,任务创建按成功Tool Result计数。
|
||||
|
||||
有写操作的 Agent Case 必须运行在隔离 Vault/数据库中,测试结束后清理 Fixture,禁止作用于用户真实知识库。
|
||||
|
||||
### 9.4 创建 RAG Benchmark
|
||||
@@ -902,6 +903,7 @@ Dataset 从仓库或受控导入目录注册。API 不接受调用方提交任
|
||||
"modes": ["fts", "vector", "hybrid"],
|
||||
"retrieval": {
|
||||
"top_k": 10,
|
||||
"fusion": "rrf",
|
||||
"rrf_k": 60,
|
||||
"rerank": true,
|
||||
"rerank_candidates": 20,
|
||||
@@ -914,23 +916,28 @@ Dataset 从仓库或受控导入目录注册。API 不接受调用方提交任
|
||||
|
||||
配置快照必须记录 Embedding model ID/version/dimension、Reranker、索引版本、Dataset Hash 和运行环境。
|
||||
|
||||
### 9.5 创建 Agent Benchmark(暂缓,未暴露接口)
|
||||
### 9.5 创建 Agent Benchmark(已实现)
|
||||
|
||||
`POST /api/benchmarks/agent/runs`
|
||||
|
||||
```json
|
||||
{
|
||||
"dataset_id": "agent-core-v1",
|
||||
"provider_id": "mock",
|
||||
"model": "mock-1",
|
||||
"skill_id": null,
|
||||
"max_steps": 10,
|
||||
"token_budget": 20000,
|
||||
"concurrency": 1,
|
||||
"metadata": {}
|
||||
"provider_id": "已有启用的Provider ID",
|
||||
"model": "已有模型",
|
||||
"max_steps": 6,
|
||||
"timeout_seconds": 90,
|
||||
"token_budget": 6000,
|
||||
"repeat": 1,
|
||||
"allow_network": false,
|
||||
"offline": false
|
||||
}
|
||||
```
|
||||
|
||||
`max_steps` 1–20、`timeout_seconds` 1–300、`token_budget` 1–30000、`repeat` 1–3。顺序执行以限制额度;默认拒绝mock。仅显式offline=true可使用mock,且offline不能选真实Provider。创建返回202;无Provider为404,模式冲突为422,容量超限为429。权限由用户在真实Trace中处理,不自动批准。共用运行查询/SSE/取消/报告端点。
|
||||
|
||||
报告包含逐例agent_run_id、success/checks、调用/匹配/无效数量、steps、latency_ms、token_usage。汇总task_success_rate以计划案例总数为分母,total_cases/evaluated_cases区分未完成样本;选择/参数准确率以max(实际调用总数,预期调用总数)为分母,无调用时为null。无效调用率以实际调用数为分母。平均步骤/耗时采用计划数分母,取消/失败报告不可当完整性能测量。配置冻结dataset hash/version、Provider类型/引用、模型、预算、评分版本与权限策略。
|
||||
|
||||
Benchmark Runner 通过正式 Agent Runtime 创建 Run,并从 Trace 计算结果,不能直接调用 Tool Executor 绕过权限和步骤控制。
|
||||
|
||||
### 9.6 Benchmark Run
|
||||
@@ -1080,6 +1087,8 @@ VECTOR_INDEX_REBUILD_REQUIRED
|
||||
|
||||
## 10. Export Service
|
||||
|
||||
> 当前实现:三格式已有编辑器快照入口、任务/取消/下载/warning。函数图为HTML SVG、PDF共享几何矢量、DOCX PNG;Mermaid通过前端准备并按源码摘要绑定PNG后进入三格式。未提供静态资源的直接API Mermaid请求仍保留源码并warning,不能假称服务器独立运行Mermaid。受支持MathText公式及Vault图片内嵌,完整限制见本节末补充。
|
||||
|
||||
### 10.1 创建导出任务
|
||||
|
||||
`POST /api/exports`,返回 `202 ExportJob`。
|
||||
@@ -1090,7 +1099,7 @@ VECTOR_INDEX_REBUILD_REQUIRED
|
||||
"type": "note",
|
||||
"note_id": "note_123"
|
||||
},
|
||||
"format": "pdf",
|
||||
"format": "html",
|
||||
"options": {
|
||||
"theme_id": "light",
|
||||
"include_title": true,
|
||||
@@ -1101,7 +1110,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 +1118,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 +1139,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 +1229,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
|
||||
```
|
||||
|
||||
---
|
||||
@@ -1613,3 +1623,25 @@ CUDA 组件:`GET /api/local-models/runtime-components/cuda` 返回 status、st
|
||||
#### 回答版本的上下文快照(2026-09-07)
|
||||
|
||||
`ChatMessage.context_captured` 为布尔值,旧记录默认 false。新 assistant 消息保存本次请求的 `workspace_context` 和 `attachments`,并设置 context_captured 为 true;此时 null 文件上下文和空附件列表都是明确快照。重新生成不覆盖原 user 消息的快照。客户端恢复旧记录时仅在 context_captured 为 false 时回退到对应父用户消息。
|
||||
|
||||
|
||||
## 2026-09-07 预览与静态资源增量契约
|
||||
|
||||
`POST /api/plots/function`:请求`{source, theme_id}`,source最多20000字符;响应`{result: {content,mime_type,width,height,warnings} | null, diagnostics: [{severity,code,message,line}], node_count}`。语法错误为200诊断、请求字段违规422。共享plot白名单解释器,不执行eval;每块16表达式、8000累计节点、并发2。主题映射当前六个Theme ID并提供CSS图表Token;未知主题回退light。
|
||||
|
||||
`ExportRequest`新增可选title(最多200字符)、assets(HTML/DOCX 最多64,PDF 不设数量上限);`source.file_path`为未保存快照中相对图片的基准位置,不能用于任意文件读。每个asset为`{kind: mermaid|math_block|math_inline|image, source_hash: 64位sha256十六进制, png_base64}`;摘要为strip后UTF-8源码(image为src)的SHA256。只接受有效PNG并重编码;HTML/DOCX 每图4百万像素、总16百万像素/8MiB,PDF 不使用这些预算。重复kind/hash或无效PNG返回422 EXPORT_ASSET_INVALID。摘要失配不替换当前节点,不接受客户端SVG/XML/URL执行。
|
||||
|
||||
未带资源的公式由 MathText 转换,仅解析 Vault 范围内 PNG/JPEG/WebP,拒绝远端与越界路径。HTML/DOCX 保留 512 字符/20 层/64 资源、单文件 2MB 的预算;PDF 不使用这些预算,也不限制导出源长度、产物字节数、函数图数量、表达式数量及累计复杂度。表达式白名单、有效图片校验、数值采样的收敛控制和队列并发调度仍保留。资源无法表示时保留源码/替代文字和 warning。
|
||||
|
||||
PDF 使用当前主题配色。`ExportOptions.palette` 可选,包含 page/surface/text/muted/code/border/accent 七个必填 `#RRGGBB` 值,由客户端在点击导出时冻结,用于自定义主题。未传 palette 时按 theme_id 解析六套内置配色,未知 ID 回退 light 并警告。PDF 页背景、正文、代码、表格、引用、链接、公式、Mermaid 和函数图均主题化;不会执行主题 CSS。DOCX 仍采用浅色打印样式;HTML 保留有限主题调色板。DOCX 图片为静态内容,不提供可编辑公式对象。
|
||||
|
||||
关闭导出窗口仅停止 UI 轮询,已发起的导出继续。主动取消在准备阶段停止提交;创建请求期间取消会等待任务 ID,调用后台取消接口并读取实际状态。
|
||||
|
||||
RAG `retrieval.fusion`接受rrf(默认)或weighted(归一化FTS/vector各50%),参数写入config_snapshot。真实本地单查询Embedding可命中有界进程缓存,provenance.query_embedding_cache为hit/miss;比较延迟须分别报告冷暖样本。HashEmbedding仍仅为确定性单元测试,不是当前生产检索模型。
|
||||
|
||||
|
||||
## PDF 浏览器快照契约(2026-09-07)
|
||||
|
||||
`ExportRequest.print_html?: string` 仅允许 format=pdf。应用导出发送包含实际主题 CSS、根属性/CSS 变量、笔记 DOM 和内嵌字体图片的 HTML 快照;后端通过禁用脚本与网络的 Chromium 打印。`options.page_size` 控制 A4/Letter。print_html 存在时不再经过 ReportLab 的 AST 重排;无此字段的旧客户端维持兼容渲染。
|
||||
|
||||
`POST /api/exports/preview-resources` 接收 ExportRequest 的 source/options,返回 `{images:[{source,data:string|null,warnings:string[]}],plots:[{source,svg,warnings:string[]}]}`,供浏览器共享 Markdown 渲染器使用。图片仅解析 Vault 相对路径;函数图使用共享白名单解释器,PDF 不使用预览页面配额。客户端准备失败不会创建半成品任务,已提交任务仍走统一取消和下载接口。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Benchmark 开发说明
|
||||
|
||||
> 所属模块:Knowledge / Retrieval Core(后端,负责人 yxx)。RAG Benchmark 已交付;Agent Benchmark 暂缓,待 Agent Runtime 完成后在同一契约下补齐。
|
||||
> 所属模块:Knowledge / Retrieval Core(后端,负责人 yxx)。RAG 与标准Agent Benchmark均已实现,前端入口`/benchmarks`。真实验收结果与范围见[第二阶段收尾记录](第二阶段收尾实现与验收-2026-09-07.md)。
|
||||
|
||||
## 定位
|
||||
|
||||
@@ -18,7 +18,7 @@ Benchmark Service 用受控 Dataset 对检索引擎做可复现评测:创建
|
||||
| POST | `/api/benchmarks/runs/{run_id}/cancel` | 取消运行 |
|
||||
| GET | `/api/benchmarks/runs/{run_id}/report` | 结构化完整报告 |
|
||||
|
||||
Agent Benchmark 的 `/api/benchmarks/agent/runs` 未暴露(暂缓),不在 OpenAPI 注册占位接口。
|
||||
Agent Benchmark的`POST /api/benchmarks/agent/runs`已实现并注册OpenAPI,使用正式Agent Runtime与Trace;请求/Case见[契约§9](../contracts/第二阶段接口契约-开发版.md)。默认只接受真实Provider,显式offline才允许mock,不自动批准工具权限。
|
||||
|
||||
## Dataset
|
||||
|
||||
@@ -30,7 +30,7 @@ RAG Case 结构:`case_id`、`query`、`expected_note_ids`、`expected_block_id
|
||||
|
||||
`queued → running → completed | failed | cancelled`。
|
||||
|
||||
- 创建时校验索引兼容性:索引非空、Embedding model/dim 与当前引擎一致、vector/hybrid 时向量索引非空;不满足返回 `BENCHMARK_INDEX_INCOMPATIBLE`(409),避免把环境/索引错误误判为检索质量差。
|
||||
- RAG创建时校验索引兼容性:索引非空、Embedding model/dim 与当前引擎一致、vector/hybrid 时向量索引非空;不满足返回 `BENCHMARK_INDEX_INCOMPATIBLE`(409),避免把环境/索引错误误判为检索质量差。
|
||||
- 内存注册表上限 `MAX_RUNS=100`,超限只淘汰终态 run;满容量且全为活动 run 时返回 `BENCHMARK_CAPACITY_EXCEEDED`(429)。
|
||||
- 失败/取消只向公开响应暴露项目错误码与安全消息,详细异常进入日志,不通过 HTTP/SSE 返回。
|
||||
|
||||
@@ -76,3 +76,10 @@ uv run pytest -q
|
||||
```
|
||||
|
||||
`tests/test_benchmark.py` 覆盖数据集注册与校验、指标纯函数、端到端运行、取消、索引兼容、容量与失败样本聚合;`tests/test_retrieval.py` 覆盖 FTS 阈值与分页 total 一致性。
|
||||
|
||||
|
||||
## 2026-09-07 数据与复现补充
|
||||
|
||||
`agent-core-v1.json`为四例标准任务;`rag-phase2-v1.json`对应独立测试语料,不对应任意用户Vault,运行前必须用`phase2-quality.py`准备语料及真实索引。语料源、配置、逐例结果和冷暖缓存说明见收尾验收记录。UI支持RAG/Agent切换、Provider/模型、融合/TopK/RRF/rerank配置、取消、报告JSON与Trace入口。
|
||||
|
||||
Agent成功判定包含终态完成、预期工具及声明参数一一匹配、无额外调用、工具结果成功、输出子串、引用和任务数量;并非由另一个LLM主观打分。失败/取消报告保留total_cases与evaluated_cases,不能当成完成的质量测量。报告注册表最多100条且不跨进程恢复,重要结果须下载保存;持久Agent Trace独立保留。工具选择/参数准确率和无效调用率在无分母时返回null。
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
# 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 → 共享几何(compute_geometry)+ 静态 SVG
|
||||
├── render_reportlab.py FunctionPlot → reportlab 矢量 Drawing(PDF 内嵌)
|
||||
└── 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 为文本优先,覆盖标题/段落/行内强调与链接/列表/引用/表格/代码块/数学文本;`mermaid` 保留源码占位并记 warning。`function_plot` 在 PDF 中已内嵌为矢量图,在 DOCX 中仍保留源码占位并记 warning(DOCX 内嵌需栅格化,本轮范围外)。
|
||||
|
||||
- PDF 中文字体用 reportlab 内置 `STSong-Light` CID 字体,无外部字体依赖;CID 字体无独立 bold/italic 字重,行内强调退化为普通文本(内容不丢、样式简化),标题靠字号区分层级。
|
||||
- PDF 的 `function_plot` 经 `render_reportlab` 消费 `compute_geometry` 的共享几何,产出矢量 `Drawing`(网格/坐标轴 `Line`、曲线 `PolyLine`、刻度/标签 `String`,ylabel 用 `Group` 旋转),再按页面内容宽缩放追加到 story,与 HTML 的 SVG 视觉一致;解析/渲染失败或超预算时回退源码占位并记 warning,单图失败不阻断整篇。
|
||||
- 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 渲染、共享几何 `compute_geometry`、`render_reportlab` 矢量 Drawing(Line/PolyLine/String/Group、CJK 字体、y 翻转、缩放)与 `StaticRenderer` 契约(函数图像渲染、Mermaid 占位)。
|
||||
|
||||
## 范围外(后续 PR)
|
||||
|
||||
- Mermaid 静态渲染(后端无渲染能力,HTML/PDF/DOCX 均保留源码占位)。
|
||||
- DOCX 内嵌函数图像(需栅格化为 PNG,本轮范围外,仅 PDF 内嵌矢量图)。
|
||||
- 函数图像交互预览与缩放(前端 JS Renderer 负责,后端仅提供静态 SVG)。
|
||||
- 代码语法高亮(当前仅 CSS class 占位)。
|
||||
|
||||
### PR #41:陡峭连续曲线与渐近线区分(2026-09-07)
|
||||
|
||||
每个相邻有限采样区间都会检查中点,不再要求端点分别位于 range 上下两侧,也不因找到一个可见中点就连接整个区间。共享几何层检查中点与弦的偏差:有可见点且误差不超过四分之一像素时保留子段,否则继续细分左右两侧。每个区间最多额外求值 256 次、深度最多 24 层;同一表达式全部区间共享 8192 次额外求值预算,避免全区间检查导致无界增长。达到限制或无法继续推进浮点坐标时,以显式断点隔开未验证子段。遇到非有限中点仍检查它的两侧,保留有效分支,但不跨过非有限点连接。整条曲线耗尽预算时返回 warning,提示缩小 domain 后重试。
|
||||
|
||||
采样三点全在同一不可见侧的子段直接舍弃。细分点与普通点一样检查映射后坐标是否有限,再统一裁剪。SVG 与 PDF 使用相同结果。这是有界数值采样,不是任意函数连续性的数学证明;高频或极窄特征仍受采样与精度限制。
|
||||
|
||||
回归覆盖陡峭正负直线、百万斜率、可见中点混合极点、极小纵轴范围、两端均在可见范围内的极点、极点恰好位于中点、常见连续函数及 log/sqrt 定义域边界;验证区间与整条曲线共享求值预算,耗尽后保留断点和 warning,SVG/PDF 曲线坐标不得包含 NaN/Infinity。
|
||||
|
||||
补充检测:36 组不同系数和极点位置的几何检查通过。一次本机测量中,百万斜率直线和普通倒数曲线约 3 ms,高频 `sin(1000000000*x)` 达到预算并返回 warning,约 45 ms;该数据用于验证有界退出,不作为性能承诺。
|
||||
|
||||
### PR #41:主题与警告框导出(2026-09-07)
|
||||
|
||||
HTML 支持 light、dark、sepia、paper-moments、midnight-purple 五套固定导出配色,覆盖正文、代码、表格、链接、引用和函数图像坐标文字。代码块独立设置前景与背景;不加载任意主题 CSS,也不复刻编辑器装饰。未知主题回退 light 并返回 warning。
|
||||
|
||||
PDF、DOCX 保持浅色打印样式;选择其他主题时返回明确 warning,需要主题配色请导出 HTML。警告框保留类型、富文本标题、正文与嵌套块;HTML 使用 details 支持默认展开和折叠,PDF、DOCX 始终输出完整内容,以彩色标题区区分类型。
|
||||
|
||||
验证:test_export.py 覆盖五套配色、未知主题安全回退、所有内置警告框类型与别名、折叠状态、嵌套正文和打印回退提示。浏览器检查深色导出的代码、表格及警告框对比度。
|
||||
|
||||
|
||||
列表内的警告框和其他已支持块级节点使用块级渲染,PDF 保留列表缩进及可用宽度,DOCX 累加段落和表格缩进。回归测试检查有序、无序、任务列表中的警告框标题、正文、多层嵌套及后续段落,直接验证 PDF 文本和 DOCX 段落的内容顺序。
|
||||
|
||||
警告框识别与工作区一致:标记与标题之间可不留空格,类型允许数字、下划线和连字符;自定义类型回退 note 配色并保留自定义标题,省略标题时使用类型名称首字母大写。
|
||||
|
||||
警告框、普通引用、列表及交叉嵌套中的 Markdown 表格均启用容器内部解析,HTML 输出 table、PDF 输出 Table、DOCX 输出原生表格。测试逐一检查单元格内容和产物结构。每个 HTML 警告框独立初始化颜色变量,避免 NOTE 等类型继承外层 WARNING 的颜色;已在五套内置导出主题中检查嵌套配色及表格显示。
|
||||
|
||||
|
||||
### PDF 主题与资源策略更新(2026-09-07)
|
||||
|
||||
当前 PDF 行为以此节为准,覆盖上文早期 PR 的浅色打印和资源预算说明。
|
||||
|
||||
PDF 使用导出按钮点击时的主题配色快照,支持六种内置/社区主题与自定义主题的七项颜色。页背景、正文、引用、代码块、表格、链接、语义提示块、Mermaid、公式与函数图均参与主题适配;公式使用透明底再合成主题表面色,深色曲线使用较亮的默认色。标题随下一块分页,代码保留背景与边框。PDF 不执行 CSS 装饰或主题脚本。
|
||||
|
||||
PDF 取消导出源/产物大小限制、Mermaid 数量和像素预算、请求资源数量和字节预算、Vault 图片大小/累计预算、MathText 长度/深度预算、函数图数量/表达式数量及 AST 预算。HTML/DOCX、在线预览仍使用原有限制;有效图片、Vault 路径、表达式语法校验和数值求解退出条件仍生效。无限制指移除应用导出配额,实际文件规模仍受浏览器、解析器和可用内存约束。
|
||||
|
||||
关闭窗口仅结束 UI 生命周期,导出流程继续;主动取消仍取消提交中的后台任务。回归测试见 `ExportDialog.spec.ts`、`exportService.spec.ts`、`test_pdf_theme_resources.py`。
|
||||
|
||||
|
||||
### PDF 实际主题样式修正(2026-09-07)
|
||||
|
||||
七色调色板加 ReportLab 固定排版不能复现笔记主题。应用界面的 PDF 导出现在提交 `print_html`:复用 Markdown 渲染器、KaTeX、Shiki、编辑器 DOM 容器、主题 CSS、CSS 变量、标题偏好和字体资源,由 Chromium 打印为可选择文本的 PDF。保留背景纹理、伪元素、边框、阴影和语义样式;打印规则只处理页面尺寸、滚动容器、分页、图表缩放和交互按钮。提示块采用编辑器的 blockquote 结构以匹配主题选择器。KaTeX 内部 SVG 不应用图表缩放规则。
|
||||
|
||||
`POST /api/exports/preview-resources` 根据同一 Markdown 快照准备 Vault 图片和函数图 SVG,不恢复 PDF 的旧资源配额。图片保留透明通道;不读取 Vault 外文件。客户端将字体和主题图片内嵌为 data URI。浏览器进程关闭文档 JavaScript、网络与文件资源加载,等待字体就绪后打印;不会为静态 CSS 加载用户脚本。
|
||||
|
||||
后端依赖 Playwright(已写入 pyproject/uv.lock)。Windows 优先使用本机 Edge/Chrome,可用 `APP_PDF_BROWSER` 指定可执行文件;没有系统浏览器时,在 backend 目录运行 `uv run playwright install chromium`。打印在独立子进程中执行,避免 Windows Uvicorn 事件循环冲突。
|
||||
|
||||
不传 print_html 的旧 API 客户端保留 ReportLab 兼容路径,其固定排版不能代表应用主题的实际样式。HTML/DOCX 导出行为不变。主题字体/图片需可从应用资源读取并内嵌,读取失败会报错,避免输出缺失资源却宣称样式一致。
|
||||
@@ -0,0 +1,22 @@
|
||||
# PDF 主题与资源限制修复验收
|
||||
|
||||
工作目录:`G:/OSProject/NotesAgent`;分支:`feat/phase2-completion`。
|
||||
|
||||
## 完成内容
|
||||
|
||||
1. 修复关闭窗口误取消提交中的导出任务,保留主动取消。
|
||||
2. PDF 移除前后端导出数量、大小、图片像素及累计资源配额,详见接口契约。
|
||||
3. PDF 读取点击导出时的主题颜色快照,覆盖六套主题和自定义调色板,移除强制浅色打印提示。
|
||||
4. 公式透明底、Mermaid 主题变量、矢量函数图、代码背景与边框、表格、提示块和页面背景都使用对应主题颜色。
|
||||
|
||||
## 验证
|
||||
|
||||
- 全量后端:879 项通过,1 条既有 Starlette/httpx 弃用警告。
|
||||
- 全量前端:82 文件、448 项通过。
|
||||
- 分页/代码背景最终调整后:相关后端 108 项通过。
|
||||
- 前端生产构建通过,仍有既有大分包提示。
|
||||
- 超旧限制回归:17 个 Mermaid、65 个文档资源、单图超过 400 万像素、超过旧源/产物阈值、17 条函数表达式与20个函数图。
|
||||
- 真实前端 Mermaid 栅格化 + 后端导出流水线生成 light/dark/sepia/paper-moments/ocean-blue/midnight-purple 六份 PDF;Poppler 渲染并检查全部12页,未出现白底公式、不可读深色文字、图表裁切或孤立章节标题。
|
||||
- 验证数据与截图:本机 `.local-plans/pdf-theme/`;全部接口拦截为测试数据,未访问真实模型或用户笔记。
|
||||
|
||||
本次未改动用户已有的三份笔记修改。主题适配使用颜色与语义样式,不将任意主题 CSS/脚本直接用于 PDF。
|
||||
@@ -0,0 +1,25 @@
|
||||
# PDF 实际主题样式修复验收
|
||||
|
||||
路径:G:/OSProject/NotesAgent。此前七色调色板验收仅覆盖配色,不能代表完整主题样式一致;本次替换应用 PDF 导出的渲染链路。
|
||||
|
||||
- 使用真实主题 CSS、编辑器容器、伪元素、字体、标题偏好、KaTeX 与 Shiki,Chromium 输出 PDF;旧客户端 ReportLab 路径仅用于兼容。
|
||||
- 纸间时光保留纸张边框、缝线、左侧装订线、顶部胶带、提示块与代码块装饰。修复跨页图表右侧裁切、提示块 DOM 选择器及 KaTeX 根号 SVG 受图表规则影响的问题。
|
||||
- 无旧 PDF 数量/大小配额;内嵌资源、禁用文档脚本及网络访问、保留用户主动取消行为。
|
||||
- 六主题各两页,由真实前端构造快照、实际后端导出、Poppler 渲染检查。样例和浏览器参考截图位于本机 .local-plans/pdf-browser。
|
||||
- 后端全量 882 项、前端全量 451 项通过;最终排版调整后相关前端19项、资源/浏览器后端15项通过;生产构建成功。1 条既有 Starlette 警告和既有大分包提示保留。
|
||||
- 浏览器烟测验证 CSS 伪元素文本进入 PDF,文档脚本未执行;Vault 越界图片被拒绝,17 条函数表达式可准备。
|
||||
- 用户已有的三份笔记修改未纳入提交。
|
||||
|
||||
|
||||
## 元数据栏补充验收
|
||||
|
||||
PDF 快照使用与可视编辑器相同的 `splitNoteMetadata` 解析当前内容,在正文前输出笔记属性、标题和标签,保留 `.note-metadata` 结构及 Vue scoped 样式属性。标签输入框和移除按钮不进入导出。识别出的 YAML 不再作为 Markdown 正文渲染;不支持的元数据仍按编辑器规则保留原文。不额外读取磁盘,因此导出包含当前未保存的元数据。
|
||||
|
||||
验证:PDF 快照与导出服务 15 项测试通过,前端类型检查及生产构建通过(保留已有大分块提示)。六种主题均实际生成 PDF,并检查包含元数据栏的第一页:paper-moments、dark、light、sepia、ocean-blue、midnight-purple。纸张主题的胶带、缝线及标签配色正常,其他主题的元数据背景、边框和文字配色正常。
|
||||
|
||||
|
||||
## 合并审阅问题修复
|
||||
|
||||
- 只有标题、标签而正文为空或仅有空白时,跳过资源准备请求,继续生成元数据栏,避免空 Markdown 触发 422。
|
||||
- PDF 资源准备覆盖块级及行内 HTML 的 img,使用 HTMLParser 处理标签和属性实体,再复用 Vault 图片路径与格式校验。行内 HTML 在 AST 中单独标记;行内代码和代码块不会被作为 HTML 图片收集。
|
||||
- 回归覆盖纯元数据、空白正文、HTML 图片实体与相对路径、越界及远程资源、代码示例排除。后端相关 114 项、前端相关 18 项通过,vue-tsc 类型检查通过。pytest 仅提示本机缓存目录不可写,测试本身通过。
|
||||
@@ -0,0 +1,50 @@
|
||||
[
|
||||
{
|
||||
"theme": "paper-moments",
|
||||
"pages": 2,
|
||||
"bytes": 187968,
|
||||
"sha256": "33a28768101307d97f950135bd8910907ab88df82174b87d4350bd02c71e3236",
|
||||
"renderer": "Chromium / actual CSS snapshot",
|
||||
"visual_review": "passed"
|
||||
},
|
||||
{
|
||||
"theme": "light",
|
||||
"pages": 2,
|
||||
"bytes": 105426,
|
||||
"sha256": "50bd5cfb9303c17d1b34fd5e7b3ed05915720dd45aba2dfa5623e4c387f273a6",
|
||||
"renderer": "Chromium / actual CSS snapshot",
|
||||
"visual_review": "passed"
|
||||
},
|
||||
{
|
||||
"theme": "dark",
|
||||
"pages": 2,
|
||||
"bytes": 105445,
|
||||
"sha256": "586da3acaf3f55ad802da13c188544a50347f1b58d2fbe2c20a22605d9d9e3d9",
|
||||
"renderer": "Chromium / actual CSS snapshot",
|
||||
"visual_review": "passed"
|
||||
},
|
||||
{
|
||||
"theme": "sepia",
|
||||
"pages": 2,
|
||||
"bytes": 105471,
|
||||
"sha256": "700bf49901ef23da7f760efba346a69a61f7ce4f3bb26b3b2e7ab673ecbd3809",
|
||||
"renderer": "Chromium / actual CSS snapshot",
|
||||
"visual_review": "passed"
|
||||
},
|
||||
{
|
||||
"theme": "ocean-blue",
|
||||
"pages": 2,
|
||||
"bytes": 105414,
|
||||
"sha256": "7501dc0765af583d498ed54c9678631d9e6578dcb68dbc6db9c0e83551cc3479",
|
||||
"renderer": "Chromium / actual CSS snapshot",
|
||||
"visual_review": "passed"
|
||||
},
|
||||
{
|
||||
"theme": "midnight-purple",
|
||||
"pages": 2,
|
||||
"bytes": 105489,
|
||||
"sha256": "213f59e4b43e701deda9e6b8589913c2d795d3ef8a642b516ba425aa1bd6da9e",
|
||||
"renderer": "Chromium / actual CSS snapshot",
|
||||
"visual_review": "passed"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,104 @@
|
||||
[
|
||||
{
|
||||
"theme": "light",
|
||||
"pages": 2,
|
||||
"bytes": 67245,
|
||||
"sha256": "5b6efed909df971f455253958ceadce47a7bd198b52ca1a1c755d9d25c51dc05",
|
||||
"palette": {
|
||||
"page": "#ffffff",
|
||||
"surface": "#ffffff",
|
||||
"text": "#1f2328",
|
||||
"muted": "#656d76",
|
||||
"code": "#f7f8fa",
|
||||
"border": "#e4e7eb",
|
||||
"accent": "#5b67f1"
|
||||
},
|
||||
"warnings": [],
|
||||
"visual_review": "passed"
|
||||
},
|
||||
{
|
||||
"theme": "dark",
|
||||
"pages": 2,
|
||||
"bytes": 66788,
|
||||
"sha256": "b09da4dc97246b7444f17d2b5756dea6dda004981cbcbd360bc2de3d26bd00cf",
|
||||
"palette": {
|
||||
"page": "#0d1117",
|
||||
"surface": "#161b22",
|
||||
"text": "#e6edf3",
|
||||
"muted": "#8b949e",
|
||||
"code": "#161b22",
|
||||
"border": "#30363d",
|
||||
"accent": "#7d8bff"
|
||||
},
|
||||
"warnings": [],
|
||||
"visual_review": "passed"
|
||||
},
|
||||
{
|
||||
"theme": "sepia",
|
||||
"pages": 2,
|
||||
"bytes": 66836,
|
||||
"sha256": "abc329f9691ba46d8ddd25e4b3621b63fc4cc8baba8823800c47d68feb910e0c",
|
||||
"palette": {
|
||||
"page": "#fbf3df",
|
||||
"surface": "#fff8e8",
|
||||
"text": "#40372b",
|
||||
"muted": "#746653",
|
||||
"code": "#f4e8ca",
|
||||
"border": "#ddcfad",
|
||||
"accent": "#8a5b32"
|
||||
},
|
||||
"warnings": [],
|
||||
"visual_review": "passed"
|
||||
},
|
||||
{
|
||||
"theme": "paper-moments",
|
||||
"pages": 2,
|
||||
"bytes": 66808,
|
||||
"sha256": "e1929fbec0486b59abcc808fab557f4103ec102ad70e6fb9f0b26f84414c0fe4",
|
||||
"palette": {
|
||||
"page": "#faf7ee",
|
||||
"surface": "#fffdf5",
|
||||
"text": "#493f35",
|
||||
"muted": "#6e6053",
|
||||
"code": "#f3eee3",
|
||||
"border": "#b5a693",
|
||||
"accent": "#875343"
|
||||
},
|
||||
"warnings": [],
|
||||
"visual_review": "passed"
|
||||
},
|
||||
{
|
||||
"theme": "ocean-blue",
|
||||
"pages": 2,
|
||||
"bytes": 67260,
|
||||
"sha256": "1e567daeb2ba8bcb5c13af465f174865a3662ade444c58cd09af66ae99233346",
|
||||
"palette": {
|
||||
"page": "#ffffff",
|
||||
"surface": "#ffffff",
|
||||
"text": "#1e293b",
|
||||
"muted": "#64748b",
|
||||
"code": "#f8fafc",
|
||||
"border": "#e2e8f0",
|
||||
"accent": "#0077b6"
|
||||
},
|
||||
"warnings": [],
|
||||
"visual_review": "passed"
|
||||
},
|
||||
{
|
||||
"theme": "midnight-purple",
|
||||
"pages": 2,
|
||||
"bytes": 66788,
|
||||
"sha256": "fd5efcedec0d7f4892fb987e0e4ba9052aa88b7361159cbb2ca3b7e00dde495c",
|
||||
"palette": {
|
||||
"page": "#1a1b26",
|
||||
"surface": "#24283b",
|
||||
"text": "#c0caf5",
|
||||
"muted": "#9aa5ce",
|
||||
"code": "#24283b",
|
||||
"border": "#3b3f5c",
|
||||
"accent": "#9d4edd"
|
||||
},
|
||||
"warnings": [],
|
||||
"visual_review": "passed"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,234 @@
|
||||
{
|
||||
"python": "3.13.9",
|
||||
"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": 168.93,
|
||||
"p95_ms": 172.17,
|
||||
"max_ms": 173.06
|
||||
},
|
||||
"completed": 20,
|
||||
"ordered_events_and_replay": true,
|
||||
"terminal_recovery": true,
|
||||
"recovery_read_ms": 35.15,
|
||||
"retained_records": 20,
|
||||
"elapsed_ms": 3446.25,
|
||||
"event_loop_lag": {
|
||||
"count": 223,
|
||||
"median_ms": 5.42,
|
||||
"p95_ms": 12.94,
|
||||
"max_ms": 15.02
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario": "agent_tool_runs",
|
||||
"concurrency": 10,
|
||||
"runs": 20,
|
||||
"latency": {
|
||||
"count": 20,
|
||||
"median_ms": 203.54,
|
||||
"p95_ms": 205.86,
|
||||
"max_ms": 206.36
|
||||
},
|
||||
"completed": 20,
|
||||
"ordered_events_and_replay": true,
|
||||
"terminal_recovery": true,
|
||||
"recovery_read_ms": 31.0,
|
||||
"retained_records": 40,
|
||||
"elapsed_ms": 479.2,
|
||||
"event_loop_lag": {
|
||||
"count": 31,
|
||||
"median_ms": 5.12,
|
||||
"p95_ms": 13.95,
|
||||
"max_ms": 14.6
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario": "agent_tool_runs",
|
||||
"concurrency": 50,
|
||||
"runs": 50,
|
||||
"latency": {
|
||||
"count": 50,
|
||||
"median_ms": 396.17,
|
||||
"p95_ms": 397.13,
|
||||
"max_ms": 397.52
|
||||
},
|
||||
"completed": 50,
|
||||
"ordered_events_and_replay": true,
|
||||
"terminal_recovery": true,
|
||||
"recovery_read_ms": 86.99,
|
||||
"retained_records": 90,
|
||||
"elapsed_ms": 554.8,
|
||||
"event_loop_lag": {
|
||||
"count": 35,
|
||||
"median_ms": 5.12,
|
||||
"p95_ms": 11.71,
|
||||
"max_ms": 46.86
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario": "agent_tool_runs",
|
||||
"concurrency": 200,
|
||||
"runs": 200,
|
||||
"latency": {
|
||||
"count": 200,
|
||||
"median_ms": 1004.47,
|
||||
"p95_ms": 1155.52,
|
||||
"max_ms": 1155.62
|
||||
},
|
||||
"completed": 200,
|
||||
"ordered_events_and_replay": true,
|
||||
"terminal_recovery": true,
|
||||
"recovery_read_ms": 313.29,
|
||||
"retained_records": 200,
|
||||
"elapsed_ms": 1550.94,
|
||||
"event_loop_lag": {
|
||||
"count": 113,
|
||||
"median_ms": 3.39,
|
||||
"p95_ms": 10.13,
|
||||
"max_ms": 12.19
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario": "capacity_and_cancel",
|
||||
"active_limit": 200,
|
||||
"overflow_rejected": true,
|
||||
"cancelled": 200,
|
||||
"cancel_latency": {
|
||||
"count": 200,
|
||||
"median_ms": 4.26,
|
||||
"p95_ms": 5.85,
|
||||
"max_ms": 12.49
|
||||
},
|
||||
"elapsed_ms": 1977.57,
|
||||
"event_loop_lag": {
|
||||
"count": 167,
|
||||
"median_ms": 1.68,
|
||||
"p95_ms": 4.24,
|
||||
"max_ms": 6.54
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario": "permission_wait",
|
||||
"runs": 20,
|
||||
"approved_completed": 10,
|
||||
"cancelled_waiting_permission": 10,
|
||||
"subscribers_released": true,
|
||||
"elapsed_ms": 294.49,
|
||||
"event_loop_lag": {
|
||||
"count": 20,
|
||||
"median_ms": 4.08,
|
||||
"p95_ms": 6.57,
|
||||
"max_ms": 9.92
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario": "failure_and_timeout_isolation",
|
||||
"runs": 20,
|
||||
"success": 5,
|
||||
"provider_errors": 5,
|
||||
"model_timeouts": 5,
|
||||
"tool_timeouts": 5,
|
||||
"remaining_tool_executors": 0,
|
||||
"elapsed_ms": 1220.51,
|
||||
"event_loop_lag": {
|
||||
"count": 80,
|
||||
"median_ms": 5.38,
|
||||
"p95_ms": 6.33,
|
||||
"max_ms": 11.29
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario": "task_api_crud",
|
||||
"tasks": 100,
|
||||
"client_concurrency": 20,
|
||||
"latencies": {
|
||||
"create": {
|
||||
"count": 100,
|
||||
"median_ms": 103.05,
|
||||
"p95_ms": 143.89,
|
||||
"max_ms": 167.59
|
||||
},
|
||||
"update": {
|
||||
"count": 100,
|
||||
"median_ms": 124.21,
|
||||
"p95_ms": 139.58,
|
||||
"max_ms": 146.49
|
||||
},
|
||||
"list": {
|
||||
"count": 1,
|
||||
"median_ms": 3.33,
|
||||
"p95_ms": 3.33,
|
||||
"max_ms": 3.33
|
||||
},
|
||||
"delete": {
|
||||
"count": 100,
|
||||
"median_ms": 118.6,
|
||||
"p95_ms": 134.84,
|
||||
"max_ms": 147.0
|
||||
}
|
||||
},
|
||||
"default_page_count": 50,
|
||||
"default_total": 100,
|
||||
"pagination_complete": true,
|
||||
"final_total": 0,
|
||||
"elapsed_ms": 1841.45,
|
||||
"event_loop_lag": {
|
||||
"count": 146,
|
||||
"median_ms": 1.87,
|
||||
"p95_ms": 5.05,
|
||||
"max_ms": 65.53
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario": "task_api_crud",
|
||||
"tasks": 1000,
|
||||
"client_concurrency": 20,
|
||||
"latencies": {
|
||||
"create": {
|
||||
"count": 1000,
|
||||
"median_ms": 109.16,
|
||||
"p95_ms": 121.67,
|
||||
"max_ms": 133.03
|
||||
},
|
||||
"update": {
|
||||
"count": 1000,
|
||||
"median_ms": 125.42,
|
||||
"p95_ms": 146.2,
|
||||
"max_ms": 165.48
|
||||
},
|
||||
"list": {
|
||||
"count": 10,
|
||||
"median_ms": 5.12,
|
||||
"p95_ms": 7.31,
|
||||
"max_ms": 7.31
|
||||
},
|
||||
"delete": {
|
||||
"count": 1000,
|
||||
"median_ms": 122.71,
|
||||
"p95_ms": 145.87,
|
||||
"max_ms": 171.81
|
||||
}
|
||||
},
|
||||
"default_page_count": 50,
|
||||
"default_total": 1000,
|
||||
"pagination_complete": true,
|
||||
"final_total": 0,
|
||||
"elapsed_ms": 18067.11,
|
||||
"event_loop_lag": {
|
||||
"count": 1540,
|
||||
"median_ms": 1.33,
|
||||
"p95_ms": 4.46,
|
||||
"max_ms": 31.93
|
||||
}
|
||||
}
|
||||
],
|
||||
"database_bytes": 2932736,
|
||||
"complete": true
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
{
|
||||
"python": "3.13.9",
|
||||
"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": 167.71,
|
||||
"p95_ms": 181.07,
|
||||
"max_ms": 203.5
|
||||
},
|
||||
"completed": 20,
|
||||
"ordered_events_and_replay": true,
|
||||
"terminal_recovery": true,
|
||||
"retained_records": 20,
|
||||
"elapsed_ms": 3464.02,
|
||||
"event_loop_lag": {
|
||||
"count": 224,
|
||||
"median_ms": 5.41,
|
||||
"p95_ms": 13.45,
|
||||
"max_ms": 33.56
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario": "agent_tool_runs",
|
||||
"concurrency": 10,
|
||||
"runs": 20,
|
||||
"latency": {
|
||||
"count": 20,
|
||||
"median_ms": 219.61,
|
||||
"p95_ms": 224.28,
|
||||
"max_ms": 224.39
|
||||
},
|
||||
"completed": 20,
|
||||
"ordered_events_and_replay": true,
|
||||
"terminal_recovery": true,
|
||||
"retained_records": 40,
|
||||
"elapsed_ms": 500.46,
|
||||
"event_loop_lag": {
|
||||
"count": 33,
|
||||
"median_ms": 4.35,
|
||||
"p95_ms": 13.1,
|
||||
"max_ms": 24.21
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario": "agent_tool_runs",
|
||||
"concurrency": 50,
|
||||
"runs": 50,
|
||||
"latency": {
|
||||
"count": 50,
|
||||
"median_ms": 356.51,
|
||||
"p95_ms": 358.17,
|
||||
"max_ms": 358.28
|
||||
},
|
||||
"completed": 50,
|
||||
"ordered_events_and_replay": true,
|
||||
"terminal_recovery": true,
|
||||
"retained_records": 90,
|
||||
"elapsed_ms": 495.22,
|
||||
"event_loop_lag": {
|
||||
"count": 27,
|
||||
"median_ms": 2.66,
|
||||
"p95_ms": 35.07,
|
||||
"max_ms": 73.78
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario": "agent_tool_runs",
|
||||
"concurrency": 200,
|
||||
"runs": 200,
|
||||
"latency": {
|
||||
"count": 200,
|
||||
"median_ms": 944.9,
|
||||
"p95_ms": 1007.22,
|
||||
"max_ms": 1007.96
|
||||
},
|
||||
"completed": 200,
|
||||
"ordered_events_and_replay": true,
|
||||
"terminal_recovery": true,
|
||||
"retained_records": 200,
|
||||
"elapsed_ms": 1412.6,
|
||||
"event_loop_lag": {
|
||||
"count": 77,
|
||||
"median_ms": 3.46,
|
||||
"p95_ms": 10.98,
|
||||
"max_ms": 326.14
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario": "capacity_and_cancel",
|
||||
"active_limit": 200,
|
||||
"overflow_rejected": true,
|
||||
"cancelled": 200,
|
||||
"cancel_latency": {
|
||||
"count": 200,
|
||||
"median_ms": 4.47,
|
||||
"p95_ms": 6.37,
|
||||
"max_ms": 14.65
|
||||
},
|
||||
"elapsed_ms": 2086.28,
|
||||
"event_loop_lag": {
|
||||
"count": 174,
|
||||
"median_ms": 1.48,
|
||||
"p95_ms": 4.69,
|
||||
"max_ms": 14.19
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario": "permission_wait",
|
||||
"runs": 20,
|
||||
"approved_completed": 10,
|
||||
"cancelled_waiting_permission": 10,
|
||||
"subscribers_released": true,
|
||||
"elapsed_ms": 307.52,
|
||||
"event_loop_lag": {
|
||||
"count": 20,
|
||||
"median_ms": 5.28,
|
||||
"p95_ms": 12.2,
|
||||
"max_ms": 15.35
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario": "failure_and_timeout_isolation",
|
||||
"runs": 20,
|
||||
"success": 5,
|
||||
"provider_errors": 5,
|
||||
"model_timeouts": 5,
|
||||
"tool_timeouts": 5,
|
||||
"remaining_tool_executors": 0,
|
||||
"elapsed_ms": 1236.24,
|
||||
"event_loop_lag": {
|
||||
"count": 80,
|
||||
"median_ms": 5.44,
|
||||
"p95_ms": 6.58,
|
||||
"max_ms": 15.75
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario": "task_api_crud",
|
||||
"tasks": 100,
|
||||
"client_concurrency": 20,
|
||||
"latencies": {
|
||||
"create": {
|
||||
"count": 100,
|
||||
"median_ms": 107.62,
|
||||
"p95_ms": 146.4,
|
||||
"max_ms": 177.83
|
||||
},
|
||||
"update": {
|
||||
"count": 100,
|
||||
"median_ms": 119.9,
|
||||
"p95_ms": 148.86,
|
||||
"max_ms": 176.92
|
||||
},
|
||||
"list": {
|
||||
"count": 1,
|
||||
"median_ms": 3.96,
|
||||
"p95_ms": 3.96,
|
||||
"max_ms": 3.96
|
||||
},
|
||||
"delete": {
|
||||
"count": 100,
|
||||
"median_ms": 126.81,
|
||||
"p95_ms": 180.34,
|
||||
"max_ms": 181.32
|
||||
}
|
||||
},
|
||||
"default_page_count": 50,
|
||||
"default_total": 100,
|
||||
"pagination_complete": true,
|
||||
"final_total": 0,
|
||||
"elapsed_ms": 1908.85,
|
||||
"event_loop_lag": {
|
||||
"count": 156,
|
||||
"median_ms": 1.39,
|
||||
"p95_ms": 5.2,
|
||||
"max_ms": 65.37
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario": "task_api_crud",
|
||||
"tasks": 1000,
|
||||
"client_concurrency": 20,
|
||||
"latencies": {
|
||||
"create": {
|
||||
"count": 1000,
|
||||
"median_ms": 109.25,
|
||||
"p95_ms": 122.91,
|
||||
"max_ms": 130.79
|
||||
},
|
||||
"update": {
|
||||
"count": 1000,
|
||||
"median_ms": 127.29,
|
||||
"p95_ms": 150.37,
|
||||
"max_ms": 180.05
|
||||
},
|
||||
"list": {
|
||||
"count": 10,
|
||||
"median_ms": 4.6,
|
||||
"p95_ms": 8.08,
|
||||
"max_ms": 8.08
|
||||
},
|
||||
"delete": {
|
||||
"count": 1000,
|
||||
"median_ms": 124.33,
|
||||
"p95_ms": 146.88,
|
||||
"max_ms": 167.34
|
||||
}
|
||||
},
|
||||
"default_page_count": 50,
|
||||
"default_total": 1000,
|
||||
"pagination_complete": true,
|
||||
"final_total": 0,
|
||||
"elapsed_ms": 18264.74,
|
||||
"event_loop_lag": {
|
||||
"count": 1555,
|
||||
"median_ms": 1.31,
|
||||
"p95_ms": 4.68,
|
||||
"max_ms": 39.35
|
||||
}
|
||||
}
|
||||
],
|
||||
"database_bytes": 2928640,
|
||||
"complete": true
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
{
|
||||
"run_id": "benchmark_e3047791a92d",
|
||||
"kind": "agent",
|
||||
"dataset_id": "agent-core-v1",
|
||||
"dataset_hash": "sha256:82fd1b84cc2dc2feec0fc67357d64b145dcdf6c44ec2660f182d550e85b9d27f",
|
||||
"status": "completed",
|
||||
"config_snapshot": {
|
||||
"dataset_id": "agent-core-v1",
|
||||
"provider_id": "provider_65fd326edba646e3bc735c25fbf5ffe8",
|
||||
"model": "deepseek-v4-flash",
|
||||
"max_steps": 6,
|
||||
"timeout_seconds": 90,
|
||||
"token_budget": 6000,
|
||||
"repeat": 1,
|
||||
"allow_network": false,
|
||||
"offline": false,
|
||||
"dataset_hash": "sha256:82fd1b84cc2dc2feec0fc67357d64b145dcdf6c44ec2660f182d550e85b9d27f",
|
||||
"dataset_version": "1.0.0",
|
||||
"execution": "real_agent_runtime",
|
||||
"provider_type": "openai_compatible",
|
||||
"scoring_version": "1.0",
|
||||
"permission_policy": "runtime_user_decision",
|
||||
"active_agent_run_id": "run_1230c37a1c2c4559b69cbc4e01b4721b"
|
||||
},
|
||||
"metrics": {
|
||||
"total_cases": 4,
|
||||
"evaluated_cases": 4,
|
||||
"task_success_rate": 1,
|
||||
"tool_selection_accuracy": 1,
|
||||
"tool_argument_accuracy": 1,
|
||||
"invalid_tool_call_rate": 0,
|
||||
"average_steps": 1.75,
|
||||
"average_latency_ms": 4646.515175001696,
|
||||
"token_usage": 5939,
|
||||
"tool_calls": 3,
|
||||
"expected_calls": 3
|
||||
},
|
||||
"cases": [
|
||||
{
|
||||
"case_id": "arithmetic",
|
||||
"repeat": 0,
|
||||
"agent_run_id": "run_f4baf45c66c34e3f94b60b90f0846a95",
|
||||
"success": true,
|
||||
"tool_calls": 1,
|
||||
"expected_calls": 1,
|
||||
"selected_calls": 1,
|
||||
"accurate_calls": 1,
|
||||
"invalid_calls": 0,
|
||||
"steps": 2,
|
||||
"latency_ms": 4902.329800010193,
|
||||
"token_usage": 1585,
|
||||
"checks": {
|
||||
"completed": true,
|
||||
"tools_selected": true,
|
||||
"tool_arguments": true,
|
||||
"no_extra_calls": true,
|
||||
"tool_results": true,
|
||||
"output": true,
|
||||
"citation": true,
|
||||
"tasks_created": true
|
||||
},
|
||||
"error_code": null
|
||||
},
|
||||
{
|
||||
"case_id": "echo",
|
||||
"repeat": 0,
|
||||
"agent_run_id": "run_5cf8341f73fa431ab7978f9ed4fe84c7",
|
||||
"success": true,
|
||||
"tool_calls": 1,
|
||||
"expected_calls": 1,
|
||||
"selected_calls": 1,
|
||||
"accurate_calls": 1,
|
||||
"invalid_calls": 0,
|
||||
"steps": 2,
|
||||
"latency_ms": 5341.693700000178,
|
||||
"token_usage": 1821,
|
||||
"checks": {
|
||||
"completed": true,
|
||||
"tools_selected": true,
|
||||
"tool_arguments": true,
|
||||
"no_extra_calls": true,
|
||||
"tool_results": true,
|
||||
"output": true,
|
||||
"citation": true,
|
||||
"tasks_created": true
|
||||
},
|
||||
"error_code": null
|
||||
},
|
||||
{
|
||||
"case_id": "no-tool",
|
||||
"repeat": 0,
|
||||
"agent_run_id": "run_226a20145fbc4737ae2d3e812ff8f25c",
|
||||
"success": true,
|
||||
"tool_calls": 0,
|
||||
"expected_calls": 0,
|
||||
"selected_calls": 0,
|
||||
"accurate_calls": 0,
|
||||
"invalid_calls": 0,
|
||||
"steps": 1,
|
||||
"latency_ms": 1806.5612000063993,
|
||||
"token_usage": 541,
|
||||
"checks": {
|
||||
"completed": true,
|
||||
"tools_selected": true,
|
||||
"tool_arguments": true,
|
||||
"no_extra_calls": true,
|
||||
"tool_results": true,
|
||||
"output": true,
|
||||
"citation": true,
|
||||
"tasks_created": true
|
||||
},
|
||||
"error_code": null
|
||||
},
|
||||
{
|
||||
"case_id": "markdown-catalog",
|
||||
"repeat": 0,
|
||||
"agent_run_id": "run_1230c37a1c2c4559b69cbc4e01b4721b",
|
||||
"success": true,
|
||||
"tool_calls": 1,
|
||||
"expected_calls": 1,
|
||||
"selected_calls": 1,
|
||||
"accurate_calls": 1,
|
||||
"invalid_calls": 0,
|
||||
"steps": 2,
|
||||
"latency_ms": 6535.4759999900125,
|
||||
"token_usage": 1992,
|
||||
"checks": {
|
||||
"completed": true,
|
||||
"tools_selected": true,
|
||||
"tool_arguments": true,
|
||||
"no_extra_calls": true,
|
||||
"tool_results": true,
|
||||
"output": true,
|
||||
"citation": true,
|
||||
"tasks_created": true
|
||||
},
|
||||
"error_code": null
|
||||
}
|
||||
],
|
||||
"error": null,
|
||||
"error_code": null
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"html": {
|
||||
"bytes": 42542,
|
||||
"sha256": "4c5e3f5eeb0167110cbbc750c12ef0a52420975288853f9653575cb49f3b4098",
|
||||
"snapshot_present": true,
|
||||
"static_images": 2
|
||||
},
|
||||
"pdf": {
|
||||
"bytes": 53952,
|
||||
"sha256": "a2aa2095495cfa612f4b3f2e2c35104f305289468bb5a8a5de8748a899d8f598"
|
||||
},
|
||||
"docx": {
|
||||
"bytes": 59638,
|
||||
"sha256": "ade1b2d993c8e157e245af5476a7808c891d70ae91b82ac300e12d3fa727288e",
|
||||
"snapshot_present": true,
|
||||
"images": [
|
||||
{
|
||||
"name": "word/media/image1.png",
|
||||
"width": 1280,
|
||||
"height": 1008
|
||||
},
|
||||
{
|
||||
"name": "word/media/image2.png",
|
||||
"width": 1000,
|
||||
"height": 280
|
||||
},
|
||||
{
|
||||
"name": "word/media/image3.png",
|
||||
"width": 135,
|
||||
"height": 32
|
||||
}
|
||||
]
|
||||
},
|
||||
"browser_interactions": [
|
||||
"six_themes",
|
||||
"wide_narrow_viewer",
|
||||
"source_toggle",
|
||||
"zoom_reset",
|
||||
"edit_refresh",
|
||||
"theme_switch",
|
||||
"invalid_expression",
|
||||
"html_pdf_docx_download",
|
||||
"print_warning",
|
||||
"export_cancelled"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 91%]
|
||||
..................................................................... [100%]
|
||||
============================== warnings summary ===============================
|
||||
.venv\Lib\site-packages\fastapi\testclient.py:1
|
||||
C:\Users\KiriAky\.codex\worktrees\7950\NotesAgent\backend\.venv\Lib\site-packages\fastapi\testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
861 passed, 1 warning in 48.32s
|
||||
@@ -0,0 +1,222 @@
|
||||
{
|
||||
"passed": 36,
|
||||
"total": 36,
|
||||
"results": [
|
||||
{
|
||||
"theme": "light",
|
||||
"type": "flowchart",
|
||||
"passed": true,
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"theme": "light",
|
||||
"type": "sequence",
|
||||
"passed": true,
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"theme": "light",
|
||||
"type": "class",
|
||||
"passed": true,
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"theme": "light",
|
||||
"type": "state",
|
||||
"passed": true,
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"theme": "light",
|
||||
"type": "er",
|
||||
"passed": true,
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"theme": "light",
|
||||
"type": "gantt",
|
||||
"passed": true,
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"theme": "dark",
|
||||
"type": "flowchart",
|
||||
"passed": true,
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"theme": "dark",
|
||||
"type": "sequence",
|
||||
"passed": true,
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"theme": "dark",
|
||||
"type": "class",
|
||||
"passed": true,
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"theme": "dark",
|
||||
"type": "state",
|
||||
"passed": true,
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"theme": "dark",
|
||||
"type": "er",
|
||||
"passed": true,
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"theme": "dark",
|
||||
"type": "gantt",
|
||||
"passed": true,
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"theme": "sepia",
|
||||
"type": "flowchart",
|
||||
"passed": true,
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"theme": "sepia",
|
||||
"type": "sequence",
|
||||
"passed": true,
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"theme": "sepia",
|
||||
"type": "class",
|
||||
"passed": true,
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"theme": "sepia",
|
||||
"type": "state",
|
||||
"passed": true,
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"theme": "sepia",
|
||||
"type": "er",
|
||||
"passed": true,
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"theme": "sepia",
|
||||
"type": "gantt",
|
||||
"passed": true,
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"theme": "paper-moments",
|
||||
"type": "flowchart",
|
||||
"passed": true,
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"theme": "paper-moments",
|
||||
"type": "sequence",
|
||||
"passed": true,
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"theme": "paper-moments",
|
||||
"type": "class",
|
||||
"passed": true,
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"theme": "paper-moments",
|
||||
"type": "state",
|
||||
"passed": true,
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"theme": "paper-moments",
|
||||
"type": "er",
|
||||
"passed": true,
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"theme": "paper-moments",
|
||||
"type": "gantt",
|
||||
"passed": true,
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"theme": "ocean-blue",
|
||||
"type": "flowchart",
|
||||
"passed": true,
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"theme": "ocean-blue",
|
||||
"type": "sequence",
|
||||
"passed": true,
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"theme": "ocean-blue",
|
||||
"type": "class",
|
||||
"passed": true,
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"theme": "ocean-blue",
|
||||
"type": "state",
|
||||
"passed": true,
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"theme": "ocean-blue",
|
||||
"type": "er",
|
||||
"passed": true,
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"theme": "ocean-blue",
|
||||
"type": "gantt",
|
||||
"passed": true,
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"theme": "midnight-purple",
|
||||
"type": "flowchart",
|
||||
"passed": true,
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"theme": "midnight-purple",
|
||||
"type": "sequence",
|
||||
"passed": true,
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"theme": "midnight-purple",
|
||||
"type": "class",
|
||||
"passed": true,
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"theme": "midnight-purple",
|
||||
"type": "state",
|
||||
"passed": true,
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"theme": "midnight-purple",
|
||||
"type": "er",
|
||||
"passed": true,
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"theme": "midnight-purple",
|
||||
"type": "gantt",
|
||||
"passed": true,
|
||||
"warnings": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"theme": "light",
|
||||
"errors": [],
|
||||
"plotCount": 4
|
||||
},
|
||||
{
|
||||
"theme": "dark",
|
||||
"errors": [],
|
||||
"plotCount": 4
|
||||
},
|
||||
{
|
||||
"theme": "sepia",
|
||||
"errors": [],
|
||||
"plotCount": 4
|
||||
},
|
||||
{
|
||||
"theme": "paper-moments",
|
||||
"errors": [],
|
||||
"plotCount": 4
|
||||
},
|
||||
{
|
||||
"theme": "ocean-blue",
|
||||
"errors": [],
|
||||
"plotCount": 4
|
||||
},
|
||||
{
|
||||
"theme": "midnight-purple",
|
||||
"errors": [],
|
||||
"plotCount": 4
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,560 @@
|
||||
|
||||
> notes-agent-frontend@0.2.0 build C:\Users\KiriAky\.codex\worktrees\7950\NotesAgent\frontend
|
||||
> vue-tsc -b && vite build
|
||||
|
||||
vite v6.4.3 building for production...
|
||||
transforming...
|
||||
✓ 3262 modules transformed.
|
||||
rendering chunks...
|
||||
computing gzip size...
|
||||
dist/index.html 0.55 kB │ gzip: 0.33 kB
|
||||
dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff 4.42 kB
|
||||
dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 4.93 kB
|
||||
dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 5.21 kB
|
||||
dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 5.47 kB
|
||||
dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff 5.98 kB
|
||||
dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff 6.19 kB
|
||||
dist/assets/KaTeX_Size1-Regular-C195tn64.woff 6.50 kB
|
||||
dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 6.91 kB
|
||||
dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 6.91 kB
|
||||
dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf 7.59 kB
|
||||
dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff 7.66 kB
|
||||
dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff 7.72 kB
|
||||
dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 9.64 kB
|
||||
dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 10.34 kB
|
||||
dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf 10.36 kB
|
||||
dist/assets/KaTeX_Script-Regular-D5yQViql.woff 10.59 kB
|
||||
dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 11.32 kB
|
||||
dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 11.35 kB
|
||||
dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf 11.51 kB
|
||||
dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 12.03 kB
|
||||
dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 12.22 kB
|
||||
dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf 12.23 kB
|
||||
dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff 12.32 kB
|
||||
dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf 12.34 kB
|
||||
dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf 12.37 kB
|
||||
dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff 13.21 kB
|
||||
dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff 13.30 kB
|
||||
dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 13.57 kB
|
||||
dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff 14.11 kB
|
||||
dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff 14.41 kB
|
||||
dist/assets/paper-moments-DOmjCJQa.theme 15.58 kB
|
||||
dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff 16.03 kB
|
||||
dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 16.40 kB
|
||||
dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 16.44 kB
|
||||
dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf 16.65 kB
|
||||
dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 16.78 kB
|
||||
dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 16.99 kB
|
||||
dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff 18.67 kB
|
||||
dist/assets/KaTeX_Math-Italic-DA0__PXp.woff 18.75 kB
|
||||
dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff 19.41 kB
|
||||
dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf 19.44 kB
|
||||
dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf 19.57 kB
|
||||
dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf 19.58 kB
|
||||
dist/assets/KaTeX_Main-Italic-BMLOBm91.woff 19.68 kB
|
||||
dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf 22.36 kB
|
||||
dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf 24.50 kB
|
||||
dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 25.32 kB
|
||||
dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 26.27 kB
|
||||
dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf 27.56 kB
|
||||
dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 28.08 kB
|
||||
dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff 29.91 kB
|
||||
dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff 30.77 kB
|
||||
dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf 31.20 kB
|
||||
dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf 31.31 kB
|
||||
dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf 32.97 kB
|
||||
dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff 33.52 kB
|
||||
dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf 33.58 kB
|
||||
dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf 51.34 kB
|
||||
dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf 53.58 kB
|
||||
dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf 63.63 kB
|
||||
dist/.vite/manifest.json 258.05 kB │ gzip: 20.84 kB
|
||||
dist/assets/HeadingStyleSettings-DHfF7b37.css 0.57 kB │ gzip: 0.29 kB
|
||||
dist/assets/SkillsView-BwLulPo9.css 0.98 kB │ gzip: 0.39 kB
|
||||
dist/assets/WorkspaceChat-DhGri1NS.css 1.09 kB │ gzip: 0.48 kB
|
||||
dist/assets/ExtensionInstallDialog-DRNL9bJf.css 1.14 kB │ gzip: 0.45 kB
|
||||
dist/assets/LogsView-BrIX2apd.css 1.35 kB │ gzip: 0.53 kB
|
||||
dist/assets/BenchmarkView-DLutUBdD.css 1.39 kB │ gzip: 0.55 kB
|
||||
dist/assets/TasksView-DUgcfVCX.css 1.47 kB │ gzip: 0.58 kB
|
||||
dist/assets/SearchView-DW-1gSdx.css 1.47 kB │ gzip: 0.56 kB
|
||||
dist/assets/FilePicker-BO9C6lOI.css 1.63 kB │ gzip: 0.60 kB
|
||||
dist/assets/MediaView-D48AE7U_.css 1.98 kB │ gzip: 0.69 kB
|
||||
dist/assets/ChatPersonaDialog-DHMXahgY.css 2.03 kB │ gzip: 0.72 kB
|
||||
dist/assets/MarkdownContent-CLXIe9X7.css 3.56 kB │ gzip: 0.94 kB
|
||||
dist/assets/WorkspaceView-yzL09utK.css 4.35 kB │ gzip: 1.23 kB
|
||||
dist/assets/McpServersView-BFLRUwRT.css 5.10 kB │ gzip: 1.25 kB
|
||||
dist/assets/ChatView-BQEJrX-5.css 5.20 kB │ gzip: 1.41 kB
|
||||
dist/assets/markdown-D2L0VTei.css 5.43 kB │ gzip: 1.51 kB
|
||||
dist/assets/VaultEntry-DjSz9QsE.css 5.64 kB │ gzip: 1.37 kB
|
||||
dist/assets/ThemesView-i5ccHc0M.css 7.00 kB │ gzip: 1.72 kB
|
||||
dist/assets/PluginsView-CwBTnoBl.css 11.21 kB │ gzip: 2.11 kB
|
||||
dist/assets/AgentView-DSWsxP_V.css 12.59 kB │ gzip: 2.31 kB
|
||||
dist/assets/SettingsView-CNkfTZZK.css 13.85 kB │ gzip: 3.02 kB
|
||||
dist/assets/math-katex-0-18-4-CEK31ho9.css 30.19 kB │ gzip: 8.06 kB
|
||||
dist/assets/index-DUxb9c05.css 50.43 kB │ gzip: 9.59 kB
|
||||
dist/assets/editor-milkdown-DebjlF2b.css 83.87 kB │ gzip: 16.14 kB
|
||||
dist/assets/VisualMarkdownEditor-IxqBomSx.css 390.03 kB │ gzip: 105.48 kB
|
||||
dist/assets/channel-CrNH6nrN.js 0.11 kB │ gzip: 0.13 kB
|
||||
dist/assets/init-Gi6I4Gst.js 0.15 kB │ gzip: 0.13 kB
|
||||
dist/assets/chunk-2Q5K7J3B-BZASLIXN.js 0.19 kB │ gzip: 0.16 kB
|
||||
dist/assets/chunk-XXDRQBXY-ChINTzlJ.js 0.23 kB │ gzip: 0.21 kB
|
||||
dist/assets/chunk-JWPE2WC7-BNAOuj8n.js 0.30 kB │ gzip: 0.21 kB
|
||||
dist/assets/diff-DbItnlRl.js 0.31 kB │ gzip: 0.24 kB
|
||||
dist/assets/chunk-5VM5RSS4-TQefSFWe.js 0.37 kB │ gzip: 0.27 kB
|
||||
dist/assets/index-DIIxsTGV.js 0.48 kB │ gzip: 0.31 kB
|
||||
dist/assets/stateDiagram-v2-MP3YSRHH-Nc43I8Ns.js 0.51 kB │ gzip: 0.35 kB
|
||||
dist/assets/chunk-POPQ4Y6H-BG03mjXr.js 0.53 kB │ gzip: 0.38 kB
|
||||
dist/assets/codeowners-Bp6g37R7.js 0.55 kB │ gzip: 0.32 kB
|
||||
dist/assets/classDiagram-ZZMXUADV-CI5Lv9Pw.js 0.55 kB │ gzip: 0.36 kB
|
||||
dist/assets/classDiagram-v2-VYDZK3BY-CI5Lv9Pw.js 0.55 kB │ gzip: 0.36 kB
|
||||
dist/assets/brainfuck-C4LP7Hcl.js 0.61 kB │ gzip: 0.33 kB
|
||||
dist/assets/swimlanesDiagram-VR7AAH4N-C4Ag8puk.js 0.63 kB │ gzip: 0.41 kB
|
||||
dist/assets/infoDiagram-27XIBGKW-Bb4UVFAL.js 0.66 kB │ gzip: 0.45 kB
|
||||
dist/assets/properties-C78fOPTZ.js 0.67 kB │ gzip: 0.35 kB
|
||||
dist/assets/shellsession-BADoaaVG.js 0.71 kB │ gzip: 0.43 kB
|
||||
dist/assets/tsv-B_m7g4N7.js 0.74 kB │ gzip: 0.34 kB
|
||||
dist/assets/cmake-BQqOBYOt.js 0.78 kB │ gzip: 0.46 kB
|
||||
dist/assets/asciiarmor-Df11BRmG.js 0.79 kB │ gzip: 0.41 kB
|
||||
dist/assets/http-DBlCnlav.js 0.85 kB │ gzip: 0.41 kB
|
||||
dist/assets/protobuf-ChK-085T.js 0.86 kB │ gzip: 0.52 kB
|
||||
dist/assets/solr-DehyRSwq.js 0.87 kB │ gzip: 0.46 kB
|
||||
dist/assets/html-derivative-DlHx6ybY.js 0.90 kB │ gzip: 0.50 kB
|
||||
dist/assets/troff-wAsdV37c.js 0.96 kB │ gzip: 0.44 kB
|
||||
dist/assets/git-rebase-r7XF79zn.js 0.98 kB │ gzip: 0.44 kB
|
||||
dist/assets/qmldir-C8lEn-DE.js 1.00 kB │ gzip: 0.45 kB
|
||||
dist/assets/FilePicker-CjNjYr-N.js 1.04 kB │ gzip: 0.61 kB
|
||||
dist/assets/sizeCapture-INFHLROL-DWCqhl6c.js 1.08 kB │ gzip: 0.61 kB
|
||||
dist/assets/spreadsheet-BCZA_wO0.js 1.14 kB │ gzip: 0.54 kB
|
||||
dist/assets/toml-Bm5Em-hy.js 1.14 kB │ gzip: 0.54 kB
|
||||
dist/assets/csv-fuZLfV_i.js 1.14 kB │ gzip: 0.37 kB
|
||||
dist/assets/ordinal-Cboi1Yqb.js 1.19 kB │ gzip: 0.57 kB
|
||||
dist/assets/git-commit-F4YmCXRG.js 1.23 kB │ gzip: 0.53 kB
|
||||
dist/assets/xsl-CtQFsRM5.js 1.39 kB │ gzip: 0.52 kB
|
||||
dist/assets/mbox-CNhZ1qSd.js 1.40 kB │ gzip: 0.66 kB
|
||||
dist/assets/dotenv-Da5cRb03.js 1.42 kB │ gzip: 0.53 kB
|
||||
dist/assets/sparql-rVzFXLq3.js 1.48 kB │ gzip: 0.82 kB
|
||||
dist/assets/index-Vcq4gwWv.js 1.49 kB │ gzip: 0.82 kB
|
||||
dist/assets/ini-BEwlwnbL.js 1.53 kB │ gzip: 0.50 kB
|
||||
dist/assets/MarkdownContent.vue_vue_type_style_index_0_lang-B376YVIn.js 1.56 kB │ gzip: 0.83 kB
|
||||
dist/assets/sieve-C3Gn_uJK.js 1.62 kB │ gzip: 0.77 kB
|
||||
dist/assets/rpm-CTu-6PCP.js 1.62 kB │ gzip: 0.83 kB
|
||||
dist/assets/markdownPreferences-DKoh9bVv.js 1.66 kB │ gzip: 0.86 kB
|
||||
dist/assets/fortran-fixed-form-CkoXwp7k.js 1.67 kB │ gzip: 0.69 kB
|
||||
dist/assets/factor-kuTfRLto.js 1.67 kB │ gzip: 0.61 kB
|
||||
dist/assets/railroadDiagram-O6MQD6OU-DwB0kF9i.js 1.69 kB │ gzip: 0.80 kB
|
||||
dist/assets/docker-BcOcwvcX.js 1.74 kB │ gzip: 0.60 kB
|
||||
dist/assets/z80-Hz9HOZM7.js 1.75 kB │ gzip: 0.81 kB
|
||||
dist/assets/eiffel-CnydiIhH.js 1.81 kB │ gzip: 0.92 kB
|
||||
dist/assets/desktop-BmXAJ9_W.js 1.83 kB │ gzip: 0.76 kB
|
||||
dist/assets/pluginCommandForm-DV9KKjoP.js 1.85 kB │ gzip: 0.95 kB
|
||||
dist/assets/chunk-F27PBJKO-Ctsjd131.js 1.88 kB │ gzip: 0.83 kB
|
||||
dist/assets/abnfDiagram-VCTEODGH-s3lnCumS.js 1.89 kB │ gzip: 0.94 kB
|
||||
dist/assets/elm-vLlmbW-K.js 1.89 kB │ gzip: 0.82 kB
|
||||
dist/assets/hxml-2-FPmUDs.js 1.89 kB │ gzip: 0.90 kB
|
||||
dist/assets/mathematica-DTrFuWx2.js 1.92 kB │ gzip: 0.82 kB
|
||||
dist/assets/dockerfile-BKs6k2Af.js 1.95 kB │ gzip: 0.67 kB
|
||||
dist/assets/turtle-B1tBg_DP.js 1.98 kB │ gzip: 0.90 kB
|
||||
dist/assets/ebnf-CDyGwa7X.js 1.99 kB │ gzip: 0.81 kB
|
||||
dist/assets/smalltalk-CnHTOXQT.js 2.01 kB │ gzip: 0.85 kB
|
||||
dist/assets/pegDiagram-XKGWAZYB-CUdM1qfX.js 2.04 kB │ gzip: 0.97 kB
|
||||
dist/assets/dtd-DF_7sFjM.js 2.06 kB │ gzip: 0.87 kB
|
||||
dist/assets/mumps-BT43cFF4.js 2.07 kB │ gzip: 0.98 kB
|
||||
dist/assets/fcl-Kvtd6kyn.js 2.08 kB │ gzip: 0.95 kB
|
||||
dist/assets/ebnfDiagram-PWID7BFC-B0NYa1C1.js 2.09 kB │ gzip: 0.92 kB
|
||||
dist/assets/ntriples-BfvgReVJ.js 2.10 kB │ gzip: 0.75 kB
|
||||
dist/assets/yacas-BJ4BC0dw.js 2.15 kB │ gzip: 1.09 kB
|
||||
dist/assets/wenyan-BV7otONQ.js 2.16 kB │ gzip: 1.09 kB
|
||||
dist/assets/jssm-C2t-YnRu.js 2.24 kB │ gzip: 0.62 kB
|
||||
dist/assets/simple-mode-GW_nhZxv.js 2.28 kB │ gzip: 1.09 kB
|
||||
dist/assets/apl-B4CMkyY2.js 2.30 kB │ gzip: 1.23 kB
|
||||
dist/assets/pascal--L3eBynH.js 2.30 kB │ gzip: 1.19 kB
|
||||
dist/assets/octave-Ck1zUtKM.js 2.31 kB │ gzip: 1.09 kB
|
||||
dist/assets/commonlisp-DBKNyK5s.js 2.32 kB │ gzip: 1.10 kB
|
||||
dist/assets/reg-C-SQnVFl.js 2.35 kB │ gzip: 0.70 kB
|
||||
dist/assets/tcl-DVfN8rqt.js 2.36 kB │ gzip: 1.23 kB
|
||||
dist/assets/edge-FbVlp4U3.js 2.36 kB │ gzip: 0.70 kB
|
||||
dist/assets/index-CjhMCctz.js 2.46 kB │ gzip: 1.52 kB
|
||||
dist/assets/webidl-ZXfAyPTL.js 2.52 kB │ gzip: 1.26 kB
|
||||
dist/assets/pig-CevX1Tat.js 2.53 kB │ gzip: 1.38 kB
|
||||
dist/assets/puppet-DMA9R1ak.js 2.54 kB │ gzip: 1.20 kB
|
||||
dist/assets/forth-Ffai-XNe.js 2.54 kB │ gzip: 1.33 kB
|
||||
dist/assets/diff-D97Zzqfu.js 2.57 kB │ gzip: 0.70 kB
|
||||
dist/assets/shell-CjFT_Tl9.js 2.57 kB │ gzip: 1.21 kB
|
||||
dist/assets/gleam-BspZqrRM.js 2.58 kB │ gzip: 0.82 kB
|
||||
dist/assets/erb-DXfck5VN.js 2.61 kB │ gzip: 0.84 kB
|
||||
dist/assets/hy-DFXneXwc.js 2.65 kB │ gzip: 1.18 kB
|
||||
dist/assets/index-Bu1SCIgx.js 2.66 kB │ gzip: 1.53 kB
|
||||
dist/assets/velocity-D8B20fx6.js 2.67 kB │ gzip: 1.11 kB
|
||||
dist/assets/tiddlywiki-DO-Gjzrf.js 2.78 kB │ gzip: 1.07 kB
|
||||
dist/assets/modelica-Dc1JOy9r.js 2.79 kB │ gzip: 1.29 kB
|
||||
dist/assets/json-Cp-IABpG.js 2.82 kB │ gzip: 0.78 kB
|
||||
dist/assets/openscad-C4EeE6gA.js 2.82 kB │ gzip: 1.01 kB
|
||||
dist/assets/log-2UxHyX5q.js 2.85 kB │ gzip: 0.90 kB
|
||||
dist/assets/oz-BzwKVEFT.js 2.90 kB │ gzip: 1.28 kB
|
||||
dist/assets/cairo-KRGpt6FW.js 2.94 kB │ gzip: 0.81 kB
|
||||
dist/assets/r-B6wPVr8A.js 2.94 kB │ gzip: 1.36 kB
|
||||
dist/assets/berry-uYugtg8r.js 3.01 kB │ gzip: 0.81 kB
|
||||
dist/assets/jsonl-DcaNXYhu.js 3.01 kB │ gzip: 0.79 kB
|
||||
dist/assets/jsonc-Des-eS-w.js 3.11 kB │ gzip: 0.80 kB
|
||||
dist/assets/HeadingStyleSettings-Bhiwz6_B.js 3.12 kB │ gzip: 1.48 kB
|
||||
dist/assets/stex-C3f8Ysf7.js 3.12 kB │ gzip: 1.20 kB
|
||||
dist/assets/logo-BtOb2qkB.js 3.13 kB │ gzip: 1.47 kB
|
||||
dist/assets/po-BTJTHyun.js 3.24 kB │ gzip: 0.91 kB
|
||||
dist/assets/tiki-DGYXhP31.js 3.25 kB │ gzip: 1.26 kB
|
||||
dist/assets/json5-C9tS-k6U.js 3.25 kB │ gzip: 0.83 kB
|
||||
dist/assets/mipsasm-CKIfxQSi.js 3.26 kB │ gzip: 1.18 kB
|
||||
dist/assets/tasl-QIJgUcNo.js 3.29 kB │ gzip: 0.85 kB
|
||||
dist/assets/rbs-CpoqiR4B.js 3.31 kB │ gzip: 0.73 kB
|
||||
dist/assets/vhdl-lSbBsy5d.js 3.35 kB │ gzip: 1.51 kB
|
||||
dist/assets/genie-D0YGMca9.js 3.36 kB │ gzip: 1.21 kB
|
||||
dist/assets/rel-C3B-1QV4.js 3.37 kB │ gzip: 1.11 kB
|
||||
dist/assets/vala-CsfeWuGM.js 3.37 kB │ gzip: 1.19 kB
|
||||
dist/assets/lua-VAEuO923.js 3.41 kB │ gzip: 1.43 kB
|
||||
dist/assets/mermaidService-EVMjUBQI.js 3.42 kB │ gzip: 1.79 kB
|
||||
dist/assets/arc-DnWybcCe.js 3.43 kB │ gzip: 1.47 kB
|
||||
dist/assets/splunk-BtCnVYZw.js 3.44 kB │ gzip: 1.52 kB
|
||||
dist/assets/index-833-DuH6.js 3.44 kB │ gzip: 1.74 kB
|
||||
dist/assets/mscgen-BA5vi2Kp.js 3.51 kB │ gzip: 0.98 kB
|
||||
dist/assets/sparql-DkYu6x3z.js 3.55 kB │ gzip: 1.63 kB
|
||||
dist/assets/cypher-C_CwsFkJ.js 3.56 kB │ gzip: 1.52 kB
|
||||
dist/assets/fluent-C4IJs8-o.js 3.61 kB │ gzip: 0.90 kB
|
||||
dist/assets/ssh-config-_ykCGR6B.js 3.62 kB │ gzip: 1.60 kB
|
||||
dist/assets/jsonnet-DFQXde-d.js 3.62 kB │ gzip: 1.05 kB
|
||||
dist/assets/kdl-DV7GczEv.js 3.63 kB │ gzip: 1.04 kB
|
||||
dist/assets/glsl-DplSGwfg.js 3.63 kB │ gzip: 1.41 kB
|
||||
dist/assets/hurl-irOxFIW8.js 3.65 kB │ gzip: 1.16 kB
|
||||
dist/assets/narrat-DRg8JJMk.js 3.67 kB │ gzip: 1.11 kB
|
||||
dist/assets/_baseMerge-BNBydYQA.js 3.68 kB │ gzip: 1.73 kB
|
||||
dist/assets/turtle-BsS91CYL.js 3.70 kB │ gzip: 0.98 kB
|
||||
dist/assets/d-pRatUO7H.js 3.72 kB │ gzip: 1.68 kB
|
||||
dist/assets/coffeescript-S37ZYGWr.js 3.87 kB │ gzip: 1.69 kB
|
||||
dist/assets/vb-CmGdzxic.js 3.90 kB │ gzip: 1.79 kB
|
||||
dist/assets/zenscript-DVFEvuxE.js 3.91 kB │ gzip: 1.28 kB
|
||||
dist/assets/ron-D8l8udqQ.js 3.91 kB │ gzip: 0.98 kB
|
||||
dist/assets/swift-BzpIVaGY.js 3.96 kB │ gzip: 1.79 kB
|
||||
dist/assets/VaultEntry-DmoPppw3.js 3.96 kB │ gzip: 1.95 kB
|
||||
dist/assets/WorkspaceChat-CrwU7la3.js 3.97 kB │ gzip: 1.89 kB
|
||||
dist/assets/asn1-EdZsLKOL.js 3.98 kB │ gzip: 1.96 kB
|
||||
dist/assets/gn-n2N0HUVH.js 4.00 kB │ gzip: 1.49 kB
|
||||
dist/assets/q-pXgVlZs6.js 4.03 kB │ gzip: 1.67 kB
|
||||
dist/assets/ttcn-cfg-B9xdYoR4.js 4.05 kB │ gzip: 1.71 kB
|
||||
dist/assets/dylan-DwRh75JA.js 4.05 kB │ gzip: 1.65 kB
|
||||
dist/assets/livescript-BwQOo05w.js 4.09 kB │ gzip: 1.64 kB
|
||||
dist/assets/groovy-D9Dt4D0W.js 4.14 kB │ gzip: 1.77 kB
|
||||
dist/assets/pascal-D93ZcfNL.js 4.15 kB │ gzip: 1.67 kB
|
||||
dist/assets/haskell-Cw1EW3IL.js 4.17 kB │ gzip: 1.89 kB
|
||||
dist/assets/SearchView-B47FoyI1.js 4.26 kB │ gzip: 2.09 kB
|
||||
dist/assets/diagram-Z3DM3KII-Bb5zNdmf.js 4.33 kB │ gzip: 1.91 kB
|
||||
dist/assets/tcl-dwOrl1Do.js 4.43 kB │ gzip: 1.52 kB
|
||||
dist/assets/asterisk-B-8jnY81.js 4.48 kB │ gzip: 1.78 kB
|
||||
dist/assets/nextflow-C-mBbutL.js 4.51 kB │ gzip: 1.17 kB
|
||||
dist/assets/rosmsg-BJDFO7_C.js 4.52 kB │ gzip: 1.06 kB
|
||||
dist/assets/http-jrhK8wxY.js 4.55 kB │ gzip: 1.12 kB
|
||||
dist/assets/gas-Bneqetm1.js 4.57 kB │ gzip: 1.28 kB
|
||||
dist/assets/fortran-DYz_wnZ1.js 4.65 kB │ gzip: 2.01 kB
|
||||
dist/assets/polar-C0HS_06l.js 4.67 kB │ gzip: 1.12 kB
|
||||
dist/assets/defaultLocale-DX6XiGOO.js 4.69 kB │ gzip: 2.18 kB
|
||||
dist/assets/sdbl-DVxCFoDh.js 4.70 kB │ gzip: 2.01 kB
|
||||
dist/assets/fennel-BYunw83y.js 4.77 kB │ gzip: 1.53 kB
|
||||
dist/assets/mllike-CXdrOF99.js 4.79 kB │ gzip: 1.53 kB
|
||||
dist/assets/bibtex-CHM0blh-.js 4.80 kB │ gzip: 0.83 kB
|
||||
dist/assets/ttcn-CfJYG6tj.js 4.80 kB │ gzip: 2.13 kB
|
||||
dist/assets/SkillsView-BIOhy4r9.js 4.90 kB │ gzip: 2.06 kB
|
||||
dist/assets/index-BlxF_W4d.js 4.95 kB │ gzip: 1.51 kB
|
||||
dist/assets/llvm-BZoOZj88.js 5.04 kB │ gzip: 2.01 kB
|
||||
dist/assets/index-E-vsI35b.js 5.10 kB │ gzip: 2.43 kB
|
||||
dist/assets/index-D2e4uDXw.js 5.11 kB │ gzip: 2.55 kB
|
||||
dist/assets/ecl-Cabwm37j.js 5.13 kB │ gzip: 2.31 kB
|
||||
dist/assets/crystal-SjHAIU92.js 5.13 kB │ gzip: 2.07 kB
|
||||
dist/assets/wgsl-Dx-B1_4e.js 5.14 kB │ gzip: 1.39 kB
|
||||
dist/assets/ruby-B2Rjki9n.js 5.16 kB │ gzip: 2.15 kB
|
||||
dist/assets/gdresource-TyuKm33G.js 5.29 kB │ gzip: 1.34 kB
|
||||
dist/assets/qml-3beO22l8.js 5.34 kB │ gzip: 1.38 kB
|
||||
dist/assets/zig-VOosw3JB.js 5.34 kB │ gzip: 1.55 kB
|
||||
dist/assets/dax-CEL-wOlO.js 5.37 kB │ gzip: 2.23 kB
|
||||
dist/assets/bicep-Bmn6On1c.js 5.38 kB │ gzip: 1.15 kB
|
||||
dist/assets/xml-sdJ4AIDG.js 5.38 kB │ gzip: 1.21 kB
|
||||
dist/assets/julia-DuME0IfC.js 5.39 kB │ gzip: 2.14 kB
|
||||
dist/assets/awk-DMzUqQB5.js 5.46 kB │ gzip: 1.38 kB
|
||||
dist/assets/browser-c3U3mMxj.js 5.53 kB │ gzip: 2.79 kB
|
||||
dist/assets/TasksView-BnSHnCQV.js 5.65 kB │ gzip: 2.49 kB
|
||||
dist/assets/linear-Dsc1LO7N.js 5.66 kB │ gzip: 2.31 kB
|
||||
dist/assets/ExtensionInstallDialog-BSAwvlPk.js 5.68 kB │ gzip: 2.80 kB
|
||||
dist/assets/jinja-f2NsQr07.js 5.69 kB │ gzip: 1.40 kB
|
||||
dist/assets/lean-BZvkOJ9d.js 5.78 kB │ gzip: 1.92 kB
|
||||
dist/assets/vbscript-BuJXcnF6.js 5.82 kB │ gzip: 2.57 kB
|
||||
dist/assets/powerquery-CEu0bR-o.js 5.90 kB │ gzip: 1.52 kB
|
||||
dist/assets/mirc-CjQqDB4T.js 5.92 kB │ gzip: 2.68 kB
|
||||
dist/assets/shaderlab-Dg9Lc6iA.js 5.92 kB │ gzip: 2.08 kB
|
||||
dist/assets/cypher-COkxafJQ.js 5.96 kB │ gzip: 1.73 kB
|
||||
dist/assets/coq-C7JzOVbR.js 6.01 kB │ gzip: 1.95 kB
|
||||
dist/assets/vb-Cu-pLBUe.js 6.08 kB │ gzip: 2.33 kB
|
||||
dist/assets/cobol-CWcv1MsR.js 6.20 kB │ gzip: 2.96 kB
|
||||
dist/assets/diagram-UQ7AKVKN-Cms5HwfU.js 6.31 kB │ gzip: 2.73 kB
|
||||
dist/assets/gdshader-DkwncUOv.js 6.33 kB │ gzip: 1.73 kB
|
||||
dist/assets/ara-BRHolxvo.js 6.36 kB │ gzip: 1.81 kB
|
||||
dist/assets/scheme-C41bIUwD.js 6.37 kB │ gzip: 2.39 kB
|
||||
dist/assets/verilog-nZwndyjY.js 6.41 kB │ gzip: 1.92 kB
|
||||
dist/assets/clojure-P80f7IUj.js 6.41 kB │ gzip: 1.42 kB
|
||||
dist/assets/postcss-CXtECtnM.js 6.42 kB │ gzip: 1.91 kB
|
||||
dist/assets/toml-vGWfd6FD.js 6.43 kB │ gzip: 1.28 kB
|
||||
dist/assets/pieDiagram-E7YTZNPT-YK6ICI5_.js 6.43 kB │ gzip: 2.70 kB
|
||||
dist/assets/python-BuPzkPfP.js 6.48 kB │ gzip: 2.73 kB
|
||||
dist/assets/LogsView-ZSnm_Csu.js 6.52 kB │ gzip: 3.14 kB
|
||||
dist/assets/proto-C7zT0LnQ.js 6.55 kB │ gzip: 1.42 kB
|
||||
dist/assets/chapel-DTp_pixX.js 6.62 kB │ gzip: 1.85 kB
|
||||
dist/assets/xquery-DzFWVndE.js 6.62 kB │ gzip: 2.56 kB
|
||||
dist/assets/pug-DeIclll2.js 6.67 kB │ gzip: 1.94 kB
|
||||
dist/assets/r-Cf5RLm7j.js 6.74 kB │ gzip: 1.84 kB
|
||||
dist/assets/talonscript-CkByrt1z.js 6.76 kB │ gzip: 1.49 kB
|
||||
dist/assets/textile-CnDTJFAw.js 6.80 kB │ gzip: 2.44 kB
|
||||
dist/assets/nsis-LdVXkNf5.js 6.81 kB │ gzip: 2.99 kB
|
||||
dist/assets/BenchmarkView-CoNnmLzX.js 6.89 kB │ gzip: 3.19 kB
|
||||
dist/assets/riscv-BM1_JUlF.js 6.91 kB │ gzip: 1.98 kB
|
||||
dist/assets/soy-8wufbnw4.js 6.98 kB │ gzip: 1.66 kB
|
||||
dist/assets/scheme-C98Dy4si.js 7.17 kB │ gzip: 2.05 kB
|
||||
dist/assets/hlsl-D3lLCCz7.js 7.26 kB │ gzip: 2.19 kB
|
||||
dist/assets/nginx-DdIZxoE0.js 7.34 kB │ gzip: 2.72 kB
|
||||
dist/assets/qss-IeuSbFQv.js 7.47 kB │ gzip: 2.58 kB
|
||||
dist/assets/prisma-Vru482bI.js 7.74 kB │ gzip: 1.48 kB
|
||||
dist/assets/powershell-CFHJl5sT.js 7.77 kB │ gzip: 3.33 kB
|
||||
dist/assets/ChatPersonaDialog-BHpRSHc3.js 7.84 kB │ gzip: 3.61 kB
|
||||
dist/assets/systemd-4A_iFExJ.js 7.87 kB │ gzip: 2.55 kB
|
||||
dist/assets/haxe-H-WmDvRZ.js 7.89 kB │ gzip: 2.95 kB
|
||||
dist/assets/regexp-CDVJQ6XC.js 7.99 kB │ gzip: 1.42 kB
|
||||
dist/assets/erlang-BNw1qcRV.js 8.10 kB │ gzip: 2.89 kB
|
||||
dist/assets/verilog-C6RDOZhf.js 8.24 kB │ gzip: 3.52 kB
|
||||
dist/assets/haml-D5jkg6IW.js 8.26 kB │ gzip: 1.81 kB
|
||||
dist/assets/diagram-S7CK7UJ4-C0vmdMvd.js 8.44 kB │ gzip: 3.77 kB
|
||||
dist/assets/vue-html-AaS7Mt5G.js 8.47 kB │ gzip: 1.68 kB
|
||||
dist/assets/plsql-ChMvpjG-.js 8.51 kB │ gzip: 3.00 kB
|
||||
dist/assets/isArrayLikeObject-Csl51Uc7.js 8.52 kB │ gzip: 3.19 kB
|
||||
dist/assets/dart-bE4Kk8sk.js 8.62 kB │ gzip: 2.00 kB
|
||||
dist/assets/kotlin-BdnUsdx6.js 8.79 kB │ gzip: 2.13 kB
|
||||
dist/assets/make-CHLpvVh8.js 8.96 kB │ gzip: 1.77 kB
|
||||
dist/assets/sas-DEy46yEz.js 9.06 kB │ gzip: 3.81 kB
|
||||
dist/assets/smithy-cds9vsN8.js 9.13 kB │ gzip: 1.53 kB
|
||||
dist/assets/sass-Cj5Yp3dK.js 9.29 kB │ gzip: 2.49 kB
|
||||
dist/assets/sas-B4kiWyti.js 9.33 kB │ gzip: 4.11 kB
|
||||
dist/assets/tex-D96PA37w.js 9.67 kB │ gzip: 3.06 kB
|
||||
dist/assets/jison-wvAkD_A8.js 9.69 kB │ gzip: 1.85 kB
|
||||
dist/assets/perl-CdXCOZ3F.js 9.75 kB │ gzip: 3.46 kB
|
||||
dist/assets/cmake-D1j8_8rp.js 9.86 kB │ gzip: 3.37 kB
|
||||
dist/assets/hcl-BWvSN4gD.js 10.05 kB │ gzip: 1.93 kB
|
||||
dist/assets/gherkin-heZmZLOM.js 10.16 kB │ gzip: 5.09 kB
|
||||
dist/assets/cynefinDiagram-5FMLGOSQ-BD2mGua6.js 10.18 kB │ gzip: 3.68 kB
|
||||
dist/assets/pkl-u5AG7uiY.js 10.37 kB │ gzip: 1.38 kB
|
||||
dist/assets/beancount-k_qm7-4y.js 10.37 kB │ gzip: 1.44 kB
|
||||
dist/assets/nextflow-groovy-vE_lwT2v.js 10.44 kB │ gzip: 2.14 kB
|
||||
dist/assets/dream-maker-BtqSS_iP.js 10.47 kB │ gzip: 2.25 kB
|
||||
dist/assets/raku-DXvB9xmW.js 10.47 kB │ gzip: 2.94 kB
|
||||
dist/assets/stateDiagram-D77RDMKH-B-0A9xPX.js 10.48 kB │ gzip: 3.69 kB
|
||||
dist/assets/yaml-Buea-lGh.js 10.51 kB │ gzip: 2.27 kB
|
||||
dist/assets/rst-bs7f0vWN.js 10.67 kB │ gzip: 2.42 kB
|
||||
dist/assets/clojure-BMjYHr_A.js 10.82 kB │ gzip: 3.99 kB
|
||||
dist/assets/diagram-VSXAHHWV-Df9UVgEq.js 10.90 kB │ gzip: 4.21 kB
|
||||
dist/assets/elm-DbKCFpqz.js 10.97 kB │ gzip: 2.12 kB
|
||||
dist/assets/just-Cwhn7H3k.js 11.23 kB │ gzip: 2.80 kB
|
||||
dist/assets/dagre-GXQ25YYZ-xEIgJp7x.js 11.25 kB │ gzip: 4.25 kB
|
||||
dist/assets/prolog-CbFg5uaA.js 11.36 kB │ gzip: 3.83 kB
|
||||
dist/assets/terraform-BETggiCN.js 11.39 kB │ gzip: 2.51 kB
|
||||
dist/assets/puppet-BMWR74SV.js 11.44 kB │ gzip: 2.11 kB
|
||||
dist/assets/idl-BEugSyMb.js 11.63 kB │ gzip: 4.52 kB
|
||||
dist/assets/gherkin-DyxjwDmM.js 11.95 kB │ gzip: 5.05 kB
|
||||
dist/assets/wasm-MzD3tlZU.js 12.01 kB │ gzip: 2.19 kB
|
||||
dist/assets/hjson-D5-asLiD.js 12.05 kB │ gzip: 1.64 kB
|
||||
dist/assets/surrealql-Cjom0U5J.js 12.06 kB │ gzip: 3.30 kB
|
||||
dist/assets/handlebars-BpdQsYii.js 12.15 kB │ gzip: 2.38 kB
|
||||
dist/assets/apache-Pmp26Uib.js 12.46 kB │ gzip: 3.72 kB
|
||||
dist/assets/index-CZ9cYO_F.js 12.75 kB │ gzip: 5.63 kB
|
||||
dist/assets/bat-CickPsom.js 12.89 kB │ gzip: 3.23 kB
|
||||
dist/assets/fish-BvzEVeQv.js 13.04 kB │ gzip: 1.74 kB
|
||||
dist/assets/nsis-BlV79W_Q.js 13.13 kB │ gzip: 4.07 kB
|
||||
dist/assets/v-BGw2Nkan.js 13.33 kB │ gzip: 2.77 kB
|
||||
dist/assets/pug-DKIMFp6K.js 13.84 kB │ gzip: 2.58 kB
|
||||
dist/assets/smalltalk-BOQMe2GC.js 14.13 kB │ gzip: 2.16 kB
|
||||
dist/assets/clarity-Dn5IMItf.js 14.36 kB │ gzip: 2.51 kB
|
||||
dist/assets/gnuplot-DdkO51Og.js 14.78 kB │ gzip: 3.27 kB
|
||||
dist/assets/index-CvBQ3_sw.js 14.83 kB │ gzip: 6.00 kB
|
||||
dist/assets/rust-B1yitclQ.js 15.07 kB │ gzip: 2.72 kB
|
||||
dist/assets/kusto-wEQ09or8.js 15.17 kB │ gzip: 3.92 kB
|
||||
dist/assets/ChatView-yeXQKTN6.js 15.41 kB │ gzip: 6.38 kB
|
||||
dist/assets/nix-CwoSXNpI.js 15.51 kB │ gzip: 2.48 kB
|
||||
dist/assets/lua-BaeVxFsk.js 15.54 kB │ gzip: 3.16 kB
|
||||
dist/assets/actionscript-3-B3316cI-.js 15.54 kB │ gzip: 2.71 kB
|
||||
dist/assets/MediaView-DDn-_XZH.js 15.57 kB │ gzip: 6.74 kB
|
||||
dist/assets/chunk-SVP7TREG-C9m5TIe-.js 15.75 kB │ gzip: 4.42 kB
|
||||
dist/assets/abap-BdImnpbu.js 15.85 kB │ gzip: 5.91 kB
|
||||
dist/assets/matlab-D7o27uSR.js 16.09 kB │ gzip: 3.06 kB
|
||||
dist/assets/luau-BnpPk5vE.js 16.14 kB │ gzip: 3.55 kB
|
||||
dist/assets/diagram-VX7I27RA-ziQFycS8.js 16.15 kB │ gzip: 5.78 kB
|
||||
dist/assets/cue-D82EKSYY.js 16.20 kB │ gzip: 2.06 kB
|
||||
dist/assets/elixir-CkH2-t6x.js 16.32 kB │ gzip: 2.80 kB
|
||||
dist/assets/index-Cs5JlGLq.js 16.35 kB │ gzip: 7.47 kB
|
||||
dist/assets/solidity-DijEV5ha.js 16.41 kB │ gzip: 3.16 kB
|
||||
dist/assets/odin-BBf5iR-q.js 16.51 kB │ gzip: 2.94 kB
|
||||
dist/assets/ts-tags-D351s5mN.js 16.57 kB │ gzip: 2.07 kB
|
||||
dist/assets/javascript-iXu5QeM3.js 17.08 kB │ gzip: 5.75 kB
|
||||
dist/assets/ishikawaDiagram-5VMMS53U-DiQySQzt.js 17.65 kB │ gzip: 6.72 kB
|
||||
dist/assets/move-el3G9tDJ.js 17.66 kB │ gzip: 3.06 kB
|
||||
dist/assets/graphql-ChdNCCLP.js 18.00 kB │ gzip: 2.52 kB
|
||||
dist/assets/liquid-C0sCDyMI.js 18.09 kB │ gzip: 3.16 kB
|
||||
dist/assets/svelte-Cy7k_4gC.js 18.24 kB │ gzip: 3.14 kB
|
||||
dist/assets/gdscript-DqcFQ5yU.js 19.09 kB │ gzip: 3.77 kB
|
||||
dist/assets/groovy-gcz8RCvz.js 19.18 kB │ gzip: 3.60 kB
|
||||
dist/assets/WorkspaceView-D--Q9mXh.js 19.88 kB │ gzip: 8.44 kB
|
||||
dist/assets/glimmer-js-ByusRIyA.js 20.07 kB │ gzip: 2.95 kB
|
||||
dist/assets/glimmer-ts-BfAWNZQY.js 20.07 kB │ gzip: 2.94 kB
|
||||
dist/assets/powershell-BmBUJMz7.js 20.16 kB │ gzip: 4.07 kB
|
||||
dist/assets/mdc-D1_yUvq7.js 20.18 kB │ gzip: 6.81 kB
|
||||
dist/assets/viml-CJc9bBzg.js 20.37 kB │ gzip: 6.73 kB
|
||||
dist/assets/kanban-definition-UXKFOSKX-C3erkLcV.js 20.78 kB │ gzip: 7.34 kB
|
||||
dist/assets/nushell-D3jzshHO.js 20.88 kB │ gzip: 5.36 kB
|
||||
dist/assets/index-D0DaXqvL.js 20.96 kB │ gzip: 9.37 kB
|
||||
dist/assets/index-Cmf0-okb.js 21.23 kB │ gzip: 9.76 kB
|
||||
dist/assets/wit-5i3qLPDT.js 21.47 kB │ gzip: 2.89 kB
|
||||
dist/assets/twig-27uCiNez.js 21.98 kB │ gzip: 3.98 kB
|
||||
dist/assets/clike-B9uivgTg.js 22.30 kB │ gzip: 7.86 kB
|
||||
dist/assets/nim-BIad80T-.js 22.46 kB │ gzip: 3.16 kB
|
||||
dist/assets/common-lisp-Cg-RD9OK.js 22.58 kB │ gzip: 6.06 kB
|
||||
dist/assets/index-DduR1oeT.js 23.10 kB │ gzip: 10.41 kB
|
||||
dist/assets/sql-CRqJ_cUM.js 23.43 kB │ gzip: 7.42 kB
|
||||
dist/assets/mindmap-definition-YA3MSWOX-DSZ7Nbk9.js 23.44 kB │ gzip: 7.94 kB
|
||||
dist/assets/sankeyDiagram-P5KCCOFB-Cnmoq8od.js 23.47 kB │ gzip: 8.63 kB
|
||||
dist/assets/journeyDiagram-3NMN7TZE-BwGxWKov.js 23.65 kB │ gzip: 8.39 kB
|
||||
dist/assets/cadence-Bv_4Rxtq.js 23.67 kB │ gzip: 3.67 kB
|
||||
dist/assets/astro-HNnZUWAn.js 24.01 kB │ gzip: 7.55 kB
|
||||
dist/assets/apl-CORt7UWP.js 24.04 kB │ gzip: 4.20 kB
|
||||
dist/assets/templ-DhtptRzy.js 24.06 kB │ gzip: 5.40 kB
|
||||
dist/assets/vhdl-CeAyd5Ju.js 24.26 kB │ gzip: 3.87 kB
|
||||
dist/assets/angular-html-DA-rfuFy.js 24.29 kB │ gzip: 4.01 kB
|
||||
dist/assets/purescript-CklMAg4u.js 24.69 kB │ gzip: 3.25 kB
|
||||
dist/assets/McpServersView-BXDi-IBv.js 24.81 kB │ gzip: 9.19 kB
|
||||
dist/assets/moonbit-CHtswR0a.js 24.94 kB │ gzip: 3.79 kB
|
||||
dist/assets/vue-BqiEGhQt.js 24.96 kB │ gzip: 3.04 kB
|
||||
dist/assets/typespec-CAFt9gP4.js 25.05 kB │ gzip: 2.63 kB
|
||||
dist/assets/fsharp-CXgrBDvD.js 25.31 kB │ gzip: 4.13 kB
|
||||
dist/assets/marko-DjSrsDqO.js 25.48 kB │ gzip: 3.59 kB
|
||||
dist/assets/stylus-B533Al4x.js 25.80 kB │ gzip: 8.60 kB
|
||||
dist/assets/wardleyDiagram-VM6X3IG4-bQBfHON_.js 26.18 kB │ gzip: 6.99 kB
|
||||
dist/assets/c3-Dp5svz6Z.js 26.39 kB │ gzip: 3.99 kB
|
||||
dist/assets/system-verilog-0hqHdDBg.js 26.56 kB │ gzip: 4.88 kB
|
||||
dist/assets/PluginsView-L41qWUGa.js 26.83 kB │ gzip: 8.88 kB
|
||||
dist/assets/codeql-DsOJ9woJ.js 26.88 kB │ gzip: 3.79 kB
|
||||
dist/assets/css-BnMrqG3P.js 27.13 kB │ gzip: 8.47 kB
|
||||
dist/assets/AgentView-vuEh8_sU.js 27.16 kB │ gzip: 9.10 kB
|
||||
dist/assets/scss-D5BDwBP9.js 27.20 kB │ gzip: 4.20 kB
|
||||
dist/assets/java-CylS5w8V.js 27.22 kB │ gzip: 4.26 kB
|
||||
dist/assets/coffee-Ch7k5sss.js 27.42 kB │ gzip: 6.35 kB
|
||||
dist/assets/razor-BjBPvh-w.js 27.51 kB │ gzip: 3.57 kB
|
||||
dist/assets/index-yVH2efBJ.js 28.27 kB │ gzip: 12.76 kB
|
||||
dist/assets/index-rtxKP8li.js 28.80 kB │ gzip: 11.71 kB
|
||||
dist/assets/scala-CqE71os6.js 28.89 kB │ gzip: 3.94 kB
|
||||
dist/assets/purify.es-5AjVNlXF.js 28.92 kB │ gzip: 11.14 kB
|
||||
dist/assets/crystal-DGywbUpC.js 29.39 kB │ gzip: 4.45 kB
|
||||
dist/assets/applescript-Co6uUVPk.js 29.57 kB │ gzip: 5.93 kB
|
||||
dist/assets/gitGraphDiagram-WWUBYQGX-C3QLpWqM.js 30.02 kB │ gzip: 8.92 kB
|
||||
dist/assets/index-l9h65gFm.js 30.82 kB │ gzip: 12.67 kB
|
||||
dist/assets/erDiagram-RLTQ6QDP-gaZhO_Bd.js 31.01 kB │ gzip: 10.68 kB
|
||||
dist/assets/stylus-BEDo0Tqx.js 31.07 kB │ gzip: 7.99 kB
|
||||
dist/assets/julia-5Bft2YPA.js 31.08 kB │ gzip: 4.34 kB
|
||||
dist/assets/requirementDiagram-BXWQKSXE-Cia-WX85.js 31.29 kB │ gzip: 9.87 kB
|
||||
dist/assets/timeline-definition-24CTP7MA-DRPkaIkb.js 31.43 kB │ gzip: 10.47 kB
|
||||
dist/assets/ahk-CsyLZFj1.js 31.44 kB │ gzip: 7.81 kB
|
||||
dist/assets/layout-BPporBfO.js 32.04 kB │ gzip: 11.47 kB
|
||||
dist/assets/VisualMarkdownEditor-DpC_b1CQ.js 32.34 kB │ gzip: 12.47 kB
|
||||
dist/assets/index-BtCU2wNO.js 32.36 kB │ gzip: 13.09 kB
|
||||
dist/assets/bsl-DlhNcFeZ.js 33.95 kB │ gzip: 8.36 kB
|
||||
dist/assets/quadrantDiagram-AXDQQJYC-BjYsuyTc.js 34.57 kB │ gzip: 10.18 kB
|
||||
dist/assets/nginx-BpAMiNFr.js 35.37 kB │ gzip: 4.43 kB
|
||||
dist/assets/haxe-CfZj7gIn.js 35.95 kB │ gzip: 6.01 kB
|
||||
dist/assets/org-DM6o9KBp.js 36.32 kB │ gzip: 3.70 kB
|
||||
dist/assets/sql-D0XecflT.js 37.05 kB │ gzip: 10.94 kB
|
||||
dist/assets/erlang-DsQrWhSR.js 37.48 kB │ gzip: 4.40 kB
|
||||
dist/assets/chunk-IMKFNOWR-CMjnXVE8.js 38.62 kB │ gzip: 12.68 kB
|
||||
dist/assets/bird2-Bx8U0n9b.js 38.68 kB │ gzip: 8.50 kB
|
||||
dist/assets/cobol-nBiQ_Alo.js 39.13 kB │ gzip: 10.86 kB
|
||||
dist/assets/asm-D_Q5rh1f.js 40.72 kB │ gzip: 8.21 kB
|
||||
dist/assets/index-CRAC0HLJ.js 40.77 kB │ gzip: 16.77 kB
|
||||
dist/assets/shellscript-Yzrsuije.js 41.48 kB │ gzip: 6.09 kB
|
||||
dist/assets/haskell-Df6bDoY_.js 41.49 kB │ gzip: 6.44 kB
|
||||
dist/assets/vennDiagram-4TSXK5OY-CS54t_H7.js 42.45 kB │ gzip: 15.85 kB
|
||||
dist/assets/perl-B9cMNwum.js 43.16 kB │ gzip: 4.67 kB
|
||||
dist/assets/blockDiagram-I7D4REHJ-CwXV7v-G.js 43.24 kB │ gzip: 13.96 kB
|
||||
dist/assets/d-85-TOEBH.js 43.80 kB │ gzip: 8.47 kB
|
||||
dist/assets/index-CiwVSVo-.js 44.38 kB │ gzip: 15.39 kB
|
||||
dist/assets/xychartDiagram-S5SC5T6Z-SD9juUjS.js 44.59 kB │ gzip: 12.66 kB
|
||||
dist/assets/ahk2-8Zs4aa1G.js 45.07 kB │ gzip: 9.88 kB
|
||||
dist/assets/index-Dl3jeLBI.js 45.14 kB │ gzip: 19.18 kB
|
||||
dist/assets/ruby-C0TQ7zu5.js 46.72 kB │ gzip: 5.83 kB
|
||||
dist/assets/go-C27-OAKa.js 46.82 kB │ gzip: 5.18 kB
|
||||
dist/assets/apex-DhZFqWV2.js 47.89 kB │ gzip: 6.90 kB
|
||||
dist/assets/ada-bCR0ucgS.js 48.08 kB │ gzip: 6.03 kB
|
||||
dist/assets/css-CLj8gQPS.js 49.04 kB │ gzip: 11.86 kB
|
||||
dist/assets/chunk-TICWLB2K-Dykoa1DO.js 49.34 kB │ gzip: 15.79 kB
|
||||
dist/assets/imba-DGztddWO.js 49.93 kB │ gzip: 9.46 kB
|
||||
dist/assets/mermaid-CQcHuHx7.js 54.81 kB │ gzip: 4.81 kB
|
||||
dist/assets/wikitext-BhOHFoWU.js 55.89 kB │ gzip: 4.76 kB
|
||||
dist/assets/stata-DI20mbqo.js 56.99 kB │ gzip: 12.36 kB
|
||||
dist/assets/html-pp8916En.js 57.25 kB │ gzip: 11.69 kB
|
||||
dist/assets/ballerina-BFfxhgS-.js 58.69 kB │ gzip: 8.15 kB
|
||||
dist/assets/markdown-Cvjx9yec.js 59.34 kB │ gzip: 5.64 kB
|
||||
dist/assets/ThemesView-ROhLW1IX.js 60.69 kB │ gzip: 16.28 kB
|
||||
dist/assets/ocaml-C0hk2d4L.js 62.45 kB │ gzip: 5.02 kB
|
||||
dist/assets/flowDiagram-HODETNUW-B-wmlXgh.js 62.65 kB │ gzip: 20.06 kB
|
||||
dist/assets/c4Diagram-7LVT6UL2-CZK_oPAh.js 65.64 kB │ gzip: 18.87 kB
|
||||
dist/assets/mojo-DJz3ZmWd.js 69.52 kB │ gzip: 9.27 kB
|
||||
dist/assets/ganttDiagram-EL5Y4UJY-C00FfxS-.js 69.78 kB │ gzip: 23.60 kB
|
||||
dist/assets/python-B6aJPvgy.js 69.95 kB │ gzip: 9.13 kB
|
||||
dist/assets/index-C7y4_zOF.js 70.84 kB │ gzip: 25.86 kB
|
||||
dist/assets/c-BIGW1oBm.js 72.11 kB │ gzip: 10.51 kB
|
||||
dist/assets/latex-D5pSuvFb.js 72.90 kB │ gzip: 6.78 kB
|
||||
dist/assets/vyper-CDx5xZoG.js 74.65 kB │ gzip: 10.74 kB
|
||||
dist/assets/preload-helper-BYUGvJBp.js 77.31 kB │ gzip: 30.71 kB
|
||||
dist/assets/hack-BWmVpMyf.js 81.13 kB │ gzip: 26.38 kB
|
||||
dist/assets/cose-bilkent-JH36ORCC-ucdrH9O7.js 81.80 kB │ gzip: 22.53 kB
|
||||
dist/assets/index-DJmQi6DI.js 87.01 kB │ gzip: 34.53 kB
|
||||
dist/assets/swift-C2oV4EkX.js 87.23 kB │ gzip: 14.84 kB
|
||||
dist/assets/fortran-free-form-BxgE0vQu.js 88.97 kB │ gzip: 11.27 kB
|
||||
dist/assets/csharp-DSvCPggb.js 90.19 kB │ gzip: 10.75 kB
|
||||
dist/assets/racket-BqYA7rlc.js 92.39 kB │ gzip: 15.02 kB
|
||||
dist/assets/less-B1dDrJ26.js 97.63 kB │ gzip: 14.70 kB
|
||||
dist/assets/index-B_zoUCtT.js 97.90 kB │ gzip: 28.48 kB
|
||||
dist/assets/index-CVdm6qZy.js 103.95 kB │ gzip: 34.40 kB
|
||||
dist/assets/blade-2xfisSek.js 104.98 kB │ gzip: 28.20 kB
|
||||
dist/assets/objective-c-DXmwc3jG.js 105.41 kB │ gzip: 23.33 kB
|
||||
dist/assets/SettingsView-CA8mvhNi.js 110.00 kB │ gzip: 38.72 kB
|
||||
dist/assets/php-Csjmro_R.js 113.09 kB │ gzip: 28.74 kB
|
||||
dist/assets/swimlanes-42K2YHIH-DBnK_luN.js 117.20 kB │ gzip: 42.88 kB
|
||||
dist/assets/sequenceDiagram-WJ2MYXX4-CT529fmH.js 117.53 kB │ gzip: 31.32 kB
|
||||
dist/assets/asciidoc-CSVQ5wI8.js 136.05 kB │ gzip: 9.50 kB
|
||||
dist/assets/mdx-Cmh6b_Ma.js 136.11 kB │ gzip: 23.35 kB
|
||||
dist/assets/architectureDiagram-5GKGNRK7-MxGRspvX.js 152.10 kB │ gzip: 43.27 kB
|
||||
dist/assets/typst-BUadGCkm.js 163.55 kB │ gzip: 10.98 kB
|
||||
dist/assets/index-CX-Z_M19.js 165.35 kB │ gzip: 49.44 kB
|
||||
dist/assets/objective-cpp-CLxacb5B.js 171.97 kB │ gzip: 30.62 kB
|
||||
dist/assets/javascript-wDzz0qaB.js 174.83 kB │ gzip: 16.51 kB
|
||||
dist/assets/tsx-COt5Ahok.js 175.54 kB │ gzip: 16.51 kB
|
||||
dist/assets/jsx-g9-lgVsj.js 177.79 kB │ gzip: 16.61 kB
|
||||
dist/assets/markdown-D1OO11DI.js 180.56 kB │ gzip: 56.48 kB
|
||||
dist/assets/typescript-BPQ3VLAy.js 181.08 kB │ gzip: 16.04 kB
|
||||
dist/assets/angular-ts-BrjP3tb8.js 183.82 kB │ gzip: 16.63 kB
|
||||
dist/assets/vue-vine-BoDAl6tE.js 190.05 kB │ gzip: 17.99 kB
|
||||
dist/assets/math-katex-0-18-4-CHandE1Z.js 261.18 kB │ gzip: 77.76 kB
|
||||
dist/assets/editor-prosemirror-DdSLUblA.js 261.62 kB │ gzip: 81.66 kB
|
||||
dist/assets/wolfram-lXgVvXCa.js 262.39 kB │ gzip: 77.14 kB
|
||||
dist/assets/editor-milkdown-CfUWLk7w.js 288.62 kB │ gzip: 86.76 kB
|
||||
dist/assets/index-BPFAEZBr.js 347.56 kB │ gzip: 114.62 kB
|
||||
dist/assets/editor-codemirror-BFoSK6g_.js 425.42 kB │ gzip: 137.40 kB
|
||||
dist/assets/cytoscape.esm-Bch-eiPH.js 443.83 kB │ gzip: 142.41 kB
|
||||
dist/assets/wasm-CG6Dc4jp.js 622.34 kB │ gzip: 230.29 kB
|
||||
dist/assets/mermaid.core-C2yjh7Um.js 658.47 kB │ gzip: 158.25 kB
|
||||
dist/assets/cynefin-OW5HDTMX-DYRSVYbi.js 690.86 kB │ gzip: 155.12 kB
|
||||
dist/assets/cpp-BMRokrvK.js 785.49 kB │ gzip: 53.20 kB
|
||||
dist/assets/emacs-lisp-C_m_b--Z.js 790.01 kB │ gzip: 198.83 kB
|
||||
|
||||
(!) Some chunks are larger than 500 kB after minification. Consider:
|
||||
- Using dynamic import() to code-split the application
|
||||
- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks
|
||||
- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.
|
||||
✓ built in 18.06s
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"model": "deepseek-v4-flash",
|
||||
"configured_test_window": 8192,
|
||||
"vendor_max_context_tested": false,
|
||||
"detect": {
|
||||
"error_code": "CONTEXT_COMPRESSION_REQUIRED",
|
||||
"network_calls": 0
|
||||
},
|
||||
"compression": {
|
||||
"passed": false,
|
||||
"before_estimate": 2262,
|
||||
"after_estimate": 523,
|
||||
"archive_unchanged": true,
|
||||
"network_calls": 2,
|
||||
"answer_input_tokens": 132,
|
||||
"answer_output_tokens": 256
|
||||
},
|
||||
"observed_provider_cache": {
|
||||
"requests": 18,
|
||||
"reporting_requests": 16,
|
||||
"positive_hit_requests": 12,
|
||||
"positive_miss_requests": 16,
|
||||
"scope": "provider reported usage across this isolated acceptance session; not deterministic cache control"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"model": "deepseek-v4-flash",
|
||||
"configured_test_window": 8192,
|
||||
"vendor_max_context_tested": false,
|
||||
"detect": {
|
||||
"error_code": "CONTEXT_COMPRESSION_REQUIRED",
|
||||
"network_calls": 0
|
||||
},
|
||||
"compression": {
|
||||
"passed": true,
|
||||
"before_estimate": 2262,
|
||||
"after_estimate": 516,
|
||||
"archive_unchanged": true,
|
||||
"network_calls": 2,
|
||||
"answer_input_tokens": 129,
|
||||
"answer_output_tokens": 289
|
||||
},
|
||||
"observed_provider_cache": {
|
||||
"requests": 20,
|
||||
"reporting_requests": 18,
|
||||
"positive_hit_requests": 13,
|
||||
"positive_miss_requests": 18,
|
||||
"scope": "provider reported usage across this isolated acceptance session; not deterministic cache control"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
|
||||
> notes-agent-frontend@0.2.0 test C:\Users\KiriAky\.codex\worktrees\7950\NotesAgent\frontend
|
||||
> vitest run "--reporter=dot"
|
||||
|
||||
|
||||
RUN v4.1.11 C:/Users/KiriAky/.codex/worktrees/7950/NotesAgent/frontend
|
||||
|
||||
stderr | src/features/editor/shikiCodeMirror.spec.ts
|
||||
Warning: KaTeX doesn't work in quirks mode. Make sure your website has a suitable doctype.
|
||||
|
||||
stderr | src/features/themes/ThemesView.spec.ts
|
||||
Warning: KaTeX doesn't work in quirks mode. Make sure your website has a suitable doctype.
|
||||
|
||||
stderr | src/features/editor/diagramIntegration.spec.ts
|
||||
Warning: KaTeX doesn't work in quirks mode. Make sure your website has a suitable doctype.
|
||||
Warning: KaTeX doesn't work in quirks mode. Make sure your website has a suitable doctype.
|
||||
|
||||
stderr | src/features/editor/EditorPane.spec.ts > EditorPane file switching > recreates the visual editor with the newly loaded file content
|
||||
Warning: KaTeX doesn't work in quirks mode. Make sure your website has a suitable doctype.
|
||||
Warning: KaTeX doesn't work in quirks mode. Make sure your website has a suitable doctype.
|
||||
|
||||
·····················································································································································································································································································································································································································································
|
||||
|
||||
Test Files 80 passed (80)
|
||||
Tests 437 passed (437)
|
||||
Start at 02:50:15
|
||||
Duration 21.99s (transform 24.03s, setup 0ms, import 75.03s, tests 27.89s, environment 143.74s)
|
||||
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"run_id": "benchmark_f98741e7649a",
|
||||
"kind": "agent",
|
||||
"dataset_id": "agent-integration-v1",
|
||||
"dataset_hash": "sha256:af10b953037a35cad6fae678cbc118b519b209e88906f80721080560d49a508c",
|
||||
"status": "completed",
|
||||
"config_snapshot": {
|
||||
"dataset_id": "agent-integration-v1",
|
||||
"provider_id": "provider_65fd326edba646e3bc735c25fbf5ffe8",
|
||||
"model": "deepseek-v4-flash",
|
||||
"max_steps": 10,
|
||||
"timeout_seconds": 150,
|
||||
"token_budget": 10000,
|
||||
"repeat": 1,
|
||||
"allow_network": true,
|
||||
"offline": false,
|
||||
"dataset_hash": "sha256:af10b953037a35cad6fae678cbc118b519b209e88906f80721080560d49a508c",
|
||||
"dataset_version": "1.0.0",
|
||||
"execution": "real_agent_runtime",
|
||||
"provider_type": "openai_compatible",
|
||||
"scoring_version": "1.0",
|
||||
"permission_policy": "runtime_user_decision",
|
||||
"active_agent_run_id": "run_d55e28a22cb0433498ae59d0eabce566"
|
||||
},
|
||||
"metrics": {
|
||||
"total_cases": 2,
|
||||
"task_success_rate": 1.0,
|
||||
"tool_selection_accuracy": 1.0,
|
||||
"tool_argument_accuracy": 1.0,
|
||||
"invalid_tool_call_rate": 0.0,
|
||||
"average_steps": 2.5,
|
||||
"average_latency_ms": 10539.258899982087,
|
||||
"token_usage": 16070,
|
||||
"tool_calls": 6,
|
||||
"expected_calls": 6
|
||||
},
|
||||
"cases": [
|
||||
{
|
||||
"case_id": "retrieval-tasks",
|
||||
"repeat": 0,
|
||||
"agent_run_id": "run_dc5ad9f709c64b59b792bebafad77653",
|
||||
"success": true,
|
||||
"tool_calls": 5,
|
||||
"expected_calls": 5,
|
||||
"selected_calls": 5,
|
||||
"accurate_calls": 5,
|
||||
"invalid_calls": 0,
|
||||
"steps": 3,
|
||||
"latency_ms": 13835.580999962986,
|
||||
"token_usage": 8651,
|
||||
"checks": {
|
||||
"completed": true,
|
||||
"tools_selected": true,
|
||||
"tool_arguments": true,
|
||||
"no_extra_calls": true,
|
||||
"tool_results": true,
|
||||
"output": true,
|
||||
"citation": true,
|
||||
"tasks_created": true
|
||||
},
|
||||
"error_code": null
|
||||
},
|
||||
{
|
||||
"case_id": "mcp-search",
|
||||
"repeat": 0,
|
||||
"agent_run_id": "run_d55e28a22cb0433498ae59d0eabce566",
|
||||
"success": true,
|
||||
"tool_calls": 1,
|
||||
"expected_calls": 1,
|
||||
"selected_calls": 1,
|
||||
"accurate_calls": 1,
|
||||
"invalid_calls": 0,
|
||||
"steps": 2,
|
||||
"latency_ms": 7242.936800001189,
|
||||
"token_usage": 7419,
|
||||
"checks": {
|
||||
"completed": true,
|
||||
"tools_selected": true,
|
||||
"tool_arguments": true,
|
||||
"no_extra_calls": true,
|
||||
"tool_results": true,
|
||||
"output": true,
|
||||
"citation": true,
|
||||
"tasks_created": true
|
||||
},
|
||||
"error_code": null
|
||||
}
|
||||
],
|
||||
"error": null,
|
||||
"error_code": null,
|
||||
"permission_approvals": 3,
|
||||
"mcp_present": true,
|
||||
"tasks": [
|
||||
{
|
||||
"task_id": "task_7380b0dd0b2c4161a20f3b6c1a19dffa",
|
||||
"title": "验收-锁顺序"
|
||||
},
|
||||
{
|
||||
"task_id": "task_5e0584d287c442b597ca4c22f8998214",
|
||||
"title": "验收-循环等待"
|
||||
},
|
||||
{
|
||||
"task_id": "task_22ac49c4e4214c98951b387a6f128a94",
|
||||
"title": "验收-互斥条件"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
{
|
||||
"provider_id": "provider_65fd326edba646e3bc735c25fbf5ffe8",
|
||||
"protocol": "openai_compatible",
|
||||
"model": "deepseek-v4-flash",
|
||||
"checks": {
|
||||
"discovery": {
|
||||
"passed": true,
|
||||
"count": 3,
|
||||
"elapsed_ms": 1904.77
|
||||
},
|
||||
"normal_chat": {
|
||||
"passed": true,
|
||||
"input_tokens": 87,
|
||||
"output_tokens": 16,
|
||||
"elapsed_ms": 2107.22
|
||||
},
|
||||
"stream_usage_reasoning": {
|
||||
"passed": true,
|
||||
"events": [
|
||||
"Done",
|
||||
"TextDelta",
|
||||
"ThinkingDelta",
|
||||
"Usage"
|
||||
],
|
||||
"usage_reported": true,
|
||||
"reasoning_observed": true,
|
||||
"elapsed_ms": 2118.0
|
||||
},
|
||||
"stream_cancel": {
|
||||
"passed": true,
|
||||
"first_event": "ThinkingDelta",
|
||||
"close_ms": 12.160799989942461,
|
||||
"scope": "client stream resource close; provider billing cessation not observable",
|
||||
"elapsed_ms": 1842.69
|
||||
},
|
||||
"error_mapping": {
|
||||
"passed": true,
|
||||
"error_code": "PROVIDER_INVALID_REQUEST",
|
||||
"elapsed_ms": 1590.6
|
||||
}
|
||||
},
|
||||
"unconfigured_protocols": [
|
||||
"openai_responses",
|
||||
"anthropic_messages",
|
||||
"ollama"
|
||||
],
|
||||
"not_tested": [
|
||||
"vendor_maximum_context_boundary"
|
||||
],
|
||||
"max_direct_requests": 5,
|
||||
"agent": {
|
||||
"run_id": "benchmark_8ebbf8c6f27f",
|
||||
"kind": "agent",
|
||||
"dataset_id": "agent-core-v1",
|
||||
"dataset_hash": "sha256:82fd1b84cc2dc2feec0fc67357d64b145dcdf6c44ec2660f182d550e85b9d27f",
|
||||
"status": "completed",
|
||||
"config_snapshot": {
|
||||
"dataset_id": "agent-core-v1",
|
||||
"provider_id": "provider_65fd326edba646e3bc735c25fbf5ffe8",
|
||||
"model": "deepseek-v4-flash",
|
||||
"max_steps": 6,
|
||||
"timeout_seconds": 90,
|
||||
"token_budget": 6000,
|
||||
"repeat": 1,
|
||||
"allow_network": false,
|
||||
"offline": false,
|
||||
"dataset_hash": "sha256:82fd1b84cc2dc2feec0fc67357d64b145dcdf6c44ec2660f182d550e85b9d27f",
|
||||
"dataset_version": "1.0.0",
|
||||
"execution": "real_agent_runtime",
|
||||
"provider_type": "openai_compatible",
|
||||
"scoring_version": "1.0",
|
||||
"permission_policy": "runtime_user_decision",
|
||||
"active_agent_run_id": "run_c9296aaea4c7429db97528edc72b6180"
|
||||
},
|
||||
"metrics": {
|
||||
"total_cases": 4,
|
||||
"task_success_rate": 1.0,
|
||||
"tool_selection_accuracy": 1.0,
|
||||
"tool_argument_accuracy": 1.0,
|
||||
"invalid_tool_call_rate": 0.0,
|
||||
"average_steps": 1.75,
|
||||
"average_latency_ms": 5682.329325005412,
|
||||
"token_usage": 5888,
|
||||
"tool_calls": 3,
|
||||
"expected_calls": 3
|
||||
},
|
||||
"cases": [
|
||||
{
|
||||
"case_id": "arithmetic",
|
||||
"repeat": 0,
|
||||
"agent_run_id": "run_480c159ffc0341f9883a09914ff003b5",
|
||||
"success": true,
|
||||
"tool_calls": 1,
|
||||
"expected_calls": 1,
|
||||
"selected_calls": 1,
|
||||
"accurate_calls": 1,
|
||||
"invalid_calls": 0,
|
||||
"steps": 2,
|
||||
"latency_ms": 4888.349500019103,
|
||||
"token_usage": 1545,
|
||||
"checks": {
|
||||
"completed": true,
|
||||
"tools_selected": true,
|
||||
"tool_arguments": true,
|
||||
"no_extra_calls": true,
|
||||
"tool_results": true,
|
||||
"output": true,
|
||||
"citation": true,
|
||||
"tasks_created": true
|
||||
},
|
||||
"error_code": null
|
||||
},
|
||||
{
|
||||
"case_id": "echo",
|
||||
"repeat": 0,
|
||||
"agent_run_id": "run_4fdbd0508bb84bcda424d97de1b91241",
|
||||
"success": true,
|
||||
"tool_calls": 1,
|
||||
"expected_calls": 1,
|
||||
"selected_calls": 1,
|
||||
"accurate_calls": 1,
|
||||
"invalid_calls": 0,
|
||||
"steps": 2,
|
||||
"latency_ms": 4978.496600000653,
|
||||
"token_usage": 1601,
|
||||
"checks": {
|
||||
"completed": true,
|
||||
"tools_selected": true,
|
||||
"tool_arguments": true,
|
||||
"no_extra_calls": true,
|
||||
"tool_results": true,
|
||||
"output": true,
|
||||
"citation": true,
|
||||
"tasks_created": true
|
||||
},
|
||||
"error_code": null
|
||||
},
|
||||
{
|
||||
"case_id": "no-tool",
|
||||
"repeat": 0,
|
||||
"agent_run_id": "run_5dfb4ae0a6c547b79706ad102e577bab",
|
||||
"success": true,
|
||||
"tool_calls": 0,
|
||||
"expected_calls": 0,
|
||||
"selected_calls": 0,
|
||||
"accurate_calls": 0,
|
||||
"invalid_calls": 0,
|
||||
"steps": 1,
|
||||
"latency_ms": 2549.696500005666,
|
||||
"token_usage": 541,
|
||||
"checks": {
|
||||
"completed": true,
|
||||
"tools_selected": true,
|
||||
"tool_arguments": true,
|
||||
"no_extra_calls": true,
|
||||
"tool_results": true,
|
||||
"output": true,
|
||||
"citation": true,
|
||||
"tasks_created": true
|
||||
},
|
||||
"error_code": null
|
||||
},
|
||||
{
|
||||
"case_id": "markdown-catalog",
|
||||
"repeat": 0,
|
||||
"agent_run_id": "run_c9296aaea4c7429db97528edc72b6180",
|
||||
"success": true,
|
||||
"tool_calls": 1,
|
||||
"expected_calls": 1,
|
||||
"selected_calls": 1,
|
||||
"accurate_calls": 1,
|
||||
"invalid_calls": 0,
|
||||
"steps": 2,
|
||||
"latency_ms": 10312.774699996226,
|
||||
"token_usage": 2201,
|
||||
"checks": {
|
||||
"completed": true,
|
||||
"tools_selected": true,
|
||||
"tool_arguments": true,
|
||||
"no_extra_calls": true,
|
||||
"tool_results": true,
|
||||
"output": true,
|
||||
"citation": true,
|
||||
"tasks_created": true
|
||||
},
|
||||
"error_code": null
|
||||
}
|
||||
],
|
||||
"error": null,
|
||||
"error_code": null
|
||||
},
|
||||
"followup_evidence": [
|
||||
"context.json",
|
||||
"integration.json"
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,164 @@
|
||||
[
|
||||
{
|
||||
"theme": "light",
|
||||
"errors": [],
|
||||
"overflow": false,
|
||||
"colors": {
|
||||
"panel": {
|
||||
"background": "rgb(255, 255, 255)",
|
||||
"color": "rgb(31, 35, 40)",
|
||||
"border": "rgb(228, 231, 235)"
|
||||
},
|
||||
"input": {
|
||||
"background": "rgb(255, 255, 255)",
|
||||
"color": "rgb(31, 35, 40)",
|
||||
"border": "rgb(102, 113, 241)"
|
||||
},
|
||||
"button": {
|
||||
"background": "rgb(91, 103, 241)",
|
||||
"color": "rgb(255, 255, 255)",
|
||||
"border": "rgba(0, 0, 0, 0)"
|
||||
},
|
||||
"metric": {
|
||||
"background": "rgb(247, 248, 250)",
|
||||
"color": "rgb(31, 35, 40)",
|
||||
"border": "rgb(228, 231, 235)"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"theme": "dark",
|
||||
"errors": [],
|
||||
"overflow": false,
|
||||
"colors": {
|
||||
"panel": {
|
||||
"background": "rgb(22, 27, 34)",
|
||||
"color": "rgb(230, 237, 243)",
|
||||
"border": "rgb(48, 54, 61)"
|
||||
},
|
||||
"input": {
|
||||
"background": "rgb(21, 26, 33)",
|
||||
"color": "rgb(230, 237, 243)",
|
||||
"border": "rgb(116, 129, 231)"
|
||||
},
|
||||
"button": {
|
||||
"background": "rgb(125, 139, 255)",
|
||||
"color": "rgb(13, 17, 23)",
|
||||
"border": "rgba(0, 0, 0, 0)"
|
||||
},
|
||||
"metric": {
|
||||
"background": "rgb(22, 27, 34)",
|
||||
"color": "rgb(230, 237, 243)",
|
||||
"border": "rgb(48, 54, 61)"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"theme": "sepia",
|
||||
"errors": [],
|
||||
"overflow": false,
|
||||
"colors": {
|
||||
"panel": {
|
||||
"background": "rgb(255, 248, 232)",
|
||||
"color": "rgb(64, 55, 43)",
|
||||
"border": "rgb(221, 207, 173)"
|
||||
},
|
||||
"input": {
|
||||
"background": "rgb(255, 247, 231)",
|
||||
"color": "rgb(64, 55, 43)",
|
||||
"border": "rgb(148, 105, 65)"
|
||||
},
|
||||
"button": {
|
||||
"background": "rgb(138, 91, 50)",
|
||||
"color": "rgb(255, 255, 255)",
|
||||
"border": "rgba(0, 0, 0, 0)"
|
||||
},
|
||||
"metric": {
|
||||
"background": "rgb(244, 232, 202)",
|
||||
"color": "rgb(64, 55, 43)",
|
||||
"border": "rgb(221, 207, 173)"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"theme": "paper-moments",
|
||||
"errors": [],
|
||||
"overflow": false,
|
||||
"colors": {
|
||||
"panel": {
|
||||
"background": "rgb(255, 253, 245)",
|
||||
"color": "rgb(73, 63, 53)",
|
||||
"border": "rgb(181, 166, 147)"
|
||||
},
|
||||
"input": {
|
||||
"background": "rgb(254, 251, 243)",
|
||||
"color": "rgb(73, 63, 53)",
|
||||
"border": "rgb(147, 105, 88)"
|
||||
},
|
||||
"button": {
|
||||
"background": "rgb(134, 82, 66)",
|
||||
"color": "rgb(255, 253, 245)",
|
||||
"border": "rgba(0, 0, 0, 0)"
|
||||
},
|
||||
"metric": {
|
||||
"background": "rgb(243, 238, 227)",
|
||||
"color": "rgb(73, 63, 53)",
|
||||
"border": "rgb(181, 166, 147)"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"theme": "ocean-blue",
|
||||
"errors": [],
|
||||
"overflow": false,
|
||||
"colors": {
|
||||
"panel": {
|
||||
"background": "rgb(255, 255, 255)",
|
||||
"color": "rgb(30, 41, 59)",
|
||||
"border": "rgb(226, 232, 240)"
|
||||
},
|
||||
"input": {
|
||||
"background": "rgb(255, 255, 255)",
|
||||
"color": "rgb(30, 41, 59)",
|
||||
"border": "rgb(27, 133, 189)"
|
||||
},
|
||||
"button": {
|
||||
"background": "rgb(0, 118, 181)",
|
||||
"color": "rgb(255, 255, 255)",
|
||||
"border": "rgba(0, 0, 0, 0)"
|
||||
},
|
||||
"metric": {
|
||||
"background": "rgb(248, 250, 252)",
|
||||
"color": "rgb(30, 41, 59)",
|
||||
"border": "rgb(226, 232, 240)"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"theme": "midnight-purple",
|
||||
"errors": [],
|
||||
"overflow": false,
|
||||
"colors": {
|
||||
"panel": {
|
||||
"background": "rgb(36, 40, 59)",
|
||||
"color": "rgb(192, 202, 245)",
|
||||
"border": "rgb(59, 63, 92)"
|
||||
},
|
||||
"input": {
|
||||
"background": "rgb(34, 38, 55)",
|
||||
"color": "rgb(192, 202, 245)",
|
||||
"border": "rgb(139, 75, 197)"
|
||||
},
|
||||
"button": {
|
||||
"background": "rgb(156, 77, 220)",
|
||||
"color": "rgb(26, 27, 38)",
|
||||
"border": "rgba(0, 0, 0, 0)"
|
||||
},
|
||||
"metric": {
|
||||
"background": "rgb(36, 40, 59)",
|
||||
"color": "rgb(192, 202, 245)",
|
||||
"border": "rgb(59, 63, 92)"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,106 @@
|
||||
# 第二阶段全量验收报告
|
||||
|
||||
> 以下保留修复前基线审计。其后已完成的收尾实现、真实运行结果和剩余外部边界以[第二阶段收尾实现与验收](第二阶段收尾实现与验收-2026-09-07.md)为准,不能把本报告中的“未实现”继续当作当前分支状态。
|
||||
|
||||
> 日期:2026-09-07。基线:`main` / `95095197df8b607e55144748c9be0c7f6daf00df`,与本轮拉取的 `gitea/main` 一致。范围包含三位成员负责的全部第二阶段模块,不沿用历史“不含杨侧验收”的排除范围。
|
||||
|
||||
## 结论与口径
|
||||
|
||||
**第二阶段主要基础能力已落地,但尚未达到完整 Definition of Done。** 自动化测试通过不代表 Agent Benchmark、函数图像前端、Mermaid 静态导出、真实厂商协议和音频质量已经验收。
|
||||
|
||||
依据为[第二阶段团队分工表](../architecture/第二阶段团队分工表.md)、[第二阶段接口契约](../contracts/第二阶段接口契约-开发版.md)、当前实现、自动化测试、隔离压测及浏览器检查。历史文档中未经同步的勾选框不直接作为实时完成率。
|
||||
|
||||
本轮不修改业务逻辑、不调用收费模型或外部 MCP、不重跑长录音。测试使用临时数据库/Vault;内置语料检查读取 Git HEAD,避开用户未提交笔记。主工作区原有两份笔记修改保持不变。
|
||||
|
||||
## 本轮验证结果
|
||||
|
||||
| 检查 | 结果 | 证据与边界 |
|
||||
| --- | --- | --- |
|
||||
| 后端全量 | 838 项通过 | Python 3.12,临时 APP_DATA_DIR / APP_DB_PATH / APP_VAULT_PATH;1 条既有 Starlette/httpx 弃用提示 |
|
||||
| 前端全量 | 78 个测试文件、433 项通过 | Vitest;部分测试存在 KaTeX quirks-mode 提示 |
|
||||
| 类型与生产构建 | 通过 | `npm run build`,vue-tsc + Vite;仍有超过 500 kB 的惰性分块提示 |
|
||||
| Mermaid 真实渲染 | 36 / 36 结构检查通过 | 6 图型 × 6 主题,无 Mermaid mock;不是所有图形字形、缩放和主题外观的完整人工验收 |
|
||||
| Agent 并发 | 1 / 10 / 50 / 200 并发场景通过 | 共 290 次完成;mock 每轮注入 50 ms,验证事件顺序、回放及终态恢复,不代表真实模型性能 |
|
||||
| 容量与取消 | 200 个活跃运行上限及取消通过 | 溢出拒绝;取消 P95 9.84 ms;权限等待 10 个批准、10 个取消,订阅释放 |
|
||||
| 故障隔离 | 通过 | 成功、Provider 错误、模型超时、工具超时各 5 次;剩余工具执行器为 0 |
|
||||
| Task API | 100 / 1000 条 CRUD、20 并发通过 | 默认分页 50,分页完整,删除后 total=0;1000 条场景创建/更新/删除 P95 为 262.36 / 275.78 / 286.18 ms |
|
||||
| sqlite-vec 合成对照 | Top-20 一致 | 4000 块 × 384 维,5 次样本;Python JSON 扫描中位 1376.98 ms,sqlite-vec 12.92 ms;只证明该合成场景效率,不代表检索质量 |
|
||||
| 内置 RAG 数据集完整性 | 5 / 5 案例关联有效 | 对照 HEAD 中 16 篇笔记、3787 块;预期 note_id / block_id 均存在,不等同于质量指标达标 |
|
||||
|
||||
首次后端测试因本地 `.venv` 缺少 `python-docx` 等新合并依赖而在收集阶段失败。执行 `uv sync --frozen` 后补齐 6 个锁定包并重新全量通过;未改动依赖声明或锁文件。这是本机环境未同步,不作为代码回归计数。
|
||||
|
||||
性能测试与构建曾并行执行,上述延迟只用于发现功能及容量问题,不作为吞吐 SLA 或性能倍率承诺。200 并发 Agent 的事件循环最大延迟为 334.08 ms,值得单独串行复测。
|
||||
|
||||
## 第二阶段任务矩阵
|
||||
|
||||
| 任务 | 当前状态 | 实现/验证依据 | 尚缺内容 |
|
||||
| --- | --- | --- | --- |
|
||||
| 音频转写 | 替代技术路径已实现,质量待验收 | `app/local_models/worker.py`、阶段 F 记录;当前为 Qwen3-ASR | 原清单 faster-whisper 未交付;需明确接受技术替代并同步清单 |
|
||||
| 说话人分段 | 部分实现 | ERes2NetV2 片段聚类,历史 CPU/CUDA 与长录音闭环 | 非 pyannote 完整分离;逐字强制对齐、同段多人/重叠分离未实现;CER/WER/DER、阈值缺标注验收 |
|
||||
| MCP Bridge / Registry | 工程实现与离线回归通过 | `extensions`、MCP Host/Registry 测试、Agent 工具链 | 目标外部服务器专项并非本轮重验;生产 OS 沙箱属于第三阶段 |
|
||||
| Plugin Command | 已实现 | Palette、工作区右键与工具栏、命令校验及 effect 回归 | 后续复杂 UI 贡献属于扩展阶段 |
|
||||
| Plugin Settings / Secret | 已实现 | schema 动态表单、独立凭据引用及权限校验测试 | 真实第三方插件组合仍按包验收 |
|
||||
| Skill / Plugin 本地持久注册 | 已实现 | `extensions/installed.py`、恢复、摘要复核、ZIP 清理回归 | 不应继续列为“尚无持久化”;在线升级事务与社区审核属于第三阶段 |
|
||||
| Provider 适配 | 工程实现,外部专项待验收 | OpenAI Compatible/Responses、Anthropic、Ollama 适配及协议回归 | 逐厂商的发现、工具往返、流、取消、Usage、错误映射、上下文限制等真实矩阵未全部验收 |
|
||||
| RAG Benchmark | 后端基础已实现 | `benchmarks`、指标与生命周期测试、5 案例数据集 | 代表性评测集与调优前后对照不足;无前端报告页 |
|
||||
| Agent Benchmark | 未实现 | 无 `/api/benchmarks/agent/runs`,`Benchmark开发说明` 明确暂缓 | 标准 Case/Dataset、任务成功率、工具选择/参数准确率、无效调用率、步骤/耗时/Token 报告 |
|
||||
| Retrieval 调优 | 性能改进已实现,质量验收不完整 | 模型/维度隔离 sqlite-vec、增量索引、过滤及检索回归 | 相同数据集/模型/参数下的质量前后对照;当前 reranker 仍为 LexicalReranker,不是已上线 BGE Cross-Encoder |
|
||||
| HTML / PDF / DOCX 导出 | 后端基础已实现,界面联调未完成 | `export`、三种产物内容/结构回归 | 前端无 exportService、调用入口及任务下载流程;仍不能直接完成工作区总 Demo |
|
||||
| 复杂内容导出 | 部分实现 | 警告框、嵌套表格、主题配色已覆盖 | Mermaid 为源码占位;PDF/DOCX 公式为文本,图片嵌入/资源解析仍有限;DOCX 函数图像占位 |
|
||||
| 函数图像 | 后端解析/静态渲染已实现 | `plot`、SVG/PDF 共享几何与预算测试 | 前端没有 function-plot 渲染、编辑刷新、缩放和预览入口;仅 Mermaid 被前端识别为图表 |
|
||||
| Theme Manifest / 导入 / 管理 | 已实现 | 文件/URL/ZIP、兼容性、安全校验、隔离预览、启停卸载及前端测试 | 网络 Marketplace 非第二阶段要求;导出固定配色不等于完整主题 CSS 复刻 |
|
||||
| Agent Trace | 已实现 | Tree/Timeline、参数/结果/耗时/错误、筛选、引用跳转、恢复与取消测试 | 真实 Provider 的特殊事件仍需专项验证 |
|
||||
| Mermaid 编辑器与预览 | 已实现 | 真实 36 组矩阵、编辑器回归、源码/预览及大图组件 | 静态 SVG/Image 向 Export Service 的传递尚未打通 |
|
||||
| 跨模块共同 Demo | 部分完成 | 录音→笔记→检索、Agent/工具/Trace 已有历史闭环 | 函数图像前端、三格式导出 UI、Mermaid 静态导出等阻止完整演示闭环 |
|
||||
|
||||
## 当前可推进的收尾项
|
||||
|
||||
### 优先完成第二阶段明确交付项
|
||||
|
||||
1. **Agent Benchmark**:复用已有 Agent Runtime/Trace 实现标准 Case 和报告,不以 mock 压测脚本替代任务成功率验收。
|
||||
2. **函数图像前端接入**:接入编辑器、只读 Markdown/聊天、源码/预览、编辑刷新、缩放及主题。复用后端语法和资源预算,不新增不一致语法。
|
||||
3. **导出前端闭环**:工作区格式/选项入口、后台任务状态、取消、下载、warning 呈现,支持未保存内容快照。
|
||||
4. **Mermaid 静态导出**:安全传递 SVG/Image,补齐三格式产物检查;DOCX 函数图像和公式/图片保真度随后逐项补齐。
|
||||
5. **检索质量验收**:扩充当前仅 5 案例的 Dataset,冻结语料、模型、维度、参数与回退策略,保存 FTS/vector/hybrid/RRF/rerank 的对照报告。
|
||||
|
||||
### 有外部依赖的验收
|
||||
|
||||
- 真实 Provider:准备各协议对应账号/模型矩阵与用量预算,逐项记录结果;连接测试不能代替工具、取消、错误与上下文专项。
|
||||
- 音频质量:准备人工参考文本、说话人及时间标注,明确 CER/WER/DER 和业务阈值。替代技术路径是否满足原始 DoD 应单独确认。
|
||||
|
||||
### 本阶段可继续优化
|
||||
|
||||
- 单独复测 200 并发 Agent 的事件循环峰值,定位批量持久化/事件分发热点。
|
||||
- 构建仍含两份 KaTeX 运行时与较大 Mermaid/语法资源块;按兼容性和首屏加载数据优化,不通过调高阈值隐藏问题。
|
||||
- Mermaid 矩阵循环复用全局主题,结束后页面保留最后一个主题;36/36 只能证明结构检查。视觉验收应使用 `?theme=` 分主题页面或隔离容器并保存截图。
|
||||
- 清理历史文档状态冲突:团队清单未勾选已有功能;第三阶段规划仍有“安装记录仅内存、待持久化”的旧描述;契约含旧 HashEmbedding 基线,应统一标明历史与当前实现。
|
||||
|
||||
## 与第三阶段的边界
|
||||
|
||||
Tauri/Rust Host、独立 Sync Server、联网主题/Skill/Plugin/MCP 等社区、签名分发、生产沙箱及跨设备冲突处理,仍按[第三阶段规划](../architecture/第三阶段实施规划.md)推进,不计入本轮第二阶段代码缺陷。第二阶段要求的是本地扩展、包格式与稳定接口;不能将“社区格式已实现”写成“联网社区已交付”。
|
||||
|
||||
## 复测与本地证据
|
||||
|
||||
```powershell
|
||||
cd backend
|
||||
uv sync --frozen
|
||||
# 先设置 APP_DATA_DIR / APP_DB_PATH / APP_VAULT_PATH 为独立临时目录
|
||||
uv run pytest tests -q -p no:cacheprovider
|
||||
|
||||
# 此脚本自行隔离运行数据,使用 mock,不调用远端 Provider
|
||||
.venv/Scripts/python.exe scripts/agent-task-stress.py --output ../.local-plans/phase2-audit-20260907/agent-task.json
|
||||
.venv/Scripts/python.exe scripts/vector-index-benchmark.py
|
||||
|
||||
cd ../frontend
|
||||
npm test -- --reporter=dot
|
||||
npm run build
|
||||
npm run dev -- --host 127.0.0.1 --port 5187 --strictPort
|
||||
```
|
||||
|
||||
浏览器入口:`/tests/visual/mermaid-matrix.html`。本轮开发服务器验证完成后关闭,不替换用户正在使用的服务。
|
||||
|
||||
本地详细证据:`.local-plans/phase2-audit-20260907/agent-task.json`、`dataset-integrity.json`。这些是本机生成的审计产物,不作为真实模型质量或生产性能承诺;本报告记录可复查的汇总结果。
|
||||
|
||||
## 用户追加验收要求:所有主题支持函数图像
|
||||
|
||||
第二阶段收尾任务必须更新所有现有主题对 function-plot 的支持,包括 light、dark、sepia、paper-moments、ocean-blue、midnight-purple,以及实际主题注册表中的其他主题。统一函数图像设计变量及导出配色映射,覆盖坐标轴、刻度、网格、多曲线、图例/标签、背景、错误提示、焦点/悬停和缩放控件。检查工作区编辑器、只读预览、AI 对话、大图窗口及适用的静态导出。不得仅在默认浅色下通过;以逐主题实际渲染和截图验证对比度、文字完整性、窄屏布局及主题切换。PDF/DOCX 如仍采用明确的打印配色策略,应保留可读性和提示,不声称复刻整个主题。
|
||||
@@ -0,0 +1,24 @@
|
||||
# 第二阶段审阅修复与主题适配
|
||||
|
||||
日期:2026-09-07。工作目录:`G:/OSProject/NotesAgent`,分支:`feat/phase2-completion`。
|
||||
|
||||
## 修复内容
|
||||
|
||||
- DOCX 静态图同时受页面可用宽度和高度约束,保留比例,避免长图超出页面;覆盖 A4、Letter 的横图与长图。
|
||||
- Agent 参数评分改为最大一对一匹配,避免宽泛参数预期抢占唯一满足具体参数预期的调用;同一调用不可重复计分。
|
||||
- 导出提交期间取消会在收到任务 ID 后取消后台任务,并读取实际状态。已经完成、失败或取消请求失败时如实显示结果。
|
||||
- Mermaid 围栏识别统一支持大小写和附加信息,包括 `Mermaid`、`mermaid title="Flow"`。
|
||||
- Benchmark 页接入共享页面、面板、表单、按钮、状态和主题变量,补充空状态、中文状态、指标格式及窄屏布局;同步更新执行轨迹浏览器检查的定位文字。
|
||||
|
||||
## 验证
|
||||
|
||||
- 后端:867 passed;1 条既有 Starlette/httpx 弃用警告。
|
||||
- 前端:81 个测试文件、445 项测试通过。
|
||||
- 前端生产构建通过;仍有较大分包提示。
|
||||
- 六主题:light、dark、sepia、paper-moments、ocean-blue、midnight-purple。分别生成空状态、报告、窄屏记录、窄屏报告截图,共 24 张;页面脚本错误与窄屏页面横向溢出均为零。
|
||||
- 主题检查脚本:`frontend/scripts/benchmark-theme-check.cjs`。需先在 5189 端口启动 Vite,并提供 Playwright 与 Edge。接口全部使用拦截测试数据,未调用模型,也不代表真实评测质量。
|
||||
- 主题检查结果:`evidence/phase2-review-20260907/theme-results.json`;完整本机截图与测试日志在 `.local-plans/phase2-review/`。
|
||||
|
||||
DOCX 本次验证包括文档内部图片尺寸和比例;未进行 Word/LibreOffice 排版截图验证。此前真实模型验收记录仍以《第二阶段收尾实现与验收-2026-09-07》为准。
|
||||
|
||||
用户已有的三份笔记修改未纳入此次修复提交。
|
||||
@@ -0,0 +1,87 @@
|
||||
# 第二阶段收尾实现与验收
|
||||
|
||||
日期:2026-09-07。实现提交 `89df10b`,开发分支 `feat/phase2-completion`,基于最新拉取的 `gitea/main` / `95095197df8b607e55144748c9be0c7f6daf00df`。本分支尚未合并、推送或发布。本记录覆盖所有成员的第二阶段工程范围,替代早期“不含杨侧验收”的当前状态口径;[此前全量审计](第二阶段全量验收报告-2026-09-07.md)保留为修复前基线。
|
||||
|
||||
## 结论
|
||||
|
||||
原审计中可在现有环境推进的主要缺项已实现:标准 Agent Benchmark 与报告页、函数图像编辑器和共用 Markdown 预览、三格式未保存快照导出、Mermaid 静态图片、DOCX 图像与受支持公式、真实 RAG 质量对照,以及现有 Provider/MCP 的受限集成验证。
|
||||
|
||||
不将本次结果写成“所有厂商、所有文档及音频质量全部通过”。未配置的 Responses/Anthropic/Ollama 真实服务、厂商最大上下文边界、DOCX Office/LibreOffice 整页视觉复核仍列为验收边界;音频质量按本轮范围排除。Tauri/Rust、联网社区、Sync Server、操作系统沙箱仍属第三阶段。
|
||||
|
||||
## DoD 矩阵
|
||||
|
||||
| 项目 | 本轮状态 | 实现与证据 |
|
||||
| --- | --- | --- |
|
||||
| Agent Benchmark | 已实现、真实运行通过 | 标准版本化 Case、正式 Runtime、真实 Trace、逐例检查及成功/选择/参数/无效调用/步骤/耗时/Token 指标;4/4 标准案例与2/2集成案例通过 |
|
||||
| RAG Benchmark 与报告页 | 已实现、真实本地模型对照完成 | 12篇自建中文工程笔记,关键词/语义改写各12题;6配置×24题×2轮,288个样本,零运行失败;冻结语料与逐例 provenance |
|
||||
| 函数图像 | 已实现、页面复核通过 | 写作/源码切换、编辑刷新、诊断、只读/AI共用组件、缩放/重置/大图、六主题宽窄屏;共享后端白名单语法与几何 |
|
||||
| 导出 UI | 已实现、浏览器下载通过 | HTML/PDF/DOCX,点击时快照、标题/纸张选项、后台状态、取消、下载和 warning;包含未保存 PHASE2-SNAPSHOT |
|
||||
| 静态内容导出 | 已实现、产物检查通过 | Mermaid 浏览器净化后栅格化,按源码摘要绑定PNG;HTML/PDF/DOCX真实嵌图;DOCX函数图、MathText公式及Vault内PNG/JPEG/WebP;无法表示的资源显式warning |
|
||||
| 主题与 Mermaid | 已复核 | light/dark/sepia/paper-moments/ocean-blue/midnight-purple;36组Mermaid用隔离iframe检查,无全局主题串扰 |
|
||||
| Provider | 已配置协议通过 | DeepSeek OpenAI Compatible:发现、普通、流式、推理事件、Tool往返、Usage、取消与错误映射;额外上下文压缩及缓存字段观测 |
|
||||
| MCP | 现有服务通过 | MiniMax Coding Plan stdio注册与Agent真实web_search调用,工具权限仍由Runtime约束 |
|
||||
| Plugin/Skill、Trace、Task、工作区 | 回归通过 | 全量回归;隔离压力测试、权限等待/取消、恢复与分页;本地持久安装已实现 |
|
||||
| 音频 | 本轮排除质量专项 | 当前Qwen3-ASR + ERes2NetV2;没有将faster-whisper/pyannote写成已交付,也未声称CER/WER/DER达标 |
|
||||
|
||||
## RAG 结果与调优决策
|
||||
|
||||
冻结模型 `hotchpotch/bekko-embedding-v1-a8m@c721113d59a1d91b447450324f51c4b3332c924a`,384维,真实已安装本地模型;LexicalReranker `lexical-v1/1`。Top K=5、RRF K=60、候选20,参数变体K=20。向量来源逐例为local,无HashEmbedding测试注入或远端失败回退。完整记录见[rag-quality.json](evidence/phase2-20260907/rag-quality.json)。
|
||||
|
||||
| 配置 | Hit@1 | Hit@5 / Recall@5 | MRR | Citation Hit Rate | P50 / P95 ms |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: |
|
||||
| FTS | .9583 | 1 / 1 | .9667 | .7500 | 9.64 / 12.20 |
|
||||
| Vector(冷暖混合) | 1 | 1 / 1 | 1 | .5833 | 4571.74 / 10284.19 |
|
||||
| Hybrid 加权50/50 | .9583 | 1 / 1 | .9688 | .6667 | 15.44 / 19.69 |
|
||||
| Hybrid RRF | .9167 | 1 / 1 | .9583 | .6667 | 15.44 / 20.28 |
|
||||
| RRF + Lexical rerank | .9167 | 1 / 1 | .9583 | .8750 | 15.70 / 22.26 |
|
||||
| RRF K=20 | .9167 | 1 / 1 | .9583 | .6667 | 15.68 / 27.67 |
|
||||
|
||||
Citation Hit Rate判断首条返回块是否命中预期引用块,不是“任意返回块命中”。因此高笔记命中率不等于准确引用。数据属于开发集,不是生产分布或留出测试集;没有事后补造质量阈值来宣布生产达标。
|
||||
|
||||
RRF K=20没有收益,保留默认60;加权融合提供可选对照,不改变默认策略。Lexical rerank改善本集引用命中,却未提高笔记Top1,不能宣布通用质量提升。
|
||||
|
||||
瓶颈是重复查询反复启动本地Embedding推理。新增128条/10分钟进程内缓存,仅存真实本地单文本向量,键包含模型目录/revision、冻结运行配置与查询摘要;返回副本,未安装模型不走缓存,不缓存远端响应。Vector的24个miss P50=9474.32ms,24个hit P50=15.11ms。后续Hybrid配置大多复用暖缓存,其时延不能与冷Vector直接比较。改前无缓存Vector P50约9368.46ms;冷推理没有提速。保留本地进程长期驻留优化作为后续独立设计,不扩大本轮范围。
|
||||
|
||||
## 真实 Provider、Agent、MCP
|
||||
|
||||
[标准报告](evidence/phase2-20260907/live.json):现有DeepSeek `deepseek-v4-flash`,4/4任务成功,工具选择/参数准确率均1,无效调用率0,平均1.75步、5682.33ms,5888 Token。发现3个模型;流包含TextDelta/ThinkingDelta/Usage/Done;无效模型映射PROVIDER_INVALID_REQUEST。取消关闭客户端流约12.16ms,不等于能观测到服务商停止计费。
|
||||
|
||||
另通过实际Benchmark页面启动、下载与Trace跳转复测,4/4成功、5939 Token、平均4646.52ms,见[UI运行报告](evidence/phase2-20260907/agent-ui-report.json)。修复了未打开工作区时无法从报告进入已有Trace的路由拦截;创建普通新Agent运行仍遵守原工作区入口约束。
|
||||
|
||||
[集成报告](evidence/phase2-20260907/integration.json):检索→读笔记→3个任务创建,以及MCP web_search,共2/2通过,工具6/6匹配,16070 Token。写入仅针对隔离数据库的3个任务,按这次运行的权限票据逐项批准。报告保留真实Agent Run ID,可在保留隔离数据库时恢复Trace;Benchmark报告本身是有界内存任务,应下载保存,重启不会恢复评测注册表。
|
||||
|
||||
[上下文复测](evidence/phase2-20260907/context.json):不修改已保存Provider配置,在请求级测试配置中设8192窗口;detect在网络前返回CONTEXT_COMPRESSION_REQUIRED,compress调用真实模型生成摘要再回答,估算从2262降至516,原会话不变,最终答案保留Vault事实。首次256输出Token预算耗尽,见[失败记录](evidence/phase2-20260907/context-budget256.json);改用1024上限后最终回答使用289输出Token并通过。四次受限调用包含两轮摘要及回答。该测试验证应用的预算与压缩路径,不证明厂商最大窗口。
|
||||
|
||||
验收库中18个该Provider请求上报缓存计数,13个有正命中、18个有正未命中;缺失字段保持未知,不记作0。缓存是否命中由服务商控制,不能强制保证。没有新增账号、密钥或服务,没有打印凭据或请求认证信息。
|
||||
|
||||
外部剩余项:当前未配置OpenAI Responses、Anthropic Messages、Ollama真实服务,协议单测通过仍不足以替代真实服务矩阵;厂商极限上下文需要单独预算与目标窗口。本轮没有发出超大输入探测。
|
||||
|
||||
## 性能与回归
|
||||
|
||||
- 后端全量与前端测试/构建结果以本报告末尾最终验证记录为准。
|
||||
- [串行压力基线](evidence/phase2-20260907/agent-task-before.json)与[修复后](evidence/phase2-20260907/agent-task-after.json):200并发均完成,事件顺序、回放和终态恢复正确。恢复读取约313ms移出事件循环;该场景loop lag max从326.14降到12.19ms。运行P95从1007.22到1155.52ms,没有声称模型或总吞吐提速。
|
||||
- 200活跃运行容量拒绝与取消通过,取消P95=5.85ms;权限等待10批准/10取消、订阅释放,故障隔离后无残余工具执行器。
|
||||
- Task API 100/1000条、20并发CRUD及50条分页完整;1000条创建/更新/删除P95约121.67/146.20/145.87ms。此脚本显式mock,不能代替真实Agent Benchmark。
|
||||
- KaTeX通过Vite dedupe合为一个运行时chunk;Mermaid/语法等大惰性资源仍有构建提示,未调高阈值隐藏问题。
|
||||
|
||||
## 产物与视觉复核边界
|
||||
|
||||
浏览器真实加载项目组件与API,六主题各保存宽屏、390px窄屏和大图截图,检查缩放、源码切换、实时编辑、主题切换、无效表达式及打印warning。使用受限复杂图文档实际从UI取消后台PDF任务并看到已取消终态。[页面结果](evidence/phase2-20260907/browser-results.json)、[Mermaid矩阵](evidence/phase2-20260907/browser-mermaid-matrix.json)。窄屏函数图保留640px坐标区域并可横向滚动,避免刻度被整体缩得过小。
|
||||
|
||||
PDF使用嵌入的可用CJK字体,已通过Poppler检查实际页面;未安装可用字体时保留CID回退并明确warning。可用APP_EXPORT_FONT指定兼容TTF/TTC。三格式包含未保存快照、函数图、中文Mermaid及公式;DOCX的OOXML、图片关系和媒体内容通过结构检查,参见[产物清单](evidence/phase2-20260907/artifacts.json)。捆绑文档渲染器因缺少LibreOffice `soffice.exe` 无法完成DOCX整页渲染,不能声称Word分页已视觉通过。没有改用用户安装的Office/LibreOffice绕过工具约束。
|
||||
|
||||
MathText是受限TeX子集,不等同于KaTeX全语法;不支持的公式、远端/越界图片明确保留源码或替代文字及warning。服务器不执行客户端SVG/XML、任意TeX命令或外部资源下载。PNG每图4百万像素、总16百万像素/8MiB、最多64附件;重复引用及本地自动补全资源也受文档级累计预算约束;前端最多准备16幅Mermaid。函数图每块16表达式、预览总8000节点,导出每文档16图/8000节点,预览并发2。取消在线程渲染边界生效,不能强杀正在运行的Python绘图线程。
|
||||
|
||||
## 复现
|
||||
|
||||
1. `uv sync --frozen` 与 `pnpm install --frozen-lockfile`;把APP_DATA_DIR/APP_DB_PATH/APP_VAULT_PATH指向全新验收目录,不能指向用户知识库。
|
||||
2. 仅在用户授权下复用已有Provider凭据及模型配置;APP_MODEL_PYTHON指向已安装模型解释器。语料源为`backend/data/benchmarks/corpus/phase2-v1.json`;运行`backend/scripts/phase2-quality.py --output <report.json>`,生成独立Vault和Dataset、重建真实索引。`--reuse-index`会验证块ID一致,不能跳过缺失索引。
|
||||
3. 标准真实测试:`backend/scripts/phase2-live.py --provider <已有ID> --output <report.json> --execute`;上下文`phase2-context.py`同参数。写任务/MCP测试用`phase2-integration.py --execute`,先查脚本中隔离端口与现有工具ID;不能复制到生产Vault直接执行。
|
||||
4. API监听8017,前端以NOTES_API_TARGET=http://127.0.0.1:8017在5187启动。设置NODE_PATH到含Playwright的运行库后执行`node frontend/scripts/phase2-browser-check.cjs <输出目录>`,访问`/tests/visual/phase2.html`及隔离Mermaid矩阵。
|
||||
5. `backend/.venv/Scripts/python -m pytest backend/tests -q -p no:cacheprovider`;前端目录`pnpm test -- --reporter=dot`、`pnpm build`。压力脚本`backend/scripts/agent-task-stress.py --output <report.json>`自建隔离数据。
|
||||
|
||||
浏览器产物及完整测试日志保留于本机`.local-plans/phase2-completion/`;可复查的配置、指标与逐例证据已提交文档目录,不依赖该忽略目录才能读到结果。主工作区两份原有未提交笔记未修改。
|
||||
|
||||
## 最终验证记录
|
||||
|
||||
后端861测试通过(Python 3.13.9,48.32s),含HTTP/OpenAPI、预览累计复杂度、重复资源预算、导出、评分、取消与缓存回归;仅1条既有Starlette/httpx弃用提示。前端80文件437测试通过,vue-tsc及Vite生产构建通过;KaTeX仅一个261.18kB运行时chunk(gzip77.76kB),大惰性分块警告仍保留。
|
||||
@@ -1,5 +1,7 @@
|
||||
# NotesAgent Frontend
|
||||
|
||||
> 第二阶段收尾:标准 Agent/RAG Benchmark 与报告页、函数图预览、三格式快照导出及真实 Provider/MCP 结果见[实现与验收记录](../docs/development/第二阶段收尾实现与验收-2026-09-07.md)。当前分支尚未合并,不更改下文历史 main 基线。
|
||||
|
||||
NotesAgent Frontend 是基于 Vue 3、TypeScript、Vite、Pinia、Vue Router、Milkdown 和 CodeMirror 6 的 Web 联调前端。当前页面调用 FastAPI 真实接口,不使用业务 Mock 作为运行时回退;测试文件中的 mock 只用于隔离单元和组件测试。
|
||||
|
||||
## 初始化与运行
|
||||
@@ -19,6 +21,7 @@ pnpm dev
|
||||
| `/search` | 全文、向量和混合检索;从后端读取并清空搜索历史 |
|
||||
| `/chat` | 流式 AI 对话、知识库上下文与 Citation |
|
||||
| `/agent/runs/:runId?` | 创建 Agent 运行,查看可恢复 Trace 与 Tool/Permission 事件 |
|
||||
| `/benchmarks` | RAG/Agent 数据集、参数、真实运行、报告下载与 Trace 入口 |
|
||||
| `/media` | 上传音频、创建/取消/重试转写、修订结果并生成知识库笔记 |
|
||||
| `/tasks` | 管理用户、笔记和 Agent 产生的任务 |
|
||||
| `/extensions/skills` | Skill 安装、启停与配置 |
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Visual fixtures only: all API traffic is intercepted; no provider calls or user data.
|
||||
const {chromium}=require('playwright');const fs=require('node:fs/promises');const path=require('node:path');
|
||||
(async()=>{
|
||||
const output=path.resolve(process.argv[2]||'.local-plans/phase2-review/themes');await fs.mkdir(output,{recursive:true});
|
||||
const browser=await chromium.launch({channel:'msedge',headless:true});const results=[];
|
||||
for(const theme of ['light','dark','sepia','paper-moments','ocean-blue','midnight-purple']){
|
||||
const page=await browser.newPage({viewport:{width:1280,height:1400}});let populated=false;const errors=[];page.on('pageerror',e=>errors.push(e.message));
|
||||
const run=(id,status,progress)=>({run_id:id,kind:'rag',dataset_id:'rag-demo-v1',status,progress,error_code:status==='failed'?'BENCHMARK_RUN_FAILED':null,config_snapshot:{}});
|
||||
await page.route('**/api/**',async route=>{
|
||||
const request=route.request();const url=new URL(request.url());let body={items:[]};
|
||||
if(url.pathname==='/api/benchmarks/datasets')body={items:[{dataset_id:'rag-demo-v1',description:'视觉测试数据集',case_count:24}]};
|
||||
else if(url.pathname==='/api/providers')body={items:[]};
|
||||
else if(url.pathname==='/api/benchmarks/rag/runs'&&request.method()==='POST'){populated=true;body=run('visual-completed','completed',1)}
|
||||
else if(url.pathname==='/api/benchmarks/runs')body={items:populated?[run('visual-completed','completed',1),run('visual-running','running',.5),run('visual-failed','failed',.25)]:[]};
|
||||
else if(url.pathname.endsWith('/report'))body={metrics:{fts:{total_cases:24,hit_at_1:.875,hit_at_5:1,mrr:.9235,citation_hit_rate:.75,p50_latency_ms:12.43,p95_latency_ms:22.16,failed_cases:0}},cases:[],config_snapshot:{fixture:true}};
|
||||
else if(url.pathname==='/status')body={status:'ready'};
|
||||
await route.fulfill({json:body});
|
||||
});
|
||||
await page.goto('http://127.0.0.1:5189/#/benchmarks');await page.getByText('还没有评测记录',{exact:true}).waitFor();
|
||||
await page.evaluate(async id=>{const{useThemeStore}=await import('/src/stores/theme.ts');const store=useThemeStore();if(!['light','dark','sepia'].includes(id))await store.installCommunityTheme(id);if(!store.applyTheme(id,{persist:false}))throw Error('theme failed')},theme);
|
||||
await page.screenshot({path:path.join(output,`${theme}-empty.png`)});
|
||||
await page.getByRole('button',{name:'运行评测',exact:true}).click();
|
||||
await page.getByRole('button',{name:'查看报告',exact:true}).first().waitFor();await page.getByRole('button',{name:'查看报告',exact:true}).first().click();
|
||||
await page.getByText('87.5%',{exact:true}).waitFor();
|
||||
await page.locator('#benchmark-topk').focus();
|
||||
await page.screenshot({path:path.join(output,`${theme}-report.png`)});
|
||||
const colors=await page.evaluate(()=>{const read=selector=>{const s=getComputedStyle(document.querySelector(selector));return {background:s.backgroundColor,color:s.color,border:s.borderColor}};return {panel:read('.benchmark-config'),input:read('#benchmark-topk'),button:read('.benchmark-config .button-primary'),metric:read('.metric-card')}});
|
||||
await page.setViewportSize({width:390,height:1100});await page.screenshot({path:path.join(output,`${theme}-narrow.png`)});
|
||||
const overflow=await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth);
|
||||
await page.locator('.benchmark-report').scrollIntoViewIfNeeded();await page.screenshot({path:path.join(output,`${theme}-narrow-report.png`)});
|
||||
if(errors.length||overflow)throw Error(JSON.stringify({theme,errors,overflow}));results.push({theme,errors,overflow,colors});await page.close();
|
||||
}
|
||||
await fs.writeFile(path.join(output,'results.json'),JSON.stringify(results,null,2));await browser.close();console.log(JSON.stringify(results));
|
||||
})().catch(e=>{console.error(e);process.exit(1)});
|
||||
@@ -0,0 +1,22 @@
|
||||
const {chromium}=require('playwright');const fs=require('node:fs/promises');const path=require('node:path');
|
||||
(async()=>{
|
||||
if(!process.argv.includes('--execute')&&!process.argv.includes('--reuse'))throw Error('--execute required; four real Agent cases use existing quota');
|
||||
const output=path.resolve('.local-plans/phase2-completion/browser');
|
||||
const browser=await chromium.launch({channel:'msedge',headless:true});
|
||||
const page=await browser.newPage({viewport:{width:1300,height:1000}});
|
||||
await page.goto('http://127.0.0.1:5187/#/benchmarks');
|
||||
await page.getByLabel(/^类型/).selectOption('agent');
|
||||
await page.getByLabel(/^数据集/).selectOption('agent-core-v1');
|
||||
if(!process.argv.includes('--reuse')) await page.getByRole('button',{name:'运行评测',exact:true}).click();
|
||||
const row=page.locator('tbody tr').filter({hasText:'agent-core-v1'}).first();
|
||||
await row.getByRole('button',{name:'查看报告'}).waitFor({timeout:180000});
|
||||
await row.getByRole('button',{name:'查看报告'}).click();
|
||||
await page.getByRole('heading',{name:'评测报告'}).waitFor();
|
||||
await page.screenshot({path:path.join(output,'benchmark-report.png'),fullPage:true});
|
||||
const pending=page.waitForEvent('download');await page.getByRole('button',{name:'下载完整 JSON'}).click();
|
||||
await(await pending).saveAs(path.join(output,'agent-ui-report.json'));
|
||||
await row.getByRole('link',{name:'执行轨迹'}).click();
|
||||
await page.waitForSelector('.agent-page .trace-visualization',{timeout:20000});
|
||||
await page.screenshot({path:path.join(output,'benchmark-trace.png'),fullPage:true});
|
||||
await browser.close();console.log('Benchmark UI start/report/download/Trace completed');
|
||||
})().catch(e=>{console.error(e);process.exit(1)})
|
||||
@@ -0,0 +1,94 @@
|
||||
// Run with NODE_PATH pointing to the bundled Playwright package, or a local install.
|
||||
const { chromium } = require('playwright')
|
||||
const fs = require('node:fs/promises')
|
||||
const path = require('node:path')
|
||||
;(async () => {
|
||||
const output=path.resolve(process.argv[2] || '../.local-plans/phase2-completion/browser')
|
||||
await fs.mkdir(output,{recursive:true})
|
||||
const browser=await chromium.launch({channel:'msedge',headless:true})
|
||||
const results=[]
|
||||
for(const theme of ['light','dark','sepia','paper-moments','ocean-blue','midnight-purple']) {
|
||||
const page=await browser.newPage({viewport:{width:1200,height:1000}})
|
||||
const errors=[];page.on('pageerror',e=>errors.push(e.message))
|
||||
await page.goto(`http://127.0.0.1:5187/tests/visual/phase2.html?theme=${theme}`)
|
||||
await page.waitForSelector('.editor-mermaid-preview polyline',{timeout:60000})
|
||||
await page.waitForSelector('.markdown-mermaid polyline',{timeout:60000})
|
||||
const zoom=page.locator('.markdown-mermaid').first()
|
||||
await zoom.hover()
|
||||
await zoom.locator('[data-diagram-action="in"]').click()
|
||||
if(Number(await zoom.getAttribute('data-diagram-scale'))<=1) throw Error('zoom failed')
|
||||
await zoom.locator('[data-diagram-action="reset"]').click()
|
||||
await zoom.locator('[data-code-action="source"]').click()
|
||||
if(!(await zoom.locator('.markdown-code-source').isVisible())) throw Error('source toggle failed')
|
||||
await zoom.locator('[data-code-action="source"]').click()
|
||||
await zoom.hover()
|
||||
await zoom.locator('[data-diagram-action="view"]').click()
|
||||
await page.screenshot({path:path.join(output,`${theme}-viewer.png`)})
|
||||
await page.keyboard.press('Escape')
|
||||
await page.screenshot({path:path.join(output,`${theme}-wide.png`),fullPage:true})
|
||||
await page.setViewportSize({width:390,height:844})
|
||||
await page.screenshot({path:path.join(output,`${theme}-narrow.png`),fullPage:true})
|
||||
if(theme==='light') {
|
||||
await page.setViewportSize({width:1200,height:1000})
|
||||
await page.getByRole('button',{name:'导出',exact:true}).click()
|
||||
for(const format of ['html','pdf','docx']) {
|
||||
await page.getByLabel('格式',{exact:true}).selectOption(format)
|
||||
await page.getByRole('button',{name:'开始导出',exact:true}).click()
|
||||
const row=page.locator('.export-modal > ul > li').filter({hasText:`phase2-demo.${format}`}).first()
|
||||
await row.getByRole('button',{name:'下载',exact:true}).waitFor({timeout:60000})
|
||||
const download=page.waitForEvent('download')
|
||||
await row.getByRole('button',{name:'下载',exact:true}).click()
|
||||
await (await download).saveAs(path.join(output,`demo.${format}`))
|
||||
}
|
||||
await page.screenshot({path:path.join(output,'export-jobs.png')})
|
||||
}
|
||||
if(theme==='light') {
|
||||
await page.getByRole('button',{name:'关闭',exact:true}).click()
|
||||
await page.getByRole('button',{name:'源码',exact:true}).click()
|
||||
const input=page.getByRole('textbox',{name:'Markdown 源码编辑器'})
|
||||
const original=await input.inputValue()
|
||||
await input.fill(original.replace('y = x^2','y = cos(x)'))
|
||||
await page.getByRole('button',{name:'写作',exact:true}).click()
|
||||
await page.waitForFunction(()=>document.querySelector('.editor-mermaid-preview svg')?.textContent.includes('cos(x)'))
|
||||
await page.getByRole('button',{name:'测试切换主题'}).click()
|
||||
await page.waitForFunction(()=>document.documentElement.dataset.theme==='dark')
|
||||
await page.waitForFunction(()=>document.querySelector('.editor-mermaid-preview svg rect')?.getAttribute('fill')!=='#ffffff')
|
||||
await page.getByRole('button',{name:'源码',exact:true}).click()
|
||||
await input.fill(original.replace('y = x^2','y = __import__("os")'))
|
||||
await page.getByRole('button',{name:'写作',exact:true}).click()
|
||||
await page.waitForFunction(()=>!document.querySelector('.editor-mermaid-preview polyline')&&!document.querySelector('.markdown-function-plot polyline'))
|
||||
await page.screenshot({path:path.join(output,'invalid-expression.png'),fullPage:true})
|
||||
await page.getByRole('button',{name:'源码',exact:true}).click()
|
||||
await input.fill(original)
|
||||
await page.getByRole('button',{name:'写作',exact:true}).click()
|
||||
await page.waitForSelector('.editor-mermaid-preview polyline')
|
||||
await page.getByRole('button',{name:'导出',exact:true}).click()
|
||||
await page.getByLabel('格式',{exact:true}).selectOption('pdf')
|
||||
await page.getByRole('button',{name:'开始导出',exact:true}).click()
|
||||
await page.locator('.export-warnings').first().waitFor({timeout:60000})
|
||||
await page.screenshot({path:path.join(output,'print-warning.png')})
|
||||
}
|
||||
results.push({theme,errors,plotCount:await page.locator('polyline').count()});await page.close()
|
||||
}
|
||||
const cancellation=await browser.newPage()
|
||||
await cancellation.goto('http://127.0.0.1:5187/tests/visual/phase2.html')
|
||||
await cancellation.getByRole('button',{name:'源码',exact:true}).click()
|
||||
const heavy=Array(13).fill('```function-plot\ny = '+Array(150).fill('(x+x)').join('+')+'\n```').join('\n\n')
|
||||
await cancellation.getByRole('textbox',{name:'Markdown 源码编辑器'}).fill(heavy)
|
||||
await cancellation.getByRole('button',{name:'导出',exact:true}).click()
|
||||
await cancellation.getByLabel('格式',{exact:true}).selectOption('pdf')
|
||||
await cancellation.getByRole('button',{name:'开始导出',exact:true}).click()
|
||||
const cancelRow=cancellation.locator('.export-modal > ul > li').first()
|
||||
await cancelRow.getByRole('button',{name:'取消',exact:true}).click()
|
||||
await cancellation.waitForFunction(()=>document.querySelector('.export-modal > ul > li')?.textContent.includes('已取消'),null,{timeout:60000})
|
||||
await cancellation.screenshot({path:path.join(output,'export-cancelled.png')})
|
||||
await cancellation.close()
|
||||
const matrix=await browser.newPage()
|
||||
await matrix.goto('http://127.0.0.1:5187/tests/visual/mermaid-matrix.html')
|
||||
await matrix.waitForFunction(()=>document.documentElement.dataset.complete==='true',null,{timeout:120000})
|
||||
const mermaid=JSON.parse(await matrix.locator('#status').textContent())
|
||||
if(mermaid.passed!==36 || mermaid.total!==36) throw Error('Mermaid matrix failed')
|
||||
await fs.writeFile(path.join(output,'mermaid-matrix.json'),JSON.stringify(mermaid,null,2))
|
||||
await matrix.close()
|
||||
await fs.writeFile(path.join(output,'results.json'),JSON.stringify(results,null,2));console.log(JSON.stringify(results));await browser.close()
|
||||
})().catch(e=>{console.error(e);process.exit(1)})
|
||||
@@ -220,3 +220,8 @@ function close() { disarm(); viewer.value?.close(); svgHtml.value = ''; opener?.
|
||||
:is(.editor-mermaid-preview, .markdown-mermaid):is(:hover, :focus-within) > .diagram-controls { opacity: 1; pointer-events: auto; }
|
||||
@media (hover: none) { :is(.editor-mermaid-preview, .markdown-mermaid) > .diagram-controls { opacity: 1; pointer-events: auto; } }
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 窄屏仍保持 10px 坐标轴文字可读,溢出由现有图表容器滚动承接。 */
|
||||
.function-plot-preview > svg, .markdown-function-plot > svg { min-width: 640px; }
|
||||
</style>
|
||||
|
||||
@@ -24,7 +24,7 @@ const diagramTheme = computed<'light' | 'dark'>(() => (themeStore.isDark ? 'dark
|
||||
// 主题切换需要重渲染:Mermaid SVG 的配色在渲染时烘焙,无法靠 CSS 变量事后调整。
|
||||
watch([() => props.source, diagramTheme, () => themeStore.currentThemeId, () => JSON.stringify(markdownPreferences.normalized), () => JSON.stringify([props.citationNumbers, props.citationAliases])], async ([source, theme]) => {
|
||||
const version = ++renderVersion
|
||||
const result = await renderMarkdown(source, { theme, preferences: markdownPreferences.normalized, citationNumbers: props.citationNumbers, citationAliases: props.citationAliases })
|
||||
const result = await renderMarkdown(source, { theme, themeId: themeStore.currentThemeId, preferences: markdownPreferences.normalized, citationNumbers: props.citationNumbers, citationAliases: props.citationAliases })
|
||||
if (version === renderVersion) html.value = result
|
||||
}, { immediate: true, flush: 'post' })
|
||||
</script>
|
||||
|
||||
@@ -20,6 +20,7 @@ const navItems = computed(() => [
|
||||
{ name: 'plugins', icon: Connection, label: 'Plugin' },
|
||||
{ name: 'mcp-servers', icon: Monitor, label: 'MCP' },
|
||||
{ name: 'themes', icon: Brush, label: t('主题', 'Themes') },
|
||||
{ name: 'benchmarks', icon: Monitor, label: 'Benchmark' },
|
||||
{ name: 'logs', icon: Document, label: t('日志', 'Logs') },
|
||||
{ name: 'settings', icon: Setting, label: t('设置', 'Settings') },
|
||||
])
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// @vitest-environment happy-dom
|
||||
import {mount,flushPromises} from '@vue/test-utils'
|
||||
import {afterEach,beforeEach,expect,it,vi} from 'vitest'
|
||||
import BenchmarkView from './BenchmarkView.vue'
|
||||
const service=vi.hoisted(()=>({datasets:vi.fn(),list:vi.fn(),start:vi.fn(),cancel:vi.fn(),report:vi.fn()}))
|
||||
vi.mock('@/services/benchmarkService',()=>({benchmarkService:service}))
|
||||
vi.mock('@/services/providerService',()=>({listProviders:vi.fn().mockResolvedValue([])}))
|
||||
beforeEach(()=>{service.datasets.mockResolvedValue([{id:'rag-demo',cases:2}]);service.list.mockResolvedValue([])})
|
||||
afterEach(()=>vi.clearAllMocks())
|
||||
it('shows a useful empty state and submits the selected retrieval configuration',async()=>{
|
||||
const wrapper=mount(BenchmarkView,{global:{stubs:{RouterLink:true}}});await flushPromises()
|
||||
expect(wrapper.text()).toContain('还没有评测记录')
|
||||
await wrapper.get('#benchmark-fusion').setValue('weighted')
|
||||
expect(wrapper.get('#benchmark-rrfk').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('form').trigger('submit');await flushPromises()
|
||||
expect(service.start).toHaveBeenCalledWith('rag',expect.objectContaining({dataset_id:'rag-demo',retrieval:expect.objectContaining({fusion:'weighted'})}))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('renders report percentages, unavailable metrics and localized terminal states',async()=>{
|
||||
service.list.mockResolvedValue([{id:'r',datasetId:'agent-demo',status:'completed',progress:1,errorCode:null}])
|
||||
service.report.mockResolvedValue({metrics:{task_success_rate:.75,tool_argument_accuracy:null,token_usage:120},cases:[],config_snapshot:{}})
|
||||
const wrapper=mount(BenchmarkView,{global:{stubs:{RouterLink:true}}});await flushPromises()
|
||||
expect(wrapper.text()).toContain('已完成')
|
||||
await wrapper.findAll('button').find(button=>button.text()==='查看报告')!.trigger('click');await flushPromises()
|
||||
expect(wrapper.get('.benchmark-report').text()).toContain('75%')
|
||||
expect(wrapper.get('.benchmark-report').text()).toContain('不适用')
|
||||
expect(wrapper.get('.benchmark-report').text()).toContain('agent-demo')
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { benchmarkService as service, type BenchmarkRun } from '@/services/benchmarkService'
|
||||
import { listProviders } from '@/services/providerService'
|
||||
const kind = ref<'rag' | 'agent'>('rag'), dataset = ref(''), error = ref(''), busy = ref(false)
|
||||
const datasets = ref<Awaited<ReturnType<typeof service.datasets>>>([]), runs = ref<BenchmarkRun[]>([])
|
||||
const providers = ref<Awaited<ReturnType<typeof listProviders>>>([]), provider = ref(''), model = ref('')
|
||||
const report = ref<Awaited<ReturnType<typeof service.report>> | null>(null)
|
||||
const reportName = ref(''), loading = ref(true)
|
||||
const statusLabels: Record<string,string> = { queued:'排队中', running:'运行中', completed:'已完成', failed:'失败', cancelled:'已取消' }
|
||||
const activeCount = computed(() => runs.value.filter(run => ['queued','running'].includes(run.status)).length)
|
||||
const ratioKeys = new Set(['task_success_rate','tool_selection_accuracy','tool_argument_accuracy','invalid_tool_call_rate','hit_at_1','hit_at_5','recall_at_k','citation_hit_rate','failure_rate'])
|
||||
const topK = ref(5), rrfK = ref(60), rerank = ref(false)
|
||||
const fusion = ref<'rrf' | 'weighted'>('rrf')
|
||||
const metricLabels: Record<string, string> = {
|
||||
total_cases: '计划样本', evaluated_cases: '已评样本', successful_cases: '运行成功', failed_cases: '运行失败',
|
||||
task_success_rate: '任务成功率', tool_selection_accuracy: '工具选择准确率', tool_argument_accuracy: '参数准确率',
|
||||
invalid_tool_call_rate: '无效调用率', average_steps: '平均步骤', average_latency_ms: '平均耗时 (ms)',
|
||||
token_usage: 'Token 用量', tool_calls: '实际工具调用', expected_calls: '预期工具调用',
|
||||
hit_at_1: 'Hit@1', hit_at_5: 'Hit@5', recall_at_k: 'Recall@K', mrr: 'MRR', citation_hit_rate: '引用命中率',
|
||||
p50_latency_ms: 'P50 (ms)', p95_latency_ms: 'P95 (ms)', failure_rate: '运行失败率',
|
||||
}
|
||||
const metricGroups = computed(() => {
|
||||
const metrics = report.value?.metrics ?? {}
|
||||
const groups = 'task_success_rate' in metrics ? { Agent: metrics } : metrics
|
||||
return Object.entries(groups).filter(([, value]) => value && typeof value === 'object').map(([name, value]) => ({
|
||||
name, rows: Object.entries(value as Record<string, unknown>).map(([key, number]) => ({
|
||||
label: metricLabels[key] ?? key,
|
||||
value: number === null ? '不适用' : typeof number === 'number' ? ratioKeys.has(key) ? `${Number((number*100).toFixed(2))}%` : Number(number.toFixed(key.includes('latency') ? 2 : 4)).toLocaleString() : String(number),
|
||||
})),
|
||||
}))
|
||||
})
|
||||
let timer: ReturnType<typeof setTimeout> | undefined, disposed = false
|
||||
async function loadDatasets() { try { datasets.value = await service.datasets(kind.value); dataset.value = datasets.value[0]?.id ?? '' } catch(e) { error.value = String(e) } }
|
||||
async function refresh() { try { runs.value = await service.list() } catch(e) { error.value = String(e) } finally { loading.value = false } if (!disposed) timer = setTimeout(refresh, 1500) }
|
||||
watch(kind, loadDatasets)
|
||||
watch(provider, id => { model.value = providers.value.find(p => p.provider_id === id)?.default_model ?? '' })
|
||||
async function start() {
|
||||
error.value = ''; busy.value = true
|
||||
try { await service.start(kind.value, kind.value === 'agent' ? { dataset_id: dataset.value, provider_id: provider.value, model: model.value, max_steps: 6, timeout_seconds: 90, token_budget: 6000 } : { dataset_id: dataset.value, modes: ['fts','vector','hybrid'], retrieval: { top_k: topK.value, fusion: fusion.value, rrf_k: rrfK.value, rerank: rerank.value } }) }
|
||||
catch(e) { error.value = String(e) } finally { busy.value = false }
|
||||
}
|
||||
async function action(run: BenchmarkRun, cancel = false) { try { if (cancel) await service.cancel(run.id); else { report.value = await service.report(run.id); reportName.value = run.datasetId } } catch(e) { error.value = String(e) } }
|
||||
function download() { const url = URL.createObjectURL(new Blob([JSON.stringify(report.value,null,2)], { type:'application/json' })); const a=document.createElement('a'); a.href=url; a.download='benchmark-report.json'; a.click(); setTimeout(()=>URL.revokeObjectURL(url),1000) }
|
||||
onMounted(async () => { void refresh(); void loadDatasets(); try { providers.value=(await listProviders()).filter(p=>p.enabled); provider.value=providers.value[0]?.provider_id ?? '' } catch(e) { error.value=String(e) } })
|
||||
onBeforeUnmount(() => { disposed=true; clearTimeout(timer) })
|
||||
</script>
|
||||
<template>
|
||||
<main class="feature-page benchmark-page">
|
||||
<header class="feature-header">
|
||||
<div><h1>Benchmark 评测</h1><p>比较检索质量与智能体表现,查看每次评测的结果和执行轨迹。</p></div>
|
||||
<span class="badge" :class="{ info: activeCount > 0 }">{{ activeCount ? `${activeCount} 项正在运行` : 'RAG / Agent' }}</span>
|
||||
</header>
|
||||
<div class="benchmark-content">
|
||||
<form class="panel benchmark-config" @submit.prevent="start">
|
||||
<div class="section-heading"><div><h2>创建评测</h2><p class="subtle">选择数据集和运行配置,结果将保留在下方列表。</p></div></div>
|
||||
<div class="form-grid">
|
||||
<div class="field"><label for="benchmark-kind">类型</label><select id="benchmark-kind" v-model="kind" class="select"><option value="rag">RAG 检索</option><option value="agent">Agent 任务</option></select></div>
|
||||
<div class="field dataset-field"><label for="benchmark-dataset">数据集</label><select id="benchmark-dataset" v-model="dataset" class="select"><option v-if="!datasets.length" value="">暂无可用数据集</option><option v-for="d in datasets" :key="d.id" :value="d.id">{{ d.id }} · {{ d.cases }} 案例</option></select></div>
|
||||
<template v-if="kind === 'agent'">
|
||||
<div class="field"><label for="benchmark-provider">提供商</label><select id="benchmark-provider" v-model="provider" class="select"><option v-if="!providers.length" value="">暂无可用提供商</option><option v-for="p in providers" :key="p.provider_id" :value="p.provider_id">{{ p.name }}</option></select></div>
|
||||
<div class="field"><label for="benchmark-model">模型</label><input id="benchmark-model" v-model="model" class="input" placeholder="模型 ID"></div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="field"><label for="benchmark-fusion">融合方式</label><select id="benchmark-fusion" v-model="fusion" class="select"><option value="rrf">RRF 排名融合</option><option value="weighted">加权 50/50</option></select></div>
|
||||
<div class="field"><label for="benchmark-topk">Top K</label><input id="benchmark-topk" v-model.number="topK" class="input" type="number" min="1" max="100"></div>
|
||||
<div class="field"><label for="benchmark-rrfk">RRF K</label><input id="benchmark-rrfk" v-model.number="rrfK" class="input" type="number" min="1" :disabled="fusion !== 'rrf'"></div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="config-footer">
|
||||
<label v-if="kind === 'rag'" class="checkbox-label"><input v-model="rerank" type="checkbox">启用词面重排(Lexical Reranker)</label>
|
||||
<p v-else class="subtle">将使用所选提供商额度;需要工具权限时,请在执行轨迹中处理。</p>
|
||||
<button class="button-primary" :disabled="busy || !dataset || (kind === 'agent' && (!provider || !model))">{{ busy ? '正在创建…' : '运行评测' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
<p v-if="error" class="error-banner" role="alert">{{ error }}</p>
|
||||
<section class="panel benchmark-history" aria-labelledby="benchmark-history-title" :aria-busy="loading">
|
||||
<div class="section-heading"><h2 id="benchmark-history-title">运行记录</h2><span class="badge">{{ runs.length }} 项</span></div>
|
||||
<div v-if="!runs.length" class="empty-state"><div><strong>{{ loading ? '正在加载记录…' : '还没有评测记录' }}</strong><p>选择上方的数据集并运行评测,完成后可查看指标、下载报告。</p></div></div>
|
||||
<div v-else class="table-scroll"><table><thead><tr><th>数据集</th><th>状态</th><th>操作</th></tr></thead><tbody><tr v-for="run in runs" :key="run.id">
|
||||
<td><strong>{{ run.datasetId }}</strong><small class="subtle run-id">{{ run.id }}</small></td>
|
||||
<td><span class="badge" :class="{ success:run.status === 'completed', error:run.status === 'failed', info:['queued','running'].includes(run.status), warning:run.status === 'cancelled' }">{{ statusLabels[run.status] ?? run.status }}</span><span v-if="run.progress !== null" class="progress-label subtle">{{ Math.round(run.progress*100) }}%</span><small v-if="run.errorCode" class="run-error">{{ run.errorCode }}</small></td>
|
||||
<td><div class="inline-actions"><button v-if="['queued','running'].includes(run.status)" class="button-secondary" @click="action(run,true)">取消</button><button v-else class="button-secondary" @click="action(run)">查看报告</button><RouterLink v-if="run.agentId" class="trace-link" :to="`/agent/runs/${run.agentId}`">执行轨迹</RouterLink></div></td>
|
||||
</tr></tbody></table></div>
|
||||
</section>
|
||||
<section v-if="report" class="panel benchmark-report" aria-labelledby="benchmark-report-title">
|
||||
<div class="section-heading"><div><h2 id="benchmark-report-title">评测报告</h2><p class="subtle">{{ reportName }}</p></div><button class="button-secondary" @click="download">下载完整 JSON</button></div>
|
||||
<div v-for="group in metricGroups" :key="group.name" class="metric-group"><h3>{{ group.name }}</h3><dl class="metric-grid"><div v-for="row in group.rows" :key="row.label" class="metric-card"><dt>{{ row.label }}</dt><dd>{{ row.value }}</dd></div></dl></div>
|
||||
<details class="ui-disclosure"><summary>冻结配置与逐例证据</summary><pre>{{ JSON.stringify(report,null,2) }}</pre></details>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
<style scoped>
|
||||
.benchmark-page { width:100%; min-width:0; color:var(--color-text-primary); }
|
||||
.benchmark-content { max-width:1180px; margin:0 auto; display:grid; gap:var(--space-xl); }
|
||||
.benchmark-content > .panel { width:100%; min-width:0; margin:0; padding:var(--space-xl); }
|
||||
.section-heading { display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom:var(--space-lg); }
|
||||
h2 { margin:0; font-size:var(--font-size-lg); font-weight:650; } h3 { margin:0 0 var(--space-md); font-size:var(--font-size-md); }
|
||||
.section-heading p { margin:var(--space-xs) 0 0; } .form-grid { grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); }
|
||||
.dataset-field { grid-column:span 2; } .field { min-width:0; }
|
||||
.config-footer { display:flex; align-items:center; justify-content:space-between; gap:var(--space-lg); margin-top:var(--space-xl); padding-top:var(--space-lg); border-top:1px solid var(--color-border-default); }
|
||||
.config-footer p { margin:0; } .config-footer .button-primary { margin-left:auto; flex-shrink:0; }
|
||||
.checkbox-label { display:flex; align-items:center; gap:var(--space-sm); color:var(--color-text-secondary); font-size:var(--font-size-sm); }
|
||||
.empty-state { min-height:170px; } .empty-state p { margin:0; line-height:1.7; }
|
||||
.table-scroll { overflow-x:auto; } table { width:100%; min-width:580px; border-collapse:collapse; font-size:var(--font-size-sm); }
|
||||
th { text-align:left; color:var(--color-text-secondary); background:var(--color-background-secondary); font-weight:600; }
|
||||
td,th { padding:var(--space-md); border-bottom:1px solid var(--color-border-default); } tbody tr:last-child td { border-bottom:0; }
|
||||
.run-id,.run-error { display:block; margin-top:var(--space-xs); overflow-wrap:anywhere; } .run-error { color:var(--color-error); }
|
||||
.progress-label { margin-left:var(--space-sm); } .trace-link { color:var(--color-accent-primary); text-decoration:none; font-weight:600; } .trace-link:hover { text-decoration:underline; }
|
||||
.metric-group + .metric-group { margin-top:var(--space-xl); }
|
||||
.metric-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:var(--space-md); margin:0 0 var(--space-xl); }
|
||||
.metric-card { padding:var(--space-lg); border:1px solid var(--color-border-default); border-radius:var(--radius-md); background:var(--color-background-secondary); }
|
||||
dt { font-size:var(--font-size-sm); color:var(--color-text-secondary); } dd { margin:var(--space-sm) 0 0; font-size:var(--font-size-2xl); font-weight:650; font-variant-numeric:tabular-nums; }
|
||||
pre { padding:var(--space-md); border-radius:var(--radius-md); background:var(--color-background-secondary); color:var(--color-text-primary); white-space:pre-wrap; overflow-wrap:anywhere; font-family:var(--font-editor-mono); font-size:var(--font-size-sm); }
|
||||
.error-banner { margin:0; }
|
||||
@media(max-width:640px) {
|
||||
.benchmark-content > .panel { padding:var(--space-lg); }
|
||||
.form-grid { grid-template-columns:minmax(0,1fr); } .dataset-field { grid-column:auto; }
|
||||
.section-heading,.config-footer { align-items:flex-start; flex-wrap:wrap; } .config-footer .button-primary { width:100%; }
|
||||
.metric-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } dd { font-size:var(--font-size-xl); }
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import ExportDialog from './ExportDialog.vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import ActionDialog from '@/components/common/ActionDialog.vue'
|
||||
import { useActionDialog } from '@/composables/useActionDialog'
|
||||
@@ -10,6 +11,7 @@ const editorStore = useEditorStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
|
||||
const reloadError = ref('')
|
||||
const exportOpen = ref(false)
|
||||
const needsRecovery = computed(() => ['conflict', 'external_changed'].includes(editorStore.saveStatus))
|
||||
const missingFile = computed(() => needsRecovery.value && editorStore.currentFilePath === workspaceStore.activeFilePath && !workspaceStore.activeFile && !workspaceStore.treeRefreshError)
|
||||
function downloadCopy() {
|
||||
@@ -43,9 +45,11 @@ const statusText = computed<Record<string, string>>(() => ({
|
||||
|
||||
<template>
|
||||
<header class="editor-header">
|
||||
<ExportDialog v-if="exportOpen" @close="exportOpen = false" />
|
||||
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
|
||||
<div class="file-identity"><strong>{{ workspaceStore.activeFile?.name ?? t('未命名笔记', 'Untitled note') }}</strong><small>{{ workspaceStore.activeFilePath }}</small></div>
|
||||
<div class="editor-actions">
|
||||
<button class="button-secondary" @click="exportOpen = true">{{ t('导出', 'Export') }}</button>
|
||||
<span class="save-status" :class="editorStore.saveStatus">{{ statusText[editorStore.saveStatus] }}</span>
|
||||
<button v-if="needsRecovery && !missingFile" class="button-secondary" @click="reload">{{ t('重新加载外部版本', 'Reload external version') }}</button>
|
||||
<span v-if="missingFile" class="save-status conflict">{{ t('原文件已删除或移动', 'Original file deleted or moved') }}</span>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// @vitest-environment jsdom
|
||||
import {mount,flushPromises} from '@vue/test-utils'
|
||||
import {it,expect,vi} from 'vitest'
|
||||
import ExportDialog from './ExportDialog.vue'
|
||||
import {apiClient} from '@/services/apiClient'
|
||||
vi.mock('@/stores/editor',()=>({useEditorStore:()=>({content:'# snapshot',currentFilePath:'note.md'})}))
|
||||
vi.mock('@/stores/theme',()=>({useThemeStore:()=>({currentThemeId:'light'})}))
|
||||
vi.mock('@/services/mermaidService',()=>({renderMermaid:vi.fn()}))
|
||||
vi.mock('@/services/apiClient',()=>({apiClient:{post:vi.fn(),get:vi.fn()}}))
|
||||
it('closing the dialog after submitting preserves the background export',async()=>{
|
||||
let finish!:(value:unknown)=>void
|
||||
vi.mocked(apiClient.get).mockImplementation(async(path:string)=>path==='/api/exports'?{items:[]}:{job_id:'closing-job',status:'cancelled',warnings:[],error:null,file:null})
|
||||
vi.mocked(apiClient.post).mockImplementationOnce(()=>new Promise(resolve=>{finish=resolve}) as never).mockResolvedValue({status:'completed'})
|
||||
const wrapper=mount(ExportDialog,{global:{stubs:{AppDialog:{template:'<div><slot /></div>'}}}})
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find(b=>b.text()==='开始导出')!.trigger('click')
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/api/exports',expect.anything())
|
||||
wrapper.unmount()
|
||||
finish({job_id:'closing-job',status:'queued',warnings:[],error:null,file:null})
|
||||
await flushPromises()
|
||||
expect(apiClient.post).not.toHaveBeenCalledWith('/api/exports/closing-job/cancel')
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import AppDialog from '@/components/common/AppDialog.vue'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { exportService, captureExportPalette, type ExportFormat, type ExportJob } from '@/services/exportService'
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
const editor = useEditorStore(), theme = useThemeStore()
|
||||
const format = ref<ExportFormat>('html'), page = ref('A4'), title = ref(true)
|
||||
const jobs = ref<ExportJob[]>([]), error = ref(''), preparing = ref(false)
|
||||
let timer: ReturnType<typeof setTimeout> | undefined, disposed = false
|
||||
let controller: AbortController | undefined
|
||||
const labels = { queued: '排队中', running: '渲染中', completed: '已完成', failed: '失败', cancelled: '已取消' }
|
||||
async function refresh() {
|
||||
try { const value = await exportService.list(); if (!disposed) jobs.value = value } catch (e) { error.value = String(e) }
|
||||
if (!disposed) timer = setTimeout(refresh, 1500)
|
||||
}
|
||||
async function start() {
|
||||
preparing.value = true; error.value = ''; controller = new AbortController()
|
||||
const snapshot = editor.content, name = editor.currentFilePath?.split('/').pop()?.replace(/\.md$/i, '') ?? '笔记'
|
||||
try {
|
||||
const job = await exportService.create(snapshot, name, format.value, { theme_id: theme.currentThemeId, include_title: title.value, page_size: page.value, palette: captureExportPalette() }, controller.signal, editor.currentFilePath ?? undefined)
|
||||
if (!disposed) jobs.value.unshift(job)
|
||||
} catch (e) { error.value = e instanceof DOMException && e.name === 'AbortError' ? '已取消导出' : String(e) }
|
||||
finally { preparing.value = false }
|
||||
}
|
||||
async function action(job: ExportJob, download = false) {
|
||||
try { if (download) await exportService.download(job); else await exportService.cancel(job.id) } catch (e) { error.value = String(e) }
|
||||
}
|
||||
onMounted(refresh)
|
||||
onBeforeUnmount(() => { disposed = true; clearTimeout(timer) })
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog label="导出笔记" @close="emit('close')"><section class="modal export-modal">
|
||||
<h2>导出笔记</h2><p>导出点击时的编辑器快照,包含未保存修改。关闭窗口后后台任务继续运行。</p>
|
||||
<label for="export-format">格式</label><select id="export-format" v-model="format"><option value="html">HTML</option><option value="pdf">PDF</option><option value="docx">DOCX</option></select>
|
||||
<label>纸张 <select v-model="page"><option>A4</option><option>Letter</option></select></label>
|
||||
<label><input v-model="title" type="checkbox">包含标题</label>
|
||||
<p v-if="format === 'docx'">DOCX 使用浅色打印样式。</p>
|
||||
<p v-if="format === 'pdf'">PDF 使用当前笔记主题与排版样式。</p>
|
||||
<button class="button-primary" :disabled="preparing || !editor.content.trim()" @click="start">{{ preparing ? '准备图表…' : '开始导出' }}</button>
|
||||
<button v-if="preparing" @click="controller?.abort()">取消准备</button>
|
||||
<p v-if="error" role="alert">{{ error }}</p>
|
||||
<ul><li v-for="job in jobs" :key="job.id">
|
||||
<strong>{{ job.fileName ?? job.id }}</strong> · {{ labels[job.status] }}
|
||||
<button v-if="job.status === 'completed'" @click="action(job, true)">下载</button>
|
||||
<button v-if="['queued','running'].includes(job.status)" @click="action(job)">取消</button>
|
||||
<p v-if="job.error" role="alert">{{ job.error }}</p>
|
||||
<ul v-if="job.warnings.length" class="export-warnings" aria-label="导出警告"><li v-for="warning in job.warnings" :key="warning">{{ warning }}</li></ul>
|
||||
</li></ul>
|
||||
<button class="button-secondary" @click="emit('close')">关闭</button>
|
||||
</section></AppDialog>
|
||||
</template>
|
||||
<style scoped>
|
||||
.export-modal { width: min(640px, 100%); padding: 24px; background: var(--color-surface-primary); border: 1px solid var(--color-border-default); border-radius: 12px; }
|
||||
label { display: inline-flex; align-items:center; gap: 8px; margin: 8px; } li { margin-block: 12px; overflow-wrap: anywhere; } button { margin: 6px; } .export-warnings { color: var(--color-warning); } [role=alert] { color:var(--color-error); }
|
||||
</style>
|
||||
@@ -156,20 +156,20 @@ function foldHeadings(action: 'toggle' | 'all' | 'none') {
|
||||
}
|
||||
})
|
||||
}
|
||||
const diagramPreviews = new Map<string, { source: string; apply: (value: HTMLElement) => void }>()
|
||||
function renderDiagram(source: string, apply: (value: HTMLElement) => void) {
|
||||
const diagramPreviews = new Map<string, { source: string; kind: string; apply: (value: HTMLElement) => void }>()
|
||||
function renderDiagram(source: string, apply: (value: HTMLElement) => void, kind = 'mermaid') {
|
||||
for (const [id, entry] of diagramPreviews) {
|
||||
if (entry.apply === apply) diagramPreviews.delete(id)
|
||||
}
|
||||
const element = createMermaidPreview(source, themeStore.isDark, apply)
|
||||
diagramPreviews.set(element.dataset.previewId!, { source, apply })
|
||||
const element = createMermaidPreview(source, themeStore.isDark, apply, kind, themeStore.currentThemeId)
|
||||
diagramPreviews.set(element.dataset.previewId!, { source, apply, kind })
|
||||
return element
|
||||
}
|
||||
watch(() => themeStore.currentThemeId, () => {
|
||||
const current = [...diagramPreviews.entries()]
|
||||
diagramPreviews.clear()
|
||||
for (const [id, entry] of current) {
|
||||
if (editorRoot.value?.querySelector(`[id="${id}"]`)) entry.apply(renderDiagram(entry.source, entry.apply))
|
||||
if (editorRoot.value?.querySelector(`[id="${id}"]`)) entry.apply(renderDiagram(entry.source, entry.apply, entry.kind))
|
||||
}
|
||||
}, { flush: 'post' })
|
||||
|
||||
@@ -333,8 +333,8 @@ onMounted(async () => {
|
||||
...config,
|
||||
languages: shikiLanguages(themeStore.resolvedCodeBlockTheme),
|
||||
renderLanguage: renderCodeLanguage,
|
||||
renderPreview: (language, content, applyPreview) => language.trim().toLowerCase() === 'mermaid'
|
||||
? markdownPreferences.diagrams ? renderDiagram(content, applyPreview) : null
|
||||
renderPreview: (language, content, applyPreview) => ['mermaid', 'function-plot'].includes(language.trim().toLowerCase())
|
||||
? markdownPreferences.diagrams ? renderDiagram(content, applyPreview, language.trim().toLowerCase()) : null
|
||||
: config.renderPreview(language, content, applyPreview),
|
||||
extensions: [basicSetup, keymap.of([indentWithTab]), shikiEditorTheme(themeStore.resolvedCodeBlockTheme),
|
||||
indentUnit.of(' '.repeat(markdownPreferences.indent)), CodeEditorState.tabSize.of(markdownPreferences.indent),
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { renderFunctionPlot } from '@/services/functionPlotService'
|
||||
import { nextTick } from 'vue'
|
||||
import { renderMermaid } from '@/services/mermaidService'
|
||||
import { t } from '@/i18n'
|
||||
import { appendDiagramControls } from '@/utils/diagramControls'
|
||||
|
||||
let previewId = 0
|
||||
export function createMermaidPreview(source: string, dark: boolean, applyPreview: (value: HTMLElement) => void): HTMLElement {
|
||||
export function createMermaidPreview(source: string, dark: boolean, applyPreview: (value: HTMLElement) => void, kind = 'mermaid', themeId = 'light'): HTMLElement {
|
||||
// Each revision owns its element, so a slow render cannot replace newer content.
|
||||
// Milkdown sanitizes Element input to its inner HTML; retain the revision
|
||||
// marker and controls inside an otherwise disposable envelope.
|
||||
const envelope = document.createElement('div')
|
||||
const container = document.createElement('div')
|
||||
envelope.append(container)
|
||||
container.className = 'editor-mermaid-preview'
|
||||
container.className = 'editor-mermaid-preview' + (kind === 'function-plot' ? ' function-plot-preview' : '')
|
||||
container.id = `editor-mermaid-preview-${++previewId}`
|
||||
envelope.dataset.previewId = container.id
|
||||
container.setAttribute('aria-live', 'polite')
|
||||
@@ -27,8 +28,11 @@ export function createMermaidPreview(source: string, dark: boolean, applyPreview
|
||||
applyPreview(envelope.cloneNode(true) as HTMLElement)
|
||||
}
|
||||
}
|
||||
void renderMermaid(source, { theme: dark ? 'dark' : 'light' }).then(result => {
|
||||
if (result.warnings.length) {
|
||||
void (kind === 'function-plot' ? new Promise<void>(resolve => setTimeout(resolve, 180)) : Promise.resolve()).then<{ svg: string; warnings: string[] }>(() => {
|
||||
if (kind === 'function-plot' && !document.getElementById(container.id)) throw new Error('stale preview')
|
||||
return kind === 'function-plot' ? renderFunctionPlot(source, themeId) : renderMermaid(source, { theme: dark ? 'dark' : 'light' })
|
||||
}).then(result => {
|
||||
if (result.warnings.length && (kind === 'mermaid' || !result.svg)) {
|
||||
container.classList.add('has-error')
|
||||
container.textContent = `${t('图表语法有误,可点击编辑修改:', 'Diagram syntax error. Choose Edit to fix:')} ${result.warnings.join('\n')}`
|
||||
void publish()
|
||||
@@ -37,6 +41,7 @@ export function createMermaidPreview(source: string, dark: boolean, applyPreview
|
||||
// Mermaid runs in strict mode; Milkdown sanitizes the preview before insertion.
|
||||
container.innerHTML = result.svg
|
||||
appendDiagramControls(container)
|
||||
if (result.warnings.length) { const warning = document.createElement('p'); warning.textContent = result.warnings.join('\n'); warning.setAttribute('role', 'status'); container.append(warning) }
|
||||
void publish()
|
||||
}).catch(() => {
|
||||
container.textContent = t('图表渲染失败,请点击编辑检查源码。', 'Unable to render diagram. Choose Edit to inspect the source.')
|
||||
|
||||
@@ -69,7 +69,7 @@ it('offers fenced-code aliases and retains the LaTeX selector', () => {
|
||||
|
||||
it('offers every bundled Shiki language and alias', () => {
|
||||
const languages = shikiLanguages('github-light')
|
||||
expect(languages).toHaveLength(bundledLanguagesInfo.length + 1)
|
||||
expect(languages).toHaveLength(bundledLanguagesInfo.length + 2)
|
||||
for (const info of bundledLanguagesInfo) {
|
||||
const language = languages.find(item => item.alias.includes(info.id))!
|
||||
expect(language, info.id).toBeDefined()
|
||||
|
||||
@@ -60,6 +60,7 @@ export async function shikiLanguage(language: string, theme: CodeTheme): Promise
|
||||
|
||||
export function shikiLanguages(theme: CodeTheme): LanguageDescription[] {
|
||||
return [
|
||||
LanguageDescription.of({ name: 'function-plot', alias: ['Function Plot'], load: () => shikiLanguage('text', theme) }),
|
||||
...bundledLanguagesInfo.map(info => LanguageDescription.of({
|
||||
name: info.id,
|
||||
alias: [info.name, ...(info.aliases ?? [])],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const routes = [
|
||||
{ path: '/benchmarks', name: 'benchmarks', component: () => import('@/features/benchmarks/BenchmarkView.vue'), meta: { title: 'Benchmark' } },
|
||||
{ path: '/logs', name: 'logs', component: () => import('@/features/logs/LogsView.vue'), meta: { title: '运行日志' } },
|
||||
{ path: '/media', name: 'media', component: () => import('@/features/media/MediaView.vue'), meta: { title: '音视频转写', requiresVault: true } },
|
||||
{
|
||||
@@ -80,7 +81,9 @@ const router = createRouter({
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
if (to.meta.requiresVault && !workspaceStore.hasVault) {
|
||||
// Benchmark 不依赖工作区界面;报告中的持久化 Trace 和待决权限入口必须仍可访问。
|
||||
const existingAgentRun = to.name === 'agent' && Boolean(to.params.runId)
|
||||
if (to.meta.requiresVault && !workspaceStore.hasVault && !existingAgentRun) {
|
||||
return { path: '/' }
|
||||
}
|
||||
if (to.path === '/' && workspaceStore.hasVault) {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { apiClient } from './apiClient'
|
||||
interface RunWire { run_id: string; kind: 'rag' | 'agent'; dataset_id: string; status: string; progress: number | null; config_snapshot: Record<string, unknown>; error_code: string | null }
|
||||
export interface BenchmarkRun { id: string; kind: 'rag' | 'agent'; datasetId: string; status: string; progress: number | null; agentId?: string; errorCode: string | null }
|
||||
const map = (r: RunWire): BenchmarkRun => ({ id: r.run_id, kind: r.kind, datasetId: r.dataset_id, status: r.status, progress: r.progress, agentId: r.config_snapshot.active_agent_run_id as string | undefined, errorCode: r.error_code })
|
||||
// 保留运行配置中的 Agent Run ID,使报告页可直接进入对应 Trace 和权限处理入口。
|
||||
export const benchmarkService = {
|
||||
async datasets(kind: 'rag' | 'agent') {
|
||||
const r = await apiClient.get<{ items: { dataset_id: string; description: string; case_count: number }[] }>('/api/benchmarks/datasets', { params: { kind } })
|
||||
return r.items.map(d => ({ id: d.dataset_id, description: d.description, cases: d.case_count }))
|
||||
},
|
||||
async list() { return (await apiClient.get<{ items: RunWire[] }>('/api/benchmarks/runs')).items.map(map) },
|
||||
async start(kind: 'rag' | 'agent', body: object) { return map(await apiClient.post<RunWire>(`/api/benchmarks/${kind}/runs`, body)) },
|
||||
cancel(id: string) { return apiClient.post(`/api/benchmarks/runs/${encodeURIComponent(id)}/cancel`) },
|
||||
report(id: string) { return apiClient.get<{ metrics: Record<string, unknown>; cases: unknown[]; config_snapshot: Record<string, unknown> }>(`/api/benchmarks/runs/${encodeURIComponent(id)}/report`) },
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// @vitest-environment jsdom
|
||||
import {webcrypto} from 'node:crypto'
|
||||
import {describe,it,expect,vi,afterEach} from 'vitest'
|
||||
vi.mock('./apiClient',()=>({apiClient:{post:vi.fn(),get:vi.fn()}}))
|
||||
vi.mock('./mermaidService',()=>({renderMermaid:vi.fn()}))
|
||||
vi.mock('./pdfSnapshotService',()=>({preparePdfSnapshot:vi.fn().mockResolvedValue('<html>theme snapshot</html>')}))
|
||||
import {preparePdfSnapshot} from './pdfSnapshotService'
|
||||
import {renderMermaid} from './mermaidService'
|
||||
import {apiClient} from './apiClient'
|
||||
import {exportService,captureExportPalette} from './exportService'
|
||||
describe('export snapshot contract',()=>{
|
||||
it('submits the unsaved Markdown snapshot and maps warnings and filename',async()=>{
|
||||
vi.mocked(apiClient.post).mockResolvedValue({job_id:'job',status:'queued',warnings:['print palette'],file:{file_name:'note.pdf'},error:null})
|
||||
const job=await exportService.create('# unsaved', 'note','pdf',{theme_id:'dark',include_title:true,page_size:'A4'},undefined,'folder/note.md')
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/api/exports',expect.objectContaining({source:{type:'markdown',markdown:'# unsaved',file_path:'folder/note.md'},format:'pdf'}))
|
||||
expect(job.warnings).toEqual(['print palette']);expect(job.fileName).toBe('note.pdf')
|
||||
})
|
||||
it('aborted preparation does not create a backend job',async()=>{
|
||||
vi.mocked(apiClient.post).mockClear()
|
||||
const abort=new AbortController();abort.abort()
|
||||
await expect(exportService.create('snapshot','note','html',{theme_id:'light',include_title:true,page_size:'A4'},abort.signal)).rejects.toThrow()
|
||||
expect(apiClient.post).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
const reviewOptions={theme_id:'light',include_title:true,page_size:'A4'}
|
||||
const queued={job_id:'review-job',status:'queued',warnings:[],file:null,error:null}
|
||||
afterEach(()=>{vi.restoreAllMocks();vi.unstubAllGlobals();vi.clearAllMocks()})
|
||||
it('cancels a created server job after an in-flight submission is aborted',async()=>{
|
||||
let finish!:(value:unknown)=>void
|
||||
vi.mocked(apiClient.post).mockImplementationOnce(()=>new Promise(resolve=>{finish=resolve}) as never).mockResolvedValue({status:'completed'})
|
||||
vi.mocked(apiClient.get).mockResolvedValue({...queued,status:'cancelled'})
|
||||
const controller=new AbortController()
|
||||
const pending=exportService.create('# snapshot','review','html',reviewOptions,controller.signal)
|
||||
controller.abort();finish(queued)
|
||||
await expect(pending).rejects.toMatchObject({name:'AbortError'})
|
||||
expect(apiClient.post).toHaveBeenLastCalledWith('/api/exports/review-job/cancel')
|
||||
})
|
||||
it('does not report cancellation when the server job already completed',async()=>{
|
||||
let finish!:(value:unknown)=>void
|
||||
vi.mocked(apiClient.post).mockImplementationOnce(()=>new Promise(resolve=>{finish=resolve}) as never).mockResolvedValue({status:'completed'})
|
||||
vi.mocked(apiClient.get).mockResolvedValue({...queued,status:'completed'})
|
||||
const controller=new AbortController()
|
||||
const pending=exportService.create('# snapshot','review','html',reviewOptions,controller.signal)
|
||||
controller.abort();finish(queued)
|
||||
await expect(pending).rejects.toThrow('导出已完成,无法取消')
|
||||
})
|
||||
it('surfaces a server cancellation failure instead of claiming it was cancelled',async()=>{
|
||||
let finish!:(value:unknown)=>void
|
||||
vi.mocked(apiClient.post).mockImplementationOnce(()=>new Promise(resolve=>{finish=resolve}) as never).mockRejectedValue(new Error('network failure'))
|
||||
const controller=new AbortController()
|
||||
const pending=exportService.create('# snapshot','review','html',reviewOptions,controller.signal)
|
||||
controller.abort();finish(queued)
|
||||
await expect(pending).rejects.toThrow('network failure')
|
||||
})
|
||||
it.each(['mermaid','Mermaid','mermaid title="Flow"'])('prepares a static asset for %s',async language=>{
|
||||
vi.stubGlobal('crypto',webcrypto)
|
||||
vi.stubGlobal('Image',class {src='';decode(){return Promise.resolve()}})
|
||||
vi.spyOn(HTMLCanvasElement.prototype,'getContext').mockReturnValue({fillStyle:'',fillRect:vi.fn(),drawImage:vi.fn()} as never)
|
||||
vi.spyOn(HTMLCanvasElement.prototype,'toDataURL').mockReturnValue('data:image/png;base64,YWJj')
|
||||
vi.mocked(renderMermaid).mockResolvedValue({svg:'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 10"></svg>',warnings:[]} as never)
|
||||
vi.mocked(apiClient.post).mockResolvedValue(queued)
|
||||
await exportService.create('```'+language+'\nflowchart LR\n A-->B\n```','review','html',reviewOptions)
|
||||
expect(renderMermaid).toHaveBeenCalledWith('flowchart LR\n A-->B',{mode:'raster',theme:'light'})
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/api/exports',expect.objectContaining({assets:[expect.objectContaining({kind:'mermaid',png_base64:'YWJj',source_hash:expect.stringMatching(/^[a-f0-9]{64}$/)})]}))
|
||||
})
|
||||
|
||||
it('PDF submits the shared browser snapshot instead of raster assets',async()=>{
|
||||
vi.mocked(apiClient.post).mockResolvedValue(queued)
|
||||
const markdown=Array.from({length:17},(_,i)=>'```mermaid\nflowchart LR\n A'+i+'-->B\n```').join('\n\n')
|
||||
await exportService.create(markdown,'many','pdf',{...reviewOptions,theme_id:'dark'})
|
||||
expect(preparePdfSnapshot).toHaveBeenCalledWith(markdown,'many',expect.objectContaining({theme_id:'dark'}),undefined,undefined)
|
||||
expect(renderMermaid).not.toHaveBeenCalled()
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/api/exports',expect.objectContaining({assets:[],print_html:'<html>theme snapshot</html>'}))
|
||||
})
|
||||
it('captures custom theme CSS as a portable palette',()=>{
|
||||
const values={'background-primary':'#010409','surface-primary':'rgb(22, 27, 34)','text-primary':'#e6edf3','text-secondary':'#b1bac4','background-secondary':'#21262d','border-default':'#57606a','accent-primary':'#79c0ff'}
|
||||
for(const [key,value] of Object.entries(values))document.documentElement.style.setProperty('--color-'+key,value)
|
||||
try { expect(captureExportPalette()).toMatchObject({surface:'#161b22',text:'#e6edf3',accent:'#79c0ff'}) }
|
||||
finally { for(const key of Object.keys(values))document.documentElement.style.removeProperty('--color-'+key) }
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import { apiClient } from './apiClient'
|
||||
import { Marked } from 'marked'
|
||||
import { renderMermaid } from './mermaidService'
|
||||
|
||||
export type ExportFormat = 'html' | 'pdf' | 'docx'
|
||||
interface JobWire {
|
||||
job_id: string; status: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
|
||||
warnings: string[]; error: string | null
|
||||
file: { file_name: string; size: number } | null
|
||||
}
|
||||
export interface ExportJob { id: string; status: JobWire['status']; warnings: string[]; error: string | null; fileName?: string }
|
||||
const mapJob = (w: JobWire): ExportJob => ({ id: w.job_id, status: w.status, warnings: w.warnings, error: w.error, fileName: w.file?.file_name })
|
||||
export type ExportPalette = Record<'page' | 'surface' | 'text' | 'muted' | 'code' | 'border' | 'accent', string>
|
||||
// 冻结导出开始时的主题颜色,避免后台兼容渲染受到后续主题切换影响。
|
||||
export function captureExportPalette(): ExportPalette | undefined {
|
||||
const style = getComputedStyle(document.documentElement)
|
||||
const tokens = { page:'background-primary', surface:'surface-primary', text:'text-primary', muted:'text-secondary', code:'background-secondary', border:'border-default', accent:'accent-primary' }
|
||||
const entries = Object.entries(tokens).map(([key, token]) => {
|
||||
const value = style.getPropertyValue(`--color-${token}`).trim()
|
||||
if (/^#[0-9a-f]{6}$/i.test(value)) return [key,value]
|
||||
if (/^#[0-9a-f]{3}$/i.test(value)) return [key, '#' + [...value.slice(1)].map(c => c+c).join('')]
|
||||
const rgb = value.match(/^rgb\(\s*(\d+)[, ]+\s*(\d+)[, ]+\s*(\d+)\s*\)$/)
|
||||
if (rgb) return [key, '#' + rgb.slice(1,4).map(v => Number(v).toString(16).padStart(2,'0')).join('')]
|
||||
// 借助浏览器解析命名色、color-mix、OKLCH 和透明色,再冻结为可移植 RGB 色板。
|
||||
if (typeof CSS !== 'undefined' && CSS.supports('color', value)) {
|
||||
const canvas = document.createElement('canvas'); canvas.width = canvas.height = 1
|
||||
const context = canvas.getContext('2d')
|
||||
if (context) {
|
||||
context.fillStyle = '#ffffff'; context.fillRect(0,0,1,1)
|
||||
context.fillStyle = value; context.fillRect(0,0,1,1)
|
||||
const pixel = context.getImageData(0,0,1,1).data
|
||||
return [key, '#' + [...pixel.slice(0,3)].map(v => v.toString(16).padStart(2,'0')).join('')]
|
||||
}
|
||||
}
|
||||
return [key, '']
|
||||
})
|
||||
return entries.every(([,value]) => value) ? Object.fromEntries(entries) as ExportPalette : undefined
|
||||
}
|
||||
export async function rasterize(svg: string, signal?: AbortSignal, unlimited = false, background = '#ffffff'): Promise<string> {
|
||||
// 非 PDF 格式保留像素预算和解码超时;PDF 的自包含快照解除资源配额。
|
||||
const doc = new DOMParser().parseFromString(svg, 'image/svg+xml')
|
||||
const root = doc.documentElement
|
||||
const box = root.getAttribute('viewBox')?.split(/[ ,]+/).map(Number)
|
||||
const width = box?.[2] || 800, height = box?.[3] || 600
|
||||
if (!Number.isFinite(width + height) || width <= 0 || height <= 0) throw new Error('图表尺寸无效')
|
||||
const scale = Math.min(4, Math.max(2, 1200 / width), unlimited ? Infinity : Math.sqrt(4_000_000 / (width * height)))
|
||||
root.setAttribute('width', String(Math.floor(width * scale))); root.setAttribute('height', String(Math.floor(height * scale)))
|
||||
root.style.maxWidth = 'none'
|
||||
const data = new XMLSerializer().serializeToString(root)
|
||||
const image = new Image()
|
||||
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(data)}`
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const abort = () => reject(new DOMException('Aborted', 'AbortError'))
|
||||
const timer = unlimited ? undefined : setTimeout(() => reject(new Error('图表图片解码超时')), 15000)
|
||||
const cleanup = () => { clearTimeout(timer); signal?.removeEventListener('abort', abort) }
|
||||
if (signal?.aborted) { cleanup(); abort(); return }
|
||||
signal?.addEventListener('abort', abort, { once: true })
|
||||
image.decode().then(resolve, reject).finally(cleanup)
|
||||
})
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = Math.floor(width * scale); canvas.height = Math.floor(height * scale)
|
||||
const context = canvas.getContext('2d')!
|
||||
context.fillStyle = background; context.fillRect(0, 0, canvas.width, canvas.height)
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height)
|
||||
return canvas.toDataURL('image/png').split(',')[1]!
|
||||
}
|
||||
export async function hashSource(source: string) {
|
||||
return [...new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(source.trim())))].map(v => v.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
export const exportService = {
|
||||
async create(markdown: string, title: string, format: ExportFormat, options: { theme_id: string; include_title: boolean; page_size: string; palette?: ExportPalette }, signal?: AbortSignal, filePath?: string) {
|
||||
let printHtml: string | undefined
|
||||
if (format === 'pdf') {
|
||||
const { preparePdfSnapshot } = await import('./pdfSnapshotService')
|
||||
printHtml = await preparePdfSnapshot(markdown,title,options,signal,filePath)
|
||||
}
|
||||
const blocks: string[] = []
|
||||
const parser = new Marked()
|
||||
if (format !== 'pdf') parser.walkTokens(parser.lexer(markdown), token => { if (token.type === 'code' && token.lang?.trim().split(/\s+/)[0]?.toLowerCase() === 'mermaid') blocks.push(token.text) })
|
||||
const assets = []
|
||||
for (const source of [...new Set(blocks)]) {
|
||||
signal?.throwIfAborted()
|
||||
if (format !== 'pdf' && assets.length >= 16) throw new Error('每次导出最多 16 个 Mermaid 图表')
|
||||
const pdf = format === 'pdf'
|
||||
const result = await renderMermaid(source, pdf ? { mode: 'raster', theme: ['dark','midnight-purple'].includes(options.theme_id) ? 'dark' : 'light', palette: options.palette, unlimited: true } : { mode: 'raster', theme: 'light' })
|
||||
if (result.warnings.length) throw new Error(`Mermaid 无法导出:${result.warnings.join('; ')}`)
|
||||
assets.push({ kind: 'mermaid', source_hash: await hashSource(source), png_base64: await rasterize(result.svg, signal, pdf, pdf ? options.palette?.surface ?? (['dark','midnight-purple'].includes(options.theme_id) ? '#161b22' : '#ffffff') : '#ffffff') })
|
||||
}
|
||||
signal?.throwIfAborted()
|
||||
// 提交期间收到取消时仍等待服务器返回任务句柄;只中断 HTTP 会遗留无法追踪的后台任务。
|
||||
const job = mapJob(await apiClient.post<JobWire>('/api/exports', { source: { type: 'markdown', markdown, file_path: filePath }, title, format, options, assets, ...(printHtml ? { print_html:printHtml } : {}) }))
|
||||
if (signal?.aborted) {
|
||||
await apiClient.post(`/api/exports/${encodeURIComponent(job.id)}/cancel`)
|
||||
const current = await apiClient.get<JobWire>(`/api/exports/${encodeURIComponent(job.id)}`)
|
||||
if (current.status === 'completed') throw new Error('导出已完成,无法取消;请在任务列表中下载。')
|
||||
if (current.status === 'failed') throw new Error(current.error || '导出任务已失败,请查看任务列表。')
|
||||
signal.throwIfAborted()
|
||||
}
|
||||
return job
|
||||
},
|
||||
async get(id: string) { return mapJob(await apiClient.get<JobWire>(`/api/exports/${encodeURIComponent(id)}`)) },
|
||||
async list() { const response = await apiClient.get<{ items: JobWire[] }>('/api/exports'); return response.items.map(mapJob) },
|
||||
cancel(id: string) { return apiClient.post(`/api/exports/${encodeURIComponent(id)}/cancel`) },
|
||||
async download(job: ExportJob) {
|
||||
const response = await apiClient.get<Response>(`/api/exports/${encodeURIComponent(job.id)}/file`)
|
||||
const url = URL.createObjectURL(await response.blob())
|
||||
const link = document.createElement('a'); link.href = url; link.download = job.fileName ?? 'export'
|
||||
link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe,it,expect,vi,beforeEach } from 'vitest'
|
||||
vi.mock('./apiClient',()=>({apiClient:{post:vi.fn()}}))
|
||||
import { apiClient } from './apiClient'
|
||||
import { renderFunctionPlot } from './functionPlotService'
|
||||
describe('function plot preview boundary',()=>{
|
||||
beforeEach(()=>vi.clearAllMocks())
|
||||
it('shares requests only for the same source and theme, sanitizes SVG',async()=>{
|
||||
vi.mocked(apiClient.post).mockResolvedValue({result:{content:'<svg onload="alert(1)"><script>alert(2)</script><path d="M0 0L1 1"/></svg>',warnings:[]},diagnostics:[],node_count:3})
|
||||
const a=await renderFunctionPlot('y = x + 100','dark')
|
||||
await renderFunctionPlot('y = x + 100','dark')
|
||||
await renderFunctionPlot('y = x + 100','light')
|
||||
expect(apiClient.post).toHaveBeenCalledTimes(2)
|
||||
expect(a.svg).not.toMatch(/onload|script|alert/)
|
||||
})
|
||||
it('does not cache network failures',async()=>{
|
||||
vi.mocked(apiClient.post).mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce({result:null,diagnostics:[{message:'bad expression',line:2}],node_count:0})
|
||||
await expect(renderFunctionPlot('bad expression')).rejects.toThrow('offline')
|
||||
const result=await renderFunctionPlot('bad expression')
|
||||
expect(result.svg).toBe('');expect(result.warnings[0]).toContain('2')
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user