feat(思维导图助手):思维导图助手增加智能整理总结-分批次整理

develop
panyy 2026-06-24 10:19:11 +08:00
parent fd69d47eca
commit 8dbfeddfe4
5 changed files with 257 additions and 65 deletions

View File

@ -388,8 +388,117 @@ DEFAULT_MINDMAP_ORGANIZE_PROMPT = """你是文档结构整理助手。请基于
8. 节点标题尽量简短正文说明使用短句列表 8. 节点标题尽量简短正文说明使用短句列表
9. 只输出 Markdown不要输出解释代码块围栏或额外说明""" 9. 只输出 Markdown不要输出解释代码块围栏或额外说明"""
MINDMAP_MERGE_PROMPT = """你是 Markdown 思维导图结构校对助手。下面是多个分块已经整理总结后的局部 Markdown 大纲。
def _call_mindmap_llm(markdown: str, mode: str = "smart", custom_prompt: Optional[str] = None, task_id: Optional[str] = None) -> str: 任务
1. 合并这些局部大纲为一份完整 Markdown
2. 只检查和调整标题层级结构顺序重复标题和父子关系
3. 不要重新总结正文不要扩写内容不要新增原文没有的信息
4. 保留各局部大纲中的源语言英文保持英文中文保持中文多语言分别保留
5. 最大层级不超过 4
6. 每个父节点下最多 8 个子节点必要时只合并相近标题
7. 只输出 Markdown不要输出解释代码块围栏或额外说明"""
def _estimate_tokens(text: str) -> int:
ascii_chars = 0
non_ascii_chars = 0
for ch in text:
if ch.isspace():
continue
if ord(ch) < 128:
ascii_chars += 1
else:
non_ascii_chars += 1
return max(1, int(ascii_chars / 4) + int(non_ascii_chars * 1.5))
def _get_mindmap_context_budget(prompt: str, reserve_output_tokens: int = 4096) -> tuple[int, int]:
max_context_tokens = int(os.getenv("MINDMAP_LLM_MAX_CONTEXT_TOKENS", "32768"))
prompt_tokens = _estimate_tokens(prompt)
safety_tokens = int(os.getenv("MINDMAP_LLM_SAFETY_TOKENS", "1024"))
input_budget_tokens = max(2048, max_context_tokens - prompt_tokens - reserve_output_tokens - safety_tokens)
logger.info(
"Mindmap context budget max_context_tokens={} prompt_tokens={} reserve_output_tokens={} safety_tokens={} input_budget_tokens={}",
max_context_tokens, prompt_tokens, reserve_output_tokens, safety_tokens, input_budget_tokens
)
return max_context_tokens, input_budget_tokens
def _split_markdown_blocks(markdown: str) -> list[str]:
lines = markdown.splitlines()
blocks: list[str] = []
current: list[str] = []
heading_pattern = re.compile(r"^#{1,6}\s+")
for line in lines:
if heading_pattern.match(line) and current:
blocks.append("\n".join(current).strip())
current = [line]
else:
current.append(line)
if current:
blocks.append("\n".join(current).strip())
return [block for block in blocks if block]
def _split_large_block(block: str, max_tokens: int) -> list[str]:
paragraphs = re.split(r"\n{2,}", block)
chunks: list[str] = []
current: list[str] = []
current_tokens = 0
for paragraph in paragraphs:
paragraph = paragraph.strip()
if not paragraph:
continue
paragraph_tokens = _estimate_tokens(paragraph)
if current and current_tokens + paragraph_tokens > max_tokens:
chunks.append("\n\n".join(current))
current = [paragraph]
current_tokens = paragraph_tokens
else:
current.append(paragraph)
current_tokens += paragraph_tokens
if current:
chunks.append("\n\n".join(current))
return chunks or [block]
def _chunk_markdown_by_headings(markdown: str, max_tokens: int) -> list[str]:
blocks = _split_markdown_blocks(markdown)
chunks: list[str] = []
current: list[str] = []
current_tokens = 0
for block in blocks:
block_tokens = _estimate_tokens(block)
if block_tokens > max_tokens:
if current:
chunks.append("\n\n".join(current))
current = []
current_tokens = 0
chunks.extend(_split_large_block(block, max_tokens))
continue
if current and current_tokens + block_tokens > max_tokens:
chunks.append("\n\n".join(current))
current = [block]
current_tokens = block_tokens
else:
current.append(block)
current_tokens += block_tokens
if current:
chunks.append("\n\n".join(current))
return chunks or [markdown]
def _call_mindmap_llm(markdown: str, mode: str = "smart", custom_prompt: Optional[str] = None, task_id: Optional[str] = None, request_role: str = "organize") -> str:
base_url = os.getenv("MINDMAP_LLM_BASE_URL", "").rstrip("/") base_url = os.getenv("MINDMAP_LLM_BASE_URL", "").rstrip("/")
model = os.getenv("MINDMAP_LLM_MODEL", "gemma-4-26B") model = os.getenv("MINDMAP_LLM_MODEL", "gemma-4-26B")
api_key = os.getenv("MINDMAP_LLM_API_KEY", "") api_key = os.getenv("MINDMAP_LLM_API_KEY", "")
@ -399,10 +508,6 @@ def _call_mindmap_llm(markdown: str, mode: str = "smart", custom_prompt: Optiona
raise RuntimeError("未配置智能整理模型服务,请设置 MINDMAP_LLM_BASE_URL") raise RuntimeError("未配置智能整理模型服务,请设置 MINDMAP_LLM_BASE_URL")
compact_markdown = markdown.strip() compact_markdown = markdown.strip()
max_chars = int(os.getenv("MINDMAP_LLM_MAX_INPUT_CHARS", "30000"))
if len(compact_markdown) > max_chars:
compact_markdown = compact_markdown[:max_chars] + "\n\n...(后续内容已截断)"
prompt_template = (custom_prompt or "").strip() or DEFAULT_MINDMAP_ORGANIZE_PROMPT prompt_template = (custom_prompt or "").strip() or DEFAULT_MINDMAP_ORGANIZE_PROMPT
prompt = f"""{prompt_template} prompt = f"""{prompt_template}
@ -410,8 +515,8 @@ def _call_mindmap_llm(markdown: str, mode: str = "smart", custom_prompt: Optiona
{compact_markdown} {compact_markdown}
""" """
logger.info( logger.info(
"Mindmap LLM request start task_id={} model={} base_url={} mode={} input_chars={} prompt_chars={}", "Mindmap LLM request start task_id={} role={} model={} base_url={} mode={} input_chars={} input_tokens_est={} prompt_chars={}",
task_id or "-", model, base_url, mode, len(compact_markdown), len(prompt_template) task_id or "-", request_role, model, base_url, mode, len(compact_markdown), _estimate_tokens(compact_markdown), len(prompt_template)
) )
payload = { payload = {
@ -444,12 +549,60 @@ def _call_mindmap_llm(markdown: str, mode: str = "smart", custom_prompt: Optiona
if not organized: if not organized:
raise RuntimeError("智能整理模型未返回有效内容") raise RuntimeError("智能整理模型未返回有效内容")
logger.info( logger.info(
"Mindmap LLM request completed task_id={} output_chars={}", "Mindmap LLM request completed task_id={} role={} output_chars={} output_tokens_est={}",
task_id or "-", len(organized) task_id or "-", request_role, len(organized), _estimate_tokens(organized)
) )
return organized return organized
def _organize_mindmap_markdown(markdown: str, mode: str, custom_prompt: Optional[str], task_id: str) -> str:
prompt_template = (custom_prompt or "").strip() or DEFAULT_MINDMAP_ORGANIZE_PROMPT
_, input_budget_tokens = _get_mindmap_context_budget(prompt_template)
source_tokens = _estimate_tokens(markdown)
logger.info(
"Mindmap organize strategy task_id={} source_chars={} source_tokens_est={} input_budget_tokens={}",
task_id, len(markdown), source_tokens, input_budget_tokens
)
if source_tokens <= input_budget_tokens:
_update_task_progress(task_id, 35, "调用智能整理模型")
return _call_mindmap_llm(markdown, mode, prompt_template, task_id, "single")
chunks = _chunk_markdown_by_headings(markdown, input_budget_tokens)
logger.info("Mindmap large input split task_id={} chunks={}", task_id, len(chunks))
partial_results: list[str] = []
for index, chunk in enumerate(chunks, start=1):
progress = 20 + int(index / max(len(chunks), 1) * 55)
_update_task_progress(task_id, progress, f"智能整理分块 {index}/{len(chunks)}")
logger.info(
"Mindmap chunk organize task_id={} chunk={}/{} chars={} tokens_est={}",
task_id, index, len(chunks), len(chunk), _estimate_tokens(chunk)
)
partial = _call_mindmap_llm(chunk, mode, prompt_template, task_id, f"chunk-{index}")
partial_results.append(partial)
merged_input = "\n\n".join(
f"<!-- chunk {index} -->\n{partial}"
for index, partial in enumerate(partial_results, start=1)
)
_, merge_budget_tokens = _get_mindmap_context_budget(MINDMAP_MERGE_PROMPT)
merge_tokens = _estimate_tokens(merged_input)
if merge_tokens > merge_budget_tokens:
logger.warning(
"Mindmap merged outline still exceeds context task_id={} tokens_est={} budget={} chunks={}",
task_id, merge_tokens, merge_budget_tokens, len(partial_results)
)
merge_chunks = _chunk_markdown_by_headings(merged_input, merge_budget_tokens)
merged_round: list[str] = []
for index, chunk in enumerate(merge_chunks, start=1):
_update_task_progress(task_id, 78 + int(index / max(len(merge_chunks), 1) * 10), f"合并局部大纲 {index}/{len(merge_chunks)}")
merged_round.append(_call_mindmap_llm(chunk, mode, MINDMAP_MERGE_PROMPT, task_id, f"merge-round-{index}"))
merged_input = "\n\n".join(merged_round)
_update_task_progress(task_id, 90, "全局整理标题结构")
return _call_mindmap_llm(merged_input, mode, MINDMAP_MERGE_PROMPT, task_id, "merge")
async def _run_mindmap_organize_task(task_id: str, markdown: str, mode: str, prompt: Optional[str]): async def _run_mindmap_organize_task(task_id: str, markdown: str, mode: str, prompt: Optional[str]):
try: try:
_store_task_progress(task_id, { _store_task_progress(task_id, {
@ -460,8 +613,7 @@ async def _run_mindmap_organize_task(task_id: str, markdown: str, mode: str, pro
"file_names": "", "file_names": "",
"result_md": None, "result_md": None,
}) })
_update_task_progress(task_id, 35, "调用智能整理模型") organized = await asyncio.to_thread(_organize_mindmap_markdown, markdown, mode, prompt, task_id)
organized = await asyncio.to_thread(_call_mindmap_llm, markdown, mode, prompt, task_id)
state = _get_task_progress(task_id) or {} state = _get_task_progress(task_id) or {}
state.update({ state.update({
"progress": 100, "progress": 100,

View File

@ -14,29 +14,6 @@
/> />
</el-form-item> </el-form-item>
<el-form-item label="导出分辨率" class="form-item">
<el-select v-model="exportResolutionPreset" style="width: 100%" class="select">
<el-option
v-for="option in exportResolutionOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
<el-input-number
v-if="exportResolutionPreset === 'custom'"
v-model="draftConfig.exportResolution"
:min="720"
:max="16384"
:step="100"
controls-position="right"
class="export-resolution-input"
/>
<div class="form-item-description">
PNG/JPEG 导出长边上限默认 8K导出文件会按该最大长边生效
</div>
</el-form-item>
<!-- 解析后端 --> <!-- 解析后端 -->
<el-form-item :label="$t('config.backend')" class="form-item"> <el-form-item :label="$t('config.backend')" class="form-item">
<el-select <el-select
@ -73,25 +50,6 @@
</div> </div>
</el-form-item> </el-form-item>
<div class="divider"></div>
<div class="section-title">思维导图智能整理</div>
<el-form-item label="智能整理提示词" class="form-item">
<el-input
v-model="draftConfig.mindmapPrompt"
type="textarea"
:rows="10"
resize="vertical"
class="input"
/>
<div class="form-item-description">
切换到智能整理时发送给后端大模型默认保留标题结构总结段落要点并保持原文语言
</div>
</el-form-item>
<div class="divider"></div>
<!-- 识别选项 --> <!-- 识别选项 -->
<div class="section-title">{{ $t('config.recognitionOptions') }}</div> <div class="section-title">{{ $t('config.recognitionOptions') }}</div>
@ -144,6 +102,46 @@
</div> </div>
</el-form-item> </el-form-item>
</template> </template>
<div class="divider"></div>
<div class="section-title">导出与智能整理</div>
<el-form-item label="导出分辨率" class="form-item">
<el-select v-model="exportResolutionPreset" style="width: 100%" class="select">
<el-option
v-for="option in exportResolutionOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
<el-input-number
v-if="exportResolutionPreset === 'custom'"
v-model="draftConfig.exportResolution"
:min="720"
:max="16384"
:step="100"
controls-position="right"
class="export-resolution-input"
/>
<div class="form-item-description">
PNG/JPEG 导出长边上限默认 8K导出文件会按该最大长边生效
</div>
</el-form-item>
<el-form-item label="智能整理提示词" class="form-item">
<el-input
v-model="draftConfig.mindmapPrompt"
type="textarea"
:rows="10"
resize="vertical"
class="input"
/>
<div class="form-item-description">
切换到智能整理时发送给后端大模型默认保留标题结构总结段落要点并保持原文语言
</div>
</el-form-item>
<div class="config-actions"> <div class="config-actions">
<el-button @click="resetDraft"></el-button> <el-button @click="resetDraft"></el-button>
<el-button type="primary" @click="confirmConfig"></el-button> <el-button type="primary" @click="confirmConfig"></el-button>

View File

@ -10,6 +10,7 @@
type="primary" type="primary"
size="small" size="small"
class="action-button primary" class="action-button primary"
:disabled="!content"
> >
<template #icon> <template #icon>
<el-icon><Download /></el-icon> <el-icon><Download /></el-icon>
@ -456,6 +457,10 @@ const downloadMindMap = async (format: ExportFormat = 'svg') => {
// //
const handleDownload = (command: string) => { const handleDownload = (command: string) => {
if (!props.content) {
ElMessage.warning('暂无思维导图内容可下载')
return
}
if (command === 'svg' || command === 'png' || command === 'jpeg') { if (command === 'svg' || command === 'png' || command === 'jpeg') {
downloadMindMap(command) downloadMindMap(command)
} }

View File

@ -129,6 +129,8 @@ export function useDocumentProcessor() {
const validFiles: File[] = [] const validFiles: File[] = []
const file = files[0] const file = files[0]
uploadedFiles.value = [] uploadedFiles.value = []
results.value = null
error.value = null
if (files.length > 1) { if (files.length > 1) {
error.value = '一次只能选择一个文件,已使用第一个文件' error.value = '一次只能选择一个文件,已使用第一个文件'
} }
@ -138,16 +140,17 @@ export function useDocumentProcessor() {
console.log('Processing file:', fileName, 'type:', fileType) console.log('Processing file:', fileName, 'type:', fileType)
// 验证文件类型 - 支持 PDF、Word 和后端可转换为 PDF 的图片格式 // 验证文件类型 - 支持 Markdown、PDF、Word 和后端可转换为 PDF 的图片格式
const supportedImageExtensions = ['.png', '.jpg', '.jpeg', '.webp', '.bmp', '.tiff', '.gif', '.jp2'] const supportedImageExtensions = ['.png', '.jpg', '.jpeg', '.webp', '.bmp', '.tiff', '.gif', '.jp2']
const isImage = fileType.startsWith('image/') && supportedImageExtensions.some((ext) => fileName.endsWith(ext)) const isImage = fileType.startsWith('image/') && supportedImageExtensions.some((ext) => fileName.endsWith(ext))
const isPdf = fileType === 'application/pdf' const isPdf = fileType === 'application/pdf'
const isWord = fileName.endsWith('.docx') || fileName.endsWith('.doc') || fileType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' const isWord = fileName.endsWith('.docx') || fileName.endsWith('.doc') || fileType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
const isMarkdown = fileName.endsWith('.md') || fileType === 'text/markdown'
console.log('File type checks - isImage:', isImage, 'isPdf:', isPdf, 'isWord:', isWord) console.log('File type checks - isImage:', isImage, 'isPdf:', isPdf, 'isWord:', isWord, 'isMarkdown:', isMarkdown)
if (!isImage && !isPdf && !isWord) { if (!isImage && !isPdf && !isWord && !isMarkdown) {
error.value = '不支持的文件类型,请选择 PDF、Word、JPG、PNG、WEBP、BMP、TIFF、GIF 或 JP2 文件' error.value = '不支持的文件类型,请选择 MD、PDF、Word、JPG、PNG、WEBP、BMP、TIFF、GIF 或 JP2 文件'
console.log('Unsupported file type:', fileName, fileType) console.log('Unsupported file type:', fileName, fileType)
return return
} }
@ -159,8 +162,26 @@ export function useDocumentProcessor() {
return return
} }
// 如果是 Word 文档,转换为 PDF if (isMarkdown) {
if (isWord) { try {
isUploading.value = true
const markdown = await file.text()
validFiles.push(file)
results.value = {
markdown,
source: markdown,
mindmap: buildMindmapMarkdown(markdown)
}
error.value = null
console.log('Markdown file loaded directly:', fileName, markdown.length)
} catch (err) {
error.value = 'Markdown 文件读取失败: ' + (err as Error).message
console.error('Markdown read failed:', (err as Error).message)
return
} finally {
isUploading.value = false
}
} else if (isWord) {
try { try {
isUploading.value = true isUploading.value = true
error.value = '正在将 Word 文档转换为 PDF...' error.value = '正在将 Word 文档转换为 PDF...'
@ -242,6 +263,14 @@ export function useDocumentProcessor() {
return return
} }
const firstFileName = uploadedFiles.value[0]?.name.toLowerCase() || ''
if (firstFileName.endsWith('.md')) {
if (!results.value) {
error.value = 'Markdown 文件内容为空,请重新上传'
}
return
}
isProcessing.value = true isProcessing.value = true
error.value = null error.value = null
processingStage.value = '准备提交解析任务' processingStage.value = '准备提交解析任务'

View File

@ -21,7 +21,7 @@
<el-icon class="upload-icon"><Folder /></el-icon> <el-icon class="upload-icon"><Folder /></el-icon>
<div class="upload-copy"> <div class="upload-copy">
<div class="upload-text">文件导入</div> <div class="upload-text">文件导入</div>
<div class="upload-hint">支持 PDFWordJPGPNGWEBPBMPTIFFGIFJP2 单个文件</div> <div class="upload-hint">支持 MDPDFWordJPGPNGBMPTIFF 等格式文件</div>
</div> </div>
</div> </div>
@ -201,7 +201,7 @@
<input <input
ref="fileInput" ref="fileInput"
type="file" type="file"
accept=".pdf,.doc,.docx,.png,.jpg,.jpeg,.webp,.bmp,.tiff,.gif,.jp2" accept=".md,.pdf,.doc,.docx,.png,.jpg,.jpeg,.webp,.bmp,.tiff,.gif,.jp2"
style="position: absolute; width: 0; height: 0; overflow: hidden;" style="position: absolute; width: 0; height: 0; overflow: hidden;"
@change="handleFileInputChange" @change="handleFileInputChange"
/> />
@ -618,7 +618,9 @@ const handleFileInputChange = async (event: Event) => {
if (uploadedFiles.value.length > 0) { if (uploadedFiles.value.length > 0) {
resetSmartOrganizeState() resetSmartOrganizeState()
isUploadAreaCollapsed.value = true isUploadAreaCollapsed.value = true
initializeManualResult() if (!results.value) {
initializeManualResult()
}
markdownRenderMode.value = 'markdown' markdownRenderMode.value = 'markdown'
sourceViewMode.value = 'markdown' sourceViewMode.value = 'markdown'
activeTab.value = 'source' activeTab.value = 'source'
@ -635,7 +637,9 @@ const handleDrop = async (event: DragEvent) => {
if (uploadedFiles.value.length > 0) { if (uploadedFiles.value.length > 0) {
resetSmartOrganizeState() resetSmartOrganizeState()
isUploadAreaCollapsed.value = true isUploadAreaCollapsed.value = true
initializeManualResult() if (!results.value) {
initializeManualResult()
}
markdownRenderMode.value = 'markdown' markdownRenderMode.value = 'markdown'
sourceViewMode.value = 'markdown' sourceViewMode.value = 'markdown'
activeTab.value = 'source' activeTab.value = 'source'
@ -733,14 +737,16 @@ onUnmounted(() => {
background-color: #FFFFFF; background-color: #FFFFFF;
margin-bottom: 0; margin-bottom: 0;
overflow: hidden; overflow: hidden;
min-height: 36px; height: 52px;
min-height: 52px;
display: flex; display: flex;
align-items: center; align-items: center;
box-sizing: border-box;
} }
.drag-upload-area.collapsed { .drag-upload-area.collapsed {
padding: 8px 16px; padding: 8px 16px;
min-height: 36px; min-height: 52px;
} }
.drag-upload-area:hover, .drag-upload-area:hover,
@ -782,6 +788,7 @@ onUnmounted(() => {
.uploaded-files { .uploaded-files {
width: 100%; width: 100%;
height: 100%;
text-align: left; text-align: left;
} }
@ -795,6 +802,7 @@ onUnmounted(() => {
margin-bottom: 0; margin-bottom: 0;
transition: all 0.3s ease; transition: all 0.3s ease;
width: 100%; width: 100%;
height: 100%;
box-sizing: border-box; box-sizing: border-box;
font-size: 13px; font-size: 13px;
} }