From 40f200db4f0e382ce6581f4908795af0a39bc2be Mon Sep 17 00:00:00 2001 From: Fam Zheng Date: Sun, 1 Mar 2026 08:56:08 +0000 Subject: [PATCH] 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 --- src/agent.rs | 21 ++-- src/api/kb.rs | 176 +++++++++++++++++++++++++++---- src/db.rs | 61 +++++++++-- src/kb.rs | 69 +++++++++--- web/src/api.ts | 23 +++- web/src/components/AppLayout.vue | 73 ++++++++++++- web/src/components/KbEditor.vue | 57 ++++++++-- web/src/components/Sidebar.vue | 117 +++++++++++++++----- web/src/types.ts | 13 +++ 9 files changed, 513 insertions(+), 97 deletions(-) diff --git a/src/agent.rs b/src/agent.rs index 573daab..dc6e594 100644 --- a/src/agent.rs +++ b/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::>().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)); } diff --git a/src/api/kb.rs b/src/api/kb.rs index d6efc9c..691ce08 100644 --- a/src/api/kb.rs +++ b/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, + pub content: Option, +} + pub fn router(state: Arc) -> 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>, ) -> ApiResult { - 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>, - Json(input): Json, -) -> ApiResult { - 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> { + let articles: Vec = 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>, + Json(input): Json, +) -> ApiResult { + 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>, + Path(id): Path, +) -> ApiResult { + 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>, + Path(id): Path, + Json(input): Json, +) -> ApiResult { + // 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>, + Path(id): Path, +) -> ApiResult { + 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)) } diff --git a/src/db.rs b/src/db.rs index 67f35a0..83dbbcb 100644 --- a/src/db.rs +++ b/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 = 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, diff --git a/src/kb.rs b/src/kb.rs index 2c1d0b3..0a6624d 100644 --- a/src/kb.rs +++ b/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> { 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)> = - 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, 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 { + 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::>() + .join("\n\n---\n\n"); + + Ok(combined) + } } /// Call Python script to compute embeddings diff --git a/web/src/api.ts b/web/src/api.ts index af56077..75c4c94 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1,4 +1,4 @@ -import type { Project, Workflow, PlanStep, Comment, Timer } from './types' +import type { Project, Workflow, PlanStep, Comment, Timer, KbArticle, KbArticleSummary } from './types' const BASE = '/api' @@ -78,9 +78,22 @@ export const api = { getKb: () => request<{ content: string }>('/kb'), - putKb: (content: string) => - request<{ content: string }>('/kb', { - method: 'PUT', - body: JSON.stringify({ content }), + listArticles: () => request('/kb/articles'), + + createArticle: (title: string) => + request('/kb/articles', { + method: 'POST', + body: JSON.stringify({ title }), }), + + getArticle: (id: string) => request(`/kb/articles/${id}`), + + updateArticle: (id: string, data: { title?: string; content?: string }) => + request(`/kb/articles/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }), + + deleteArticle: (id: string) => + request(`/kb/articles/${id}`, { method: 'DELETE' }), } diff --git a/web/src/components/AppLayout.vue b/web/src/components/AppLayout.vue index b9379b5..cef33ca 100644 --- a/web/src/components/AppLayout.vue +++ b/web/src/components/AppLayout.vue @@ -6,7 +6,7 @@ import ReportView from './ReportView.vue' import CreateForm from './CreateForm.vue' import KbEditor from './KbEditor.vue' import { api } from '../api' -import type { Project } from '../types' +import type { Project, KbArticleSummary } from '../types' const projects = ref([]) const selectedProjectId = ref('') @@ -14,6 +14,8 @@ const reportWorkflowId = ref('') const error = ref('') const creating = ref(false) const showKb = ref(false) +const kbArticles = ref([]) +const selectedArticleId = ref('') const isReportPage = computed(() => !!reportWorkflowId.value) @@ -100,6 +102,56 @@ async function onDeleteProject(id: string) { error.value = e.message } } + +async function onOpenKb() { + showKb.value = true + selectedProjectId.value = '' + creating.value = false + try { + kbArticles.value = await api.listArticles() + if (kbArticles.value.length > 0 && !selectedArticleId.value) { + selectedArticleId.value = kbArticles.value[0]!.id + } + } catch (e: any) { + error.value = e.message + } +} + +function onCloseKb() { + showKb.value = false + selectedArticleId.value = '' + if (projects.value[0]) { + selectedProjectId.value = projects.value[0].id + history.replaceState(null, '', `/projects/${projects.value[0].id}`) + } +} + +async function onCreateArticle() { + try { + const article = await api.createArticle('新文章') + kbArticles.value.unshift({ id: article.id, title: article.title, updated_at: article.updated_at }) + selectedArticleId.value = article.id + } catch (e: any) { + error.value = e.message + } +} + +async function onDeleteArticle(id: string) { + try { + await api.deleteArticle(id) + kbArticles.value = kbArticles.value.filter(a => a.id !== id) + if (selectedArticleId.value === id) { + selectedArticleId.value = kbArticles.value[0]?.id ?? '' + } + } catch (e: any) { + error.value = e.message + } +} + +function onArticleSaved(id: string, title: string) { + const a = kbArticles.value.find(a => a.id === id) + if (a) a.title = title +}