docs(code): 补齐第二阶段前后端中文注释
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
"""Standard task evaluation over AgentRuntime, never a scripted substitute runner."""
|
||||
"""通过真实 AgentRuntime 执行标准任务评测,不使用脚本化替代运行器。"""
|
||||
import asyncio
|
||||
from time import perf_counter
|
||||
from uuid import uuid4
|
||||
@@ -10,9 +10,10 @@ 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']
|
||||
# Maximum bipartite matching: broad parameter subsets must not consume the
|
||||
# only call satisfying a more specific expectation. Each call is used once.
|
||||
# 使用最大二分匹配,避免宽松的参数子集占用唯一能满足更严格预期的调用;
|
||||
# 每个实际调用最多匹配一个预期调用。
|
||||
matched = {}
|
||||
def assign(expected_index, visited):
|
||||
expected = case.expected_tools[expected_index]
|
||||
@@ -49,10 +50,11 @@ def score(case, run, events, latency, repeat):
|
||||
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,
|
||||
@@ -63,6 +65,7 @@ def aggregate(cases, planned_total=None):
|
||||
'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:
|
||||
@@ -91,6 +94,7 @@ async def create_run(request: AgentBenchmarkRequest):
|
||||
return run
|
||||
|
||||
async def execute(run_id, request, dataset, runtime):
|
||||
"""顺序执行样本,传播取消信号,并持续发布可订阅的运行事件。"""
|
||||
flag = service._cancel_flags[run_id]
|
||||
results = []; active = None
|
||||
def emit(kind, data):
|
||||
@@ -112,7 +116,7 @@ async def execute(run_id, request, dataset, runtime):
|
||||
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.
|
||||
# 样本仍在运行时就暴露真实 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())
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Raster-only resources; PDF bypasses export quotas but retains path/format validation."""
|
||||
"""处理栅格资源;PDF 不受导出配额限制,但仍执行路径和格式校验。"""
|
||||
import base64
|
||||
import hashlib
|
||||
import threading
|
||||
@@ -9,7 +9,7 @@ from app.errors import ApiError
|
||||
_math_lock = threading.Lock()
|
||||
|
||||
def enrich_document(document, file_path=None, unlimited=False, options=None, preserve_alpha=False):
|
||||
"""Embed Vault images and MathText, with format-specific quotas and palette."""
|
||||
"""内嵌 Vault 图片和 MathText,并按导出格式应用配额与主题配色。"""
|
||||
from app.config import get_settings
|
||||
from urllib.parse import unquote, urlsplit
|
||||
vault = get_settings().vault_path.resolve()
|
||||
@@ -51,7 +51,7 @@ def enrich_document(document, file_path=None, unlimited=False, options=None, pre
|
||||
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()
|
||||
# Composite transparency over the PDF theme or the print/Word white surface.
|
||||
# 透明像素按 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)
|
||||
@@ -70,6 +70,7 @@ 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:
|
||||
@@ -98,6 +99,7 @@ def validate_assets(assets, unlimited=False):
|
||||
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))
|
||||
@@ -109,7 +111,7 @@ def attach_assets(document, assets):
|
||||
visit(child)
|
||||
|
||||
def plot_png(plot):
|
||||
"""DOCX consumes the same clipped geometry as SVG/PDF, rendered at 2x."""
|
||||
"""按 SVG/PDF 共用的裁剪几何,以二倍分辨率生成 DOCX 图像。"""
|
||||
from app.plot.render import compute_geometry, _sx, _sy, _fmt_num
|
||||
from PIL import ImageDraw, ImageFont
|
||||
geo = compute_geometry(plot)
|
||||
@@ -135,7 +137,7 @@ def plot_png(plot):
|
||||
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.
|
||||
# 纵轴标题横排在左上边距,避免 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)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Print the app's self-contained theme snapshot with a real browser engine.
|
||||
"""使用真实浏览器引擎打印应用生成的自包含主题快照。
|
||||
|
||||
A child process isolates Playwright's Windows event loop from Uvicorn and keeps
|
||||
browser lifecycle scoped to one export. Snapshot scripts/network/file loads are
|
||||
blocked; fonts and images must already be embedded by the client.
|
||||
子进程隔离 Playwright 在 Windows 上的事件循环与 Uvicorn,并把浏览器生命周期限制在
|
||||
单次导出内。快照禁止脚本、网络和文件加载,字体与图片必须由客户端提前内嵌。
|
||||
"""
|
||||
from pathlib import Path
|
||||
import os
|
||||
@@ -14,6 +13,7 @@ from app.export.document import ExportResult
|
||||
|
||||
|
||||
def browser_executable():
|
||||
"""优先使用显式配置,再查找系统已安装的 Chromium 系浏览器。"""
|
||||
configured = os.environ.get('APP_PDF_BROWSER')
|
||||
if configured:
|
||||
return configured
|
||||
@@ -28,6 +28,7 @@ def browser_executable():
|
||||
|
||||
|
||||
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'
|
||||
@@ -42,6 +43,7 @@ def render_snapshot(snapshot: str, page_size: str) -> ExportResult:
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -117,7 +117,7 @@ class DocxExporter:
|
||||
with Image.open(BytesIO(png)) as image:
|
||||
section = self._doc.sections[-1]
|
||||
available_width = (section.page_width - section.left_margin - section.right_margin) / 914400
|
||||
# Leave room for Word's containing paragraph line/spacing.
|
||||
# 为 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),
|
||||
|
||||
@@ -285,7 +285,7 @@ class PdfExporter:
|
||||
parts.append(self._render_inline(child.children, warnings))
|
||||
elif hasattr(self, f"_block_{child.type}"):
|
||||
flush()
|
||||
# Keep block content inside the list frame, including tables and callouts.
|
||||
# 表格、警告框等块级内容也要保持在列表缩进框内。
|
||||
story.append(Indenter(left=indent))
|
||||
self._render_block(child, story, warnings)
|
||||
story.append(Indenter(left=-indent))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Embed an available CJK TrueType font; retain the portable CID fallback."""
|
||||
"""嵌入可用的 CJK TrueType 字体,找不到时保留可移植的 CID 字体回退。"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
@@ -6,6 +6,7 @@ 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']
|
||||
|
||||
@@ -179,6 +179,7 @@ class _AstMapper:
|
||||
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", ""))
|
||||
|
||||
@@ -406,7 +406,7 @@ async def wait_for_export(job_id: str) -> ExportJob | None:
|
||||
|
||||
|
||||
async def preview_resources(request: ExportRequest):
|
||||
"""Prepare Vault images and vector plots for the shared browser renderer."""
|
||||
"""为浏览器渲染器准备通过 Vault 校验的图片和静态函数图。"""
|
||||
import base64
|
||||
from app.export.assets import enrich_document
|
||||
from app.plot.parser import parse_source
|
||||
@@ -418,6 +418,8 @@ async def preview_resources(request: ExportRequest):
|
||||
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')
|
||||
|
||||
@@ -241,9 +241,8 @@ 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.
|
||||
# 对确定性的单文本本地向量做有界内存复用。键包含模型目录、不可变版本和冻结运行配置;
|
||||
# 远程 API 响应以及模型不可用时的回退结果都不进入缓存。
|
||||
_embedding_cache = OrderedDict()
|
||||
_EMBEDDING_CACHE_TTL = 600
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Interactive previews use the same bounded parser and geometry as exports."""
|
||||
"""交互预览复用导出使用的有界解析器和几何计算。"""
|
||||
import asyncio
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -19,6 +19,7 @@ class PlotResponse(BaseModel):
|
||||
node_count: int = 0
|
||||
|
||||
def preview(request):
|
||||
"""同步解析并渲染函数图,供受并发限制的异步路由在线程中调用。"""
|
||||
parsed = parse_source(request.source)
|
||||
if parsed.plot is None:
|
||||
return PlotResponse(diagnostics=parsed.diagnostics)
|
||||
@@ -30,5 +31,6 @@ def preview(request):
|
||||
|
||||
@router.post('/function', response_model=PlotResponse)
|
||||
async def render_function(request: PlotRequest):
|
||||
# 绘图属于 CPU 密集任务,限制并发并移入线程,避免阻塞事件循环。
|
||||
async with _slots:
|
||||
return await asyncio.to_thread(preview, request)
|
||||
|
||||
@@ -115,6 +115,7 @@ class RetrievalEngine:
|
||||
candidate_scores = vec_scores
|
||||
else: # hybrid:RRF 融合
|
||||
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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Two bounded live calls for context summary + answer; never changes saved config."""
|
||||
"""执行两次有界真实调用完成上下文摘要与回答,不修改已保存配置。"""
|
||||
import argparse, asyncio, json, sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0,str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Explicit isolated Demo: retrieval → read → three tasks, then real read-only MCP.
|
||||
"""显式隔离演示:检索、读取、创建三个任务,再调用真实只读 MCP。
|
||||
|
||||
Approves only this run's tasks.write tickets. Requires the quality fixture Vault.
|
||||
Calls public API contracts; never writes completion state into SQLite.
|
||||
只批准本次运行产生的 tasks.write 权限票据,需要质量验收用 Vault。
|
||||
脚本仅调用公共 API 契约,不把伪造的完成状态写入 SQLite。
|
||||
"""
|
||||
import argparse, json, time
|
||||
from pathlib import Path
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Bounded real protocol/Agent checks using existing configuration; no secret output.
|
||||
"""使用现有配置执行有界的真实协议与 Agent 检查,不输出凭据。
|
||||
|
||||
Requires --execute; at most 5 direct model requests plus one 4-case Agent dataset
|
||||
(6 steps and 6000 tokens per case). No provisioning or external writes.
|
||||
必须显式传入 --execute;最多发起 5 次直接模型请求和一组 4 样本 Agent 评测,
|
||||
每个样本最多 6 步、6000 Token。不创建配置,也不执行外部写入。
|
||||
"""
|
||||
import argparse, asyncio, json, sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Reproducible real-model quality run in an explicitly isolated APP_DATA_DIR.
|
||||
"""在显式隔离的 APP_DATA_DIR 中执行可复现的真实模型质量验证。
|
||||
|
||||
Uses the application index/benchmark services. Never injects vectors or completion rows.
|
||||
Existing local weights/runtime must be configured; inference does not download models.
|
||||
使用应用自身的索引与评测服务,不注入向量或伪造完成记录。
|
||||
运行前必须已有本地权重和运行环境,推理过程不会下载模型。
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
|
||||
@@ -33,8 +33,7 @@ def test_preview_resources_keeps_vault_boundary_and_plot_quota_removed():
|
||||
|
||||
@pytest.mark.skipif(browser_executable() is None,reason='No installed Chromium browser')
|
||||
def test_browser_prints_css_without_executing_document_scripts(tmp_path):
|
||||
# The script would erase all text if executed. Embedded CSS and fonts must
|
||||
# survive the browser path while network and file resources stay blocked.
|
||||
# 若脚本被执行会清空正文;测试同时确认 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')
|
||||
|
||||
@@ -222,6 +222,6 @@ function close() { disarm(); viewer.value?.close(); svgHtml.value = ''; opener?.
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* Keep 10px axis labels readable on narrow screens; the existing container scrolls. */
|
||||
/* 窄屏仍保持 10px 坐标轴文字可读,溢出由现有图表容器滚动承接。 */
|
||||
.function-plot-preview > svg, .markdown-function-plot > svg { min-width: 640px; }
|
||||
</style>
|
||||
|
||||
@@ -81,8 +81,7 @@ const router = createRouter({
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
// A benchmark can run without the workspace UI being open. Its persisted Trace
|
||||
// and permission tickets must remain reachable from the report page.
|
||||
// Benchmark 不依赖工作区界面;报告中的持久化 Trace 和待决权限入口必须仍可访问。
|
||||
const existingAgentRun = to.name === 'agent' && Boolean(to.params.runId)
|
||||
if (to.meta.requiresVault && !workspaceStore.hasVault && !existingAgentRun) {
|
||||
return { path: '/' }
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 } })
|
||||
|
||||
@@ -11,6 +11,7 @@ interface JobWire {
|
||||
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' }
|
||||
@@ -20,8 +21,7 @@ export function captureExportPalette(): ExportPalette | undefined {
|
||||
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('')]
|
||||
// Resolve named colors, color-mix/OKLCH and alpha through the browser's
|
||||
// color implementation before freezing a portable RGB palette.
|
||||
// 借助浏览器解析命名色、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')
|
||||
@@ -37,6 +37,7 @@ export function captureExportPalette(): ExportPalette | undefined {
|
||||
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)
|
||||
@@ -86,8 +87,7 @@ export const exportService = {
|
||||
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()
|
||||
// Keep the response handle when cancellation arrives during submission:
|
||||
// aborting HTTP alone could leave an undiscoverable running server job.
|
||||
// 提交期间收到取消时仍等待服务器返回任务句柄;只中断 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`)
|
||||
|
||||
@@ -8,9 +8,11 @@ interface PlotWire {
|
||||
}
|
||||
const cache = new Map<string, Promise<{ svg: string; warnings: string[]; nodeCount: number }>>()
|
||||
export function renderFunctionPlot(source: string, themeId = 'light') {
|
||||
// 缓存 Promise 既合并并发的相同请求,也避免重复渲染;失败结果立即移除以允许重试。
|
||||
const key = JSON.stringify([source, themeId])
|
||||
if (cache.has(key)) return cache.get(key)!
|
||||
const result = apiClient.post<PlotWire>('/api/plots/function', { source, theme_id: themeId }, { timeoutMs: 30000 }).then(wire => ({
|
||||
// 后端只生成静态 SVG,前端仍在渲染边界执行净化,防止未来响应扩展引入可执行标记。
|
||||
svg: DOMPurify.sanitize(wire.result?.content ?? '', { USE_PROFILES: { svg: true } }),
|
||||
warnings: [...wire.diagnostics.map(d => `${d.message}${d.line ? ` (行 ${d.line})` : ''}`), ...(wire.result?.warnings ?? [])],
|
||||
nodeCount: wire.node_count,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { mermaidThemeVariables } from './mermaidService'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { useMarkdownPreferencesStore } from '@/stores/markdownPreferences'
|
||||
import { useHeadingAppearanceStore } from '@/stores/headingAppearance'
|
||||
// Load the same CSS, including Vue's scoped editor rules, without mounting an editor.
|
||||
// 仅加载编辑器及 Markdown 组件的样式,包括 Vue scoped 规则,不额外挂载编辑器实例。
|
||||
import MarkdownContent from '@/components/common/MarkdownContent.vue'
|
||||
import VisualMarkdownEditor from '@/features/editor/VisualMarkdownEditor.vue'
|
||||
void MarkdownContent; void VisualMarkdownEditor
|
||||
@@ -39,6 +39,7 @@ async function dataUrl(url: string, signal?:AbortSignal):Promise<string> {
|
||||
return await new Promise((resolve,reject)=>{const reader=new FileReader();reader.onload=()=>resolve(String(reader.result));reader.onerror=reject;reader.readAsDataURL(blob)})
|
||||
}
|
||||
async function embedCss(css: string, base: string, signal?:AbortSignal) {
|
||||
// 打印进程完全离线,主题资源必须来自应用同源地址并在此转换为 data URL。
|
||||
const matches=[...css.matchAll(/url\(\s*(['"]?)(.*?)\1\s*\)/g)]
|
||||
for(const match of matches) {
|
||||
const url=match[2]!
|
||||
@@ -62,6 +63,7 @@ function stylesheetSnapshot(): {css:string;base:string}[] {
|
||||
}
|
||||
|
||||
export async function preparePdfSnapshot(markdown:string,title:string,options:Options,signal?:AbortSignal,filePath?:string):Promise<string> {
|
||||
// 在任何异步资源请求前冻结主题、排版和编辑器内容,保证产物对应点击导出时的状态。
|
||||
signal?.throwIfAborted()
|
||||
const theme=useThemeStore(), preferences={...useMarkdownPreferencesStore().normalized}, heading=useHeadingAppearanceStore()
|
||||
if(theme.currentThemeId && theme.currentThemeId!==options.theme_id)throw Error('主题在导出准备期间发生变化,请重新导出。')
|
||||
@@ -76,8 +78,9 @@ export async function preparePdfSnapshot(markdown:string,title:string,options:Op
|
||||
const metadata=splitNoteMetadata(markdown)
|
||||
const body=metadata?.body ?? markdown
|
||||
const scope=scopeAttributes(VisualMarkdownEditor)
|
||||
// Match the editor DOM and scoped styles, with read-only metadata controls.
|
||||
// 复用编辑器 DOM 与 scoped 样式;元数据只输出展示内容,不携带编辑控件。
|
||||
const metadataHtml=metadata ? `<section class="note-metadata"${scope} aria-label="${escape(t('笔记属性','Note properties'))}"><span class="metadata-caption"${scope}>${escape(t('笔记属性','Note properties'))}</span>${metadata.title ? `<h1${scope}>${escape(metadata.title)}</h1>` : ''}<div class="metadata-tags"${scope}><span class="metadata-label"${scope}>${escape(t('标签','Tags'))}</span>${metadata.tags.map(tag=>`<span class="metadata-tag"${scope}><span${scope}>${escape(tag)}</span></span>`).join('')}</div></section>` : ''
|
||||
// 仅含元数据的笔记没有正文资源,跳过请求可避免空 Markdown 触发接口的 422 校验。
|
||||
const resources:Resources=body.trim() ? await apiClient.post<Resources>('/api/exports/preview-resources',{format:'pdf',source:{type:'markdown',markdown:body,file_path:filePath},options}) : {images:[],plots:[]}
|
||||
signal?.throwIfAborted()
|
||||
const rendered=await renderMarkdown(body,{themeId:options.theme_id,theme:dark?'dark':'light',preferences,pdf:{mermaidVariables:diagramVariables,plot:async source=>{
|
||||
@@ -91,10 +94,9 @@ export async function preparePdfSnapshot(markdown:string,title:string,options:Op
|
||||
if(!resource?.data)throw Error(resource?.warnings.join('; ')||`PDF 图片无法读取:${source}`)
|
||||
image.src=resource.data
|
||||
}
|
||||
// Print all callout content and remove only interactive tools, not decoration.
|
||||
// 打印全部警告框内容,只移除交互控件,保留主题装饰。
|
||||
fragment.querySelectorAll('details').forEach(d=>d.open=true)
|
||||
// The workspace uses blockquotes for callouts. Preserve that DOM contract so
|
||||
// editor-specific theme selectors apply, including spacing and decoration.
|
||||
// 工作区使用 blockquote 表示警告框;保持相同 DOM 契约,让间距和装饰选择器继续生效。
|
||||
fragment.querySelectorAll('.markdown-callout:not(blockquote)').forEach(details=>{
|
||||
const block=fragment.createElement('blockquote')
|
||||
for(const attribute of [...details.attributes])if(attribute.name!=='open')block.setAttribute(attribute.name,attribute.value)
|
||||
|
||||
@@ -376,7 +376,7 @@ ol {
|
||||
--color-border-disabled: #eadfc4;
|
||||
}
|
||||
|
||||
/* Function plot tokens inherit all installed themes, including custom packages. */
|
||||
/* 函数图颜色继承所有已安装主题,也允许自定义主题包覆盖这些 Token。 */
|
||||
:root {
|
||||
--color-plot-background: var(--color-surface-primary);
|
||||
--color-plot-text: var(--color-text-primary);
|
||||
|
||||
@@ -146,6 +146,7 @@ export async function renderMarkdown(source: string, options?: { themeId?: strin
|
||||
|
||||
for (const code of documentNode.querySelectorAll('pre > code')) {
|
||||
const requestedLanguage = [...code.classList].find((name) => name.startsWith('language-'))?.slice(9) || 'text'
|
||||
// Mermaid 与函数图共用静态图表管线;兼容旧的 function_plot 围栏写法。
|
||||
const diagramKind = requestedLanguage.toLowerCase().split(/\s+/)[0]!.replace('function_plot','function-plot')
|
||||
if (['mermaid', 'function-plot'].includes(diagramKind) && preferences.diagrams) {
|
||||
mermaidBlocks.push({ pre: code.parentElement!, source: code.textContent ?? '', kind: diagramKind })
|
||||
@@ -173,6 +174,7 @@ export async function renderMarkdown(source: string, options?: { themeId?: strin
|
||||
let plotCount = 0, plotNodes = 0
|
||||
for (const { pre, source, kind } of mermaidBlocks) {
|
||||
try {
|
||||
// 交互预览保持数量和 AST 复杂度预算;PDF 已在隔离渲染链路中按需求解除限制。
|
||||
if (!options?.pdf && kind === 'function-plot' && ++plotCount > 16) throw new Error('函数图像数量超过 16')
|
||||
const result = kind === 'function-plot' ? (options?.pdf ? await options.pdf.plot(source) : await renderFunctionPlot(source, options?.themeId)) : await renderMermaid(source, { theme: options?.theme, mode: 'static', ...(options?.pdf ? { unlimited:true, themeVariables:options.pdf.mermaidVariables } : {}) })
|
||||
if (!options?.pdf && 'nodeCount' in result && (plotNodes += Number(result.nodeCount)) > 8000) throw new Error('函数图像累计复杂度超过 8000')
|
||||
|
||||
Reference in New Issue
Block a user