From e320381eaf70a8c2f0294eadc87c654dfa3e13b3 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:42:13 -0300 Subject: [PATCH 1/3] feat(store): full-text search over the chats Migration 0016 adds chat_search, an FTS5 index read through the chat_text view, so no text is stored twice; triggers keep it in step with chat_items and the migration indexes what was there. It covers what bots were told, their replies and their questions, without accents. Spec 8.8. --- app/src/lib/protocol.gen.ts | 44 ++++- crates/botloft-core/src/protocol/chat.rs | 6 + crates/botloft-core/src/protocol/export.rs | 4 + .../src/protocol/export_search.rs | 29 +++ crates/botloft-core/src/protocol/mod.rs | 3 + crates/botloft-core/src/protocol/search.rs | 48 +++++ .../migrations/0016_chat_search.sql | 62 ++++++ crates/botloft-store/src/chat.rs | 15 +- crates/botloft-store/src/delete.rs | 3 +- crates/botloft-store/src/lib.rs | 2 + crates/botloft-store/src/migrate.rs | 28 +++ crates/botloft-store/src/search.rs | 88 +++++++++ crates/botloft-store/src/search/tests.rs | 183 ++++++++++++++++++ docs/spec.md | 21 +- 14 files changed, 530 insertions(+), 6 deletions(-) create mode 100644 crates/botloft-core/src/protocol/export_search.rs create mode 100644 crates/botloft-core/src/protocol/search.rs create mode 100644 crates/botloft-store/migrations/0016_chat_search.sql create mode 100644 crates/botloft-store/src/search.rs create mode 100644 crates/botloft-store/src/search/tests.rs diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index 1e342679..da62bf4f 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -950,6 +950,41 @@ export type ChatItemChanged = { item: ChatItem, */ activity: Activity | null, }; +/** What a search snippet puts around each matched word (spec 8.8). */ +export const SNIPPET_MARKS = { open: "\u0002", close: "\u0003" } as const; + +/** + * Newest first. Page back with `before` set to the oldest item received. + */ +export type ChatSearchParams = { +/** + * Words to find, as the owner typed them; the last one may be the + * start of a word. + */ +query: string, +/** + * Only this bot's chat. + */ +botId?: BotId, +/** + * Only the chats of this crew's bots. + */ +crewId?: CrewId, before?: ChatItemId, +/** + * 1 to 100; 30 when absent. + */ +limit?: number, }; + +/** + * A chat item with every searched word. + */ +export type SearchHit = { item: ChatItem, crewId: CrewId, +/** + * A few words around the match, on one line, with each matched word + * between `\u0002` and `\u0003`. + */ +snippet: string, }; + /** * A permission request (spec 10.1). */ @@ -975,7 +1010,13 @@ export type ChatHistoryParams = { botId: BotId, before?: ChatItemId, /** * 1 to 200; 50 when absent. */ -limit?: number, }; +limit?: number, +/** + * Instead of a page: every item from this one to the newest, up to + * 1,000 newest, to open the chat at a search result (spec 8.8). + * Not with `before` or `limit`. + */ +until?: ChatItemId, }; export type ApprovalsAnswerParams = { approvalId: ApprovalId, allow: boolean, /** @@ -1352,6 +1393,7 @@ export interface RpcMethods { "questions.list": { params: QuestionsListParams; result: Array }; "questions.answer": { params: QuestionsAnswerParams; result: Question }; "questions.dismiss": { params: QuestionIdParams; result: Question }; + "chat.search": { params: ChatSearchParams; result: Array }; "browser.list": { params: undefined; result: Array }; "browser.watch": { params: BrowserWatchParams; result: BrowserView }; "browser.unwatch": { params: undefined; result: null }; diff --git a/crates/botloft-core/src/protocol/chat.rs b/crates/botloft-core/src/protocol/chat.rs index 88b4f3de..d13b6b0f 100644 --- a/crates/botloft-core/src/protocol/chat.rs +++ b/crates/botloft-core/src/protocol/chat.rs @@ -247,6 +247,12 @@ pub struct ChatHistoryParams { #[serde(default)] #[cfg_attr(test, ts(optional))] pub limit: Option, + /// Instead of a page: every item from this one to the newest, up to + /// 1,000 newest, to open the chat at a search result (spec 8.8). + /// Not with `before` or `limit`. + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub until: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs index c9969603..10074136 100644 --- a/crates/botloft-core/src/protocol/export.rs +++ b/crates/botloft-core/src/protocol/export.rs @@ -22,6 +22,8 @@ mod questions; mod routines; #[path = "export_rules.rs"] mod rules; +#[path = "export_search.rs"] +mod search; #[path = "export_session.rs"] mod session; #[path = "export_usage.rs"] @@ -178,6 +180,7 @@ fn export_bindings() { out.decl::(); out.decl::(); out.decl::(); + search::decls(&mut out); out.decl::(); out.decl::(); out.decl::(); @@ -333,6 +336,7 @@ fn export_bindings() { rules::methods(&mut out); routines::methods(&mut out); questions::methods(&mut out); + search::methods(&mut out); browser::methods(&mut out); out.text.push_str("}\n\n"); diff --git a/crates/botloft-core/src/protocol/export_search.rs b/crates/botloft-core/src/protocol/export_search.rs new file mode 100644 index 00000000..48ae965d --- /dev/null +++ b/crates/botloft-core/src/protocol/export_search.rs @@ -0,0 +1,29 @@ +//! The search part of the TypeScript bindings (spec 8.8). + +use std::fmt::Write as _; + +use super::super::*; +use super::Out; + +/// After `ChatItem`, which `SearchHit` holds. +pub(super) fn decls(out: &mut Out) { + let mark = |c: char| format!("\\u{:04x}", u32::from(c)); + out.text + .push_str("/** What a search snippet puts around each matched word (spec 8.8). */\n"); + let _ = writeln!( + out.text, + "export const SNIPPET_MARKS = {{ open: \"{}\", close: \"{}\" }} as const;\n", + mark(SNIPPET_OPEN), + mark(SNIPPET_CLOSE) + ); + out.decl::(); + out.decl::(); +} + +pub(super) fn methods(out: &mut Out) { + out.method( + method::CHAT_SEARCH, + &out.name::(), + &out.name::>(), + ); +} diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs index 55b7e616..f1704b97 100644 --- a/crates/botloft-core/src/protocol/mod.rs +++ b/crates/botloft-core/src/protocol/mod.rs @@ -62,6 +62,7 @@ mod questions; mod routines; mod rules; mod screens; +mod search; mod session; mod settings; mod usage; @@ -77,6 +78,7 @@ pub use questions::*; pub use routines::*; pub use rules::*; pub use screens::*; +pub use search::*; pub use session::*; pub use settings::*; pub use usage::*; @@ -111,6 +113,7 @@ pub mod method { pub const BOTS_DELETE: &str = "bots.delete"; pub const BOTS_RESTART: &str = "bots.restart"; pub const CHAT_HISTORY: &str = "chat.history"; + pub const CHAT_SEARCH: &str = "chat.search"; pub const APPROVALS_ANSWER: &str = "approvals.answer"; pub const RULES_LIST: &str = "rules.list"; pub const RULES_DELETE: &str = "rules.delete"; diff --git a/crates/botloft-core/src/protocol/search.rs b/crates/botloft-core/src/protocol/search.rs new file mode 100644 index 00000000..67de8e70 --- /dev/null +++ b/crates/botloft-core/src/protocol/search.rs @@ -0,0 +1,48 @@ +//! Searching the chats (spec 8.8): `chat.search`. + +use serde::{Deserialize, Serialize}; + +use super::ChatItem; +use crate::ids::{BotId, ChatItemId, CrewId}; + +/// Marks where a matched word starts and ends in a snippet: the app shows +/// what is between them highlighted. +pub const SNIPPET_OPEN: char = '\u{2}'; +pub const SNIPPET_CLOSE: char = '\u{3}'; + +/// Newest first. Page back with `before` set to the oldest item received. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct ChatSearchParams { + /// Words to find, as the owner typed them; the last one may be the + /// start of a word. + pub query: String, + /// Only this bot's chat. + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub bot_id: Option, + /// Only the chats of this crew's bots. + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub crew_id: Option, + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub before: Option, + /// 1 to 100; 30 when absent. + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub limit: Option, +} + +/// A chat item with every searched word. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct SearchHit { + pub item: ChatItem, + pub crew_id: CrewId, + /// A few words around the match, on one line, with each matched word + /// between `\u0002` and `\u0003`. + pub snippet: String, +} diff --git a/crates/botloft-store/migrations/0016_chat_search.sql b/crates/botloft-store/migrations/0016_chat_search.sql new file mode 100644 index 00000000..7b2daec2 --- /dev/null +++ b/crates/botloft-store/migrations/0016_chat_search.sql @@ -0,0 +1,62 @@ +-- Searching the chats (spec 8.8). The index reads the text of each chat +-- item through a view, so nothing is stored twice: what the bot was told +-- (the owner, another bot or Botloft), what it replied, and its questions +-- with their answers. Tool calls, approvals and notices are left out. +-- Triggers keep the index in step with chat_items, deletes included. + +CREATE VIEW chat_text (id, text) AS +SELECT rowid, + CASE kind + WHEN 'inbound' THEN json_extract(data, '$.message.body') + WHEN 'reply' THEN json_extract(data, '$.text') + WHEN 'question' THEN json_extract(data, '$.question.text') || char(10) || + coalesce(json_extract(data, '$.question.answer'), '') + END +FROM chat_items; + +CREATE VIRTUAL TABLE chat_search USING fts5 ( + text, + content = 'chat_text', + content_rowid = 'id', + tokenize = 'unicode61 remove_diacritics 2' +); + +INSERT INTO chat_search (rowid, text) +SELECT id, text FROM chat_text WHERE text IS NOT NULL; + +CREATE TRIGGER chat_search_insert AFTER INSERT ON chat_items +WHEN NEW.kind IN ('inbound', 'reply', 'question') +BEGIN + INSERT INTO chat_search (rowid, text) + SELECT id, text FROM chat_text WHERE id = NEW.rowid; +END; + +-- The old text has to be given back exactly to take it out. +CREATE TRIGGER chat_search_delete AFTER DELETE ON chat_items +WHEN OLD.kind IN ('inbound', 'reply', 'question') +BEGIN + INSERT INTO chat_search (chat_search, rowid, text) VALUES ('delete', OLD.rowid, + CASE OLD.kind + WHEN 'inbound' THEN json_extract(OLD.data, '$.message.body') + WHEN 'reply' THEN json_extract(OLD.data, '$.text') + WHEN 'question' THEN json_extract(OLD.data, '$.question.text') || char(10) || + coalesce(json_extract(OLD.data, '$.question.answer'), '') + END); +END; + +-- One trigger, so the old text leaves before the new one comes in. +CREATE TRIGGER chat_search_update AFTER UPDATE OF kind, data ON chat_items +BEGIN + INSERT INTO chat_search (chat_search, rowid, text) + SELECT 'delete', OLD.rowid, + CASE OLD.kind + WHEN 'inbound' THEN json_extract(OLD.data, '$.message.body') + WHEN 'reply' THEN json_extract(OLD.data, '$.text') + WHEN 'question' THEN json_extract(OLD.data, '$.question.text') || char(10) || + coalesce(json_extract(OLD.data, '$.question.answer'), '') + END + WHERE OLD.kind IN ('inbound', 'reply', 'question'); + INSERT INTO chat_search (rowid, text) + SELECT id, text FROM chat_text + WHERE id = NEW.rowid AND NEW.kind IN ('inbound', 'reply', 'question'); +END; diff --git a/crates/botloft-store/src/chat.rs b/crates/botloft-store/src/chat.rs index fe034546..1b4a6db6 100644 --- a/crates/botloft-store/src/chat.rs +++ b/crates/botloft-store/src/chat.rs @@ -11,7 +11,7 @@ use crate::{Result, Store, cached_execute, cached_row, parse_column}; const COLUMNS: &str = "id, bot_id, data, created_at, updated_at"; -fn from_row(row: &Row<'_>) -> rusqlite::Result { +pub(crate) fn from_row(row: &Row<'_>) -> rusqlite::Result { let data: String = row.get(2)?; let body = serde_json::from_str(&data) .map_err(|err| rusqlite::Error::FromSqlConversionFailure(2, Type::Text, Box::new(err)))?; @@ -123,6 +123,19 @@ impl Store { Ok(rows.collect::>()?) } + /// Every item from `until` to the newest, newest first, at most + /// `limit`; empty if `until` is not in the bot's chat. + pub fn chat_since(&self, bot: &BotId, until: &ChatItemId, limit: u32) -> Result> { + let mut stmt = self.conn.prepare_cached(&format!( + "SELECT {COLUMNS} FROM chat_items \ + WHERE bot_id = ?1 \ + AND rowid >= (SELECT rowid FROM chat_items WHERE id = ?2 AND bot_id = ?1) \ + ORDER BY rowid DESC LIMIT ?3" + ))?; + let rows = stmt.query_map(params![bot.as_str(), until.as_str(), limit], from_row)?; + Ok(rows.collect::>()?) + } + /// The bot's newest tool item for `tool_use_id`, to update it when the /// tool returns. pub fn tool_item(&self, bot: &BotId, tool_use_id: &str) -> Result> { diff --git a/crates/botloft-store/src/delete.rs b/crates/botloft-store/src/delete.rs index 1ff13145..fe436fbc 100644 --- a/crates/botloft-store/src/delete.rs +++ b/crates/botloft-store/src/delete.rs @@ -11,7 +11,8 @@ use crate::{Result, Store}; /// what points at a row goes, or lets go of it, before the row. const DELETE_BOT: &[&str] = &[ // Questions point at chat items, and answers at questions. - "UPDATE messages SET question_id = NULL WHERE question_id IN (SELECT id FROM questions WHERE bot_id = ?1)", + "UPDATE messages SET question_id = NULL \ + WHERE question_id IN (SELECT id FROM questions WHERE bot_id = ?1)", "DELETE FROM questions WHERE bot_id = ?1", "DELETE FROM approvals WHERE bot_id = ?1", "DELETE FROM chat_items WHERE bot_id = ?1", diff --git a/crates/botloft-store/src/lib.rs b/crates/botloft-store/src/lib.rs index cff0da8f..680fcf0d 100644 --- a/crates/botloft-store/src/lib.rs +++ b/crates/botloft-store/src/lib.rs @@ -19,6 +19,7 @@ mod plans; mod questions; mod routine_runs; mod routines; +mod search; mod tasks; mod usage; @@ -35,6 +36,7 @@ pub use deliveries::DeliveryOutcome; pub use messages::MessageFilter; pub use migrate::LATEST_VERSION; pub use questions::{Answered, QuestionRecord}; +pub use search::{Found, SearchFilter, fts_query}; pub use tasks::TaskFilter; #[derive(Debug, thiserror::Error)] diff --git a/crates/botloft-store/src/migrate.rs b/crates/botloft-store/src/migrate.rs index 8f24d64c..a7f324b1 100644 --- a/crates/botloft-store/src/migrate.rs +++ b/crates/botloft-store/src/migrate.rs @@ -22,6 +22,7 @@ const MIGRATIONS: &[(u32, &str)] = &[ (13, include_str!("../migrations/0013_reply_index.sql")), (14, include_str!("../migrations/0014_allow_rules.sql")), (15, include_str!("../migrations/0015_questions.sql")), + (16, include_str!("../migrations/0016_chat_search.sql")), ]; /// Schema version after every migration has run. @@ -66,6 +67,33 @@ mod tests { assert_eq!(version, LATEST_VERSION); } + #[test] + fn chats_from_before_search_are_indexed() { + let mut conn = Connection::open_in_memory().expect("open"); + for &(version, sql) in &MIGRATIONS[..15] { + conn.execute_batch(sql).expect("migration"); + conn.pragma_update(None, "user_version", version) + .expect("version"); + } + conn.execute_batch( + // Items without their bot: only the index is under test. + r#"PRAGMA foreign_keys = OFF; + INSERT INTO chat_items (id, bot_id, kind, data, created_at, updated_at) VALUES + ('cht_1', 'bot_1', 'reply', '{"kind":"reply","text":"older answer"}', 0, 0), + ('cht_2', 'bot_1', 'tool', '{"kind":"tool","summary":"older"}', 0, 0);"#, + ) + .expect("items"); + run(&mut conn).expect("search migration"); + let found: Vec = conn + .prepare("SELECT c.id FROM chat_search JOIN chat_items c ON c.rowid = chat_search.rowid WHERE chat_search MATCH 'older'") + .expect("query") + .query_map([], |row| row.get(0)) + .expect("rows") + .collect::>() + .expect("ids"); + assert_eq!(found, ["cht_1"]); + } + #[test] fn refuses_a_database_from_a_newer_build() { let mut conn = Connection::open_in_memory().expect("open"); diff --git a/crates/botloft-store/src/search.rs b/crates/botloft-store/src/search.rs new file mode 100644 index 00000000..616ee7fc --- /dev/null +++ b/crates/botloft-store/src/search.rs @@ -0,0 +1,88 @@ +//! Searching the chats (spec 8.8) through `chat_search`, the FTS5 index the +//! triggers of migration 0016 keep over `chat_items`. + +use botloft_core::ids::{BotId, ChatItemId, CrewId}; +use botloft_core::protocol::{ChatItem, SNIPPET_CLOSE, SNIPPET_OPEN}; +use rusqlite::params; + +use crate::{Result, Store, chat, parse_column}; + +/// Words of context a snippet keeps around the match. +const SNIPPET_TOKENS: u32 = 18; + +/// Which chats a search looks in, and where its page starts. +#[derive(Debug, Clone, Copy, Default)] +pub struct SearchFilter<'a> { + pub bot: Option<&'a BotId>, + pub crew: Option<&'a CrewId>, + /// Only items stored before this one. + pub before: Option<&'a ChatItemId>, + pub limit: u32, +} + +/// A chat item that matched, with its crew and a snippet. +#[derive(Debug, Clone, PartialEq)] +pub struct Found { + pub item: ChatItem, + pub crew: CrewId, + pub snippet: String, +} + +/// The words of `text` as an FTS5 query: every word must be there, each +/// taken literally, and the last one may be the start of a word. `None` +/// when no word is left. +pub fn fts_query(text: &str) -> Option { + let words: Vec = text + .split_whitespace() + .map(|word| word.replace('"', "")) + .filter(|word| word.chars().any(char::is_alphanumeric)) + .collect(); + let (last, rest) = words.split_last()?; + let mut query: Vec = rest.iter().map(|word| format!("\"{word}\"")).collect(); + query.push(format!("\"{last}\"*")); + Some(query.join(" ")) +} + +impl Store { + /// Chat items of active bots in active crews that match `query` (an + /// FTS5 query, see [`fts_query`]), newest first. + pub fn search_chat(&self, query: &str, filter: SearchFilter<'_>) -> Result> { + let mut stmt = self.conn.prepare_cached( + "SELECT c.id, c.bot_id, c.data, c.created_at, c.updated_at, b.crew_id, \ + snippet(chat_search, 0, ?6, ?7, '…', ?8) \ + FROM chat_search \ + JOIN chat_items c ON c.rowid = chat_search.rowid \ + JOIN bots b ON b.id = c.bot_id \ + JOIN crews k ON k.id = b.crew_id \ + WHERE chat_search MATCH ?1 \ + AND b.archived_at IS NULL AND k.archived_at IS NULL \ + AND (?2 IS NULL OR c.bot_id = ?2) \ + AND (?3 IS NULL OR b.crew_id = ?3) \ + AND (?4 IS NULL OR c.rowid < (SELECT rowid FROM chat_items WHERE id = ?4)) \ + ORDER BY c.rowid DESC LIMIT ?5", + )?; + let rows = stmt.query_map( + params![ + query, + filter.bot.map(BotId::as_str), + filter.crew.map(CrewId::as_str), + filter.before.map(ChatItemId::as_str), + filter.limit, + SNIPPET_OPEN.to_string(), + SNIPPET_CLOSE.to_string(), + SNIPPET_TOKENS, + ], + |row| { + Ok(Found { + item: chat::from_row(row)?, + crew: parse_column(row, 5)?, + snippet: row.get(6)?, + }) + }, + )?; + Ok(rows.collect::>()?) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/botloft-store/src/search/tests.rs b/crates/botloft-store/src/search/tests.rs new file mode 100644 index 00000000..5f9d1772 --- /dev/null +++ b/crates/botloft-store/src/search/tests.rs @@ -0,0 +1,183 @@ +//! Tests for searching the chats. + +use botloft_core::ids::{BotId, ChatItemId}; +use botloft_core::protocol::{ChatBody, ChatItem, ReplyItem, ToolItem, ToolStatus}; + +use super::*; +use crate::tests::{Fixture, message_to}; + +fn reply(store: &Store, bot: &BotId, text: &str) -> ChatItem { + let item = ChatItem { + id: ChatItemId::generate(), + bot_id: bot.clone(), + body: ChatBody::Reply(ReplyItem { text: text.into() }), + created_at: 0, + updated_at: 0, + }; + store.insert_chat_item(&item).expect("reply"); + item +} + +fn find(store: &Store, words: &str, filter: SearchFilter<'_>) -> Vec { + let query = fts_query(words).expect("words"); + store + .search_chat( + &query, + SearchFilter { + limit: 50, + ..filter + }, + ) + .expect("search") +} + +fn texts(found: &[Found]) -> Vec<&str> { + found + .iter() + .map(|hit| match &hit.item.body { + ChatBody::Reply(reply) => reply.text.as_str(), + ChatBody::Inbound(inbound) => inbound.message.body.as_str(), + _ => "", + }) + .collect() +} + +#[test] +fn words_are_found_in_any_order_without_accents_and_by_their_start() { + let fx = Fixture::new(); + let scout = &fx.bots[0].id; + reply(&fx.store, scout, "A previsão do tempo para sábado"); + reply(&fx.store, scout, "Nothing about the weather here"); + let (note, delivery) = message_to(&fx.crew.id, scout, "Check the weather for Saturday"); + fx.store + .insert_message(¬e, &delivery, None) + .expect("note"); + + assert_eq!( + texts(&find(&fx.store, "sabado previsao", SearchFilter::default())), + ["A previsão do tempo para sábado"] + ); + // Newest first, and the last word may be cut short. + assert_eq!( + texts(&find(&fx.store, "weath", SearchFilter::default())), + [ + "Check the weather for Saturday", + "Nothing about the weather here" + ] + ); + let hit = &find(&fx.store, "saturday", SearchFilter::default())[0]; + assert_eq!(hit.crew, fx.crew.id); + assert!( + hit.snippet.contains("\u{2}Saturday\u{3}"), + "{:?}", + hit.snippet + ); +} + +#[test] +fn filters_paging_and_what_is_left_out() { + let fx = Fixture::new(); + let (scout, writer) = (&fx.bots[0].id, &fx.bots[1].id); + let first = reply(&fx.store, scout, "report one"); + let second = reply(&fx.store, scout, "report two"); + reply(&fx.store, writer, "report three"); + // Tool calls are not searched. + let tool = ChatItem { + id: ChatItemId::generate(), + bot_id: scout.clone(), + body: ChatBody::Tool(ToolItem { + tool_use_id: "toolu_1".into(), + name: "Bash".into(), + summary: "report".into(), + explanation: Some("report".into()), + input: "{\"command\":\"report\"}".into(), + status: ToolStatus::Done, + output: Some("report".into()), + file: None, + }), + created_at: 0, + updated_at: 0, + }; + fx.store.insert_chat_item(&tool).expect("tool"); + + let only_scout = SearchFilter { + bot: Some(scout), + ..SearchFilter::default() + }; + assert_eq!( + texts(&find(&fx.store, "report", only_scout)), + ["report two", "report one"] + ); + let crew = SearchFilter { + crew: Some(&fx.crew.id), + ..SearchFilter::default() + }; + assert_eq!(find(&fx.store, "report", crew).len(), 3); + let older = SearchFilter { + before: Some(&second.id), + ..SearchFilter::default() + }; + let page = find(&fx.store, "report", older); + assert_eq!(page.len(), 1); + assert_eq!(page[0].item.id, first.id); + + let mut archived = fx.bots[1].clone(); + archived.archived_at = Some(5); + fx.store.update_bot(&archived).expect("archive"); + assert_eq!(find(&fx.store, "report", SearchFilter::default()).len(), 2); +} + +#[test] +fn the_index_follows_changes_and_deletes() { + let fx = Fixture::new(); + let scout = &fx.bots[0].id; + let item = reply(&fx.store, scout, "draft about apples"); + fx.store + .update_chat_item( + &item.id, + &ChatBody::Reply(ReplyItem { + text: "final about pears".into(), + }), + 1, + ) + .expect("update"); + assert!(find(&fx.store, "apples", SearchFilter::default()).is_empty()); + assert_eq!(find(&fx.store, "pears", SearchFilter::default()).len(), 1); + + assert!(fx.store.delete_bot(scout).expect("delete")); + assert!(find(&fx.store, "pears", SearchFilter::default()).is_empty()); + let integrity: rusqlite::Result<()> = fx + .store + .conn + .execute_batch("INSERT INTO chat_search (chat_search, rank) VALUES ('integrity-check', 1)"); + assert!(integrity.is_ok(), "{integrity:?}"); +} + +#[test] +fn chat_since_opens_the_chat_at_an_item() { + let fx = Fixture::new(); + let (scout, writer) = (&fx.bots[0].id, &fx.bots[1].id); + reply(&fx.store, scout, "zero"); + let one = reply(&fx.store, scout, "one"); + reply(&fx.store, scout, "two"); + let since = fx.store.chat_since(scout, &one.id, 100).expect("since"); + assert_eq!(since.len(), 2); + assert_eq!(since[1].id, one.id); + assert!( + fx.store + .chat_since(writer, &one.id, 100) + .expect("other") + .is_empty() + ); +} + +#[test] +fn queries_take_words_literally() { + assert_eq!(fts_query(" "), None); + assert_eq!(fts_query("- ?"), None); + assert_eq!(fts_query("weather"), Some("\"weather\"*".into())); + assert_eq!( + fts_query("say \"NEAR\" OR x*"), + Some("\"say\" \"NEAR\" \"OR\" \"x*\"*".into()) + ); +} diff --git a/docs/spec.md b/docs/spec.md index 99754e68..60da44d5 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -422,6 +422,19 @@ O dono vê quantos tokens cada bot gasta: em cada turno, no chat, e somados por - **Por bot.** `usage.tokens {since}` soma os itens `turn` com `tokens` terminados a partir de `since` (ms Unix), por bot, com nome, cor, o nome da equipe (dois bots podem ter o mesmo nome em equipes diferentes) e se está arquivado, do que mais gastou para o que menos. Bots sem turno no período ficam de fora; bots excluídos levam os itens junto (7.6). O app oferece hoje (desde a meia-noite local), 7 dias, 30 dias e tudo, com o total dos bots embaixo. - Uma compactação (8.6) não vira item `turn` e não entra na conta. +### 8.8 Busca + +O dono procura palavras em todas as conversas: o que ele mandou, o que outros bots e o Botloft mandaram, o que cada bot respondeu, e as perguntas dos bots com as respostas (23). Ferramentas, pedidos e avisos ficam de fora: são o trabalho, não a conversa. + +- **Índice.** Uma tabela FTS5 (`chat_search`) sobre `chat_items`, lida por uma view (`chat_text`) que tira o texto do JSON de cada item: nada é gravado duas vezes. Triggers acompanham cada item novo, mudado ou apagado (a exclusão de um bot, 7.6, leva o índice junto); a migration indexa o que já existia. O tokenizador é `unicode61` sem acentos: "previsao" acha "previsão", e maiúsculas não contam. +- **Consulta.** `chat.search {query, botId?, crewId?, before?, limit?}` acha os itens com **todas** as palavras, em qualquer ordem; a última vale também como começo de palavra ("relat" acha "relatório"). Cada palavra é tomada ao pé da letra: aspas, `OR`, `NEAR` e `*` do FTS5 não fazem nada de especial. Precisa de pelo menos 2 letras ou números; menos que isso é `validation`. Só bots e crews ativos, do item mais novo ao mais velho; `before` pagina, `limit` de 1 a 100 (30 se ausente). +- **Resultado.** `SearchHit`: o `ChatItem`, o `crewId` e um trecho (`snippet`) de umas 18 palavras em volta do que foi achado, com cada palavra achada entre `\u0002` e `\u0003` (`SNIPPET_MARKS` no TypeScript), que o app destaca. +- **Abrir no ponto.** `chat.history {botId, until}` devolve do item achado até o mais novo, até 1 000 itens (o chat abre ali e o resto carrega como sempre). Um item que não é do bot dá `not_found`. +- **Por fora da fila.** `chat.search` responde quando fica pronta, como `files.list` (11.1): uma busca lenta não segura o texto ao vivo. +- **Privacidade.** A consulta é texto do dono e nunca vai para o log em `info` ou acima (8.5). + +**No app.** "Buscar", no topo da barra lateral (ou Ctrl+K), abre a busca no meio da janela: um campo, a escolha de uma equipe ou de todas, e os resultados conforme o dono escreve, cada um com o rosto e o nome do bot, a equipe, a hora, quem escreveu (o dono, outro bot, o Botloft ou o próprio bot) e o trecho com as palavras em destaque. "Mostrar mais" traz os anteriores. Um clique abre o chat do bot nesse ponto, com o item em destaque por um instante. A busca guarda o que foi digitado enquanto o app está aberto, e o texto volta selecionado, pronto para uma busca nova. Sem nada achado: "Nada encontrado". + ## 9. Mensagens e entrega ### 9.1 Fluxo @@ -597,7 +610,7 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co - Qualquer outro método antes do hello: erro `-32001` e a conexão fecha. Token errado também dá `-32001`; `protocol` diferente dá `-32004`; nos dois casos a conexão fecha. O hello tem que chegar em até 10 s. - `Origin` aceito: `http://tauri.localhost`, `tauri://localhost` e `http://localhost:1420` (dev). Qualquer outro `Origin` recebe HTTP 403 antes do upgrade. Sem `Origin` (cliente nativo, testes) é aceito: navegadores sempre mandam o header, e o token continua obrigatório. - Um cliente lento que deixa acumular mais de 4096 notificações é desconectado e recarrega o estado ao reconectar. -- Os requests rodam numa thread de bloqueio, nunca numa do runtime. Quem espera o banco numa thread do runtime (o courier, o supervisor, as tools) espera com `block_in_place`, que passa o resto do trabalho dessa thread para outra: parada ali, ela deixaria de rodar os timers e os sockets de todo o daemon até outra thread acordar, e um request lento seguraria até o `/health`. Os de uma conexão são respondidos um de cada vez, na ordem em que chegaram, e a resposta sai antes de qualquer notificação que venha depois dela: o app trata uma resposta como mais nova que tudo o que recebeu antes e grava o objeto inteiro (um `Bot`, uma `Routine`, a lista de uma carga). A exceção são as leituras que o app nunca cruza com notificações (`files.list`, `files.read`, `screens.list`, `attachments.read`, `usage.tokens`). Elas correm por fora e respondem quando ficam prontas, de modo que varrer uma pasta grande ou ler um arquivo grande não segura o texto ao vivo. Os requests do navegador (21.7, 21.10) pertencem à conexão e são respondidos na hora. +- Os requests rodam numa thread de bloqueio, nunca numa do runtime. Quem espera o banco numa thread do runtime (o courier, o supervisor, as tools) espera com `block_in_place`, que passa o resto do trabalho dessa thread para outra: parada ali, ela deixaria de rodar os timers e os sockets de todo o daemon até outra thread acordar, e um request lento seguraria até o `/health`. Os de uma conexão são respondidos um de cada vez, na ordem em que chegaram, e a resposta sai antes de qualquer notificação que venha depois dela: o app trata uma resposta como mais nova que tudo o que recebeu antes e grava o objeto inteiro (um `Bot`, uma `Routine`, a lista de uma carga). A exceção são as leituras que o app nunca cruza com notificações (`files.list`, `files.read`, `screens.list`, `attachments.read`, `usage.tokens`, `chat.search`). Elas correm por fora e respondem quando ficam prontas, de modo que varrer uma pasta grande ou ler um arquivo grande não segura o texto ao vivo. Os requests do navegador (21.7, 21.10) pertencem à conexão e são respondidos na hora. - A versão 2 do protocolo troca `terminal.*` pelo chat (ADR 0001). ### 11.2 Métodos (MVP) @@ -626,7 +639,8 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co | `bots.restart` | `botId, fresh?` | `Bot` | | `bots.archive` | `botId` | `Bot` | | `bots.delete` | `botId, recycleFolder?` | `{botId, crewId}`; exclui o bot, ativo ou arquivado; com `recycleFolder`, a pasta dele vai depois para a Lixeira (7.6) | -| `chat.history` | `botId, before?, limit?` | `ChatItem[]`, mais novo primeiro; `limit` de 1 a 200, 50 se ausente | +| `chat.history` | `botId, before?, limit?` ou `botId, until` | `ChatItem[]`, mais novo primeiro; `limit` de 1 a 200, 50 se ausente; `until`: do item até o mais novo, até 1 000 (8.8) | +| `chat.search` | `query, botId?, crewId?, before?, limit?` | `SearchHit[]`: itens com todas as palavras, mais novo primeiro, com um trecho em destaque (8.8) | | `approvals.answer` | `approvalId, allow, note?, input?, always?` (`input`: a sugestão de bot como o dono a deixou, 10.2; `always`: com `allow`, grava o `always` do pedido como regra do bot, 10.1) | `Approval` | | `rules.list` | `botId` | `AllowRule[]`: o que o bot faz sem perguntar, da regra mais antiga à mais nova (10.1) | | `rules.delete` | `ruleId` | `{botId, rules}`: as regras que ficaram; o bot volta a perguntar pelo que saiu (10.1) | @@ -684,6 +698,7 @@ Pragmas: `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_time | `browser_sites` | `bot_id, host, allowed_at`: sites que o dono deixou o bot usar no navegador (21.5) | | `allow_rules` | `id (rul_), bot_id, tool_name, kind, value, created_at`, única por `(bot_id, tool_name, kind, value)`: o que o dono permitiu de vez para o bot (10.1) | | `questions` | seção 23.7; `messages` ganha `question_id` | +| `chat_search` | índice FTS5 do texto de `chat_items`, pela view `chat_text`, mantido por triggers (8.8) | Índices mínimos: `deliveries(state, next_attempt_at)`, `messages(crew_id, created_at)`, `tasks(assignee_bot_id, status)`, `bots(crew_id)`, `chat_items(bot_id, id)`, `chat_items(created_at) WHERE kind = 'turn'` (8.7), `attachments(message_id)`. @@ -991,7 +1006,7 @@ M2 a M4 foram entregues com ConPTY, terminal com replay, inbox por named pipe e 1. Rotinas: horário semanal, intervalo ou cron, com fuso, sobreposição e horários perdidos. Desenho na seção 20. 2. Caixa de perguntas ao owner (bot pergunta, owner responde, resposta volta como mensagem). Desenho na seção 23. 3. "Permitir sempre" nas aprovações, gravado como regra do bot. Feito (10.1). -4. Busca FTS5 em mensagens e no chat. +4. Busca FTS5 em mensagens e no chat. Feito (8.8). 5. Histórico de versões das instruções do bot. 6. Acesso remoto com token por dispositivo (Tailscale). 7. Sinais entre bots disparando rotinas. From 0b87a28a67ca220dfaca239ee70e72c3ee8e5ec4 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:42:13 -0300 Subject: [PATCH 2/3] feat(daemon): chat.search and chat.history until chat.search finds items with every word, the last one as a prefix, each taken literally, newest first with a marked snippet, and answers outside the queue. chat.history {until} returns from an item to the newest, to open a chat at a result. --- crates/botloftd/src/rpc/dispatch.rs | 1 + crates/botloftd/src/rpc/mod.rs | 3 +- crates/botloftd/src/service/chat.rs | 69 +++++++++++++++++- crates/botloftd/tests/search.rs | 104 ++++++++++++++++++++++++++++ 4 files changed, 173 insertions(+), 4 deletions(-) create mode 100644 crates/botloftd/tests/search.rs diff --git a/crates/botloftd/src/rpc/dispatch.rs b/crates/botloftd/src/rpc/dispatch.rs index 12a78893..297bb110 100644 --- a/crates/botloftd/src/rpc/dispatch.rs +++ b/crates/botloftd/src/rpc/dispatch.rs @@ -44,6 +44,7 @@ pub fn dispatch(daemon: &Daemon, name: &str, params: Option) -> Result reply(delete::bot(daemon, parse(params)?)), method::BOTS_RESTART => reply(bots::restart(daemon, parse(params)?)), method::CHAT_HISTORY => reply(chat::history(daemon, parse(params)?)), + method::CHAT_SEARCH => reply(chat::search(daemon, parse(params)?)), method::APPROVALS_ANSWER => reply(approvals::answer(daemon, parse(params)?)), method::RULES_LIST => reply(rules::list(daemon, parse(params)?)), method::RULES_DELETE => reply(rules::delete(daemon, parse(params)?)), diff --git a/crates/botloftd/src/rpc/mod.rs b/crates/botloftd/src/rpc/mod.rs index fe17397a..a7980fb1 100644 --- a/crates/botloftd/src/rpc/mod.rs +++ b/crates/botloftd/src/rpc/mod.rs @@ -176,12 +176,13 @@ async fn run( /// Reads that the app never sets against notifications: their answer may /// come after newer notifications, so they wait neither for the requests /// before them nor hold back the notifications meanwhile. -const ASIDE: [&str; 5] = [ +const ASIDE: [&str; 6] = [ method::FILES_LIST, method::FILES_READ, method::SCREENS_LIST, method::ATTACHMENTS_READ, method::USAGE_TOKENS, + method::CHAT_SEARCH, ]; enum Routed { diff --git a/crates/botloftd/src/service/chat.rs b/crates/botloftd/src/service/chat.rs index df3abc95..5335fcb7 100644 --- a/crates/botloftd/src/service/chat.rs +++ b/crates/botloftd/src/service/chat.rs @@ -1,15 +1,38 @@ -//! `chat.history` (spec 8, 11.2). +//! `chat.history` and `chat.search` (spec 8, 8.8, 11.2). -use botloft_core::protocol::{ChatHistoryParams, ChatItem}; +use botloft_core::protocol::{ChatHistoryParams, ChatItem, ChatSearchParams, SearchHit}; +use botloft_store::{SearchFilter, fts_query}; -use super::{ApiError, ApiResult, bots}; +use super::{ApiError, ApiResult, bots, crews}; use crate::state::Daemon; const DEFAULT_LIMIT: u32 = 50; const MAX_LIMIT: u32 = 200; +/// Most items `until` brings back, newest first. +const UNTIL_MAX: u32 = 1_000; +const SEARCH_DEFAULT_LIMIT: u32 = 30; +const SEARCH_MAX_LIMIT: u32 = 100; +/// Fewest letters worth searching for. +const SEARCH_MIN_CHARS: usize = 2; /// A page of a bot's chat, newest first. Archived bots keep theirs. pub fn history(daemon: &Daemon, params: ChatHistoryParams) -> ApiResult> { + if let Some(until) = ¶ms.until { + if params.before.is_some() || params.limit.is_some() { + return Err(ApiError::validation( + "until does not go with before or limit", + )); + } + let store = daemon.store(); + bots::find(&store, ¶ms.bot_id)?; + let items = store.chat_since(¶ms.bot_id, until, UNTIL_MAX)?; + if items.is_empty() { + return Err(ApiError::NotFound(format!( + "chat item {until} is not in this bot's chat" + ))); + } + return Ok(items); + } let limit = params.limit.unwrap_or(DEFAULT_LIMIT); if !(1..=MAX_LIMIT).contains(&limit) { return Err(ApiError::validation(format!( @@ -20,3 +43,43 @@ pub fn history(daemon: &Daemon, params: ChatHistoryParams) -> ApiResult ApiResult> { + let limit = params.limit.unwrap_or(SEARCH_DEFAULT_LIMIT); + if !(1..=SEARCH_MAX_LIMIT).contains(&limit) { + return Err(ApiError::validation(format!( + "limit must be between 1 and {SEARCH_MAX_LIMIT}" + ))); + } + let letters = params.query.chars().filter(|c| c.is_alphanumeric()).count(); + let query = fts_query(¶ms.query) + .filter(|_| letters >= SEARCH_MIN_CHARS) + .ok_or_else(|| { + ApiError::validation(format!( + "query needs at least {SEARCH_MIN_CHARS} letters or digits" + )) + })?; + let store = daemon.store(); + if let Some(bot) = ¶ms.bot_id { + bots::find(&store, bot)?; + } + if let Some(crew) = ¶ms.crew_id { + crews::find(&store, crew)?; + } + let filter = SearchFilter { + bot: params.bot_id.as_ref(), + crew: params.crew_id.as_ref(), + before: params.before.as_ref(), + limit, + }; + Ok(store + .search_chat(&query, filter)? + .into_iter() + .map(|found| SearchHit { + item: found.item, + crew_id: found.crew, + snippet: found.snippet, + }) + .collect()) +} diff --git a/crates/botloftd/tests/search.rs b/crates/botloftd/tests/search.rs new file mode 100644 index 00000000..23b169ee --- /dev/null +++ b/crates/botloftd/tests/search.rs @@ -0,0 +1,104 @@ +//! `chat.search` (spec 8.8): finding words in the chats, and opening a +//! chat at what was found with `chat.history {until}`. + +mod common; + +use common::TestDaemon; +use serde_json::{Value, json}; + +async fn setup() -> (TestDaemon, common::Client, Value, Value) { + let t = TestDaemon::start().await; + let mut app = t.session().await; + let crew = app + .call("crews.create", json!({ "name": "Ops" })) + .await + .expect("crew"); + let bot = json!({ "crewId": crew["id"], "name": "Scout", "role": "", "instructions": "" }); + let bot = app.call("bots.create", bot).await.expect("bot"); + for body in [ + "Previsão do tempo para sábado, por favor", + "Then summarize the weather report", + "Thanks!", + ] { + app.call("messages.send", json!({ "botId": bot["id"], "body": body })) + .await + .expect("sent"); + } + (t, app, crew, bot) +} + +#[tokio::test] +async fn words_are_found_with_a_marked_snippet() { + let (_t, mut app, crew, bot) = setup().await; + let hits = app + .call("chat.search", json!({ "query": "previsao SÁB" })) + .await + .expect("search"); + let hits = hits.as_array().expect("hits"); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0]["crewId"], crew["id"]); + assert_eq!(hits[0]["item"]["botId"], bot["id"]); + assert_eq!(hits[0]["item"]["body"]["kind"], "inbound"); + let snippet = hits[0]["snippet"].as_str().expect("snippet"); + assert!(snippet.contains("\u{2}Previsão\u{3}"), "{snippet:?}"); + assert!(snippet.contains("\u{2}sábado\u{3}"), "{snippet:?}"); + + let only_crew = app + .call( + "chat.search", + json!({ "query": "weather", "crewId": crew["id"], "limit": 1 }), + ) + .await + .expect("crew search"); + assert_eq!(only_crew.as_array().map(Vec::len), Some(1)); + + for query in ["a", " ", "- !"] { + let err = app + .call("chat.search", json!({ "query": query })) + .await + .expect_err("too short"); + assert_eq!(err.code, -32004, "{query:?}"); + } +} + +#[tokio::test] +async fn the_chat_opens_at_a_result() { + let (_t, mut app, _crew, bot) = setup().await; + let hits = app + .call("chat.search", json!({ "query": "weather" })) + .await + .expect("search"); + let found = hits[0]["item"]["id"].clone(); + let items = app + .call( + "chat.history", + json!({ "botId": bot["id"], "until": found }), + ) + .await + .expect("history"); + let items = items.as_array().expect("items"); + assert_eq!(items.len(), 2, "the result and the message after it"); + assert_eq!(items[1]["id"], found); + + let err = app + .call( + "chat.history", + json!({ "botId": bot["id"], "until": found, "limit": 5 }), + ) + .await + .expect_err("until with limit"); + assert_eq!(err.code, -32004); +} + +#[tokio::test] +async fn archived_crews_are_not_searched() { + let (_t, mut app, crew, _bot) = setup().await; + app.call("crews.archive", json!({ "crewId": crew["id"] })) + .await + .expect("archived"); + let hits = app + .call("chat.search", json!({ "query": "weather" })) + .await + .expect("search"); + assert_eq!(hits, json!([])); +} From 26eadd1aaff2733553339fcbdba7a721aa098d05 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:42:14 -0300 Subject: [PATCH 3/3] feat(app): search the chats Search on the left (Ctrl+K) opens a page that finds words as the owner types, in all crews or one, with the words highlighted. A result opens its bot's chat at that point and marks the item for a moment. --- app/src/features/chat/ChatView.tsx | 35 ++++- app/src/features/chat/rows.ts | 12 ++ app/src/features/chat/useChat.ts | 17 ++- app/src/features/crews/Sidebar.tsx | 50 +------ app/src/features/crews/SidebarPages.tsx | 66 +++++++++ app/src/features/search/SearchPage.tsx | 175 +++++++++++++++++++++++ app/src/features/search/SearchResult.tsx | 90 ++++++++++++ app/src/features/search/search.test.tsx | 114 +++++++++++++++ app/src/features/search/searchKey.ts | 27 ++++ app/src/i18n/en/index.ts | 2 + app/src/i18n/en/search.ts | 25 ++++ app/src/i18n/es/index.ts | 2 + app/src/i18n/es/search.ts | 24 ++++ app/src/i18n/pt-BR/index.ts | 2 + app/src/i18n/pt-BR/search.ts | 24 ++++ app/src/index.css | 1 + app/src/lib/fake.ts | 2 + app/src/lib/fakeChat.ts | 10 +- app/src/lib/fakeSearch.ts | 82 +++++++++++ app/src/search.css | 33 +++++ app/src/shell/Workspace.tsx | 18 ++- app/src/store/app.ts | 130 +++++++++-------- 22 files changed, 825 insertions(+), 116 deletions(-) create mode 100644 app/src/features/crews/SidebarPages.tsx create mode 100644 app/src/features/search/SearchPage.tsx create mode 100644 app/src/features/search/SearchResult.tsx create mode 100644 app/src/features/search/search.test.tsx create mode 100644 app/src/features/search/searchKey.ts create mode 100644 app/src/i18n/en/search.ts create mode 100644 app/src/i18n/es/search.ts create mode 100644 app/src/i18n/pt-BR/search.ts create mode 100644 app/src/lib/fakeSearch.ts create mode 100644 app/src/search.css diff --git a/app/src/features/chat/ChatView.tsx b/app/src/features/chat/ChatView.tsx index b053d480..b40683f0 100644 --- a/app/src/features/chat/ChatView.tsx +++ b/app/src/features/chat/ChatView.tsx @@ -15,6 +15,7 @@ import { import { useT } from "../../i18n"; import { day } from "../../lib/format"; import type { Bot } from "../../lib/protocol.gen"; +import { useApp } from "../../store/context"; import { Button } from "../../ui/Button"; import { Callout } from "../../ui/Callout"; import { SeenSince } from "../../ui/motion"; @@ -23,11 +24,14 @@ import { BotRun, type Live } from "./BotRun"; import { ChatComposer } from "./ChatComposer"; import { InboundRow } from "./InboundRow"; import { NoticeRow } from "./NoticeRow"; -import { chatRows } from "./rows"; +import { chatRows, rowHas } from "./rows"; import { useChat } from "./useChat"; import { useFiles } from "./useFiles"; import { useSharedSound } from "./useSharedSound"; +/** How long a search result stays marked (`search.css`). */ +const FOCUS_MS = 2400; + /** Distance from the bottom that still counts as "reading the latest". */ const STICKY_PX = 80; @@ -36,9 +40,13 @@ const hasFiles = (event: DragEvent) => event.dataTransfer.types.includes("Files" /** Skips the renders of the view around it; the text being written re-renders only the last run. */ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; stopped: boolean }) { const t = useT(); - const chat = useChat(bot.id); + const focus = useApp((state) => (state.focus?.botId === bot.id ? state.focus.itemId : null)); + const chat = useChat(bot.id, focus); const files = useFiles(); const scroller = useRef(null); + const listRef = useRef(null); + /** The search result already shown, so it is not scrolled to again. */ + const shownFocus = useRef(null); const atEnd = useRef(true); /** Distance from the bottom to keep while older items load above. */ const anchor = useRef(null); @@ -111,6 +119,23 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto /> ); }); + // A search result opened the chat: bring its row into view, marked. + useLayoutEffect(() => { + if (!focus || shownFocus.current === focus) { + return; + } + const row = listRef.current?.children[rows.findIndex((each) => rowHas(each, focus))]; + if (!row) { + return; + } + shownFocus.current = focus; + atEnd.current = false; + row.scrollIntoView?.({ block: "center" }); + row.classList.add("search-focus"); + // As long as the band's animation, also when it does not move. + setTimeout(() => row.classList.remove("search-focus"), FOCUS_MS); + }, [focus, rows]); + if (live && !openRun) { list.push(); } @@ -177,7 +202,11 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto )} -
    +
      {list}
    diff --git a/app/src/features/chat/rows.ts b/app/src/features/chat/rows.ts index 32cebe7f..ae8384be 100644 --- a/app/src/features/chat/rows.ts +++ b/app/src/features/chat/rows.ts @@ -60,6 +60,18 @@ export function chatRows(items: ChatItem[]): Row[] { return rows; } +/** Whether the row shows the chat item `id`. */ +export function rowHas(row: Row, id: string): boolean { + switch (row.kind) { + case "day": + return false; + case "run": + return row.items.some((item) => item.id === id); + default: + return row.item.id === id; + } +} + /** Tool calls that run together as lines; shared files are cards of their own. */ function isLine(item: ChatItem | undefined): boolean { return item?.body.kind === "tool" && !isSharedFiles(item); diff --git a/app/src/features/chat/useChat.ts b/app/src/features/chat/useChat.ts index db18d75a..b8bda927 100644 --- a/app/src/features/chat/useChat.ts +++ b/app/src/features/chat/useChat.ts @@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from "react"; import { errorText } from "../../lib/api"; -import type { BotId, ChatItem } from "../../lib/protocol.gen"; +import type { BotId, ChatItem, ChatItemId } from "../../lib/protocol.gen"; import { useApi, useApp } from "../../store/context"; const PAGE = 50; @@ -44,7 +44,8 @@ function merge(history: ChatItem[], live: ChatItem[]): ChatItem[] { return items; } -export function useChat(botId: BotId) { +/** With `focus`, the chat loads from that item to the newest (spec 8.8). */ +export function useChat(botId: BotId, focus: ChatItemId | null = null) { const api = useApi(); const open = useApp((state) => state.connection.kind === "open"); const [page, setPage] = useState(EMPTY); @@ -71,13 +72,19 @@ export function useChat(botId: BotId) { setPage((current) => ({ ...current, draft: current.draft + text })); } }); - api.call("chat.history", { botId, limit: PAGE }).then( + const newest = () => api.call("chat.history", { botId, limit: PAGE }); + // An item that is gone opens the chat as usual. + const first = focus + ? api.call("chat.history", { botId, until: focus }).catch(newest) + : newest(); + first.then( (history) => { if (alive) { setPage((current) => ({ ...current, items: merge(history, current.items), - complete: history.length < PAGE, + // From an item, older ones may still be there. + complete: !focus && history.length < PAGE, loading: false, })); } @@ -92,7 +99,7 @@ export function useChat(botId: BotId) { alive = false; unsubscribe(); }; - }, [api, open, botId]); + }, [api, open, botId, focus]); const oldest = page.items[0]?.id; const loadOlder = useCallback(async () => { diff --git a/app/src/features/crews/Sidebar.tsx b/app/src/features/crews/Sidebar.tsx index 23455007..17644684 100644 --- a/app/src/features/crews/Sidebar.tsx +++ b/app/src/features/crews/Sidebar.tsx @@ -1,11 +1,4 @@ -import { - ChevronDown, - ChevronsDownUp, - ChevronsUpDown, - MessageCircleQuestion, - Pause, - Plus, -} from "lucide-react"; +import { ChevronDown, ChevronsDownUp, ChevronsUpDown, Pause, Plus } from "lucide-react"; import { memo, useCallback, useState } from "react"; import { createPortal } from "react-dom"; import { useShallow } from "zustand/react/shallow"; @@ -14,7 +7,6 @@ import { when } from "../../lib/format"; import type { Activity, Bot, Crew } from "../../lib/protocol.gen"; import { activityOf, botsOf, crewList } from "../../store/app"; import { useApp } from "../../store/context"; -import { openQuestions } from "../../store/questions"; import { isUnread, unreadIn } from "../../store/seen"; import { Button } from "../../ui/Button"; import { ContextMenu, menuPoint, type Point } from "../../ui/ContextMenu"; @@ -27,13 +19,14 @@ import { ChiefBadge, isChief } from "../bots/ChiefBadge"; import { toolAction, toolTitle } from "../chat/toolNames"; import { CrewDialog } from "./CrewDialog"; import { setAllCollapsed, setCollapsed, useCollapsed, useCollapsedSet } from "./collapsed"; +import { QuestionsEntry, row, SearchEntry } from "./SidebarPages"; /** Crews as sections and their bots as conversations (spec 15.1). */ export function Sidebar() { const t = useT(); const words = t.crews.sidebar; const crews = useApp(useShallow(crewList)); - const overview = useApp((state) => state.selectedCrewId === null && !state.questionBox); + const overview = useApp((state) => state.selectedCrewId === null && !state.page); const selectCrew = useApp((state) => state.selectCrew); const collapsed = useCollapsedSet(); const allFolded = crews.length > 0 && crews.every((crew) => collapsed.has(crew.id)); @@ -68,6 +61,7 @@ export function Sidebar() { onClick={() => setCreating(true)} /> +
      {crews.map((crew) => ( @@ -81,42 +75,6 @@ export function Sidebar() { ); } -const row = - "relative flex w-full items-center rounded-lg text-left transition-colors duration-150 hover:bg-sunken"; - -/** The question box (spec 23.6), with how many questions wait. */ -function QuestionsEntry() { - const t = useT(); - const words = t.questions.box; - const count = useApp((state) => openQuestions(state).length); - const open = useApp((state) => state.questionBox); - const openBox = useApp((state) => state.openQuestionBox); - return ( -
      - -
      - ); -} - function CrewEntry({ crew }: { crew: Crew }) { const t = useT(); const bots = useApp(useShallow((state) => botsOf(state, crew.id))); diff --git a/app/src/features/crews/SidebarPages.tsx b/app/src/features/crews/SidebarPages.tsx new file mode 100644 index 00000000..2eb4945d --- /dev/null +++ b/app/src/features/crews/SidebarPages.tsx @@ -0,0 +1,66 @@ +// The pages at the top of the sidebar: the search (spec 8.8) and the +// question box (spec 23.6). + +import { MessageCircleQuestion, Search } from "lucide-react"; +import { useT } from "../../i18n"; +import { useApp } from "../../store/context"; +import { openQuestions } from "../../store/questions"; + +/** A row of the sidebar, as the conversations are. */ +export const row = + "relative flex w-full items-center rounded-lg text-left transition-colors duration-150 hover:bg-sunken"; + +/** The search (spec 8.8). */ +export function SearchEntry() { + const t = useT(); + const open = useApp((state) => state.page === "search"); + const openPage = useApp((state) => state.openPage); + return ( +
      + +
      + ); +} + +/** The question box (spec 23.6), with how many questions wait. */ +export function QuestionsEntry() { + const t = useT(); + const words = t.questions.box; + const count = useApp((state) => openQuestions(state).length); + const open = useApp((state) => state.page === "questions"); + const openPage = useApp((state) => state.openPage); + return ( +
      + +
      + ); +} diff --git a/app/src/features/search/SearchPage.tsx b/app/src/features/search/SearchPage.tsx new file mode 100644 index 00000000..c85d698e --- /dev/null +++ b/app/src/features/search/SearchPage.tsx @@ -0,0 +1,175 @@ +// Searching the chats (spec 8.8): a field, the crew to look in, and what +// was found as the owner types, newest first. A result opens its bot's +// chat at that point. + +import { LoaderCircle, Search } from "lucide-react"; +import { useEffect, useId, useRef, useState } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { useT } from "../../i18n"; +import { errorText } from "../../lib/api"; +import type { CrewId, SearchHit } from "../../lib/protocol.gen"; +import { crewList } from "../../store/app"; +import { useApi, useApp } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { Callout } from "../../ui/Callout"; +import { Select } from "../../ui/Select"; +import { SearchResult } from "./SearchResult"; + +const PAGE = 30; +/** Quiet time after a key before searching. */ +const TYPING_MS = 250; + +/** What the owner searched, kept while the app is open. */ +const kept = { query: "", crew: "" as CrewId | "" }; + +interface Found { + hits: SearchHit[]; + /** More may be older than the last one. */ + more: boolean; + loading: boolean; + error: string | null; +} + +const NONE: Found = { hits: [], more: false, loading: false, error: null }; + +/** Letters and digits in the query: fewer than 2 are not searched. */ +const enough = (query: string) => query.replace(/[^\p{L}\p{N}]/gu, "").length >= 2; + +export function SearchPage() { + const t = useT(); + const words = t.search; + const api = useApi(); + const crews = useApp(useShallow(crewList)); + const [query, setQuery] = useState(kept.query); + const [crew, setCrew] = useState( + kept.crew && crews.some((c) => c.id === kept.crew) ? kept.crew : "", + ); + const [found, setFound] = useState(NONE); + /** Which search the shown results are for; older answers are dropped. */ + const latest = useRef(0); + const fieldId = useId(); + + kept.query = query; + kept.crew = crew; + const trimmed = query.trim(); + + useEffect(() => { + const ask = ++latest.current; + if (!enough(trimmed)) { + setFound(NONE); + return; + } + setFound((current) => ({ ...current, loading: true, error: null })); + const timer = setTimeout(() => { + api + .call("chat.search", { query: trimmed, limit: PAGE, ...(crew ? { crewId: crew } : {}) }) + .then( + (hits) => + ask === latest.current && + setFound({ hits, more: hits.length === PAGE, loading: false, error: null }), + (error) => ask === latest.current && setFound({ ...NONE, error: errorText(error) }), + ); + }, TYPING_MS); + return () => clearTimeout(timer); + }, [api, trimmed, crew]); + + const loadMore = () => { + const before = found.hits.at(-1)?.item.id; + if (!before) { + return; + } + const ask = latest.current; + setFound((current) => ({ ...current, loading: true })); + api + .call("chat.search", { + query: trimmed, + limit: PAGE, + before, + ...(crew ? { crewId: crew } : {}), + }) + .then( + (older) => + ask === latest.current && + setFound((current) => ({ + hits: [...current.hits, ...older], + more: older.length === PAGE, + loading: false, + error: null, + })), + (error) => + ask === latest.current && + setFound((current) => ({ ...current, loading: false, error: errorText(error) })), + ); + }; + + const scopes = [ + { value: "" as CrewId | "", label: words.allCrews }, + ...crews.map((c) => ({ value: c.id as CrewId | "", label: c.name })), + ]; + const searched = enough(trimmed); + + return ( +
      +
      + +
      + + setQuery(event.target.value)} + // What was searched before comes back selected, ready to replace. + onFocus={(event) => event.currentTarget.select()} + className="h-10 min-w-0 flex-1 bg-transparent text-base outline-none placeholder:text-muted" + /> + {found.loading && ( + + )} +
      +
      +