Long chats stay fast; search in a chat; media overview
Performance (measured with 5,900 messages: open 9 s -> 0.7 s, typing 440 ms -> 25 ms)
- GET /sessions/{id}?limit=N loads the end of a chat (from the last summary
on, at most N); /sessions/{id}/messages?before|after|around loads more
(scrolling up stops at a summary unless asked). Without parameters the
full history as before (terminal client).
- Chat view: memoised message list (typing, streaming and status changes no
longer re-render the history), finished turns fold into "N steps · M files"
with the answer visible, tool card bodies and summaries are built only when
opened, scroll-to-bottom only when content changes.
Search: /sessions/{id}/search over the whole chat; a hit loads its part
(around) and marks the message, "back to the latest messages" returns.
Media: /sessions/{id}/media lists images, videos and audio of a chat and its
subagents (generated images, tool output, files written, media in answers,
attachments); ⋯ -> Media shows them with filters, a large view and a jump to
the message.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6e9eda28b5
commit
3b0764450a
14 files changed
+1278
-99
No files matched your search
+73
-3
@@ -81,6 +81,9 @@ pub fn router(app: AppState) -> Router {
|
||||
.route("/sessions/{id}/tabs", get(session_tabs).put(put_file_tabs))
|
||||
.route("/sessions/{id}/export", get(export_session))
|
||||
.route("/blobs/{hash}", get(get_blob))
|
||||
.route("/sessions/{id}/messages", get(session_messages))
|
||||
.route("/sessions/{id}/search", get(search_in_session))
|
||||
.route("/sessions/{id}/media", get(session_media))
|
||||
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
|
||||
.route("/sessions/{id}/prompt", post(prompt))
|
||||
.route("/sessions/{id}/abort", post(abort))
|
||||
@@ -813,14 +816,81 @@ async fn export_session(Extension(user): Extension<User>, State(app): State<AppS
|
||||
.into_response())
|
||||
}
|
||||
|
||||
async fn get_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
#[derive(Deserialize)]
|
||||
struct WindowQ {
|
||||
/// Only the end of the chat (from the last summary on, at most this many messages);
|
||||
/// without it, all messages (terminal client).
|
||||
limit: Option<i64>,
|
||||
before: Option<String>,
|
||||
after: Option<String>,
|
||||
around: Option<String>,
|
||||
/// Loading older messages stops at a summary (the history before it on request).
|
||||
#[serde(default)]
|
||||
stop_at_summary: bool,
|
||||
}
|
||||
|
||||
impl WindowQ {
|
||||
fn window(&self) -> Option<crate::store::MessageWindow> {
|
||||
use crate::store::MessageWindow as W;
|
||||
let limit = self.limit.unwrap_or(200).clamp(1, 1000);
|
||||
Some(match (&self.before, &self.after, &self.around) {
|
||||
(Some(id), _, _) => W::Before { id: id.clone(), limit, stop_at_summary: self.stop_at_summary },
|
||||
(_, Some(id), _) => W::After { id: id.clone(), limit },
|
||||
(_, _, Some(id)) => W::Around { id: id.clone(), limit },
|
||||
_ if self.limit.is_some() => W::Tail { limit },
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<WindowQ>) -> ApiResult {
|
||||
let s = readable(&app, &user, &id)?;
|
||||
let messages = app.store.messages(&id)?;
|
||||
let busy = app.is_busy(&id);
|
||||
let can_write = app.access(&user, &id) == Access::Full;
|
||||
let queue = app.store.queue_list(&id)?;
|
||||
let step = app.with_runtime(&id, |r| r.step.clone());
|
||||
Ok(Json(json!({"session": s, "messages": messages, "busy": busy, "can_write": can_write, "queue": queue, "step": step})))
|
||||
let mut out = json!({"session": s, "busy": busy, "can_write": can_write, "queue": queue, "step": step});
|
||||
match q.window() {
|
||||
Some(w) => {
|
||||
let (messages, older, newer) = app.store.messages_window(&id, &w)?;
|
||||
out["messages"] = json!(messages);
|
||||
out["has_older"] = json!(older);
|
||||
out["has_newer"] = json!(newer);
|
||||
}
|
||||
None => out["messages"] = json!(app.store.messages(&id)?),
|
||||
}
|
||||
Ok(Json(out))
|
||||
}
|
||||
|
||||
/// More messages of a chat: before / after / around a message.
|
||||
async fn session_messages(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<WindowQ>) -> ApiResult {
|
||||
readable(&app, &user, &id)?;
|
||||
let w = q.window().ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "before, after, around or limit is required".into()))?;
|
||||
let (messages, older, newer) = app.store.messages_window(&id, &w).map_err(|e| ApiError(StatusCode::NOT_FOUND, format!("{e:#}")))?;
|
||||
Ok(Json(json!({"messages": messages, "has_older": older, "has_newer": newer})))
|
||||
}
|
||||
|
||||
/// Images, videos and audio files shown in a chat (all of it, with subagents).
|
||||
async fn session_media(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
readable(&app, &user, &id)?;
|
||||
let a = app.clone();
|
||||
let items = tokio::task::spawn_blocking(move || crate::media::collect(&a, &id)).await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))??;
|
||||
Ok(Json(json!(items)))
|
||||
}
|
||||
|
||||
/// Search within one chat (all of it, not only the loaded part).
|
||||
async fn search_in_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<SearchQ>) -> ApiResult {
|
||||
readable(&app, &user, &id)?;
|
||||
if q.q.trim().chars().count() < 2 {
|
||||
return Ok(Json(json!([])));
|
||||
}
|
||||
let hits: Vec<Value> = app
|
||||
.store
|
||||
.search_messages(&id, q.q.trim(), 100)?
|
||||
.into_iter()
|
||||
.map(|(mid, role, at, snippet)| json!({"id": mid, "role": role, "created_at": at, "snippet": snippet}))
|
||||
.collect();
|
||||
Ok(Json(json!(hits)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
||||
@@ -22,6 +22,7 @@ mod lsp;
|
||||
mod mentions;
|
||||
mod metrics;
|
||||
mod mcp;
|
||||
mod media;
|
||||
mod permission;
|
||||
mod prompts;
|
||||
mod push;
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
//! Media overview of a chat: every image, video and audio file that was shown in it –
|
||||
//! generated images, images in tool results (screenshots), media files the agent wrote,
|
||||
//! media referenced in answers, and the user's own attachments. Includes subagents.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::types::{Message, Part, Role};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq)]
|
||||
pub struct MediaItem {
|
||||
/// "image", "video" or "audio".
|
||||
pub kind: &'static str,
|
||||
pub url: String,
|
||||
pub name: String,
|
||||
/// "generated", "tool", "written", "answer" or "upload".
|
||||
pub source: &'static str,
|
||||
pub created_at: i64,
|
||||
/// Message to jump to (only for messages of the chat itself, not of subagents).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message_id: Option<String>,
|
||||
}
|
||||
|
||||
const IMAGE: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif"];
|
||||
const VIDEO: &[&str] = &["mp4", "webm", "mov", "m4v", "ogv"];
|
||||
const AUDIO: &[&str] = &["mp3", "wav", "ogg", "oga", "m4a", "flac", "opus", "aac"];
|
||||
|
||||
/// Kind of a media file by its extension.
|
||||
pub fn kind_of(path: &str) -> Option<&'static str> {
|
||||
let ext = Path::new(path.split(['?', '#']).next().unwrap_or(path)).extension()?.to_str()?.to_ascii_lowercase();
|
||||
if IMAGE.contains(&ext.as_str()) {
|
||||
Some("image")
|
||||
} else if VIDEO.contains(&ext.as_str()) {
|
||||
Some("video")
|
||||
} else if AUDIO.contains(&ext.as_str()) {
|
||||
Some("audio")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_of_mime(mime: &str) -> &'static str {
|
||||
if mime.starts_with("video/") {
|
||||
"video"
|
||||
} else if mime.starts_with("audio/") {
|
||||
"audio"
|
||||
} else {
|
||||
"image"
|
||||
}
|
||||
}
|
||||
|
||||
/// Like JavaScript's encodeURIComponent.
|
||||
fn encode(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for b in s.bytes() {
|
||||
if b.is_ascii_alphanumeric() || b"-_.!~*'()".contains(&b) {
|
||||
out.push(b as char);
|
||||
} else {
|
||||
out.push_str(&format!("%{b:02X}"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Absolute, normalised path of `p` (relative to `cwd`).
|
||||
fn resolve(p: &str, cwd: &Path) -> PathBuf {
|
||||
let joined = if p.starts_with('/') { PathBuf::from(p) } else { cwd.join(p) };
|
||||
crate::tools::normalize(&joined)
|
||||
}
|
||||
|
||||
/// Media file paths in a text: Markdown images/links and plain paths with a media extension.
|
||||
fn paths_in(text: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for token in text.split(|c: char| c.is_whitespace() || "()[]<>\"'`,;".contains(c)) {
|
||||
let t = token.trim_end_matches(['.', ':', '!', '?']);
|
||||
if t.is_empty() || t.contains("://") || t.starts_with("data:") {
|
||||
continue;
|
||||
}
|
||||
if kind_of(t).is_some() && t.chars().any(|c| c == '/' || c == '.') {
|
||||
out.push(t.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// All media of a chat and its subagents, newest first.
|
||||
pub fn collect(app: &App, session_id: &str) -> anyhow::Result<Vec<MediaItem>> {
|
||||
let mut sessions = vec![session_id.to_string()];
|
||||
// Subagents (and theirs).
|
||||
let mut i = 0;
|
||||
while i < sessions.len() && sessions.len() < 500 {
|
||||
let children = app.store.child_sessions(&sessions[i])?;
|
||||
sessions.extend(children);
|
||||
i += 1;
|
||||
}
|
||||
let mut out: Vec<MediaItem> = Vec::new();
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
let mut push = |item: MediaItem, out: &mut Vec<MediaItem>| {
|
||||
if seen.insert(item.url.clone()) {
|
||||
out.push(item);
|
||||
}
|
||||
};
|
||||
for sid in &sessions {
|
||||
let Some(session) = app.store.get_session(sid)? else { continue };
|
||||
let cwd = PathBuf::from(&session.cwd);
|
||||
let own = sid == session_id;
|
||||
// Tool results are shown in the card of the call: jump to that message.
|
||||
let mut call_msg: Option<String> = None;
|
||||
for m in app.store.messages(sid)? {
|
||||
collect_message(&m, &cwd, own, call_msg.as_deref(), &mut |item| push(item, &mut out));
|
||||
if m.role == Role::Assistant {
|
||||
call_msg = Some(m.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Generated images whose tool result was not found (e.g. removed by a rewind).
|
||||
for img in app.store.images_of_sessions(&sessions)? {
|
||||
push(
|
||||
MediaItem { kind: "image", url: format!("/api/images/{}/file", img.id), name: file_name(&img.path), source: "generated", created_at: img.created_at, message_id: None },
|
||||
&mut out,
|
||||
);
|
||||
}
|
||||
// Names of generated images (their files).
|
||||
let names: std::collections::HashMap<String, String> = app.store.images_of_sessions(&sessions)?.into_iter().map(|i| (format!("/api/images/{}/file", i.id), file_name(&i.path))).collect();
|
||||
for item in &mut out {
|
||||
if let Some(n) = names.get(&item.url) {
|
||||
item.name = n.clone();
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn file_name(p: &str) -> String {
|
||||
Path::new(p).file_name().and_then(|n| n.to_str()).unwrap_or(p).to_string()
|
||||
}
|
||||
|
||||
fn collect_message(m: &Message, cwd: &Path, own: bool, call_msg: Option<&str>, add: &mut dyn FnMut(MediaItem)) {
|
||||
let mid = own.then(|| m.id.clone());
|
||||
let result_mid = own.then(|| call_msg.unwrap_or(&m.id).to_string());
|
||||
let at = m.created_at;
|
||||
let file = |p: &str, source: &'static str| -> Option<MediaItem> {
|
||||
let kind = kind_of(p)?;
|
||||
let abs = resolve(p, cwd);
|
||||
abs.is_file().then(|| MediaItem { kind, url: format!("/api/files/raw?path={}", encode(&abs.display().to_string())), name: file_name(p), source, created_at: at, message_id: mid.clone() })
|
||||
};
|
||||
for p in &m.parts {
|
||||
match p {
|
||||
Part::Image { image } if m.role == Role::User => {
|
||||
if let Some(h) = &image.blob {
|
||||
add(MediaItem { kind: kind_of_mime(&image.media_type), url: format!("/api/blobs/{h}"), name: "image".into(), source: "upload", created_at: at, message_id: mid.clone() });
|
||||
}
|
||||
}
|
||||
Part::ToolResult { images, meta, .. } => {
|
||||
let ids: Vec<&str> = meta.as_ref().and_then(|v| v["image_ids"].as_array()).map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
|
||||
if !ids.is_empty() {
|
||||
for id in ids {
|
||||
add(MediaItem { kind: "image", url: format!("/api/images/{id}/file"), name: "image".into(), source: "generated", created_at: at, message_id: result_mid.clone() });
|
||||
}
|
||||
} else {
|
||||
for img in images {
|
||||
if let Some(h) = &img.blob {
|
||||
add(MediaItem { kind: kind_of_mime(&img.media_type), url: format!("/api/blobs/{h}"), name: "screenshot".into(), source: "tool", created_at: at, message_id: result_mid.clone() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Part::ToolCall { name, input, .. } if matches!(name.as_str(), "write" | "edit") => {
|
||||
if let Some(item) = input["path"].as_str().and_then(|p| file(p, "written")) {
|
||||
add(item);
|
||||
}
|
||||
}
|
||||
Part::Text { text } if m.role == Role::Assistant => {
|
||||
for p in paths_in(text) {
|
||||
if let Some(item) = file(&p, "answer") {
|
||||
add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn kinds_paths_and_encoding() {
|
||||
assert_eq!(kind_of("a/b.PNG"), Some("image"));
|
||||
assert_eq!(kind_of("clip.mp4?v=1"), Some("video"));
|
||||
assert_eq!(kind_of("song.flac"), Some("audio"));
|
||||
assert_eq!(kind_of("main.rs"), None);
|
||||
assert_eq!(paths_in("Saved to `art/final/hero.png` and . See https://x.org/y.png"), vec!["art/final/hero.png", "shots/a.webp"]);
|
||||
assert_eq!(encode("/w/ä b.png"), "%2Fw%2F%C3%A4%20b.png");
|
||||
}
|
||||
}
|
||||
+170
-23
@@ -1,6 +1,6 @@
|
||||
//! SQLite persistence for sessions and messages.
|
||||
|
||||
use crate::types::{ImageData, Message, Part, Session, now_ms};
|
||||
use crate::types::{ImageData, Message, Part, Role, Session, now_ms};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use anyhow::Result;
|
||||
@@ -244,28 +244,7 @@ impl Store {
|
||||
let (sdata, mdata) = row?;
|
||||
let session: Session = serde_json::from_str(&sdata)?;
|
||||
// Snippet from the visible text of the matching message (not JSON keys).
|
||||
let mut snippet = String::new();
|
||||
if let Some(m) = mdata.and_then(|d| serde_json::from_str::<Message>(&d).ok()) {
|
||||
for p in &m.parts {
|
||||
let text = match p {
|
||||
Part::Text { text } | Part::Summary { text, .. } | Part::Reasoning { text, .. } => text.as_str(),
|
||||
Part::ToolResult { content, .. } => content.as_str(),
|
||||
_ => continue,
|
||||
};
|
||||
if let Some(i) = text.to_lowercase().find(&needle) {
|
||||
let mut a = i.saturating_sub(60);
|
||||
while !text.is_char_boundary(a) {
|
||||
a -= 1;
|
||||
}
|
||||
let mut b = (i + needle.len() + 100).min(text.len());
|
||||
while !text.is_char_boundary(b) {
|
||||
b += 1;
|
||||
}
|
||||
snippet = format!("{}{}{}", if a > 0 { "…" } else { "" }, text[a..b].replace('\n', " "), if b < text.len() { "…" } else { "" });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let snippet = mdata.and_then(|d| serde_json::from_str::<Message>(&d).ok()).and_then(|m| snippet_in(&m, &needle)).unwrap_or_default();
|
||||
if snippet.is_empty() && !session.title.to_lowercase().contains(&needle) {
|
||||
continue; // matched only JSON structure (keys, ids)
|
||||
}
|
||||
@@ -274,6 +253,121 @@ impl Store {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A part of a session's messages for the chat view (long chats are not loaded at
|
||||
/// once). Returns the messages (oldest first) and whether older / newer ones exist.
|
||||
pub fn messages_window(&self, session_id: &str, w: &MessageWindow) -> Result<(Vec<Message>, bool, bool)> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let seq_of = |id: &str| -> Result<i64> {
|
||||
conn.query_row("SELECT seq FROM messages WHERE session_id = ?1 AND id = ?2", params![session_id, id], |r| r.get(0))
|
||||
.optional()?
|
||||
.ok_or_else(|| anyhow::anyhow!("message {id} not found"))
|
||||
};
|
||||
let (sql, a, limit): (&str, i64, i64) = match w {
|
||||
// From the last summary (compaction) on, at most `limit`.
|
||||
MessageWindow::Tail { limit } => {
|
||||
let from: i64 = conn
|
||||
.query_row(
|
||||
"SELECT seq FROM messages WHERE session_id = ?1 AND data LIKE '%\"type\":\"summary\"%' ORDER BY seq DESC LIMIT 1",
|
||||
[session_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?
|
||||
.unwrap_or(i64::MIN);
|
||||
("SELECT data FROM messages WHERE session_id = ?1 AND seq >= ?2 ORDER BY seq DESC LIMIT ?3", from, *limit)
|
||||
}
|
||||
// Scrolling up stops at a summary (compaction); the history before it on request.
|
||||
MessageWindow::Before { id, limit, stop_at_summary } => {
|
||||
let before = seq_of(id)?;
|
||||
let summary: Option<i64> = if *stop_at_summary {
|
||||
conn.query_row(
|
||||
"SELECT seq FROM messages WHERE session_id = ?1 AND seq < ?2 AND data LIKE '%\"type\":\"summary\"%' ORDER BY seq DESC LIMIT 1",
|
||||
params![session_id, before],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match summary {
|
||||
Some(from) => {
|
||||
let mut stmt = conn.prepare("SELECT seq, data FROM messages WHERE session_id = ?1 AND seq >= ?2 AND seq < ?3 ORDER BY seq DESC LIMIT ?4")?;
|
||||
let mut rows: Vec<(i64, Message)> = Vec::new();
|
||||
for r in stmt.query_map(params![session_id, from, before, limit], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))? {
|
||||
let (seq, data) = r?;
|
||||
rows.push((seq, serde_json::from_str(&data)?));
|
||||
}
|
||||
drop(stmt);
|
||||
return Self::finish_window(&conn, session_id, rows, false);
|
||||
}
|
||||
None => ("SELECT data FROM messages WHERE session_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3", before, *limit),
|
||||
}
|
||||
}
|
||||
MessageWindow::After { id, limit } => ("SELECT data FROM messages WHERE session_id = ?1 AND seq > ?2 ORDER BY seq ASC LIMIT ?3", seq_of(id)?, *limit),
|
||||
// The message and about half the limit before it.
|
||||
MessageWindow::Around { id, limit } => {
|
||||
let seq = seq_of(id)?;
|
||||
let before: i64 = conn.query_row(
|
||||
"SELECT COALESCE(MIN(seq), ?2) FROM (SELECT seq FROM messages WHERE session_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3)",
|
||||
params![session_id, seq, limit / 2],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
("SELECT data FROM messages WHERE session_id = ?1 AND seq >= ?2 ORDER BY seq ASC LIMIT ?3", before, *limit)
|
||||
}
|
||||
};
|
||||
let mut rows: Vec<(i64, Message)> = Vec::new();
|
||||
let mut stmt = conn.prepare(&sql.replace("SELECT data", "SELECT seq, data"))?;
|
||||
for r in stmt.query_map(params![session_id, a, limit], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))? {
|
||||
let (seq, data) = r?;
|
||||
rows.push((seq, serde_json::from_str(&data)?));
|
||||
}
|
||||
drop(stmt);
|
||||
Self::finish_window(&conn, session_id, rows, matches!(w, MessageWindow::Tail { .. }))
|
||||
}
|
||||
|
||||
/// Sort a window and tell whether older / newer messages exist around it.
|
||||
fn finish_window(conn: &Connection, session_id: &str, mut rows: Vec<(i64, Message)>, tail: bool) -> Result<(Vec<Message>, bool, bool)> {
|
||||
rows.sort_by_key(|(seq, _)| *seq);
|
||||
let exists = |q: &str, seq: i64| -> Result<bool> { Ok(conn.prepare(q)?.exists(params![session_id, seq])?) };
|
||||
let older = match rows.first() {
|
||||
Some((first, _)) => exists("SELECT 1 FROM messages WHERE session_id = ?1 AND seq < ?2", *first)?,
|
||||
None => tail && exists("SELECT 1 FROM messages WHERE session_id = ?1 AND seq > ?2", i64::MIN)?,
|
||||
};
|
||||
let newer = match rows.last() {
|
||||
Some((last, _)) => exists("SELECT 1 FROM messages WHERE session_id = ?1 AND seq > ?2", *last)?,
|
||||
None => false,
|
||||
};
|
||||
Ok((rows.into_iter().map(|(_, m)| m).collect(), older, newer))
|
||||
}
|
||||
|
||||
/// Messages of one session containing `q` (case-insensitive), newest first:
|
||||
/// (message id, role, created_at, snippet).
|
||||
pub fn search_messages(&self, session_id: &str, q: &str, limit: usize) -> Result<Vec<(String, Role, i64, String)>> {
|
||||
let needle = q.to_lowercase();
|
||||
let like = format!("%{}%", q.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_"));
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT data FROM messages WHERE session_id = ?1 AND data LIKE ?2 ESCAPE '\\' ORDER BY seq DESC")?;
|
||||
let mut out = Vec::new();
|
||||
for r in stmt.query_map(params![session_id, like], |r| r.get::<_, String>(0))? {
|
||||
let Ok(m) = serde_json::from_str::<Message>(&r?) else { continue };
|
||||
// Only visible text counts (not JSON keys or ids).
|
||||
if let Some(snippet) = snippet_in(&m, &needle) {
|
||||
out.push((m.id.clone(), m.role, m.created_at, snippet));
|
||||
if out.len() >= limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Ids of the subagent sessions started by a session.
|
||||
pub fn child_sessions(&self, id: &str) -> Result<Vec<String>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT id FROM sessions WHERE parent_id = ?1")?;
|
||||
let rows = stmt.query_map([id], |r| r.get(0))?.collect::<rusqlite::Result<Vec<String>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub fn delete_session(&self, id: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
// Child sessions (subagents) go with their parent.
|
||||
@@ -1045,6 +1139,17 @@ impl Store {
|
||||
}
|
||||
|
||||
/// Latest images; `user = None` lists everybody's (admins).
|
||||
/// Generated images of these sessions.
|
||||
pub fn images_of_sessions(&self, sessions: &[String]) -> Result<Vec<ImageRecord>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(&format!("SELECT {IMAGE_COLS} FROM images WHERE session_id = ?1"))?;
|
||||
let mut out = Vec::new();
|
||||
for s in sessions {
|
||||
out.extend(stmt.query_map([s], image_row)?.collect::<rusqlite::Result<Vec<_>>>()?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn images(&self, user: Option<&str>, limit: i64) -> Result<Vec<ImageRecord>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
@@ -1392,3 +1497,45 @@ pub struct PushSub {
|
||||
pub p256dh: String,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
/// Which messages of a session the chat view loads (see Store::messages_window).
|
||||
pub enum MessageWindow {
|
||||
Tail { limit: i64 },
|
||||
Before { id: String, limit: i64, stop_at_summary: bool },
|
||||
After { id: String, limit: i64 },
|
||||
Around { id: String, limit: i64 },
|
||||
}
|
||||
|
||||
/// Text around the first occurrence of `needle` (lowercase) in the visible text of a
|
||||
/// message: its text, reasoning, summaries, tool calls and tool output.
|
||||
fn snippet_in(m: &Message, needle: &str) -> Option<String> {
|
||||
for p in &m.parts {
|
||||
let owned;
|
||||
let text = match p {
|
||||
Part::Text { text } | Part::Summary { text, .. } | Part::Reasoning { text, .. } => text.as_str(),
|
||||
Part::ToolResult { content, .. } => content.as_str(),
|
||||
Part::ToolCall { input, .. } => {
|
||||
owned = input.to_string();
|
||||
owned.as_str()
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
let lower = text.to_lowercase();
|
||||
// Lowercasing can change byte lengths (rare): then cut the lowercase text.
|
||||
let (src, i) = match lower.find(needle) {
|
||||
Some(i) if lower.len() == text.len() => (text, i),
|
||||
Some(i) => (lower.as_str(), i),
|
||||
None => continue,
|
||||
};
|
||||
let mut a = i.saturating_sub(60);
|
||||
while !src.is_char_boundary(a) {
|
||||
a -= 1;
|
||||
}
|
||||
let mut b = (i + needle.len() + 100).min(src.len());
|
||||
while !src.is_char_boundary(b) {
|
||||
b += 1;
|
||||
}
|
||||
return Some(format!("{}{}{}", if a > 0 { "…" } else { "" }, src[a..b].replace('\n', " "), if b < src.len() { "…" } else { "" }));
|
||||
}
|
||||
None
|
||||
}
|
||||
Reference in new issue
Block a user