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) if results.is_empty() => "知识库为空或没有匹配结果。".to_string(),
Ok(results) => { Ok(results) => {
results.iter().enumerate().map(|(i, r)| { 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") }).collect::<Vec<_>>().join("\n\n")
} }
Err(e) => format!("Error: {}", e), Err(e) => format!("Error: {}", e),
@ -995,14 +1000,14 @@ async fn run_agent_loop(
} }
"kb_read" => { "kb_read" => {
let result: String = match sqlx::query_scalar::<_, String>("SELECT content FROM kb_content WHERE id = 1") let result = if let Some(kb) = &mgr.kb {
.fetch_one(pool) match kb.read_all().await {
.await Ok(content) if content.is_empty() => "知识库为空。".to_string(),
{ Ok(content) => content,
Ok(content) => {
if content.is_empty() { "知识库为空。".to_string() } else { content }
}
Err(e) => format!("Error: {}", e), Err(e) => format!("Error: {}", e),
}
} else {
"知识库未初始化。".to_string()
}; };
state.step_messages.push(ChatMessage::tool_result(&tc.id, &result)); state.step_messages.push(ChatMessage::tool_result(&tc.id, &result));
} }

View File

@ -1,53 +1,183 @@
use std::sync::Arc; use std::sync::Arc;
use axum::{ use axum::{
extract::State, extract::{Path, State},
routing::get, routing::get,
Json, Router, Json, Router,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::AppState; use crate::AppState;
use crate::db::KbArticle;
use super::{ApiResult, db_err}; use super::{ApiResult, db_err};
#[derive(Serialize, Deserialize)] #[derive(Serialize)]
pub struct KbContent { pub struct KbContent {
pub content: String, 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 { pub fn router(state: Arc<AppState>) -> Router {
Router::new() 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) .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>>, State(state): State<Arc<AppState>>,
) -> ApiResult<KbContent> { ) -> ApiResult<KbContent> {
let content: String = sqlx::query_scalar("SELECT content FROM kb_content WHERE id = 1") let content = if let Some(kb) = &state.kb {
.fetch_one(&state.db.pool) kb.read_all().await.unwrap_or_default()
.await } else {
.map_err(db_err)?; String::new()
};
Ok(Json(KbContent { content })) 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>>, State(state): State<Arc<AppState>>,
Json(input): Json<KbContent>, ) -> ApiResult<Vec<ArticleSummary>> {
) -> ApiResult<KbContent> { let articles: Vec<KbArticle> = sqlx::query_as(
sqlx::query("UPDATE kb_content SET content = ?, updated_at = datetime('now') WHERE id = 1") "SELECT id, title, content, updated_at FROM kb_articles ORDER BY updated_at DESC"
.bind(&input.content) )
.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) .execute(&state.db.pool)
.await .await
.map_err(db_err)?; .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 Some(kb) = &state.kb {
if let Err(e) = kb.index(&input.content).await { if let Err(e) = kb.index(&id, &new_content).await {
tracing::error!("KB indexing failed: {}", e); tracing::error!("KB indexing failed for article {}: {}", id, e);
} }
} }
Ok(Json(KbContent { let article: KbArticle = sqlx::query_as(
content: input.content, "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 // KB tables
sqlx::query( sqlx::query(
"CREATE TABLE IF NOT EXISTS kb_content ( "CREATE TABLE IF NOT EXISTS kb_articles (
id INTEGER PRIMARY KEY CHECK (id = 1), id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '', content TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT (datetime('now')) updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)" )"
@ -112,16 +113,10 @@ impl Database {
.execute(&self.pool) .execute(&self.pool)
.await?; .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( sqlx::query(
"CREATE TABLE IF NOT EXISTS kb_chunks ( "CREATE TABLE IF NOT EXISTS kb_chunks (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
article_id TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL, content TEXT NOT NULL,
embedding BLOB NOT NULL embedding BLOB NOT NULL
@ -130,6 +125,46 @@ impl Database {
.execute(&self.pool) .execute(&self.pool)
.await?; .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( sqlx::query(
"CREATE TABLE IF NOT EXISTS timers ( "CREATE TABLE IF NOT EXISTS timers (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@ -192,6 +227,14 @@ pub struct Comment {
pub created_at: String, 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)] #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Timer { pub struct Timer {
pub id: String, pub id: String,

View File

@ -13,6 +13,7 @@ pub struct SearchResult {
pub title: String, pub title: String,
pub content: String, pub content: String,
pub score: f32, pub score: f32,
pub article_title: String,
} }
/// A chunk of KB content split by heading /// A chunk of KB content split by heading
@ -27,10 +28,11 @@ impl KbManager {
Ok(Self { pool }) Ok(Self { pool })
} }
/// Re-index: chunk the content, embed via Python, store in SQLite /// Re-index a single article: delete its old chunks, chunk the content, embed, store
pub async fn index(&self, content: &str) -> Result<()> { pub async fn index(&self, article_id: &str, content: &str) -> Result<()> {
// Clear old chunks // Delete only this article's chunks
sqlx::query("DELETE FROM kb_chunks") sqlx::query("DELETE FROM kb_chunks WHERE article_id = ?")
.bind(article_id)
.execute(&self.pool) .execute(&self.pool)
.await?; .await?;
@ -45,9 +47,10 @@ impl KbManager {
for (chunk, embedding) in chunks.iter().zip(embeddings.into_iter()) { for (chunk, embedding) in chunks.iter().zip(embeddings.into_iter()) {
let vec_bytes = embedding_to_bytes(&embedding); let vec_bytes = embedding_to_bytes(&embedding);
sqlx::query( 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(uuid::Uuid::new_v4().to_string())
.bind(article_id)
.bind(&chunk.title) .bind(&chunk.title)
.bind(&chunk.content) .bind(&chunk.content)
.bind(&vec_bytes) .bind(&vec_bytes)
@ -55,11 +58,24 @@ impl KbManager {
.await?; .await?;
} }
tracing::info!("KB indexed: {} chunks", chunks.len()); tracing::info!("KB indexed article {}: {} chunks", article_id, chunks.len());
Ok(()) 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>> { pub async fn search(&self, query: &str) -> Result<Vec<SearchResult>> {
let query_embeddings = compute_embeddings(&[query.to_string()]).await?; let query_embeddings = compute_embeddings(&[query.to_string()]).await?;
let query_vec = query_embeddings let query_vec = query_embeddings
@ -67,19 +83,22 @@ impl KbManager {
.next() .next()
.ok_or_else(|| anyhow::anyhow!("Failed to embed query"))?; .ok_or_else(|| anyhow::anyhow!("Failed to embed query"))?;
// Fetch all chunks with embeddings // Fetch all chunks with embeddings, join with articles for title
let rows: Vec<(String, String, Vec<u8>)> = let rows: Vec<(String, String, Vec<u8>, String)> =
sqlx::query_as("SELECT title, content, embedding FROM kb_chunks") 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) .fetch_all(&self.pool)
.await?; .await?;
// Compute cosine similarity and rank // Compute cosine similarity and rank
let mut scored: Vec<(f32, String, String)> = rows let mut scored: Vec<(f32, String, String, String)> = rows
.into_iter() .into_iter()
.filter_map(|(title, content, blob)| { .filter_map(|(title, content, blob, article_title)| {
let emb = bytes_to_embedding(&blob); let emb = bytes_to_embedding(&blob);
let score = cosine_similarity(&query_vec, &emb); let score = cosine_similarity(&query_vec, &emb);
Some((score, title, content)) Some((score, title, content, article_title))
}) })
.collect(); .collect();
@ -88,13 +107,35 @@ impl KbManager {
Ok(scored Ok(scored
.into_iter() .into_iter()
.map(|(score, title, content)| SearchResult { .map(|(score, title, content, article_title)| SearchResult {
title, title,
content, content,
score, score,
article_title,
}) })
.collect()) .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 /// 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' const BASE = '/api'
@ -78,9 +78,22 @@ export const api = {
getKb: () => request<{ content: string }>('/kb'), getKb: () => request<{ content: string }>('/kb'),
putKb: (content: string) => listArticles: () => request<KbArticleSummary[]>('/kb/articles'),
request<{ content: string }>('/kb', {
method: 'PUT', createArticle: (title: string) =>
body: JSON.stringify({ content }), 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 CreateForm from './CreateForm.vue'
import KbEditor from './KbEditor.vue' import KbEditor from './KbEditor.vue'
import { api } from '../api' import { api } from '../api'
import type { Project } from '../types' import type { Project, KbArticleSummary } from '../types'
const projects = ref<Project[]>([]) const projects = ref<Project[]>([])
const selectedProjectId = ref('') const selectedProjectId = ref('')
@ -14,6 +14,8 @@ const reportWorkflowId = ref('')
const error = ref('') const error = ref('')
const creating = ref(false) const creating = ref(false)
const showKb = ref(false) const showKb = ref(false)
const kbArticles = ref<KbArticleSummary[]>([])
const selectedArticleId = ref('')
const isReportPage = computed(() => !!reportWorkflowId.value) const isReportPage = computed(() => !!reportWorkflowId.value)
@ -100,6 +102,56 @@ async function onDeleteProject(id: string) {
error.value = e.message 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> </script>
<template> <template>
@ -110,14 +162,29 @@ async function onDeleteProject(id: string) {
<Sidebar <Sidebar
:projects="projects" :projects="projects"
:selectedId="selectedProjectId" :selectedId="selectedProjectId"
:kbMode="showKb"
:kbArticles="kbArticles"
:selectedArticleId="selectedArticleId"
@select="onSelectProject" @select="onSelectProject"
@create="onStartCreate" @create="onStartCreate"
@delete="onDeleteProject" @delete="onDeleteProject"
@openKb="showKb = true; selectedProjectId = ''; creating = false" @openKb="onOpenKb"
@closeKb="onCloseKb"
@selectArticle="selectedArticleId = $event"
@createArticle="onCreateArticle"
@deleteArticle="onDeleteArticle"
/> />
<main class="main-content"> <main class="main-content">
<div v-if="error" class="error-banner" @click="error = ''">{{ error }}</div> <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"> <div v-else-if="creating" class="empty-state">
<CreateForm @submit="onConfirmCreate" @cancel="creating = false" /> <CreateForm @submit="onConfirmCreate" @cancel="creating = false" />
</div> </div>

View File

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

View File

@ -1,10 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import type { Project } from '../types' import type { Project, KbArticleSummary } from '../types'
defineProps<{ defineProps<{
projects: Project[] projects: Project[]
selectedId: string selectedId: string
kbMode: boolean
kbArticles: KbArticleSummary[]
selectedArticleId: string
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@ -12,6 +15,10 @@ const emit = defineEmits<{
create: [] create: []
delete: [id: string] delete: [id: string]
openKb: [] openKb: []
closeKb: []
selectArticle: [id: string]
createArticle: []
deleteArticle: [id: string]
}>() }>()
const showSettings = ref(false) 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() { function onOpenKb() {
showSettings.value = false showSettings.value = false
emit('openKb') emit('openKb')
@ -31,6 +45,33 @@ function onOpenKb() {
<template> <template>
<aside class="sidebar"> <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"> <div class="sidebar-header">
<h1 class="logo">Tori</h1> <h1 class="logo">Tori</h1>
<button class="btn-new" @click="emit('create')">+ 新项目</button> <button class="btn-new" @click="emit('create')">+ 新项目</button>
@ -58,6 +99,7 @@ function onOpenKb() {
</div> </div>
</div> </div>
</div> </div>
</template>
</aside> </aside>
</template> </template>
@ -84,6 +126,24 @@ function onOpenKb() {
margin-bottom: 12px; 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 { .btn-new {
width: 100%; width: 100%;
padding: 8px; padding: 8px;
@ -171,6 +231,13 @@ function onOpenKb() {
color: var(--text-secondary); color: var(--text-secondary);
} }
.empty-hint {
padding: 20px;
text-align: center;
color: var(--text-secondary);
font-size: 13px;
}
.sidebar-footer { .sidebar-footer {
padding: 12px 16px; padding: 12px 16px;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);

View File

@ -35,6 +35,19 @@ export interface Comment {
created_at: string 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 { export interface Timer {
id: string id: string
project_id: string project_id: string