diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.css index f14ba8fa2d..0816518dfb 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.css @@ -84,11 +84,9 @@ @media (max-width: 640px) { .research-analysis { padding: 16px; } .analysis-episode-context { grid-template-columns: 1fr; gap: 12px; } .analysis-method dl { grid-template-columns: 1fr; gap: 2px; } .analysis-method dd { margin-bottom: 8px; } .analysis-pack-choice { flex-direction: column; align-items: stretch; } } /* Readback has one status surface, shared by the summary and its detail. */ -.analysis-read-error,.analysis-read-empty{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-block:12px;color:var(--pw-muted)} -.analysis-read-empty{margin-block:0 16px;padding:10px 12px;border:1px solid var(--pw-line);border-radius:8px;background:var(--pw-hover);font-size:var(--text-body)} -.analysis-read-empty>button{margin-inline-start:auto} -.analysis-read-error>button,.analysis-read-empty>button{display:inline-flex;align-items:center;justify-content:center;gap:4px;min-height:44px;border:1px solid var(--pw-line);border-radius:6px;padding:8px 12px;color:var(--pw-text);background:var(--pw-bg);font:inherit;cursor:pointer} -.analysis-read-error>button:disabled,.analysis-read-empty>button:disabled{opacity:.5;cursor:default} +.analysis-read-error{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-block:12px;color:var(--pw-muted)} +.analysis-read-error>button{display:inline-flex;align-items:center;justify-content:center;gap:4px;min-height:44px;border:1px solid var(--pw-line);border-radius:6px;padding:8px 12px;color:var(--pw-text);background:var(--pw-bg);font:inherit;cursor:pointer} +.analysis-read-error>button:disabled{opacity:.5;cursor:default} .analysis-read-loading{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;padding-block:16px} .analysis-read-loading>span{height:5rem;border-radius:6px;background:var(--pw-line)} @media(max-width:640px){.analysis-read-loading{grid-template-columns:repeat(2,minmax(0,1fr))}} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx index 7589627889..f8e5c4dd0a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx @@ -155,7 +155,7 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean }; const execute = async (retry = false) => { if (readSourceUrl && snapshot?.execution_enabled !== true) return; - if (!pack || pack.availability !== "available" || submitting.current) return; + if (readError || !pack || pack.availability !== "available" || submitting.current) return; show(); submitting.current = true; setPending(true); setError(""); const current = generation.current; try { @@ -204,12 +204,14 @@ const reasonCopy = (reason: string | null | undefined, zh: boolean) => { export function ResearchAnalysisButton({analysis: a}: {analysis: Controller}) { const unavailable = a.readOnly || a.pack?.availability !== "available"; + const sourceUnavailable = !a.loading && !a.readError && a.snapshot && (!a.pack || a.pack.availability !== "available"); // The action keeps its separate-usage consequence visible. Further method // explanation is optional; cached results and unavailable states are reads. - const readOnly = unavailable || a.pack?.result || a.pending || a.recordedRunning; - return
+ + {sourceUnavailable ?

{reasonCopy(a.pack?.reason_code ?? a.snapshot?.catalog.reason_code, a.zh)}

: null} {a.pack ?

{a.zh ? "分析请求设置(固定):" : "Fixed analysis request: "}{a.pack.config.model} / {a.pack.config.effort} / {a.pack.config.service_tier}

: null}

{a.pack?.display_request ? (a.zh ? "本次分析同时生成固定历史快照的中文展示译文;问题、决策、结论与历史正文按字段核对版本,缺失项保留原文。" : "This analysis also translates the frozen history snapshot: questions, decisions, findings and historical text are version-bound field by field; missing translations retain their originals.") @@ -266,9 +268,8 @@ function AnalysisReadState({analysis: a}: {analysis: Controller}) {

; if (!a.pack && a.loading && (!a.snapshot || a.snapshot.catalog.packs.length > 0)) return
{Array.from({length: 6}, (_, i) => )}
; - if (!a.pack) return
{reasonCopy(a.snapshot?.catalog.reason_code, a.zh)} -
; - return !a.pack.result && a.pack.availability !== "available" ?

{reasonCopy(a.pack.reason_code, a.zh)}

: null; + if (!a.pack && a.location.open) return

{a.zh ? "分析来源未就绪" : "Analysis source unavailable"}

; + return null; } export function ResearchAnalysisStatus({analysis: a}: {analysis: Controller}) { @@ -404,14 +405,15 @@ export function ResearchAnalysisPanel({analysis: a, onHistory}: {analysis: Contr const zh = a.zh, p = a.pack, result = p?.result; const resultJob = p?.result_job ?? p?.job; const failed = p?.job?.status === "failed" || p?.job?.status === "cancelled"; - const available = p?.availability === "available" && !a.readOnly; + const sourceAvailable = p?.availability === "available" && !a.readOnly; + const available = sourceAvailable && !a.readError; const benefit = result?.metrics.find(m => m.metric === "benefit_test"); const headline = !result ? null : benefit?.no ? (zh ? "仍有研究片段尚未检验原目标收益" : "Some episodes have not tested original-objective utility") : benefit?.unknown ? (zh ? "原目标收益的检验情况仍未知" : "Original-objective utility testing remains unknown") : benefit?.yes ? (zh ? "模型记录了收益检验;这不等于收益为正" : "The model identified a utility test; this does not imply positive utility") : (zh ? "此方法未覆盖原目标收益检验" : "This method does not cover original-objective utility testing"); return
{if (event.key === "Escape") {event.stopPropagation(); a.close();}}}> -

{result?.synthesis ? (zh ? "研究汇报" : "Research report") : (zh ? "轨迹深度分析" : "Trajectory analysis")}

{p?.title ?? (zh ? "只分析已授权的固定轨迹,不改变研究历史。" : "Analyze an authorized fixed trajectory without changing its history.")}

{result?.synthesis && onHistory ? : null}{result ? : null}
+

{result?.synthesis ? (zh ? "研究汇报" : "Research report") : (zh ? "轨迹深度分析" : "Trajectory analysis")}

{p?.title ?

{p.title}

: null}
{result?.synthesis && onHistory ? : null}{result ? : null}
{a.error ?
{zh ? "分析请求未完成,已保存结果保留。" : "The analysis request did not complete; saved results are preserved."}

{a.error}

: null} {a.snapshot?.catalog.unreadable_pack_ids?.length && !p?.continuation_unavailable ?

{zh ? "部分历史分析暂不可读" : "Some historical analyses cannot be read"} · {a.snapshot.catalog.unreadable_pack_ids.length}

: null} @@ -423,7 +425,7 @@ export function ResearchAnalysisPanel({analysis: a, onHistory}: {analysis: Contr

{zh ? "下方仍显示当前研究 Goal 的已记录历史。" : "The current research Goal’s recorded history remains below."}

- {p.result && !available ?

{reasonCopy(p.reason_code, zh)}

: null} + {p.result && !sourceAvailable ?

{reasonCopy(p.reason_code, zh)}

: null} {p.result_attempt && p.result_attempt !== p.job?.attempt ?

{zh ? "当前重试尚未完成,显示上次已保存分析。" : "The current retry is incomplete; showing the previously saved analysis."}

: null} {result?.display?.coverage.missing && !a.pending && !a.recordedRunning ?
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.css index 6c0025733e..f395110017 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.css @@ -83,11 +83,9 @@ .research-history-read-error > svg { color: var(--pw-red); } .research-history-empty > svg { color: var(--pw-muted); } .research-history-read-error > div, .research-history-empty > div { flex: 1; min-width: 0; } +.research-history-read-error > div { display: flex; align-items: center; gap: 4px; } .research-history-read-error strong, .research-history-empty strong { font-size: var(--text-body); font-weight: 600; } .research-history-read-error p, .research-history-empty p { margin: 4px 0 0; color: var(--pw-muted); font-size: var(--text-body); line-height: var(--leading-prose); } -.research-history-read-error details { margin-top: 8px; color: var(--pw-muted); font-size: var(--text-caption); } -.research-history-read-error summary { min-height: 32px; cursor: pointer; } -.research-history-read-error code { display: block; overflow-wrap: anywhere; color: var(--pw-muted); font-family: var(--font-mono); } .research-history-read-error > button, .research-history-empty > button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; flex-shrink: 0; min-height: 44px; padding: 8px 12px; border: 1px solid var(--pw-line); border-radius: 6px; background: var(--pw-bg); color: var(--pw-text); font: inherit; font-size: var(--text-body); cursor: pointer; } .research-history-read-error > button:hover, .research-history-empty > button:hover { background: var(--pw-hover); } .research-history-error { color: var(--pw-red); font-size: var(--text-body); overflow-wrap: anywhere; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx index 5d4c7b3e37..3998c9ec81 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx @@ -419,8 +419,7 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re {readFailure ?
{read.events.length ? (zh ? "刷新失败,当前记录已保留" : "Refresh failed; current records are preserved") : copy.failure} -

{read.events.length ? (zh ? "可继续阅读,或重试获取最新记录。" : "Keep reading, or retry for the latest records.") : (zh ? "请检查本地服务后重试;技术详情默认收起。" : "Check the local service and retry. Technical details stay collapsed.")}

-
{zh ? "技术详情" : "Technical details"}{readFailure}
+

{readFailure}

: null} diff --git a/docs/development/assets/research-report-episodes/research-state-after-desktop.png b/docs/development/assets/research-report-episodes/research-state-after-desktop.png new file mode 100644 index 0000000000..27b7c40016 Binary files /dev/null and b/docs/development/assets/research-report-episodes/research-state-after-desktop.png differ diff --git a/docs/development/assets/research-report-episodes/research-state-after-narrow.png b/docs/development/assets/research-report-episodes/research-state-after-narrow.png new file mode 100644 index 0000000000..e6038e6edd Binary files /dev/null and b/docs/development/assets/research-report-episodes/research-state-after-narrow.png differ diff --git a/docs/development/assets/research-report-episodes/research-state-before-desktop.png b/docs/development/assets/research-report-episodes/research-state-before-desktop.png new file mode 100644 index 0000000000..48c6cb4d7f Binary files /dev/null and b/docs/development/assets/research-report-episodes/research-state-before-desktop.png differ diff --git a/docs/development/assets/research-report-episodes/research-state-before-narrow.png b/docs/development/assets/research-report-episodes/research-state-before-narrow.png new file mode 100644 index 0000000000..be61908129 Binary files /dev/null and b/docs/development/assets/research-report-episodes/research-state-before-narrow.png differ diff --git a/docs/development/assets/research-report-episodes/research-state-error-after-narrow.png b/docs/development/assets/research-report-episodes/research-state-error-after-narrow.png new file mode 100644 index 0000000000..cb6da0bb8b Binary files /dev/null and b/docs/development/assets/research-report-episodes/research-state-error-after-narrow.png differ diff --git a/docs/development/assets/research-report-episodes/research-state-error-before-narrow.png b/docs/development/assets/research-report-episodes/research-state-error-before-narrow.png new file mode 100644 index 0000000000..b413701136 Binary files /dev/null and b/docs/development/assets/research-report-episodes/research-state-error-before-narrow.png differ diff --git a/docs/development/assets/research-report-episodes/research-state-help-narrow.png b/docs/development/assets/research-report-episodes/research-state-help-narrow.png new file mode 100644 index 0000000000..6f22d71c51 Binary files /dev/null and b/docs/development/assets/research-report-episodes/research-state-help-narrow.png differ diff --git a/docs/development/assets/research-report-episodes/research-state-populated-desktop.png b/docs/development/assets/research-report-episodes/research-state-populated-desktop.png new file mode 100644 index 0000000000..8dd5ebeeb2 Binary files /dev/null and b/docs/development/assets/research-report-episodes/research-state-populated-desktop.png differ diff --git a/examples/personal-workspace-browser/research-history.mjs b/examples/personal-workspace-browser/research-history.mjs index 17c370be19..e5a3c730d3 100644 --- a/examples/personal-workspace-browser/research-history.mjs +++ b/examples/personal-workspace-browser/research-history.mjs @@ -326,14 +326,15 @@ export const researchHistoryScenario = { let unavailable = false; let analysisFixture = false, holdOldAnalysis = false, slowStarted, releaseOld, oldDelivered; let holdCitation = false, failedCitation = false, citationStarted, releaseCitation; - let unavailableAnalysis = false, episodeMetadataLimit = null, comparisonFixture = false; - let analysisWrites = 0, localizedFixture = false, continuationFixture = false, equivalentContinuation = false; + let unavailableAnalysis = false, failedAnalysisPack = "a", episodeMetadataLimit = null, comparisonFixture = false; + let analysisWrites = 0, analysisReads = 0, localizedFixture = false, continuationFixture = false, equivalentContinuation = false; let unavailableTask = false; const taskReads = []; try { context = await openWorkspacePage(browser, url, {collectCoverage, beforeGoto: async (_, page) => { await page.route("**/api/chat/research-analysis**", async route => { if (route.request().method() !== "GET") {analysisWrites++; return route.fulfill({status: 403, json: {error: "Read-only presentation fixture"}});} + analysisReads++; if (!analysisFixture) return route.fulfill({json: {ok: true, catalog: {availability: "unavailable", reason_code: "no_available_pack", default_pack_id: null, packs: []}, pack: null}}); const query = new URL(route.request().url()).searchParams; const language = query.get('output_language'); @@ -388,7 +389,7 @@ export const researchHistoryScenario = { sources: {added: [], revised: [], removed: []}, episodes: {added: [], revised: [], removed: []}, reassessed_episode_ids: [], verdict_changes: []}); readback.pack.result = null; readback.pack.job = null; } - await route.fulfill(unavailableAnalysis && selected === "a" + await route.fulfill(unavailableAnalysis && selected === failedAnalysisPack ? {status: 503, json: {error: "Synthetic analysis read unavailable"}} : {json: readback}); if (holdOldAnalysis && selected === "a") oldDelivered(); @@ -432,6 +433,43 @@ export const researchHistoryScenario = { await history.getByRole("status").filter({hasText: "76 / 76"}).waitFor(); assert.equal(pages.length, 2, "The complete Portfolio checkpoint requires every bounded HTTP page"); assert.deepEqual(pages.flatMap(value => value.items).map(row => row.event_id), capture.events.map(row => row.event_id)); + assert.equal(await history.locator('.analysis-read-empty').count(), 0, + 'No analysis source must not add another explanation card above the recorded history'); + const sourceHelp = history.getByRole('button', {name: '分析来源未就绪', exact: true}); + await sourceHelp.waitFor(); + assert.equal(await history.getByRole('button', {name: '分析说明', exact: true}).count(), 0, + 'Source state and optional analysis guidance share one contextual entry'); + assert.doesNotMatch(await history.innerText(), /尚无可分析的授权轨迹包/); + await sourceHelp.hover(); + const sourceExplanation = page.locator('.context-help-popover:popover-open'); + await sourceExplanation.hover(); + assert.match(await sourceExplanation.innerText(), /尚无可分析的授权轨迹包/); + await page.keyboard.press('Escape'); + await sourceHelp.focus(); + await sourceExplanation.waitFor({state: 'visible'}); + await page.keyboard.press('Escape'); + assert(await sourceHelp.evaluate(e => e === document.activeElement)); + const readsBeforeRefresh = analysisReads; + const analysisRefreshed = page.waitForResponse(response => new URL(response.url()).pathname === '/api/chat/research-analysis'); + await history.getByRole('button', {name: '刷新记录', exact: true}).click(); + await analysisRefreshed; + await sourceHelp.waitFor(); + assert(analysisReads > readsBeforeRefresh, 'The shared research refresh still reads the analysis owner'); + await page.setViewportSize({width: 390, height: 844}); + await sourceHelp.click(); + await sourceExplanation.waitFor({state: 'visible'}); + const sourceBounds = await sourceExplanation.boundingBox(); + assert(sourceBounds.x >= 0 && sourceBounds.x + sourceBounds.width <= 390); + await history.getByRole('heading', {name: '研究历程', exact: true}).click(); + await sourceExplanation.waitFor({state: 'hidden'}); + await history.getByRole('button', {name: '深度分析', exact: true}).click(); + const unavailablePanel = history.getByRole('region', {name: '轨迹深度分析', exact: true}); + await unavailablePanel.getByRole('status').filter({hasText: '分析来源未就绪'}).waitFor(); + assert.equal(await unavailablePanel.locator('header p').count(), 0, 'An empty reader does not repeat analysis instructions'); + await unavailablePanel.press('Escape'); + assert(await history.getByRole('button', {name: '深度分析', exact: true}).evaluate(e => e === document.activeElement)); + assert.equal(analysisWrites, 0, 'Unavailable analysis, help and recovery are reads'); + await page.setViewportSize({width: 1280, height: 900}); await history.locator(".research-phases").waitFor(); const early = history.locator(".research-phase").first(); await early.click(); @@ -487,7 +525,11 @@ export const researchHistoryScenario = { assert.match(await history.textContent(),/76 \/ 76/,'A failed refresh retains the complete real capture'); const readError = history.locator('.research-history-read-error'); assert.match(await readError.innerText(), /刷新失败,当前记录已保留/); - assert.equal(await readError.locator('details').evaluate(element => element.open), false, 'Technical failure detail stays secondary'); + assert.equal(await readError.getByRole('tooltip').isVisible(), false, 'Technical failure detail stays secondary'); + assert.doesNotMatch(await readError.innerText(), /可继续阅读|技术详情默认收起/); + await readError.getByRole('button', {name: '研究记录读取详情', exact: true}).focus(); + assert.match(await readError.getByRole('tooltip').innerText(), /Isolated history transport unavailable/); + await page.keyboard.press('Escape'); await readError.screenshot({path:resolve(outputDir,'research-history-refresh-error.png'),animations:'disabled'}); unavailable=false; await readError.getByRole('button',{name:'重试',exact:true}).click(); @@ -1149,6 +1191,20 @@ Path(sys.argv[1]).write_text(json.dumps({'goal_id':goal,'display_name':'Syntheti assert.equal(await progress.locator('.research-effect-metrics').count(), 0, 'An unanalysed cost pack never borrows another pack’s judgments'); assert.equal(await analysis.count(), 0, 'Choosing an unanalysed source never requests analysis'); assert.equal(await improvement.locator('[data-retained-improvement]').textContent(), '—', 'Missing comparison evidence is unknown, never zero'); + failedAnalysisPack = "d"; unavailableAnalysis = true; + await tabs.getByRole('button', {name: '概览', exact: true}).click(); + await tabs.getByRole('button', {name: '研究', exact: true}).click(); + await history.locator('.analysis-read-error').waitFor(); + assert.equal(await nativeResults.locator('[data-native-point="retained"] strong').textContent(), '3', 'A failed same-scope refresh keeps recorded native facts'); + await history.getByRole('button', {name: '深度分析', exact: true}).click(); + await analysis.waitFor(); + assert(await analysis.getByRole('button', {name: '分析所选范围', exact: true}).isDisabled(), 'A stale unanalysed pack cannot start a model while its refresh is unavailable'); + assert.equal(analysisWrites, 0, 'The entry opens recovery instead of executing from a stale snapshot'); + unavailableAnalysis = false; failedAnalysisPack = "a"; + await analysis.getByRole('button', {name: '重试读取', exact: true}).click(); + await analysis.getByRole('alert').waitFor({state: 'hidden'}); + assert(await analysis.getByRole('button', {name: '分析所选范围', exact: true}).isEnabled(), 'A successful owner read restores the existing explicit analysis action'); + await analysis.press('Escape'); await basis.locator(':scope > summary').click(); assert.equal(await nativeResults.locator('[data-native-point="retained"] strong').textContent(), '3', 'Native facts do not require an analyzer result'); assert.match(await nativeResults.innerText(), /部分窗口 · 起点未知/);