Add global knowledge base with RAG search

- KB module: fastembed (AllMiniLML6V2) for CPU embedding, SQLite for
  vector storage with brute-force cosine similarity search
- Chunking by ## headings, embeddings stored as BLOB in kb_chunks table
- API: GET/PUT /api/kb for full-text read/write with auto re-indexing
- Agent tools: kb_search (top-5 semantic search) and kb_read (full text)
  available in both planning and execution phases
- Frontend: Settings menu in sidebar footer, KB editor as independent
  view with markdown textarea and save button
- Also: extract shared db_err/ApiResult to api/mod.rs, add context
  management design doc

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 08:15:50 +00:00
parent 1aa81896b5
commit d9d3bc340c
19 changed files with 2283 additions and 53 deletions

View File

@@ -75,4 +75,12 @@ export const api = {
deleteTimer: (timerId: string) =>
request<void>(`/timers/${timerId}`, { method: 'DELETE' }),
getKb: () => request<{ content: string }>('/kb'),
putKb: (content: string) =>
request<{ content: string }>('/kb', {
method: 'PUT',
body: JSON.stringify({ content }),
}),
}

View File

@@ -4,6 +4,7 @@ import Sidebar from './Sidebar.vue'
import WorkflowView from './WorkflowView.vue'
import ReportView from './ReportView.vue'
import CreateForm from './CreateForm.vue'
import KbEditor from './KbEditor.vue'
import { api } from '../api'
import type { Project } from '../types'
@@ -12,6 +13,7 @@ const selectedProjectId = ref('')
const reportWorkflowId = ref('')
const error = ref('')
const creating = ref(false)
const showKb = ref(false)
const isReportPage = computed(() => !!reportWorkflowId.value)
@@ -54,6 +56,7 @@ function onSelectProject(id: string) {
selectedProjectId.value = id
reportWorkflowId.value = ''
creating.value = false
showKb.value = false
history.pushState(null, '', `/projects/${id}`)
}
@@ -110,10 +113,12 @@ async function onDeleteProject(id: string) {
@select="onSelectProject"
@create="onStartCreate"
@delete="onDeleteProject"
@openKb="showKb = true; selectedProjectId = ''; creating = false"
/>
<main class="main-content">
<div v-if="error" class="error-banner" @click="error = ''">{{ error }}</div>
<div v-if="creating" class="empty-state">
<KbEditor v-if="showKb" />
<div v-else-if="creating" class="empty-state">
<CreateForm @submit="onConfirmCreate" @cancel="creating = false" />
</div>
<div v-else-if="!selectedProjectId" class="empty-state">

View File

@@ -0,0 +1,130 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '../api'
const content = ref('')
const saving = ref(false)
const loading = ref(true)
const message = ref('')
onMounted(async () => {
try {
const kb = await api.getKb()
content.value = kb.content
} catch (e: any) {
message.value = 'Failed to load: ' + e.message
} finally {
loading.value = false
}
})
async function save() {
saving.value = true
message.value = ''
try {
await api.putKb(content.value)
message.value = 'Saved & indexed'
setTimeout(() => { message.value = '' }, 2000)
} catch (e: any) {
message.value = 'Error: ' + e.message
} finally {
saving.value = false
}
}
</script>
<template>
<div class="kb-view">
<div class="kb-header">
<h2>Knowledge Base</h2>
<div class="kb-actions">
<span v-if="message" class="kb-message">{{ message }}</span>
<button class="btn-save" @click="save" :disabled="saving">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
<div v-if="loading" class="kb-loading">Loading...</div>
<textarea
v-else
v-model="content"
class="kb-textarea"
placeholder="Write your knowledge base in Markdown...&#10;&#10;Use ## headings to split into searchable chunks."
spellcheck="false"
/>
</div>
</template>
<style scoped>
.kb-view {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.kb-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.kb-header h2 {
font-size: 16px;
font-weight: 600;
margin: 0;
}
.kb-actions {
display: flex;
align-items: center;
gap: 12px;
}
.kb-message {
font-size: 12px;
color: var(--accent);
}
.btn-save {
padding: 6px 16px;
background: var(--accent);
color: var(--bg-primary);
border: none;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
.btn-save:disabled {
opacity: 0.5;
}
.kb-loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
}
.kb-textarea {
flex: 1;
padding: 20px;
background: var(--bg-primary);
color: var(--text-primary);
border: none;
resize: none;
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
line-height: 1.6;
outline: none;
}
.kb-textarea::placeholder {
color: var(--text-secondary);
opacity: 0.5;
}
</style>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { Project } from '../types'
defineProps<{
@@ -10,14 +11,22 @@ const emit = defineEmits<{
select: [id: string]
create: []
delete: [id: string]
openKb: []
}>()
const showSettings = ref(false)
function onDelete(e: Event, id: string) {
e.stopPropagation()
if (confirm('确定删除这个项目?')) {
emit('delete', id)
}
}
function onOpenKb() {
showSettings.value = false
emit('openKb')
}
</script>
<template>
@@ -41,6 +50,14 @@ function onDelete(e: Event, id: string) {
<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>
</aside>
</template>
@@ -153,4 +170,59 @@ function onDelete(e: Event, id: string) {
font-size: 11px;
color: var(--text-secondary);
}
.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>

View File

@@ -82,7 +82,7 @@ function handleWsMessage(msg: WsMessage) {
break
case 'ReportReady':
if (workflow.value && msg.workflow_id === workflow.value.id) {
workflow.value = { ...workflow.value, status: workflow.value.status }
loadData()
}
break
case 'ProjectUpdate':