Files
sovereign_browser_rust/src/agent_conversations.rs
T

304 lines
12 KiB
Rust

//! Agent chat conversation persistence.
//!
//! Port of `agent_conversations.c` from the C project. Conversations are
//! stored locally in SQLite (`agent_sessions` / `agent_messages`) — that is
//! the source of truth, so chat works in no-login mode too. When a signer
//! is available each conversation is also synced to Nostr as a
//! NIP-44-encrypted (self-to-self) kind 30078 event:
//!
//! ```text
//! kind 30078, tags [["d", id], ["t", "client-ai-chat-v1"], ["client", "sovereign_browser"]]
//! content = nip44({ "schema": 1, "title": "...", "messages": [...] })
//! ```
//!
//! Only user/assistant messages are synced (tool results can be huge).
//! All signing / relay work runs on worker threads so the sovereign://
//! scheme handler (GTK main thread) never blocks on a remote signer.
use nostr_signer::traits::NostrSigner;
use once_cell::sync::Lazy;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::sync::Mutex;
use crate::agent_chat_store;
use crate::nostr_publish;
/// The Nostr kind for arbitrary custom app data (NIP-78).
const AGENT_CONV_KIND: u64 = 30078;
/// The t-tag that identifies conversation events.
const AGENT_CONV_T_TAG: &str = "client-ai-chat-v1";
const DEFAULT_TITLE: &str = "New Chat";
/// Whether the one-shot Nostr → local import has run for this identity.
static G_IMPORTED: AtomicBool = AtomicBool::new(false);
/// Serializes publish threads so saves of the same conversation land in order.
static G_PUBLISH_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
/// Initialize for a (new) identity. The signer is read from the app state
/// at use time; the arguments are kept for API compatibility.
pub fn agent_conversations_init(_signer: Option<Arc<dyn NostrSigner>>, _pubkey_hex: &str) {
G_IMPORTED.store(false, Ordering::SeqCst);
agent_chat_store::agent_chat_store_init();
}
/// Update the signer reference.
pub fn agent_conversations_set_signer(signer: Option<Arc<dyn NostrSigner>>, pubkey_hex: &str) {
agent_conversations_init(signer, pubkey_hex);
}
/// Derive a title from the first user message (60 chars, single line).
fn derive_title(messages: &[serde_json::Value]) -> String {
messages
.iter()
.find(|m| m["role"] == "user")
.and_then(|m| m["content"].as_str())
.map(|c| {
let t: String = c.chars().take(60).map(|ch| if ch == '\n' || ch == '\r' { ' ' } else { ch }).collect();
t.trim().to_string()
})
.filter(|t| !t.is_empty())
.unwrap_or_else(|| DEFAULT_TITLE.to_string())
}
/// The user's cached kind 30078 conversation events, newest per d-tag.
fn cached_conversation_events() -> Vec<serde_json::Value> {
let pk = nostr_publish::my_pubkey();
if pk.is_empty() {
return Vec::new();
}
let events = crate::db::db_query_events(&[AGENT_CONV_KIND], Some(&[pk]), None, None, Some(1000))
.unwrap_or_default()
.into_iter()
.filter(|e| nostr_publish::has_tag(e, "t", AGENT_CONV_T_TAG))
.collect();
nostr_publish::latest_per_d_tag(events)
}
/// Decrypt a conversation event into (title, messages).
fn decrypt_event(event: &serde_json::Value) -> Option<(String, Vec<serde_json::Value>)> {
let content = event["content"].as_str()?;
let plaintext = nostr_publish::nip44_decrypt_self(content)
.map_err(|e| eprintln!("[agent_conv] {}", e))
.ok()?;
let payload: serde_json::Value = serde_json::from_str(&plaintext).ok()?;
let messages = payload["messages"].as_array().cloned().unwrap_or_default();
let title = payload["title"]
.as_str()
.filter(|t| !t.is_empty())
.map(String::from)
.unwrap_or_else(|| derive_title(&messages));
Some((title, messages))
}
/// Import conversations that exist on Nostr (e.g. from another device)
/// but not locally. Runs once per identity on a worker thread.
fn spawn_import_once() {
if G_IMPORTED.swap(true, Ordering::SeqCst) || !nostr_publish::have_signer() {
return;
}
std::thread::spawn(|| {
// Skip conversations that already have local messages. Sessions
// with a row but no messages (a failed earlier import) are retried.
let local: std::collections::HashSet<String> = crate::db::db_agent_sessions_list()
.unwrap_or_default()
.into_iter()
.map(|(id, _, _)| id)
.filter(|id| crate::db::db_agent_message_count(id).unwrap_or(0) > 0)
.collect();
let mut imported = 0;
for ev in cached_conversation_events() {
let Some(id) = nostr_publish::tag_value(&ev, "d").map(String::from) else { continue };
if id.is_empty() || local.contains(&id) {
continue;
}
if let Some((title, messages)) = decrypt_event(&ev) {
match crate::db::db_agent_messages_replace(&id, &messages) {
Ok(()) => {
let _ = crate::db::db_agent_session_set_title(&id, &title);
imported += 1;
}
Err(e) => eprintln!("[agent_conv] Failed to import {}: {}", id, e),
}
}
}
if imported > 0 {
println!("[agent_conv] Imported {} conversation(s) from Nostr", imported);
}
});
}
/// List conversations (newest first) as `[{id, title, updated_at}]`.
pub fn agent_conversations_list() -> Vec<serde_json::Value> {
spawn_import_once();
crate::db::db_agent_sessions_list()
.unwrap_or_default()
.into_iter()
.map(|(id, title, updated)| {
serde_json::json!({
"id": id,
"title": if title.is_empty() { DEFAULT_TITLE.to_string() } else { title },
"updated_at": updated,
})
})
.collect()
}
fn local_title(id: &str) -> Option<String> {
crate::db::db_agent_sessions_list()
.ok()?
.into_iter()
.find(|(sid, _, _)| sid == id)
.map(|(_, t, _)| t)
.filter(|t| !t.is_empty())
}
/// Publish the conversation `id` (with `title`) to Nostr in the background.
fn spawn_publish(id: String, title: String) {
if !nostr_publish::have_signer() {
return;
}
// Only user/assistant turns with text are synced (tool results can be
// huge; tool_call-only assistant turns are meaningless without them).
let messages: Vec<serde_json::Value> = crate::db::db_agent_messages_get(&id)
.unwrap_or_default()
.into_iter()
.filter(|m| m["role"] == "user" || m["role"] == "assistant")
.filter(|m| !m["content"].as_str().unwrap_or("").trim().is_empty())
.map(|m| serde_json::json!({"role": m["role"], "content": m["content"]}))
.collect();
std::thread::spawn(move || {
let _guard = G_PUBLISH_LOCK.lock().unwrap();
let payload = serde_json::json!({"schema": 1, "title": title, "messages": messages});
let result = nostr_publish::nip44_encrypt_self(&payload.to_string()).and_then(|ct| {
nostr_publish::sign_store_publish(
AGENT_CONV_KIND,
vec![
vec!["d".into(), id.clone()],
vec!["t".into(), AGENT_CONV_T_TAG.into()],
vec!["client".into(), "sovereign_browser".into()],
],
&ct,
)
});
if let Err(e) = result {
eprintln!("[agent_conv] Failed to sync conversation {} to Nostr: {}", id, e);
}
});
}
/// Save the current conversation. If `conversation_id` is given the
/// current session is (re)bound to it. The title is kept when not
/// provided (so an auto-save never overwrites a user rename). Returns the
/// conversation id.
pub fn agent_conversations_save(conversation_id: Option<&str>, title: Option<&str>) -> Option<String> {
let id = match conversation_id.filter(|s| !s.is_empty()) {
Some(id) => {
if agent_chat_store::agent_chat_store_session_id().as_deref() != Some(id) {
agent_chat_store::agent_chat_store_set_session(id);
}
id.to_string()
}
None => agent_chat_store::agent_chat_store_get_session(),
};
let use_title = match title.filter(|t| !t.trim().is_empty()) {
Some(t) => t.trim().to_string(),
None => local_title(&id)
.filter(|t| t != DEFAULT_TITLE)
.unwrap_or_else(|| derive_title(&agent_chat_store::agent_chat_store_get_messages())),
};
let _ = crate::db::db_agent_session_set_title(&id, &use_title);
spawn_publish(id.clone(), use_title);
Some(id)
}
/// Make a conversation current. If it has no local messages but a
/// Nostr copy exists, decrypt and import it first.
pub fn agent_conversations_load(conversation_id: &str) -> Result<(), String> {
if conversation_id.is_empty() {
return Err("Missing conversation id".to_string());
}
// Import from Nostr when there's no local copy — or an empty one (e.g.
// an earlier import that failed to write its messages).
let has_local_messages = crate::db::db_agent_message_count(conversation_id).unwrap_or(0) > 0;
if !has_local_messages {
if let Some(ev) = cached_conversation_events()
.into_iter()
.find(|e| nostr_publish::tag_value(e, "d") == Some(conversation_id))
{
let (title, messages) =
decrypt_event(&ev).ok_or_else(|| "Failed to decrypt conversation".to_string())?;
crate::db::db_agent_messages_replace(conversation_id, &messages).map_err(|e| e.to_string())?;
let _ = crate::db::db_agent_session_set_title(conversation_id, &title);
}
}
agent_chat_store::agent_chat_store_set_session(conversation_id);
Ok(())
}
/// Delete a conversation locally and (in the background) publish a
/// NIP-09 kind 5 deletion for its Nostr copy.
pub fn agent_conversations_delete(conversation_id: &str) -> Result<(), String> {
if conversation_id.is_empty() {
return Err("Missing conversation id".to_string());
}
agent_chat_store::agent_chat_store_delete_session(conversation_id);
let events: Vec<serde_json::Value> = cached_conversation_events()
.into_iter()
.filter(|e| nostr_publish::tag_value(e, "d") == Some(conversation_id))
.collect();
if events.is_empty() || !nostr_publish::have_signer() {
return Ok(());
}
let pk = nostr_publish::my_pubkey();
let d = conversation_id.to_string();
std::thread::spawn(move || {
let mut tags: Vec<Vec<String>> = events
.iter()
.filter_map(|e| e["id"].as_str())
.map(|id| vec!["e".to_string(), id.to_string()])
.collect();
tags.push(vec!["a".into(), format!("{}:{}:{}", AGENT_CONV_KIND, pk, d)]);
match nostr_publish::sign_store_publish(5, tags, "Deleted conversation") {
Ok(_) => {
for e in &events {
if let Some(id) = e["id"].as_str() {
nostr_publish::delete_local_event(id);
}
}
}
Err(e) => eprintln!("[agent_conv] Failed to publish deletion: {}", e),
}
});
Ok(())
}
/// Rename a conversation (local title + re-publish).
pub fn agent_conversations_rename(conversation_id: &str, title: &str) -> Result<(), String> {
if conversation_id.is_empty() || title.trim().is_empty() {
return Err("Missing id or title".to_string());
}
crate::db::db_agent_session_set_title(conversation_id, title.trim()).map_err(|e| e.to_string())?;
spawn_publish(conversation_id.to_string(), title.trim().to_string());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn derives_titles() {
let msgs = vec![
serde_json::json!({"role": "assistant", "content": "hi"}),
serde_json::json!({"role": "user", "content": "line one\nline two"}),
];
assert_eq!(derive_title(&msgs), "line one line two");
assert_eq!(derive_title(&[]), DEFAULT_TITLE);
let long = "x".repeat(100);
assert_eq!(derive_title(&[serde_json::json!({"role":"user","content":long})]).len(), 60);
}
}