diff --git a/Cargo.lock b/Cargo.lock index e3a709e..cf088c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3055,7 +3055,7 @@ dependencies = [ [[package]] name = "sovereign_browser" -version = "0.0.9" +version = "0.0.10" dependencies = [ "anyhow", "base64", @@ -3073,6 +3073,7 @@ dependencies = [ "hex", "hmac", "image", + "javascriptcore-rs", "lazy_static", "log", "nostr-core", diff --git a/Cargo.toml b/Cargo.toml index bbfde8f..a0ea914 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ members = [ [package] name = "sovereign_browser" -version = "0.0.9" +version = "0.0.10" edition = "2021" license = "MIT" description = "A Linux x86 web browser built on WebKitGTK with Nostr identity" @@ -24,6 +24,9 @@ gtk = { version = "0.18", features = ["v3_24"] } gdk = "0.18" gdk-pixbuf = "0.18" webkit2gtk = { version = "2.0", features = ["v2_40"] } +# JSC value accessors (to_json / to_str) for evaluate_javascript results. +# Same version webkit2gtk already depends on (no new crate download). +javascriptcore-rs = "=1.1" glib = "0.18" gio = { version = "0.18", features = ["v2_76"] } cairo-rs = "0.18" diff --git a/VERSION b/VERSION index c5d54ec..7c1886b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.9 +0.0.10 diff --git a/src/agent_chat.rs b/src/agent_chat.rs index eccb5e8..1be3e75 100644 --- a/src/agent_chat.rs +++ b/src/agent_chat.rs @@ -1,4 +1,4 @@ -//! Agent chat management +//! Agent chat entry points. //! //! Port of `agent_chat.c` / `agent_chat.h` from the C project. @@ -9,12 +9,23 @@ pub fn agent_chat_init() { agent_chat_store::agent_chat_store_init(); } -/// Handle a chat request from the web UI. -pub fn agent_chat_handle_request(_request: &str) -> String { - // TODO: implement chat request handling - serde_json::json!({ - "status": "ok", - "message": "Chat handler not yet fully implemented" - }) - .to_string() +/// Route URL-bar input of the form `; ` to the embedded agent. +/// +/// Mirrors the C `agent_chat_route_input` (agent_chat.c:17): opens the +/// agent sidebar (if hidden) so the chat appears next to the current +/// page, then starts the agent loop with the message. The sidebar's chat +/// page polls `sovereign://agents/messages`, so it picks up the reply. +pub fn agent_chat_route_input(message: Option<&str>) -> Result<(), String> { + if !crate::tab_manager::tab_manager_sidebar_visible() { + crate::tab_manager::tab_manager_toggle_sidebar(); + } + match message.map(str::trim).filter(|m| !m.is_empty()) { + Some(msg) => crate::agent_loop::agent_loop_run(msg), + None => Ok(()), + } +} + +/// The current chat session id, if one has been chosen. +pub fn agent_chat_session_id() -> Option { + agent_chat_store::agent_chat_store_session_id() } diff --git a/src/agent_chat_store.rs b/src/agent_chat_store.rs index f210e08..fae6ea1 100644 --- a/src/agent_chat_store.rs +++ b/src/agent_chat_store.rs @@ -1,100 +1,132 @@ -//! Agent chat message store +//! Agent chat message store. //! -//! Port of `agent_chat_store.c` / `agent_chat_store.h` from the C project. +//! Port of `agent_chat_store.c` from the C project: a thin wrapper over +//! the per-profile SQLite database that tracks the *current* chat session +//! and stores its messages in OpenAI chat-completions format: +//! +//! ```text +//! {"role":"user","content":"..."} +//! {"role":"assistant","content":"...","tool_calls":[...]} +//! {"role":"tool","tool_call_id":"...","content":"..."} +//! ``` +//! +//! Messages are kept as raw JSON so tool_calls round-trip exactly as the +//! LLM returned them. Safe to call from the agent worker thread (the DB +//! connection is behind a mutex and opened with SQLITE_OPEN_FULL_MUTEX). -use std::sync::Mutex; use once_cell::sync::Lazy; +use std::sync::Mutex; -/// A single chat message. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct ChatMessage { - pub role: String, - pub content: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_calls: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_call_id: Option, +/// The current session id (None = not chosen yet). +static G_SESSION: Lazy>> = Lazy::new(|| Mutex::new(None)); + +/// Reset the store (the current session is re-resolved on next use). +/// Called at startup and after an identity switch (new database). +pub fn agent_chat_store_init() { + *G_SESSION.lock().unwrap() = None; } -/// A tool call within a message. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct ToolCall { - pub id: String, - #[serde(rename = "type")] - pub call_type: String, - pub function: ToolCallFunction, +/// Get the current session id, loading the most recent session from the +/// database or creating a new one if none exists. +pub fn agent_chat_store_get_session() -> String { + let mut guard = G_SESSION.lock().unwrap(); + if let Some(id) = guard.as_ref() { + return id.clone(); + } + let id = crate::db::db_agent_session_latest() + .ok() + .flatten() + .unwrap_or_else(|| { + let id = uuid::Uuid::new_v4().to_string(); + let _ = crate::db::db_agent_session_upsert(&id, "New Chat"); + id + }); + *guard = Some(id.clone()); + id } -/// A tool call function. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct ToolCallFunction { - pub name: String, - pub arguments: String, +/// The current session id without creating one. +pub fn agent_chat_store_session_id() -> Option { + G_SESSION.lock().unwrap().clone() } -/// The chat session state. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct ChatSession { - pub messages: Vec, - pub system_prompt: String, +/// Start a new (empty) session and make it current. Returns its id. +pub fn agent_chat_store_new_session(title: &str) -> String { + let id = uuid::Uuid::new_v4().to_string(); + let _ = crate::db::db_agent_session_upsert(&id, title); + *G_SESSION.lock().unwrap() = Some(id.clone()); + id } -impl Default for ChatSession { - fn default() -> Self { - ChatSession { - messages: Vec::new(), - system_prompt: String::new(), +/// Make an existing (or new) session id current. +pub fn agent_chat_store_set_session(id: &str) { + let _ = crate::db::db_agent_session_upsert(id, ""); + *G_SESSION.lock().unwrap() = Some(id.to_string()); +} + +/// Store a message. Errors are returned (and logged) — the agent loop +/// must stop if the transcript can't be persisted, otherwise the LLM is +/// called with a history that is missing the user's question / tool +/// results and loops blindly. +fn add(msg: serde_json::Value) -> Result<(), String> { + let sid = agent_chat_store_get_session(); + crate::db::db_agent_message_add(&sid, &msg).map_err(|e| { + let e = format!("Failed to store chat message: {}", e); + eprintln!("[agent_chat_store] {}", e); + e + }) +} + +/// Add a user message to the current session. +pub fn agent_chat_store_add_user_message(content: &str) -> Result<(), String> { + add(serde_json::json!({"role": "user", "content": content})) +} + +/// Add an assistant message (with optional tool_calls array). +pub fn agent_chat_store_add_assistant_message( + content: &str, + tool_calls: Option<&serde_json::Value>, +) -> Result<(), String> { + let mut msg = serde_json::json!({"role": "assistant", "content": content}); + if let Some(tc) = tool_calls { + if tc.as_array().map(|a| !a.is_empty()).unwrap_or(false) { + msg["tool_calls"] = tc.clone(); } } + add(msg) +} + +/// Add a tool result message. +pub fn agent_chat_store_add_tool_result(tool_call_id: &str, content: &str) -> Result<(), String> { + add(serde_json::json!({ + "role": "tool", + "tool_call_id": tool_call_id, + "content": content, + })) +} + +/// All messages of the current session, in OpenAI format. +pub fn agent_chat_store_get_messages() -> Vec { + let sid = agent_chat_store_get_session(); + crate::db::db_agent_messages_get(&sid).unwrap_or_else(|e| { + eprintln!("[agent_chat_store] Failed to load messages: {}", e); + Vec::new() + }) +} + +/// Replace the current session's messages (conversation load). +pub fn agent_chat_store_replace_messages(messages: &[serde_json::Value]) { + let sid = agent_chat_store_get_session(); + if let Err(e) = crate::db::db_agent_messages_replace(&sid, messages) { + eprintln!("[agent_chat_store] Failed to replace messages: {}", e); + } } -/// Global chat store. -static G_CHAT_STORE: Lazy> = Lazy::new(|| Mutex::new(ChatSession::default())); - -/// Initialize the chat store. -pub fn agent_chat_store_init() { - let mut store = G_CHAT_STORE.lock().unwrap(); - *store = ChatSession::default(); -} - -/// Add a message to the chat store. -pub fn agent_chat_store_add_message(msg: ChatMessage) { - let mut store = G_CHAT_STORE.lock().unwrap(); - store.messages.push(msg); -} - -/// Get all messages. -pub fn agent_chat_store_get_messages() -> Vec { - let store = G_CHAT_STORE.lock().unwrap(); - store.messages.clone() -} - -/// Clear all messages. -pub fn agent_chat_store_clear() { - let mut store = G_CHAT_STORE.lock().unwrap(); - store.messages.clear(); -} - -/// Set the system prompt. -pub fn agent_chat_store_set_system_prompt(prompt: &str) { - let mut store = G_CHAT_STORE.lock().unwrap(); - store.system_prompt = prompt.to_string(); -} - -/// Get the system prompt. -pub fn agent_chat_store_get_system_prompt() -> String { - let store = G_CHAT_STORE.lock().unwrap(); - store.system_prompt.clone() -} - -/// Get the full session. -pub fn agent_chat_store_get_session() -> ChatSession { - let store = G_CHAT_STORE.lock().unwrap(); - store.clone() -} - -/// Load a session. -pub fn agent_chat_store_load_session(session: ChatSession) { - let mut store = G_CHAT_STORE.lock().unwrap(); - *store = session; +/// Delete a session locally; clears the current session if it matches. +pub fn agent_chat_store_delete_session(id: &str) { + let _ = crate::db::db_agent_session_delete(id); + let mut guard = G_SESSION.lock().unwrap(); + if guard.as_deref() == Some(id) { + *guard = None; + } } diff --git a/src/agent_conversations.rs b/src/agent_conversations.rs index c3270f2..17bd966 100644 --- a/src/agent_conversations.rs +++ b/src/agent_conversations.rs @@ -1,47 +1,45 @@ -//! Nostr conversation persistence for agent chat +//! Agent chat conversation persistence. //! -//! Port of `agent_conversations.c` / `agent_conversations.h` from the C project. -//! Uses `nostr_signer` from `rust_core_lib`. +//! 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 once_cell::sync::Lazy; -use crate::db; 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"; -/// Global state for conversation persistence. -static G_CONV_STATE: Lazy> = Lazy::new(|| Mutex::new(ConvState::default())); +/// 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> = Lazy::new(|| Mutex::new(())); -struct ConvState { - signer: Option>, - pubkey_hex: String, - have_signer: bool, -} - -impl Default for ConvState { - fn default() -> Self { - ConvState { - signer: None, - pubkey_hex: String::new(), - have_signer: false, - } - } -} - -/// Initialize the conversation persistence module. -pub fn agent_conversations_init(signer: Option>, pubkey_hex: &str) { - let mut state = G_CONV_STATE.lock().unwrap(); - state.signer = signer; - state.pubkey_hex = pubkey_hex.to_string(); - state.have_signer = state.signer.is_some() && !state.pubkey_hex.is_empty(); +/// 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>, _pubkey_hex: &str) { + G_IMPORTED.store(false, Ordering::SeqCst); + agent_chat_store::agent_chat_store_init(); } /// Update the signer reference. @@ -49,116 +47,257 @@ pub fn agent_conversations_set_signer(signer: Option>, pubk agent_conversations_init(signer, pubkey_hex); } -/// List all saved conversations from the local SQLite cache. -pub fn agent_conversations_list() -> Vec { - match db::db_query_events(&[AGENT_CONV_KIND], None, None, None, None) { - Ok(events) => { - let mut result = Vec::new(); - for event in events { - let tags = event["tags"].as_array(); - let has_conv_tag = tags.map_or(false, |t| { - t.iter().any(|tag| { - tag.as_array() - .map_or(false, |a| a.len() >= 2 && a[0] == "t" && a[1] == AGENT_CONV_T_TAG) - }) - }); +/// 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()) +} - if has_conv_tag { - let conv_id = tags.and_then(|t| { - t.iter().find_map(|tag| { - tag.as_array().and_then(|a| { - if a.len() >= 2 && a[0] == "d" { - Some(a[1].as_str().unwrap_or("").to_string()) - } else { - None - } - }) - }) - }).unwrap_or_default(); +/// The user's cached kind 30078 conversation events, newest per d-tag. +fn cached_conversation_events() -> Vec { + 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) +} - result.push(serde_json::json!({ - "id": conv_id, - "title": event["content"].as_str().unwrap_or("Untitled"), - "updated_at": event["created_at"], - })); +/// Decrypt a conversation event into (title, messages). +fn decrypt_event(event: &serde_json::Value) -> Option<(String, Vec)> { + 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 = 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), } } - result } - Err(_) => Vec::new(), - } + if imported > 0 { + println!("[agent_conv] Imported {} conversation(s) from Nostr", imported); + } + }); } -/// Save the current agent chat session to Nostr as a kind 30078 event. -pub fn agent_conversations_save( - conversation_id: Option<&str>, - title: Option<&str>, -) -> Option { - let state = G_CONV_STATE.lock().unwrap(); - if !state.have_signer { - return None; - } - - let conv_id = conversation_id - .map(|s| s.to_string()) - .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); - - let session = agent_chat_store::agent_chat_store_get_session(); - let _title = title - .map(|s| s.to_string()) - .unwrap_or_else(|| { - session.messages.first() - .map(|m| { - let truncated: String = m.content.chars().take(60).collect(); - truncated - }) - .unwrap_or_else(|| "Untitled".to_string()) - }); - - println!( - "[conversations] Save requested: id={} (NIP-44 encryption not yet implemented)", - conv_id - ); - - Some(conv_id) -} - -/// Load a conversation from the local SQLite cache. -pub fn agent_conversations_load(conversation_id: &str) -> Result<(), Box> { - let events = db::db_query_events( - &[AGENT_CONV_KIND], - None, - None, - None, - Some(1), - )?; - - for event in events { - let tags = event["tags"].as_array(); - let has_id = tags.map_or(false, |t| { - t.iter().any(|tag| { - tag.as_array() - .map_or(false, |a| a.len() >= 2 && a[0] == "d" && a[1] == conversation_id) +/// List conversations (newest first) as `[{id, title, updated_at}]`. +pub fn agent_conversations_list() -> Vec { + 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() +} - if has_id { - if let Some(content_str) = event["content"].as_str() { - if let Ok(data) = serde_json::from_str::(content_str) { - if let Some(messages) = data["messages"].as_array() { - let msgs: Vec = - serde_json::from_value(serde_json::Value::Array(messages.clone()))?; - let session = agent_chat_store::ChatSession { - messages: msgs, - system_prompt: String::new(), - }; - agent_chat_store::agent_chat_store_load_session(session); - return Ok(()); +fn local_title(id: &str) -> Option { + 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 = 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 { + 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 = 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> = 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), } - } - - Err("Conversation not found".into()) + }); + 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); + } } diff --git a/src/agent_llm.rs b/src/agent_llm.rs index d6cc48d..1d96af2 100644 --- a/src/agent_llm.rs +++ b/src/agent_llm.rs @@ -1,82 +1,371 @@ -//! LLM client for agent chat +//! Agent configuration + OpenAI-compatible LLM client. //! -//! Port of `agent_llm.c` / `agent_llm.h` from the C project. +//! Port of `agent_llm.c` and the agent fields of `settings.c` from the C +//! project. The configuration (providers, active provider, model, max +//! iterations, and the "Sovereign Browser Skill" fields) is persisted as a +//! single JSON blob in the per-profile `kv_store` under `agent.config`. +//! +//! The HTTP calls are *blocking* (reqwest::blocking) — callers must run +//! them off the GTK main thread (the agent loop runs in its own thread and +//! the `sovereign://agents/models` route spawns a worker thread). -use std::sync::Mutex; use once_cell::sync::Lazy; +use std::sync::Mutex; +use std::time::Duration; -/// LLM provider configuration. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +/// Default system prompt / Sovereign Browser Skill template. +/// Mirrors `SETTINGS_AGENT_SYSTEM_PROMPT_DEFAULT` (settings.h:39). +pub const AGENT_SYSTEM_PROMPT_DEFAULT: &str = "You are an AI assistant embedded in a web browser. \ +You have access to browser automation tools (navigate, snapshot, click, fill, etc.) and system \ +tools (filesystem read/write, shell command execution). Use the snapshot tool to understand page \ +content, then interact with elements using refs (e.g. @e1). You can read and write files and run \ +shell commands. Be concise in your responses. When a task is complete, summarize what you did."; + +pub const AGENT_SKILL_NAME_DEFAULT: &str = "Sovereign Browser Default"; +pub const AGENT_SKILL_DESCRIPTION_DEFAULT: &str = "Default agent skill for sovereign_browser"; +pub const AGENT_SKILL_REQUIRES_TOOLS_DEFAULT: &str = "browser, fs, shell"; +pub const AGENT_MAX_ITERATIONS_DEFAULT: i32 = 100; + +const CONFIG_KEY: &str = "agent.config"; +const LEGACY_PROVIDER_KEY: &str = "agent.llm_provider"; + +/// One OpenAI-compatible LLM endpoint. +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] pub struct LlmProvider { + #[serde(default)] pub name: String, + #[serde(default)] pub base_url: String, + #[serde(default)] pub api_key: String, + /// Model ids last fetched from `{base_url}/models` (for the dropdown). + #[serde(default)] + pub models: Vec, +} + +fn default_max_iterations() -> i32 { + AGENT_MAX_ITERATIONS_DEFAULT +} + +/// The full agent configuration. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct AgentConfig { + #[serde(default)] + pub providers: Vec, + /// Index into `providers`, or -1 when none. + #[serde(default)] + pub active_provider: i32, + #[serde(default)] pub model: String, + #[serde(default = "default_max_iterations")] + pub max_iterations: i32, + #[serde(default)] + pub skill_name: String, + #[serde(default)] + pub skill_description: String, + #[serde(default)] + pub skill_template: String, + #[serde(default)] + pub skill_requires_tools: String, } -/// Global LLM state. -static G_LLM_STATE: Lazy> = Lazy::new(|| Mutex::new(LlmState::default())); - -struct LlmState { - provider: Option, -} - -impl Default for LlmState { +impl Default for AgentConfig { fn default() -> Self { - LlmState { provider: None } - } -} - -/// Initialize the LLM module. -pub fn agent_llm_init() { - let mut state = G_LLM_STATE.lock().unwrap(); - if let Ok(Some(config)) = crate::db::db_kv_get("agent.llm_provider") { - if let Ok(provider) = serde_json::from_str::(&config) { - state.provider = Some(provider); + AgentConfig { + providers: vec![LlmProvider { + name: "Default".to_string(), + ..Default::default() + }], + active_provider: 0, + model: String::new(), + max_iterations: AGENT_MAX_ITERATIONS_DEFAULT, + skill_name: String::new(), + skill_description: String::new(), + skill_template: String::new(), + skill_requires_tools: String::new(), } } } -/// Set the LLM provider. -pub fn agent_llm_set_provider(provider: LlmProvider) { - let mut state = G_LLM_STATE.lock().unwrap(); - state.provider = Some(provider.clone()); - if let Ok(config) = serde_json::to_string(&provider) { - let _ = crate::db::db_kv_set("agent.llm_provider", &config); +impl AgentConfig { + /// The active provider, if any. + pub fn active(&self) -> Option<&LlmProvider> { + usize::try_from(self.active_provider) + .ok() + .and_then(|i| self.providers.get(i)) + } + + pub fn active_mut(&mut self) -> Option<&mut LlmProvider> { + usize::try_from(self.active_provider) + .ok() + .and_then(move |i| self.providers.get_mut(i)) + } + + pub fn base_url(&self) -> String { + self.active().map(|p| p.base_url.clone()).unwrap_or_default() + } + + pub fn api_key(&self) -> String { + self.active().map(|p| p.api_key.clone()).unwrap_or_default() + } + + /// Effective max iterations (falls back to the default when unset). + pub fn effective_max_iterations(&self) -> i32 { + if self.max_iterations > 0 { + self.max_iterations + } else { + AGENT_MAX_ITERATIONS_DEFAULT + } + } + + pub fn effective_skill_name(&self) -> String { + non_empty_or(&self.skill_name, AGENT_SKILL_NAME_DEFAULT) + } + + pub fn effective_skill_description(&self) -> String { + non_empty_or(&self.skill_description, AGENT_SKILL_DESCRIPTION_DEFAULT) + } + + pub fn effective_skill_template(&self) -> String { + non_empty_or(&self.skill_template, AGENT_SYSTEM_PROMPT_DEFAULT) + } + + pub fn effective_skill_requires_tools(&self) -> String { + non_empty_or(&self.skill_requires_tools, AGENT_SKILL_REQUIRES_TOOLS_DEFAULT) + } + + /// Keep `active_provider` pointing at a valid entry. + fn normalize(&mut self) { + if self.providers.is_empty() { + self.active_provider = -1; + } else if self.active_provider < 0 || self.active_provider as usize >= self.providers.len() { + self.active_provider = 0; + } } } -/// Get the current LLM provider. -pub fn agent_llm_get_provider() -> Option { - let state = G_LLM_STATE.lock().unwrap(); - state.provider.clone() +fn non_empty_or(v: &str, default: &str) -> String { + if v.trim().is_empty() { + default.to_string() + } else { + v.to_string() + } } -/// Send a chat completion request to the LLM. -pub async fn agent_llm_chat_completion( - messages: &[serde_json::Value], - tools: Option<&[serde_json::Value]>, -) -> Result> { - let state = G_LLM_STATE.lock().unwrap(); - let provider = state.provider.as_ref().ok_or("No LLM provider configured")?; +/// In-memory cache of the config (None = not loaded yet). +static G_CONFIG: Lazy>> = Lazy::new(|| Mutex::new(None)); - let client = reqwest::Client::new(); +fn load_from_db() -> AgentConfig { + if let Ok(Some(json)) = crate::db::db_kv_get(CONFIG_KEY) { + if let Ok(mut cfg) = serde_json::from_str::(&json) { + cfg.normalize(); + return cfg; + } + } + // Migrate the legacy single-provider key written by earlier Rust builds. + let mut cfg = AgentConfig::default(); + if let Ok(Some(json)) = crate::db::db_kv_get(LEGACY_PROVIDER_KEY) { + if let Ok(v) = serde_json::from_str::(&json) { + let s = |k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string(); + cfg.providers = vec![LlmProvider { + name: if s("name").is_empty() { "Default".into() } else { s("name") }, + base_url: s("base_url"), + api_key: s("api_key"), + models: Vec::new(), + }]; + cfg.model = s("model"); + } + } + cfg.normalize(); + cfg +} + +fn save_to_db(cfg: &AgentConfig) { + if let Ok(json) = serde_json::to_string(cfg) { + let _ = crate::db::db_kv_set(CONFIG_KEY, &json); + } +} + +/// Initialize the LLM/config module (loads lazily on first use). +pub fn agent_llm_init() { + agent_config_reload(); +} + +/// Drop the cached config so it is re-read from the (possibly switched) +/// per-profile database on next access. Called after login. +pub fn agent_config_reload() { + *G_CONFIG.lock().unwrap() = None; +} + +/// Get a snapshot of the current agent config. +pub fn agent_config_get() -> AgentConfig { + let mut guard = G_CONFIG.lock().unwrap(); + if guard.is_none() { + *guard = Some(load_from_db()); + } + guard.as_ref().unwrap().clone() +} + +/// Mutate the config and persist it. The closure's result is returned. +pub fn agent_config_update(f: impl FnOnce(&mut AgentConfig) -> R) -> R { + let mut guard = G_CONFIG.lock().unwrap(); + if guard.is_none() { + *guard = Some(load_from_db()); + } + let cfg = guard.as_mut().unwrap(); + let r = f(cfg); + cfg.normalize(); + save_to_db(cfg); + r +} + +/// Normalize an OpenAI-compatible base URL: strip trailing slashes and +/// append `/v1` unless the last path segment is already a version (`/vN`). +/// Mirrors the C `normalize_base_url` (agent_llm.c:197). +pub fn normalize_base_url(base_url: &str) -> String { + let trimmed = base_url.trim().trim_end_matches('/'); + let last = trimmed.rsplit('/').next().unwrap_or(""); + let is_version = last.len() >= 2 + && last.starts_with('v') + && last[1..].chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false); + if is_version { + trimmed.to_string() + } else { + format!("{}/v1", trimmed) + } +} + +fn http_client(timeout: Duration) -> Result { + reqwest::blocking::Client::builder() + .timeout(timeout) + .connect_timeout(Duration::from_secs(20)) + .build() + .map_err(|e| format!("HTTP client error: {}", e)) +} + +/// Extract a readable error from an API error body. +fn api_error_message(status: reqwest::StatusCode, body: &str) -> String { + if let Ok(v) = serde_json::from_str::(body) { + if let Some(msg) = v["error"]["message"].as_str().or_else(|| v["error"].as_str()) { + return format!("HTTP {}: {}", status.as_u16(), msg); + } + } + let snippet: String = body.chars().take(300).collect(); + format!("HTTP {}: {}", status.as_u16(), snippet) +} + +/// Send a chat completion request and return `choices[0].message`. +/// +/// `messages` must already be in OpenAI format (including the system +/// message). `tools` is omitted from the request when empty. +pub fn agent_llm_chat( + base_url: &str, + api_key: &str, + model: &str, + messages: &[serde_json::Value], + tools: &[serde_json::Value], +) -> Result { + if base_url.trim().is_empty() { + return Err("No LLM base URL configured — open Agent Setup… from the menu".to_string()); + } + if model.trim().is_empty() { + return Err("No model selected — open Agent Setup… from the menu".to_string()); + } + + let url = format!("{}/chat/completions", normalize_base_url(base_url)); let mut body = serde_json::json!({ - "model": provider.model, + "model": model, "messages": messages, }); - - if let Some(tools) = tools { + if !tools.is_empty() { body["tools"] = serde_json::Value::Array(tools.to_vec()); } - let response = client - .post(format!("{}/v1/chat/completions", provider.base_url)) - .header("Authorization", format!("Bearer {}", provider.api_key)) - .json(&body) - .send() - .await?; + let client = http_client(Duration::from_secs(300))?; + let mut req = client.post(&url).json(&body); + if !api_key.is_empty() { + req = req.bearer_auth(api_key); + } + let resp = req.send().map_err(|e| format!("Request to {} failed: {}", url, e))?; + let status = resp.status(); + let text = resp.text().map_err(|e| format!("Failed to read response: {}", e))?; + if !status.is_success() { + return Err(api_error_message(status, &text)); + } - let result: serde_json::Value = response.json().await?; - Ok(result) + let json: serde_json::Value = + serde_json::from_str(&text).map_err(|e| format!("Invalid JSON from LLM: {}", e))?; + if let Some(err) = json.get("error") { + let msg = err["message"].as_str().or_else(|| err.as_str()).unwrap_or("unknown error"); + return Err(format!("LLM error: {}", msg)); + } + json["choices"][0] + .get("message") + .cloned() + .ok_or_else(|| "LLM response has no choices[0].message".to_string()) +} + +/// List model ids from `{base_url}/models`, sorted. +pub fn agent_llm_list_models(base_url: &str, api_key: &str) -> Result, String> { + if base_url.trim().is_empty() { + return Err("No base URL configured".to_string()); + } + let url = format!("{}/models", normalize_base_url(base_url)); + let client = http_client(Duration::from_secs(30))?; + let mut req = client.get(&url); + if !api_key.is_empty() { + req = req.bearer_auth(api_key); + } + let resp = req.send().map_err(|e| format!("Request to {} failed: {}", url, e))?; + let status = resp.status(); + let text = resp.text().map_err(|e| format!("Failed to read response: {}", e))?; + if !status.is_success() { + return Err(api_error_message(status, &text)); + } + let json: serde_json::Value = + serde_json::from_str(&text).map_err(|e| format!("Invalid JSON: {}", e))?; + let list = json["data"].as_array().or_else(|| json.as_array()); + let mut models: Vec = list + .map(|a| { + a.iter() + .filter_map(|m| m["id"].as_str().or_else(|| m.as_str()).map(String::from)) + .collect() + }) + .unwrap_or_default(); + models.sort_by_key(|m| m.to_lowercase()); + models.dedup(); + Ok(models) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_base_urls() { + assert_eq!(normalize_base_url("https://api.openai.com"), "https://api.openai.com/v1"); + assert_eq!(normalize_base_url("https://api.openai.com/"), "https://api.openai.com/v1"); + assert_eq!(normalize_base_url("https://api.openai.com/v1/"), "https://api.openai.com/v1"); + assert_eq!(normalize_base_url("http://localhost:11434/v2"), "http://localhost:11434/v2"); + assert_eq!( + normalize_base_url("https://openrouter.ai/api"), + "https://openrouter.ai/api/v1" + ); + } + + #[test] + fn config_defaults_and_active_provider() { + let mut cfg: AgentConfig = serde_json::from_str("{}").unwrap(); + cfg.normalize(); + assert_eq!(cfg.max_iterations, AGENT_MAX_ITERATIONS_DEFAULT); + assert_eq!(cfg.active_provider, -1); + assert!(cfg.active().is_none()); + + let mut cfg = AgentConfig::default(); + cfg.providers.push(LlmProvider { name: "b".into(), ..Default::default() }); + cfg.active_provider = 7; + cfg.normalize(); + assert_eq!(cfg.active_provider, 0); + assert_eq!(cfg.effective_skill_template(), AGENT_SYSTEM_PROMPT_DEFAULT); + } } diff --git a/src/agent_loop.rs b/src/agent_loop.rs index 4a9d147..e13950d 100644 --- a/src/agent_loop.rs +++ b/src/agent_loop.rs @@ -1,45 +1,141 @@ -//! Agent main loop — orchestrates LLM calls and tool execution +//! Agent main loop — orchestrates LLM calls and tool execution. //! -//! Port of `agent_loop.c` / `agent_loop.h` from the C project. +//! Port of `agent_loop.c` from the C project: +//! 1. Append the user message to the chat store. +//! 2. Build `[system] + history` and call the LLM with the tool catalog. +//! 3. Store the assistant reply (with any tool_calls). +//! 4. Execute each tool call, storing the results as `tool` messages. +//! 5. Repeat until there are no tool calls, the iteration cap is hit, +//! or the user cancels. +//! +//! The loop runs on its own OS thread. The UI polls +//! `sovereign://agents/status` + `sovereign://agents/messages`. + +use once_cell::sync::Lazy; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; use crate::agent_chat_store; use crate::agent_llm; use crate::agent_tools; -/// Run the agent loop for a single user message. -pub async fn agent_loop_run(user_message: &str) -> Result> { - // Add user message to store - agent_chat_store::agent_chat_store_add_message( - agent_chat_store::ChatMessage { - role: "user".to_string(), - content: user_message.to_string(), - tool_calls: None, - tool_call_id: None, - }, - ); +/// Loop state, serialized as the strings the chat UI expects. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentLoopState { + Idle, + Thinking, + ToolCall, + Complete, + Error, + Cancelled, +} - // Get conversation history - let session = agent_chat_store::agent_chat_store_get_session(); - let mut messages: Vec = Vec::new(); - - // Add system prompt - if !session.system_prompt.is_empty() { - messages.push(serde_json::json!({ - "role": "system", - "content": session.system_prompt, - })); +impl AgentLoopState { + pub fn as_str(&self) -> &'static str { + match self { + AgentLoopState::Idle => "idle", + AgentLoopState::Thinking => "thinking", + AgentLoopState::ToolCall => "tool_call", + AgentLoopState::Complete => "complete", + AgentLoopState::Error => "error", + AgentLoopState::Cancelled => "cancelled", + } } - // Add conversation history - for msg in &session.messages { - messages.push(serde_json::json!({ - "role": msg.role, - "content": msg.content, - })); + fn is_running(&self) -> bool { + matches!(self, AgentLoopState::Thinking | AgentLoopState::ToolCall) } +} - // Get available tools - let tools: Vec = agent_tools::agent_tools_get_all() +/// Status snapshot returned by `sovereign://agents/status`. +#[derive(Debug, Clone)] +pub struct AgentLoopStatus { + pub state: AgentLoopState, + pub iteration: i32, + pub current_tool: Option, + pub last_message: Option, + pub error: Option, +} + +impl AgentLoopStatus { + pub fn to_json(&self) -> serde_json::Value { + serde_json::json!({ + "state": self.state.as_str(), + "iteration": self.iteration, + "current_tool": self.current_tool, + "last_message": self.last_message, + "error": self.error, + }) + } +} + +static G_STATUS: Lazy> = Lazy::new(|| { + Mutex::new(AgentLoopStatus { + state: AgentLoopState::Idle, + iteration: 0, + current_tool: None, + last_message: None, + error: None, + }) +}); +static G_CANCEL: AtomicBool = AtomicBool::new(false); + +/// Max characters of a single tool result fed back to the LLM (keeps a +/// huge page dump from blowing the context window). +const MAX_TOOL_RESULT_CHARS: usize = 60_000; + +fn set_status(state: AgentLoopState, iteration: i32, tool: Option) { + let mut s = G_STATUS.lock().unwrap(); + s.state = state; + s.iteration = iteration; + s.current_tool = if state == AgentLoopState::ToolCall { tool } else { None }; + if state != AgentLoopState::Error { + s.error = None; + } +} + +fn set_error(msg: &str) { + eprintln!("[agent-loop] error: {}", msg); + let mut s = G_STATUS.lock().unwrap(); + s.state = AgentLoopState::Error; + s.current_tool = None; + s.error = Some(msg.to_string()); +} + +/// Current status snapshot. +pub fn agent_loop_get_status() -> AgentLoopStatus { + G_STATUS.lock().unwrap().clone() +} + +/// Whether the loop is currently running. +pub fn agent_loop_is_running() -> bool { + G_STATUS.lock().unwrap().state.is_running() +} + +/// Request cancellation (takes effect between LLM calls / tool calls). +pub fn agent_loop_cancel() { + if agent_loop_is_running() { + G_CANCEL.store(true, Ordering::SeqCst); + // Reflect the cancel immediately so the UI's stop button resets; + // the worker exits at its next checkpoint. + set_status(AgentLoopState::Cancelled, agent_loop_get_status().iteration, None); + } +} + +fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + let head: String = s.chars().take(max).collect(); + format!("{}\n…[truncated {} chars]", head, s.chars().count() - max) + } +} + +/// OpenAI function-tool definitions for the whole catalog. +fn tool_definitions() -> Vec { + let mut tools = agent_tools::agent_tools_get_all(); + tools.sort_by(|a, b| a.name.cmp(&b.name)); + tools .iter() .map(|t| { serde_json::json!({ @@ -51,74 +147,150 @@ pub async fn agent_loop_run(user_message: &str) -> Result { - tool_call_list.push(agent_chat_store::ToolCall { - id: call["id"].as_str().unwrap_or("").to_string(), - call_type: "function".to_string(), - function: agent_chat_store::ToolCallFunction { - name: name.to_string(), - arguments: args_str.to_string(), - }, - }); - - // Add tool result message - agent_chat_store::agent_chat_store_add_message( - agent_chat_store::ChatMessage { - role: "tool".to_string(), - content: serde_json::to_string(&result).unwrap_or_default(), - tool_calls: None, - tool_call_id: Some(call["id"].as_str().unwrap_or("").to_string()), - }, - ); - } - Err(e) => { - agent_chat_store::agent_chat_store_add_message( - agent_chat_store::ChatMessage { - role: "tool".to_string(), - content: format!("Error: {}", e), - tool_calls: None, - tool_call_id: Some(call["id"].as_str().unwrap_or("").to_string()), - }, - ); - } - } - } - assistant_msg.tool_calls = Some(tool_call_list); - } +/// Start the agent loop for `user_message` on a background thread. +/// Returns an error if it is already running. +pub fn agent_loop_run(user_message: &str) -> Result<(), String> { + { + let mut s = G_STATUS.lock().unwrap(); + if s.state.is_running() { + return Err("Agent is already running".to_string()); } + s.state = AgentLoopState::Thinking; + s.iteration = 0; + s.current_tool = None; + s.error = None; + s.last_message = None; + } + G_CANCEL.store(false, Ordering::SeqCst); - agent_chat_store::agent_chat_store_add_message(assistant_msg); - return Ok(content.to_string()); + // Store the user message synchronously so it shows up immediately. + // If it can't be stored, don't start: the LLM would never see it. + if let Err(e) = agent_chat_store::agent_chat_store_add_user_message(user_message) { + set_error(&e); + return Err(e); } - Ok(String::new()) + std::thread::Builder::new() + .name("agent-loop".into()) + .spawn(run_loop) + .map_err(|e| { + set_error(&format!("Failed to spawn agent thread: {}", e)); + "Failed to start agent loop".to_string() + })?; + Ok(()) +} + +fn cancelled(iter: i32) -> bool { + if G_CANCEL.load(Ordering::SeqCst) { + set_status(AgentLoopState::Cancelled, iter, None); + println!("[agent-loop] cancelled at iteration {}", iter); + true + } else { + false + } +} + +fn run_loop() { + run_loop_inner(); + // Persist the conversation (derives a title from the first user + // message if it has none, and syncs to Nostr when a signer is + // available). The chat page also auto-saves, but only if its poll + // happened to observe the running state — a fast reply, or a message + // sent via the URL bar's `; ` shortcut, would otherwise never be saved. + let sid = agent_chat_store::agent_chat_store_get_session(); + let _ = crate::agent_conversations::agent_conversations_save(Some(&sid), None); +} + +fn run_loop_inner() { + let cfg = agent_llm::agent_config_get(); + let base_url = cfg.base_url(); + let api_key = cfg.api_key(); + let model = cfg.model.clone(); + let max_iter = cfg.effective_max_iterations(); + let system_prompt = crate::agent_skills::agent_skills_build_system_prompt(); + let tools = tool_definitions(); + + println!( + "[agent-loop] base_url={} model={} api_key={} max_iter={}", + base_url, + model, + if api_key.is_empty() { "(empty)" } else { "(set)" }, + max_iter + ); + + for iter in 0..max_iter { + if cancelled(iter) { + return; + } + set_status(AgentLoopState::Thinking, iter, None); + + let mut messages = vec![serde_json::json!({"role": "system", "content": system_prompt})]; + messages.extend(agent_chat_store::agent_chat_store_get_messages()); + + let reply = match agent_llm::agent_llm_chat(&base_url, &api_key, &model, &messages, &tools) { + Ok(r) => r, + Err(e) => { + set_error(&e); + return; + } + }; + if cancelled(iter) { + return; + } + + let content = reply["content"].as_str().unwrap_or("").to_string(); + let tool_calls: Vec = reply["tool_calls"].as_array().cloned().unwrap_or_default(); + if let Err(e) = agent_chat_store::agent_chat_store_add_assistant_message(&content, reply.get("tool_calls")) { + set_error(&e); + return; + } + if !content.is_empty() { + G_STATUS.lock().unwrap().last_message = Some(content.clone()); + } + + if tool_calls.is_empty() { + set_status(AgentLoopState::Complete, iter, None); + println!("[agent-loop] complete after {} iteration(s)", iter + 1); + return; + } + + for call in &tool_calls { + if cancelled(iter) { + // Keep the transcript valid for the next request: every + // tool_call must have a matching tool message. + let id = call["id"].as_str().unwrap_or(""); + let _ = agent_chat_store::agent_chat_store_add_tool_result(id, "{\"error\":\"cancelled by user\"}"); + continue; + } + let id = call["id"].as_str().unwrap_or("").to_string(); + let name = call["function"]["name"].as_str().unwrap_or("").to_string(); + let args_str = call["function"]["arguments"].as_str().unwrap_or("{}"); + let args: serde_json::Value = if args_str.trim().is_empty() { + serde_json::json!({}) + } else { + serde_json::from_str(args_str).unwrap_or_else(|_| serde_json::json!({})) + }; + + set_status(AgentLoopState::ToolCall, iter, Some(name.clone())); + println!("[agent-loop] iter {}: tool {} {}", iter, name, truncate(args_str, 200)); + + let result = match agent_tools::agent_tools_execute(&name, &args) { + Ok(v) => serde_json::to_string(&v).unwrap_or_default(), + Err(e) => serde_json::json!({"error": e}).to_string(), + }; + if let Err(e) = agent_chat_store::agent_chat_store_add_tool_result(&id, &truncate(&result, MAX_TOOL_RESULT_CHARS)) { + set_error(&e); + return; + } + } + if G_CANCEL.load(Ordering::SeqCst) { + set_status(AgentLoopState::Cancelled, iter, None); + return; + } + } + + set_error(&format!("Reached the iteration limit ({})", max_iter)); } diff --git a/src/agent_skills.rs b/src/agent_skills.rs index 3cffe3c..bfbdab6 100644 --- a/src/agent_skills.rs +++ b/src/agent_skills.rs @@ -1,166 +1,258 @@ -//! Nostr kind 31123 skill management for sovereign_browser +//! Nostr kind 31123 skill management for sovereign_browser. //! -//! Skills are PUBLIC Nostr events (kind 31123) that define system prompt -//! templates, LLM parameters, and tool requirements. +//! Skills are PUBLIC Nostr events (kind 31123) whose content is a system +//! prompt template. Tags: `d`, `name`, `description`, `requires_tool`*, +//! `client`. The user's selected skills (d-tags) are stored in the +//! per-profile kv_store; the effective system prompt is the Sovereign +//! Browser Skill template (local settings) followed by each selected +//! skill's content. //! //! Port of `agent_skills.c` / `agent_skills.h` from the C project. -//! Uses `nostr_signer` from `rust_core_lib`. use nostr_signer::traits::NostrSigner; use std::sync::Arc; -use std::sync::Mutex; -use once_cell::sync::Lazy; +use crate::agent_llm; use crate::db; +use crate::nostr_publish; /// The Nostr kind for skills. const AGENT_SKILL_KIND: u64 = 31123; - -/// db_kv key for the selected skill d-tags. +/// db_kv key for the selected skill d-tags (JSON array). const AGENT_SKILLS_SELECTED_KEY: &str = "agent.selected_skills"; - /// Maximum number of requires_tool tags per skill. const AGENT_SKILL_MAX_TOOLS: usize = 32; -/// A skill definition. -#[derive(Debug, Clone)] -pub struct AgentSkill { - pub d_tag: String, - pub name: String, - pub description: String, - pub pubkey: String, - pub content: String, - pub requires_tools: Vec, -} - -/// Global state for skills. -static G_SKILL_STATE: Lazy> = Lazy::new(|| Mutex::new(SkillState::default())); - -struct SkillState { - signer: Option>, - pubkey_hex: String, - have_signer: bool, -} - -impl Default for SkillState { - fn default() -> Self { - SkillState { - signer: None, - pubkey_hex: String::new(), - have_signer: false, - } - } -} - -/// Initialize the skills module. -pub fn agent_skills_init(signer: Option>, pubkey_hex: &str) { - let mut state = G_SKILL_STATE.lock().unwrap(); - state.signer = signer; - state.pubkey_hex = pubkey_hex.to_string(); - state.have_signer = state.signer.is_some() && !state.pubkey_hex.is_empty(); -} +/// Initialize (signer is read from the app state at use time). +pub fn agent_skills_init(_signer: Option>, _pubkey_hex: &str) {} /// Update the signer reference. pub fn agent_skills_set_signer(signer: Option>, pubkey_hex: &str) { agent_skills_init(signer, pubkey_hex); } -/// Fetch kind 31123 skill events from the local SQLite cache. -pub fn agent_skills_fetch() -> Vec { - match db::db_query_events(&[AGENT_SKILL_KIND], None, None, None, None) { - Ok(events) => { - let mut result = Vec::new(); - for event in events { - let tags = event["tags"].as_array(); - let d_tag = tags.and_then(|t| { - t.iter().find_map(|tag| { - tag.as_array().and_then(|a| { - if a.len() >= 2 && a[0] == "d" { - Some(a[1].as_str().unwrap_or("").to_string()) - } else { - None - } - }) - }) - }).unwrap_or_default(); - - let name = tags.and_then(|t| { - t.iter().find_map(|tag| { - tag.as_array().and_then(|a| { - if a.len() >= 2 && a[0] == "name" { - Some(a[1].as_str().unwrap_or("").to_string()) - } else { - None - } - }) - }) - }).unwrap_or_default(); - - let description = tags.and_then(|t| { - t.iter().find_map(|tag| { - tag.as_array().and_then(|a| { - if a.len() >= 2 && a[0] == "description" { - Some(a[1].as_str().unwrap_or("").to_string()) - } else { - None - } - }) - }) - }).unwrap_or_default(); - - let requires_tools: Vec = tags.map_or(vec![], |t| { - t.iter().filter_map(|tag| { - tag.as_array().and_then(|a| { - if a.len() >= 2 && a[0] == "requires_tool" { - Some(a[1].as_str().unwrap_or("").to_string()) - } else { - None - } - }) - }).collect() - }); - - result.push(serde_json::json!({ - "d": d_tag, - "name": name, - "description": description, - "requires_tools": requires_tools, - "content": event["content"].as_str().unwrap_or(""), - "pubkey": event["pubkey"].as_str().unwrap_or(""), - })); - } - result - } - Err(_) => Vec::new(), - } -} - -/// Get the default Sovereign Browser Skill. -pub fn agent_skills_get_default() -> serde_json::Value { +fn skill_json(event: &serde_json::Value) -> serde_json::Value { + let tag = |n: &str| nostr_publish::tag_value(event, n).unwrap_or("").to_string(); + let d = tag("d"); + let name = tag("name"); serde_json::json!({ - "d": "", - "name": "Sovereign Browser Skill", - "description": "Default skill for browser automation", - "content": "You are a helpful assistant with access to browser tools.", - "requires_tools": ["browser_navigate", "browser_click", "browser_type", "browser_screenshot"], - "unsaved": true, - "pubkey": "", + "d": d, + "name": if name.is_empty() { d.clone() } else { name }, + "description": tag("description"), + "requires_tools": nostr_publish::tag_values(event, "requires_tool"), + "content": event["content"].as_str().unwrap_or(""), + "pubkey": event["pubkey"].as_str().unwrap_or(""), + "created_at": event["created_at"], }) } -/// Build the combined system prompt from selected skills. -pub fn agent_skills_build_system_prompt(selected_skills: &[String]) -> String { - let skills = agent_skills_fetch(); - let mut parts = Vec::new(); +/// All cached kind 31123 skills (latest version per author + d-tag). +pub fn agent_skills_fetch() -> Vec { + let events = db::db_query_events(&[AGENT_SKILL_KIND], None, None, None, Some(1000)).unwrap_or_default(); + nostr_publish::latest_per_d_tag(events) + .iter() + .map(skill_json) + .filter(|s| !s["d"].as_str().unwrap_or("").is_empty()) + .collect() +} - for skill in &skills { - let d_tag = skill["d"].as_str().unwrap_or(""); - if selected_skills.contains(&d_tag.to_string()) { - if let Some(content) = skill["content"].as_str() { - parts.push(content.to_string()); - } +/// Parse a comma-separated tool list. +fn split_tools(s: &str) -> Vec { + s.split(',') + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .collect() +} + +/// The unsaved "Sovereign Browser Skill" built from local settings. +pub fn agent_skills_get_default() -> serde_json::Value { + let cfg = agent_llm::agent_config_get(); + serde_json::json!({ + "d": "", + "name": cfg.effective_skill_name(), + "description": cfg.effective_skill_description(), + "content": cfg.effective_skill_template(), + "requires_tools": split_tools(&cfg.effective_skill_requires_tools()), + "pubkey": "", + "unsaved": true, + }) +} + +/// Selected skill d-tags. +pub fn agent_skills_get_selected() -> Vec { + db::db_kv_get(AGENT_SKILLS_SELECTED_KEY) + .ok() + .flatten() + .and_then(|s| serde_json::from_str::>(&s).ok()) + .unwrap_or_default() +} + +fn set_selected(sel: &[String]) { + if let Ok(json) = serde_json::to_string(sel) { + let _ = db::db_kv_set(AGENT_SKILLS_SELECTED_KEY, &json); + } +} + +/// Toggle a skill's selection; returns the new selection. +pub fn agent_skills_toggle_selected(d_tag: &str) -> Vec { + let mut sel = agent_skills_get_selected(); + if let Some(pos) = sel.iter().position(|d| d == d_tag) { + sel.remove(pos); + } else if !d_tag.is_empty() { + sel.push(d_tag.to_string()); + } + set_selected(&sel); + sel +} + +/// Effective system prompt: the Sovereign Browser Skill template followed +/// by the content of each selected skill (in selection order). +pub fn agent_skills_build_system_prompt() -> String { + let mut prompt = agent_llm::agent_config_get().effective_skill_template(); + let selected = agent_skills_get_selected(); + if selected.is_empty() { + return prompt; + } + let skills = agent_skills_fetch(); + for d in &selected { + if let Some(content) = skills + .iter() + .find(|s| s["d"].as_str() == Some(d.as_str())) + .and_then(|s| s["content"].as_str()) + .filter(|c| !c.trim().is_empty()) + { + prompt.push_str("\n\n---\n\n"); + prompt.push_str(content); } } - - parts.join("\n\n") + prompt +} + +/// Derive a d-tag slug from a skill name. Mirrors `derive_d_tag`. +fn derive_d_tag(name: &str) -> String { + let mut slug = String::new(); + for c in name.chars() { + if c.is_ascii_alphanumeric() { + slug.push(c.to_ascii_lowercase()); + } else if matches!(c, ' ' | '_' | '-' | '.') && !slug.ends_with('-') { + slug.push('-'); + } + } + let slug = slug.trim_matches('-').to_string(); + if slug.is_empty() { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + format!("skill-{}", now) + } else { + slug + } +} + +/// Parse the `requires_tools` query value: JSON array or comma list. +pub fn parse_requires_tools(raw: &str) -> Vec { + let tools = serde_json::from_str::>(raw).unwrap_or_else(|_| split_tools(raw)); + tools.into_iter().filter(|t| !t.trim().is_empty()).take(AGENT_SKILL_MAX_TOOLS).collect() +} + +fn skill_tags(d: &str, name: &str, description: &str, tools: &[String]) -> Vec> { + let mut tags = vec![vec!["d".to_string(), d.to_string()]]; + if !name.is_empty() { + tags.push(vec!["name".into(), name.into()]); + } + if !description.is_empty() { + tags.push(vec!["description".into(), description.into()]); + } + tags.push(vec!["client".into(), "sovereign_browser".into()]); + for t in tools { + tags.push(vec!["requires_tool".into(), t.clone()]); + } + tags +} + +/// Sign + store a skill event locally (synchronously, so the list updates +/// immediately), then publish it to relays in the background. +fn sign_store_and_spawn_publish(tags: Vec>, content: &str) -> Result<(), String> { + let event = nostr_publish::sign_event(AGENT_SKILL_KIND, tags, content)?; + nostr_publish::store_event(&event)?; + std::thread::spawn(move || { + nostr_publish::publish_event(&event); + }); + Ok(()) +} + +/// Create and publish a new skill. Returns its d-tag. +pub fn agent_skills_publish( + name: &str, + description: &str, + content: &str, + requires_tools: &[String], +) -> Result { + if content.trim().is_empty() { + return Err("Skill content is required".to_string()); + } + let d = derive_d_tag(name); + sign_store_and_spawn_publish(skill_tags(&d, name, description, requires_tools), content)?; + Ok(d) +} + +/// Re-publish an existing skill (same d-tag) with edited fields. +pub fn agent_skills_update( + d_tag: &str, + name: &str, + description: &str, + content: &str, + requires_tools: &[String], +) -> Result<(), String> { + if d_tag.is_empty() || content.trim().is_empty() { + return Err("Missing d-tag or content".to_string()); + } + sign_store_and_spawn_publish(skill_tags(d_tag, name, description, requires_tools), content) +} + +/// Delete one of the user's own skills: drop it locally, deselect it and +/// publish a NIP-09 kind 5 deletion in the background. +pub fn agent_skills_delete(d_tag: &str) -> Result<(), String> { + let me = nostr_publish::my_pubkey(); + let events: Vec = db::db_query_events(&[AGENT_SKILL_KIND], None, None, None, Some(1000)) + .unwrap_or_default() + .into_iter() + .filter(|e| e["pubkey"].as_str() == Some(me.as_str()) && nostr_publish::tag_value(e, "d") == Some(d_tag)) + .collect(); + if events.is_empty() { + return Err(format!("Skill '{}' not found (or not yours)", d_tag)); + } + let mut tags: Vec> = 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_SKILL_KIND, me, d_tag)]); + let deletion = nostr_publish::sign_event(5, tags, "Deleted skill")?; + for e in &events { + if let Some(id) = e["id"].as_str() { + nostr_publish::delete_local_event(id); + } + } + let mut sel = agent_skills_get_selected(); + sel.retain(|d| d != d_tag); + set_selected(&sel); + std::thread::spawn(move || { + nostr_publish::publish_event(&deletion); + }); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slugs_and_tool_parsing() { + assert_eq!(derive_d_tag("My Cool Skill!"), "my-cool-skill"); + assert!(derive_d_tag("!!!").starts_with("skill-")); + assert_eq!(parse_requires_tools("[\"browser\",\"fs\"]"), vec!["browser", "fs"]); + assert_eq!(parse_requires_tools("browser, fs ,,shell"), vec!["browser", "fs", "shell"]); + } } diff --git a/src/agent_tools.rs b/src/agent_tools.rs index 6c064f6..4245638 100644 --- a/src/agent_tools.rs +++ b/src/agent_tools.rs @@ -23,6 +23,37 @@ pub fn agent_tools_init() { let mut registry = G_TOOL_REGISTRY.lock().unwrap(); registry.clear(); + registry.insert( + "snapshot".to_string(), + AgentTool { + name: "snapshot".to_string(), + description: "Snapshot the active tab: URL, title, a text preview, and a list of visible interactive elements with refs (e.g. @e3) that can be passed as the selector to browser_click / browser_type.".to_string(), + input_schema: serde_json::json!({"type": "object", "properties": {}, "required": []}), + }, + ); + registry.insert( + "get_page_text".to_string(), + AgentTool { + name: "get_page_text".to_string(), + description: "Get the visible text content (innerText) of the active tab.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "max_chars": {"type": "integer", "description": "Maximum characters to return (default 20000)"} + }, + "required": [] + }), + }, + ); + registry.insert( + "page_info".to_string(), + AgentTool { + name: "page_info".to_string(), + description: "Get the active tab's URL, title, load state and current text selection.".to_string(), + input_schema: serde_json::json!({"type": "object", "properties": {}, "required": []}), + }, + ); + // Browser navigation tools registry.insert( "browser_navigate".to_string(), @@ -43,11 +74,11 @@ pub fn agent_tools_init() { "browser_click".to_string(), AgentTool { name: "browser_click".to_string(), - description: "Click an element on the page".to_string(), + description: "Click an element in the active tab".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { - "selector": {"type": "string", "description": "CSS selector for the element"} + "selector": {"type": "string", "description": "Snapshot ref (e.g. @e3) or CSS selector"} }, "required": ["selector"] }), @@ -58,12 +89,13 @@ pub fn agent_tools_init() { "browser_type".to_string(), AgentTool { name: "browser_type".to_string(), - description: "Type text into an input field".to_string(), + description: "Set the text of an input / textarea / contenteditable in the active tab".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { - "selector": {"type": "string", "description": "CSS selector for the input"}, - "text": {"type": "string", "description": "Text to type"} + "selector": {"type": "string", "description": "Snapshot ref (e.g. @e3) or CSS selector"}, + "text": {"type": "string", "description": "Text to enter"}, + "submit": {"type": "boolean", "description": "Press Enter / submit the form afterwards"} }, "required": ["selector", "text"] }), @@ -189,7 +221,7 @@ pub fn agent_tools_init() { "eval_js".to_string(), AgentTool { name: "eval_js".to_string(), - description: "Execute JavaScript in the active tab's page. Console.log output appears in browser stderr.".to_string(), + description: "Evaluate a JavaScript expression in the active tab and return its (JSON-serialized) value. Promises are awaited.".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { @@ -205,11 +237,12 @@ pub fn agent_tools_init() { "shell_exec".to_string(), AgentTool { name: "shell_exec".to_string(), - description: "Execute a shell command".to_string(), + description: "Execute a shell command (sh -c) and return stdout, stderr and exit code".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { - "command": {"type": "string", "description": "Command to execute"} + "command": {"type": "string", "description": "Command to execute"}, + "timeout_seconds": {"type": "integer", "description": "Kill the command after this many seconds (default 120)"} }, "required": ["command"] }), @@ -229,9 +262,93 @@ pub fn agent_tools_get(name: &str) -> Option { registry.get(name).cloned() } +/// Run `f` with the active tab's webview on the GTK main thread (the +/// focused window's notebook, falling back to the main one). +fn with_active_webview(f: impl FnOnce(Option) + Send + 'static) { + glib::idle_add_once(move || { + let wv = crate::tab_manager::tab_manager_get_active_notebook() + .and_then(|nb| crate::tab_manager::tab_manager_get_active_webview(&nb)); + f(wv); + }); +} + +/// Evaluate `js` in the active tab and wait (up to `timeout`) for the +/// JSON-serialized result. The script's completion value is returned; a +/// Promise is awaited. MUST NOT be called on the GTK main thread (it +/// blocks waiting for the main loop) — the agent loop and MCP server +/// execute tools on worker threads. +pub fn eval_js_sync(js: &str, timeout: std::time::Duration) -> Result { + if glib::MainContext::default().is_owner() { + return Err("eval_js_sync called on the main thread".to_string()); + } + // Function *body* for call_async_javascript_function (which awaits a + // returned Promise, unlike evaluate_javascript). The user's expression + // is run via indirect eval (global scope, completion value); the result + // is always a JSON string (DOM nodes → outerHTML, undefined → null) and + // exceptions become {__error}. + let wrapped = format!( + "try {{ let __r = (0, eval)({src}); if (__r && typeof __r.then === 'function') __r = await __r; \ + return JSON.stringify(__r === undefined ? null : __r, (k, v) => \ + (typeof Node !== 'undefined' && v instanceof Node) ? (v.outerHTML || String(v)).slice(0, 2000) : v); }} \ + catch (e) {{ return JSON.stringify({{__error: String(e && e.stack || e)}}); }}", + src = serde_json::to_string(js).unwrap_or_else(|_| "\"\"".into()) + ); + let (tx, rx) = std::sync::mpsc::channel::>(); + with_active_webview(move |wv| { + let Some(wv) = wv else { + let _ = tx.send(Err("No active tab".to_string())); + return; + }; + wv.call_async_javascript_function(&wrapped, None, None, None, None::<&gio::Cancellable>, move |res| { + use javascriptcore::ValueExt; + let out = match res { + Ok(v) if v.is_string() => Ok(v.to_str().to_string()), + Ok(v) => Ok(v.to_json(0).map(|s| s.to_string()).unwrap_or_else(|| "null".into())), + Err(e) => Err(e.to_string()), + }; + let _ = tx.send(out); + }); + }); + let raw = rx + .recv_timeout(timeout) + .map_err(|_| "JavaScript evaluation timed out".to_string())??; + let value: serde_json::Value = serde_json::from_str(&raw).unwrap_or(serde_json::Value::String(raw)); + if let Some(err) = value.get("__error").and_then(|e| e.as_str()) { + return Err(format!("JavaScript error: {}", err)); + } + Ok(value) +} + +const JS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); + /// Execute a tool by name with the given arguments. pub fn agent_tools_execute(name: &str, args: &serde_json::Value) -> Result { match name { + "page_info" => { + let v = eval_js_sync( + "({url: location.href, title: document.title, \ + readyState: document.readyState, \ + selection: String(window.getSelection() || '').slice(0, 2000)})", + JS_TIMEOUT, + )?; + Ok(serde_json::json!({"status": "ok", "page": v})) + } + "get_page_text" => { + let max = args["max_chars"].as_u64().unwrap_or(20_000).clamp(500, 200_000); + let js = format!( + "(() => {{ const t = (document.body ? document.body.innerText : '') || ''; \ + return {{url: location.href, title: document.title, length: t.length, \ + text: t.slice(0, {max})}}; }})()" + ); + let v = eval_js_sync(&js, JS_TIMEOUT)?; + Ok(serde_json::json!({"status": "ok", "page": v})) + } + "snapshot" => { + // Interactive-element snapshot with @eN refs usable by + // browser_click / browser_type (sets data-sb-ref on elements). + let v = eval_js_sync(SNAPSHOT_JS, JS_TIMEOUT)?; + Ok(serde_json::json!({"status": "ok", "snapshot": v})) + } "tab_list" => { let tabs = crate::tab_manager::tab_manager_get_tabs(); Ok(serde_json::json!({ @@ -257,9 +374,9 @@ pub fn agent_tools_execute(name: &str, args: &serde_json::Value) -> Result` (Send); gtk widgets are created on the main thread // when the closure actually runs. - let url_owned = url.map(|s| s.to_string()); + let url_owned = url.map(|s| crate::search::normalize_url(s)); glib::idle_add_once(move || { - if let Some(nb) = crate::tab_manager::tab_manager_get_main_notebook() { + if let Some(nb) = crate::tab_manager::tab_manager_get_active_notebook() { if let Some(wv) = crate::tab_manager::tab_manager_get_active_webview(&nb) { if let Some(ctx) = wv.web_context() { crate::tab_manager::tab_manager_new_tab(&nb, &ctx, url_owned.as_deref()); @@ -284,106 +401,87 @@ pub fn agent_tools_execute(name: &str, args: &serde_json::Value) -> Result { let url = args["url"].as_str().ok_or("Missing url argument")?.to_string(); - let url_for_msg = url.clone(); - // Schedule navigation on the GTK main thread. - glib::idle_add_once(move || { - if let Some(nb) = crate::tab_manager::tab_manager_get_main_notebook() { - if let Some(wv) = crate::tab_manager::tab_manager_get_active_webview(&nb) { - let normalized = crate::search::normalize_url(&url); - wv.load_uri(&normalized); - } + let normalized = crate::search::normalize_url(&url); + let (tx, rx) = std::sync::mpsc::channel::(); + let target = normalized.clone(); + with_active_webview(move |wv| { + if let Some(wv) = wv { + wv.load_uri(&target); + let _ = tx.send(true); + } else { + let _ = tx.send(false); } }); - Ok(serde_json::json!({ - "status": "ok", - "message": format!("Navigating to {}", url_for_msg) - })) + match rx.recv_timeout(std::time::Duration::from_secs(5)) { + Ok(true) => {} + Ok(false) => return Err("No active tab".to_string()), + Err(_) => return Err("Timed out scheduling navigation".to_string()), + } + // Wait (bounded) for the new page to finish loading so the next + // tool call (snapshot / get_page_text) sees the new document. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + std::thread::sleep(std::time::Duration::from_millis(300)); + let mut page = serde_json::Value::Null; + while std::time::Instant::now() < deadline { + if let Ok(v) = eval_js_sync( + "({url: location.href, title: document.title, readyState: document.readyState})", + std::time::Duration::from_secs(5), + ) { + let done = v["readyState"] == "complete" && v["url"].as_str() != Some("about:blank"); + page = v; + if done { + break; + } + } + std::thread::sleep(std::time::Duration::from_millis(400)); + } + Ok(serde_json::json!({"status": "ok", "navigated_to": normalized, "page": page})) } "eval_js" => { - let js = args["js"].as_str().ok_or("Missing js argument")?.to_string(); - // We can't easily get the return value of async JS from webkit2gtk - // in a synchronous MCP response. Instead, we run the JS and return - // a status. The JS can write results to console.log which appears - // in the browser's stderr. - glib::idle_add_once(move || { - if let Some(nb) = crate::tab_manager::tab_manager_get_main_notebook() { - if let Some(wv) = crate::tab_manager::tab_manager_get_active_webview(&nb) { - // run_javascript is deprecated in webkit2gtk 2.0.2 but - // there is no replacement in the Rust bindings. Suppress. - #[allow(deprecated)] - wv.run_javascript(&js, None::<&gio::Cancellable>, |_| {}); - } - } - }); - Ok(serde_json::json!({ - "status": "ok", - "message": "JS execution scheduled (check browser stderr for console.log output)" - })) + let js = args["js"].as_str().ok_or("Missing js argument")?; + let result = eval_js_sync(js, JS_TIMEOUT)?; + Ok(serde_json::json!({"status": "ok", "result": result})) } "browser_click" => { - let selector = args["selector"].as_str().ok_or("Missing selector argument")?.to_string(); - // Build JS to click the element matching the selector. + let selector = args["selector"].as_str().ok_or("Missing selector argument")?; let js = format!( - r#"(function() {{ - var el = document.querySelector({:?}); - if (el) {{ el.click(); return "clicked"; }} - return "not found"; - }})()"#, - selector + "(() => {{ const el = {find}; if (!el) return {{found: false}}; \ + el.scrollIntoView({{block: 'center'}}); el.click(); \ + return {{found: true, tag: el.tagName, text: (el.innerText || el.value || '').slice(0, 200)}}; }})()", + find = element_lookup_js(selector) ); - let (tx, rx) = std::sync::mpsc::channel::(); - glib::idle_add_once(move || { - if let Some(nb) = crate::tab_manager::tab_manager_get_main_notebook() { - if let Some(wv) = crate::tab_manager::tab_manager_get_active_webview(&nb) { - #[allow(deprecated)] - wv.run_javascript(&js, None::<&gio::Cancellable>, move |result| { - let msg = match result { - Ok(_) => "clicked", - Err(e) => &format!("error: {}", e), - }; - let _ = tx.send(msg.to_string()); - }); - } else { let _ = tx.send("no webview".to_string()); } - } else { let _ = tx.send("no notebook".to_string()); } - }); - let result = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap_or_else(|_| "timeout".to_string()); - Ok(serde_json::json!({"status": "ok", "result": result})) + let v = eval_js_sync(&js, JS_TIMEOUT)?; + if v["found"] != true { + return Err(format!("Element not found: {}", selector)); + } + Ok(serde_json::json!({"status": "ok", "clicked": v})) } "browser_type" => { - let selector = args["selector"].as_str().ok_or("Missing selector argument")?.to_string(); - let text = args["text"].as_str().ok_or("Missing text argument")?.to_string(); - // Build JS to set the value of the element matching the selector. + let selector = args["selector"].as_str().ok_or("Missing selector argument")?; + let text = args["text"].as_str().ok_or("Missing text argument")?; + let submit = args["submit"].as_bool().unwrap_or(false); let js = format!( - r#"(function() {{ - var el = document.querySelector({:?}); - if (el) {{ - el.value = {}; - el.dispatchEvent(new Event('input', {{bubbles: true}})); - el.dispatchEvent(new Event('change', {{bubbles: true}})); - return "typed"; - }} - return "not found"; - }})()"#, - selector, - serde_json::to_string(&text).unwrap_or_default() + "(() => {{ const el = {find}; if (!el) return {{found: false}}; \ + el.focus(); \ + if (el.isContentEditable) {{ el.innerText = {text}; }} else {{ \ + const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; \ + const setter = Object.getOwnPropertyDescriptor(proto, 'value'); \ + if (setter && setter.set) setter.set.call(el, {text}); else el.value = {text}; }} \ + el.dispatchEvent(new Event('input', {{bubbles: true}})); \ + el.dispatchEvent(new Event('change', {{bubbles: true}})); \ + if ({submit}) {{ \ + el.dispatchEvent(new KeyboardEvent('keydown', {{key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true}})); \ + if (el.form) {{ if (el.form.requestSubmit) el.form.requestSubmit(); else el.form.submit(); }} }} \ + return {{found: true, tag: el.tagName}}; }})()", + find = element_lookup_js(selector), + text = serde_json::to_string(text).unwrap_or_default(), + submit = submit, ); - let (tx, rx) = std::sync::mpsc::channel::(); - glib::idle_add_once(move || { - if let Some(nb) = crate::tab_manager::tab_manager_get_main_notebook() { - if let Some(wv) = crate::tab_manager::tab_manager_get_active_webview(&nb) { - #[allow(deprecated)] - wv.run_javascript(&js, None::<&gio::Cancellable>, move |result| { - let msg = match result { - Ok(_) => "typed", - Err(e) => &format!("error: {}", e), - }; - let _ = tx.send(msg.to_string()); - }); - } else { let _ = tx.send("no webview".to_string()); } - } else { let _ = tx.send("no notebook".to_string()); } - }); - let result = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap_or_else(|_| "timeout".to_string()); - Ok(serde_json::json!({"status": "ok", "result": result})) + let v = eval_js_sync(&js, JS_TIMEOUT)?; + if v["found"] != true { + return Err(format!("Element not found: {}", selector)); + } + Ok(serde_json::json!({"status": "ok", "typed": v})) } "browser_screenshot" => { // Capture a snapshot of the active webview. @@ -393,7 +491,7 @@ pub fn agent_tools_execute(name: &str, args: &serde_json::Value) -> Result>(); glib::idle_add_once(move || { - if let Some(nb) = crate::tab_manager::tab_manager_get_main_notebook() { + if let Some(nb) = crate::tab_manager::tab_manager_get_active_notebook() { if let Some(wv) = crate::tab_manager::tab_manager_get_active_webview(&nb) { wv.snapshot( webkit2gtk::SnapshotRegion::Visible, @@ -447,24 +545,104 @@ pub fn agent_tools_execute(name: &str, args: &serde_json::Value) -> Result { let command = args["command"].as_str().ok_or("Missing command argument")?; - match std::process::Command::new("sh") - .arg("-c") - .arg(command) - .output() - { - Ok(output) => { - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - Ok(serde_json::json!({ - "status": if output.status.success() { "ok" } else { "error" }, - "stdout": stdout, - "stderr": stderr, - "exit_code": output.status.code().unwrap_or(-1), - })) - } - Err(e) => Err(format!("Failed to execute command: {}", e)), - } + let timeout_s = args["timeout_seconds"].as_u64().unwrap_or(120).clamp(1, 600); + run_shell(command, std::time::Duration::from_secs(timeout_s)) } _ => Err(format!("Unknown tool: {}", name)), } } + +/// Run a shell command with a timeout (kills it on expiry). +fn run_shell(command: &str, timeout: std::time::Duration) -> Result { + use std::io::Read; + let mut child = std::process::Command::new("sh") + .arg("-c") + .arg(command) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to execute command: {}", e))?; + // Drain pipes on threads so a chatty command can't deadlock on a full pipe. + let mut out_pipe = child.stdout.take(); + let mut err_pipe = child.stderr.take(); + let out_t = std::thread::spawn(move || { + let mut s = Vec::new(); + if let Some(p) = out_pipe.as_mut() { + let _ = p.read_to_end(&mut s); + } + s + }); + let err_t = std::thread::spawn(move || { + let mut s = Vec::new(); + if let Some(p) = err_pipe.as_mut() { + let _ = p.read_to_end(&mut s); + } + s + }); + let deadline = std::time::Instant::now() + timeout; + let (status, timed_out) = loop { + match child.try_wait() { + Ok(Some(st)) => break (Some(st), false), + Ok(None) if std::time::Instant::now() >= deadline => { + let _ = child.kill(); + break (child.wait().ok(), true); + } + Ok(None) => std::thread::sleep(std::time::Duration::from_millis(50)), + Err(e) => return Err(format!("Failed to wait for command: {}", e)), + } + }; + let clip = |b: Vec| { + let s = String::from_utf8_lossy(&b).to_string(); + if s.len() > 50_000 { format!("{}…[truncated]", &s[..s.char_indices().nth(50_000).map(|(i, _)| i).unwrap_or(s.len())]) } else { s } + }; + let stdout = clip(out_t.join().unwrap_or_default()); + let stderr = clip(err_t.join().unwrap_or_default()); + let code = status.and_then(|s| s.code()).unwrap_or(-1); + Ok(serde_json::json!({ + "status": if timed_out { "timeout" } else if code == 0 { "ok" } else { "error" }, + "stdout": stdout, + "stderr": stderr, + "exit_code": code, + "timed_out": timed_out, + })) +} + +/// JS expression that resolves `selector` to an element. Accepts a +/// snapshot ref (`@e12` / `e12`) or a CSS selector. +fn element_lookup_js(selector: &str) -> String { + let s = selector.trim(); + let r = s.strip_prefix('@').unwrap_or(s); + if r.len() > 1 && r.starts_with('e') && r[1..].chars().all(|c| c.is_ascii_digit()) { + format!("document.querySelector('[data-sb-ref=\"{}\"]')", r) + } else { + format!( + "(() => {{ try {{ return document.querySelector({}); }} catch (e) {{ return null; }} }})()", + serde_json::to_string(s).unwrap_or_default() + ) + } +} + +/// Snapshot of visible interactive elements, tagging each with a +/// `data-sb-ref="eN"` attribute so later tools can address it as `@eN`. +const SNAPSHOT_JS: &str = r#"(() => { + const sel = 'a[href],button,input,textarea,select,[role=button],[role=link],[role=textbox],[contenteditable=true],summary,[onclick]'; + const out = []; + let n = 0; + document.querySelectorAll('[data-sb-ref]').forEach(e => e.removeAttribute('data-sb-ref')); + for (const el of document.querySelectorAll(sel)) { + const r = el.getBoundingClientRect(); + const st = getComputedStyle(el); + if (r.width === 0 || r.height === 0 || st.visibility === 'hidden' || st.display === 'none') continue; + const ref = 'e' + (++n); + el.setAttribute('data-sb-ref', ref); + const label = (el.getAttribute('aria-label') || el.innerText || el.value || el.placeholder || el.title || el.name || '').trim().replace(/\s+/g, ' ').slice(0, 120); + const item = {ref: '@' + ref, tag: el.tagName.toLowerCase(), text: label}; + if (el.type) item.type = el.type; + if (el.href) item.href = el.href; + out.push(item); + if (n >= 300) break; + } + const text = (document.body ? document.body.innerText : '').slice(0, 4000); + return {url: location.href, title: document.title, elements: out, text_preview: text}; +})()"#; diff --git a/src/db.rs b/src/db.rs index 0280bc7..a560cf0 100644 --- a/src/db.rs +++ b/src/db.rs @@ -148,9 +148,115 @@ fn migrate_legacy_events(conn: &mut Connection) -> Result<(), Box Result<(), Box> { + db_init_with_path(db_dir().join("global.db")) +} + +/// Convert an `agent_messages` table using the short-lived single-column +/// layout (`message` = whole OpenAI JSON) — written by one pre-release +/// Rust build — to the C column layout. No-op for C-schema tables. +fn migrate_single_column_agent_messages(conn: &mut Connection) -> Result<(), Box> { + if !has_column(conn, "agent_messages", "message")? || has_column(conn, "agent_messages", "role")? { + return Ok(()); + } + let rows: Vec<(String, String, i64)> = { + let mut stmt = conn.prepare("SELECT session_id, message, created_at FROM agent_messages ORDER BY id")?; + let mapped = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?; + mapped.collect::>()? + }; + let tx = conn.transaction()?; + tx.execute_batch( + "DROP TABLE agent_messages; + CREATE TABLE agent_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, role TEXT, content TEXT, + tool_calls TEXT, tool_call_id TEXT, created_at INTEGER, + FOREIGN KEY (session_id) REFERENCES agent_sessions(id) ON DELETE CASCADE);", + )?; + for (sid, msg, ts) in &rows { + if let Ok(v) = serde_json::from_str::(msg) { + insert_agent_message(&tx, sid, &v, *ts as u64)?; + } + } + tx.commit()?; + println!("[db] Migrated {} agent message(s) to the shared C schema", rows.len()); + Ok(()) +} + +#[cfg(test)] +mod agent_schema_tests { + use super::*; + + /// The exact DDL the C build (db.c:122) writes. + const C_SCHEMA: &str = " + CREATE TABLE agent_sessions (id TEXT PRIMARY KEY, title TEXT, created_at INTEGER, updated_at INTEGER); + CREATE TABLE agent_messages (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, role TEXT, + content TEXT, tool_calls TEXT, tool_call_id TEXT, created_at INTEGER, + FOREIGN KEY (session_id) REFERENCES agent_sessions(id) ON DELETE CASCADE);"; + + fn roundtrip(conn: &Connection) -> Vec { + let msgs = vec![ + serde_json::json!({"role": "user", "content": "hi"}), + serde_json::json!({"role": "assistant", "content": "", + "tool_calls": [{"id": "c1", "type": "function", "function": {"name": "x", "arguments": "{}"}}]}), + serde_json::json!({"role": "tool", "tool_call_id": "c1", "content": "{\"ok\":1}"}), + ]; + for m in &msgs { + insert_agent_message(conn, "s1", m, 1).unwrap(); + } + let mut stmt = conn + .prepare("SELECT role, content, tool_calls, tool_call_id FROM agent_messages WHERE session_id = 's1' ORDER BY id") + .unwrap(); + let rows: Vec<(String, String, Option, Option)> = stmt + .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))) + .unwrap() + .collect::>() + .unwrap(); + assert_eq!(rows[1].2.as_deref().map(|s| s.contains("\"c1\"")), Some(true)); + assert_eq!(rows[2].3.as_deref(), Some("c1")); + msgs + } + + #[test] + fn writes_into_c_created_schema() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch(C_SCHEMA).unwrap(); + roundtrip(&conn); + } + + #[test] + fn migrates_single_column_layout() { + let mut conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE agent_sessions (id TEXT PRIMARY KEY, title TEXT, created_at INTEGER, updated_at INTEGER); + CREATE TABLE agent_messages (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, + message TEXT NOT NULL, created_at INTEGER NOT NULL); + INSERT INTO agent_messages (session_id, message, created_at) + VALUES ('s0', '{\"role\":\"user\",\"content\":\"old\"}', 5);", + ) + .unwrap(); + migrate_single_column_agent_messages(&mut conn).unwrap(); + assert!(has_column(&conn, "agent_messages", "role").unwrap()); + assert!(!has_column(&conn, "agent_messages", "message").unwrap()); + let (role, content): (String, String) = conn + .query_row("SELECT role, content FROM agent_messages", [], |r| Ok((r.get(0)?, r.get(1)?))) + .unwrap(); + assert_eq!((role.as_str(), content.as_str()), ("user", "old")); + // Idempotent on the (now C-layout) table. + migrate_single_column_agent_messages(&mut conn).unwrap(); + roundtrip(&conn); + } +} + /// Initialize the database for a given profile. pub fn db_init(pubkey_hex: &str) -> Result<(), Box> { - let path = db_path(pubkey_hex); + db_init_with_path(db_path(pubkey_hex)) +} + +fn db_init_with_path(path: PathBuf) -> Result<(), Box> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } @@ -167,6 +273,7 @@ pub fn db_init(pubkey_hex: &str) -> Result<(), Box> { // Upgrade legacy schemas before CREATE TABLE IF NOT EXISTS runs. migrate_legacy_history(&mut conn)?; migrate_legacy_events(&mut conn)?; + migrate_single_column_agent_messages(&mut conn)?; // Create tables conn.execute_batch( @@ -207,6 +314,27 @@ pub fn db_init(pubkey_hex: &str) -> Result<(), Box> { created_at INTEGER NOT NULL ); + -- Agent chat tables: identical to the C schema (db.c:122) so the + -- C and Rust builds can share ~/.sovereign_browser databases. + CREATE TABLE IF NOT EXISTS agent_sessions ( + id TEXT PRIMARY KEY, + title TEXT, + created_at INTEGER, + updated_at INTEGER + ); + + CREATE TABLE IF NOT EXISTS agent_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT, + role TEXT, + content TEXT, + tool_calls TEXT, + tool_call_id TEXT, + created_at INTEGER, + FOREIGN KEY (session_id) REFERENCES agent_sessions(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_agent_messages_session ON agent_messages(session_id, created_at); CREATE INDEX IF NOT EXISTS idx_events_pubkey ON events(pubkey); CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind); CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at); @@ -383,6 +511,205 @@ pub fn db_kv_get(key: &str) -> Result, Box Ok(None) } +/// Delete a cached event by id. +pub fn db_delete_event(id: &str) -> Result<(), Box> { + let db = DB.lock().unwrap(); + if let Some(ref conn) = *db { + conn.execute("DELETE FROM events WHERE id = ?1", params![id])?; + } + Ok(()) +} + +fn now_unix() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +/// Insert one OpenAI-format message as C-schema columns. +fn insert_agent_message( + conn: &Connection, + session_id: &str, + msg: &serde_json::Value, + created_at: u64, +) -> rusqlite::Result { + let role = msg["role"].as_str().unwrap_or("user"); + let content = match &msg["content"] { + serde_json::Value::Null => None, + serde_json::Value::String(s) => Some(s.clone()), + other => Some(other.to_string()), + }; + let tool_calls = msg + .get("tool_calls") + .filter(|t| t.as_array().map(|a| !a.is_empty()).unwrap_or(false)) + .map(|t| t.to_string()); + let tool_call_id = msg["tool_call_id"].as_str(); + // agent_messages.session_id REFERENCES agent_sessions(id); the bundled + // SQLite enforces foreign keys, so make sure the parent row exists. + conn.execute( + "INSERT OR IGNORE INTO agent_sessions (id, title, created_at, updated_at) VALUES (?1, '', ?2, ?2)", + params![session_id, created_at], + )?; + conn.execute( + "INSERT INTO agent_messages (session_id, role, content, tool_calls, tool_call_id, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![session_id, role, content, tool_calls, tool_call_id, created_at], + ) +} + +/// Create (or keep, if it already exists) an agent chat session row. +pub fn db_agent_session_upsert(id: &str, title: &str) -> Result<(), Box> { + let db = DB.lock().unwrap(); + if let Some(ref conn) = *db { + let now = now_unix(); + conn.execute( + "INSERT OR IGNORE INTO agent_sessions (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?3)", + params![id, title, now], + )?; + } + Ok(()) +} + +/// Most recently updated agent session id, if any. +pub fn db_agent_session_latest() -> Result, Box> { + let db = DB.lock().unwrap(); + if let Some(ref conn) = *db { + let mut stmt = conn.prepare("SELECT id FROM agent_sessions ORDER BY updated_at DESC LIMIT 1")?; + let mut rows = stmt.query([])?; + if let Some(row) = rows.next()? { + return Ok(Some(row.get(0)?)); + } + } + Ok(None) +} + +/// Append an OpenAI-format message to an agent session. +/// +/// Fails if no database is open — callers must not silently continue (an +/// unsaved user message means the LLM never sees the question). +pub fn db_agent_message_add(session_id: &str, message: &serde_json::Value) -> Result<(), Box> { + let db = DB.lock().unwrap(); + let conn = db.as_ref().ok_or("No database open")?; + let now = now_unix(); + insert_agent_message(conn, session_id, message, now)?; + conn.execute( + "UPDATE agent_sessions SET updated_at = ?2 WHERE id = ?1", + params![session_id, now], + )?; + Ok(()) +} + +/// All messages of an agent session in OpenAI format, oldest first. +pub fn db_agent_messages_get(session_id: &str) -> Result, Box> { + let db = DB.lock().unwrap(); + let mut out = Vec::new(); + if let Some(ref conn) = *db { + let mut stmt = conn.prepare( + "SELECT role, content, tool_calls, tool_call_id FROM agent_messages + WHERE session_id = ?1 ORDER BY id ASC", + )?; + let mut rows = stmt.query(params![session_id])?; + while let Some(row) = rows.next()? { + let role: Option = row.get(0)?; + let content: Option = row.get(1)?; + let tool_calls: Option = row.get(2)?; + let tool_call_id: Option = row.get(3)?; + let mut msg = serde_json::json!({ + "role": role.unwrap_or_else(|| "user".into()), + "content": content.unwrap_or_default(), + }); + if let Some(tc) = tool_calls.and_then(|s| serde_json::from_str::(&s).ok()) { + if tc.as_array().map(|a| !a.is_empty()).unwrap_or(false) { + msg["tool_calls"] = tc; + } + } + if let Some(id) = tool_call_id.filter(|s| !s.is_empty()) { + msg["tool_call_id"] = serde_json::Value::String(id); + } + out.push(msg); + } + } + Ok(out) +} + +/// Number of messages stored for an agent session. +pub fn db_agent_message_count(session_id: &str) -> Result> { + let db = DB.lock().unwrap(); + if let Some(ref conn) = *db { + let n: i64 = conn.query_row( + "SELECT COUNT(*) FROM agent_messages WHERE session_id = ?1", + params![session_id], + |r| r.get(0), + )?; + return Ok(n as u64); + } + Ok(0) +} + +/// Replace all messages of an agent session (used when loading a +/// conversation from Nostr). +pub fn db_agent_messages_replace(session_id: &str, messages: &[serde_json::Value]) -> Result<(), Box> { + let mut db = DB.lock().unwrap(); + let conn = db.as_mut().ok_or("No database open")?; + let now = now_unix(); + let tx = conn.transaction()?; + tx.execute( + "INSERT OR IGNORE INTO agent_sessions (id, title, created_at, updated_at) VALUES (?1, '', ?2, ?2)", + params![session_id, now], + )?; + tx.execute("DELETE FROM agent_messages WHERE session_id = ?1", params![session_id])?; + for m in messages { + insert_agent_message(&tx, session_id, m, now)?; + } + tx.execute("UPDATE agent_sessions SET updated_at = ?2 WHERE id = ?1", params![session_id, now])?; + tx.commit()?; + Ok(()) +} + +/// List agent sessions as (id, title, updated_at), newest first. +pub fn db_agent_sessions_list() -> Result, Box> { + let db = DB.lock().unwrap(); + let mut out = Vec::new(); + if let Some(ref conn) = *db { + // title / updated_at are nullable in the C schema. + let mut stmt = conn.prepare( + "SELECT id, title, updated_at FROM agent_sessions ORDER BY updated_at DESC", + )?; + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + let title: Option = row.get(1)?; + let updated: Option = row.get(2)?; + out.push((row.get(0)?, title.unwrap_or_default(), updated.unwrap_or(0).max(0) as u64)); + } + } + Ok(out) +} + +/// Set an agent session's title (creating the row if needed). +pub fn db_agent_session_set_title(id: &str, title: &str) -> Result<(), Box> { + let db = DB.lock().unwrap(); + if let Some(ref conn) = *db { + let now = now_unix(); + conn.execute( + "INSERT INTO agent_sessions (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?3) + ON CONFLICT(id) DO UPDATE SET title = excluded.title", + params![id, title, now], + )?; + } + Ok(()) +} + +/// Delete an agent session and its messages. +pub fn db_agent_session_delete(session_id: &str) -> Result<(), Box> { + let db = DB.lock().unwrap(); + if let Some(ref conn) = *db { + conn.execute("DELETE FROM agent_messages WHERE session_id = ?1", params![session_id])?; + conn.execute("DELETE FROM agent_sessions WHERE id = ?1", params![session_id])?; + } + Ok(()) +} + /// Delete a key-value pair. pub fn db_kv_delete(key: &str) -> Result<(), Box> { let db = DB.lock().unwrap(); diff --git a/src/lib.rs b/src/lib.rs index 9dc26ac..671bc27 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -40,6 +40,7 @@ pub mod agent_login; pub mod agent_snapshot; pub mod agent_fs_tools; pub mod agent_llm; +pub mod nostr_publish; pub mod db; pub mod settings; pub mod tab_manager; diff --git a/src/main.rs b/src/main.rs index 1d93fe4..a76b470 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,6 +31,7 @@ mod agent_login; mod agent_snapshot; mod agent_fs_tools; mod agent_llm; +mod nostr_publish; mod db; mod settings; mod tab_manager; @@ -305,6 +306,12 @@ fn main() { gtk::init().expect("Failed to initialize GTK"); + // Open the shared global database so pre-login / --no-login sessions + // have storage (replaced by the per-user db on login). Mirrors main.c. + if let Err(e) = db::db_init_global() { + eprintln!("[db] Failed to open global database: {}", e); + } + nostr_bridge::nostr_bridge_init(); agent_tools::agent_tools_init(); agent_chat::agent_chat_init(); diff --git a/src/menu.rs b/src/menu.rs index 3e7e5a4..451a979 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -60,6 +60,9 @@ pub fn app_set_signer( nostr_bridge::nostr_bridge_set_signer(state.signer.clone(), &state.pubkey_hex, state.readonly); crate::agent_login::agent_login_set_signer(state.signer.clone(), &state.pubkey_hex); crate::settings_sync::settings_sync_init(state.signer.clone(), &state.pubkey_hex); + // The per-user database may have just been switched: re-read the agent + // config and re-resolve the current chat session from it. + crate::agent_llm::agent_config_reload(); crate::agent_conversations::agent_conversations_init(state.signer.clone(), &state.pubkey_hex); crate::agent_skills::agent_skills_init(state.signer.clone(), &state.pubkey_hex); } diff --git a/src/net_services.rs b/src/net_services.rs index c1ebe5d..cd270f7 100644 --- a/src/net_services.rs +++ b/src/net_services.rs @@ -52,13 +52,23 @@ pub fn net_services_get_fips_status() -> ServiceStatus { } /// Start Tor. +/// +/// Spawning a managed Tor process is not ported yet, so this attaches to +/// an existing Tor SOCKS listener (e.g. the system daemon) and reports an +/// error if none is reachable, rather than falsely marking Tor as running. pub fn net_services_start_tor() -> Result<(), Box> { + // TODO: spawn and manage a Tor process like the C net_service_enable(). + let available = tor_socks_port_available(); let mut state = G_NET_SERVICES.lock().unwrap(); - state.tor_status = ServiceStatus::Starting; - // TODO: actually start Tor - state.tor_status = ServiceStatus::Running; - println!("[net] Tor started"); - Ok(()) + if available { + state.tor_status = ServiceStatus::Running; + println!("[net] Tor attached (SOCKS 127.0.0.1:9050)"); + Ok(()) + } else { + let msg = "no Tor SOCKS listener on 127.0.0.1:9050".to_string(); + state.tor_status = ServiceStatus::Error(msg.clone()); + Err(msg.into()) + } } /// Stop Tor. @@ -68,18 +78,24 @@ pub fn net_services_stop_tor() { println!("[net] Tor stopped"); } -/// Start FIPS. +/// Start FIPS (attaches to the FIPS daemon's control socket). pub fn net_services_start_fips() -> Result<(), Box> { + // TODO: spawn and manage a FIPS process like the C net_service_enable(). + let result = crate::fips_control::fips_control_start(); let mut state = G_NET_SERVICES.lock().unwrap(); - state.fips_status = ServiceStatus::Starting; - // TODO: actually start FIPS - state.fips_status = ServiceStatus::Running; - println!("[net] FIPS started"); - Ok(()) + match &result { + Ok(()) => { + state.fips_status = ServiceStatus::Running; + println!("[net] FIPS attached"); + } + Err(e) => state.fips_status = ServiceStatus::Error(e.to_string()), + } + result } /// Stop FIPS. pub fn net_services_stop_fips() { + crate::fips_control::fips_control_stop(); let mut state = G_NET_SERVICES.lock().unwrap(); state.fips_status = ServiceStatus::Stopped; println!("[net] FIPS stopped"); diff --git a/src/nostr_bridge.rs b/src/nostr_bridge.rs index f241567..e7da801 100644 --- a/src/nostr_bridge.rs +++ b/src/nostr_bridge.rs @@ -82,6 +82,16 @@ fn handle_sovereign_scheme(request: &URISchemeRequest) { return; } + // ── Agent chat / config API ──────────────────────────────────── + // Must precede the embedded-asset lookup, which also serves + // agents/chat(.html|.css|.js) and agents/config.(css|js). + if route == "agents" || route.starts_with("agents/") { + let query = path.split_once('?').map(|(_, q)| q).unwrap_or(""); + if handle_agents_api(request, route, query) { + return; + } + } + // ── Bookmarks REST API ───────────────────────────────────────── if route.starts_with("bookmarks/") { handle_bookmarks_api(request, &route[10..]); @@ -121,6 +131,292 @@ fn handle_sovereign_scheme(request: &URISchemeRequest) { respond_error_json(request, 404, "Not found"); } +/// Run `work` on a worker thread and finish `request` with its JSON result +/// on the GTK main thread. Used for routes that may block (LLM HTTP, +/// remote signers, relay publishing) so the UI never freezes. +fn respond_json_async( + request: &URISchemeRequest, + work: impl FnOnce() -> serde_json::Value + Send + 'static, +) { + let (tx, rx) = futures_channel::oneshot::channel::(); + std::thread::spawn(move || { + let _ = tx.send(work()); + }); + let request = request.clone(); + glib::MainContext::default().spawn_local(async move { + let value = rx + .await + .unwrap_or_else(|_| serde_json::json!({"error": -1, "message": "worker failed"})); + respond_json(&request, &value.to_string()); + }); +} + +/// C-compatible error object (`{"error": -1, "message": ...}`) — the +/// agent pages read `d.error` as a flag and `d.message` for the text. +fn agent_error(msg: &str) -> serde_json::Value { + serde_json::json!({"error": -1, "message": msg}) +} + +/// Handle sovereign://agents[/...] routes. Returns false for routes that +/// should fall through to the embedded asset lookup. +fn handle_agents_api(request: &URISchemeRequest, route: &str, query: &str) -> bool { + use crate::{agent_conversations as conv, agent_llm, agent_loop, agent_skills}; + let q = |k: &str| extract_query_param(query, k).unwrap_or_default(); + + match route { + // Config page (HTML is embedded; "agents" alone maps to it). + "agents" => respond_html(request, &crate::embedded_web_content::get_agent_config_html()), + "agents/config" => respond_json(request, &agents_config_json().to_string()), + "agents/set" => { + let result = agents_set(&q("key"), &q("value")); + respond_json(request, &result.to_string()); + } + "agents/models" => { + let cfg = agent_llm::agent_config_get(); + let base = Some(q("base_url")).filter(|s| !s.is_empty()).unwrap_or_else(|| cfg.base_url()); + let key = Some(q("api_key")).filter(|s| !s.is_empty()).unwrap_or_else(|| cfg.api_key()); + respond_json_async(request, move || match agent_llm::agent_llm_list_models(&base, &key) { + Ok(models) => { + // Cache on the active provider for the next page load. + let cached = models.clone(); + agent_llm::agent_config_update(|c| { + if let Some(p) = c.active_mut() { + p.models = cached; + } + }); + serde_json::json!(models) + } + Err(e) => serde_json::json!({"error": e}), + }); + } + + // Chat loop. + "agents/messages" => { + let msgs = crate::agent_chat_store::agent_chat_store_get_messages(); + respond_json(request, &serde_json::Value::Array(msgs).to_string()); + } + "agents/send" => { + let text = q("text"); + let result = if text.trim().is_empty() { + agent_error("Missing text parameter") + } else { + match agent_loop::agent_loop_run(&text) { + Ok(()) => serde_json::json!({"status": "started"}), + Err(e) => serde_json::json!({"status": "error", "error": -1, "message": e}), + } + }; + respond_json(request, &result.to_string()); + } + "agents/status" => { + respond_json(request, &agent_loop::agent_loop_get_status().to_json().to_string()); + } + "agents/cancel" => { + agent_loop::agent_loop_cancel(); + respond_json(request, "{\"status\":\"cancelled\"}"); + } + + // Conversations. + "agents/conversations" => { + respond_json(request, &serde_json::Value::Array(conv::agent_conversations_list()).to_string()); + } + "agents/conversations/new" => { + let title = Some(q("title")).filter(|t| !t.trim().is_empty()).unwrap_or_else(|| "New Chat".into()); + let id = crate::agent_chat_store::agent_chat_store_new_session(&title); + let _ = conv::agent_conversations_save(Some(&id), Some(&title)); + respond_json(request, &serde_json::json!({"id": id, "title": title}).to_string()); + } + "agents/conversations/load" => { + let result = match conv::agent_conversations_load(&q("id")) { + Ok(()) => serde_json::json!({"status": "ok"}), + Err(e) => agent_error(&e), + }; + respond_json(request, &result.to_string()); + } + "agents/conversations/delete" => { + let result = match conv::agent_conversations_delete(&q("id")) { + Ok(()) => serde_json::json!({"status": "deleted"}), + Err(e) => agent_error(&e), + }; + respond_json(request, &result.to_string()); + } + "agents/conversations/save" => { + let id = q("id"); + let title = q("title"); + let result = match conv::agent_conversations_save( + Some(id.as_str()).filter(|s| !s.is_empty()), + Some(title.as_str()).filter(|s| !s.is_empty()), + ) { + Some(id) => serde_json::json!({"status": "saved", "id": id}), + None => agent_error("Failed to save conversation"), + }; + respond_json(request, &result.to_string()); + } + "agents/conversations/rename" => { + let result = match conv::agent_conversations_rename(&q("id"), &q("title")) { + Ok(()) => serde_json::json!({"status": "renamed"}), + Err(e) => agent_error(&e), + }; + respond_json(request, &result.to_string()); + } + + // Skills. + "agents/skills" => { + respond_json(request, &serde_json::Value::Array(agent_skills::agent_skills_fetch()).to_string()); + } + "agents/skills/default" => { + respond_json(request, &agent_skills::agent_skills_get_default().to_string()); + } + "agents/skills/selected" => { + respond_json(request, &serde_json::json!(agent_skills::agent_skills_get_selected()).to_string()); + } + "agents/skills/select" => { + let d = q("d"); + let result = if d.is_empty() { + agent_error("Missing d parameter") + } else { + serde_json::json!({"status": "ok", "selected": agent_skills::agent_skills_toggle_selected(&d)}) + }; + respond_json(request, &result.to_string()); + } + "agents/skills/create" | "agents/skills/update" => { + let is_update = route.ends_with("update"); + let (d, name, desc, content) = (q("d"), q("name"), q("description"), q("content")); + let tools = agent_skills::parse_requires_tools(&q("requires_tools")); + // Signing may hit a remote signer: do it off the main thread. + respond_json_async(request, move || { + if is_update { + match agent_skills::agent_skills_update(&d, &name, &desc, &content, &tools) { + Ok(()) => serde_json::json!({"status": "updated"}), + Err(e) => agent_error(&e), + } + } else if name.trim().is_empty() { + agent_error("Missing name parameter") + } else { + match agent_skills::agent_skills_publish(&name, &desc, &content, &tools) { + Ok(d) => serde_json::json!({"status": "published", "d": d}), + Err(e) => agent_error(&e), + } + } + }); + } + "agents/skills/delete" => { + let d = q("d"); + respond_json_async(request, move || match agent_skills::agent_skills_delete(&d) { + Ok(()) => serde_json::json!({"status": "deleted"}), + Err(e) => agent_error(&e), + }); + } + + // Everything else (chat.html/css/js, config.css/js) → embedded assets. + _ => return false, + } + true +} + +/// JSON for sovereign://agents/config (consumed by www/agents/config.js). +fn agents_config_json() -> serde_json::Value { + let cfg = crate::agent_llm::agent_config_get(); + let active = cfg.active(); + serde_json::json!({ + "providers": cfg.providers.iter().map(|p| p.name.clone()).collect::>(), + "active_provider": cfg.active_provider, + "provider_name": active.map(|p| p.name.clone()).unwrap_or_default(), + "base_url": cfg.base_url(), + "api_key": cfg.api_key(), + "models": active.map(|p| p.models.clone()).unwrap_or_default(), + "model": cfg.model, + "max_iterations": cfg.effective_max_iterations(), + "system_prompt": cfg.effective_skill_template(), + "skill_name": cfg.effective_skill_name(), + "skill_description": cfg.effective_skill_description(), + "skill_template": cfg.effective_skill_template(), + "skill_requires_tools": cfg.effective_skill_requires_tools(), + }) +} + +/// Handle sovereign://agents/set?key=...&value=... (port of +/// `handle_agents_set`, nostr_bridge.c:2085). +fn agents_set(key: &str, value: &str) -> serde_json::Value { + if key.is_empty() { + return agent_error("Missing key or value parameter"); + } + let value_owned = value.to_string(); + let result: Result<(), String> = crate::agent_llm::agent_config_update(|c| { + let v = value_owned.trim().to_string(); + match key { + "agent.llm_base_url" => { + let p = c.active_mut().ok_or("No active provider")?; + p.base_url = v; + } + "agent.llm_api_key" => { + let p = c.active_mut().ok_or("No active provider")?; + p.api_key = v; + } + "agent.llm_model" => c.model = v, + "agent.llm_system_prompt" | "agent.skill_template" => c.skill_template = value_owned.clone(), + "agent.skill_name" => c.skill_name = v, + "agent.skill_description" => c.skill_description = v, + "agent.skill_requires_tools" => c.skill_requires_tools = v, + "agent.max_iterations" => { + let n: i32 = v.parse().map_err(|_| "max_iterations must be a number")?; + if !(1..=1000).contains(&n) { + return Err("max_iterations must be between 1 and 1000".into()); + } + c.max_iterations = n; + } + "agent.provider" => { + let idx = c.providers.iter().position(|p| p.name == v).ok_or("Unknown provider")?; + c.active_provider = idx as i32; + } + "agent.provider_add" => { + if v.is_empty() { + return Err("Provider name is required".into()); + } + if c.providers.iter().any(|p| p.name == v) { + return Err("A provider with that name already exists".into()); + } + c.providers.push(crate::agent_llm::LlmProvider { name: v, ..Default::default() }); + c.active_provider = c.providers.len() as i32 - 1; + } + "agent.provider_remove" => { + if c.providers.len() <= 1 { + return Err("Cannot remove the last provider".into()); + } + let idx = c.providers.iter().position(|p| p.name == v).ok_or("Unknown provider")?; + c.providers.remove(idx); + if c.active_provider as usize >= c.providers.len() || c.active_provider as usize == idx { + c.active_provider = 0; + } + } + "agent.provider_name" => { + if v.is_empty() { + return Err("Provider name is required".into()); + } + let active = c.active_provider; + if c.providers.iter().enumerate().any(|(i, p)| p.name == v && i as i32 != active) { + return Err("A provider with that name already exists".into()); + } + c.active_mut().ok_or("No active provider")?.name = v; + } + "agent.provider_base_url" | "agent.provider_api_key" => { + let (name, val) = v.split_once('|').ok_or("Expected name|value")?; + let p = c.providers.iter_mut().find(|p| p.name == name).ok_or("Unknown provider")?; + if key.ends_with("base_url") { + p.base_url = val.trim().to_string(); + } else { + p.api_key = val.trim().to_string(); + } + } + _ => return Err(format!("Unknown key: {}", key)), + } + Ok(()) + }); + match result { + Ok(()) => serde_json::json!({"status": "ok", "key": key, "value": value}), + Err(e) => agent_error(&e), + } +} + /// Handle sovereign://processes/* REST API. /// Routes: list, tabs, probe-report, tab_probe, tab_action. fn handle_processes_api(request: &URISchemeRequest, sub: &str) { diff --git a/src/nostr_publish.rs b/src/nostr_publish.rs new file mode 100644 index 0000000..3f6eeb3 --- /dev/null +++ b/src/nostr_publish.rs @@ -0,0 +1,248 @@ +//! Shared helpers for creating, signing, encrypting, storing and publishing +//! the user's own Nostr events (agent conversations, skills, deletions). +//! +//! Port of the C `nostr_create_and_sign_event_with_signer` + +//! `synchronous_publish_event_with_progress` usage in agent_conversations.c +//! and agent_skills.c. All functions are blocking: call them from a worker +//! thread (never the GTK main thread) since remote signers and relay +//! publishing can take seconds. + +use nostr_core::types::{Event, Kind, PublicKey, Tag}; + +/// Maximum number of bootstrap relays to publish to. +const MAX_RELAYS: usize = 32; +/// Per-publish connect timeout. +const PUBLISH_CONNECT_TIMEOUT_MS: u64 = 10_000; + +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn parse_pubkey(hex_str: &str) -> Result { + let bytes = hex::decode(hex_str).map_err(|_| "Invalid pubkey hex".to_string())?; + let arr: [u8; 32] = bytes.try_into().map_err(|_| "Invalid pubkey length".to_string())?; + Ok(PublicKey::from_bytes(arr)) +} + +/// The signed-in identity, or an error if there is no signer (read-only +/// / no-login mode). +fn identity() -> Result<(std::sync::Arc, String), String> { + let signer = crate::menu::app_get_signer().ok_or("No signer available (read-only or not logged in)")?; + let pk = crate::menu::app_get_pubkey_hex(); + if pk.is_empty() { + return Err("Not logged in".to_string()); + } + Ok((signer, pk)) +} + +/// Whether a signer is available for publishing / encryption. +pub fn have_signer() -> bool { + identity().is_ok() +} + +/// The signed-in user's pubkey hex (empty when not logged in). +pub fn my_pubkey() -> String { + crate::menu::app_get_pubkey_hex() +} + +/// NIP-44 encrypt `plaintext` to self; returns base64 ciphertext. +pub fn nip44_encrypt_self(plaintext: &str) -> Result { + let (signer, pk_hex) = identity()?; + let pk = parse_pubkey(&pk_hex)?; + let ct = signer + .nip44_encrypt(&pk, plaintext) + .map_err(|e| format!("NIP-44 encrypt failed: {}", e))?; + Ok(nostr_core::util::base64_encode(&ct)) +} + +/// NIP-44 decrypt a base64 ciphertext encrypted to self. +pub fn nip44_decrypt_self(ciphertext_b64: &str) -> Result { + let (signer, pk_hex) = identity()?; + let pk = parse_pubkey(&pk_hex)?; + let raw = nostr_core::util::base64_decode(ciphertext_b64) + .map_err(|e| format!("Invalid base64 ciphertext: {}", e))?; + let pt = signer + .nip44_decrypt(&pk, &raw) + .map_err(|e| format!("NIP-44 decrypt failed: {}", e))?; + String::from_utf8(pt).map_err(|_| "Decrypted content is not UTF-8".to_string()) +} + +/// Build and sign an event of `kind` with the given tags and content. +pub fn sign_event(kind: u64, tags: Vec>, content: &str) -> Result { + let (signer, pk_hex) = identity()?; + let pk = parse_pubkey(&pk_hex)?; + let event = Event::new( + pk, + now_secs(), + Kind::from_u64(kind), + tags.into_iter().map(Tag).collect(), + content, + ); + signer.sign_event(&event).map_err(|e| format!("Signing failed: {}", e)) +} + +/// Store a signed event in the local SQLite cache. Returns the event id. +pub fn store_event(event: &Event) -> Result { + let id = event.id.as_ref().map(|i| i.to_hex()).ok_or("Signed event has no id")?; + let tags_json = serde_json::to_string(&event.tags).unwrap_or_else(|_| "[]".into()); + crate::db::db_store_event( + &id, + &event.pubkey.to_hex(), + event.kind.as_u64(), + event.created_at, + &event.content, + &tags_json, + event.sig.as_ref().map(|s| s.to_hex()).as_deref(), + None, + ) + .map_err(|e| format!("Failed to store event: {}", e))?; + Ok(id) +} + +/// Delete a locally cached event by id (used after publishing a kind 5). +pub fn delete_local_event(id: &str) { + let _ = crate::db::db_delete_event(id); +} + +/// Publish a signed event to the bootstrap relays. Returns the number of +/// relays the event was sent to. Never fails hard: offline use still +/// works because events are stored locally first. +pub fn publish_event(event: &Event) -> usize { + let relays: Vec = crate::settings::settings_get_bootstrap_relays() + .into_iter() + .filter(|u| { + // Skip .onion relays unless Tor is reachable. + !u.contains(".onion") + || crate::net_services::net_services_get_tor_socks_endpoint().is_some() + }) + .take(MAX_RELAYS) + .collect(); + if relays.is_empty() { + println!("[nostr-publish] No relays configured; event stored locally only"); + return 0; + } + + let rt = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(e) => { + eprintln!("[nostr-publish] Failed to create runtime: {}", e); + return 0; + } + }; + let sent = rt.block_on(async { + use nostr_relay::pool::{ReconnectConfig, RelayPool}; + let pool = RelayPool::new(Some(ReconnectConfig::default())); + for url in &relays { + let _ = pool.add_relay(url).await; + } + pool.connect_all_with_timeout(PUBLISH_CONNECT_TIMEOUT_MS).await; + let connected = pool.connected_relay_urls().await; + if connected.is_empty() { + pool.disconnect_all().await; + return 0; + } + if let Err(e) = pool.publish_async(&connected, event).await { + eprintln!("[nostr-publish] Publish failed: {:?}", e); + } + // Give the relays a moment to receive the frame before closing. + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + pool.disconnect_all().await; + connected.len() + }); + println!( + "[nostr-publish] kind {} published to {}/{} relays", + event.kind.as_u64(), + sent, + relays.len() + ); + sent +} + +/// Sign, store locally and publish. Returns the event id. +pub fn sign_store_publish(kind: u64, tags: Vec>, content: &str) -> Result { + let event = sign_event(kind, tags, content)?; + let id = store_event(&event)?; + publish_event(&event); + Ok(id) +} + +/// Return the first value of tag `name` in a cached event JSON. +pub fn tag_value<'a>(event: &'a serde_json::Value, name: &str) -> Option<&'a str> { + event["tags"].as_array()?.iter().find_map(|t| { + let a = t.as_array()?; + if a.len() >= 2 && a[0].as_str() == Some(name) { + a[1].as_str() + } else { + None + } + }) +} + +/// All values of tag `name` in a cached event JSON. +pub fn tag_values(event: &serde_json::Value, name: &str) -> Vec { + event["tags"] + .as_array() + .map(|tags| { + tags.iter() + .filter_map(|t| { + let a = t.as_array()?; + if a.len() >= 2 && a[0].as_str() == Some(name) { + a[1].as_str().map(String::from) + } else { + None + } + }) + .collect() + }) + .unwrap_or_default() +} + +/// Whether a cached event has tag `[name, value]`. +pub fn has_tag(event: &serde_json::Value, name: &str, value: &str) -> bool { + tag_values(event, name).iter().any(|v| v == value) +} + +/// For addressable events (30000–39999), keep only the newest event per +/// (pubkey, d-tag). Input need not be sorted. +pub fn latest_per_d_tag(events: Vec) -> Vec { + let mut best: std::collections::HashMap<(String, String), serde_json::Value> = + std::collections::HashMap::new(); + for ev in events { + let key = ( + ev["pubkey"].as_str().unwrap_or("").to_string(), + tag_value(&ev, "d").unwrap_or("").to_string(), + ); + let newer = best + .get(&key) + .map(|cur| ev["created_at"].as_u64() > cur["created_at"].as_u64()) + .unwrap_or(true); + if newer { + best.insert(key, ev); + } + } + let mut out: Vec = best.into_values().collect(); + out.sort_by(|a, b| b["created_at"].as_u64().cmp(&a["created_at"].as_u64())); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tag_helpers_and_dedup() { + let a = serde_json::json!({"pubkey":"p","created_at":1,"tags":[["d","x"],["t","a"],["t","b"]]}); + let b = serde_json::json!({"pubkey":"p","created_at":5,"tags":[["d","x"]]}); + let c = serde_json::json!({"pubkey":"p","created_at":3,"tags":[["d","y"]]}); + assert_eq!(tag_value(&a, "d"), Some("x")); + assert_eq!(tag_values(&a, "t"), vec!["a", "b"]); + assert!(has_tag(&a, "t", "b")); + let out = latest_per_d_tag(vec![a, b, c]); + assert_eq!(out.len(), 2); + assert_eq!(out[0]["created_at"], 5); + assert_eq!(out[1]["created_at"], 3); + } +} diff --git a/src/settings.rs b/src/settings.rs index d6b73da..aeead34 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -44,6 +44,12 @@ pub struct BrowserSettings { /// break scrolling on listener-heavy pages. Mirrors the C default /// (settings.c:136). pub perf_probe_enabled: bool, + /// Route .onion / tor:// traffic through Tor. Toggled from the + /// hamburger menu. Mirrors the C `tor_enabled` (default TRUE). + pub tor_enabled: bool, + /// Enable the FIPS mesh service. Mirrors the C `fips_enabled` + /// (default TRUE). + pub fips_enabled: bool, } impl Default for BrowserSettings { @@ -81,6 +87,8 @@ impl Default for BrowserSettings { enable_write_console_messages_to_stdout: false, // Perf probe OFF by default (see field docs). perf_probe_enabled: false, + tor_enabled: true, + fips_enabled: true, } } } @@ -139,6 +147,26 @@ pub fn settings_update(json: &serde_json::Value) { if let Some(v) = json.get("enable_write_console_messages_to_stdout").and_then(|v| v.as_bool()) { settings.enable_write_console_messages_to_stdout = v; } + if let Some(v) = json.get("tor_enabled").and_then(|v| v.as_bool()) { + settings.tor_enabled = v; + } + if let Some(v) = json.get("fips_enabled").and_then(|v| v.as_bool()) { + settings.fips_enabled = v; + } +} + +/// Set the Tor / FIPS enabled preference and persist it. +pub fn settings_set_network_enabled(tor: Option, fips: Option) { + { + let mut settings = G_SETTINGS.lock().unwrap(); + if let Some(v) = tor { + settings.tor_enabled = v; + } + if let Some(v) = fips { + settings.fips_enabled = v; + } + } + settings_save(); } /// Load settings from the database. @@ -168,7 +196,10 @@ pub fn settings_save() { "theme_dark": settings.theme_dark, "perf_probe_enabled": settings.perf_probe_enabled, "enable_write_console_messages_to_stdout": settings.enable_write_console_messages_to_stdout, + "tor_enabled": settings.tor_enabled, + "fips_enabled": settings.fips_enabled, }); + drop(settings); if let Ok(json_str) = serde_json::to_string(&json) { let _ = db::db_kv_set("browser_settings", &json_str); diff --git a/src/tab_manager.rs b/src/tab_manager.rs index f52ceb1..9d2475c 100644 --- a/src/tab_manager.rs +++ b/src/tab_manager.rs @@ -357,7 +357,7 @@ pub fn tab_manager_new_tab(notebook: >k::Notebook, ctx: &WebContext, url: Opti Some("view-refresh-symbolic"), gtk::IconSize::Menu, ))); - refresh_btn.set_tooltip_text(Some("Reload page")); + refresh_btn.set_tooltip_text(Some("Reload page (right-click for hard reload options)")); toolbar.pack_start(&refresh_btn, false, false, 0); // Back button. @@ -433,6 +433,8 @@ pub fn tab_manager_new_tab(notebook: >k::Notebook, ctx: &WebContext, url: Opti bookmark_bar.set_margin_start(4); bookmark_bar.set_margin_end(4); page.pack_start(&bookmark_bar, false, false, 0); + bookmark_bar_refresh(&bookmark_bar, &webview); + track_bookmark_bar(&bookmark_bar, &webview); // ── Load-progress bar ─────────────────────────────────────────── let progress_bar = gtk::ProgressBar::new(); @@ -553,8 +555,10 @@ pub fn tab_manager_new_tab(notebook: >k::Notebook, ctx: &WebContext, url: Opti // Load progress. let progress_bar_clone = progress_bar.clone(); + let refresh_btn_load = refresh_btn.clone(); webview.connect_load_changed(move |wv, event| { let loading = wv.is_loading(); + refresh_button_update(&refresh_btn_load, loading); progress_bar_clone.set_visible(loading); if !loading { progress_bar_clone.set_fraction(1.0); @@ -592,6 +596,27 @@ pub fn tab_manager_new_tab(notebook: >k::Notebook, ctx: &WebContext, url: Opti None }); + // Keep the progress fraction moving during the load. + let progress_bar_est = progress_bar.clone(); + webview.connect_estimated_load_progress_notify(move |wv| { + progress_bar_est.set_fraction(wv.estimated_load_progress()); + }); + // A failed/cancelled load also ends the "stop" state. + let refresh_btn_fail = refresh_btn.clone(); + webview.connect_load_failed(move |wv, _ev, _uri, _err| { + refresh_button_update(&refresh_btn_fail, wv.is_loading()); + false + }); + + // Grab GTK focus on the webview when clicked so mouse-wheel scrolling + // keeps working (mirrors the C `on_webview_button_press`). + webview.connect_button_press_event(|wv, _ev| { + if !wv.has_focus() { + wv.grab_focus(); + } + glib::Propagation::Proceed + }); + // URL entry activate. let webview_clone = webview.clone(); url_entry.connect_activate(move |entry| { @@ -599,11 +624,35 @@ pub fn tab_manager_new_tab(notebook: >k::Notebook, ctx: &WebContext, url: Opti if text.is_empty() { return; } - let url = if search::search_is_url(&text) { + // Agent chat shortcut: "; " routes to the embedded agent + // (opens the sidebar and sends). Mirrors on_url_activate. + if let Some(msg) = text.strip_prefix(';') { + if let Err(e) = crate::agent_chat::agent_chat_route_input(Some(msg)) { + eprintln!("[agent_chat] {}", e); + } + entry.set_text(""); + return; + } + // Block navigation to Nostr private keys (mirrors on_url_activate). + if crate::nostr_url::nostr_url_detect(&text) == crate::nostr_url::NostrEntityType::Nsec { + webview_clone.load_html( + "Private key blocked\ +

Navigation blocked

Nostr private keys cannot be opened or navigated to.

", + Some("nostr://blocked-private-key"), + ); + return; + } + let mut url = if search::search_is_url(&text) { search::normalize_url(&text) } else { search::search_query(&text) }; + // Rewrite file:// to local:// so pages get a proper SecurityOrigin + // with full storage quota (mirrors tab_manager.c on_url_activate). + if let Some(rest) = url.strip_prefix("file://") { + url = format!("local://{}", rest); + println!("[url-bar] Rewriting {} -> {}", text, url); + } webview_clone.load_uri(&url); }); @@ -620,42 +669,47 @@ pub fn tab_manager_new_tab(notebook: >k::Notebook, ctx: &WebContext, url: Opti webview_clone3.go_forward(); } }); + // Refresh/Stop: left-click stops while loading, reloads otherwise. let webview_clone4 = webview.clone(); - refresh_btn.connect_clicked(move |_| { - webview_clone4.reload(); + refresh_btn.connect_clicked(move |btn| { + refresh_or_stop(&webview_clone4, btn); + }); + // Right-click: Reload / Hard reload / Clear site data + hard reload. + let webview_refresh_menu = webview.clone(); + refresh_btn.connect_button_press_event(move |btn, event| { + if event.button() == 3 { + show_refresh_menu(&webview_refresh_menu, btn, event); + return glib::Propagation::Stop; + } + glib::Propagation::Proceed }); - // Bookmark button. + // Bookmark button — opens the folder picker dialog. let webview_clone5 = webview.clone(); - bookmark_btn.connect_clicked(move |_| { - if let Some(uri) = webview_clone5.uri() { - let title: String = webview_clone5.title().unwrap_or_else(|| "Untitled".into()).into(); - let _ = bookmarks::bookmarks_add(&title, &uri, "General"); - } + bookmark_btn.connect_clicked(move |btn| { + show_bookmark_dialog(btn, &webview_clone5); }); // Close button. - if let Some(close_btn) = close_btn { + if let Some(ref close_btn) = close_btn { let notebook_clone = notebook.clone(); close_btn.connect_clicked(move |_| { tab_manager_close_tab(¬ebook_clone, tab_id); }); } - // Tab label button-press (middle-click close, right-click context menu). - let notebook_clone2 = notebook.clone(); - tab_label.connect_button_press_event(move |_w, event| { - let s = settings::settings_get(); - if event.button() == 2 && s.middle_click_close { - tab_manager_close_tab(¬ebook_clone2, tab_id); - return glib::Propagation::Stop; - } - if event.button() == 3 { - show_tab_context_menu(¬ebook_clone2, tab_id, event); - return glib::Propagation::Stop; - } - glib::Propagation::Proceed - }); + // Tab label right/middle-click. The label is a windowless gtk::Box, so + // it never receives button-press events itself — the notebook-level + // handler installed by `setup_notebook_action_widgets` hit-tests the + // tab labels instead (mirrors the C `on_notebook_button_press`). The + // close button *does* have its own input window and swallows presses, + // so it gets the handler directly (mirrors tab_manager.c:1442). + if let Some(ref btn) = close_btn { + let notebook_clone2 = notebook.clone(); + btn.connect_button_press_event(move |_w, event| { + handle_tab_button_press(¬ebook_clone2, tab_id, event) + }); + } // Update the tab's URL in state when the page navigates. // Also rewrites .onion HTTP(S) URLs to tor:// for Tor SOCKS routing, @@ -993,20 +1047,25 @@ pub fn tab_manager_go_forward() { } } -/// Focus the URL entry of the active tab. +/// Depth-first search for the first `gtk::Entry` under `widget`. +fn find_entry(widget: >k::Widget) -> Option { + if let Some(entry) = widget.downcast_ref::() { + return Some(entry.clone()); + } + let container = widget.downcast_ref::()?; + container.children().iter().find_map(find_entry) +} + +/// Focus the URL entry of the active tab and select its text. +/// +/// The entry lives inside the per-tab toolbar box (page → toolbar → +/// entry), so it must be searched for recursively. pub fn tab_manager_focus_url() { if let Some(nb) = tab_manager_get_active_notebook() { let cur = nb.current_page().unwrap_or(0); - if let Some(page) = nb.nth_page(Some(cur)) { - if let Ok(box_) = page.downcast::() { - for child in box_.children() { - if let Ok(entry) = child.downcast::() { - entry.grab_focus(); - entry.select_region(0, -1); - return; - } - } - } + if let Some(entry) = nb.nth_page(Some(cur)).as_ref().and_then(find_entry) { + entry.grab_focus(); + entry.select_region(0, -1); } } } @@ -1238,6 +1297,70 @@ pub fn tab_manager_new_window(url: Option<&str>, ctx: &WebContext) -> Option Option { + let wv = find_webview(page)?; + G_TAB_WIDGETS + .lock() + .unwrap() + .iter() + .find(|(_, (_, w))| w.get_ref() == &wv) + .map(|(id, _)| *id) +} + +/// Shared middle-click-close / right-click-menu handling for a tab. +fn handle_tab_button_press( + notebook: >k::Notebook, + tab_id: i32, + event: &gdk::EventButton, +) -> glib::Propagation { + if event.event_type() != gdk::EventType::ButtonPress { + return glib::Propagation::Proceed; + } + match event.button() { + 2 if settings::settings_get().middle_click_close => { + tab_manager_close_tab(notebook, tab_id); + glib::Propagation::Stop + } + 3 => { + show_tab_context_menu(notebook, tab_id, event); + glib::Propagation::Stop + } + _ => glib::Propagation::Proceed, + } +} + +/// Notebook-level button-press handler: finds which tab label was clicked +/// (by comparing root coordinates against each label's allocation) and +/// shows the tab context menu / closes on middle-click. Port of the C +/// `on_notebook_button_press` (tab_manager.c:720). +fn on_notebook_button_press(notebook: >k::Notebook, event: &gdk::EventButton) -> glib::Propagation { + if event.button() != 2 && event.button() != 3 { + return glib::Propagation::Proceed; + } + let (rx, ry) = event.root(); + for i in 0..notebook.n_pages() { + let Some(page) = notebook.nth_page(Some(i)) else { continue }; + let Some(label) = notebook.tab_label(&page) else { continue }; + if !label.is_mapped() { + continue; + } + // A windowless widget's allocation is relative to its parent's + // GdkWindow; convert that window's origin to root coordinates. + let Some(win) = label.window() else { continue }; + let (_, ox, oy) = win.origin(); + let a = label.allocation(); + let (lx, ly) = ((ox + a.x()) as f64, (oy + a.y()) as f64); + if rx >= lx && rx < lx + a.width() as f64 && ry >= ly && ry < ly + a.height() as f64 { + if let Some(tab_id) = tab_id_for_page(&page) { + return handle_tab_button_press(notebook, tab_id, event); + } + return glib::Propagation::Proceed; + } + } + glib::Propagation::Proceed +} + /// Show the tab context menu (right-click on a tab label). fn show_tab_context_menu(notebook: >k::Notebook, tab_id: i32, event: &gdk::EventButton) { let menu = gtk::Menu::new(); @@ -1401,6 +1524,245 @@ pub fn tab_manager_open_in_new_window(tab_id: i32) { tab_manager_close_tab(&nb, tab_id); } +/// Sync the refresh button's icon/tooltip with the loading state. +fn refresh_button_update(btn: >k::Button, loading: bool) { + let (icon, tip) = if loading { + ("process-stop-symbolic", "Stop loading") + } else { + ("view-refresh-symbolic", "Reload page (right-click for hard reload options)") + }; + btn.set_image(Some(>k::Image::from_icon_name(Some(icon), gtk::IconSize::Menu))); + btn.set_tooltip_text(Some(tip)); +} + +/// Refresh/Stop action: stop when loading, reload when idle. +fn refresh_or_stop(wv: &WebView, btn: >k::Button) { + if wv.is_loading() { + println!("[tab] stop loading requested"); + wv.stop_loading(); + refresh_button_update(btn, wv.is_loading()); + } else { + wv.reload(); + } +} + +/// Right-click menu on the refresh button (port of on_refresh_button_press). +fn show_refresh_menu(wv: &WebView, btn: >k::Button, event: &gdk::EventButton) { + let menu = gtk::Menu::new(); + + let item_reload = gtk::MenuItem::with_label("Reload"); + let (w, b) = (wv.clone(), btn.clone()); + item_reload.connect_activate(move |_| refresh_or_stop(&w, &b)); + menu.append(&item_reload); + + let item_hard = gtk::MenuItem::with_label("Hard reload (bypass cache)"); + let w = wv.clone(); + item_hard.connect_activate(move |_| w.reload_bypass_cache()); + menu.append(&item_hard); + + menu.append(>k::SeparatorMenuItem::new()); + + let item_clear = gtk::MenuItem::with_label("Clear site data + hard reload"); + let w = wv.clone(); + item_clear.connect_activate(move |_| { + let w2 = w.clone(); + // run_javascript is deprecated but is what the rest of the crate uses. + #[allow(deprecated)] + w.run_javascript( + "(function(){try{localStorage.clear();}catch(e){}\ + try{sessionStorage.clear();}catch(e){}return 'ok';})()", + None::<&gio::Cancellable>, + move |_| w2.reload_bypass_cache(), + ); + }); + menu.append(&item_clear); + + menu.show_all(); + menu.popup_at_pointer(Some(event)); +} + +/// Name of the bookmark folder shown in the per-tab bookmark bar. +const BOOKMARKS_BAR_FOLDER: &str = "Bookmarks Bar"; + +/// All live bookmark bars (bar, owning webview), refreshed when bookmarks +/// change. Weak refs so closed tabs drop out automatically. +static G_BOOKMARK_BARS: Lazy< + Mutex, glib::WeakRef)>>>, +> = Lazy::new(|| Mutex::new(Vec::new())); + +fn track_bookmark_bar(bar: >k::Box, wv: &WebView) { + G_BOOKMARK_BARS + .lock() + .unwrap() + .push(glib::thread_guard::ThreadGuard::new((bar.downgrade(), wv.downgrade()))); +} + +/// Rebuild every open tab's bookmark bar. +pub fn tab_manager_refresh_bookmark_bars() { + let bars: Vec<(gtk::Box, WebView)> = { + let mut list = G_BOOKMARK_BARS.lock().unwrap(); + list.retain(|g| g.get_ref().0.upgrade().is_some()); + list.iter() + .filter_map(|g| { + let (b, w) = g.get_ref(); + Some((b.upgrade()?, w.upgrade()?)) + }) + .collect() + }; + for (bar, wv) in bars { + bookmark_bar_refresh(&bar, &wv); + } +} + +/// Populate a tab's bookmark bar from the "Bookmarks Bar" folder (and its +/// sub-folders, shown as drop-down menu buttons). Port of the C +/// `bookmark_bar_refresh` / `bookmark_bar_add_folder`. +fn bookmark_bar_refresh(bar: >k::Box, wv: &WebView) { + for child in bar.children() { + bar.remove(&child); + } + + let prefix = format!("{}/", BOOKMARKS_BAR_FOLDER); + let bms = bookmarks::bookmarks_get_all().unwrap_or_default(); + let field = |bm: &serde_json::Value, k: &str| { + bm.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string() + }; + + // Direct bookmarks + sub-folder name → bookmarks. + let mut direct: Vec<(String, String)> = Vec::new(); + let mut subfolders: Vec<(String, Vec<(String, String)>)> = Vec::new(); + for bm in &bms { + let folder = field(bm, "folder"); + let url = field(bm, "url"); + let title = field(bm, "title"); + let label = if title.is_empty() { url.clone() } else { title }; + if folder == BOOKMARKS_BAR_FOLDER { + direct.push((label, url)); + } else if let Some(sub) = folder.strip_prefix(&prefix) { + match subfolders.iter_mut().find(|(n, _)| n == sub) { + Some((_, v)) => v.push((label, url)), + None => subfolders.push((sub.to_string(), vec![(label, url)])), + } + } + } + + if direct.is_empty() && subfolders.is_empty() { + let hint = gtk::Label::new(Some( + "Bookmarks Bar is empty — bookmark a page and choose \"Bookmarks Bar\" as the folder", + )); + hint.set_sensitive(false); + hint.set_margin_start(4); + bar.pack_start(&hint, false, false, 0); + bar.show_all(); + return; + } + + for (label, url) in direct { + let btn = gtk::Button::with_label(&truncate_label(&label)); + btn.set_widget_name("bookmark-bar-btn"); + btn.style_context().add_class("bookmark-bar-btn"); + btn.set_tooltip_text(Some(&url)); + let w = wv.clone(); + btn.connect_clicked(move |_| w.load_uri(&url)); + bar.pack_start(&btn, false, false, 0); + } + + for (name, items) in subfolders { + let menu = gtk::Menu::new(); + for (label, url) in items { + let item = gtk::MenuItem::with_label(&truncate_label(&label)); + item.set_tooltip_text(Some(&url)); + let w = wv.clone(); + item.connect_activate(move |_| w.load_uri(&url)); + menu.append(&item); + } + menu.show_all(); + let mb = gtk::MenuButton::new(); + mb.set_widget_name("bookmark-bar-menu-btn"); + mb.style_context().add_class("bookmark-bar-btn"); + mb.set_label(&name); + mb.set_tooltip_text(Some(&format!("{}{}", prefix, name))); + mb.set_popup(Some(&menu)); + bar.pack_start(&mb, false, false, 0); + } + bar.show_all(); +} + +/// "Bookmark this page" dialog with a folder picker (existing folders plus +/// a free-text entry for new paths). Port of the C `on_bookmark_clicked`. +fn show_bookmark_dialog(btn: >k::Button, wv: &WebView) { + let url = match wv.uri() { + Some(u) if !u.is_empty() => u.to_string(), + _ => { + println!("[bookmarks] No URL to bookmark"); + return; + } + }; + if url.starts_with("sovereign://") { + println!("[bookmarks] Cannot bookmark internal pages"); + return; + } + let title = wv.title().map(|t| t.to_string()).unwrap_or_default(); + + let parent = btn.toplevel().and_then(|w| w.downcast::().ok()); + let dialog = gtk::Dialog::with_buttons( + Some("Bookmark Page"), + parent.as_ref(), + gtk::DialogFlags::MODAL | gtk::DialogFlags::DESTROY_WITH_PARENT, + &[("_Cancel", gtk::ResponseType::Cancel), ("_Add", gtk::ResponseType::Accept)], + ); + dialog.set_default_size(350, 150); + let content = dialog.content_area(); + content.set_border_width(12); + + let info = gtk::Label::new(None); + info.set_markup(&format!( + "{}\n{}", + glib::markup_escape_text(if title.is_empty() { "(untitled)" } else { &title }), + glib::markup_escape_text(&url), + )); + info.set_line_wrap(true); + info.set_halign(gtk::Align::Start); + content.pack_start(&info, false, false, 8); + + let dir_label = gtk::Label::new(Some("Folder path:")); + dir_label.set_halign(gtk::Align::Start); + content.pack_start(&dir_label, false, false, 4); + + // Existing folders (always offer the bar + General). + let mut folders: Vec = vec![BOOKMARKS_BAR_FOLDER.to_string(), "General".to_string()]; + for bm in bookmarks::bookmarks_get_all().unwrap_or_default() { + if let Some(f) = bm.get("folder").and_then(|v| v.as_str()) { + if !f.is_empty() && !folders.iter().any(|x| x == f) { + folders.push(f.to_string()); + } + } + } + let combo = gtk::ComboBoxText::with_entry(); + for f in &folders { + combo.append_text(f); + } + combo.set_active(Some(0)); + content.pack_start(&combo, false, false, 4); + + dialog.show_all(); + let response = dialog.run(); + if response == gtk::ResponseType::Accept { + let folder = combo + .active_text() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "General".to_string()); + let t = if title.is_empty() { url.clone() } else { title }; + match bookmarks::bookmarks_add(&t, &url, &folder) { + Ok(_) => println!("[bookmarks] Bookmarked '{}' to '{}'", url, folder), + Err(e) => eprintln!("[bookmarks] Failed to bookmark: {}", e), + } + tab_manager_refresh_bookmark_bars(); + } + unsafe { dialog.destroy(); } +} + /// Build the hamburger menu button. /// Mirrors the C version (tab_manager.c:2562) with all menu items: /// Navigation, Recents, Bookmarks, Identity, Network, Tools, Pages, About. @@ -1436,36 +1798,27 @@ fn build_hamburger_menu() -> gtk::MenuButton { for child in m.children() { m.remove(&child); } - // Add recent history entries. - if let Ok(history) = crate::db::db_get_history(10) { - if history.is_empty() { - let empty = gtk::MenuItem::with_label("(no recent pages)"); - empty.set_sensitive(false); - m.append(&empty); - } else { - for entry in &history { - let url = entry.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let title = entry.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let label = if !title.is_empty() { title } else { url.clone() }; - let item = gtk::MenuItem::with_label(&label); - item.set_tooltip_text(Some(&url)); - let url_clone = url.clone(); - item.connect_activate(move |_| { - if let Some(nb) = tab_manager_get_active_notebook() { - if let Some(wv) = tab_manager_get_active_webview(&nb) { - if let Some(ctx) = wv.web_context() { - tab_manager_new_tab(&nb, &ctx, Some(&url_clone)); - } - } - } - }); - m.append(&item); - } + // Add recent history entries (up to 20, like the C version). + let history = crate::db::db_get_history(20).unwrap_or_default(); + if history.is_empty() { + let empty = gtk::MenuItem::with_label("(no recent pages)"); + empty.set_sensitive(false); + m.append(&empty); + } else { + for entry in &history { + let url = entry.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let title = entry.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let label = if !title.is_empty() { title } else { url.clone() }; + let item = gtk::MenuItem::with_label(&truncate_label(&label)); + item.set_tooltip_text(Some(&url)); + item.connect_activate(move |_| navigate_active_tab(&url)); + m.append(&item); } m.append(>k::SeparatorMenuItem::new()); let clear_item = gtk::MenuItem::with_label("Clear Recents"); clear_item.connect_activate(|_| { let _ = crate::db::db_clear_history(); + println!("[history] cleared"); }); m.append(&clear_item); } @@ -1482,35 +1835,28 @@ fn build_hamburger_menu() -> gtk::MenuButton { for child in m.children() { m.remove(&child); } - if let Ok(bms) = crate::bookmarks::bookmarks_get_all() { - if bms.is_empty() { - let empty = gtk::MenuItem::with_label("(no bookmarks)"); - empty.set_sensitive(false); - m.append(&empty); - } else { - for bm in &bms { - let url = bm.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let title = bm.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let label = if !title.is_empty() { title.clone() } else { url.clone() }; - let item = gtk::MenuItem::with_label(&label); - let url_clone = url.clone(); - item.connect_activate(move |_| { - if let Some(nb) = tab_manager_get_active_notebook() { - if let Some(wv) = tab_manager_get_active_webview(&nb) { - if let Some(ctx) = wv.web_context() { - tab_manager_new_tab(&nb, &ctx, Some(&url_clone)); - } - } - } - }); - m.append(&item); - } + let bms = crate::bookmarks::bookmarks_get_all().unwrap_or_default(); + if bms.is_empty() { + let empty = gtk::MenuItem::with_label("(no bookmarks)"); + empty.set_sensitive(false); + m.append(&empty); + } else { + // Up to 15 bookmarks, like the C version. + for bm in bms.iter().take(15) { + let url = bm.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let title = bm.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let label = if !title.is_empty() { title } else { url.clone() }; + let item = gtk::MenuItem::with_label(&truncate_label(&label)); + item.set_tooltip_text(Some(&url)); + item.connect_activate(move |_| navigate_active_tab(&url)); + m.append(&item); } - m.append(>k::SeparatorMenuItem::new()); - let manage_item = gtk::MenuItem::with_label("Manage Bookmarks…"); - manage_item.connect_activate(|_| tab_manager_open_internal("sovereign://bookmarks")); - m.append(&manage_item); } + // "Manage Bookmarks…" is always available (even with no bookmarks). + m.append(>k::SeparatorMenuItem::new()); + let manage_item = gtk::MenuItem::with_label("Manage Bookmarks…"); + manage_item.connect_activate(|_| tab_manager_open_internal("sovereign://bookmarks")); + m.append(&manage_item); m.show_all(); }); let item_bookmarks = gtk::MenuItem::with_label("Bookmarks"); @@ -1535,14 +1881,51 @@ fn build_hamburger_menu() -> gtk::MenuButton { menu.append(>k::SeparatorMenuItem::new()); // ── Network toggles ───────────────────────────────────────────── + // Initialized from settings, persisted on toggle, and re-synced every + // time the menu opens so a toggle in one tab is reflected in every + // tab's menu. Mirrors the C `app_menu_network_service_proxy` and + // `on_hamburger_menu_show` (main.c:359, tab_manager.c:2550). + let s = settings::settings_get(); + let syncing = std::rc::Rc::new(std::cell::Cell::new(false)); + let item_tor = gtk::CheckMenuItem::with_label("Tor-routed transport"); - // Tor state is read from settings; for now, default to off. + item_tor.set_active(s.tor_enabled); + let sync_tor = syncing.clone(); + item_tor.connect_toggled(move |item| { + if sync_tor.get() { + return; + } + on_network_toggle(item, NetToggle::Tor, &sync_tor); + }); menu.append(&item_tor); let item_fips = gtk::CheckMenuItem::with_label("FIPS mesh"); + item_fips.set_active(s.fips_enabled); + let sync_fips = syncing.clone(); + item_fips.connect_toggled(move |item| { + if sync_fips.get() { + return; + } + on_network_toggle(item, NetToggle::Fips, &sync_fips); + }); menu.append(&item_fips); + let tor_show = item_tor.clone(); + let fips_show = item_fips.clone(); + let sync_show = syncing.clone(); + menu.connect_show(move |_| { + let s = settings::settings_get(); + sync_show.set(true); + tor_show.set_active(s.tor_enabled); + fips_show.set_active(s.fips_enabled); + sync_show.set(false); + }); + let item_security = gtk::CheckMenuItem::with_label("Security strip (SOP/CORS/certs)"); + item_security.set_active(false); + item_security.connect_toggled(|item| { + println!("[menu] security strip: {}", if item.is_active() { "ON" } else { "OFF" }); + }); menu.append(&item_security); menu.append(>k::SeparatorMenuItem::new()); @@ -1568,7 +1951,8 @@ fn build_hamburger_menu() -> gtk::MenuButton { let label = if pk.is_empty() { "Nostr: not logged in".to_string() } else { - format!("Nostr: {} ({})", &pk[..8], if ro { "read-only" } else { "signing" }) + let short: String = pk.chars().take(8).collect(); + format!("Nostr: {} ({})", short, if ro { "read-only" } else { "signing" }) }; item_nostr_clone.set_label(&label); }); @@ -1662,6 +2046,86 @@ fn build_hamburger_menu() -> gtk::MenuButton { button } +/// Which network service a hamburger-menu toggle controls. +#[derive(Clone, Copy)] +enum NetToggle { + Tor, + Fips, +} + +/// Handle a Tor/FIPS check-item toggle: persist the preference and +/// start/stop the service, reverting the preference (and the check item) +/// if enabling fails. Port of the C `app_menu_network_service_proxy`. +fn on_network_toggle( + item: >k::CheckMenuItem, + which: NetToggle, + syncing: &std::rc::Rc>, +) { + let enabled = item.is_active(); + let (name, set): (&str, fn(bool)) = match which { + NetToggle::Tor => ("Tor", |v| settings::settings_set_network_enabled(Some(v), None)), + NetToggle::Fips => ("FIPS", |v| settings::settings_set_network_enabled(None, Some(v))), + }; + set(enabled); + + let result: Result<(), Box> = match (which, enabled) { + (NetToggle::Tor, true) => crate::net_services::net_services_start_tor(), + (NetToggle::Tor, false) => { + crate::net_services::net_services_stop_tor(); + Ok(()) + } + (NetToggle::Fips, true) => crate::net_services::net_services_start_fips(), + (NetToggle::Fips, false) => { + crate::net_services::net_services_stop_fips(); + Ok(()) + } + }; + + match result { + Err(e) if enabled => { + eprintln!( + "[menu.network] Failed to enable {} service: {}; reverting preference", + name, e + ); + set(false); + syncing.set(true); + item.set_active(false); + syncing.set(false); + } + Err(e) => eprintln!("[menu.network] Failed to disable {} service: {}", name, e), + Ok(()) => println!( + "[menu.network] {} service {}; per-user preference saved", + name, + if enabled { "enabled" } else { "disabled" } + ), + } +} + +/// Truncate a menu label to 75 chars (72 + "..."), like the C version. +fn truncate_label(label: &str) -> String { + if label.chars().count() > 75 { + let mut s: String = label.chars().take(72).collect(); + s.push_str("..."); + s + } else { + label.to_string() + } +} + +/// Load a URL in the active tab of the focused window (used by the +/// Recents / Bookmarks submenus, which navigate the current tab in the +/// C version rather than opening a new one). +fn navigate_active_tab(url: &str) { + if url.is_empty() { + return; + } + if let Some(nb) = tab_manager_get_active_notebook() { + if let Some(wv) = tab_manager_get_active_webview(&nb) { + wv.load_uri(&search::normalize_url(url)); + } + } +} + /// Open an internal sovereign:// page in a new tab. pub fn tab_manager_open_internal(url: &str) { if let Some(nb) = tab_manager_get_active_notebook() { @@ -1683,41 +2147,70 @@ pub fn tab_manager_set_sidebar(paned: >k::Paned, container: >k::Box, _ctx: & container.hide(); } -/// Toggle the agent chat sidebar. -/// Mirrors the C version (tab_manager.c:4294): lazily creates the sidebar -/// webview on first show, sets the paned position to 280px, and hides/shows -/// the sidebar container. -pub fn tab_manager_toggle_sidebar() { - let paned = match G_SIDEBAR_PANED.lock().unwrap().as_ref() { - Some(g) => g.get_ref().clone(), - None => return, - }; - let container = match G_SIDEBAR_CONTAINER.lock().unwrap().as_ref() { - Some(g) => g.get_ref().clone(), - None => return, - }; +/// Whether the agent sidebar is currently shown. +pub fn tab_manager_sidebar_visible() -> bool { + *G_SIDEBAR_VISIBLE.lock().unwrap() +} - let mut visible = G_SIDEBAR_VISIBLE.lock().unwrap(); - if *visible { - // Hide. - paned.set_position(0); - container.hide(); - *visible = false; - } else { - // Show. Create the sidebar webview lazily on first show. - let mut created = G_SIDEBAR_WEBVIEW_CREATED.lock().unwrap(); - if !*created { - create_sidebar_webview(&container); - *created = true; - } - paned.set_position(SIDEBAR_DEFAULT_WIDTH); - container.show_all(); - *visible = true; +/// Remembered sidebar width (the user may drag the divider). +static G_SIDEBAR_WIDTH: Lazy> = Lazy::new(|| Mutex::new(SIDEBAR_DEFAULT_WIDTH)); + +fn sidebar_widgets() -> Option<(gtk::Paned, gtk::Box)> { + let paned = G_SIDEBAR_PANED.lock().unwrap().as_ref()?.get_ref().clone(); + let container = G_SIDEBAR_CONTAINER.lock().unwrap().as_ref()?.get_ref().clone(); + Some((paned, container)) +} + +fn hide_sidebar() { + let Some((paned, container)) = sidebar_widgets() else { return }; + let pos = paned.position(); + if pos > 50 { + *G_SIDEBAR_WIDTH.lock().unwrap() = pos; + } + container.hide(); + paned.set_position(0); + *G_SIDEBAR_VISIBLE.lock().unwrap() = false; + // Hand focus back to the page. + if let Some(wv) = tab_manager_get_main_notebook().and_then(|nb| tab_manager_get_active_webview(&nb)) { + wv.grab_focus(); } } -/// Create the sidebar webview loading sovereign://agents/chat. -fn create_sidebar_webview(container: >k::Box) { +/// Toggle the agent chat sidebar. +/// Mirrors the C version (tab_manager.c:4294): lazily creates the sidebar +/// webview on first show, restores the last width, and hides/shows the +/// sidebar container. +pub fn tab_manager_toggle_sidebar() { + let Some((paned, container)) = sidebar_widgets() else { return }; + + if tab_manager_sidebar_visible() { + hide_sidebar(); + return; + } + + // Show. Create the sidebar webview lazily on first show; retry on a + // later toggle if it could not be created (e.g. no web context yet). + { + let mut created = G_SIDEBAR_WEBVIEW_CREATED.lock().unwrap(); + if !*created { + *created = create_sidebar_webview(&container); + } + } + container.show_all(); + paned.set_position(*G_SIDEBAR_WIDTH.lock().unwrap()); + *G_SIDEBAR_VISIBLE.lock().unwrap() = true; + if let Some(wv) = G_SIDEBAR_WEBVIEW.lock().unwrap().as_ref() { + wv.get_ref().grab_focus(); + } +} + +/// The sidebar's chat webview (for focus handling). +static G_SIDEBAR_WEBVIEW: Lazy>>> = + Lazy::new(|| Mutex::new(None)); + +/// Create the sidebar webview loading sovereign://agents/chat. Returns +/// whether it was created. +fn create_sidebar_webview(container: >k::Box) -> bool { // Get the web context from the active webview. let ctx = match tab_manager_get_active_notebook() .and_then(|nb| tab_manager_get_active_webview(&nb)) @@ -1726,7 +2219,7 @@ fn create_sidebar_webview(container: >k::Box) { Some(c) => c, None => { println!("[sidebar] No web context available"); - return; + return false; } }; @@ -1738,12 +2231,45 @@ fn create_sidebar_webview(container: >k::Box) { // Apply settings so JS and sovereign:// bridge work. if let Some(ws) = webkit2gtk::WebViewExt::settings(&webview) { + crate::web_context::web_context_configure_settings(&ws); ws.set_enable_javascript(true); ws.set_enable_developer_extras(true); ws.set_allow_file_access_from_file_urls(true); ws.set_allow_universal_access_from_file_urls(true); } + // Browser shortcuts must work while the chat has focus (e.g. the + // Ctrl+Shift+A toggle to close the sidebar again). + webview.connect_key_press_event(|_wv, event| crate::key_handler::handle_key_press(event, true)); + + // Links clicked in the chat (e.g. URLs in an answer) open in a tab + // instead of navigating the sidebar away from the chat. + webview.connect_decide_policy(|_wv, decision, decision_type| { + if decision_type != webkit2gtk::PolicyDecisionType::NavigationAction + && decision_type != webkit2gtk::PolicyDecisionType::NewWindowAction + { + return false; + } + let Some(nav) = decision.downcast_ref::() else { return false }; + let Some(uri) = nav.navigation_action().and_then(|a| a.request()).and_then(|r| r.uri()) else { + return false; + }; + let uri = uri.to_string(); + if uri.starts_with(AGENT_CHAT_URL) || uri == "about:blank" { + return false; + } + decision.ignore(); + glib::idle_add_local_once(move || { + if let Some(nb) = tab_manager_get_main_notebook() { + if let Some(ctx) = tab_manager_get_active_webview(&nb).and_then(|w| w.web_context()) { + tab_manager_new_tab(&nb, &ctx, Some(&uri)); + } + } + }); + true + }); + *G_SIDEBAR_WEBVIEW.lock().unwrap() = Some(glib::thread_guard::ThreadGuard::new(webview.clone())); + webview.set_vexpand(true); webview.set_hexpand(true); @@ -1767,15 +2293,7 @@ fn create_sidebar_webview(container: >k::Box) { close_btn.set_margin_end(2); // Close button hides the sidebar. - close_btn.connect_clicked(|_| { - if let Some(g) = G_SIDEBAR_PANED.lock().unwrap().as_ref() { - g.get_ref().set_position(0); - } - if let Some(g) = G_SIDEBAR_CONTAINER.lock().unwrap().as_ref() { - g.get_ref().hide(); - } - *G_SIDEBAR_VISIBLE.lock().unwrap() = false; - }); + close_btn.connect_clicked(|_| hide_sidebar()); overlay.add_overlay(&close_btn); @@ -1783,6 +2301,7 @@ fn create_sidebar_webview(container: >k::Box) { // Load the chat page. webview.load_uri(AGENT_CHAT_URL); + true } /// Whether the browser chrome (tab strip, toolbars, bookmark bars) is @@ -2108,6 +2627,20 @@ pub fn tab_manager_set_avatar(pubkey_hex: Option<&str>) { /// notebook action widgets. Port of the C `setup_notebook_action_widgets`. /// Used by the main window's notebook and each auxiliary window's notebook. pub fn setup_notebook_action_widgets(notebook: >k::Notebook) { + // Right-click / middle-click on the tab strip (tab context menu). + notebook.add_events(gdk::EventMask::BUTTON_PRESS_MASK); + notebook.connect_button_press_event(on_notebook_button_press); + + // Give the webview GTK focus whenever its tab becomes active, so + // mouse-wheel scrolling works (mirrors the C `on_notebook_switch_page`). + notebook.connect_switch_page(|_nb, page, _n| { + if let Some(wv) = find_webview(page) { + if !wv.has_focus() { + wv.grab_focus(); + } + } + }); + // New-tab button at the end of the tab strip. let new_btn = gtk::Button::new(); new_btn.set_relief(gtk::ReliefStyle::None); diff --git a/src/version.rs b/src/version.rs index 12894f0..d50843c 100644 --- a/src/version.rs +++ b/src/version.rs @@ -1,7 +1,7 @@ //! Version information for sovereign_browser /// The current version of sovereign_browser (with leading 'v'). -pub const VERSION: &str = "v0.0.9"; +pub const VERSION: &str = "v0.0.10"; /// Major version number. pub const VERSION_MAJOR: u32 = 0; @@ -10,4 +10,4 @@ pub const VERSION_MAJOR: u32 = 0; pub const VERSION_MINOR: u32 = 0; /// Patch version number. -pub const VERSION_PATCH: u32 = 9; +pub const VERSION_PATCH: u32 = 10; diff --git a/www/agents/chat.js b/www/agents/chat.js index af0b802..b5baf3e 100644 --- a/www/agents/chat.js +++ b/www/agents/chat.js @@ -377,7 +377,12 @@ function renderMessages(msgs) { * would clutter the UI. They're kept in the local session for the * agent loop's context but not displayed. */ var visibleMsgs = msgs.filter(function(m) { - return m.role === 'user' || m.role === 'assistant'; + /* Skip assistant turns that only carry tool_calls (no text) — they + * would render as empty bubbles. */ + if (m.role === 'assistant') { + return !!(m.content && String(m.content).trim()); + } + return m.role === 'user'; }); visibleMsgs.forEach(function(m, i) { html += renderMessage(m, i); }); c.innerHTML = html;