feat(思维导图助手):思维导图助手过程2

develop
panyy 2026-06-23 11:05:11 +08:00
parent 59e2554238
commit 8509f351a4
6 changed files with 331 additions and 215 deletions

View File

@ -12,6 +12,9 @@ import click
import zipfile import zipfile
from pathlib import Path from pathlib import Path
import glob import glob
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 fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.gzip import GZipMiddleware
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@ -25,7 +28,9 @@ from base64 import b64encode
# MinerU 内部导入 # MinerU 内部导入
from mineru.cli.common import aio_do_parse, read_fn, pdf_suffixes, image_suffixes, word_suffixes from mineru.cli.common import aio_do_parse, read_fn, pdf_suffixes, image_suffixes, word_suffixes
from mineru.utils.cli_parser import arg_parse from mineru.utils.cli_parser import arg_parse
from mineru.utils.config_reader import get_device
from mineru.utils.guess_suffix_or_lang import guess_suffix_by_path from mineru.utils.guess_suffix_or_lang import guess_suffix_by_path
from mineru.utils.model_utils import clean_memory
from mineru.version import __version__ from mineru.version import __version__
# --- 日志配置 --- # --- 日志配置 ---
@ -89,6 +94,22 @@ def _update_task_progress(task_id: Optional[str], progress: int, stage: str):
_store_task_progress(task_id, state) _store_task_progress(task_id, state)
def _cleanup_runtime_memory():
"""Release transient accelerator caches after each API parse request."""
try:
clean_memory(get_device())
except Exception as exc:
logger.warning(f"Failed to clean runtime memory: {exc}")
def _format_parse_error(exc: Exception) -> str:
message = str(exc)
if "CUDA out of memory" in message or exc.__class__.__name__ in ("OutOfMemoryError", "RuntimeError"):
if "out of memory" in message.lower():
return "GPU显存不足已尝试清理缓存。请减少最大页数、关闭公式/表格识别,或稍后重试。"
return f"Internal Error: {message}"
class ProgressTracker: class ProgressTracker:
def __init__(self): def __init__(self):
self.progress = 0 self.progress = 0
@ -467,6 +488,7 @@ async def parse_pdf(
completed_state = _get_task_progress(task_id) or {} completed_state = _get_task_progress(task_id) or {}
completed_state["status"] = "completed" completed_state["status"] = "completed"
_store_task_progress(task_id, completed_state) _store_task_progress(task_id, completed_state)
_cleanup_runtime_memory()
# 清理日志捕获和 stderr 捕获 # 清理日志捕获和 stderr 捕获
stderr_capture.stop() stderr_capture.stop()
@ -531,9 +553,10 @@ async def parse_pdf(
pass pass
failed_state = _get_task_progress(task_id) or {} failed_state = _get_task_progress(task_id) or {}
failed_state["status"] = "failed" failed_state["status"] = "failed"
failed_state["error"] = str(e) failed_state["error"] = _format_parse_error(e)
_store_task_progress(task_id, failed_state) _store_task_progress(task_id, failed_state)
return JSONResponse(status_code=500, content={"error": f"Internal Error: {str(e)}"}) _cleanup_runtime_memory()
return JSONResponse(status_code=500, content={"error": _format_parse_error(e)})
# --- FastAPI 核心应用 --- # --- FastAPI 核心应用 ---

View File

@ -16,9 +16,12 @@
</el-button> </el-button>
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu>
<el-dropdown-item command="svg">SVG 格式</el-dropdown-item> <el-dropdown-item command="full-svg">全部导图 SVG</el-dropdown-item>
<el-dropdown-item command="png">PNG 格式</el-dropdown-item> <el-dropdown-item command="full-png">全部导图 PNG</el-dropdown-item>
<el-dropdown-item command="jpeg">JPEG 格式</el-dropdown-item> <el-dropdown-item command="full-jpeg">全部导图 JPEG</el-dropdown-item>
<el-dropdown-item divided command="viewport-svg">当前窗口 SVG</el-dropdown-item>
<el-dropdown-item command="viewport-png">当前窗口 PNG</el-dropdown-item>
<el-dropdown-item command="viewport-jpeg">当前窗口 JPEG</el-dropdown-item>
</el-dropdown-menu> </el-dropdown-menu>
</template> </template>
</el-dropdown> </el-dropdown>
@ -176,14 +179,30 @@ function downloadBlob(blob: Blob, fileName: string) {
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} }
function createExportSvg() { type ExportFormat = 'svg' | 'png' | 'jpeg'
type ExportScope = 'full' | 'viewport'
function getViewportSize(svg: SVGElement) {
const rect = svg.getBoundingClientRect()
return {
width: Math.ceil(Math.max(rect.width, 320)),
height: Math.ceil(Math.max(rect.height, 240))
}
}
function cleanExportSvg(svg: SVGElement) {
if (!svg.getAttribute('xmlns')) {
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
}
svg.querySelectorAll('[style*="cursor"]').forEach((node) => node.removeAttribute('style'))
}
function createFullExportSvg() {
const svg = svgRef.value const svg = svgRef.value
if (!svg) return null if (!svg) return null
const svgCopy = svg.cloneNode(true) as SVGElement const svgCopy = svg.cloneNode(true) as SVGElement
if (!svgCopy.getAttribute('xmlns')) { cleanExportSvg(svgCopy)
svgCopy.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
}
const contentGroup = svg.querySelector('g') const contentGroup = svg.querySelector('g')
const bbox = contentGroup const bbox = contentGroup
@ -195,10 +214,30 @@ function createExportSvg() {
const height = Math.ceil(Math.max(bbox.height + padding * 2, 240)) const height = Math.ceil(Math.max(bbox.height + padding * 2, 240))
const viewBox = `${Math.floor(bbox.x - padding)} ${Math.floor(bbox.y - padding)} ${width} ${height}` const viewBox = `${Math.floor(bbox.x - padding)} ${Math.floor(bbox.y - padding)} ${width} ${height}`
const viewportGroupCopy = svgCopy.querySelector('g')
viewportGroupCopy?.removeAttribute('transform')
svgCopy.setAttribute('viewBox', viewBox) svgCopy.setAttribute('viewBox', viewBox)
svgCopy.setAttribute('width', String(width)) svgCopy.setAttribute('width', String(width))
svgCopy.setAttribute('height', String(height)) svgCopy.setAttribute('height', String(height))
svgCopy.querySelectorAll('[style*="cursor"]').forEach((node) => node.removeAttribute('style'))
return {
data: new XMLSerializer().serializeToString(svgCopy),
width,
height
}
}
function createViewportExportSvg() {
const svg = svgRef.value
if (!svg) return null
const svgCopy = svg.cloneNode(true) as SVGElement
cleanExportSvg(svgCopy)
const { width, height } = getViewportSize(svg)
svgCopy.setAttribute('width', String(width))
svgCopy.setAttribute('height', String(height))
svgCopy.setAttribute('viewBox', svg.getAttribute('viewBox') || `0 0 ${width} ${height}`)
return { return {
data: new XMLSerializer().serializeToString(svgCopy), data: new XMLSerializer().serializeToString(svgCopy),
@ -208,17 +247,18 @@ function createExportSvg() {
} }
// //
const downloadMindMap = async (format: 'svg' | 'png' | 'jpeg' = 'svg') => { const downloadMindMap = async (format: ExportFormat = 'svg', scope: ExportScope = 'full') => {
const exportSvg = createExportSvg() const exportSvg = scope === 'viewport' ? createViewportExportSvg() : createFullExportSvg()
if (!exportSvg) return if (!exportSvg) return
const time = new Date().getTime() const time = new Date().getTime()
const scopeName = scope === 'viewport' ? 'viewport' : 'full'
if (format === 'svg') { if (format === 'svg') {
downloadBlob(new Blob([exportSvg.data], { type: 'image/svg+xml;charset=utf-8' }), `mindmap_${time}.svg`) downloadBlob(new Blob([exportSvg.data], { type: 'image/svg+xml;charset=utf-8' }), `mindmap_${scopeName}_${time}.svg`)
return return
} }
const maxCanvasPixels = 16_000_000 const maxCanvasPixels = scope === 'viewport' ? 16_000_000 : 64_000_000
const scaleFactor = Math.min(3, Math.max(1, Math.sqrt(maxCanvasPixels / (exportSvg.width * exportSvg.height)))) const scaleFactor = Math.min(3, Math.max(1, Math.sqrt(maxCanvasPixels / (exportSvg.width * exportSvg.height))))
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d') const ctx = canvas.getContext('2d')
@ -236,7 +276,7 @@ const downloadMindMap = async (format: 'svg' | 'png' | 'jpeg' = 'svg') => {
const mimeType = format === 'jpeg' ? 'image/jpeg' : 'image/png' const mimeType = format === 'jpeg' ? 'image/jpeg' : 'image/png'
canvas.toBlob((blob) => { canvas.toBlob((blob) => {
if (!blob) return if (!blob) return
downloadBlob(blob, `mindmap_${time}.${format === 'jpeg' ? 'jpg' : 'png'}`) downloadBlob(blob, `mindmap_${scopeName}_${time}.${format === 'jpeg' ? 'jpg' : 'png'}`)
}, mimeType, 0.96) }, mimeType, 0.96)
} }
img.onerror = () => ElMessage.error('导出图片失败') img.onerror = () => ElMessage.error('导出图片失败')
@ -245,8 +285,9 @@ const downloadMindMap = async (format: 'svg' | 'png' | 'jpeg' = 'svg') => {
// //
const handleDownload = (command: string) => { const handleDownload = (command: string) => {
if (command === 'svg' || command === 'png' || command === 'jpeg') { const [scope, format] = command.split('-') as [ExportScope, ExportFormat]
downloadMindMap(command) if ((scope === 'full' || scope === 'viewport') && (format === 'svg' || format === 'png' || format === 'jpeg')) {
downloadMindMap(format, scope)
} }
} }

View File

@ -1,4 +1,4 @@
import { ref, reactive } from 'vue' import { ref, reactive, watch } from 'vue'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { documentApi, type ParseParams } from '@/api/document' import { documentApi, type ParseParams } from '@/api/document'
import { convertWordToPdf, isWordFile } from '@/utils/wordToPdf' import { convertWordToPdf, isWordFile } from '@/utils/wordToPdf'
@ -26,13 +26,24 @@ export interface ProcessResult {
downloadUrl?: string downloadUrl?: string
} }
const CONFIG_STORAGE_KEY = 'mineru.documentProcessor.config'
function loadCachedConfig(): Partial<DocumentConfig> {
try {
const raw = window.localStorage.getItem(CONFIG_STORAGE_KEY)
return raw ? JSON.parse(raw) : {}
} catch {
return {}
}
}
export function useDocumentProcessor() { export function useDocumentProcessor() {
// 文件相关 // 文件相关
const uploadedFiles: Ref<File[]> = ref([]) const uploadedFiles: Ref<File[]> = ref([])
const isUploading = ref(false) const isUploading = ref(false)
// 配置相关 // 配置相关
const config = reactive<DocumentConfig>({ const defaultConfig: DocumentConfig = {
maxPages: 1000, maxPages: 1000,
backend: 'hybrid-auto-engine', backend: 'hybrid-auto-engine',
serverUrl: 'http://localhost:30000', serverUrl: 'http://localhost:30000',
@ -40,8 +51,24 @@ export function useDocumentProcessor() {
formulaEnable: true, formulaEnable: true,
language: 'ch', language: 'ch',
forceOcr: false forceOcr: false
}
const config = reactive<DocumentConfig>({
...defaultConfig,
...loadCachedConfig()
}) })
watch(
config,
(value) => {
try {
window.localStorage.setItem(CONFIG_STORAGE_KEY, JSON.stringify(value))
} catch {
// 浏览器禁用本地存储时不影响当前页面使用。
}
},
{ deep: true }
)
// 结果相关 // 结果相关
const results = ref<ProcessResult | null>(null) const results = ref<ProcessResult | null>(null)
const isProcessing = ref(false) const isProcessing = ref(false)
@ -82,55 +109,57 @@ export function useDocumentProcessor() {
if (!files || files.length === 0) return if (!files || files.length === 0) return
const validFiles: File[] = [] const validFiles: File[] = []
const file = files[0]
uploadedFiles.value = []
if (files.length > 1) {
error.value = '一次只能选择一个文件,已使用第一个文件'
}
for (let i = 0; i < files.length; i++) { const fileType = file.type
const file = files[i] const fileName = file.name.toLowerCase()
const fileType = file.type
const fileName = file.name.toLowerCase()
console.log('Processing file:', fileName, 'type:', fileType) console.log('Processing file:', fileName, 'type:', fileType)
// 验证文件类型 - 支持 PDF、图片和 Word 文档 // 验证文件类型 - 支持 PDF、图片和 Word 文档
const isImage = fileType.startsWith('image/') const isImage = fileType.startsWith('image/')
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'
console.log('File type checks - isImage:', isImage, 'isPdf:', isPdf, 'isWord:', isWord) console.log('File type checks - isImage:', isImage, 'isPdf:', isPdf, 'isWord:', isWord)
if (!isImage && !isPdf && !isWord) { if (!isImage && !isPdf && !isWord) {
error.value = '不支持的文件类型' error.value = '不支持的文件类型'
console.log('Unsupported file type:', fileName, fileType) console.log('Unsupported file type:', fileName, fileType)
continue return
} }
// 验证文件大小 (100MB) // 验证文件大小 (100MB)
if (file.size > 100 * 1024 * 1024) { if (file.size > 100 * 1024 * 1024) {
error.value = '文件大小超出限制' error.value = '文件大小超出限制'
console.log('File too large:', fileName, file.size) console.log('File too large:', fileName, file.size)
continue return
} }
// 如果是 Word 文档,转换为 PDF // 如果是 Word 文档,转换为 PDF
if (isWord) { if (isWord) {
try { try {
isUploading.value = true isUploading.value = true
error.value = '正在将 Word 文档转换为 PDF...' error.value = '正在将 Word 文档转换为 PDF...'
console.log('Converting Word to PDF:', fileName) console.log('Converting Word to PDF:', fileName)
const pdfFile = await convertWordToPdf(file) const pdfFile = await convertWordToPdf(file)
console.log('Conversion successful, PDF file:', pdfFile.name, pdfFile.size) console.log('Conversion successful, PDF file:', pdfFile.name, pdfFile.size)
validFiles.push(pdfFile) validFiles.push(pdfFile)
error.value = null error.value = null
} catch (err) { } catch (err) {
error.value = 'Word 转换为 PDF 失败: ' + (err as Error).message error.value = 'Word 转换为 PDF 失败: ' + (err as Error).message
console.error('Conversion failed:', (err as Error).message) console.error('Conversion failed:', (err as Error).message)
continue return
} finally { } finally {
isUploading.value = false isUploading.value = false
}
} else {
validFiles.push(file)
console.log('Adding file directly:', fileName)
} }
} else {
validFiles.push(file)
console.log('Adding file directly:', fileName)
} }
uploadedFiles.value = validFiles uploadedFiles.value = validFiles

View File

@ -1,167 +1,156 @@
const MAX_NODE_TEXT_LENGTH = 72 import MarkdownIt from 'markdown-it'
const MAX_PARAGRAPH_SUMMARY_LENGTH = 90 import type Token from 'markdown-it/lib/token.mjs'
const MAX_CHILDREN_PER_HEADING = 18
const ROOT_TITLE = '文档思维导图' const ROOT_TITLE = '文档思维导图'
const MAX_HEADING_LEVEL = 6
type BlockType = 'paragraph' | 'list' | 'table' | 'code' | 'math' | 'image' const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
breaks: true
})
interface HeadingState { function normalizeNodeText(text: string) {
level: number return text
childCount: number .replace(/\r?\n/g, ' ')
.replace(/\s+/g, ' ')
.trim()
} }
function normalizeWhitespace(text: string) { function clampHeadingLevel(level: number) {
return text.replace(/\s+/g, ' ').trim() return Math.min(Math.max(level, 1), MAX_HEADING_LEVEL)
} }
function stripMarkdownInline(text: string) { function pushHeading(result: string[], level: number, text: string) {
return normalizeWhitespace(text) const normalized = normalizeNodeText(text)
.replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1') if (!normalized) return
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') result.push(`${'#'.repeat(clampHeadingLevel(level))} ${normalized}`)
.replace(/[*_`~>#]/g, '')
} }
function truncateText(text: string, maxLength = MAX_NODE_TEXT_LENGTH) { function getHeadingLevel(token: Token) {
const normalized = normalizeWhitespace(text) const tagLevel = Number(token.tag.replace(/^h/i, ''))
if (normalized.length <= maxLength) return normalized return Number.isFinite(tagLevel) && tagLevel > 0 ? tagLevel : 1
return `${normalized.slice(0, maxLength - 1)}...`
} }
function summarizeParagraph(text: string) { function getNextInlineContent(tokens: Token[], index: number) {
const cleaned = stripMarkdownInline(text) const next = tokens[index + 1]
const sentence = cleaned.match(/^(.+?[。!?!?;:]|\S.{20,}?[,])/) return next?.type === 'inline' ? next.content : ''
return truncateText(sentence?.[1] || cleaned, MAX_PARAGRAPH_SUMMARY_LENGTH)
} }
function isHeading(line: string) { function getFenceContent(token: Token) {
return /^#{1,6}\s+/.test(line.trim()) const info = normalizeNodeText(token.info || '')
const content = token.content.trim()
if (!content) return info ? `代码块 ${info}` : '代码块'
return info ? `代码块 ${info}: ${content}` : `代码块: ${content}`
} }
function getHeadingLevel(line: string) { function collectTableRows(tokens: Token[], startIndex: number) {
return line.trim().match(/^#{1,6}/)?.[0].length || 0 const rows: string[][] = []
} let currentRow: string[] | null = null
let endIndex = startIndex
function getHeadingText(line: string) { for (let i = startIndex + 1; i < tokens.length; i += 1) {
return stripMarkdownInline(line.trim().replace(/^#{1,6}\s+/, '')) const token = tokens[i]
} endIndex = i
function getListText(line: string) { if (token.type === 'table_close') break
return stripMarkdownInline(line.trim().replace(/^[-*+]\s+|^\d+[.)、]\s+/, '')) if (token.type === 'tr_open') {
} currentRow = []
continue
function countHeadingChildren(stack: HeadingState[], level: number) { }
while (stack.length && stack[stack.length - 1].level >= level) { if (token.type === 'tr_close') {
stack.pop() if (currentRow && currentRow.some((cell) => cell.trim())) {
rows.push(currentRow)
}
currentRow = null
continue
}
if ((token.type === 'th_open' || token.type === 'td_open') && currentRow) {
currentRow.push(getNextInlineContent(tokens, i))
}
} }
const parent = stack[stack.length - 1] return { rows, endIndex }
if (!parent) return true
parent.childCount += 1
return parent.childCount <= MAX_CHILDREN_PER_HEADING
}
function pushBlock(result: string[], stack: HeadingState[], blockType: BlockType, text: string) {
const cleaned = text.trim()
if (!cleaned) return
const parentLevel = stack.length ? stack[stack.length - 1].level : 1
const level = Math.min(parentLevel + 1, 6)
if (!countHeadingChildren(stack, level)) return
const prefixMap: Record<BlockType, string> = {
paragraph: '摘要',
list: '要点',
table: '表格',
code: '代码',
math: '公式',
image: '图片'
}
result.push(`${'#'.repeat(level)} ${prefixMap[blockType]}${cleaned}`)
} }
export function buildMindmapMarkdown(markdown: string) { export function buildMindmapMarkdown(markdown: string) {
const lines = markdown.split(/\r?\n/) if (!markdown.trim()) return ''
const tokens = md.parse(markdown, {})
const result: string[] = [`# ${ROOT_TITLE}`] const result: string[] = [`# ${ROOT_TITLE}`]
const stack: HeadingState[] = [{ level: 1, childCount: 0 }] let currentHeadingLevel = 1
let paragraphBuffer: string[] = [] let listDepth = 0
let inCodeBlock = false
let codeBlockTitle = ''
const flushParagraph = () => { for (let i = 0; i < tokens.length; i += 1) {
if (paragraphBuffer.length === 0) return const token = tokens[i]
const text = summarizeParagraph(paragraphBuffer.join(' '))
pushBlock(result, stack, 'paragraph', text) if (token.type === 'heading_open') {
paragraphBuffer = [] currentHeadingLevel = clampHeadingLevel(getHeadingLevel(token) + 1)
pushHeading(result, currentHeadingLevel, getNextInlineContent(tokens, i) || '未命名章节')
i += 2
continue
}
if (token.type === 'bullet_list_open' || token.type === 'ordered_list_open') {
listDepth += 1
continue
}
if (token.type === 'bullet_list_close' || token.type === 'ordered_list_close') {
listDepth = Math.max(0, listDepth - 1)
continue
}
if (token.type === 'paragraph_open') {
const level = currentHeadingLevel + Math.max(listDepth, 1)
pushHeading(result, level, getNextInlineContent(tokens, i))
i += 2
continue
}
if (token.type === 'fence' || token.type === 'code_block') {
pushHeading(result, currentHeadingLevel + 1, getFenceContent(token))
continue
}
if (token.type === 'html_block') {
pushHeading(result, currentHeadingLevel + 1, token.content)
continue
}
if (token.type === 'blockquote_open') {
pushHeading(result, currentHeadingLevel + 1, '引用')
currentHeadingLevel = clampHeadingLevel(currentHeadingLevel + 1)
continue
}
if (token.type === 'blockquote_close') {
currentHeadingLevel = Math.max(1, currentHeadingLevel - 1)
continue
}
if (token.type === 'hr') {
pushHeading(result, currentHeadingLevel + 1, '---')
continue
}
if (token.type === 'table_open') {
const tableLevel = currentHeadingLevel + 1
const { rows, endIndex } = collectTableRows(tokens, i)
pushHeading(result, tableLevel, '表格')
rows.forEach((row) => {
pushHeading(result, tableLevel + 1, row.map(normalizeNodeText).join(' | '))
})
i = endIndex
}
} }
for (const line of lines) {
const trimmed = line.trim()
if (/^```/.test(trimmed)) {
if (inCodeBlock) {
pushBlock(result, stack, 'code', codeBlockTitle || '代码块')
codeBlockTitle = ''
} else {
flushParagraph()
codeBlockTitle = trimmed.replace(/^```/, '').trim()
}
inCodeBlock = !inCodeBlock
continue
}
if (inCodeBlock) continue
if (!trimmed) {
flushParagraph()
continue
}
if (isHeading(trimmed)) {
flushParagraph()
const rawLevel = getHeadingLevel(trimmed)
const level = Math.min(rawLevel + 1, 6)
while (stack.length && stack[stack.length - 1].level >= level) {
stack.pop()
}
result.push(`${'#'.repeat(level)} ${truncateText(getHeadingText(trimmed)) || '未命名章节'}`)
stack.push({ level, childCount: 0 })
continue
}
if (/^\|.+\|$/.test(trimmed)) {
flushParagraph()
pushBlock(result, stack, 'table', '表格内容')
continue
}
if (/^!\[[^\]]*]\([^)]+\)/.test(trimmed)) {
flushParagraph()
pushBlock(result, stack, 'image', stripMarkdownInline(trimmed) || '图片')
continue
}
if (/^\$\$.*\$\$$/.test(trimmed) || /^\$[^$].*\$$/.test(trimmed)) {
flushParagraph()
pushBlock(result, stack, 'math', truncateText(trimmed, 80))
continue
}
if (/^[-*+]\s+|^\d+[.)、]\s+/.test(trimmed)) {
flushParagraph()
pushBlock(result, stack, 'list', truncateText(getListText(trimmed)))
continue
}
paragraphBuffer.push(trimmed)
}
flushParagraph()
return result.join('\n') return result.join('\n')
} }
export function replaceFirstMindmapText(markdown: string, oldText: string, newText: string) { export function replaceFirstMindmapText(markdown: string, oldText: string, newText: string) {
const normalizedOld = oldText.replace(/^(摘要|要点|表格|代码|公式|图片)/, '').trim() const normalizedOld = oldText.replace(/^(引用|表格|代码块)(\s|:|)?/, '').trim()
if (!normalizedOld || !newText.trim()) return markdown if (!normalizedOld || !newText.trim()) return markdown
const escaped = normalizedOld.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const escaped = normalizedOld.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
@ -171,7 +160,7 @@ export function replaceFirstMindmapText(markdown: string, oldText: string, newTe
} }
const lines = markdown.split(/\r?\n/) const lines = markdown.split(/\r?\n/)
const targetLineIndex = lines.findIndex((line) => stripMarkdownInline(line).includes(normalizedOld)) const targetLineIndex = lines.findIndex((line) => normalizeNodeText(line).includes(normalizedOld))
if (targetLineIndex >= 0) { if (targetLineIndex >= 0) {
lines[targetLineIndex] = lines[targetLineIndex].replace(normalizedOld, newText.trim()) lines[targetLineIndex] = lines[targetLineIndex].replace(normalizedOld, newText.trim())
return lines.join('\n') return lines.join('\n')

View File

@ -28,6 +28,12 @@ request.interceptors.response.use(
(error) => { (error) => {
// 统一错误处理 // 统一错误处理
if (error.response) { if (error.response) {
const serverMessage = error.response.data?.error || error.response.data?.detail
if (serverMessage) {
error.message = serverMessage
return Promise.reject(error)
}
switch (error.response.status) { switch (error.response.status) {
case 400: case 400:
error.message = '请求参数错误' error.message = '请求参数错误'

View File

@ -20,7 +20,7 @@
<div class="upload-content" v-show="!isUploadAreaCollapsed"> <div class="upload-content" v-show="!isUploadAreaCollapsed">
<el-icon class="upload-icon"><Folder /></el-icon> <el-icon class="upload-icon"><Folder /></el-icon>
<div class="upload-text">文件导入</div> <div class="upload-text">文件导入</div>
<div class="upload-hint">支持 PDFWordPNG 格式文件可转换为 Markdown 和思维导图也可手动粘贴 Markdown 源码</div> <div class="upload-hint">支持 PDFWordPNG 格式单个文件可转换为 Markdown 和思维导图也可手动粘贴 Markdown 源码</div>
</div> </div>
<div class="uploaded-files"> <div class="uploaded-files">
@ -174,7 +174,6 @@
<input <input
ref="fileInput" ref="fileInput"
type="file" type="file"
multiple
accept=".pdf,.doc,.docx,.png" accept=".pdf,.doc,.docx,.png"
style="position: absolute; width: 0; height: 0; overflow: hidden;" style="position: absolute; width: 0; height: 0; overflow: hidden;"
@change="handleFileInputChange" @change="handleFileInputChange"
@ -219,7 +218,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { Delete, Document, Folder, Close, Loading, ArrowDown } from '@element-plus/icons-vue' import { Delete, Document, Folder, Close, Loading, ArrowDown } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import ConfigPanel from '@/components/ConfigPanel.vue' import ConfigPanel from '@/components/ConfigPanel.vue'
@ -230,6 +229,36 @@ import { markdownToAstString } from '@/utils/markdownAst'
import { buildMindmapMarkdown, replaceFirstMindmapText } from '@/utils/mindmapMarkdown' import { buildMindmapMarkdown, replaceFirstMindmapText } from '@/utils/mindmapMarkdown'
import { createEmptyReplacementRule, renderMarkdownTemplate, type ReplacementRule } from '@/utils/markdownTemplate' import { createEmptyReplacementRule, renderMarkdownTemplate, type ReplacementRule } from '@/utils/markdownTemplate'
const MARKDOWN_RENDER_MODE_KEY = 'mineru.documentProcessor.markdownRenderMode'
const SOURCE_VIEW_MODE_KEY = 'mineru.documentProcessor.sourceViewMode'
function loadStoredValue<T>(key: string, fallback: T, validate?: (value: unknown) => value is T): T {
try {
const raw = window.localStorage.getItem(key)
if (!raw) return fallback
const value = JSON.parse(raw)
return validate ? (validate(value) ? value : fallback) : value
} catch {
return fallback
}
}
function storeValue(key: string, value: unknown) {
try {
window.localStorage.setItem(key, JSON.stringify(value))
} catch {
// 使
}
}
function isMarkdownRenderMode(value: unknown): value is 'markdown' | 'html' | 'pdf' | 'richtext' {
return value === 'markdown' || value === 'html' || value === 'pdf' || value === 'richtext'
}
function isSourceViewMode(value: unknown): value is 'markdown' | 'ast' {
return value === 'markdown' || value === 'ast'
}
const { const {
uploadedFiles, uploadedFiles,
config, config,
@ -253,14 +282,21 @@ const fileInput = ref<HTMLInputElement | null>(null)
const isDragging = ref(false) const isDragging = ref(false)
const activeTab = ref('markdown') const activeTab = ref('markdown')
const isUploadAreaCollapsed = ref(false) const isUploadAreaCollapsed = ref(false)
const markdownRenderMode = ref<'markdown' | 'html' | 'pdf' | 'richtext'>('markdown') const markdownRenderMode = ref<'markdown' | 'html' | 'pdf' | 'richtext'>(
loadStoredValue(MARKDOWN_RENDER_MODE_KEY, 'markdown', isMarkdownRenderMode)
)
const markdownCompatibilityFlavor = ref<'commonmark' | 'gfm'>('gfm') const markdownCompatibilityFlavor = ref<'commonmark' | 'gfm'>('gfm')
const sourceViewMode = ref<'markdown' | 'ast'>('markdown') const sourceViewMode = ref<'markdown' | 'ast'>(
loadStoredValue(SOURCE_VIEW_MODE_KEY, 'markdown', isSourceViewMode)
)
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[]>([])
watch(markdownRenderMode, (value) => storeValue(MARKDOWN_RENDER_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 templateRenderResult = computed(() => renderMarkdownTemplate(manualMarkdownContent.value, replacementRules.value))
const renderedMarkdownContent = computed(() => templateRenderResult.value.markdown) const renderedMarkdownContent = computed(() => templateRenderResult.value.markdown)
@ -417,11 +453,6 @@ const triggerUpload = () => {
fileInput.value?.click() fileInput.value?.click()
} }
const resetReplacementConfig = () => {
replacementRules.value = []
draftReplacementRules.value = []
}
const handleFileInputChange = async (event: Event) => { const handleFileInputChange = async (event: Event) => {
const input = event.target as HTMLInputElement const input = event.target as HTMLInputElement
if (input.files) { if (input.files) {
@ -430,7 +461,6 @@ const handleFileInputChange = async (event: Event) => {
if (uploadedFiles.value.length > 0) { if (uploadedFiles.value.length > 0) {
isUploadAreaCollapsed.value = true isUploadAreaCollapsed.value = true
initializeManualResult() initializeManualResult()
resetReplacementConfig()
markdownRenderMode.value = 'markdown' markdownRenderMode.value = 'markdown'
sourceViewMode.value = 'markdown' sourceViewMode.value = 'markdown'
activeTab.value = 'source' activeTab.value = 'source'
@ -447,7 +477,6 @@ const handleDrop = async (event: DragEvent) => {
if (uploadedFiles.value.length > 0) { if (uploadedFiles.value.length > 0) {
isUploadAreaCollapsed.value = true isUploadAreaCollapsed.value = true
initializeManualResult() initializeManualResult()
resetReplacementConfig()
markdownRenderMode.value = 'markdown' markdownRenderMode.value = 'markdown'
sourceViewMode.value = 'markdown' sourceViewMode.value = 'markdown'
activeTab.value = 'source' activeTab.value = 'source'
@ -464,7 +493,6 @@ const removeFile = (index: number) => {
const clearAllFiles = () => { const clearAllFiles = () => {
clearAll() clearAll()
resetReplacementConfig()
isUploadAreaCollapsed.value = false isUploadAreaCollapsed.value = false
} }