107 lines
3.4 KiB
TypeScript
107 lines
3.4 KiB
TypeScript
import { apiFetch } from "@/core/api/fetch-client";
|
|
import { getBackendBaseURL } from "@/core/config";
|
|
|
|
export type RewriteAction = "polish" | "expand" | "shorten" | "tone";
|
|
export type RewriteTone =
|
|
| "professional"
|
|
| "casual"
|
|
| "readable"
|
|
| "academic"
|
|
| "expressive";
|
|
|
|
export interface RewriteRequest {
|
|
threadId?: string | null;
|
|
selection: { from: number; to: number; text: string };
|
|
action: RewriteAction;
|
|
tone?: RewriteTone;
|
|
modelName?: string;
|
|
surroundingContext?: { before?: string; after?: string };
|
|
customInstruction?: string;
|
|
followUpInstruction?: string;
|
|
previousResult?: string;
|
|
}
|
|
|
|
export type RewriteStreamEvent =
|
|
| { type: "thinking"; chunk: string }
|
|
| { type: "text"; chunk: string }
|
|
| { type: "done" }
|
|
| { type: "error"; message: string };
|
|
|
|
// ── SSE parser ────────────────────────────────────────────────────────────────
|
|
|
|
async function* parseSse(body: ReadableStream<Uint8Array>): AsyncGenerator<{ event: string; data: string }> {
|
|
const reader = body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
let currentEvent = "";
|
|
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buffer += decoder.decode(value, { stream: true });
|
|
|
|
const lines = buffer.split("\n");
|
|
buffer = lines.pop() ?? "";
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trimEnd();
|
|
if (trimmed.startsWith("event: ")) {
|
|
currentEvent = trimmed.slice(7);
|
|
} else if (trimmed.startsWith("data: ")) {
|
|
yield { event: currentEvent, data: trimmed.slice(6) };
|
|
currentEvent = "";
|
|
}
|
|
// empty line resets event field (SSE spec)
|
|
else if (trimmed === "") {
|
|
currentEvent = "";
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
reader.releaseLock();
|
|
}
|
|
}
|
|
|
|
// ── Streaming entry point ─────────────────────────────────────────────────────
|
|
|
|
export async function* streamRewrite(
|
|
req: RewriteRequest,
|
|
signal?: AbortSignal,
|
|
): AsyncGenerator<RewriteStreamEvent> {
|
|
const res = await apiFetch(`${getBackendBaseURL()}/api/writing/rewrite`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(req),
|
|
signal,
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({})) as Record<string, unknown>;
|
|
const msg = (body?.detail as string | undefined) ?? (body?.message as string | undefined) ?? `HTTP ${res.status}`;
|
|
throw new Error(msg);
|
|
}
|
|
|
|
if (!res.body) throw new Error("No response body");
|
|
|
|
for await (const { event, data } of parseSse(res.body)) {
|
|
if (signal?.aborted) return;
|
|
try {
|
|
const parsed = JSON.parse(data) as Record<string, unknown>;
|
|
if (event === "thinking") {
|
|
yield { type: "thinking", chunk: parsed.chunk as string };
|
|
} else if (event === "text") {
|
|
yield { type: "text", chunk: parsed.chunk as string };
|
|
} else if (event === "done") {
|
|
yield { type: "done" };
|
|
return;
|
|
} else if (event === "error") {
|
|
yield { type: "error", message: parsed.message as string };
|
|
return;
|
|
}
|
|
} catch {
|
|
// skip malformed frame
|
|
}
|
|
}
|
|
}
|