mattrguerra/cortexmainPrivate on GitHub, published here in full

chat.rs

Language Rust7.4 KB193 lines

crates/cortex-core/src/chat.rs

  1. //! One turn of a conversation: build the request from history, stream the
  2. //! reply, and save it with its tokens and cost. The Tauri command is a thin
  3. //! wrapper over `run_turn`, so the whole path is tested without a window.
  4. use std::sync::{Mutex, PoisonError};
  5. use std::time::{Duration, Instant};
  6. use tokio_util::sync::CancellationToken;
  7. use crate::api::StreamEvent;
  8. use crate::attachments::{data_url, text_block};
  9. use crate::cost::cost_nanousd;
  10. use crate::estimate::{estimate_prompt, estimate_tokens};
  11. use crate::provider::openai::{OutContent, Part};
  12. use crate::provider::{ChatRequest, Client, OutMessage, StreamItem, StreamOptions};
  13. use crate::storage::{Db, DbError, Finalize, Message};
  14. use crate::types::{Prices, Usage, UsageSource};
  15. /// Deltas are batched to about this often, so the interface is not flooded.
  16. pub const COALESCE: Duration = Duration::from_millis(30);
  17. /// The request for a conversation as it stands: system prompt, then every
  18. /// finished message, with text files inlined and images as data URLs.
  19. /// Replies that failed or were stopped are left out of the model's view.
  20. pub fn build_request(db: &Db, conversation_id: &str) -> Result<ChatRequest, DbError> {
  21. let conv = db.get_conversation(conversation_id)?;
  22. let preset = match &conv.preset_id {
  23. Some(id) => db.list_presets()?.into_iter().find(|p| &p.id == id),
  24. None => None,
  25. };
  26. let mut messages = Vec::new();
  27. if !conv.system_prompt.trim().is_empty() {
  28. messages.push(OutMessage::text("system", conv.system_prompt.trim()));
  29. }
  30. for m in db.list_messages(conversation_id)? {
  31. match m.role.as_str() {
  32. "user" => {
  33. let mut text = m.content.clone();
  34. let mut images = Vec::new();
  35. for a in db.attachment_data_for(&m.id)? {
  36. match a.meta.kind.as_str() {
  37. "text" => {
  38. if !text.is_empty() {
  39. text.push_str("\n\n");
  40. }
  41. text.push_str(&text_block(&a.meta.filename, a.text_content.as_deref().unwrap_or("")));
  42. }
  43. "image" => {
  44. if let Some(bytes) = a.path.as_deref().and_then(|p| std::fs::read(p).ok()) {
  45. images.push(data_url(&a.meta.mime, &bytes));
  46. }
  47. }
  48. _ => {}
  49. }
  50. }
  51. messages.push(OutMessage::with_images("user", text, images));
  52. }
  53. "assistant" if m.status == "complete" && !m.content.is_empty() => {
  54. messages.push(OutMessage::text("assistant", m.content.clone()));
  55. }
  56. _ => {}
  57. }
  58. }
  59. Ok(ChatRequest {
  60. model: conv.model_id,
  61. messages,
  62. stream: true,
  63. stream_options: Some(StreamOptions { include_usage: true }),
  64. temperature: preset.as_ref().and_then(|p| p.temperature),
  65. max_tokens: preset.as_ref().and_then(|p| p.max_tokens),
  66. })
  67. }
  68. /// Rough prompt size of a request, for estimates.
  69. pub fn request_tokens(req: &ChatRequest) -> i64 {
  70. let msgs: Vec<(String, usize)> = req
  71. .messages
  72. .iter()
  73. .map(|m| match &m.content {
  74. OutContent::Text(s) => (s.clone(), 0),
  75. OutContent::Parts(parts) => {
  76. let text = parts
  77. .iter()
  78. .filter_map(|p| match p {
  79. Part::Text { text } => Some(text.as_str()),
  80. Part::ImageUrl { .. } => None,
  81. })
  82. .collect::<Vec<_>>()
  83. .join("\n");
  84. let images = parts.iter().filter(|p| matches!(p, Part::ImageUrl { .. })).count();
  85. (text, images)
  86. }
  87. })
  88. .collect();
  89. estimate_prompt(msgs.iter().map(|(t, n)| (t.as_str(), *n)))
  90. }
  91. /// Batches deltas: flushes when `COALESCE` has passed since the last flush.
  92. struct Coalescer {
  93. content: String,
  94. reasoning: String,
  95. last: Instant,
  96. }
  97. impl Coalescer {
  98. fn new() -> Self {
  99. Self { content: String::new(), reasoning: String::new(), last: Instant::now() }
  100. }
  101. fn flush(&mut self, emit: &mut impl FnMut(StreamEvent)) {
  102. if !self.reasoning.is_empty() {
  103. emit(StreamEvent::Reasoning { text: std::mem::take(&mut self.reasoning) });
  104. }
  105. if !self.content.is_empty() {
  106. emit(StreamEvent::Delta { text: std::mem::take(&mut self.content) });
  107. }
  108. self.last = Instant::now();
  109. }
  110. fn push(&mut self, item: StreamItem, emit: &mut impl FnMut(StreamEvent)) {
  111. match item {
  112. StreamItem::Content(t) => self.content.push_str(&t),
  113. StreamItem::Reasoning(t) => self.reasoning.push_str(&t),
  114. StreamItem::Usage(u) => {
  115. self.flush(emit);
  116. emit(StreamEvent::Usage { usage: u });
  117. return;
  118. }
  119. }
  120. if self.last.elapsed() >= COALESCE {
  121. self.flush(emit);
  122. }
  123. }
  124. }
  125. /// Stream the reply into the placeholder `assistant` row and save the outcome.
  126. /// Always returns the saved row: complete, cancelled (keeping what arrived), or error.
  127. pub async fn run_turn(
  128. db: &Mutex<Db>,
  129. client: &Client,
  130. req: &ChatRequest,
  131. assistant: &Message,
  132. prices: Option<Prices>,
  133. cancel: &CancellationToken,
  134. mut emit: impl FnMut(StreamEvent),
  135. ) -> Result<Message, DbError> {
  136. let mut co = Coalescer::new();
  137. let out = client.stream_chat(req, cancel, |item| co.push(item, &mut emit)).await;
  138. co.flush(&mut emit);
  139. let got_anything = !out.content.is_empty() || !out.reasoning.is_empty();
  140. let (usage, source) = match (out.usage, got_anything) {
  141. (Some(u), _) => (u, UsageSource::Reported),
  142. // Nothing reported: estimate from what was sent and what came back.
  143. (None, true) => (
  144. Usage {
  145. prompt_tokens: request_tokens(req),
  146. completion_tokens: estimate_tokens(&out.content) + estimate_tokens(&out.reasoning),
  147. cached_tokens: 0,
  148. },
  149. UsageSource::Estimated,
  150. ),
  151. // Refused before a single token: nothing billed.
  152. (None, false) => (Usage::default(), UsageSource::None),
  153. };
  154. let cost = prices.map(|p| cost_nanousd(&usage, &p)).unwrap_or(0);
  155. let status = if out.cancelled {
  156. "cancelled"
  157. } else if out.error.is_some() {
  158. "error"
  159. } else {
  160. "complete"
  161. };
  162. let f = Finalize {
  163. content: out.content.clone(),
  164. reasoning: Some(out.reasoning.clone()),
  165. status,
  166. error_kind: out.error.as_ref().map(|e| e.kind().to_string()),
  167. error_message: out.error.as_ref().map(|e| e.to_string()),
  168. usage,
  169. usage_source: Some(source),
  170. prices,
  171. cost_nanousd: cost,
  172. provider_cost_nanousd: out.provider_cost_usd.map(|c| (c * crate::cost::NANO_PER_USD).round() as i64),
  173. finish_reason: out.finish_reason.clone(),
  174. ttft_ms: out.ttft_ms.map(|v| v as i64),
  175. duration_ms: Some(out.duration_ms as i64),
  176. };
  177. let saved = db.lock().unwrap_or_else(PoisonError::into_inner).finalize_assistant(&assistant.id, &f)?;
  178. match &out.error {
  179. Some(e) => emit(StreamEvent::Error { kind: e.kind().into(), error: e.to_string(), message: saved.clone() }),
  180. None => emit(StreamEvent::Done { message: saved.clone() }),
  181. }
  182. Ok(saved)
  183. }