v0.0.10 - Agent sidebar working end-to-end: agent config + LLM client, chat loop with tool calls, conversations (local + NIP-44 Nostr sync), skills, sovereign://agents routes, sidebar and ';' URL-bar routing; plus tab context menu, hamburger menu, bookmark bar and refresh-button parity; share the C agent_messages schema

This commit is contained in:
Laan Tungir
2026-09-28 13:19:25 -04:00
parent 33123f8a45
commit 07d41e05da
21 changed files with 3155 additions and 771 deletions
Generated
+2 -1
View File
@@ -3055,7 +3055,7 @@ dependencies = [
[[package]] [[package]]
name = "sovereign_browser" name = "sovereign_browser"
version = "0.0.9" version = "0.0.10"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@@ -3073,6 +3073,7 @@ dependencies = [
"hex", "hex",
"hmac", "hmac",
"image", "image",
"javascriptcore-rs",
"lazy_static", "lazy_static",
"log", "log",
"nostr-core", "nostr-core",
+4 -1
View File
@@ -6,7 +6,7 @@ members = [
[package] [package]
name = "sovereign_browser" name = "sovereign_browser"
version = "0.0.9" version = "0.0.10"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
description = "A Linux x86 web browser built on WebKitGTK with Nostr identity" 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 = "0.18"
gdk-pixbuf = "0.18" gdk-pixbuf = "0.18"
webkit2gtk = { version = "2.0", features = ["v2_40"] } 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" glib = "0.18"
gio = { version = "0.18", features = ["v2_76"] } gio = { version = "0.18", features = ["v2_76"] }
cairo-rs = "0.18" cairo-rs = "0.18"
+1 -1
View File
@@ -1 +1 @@
0.0.9 0.0.10
+20 -9
View File
@@ -1,4 +1,4 @@
//! Agent chat management //! Agent chat entry points.
//! //!
//! Port of `agent_chat.c` / `agent_chat.h` from the C project. //! 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(); agent_chat_store::agent_chat_store_init();
} }
/// Handle a chat request from the web UI. /// Route URL-bar input of the form `; <message>` to the embedded agent.
pub fn agent_chat_handle_request(_request: &str) -> String { ///
// TODO: implement chat request handling /// Mirrors the C `agent_chat_route_input` (agent_chat.c:17): opens the
serde_json::json!({ /// agent sidebar (if hidden) so the chat appears next to the current
"status": "ok", /// page, then starts the agent loop with the message. The sidebar's chat
"message": "Chat handler not yet fully implemented" /// page polls `sovereign://agents/messages`, so it picks up the reply.
}) pub fn agent_chat_route_input(message: Option<&str>) -> Result<(), String> {
.to_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<String> {
agent_chat_store::agent_chat_store_session_id()
} }
+111 -79
View File
@@ -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 once_cell::sync::Lazy;
use std::sync::Mutex;
/// A single chat message. /// The current session id (None = not chosen yet).
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] static G_SESSION: Lazy<Mutex<Option<String>>> = Lazy::new(|| Mutex::new(None));
pub struct ChatMessage {
pub role: String,
pub content: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
}
/// A tool call within a message. /// Reset the store (the current session is re-resolved on next use).
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] /// Called at startup and after an identity switch (new database).
pub struct ToolCall {
pub id: String,
#[serde(rename = "type")]
pub call_type: String,
pub function: ToolCallFunction,
}
/// A tool call function.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ToolCallFunction {
pub name: String,
pub arguments: String,
}
/// The chat session state.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ChatSession {
pub messages: Vec<ChatMessage>,
pub system_prompt: String,
}
impl Default for ChatSession {
fn default() -> Self {
ChatSession {
messages: Vec::new(),
system_prompt: String::new(),
}
}
}
/// Global chat store.
static G_CHAT_STORE: Lazy<Mutex<ChatSession>> = Lazy::new(|| Mutex::new(ChatSession::default()));
/// Initialize the chat store.
pub fn agent_chat_store_init() { pub fn agent_chat_store_init() {
let mut store = G_CHAT_STORE.lock().unwrap(); *G_SESSION.lock().unwrap() = None;
*store = ChatSession::default();
} }
/// Add a message to the chat store. /// Get the current session id, loading the most recent session from the
pub fn agent_chat_store_add_message(msg: ChatMessage) { /// database or creating a new one if none exists.
let mut store = G_CHAT_STORE.lock().unwrap(); pub fn agent_chat_store_get_session() -> String {
store.messages.push(msg); 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
} }
/// Get all messages. /// The current session id without creating one.
pub fn agent_chat_store_get_messages() -> Vec<ChatMessage> { pub fn agent_chat_store_session_id() -> Option<String> {
let store = G_CHAT_STORE.lock().unwrap(); G_SESSION.lock().unwrap().clone()
store.messages.clone()
} }
/// Clear all messages. /// Start a new (empty) session and make it current. Returns its id.
pub fn agent_chat_store_clear() { pub fn agent_chat_store_new_session(title: &str) -> String {
let mut store = G_CHAT_STORE.lock().unwrap(); let id = uuid::Uuid::new_v4().to_string();
store.messages.clear(); let _ = crate::db::db_agent_session_upsert(&id, title);
*G_SESSION.lock().unwrap() = Some(id.clone());
id
} }
/// Set the system prompt. /// Make an existing (or new) session id current.
pub fn agent_chat_store_set_system_prompt(prompt: &str) { pub fn agent_chat_store_set_session(id: &str) {
let mut store = G_CHAT_STORE.lock().unwrap(); let _ = crate::db::db_agent_session_upsert(id, "");
store.system_prompt = prompt.to_string(); *G_SESSION.lock().unwrap() = Some(id.to_string());
} }
/// Get the system prompt. /// Store a message. Errors are returned (and logged) — the agent loop
pub fn agent_chat_store_get_system_prompt() -> String { /// must stop if the transcript can't be persisted, otherwise the LLM is
let store = G_CHAT_STORE.lock().unwrap(); /// called with a history that is missing the user's question / tool
store.system_prompt.clone() /// 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
})
} }
/// Get the full session. /// Add a user message to the current session.
pub fn agent_chat_store_get_session() -> ChatSession { pub fn agent_chat_store_add_user_message(content: &str) -> Result<(), String> {
let store = G_CHAT_STORE.lock().unwrap(); add(serde_json::json!({"role": "user", "content": content}))
store.clone()
} }
/// Load a session. /// Add an assistant message (with optional tool_calls array).
pub fn agent_chat_store_load_session(session: ChatSession) { pub fn agent_chat_store_add_assistant_message(
let mut store = G_CHAT_STORE.lock().unwrap(); content: &str,
*store = session; 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<serde_json::Value> {
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);
}
}
/// 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;
}
} }
+265 -126
View File
@@ -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. //! Port of `agent_conversations.c` from the C project. Conversations are
//! Uses `nostr_signer` from `rust_core_lib`. //! 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 nostr_signer::traits::NostrSigner;
use once_cell::sync::Lazy;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::sync::Mutex; use std::sync::Mutex;
use once_cell::sync::Lazy;
use crate::db;
use crate::agent_chat_store; use crate::agent_chat_store;
use crate::nostr_publish;
/// The Nostr kind for arbitrary custom app data (NIP-78). /// The Nostr kind for arbitrary custom app data (NIP-78).
const AGENT_CONV_KIND: u64 = 30078; const AGENT_CONV_KIND: u64 = 30078;
/// The t-tag that identifies conversation events. /// The t-tag that identifies conversation events.
const AGENT_CONV_T_TAG: &str = "client-ai-chat-v1"; const AGENT_CONV_T_TAG: &str = "client-ai-chat-v1";
const DEFAULT_TITLE: &str = "New Chat";
/// Global state for conversation persistence. /// Whether the one-shot Nostr → local import has run for this identity.
static G_CONV_STATE: Lazy<Mutex<ConvState>> = Lazy::new(|| Mutex::new(ConvState::default())); static G_IMPORTED: AtomicBool = AtomicBool::new(false);
/// Serializes publish threads so saves of the same conversation land in order.
static G_PUBLISH_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
struct ConvState { /// Initialize for a (new) identity. The signer is read from the app state
signer: Option<Arc<dyn NostrSigner>>, /// at use time; the arguments are kept for API compatibility.
pubkey_hex: String, pub fn agent_conversations_init(_signer: Option<Arc<dyn NostrSigner>>, _pubkey_hex: &str) {
have_signer: bool, G_IMPORTED.store(false, Ordering::SeqCst);
} agent_chat_store::agent_chat_store_init();
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<Arc<dyn NostrSigner>>, 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();
} }
/// Update the signer reference. /// Update the signer reference.
@@ -49,116 +47,257 @@ pub fn agent_conversations_set_signer(signer: Option<Arc<dyn NostrSigner>>, pubk
agent_conversations_init(signer, pubkey_hex); agent_conversations_init(signer, pubkey_hex);
} }
/// List all saved conversations from the local SQLite cache. /// Derive a title from the first user message (60 chars, single line).
fn derive_title(messages: &[serde_json::Value]) -> String {
messages
.iter()
.find(|m| m["role"] == "user")
.and_then(|m| m["content"].as_str())
.map(|c| {
let t: String = c.chars().take(60).map(|ch| if ch == '\n' || ch == '\r' { ' ' } else { ch }).collect();
t.trim().to_string()
})
.filter(|t| !t.is_empty())
.unwrap_or_else(|| DEFAULT_TITLE.to_string())
}
/// The user's cached kind 30078 conversation events, newest per d-tag.
fn cached_conversation_events() -> Vec<serde_json::Value> {
let pk = nostr_publish::my_pubkey();
if pk.is_empty() {
return Vec::new();
}
let events = crate::db::db_query_events(&[AGENT_CONV_KIND], Some(&[pk]), None, None, Some(1000))
.unwrap_or_default()
.into_iter()
.filter(|e| nostr_publish::has_tag(e, "t", AGENT_CONV_T_TAG))
.collect();
nostr_publish::latest_per_d_tag(events)
}
/// Decrypt a conversation event into (title, messages).
fn decrypt_event(event: &serde_json::Value) -> Option<(String, Vec<serde_json::Value>)> {
let content = event["content"].as_str()?;
let plaintext = nostr_publish::nip44_decrypt_self(content)
.map_err(|e| eprintln!("[agent_conv] {}", e))
.ok()?;
let payload: serde_json::Value = serde_json::from_str(&plaintext).ok()?;
let messages = payload["messages"].as_array().cloned().unwrap_or_default();
let title = payload["title"]
.as_str()
.filter(|t| !t.is_empty())
.map(String::from)
.unwrap_or_else(|| derive_title(&messages));
Some((title, messages))
}
/// Import conversations that exist on Nostr (e.g. from another device)
/// but not locally. Runs once per identity on a worker thread.
fn spawn_import_once() {
if G_IMPORTED.swap(true, Ordering::SeqCst) || !nostr_publish::have_signer() {
return;
}
std::thread::spawn(|| {
// Skip conversations that already have local messages. Sessions
// with a row but no messages (a failed earlier import) are retried.
let local: std::collections::HashSet<String> = crate::db::db_agent_sessions_list()
.unwrap_or_default()
.into_iter()
.map(|(id, _, _)| id)
.filter(|id| crate::db::db_agent_message_count(id).unwrap_or(0) > 0)
.collect();
let mut imported = 0;
for ev in cached_conversation_events() {
let Some(id) = nostr_publish::tag_value(&ev, "d").map(String::from) else { continue };
if id.is_empty() || local.contains(&id) {
continue;
}
if let Some((title, messages)) = decrypt_event(&ev) {
match crate::db::db_agent_messages_replace(&id, &messages) {
Ok(()) => {
let _ = crate::db::db_agent_session_set_title(&id, &title);
imported += 1;
}
Err(e) => eprintln!("[agent_conv] Failed to import {}: {}", id, e),
}
}
}
if imported > 0 {
println!("[agent_conv] Imported {} conversation(s) from Nostr", imported);
}
});
}
/// List conversations (newest first) as `[{id, title, updated_at}]`.
pub fn agent_conversations_list() -> Vec<serde_json::Value> { pub fn agent_conversations_list() -> Vec<serde_json::Value> {
match db::db_query_events(&[AGENT_CONV_KIND], None, None, None, None) { spawn_import_once();
Ok(events) => { crate::db::db_agent_sessions_list()
let mut result = Vec::new(); .unwrap_or_default()
for event in events { .into_iter()
let tags = event["tags"].as_array(); .map(|(id, title, updated)| {
let has_conv_tag = tags.map_or(false, |t| { serde_json::json!({
t.iter().any(|tag| { "id": id,
tag.as_array() "title": if title.is_empty() { DEFAULT_TITLE.to_string() } else { title },
.map_or(false, |a| a.len() >= 2 && a[0] == "t" && a[1] == AGENT_CONV_T_TAG) "updated_at": updated,
})
});
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(); .collect()
result.push(serde_json::json!({
"id": conv_id,
"title": event["content"].as_str().unwrap_or("Untitled"),
"updated_at": event["created_at"],
}));
}
}
result
}
Err(_) => Vec::new(),
}
} }
/// Save the current agent chat session to Nostr as a kind 30078 event. fn local_title(id: &str) -> Option<String> {
pub fn agent_conversations_save( crate::db::db_agent_sessions_list()
conversation_id: Option<&str>, .ok()?
title: Option<&str>, .into_iter()
) -> Option<String> { .find(|(sid, _, _)| sid == id)
let state = G_CONV_STATE.lock().unwrap(); .map(|(_, t, _)| t)
if !state.have_signer { .filter(|t| !t.is_empty())
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. /// Publish the conversation `id` (with `title`) to Nostr in the background.
pub fn agent_conversations_load(conversation_id: &str) -> Result<(), Box<dyn std::error::Error>> { fn spawn_publish(id: String, title: String) {
let events = db::db_query_events( if !nostr_publish::have_signer() {
&[AGENT_CONV_KIND], return;
None, }
None, // Only user/assistant turns with text are synced (tool results can be
None, // huge; tool_call-only assistant turns are meaningless without them).
Some(1), let messages: Vec<serde_json::Value> = crate::db::db_agent_messages_get(&id)
)?; .unwrap_or_default()
.into_iter()
for event in events { .filter(|m| m["role"] == "user" || m["role"] == "assistant")
let tags = event["tags"].as_array(); .filter(|m| !m["content"].as_str().unwrap_or("").trim().is_empty())
let has_id = tags.map_or(false, |t| { .map(|m| serde_json::json!({"role": m["role"], "content": m["content"]}))
t.iter().any(|tag| { .collect();
tag.as_array() std::thread::spawn(move || {
.map_or(false, |a| a.len() >= 2 && a[0] == "d" && a[1] == conversation_id) 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);
}
});
}
if has_id { /// Save the current conversation. If `conversation_id` is given the
if let Some(content_str) = event["content"].as_str() { /// current session is (re)bound to it. The title is kept when not
if let Ok(data) = serde_json::from_str::<serde_json::Value>(content_str) { /// provided (so an auto-save never overwrites a user rename). Returns the
if let Some(messages) = data["messages"].as_array() { /// conversation id.
let msgs: Vec<agent_chat_store::ChatMessage> = pub fn agent_conversations_save(conversation_id: Option<&str>, title: Option<&str>) -> Option<String> {
serde_json::from_value(serde_json::Value::Array(messages.clone()))?; let id = match conversation_id.filter(|s| !s.is_empty()) {
let session = agent_chat_store::ChatSession { Some(id) => {
messages: msgs, if agent_chat_store::agent_chat_store_session_id().as_deref() != Some(id) {
system_prompt: String::new(), agent_chat_store::agent_chat_store_set_session(id);
}
id.to_string()
}
None => agent_chat_store::agent_chat_store_get_session(),
}; };
agent_chat_store::agent_chat_store_load_session(session);
let use_title = match title.filter(|t| !t.trim().is_empty()) {
Some(t) => t.trim().to_string(),
None => local_title(&id)
.filter(|t| t != DEFAULT_TITLE)
.unwrap_or_else(|| derive_title(&agent_chat_store::agent_chat_store_get_messages())),
};
let _ = crate::db::db_agent_session_set_title(&id, &use_title);
spawn_publish(id.clone(), use_title);
Some(id)
}
/// Make a conversation current. If it has no local messages but a
/// Nostr copy exists, decrypt and import it first.
pub fn agent_conversations_load(conversation_id: &str) -> Result<(), String> {
if conversation_id.is_empty() {
return Err("Missing conversation id".to_string());
}
// Import from Nostr when there's no local copy — or an empty one (e.g.
// an earlier import that failed to write its messages).
let has_local_messages = crate::db::db_agent_message_count(conversation_id).unwrap_or(0) > 0;
if !has_local_messages {
if let Some(ev) = cached_conversation_events()
.into_iter()
.find(|e| nostr_publish::tag_value(e, "d") == Some(conversation_id))
{
let (title, messages) =
decrypt_event(&ev).ok_or_else(|| "Failed to decrypt conversation".to_string())?;
crate::db::db_agent_messages_replace(conversation_id, &messages).map_err(|e| e.to_string())?;
let _ = crate::db::db_agent_session_set_title(conversation_id, &title);
}
}
agent_chat_store::agent_chat_store_set_session(conversation_id);
Ok(())
}
/// Delete a conversation locally and (in the background) publish a
/// NIP-09 kind 5 deletion for its Nostr copy.
pub fn agent_conversations_delete(conversation_id: &str) -> Result<(), String> {
if conversation_id.is_empty() {
return Err("Missing conversation id".to_string());
}
agent_chat_store::agent_chat_store_delete_session(conversation_id);
let events: Vec<serde_json::Value> = cached_conversation_events()
.into_iter()
.filter(|e| nostr_publish::tag_value(e, "d") == Some(conversation_id))
.collect();
if events.is_empty() || !nostr_publish::have_signer() {
return Ok(()); return Ok(());
} }
let pk = nostr_publish::my_pubkey();
let d = conversation_id.to_string();
std::thread::spawn(move || {
let mut tags: Vec<Vec<String>> = events
.iter()
.filter_map(|e| e["id"].as_str())
.map(|id| vec!["e".to_string(), id.to_string()])
.collect();
tags.push(vec!["a".into(), format!("{}:{}:{}", AGENT_CONV_KIND, pk, d)]);
match nostr_publish::sign_store_publish(5, tags, "Deleted conversation") {
Ok(_) => {
for e in &events {
if let Some(id) = e["id"].as_str() {
nostr_publish::delete_local_event(id);
} }
} }
} }
Err(e) => eprintln!("[agent_conv] Failed to publish deletion: {}", e),
}
});
Ok(())
}
/// Rename a conversation (local title + re-publish).
pub fn agent_conversations_rename(conversation_id: &str, title: &str) -> Result<(), String> {
if conversation_id.is_empty() || title.trim().is_empty() {
return Err("Missing id or title".to_string());
}
crate::db::db_agent_session_set_title(conversation_id, title.trim()).map_err(|e| e.to_string())?;
spawn_publish(conversation_id.to_string(), title.trim().to_string());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn derives_titles() {
let msgs = vec![
serde_json::json!({"role": "assistant", "content": "hi"}),
serde_json::json!({"role": "user", "content": "line one\nline two"}),
];
assert_eq!(derive_title(&msgs), "line one line two");
assert_eq!(derive_title(&[]), DEFAULT_TITLE);
let long = "x".repeat(100);
assert_eq!(derive_title(&[serde_json::json!({"role":"user","content":long})]).len(), 60);
} }
Err("Conversation not found".into())
} }
+338 -49
View File
@@ -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 once_cell::sync::Lazy;
use std::sync::Mutex;
use std::time::Duration;
/// LLM provider configuration. /// Default system prompt / Sovereign Browser Skill template.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] /// 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 { pub struct LlmProvider {
#[serde(default)]
pub name: String, pub name: String,
#[serde(default)]
pub base_url: String, pub base_url: String,
#[serde(default)]
pub api_key: String, pub api_key: String,
/// Model ids last fetched from `{base_url}/models` (for the dropdown).
#[serde(default)]
pub models: Vec<String>,
}
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<LlmProvider>,
/// Index into `providers`, or -1 when none.
#[serde(default)]
pub active_provider: i32,
#[serde(default)]
pub model: String, 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. impl Default for AgentConfig {
static G_LLM_STATE: Lazy<Mutex<LlmState>> = Lazy::new(|| Mutex::new(LlmState::default()));
struct LlmState {
provider: Option<LlmProvider>,
}
impl Default for LlmState {
fn default() -> Self { fn default() -> Self {
LlmState { provider: None } 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(),
}
} }
} }
/// Initialize the LLM module. 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;
}
}
}
fn non_empty_or(v: &str, default: &str) -> String {
if v.trim().is_empty() {
default.to_string()
} else {
v.to_string()
}
}
/// In-memory cache of the config (None = not loaded yet).
static G_CONFIG: Lazy<Mutex<Option<AgentConfig>>> = Lazy::new(|| Mutex::new(None));
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::<AgentConfig>(&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::<serde_json::Value>(&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() { pub fn agent_llm_init() {
let mut state = G_LLM_STATE.lock().unwrap(); agent_config_reload();
if let Ok(Some(config)) = crate::db::db_kv_get("agent.llm_provider") { }
if let Ok(provider) = serde_json::from_str::<LlmProvider>(&config) {
state.provider = Some(provider); /// 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<R>(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)
} }
} }
/// Set the LLM provider. fn http_client(timeout: Duration) -> Result<reqwest::blocking::Client, String> {
pub fn agent_llm_set_provider(provider: LlmProvider) { reqwest::blocking::Client::builder()
let mut state = G_LLM_STATE.lock().unwrap(); .timeout(timeout)
state.provider = Some(provider.clone()); .connect_timeout(Duration::from_secs(20))
if let Ok(config) = serde_json::to_string(&provider) { .build()
let _ = crate::db::db_kv_set("agent.llm_provider", &config); .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::<serde_json::Value>(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)
} }
/// Get the current LLM provider. /// Send a chat completion request and return `choices[0].message`.
pub fn agent_llm_get_provider() -> Option<LlmProvider> { ///
let state = G_LLM_STATE.lock().unwrap(); /// `messages` must already be in OpenAI format (including the system
state.provider.clone() /// message). `tools` is omitted from the request when empty.
} pub fn agent_llm_chat(
base_url: &str,
/// Send a chat completion request to the LLM. api_key: &str,
pub async fn agent_llm_chat_completion( model: &str,
messages: &[serde_json::Value], messages: &[serde_json::Value],
tools: Option<&[serde_json::Value]>, tools: &[serde_json::Value],
) -> Result<serde_json::Value, Box<dyn std::error::Error>> { ) -> Result<serde_json::Value, String> {
let state = G_LLM_STATE.lock().unwrap(); if base_url.trim().is_empty() {
let provider = state.provider.as_ref().ok_or("No LLM provider configured")?; 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 client = reqwest::Client::new(); let url = format!("{}/chat/completions", normalize_base_url(base_url));
let mut body = serde_json::json!({ let mut body = serde_json::json!({
"model": provider.model, "model": model,
"messages": messages, "messages": messages,
}); });
if !tools.is_empty() {
if let Some(tools) = tools {
body["tools"] = serde_json::Value::Array(tools.to_vec()); body["tools"] = serde_json::Value::Array(tools.to_vec());
} }
let response = client let client = http_client(Duration::from_secs(300))?;
.post(format!("{}/v1/chat/completions", provider.base_url)) let mut req = client.post(&url).json(&body);
.header("Authorization", format!("Bearer {}", provider.api_key)) if !api_key.is_empty() {
.json(&body) req = req.bearer_auth(api_key);
.send() }
.await?; 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?; let json: serde_json::Value =
Ok(result) 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<Vec<String>, 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<String> = 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);
}
} }
+262 -90
View File
@@ -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_chat_store;
use crate::agent_llm; use crate::agent_llm;
use crate::agent_tools; use crate::agent_tools;
/// Run the agent loop for a single user message. /// Loop state, serialized as the strings the chat UI expects.
pub async fn agent_loop_run(user_message: &str) -> Result<String, Box<dyn std::error::Error>> { #[derive(Debug, Clone, Copy, PartialEq, Eq)]
// Add user message to store pub enum AgentLoopState {
agent_chat_store::agent_chat_store_add_message( Idle,
agent_chat_store::ChatMessage { Thinking,
role: "user".to_string(), ToolCall,
content: user_message.to_string(), Complete,
tool_calls: None, Error,
tool_call_id: None, Cancelled,
}, }
);
// Get conversation history impl AgentLoopState {
let session = agent_chat_store::agent_chat_store_get_session(); pub fn as_str(&self) -> &'static str {
let mut messages: Vec<serde_json::Value> = Vec::new(); match self {
AgentLoopState::Idle => "idle",
// Add system prompt AgentLoopState::Thinking => "thinking",
if !session.system_prompt.is_empty() { AgentLoopState::ToolCall => "tool_call",
messages.push(serde_json::json!({ AgentLoopState::Complete => "complete",
"role": "system", AgentLoopState::Error => "error",
"content": session.system_prompt, AgentLoopState::Cancelled => "cancelled",
})); }
} }
// Add conversation history fn is_running(&self) -> bool {
for msg in &session.messages { matches!(self, AgentLoopState::Thinking | AgentLoopState::ToolCall)
messages.push(serde_json::json!({
"role": msg.role,
"content": msg.content,
}));
} }
}
// Get available tools /// Status snapshot returned by `sovereign://agents/status`.
let tools: Vec<serde_json::Value> = agent_tools::agent_tools_get_all() #[derive(Debug, Clone)]
pub struct AgentLoopStatus {
pub state: AgentLoopState,
pub iteration: i32,
pub current_tool: Option<String>,
pub last_message: Option<String>,
pub error: Option<String>,
}
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<Mutex<AgentLoopStatus>> = 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<String>) {
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<serde_json::Value> {
let mut tools = agent_tools::agent_tools_get_all();
tools.sort_by(|a, b| a.name.cmp(&b.name));
tools
.iter() .iter()
.map(|t| { .map(|t| {
serde_json::json!({ serde_json::json!({
@@ -51,74 +147,150 @@ pub async fn agent_loop_run(user_message: &str) -> Result<String, Box<dyn std::e
} }
}) })
}) })
.collect(); .collect()
}
// Call LLM /// Start the agent loop for `user_message` on a background thread.
let response = agent_llm::agent_llm_chat_completion(&messages, Some(&tools)).await?; /// 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);
// Extract assistant message // Store the user message synchronously so it shows up immediately.
if let Some(choice) = response["choices"][0]["message"].as_object() { // If it can't be stored, don't start: the LLM would never see it.
let content = choice.get("content").and_then(|c| c.as_str()).unwrap_or(""); if let Err(e) = agent_chat_store::agent_chat_store_add_user_message(user_message) {
let tool_calls = choice.get("tool_calls"); set_error(&e);
return Err(e);
}
// Add assistant message to store std::thread::Builder::new()
let mut assistant_msg = agent_chat_store::ChatMessage { .name("agent-loop".into())
role: "assistant".to_string(), .spawn(run_loop)
content: content.to_string(), .map_err(|e| {
tool_calls: None, set_error(&format!("Failed to spawn agent thread: {}", e));
tool_call_id: None, "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<serde_json::Value> = 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!({}))
}; };
// Handle tool calls set_status(AgentLoopState::ToolCall, iter, Some(name.clone()));
if let Some(tc) = tool_calls { println!("[agent-loop] iter {}: tool {} {}", iter, name, truncate(args_str, 200));
if let Some(calls) = tc.as_array() {
let mut tool_call_list = Vec::new();
for call in calls {
let name = call["function"]["name"].as_str().unwrap_or("");
let args_str = call["function"]["arguments"].as_str().unwrap_or("{}");
let args: serde_json::Value = serde_json::from_str(args_str).unwrap_or_default();
// Execute the tool let result = match agent_tools::agent_tools_execute(&name, &args) {
match agent_tools::agent_tools_execute(name, &args) { Ok(v) => serde_json::to_string(&v).unwrap_or_default(),
Ok(result) => { Err(e) => serde_json::json!({"error": e}).to_string(),
tool_call_list.push(agent_chat_store::ToolCall { };
id: call["id"].as_str().unwrap_or("").to_string(), if let Err(e) = agent_chat_store::agent_chat_store_add_tool_result(&id, &truncate(&result, MAX_TOOL_RESULT_CHARS)) {
call_type: "function".to_string(), set_error(&e);
function: agent_chat_store::ToolCallFunction { return;
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()),
},
);
} }
} }
} if G_CANCEL.load(Ordering::SeqCst) {
assistant_msg.tool_calls = Some(tool_call_list); set_status(AgentLoopState::Cancelled, iter, None);
return;
} }
} }
agent_chat_store::agent_chat_store_add_message(assistant_msg); set_error(&format!("Reached the iteration limit ({})", max_iter));
return Ok(content.to_string());
}
Ok(String::new())
} }
+231 -139
View File
@@ -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 //! Skills are PUBLIC Nostr events (kind 31123) whose content is a system
//! templates, LLM parameters, and tool requirements. //! 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. //! 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 nostr_signer::traits::NostrSigner;
use std::sync::Arc; use std::sync::Arc;
use std::sync::Mutex;
use once_cell::sync::Lazy;
use crate::agent_llm;
use crate::db; use crate::db;
use crate::nostr_publish;
/// The Nostr kind for skills. /// The Nostr kind for skills.
const AGENT_SKILL_KIND: u64 = 31123; const AGENT_SKILL_KIND: u64 = 31123;
/// db_kv key for the selected skill d-tags (JSON array).
/// db_kv key for the selected skill d-tags.
const AGENT_SKILLS_SELECTED_KEY: &str = "agent.selected_skills"; const AGENT_SKILLS_SELECTED_KEY: &str = "agent.selected_skills";
/// Maximum number of requires_tool tags per skill. /// Maximum number of requires_tool tags per skill.
const AGENT_SKILL_MAX_TOOLS: usize = 32; const AGENT_SKILL_MAX_TOOLS: usize = 32;
/// A skill definition. /// Initialize (signer is read from the app state at use time).
#[derive(Debug, Clone)] pub fn agent_skills_init(_signer: Option<Arc<dyn NostrSigner>>, _pubkey_hex: &str) {}
pub struct AgentSkill {
pub d_tag: String,
pub name: String,
pub description: String,
pub pubkey: String,
pub content: String,
pub requires_tools: Vec<String>,
}
/// Global state for skills.
static G_SKILL_STATE: Lazy<Mutex<SkillState>> = Lazy::new(|| Mutex::new(SkillState::default()));
struct SkillState {
signer: Option<Arc<dyn NostrSigner>>,
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<Arc<dyn NostrSigner>>, 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();
}
/// Update the signer reference. /// Update the signer reference.
pub fn agent_skills_set_signer(signer: Option<Arc<dyn NostrSigner>>, pubkey_hex: &str) { pub fn agent_skills_set_signer(signer: Option<Arc<dyn NostrSigner>>, pubkey_hex: &str) {
agent_skills_init(signer, pubkey_hex); agent_skills_init(signer, pubkey_hex);
} }
/// Fetch kind 31123 skill events from the local SQLite cache. fn skill_json(event: &serde_json::Value) -> serde_json::Value {
pub fn agent_skills_fetch() -> Vec<serde_json::Value> { let tag = |n: &str| nostr_publish::tag_value(event, n).unwrap_or("").to_string();
match db::db_query_events(&[AGENT_SKILL_KIND], None, None, None, None) { let d = tag("d");
Ok(events) => { let name = tag("name");
let mut result = Vec::new(); serde_json::json!({
for event in events { "d": d,
let tags = event["tags"].as_array(); "name": if name.is_empty() { d.clone() } else { name },
let d_tag = tags.and_then(|t| { "description": tag("description"),
t.iter().find_map(|tag| { "requires_tools": nostr_publish::tag_values(event, "requires_tool"),
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<String> = 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(""), "content": event["content"].as_str().unwrap_or(""),
"pubkey": event["pubkey"].as_str().unwrap_or(""), "pubkey": event["pubkey"].as_str().unwrap_or(""),
})); "created_at": event["created_at"],
}
result
}
Err(_) => Vec::new(),
}
}
/// Get the default Sovereign Browser Skill.
pub fn agent_skills_get_default() -> serde_json::Value {
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": "",
}) })
} }
/// Build the combined system prompt from selected skills. /// All cached kind 31123 skills (latest version per author + d-tag).
pub fn agent_skills_build_system_prompt(selected_skills: &[String]) -> String { pub fn agent_skills_fetch() -> Vec<serde_json::Value> {
let skills = agent_skills_fetch(); let events = db::db_query_events(&[AGENT_SKILL_KIND], None, None, None, Some(1000)).unwrap_or_default();
let mut parts = Vec::new(); nostr_publish::latest_per_d_tag(events)
.iter()
for skill in &skills { .map(skill_json)
let d_tag = skill["d"].as_str().unwrap_or(""); .filter(|s| !s["d"].as_str().unwrap_or("").is_empty())
if selected_skills.contains(&d_tag.to_string()) { .collect()
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<String> {
} s.split(',')
.map(|t| t.trim().to_string())
parts.join("\n\n") .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<String> {
db::db_kv_get(AGENT_SKILLS_SELECTED_KEY)
.ok()
.flatten()
.and_then(|s| serde_json::from_str::<Vec<String>>(&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<String> {
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);
}
}
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<String> {
let tools = serde_json::from_str::<Vec<String>>(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<Vec<String>> {
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<Vec<String>>, 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<String, String> {
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<serde_json::Value> = 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<Vec<String>> = events
.iter()
.filter_map(|e| e["id"].as_str())
.map(|id| vec!["e".to_string(), id.to_string()])
.collect();
tags.push(vec!["a".into(), format!("{}:{}:{}", AGENT_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"]);
}
} }
+293 -115
View File
@@ -23,6 +23,37 @@ pub fn agent_tools_init() {
let mut registry = G_TOOL_REGISTRY.lock().unwrap(); let mut registry = G_TOOL_REGISTRY.lock().unwrap();
registry.clear(); 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 // Browser navigation tools
registry.insert( registry.insert(
"browser_navigate".to_string(), "browser_navigate".to_string(),
@@ -43,11 +74,11 @@ pub fn agent_tools_init() {
"browser_click".to_string(), "browser_click".to_string(),
AgentTool { AgentTool {
name: "browser_click".to_string(), 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!({ input_schema: serde_json::json!({
"type": "object", "type": "object",
"properties": { "properties": {
"selector": {"type": "string", "description": "CSS selector for the element"} "selector": {"type": "string", "description": "Snapshot ref (e.g. @e3) or CSS selector"}
}, },
"required": ["selector"] "required": ["selector"]
}), }),
@@ -58,12 +89,13 @@ pub fn agent_tools_init() {
"browser_type".to_string(), "browser_type".to_string(),
AgentTool { AgentTool {
name: "browser_type".to_string(), 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!({ input_schema: serde_json::json!({
"type": "object", "type": "object",
"properties": { "properties": {
"selector": {"type": "string", "description": "CSS selector for the input"}, "selector": {"type": "string", "description": "Snapshot ref (e.g. @e3) or CSS selector"},
"text": {"type": "string", "description": "Text to type"} "text": {"type": "string", "description": "Text to enter"},
"submit": {"type": "boolean", "description": "Press Enter / submit the form afterwards"}
}, },
"required": ["selector", "text"] "required": ["selector", "text"]
}), }),
@@ -189,7 +221,7 @@ pub fn agent_tools_init() {
"eval_js".to_string(), "eval_js".to_string(),
AgentTool { AgentTool {
name: "eval_js".to_string(), 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!({ input_schema: serde_json::json!({
"type": "object", "type": "object",
"properties": { "properties": {
@@ -205,11 +237,12 @@ pub fn agent_tools_init() {
"shell_exec".to_string(), "shell_exec".to_string(),
AgentTool { AgentTool {
name: "shell_exec".to_string(), 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!({ input_schema: serde_json::json!({
"type": "object", "type": "object",
"properties": { "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"] "required": ["command"]
}), }),
@@ -229,9 +262,93 @@ pub fn agent_tools_get(name: &str) -> Option<AgentTool> {
registry.get(name).cloned() 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<webkit2gtk::WebView>) + 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<serde_json::Value, String> {
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::<Result<String, String>>();
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. /// Execute a tool by name with the given arguments.
pub fn agent_tools_execute(name: &str, args: &serde_json::Value) -> Result<serde_json::Value, String> { pub fn agent_tools_execute(name: &str, args: &serde_json::Value) -> Result<serde_json::Value, String> {
match name { 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" => { "tab_list" => {
let tabs = crate::tab_manager::tab_manager_get_tabs(); let tabs = crate::tab_manager::tab_manager_get_tabs();
Ok(serde_json::json!({ Ok(serde_json::json!({
@@ -257,9 +374,9 @@ pub fn agent_tools_execute(name: &str, args: &serde_json::Value) -> Result<serde
// `idle_add_local` which panics off-main). Our closure only captures // `idle_add_local` which panics off-main). Our closure only captures
// `Option<String>` (Send); gtk widgets are created on the main thread // `Option<String>` (Send); gtk widgets are created on the main thread
// when the closure actually runs. // 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 || { 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(wv) = crate::tab_manager::tab_manager_get_active_webview(&nb) {
if let Some(ctx) = wv.web_context() { if let Some(ctx) = wv.web_context() {
crate::tab_manager::tab_manager_new_tab(&nb, &ctx, url_owned.as_deref()); 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<serde
} }
"browser_navigate" => { "browser_navigate" => {
let url = args["url"].as_str().ok_or("Missing url argument")?.to_string(); 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); let normalized = crate::search::normalize_url(&url);
wv.load_uri(&normalized); let (tx, rx) = std::sync::mpsc::channel::<bool>();
} 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!({ match rx.recv_timeout(std::time::Duration::from_secs(5)) {
"status": "ok", Ok(true) => {}
"message": format!("Navigating to {}", url_for_msg) 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" => { "eval_js" => {
let js = args["js"].as_str().ok_or("Missing js argument")?.to_string(); let js = args["js"].as_str().ok_or("Missing js argument")?;
// We can't easily get the return value of async JS from webkit2gtk let result = eval_js_sync(js, JS_TIMEOUT)?;
// in a synchronous MCP response. Instead, we run the JS and return Ok(serde_json::json!({"status": "ok", "result": result}))
// 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)"
}))
} }
"browser_click" => { "browser_click" => {
let selector = args["selector"].as_str().ok_or("Missing selector argument")?.to_string(); let selector = args["selector"].as_str().ok_or("Missing selector argument")?;
// Build JS to click the element matching the selector.
let js = format!( let js = format!(
r#"(function() {{ "(() => {{ const el = {find}; if (!el) return {{found: false}}; \
var el = document.querySelector({:?}); el.scrollIntoView({{block: 'center'}}); el.click(); \
if (el) {{ el.click(); return "clicked"; }} return {{found: true, tag: el.tagName, text: (el.innerText || el.value || '').slice(0, 200)}}; }})()",
return "not found"; find = element_lookup_js(selector)
}})()"#,
selector
); );
let (tx, rx) = std::sync::mpsc::channel::<String>(); let v = eval_js_sync(&js, JS_TIMEOUT)?;
glib::idle_add_once(move || { if v["found"] != true {
if let Some(nb) = crate::tab_manager::tab_manager_get_main_notebook() { return Err(format!("Element not found: {}", selector));
if let Some(wv) = crate::tab_manager::tab_manager_get_active_webview(&nb) { }
#[allow(deprecated)] Ok(serde_json::json!({"status": "ok", "clicked": v}))
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}))
} }
"browser_type" => { "browser_type" => {
let selector = args["selector"].as_str().ok_or("Missing selector argument")?.to_string(); let selector = args["selector"].as_str().ok_or("Missing selector argument")?;
let text = args["text"].as_str().ok_or("Missing text argument")?.to_string(); let text = args["text"].as_str().ok_or("Missing text argument")?;
// Build JS to set the value of the element matching the selector. let submit = args["submit"].as_bool().unwrap_or(false);
let js = format!( let js = format!(
r#"(function() {{ "(() => {{ const el = {find}; if (!el) return {{found: false}}; \
var el = document.querySelector({:?}); el.focus(); \
if (el) {{ if (el.isContentEditable) {{ el.innerText = {text}; }} else {{ \
el.value = {}; const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; \
el.dispatchEvent(new Event('input', {{bubbles: true}})); const setter = Object.getOwnPropertyDescriptor(proto, 'value'); \
el.dispatchEvent(new Event('change', {{bubbles: true}})); if (setter && setter.set) setter.set.call(el, {text}); else el.value = {text}; }} \
return "typed"; el.dispatchEvent(new Event('input', {{bubbles: true}})); \
}} el.dispatchEvent(new Event('change', {{bubbles: true}})); \
return "not found"; if ({submit}) {{ \
}})()"#, el.dispatchEvent(new KeyboardEvent('keydown', {{key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true}})); \
selector, if (el.form) {{ if (el.form.requestSubmit) el.form.requestSubmit(); else el.form.submit(); }} }} \
serde_json::to_string(&text).unwrap_or_default() 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::<String>(); let v = eval_js_sync(&js, JS_TIMEOUT)?;
glib::idle_add_once(move || { if v["found"] != true {
if let Some(nb) = crate::tab_manager::tab_manager_get_main_notebook() { return Err(format!("Element not found: {}", selector));
if let Some(wv) = crate::tab_manager::tab_manager_get_active_webview(&nb) { }
#[allow(deprecated)] Ok(serde_json::json!({"status": "ok", "typed": v}))
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}))
} }
"browser_screenshot" => { "browser_screenshot" => {
// Capture a snapshot of the active webview. // Capture a snapshot of the active webview.
@@ -393,7 +491,7 @@ pub fn agent_tools_execute(name: &str, args: &serde_json::Value) -> Result<serde
// would convert the surface to PNG via the cairo C FFI. // would convert the surface to PNG via the cairo C FFI.
let (tx, rx) = std::sync::mpsc::channel::<Result<String, String>>(); let (tx, rx) = std::sync::mpsc::channel::<Result<String, String>>();
glib::idle_add_once(move || { 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(wv) = crate::tab_manager::tab_manager_get_active_webview(&nb) {
wv.snapshot( wv.snapshot(
webkit2gtk::SnapshotRegion::Visible, webkit2gtk::SnapshotRegion::Visible,
@@ -447,24 +545,104 @@ pub fn agent_tools_execute(name: &str, args: &serde_json::Value) -> Result<serde
} }
"shell_exec" => { "shell_exec" => {
let command = args["command"].as_str().ok_or("Missing command argument")?; let command = args["command"].as_str().ok_or("Missing command argument")?;
match std::process::Command::new("sh") let timeout_s = args["timeout_seconds"].as_u64().unwrap_or(120).clamp(1, 600);
.arg("-c") run_shell(command, std::time::Duration::from_secs(timeout_s))
.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)),
}
} }
_ => Err(format!("Unknown tool: {}", name)), _ => 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<serde_json::Value, String> {
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<u8>| {
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};
})()"#;
+328 -1
View File
@@ -148,9 +148,115 @@ fn migrate_legacy_events(conn: &mut Connection) -> Result<(), Box<dyn std::error
Ok(()) Ok(())
} }
/// Open the shared `~/.sovereign_browser/global.db`. Used at startup so
/// no-login / pre-login mode still has storage (agent config, chats,
/// history). Replaced by the per-user database on login. Mirrors the C
/// `db_init_global()` (db.c:205).
pub fn db_init_global() -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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::<Result<_, _>>()?
};
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::<serde_json::Value>(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<serde_json::Value> {
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<String>, Option<String>)> = stmt
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))
.unwrap()
.collect::<Result<_, _>>()
.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. /// Initialize the database for a given profile.
pub fn db_init(pubkey_hex: &str) -> Result<(), Box<dyn std::error::Error>> { pub fn db_init(pubkey_hex: &str) -> Result<(), Box<dyn std::error::Error>> {
let path = db_path(pubkey_hex); db_init_with_path(db_path(pubkey_hex))
}
fn db_init_with_path(path: PathBuf) -> Result<(), Box<dyn std::error::Error>> {
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
} }
@@ -167,6 +273,7 @@ pub fn db_init(pubkey_hex: &str) -> Result<(), Box<dyn std::error::Error>> {
// Upgrade legacy schemas before CREATE TABLE IF NOT EXISTS runs. // Upgrade legacy schemas before CREATE TABLE IF NOT EXISTS runs.
migrate_legacy_history(&mut conn)?; migrate_legacy_history(&mut conn)?;
migrate_legacy_events(&mut conn)?; migrate_legacy_events(&mut conn)?;
migrate_single_column_agent_messages(&mut conn)?;
// Create tables // Create tables
conn.execute_batch( conn.execute_batch(
@@ -207,6 +314,27 @@ pub fn db_init(pubkey_hex: &str) -> Result<(), Box<dyn std::error::Error>> {
created_at INTEGER NOT NULL 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_pubkey ON events(pubkey);
CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind); CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind);
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at); 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<Option<String>, Box<dyn std::error::Error>
Ok(None) Ok(None)
} }
/// Delete a cached event by id.
pub fn db_delete_event(id: &str) -> Result<(), Box<dyn std::error::Error>> {
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<usize> {
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<dyn std::error::Error>> {
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<Option<String>, Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<Vec<serde_json::Value>, Box<dyn std::error::Error>> {
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<String> = row.get(0)?;
let content: Option<String> = row.get(1)?;
let tool_calls: Option<String> = row.get(2)?;
let tool_call_id: Option<String> = 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::<serde_json::Value>(&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<u64, Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<Vec<(String, String, u64)>, Box<dyn std::error::Error>> {
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<String> = row.get(1)?;
let updated: Option<i64> = 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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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. /// Delete a key-value pair.
pub fn db_kv_delete(key: &str) -> Result<(), Box<dyn std::error::Error>> { pub fn db_kv_delete(key: &str) -> Result<(), Box<dyn std::error::Error>> {
let db = DB.lock().unwrap(); let db = DB.lock().unwrap();
+1
View File
@@ -40,6 +40,7 @@ pub mod agent_login;
pub mod agent_snapshot; pub mod agent_snapshot;
pub mod agent_fs_tools; pub mod agent_fs_tools;
pub mod agent_llm; pub mod agent_llm;
pub mod nostr_publish;
pub mod db; pub mod db;
pub mod settings; pub mod settings;
pub mod tab_manager; pub mod tab_manager;
+7
View File
@@ -31,6 +31,7 @@ mod agent_login;
mod agent_snapshot; mod agent_snapshot;
mod agent_fs_tools; mod agent_fs_tools;
mod agent_llm; mod agent_llm;
mod nostr_publish;
mod db; mod db;
mod settings; mod settings;
mod tab_manager; mod tab_manager;
@@ -305,6 +306,12 @@ fn main() {
gtk::init().expect("Failed to initialize GTK"); 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(); nostr_bridge::nostr_bridge_init();
agent_tools::agent_tools_init(); agent_tools::agent_tools_init();
agent_chat::agent_chat_init(); agent_chat::agent_chat_init();
+3
View File
@@ -60,6 +60,9 @@ pub fn app_set_signer(
nostr_bridge::nostr_bridge_set_signer(state.signer.clone(), &state.pubkey_hex, state.readonly); 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::agent_login::agent_login_set_signer(state.signer.clone(), &state.pubkey_hex);
crate::settings_sync::settings_sync_init(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_conversations::agent_conversations_init(state.signer.clone(), &state.pubkey_hex);
crate::agent_skills::agent_skills_init(state.signer.clone(), &state.pubkey_hex); crate::agent_skills::agent_skills_init(state.signer.clone(), &state.pubkey_hex);
} }
+24 -8
View File
@@ -52,13 +52,23 @@ pub fn net_services_get_fips_status() -> ServiceStatus {
} }
/// Start Tor. /// 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<dyn std::error::Error>> { pub fn net_services_start_tor() -> Result<(), Box<dyn std::error::Error>> {
// 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(); let mut state = G_NET_SERVICES.lock().unwrap();
state.tor_status = ServiceStatus::Starting; if available {
// TODO: actually start Tor
state.tor_status = ServiceStatus::Running; state.tor_status = ServiceStatus::Running;
println!("[net] Tor started"); println!("[net] Tor attached (SOCKS 127.0.0.1:9050)");
Ok(()) 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. /// Stop Tor.
@@ -68,18 +78,24 @@ pub fn net_services_stop_tor() {
println!("[net] Tor stopped"); println!("[net] Tor stopped");
} }
/// Start FIPS. /// Start FIPS (attaches to the FIPS daemon's control socket).
pub fn net_services_start_fips() -> Result<(), Box<dyn std::error::Error>> { pub fn net_services_start_fips() -> Result<(), Box<dyn std::error::Error>> {
// 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(); let mut state = G_NET_SERVICES.lock().unwrap();
state.fips_status = ServiceStatus::Starting; match &result {
// TODO: actually start FIPS Ok(()) => {
state.fips_status = ServiceStatus::Running; state.fips_status = ServiceStatus::Running;
println!("[net] FIPS started"); println!("[net] FIPS attached");
Ok(()) }
Err(e) => state.fips_status = ServiceStatus::Error(e.to_string()),
}
result
} }
/// Stop FIPS. /// Stop FIPS.
pub fn net_services_stop_fips() { pub fn net_services_stop_fips() {
crate::fips_control::fips_control_stop();
let mut state = G_NET_SERVICES.lock().unwrap(); let mut state = G_NET_SERVICES.lock().unwrap();
state.fips_status = ServiceStatus::Stopped; state.fips_status = ServiceStatus::Stopped;
println!("[net] FIPS stopped"); println!("[net] FIPS stopped");
+296
View File
@@ -82,6 +82,16 @@ fn handle_sovereign_scheme(request: &URISchemeRequest) {
return; 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 ───────────────────────────────────────── // ── Bookmarks REST API ─────────────────────────────────────────
if route.starts_with("bookmarks/") { if route.starts_with("bookmarks/") {
handle_bookmarks_api(request, &route[10..]); handle_bookmarks_api(request, &route[10..]);
@@ -121,6 +131,292 @@ fn handle_sovereign_scheme(request: &URISchemeRequest) {
respond_error_json(request, 404, "Not found"); 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::<serde_json::Value>();
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::<Vec<_>>(),
"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. /// Handle sovereign://processes/* REST API.
/// Routes: list, tabs, probe-report, tab_probe, tab_action. /// Routes: list, tabs, probe-report, tab_probe, tab_action.
fn handle_processes_api(request: &URISchemeRequest, sub: &str) { fn handle_processes_api(request: &URISchemeRequest, sub: &str) {
+248
View File
@@ -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<PublicKey, String> {
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<dyn nostr_signer::traits::NostrSigner>, 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<String, String> {
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<String, String> {
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<Vec<String>>, content: &str) -> Result<Event, String> {
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<String, String> {
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<String> = 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<Vec<String>>, content: &str) -> Result<String, String> {
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<String> {
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<serde_json::Value>) -> Vec<serde_json::Value> {
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<serde_json::Value> = 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);
}
}
+31
View File
@@ -44,6 +44,12 @@ pub struct BrowserSettings {
/// break scrolling on listener-heavy pages. Mirrors the C default /// break scrolling on listener-heavy pages. Mirrors the C default
/// (settings.c:136). /// (settings.c:136).
pub perf_probe_enabled: bool, 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 { impl Default for BrowserSettings {
@@ -81,6 +87,8 @@ impl Default for BrowserSettings {
enable_write_console_messages_to_stdout: false, enable_write_console_messages_to_stdout: false,
// Perf probe OFF by default (see field docs). // Perf probe OFF by default (see field docs).
perf_probe_enabled: false, 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()) { 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; 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<bool>, fips: Option<bool>) {
{
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. /// Load settings from the database.
@@ -168,7 +196,10 @@ pub fn settings_save() {
"theme_dark": settings.theme_dark, "theme_dark": settings.theme_dark,
"perf_probe_enabled": settings.perf_probe_enabled, "perf_probe_enabled": settings.perf_probe_enabled,
"enable_write_console_messages_to_stdout": settings.enable_write_console_messages_to_stdout, "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) { if let Ok(json_str) = serde_json::to_string(&json) {
let _ = db::db_kv_set("browser_settings", &json_str); let _ = db::db_kv_set("browser_settings", &json_str);
+636 -103
View File
@@ -357,7 +357,7 @@ pub fn tab_manager_new_tab(notebook: &gtk::Notebook, ctx: &WebContext, url: Opti
Some("view-refresh-symbolic"), Some("view-refresh-symbolic"),
gtk::IconSize::Menu, 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); toolbar.pack_start(&refresh_btn, false, false, 0);
// Back button. // Back button.
@@ -433,6 +433,8 @@ pub fn tab_manager_new_tab(notebook: &gtk::Notebook, ctx: &WebContext, url: Opti
bookmark_bar.set_margin_start(4); bookmark_bar.set_margin_start(4);
bookmark_bar.set_margin_end(4); bookmark_bar.set_margin_end(4);
page.pack_start(&bookmark_bar, false, false, 0); page.pack_start(&bookmark_bar, false, false, 0);
bookmark_bar_refresh(&bookmark_bar, &webview);
track_bookmark_bar(&bookmark_bar, &webview);
// ── Load-progress bar ─────────────────────────────────────────── // ── Load-progress bar ───────────────────────────────────────────
let progress_bar = gtk::ProgressBar::new(); let progress_bar = gtk::ProgressBar::new();
@@ -553,8 +555,10 @@ pub fn tab_manager_new_tab(notebook: &gtk::Notebook, ctx: &WebContext, url: Opti
// Load progress. // Load progress.
let progress_bar_clone = progress_bar.clone(); let progress_bar_clone = progress_bar.clone();
let refresh_btn_load = refresh_btn.clone();
webview.connect_load_changed(move |wv, event| { webview.connect_load_changed(move |wv, event| {
let loading = wv.is_loading(); let loading = wv.is_loading();
refresh_button_update(&refresh_btn_load, loading);
progress_bar_clone.set_visible(loading); progress_bar_clone.set_visible(loading);
if !loading { if !loading {
progress_bar_clone.set_fraction(1.0); progress_bar_clone.set_fraction(1.0);
@@ -592,6 +596,27 @@ pub fn tab_manager_new_tab(notebook: &gtk::Notebook, ctx: &WebContext, url: Opti
None 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. // URL entry activate.
let webview_clone = webview.clone(); let webview_clone = webview.clone();
url_entry.connect_activate(move |entry| { url_entry.connect_activate(move |entry| {
@@ -599,11 +624,35 @@ pub fn tab_manager_new_tab(notebook: &gtk::Notebook, ctx: &WebContext, url: Opti
if text.is_empty() { if text.is_empty() {
return; return;
} }
let url = if search::search_is_url(&text) { // Agent chat shortcut: "; <message>" 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(
"<!doctype html><meta charset=\"utf-8\"><title>Private key blocked</title>\
<h1>Navigation blocked</h1><p>Nostr private keys cannot be opened or navigated to.</p>",
Some("nostr://blocked-private-key"),
);
return;
}
let mut url = if search::search_is_url(&text) {
search::normalize_url(&text) search::normalize_url(&text)
} else { } else {
search::search_query(&text) 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); webview_clone.load_uri(&url);
}); });
@@ -620,42 +669,47 @@ pub fn tab_manager_new_tab(notebook: &gtk::Notebook, ctx: &WebContext, url: Opti
webview_clone3.go_forward(); webview_clone3.go_forward();
} }
}); });
// Refresh/Stop: left-click stops while loading, reloads otherwise.
let webview_clone4 = webview.clone(); let webview_clone4 = webview.clone();
refresh_btn.connect_clicked(move |_| { refresh_btn.connect_clicked(move |btn| {
webview_clone4.reload(); 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(); let webview_clone5 = webview.clone();
bookmark_btn.connect_clicked(move |_| { bookmark_btn.connect_clicked(move |btn| {
if let Some(uri) = webview_clone5.uri() { show_bookmark_dialog(btn, &webview_clone5);
let title: String = webview_clone5.title().unwrap_or_else(|| "Untitled".into()).into();
let _ = bookmarks::bookmarks_add(&title, &uri, "General");
}
}); });
// Close button. // Close button.
if let Some(close_btn) = close_btn { if let Some(ref close_btn) = close_btn {
let notebook_clone = notebook.clone(); let notebook_clone = notebook.clone();
close_btn.connect_clicked(move |_| { close_btn.connect_clicked(move |_| {
tab_manager_close_tab(&notebook_clone, tab_id); tab_manager_close_tab(&notebook_clone, tab_id);
}); });
} }
// Tab label button-press (middle-click close, right-click context menu). // 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(); let notebook_clone2 = notebook.clone();
tab_label.connect_button_press_event(move |_w, event| { btn.connect_button_press_event(move |_w, event| {
let s = settings::settings_get(); handle_tab_button_press(&notebook_clone2, tab_id, event)
if event.button() == 2 && s.middle_click_close {
tab_manager_close_tab(&notebook_clone2, tab_id);
return glib::Propagation::Stop;
}
if event.button() == 3 {
show_tab_context_menu(&notebook_clone2, tab_id, event);
return glib::Propagation::Stop;
}
glib::Propagation::Proceed
}); });
}
// Update the tab's URL in state when the page navigates. // Update the tab's URL in state when the page navigates.
// Also rewrites .onion HTTP(S) URLs to tor:// for Tor SOCKS routing, // 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: &gtk::Widget) -> Option<gtk::Entry> {
if let Some(entry) = widget.downcast_ref::<gtk::Entry>() {
return Some(entry.clone());
}
let container = widget.downcast_ref::<gtk::Container>()?;
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() { pub fn tab_manager_focus_url() {
if let Some(nb) = tab_manager_get_active_notebook() { if let Some(nb) = tab_manager_get_active_notebook() {
let cur = nb.current_page().unwrap_or(0); let cur = nb.current_page().unwrap_or(0);
if let Some(page) = nb.nth_page(Some(cur)) { if let Some(entry) = nb.nth_page(Some(cur)).as_ref().and_then(find_entry) {
if let Ok(box_) = page.downcast::<gtk::Box>() {
for child in box_.children() {
if let Ok(entry) = child.downcast::<gtk::Entry>() {
entry.grab_focus(); entry.grab_focus();
entry.select_region(0, -1); entry.select_region(0, -1);
return;
}
}
}
} }
} }
} }
@@ -1238,6 +1297,70 @@ pub fn tab_manager_new_window(url: Option<&str>, ctx: &WebContext) -> Option<Web
webview webview
} }
/// Resolve the tab id owning a notebook page (via the per-tab widget map).
fn tab_id_for_page(page: &gtk::Widget) -> Option<i32> {
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: &gtk::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: &gtk::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). /// Show the tab context menu (right-click on a tab label).
fn show_tab_context_menu(notebook: &gtk::Notebook, tab_id: i32, event: &gdk::EventButton) { fn show_tab_context_menu(notebook: &gtk::Notebook, tab_id: i32, event: &gdk::EventButton) {
let menu = gtk::Menu::new(); 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); tab_manager_close_tab(&nb, tab_id);
} }
/// Sync the refresh button's icon/tooltip with the loading state.
fn refresh_button_update(btn: &gtk::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(&gtk::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: &gtk::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: &gtk::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(&gtk::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<Vec<glib::thread_guard::ThreadGuard<(glib::WeakRef<gtk::Box>, glib::WeakRef<WebView>)>>>,
> = Lazy::new(|| Mutex::new(Vec::new()));
fn track_bookmark_bar(bar: &gtk::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: &gtk::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: &gtk::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::<gtk::Window>().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!(
"<b>{}</b>\n<span size='small' color='#888'>{}</span>",
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<String> = 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. /// Build the hamburger menu button.
/// Mirrors the C version (tab_manager.c:2562) with all menu items: /// Mirrors the C version (tab_manager.c:2562) with all menu items:
/// Navigation, Recents, Bookmarks, Identity, Network, Tools, Pages, About. /// Navigation, Recents, Bookmarks, Identity, Network, Tools, Pages, About.
@@ -1436,8 +1798,8 @@ fn build_hamburger_menu() -> gtk::MenuButton {
for child in m.children() { for child in m.children() {
m.remove(&child); m.remove(&child);
} }
// Add recent history entries. // Add recent history entries (up to 20, like the C version).
if let Ok(history) = crate::db::db_get_history(10) { let history = crate::db::db_get_history(20).unwrap_or_default();
if history.is_empty() { if history.is_empty() {
let empty = gtk::MenuItem::with_label("(no recent pages)"); let empty = gtk::MenuItem::with_label("(no recent pages)");
empty.set_sensitive(false); empty.set_sensitive(false);
@@ -1447,25 +1809,16 @@ fn build_hamburger_menu() -> gtk::MenuButton {
let url = entry.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string(); 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 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 label = if !title.is_empty() { title } else { url.clone() };
let item = gtk::MenuItem::with_label(&label); let item = gtk::MenuItem::with_label(&truncate_label(&label));
item.set_tooltip_text(Some(&url)); item.set_tooltip_text(Some(&url));
let url_clone = url.clone(); item.connect_activate(move |_| navigate_active_tab(&url));
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); m.append(&item);
} }
}
m.append(&gtk::SeparatorMenuItem::new()); m.append(&gtk::SeparatorMenuItem::new());
let clear_item = gtk::MenuItem::with_label("Clear Recents"); let clear_item = gtk::MenuItem::with_label("Clear Recents");
clear_item.connect_activate(|_| { clear_item.connect_activate(|_| {
let _ = crate::db::db_clear_history(); let _ = crate::db::db_clear_history();
println!("[history] cleared");
}); });
m.append(&clear_item); m.append(&clear_item);
} }
@@ -1482,35 +1835,28 @@ fn build_hamburger_menu() -> gtk::MenuButton {
for child in m.children() { for child in m.children() {
m.remove(&child); m.remove(&child);
} }
if let Ok(bms) = crate::bookmarks::bookmarks_get_all() { let bms = crate::bookmarks::bookmarks_get_all().unwrap_or_default();
if bms.is_empty() { if bms.is_empty() {
let empty = gtk::MenuItem::with_label("(no bookmarks)"); let empty = gtk::MenuItem::with_label("(no bookmarks)");
empty.set_sensitive(false); empty.set_sensitive(false);
m.append(&empty); m.append(&empty);
} else { } else {
for bm in &bms { // 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 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 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 label = if !title.is_empty() { title } else { url.clone() };
let item = gtk::MenuItem::with_label(&label); let item = gtk::MenuItem::with_label(&truncate_label(&label));
let url_clone = url.clone(); item.set_tooltip_text(Some(&url));
item.connect_activate(move |_| { item.connect_activate(move |_| navigate_active_tab(&url));
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); m.append(&item);
} }
} }
// "Manage Bookmarks…" is always available (even with no bookmarks).
m.append(&gtk::SeparatorMenuItem::new()); m.append(&gtk::SeparatorMenuItem::new());
let manage_item = gtk::MenuItem::with_label("Manage Bookmarks…"); let manage_item = gtk::MenuItem::with_label("Manage Bookmarks…");
manage_item.connect_activate(|_| tab_manager_open_internal("sovereign://bookmarks")); manage_item.connect_activate(|_| tab_manager_open_internal("sovereign://bookmarks"));
m.append(&manage_item); m.append(&manage_item);
}
m.show_all(); m.show_all();
}); });
let item_bookmarks = gtk::MenuItem::with_label("Bookmarks"); let item_bookmarks = gtk::MenuItem::with_label("Bookmarks");
@@ -1535,14 +1881,51 @@ fn build_hamburger_menu() -> gtk::MenuButton {
menu.append(&gtk::SeparatorMenuItem::new()); menu.append(&gtk::SeparatorMenuItem::new());
// ── Network toggles ───────────────────────────────────────────── // ── 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"); 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); menu.append(&item_tor);
let item_fips = gtk::CheckMenuItem::with_label("FIPS mesh"); 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); 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)"); 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(&item_security);
menu.append(&gtk::SeparatorMenuItem::new()); menu.append(&gtk::SeparatorMenuItem::new());
@@ -1568,7 +1951,8 @@ fn build_hamburger_menu() -> gtk::MenuButton {
let label = if pk.is_empty() { let label = if pk.is_empty() {
"Nostr: not logged in".to_string() "Nostr: not logged in".to_string()
} else { } 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); item_nostr_clone.set_label(&label);
}); });
@@ -1662,6 +2046,86 @@ fn build_hamburger_menu() -> gtk::MenuButton {
button 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: &gtk::CheckMenuItem,
which: NetToggle,
syncing: &std::rc::Rc<std::cell::Cell<bool>>,
) {
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<dyn std::error::Error>> = 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. /// Open an internal sovereign:// page in a new tab.
pub fn tab_manager_open_internal(url: &str) { pub fn tab_manager_open_internal(url: &str) {
if let Some(nb) = tab_manager_get_active_notebook() { if let Some(nb) = tab_manager_get_active_notebook() {
@@ -1683,41 +2147,70 @@ pub fn tab_manager_set_sidebar(paned: &gtk::Paned, container: &gtk::Box, _ctx: &
container.hide(); container.hide();
} }
/// Toggle the agent chat sidebar. /// Whether the agent sidebar is currently shown.
/// Mirrors the C version (tab_manager.c:4294): lazily creates the sidebar pub fn tab_manager_sidebar_visible() -> bool {
/// webview on first show, sets the paned position to 280px, and hides/shows *G_SIDEBAR_VISIBLE.lock().unwrap()
/// 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,
};
let mut visible = G_SIDEBAR_VISIBLE.lock().unwrap(); /// Remembered sidebar width (the user may drag the divider).
if *visible { static G_SIDEBAR_WIDTH: Lazy<Mutex<i32>> = Lazy::new(|| Mutex::new(SIDEBAR_DEFAULT_WIDTH));
// Hide.
paned.set_position(0); fn sidebar_widgets() -> Option<(gtk::Paned, gtk::Box)> {
container.hide(); let paned = G_SIDEBAR_PANED.lock().unwrap().as_ref()?.get_ref().clone();
*visible = false; let container = G_SIDEBAR_CONTAINER.lock().unwrap().as_ref()?.get_ref().clone();
} else { Some((paned, container))
// Show. Create the sidebar webview lazily on first show. }
let mut created = G_SIDEBAR_WEBVIEW_CREATED.lock().unwrap();
if !*created { fn hide_sidebar() {
create_sidebar_webview(&container); let Some((paned, container)) = sidebar_widgets() else { return };
*created = true; let pos = paned.position();
if pos > 50 {
*G_SIDEBAR_WIDTH.lock().unwrap() = pos;
} }
paned.set_position(SIDEBAR_DEFAULT_WIDTH); container.hide();
container.show_all(); paned.set_position(0);
*visible = true; *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. /// Toggle the agent chat sidebar.
fn create_sidebar_webview(container: &gtk::Box) { /// 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<Mutex<Option<glib::thread_guard::ThreadGuard<WebView>>>> =
Lazy::new(|| Mutex::new(None));
/// Create the sidebar webview loading sovereign://agents/chat. Returns
/// whether it was created.
fn create_sidebar_webview(container: &gtk::Box) -> bool {
// Get the web context from the active webview. // Get the web context from the active webview.
let ctx = match tab_manager_get_active_notebook() let ctx = match tab_manager_get_active_notebook()
.and_then(|nb| tab_manager_get_active_webview(&nb)) .and_then(|nb| tab_manager_get_active_webview(&nb))
@@ -1726,7 +2219,7 @@ fn create_sidebar_webview(container: &gtk::Box) {
Some(c) => c, Some(c) => c,
None => { None => {
println!("[sidebar] No web context available"); println!("[sidebar] No web context available");
return; return false;
} }
}; };
@@ -1738,12 +2231,45 @@ fn create_sidebar_webview(container: &gtk::Box) {
// Apply settings so JS and sovereign:// bridge work. // Apply settings so JS and sovereign:// bridge work.
if let Some(ws) = webkit2gtk::WebViewExt::settings(&webview) { if let Some(ws) = webkit2gtk::WebViewExt::settings(&webview) {
crate::web_context::web_context_configure_settings(&ws);
ws.set_enable_javascript(true); ws.set_enable_javascript(true);
ws.set_enable_developer_extras(true); ws.set_enable_developer_extras(true);
ws.set_allow_file_access_from_file_urls(true); ws.set_allow_file_access_from_file_urls(true);
ws.set_allow_universal_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::<webkit2gtk::NavigationPolicyDecision>() 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_vexpand(true);
webview.set_hexpand(true); webview.set_hexpand(true);
@@ -1767,15 +2293,7 @@ fn create_sidebar_webview(container: &gtk::Box) {
close_btn.set_margin_end(2); close_btn.set_margin_end(2);
// Close button hides the sidebar. // Close button hides the sidebar.
close_btn.connect_clicked(|_| { close_btn.connect_clicked(|_| hide_sidebar());
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;
});
overlay.add_overlay(&close_btn); overlay.add_overlay(&close_btn);
@@ -1783,6 +2301,7 @@ fn create_sidebar_webview(container: &gtk::Box) {
// Load the chat page. // Load the chat page.
webview.load_uri(AGENT_CHAT_URL); webview.load_uri(AGENT_CHAT_URL);
true
} }
/// Whether the browser chrome (tab strip, toolbars, bookmark bars) is /// 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`. /// notebook action widgets. Port of the C `setup_notebook_action_widgets`.
/// Used by the main window's notebook and each auxiliary window's notebook. /// Used by the main window's notebook and each auxiliary window's notebook.
pub fn setup_notebook_action_widgets(notebook: &gtk::Notebook) { pub fn setup_notebook_action_widgets(notebook: &gtk::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. // New-tab button at the end of the tab strip.
let new_btn = gtk::Button::new(); let new_btn = gtk::Button::new();
new_btn.set_relief(gtk::ReliefStyle::None); new_btn.set_relief(gtk::ReliefStyle::None);
+2 -2
View File
@@ -1,7 +1,7 @@
//! Version information for sovereign_browser //! Version information for sovereign_browser
/// The current version of sovereign_browser (with leading 'v'). /// 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. /// Major version number.
pub const VERSION_MAJOR: u32 = 0; pub const VERSION_MAJOR: u32 = 0;
@@ -10,4 +10,4 @@ pub const VERSION_MAJOR: u32 = 0;
pub const VERSION_MINOR: u32 = 0; pub const VERSION_MINOR: u32 = 0;
/// Patch version number. /// Patch version number.
pub const VERSION_PATCH: u32 = 9; pub const VERSION_PATCH: u32 = 10;
+6 -1
View File
@@ -377,7 +377,12 @@ function renderMessages(msgs) {
* would clutter the UI. They're kept in the local session for the * would clutter the UI. They're kept in the local session for the
* agent loop's context but not displayed. */ * agent loop's context but not displayed. */
var visibleMsgs = msgs.filter(function(m) { 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); }); visibleMsgs.forEach(function(m, i) { html += renderMessage(m, i); });
c.innerHTML = html; c.innerHTML = html;