Complete phase two benchmarks, plot previews and static export workflow

This commit is contained in:
2026-09-07 02:54:52 +08:00
parent 95095197df
commit 89df10bc4e
59 changed files with 2535 additions and 83 deletions
+14
View File
@@ -0,0 +1,14 @@
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 })
export const benchmarkService = {
async datasets(kind: 'rag' | 'agent') {
const r = await apiClient.get<{ items: { dataset_id: string; description: string; case_count: number }[] }>('/api/benchmarks/datasets', { params: { kind } })
return r.items.map(d => ({ id: d.dataset_id, description: d.description, cases: d.case_count }))
},
async list() { return (await apiClient.get<{ items: RunWire[] }>('/api/benchmarks/runs')).items.map(map) },
async start(kind: 'rag' | 'agent', body: object) { return map(await apiClient.post<RunWire>(`/api/benchmarks/${kind}/runs`, body)) },
cancel(id: string) { return apiClient.post(`/api/benchmarks/runs/${encodeURIComponent(id)}/cancel`) },
report(id: string) { return apiClient.get<{ metrics: Record<string, unknown>; cases: unknown[]; config_snapshot: Record<string, unknown> }>(`/api/benchmarks/runs/${encodeURIComponent(id)}/report`) },
}
@@ -0,0 +1,18 @@
import {describe,it,expect,vi} from 'vitest'
vi.mock('./apiClient',()=>({apiClient:{post:vi.fn(),get:vi.fn()}}))
import {apiClient} from './apiClient'
import {exportService} from './exportService'
describe('export snapshot contract',()=>{
it('submits the unsaved Markdown snapshot and maps warnings and filename',async()=>{
vi.mocked(apiClient.post).mockResolvedValue({job_id:'job',status:'queued',warnings:['print palette'],file:{file_name:'note.pdf'},error:null})
const job=await exportService.create('# unsaved', 'note','pdf',{theme_id:'dark',include_title:true,page_size:'A4'},undefined,'folder/note.md')
expect(apiClient.post).toHaveBeenCalledWith('/api/exports',expect.objectContaining({source:{type:'markdown',markdown:'# unsaved',file_path:'folder/note.md'},format:'pdf'}))
expect(job.warnings).toEqual(['print palette']);expect(job.fileName).toBe('note.pdf')
})
it('aborted preparation does not create a backend job',async()=>{
vi.mocked(apiClient.post).mockClear()
const abort=new AbortController();abort.abort()
await expect(exportService.create('snapshot','note','html',{theme_id:'light',include_title:true,page_size:'A4'},abort.signal)).rejects.toThrow()
expect(apiClient.post).not.toHaveBeenCalled()
})
})
+68
View File
@@ -0,0 +1,68 @@
import { apiClient } from './apiClient'
import { Marked } from 'marked'
import { renderMermaid } from './mermaidService'
export type ExportFormat = 'html' | 'pdf' | 'docx'
interface JobWire {
job_id: string; status: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
warnings: string[]; error: string | null
file: { file_name: string; size: number } | null
}
export interface ExportJob { id: string; status: JobWire['status']; warnings: string[]; error: string | null; fileName?: string }
const mapJob = (w: JobWire): ExportJob => ({ id: w.job_id, status: w.status, warnings: w.warnings, error: w.error, fileName: w.file?.file_name })
export async function rasterize(svg: string, signal?: AbortSignal): Promise<string> {
const doc = new DOMParser().parseFromString(svg, 'image/svg+xml')
const root = doc.documentElement
const box = root.getAttribute('viewBox')?.split(/[ ,]+/).map(Number)
const width = box?.[2] || 800, height = box?.[3] || 600
if (!Number.isFinite(width + height) || width <= 0 || height <= 0) throw new Error('图表尺寸无效')
const scale = Math.min(4, Math.max(2, 1200 / width), Math.sqrt(4_000_000 / (width * height)))
root.setAttribute('width', String(Math.floor(width * scale))); root.setAttribute('height', String(Math.floor(height * scale)))
root.style.maxWidth = 'none'
const data = new XMLSerializer().serializeToString(root)
const image = new Image()
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(data)}`
await new Promise<void>((resolve, reject) => {
const abort = () => reject(new DOMException('Aborted', 'AbortError'))
const timer = setTimeout(() => reject(new Error('图表图片解码超时')), 15000)
const cleanup = () => { clearTimeout(timer); signal?.removeEventListener('abort', abort) }
if (signal?.aborted) { cleanup(); abort(); return }
signal?.addEventListener('abort', abort, { once: true })
image.decode().then(resolve, reject).finally(cleanup)
})
const canvas = document.createElement('canvas')
canvas.width = Math.floor(width * scale); canvas.height = Math.floor(height * scale)
const context = canvas.getContext('2d')!
context.fillStyle = '#ffffff'; context.fillRect(0, 0, canvas.width, canvas.height)
context.drawImage(image, 0, 0, canvas.width, canvas.height)
return canvas.toDataURL('image/png').split(',')[1]!
}
export async function hashSource(source: string) {
return [...new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(source.trim())))].map(v => v.toString(16).padStart(2, '0')).join('')
}
export const exportService = {
async create(markdown: string, title: string, format: ExportFormat, options: { theme_id: string; include_title: boolean; page_size: string }, signal?: AbortSignal, filePath?: string) {
const blocks: string[] = []
const parser = new Marked()
parser.walkTokens(parser.lexer(markdown), token => { if (token.type === 'code' && token.lang === 'mermaid') blocks.push(token.text) })
const assets = []
for (const source of [...new Set(blocks)]) {
signal?.throwIfAborted()
if (assets.length >= 16) throw new Error('每次导出最多 16 个 Mermaid 图表')
const result = await renderMermaid(source, { mode: 'raster', theme: 'light' })
if (result.warnings.length) throw new Error(`Mermaid 无法导出:${result.warnings.join('; ')}`)
assets.push({ kind: 'mermaid', source_hash: await hashSource(source), png_base64: await rasterize(result.svg, signal) })
}
signal?.throwIfAborted()
return mapJob(await apiClient.post<JobWire>('/api/exports', { source: { type: 'markdown', markdown, file_path: filePath }, title, format, options, assets }))
},
async get(id: string) { return mapJob(await apiClient.get<JobWire>(`/api/exports/${encodeURIComponent(id)}`)) },
async list() { const response = await apiClient.get<{ items: JobWire[] }>('/api/exports'); return response.items.map(mapJob) },
cancel(id: string) { return apiClient.post(`/api/exports/${encodeURIComponent(id)}/cancel`) },
async download(job: ExportJob) {
const response = await apiClient.get<Response>(`/api/exports/${encodeURIComponent(job.id)}/file`)
const url = URL.createObjectURL(await response.blob())
const link = document.createElement('a'); link.href = url; link.download = job.fileName ?? 'export'
link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
},
}
@@ -0,0 +1,22 @@
// @vitest-environment jsdom
import { describe,it,expect,vi,beforeEach } from 'vitest'
vi.mock('./apiClient',()=>({apiClient:{post:vi.fn()}}))
import { apiClient } from './apiClient'
import { renderFunctionPlot } from './functionPlotService'
describe('function plot preview boundary',()=>{
beforeEach(()=>vi.clearAllMocks())
it('shares requests only for the same source and theme, sanitizes SVG',async()=>{
vi.mocked(apiClient.post).mockResolvedValue({result:{content:'<svg onload="alert(1)"><script>alert(2)</script><path d="M0 0L1 1"/></svg>',warnings:[]},diagnostics:[],node_count:3})
const a=await renderFunctionPlot('y = x + 100','dark')
await renderFunctionPlot('y = x + 100','dark')
await renderFunctionPlot('y = x + 100','light')
expect(apiClient.post).toHaveBeenCalledTimes(2)
expect(a.svg).not.toMatch(/onload|script|alert/)
})
it('does not cache network failures',async()=>{
vi.mocked(apiClient.post).mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce({result:null,diagnostics:[{message:'bad expression',line:2}],node_count:0})
await expect(renderFunctionPlot('bad expression')).rejects.toThrow('offline')
const result=await renderFunctionPlot('bad expression')
expect(result.svg).toBe('');expect(result.warnings[0]).toContain('2')
})
})
@@ -0,0 +1,21 @@
import { apiClient } from './apiClient'
import DOMPurify from 'dompurify'
interface PlotWire {
result: { content: string; width: number; height: number; warnings: string[] } | null
diagnostics: { message: string; severity: string; line?: number }[]
node_count: number
}
const cache = new Map<string, Promise<{ svg: string; warnings: string[]; nodeCount: number }>>()
export function renderFunctionPlot(source: string, themeId = 'light') {
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: 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,
})).catch(error => { cache.delete(key); throw error })
if (cache.size >= 32) cache.delete(cache.keys().next().value!)
cache.set(key, result)
return result
}
+9 -9
View File
@@ -8,8 +8,8 @@ function loadMermaid() {
import { computed } from 'vue'
import { useThemeStore } from '@/stores/theme'
export function mermaidThemeVariables(dark: boolean) {
const style = typeof document === 'undefined' ? null : getComputedStyle(document.documentElement)
export function mermaidThemeVariables(dark: boolean, useDocument = true) {
const style = !useDocument || typeof document === 'undefined' ? null : getComputedStyle(document.documentElement)
const color = (name: string, fallback: string) => style?.getPropertyValue(`--color-${name}`).trim() || fallback
const text = color('text-primary', dark ? '#e6edf3' : '#1f2328')
const border = color('border-default', dark ? '#484f58' : '#d0d7de')
@@ -29,15 +29,15 @@ export function mermaidThemeVariables(dark: boolean) {
}
}
async function ensureInitialized(theme: 'light' | 'dark') {
async function ensureInitialized(theme: 'light' | 'dark', raster = false) {
const mermaid = await loadMermaid()
mermaid.initialize({
startOnLoad: false,
theme: 'base',
themeVariables: mermaidThemeVariables(theme === 'dark'),
themeVariables: mermaidThemeVariables(theme === 'dark', !raster),
securityLevel: 'strict',
fontFamily: 'var(--font-ui-sans)',
flowchart: { useMaxWidth: true, htmlLabels: true },
fontFamily: raster ? 'Arial, Microsoft YaHei, sans-serif' : 'var(--font-ui-sans)',
flowchart: { useMaxWidth: true, htmlLabels: !raster },
sequence: { useMaxWidth: true },
gantt: { useMaxWidth: true },
})
@@ -65,18 +65,18 @@ export interface MermaidParseError {
let renderCounter = 0
export function renderMermaid(source: string, options: { theme?: 'light' | 'dark'; mode?: 'interactive' | 'static' } = {}): Promise<MermaidRenderResult> {
export function renderMermaid(source: string, options: { theme?: 'light' | 'dark'; mode?: 'interactive' | 'static' | 'raster' } = {}): Promise<MermaidRenderResult> {
return serialized(() => renderMermaidNow(source, options))
}
async function renderMermaidNow(
source: string,
options: { theme?: 'light' | 'dark'; mode?: 'interactive' | 'static' } = {}
options: { theme?: 'light' | 'dark'; mode?: 'interactive' | 'static' | 'raster' } = {}
): Promise<MermaidRenderResult> {
const theme = options.theme ?? 'light'
const id = `mermaid-${Date.now()}-${++renderCounter}`
try {
const mermaid = await ensureInitialized(theme)
const mermaid = await ensureInitialized(theme, options.mode === 'raster')
const result = await mermaid.render(id, source)
const parser = new DOMParser()
const doc = parser.parseFromString(result.svg, 'image/svg+xml')