KB multi-article support: CRUD articles, per-article indexing, sidebar KB mode

- Replace singleton kb_content table with kb_articles (id, title, content)
- Add article_id to kb_chunks for per-article chunk tracking
- Auto-migrate old kb_content data on startup
- KbManager: index/delete per article, search across all with article_title
- API: full CRUD on /kb/articles, keep GET /kb for agent tool
- Agent: kb_search shows article labels, kb_read concatenates all articles
- Frontend: Sidebar KB mode with article list, KbEditor for single article

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 08:56:08 +00:00
parent 3d1c910c4a
commit 40f200db4f
9 changed files with 513 additions and 97 deletions

View File

@@ -1,53 +1,183 @@
use std::sync::Arc;
use axum::{
extract::State,
extract::{Path, State},
routing::get,
Json, Router,
};
use serde::{Deserialize, Serialize};
use crate::AppState;
use crate::db::KbArticle;
use super::{ApiResult, db_err};
#[derive(Serialize, Deserialize)]
#[derive(Serialize)]
pub struct KbContent {
pub content: String,
}
#[derive(Serialize)]
pub struct ArticleSummary {
pub id: String,
pub title: String,
pub updated_at: String,
}
#[derive(Deserialize)]
pub struct CreateArticleInput {
pub title: String,
}
#[derive(Deserialize)]
pub struct UpdateArticleInput {
pub title: Option<String>,
pub content: Option<String>,
}
pub fn router(state: Arc<AppState>) -> Router {
Router::new()
.route("/kb", get(get_kb).put(put_kb))
.route("/kb", get(get_kb_all))
.route("/kb/articles", get(list_articles).post(create_article))
.route("/kb/articles/{id}", get(get_article).put(update_article).delete(delete_article))
.with_state(state)
}
async fn get_kb(
/// GET /kb — return all articles concatenated (for agent tools)
async fn get_kb_all(
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)?;
let content = if let Some(kb) = &state.kb {
kb.read_all().await.unwrap_or_default()
} else {
String::new()
};
Ok(Json(KbContent { content }))
}
async fn put_kb(
/// GET /kb/articles — list all articles (without content)
async fn list_articles(
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)?;
) -> ApiResult<Vec<ArticleSummary>> {
let articles: Vec<KbArticle> = sqlx::query_as(
"SELECT id, title, content, updated_at FROM kb_articles ORDER BY updated_at DESC"
)
.fetch_all(&state.db.pool)
.await
.map_err(db_err)?;
// Re-index
Ok(Json(articles.into_iter().map(|a| ArticleSummary {
id: a.id,
title: a.title,
updated_at: a.updated_at,
}).collect()))
}
/// POST /kb/articles — create a new article
async fn create_article(
State(state): State<Arc<AppState>>,
Json(input): Json<CreateArticleInput>,
) -> ApiResult<KbArticle> {
let id = uuid::Uuid::new_v4().to_string();
sqlx::query(
"INSERT INTO kb_articles (id, title, content) VALUES (?, ?, '')"
)
.bind(&id)
.bind(&input.title)
.execute(&state.db.pool)
.await
.map_err(db_err)?;
let article: KbArticle = sqlx::query_as(
"SELECT id, title, content, updated_at FROM kb_articles WHERE id = ?"
)
.bind(&id)
.fetch_one(&state.db.pool)
.await
.map_err(db_err)?;
Ok(Json(article))
}
/// GET /kb/articles/:id — get single article with content
async fn get_article(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> ApiResult<KbArticle> {
let article: KbArticle = sqlx::query_as(
"SELECT id, title, content, updated_at FROM kb_articles WHERE id = ?"
)
.bind(&id)
.fetch_one(&state.db.pool)
.await
.map_err(db_err)?;
Ok(Json(article))
}
/// PUT /kb/articles/:id — update article title/content + re-index
async fn update_article(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Json(input): Json<UpdateArticleInput>,
) -> ApiResult<KbArticle> {
// Fetch current article
let current: KbArticle = sqlx::query_as(
"SELECT id, title, content, updated_at FROM kb_articles WHERE id = ?"
)
.bind(&id)
.fetch_one(&state.db.pool)
.await
.map_err(db_err)?;
let new_title = input.title.unwrap_or(current.title);
let new_content = input.content.unwrap_or(current.content);
sqlx::query(
"UPDATE kb_articles SET title = ?, content = ?, updated_at = datetime('now') WHERE id = ?"
)
.bind(&new_title)
.bind(&new_content)
.bind(&id)
.execute(&state.db.pool)
.await
.map_err(db_err)?;
// Re-index this article
if let Some(kb) = &state.kb {
if let Err(e) = kb.index(&input.content).await {
tracing::error!("KB indexing failed: {}", e);
if let Err(e) = kb.index(&id, &new_content).await {
tracing::error!("KB indexing failed for article {}: {}", id, e);
}
}
Ok(Json(KbContent {
content: input.content,
}))
let article: KbArticle = sqlx::query_as(
"SELECT id, title, content, updated_at FROM kb_articles WHERE id = ?"
)
.bind(&id)
.fetch_one(&state.db.pool)
.await
.map_err(db_err)?;
Ok(Json(article))
}
/// DELETE /kb/articles/:id — delete article and its chunks
async fn delete_article(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> ApiResult<bool> {
if let Some(kb) = &state.kb {
if let Err(e) = kb.delete_article(&id).await {
tracing::error!("KB delete article failed: {}", e);
}
} else {
sqlx::query("DELETE FROM kb_chunks WHERE article_id = ?")
.bind(&id)
.execute(&state.db.pool)
.await
.map_err(db_err)?;
sqlx::query("DELETE FROM kb_articles WHERE id = ?")
.bind(&id)
.execute(&state.db.pool)
.await
.map_err(db_err)?;
}
Ok(Json(true))
}