KB multi-article support: CRUD articles, per-article indexing, sidebar KB mode

- Replace singleton kb_content table with kb_articles (id, title, content)
- Add article_id to kb_chunks for per-article chunk tracking
- Auto-migrate old kb_content data on startup
- KbManager: index/delete per article, search across all with article_title
- API: full CRUD on /kb/articles, keep GET /kb for agent tool
- Agent: kb_search shows article labels, kb_read concatenates all articles
- Frontend: Sidebar KB mode with article list, KbEditor for single article

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 08:56:08 +00:00
parent 3d1c910c4a
commit 40f200db4f
9 changed files with 513 additions and 97 deletions

View File

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

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