fix: address phase two review and theme benchmark page
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
// Visual fixtures only: all API traffic is intercepted; no provider calls or user data.
|
||||
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-review/themes');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:1280,height:1400}});let populated=false;const errors=[];page.on('pageerror',e=>errors.push(e.message));
|
||||
const run=(id,status,progress)=>({run_id:id,kind:'rag',dataset_id:'rag-demo-v1',status,progress,error_code:status==='failed'?'BENCHMARK_RUN_FAILED':null,config_snapshot:{}});
|
||||
await page.route('**/api/**',async route=>{
|
||||
const request=route.request();const url=new URL(request.url());let body={items:[]};
|
||||
if(url.pathname==='/api/benchmarks/datasets')body={items:[{dataset_id:'rag-demo-v1',description:'视觉测试数据集',case_count:24}]};
|
||||
else if(url.pathname==='/api/providers')body={items:[]};
|
||||
else if(url.pathname==='/api/benchmarks/rag/runs'&&request.method()==='POST'){populated=true;body=run('visual-completed','completed',1)}
|
||||
else if(url.pathname==='/api/benchmarks/runs')body={items:populated?[run('visual-completed','completed',1),run('visual-running','running',.5),run('visual-failed','failed',.25)]:[]};
|
||||
else if(url.pathname.endsWith('/report'))body={metrics:{fts:{total_cases:24,hit_at_1:.875,hit_at_5:1,mrr:.9235,citation_hit_rate:.75,p50_latency_ms:12.43,p95_latency_ms:22.16,failed_cases:0}},cases:[],config_snapshot:{fixture:true}};
|
||||
else if(url.pathname==='/status')body={status:'ready'};
|
||||
await route.fulfill({json:body});
|
||||
});
|
||||
await page.goto('http://127.0.0.1:5189/#/benchmarks');await page.getByText('还没有评测记录',{exact:true}).waitFor();
|
||||
await page.evaluate(async id=>{const{useThemeStore}=await import('/src/stores/theme.ts');const store=useThemeStore();if(!['light','dark','sepia'].includes(id))await store.installCommunityTheme(id);if(!store.applyTheme(id,{persist:false}))throw Error('theme failed')},theme);
|
||||
await page.screenshot({path:path.join(output,`${theme}-empty.png`)});
|
||||
await page.getByRole('button',{name:'运行评测',exact:true}).click();
|
||||
await page.getByRole('button',{name:'查看报告',exact:true}).first().waitFor();await page.getByRole('button',{name:'查看报告',exact:true}).first().click();
|
||||
await page.getByText('87.5%',{exact:true}).waitFor();
|
||||
await page.locator('#benchmark-topk').focus();
|
||||
await page.screenshot({path:path.join(output,`${theme}-report.png`)});
|
||||
const colors=await page.evaluate(()=>{const read=selector=>{const s=getComputedStyle(document.querySelector(selector));return {background:s.backgroundColor,color:s.color,border:s.borderColor}};return {panel:read('.benchmark-config'),input:read('#benchmark-topk'),button:read('.benchmark-config .button-primary'),metric:read('.metric-card')}});
|
||||
await page.setViewportSize({width:390,height:1100});await page.screenshot({path:path.join(output,`${theme}-narrow.png`)});
|
||||
const overflow=await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth);
|
||||
await page.locator('.benchmark-report').scrollIntoViewIfNeeded();await page.screenshot({path:path.join(output,`${theme}-narrow-report.png`)});
|
||||
if(errors.length||overflow)throw Error(JSON.stringify({theme,errors,overflow}));results.push({theme,errors,overflow,colors});await page.close();
|
||||
}
|
||||
await fs.writeFile(path.join(output,'results.json'),JSON.stringify(results,null,2));await browser.close();console.log(JSON.stringify(results));
|
||||
})().catch(e=>{console.error(e);process.exit(1)});
|
||||
@@ -15,7 +15,7 @@ const {chromium}=require('playwright');const fs=require('node:fs/promises');cons
|
||||
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 row.getByRole('link',{name:'执行轨迹'}).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');
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// @vitest-environment happy-dom
|
||||
import {mount,flushPromises} from '@vue/test-utils'
|
||||
import {afterEach,beforeEach,expect,it,vi} from 'vitest'
|
||||
import BenchmarkView from './BenchmarkView.vue'
|
||||
const service=vi.hoisted(()=>({datasets:vi.fn(),list:vi.fn(),start:vi.fn(),cancel:vi.fn(),report:vi.fn()}))
|
||||
vi.mock('@/services/benchmarkService',()=>({benchmarkService:service}))
|
||||
vi.mock('@/services/providerService',()=>({listProviders:vi.fn().mockResolvedValue([])}))
|
||||
beforeEach(()=>{service.datasets.mockResolvedValue([{id:'rag-demo',cases:2}]);service.list.mockResolvedValue([])})
|
||||
afterEach(()=>vi.clearAllMocks())
|
||||
it('shows a useful empty state and submits the selected retrieval configuration',async()=>{
|
||||
const wrapper=mount(BenchmarkView,{global:{stubs:{RouterLink:true}}});await flushPromises()
|
||||
expect(wrapper.text()).toContain('还没有评测记录')
|
||||
await wrapper.get('#benchmark-fusion').setValue('weighted')
|
||||
expect(wrapper.get('#benchmark-rrfk').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('form').trigger('submit');await flushPromises()
|
||||
expect(service.start).toHaveBeenCalledWith('rag',expect.objectContaining({dataset_id:'rag-demo',retrieval:expect.objectContaining({fusion:'weighted'})}))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('renders report percentages, unavailable metrics and localized terminal states',async()=>{
|
||||
service.list.mockResolvedValue([{id:'r',datasetId:'agent-demo',status:'completed',progress:1,errorCode:null}])
|
||||
service.report.mockResolvedValue({metrics:{task_success_rate:.75,tool_argument_accuracy:null,token_usage:120},cases:[],config_snapshot:{}})
|
||||
const wrapper=mount(BenchmarkView,{global:{stubs:{RouterLink:true}}});await flushPromises()
|
||||
expect(wrapper.text()).toContain('已完成')
|
||||
await wrapper.findAll('button').find(button=>button.text()==='查看报告')!.trigger('click');await flushPromises()
|
||||
expect(wrapper.get('.benchmark-report').text()).toContain('75%')
|
||||
expect(wrapper.get('.benchmark-report').text()).toContain('不适用')
|
||||
expect(wrapper.get('.benchmark-report').text()).toContain('agent-demo')
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -6,6 +6,10 @@ const kind = ref<'rag' | 'agent'>('rag'), dataset = ref(''), error = ref(''), bu
|
||||
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 reportName = ref(''), loading = ref(true)
|
||||
const statusLabels: Record<string,string> = { queued:'排队中', running:'运行中', completed:'已完成', failed:'失败', cancelled:'已取消' }
|
||||
const activeCount = computed(() => runs.value.filter(run => ['queued','running'].includes(run.status)).length)
|
||||
const ratioKeys = new Set(['task_success_rate','tool_selection_accuracy','tool_argument_accuracy','invalid_tool_call_rate','hit_at_1','hit_at_5','recall_at_k','citation_hit_rate','failure_rate'])
|
||||
const topK = ref(5), rrfK = ref(60), rerank = ref(false)
|
||||
const fusion = ref<'rrf' | 'weighted'>('rrf')
|
||||
const metricLabels: Record<string, string> = {
|
||||
@@ -22,13 +26,13 @@ const metricGroups = computed(() => {
|
||||
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),
|
||||
value: number === null ? '不适用' : typeof number === 'number' ? ratioKeys.has(key) ? `${Number((number*100).toFixed(2))}%` : Number(number.toFixed(key.includes('latency') ? 2 : 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) }
|
||||
async function refresh() { try { runs.value = await service.list() } catch(e) { error.value = String(e) } finally { loading.value = false } 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() {
|
||||
@@ -36,30 +40,84 @@ async function start() {
|
||||
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) } }
|
||||
async function action(run: BenchmarkRun, cancel = false) { try { if (cancel) await service.cancel(run.id); else { report.value = await service.report(run.id); reportName.value = run.datasetId } } 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 class="feature-page benchmark-page">
|
||||
<header class="feature-header">
|
||||
<div><h1>Benchmark 评测</h1><p>比较检索质量与智能体表现,查看每次评测的结果和执行轨迹。</p></div>
|
||||
<span class="badge" :class="{ info: activeCount > 0 }">{{ activeCount ? `${activeCount} 项正在运行` : 'RAG / Agent' }}</span>
|
||||
</header>
|
||||
<div class="benchmark-content">
|
||||
<form class="panel benchmark-config" @submit.prevent="start">
|
||||
<div class="section-heading"><div><h2>创建评测</h2><p class="subtle">选择数据集和运行配置,结果将保留在下方列表。</p></div></div>
|
||||
<div class="form-grid">
|
||||
<div class="field"><label for="benchmark-kind">类型</label><select id="benchmark-kind" v-model="kind" class="select"><option value="rag">RAG 检索</option><option value="agent">Agent 任务</option></select></div>
|
||||
<div class="field dataset-field"><label for="benchmark-dataset">数据集</label><select id="benchmark-dataset" v-model="dataset" class="select"><option v-if="!datasets.length" value="">暂无可用数据集</option><option v-for="d in datasets" :key="d.id" :value="d.id">{{ d.id }} · {{ d.cases }} 案例</option></select></div>
|
||||
<template v-if="kind === 'agent'">
|
||||
<div class="field"><label for="benchmark-provider">提供商</label><select id="benchmark-provider" v-model="provider" class="select"><option v-if="!providers.length" value="">暂无可用提供商</option><option v-for="p in providers" :key="p.provider_id" :value="p.provider_id">{{ p.name }}</option></select></div>
|
||||
<div class="field"><label for="benchmark-model">模型</label><input id="benchmark-model" v-model="model" class="input" placeholder="模型 ID"></div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="field"><label for="benchmark-fusion">融合方式</label><select id="benchmark-fusion" v-model="fusion" class="select"><option value="rrf">RRF 排名融合</option><option value="weighted">加权 50/50</option></select></div>
|
||||
<div class="field"><label for="benchmark-topk">Top K</label><input id="benchmark-topk" v-model.number="topK" class="input" type="number" min="1" max="100"></div>
|
||||
<div class="field"><label for="benchmark-rrfk">RRF K</label><input id="benchmark-rrfk" v-model.number="rrfK" class="input" type="number" min="1" :disabled="fusion !== 'rrf'"></div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="config-footer">
|
||||
<label v-if="kind === 'rag'" class="checkbox-label"><input v-model="rerank" type="checkbox">启用词面重排(Lexical Reranker)</label>
|
||||
<p v-else class="subtle">将使用所选提供商额度;需要工具权限时,请在执行轨迹中处理。</p>
|
||||
<button class="button-primary" :disabled="busy || !dataset || (kind === 'agent' && (!provider || !model))">{{ busy ? '正在创建…' : '运行评测' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
<p v-if="error" class="error-banner" role="alert">{{ error }}</p>
|
||||
<section class="panel benchmark-history" aria-labelledby="benchmark-history-title" :aria-busy="loading">
|
||||
<div class="section-heading"><h2 id="benchmark-history-title">运行记录</h2><span class="badge">{{ runs.length }} 项</span></div>
|
||||
<div v-if="!runs.length" class="empty-state"><div><strong>{{ loading ? '正在加载记录…' : '还没有评测记录' }}</strong><p>选择上方的数据集并运行评测,完成后可查看指标、下载报告。</p></div></div>
|
||||
<div v-else class="table-scroll"><table><thead><tr><th>数据集</th><th>状态</th><th>操作</th></tr></thead><tbody><tr v-for="run in runs" :key="run.id">
|
||||
<td><strong>{{ run.datasetId }}</strong><small class="subtle run-id">{{ run.id }}</small></td>
|
||||
<td><span class="badge" :class="{ success:run.status === 'completed', error:run.status === 'failed', info:['queued','running'].includes(run.status), warning:run.status === 'cancelled' }">{{ statusLabels[run.status] ?? run.status }}</span><span v-if="run.progress !== null" class="progress-label subtle">{{ Math.round(run.progress*100) }}%</span><small v-if="run.errorCode" class="run-error">{{ run.errorCode }}</small></td>
|
||||
<td><div class="inline-actions"><button v-if="['queued','running'].includes(run.status)" class="button-secondary" @click="action(run,true)">取消</button><button v-else class="button-secondary" @click="action(run)">查看报告</button><RouterLink v-if="run.agentId" class="trace-link" :to="`/agent/runs/${run.agentId}`">执行轨迹</RouterLink></div></td>
|
||||
</tr></tbody></table></div>
|
||||
</section>
|
||||
<section v-if="report" class="panel benchmark-report" aria-labelledby="benchmark-report-title">
|
||||
<div class="section-heading"><div><h2 id="benchmark-report-title">评测报告</h2><p class="subtle">{{ reportName }}</p></div><button class="button-secondary" @click="download">下载完整 JSON</button></div>
|
||||
<div v-for="group in metricGroups" :key="group.name" class="metric-group"><h3>{{ group.name }}</h3><dl class="metric-grid"><div v-for="row in group.rows" :key="row.label" class="metric-card"><dt>{{ row.label }}</dt><dd>{{ row.value }}</dd></div></dl></div>
|
||||
<details class="ui-disclosure"><summary>冻结配置与逐例证据</summary><pre>{{ JSON.stringify(report,null,2) }}</pre></details>
|
||||
</section>
|
||||
</div>
|
||||
</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; }
|
||||
.benchmark-page { width:100%; min-width:0; color:var(--color-text-primary); }
|
||||
.benchmark-content { max-width:1180px; margin:0 auto; display:grid; gap:var(--space-xl); }
|
||||
.benchmark-content > .panel { width:100%; min-width:0; margin:0; padding:var(--space-xl); }
|
||||
.section-heading { display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom:var(--space-lg); }
|
||||
h2 { margin:0; font-size:var(--font-size-lg); font-weight:650; } h3 { margin:0 0 var(--space-md); font-size:var(--font-size-md); }
|
||||
.section-heading p { margin:var(--space-xs) 0 0; } .form-grid { grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); }
|
||||
.dataset-field { grid-column:span 2; } .field { min-width:0; }
|
||||
.config-footer { display:flex; align-items:center; justify-content:space-between; gap:var(--space-lg); margin-top:var(--space-xl); padding-top:var(--space-lg); border-top:1px solid var(--color-border-default); }
|
||||
.config-footer p { margin:0; } .config-footer .button-primary { margin-left:auto; flex-shrink:0; }
|
||||
.checkbox-label { display:flex; align-items:center; gap:var(--space-sm); color:var(--color-text-secondary); font-size:var(--font-size-sm); }
|
||||
.empty-state { min-height:170px; } .empty-state p { margin:0; line-height:1.7; }
|
||||
.table-scroll { overflow-x:auto; } table { width:100%; min-width:580px; border-collapse:collapse; font-size:var(--font-size-sm); }
|
||||
th { text-align:left; color:var(--color-text-secondary); background:var(--color-background-secondary); font-weight:600; }
|
||||
td,th { padding:var(--space-md); border-bottom:1px solid var(--color-border-default); } tbody tr:last-child td { border-bottom:0; }
|
||||
.run-id,.run-error { display:block; margin-top:var(--space-xs); overflow-wrap:anywhere; } .run-error { color:var(--color-error); }
|
||||
.progress-label { margin-left:var(--space-sm); } .trace-link { color:var(--color-accent-primary); text-decoration:none; font-weight:600; } .trace-link:hover { text-decoration:underline; }
|
||||
.metric-group + .metric-group { margin-top:var(--space-xl); }
|
||||
.metric-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:var(--space-md); margin:0 0 var(--space-xl); }
|
||||
.metric-card { padding:var(--space-lg); border:1px solid var(--color-border-default); border-radius:var(--radius-md); background:var(--color-background-secondary); }
|
||||
dt { font-size:var(--font-size-sm); color:var(--color-text-secondary); } dd { margin:var(--space-sm) 0 0; font-size:var(--font-size-2xl); font-weight:650; font-variant-numeric:tabular-nums; }
|
||||
pre { padding:var(--space-md); border-radius:var(--radius-md); background:var(--color-background-secondary); color:var(--color-text-primary); white-space:pre-wrap; overflow-wrap:anywhere; font-family:var(--font-editor-mono); font-size:var(--font-size-sm); }
|
||||
.error-banner { margin:0; }
|
||||
@media(max-width:640px) {
|
||||
.benchmark-content > .panel { padding:var(--space-lg); }
|
||||
.form-grid { grid-template-columns:minmax(0,1fr); } .dataset-field { grid-column:auto; }
|
||||
.section-heading,.config-footer { align-items:flex-start; flex-wrap:wrap; } .config-footer .button-primary { width:100%; }
|
||||
.metric-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } dd { font-size:var(--font-size-xl); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -21,7 +21,7 @@ async function start() {
|
||||
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) }
|
||||
} catch (e) { error.value = e instanceof DOMException && e.name === 'AbortError' ? '已取消导出' : String(e) }
|
||||
finally { preparing.value = false }
|
||||
}
|
||||
async function action(job: ExportJob, download = false) {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import {describe,it,expect,vi} from 'vitest'
|
||||
// @vitest-environment jsdom
|
||||
import {webcrypto} from 'node:crypto'
|
||||
import {describe,it,expect,vi,afterEach} from 'vitest'
|
||||
vi.mock('./apiClient',()=>({apiClient:{post:vi.fn(),get:vi.fn()}}))
|
||||
vi.mock('./mermaidService',()=>({renderMermaid:vi.fn()}))
|
||||
import {renderMermaid} from './mermaidService'
|
||||
import {apiClient} from './apiClient'
|
||||
import {exportService} from './exportService'
|
||||
describe('export snapshot contract',()=>{
|
||||
@@ -16,3 +20,45 @@ describe('export snapshot contract',()=>{
|
||||
expect(apiClient.post).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
const reviewOptions={theme_id:'light',include_title:true,page_size:'A4'}
|
||||
const queued={job_id:'review-job',status:'queued',warnings:[],file:null,error:null}
|
||||
afterEach(()=>{vi.restoreAllMocks();vi.unstubAllGlobals();vi.clearAllMocks()})
|
||||
it('cancels a created server job after an in-flight submission is aborted',async()=>{
|
||||
let finish!:(value:unknown)=>void
|
||||
vi.mocked(apiClient.post).mockImplementationOnce(()=>new Promise(resolve=>{finish=resolve}) as never).mockResolvedValue({status:'completed'})
|
||||
vi.mocked(apiClient.get).mockResolvedValue({...queued,status:'cancelled'})
|
||||
const controller=new AbortController()
|
||||
const pending=exportService.create('# snapshot','review','html',reviewOptions,controller.signal)
|
||||
controller.abort();finish(queued)
|
||||
await expect(pending).rejects.toMatchObject({name:'AbortError'})
|
||||
expect(apiClient.post).toHaveBeenLastCalledWith('/api/exports/review-job/cancel')
|
||||
})
|
||||
it('does not report cancellation when the server job already completed',async()=>{
|
||||
let finish!:(value:unknown)=>void
|
||||
vi.mocked(apiClient.post).mockImplementationOnce(()=>new Promise(resolve=>{finish=resolve}) as never).mockResolvedValue({status:'completed'})
|
||||
vi.mocked(apiClient.get).mockResolvedValue({...queued,status:'completed'})
|
||||
const controller=new AbortController()
|
||||
const pending=exportService.create('# snapshot','review','html',reviewOptions,controller.signal)
|
||||
controller.abort();finish(queued)
|
||||
await expect(pending).rejects.toThrow('导出已完成,无法取消')
|
||||
})
|
||||
it('surfaces a server cancellation failure instead of claiming it was cancelled',async()=>{
|
||||
let finish!:(value:unknown)=>void
|
||||
vi.mocked(apiClient.post).mockImplementationOnce(()=>new Promise(resolve=>{finish=resolve}) as never).mockRejectedValue(new Error('network failure'))
|
||||
const controller=new AbortController()
|
||||
const pending=exportService.create('# snapshot','review','html',reviewOptions,controller.signal)
|
||||
controller.abort();finish(queued)
|
||||
await expect(pending).rejects.toThrow('network failure')
|
||||
})
|
||||
it.each(['mermaid','Mermaid','mermaid title="Flow"'])('prepares a static asset for %s',async language=>{
|
||||
vi.stubGlobal('crypto',webcrypto)
|
||||
vi.stubGlobal('Image',class {src='';decode(){return Promise.resolve()}})
|
||||
vi.spyOn(HTMLCanvasElement.prototype,'getContext').mockReturnValue({fillStyle:'',fillRect:vi.fn(),drawImage:vi.fn()} as never)
|
||||
vi.spyOn(HTMLCanvasElement.prototype,'toDataURL').mockReturnValue('data:image/png;base64,YWJj')
|
||||
vi.mocked(renderMermaid).mockResolvedValue({svg:'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 10"></svg>',warnings:[]} as never)
|
||||
vi.mocked(apiClient.post).mockResolvedValue(queued)
|
||||
await exportService.create('```'+language+'\nflowchart LR\n A-->B\n```','review','html',reviewOptions)
|
||||
expect(renderMermaid).toHaveBeenCalledWith('flowchart LR\n A-->B',{mode:'raster',theme:'light'})
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/api/exports',expect.objectContaining({assets:[expect.objectContaining({kind:'mermaid',png_base64:'YWJj',source_hash:expect.stringMatching(/^[a-f0-9]{64}$/)})]}))
|
||||
})
|
||||
|
||||
@@ -44,7 +44,7 @@ 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) })
|
||||
parser.walkTokens(parser.lexer(markdown), token => { if (token.type === 'code' && token.lang?.trim().split(/\s+/)[0]?.toLowerCase() === 'mermaid') blocks.push(token.text) })
|
||||
const assets = []
|
||||
for (const source of [...new Set(blocks)]) {
|
||||
signal?.throwIfAborted()
|
||||
@@ -54,7 +54,17 @@ export const exportService = {
|
||||
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 }))
|
||||
// Keep the response handle when cancellation arrives during submission:
|
||||
// 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 }))
|
||||
if (signal?.aborted) {
|
||||
await apiClient.post(`/api/exports/${encodeURIComponent(job.id)}/cancel`)
|
||||
const current = await apiClient.get<JobWire>(`/api/exports/${encodeURIComponent(job.id)}`)
|
||||
if (current.status === 'completed') throw new Error('导出已完成,无法取消;请在任务列表中下载。')
|
||||
if (current.status === 'failed') throw new Error(current.error || '导出任务已失败,请查看任务列表。')
|
||||
signal.throwIfAborted()
|
||||
}
|
||||
return job
|
||||
},
|
||||
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) },
|
||||
|
||||
Reference in New Issue
Block a user