1use std::collections::{BTreeMap, BTreeSet};
8use std::io;
9
10use rig_core::completion::Message;
11use rig_core::conversation::EntryRef;
12use rig_core::message::{AssistantContent, UserContent};
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16use crate::{Files, Session, main_order};
17use rig_core::now_ms;
18
19pub const META: &str = "meta.json";
21
22const TITLE_CHARS: usize = 60;
24
25#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(default)]
32pub struct SessionMeta {
33 pub title: String,
36 pub labels: BTreeMap<String, String>,
38 pub updated: u64,
40 pub model: String,
42 pub messages: usize,
44 #[serde(rename = "data", skip_serializing_if = "BTreeMap::is_empty")]
48 pub listed: BTreeMap<String, Value>,
49}
50
51impl SessionMeta {
52 pub fn read(files: &(impl Files + ?Sized)) -> Option<Self> {
54 serde_json::from_slice(&files.read_file(META).ok()?).ok()
55 }
56
57 pub fn write(&self, files: &(impl Files + ?Sized)) -> io::Result<()> {
63 files.write_file(META, &serde_json::to_vec_pretty(self)?)
64 }
65
66 pub fn relist(&mut self, session: &Session, listed: &BTreeSet<String>) {
73 let mut agents: Vec<_> = session.agents.iter().filter(|a| !a.retired).collect();
74 agents.sort_by(|a, b| {
75 main_order(a.parent.is_some(), &a.id).cmp(&main_order(b.parent.is_some(), &b.id))
76 });
77 let main = agents.first();
78 self.messages = main.map_or(0, |main| main.conversation.messages().len());
79 self.model = main.and_then(|main| main.model()).unwrap_or_default();
80 let keys: BTreeSet<String> = listed.iter().chain(self.listed.keys()).cloned().collect();
81 self.listed = keys
82 .into_iter()
83 .filter_map(|key| {
84 let mut holders = agents.iter().map(|agent| &agent.saved);
85 let value = session
86 .saved
87 .get(&key)
88 .or_else(|| holders.find_map(|state| state.get(&key)))?;
89 Some((key, value.clone()))
90 })
91 .collect();
92 self.updated = now_ms();
93 }
94}
95
96pub fn title_of(text: &str) -> String {
107 let line = text.split_whitespace().collect::<Vec<_>>().join(" ");
108 match line.char_indices().nth(TITLE_CHARS) {
109 None => line,
110 Some(_) => {
111 let kept = line.chars().take(TITLE_CHARS - 1);
112 kept.chain(['…']).collect()
113 }
114 }
115}
116
117pub fn excerpt(entry: &EntryRef<'_>) -> String {
120 let (who, text) = match entry.message {
121 Message::System { content } => ("system", content.clone()),
122 Message::User { content } => {
123 let text: Vec<&str> = content
124 .iter()
125 .filter_map(|part| match part {
126 UserContent::Text(text) => Some(text.text.as_str()),
127 _ => None,
128 })
129 .collect();
130 match text.is_empty() {
131 true => ("tool results", String::new()),
132 false => ("user", text.join(" ")),
133 }
134 }
135 Message::Assistant(turn) => {
136 let text = turn.text();
137 let calls: Vec<&str> = turn
138 .content
139 .iter()
140 .filter_map(|part| match part {
141 AssistantContent::ToolCall(call) => Some(call.function.name.as_str()),
142 _ => None,
143 })
144 .collect();
145 match (text.trim().is_empty(), calls.is_empty()) {
146 (true, false) => ("calls", calls.join(", ")),
147 _ => ("assistant", text),
148 }
149 }
150 };
151 format!("{who}: {}", title_of(&text)).trim_end().to_owned()
152}
153
154#[cfg(test)]
155mod tests;