mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-08-31 00:50:02 +08:00
feat(summary): enhance document summarization capabilities and UI
- Added max_input_chars configuration to limit input size for summary generation. - Updated generate_summary.yaml to clarify summary generation steps and requirements. - Introduced a new summary section in the document content view to display generated summaries or loading states. - Refactored doc-content.vue to utilize computed properties for improved performance and readability. - Enhanced knowledge base hooks to include description and summary status fields for better data handling. - Updated internationalization files to include new summary-related labels in multiple languages.
This commit is contained in:
@@ -23,6 +23,7 @@ conversation:
|
||||
generate_summary_prompt_id: "default_summary" # from prompt_templates/generate_summary.yaml
|
||||
generate_session_title_prompt_id: "default_session_title" # from prompt_templates/generate_session_title.yaml
|
||||
summary:
|
||||
max_input_chars: 16384
|
||||
repeat_penalty: 1.0
|
||||
temperature: 0.3
|
||||
max_completion_tokens: 2048
|
||||
|
||||
@@ -5,12 +5,20 @@ templates:
|
||||
description: "Generate a concise document summary"
|
||||
default: true
|
||||
content: |
|
||||
You are a precise document summarization expert. Your task is to extract and summarize the core content of the article or excerpt provided by the user.
|
||||
You are a precise document summarization expert. Your task is to extract and summarize the core content of the document provided by the user.
|
||||
|
||||
## Steps
|
||||
1. Identify the document type from the metadata (e.g., technical doc, meeting notes, research paper, code, etc.)
|
||||
2. Extract 3-5 key points or main topics covered in the document
|
||||
3. Write a coherent summary incorporating these key points
|
||||
|
||||
## Core Requirements
|
||||
- Summary length should be 100-300 words, adjusted flexibly based on content complexity
|
||||
- Generate the summary entirely based on the provided content, without adding any information not present in the article
|
||||
- Summary length: 100-500 words, adjusted based on content complexity
|
||||
- Short/simple documents: 100-200 words
|
||||
- Long/complex documents: 300-500 words
|
||||
- Generate the summary entirely based on the provided content, without adding any information not present in the document
|
||||
- Ensure the summary captures key information points and main conclusions
|
||||
- If the content contains "[...content omitted...]" markers, it is a sampled excerpt from a longer document — cover ALL topics that appear across the provided sections, not just the beginning
|
||||
- Even for complex or specialized content, you must attempt to extract core points for summarization
|
||||
- Output the summary directly, without any preamble, prefix, or explanation
|
||||
|
||||
@@ -18,13 +26,13 @@ templates:
|
||||
- Use an objective, neutral third-person narrative tone
|
||||
- Maintain logical coherence with smooth transitions between sentences
|
||||
- Avoid repetitive use of the same expressions or sentence structures
|
||||
- For technical documents: preserve key terms, metrics, and specific details
|
||||
- For meeting notes/reports: highlight decisions, action items, and conclusions
|
||||
|
||||
## Important Notes
|
||||
- NEVER output refusal phrases such as "unable to generate", "unable to summarize", or "insufficient content"
|
||||
- Do not copy or reference any content from examples; ensure the summary is entirely based on the user's new article
|
||||
- Do not copy or reference any content from examples; ensure the summary is entirely based on the user's document
|
||||
- Make every effort to extract key points and summarize for any text, regardless of length or complexity
|
||||
|
||||
## Requirements:
|
||||
## Language
|
||||
- Use {{language}} for all outputs
|
||||
|
||||
## The following is the article information provided by the user:
|
||||
|
||||
@@ -5,7 +5,7 @@ import { marked } from "marked";
|
||||
import hljs from "highlight.js";
|
||||
import "highlight.js/styles/github.css";
|
||||
import mermaid from "mermaid";
|
||||
import { onMounted, ref, nextTick, onUnmounted, watch } from "vue";
|
||||
import { onMounted, ref, nextTick, onUnmounted, watch, computed } from "vue";
|
||||
import { downKnowledgeDetails, deleteGeneratedQuestion, getChunkByIdOnly } from "@/api/knowledge-base/index";
|
||||
import { MessagePlugin, DialogPlugin } from "tdesign-vue-next";
|
||||
import { sanitizeHTML, safeMarkdownToHTML, createSafeImage, isValidImageURL, hydrateProtectedFileImages } from '@/utils/security';
|
||||
@@ -66,8 +66,7 @@ let url = ref('')
|
||||
// file 类型默认「预览」,URL / 手动创建 默认「全文」
|
||||
const viewMode = ref<'chunks' | 'merged' | 'preview'>('merged');
|
||||
|
||||
// 合并后的文档内容
|
||||
const mergedContent = ref<string>('');
|
||||
// 合并后的文档内容(在下方通过 computed 定义)
|
||||
|
||||
/**
|
||||
* 根据 start_at 和 end_at 字段合并有 overlap 的 chunks
|
||||
@@ -136,8 +135,11 @@ const mergeChunks = (chunks: any[]): string => {
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
doc = document.getElementsByClassName('t-drawer__body')[0]
|
||||
doc.addEventListener('scroll', handleDetailsScroll);
|
||||
const drawers = document.getElementsByClassName('t-drawer__body');
|
||||
if (drawers && drawers.length > 0) {
|
||||
doc = drawers[0];
|
||||
doc.addEventListener('scroll', handleDetailsScroll);
|
||||
}
|
||||
})
|
||||
})
|
||||
watch(() => props.details?.id, () => {
|
||||
@@ -162,7 +164,9 @@ watch(() => props.details?.chunkLoading, (val) => {
|
||||
}
|
||||
});
|
||||
onUnmounted(() => {
|
||||
doc.removeEventListener('scroll', handleDetailsScroll);
|
||||
if (doc) {
|
||||
doc.removeEventListener('scroll', handleDetailsScroll);
|
||||
}
|
||||
})
|
||||
const checkImage = (url) => {
|
||||
return new Promise((resolve) => {
|
||||
@@ -223,14 +227,28 @@ renderer.code = function ({text, lang}) {
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
// 监听 chunks 变化,自动更新合并内容
|
||||
watch(() => props.details?.md, (newChunks) => {
|
||||
// 监听 chunks 变化,自动更新合并内容(已改为 computed 属性)
|
||||
const mergedContent = computed(() => {
|
||||
const newChunks = props.details?.md;
|
||||
if (newChunks && newChunks.length > 0) {
|
||||
mergedContent.value = mergeChunks(newChunks);
|
||||
} else {
|
||||
mergedContent.value = '';
|
||||
return mergeChunks(newChunks);
|
||||
}
|
||||
}, { immediate: true, deep: true });
|
||||
return '';
|
||||
});
|
||||
|
||||
// 计算处理后的分块数据,避免在模板中频繁调用方法和 JSON.parse
|
||||
const processedChunks = computed(() => {
|
||||
return (props.details?.md || []).map((item: any, index: number) => {
|
||||
return {
|
||||
original: item,
|
||||
processedContent: processMarkdown(item.content),
|
||||
questions: getGeneratedQuestions(item),
|
||||
meta: getChunkMeta(item),
|
||||
hasParent: hasParentChunk(item),
|
||||
chunkClass: getChunkClass(index)
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const previewSupportedTypes = new Set([
|
||||
'pdf', 'docx', 'pptx', 'ppt', 'xlsx', 'xls', 'csv',
|
||||
@@ -353,8 +371,11 @@ const bindMermaidClickEvents = () => {
|
||||
const processMarkdown = (markdownText) => {
|
||||
if (!markdownText || typeof markdownText !== 'string') return '';
|
||||
|
||||
// 去除 Markdown 头部的 YAML Frontmatter(例如 --- title: xxx ---)
|
||||
let processedText = markdownText.replace(/^\s*---\r?\n[\s\S]*?\r?\n---\r?\n/, '');
|
||||
|
||||
// 先还原原始文本中的 HTML 实体,让它们作为普通字符参与渲染
|
||||
let processedText = markdownText
|
||||
processedText = processedText
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/gi, "'")
|
||||
.replace(/'/g, "'")
|
||||
@@ -387,7 +408,7 @@ const processMarkdown = (markdownText) => {
|
||||
};
|
||||
const handleClose = () => {
|
||||
emit("closeDoc", false);
|
||||
doc.scrollTop = 0;
|
||||
if (doc) doc.scrollTop = 0;
|
||||
viewMode.value = 'merged';
|
||||
};
|
||||
|
||||
@@ -724,6 +745,19 @@ const handleDetailsScroll = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文档摘要 -->
|
||||
<div v-if="details.description" class="summary_box">
|
||||
<span class="label">{{ $t('knowledgeBase.documentSummary') }}</span>
|
||||
<div class="summary_content">{{ details.description }}</div>
|
||||
</div>
|
||||
<div v-else-if="details.summary_status === 'pending' || details.summary_status === 'processing'" class="summary_box">
|
||||
<span class="label">{{ $t('knowledgeBase.documentSummary') }}</span>
|
||||
<div class="summary_loading">
|
||||
<t-loading size="small" />
|
||||
<span>{{ $t('knowledgeBase.generatingSummary') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content_header">
|
||||
<div class="header-left">
|
||||
<div class="title-row">
|
||||
@@ -733,10 +767,12 @@ const handleDetailsScroll = () => {
|
||||
</span>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span class="time"> {{ getTimeLabel() }}:{{ details.time }} </span>
|
||||
<t-tag v-if="details.channel && details.channel !== 'web'" size="small" variant="light" theme="warning" class="channel-tag">
|
||||
{{ getChannelLabel(details.channel) }}
|
||||
</t-tag>
|
||||
<div class="meta-left">
|
||||
<span class="time"> {{ getTimeLabel() }}:{{ details.time }} </span>
|
||||
<t-tag v-if="details.channel && details.channel !== 'web'" size="small" variant="light" theme="warning" class="channel-tag">
|
||||
{{ getChannelLabel(details.channel) }}
|
||||
</t-tag>
|
||||
</div>
|
||||
<div class="view-mode-buttons">
|
||||
<t-button
|
||||
v-if="canPreview()"
|
||||
@@ -780,18 +816,17 @@ const handleDetailsScroll = () => {
|
||||
|
||||
<!-- 分块视图 -->
|
||||
<div v-else-if="viewMode === 'chunks'">
|
||||
<div v-if="details.md.length == 0" class="no_content">{{ $t('common.noData') }}</div>
|
||||
<div v-if="!processedChunks.length" class="no_content">{{ $t('common.noData') }}</div>
|
||||
<div v-else class="chunk-list">
|
||||
<div class="chunk-item"
|
||||
v-for="(item, index) in details.md"
|
||||
v-for="(chunk, index) in processedChunks"
|
||||
:key="index"
|
||||
:class="getChunkClass(index)"
|
||||
>
|
||||
<div class="chunk-header">
|
||||
<span class="chunk-index">{{ $t('knowledgeBase.segment') }} {{ index + 1 }}</span>
|
||||
<div class="chunk-header-right">
|
||||
<t-tag
|
||||
v-if="hasParentChunk(item)"
|
||||
v-if="chunk.hasParent"
|
||||
size="small"
|
||||
theme="primary"
|
||||
variant="light"
|
||||
@@ -799,39 +834,39 @@ const handleDetailsScroll = () => {
|
||||
{{ $t('knowledgeBase.childChunk') }}
|
||||
</t-tag>
|
||||
<t-tag
|
||||
v-if="getGeneratedQuestions(item).length > 0"
|
||||
v-if="chunk.questions.length > 0"
|
||||
size="small"
|
||||
theme="success"
|
||||
variant="light"
|
||||
>
|
||||
{{ $t('knowledgeBase.questions') }} {{ getGeneratedQuestions(item).length }}
|
||||
{{ $t('knowledgeBase.questions') }} {{ chunk.questions.length }}
|
||||
</t-tag>
|
||||
<span class="chunk-meta">{{ getChunkMeta(item) }}</span>
|
||||
<span class="chunk-meta">{{ chunk.meta }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="md-content" v-html="processMarkdown(item.content)"></div>
|
||||
<div class="md-content" v-html="chunk.processedContent"></div>
|
||||
|
||||
<!-- 父 Chunk 上下文展开 -->
|
||||
<div v-if="hasParentChunk(item)" class="parent-context-section">
|
||||
<div class="parent-context-toggle" @click="toggleParentContext(item, index)">
|
||||
<div v-if="chunk.hasParent" class="parent-context-section">
|
||||
<div class="parent-context-toggle" @click="toggleParentContext(chunk.original, index)">
|
||||
<t-icon v-if="!parentContextLoading.has(index)" :name="isParentExpanded(index) ? 'chevron-down' : 'chevron-right'" size="14px" />
|
||||
<t-loading v-else size="small" style="width: 14px; height: 14px;" />
|
||||
<span>{{ $t('knowledgeBase.viewParentContext') }}</span>
|
||||
</div>
|
||||
<div v-show="isParentExpanded(index)" class="parent-context-content">
|
||||
<div class="md-content" v-html="processMarkdown(getParentContent(item))"></div>
|
||||
<div class="md-content" v-html="processMarkdown(getParentContent(chunk.original))"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 生成的问题展示 -->
|
||||
<div v-if="getGeneratedQuestions(item).length > 0" class="questions-section">
|
||||
<div v-if="chunk.questions.length > 0" class="questions-section">
|
||||
<div class="questions-toggle" @click="toggleQuestions(index)">
|
||||
<t-icon :name="isExpanded(index) ? 'chevron-down' : 'chevron-right'" size="14px" />
|
||||
<span>{{ $t('knowledgeBase.generatedQuestions') }} ({{ getGeneratedQuestions(item).length }})</span>
|
||||
<span>{{ $t('knowledgeBase.generatedQuestions') }} ({{ chunk.questions.length }})</span>
|
||||
</div>
|
||||
<div v-show="isExpanded(index)" class="questions-list">
|
||||
<div
|
||||
v-for="question in getGeneratedQuestions(item)"
|
||||
v-for="question in chunk.questions"
|
||||
:key="question.id"
|
||||
class="question-item"
|
||||
>
|
||||
@@ -843,7 +878,7 @@ const handleDetailsScroll = () => {
|
||||
size="small"
|
||||
class="delete-question-btn"
|
||||
:loading="isDeleting(index, question.id)"
|
||||
@click.stop="handleDeleteQuestion(item, index, question)"
|
||||
@click.stop="handleDeleteQuestion(chunk.original, index, question)"
|
||||
>
|
||||
<template #icon>
|
||||
<t-icon name="delete" size="14px" />
|
||||
@@ -928,38 +963,80 @@ const handleDetailsScroll = () => {
|
||||
}
|
||||
|
||||
:deep(.t-drawer__header) {
|
||||
font-weight: 800;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
:deep(.t-drawer__body.narrow-scrollbar) {
|
||||
padding: 16px 24px;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: 8px;
|
||||
|
||||
.header-title {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.doc_box, .url_box, .manual_box {
|
||||
// 信息面板通用样式
|
||||
.info_panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.doc_box, .url_box, .manual_box {
|
||||
.info_panel();
|
||||
}
|
||||
|
||||
// 文档摘要区域
|
||||
.summary_box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 24px;
|
||||
margin-top: 8px;
|
||||
|
||||
.label {
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.summary_content {
|
||||
padding: 12px;
|
||||
background: var(--td-bg-color-container-hover);
|
||||
border-radius: 4px;
|
||||
color: var(--td-text-color-primary);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.summary_loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: var(--td-bg-color-container-hover);
|
||||
border-radius: 4px;
|
||||
color: var(--td-text-color-placeholder);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--td-text-color-primary);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
line-height: 22px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
@@ -968,61 +1045,46 @@ const handleDetailsScroll = () => {
|
||||
.download_box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--td-bg-color-container-hover);
|
||||
border-radius: 4px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.doc_t {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
padding: 5px 8px;
|
||||
align-items: center;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--td-component-border);
|
||||
background: var(--td-bg-color-container-hover);
|
||||
word-break: break-all;
|
||||
text-align: justify;
|
||||
font-size: 13px;
|
||||
color: var(--td-text-color-primary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.icon_box {
|
||||
margin-left: 18px;
|
||||
margin-left: 12px;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--td-brand-color);
|
||||
cursor: pointer;
|
||||
|
||||
.download_box {
|
||||
img.download_box {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: currentColor;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
// URL链接区域
|
||||
.url_link_box {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--td-success-color-focus);
|
||||
background: var(--td-success-color-light);
|
||||
background: var(--td-bg-color-container-hover);
|
||||
padding: 8px 12px;
|
||||
|
||||
.url_link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--td-brand-color-active);
|
||||
color: var(--td-brand-color);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--td-brand-color);
|
||||
background: var(--td-success-color-light);
|
||||
border-radius: 3px;
|
||||
padding: 4px 6px;
|
||||
margin: -4px -6px;
|
||||
|
||||
.jump-icon {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
}
|
||||
|
||||
.url_text {
|
||||
flex: 1;
|
||||
@@ -1031,52 +1093,67 @@ const handleDetailsScroll = () => {
|
||||
}
|
||||
|
||||
.jump-icon {
|
||||
transition: transform 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
color: var(--td-brand-color-active);
|
||||
color: var(--td-brand-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 手动创建标题区域
|
||||
.manual_title_box {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--td-component-border);
|
||||
background: var(--td-bg-color-container-hover);
|
||||
padding: 8px 12px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.manual_title {
|
||||
color: var(--td-text-color-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.content_header {
|
||||
margin-top: 22px;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--td-component-stroke);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
|
||||
.label {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.meta-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -1085,11 +1162,11 @@ const handleDetailsScroll = () => {
|
||||
}
|
||||
|
||||
.chunk-count {
|
||||
color: var(--td-brand-color);
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: 12px;
|
||||
background: var(--td-brand-color)14;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
background: var(--td-bg-color-container-hover);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.view-mode-buttons {
|
||||
@@ -1101,26 +1178,20 @@ const handleDetailsScroll = () => {
|
||||
min-width: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
.view-mode-toggle {
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.time {
|
||||
color: var(--td-text-color-disabled);
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.no_content {
|
||||
margin-top: 12px;
|
||||
color: var(--td-text-color-disabled);
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
padding: 16px;
|
||||
background: var(--td-bg-color-container);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -1132,23 +1203,10 @@ const handleDetailsScroll = () => {
|
||||
}
|
||||
|
||||
.chunk-item {
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
|
||||
&.chunk-even {
|
||||
background: var(--td-bg-color-container-hover);
|
||||
}
|
||||
|
||||
&.chunk-odd {
|
||||
background: var(--td-brand-color)0d;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--td-brand-color);
|
||||
box-shadow: 0 2px 8px rgba(7, 192, 95, 0.1);
|
||||
}
|
||||
border-radius: 4px;
|
||||
padding: 12px 16px;
|
||||
background: var(--td-bg-color-container);
|
||||
border: 1px solid var(--td-component-border);
|
||||
}
|
||||
|
||||
.chunk-header {
|
||||
@@ -1194,11 +1252,6 @@ const handleDetailsScroll = () => {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 4px 0;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--td-brand-color);
|
||||
}
|
||||
}
|
||||
|
||||
.parent-context-content {
|
||||
@@ -1230,11 +1283,6 @@ const handleDetailsScroll = () => {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 4px 0;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--td-brand-color);
|
||||
}
|
||||
}
|
||||
|
||||
.questions-list {
|
||||
@@ -1253,11 +1301,8 @@ const handleDetailsScroll = () => {
|
||||
font-size: 13px;
|
||||
color: var(--td-text-color-primary);
|
||||
line-height: 1.5;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--td-success-color-light);
|
||||
|
||||
.delete-question-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -1278,7 +1323,6 @@ const handleDetailsScroll = () => {
|
||||
opacity: 0;
|
||||
flex-shrink: 0;
|
||||
color: var(--td-text-color-placeholder);
|
||||
transition: opacity 0.2s ease, color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--td-error-color);
|
||||
|
||||
@@ -30,6 +30,8 @@ export default function (knowledgeBaseId?: string) {
|
||||
source: "",
|
||||
channel: "",
|
||||
file_type: "",
|
||||
description: "",
|
||||
summary_status: "",
|
||||
chunkLoading: false,
|
||||
chunkLoadError: "",
|
||||
});
|
||||
@@ -154,6 +156,8 @@ export default function (knowledgeBaseId?: string) {
|
||||
source: "",
|
||||
channel: "",
|
||||
file_type: "",
|
||||
description: "",
|
||||
summary_status: "",
|
||||
chunkLoadError: "",
|
||||
});
|
||||
getKnowledgeDetails(item.id)
|
||||
@@ -167,7 +171,9 @@ export default function (knowledgeBaseId?: string) {
|
||||
type: data.type || 'file',
|
||||
source: data.source || '',
|
||||
channel: data.channel || '',
|
||||
file_type: data.file_type || ''
|
||||
file_type: data.file_type || '',
|
||||
description: data.description || '',
|
||||
summary_status: data.summary_status || '',
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
@@ -236,6 +236,7 @@ export default {
|
||||
parsingFailed: 'Parsing failed',
|
||||
parsingInProgress: 'Parsing...',
|
||||
generatingSummary: 'Generating summary...',
|
||||
documentSummary: 'Summary',
|
||||
deleteConfirmation: 'Delete Confirmation',
|
||||
confirmDeleteDocument: 'Confirm deletion of document "{fileName}", recovery will be impossible after deletion',
|
||||
cancel: 'Cancel',
|
||||
|
||||
@@ -240,6 +240,7 @@ export default {
|
||||
parsingFailed: "파싱 실패",
|
||||
parsingInProgress: "파싱 중...",
|
||||
generatingSummary: "요약 생성 중...",
|
||||
documentSummary: "요약",
|
||||
deleteConfirmation: "삭제 확인",
|
||||
confirmDeleteDocument: '"{fileName}" 문서를 삭제하시겠습니까? 삭제 후 복구할 수 없습니다',
|
||||
cancel: "취소",
|
||||
|
||||
@@ -208,6 +208,7 @@ export default {
|
||||
parsingFailed: 'Парсинг не удался',
|
||||
parsingInProgress: 'Парсинг...',
|
||||
generatingSummary: 'Генерация резюме...',
|
||||
documentSummary: 'Резюме',
|
||||
deleteConfirmation: 'Подтверждение удаления',
|
||||
confirmDeleteDocument: 'Подтвердить удаление документа "{fileName}", после удаления восстановление невозможно',
|
||||
cancel: 'Отмена',
|
||||
|
||||
@@ -237,6 +237,7 @@ export default {
|
||||
parsingFailed: "解析失败",
|
||||
parsingInProgress: "解析中...",
|
||||
generatingSummary: "生成摘要中...",
|
||||
documentSummary: "摘要",
|
||||
deleteConfirmation: "删除确认",
|
||||
confirmDeleteDocument: '确认删除文档"{fileName}",删除后将无法恢复',
|
||||
cancel: "取消",
|
||||
|
||||
@@ -1910,7 +1910,10 @@ func (s *knowledgeService) processChunks(ctx context.Context,
|
||||
logger.GetLogger(ctx).Infof("processChunks successfully")
|
||||
}
|
||||
|
||||
// GetSummary generates a summary for knowledge content using an AI model
|
||||
// defaultMaxInputChars is the default maximum characters used as input for summary generation.
|
||||
const defaultMaxInputChars = 16384
|
||||
|
||||
// getSummary generates a summary for knowledge content using an AI model
|
||||
func (s *knowledgeService) getSummary(ctx context.Context,
|
||||
summaryModel chat.Chat, knowledge *types.Knowledge, chunks []*types.Chunk,
|
||||
) (string, error) {
|
||||
@@ -1919,6 +1922,12 @@ func (s *knowledgeService) getSummary(ctx context.Context,
|
||||
return "", fmt.Errorf("no chunks provided for summary generation")
|
||||
}
|
||||
|
||||
// Determine max input chars from config
|
||||
maxInputChars := defaultMaxInputChars
|
||||
if s.config.Conversation.Summary != nil && s.config.Conversation.Summary.MaxInputChars > 0 {
|
||||
maxInputChars = s.config.Conversation.Summary.MaxInputChars
|
||||
}
|
||||
|
||||
// concat chunk contents
|
||||
chunkContents := ""
|
||||
allImageInfos := make([]*types.ImageInfo, 0)
|
||||
@@ -1930,11 +1939,8 @@ func (s *knowledgeService) getSummary(ctx context.Context,
|
||||
return sortedChunks[i].StartAt < sortedChunks[j].StartAt
|
||||
})
|
||||
|
||||
// concat chunk contents and collect image infos
|
||||
// concat ALL chunk contents (no early truncation) and collect image infos
|
||||
for _, chunk := range sortedChunks {
|
||||
if chunk.EndAt > 4096 {
|
||||
break
|
||||
}
|
||||
// Ensure we don't slice beyond the current content length
|
||||
runes := []rune(chunkContents)
|
||||
if chunk.StartAt <= len(runes) {
|
||||
@@ -1970,9 +1976,11 @@ func (s *knowledgeService) getSummary(ctx context.Context,
|
||||
chunkContents = chunkContents + imageAnnotations
|
||||
}
|
||||
|
||||
if len(chunkContents) < 300 {
|
||||
return chunkContents, nil
|
||||
}
|
||||
// Apply length limit: sample long content to fit within maxInputChars
|
||||
chunkContents = sampleLongContent(chunkContents, maxInputChars)
|
||||
|
||||
logger.GetLogger(ctx).Infof("getSummary: content length=%d chars (max=%d) for knowledge %s",
|
||||
len([]rune(chunkContents)), maxInputChars, knowledge.ID)
|
||||
|
||||
// Prepare content with metadata for summary generation
|
||||
contentWithMetadata := chunkContents
|
||||
@@ -1990,6 +1998,12 @@ func (s *knowledgeService) getSummary(ctx context.Context,
|
||||
contentWithMetadata = metadataIntro + "\nContent:\n" + contentWithMetadata
|
||||
}
|
||||
|
||||
// Determine max output tokens from config
|
||||
maxTokens := 2048
|
||||
if s.config.Conversation.Summary != nil && s.config.Conversation.Summary.MaxCompletionTokens > 0 {
|
||||
maxTokens = s.config.Conversation.Summary.MaxCompletionTokens
|
||||
}
|
||||
|
||||
// Generate summary using AI model
|
||||
summaryPrompt := types.RenderPromptPlaceholders(s.config.Conversation.GenerateSummaryPrompt, types.PlaceholderValues{
|
||||
"language": types.LanguageNameFromContext(ctx),
|
||||
@@ -2006,7 +2020,7 @@ func (s *knowledgeService) getSummary(ctx context.Context,
|
||||
},
|
||||
}, &chat.ChatOptions{
|
||||
Temperature: 0.3,
|
||||
MaxTokens: 1024,
|
||||
MaxTokens: maxTokens,
|
||||
Thinking: &thinking,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -2017,6 +2031,51 @@ func (s *knowledgeService) getSummary(ctx context.Context,
|
||||
return summary.Content, nil
|
||||
}
|
||||
|
||||
// sampleLongContent returns content that fits within maxChars.
|
||||
// For short content (≤ maxChars), it is returned as-is.
|
||||
// For long content, it samples: head (60%), tail (20%), and evenly-spaced middle (20%),
|
||||
// joined by "[...content omitted...]" markers so the LLM knows content was skipped.
|
||||
func sampleLongContent(content string, maxChars int) string {
|
||||
runes := []rune(content)
|
||||
if len(runes) <= maxChars {
|
||||
return content
|
||||
}
|
||||
|
||||
const omitMarker = "\n\n[...content omitted...]\n\n"
|
||||
omitRunes := len([]rune(omitMarker))
|
||||
|
||||
// Reserve space for two omit markers (head→middle, middle→tail)
|
||||
usable := maxChars - 2*omitRunes
|
||||
if usable < 100 {
|
||||
// Fallback: just truncate
|
||||
return string(runes[:maxChars])
|
||||
}
|
||||
|
||||
headLen := usable * 60 / 100
|
||||
tailLen := usable * 20 / 100
|
||||
midLen := usable - headLen - tailLen
|
||||
|
||||
head := string(runes[:headLen])
|
||||
tail := string(runes[len(runes)-tailLen:])
|
||||
|
||||
// Sample middle portion: take a contiguous block from the center of the document
|
||||
midStart := len(runes)/2 - midLen/2
|
||||
if midStart < headLen {
|
||||
midStart = headLen
|
||||
}
|
||||
midEnd := midStart + midLen
|
||||
if midEnd > len(runes)-tailLen {
|
||||
midEnd = len(runes) - tailLen
|
||||
midStart = midEnd - midLen
|
||||
if midStart < headLen {
|
||||
midStart = headLen
|
||||
}
|
||||
}
|
||||
middle := string(runes[midStart:midEnd])
|
||||
|
||||
return head + omitMarker + middle + omitMarker + tail
|
||||
}
|
||||
|
||||
// enqueueQuestionGenerationTask enqueues an async task for question generation
|
||||
func (s *knowledgeService) enqueueQuestionGenerationTask(ctx context.Context,
|
||||
kbID, knowledgeID string, questionCount int,
|
||||
|
||||
@@ -111,6 +111,7 @@ type ConversationConfig struct {
|
||||
|
||||
// SummaryConfig 摘要配置
|
||||
type SummaryConfig struct {
|
||||
MaxInputChars int `yaml:"max_input_chars" json:"max_input_chars"` // Max input characters for summary generation (default: 16384)
|
||||
MaxTokens int `yaml:"max_tokens" json:"max_tokens"`
|
||||
RepeatPenalty float64 `yaml:"repeat_penalty" json:"repeat_penalty"`
|
||||
TopK int `yaml:"top_k" json:"top_k"`
|
||||
|
||||
Reference in New Issue
Block a user