实现本地 Markdown 清洗评审器
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user