213 lines
7.0 KiB
TypeScript
213 lines
7.0 KiB
TypeScript
import { z } from "zod";
|
|
import {
|
|
conversationalRectificationCommandSchema,
|
|
conversationalRectificationResponseSchema,
|
|
type ConversationalRectificationCommand,
|
|
type ConversationalRectificationResponse,
|
|
} from "./contracts.ts";
|
|
|
|
export const CONVERSATIONAL_RECTIFICATION_UNAVAILABLE = "生时校正暂时无法继续,请稍后重试。";
|
|
|
|
const publicErrorSchema = z.object({
|
|
code: z.string(),
|
|
message: z.string(),
|
|
}).passthrough();
|
|
|
|
const streamEventSchema = z.discriminatedUnion("type", [
|
|
z.object({ type: z.literal("delta"), text: z.string() }).strict(),
|
|
z.object({
|
|
type: z.literal("turn"),
|
|
turn: conversationalRectificationResponseSchema,
|
|
}).strict(),
|
|
]);
|
|
|
|
export type ConversationalRectificationStreamOptions = Readonly<{
|
|
onNarrativeDelta?: (text: string) => void;
|
|
}>;
|
|
|
|
export class ConversationalRectificationRequestError extends Error {
|
|
readonly name = "ConversationalRectificationRequestError";
|
|
readonly status: number;
|
|
readonly code: string | null;
|
|
|
|
constructor(status: number, code: string | null, message: string) {
|
|
super(message);
|
|
this.status = status;
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
function canonicalValue(value: unknown): unknown {
|
|
if (Array.isArray(value)) return value.map(canonicalValue);
|
|
if (value !== null && typeof value === "object") {
|
|
const record = value as Readonly<Record<string, unknown>>;
|
|
return Object.fromEntries(
|
|
Object.keys(record)
|
|
.filter((key) => record[key] !== undefined)
|
|
.sort()
|
|
.map((key) => [key, canonicalValue(record[key])]),
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function canonicalConversationalRectificationPayload(value: unknown): string {
|
|
return JSON.stringify(canonicalValue(value));
|
|
}
|
|
|
|
export type ConversationalRectificationActionIdentity = Readonly<{
|
|
caseId: string;
|
|
turnVersion: number;
|
|
operation: ConversationalRectificationCommand["type"];
|
|
payload: unknown;
|
|
}>;
|
|
|
|
function actionIdentity(input: ConversationalRectificationActionIdentity): string {
|
|
return canonicalConversationalRectificationPayload({
|
|
caseId: input.caseId,
|
|
operation: input.operation,
|
|
payload: input.payload,
|
|
turnVersion: input.turnVersion,
|
|
});
|
|
}
|
|
|
|
export function createConversationalRectificationActionRegistry(
|
|
createId: () => string = () => globalThis.crypto.randomUUID(),
|
|
) {
|
|
const actionIds = new Map<string, string>();
|
|
return {
|
|
async run<T>(
|
|
input: ConversationalRectificationActionIdentity,
|
|
operation: (actionId: string) => Promise<T>,
|
|
): Promise<T> {
|
|
const identity = actionIdentity(input);
|
|
const actionId = actionIds.get(identity) ?? createId();
|
|
actionIds.set(identity, actionId);
|
|
const result = await operation(actionId);
|
|
if (actionIds.get(identity) === actionId) actionIds.delete(identity);
|
|
return result;
|
|
},
|
|
};
|
|
}
|
|
|
|
export type ConversationalRectificationActionRegistry = ReturnType<
|
|
typeof createConversationalRectificationActionRegistry
|
|
>;
|
|
|
|
function isAbortError(error: unknown): boolean {
|
|
return error instanceof DOMException && error.name === "AbortError";
|
|
}
|
|
|
|
function isRetryableTransportError(error: unknown): boolean {
|
|
return !isAbortError(error) && (
|
|
error instanceof TypeError
|
|
|| error instanceof SyntaxError
|
|
|| (error instanceof DOMException && error.name === "SyntaxError")
|
|
);
|
|
}
|
|
|
|
async function readJsonPayload(response: Response): Promise<unknown> {
|
|
return response.json().catch(() => null);
|
|
}
|
|
|
|
async function readStreamedTurn(
|
|
response: Response,
|
|
options: ConversationalRectificationStreamOptions,
|
|
): Promise<ConversationalRectificationResponse> {
|
|
if (!response.body) throw new SyntaxError("missing rectification response stream");
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffered = "";
|
|
let turn: ConversationalRectificationResponse | null = null;
|
|
const consumeLine = (line: string) => {
|
|
if (!line.trim()) return;
|
|
const event = streamEventSchema.parse(JSON.parse(line));
|
|
if (event.type === "delta") options.onNarrativeDelta?.(event.text);
|
|
else turn = event.turn;
|
|
};
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
buffered += decoder.decode(value, { stream: !done });
|
|
let newline = buffered.indexOf("\n");
|
|
while (newline >= 0) {
|
|
consumeLine(buffered.slice(0, newline));
|
|
buffered = buffered.slice(newline + 1);
|
|
newline = buffered.indexOf("\n");
|
|
}
|
|
if (done) break;
|
|
}
|
|
consumeLine(buffered);
|
|
if (!turn) throw new SyntaxError("missing rectification turn event");
|
|
return turn;
|
|
}
|
|
|
|
async function postCommandWithOneReplay(
|
|
body: string,
|
|
options: ConversationalRectificationStreamOptions,
|
|
) {
|
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
let emittedNarrative = false;
|
|
try {
|
|
const response = await fetch("/api/birth-time-conversation", {
|
|
method: "POST",
|
|
credentials: "same-origin",
|
|
headers: {
|
|
Accept: "application/x-ndjson, application/json",
|
|
"Content-Type": "application/json",
|
|
},
|
|
body,
|
|
});
|
|
if (!response.ok) {
|
|
const payload = await readJsonPayload(response);
|
|
const nonJsonFailure = payload === null;
|
|
if (attempt === 0 && (response.status === 502 || nonJsonFailure)) continue;
|
|
return { response, payload, turn: null };
|
|
}
|
|
if (response.headers.get("content-type")?.includes("application/x-ndjson")) {
|
|
const turn = await readStreamedTurn(response, {
|
|
onNarrativeDelta(text) {
|
|
emittedNarrative = true;
|
|
options.onNarrativeDelta?.(text);
|
|
},
|
|
});
|
|
return { response, payload: null, turn };
|
|
}
|
|
return { response, payload: await readJsonPayload(response), turn: null };
|
|
} catch (error) {
|
|
if (attempt === 0 && !emittedNarrative && isRetryableTransportError(error)) continue;
|
|
throw error;
|
|
}
|
|
}
|
|
throw new TypeError("unreachable conversational rectification replay state");
|
|
}
|
|
|
|
export async function sendConversationalRectificationCommand(
|
|
command: ConversationalRectificationCommand,
|
|
options: ConversationalRectificationStreamOptions = {},
|
|
): Promise<ConversationalRectificationResponse> {
|
|
const request = conversationalRectificationCommandSchema.parse(command);
|
|
const body = JSON.stringify(request);
|
|
try {
|
|
const { response, payload, turn } = await postCommandWithOneReplay(body, options);
|
|
if (!response.ok) {
|
|
const parsed = publicErrorSchema.safeParse(payload);
|
|
const safeServerMessage = response.status < 500 && parsed.success
|
|
? parsed.data.message
|
|
: CONVERSATIONAL_RECTIFICATION_UNAVAILABLE;
|
|
throw new ConversationalRectificationRequestError(
|
|
response.status,
|
|
parsed.success ? parsed.data.code : null,
|
|
safeServerMessage,
|
|
);
|
|
}
|
|
return turn ?? conversationalRectificationResponseSchema.parse(payload);
|
|
} catch (error) {
|
|
if (error instanceof ConversationalRectificationRequestError) throw error;
|
|
throw new ConversationalRectificationRequestError(
|
|
502,
|
|
null,
|
|
CONVERSATIONAL_RECTIFICATION_UNAVAILABLE,
|
|
);
|
|
}
|
|
}
|