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:
parent
e3ae2f3a56
commit
1f691ca178
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
<template>
|
||||
<div class="document-upload">
|
||||
<div class="document-upload__toolbar">
|
||||
<a-upload-dragger
|
||||
class="document-upload__dragger"
|
||||
:multiple="true"
|
||||
:custom-request="handleUpload"
|
||||
:before-upload="beforeUpload"
|
||||
|
|
@ -16,6 +18,12 @@
|
|||
</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" />
|
||||
|
||||
<div v-if="kbStore.documents.length > 0" class="document-list">
|
||||
|
|
@ -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-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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue