docs(code): 补齐第二阶段前后端中文注释

This commit is contained in:
2026-09-07 15:00:37 +08:00
parent ca5b52bc8a
commit 6cd8913d31
24 changed files with 66 additions and 47 deletions
+9 -5
View File
@@ -1,4 +1,4 @@
"""Standard task evaluation over AgentRuntime, never a scripted substitute runner.""" """通过真实 AgentRuntime 执行标准任务评测,不使用脚本化替代运行器。"""
import asyncio import asyncio
from time import perf_counter from time import perf_counter
from uuid import uuid4 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'} INVALID = {'TOOL_NOT_FOUND', 'TOOL_NOT_ALLOWED', 'TOOL_ARGUMENT_INVALID', 'TOOL_VALIDATION_ERROR'}
def score(case, run, events, latency, repeat): def score(case, run, events, latency, repeat):
"""按工具选择、参数、结果、输出和引用要求评定单个样本。"""
calls = [e.data for e in events if e.event.value == 'ToolCall'] 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 = {} matched = {}
def assign(expected_index, visited): def assign(expected_index, visited):
expected = case.expected_tools[expected_index] 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) 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): def aggregate(cases, planned_total=None):
"""汇总已执行样本,并让取消后的未执行样本继续计入计划总数。"""
total = len(cases) if planned_total is None else planned_total total = len(cases) if planned_total is None else planned_total
calls = sum(c.tool_calls for c in cases) calls = sum(c.tool_calls for c in cases)
expected = sum(c.expected_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) 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, 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_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} 'token_usage': sum(c.token_usage for c in cases), 'tool_calls': calls, 'expected_calls': expected}
async def create_run(request: AgentBenchmarkRequest): async def create_run(request: AgentBenchmarkRequest):
"""冻结数据集与运行配置,并把评测交给后台真实 Agent Runtime。"""
from app.container import container from app.container import container
from app.providers.registry import ProviderNotFoundError from app.providers.registry import ProviderNotFoundError
try: try:
@@ -91,6 +94,7 @@ async def create_run(request: AgentBenchmarkRequest):
return run return run
async def execute(run_id, request, dataset, runtime): async def execute(run_id, request, dataset, runtime):
"""顺序执行样本,传播取消信号,并持续发布可订阅的运行事件。"""
flag = service._cancel_flags[run_id] flag = service._cancel_flags[run_id]
results = []; active = None results = []; active = None
def emit(kind, data): 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, token_budget=request.token_budget, run_timeout_seconds=request.timeout_seconds,
tool_timeout_seconds=min(30, request.timeout_seconds), allow_network=request.allow_network, tool_timeout_seconds=min(30, request.timeout_seconds), allow_network=request.allow_network,
metadata={'benchmark_run_id': run_id, 'case_id': case.case_id})) 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 service._runs[run_id].config_snapshot['active_agent_run_id'] = active.run_id
wait = asyncio.create_task(runtime.wait(active.run_id)) wait = asyncio.create_task(runtime.wait(active.run_id))
cancel = asyncio.create_task(flag.wait()) cancel = asyncio.create_task(flag.wait())
+7 -5
View File
@@ -1,4 +1,4 @@
"""Raster-only resources; PDF bypasses export quotas but retains path/format validation.""" """处理栅格资源;PDF 不受导出配额限制,但仍执行路径和格式校验。"""
import base64 import base64
import hashlib import hashlib
import threading import threading
@@ -9,7 +9,7 @@ from app.errors import ApiError
_math_lock = threading.Lock() _math_lock = threading.Lock()
def enrich_document(document, file_path=None, unlimited=False, options=None, preserve_alpha=False): 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 app.config import get_settings
from urllib.parse import unquote, urlsplit from urllib.parse import unquote, urlsplit
vault = get_settings().vault_path.resolve() 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 pixels > 16_000_000: raise ValueError('document pixels')
if not unlimited and image.width * image.height > 4_000_000: raise ValueError('image dimensions') if not unlimited and image.width * image.height > 4_000_000: raise ValueError('image dimensions')
out = BytesIO() 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') 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') background.alpha_composite(rgba); (rgba if preserve_alpha else background.convert('RGB')).save(out,'PNG')
png=out.getvalue();total += len(png) png=out.getvalue();total += len(png)
@@ -70,6 +70,7 @@ def source_hash(source):
return hashlib.sha256(source.strip().encode()).hexdigest() return hashlib.sha256(source.strip().encode()).hexdigest()
def validate_assets(assets, unlimited=False): def validate_assets(assets, unlimited=False):
"""校验前端静态资源并解码为 PNG;PDF 仅解除容量限制,不放宽格式要求。"""
result = {} result = {}
total = pixels = 0 total = pixels = 0
for asset in assets: for asset in assets:
@@ -98,6 +99,7 @@ def validate_assets(assets, unlimited=False):
return result return result
def attach_assets(document, assets): def attach_assets(document, assets):
"""按资源类型和源码哈希把已验证图片挂载到对应文档节点。"""
def visit(node): def visit(node):
source = node.attributes.get('src', '') if node.type == 'image' else node.text source = node.attributes.get('src', '') if node.type == 'image' else node.text
key = (node.type, source_hash(source)) key = (node.type, source_hash(source))
@@ -109,7 +111,7 @@ def attach_assets(document, assets):
visit(child) visit(child)
def plot_png(plot): 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 app.plot.render import compute_geometry, _sx, _sy, _fmt_num
from PIL import ImageDraw, ImageFont from PIL import ImageDraw, ImageFont
geo = compute_geometry(plot) geo = compute_geometry(plot)
@@ -135,7 +137,7 @@ def plot_png(plot):
if geo.xlabel: if geo.xlabel:
draw.text((geo.width, (geo.height - 18)*2), geo.xlabel, fill='#1f2328', font=font, anchor='mm') draw.text((geo.width, (geo.height - 18)*2), geo.xlabel, fill='#1f2328', font=font, anchor='mm')
if geo.ylabel: 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) draw.text((24, 24), geo.ylabel, fill='#1f2328', font=font)
for index, expression in enumerate(plot.expressions): 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) draw.text((48+(index%2)*620,geo.height*2+index//2*48),expression.label or 'y = '+expression.expression,fill=geo.colors[index],font=font)
+6 -4
View File
@@ -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 子进程隔离 Playwright Windows 上的事件循环与 Uvicorn,并把浏览器生命周期限制在
browser lifecycle scoped to one export. Snapshot scripts/network/file loads are 单次导出内。快照禁止脚本、网络和文件加载,字体与图片必须由客户端提前内嵌。
blocked; fonts and images must already be embedded by the client.
""" """
from pathlib import Path from pathlib import Path
import os import os
@@ -14,6 +13,7 @@ from app.export.document import ExportResult
def browser_executable(): def browser_executable():
"""优先使用显式配置,再查找系统已安装的 Chromium 系浏览器。"""
configured = os.environ.get('APP_PDF_BROWSER') configured = os.environ.get('APP_PDF_BROWSER')
if configured: if configured:
return configured return configured
@@ -28,6 +28,7 @@ def browser_executable():
def render_snapshot(snapshot: str, page_size: str) -> ExportResult: def render_snapshot(snapshot: str, page_size: str) -> ExportResult:
"""在隔离子进程中打印快照,避免阻塞或污染服务进程的事件循环。"""
with tempfile.TemporaryDirectory(prefix='notes-pdf-') as directory: with tempfile.TemporaryDirectory(prefix='notes-pdf-') as directory:
source = Path(directory) / 'snapshot.html' source = Path(directory) / 'snapshot.html'
output = Path(directory) / 'document.pdf' 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): def print_snapshot(source: Path, output: Path, page_size: str):
"""在离线、禁用 JavaScript 的上下文中将自包含 HTML 打印为 PDF。"""
from playwright.sync_api import sync_playwright from playwright.sync_api import sync_playwright
with sync_playwright() as runtime: with sync_playwright() as runtime:
browser = runtime.chromium.launch(executable_path=browser_executable(), headless=True) browser = runtime.chromium.launch(executable_path=browser_executable(), headless=True)
+1 -1
View File
@@ -117,7 +117,7 @@ class DocxExporter:
with Image.open(BytesIO(png)) as image: with Image.open(BytesIO(png)) as image:
section = self._doc.sections[-1] section = self._doc.sections[-1]
available_width = (section.page_width - section.left_margin - section.right_margin) / 914400 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 available_height = (section.page_height - section.top_margin - section.bottom_margin) / 914400 - 0.25
width = min(5.8, available_width, width = min(5.8, available_width,
image.width / (180 if node.type == 'math_block' else 96), image.width / (180 if node.type == 'math_block' else 96),
+1 -1
View File
@@ -285,7 +285,7 @@ class PdfExporter:
parts.append(self._render_inline(child.children, warnings)) parts.append(self._render_inline(child.children, warnings))
elif hasattr(self, f"_block_{child.type}"): elif hasattr(self, f"_block_{child.type}"):
flush() flush()
# Keep block content inside the list frame, including tables and callouts. # 表格、警告框等块级内容也要保持在列表缩进框内。
story.append(Indenter(left=indent)) story.append(Indenter(left=indent))
self._render_block(child, story, warnings) self._render_block(child, story, warnings)
story.append(Indenter(left=-indent)) story.append(Indenter(left=-indent))
+2 -1
View File
@@ -1,4 +1,4 @@
"""Embed an available CJK TrueType font; retain the portable CID fallback.""" """嵌入可用的 CJK TrueType 字体,找不到时保留可移植的 CID 字体回退。"""
import os import os
from pathlib import Path from pathlib import Path
from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase import pdfmetrics
@@ -6,6 +6,7 @@ from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfbase.cidfonts import UnicodeCIDFont from reportlab.pdfbase.cidfonts import UnicodeCIDFont
def register_font(): def register_font():
"""按显式配置、系统字体、Linux 字体的顺序注册 PDF 中文字体。"""
candidates = [os.getenv('APP_EXPORT_FONT',''), candidates = [os.getenv('APP_EXPORT_FONT',''),
str(Path(os.getenv('WINDIR','C:/Windows'))/'Fonts/simsun.ttc'), str(Path(os.getenv('WINDIR','C:/Windows'))/'Fonts/simsun.ttc'),
'/usr/share/fonts/truetype/arphic/uming.ttc'] '/usr/share/fonts/truetype/arphic/uming.ttc']
+1
View File
@@ -179,6 +179,7 @@ class _AstMapper:
children=self.map_inline(token.get("children", [])), children=self.map_inline(token.get("children", [])),
) )
if kind == "inline_html": 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}) return DocumentNode(type="text", node_id=self.next_id(), text=token.get("raw", ""), attributes={"raw_html": True})
if kind == "codespan": if kind == "codespan":
return DocumentNode(type="codespan", node_id=self.next_id(), text=token.get("raw", "")) return DocumentNode(type="codespan", node_id=self.next_id(), text=token.get("raw", ""))
+3 -1
View File
@@ -406,7 +406,7 @@ async def wait_for_export(job_id: str) -> ExportJob | None:
async def preview_resources(request: ExportRequest): async def preview_resources(request: ExportRequest):
"""Prepare Vault images and vector plots for the shared browser renderer.""" """为浏览器渲染器准备通过 Vault 校验的图片和静态函数图。"""
import base64 import base64
from app.export.assets import enrich_document from app.export.assets import enrich_document
from app.plot.parser import parse_source from app.plot.parser import parse_source
@@ -418,6 +418,8 @@ async def preview_resources(request: ExportRequest):
document = parse_document(markdown) document = parse_document(markdown)
images, plots = [], [] images, plots = [], []
class HtmlImages(HTMLParser): class HtmlImages(HTMLParser):
# 原始 HTML 只提取 img.src;路径、扩展名和图片格式仍交给 enrich_document 校验。
# 行内代码和代码块在 AST 中不是 HTML 节点,因此不会误当作图片资源。
def handle_starttag(self, tag, attrs): def handle_starttag(self, tag, attrs):
if tag == 'img': if tag == 'img':
src = dict(attrs).get('src') src = dict(attrs).get('src')
+2 -3
View File
@@ -241,9 +241,8 @@ class Runtime:
runtime = 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 # 远程 API 响应以及模型不可用时的回退结果都不进入缓存。
# configuration. No remote API response or unavailable-model fallback is cached.
_embedding_cache = OrderedDict() _embedding_cache = OrderedDict()
_EMBEDDING_CACHE_TTL = 600 _EMBEDDING_CACHE_TTL = 600
+3 -1
View File
@@ -1,4 +1,4 @@
"""Interactive previews use the same bounded parser and geometry as exports.""" """交互预览复用导出使用的有界解析器和几何计算。"""
import asyncio import asyncio
from fastapi import APIRouter from fastapi import APIRouter
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -19,6 +19,7 @@ class PlotResponse(BaseModel):
node_count: int = 0 node_count: int = 0
def preview(request): def preview(request):
"""同步解析并渲染函数图,供受并发限制的异步路由在线程中调用。"""
parsed = parse_source(request.source) parsed = parse_source(request.source)
if parsed.plot is None: if parsed.plot is None:
return PlotResponse(diagnostics=parsed.diagnostics) return PlotResponse(diagnostics=parsed.diagnostics)
@@ -30,5 +31,6 @@ def preview(request):
@router.post('/function', response_model=PlotResponse) @router.post('/function', response_model=PlotResponse)
async def render_function(request: PlotRequest): async def render_function(request: PlotRequest):
# 绘图属于 CPU 密集任务,限制并发并移入线程,避免阻塞事件循环。
async with _slots: async with _slots:
return await asyncio.to_thread(preview, request) return await asyncio.to_thread(preview, request)
+1
View File
@@ -115,6 +115,7 @@ class RetrievalEngine:
candidate_scores = vec_scores candidate_scores = vec_scores
else: # hybridRRF 融合 else: # hybridRRF 融合
if request.fusion == 'weighted': if request.fusion == 'weighted':
# 两路原始分值量纲不同,先各自归一化再等权融合,避免任一路分值范围支配结果。
fts_normal = dict(normalize_scores(list(fts_scores.items()))) fts_normal = dict(normalize_scores(list(fts_scores.items())))
vec_normal = dict(normalize_scores(list(vec_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) candidate_scores = {bid: .5 * fts_normal.get(bid, 0) + .5 * vec_normal.get(bid, 0)
+1 -1
View File
@@ -1,4 +1,4 @@
"""Two bounded live calls for context summary + answer; never changes saved config.""" """执行两次有界真实调用完成上下文摘要与回答,不修改已保存配置。"""
import argparse, asyncio, json, sys import argparse, asyncio, json, sys
from pathlib import Path from pathlib import Path
sys.path.insert(0,str(Path(__file__).resolve().parents[1])) sys.path.insert(0,str(Path(__file__).resolve().parents[1]))
+3 -3
View File
@@ -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. 只批准本次运行产生的 tasks.write 权限票据需要质量验收用 Vault
Calls public API contracts; never writes completion state into SQLite. 脚本仅调用公共 API 契约不把伪造的完成状态写入 SQLite
""" """
import argparse, json, time import argparse, json, time
from pathlib import Path from pathlib import Path
+3 -3
View File
@@ -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 必须显式传入 --execute最多发起 5 次直接模型请求和一组 4 样本 Agent 评测
(6 steps and 6000 tokens per case). No provisioning or external writes. 每个样本最多 6 6000 Token不创建配置也不执行外部写入
""" """
import argparse, asyncio, json, sys import argparse, asyncio, json, sys
from pathlib import Path from pathlib import Path
+3 -3
View File
@@ -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 argparse
import asyncio import asyncio
+1 -2
View File
@@ -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') @pytest.mark.skipif(browser_executable() is None,reason='No installed Chromium browser')
def test_browser_prints_css_without_executing_document_scripts(tmp_path): def test_browser_prints_css_without_executing_document_scripts(tmp_path):
# The script would erase all text if executed. Embedded CSS and fonts must # 若脚本被执行会清空正文;测试同时确认 CSS/字体可用且网络、文件资源保持禁用。
# survive the browser path while network and file resources stay blocked.
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">' 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') result=render_snapshot(html,'A4')
assert result.content.startswith(b'%PDF') assert result.content.startswith(b'%PDF')
@@ -222,6 +222,6 @@ function close() { disarm(); viewer.value?.close(); svgHtml.value = ''; opener?.
</style> </style>
<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; } .function-plot-preview > svg, .markdown-function-plot > svg { min-width: 640px; }
</style> </style>
+1 -2
View File
@@ -81,8 +81,7 @@ const router = createRouter({
router.beforeEach((to) => { router.beforeEach((to) => {
const workspaceStore = useWorkspaceStore() const workspaceStore = useWorkspaceStore()
// A benchmark can run without the workspace UI being open. Its persisted Trace // Benchmark 不依赖工作区界面;报告中的持久化 Trace 和待决权限入口必须仍可访问。
// and permission tickets must remain reachable from the report page.
const existingAgentRun = to.name === 'agent' && Boolean(to.params.runId) const existingAgentRun = to.name === 'agent' && Boolean(to.params.runId)
if (to.meta.requiresVault && !workspaceStore.hasVault && !existingAgentRun) { if (to.meta.requiresVault && !workspaceStore.hasVault && !existingAgentRun) {
return { path: '/' } 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 } 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 } 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 }) 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 = { export const benchmarkService = {
async datasets(kind: 'rag' | 'agent') { async datasets(kind: 'rag' | 'agent') {
const r = await apiClient.get<{ items: { dataset_id: string; description: string; case_count: number }[] }>('/api/benchmarks/datasets', { params: { kind } }) const r = await apiClient.get<{ items: { dataset_id: string; description: string; case_count: number }[] }>('/api/benchmarks/datasets', { params: { kind } })
+4 -4
View File
@@ -11,6 +11,7 @@ interface JobWire {
export interface ExportJob { id: string; status: JobWire['status']; warnings: string[]; error: string | null; fileName?: string } 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 }) 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 type ExportPalette = Record<'page' | 'surface' | 'text' | 'muted' | 'code' | 'border' | 'accent', string>
// 冻结导出开始时的主题颜色,避免后台兼容渲染受到后续主题切换影响。
export function captureExportPalette(): ExportPalette | undefined { export function captureExportPalette(): ExportPalette | undefined {
const style = getComputedStyle(document.documentElement) 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 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('')] 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*\)$/) 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('')] 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-mixOKLCH 和透明色,再冻结为可移植 RGB 色板。
// color implementation before freezing a portable RGB palette.
if (typeof CSS !== 'undefined' && CSS.supports('color', value)) { if (typeof CSS !== 'undefined' && CSS.supports('color', value)) {
const canvas = document.createElement('canvas'); canvas.width = canvas.height = 1 const canvas = document.createElement('canvas'); canvas.width = canvas.height = 1
const context = canvas.getContext('2d') 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 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> { 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 doc = new DOMParser().parseFromString(svg, 'image/svg+xml')
const root = doc.documentElement const root = doc.documentElement
const box = root.getAttribute('viewBox')?.split(/[ ,]+/).map(Number) 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') }) 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() signal?.throwIfAborted()
// Keep the response handle when cancellation arrives during submission: // 提交期间收到取消时仍等待服务器返回任务句柄;只中断 HTTP 会遗留无法追踪的后台任务。
// aborting HTTP alone could leave an undiscoverable running server job.
const job = mapJob(await apiClient.post<JobWire>('/api/exports', { source: { type: 'markdown', markdown, file_path: filePath }, title, format, options, assets, ...(printHtml ? { print_html:printHtml } : {}) })) 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) { if (signal?.aborted) {
await apiClient.post(`/api/exports/${encodeURIComponent(job.id)}/cancel`) 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 }>>() const cache = new Map<string, Promise<{ svg: string; warnings: string[]; nodeCount: number }>>()
export function renderFunctionPlot(source: string, themeId = 'light') { export function renderFunctionPlot(source: string, themeId = 'light') {
// 缓存 Promise 既合并并发的相同请求,也避免重复渲染;失败结果立即移除以允许重试。
const key = JSON.stringify([source, themeId]) const key = JSON.stringify([source, themeId])
if (cache.has(key)) return cache.get(key)! if (cache.has(key)) return cache.get(key)!
const result = apiClient.post<PlotWire>('/api/plots/function', { source, theme_id: themeId }, { timeoutMs: 30000 }).then(wire => ({ 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 } }), svg: DOMPurify.sanitize(wire.result?.content ?? '', { USE_PROFILES: { svg: true } }),
warnings: [...wire.diagnostics.map(d => `${d.message}${d.line ? ` (行 ${d.line})` : ''}`), ...(wire.result?.warnings ?? [])], warnings: [...wire.diagnostics.map(d => `${d.message}${d.line ? ` (行 ${d.line})` : ''}`), ...(wire.result?.warnings ?? [])],
nodeCount: wire.node_count, nodeCount: wire.node_count,
+7 -5
View File
@@ -6,7 +6,7 @@ import { mermaidThemeVariables } from './mermaidService'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import { useMarkdownPreferencesStore } from '@/stores/markdownPreferences' import { useMarkdownPreferencesStore } from '@/stores/markdownPreferences'
import { useHeadingAppearanceStore } from '@/stores/headingAppearance' 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 MarkdownContent from '@/components/common/MarkdownContent.vue'
import VisualMarkdownEditor from '@/features/editor/VisualMarkdownEditor.vue' import VisualMarkdownEditor from '@/features/editor/VisualMarkdownEditor.vue'
void MarkdownContent; void VisualMarkdownEditor 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)}) 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) { async function embedCss(css: string, base: string, signal?:AbortSignal) {
// 打印进程完全离线,主题资源必须来自应用同源地址并在此转换为 data URL。
const matches=[...css.matchAll(/url\(\s*(['"]?)(.*?)\1\s*\)/g)] const matches=[...css.matchAll(/url\(\s*(['"]?)(.*?)\1\s*\)/g)]
for(const match of matches) { for(const match of matches) {
const url=match[2]! 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> { export async function preparePdfSnapshot(markdown:string,title:string,options:Options,signal?:AbortSignal,filePath?:string):Promise<string> {
// 在任何异步资源请求前冻结主题、排版和编辑器内容,保证产物对应点击导出时的状态。
signal?.throwIfAborted() signal?.throwIfAborted()
const theme=useThemeStore(), preferences={...useMarkdownPreferencesStore().normalized}, heading=useHeadingAppearanceStore() const theme=useThemeStore(), preferences={...useMarkdownPreferencesStore().normalized}, heading=useHeadingAppearanceStore()
if(theme.currentThemeId && theme.currentThemeId!==options.theme_id)throw Error('主题在导出准备期间发生变化,请重新导出。') 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 metadata=splitNoteMetadata(markdown)
const body=metadata?.body ?? markdown const body=metadata?.body ?? markdown
const scope=scopeAttributes(VisualMarkdownEditor) 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>` : '' 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:[]} 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() signal?.throwIfAborted()
const rendered=await renderMarkdown(body,{themeId:options.theme_id,theme:dark?'dark':'light',preferences,pdf:{mermaidVariables:diagramVariables,plot:async source=>{ 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}`) if(!resource?.data)throw Error(resource?.warnings.join('; ')||`PDF 图片无法读取:${source}`)
image.src=resource.data image.src=resource.data
} }
// Print all callout content and remove only interactive tools, not decoration. // 打印全部警告框内容,只移除交互控件,保留主题装饰。
fragment.querySelectorAll('details').forEach(d=>d.open=true) fragment.querySelectorAll('details').forEach(d=>d.open=true)
// The workspace uses blockquotes for callouts. Preserve that DOM contract so // 工作区使用 blockquote 表示警告框;保持相同 DOM 契约,让间距和装饰选择器继续生效。
// editor-specific theme selectors apply, including spacing and decoration.
fragment.querySelectorAll('.markdown-callout:not(blockquote)').forEach(details=>{ fragment.querySelectorAll('.markdown-callout:not(blockquote)').forEach(details=>{
const block=fragment.createElement('blockquote') const block=fragment.createElement('blockquote')
for(const attribute of [...details.attributes])if(attribute.name!=='open')block.setAttribute(attribute.name,attribute.value) for(const attribute of [...details.attributes])if(attribute.name!=='open')block.setAttribute(attribute.name,attribute.value)
+1 -1
View File
@@ -376,7 +376,7 @@ ol {
--color-border-disabled: #eadfc4; --color-border-disabled: #eadfc4;
} }
/* Function plot tokens inherit all installed themes, including custom packages. */ /* 函数图颜色继承所有已安装主题,也允许自定义主题包覆盖这些 Token。 */
:root { :root {
--color-plot-background: var(--color-surface-primary); --color-plot-background: var(--color-surface-primary);
--color-plot-text: var(--color-text-primary); --color-plot-text: var(--color-text-primary);
+2
View File
@@ -146,6 +146,7 @@ export async function renderMarkdown(source: string, options?: { themeId?: strin
for (const code of documentNode.querySelectorAll('pre > code')) { for (const code of documentNode.querySelectorAll('pre > code')) {
const requestedLanguage = [...code.classList].find((name) => name.startsWith('language-'))?.slice(9) || 'text' 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') const diagramKind = requestedLanguage.toLowerCase().split(/\s+/)[0]!.replace('function_plot','function-plot')
if (['mermaid', 'function-plot'].includes(diagramKind) && preferences.diagrams) { if (['mermaid', 'function-plot'].includes(diagramKind) && preferences.diagrams) {
mermaidBlocks.push({ pre: code.parentElement!, source: code.textContent ?? '', kind: diagramKind }) 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 let plotCount = 0, plotNodes = 0
for (const { pre, source, kind } of mermaidBlocks) { for (const { pre, source, kind } of mermaidBlocks) {
try { try {
// 交互预览保持数量和 AST 复杂度预算;PDF 已在隔离渲染链路中按需求解除限制。
if (!options?.pdf && kind === 'function-plot' && ++plotCount > 16) throw new Error('函数图像数量超过 16') 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 } : {}) }) 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') if (!options?.pdf && 'nodeCount' in result && (plotNodes += Number(result.nodeCount)) > 8000) throw new Error('函数图像累计复杂度超过 8000')