Add global knowledge base with RAG search

- KB module: fastembed (AllMiniLML6V2) for CPU embedding, SQLite for
  vector storage with brute-force cosine similarity search
- Chunking by ## headings, embeddings stored as BLOB in kb_chunks table
- API: GET/PUT /api/kb for full-text read/write with auto re-indexing
- Agent tools: kb_search (top-5 semantic search) and kb_read (full text)
  available in both planning and execution phases
- Frontend: Settings menu in sidebar footer, KB editor as independent
  view with markdown textarea and save button
- Also: extract shared db_err/ApiResult to api/mod.rs, add context
  management design doc

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 08:15:50 +00:00
parent 1aa81896b5
commit d9d3bc340c
19 changed files with 2283 additions and 53 deletions

53
src/api/kb.rs Normal file
View File

@@ -0,0 +1,53 @@
use std::sync::Arc;
use axum::{
extract::State,
routing::get,
Json, Router,
};
use serde::{Deserialize, Serialize};
use crate::AppState;
use super::{ApiResult, db_err};
#[derive(Serialize, Deserialize)]
pub struct KbContent {
pub content: String,
}
pub fn router(state: Arc<AppState>) -> Router {
Router::new()
.route("/kb", get(get_kb).put(put_kb))
.with_state(state)
}
async fn get_kb(
State(state): State<Arc<AppState>>,
) -> ApiResult<KbContent> {
let content: String = sqlx::query_scalar("SELECT content FROM kb_content WHERE id = 1")
.fetch_one(&state.db.pool)
.await
.map_err(db_err)?;
Ok(Json(KbContent { content }))
}
async fn put_kb(
State(state): State<Arc<AppState>>,
Json(input): Json<KbContent>,
) -> ApiResult<KbContent> {
sqlx::query("UPDATE kb_content SET content = ?, updated_at = datetime('now') WHERE id = 1")
.bind(&input.content)
.execute(&state.db.pool)
.await
.map_err(db_err)?;
// Re-index
if let Some(kb) = &state.kb {
if let Err(e) = kb.index(&input.content).await {
tracing::error!("KB indexing failed: {}", e);
}
}
Ok(Json(KbContent {
content: input.content,
}))
}

View File

@@ -1,3 +1,4 @@
mod kb;
mod projects;
mod timers;
mod workflows;
@@ -9,16 +10,24 @@ use axum::{
http::StatusCode,
response::{IntoResponse, Response},
routing::{get, any},
Router,
Json, Router,
};
use crate::AppState;
pub(crate) type ApiResult<T> = Result<Json<T>, Response>;
pub(crate) fn db_err(e: sqlx::Error) -> Response {
tracing::error!("Database error: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
}
pub fn router(state: Arc<AppState>) -> Router {
Router::new()
.merge(projects::router(state.clone()))
.merge(workflows::router(state.clone()))
.merge(timers::router(state.clone()))
.merge(kb::router(state.clone()))
.route("/projects/{id}/files/{*path}", get(serve_project_file))
.route("/projects/{id}/app/{*path}", any(proxy_to_service).with_state(state.clone()))
.route("/projects/{id}/app/", any(proxy_to_service_root).with_state(state))

View File

@@ -1,21 +1,13 @@
use std::sync::Arc;
use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
Json, Router,
};
use serde::Deserialize;
use crate::AppState;
use crate::db::Project;
type ApiResult<T> = Result<Json<T>, Response>;
fn db_err(e: sqlx::Error) -> Response {
tracing::error!("Database error: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
}
use super::{ApiResult, db_err};
#[derive(Deserialize)]
pub struct CreateProject {

View File

@@ -9,13 +9,7 @@ use axum::{
use serde::Deserialize;
use crate::AppState;
use crate::db::Timer;
type ApiResult<T> = Result<Json<T>, Response>;
fn db_err(e: sqlx::Error) -> Response {
tracing::error!("Database error: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
}
use super::{ApiResult, db_err};
#[derive(Deserialize)]
pub struct CreateTimer {

View File

@@ -10,19 +10,13 @@ use serde::Deserialize;
use crate::AppState;
use crate::agent::AgentEvent;
use crate::db::{Workflow, PlanStep, Comment};
use super::{ApiResult, db_err};
#[derive(serde::Serialize)]
struct ReportResponse {
report: String,
}
type ApiResult<T> = Result<Json<T>, Response>;
fn db_err(e: sqlx::Error) -> Response {
tracing::error!("Database error: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
}
#[derive(Deserialize)]
pub struct CreateWorkflow {
pub requirement: String,