304 lines
12 KiB
Rust
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);
|
|
}
|
|
}
|