diff --git a/apps/presentation/dashboard/smoke/research-evidence-version-browser-smoke.mjs b/apps/presentation/dashboard/smoke/research-evidence-version-browser-smoke.mjs index 475d93a576..cf70d3c496 100644 --- a/apps/presentation/dashboard/smoke/research-evidence-version-browser-smoke.mjs +++ b/apps/presentation/dashboard/smoke/research-evidence-version-browser-smoke.mjs @@ -103,8 +103,15 @@ try { localizedReady = false; const child = JSON.parse(await readFile(resolve(import.meta.dirname, "../src/data/fixtures/case-study-child.json"), "utf8")); const parent = {goal_id: child.parent_goal_id, display_name: "Benchmark", events: [], analyses: []}; + const second = structuredClone(child.analyses[0]); + second.title = "另一版解读"; + second.case_study.sections[1].citations[0].first_line = 2; + child.analyses.push(second); const study = await start("study", child, parent); - const studyPack = (await (await fetch(new URL(analysisPath + "?" + new URLSearchParams({goal_id: child.goal_id}), study))).json()).pack; + const studyRead = await (await fetch(new URL(analysisPath + "?" + new URLSearchParams({goal_id: child.goal_id}), study))).json(); + const studyPack = studyRead.pack; + const otherPack = studyRead.catalog.packs.find(pack => pack.pack_id !== studyPack.pack_id); + assert(otherPack, "Same Goal has two saved versions"); const chapter = studyPack.case_study.sections[1]; const address = new URL("/chat/", study); address.search = new URLSearchParams({goalId: child.goal_id, view: "research", research_goal: child.goal_id, @@ -113,6 +120,73 @@ try { const atChapter = () => page.waitForFunction(id => document.activeElement?.id === id, address.hash.slice(1)); await page.goto(address.href); await atChapter(); + const sourceRequests = []; + const quoteRoute = url => url.origin === new URL(study).origin && url.pathname.endsWith("/citation"); + let failNextQuote = false; + await page.route(quoteRoute, route => { + sourceRequests.push(new URL(route.request().url()).search); + if (failNextQuote) { + failNextQuote = false; + return route.fulfill({status: 503, json: {ok: false, error: "Synthetic source unavailable"}}); + } + return route.continue(); + }); + const chapterControl = page.locator(".case-sections > section").nth(1); + await chapterControl.locator("summary").click(); + const originalLink = chapterControl.locator(".case-citation-links").getByRole("link"); + const bookmark = new URL(await originalLink.getAttribute("href")); + assert.equal(bookmark.searchParams.get("research_pack"), studyPack.pack_id); + const cited = chapter.citations[0]; + const expectedSource = await (await fetch(new URL(analysisPath + "/citation?" + new URLSearchParams({ + goal_id: child.goal_id, pack_id: studyPack.pack_id, analysis_key: studyPack.analysis_key, + source_id: cited.source_id, first_line: String(cited.first_line), last_line: String(cited.last_line), + }), study))).json(); + assert.equal(expectedSource.source_id, cited.source_id); + assert(expectedSource.text); + await originalLink.press("Enter"); + await page.waitForURL(url => url.hash === bookmark.hash); + await citation.locator("pre").first().waitFor(); + assert.equal(await citation.locator("pre").first().textContent(), expectedSource.text); + const originalQuery = sourceRequests.at(-1); + await page.setViewportSize({width: 390, height: 900}); + failNextQuote = true; + await page.reload(); + await citation.getByRole("alert").waitFor(); + assert.equal(new URL(page.url()).hash, bookmark.hash); + await citation.getByRole("button", {name: "重试读取原文"}).click(); + await citation.locator("pre").first().waitFor(); + assert.equal(await citation.locator("pre").first().textContent(), expectedSource.text); + assert.deepEqual(sourceRequests.slice(-2), [originalQuery, originalQuery]); + await citation.press("Escape"); + await originalLink.evaluate(element => { + if (document.activeElement !== element) return new Promise(resolve => element.addEventListener("focus", resolve, {once: true})); + }); + assert.equal(new URL(page.url()).hash, address.hash); + assert.equal(await citation.count(), 0); + // Paste the same permalink in the existing document. This exercises the + // browser route, where neither the link's click handler nor reload runs. + const documentStart = await page.evaluate(() => performance.timeOrigin); + const readsBeforeReopen = sourceRequests.length; + await page.evaluate(href => {window.location.assign(href);}, bookmark.href); + await citation.locator("pre").first().waitFor({timeout: 6_000}); + assert.equal(await page.evaluate(() => performance.timeOrigin), documentStart); + assert.equal(await citation.locator("pre").first().textContent(), expectedSource.text); + assert.deepEqual(sourceRequests.slice(readsBeforeReopen), [originalQuery], + "Reopening a closed permalink reads its original evidence exactly once"); + await citation.getByRole("button", {name: "关闭原文", exact: true}).click(); + assert.equal(new URL(page.url()).hash, address.hash); + assert.equal(await citation.count(), 0); + // A valid position from another pack must never fall through to this pack. + const misplaced = new URL(bookmark); + misplaced.searchParams.set("research_pack", otherPack.pack_id); + const readsBeforeMismatch = sourceRequests.length; + await page.goto(misplaced.href); + await page.getByText("这条链接在所选解读中找不到原文。", {exact: false}).waitFor(); + assert.equal(sourceRequests.length, readsBeforeMismatch); + assert.equal(await citation.count(), 0); + await page.setViewportSize({width: 1200, height: 900}); + await page.goto(address.href); + await atChapter(); const tabs = page.locator(".personal-goal-tabs"); await tabs.getByRole("button", {name: "任务", exact: true}).click(); await page.waitForURL(url => url.searchParams.get("view") === "tasks"); @@ -136,7 +210,7 @@ try { await page.goForward(); await page.waitForURL(url => url.searchParams.get("goalId") === parent.goal_id); assertParentAddress(); - console.log("research evidence version: saved excerpts isolated and chapter navigation restored"); + console.log("research evidence version: saved excerpts, exact source links and chapter navigation restored"); } finally { releaseOriginal?.(); await browser?.close(); 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 f52ffbcfb3..dc345566ef 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 @@ -7,7 +7,7 @@ import {requestJson} from "../../data/chat"; import {sourceReadApiUrl} from "../../data/local-status-query"; import {ContextHelp} from "./context-help"; import {ResearchSynthesis} from "./goal-research-synthesis"; -import {ResearchCaseStudy, caseChapterHashPrefix} from "./research-case-study"; +import {ResearchCaseStudy, caseChapterHashPrefix, type CaseCitationBookmark} from "./research-case-study"; import type {TrajectoryCaseStudy} from "../../../../../../loopx/control_plane/capabilities/trajectory_case_study.js"; import "./goal-research-analysis.css"; @@ -36,6 +36,7 @@ type EvidenceRead = { value?: Citation; failure?: {message: string; route: string; selection: Record}; }; +type EvidenceTrigger = {origin?: HTMLElement; bookmark?: CaseCitationBookmark}; const errorText = (error: unknown) => error instanceof Error ? error.message : String(error); const locationFor = (goal: string) => { const query = new URLSearchParams(window.location.search); @@ -95,12 +96,16 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean const [evidenceRead, setEvidenceRead] = useState(null); const citeGeneration = useRef(0); const sourcePanel = useRef(null); - const sourceTrigger = useRef(null); + const sourceTrigger = useRef<(EvidenceTrigger & {scope: string}) | null>(null); const invalidateCitation = useCallback(() => { citeGeneration.current++; sourceTrigger.current = null; setEvidenceRead(null); }, []); + const onCitationLocationChange = useCallback((hash: string) => { + const activeBookmark = sourceTrigger.current?.bookmark; + if (activeBookmark && activeBookmark.hash !== hash) invalidateCitation(); + }, [invalidateCitation]); const [location, setLocation] = useState(() => locationFor(goalId)); const sourceScope = JSON.stringify([goalId, outputLanguage, readSourceUrl ?? ""]); const [readState, setReadState] = useState<{scope: string; packId: string | null; snapshot: Snapshot | null; error: string; loading: boolean}>( @@ -234,8 +239,8 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean url.searchParams.set("research_goal", goalId); url.searchParams.set("research_reading", "case"); const namespace = readSourceUrl ? new URL(readSourceUrl, url).searchParams.get("source_namespace") : null; - return {url, goalId, packId, namespace, follow: (href: URL) => { - if (href.href !== window.location.href) window.history.pushState(window.history.state, "", href); + return {url, goalId, packId, namespace, follow: (href: URL, replace = false) => { + if (href.href !== window.location.href) window.history[replace ? "replaceState" : "pushState"](window.history.state, "", href); if (location.packId !== packId) applyLocation({open: location.open, packId}); }}; }; @@ -265,16 +270,21 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean } catch (e) {if (current === generation.current) setError(errorText(e));} finally {if (current === generation.current) setCancelPending(false);} }; - const readEvidence = async (selection: Record, route: string, origin?: HTMLButtonElement) => { + const readEvidence = async (selection: Record, route: string, trigger?: EvidenceTrigger) => { const p = readingPack; if (!p || evidenceScope === null) return; - if (origin) sourceTrigger.current = origin; + if (trigger) sourceTrigger.current = {...trigger, scope: evidenceScope}; + const bookmark = sourceTrigger.current?.bookmark; const current = ++citeGeneration.current; setEvidenceRead({scope: evidenceScope, busy: true}); const query = new URLSearchParams({goal_id: goalId, pack_id: p.pack_id, analysis_key: p.analysis_key, ...(p.config.output_language ? {output_language: p.config.output_language} : {}), ...selection}); try { const value = await requestJson(sourceReadApiUrl(readSourceUrl, `${endpoint}/${route}?${query}`)); + if (bookmark && (value.source_id !== selection.source_id || String(value.first_line) !== selection.first_line + || String(value.last_line) !== selection.last_line)) { + throw new Error(zh ? "原文读回与引用范围不一致。" : "The original returned does not match the cited range."); + } if (current === citeGeneration.current) setEvidenceRead({scope: evidenceScope, value}); } catch (e) { if (current === citeGeneration.current) setEvidenceRead({ @@ -282,16 +292,28 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean }); } }; - const readCitation = (c: AssessmentCitation, origin?: HTMLButtonElement) => readEvidence({source_id: c.source_id, first_line: String(c.first_line), last_line: String(c.last_line)}, "citation", origin); - const readUsageTurn = (turnId: string, origin: HTMLButtonElement) => readEvidence({turn_id: turnId}, "usage-turn", origin); + const readCitation = (c: AssessmentCitation, origin?: HTMLElement, bookmark?: CaseCitationBookmark) => { + const activeTrigger = sourceTrigger.current; + // Coalesce browser navigation events only while this source panel is open. + if (bookmark && activeTrigger && activeTrigger.scope === evidenceScope && activeTrigger.bookmark?.hash === bookmark.hash) return; + return readEvidence({source_id: c.source_id, first_line: String(c.first_line), last_line: String(c.last_line)}, + "citation", {origin, bookmark}); + }; + const readUsageTurn = (turnId: string, origin: HTMLButtonElement) => readEvidence({turn_id: turnId}, "usage-turn", {origin}); const retryCitation = failedCitation ? () => void readEvidence(failedCitation.selection, failedCitation.route) : undefined; const closeCitation = () => { - const origin = sourceTrigger.current; + const trigger = sourceTrigger.current; + const {origin, bookmark} = trigger && trigger.scope === evidenceScope ? trigger : {}; + if (bookmark && window.location.hash === bookmark.hash) { + const url = new URL(window.location.href); + url.hash = bookmark.chapterHash; + window.history.replaceState(window.history.state, "", url); + } invalidateCitation(); const closedAt = citeGeneration.current; requestAnimationFrame(() => {if (closedAt === citeGeneration.current) origin?.focus();}); }; - return {readOnly: currentSnapshot?.execution_enabled === false || (Boolean(readSourceUrl) && currentSnapshot?.execution_enabled !== true), sourceScope, readUsageComparison, citation, citationBusy, citationError, sourcePanel, readCitation, readUsageTurn, retryCitation, closeCitation, goalId, zh, location, snapshot: currentSnapshot, pack: readingPack, requestPack: pack, savedOriginal, readError, error, loading, pending, cancelPending, recordedRunning, panel, trigger, read, show, execute, cancel, + return {readOnly: currentSnapshot?.execution_enabled === false || (Boolean(readSourceUrl) && currentSnapshot?.execution_enabled !== true), sourceScope, readUsageComparison, citation, citationBusy, citationError, sourcePanel, readCitation, readUsageTurn, retryCitation, closeCitation, onCitationLocationChange, goalId, zh, location, snapshot: currentSnapshot, pack: readingPack, requestPack: pack, savedOriginal, readError, error, loading, pending, cancelPending, recordedRunning, panel, trigger, read, show, execute, cancel, studyNavigation, close: () => {invalidateCitation(); navigate(false); requestAnimationFrame(() => trigger.current?.focus());}, select: (id: string) => navigate(location.open, id, true)}; } type Controller = ReturnType; @@ -401,17 +423,20 @@ export function ResearchCaseReading({analysis: a, active, onHistory}: {analysis: const p = a.pack; const firstStudy = packs[0]?.pack_id; useEffect(() => { - if (!a.loading && !a.readError && p && !p.case_study && firstStudy) a.select(firstStudy); + const hash = window.location.hash; + if (!a.loading && !a.readError && p && !p.case_study && firstStudy + && !(hash.startsWith(caseChapterHashPrefix) && hash.includes("~"))) a.select(firstStudy); }, [a.loading, a.readError, p?.pack_id, Boolean(p?.case_study), firstStudy]); return
{(packs.length > 1 || (packs.length === 1 && !p?.case_study)) ? : null}
+ : null} - {p?.case_study ? : !a.loading && !a.readError ?

{a.zh ? "选择一份解读,阅读问题背景、策略差异、关键转折与证据。" : "Choose a study to read its background, approaches, turning points and evidence."}

: null} + {p?.case_study ? : !a.loading && !a.readError ?

{a.zh ? "选择一份解读,阅读问题背景、策略差异、关键转折与证据。" : "Choose a study to read its background, approaches, turning points and evidence."}

: null}
; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/research-case-study.css b/apps/presentation/dashboard/src/features/personal-workspace/research-case-study.css index bd216c436b..51759bee0e 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/research-case-study.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/research-case-study.css @@ -38,7 +38,7 @@ .case-section > .personal-md { max-width: 80ch; } .case-sources { margin-block-top: 8px; } .case-citation-links { list-style: none; margin: 0; padding: 0; } -.case-citation-links button { display: block; text-align: start; font: inherit; font-size: var(--text-body); color: var(--pw-text); background: none; border: 0; min-height: 44px; padding: 8px 0; cursor: pointer; overflow-wrap: anywhere; } +.case-citation-links a { display: block; font-size: var(--text-body); color: var(--pw-text); min-height: 44px; padding: 8px 0; text-decoration: underline; text-underline-offset: .2em; overflow-wrap: anywhere; } .case-table-scroll { overflow-x: auto; margin-block: 24px; }.case-table-scroll table { border-collapse: collapse; width: 100%; font-size: var(--text-body); line-height: 1.7; } .case-table-scroll :is(th,td) { border-bottom: 1px solid var(--pw-line); text-align: start; vertical-align: top; padding: 12px 16px 12px 0; min-width: 85px; } .case-table-scroll th { font-weight: 500; }.case-table-scroll thead th { color: var(--pw-muted); }.goal-research-workbench .research-case-reader .research-case-study .case-table-scroll .personal-md { font-size: inherit; }.case-table-scroll .personal-md p { margin: 0; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/research-case-study.tsx b/apps/presentation/dashboard/src/features/personal-workspace/research-case-study.tsx index 047f2e7cea..91e9ec088c 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/research-case-study.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/research-case-study.tsx @@ -1,4 +1,4 @@ -import {useEffect, useRef} from 'react'; +import {useEffect, useRef, useState} from 'react'; import type {TrajectoryCaseStudy} from '../../../../../../loopx/control_plane/capabilities/trajectory_case_study.js'; import type {AssessmentCitation} from '../../../../../../loopx/control_plane/capabilities/trajectory_assessment_types.js'; import {MarkdownText} from './markdown'; @@ -46,33 +46,60 @@ function CaseSeries({series, zh}: {series: Series; zh: boolean}) { export const caseChapterHashPrefix = '#case-section-'; +export type CaseCitationBookmark = {hash: string; chapterHash: string}; + function focusChapter(chapter: HTMLElement) { chapter.focus({preventScroll: true}); chapter.scrollIntoView({block: 'start'}); } /** A document's author controls the composition; the host owns navigation and evidence. */ -export function ResearchCaseStudy({study, zh, historical, active, scope, navigation, onCitation}: { +export function ResearchCaseStudy({study, zh, historical, active, scope, navigation, onCitation, onCitationLocationChange}: { study: TrajectoryCaseStudy; zh: boolean; historical: boolean; active: boolean; scope: {case_id: string; run_id: string; revision: string; window: string}; - navigation: {url: URL; goalId: string; packId: string; namespace: string | null; follow: (href: URL) => void}; - onCitation: (c: AssessmentCitation, origin?: HTMLButtonElement) => void; + navigation: {url: URL; goalId: string; packId: string; namespace: string | null; follow: (href: URL, replace?: boolean) => void}; + onCitation: (c: AssessmentCitation, origin: HTMLElement, bookmark: CaseCitationBookmark) => void; + onCitationLocationChange: (hash: string) => void; }) { const article = useRef(null); + const openCitation = useRef(onCitation); + openCitation.current = onCitation; + const [missingCitation, setMissingCitation] = useState(false); const {url, goalId, packId, namespace, follow} = navigation; const prefix = `${caseChapterHashPrefix.slice(1)}${namespace ? namespace + '-' : ''}${packId}-`; useEffect(() => { if (!active) return; - // The URL can arrive before the saved study is mounted, or while history - // switches to another Goal/version. Only restore a visible matching chapter. let frame = 0; const restore = () => { cancelAnimationFrame(frame); + onCitationLocationChange(window.location.hash); frame = requestAnimationFrame(() => { const {searchParams, hash} = new URL(window.location.href); if (searchParams.get('view') !== 'research' || searchParams.get('research_reading') !== 'case' || searchParams.get('research_goal') !== goalId || searchParams.get('research_analysis_goal') !== goalId - || searchParams.get('research_pack') !== packId || !hash.startsWith(`#${prefix}`)) return; + || searchParams.get('research_pack') !== packId) return; + const citationLink = hash.startsWith(caseChapterHashPrefix) && hash.includes('~'); + const scoped = hash.startsWith(`#${prefix}`); + if (citationLink) { + // Resolve an ordinal against this saved study. Never turn URL text + // into a source identifier or range for the evidence API. + const match = scoped ? /^([a-zA-Z0-9_-]*)~([1-9][0-9]?)$/.exec(hash.slice(prefix.length + 1)) : null; + const section = match?.[1]; + const citations = section ? study.sections.find(item => item.id === section)?.citations : study.citations; + const citation = match ? citations?.[Number(match[2]) - 1] : undefined; + const origin = document.getElementById(hash.slice(1)); + if (!citation || !origin || !article.current?.contains(origin)) { + setMissingCitation(true); + return; + } + setMissingCitation(false); + const disclosure = origin.closest('details'); + if (disclosure) disclosure.open = true; + openCitation.current(citation, origin, {hash, chapterHash: section ? `#${prefix}${section}` : ""}); + return; + } + setMissingCitation(false); + if (!scoped) return; const chapter = document.getElementById(hash.slice(1)); if (chapter && article.current?.contains(chapter) && chapter.getClientRects().length) focusChapter(chapter); }); @@ -85,11 +112,22 @@ export function ResearchCaseStudy({study, zh, historical, active, scope, navigat window.removeEventListener('popstate', restore); window.removeEventListener('hashchange', restore); }; - }, [active, goalId, packId, prefix]); - const citationLinks = (citations: AssessmentCitation[]) =>