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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user