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/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("&", "&")
|
||||||
|
.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