feat: 增加项目无关的本地清洗评审器

This commit is contained in:
2026-08-28 19:06:10 +08:00
parent 10c026c7ad
commit 6c0dd5974b
82 changed files with 9335 additions and 48 deletions
+381
View File
@@ -0,0 +1,381 @@
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import type {
ChangeDetail,
CollectionSummaryResponse,
DocumentComparisonResponse,
ModifierStageResponse,
RunStatus,
} from "../shared/api.js";
import { fetchCollection, fetchDocument, fetchModifierStage } 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"> Modifier </p>;
}
return (
<ol className="change-list">
{changes.map((change) => (
<li
key={`${change.modifier_position}-${change.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_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 [collectionState, setCollectionState] =
useState<AsyncState<CollectionSummaryResponse>>(emptyState);
const [selectedDocument, setSelectedDocument] = useState<string | null>(null);
const [documentState, setDocumentState] = useState<AsyncState<DocumentComparisonResponse>>({
loading: false,
value: null,
error: null,
});
const [selectedModifier, setSelectedModifier] = useState<number | null>(null);
const [stageState, setStageState] = useState<AsyncState<ModifierStageResponse>>({
loading: false,
value: null,
error: null,
});
const [focusRange, setFocusRange] = useState<{ start: number; end: number } | null>(null);
useEffect(() => {
const controller = new AbortController();
fetchCollection(controller.signal)
.then((collection) => {
setCollectionState({ loading: false, value: collection, error: null });
setSelectedDocument(collection.documents[0]?.document_id ?? null);
})
.catch((error: unknown) => {
if (!controller.signal.aborted) {
setCollectionState({
loading: false,
value: null,
error: error instanceof Error ? error.message : "无法读取评审集合摘要。",
});
}
});
return () => controller.abort();
}, []);
useEffect(() => {
setSelectedModifier(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 || selectedModifier === null) {
setStageState({ loading: false, value: null, error: null });
return undefined;
}
const controller = new AbortController();
setStageState(emptyState());
fetchModifierStage(selectedDocument, selectedModifier, 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 : "无法读取 Modifier 阶段。",
});
}
});
return () => controller.abort();
}, [selectedDocument, selectedModifier]);
const visibleChanges = useMemo(() => {
const document = documentState.value;
if (document === null) {
return [];
}
if (selectedModifier === null) {
return document.changes;
}
return document.changes.filter((change) => change.modifier_position === selectedModifier);
}, [documentState.value, selectedModifier]);
const selectChange = (change: ChangeDetail): void => {
setSelectedModifier(change.modifier_position);
setFocusRange(change.editor_range);
};
if (collectionState.loading) {
return <LoadingPanel />;
}
if (collectionState.error !== null || collectionState.value === null) {
return <ErrorPanel message={collectionState.error ?? "评审集合摘要为空。"} />;
}
const collection = collectionState.value;
const document = documentState.value;
const selectedSummary = collection.documents.find(
(item) => item.document_id === selectedDocument,
);
const selectedStage = stageState.value;
const canCompare =
document?.document.status === "success" && document.current_markdown !== null;
const beforeText = selectedStage?.before_markdown ?? document?.input_markdown ?? "";
const afterText = selectedStage?.after_markdown ?? document?.current_markdown ?? "";
const beforeLabel =
selectedStage === null
? "清洗前"
: `Modifier ${selectedStage.modifier.modifier_position + 1} 执行前`;
const afterLabel =
selectedStage === null
? "清洗后"
: `Modifier ${selectedStage.modifier.modifier_position + 1} 执行后`;
return (
<div className="app-shell">
<header className="topbar">
<div>
<span className="brand-mark">md</span>
<div>
<p className="eyebrow"></p>
<h1>{collection.collection.label}</h1>
</div>
</div>
<div className="run-facts">
<span className={`status status--${collection.collection.status}`}>
{statusLabel(collection.collection.status)}
</span>
<span>{collection.summary.document_count} </span>
<span>{collection.summary.change_count} </span>
</div>
</header>
<div className="layout">
<aside className="sidebar" aria-label="评审导航">
<section>
<div className="section-heading">
<h2></h2>
<span>{collection.documents.length}</span>
</div>
<nav className="document-list" aria-label="文档列表">
{collection.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="modifier-section">
<div className="section-heading">
<h2>Modifier 线</h2>
<button
type="button"
className="text-button"
onClick={() => {
setSelectedModifier(null);
setFocusRange(null);
}}
disabled={selectedModifier === null}
>
</button>
</div>
<ol className="modifier-list">
{(document?.modifiers ?? []).map((modifier) => (
<li key={`${modifier.modifier_position}-${modifier.modifier_id}`}>
<button
type="button"
className={modifier.modifier_position === selectedModifier ? "is-active" : ""}
onClick={() => {
setSelectedModifier(modifier.modifier_position);
setFocusRange(null);
}}
disabled={!canCompare || !modifier.stage_available}
>
<span className="modifier-index">{modifier.modifier_position + 1}</span>
<span>
<strong>{modifier.modifier_id}</strong>
<small>
v{modifier.modifier_version} · {modifier.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" ? (
<div className="diagnostic-panel">
<p className="eyebrow"></p>
<h3>{statusLabel(document.document.status)}</h3>
<p>partial output </p>
{document.errors.map((error) => (
<article key={`${error.modifier_position}-${error.stage}-${error.code}`}>
<strong>{error.diagnostic_type}</strong>
<span>{error.message}</span>
</article>
))}
{document.residual_proposals.map((proposal) => (
<article key={`${proposal.modifier_position}-${proposal.proposal_index}`}>
<strong> {proposal.edits.length} </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>
{selectedModifier === null
? `全部 Modifier · ${visibleChanges.length}`
: `${document.modifiers[selectedModifier]?.modifier_id ?? "Modifier"} · ${
visibleChanges.length
}`}
</h3>
</div>
{selectedStage === null ? null : <p>{selectedStage.modifier.applicability}</p>}
</div>
<ChangeList changes={visibleChanges} onSelect={selectChange} canJump={canCompare} />
</section>
) : null}
</main>
</div>
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
import { markdown } from "@codemirror/lang-markdown";
import { MergeView } from "@codemirror/merge";
import { EditorSelection, EditorState } from "@codemirror/state";
import { drawSelection, 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),
drawSelection(),
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,
});
merge.current = view;
return () => {
view.destroy();
merge.current = null;
};
}, [before, after]);
// 必须在对应阶段文本重建完成后再次聚焦,不能只依赖 focusRange。
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: EditorSelection.range(anchor, head),
effects: EditorView.scrollIntoView(anchor, { y: "center" }),
});
view.a.focus();
}, [focusRange, before, after]);
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>
);
}
+282
View File
@@ -0,0 +1,282 @@
import type {
ApiErrorResponse,
ChangeDetail,
CollectionSummaryResponse,
DocumentComparisonResponse,
DocumentSummary,
ErrorDetail,
ModifierStageResponse,
ModifierSummary,
ResidualProposalDetail,
RunStatus,
} 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 nullableString(value: unknown, label: string): string | null {
return value === null ? null : string(value, label);
}
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 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): RunStatus {
if (value !== "success" && value !== "failed" && value !== "unstable") {
return invalid("status");
}
return value;
}
function documentSummary(value: unknown): DocumentSummary {
const item = record(value, "document summary");
const runStatus = status(item.status);
const currentKind = item.current_kind;
if (currentKind !== "success_output" && currentKind !== "partial_output") {
return invalid("current_kind");
}
if ((runStatus === "success") !== (currentKind === "success_output")) {
return invalid("document status/current_kind");
}
return {
document_id: string(item.document_id, "document_id"),
source_label: string(item.source_label, "source_label"),
status: runStatus,
current_kind: currentKind,
input_sha256: hash(item.input_sha256, "input_sha256"),
current_sha256: hash(item.current_sha256, "current_sha256"),
modifier_count: integer(item.modifier_count, "modifier_count"),
completed_stage_count: integer(item.completed_stage_count, "completed_stage_count"),
change_count: integer(item.change_count, "change_count"),
error_count: integer(item.error_count, "error_count"),
residual_proposal_count: integer(item.residual_proposal_count, "residual_proposal_count"),
};
}
function modifier(value: unknown): ModifierSummary {
const item = record(value, "modifier");
return {
modifier_position: integer(item.modifier_position, "modifier_position"),
modifier_id: string(item.modifier_id, "modifier_id"),
modifier_version: string(item.modifier_version, "modifier_version"),
parameters: item.parameters,
applicability: string(item.applicability, "applicability"),
change_count: integer(item.change_count, "modifier change_count"),
stage_available: boolean(item.stage_available, "stage_available"),
};
}
function range(value: unknown, label: string): { start: number; end: number } {
const item = record(value, label);
const start = integer(item.start, `${label}.start`);
const end = integer(item.end, `${label}.end`);
if (end < start) {
return invalid(label);
}
return { start, end };
}
function change(value: unknown): ChangeDetail {
const item = record(value, "change");
const location = record(item.location, "change location");
return {
modifier_position: integer(item.modifier_position, "change modifier_position"),
modifier_id: string(item.modifier_id, "change modifier_id"),
modifier_version: string(item.modifier_version, "change modifier_version"),
proposal_index: integer(item.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),
},
span: range(item.span, "span"),
editor_range: range(item.editor_range, "editor_range"),
before: string(item.before, "before"),
after: string(item.after, "after"),
before_sha256: hash(item.before_sha256, "before_sha256"),
after_sha256: hash(item.after_sha256, "after_sha256"),
};
}
function runError(value: unknown): ErrorDetail {
const item = record(value, "run error");
return {
code: string(item.code, "error code"),
stage: string(item.stage, "error stage"),
modifier_position: integer(item.modifier_position, "error modifier_position"),
modifier_id: string(item.modifier_id, "error modifier_id"),
modifier_version: string(item.modifier_version, "error modifier_version"),
diagnostic_type: string(item.diagnostic_type, "diagnostic_type"),
message: string(item.message, "error message"),
};
}
function residual(value: unknown): ResidualProposalDetail {
const item = record(value, "residual proposal");
return {
modifier_position: integer(item.modifier_position, "residual modifier_position"),
modifier_id: string(item.modifier_id, "residual modifier_id"),
modifier_version: string(item.modifier_version, "residual modifier_version"),
proposal_index: integer(item.proposal_index, "residual proposal_index"),
snapshot_sha256: hash(item.snapshot_sha256, "residual snapshot_sha256"),
reason: string(item.reason, "residual reason"),
edits: array(item.edits, "residual edits"),
};
}
function parseCollection(value: unknown): CollectionSummaryResponse {
const payload = record(value, "collection response");
if (payload.schema_version !== 1) {
return invalid("collection schema_version");
}
const collection = record(payload.collection, "collection");
const summary = record(payload.summary, "summary");
return {
schema_version: 1,
collection: {
label: string(collection.label, "collection label"),
status: status(collection.status),
},
documents: array(payload.documents, "documents").map(documentSummary),
summary: {
document_count: integer(summary.document_count, "document_count"),
success_count: integer(summary.success_count, "success_count"),
failed_count: integer(summary.failed_count, "failed_count"),
unstable_count: integer(summary.unstable_count, "unstable_count"),
change_count: integer(summary.change_count, "change_count"),
},
};
}
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),
modifiers: array(payload.modifiers, "modifiers").map(modifier),
input_markdown: string(payload.input_markdown, "input_markdown"),
current_markdown: nullableString(payload.current_markdown, "current_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): ModifierStageResponse {
const payload = record(value, "modifier stage response");
if (payload.schema_version !== 1) {
return invalid("modifier stage schema_version");
}
return {
schema_version: 1,
document_id: string(payload.document_id, "document_id"),
modifier: modifier(payload.modifier),
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 fetchCollection(signal?: AbortSignal): Promise<CollectionSummaryResponse> {
return getJson("/api/v1/collection", parseCollection, signal);
}
export function fetchDocument(
documentId: string,
signal?: AbortSignal,
): Promise<DocumentComparisonResponse> {
return getJson(`/api/v1/documents/${encodeURIComponent(documentId)}`, parseDocument, signal);
}
export function fetchModifierStage(
documentId: string,
modifierPosition: number,
signal?: AbortSignal,
): Promise<ModifierStageResponse> {
return getJson(
`/api/v1/documents/${encodeURIComponent(documentId)}/modifiers/${modifierPosition}`,
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>,
);
+539
View File
@@ -0,0 +1,539 @@
: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;
}
.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,
.modifier-list {
display: grid;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.document-list button,
.modifier-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,
.modifier-list button:hover:not(:disabled) {
background: #eceae2;
}
.document-list button.is-active,
.modifier-list button.is-active {
background: #e0e8e0;
color: #234b39;
}
.document-list strong,
.modifier-list strong {
display: block;
overflow: hidden;
font-size: 12px;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.document-list small,
.modifier-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;
}
.modifier-list button {
grid-template-columns: 26px minmax(0, 1fr);
gap: 8px;
padding: 8px;
}
.modifier-list button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.modifier-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 {
height: 510px;
}
.diff-host > .cm-mergeView {
height: 100%;
overflow-y: auto;
overscroll-behavior: contain;
}
.diff-host .cm-mergeViewEditors {
min-height: 100%;
}
.diff-host .cm-editor {
min-width: 0;
}
.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;
}
@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);
}
}
+115
View File
@@ -0,0 +1,115 @@
export type RunStatus = "success" | "failed" | "unstable";
export interface ModifierSummary {
modifier_position: number;
modifier_id: string;
modifier_version: string;
parameters: unknown;
applicability: string;
change_count: number;
stage_available: boolean;
}
export interface DocumentSummary {
document_id: string;
source_label: string;
status: RunStatus;
current_kind: "success_output" | "partial_output";
input_sha256: string;
current_sha256: string;
modifier_count: number;
completed_stage_count: number;
change_count: number;
error_count: number;
residual_proposal_count: number;
}
export interface CollectionSummaryResponse {
schema_version: 1;
collection: {
label: string;
status: RunStatus;
};
documents: DocumentSummary[];
summary: {
document_count: number;
success_count: number;
failed_count: number;
unstable_count: number;
change_count: number;
};
}
export interface ChangeDetail {
modifier_position: number;
modifier_id: string;
modifier_version: string;
proposal_index: number;
edit_index: number;
reason: string;
location: {
line: number;
column: number;
};
span: {
start: number;
end: number;
};
editor_range: {
start: number;
end: number;
};
before: string;
after: string;
before_sha256: string;
after_sha256: string;
}
export interface ErrorDetail {
code: string;
stage: string;
modifier_position: number;
modifier_id: string;
modifier_version: string;
diagnostic_type: string;
message: string;
}
export interface ResidualProposalDetail {
modifier_position: number;
modifier_id: string;
modifier_version: string;
proposal_index: number;
snapshot_sha256: string;
reason: string;
edits: unknown[];
}
export interface DocumentComparisonResponse {
schema_version: 1;
document: DocumentSummary;
modifiers: ModifierSummary[];
input_markdown: string;
current_markdown: string | null;
changes: ChangeDetail[];
errors: ErrorDetail[];
residual_proposals: ResidualProposalDetail[];
}
export interface ModifierStageResponse {
schema_version: 1;
document_id: string;
modifier: ModifierSummary;
before_sha256: string;
after_sha256: string;
before_markdown: string;
after_markdown: string;
changes: ChangeDetail[];
}
export interface ApiErrorResponse {
error: {
code: string;
message: string;
};
}