use axum::{
extract::Path,
response::Html,
routing::{get, post},
Router,
};
use std::net::SocketAddr;
pub mod templates;
use crate::services::mail::{MailService, MailServiceImpl};
use crate::services::kb::{KnowledgeBaseService, KnowledgeBaseServiceImpl};
use crate::services::kb::domain::LuhmannId;
use crate::storage::{memory::InMemoryStorage, postgres::PostgresStorage};
pub async fn run_web_server(
database_url: Option<String>,
host: String,
port: u16,
) -> anyhow::Result<()> {
let app = create_router(database_url);
let addr: SocketAddr = format!("{}:{}", host, port).parse()?;
println!("🌐 Starting web server on http://{}", addr);
println!("📱 Open your browser and navigate to http://{}", addr);
println!("Press Ctrl+C to stop");
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
fn create_router(database_url: Option<String>) -> Router {
use std::sync::Arc;
let db_url = Arc::new(database_url.clone());
let db_url2 = Arc::new(database_url.clone());
let db_url3 = Arc::new(database_url.clone());
let db_url4 = Arc::new(database_url.clone());
let db_url5 = Arc::new(database_url.clone());
let db_url6 = Arc::new(database_url.clone());
Router::new()
.route("/", get({
let db = db_url.clone();
move || dashboard((*db).clone())
}))
.route("/agents", get({
let db = db_url.clone();
move || list_agents((*db).clone())
}))
.route("/mail/inbox/{agent_id}", get({
let db = db_url2.clone();
move |Path(agent_id): Path<String>| inbox_view((*db).clone(), agent_id)
}))
.route("/agents/{agent_id}/status", post({
let db = db_url3.clone();
move |Path(agent_id): Path<String>| set_agent_status((*db).clone(), agent_id)
}))
.route("/kb", get({
let db = db_url4.clone();
move || kb_list_notes((*db).clone())
}))
.route("/kb/note/{note_id}", get({
let db = db_url5.clone();
move |Path(note_id): Path<String>| kb_view_note((*db).clone(), note_id)
}))
.route("/kb/tree/{prefix}", get({
let db = db_url6.clone();
move |Path(prefix): Path<String>| kb_tree_view((*db).clone(), prefix)
}))
.route("/static/style.css", get(|| async {
([("content-type", "text/css")], templates::CSS)
}))
}
async fn dashboard(database_url: Option<String>) -> Html<String> {
let agents = if let Some(url) = database_url {
let pool = match sqlx::postgres::PgPool::connect(&url).await {
Ok(p) => p,
Err(_) => return Html(templates::error_page("Failed to connect to database")),
};
let storage = PostgresStorage::new(pool);
let service = MailServiceImpl::new(storage);
match service.list_agents().await {
Ok(agents) => agents,
Err(_) => return Html(templates::error_page("Failed to load agents")),
}
} else {
let storage = InMemoryStorage::new();
let service = MailServiceImpl::new(storage);
match service.list_agents().await {
Ok(agents) => agents,
Err(_) => return Html(templates::error_page("Failed to load agents")),
}
};
let mut agent_cards = String::new();
for agent in &agents {
let status_class = match agent.status.as_str() {
"online" => "online",
"busy" => "busy",
_ => "offline",
};
let mailbox_list = format!(
r#"<div class="mailbox-item">
<a href="/mail/inbox/{}" class="btn btn-sm">📧 Inbox</a>
</div>"#,
agent.id
);
let status_button = if agent.status != "offline" {
format!(
"<button class=\"btn btn-sm btn-offline\" \
hx-post=\"/agents/{}/status\" \
hx-target=\"#agent-status-{}\" \
hx-swap=\"outerHTML\"> \
Set Offline \
</button>",
agent.id, agent.id
)
} else {
String::new()
};
agent_cards.push_str(&format!(
r#"<div class="agent-card">
<div class="agent-info">
<h3>{}</h3>
<span class="status {}" id="agent-status-{}">{}</span>
{}
</div>
<div class="agent-mailboxes">
<h4>Mailboxes</h4>
{}
</div>
</div>"#,
agent.name, status_class, agent.id, agent.status, status_button, mailbox_list
));
}
let content = format!(
r#"
<h2>Dashboard <span class="section-count">{} agents</span></h2>
<div class="agent-list">
{}
</div>
"#,
agents.len(),
if agent_cards.is_empty() {
"<p class='empty-state'>No agents registered yet</p>".to_string()
} else {
agent_cards
}
);
Html(templates::wrap_content(content))
}
async fn list_agents(database_url: Option<String>) -> Html<String> {
let agents = if let Some(url) = database_url {
let pool = match sqlx::postgres::PgPool::connect(&url).await {
Ok(p) => p,
Err(_) => return Html(templates::error_page("Failed to connect to database")),
};
let storage = PostgresStorage::new(pool);
let service = MailServiceImpl::new(storage);
match service.list_agents().await {
Ok(agents) => agents,
Err(_) => return Html(templates::error_page("Failed to load agents")),
}
} else {
let storage = InMemoryStorage::new();
let service = MailServiceImpl::new(storage);
match service.list_agents().await {
Ok(agents) => agents,
Err(_) => return Html(templates::error_page("Failed to load agents")),
}
};
let mut agent_rows = String::new();
for agent in &agents {
let status_class = match agent.status.as_str() {
"online" => "online",
"busy" => "busy",
_ => "offline",
};
agent_rows.push_str(&format!(
r#"<tr>
<td><strong>{}</strong></td>
<td><span class="status {}">{}</span></td>
</tr>"#,
agent.name, status_class, agent.status
));
}
let content = format!(
r#"
<h2>Agents <span class="section-count">{} total</span></h2>
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{}
</tbody>
</table>
"#,
agents.len(),
if agent_rows.is_empty() {
"<tr><td colspan=\"2\" class=\"empty-state\">No agents registered</td></tr>".to_string()
} else {
agent_rows
}
);
Html(templates::wrap_content(content))
}
async fn kb_list_notes(database_url: Option<String>) -> Html<String> {
let notes = if let Some(url) = database_url {
let pool = match sqlx::postgres::PgPool::connect(&url).await {
Ok(p) => p,
Err(_) => return Html(templates::error_page("Failed to connect to database")),
};
let storage = PostgresStorage::new(pool);
let service = KnowledgeBaseServiceImpl::new(storage);
match service.list_notes().await {
Ok(notes) => notes,
Err(_) => return Html(templates::error_page("Failed to load notes")),
}
} else {
let storage = InMemoryStorage::new();
let service = KnowledgeBaseServiceImpl::new(storage);
match service.list_notes().await {
Ok(notes) => notes,
Err(_) => return Html(templates::error_page("Failed to load notes")),
}
};
let mut notes_html = String::new();
for note in ¬es {
notes_html.push_str(&format!(
r#"<div class="note-card">
<div class="note-header">
<span class="note-id"><a href="/kb/note/{}">[{}]</a></span>
<span class="note-title">{}</span>
</div>
<div class="note-preview">{}</div>
<div class="note-meta">
<a href="/kb/tree/{}" class="btn btn-sm">🌳 Tree</a>
</div>
</div>"#,
note.id,
note.id,
note.title,
¬e.content.chars().take(100).collect::<String>(),
note.id
));
}
let content = format!(
r#"
<div class="page-header">
<h2>📚 Knowledge Base</h2>
<div class="header-actions">
<span class="note-count">{} notes</span>
</div>
</div>
<div class="notes-list">
{}
</div>
"#,
notes.len(),
if notes_html.is_empty() {
"<p class='empty-state'>No notes yet. Use 'kb create' to add notes.</p>".to_string()
} else {
notes_html
}
);
Html(templates::wrap_content(content))
}
async fn kb_view_note(database_url: Option<String>, note_id: String) -> Html<String> {
let id = match LuhmannId::parse(¬e_id) {
Some(id) => id,
None => return Html(templates::error_page(&format!("Invalid Luhmann ID: {}", note_id))),
};
let (note, children, parent, links, backlinks) = if let Some(url) = database_url {
let pool = match sqlx::postgres::PgPool::connect(&url).await {
Ok(p) => p,
Err(_) => return Html(templates::error_page("Failed to connect to database")),
};
let storage = PostgresStorage::new(pool);
let service = KnowledgeBaseServiceImpl::new(storage);
let note = match service.get_note(&id).await {
Ok(n) => n,
Err(_) => return Html(templates::error_page(&format!("Note '{}' not found", note_id))),
};
let all_notes = match service.list_notes().await {
Ok(n) => n,
Err(_) => vec![],
};
let children: Vec<_> = all_notes.iter()
.filter(|n| n.id.parent().as_ref() == Some(&id))
.cloned()
.collect();
let parent = if let Some(parent_id) = id.parent() {
service.get_note(&parent_id).await.ok()
} else {
None
};
let links = match service.get_links(&id).await {
Ok(l) => {
let mut linked_notes = vec![];
for link in l {
if let Ok(target) = service.get_note(&link.to_note_id).await {
linked_notes.push(target);
}
}
linked_notes
},
Err(_) => vec![],
};
let ctx = match service.get_context(&id).await {
Ok(c) => c.backlinks,
Err(_) => vec![],
};
(note, children, parent, links, ctx)
} else {
let storage = InMemoryStorage::new();
let service = KnowledgeBaseServiceImpl::new(storage);
let note = match service.get_note(&id).await {
Ok(n) => n,
Err(_) => return Html(templates::error_page(&format!("Note '{}' not found", note_id))),
};
let all_notes = match service.list_notes().await {
Ok(n) => n,
Err(_) => vec![],
};
let children: Vec<_> = all_notes.iter()
.filter(|n| n.id.parent().as_ref() == Some(&id))
.cloned()
.collect();
let parent = if let Some(parent_id) = id.parent() {
service.get_note(&parent_id).await.ok()
} else {
None
};
let links = match service.get_links(&id).await {
Ok(l) => {
let mut linked_notes = vec![];
for link in l {
if let Ok(target) = service.get_note(&link.to_note_id).await {
linked_notes.push(target);
}
}
linked_notes
},
Err(_) => vec![],
};
let ctx = match service.get_context(&id).await {
Ok(c) => c.backlinks,
Err(_) => vec![],
};
(note, children, parent, links, ctx)
};
let mut relations_html = String::new();
if let Some(p) = parent {
relations_html.push_str(&format!(
r#"<div class="relation-section">
<h4>📁 Parent</h4>
<a href="/kb/note/{}" class="relation-link">[{}] {}</a>
</div>"#,
p.id, p.id, p.title
));
}
if !children.is_empty() {
relations_html.push_str(r#"<div class="relation-section"><h4>📂 Children</h4>"#);
for child in &children {
relations_html.push_str(&format!(
r#"<a href="/kb/note/{}" class="relation-link">└─ [{}] {}</a>"#,
child.id, child.id, child.title
));
}
relations_html.push_str("</div>");
}
if !links.is_empty() {
relations_html.push_str(r#"<div class="relation-section"><h4>🔗 Links To</h4>"#);
for link in &links {
relations_html.push_str(&format!(
r#"<a href="/kb/note/{}" class="relation-link">→ [{}] {}</a>"#,
link.id, link.id, link.title
));
}
relations_html.push_str("</div>");
}
if !backlinks.is_empty() {
relations_html.push_str(r#"<div class="relation-section"><h4>🔗 Backlinks</h4>"#);
for backlink in &backlinks {
relations_html.push_str(&format!(
r#"<a href="/kb/note/{}" class="relation-link">← [{}] {}</a>"#,
backlink.id, backlink.id, backlink.title
));
}
relations_html.push_str("</div>");
}
let content = format!(
r#"
<div class="note-detail">
<div class="note-breadcrumb">
<a href="/kb">📚 KB</a> / <span>[{}]</span>
</div>
<h2 class="note-title-large">[{}] {}</h2>
<div class="note-content-full">
{}
</div>
<div class="note-meta-bar">
<span>Created: {}</span>
<a href="/kb/tree/{}" class="btn btn-sm">🌳 View in Tree</a>
</div>
</div>
<div class="note-relationships">
{}
</div>
"#,
note_id,
note_id,
note.title,
note.content.replace("\n", "<br>"),
note.created_at.format("%Y-%m-%d %H:%M"),
note_id,
if relations_html.is_empty() {
"<p class='empty-state'>No relationships yet</p>".to_string()
} else {
relations_html
}
);
Html(templates::wrap_content(content))
}
async fn kb_tree_view(database_url: Option<String>, prefix: String) -> Html<String> {
let prefix_id = match LuhmannId::parse(&prefix) {
Some(id) => id,
None => return Html(templates::error_page(&format!("Invalid prefix: {}", prefix))),
};
let (notes_in_tree, parent_note) = if let Some(url) = database_url {
let pool = match sqlx::postgres::PgPool::connect(&url).await {
Ok(p) => p,
Err(_) => return Html(templates::error_page("Failed to connect to database")),
};
let storage = PostgresStorage::new(pool);
let service = KnowledgeBaseServiceImpl::new(storage);
let all_notes = match service.list_notes().await {
Ok(n) => n,
Err(_) => return Html(templates::error_page("Failed to load notes")),
};
let notes_in_tree: Vec<_> = all_notes.iter()
.filter(|n| n.id.to_string().starts_with(&prefix))
.cloned()
.collect();
let parent = if let Some(parent_id) = prefix_id.parent() {
service.get_note(&parent_id).await.ok()
} else {
None
};
(notes_in_tree, parent)
} else {
let storage = InMemoryStorage::new();
let service = KnowledgeBaseServiceImpl::new(storage);
let all_notes = match service.list_notes().await {
Ok(n) => n,
Err(_) => return Html(templates::error_page("Failed to load notes")),
};
let notes_in_tree: Vec<_> = all_notes.iter()
.filter(|n| n.id.to_string().starts_with(&prefix))
.cloned()
.collect();
let parent = if let Some(parent_id) = prefix_id.parent() {
service.get_note(&parent_id).await.ok()
} else {
None
};
(notes_in_tree, parent)
};
let mut tree_html = String::new();
if let Some(parent) = parent_note {
tree_html.push_str(&format!(
r#"<div class="tree-level parent-level">
<a href="/kb/note/{}" class="tree-node parent-node">📁 [{}] {}</a>
</div>"#,
parent.id, parent.id, parent.title
));
}
tree_html.push_str(r#"<div class="tree-level current-level">"#);
for note in ¬es_in_tree {
let is_current = note.id.to_string() == prefix;
let node_class = if is_current { "tree-node current-node" } else { "tree-node" };
let icon = if note.id.to_string().len() > prefix.len() { "📄" } else { "📂" };
tree_html.push_str(&format!(
r#"<a href="/kb/note/{}" class="{}">{} [{}] {}</a>"#,
note.id, node_class, icon, note.id, note.title
));
}
tree_html.push_str("</div>");
let content = format!(
r#"
<div class="tree-view">
<div class="tree-header">
<h2>🌳 Tree View: {}</h2>
<a href="/kb" class="btn btn-sm">← Back to All Notes</a>
</div>
<div class="tree-structure">
{}
</div>
<div class="tree-stats">
<span>{} notes in this branch</span>
</div>
</div>
"#,
prefix,
tree_html,
notes_in_tree.len()
);
Html(templates::wrap_content(content))
}
async fn set_agent_status(database_url: Option<String>, agent_id: String) -> Html<String> {
let result = if let Some(url) = database_url {
let pool = match sqlx::postgres::PgPool::connect(&url).await {
Ok(p) => p,
Err(_) => return Html(templates::error_page("Failed to connect to database")),
};
let storage = PostgresStorage::new(pool);
let service = MailServiceImpl::new(storage);
service.set_agent_status(agent_id, "offline").await
} else {
let storage = InMemoryStorage::new();
let service = MailServiceImpl::new(storage);
service.set_agent_status(agent_id, "offline").await
};
match result {
Ok(agent) => {
let status_class = "offline";
Html(format!(
r#"<span class="status {}" id="agent-status-{}">{}</span>"#,
status_class, agent.id, agent.status
))
}
Err(_) => Html(templates::error_page("Failed to update agent status")),
}
}
async fn inbox_view(database_url: Option<String>, agent_id: String) -> Html<String> {
let (inbox_mail, agent_name) = if let Some(url) = database_url {
let pool = match sqlx::postgres::PgPool::connect(&url).await {
Ok(p) => p,
Err(_) => return Html(templates::error_page("Failed to connect to database")),
};
let storage = PostgresStorage::new(pool);
let service = MailServiceImpl::new(storage);
let agent = match service.get_agent(agent_id.clone()).await {
Ok(a) => a,
Err(_) => return Html(templates::error_page(&format!("Agent '{}' not found", agent_id))),
};
let mailbox = match service.get_agent_mailbox(agent_id.clone()).await {
Ok(m) => m,
Err(_) => return Html(templates::error_page("Failed to get mailbox")),
};
let mail = match service.get_mailbox_inbox(mailbox.id).await {
Ok(m) => m,
Err(_) => vec![],
};
(mail, agent.name)
} else {
let storage = InMemoryStorage::new();
let service = MailServiceImpl::new(storage);
let agent = match service.get_agent(agent_id.clone()).await {
Ok(a) => a,
Err(_) => return Html(templates::error_page(&format!("Agent '{}' not found", agent_id))),
};
let mailbox = match service.get_agent_mailbox(agent_id.clone()).await {
Ok(m) => m,
Err(_) => return Html(templates::error_page("Failed to get mailbox")),
};
let mail = match service.get_mailbox_inbox(mailbox.id).await {
Ok(m) => m,
Err(_) => vec![],
};
(mail, agent.name)
};
let mail_html = inbox_mail.iter()
.map(|m| {
let status_class = if m.read { "read" } else { "unread" };
format!(
r#"<div class="mail-card {}">
<div class="mail-header">
<span class="mail-subject">{}</span>
<span class="mail-meta">{}</span>
</div>
<div class="mail-body">{}</div>
</div>"#,
status_class, m.subject, m.created_at.format("%Y-%m-%d %H:%M"), m.body
)
})
.collect::<String>();
let content = format!(
r#"
<div class="back-link">
<a href="/" class="btn btn-secondary btn-sm">← Back to Dashboard</a>
</div>
<h2>Inbox: {} <span class="section-count">{} messages</span></h2>
<div class="mail-list">
{}
</div>
"#,
agent_name,
inbox_mail.len(),
if mail_html.is_empty() {
"<p class='empty-state'>No mail in inbox</p>".to_string()
} else {
mail_html
}
);
Html(templates::wrap_content(content))
}