实现本地 Markdown 清洗评审器

This commit is contained in:
2026-08-24 01:02:16 +08:00
parent 80001a8ab9
commit ac798a2610
39 changed files with 9357 additions and 119 deletions
+378
View File
@@ -0,0 +1,378 @@
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import type {
ChangeDetail,
ComponentStageResponse,
DocumentComparisonResponse,
RunStatus,
RunSummaryResponse,
} from "../shared/api.js";
import { fetchComponentStage, fetchDocument, fetchRun } from "./api-client.js";
const DiffView = lazy(async () => {
const module = await import("./DiffView.js");
return { default: module.DiffView };
});
interface AsyncState<T> {
loading: boolean;
value: T | null;
error: string | null;
}
const emptyState = <T,>(): AsyncState<T> => ({ loading: true, value: null, error: null });
function statusLabel(status: RunStatus): string {
switch (status) {
case "success":
return "成功";
case "failed":
return "失败";
case "unstable":
return "不稳定";
}
}
function shortHash(hash: string): string {
return `${hash.slice(0, 8)}${hash.slice(-6)}`;
}
function ErrorPanel({ message }: { message: string }) {
return (
<div className="state-panel state-panel--error" role="alert">
<span className="eyebrow"></span>
<p>{message}</p>
</div>
);
}
function LoadingPanel() {
return (
<div className="state-panel" role="status">
<span className="loading-dot" />
<p></p>
</div>
);
}
function ChangeList({
changes,
onSelect,
canJump,
}: {
changes: ChangeDetail[];
onSelect: (change: ChangeDetail) => void;
canJump: boolean;
}) {
if (changes.length === 0) {
return <p className="quiet-message"></p>;
}
return (
<ol className="change-list">
{changes.map((change) => (
<li key={`${change.proposal_ref.snapshot_sha256}-${change.proposal_ref.proposal_index}-${change.edit_index}`}>
<button type="button" onClick={() => onSelect(change)} disabled={!canJump}>
<span className="change-location">
{change.location.line} {change.location.column} ·
{change.proposal_ref.proposal_index + 1} / {change.edit_index + 1}
</span>
<strong>{change.reason}</strong>
<span className="change-sample">
<del>{change.before || "∅"}</del>
<span aria-hidden="true"></span>
<ins>{change.after || "∅"}</ins>
</span>
</button>
</li>
))}
</ol>
);
}
export function App() {
const [runState, setRunState] = useState<AsyncState<RunSummaryResponse>>(emptyState);
const [selectedDocument, setSelectedDocument] = useState<string | null>(null);
const [documentState, setDocumentState] = useState<AsyncState<DocumentComparisonResponse>>({
loading: false,
value: null,
error: null,
});
const [selectedComponent, setSelectedComponent] = useState<number | null>(null);
const [stageState, setStageState] = useState<AsyncState<ComponentStageResponse>>({
loading: false,
value: null,
error: null,
});
const [focusRange, setFocusRange] = useState<{ start: number; end: number } | null>(null);
useEffect(() => {
const controller = new AbortController();
fetchRun(controller.signal)
.then((run) => {
setRunState({ loading: false, value: run, error: null });
setSelectedDocument(run.documents[0]?.document_id ?? null);
})
.catch((error: unknown) => {
if (!controller.signal.aborted) {
setRunState({
loading: false,
value: null,
error: error instanceof Error ? error.message : "无法读取运行摘要。",
});
}
});
return () => controller.abort();
}, []);
useEffect(() => {
setSelectedComponent(null);
setFocusRange(null);
if (selectedDocument === null) {
setDocumentState({ loading: false, value: null, error: null });
return undefined;
}
const controller = new AbortController();
setDocumentState(emptyState());
fetchDocument(selectedDocument, controller.signal)
.then((document) => setDocumentState({ loading: false, value: document, error: null }))
.catch((error: unknown) => {
if (!controller.signal.aborted) {
setDocumentState({
loading: false,
value: null,
error: error instanceof Error ? error.message : "无法读取文档。",
});
}
});
return () => controller.abort();
}, [selectedDocument]);
useEffect(() => {
if (selectedDocument === null || selectedComponent === null) {
setStageState({ loading: false, value: null, error: null });
return undefined;
}
const controller = new AbortController();
setStageState(emptyState());
fetchComponentStage(selectedDocument, selectedComponent, controller.signal)
.then((stage) => setStageState({ loading: false, value: stage, error: null }))
.catch((error: unknown) => {
if (!controller.signal.aborted) {
setStageState({
loading: false,
value: null,
error: error instanceof Error ? error.message : "无法读取组件阶段。",
});
}
});
return () => controller.abort();
}, [selectedDocument, selectedComponent]);
const visibleChanges = useMemo(() => {
const document = documentState.value;
if (document === null) {
return [];
}
if (selectedComponent === null) {
return document.changes;
}
return document.changes.filter((change) => change.component_position === selectedComponent);
}, [documentState.value, selectedComponent]);
const selectChange = (change: ChangeDetail): void => {
setSelectedComponent(change.component_position);
setFocusRange(change.editor_range);
};
if (runState.loading) {
return <LoadingPanel />;
}
if (runState.error !== null || runState.value === null) {
return <ErrorPanel message={runState.error ?? "运行摘要为空。"} />;
}
const run = runState.value;
const document = documentState.value;
const selectedSummary = run.documents.find((item) => item.document_id === selectedDocument);
const selectedStage = stageState.value;
const canCompare =
document?.document.status === "success" &&
document.document.source_available &&
document.document.output_available &&
document.original_markdown !== null &&
document.cleaned_markdown !== null;
const beforeText = selectedStage?.before_markdown ?? document?.original_markdown ?? "";
const afterText = selectedStage?.after_markdown ?? document?.cleaned_markdown ?? "";
const beforeLabel = selectedStage === null ? "清洗前" : `组件 ${selectedStage.component.component_position + 1} 执行前`;
const afterLabel = selectedStage === null ? "清洗后" : `组件 ${selectedStage.component.component_position + 1} 执行后`;
return (
<div className="app-shell">
<header className="topbar">
<div>
<span className="brand-mark">md</span>
<div>
<p className="eyebrow"></p>
<h1>{run.run.run_id}</h1>
</div>
</div>
<div className="run-facts">
<span className={`status status--${run.run.status}`}>{statusLabel(run.run.status)}</span>
<span>{run.summary.document_count} </span>
<span>{run.summary.change_count} </span>
</div>
</header>
{run.original_run_location_changed ? (
<div className="notice" role="status">
使
</div>
) : null}
<div className="layout">
<aside className="sidebar" aria-label="运行导航">
<section>
<div className="section-heading">
<h2></h2>
<span>{run.documents.length}</span>
</div>
<nav className="document-list" aria-label="文档列表">
{run.documents.map((item) => (
<button
type="button"
key={item.document_id}
className={item.document_id === selectedDocument ? "is-active" : ""}
onClick={() => setSelectedDocument(item.document_id)}
aria-current={item.document_id === selectedDocument ? "page" : undefined}
>
<span className={`status-dot status-dot--${item.status}`} />
<span>
<strong>{item.source_label}</strong>
<small>{item.change_count} </small>
</span>
</button>
))}
</nav>
</section>
<section className="component-section">
<div className="section-heading">
<h2>线</h2>
<button
type="button"
className="text-button"
onClick={() => {
setSelectedComponent(null);
setFocusRange(null);
}}
disabled={selectedComponent === null}
>
</button>
</div>
<ol className="component-list">
{(document?.components ?? run.components).map((component) => (
<li key={component.component_id}>
<button
type="button"
className={component.component_position === selectedComponent ? "is-active" : ""}
onClick={() => {
setSelectedComponent(component.component_position);
setFocusRange(null);
}}
disabled={!canCompare}
>
<span className="component-index">{component.component_position + 1}</span>
<span>
<strong>{component.component_id}</strong>
<small>
v{component.version} · {component.change_count}
</small>
</span>
</button>
</li>
))}
</ol>
</section>
</aside>
<main className="workspace">
<section className="document-header">
<div>
<p className="eyebrow"></p>
<h2>{selectedSummary?.source_label ?? "未选择"}</h2>
</div>
{selectedSummary === undefined ? null : (
<div className="document-meta">
<span className={`status status--${selectedSummary.status}`}>
{statusLabel(selectedSummary.status)}
</span>
<span title={selectedSummary.input_sha256}> {shortHash(selectedSummary.input_sha256)}</span>
<span title={selectedSummary.current_sha256}> {shortHash(selectedSummary.current_sha256)}</span>
</div>
)}
</section>
{documentState.loading || stageState.loading ? <LoadingPanel /> : null}
{documentState.error !== null ? <ErrorPanel message={documentState.error} /> : null}
{stageState.error !== null ? <ErrorPanel message={stageState.error} /> : null}
{!documentState.loading && document !== null && document.document.status === "success" && !canCompare ? (
<ErrorPanel message={document.document.availability_error ?? "完整原文或清洗结果不可用。"} />
) : null}
{!documentState.loading && document !== null && document.document.status !== "success" ? (
<div className="diagnostic-panel">
<p className="eyebrow"></p>
<h3>{statusLabel(document.document.status)}</h3>
<p>
<code>cleaned.md</code>
</p>
{document.errors.map((error) => (
<article key={`${error.component_position}-${error.stage}-${error.error_type}`}>
<strong>{error.error_type}</strong>
<span>{error.message}</span>
</article>
))}
{document.residual_proposals.map((proposal) => (
<article key={`${proposal.component_position}-${proposal.reason}`}>
<strong> {proposal.edit_count} </strong>
<span>{proposal.reason}</span>
</article>
))}
</div>
) : null}
{!documentState.loading && !stageState.loading && canCompare && stageState.error === null ? (
<Suspense fallback={<LoadingPanel />}>
<DiffView
before={beforeText}
after={afterText}
beforeLabel={beforeLabel}
afterLabel={afterLabel}
focusRange={focusRange}
/>
</Suspense>
) : null}
{document !== null ? (
<section className="changes-panel" aria-label="修改详情">
<div className="changes-heading">
<div>
<p className="eyebrow"></p>
<h3>
{selectedComponent === null
? `全部组件 · ${visibleChanges.length}`
: `${document.components[selectedComponent]?.component_id ?? "组件"} · ${visibleChanges.length}`}
</h3>
</div>
{selectedStage === null ? null : <p>{selectedStage.component.applicability}</p>}
</div>
<ChangeList changes={visibleChanges} onSelect={selectChange} canJump={canCompare} />
</section>
) : null}
</main>
</div>
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
import { markdown } from "@codemirror/lang-markdown";
import { MergeView } from "@codemirror/merge";
import { EditorState } from "@codemirror/state";
import { EditorView, lineNumbers } from "@codemirror/view";
import { useEffect, useRef } from "react";
interface DiffViewProps {
before: string;
after: string;
beforeLabel: string;
afterLabel: string;
focusRange?: { start: number; end: number } | null;
}
const editorTheme = EditorView.theme({
"&": {
height: "100%",
backgroundColor: "#fbfaf7",
color: "#262822",
fontSize: "13px",
},
".cm-scroller": {
fontFamily: '"SFMono-Regular", Consolas, "Liberation Mono", monospace',
lineHeight: "1.68",
},
".cm-gutters": {
backgroundColor: "#f2f0ea",
color: "#8a877e",
border: "none",
},
".cm-content": {
padding: "18px 0 36px",
},
".cm-line": {
padding: "0 14px",
},
"&.cm-focused": {
outline: "2px solid #a7b9ac",
outlineOffset: "-2px",
},
});
const readOnlyExtensions = [
lineNumbers(),
markdown(),
EditorState.readOnly.of(true),
EditorView.editable.of(false),
EditorView.lineWrapping,
editorTheme,
];
export function DiffView({ before, after, beforeLabel, afterLabel, focusRange }: DiffViewProps) {
const host = useRef<HTMLDivElement>(null);
const merge = useRef<MergeView | null>(null);
useEffect(() => {
if (host.current === null) {
return undefined;
}
const view = new MergeView({
parent: host.current,
a: { doc: before, extensions: readOnlyExtensions },
b: { doc: after, extensions: readOnlyExtensions },
orientation: "a-b",
gutter: true,
highlightChanges: true,
collapseUnchanged: { margin: 4, minSize: 8 },
});
merge.current = view;
return () => {
view.destroy();
merge.current = null;
};
}, [before, after]);
useEffect(() => {
const view = merge.current;
if (view === null || focusRange === null || focusRange === undefined) {
return;
}
const anchor = Math.min(Math.max(focusRange.start, 0), view.a.state.doc.length);
const head = Math.min(Math.max(focusRange.end, anchor), view.a.state.doc.length);
view.a.dispatch({
selection: { anchor, head },
effects: EditorView.scrollIntoView(anchor, { y: "center" }),
});
view.a.focus();
}, [focusRange]);
return (
<section className="diff-shell" aria-label={`${beforeLabel}${afterLabel}对比`}>
<div className="diff-labels" aria-hidden="true">
<span>{beforeLabel}</span>
<span>{afterLabel}</span>
</div>
<div className="diff-host" ref={host} />
</section>
);
}
+288
View File
@@ -0,0 +1,288 @@
import type {
ApiErrorResponse,
ComponentStageResponse,
DocumentComparisonResponse,
RunSummaryResponse,
} from "../shared/api.js";
export class ReviewerApiError extends Error {
readonly code: string;
constructor(code: string, message: string) {
super(message);
this.name = "ReviewerApiError";
this.code = code;
}
}
type JsonRecord = Record<string, unknown>;
function invalid(label: string): never {
throw new ReviewerApiError("invalid_response", `本地服务返回的 ${label} 格式不正确。`);
}
function record(value: unknown, label: string): JsonRecord {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return invalid(label);
}
return value as JsonRecord;
}
function array(value: unknown, label: string): unknown[] {
if (!Array.isArray(value)) {
return invalid(label);
}
return value;
}
function string(value: unknown, label: string): string {
if (typeof value !== "string") {
return invalid(label);
}
return value;
}
function integer(value: unknown, label: string, minimum = 0): number {
if (!Number.isSafeInteger(value) || (value as number) < minimum) {
return invalid(label);
}
return value as number;
}
function boolean(value: unknown, label: string): boolean {
if (typeof value !== "boolean") {
return invalid(label);
}
return value;
}
function nullableString(value: unknown, label: string): string | null {
return value === null ? null : string(value, label);
}
function hash(value: unknown, label: string): string {
const digest = string(value, label);
if (!/^[0-9a-f]{64}$/.test(digest)) {
return invalid(label);
}
return digest;
}
function status(value: unknown): "success" | "failed" | "unstable" {
if (value !== "success" && value !== "failed" && value !== "unstable") {
return invalid("status");
}
return value;
}
function component(value: unknown): RunSummaryResponse["components"][number] {
const item = record(value, "component");
return {
component_position: integer(item.component_position, "component_position"),
component_id: string(item.component_id, "component_id"),
version: string(item.version, "component version"),
parameters: item.parameters,
applicability: string(item.applicability, "component applicability"),
change_count: integer(item.change_count, "component change_count"),
};
}
function documentSummary(value: unknown): RunSummaryResponse["documents"][number] {
const item = record(value, "document summary");
return {
document_id: string(item.document_id, "document_id"),
source_label: string(item.source_label, "source_label"),
status: status(item.status),
input_sha256: hash(item.input_sha256, "input_sha256"),
current_sha256: hash(item.current_sha256, "current_sha256"),
change_count: integer(item.change_count, "document change_count"),
source_available: boolean(item.source_available, "source_available"),
output_available: boolean(item.output_available, "output_available"),
availability_error: nullableString(item.availability_error, "availability_error"),
};
}
function summary(value: unknown): RunSummaryResponse["summary"] {
const item = record(value, "summary");
return {
document_count: integer(item.document_count, "document_count"),
success_count: integer(item.success_count, "success_count"),
failed_count: integer(item.failed_count, "failed_count"),
unstable_count: integer(item.unstable_count, "unstable_count"),
change_count: integer(item.change_count, "change_count"),
};
}
function change(value: unknown): DocumentComparisonResponse["changes"][number] {
const item = record(value, "change");
const proposal = record(item.proposal_ref, "proposal_ref");
const location = record(item.location, "location");
const editorValue = item.editor_range;
const editorRange =
editorValue === null
? null
: (() => {
const editor = record(editorValue, "editor_range");
const start = integer(editor.start, "editor_range.start");
const end = integer(editor.end, "editor_range.end");
if (end < start) {
return invalid("editor_range");
}
return { start, end };
})();
return {
component_id: string(item.component_id, "change component_id"),
component_version: string(item.component_version, "change component_version"),
component_position: integer(item.component_position, "change component_position"),
proposal_ref: {
component_position: integer(proposal.component_position, "proposal component_position"),
snapshot_sha256: hash(proposal.snapshot_sha256, "proposal snapshot_sha256"),
proposal_index: integer(proposal.proposal_index, "proposal_index"),
},
edit_index: integer(item.edit_index, "edit_index"),
reason: string(item.reason, "reason"),
location: {
line: integer(location.line, "location.line", 1),
column: integer(location.column, "location.column", 1),
},
editor_range: editorRange,
before: string(item.before, "before"),
after: string(item.after, "after"),
};
}
function runError(value: unknown): DocumentComparisonResponse["errors"][number] {
const item = record(value, "run error");
const stage = item.stage;
if (stage !== "transform" && stage !== "final_review") {
return invalid("error stage");
}
return {
component_id: string(item.component_id, "error component_id"),
component_version: string(item.component_version, "error component_version"),
component_position: integer(item.component_position, "error component_position"),
stage,
error_type: string(item.error_type, "error_type"),
message: string(item.message, "error message"),
};
}
function residual(value: unknown): DocumentComparisonResponse["residual_proposals"][number] {
const item = record(value, "residual proposal");
return {
component_id: string(item.component_id, "residual component_id"),
component_version: string(item.component_version, "residual component_version"),
component_position: integer(item.component_position, "residual component_position"),
reason: string(item.reason, "residual reason"),
edit_count: integer(item.edit_count, "residual edit_count", 1),
};
}
function parseRun(value: unknown): RunSummaryResponse {
const payload = record(value, "run response");
if (payload.schema_version !== 1) {
return invalid("run schema_version");
}
const run = record(payload.run, "run");
return {
schema_version: 1,
run: {
run_id: string(run.run_id, "run_id"),
run_date: string(run.run_date, "run_date"),
status: status(run.status),
started_at_utc: string(run.started_at_utc, "started_at_utc"),
completed_at_utc: string(run.completed_at_utc, "completed_at_utc"),
retention_until: string(run.retention_until, "retention_until"),
},
components: array(payload.components, "components").map(component),
documents: array(payload.documents, "documents").map(documentSummary),
summary: summary(payload.summary),
original_run_location_changed: boolean(
payload.original_run_location_changed,
"original_run_location_changed",
),
};
}
function parseDocument(value: unknown): DocumentComparisonResponse {
const payload = record(value, "document response");
if (payload.schema_version !== 1) {
return invalid("document schema_version");
}
return {
schema_version: 1,
document: documentSummary(payload.document),
components: array(payload.components, "components").map(component),
original_markdown: nullableString(payload.original_markdown, "original_markdown"),
cleaned_markdown: nullableString(payload.cleaned_markdown, "cleaned_markdown"),
changes: array(payload.changes, "changes").map(change),
errors: array(payload.errors, "errors").map(runError),
residual_proposals: array(payload.residual_proposals, "residual_proposals").map(residual),
};
}
function parseStage(value: unknown): ComponentStageResponse {
const payload = record(value, "component stage response");
if (payload.schema_version !== 1) {
return invalid("component stage schema_version");
}
return {
schema_version: 1,
document_id: string(payload.document_id, "document_id"),
component: component(payload.component),
before_sha256: hash(payload.before_sha256, "before_sha256"),
after_sha256: hash(payload.after_sha256, "after_sha256"),
before_markdown: string(payload.before_markdown, "before_markdown"),
after_markdown: string(payload.after_markdown, "after_markdown"),
changes: array(payload.changes, "changes").map(change),
};
}
async function getJson<T>(
pathname: string,
parse: (payload: unknown) => T,
signal?: AbortSignal,
): Promise<T> {
const response = await fetch(pathname, {
method: "GET",
cache: "no-store",
credentials: "same-origin",
signal,
});
const payload: unknown = await response.json();
if (!response.ok) {
const errorPayload = payload as Partial<ApiErrorResponse>;
throw new ReviewerApiError(
errorPayload.error?.code ?? "request_failed",
errorPayload.error?.message ?? `请求失败(HTTP ${response.status})。`,
);
}
return parse(payload);
}
export function fetchRun(signal?: AbortSignal): Promise<RunSummaryResponse> {
return getJson("/api/v1/run", parseRun, signal);
}
export function fetchDocument(
documentId: string,
signal?: AbortSignal,
): Promise<DocumentComparisonResponse> {
return getJson(
`/api/v1/documents/${encodeURIComponent(documentId)}`,
parseDocument,
signal,
);
}
export function fetchComponentStage(
documentId: string,
componentPosition: number,
signal?: AbortSignal,
): Promise<ComponentStageResponse> {
return getJson(
`/api/v1/documents/${encodeURIComponent(documentId)}/components/${componentPosition}`,
parseStage,
signal,
);
}
+16
View File
@@ -0,0 +1,16 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App.js";
import "./styles.css";
const root = document.getElementById("root");
if (root === null) {
throw new Error("missing #root element");
}
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);
+554
View File
@@ -0,0 +1,554 @@
:root {
color: #252720;
background: #ecebe5;
font-family:
Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei",
sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
min-width: 1180px;
min-height: 100%;
margin: 0;
}
button {
color: inherit;
font: inherit;
}
button:focus-visible {
outline: 2px solid #315f4b;
outline-offset: 2px;
}
.app-shell {
min-height: 100vh;
background:
radial-gradient(circle at 12% 0%, rgb(255 255 255 / 72%), transparent 34%),
#ecebe5;
}
.topbar {
position: sticky;
z-index: 20;
top: 0;
display: flex;
min-height: 76px;
align-items: center;
justify-content: space-between;
padding: 12px 24px;
border-bottom: 1px solid #d7d5cd;
background: rgb(248 247 242 / 94%);
backdrop-filter: blur(18px);
}
.topbar > div:first-child {
display: flex;
align-items: center;
gap: 12px;
}
.brand-mark {
display: grid;
width: 42px;
height: 42px;
place-items: center;
border-radius: 12px;
background: #284d3d;
color: #f3f4ed;
font-family: Georgia, serif;
font-size: 19px;
letter-spacing: -0.08em;
}
.eyebrow {
margin: 0 0 3px;
color: #78796f;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
h1,
h2,
h3,
p {
margin-top: 0;
}
.topbar h1 {
margin: 0;
font-family: Georgia, "Songti SC", serif;
font-size: 19px;
font-weight: 600;
}
.run-facts,
.document-meta {
display: flex;
align-items: center;
gap: 10px;
color: #66685f;
font-size: 12px;
}
.run-facts > span:not(.status),
.document-meta > span:not(.status) {
padding-left: 10px;
border-left: 1px solid #d5d2c9;
}
.status {
display: inline-flex;
align-items: center;
border: 1px solid currentColor;
border-radius: 999px;
padding: 3px 8px;
font-size: 11px;
font-weight: 700;
}
.status--success {
color: #277052;
background: #edf6ef;
}
.status--failed {
color: #a04338;
background: #fff0ed;
}
.status--unstable {
color: #986617;
background: #fff7df;
}
.notice {
padding: 9px 24px;
border-bottom: 1px solid #e6d09c;
background: #fff7de;
color: #77561d;
font-size: 12px;
}
.layout {
display: grid;
min-height: calc(100vh - 76px);
grid-template-columns: 300px minmax(0, 1fr);
}
.sidebar {
position: sticky;
top: 76px;
overflow-y: auto;
height: calc(100vh - 76px);
border-right: 1px solid #d7d5cd;
background: #f7f6f1;
}
.sidebar section {
padding: 20px 16px;
}
.sidebar section + section {
border-top: 1px solid #dfddd5;
}
.section-heading {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.section-heading h2 {
margin: 0;
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.section-heading > span {
color: #888980;
font-size: 11px;
}
.document-list,
.component-list {
display: grid;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.document-list button,
.component-list button {
display: grid;
width: 100%;
align-items: center;
border: 0;
border-radius: 9px;
background: transparent;
cursor: pointer;
text-align: left;
}
.document-list button {
grid-template-columns: 9px 1fr;
gap: 10px;
padding: 9px 10px;
}
.document-list button:hover,
.component-list button:hover:not(:disabled) {
background: #eceae2;
}
.document-list button.is-active,
.component-list button.is-active {
background: #e0e8e0;
color: #234b39;
}
.document-list strong,
.component-list strong {
display: block;
overflow: hidden;
font-size: 12px;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.document-list small,
.component-list small {
display: block;
margin-top: 3px;
color: #7d7e75;
font-size: 10px;
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #999;
}
.status-dot--success {
background: #348361;
}
.status-dot--failed {
background: #b64b3f;
}
.status-dot--unstable {
background: #bd831c;
}
.text-button {
border: 0;
background: transparent;
color: #315f4b;
cursor: pointer;
font-size: 11px;
}
.text-button:disabled {
color: #aaa99f;
cursor: default;
}
.component-list {
counter-reset: components;
}
.component-list button {
grid-template-columns: 26px minmax(0, 1fr);
gap: 8px;
padding: 8px;
}
.component-list button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.component-index {
display: grid;
width: 24px;
height: 24px;
place-items: center;
border: 1px solid #d3d1c8;
border-radius: 50%;
color: #74766e;
font-family: Georgia, serif;
font-size: 11px;
}
.workspace {
display: grid;
min-width: 0;
align-content: start;
gap: 14px;
padding: 18px 20px 30px;
}
.document-header {
display: flex;
align-items: end;
justify-content: space-between;
gap: 20px;
}
.document-header h2 {
margin: 0;
font-family: Georgia, "Songti SC", serif;
font-size: 21px;
font-weight: 600;
}
.diff-shell,
.changes-panel,
.diagnostic-panel,
.state-panel {
overflow: hidden;
border: 1px solid #d5d3ca;
border-radius: 13px;
background: #fbfaf7;
box-shadow: 0 12px 36px rgb(55 57 48 / 7%);
}
.diff-shell {
min-height: 510px;
}
.diff-labels {
display: grid;
grid-template-columns: 1fr 1fr;
border-bottom: 1px solid #dcdbd3;
background: #f4f2ec;
color: #6f7168;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.diff-labels span {
padding: 9px 14px;
}
.diff-labels span + span {
border-left: 1px solid #dcdbd3;
}
.diff-host,
.diff-host > .cm-mergeView {
height: 510px;
}
.diff-host .cm-mergeViewEditors {
height: 100%;
}
.diff-host .cm-editor {
min-width: 0;
height: 100%;
}
.changes-panel {
min-height: 120px;
}
.changes-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 15px 18px;
border-bottom: 1px solid #e0ded6;
}
.changes-heading h3 {
margin: 0;
font-size: 14px;
}
.changes-heading > p {
max-width: 58%;
margin: 0;
color: #77786f;
font-size: 11px;
line-height: 1.5;
}
.change-list {
display: grid;
max-height: 310px;
gap: 1px;
overflow-y: auto;
margin: 0;
padding: 0;
background: #e4e2da;
list-style: none;
}
.change-list button {
display: grid;
width: 100%;
grid-template-columns: 145px minmax(240px, 1fr) minmax(260px, 0.9fr);
align-items: center;
gap: 16px;
border: 0;
padding: 11px 18px;
background: #fbfaf7;
cursor: pointer;
text-align: left;
}
.change-list button:hover {
background: #f4f5ef;
}
.change-list button:disabled {
cursor: default;
}
.change-location {
color: #73756c;
font-size: 11px;
}
.change-list strong {
font-size: 12px;
font-weight: 600;
}
.change-sample {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 11px;
}
.change-sample del,
.change-sample ins {
overflow: hidden;
max-width: 46%;
border-radius: 4px;
padding: 2px 5px;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
}
.change-sample del {
background: #f9ded9;
color: #913e34;
}
.change-sample ins {
background: #dcecdf;
color: #276348;
}
.quiet-message {
margin: 0;
padding: 22px 18px;
color: #77786f;
font-size: 12px;
}
.state-panel,
.diagnostic-panel {
padding: 28px;
}
.state-panel {
display: grid;
min-height: 180px;
place-items: center;
align-content: center;
color: #66685f;
}
.state-panel p {
margin: 8px 0 0;
}
.state-panel--error {
border-color: #e2b6ae;
color: #8f3e34;
}
.loading-dot {
width: 11px;
height: 11px;
border-radius: 50%;
background: #3d735b;
box-shadow: 0 0 0 7px #dce9df;
animation: pulse 1.25s ease-in-out infinite;
}
.diagnostic-panel h3 {
margin-bottom: 8px;
}
.diagnostic-panel > p:not(.eyebrow) {
color: #686a61;
font-size: 13px;
}
.diagnostic-panel article {
display: grid;
grid-template-columns: 220px 1fr;
gap: 12px;
padding: 10px 0;
border-top: 1px solid #e0ded6;
font-size: 12px;
}
code {
border-radius: 4px;
padding: 1px 4px;
background: #eceae3;
font-family: "SFMono-Regular", Consolas, monospace;
}
@keyframes pulse {
0%,
100% {
opacity: 0.45;
transform: scale(0.82);
}
50% {
opacity: 1;
transform: scale(1);
}
}
@media (max-width: 1280px) {
.layout {
grid-template-columns: 270px minmax(0, 1fr);
}
.change-list button {
grid-template-columns: 125px minmax(180px, 1fr) minmax(220px, 0.8fr);
}
}
+113
View File
@@ -0,0 +1,113 @@
export type RunStatus = "success" | "failed" | "unstable";
export interface ComponentSummary {
component_position: number;
component_id: string;
version: string;
parameters: unknown;
applicability: string;
change_count: number;
}
export interface DocumentSummary {
document_id: string;
source_label: string;
status: RunStatus;
input_sha256: string;
current_sha256: string;
change_count: number;
source_available: boolean;
output_available: boolean;
availability_error: string | null;
}
export interface RunSummaryResponse {
schema_version: 1;
run: {
run_id: string;
run_date: string;
status: RunStatus;
started_at_utc: string;
completed_at_utc: string;
retention_until: string;
};
components: ComponentSummary[];
documents: DocumentSummary[];
summary: {
document_count: number;
success_count: number;
failed_count: number;
unstable_count: number;
change_count: number;
};
original_run_location_changed: boolean;
}
export interface ChangeDetail {
component_id: string;
component_version: string;
component_position: number;
proposal_ref: {
component_position: number;
snapshot_sha256: string;
proposal_index: number;
};
edit_index: number;
reason: string;
location: {
line: number;
column: number;
};
editor_range: {
start: number;
end: number;
} | null;
before: string;
after: string;
}
export interface RunErrorDetail {
component_id: string;
component_version: string;
component_position: number;
stage: "transform" | "final_review";
error_type: string;
message: string;
}
export interface ResidualProposalDetail {
component_id: string;
component_version: string;
component_position: number;
reason: string;
edit_count: number;
}
export interface DocumentComparisonResponse {
schema_version: 1;
document: DocumentSummary;
components: ComponentSummary[];
original_markdown: string | null;
cleaned_markdown: string | null;
changes: ChangeDetail[];
errors: RunErrorDetail[];
residual_proposals: ResidualProposalDetail[];
}
export interface ComponentStageResponse {
schema_version: 1;
document_id: string;
component: ComponentSummary;
before_sha256: string;
after_sha256: string;
before_markdown: string;
after_markdown: string;
changes: ChangeDetail[];
}
export interface ApiErrorResponse {
error: {
code: string;
message: string;
};
}