use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::Json;
use chrono::Datelike;
use serde::{Deserialize, Serialize};
use crate::error::AppError;
use crate::server::AppState;
#[derive(Debug, Deserialize)]
pub(crate) struct KnowledgeTreeParams {
#[serde(default)]
pub dir: Option<String>,
}
#[derive(Debug, Serialize, Clone)]
pub(crate) struct KnowledgeTreeEntry {
pub name: String,
pub is_dir: bool,
pub size: u64,
}
#[derive(Debug, Deserialize)]
pub(crate) struct KnowledgeSearchBody {
pub query: String,
#[serde(default = "default_search_limit")]
pub limit: usize,
}
fn default_search_limit() -> usize {
20
}
#[derive(Debug, Serialize, Clone)]
pub(crate) struct KnowledgeSearchHit {
pub path: String,
pub name: String,
pub snippet: String,
pub backlink_count: usize,
pub name_similarity: i32,
}
#[derive(Debug, Deserialize)]
pub(crate) struct KnowledgeBacklinksParams {
pub path: String,
}
#[derive(Debug, Serialize, Clone)]
pub(crate) struct KnowledgeBacklink {
pub source_path: String,
pub link_text: String,
pub context: String,
}
#[derive(Debug, Serialize, Clone)]
pub(crate) struct KnowledgeGraphNode {
pub id: String,
pub label: String,
pub group: String,
}
#[derive(Debug, Serialize, Clone)]
pub(crate) struct KnowledgeGraphEdge {
pub source: String,
pub target: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub label: String,
}
#[derive(Debug, Serialize, Clone)]
pub(crate) struct KnowledgeGraph {
pub nodes: Vec<KnowledgeGraphNode>,
pub edges: Vec<KnowledgeGraphEdge>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct KnowledgeCopilotBody {
pub question: String,
pub context_path: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ChecklistItemsBody {
pub path: String,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ChecklistAddBody {
pub path: String,
pub item: String,
#[serde(default)]
pub checked: bool,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ChecklistCompleteBody {
pub path: String,
pub item_hash: String,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ChecklistRemoveBody {
pub path: String,
pub item_or_hash: String,
}
#[derive(Debug, Serialize)]
pub(crate) struct ChecklistItemsResponse {
pub items: Vec<String>,
pub incomplete: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ChatAppendBody {
pub message: String,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ChatDeleteBody {
pub msg_hash: String,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ChatMoveBody {
pub msg_hash: String,
pub target_path: String,
}
#[derive(Debug, Deserialize)]
pub(crate) struct JournalAddRecordBody {
pub record: String,
}
#[derive(Debug, Deserialize)]
pub(crate) struct JournalAddEmojiBody {
pub emoji: String,
}
#[derive(Debug, Serialize)]
pub(crate) struct JournalTodayResponse {
pub path: String,
}
#[derive(Debug, Deserialize)]
pub(crate) struct HabitsParams {
#[serde(default = "default_habits_year")]
pub year: Option<i32>,
}
fn default_habits_year() -> Option<i32> {
None
}
#[derive(Debug, Deserialize)]
pub(crate) struct KnowledgeConfigBody {
#[serde(default)]
pub language: Option<String>,
#[serde(default)]
pub timezone: Option<String>,
#[serde(default)]
pub move_to_commands: Option<Vec<String>>,
#[serde(default)]
pub pomodoro_duration_in_minutes: Option<i64>,
#[serde(default)]
pub schedules: Option<Vec<serde_json::Value>>,
#[serde(default)]
pub quick_commands: Option<Vec<String>>,
#[serde(default)]
pub two_emojis_enabled: Option<bool>,
#[serde(default)]
pub mode: Option<String>,
#[serde(default)]
pub quick_habits_enabled: Option<bool>,
#[serde(default)]
pub channels: Option<Vec<i64>>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ConvertHtmlBody {
pub md: String,
}
#[derive(Debug, Serialize)]
pub(crate) struct ConvertHtmlResponse {
pub html: String,
}
#[derive(Debug, Deserialize)]
pub(crate) struct EmojiQueryParams {
pub text: String,
}
#[derive(Debug, Serialize)]
pub(crate) struct EmojiResponse {
pub emoji: String,
}
#[derive(Debug, Serialize, Clone)]
pub(crate) struct KnowledgeCopilotResponse {
pub content: String,
pub referenced_notes: Vec<String>,
}
fn guess_knowledge_mime(path: &str) -> String {
match path.rsplit('.').next() {
Some("md") => "text/markdown; charset=utf-8".into(),
Some("json") => "application/json".into(),
Some("toml") => "application/toml".into(),
Some("yaml" | "yml") => "application/yaml".into(),
Some("txt") => "text/plain; charset=utf-8".into(),
Some("html") => "text/html".into(),
Some("css") => "text/css".into(),
Some("js") => "application/javascript".into(),
Some("png") => "image/png".into(),
Some("jpg" | "jpeg") => "image/jpeg".into(),
Some("gif") => "image/gif".into(),
Some("webp") => "image/webp".into(),
_ => "text/plain; charset=utf-8".into(),
}
}
pub(crate) async fn handle_knowledge_tree(
state: State<Arc<AppState>>,
Query(params): Query<KnowledgeTreeParams>,
) -> Result<Json<Vec<KnowledgeTreeEntry>>, AppError> {
let dir = params.dir.as_deref().unwrap_or("");
let entries = state
.kernel
.knowledge
.note_tree(dir)
.map_err(|e| AppError::Internal(e.to_string()))?;
let mut result: Vec<KnowledgeTreeEntry> = entries
.into_iter()
.filter(|e| !e.name.starts_with('.') && e.name != ".DS_Store")
.map(|e| KnowledgeTreeEntry {
name: e.name,
is_dir: e.is_dir,
size: 0, })
.collect();
result.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then(a.name.cmp(&b.name)));
Ok(Json(result))
}
pub(crate) async fn handle_knowledge_file_get(
state: State<Arc<AppState>>,
Path(path): Path<String>,
) -> Result<impl IntoResponse, AppError> {
let content = state
.kernel
.knowledge
.note_read(&path)
.map_err(|e| AppError::Internal(e.to_string()))?
.ok_or_else(|| AppError::NotFound("file not found".into()))?;
let mime = guess_knowledge_mime(&path);
Ok((
StatusCode::OK,
[(axum::http::header::CONTENT_TYPE, mime)],
content,
)
.into_response())
}
pub(crate) async fn handle_knowledge_file_put(
state: State<Arc<AppState>>,
Path(path): Path<String>,
body: String,
) -> Result<StatusCode, AppError> {
const MAX_KNOWLEDGE_FILE_SIZE: usize = 5 * 1024 * 1024;
if body.len() > MAX_KNOWLEDGE_FILE_SIZE {
return Err(AppError::PayloadTooLarge {
size: body.len(),
limit: MAX_KNOWLEDGE_FILE_SIZE,
});
}
state
.kernel
.knowledge
.note_write(&path, &body)
.map_err(|e| AppError::Internal(e.to_string()))?;
tracing::info!(path = %path, "Knowledge file written");
Ok(StatusCode::NO_CONTENT)
}
pub(crate) async fn handle_knowledge_file_delete(
state: State<Arc<AppState>>,
Path(path): Path<String>,
) -> Result<StatusCode, AppError> {
state
.kernel
.knowledge
.note_delete(&path)
.map_err(|e| AppError::Internal(e.to_string()))?;
tracing::info!(path = %path, "Knowledge file deleted");
Ok(StatusCode::NO_CONTENT)
}
pub(crate) async fn handle_knowledge_search(
state: State<Arc<AppState>>,
Json(body): Json<KnowledgeSearchBody>,
) -> Result<Json<serde_json::Value>, AppError> {
let hits = state
.kernel
.knowledge
.search(&body.query, body.limit)
.map_err(|e| AppError::Internal(e.to_string()))?;
let results: Vec<KnowledgeSearchHit> = hits
.into_iter()
.map(|h| KnowledgeSearchHit {
path: h.path,
name: h.name,
snippet: h.snippet,
backlink_count: h.backlink_count,
name_similarity: h.name_similarity,
})
.collect();
let count = results.len();
Ok(Json(serde_json::json!({
"results": results,
"count": count,
"query": body.query,
})))
}
pub(crate) async fn handle_knowledge_backlinks(
state: State<Arc<AppState>>,
Query(params): Query<KnowledgeBacklinksParams>,
) -> Result<Json<Vec<KnowledgeBacklink>>, AppError> {
let backlinks = state.knowledge.backlinks_for(¶ms.path);
let result: Vec<KnowledgeBacklink> = backlinks
.into_iter()
.map(|bl| KnowledgeBacklink {
source_path: bl.source_path,
link_text: bl.link_text,
context: format!("line {}", bl.line_number),
})
.collect();
Ok(Json(result))
}
pub(crate) async fn handle_knowledge_graph(
state: State<Arc<AppState>>,
) -> Result<Json<KnowledgeGraph>, AppError> {
let graph = state.knowledge.link_graph();
Ok(Json(KnowledgeGraph {
nodes: graph
.nodes
.into_iter()
.map(|n| KnowledgeGraphNode {
id: n.id,
label: n.label,
group: n.group,
})
.collect(),
edges: graph
.edges
.into_iter()
.map(|e| KnowledgeGraphEdge {
source: e.source,
target: e.target,
label: e.label,
})
.collect(),
}))
}
pub(crate) async fn handle_knowledge_copilot(
state: State<Arc<AppState>>,
Json(body): Json<KnowledgeCopilotBody>,
) -> Result<Json<KnowledgeCopilotResponse>, AppError> {
const MAX_QUESTION_SIZE: usize = 10 * 1024;
if body.question.len() > MAX_QUESTION_SIZE {
return Err(AppError::PayloadTooLarge {
size: body.question.len(),
limit: MAX_QUESTION_SIZE,
});
}
let result = state
.kernel
.knowledge_lens
.copilot_chat(
Arc::new(oxios_kernel::OxiEngineProvider::new(
"anthropic/claude-sonnet-4",
)),
"anthropic/claude-sonnet-4",
&body.question,
body.context_path.as_deref(),
)
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(Json(KnowledgeCopilotResponse {
content: result.content,
referenced_notes: result.referenced_notes,
}))
}
pub(crate) async fn handle_knowledge_checklist_items(
state: State<Arc<AppState>>,
Json(body): Json<ChecklistItemsBody>,
) -> Result<Json<ChecklistItemsResponse>, AppError> {
let (items, _completed) = state
.kernel
.knowledge
.checklist_items(&body.path)
.map_err(|e| AppError::Internal(e.to_string()))?;
let incomplete = state
.kernel
.knowledge
.checklist_incomplete(&body.path)
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(Json(ChecklistItemsResponse { items, incomplete }))
}
pub(crate) async fn handle_knowledge_checklist_add(
state: State<Arc<AppState>>,
Json(body): Json<ChecklistAddBody>,
) -> Result<StatusCode, AppError> {
state
.kernel
.knowledge
.checklist_add(&body.path, &body.item, body.checked)
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(StatusCode::NO_CONTENT)
}
pub(crate) async fn handle_knowledge_checklist_complete(
state: State<Arc<AppState>>,
Json(body): Json<ChecklistCompleteBody>,
) -> Result<Json<serde_json::Value>, AppError> {
let found = state
.kernel
.knowledge
.checklist_complete(&body.path, &body.item_hash)
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(Json(serde_json::json!({ "found": found })))
}
pub(crate) async fn handle_knowledge_checklist_remove(
state: State<Arc<AppState>>,
Json(body): Json<ChecklistRemoveBody>,
) -> Result<Json<serde_json::Value>, AppError> {
let found = state
.kernel
.knowledge
.checklist_remove(&body.path, &body.item_or_hash)
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(Json(serde_json::json!({ "found": found })))
}
pub(crate) async fn handle_knowledge_chat_append(
state: State<Arc<AppState>>,
Json(body): Json<ChatAppendBody>,
) -> Result<StatusCode, AppError> {
state
.kernel
.knowledge
.chat_append(&body.message)
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(StatusCode::NO_CONTENT)
}
pub(crate) async fn handle_knowledge_chat_messages(
state: State<Arc<AppState>>,
) -> Result<Json<Vec<String>>, AppError> {
let messages = state
.kernel
.knowledge
.chat_messages()
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(Json(messages))
}
pub(crate) async fn handle_knowledge_chat_delete(
state: State<Arc<AppState>>,
Json(body): Json<ChatDeleteBody>,
) -> Result<Json<serde_json::Value>, AppError> {
let deleted = state
.kernel
.knowledge
.chat_delete(&body.msg_hash)
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(Json(serde_json::json!({ "deleted": deleted })))
}
pub(crate) async fn handle_knowledge_chat_move(
state: State<Arc<AppState>>,
Json(body): Json<ChatMoveBody>,
) -> Result<Json<serde_json::Value>, AppError> {
let moved = state
.kernel
.knowledge
.chat_move_to(&body.msg_hash, &body.target_path)
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(Json(serde_json::json!({ "moved": moved })))
}
pub(crate) async fn handle_knowledge_journal_add(
state: State<Arc<AppState>>,
Json(body): Json<JournalAddRecordBody>,
) -> Result<StatusCode, AppError> {
state
.kernel
.knowledge
.journal_add_record(&body.record)
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(StatusCode::NO_CONTENT)
}
pub(crate) async fn handle_knowledge_journal_emoji(
state: State<Arc<AppState>>,
Json(body): Json<JournalAddEmojiBody>,
) -> Result<StatusCode, AppError> {
state
.kernel
.knowledge
.journal_add_emoji(&body.emoji)
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(StatusCode::NO_CONTENT)
}
pub(crate) async fn handle_knowledge_journal_today(
state: State<Arc<AppState>>,
) -> Result<Json<JournalTodayResponse>, AppError> {
let path = state.knowledge.journal_today_path();
Ok(Json(JournalTodayResponse { path }))
}
pub(crate) async fn handle_knowledge_habits(
state: State<Arc<AppState>>,
Query(params): Query<HabitsParams>,
) -> Result<Json<serde_json::Value>, AppError> {
let year = params.year.unwrap_or_else(|| chrono::Local::now().year());
let habits = state
.kernel
.knowledge
.habits(year)
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(Json(serde_json::to_value(habits).unwrap_or_default()))
}
pub(crate) async fn handle_knowledge_habits_last_week(
state: State<Arc<AppState>>,
) -> Result<Json<serde_json::Value>, AppError> {
let habits = state
.kernel
.knowledge
.habits_last_week()
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(Json(serde_json::to_value(habits).unwrap_or_default()))
}
pub(crate) async fn handle_knowledge_stats_today(
state: State<Arc<AppState>>,
) -> Result<Json<serde_json::Value>, AppError> {
let report = state
.kernel
.knowledge
.today_report()
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(Json(serde_json::to_value(report).unwrap_or_default()))
}
pub(crate) async fn handle_knowledge_stats_done_today(
state: State<Arc<AppState>>,
) -> Result<Json<serde_json::Value>, AppError> {
let entries = state
.kernel
.knowledge
.done_today()
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(Json(serde_json::json!({
"items": entries,
"count": entries.len(),
})))
}
pub(crate) async fn handle_knowledge_config_get(
state: State<Arc<AppState>>,
) -> Result<Json<serde_json::Value>, AppError> {
let config = state
.kernel
.knowledge
.config()
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(Json(serde_json::to_value(config).unwrap_or_default()))
}
pub(crate) async fn handle_knowledge_config_put(
state: State<Arc<AppState>>,
Json(body): Json<KnowledgeConfigBody>,
) -> Result<StatusCode, AppError> {
let mut config = state
.kernel
.knowledge
.config()
.map_err(|e| AppError::Internal(e.to_string()))?;
if let Some(v) = body.language {
config.language = v;
}
if let Some(v) = body.timezone {
config.timezone = v;
}
if let Some(v) = body.move_to_commands {
config.move_to_commands = v;
}
if let Some(v) = body.pomodoro_duration_in_minutes {
config.pomodoro_duration_in_minutes = v;
}
if let Some(v) = body.schedules {
config.schedules = v
.into_iter()
.filter_map(|v| serde_json::from_value(v).ok())
.collect();
}
if let Some(v) = body.quick_commands {
config.quick_commands = v;
}
if let Some(v) = body.two_emojis_enabled {
config.two_emojis_enabled = v;
}
if let Some(v) = body.mode {
config.mode = v;
}
if let Some(v) = body.quick_habits_enabled {
config.quick_habits_enabled = v;
}
if let Some(v) = body.channels {
config.channels = v;
}
state
.kernel
.knowledge
.set_config(&config)
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(StatusCode::NO_CONTENT)
}
pub(crate) async fn handle_knowledge_worker_nightly(
state: State<Arc<AppState>>,
) -> Result<Json<serde_json::Value>, AppError> {
let report = state
.kernel
.knowledge
.run_nightly_cleanup()
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(Json(serde_json::to_value(report).unwrap_or_default()))
}
pub(crate) async fn handle_knowledge_worker_scheduled(
state: State<Arc<AppState>>,
) -> Result<Json<serde_json::Value>, AppError> {
let moved = state
.kernel
.knowledge
.run_scheduled_tasks()
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(Json(serde_json::json!({
"moved": moved,
"count": moved.len(),
})))
}
pub(crate) async fn handle_knowledge_convert_html(
state: State<Arc<AppState>>,
Json(body): Json<ConvertHtmlBody>,
) -> Result<Json<ConvertHtmlResponse>, AppError> {
let html = state.knowledge.markdown_to_html(&body.md);
Ok(Json(ConvertHtmlResponse { html }))
}
pub(crate) async fn handle_knowledge_emoji(
state: State<Arc<AppState>>,
Query(params): Query<EmojiQueryParams>,
) -> Result<Json<EmojiResponse>, AppError> {
let emoji = state.knowledge.auto_emoji(¶ms.text);
Ok(Json(EmojiResponse { emoji }))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_guess_knowledge_mime() {
assert_eq!(
guess_knowledge_mime("brain/Rust.md"),
"text/markdown; charset=utf-8"
);
assert_eq!(guess_knowledge_mime("data.json"), "application/json");
assert_eq!(guess_knowledge_mime("image.png"), "image/png");
assert_eq!(
guess_knowledge_mime("unknown.bin"),
"text/plain; charset=utf-8"
);
}
#[test]
fn test_tree_entry_serialization() {
let entry = KnowledgeTreeEntry {
name: "Rust.md".into(),
is_dir: false,
size: 1024,
};
let json = serde_json::to_value(&entry).unwrap();
assert_eq!(json["name"], "Rust.md");
assert_eq!(json["is_dir"], false);
assert_eq!(json["size"], 1024);
}
#[test]
fn test_search_hit_serialization() {
let hit = KnowledgeSearchHit {
path: "brain/Rust.md".into(),
name: "Rust.md".into(),
snippet: "...ownership model...".into(),
backlink_count: 3,
name_similarity: 95,
};
let json = serde_json::to_value(&hit).unwrap();
assert_eq!(json["path"], "brain/Rust.md");
assert_eq!(json["backlink_count"], 3);
}
#[test]
fn test_backlink_serialization() {
let bl = KnowledgeBacklink {
source_path: "brain/Overview.md".into(),
link_text: "Architecture".into(),
context: "See [Architecture](brain/Architecture.md)".into(),
};
let json = serde_json::to_value(&bl).unwrap();
assert_eq!(json["source_path"], "brain/Overview.md");
}
#[test]
fn test_graph_serialization() {
let graph = KnowledgeGraph {
nodes: vec![KnowledgeGraphNode {
id: "brain/Rust.md".into(),
label: "Rust".into(),
group: "brain".into(),
}],
edges: vec![KnowledgeGraphEdge {
source: "brain/Rust.md".into(),
target: "brain/Ownership.md".into(),
label: "Ownership".into(),
}],
};
let json = serde_json::to_value(&graph).unwrap();
assert_eq!(json["nodes"][0]["label"], "Rust");
assert_eq!(json["edges"][0]["target"], "brain/Ownership.md");
}
}