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; 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( pathname: string, parse: (payload: unknown) => T, signal?: AbortSignal, ): Promise { 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; throw new ReviewerApiError( errorPayload.error?.code ?? "request_failed", errorPayload.error?.message ?? `请求失败(HTTP ${response.status})。`, ); } return parse(payload); } export function fetchRun(signal?: AbortSignal): Promise { return getJson("/api/v1/run", parseRun, signal); } export function fetchDocument( documentId: string, signal?: AbortSignal, ): Promise { return getJson( `/api/v1/documents/${encodeURIComponent(documentId)}`, parseDocument, signal, ); } export function fetchComponentStage( documentId: string, componentPosition: number, signal?: AbortSignal, ): Promise { return getJson( `/api/v1/documents/${encodeURIComponent(documentId)}/components/${componentPosition}`, parseStage, signal, ); }