fix: true SSE streaming via fetch instead of requestUrl

Replaces the buffered requestUrl call with native fetch + ReadableStream
so text chunks are yielded incrementally as they arrive. The ChatView
streaming loop is unchanged — it already handles chunks correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
svemagie
2026-03-31 00:35:46 +02:00
parent 3b5c1a2e8e
commit 98334b9075
2 changed files with 115 additions and 44 deletions
+49 -14
View File
@@ -32728,30 +32728,65 @@ var ClaudeClient = class {
"anthropic-version": "2023-06-01"
};
}
/**
* "Stream" a chat completion via requestUrl (no real streaming CORS blocks
* native fetch from app://obsidian.md). Yields the full response as a single
* text chunk so ChatView's streaming loop keeps working unchanged.
*/
/** Stream a chat completion via fetch + SSE, yielding text chunks as they arrive. */
async *streamChat(messages, options) {
const response = await (0, import_obsidian2.requestUrl)({
url: this.baseUrl,
let response;
try {
response = await fetch(this.baseUrl, {
method: "POST",
headers: this.headers(options.apiKey),
body: JSON.stringify({
model: options.model,
max_tokens: options.maxTokens ?? 8192,
system: options.systemPrompt,
messages
}),
throw: false
messages,
stream: true
})
});
if (response.status >= 400) {
yield { type: "error", error: `API Error ${response.status}: ${response.text}` };
} catch (e) {
yield { type: "error", error: e.message };
return;
}
const text = response.json.content?.[0]?.text ?? "";
yield { type: "text", text };
if (!response.ok) {
const text = await response.text();
yield { type: "error", error: `API Error ${response.status}: ${text}` };
return;
}
const reader = response.body?.getReader();
if (!reader) {
yield { type: "error", error: "No response body" };
return;
}
const decoder = new TextDecoder();
let buffer = "";
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) {
if (!line.startsWith("data: "))
continue;
const data = line.slice(6).trim();
if (data === "[DONE]") {
yield { type: "done" };
return;
}
try {
const event = JSON.parse(data);
if (event.type === "content_block_delta" && event.delta?.type === "text_delta") {
yield { type: "text", text: event.delta.text };
}
} catch {
}
}
}
} finally {
reader.releaseLock();
}
yield { type: "done" };
}
/** Non-streaming convenience wrapper */
+50 -14
View File
@@ -18,7 +18,7 @@ export interface ClaudeStreamChunk {
error?: string;
}
/** Minimal Claude API client using Obsidian's requestUrl (bypasses CORS) */
/** Minimal Claude API client. streamChat uses fetch+SSE; other methods use requestUrl. */
export class ClaudeClient {
private baseUrl = "https://api.anthropic.com/v1/messages";
@@ -30,17 +30,14 @@ export class ClaudeClient {
};
}
/**
* "Stream" a chat completion via requestUrl (no real streaming — CORS blocks
* native fetch from app://obsidian.md). Yields the full response as a single
* text chunk so ChatView's streaming loop keeps working unchanged.
*/
/** Stream a chat completion via fetch + SSE, yielding text chunks as they arrive. */
async *streamChat(
messages: ClaudeMessage[],
options: ClaudeOptions
): AsyncGenerator<ClaudeStreamChunk> {
const response = await requestUrl({
url: this.baseUrl,
let response: Response;
try {
response = await fetch(this.baseUrl, {
method: "POST",
headers: this.headers(options.apiKey),
body: JSON.stringify({
@@ -48,17 +45,56 @@ export class ClaudeClient {
max_tokens: options.maxTokens ?? 8192,
system: options.systemPrompt,
messages,
stream: true,
}),
throw: false,
});
if (response.status >= 400) {
yield { type: "error", error: `API Error ${response.status}: ${response.text}` };
} catch (e) {
yield { type: "error", error: (e as Error).message };
return;
}
const text: string = response.json.content?.[0]?.text ?? "";
yield { type: "text", text };
if (!response.ok) {
const text = await response.text();
yield { type: "error", error: `API Error ${response.status}: ${text}` };
return;
}
const reader = response.body?.getReader();
if (!reader) {
yield { type: "error", error: "No response body" };
return;
}
const decoder = new TextDecoder();
let buffer = "";
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) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6).trim();
if (data === "[DONE]") { yield { type: "done" }; return; }
try {
const event = JSON.parse(data);
if (event.type === "content_block_delta" && event.delta?.type === "text_delta") {
yield { type: "text", text: event.delta.text };
}
} catch {
// skip malformed SSE lines
}
}
}
} finally {
reader.releaseLock();
}
yield { type: "done" };
}