From 47c53b6f38cf5ed8c3b6a03eb3ad19b2b47abef2 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Mon, 7 Sep 2026 13:26:14 +0800 Subject: [PATCH] fix: address phase two review and theme benchmark page --- backend/app/benchmarks/agent.py | 30 +++- backend/app/export/exporters/docx.py | 8 +- backend/tests/test_phase2_completion.py | 33 ++++ .../phase2-review-20260907/theme-results.json | 164 ++++++++++++++++++ .../第二阶段审阅修复与主题适配-2026-09-07.md | 24 +++ frontend/scripts/benchmark-theme-check.cjs | 34 ++++ frontend/scripts/phase2-benchmark-browser.cjs | 2 +- .../features/benchmarks/BenchmarkView.spec.ts | 29 ++++ .../src/features/benchmarks/BenchmarkView.vue | 98 ++++++++--- frontend/src/features/editor/ExportDialog.vue | 2 +- frontend/src/services/exportService.spec.ts | 48 ++++- frontend/src/services/exportService.ts | 14 +- 12 files changed, 451 insertions(+), 35 deletions(-) create mode 100644 docs/development/evidence/phase2-review-20260907/theme-results.json create mode 100644 docs/development/第二阶段审阅修复与主题适配-2026-09-07.md create mode 100644 frontend/scripts/benchmark-theme-check.cjs create mode 100644 frontend/src/features/benchmarks/BenchmarkView.spec.ts diff --git a/backend/app/benchmarks/agent.py b/backend/app/benchmarks/agent.py index 12cec3f..6701e19 100644 --- a/backend/app/benchmarks/agent.py +++ b/backend/app/benchmarks/agent.py @@ -11,15 +11,27 @@ INVALID = {'TOOL_NOT_FOUND', 'TOOL_NOT_ALLOWED', 'TOOL_ARGUMENT_INVALID', 'TOOL_ def score(case, run, events, latency, repeat): calls = [e.data for e in events if e.event.value == 'ToolCall'] - unmatched = list(calls) - selected = accurate = 0 - for expected in case.expected_tools: - candidates = [c for c in unmatched if c.get('name') == expected.name] - if not candidates: - continue - exact = next((c for c in candidates if all(k in c.get('arguments', {}) and c['arguments'][k] == v for k,v in expected.arguments.items())), None) - chosen = exact or candidates[0] - unmatched.remove(chosen); selected += 1; accurate += int(exact is not None) + # Maximum bipartite matching: broad parameter subsets must not consume the + # only call satisfying a more specific expectation. Each call is used once. + matched = {} + def assign(expected_index, visited): + expected = case.expected_tools[expected_index] + for call_index, call in enumerate(calls): + if call_index in visited or call.get('name') != expected.name: + continue + arguments = call.get('arguments', {}) + if not all(key in arguments and arguments[key] == value for key, value in expected.arguments.items()): + continue + visited.add(call_index) + if call_index not in matched or assign(matched[call_index], visited): + matched[call_index] = expected_index + return True + return False + accurate = sum(assign(index, set()) for index in range(len(case.expected_tools))) + from collections import Counter + actual_names = Counter(call.get('name') for call in calls) + expected_names = Counter(tool.name for tool in case.expected_tools) + selected = sum(min(count, actual_names[name]) for name, count in expected_names.items()) results = run.tool_results checks = { 'completed': run.status.value == 'completed', diff --git a/backend/app/export/exporters/docx.py b/backend/app/export/exporters/docx.py index 12cefcb..54bdc79 100644 --- a/backend/app/export/exporters/docx.py +++ b/backend/app/export/exporters/docx.py @@ -115,7 +115,13 @@ class DocxExporter: from PIL import Image png = node.attributes['static_png'] with Image.open(BytesIO(png)) as image: - width = min(5.8, image.width / (180 if node.type == 'math_block' else 96)) + section = self._doc.sections[-1] + available_width = (section.page_width - section.left_margin - section.right_margin) / 914400 + # Leave room for Word's containing paragraph line/spacing. + available_height = (section.page_height - section.top_margin - section.bottom_margin) / 914400 - 0.25 + width = min(5.8, available_width, + image.width / (180 if node.type == 'math_block' else 96), + available_height * image.width / image.height) self._doc.add_picture(BytesIO(png), width=Inches(width)) return handler = getattr(self, f"_block_{node.type}", None) diff --git a/backend/tests/test_phase2_completion.py b/backend/tests/test_phase2_completion.py index 0a1b8ad..6001667 100644 --- a/backend/tests/test_phase2_completion.py +++ b/backend/tests/test_phase2_completion.py @@ -164,3 +164,36 @@ def test_repeated_static_assets_share_document_resource_budget(): warnings = enrich_document(document) assert sum(bool(node.attributes.get('static_png')) for node in document.children) == 64 assert any('预算' in warning for warning in warnings) + + +@pytest.mark.parametrize('order', [(1, 2), (2, 1)]) +def test_agent_parameter_matching_is_independent_of_call_order(order): + from types import SimpleNamespace as NS + from app.benchmarks.agent import score + from app.contracts import AgentDatasetCase + case = AgentDatasetCase(case_id='overlap', prompt='test', allowed_tools=['math.add'], + expected_tools=[{'name':'math.add','arguments':{}}, {'name':'math.add','arguments':{'left':1}}]) + events = [NS(event=NS(value='ToolCall'), data={'name':'math.add','arguments':{'left':value}}) for value in order] + run = NS(status=NS(value='completed'),tool_results=[],output='',citations=[],run_id='test',current_step=1,token_usage=0,error_code=None) + result = score(case, run, events, 1, 0) + assert result.success and result.accurate_calls == result.selected_calls == 2 + # Two expectations cannot reuse one matching call. + result = score(case, run, events[:1], 1, 0) + assert not result.success and result.accurate_calls == 1 + + +@pytest.mark.parametrize('page_size', ['A4', 'Letter']) +@pytest.mark.parametrize('dimensions', [(200, 2000), (2000, 200)]) +def test_docx_static_images_fit_both_page_dimensions(page_size, dimensions): + from app.export.markdown import parse_document + from app.export.exporters.docx import DocxExporter + from app.contracts import ExportOptions + from docx import Document + png = BytesIO(); Image.new('RGB', dimensions, 'white').save(png, 'PNG') + document = parse_document('```mermaid\nflowchart TD\n A-->B\n```') + document.children[0].attributes['static_png'] = png.getvalue() + result = DocxExporter().render(document, ExportOptions(page_size=page_size)) + word = Document(BytesIO(result.content)); section = word.sections[0]; shape = word.inline_shapes[0] + assert shape.width <= section.page_width - section.left_margin - section.right_margin + assert shape.height < section.page_height - section.top_margin - section.bottom_margin + assert shape.width / shape.height == pytest.approx(dimensions[0] / dimensions[1], rel=1e-5) diff --git a/docs/development/evidence/phase2-review-20260907/theme-results.json b/docs/development/evidence/phase2-review-20260907/theme-results.json new file mode 100644 index 0000000..5fc2cca --- /dev/null +++ b/docs/development/evidence/phase2-review-20260907/theme-results.json @@ -0,0 +1,164 @@ +[ + { + "theme": "light", + "errors": [], + "overflow": false, + "colors": { + "panel": { + "background": "rgb(255, 255, 255)", + "color": "rgb(31, 35, 40)", + "border": "rgb(228, 231, 235)" + }, + "input": { + "background": "rgb(255, 255, 255)", + "color": "rgb(31, 35, 40)", + "border": "rgb(102, 113, 241)" + }, + "button": { + "background": "rgb(91, 103, 241)", + "color": "rgb(255, 255, 255)", + "border": "rgba(0, 0, 0, 0)" + }, + "metric": { + "background": "rgb(247, 248, 250)", + "color": "rgb(31, 35, 40)", + "border": "rgb(228, 231, 235)" + } + } + }, + { + "theme": "dark", + "errors": [], + "overflow": false, + "colors": { + "panel": { + "background": "rgb(22, 27, 34)", + "color": "rgb(230, 237, 243)", + "border": "rgb(48, 54, 61)" + }, + "input": { + "background": "rgb(21, 26, 33)", + "color": "rgb(230, 237, 243)", + "border": "rgb(116, 129, 231)" + }, + "button": { + "background": "rgb(125, 139, 255)", + "color": "rgb(13, 17, 23)", + "border": "rgba(0, 0, 0, 0)" + }, + "metric": { + "background": "rgb(22, 27, 34)", + "color": "rgb(230, 237, 243)", + "border": "rgb(48, 54, 61)" + } + } + }, + { + "theme": "sepia", + "errors": [], + "overflow": false, + "colors": { + "panel": { + "background": "rgb(255, 248, 232)", + "color": "rgb(64, 55, 43)", + "border": "rgb(221, 207, 173)" + }, + "input": { + "background": "rgb(255, 247, 231)", + "color": "rgb(64, 55, 43)", + "border": "rgb(148, 105, 65)" + }, + "button": { + "background": "rgb(138, 91, 50)", + "color": "rgb(255, 255, 255)", + "border": "rgba(0, 0, 0, 0)" + }, + "metric": { + "background": "rgb(244, 232, 202)", + "color": "rgb(64, 55, 43)", + "border": "rgb(221, 207, 173)" + } + } + }, + { + "theme": "paper-moments", + "errors": [], + "overflow": false, + "colors": { + "panel": { + "background": "rgb(255, 253, 245)", + "color": "rgb(73, 63, 53)", + "border": "rgb(181, 166, 147)" + }, + "input": { + "background": "rgb(254, 251, 243)", + "color": "rgb(73, 63, 53)", + "border": "rgb(147, 105, 88)" + }, + "button": { + "background": "rgb(134, 82, 66)", + "color": "rgb(255, 253, 245)", + "border": "rgba(0, 0, 0, 0)" + }, + "metric": { + "background": "rgb(243, 238, 227)", + "color": "rgb(73, 63, 53)", + "border": "rgb(181, 166, 147)" + } + } + }, + { + "theme": "ocean-blue", + "errors": [], + "overflow": false, + "colors": { + "panel": { + "background": "rgb(255, 255, 255)", + "color": "rgb(30, 41, 59)", + "border": "rgb(226, 232, 240)" + }, + "input": { + "background": "rgb(255, 255, 255)", + "color": "rgb(30, 41, 59)", + "border": "rgb(27, 133, 189)" + }, + "button": { + "background": "rgb(0, 118, 181)", + "color": "rgb(255, 255, 255)", + "border": "rgba(0, 0, 0, 0)" + }, + "metric": { + "background": "rgb(248, 250, 252)", + "color": "rgb(30, 41, 59)", + "border": "rgb(226, 232, 240)" + } + } + }, + { + "theme": "midnight-purple", + "errors": [], + "overflow": false, + "colors": { + "panel": { + "background": "rgb(36, 40, 59)", + "color": "rgb(192, 202, 245)", + "border": "rgb(59, 63, 92)" + }, + "input": { + "background": "rgb(34, 38, 55)", + "color": "rgb(192, 202, 245)", + "border": "rgb(139, 75, 197)" + }, + "button": { + "background": "rgb(156, 77, 220)", + "color": "rgb(26, 27, 38)", + "border": "rgba(0, 0, 0, 0)" + }, + "metric": { + "background": "rgb(36, 40, 59)", + "color": "rgb(192, 202, 245)", + "border": "rgb(59, 63, 92)" + } + } + } +] \ No newline at end of file diff --git a/docs/development/第二阶段审阅修复与主题适配-2026-09-07.md b/docs/development/第二阶段审阅修复与主题适配-2026-09-07.md new file mode 100644 index 0000000..d3ef173 --- /dev/null +++ b/docs/development/第二阶段审阅修复与主题适配-2026-09-07.md @@ -0,0 +1,24 @@ +# 第二阶段审阅修复与主题适配 + +日期:2026-09-07。工作目录:`G:/OSProject/NotesAgent`,分支:`feat/phase2-completion`。 + +## 修复内容 + +- DOCX 静态图同时受页面可用宽度和高度约束,保留比例,避免长图超出页面;覆盖 A4、Letter 的横图与长图。 +- Agent 参数评分改为最大一对一匹配,避免宽泛参数预期抢占唯一满足具体参数预期的调用;同一调用不可重复计分。 +- 导出提交期间取消会在收到任务 ID 后取消后台任务,并读取实际状态。已经完成、失败或取消请求失败时如实显示结果。 +- Mermaid 围栏识别统一支持大小写和附加信息,包括 `Mermaid`、`mermaid title="Flow"`。 +- Benchmark 页接入共享页面、面板、表单、按钮、状态和主题变量,补充空状态、中文状态、指标格式及窄屏布局;同步更新执行轨迹浏览器检查的定位文字。 + +## 验证 + +- 后端:867 passed;1 条既有 Starlette/httpx 弃用警告。 +- 前端:81 个测试文件、445 项测试通过。 +- 前端生产构建通过;仍有较大分包提示。 +- 六主题:light、dark、sepia、paper-moments、ocean-blue、midnight-purple。分别生成空状态、报告、窄屏记录、窄屏报告截图,共 24 张;页面脚本错误与窄屏页面横向溢出均为零。 +- 主题检查脚本:`frontend/scripts/benchmark-theme-check.cjs`。需先在 5189 端口启动 Vite,并提供 Playwright 与 Edge。接口全部使用拦截测试数据,未调用模型,也不代表真实评测质量。 +- 主题检查结果:`evidence/phase2-review-20260907/theme-results.json`;完整本机截图与测试日志在 `.local-plans/phase2-review/`。 + +DOCX 本次验证包括文档内部图片尺寸和比例;未进行 Word/LibreOffice 排版截图验证。此前真实模型验收记录仍以《第二阶段收尾实现与验收-2026-09-07》为准。 + +用户已有的三份笔记修改未纳入此次修复提交。 diff --git a/frontend/scripts/benchmark-theme-check.cjs b/frontend/scripts/benchmark-theme-check.cjs new file mode 100644 index 0000000..3739f4b --- /dev/null +++ b/frontend/scripts/benchmark-theme-check.cjs @@ -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)}); diff --git a/frontend/scripts/phase2-benchmark-browser.cjs b/frontend/scripts/phase2-benchmark-browser.cjs index e2824cc..05aca9e 100644 --- a/frontend/scripts/phase2-benchmark-browser.cjs +++ b/frontend/scripts/phase2-benchmark-browser.cjs @@ -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'); diff --git a/frontend/src/features/benchmarks/BenchmarkView.spec.ts b/frontend/src/features/benchmarks/BenchmarkView.spec.ts new file mode 100644 index 0000000..417ed28 --- /dev/null +++ b/frontend/src/features/benchmarks/BenchmarkView.spec.ts @@ -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() +}) diff --git a/frontend/src/features/benchmarks/BenchmarkView.vue b/frontend/src/features/benchmarks/BenchmarkView.vue index 6eb63e8..435488f 100644 --- a/frontend/src/features/benchmarks/BenchmarkView.vue +++ b/frontend/src/features/benchmarks/BenchmarkView.vue @@ -6,6 +6,10 @@ const kind = ref<'rag' | 'agent'>('rag'), dataset = ref(''), error = ref(''), bu const datasets = ref>>([]), runs = ref([]) const providers = ref>>([]), provider = ref(''), model = ref('') const report = ref> | null>(null) +const reportName = ref(''), loading = ref(true) +const statusLabels: Record = { 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 = { @@ -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).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 | 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) }) diff --git a/frontend/src/features/editor/ExportDialog.vue b/frontend/src/features/editor/ExportDialog.vue index ba97302..883876a 100644 --- a/frontend/src/features/editor/ExportDialog.vue +++ b/frontend/src/features/editor/ExportDialog.vue @@ -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) { diff --git a/frontend/src/services/exportService.spec.ts b/frontend/src/services/exportService.spec.ts index 8121e47..84bb387 100644 --- a/frontend/src/services/exportService.spec.ts +++ b/frontend/src/services/exportService.spec.ts @@ -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:'',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}$/)})]})) +}) diff --git a/frontend/src/services/exportService.ts b/frontend/src/services/exportService.ts index 64dc51f..7d34705 100644 --- a/frontend/src/services/exportService.ts +++ b/frontend/src/services/exportService.ts @@ -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('/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('/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(`/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(`/api/exports/${encodeURIComponent(id)}`)) }, async list() { const response = await apiClient.get<{ items: JobWire[] }>('/api/exports'); return response.items.map(mapJob) },