use crate::iced_ui::{self, IcedUiLaunchConfig};
#[derive(Clone)]
pub struct UiLaunchConfig {
pub db_path: String,
pub daemon_url: String,
pub user_id: String,
}
impl Default for UiLaunchConfig {
fn default() -> Self {
Self {
db_path: crate::runtime_paths::default_db_path(),
daemon_url: "http://127.0.0.1:7878".to_string(),
user_id: "user".to_string(),
}
}
}
pub fn launch_ui() {
launch_ui_with_config(UiLaunchConfig::default());
}
pub fn launch_ui_with_config(config: UiLaunchConfig) {
let result = iced_ui::launch_ui(IcedUiLaunchConfig {
daemon_url: config.daemon_url,
user_id: config.user_id,
db_path: config.db_path,
});
if let Err(err) = result {
tracing::error!(error = %err, "failed to launch iced UI");
}
}
#[cfg(any())]
mod legacy_ui {
#![allow(
clippy::clone_on_copy,
clippy::collapsible_match,
clippy::collapsible_else_if
)]
use legacy_framework::document::eval;
use legacy_framework::launch;
use legacy_framework::prelude::*;
use futures::StreamExt;
#[cfg(target_os = "linux")]
use notify_rust::Notification;
use pulldown_cmark::{html, Options, Parser};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::fs::OpenOptions;
use std::io::{ErrorKind, Read, Write};
use std::net::ToSocketAddrs;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::{Mutex, OnceLock};
use std::time::Duration as StdDuration;
use syntect::easy::HighlightLines;
use syntect::highlighting::ThemeSet;
use syntect::html::styled_line_to_highlighted_html;
use syntect::parsing::SyntaxSet;
use syntect::util::LinesWithEndings;
use time::format_description::well_known::Rfc3339;
use time::{macros::format_description, OffsetDateTime, UtcOffset};
use tokio::time::{sleep, timeout, Duration, Instant};
const APP_LOGO: Asset = asset!("/assets/icons/hicolor/32x32/apps/butterfly-bot.png");
const OPENAI_DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
const OPENAI_DEFAULT_CHAT_MODEL: &str = "gpt-4.1-mini";
const OPENAI_DEFAULT_SUMMARY_MODEL: &str = "gpt-4.1-mini";
const OPENAI_DEFAULT_EMBED_MODEL: &str = "text-embedding-3-small";
const OPENAI_DEFAULT_RERANK_MODEL: &str = "gpt-4.1-mini";
#[cfg(target_os = "linux")]
fn send_desktop_notification(title: &str) {
if let Err(err) = Notification::new()
.summary("Butterfly Bot")
.body(title)
.show()
{
tracing::warn!(error = %err, "Desktop notification failed");
}
}
#[cfg(not(target_os = "linux"))]
fn send_desktop_notification(_title: &str) {}
#[derive(Clone, Serialize)]
struct ProcessTextRequest {
user_id: String,
text: String,
prompt: Option<String>,
}
#[derive(Clone, Serialize)]
struct PreloadBootRequest {
user_id: String,
}
#[allow(dead_code)]
#[derive(Clone, Deserialize)]
struct DoctorCheckResponse {
name: String,
status: String,
message: String,
fix_hint: Option<String>,
}
#[derive(Clone, Deserialize)]
struct DoctorResponse {
overall: String,
checks: Vec<DoctorCheckResponse>,
}
#[allow(dead_code)]
#[derive(Clone, Deserialize)]
struct SecurityAuditFindingResponse {
id: String,
severity: String,
status: String,
message: String,
fix_hint: Option<String>,
auto_fixable: bool,
}
#[allow(dead_code)]
#[derive(Clone, Deserialize)]
struct SecurityAuditResponse {
overall: String,
findings: Vec<SecurityAuditFindingResponse>,
}
#[allow(dead_code)]
#[derive(Clone, Deserialize)]
struct FactoryResetConfigResponse {
message: String,
config: Value,
}
async fn run_doctor_request(daemon_url: String, token: String) -> Result<DoctorResponse, String> {
let client = daemon_request_client();
let url = format!("{}/doctor", daemon_url.trim_end_matches('/'));
let mut request = client.post(url);
if !token.trim().is_empty() {
request = request.header("authorization", format!("Bearer {token}"));
}
let response = request.send().await.map_err(|err| err.to_string())?;
if !response.status().is_success() {
let status = response.status();
let text = response
.text()
.await
.unwrap_or_else(|_| "Unable to read response body".to_string());
return Err(format!("HTTP {status}: {text}"));
}
response
.json::<DoctorResponse>()
.await
.map_err(|err| err.to_string())
}
#[allow(dead_code)]
async fn run_security_audit_request(
daemon_url: String,
token: String,
) -> Result<SecurityAuditResponse, String> {
let client = daemon_request_client();
let url = format!("{}/security_audit", daemon_url.trim_end_matches('/'));
let mut request = client.post(url);
if !token.trim().is_empty() {
request = request.header("authorization", format!("Bearer {token}"));
}
let response = request.send().await.map_err(|err| err.to_string())?;
if !response.status().is_success() {
let status = response.status();
let text = response
.text()
.await
.unwrap_or_else(|_| "Unable to read response body".to_string());
return Err(format!("HTTP {status}: {text}"));
}
response
.json::<SecurityAuditResponse>()
.await
.map_err(|err| err.to_string())
}
#[allow(dead_code)]
async fn run_factory_reset_config_request(
daemon_url: String,
token: String,
) -> Result<FactoryResetConfigResponse, String> {
let client = daemon_request_client();
let url = format!("{}/factory_reset_config", daemon_url.trim_end_matches('/'));
let mut request = client.post(url);
if !token.trim().is_empty() {
request = request.header("authorization", format!("Bearer {token}"));
}
let response = request.send().await.map_err(|err| err.to_string())?;
if !response.status().is_success() {
let status = response.status();
let text = response
.text()
.await
.unwrap_or_else(|_| "Unable to read response body".to_string());
return Err(format!("HTTP {status}: {text}"));
}
response
.json::<FactoryResetConfigResponse>()
.await
.map_err(|err| err.to_string())
}
async fn run_chat_history_request(
daemon_url: String,
token: String,
user_id: String,
limit: usize,
) -> Result<Vec<String>, String> {
let client = daemon_request_client();
let url = format!(
"{}/chat_history?user_id={}&limit={}",
daemon_url.trim_end_matches('/'),
user_id,
limit
);
let mut request = client.get(url);
if !token.trim().is_empty() {
request = request.header("authorization", format!("Bearer {token}"));
}
let response = request.send().await.map_err(|err| err.to_string())?;
if !response.status().is_success() {
let status = response.status();
let text = response
.text()
.await
.unwrap_or_else(|_| "Unable to read response body".to_string());
return Err(format!("HTTP {status}: {text}"));
}
let value = response
.json::<Value>()
.await
.map_err(|err| err.to_string())?;
let history = value
.get("history")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|item| item.as_str().map(|s| s.to_string()))
.collect::<Vec<_>>()
})
.unwrap_or_default();
Ok(history)
}
async fn run_clear_user_history_request(
daemon_url: String,
token: String,
user_id: String,
) -> Result<(), String> {
let client = daemon_request_client();
let url = format!("{}/clear_user_history", daemon_url.trim_end_matches('/'));
let mut request = client.post(url);
if !token.trim().is_empty() {
request = request.header("authorization", format!("Bearer {token}"));
}
let response = request
.json(&serde_json::json!({"user_id": user_id}))
.send()
.await
.map_err(|err| err.to_string())?;
if !response.status().is_success() {
let status = response.status();
let text = response
.text()
.await
.unwrap_or_else(|_| "Unable to read response body".to_string());
return Err(format!("HTTP {status}: {text}"));
}
Ok(())
}
#[derive(Clone, PartialEq)]
struct ChatMessage {
id: u64,
role: MessageRole,
text: String,
html: String,
timestamp: i64,
}
impl ChatMessage {
fn new(id: u64, role: MessageRole, text: String, timestamp: i64) -> Self {
let html = markdown_to_html(&text);
Self {
id,
role,
text,
html,
timestamp,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum MessageRole {
User,
Bot,
}
#[derive(Props, Clone, PartialEq)]
struct ChatTimelineProps {
messages: Signal<Vec<ChatMessage>>,
busy: Signal<bool>,
}
fn chat_timeline(props: ChatTimelineProps) -> Element {
legacy_markup! {
div { class: "chat", id: "chat-scroll",
onwheel: move |_| {
suspend_chat_autoscroll_for(1500);
},
onscroll: move |_| {
suspend_chat_autoscroll_for(1200);
},
for message in props
.messages
.read()
.iter()
.filter(|msg| msg.role == MessageRole::User || !msg.text.is_empty())
{
div {
class: if message.role == MessageRole::User {
"bubble user"
} else {
"bubble bot"
},
div {
class: "bubble-content",
dangerous_inner_html: message.html.clone(),
}
div { class: "bubble-time", "{format_local_time(message.timestamp)}" }
}
}
if *props.busy.read() {
div { class: "hint", "Bot is typing…" }
}
}
}
}
#[derive(Props, Clone, PartialEq)]
struct ChatComposerProps {
busy: bool,
on_send: EventHandler<String>,
}
fn chat_composer(props: ChatComposerProps) -> Element {
let draft = use_signal(String::new);
legacy_markup! {
div { class: "composer",
div { class: "composer-row",
label { "Message" }
div { class: "composer-input",
textarea {
id: "composer-textarea",
value: "{draft}",
oninput: move |evt| {
let mut draft = draft.clone();
draft.set(evt.value());
},
onkeydown: move |evt| {
if evt.key() == Key::Enter && !evt.modifiers().shift() {
evt.prevent_default();
let text = draft();
let mut draft = draft.clone();
draft.set(String::new());
props.on_send.call(text);
}
},
}
button {
class: "send",
disabled: props.busy,
onclick: move |_| {
let text = draft();
let mut draft = draft.clone();
draft.set(String::new());
props.on_send.call(text);
},
"Send"
}
}
}
}
}
}
const HISTORY_TIMESTAMP_FORMAT: &[time::format_description::FormatItem<'static>] =
format_description!("[year]-[month]-[day] [hour]:[minute]");
const MAX_CHAT_MESSAGES: usize = 100;
const MAX_ACTIVITY_MESSAGES: usize = 260;
const SEND_COOLDOWN_MS: i64 = 150;
const STREAM_SLOW_RENDER_WARN_MS: i64 = 60;
const STREAM_HARD_TIMEOUT_SECS: u64 = 75;
const CHAT_SCROLL_SUSPEND_MS_DEFAULT: i64 = 1200;
const CHAT_SCROLL_THROTTLE_MS: i64 = 250;
fn chat_scroll_suspend_until_ms() -> &'static AtomicI64 {
static VALUE: OnceLock<AtomicI64> = OnceLock::new();
VALUE.get_or_init(|| AtomicI64::new(0))
}
fn last_chat_scroll_ms() -> &'static AtomicI64 {
static VALUE: OnceLock<AtomicI64> = OnceLock::new();
VALUE.get_or_init(|| AtomicI64::new(0))
}
fn suspend_chat_autoscroll_for(duration_ms: i64) {
let until = now_unix_ms().saturating_add(duration_ms.max(CHAT_SCROLL_SUSPEND_MS_DEFAULT));
chat_scroll_suspend_until_ms().store(until, Ordering::Relaxed);
}
fn chat_autoscroll_suspended() -> bool {
now_unix_ms() < chat_scroll_suspend_until_ms().load(Ordering::Relaxed)
}
fn chat_scroll_allowed_now() -> bool {
let now = now_unix_ms();
let last = last_chat_scroll_ms().load(Ordering::Relaxed);
if now.saturating_sub(last) < CHAT_SCROLL_THROTTLE_MS {
return false;
}
last_chat_scroll_ms().store(now, Ordering::Relaxed);
true
}
fn push_bounded_message(list: &mut Vec<ChatMessage>, message: ChatMessage, max_len: usize) {
list.push(message);
if list.len() > max_len {
let overflow = list.len() - max_len;
list.drain(0..overflow);
}
}
fn now_unix_ts() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
fn now_unix_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}
fn format_local_time(ts: i64) -> String {
let dt = OffsetDateTime::from_unix_timestamp(ts)
.unwrap_or_else(|_| OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(ts.max(0)));
let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
let local = dt.to_offset(local_offset);
local
.format(HISTORY_TIMESTAMP_FORMAT)
.unwrap_or_else(|_| ts.to_string())
}
fn parse_history_timestamp(raw: &str) -> Option<i64> {
let trimmed = raw.trim();
if let Ok(value) = trimmed.parse::<i64>() {
return Some(if value >= 1_000_000_000_000 {
value / 1000
} else {
value
});
}
if let Ok(parsed) = OffsetDateTime::parse(trimmed, &Rfc3339) {
return Some(parsed.unix_timestamp());
}
OffsetDateTime::parse(trimmed, HISTORY_TIMESTAMP_FORMAT)
.ok()
.map(|value| value.unix_timestamp())
}
fn parse_history_entry(line: &str) -> Option<(MessageRole, String, Option<i64>)> {
let trimmed = line.trim();
let payload = trimmed.strip_prefix('[')?;
let (ts_str, rest) = payload.split_once("] ")?;
let (role, content) = rest.split_once(": ")?;
if content.trim().is_empty() {
return None;
}
let role = match role.trim() {
"user" => MessageRole::User,
_ => MessageRole::Bot,
};
Some((role, content.to_string(), parse_history_timestamp(ts_str)))
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum UiTab {
Chat,
Activity,
Config,
Context,
Heartbeat,
}
#[derive(Clone, Default)]
struct UiMcpServer {
name: String,
url: String,
header_key: String,
header_value: String,
headers: HashMap<String, String>,
primary_header_key: String,
}
#[derive(Clone, Default)]
struct UiHttpCallServer {
name: String,
url: String,
header_key: String,
header_value: String,
headers: HashMap<String, String>,
primary_header_key: String,
}
fn is_url_source(value: &str) -> bool {
let trimmed = value.trim();
trimmed.starts_with("http://") || trimmed.starts_with("https://")
}
async fn load_markdown_source(source: &str) -> Result<String, String> {
let trimmed = source.trim();
if trimmed.is_empty() {
return Ok(String::new());
}
if is_url_source(trimmed) {
let response = reqwest::get(trimmed).await.map_err(|err| err.to_string())?;
if !response.status().is_success() {
return Err(format!("Failed to fetch {trimmed}"));
}
return response.text().await.map_err(|err| err.to_string());
}
Err("Only URL markdown sources are supported for external loading.".to_string())
}
async fn save_markdown_source_to_store(
db_path: String,
target: &'static str,
source: crate::config::MarkdownSource,
) -> Result<(), String> {
let mut config = crate::config::Config::from_store(&db_path).map_err(|err| err.to_string())?;
match target {
"heartbeat" => config.heartbeat_source = source,
"prompt" => config.prompt_source = source,
_ => return Err("Unknown markdown source target".to_string()),
}
let pretty = serde_json::to_string_pretty(&config).map_err(|err| err.to_string())?;
let db_path_for_save = db_path.clone();
let config_for_save = config.clone();
let save_result = tokio::task::spawn_blocking(move || {
crate::config_store::save_config(&db_path_for_save, &config_for_save)
})
.await
.map_err(|err| err.to_string())?;
save_result.map_err(|err| err.to_string())?;
crate::vault::set_secret("app_config_json", &pretty).map_err(|err| err.to_string())?;
Ok(())
}
fn markdown_to_html(input: &str) -> String {
let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TASKLISTS);
options.insert(Options::ENABLE_FOOTNOTES);
options.insert(Options::ENABLE_SMART_PUNCTUATION);
let parser = Parser::new_ext(input, options);
let mut output = String::new();
html::push_html(&mut output, parser);
output
}
fn plain_text_to_html(input: &str) -> String {
let mut out = String::with_capacity(input.len() + 32);
for ch in input.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
'\n' => out.push_str("<br/>"),
_ => out.push(ch),
}
}
out
}
fn is_empty_list_result(payload: &Value) -> bool {
let action = payload
.get("args")
.and_then(|args| args.get("action"))
.and_then(|value| value.as_str())
.unwrap_or_default();
if action != "list" {
return false;
}
let result = payload.get("result").unwrap_or(&Value::Null);
["reminders", "items", "tasks", "plans", "todos", "results"]
.iter()
.any(|key| {
result
.get(*key)
.and_then(|value| value.as_array())
.map(|items| items.is_empty())
.unwrap_or(false)
})
}
async fn scroll_chat_to_bottom(force: bool) {
if !force && chat_autoscroll_suspended() {
return;
}
if !chat_scroll_allowed_now() {
return;
}
let script = if force {
"const el = document.getElementById('chat-scroll'); if (el) { el.scrollTop = el.scrollHeight; }"
} else {
"const el = document.getElementById('chat-scroll'); if (el) { const atBottom = (el.scrollHeight - el.scrollTop - el.clientHeight) < 120; if (atBottom) { el.scrollTop = el.scrollHeight; } }"
};
let eval_future = eval(script);
if timeout(Duration::from_millis(80), eval_future).await.is_err() {
tracing::warn!("chat scroll eval timed out");
}
}
async fn scroll_chat_after_render() {
scroll_chat_to_bottom(false).await;
}
async fn scroll_activity_to_bottom() {
let eval_future = eval(
"const el = document.getElementById('activity-scroll'); if (el) { el.scrollTop = el.scrollHeight; }",
);
if timeout(Duration::from_millis(120), eval_future).await.is_err() {
tracing::warn!("activity scroll eval timed out");
}
}
async fn scroll_activity_after_render() {
scroll_activity_to_bottom().await;
sleep(Duration::from_millis(16)).await;
scroll_activity_to_bottom().await;
}
#[allow(dead_code)]
fn highlight_json_html(input: &str) -> String {
static SYNTAX_SET: once_cell::sync::Lazy<SyntaxSet> =
once_cell::sync::Lazy::new(SyntaxSet::load_defaults_newlines);
static THEMES: once_cell::sync::Lazy<ThemeSet> =
once_cell::sync::Lazy::new(ThemeSet::load_defaults);
let syntax = SYNTAX_SET
.find_syntax_by_extension("json")
.unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text());
let theme = THEMES
.themes
.get("base16-ocean.dark")
.or_else(|| THEMES.themes.values().next())
.expect("theme available");
let mut highlighter = HighlightLines::new(syntax, theme);
let mut out = String::new();
for line in LinesWithEndings::from(input) {
let ranges = highlighter
.highlight_line(line, &SYNTAX_SET)
.unwrap_or_default();
let html =
styled_line_to_highlighted_html(&ranges[..], syntect::html::IncludeBackground::No)
.unwrap_or_default();
out.push_str(&html);
}
out
}
pub fn launch_ui() {
launch_ui_with_config(UiLaunchConfig::default());
}
#[derive(Clone)]
pub struct UiLaunchConfig {
pub db_path: String,
pub daemon_url: String,
pub user_id: String,
}
impl Default for UiLaunchConfig {
fn default() -> Self {
Self {
db_path: crate::runtime_paths::default_db_path(),
daemon_url: "http://127.0.0.1:7878".to_string(),
user_id: "user".to_string(),
}
}
}
fn ui_launch_config_lock() -> &'static Mutex<UiLaunchConfig> {
static CONFIG: OnceLock<Mutex<UiLaunchConfig>> = OnceLock::new();
CONFIG.get_or_init(|| Mutex::new(UiLaunchConfig::default()))
}
fn set_ui_launch_config(config: UiLaunchConfig) {
let lock = ui_launch_config_lock();
match lock.lock() {
Ok(mut guard) => *guard = config,
Err(poisoned) => {
let mut guard = poisoned.into_inner();
*guard = config;
}
}
}
fn ui_launch_config() -> UiLaunchConfig {
let lock = ui_launch_config_lock();
match lock.lock() {
Ok(guard) => guard.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
}
}
pub fn launch_ui_with_config(config: UiLaunchConfig) {
set_ui_launch_config(config);
force_dbusrs();
normalize_oom_score_adj_best_effort();
install_shutdown_hooks_once();
install_process_watchdog_once();
if ui_manage_local_daemon_enabled() {
kill_all_daemons_best_effort();
}
launch(app_view);
if ui_manage_local_daemon_enabled() {
kill_all_daemons_best_effort();
}
}
fn shutdown_spawned_daemon_best_effort() {
let _ = stop_local_daemon();
}
fn kill_all_daemons_best_effort() {
shutdown_spawned_daemon_best_effort();
#[cfg(unix)]
{
let _ = Command::new("pkill")
.arg("-f")
.arg("butterfly-botd")
.status();
std::thread::sleep(StdDuration::from_millis(120));
let _ = Command::new("pkill")
.arg("-9")
.arg("-f")
.arg("butterfly-botd")
.status();
}
#[cfg(windows)]
{
let _ = Command::new("taskkill")
.arg("/F")
.arg("/T")
.arg("/IM")
.arg("butterfly-botd.exe")
.status();
}
}
fn install_shutdown_hooks_once() {
static INSTALLED: OnceLock<()> = OnceLock::new();
INSTALLED.get_or_init(|| {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
kill_all_daemons_best_effort();
previous(info);
}));
});
}
#[cfg(target_os = "linux")]
fn normalize_oom_score_adj_best_effort() {
let current = std::fs::read_to_string("/proc/self/oom_score_adj")
.ok()
.and_then(|raw| raw.trim().parse::<i64>().ok())
.unwrap_or(0);
if current <= 0 {
return;
}
match std::fs::write("/proc/self/oom_score_adj", b"0") {
Ok(_) => tracing::info!(from = current, to = 0, "normalized oom_score_adj"),
Err(err) => tracing::warn!(
from = current,
error = %err,
"could not lower oom_score_adj; process may still be preferred kill target"
),
}
}
#[cfg(not(target_os = "linux"))]
fn normalize_oom_score_adj_best_effort() {}
#[cfg(target_os = "linux")]
fn install_process_watchdog_once() {
static INSTALLED: OnceLock<()> = OnceLock::new();
INSTALLED.get_or_init(|| {
let pid = std::process::id();
let cgroup_path = std::fs::read_to_string("/proc/self/cgroup")
.ok()
.and_then(|raw| {
raw.lines()
.find_map(|line| line.split_once("::").map(|(_, path)| path.trim().to_string()))
})
.unwrap_or_else(|| "/".to_string());
let cgroup_dir = if cgroup_path == "/" {
"/sys/fs/cgroup".to_string()
} else {
format!("/sys/fs/cgroup{}", cgroup_path)
};
tracing::info!(pid, cgroup = %cgroup_path, cgroup_dir = %cgroup_dir, "ui watchdog initialized");
std::thread::spawn(move || loop {
let mut rss_kb: Option<u64> = None;
let mut hwm_kb: Option<u64> = None;
let oom_score = std::fs::read_to_string("/proc/self/oom_score")
.ok()
.and_then(|raw| raw.trim().parse::<u64>().ok())
.unwrap_or(0);
let oom_score_adj = std::fs::read_to_string("/proc/self/oom_score_adj")
.ok()
.and_then(|raw| raw.trim().parse::<i64>().ok())
.unwrap_or(0);
let cgroup_current_kb = std::fs::read_to_string(format!("{}/memory.current", cgroup_dir))
.ok()
.and_then(|raw| raw.trim().parse::<u64>().ok())
.map(|bytes| bytes / 1024);
let cgroup_max_kb = std::fs::read_to_string(format!("{}/memory.max", cgroup_dir))
.ok()
.and_then(|raw| {
let trimmed = raw.trim();
if trimmed == "max" {
None
} else {
trimmed.parse::<u64>().ok().map(|bytes| bytes / 1024)
}
});
if let Ok(status) = std::fs::read_to_string("/proc/self/status") {
for line in status.lines() {
if rss_kb.is_none() && line.starts_with("VmRSS:") {
rss_kb = line
.split_whitespace()
.nth(1)
.and_then(|value| value.parse::<u64>().ok());
}
if hwm_kb.is_none() && line.starts_with("VmHWM:") {
hwm_kb = line
.split_whitespace()
.nth(1)
.and_then(|value| value.parse::<u64>().ok());
}
}
}
if let Some(rss_kb) = rss_kb {
tracing::debug!(
rss_kb,
hwm_kb = hwm_kb.unwrap_or(0),
oom_score,
oom_score_adj,
cgroup_current_kb = cgroup_current_kb.unwrap_or(0),
cgroup_max_kb = cgroup_max_kb.unwrap_or(0),
"ui process memory"
);
}
std::thread::sleep(StdDuration::from_secs(2));
});
});
}
#[cfg(not(target_os = "linux"))]
fn install_process_watchdog_once() {}
fn stream_timeout_duration() -> Duration {
Duration::from_secs(45)
}
fn daemon_request_client() -> reqwest::Client {
reqwest::Client::builder()
.connect_timeout(StdDuration::from_secs(2))
.timeout(StdDuration::from_secs(12))
.build()
.unwrap_or_else(|_| reqwest::Client::new())
}
fn daemon_stream_client() -> reqwest::Client {
reqwest::Client::builder()
.connect_timeout(StdDuration::from_secs(2))
.timeout(StdDuration::from_secs(STREAM_HARD_TIMEOUT_SECS))
.build()
.unwrap_or_else(|_| reqwest::Client::new())
}
fn env_flag_enabled(name: &str, default: bool) -> bool {
std::env::var(name)
.ok()
.map(|value| {
let trimmed = value.trim();
!trimmed.eq_ignore_ascii_case("0")
&& !trimmed.eq_ignore_ascii_case("false")
&& !trimmed.eq_ignore_ascii_case("off")
&& !trimmed.eq_ignore_ascii_case("no")
})
.unwrap_or(default)
}
fn ui_manage_local_daemon_enabled() -> bool {
env_flag_enabled("BUTTERFLY_UI_MANAGE_DAEMON", true)
}
fn ui_daemon_autostart_enabled() -> bool {
env_flag_enabled("BUTTERFLY_UI_DAEMON_AUTOSTART", true)
}
fn ui_autoboot_enabled() -> bool {
env_flag_enabled("BUTTERFLY_UI_AUTOBOOT", false)
}
#[cfg(target_os = "linux")]
fn force_dbusrs() {}
#[cfg(not(target_os = "linux"))]
fn force_dbusrs() {}
struct DaemonControl {
child: Child,
}
fn daemon_control() -> &'static Mutex<Option<DaemonControl>> {
static CONTROL: OnceLock<Mutex<Option<DaemonControl>>> = OnceLock::new();
CONTROL.get_or_init(|| Mutex::new(None))
}
fn local_daemon_exit_status() -> Option<std::process::ExitStatus> {
let control = daemon_control();
let mut guard = control.lock().ok()?;
let control = guard.as_mut()?;
control.child.try_wait().ok().flatten()
}
fn daemon_binary_candidates() -> Vec<PathBuf> {
let mut candidates = Vec::new();
let mut push_candidate = |path: PathBuf| {
if !candidates.iter().any(|existing| existing == &path) {
candidates.push(path);
}
};
if let Ok(explicit) = std::env::var("BUTTERFLY_BOTD_PATH") {
let explicit = explicit.trim();
if !explicit.is_empty() {
push_candidate(PathBuf::from(explicit));
}
}
if let Ok(current_exe) = std::env::current_exe() {
if let Some(dir) = current_exe.parent() {
push_candidate(dir.join("butterfly-botd"));
#[cfg(windows)]
push_candidate(dir.join("butterfly-botd.exe"));
if let Some(profile_dir) = dir.file_name().and_then(|value| value.to_str()) {
if (profile_dir == "release" || profile_dir == "debug") && dir.parent().is_some() {
let target_dir = dir.parent().unwrap();
let other_profile = if profile_dir == "release" {
"debug"
} else {
"release"
};
push_candidate(target_dir.join(other_profile).join("butterfly-botd"));
#[cfg(windows)]
push_candidate(target_dir.join(other_profile).join("butterfly-botd.exe"));
}
}
}
}
if let Ok(cwd) = std::env::current_dir() {
push_candidate(cwd.join("target").join("debug").join("butterfly-botd"));
push_candidate(cwd.join("target").join("release").join("butterfly-botd"));
#[cfg(windows)]
{
push_candidate(cwd.join("target").join("debug").join("butterfly-botd.exe"));
push_candidate(
cwd.join("target")
.join("release")
.join("butterfly-botd.exe"),
);
}
}
push_candidate(PathBuf::from("butterfly-botd"));
candidates
}
fn daemon_log_path() -> PathBuf {
crate::runtime_paths::app_root()
.join("logs")
.join("ui-daemon.log")
}
fn daemon_tpm_mode(db_path: &str) -> String {
crate::config::Config::from_store(db_path)
.ok()
.and_then(|config| config.tools)
.and_then(|tools| tools.get("settings").cloned())
.and_then(|settings| settings.get("security").cloned())
.and_then(|security| security.get("tpm_mode").cloned())
.and_then(|mode| mode.as_str().map(|s| s.to_string()))
.filter(|mode| matches!(mode.as_str(), "strict" | "auto" | "compatible"))
.unwrap_or_else(|| "auto".to_string())
}
fn spawn_daemon_process(
host: String,
port: u16,
db_path: String,
token: String,
tpm_mode: String,
) -> Result<Child, String> {
let candidates = daemon_binary_candidates();
let mut not_found = Vec::new();
let log_path = daemon_log_path();
if let Some(parent) = log_path.parent() {
std::fs::create_dir_all(parent).map_err(|err| {
format!(
"Failed to create daemon log directory '{}': {err}",
parent.to_string_lossy()
)
})?;
}
for candidate in candidates {
let candidate_display = candidate.to_string_lossy().to_string();
let log_file = OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.map_err(|err| {
format!(
"Failed to open daemon log file '{}': {err}",
log_path.to_string_lossy()
)
})?;
let stdout_log = log_file.try_clone().map_err(|err| {
format!(
"Failed to clone daemon log file handle '{}': {err}",
log_path.to_string_lossy()
)
})?;
let mut command = Command::new(&candidate);
command
.arg("--host")
.arg(host.clone())
.arg("--port")
.arg(port.to_string())
.arg("--db")
.arg(db_path.clone())
.env("BUTTERFLY_BOT_TOKEN", token.clone())
.env("BUTTERFLY_TPM_MODE", tpm_mode.clone())
.stdin(Stdio::null())
.stdout(Stdio::from(stdout_log))
.stderr(Stdio::from(log_file));
let result = command.spawn();
match result {
Ok(child) => return Ok(child),
Err(err) if err.kind() == ErrorKind::NotFound => {
not_found.push(candidate_display);
}
Err(err) => {
return Err(format!(
"Failed to start butterfly-botd process via '{}': {err} (log: {})",
candidate_display,
log_path.to_string_lossy()
));
}
}
}
Err(format!(
"Failed to start butterfly-botd process: executable not found. Tried: {}. Set BUTTERFLY_BOTD_PATH or ensure butterfly-botd is installed.",
not_found.join(", ")
))
}
fn start_local_daemon() -> Result<(), String> {
kill_all_daemons_best_effort();
let control = daemon_control();
let mut guard = control
.lock()
.map_err(|_| "Daemon lock unavailable".to_string())?;
if let Some(control) = guard.as_mut() {
match control.child.try_wait() {
Ok(Some(_)) => {
*guard = None;
}
Ok(None) => return Ok(()),
Err(_) => {
*guard = None;
}
}
}
let config = ui_launch_config();
let daemon_url = config.daemon_url;
let (host, port) = parse_daemon_address(&daemon_url);
let daemon_addr = format!("{host}:{port}");
let db_path = config.db_path;
let token = env_auth_token();
let tpm_mode = daemon_tpm_mode(&db_path);
let mut child = spawn_daemon_process(host.clone(), port, db_path, token, tpm_mode)?;
bias_daemon_oom_priority_best_effort(child.id());
std::thread::sleep(StdDuration::from_millis(200));
if let Ok(Some(status)) = child.try_wait() {
if daemon_health_ok(&host, port) {
tracing::info!(address = %daemon_addr, "Daemon already healthy on target address; reusing existing instance");
return Ok(());
}
return Err(format!(
"Daemon exited immediately ({status}) while starting on {daemon_addr}. See log: {}",
daemon_log_path().to_string_lossy()
));
}
tracing::info!("UI spawned local daemon process");
*guard = Some(DaemonControl { child });
Ok(())
}
#[cfg(target_os = "linux")]
fn bias_daemon_oom_priority_best_effort(pid: u32) {
let path = format!("/proc/{pid}/oom_score_adj");
if let Err(err) = std::fs::write(&path, b"500") {
tracing::debug!(pid, path = %path, error = %err, "could not raise daemon oom_score_adj");
} else {
tracing::info!(pid, oom_score_adj = 500, "raised daemon oom_score_adj to protect UI process");
}
}
#[cfg(not(target_os = "linux"))]
fn bias_daemon_oom_priority_best_effort(_pid: u32) {}
fn daemon_health_ok(host: &str, port: u16) -> bool {
let address = format!("{host}:{port}");
let socket = match address
.to_socket_addrs()
.ok()
.and_then(|mut addrs| addrs.next())
{
Some(value) => value,
None => return false,
};
let mut stream =
match std::net::TcpStream::connect_timeout(&socket, StdDuration::from_millis(500)) {
Ok(value) => value,
Err(_) => return false,
};
let _ = stream.set_write_timeout(Some(StdDuration::from_millis(500)));
let _ = stream.set_read_timeout(Some(StdDuration::from_millis(700)));
let request =
format!("GET /health HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\n\r\n");
if stream.write_all(request.as_bytes()).is_err() {
return false;
}
let mut response = [0u8; 256];
let bytes_read = match stream.read(&mut response) {
Ok(count) if count > 0 => count,
_ => return false,
};
let text = String::from_utf8_lossy(&response[..bytes_read]);
text.contains(" 200 ") || text.contains("{\"status\":\"ok\"}")
}
fn env_auth_token() -> String {
crate::vault::ensure_daemon_auth_token().unwrap_or_default()
}
fn stop_local_daemon() -> Result<(), String> {
let control = daemon_control();
let mut guard = control
.lock()
.map_err(|_| "Daemon lock unavailable".to_string())?;
if let Some(mut control) = guard.take() {
if let Ok(Some(_)) = control.child.try_wait() {
tracing::info!("Local daemon already exited");
return Ok(());
}
let _ = control.child.kill();
tracing::info!("Sent kill signal to local daemon");
for _ in 0..80 {
match control.child.try_wait() {
Ok(Some(_)) => {
tracing::info!("Local daemon exited");
return Ok(());
}
Ok(None) => std::thread::sleep(StdDuration::from_millis(25)),
Err(err) => {
tracing::error!(error = %err, "Failed waiting for local daemon shutdown");
return Err(format!("Failed waiting for daemon shutdown: {err}"));
}
}
}
tracing::warn!("Local daemon did not exit before shutdown timeout");
Ok(())
} else {
Err("Daemon is not running".to_string())
}
}
fn normalize_daemon_url(daemon: &str) -> String {
let trimmed = daemon.trim();
let (scheme, rest) = if let Some(value) = trimmed.strip_prefix("https://") {
("https://", value)
} else if let Some(value) = trimmed.strip_prefix("http://") {
("http://", value)
} else {
("http://", trimmed)
};
let host_port = rest.split('/').next().unwrap_or("127.0.0.1:7878");
format!("{scheme}{host_port}")
}
fn parse_daemon_address(daemon: &str) -> (String, u16) {
let trimmed = daemon.trim();
let without_scheme = trimmed
.strip_prefix("http://")
.or_else(|| trimmed.strip_prefix("https://"))
.unwrap_or(trimmed);
let host_port = without_scheme.split('/').next().unwrap_or("127.0.0.1:7878");
let mut parts = host_port.splitn(2, ':');
let host = parts.next().unwrap_or("127.0.0.1");
let port = parts
.next()
.and_then(|value| value.parse::<u16>().ok())
.unwrap_or(7878);
(host.to_string(), port)
}
fn app_view() -> Element {
let launch_config = ui_launch_config();
let db_path = launch_config.db_path;
let daemon_url = use_signal(|| normalize_daemon_url(&launch_config.daemon_url));
let token = use_signal(env_auth_token);
let user_id = use_signal(|| launch_config.user_id.clone());
let busy = use_signal(|| false);
let error = use_signal(String::new);
let messages = use_signal(Vec::<ChatMessage>::new);
let activity_messages = use_signal(Vec::<ChatMessage>::new);
let daemon_running = use_signal(|| false);
let daemon_starting = use_signal(|| false);
let daemon_autostart_attempted = use_signal(|| false);
let daemon_exit_watch_started = use_signal(|| false);
let daemon_status = use_signal(String::new);
let next_id = use_signal(|| 1u64);
let send_request_epoch = use_signal(|| 0u64);
let last_send_ms = use_signal(|| 0i64);
let active_tab = use_signal(|| UiTab::Chat);
let reminders_listening = use_signal(|| false);
let reminders_listener_started = use_signal(|| false);
let ui_events_listening = use_signal(|| false);
let ui_events_listener_started = use_signal(|| false);
let history_load_started = use_signal(|| false);
let last_bot_scroll_id = use_signal(|| 0u64);
let tools_loaded = use_signal(|| false);
let settings_load_started = use_signal(|| false);
let boot_ready = use_signal(|| false);
let boot_status = use_signal(String::new);
let boot_prompt_ready = use_signal(|| false);
let boot_heartbeat_ready = use_signal(|| false);
let settings_error = use_signal(String::new);
let settings_status = use_signal(String::new);
let doctor_status = use_signal(String::new);
let doctor_error = use_signal(String::new);
let doctor_running = use_signal(|| false);
let doctor_overall = use_signal(String::new);
let doctor_checks = use_signal(Vec::<DoctorCheckResponse>::new);
let security_audit_status = use_signal(String::new);
let security_audit_error = use_signal(String::new);
let _security_audit_running = use_signal(|| false);
let security_audit_overall = use_signal(String::new);
let security_audit_findings = use_signal(Vec::<SecurityAuditFindingResponse>::new);
let config_json_text = use_signal(String::new);
let wakeup_poll_seconds_input = use_signal(|| "60".to_string());
let github_pat_input = use_signal(String::new);
let zapier_token_input = use_signal(String::new);
let tpm_mode_input = use_signal(|| "auto".to_string());
let openai_api_key_input = use_signal(String::new);
let openai_base_url_input = use_signal(String::new);
let openai_model_input = use_signal(String::new);
let openai_summary_model_input = use_signal(String::new);
let openai_embedding_model_input = use_signal(String::new);
let openai_rerank_model_input = use_signal(String::new);
let coding_api_key_input = use_signal(String::new);
let search_api_key_input = use_signal(String::new);
let solana_rpc_endpoint_input = use_signal(String::new);
let mcp_servers_form = use_signal(Vec::<UiMcpServer>::new);
let http_call_servers_form = use_signal(Vec::<UiHttpCallServer>::new);
let network_allow_form = use_signal(Vec::<String>::new);
let context_text = use_signal(String::new);
let context_path = use_signal(|| "database".to_string());
let context_status = use_signal(String::new);
let context_error = use_signal(String::new);
let heartbeat_text = use_signal(String::new);
let heartbeat_path = use_signal(|| "database".to_string());
let heartbeat_status = use_signal(String::new);
let heartbeat_error = use_signal(String::new);
let search_provider = use_signal(|| "grok".to_string());
let search_model = use_signal(String::new);
let search_citations = use_signal(|| true);
let search_grok_web = use_signal(|| true);
let search_grok_x = use_signal(|| true);
let search_grok_timeout = use_signal(|| "90".to_string());
let search_network_allow = use_signal(String::new);
let search_default_deny = use_signal(|| false);
let search_api_key_status = use_signal(String::new);
let reminders_sqlite_path = use_signal(String::new);
let memory_enabled = use_signal(|| true);
let on_send = {
let daemon_url = daemon_url.clone();
let token = token.clone();
let user_id = user_id.clone();
let busy = busy.clone();
let error = error.clone();
let messages = messages.clone();
let next_id = next_id.clone();
let send_request_epoch = send_request_epoch.clone();
let last_send_ms = last_send_ms.clone();
use_callback(move |text: String| {
let daemon_url = daemon_url();
let token = token();
let user_id = user_id();
let mut busy = busy.clone();
let mut error = error.clone();
let messages = messages.clone();
let next_id = next_id.clone();
let mut send_request_epoch = send_request_epoch.clone();
let mut last_send_ms = last_send_ms.clone();
if *busy.read() {
return;
}
if text.trim().is_empty() {
error.set("Enter a message to send.".to_string());
return;
}
let now_ms = now_unix_ms();
let elapsed_ms = now_ms.saturating_sub(last_send_ms());
if elapsed_ms < SEND_COOLDOWN_MS {
return;
}
last_send_ms.set(now_ms);
let request_epoch = send_request_epoch().saturating_add(1);
send_request_epoch.set(request_epoch);
busy.set(true);
error.set(String::new());
spawn(async move {
let mut busy = busy;
let mut error = error;
let mut messages = messages;
let mut next_id = next_id;
let send_request_epoch = send_request_epoch;
let request_started_ms = now_unix_ms();
let input_len = text.len();
if send_request_epoch() != request_epoch {
return;
}
tracing::info!(
request_epoch,
input_len,
"chat stream request started"
);
let user_message_id = {
let id = next_id();
next_id.set(id + 1);
id
};
let bot_message_id = {
let id = next_id();
next_id.set(id + 1);
id
};
let timestamp = now_unix_ts();
{
let mut list = messages.write();
push_bounded_message(
&mut list,
ChatMessage::new(
user_message_id,
MessageRole::User,
text.clone(),
timestamp,
),
MAX_CHAT_MESSAGES,
);
push_bounded_message(
&mut list,
ChatMessage::new(
bot_message_id,
MessageRole::Bot,
String::new(),
timestamp,
),
MAX_CHAT_MESSAGES,
);
}
scroll_chat_after_render().await;
let client = daemon_stream_client();
let url = format!("{}/process_text_stream", daemon_url.trim_end_matches('/'));
let body = ProcessTextRequest {
user_id,
text,
prompt: None,
};
let make_request = |client: &reqwest::Client,
url: &str,
token: &str,
body: &ProcessTextRequest| {
let mut request = client.post(url);
if !token.trim().is_empty() {
request = request.header("authorization", format!("Bearer {token}"));
}
request.json(body)
};
match make_request(&client, &url, &token, &body).send().await {
Ok(response) => {
if send_request_epoch() != request_epoch {
return;
}
let mut messages = messages.clone();
let mut error = error.clone();
if response.status().is_success() {
tracing::debug!(request_epoch, "chat stream connected");
let mut stream = response.bytes_stream();
let mut chunk_counter = 0usize;
let mut full_text = String::new();
let stream_deadline =
Instant::now() + Duration::from_secs(STREAM_HARD_TIMEOUT_SECS);
loop {
if send_request_epoch() != request_epoch {
break;
}
if Instant::now() >= stream_deadline {
if send_request_epoch() == request_epoch {
error.set(format!(
"Stream exceeded {}s; request canceled.",
STREAM_HARD_TIMEOUT_SECS
));
}
tracing::warn!(
request_epoch,
timeout_secs = STREAM_HARD_TIMEOUT_SECS,
"chat stream hard-timeout"
);
break;
}
let next_chunk =
match timeout(stream_timeout_duration(), stream.next()).await {
Ok(value) => value,
Err(_) => {
if send_request_epoch() == request_epoch {
error.set(
"Stream timed out waiting for response."
.to_string(),
);
}
break;
}
};
let Some(chunk) = next_chunk else {
break;
};
match chunk {
Ok(bytes) => {
if let Ok(text_chunk) = std::str::from_utf8(&bytes) {
if !text_chunk.is_empty() {
full_text.push_str(text_chunk);
chunk_counter += 1;
}
}
}
Err(err) => {
if send_request_epoch() == request_epoch {
error.set(format!("Stream error: {err}"));
}
break;
}
}
}
if send_request_epoch() == request_epoch {
let render_started_ms = now_unix_ms();
let mut list = messages.write();
if let Some(last) =
list.iter_mut().rev().find(|msg| msg.id == bot_message_id)
{
last.text = full_text;
last.html = plain_text_to_html(&last.text);
}
drop(list);
let render_elapsed_ms =
now_unix_ms().saturating_sub(render_started_ms);
if render_elapsed_ms >= STREAM_SLOW_RENDER_WARN_MS {
tracing::warn!(
request_epoch,
render_elapsed_ms,
chunk_counter,
"final markdown render was slow"
);
}
scroll_chat_after_render().await;
tracing::info!(
request_epoch,
chunk_counter,
elapsed_ms = now_unix_ms().saturating_sub(request_started_ms),
"chat stream request completed"
);
}
} else {
let status = response.status();
let text = response
.text()
.await
.unwrap_or_else(|_| "Unable to read error body".to_string());
tracing::warn!(request_epoch, status = %status, "chat stream returned non-success status");
if send_request_epoch() == request_epoch {
error.set(format!("Request failed ({status}): {text}"));
}
}
}
Err(err) => {
tracing::warn!(request_epoch, error = %err, "chat stream request failed");
if send_request_epoch() == request_epoch {
error.set(format!(
"Request failed: {err}. Daemon unreachable at {daemon_url}. Use Start on the main page (Chat tab)."
));
}
}
}
if send_request_epoch() == request_epoch {
busy.set(false);
}
});
})
};
let on_send_key = on_send.clone();
let on_clear_histories = {
let daemon_running = daemon_running.clone();
let daemon_url = daemon_url.clone();
let token = token.clone();
let user_id = user_id.clone();
let messages = messages.clone();
let activity_messages = activity_messages.clone();
let next_id = next_id.clone();
let error = error.clone();
use_callback(move |_: ()| {
let daemon_running = daemon_running.clone();
let daemon_url = daemon_url.clone();
let token = token.clone();
let user_id = user_id.clone();
let messages = messages.clone();
let activity_messages = activity_messages.clone();
let next_id = next_id.clone();
let error = error.clone();
spawn(async move {
let mut messages = messages;
let mut activity_messages = activity_messages;
let mut next_id = next_id;
let mut error = error;
messages.set(Vec::new());
activity_messages.set(Vec::new());
next_id.set(1);
error.set(String::new());
if daemon_running() {
if let Err(err) =
run_clear_user_history_request(daemon_url(), token(), user_id()).await
{
error.set(format!(
"Cleared local history, but daemon clear failed: {err}"
));
}
}
});
})
};
let on_daemon_start = {
let daemon_status = daemon_status.clone();
let daemon_running = daemon_running.clone();
let daemon_starting = daemon_starting.clone();
let daemon_url = daemon_url.clone();
let token = token.clone();
let user_id = user_id.clone();
let boot_ready = boot_ready.clone();
let boot_status = boot_status.clone();
let doctor_status = doctor_status.clone();
let doctor_error = doctor_error.clone();
let doctor_running = doctor_running.clone();
let doctor_overall = doctor_overall.clone();
let doctor_checks = doctor_checks.clone();
use_callback(move |_: ()| {
let daemon_status = daemon_status.clone();
let daemon_running = daemon_running.clone();
let daemon_starting = daemon_starting.clone();
let daemon_url = daemon_url.clone();
let token = token.clone();
let user_id = user_id.clone();
let boot_ready = boot_ready.clone();
let boot_status = boot_status.clone();
let doctor_status = doctor_status.clone();
let doctor_error = doctor_error.clone();
let doctor_running = doctor_running.clone();
let doctor_overall = doctor_overall.clone();
let doctor_checks = doctor_checks.clone();
spawn(async move {
let mut daemon_status = daemon_status;
let mut daemon_running = daemon_running;
let mut daemon_starting = daemon_starting;
let mut token = token;
let mut boot_ready = boot_ready;
let mut boot_status = boot_status;
let mut doctor_status = doctor_status;
let mut doctor_error = doctor_error;
let mut doctor_running = doctor_running;
let mut doctor_overall = doctor_overall;
let mut doctor_checks = doctor_checks;
if daemon_starting() {
daemon_status.set("Daemon start already in progress…".to_string());
return;
}
if daemon_running() {
daemon_status.set("Daemon already running.".to_string());
return;
}
if !ui_manage_local_daemon_enabled() {
let daemon_url_value = daemon_url();
let (host, port) = parse_daemon_address(&daemon_url_value);
if daemon_health_ok(&host, port) {
daemon_running.set(true);
boot_ready.set(true);
daemon_status.set("External daemon is healthy.".to_string());
if ui_autoboot_enabled() {
boot_status
.set("External daemon ready; auto preload is enabled.".to_string());
} else {
boot_status
.set("External daemon ready; auto preload disabled.".to_string());
}
} else {
daemon_running.set(false);
boot_ready.set(false);
daemon_status.set(
"External daemon is not reachable. Start butterfly-botd separately."
.to_string(),
);
boot_status.set(
"Run butterfly-botd outside UI process, then press Start again."
.to_string(),
);
}
daemon_starting.set(false);
return;
}
daemon_starting.set(true);
daemon_status.set("Starting daemon…".to_string());
let result = start_local_daemon();
match result {
Ok(()) => {
daemon_status.set("Daemon started.".to_string());
boot_ready.set(true);
boot_status.set(
"Daemon starting… boot preload will continue in background."
.to_string(),
);
let client = daemon_request_client();
let mut daemon_ready = false;
let mut daemon_exit: Option<std::process::ExitStatus> = None;
for i in 0..60 {
sleep(Duration::from_millis(500)).await;
if let Some(status) = local_daemon_exit_status() {
daemon_exit = Some(status);
break;
}
let health_url =
format!("{}/health", daemon_url().trim_end_matches('/'));
if let Ok(resp) = client.get(&health_url).send().await {
if resp.status().is_success() {
daemon_ready = true;
break;
}
}
boot_status.set(format!("Waiting for daemon... ({}/60)", i + 1));
}
if !daemon_ready {
if let Some(status) = daemon_exit {
daemon_running.set(false);
boot_ready.set(false);
daemon_status
.set(format!("Daemon exited during startup: {status}."));
boot_status.set(
"Daemon failed to start. Check daemon logs/token and retry."
.to_string(),
);
} else {
daemon_running.set(false);
boot_ready.set(false);
daemon_status.set(
"Daemon did not become healthy within startup timeout."
.to_string(),
);
boot_status.set(
"Daemon startup timed out. Retry Start; ensure no other daemon is bound to this port."
.to_string(),
);
}
} else {
daemon_running.set(true);
if !ui_autoboot_enabled() {
boot_status.set(
"Daemon ready. Auto preload/doctor disabled for stability."
.to_string(),
);
doctor_running.set(false);
} else {
let url =
format!("{}/preload_boot", daemon_url().trim_end_matches('/'));
let preload_body = PreloadBootRequest { user_id: user_id() };
let mut token_value = token();
if token_value.trim().is_empty() {
let refreshed = env_auth_token();
if !refreshed.trim().is_empty() {
token.set(refreshed.clone());
token_value = refreshed;
}
}
let send_preload = |token_value: &str| {
let mut request = client.post(&url).json(&preload_body);
if !token_value.trim().is_empty() {
request = request.header(
"authorization",
format!("Bearer {token_value}"),
);
}
request
};
match send_preload(&token_value).send().await {
Ok(resp) if resp.status().is_success() => {
boot_status
.set("Boot preload started in background…".to_string());
}
Ok(resp) => {
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED {
let refreshed = env_auth_token();
if !refreshed.trim().is_empty()
&& refreshed != token_value
{
token.set(refreshed.clone());
token_value = refreshed;
match send_preload(&token_value).send().await {
Ok(retry_resp)
if retry_resp.status().is_success() =>
{
boot_status.set(
"Boot preload started in background…"
.to_string(),
);
}
Ok(retry_resp)
if retry_resp.status()
== reqwest::StatusCode::UNAUTHORIZED =>
{
boot_status.set(
"Boot preload failed: HTTP 401 Unauthorized. Likely daemon auth token mismatch. Stop other daemon instances and start daemon from this UI session."
.to_string(),
);
daemon_status.set(
"Daemon auth mismatch detected (401). Ensure one daemon instance and shared BUTTERFLY_BOT_TOKEN."
.to_string(),
);
}
Ok(retry_resp) => {
let retry_status = retry_resp.status();
boot_status.set(format!(
"Boot preload failed: HTTP {retry_status}"
));
}
Err(err) => {
boot_status.set(format!(
"Boot preload error: {err}"
));
}
}
} else {
boot_status.set(
"Boot preload failed: HTTP 401 Unauthorized. Likely daemon auth token mismatch. Stop other daemon instances and start daemon from this UI session."
.to_string(),
);
daemon_status.set(
"Daemon auth mismatch detected (401). Ensure one daemon instance and shared BUTTERFLY_BOT_TOKEN."
.to_string(),
);
}
} else {
boot_status
.set(format!("Boot preload failed: HTTP {status}"));
}
}
Err(err) => {
boot_status.set(format!("Boot preload error: {err}"));
}
}
doctor_running.set(true);
doctor_error.set(String::new());
doctor_status.set("Running diagnostics…".to_string());
match run_doctor_request(daemon_url(), token()).await {
Ok(report) => {
let overall = report.overall.clone();
doctor_overall.set(overall.clone());
doctor_checks.set(report.checks);
doctor_status
.set(format!("Diagnostics complete ({overall})."));
}
Err(err) => {
doctor_error.set(format!("Diagnostics failed: {err}"));
doctor_status.set(String::new());
}
}
doctor_running.set(false);
}
}
}
Err(err) => {
daemon_running.set(false);
boot_ready.set(false);
daemon_status.set(err);
}
}
daemon_starting.set(false);
});
})
};
let on_daemon_stop = {
let daemon_status = daemon_status.clone();
let daemon_running = daemon_running.clone();
let daemon_starting = daemon_starting.clone();
let reminders_listening = reminders_listening.clone();
let ui_events_listening = ui_events_listening.clone();
let boot_ready = boot_ready.clone();
let boot_status = boot_status.clone();
let doctor_status = doctor_status.clone();
let doctor_error = doctor_error.clone();
let doctor_overall = doctor_overall.clone();
let doctor_checks = doctor_checks.clone();
let security_audit_status = security_audit_status.clone();
let security_audit_error = security_audit_error.clone();
let security_audit_overall = security_audit_overall.clone();
let security_audit_findings = security_audit_findings.clone();
use_callback(move |_: ()| {
let daemon_status = daemon_status.clone();
let daemon_running = daemon_running.clone();
let daemon_starting = daemon_starting.clone();
let reminders_listening = reminders_listening.clone();
let ui_events_listening = ui_events_listening.clone();
let boot_ready = boot_ready.clone();
let boot_status = boot_status.clone();
let doctor_status = doctor_status.clone();
let doctor_error = doctor_error.clone();
let doctor_overall = doctor_overall.clone();
let doctor_checks = doctor_checks.clone();
let security_audit_status = security_audit_status.clone();
let security_audit_error = security_audit_error.clone();
let security_audit_overall = security_audit_overall.clone();
let security_audit_findings = security_audit_findings.clone();
spawn(async move {
let mut daemon_status = daemon_status;
let mut daemon_running = daemon_running;
let mut daemon_starting = daemon_starting;
let mut reminders_listening = reminders_listening;
let mut ui_events_listening = ui_events_listening;
let mut boot_ready = boot_ready;
let mut boot_status = boot_status;
let mut doctor_status = doctor_status;
let mut doctor_error = doctor_error;
let mut doctor_overall = doctor_overall;
let mut doctor_checks = doctor_checks;
let mut security_audit_status = security_audit_status;
let mut security_audit_error = security_audit_error;
let mut security_audit_overall = security_audit_overall;
let mut security_audit_findings = security_audit_findings;
if !ui_manage_local_daemon_enabled() {
daemon_starting.set(false);
daemon_running.set(false);
reminders_listening.set(false);
ui_events_listening.set(false);
boot_ready.set(false);
boot_status.set(
"External daemon mode: stop it from its own service/process."
.to_string(),
);
daemon_status
.set("Local daemon control disabled in this UI session.".to_string());
doctor_status.set(String::new());
doctor_error.set(String::new());
doctor_overall.set(String::new());
doctor_checks.set(Vec::new());
security_audit_status.set(String::new());
security_audit_error.set(String::new());
security_audit_overall.set(String::new());
security_audit_findings.set(Vec::new());
return;
}
let result = stop_local_daemon();
match result {
Ok(()) => {
daemon_starting.set(false);
daemon_running.set(false);
reminders_listening.set(false);
ui_events_listening.set(false);
boot_ready.set(false);
boot_status.set(
"Daemon stopped. Start it to preload prompt + heartbeat.".to_string(),
);
daemon_status.set("Daemon stopped.".to_string());
doctor_status.set(String::new());
doctor_error.set(String::new());
doctor_overall.set(String::new());
doctor_checks.set(Vec::new());
security_audit_status.set(String::new());
security_audit_error.set(String::new());
security_audit_overall.set(String::new());
security_audit_findings.set(Vec::new());
}
Err(err) => {
daemon_status.set(err);
}
}
});
})
};
let on_run_doctor = {
let daemon_running = daemon_running.clone();
let daemon_url = daemon_url.clone();
let token = token.clone();
let doctor_status = doctor_status.clone();
let doctor_error = doctor_error.clone();
let doctor_running = doctor_running.clone();
let doctor_overall = doctor_overall.clone();
let doctor_checks = doctor_checks.clone();
use_callback(move |_: ()| {
let daemon_running = daemon_running.clone();
let daemon_url = daemon_url.clone();
let token = token.clone();
let doctor_status = doctor_status.clone();
let doctor_error = doctor_error.clone();
let doctor_running = doctor_running.clone();
let doctor_overall = doctor_overall.clone();
let doctor_checks = doctor_checks.clone();
spawn(async move {
let mut doctor_status = doctor_status;
let mut doctor_error = doctor_error;
let mut doctor_running = doctor_running;
let mut doctor_overall = doctor_overall;
let mut doctor_checks = doctor_checks;
if !daemon_running() {
doctor_error
.set("Doctor requires a running daemon. Start daemon first.".to_string());
doctor_status.set(String::new());
return;
}
doctor_running.set(true);
doctor_error.set(String::new());
doctor_status.set("Running diagnostics…".to_string());
match run_doctor_request(daemon_url(), token()).await {
Ok(report) => {
let overall = report.overall.clone();
doctor_overall.set(overall.clone());
doctor_checks.set(report.checks);
doctor_status.set(format!("Diagnostics complete ({overall})."));
}
Err(err) => {
doctor_error.set(format!("Diagnostics failed: {err}"));
doctor_status.set(String::new());
doctor_overall.set(String::new());
doctor_checks.set(Vec::new());
}
}
doctor_running.set(false);
});
})
};
{
let daemon_autostart_attempted = daemon_autostart_attempted.clone();
let daemon_running = daemon_running.clone();
let daemon_starting = daemon_starting.clone();
let daemon_status = daemon_status.clone();
let on_daemon_start = on_daemon_start.clone();
use_effect(move || {
if *daemon_autostart_attempted.read() {
return;
}
let mut daemon_autostart_attempted = daemon_autostart_attempted.clone();
daemon_autostart_attempted.set(true);
if *daemon_running.read() {
return;
}
if *daemon_starting.read() {
return;
}
if !ui_daemon_autostart_enabled() {
let mut daemon_status = daemon_status.clone();
daemon_status.set(
"Daemon autostart disabled; using external daemon mode.".to_string(),
);
return;
}
let mut daemon_status = daemon_status.clone();
daemon_status.set("Starting daemon for zero-step onboarding…".to_string());
on_daemon_start.call(());
});
}
{
let daemon_exit_watch_started = daemon_exit_watch_started.clone();
let daemon_running = daemon_running.clone();
let daemon_status = daemon_status.clone();
let boot_ready = boot_ready.clone();
let boot_status = boot_status.clone();
let daemon_starting = daemon_starting.clone();
use_effect(move || {
if *daemon_exit_watch_started.read() {
return;
}
let mut started = daemon_exit_watch_started.clone();
started.set(true);
spawn(async move {
let mut daemon_running = daemon_running;
let mut daemon_status = daemon_status;
let mut boot_ready = boot_ready;
let mut boot_status = boot_status;
let mut daemon_starting = daemon_starting;
loop {
if *daemon_running.read() {
if let Some(status) = local_daemon_exit_status() {
daemon_running.set(false);
daemon_starting.set(false);
boot_ready.set(false);
daemon_status.set(format!("Daemon exited: {status}."));
boot_status.set(
"Daemon exited. Check logs, then press Start again.".to_string(),
);
}
}
sleep(Duration::from_secs(1)).await;
}
});
});
}
{
let messages = messages.clone();
let last_bot_scroll_id = last_bot_scroll_id.clone();
use_effect(move || {
let latest_bot_id = messages
.read()
.iter()
.rev()
.find(|msg| msg.role == MessageRole::Bot && !msg.text.is_empty())
.map(|msg| msg.id)
.unwrap_or(0);
if latest_bot_id == 0 || latest_bot_id == *last_bot_scroll_id.read() {
return;
}
let mut last_bot_scroll_id = last_bot_scroll_id.clone();
last_bot_scroll_id.set(latest_bot_id);
spawn(async move {
scroll_chat_after_render().await;
});
});
}
{
let history_load_started = history_load_started.clone();
let daemon_running = daemon_running.clone();
let daemon_url = daemon_url.clone();
let token = token.clone();
let user_id = user_id.clone();
let messages = messages.clone();
let next_id = next_id.clone();
use_effect(move || {
if *history_load_started.read() || !*daemon_running.read() {
return;
}
let mut started = history_load_started.clone();
started.set(true);
let mut messages = messages.clone();
let mut next_id = next_id.clone();
spawn(async move {
let history =
match run_chat_history_request(daemon_url(), token(), user_id(), 40).await {
Ok(history) => history,
Err(_) => return,
};
if history.is_empty() || !messages.read().is_empty() {
return;
}
let parsed = history
.into_iter()
.filter_map(|line| parse_history_entry(&line))
.collect::<Vec<_>>();
if parsed.is_empty() || !messages.read().is_empty() {
return;
}
let mut list = messages.write();
if !list.is_empty() {
return;
}
for (role, text, timestamp) in parsed {
let id = next_id();
next_id.set(id + 1);
push_bounded_message(
&mut list,
ChatMessage::new(id, role, text, timestamp.unwrap_or_else(now_unix_ts)),
MAX_CHAT_MESSAGES,
);
}
drop(list);
scroll_chat_after_render().await;
});
});
}
{
let reminders_listener_started = reminders_listener_started.clone();
let reminders_listening = reminders_listening.clone();
let daemon_url = daemon_url.clone();
let token = token.clone();
let user_id = user_id.clone();
let daemon_running = daemon_running.clone();
let active_tab = active_tab.clone();
let messages = messages.clone();
let next_id = next_id.clone();
use_effect(move || {
if *reminders_listener_started.read() {
return;
}
let mut started = reminders_listener_started.clone();
started.set(true);
spawn(async move {
let mut reminders_listening = reminders_listening;
let daemon_url = daemon_url;
let token = token;
let user_id = user_id;
let mut messages = messages;
let mut next_id = next_id;
reminders_listening.set(true);
let client = daemon_stream_client();
loop {
if !*daemon_running.read() {
reminders_listening.set(false);
sleep(Duration::from_secs(1)).await;
continue;
}
reminders_listening.set(true);
let url = format!(
"{}/reminder_stream?user_id={}",
daemon_url().trim_end_matches('/'),
user_id()
);
let mut request = client.get(&url);
let token_value = token();
if !token_value.trim().is_empty() {
request = request.header("authorization", format!("Bearer {token_value}"));
}
let response = match request.send().await {
Ok(resp) => resp,
Err(_) => {
if cfg!(debug_assertions) {
tracing::debug!(
"Reminder stream request failed (daemon unreachable?)"
);
}
sleep(Duration::from_secs(2)).await;
continue;
}
};
if !response.status().is_success() {
if cfg!(debug_assertions) {
tracing::debug!(status = %response.status(), "Reminder stream returned non-success status");
}
sleep(Duration::from_secs(2)).await;
continue;
}
let mut stream = response.bytes_stream();
let mut buffer = String::new();
while let Some(chunk) = stream.next().await {
let Ok(chunk) = chunk else {
break;
};
if let Ok(text) = std::str::from_utf8(&chunk) {
buffer.push_str(text);
while let Some(idx) = buffer.find('\n') {
let mut line = buffer[..idx].to_string();
buffer = buffer[idx + 1..].to_string();
if line.starts_with("data:") {
line = line.trim_start_matches("data:").trim().to_string();
if let Ok(value) = serde_json::from_str::<Value>(&line) {
let title = value
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("Reminder");
let id = next_id();
next_id.set(id + 1);
let timestamp = value
.get("due_at")
.and_then(|v| v.as_i64())
.unwrap_or_else(now_unix_ts);
push_bounded_message(
&mut messages.write(),
ChatMessage::new(
id,
MessageRole::Bot,
format!("⏰ {title}"),
timestamp,
),
MAX_CHAT_MESSAGES,
);
if *active_tab.read() == UiTab::Chat {
scroll_chat_to_bottom(false).await;
}
send_desktop_notification(title);
}
}
}
}
}
sleep(Duration::from_secs(2)).await;
}
});
});
}
{
let active_tab = active_tab.clone();
use_effect(move || {
let tab = *active_tab.read();
spawn(async move {
match tab {
UiTab::Chat => scroll_chat_after_render().await,
UiTab::Activity => scroll_activity_after_render().await,
_ => {}
}
});
});
}
{
let ui_events_listener_started = ui_events_listener_started.clone();
let ui_events_listening = ui_events_listening.clone();
let daemon_url = daemon_url.clone();
let token = token.clone();
let user_id = user_id.clone();
let active_tab = active_tab.clone();
let activity_messages = activity_messages.clone();
let next_id = next_id.clone();
let daemon_running = daemon_running.clone();
let mut boot_ready = boot_ready.clone();
let mut boot_status = boot_status.clone();
let mut boot_prompt_ready = boot_prompt_ready.clone();
let mut boot_heartbeat_ready = boot_heartbeat_ready.clone();
use_effect(move || {
if *ui_events_listener_started.read() {
return;
}
let mut started = ui_events_listener_started.clone();
started.set(true);
spawn(async move {
let mut ui_events_listening = ui_events_listening;
let daemon_url = daemon_url;
let token = token;
let user_id = user_id;
let mut activity_messages = activity_messages;
let mut next_id = next_id;
ui_events_listening.set(true);
let client = daemon_stream_client();
loop {
if !*daemon_running.read() {
ui_events_listening.set(false);
sleep(Duration::from_secs(1)).await;
continue;
}
ui_events_listening.set(true);
let url = format!(
"{}/ui_events?user_id={}",
daemon_url().trim_end_matches('/'),
user_id()
);
let mut request = client.get(&url);
let token_value = token();
if !token_value.trim().is_empty() {
request = request.header("authorization", format!("Bearer {token_value}"));
}
let response = match request.send().await {
Ok(resp) => resp,
Err(_) => {
sleep(Duration::from_secs(2)).await;
continue;
}
};
if !response.status().is_success() {
sleep(Duration::from_secs(2)).await;
continue;
}
let mut stream = response.bytes_stream();
let mut buffer = String::new();
while let Some(chunk) = stream.next().await {
let Ok(chunk) = chunk else {
break;
};
if let Ok(text) = std::str::from_utf8(&chunk) {
buffer.push_str(text);
while let Some(idx) = buffer.find('\n') {
let mut line = buffer[..idx].to_string();
buffer = buffer[idx + 1..].to_string();
if line.starts_with("data:") {
line = line.trim_start_matches("data:").trim().to_string();
if let Ok(value) = serde_json::from_str::<Value>(&line) {
let event_type = value
.get("event_type")
.and_then(|v| v.as_str())
.unwrap_or("tool");
let tool = value
.get("tool")
.and_then(|v| v.as_str())
.unwrap_or("tool");
let status = value
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("ok");
let event_user = value
.get("user_id")
.and_then(|v| v.as_str())
.unwrap_or_default();
if (event_type == "boot" || tool == "prompt")
&& status == "ok"
{
boot_prompt_ready.set(true);
}
if (event_type == "boot" || tool == "heartbeat")
&& status == "ok"
{
boot_heartbeat_ready.set(true);
}
if *boot_prompt_ready.read() && *boot_heartbeat_ready.read()
{
boot_ready.set(true);
boot_status.set("Prompt + heartbeat ready".to_string());
}
let show_success = false;
if !show_success && (status == "success" || status == "ok")
{
if event_type == "tool" {
if event_user == "system" {
continue;
}
if let Some(payload) = value.get("payload") {
if payload.get("error").is_none()
&& is_empty_list_result(payload)
{
continue;
}
}
} else if event_type != "boot"
&& event_type != "autonomy"
{
if let Some(payload) = value.get("payload") {
if payload.get("error").is_none() {
continue;
}
} else {
continue;
}
}
}
let prefix = if event_type == "autonomy" {
"🤖"
} else {
"🔧"
};
let mut text = format!("{prefix} {tool}: {status}");
if let Some(payload) = value.get("payload") {
if let Some(error) =
payload.get("error").and_then(|v| v.as_str())
{
text.push_str(&format!(" — {error}"));
} else if event_type == "autonomy" {
if status == "skipped" {
if let Some(reason) = payload
.get("reason")
.and_then(|v| v.as_str())
{
text.push_str(&format!(" — {reason}"));
}
if let Some(remaining) = payload
.get("cooldown_remaining_seconds")
.and_then(|v| v.as_i64())
{
text.push_str(&format!(
" ({}s)",
remaining.max(0)
));
}
}
} else if let Some(output) = payload
.get("output")
.or_else(|| payload.get("response"))
.and_then(|v| v.as_str())
{
text.push_str(&format!(" — {output}"));
}
}
let id = next_id();
next_id.set(id + 1);
let timestamp = value
.get("timestamp")
.and_then(|v| v.as_i64())
.unwrap_or_else(now_unix_ts);
push_bounded_message(
&mut activity_messages.write(),
ChatMessage::new(id, MessageRole::Bot, text, timestamp),
MAX_ACTIVITY_MESSAGES,
);
if *active_tab.read() == UiTab::Activity {
scroll_activity_to_bottom().await;
}
}
}
}
}
}
sleep(Duration::from_secs(2)).await;
}
});
});
}
{
let settings_load_started = settings_load_started.clone();
let settings_error = settings_error.clone();
let tools_loaded = tools_loaded.clone();
let settings_status = settings_status.clone();
let config_json_text = config_json_text.clone();
let wakeup_poll_seconds_input = wakeup_poll_seconds_input.clone();
let github_pat_input = github_pat_input.clone();
let zapier_token_input = zapier_token_input.clone();
let tpm_mode_input = tpm_mode_input.clone();
let openai_api_key_input = openai_api_key_input.clone();
let openai_base_url_input = openai_base_url_input.clone();
let openai_model_input = openai_model_input.clone();
let openai_summary_model_input = openai_summary_model_input.clone();
let openai_embedding_model_input = openai_embedding_model_input.clone();
let openai_rerank_model_input = openai_rerank_model_input.clone();
let coding_api_key_input = coding_api_key_input.clone();
let search_api_key_input = search_api_key_input.clone();
let solana_rpc_endpoint_input = solana_rpc_endpoint_input.clone();
let mcp_servers_form = mcp_servers_form.clone();
let http_call_servers_form = http_call_servers_form.clone();
let network_allow_form = network_allow_form.clone();
let boot_status = boot_status.clone();
let boot_ready = boot_ready.clone();
let daemon_url = daemon_url.clone();
let token = token.clone();
let user_id = user_id.clone();
let search_provider = search_provider.clone();
let search_model = search_model.clone();
let search_citations = search_citations.clone();
let search_grok_web = search_grok_web.clone();
let search_grok_x = search_grok_x.clone();
let search_grok_timeout = search_grok_timeout.clone();
let search_network_allow = search_network_allow.clone();
let search_default_deny = search_default_deny.clone();
let search_api_key_status = search_api_key_status.clone();
let reminders_sqlite_path = reminders_sqlite_path.clone();
let memory_enabled = memory_enabled.clone();
let context_text = context_text.clone();
let context_path = context_path.clone();
let context_error = context_error.clone();
let heartbeat_text = heartbeat_text.clone();
let heartbeat_path = heartbeat_path.clone();
let heartbeat_error = heartbeat_error.clone();
let db_path = db_path.clone();
use_effect(move || {
if *settings_load_started.read() {
return;
}
let mut started = settings_load_started.clone();
started.set(true);
let db_path = db_path.clone();
spawn(async move {
let mut settings_error = settings_error;
let mut tools_loaded = tools_loaded;
let mut settings_status = settings_status;
let mut config_json_text = config_json_text;
let mut wakeup_poll_seconds_input = wakeup_poll_seconds_input;
let mut github_pat_input = github_pat_input;
let mut zapier_token_input = zapier_token_input;
let mut tpm_mode_input = tpm_mode_input;
let mut openai_api_key_input = openai_api_key_input;
let mut openai_base_url_input = openai_base_url_input;
let mut openai_model_input = openai_model_input;
let mut openai_summary_model_input = openai_summary_model_input;
let mut openai_embedding_model_input = openai_embedding_model_input;
let mut openai_rerank_model_input = openai_rerank_model_input;
let mut coding_api_key_input = coding_api_key_input;
let mut search_api_key_input = search_api_key_input;
let mut solana_rpc_endpoint_input = solana_rpc_endpoint_input;
let mut mcp_servers_form = mcp_servers_form;
let mut http_call_servers_form = http_call_servers_form;
let mut network_allow_form = network_allow_form;
let mut search_provider = search_provider;
let mut search_model = search_model;
let mut search_citations = search_citations;
let mut search_grok_web = search_grok_web;
let mut search_grok_x = search_grok_x;
let mut search_grok_timeout = search_grok_timeout;
let mut search_network_allow = search_network_allow;
let mut search_default_deny = search_default_deny;
let mut search_api_key_status = search_api_key_status;
let mut reminders_sqlite_path = reminders_sqlite_path;
let mut memory_enabled = memory_enabled;
let mut context_text = context_text;
let mut context_path = context_path;
let mut context_error = context_error;
let mut heartbeat_text = heartbeat_text;
let mut heartbeat_path = heartbeat_path;
let heartbeat_error = heartbeat_error;
let mut boot_status = boot_status;
let mut boot_ready = boot_ready;
let config = match crate::config::Config::from_store(&db_path) {
Ok(value) => value,
Err(err) => {
settings_error.set(format!("Failed to load config: {err}"));
tools_loaded.set(true);
return;
}
};
match crate::vault::get_secret("app_config_json") {
Ok(Some(secret)) if !secret.trim().is_empty() => {
config_json_text.set(secret);
settings_status.set("Loaded config from keyring.".to_string());
}
Ok(_) => {
if let Ok(pretty) = serde_json::to_string_pretty(&config) {
config_json_text.set(pretty);
}
}
Err(err) => {
settings_error.set(format!("Vault error: {err}"));
tools_loaded.set(true);
return;
}
}
let mut allowlist: Vec<String> = Vec::new();
let mut default_deny = false;
if let Some(tools_value) = &config.tools {
if let Value::Object(map) = tools_value {
if let Some(settings) = map.get("settings").and_then(|v| v.as_object()) {
if let Some(perms) = settings.get("permissions") {
if let Some(items) =
perms.get("network_allow").and_then(|v| v.as_array())
{
allowlist = items
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
}
if let Some(value) =
perms.get("default_deny").and_then(|v| v.as_bool())
{
default_deny = value;
}
}
}
}
}
let enabled = config
.memory
.as_ref()
.and_then(|memory| memory.enabled)
.unwrap_or(true);
memory_enabled.set(enabled);
let tpm_mode = config
.tools
.as_ref()
.and_then(|tools| tools.get("settings"))
.and_then(|settings| settings.get("security"))
.and_then(|security| security.get("tpm_mode"))
.and_then(|value| value.as_str())
.unwrap_or("auto");
tpm_mode_input.set(match tpm_mode {
"strict" | "auto" | "compatible" => tpm_mode.to_string(),
_ => "auto".to_string(),
});
if let Some(openai) = &config.openai {
if let Some(base_url) = &openai.base_url {
openai_base_url_input.set(base_url.clone());
}
if let Some(model) = &openai.model {
openai_model_input.set(model.clone());
}
}
if openai_base_url_input().trim().is_empty() {
openai_base_url_input.set(OPENAI_DEFAULT_BASE_URL.to_string());
}
if openai_model_input().trim().is_empty() {
openai_model_input.set(OPENAI_DEFAULT_CHAT_MODEL.to_string());
}
openai_summary_model_input.set(OPENAI_DEFAULT_SUMMARY_MODEL.to_string());
openai_embedding_model_input.set(OPENAI_DEFAULT_EMBED_MODEL.to_string());
openai_rerank_model_input.set(OPENAI_DEFAULT_RERANK_MODEL.to_string());
if let Some(tools_value) = &config.tools {
if let Some(wakeup_cfg) = tools_value.get("wakeup") {
if let Some(poll_seconds) =
wakeup_cfg.get("poll_seconds").and_then(|v| v.as_u64())
{
wakeup_poll_seconds_input.set(poll_seconds.to_string());
}
}
if let Some(solana_rpc_endpoint) = tools_value
.get("settings")
.and_then(|settings| settings.get("solana"))
.and_then(|solana| solana.get("rpc"))
.and_then(|rpc| rpc.get("endpoint"))
.and_then(|value| value.as_str())
{
solana_rpc_endpoint_input.set(solana_rpc_endpoint.to_string());
}
if let Some(mcp_servers) = tools_value
.get("mcp")
.and_then(|mcp| mcp.get("servers"))
.and_then(|servers| servers.as_array())
{
let parsed_servers = mcp_servers
.iter()
.filter_map(|entry| {
let name = entry
.get("name")
.and_then(|v| v.as_str())
.unwrap_or_default()
.trim()
.to_string();
let url = entry
.get("url")
.and_then(|v| v.as_str())
.unwrap_or_default()
.trim()
.to_string();
let headers = entry
.get("headers")
.and_then(|v| v.as_object())
.map(|map| {
map.iter()
.filter_map(|(k, v)| {
v.as_str().map(|value| {
(k.trim().to_string(), value.trim().to_string())
})
})
.collect::<HashMap<_, _>>()
})
.unwrap_or_default();
let mut header_entries = headers.iter().collect::<Vec<_>>();
header_entries.sort_by(|(left, _), (right, _)| left.cmp(right));
let (header_key, header_value) = header_entries
.first()
.map(|(key, value)| ((*key).clone(), (*value).clone()))
.unwrap_or_else(|| (String::new(), String::new()));
if name.is_empty() && url.is_empty() {
None
} else {
let primary_header_key = header_key.clone();
Some(UiMcpServer {
name,
url,
header_key,
header_value,
headers,
primary_header_key,
})
}
})
.collect::<Vec<_>>();
mcp_servers_form.set(parsed_servers);
}
if let Some(http_call_cfg) = tools_value.get("http_call") {
let mut parsed_servers = http_call_cfg
.get("servers")
.and_then(|v| v.as_array())
.map(|servers| {
servers
.iter()
.filter_map(|entry| {
let name = entry
.get("name")
.and_then(|v| v.as_str())
.unwrap_or_default()
.trim()
.to_string();
let url = entry
.get("url")
.and_then(|v| v.as_str())
.unwrap_or_default()
.trim()
.to_string();
let headers = entry
.get("headers")
.and_then(|v| v.as_object())
.map(|map| {
map.iter()
.filter_map(|(k, v)| {
v.as_str().map(|value| {
(
k.trim().to_string(),
value.trim().to_string(),
)
})
})
.collect::<HashMap<_, _>>()
})
.unwrap_or_default();
let mut header_entries = headers.iter().collect::<Vec<_>>();
header_entries
.sort_by(|(left, _), (right, _)| left.cmp(right));
let (header_key, header_value) = header_entries
.first()
.map(|(key, value)| ((*key).clone(), (*value).clone()))
.unwrap_or_else(|| (String::new(), String::new()));
if name.is_empty() && url.is_empty() {
None
} else {
let primary_header_key = header_key.clone();
Some(UiHttpCallServer {
name,
url,
header_key,
header_value,
headers,
primary_header_key,
})
}
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
if parsed_servers.is_empty() {
let mut shared_headers = http_call_cfg
.get("custom_headers")
.and_then(|v| v.as_object())
.map(|map| {
map.iter()
.filter_map(|(k, v)| {
v.as_str().map(|value| {
(k.trim().to_string(), value.trim().to_string())
})
})
.collect::<HashMap<_, _>>()
})
.unwrap_or_default();
if let Some(default_headers) = http_call_cfg
.get("default_headers")
.and_then(|v| v.as_object())
{
for (key, value) in default_headers {
if let Some(value) = value.as_str() {
shared_headers
.entry(key.trim().to_string())
.or_insert(value.trim().to_string());
}
}
}
let mut header_entries = shared_headers.iter().collect::<Vec<_>>();
header_entries.sort_by(|(left, _), (right, _)| left.cmp(right));
let (header_key, header_value) = header_entries
.first()
.map(|(key, value)| ((*key).clone(), (*value).clone()))
.unwrap_or_else(|| (String::new(), String::new()));
if let Some(base_urls) =
http_call_cfg.get("base_urls").and_then(|v| v.as_array())
{
parsed_servers = base_urls
.iter()
.enumerate()
.filter_map(|(index, value)| {
let url =
value.as_str().unwrap_or_default().trim().to_string();
if url.is_empty() {
None
} else {
let primary_header_key = header_key.clone();
Some(UiHttpCallServer {
name: format!("server_{}", index + 1),
url,
header_key: header_key.clone(),
header_value: header_value.clone(),
headers: shared_headers.clone(),
primary_header_key,
})
}
})
.collect::<Vec<_>>();
}
if parsed_servers.is_empty() {
let base_url = http_call_cfg
.get("base_url")
.and_then(|v| v.as_str())
.unwrap_or_default()
.trim()
.to_string();
if !base_url.is_empty() {
let primary_header_key = header_key.clone();
parsed_servers.push(UiHttpCallServer {
name: "default".to_string(),
url: base_url,
header_key,
header_value,
headers: shared_headers,
primary_header_key,
});
}
}
}
http_call_servers_form.set(parsed_servers);
}
if let Some(search_cfg) = tools_value.get("search_internet") {
if let Some(provider) = search_cfg.get("provider").and_then(|v| v.as_str())
{
let normalized = match provider {
"openai" | "grok" | "perplexity" => provider,
_ => "grok",
};
search_provider.set(normalized.to_string());
}
if let Some(model) = search_cfg.get("model").and_then(|v| v.as_str()) {
search_model.set(model.to_string());
}
if let Some(citations) =
search_cfg.get("citations").and_then(|v| v.as_bool())
{
search_citations.set(citations);
}
if let Some(web) =
search_cfg.get("grok_web_search").and_then(|v| v.as_bool())
{
search_grok_web.set(web);
}
if let Some(x_search) =
search_cfg.get("grok_x_search").and_then(|v| v.as_bool())
{
search_grok_x.set(x_search);
}
if let Some(timeout) =
search_cfg.get("grok_timeout").and_then(|v| v.as_u64())
{
search_grok_timeout.set(timeout.to_string());
}
if let Some(perms) = search_cfg.get("permissions") {
if allowlist.is_empty() {
if let Some(items) =
perms.get("network_allow").and_then(|v| v.as_array())
{
allowlist = items
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
}
}
}
}
if let Some(reminders_cfg) = tools_value.get("reminders") {
if let Some(path) =
reminders_cfg.get("sqlite_path").and_then(|v| v.as_str())
{
reminders_sqlite_path.set(path.to_string());
}
}
}
match &config.prompt_source {
crate::config::MarkdownSource::Url { url } => {
context_path.set(url.clone());
match load_markdown_source(url).await {
Ok(text) => context_text.set(text),
Err(err) => context_error.set(format!("Prompt URL error: {err}")),
}
}
crate::config::MarkdownSource::Database { markdown } => {
context_path.set("database".to_string());
context_text.set(markdown.clone());
}
}
let mut heartbeat_error = heartbeat_error;
match &config.heartbeat_source {
crate::config::MarkdownSource::Url { url } => {
heartbeat_path.set(url.clone());
match load_markdown_source(url).await {
Ok(text) => heartbeat_text.set(text),
Err(err) => heartbeat_error.set(format!("Heartbeat URL error: {err}")),
}
}
crate::config::MarkdownSource::Database { markdown } => {
heartbeat_path.set("database".to_string());
heartbeat_text.set(markdown.clone());
}
}
search_default_deny.set(default_deny);
if !allowlist.is_empty() {
search_network_allow.set(allowlist.join(", "));
}
network_allow_form.set(allowlist);
match crate::vault::get_secret_required("github_pat") {
Ok(Some(secret)) if !secret.trim().is_empty() => {
github_pat_input.set(secret);
}
Ok(_) => github_pat_input.set(String::new()),
Err(err) => {
settings_error.set(format!("Vault error: {err}"));
tools_loaded.set(true);
return;
}
}
match crate::vault::get_secret_required("zapier_token") {
Ok(Some(secret)) if !secret.trim().is_empty() => {
zapier_token_input.set(secret);
}
Ok(_) => zapier_token_input.set(String::new()),
Err(err) => {
settings_error.set(format!("Vault error: {err}"));
tools_loaded.set(true);
return;
}
}
match crate::vault::get_secret_required("openai_api_key") {
Ok(Some(secret)) if !secret.trim().is_empty() => {
openai_api_key_input.set(secret);
}
Ok(_) => {
let fallback = config
.openai
.as_ref()
.and_then(|openai| openai.api_key.clone())
.unwrap_or_default();
openai_api_key_input.set(fallback);
}
Err(err) => {
settings_error.set(format!("Vault error: {err}"));
tools_loaded.set(true);
return;
}
}
search_provider.set("grok".to_string());
coding_api_key_input.set(openai_api_key_input());
match crate::vault::get_secret_required("search_internet_grok_api_key") {
Ok(Some(secret)) if !secret.trim().is_empty() => {
if secret.trim_start().starts_with("sk-") {
search_api_key_input.set(String::new());
search_api_key_status.set(
"Stored key looks like OpenAI. Please paste your Grok key."
.to_string(),
);
} else {
search_api_key_input.set(secret);
search_api_key_status.set("Stored in vault".to_string());
}
}
Ok(_) => {
search_api_key_input.set(String::new());
search_api_key_status.set("Not set".to_string());
}
Err(err) => {
settings_error.set(format!("Vault error: {err}"));
tools_loaded.set(true);
return;
}
}
if !*daemon_running.read() {
boot_status.set(
"Daemon is stopped. Start it to preload prompt + heartbeat.".to_string(),
);
} else if ui_autoboot_enabled() {
boot_ready.set(true);
boot_status.set("Initializing prompt + heartbeat in background...".to_string());
let client = daemon_request_client();
let url = format!("{}/preload_boot", daemon_url().trim_end_matches('/'));
let mut request = client
.post(&url)
.json(&PreloadBootRequest { user_id: user_id() });
let token_value = token();
if !token_value.trim().is_empty() {
request = request.header("authorization", format!("Bearer {token_value}"));
}
match request.send().await {
Ok(resp) if resp.status().is_success() => {
boot_status.set("Boot preload started in background...".to_string());
}
Ok(resp) => {
let status = resp.status();
boot_status.set(format!(
"Boot preload failed: HTTP {status}. Continuing without preload."
));
}
Err(err) => {
boot_status.set(format!(
"Boot preload error: {err}. Continuing without preload."
));
}
}
} else {
boot_ready.set(true);
boot_status.set(
"Daemon ready. Auto preload disabled for stability.".to_string(),
);
}
tools_loaded.set(true);
});
});
}
let on_save_config = {
let settings_error = settings_error.clone();
let settings_status = settings_status.clone();
let config_json_text = config_json_text.clone();
let wakeup_poll_seconds_input = wakeup_poll_seconds_input.clone();
let github_pat_input = github_pat_input.clone();
let zapier_token_input = zapier_token_input.clone();
let tpm_mode_input = tpm_mode_input.clone();
let openai_api_key_input = openai_api_key_input.clone();
let search_api_key_input = search_api_key_input.clone();
let solana_rpc_endpoint_input = solana_rpc_endpoint_input.clone();
let mcp_servers_form = mcp_servers_form.clone();
let http_call_servers_form = http_call_servers_form.clone();
let network_allow_form = network_allow_form.clone();
let db_path = db_path.clone();
let daemon_url = daemon_url.clone();
let token = token.clone();
use_callback(move |_: ()| {
let settings_error = settings_error.clone();
let settings_status = settings_status.clone();
let config_json_text = config_json_text.clone();
let wakeup_poll_seconds_input = wakeup_poll_seconds_input.clone();
let github_pat_input = github_pat_input.clone();
let zapier_token_input = zapier_token_input.clone();
let tpm_mode_input = tpm_mode_input.clone();
let openai_api_key_input = openai_api_key_input.clone();
let search_api_key_input = search_api_key_input.clone();
let solana_rpc_endpoint_input = solana_rpc_endpoint_input.clone();
let mcp_servers_form = mcp_servers_form.clone();
let http_call_servers_form = http_call_servers_form.clone();
let network_allow_form = network_allow_form.clone();
let db_path = db_path.clone();
let daemon_url = daemon_url.clone();
let token = token.clone();
spawn(async move {
let mut settings_error = settings_error;
let mut settings_status = settings_status;
let mut config_json_text = config_json_text;
settings_error.set(String::new());
settings_status.set(String::new());
let wakeup_poll_seconds = match wakeup_poll_seconds_input().trim().parse::<u64>() {
Ok(value) if value > 0 => value,
_ => {
settings_error
.set("Wakeup interval must be a number greater than 0.".to_string());
return;
}
};
let tpm_mode_value = match tpm_mode_input().trim() {
"strict" | "auto" | "compatible" => tpm_mode_input().trim().to_string(),
_ => {
settings_error
.set("TPM mode must be strict, auto, or compatible.".to_string());
return;
}
};
let openai_base_url_value = OPENAI_DEFAULT_BASE_URL.to_string();
let openai_model_value = OPENAI_DEFAULT_CHAT_MODEL.to_string();
let openai_summary_model_value = OPENAI_DEFAULT_SUMMARY_MODEL.to_string();
let openai_embedding_model_value = OPENAI_DEFAULT_EMBED_MODEL.to_string();
let openai_rerank_model_value = OPENAI_DEFAULT_RERANK_MODEL.to_string();
let mut mcp_servers = Vec::new();
for entry in mcp_servers_form().iter() {
let name = entry.name.trim();
let url = entry.url.trim();
let header_key = entry.header_key.trim();
let header_value = entry.header_value.trim();
let primary_header_key = entry.primary_header_key.trim();
if name.is_empty() && url.is_empty() {
continue;
}
if name.is_empty() || url.is_empty() {
settings_error
.set("Each MCP server needs both a name and URL.".to_string());
return;
}
if !url.starts_with("http://") && !url.starts_with("https://") {
settings_error.set(format!(
"MCP server URL must start with http:// or https:// ({url})."
));
return;
}
if (header_key.is_empty() && !header_value.is_empty())
|| (!header_key.is_empty() && header_value.is_empty())
{
settings_error.set(
"MCP header key and value must both be set or both be empty."
.to_string(),
);
return;
}
let mut headers = entry.headers.clone();
if !primary_header_key.is_empty() && primary_header_key != header_key {
headers.remove(primary_header_key);
}
if !header_key.is_empty() {
headers.insert(header_key.to_string(), header_value.to_string());
}
mcp_servers.push((name.to_string(), url.to_string(), headers));
}
let mut http_call_servers = Vec::new();
for entry in http_call_servers_form().iter() {
let name = entry.name.trim();
let url = entry.url.trim();
let header_key = entry.header_key.trim();
let header_value = entry.header_value.trim();
let primary_header_key = entry.primary_header_key.trim();
if name.is_empty() && url.is_empty() {
continue;
}
if name.is_empty() || url.is_empty() {
settings_error
.set("Each HTTP call server needs both a name and URL.".to_string());
return;
}
if !url.starts_with("http://") && !url.starts_with("https://") {
settings_error.set(format!(
"HTTP call server URL must start with http:// or https:// ({url})."
));
return;
}
if (header_key.is_empty() && !header_value.is_empty())
|| (!header_key.is_empty() && header_value.is_empty())
{
settings_error.set(
"HTTP call header key and value must both be set or both be empty."
.to_string(),
);
return;
}
let mut headers = entry.headers.clone();
if !primary_header_key.is_empty() && primary_header_key != header_key {
headers.remove(primary_header_key);
}
if !header_key.is_empty() {
headers.insert(header_key.to_string(), header_value.to_string());
}
http_call_servers.push((name.to_string(), url.to_string(), headers));
}
let network_allow = network_allow_form()
.iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.collect::<Vec<_>>();
let search_provider_value = "grok".to_string();
let solana_rpc_endpoint = solana_rpc_endpoint_input().trim().to_string();
let mut config = match crate::config::Config::from_store(&db_path) {
Ok(value) => value,
Err(err) => {
settings_error.set(format!("Failed to load current config: {err}"));
return;
}
};
config.provider = None;
let openai_cfg = config.openai.get_or_insert(crate::config::OpenAiConfig {
api_key: None,
model: None,
base_url: None,
});
openai_cfg.base_url = Some(openai_base_url_value);
openai_cfg.model = Some(openai_model_value);
openai_cfg.api_key = None;
let memory_cfg = config.memory.get_or_insert(crate::config::MemoryConfig {
enabled: Some(true),
sqlite_path: Some(db_path.clone()),
summary_model: None,
embedding_model: None,
rerank_model: None,
openai: None,
context_embed_enabled: Some(false),
summary_threshold: None,
retention_days: None,
});
memory_cfg.embedding_model = Some(openai_embedding_model_value);
memory_cfg.summary_model = Some(openai_summary_model_value);
memory_cfg.rerank_model = Some(openai_rerank_model_value);
memory_cfg.openai = None;
let tools_value = config
.tools
.get_or_insert_with(|| Value::Object(serde_json::Map::new()));
if !tools_value.is_object() {
*tools_value = Value::Object(serde_json::Map::new());
}
let Some(tools_obj) = tools_value.as_object_mut() else {
settings_error.set("Failed to update tools config.".to_string());
return;
};
let wakeup_cfg = tools_obj
.entry("wakeup")
.or_insert_with(|| Value::Object(serde_json::Map::new()));
if !wakeup_cfg.is_object() {
*wakeup_cfg = Value::Object(serde_json::Map::new());
}
if let Some(wakeup_obj) = wakeup_cfg.as_object_mut() {
wakeup_obj.insert(
"poll_seconds".to_string(),
Value::Number(serde_json::Number::from(wakeup_poll_seconds)),
);
}
let mcp_cfg = tools_obj
.entry("mcp")
.or_insert_with(|| Value::Object(serde_json::Map::new()));
if !mcp_cfg.is_object() {
*mcp_cfg = Value::Object(serde_json::Map::new());
}
if let Some(mcp_obj) = mcp_cfg.as_object_mut() {
mcp_obj.insert(
"servers".to_string(),
Value::Array(
mcp_servers
.into_iter()
.map(|(name, url, headers)| {
let mut server = serde_json::Map::new();
server.insert("name".to_string(), Value::String(name));
server.insert("url".to_string(), Value::String(url));
if !headers.is_empty() {
let headers = headers
.into_iter()
.map(|(key, value)| (key, Value::String(value)))
.collect::<serde_json::Map<_, _>>();
server
.insert("headers".to_string(), Value::Object(headers));
}
Value::Object(server)
})
.collect(),
),
);
}
let http_call_cfg = tools_obj
.entry("http_call")
.or_insert_with(|| Value::Object(serde_json::Map::new()));
if !http_call_cfg.is_object() {
*http_call_cfg = Value::Object(serde_json::Map::new());
}
if let Some(http_call_obj) = http_call_cfg.as_object_mut() {
let mut first_url: Option<String> = None;
let mut first_headers: Option<HashMap<String, String>> = None;
let servers = http_call_servers
.iter()
.map(|(name, url, headers)| {
if first_url.is_none() {
first_url = Some(url.clone());
}
if first_headers.is_none() {
first_headers = Some(headers.clone());
}
let mut server = serde_json::Map::new();
server.insert("name".to_string(), Value::String(name.clone()));
server.insert("url".to_string(), Value::String(url.clone()));
if !headers.is_empty() {
let headers = headers
.iter()
.map(|(key, value)| (key.clone(), Value::String(value.clone())))
.collect::<serde_json::Map<_, _>>();
server.insert("headers".to_string(), Value::Object(headers));
}
Value::Object(server)
})
.collect::<Vec<_>>();
http_call_obj.insert("servers".to_string(), Value::Array(servers));
let base_urls = http_call_servers
.iter()
.map(|(_, url, _)| Value::String(url.clone()))
.collect::<Vec<_>>();
http_call_obj.insert("base_urls".to_string(), Value::Array(base_urls));
if let Some(url) = first_url {
http_call_obj.insert("base_url".to_string(), Value::String(url));
} else {
http_call_obj.remove("base_url");
}
let legacy_headers = first_headers
.unwrap_or_default()
.into_iter()
.map(|(key, value)| (key, Value::String(value)))
.collect::<serde_json::Map<_, _>>();
http_call_obj.insert(
"custom_headers".to_string(),
Value::Object(legacy_headers.clone()),
);
http_call_obj
.insert("default_headers".to_string(), Value::Object(legacy_headers));
}
let search_cfg = tools_obj
.entry("search_internet")
.or_insert_with(|| Value::Object(serde_json::Map::new()));
if !search_cfg.is_object() {
*search_cfg = Value::Object(serde_json::Map::new());
}
if let Some(search_obj) = search_cfg.as_object_mut() {
search_obj.insert(
"provider".to_string(),
Value::String(search_provider_value.clone()),
);
search_obj.remove("api_key");
search_obj.remove("model");
}
let settings_cfg = tools_obj
.entry("settings")
.or_insert_with(|| Value::Object(serde_json::Map::new()));
if !settings_cfg.is_object() {
*settings_cfg = Value::Object(serde_json::Map::new());
}
if let Some(settings_obj) = settings_cfg.as_object_mut() {
let permissions = settings_obj
.entry("permissions")
.or_insert_with(|| Value::Object(serde_json::Map::new()));
if !permissions.is_object() {
*permissions = Value::Object(serde_json::Map::new());
}
if let Some(perms_obj) = permissions.as_object_mut() {
perms_obj.insert(
"network_allow".to_string(),
Value::Array(
network_allow
.into_iter()
.map(Value::String)
.collect::<Vec<_>>(),
),
);
}
let security_cfg = settings_obj
.entry("security")
.or_insert_with(|| Value::Object(serde_json::Map::new()));
if !security_cfg.is_object() {
*security_cfg = Value::Object(serde_json::Map::new());
}
if let Some(security_obj) = security_cfg.as_object_mut() {
security_obj.insert(
"tpm_mode".to_string(),
Value::String(tpm_mode_value.clone()),
);
}
let solana_cfg = settings_obj
.entry("solana")
.or_insert_with(|| Value::Object(serde_json::Map::new()));
if !solana_cfg.is_object() {
*solana_cfg = Value::Object(serde_json::Map::new());
}
if let Some(solana_obj) = solana_cfg.as_object_mut() {
let rpc_cfg = solana_obj
.entry("rpc")
.or_insert_with(|| Value::Object(serde_json::Map::new()));
if !rpc_cfg.is_object() {
*rpc_cfg = Value::Object(serde_json::Map::new());
}
if let Some(rpc_obj) = rpc_cfg.as_object_mut() {
rpc_obj
.insert("endpoint".to_string(), Value::String(solana_rpc_endpoint));
}
}
}
let github_pat = github_pat_input().trim().to_string();
std::env::set_var("BUTTERFLY_TPM_MODE", tpm_mode_value);
let zapier_token = zapier_token_input().trim().to_string();
let openai_api_key = openai_api_key_input().trim().to_string();
let search_api_key = search_api_key_input().trim().to_string();
let pretty = match serde_json::to_string_pretty(&config) {
Ok(value) => value,
Err(err) => {
settings_error.set(format!("Failed to serialize config: {err}"));
return;
}
};
let pretty_for_vault = pretty.clone();
let persist_secrets = tokio::task::spawn_blocking(move || -> Result<(), String> {
crate::vault::set_secret_required("github_pat", &github_pat)
.map_err(|err| format!("Failed to store GitHub token: {err}"))?;
crate::vault::set_secret_required("zapier_token", &zapier_token)
.map_err(|err| format!("Failed to store Zapier token: {err}"))?;
crate::vault::set_secret_required("openai_api_key", &openai_api_key)
.map_err(|err| format!("Failed to store OpenAI API key: {err}"))?;
crate::vault::set_secret_required("coding_openai_api_key", &openai_api_key)
.map_err(|err| format!("Failed to store coding API key: {err}"))?;
crate::vault::set_secret_required("search_internet_grok_api_key", &search_api_key)
.map_err(|err| format!("Failed to store search API key: {err}"))?;
crate::vault::set_secret_required("app_config_json", &pretty_for_vault)
.map_err(|err| format!("Failed to store config in keyring: {err}"))?;
Ok(())
})
.await;
match persist_secrets {
Ok(Ok(())) => {}
Ok(Err(err)) => {
settings_error.set(err);
return;
}
Err(err) => {
settings_error.set(format!("Failed to persist secrets: {err}"));
return;
}
}
let config_for_save = config.clone();
let db_path_for_save = db_path.clone();
let result = tokio::task::spawn_blocking(move || {
crate::config_store::save_config(&db_path_for_save, &config_for_save)
})
.await;
match result {
Ok(Ok(())) => {
config_json_text.set(pretty);
let client = daemon_request_client();
let url = format!("{}/reload_config", daemon_url().trim_end_matches('/'));
let mut request = client.post(url);
let token_value = token();
if !token_value.trim().is_empty() {
request =
request.header("authorization", format!("Bearer {token_value}"));
}
match request.send().await {
Ok(response) if response.status().is_success() => {
settings_status.set("Settings saved.".to_string());
}
Ok(response) => {
let status = response.status();
let text = response
.text()
.await
.unwrap_or_else(|_| "Unable to read error body".to_string());
settings_status.set(format!(
"Settings saved, but reload failed ({status}). Restart required. {text}"
));
}
Err(err) => {
settings_status.set(format!(
"Settings saved, but reload failed: {err}. Restart required."
));
}
}
}
Ok(Err(err)) => settings_error.set(format!("Save failed: {err}")),
Err(err) => settings_error.set(format!("Save failed: {err}")),
}
return;
});
})
};
let on_factory_reset_config = {
let settings_error = settings_error.clone();
let settings_status = settings_status.clone();
let settings_load_started = settings_load_started.clone();
let tools_loaded = tools_loaded.clone();
let daemon_url = daemon_url.clone();
let token = token.clone();
use_callback(move |_: ()| {
let settings_error = settings_error.clone();
let settings_status = settings_status.clone();
let settings_load_started = settings_load_started.clone();
let tools_loaded = tools_loaded.clone();
let daemon_url = daemon_url.clone();
let token = token.clone();
spawn(async move {
let mut settings_error = settings_error;
let mut settings_status = settings_status;
let mut settings_load_started = settings_load_started;
let mut tools_loaded = tools_loaded;
settings_error.set(String::new());
settings_status.set("Resetting config to defaults…".to_string());
match run_factory_reset_config_request(daemon_url(), token()).await {
Ok(response) => {
settings_status.set(response.message);
tools_loaded.set(false);
settings_load_started.set(false);
}
Err(err) => {
settings_error.set(format!("Factory reset failed: {err}"));
settings_status.set(String::new());
}
}
});
})
};
let on_validate_context = {
let context_text = context_text.clone();
let context_path = context_path.clone();
let context_status = context_status.clone();
let context_error = context_error.clone();
use_callback(move |_: ()| {
let context_text = context_text.clone();
let context_path = context_path.clone();
let context_status = context_status.clone();
let context_error = context_error.clone();
spawn(async move {
let mut context_status = context_status;
let mut context_error = context_error;
context_status.set(String::new());
context_error.set(String::new());
let source = context_path();
if source.trim().is_empty() {
context_error.set("Context file path is empty.".to_string());
return;
}
if is_url_source(&source) {
match load_markdown_source(&source).await {
Ok(text) if !text.trim().is_empty() => {
context_status.set("Context URL is reachable.".to_string())
}
Ok(_) => {
context_error.set("Context URL returned empty content.".to_string())
}
Err(err) => context_error.set(format!("Context URL error: {err}")),
}
return;
}
let content = context_text();
if content.trim().is_empty() {
context_error.set("Context markdown is empty.".to_string());
return;
}
context_status.set("Context markdown looks valid.".to_string());
});
})
};
let on_save_context = {
let context_text = context_text.clone();
let context_path = context_path.clone();
let context_status = context_status.clone();
let context_error = context_error.clone();
let db_path = db_path.clone();
let daemon_running = daemon_running.clone();
let daemon_url = daemon_url.clone();
let token = token.clone();
use_callback(move |_: ()| {
let context_text = context_text.clone();
let context_path = context_path.clone();
let context_status = context_status.clone();
let context_error = context_error.clone();
let db_path = db_path.clone();
let daemon_running = daemon_running.clone();
let daemon_url = daemon_url.clone();
let token = token.clone();
spawn(async move {
let mut context_status = context_status;
let mut context_error = context_error;
context_status.set(String::new());
context_error.set(String::new());
let source = context_path();
if source.trim().is_empty() {
context_error.set("Context source is empty.".to_string());
return;
}
if is_url_source(&source) {
context_error.set(
"Context source is a URL and cannot be saved in the DB editor.".to_string(),
);
return;
}
let content = context_text();
if content.trim().is_empty() {
context_error.set("Context markdown is empty.".to_string());
return;
}
if let Err(err) = save_markdown_source_to_store(
db_path.clone(),
"prompt",
crate::config::MarkdownSource::Database { markdown: content },
)
.await
{
context_error.set(format!("Failed to save context to DB: {err}"));
return;
}
if *daemon_running.read() {
let client = daemon_request_client();
let url = format!("{}/reload_config", daemon_url().trim_end_matches('/'));
let mut request = client.post(url);
let token_value = token();
if !token_value.trim().is_empty() {
request = request.header("authorization", format!("Bearer {token_value}"));
}
match request.send().await {
Ok(response) if response.status().is_success() => {
context_status.set("Context saved to DB and runtime reloaded.".to_string())
}
Ok(response) => {
context_status.set(format!(
"Context saved to DB. Reload failed (HTTP {}). Restart daemon to apply.",
response.status()
))
}
Err(err) => {
context_status.set(format!(
"Context saved to DB. Reload failed: {err}. Restart daemon to apply."
))
}
}
} else {
context_status.set("Context saved to DB.".to_string());
}
});
})
};
let on_validate_heartbeat = {
let heartbeat_text = heartbeat_text.clone();
let heartbeat_path = heartbeat_path.clone();
let heartbeat_status = heartbeat_status.clone();
let heartbeat_error = heartbeat_error.clone();
use_callback(move |_: ()| {
let heartbeat_text = heartbeat_text.clone();
let heartbeat_path = heartbeat_path.clone();
let heartbeat_status = heartbeat_status.clone();
let heartbeat_error = heartbeat_error.clone();
spawn(async move {
let mut heartbeat_status = heartbeat_status;
let mut heartbeat_error = heartbeat_error;
heartbeat_status.set(String::new());
heartbeat_error.set(String::new());
let source = heartbeat_path();
if source.trim().is_empty() {
heartbeat_error.set("Heartbeat path or URL is empty.".to_string());
return;
}
if is_url_source(&source) {
match load_markdown_source(&source).await {
Ok(text) if !text.trim().is_empty() => {
heartbeat_status.set("Heartbeat URL is reachable.".to_string())
}
Ok(_) => {
heartbeat_error.set("Heartbeat URL returned empty content.".to_string())
}
Err(err) => heartbeat_error.set(format!("Heartbeat URL error: {err}")),
}
return;
}
let content = heartbeat_text();
if content.trim().is_empty() {
heartbeat_error.set("Heartbeat markdown is empty.".to_string());
return;
}
heartbeat_status.set("Heartbeat markdown looks valid.".to_string());
});
})
};
let on_save_heartbeat = {
let heartbeat_text = heartbeat_text.clone();
let heartbeat_path = heartbeat_path.clone();
let heartbeat_status = heartbeat_status.clone();
let heartbeat_error = heartbeat_error.clone();
let db_path = db_path.clone();
let daemon_running = daemon_running.clone();
let daemon_url = daemon_url.clone();
let token = token.clone();
use_callback(move |_: ()| {
let heartbeat_text = heartbeat_text.clone();
let heartbeat_path = heartbeat_path.clone();
let heartbeat_status = heartbeat_status.clone();
let heartbeat_error = heartbeat_error.clone();
let db_path = db_path.clone();
let daemon_running = daemon_running.clone();
let daemon_url = daemon_url.clone();
let token = token.clone();
spawn(async move {
let mut heartbeat_status = heartbeat_status;
let mut heartbeat_error = heartbeat_error;
heartbeat_status.set(String::new());
heartbeat_error.set(String::new());
let source = heartbeat_path();
if source.trim().is_empty() {
heartbeat_error.set("Heartbeat source is empty.".to_string());
return;
}
if is_url_source(&source) {
heartbeat_error.set(
"Heartbeat source is a URL and cannot be saved in the DB editor."
.to_string(),
);
return;
}
let content = heartbeat_text();
if content.trim().is_empty() {
heartbeat_error.set("Heartbeat markdown is empty.".to_string());
return;
}
if let Err(err) = save_markdown_source_to_store(
db_path.clone(),
"heartbeat",
crate::config::MarkdownSource::Database { markdown: content },
)
.await
{
heartbeat_error.set(format!("Failed to save heartbeat to DB: {err}"));
return;
}
if *daemon_running.read() {
let client = daemon_request_client();
let url = format!("{}/reload_config", daemon_url().trim_end_matches('/'));
let mut request = client.post(url);
let token_value = token();
if !token_value.trim().is_empty() {
request = request.header("authorization", format!("Bearer {token_value}"));
}
match request.send().await {
Ok(response) if response.status().is_success() => {
heartbeat_status
.set("Heartbeat saved to DB and runtime reloaded.".to_string())
}
Ok(response) => {
heartbeat_status.set(format!(
"Heartbeat saved to DB. Reload failed (HTTP {}). Restart daemon to apply.",
response.status()
))
}
Err(err) => {
heartbeat_status.set(format!(
"Heartbeat saved to DB. Reload failed: {err}. Restart daemon to apply."
))
}
}
} else {
heartbeat_status.set("Heartbeat saved to DB.".to_string());
}
});
})
};
let active_tab_chat = active_tab.clone();
let active_tab_activity = active_tab.clone();
let active_tab_config = active_tab.clone();
let active_tab_context = active_tab.clone();
let active_tab_heartbeat = active_tab.clone();
legacy_markup! {
style { r#"
body {{
font-family: system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", sans-serif;
background: radial-gradient(1200px 800px at 20% -10%, rgba(120,119,198,0.35), transparent 60%),
radial-gradient(1000px 700px at 110% 10%, rgba(56,189,248,0.25), transparent 60%),
#0b1020;
color: #e5e7eb;
margin: 0;
overflow: hidden;
}}
.container {{ max-width: 980px; margin: 0 auto; padding: 10px; height: 100dvh; box-sizing: border-box; overflow: hidden; display: flex; flex-direction: column; gap: 10px; }}
.header {{
padding: 16px 20px;
background: rgba(17,24,39,0.55);
color: #e5e7eb;
display: flex; align-items: center; justify-content: space-between;
border-bottom: 1px solid rgba(255,255,255,0.08);
backdrop-filter: blur(18px) saturate(180%);
box-shadow: 0 8px 30px rgba(0,0,0,0.25);
border-radius: 14px;
}}
.nav {{
display: flex;
gap: 8px;
align-items: center;
justify-content: flex-end;
margin-left: auto;
flex-wrap: wrap;
}}
.nav > button {{
min-width: 0;
padding: 8px 14px;
}}
.nav button {{ background: rgba(255,255,255,0.08); }}
.nav button.active {{ background: rgba(99,102,241,0.6); }}
.nav-controls {{ display: flex; gap: 6px; margin-left: 8px; }}
.daemon-icon-btn {{
width: 36px;
height: 36px;
min-width: 36px;
padding: 0;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
line-height: 1;
background: rgba(255,255,255,0.12);
}}
.daemon-icon-btn:disabled {{ opacity: 0.45; }}
.daemon-trash-btn {{
margin-left: 6px;
background: rgba(239,68,68,0.30);
}}
.daemon-trash-btn:hover {{
background: rgba(239,68,68,0.50);
}}
.title {{ display: flex; align-items: center; }}
.title-logo {{ width: 30px; height: 30px; display: block; }}
.chat {{ flex: 1; min-height: 0; overflow-y: auto; padding: 20px; background: rgba(10,16,34,0.22); border: 1px solid rgba(255,255,255,0.08); border-radius: 16px; }}
.bubble {{
max-width: 72%;
padding: 12px 14px;
border-radius: 18px;
margin-bottom: 10px;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
line-height: 1.45;
background: rgba(255,255,255,0.10);
border: 1px solid rgba(255,255,255,0.12);
backdrop-filter: blur(14px) saturate(180%);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.08), 0 10px 30px rgba(0,0,0,0.18);
}}
.bubble.user {{ margin-left: auto; background: rgba(99,102,241,0.55); color: white; border-bottom-right-radius: 6px; }}
.bubble.bot {{ margin-right: auto; background: rgba(124,58,237,0.45); color: white; border-bottom-left-radius: 6px; }}
.bubble-content {{ margin-bottom: 6px; }}
.bubble-time {{ font-size: 11px; color: rgba(229,231,235,0.72); letter-spacing: 0.02em; }}
.bubble.user .bubble-time {{ text-align: right; }}
.bubble.bot .bubble-time {{ text-align: right; }}
.composer {{
padding: 16px 20px;
background: rgba(17,24,39,0.55);
border-top: 1px solid rgba(255,255,255,0.08);
display: flex; flex-direction: column; gap: 12px;
position: sticky; bottom: 0;
backdrop-filter: blur(18px) saturate(180%);
border-radius: 16px;
}}
.composer-row {{ display: flex; flex-direction: column; gap: 8px; }}
.composer-input {{ position: relative; display: flex; align-items: stretch; }}
textarea {{
flex: 1;
min-height: 52px;
max-height: 200px;
resize: vertical;
padding-right: 60px;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}}
label {{ display: block; font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: rgba(229,231,235,0.7); margin-bottom: 6px; }}
input, textarea, select {{
width: 100%; padding: 10px 12px; border-radius: 12px;
border: 1px solid rgba(255,255,255,0.12);
background: rgba(15,23,42,0.55);
color: #e5e7eb;
backdrop-filter: blur(12px) saturate(180%);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.06);
}}
input:focus, textarea:focus, select:focus {{
outline: none;
border-color: rgba(99,102,241,0.75);
background: rgba(15,23,42,0.9);
color: #e5e7eb;
}}
select {{
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background: rgba(15,23,42,0.9);
color: #e5e7eb;
padding-right: 36px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none'%3E%3Cpath d='M6 8l4 4 4-4' stroke='%23e5e7eb' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 14px 14px;
}}
select option {{
background: #0f172a;
color: #e5e7eb;
}}
.config-editor {{
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 13px;
line-height: 1.5;
background: rgba(2,6,23,0.6);
border: 1px solid rgba(148,163,184,0.35);
border-radius: 14px;
padding: 14px 16px;
min-height: 340px;
}}
.config-grid {{
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 14px;
align-items: stretch;
}}
.config-head {{
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 14px;
}}
.config-head label {{
margin-bottom: 6px;
line-height: 16px;
}}
.config-panel {{
display: flex;
flex-direction: column;
min-width: 0;
min-height: 420px;
height: 100%;
}}
.config-panel > textarea,
.config-panel > pre {{
height: 100%;
width: 100%;
}}
.config-panel > pre {{
margin: 0;
}}
.config-preview {{
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 13px;
line-height: 1.5;
background: rgba(2,6,23,0.65);
border: 1px solid rgba(148,163,184,0.35);
border-radius: 14px;
padding: 14px 16px;
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
box-sizing: border-box;
margin: 0;
}}
.config-editor {{
resize: none;
box-sizing: border-box;
height: 100%;
max-height: 100%;
}}
@media (max-width: 860px) {{
.config-grid {{ grid-template-columns: 1fr; }}
.config-head {{ grid-template-columns: 1fr; }}
.config-panel {{ min-height: 360px; }}
}}
.config-actions {{
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: center;
margin-top: 24px;
}}
.config-actions button {{
min-width: 140px;
}}
button {{
padding: 10px 18px; border-radius: 16px; border: 1px solid rgba(255,255,255,0.12);
background: rgba(99,102,241,0.55);
color: white; font-weight: 600; cursor: pointer;
backdrop-filter: blur(14px) saturate(180%);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.08), 0 10px 24px rgba(0,0,0,0.18);
transition: transform 0.08s ease, box-shadow 0.2s ease, background 0.2s ease;
}}
button:hover {{ background: rgba(99,102,241,0.7); }}
button:active {{ transform: translateY(1px); }}
button:disabled {{ opacity: 0.6; cursor: not-allowed; }}
.send {{
position: absolute;
right: 6px;
bottom: 6px;
height: 40px;
width: 40px;
min-width: 40px;
padding: 0;
border-radius: 10px;
display: flex; align-items: center; justify-content: center;
}}
.error {{ color: #fca5a5; font-weight: 600; padding: 8px 20px; background: rgba(17,24,39,0.55); backdrop-filter: blur(12px); }}
.hint {{ color: rgba(229,231,235,0.7); font-size: 12px; }}
.bubble pre {{ background: rgba(0,0,0,0.2); padding: 10px; border-radius: 10px; overflow-x: auto; }}
.bubble code {{ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }}
.bubble a {{ color: #e0e7ff; text-decoration: underline; }}
.bubble blockquote {{ border-left: 3px solid rgba(255,255,255,0.5); margin: 6px 0; padding-left: 10px; color: rgba(255,255,255,0.9); }}
.bubble ul, .bubble ol {{ padding-left: 20px; margin: 6px 0; }}
.bubble h1, .bubble h2, .bubble h3 {{ margin: 6px 0; font-weight: 700; }}
.settings {{ flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 16px; }}
.settings-card {{
background: rgba(17,24,39,0.55);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 16px;
padding: 16px;
backdrop-filter: blur(14px) saturate(180%);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.06), 0 12px 28px rgba(0,0,0,0.18);
}}
.tool-list {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px; }}
.tool-item {{ display: flex; align-items: center; gap: 10px; }}
.status {{ color: rgba(229,231,235,0.7); font-weight: 500; }}
.simple-settings {{
display: flex;
flex-direction: column;
gap: 18px;
}}
.simple-top {{
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}}
.simple-top > div {{
min-width: 0;
}}
.simple-top input {{
box-sizing: border-box;
}}
.simple-section {{
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 6px;
}}
.api-key-section {{
gap: 6px;
padding-top: 2px;
}}
.api-key-section input {{
padding: 7px 10px;
border-radius: 10px;
font-size: 13px;
}}
.simple-section-highlight {{
padding: 10px;
border-radius: 14px;
border: 1px solid rgba(59,130,246,0.65);
background: rgba(37,99,235,0.16);
}}
.simple-list {{
display: flex;
flex-direction: column;
gap: 10px;
}}
.simple-row {{
display: flex;
align-items: center;
gap: 10px;
}}
.simple-row input {{
margin: 0;
}}
.simple-row button {{
min-width: 108px;
padding: 10px 12px;
border-radius: 12px;
}}
.server-row {{
display: grid;
grid-template-columns: minmax(140px, 1fr) minmax(220px, 2fr) minmax(160px, 1fr) minmax(180px, 1fr) auto;
align-items: center;
gap: 10px;
}}
.server-row input,
.server-row button {{
width: 100%;
min-width: 0;
box-sizing: border-box;
}}
.simple-row .mcp-name {{
flex: 1;
}}
.simple-row .mcp-url {{
flex: 1;
}}
.simple-row .mcp-header-key {{
flex: 1;
}}
.simple-row .mcp-header-value {{
flex: 1;
}}
.simple-row .host-input {{
flex: 1;
}}
.network-row {{
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
}}
.network-row input,
.network-row button {{
width: 100%;
min-width: 0;
box-sizing: border-box;
}}
.network-row button {{
min-width: 108px;
}}
.simple-actions {{
display: flex;
justify-content: center;
gap: 14px;
margin-top: 2px;
}}
.add-host-actions {{
margin-top: 12px;
}}
@media (max-width: 1280px) {{
.server-row {{
grid-template-columns: minmax(180px, 1fr) minmax(220px, 1fr);
}}
.server-row button {{
grid-column: 1 / -1;
}}
}}
@media (max-width: 1024px) {{
.server-row {{
grid-template-columns: 1fr;
}}
.server-row button {{
grid-column: auto;
}}
.network-row {{
grid-template-columns: 1fr;
}}
}}
@media (max-width: 960px) {{
.header {{
padding: 12px 14px;
justify-content: space-between;
gap: 8px;
}}
.nav {{
width: auto;
gap: 6px;
justify-content: flex-end;
margin-left: auto;
flex-wrap: nowrap;
}}
.nav > button {{
flex: 0 0 auto;
}}
.nav-controls {{
width: auto;
margin-left: auto;
display: flex;
gap: 6px;
}}
.daemon-icon-btn {{
width: 34px;
height: 34px;
min-width: 34px;
border-radius: 999px;
}}
.daemon-trash-btn {{
margin-left: 4px;
}}
}}
@media (max-width: 640px) {{
.header {{
justify-content: space-between;
flex-wrap: wrap;
}}
.nav {{
width: 100%;
justify-content: flex-end;
margin-left: 0;
flex-wrap: wrap;
}}
.nav > button {{
flex: 0 0 auto;
}}
.nav-controls {{
width: auto;
margin-left: 8px;
display: flex;
}}
.daemon-icon-btn {{
width: 34px;
height: 34px;
min-width: 34px;
border-radius: 999px;
}}
.daemon-trash-btn {{
margin-left: 4px;
}}
}}
@media (max-width: 860px) {{
.simple-top {{ grid-template-columns: 1fr; }}
.simple-row {{ flex-direction: column; align-items: stretch; }}
.server-row {{ grid-template-columns: 1fr; }}
.simple-row .mcp-name {{ flex: 1 1 auto; }}
.simple-row .mcp-url {{ flex: 1 1 auto; }}
.simple-row .mcp-header-key {{ flex: 1 1 auto; }}
.simple-row .mcp-header-value {{ flex: 1 1 auto; }}
.simple-row button {{ width: 100%; }}
}}
"# }
div { class: "container",
div { class: "header",
div { class: "title",
img { class: "title-logo", src: APP_LOGO, alt: "Butterfly Bot" }
}
div { class: "nav",
button {
class: if *active_tab.read() == UiTab::Chat { "active" } else { "" },
onclick: move |_| {
let mut active_tab_chat = active_tab_chat.clone();
active_tab_chat.set(UiTab::Chat);
},
"Chat"
}
button {
class: if *active_tab.read() == UiTab::Activity { "active" } else { "" },
onclick: move |_| {
let mut active_tab_activity = active_tab_activity.clone();
active_tab_activity.set(UiTab::Activity);
},
"Activity"
}
button {
class: if *active_tab.read() == UiTab::Config { "active" } else { "" },
onclick: move |_| {
let mut active_tab_config = active_tab_config.clone();
active_tab_config.set(UiTab::Config);
},
"Config"
}
button {
class: if *active_tab.read() == UiTab::Context { "active" } else { "" },
onclick: move |_| {
let mut active_tab_context = active_tab_context.clone();
active_tab_context.set(UiTab::Context);
},
"Context"
}
button {
class: if *active_tab.read() == UiTab::Heartbeat { "active" } else { "" },
onclick: move |_| {
let mut active_tab_heartbeat = active_tab_heartbeat.clone();
active_tab_heartbeat.set(UiTab::Heartbeat);
},
"Heartbeat"
}
div { class: "nav-controls",
button {
class: "daemon-icon-btn",
title: "Start daemon",
onclick: move |_| on_daemon_start.call(()),
disabled: *daemon_running.read() || *daemon_starting.read(),
"▶"
}
button {
class: "daemon-icon-btn",
title: "Stop daemon",
onclick: move |_| on_daemon_stop.call(()),
disabled: !*daemon_running.read() || *daemon_starting.read(),
"⏹"
}
button {
class: "daemon-icon-btn daemon-trash-btn",
title: "Clear chat and activity history",
onclick: move |_| on_clear_histories.call(()),
"🗑"
}
}
}
}
if !error.read().is_empty() {
div { class: "error", "{error}" }
}
if *active_tab.read() == UiTab::Chat {
if !boot_status.read().is_empty() {
div { class: "hint", "{boot_status}" }
}
if !daemon_status.read().is_empty() {
div { class: "hint", "{daemon_status}" }
}
chat_timeline { messages: messages, busy: busy }
chat_composer {
busy: *busy.read(),
on_send: on_send_key,
}
}
if *active_tab.read() == UiTab::Config {
div { class: "settings",
if !*tools_loaded.read() {
div { class: "hint", "Loading config…" }
}
if *tools_loaded.read() {
div { class: "settings-card",
label { "Simple Settings" }
p { class: "hint", "Only essential settings are editable here." }
div { class: "simple-settings",
div { class: "simple-section simple-section-highlight api-key-section",
label { "OpenAI API Key (Required)" }
input {
r#type: "password",
value: "{openai_api_key_input}",
oninput: move |evt| {
let mut openai_api_key_input = openai_api_key_input.clone();
openai_api_key_input.set(evt.value());
},
placeholder: "Paste OpenAI API key",
}
p { class: "hint", "This key is used for router and coding." }
p { class: "hint", "Router: gpt-4.1-mini on api.openai.com. Memory uses OpenAI defaults." }
}
div { class: "simple-section api-key-section",
label { "Grok API Key for Internet Search" }
input {
r#type: "password",
value: "{search_api_key_input}",
oninput: move |evt| {
let mut search_api_key_input = search_api_key_input.clone();
search_api_key_input.set(evt.value());
},
placeholder: "Paste Grok API key",
}
}
div { class: "simple-top",
div {
label { "Wakeup Interval (seconds)" }
input {
r#type: "number",
min: "1",
value: "{wakeup_poll_seconds_input}",
oninput: move |evt| {
let mut wakeup_poll_seconds_input = wakeup_poll_seconds_input.clone();
wakeup_poll_seconds_input.set(evt.value());
},
}
}
div {
label { "GitHub Token" }
input {
r#type: "password",
value: "{github_pat_input}",
oninput: move |evt| {
let mut github_pat_input = github_pat_input.clone();
github_pat_input.set(evt.value());
},
placeholder: "Paste PAT",
}
}
div {
label { "Zapier Token" }
input {
r#type: "password",
value: "{zapier_token_input}",
oninput: move |evt| {
let mut zapier_token_input = zapier_token_input.clone();
zapier_token_input.set(evt.value());
},
placeholder: "Paste Zapier token",
}
}
}
div { class: "simple-top",
div {
label { "Solana RPC Endpoint" }
input {
value: "{solana_rpc_endpoint_input}",
oninput: move |evt| {
let mut solana_rpc_endpoint_input = solana_rpc_endpoint_input.clone();
solana_rpc_endpoint_input.set(evt.value());
},
placeholder: "https://...",
}
}
div {
label { "TPM Mode" }
select {
value: "{tpm_mode_input}",
onchange: move |evt| {
let selected = evt.value();
let normalized = match selected.as_str() {
"strict" | "auto" | "compatible" => selected,
_ => "auto".to_string(),
};
let mut tpm_mode_input = tpm_mode_input.clone();
tpm_mode_input.set(normalized);
},
option {
value: "strict",
"strict"
}
option {
value: "auto",
"auto"
}
option {
value: "compatible",
"compatible"
}
}
}
div {}
}
div { class: "simple-section",
label { "MCP Servers" }
div { class: "simple-list",
for (index, server) in mcp_servers_form.read().iter().enumerate() {
div { class: "simple-row server-row",
input {
class: "mcp-name",
value: "{server.name}",
placeholder: "Server name",
oninput: move |evt| {
let mut mcp_servers_form = mcp_servers_form.clone();
let mut list = mcp_servers_form();
if let Some(item) = list.get_mut(index) {
item.name = evt.value();
}
mcp_servers_form.set(list);
},
}
input {
class: "mcp-url",
value: "{server.url}",
placeholder: "https://server.example/mcp",
oninput: move |evt| {
let mut mcp_servers_form = mcp_servers_form.clone();
let mut list = mcp_servers_form();
if let Some(item) = list.get_mut(index) {
item.url = evt.value();
}
mcp_servers_form.set(list);
},
}
input {
class: "mcp-header-key",
value: "{server.header_key}",
placeholder: "Header key (optional)",
oninput: move |evt| {
let mut mcp_servers_form = mcp_servers_form.clone();
let mut list = mcp_servers_form();
if let Some(item) = list.get_mut(index) {
item.header_key = evt.value();
}
mcp_servers_form.set(list);
},
}
input {
class: "mcp-header-value",
value: "{server.header_value}",
placeholder: "Header value (optional)",
oninput: move |evt| {
let mut mcp_servers_form = mcp_servers_form.clone();
let mut list = mcp_servers_form();
if let Some(item) = list.get_mut(index) {
item.header_value = evt.value();
}
mcp_servers_form.set(list);
},
}
button {
onclick: move |_| {
let mut mcp_servers_form = mcp_servers_form.clone();
let mut list = mcp_servers_form();
if index < list.len() {
list.remove(index);
}
mcp_servers_form.set(list);
},
"Remove"
}
}
}
}
div { class: "simple-actions",
button {
onclick: move |_| {
let mut mcp_servers_form = mcp_servers_form.clone();
let mut list = mcp_servers_form();
list.push(UiMcpServer::default());
mcp_servers_form.set(list);
},
"+ Add MCP Server"
}
}
}
div { class: "simple-section",
label { "HTTP Call Servers" }
div { class: "simple-list",
for (index, server) in http_call_servers_form.read().iter().enumerate() {
div { class: "simple-row server-row",
input {
class: "mcp-name",
value: "{server.name}",
placeholder: "Server name",
oninput: move |evt| {
let mut http_call_servers_form = http_call_servers_form.clone();
let mut list = http_call_servers_form();
if let Some(item) = list.get_mut(index) {
item.name = evt.value();
}
http_call_servers_form.set(list);
},
}
input {
class: "mcp-url",
value: "{server.url}",
placeholder: "https://api.example.com/v1",
oninput: move |evt| {
let mut http_call_servers_form = http_call_servers_form.clone();
let mut list = http_call_servers_form();
if let Some(item) = list.get_mut(index) {
item.url = evt.value();
}
http_call_servers_form.set(list);
},
}
input {
class: "mcp-header-key",
value: "{server.header_key}",
placeholder: "Header key (optional)",
oninput: move |evt| {
let mut http_call_servers_form = http_call_servers_form.clone();
let mut list = http_call_servers_form();
if let Some(item) = list.get_mut(index) {
item.header_key = evt.value();
}
http_call_servers_form.set(list);
},
}
input {
class: "mcp-header-value",
value: "{server.header_value}",
placeholder: "Header value (optional)",
oninput: move |evt| {
let mut http_call_servers_form = http_call_servers_form.clone();
let mut list = http_call_servers_form();
if let Some(item) = list.get_mut(index) {
item.header_value = evt.value();
}
http_call_servers_form.set(list);
},
}
button {
onclick: move |_| {
let mut http_call_servers_form = http_call_servers_form.clone();
let mut list = http_call_servers_form();
if index < list.len() {
list.remove(index);
}
http_call_servers_form.set(list);
},
"Remove"
}
}
}
}
div { class: "simple-actions",
button {
onclick: move |_| {
let mut http_call_servers_form = http_call_servers_form.clone();
let mut list = http_call_servers_form();
list.push(UiHttpCallServer::default());
http_call_servers_form.set(list);
},
"+ Add HTTP Server"
}
}
}
div { class: "simple-section",
label { "Network Allow List" }
div { class: "simple-list",
for (index, host) in network_allow_form.read().iter().enumerate() {
div { class: "simple-row network-row",
input {
class: "host-input",
value: "{host}",
placeholder: "api.example.com",
oninput: move |evt| {
let mut network_allow_form = network_allow_form.clone();
let mut list = network_allow_form();
if let Some(item) = list.get_mut(index) {
*item = evt.value();
}
network_allow_form.set(list);
},
}
button {
onclick: move |_| {
let mut network_allow_form = network_allow_form.clone();
let mut list = network_allow_form();
if index < list.len() {
list.remove(index);
}
network_allow_form.set(list);
},
"Remove"
}
}
}
}
div { class: "simple-actions add-host-actions",
button {
onclick: move |_| {
let mut network_allow_form = network_allow_form.clone();
let mut list = network_allow_form();
list.push(String::new());
network_allow_form.set(list);
},
"+ Add Host"
}
}
}
div { class: "simple-section",
label { "Doctor" }
p { class: "hint", "Run diagnostics for provider health, security posture, and daemon readiness." }
div { class: "simple-actions",
button {
onclick: move |_| on_run_doctor.call(()),
disabled: *doctor_running.read(),
if *doctor_running.read() { "Running…" } else { "Run Doctor" }
}
}
if !doctor_status.read().is_empty() {
p { class: "hint", "{doctor_status}" }
}
if !doctor_overall.read().is_empty() {
p { class: "hint", "Overall: {doctor_overall}" }
}
if !doctor_error.read().is_empty() {
p { class: "error", "{doctor_error}" }
}
if !doctor_checks.read().is_empty() {
div { class: "simple-list",
for check in doctor_checks.read().iter() {
div { class: "simple-row",
div { class: "hint", "{check.name}: {check.status}" }
div { class: "hint", "{check.message}" }
if let Some(fix) = &check.fix_hint {
div { class: "hint", "Fix: {fix}" }
}
}
}
}
}
}
div { class: "simple-actions",
button {
onclick: move |_| on_save_config.call(()),
"Save Settings"
}
button {
onclick: move |_| on_factory_reset_config.call(()),
"Factory Reset Defaults"
}
}
}
}
if !settings_error.read().is_empty() {
div { class: "error", "{settings_error}" }
}
}
}
}
if *active_tab.read() == UiTab::Activity {
div { class: "settings",
div { class: "settings-card",
label { "Activity" }
p { class: "hint", "Background reminders, tool events, and autonomy updates appear here." }
}
div { class: "chat", id: "activity-scroll",
for message in activity_messages
.read()
.iter()
.filter(|msg| !msg.text.is_empty())
{
div {
class: "bubble bot",
div {
class: "bubble-content",
dangerous_inner_html: message.html.clone(),
}
div { class: "bubble-time", "{format_local_time(message.timestamp)}" }
}
}
if activity_messages.read().is_empty() {
div { class: "hint", "No activity yet." }
}
}
}
}
if *active_tab.read() == UiTab::Context {
div { class: "settings",
div { class: "settings-card",
label { "Context (Markdown)" }
p { class: "hint", "Source: {context_path}" }
div { class: "config-head",
label { "Editor" }
label { "Preview" }
}
div { class: "config-grid",
div { class: "config-panel",
textarea {
id: "context-md",
value: "{context_text}",
rows: "18",
class: "config-editor",
oninput: move |evt| {
let mut context_text = context_text.clone();
context_text.set(evt.value());
},
}
}
div { class: "config-panel",
div {
class: "config-preview",
dangerous_inner_html: "{markdown_to_html(&context_text.read())}",
}
}
}
div { class: "config-actions",
button { onclick: move |_| on_validate_context.call(()), "Validate" }
button {
disabled: is_url_source(&context_path.read()),
onclick: move |_| on_save_context.call(()),
"Save Context"
}
}
if is_url_source(&context_path.read()) {
p { class: "hint", "Remote URL sources are read-only." }
}
}
if !context_error.read().is_empty() {
div { class: "error", "{context_error}" }
}
}
}
if *active_tab.read() == UiTab::Heartbeat {
div { class: "settings",
div { class: "settings-card",
label { "Heartbeat (Markdown)" }
p { class: "hint", "Source: {heartbeat_path}" }
div { class: "config-head",
label { "Editor" }
label { "Preview" }
}
div { class: "config-grid",
div { class: "config-panel",
textarea {
id: "heartbeat-md",
value: "{heartbeat_text}",
rows: "18",
class: "config-editor",
oninput: move |evt| {
let mut heartbeat_text = heartbeat_text.clone();
heartbeat_text.set(evt.value());
},
}
}
div { class: "config-panel",
div {
class: "config-preview",
dangerous_inner_html: "{markdown_to_html(&heartbeat_text.read())}",
}
}
}
div { class: "config-actions",
button { onclick: move |_| on_validate_heartbeat.call(()), "Validate" }
button {
disabled: is_url_source(&heartbeat_path.read()),
onclick: move |_| on_save_heartbeat.call(()),
"Save Heartbeat"
}
}
if is_url_source(&heartbeat_path.read()) {
p { class: "hint", "Remote URL sources are read-only." }
}
}
if !heartbeat_error.read().is_empty() {
div { class: "error", "{heartbeat_error}" }
}
}
}
}
}
}
}