From e57088688349dbaab32c3cc2dea91eed4d29df9d Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Thu, 14 May 2026 15:45:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(shell-core/chat):=20ChatState::send=5Fvia?= =?UTF-8?q?=5Fprovider=20=E2=80=94=20streams=20through=20ChatProvider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the existing chat widget to the `ChatProvider` trait (commit `548c5336`). The old `send()` echo-stub stays for the zero-provider fallback path; `send_via_provider(provider, system_prompt, max_tokens)` is the real entry that: - Pushes the input as a `ChatRole::User` message - Builds a `ChatRequest` and calls `provider.send(req)` - Drains the delta iterator into a single accumulated assistant message (TextDelta + Thinking concatenate; Error replaces with "error: ..."; Done breaks the loop; ToolUse is ignored — that flow belongs to the agent runtime port) - Returns the delta count for streaming-progress reporting Synchronous wrapper — the caller polls the iterator to completion. A future async-capable widget can replace the inner loop with one delta per render frame; the trait signature already returns an `Iterator + Send`. Tests (3): EchoProvider streams two text fragments → assistant body "Hello, world!"; provider error surfaces as assistant body "error: rate limited"; empty input is a no-op. #6 AI chat now ~25% — types + trait + chat-widget integration working end-to-end with a test double. Real HTTP transport (reqwest + Anthropic / OpenAI-compat / Ollama backends) is the remaining multi-week port — but the seam is in place. Tests total: 239 shell-core (+3). Wasm32 build clean. --- .../src/document/chat.rs | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/crates/openpencil-shell-core/src/document/chat.rs b/crates/openpencil-shell-core/src/document/chat.rs index 0670925a6..c2a8ea874 100644 --- a/crates/openpencil-shell-core/src/document/chat.rs +++ b/crates/openpencil-shell-core/src/document/chat.rs @@ -102,4 +102,104 @@ impl ChatState { self.messages.push(echo); self.input.clear(); } + + /// Push the focused input as a user message and stream the reply + /// through `provider`. Accumulates `TextDelta` + `Thinking` + /// fragments into a single assistant message; bails on `Error` + /// with the error text as the assistant body. Returns the count + /// of fragments consumed so callers can report streaming + /// progress / errors. + /// + /// Synchronous wrapper — the provider's iterator yields deltas + /// until `Done`; this drains them. A future async-capable widget + /// can replace the loop with one delta per render frame. + pub fn send_via_provider( + &mut self, + provider: &dyn crate::chat_provider::ChatProvider, + system_prompt: impl Into, + max_output_tokens: u32, + ) -> usize { + use crate::chat_provider::{ChatDelta, ChatRequest}; + let trimmed = self.input.trim().to_string(); + if trimmed.is_empty() { + return 0; + } + self.messages.push(ChatMessage { + role: ChatRole::User, + content: trimmed.clone(), + }); + self.input.clear(); + let req = ChatRequest { + system_prompt: system_prompt.into(), + user_message: trimmed, + max_output_tokens, + }; + let mut buffer = String::new(); + let mut count = 0usize; + for delta in provider.send(req) { + count += 1; + match delta { + ChatDelta::TextDelta(s) | ChatDelta::Thinking(s) => buffer.push_str(&s), + ChatDelta::Error(msg) => { + buffer = format!("error: {msg}"); + break; + } + ChatDelta::Done { .. } => break, + ChatDelta::ToolUse { .. } => {} // tool dispatch is the agent runtime's job + } + } + self.messages.push(ChatMessage { + role: ChatRole::Assistant, + content: buffer, + }); + count + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::chat_provider::{ChatDelta, EchoProvider, StopReason}; + + #[test] + fn send_via_provider_streams_text_into_assistant_message() { + let mut chat = ChatState::default(); + chat.input = "hi".into(); + let p = EchoProvider { + script: vec![ + ChatDelta::TextDelta("Hello".into()), + ChatDelta::TextDelta(", world!".into()), + ChatDelta::Done { + stop_reason: StopReason::EndTurn, + }, + ], + }; + let n = chat.send_via_provider(&p, "be helpful", 1024); + assert_eq!(n, 3, "consumed 3 deltas"); + // User message + assistant message pushed. + assert_eq!(chat.messages.len(), 2); + assert_eq!(chat.messages[1].role, ChatRole::Assistant); + assert_eq!(chat.messages[1].content, "Hello, world!"); + assert!(chat.input.is_empty()); + } + + #[test] + fn send_via_provider_surfaces_provider_error_in_assistant_body() { + let mut chat = ChatState::default(); + chat.input = "x".into(); + let p = EchoProvider { + script: vec![ChatDelta::Error("rate limited".into())], + }; + chat.send_via_provider(&p, "", 0); + assert!(chat.messages[1].content.contains("rate limited")); + } + + #[test] + fn send_via_provider_empty_input_no_ops() { + let mut chat = ChatState::default(); + let p = EchoProvider { script: Vec::new() }; + let n = chat.send_via_provider(&p, "", 0); + assert_eq!(n, 0); + assert!(chat.messages.is_empty()); + } }