chat.rsannotatedchat.rssource140 lines · 5.2 KB · raw
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};

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}

The chat as it persists between launches: a rolling summary of everything older, 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>,

When anything last happened, so a relaunch within the hour does not say hello again.

31    #[serde(default)]
32    pub last_active_ms: u64,

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}
38impl ChatState {

Whether this copy of the session is the one to keep over other: the more changed, and 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    }

Takes in another copy of the one session if it is the newer; otherwise changes nothing. Returns 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    }
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}

The chat in one JSON file, replaced atomically on every change.

67#[cfg(not(target_family = "wasm"))]
68pub struct JsonChat {
69    path: PathBuf,
70}
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    }

Like open, but a chat file that cannot be read is moved aside (and 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}
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}

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}