feat: 新增知识库文档查找和打开工具,优化数据库信息展示

This commit is contained in:
Wenjie Zhang 2026-05-18 17:52:05 +08:00
parent 1a3ec71b2b
commit 213a542a87
13 changed files with 448 additions and 14 deletions

View File

@ -27,4 +27,6 @@
- [ ] Config spacy model 的 load
- [x] 在工作区的文件编辑的时候,保存和取消的按钮应该是悬浮在编辑框的右上角,而不是在 header 上面
- [ ] default enable all build in tools / kbs / skills / mcps / subagents
- [ ] 链接 Notion 和 feishu 目前来看,都是支持的
- [ ] 链接 Notion 和 feishu 目前来看,都是支持的
- [ ] 知识库的权限调整修改为三个等级全局共享、部门共享选择多个部门默认是自己部门且必须包含自己部门、指定人可访问选择多个用户默认是仅自己可以添加其他人。UI 上也需要调整三个卡片不再是等宽而是选中的会宽一点并展示描述以及选择按钮未选中的则是默认宽度仅显示标题。对于选中的卡片除了展示描述、按钮之外还包括“X 个部门可访问”、“X 个用户可访问”的信息展示。全局的就看是所有用户可访问。所以等级的字段配置也要重新设计,不需要考虑兼容,所有知识库都会重新构建。
- [ ] databaseinfo 的重构,在左侧展示那个 tab 标签吧将文件管理filetable以及右侧的那些图谱、检索、检索配置、评估之类的都列为不同的 tab进入之后默认激活的是 filetable。这样页面布局就好的多。作恶侧边栏除了这些 tab 之外,顶部是和 Skill Detail 那里的 header 一样,

View File

@ -23,6 +23,8 @@ import WebSearchTool from './tools/WebSearchTool.vue'
import ListKbsTool from './tools/ListKbsTool.vue'
import GetMindmapTool from './tools/GetMindmapTool.vue'
import QueryKbTool from './tools/QueryKbTool.vue'
import FindKbDocumentTool from './tools/FindKbDocumentTool.vue'
import OpenKbDocumentTool from './tools/OpenKbDocumentTool.vue'
import CalculatorTool from './tools/CalculatorTool.vue'
import TodoListTool from './tools/TodoListTool.vue'
import TaskTool from './tools/TaskTool.vue'
@ -65,6 +67,7 @@ const TOOL_RENDERERS = {
cmd: ExecuteTool,
edit_file: EditFileTool,
execute: ExecuteTool,
find_kb_document: FindKbDocumentTool,
get_mindmap: GetMindmapTool,
glob: GlobTool,
grep: GrepTool,
@ -74,6 +77,7 @@ const TOOL_RENDERERS = {
mysql_describe_table: MysqlDescribeTableTool,
mysql_list_tables: MysqlListTablesTool,
mysql_query: MysqlQueryTool,
open_kb_document: OpenKbDocumentTool,
query_kb: QueryKbTool,
read_file: ReadFileTool,
replace: EditFileTool,

View File

@ -7,6 +7,8 @@ export { default as WebSearchTool } from './tools/WebSearchTool.vue'
export { default as ListKbsTool } from './tools/ListKbsTool.vue'
export { default as GetMindmapTool } from './tools/GetMindmapTool.vue'
export { default as QueryKbTool } from './tools/QueryKbTool.vue'
export { default as FindKbDocumentTool } from './tools/FindKbDocumentTool.vue'
export { default as OpenKbDocumentTool } from './tools/OpenKbDocumentTool.vue'
export { default as CalculatorTool } from './tools/CalculatorTool.vue'
export { default as TodoListTool } from './tools/TodoListTool.vue'
export { default as ImageTool } from './tools/ImageTool.vue'

View File

@ -24,6 +24,7 @@ export const TOOL_ICON_MAP = {
cmd: Terminal,
edit_file: FilePen,
execute: Terminal,
find_kb_document: FolderSearch,
get_mindmap: Network,
glob: FolderSearch,
grep: FolderSearch,
@ -33,6 +34,7 @@ export const TOOL_ICON_MAP = {
mysql_describe_table: Database,
mysql_list_tables: Database,
mysql_query: Database,
open_kb_document: FileText,
present_artifacts: FolderOutput,
query_kb: BookOpen,
read_file: FileText,

View File

@ -0,0 +1,88 @@
<template>
<BaseToolCall :tool-call="toolCall" :hide-params="true">
<template #header>
<div class="sep-header">
<span class="note">Find</span>
<span class="separator" v-if="resourceName">|</span>
<span class="description" v-if="resourceName">知识库: {{ resourceName }}</span>
<span class="separator" v-if="fileId">|</span>
<span class="description" v-if="fileId">文件: {{ fileId }}</span>
<span class="tag" v-if="patternsLabel" :title="fullPatternsLabel">{{ patternsLabel }}</span>
</div>
</template>
<template #result="{ resultContent }">
<KbDocumentPreview
v-if="isPreviewResult(parsedResult(resultContent))"
:result="parsedResult(resultContent)"
mode="find"
/>
<div v-else class="plain-result">{{ stringifyResult(resultContent) }}</div>
</template>
</BaseToolCall>
</template>
<script setup>
import { computed } from 'vue'
import BaseToolCall from '../BaseToolCall.vue'
import KbDocumentPreview from './KbDocumentPreview.vue'
import { useDatabaseStore } from '@/stores/database'
const props = defineProps({
toolCall: {
type: Object,
required: true
}
})
const databaseStore = useDatabaseStore()
const args = computed(() => {
const value = props.toolCall.args || props.toolCall.function?.arguments
if (!value) return {}
if (typeof value === 'object') return value
try {
return JSON.parse(value)
} catch {
return {}
}
})
const resourceName = computed(() => databaseStore.getDatabaseNameById(args.value.resource_id))
const fileId = computed(() => args.value.file_id || '')
const patterns = computed(() => (Array.isArray(args.value.patterns) ? args.value.patterns : []))
const fullPatternsLabel = computed(() => patterns.value.join(', '))
const patternsLabel = computed(() => {
if (patterns.value.length <= 3) return fullPatternsLabel.value
return `${patterns.value.slice(0, 3).join(', ')}...`
})
const parsedResult = (content) => {
if (typeof content !== 'string') return content
try {
return JSON.parse(content)
} catch {
return content
}
}
const isPreviewResult = (result) =>
result && typeof result === 'object' && Array.isArray(result.windows)
const stringifyResult = (content) => {
const result = parsedResult(content)
return typeof result === 'string' ? result : JSON.stringify(result, null, 2)
}
</script>
<style scoped lang="less">
.plain-result {
border: 1px solid var(--gray-150);
border-radius: 8px;
padding: 10px 12px;
color: var(--gray-700);
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
}
</style>

View File

@ -0,0 +1,176 @@
<template>
<div class="kb-document-preview">
<div class="document-summary">
<div class="summary-main">{{ summaryText }}</div>
<div v-if="result?.file_id" class="summary-meta">file_id: {{ result.file_id }}</div>
</div>
<div v-if="documentWindows.length > 0" class="window-list">
<div
v-for="(window, windowIndex) in documentWindows"
:key="windowKey(window, windowIndex)"
class="window-card"
>
<div class="window-title">
<span>{{ windowTitle(window, windowIndex) }}</span>
<span v-if="window.matched_lines?.length" class="matched-count">
命中 {{ window.matched_lines.length }}
</span>
</div>
<pre class="content-preview"><span
v-for="(line, lineIndex) in splitContent(window.content)"
:key="`${windowKey(window, windowIndex)}-${lineIndex}`"
class="content-line"
:class="{ matched: isMatchedLine(line, window.matched_lines) }"
>{{ line }}</span></pre>
</div>
</div>
<div v-else class="empty-text">暂无可预览内容</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
result: {
type: Object,
required: true
},
mode: {
type: String,
default: 'open'
}
})
const documentWindows = computed(() => {
if (Array.isArray(props.result.windows)) return props.result.windows
if (props.result.content) return [props.result]
return []
})
const summaryText = computed(() => {
if (props.mode === 'find') {
const modeText = props.result.match_mode === 'regex' ? '正则' : '关键词'
return `${modeText}查找: ${props.result.total_matches || 0} 处匹配,${documentWindows.value.length} 个上下文窗口`
}
const startLine = props.result.start_line || 0
const endLine = props.result.end_line || 0
const totalLines = props.result.total_lines || 0
const moreText = props.result.has_more_after ? `,下一段 offset ${props.result.next_offset}` : ''
return `打开文档: 第 ${startLine}-${endLine} 行 / 共 ${totalLines}${moreText}`
})
const splitContent = (content = '') => String(content).split('\n')
const lineNumber = (line) => {
const match = String(line).match(/^\s*(\d+)\t/)
return match ? Number(match[1]) : null
}
const isMatchedLine = (line, matchedLines = []) => {
const number = lineNumber(line)
return number !== null && matchedLines.includes(number)
}
const windowKey = (window, index) => `${window.start_line || 0}-${window.end_line || 0}-${index}`
const windowTitle = (window, index) => {
const startLine = window.start_line || 0
const endLine = window.end_line || 0
if (startLine || endLine) return `窗口 ${index + 1}: 第 ${startLine}-${endLine}`
return `窗口 ${index + 1}`
}
</script>
<style scoped lang="less">
.kb-document-preview {
border: 1px solid var(--gray-150);
border-radius: 8px;
background: var(--gray-0);
overflow: hidden;
.document-summary {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
background: var(--gray-25);
border-bottom: 1px solid var(--gray-100);
font-size: 12px;
color: var(--gray-700);
.summary-main {
font-weight: 600;
}
.summary-meta {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--gray-600);
}
}
.window-list {
display: flex;
flex-direction: column;
}
.window-card {
border-bottom: 1px solid var(--gray-100);
&:last-child {
border-bottom: none;
}
}
.window-title {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
font-size: 12px;
color: var(--gray-700);
background: var(--gray-10);
.matched-count {
color: var(--main-700);
white-space: nowrap;
}
}
.content-preview {
margin: 0;
padding: 10px 12px;
max-height: 360px;
overflow: auto;
background: var(--gray-0);
color: var(--gray-700);
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.content-line {
display: block;
min-height: 1.6em;
&.matched {
background: var(--main-50);
color: var(--main-800);
}
}
.empty-text {
padding: 14px;
text-align: center;
color: var(--gray-600);
font-size: 12px;
}
}
</style>

View File

@ -0,0 +1,87 @@
<template>
<BaseToolCall :tool-call="toolCall" :hide-params="true">
<template #header>
<div class="sep-header">
<span class="note">Open</span>
<span class="separator" v-if="resourceName">|</span>
<span class="description" v-if="resourceName">知识库: {{ resourceName }}</span>
<span class="separator" v-if="fileId">|</span>
<span class="description" v-if="fileId">文件: {{ fileId }}</span>
<span class="tag" v-if="lineLabel">{{ lineLabel }}</span>
</div>
</template>
<template #result="{ resultContent }">
<KbDocumentPreview
v-if="isPreviewResult(parsedResult(resultContent))"
:result="parsedResult(resultContent)"
mode="open"
/>
<div v-else class="plain-result">{{ stringifyResult(resultContent) }}</div>
</template>
</BaseToolCall>
</template>
<script setup>
import { computed } from 'vue'
import BaseToolCall from '../BaseToolCall.vue'
import KbDocumentPreview from './KbDocumentPreview.vue'
import { useDatabaseStore } from '@/stores/database'
const props = defineProps({
toolCall: {
type: Object,
required: true
}
})
const databaseStore = useDatabaseStore()
const args = computed(() => {
const value = props.toolCall.args || props.toolCall.function?.arguments
if (!value) return {}
if (typeof value === 'object') return value
try {
return JSON.parse(value)
} catch {
return {}
}
})
const resourceName = computed(() => databaseStore.getDatabaseNameById(args.value.resource_id))
const fileId = computed(() => args.value.file_id || '')
const lineLabel = computed(() => {
if (args.value.line) return `Line ${args.value.line}`
if (args.value.offset !== undefined) return `Offset ${args.value.offset}`
return ''
})
const parsedResult = (content) => {
if (typeof content !== 'string') return content
try {
return JSON.parse(content)
} catch {
return content
}
}
const isPreviewResult = (result) =>
result && typeof result === 'object' && typeof result.content === 'string'
const stringifyResult = (content) => {
const result = parsedResult(content)
return typeof result === 'string' ? result : JSON.stringify(result, null, 2)
}
</script>
<style scoped lang="less">
.plain-result {
border: 1px solid var(--gray-150);
border-radius: 8px;
padding: 10px 12px;
color: var(--gray-700);
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
}
</style>

View File

@ -3,8 +3,8 @@
<template #header>
<div class="sep-header">
<span class="note">{{ operationLabel }}</span>
<span class="separator" v-if="kbName">|</span>
<span class="description" v-if="kbName">知识库: {{ kbName }}</span>
<span class="separator" v-if="resourceLabel">|</span>
<span class="description" v-if="resourceLabel">知识库: {{ resourceLabel }}</span>
<span class="separator" v-if="queryText">|</span>
<span class="description">{{ queryText }}</span>
</div>
@ -93,6 +93,7 @@
import { computed } from 'vue'
import BaseToolCall from '../BaseToolCall.vue'
import KbResultGroupedList from '@/components/sources/KbResultGroupedList.vue'
import { useDatabaseStore } from '@/stores/database'
const props = defineProps({
toolCall: {
@ -101,6 +102,8 @@ const props = defineProps({
}
})
const databaseStore = useDatabaseStore()
const args = computed(() => {
const value = props.toolCall.args || props.toolCall.function?.arguments
if (!value) return {}
@ -116,7 +119,9 @@ const toolName = computed(() => props.toolCall.name || props.toolCall.function?.
const operationLabel = computed(() => `${toolName.value} 搜索`)
const kbName = computed(() => args.value.kb_name || '')
const resourceLabel = computed(
() => args.value.kb_name || databaseStore.getDatabaseNameById(args.value.resource_id)
)
const queryText = computed(() => args.value.query_text || '')
const EMPTY_RESULT = Object.freeze({
@ -133,7 +138,9 @@ const normalizeChunks = (payload) => {
if (Array.isArray(payload)) return payload
if (!payload || typeof payload !== 'object') return []
if (Array.isArray(payload.results)) return payload.results
if (Array.isArray(payload.chunks)) return payload.chunks
if (Array.isArray(payload.data?.results)) return payload.data.results
if (Array.isArray(payload.data?.chunks)) return payload.data.chunks
return []

View File

@ -97,7 +97,31 @@ const resolveChunks = (input) => {
}
const normalizedChunks = computed(() =>
resolveChunks(props.chunks).filter((item) => item && typeof item === 'object' && item.content)
resolveChunks(props.chunks)
.filter((item) => item && typeof item === 'object' && item.content)
.map((item) => {
const metadata = item.metadata && typeof item.metadata === 'object' ? item.metadata : {}
const source =
metadata.source ||
metadata.file_name ||
metadata.filename ||
metadata.title ||
item.file_name ||
item.filename ||
item.file_id ||
item.resource_id ||
'未知来源'
return {
...item,
score: typeof item.score === 'number' ? item.score : metadata.score,
metadata: {
...metadata,
source,
chunk_id: metadata.chunk_id || item.id
}
}
})
)
const fileGroupList = computed(() => {

View File

@ -527,6 +527,22 @@ export const useDatabaseStore = defineStore('database', () => {
}
}
function getDatabaseNameById(id) {
const normalizedId = String(id || '').trim()
if (!normalizedId) return ''
const matchedDatabase = databases.value.find(
(item) => String(item.db_id || '').trim() === normalizedId
)
if (matchedDatabase?.name) return matchedDatabase.name
if (String(database.value?.db_id || '').trim() === normalizedId) {
return database.value?.name || ''
}
return ''
}
return {
databases,
database,
@ -554,6 +570,7 @@ export const useDatabaseStore = defineStore('database', () => {
startAutoRefresh,
stopAutoRefresh,
toggleAutoRefresh,
selectAllFailedFiles
selectAllFailedFiles,
getDatabaseNameById
}
})

View File

@ -16,7 +16,7 @@ export const brandIcons = {
export const getKbTypeLabel = (type) => {
const labels = {
milvus: 'CommonRAG',
milvus: 'Yuxi',
dify: 'Dify',
notion: 'Notion'
}
@ -40,3 +40,24 @@ export const getKbTypeColor = (type) => {
}
return colors[type] || 'blue'
}
const READ_ONLY_KB_TYPES = new Set(['dify', 'notion'])
export const isReadOnlyDatabase = (database, kbTypes = {}) => {
const kbType = (typeof database === 'string' ? database : database?.kb_type || 'milvus').toLowerCase()
if (database?.supports_documents !== undefined) {
return database.supports_documents === false
}
if (kbTypes[kbType]?.supports_documents !== undefined) {
return kbTypes[kbType].supports_documents === false
}
return READ_ONLY_KB_TYPES.has(kbType)
}
export const kbUtils = {
getKbTypeLabel,
getKbTypeIcon,
getKbTypeColor,
isReadOnlyDatabase
}

View File

@ -139,7 +139,7 @@
</template>
<script setup>
import { ref, watch, onUnmounted, computed } from 'vue'
import { ref, watch, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useDatabaseStore } from '@/stores/database'
import { useTaskerStore } from '@/stores/tasker'
@ -157,6 +157,7 @@ import RAGEvaluationTab from '@/components/RAGEvaluationTab.vue'
import EvaluationBenchmarks from '@/components/EvaluationBenchmarks.vue'
import SearchConfigModal from '@/components/SearchConfigModal.vue'
import SearchConfigPanel from '@/components/SearchConfigPanel.vue'
import { kbUtils } from '@/utils/kb_utils'
const route = useRoute()
const store = useDatabaseStore()
@ -168,7 +169,9 @@ const state = computed(() => store.state)
const isCurrentDatabaseLoaded = computed(() => database.value?.db_id === databaseId.value)
const kbType = computed(() => (isCurrentDatabaseLoaded.value ? database.value.kb_type?.toLowerCase() : ''))
const isMilvus = computed(() => kbType.value === 'milvus')
const isConnector = computed(() => Boolean(kbType.value) && !isMilvus.value)
const isConnector = computed(
() => isCurrentDatabaseLoaded.value && kbUtils.isReadOnlyDatabase(database.value)
)
// status: 'uploaded'
const pendingParseCount = computed(() => {
@ -237,9 +240,7 @@ const isDragging = ref(false)
watch(
() => [databaseId.value, isMilvus.value],
([newDbId, isMilvusType], oldValue = []) => {
const [oldDbId] = oldValue
([newDbId, isMilvusType]) => {
if (!newDbId) {
return
}

View File

@ -220,7 +220,7 @@ import ExtensionCardGrid from '@/components/extensions/ExtensionCardGrid.vue'
import InfoCard from '@/components/shared/InfoCard.vue'
import dayjs, { parseToShanghai } from '@/utils/time'
import AiTextarea from '@/components/AiTextarea.vue'
import { getKbTypeLabel, getKbTypeIcon, getKbTypeColor } from '@/utils/kb_utils'
import { getKbTypeLabel, getKbTypeIcon, getKbTypeColor, kbUtils } from '@/utils/kb_utils'
import { CHUNK_PRESET_OPTIONS, getChunkPresetDescription } from '@/utils/chunk_presets'
const route = useRoute()
@ -450,10 +450,13 @@ const handleCreateDatabase = async () => {
}
const cardSubtitle = (database) => {
const parts = [`${database.row_count || 0} 文件`]
const parts = []
if (database.created_at) {
parts.push(formatCreatedTime(database.created_at))
}
if (!kbUtils.isReadOnlyDatabase(database)) {
parts.push(`${database.row_count || 0} 文件`)
}
return parts.join(' · ')
}