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
@@ -0,0 +1,180 @@
<script setup lang="ts">
import { useQuery, useQueryClient } from "@tanstack/vue-query";
import { computed, watch } from "vue";
import MarkdownPreview from "@/components/MarkdownPreview.vue";
import { knowledgeApi } from "@/lib/api";
import { formatDateTime } from "@/lib/display";
const props = defineProps<{
open: boolean;
itemId: number | null;
}>();
const emit = defineEmits<{
close: [];
}>();
const queryClient = useQueryClient();
const enabled = computed(() => props.open && Boolean(props.itemId));
const detailQuery = useQuery({
queryKey: computed(() => ["knowledge", "detail", props.itemId]),
enabled,
queryFn: () => knowledgeApi.detail(props.itemId as number),
});
const detail = computed(() => detailQuery.data.value);
const hasMarkdown = computed(() => Boolean(detail.value?.markdown_content?.trim()));
const isProcessing = computed(() =>
detail.value?.status === "pending" || detail.value?.status === "processing",
);
const isFailed = computed(() => detail.value?.status === "failed");
const statusLabel = computed(() => formatKnowledgeStatus(detail.value?.status));
const statusColor = computed(() => {
if (detail.value?.status === "completed") return "success";
if (detail.value?.status === "failed") return "error";
if (isProcessing.value) return "processing";
return "default";
});
watch(
[() => props.open, () => props.itemId, () => detail.value?.status],
([open, itemId, status], _prev, onCleanup) => {
if (!open || !itemId || (status !== "pending" && status !== "processing")) {
return;
}
const timer = window.setInterval(() => {
void Promise.all([
detailQuery.refetch(),
queryClient.invalidateQueries({ queryKey: ["knowledge", "items"] }),
]);
}, 2500);
onCleanup(() => {
window.clearInterval(timer);
});
},
{ immediate: true },
);
function handleClose(): void {
emit("close");
}
function formatKnowledgeStatus(status?: string): string {
switch (status) {
case "completed":
return "学习完成";
case "processing":
return "处理中";
case "failed":
return "解析失败";
case "pending":
return "待处理";
case "deleted":
return "已删除";
default:
return status || "未知状态";
}
}
</script>
<template>
<a-drawer
:open="open"
width="760"
title="知识详情"
class="knowledge-detail-drawer"
@close="handleClose"
>
<div v-if="detailQuery.isPending.value" class="knowledge-detail-drawer__loading">
<a-skeleton active :paragraph="{ rows: 10 }" />
</div>
<template v-else-if="detail">
<section class="knowledge-detail-drawer__hero">
<div>
<h2>{{ detail.name }}</h2>
<div class="knowledge-detail-drawer__meta">
<span>{{ detail.group_name }}</span>
<span>{{ formatDateTime(detail.created_at) }}</span>
<span v-if="detail.source_uri">{{ detail.source_uri }}</span>
</div>
</div>
<a-tag :color="statusColor">{{ statusLabel }}</a-tag>
</section>
<a-alert
v-if="isProcessing"
type="info"
show-icon
message="处理中"
description="内容正在解析并整理为 Markdown,抽屉会自动刷新。"
class="knowledge-detail-drawer__alert"
/>
<a-alert
v-else-if="isFailed"
type="error"
show-icon
message="解析失败"
:description="detail.error_message || '当前内容解析失败,请稍后重试。'"
class="knowledge-detail-drawer__alert"
/>
<div v-if="hasMarkdown" class="knowledge-detail-drawer__content">
<MarkdownPreview :content="detail.markdown_content || ''" />
</div>
<a-empty v-else description="无可预览文本" />
</template>
<a-result
v-else
status="404"
title="未找到知识内容"
sub-title="该内容可能已删除或当前租户无权访问"
/>
</a-drawer>
</template>
<style scoped>
.knowledge-detail-drawer__loading {
padding-top: 8px;
}
.knowledge-detail-drawer__hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.knowledge-detail-drawer__hero h2 {
margin: 0 0 8px;
color: #111827;
font-size: 22px;
font-weight: 700;
}
.knowledge-detail-drawer__meta {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
color: #6b7280;
font-size: 13px;
}
.knowledge-detail-drawer__alert {
margin-bottom: 16px;
}
.knowledge-detail-drawer__content {
padding: 20px 24px;
border: 1px solid #f0f0f0;
border-radius: 16px;
background: #fff;
}
</style>
@@ -0,0 +1,114 @@
<script setup lang="ts">
import MarkdownIt from "markdown-it";
import { computed } from "vue";
const props = defineProps<{
content: string;
}>();
const renderer = new MarkdownIt({
html: false,
linkify: true,
breaks: true,
});
const html = computed(() => renderer.render(props.content || ""));
</script>
<template>
<div class="markdown-preview" v-html="html" />
</template>
<style scoped>
.markdown-preview {
color: #111827;
font-size: 14px;
line-height: 1.75;
}
.markdown-preview :deep(h1),
.markdown-preview :deep(h2),
.markdown-preview :deep(h3),
.markdown-preview :deep(h4) {
margin: 1.2em 0 0.6em;
color: #111827;
font-weight: 700;
}
.markdown-preview :deep(h1) {
font-size: 28px;
}
.markdown-preview :deep(h2) {
font-size: 22px;
}
.markdown-preview :deep(h3) {
font-size: 18px;
}
.markdown-preview :deep(p),
.markdown-preview :deep(ul),
.markdown-preview :deep(ol),
.markdown-preview :deep(blockquote) {
margin: 0 0 1em;
}
.markdown-preview :deep(ul),
.markdown-preview :deep(ol) {
padding-left: 1.5em;
}
.markdown-preview :deep(a) {
color: #1677ff;
}
.markdown-preview :deep(code) {
padding: 2px 6px;
border-radius: 6px;
background: #f3f4f6;
font-size: 13px;
}
.markdown-preview :deep(pre) {
overflow-x: auto;
padding: 16px;
border-radius: 12px;
background: #111827;
color: #f9fafb;
}
.markdown-preview :deep(pre code) {
padding: 0;
background: transparent;
color: inherit;
}
.markdown-preview :deep(table) {
width: 100%;
margin: 0 0 1em;
border-collapse: collapse;
overflow: hidden;
border: 1px solid #e5e7eb;
border-radius: 12px;
}
.markdown-preview :deep(th),
.markdown-preview :deep(td) {
padding: 10px 12px;
border: 1px solid #e5e7eb;
text-align: left;
vertical-align: top;
}
.markdown-preview :deep(th) {
background: #f9fafb;
font-weight: 600;
}
.markdown-preview :deep(hr) {
margin: 24px 0;
border: 0;
border-top: 1px solid #e5e7eb;
}
</style>