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:
176
src/api/kb.rs
176
src/api/kb.rs
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user