289 lines
9.7 KiB
TypeScript
289 lines
9.7 KiB
TypeScript
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,
|
|
);
|
|
}
|