chat.rs
Language Rust7.4 KB193 lines
//! One turn of a conversation: build the request from history, stream the//! reply, and save it with its tokens and cost. The Tauri command is a thin//! wrapper over `run_turn`, so the whole path is tested without a window.use std::sync::{Mutex, PoisonError};use std::time::{Duration, Instant};use tokio_util::sync::CancellationToken;use crate::api::StreamEvent;use crate::attachments::{data_url, text_block};use crate::cost::cost_nanousd;use crate::estimate::{estimate_prompt, estimate_tokens};use crate::provider::openai::{OutContent, Part};use crate::provider::{ChatRequest, Client, OutMessage, StreamItem, StreamOptions};use crate::storage::{Db, DbError, Finalize, Message};use crate::types::{Prices, Usage, UsageSource};/// Deltas are batched to about this often, so the interface is not flooded.pub const COALESCE: Duration = Duration::from_millis(30);/// The request for a conversation as it stands: system prompt, then every/// finished message, with text files inlined and images as data URLs./// Replies that failed or were stopped are left out of the model's view.pub fn build_request(db: &Db, conversation_id: &str) -> Result<ChatRequest, DbError> {let conv = db.get_conversation(conversation_id)?;let preset = match &conv.preset_id {Some(id) => db.list_presets()?.into_iter().find(|p| &p.id == id),None => None,};let mut messages = Vec::new();if !conv.system_prompt.trim().is_empty() {messages.push(OutMessage::text("system", conv.system_prompt.trim()));}for m in db.list_messages(conversation_id)? {match m.role.as_str() {"user" => {let mut text = m.content.clone();let mut images = Vec::new();for a in db.attachment_data_for(&m.id)? {match a.meta.kind.as_str() {"text" => {if !text.is_empty() {text.push_str("\n\n");}text.push_str(&text_block(&a.meta.filename, a.text_content.as_deref().unwrap_or("")));}"image" => {if let Some(bytes) = a.path.as_deref().and_then(|p| std::fs::read(p).ok()) {images.push(data_url(&a.meta.mime, &bytes));}}_ => {}}}messages.push(OutMessage::with_images("user", text, images));}"assistant" if m.status == "complete" && !m.content.is_empty() => {messages.push(OutMessage::text("assistant", m.content.clone()));}_ => {}}}Ok(ChatRequest {model: conv.model_id,messages,stream: true,stream_options: Some(StreamOptions { include_usage: true }),temperature: preset.as_ref().and_then(|p| p.temperature),max_tokens: preset.as_ref().and_then(|p| p.max_tokens),})}/// Rough prompt size of a request, for estimates.pub fn request_tokens(req: &ChatRequest) -> i64 {let msgs: Vec<(String, usize)> = req.messages.iter().map(|m| match &m.content {OutContent::Text(s) => (s.clone(), 0),OutContent::Parts(parts) => {let text = parts.iter().filter_map(|p| match p {Part::Text { text } => Some(text.as_str()),Part::ImageUrl { .. } => None,}).collect::<Vec<_>>().join("\n");let images = parts.iter().filter(|p| matches!(p, Part::ImageUrl { .. })).count();(text, images)}}).collect();estimate_prompt(msgs.iter().map(|(t, n)| (t.as_str(), *n)))}/// Batches deltas: flushes when `COALESCE` has passed since the last flush.struct Coalescer {content: String,reasoning: String,last: Instant,}impl Coalescer {fn new() -> Self {Self { content: String::new(), reasoning: String::new(), last: Instant::now() }}fn flush(&mut self, emit: &mut impl FnMut(StreamEvent)) {if !self.reasoning.is_empty() {emit(StreamEvent::Reasoning { text: std::mem::take(&mut self.reasoning) });}if !self.content.is_empty() {emit(StreamEvent::Delta { text: std::mem::take(&mut self.content) });}self.last = Instant::now();}fn push(&mut self, item: StreamItem, emit: &mut impl FnMut(StreamEvent)) {match item {StreamItem::Content(t) => self.content.push_str(&t),StreamItem::Reasoning(t) => self.reasoning.push_str(&t),StreamItem::Usage(u) => {self.flush(emit);emit(StreamEvent::Usage { usage: u });return;}}if self.last.elapsed() >= COALESCE {self.flush(emit);}}}/// Stream the reply into the placeholder `assistant` row and save the outcome./// Always returns the saved row: complete, cancelled (keeping what arrived), or error.pub async fn run_turn(db: &Mutex<Db>,client: &Client,req: &ChatRequest,assistant: &Message,prices: Option<Prices>,cancel: &CancellationToken,mut emit: impl FnMut(StreamEvent),) -> Result<Message, DbError> {let mut co = Coalescer::new();let out = client.stream_chat(req, cancel, |item| co.push(item, &mut emit)).await;co.flush(&mut emit);let got_anything = !out.content.is_empty() || !out.reasoning.is_empty();let (usage, source) = match (out.usage, got_anything) {(Some(u), _) => (u, UsageSource::Reported),// Nothing reported: estimate from what was sent and what came back.(None, true) => (Usage {prompt_tokens: request_tokens(req),completion_tokens: estimate_tokens(&out.content) + estimate_tokens(&out.reasoning),cached_tokens: 0,},UsageSource::Estimated,),// Refused before a single token: nothing billed.(None, false) => (Usage::default(), UsageSource::None),};let cost = prices.map(|p| cost_nanousd(&usage, &p)).unwrap_or(0);let status = if out.cancelled {"cancelled"} else if out.error.is_some() {"error"} else {"complete"};let f = Finalize {content: out.content.clone(),reasoning: Some(out.reasoning.clone()),status,error_kind: out.error.as_ref().map(|e| e.kind().to_string()),error_message: out.error.as_ref().map(|e| e.to_string()),usage,usage_source: Some(source),prices,cost_nanousd: cost,provider_cost_nanousd: out.provider_cost_usd.map(|c| (c * crate::cost::NANO_PER_USD).round() as i64),finish_reason: out.finish_reason.clone(),ttft_ms: out.ttft_ms.map(|v| v as i64),duration_ms: Some(out.duration_ms as i64),};let saved = db.lock().unwrap_or_else(PoisonError::into_inner).finalize_assistant(&assistant.id, &f)?;match &out.error {Some(e) => emit(StreamEvent::Error { kind: e.kind().into(), error: e.to_string(), message: saved.clone() }),None => emit(StreamEvent::Done { message: saved.clone() }),}Ok(saved)}