feat(思维导图助手):思维导图助手过程2
parent
59e2554238
commit
8509f351a4
|
|
@ -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 核心应用 ---
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,9 +109,12 @@ 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 file = files[i]
|
|
||||||
const fileType = file.type
|
const fileType = file.type
|
||||||
const fileName = file.name.toLowerCase()
|
const fileName = file.name.toLowerCase()
|
||||||
|
|
||||||
|
|
@ -100,14 +130,14 @@ export function useDocumentProcessor() {
|
||||||
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
|
||||||
|
|
@ -123,7 +153,7 @@ export function useDocumentProcessor() {
|
||||||
} 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
|
||||||
}
|
}
|
||||||
|
|
@ -131,7 +161,6 @@ export function useDocumentProcessor() {
|
||||||
validFiles.push(file)
|
validFiles.push(file)
|
||||||
console.log('Adding file directly:', fileName)
|
console.log('Adding file directly:', fileName)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
uploadedFiles.value = validFiles
|
uploadedFiles.value = validFiles
|
||||||
console.log('Final uploaded files:', validFiles.map(f => f.name))
|
console.log('Final uploaded files:', validFiles.map(f => f.name))
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
paragraphBuffer = []
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const line of lines) {
|
if (token.type === 'heading_open') {
|
||||||
const trimmed = line.trim()
|
currentHeadingLevel = clampHeadingLevel(getHeadingLevel(token) + 1)
|
||||||
|
pushHeading(result, currentHeadingLevel, getNextInlineContent(tokens, i) || '未命名章节')
|
||||||
if (/^```/.test(trimmed)) {
|
i += 2
|
||||||
if (inCodeBlock) {
|
|
||||||
pushBlock(result, stack, 'code', codeBlockTitle || '代码块')
|
|
||||||
codeBlockTitle = ''
|
|
||||||
} else {
|
|
||||||
flushParagraph()
|
|
||||||
codeBlockTitle = trimmed.replace(/^```/, '').trim()
|
|
||||||
}
|
|
||||||
inCodeBlock = !inCodeBlock
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inCodeBlock) continue
|
if (token.type === 'bullet_list_open' || token.type === 'ordered_list_open') {
|
||||||
|
listDepth += 1
|
||||||
if (!trimmed) {
|
|
||||||
flushParagraph()
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isHeading(trimmed)) {
|
if (token.type === 'bullet_list_close' || token.type === 'ordered_list_close') {
|
||||||
flushParagraph()
|
listDepth = Math.max(0, listDepth - 1)
|
||||||
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/^\|.+\|$/.test(trimmed)) {
|
if (token.type === 'paragraph_open') {
|
||||||
flushParagraph()
|
const level = currentHeadingLevel + Math.max(listDepth, 1)
|
||||||
pushBlock(result, stack, 'table', '表格内容')
|
pushHeading(result, level, getNextInlineContent(tokens, i))
|
||||||
|
i += 2
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/^!\[[^\]]*]\([^)]+\)/.test(trimmed)) {
|
if (token.type === 'fence' || token.type === 'code_block') {
|
||||||
flushParagraph()
|
pushHeading(result, currentHeadingLevel + 1, getFenceContent(token))
|
||||||
pushBlock(result, stack, 'image', stripMarkdownInline(trimmed) || '图片')
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/^\$\$.*\$\$$/.test(trimmed) || /^\$[^$].*\$$/.test(trimmed)) {
|
if (token.type === 'html_block') {
|
||||||
flushParagraph()
|
pushHeading(result, currentHeadingLevel + 1, token.content)
|
||||||
pushBlock(result, stack, 'math', truncateText(trimmed, 80))
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/^[-*+]\s+|^\d+[.)、]\s+/.test(trimmed)) {
|
if (token.type === 'blockquote_open') {
|
||||||
flushParagraph()
|
pushHeading(result, currentHeadingLevel + 1, '引用')
|
||||||
pushBlock(result, stack, 'list', truncateText(getListText(trimmed)))
|
currentHeadingLevel = clampHeadingLevel(currentHeadingLevel + 1)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
paragraphBuffer.push(trimmed)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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')
|
||||||
|
|
|
||||||
|
|
@ -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 = '请求参数错误'
|
||||||
|
|
|
||||||
|
|
@ -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">支持 PDF、Word、PNG 格式文件,可转换为 Markdown 和思维导图,也可手动粘贴 Markdown 源码</div>
|
<div class="upload-hint">支持 PDF、Word、PNG 格式单个文件,可转换为 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue