1#[cfg(not(target_family = "wasm"))] 2use std::fs; 3#[cfg(not(target_family = "wasm"))] 4use std::path::{Path, PathBuf}; 5 6#[cfg(not(target_family = "wasm"))] 7use ::log::{debug, error, info, warn}; 8use ::log::trace; 9use serde::{Deserialize, Serialize}; 10 11#[cfg(not(target_family = "wasm"))] 12use crate::ports::{ChatError, ChatStore}; 13use crate::types::{Speaker, Turn}; 14 15/// One line of the running chat: words only. 16#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 17pub struct ChatTurn { 18 pub speaker: Speaker, 19 pub text: String, 20} 21 22/// The chat as it persists between launches: a rolling summary of everything older, 23/// and the recent turns word for word. It never grows past the compression threshold. 24#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] 25pub struct ChatState { 26 #[serde(default)] 27 pub summary: String, 28 #[serde(default)] 29 pub turns: Vec<ChatTurn>, 30 /// When anything last happened, so a relaunch within the hour does not say hello again. 31 #[serde(default)] 32 pub last_active_ms: u64, 33 /// Counts every change, wherever it was made, so the newest copy of the one session can be told. 34 #[serde(default)] 35 pub version: u64, 36} 37 38impl ChatState { 39 /// Whether this copy of the session is the one to keep over `other`: the more changed, and 40 /// on a tie the more recently active. 41 pub fn is_newer_than(&self, other: &ChatState) -> bool { 42 let newer = (self.version, self.last_active_ms) > (other.version, other.last_active_ms); 43 trace!( 44 "chat compare: mine v{} at {}, other v{} at {}, mine newer = {newer}", 45 self.version, self.last_active_ms, other.version, other.last_active_ms 46 ); 47 newer 48 } 49 50 /// Takes in another copy of the one session if it is the newer; otherwise changes nothing. Returns 51 /// whether it did. Pure: where the result is kept is the holder's business. 52 pub fn merge(&mut self, theirs: ChatState) -> bool { 53 if theirs.is_newer_than(self) { 54 *self = theirs; 55 true 56 } else { 57 false 58 } 59 } 60 61 pub fn turns_for_model(&self) -> Vec<Turn> { 62 self.turns.iter().map(|t| Turn::said(t.speaker, t.text.clone())).collect() 63 } 64} 65 66/// The chat in one JSON file, replaced atomically on every change. 67#[cfg(not(target_family = "wasm"))] 68pub struct JsonChat { 69 path: PathBuf, 70} 71 72#[cfg(not(target_family = "wasm"))] 73impl JsonChat { 74 pub fn open(path: &Path) -> Self { 75 debug!("chat store at {}", path.display()); 76 Self { path: path.to_owned() } 77 } 78 79 /// Like [`open`](Self::open), but a chat file that cannot be read is moved aside (and 80 /// returned) and the chat starts empty, rather than the app refusing to start. 81 pub fn open_or_set_aside(path: &Path) -> Result<(Self, Option<PathBuf>), ChatError> { 82 let chat = Self::open(path); 83 match chat.load() { 84 Ok(_) => Ok((chat, None)), 85 Err(e) if e.0.contains("damaged") => { 86 warn!("chat file {} is damaged ({}); moving it aside and starting empty", path.display(), e.0); 87 let aside = crate::atomic::set_aside(path).map_err(|e| { 88 error!("could not move the damaged chat file aside: {e}"); 89 ChatError(e.to_string()) 90 })?; 91 info!("damaged chat file kept as {}", aside.display()); 92 Ok((Self::open(path), Some(aside))) 93 } 94 Err(e) => { 95 error!("chat file {} cannot be opened: {}", path.display(), e.0); 96 Err(e) 97 } 98 } 99 } 100} 101 102#[cfg(not(target_family = "wasm"))] 103impl ChatStore for JsonChat { 104 fn load(&self) -> Result<ChatState, ChatError> { 105 match fs::read_to_string(&self.path) { 106 Ok(text) => { 107 let state: ChatState = serde_json::from_str(&text).map_err(|e| { 108 warn!("chat file {} does not parse: {e}", self.path.display()); 109 ChatError(format!("chat file is damaged: {e}")) 110 })?; 111 debug!("chat loaded: {} turns, version {}, summary {} bytes", state.turns.len(), state.version, state.summary.len()); 112 Ok(state) 113 } 114 Err(e) if e.kind() == std::io::ErrorKind::NotFound => { 115 debug!("no chat file at {}; starting empty", self.path.display()); 116 Ok(ChatState::default()) 117 } 118 Err(e) => { 119 error!("chat file {} unreadable: {e}", self.path.display()); 120 Err(ChatError(e.to_string())) 121 } 122 } 123 } 124 125 fn save(&mut self, state: &ChatState) -> Result<(), ChatError> { 126 let text = serde_json::to_string(state).map_err(|e| { 127 error!("chat state does not serialize: {e}"); 128 ChatError(e.to_string()) 129 })?; 130 debug!("saving chat: {} turns, version {}, {} bytes", state.turns.len(), state.version, text.len()); 131 crate::atomic::write_atomic(&self.path, text.as_bytes()).map_err(|e| ChatError(e.to_string())) 132 } 133} 134 135/// What the compressor needs: the old summary, and the oldest `n` turns to fold into it. 136#[derive(Clone, Debug, PartialEq, Eq)] 137pub struct CompressPlan { 138 pub summary: String, 139 pub oldest: Vec<ChatTurn>, 140}