feat: add web UI for Obsidian vault browser
- 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:
@@ -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/projects", web::get().to(projects_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))?
|
||||
.run()
|
||||
@@ -301,8 +304,8 @@ pub async fn skills_handler(
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /memory/vault — generate Obsidian vault from memories
|
||||
pub async fn vault_handler(
|
||||
/// POST /memory/vault/generate — generate Obsidian vault from memories
|
||||
pub async fn vault_generate_handler(
|
||||
req: HttpRequest,
|
||||
state: web::Data<AppState>,
|
||||
) -> 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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user