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:
parent
3d1c910c4a
commit
40f200db4f
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
|
||||
|
||||
@ -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' }),
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,33 +45,61 @@ function onOpenKb() {
|
||||
|
||||
<template>
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h1 class="logo">Tori</h1>
|
||||
<button class="btn-new" @click="emit('create')">+ 新项目</button>
|
||||
</div>
|
||||
<nav class="project-list">
|
||||
<div
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
class="project-item"
|
||||
:class="{ active: project.id === selectedId }"
|
||||
@click="emit('select', project.id)"
|
||||
>
|
||||
<div class="project-row">
|
||||
<span class="project-name">{{ project.name }}</span>
|
||||
<button class="btn-delete" @click="onDelete($event, project.id)" title="删除项目">×</button>
|
||||
</div>
|
||||
<span class="project-time">{{ new Date(project.updated_at).toLocaleDateString() }}</span>
|
||||
<!-- 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>
|
||||
<div class="sidebar-footer">
|
||||
<div class="settings-wrapper">
|
||||
<button class="btn-settings" @click="showSettings = !showSettings">Settings</button>
|
||||
<div v-if="showSettings" class="settings-menu">
|
||||
<button class="settings-item" @click="onOpenKb">Knowledge Base</button>
|
||||
<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>
|
||||
</div>
|
||||
<nav class="project-list">
|
||||
<div
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
class="project-item"
|
||||
:class="{ active: project.id === selectedId }"
|
||||
@click="emit('select', project.id)"
|
||||
>
|
||||
<div class="project-row">
|
||||
<span class="project-name">{{ project.name }}</span>
|
||||
<button class="btn-delete" @click="onDelete($event, project.id)" title="删除项目">×</button>
|
||||
</div>
|
||||
<span class="project-time">{{ new Date(project.updated_at).toLocaleDateString() }}</span>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="settings-wrapper">
|
||||
<button class="btn-settings" @click="showSettings = !showSettings">Settings</button>
|
||||
<div v-if="showSettings" class="settings-menu">
|
||||
<button class="settings-item" @click="onOpenKb">Knowledge Base</button>
|
||||
</div>
|
||||
</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);
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user