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
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 核心应用 ---

View File

@ -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)
}
}

View File

@ -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,7 +51,23 @@ 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)
@ -82,55 +109,57 @@ 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()
console.log('Processing file:', fileName, 'type:', fileType)
// 验证文件类型 - 支持 PDF、图片和 Word 文档
const isImage = fileType.startsWith('image/')
const isPdf = fileType === 'application/pdf'
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)
if (!isImage && !isPdf && !isWord) {
error.value = '不支持的文件类型'
console.log('Unsupported file type:', fileName, fileType)
continue
}
// 验证文件大小 (100MB)
if (file.size > 100 * 1024 * 1024) {
error.value = '文件大小超出限制'
console.log('File too large:', fileName, file.size)
continue
}
// 如果是 Word 文档,转换为 PDF
if (isWord) {
try {
isUploading.value = true
error.value = '正在将 Word 文档转换为 PDF...'
console.log('Converting Word to PDF:', fileName)
const pdfFile = await convertWordToPdf(file)
console.log('Conversion successful, PDF file:', pdfFile.name, pdfFile.size)
validFiles.push(pdfFile)
error.value = null
} catch (err) {
error.value = 'Word 转换为 PDF 失败: ' + (err as Error).message
console.error('Conversion failed:', (err as Error).message)
continue
} finally {
isUploading.value = false
}
} else {
validFiles.push(file)
console.log('Adding file directly:', fileName)
const fileType = file.type
const fileName = file.name.toLowerCase()
console.log('Processing file:', fileName, 'type:', fileType)
// 验证文件类型 - 支持 PDF、图片和 Word 文档
const isImage = fileType.startsWith('image/')
const isPdf = fileType === 'application/pdf'
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)
if (!isImage && !isPdf && !isWord) {
error.value = '不支持的文件类型'
console.log('Unsupported file type:', fileName, fileType)
return
}
// 验证文件大小 (100MB)
if (file.size > 100 * 1024 * 1024) {
error.value = '文件大小超出限制'
console.log('File too large:', fileName, file.size)
return
}
// 如果是 Word 文档,转换为 PDF
if (isWord) {
try {
isUploading.value = true
error.value = '正在将 Word 文档转换为 PDF...'
console.log('Converting Word to PDF:', fileName)
const pdfFile = await convertWordToPdf(file)
console.log('Conversion successful, PDF file:', pdfFile.name, pdfFile.size)
validFiles.push(pdfFile)
error.value = null
} catch (err) {
error.value = 'Word 转换为 PDF 失败: ' + (err as Error).message
console.error('Conversion failed:', (err as Error).message)
return
} finally {
isUploading.value = false
}
} else {
validFiles.push(file)
console.log('Adding file directly:', fileName)
}
uploadedFiles.value = validFiles

View File

@ -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]
if (token.type === 'heading_open') {
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')
}
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')

View File

@ -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 = '请求参数错误'
@ -64,4 +70,4 @@ request.interceptors.response.use(
}
)
export default request
export default request

View File

@ -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">支持 PDFWordPNG 格式文件可转换为 Markdown 和思维导图也可手动粘贴 Markdown 源码</div>
<div class="upload-hint">支持 PDFWordPNG 格式单个文件可转换为 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
}