Files
mdpolish/reviewer/src/client/api-client.ts
T

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,
);
}