Complete phase two benchmarks, plot previews and static export workflow
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
const {chromium}=require('playwright');const fs=require('node:fs/promises');const path=require('node:path');
|
||||
(async()=>{
|
||||
if(!process.argv.includes('--execute')&&!process.argv.includes('--reuse'))throw Error('--execute required; four real Agent cases use existing quota');
|
||||
const output=path.resolve('.local-plans/phase2-completion/browser');
|
||||
const browser=await chromium.launch({channel:'msedge',headless:true});
|
||||
const page=await browser.newPage({viewport:{width:1300,height:1000}});
|
||||
await page.goto('http://127.0.0.1:5187/#/benchmarks');
|
||||
await page.getByLabel(/^类型/).selectOption('agent');
|
||||
await page.getByLabel(/^数据集/).selectOption('agent-core-v1');
|
||||
if(!process.argv.includes('--reuse')) await page.getByRole('button',{name:'运行评测',exact:true}).click();
|
||||
const row=page.locator('tbody tr').filter({hasText:'agent-core-v1'}).first();
|
||||
await row.getByRole('button',{name:'查看报告'}).waitFor({timeout:180000});
|
||||
await row.getByRole('button',{name:'查看报告'}).click();
|
||||
await page.getByRole('heading',{name:'评测报告'}).waitFor();
|
||||
await page.screenshot({path:path.join(output,'benchmark-report.png'),fullPage:true});
|
||||
const pending=page.waitForEvent('download');await page.getByRole('button',{name:'下载完整 JSON'}).click();
|
||||
await(await pending).saveAs(path.join(output,'agent-ui-report.json'));
|
||||
await row.getByRole('link',{name:'Agent Trace'}).click();
|
||||
await page.waitForSelector('.agent-page .trace-visualization',{timeout:20000});
|
||||
await page.screenshot({path:path.join(output,'benchmark-trace.png'),fullPage:true});
|
||||
await browser.close();console.log('Benchmark UI start/report/download/Trace completed');
|
||||
})().catch(e=>{console.error(e);process.exit(1)})
|
||||
@@ -0,0 +1,94 @@
|
||||
// Run with NODE_PATH pointing to the bundled Playwright package, or a local install.
|
||||
const { chromium } = require('playwright')
|
||||
const fs = require('node:fs/promises')
|
||||
const path = require('node:path')
|
||||
;(async () => {
|
||||
const output=path.resolve(process.argv[2] || '../.local-plans/phase2-completion/browser')
|
||||
await fs.mkdir(output,{recursive:true})
|
||||
const browser=await chromium.launch({channel:'msedge',headless:true})
|
||||
const results=[]
|
||||
for(const theme of ['light','dark','sepia','paper-moments','ocean-blue','midnight-purple']) {
|
||||
const page=await browser.newPage({viewport:{width:1200,height:1000}})
|
||||
const errors=[];page.on('pageerror',e=>errors.push(e.message))
|
||||
await page.goto(`http://127.0.0.1:5187/tests/visual/phase2.html?theme=${theme}`)
|
||||
await page.waitForSelector('.editor-mermaid-preview polyline',{timeout:60000})
|
||||
await page.waitForSelector('.markdown-mermaid polyline',{timeout:60000})
|
||||
const zoom=page.locator('.markdown-mermaid').first()
|
||||
await zoom.hover()
|
||||
await zoom.locator('[data-diagram-action="in"]').click()
|
||||
if(Number(await zoom.getAttribute('data-diagram-scale'))<=1) throw Error('zoom failed')
|
||||
await zoom.locator('[data-diagram-action="reset"]').click()
|
||||
await zoom.locator('[data-code-action="source"]').click()
|
||||
if(!(await zoom.locator('.markdown-code-source').isVisible())) throw Error('source toggle failed')
|
||||
await zoom.locator('[data-code-action="source"]').click()
|
||||
await zoom.hover()
|
||||
await zoom.locator('[data-diagram-action="view"]').click()
|
||||
await page.screenshot({path:path.join(output,`${theme}-viewer.png`)})
|
||||
await page.keyboard.press('Escape')
|
||||
await page.screenshot({path:path.join(output,`${theme}-wide.png`),fullPage:true})
|
||||
await page.setViewportSize({width:390,height:844})
|
||||
await page.screenshot({path:path.join(output,`${theme}-narrow.png`),fullPage:true})
|
||||
if(theme==='light') {
|
||||
await page.setViewportSize({width:1200,height:1000})
|
||||
await page.getByRole('button',{name:'导出',exact:true}).click()
|
||||
for(const format of ['html','pdf','docx']) {
|
||||
await page.getByLabel('格式',{exact:true}).selectOption(format)
|
||||
await page.getByRole('button',{name:'开始导出',exact:true}).click()
|
||||
const row=page.locator('.export-modal > ul > li').filter({hasText:`phase2-demo.${format}`}).first()
|
||||
await row.getByRole('button',{name:'下载',exact:true}).waitFor({timeout:60000})
|
||||
const download=page.waitForEvent('download')
|
||||
await row.getByRole('button',{name:'下载',exact:true}).click()
|
||||
await (await download).saveAs(path.join(output,`demo.${format}`))
|
||||
}
|
||||
await page.screenshot({path:path.join(output,'export-jobs.png')})
|
||||
}
|
||||
if(theme==='light') {
|
||||
await page.getByRole('button',{name:'关闭',exact:true}).click()
|
||||
await page.getByRole('button',{name:'源码',exact:true}).click()
|
||||
const input=page.getByRole('textbox',{name:'Markdown 源码编辑器'})
|
||||
const original=await input.inputValue()
|
||||
await input.fill(original.replace('y = x^2','y = cos(x)'))
|
||||
await page.getByRole('button',{name:'写作',exact:true}).click()
|
||||
await page.waitForFunction(()=>document.querySelector('.editor-mermaid-preview svg')?.textContent.includes('cos(x)'))
|
||||
await page.getByRole('button',{name:'测试切换主题'}).click()
|
||||
await page.waitForFunction(()=>document.documentElement.dataset.theme==='dark')
|
||||
await page.waitForFunction(()=>document.querySelector('.editor-mermaid-preview svg rect')?.getAttribute('fill')!=='#ffffff')
|
||||
await page.getByRole('button',{name:'源码',exact:true}).click()
|
||||
await input.fill(original.replace('y = x^2','y = __import__("os")'))
|
||||
await page.getByRole('button',{name:'写作',exact:true}).click()
|
||||
await page.waitForFunction(()=>!document.querySelector('.editor-mermaid-preview polyline')&&!document.querySelector('.markdown-function-plot polyline'))
|
||||
await page.screenshot({path:path.join(output,'invalid-expression.png'),fullPage:true})
|
||||
await page.getByRole('button',{name:'源码',exact:true}).click()
|
||||
await input.fill(original)
|
||||
await page.getByRole('button',{name:'写作',exact:true}).click()
|
||||
await page.waitForSelector('.editor-mermaid-preview polyline')
|
||||
await page.getByRole('button',{name:'导出',exact:true}).click()
|
||||
await page.getByLabel('格式',{exact:true}).selectOption('pdf')
|
||||
await page.getByRole('button',{name:'开始导出',exact:true}).click()
|
||||
await page.locator('.export-warnings').first().waitFor({timeout:60000})
|
||||
await page.screenshot({path:path.join(output,'print-warning.png')})
|
||||
}
|
||||
results.push({theme,errors,plotCount:await page.locator('polyline').count()});await page.close()
|
||||
}
|
||||
const cancellation=await browser.newPage()
|
||||
await cancellation.goto('http://127.0.0.1:5187/tests/visual/phase2.html')
|
||||
await cancellation.getByRole('button',{name:'源码',exact:true}).click()
|
||||
const heavy=Array(13).fill('```function-plot\ny = '+Array(150).fill('(x+x)').join('+')+'\n```').join('\n\n')
|
||||
await cancellation.getByRole('textbox',{name:'Markdown 源码编辑器'}).fill(heavy)
|
||||
await cancellation.getByRole('button',{name:'导出',exact:true}).click()
|
||||
await cancellation.getByLabel('格式',{exact:true}).selectOption('pdf')
|
||||
await cancellation.getByRole('button',{name:'开始导出',exact:true}).click()
|
||||
const cancelRow=cancellation.locator('.export-modal > ul > li').first()
|
||||
await cancelRow.getByRole('button',{name:'取消',exact:true}).click()
|
||||
await cancellation.waitForFunction(()=>document.querySelector('.export-modal > ul > li')?.textContent.includes('已取消'),null,{timeout:60000})
|
||||
await cancellation.screenshot({path:path.join(output,'export-cancelled.png')})
|
||||
await cancellation.close()
|
||||
const matrix=await browser.newPage()
|
||||
await matrix.goto('http://127.0.0.1:5187/tests/visual/mermaid-matrix.html')
|
||||
await matrix.waitForFunction(()=>document.documentElement.dataset.complete==='true',null,{timeout:120000})
|
||||
const mermaid=JSON.parse(await matrix.locator('#status').textContent())
|
||||
if(mermaid.passed!==36 || mermaid.total!==36) throw Error('Mermaid matrix failed')
|
||||
await fs.writeFile(path.join(output,'mermaid-matrix.json'),JSON.stringify(mermaid,null,2))
|
||||
await matrix.close()
|
||||
await fs.writeFile(path.join(output,'results.json'),JSON.stringify(results,null,2));console.log(JSON.stringify(results));await browser.close()
|
||||
})().catch(e=>{console.error(e);process.exit(1)})
|
||||
@@ -220,3 +220,8 @@ function close() { disarm(); viewer.value?.close(); svgHtml.value = ''; opener?.
|
||||
:is(.editor-mermaid-preview, .markdown-mermaid):is(:hover, :focus-within) > .diagram-controls { opacity: 1; pointer-events: auto; }
|
||||
@media (hover: none) { :is(.editor-mermaid-preview, .markdown-mermaid) > .diagram-controls { opacity: 1; pointer-events: auto; } }
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* Keep 10px axis labels readable on narrow screens; the existing container scrolls. */
|
||||
.function-plot-preview > svg, .markdown-function-plot > svg { min-width: 640px; }
|
||||
</style>
|
||||
|
||||
@@ -24,7 +24,7 @@ const diagramTheme = computed<'light' | 'dark'>(() => (themeStore.isDark ? 'dark
|
||||
// 主题切换需要重渲染:Mermaid SVG 的配色在渲染时烘焙,无法靠 CSS 变量事后调整。
|
||||
watch([() => props.source, diagramTheme, () => themeStore.currentThemeId, () => JSON.stringify(markdownPreferences.normalized), () => JSON.stringify([props.citationNumbers, props.citationAliases])], async ([source, theme]) => {
|
||||
const version = ++renderVersion
|
||||
const result = await renderMarkdown(source, { theme, preferences: markdownPreferences.normalized, citationNumbers: props.citationNumbers, citationAliases: props.citationAliases })
|
||||
const result = await renderMarkdown(source, { theme, themeId: themeStore.currentThemeId, preferences: markdownPreferences.normalized, citationNumbers: props.citationNumbers, citationAliases: props.citationAliases })
|
||||
if (version === renderVersion) html.value = result
|
||||
}, { immediate: true, flush: 'post' })
|
||||
</script>
|
||||
|
||||
@@ -20,6 +20,7 @@ const navItems = computed(() => [
|
||||
{ name: 'plugins', icon: Connection, label: 'Plugin' },
|
||||
{ name: 'mcp-servers', icon: Monitor, label: 'MCP' },
|
||||
{ name: 'themes', icon: Brush, label: t('主题', 'Themes') },
|
||||
{ name: 'benchmarks', icon: Monitor, label: 'Benchmark' },
|
||||
{ name: 'logs', icon: Document, label: t('日志', 'Logs') },
|
||||
{ name: 'settings', icon: Setting, label: t('设置', 'Settings') },
|
||||
])
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { benchmarkService as service, type BenchmarkRun } from '@/services/benchmarkService'
|
||||
import { listProviders } from '@/services/providerService'
|
||||
const kind = ref<'rag' | 'agent'>('rag'), dataset = ref(''), error = ref(''), busy = ref(false)
|
||||
const datasets = ref<Awaited<ReturnType<typeof service.datasets>>>([]), runs = ref<BenchmarkRun[]>([])
|
||||
const providers = ref<Awaited<ReturnType<typeof listProviders>>>([]), provider = ref(''), model = ref('')
|
||||
const report = ref<Awaited<ReturnType<typeof service.report>> | null>(null)
|
||||
const topK = ref(5), rrfK = ref(60), rerank = ref(false)
|
||||
const fusion = ref<'rrf' | 'weighted'>('rrf')
|
||||
const metricLabels: Record<string, string> = {
|
||||
total_cases: '计划样本', evaluated_cases: '已评样本', successful_cases: '运行成功', failed_cases: '运行失败',
|
||||
task_success_rate: '任务成功率', tool_selection_accuracy: '工具选择准确率', tool_argument_accuracy: '参数准确率',
|
||||
invalid_tool_call_rate: '无效调用率', average_steps: '平均步骤', average_latency_ms: '平均耗时 (ms)',
|
||||
token_usage: 'Token 用量', tool_calls: '实际工具调用', expected_calls: '预期工具调用',
|
||||
hit_at_1: 'Hit@1', hit_at_5: 'Hit@5', recall_at_k: 'Recall@K', mrr: 'MRR', citation_hit_rate: '引用命中率',
|
||||
p50_latency_ms: 'P50 (ms)', p95_latency_ms: 'P95 (ms)', failure_rate: '运行失败率',
|
||||
}
|
||||
const metricGroups = computed(() => {
|
||||
const metrics = report.value?.metrics ?? {}
|
||||
const groups = 'task_success_rate' in metrics ? { Agent: metrics } : metrics
|
||||
return Object.entries(groups).filter(([, value]) => value && typeof value === 'object').map(([name, value]) => ({
|
||||
name, rows: Object.entries(value as Record<string, unknown>).map(([key, number]) => ({
|
||||
label: metricLabels[key] ?? key,
|
||||
value: number === null ? '不适用' : typeof number === 'number' ? Number(number.toFixed(4)).toLocaleString() : String(number),
|
||||
})),
|
||||
}))
|
||||
})
|
||||
let timer: ReturnType<typeof setTimeout> | undefined, disposed = false
|
||||
async function loadDatasets() { try { datasets.value = await service.datasets(kind.value); dataset.value = datasets.value[0]?.id ?? '' } catch(e) { error.value = String(e) } }
|
||||
async function refresh() { try { runs.value = await service.list() } catch(e) { error.value = String(e) } if (!disposed) timer = setTimeout(refresh, 1500) }
|
||||
watch(kind, loadDatasets)
|
||||
watch(provider, id => { model.value = providers.value.find(p => p.provider_id === id)?.default_model ?? '' })
|
||||
async function start() {
|
||||
error.value = ''; busy.value = true
|
||||
try { await service.start(kind.value, kind.value === 'agent' ? { dataset_id: dataset.value, provider_id: provider.value, model: model.value, max_steps: 6, timeout_seconds: 90, token_budget: 6000 } : { dataset_id: dataset.value, modes: ['fts','vector','hybrid'], retrieval: { top_k: topK.value, fusion: fusion.value, rrf_k: rrfK.value, rerank: rerank.value } }) }
|
||||
catch(e) { error.value = String(e) } finally { busy.value = false }
|
||||
}
|
||||
async function action(run: BenchmarkRun, cancel = false) { try { if (cancel) await service.cancel(run.id); else report.value = await service.report(run.id) } catch(e) { error.value = String(e) } }
|
||||
function download() { const url = URL.createObjectURL(new Blob([JSON.stringify(report.value,null,2)], { type:'application/json' })); const a=document.createElement('a'); a.href=url; a.download='benchmark-report.json'; a.click(); setTimeout(()=>URL.revokeObjectURL(url),1000) }
|
||||
onMounted(async () => { void refresh(); void loadDatasets(); try { providers.value=(await listProviders()).filter(p=>p.enabled); provider.value=providers.value[0]?.provider_id ?? '' } catch(e) { error.value=String(e) } })
|
||||
onBeforeUnmount(() => { disposed=true; clearTimeout(timer) })
|
||||
</script>
|
||||
<template>
|
||||
<main class="benchmark-page"><h1>Benchmark 评测</h1><p>标准数据集通过真实检索引擎或 Agent Runtime 执行。Agent 会使用所选提供商额度;需要权限时请打开 Trace 处理。</p>
|
||||
<div class="controls"><label>类型 <select v-model="kind"><option value="rag">RAG</option><option value="agent">Agent</option></select></label>
|
||||
<label>数据集 <select v-model="dataset"><option v-for="d in datasets" :key="d.id" :value="d.id">{{ d.id }} · {{ d.cases }} 案例</option></select></label>
|
||||
<template v-if="kind === 'agent'"><label>提供商 <select v-model="provider"><option v-for="p in providers" :key="p.provider_id" :value="p.provider_id">{{ p.name }}</option></select></label><label>模型 <input v-model="model"></label></template>
|
||||
<template v-else><label>融合 <select v-model="fusion"><option value="rrf">RRF</option><option value="weighted">加权 50/50</option></select></label><label>Top K <input v-model.number="topK" type="number" min="1" max="100"></label><label>RRF K <input v-model.number="rrfK" type="number" min="1"></label><label><input v-model="rerank" type="checkbox">Lexical Reranker</label></template>
|
||||
<button :disabled="busy || !dataset || (kind === 'agent' && (!provider || !model))" @click="start">运行评测</button></div>
|
||||
<p v-if="error" role="alert">{{ error }}</p>
|
||||
<table><thead><tr><th>数据集</th><th>状态</th><th>操作</th></tr></thead><tbody><tr v-for="run in runs" :key="run.id"><td>{{ run.datasetId }}<small>{{ run.id }}</small></td><td>{{ run.status }} {{ run.progress === null ? '' : `${Math.round(run.progress*100)}%` }} {{ run.errorCode }}</td><td><button v-if="['queued','running'].includes(run.status)" @click="action(run,true)">取消</button><button v-else @click="action(run)">查看报告</button><RouterLink v-if="run.agentId" :to="`/agent/runs/${run.agentId}`">Agent Trace</RouterLink></td></tr></tbody></table>
|
||||
<section v-if="report"><h2>评测报告</h2><button class="button-secondary" @click="download">下载完整 JSON</button>
|
||||
<div v-for="group in metricGroups" :key="group.name"><h3>{{ group.name }}</h3><dl class="metric-grid"><div v-for="row in group.rows" :key="row.label"><dt>{{ row.label }}</dt><dd>{{ row.value }}</dd></div></dl></div>
|
||||
<details><summary>冻结配置与逐例证据</summary><pre>{{ JSON.stringify(report,null,2) }}</pre></details></section>
|
||||
</main>
|
||||
</template>
|
||||
<style scoped>
|
||||
.benchmark-page { padding:24px; overflow:auto; width:100%; } .controls { display:flex; gap:12px; flex-wrap:wrap; } label { display:flex; align-items:center; gap:6px; } input[type=number] { width:80px; } table { width:100%; margin-block:20px; border-collapse:collapse; } td,th { text-align:left; padding:12px; border-bottom:1px solid var(--color-border-default); } small { display:block; } pre { white-space:pre-wrap; overflow-wrap:anywhere; } [role=alert] { color:var(--color-error); }
|
||||
.metric-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:12px; margin:16px 0; }
|
||||
.metric-grid > div { padding:16px; border:1px solid var(--color-border-default); border-radius:8px; background:var(--color-surface-primary); }
|
||||
dt { font-size:13px; color:var(--color-text-secondary); } dd { margin:8px 0 0; font-size:22px; font-weight:600; }
|
||||
button { padding:6px 12px; border:1px solid var(--color-border-default); border-radius:6px; background:var(--color-surface-primary); cursor:pointer; }
|
||||
button:disabled { opacity:.5; cursor:default; } td a { margin-left:12px; } input:not([type=checkbox]) { border:1px solid var(--color-border-default); border-radius:6px; padding:6px; }
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import ExportDialog from './ExportDialog.vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import ActionDialog from '@/components/common/ActionDialog.vue'
|
||||
import { useActionDialog } from '@/composables/useActionDialog'
|
||||
@@ -10,6 +11,7 @@ const editorStore = useEditorStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
|
||||
const reloadError = ref('')
|
||||
const exportOpen = ref(false)
|
||||
const needsRecovery = computed(() => ['conflict', 'external_changed'].includes(editorStore.saveStatus))
|
||||
const missingFile = computed(() => needsRecovery.value && editorStore.currentFilePath === workspaceStore.activeFilePath && !workspaceStore.activeFile && !workspaceStore.treeRefreshError)
|
||||
function downloadCopy() {
|
||||
@@ -43,9 +45,11 @@ const statusText = computed<Record<string, string>>(() => ({
|
||||
|
||||
<template>
|
||||
<header class="editor-header">
|
||||
<ExportDialog v-if="exportOpen" @close="exportOpen = false" />
|
||||
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
|
||||
<div class="file-identity"><strong>{{ workspaceStore.activeFile?.name ?? t('未命名笔记', 'Untitled note') }}</strong><small>{{ workspaceStore.activeFilePath }}</small></div>
|
||||
<div class="editor-actions">
|
||||
<button class="button-secondary" @click="exportOpen = true">{{ t('导出', 'Export') }}</button>
|
||||
<span class="save-status" :class="editorStore.saveStatus">{{ statusText[editorStore.saveStatus] }}</span>
|
||||
<button v-if="needsRecovery && !missingFile" class="button-secondary" @click="reload">{{ t('重新加载外部版本', 'Reload external version') }}</button>
|
||||
<span v-if="missingFile" class="save-status conflict">{{ t('原文件已删除或移动', 'Original file deleted or moved') }}</span>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import AppDialog from '@/components/common/AppDialog.vue'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { exportService, type ExportFormat, type ExportJob } from '@/services/exportService'
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
const editor = useEditorStore(), theme = useThemeStore()
|
||||
const format = ref<ExportFormat>('html'), page = ref('A4'), title = ref(true)
|
||||
const jobs = ref<ExportJob[]>([]), error = ref(''), preparing = ref(false)
|
||||
let timer: ReturnType<typeof setTimeout> | undefined, disposed = false
|
||||
let controller: AbortController | undefined
|
||||
const labels = { queued: '排队中', running: '渲染中', completed: '已完成', failed: '失败', cancelled: '已取消' }
|
||||
async function refresh() {
|
||||
try { const value = await exportService.list(); if (!disposed) jobs.value = value } catch (e) { error.value = String(e) }
|
||||
if (!disposed) timer = setTimeout(refresh, 1500)
|
||||
}
|
||||
async function start() {
|
||||
preparing.value = true; error.value = ''; controller = new AbortController()
|
||||
const snapshot = editor.content, name = editor.currentFilePath?.split('/').pop()?.replace(/\.md$/i, '') ?? '笔记'
|
||||
try {
|
||||
const job = await exportService.create(snapshot, name, format.value, { theme_id: theme.currentThemeId, include_title: title.value, page_size: page.value }, controller.signal, editor.currentFilePath ?? undefined)
|
||||
if (!disposed) jobs.value.unshift(job)
|
||||
} catch (e) { error.value = controller.signal.aborted ? '已取消图表准备' : String(e) }
|
||||
finally { preparing.value = false }
|
||||
}
|
||||
async function action(job: ExportJob, download = false) {
|
||||
try { if (download) await exportService.download(job); else await exportService.cancel(job.id) } catch (e) { error.value = String(e) }
|
||||
}
|
||||
onMounted(refresh)
|
||||
onBeforeUnmount(() => { disposed = true; clearTimeout(timer); controller?.abort() })
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog label="导出笔记" @close="emit('close')"><section class="modal export-modal">
|
||||
<h2>导出笔记</h2><p>导出点击时的编辑器快照,包含未保存修改。关闭窗口后后台任务继续运行。</p>
|
||||
<label for="export-format">格式</label><select id="export-format" v-model="format"><option value="html">HTML</option><option value="pdf">PDF</option><option value="docx">DOCX</option></select>
|
||||
<label>纸张 <select v-model="page"><option>A4</option><option>Letter</option></select></label>
|
||||
<label><input v-model="title" type="checkbox">包含标题</label>
|
||||
<p v-if="format !== 'html'">PDF / DOCX 使用浅色打印样式。</p>
|
||||
<button class="button-primary" :disabled="preparing || !editor.content.trim()" @click="start">{{ preparing ? '准备图表…' : '开始导出' }}</button>
|
||||
<button v-if="preparing" @click="controller?.abort()">取消准备</button>
|
||||
<p v-if="error" role="alert">{{ error }}</p>
|
||||
<ul><li v-for="job in jobs" :key="job.id">
|
||||
<strong>{{ job.fileName ?? job.id }}</strong> · {{ labels[job.status] }}
|
||||
<button v-if="job.status === 'completed'" @click="action(job, true)">下载</button>
|
||||
<button v-if="['queued','running'].includes(job.status)" @click="action(job)">取消</button>
|
||||
<p v-if="job.error" role="alert">{{ job.error }}</p>
|
||||
<ul v-if="job.warnings.length" class="export-warnings" aria-label="导出警告"><li v-for="warning in job.warnings" :key="warning">{{ warning }}</li></ul>
|
||||
</li></ul>
|
||||
<button class="button-secondary" @click="emit('close')">关闭</button>
|
||||
</section></AppDialog>
|
||||
</template>
|
||||
<style scoped>
|
||||
.export-modal { width: min(640px, 100%); padding: 24px; background: var(--color-surface-primary); border: 1px solid var(--color-border-default); border-radius: 12px; }
|
||||
label { display: inline-flex; align-items:center; gap: 8px; margin: 8px; } li { margin-block: 12px; overflow-wrap: anywhere; } button { margin: 6px; } .export-warnings { color: var(--color-warning); } [role=alert] { color:var(--color-error); }
|
||||
</style>
|
||||
@@ -156,20 +156,20 @@ function foldHeadings(action: 'toggle' | 'all' | 'none') {
|
||||
}
|
||||
})
|
||||
}
|
||||
const diagramPreviews = new Map<string, { source: string; apply: (value: HTMLElement) => void }>()
|
||||
function renderDiagram(source: string, apply: (value: HTMLElement) => void) {
|
||||
const diagramPreviews = new Map<string, { source: string; kind: string; apply: (value: HTMLElement) => void }>()
|
||||
function renderDiagram(source: string, apply: (value: HTMLElement) => void, kind = 'mermaid') {
|
||||
for (const [id, entry] of diagramPreviews) {
|
||||
if (entry.apply === apply) diagramPreviews.delete(id)
|
||||
}
|
||||
const element = createMermaidPreview(source, themeStore.isDark, apply)
|
||||
diagramPreviews.set(element.dataset.previewId!, { source, apply })
|
||||
const element = createMermaidPreview(source, themeStore.isDark, apply, kind, themeStore.currentThemeId)
|
||||
diagramPreviews.set(element.dataset.previewId!, { source, apply, kind })
|
||||
return element
|
||||
}
|
||||
watch(() => themeStore.currentThemeId, () => {
|
||||
const current = [...diagramPreviews.entries()]
|
||||
diagramPreviews.clear()
|
||||
for (const [id, entry] of current) {
|
||||
if (editorRoot.value?.querySelector(`[id="${id}"]`)) entry.apply(renderDiagram(entry.source, entry.apply))
|
||||
if (editorRoot.value?.querySelector(`[id="${id}"]`)) entry.apply(renderDiagram(entry.source, entry.apply, entry.kind))
|
||||
}
|
||||
}, { flush: 'post' })
|
||||
|
||||
@@ -333,8 +333,8 @@ onMounted(async () => {
|
||||
...config,
|
||||
languages: shikiLanguages(themeStore.resolvedCodeBlockTheme),
|
||||
renderLanguage: renderCodeLanguage,
|
||||
renderPreview: (language, content, applyPreview) => language.trim().toLowerCase() === 'mermaid'
|
||||
? markdownPreferences.diagrams ? renderDiagram(content, applyPreview) : null
|
||||
renderPreview: (language, content, applyPreview) => ['mermaid', 'function-plot'].includes(language.trim().toLowerCase())
|
||||
? markdownPreferences.diagrams ? renderDiagram(content, applyPreview, language.trim().toLowerCase()) : null
|
||||
: config.renderPreview(language, content, applyPreview),
|
||||
extensions: [basicSetup, keymap.of([indentWithTab]), shikiEditorTheme(themeStore.resolvedCodeBlockTheme),
|
||||
indentUnit.of(' '.repeat(markdownPreferences.indent)), CodeEditorState.tabSize.of(markdownPreferences.indent),
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { renderFunctionPlot } from '@/services/functionPlotService'
|
||||
import { nextTick } from 'vue'
|
||||
import { renderMermaid } from '@/services/mermaidService'
|
||||
import { t } from '@/i18n'
|
||||
import { appendDiagramControls } from '@/utils/diagramControls'
|
||||
|
||||
let previewId = 0
|
||||
export function createMermaidPreview(source: string, dark: boolean, applyPreview: (value: HTMLElement) => void): HTMLElement {
|
||||
export function createMermaidPreview(source: string, dark: boolean, applyPreview: (value: HTMLElement) => void, kind = 'mermaid', themeId = 'light'): HTMLElement {
|
||||
// Each revision owns its element, so a slow render cannot replace newer content.
|
||||
// Milkdown sanitizes Element input to its inner HTML; retain the revision
|
||||
// marker and controls inside an otherwise disposable envelope.
|
||||
const envelope = document.createElement('div')
|
||||
const container = document.createElement('div')
|
||||
envelope.append(container)
|
||||
container.className = 'editor-mermaid-preview'
|
||||
container.className = 'editor-mermaid-preview' + (kind === 'function-plot' ? ' function-plot-preview' : '')
|
||||
container.id = `editor-mermaid-preview-${++previewId}`
|
||||
envelope.dataset.previewId = container.id
|
||||
container.setAttribute('aria-live', 'polite')
|
||||
@@ -27,8 +28,11 @@ export function createMermaidPreview(source: string, dark: boolean, applyPreview
|
||||
applyPreview(envelope.cloneNode(true) as HTMLElement)
|
||||
}
|
||||
}
|
||||
void renderMermaid(source, { theme: dark ? 'dark' : 'light' }).then(result => {
|
||||
if (result.warnings.length) {
|
||||
void (kind === 'function-plot' ? new Promise<void>(resolve => setTimeout(resolve, 180)) : Promise.resolve()).then<{ svg: string; warnings: string[] }>(() => {
|
||||
if (kind === 'function-plot' && !document.getElementById(container.id)) throw new Error('stale preview')
|
||||
return kind === 'function-plot' ? renderFunctionPlot(source, themeId) : renderMermaid(source, { theme: dark ? 'dark' : 'light' })
|
||||
}).then(result => {
|
||||
if (result.warnings.length && (kind === 'mermaid' || !result.svg)) {
|
||||
container.classList.add('has-error')
|
||||
container.textContent = `${t('图表语法有误,可点击编辑修改:', 'Diagram syntax error. Choose Edit to fix:')} ${result.warnings.join('\n')}`
|
||||
void publish()
|
||||
@@ -37,6 +41,7 @@ export function createMermaidPreview(source: string, dark: boolean, applyPreview
|
||||
// Mermaid runs in strict mode; Milkdown sanitizes the preview before insertion.
|
||||
container.innerHTML = result.svg
|
||||
appendDiagramControls(container)
|
||||
if (result.warnings.length) { const warning = document.createElement('p'); warning.textContent = result.warnings.join('\n'); warning.setAttribute('role', 'status'); container.append(warning) }
|
||||
void publish()
|
||||
}).catch(() => {
|
||||
container.textContent = t('图表渲染失败,请点击编辑检查源码。', 'Unable to render diagram. Choose Edit to inspect the source.')
|
||||
|
||||
@@ -69,7 +69,7 @@ it('offers fenced-code aliases and retains the LaTeX selector', () => {
|
||||
|
||||
it('offers every bundled Shiki language and alias', () => {
|
||||
const languages = shikiLanguages('github-light')
|
||||
expect(languages).toHaveLength(bundledLanguagesInfo.length + 1)
|
||||
expect(languages).toHaveLength(bundledLanguagesInfo.length + 2)
|
||||
for (const info of bundledLanguagesInfo) {
|
||||
const language = languages.find(item => item.alias.includes(info.id))!
|
||||
expect(language, info.id).toBeDefined()
|
||||
|
||||
@@ -60,6 +60,7 @@ export async function shikiLanguage(language: string, theme: CodeTheme): Promise
|
||||
|
||||
export function shikiLanguages(theme: CodeTheme): LanguageDescription[] {
|
||||
return [
|
||||
LanguageDescription.of({ name: 'function-plot', alias: ['Function Plot'], load: () => shikiLanguage('text', theme) }),
|
||||
...bundledLanguagesInfo.map(info => LanguageDescription.of({
|
||||
name: info.id,
|
||||
alias: [info.name, ...(info.aliases ?? [])],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const routes = [
|
||||
{ path: '/benchmarks', name: 'benchmarks', component: () => import('@/features/benchmarks/BenchmarkView.vue'), meta: { title: 'Benchmark' } },
|
||||
{ path: '/logs', name: 'logs', component: () => import('@/features/logs/LogsView.vue'), meta: { title: '运行日志' } },
|
||||
{ path: '/media', name: 'media', component: () => import('@/features/media/MediaView.vue'), meta: { title: '音视频转写', requiresVault: true } },
|
||||
{
|
||||
@@ -80,7 +81,10 @@ const router = createRouter({
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
if (to.meta.requiresVault && !workspaceStore.hasVault) {
|
||||
// A benchmark can run without the workspace UI being open. Its persisted Trace
|
||||
// and permission tickets must remain reachable from the report page.
|
||||
const existingAgentRun = to.name === 'agent' && Boolean(to.params.runId)
|
||||
if (to.meta.requiresVault && !workspaceStore.hasVault && !existingAgentRun) {
|
||||
return { path: '/' }
|
||||
}
|
||||
if (to.path === '/' && workspaceStore.hasVault) {
|
||||
|
||||
@@ -0,0 +1,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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -375,3 +375,41 @@ ol {
|
||||
--color-border-focus: #8a5b32;
|
||||
--color-border-disabled: #eadfc4;
|
||||
}
|
||||
|
||||
/* Function plot tokens inherit all installed themes, including custom packages. */
|
||||
:root {
|
||||
--color-plot-background: var(--color-surface-primary);
|
||||
--color-plot-text: var(--color-text-primary);
|
||||
--color-plot-axis: var(--color-text-secondary);
|
||||
--color-plot-grid: var(--color-border-default);
|
||||
--color-plot-curve-0: #0969da;
|
||||
--color-plot-curve-1: #d1242f;
|
||||
--color-plot-curve-2: #1a7f37;
|
||||
--color-plot-curve-3: #8250df;
|
||||
--color-plot-curve-4: #9a6700;
|
||||
--color-plot-curve-5: #bc4c00;
|
||||
}
|
||||
[data-theme='dark'], [data-theme='midnight-purple'] {
|
||||
--color-plot-curve-0: #79c0ff;
|
||||
--color-plot-curve-1: #ff9b9b;
|
||||
--color-plot-curve-2: #7ee787;
|
||||
--color-plot-curve-3: #d2a8ff;
|
||||
--color-plot-curve-4: #f2cc60;
|
||||
--color-plot-curve-5: #ffa657;
|
||||
}
|
||||
.function-plot-svg > rect:first-child { fill: var(--color-plot-background); }
|
||||
.function-plot-svg .plot-grid { stroke: var(--color-plot-grid); }
|
||||
.function-plot-svg .plot-axis { stroke: var(--color-plot-axis); }
|
||||
.function-plot-svg text { fill: var(--color-plot-text); }
|
||||
.function-plot-svg .plot-curve-0 { stroke: var(--color-plot-curve-0); }
|
||||
.function-plot-svg .plot-legend-0 { fill: var(--color-plot-curve-0); }
|
||||
.function-plot-svg .plot-curve-1 { stroke: var(--color-plot-curve-1); }
|
||||
.function-plot-svg .plot-legend-1 { fill: var(--color-plot-curve-1); }
|
||||
.function-plot-svg .plot-curve-2 { stroke: var(--color-plot-curve-2); }
|
||||
.function-plot-svg .plot-legend-2 { fill: var(--color-plot-curve-2); }
|
||||
.function-plot-svg .plot-curve-3 { stroke: var(--color-plot-curve-3); }
|
||||
.function-plot-svg .plot-legend-3 { fill: var(--color-plot-curve-3); }
|
||||
.function-plot-svg .plot-curve-4 { stroke: var(--color-plot-curve-4); }
|
||||
.function-plot-svg .plot-legend-4 { fill: var(--color-plot-curve-4); }
|
||||
.function-plot-svg .plot-curve-5 { stroke: var(--color-plot-curve-5); }
|
||||
.function-plot-svg .plot-legend-5 { fill: var(--color-plot-curve-5); }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { renderFunctionPlot } from '@/services/functionPlotService'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { Marked } from 'marked'
|
||||
import { defaultMarkdownPreferences, type MarkdownPreferences } from '@/stores/markdownPreferences'
|
||||
@@ -125,7 +126,7 @@ export async function getCodeTokenizer(theme: 'github-light' | 'github-dark', re
|
||||
}
|
||||
}
|
||||
|
||||
export async function renderMarkdown(source: string, options?: { theme?: 'light' | 'dark'; preferences?: MarkdownPreferences; citationNumbers?: number[]; citationAliases?: Record<string, number> }): Promise<string> {
|
||||
export async function renderMarkdown(source: string, options?: { themeId?: string; theme?: 'light' | 'dark'; preferences?: MarkdownPreferences; citationNumbers?: number[]; citationAliases?: Record<string, number> }): Promise<string> {
|
||||
const preferences = options?.preferences ?? defaultMarkdownPreferences
|
||||
const marked = createMarkdownParser(preferences)
|
||||
const citations = new Set(options?.citationNumbers ?? [])
|
||||
@@ -141,12 +142,12 @@ export async function renderMarkdown(source: string, options?: { theme?: 'light'
|
||||
const html = marked.parse(source, { async: false }) as string
|
||||
const documentNode = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html')
|
||||
|
||||
const mermaidBlocks: { pre: Element; source: string }[] = []
|
||||
const mermaidBlocks: { pre: Element; source: string; kind: string }[] = []
|
||||
|
||||
for (const code of documentNode.querySelectorAll('pre > code')) {
|
||||
const requestedLanguage = [...code.classList].find((name) => name.startsWith('language-'))?.slice(9) || 'text'
|
||||
if (requestedLanguage === 'mermaid' && preferences.diagrams) {
|
||||
mermaidBlocks.push({ pre: code.parentElement!, source: code.textContent ?? '' })
|
||||
if (['mermaid', 'function-plot'].includes(requestedLanguage) && preferences.diagrams) {
|
||||
mermaidBlocks.push({ pre: code.parentElement!, source: code.textContent ?? '', kind: requestedLanguage })
|
||||
continue
|
||||
}
|
||||
if (requestedLanguage.toLowerCase() === 'latex' && preferences.math) {
|
||||
@@ -168,19 +169,23 @@ export async function renderMarkdown(source: string, options?: { theme?: 'light'
|
||||
code.parentElement?.replaceWith(wrapper)
|
||||
}
|
||||
|
||||
for (const { pre, source } of mermaidBlocks) {
|
||||
let plotCount = 0, plotNodes = 0
|
||||
for (const { pre, source, kind } of mermaidBlocks) {
|
||||
try {
|
||||
const result = await renderMermaid(source, { theme: options?.theme, mode: 'static' })
|
||||
if (kind === 'function-plot' && ++plotCount > 16) throw new Error('函数图像数量超过 16')
|
||||
const result = kind === 'function-plot' ? await renderFunctionPlot(source, options?.themeId) : await renderMermaid(source, { theme: options?.theme, mode: 'static' })
|
||||
if ('nodeCount' in result && (plotNodes += result.nodeCount) > 8000) throw new Error('函数图像累计复杂度超过 8000')
|
||||
const container = document.createElement('div')
|
||||
container.className = 'markdown-mermaid'
|
||||
container.className = 'markdown-mermaid' + (kind === 'function-plot' ? ' markdown-function-plot' : '')
|
||||
container.innerHTML = result.svg
|
||||
appendCodeToolbar(container, 'mermaid', source, true)
|
||||
if (!result.warnings.length) appendDiagramControls(container)
|
||||
appendCodeToolbar(container, kind, source, true)
|
||||
if (result.warnings.length) { const message = document.createElement('p'); message.textContent = result.warnings.join('\n'); message.setAttribute('role', 'status'); container.append(message) }
|
||||
if (!result.warnings.length || (kind === 'function-plot' && result.svg)) appendDiagramControls(container)
|
||||
pre.replaceWith(container)
|
||||
} catch {
|
||||
} catch (error) {
|
||||
const fallback = document.createElement('pre')
|
||||
fallback.className = 'mermaid-error'
|
||||
fallback.textContent = source
|
||||
fallback.textContent = `${error instanceof Error ? error.message : '图表渲染失败'}\n${source}`
|
||||
pre.replaceWith(fallback)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,19 @@ const fixtures = {
|
||||
};
|
||||
const allThemes = [{theme_id:'light',is_dark:false},{theme_id:'dark',is_dark:true},{theme_id:'sepia',is_dark:false},...mockCommunityThemes];
|
||||
const requested = new URLSearchParams(location.search).get('theme');
|
||||
if (!requested) {
|
||||
const results=[];
|
||||
for (const theme of allThemes) {
|
||||
const frame=document.createElement('iframe');
|
||||
frame.title=theme.theme_id; frame.style.cssText='width:100%;height:900px;border:0';
|
||||
frame.src='?theme='+encodeURIComponent(theme.theme_id); document.querySelector('#results').append(frame);
|
||||
await new Promise(resolve=>frame.onload=resolve);
|
||||
while(frame.contentDocument.documentElement.dataset.complete!=='true') await new Promise(resolve=>setTimeout(resolve,100));
|
||||
results.push(...JSON.parse(frame.contentDocument.querySelector('#status').textContent).results);
|
||||
}
|
||||
document.querySelector('#status').textContent=JSON.stringify({passed:results.filter(r=>r.passed).length,total:results.length,results});
|
||||
document.documentElement.dataset.complete='true';
|
||||
} else {
|
||||
const themes = requested ? allThemes.filter(t=>t.theme_id===requested) : allThemes;
|
||||
const style = document.createElement('style'); document.head.append(style);
|
||||
const results = [];
|
||||
@@ -35,4 +48,5 @@ for (const theme of themes) {
|
||||
}
|
||||
document.querySelector('#status').textContent=JSON.stringify({passed:results.filter(r=>r.passed).length,total:results.length,results});
|
||||
document.documentElement.dataset.complete='true';
|
||||
}
|
||||
</script></body></html>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Phase 2 interaction acceptance</title></head><body><div id="app"></div>
|
||||
<script type="module">
|
||||
import {createApp,h} from 'vue'; import {createPinia,setActivePinia} from 'pinia';
|
||||
import {useSettingsStore} from '/src/stores/settings.ts';
|
||||
import {useThemeStore} from '/src/stores/theme.ts'; import {useEditorStore} from '/src/stores/editor.ts';
|
||||
import {mockCommunityThemes} from '/src/services/themePackageService.ts';
|
||||
import EditorHeader from '/src/features/editor/EditorHeader.vue'; import EditorPane from '/src/features/editor/EditorPane.vue';
|
||||
import MarkdownContent from '/src/components/common/MarkdownContent.vue';
|
||||
import '/src/styles/tokens.css'; import '/src/styles/features.css';
|
||||
const pinia=createPinia();setActivePinia(pinia);
|
||||
const theme=useThemeStore(); await theme.loadCustomThemes();
|
||||
const id=new URLSearchParams(location.search).get('theme')||'light';
|
||||
if(mockCommunityThemes.some(t=>t.theme_id===id)) await theme.installCommunityTheme(id);
|
||||
theme.applyTheme(id,{persist:false});
|
||||
useSettingsStore().autoSaveInterval=3600000;
|
||||
const editor=useEditorStore();
|
||||
editor.content='# 第二阶段图表与导出\n\n未保存快照 PHASE2-SNAPSHOT\n\n```function-plot\ndomain: -4, 4\ny = x^2\ny = sin(x)\n```\n\n```mermaid\nflowchart LR\n A[笔记] --> B[导出]\n```\n\n公式:$x^2 + y^2 = 1$。';
|
||||
editor.currentFilePath='phase2-demo.md';
|
||||
createApp({render:()=>h('main',{},[h('h1',{},id),h('button',{onClick:()=>theme.applyTheme(theme.currentThemeId==='dark'?'light':'dark',{persist:false})},'测试切换主题'),h(EditorHeader),h('div',{style:'height:650px;display:flex'},[h(EditorPane)]),h('h2',{},'只读 / AI 共用预览'),h(MarkdownContent,{source:editor.content})])}).use(pinia).mount('#app');
|
||||
</script><style>body{height:auto!important;overflow:auto!important;margin:0;padding:16px;background:var(--color-background-primary);color:var(--color-text-primary);font-family:Arial,'Microsoft YaHei',sans-serif}main{max-width:1050px;margin:auto}button,select,input{font:inherit;color:inherit;background:var(--color-surface-primary);border:1px solid var(--color-border-default);border-radius:4px;padding:5px}button{cursor:pointer}svg{max-width:100%}</style></body></html>
|
||||
@@ -17,6 +17,7 @@ function mathChunk(module: string) {
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
dedupe: ['katex'],
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
@@ -42,8 +43,8 @@ export default defineConfig({
|
||||
host: '127.0.0.1',
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:8000',
|
||||
'/health': 'http://127.0.0.1:8000',
|
||||
'/api': process.env.NOTES_API_TARGET ?? 'http://127.0.0.1:8000',
|
||||
'/health': process.env.NOTES_API_TARGET ?? 'http://127.0.0.1:8000',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user