diff --git a/backend/app/agent/markdown_tools.py b/backend/app/agent/markdown_tools.py
index ee6d627..a7738e1 100644
--- a/backend/app/agent/markdown_tools.py
+++ b/backend/app/agent/markdown_tools.py
@@ -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, _):
diff --git a/backend/app/benchmarks/agent.py b/backend/app/benchmarks/agent.py
new file mode 100644
index 0000000..12cec3f
--- /dev/null
+++ b/backend/app/benchmarks/agent.py
@@ -0,0 +1,137 @@
+"""Standard task evaluation over AgentRuntime, never a scripted substitute runner."""
+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']
+ unmatched = list(calls)
+ selected = accurate = 0
+ for expected in case.expected_tools:
+ candidates = [c for c in unmatched if c.get('name') == expected.name]
+ if not candidates:
+ continue
+ exact = next((c for c in candidates if all(k in c.get('arguments', {}) and c['arguments'][k] == v for k,v in expected.arguments.items())), None)
+ chosen = exact or candidates[0]
+ unmatched.remove(chosen); selected += 1; accurate += int(exact is not None)
+ 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)
+ # Micro accuracy penalizes omitted AND unnecessary calls; no-call cases are N/A.
+ 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):
+ 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}))
+ # Surface the real Trace/permission entry while the case is still executing.
+ 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)
diff --git a/backend/app/benchmarks/datasets.py b/backend/app/benchmarks/datasets.py
index fff0661..6b2901e 100644
--- a/backend/app/benchmarks/datasets.py
+++ b/backend/app/benchmarks/datasets.py
@@ -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,
diff --git a/backend/app/benchmarks/rag.py b/backend/app/benchmarks/rag.py
index e364f46..6f53067 100644
--- a/backend/app/benchmarks/rag.py
+++ b/backend/app/benchmarks/rag.py
@@ -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,
diff --git a/backend/app/benchmarks/service.py b/backend/app/benchmarks/service.py
index f06f3ca..3ba2bbd 100644
--- a/backend/app/benchmarks/service.py
+++ b/backend/app/benchmarks/service.py
@@ -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)
diff --git a/backend/app/contracts.py b/backend/app/contracts.py
index 05e0608..dc3fd30 100644
--- a/backend/app/contracts.py
+++ b/backend/app/contracts.py
@@ -155,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)
@@ -1221,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)
@@ -1329,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
@@ -1337,7 +1384,7 @@ 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
@@ -1366,6 +1413,7 @@ 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
@@ -1386,7 +1434,15 @@ class ExportOptions(Contract):
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 = Field(max_length=2800000)
+
+
class ExportRequest(Contract):
+ assets: list[ExportAsset] = Field(default_factory=list, max_length=64)
+ title: str = Field(default="", max_length=200)
source: ExportSource
format: ExportFormat
options: ExportOptions = Field(default_factory=ExportOptions)
diff --git a/backend/app/export/assets.py b/backend/app/export/assets.py
new file mode 100644
index 0000000..5415345
--- /dev/null
+++ b/backend/app/export/assets.py
@@ -0,0 +1,140 @@
+"""Bounded raster-only resource boundary. No URLs, XML or filesystem paths accepted."""
+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):
+ """Embed local vault images and bounded MathText. Unsupported TeX stays explicit."""
+ 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
+ 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 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 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 depth > 20: raise ValueError('math depth')
+ if len(source) > 512 or depth != 0: raise ValueError('math budget')
+ from matplotlib.mathtext import math_to_image
+ with _math_lock:
+ out = BytesIO()
+ math_to_image('$'+source+'$', out, dpi=180, format='png', color='black')
+ raw = out.getvalue()
+ with Image.open(BytesIO(raw)) as image:
+ pixels += image.width * image.height
+ if pixels > 16_000_000: raise ValueError('document pixels')
+ if image.width * image.height > 4_000_000: raise ValueError('image dimensions')
+ out = BytesIO()
+ # Flatten alpha on white for portable print/Word output.
+ rgba=image.convert('RGBA'); background=Image.new('RGBA',rgba.size,'white')
+ background.alpha_composite(rgba); background.convert('RGB').save(out,'PNG')
+ png=out.getvalue();total += len(png)
+ if 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):
+ result = {}
+ total = pixels = 0
+ for asset in assets:
+ try:
+ raw = base64.b64decode(asset.png_base64, validate=True)
+ total += len(raw)
+ if total > 8 * 1024 * 1024:
+ raise ValueError('asset budget')
+ with Image.open(BytesIO(raw)) as image:
+ pixels += image.width * image.height
+ if pixels > 16_000_000: raise ValueError('document pixel budget')
+ if image.format != 'PNG' or 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)
+ 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):
+ """DOCX consumes the same clipped geometry as SVG/PDF, rendered at 2x."""
+ 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:
+ # Horizontal at the upper-left margin keeps CJK labels readable in 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
diff --git a/backend/app/export/exporters/docx.py b/backend/app/export/exporters/docx.py
index d3f03c0..12cefcb 100644
--- a/backend/app/export/exporters/docx.py
+++ b/backend/app/export/exporters/docx.py
@@ -1,7 +1,7 @@
"""DocxExporter:Document AST → DOCX(python-docx)。
-v1 为文本优先:标题/段落/行内强调与链接/列表/引用/表格/代码块/数学文本均可导出;
-function_plot 与 mermaid 保留源码占位并记 warning。中文字体通过 Normal 样式挂载
+标题、段落、列表、表格等使用原生 Word 元素;函数图、已准备的 Mermaid、
+受支持的公式与 Vault 图片使用静态图片,无法表示的资源保留源码并记 warning。中文字体通过 Normal 样式挂载
w:eastAsia=宋体,保证 Word 打开时中文正常显示;bold/italic 由 Word 原生渲染。
"""
@@ -50,6 +50,8 @@ class DocxExporter:
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)
@@ -109,6 +111,13 @@ class DocxExporter:
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:
+ width = min(5.8, image.width / (180 if node.type == 'math_block' else 96))
+ 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)
@@ -270,7 +279,20 @@ class DocxExporter:
self._block_code_block(node, warnings)
def _block_function_plot(self, node: DocumentNode, warnings: list[str]) -> None:
- warnings.append(PLOT_PLACEHOLDER_WARNING)
+ 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:
@@ -303,6 +325,12 @@ class DocxExporter:
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)
diff --git a/backend/app/export/exporters/html.py b/backend/app/export/exporters/html.py
index ac8c8e8..5ccd364 100644
--- a/backend/app/export/exporters/html.py
+++ b/backend/app/export/exporters/html.py
@@ -151,6 +151,16 @@ class HtmlExporter:
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'
'
handler = getattr(self, f"_render_{node.type}", None)
if handler is not None:
return handler(node, warnings)
@@ -257,6 +267,8 @@ class HtmlExporter:
warnings.append(f"函数图像:解析或渲染失败,已回退占位({exc})")
return f'
{html.escape(node.text)}'
warnings.extend(rendered.warnings)
+ from app.plot.render import theme_svg
+ rendered.content = theme_svg(rendered.content, self._options.theme_id)
return f'{rendered.content}'
def _render_math_block(self, node: DocumentNode, warnings: list[str]) -> str:
diff --git a/backend/app/export/exporters/pdf.py b/backend/app/export/exporters/pdf.py
index 2f44d22..2ba7949 100644
--- a/backend/app/export/exporters/pdf.py
+++ b/backend/app/export/exporters/pdf.py
@@ -42,8 +42,7 @@ from app.export.exporters._common import (
from app.plot.render_reportlab import render_drawing
from app.plot.renderer import FunctionPlotStaticRenderer, StaticRenderRequest
-_FONT = "STSong-Light"
-pdfmetrics.registerFont(UnicodeCIDFont(_FONT))
+from app.export.fonts import FONT as _FONT
_MIME = "application/pdf"
@@ -122,6 +121,7 @@ class PdfExporter:
self._styles = _make_styles()
warnings: list[str] = []
print_theme_warning(options, warnings, "PDF")
+ 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
@@ -169,6 +169,14 @@ class PdfExporter:
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, 600 / 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)
@@ -362,6 +370,15 @@ class PdfExporter:
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'
'
t = node.type
if t == "text":
return _html.escape(node.text)
diff --git a/backend/app/export/fonts.py b/backend/app/export/fonts.py
new file mode 100644
index 0000000..df2440f
--- /dev/null
+++ b/backend/app/export/fonts.py
@@ -0,0 +1,22 @@
+"""Embed an available CJK TrueType font; retain the portable CID fallback."""
+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():
+ 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()
diff --git a/backend/app/export/service.py b/backend/app/export/service.py
index 10666eb..dfe7b31 100644
--- a/backend/app/export/service.py
+++ b/backend/app/export/service.py
@@ -182,12 +182,15 @@ async def _resolve_source(source: ExportSource) -> tuple[str, str, dict | None]:
f"markdown source exceeds {MAX_MARKDOWN_CHARS} characters",
{"size": len(markdown), "limit": MAX_MARKDOWN_CHARS},
)
- return markdown, "", None
+ 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)
+ title = request.title or title
+ from app.export.assets import validate_assets
+ assets = await asyncio.to_thread(validate_assets, request.assets)
if not _evict_terminal():
raise ApiError(
@@ -207,7 +210,7 @@ async def create_export(request: ExportRequest) -> ExportJob:
_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)
+ _execute(job_id, request.format, markdown, title, metadata, request.options, assets)
)
return job
@@ -245,6 +248,7 @@ async def _execute(
title: str,
metadata: dict | None,
options: ExportOptions,
+ assets: dict | None = None,
) -> None:
"""后台渲染:排队 → 解析 → 导出 → 写文件 → 挂载产物元信息。"""
cancel_event = _cancel_flags[job_id]
@@ -273,10 +277,15 @@ async def _execute(
# 使运行中的取消能在渲染边界生效;写文件前再次检查取消。
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'))
result = await asyncio.to_thread(_render_document, document, options, format)
+ result.warnings[:0] = resource_warnings
if cancel_event.is_set():
raise ExportCancelled()
if len(result.content) > MAX_EXPORT_BYTES:
diff --git a/backend/app/export/themes.py b/backend/app/export/themes.py
index 94e07ee..bf833cc 100644
--- a/backend/app/export/themes.py
+++ b/backend/app/export/themes.py
@@ -1,5 +1,6 @@
"""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'),
diff --git a/backend/app/local_models/runtime.py b/backend/app/local_models/runtime.py
index 7bcb7e2..63766d1 100644
--- a/backend/app/local_models/runtime.py
+++ b/backend/app/local_models/runtime.py
@@ -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,12 @@ class Runtime:
runtime = Runtime()
+# Bounded in-memory reuse of deterministic single-text local embeddings. The key
+# includes the data/model location, immutable model revision and frozen runtime
+# configuration. No remote API response or unavailable-model fallback is cached.
+_embedding_cache = OrderedDict()
+_EMBEDDING_CACHE_TTL = 600
+
class LocalEmbedding:
dim = 384
@@ -264,9 +272,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)
diff --git a/backend/app/main.py b/backend/app/main.py
index c9e0651..6426954 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -35,6 +35,8 @@ async def lifespan(_: FastAPI):
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()
@@ -75,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')
diff --git a/backend/app/plot/model.py b/backend/app/plot/model.py
index f11273e..63ba52e 100644
--- a/backend/app/plot/model.py
+++ b/backend/app/plot/model.py
@@ -1,7 +1,7 @@
"""Function Plot 内部数据模型。
-契约 §12.2 的 FunctionPlot 结构与 §10.4 的 StaticRenderResult 只在导出链路的后端内部
-流转,不进入 HTTP 契约,因此与 Document AST 一样放在独立包内,不进 contracts.py。
+FunctionPlot 供预览和导出共享;StaticRenderResult 同时是交互预览端点的响应内容。
+模型保留在独立包内,由 plot_routes 中的请求与响应类型注册 OpenAPI。
"""
from __future__ import annotations
diff --git a/backend/app/plot/render.py b/backend/app/plot/render.py
index baee2be..40f494c 100644
--- a/backend/app/plot/render.py
+++ b/backend/app/plot/render.py
@@ -413,12 +413,12 @@ def _grid_svg(geo: PlotGeometry) -> str:
for x in geo.xticks:
parts.append(
f''
+ f'y2="{sy(geo.ymax):.2f}" stroke="#eaeef2" class="plot-grid"/>'
)
for y in geo.yticks:
parts.append(
f''
+ f'y2="{sy(y):.2f}" stroke="#eaeef2" class="plot-grid"/>'
)
return "".join(parts)
@@ -430,11 +430,11 @@ def _axes_svg(geo: PlotGeometry) -> str:
# 坐标轴:过原点则画在原点,否则贴边,保证始终有参照系
parts.append(
f''
+ f'y2="{sy(geo.x_axis_y):.2f}" stroke="#57606a" class="plot-axis"/>'
)
parts.append(
f''
+ f'y2="{sy(geo.ymax):.2f}" stroke="#57606a" class="plot-axis"/>'
)
# x 轴刻度数字(画在轴下方)
for x in geo.xticks:
@@ -453,10 +453,10 @@ def _axes_svg(geo: PlotGeometry) -> str:
def _polylines_svg(geo: PlotGeometry) -> str:
parts: list[str] = []
- for segments, color in zip(geo.polylines, geo.colors):
+ 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'')
+ parts.append(f'')
return "".join(parts)
@@ -476,22 +476,43 @@ def _labels_svg(geo: PlotGeometry) -> str:
return "".join(parts)
-def render_svg(plot: FunctionPlot) -> StaticRenderResult:
+def render_svg(plot: FunctionPlot, theme_id: str = 'light') -> StaticRenderResult:
"""把已解析的 FunctionPlot 渲染为内嵌 SVG。"""
geo = compute_geometry(plot)
+ legend_height = ((len(plot.expressions) + 1) // 2) * 24
+ height = geo.height + legend_height
parts: list[str] = [
- f'