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:
Generated
+2
-1
@@ -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
@@ -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"
|
||||||
|
|||||||
+20
-9
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
+115
-83
@@ -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,
|
/// Reset the store (the current session is re-resolved on next use).
|
||||||
pub content: String,
|
/// Called at startup and after an identity switch (new database).
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
pub fn agent_chat_store_init() {
|
||||||
pub tool_calls: Option<Vec<ToolCall>>,
|
*G_SESSION.lock().unwrap() = None;
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub tool_call_id: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A tool call within a message.
|
/// Get the current session id, loading the most recent session from the
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
/// database or creating a new one if none exists.
|
||||||
pub struct ToolCall {
|
pub fn agent_chat_store_get_session() -> String {
|
||||||
pub id: String,
|
let mut guard = G_SESSION.lock().unwrap();
|
||||||
#[serde(rename = "type")]
|
if let Some(id) = guard.as_ref() {
|
||||||
pub call_type: String,
|
return id.clone();
|
||||||
pub function: ToolCallFunction,
|
}
|
||||||
|
let id = crate::db::db_agent_session_latest()
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let _ = crate::db::db_agent_session_upsert(&id, "New Chat");
|
||||||
|
id
|
||||||
|
});
|
||||||
|
*guard = Some(id.clone());
|
||||||
|
id
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A tool call function.
|
/// The current session id without creating one.
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
pub fn agent_chat_store_session_id() -> Option<String> {
|
||||||
pub struct ToolCallFunction {
|
G_SESSION.lock().unwrap().clone()
|
||||||
pub name: String,
|
|
||||||
pub arguments: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The chat session state.
|
/// Start a new (empty) session and make it current. Returns its id.
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
pub fn agent_chat_store_new_session(title: &str) -> String {
|
||||||
pub struct ChatSession {
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
pub messages: Vec<ChatMessage>,
|
let _ = crate::db::db_agent_session_upsert(&id, title);
|
||||||
pub system_prompt: String,
|
*G_SESSION.lock().unwrap() = Some(id.clone());
|
||||||
|
id
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ChatSession {
|
/// Make an existing (or new) session id current.
|
||||||
fn default() -> Self {
|
pub fn agent_chat_store_set_session(id: &str) {
|
||||||
ChatSession {
|
let _ = crate::db::db_agent_session_upsert(id, "");
|
||||||
messages: Vec::new(),
|
*G_SESSION.lock().unwrap() = Some(id.to_string());
|
||||||
system_prompt: String::new(),
|
}
|
||||||
|
|
||||||
|
/// Store a message. Errors are returned (and logged) — the agent loop
|
||||||
|
/// must stop if the transcript can't be persisted, otherwise the LLM is
|
||||||
|
/// called with a history that is missing the user's question / tool
|
||||||
|
/// results and loops blindly.
|
||||||
|
fn add(msg: serde_json::Value) -> Result<(), String> {
|
||||||
|
let sid = agent_chat_store_get_session();
|
||||||
|
crate::db::db_agent_message_add(&sid, &msg).map_err(|e| {
|
||||||
|
let e = format!("Failed to store chat message: {}", e);
|
||||||
|
eprintln!("[agent_chat_store] {}", e);
|
||||||
|
e
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a user message to the current session.
|
||||||
|
pub fn agent_chat_store_add_user_message(content: &str) -> Result<(), String> {
|
||||||
|
add(serde_json::json!({"role": "user", "content": content}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add an assistant message (with optional tool_calls array).
|
||||||
|
pub fn agent_chat_store_add_assistant_message(
|
||||||
|
content: &str,
|
||||||
|
tool_calls: Option<&serde_json::Value>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut msg = serde_json::json!({"role": "assistant", "content": content});
|
||||||
|
if let Some(tc) = tool_calls {
|
||||||
|
if tc.as_array().map(|a| !a.is_empty()).unwrap_or(false) {
|
||||||
|
msg["tool_calls"] = tc.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
add(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a tool result message.
|
||||||
|
pub fn agent_chat_store_add_tool_result(tool_call_id: &str, content: &str) -> Result<(), String> {
|
||||||
|
add(serde_json::json!({
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": tool_call_id,
|
||||||
|
"content": content,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All messages of the current session, in OpenAI format.
|
||||||
|
pub fn agent_chat_store_get_messages() -> Vec<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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Global chat store.
|
/// Delete a session locally; clears the current session if it matches.
|
||||||
static G_CHAT_STORE: Lazy<Mutex<ChatSession>> = Lazy::new(|| Mutex::new(ChatSession::default()));
|
pub fn agent_chat_store_delete_session(id: &str) {
|
||||||
|
let _ = crate::db::db_agent_session_delete(id);
|
||||||
/// Initialize the chat store.
|
let mut guard = G_SESSION.lock().unwrap();
|
||||||
pub fn agent_chat_store_init() {
|
if guard.as_deref() == Some(id) {
|
||||||
let mut store = G_CHAT_STORE.lock().unwrap();
|
*guard = None;
|
||||||
*store = ChatSession::default();
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Add a message to the chat store.
|
|
||||||
pub fn agent_chat_store_add_message(msg: ChatMessage) {
|
|
||||||
let mut store = G_CHAT_STORE.lock().unwrap();
|
|
||||||
store.messages.push(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get all messages.
|
|
||||||
pub fn agent_chat_store_get_messages() -> Vec<ChatMessage> {
|
|
||||||
let store = G_CHAT_STORE.lock().unwrap();
|
|
||||||
store.messages.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear all messages.
|
|
||||||
pub fn agent_chat_store_clear() {
|
|
||||||
let mut store = G_CHAT_STORE.lock().unwrap();
|
|
||||||
store.messages.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the system prompt.
|
|
||||||
pub fn agent_chat_store_set_system_prompt(prompt: &str) {
|
|
||||||
let mut store = G_CHAT_STORE.lock().unwrap();
|
|
||||||
store.system_prompt = prompt.to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the system prompt.
|
|
||||||
pub fn agent_chat_store_get_system_prompt() -> String {
|
|
||||||
let store = G_CHAT_STORE.lock().unwrap();
|
|
||||||
store.system_prompt.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the full session.
|
|
||||||
pub fn agent_chat_store_get_session() -> ChatSession {
|
|
||||||
let store = G_CHAT_STORE.lock().unwrap();
|
|
||||||
store.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load a session.
|
|
||||||
pub fn agent_chat_store_load_session(session: ChatSession) {
|
|
||||||
let mut store = G_CHAT_STORE.lock().unwrap();
|
|
||||||
*store = session;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+268
-129
@@ -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).
|
||||||
pub fn agent_conversations_list() -> Vec<serde_json::Value> {
|
fn derive_title(messages: &[serde_json::Value]) -> String {
|
||||||
match db::db_query_events(&[AGENT_CONV_KIND], None, None, None, None) {
|
messages
|
||||||
Ok(events) => {
|
.iter()
|
||||||
let mut result = Vec::new();
|
.find(|m| m["role"] == "user")
|
||||||
for event in events {
|
.and_then(|m| m["content"].as_str())
|
||||||
let tags = event["tags"].as_array();
|
.map(|c| {
|
||||||
let has_conv_tag = tags.map_or(false, |t| {
|
let t: String = c.chars().take(60).map(|ch| if ch == '\n' || ch == '\r' { ' ' } else { ch }).collect();
|
||||||
t.iter().any(|tag| {
|
t.trim().to_string()
|
||||||
tag.as_array()
|
})
|
||||||
.map_or(false, |a| a.len() >= 2 && a[0] == "t" && a[1] == AGENT_CONV_T_TAG)
|
.filter(|t| !t.is_empty())
|
||||||
})
|
.unwrap_or_else(|| DEFAULT_TITLE.to_string())
|
||||||
});
|
}
|
||||||
|
|
||||||
if has_conv_tag {
|
/// The user's cached kind 30078 conversation events, newest per d-tag.
|
||||||
let conv_id = tags.and_then(|t| {
|
fn cached_conversation_events() -> Vec<serde_json::Value> {
|
||||||
t.iter().find_map(|tag| {
|
let pk = nostr_publish::my_pubkey();
|
||||||
tag.as_array().and_then(|a| {
|
if pk.is_empty() {
|
||||||
if a.len() >= 2 && a[0] == "d" {
|
return Vec::new();
|
||||||
Some(a[1].as_str().unwrap_or("").to_string())
|
}
|
||||||
} else {
|
let events = crate::db::db_query_events(&[AGENT_CONV_KIND], Some(&[pk]), None, None, Some(1000))
|
||||||
None
|
.unwrap_or_default()
|
||||||
}
|
.into_iter()
|
||||||
})
|
.filter(|e| nostr_publish::has_tag(e, "t", AGENT_CONV_T_TAG))
|
||||||
})
|
.collect();
|
||||||
}).unwrap_or_default();
|
nostr_publish::latest_per_d_tag(events)
|
||||||
|
}
|
||||||
|
|
||||||
result.push(serde_json::json!({
|
/// Decrypt a conversation event into (title, messages).
|
||||||
"id": conv_id,
|
fn decrypt_event(event: &serde_json::Value) -> Option<(String, Vec<serde_json::Value>)> {
|
||||||
"title": event["content"].as_str().unwrap_or("Untitled"),
|
let content = event["content"].as_str()?;
|
||||||
"updated_at": event["created_at"],
|
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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result
|
|
||||||
}
|
}
|
||||||
Err(_) => Vec::new(),
|
if imported > 0 {
|
||||||
}
|
println!("[agent_conv] Imported {} conversation(s) from Nostr", imported);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save the current agent chat session to Nostr as a kind 30078 event.
|
/// List conversations (newest first) as `[{id, title, updated_at}]`.
|
||||||
pub fn agent_conversations_save(
|
pub fn agent_conversations_list() -> Vec<serde_json::Value> {
|
||||||
conversation_id: Option<&str>,
|
spawn_import_once();
|
||||||
title: Option<&str>,
|
crate::db::db_agent_sessions_list()
|
||||||
) -> Option<String> {
|
.unwrap_or_default()
|
||||||
let state = G_CONV_STATE.lock().unwrap();
|
.into_iter()
|
||||||
if !state.have_signer {
|
.map(|(id, title, updated)| {
|
||||||
return None;
|
serde_json::json!({
|
||||||
}
|
"id": id,
|
||||||
|
"title": if title.is_empty() { DEFAULT_TITLE.to_string() } else { title },
|
||||||
let conv_id = conversation_id
|
"updated_at": updated,
|
||||||
.map(|s| s.to_string())
|
|
||||||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
|
||||||
|
|
||||||
let session = agent_chat_store::agent_chat_store_get_session();
|
|
||||||
let _title = title
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
session.messages.first()
|
|
||||||
.map(|m| {
|
|
||||||
let truncated: String = m.content.chars().take(60).collect();
|
|
||||||
truncated
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| "Untitled".to_string())
|
|
||||||
});
|
|
||||||
|
|
||||||
println!(
|
|
||||||
"[conversations] Save requested: id={} (NIP-44 encryption not yet implemented)",
|
|
||||||
conv_id
|
|
||||||
);
|
|
||||||
|
|
||||||
Some(conv_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load a conversation from the local SQLite cache.
|
|
||||||
pub fn agent_conversations_load(conversation_id: &str) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let events = db::db_query_events(
|
|
||||||
&[AGENT_CONV_KIND],
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
Some(1),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
for event in events {
|
|
||||||
let tags = event["tags"].as_array();
|
|
||||||
let has_id = tags.map_or(false, |t| {
|
|
||||||
t.iter().any(|tag| {
|
|
||||||
tag.as_array()
|
|
||||||
.map_or(false, |a| a.len() >= 2 && a[0] == "d" && a[1] == conversation_id)
|
|
||||||
})
|
})
|
||||||
});
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
if has_id {
|
fn local_title(id: &str) -> Option<String> {
|
||||||
if let Some(content_str) = event["content"].as_str() {
|
crate::db::db_agent_sessions_list()
|
||||||
if let Ok(data) = serde_json::from_str::<serde_json::Value>(content_str) {
|
.ok()?
|
||||||
if let Some(messages) = data["messages"].as_array() {
|
.into_iter()
|
||||||
let msgs: Vec<agent_chat_store::ChatMessage> =
|
.find(|(sid, _, _)| sid == id)
|
||||||
serde_json::from_value(serde_json::Value::Array(messages.clone()))?;
|
.map(|(_, t, _)| t)
|
||||||
let session = agent_chat_store::ChatSession {
|
.filter(|t| !t.is_empty())
|
||||||
messages: msgs,
|
}
|
||||||
system_prompt: String::new(),
|
|
||||||
};
|
/// Publish the conversation `id` (with `title`) to Nostr in the background.
|
||||||
agent_chat_store::agent_chat_store_load_session(session);
|
fn spawn_publish(id: String, title: String) {
|
||||||
return Ok(());
|
if !nostr_publish::have_signer() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only user/assistant turns with text are synced (tool results can be
|
||||||
|
// huge; tool_call-only assistant turns are meaningless without them).
|
||||||
|
let messages: Vec<serde_json::Value> = crate::db::db_agent_messages_get(&id)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|m| m["role"] == "user" || m["role"] == "assistant")
|
||||||
|
.filter(|m| !m["content"].as_str().unwrap_or("").trim().is_empty())
|
||||||
|
.map(|m| serde_json::json!({"role": m["role"], "content": m["content"]}))
|
||||||
|
.collect();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _guard = G_PUBLISH_LOCK.lock().unwrap();
|
||||||
|
let payload = serde_json::json!({"schema": 1, "title": title, "messages": messages});
|
||||||
|
let result = nostr_publish::nip44_encrypt_self(&payload.to_string()).and_then(|ct| {
|
||||||
|
nostr_publish::sign_store_publish(
|
||||||
|
AGENT_CONV_KIND,
|
||||||
|
vec![
|
||||||
|
vec!["d".into(), id.clone()],
|
||||||
|
vec!["t".into(), AGENT_CONV_T_TAG.into()],
|
||||||
|
vec!["client".into(), "sovereign_browser".into()],
|
||||||
|
],
|
||||||
|
&ct,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
if let Err(e) = result {
|
||||||
|
eprintln!("[agent_conv] Failed to sync conversation {} to Nostr: {}", id, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save the current conversation. If `conversation_id` is given the
|
||||||
|
/// current session is (re)bound to it. The title is kept when not
|
||||||
|
/// provided (so an auto-save never overwrites a user rename). Returns the
|
||||||
|
/// conversation id.
|
||||||
|
pub fn agent_conversations_save(conversation_id: Option<&str>, title: Option<&str>) -> Option<String> {
|
||||||
|
let id = match conversation_id.filter(|s| !s.is_empty()) {
|
||||||
|
Some(id) => {
|
||||||
|
if agent_chat_store::agent_chat_store_session_id().as_deref() != Some(id) {
|
||||||
|
agent_chat_store::agent_chat_store_set_session(id);
|
||||||
|
}
|
||||||
|
id.to_string()
|
||||||
|
}
|
||||||
|
None => agent_chat_store::agent_chat_store_get_session(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let use_title = match title.filter(|t| !t.trim().is_empty()) {
|
||||||
|
Some(t) => t.trim().to_string(),
|
||||||
|
None => local_title(&id)
|
||||||
|
.filter(|t| t != DEFAULT_TITLE)
|
||||||
|
.unwrap_or_else(|| derive_title(&agent_chat_store::agent_chat_store_get_messages())),
|
||||||
|
};
|
||||||
|
let _ = crate::db::db_agent_session_set_title(&id, &use_title);
|
||||||
|
spawn_publish(id.clone(), use_title);
|
||||||
|
Some(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make a conversation current. If it has no local messages but a
|
||||||
|
/// Nostr copy exists, decrypt and import it first.
|
||||||
|
pub fn agent_conversations_load(conversation_id: &str) -> Result<(), String> {
|
||||||
|
if conversation_id.is_empty() {
|
||||||
|
return Err("Missing conversation id".to_string());
|
||||||
|
}
|
||||||
|
// Import from Nostr when there's no local copy — or an empty one (e.g.
|
||||||
|
// an earlier import that failed to write its messages).
|
||||||
|
let has_local_messages = crate::db::db_agent_message_count(conversation_id).unwrap_or(0) > 0;
|
||||||
|
if !has_local_messages {
|
||||||
|
if let Some(ev) = cached_conversation_events()
|
||||||
|
.into_iter()
|
||||||
|
.find(|e| nostr_publish::tag_value(e, "d") == Some(conversation_id))
|
||||||
|
{
|
||||||
|
let (title, messages) =
|
||||||
|
decrypt_event(&ev).ok_or_else(|| "Failed to decrypt conversation".to_string())?;
|
||||||
|
crate::db::db_agent_messages_replace(conversation_id, &messages).map_err(|e| e.to_string())?;
|
||||||
|
let _ = crate::db::db_agent_session_set_title(conversation_id, &title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
agent_chat_store::agent_chat_store_set_session(conversation_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a conversation locally and (in the background) publish a
|
||||||
|
/// NIP-09 kind 5 deletion for its Nostr copy.
|
||||||
|
pub fn agent_conversations_delete(conversation_id: &str) -> Result<(), String> {
|
||||||
|
if conversation_id.is_empty() {
|
||||||
|
return Err("Missing conversation id".to_string());
|
||||||
|
}
|
||||||
|
agent_chat_store::agent_chat_store_delete_session(conversation_id);
|
||||||
|
|
||||||
|
let events: Vec<serde_json::Value> = cached_conversation_events()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|e| nostr_publish::tag_value(e, "d") == Some(conversation_id))
|
||||||
|
.collect();
|
||||||
|
if events.is_empty() || !nostr_publish::have_signer() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let pk = nostr_publish::my_pubkey();
|
||||||
|
let d = conversation_id.to_string();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut tags: Vec<Vec<String>> = events
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| e["id"].as_str())
|
||||||
|
.map(|id| vec!["e".to_string(), id.to_string()])
|
||||||
|
.collect();
|
||||||
|
tags.push(vec!["a".into(), format!("{}:{}:{}", AGENT_CONV_KIND, pk, d)]);
|
||||||
|
match nostr_publish::sign_store_publish(5, tags, "Deleted conversation") {
|
||||||
|
Ok(_) => {
|
||||||
|
for e in &events {
|
||||||
|
if let Some(id) = e["id"].as_str() {
|
||||||
|
nostr_publish::delete_local_event(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Err(e) => eprintln!("[agent_conv] Failed to publish deletion: {}", e),
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
Ok(())
|
||||||
Err("Conversation not found".into())
|
}
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+341
-52
@@ -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()
|
||||||
/// Initialize the LLM module.
|
}],
|
||||||
pub fn agent_llm_init() {
|
active_provider: 0,
|
||||||
let mut state = G_LLM_STATE.lock().unwrap();
|
model: String::new(),
|
||||||
if let Ok(Some(config)) = crate::db::db_kv_get("agent.llm_provider") {
|
max_iterations: AGENT_MAX_ITERATIONS_DEFAULT,
|
||||||
if let Ok(provider) = serde_json::from_str::<LlmProvider>(&config) {
|
skill_name: String::new(),
|
||||||
state.provider = Some(provider);
|
skill_description: String::new(),
|
||||||
|
skill_template: String::new(),
|
||||||
|
skill_requires_tools: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the LLM provider.
|
impl AgentConfig {
|
||||||
pub fn agent_llm_set_provider(provider: LlmProvider) {
|
/// The active provider, if any.
|
||||||
let mut state = G_LLM_STATE.lock().unwrap();
|
pub fn active(&self) -> Option<&LlmProvider> {
|
||||||
state.provider = Some(provider.clone());
|
usize::try_from(self.active_provider)
|
||||||
if let Ok(config) = serde_json::to_string(&provider) {
|
.ok()
|
||||||
let _ = crate::db::db_kv_set("agent.llm_provider", &config);
|
.and_then(|i| self.providers.get(i))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn active_mut(&mut self) -> Option<&mut LlmProvider> {
|
||||||
|
usize::try_from(self.active_provider)
|
||||||
|
.ok()
|
||||||
|
.and_then(move |i| self.providers.get_mut(i))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn base_url(&self) -> String {
|
||||||
|
self.active().map(|p| p.base_url.clone()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn api_key(&self) -> String {
|
||||||
|
self.active().map(|p| p.api_key.clone()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Effective max iterations (falls back to the default when unset).
|
||||||
|
pub fn effective_max_iterations(&self) -> i32 {
|
||||||
|
if self.max_iterations > 0 {
|
||||||
|
self.max_iterations
|
||||||
|
} else {
|
||||||
|
AGENT_MAX_ITERATIONS_DEFAULT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn effective_skill_name(&self) -> String {
|
||||||
|
non_empty_or(&self.skill_name, AGENT_SKILL_NAME_DEFAULT)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn effective_skill_description(&self) -> String {
|
||||||
|
non_empty_or(&self.skill_description, AGENT_SKILL_DESCRIPTION_DEFAULT)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn effective_skill_template(&self) -> String {
|
||||||
|
non_empty_or(&self.skill_template, AGENT_SYSTEM_PROMPT_DEFAULT)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn effective_skill_requires_tools(&self) -> String {
|
||||||
|
non_empty_or(&self.skill_requires_tools, AGENT_SKILL_REQUIRES_TOOLS_DEFAULT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep `active_provider` pointing at a valid entry.
|
||||||
|
fn normalize(&mut self) {
|
||||||
|
if self.providers.is_empty() {
|
||||||
|
self.active_provider = -1;
|
||||||
|
} else if self.active_provider < 0 || self.active_provider as usize >= self.providers.len() {
|
||||||
|
self.active_provider = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the current LLM provider.
|
fn non_empty_or(v: &str, default: &str) -> String {
|
||||||
pub fn agent_llm_get_provider() -> Option<LlmProvider> {
|
if v.trim().is_empty() {
|
||||||
let state = G_LLM_STATE.lock().unwrap();
|
default.to_string()
|
||||||
state.provider.clone()
|
} else {
|
||||||
|
v.to_string()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a chat completion request to the LLM.
|
/// In-memory cache of the config (None = not loaded yet).
|
||||||
pub async fn agent_llm_chat_completion(
|
static G_CONFIG: Lazy<Mutex<Option<AgentConfig>>> = Lazy::new(|| Mutex::new(None));
|
||||||
messages: &[serde_json::Value],
|
|
||||||
tools: Option<&[serde_json::Value]>,
|
|
||||||
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
|
|
||||||
let state = G_LLM_STATE.lock().unwrap();
|
|
||||||
let provider = state.provider.as_ref().ok_or("No LLM provider configured")?;
|
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
fn load_from_db() -> AgentConfig {
|
||||||
|
if let Ok(Some(json)) = crate::db::db_kv_get(CONFIG_KEY) {
|
||||||
|
if let Ok(mut cfg) = serde_json::from_str::<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() {
|
||||||
|
agent_config_reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop the cached config so it is re-read from the (possibly switched)
|
||||||
|
/// per-profile database on next access. Called after login.
|
||||||
|
pub fn agent_config_reload() {
|
||||||
|
*G_CONFIG.lock().unwrap() = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a snapshot of the current agent config.
|
||||||
|
pub fn agent_config_get() -> AgentConfig {
|
||||||
|
let mut guard = G_CONFIG.lock().unwrap();
|
||||||
|
if guard.is_none() {
|
||||||
|
*guard = Some(load_from_db());
|
||||||
|
}
|
||||||
|
guard.as_ref().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mutate the config and persist it. The closure's result is returned.
|
||||||
|
pub fn agent_config_update<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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn http_client(timeout: Duration) -> Result<reqwest::blocking::Client, String> {
|
||||||
|
reqwest::blocking::Client::builder()
|
||||||
|
.timeout(timeout)
|
||||||
|
.connect_timeout(Duration::from_secs(20))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("HTTP client error: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract a readable error from an API error body.
|
||||||
|
fn api_error_message(status: reqwest::StatusCode, body: &str) -> String {
|
||||||
|
if let Ok(v) = serde_json::from_str::<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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a chat completion request and return `choices[0].message`.
|
||||||
|
///
|
||||||
|
/// `messages` must already be in OpenAI format (including the system
|
||||||
|
/// message). `tools` is omitted from the request when empty.
|
||||||
|
pub fn agent_llm_chat(
|
||||||
|
base_url: &str,
|
||||||
|
api_key: &str,
|
||||||
|
model: &str,
|
||||||
|
messages: &[serde_json::Value],
|
||||||
|
tools: &[serde_json::Value],
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
if base_url.trim().is_empty() {
|
||||||
|
return Err("No LLM base URL configured — open Agent Setup… from the menu".to_string());
|
||||||
|
}
|
||||||
|
if model.trim().is_empty() {
|
||||||
|
return Err("No model selected — open Agent Setup… from the menu".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = format!("{}/chat/completions", normalize_base_url(base_url));
|
||||||
let mut body = serde_json::json!({
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+268
-96
@@ -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> {
|
||||||
// Extract assistant message
|
{
|
||||||
if let Some(choice) = response["choices"][0]["message"].as_object() {
|
let mut s = G_STATUS.lock().unwrap();
|
||||||
let content = choice.get("content").and_then(|c| c.as_str()).unwrap_or("");
|
if s.state.is_running() {
|
||||||
let tool_calls = choice.get("tool_calls");
|
return Err("Agent is already running".to_string());
|
||||||
|
|
||||||
// Add assistant message to store
|
|
||||||
let mut assistant_msg = agent_chat_store::ChatMessage {
|
|
||||||
role: "assistant".to_string(),
|
|
||||||
content: content.to_string(),
|
|
||||||
tool_calls: None,
|
|
||||||
tool_call_id: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle tool calls
|
|
||||||
if let Some(tc) = tool_calls {
|
|
||||||
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
|
|
||||||
match agent_tools::agent_tools_execute(name, &args) {
|
|
||||||
Ok(result) => {
|
|
||||||
tool_call_list.push(agent_chat_store::ToolCall {
|
|
||||||
id: call["id"].as_str().unwrap_or("").to_string(),
|
|
||||||
call_type: "function".to_string(),
|
|
||||||
function: agent_chat_store::ToolCallFunction {
|
|
||||||
name: name.to_string(),
|
|
||||||
arguments: args_str.to_string(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add tool result message
|
|
||||||
agent_chat_store::agent_chat_store_add_message(
|
|
||||||
agent_chat_store::ChatMessage {
|
|
||||||
role: "tool".to_string(),
|
|
||||||
content: serde_json::to_string(&result).unwrap_or_default(),
|
|
||||||
tool_calls: None,
|
|
||||||
tool_call_id: Some(call["id"].as_str().unwrap_or("").to_string()),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
agent_chat_store::agent_chat_store_add_message(
|
|
||||||
agent_chat_store::ChatMessage {
|
|
||||||
role: "tool".to_string(),
|
|
||||||
content: format!("Error: {}", e),
|
|
||||||
tool_calls: None,
|
|
||||||
tool_call_id: Some(call["id"].as_str().unwrap_or("").to_string()),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assistant_msg.tool_calls = Some(tool_call_list);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
s.state = AgentLoopState::Thinking;
|
||||||
|
s.iteration = 0;
|
||||||
|
s.current_tool = None;
|
||||||
|
s.error = None;
|
||||||
|
s.last_message = None;
|
||||||
|
}
|
||||||
|
G_CANCEL.store(false, Ordering::SeqCst);
|
||||||
|
|
||||||
agent_chat_store::agent_chat_store_add_message(assistant_msg);
|
// Store the user message synchronously so it shows up immediately.
|
||||||
return Ok(content.to_string());
|
// If it can't be stored, don't start: the LLM would never see it.
|
||||||
|
if let Err(e) = agent_chat_store::agent_chat_store_add_user_message(user_message) {
|
||||||
|
set_error(&e);
|
||||||
|
return Err(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(String::new())
|
std::thread::Builder::new()
|
||||||
|
.name("agent-loop".into())
|
||||||
|
.spawn(run_loop)
|
||||||
|
.map_err(|e| {
|
||||||
|
set_error(&format!("Failed to spawn agent thread: {}", e));
|
||||||
|
"Failed to start agent loop".to_string()
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancelled(iter: i32) -> bool {
|
||||||
|
if G_CANCEL.load(Ordering::SeqCst) {
|
||||||
|
set_status(AgentLoopState::Cancelled, iter, None);
|
||||||
|
println!("[agent-loop] cancelled at iteration {}", iter);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_loop() {
|
||||||
|
run_loop_inner();
|
||||||
|
// Persist the conversation (derives a title from the first user
|
||||||
|
// message if it has none, and syncs to Nostr when a signer is
|
||||||
|
// available). The chat page also auto-saves, but only if its poll
|
||||||
|
// happened to observe the running state — a fast reply, or a message
|
||||||
|
// sent via the URL bar's `; ` shortcut, would otherwise never be saved.
|
||||||
|
let sid = agent_chat_store::agent_chat_store_get_session();
|
||||||
|
let _ = crate::agent_conversations::agent_conversations_save(Some(&sid), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_loop_inner() {
|
||||||
|
let cfg = agent_llm::agent_config_get();
|
||||||
|
let base_url = cfg.base_url();
|
||||||
|
let api_key = cfg.api_key();
|
||||||
|
let model = cfg.model.clone();
|
||||||
|
let max_iter = cfg.effective_max_iterations();
|
||||||
|
let system_prompt = crate::agent_skills::agent_skills_build_system_prompt();
|
||||||
|
let tools = tool_definitions();
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"[agent-loop] base_url={} model={} api_key={} max_iter={}",
|
||||||
|
base_url,
|
||||||
|
model,
|
||||||
|
if api_key.is_empty() { "(empty)" } else { "(set)" },
|
||||||
|
max_iter
|
||||||
|
);
|
||||||
|
|
||||||
|
for iter in 0..max_iter {
|
||||||
|
if cancelled(iter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set_status(AgentLoopState::Thinking, iter, None);
|
||||||
|
|
||||||
|
let mut messages = vec![serde_json::json!({"role": "system", "content": system_prompt})];
|
||||||
|
messages.extend(agent_chat_store::agent_chat_store_get_messages());
|
||||||
|
|
||||||
|
let reply = match agent_llm::agent_llm_chat(&base_url, &api_key, &model, &messages, &tools) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
set_error(&e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if cancelled(iter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = reply["content"].as_str().unwrap_or("").to_string();
|
||||||
|
let tool_calls: Vec<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!({}))
|
||||||
|
};
|
||||||
|
|
||||||
|
set_status(AgentLoopState::ToolCall, iter, Some(name.clone()));
|
||||||
|
println!("[agent-loop] iter {}: tool {} {}", iter, name, truncate(args_str, 200));
|
||||||
|
|
||||||
|
let result = match agent_tools::agent_tools_execute(&name, &args) {
|
||||||
|
Ok(v) => serde_json::to_string(&v).unwrap_or_default(),
|
||||||
|
Err(e) => serde_json::json!({"error": e}).to_string(),
|
||||||
|
};
|
||||||
|
if let Err(e) = agent_chat_store::agent_chat_store_add_tool_result(&id, &truncate(&result, MAX_TOOL_RESULT_CHARS)) {
|
||||||
|
set_error(&e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if G_CANCEL.load(Ordering::SeqCst) {
|
||||||
|
set_status(AgentLoopState::Cancelled, iter, None);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set_error(&format!("Reached the iteration limit ({})", max_iter));
|
||||||
}
|
}
|
||||||
|
|||||||
+229
-137
@@ -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();
|
|
||||||
for event in events {
|
|
||||||
let tags = event["tags"].as_array();
|
|
||||||
let d_tag = tags.and_then(|t| {
|
|
||||||
t.iter().find_map(|tag| {
|
|
||||||
tag.as_array().and_then(|a| {
|
|
||||||
if a.len() >= 2 && a[0] == "d" {
|
|
||||||
Some(a[1].as_str().unwrap_or("").to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}).unwrap_or_default();
|
|
||||||
|
|
||||||
let name = tags.and_then(|t| {
|
|
||||||
t.iter().find_map(|tag| {
|
|
||||||
tag.as_array().and_then(|a| {
|
|
||||||
if a.len() >= 2 && a[0] == "name" {
|
|
||||||
Some(a[1].as_str().unwrap_or("").to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}).unwrap_or_default();
|
|
||||||
|
|
||||||
let description = tags.and_then(|t| {
|
|
||||||
t.iter().find_map(|tag| {
|
|
||||||
tag.as_array().and_then(|a| {
|
|
||||||
if a.len() >= 2 && a[0] == "description" {
|
|
||||||
Some(a[1].as_str().unwrap_or("").to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}).unwrap_or_default();
|
|
||||||
|
|
||||||
let requires_tools: Vec<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(""),
|
|
||||||
"pubkey": event["pubkey"].as_str().unwrap_or(""),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}
|
|
||||||
Err(_) => Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the default Sovereign Browser Skill.
|
|
||||||
pub fn agent_skills_get_default() -> serde_json::Value {
|
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"d": "",
|
"d": d,
|
||||||
"name": "Sovereign Browser Skill",
|
"name": if name.is_empty() { d.clone() } else { name },
|
||||||
"description": "Default skill for browser automation",
|
"description": tag("description"),
|
||||||
"content": "You are a helpful assistant with access to browser tools.",
|
"requires_tools": nostr_publish::tag_values(event, "requires_tool"),
|
||||||
"requires_tools": ["browser_navigate", "browser_click", "browser_type", "browser_screenshot"],
|
"content": event["content"].as_str().unwrap_or(""),
|
||||||
"unsaved": true,
|
"pubkey": event["pubkey"].as_str().unwrap_or(""),
|
||||||
"pubkey": "",
|
"created_at": event["created_at"],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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()
|
||||||
|
.map(skill_json)
|
||||||
|
.filter(|s| !s["d"].as_str().unwrap_or("").is_empty())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
for skill in &skills {
|
/// Parse a comma-separated tool list.
|
||||||
let d_tag = skill["d"].as_str().unwrap_or("");
|
fn split_tools(s: &str) -> Vec<String> {
|
||||||
if selected_skills.contains(&d_tag.to_string()) {
|
s.split(',')
|
||||||
if let Some(content) = skill["content"].as_str() {
|
.map(|t| t.trim().to_string())
|
||||||
parts.push(content.to_string());
|
.filter(|t| !t.is_empty())
|
||||||
}
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The unsaved "Sovereign Browser Skill" built from local settings.
|
||||||
|
pub fn agent_skills_get_default() -> serde_json::Value {
|
||||||
|
let cfg = agent_llm::agent_config_get();
|
||||||
|
serde_json::json!({
|
||||||
|
"d": "",
|
||||||
|
"name": cfg.effective_skill_name(),
|
||||||
|
"description": cfg.effective_skill_description(),
|
||||||
|
"content": cfg.effective_skill_template(),
|
||||||
|
"requires_tools": split_tools(&cfg.effective_skill_requires_tools()),
|
||||||
|
"pubkey": "",
|
||||||
|
"unsaved": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selected skill d-tags.
|
||||||
|
pub fn agent_skills_get_selected() -> Vec<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
|
||||||
parts.join("\n\n")
|
}
|
||||||
|
|
||||||
|
/// 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"]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+294
-116
@@ -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();
|
let normalized = crate::search::normalize_url(&url);
|
||||||
// Schedule navigation on the GTK main thread.
|
let (tx, rx) = std::sync::mpsc::channel::<bool>();
|
||||||
glib::idle_add_once(move || {
|
let target = normalized.clone();
|
||||||
if let Some(nb) = crate::tab_manager::tab_manager_get_main_notebook() {
|
with_active_webview(move |wv| {
|
||||||
if let Some(wv) = crate::tab_manager::tab_manager_get_active_webview(&nb) {
|
if let Some(wv) = wv {
|
||||||
let normalized = crate::search::normalize_url(&url);
|
wv.load_uri(&target);
|
||||||
wv.load_uri(&normalized);
|
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};
|
||||||
|
})()"#;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-11
@@ -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 attached (SOCKS 127.0.0.1:9050)");
|
||||||
println!("[net] Tor started");
|
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");
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
+664
-131
@@ -357,7 +357,7 @@ pub fn tab_manager_new_tab(notebook: >k::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: >k::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: >k::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: >k::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: >k::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: >k::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(¬ebook_clone, tab_id);
|
tab_manager_close_tab(¬ebook_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
|
||||||
let notebook_clone2 = notebook.clone();
|
// it never receives button-press events itself — the notebook-level
|
||||||
tab_label.connect_button_press_event(move |_w, event| {
|
// handler installed by `setup_notebook_action_widgets` hit-tests the
|
||||||
let s = settings::settings_get();
|
// tab labels instead (mirrors the C `on_notebook_button_press`). The
|
||||||
if event.button() == 2 && s.middle_click_close {
|
// close button *does* have its own input window and swallows presses,
|
||||||
tab_manager_close_tab(¬ebook_clone2, tab_id);
|
// so it gets the handler directly (mirrors tab_manager.c:1442).
|
||||||
return glib::Propagation::Stop;
|
if let Some(ref btn) = close_btn {
|
||||||
}
|
let notebook_clone2 = notebook.clone();
|
||||||
if event.button() == 3 {
|
btn.connect_button_press_event(move |_w, event| {
|
||||||
show_tab_context_menu(¬ebook_clone2, tab_id, event);
|
handle_tab_button_press(¬ebook_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: >k::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>() {
|
entry.grab_focus();
|
||||||
for child in box_.children() {
|
entry.select_region(0, -1);
|
||||||
if let Ok(entry) = child.downcast::<gtk::Entry>() {
|
|
||||||
entry.grab_focus();
|
|
||||||
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: >k::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: >k::Notebook,
|
||||||
|
tab_id: i32,
|
||||||
|
event: &gdk::EventButton,
|
||||||
|
) -> glib::Propagation {
|
||||||
|
if event.event_type() != gdk::EventType::ButtonPress {
|
||||||
|
return glib::Propagation::Proceed;
|
||||||
|
}
|
||||||
|
match event.button() {
|
||||||
|
2 if settings::settings_get().middle_click_close => {
|
||||||
|
tab_manager_close_tab(notebook, tab_id);
|
||||||
|
glib::Propagation::Stop
|
||||||
|
}
|
||||||
|
3 => {
|
||||||
|
show_tab_context_menu(notebook, tab_id, event);
|
||||||
|
glib::Propagation::Stop
|
||||||
|
}
|
||||||
|
_ => glib::Propagation::Proceed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Notebook-level button-press handler: finds which tab label was clicked
|
||||||
|
/// (by comparing root coordinates against each label's allocation) and
|
||||||
|
/// shows the tab context menu / closes on middle-click. Port of the C
|
||||||
|
/// `on_notebook_button_press` (tab_manager.c:720).
|
||||||
|
fn on_notebook_button_press(notebook: >k::Notebook, event: &gdk::EventButton) -> glib::Propagation {
|
||||||
|
if event.button() != 2 && event.button() != 3 {
|
||||||
|
return glib::Propagation::Proceed;
|
||||||
|
}
|
||||||
|
let (rx, ry) = event.root();
|
||||||
|
for i in 0..notebook.n_pages() {
|
||||||
|
let Some(page) = notebook.nth_page(Some(i)) else { continue };
|
||||||
|
let Some(label) = notebook.tab_label(&page) else { continue };
|
||||||
|
if !label.is_mapped() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// A windowless widget's allocation is relative to its parent's
|
||||||
|
// GdkWindow; convert that window's origin to root coordinates.
|
||||||
|
let Some(win) = label.window() else { continue };
|
||||||
|
let (_, ox, oy) = win.origin();
|
||||||
|
let a = label.allocation();
|
||||||
|
let (lx, ly) = ((ox + a.x()) as f64, (oy + a.y()) as f64);
|
||||||
|
if rx >= lx && rx < lx + a.width() as f64 && ry >= ly && ry < ly + a.height() as f64 {
|
||||||
|
if let Some(tab_id) = tab_id_for_page(&page) {
|
||||||
|
return handle_tab_button_press(notebook, tab_id, event);
|
||||||
|
}
|
||||||
|
return glib::Propagation::Proceed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
glib::Propagation::Proceed
|
||||||
|
}
|
||||||
|
|
||||||
/// Show the tab context menu (right-click on a tab label).
|
/// Show the tab context menu (right-click on a tab label).
|
||||||
fn show_tab_context_menu(notebook: >k::Notebook, tab_id: i32, event: &gdk::EventButton) {
|
fn show_tab_context_menu(notebook: >k::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: >k::Button, loading: bool) {
|
||||||
|
let (icon, tip) = if loading {
|
||||||
|
("process-stop-symbolic", "Stop loading")
|
||||||
|
} else {
|
||||||
|
("view-refresh-symbolic", "Reload page (right-click for hard reload options)")
|
||||||
|
};
|
||||||
|
btn.set_image(Some(>k::Image::from_icon_name(Some(icon), gtk::IconSize::Menu)));
|
||||||
|
btn.set_tooltip_text(Some(tip));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh/Stop action: stop when loading, reload when idle.
|
||||||
|
fn refresh_or_stop(wv: &WebView, btn: >k::Button) {
|
||||||
|
if wv.is_loading() {
|
||||||
|
println!("[tab] stop loading requested");
|
||||||
|
wv.stop_loading();
|
||||||
|
refresh_button_update(btn, wv.is_loading());
|
||||||
|
} else {
|
||||||
|
wv.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Right-click menu on the refresh button (port of on_refresh_button_press).
|
||||||
|
fn show_refresh_menu(wv: &WebView, btn: >k::Button, event: &gdk::EventButton) {
|
||||||
|
let menu = gtk::Menu::new();
|
||||||
|
|
||||||
|
let item_reload = gtk::MenuItem::with_label("Reload");
|
||||||
|
let (w, b) = (wv.clone(), btn.clone());
|
||||||
|
item_reload.connect_activate(move |_| refresh_or_stop(&w, &b));
|
||||||
|
menu.append(&item_reload);
|
||||||
|
|
||||||
|
let item_hard = gtk::MenuItem::with_label("Hard reload (bypass cache)");
|
||||||
|
let w = wv.clone();
|
||||||
|
item_hard.connect_activate(move |_| w.reload_bypass_cache());
|
||||||
|
menu.append(&item_hard);
|
||||||
|
|
||||||
|
menu.append(>k::SeparatorMenuItem::new());
|
||||||
|
|
||||||
|
let item_clear = gtk::MenuItem::with_label("Clear site data + hard reload");
|
||||||
|
let w = wv.clone();
|
||||||
|
item_clear.connect_activate(move |_| {
|
||||||
|
let w2 = w.clone();
|
||||||
|
// run_javascript is deprecated but is what the rest of the crate uses.
|
||||||
|
#[allow(deprecated)]
|
||||||
|
w.run_javascript(
|
||||||
|
"(function(){try{localStorage.clear();}catch(e){}\
|
||||||
|
try{sessionStorage.clear();}catch(e){}return 'ok';})()",
|
||||||
|
None::<&gio::Cancellable>,
|
||||||
|
move |_| w2.reload_bypass_cache(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
menu.append(&item_clear);
|
||||||
|
|
||||||
|
menu.show_all();
|
||||||
|
menu.popup_at_pointer(Some(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Name of the bookmark folder shown in the per-tab bookmark bar.
|
||||||
|
const BOOKMARKS_BAR_FOLDER: &str = "Bookmarks Bar";
|
||||||
|
|
||||||
|
/// All live bookmark bars (bar, owning webview), refreshed when bookmarks
|
||||||
|
/// change. Weak refs so closed tabs drop out automatically.
|
||||||
|
static G_BOOKMARK_BARS: Lazy<
|
||||||
|
Mutex<Vec<glib::thread_guard::ThreadGuard<(glib::WeakRef<gtk::Box>, glib::WeakRef<WebView>)>>>,
|
||||||
|
> = Lazy::new(|| Mutex::new(Vec::new()));
|
||||||
|
|
||||||
|
fn track_bookmark_bar(bar: >k::Box, wv: &WebView) {
|
||||||
|
G_BOOKMARK_BARS
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(glib::thread_guard::ThreadGuard::new((bar.downgrade(), wv.downgrade())));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuild every open tab's bookmark bar.
|
||||||
|
pub fn tab_manager_refresh_bookmark_bars() {
|
||||||
|
let bars: Vec<(gtk::Box, WebView)> = {
|
||||||
|
let mut list = G_BOOKMARK_BARS.lock().unwrap();
|
||||||
|
list.retain(|g| g.get_ref().0.upgrade().is_some());
|
||||||
|
list.iter()
|
||||||
|
.filter_map(|g| {
|
||||||
|
let (b, w) = g.get_ref();
|
||||||
|
Some((b.upgrade()?, w.upgrade()?))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
for (bar, wv) in bars {
|
||||||
|
bookmark_bar_refresh(&bar, &wv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Populate a tab's bookmark bar from the "Bookmarks Bar" folder (and its
|
||||||
|
/// sub-folders, shown as drop-down menu buttons). Port of the C
|
||||||
|
/// `bookmark_bar_refresh` / `bookmark_bar_add_folder`.
|
||||||
|
fn bookmark_bar_refresh(bar: >k::Box, wv: &WebView) {
|
||||||
|
for child in bar.children() {
|
||||||
|
bar.remove(&child);
|
||||||
|
}
|
||||||
|
|
||||||
|
let prefix = format!("{}/", BOOKMARKS_BAR_FOLDER);
|
||||||
|
let bms = bookmarks::bookmarks_get_all().unwrap_or_default();
|
||||||
|
let field = |bm: &serde_json::Value, k: &str| {
|
||||||
|
bm.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Direct bookmarks + sub-folder name → bookmarks.
|
||||||
|
let mut direct: Vec<(String, String)> = Vec::new();
|
||||||
|
let mut subfolders: Vec<(String, Vec<(String, String)>)> = Vec::new();
|
||||||
|
for bm in &bms {
|
||||||
|
let folder = field(bm, "folder");
|
||||||
|
let url = field(bm, "url");
|
||||||
|
let title = field(bm, "title");
|
||||||
|
let label = if title.is_empty() { url.clone() } else { title };
|
||||||
|
if folder == BOOKMARKS_BAR_FOLDER {
|
||||||
|
direct.push((label, url));
|
||||||
|
} else if let Some(sub) = folder.strip_prefix(&prefix) {
|
||||||
|
match subfolders.iter_mut().find(|(n, _)| n == sub) {
|
||||||
|
Some((_, v)) => v.push((label, url)),
|
||||||
|
None => subfolders.push((sub.to_string(), vec![(label, url)])),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if direct.is_empty() && subfolders.is_empty() {
|
||||||
|
let hint = gtk::Label::new(Some(
|
||||||
|
"Bookmarks Bar is empty — bookmark a page and choose \"Bookmarks Bar\" as the folder",
|
||||||
|
));
|
||||||
|
hint.set_sensitive(false);
|
||||||
|
hint.set_margin_start(4);
|
||||||
|
bar.pack_start(&hint, false, false, 0);
|
||||||
|
bar.show_all();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (label, url) in direct {
|
||||||
|
let btn = gtk::Button::with_label(&truncate_label(&label));
|
||||||
|
btn.set_widget_name("bookmark-bar-btn");
|
||||||
|
btn.style_context().add_class("bookmark-bar-btn");
|
||||||
|
btn.set_tooltip_text(Some(&url));
|
||||||
|
let w = wv.clone();
|
||||||
|
btn.connect_clicked(move |_| w.load_uri(&url));
|
||||||
|
bar.pack_start(&btn, false, false, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (name, items) in subfolders {
|
||||||
|
let menu = gtk::Menu::new();
|
||||||
|
for (label, url) in items {
|
||||||
|
let item = gtk::MenuItem::with_label(&truncate_label(&label));
|
||||||
|
item.set_tooltip_text(Some(&url));
|
||||||
|
let w = wv.clone();
|
||||||
|
item.connect_activate(move |_| w.load_uri(&url));
|
||||||
|
menu.append(&item);
|
||||||
|
}
|
||||||
|
menu.show_all();
|
||||||
|
let mb = gtk::MenuButton::new();
|
||||||
|
mb.set_widget_name("bookmark-bar-menu-btn");
|
||||||
|
mb.style_context().add_class("bookmark-bar-btn");
|
||||||
|
mb.set_label(&name);
|
||||||
|
mb.set_tooltip_text(Some(&format!("{}{}", prefix, name)));
|
||||||
|
mb.set_popup(Some(&menu));
|
||||||
|
bar.pack_start(&mb, false, false, 0);
|
||||||
|
}
|
||||||
|
bar.show_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Bookmark this page" dialog with a folder picker (existing folders plus
|
||||||
|
/// a free-text entry for new paths). Port of the C `on_bookmark_clicked`.
|
||||||
|
fn show_bookmark_dialog(btn: >k::Button, wv: &WebView) {
|
||||||
|
let url = match wv.uri() {
|
||||||
|
Some(u) if !u.is_empty() => u.to_string(),
|
||||||
|
_ => {
|
||||||
|
println!("[bookmarks] No URL to bookmark");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if url.starts_with("sovereign://") {
|
||||||
|
println!("[bookmarks] Cannot bookmark internal pages");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let title = wv.title().map(|t| t.to_string()).unwrap_or_default();
|
||||||
|
|
||||||
|
let parent = btn.toplevel().and_then(|w| w.downcast::<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,36 +1798,27 @@ 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);
|
||||||
m.append(&empty);
|
m.append(&empty);
|
||||||
} else {
|
} else {
|
||||||
for entry in &history {
|
for entry in &history {
|
||||||
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 |_| {
|
m.append(&item);
|
||||||
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(>k::SeparatorMenuItem::new());
|
m.append(>k::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.
|
||||||
let url = bm.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
for bm in bms.iter().take(15) {
|
||||||
let title = bm.get("title").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 label = if !title.is_empty() { title.clone() } else { url.clone() };
|
let title = bm.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
let item = gtk::MenuItem::with_label(&label);
|
let label = if !title.is_empty() { title } else { url.clone() };
|
||||||
let url_clone = url.clone();
|
let item = gtk::MenuItem::with_label(&truncate_label(&label));
|
||||||
item.connect_activate(move |_| {
|
item.set_tooltip_text(Some(&url));
|
||||||
if let Some(nb) = tab_manager_get_active_notebook() {
|
item.connect_activate(move |_| navigate_active_tab(&url));
|
||||||
if let Some(wv) = tab_manager_get_active_webview(&nb) {
|
m.append(&item);
|
||||||
if let Some(ctx) = wv.web_context() {
|
|
||||||
tab_manager_new_tab(&nb, &ctx, Some(&url_clone));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
m.append(&item);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
m.append(>k::SeparatorMenuItem::new());
|
|
||||||
let manage_item = gtk::MenuItem::with_label("Manage Bookmarks…");
|
|
||||||
manage_item.connect_activate(|_| tab_manager_open_internal("sovereign://bookmarks"));
|
|
||||||
m.append(&manage_item);
|
|
||||||
}
|
}
|
||||||
|
// "Manage Bookmarks…" is always available (even with no bookmarks).
|
||||||
|
m.append(>k::SeparatorMenuItem::new());
|
||||||
|
let manage_item = gtk::MenuItem::with_label("Manage Bookmarks…");
|
||||||
|
manage_item.connect_activate(|_| tab_manager_open_internal("sovereign://bookmarks"));
|
||||||
|
m.append(&manage_item);
|
||||||
m.show_all();
|
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(>k::SeparatorMenuItem::new());
|
menu.append(>k::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(>k::SeparatorMenuItem::new());
|
menu.append(>k::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: >k::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: >k::Paned, container: >k::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 {
|
||||||
paned.set_position(SIDEBAR_DEFAULT_WIDTH);
|
*G_SIDEBAR_WIDTH.lock().unwrap() = pos;
|
||||||
container.show_all();
|
}
|
||||||
*visible = true;
|
container.hide();
|
||||||
|
paned.set_position(0);
|
||||||
|
*G_SIDEBAR_VISIBLE.lock().unwrap() = false;
|
||||||
|
// Hand focus back to the page.
|
||||||
|
if let Some(wv) = tab_manager_get_main_notebook().and_then(|nb| tab_manager_get_active_webview(&nb)) {
|
||||||
|
wv.grab_focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create the sidebar webview loading sovereign://agents/chat.
|
/// Toggle the agent chat sidebar.
|
||||||
fn create_sidebar_webview(container: >k::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: >k::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: >k::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: >k::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: >k::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: >k::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: >k::Notebook) {
|
pub fn setup_notebook_action_widgets(notebook: >k::Notebook) {
|
||||||
|
// Right-click / middle-click on the tab strip (tab context menu).
|
||||||
|
notebook.add_events(gdk::EventMask::BUTTON_PRESS_MASK);
|
||||||
|
notebook.connect_button_press_event(on_notebook_button_press);
|
||||||
|
|
||||||
|
// Give the webview GTK focus whenever its tab becomes active, so
|
||||||
|
// mouse-wheel scrolling works (mirrors the C `on_notebook_switch_page`).
|
||||||
|
notebook.connect_switch_page(|_nb, page, _n| {
|
||||||
|
if let Some(wv) = find_webview(page) {
|
||||||
|
if !wv.has_focus() {
|
||||||
|
wv.grab_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// New-tab button at the end of the tab strip.
|
// 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
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user