Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion ui/src/api/API_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -548,7 +548,8 @@ Workspace 共享 Paragraph API 仅 getParagraphPage、getParagraphDetail,不
Workspace、System 资源管理、System 共享提供完整问题 API:
`postProblems` 直接提交内容字符串数组,`putProblem` 提交 `{ content }`,单项 DELETE 使用问题 ID,
`putBatchDeleteProblems` 直接提交问题 ID 数组;单个问题内容最多 256 字符。
`getProblemDetail` 返回 `ProblemParagraph[]`,包含分段字段及 knowledge_id、document_name。
`getProblemDetail` 返回 `ProblemParagraph[]`,包含分段字段及 knowledge_id、document_name、knowledge_type、
document_resource_type、document_image_id;来源元数据由后端补齐,图片详情使用 document_image_id 读取原图。
取消关联复用 Paragraph API 的 `putUnassociateParagraphProblem`,不调用问题或分段删除接口。
Workspace 共享仅有 `getProblemsPage`,不补造详情或写接口,不回退其他范围。

Expand Down
8 changes: 6 additions & 2 deletions ui/src/api/types/paragraph.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { RelatedQuestionsConfig } from './knowledge'
import type { KnowledgeType, RelatedQuestionsConfig } from './knowledge'
import type { DocumentResourceType } from './document'

/** 文档分段的查询响应与新增载荷。 */
export interface ParagraphItem {
Expand Down Expand Up @@ -29,10 +30,13 @@ export interface ProblemItem extends ParagraphProblem {
update_time: string
}

/** 从问题详情查询到的关联分段,额外携带所属知识库与文档名称。 */
/** 从问题详情查询到的关联分段,包含所属知识库、文档及图片来源信息。 */
export interface ProblemParagraph extends ParagraphItem {
knowledge_id: string
document_name: string
knowledge_type: KnowledgeType | null
document_resource_type: DocumentResourceType
document_image_id: string | null
}

/** 批量将问题关联到文档分段时使用的分段引用。 */
Expand Down
11 changes: 7 additions & 4 deletions ui/src/views/VIEW_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
| `knowledge-detail/library/document/`、`tags/` | 文档及标签维护 |
| `knowledge-detail/library/image/` | 图片列表,复用文档操作 |
| `knowledge-detail/hit-test/` | 召回测试参数、文本/图片查询及结果展示 |
| `knowledge-detail/question/` | 问题管理占位页 |
| `knowledge-detail/problem/` | 问题查询、维护与分段关联 |
| `knowledge-detail/termbase/` | 知识库自定义分词维护 |
| `knowledge-detail/chat-user/`、`external-retrieval/` | 对话授权与外部检索配置 |
| `knowledge-detail/setting/base/`、`document-strategy/`、`scheduled-sync/` | 基础设置、文档策略、定时同步与日志 |
Expand Down Expand Up @@ -246,14 +246,17 @@ System 资源管理或 System 共享 Knowledge API 的 `postKnowledgeHitTest`;

### 问题管理

`question/` 使用 `resourceApi.problem`,默认每页 20 条,不提供表头排序,搜索将 content 传给后端。
`problem/` 使用 `resourceApi.problem`,默认每页 20 条,不提供表头排序,搜索将 content 传给后端。
`ButtonCreateProblem` 内聚创建按钮和多行输入弹窗;快捷创建使用 `MkQuickCreate`,每个问题最多 256 字符。
单项与批量删除直接提交,删除后末页为空时回退到最后有效页;名称编辑复用 `MkEditName`。
点击数据行打开 `ProblemDetailDrawer`,勾选、名称编辑和操作按钮不触发行详情。
详情支持问题名称快捷编辑、关联分段卡片和跨页上一个/下一个;卡片复用 `.mk-paragraph-card` 样式,
点击打开已有 `ParagraphDialog`,悬浮取消关联按钮仅解除当前关联,成功刷新卡片与列表计数。
分段详情关闭后重查关联,保持编辑与解除关联后的数据一致。
单项、批量、详情的“关联分段”入口暂时禁用,待交互确认后接入,不创建关联弹窗或发送关联请求。
分段详情关闭后重查关联,保持编辑与解除关联后的数据一致。关联卡片按接口来源字段显示图片、Web 或文件图标,
图片原图引用同时传给分段详情;加载失败支持重试。抽屉暴露 `refresh()`,由问题页在批量关联成功后刷新当前关联分段。
单项、批量、详情的“关联分段”入口统一使用 `AssociateParagraphDialog`,勾选仅维护本次选择,
跨文档、图片类型及搜索保留选择;确认后通过 `putBatchAssociateProblems` 一次提交问题与分段列表,
成功关闭并刷新问题列表,失败保留选择,取消不提交。弹窗不回填既有关联或执行解除关联,解除关联仍由问题详情维护。
Workspace 共享只展示和搜索问题列表,不显示维护入口,也不打开缺少查询接口的问题详情。

## 文档分段
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
<script setup lang="ts">
import { computed, nextTick, ref, useTemplateRef } from 'vue'
import { computed, ref, useTemplateRef } from 'vue'
import type MkViewLayout from '@/components/global/mk-view-layout/index.vue'
import MkSearchList from '@/components/mk-search-list/index.vue'
import type { ParamsPage } from '@/api/admin/core/types'
import { DOCUMENT_RESOURCE_TYPE } from '@/api/enums'
import type { DocumentItem, DocumentResourceType, ParagraphItem, ProblemParagraphReference } from '@/api/types'
import { KNOWLEDGE_TYPE_KEY, KNOWLEDGE_TYPE_MAP } from '@/constants/knowledge'
import { getFileUrl } from '@/utils/common'
import { getFileIconUrl } from '@/utils/icon'
import { MsgSuccess } from '@/utils/message'
import { useKnowledgeDetailContext } from '@/views/knowledge-detail/context'
import ParagraphDialog from '@/views/document-detail/paragraph-dialog/ParagraphDialog.vue'

defineOptions({ name: 'AssociateParagraphDialog' })
const emit = defineEmits<{ success: [] }>()
Expand All @@ -26,12 +26,9 @@ const selectedDocumentId = ref('')
const paragraphSearchText = ref('')
const paragraphs = ref<ParagraphItem[]>([])
const selectedParagraphs = ref<ProblemParagraphReference[]>([])
const detailParagraph = ref<ParagraphItem>()
const infiniteScrollRef = useTemplateRef<{ reset: () => Promise<void> }>('infiniteScrollRef')
const paragraphLayoutRef = useTemplateRef<InstanceType<typeof MkViewLayout>>('paragraphLayoutRef')
const paragraphDialogRef = useTemplateRef<InstanceType<typeof ParagraphDialog>>('paragraphDialogRef')

const isBatchAssociation = computed(() => problemIds.value.length > 1)
const selectedDocument = computed(() => documentData.value.find(({ id }) => id === selectedDocumentId.value))
const selectedParagraphIds = computed(() => new Set(selectedParagraphs.value.map(({ paragraph_id }) => paragraph_id)))

Expand Down Expand Up @@ -91,85 +88,43 @@ function handleParagraphSearch() {
return refreshParagraphs()
}

function removeSelectedParagraph(paragraphId: string) {
selectedParagraphs.value = selectedParagraphs.value.filter(({ paragraph_id }) => paragraph_id !== paragraphId)
}

function addSelectedParagraph(paragraph: ParagraphItem) {
selectedParagraphs.value = [...selectedParagraphs.value, { paragraph_id: paragraph.id, document_id: paragraph.document_id }]
// 勾选仅维护本次批量关联的选择,确认后统一提交。
function handleToggleParagraph(paragraph: ParagraphItem) {
if (operationLoading.value) return
if (isSelected(paragraph.id)) {
selectedParagraphs.value = selectedParagraphs.value.filter(({ paragraph_id }) => paragraph_id !== paragraph.id)
} else {
selectedParagraphs.value.push({ paragraph_id: paragraph.id, document_id: paragraph.document_id })
}
}

function updateParagraphAssociation(paragraph: ParagraphItem, unlink: boolean) {
if (operationLoading.value) return
const problemId = problemIds.value[0]
if (!problemId) return
function handleSubmit() {
if (operationLoading.value || documentLoading.value || !problemIds.value.length || !selectedParagraphs.value.length) return
operationLoading.value = true
// TODO(图片联调):核对图片分段的关联及取消关联是否复用现有 document_id / paragraph_id 协议,成功后重新打开确认回显。
const request = unlink
? resourceApi.paragraph.putUnassociateParagraphProblem(knowledgeId.value, paragraph.document_id, paragraph.id, problemId)
: resourceApi.problem.putBatchAssociateProblems(knowledgeId.value, {
problem_id_list: [...problemIds.value],
paragraph_list: [{ paragraph_id: paragraph.id, document_id: paragraph.document_id }],
})
return request
return resourceApi.problem
.putBatchAssociateProblems(knowledgeId.value, {
problem_id_list: [...problemIds.value],
paragraph_list: [...selectedParagraphs.value],
})
.then(() => {
if (unlink) {
removeSelectedParagraph(paragraph.id)
} else {
addSelectedParagraph(paragraph)
}
MsgSuccess(unlink ? '取消关联成功' : '关联成功')
MsgSuccess('关联成功')
visible.value = false
emit('success')
})
.catch(() => {
// 请求层统一提示错误,失败时保留当前选择。
// 请求层统一提示错误,失败时保留本次选择以便重试。
})
.finally(() => {
operationLoading.value = false
})
}

function handleAssociateParagraph(paragraph: ParagraphItem) {
if (isSelected(paragraph.id)) return
return updateParagraphAssociation(paragraph, false)
}

function handleUnassociateParagraph(paragraph: ParagraphItem) {
// 批量关联提交后不支持在此弹窗批量解除关联,避免只解除首个问题的关联。
if (isBatchAssociation.value || !isSelected(paragraph.id)) return
return updateParagraphAssociation(paragraph, true)
}

async function handleOpenParagraphDetail(paragraph: ParagraphItem) {
if (operationLoading.value || detailParagraph.value) return
detailParagraph.value = paragraph
await nextTick()
paragraphDialogRef.value?.open(paragraph)
}

function handleParagraphDialogClosed() {
detailParagraph.value = undefined
const problemId = problemIds.value[0]
if (isBatchAssociation.value || !problemId) return
// 分段详情可修改问题关联,关闭后重新读取当前问题的关联状态。
void resourceApi.problem.getProblemDetail(knowledgeId.value, problemId).then((associatedParagraphs) => {
selectedParagraphs.value = associatedParagraphs.map(({ id, document_id }) => ({ paragraph_id: id, document_id }))
})
}

function open(targetProblemIds: string[]) {
if (visible.value || operationLoading.value || !targetProblemIds[0]) return
problemIds.value = [...targetProblemIds]
visible.value = true
documentLoading.value = true
return Promise.all([
loadDocuments(),
// TODO(图片联调):确认已关联分段查询包含图片分段,并返回 id、document_id,供选中态和关联数量回显。
targetProblemIds.length === 1 ? resourceApi.problem.getProblemDetail(knowledgeId.value, targetProblemIds[0]) : [],
])
.then(([, problemParagraphs]) => {
selectedParagraphs.value = problemParagraphs.map(({ id, document_id }) => ({ paragraph_id: id, document_id }))
})
return loadDocuments()
.catch(() => {
// 请求层统一提示错误,失败时保持空列表。
})
Expand All @@ -186,7 +141,6 @@ function handleClosed() {
paragraphSearchText.value = ''
paragraphs.value = []
selectedParagraphs.value = []
detailParagraph.value = undefined
resourceType.value = DOCUMENT_RESOURCE_TYPE.DOCUMENT
}

Expand All @@ -203,6 +157,7 @@ defineExpose({ open })
<div v-loading="documentLoading" class="flex-column h-full min-h-0 pt-4" :inert="operationLoading">
<!-- 切换文档或图片资源 -->
<el-tabs
v-if="resourceType === DOCUMENT_RESOURCE_TYPE.IMAGE"
v-model="resourceType"
class="mx-4 mb-4"
:before-leave="() => !documentLoading && !operationLoading"
Expand All @@ -212,13 +167,7 @@ defineExpose({ open })
<el-tab-pane label="图片" :name="DOCUMENT_RESOURCE_TYPE.IMAGE" />
</el-tabs>
<!-- 搜索并切换候选分段所在资源 -->
<MkSearchList
v-model="documentSearchText"
:data="documentData"
:default-active="selectedDocumentId"
:empty-text="resourceType === DOCUMENT_RESOURCE_TYPE.IMAGE ? '暂无图片' : '暂无文档'"
@click="handleSelectDocument"
>
<MkSearchList v-model="documentSearchText" :data="documentData" :default-active="selectedDocumentId" @click="handleSelectDocument">
<template #default="{ row }">
<!-- TODO 图片 -->
<el-image
Expand All @@ -228,6 +177,12 @@ defineExpose({ open })
:alt="row.name"
fit="cover"
/>
<img
v-else-if="KNOWLEDGE_TYPE_MAP[row.type] === KNOWLEDGE_TYPE_KEY.WEB"
src="@/assets/file-type/web-link-icon.svg"
alt=""
class="size-4"
/>
<img v-else :src="getFileIconUrl(row.name)" class="mr-2 size-4 shrink-0" alt="" />
<span class="min-w-0 flex-1 truncate" :title="row.name">{{ row.name }}</span>
<span v-if="getSelectedCount(row.id)" class="ml-1 flex-center h-4 min-w-4 shrink-0 rounded-full bg-primary px-1 text-xs text-white">
Expand All @@ -253,37 +208,29 @@ defineExpose({ open })
>
<div class="space-y-2">
<template v-for="paragraph in paragraphs" :key="paragraph.id">
<!-- 查看当前分段详情及其关联状态 -->
<!-- 勾选或取消本次批量关联的分段 -->
<el-card
shadow="never"
class="mk-paragraph-card group relative cursor-pointer"
:class="{ active: isSelected(paragraph.id), disabled: operationLoading }"
@click="handleOpenParagraphDetail(paragraph)"
@click="handleToggleParagraph(paragraph)"
>
<!-- 关联当前问题与分段 -->
<MkTooltip v-if="!isSelected(paragraph.id)" content="关联" placement="top">
<el-button
circle
class="group-hover-visible absolute top-2 right-2 shadow-md"
:disabled="operationLoading"
@click.stop="handleAssociateParagraph(paragraph)"
>
<MkIcon name="icon_link-record_outlined" />
</el-button>
</MkTooltip>
<!-- 取消当前问题与分段的关联 -->
<MkTooltip v-else-if="!isBatchAssociation" content="取消关联" placement="top">
<el-button
circle
class="group-hover-visible absolute top-2 right-2 shadow-md"
:disabled="operationLoading"
@click.stop="handleUnassociateParagraph(paragraph)"
>
<MkIcon name="icon_unlink_outlined" />
</el-button>
</MkTooltip>
<h4 class="mb-2">{{ paragraph.title || '-' }}</h4>
<!-- 勾选当前分段用于批量关联 -->
<el-checkbox
class="absolute! top-4 right-4"
:model-value="isSelected(paragraph.id)"
@click.stop
@change="handleToggleParagraph(paragraph)"
/>

<el-image
v-if="resourceType === DOCUMENT_RESOURCE_TYPE.IMAGE"
:src="getFileUrl(selectedDocument.meta?.source_file_id as string | undefined)"
class="mr-2 size-6 shrink-0"
:alt="selectedDocument.name"
fit="cover"
/>
<h4 class="mb-2">{{ paragraph.title || '-' }}</h4>
<MdPreview :model-value="paragraph.content" />
<p class="mt-2 text-sm text-N600">{{ paragraph.content.length }} 字符</p>
</el-card>
Expand All @@ -297,15 +244,15 @@ defineExpose({ open })
</template>
</MkViewLayout>
<template #footer>
<div class="text-left text-sm text-N600">已关联 {{ selectedParagraphs.length }}</div>
<div class="flex-between">
<div class="text-sm text-N600">已选择 {{ selectedParagraphs.length }}</div>
<div>
<!-- 取消本次批量关联 -->
<el-button plain :disabled="operationLoading || documentLoading" @click="visible = false">取消</el-button>
<!-- 确认批量关联所选分段 -->
<el-button type="primary" :loading="operationLoading" @click="handleSubmit">确认</el-button>
</div>
</div>
</template>
</MkDialog>
<ParagraphDialog
v-if="detailParagraph"
ref="paragraphDialogRef"
title="分段详情"
:knowledge-id="knowledgeId"
:document-id="detailParagraph.document_id"
@closed="handleParagraphDialogClosed"
/>
</template>
Loading
Loading