File size: 4,184 Bytes
50fe3c9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 | import { Channel, invoke } from "@tauri-apps/api/core";
/** Streaming events emitted by the Rust `ai_http_stream` command. */
type AiStreamEvent =
| { kind: "headers"; status: number; headers: Record<string, string> }
| { kind: "chunk"; bytes: number[] }
| { kind: "end" }
| { kind: "error"; message: string };
type RequestHeaders = Record<string, string>;
function headerInitToRecord(
init: HeadersInit | undefined,
): RequestHeaders | undefined {
if (!init) return undefined;
const out: RequestHeaders = {};
if (init instanceof Headers) {
init.forEach((value, key) => {
out[key] = value;
});
} else if (Array.isArray(init)) {
for (const [k, v] of init) out[k] = v;
} else {
for (const [k, v] of Object.entries(init)) out[k] = String(v);
}
return out;
}
async function bodyToBytes(
body: BodyInit | null | undefined,
): Promise<number[] | undefined> {
if (body == null) return undefined;
if (typeof body === "string") {
return Array.from(new TextEncoder().encode(body));
}
if (body instanceof ArrayBuffer) return Array.from(new Uint8Array(body));
if (ArrayBuffer.isView(body)) {
const view = body as ArrayBufferView;
return Array.from(
new Uint8Array(view.buffer, view.byteOffset, view.byteLength),
);
}
if (body instanceof Blob) return Array.from(new Uint8Array(await body.arrayBuffer()));
// FormData / URLSearchParams / ReadableStream — uncommon for AI SDK calls.
const text = await new Response(body as BodyInit).text();
return Array.from(new TextEncoder().encode(text));
}
/** Fetch API replacement that routes through Tauri so requests bypass the
* webview's CORS / Mixed-Content / Private-Network-Access restrictions.
* Intended for local model servers (LM Studio, Ollama, vLLM, …). */
export const proxyFetch: typeof fetch = async (input, init) => {
const url = input instanceof URL ? input.toString() : String(input);
const method = (init?.method ?? "GET").toUpperCase();
const headers = headerInitToRecord(init?.headers);
const body = await bodyToBytes(init?.body);
const signal = init?.signal;
if (signal?.aborted) {
throw makeAbortError();
}
return new Promise<Response>((resolve, reject) => {
let resolved = false;
let streamController: ReadableStreamDefaultController<Uint8Array> | null =
null;
let cancelled = false;
const onAbort = () => {
cancelled = true;
if (!resolved) {
reject(makeAbortError());
} else if (streamController) {
try {
streamController.error(makeAbortError());
} catch {
/* already closed */
}
}
};
signal?.addEventListener("abort", onAbort, { once: true });
const channel = new Channel<AiStreamEvent>();
channel.onmessage = (event) => {
if (cancelled) return;
switch (event.kind) {
case "headers": {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
streamController = controller;
},
cancel() {
cancelled = true;
},
});
resolved = true;
resolve(
new Response(stream, {
status: event.status,
headers: new Headers(event.headers),
}),
);
break;
}
case "chunk": {
streamController?.enqueue(Uint8Array.from(event.bytes));
break;
}
case "end": {
streamController?.close();
break;
}
case "error": {
if (!resolved) {
reject(new Error(event.message));
} else {
streamController?.error(new Error(event.message));
}
break;
}
}
};
invoke("ai_http_stream", {
url,
method,
headers,
body,
onEvent: channel,
}).catch((e) => {
if (resolved) return; // headers already arrived; chunk-side error wins
reject(e instanceof Error ? e : new Error(String(e)));
});
});
};
function makeAbortError(): DOMException {
return new DOMException("Request aborted", "AbortError");
}
|