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:
21
src/agent.rs
21
src/agent.rs
@@ -983,7 +983,12 @@ async fn run_agent_loop(
|
||||
Ok(results) if results.is_empty() => "知识库为空或没有匹配结果。".to_string(),
|
||||
Ok(results) => {
|
||||
results.iter().enumerate().map(|(i, r)| {
|
||||
format!("--- 片段 {} (相似度: {:.2}) ---\n{}", i + 1, r.score, r.content)
|
||||
let article_label = if r.article_title.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" [文章: {}]", r.article_title)
|
||||
};
|
||||
format!("--- 片段 {} (相似度: {:.2}){} ---\n{}", i + 1, r.score, article_label, r.content)
|
||||
}).collect::<Vec<_>>().join("\n\n")
|
||||
}
|
||||
Err(e) => format!("Error: {}", e),
|
||||
@@ -995,14 +1000,14 @@ async fn run_agent_loop(
|
||||
}
|
||||
|
||||
"kb_read" => {
|
||||
let result: String = match sqlx::query_scalar::<_, String>("SELECT content FROM kb_content WHERE id = 1")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
{
|
||||
Ok(content) => {
|
||||
if content.is_empty() { "知识库为空。".to_string() } else { content }
|
||||
let result = if let Some(kb) = &mgr.kb {
|
||||
match kb.read_all().await {
|
||||
Ok(content) if content.is_empty() => "知识库为空。".to_string(),
|
||||
Ok(content) => content,
|
||||
Err(e) => format!("Error: {}", e),
|
||||
}
|
||||
Err(e) => format!("Error: {}", e),
|
||||
} else {
|
||||
"知识库未初始化。".to_string()
|
||||
};
|
||||
state.step_messages.push(ChatMessage::tool_result(&tc.id, &result));
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
61
src/db.rs
61
src/db.rs
@@ -103,8 +103,9 @@ impl Database {
|
||||
|
||||
// KB tables
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS kb_content (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
"CREATE TABLE IF NOT EXISTS kb_articles (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)"
|
||||
@@ -112,16 +113,10 @@ impl Database {
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
// Insert default row if not exists
|
||||
let _ = sqlx::query(
|
||||
"INSERT OR IGNORE INTO kb_content (id, content) VALUES (1, '')"
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS kb_chunks (
|
||||
id TEXT PRIMARY KEY,
|
||||
article_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
content TEXT NOT NULL,
|
||||
embedding BLOB NOT NULL
|
||||
@@ -130,6 +125,46 @@ impl Database {
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
// Migration: add article_id to kb_chunks if missing
|
||||
let _ = sqlx::query(
|
||||
"ALTER TABLE kb_chunks ADD COLUMN article_id TEXT NOT NULL DEFAULT ''"
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await;
|
||||
|
||||
// Migrate old kb_content to kb_articles
|
||||
let has_old_table: bool = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='kb_content'"
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
if has_old_table {
|
||||
let old_content: Option<String> = sqlx::query_scalar(
|
||||
"SELECT content FROM kb_content WHERE id = 1"
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
|
||||
if let Some(content) = old_content {
|
||||
if !content.is_empty() {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let _ = sqlx::query(
|
||||
"INSERT OR IGNORE INTO kb_articles (id, title, content) VALUES (?, '导入的知识库', ?)"
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&content)
|
||||
.execute(&self.pool)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
let _ = sqlx::query("DROP TABLE kb_content")
|
||||
.execute(&self.pool)
|
||||
.await;
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS timers (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -192,6 +227,14 @@ pub struct Comment {
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct KbArticle {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Timer {
|
||||
pub id: String,
|
||||
|
||||
69
src/kb.rs
69
src/kb.rs
@@ -13,6 +13,7 @@ pub struct SearchResult {
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub score: f32,
|
||||
pub article_title: String,
|
||||
}
|
||||
|
||||
/// A chunk of KB content split by heading
|
||||
@@ -27,10 +28,11 @@ impl KbManager {
|
||||
Ok(Self { pool })
|
||||
}
|
||||
|
||||
/// Re-index: chunk the content, embed via Python, store in SQLite
|
||||
pub async fn index(&self, content: &str) -> Result<()> {
|
||||
// Clear old chunks
|
||||
sqlx::query("DELETE FROM kb_chunks")
|
||||
/// Re-index a single article: delete its old chunks, chunk the content, embed, store
|
||||
pub async fn index(&self, article_id: &str, content: &str) -> Result<()> {
|
||||
// Delete only this article's chunks
|
||||
sqlx::query("DELETE FROM kb_chunks WHERE article_id = ?")
|
||||
.bind(article_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
@@ -45,9 +47,10 @@ impl KbManager {
|
||||
for (chunk, embedding) in chunks.iter().zip(embeddings.into_iter()) {
|
||||
let vec_bytes = embedding_to_bytes(&embedding);
|
||||
sqlx::query(
|
||||
"INSERT INTO kb_chunks (id, title, content, embedding) VALUES (?, ?, ?, ?)",
|
||||
"INSERT INTO kb_chunks (id, article_id, title, content, embedding) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(uuid::Uuid::new_v4().to_string())
|
||||
.bind(article_id)
|
||||
.bind(&chunk.title)
|
||||
.bind(&chunk.content)
|
||||
.bind(&vec_bytes)
|
||||
@@ -55,11 +58,24 @@ impl KbManager {
|
||||
.await?;
|
||||
}
|
||||
|
||||
tracing::info!("KB indexed: {} chunks", chunks.len());
|
||||
tracing::info!("KB indexed article {}: {} chunks", article_id, chunks.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Search KB by query, returns top-k results
|
||||
/// Delete an article and all its chunks
|
||||
pub async fn delete_article(&self, article_id: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM kb_chunks WHERE article_id = ?")
|
||||
.bind(article_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM kb_articles WHERE id = ?")
|
||||
.bind(article_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Search KB by query across all articles, returns top-k results
|
||||
pub async fn search(&self, query: &str) -> Result<Vec<SearchResult>> {
|
||||
let query_embeddings = compute_embeddings(&[query.to_string()]).await?;
|
||||
let query_vec = query_embeddings
|
||||
@@ -67,19 +83,22 @@ impl KbManager {
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to embed query"))?;
|
||||
|
||||
// Fetch all chunks with embeddings
|
||||
let rows: Vec<(String, String, Vec<u8>)> =
|
||||
sqlx::query_as("SELECT title, content, embedding FROM kb_chunks")
|
||||
// Fetch all chunks with embeddings, join with articles for title
|
||||
let rows: Vec<(String, String, Vec<u8>, String)> =
|
||||
sqlx::query_as(
|
||||
"SELECT c.title, c.content, c.embedding, COALESCE(a.title, '') \
|
||||
FROM kb_chunks c LEFT JOIN kb_articles a ON c.article_id = a.id"
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
// Compute cosine similarity and rank
|
||||
let mut scored: Vec<(f32, String, String)> = rows
|
||||
let mut scored: Vec<(f32, String, String, String)> = rows
|
||||
.into_iter()
|
||||
.filter_map(|(title, content, blob)| {
|
||||
.filter_map(|(title, content, blob, article_title)| {
|
||||
let emb = bytes_to_embedding(&blob);
|
||||
let score = cosine_similarity(&query_vec, &emb);
|
||||
Some((score, title, content))
|
||||
Some((score, title, content, article_title))
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -88,13 +107,35 @@ impl KbManager {
|
||||
|
||||
Ok(scored
|
||||
.into_iter()
|
||||
.map(|(score, title, content)| SearchResult {
|
||||
.map(|(score, title, content, article_title)| SearchResult {
|
||||
title,
|
||||
content,
|
||||
score,
|
||||
article_title,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Read all articles concatenated (for agent kb_read tool)
|
||||
pub async fn read_all(&self) -> Result<String> {
|
||||
let articles: Vec<(String, String)> = sqlx::query_as(
|
||||
"SELECT title, content FROM kb_articles ORDER BY updated_at DESC"
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
if articles.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
let combined = articles
|
||||
.iter()
|
||||
.map(|(title, content)| format!("# {}\n\n{}", title, content))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n---\n\n");
|
||||
|
||||
Ok(combined)
|
||||
}
|
||||
}
|
||||
|
||||
/// Call Python script to compute embeddings
|
||||
|
||||
Reference in New Issue
Block a user