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

@@ -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,