From 35131420bf6c254ed97d10d3d2aafbfde95a4f83 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:11:42 +0800 Subject: [PATCH 1/2] feat(site): read native Explore logs in Time Machine Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../site/scripts/time-machine.test.mjs | 81 ++++++++++ .../site/src/time-machine/LocalLogReader.tsx | 148 ++++++++++++++++++ .../site/src/time-machine/TimeMachine.tsx | 31 +++- .../site/src/time-machine/local-log.ts | 73 +++++++++ .../site/src/time-machine/time-machine.css | 54 +++++++ apps/presentation/site/tsconfig.json | 1 + .../dashboard-frontstage-browser-smoke.mjs | 88 +++++++++++ 7 files changed, 472 insertions(+), 4 deletions(-) create mode 100644 apps/presentation/site/src/time-machine/LocalLogReader.tsx create mode 100644 apps/presentation/site/src/time-machine/local-log.ts diff --git a/apps/presentation/site/scripts/time-machine.test.mjs b/apps/presentation/site/scripts/time-machine.test.mjs index f3fabe662c..2a67c13366 100644 --- a/apps/presentation/site/scripts/time-machine.test.mjs +++ b/apps/presentation/site/scripts/time-machine.test.mjs @@ -1,8 +1,10 @@ import assert from "node:assert/strict"; import {createHash} from "node:crypto"; +import {spawnSync} from "node:child_process"; import {readFile} from "node:fs/promises"; import test from "node:test"; import {readPinnedCapture} from "../src/time-machine/records.ts"; +import {LOCAL_LOG_MAX_BYTES, LOCAL_LOG_MAX_RECORDS, LocalLogError, readLocalExploreLog, selectLocalLogEntries} from "../src/time-machine/local-log.ts"; const scenes = JSON.parse(await readFile(new URL("../src/time-machine/scenes.json", import.meta.url))); const buffer = bytes => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); @@ -62,3 +64,82 @@ test("a record reader preserves append order rather than inventing historical st assert.deepEqual(rows, data.events); assert.equal(rows[0].recorded_at, "2030-01-01T00:00:00Z"); }); + +// Public synthetic v0 records: the oracle is the original event/append contract, +// independent of the file adapter. Real native-builder bytes also enter the +// packaged browser qualification; this fixture is not a research result. +const localEvent = (extra = {}) => ({schema_version: "loopx_explore_result_event_v0", goal_id: "local-reader-example", + event_id: "question-open", result_id: "question", event_kind: "node", node_kind: "question", status: "open", + title: "合成问题 / Synthetic question", summary: "A question, not an experiment count.", recorded_at: "2026-01-01T00:00:00Z", ...extra}); +const localRows = () => [localEvent(), + localEvent({event_id: "early-negative", result_id: "finding", event_kind: "finding", node_id: "question", status: "refuted", title: "Counterevidence", + summary: "原始反例 literal source text", evidence_refs: ["artifact:input-v1"], recorded_at: "2026-01-01T04:00:00Z"}), + localEvent({event_id: "late-revision", result_id: "finding", event_kind: "finding", node_id: "question", status: "tentative", title: "Later revision", + summary: "The later input is still uncertain.", evidence_refs: ["artifact:input-v2"], recorded_at: "2026-01-01T18:00:00Z"}), + localEvent({event_id: "backfill", status: "exploring", recorded_at: "2026-01-01T03:00:00Z"}), + localEvent({event_id: "unattached", event_kind: "finding", result_id: "unattached", node_id: "missing", title: "Unattached finding", status: "refuted", recorded_at: "unknown"}), +]; +const encoded = text => new TextEncoder().encode(text).buffer; +const jsonl = rows => encoded(rows.map(row => JSON.stringify(row)).join("\n")); +const issue = (code, line = null) => error => error instanceof LocalLogError && error.issue === code && error.line === line; + +test("local native-shaped JSONL retains the entire span, append corrections, negatives and unattached records", () => { + const rows = localRows(); + rows[1].source_extra = {note: "Uninterpreted metadata remains inspectable"}; + const log = readLocalExploreLog(encoded(`\n${rows.map(row => JSON.stringify(row)).join("\r\n\n")}\n`)); + assert.deepEqual(log.entries.map(entry => entry.source), rows); + assert.deepEqual(log.entries.map(entry => entry.line), [2, 4, 6, 8, 10]); + assert.deepEqual(log.entries.map(entry => entry.event.summary), rows.map(row => row.summary)); + assert.equal(log.timeline.end - log.timeline.start, 18 * 3_600_000); + assert.equal(log.history.nodes[0].latest.status, "exploring", "append order owns the latest node label"); + assert.equal(log.history.nodes[0].revisions.some(row => row.status === "refuted"), true); + assert.equal(log.history.findings.some(row => row.result_id === "unattached"), true); + assert.equal(log.history.unknownTimeCount, 1); + assert.equal(log.history.edges.length, 0, "record-time adjacency is not a relationship"); +}); + +test("local search and time/label filters preserve source order, exact labels and original text", () => { + const log = readLocalExploreLog(jsonl(localRows())); + assert.deepEqual(selectLocalLogEntries(log, " ARTIFACT:INPUT ", null, null).map(row => row.line), [2, 3]); + assert.equal(selectLocalLogEntries(log, "原始反例", "refuted", null)[0].event.summary, "原始反例 literal source text"); + assert.deepEqual(selectLocalLogEntries(log, "", "refuted", null).map(row => row.line), [2, 5]); + assert.deepEqual(selectLocalLogEntries(log, "", null, 0).map(row => row.line), [1, 2, 4]); + assert.deepEqual(selectLocalLogEntries(log, "", null, 2).map(row => row.line), [3]); + assert.deepEqual(selectLocalLogEntries(log, "", null, "unknown").map(row => row.line), [5]); + assert.equal(selectLocalLogEntries(log, "absent text", null, null).length, 0); + const futureLabel = readLocalExploreLog(jsonl([localEvent({status: "all"})])); + assert.equal(selectLocalLogEntries(futureLabel, "", "all", null).length, 1, "a source label is data, not a reserved UI option"); +}); + +test("bad local files are rejected as a whole, with a line when the fault belongs to one record", () => { + assert.throws(() => readLocalExploreLog(encoded("\n \r\n")), issue("empty")); + assert.throws(() => readLocalExploreLog(new Uint8Array([0xff]).buffer), issue("encoding")); + assert.throws(() => readLocalExploreLog(encoded(`${JSON.stringify(localEvent())}\n{broken`)), issue("json", 2)); + for (const value of [null, [], 1, {}, localEvent({goal_id: ""}), localEvent({schema_version: "not-v0"}), localEvent({confidence: 2}), localEvent({evidence_refs: [123]})]) { + assert.throws(() => readLocalExploreLog(jsonl([value])), issue("record", 1)); + } + assert.throws(() => readLocalExploreLog(jsonl([localEvent(), localEvent({goal_id: "another-goal"})])), issue("record", 2)); + assert.throws(() => readLocalExploreLog(jsonl([localEvent(), localEvent({summary: "Conflicting event identity"})])), issue("identity")); + const repeated = readLocalExploreLog(jsonl([localEvent(), localEvent()])); + assert.equal(repeated.entries.length, 2, "identical source rows remain readable"); + assert.equal(repeated.history.events.length, 1, "the existing common owner deduplicates only its display projection"); +}); + +test("local browser bounds admit the full supported log and reject overflow rather than returning a prefix", () => { + assert.throws(() => readLocalExploreLog(new ArrayBuffer(LOCAL_LOG_MAX_BYTES + 1)), issue("size")); + const rows = Array.from({length: LOCAL_LOG_MAX_RECORDS}, (_, index) => localEvent({event_id: `event-${index}`, result_id: `node-${index}`})); + assert.equal(readLocalExploreLog(jsonl(rows)).entries.length, LOCAL_LOG_MAX_RECORDS); + assert.throws(() => readLocalExploreLog(jsonl([...rows, localEvent({event_id: "overflow"})])), issue("size")); +}); + +test("a byte-limit file of blank lines reports empty without a line-array memory explosion", () => { + const source = JSON.stringify(new URL("../src/time-machine/local-log.ts", import.meta.url).href); + const code = `import {readLocalExploreLog, LOCAL_LOG_MAX_BYTES, LocalLogError} from ${source}; + const bytes = new Uint8Array(LOCAL_LOG_MAX_BYTES).fill(10); + try { readLocalExploreLog(bytes.buffer); process.exit(2); } + catch(error) { if (!(error instanceof LocalLogError) || error.issue !== "empty") throw error; }`; + // The child protects the test host; this is an intake regression, not a + // general browser-memory or latency promise for all supported records. + const result = spawnSync(process.execPath, ["--max-old-space-size=96", "--input-type=module", "-e", code], {encoding: "utf8", timeout: 5000}); + assert.equal(result.status, 0, result.stderr || String(result.error ?? result.signal)); +}); diff --git a/apps/presentation/site/src/time-machine/LocalLogReader.tsx b/apps/presentation/site/src/time-machine/LocalLogReader.tsx new file mode 100644 index 0000000000..0ada2916f6 --- /dev/null +++ b/apps/presentation/site/src/time-machine/LocalLogReader.tsx @@ -0,0 +1,148 @@ +import {useEffect, useMemo, useRef, useState} from "react"; +import {ArrowLeft, ArrowRight, FileText, FolderOpen, X} from "lucide-react"; +import {LocalLogError, LOCAL_LOG_MAX_BYTES, readLocalExploreLog, selectLocalLogEntries, type LocalExploreLog, type LocalLogIssue} from "./local-log"; + +const copy = { + en: { + title: "Your research record", + privacy: "Local reading · No file upload or storage.", + choose: "Choose another file", close: "Back to the three episodes", reading: "Reading your file…", + span: "Recorded between", unknown: "Unknown record time", unknownEvents: "distinct events have unknown record times.", + search: "Find in the original record", placeholder: "A question, a phrase, an evidence reference…", + status: "Source label", allLabels: "All labels", emptyLabel: "Unrecorded label", time: "Record time", allTime: "Whole recording", + phases: ["First third", "Middle third", "Last third"], match: "Matching records", of: "of", line: "Source line", + previous: "Previous record", next: "Next record", previousPage: "Previous 50", nextPage: "Next 50", page: "Page", + noMatch: "No matching record.", reset: "Clear filters", original: "Original text · untranslated", structured: "Source sections · untranslated", + observation: "Source observation", applicability: "Tested scope · source", interpretation: "Source interpretation", fullOriginal: "Read the complete original text", + details: "Source details and reading limits", references: "Evidence references · text only", raw: "Original JSON record", + limits: "Explore JSONL, UTF-8, one Goal per file. Up to 20 MiB and 20,000 records; larger files are rejected in full.", + boundary: "Source labels are claims in your file, not independently verified findings. The time strip uses recorded_at, not execution time. Reading does not restore a past state or start an Agent.", + coverage: "Every source line remains readable, including identical repeated events. The time strip reuses Explore’s unique-event projection; unknown times and missing links remain unknown.", + failed: "No records have been loaded from this file.", retry: "Choose a file to try again.", + issues: {size: "This file exceeds the reading limit.", empty: "This file has no records.", encoding: "This file is not valid UTF-8.", json: "This line is not valid JSON.", record: "This is not a supported Explore record, or it belongs to another Goal.", identity: "One event ID has conflicting records.", read: "The browser could not read this file."}, + }, + zh: { + title: "你的研究记录", + privacy: "本地阅读 · 文件内容不上传、不保存。", + choose: "换一份文件", close: "回到三个案例", reading: "正在读取你的文件…", + span: "记录跨度", unknown: "记录时间未知", unknownEvents: "个事件的记录时间未知(按事件 ID 去重)。", + search: "在原记录里找一找", placeholder: "一个问题、一段话、一条证据引用…", + status: "来源标记", allLabels: "全部标记", emptyLabel: "未记录标记", time: "记录时间", allTime: "完整记录跨度", + phases: ["前段", "中段", "后段"], match: "匹配记录", of: "/", line: "来源行", + previous: "上一条记录", next: "下一条记录", previousPage: "前 50 条", nextPage: "后 50 条", page: "页", + noMatch: "没有找到匹配的记录。", reset: "清除筛选", original: "原文 · 未翻译", structured: "来源分段 · 未翻译", + observation: "来源中的观察", applicability: "测试范围 · 来源原文", interpretation: "来源中的解释", fullOriginal: "展开完整原文", + details: "来源详情与阅读范围", references: "证据引用 · 仅文本", raw: "原始 JSON 记录", + limits: "支持 UTF-8 Explore JSONL,每份文件一个 Goal。最多 20 MiB、20,000 条记录;超出时整份拒绝,不截取部分。", + boundary: "来源标记是文件中的声明,未在这里独立核验。时间条使用记录时间 recorded_at,不是执行耗时。阅读不会恢复历史状态或启动 Agent。", + coverage: "每条来源记录均可阅读,包括相同事件的重复行。时间条复用 Explore 的去重事件投影;未知时间和缺失关联仍保持未知。", + failed: "当前未加载这份文件中的任何记录。", retry: "重新选择文件即可重试。", + issues: {size: "这份文件超过了阅读上限。", empty: "这份文件没有记录。", encoding: "这份文件不是有效的 UTF-8 文本。", json: "这一行不是有效的 JSON。", record: "这不是支持的 Explore 记录,或记录来自另一个 Goal。", identity: "同一个事件 ID 出现了冲突的记录。", read: "浏览器无法读取这份文件。"}, + }, +}; +type ReadState = {kind: "reading"} | {kind: "ready"; log: LocalExploreLog} | {kind: "error"; issue: LocalLogIssue; line: number | null}; +type Props = {file: File; language: "en" | "zh"; onChoose: () => void; onClose: () => void}; + +export default function LocalLogReader({file, language, onChoose, onClose}: Props) { + const c = copy[language]; + const [state, setState] = useState({kind: "reading"}); + const title = useRef(null); + useEffect(() => { + let active = true; + title.current?.focus({preventScroll: true}); + void (async () => { + if (file.size > LOCAL_LOG_MAX_BYTES) throw new LocalLogError("size"); + const bytes = await file.arrayBuffer(); + if (!active) return; + const log = readLocalExploreLog(bytes); + if (active) setState({kind: "ready", log}); + })().catch(error => { + if (active) setState({kind: "error", issue: error instanceof LocalLogError ? error.issue : "read", line: error instanceof LocalLogError ? error.line : null}); + }); + return () => {active = false;}; + }, [file]); + + return
+
+

+
+
+
+

{c.privacy}

+ {state.kind === "reading" ?

{c.reading}

: state.kind === "error" ? +

{c.issues[state.issue]} {state.line !== null && {c.line} {state.line}.}

+

{c.failed} {c.retry}

{c.limits}

: + } +
; +} + +function LoadedLog({log, language}: {log: LocalExploreLog; language: "en" | "zh"}) { + const c = copy[language]; + const [query, setQuery] = useState(""); + const [status, setStatus] = useState(null); + const [phase, setPhase] = useState(null); + const [selectedLine, setSelectedLine] = useState(null); + const original = useRef(null); + const search = useRef(null); + const entries = useMemo(() => selectLocalLogEntries(log, query, status, phase), [log, query, status, phase]); + const labels = useMemo(() => [...new Set(log.entries.map(entry => entry.event.status))], [log]); + const selected = entries.find(entry => entry.line === selectedLine) ?? entries[0]; + const position = selected ? entries.indexOf(selected) : -1; + const page = Math.max(0, Math.floor(position / 50)); + const pages = Math.ceil(entries.length / 50); + const {timeline, history} = log; + // Reuse the existing codec for Explore-authored section labels only. Other + // summaries stay raw; the complete source text is always available unchanged. + const sections = selected ? timeline.findings.find(item => item.event.event_id === selected.event.event_id)?.sections : null; + const maxBin = Math.max(1, ...timeline.bins.map(bin => bin.events)); + const date = (value: number | null) => value === null ? c.unknown : new Intl.DateTimeFormat(language === "zh" ? "zh-CN" : "en-GB", + {year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", timeZone: "UTC", timeZoneName: "short"}).format(value); + function move(index: number) { + setSelectedLine(entries[index]!.line); + window.requestAnimationFrame(() => original.current?.focus()); + } + function reset() { setQuery(""); setStatus(null); setPhase(null); search.current?.focus(); } + + return <> +
+

{c.span}: {timeline.start === null ? c.unknown : <> — }

+ {timeline.bins.length > 0 && + {timeline.bins.map((bin, index) => )}} +
+
+ + + +
+

{c.match}: {entries.length} {c.of} {log.entries.length} + {(query || status !== null || phase !== null) && }

+ {!selected ?

{c.noMatch}

: +
+
+ {sections ? c.structured : c.original}

· {selected.event.status || c.emptyLabel} · {selected.event.event_kind}

+

{selected.event.title}

+ {sections ?

{c.observation}

{sections.observation}

+

{c.applicability}

{sections.applicability}

+

{c.interpretation}

{sections.interpretation}

+
{c.fullOriginal}

{selected.event.summary}

:

{selected.event.summary}

} +
{c.details} +
{c.line}
{selected.line}
Goal
{log.goalId}
Result
{selected.event.result_id}
Event
{selected.event.event_id}
+ {selected.event.evidence_refs.length > 0 && <>

{c.references}

    {selected.event.evidence_refs.map((ref, index) =>
  • {ref}
  • )}
} +

{c.boundary}

{c.coverage}

{c.limits}

+
{c.raw}
{JSON.stringify(selected.source, null, 2)}
+
+
+ {position + 1}/{entries.length}
+
    + {entries.slice(page * 50, (page + 1) * 50).map(entry =>
  1. )} +
{pages > 1 &&
+ {c.page} {page + 1}/{pages}
}
+
} +

{c.boundary}{history.unknownTimeCount > 0 && <> {history.unknownTimeCount} {c.unknownEvents}}

+ ; +} diff --git a/apps/presentation/site/src/time-machine/TimeMachine.tsx b/apps/presentation/site/src/time-machine/TimeMachine.tsx index 69ec43e2d0..f9140c5721 100644 --- a/apps/presentation/site/src/time-machine/TimeMachine.tsx +++ b/apps/presentation/site/src/time-machine/TimeMachine.tsx @@ -1,8 +1,9 @@ import {useEffect, useRef, useState} from "react"; -import {ArrowLeft, ArrowRight, ArrowUpRight} from "lucide-react"; +import {ArrowLeft, ArrowRight, ArrowUpRight, FolderOpen} from "lucide-react"; import {usePublicPageNavigation} from "../usePublicPageNavigation"; import {captureRevision, datasetUrl, readPinnedCapture, type CapturedRecord} from "./records"; import scenes from "./scenes.json"; +import LocalLogReader from "./LocalLogReader"; const strings = { en: { @@ -20,6 +21,7 @@ const strings = { sources: "Fixed public source", fixture: "This exact capture", totals: "76 + 134 + 105 = 315 Explore records. Record counts are not experiment counts.", close: "Back to the question", noJs: "Enable JavaScript to make choices and browse records. The fixed public archive is also available directly.", lesson: "What stays with the next decision?", + openLog: "Open your research log", localStage: "Your research record", }, zh: { back: "LoopX 首页", name: "时光机", @@ -33,6 +35,7 @@ const strings = { cursor: "原始记录序号", record: "记录", previous: "上一条记录", following: "下一条记录", original: "原文 · 未翻译", identifiers: "来源标识", sources: "固定版本公开来源", fixture: "这份原始记录", totals: "76 + 134 + 105 = 315 条 Explore 记录,记录数量不是实验次数。", close: "回到问题", noJs: "启用 JavaScript 后可做选择、浏览原记录,也可以直接打开固定版本的公开来源。", lesson: "下一次判断,能留下什么?", + openLog: "打开你的研究日志", localStage: "你的研究记录", }, }; const readCase = () => { @@ -48,6 +51,11 @@ export default function TimeMachine() { const [failedFor, setFailed] = useState(null); const [attempt, setAttempt] = useState(0); const [position, setPosition] = useState(0); + const [upload, setUpload] = useState<{file: File; version: number} | null>(null); + const fileInput = useRef(null); + const openLog = useRef(null); + const fileVersion = useRef(0); + const localReading = upload !== null; const resultTitle = useRef(null); const questionTitle = useRef(null); const reader = useRef(null); @@ -75,6 +83,7 @@ export default function TimeMachine() { let active = true; setLoaded(null); setFailed(null); setPosition(0); if (reader.current) reader.current.open = false; + if (localReading) return () => {active = false; abort.abort();}; void (async () => { const response = await fetch(fileUrl, {signal: abort.signal}); if (!response.ok) throw new Error(`Capture HTTP ${response.status}`); @@ -82,7 +91,7 @@ export default function TimeMachine() { if (active) setLoaded({id: scene.id, rows}); })().catch(() => {if (active) setFailed(scene.id);}); return () => {active = false; abort.abort();}; - }, [fileUrl, scene, attempt]); + }, [fileUrl, scene, attempt, localReading]); useEffect(() => { if (chosen && records && pendingFocus.current) { @@ -116,7 +125,7 @@ export default function TimeMachine() { } return
-

IF YOU WERE THERE

{c.title}

{c.deck}

+ { + const file = event.target.files?.[0]; + event.target.value = ""; // Selecting the same file again is a real retry. + if (!file) return; // Cancelling the chooser leaves the current reading intact. + fileVersion.current += 1; + setUpload({file, version: fileVersion.current}); + window.scrollTo({top: 0, behavior: "instant"}); + }}/> + {upload ? fileInput.current?.click()} onClose={() => { + setUpload(null); + window.requestAnimationFrame(() => openLog.current?.focus()); + }}/> : <> +

IF YOU WERE THERE

{c.title}

{c.deck}

+

{c.count} {Object.keys(answers).length} / 3

; } diff --git a/apps/presentation/site/src/time-machine/local-log.ts b/apps/presentation/site/src/time-machine/local-log.ts new file mode 100644 index 0000000000..14284f90fb --- /dev/null +++ b/apps/presentation/site/src/time-machine/local-log.ts @@ -0,0 +1,73 @@ +/** Local-file adapter to Explore's existing display owners. This accepts records + * for reading; it does not verify their claims or grant curated scene authority. */ +import {parseExploreHistoryPage, projectExploreHistory, type ExploreHistoryEvent} from "../../../../../loopx/control_plane/presentation/explore_history.ts"; +import {buildExploreTimeline, exploreTimelinePhaseAt} from "../../../../../loopx/control_plane/presentation/explore_timeline.ts"; + +// Bound synchronous browser parsing and the shared timeline's in-memory arrays. +// Reject the entire file at the limit; never publish a silently shortened log. +export const LOCAL_LOG_MAX_BYTES = 20 * 1024 * 1024; +export const LOCAL_LOG_MAX_RECORDS = 20_000; +// File-intake UI errors only; these do not extend Explore's event/status vocabulary. +export type LocalLogIssue = "size" | "empty" | "encoding" | "json" | "record" | "identity" | "read"; +export class LocalLogError extends Error { + readonly issue: LocalLogIssue; + readonly line: number | null; + constructor(issue: LocalLogIssue, line: number | null = null) { + super(issue); this.issue = issue; this.line = line; + } +} +export type LocalLogEntry = {line: number; event: ExploreHistoryEvent; source: Record}; +export type LocalExploreLog = ReturnType; + +export function readLocalExploreLog(bytes: ArrayBuffer) { + if (bytes.byteLength > LOCAL_LOG_MAX_BYTES) throw new LocalLogError("size"); + let text: string; + try { text = new TextDecoder("utf-8", {fatal: true}).decode(bytes); } + catch { throw new LocalLogError("encoding"); } + const entries: LocalLogEntry[] = []; + let goalId = ""; + let offset = 0, lineNumber = 0; + // Blank lines are legal. Walk them without allocating an array containing + // millions of empty entries for a newline-only file within the byte limit. + while (offset <= text.length) { + const end = text.indexOf("\n", offset); + const line = text.slice(offset, end < 0 ? text.length : end); + offset = end < 0 ? text.length + 1 : end + 1; + lineNumber++; + if (!line.trim()) continue; + if (entries.length === LOCAL_LOG_MAX_RECORDS) throw new LocalLogError("size"); + let source: Record; + try { source = JSON.parse(line); } + catch { throw new LocalLogError("json", lineNumber); } + if (!source || Array.isArray(source) || typeof source !== "object" || + typeof source.goal_id !== "string" || !source.goal_id.trim()) throw new LocalLogError("record", lineNumber); + if (!goalId) goalId = source.goal_id; + let event: ExploreHistoryEvent; + try { + event = parseExploreHistoryPage({ok: true, goal_id: goalId, items: [source], total: 1, next_cursor: null}, goalId).items[0]!; + } catch { throw new LocalLogError("record", lineNumber); } + entries.push({line: lineNumber, event, source}); + } + if (!entries.length) throw new LocalLogError("empty"); + let history: ReturnType; + try { history = projectExploreHistory(entries.map(entry => entry.event), goalId); } + catch { throw new LocalLogError("identity"); } + const timeline = buildExploreTimeline(history.nodes, history.edges, history.events); + return {goalId, entries, history, timeline}; +} + +/** Filter source records without changing append order, labels or time semantics. */ +export function selectLocalLogEntries(log: LocalExploreLog, query: string, status: string | null, phase: number | "unknown" | null) { + const needle = query.trim().toLowerCase(); + return log.entries.filter(entry => { + const event = entry.event; + if (status !== null && event.status !== status) return false; + if (phase !== null) { + const parsed = Date.parse(event.recorded_at); + const at = Number.isFinite(parsed) ? parsed : null; + if (phase === "unknown" ? at !== null : exploreTimelinePhaseAt(log.timeline.phases, at) !== phase) return false; + } + return !needle || [event.title, event.summary, event.result_id, event.event_id, ...event.evidence_refs] + .some(value => value.toLowerCase().includes(needle)); + }); +} diff --git a/apps/presentation/site/src/time-machine/time-machine.css b/apps/presentation/site/src/time-machine/time-machine.css index d6bed02d9b..f743f3d116 100644 --- a/apps/presentation/site/src/time-machine/time-machine.css +++ b/apps/presentation/site/src/time-machine/time-machine.css @@ -65,6 +65,45 @@ .tm-takeaway a { color: var(--blue); display: inline-flex; gap: 8px; align-items: center; min-height: 44px; font-size: 14px; } .tm-footer { display: flex; gap: 20px; justify-content: space-between; align-items: center; padding: 20px 0 0; border-top: 1px solid var(--border); color: var(--body); font-size: 12px; } .tm-footer a { display: inline-flex; align-items: center; gap: 4px; min-height: 44px; text-decoration: underline; text-underline-offset: 3px; } +.tm-open-log, .tm-log button { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; border: 0; background: none; color: var(--blue); font: inherit; font-size: 13px; text-align: left; cursor: pointer; padding: 8px 0; } +.tm-open-log { margin-top: 12px; } +.tm-log-format { font: 12px/1.5 var(--font-mono); color: var(--body); } +.tm-log-heading { display: flex; justify-content: space-between; align-items: start; gap: 32px; } +.tm-log h1 { font-size: 24px; font-weight: 500; letter-spacing: -.025em; line-height: 1.4; margin: 0; min-width: 0; } +.tm-log h1:focus { outline: none; } +.tm-log-file { display: flex; align-items: baseline; gap: 8px; overflow-wrap: anywhere; color: var(--ink); } +.tm-log-file svg { flex-shrink: 0; } +.tm-log-actions { display: flex; align-items: center; gap: 24px; flex-shrink: 0; } +.tm-log-actions button:last-child { color: var(--body); } +.tm-log-privacy { font-size: 12px; color: var(--body); margin: 20px 0 0; } +.tm-log-overview { border-block: 1px solid var(--border); padding: 20px 0 0; margin-top: 20px; } +.tm-log-overview>p { font-size: 13px; color: var(--body); margin: 0 0 8px; } +.tm-log-strip { display: block; width: 100%; height: 40px; margin-top: 16px; fill: var(--body); opacity: .65; } +.tm-log-filters { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr); gap: 16px; padding-top: 24px; } +.tm-log-filters label { display: grid; gap: 8px; font-size: 12px; color: var(--body); min-width: 0; } +.tm-log-filters :is(input, select) { font: inherit; font-size: 14px; width: 100%; min-width: 0; min-height: 44px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--ink); padding: 10px 12px; } +.tm-log-match { display: flex; flex-wrap: wrap; align-items: center; gap: 0 6px; font-size: 12px; color: var(--body); margin: 12px 0; min-height: 44px; } +.tm-log-match button { margin-left: auto; } +.tm-log-reading { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 28px; align-items: start; } +.tm-log-index { min-width: 0; grid-column: 1; grid-row: 1; } +.tm-log-index ol { list-style: none; padding: 0; margin: 0; max-height: 520px; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; } +.tm-log-index li { border-bottom: 1px solid var(--border); } +.tm-log-index li button { display: grid; gap: 4px; width: 100%; color: var(--body); padding: 12px; border-left: 2px solid transparent; overflow-wrap: anywhere; } +.tm-log-index li button[aria-current=true] { border-left-color: var(--ink); background: var(--soft); color: var(--ink); } +.tm-log-row-meta { font: 12px/1.6 var(--font-mono); color: var(--body); } +.tm-log-detail { min-width: 0; grid-column: 2; grid-row: 1; } +.tm-log-detail h2 { font-size: 20px; line-height: 1.5; margin: 12px 0; scroll-margin-top: 24px; } +.tm-log-finding>p { font-size: 14px; line-height: 1.8; color: var(--body); margin: 8px 0 20px; white-space: pre-wrap; } +.tm-log-finding>p.tm-log-field-label { font-size: 12px; margin: 16px 0 4px; } +.tm-log-finding>p.tm-log-observation { color: var(--ink); } +.tm-log-source dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px 16px; font: 12px/1.7 var(--font-mono); } +.tm-log-source dd { margin: 0; } +.tm-log-source ul { padding-left: 20px; } +.tm-log-source pre { white-space: pre-wrap; overflow-wrap: anywhere; font: 12px/1.8 var(--font-mono); } +.tm-log-record-nav, .tm-log-pagination { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--body); font: 12px/1.5 var(--font-mono); } +.tm-log-message { padding: 24px 0; font-size: 14px; color: var(--body); } +.tm-log-error [role=alert] { color: var(--danger); } +.tm-log-note { font-size: 12px; color: var(--body); border-top: 1px solid var(--border); padding-top: 16px; margin-top: 24px; } @keyframes tm-appear { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } } @media (prefers-reduced-motion: reduce) { .tm-page *, html:has(.tm-page) { animation: none; scroll-behavior: auto; } } @media (max-width: 700px) { @@ -86,4 +125,19 @@ .tm-boundary { flex-direction: column; gap: 12px; } .tm-footer { flex-direction: column; align-items: start; gap: 4px; } .tm-original { padding: 20px; } + .tm-log-heading { display: flex; gap: 12px; } + .tm-log-actions { flex-direction: row; gap: 0; } + .tm-log-actions button { min-width: 44px; justify-content: center; } + .tm-log-actions button>span { display: none; } + .tm-log-privacy { margin-top: 12px; } + .tm-log-overview { padding-top: 12px; margin-top: 16px; } + .tm-log-strip { height: 24px; margin-top: 12px; } + .tm-log-filters { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; padding-top: 16px; } + .tm-log-match { margin-block: 8px; } + .tm-log-filters>label:first-child { grid-column: 1 / -1; } + .tm-log-reading { grid-template-columns: minmax(0, 1fr); gap: 20px; } + .tm-log-index, .tm-log-detail { grid-column: auto; grid-row: auto; } + .tm-log h1 { font-size: 20px; } + .tm-log-index ol { max-height: 200px; } + .tm-log-overview .tm-log-span time { display: inline-block; } } diff --git a/apps/presentation/site/tsconfig.json b/apps/presentation/site/tsconfig.json index 86df527c8a..cfe3b0de3d 100644 --- a/apps/presentation/site/tsconfig.json +++ b/apps/presentation/site/tsconfig.json @@ -12,6 +12,7 @@ "forceConsistentCasingInFileNames": true, "module": "ESNext", "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, diff --git a/examples/dashboard-frontstage-browser-smoke.mjs b/examples/dashboard-frontstage-browser-smoke.mjs index dbdbc9384a..d36d47abe3 100644 --- a/examples/dashboard-frontstage-browser-smoke.mjs +++ b/examples/dashboard-frontstage-browser-smoke.mjs @@ -118,6 +118,94 @@ async function assertTimeMachineJourney(page, lang) { await page.locator(".tm-choices button:not([disabled])").first().click(); await page.locator(".tm-revealed").waitFor(); assert.match(await page.locator(".tm-revealed").innerText(), /13\.84/); + await assertLocalTimeMachineLog(page, lang); +} + +async function assertLocalTimeMachineLog(page, lang) { + const row = (extra = {}) => ({schema_version: "loopx_explore_result_event_v0", goal_id: "public-local-example", + event_id: "question", result_id: "question", event_kind: "node", node_kind: "question", status: "open", + title: "Synthetic question", summary: "Source text, not a scored result.", recorded_at: "2026-01-01T00:00:00Z", ...extra}); + const negative = "Original counterevidence literal, not HTML"; + const records = [row(), row({event_id: "early-negative", result_id: "finding", event_kind: "finding", node_id: "question", status: "refuted", + title: "Earlier counterevidence", summary: negative, evidence_refs: ["https://example.invalid/do-not-fetch-local-evidence"], recorded_at: "2026-01-01T04:00:00Z"}), + row({event_id: "revision", result_id: "finding", event_kind: "finding", node_id: "question", title: "Later tentative revision", status: "tentative", recorded_at: "2026-01-01T18:00:00Z"}), + row({event_id: "backfill", status: "exploring", recorded_at: "2026-01-01T03:00:00Z"}), + row({event_id: "unattached", result_id: "unattached", event_kind: "finding", node_id: "missing", title: "Unattached finding", status: "refuted", recorded_at: "unknown"})]; + const file = (name, rows = records) => ({name, mimeType: "application/x-ndjson", buffer: Buffer.from(rows.map(row => JSON.stringify(row)).join("\n"))}); + const input = page.locator("#tm-log-file"); + await page.evaluate(() => { + window.__tmLocalAudit = {requests: [], writes: []}; + const audit = window.__tmLocalAudit; + const fetch = window.fetch; + window.fetch = function (...args) { audit.requests.push(String(args[0])); return fetch.apply(this, args); }; + const send = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.send = function (...args) { audit.requests.push("xhr"); return send.apply(this, args); }; + const beacon = navigator.sendBeacon.bind(navigator); + navigator.sendBeacon = (...args) => { audit.requests.push(String(args[0])); return beacon(...args); }; + const set = Storage.prototype.setItem; + Storage.prototype.setItem = function (...args) { audit.writes.push(String(args[0])); return set.apply(this, args); }; + const open = indexedDB.open.bind(indexedDB); + indexedDB.open = (...args) => { audit.writes.push("indexedDB"); return open(...args); }; + }); + // The ordinary entry opens the actual browser file chooser, not a command-copy path. + const chooser = page.waitForEvent("filechooser"); + await page.locator("#tm-open-log").focus(); + await page.locator("#tm-open-log").press("Enter"); + await (await chooser).setFiles(file("research.jsonl")); + await page.locator(".tm-log-reading").waitFor(); + assert.equal(await page.locator(".tm-log-match strong").textContent(), "5"); + assert.match(await page.locator(".tm-log-span").textContent(), /00:00.*18:00/); + assert.equal(await page.locator(".tm-revealed,.tm-metrics,.tm-source").count(), 0, "local bytes inherit no curated claim"); + await page.locator("#tm-log-label").selectOption(JSON.stringify("refuted")); + assert.equal(await page.locator(".tm-log-match strong").textContent(), "2"); + assert.equal(await page.locator(".tm-log-detail .tm-record-body").textContent(), negative); + assert.equal(await page.locator(".tm-record-body b").count(), 0); + await page.locator(".tm-log-source > summary").click(); + assert.equal(await page.locator(".tm-log-source a").count(), 0, "external evidence references remain text"); + await page.locator(".tm-log-source > summary").click(); + await page.locator("#tm-log-search").fill("absent phrase"); + assert.equal(await page.locator(".tm-log-reading").count(), 0, "no stale original when filters have no match"); + await page.locator(".tm-log-match button").click(); + assert.equal(await page.evaluate(() => document.activeElement.id), "tm-log-search"); + await page.locator("#tm-log-time").selectOption("unknown"); + assert.equal(await page.locator(".tm-log-match strong").textContent(), "1"); + assert.equal(await page.locator(".tm-log-detail h2").textContent(), "Unattached finding"); + await input.setInputFiles({name: "bad.jsonl", mimeType: "application/x-ndjson", buffer: Buffer.from(`${JSON.stringify(records[0])}\n{broken`)}); + await page.locator(".tm-log-error [role=alert]").waitFor(); + assert.match(await page.locator(".tm-log-error [role=alert]").textContent(), /2/); + assert.equal(await page.locator(".tm-log-reading").count(), 0); + await input.setInputFiles(file("research.jsonl")); + await page.locator(".tm-log-reading").waitFor(); + await input.setInputFiles(file("research.jsonl")); + await page.locator(".tm-log-reading").waitFor(); + await page.evaluate(() => { + const read = File.prototype.arrayBuffer; + File.prototype.arrayBuffer = function () { + if (this.name === "slow.jsonl") return new Promise((resolve, reject) => { window.__tmReleaseSlow = () => read.call(this).then(resolve, reject); }); + return read.call(this); + }; + }); + await input.setInputFiles(file("slow.jsonl")); + await page.waitForFunction(() => typeof window.__tmReleaseSlow === "function"); + await input.setInputFiles(file("newer.jsonl", [row({title: "New file only"})])); + await page.locator(".tm-log-reading").waitFor(); + await page.evaluate(() => window.__tmReleaseSlow()); + assert.equal(await page.locator(".tm-log-detail h2").textContent(), "New file only"); + const structured = "Input revision: synthetic-v2 Applicability: this fixture only Observation: The new input refutes the earlier route. Interpretation: Outcome: continue\nretained:\n- original scope\nstopped:\n- the failed route"; + await input.setInputFiles(file("structured.jsonl", [row({event_id: "structured", event_kind: "finding", status: "refuted", summary: structured})])); + await page.locator(".tm-log-observation").waitFor(); + assert.equal(await page.locator(".tm-log-observation").textContent(), "The new input refutes the earlier route."); + assert.match(await page.locator(".tm-log-finding").textContent(), /this fixture only/); + await page.locator(".tm-log-finding > details > summary").click(); + assert.equal(await page.locator(".tm-record-body").textContent(), structured, "structured reading does not replace the complete original text"); + assert.deepEqual(await page.evaluate(() => window.__tmLocalAudit), {requests: [], writes: []}); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), "local reader fits the viewport"); + await page.locator(".tm-log-actions button").last().click(); + await page.waitForFunction(() => document.activeElement.id === "tm-open-log"); + await page.locator(".tm-revealed").waitFor(); + assert.equal(await page.locator(".tm-log").count(), 0); + assert.match(await page.locator(".tm-revealed").innerText(), /13\.84/); + assert.equal(await page.locator("html").getAttribute("lang"), lang === "zh" ? "zh-CN" : "en"); } let browser; From f08dfcccfff1b4c7460e45359d2a85d6b6b8bda1 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:21:37 +0800 Subject: [PATCH 2/2] feat(site): surface source-recorded research decisions Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../site/scripts/time-machine.test.mjs | 42 ++++++++++++ .../site/src/time-machine/LocalLogReader.tsx | 65 +++++++++++++++---- .../site/src/time-machine/local-log.ts | 14 +++- .../site/src/time-machine/time-machine.css | 26 ++++++++ .../dashboard-frontstage-browser-smoke.mjs | 52 +++++++++++++-- 5 files changed, 178 insertions(+), 21 deletions(-) diff --git a/apps/presentation/site/scripts/time-machine.test.mjs b/apps/presentation/site/scripts/time-machine.test.mjs index 2a67c13366..0b82496d02 100644 --- a/apps/presentation/site/scripts/time-machine.test.mjs +++ b/apps/presentation/site/scripts/time-machine.test.mjs @@ -111,6 +111,48 @@ test("local search and time/label filters preserve source order, exact labels an assert.equal(selectLocalLogEntries(futureLabel, "", "all", null).length, 1, "a source label is data, not a reserved UI option"); }); +// Independently declared Explore writeback semantics: only a finding with the +// owner's complete section envelope and an explicit leading Outcome is a +// decision. Status labels and incidental words do not confer that meaning. +const decisionSummary = interpretation => `Input revision: input-v2\nApplicability: Synthetic source only.\nObservation: The earlier route missed counterevidence.\nInterpretation: ${interpretation}`; +const decisionRows = () => { + const replan = localEvent({event_id: "decision-replan", event_kind: "finding", node_id: "question", result_id: "decision-1", status: "tentative", + summary: decisionSummary("Outcome: replan changed: - Read the early counterexample. stopped: - Retry the same input. retained: - Keep source references."), recorded_at: "2026-01-01T18:00:00Z"}); + return [localEvent({summary: decisionSummary("Outcome: stop"), status: "replan"}), replan, + localEvent({event_id: "plain-mention", event_kind: "finding", result_id: "plain", summary: "Discuss Outcome: replan, changed: and retained: without a structured decision."}), + localEvent({event_id: "decision-continue", event_kind: "finding", result_id: "decision-2", summary: decisionSummary("Outcome: continue retained: - Keep the negative result."), recorded_at: "2026-01-01T03:00:00Z"}), + localEvent({event_id: "unknown-outcome", event_kind: "finding", result_id: "decision-3", summary: decisionSummary("Outcome: __proto__")}), replan, + localEvent({event_id: "unknown-time-decision", event_kind: "finding", result_id: "decision-4", recorded_at: "unknown", summary: decisionSummary("Outcome: all")})]; +}; + +test("decision reading uses explicit source outcomes, keeps repeated rows and never invents chronology or links", () => { + const rows = decisionRows(); + const log = readLocalExploreLog(jsonl(rows)); + assert.deepEqual(selectLocalLogEntries(log, "", null, null).map(row => row.line), [1, 2, 3, 4, 5, 6, 7]); + assert.deepEqual(selectLocalLogEntries(log, "", null, null, null).map(row => row.line), [2, 4, 5, 6, 7]); + assert.deepEqual(selectLocalLogEntries(log, "", null, null, "replan").map(row => row.line), [2, 6]); + assert.deepEqual(selectLocalLogEntries(log, "", null, null, "continue").map(row => row.line), [4]); + assert.deepEqual(selectLocalLogEntries(log, "", null, null, "__proto__").map(row => row.line), [5]); + assert.deepEqual(selectLocalLogEntries(log, "", null, null, "all").map(row => row.line), [7]); + assert.equal(selectLocalLogEntries(log, "", null, null, "stop").length, 0, "a node is not a finding decision"); + assert.equal(log.timeline.decisions.length, 4, "the shared owner deduplicates its projection, not source rows"); + assert.equal(log.timeline.routes.length, 0); + assert.equal(log.history.edges.length, 0); + const sections = log.findingsByEventId.get("decision-replan").sections; + assert.deepEqual(sections.decision, {outcome: "replan", changed: ["Read the early counterexample."], stopped: ["Retry the same input."], retained: ["Keep source references."]}); + assert.equal(sections.inputRevision, "input-v2"); + assert.equal(selectLocalLogEntries(log, "", null, null, "replan")[0].source.summary, rows[1].summary, "the complete original is never rewritten"); +}); + +test("decision filters compose with original search, source labels and unknown record times", () => { + const log = readLocalExploreLog(jsonl(decisionRows())); + assert.deepEqual(selectLocalLogEntries(log, "early counterexample", "tentative", 2, "replan").map(row => row.line), [2, 6]); + assert.equal(selectLocalLogEntries(log, "", "replan", null, null).length, 0, "a source status is separate from an outcome"); + assert.deepEqual(selectLocalLogEntries(log, "", null, "unknown", null).map(row => row.line), [7]); + assert.equal(selectLocalLogEntries(log, "absent", null, null, "replan").length, 0); + assert.equal(selectLocalLogEntries(readLocalExploreLog(jsonl(localRows())), "", null, null, null).length, 0, "a file without decisions stays empty in this view"); +}); + test("bad local files are rejected as a whole, with a line when the fault belongs to one record", () => { assert.throws(() => readLocalExploreLog(encoded("\n \r\n")), issue("empty")); assert.throws(() => readLocalExploreLog(new Uint8Array([0xff]).buffer), issue("encoding")); diff --git a/apps/presentation/site/src/time-machine/LocalLogReader.tsx b/apps/presentation/site/src/time-machine/LocalLogReader.tsx index 0ada2916f6..0c36a4d4e6 100644 --- a/apps/presentation/site/src/time-machine/LocalLogReader.tsx +++ b/apps/presentation/site/src/time-machine/LocalLogReader.tsx @@ -8,9 +8,14 @@ const copy = { privacy: "Local reading · No file upload or storage.", choose: "Choose another file", close: "Back to the three episodes", reading: "Reading your file…", span: "Recorded between", unknown: "Unknown record time", unknownEvents: "distinct events have unknown record times.", - search: "Find in the original record", placeholder: "A question, a phrase, an evidence reference…", + search: "Find in the original record", filters: "Search and source filters", placeholder: "A question, a phrase, an evidence reference…", status: "Source label", allLabels: "All labels", emptyLabel: "Unrecorded label", time: "Record time", allTime: "Whole recording", phases: ["First third", "Middle third", "Last third"], match: "Matching records", of: "of", line: "Source line", + readingView: "Read your record", allRecords: "All records", decisions: "Decision records", outcome: "Recorded decision", allOutcomes: "All decisions", + outcomes: {continue: "Continue", no_change: "No change", replan: "Replan", stop: "Stop", wait: "Wait", ask_human: "Ask the owner"}, + changed: "Changed", stopped: "Stopped", retained: "Retained", noChanges: "No path changes were listed.", + decisionBoundary: "Decisions recorded by the source, not a new assessment.", noDecisions: "This file contains no explicitly recorded decisions.", + context: "Source context", inputRevision: "Input revision", allRecordsAction: "Read all records", previous: "Previous record", next: "Next record", previousPage: "Previous 50", nextPage: "Next 50", page: "Page", noMatch: "No matching record.", reset: "Clear filters", original: "Original text · untranslated", structured: "Source sections · untranslated", observation: "Source observation", applicability: "Tested scope · source", interpretation: "Source interpretation", fullOriginal: "Read the complete original text", @@ -26,9 +31,14 @@ const copy = { privacy: "本地阅读 · 文件内容不上传、不保存。", choose: "换一份文件", close: "回到三个案例", reading: "正在读取你的文件…", span: "记录跨度", unknown: "记录时间未知", unknownEvents: "个事件的记录时间未知(按事件 ID 去重)。", - search: "在原记录里找一找", placeholder: "一个问题、一段话、一条证据引用…", + search: "在原记录里找一找", filters: "搜索与来源筛选", placeholder: "一个问题、一段话、一条证据引用…", status: "来源标记", allLabels: "全部标记", emptyLabel: "未记录标记", time: "记录时间", allTime: "完整记录跨度", phases: ["前段", "中段", "后段"], match: "匹配记录", of: "/", line: "来源行", + readingView: "阅读记录", allRecords: "全部记录", decisions: "决策记录", outcome: "已记录决策", allOutcomes: "全部决策", + outcomes: {continue: "继续", no_change: "不变", replan: "调整方向", stop: "停止", wait: "等待", ask_human: "请本人决定"}, + changed: "改变了什么", stopped: "停止了什么", retained: "保留了什么", noChanges: "来源未列出路径变更。", + decisionBoundary: "这里展示来源已记录的决策,不作新的判断。", noDecisions: "这份文件没有明确记录的决策。", + context: "来源上下文", inputRevision: "输入版本", allRecordsAction: "阅读全部记录", previous: "上一条记录", next: "下一条记录", previousPage: "前 50 条", nextPage: "后 50 条", page: "页", noMatch: "没有找到匹配的记录。", reset: "清除筛选", original: "原文 · 未翻译", structured: "来源分段 · 未翻译", observation: "来源中的观察", applicability: "测试范围 · 来源原文", interpretation: "来源中的解释", fullOriginal: "展开完整原文", @@ -81,10 +91,15 @@ function LoadedLog({log, language}: {log: LocalExploreLog; language: "en" | "zh" const [query, setQuery] = useState(""); const [status, setStatus] = useState(null); const [phase, setPhase] = useState(null); + // UI-only filter over the shared owner's decoded outcomes; unknown source + // values remain selectable. undefined reads all rows, null all decisions. + const [outcome, setOutcome] = useState(() => log.timeline.decisions.length ? null : undefined); const [selectedLine, setSelectedLine] = useState(null); const original = useRef(null); const search = useRef(null); - const entries = useMemo(() => selectLocalLogEntries(log, query, status, phase), [log, query, status, phase]); + const entries = useMemo(() => selectLocalLogEntries(log, query, status, phase, outcome), [log, query, status, phase, outcome]); + const decisionEntries = useMemo(() => selectLocalLogEntries(log, "", null, null, null), [log]); + const outcomes = useMemo(() => [...new Set(log.timeline.decisions.map(item => item.sections!.decision!.outcome))], [log]); const labels = useMemo(() => [...new Set(log.entries.map(entry => entry.event.status))], [log]); const selected = entries.find(entry => entry.line === selectedLine) ?? entries[0]; const position = selected ? entries.indexOf(selected) : -1; @@ -93,7 +108,9 @@ function LoadedLog({log, language}: {log: LocalExploreLog; language: "en" | "zh" const {timeline, history} = log; // Reuse the existing codec for Explore-authored section labels only. Other // summaries stay raw; the complete source text is always available unchanged. - const sections = selected ? timeline.findings.find(item => item.event.event_id === selected.event.event_id)?.sections : null; + const sections = selected ? log.findingsByEventId.get(selected.event.event_id)?.sections : null; + const decision = sections?.decision; + const outcomeLabel = (value: string) => Object.hasOwn(c.outcomes, value) ? c.outcomes[value as keyof typeof c.outcomes] : value; const maxBin = Math.max(1, ...timeline.bins.map(bin => bin.events)); const date = (value: number | null) => value === null ? c.unknown : new Intl.DateTimeFormat(language === "zh" ? "zh-CN" : "en-GB", {year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", timeZone: "UTC", timeZoneName: "short"}).format(value); @@ -101,7 +118,12 @@ function LoadedLog({log, language}: {log: LocalExploreLog; language: "en" | "zh" setSelectedLine(entries[index]!.line); window.requestAnimationFrame(() => original.current?.focus()); } - function reset() { setQuery(""); setStatus(null); setPhase(null); search.current?.focus(); } + function changeView(value: undefined | null) { + const visible = selectLocalLogEntries(log, query, status, phase, value); + setSelectedLine(visible.some(entry => entry.line === selected?.line) ? selected!.line : visible[0]?.line ?? null); + setOutcome(value); + } + function reset() { setQuery(""); setStatus(null); setPhase(null); setOutcome(outcome === undefined ? undefined : null); setSelectedLine(null); search.current?.focus(); } return <>
@@ -110,24 +132,39 @@ function LoadedLog({log, language}: {log: LocalExploreLog; language: "en" | "zh" {timeline.bins.map((bin, index) => )}}
-
- - } +
+
{c.filters}
+ + - -
+

{c.match}: {entries.length} {c.of} {log.entries.length} - {(query || status !== null || phase !== null) && }

- {!selected ?

{c.noMatch}

: + {(query || status !== null || phase !== null || typeof outcome === "string") && }

+ {!selected ?

{outcome !== undefined && !decisionEntries.length ? c.noDecisions : c.noMatch}

+ {outcome !== undefined && !decisionEntries.length ? : }
:
{sections ? c.structured : c.original}

· {selected.event.status || c.emptyLabel} · {selected.event.event_kind}

{selected.event.title}

- {sections ?

{c.observation}

{sections.observation}

+ {decision &&
+

{c.outcome} · {outcomeLabel(decision.outcome)}

+
{(["changed", "stopped", "retained"] as const).map(key => decision[key].length > 0 &&
{c[key]}
    {decision[key].map((item, index) =>
  • {item}
  • )}
)}
+ {![...decision.changed, ...decision.stopped, ...decision.retained].length &&

{c.noChanges}

} +

{c.decisionBoundary}

+
} + {sections ?
+ {decision ?
{c.context}
{c.inputRevision}
{sections.inputRevision}
{c.applicability}
{sections.applicability}
{c.observation}
{sections.observation}
: <>

{c.observation}

{sections.observation}

{c.applicability}

{sections.applicability}

-

{c.interpretation}

{sections.interpretation}

+

{c.interpretation}

{sections.interpretation}

}
{c.fullOriginal}

{selected.event.summary}

:

{selected.event.summary}

}
{c.details}
{c.line}
{selected.line}
Goal
{log.goalId}
Result
{selected.event.result_id}
Event
{selected.event.event_id}
diff --git a/apps/presentation/site/src/time-machine/local-log.ts b/apps/presentation/site/src/time-machine/local-log.ts index 14284f90fb..4d92b322e5 100644 --- a/apps/presentation/site/src/time-machine/local-log.ts +++ b/apps/presentation/site/src/time-machine/local-log.ts @@ -53,14 +53,22 @@ export function readLocalExploreLog(bytes: ArrayBuffer) { try { history = projectExploreHistory(entries.map(entry => entry.event), goalId); } catch { throw new LocalLogError("identity"); } const timeline = buildExploreTimeline(history.nodes, history.edges, history.events); - return {goalId, entries, history, timeline}; + const findingsByEventId = new Map(timeline.findings.map(item => [item.event.event_id, item])); + return {goalId, entries, history, timeline, findingsByEventId}; } -/** Filter source records without changing append order, labels or time semantics. */ -export function selectLocalLogEntries(log: LocalExploreLog, query: string, status: string | null, phase: number | "unknown" | null) { +/** Filter source records without changing append order, labels or time semantics. + * decisionOutcome is a local reading filter: undefined reads every record, + * null reads every explicitly decoded decision, and a string matches its + * source outcome verbatim. This does not classify prose or node updates. */ +export function selectLocalLogEntries(log: LocalExploreLog, query: string, status: string | null, phase: number | "unknown" | null, decisionOutcome?: string | null) { const needle = query.trim().toLowerCase(); return log.entries.filter(entry => { const event = entry.event; + if (decisionOutcome !== undefined) { + const decision = log.findingsByEventId.get(event.event_id)?.sections?.decision; + if (!decision || (decisionOutcome !== null && decision.outcome !== decisionOutcome)) return false; + } if (status !== null && event.status !== status) return false; if (phase !== null) { const parsed = Date.parse(event.recorded_at); diff --git a/apps/presentation/site/src/time-machine/time-machine.css b/apps/presentation/site/src/time-machine/time-machine.css index f743f3d116..f9b29e6abf 100644 --- a/apps/presentation/site/src/time-machine/time-machine.css +++ b/apps/presentation/site/src/time-machine/time-machine.css @@ -79,6 +79,15 @@ .tm-log-overview { border-block: 1px solid var(--border); padding: 20px 0 0; margin-top: 20px; } .tm-log-overview>p { font-size: 13px; color: var(--body); margin: 0 0 8px; } .tm-log-strip { display: block; width: 100%; height: 40px; margin-top: 16px; fill: var(--body); opacity: .65; } +.tm-log-views { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 16px; } +.tm-log-views>button { padding-inline: 12px; color: var(--body); border-radius: 6px; } +.tm-log-views>button[aria-pressed=true] { color: var(--ink); background: var(--soft); } +.tm-log-views>button span { font: 12px/1.5 var(--font-mono); } +.tm-log-views>label { display: flex; align-items: center; gap: 8px; margin-left: auto; font-size: 12px; color: var(--body); min-width: 0; } +.tm-log-views select { font: inherit; font-size: 14px; min-height: 44px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--ink); padding: 8px; max-width: 100%; } +.tm-log-filter-disclosure { margin-top: 12px; font-size: 13px; color: var(--body); } +.tm-log-filter-disclosure>summary { min-height: 44px; align-content: center; cursor: pointer; width: fit-content; } +.tm-log-filter-disclosure .tm-log-filters { padding-top: 8px; } .tm-log-filters { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr); gap: 16px; padding-top: 24px; } .tm-log-filters label { display: grid; gap: 8px; font-size: 12px; color: var(--body); min-width: 0; } .tm-log-filters :is(input, select) { font: inherit; font-size: 14px; width: 100%; min-width: 0; min-height: 44px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--ink); padding: 10px 12px; } @@ -96,6 +105,19 @@ .tm-log-finding>p { font-size: 14px; line-height: 1.8; color: var(--body); margin: 8px 0 20px; white-space: pre-wrap; } .tm-log-finding>p.tm-log-field-label { font-size: 12px; margin: 16px 0 4px; } .tm-log-finding>p.tm-log-observation { color: var(--ink); } +.tm-log-decision { border-top: 1px solid var(--border); padding-top: 12px; } +.tm-log-decision-outcome { font-size: 14px; color: var(--body); margin: 0 0 16px; } +.tm-log-decision-outcome strong { color: var(--ink); font-weight: 500; } +.tm-log-decision dl { margin: 0; } +.tm-log-decision dl>div { display: grid; grid-template-columns: 104px minmax(0, 1fr); gap: 16px; padding-block: 12px; } +.tm-log-decision dt { font-size: 12px; color: var(--body); padding-top: 2px; } +.tm-log-decision dd { margin: 0; font-size: 14px; line-height: 1.8; white-space: pre-wrap; } +.tm-log-decision ul { padding: 0; margin: 0; list-style: none; } +.tm-log-decision li+li { margin-top: 8px; } +.tm-log-decision-note { font-size: 12px; color: var(--body); margin: 16px 0; } +.tm-log-context dl { display: grid; gap: 8px; font-size: 14px; line-height: 1.8; } +.tm-log-context dt { font-size: 12px; color: var(--body); } +.tm-log-context dd { margin: 0 0 12px; white-space: pre-wrap; } .tm-log-source dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px 16px; font: 12px/1.7 var(--font-mono); } .tm-log-source dd { margin: 0; } .tm-log-source ul { padding-left: 20px; } @@ -135,6 +157,10 @@ .tm-log-filters { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; padding-top: 16px; } .tm-log-match { margin-block: 8px; } .tm-log-filters>label:first-child { grid-column: 1 / -1; } + .tm-log-views { gap: 4px; margin-top: 12px; } + .tm-log-views>label { width: 100%; margin-top: 4px; } + .tm-log-views select { flex: 1; min-width: 0; } + .tm-log-decision dl>div { grid-template-columns: minmax(0, 1fr); gap: 4px; } .tm-log-reading { grid-template-columns: minmax(0, 1fr); gap: 20px; } .tm-log-index, .tm-log-detail { grid-column: auto; grid-row: auto; } .tm-log h1 { font-size: 20px; } diff --git a/examples/dashboard-frontstage-browser-smoke.mjs b/examples/dashboard-frontstage-browser-smoke.mjs index d36d47abe3..b699dd8089 100644 --- a/examples/dashboard-frontstage-browser-smoke.mjs +++ b/examples/dashboard-frontstage-browser-smoke.mjs @@ -156,6 +156,7 @@ async function assertLocalTimeMachineLog(page, lang) { assert.equal(await page.locator(".tm-log-match strong").textContent(), "5"); assert.match(await page.locator(".tm-log-span").textContent(), /00:00.*18:00/); assert.equal(await page.locator(".tm-revealed,.tm-metrics,.tm-source").count(), 0, "local bytes inherit no curated claim"); + await page.locator(".tm-log-filter-disclosure > summary").click(); await page.locator("#tm-log-label").selectOption(JSON.stringify("refuted")); assert.equal(await page.locator(".tm-log-match strong").textContent(), "2"); assert.equal(await page.locator(".tm-log-detail .tm-record-body").textContent(), negative); @@ -193,11 +194,54 @@ async function assertLocalTimeMachineLog(page, lang) { assert.equal(await page.locator(".tm-log-detail h2").textContent(), "New file only"); const structured = "Input revision: synthetic-v2 Applicability: this fixture only Observation: The new input refutes the earlier route. Interpretation: Outcome: continue\nretained:\n- original scope\nstopped:\n- the failed route"; await input.setInputFiles(file("structured.jsonl", [row({event_id: "structured", event_kind: "finding", status: "refuted", summary: structured})])); - await page.locator(".tm-log-observation").waitFor(); - assert.equal(await page.locator(".tm-log-observation").textContent(), "The new input refutes the earlier route."); - assert.match(await page.locator(".tm-log-finding").textContent(), /this fixture only/); - await page.locator(".tm-log-finding > details > summary").click(); + await page.locator(".tm-log-decision").waitFor(); + assert.match(await page.locator(".tm-log-decision").textContent(), /original scope/); + assert.match(await page.locator(".tm-log-decision").textContent(), /the failed route/); + await page.locator(".tm-log-context > summary").click(); + assert.match(await page.locator(".tm-log-context").textContent(), /this fixture only.*The new input refutes the earlier route\./s); + await page.locator(".tm-log-finding > details:not(.tm-log-context) > summary").click(); assert.equal(await page.locator(".tm-record-body").textContent(), structured, "structured reading does not replace the complete original text"); + + const portfolio = JSON.parse(await readFile(resolve(exportSite, "lab/time-machine/data/portfolio.json"), "utf8")); + await input.setInputFiles(file("public-research.jsonl", portfolio.events)); + await page.locator(".tm-log-decision").waitFor(); + assert.equal(await page.locator(".tm-log-match strong").textContent(), "37"); + await page.locator("#tm-log-outcome").selectOption(JSON.stringify("replan")); + assert.equal(await page.locator(".tm-log-match strong").textContent(), "29"); + await page.locator("#tm-log-outcome").selectOption(JSON.stringify("continue")); + assert.equal(await page.locator(".tm-log-match strong").textContent(), "8"); + await page.locator(".tm-log-filter-disclosure > summary").click(); + await page.locator("#tm-log-search").fill("absent-decision-text"); + assert.equal(await page.locator(".tm-log-reading").count(), 0, "filtered decision cannot leave a stale readout"); + await page.locator(".tm-log-match button").click(); + assert.equal(await page.locator(".tm-log-match strong").textContent(), "37"); + for (let index = 0; index < 37; index++) { + await page.locator(".tm-log-index li button").nth(index).click(); + await page.waitForFunction(() => document.querySelector(".tm-log-detail h2") === document.activeElement); + const source = page.locator(".tm-log-source"); + if (!await source.getAttribute("open").then(value => value !== null)) await source.locator(":scope > summary").click(); + const line = Number(await source.locator("dd").first().textContent()); + const original = page.locator(".tm-log-finding > details:not(.tm-log-context)"); + if (!await original.getAttribute("open").then(value => value !== null)) await original.locator(":scope > summary").click(); + assert.equal(await page.locator(".tm-record-body").textContent(), portfolio.events[line - 1].summary, "each decision returns to its exact source row"); + } + const allRecords = page.locator(".tm-log-views > button").first(); + await allRecords.focus(); await allRecords.press("Enter"); + assert.equal(await page.locator(".tm-log-match strong").textContent(), "76"); + assert.equal(await page.locator("#tm-log-outcome").count(), 0); + const unknown = structured.replace("Outcome: continue", "Outcome: __proto__"); + await input.setInputFiles(file("unknown.jsonl", [row({event_id: "unknown-decision", event_kind: "finding", summary: unknown})])); + await page.locator(".tm-log-decision").waitFor(); + assert.match(await page.locator(".tm-log-decision-outcome").textContent(), /__proto__/); + await page.locator("#tm-log-outcome").selectOption(JSON.stringify("__proto__")); + assert.equal(await page.locator(".tm-log-match strong").textContent(), "1"); + await input.setInputFiles(file("no-decisions.jsonl")); + await page.locator(".tm-log-reading").waitFor(); + await page.locator(".tm-log-views > button").nth(1).click(); + assert.equal(await page.locator(".tm-log-reading").count(), 0); + assert.equal(await page.locator("#tm-log-outcome").count(), 0); + await page.locator(".tm-log-message button").click(); + assert.equal(await page.locator(".tm-log-match strong").textContent(), "5"); assert.deepEqual(await page.evaluate(() => window.__tmLocalAudit), {requests: [], writes: []}); assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), "local reader fits the viewport"); await page.locator(".tm-log-actions button").last().click();