feat(frontend): U9 — KB management extension with segment preview, status display, settings

New: SegmentPreview.vue, KBSettings.vue
Extended: DocumentUpload.vue (status badges, retry, preview), SearchTest.vue (3 modes), SourceConfig.vue (ACL), KnowledgeBaseView.vue (settings + task history tabs)
API+Store: kb.ts new types/methods, knowledge.ts new state/actions

typecheck: passed
This commit is contained in:
chiguyong 2026-06-25 13:14:58 +08:00
parent e3ae2f3a56
commit 1f691ca178
8 changed files with 941 additions and 40 deletions

View File

@ -12,12 +12,23 @@ export interface IKbSource {
document_count: number
last_synced: string | null
config?: Record<string, unknown>
/** Department binding for ACL (U4). Absent = global source. */
department_id?: string | null
}
export interface IAddSourceRequest {
name: string
type: 'local' | 'feishu' | 'confluence' | 'http'
config: Record<string, unknown>
/** Document processing status flow: pending → parsing → segmenting → vectorizing → indexed | failed. */
export type DocumentStatus =
| 'pending'
| 'parsing'
| 'segmenting'
| 'vectorizing'
| 'indexed'
| 'failed'
export interface IPreviewChunk {
index: number
content: string
metadata?: Record<string, unknown>
}
export interface IUploadedDocument {
@ -27,6 +38,56 @@ export interface IUploadedDocument {
chunks: number
status: string
created_at: string
/** Present when status === 'failed'. */
error_message?: string
/** Total chunk count (upload response includes this; list may omit). */
total_chunks?: number
/** Chunk preview returned by the upload endpoint. */
chunks_preview?: IPreviewChunk[]
}
export interface IPreviewResult {
document_id: string
chunks: IPreviewChunk[]
total_chunks: number
}
/** Retrieval query mode — mirrors backend ``QueryMode`` enum. */
export type QueryMode = 'embedding' | 'keywords' | 'blend'
/** Hit-processing strategy — mirrors backend constants. */
export type HitProcessing = 'model_opt' | 'direct'
/** Rerank provider options. */
export type RerankProvider = 'cohere' | 'bge' | 'none'
export interface IKBSettings {
kb_id: string
owner: string | null
default_query_mode: QueryMode
default_hit_processing: HitProcessing
caching_disabled: boolean
rerank_enabled: boolean
rerank_provider: RerankProvider
rerank_api_key: string | null
rerank_base_url: string | null
data_export_warning: boolean
}
export interface IKBSettingsUpdate {
default_query_mode?: QueryMode
default_hit_processing?: HitProcessing
caching_disabled?: boolean
rerank_enabled?: boolean
rerank_provider?: RerankProvider
rerank_api_key?: string | null
rerank_base_url?: string | null
}
export interface IAddSourceRequest {
name: string
type: 'local' | 'feishu' | 'confluence' | 'http'
config: Record<string, unknown>
}
export interface ISearchResult {
@ -125,6 +186,41 @@ class KbApiClient extends BaseApiClient {
body: JSON.stringify(data),
})
}
/** Preview document segmentation without persisting (U3). */
async previewDocument(
file: File,
chunkSize: number = 512,
chunkOverlap: number = 50,
): Promise<IPreviewResult> {
const formData = new FormData()
formData.append('file', file)
const query = `?chunk_size=${chunkSize}&chunk_overlap=${chunkOverlap}`
return this.request<IPreviewResult>(`/documents/preview${query}`, {
method: 'POST',
body: formData,
})
}
/** Trigger vectorization for a document (retry path for failed docs). */
async vectorizeDocument(documentId: string): Promise<{ status: string }> {
return this.request<{ status: string }>(`/documents/${documentId}/vectorize`, {
method: 'POST',
})
}
/** Get KB-level settings (query mode / hit processing / caching / rerank). */
async getKbSettings(kbId: string): Promise<IKBSettings> {
return this.request<IKBSettings>(`/kbs/${kbId}/settings`)
}
/** Update KB-level settings (owner only). */
async updateKbSettings(kbId: string, update: IKBSettingsUpdate): Promise<IKBSettings> {
return this.request<IKBSettings>(`/kbs/${kbId}/settings`, {
method: 'PUT',
body: JSON.stringify(update),
})
}
}
export const kbApi = new KbApiClient()
@ -140,3 +236,7 @@ export const listDocuments = kbApi.listDocuments.bind(kbApi)
export const deleteDocument = kbApi.deleteDocument.bind(kbApi)
export const syncSource = kbApi.syncSource.bind(kbApi)
export const updateSource = kbApi.updateSource.bind(kbApi)
export const previewDocument = kbApi.previewDocument.bind(kbApi)
export const vectorizeDocument = kbApi.vectorizeDocument.bind(kbApi)
export const getKbSettings = kbApi.getKbSettings.bind(kbApi)
export const updateKbSettings = kbApi.updateKbSettings.bind(kbApi)

View File

@ -1,20 +1,28 @@
<template>
<div class="document-upload">
<a-upload-dragger
:multiple="true"
:custom-request="handleUpload"
:before-upload="beforeUpload"
:show-upload-list="false"
accept=".pdf,.docx,.doc,.md,.txt,.html,.csv,.json"
>
<p class="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p class="ant-upload-text">点击或拖拽文件到此区域上传</p>
<p class="ant-upload-hint">
支持 PDFWordMarkdownHTML纯文本等格式
</p>
</a-upload-dragger>
<div class="document-upload__toolbar">
<a-upload-dragger
class="document-upload__dragger"
:multiple="true"
:custom-request="handleUpload"
:before-upload="beforeUpload"
:show-upload-list="false"
accept=".pdf,.docx,.doc,.md,.txt,.html,.csv,.json"
>
<p class="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p class="ant-upload-text">点击或拖拽文件到此区域上传</p>
<p class="ant-upload-hint">
支持 PDFWordMarkdownHTML纯文本等格式
</p>
</a-upload-dragger>
<a-button class="document-upload__preview-btn" @click="showPreview = true">
<template #icon><EyeOutlined /></template>
预览分段
</a-button>
</div>
<a-spin v-if="kbStore.isUploading" tip="正在上传处理..." class="upload-spin" />
@ -30,20 +38,37 @@
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="record.status === 'indexed' ? 'green' : 'orange'">
{{ record.status === 'indexed' ? '已索引' : '处理中' }}
</a-tag>
<a-tooltip
:title="record.error_message"
:open="record.status === 'failed' && record.error_message ? undefined : false"
>
<a-badge
:status="statusBadge(record.status)"
:text="statusLabel(record.status)"
/>
</a-tooltip>
</template>
<template v-if="column.key === 'created_at'">
{{ formatTime(record.created_at) }}
</template>
<template v-if="column.key === 'actions'">
<a-popconfirm
title="确定删除此文档?"
@confirm="handleDelete(record.document_id)"
>
<a-button type="link" size="small" danger>删除</a-button>
</a-popconfirm>
<a-space :size="4">
<a-button
v-if="record.status === 'failed'"
type="link"
size="small"
:loading="retryingId === record.document_id"
@click="handleRetry(record.document_id)"
>
重试
</a-button>
<a-popconfirm
title="确定删除此文档?"
@confirm="handleDelete(record.document_id)"
>
<a-button type="link" size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
@ -53,25 +78,67 @@
v-else-if="!kbStore.isUploading && !kbStore.isLoading"
description="暂无文档"
/>
<SegmentPreview v-model:open="showPreview" />
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { message } from 'ant-design-vue'
import { InboxOutlined } from '@ant-design/icons-vue'
import { InboxOutlined, EyeOutlined } from '@ant-design/icons-vue'
import { useKnowledgeStore } from '@/stores/knowledge'
import SegmentPreview from './SegmentPreview.vue'
const kbStore = useKnowledgeStore()
const showPreview = ref(false)
const retryingId = ref<string | null>(null)
const docColumns = [
{ title: '文件名', dataIndex: 'filename', key: 'filename' },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
{ title: '文件名', dataIndex: 'filename', key: 'filename', ellipsis: true },
{ title: '状态', dataIndex: 'status', key: 'status', width: 140 },
{ title: '分块数', dataIndex: 'chunks', key: 'chunks', width: 80 },
{ title: '上传时间', dataIndex: 'created_at', key: 'created_at', width: 160 },
{ title: '操作', key: 'actions', width: 80 },
{ title: '操作', key: 'actions', width: 120 },
]
/** Status → badge status mapping (processing=blue, indexed=green, failed=red). */
function statusBadge(status: string): 'processing' | 'success' | 'error' | 'default' {
switch (status) {
case 'indexed':
return 'success'
case 'failed':
return 'error'
case 'pending':
case 'parsing':
case 'segmenting':
case 'vectorizing':
return 'processing'
default:
return 'default'
}
}
function statusLabel(status: string): string {
switch (status) {
case 'pending':
return '待处理'
case 'parsing':
return '解析中'
case 'segmenting':
return '分段中'
case 'vectorizing':
return '向量化中'
case 'indexed':
return '已索引'
case 'failed':
return '失败'
default:
return status
}
}
function formatTime(isoStr: string): string {
try {
const d = new Date(isoStr)
@ -112,8 +179,70 @@ async function handleDelete(documentId: string): Promise<void> {
}
}
async function handleRetry(documentId: string): Promise<void> {
retryingId.value = documentId
try {
await kbStore.retryDocument(documentId)
message.success('已触发重试')
} catch (err) {
const detail = err instanceof Error ? err.message : '重试失败'
message.error(detail)
} finally {
retryingId.value = null
}
}
/**
* Poll document statuses while any document is in a non-terminal state.
*
* ponytail: the backend has no WS push for document status today, so we
* poll the list endpoint every 5s while processing docs exist. Ceiling:
* O(n) list refetch per tick; fine for hundreds of docs. Upgrade path:
* subscribe to a dedicated document-status WS channel once the backend
* emits one (see AGENTS.md ws protocol).
*/
const POLL_INTERVAL_MS = 5000
let pollTimer: ReturnType<typeof setInterval> | null = null
function startPolling(): void {
stopPolling()
pollTimer = setInterval(() => {
if (kbStore.processingDocuments.length > 0) {
kbStore.fetchDocuments()
} else {
stopPolling()
}
}, POLL_INTERVAL_MS)
}
function stopPolling(): void {
if (pollTimer !== null) {
clearInterval(pollTimer)
pollTimer = null
}
}
// Start/stop polling based on whether any docs are processing.
watch(
() => kbStore.processingDocuments.length,
(count) => {
if (count > 0 && pollTimer === null) {
startPolling()
} else if (count === 0) {
stopPolling()
}
},
)
onMounted(() => {
kbStore.fetchDocuments()
if (kbStore.processingDocuments.length > 0) {
startPolling()
}
})
onUnmounted(() => {
stopPolling()
})
</script>
@ -122,6 +251,21 @@ onMounted(() => {
padding: 8px 0;
}
.document-upload__toolbar {
display: flex;
align-items: flex-start;
gap: 12px;
}
.document-upload__dragger {
flex: 1;
}
.document-upload__preview-btn {
flex-shrink: 0;
margin-top: 4px;
}
.upload-spin {
display: block;
text-align: center;

View File

@ -0,0 +1,209 @@
<template>
<div class="kb-settings">
<a-spin :spinning="kbStore.isLoading" tip="加载设置中...">
<a-form
v-if="form"
layout="vertical"
:model="form"
:disabled="!canEdit"
>
<a-alert
v-if="!canEdit"
type="info"
show-icon
message="仅 KB 所有者(或管理员)可修改设置"
description="当前账号为只读权限,以下设置不可编辑。"
style="margin-bottom: 16px"
/>
<a-form-item label="检索模式默认">
<a-select v-model:value="form.default_query_mode">
<a-select-option value="embedding">语义检索embedding</a-select-option>
<a-select-option value="keywords">关键词检索keywords</a-select-option>
<a-select-option value="blend">混合检索blend</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="命中处理默认">
<a-select v-model:value="form.default_hit_processing">
<a-select-option value="model_opt">模型优化model_opt</a-select-option>
<a-select-option value="direct">直接返回direct</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="禁用结果缓存" name="caching_disabled">
<a-switch v-model:checked="form.caching_disabled" />
<span class="form-hint">开启后每次检索都重新计算不读取缓存</span>
</a-form-item>
<a-divider orientation="left">Rerank 重排</a-divider>
<a-form-item label="启用 Rerank" name="rerank_enabled">
<a-switch v-model:checked="form.rerank_enabled" />
</a-form-item>
<template v-if="form.rerank_enabled">
<a-form-item label="Rerank 提供方">
<a-select v-model:value="form.rerank_provider">
<a-select-option value="cohere">Cohere</a-select-option>
<a-select-option value="bge">BGE</a-select-option>
<a-select-option value="none"></a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="form.rerank_provider !== 'none'" label="Rerank API Key">
<a-input-password
v-model:value="rerankApiKey"
placeholder="留空则不修改"
/>
</a-form-item>
<a-form-item v-if="form.rerank_provider !== 'none'" label="Rerank Base URL">
<a-input
v-model:value="rerankBaseUrl"
placeholder="https://api.cohere.ai"
/>
</a-form-item>
<a-alert
v-if="form.rerank_provider !== 'none'"
type="warning"
show-icon
:message="`使用 ${rerankProviderLabel} 处理数据可能涉及数据外发,请确认合规要求`"
style="margin-bottom: 16px"
/>
</template>
<a-divider orientation="left">授权信息</a-divider>
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="KB ID">
{{ form.kb_id }}
</a-descriptions-item>
<a-descriptions-item label="所有者">
<a-tag v-if="form.owner" color="blue">{{ form.owner }}</a-tag>
<span v-else class="form-hint">未设置首次保存时绑定当前用户</span>
</a-descriptions-item>
</a-descriptions>
<a-form-item v-if="canEdit" style="margin-top: 16px">
<a-space>
<a-button
type="primary"
:loading="kbStore.isSavingSettings"
@click="handleSave"
>
保存设置
</a-button>
<a-button @click="handleReset">重置</a-button>
</a-space>
</a-form-item>
</a-form>
<a-empty
v-else-if="!kbStore.isLoading"
description="无法加载 KB 设置"
/>
</a-spin>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { useKnowledgeStore, DEFAULT_KB_ID } from '@/stores/knowledge'
import { useAuthStore } from '@/stores/auth'
import type { IKBSettings, IKBSettingsUpdate, QueryMode, HitProcessing, RerankProvider } from '@/api/kb'
const kbStore = useKnowledgeStore()
const authStore = useAuthStore()
/** Local editable copy of settings — kept separate from the store snapshot. */
const form = ref<IKBSettings | null>(null)
const rerankApiKey = ref<string>('')
const rerankBaseUrl = ref<string>('')
/**
* Whether the current user may edit settings.
*
* Backend ACL: owner (or admin) can PUT. Before the first save the
* owner is null and any KB_WRITE user may save (becoming the owner).
* ponytail: we approximate "can edit" client-side as "has KB_WRITE";
* the server enforces the real ACL and returns 403 otherwise.
*/
const canEdit = computed(() => authStore.hasPermission('KB_WRITE'))
const rerankProviderLabel = computed(() => {
switch (form.value?.rerank_provider) {
case 'cohere':
return 'Cohere'
case 'bge':
return 'BGE'
default:
return '云端'
}
})
function syncFormFromStore(): void {
if (!kbStore.kbSettings) {
form.value = null
return
}
// Deep copy so edits don't mutate the store snapshot until save.
form.value = {
...kbStore.kbSettings,
default_query_mode: kbStore.kbSettings.default_query_mode as QueryMode,
default_hit_processing: kbStore.kbSettings.default_hit_processing as HitProcessing,
rerank_provider: kbStore.kbSettings.rerank_provider as RerankProvider,
}
// Don't prefill secrets leave blank; blank = "don't change".
rerankApiKey.value = ''
rerankBaseUrl.value = kbStore.kbSettings.rerank_base_url ?? ''
}
watch(() => kbStore.kbSettings, syncFormFromStore, { deep: true })
function handleReset(): void {
syncFormFromStore()
}
async function handleSave(): Promise<void> {
if (!form.value) return
const update: IKBSettingsUpdate = {
default_query_mode: form.value.default_query_mode,
default_hit_processing: form.value.default_hit_processing,
caching_disabled: form.value.caching_disabled,
rerank_enabled: form.value.rerank_enabled,
rerank_provider: form.value.rerank_provider,
}
// Only send rerank credentials when the user typed something.
if (form.value.rerank_enabled && form.value.rerank_provider !== 'none') {
if (rerankApiKey.value) update.rerank_api_key = rerankApiKey.value
if (rerankBaseUrl.value) update.rerank_base_url = rerankBaseUrl.value
}
try {
await kbStore.saveKbSettings(update, DEFAULT_KB_ID)
message.success('KB 设置已保存')
} catch (err) {
const detail = err instanceof Error ? err.message : '保存 KB 设置失败'
message.error(detail)
}
}
onMounted(() => {
kbStore.fetchKbSettings(DEFAULT_KB_ID)
})
</script>
<style scoped>
.kb-settings {
padding: 8px 0;
max-width: 640px;
}
.form-hint {
margin-left: 12px;
color: var(--text-placeholder);
font-size: 12px;
}
</style>

View File

@ -30,10 +30,11 @@
style="width: 80px"
/>
</a-form-item>
<a-form-item label="检索策略">
<a-radio-group v-model:value="strategy">
<a-radio-button value="vector">向量</a-radio-button>
<a-radio-button value="hybrid">混合</a-radio-button>
<a-form-item label="检索模式">
<a-radio-group v-model:value="queryMode" button-style="solid">
<a-radio-button value="embedding">语义</a-radio-button>
<a-radio-button value="keywords">关键词</a-radio-button>
<a-radio-button value="blend">混合</a-radio-button>
</a-radio-group>
</a-form-item>
</a-form>
@ -75,6 +76,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useKnowledgeStore } from '@/stores/knowledge'
import type { QueryMode } from '@/api/kb'
const kbStore = useKnowledgeStore()
const searchQuery = ref('')
@ -83,7 +85,7 @@ const activeCollapse = ref<string[]>([])
const selectedSourceIds = ref<string[]>([])
const topK = ref(5)
const strategy = ref('vector')
const queryMode = ref<QueryMode>('blend')
const sourceOptions = computed(() =>
kbStore.sources.map((s) => ({ label: s.name, value: s.id }))
@ -93,7 +95,7 @@ async function handleSearch(): Promise<void> {
if (!searchQuery.value.trim()) return
hasSearched.value = true
const sourceIds = selectedSourceIds.value.length > 0 ? selectedSourceIds.value : undefined
await kbStore.search(searchQuery.value, sourceIds, topK.value, strategy.value)
await kbStore.search(searchQuery.value, sourceIds, topK.value, queryMode.value)
}
onMounted(() => {

View File

@ -0,0 +1,192 @@
<template>
<a-modal
:open="open"
title="分段预览"
:width="720"
:footer="null"
:destroy-on-close="true"
@update:open="handleOpenChange"
>
<div class="segment-preview">
<!-- File picker + params -->
<a-space direction="vertical" :size="12" style="width: 100%">
<a-upload-dragger
:multiple="false"
:show-upload-list="false"
:before-upload="handleFileSelect"
accept=".pdf,.docx,.doc,.md,.txt,.html,.csv,.json"
>
<p class="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p class="ant-upload-text">
{{ selectedFile ? selectedFile.name : '点击或拖拽文件到此区域' }}
</p>
<p class="ant-upload-hint">选择文件预览分段效果不会保存或向量化</p>
</a-upload-dragger>
<a-form layout="inline" class="preview-form">
<a-form-item label="分块大小">
<a-input-number
v-model:value="chunkSize"
:min="64"
:max="4096"
:step="64"
style="width: 110px"
/>
</a-form-item>
<a-form-item label="重叠">
<a-input-number
v-model:value="chunkOverlap"
:min="0"
:max="512"
:step="10"
style="width: 100px"
/>
</a-form-item>
<a-form-item>
<a-button
type="primary"
:loading="kbStore.isPreviewing"
:disabled="!selectedFile"
@click="handlePreview"
>
生成预览
</a-button>
</a-form-item>
</a-form>
</a-space>
<!-- Results -->
<div v-if="kbStore.previewResult" class="preview-results">
<a-divider>
分段结果 ({{ kbStore.previewResult.total_chunks }} )
</a-divider>
<a-list
:data-source="kbStore.previewResult.chunks"
size="small"
:pagination="kbStore.previewResult.total_chunks > 10
? { pageSize: 10, size: 'small' }
: false"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #title>
<span class="chunk-index">#{{ item.index }}</span>
</template>
<template #description>
<div class="chunk-content">{{ item.content }}</div>
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</div>
<a-empty
v-else-if="!kbStore.isPreviewing"
description="选择文件并点击「生成预览」查看分段结果"
/>
</div>
</a-modal>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { message } from 'ant-design-vue'
import { InboxOutlined } from '@ant-design/icons-vue'
import { useKnowledgeStore } from '@/stores/knowledge'
interface Props {
open: boolean
}
interface Emits {
(e: 'update:open', value: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const kbStore = useKnowledgeStore()
const selectedFile = ref<File | null>(null)
const chunkSize = ref(512)
const chunkOverlap = ref(50)
function handleOpenChange(value: boolean): void {
emit('update:open', value)
if (!value) {
// Reset state when closing
selectedFile.value = null
chunkSize.value = 512
chunkOverlap.value = 50
kbStore.clearPreview()
}
}
function handleFileSelect(file: File): false {
selectedFile.value = file
return false
}
async function handlePreview(): Promise<void> {
if (!selectedFile.value) {
message.warning('请先选择文件')
return
}
try {
const result = await kbStore.previewDocument(
selectedFile.value,
chunkSize.value,
chunkOverlap.value,
)
message.success(`已生成 ${result.total_chunks} 个分段`)
} catch (err) {
const detail = err instanceof Error ? err.message : '分段预览失败'
message.error(detail)
}
}
// Reset when reopened
watch(
() => props.open,
(val) => {
if (val) {
selectedFile.value = null
kbStore.clearPreview()
}
},
)
</script>
<style scoped>
.segment-preview {
padding: 4px 0;
}
.preview-form {
flex-wrap: wrap;
gap: 8px;
}
.preview-results {
margin-top: 8px;
}
.chunk-index {
font-weight: 600;
color: var(--color-primary);
}
.chunk-content {
font-size: 13px;
line-height: 1.6;
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-word;
max-height: 160px;
overflow-y: auto;
}
</style>

View File

@ -27,6 +27,15 @@
:text="record.status === 'active' ? '正常' : record.status"
/>
</template>
<template v-if="column.key === 'acl'">
<a-tooltip
title="部门级信息源仅对绑定部门(及管理员)可见;未绑定部门的信息源对所有用户可见。"
>
<a-tag :color="record.department_id ? 'geekblue' : 'green'">
{{ record.department_id ? '部门级' : '全局可见' }}
</a-tag>
</a-tooltip>
</template>
<template v-if="column.key === 'last_synced'">
{{ record.last_synced ? formatTime(record.last_synced) : '从未同步' }}
</template>
@ -204,6 +213,7 @@ const columns = [
{ title: '名称', dataIndex: 'name', key: 'name' },
{ title: '类型', dataIndex: 'type', key: 'type', width: 120 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
{ title: '权限', key: 'acl', width: 110 },
{ title: '文档数', dataIndex: 'document_count', key: 'document_count', width: 80 },
{ title: '最近同步', dataIndex: 'last_synced', key: 'last_synced', width: 160 },
{ title: '操作', key: 'actions', width: 260 },

View File

@ -1,7 +1,27 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import * as kbApi from '@/api/kb'
import type { IKbSource, ISearchResult, IUploadedDocument } from '@/api/kb'
import type {
IKbSource,
ISearchResult,
IUploadedDocument,
IPreviewResult,
IKBSettings,
IKBSettingsUpdate,
} from '@/api/kb'
/**
* Default KB id used for KB-level settings.
*
* ponytail: the frontend has no KB selector UI; the settings endpoint
* keys on an arbitrary ``kb_id`` string. Using a single "default" KB is
* the smallest thing that works. Upgrade path: add a KB picker when
* multiple KBs are exposed.
*/
export const DEFAULT_KB_ID = 'default'
/** Terminal document statuses (no further polling needed). */
const TERMINAL_STATUSES = new Set(['indexed', 'failed'])
export const useKnowledgeStore = defineStore('knowledge', () => {
// --- State ---
@ -11,12 +31,20 @@ export const useKnowledgeStore = defineStore('knowledge', () => {
const isLoading = ref(false)
const isUploading = ref(false)
const isSearching = ref(false)
const isPreviewing = ref(false)
const previewResult = ref<IPreviewResult | null>(null)
const kbSettings = ref<IKBSettings | null>(null)
const isSavingSettings = ref(false)
const error = ref<string | null>(null)
// --- Getters ---
const sourceCount = computed(() => sources.value.length)
const localSources = computed(() => sources.value.filter((s) => s.type === 'local'))
const externalSources = computed(() => sources.value.filter((s) => s.type !== 'local'))
/** Documents still being processed (non-terminal) — drives status polling. */
const processingDocuments = computed(() =>
documents.value.filter((d) => !TERMINAL_STATUSES.has(d.status)),
)
// --- Actions ---
async function fetchSources(): Promise<void> {
@ -160,6 +188,84 @@ export const useKnowledgeStore = defineStore('knowledge', () => {
}
}
/** Preview segmentation for a file without persisting (read-only). */
async function previewDocument(
file: File,
chunkSize: number = 512,
chunkOverlap: number = 50,
): Promise<IPreviewResult> {
isPreviewing.value = true
error.value = null
previewResult.value = null
try {
const data = await kbApi.previewDocument(file, chunkSize, chunkOverlap)
previewResult.value = data
return data
} catch (err) {
error.value = err instanceof Error ? err.message : '分段预览失败'
console.error('Failed to preview document:', err)
throw err
} finally {
isPreviewing.value = false
}
}
/** Clear the preview result (when closing the preview modal). */
function clearPreview(): void {
previewResult.value = null
}
/**
* Retry vectorization for a failed document.
*
* ponytail: the backend vectorize endpoint currently returns 503
* (requires PG-backed KBStore, U8). The frontend still attempts the
* call so that once the backend lands, retry works without changes.
* Upgrade path: backend implements async vectorization + status push.
*/
async function retryDocument(documentId: string): Promise<void> {
try {
await kbApi.vectorizeDocument(documentId)
await fetchDocuments()
} catch (err) {
error.value = err instanceof Error ? err.message : '重试向量化失败'
console.error('Failed to retry vectorization:', err)
throw err
}
}
/** Fetch KB-level settings for the given KB (defaults to the single KB). */
async function fetchKbSettings(kbId: string = DEFAULT_KB_ID): Promise<void> {
isLoading.value = true
error.value = null
try {
kbSettings.value = await kbApi.getKbSettings(kbId)
} catch (err) {
error.value = err instanceof Error ? err.message : '获取 KB 设置失败'
console.error('Failed to fetch KB settings:', err)
} finally {
isLoading.value = false
}
}
/** Update KB-level settings (owner only). */
async function saveKbSettings(
update: IKBSettingsUpdate,
kbId: string = DEFAULT_KB_ID,
): Promise<void> {
isSavingSettings.value = true
error.value = null
try {
kbSettings.value = await kbApi.updateKbSettings(kbId, update)
} catch (err) {
error.value = err instanceof Error ? err.message : '保存 KB 设置失败'
console.error('Failed to save KB settings:', err)
throw err
} finally {
isSavingSettings.value = false
}
}
return {
// State
sources,
@ -168,11 +274,16 @@ export const useKnowledgeStore = defineStore('knowledge', () => {
isLoading,
isUploading,
isSearching,
isPreviewing,
previewResult,
kbSettings,
isSavingSettings,
error,
// Getters
sourceCount,
localSources,
externalSources,
processingDocuments,
// Actions
fetchSources,
addSource,
@ -184,5 +295,10 @@ export const useKnowledgeStore = defineStore('knowledge', () => {
deleteDocument,
syncSource,
updateSource,
previewDocument,
clearPreview,
retryDocument,
fetchKbSettings,
saveKbSettings,
}
})

View File

@ -10,6 +10,56 @@
<a-tab-pane key="search" tab="检索测试">
<SearchTest />
</a-tab-pane>
<a-tab-pane key="settings" tab="KB 设置">
<KBSettings />
</a-tab-pane>
<a-tab-pane key="history" tab="任务历史">
<div class="task-history">
<div class="task-history__toolbar">
<a-button :loading="kbStore.isLoading" @click="kbStore.fetchDocuments()">
刷新
</a-button>
<span class="task-history__hint">
文档处理任务列表{{ kbStore.documents.length }}
</span>
</div>
<a-table
:data-source="kbStore.documents"
:columns="taskColumns"
:loading="kbStore.isLoading"
row-key="document_id"
size="small"
:pagination="{ pageSize: 15 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-badge
:status="taskStatusBadge(record.status)"
:text="taskStatusLabel(record.status)"
/>
</template>
<template v-if="column.key === 'error'">
<a-tooltip
v-if="record.status === 'failed' && record.error_message"
:title="record.error_message"
>
<span class="task-history__error">
{{ record.error_message }}
</span>
</a-tooltip>
<span v-else class="task-history__muted"></span>
</template>
<template v-if="column.key === 'created_at'">
{{ formatTime(record.created_at) }}
</template>
</template>
</a-table>
<a-empty
v-if="!kbStore.isLoading && kbStore.documents.length === 0"
description="暂无任务记录"
/>
</div>
</a-tab-pane>
</a-tabs>
</div>
</template>
@ -20,10 +70,62 @@ import { useKnowledgeStore } from '@/stores/knowledge'
import DocumentUpload from '@/components/kb/DocumentUpload.vue'
import SourceConfig from '@/components/kb/SourceConfig.vue'
import SearchTest from '@/components/kb/SearchTest.vue'
import KBSettings from '@/components/kb/KBSettings.vue'
const kbStore = useKnowledgeStore()
const activeTab = ref('documents')
const taskColumns = [
{ title: '文件名', dataIndex: 'filename', key: 'filename', ellipsis: true },
{ title: '状态', dataIndex: 'status', key: 'status', width: 130 },
{ title: '分块数', dataIndex: 'chunks', key: 'chunks', width: 80 },
{ title: '错误信息', key: 'error', ellipsis: true },
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 160 },
]
function taskStatusBadge(status: string): 'processing' | 'success' | 'error' | 'default' {
switch (status) {
case 'indexed':
return 'success'
case 'failed':
return 'error'
case 'pending':
case 'parsing':
case 'segmenting':
case 'vectorizing':
return 'processing'
default:
return 'default'
}
}
function taskStatusLabel(status: string): string {
switch (status) {
case 'pending':
return '待处理'
case 'parsing':
return '解析中'
case 'segmenting':
return '分段中'
case 'vectorizing':
return '向量化中'
case 'indexed':
return '已索引'
case 'failed':
return '失败'
default:
return status
}
}
function formatTime(isoStr: string): string {
try {
return new Date(isoStr).toLocaleString('zh-CN')
} catch {
return isoStr
}
}
onMounted(() => {
kbStore.fetchSources()
})
@ -36,4 +138,30 @@ onMounted(() => {
overflow-y: auto;
background: var(--bg-primary);
}
.task-history {
padding: 8px 0;
}
.task-history__toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.task-history__hint {
color: var(--text-placeholder);
font-size: 13px;
}
.task-history__error {
color: var(--color-error, #ff4d4f);
font-size: 13px;
cursor: help;
}
.task-history__muted {
color: var(--text-placeholder);
}
</style>