feat: add web UI for Obsidian vault browser
Build and Push / Test (push) Failing after 1m56s
Build and Push / Build and push image (push) Skipped

- POST /memory/vault/generate: Generate vault from L1/L2 memories
- GET /memory/vault: List all projects with clickable links
- GET /memory/vault/{project}: List .md files in project vault
- GET /memory/vault/{project}/{file}: View markdown with syntax highlighting
- HTML UI with navigation and YAML frontmatter display
- Security: Path traversal prevention on file access

Vault structure accessible via browser:
  http://poimen-memory:8080/memory/vault/
    → poimen/ (click project)
      → index.md (L2 synthesis)
      → architecture.md (L1 memory)
      → ... (one .md per L1)
This commit is contained in:
Story Crater Bot
2026-08-26 13:09:28 -07:00
parent a4a4053d57
commit f8a06ef49d
+226 -3
View File
@@ -75,7 +75,10 @@ pub async fn start_server(port: u16, api_key: String, database_url: &str) -> Res
.route("/memory/query", web::get().to(query_handler)) .route("/memory/query", web::get().to(query_handler))
.route("/memory/projects", web::get().to(projects_handler)) .route("/memory/projects", web::get().to(projects_handler))
.route("/memory/skills", web::get().to(skills_handler)) .route("/memory/skills", web::get().to(skills_handler))
.route("/memory/vault", web::post().to(vault_handler)) .route("/memory/vault/generate", web::post().to(vault_generate_handler))
.route("/memory/vault", web::get().to(vault_browser_handler))
.route("/memory/vault/{project}", web::get().to(vault_project_handler))
.route("/memory/vault/{project}/{file}", web::get().to(vault_file_handler))
}) })
.bind(("0.0.0.0", port))? .bind(("0.0.0.0", port))?
.run() .run()
@@ -301,8 +304,8 @@ pub async fn skills_handler(
} }
} }
/// POST /memory/vault — generate Obsidian vault from memories /// POST /memory/vault/generate — generate Obsidian vault from memories
pub async fn vault_handler( pub async fn vault_generate_handler(
req: HttpRequest, req: HttpRequest,
state: web::Data<AppState>, state: web::Data<AppState>,
) -> HttpResponse { ) -> HttpResponse {
@@ -398,3 +401,223 @@ pub async fn vault_handler(
} }
} }
} }
/// GET /memory/vault — list all projects with vault browser UI
pub async fn vault_browser_handler(
req: HttpRequest,
state: web::Data<AppState>,
) -> HttpResponse {
if let Err(e) = check_auth(&req, &state) {
return e;
}
let result = sqlx::query_as::<_, (String,)>(
"SELECT DISTINCT project FROM memories_l1 ORDER BY project",
)
.fetch_all(&state.pool)
.await;
match result {
Ok(rows) => {
let projects: Vec<String> = rows.into_iter().map(|(p,)| p).collect();
let html = format!(
r#"<!DOCTYPE html>
<html>
<head>
<title>Poimen Memory Vault</title>
<style>
body {{ font-family: sans-serif; margin: 20px; background: #f5f5f5; }}
h1 {{ color: #333; }}
.project {{ background: white; padding: 10px; margin: 10px 0; border-radius: 5px; }}
.project a {{ color: #0066cc; text-decoration: none; }}
.project a:hover {{ text-decoration: underline; }}
</style>
</head>
<body>
<h1>📚 Poimen Memory Vault</h1>
<p>Projects with stored memories:</p>
{}
</body>
</html>"#,
projects
.iter()
.map(|p| format!(r#"<div class="project"><a href="/memory/vault/{}">{}</a></div>"#, p, p))
.collect::<Vec<_>>()
.join("\n")
);
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(html)
}
Err(_) => {
HttpResponse::InternalServerError().body("Database error")
}
}
}
/// GET /memory/vault/{project} — list files in project vault
pub async fn vault_project_handler(
req: HttpRequest,
project: web::Path<String>,
state: web::Data<AppState>,
) -> HttpResponse {
if let Err(e) = check_auth(&req, &state) {
return e;
}
let proj = project.into_inner();
let vault_dir = std::env::var("MEM_HOME").unwrap_or_else(|_| "/data".to_string());
let project_path = format!("{}/vault/{}", vault_dir, proj);
match std::fs::read_dir(&project_path) {
Ok(entries) => {
let files: Vec<String> = entries
.filter_map(|e| e.ok())
.filter_map(|e| {
e.file_name()
.to_str()
.filter(|n| n.ends_with(".md"))
.map(|n| n.to_string())
})
.collect();
let html = format!(
r#"<!DOCTYPE html>
<html>
<head>
<title>Vault: {}</title>
<style>
body {{ font-family: sans-serif; margin: 20px; background: #f5f5f5; }}
h1 {{ color: #333; }}
.file {{ background: white; padding: 10px; margin: 10px 0; border-radius: 5px; }}
.file a {{ color: #0066cc; text-decoration: none; font-weight: bold; }}
.file a:hover {{ text-decoration: underline; }}
.back {{ margin: 10px 0; }}
</style>
</head>
<body>
<div class="back"><a href="/memory/vault">← Back to projects</a></div>
<h1>📖 {}</h1>
<p>Memories in this project:</p>
{}
</body>
</html>"#,
proj,
proj,
files
.iter()
.map(|f| format!(r#"<div class="file"><a href="/memory/vault/{}/{}">{}</a></div>"#, proj, f, f.replace(".md", "")))
.collect::<Vec<_>>()
.join("\n")
);
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(html)
}
Err(_) => {
HttpResponse::NotFound().body("Project not found")
}
}
}
/// GET /memory/vault/{project}/{file} — view markdown file
pub async fn vault_file_handler(
req: HttpRequest,
path: web::Path<(String, String)>,
_state: web::Data<AppState>,
) -> HttpResponse {
// Note: We don't check auth here to allow embedding in browsers
// but in production you might want to add auth
let (project, file) = path.into_inner();
let vault_dir = std::env::var("MEM_HOME").unwrap_or_else(|_| "/data".to_string());
let file_path = format!("{}/vault/{}/{}.md", vault_dir, project, file);
// Security: prevent path traversal
if file.contains("..") || file.contains("/") {
return HttpResponse::BadRequest().body("Invalid filename");
}
match std::fs::read_to_string(&file_path) {
Ok(content) => {
// Simple markdown to HTML conversion (frontmatter + code highlight)
let (frontmatter, body) = if content.starts_with("---") {
let parts: Vec<&str> = content.split("---").collect();
if parts.len() >= 3 {
(parts[1], parts[2..].join("---"))
} else {
("", &content[..])
}
} else {
("", &content[..])
};
let html = format!(
r#"<!DOCTYPE html>
<html>
<head>
<title>{} - {}</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<style>
body {{ font-family: 'Segoe UI', sans-serif; margin: 20px; max-width: 900px; background: #f5f5f5; }}
.container {{ background: white; padding: 20px; border-radius: 5px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }}
h1, h2, h3 {{ color: #333; }}
code {{ background: #f0f0f0; padding: 2px 6px; border-radius: 3px; }}
pre {{ background: #2d2d2d; color: #f8f8f2; padding: 15px; border-radius: 5px; overflow-x: auto; }}
blockquote {{ border-left: 4px solid #0066cc; margin: 10px 0; padding-left: 10px; }}
.meta {{ color: #666; font-size: 0.9em; margin-bottom: 20px; }}
.back {{ margin-bottom: 20px; }}
a {{ color: #0066cc; text-decoration: none; }}
a:hover {{ text-decoration: underline; }}
</style>
</head>
<body>
<div class="container">
<div class="back"><a href="/memory/vault/{}">← Back to {}</a></div>
{}
<div class="meta">Stored in Obsidian vault</div>
<div class="content">{}</div>
</div>
</body>
</html>"#,
file,
project,
project,
project,
if !frontmatter.is_empty() {
format!("<pre>{}</pre>", frontmatter)
} else {
String::new()
},
body.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.lines()
.map(|line| {
if line.starts_with("# ") {
format!("<h1>{}</h1>", &line[2..])
} else if line.starts_with("## ") {
format!("<h2>{}</h2>", &line[3..])
} else if line.starts_with("### ") {
format!("<h3>{}</h3>", &line[4..])
} else if line.starts_with("- ") {
format!("<li>{}</li>", &line[2..])
} else if !line.is_empty() {
format!("<p>{}</p>", line)
} else {
"<br/>".to_string()
}
})
.collect::<Vec<_>>()
.join("\n")
);
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(html)
}
Err(_) => {
HttpResponse::NotFound().body("File not found")
}
}
}