- 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>
296 lines
6.2 KiB
Vue
296 lines
6.2 KiB
Vue
<script setup lang="ts">
|
||
import { ref } from 'vue'
|
||
import type { Project, KbArticleSummary } from '../types'
|
||
|
||
defineProps<{
|
||
projects: Project[]
|
||
selectedId: string
|
||
kbMode: boolean
|
||
kbArticles: KbArticleSummary[]
|
||
selectedArticleId: string
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
select: [id: string]
|
||
create: []
|
||
delete: [id: string]
|
||
openKb: []
|
||
closeKb: []
|
||
selectArticle: [id: string]
|
||
createArticle: []
|
||
deleteArticle: [id: string]
|
||
}>()
|
||
|
||
const showSettings = ref(false)
|
||
|
||
function onDelete(e: Event, id: string) {
|
||
e.stopPropagation()
|
||
if (confirm('确定删除这个项目?')) {
|
||
emit('delete', id)
|
||
}
|
||
}
|
||
|
||
function onDeleteArticle(e: Event, id: string) {
|
||
e.stopPropagation()
|
||
if (confirm('确定删除这篇文章?')) {
|
||
emit('deleteArticle', id)
|
||
}
|
||
}
|
||
|
||
function onOpenKb() {
|
||
showSettings.value = false
|
||
emit('openKb')
|
||
}
|
||
</script>
|
||
|
||
<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>
|
||
</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>
|
||
</template>
|
||
</aside>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.sidebar {
|
||
width: var(--sidebar-width);
|
||
min-width: var(--sidebar-width);
|
||
background: var(--bg-secondary);
|
||
border-right: 1px solid var(--border);
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.sidebar-header {
|
||
padding: 16px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.logo {
|
||
font-size: 20px;
|
||
font-weight: 700;
|
||
color: var(--accent);
|
||
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;
|
||
background: var(--bg-tertiary);
|
||
color: var(--text-primary);
|
||
border: 1px dashed var(--border);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.btn-new:hover {
|
||
background: var(--accent);
|
||
color: var(--bg-primary);
|
||
border-style: solid;
|
||
}
|
||
|
||
.project-list {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 8px;
|
||
}
|
||
|
||
.project-item {
|
||
padding: 10px 12px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
margin-bottom: 2px;
|
||
}
|
||
|
||
.project-item:hover {
|
||
background: var(--bg-tertiary);
|
||
}
|
||
|
||
.project-item.active {
|
||
background: var(--bg-tertiary);
|
||
border-left: 3px solid var(--accent);
|
||
}
|
||
|
||
.project-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.project-name {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.btn-delete {
|
||
display: none;
|
||
width: 20px;
|
||
height: 20px;
|
||
padding: 0;
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--text-secondary);
|
||
font-size: 16px;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
border-radius: 4px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.btn-delete:hover {
|
||
background: var(--error, #e74c3c);
|
||
color: #fff;
|
||
}
|
||
|
||
.project-item:hover .btn-delete {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.project-time {
|
||
font-size: 11px;
|
||
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);
|
||
}
|
||
|
||
.settings-wrapper {
|
||
position: relative;
|
||
}
|
||
|
||
.btn-settings {
|
||
width: 100%;
|
||
padding: 8px;
|
||
background: transparent;
|
||
color: var(--text-secondary);
|
||
border: none;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
text-align: left;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.btn-settings:hover {
|
||
background: var(--bg-tertiary);
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.settings-menu {
|
||
position: absolute;
|
||
bottom: 100%;
|
||
left: 0;
|
||
right: 0;
|
||
margin-bottom: 4px;
|
||
background: var(--bg-primary);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 4px;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||
}
|
||
|
||
.settings-item {
|
||
width: 100%;
|
||
padding: 8px 12px;
|
||
background: none;
|
||
border: none;
|
||
color: var(--text-primary);
|
||
font-size: 13px;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.settings-item:hover {
|
||
background: var(--bg-tertiary);
|
||
}
|
||
</style>
|