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:
Fam Zheng 2026-03-01 08:56:08 +00:00
parent 3d1c910c4a
commit 40f200db4f
9 changed files with 513 additions and 97 deletions

View File

@ -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),
}
} else {
"知识库未初始化。".to_string()
};
state.step_messages.push(ChatMessage::tool_result(&tc.id, &result));
}

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)
) -> 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)?;
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)?;
// Re-index
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))
}

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,

View File

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

View File

@ -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<KbArticleSummary[]>('/kb/articles'),
createArticle: (title: string) =>
request<KbArticle>('/kb/articles', {
method: 'POST',
body: JSON.stringify({ title }),
}),
getArticle: (id: string) => request<KbArticle>(`/kb/articles/${id}`),
updateArticle: (id: string, data: { title?: string; content?: string }) =>
request<KbArticle>(`/kb/articles/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
deleteArticle: (id: string) =>
request<boolean>(`/kb/articles/${id}`, { method: 'DELETE' }),
}

View File

@ -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<Project[]>([])
const selectedProjectId = ref('')
@ -14,6 +14,8 @@ const reportWorkflowId = ref('')
const error = ref('')
const creating = ref(false)
const showKb = ref(false)
const kbArticles = ref<KbArticleSummary[]>([])
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
}
</script>
<template>
@ -110,14 +162,29 @@ async function onDeleteProject(id: string) {
<Sidebar
:projects="projects"
:selectedId="selectedProjectId"
:kbMode="showKb"
:kbArticles="kbArticles"
:selectedArticleId="selectedArticleId"
@select="onSelectProject"
@create="onStartCreate"
@delete="onDeleteProject"
@openKb="showKb = true; selectedProjectId = ''; creating = false"
@openKb="onOpenKb"
@closeKb="onCloseKb"
@selectArticle="selectedArticleId = $event"
@createArticle="onCreateArticle"
@deleteArticle="onDeleteArticle"
/>
<main class="main-content">
<div v-if="error" class="error-banner" @click="error = ''">{{ error }}</div>
<KbEditor v-if="showKb" />
<KbEditor
v-if="showKb && selectedArticleId"
:articleId="selectedArticleId"
:key="selectedArticleId"
@saved="onArticleSaved"
/>
<div v-else-if="showKb" class="empty-state">
选择或创建一篇文章
</div>
<div v-else-if="creating" class="empty-state">
<CreateForm @submit="onConfirmCreate" @cancel="creating = false" />
</div>

View File

@ -1,29 +1,49 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, watch } from 'vue'
import { api } from '../api'
const props = defineProps<{
articleId: string
}>()
const title = ref('')
const content = ref('')
const saving = ref(false)
const loading = ref(true)
const message = ref('')
onMounted(async () => {
async function loadArticle(id: string) {
loading.value = true
message.value = ''
try {
const kb = await api.getKb()
content.value = kb.content
const article = await api.getArticle(id)
title.value = article.title
content.value = article.content
} catch (e: any) {
message.value = 'Failed to load: ' + e.message
} finally {
loading.value = false
}
})
}
watch(() => props.articleId, (id) => {
if (id) loadArticle(id)
}, { immediate: true })
const emit = defineEmits<{
saved: [id: string, title: string]
}>()
async function save() {
saving.value = true
message.value = ''
try {
await api.putKb(content.value)
const updated = await api.updateArticle(props.articleId, {
title: title.value,
content: content.value,
})
message.value = 'Saved & indexed'
emit('saved', updated.id, updated.title)
setTimeout(() => { message.value = '' }, 2000)
} catch (e: any) {
message.value = 'Error: ' + e.message
@ -36,10 +56,15 @@ async function save() {
<template>
<div class="kb-view">
<div class="kb-header">
<h2>Knowledge Base</h2>
<input
v-model="title"
class="kb-title-input"
placeholder="文章标题"
:disabled="loading"
/>
<div class="kb-actions">
<span v-if="message" class="kb-message">{{ message }}</span>
<button class="btn-save" @click="save" :disabled="saving">
<button class="btn-save" @click="save" :disabled="saving || loading">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
@ -69,18 +94,30 @@ async function save() {
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
gap: 12px;
}
.kb-header h2 {
.kb-title-input {
flex: 1;
font-size: 16px;
font-weight: 600;
margin: 0;
background: transparent;
color: var(--text-primary);
border: none;
border-bottom: 1px solid transparent;
outline: none;
padding: 4px 0;
}
.kb-title-input:focus {
border-bottom-color: var(--accent);
}
.kb-actions {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.kb-message {

View File

@ -1,10 +1,13 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { Project } from '../types'
import type { Project, KbArticleSummary } from '../types'
defineProps<{
projects: Project[]
selectedId: string
kbMode: boolean
kbArticles: KbArticleSummary[]
selectedArticleId: string
}>()
const emit = defineEmits<{
@ -12,6 +15,10 @@ const emit = defineEmits<{
create: []
delete: [id: string]
openKb: []
closeKb: []
selectArticle: [id: string]
createArticle: []
deleteArticle: [id: string]
}>()
const showSettings = ref(false)
@ -23,6 +30,13 @@ function onDelete(e: Event, id: string) {
}
}
function onDeleteArticle(e: Event, id: string) {
e.stopPropagation()
if (confirm('确定删除这篇文章?')) {
emit('deleteArticle', id)
}
}
function onOpenKb() {
showSettings.value = false
emit('openKb')
@ -31,6 +45,33 @@ function onOpenKb() {
<template>
<aside class="sidebar">
<!-- KB Mode -->
<template v-if="kbMode">
<div class="sidebar-header">
<button class="btn-back" @click="emit('closeKb')"> Back</button>
<h1 class="logo">Knowledge Base</h1>
<button class="btn-new" @click="emit('createArticle')">+ 新文章</button>
</div>
<nav class="project-list">
<div
v-for="article in kbArticles"
:key="article.id"
class="project-item"
:class="{ active: article.id === selectedArticleId }"
@click="emit('selectArticle', article.id)"
>
<div class="project-row">
<span class="project-name">{{ article.title }}</span>
<button class="btn-delete" @click="onDeleteArticle($event, article.id)" title="删除文章">×</button>
</div>
<span class="project-time">{{ new Date(article.updated_at).toLocaleDateString() }}</span>
</div>
<div v-if="kbArticles.length === 0" class="empty-hint">还没有文章</div>
</nav>
</template>
<!-- Normal Mode -->
<template v-else>
<div class="sidebar-header">
<h1 class="logo">Tori</h1>
<button class="btn-new" @click="emit('create')">+ 新项目</button>
@ -58,6 +99,7 @@ function onOpenKb() {
</div>
</div>
</div>
</template>
</aside>
</template>
@ -84,6 +126,24 @@ function onOpenKb() {
margin-bottom: 12px;
}
.btn-back {
width: 100%;
padding: 6px 8px;
background: transparent;
color: var(--text-secondary);
border: none;
font-size: 13px;
cursor: pointer;
text-align: left;
border-radius: 6px;
margin-bottom: 8px;
}
.btn-back:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.btn-new {
width: 100%;
padding: 8px;
@ -171,6 +231,13 @@ function onOpenKb() {
color: var(--text-secondary);
}
.empty-hint {
padding: 20px;
text-align: center;
color: var(--text-secondary);
font-size: 13px;
}
.sidebar-footer {
padding: 12px 16px;
border-top: 1px solid var(--border);

View File

@ -35,6 +35,19 @@ export interface Comment {
created_at: string
}
export interface KbArticle {
id: string
title: string
content: string
updated_at: string
}
export interface KbArticleSummary {
id: string
title: string
updated_at: string
}
export interface Timer {
id: string
project_id: string