Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
308 changes: 304 additions & 4 deletions src-tauri/Cargo.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ sha2 = "0.10"
base64 = "0.22"
rand = "0.8"
url = "2"
serenity = { version = "0.12", default-features = false, features = ["client", "gateway", "model", "rustls_backend", "cache"] }
tokio-util = "0.7"

[features]
custom-protocol = ["tauri/custom-protocol"]
203 changes: 202 additions & 1 deletion src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use tauri::State;
use crate::db::{Conversation, Db, Doc, Message, Notification, Routine};
use crate::embeddings;
use crate::error::{Error, Result};
use crate::integrations::{self, github, google, linear, notion, slack, telegram, whatsapp};
use crate::integrations::{self, discord, github, google, linear, notion, slack, telegram, whatsapp};
use crate::knowledge;
use crate::providers::{self, ChatTurn};
use crate::retrieval;
Expand Down Expand Up @@ -1011,3 +1011,204 @@ pub fn whatsapp_disconnect() -> Result<()> {
pub async fn whatsapp_send_message(to: String, text: String) -> Result<()> {
whatsapp::send_message(&to, &text).await
}

// --- Projects ----------------------------------------------------------------

#[derive(Debug, Serialize, Deserialize)]
pub struct ProjectFile {
pub name: String,
pub path: String,
pub is_dir: bool,
}

#[tauri::command]
pub async fn project_list(db: State<'_, Db>) -> Result<Vec<crate::db::Project>> {
db.list_projects()
}

#[tauri::command]
pub async fn project_create(
db: State<'_, Db>,
name: String,
template: String,
path: String,
) -> Result<crate::db::Project> {
// Create the directory structure based on template
let project_path = std::path::Path::new(&path);
std::fs::create_dir_all(project_path).map_err(|e| Error::Provider(e.to_string()))?;

match template.as_str() {
"coding" => {
std::fs::write(project_path.join("README.md"), format!("# {name}\n\nProject description here.\n")).ok();
std::fs::write(project_path.join(".gitignore"), "target/\nnode_modules/\n.env\n*.lock\ndist/\n").ok();
std::fs::create_dir_all(project_path.join("src")).ok();
}
"research" => {
let paper = format!("# {name}\n\n## Abstract\n\n## Introduction\n\n## Literature Review\n\n## Methodology\n\n## Results\n\n## Discussion\n\n## Conclusion\n\n## References\n");
std::fs::write(project_path.join("paper.md"), paper).ok();
std::fs::write(project_path.join("notes.md"), "# Research Notes\n\n").ok();
std::fs::write(project_path.join("references.md"), "# References\n\n| # | Title | Authors | Year | DOI | Notes |\n|---|-------|---------|------|-----|-------|\n").ok();
std::fs::create_dir_all(project_path.join("data")).ok();
std::fs::create_dir_all(project_path.join("figures")).ok();
}
_ => {
std::fs::write(project_path.join("README.md"), format!("# {name}\n\n")).ok();
}
}

let id = db.create_project(&name, &template, &path)?;
Ok(crate::db::Project {
id,
name,
template,
path,
created_at: chrono::Utc::now().to_rfc3339(),
})
}

#[tauri::command]
pub async fn project_delete(db: State<'_, Db>, id: i64) -> Result<()> {
db.delete_project(id)
}

#[tauri::command]
pub async fn project_open_in_editor(path: String) -> Result<()> {
// Try VS Code first, then Cursor, then system default
let editors = ["code", "cursor", "zed"];
for editor in &editors {
if std::process::Command::new(editor).arg(&path).spawn().is_ok() {
return Ok(());
}
}
open::that(&path).map_err(|e| Error::Provider(e.to_string()))?;
Ok(())
}

#[tauri::command]
pub async fn project_list_files(project_id: i64, db: State<'_, Db>) -> Result<Vec<ProjectFile>> {
let projects = db.list_projects()?;
let Some(project) = projects.iter().find(|p| p.id == project_id) else {
return Ok(vec![]);
};
let root = std::path::Path::new(&project.path);
let mut files = Vec::new();
collect_files(root, root, &mut files, 0)?;
Ok(files)
}

fn collect_files(
root: &std::path::Path,
dir: &std::path::Path,
out: &mut Vec<ProjectFile>,
depth: usize,
) -> Result<()> {
if depth > 4 {
return Ok(());
}
let Ok(entries) = std::fs::read_dir(dir) else {
return Ok(());
};
let mut entries: Vec<_> = entries.filter_map(|e| e.ok()).collect();
entries.sort_by_key(|e| {
let is_file = e.file_type().map(|t| t.is_file()).unwrap_or(false);
(is_file as u8, e.file_name())
});
for entry in entries {
let entry_path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') && name != ".gitignore" {
continue;
}
let rel = entry_path.strip_prefix(root).unwrap_or(&entry_path);
let is_dir = entry_path.is_dir();
out.push(ProjectFile {
name: name.clone(),
path: rel.to_string_lossy().to_string(),
is_dir,
});
if is_dir {
collect_files(root, &entry_path, out, depth + 1)?;
}
}
Ok(())
}

#[tauri::command]
pub async fn project_read_file(project_id: i64, path: String, db: State<'_, Db>) -> Result<String> {
let projects = db.list_projects()?;
let Some(project) = projects.iter().find(|p| p.id == project_id) else {
return Err(Error::Provider("Project not found".into()));
};
let full_path = std::path::Path::new(&project.path).join(&path);
std::fs::read_to_string(&full_path).map_err(|e| Error::Provider(e.to_string()))
}

#[tauri::command]
pub async fn project_write_file(project_id: i64, path: String, content: String, db: State<'_, Db>) -> Result<()> {
let projects = db.list_projects()?;
let Some(project) = projects.iter().find(|p| p.id == project_id) else {
return Err(Error::Provider("Project not found".into()));
};
let full_path = std::path::Path::new(&project.path).join(&path);
if let Some(parent) = full_path.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(&full_path, content).map_err(|e| Error::Provider(e.to_string()))
}

// --- Discord -----------------------------------------------------------------

#[tauri::command]
pub async fn discord_set_token(token: String) -> Result<()> {
discord::set_token(&token)
}

#[tauri::command]
pub async fn discord_disconnect() -> Result<()> {
discord::disconnect()
}

// --- Fathom post-meeting processing -----------------------------------------

#[tauri::command]
pub async fn fathom_process_recent_meeting(db: State<'_, Db>) -> Result<String> {
use crate::integrations::fathom;

let provider = db.get_setting("provider")?.unwrap_or_else(|| "ollama".into());
let model = db.get_setting("model")?.unwrap_or_default();
let ollama_host = db.get_setting("ollama_host")?.unwrap_or_else(|| providers::DEFAULT_OLLAMA_HOST.into());
let api_key = secrets::get_api_key(&provider)?;

if !fathom::is_connected().unwrap_or(false) {
return Err(Error::Provider("Fathom not connected".into()));
}
let meetings = fathom::list_recent_meetings(1).await?;
let Some(meeting) = meetings.first() else {
return Err(Error::Provider("No recent Fathom meetings found".into()));
};

let title = meeting.title.as_deref().unwrap_or("Meeting");
let summary = meeting.summary.as_deref().unwrap_or("(no summary)");

let turns = vec![
ChatTurn {
role: "system".into(),
content: "You are Donna, a proactive personal assistant. Analyze this meeting summary and produce: 1) Key decisions, 2) Action items with owners, 3) Follow-up emails to draft, 4) Things to add to the knowledge base. Use Markdown.".into(),
},
ChatTurn {
role: "user".into(),
content: format!("## Meeting: {title}\n\n## Summary\n{summary}"),
},
];

let content = providers::complete(&provider, &model, api_key, &ollama_host, &turns).await?;
let doc_title = format!("Post-Meeting: {title}");
let doc_id = crate::docs::create(&db, &doc_title, "fathom_post_meeting", &content)?;
db.insert_notification(
&format!("Meeting processed: {title}"),
"Donna has analysed your meeting and created action items.",
Some("open_doc"),
Some(doc_id),
)?;
Ok(content)
}
61 changes: 61 additions & 0 deletions src-tauri/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ pub struct Notification {
pub created_at: String,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Project {
pub id: i64,
pub name: String,
pub template: String,
pub path: String,
pub created_at: String,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Doc {
pub id: i64,
Expand Down Expand Up @@ -211,6 +220,16 @@ impl Db {
Some(30),
"Prepare a short briefing for an upcoming meeting: context on attendees, related knowledge, and suggested talking points.",
),
(
"post_meeting_debrief",
"Post-Meeting Debrief",
"after_meeting",
0,
5,
None,
Some(10),
"After a meeting ends, pull the Fathom summary and create action items, follow-ups, and a knowledge base update.",
),
];
for (builtin_id, name, schedule_type, hour, minute, day_of_week, minutes_before, prompt) in
builtins
Expand Down Expand Up @@ -496,6 +515,41 @@ impl Db {
conn.execute("DELETE FROM kg_embeddings", [])?;
Ok(())
}

// --- Projects ------------------------------------------------------------

pub fn create_project(&self, name: &str, template: &str, path: &str) -> Result<i64> {
let conn = self.0.lock().unwrap();
let now = now_iso();
conn.execute(
"INSERT INTO projects (name, template, path, created_at) VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![name, template, path, now],
)?;
Ok(conn.last_insert_rowid())
}

pub fn list_projects(&self) -> Result<Vec<Project>> {
let conn = self.0.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, name, template, path, created_at FROM projects ORDER BY id DESC",
)?;
let rows = stmt.query_map([], |row| {
Ok(Project {
id: row.get(0)?,
name: row.get(1)?,
template: row.get(2)?,
path: row.get(3)?,
created_at: row.get(4)?,
})
})?;
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
}

pub fn delete_project(&self, id: i64) -> Result<()> {
let conn = self.0.lock().unwrap();
conn.execute("DELETE FROM projects WHERE id = ?1", [id])?;
Ok(())
}
}

fn migrate(conn: &Connection) -> Result<()> {
Expand Down Expand Up @@ -560,6 +614,13 @@ fn migrate(conn: &Connection) -> Result<()> {
CREATE TABLE IF NOT EXISTS kg_embeddings (
node_key TEXT PRIMARY KEY,
vector TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
template TEXT NOT NULL,
path TEXT NOT NULL,
created_at TEXT NOT NULL
);",
)?;
Ok(())
Expand Down
28 changes: 28 additions & 0 deletions src-tauri/src/integrations/discord.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//! Discord bot integration — Donna as a bot in any server/channel.
//!
//! Provides token storage and status checking. A full live event loop (serenity
//! Client) can be added later; for now we just persist the token so the
//! Integrations Hub can show a "connected" state.

use crate::error::Result;
use crate::secrets;

const SECRET_KEY: &str = "discord_bot_token";

pub fn is_connected() -> Result<bool> {
secrets::has_secret(SECRET_KEY)
}

pub fn set_token(token: &str) -> Result<()> {
secrets::set_secret(SECRET_KEY, token)?;
Ok(())
}

pub fn disconnect() -> Result<()> {
secrets::delete_secret(SECRET_KEY)?;
Ok(())
}

pub fn get_token() -> Result<Option<String>> {
secrets::get_secret(SECRET_KEY)
}
7 changes: 7 additions & 0 deletions src-tauri/src/integrations/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
//! Each connector owns its auth (OAuth tokens or API keys, stored in the OS keychain)
//! and the actions Donna can take.

pub mod discord;
pub mod fathom;
pub mod github;
pub mod google;
Expand Down Expand Up @@ -77,5 +78,11 @@ pub fn status() -> Result<Vec<IntegrationStatus>> {
connected: whatsapp::is_connected()?,
needs_setup: false,
},
IntegrationStatus {
id: "discord".into(),
name: "Discord".into(),
connected: discord::is_connected().unwrap_or(false),
needs_setup: false,
},
])
}
16 changes: 16 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ pub fn run() {

Ok(())
})
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
window.hide().unwrap();
api.prevent_close();
}
})
.invoke_handler(tauri::generate_handler![
commands::get_config,
commands::save_config,
Expand Down Expand Up @@ -135,6 +141,16 @@ pub fn run() {
commands::whatsapp_set_credentials,
commands::whatsapp_disconnect,
commands::whatsapp_send_message,
commands::project_list,
commands::project_create,
commands::project_delete,
commands::project_open_in_editor,
commands::project_list_files,
commands::project_read_file,
commands::project_write_file,
commands::discord_set_token,
commands::discord_disconnect,
commands::fathom_process_recent_meeting,
])
.run(tauri::generate_context!())
.expect("error while running Donna");
Expand Down
Loading
Loading