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
|
document_count: number
|
||||||
last_synced: string | null
|
last_synced: string | null
|
||||||
config?: Record<string, unknown>
|
config?: Record<string, unknown>
|
||||||
|
/** Department binding for ACL (U4). Absent = global source. */
|
||||||
|
department_id?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IAddSourceRequest {
|
/** Document processing status flow: pending → parsing → segmenting → vectorizing → indexed | failed. */
|
||||||
name: string
|
export type DocumentStatus =
|
||||||
type: 'local' | 'feishu' | 'confluence' | 'http'
|
| 'pending'
|
||||||
config: Record<string, unknown>
|
| 'parsing'
|
||||||
|
| 'segmenting'
|
||||||
|
| 'vectorizing'
|
||||||
|
| 'indexed'
|
||||||
|
| 'failed'
|
||||||
|
|
||||||
|
export interface IPreviewChunk {
|
||||||
|
index: number
|
||||||
|
content: string
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IUploadedDocument {
|
export interface IUploadedDocument {
|
||||||
|
|
@ -27,6 +38,56 @@ export interface IUploadedDocument {
|
||||||
chunks: number
|
chunks: number
|
||||||
status: string
|
status: string
|
||||||
created_at: 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 {
|
export interface ISearchResult {
|
||||||
|
|
@ -125,6 +186,41 @@ class KbApiClient extends BaseApiClient {
|
||||||
body: JSON.stringify(data),
|
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()
|
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 deleteDocument = kbApi.deleteDocument.bind(kbApi)
|
||||||
export const syncSource = kbApi.syncSource.bind(kbApi)
|
export const syncSource = kbApi.syncSource.bind(kbApi)
|
||||||
export const updateSource = kbApi.updateSource.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,20 +1,28 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="document-upload">
|
<div class="document-upload">
|
||||||
<a-upload-dragger
|
<div class="document-upload__toolbar">
|
||||||
:multiple="true"
|
<a-upload-dragger
|
||||||
:custom-request="handleUpload"
|
class="document-upload__dragger"
|
||||||
:before-upload="beforeUpload"
|
:multiple="true"
|
||||||
:show-upload-list="false"
|
:custom-request="handleUpload"
|
||||||
accept=".pdf,.docx,.doc,.md,.txt,.html,.csv,.json"
|
:before-upload="beforeUpload"
|
||||||
>
|
:show-upload-list="false"
|
||||||
<p class="ant-upload-drag-icon">
|
accept=".pdf,.docx,.doc,.md,.txt,.html,.csv,.json"
|
||||||
<InboxOutlined />
|
>
|
||||||
</p>
|
<p class="ant-upload-drag-icon">
|
||||||
<p class="ant-upload-text">点击或拖拽文件到此区域上传</p>
|
<InboxOutlined />
|
||||||
<p class="ant-upload-hint">
|
</p>
|
||||||
支持 PDF、Word、Markdown、HTML、纯文本等格式
|
<p class="ant-upload-text">点击或拖拽文件到此区域上传</p>
|
||||||
</p>
|
<p class="ant-upload-hint">
|
||||||
</a-upload-dragger>
|
支持 PDF、Word、Markdown、HTML、纯文本等格式
|
||||||
|
</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" />
|
<a-spin v-if="kbStore.isUploading" tip="正在上传处理..." class="upload-spin" />
|
||||||
|
|
||||||
|
|
@ -30,20 +38,37 @@
|
||||||
>
|
>
|
||||||
<template #bodyCell="{ column, record }">
|
<template #bodyCell="{ column, record }">
|
||||||
<template v-if="column.key === 'status'">
|
<template v-if="column.key === 'status'">
|
||||||
<a-tag :color="record.status === 'indexed' ? 'green' : 'orange'">
|
<a-tooltip
|
||||||
{{ record.status === 'indexed' ? '已索引' : '处理中' }}
|
:title="record.error_message"
|
||||||
</a-tag>
|
:open="record.status === 'failed' && record.error_message ? undefined : false"
|
||||||
|
>
|
||||||
|
<a-badge
|
||||||
|
:status="statusBadge(record.status)"
|
||||||
|
:text="statusLabel(record.status)"
|
||||||
|
/>
|
||||||
|
</a-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="column.key === 'created_at'">
|
<template v-if="column.key === 'created_at'">
|
||||||
{{ formatTime(record.created_at) }}
|
{{ formatTime(record.created_at) }}
|
||||||
</template>
|
</template>
|
||||||
<template v-if="column.key === 'actions'">
|
<template v-if="column.key === 'actions'">
|
||||||
<a-popconfirm
|
<a-space :size="4">
|
||||||
title="确定删除此文档?"
|
<a-button
|
||||||
@confirm="handleDelete(record.document_id)"
|
v-if="record.status === 'failed'"
|
||||||
>
|
type="link"
|
||||||
<a-button type="link" size="small" danger>删除</a-button>
|
size="small"
|
||||||
</a-popconfirm>
|
: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>
|
||||||
</template>
|
</template>
|
||||||
</a-table>
|
</a-table>
|
||||||
|
|
@ -53,25 +78,67 @@
|
||||||
v-else-if="!kbStore.isUploading && !kbStore.isLoading"
|
v-else-if="!kbStore.isUploading && !kbStore.isLoading"
|
||||||
description="暂无文档"
|
description="暂无文档"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SegmentPreview v-model:open="showPreview" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from 'vue'
|
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { message } from 'ant-design-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 { useKnowledgeStore } from '@/stores/knowledge'
|
||||||
|
import SegmentPreview from './SegmentPreview.vue'
|
||||||
|
|
||||||
const kbStore = useKnowledgeStore()
|
const kbStore = useKnowledgeStore()
|
||||||
|
|
||||||
|
const showPreview = ref(false)
|
||||||
|
const retryingId = ref<string | null>(null)
|
||||||
|
|
||||||
const docColumns = [
|
const docColumns = [
|
||||||
{ title: '文件名', dataIndex: 'filename', key: 'filename' },
|
{ title: '文件名', dataIndex: 'filename', key: 'filename', ellipsis: true },
|
||||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
|
{ title: '状态', dataIndex: 'status', key: 'status', width: 140 },
|
||||||
{ title: '分块数', dataIndex: 'chunks', key: 'chunks', width: 80 },
|
{ title: '分块数', dataIndex: 'chunks', key: 'chunks', width: 80 },
|
||||||
{ title: '上传时间', dataIndex: 'created_at', key: 'created_at', width: 160 },
|
{ 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 {
|
function formatTime(isoStr: string): string {
|
||||||
try {
|
try {
|
||||||
const d = new Date(isoStr)
|
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(() => {
|
onMounted(() => {
|
||||||
kbStore.fetchDocuments()
|
kbStore.fetchDocuments()
|
||||||
|
if (kbStore.processingDocuments.length > 0) {
|
||||||
|
startPolling()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopPolling()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -122,6 +251,21 @@ onMounted(() => {
|
||||||
padding: 8px 0;
|
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 {
|
.upload-spin {
|
||||||
display: block;
|
display: block;
|
||||||
text-align: center;
|
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"
|
style="width: 80px"
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="检索策略">
|
<a-form-item label="检索模式">
|
||||||
<a-radio-group v-model:value="strategy">
|
<a-radio-group v-model:value="queryMode" button-style="solid">
|
||||||
<a-radio-button value="vector">向量</a-radio-button>
|
<a-radio-button value="embedding">语义</a-radio-button>
|
||||||
<a-radio-button value="hybrid">混合</a-radio-button>
|
<a-radio-button value="keywords">关键词</a-radio-button>
|
||||||
|
<a-radio-button value="blend">混合</a-radio-button>
|
||||||
</a-radio-group>
|
</a-radio-group>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
|
|
@ -75,6 +76,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useKnowledgeStore } from '@/stores/knowledge'
|
import { useKnowledgeStore } from '@/stores/knowledge'
|
||||||
|
import type { QueryMode } from '@/api/kb'
|
||||||
|
|
||||||
const kbStore = useKnowledgeStore()
|
const kbStore = useKnowledgeStore()
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
|
|
@ -83,7 +85,7 @@ const activeCollapse = ref<string[]>([])
|
||||||
|
|
||||||
const selectedSourceIds = ref<string[]>([])
|
const selectedSourceIds = ref<string[]>([])
|
||||||
const topK = ref(5)
|
const topK = ref(5)
|
||||||
const strategy = ref('vector')
|
const queryMode = ref<QueryMode>('blend')
|
||||||
|
|
||||||
const sourceOptions = computed(() =>
|
const sourceOptions = computed(() =>
|
||||||
kbStore.sources.map((s) => ({ label: s.name, value: s.id }))
|
kbStore.sources.map((s) => ({ label: s.name, value: s.id }))
|
||||||
|
|
@ -93,7 +95,7 @@ async function handleSearch(): Promise<void> {
|
||||||
if (!searchQuery.value.trim()) return
|
if (!searchQuery.value.trim()) return
|
||||||
hasSearched.value = true
|
hasSearched.value = true
|
||||||
const sourceIds = selectedSourceIds.value.length > 0 ? selectedSourceIds.value : undefined
|
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(() => {
|
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"
|
:text="record.status === 'active' ? '正常' : record.status"
|
||||||
/>
|
/>
|
||||||
</template>
|
</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'">
|
<template v-if="column.key === 'last_synced'">
|
||||||
{{ record.last_synced ? formatTime(record.last_synced) : '从未同步' }}
|
{{ record.last_synced ? formatTime(record.last_synced) : '从未同步' }}
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -204,6 +213,7 @@ const columns = [
|
||||||
{ title: '名称', dataIndex: 'name', key: 'name' },
|
{ title: '名称', dataIndex: 'name', key: 'name' },
|
||||||
{ title: '类型', dataIndex: 'type', key: 'type', width: 120 },
|
{ title: '类型', dataIndex: 'type', key: 'type', width: 120 },
|
||||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
|
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
|
||||||
|
{ title: '权限', key: 'acl', width: 110 },
|
||||||
{ title: '文档数', dataIndex: 'document_count', key: 'document_count', width: 80 },
|
{ title: '文档数', dataIndex: 'document_count', key: 'document_count', width: 80 },
|
||||||
{ title: '最近同步', dataIndex: 'last_synced', key: 'last_synced', width: 160 },
|
{ title: '最近同步', dataIndex: 'last_synced', key: 'last_synced', width: 160 },
|
||||||
{ title: '操作', key: 'actions', width: 260 },
|
{ title: '操作', key: 'actions', width: 260 },
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,27 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import * as kbApi from '@/api/kb'
|
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', () => {
|
export const useKnowledgeStore = defineStore('knowledge', () => {
|
||||||
// --- State ---
|
// --- State ---
|
||||||
|
|
@ -11,12 +31,20 @@ export const useKnowledgeStore = defineStore('knowledge', () => {
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const isUploading = ref(false)
|
const isUploading = ref(false)
|
||||||
const isSearching = 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)
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
// --- Getters ---
|
// --- Getters ---
|
||||||
const sourceCount = computed(() => sources.value.length)
|
const sourceCount = computed(() => sources.value.length)
|
||||||
const localSources = computed(() => sources.value.filter((s) => s.type === 'local'))
|
const localSources = computed(() => sources.value.filter((s) => s.type === 'local'))
|
||||||
const externalSources = 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 ---
|
// --- Actions ---
|
||||||
async function fetchSources(): Promise<void> {
|
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 {
|
return {
|
||||||
// State
|
// State
|
||||||
sources,
|
sources,
|
||||||
|
|
@ -168,11 +274,16 @@ export const useKnowledgeStore = defineStore('knowledge', () => {
|
||||||
isLoading,
|
isLoading,
|
||||||
isUploading,
|
isUploading,
|
||||||
isSearching,
|
isSearching,
|
||||||
|
isPreviewing,
|
||||||
|
previewResult,
|
||||||
|
kbSettings,
|
||||||
|
isSavingSettings,
|
||||||
error,
|
error,
|
||||||
// Getters
|
// Getters
|
||||||
sourceCount,
|
sourceCount,
|
||||||
localSources,
|
localSources,
|
||||||
externalSources,
|
externalSources,
|
||||||
|
processingDocuments,
|
||||||
// Actions
|
// Actions
|
||||||
fetchSources,
|
fetchSources,
|
||||||
addSource,
|
addSource,
|
||||||
|
|
@ -184,5 +295,10 @@ export const useKnowledgeStore = defineStore('knowledge', () => {
|
||||||
deleteDocument,
|
deleteDocument,
|
||||||
syncSource,
|
syncSource,
|
||||||
updateSource,
|
updateSource,
|
||||||
|
previewDocument,
|
||||||
|
clearPreview,
|
||||||
|
retryDocument,
|
||||||
|
fetchKbSettings,
|
||||||
|
saveKbSettings,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,56 @@
|
||||||
<a-tab-pane key="search" tab="检索测试">
|
<a-tab-pane key="search" tab="检索测试">
|
||||||
<SearchTest />
|
<SearchTest />
|
||||||
</a-tab-pane>
|
</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>
|
</a-tabs>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -20,10 +70,62 @@ import { useKnowledgeStore } from '@/stores/knowledge'
|
||||||
import DocumentUpload from '@/components/kb/DocumentUpload.vue'
|
import DocumentUpload from '@/components/kb/DocumentUpload.vue'
|
||||||
import SourceConfig from '@/components/kb/SourceConfig.vue'
|
import SourceConfig from '@/components/kb/SourceConfig.vue'
|
||||||
import SearchTest from '@/components/kb/SearchTest.vue'
|
import SearchTest from '@/components/kb/SearchTest.vue'
|
||||||
|
import KBSettings from '@/components/kb/KBSettings.vue'
|
||||||
|
|
||||||
const kbStore = useKnowledgeStore()
|
const kbStore = useKnowledgeStore()
|
||||||
const activeTab = ref('documents')
|
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(() => {
|
onMounted(() => {
|
||||||
kbStore.fetchSources()
|
kbStore.fetchSources()
|
||||||
})
|
})
|
||||||
|
|
@ -36,4 +138,30 @@ onMounted(() => {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: var(--bg-primary);
|
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>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue