1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
//! Chat message history persistence.
use anyhow::Result;
use sqlx::Row;
use super::Store;
#[derive(Debug, Clone)]
pub struct MessageRow {
pub id: i64,
pub vctx: String,
pub vtoken: Option<String>,
pub session_name: String,
pub peer_user_id: String,
pub role: String,
pub content: String,
pub created_at: String,
}
#[derive(Debug, Clone)]
pub struct SessionStatusEntry {
pub session_name: String,
pub last_user_content: Option<String>,
/// `true` when the last stored message is from the user (AI has not replied yet).
pub waiting_for_reply: bool,
/// ISO-8601 timestamp of the last user message — used to compute elapsed time
/// when `waiting_for_reply` is true. `None` when there are no user messages.
pub user_msg_created_at: Option<String>,
}
/// One warmup row: a recent outbound (assistant) message from `messages`.
///
/// `from_user` is the conversation scope (`peer_user_id`) the Hub sent the
/// message into — what `QuoteRouteIndex::register_outbound_content` keys on.
/// `created_at` is the message's persisted timestamp (ISO-8601 string) used
/// by the index to break ties when identical text was sent at the same
/// second; we expose it raw and let the index decide what to do with it.
#[derive(Debug, Clone)]
pub struct RecentOutboundRow {
pub from_user: String,
pub text: String,
pub vtoken: Option<String>,
pub session_name: String,
pub created_at: String,
}
impl Store {
pub async fn save_message(
&self,
vctx: &str,
vtoken: Option<&str>,
session_name: &str,
peer_user_id: &str,
role: &str,
content: &str,
) -> Result<()> {
sqlx::query(
"INSERT INTO messages (vctx, vtoken, session_name, peer_user_id, role, content) \
VALUES ($1, $2, $3, $4, $5, $6)",
)
.bind(vctx)
.bind(vtoken)
.bind(session_name)
.bind(peer_user_id)
.bind(role)
.bind(content)
.execute(&self.pool)
.await?;
Ok(())
}
/// Load the most recent N outbound messages (`role = 'assistant'`) from
/// the `messages` table, newest first.
///
/// `limit` is clamped to `[1, 10000]` to keep a runaway startup arg from
/// materialising an unbounded number of rows into memory.
///
/// The query selects the columns needed to repopulate the in-memory
/// `QuoteRouteIndex`: `peer_user_id` (the conversation scope), `content`
/// (the text the index keys on), `vtoken` (the destination client), and
/// `session_name` (the active session). Inbound (`role = 'user'`) rows are
/// skipped — only the Hub's outbound replies are ever indexed.
pub async fn recent_outbound_messages(&self, limit: i64) -> Result<Vec<RecentOutboundRow>> {
let clamped = limit.clamp(1, 10_000);
let rows = sqlx::query(
"SELECT peer_user_id, content, vtoken, session_name, created_at \
FROM messages \
WHERE role = 'assistant' AND content IS NOT NULL AND content != '' \
ORDER BY id DESC LIMIT $1",
)
.bind(clamped)
.fetch_all(&self.rpool)
.await?;
Ok(rows
.into_iter()
.map(|r| RecentOutboundRow {
from_user: r.get("peer_user_id"),
text: r.get("content"),
vtoken: r.get("vtoken"),
session_name: r.get("session_name"),
created_at: r.get("created_at"),
})
.collect())
}
/// Find the most recent assistant message in a conversation whose content starts with
/// `content_prefix`. Used as DB-backed fallback for quote-reply routing when the
/// in-memory QuoteRouteIndex is cold (e.g. after a Hub restart).
pub async fn find_assistant_message_by_content(
&self,
peer_user_id: &str,
content_prefix: &str,
) -> Result<Option<(String, Option<String>)>> {
// LIKE pattern: escape '%' and '_' in the prefix to avoid wildcard interpretation.
let escaped = content_prefix
.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_");
let pattern = format!("{escaped}%");
let row = sqlx::query(
"SELECT vtoken, session_name FROM messages \
WHERE peer_user_id = $1 AND role = 'assistant' AND content LIKE $2 ESCAPE '\\' \
ORDER BY created_at DESC LIMIT 1",
)
.bind(peer_user_id)
.bind(&pattern)
.fetch_optional(&self.rpool)
.await?;
Ok(row.map(|r| {
let vtoken: Option<String> = r.get("vtoken");
let session_name: String = r.get("session_name");
(vtoken.unwrap_or_default(), Some(session_name))
}))
}
pub async fn list_messages(&self, vctx: &str, limit: i64) -> Result<Vec<MessageRow>> {
let rows = sqlx::query(
"SELECT id, vctx, vtoken, session_name, peer_user_id, role, content, created_at \
FROM messages WHERE vctx = $1 ORDER BY created_at DESC LIMIT $2",
)
.bind(vctx)
.bind(limit)
.fetch_all(&self.rpool)
.await?;
Ok(rows
.into_iter()
.map(|r| MessageRow {
id: r.get("id"),
vctx: r.get("vctx"),
vtoken: r.get("vtoken"),
session_name: r.get("session_name"),
peer_user_id: r.get("peer_user_id"),
role: r.get("role"),
content: r.get("content"),
created_at: r.get("created_at"),
})
.collect())
}
/// Load messages for a specific (vtoken, session_name) pair, newest first.
///
/// `limit` is clamped to `[1, 500]`. Returns rows ordered by id DESC (most recent first);
/// callers that want chronological order should reverse the result.
pub async fn list_messages_for_session(
&self,
vtoken: &str,
session_name: &str,
limit: i64,
) -> Result<Vec<MessageRow>> {
let clamped = limit.clamp(1, 500);
let rows = sqlx::query(
"SELECT id, vctx, vtoken, session_name, peer_user_id, role, content, created_at \
FROM messages WHERE vtoken = $1 AND session_name = $2 ORDER BY id DESC LIMIT $3",
)
.bind(vtoken)
.bind(session_name)
.bind(clamped)
.fetch_all(&self.rpool)
.await?;
Ok(rows
.into_iter()
.map(|r| MessageRow {
id: r.get("id"),
vctx: r.get("vctx"),
vtoken: r.get("vtoken"),
session_name: r.get("session_name"),
peer_user_id: r.get("peer_user_id"),
role: r.get("role"),
content: r.get("content"),
created_at: r.get("created_at"),
})
.collect())
}
/// For each vtoken in the list, return a summary of the most recent conversation turn:
/// `(session_name, last_user_content, waiting_for_reply)`.
///
/// `waiting_for_reply` is `true` when the last stored message for that vtoken has
/// `role = 'user'` — meaning the user sent a message but the assistant has not yet
/// written a reply back to the messages table.
///
/// Uses `MAX(id)` (autoincrement, strictly monotone) as the "latest row" selector,
/// which is stable even when two rows share the same `created_at` second — portable
/// across SQLite, PostgreSQL, and MySQL.
#[allow(dead_code)]
pub async fn get_session_status_per_vtoken(
&self,
vtokens: &[String],
) -> Result<std::collections::HashMap<String, SessionStatusEntry>> {
if vtokens.is_empty() {
return Ok(std::collections::HashMap::new());
}
// Round-trip 1 (was 2): join messages against a per-vtoken MAX(id) subquery to
// retrieve the latest row's role and session_name in a single SQL statement.
let mut qb = sqlx::QueryBuilder::<sqlx::Any>::new(
"SELECT m.vtoken, m.session_name, m.role \
FROM messages m \
INNER JOIN (SELECT vtoken, MAX(id) AS max_id FROM messages \
WHERE vtoken IN (",
);
{
let mut sep = qb.separated(", ");
for vt in vtokens {
sep.push_bind(vt.as_str());
}
}
qb.push(") GROUP BY vtoken) t ON m.id = t.max_id");
let latest_rows = qb.build().fetch_all(&self.rpool).await?;
if latest_rows.is_empty() {
return Ok(std::collections::HashMap::new());
}
// Round-trip 2 (was 2): same approach for the latest user-role message per vtoken,
// fetching content and created_at for the status snippet and elapsed-time display.
let mut qb2 = sqlx::QueryBuilder::<sqlx::Any>::new(
"SELECT m.vtoken, m.content, m.created_at \
FROM messages m \
INNER JOIN (SELECT vtoken, MAX(id) AS max_id FROM messages \
WHERE role = 'user' AND vtoken IN (",
);
{
let mut sep = qb2.separated(", ");
for vt in vtokens {
sep.push_bind(vt.as_str());
}
}
qb2.push(") GROUP BY vtoken) t ON m.id = t.max_id");
let user_rows = qb2.build().fetch_all(&self.rpool).await?;
// (vtoken → (content, created_at))
let mut user_content_map: std::collections::HashMap<String, (String, String)> =
std::collections::HashMap::new();
for row in user_rows {
let vtoken: String = row.get("vtoken");
let content: String = row.get("content");
let created_at: String = row.get("created_at");
user_content_map
.entry(vtoken)
.or_insert((content, created_at));
}
let mut map = std::collections::HashMap::new();
for row in latest_rows {
let vtoken: String = row.get("vtoken");
let session: String = row.get("session_name");
let role: String = row.get("role");
let waiting = role == "user";
let (last_user_msg, user_msg_created_at) = user_content_map
.get(&vtoken)
.map(|(c, t)| (Some(c.clone()), Some(t.clone())))
.unwrap_or((None, None));
map.entry(vtoken).or_insert(SessionStatusEntry {
session_name: session,
last_user_content: last_user_msg,
waiting_for_reply: waiting,
user_msg_created_at,
});
}
Ok(map)
}
/// Return all active sessions (grouped by session_name) for the given vtokens.
///
/// For each (vtoken, session_name) pair that has at least one message, returns a
/// `SessionStatusEntry` with the latest user message and whether the AI has replied.
/// Results are ordered by the timestamp of the most recent message in that session
/// (most recent first within each vtoken).
pub async fn get_all_session_entries_per_vtoken(
&self,
vtokens: &[String],
) -> Result<std::collections::HashMap<String, Vec<SessionStatusEntry>>> {
if vtokens.is_empty() {
return Ok(std::collections::HashMap::new());
}
// Round-trip 1 (was 2): per (vtoken, session_name) MAX(id) subquery joined back to
// messages to retrieve the latest row's role in one statement.
// ORDER BY t.max_id DESC preserves "most recent session first" ordering.
let mut qb = sqlx::QueryBuilder::<sqlx::Any>::new(
"SELECT m.vtoken, m.session_name, m.role, t.max_id \
FROM messages m \
INNER JOIN (SELECT vtoken, session_name, MAX(id) AS max_id FROM messages \
WHERE vtoken IN (",
);
{
let mut sep = qb.separated(", ");
for vt in vtokens {
sep.push_bind(vt.as_str());
}
}
qb.push(") GROUP BY vtoken, session_name) t ON m.id = t.max_id ORDER BY t.max_id DESC");
let latest_rows = qb.build().fetch_all(&self.rpool).await?;
if latest_rows.is_empty() {
return Ok(std::collections::HashMap::new());
}
// Build role map from the single result set.
// (vtoken, session_name) → role of the latest message
let mut role_map: std::collections::HashMap<(String, String), String> =
std::collections::HashMap::new();
for row in &latest_rows {
let vt: String = row.get("vtoken");
let sn: String = row.get("session_name");
let role: String = row.get("role");
role_map.insert((vt, sn), role);
}
// Round-trip 2 (was 2): same approach for the latest user-role message per
// (vtoken, session_name), fetching content and created_at together.
let mut qb2 = sqlx::QueryBuilder::<sqlx::Any>::new(
"SELECT m.vtoken, m.session_name, m.content, m.created_at \
FROM messages m \
INNER JOIN (SELECT vtoken, session_name, MAX(id) AS max_id FROM messages \
WHERE role = 'user' AND vtoken IN (",
);
{
let mut sep = qb2.separated(", ");
for vt in vtokens {
sep.push_bind(vt.as_str());
}
}
qb2.push(") GROUP BY vtoken, session_name) t ON m.id = t.max_id");
let user_rows = qb2.build().fetch_all(&self.rpool).await?;
// (vtoken, session_name) → (content, created_at)
let mut user_content_map: std::collections::HashMap<(String, String), (String, String)> =
std::collections::HashMap::new();
for row in user_rows {
let vt: String = row.get("vtoken");
let sn: String = row.get("session_name");
let content: String = row.get("content");
let created_at: String = row.get("created_at");
user_content_map.insert((vt, sn), (content, created_at));
}
// Assemble result — preserve order from latest_rows (most recent first).
let mut map: std::collections::HashMap<String, Vec<SessionStatusEntry>> =
std::collections::HashMap::new();
for row in &latest_rows {
let vt: String = row.get("vtoken");
let sn: String = row.get("session_name");
let role = role_map
.get(&(vt.clone(), sn.clone()))
.map(String::as_str)
.unwrap_or("assistant");
let waiting = role == "user";
let (last_user_content, user_msg_created_at) = user_content_map
.get(&(vt.clone(), sn.clone()))
.map(|(c, t)| (Some(c.clone()), Some(t.clone())))
.unwrap_or((None, None));
map.entry(vt).or_default().push(SessionStatusEntry {
session_name: sn,
last_user_content,
waiting_for_reply: waiting,
user_msg_created_at,
});
}
Ok(map)
}
}