feat(思维导图助手):思维导图助手过程2
parent
59e2554238
commit
8509f351a4
|
|
@ -12,6 +12,9 @@ import click
|
|||
import zipfile
|
||||
from pathlib import Path
|
||||
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.middleware.gzip import GZipMiddleware
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
|
@ -25,7 +28,9 @@ from base64 import b64encode
|
|||
# MinerU 内部导入
|
||||
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.config_reader import get_device
|
||||
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__
|
||||
|
||||
# --- 日志配置 ---
|
||||
|
|
@ -89,6 +94,22 @@ def _update_task_progress(task_id: Optional[str], progress: int, stage: str):
|
|||
_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:
|
||||
def __init__(self):
|
||||
self.progress = 0
|
||||
|
|
@ -467,6 +488,7 @@ async def parse_pdf(
|
|||
completed_state = _get_task_progress(task_id) or {}
|
||||
completed_state["status"] = "completed"
|
||||
_store_task_progress(task_id, completed_state)
|
||||
_cleanup_runtime_memory()
|
||||
|
||||
# 清理日志捕获和 stderr 捕获
|
||||
stderr_capture.stop()
|
||||
|
|
@ -531,9 +553,10 @@ async def parse_pdf(
|
|||
pass
|
||||
failed_state = _get_task_progress(task_id) or {}
|
||||
failed_state["status"] = "failed"
|
||||
failed_state["error"] = str(e)
|
||||
failed_state["error"] = _format_parse_error(e)
|
||||
_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 核心应用 ---
|
||||
|
|
|
|||
|
|
@ -16,9 +16,12 @@
|
|||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="svg">SVG 格式</el-dropdown-item>
|
||||
<el-dropdown-item command="png">PNG 格式</el-dropdown-item>
|
||||
<el-dropdown-item command="jpeg">JPEG 格式</el-dropdown-item>
|
||||
<el-dropdown-item command="full-svg">全部导图 SVG</el-dropdown-item>
|
||||
<el-dropdown-item command="full-png">全部导图 PNG</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>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
|
|
@ -176,14 +179,30 @@ function downloadBlob(blob: Blob, fileName: string) {
|
|||
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
|
||||
if (!svg) return null
|
||||
|
||||
const svgCopy = svg.cloneNode(true) as SVGElement
|
||||
if (!svgCopy.getAttribute('xmlns')) {
|
||||
svgCopy.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
|
||||
}
|
||||
cleanExportSvg(svgCopy)
|
||||
|
||||
const contentGroup = svg.querySelector('g')
|
||||
const bbox = contentGroup
|
||||
|
|
@ -195,10 +214,30 @@ function createExportSvg() {
|
|||
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 viewportGroupCopy = svgCopy.querySelector('g')
|
||||
viewportGroupCopy?.removeAttribute('transform')
|
||||
svgCopy.setAttribute('viewBox', viewBox)
|
||||
svgCopy.setAttribute('width', String(width))
|
||||
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 {
|
||||
data: new XMLSerializer().serializeToString(svgCopy),
|
||||
|
|
@ -208,17 +247,18 @@ function createExportSvg() {
|
|||
}
|
||||
|
||||
// 下载思维导图
|
||||
const downloadMindMap = async (format: 'svg' | 'png' | 'jpeg' = 'svg') => {
|
||||
const exportSvg = createExportSvg()
|
||||
const downloadMindMap = async (format: ExportFormat = 'svg', scope: ExportScope = 'full') => {
|
||||
const exportSvg = scope === 'viewport' ? createViewportExportSvg() : createFullExportSvg()
|
||||
if (!exportSvg) return
|
||||
|
||||
const time = new Date().getTime()
|
||||
const scopeName = scope === 'viewport' ? 'viewport' : 'full'
|
||||
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
|
||||
}
|
||||
|
||||
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 canvas = document.createElement('canvas')
|
||||
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'
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) return
|
||||
downloadBlob(blob, `mindmap_${time}.${format === 'jpeg' ? 'jpg' : 'png'}`)
|
||||
downloadBlob(blob, `mindmap_${scopeName}_${time}.${format === 'jpeg' ? 'jpg' : 'png'}`)
|
||||
}, mimeType, 0.96)
|
||||
}
|
||||
img.onerror = () => ElMessage.error('导出图片失败')
|
||||
|
|
@ -245,8 +285,9 @@ const downloadMindMap = async (format: 'svg' | 'png' | 'jpeg' = 'svg') => {
|
|||
|
||||
// 处理下载命令
|
||||
const handleDownload = (command: string) => {
|
||||
if (command === 'svg' || command === 'png' || command === 'jpeg') {
|
||||
downloadMindMap(command)
|
||||
const [scope, format] = command.split('-') as [ExportScope, ExportFormat]
|
||||
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 { documentApi, type ParseParams } from '@/api/document'
|
||||
import { convertWordToPdf, isWordFile } from '@/utils/wordToPdf'
|
||||
|
|
@ -26,13 +26,24 @@ export interface ProcessResult {
|
|||
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() {
|
||||
// 文件相关
|
||||
const uploadedFiles: Ref<File[]> = ref([])
|
||||
const isUploading = ref(false)
|
||||
|
||||
// 配置相关
|
||||
const config = reactive<DocumentConfig>({
|
||||
const defaultConfig: DocumentConfig = {
|
||||
maxPages: 1000,
|
||||
backend: 'hybrid-auto-engine',
|
||||
serverUrl: 'http://localhost:30000',
|
||||
|
|
@ -40,8 +51,24 @@ export function useDocumentProcessor() {
|
|||
formulaEnable: true,
|
||||
language: 'ch',
|
||||
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 isProcessing = ref(false)
|
||||
|
|
@ -82,9 +109,12 @@ export function useDocumentProcessor() {
|
|||
if (!files || files.length === 0) return
|
||||
|
||||
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 fileName = file.name.toLowerCase()
|
||||
|
||||
|
|
@ -100,14 +130,14 @@ export function useDocumentProcessor() {
|
|||
if (!isImage && !isPdf && !isWord) {
|
||||
error.value = '不支持的文件类型'
|
||||
console.log('Unsupported file type:', fileName, fileType)
|
||||
continue
|
||||
return
|
||||
}
|
||||
|
||||
// 验证文件大小 (100MB)
|
||||
if (file.size > 100 * 1024 * 1024) {
|
||||
error.value = '文件大小超出限制'
|
||||
console.log('File too large:', fileName, file.size)
|
||||
continue
|
||||
return
|
||||
}
|
||||
|
||||
// 如果是 Word 文档,转换为 PDF
|
||||
|
|
@ -123,7 +153,7 @@ export function useDocumentProcessor() {
|
|||
} catch (err) {
|
||||
error.value = 'Word 转换为 PDF 失败: ' + (err as Error).message
|
||||
console.error('Conversion failed:', (err as Error).message)
|
||||
continue
|
||||
return
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
}
|
||||
|
|
@ -131,7 +161,6 @@ export function useDocumentProcessor() {
|
|||
validFiles.push(file)
|
||||
console.log('Adding file directly:', fileName)
|
||||
}
|
||||
}
|
||||
|
||||
uploadedFiles.value = validFiles
|
||||
console.log('Final uploaded files:', validFiles.map(f => f.name))
|
||||
|
|
|
|||
|
|
@ -1,167 +1,156 @@
|
|||
const MAX_NODE_TEXT_LENGTH = 72
|
||||
const MAX_PARAGRAPH_SUMMARY_LENGTH = 90
|
||||
const MAX_CHILDREN_PER_HEADING = 18
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import type Token from 'markdown-it/lib/token.mjs'
|
||||
|
||||
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 {
|
||||
level: number
|
||||
childCount: number
|
||||
function normalizeNodeText(text: string) {
|
||||
return text
|
||||
.replace(/\r?\n/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function normalizeWhitespace(text: string) {
|
||||
return text.replace(/\s+/g, ' ').trim()
|
||||
function clampHeadingLevel(level: number) {
|
||||
return Math.min(Math.max(level, 1), MAX_HEADING_LEVEL)
|
||||
}
|
||||
|
||||
function stripMarkdownInline(text: string) {
|
||||
return normalizeWhitespace(text)
|
||||
.replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1')
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
.replace(/[*_`~>#]/g, '')
|
||||
function pushHeading(result: string[], level: number, text: string) {
|
||||
const normalized = normalizeNodeText(text)
|
||||
if (!normalized) return
|
||||
result.push(`${'#'.repeat(clampHeadingLevel(level))} ${normalized}`)
|
||||
}
|
||||
|
||||
function truncateText(text: string, maxLength = MAX_NODE_TEXT_LENGTH) {
|
||||
const normalized = normalizeWhitespace(text)
|
||||
if (normalized.length <= maxLength) return normalized
|
||||
return `${normalized.slice(0, maxLength - 1)}...`
|
||||
function getHeadingLevel(token: Token) {
|
||||
const tagLevel = Number(token.tag.replace(/^h/i, ''))
|
||||
return Number.isFinite(tagLevel) && tagLevel > 0 ? tagLevel : 1
|
||||
}
|
||||
|
||||
function summarizeParagraph(text: string) {
|
||||
const cleaned = stripMarkdownInline(text)
|
||||
const sentence = cleaned.match(/^(.+?[。!?!?;;::]|\S.{20,}?[,,])/)
|
||||
return truncateText(sentence?.[1] || cleaned, MAX_PARAGRAPH_SUMMARY_LENGTH)
|
||||
function getNextInlineContent(tokens: Token[], index: number) {
|
||||
const next = tokens[index + 1]
|
||||
return next?.type === 'inline' ? next.content : ''
|
||||
}
|
||||
|
||||
function isHeading(line: string) {
|
||||
return /^#{1,6}\s+/.test(line.trim())
|
||||
function getFenceContent(token: Token) {
|
||||
const info = normalizeNodeText(token.info || '')
|
||||
const content = token.content.trim()
|
||||
if (!content) return info ? `代码块 ${info}` : '代码块'
|
||||
return info ? `代码块 ${info}: ${content}` : `代码块: ${content}`
|
||||
}
|
||||
|
||||
function getHeadingLevel(line: string) {
|
||||
return line.trim().match(/^#{1,6}/)?.[0].length || 0
|
||||
}
|
||||
function collectTableRows(tokens: Token[], startIndex: number) {
|
||||
const rows: string[][] = []
|
||||
let currentRow: string[] | null = null
|
||||
let endIndex = startIndex
|
||||
|
||||
function getHeadingText(line: string) {
|
||||
return stripMarkdownInline(line.trim().replace(/^#{1,6}\s+/, ''))
|
||||
}
|
||||
for (let i = startIndex + 1; i < tokens.length; i += 1) {
|
||||
const token = tokens[i]
|
||||
endIndex = i
|
||||
|
||||
function getListText(line: string) {
|
||||
return stripMarkdownInline(line.trim().replace(/^[-*+]\s+|^\d+[.)、]\s+/, ''))
|
||||
}
|
||||
|
||||
function countHeadingChildren(stack: HeadingState[], level: number) {
|
||||
while (stack.length && stack[stack.length - 1].level >= level) {
|
||||
stack.pop()
|
||||
if (token.type === 'table_close') break
|
||||
if (token.type === 'tr_open') {
|
||||
currentRow = []
|
||||
continue
|
||||
}
|
||||
if (token.type === 'tr_close') {
|
||||
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]
|
||||
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}`)
|
||||
return { rows, endIndex }
|
||||
}
|
||||
|
||||
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 stack: HeadingState[] = [{ level: 1, childCount: 0 }]
|
||||
let paragraphBuffer: string[] = []
|
||||
let inCodeBlock = false
|
||||
let codeBlockTitle = ''
|
||||
let currentHeadingLevel = 1
|
||||
let listDepth = 0
|
||||
|
||||
const flushParagraph = () => {
|
||||
if (paragraphBuffer.length === 0) return
|
||||
const text = summarizeParagraph(paragraphBuffer.join(' '))
|
||||
pushBlock(result, stack, 'paragraph', text)
|
||||
paragraphBuffer = []
|
||||
}
|
||||
for (let i = 0; i < tokens.length; i += 1) {
|
||||
const token = tokens[i]
|
||||
|
||||
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
|
||||
if (token.type === 'heading_open') {
|
||||
currentHeadingLevel = clampHeadingLevel(getHeadingLevel(token) + 1)
|
||||
pushHeading(result, currentHeadingLevel, getNextInlineContent(tokens, i) || '未命名章节')
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
|
||||
if (inCodeBlock) continue
|
||||
|
||||
if (!trimmed) {
|
||||
flushParagraph()
|
||||
if (token.type === 'bullet_list_open' || token.type === 'ordered_list_open') {
|
||||
listDepth += 1
|
||||
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 })
|
||||
if (token.type === 'bullet_list_close' || token.type === 'ordered_list_close') {
|
||||
listDepth = Math.max(0, listDepth - 1)
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^\|.+\|$/.test(trimmed)) {
|
||||
flushParagraph()
|
||||
pushBlock(result, stack, 'table', '表格内容')
|
||||
if (token.type === 'paragraph_open') {
|
||||
const level = currentHeadingLevel + Math.max(listDepth, 1)
|
||||
pushHeading(result, level, getNextInlineContent(tokens, i))
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^!\[[^\]]*]\([^)]+\)/.test(trimmed)) {
|
||||
flushParagraph()
|
||||
pushBlock(result, stack, 'image', stripMarkdownInline(trimmed) || '图片')
|
||||
if (token.type === 'fence' || token.type === 'code_block') {
|
||||
pushHeading(result, currentHeadingLevel + 1, getFenceContent(token))
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^\$\$.*\$\$$/.test(trimmed) || /^\$[^$].*\$$/.test(trimmed)) {
|
||||
flushParagraph()
|
||||
pushBlock(result, stack, 'math', truncateText(trimmed, 80))
|
||||
if (token.type === 'html_block') {
|
||||
pushHeading(result, currentHeadingLevel + 1, token.content)
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^[-*+]\s+|^\d+[.)、]\s+/.test(trimmed)) {
|
||||
flushParagraph()
|
||||
pushBlock(result, stack, 'list', truncateText(getListText(trimmed)))
|
||||
if (token.type === 'blockquote_open') {
|
||||
pushHeading(result, currentHeadingLevel + 1, '引用')
|
||||
currentHeadingLevel = clampHeadingLevel(currentHeadingLevel + 1)
|
||||
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')
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
const escaped = normalizedOld.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
|
@ -171,7 +160,7 @@ export function replaceFirstMindmapText(markdown: string, oldText: string, newTe
|
|||
}
|
||||
|
||||
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) {
|
||||
lines[targetLineIndex] = lines[targetLineIndex].replace(normalizedOld, newText.trim())
|
||||
return lines.join('\n')
|
||||
|
|
|
|||
|
|
@ -28,6 +28,12 @@ request.interceptors.response.use(
|
|||
(error) => {
|
||||
// 统一错误处理
|
||||
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) {
|
||||
case 400:
|
||||
error.message = '请求参数错误'
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
<div class="upload-content" v-show="!isUploadAreaCollapsed">
|
||||
<el-icon class="upload-icon"><Folder /></el-icon>
|
||||
<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 class="uploaded-files">
|
||||
|
|
@ -174,7 +174,6 @@
|
|||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,.doc,.docx,.png"
|
||||
style="position: absolute; width: 0; height: 0; overflow: hidden;"
|
||||
@change="handleFileInputChange"
|
||||
|
|
@ -219,7 +218,7 @@
|
|||
</template>
|
||||
|
||||
<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 { ElMessage } from 'element-plus'
|
||||
import ConfigPanel from '@/components/ConfigPanel.vue'
|
||||
|
|
@ -230,6 +229,36 @@ import { markdownToAstString } from '@/utils/markdownAst'
|
|||
import { buildMindmapMarkdown, replaceFirstMindmapText } from '@/utils/mindmapMarkdown'
|
||||
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 {
|
||||
uploadedFiles,
|
||||
config,
|
||||
|
|
@ -253,14 +282,21 @@ const fileInput = ref<HTMLInputElement | null>(null)
|
|||
const isDragging = ref(false)
|
||||
const activeTab = ref('markdown')
|
||||
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 sourceViewMode = ref<'markdown' | 'ast'>('markdown')
|
||||
const sourceViewMode = ref<'markdown' | 'ast'>(
|
||||
loadStoredValue(SOURCE_VIEW_MODE_KEY, 'markdown', isSourceViewMode)
|
||||
)
|
||||
const showReplacementDialog = ref(false)
|
||||
const isMobileDialog = ref(false)
|
||||
const replacementRules = 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 templateRenderResult = computed(() => renderMarkdownTemplate(manualMarkdownContent.value, replacementRules.value))
|
||||
const renderedMarkdownContent = computed(() => templateRenderResult.value.markdown)
|
||||
|
|
@ -417,11 +453,6 @@ const triggerUpload = () => {
|
|||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
const resetReplacementConfig = () => {
|
||||
replacementRules.value = []
|
||||
draftReplacementRules.value = []
|
||||
}
|
||||
|
||||
const handleFileInputChange = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
if (input.files) {
|
||||
|
|
@ -430,7 +461,6 @@ const handleFileInputChange = async (event: Event) => {
|
|||
if (uploadedFiles.value.length > 0) {
|
||||
isUploadAreaCollapsed.value = true
|
||||
initializeManualResult()
|
||||
resetReplacementConfig()
|
||||
markdownRenderMode.value = 'markdown'
|
||||
sourceViewMode.value = 'markdown'
|
||||
activeTab.value = 'source'
|
||||
|
|
@ -447,7 +477,6 @@ const handleDrop = async (event: DragEvent) => {
|
|||
if (uploadedFiles.value.length > 0) {
|
||||
isUploadAreaCollapsed.value = true
|
||||
initializeManualResult()
|
||||
resetReplacementConfig()
|
||||
markdownRenderMode.value = 'markdown'
|
||||
sourceViewMode.value = 'markdown'
|
||||
activeTab.value = 'source'
|
||||
|
|
@ -464,7 +493,6 @@ const removeFile = (index: number) => {
|
|||
|
||||
const clearAllFiles = () => {
|
||||
clearAll()
|
||||
resetReplacementConfig()
|
||||
isUploadAreaCollapsed.value = false
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue