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 3958880b0e..ddae7532b6 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 @@ -1,5 +1,5 @@ import {useCallback, useEffect, useLayoutEffect, useRef, useState} from "react"; -import {ArrowLeft, ArrowUpRight, ChevronDown, CircleHelp, LoaderCircle, RefreshCw, Sparkles, TriangleAlert, X} from "lucide-react"; +import {ArrowLeft, ArrowUpRight, ChevronDown, CircleHelp, Eye, LoaderCircle, RefreshCw, Sparkles, TriangleAlert, X} from "lucide-react"; import type {AssessmentCitation, TrajectoryAssessment, TrajectoryEpisode, TrajectoryOutputLanguage, TrajectoryContinuation} from "../../../../../../loopx/control_plane/capabilities/trajectory_assessment_types.js"; import {ResearchProgress, episodeQuestionGroups, metricCopy, verdictLabel, type ResearchProgressPack} from "./goal-research-progress"; import {requestJson} from "../../data/chat"; @@ -209,7 +209,7 @@ export function ResearchAnalysisButton({analysis: a}: {analysis: Controller}) { // explanation is optional; cached results and unavailable states are reads. const readOnly = unavailable || a.readError || 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} @@ -217,7 +217,7 @@ export function ResearchAnalysisButton({analysis: a}: {analysis: Controller}) { ? (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.") : (a.zh ? "问题解读、理由与限制说明按界面语言生成;此轨迹包未包含整页历史译文。" : "Questions, reasons and limitations follow the interface language; this pack does not include whole-history translation.")}

{a.zh ? "切换语言后单独缓存,需点击才生成。浏览、切换原文和展开内容不调用模型;译文未经独立校对,引用保留原文。" : "Each language is cached separately and generated only on request. Browsing, original-text switching and expansion never call a model. Translation is not independently checked; citations retain their originals."}

-

{a.pack?.result ? (a.zh ? "所选范围已有分析,查看不调用 Agent。" : "An analysis exists for the selected scope. Viewing never invokes an Agent.") : a.pack?.continuation?.can_reuse ? (a.zh ? "本次复用已有判读,不调用模型;原用量保留在上一版。" : "This action reuses existing judgments without a model call; original usage stays with the previous version.") : (a.zh ? "点击深度分析使用本机 Codex 账户,单独产生分析用量;普通浏览不调用 Agent。" : "Deep analysis uses the local Codex account with separate analysis usage; ordinary browsing never invokes an Agent.")}

+

{a.readOnly ? (a.zh ? "此来源只读;浏览已有分析不调用 Agent。" : "This source is read-only; viewing saved analyses never invokes an Agent.") : a.pack?.result ? (a.zh ? "所选范围已有分析,查看不调用 Agent。" : "An analysis exists for the selected scope. Viewing never invokes an Agent.") : a.pack?.continuation?.can_reuse ? (a.zh ? "本次复用已有判读,不调用模型;原用量保留在上一版。" : "This action reuses existing judgments without a model call; original usage stays with the previous version.") : (a.zh ? "点击深度分析使用本机 Codex 账户,单独产生分析用量;普通浏览不调用 Agent。" : "Deep analysis uses the local Codex account with separate analysis usage; ordinary browsing never invokes an Agent.")}

{a.zh ? "只分析已授权的固定轨迹,不改变研究历史。范围、方法与来源版本可在结果中查看。" : "Analyzes an authorized fixed trajectory without changing research history. Scope, method and source revisions remain available with the result."}

{a.pack?.continuation ?

{a.zh ? "接续保留上一版。新增、更正或删除可能影响旧判断,本版重新评估全部片段;仅证据内容与配置等价时复用已有判读。浏览与来源更新不启动分析。" : "Continuation preserves the previous version. Additions, corrections and removals can affect earlier judgments, so all episodes are reassessed. Existing judgments are reused only for equivalent evidence and configuration. Browsing and source updates never start analysis."}

: null}
{a.pack && !a.pack.result && !unavailable ? {a.pack.continuation?.can_reuse ? (a.zh ? "无需新模型调用" : "No new model call") : <>{a.pack.config.model} · {a.zh ? "单独用量" : "Separate usage"}} : null}
; @@ -480,8 +480,10 @@ 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 sourceAvailable = p?.availability === "available" && !a.readOnly; - const available = sourceAvailable && !a.readError; + // Source validity and permission to execute are independent. A read-only + // reader can still present a current saved report and its original evidence. + const sourceAvailable = p?.availability === "available"; + const available = sourceAvailable && !a.readOnly && !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") @@ -493,7 +495,8 @@ export function ResearchAnalysisPanel({analysis: a, onHistory}: {analysis: Contr {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} {p ? <> -
{p.episode_count} {zh ? "个研究片段" : "research episodes"} · {p.selection.exhaustive ? (zh ? "固定范围" : "Fixed scope") : (zh ? "定向抽样" : "Purposive sample")} +
{p.episode_count} {zh ? "个研究片段" : "research episodes"} · {p.selection.exhaustive ? (zh ? "固定范围" : "Fixed scope") : (zh ? "定向抽样" : "Purposive sample")} + {a.readOnly ?

{zh ? "可阅读已保存的分析与原文;此来源不能启动、重试或取消分析。" : "Saved analyses and originals remain readable. This source cannot start, retry or cancel analysis."}

: null}

{p.selection.exhaustive ? (zh ? "覆盖此固定分析范围;不等于覆盖全部研究。" : "Covers this fixed analysis scope, not necessarily the whole research history.") : (zh ? "定向抽样,仅分析选定研究片段;范围外或未捕获的工作未纳入,不代表全程。" : "Purposive sample of selected episodes; work outside this scope or not captured is excluded. It does not represent the whole run.")}

{zh ? "正向诊断、证据采用与最终任务收益分别判断。没有综合分数,也不据此推断因果或整体研究质量。引用存在不代表模型解释已被独立核验。" : "Diagnostic value, evidence adoption and task utility are separate. There is no total score, causal claim or whole-run quality rating. Citations do not independently verify model interpretations."}

{result ? Object.values(metricCopy).map(label =>

{label[zh ? 0 : 1]} · {label[zh ? 2 : 3]}

) : null} @@ -504,15 +507,15 @@ export function ResearchAnalysisPanel({analysis: a, onHistory}: {analysis: Contr {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 ?
- {zh ? "重新分析完整固定范围,产生新的分析用量;保留上一轮结果。" : "Reanalyzes the full fixed scope with new analysis usage; the previous result is preserved."} + {!a.readOnly ? {zh ? "重新分析完整固定范围,产生新的分析用量;保留上一轮结果。" : "Reanalyzes the full fixed scope with new analysis usage; the previous result is preserved."} : null}
: null} {!result || a.pending || a.recordedRunning ?
- {a.pending || a.recordedRunning ? <>

{a.pending ? : null}{a.pending ? (zh ? "正在处理分析请求…" : "Processing analysis request…") : (zh ? "上次记录仍待分析结果;当前执行状态尚未核验。" : "The last receipt awaits a result; current execution is unverified.")}

+ {a.pending || a.recordedRunning ? <>

{a.pending ? : null}{a.pending ? (zh ? "正在处理分析请求…" : "Processing analysis request…") : (zh ? "上次记录仍待分析结果;当前执行状态尚未核验。" : "The last receipt awaits a result; current execution is unverified.")}

: result ? : } - {!result && !a.pending && !a.recordedRunning ? {p.continuation?.can_reuse ? (zh ? "复用已有判读,无需新模型调用。" : "Reuses existing judgments without a new model call.") : zh ? "使用本机 Codex 账户,单独产生分析用量;浏览不调用 Agent。" : "Uses the local Codex account with separate analysis usage. Browsing never invokes an Agent."} : null} + {!result && !a.readOnly && !a.pending && !a.recordedRunning ? {p.continuation?.can_reuse ? (zh ? "复用已有判读,无需新模型调用。" : "Reuses existing judgments without a new model call.") : zh ? "使用本机 Codex 账户,单独产生分析用量;浏览不调用 Agent。" : "Uses the local Codex account with separate analysis usage. Browsing never invokes an Agent."} : null}
: null} - {failed && !a.pending ?

{p.job?.status === "cancelled" ? (zh ? "分析已取消,研究历史保持可读。" : "Analysis cancelled. Research history remains readable.") : (zh ? "上次分析未完成;可重试,原失败记录会保留。" : "The last analysis did not complete. Retry preserves its failure record.")}

: null} + {failed && !a.pending ?

{p.job?.status === "cancelled" ? (zh ? "分析已取消,研究历史保持可读。" : "Analysis cancelled. Research history remains readable.") : !available ? (zh ? "上次分析未完成,失败记录保留。" : "The last analysis did not complete; its failure record is preserved.") : (zh ? "上次分析未完成;可重试,原失败记录会保留。" : "The last analysis did not complete. Retry preserves its failure record.")}

: null} {failed && !a.pending ?

{zh ? "本次尝试" : "This attempt"} · {p.job?.analysis_wall_seconds == null ? (zh ? "耗时未知" : "Time unknown") : `${p.job.analysis_wall_seconds.toFixed(1)}s`} · {zh ? "输入" : "input"} {p.job?.usage?.input_tokens?.toLocaleString() ?? "—"} / {zh ? "输出" : "output"} {p.job?.usage?.output_tokens?.toLocaleString() ?? "—"} tokens

: null} diff --git a/docs/development/assets/research-report-episodes/readonly-analysis-after-1280.png b/docs/development/assets/research-report-episodes/readonly-analysis-after-1280.png new file mode 100644 index 0000000000..b7c8ae979e Binary files /dev/null and b/docs/development/assets/research-report-episodes/readonly-analysis-after-1280.png differ diff --git a/docs/development/assets/research-report-episodes/readonly-analysis-after-390.png b/docs/development/assets/research-report-episodes/readonly-analysis-after-390.png new file mode 100644 index 0000000000..6de1c54a16 Binary files /dev/null and b/docs/development/assets/research-report-episodes/readonly-analysis-after-390.png differ diff --git a/docs/development/assets/research-report-episodes/readonly-analysis-before-1280.png b/docs/development/assets/research-report-episodes/readonly-analysis-before-1280.png new file mode 100644 index 0000000000..204d62da72 Binary files /dev/null and b/docs/development/assets/research-report-episodes/readonly-analysis-before-1280.png differ diff --git a/docs/development/assets/research-report-episodes/readonly-analysis-before-390.png b/docs/development/assets/research-report-episodes/readonly-analysis-before-390.png new file mode 100644 index 0000000000..a080a4f052 Binary files /dev/null and b/docs/development/assets/research-report-episodes/readonly-analysis-before-390.png differ diff --git a/docs/development/assets/research-report-episodes/readonly-analysis-help-390.png b/docs/development/assets/research-report-episodes/readonly-analysis-help-390.png new file mode 100644 index 0000000000..e88c0efdc7 Binary files /dev/null and b/docs/development/assets/research-report-episodes/readonly-analysis-help-390.png differ diff --git a/docs/development/design.md b/docs/development/design.md index 3faf2bda10..f076a84e7e 100644 --- a/docs/development/design.md +++ b/docs/development/design.md @@ -210,6 +210,13 @@ action consequences or relying on fixed alignment offsets. Reading a citation opens only the requested original, preserving the explicit open or closed state of the full analysis and returning to the exact citation. +Read-only execution does not make a saved report or its sources unavailable. +Keep valid reports readable; explain execution restrictions in the existing +scope help and disable the affected actions. Source changes, unreadable originals +and failed refreshes retain their own warning and recovery states. + +执行只读不等于汇报或来源不可读。正常显示已保存汇报,执行限制放在现有范围帮助中, +禁用受限操作;来源变化、原文不可读及刷新失败保留各自的警告和恢复入口。 When an explicit analysis supplies a whole-history translation, use it throughout the matching research snapshot, including routes, decisions and expanded history. diff --git a/examples/personal-workspace-browser/research-history.mjs b/examples/personal-workspace-browser/research-history.mjs index c1a1aff3b2..d0a46de8ad 100644 --- a/examples/personal-workspace-browser/research-history.mjs +++ b/examples/personal-workspace-browser/research-history.mjs @@ -175,6 +175,14 @@ export const researchHistoryScenario = { await nativePage.evaluate(() => {for (const e of document.querySelectorAll('*')) if (e.scrollTop) e.scrollTop = 0;}); assert(await nativePage.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); await nativePage.screenshot({path: resolve(outputDir, 'research-native-default-mobile.png'), animations: 'disabled'}); + await nativePage.getByRole('button', {name: '分析详情', exact: true}).click(); + const readOnlyAnalysis = nativePage.getByRole('region', {name: '轨迹深度分析', exact: true}); + assert(await readOnlyAnalysis.getByRole('button', {name: '分析所选范围', exact: true}).isDisabled(), 'An available source does not grant analysis execution to its read-only reader'); + assert.equal(await readOnlyAnalysis.locator('.analysis-actions > small').count(), 0, 'A read-only action does not advertise new model usage'); + await readOnlyAnalysis.getByRole('button', {name: '分析范围', exact: true}).focus(); + await readOnlyAnalysis.getByRole('tooltip').getByText(/不能启动、重试或取消分析/).waitFor(); + await nativePage.keyboard.press('Escape'); + await readOnlyAnalysis.press('Escape'); const readback = await (await nativePage.request.get(`${nativeOwner.base}/fixture/readback`)).json(); assert.equal(readback.source_unchanged, true); assert.equal(readback.mutation_attempts, 0); @@ -237,6 +245,12 @@ export const researchHistoryScenario = { await synthesisPage.getByRole('button', {name: '深入解读', exact: true}).click(); const analysis = synthesisPage.getByRole('region', {name: '轨迹深度分析', exact: true}); await analysis.waitFor(); + assert.equal(await analysis.locator('.analysis-warning').count(), 0, 'A readable saved report is not an unavailable analysis just because execution is read-only'); + const readScope = analysis.getByRole('button', {name: '分析范围', exact: true}); + await readScope.focus(); + await analysis.getByRole('tooltip').getByText(/不能启动、重试或取消分析/).waitFor(); + await synthesisPage.keyboard.press('Escape'); + assert(await readScope.evaluate(e => e === document.activeElement), 'Read-only context help returns focus to the same reading scope'); assert.equal(await synthesisPage.getByRole('region', {name: '研究汇报', exact: true}).count(), 1, 'One reading focus when expanded'); assert.match(await analysis.innerText(), /竞争解释.*随机波动/s); const savedOverview = await analysis.locator('.research-synthesis-summary').innerText(); @@ -326,6 +340,10 @@ export const researchHistoryScenario = { await synthesisPage.setViewportSize({width: 390, height: 844}); await synthesisPage.getByRole('button', {name: 'Read deeper', exact: true}).click(); const english = synthesisPage.locator('.research-analysis'); + assert.equal(await english.locator('.analysis-warning').count(), 0); + await english.getByRole('button', {name: 'Analysis scope', exact: true}).focus(); + await english.getByRole('tooltip').getByText(/cannot start, retry or cancel analysis/).waitFor(); + await synthesisPage.keyboard.press('Escape'); const englishReview = english.locator('.analysis-method').first(); const englishOrigin = english.locator('.research-report-finding .research-review-episodes').first(); await englishOrigin.click(); @@ -355,6 +373,7 @@ export const researchHistoryScenario = { let analysisFixture = false, holdOldAnalysis = false, slowStarted, releaseOld, oldDelivered; let holdCitation = false, failedCitation = false, citationStarted, releaseCitation; let unavailableAnalysis = false, failedAnalysisPack = "a", episodeMetadataLimit = null, comparisonFixture = false; + let analysisSourceReason = null; let analysisWrites = 0, analysisReads = 0, localizedFixture = false, continuationFixture = false, equivalentContinuation = false; let versionComparison = "shared"; let unavailableTask = false; @@ -380,6 +399,7 @@ export const researchHistoryScenario = { const selected = ["b", "c", "d"].find(id => query.get("pack_id") === id.repeat(64)) ?? "a"; if (holdOldAnalysis && selected === "a") {slowStarted(); await new Promise(resolve => {releaseOld = resolve;});} const readback = comparisonFixture ? comparisonReadback() : analysisReadback(selected); + if (analysisSourceReason) Object.assign(readback.pack, {availability: 'unavailable', reason_code: analysisSourceReason}); if (continuationFixture) { const p = readback.pack, base = selected === 'b'; p.pack_id = (base ? 'b' : 'a').repeat(64); @@ -1082,6 +1102,15 @@ Path(sys.argv[1]).write_text(json.dumps({'goal_id':goal,'display_name':'Syntheti await history.getByRole("button", {name: "查看分析", exact: true}).click(); const analysis = history.getByRole("region", {name: "轨迹深度分析", exact: true}); await analysis.locator("header p").first().filter({hasText: /^Synthetic analysis A$/}).waitFor(); + for (const [reason, message] of [['source_changed', /原始来源已变化/], ['source_unavailable', /原始来源暂不可读/]]) { + analysisSourceReason = reason; + await analysis.getByRole('button', {name: '刷新分析结果', exact: true}).click(); + await analysis.locator('.analysis-warning').filter({hasText: message}).waitFor(); + assert.equal(await analysis.locator('.analysis-episode > summary').count(), 2, 'A real source-validity warning retains the saved judgments'); + } + analysisSourceReason = null; + await analysis.getByRole('button', {name: '刷新分析结果', exact: true}).click(); + await analysis.locator('.analysis-warning').waitFor({state: 'hidden'}); assert.match(await analysis.innerText(), /原目标收益|original-objective/); assert.equal(await analysis.locator('.analysis-episode > summary').count(), 2, 'The reader starts with one comparable row per episode'); const firstEpisode = analysis.locator('.analysis-episode').first();