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

develop
panyy 2026-06-23 17:17:57 +08:00
parent a598099fba
commit ac2b198cd7
3 changed files with 318 additions and 10 deletions

View File

@ -10,12 +10,15 @@ import json
import uvicorn import uvicorn
import click import click
import zipfile import zipfile
import urllib.request
import urllib.error
from pathlib import Path from pathlib import Path
import glob import glob
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
from fastapi import Depends, FastAPI, HTTPException, UploadFile, File, Form, APIRouter, Header from fastapi import Depends, FastAPI, HTTPException, UploadFile, File, Form, APIRouter, Header
from pydantic import BaseModel
from fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.gzip import GZipMiddleware
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, FileResponse from fastapi.responses import JSONResponse, FileResponse
@ -358,6 +361,116 @@ def get_infer_result(file_suffix_identifier: str, pdf_name: str, parse_dir: str)
api_router = APIRouter(prefix="/api") api_router = APIRouter(prefix="/api")
class MindmapOrganizeRequest(BaseModel):
markdown: str
mode: str = "smart"
def _extract_json_object(text: str) -> str:
content = text.strip()
if content.startswith("```"):
content = re.sub(r"^```(?:json|markdown|md)?\s*", "", content, flags=re.IGNORECASE)
content = re.sub(r"\s*```$", "", content)
return content.strip()
def _call_mindmap_llm(markdown: str, mode: str = "smart") -> str:
base_url = os.getenv("MINDMAP_LLM_BASE_URL", "").rstrip("/")
model = os.getenv("MINDMAP_LLM_MODEL", "gemma-4-26B")
api_key = os.getenv("MINDMAP_LLM_API_KEY", "")
timeout = int(os.getenv("MINDMAP_LLM_TIMEOUT", "180"))
if not base_url:
raise RuntimeError("未配置智能整理模型服务,请设置 MINDMAP_LLM_BASE_URL")
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...(后续内容已截断)"
style_instruction = "保留原文标题结构,并将段落总结成要点。" if mode == "hybrid" else "重新整理文档结构,提炼主题并合并相近段落。"
prompt = f"""你是文档结构整理助手。请基于用户提供的 Markdown 生成适合思维导图展示的 Markdown。
要求
1. {style_instruction}
2. 不要逐段照抄原文要归纳合并总结
3. 不要编造原文没有的信息
4. 保留关键数字公式专有名词步骤和结论
5. 最大层级不超过 4
6. 每个父节点下最多 8 个子节点
7. 节点标题尽量简短正文说明使用短句列表
8. 只输出 Markdown不要输出解释代码块围栏或额外说明
原始 Markdown
{compact_markdown}
"""
payload = {
"model": model,
"messages": [
{"role": "system", "content": "你擅长把长文档整理成结构清晰、层次合理的中文思维导图 Markdown。"},
{"role": "user", "content": prompt},
],
"temperature": float(os.getenv("MINDMAP_LLM_TEMPERATURE", "0.2")),
}
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
url = f"{base_url}/chat/completions"
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
result = json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="ignore")
raise RuntimeError(f"智能整理模型请求失败: HTTP {exc.code} {detail}") from exc
except Exception as exc:
raise RuntimeError(f"智能整理模型请求失败: {exc}") from exc
message = result.get("choices", [{}])[0].get("message", {})
content = message.get("content", "")
organized = _extract_json_object(content)
if not organized:
raise RuntimeError("智能整理模型未返回有效内容")
return organized
async def _run_mindmap_organize_task(task_id: str, markdown: str, mode: str):
try:
_store_task_progress(task_id, {
"progress": 10,
"stage": "准备智能整理",
"status": "processing",
"error": None,
"file_names": "",
"result_md": None,
})
_update_task_progress(task_id, 35, "调用智能整理模型")
organized = await asyncio.to_thread(_call_mindmap_llm, markdown, mode)
state = _get_task_progress(task_id) or {}
state.update({
"progress": 100,
"stage": "智能整理完成",
"status": "completed",
"error": None,
"result_md": organized,
})
_store_task_progress(task_id, state)
except Exception as exc:
logger.exception(exc)
state = _get_task_progress(task_id) or {}
state.update({
"progress": 100,
"stage": "智能整理失败",
"status": "failed",
"error": str(exc),
"result_md": None,
})
_store_task_progress(task_id, state)
@api_router.post("/parse_tasks/{task_id}", status_code=201) @api_router.post("/parse_tasks/{task_id}", status_code=201)
async def create_parse_task(task_id: str): async def create_parse_task(task_id: str):
"""Register a task before the multipart upload starts.""" """Register a task before the multipart upload starts."""
@ -383,6 +496,37 @@ async def get_parse_progress(task_id: str):
return state return state
@api_router.post("/mindmap_tasks/{task_id}", status_code=201)
async def create_mindmap_task(task_id: str, request: MindmapOrganizeRequest):
"""Create an async task that organizes Markdown into summarized mindmap Markdown."""
markdown = request.markdown.strip()
if not markdown:
raise HTTPException(status_code=400, detail="Markdown content is required")
state = {
"progress": 0,
"stage": "等待智能整理",
"status": "pending",
"error": None,
"file_names": "",
"result_md": None,
}
_store_task_progress(task_id, state)
asyncio.create_task(_run_mindmap_organize_task(task_id, markdown, request.mode))
logger.info(f"Registered mindmap organize task pid={os.getpid()} task_id={task_id}")
return state
@api_router.get("/mindmap_progress/{task_id}")
async def get_mindmap_progress(task_id: str):
"""Query async mindmap organization progress and result."""
state = _get_task_progress(task_id)
if state is None:
logger.warning(f"Mindmap task not found pid={os.getpid()} task_id={task_id}")
raise HTTPException(status_code=404, detail="Task not found")
return state
@api_router.post(path="/file_parse", dependencies=[Depends(limit_concurrency)]) @api_router.post(path="/file_parse", dependencies=[Depends(limit_concurrency)])
async def parse_pdf( async def parse_pdf(
files: List[UploadFile] = File(..., description="Upload pdf, image, or Word files for parsing"), files: List[UploadFile] = File(..., description="Upload pdf, image, or Word files for parsing"),

View File

@ -37,6 +37,10 @@ export interface ParseProgress {
file_names: string file_names: string
} }
export interface MindmapOrganizeProgress extends ParseProgress {
result_md?: string | null
}
export const documentApi = { export const documentApi = {
/** /**
* *
@ -96,5 +100,20 @@ export const documentApi = {
return request.get(`/api/parse_progress/${taskId}`).then(result => { return request.get(`/api/parse_progress/${taskId}`).then(result => {
return result as unknown as ParseProgress return result as unknown as ParseProgress
}) })
},
createMindmapTask(taskId: string, markdown: string, mode = 'smart'): Promise<MindmapOrganizeProgress> {
return request.post(`/api/mindmap_tasks/${encodeURIComponent(taskId)}`, {
markdown,
mode
}).then(result => {
return result as unknown as MindmapOrganizeProgress
})
},
getMindmapProgress(taskId: string): Promise<MindmapOrganizeProgress> {
return request.get(`/api/mindmap_progress/${encodeURIComponent(taskId)}`).then(result => {
return result as unknown as MindmapOrganizeProgress
})
} }
} }

View File

@ -141,10 +141,26 @@
<div v-show="activeTab === 'source'" class="source-content"> <div v-show="activeTab === 'source'" class="source-content">
<div class="content-toolbar source-toolbar"> <div class="content-toolbar source-toolbar">
<div v-if="templateRenderError" class="template-error-inline"> <div class="content-toolbar-info source-mode-toolbar">
{{ templateRenderError }} <el-segmented
v-model="sourceContentMode"
:options="sourceContentModeOptions"
size="small"
:disabled="sourceViewMode === 'ast' || isSmartOrganizing"
/>
<span v-if="isSmartOrganizing" class="smart-organize-status">
{{ smartOrganizeStage || '智能整理中' }} {{ smartOrganizeProgress }}%
</span>
<span v-else-if="smartOrganizeError" class="template-error-inline">
{{ smartOrganizeError }}
</span>
<span v-else-if="sourceContentMode === 'smart' && smartMarkdownContent" class="smart-organize-status">
智能整理结果
</span>
<span v-else-if="templateRenderError" class="template-error-inline">
{{ templateRenderError }}
</span>
</div> </div>
<div v-else></div>
<div class="content-toolbar-actions"> <div class="content-toolbar-actions">
<el-button <el-button
:type="hasActiveReplacementRules ? 'primary' : 'default'" :type="hasActiveReplacementRules ? 'primary' : 'default'"
@ -235,6 +251,7 @@ import { ElMessage } from 'element-plus'
import ConfigPanel from '@/components/ConfigPanel.vue' import ConfigPanel from '@/components/ConfigPanel.vue'
import MarkdownRenderer from '@/components/MarkdownRenderer.vue' import MarkdownRenderer from '@/components/MarkdownRenderer.vue'
import MindMapRenderer from '@/components/MindMapRenderer.vue' import MindMapRenderer from '@/components/MindMapRenderer.vue'
import { documentApi } from '@/api/document'
import { DOCUMENT_CONFIG_STORAGE_KEY, useDocumentProcessor } from '@/composables/useDocumentProcessor' import { DOCUMENT_CONFIG_STORAGE_KEY, useDocumentProcessor } from '@/composables/useDocumentProcessor'
import { markdownToAstString } from '@/utils/markdownAst' import { markdownToAstString } from '@/utils/markdownAst'
import { buildMindmapMarkdown, replaceFirstMindmapText } from '@/utils/mindmapMarkdown' import { buildMindmapMarkdown, replaceFirstMindmapText } from '@/utils/mindmapMarkdown'
@ -300,16 +317,34 @@ const markdownCompatibilityFlavor = ref<'commonmark' | 'gfm'>('gfm')
const sourceViewMode = ref<'markdown' | 'ast'>( const sourceViewMode = ref<'markdown' | 'ast'>(
loadStoredValue(SOURCE_VIEW_MODE_KEY, 'markdown', isSourceViewMode) loadStoredValue(SOURCE_VIEW_MODE_KEY, 'markdown', isSourceViewMode)
) )
const sourceContentMode = ref<'source' | 'smart'>('source')
const sourceContentModeOptions = [
{ label: '源码', value: 'source' },
{ label: '智能整理', value: 'smart' }
]
const showReplacementDialog = ref(false) const showReplacementDialog = ref(false)
const isMobileDialog = ref(false) const isMobileDialog = ref(false)
const replacementRules = ref<ReplacementRule[]>([]) const replacementRules = ref<ReplacementRule[]>([])
const draftReplacementRules = ref<ReplacementRule[]>([]) const draftReplacementRules = ref<ReplacementRule[]>([])
const smartMarkdownContent = ref('')
const smartOrganizeTaskId = ref('')
const isSmartOrganizing = ref(false)
const smartOrganizeProgress = ref(0)
const smartOrganizeStage = ref('')
const smartOrganizeError = ref('')
let smartOrganizeTimer: ReturnType<typeof setInterval> | null = null
watch(markdownRenderMode, (value) => storeValue(MARKDOWN_RENDER_MODE_KEY, value)) watch(markdownRenderMode, (value) => storeValue(MARKDOWN_RENDER_MODE_KEY, value))
watch(sourceViewMode, (value) => storeValue(SOURCE_VIEW_MODE_KEY, value)) watch(sourceViewMode, (value) => storeValue(SOURCE_VIEW_MODE_KEY, value))
const manualMarkdownContent = computed(() => results.value?.source || '') const manualMarkdownContent = computed(() => results.value?.source || '')
const templateRenderResult = computed(() => renderMarkdownTemplate(manualMarkdownContent.value, replacementRules.value)) const activeSourceMarkdownContent = computed(() => {
if (sourceContentMode.value === 'smart' && smartMarkdownContent.value) {
return smartMarkdownContent.value
}
return manualMarkdownContent.value
})
const templateRenderResult = computed(() => renderMarkdownTemplate(activeSourceMarkdownContent.value, replacementRules.value))
const renderedMarkdownContent = computed(() => templateRenderResult.value.markdown) const renderedMarkdownContent = computed(() => templateRenderResult.value.markdown)
const renderedMindmapContent = computed(() => buildMindmapMarkdown(renderedMarkdownContent.value)) const renderedMindmapContent = computed(() => buildMindmapMarkdown(renderedMarkdownContent.value))
const templateRenderError = computed(() => templateRenderResult.value.error) const templateRenderError = computed(() => templateRenderResult.value.error)
@ -337,15 +372,105 @@ const sourcePanelContent = computed({
if (sourceViewMode.value === 'ast') { if (sourceViewMode.value === 'ast') {
return markdownToAstString(renderedMarkdownContent.value) return markdownToAstString(renderedMarkdownContent.value)
} }
return manualMarkdownContent.value return activeSourceMarkdownContent.value
}, },
set: (value: string) => { set: (value: string) => {
if (sourceViewMode.value === 'markdown' && results.value) { if (sourceViewMode.value !== 'markdown') return
if (sourceContentMode.value === 'smart') {
smartMarkdownContent.value = value
return
}
if (results.value) {
results.value.source = value results.value.source = value
} }
} }
}) })
const stopSmartOrganizePolling = () => {
if (smartOrganizeTimer) {
clearInterval(smartOrganizeTimer)
smartOrganizeTimer = null
}
}
const resetSmartOrganizeState = () => {
stopSmartOrganizePolling()
sourceContentMode.value = 'source'
smartMarkdownContent.value = ''
smartOrganizeTaskId.value = ''
isSmartOrganizing.value = false
smartOrganizeProgress.value = 0
smartOrganizeStage.value = ''
smartOrganizeError.value = ''
}
const startSmartOrganizePolling = (taskId: string) => {
stopSmartOrganizePolling()
smartOrganizeTimer = setInterval(async () => {
try {
const data = await documentApi.getMindmapProgress(taskId)
smartOrganizeProgress.value = data.progress
smartOrganizeStage.value = data.stage
if (data.status === 'completed') {
stopSmartOrganizePolling()
isSmartOrganizing.value = false
smartMarkdownContent.value = data.result_md || ''
smartOrganizeError.value = ''
sourceContentMode.value = 'smart'
ElMessage.success('智能整理完成')
} else if (data.status === 'failed') {
stopSmartOrganizePolling()
isSmartOrganizing.value = false
sourceContentMode.value = 'source'
smartOrganizeError.value = data.error || '智能整理失败'
ElMessage.error(smartOrganizeError.value)
}
} catch (err) {
stopSmartOrganizePolling()
isSmartOrganizing.value = false
sourceContentMode.value = 'source'
smartOrganizeError.value = (err as Error).message || '智能整理进度查询失败'
ElMessage.error(smartOrganizeError.value)
}
}, 1500)
}
const startSmartOrganize = async () => {
const markdown = manualMarkdownContent.value.trim()
if (!markdown) {
sourceContentMode.value = 'source'
ElMessage.warning('暂无 Markdown 源码可整理')
return
}
if (smartMarkdownContent.value) {
sourceContentMode.value = 'smart'
return
}
const taskId = `mindmap-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
smartOrganizeTaskId.value = taskId
isSmartOrganizing.value = true
smartOrganizeProgress.value = 0
smartOrganizeStage.value = '创建智能整理任务'
smartOrganizeError.value = ''
try {
await documentApi.createMindmapTask(taskId, markdown, 'smart')
startSmartOrganizePolling(taskId)
} catch (err) {
isSmartOrganizing.value = false
sourceContentMode.value = 'source'
smartOrganizeError.value = (err as Error).message || '智能整理任务创建失败'
ElMessage.error(smartOrganizeError.value)
}
}
watch(sourceContentMode, (value) => {
if (value === 'smart') {
startSmartOrganize()
}
})
const toggleSettings = () => { const toggleSettings = () => {
if (showSettings.value) { if (showSettings.value) {
const settingsPanel = document.querySelector('.settings-panel') const settingsPanel = document.querySelector('.settings-panel')
@ -410,6 +535,7 @@ const applyConfig = (nextConfig: typeof config) => {
} }
const handleProcessDocument = async () => { const handleProcessDocument = async () => {
resetSmartOrganizeState()
await processDocument() await processDocument()
if (error.value) { if (error.value) {
ElMessage.error(error.value) ElMessage.error(error.value)
@ -465,13 +591,18 @@ const saveReplacementRules = () => {
const handleMindmapNodeEdit = ({ oldText, newText }: { oldText: string; newText: string }) => { const handleMindmapNodeEdit = ({ oldText, newText }: { oldText: string; newText: string }) => {
if (!results.value) return if (!results.value) return
const updated = replaceFirstMindmapText(results.value.source || results.value.markdown, oldText, newText) const currentMarkdown = activeSourceMarkdownContent.value
if (updated === (results.value.source || results.value.markdown)) { const updated = replaceFirstMindmapText(currentMarkdown, oldText, newText)
if (updated === currentMarkdown) {
ElMessage.warning('未在 Markdown 中定位到该节点文本,可在源码区直接编辑') ElMessage.warning('未在 Markdown 中定位到该节点文本,可在源码区直接编辑')
return return
} }
results.value.source = updated if (sourceContentMode.value === 'smart') {
results.value.markdown = updated smartMarkdownContent.value = updated
} else {
results.value.source = updated
results.value.markdown = updated
}
ElMessage.success('节点已同步到 Markdown') ElMessage.success('节点已同步到 Markdown')
} }
@ -485,6 +616,7 @@ const handleFileInputChange = async (event: Event) => {
await handleFileUpload(input.files) await handleFileUpload(input.files)
input.value = '' input.value = ''
if (uploadedFiles.value.length > 0) { if (uploadedFiles.value.length > 0) {
resetSmartOrganizeState()
isUploadAreaCollapsed.value = true isUploadAreaCollapsed.value = true
initializeManualResult() initializeManualResult()
markdownRenderMode.value = 'markdown' markdownRenderMode.value = 'markdown'
@ -501,6 +633,7 @@ const handleDrop = async (event: DragEvent) => {
if (event.dataTransfer?.files) { if (event.dataTransfer?.files) {
await handleFileUpload(event.dataTransfer.files) await handleFileUpload(event.dataTransfer.files)
if (uploadedFiles.value.length > 0) { if (uploadedFiles.value.length > 0) {
resetSmartOrganizeState()
isUploadAreaCollapsed.value = true isUploadAreaCollapsed.value = true
initializeManualResult() initializeManualResult()
markdownRenderMode.value = 'markdown' markdownRenderMode.value = 'markdown'
@ -518,6 +651,7 @@ const removeFile = (index: number) => {
} }
const clearAllFiles = () => { const clearAllFiles = () => {
resetSmartOrganizeState()
clearAll() clearAll()
isUploadAreaCollapsed.value = false isUploadAreaCollapsed.value = false
} }
@ -528,6 +662,7 @@ onMounted(() => {
}) })
onUnmounted(() => { onUnmounted(() => {
stopSmartOrganizePolling()
window.removeEventListener('resize', updateViewportState) window.removeEventListener('resize', updateViewportState)
}) })
</script> </script>
@ -957,6 +1092,16 @@ onUnmounted(() => {
flex: 1; flex: 1;
} }
.source-mode-toolbar {
align-items: center;
}
.smart-organize-status {
font-size: 12px;
color: #86909C;
white-space: nowrap;
}
.content-toolbar-actions { .content-toolbar-actions {
flex-shrink: 0; flex-shrink: 0;
} }