feat: add knowledge markdown formatting and detail retrieval

- Implemented KnowledgeWebsiteMarkdownPrompt for formatting webpage content into Markdown.
- Added GetItemDetail method in KnowledgeHandler to retrieve knowledge item details.
- Introduced KnowledgeDetailDrawer component for displaying knowledge item details in the admin web interface.
- Created MarkdownPreview component for rendering Markdown content.
- Added knowledge chunking and URL parsing logic to handle webpage content extraction and formatting.
- Updated database schema to include markdown_content in knowledge_items table.
This commit is contained in:
2026-04-05 20:41:42 +08:00
parent 446f865cdf
commit dbd7747742
26 changed files with 1556 additions and 252 deletions
+101 -45
View File
@@ -1,17 +1,18 @@
<script setup lang="ts">
import { MoreOutlined, PlusOutlined, UploadOutlined } from "@ant-design/icons-vue";
import { DeleteOutlined, EyeOutlined, MoreOutlined, PlusOutlined, UploadOutlined } from "@ant-design/icons-vue";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import { message, Modal, type TableColumnsType, type UploadProps } from "ant-design-vue";
import type { KnowledgeGroup, KnowledgeItem } from "@geo/shared-types";
import { computed, reactive, ref, watch } from "vue";
import KnowledgeDetailDrawer from "@/components/KnowledgeDetailDrawer.vue";
import { knowledgeApi } from "@/lib/api";
import { formatDateTime } from "@/lib/display";
import { formatError } from "@/lib/errors";
type KnowledgeSourceType = "document" | "website" | "text";
type KnowledgeGroupLevel = "root" | "child";
const DEFAULT_CHILD_GROUP_NAME = "默认目录";
const MAX_WEBSITE_URLS = 3;
interface TreeNode {
title: string;
@@ -29,6 +30,8 @@ const editingGroup = ref<KnowledgeGroup | null>(null);
const itemModalOpen = ref(false);
const itemSourceType = ref<KnowledgeSourceType>("document");
const uploadFiles = ref<File[]>([]);
const previewItemId = ref<number | null>(null);
const previewDrawerOpen = ref(false);
const groupForm = reactive({
name: "",
@@ -67,6 +70,17 @@ const groupMap = computed(() => buildGroupMap(rootGroups.value));
const treeData = computed(() => rootGroups.value.map((item) => toSidebarTreeNode(item)));
const storableTreeData = computed(() => rootGroups.value.map((item) => toStorageTreeNode(item)));
const storableGroupCount = computed(() => countStorableGroups(rootGroups.value));
const websiteUrls = computed(() =>
urlsText.value
.split("\n")
.map((item) => item.trim())
.filter(Boolean),
);
const websiteUrlLimitExceeded = computed(() => websiteUrls.value.length > MAX_WEBSITE_URLS);
const itemModalOkButtonProps = computed(() => ({
style: { width: "80px", borderRadius: "4px" },
disabled: itemSourceType.value === "website" && websiteUrlLimitExceeded.value,
}));
const groupModalTitle = computed(() => {
if (editingGroup.value) {
return isRootGroup(editingGroup.value) ? "编辑分组" : "编辑子目录";
@@ -119,13 +133,11 @@ const groupMutations = {
const itemMutation = useMutation({
mutationFn: async () => {
let targetGroupId = itemForm.group_id;
const targetGroupId = itemForm.group_id;
if (!targetGroupId) {
throw new Error("请选择分组目录");
}
targetGroupId = await resolveItemTargetGroupId(targetGroupId);
const promises: Promise<any>[] = [];
if (itemSourceType.value === "document") {
@@ -142,10 +154,13 @@ const itemMutation = useMutation({
);
}
} else if (itemSourceType.value === "website") {
const urls = urlsText.value.split('\n').map(u => u.trim()).filter(Boolean);
const urls = websiteUrls.value;
if (urls.length === 0) {
throw new Error("请输入包含URL的文本");
}
if (urls.length > MAX_WEBSITE_URLS) {
throw new Error(`一次最多支持 ${MAX_WEBSITE_URLS} 个网址`);
}
for (const url of urls) {
let name = url;
try { name = new URL(url).hostname + new URL(url).pathname } catch (e) {}
@@ -173,12 +188,31 @@ const itemMutation = useMutation({
}
}
await Promise.all(promises);
const results = await Promise.allSettled(promises);
const successCount = results.filter((result) => result.status === "fulfilled").length;
const failedResults = results.filter((result): result is PromiseRejectedResult => result.status === "rejected");
if (failedResults.length > 0 && successCount === 0) {
throw failedResults[0].reason;
}
return {
totalCount: promises.length,
successCount,
failedCount: failedResults.length,
firstError: failedResults[0]?.reason,
};
},
onSuccess: async () => {
message.success("内容已提交");
itemModalOpen.value = false;
resetItemForm();
onSuccess: async (result) => {
if (result.failedCount === 0) {
message.success(result.successCount > 1 ? `已提交 ${result.successCount} 条内容` : "内容已提交");
itemModalOpen.value = false;
resetItemForm();
} else {
message.warning(
`已成功提交 ${result.successCount} 条,${result.failedCount} 条失败${result.firstError ? `${formatError(result.firstError)}` : ""}`,
);
}
await queryClient.invalidateQueries({ queryKey: ["knowledge"] });
},
onError: (error) => message.error(formatError(error)),
@@ -310,29 +344,6 @@ function isStorableGroupId(id: number | null | undefined): boolean {
return Boolean(group && isStorableGroup(group));
}
function findChildGroupByName(parentId: number, name: string): KnowledgeGroup | undefined {
const parentGroup = getGroupById(parentId);
return parentGroup?.children.find((child) => child.name === name);
}
async function resolveItemTargetGroupId(groupId: number): Promise<number> {
if (!isNodeRootGroup(groupId)) {
return groupId;
}
const defaultChild = findChildGroupByName(groupId, DEFAULT_CHILD_GROUP_NAME);
if (defaultChild) {
return defaultChild.id;
}
const newGroup = await knowledgeApi.createGroup({
name: DEFAULT_CHILD_GROUP_NAME,
parent_id: groupId,
});
return newGroup.id;
}
function resolveDefaultItemGroupId(): number | undefined {
if (selectedGroupId.value) {
return selectedGroupId.value;
@@ -481,8 +492,14 @@ function formatSize(value: number): string {
return `${(value / 1024 / 1024).toFixed(1)} MB`;
}
function editItem(): void {
message.info("内容编辑功能开发中");
function openItemDetail(itemId: number): void {
previewItemId.value = itemId;
previewDrawerOpen.value = true;
}
function closeItemDetail(): void {
previewDrawerOpen.value = false;
previewItemId.value = null;
}
</script>
@@ -570,11 +587,16 @@ function editItem(): void {
{{ formatDateTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'actions'">
<div class="knowledge-actions">
<a-button type="link" style="padding: 0 4px" @click="editItem()">编辑</a-button>
<a-divider type="vertical" />
<div class="table-actions-row">
<a-tooltip title="查看">
<a-button type="text" shape="circle" size="small" class="action-btn action-eye" @click="openItemDetail(record.id)">
<EyeOutlined />
</a-button>
</a-tooltip>
<a-popconfirm title="确认删除该内容?" @confirm="deleteItemMutation.mutate(record.id)">
<a-button type="link" style="padding: 0 4px">删除</a-button>
<a-button type="text" shape="circle" size="small" class="action-btn action-delete">
<DeleteOutlined />
</a-button>
</a-popconfirm>
</div>
</template>
@@ -615,7 +637,7 @@ function editItem(): void {
"
ok-text=" "
cancel-text=" "
:ok-button-props="{ style: { width: '80px', borderRadius: '4px' } }"
:ok-button-props="itemModalOkButtonProps"
:cancel-button-props="{ style: { width: '80px', borderRadius: '4px' } }"
>
<div class="knowledge-form-add">
@@ -644,7 +666,7 @@ function editItem(): void {
</div>
<p class="dragger-text">点击或拖拽文件至此区域即可上传</p>
<p class="dragger-hint">
请上传docpdftxtxlsxlsx格式不超过30M的文件严禁上传敏感数据或其他非法文件
请上传 docxpdftxtmdxlsxlsx 格式不超过30M的文件严禁上传敏感数据或其他非法文件
</p>
<p class="dragger-count">已选择文件{{ uploadFiles.length }}</p>
</div>
@@ -655,9 +677,17 @@ function editItem(): void {
<a-textarea
v-model:value="urlsText"
:rows="11"
placeholder="请输入产品相关网页URL,每行一个"
:status="websiteUrlLimitExceeded ? 'error' : undefined"
placeholder="请输入产品相关网页URL,每行一个,最多支持3个"
class="custom-textarea"
/>
<div
class="knowledge-url-hint"
:class="{ 'knowledge-url-hint--error': websiteUrlLimitExceeded }"
>
当前 {{ websiteUrls.length }} / {{ MAX_WEBSITE_URLS }} 个网址
<span v-if="websiteUrlLimitExceeded">请删除多余网址后再提交</span>
</div>
</div>
<div v-else class="knowledge-content-wrapper">
@@ -690,6 +720,12 @@ function editItem(): void {
</div>
</div>
</a-modal>
<KnowledgeDetailDrawer
:open="previewDrawerOpen"
:item-id="previewItemId"
@close="closeItemDetail"
/>
</div>
</template>
@@ -776,7 +812,7 @@ function editItem(): void {
}
.knowledge-sidebar__add {
color: #9ca3af;
color: #666;
font-size: 13px;
cursor: pointer;
display: flex;
@@ -823,12 +859,21 @@ function editItem(): void {
color: #6b7280;
}
.knowledge-actions {
/* Actions */
.table-actions-row {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
}
.action-btn {
color: #8c8c8c;
font-size: 14px;
}
.action-eye:hover { color: #13c2c2; background: #e6fffb; }
.action-delete:hover { color: #ff4d4f; background: #fff2f0; }
.knowledge-form {
display: flex;
flex-direction: column;
@@ -871,6 +916,17 @@ function editItem(): void {
margin-top: 4px;
}
.knowledge-url-hint {
margin-top: 8px;
color: #6b7280;
font-size: 12px;
line-height: 1.5;
}
.knowledge-url-hint--error {
color: #ff4d4f;
}
:deep(.custom-dragger.ant-upload-wrapper .ant-upload-drag) {
border: 1px dashed #7eb0f7;
background-color: #fff;