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
+56 -21
View File
@@ -32728,30 +32728,65 @@ var ClaudeClient = class {
"anthropic-version": "2023-06-01" "anthropic-version": "2023-06-01"
}; };
} }
/** /** Stream a chat completion via fetch + SSE, yielding text chunks as they arrive. */
* "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.
*/
async *streamChat(messages, options) { async *streamChat(messages, options) {
const response = await (0, import_obsidian2.requestUrl)({ let response;
url: this.baseUrl, try {
method: "POST", response = await fetch(this.baseUrl, {
headers: this.headers(options.apiKey), method: "POST",
body: JSON.stringify({ headers: this.headers(options.apiKey),
model: options.model, body: JSON.stringify({
max_tokens: options.maxTokens ?? 8192, model: options.model,
system: options.systemPrompt, max_tokens: options.maxTokens ?? 8192,
messages system: options.systemPrompt,
}), messages,
throw: false 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; return;
} }
const text = response.json.content?.[0]?.text ?? ""; if (!response.ok) {
yield { type: "text", text }; 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" }; yield { type: "done" };
} }
/** Non-streaming convenience wrapper */ /** Non-streaming convenience wrapper */
+59 -23
View File
@@ -18,7 +18,7 @@ export interface ClaudeStreamChunk {
error?: string; 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 { export class ClaudeClient {
private baseUrl = "https://api.anthropic.com/v1/messages"; private baseUrl = "https://api.anthropic.com/v1/messages";
@@ -30,35 +30,71 @@ export class ClaudeClient {
}; };
} }
/** /** Stream a chat completion via fetch + SSE, yielding text chunks as they arrive. */
* "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.
*/
async *streamChat( async *streamChat(
messages: ClaudeMessage[], messages: ClaudeMessage[],
options: ClaudeOptions options: ClaudeOptions
): AsyncGenerator<ClaudeStreamChunk> { ): AsyncGenerator<ClaudeStreamChunk> {
const response = await requestUrl({ let response: Response;
url: this.baseUrl, try {
method: "POST", response = await fetch(this.baseUrl, {
headers: this.headers(options.apiKey), method: "POST",
body: JSON.stringify({ headers: this.headers(options.apiKey),
model: options.model, body: JSON.stringify({
max_tokens: options.maxTokens ?? 8192, model: options.model,
system: options.systemPrompt, max_tokens: options.maxTokens ?? 8192,
messages, system: options.systemPrompt,
}), messages,
throw: false, stream: true,
}); }),
});
if (response.status >= 400) { } catch (e) {
yield { type: "error", error: `API Error ${response.status}: ${response.text}` }; yield { type: "error", error: (e as Error).message };
return; return;
} }
const text: string = response.json.content?.[0]?.text ?? ""; if (!response.ok) {
yield { type: "text", text }; 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" }; yield { type: "done" };
} }