diff --git a/main.js b/main.js index 27c9b4d..19f93ce 100644 --- a/main.js +++ b/main.js @@ -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, - method: "POST", - headers: this.headers(options.apiKey), - body: JSON.stringify({ - model: options.model, - max_tokens: options.maxTokens ?? 8192, - system: options.systemPrompt, - messages - }), - throw: false - }); - if (response.status >= 400) { - yield { type: "error", error: `API Error ${response.status}: ${response.text}` }; + 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, + stream: true + }) + }); + } 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 */ diff --git a/src/ClaudeClient.ts b/src/ClaudeClient.ts index 3fdb777..19beff6 100644 --- a/src/ClaudeClient.ts +++ b/src/ClaudeClient.ts @@ -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,35 +30,71 @@ 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 { - const response = await requestUrl({ - url: 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, - }); - - if (response.status >= 400) { - yield { type: "error", error: `API Error ${response.status}: ${response.text}` }; + let response: 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, + stream: true, + }), + }); + } 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" }; }