Compare commits

...

9 Commits

10 changed files with 1622 additions and 430 deletions

View File

@ -5,12 +5,20 @@ import re
import tempfile import tempfile
import asyncio import asyncio
import threading import threading
import hashlib
import json
import uvicorn import uvicorn
import click import click
import zipfile import zipfile
import urllib.request
import urllib.error
from pathlib import Path from pathlib import Path
import glob import glob
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
from fastapi import Depends, FastAPI, HTTPException, UploadFile, File, Form, APIRouter, Header from fastapi import Depends, FastAPI, HTTPException, UploadFile, File, Form, APIRouter, Header
from pydantic import BaseModel
from fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.gzip import GZipMiddleware
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, FileResponse from fastapi.responses import JSONResponse, FileResponse
@ -23,7 +31,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__
# --- 日志配置 --- # --- 日志配置 ---
@ -36,13 +46,71 @@ _request_semaphore: Optional[asyncio.Semaphore] = None
# --- 任务进度跟踪 --- # --- 任务进度跟踪 ---
_task_progress: dict = {} _task_progress: dict = {}
_task_progress_dir = Path(
os.getenv("MINERU_TASK_PROGRESS_DIR", Path(tempfile.gettempdir()) / "mineru-task-progress")
)
def _task_progress_path(task_id: str) -> Path:
task_key = hashlib.sha256(task_id.encode("utf-8")).hexdigest()
return _task_progress_dir / f"{task_key}.json"
def _store_task_progress(task_id: str, state: dict) -> None:
"""Store progress in memory and on disk so API worker processes share it."""
state = dict(state)
state["task_id"] = task_id
_task_progress[task_id] = state
try:
_task_progress_dir.mkdir(parents=True, exist_ok=True)
progress_path = _task_progress_path(task_id)
temp_path = progress_path.with_name(
f"{progress_path.name}.{os.getpid()}.{threading.get_ident()}.tmp"
)
with open(temp_path, "w", encoding="utf-8") as fp:
json.dump(state, fp, ensure_ascii=False)
os.replace(temp_path, progress_path)
except Exception as exc:
logger.warning(f"Failed to persist task progress for {task_id}: {exc}")
def _get_task_progress(task_id: str) -> Optional[dict]:
try:
with open(_task_progress_path(task_id), "r", encoding="utf-8") as fp:
state = json.load(fp)
_task_progress[task_id] = state
return state
except (FileNotFoundError, json.JSONDecodeError, OSError):
return _task_progress.get(task_id)
def _update_task_progress(task_id: Optional[str], progress: int, stage: str): def _update_task_progress(task_id: Optional[str], progress: int, stage: str):
"""更新任务进度安全调用task_id 为 None 时静默跳过)""" """更新任务进度安全调用task_id 为 None 时静默跳过)"""
if task_id and task_id in _task_progress: if not task_id:
_task_progress[task_id]["progress"] = min(progress, 100) return
_task_progress[task_id]["stage"] = stage state = _get_task_progress(task_id)
if state is not None:
current = int(state.get("progress", 0) or 0)
state["progress"] = min(max(progress, current), 100)
state["stage"] = stage
_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:
@ -119,7 +187,11 @@ class _StderrProgressCapture:
# tqdm 进度条模式:名称: 百分比|...| 当前/总数 # tqdm 进度条模式:名称: 百分比|...| 当前/总数
_PATTERNS = [ _PATTERNS = [
(re.compile(r'Two Step Extraction:\s*(\d+)%.*?(\d+)/(\d+)'), 'extract'), (re.compile(r'Layout Preparation:\s*(\d+)%.*?(\d+)/(\d+)'), 'layout_prepare'),
(re.compile(r'Layout Output Parsing:\s*(\d+)%.*?(\d+)/(\d+)'), 'layout_parse'),
(re.compile(r'Extract Preparation:\s*(\d+)%.*?(\d+)/(\d+)'), 'extract_prepare'),
(re.compile(r'Post Processing:\s*(\d+)%.*?(\d+)/(\d+)'), 'post_process'),
(re.compile(r'Two Step Extraction:\s*(\d+)%.*?(\d+)/(\d+)'), 'vlm_predict'),
(re.compile(r'MFD Predict:\s*(\d+)%.*?(\d+)/(\d+)'), 'mfd'), (re.compile(r'MFD Predict:\s*(\d+)%.*?(\d+)/(\d+)'), 'mfd'),
(re.compile(r'MFR Predict:\s*(\d+)%.*?(\d+)/(\d+)'), 'mfr'), (re.compile(r'MFR Predict:\s*(\d+)%.*?(\d+)/(\d+)'), 'mfr'),
(re.compile(r'OCR-det:\s*(\d+)%.*?(\d+)/(\d+)'), 'ocr_det'), (re.compile(r'OCR-det:\s*(\d+)%.*?(\d+)/(\d+)'), 'ocr_det'),
@ -127,22 +199,35 @@ class _StderrProgressCapture:
(re.compile(r'Loading safetensors.*?:\s*(\d+)%'), 'load_model'), (re.compile(r'Loading safetensors.*?:\s*(\d+)%'), 'load_model'),
(re.compile(r'Capturing CUDA graphs.*?:\s*(\d+)%'), 'cuda_graph'), (re.compile(r'Capturing CUDA graphs.*?:\s*(\d+)%'), 'cuda_graph'),
] ]
_GENERIC_PREDICT_PATTERN = re.compile(r'^Predict:\s*(\d+)%.*?(\d+)/(\d+)')
# 各阶段的进度映射范围 [start%, end%] # 各阶段的进度映射范围 [start%, end%]
_RANGES = { _RANGES = {
'load_model': (12, 18), 'load_model': (12, 18),
'cuda_graph': (33, 37), 'cuda_graph': (33, 37),
'extract': (42, 65), 'layout_prepare': (42, 45),
'mfd': (75, 80), 'layout_predict': (45, 68),
'mfr': (80, 87), 'layout_parse': (68, 70),
'ocr_det': (87, 92), 'extract_prepare': (70, 72),
'ocr_rec': (92, 96), 'extract_predict': (72, 88),
'vlm_predict': (45, 88),
'post_process': (88, 90),
'mfd': (90, 92),
'mfr': (92, 94),
'ocr_det': (94, 96),
'ocr_rec': (96, 97),
} }
_STAGE_LABELS = { _STAGE_LABELS = {
'load_model': '加载模型权重', 'load_model': '加载模型权重',
'cuda_graph': '捕获CUDA计算图', 'cuda_graph': '捕获CUDA计算图',
'extract': 'VLM文档分析', 'layout_prepare': '准备版面分析',
'layout_predict': '版面分析',
'layout_parse': '解析版面结果',
'extract_prepare': '准备内容抽取',
'extract_predict': '内容抽取',
'vlm_predict': 'VLM文档分析',
'post_process': '后处理',
'mfd': '数学公式检测', 'mfd': '数学公式检测',
'mfr': '数学公式识别', 'mfr': '数学公式识别',
'ocr_det': '文字区域检测', 'ocr_det': '文字区域检测',
@ -152,55 +237,83 @@ class _StderrProgressCapture:
def __init__(self, task_id: str): def __init__(self, task_id: str):
self.task_id = task_id self.task_id = task_id
self._active = False self._active = False
self._thread: Optional[threading.Thread] = None
self._orig_stderr = None self._orig_stderr = None
self._buf = ""
self._last_anchor = ""
def start(self): def start(self):
self._active = True self._active = True
self._orig_stderr = sys.stderr self._orig_stderr = sys.stderr
self._thread = threading.Thread(target=self._reader_loop, daemon=True) sys.stderr = self
self._thread.start()
def stop(self): def stop(self):
self._active = False self._active = False
if self._thread and self._thread.is_alive(): if self._buf.strip():
self._thread.join(timeout=2) self._parse_line(self._buf.strip())
self._thread = None self._buf = ""
if self._orig_stderr is not None and sys.stderr is self:
sys.stderr = self._orig_stderr
def _reader_loop(self): def write(self, text):
buf = "" if self._orig_stderr is not None:
orig = self._orig_stderr self._orig_stderr.write(text)
while self._active: if not self._active:
try: return len(text)
ch = orig.read(1) for ch in text:
if not ch: self._buf += ch
break # tqdm 用 \r 更新同一行,\n 表示新行
buf += ch if ch == '\r' or ch == '\n':
# tqdm 用 \r 更新同一行,\n 表示新行 if self._buf.strip():
if ch == '\r' or ch == '\n': self._parse_line(self._buf.strip())
if buf.strip(): self._buf = ""
self._parse_line(buf.strip()) return len(text)
buf = ""
except Exception: def flush(self):
break if self._orig_stderr is not None:
self._orig_stderr.flush()
def isatty(self):
return bool(self._orig_stderr and self._orig_stderr.isatty())
def fileno(self):
if self._orig_stderr is not None:
return self._orig_stderr.fileno()
raise OSError("stderr is not available")
def __getattr__(self, name):
if self._orig_stderr is not None:
return getattr(self._orig_stderr, name)
raise AttributeError(name)
def _parse_line(self, line: str): def _parse_line(self, line: str):
if "Layout Preparation:" in line:
self._last_anchor = "layout"
elif "Extract Preparation:" in line:
self._last_anchor = "extract"
generic_predict = self._GENERIC_PREDICT_PATTERN.search(line)
if generic_predict:
stage = "extract_predict" if self._last_anchor == "extract" else "layout_predict"
self._update_from_match(generic_predict, stage)
return
for pattern, stage in self._PATTERNS: for pattern, stage in self._PATTERNS:
m = pattern.search(line) m = pattern.search(line)
if m: if m:
pct = int(m.group(1)) self._update_from_match(m, stage)
lo, hi = self._RANGES.get(stage, (0, 100))
mapped = lo + int((hi - lo) * pct / 100)
label = self._STAGE_LABELS.get(stage, stage)
if stage == 'extract' and len(m.groups()) >= 3:
cur, total = m.group(2), m.group(3)
label = f"VLM文档分析 ({cur}/{total}页)"
elif stage in ('mfd', 'mfr', 'ocr_det', 'ocr_rec') and len(m.groups()) >= 3:
cur, total = m.group(2), m.group(3)
label = f"{label} ({cur}/{total})"
_update_task_progress(self.task_id, mapped, label)
break break
def _update_from_match(self, match, stage: str):
pct = int(match.group(1))
lo, hi = self._RANGES.get(stage, (0, 100))
mapped = lo + int((hi - lo) * pct / 100)
label = self._STAGE_LABELS.get(stage, stage)
if len(match.groups()) >= 3:
cur, total = match.group(2), match.group(3)
unit = "" if stage in ("layout_predict", "vlm_predict") else ""
label = f"{label} ({cur}/{total}{unit})"
_update_task_progress(self.task_id, mapped, label)
async def limit_concurrency(): async def limit_concurrency():
if _request_semaphore is not None: if _request_semaphore is not None:
@ -248,12 +361,383 @@ def get_infer_result(file_suffix_identifier: str, pdf_name: str, parse_dir: str)
api_router = APIRouter(prefix="/api") api_router = APIRouter(prefix="/api")
class MindmapOrganizeRequest(BaseModel):
markdown: str
mode: str = "smart"
prompt: Optional[str] = None
def _extract_json_object(text: Optional[str]) -> str:
content = (text or "").strip()
if content.startswith("```"):
content = re.sub(r"^```(?:json|markdown|md)?\s*", "", content, flags=re.IGNORECASE)
content = re.sub(r"\s*```$", "", content)
return content.strip()
def _extract_llm_message_content(message: dict) -> str:
content = message.get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for item in content:
if isinstance(item, str):
parts.append(item)
elif isinstance(item, dict):
text = item.get("text") or item.get("content")
if isinstance(text, str):
parts.append(text)
if parts:
return "\n".join(parts)
for key in ("reasoning_content", "reasoning", "output_text", "text"):
value = message.get(key)
if isinstance(value, str) and value.strip():
return value
return ""
DEFAULT_MINDMAP_ORGANIZE_PROMPT = """你是文档结构整理助手。请基于用户提供的 Markdown 生成适合思维导图展示的 Markdown。
要求
1. 保留原文标题结构不要重写或打乱主要标题层级
2. 将标题下的段落内容总结为要点合并相近段落避免逐段照抄
3. 保留原文语言英文内容输出英文中文内容输出中文多语言内容按原文语言分别保留
4. 不要编造原文没有的信息
5. 保留关键数字公式专有名词步骤和结论
6. 最大层级不超过 4
7. 每个父节点下最多 8 个子节点
8. 节点标题尽量简短正文说明使用短句列表
9. 只输出 Markdown不要输出解释代码块围栏或额外说明"""
MINDMAP_MERGE_PROMPT = """你是 Markdown 思维导图结构校对助手。下面是多个分块已经整理总结后的局部 Markdown 大纲。
任务
1. 合并这些局部大纲为一份完整 Markdown
2. 只检查和调整标题层级结构顺序重复标题和父子关系
3. 不要重新总结正文不要扩写内容不要新增原文没有的信息
4. 保留各局部大纲中的源语言英文保持英文中文保持中文多语言分别保留
5. 最大层级不超过 4
6. 每个父节点下最多 8 个子节点必要时只合并相近标题
7. 只输出 Markdown不要输出解释代码块围栏或额外说明"""
def _estimate_tokens(text: str) -> int:
ascii_chars = 0
non_ascii_chars = 0
for ch in text:
if ch.isspace():
continue
if ord(ch) < 128:
ascii_chars += 1
else:
non_ascii_chars += 1
return max(1, int(ascii_chars / 4) + int(non_ascii_chars * 1.5))
def _get_mindmap_max_output_tokens() -> int:
return int(os.getenv("MINDMAP_LLM_MAX_OUTPUT_TOKENS", "4096"))
def _get_mindmap_context_budget(prompt: str, reserve_output_tokens: Optional[int] = None) -> tuple[int, int]:
if reserve_output_tokens is None:
reserve_output_tokens = _get_mindmap_max_output_tokens()
max_context_tokens = int(os.getenv("MINDMAP_LLM_MAX_CONTEXT_TOKENS", "32768"))
prompt_tokens = _estimate_tokens(prompt)
safety_tokens = int(os.getenv("MINDMAP_LLM_SAFETY_TOKENS", "1024"))
input_budget_tokens = max(2048, max_context_tokens - prompt_tokens - reserve_output_tokens - safety_tokens)
logger.info(
"Mindmap context budget max_context_tokens={} prompt_tokens={} reserve_output_tokens={} safety_tokens={} input_budget_tokens={}",
max_context_tokens, prompt_tokens, reserve_output_tokens, safety_tokens, input_budget_tokens
)
return max_context_tokens, input_budget_tokens
def _split_markdown_blocks(markdown: str) -> list[str]:
lines = markdown.splitlines()
blocks: list[str] = []
current: list[str] = []
heading_pattern = re.compile(r"^#{1,6}\s+")
for line in lines:
if heading_pattern.match(line) and current:
blocks.append("\n".join(current).strip())
current = [line]
else:
current.append(line)
if current:
blocks.append("\n".join(current).strip())
return [block for block in blocks if block]
def _split_large_block(block: str, max_tokens: int) -> list[str]:
paragraphs = re.split(r"\n{2,}", block)
chunks: list[str] = []
current: list[str] = []
current_tokens = 0
for paragraph in paragraphs:
paragraph = paragraph.strip()
if not paragraph:
continue
paragraph_tokens = _estimate_tokens(paragraph)
if current and current_tokens + paragraph_tokens > max_tokens:
chunks.append("\n\n".join(current))
current = [paragraph]
current_tokens = paragraph_tokens
else:
current.append(paragraph)
current_tokens += paragraph_tokens
if current:
chunks.append("\n\n".join(current))
return chunks or [block]
def _chunk_markdown_by_headings(markdown: str, max_tokens: int) -> list[str]:
blocks = _split_markdown_blocks(markdown)
chunks: list[str] = []
current: list[str] = []
current_tokens = 0
for block in blocks:
block_tokens = _estimate_tokens(block)
if block_tokens > max_tokens:
if current:
chunks.append("\n\n".join(current))
current = []
current_tokens = 0
chunks.extend(_split_large_block(block, max_tokens))
continue
if current and current_tokens + block_tokens > max_tokens:
chunks.append("\n\n".join(current))
current = [block]
current_tokens = block_tokens
else:
current.append(block)
current_tokens += block_tokens
if current:
chunks.append("\n\n".join(current))
return chunks or [markdown]
def _call_mindmap_llm(markdown: str, mode: str = "smart", custom_prompt: Optional[str] = None, task_id: Optional[str] = None, request_role: str = "organize") -> str:
base_url = os.getenv("MINDMAP_LLM_BASE_URL", "").rstrip("/")
model = os.getenv("MINDMAP_LLM_MODEL", "gemma-4-26B")
api_key = os.getenv("MINDMAP_LLM_API_KEY", "")
timeout = int(os.getenv("MINDMAP_LLM_TIMEOUT", "180"))
max_output_tokens = _get_mindmap_max_output_tokens()
if not base_url:
raise RuntimeError("未配置智能整理模型服务,请设置 MINDMAP_LLM_BASE_URL")
compact_markdown = markdown.strip()
prompt_template = (custom_prompt or "").strip() or DEFAULT_MINDMAP_ORGANIZE_PROMPT
prompt = f"""{prompt_template}
原始 Markdown
{compact_markdown}
"""
logger.info(
"Mindmap LLM request start task_id={} role={} model={} base_url={} mode={} input_chars={} input_tokens_est={} prompt_chars={} max_tokens={}",
task_id or "-", request_role, model, base_url, mode, len(compact_markdown), _estimate_tokens(compact_markdown), len(prompt_template), max_output_tokens
)
payload = {
"model": model,
"messages": [
{"role": "system", "content": "你擅长把长文档整理成结构清晰、层次合理的思维导图 Markdown并严格保留原文语言。"},
{"role": "user", "content": prompt},
],
"temperature": float(os.getenv("MINDMAP_LLM_TEMPERATURE", "0.2")),
"max_tokens": max_output_tokens,
}
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
url = f"{base_url}/chat/completions"
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
result = json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="ignore")
raise RuntimeError(f"智能整理模型请求失败: HTTP {exc.code} {detail}") from exc
except Exception as exc:
raise RuntimeError(f"智能整理模型请求失败: {exc}") from exc
choices = result.get("choices") or []
choice = choices[0] if choices else {}
message = choice.get("message") or {}
finish_reason = choice.get("finish_reason")
usage = result.get("usage")
content = _extract_llm_message_content(message)
if not content:
logger.warning(
"Mindmap LLM returned empty content task_id={} role={} finish_reason={} usage={} message_keys={}",
task_id or "-", request_role, finish_reason, usage, list(message.keys())
)
raise RuntimeError(f"智能整理模型返回空内容finish_reason={finish_reason}")
organized = _extract_json_object(content)
if not organized:
logger.warning(
"Mindmap LLM returned invalid organized content task_id={} role={} finish_reason={} usage={}",
task_id or "-", request_role, finish_reason, usage
)
raise RuntimeError(f"智能整理模型未返回有效内容finish_reason={finish_reason}")
logger.info(
"Mindmap LLM request completed task_id={} role={} finish_reason={} usage={} output_chars={} output_tokens_est={}",
task_id or "-", request_role, finish_reason, usage, len(organized), _estimate_tokens(organized)
)
return organized
def _organize_mindmap_markdown(markdown: str, mode: str, custom_prompt: Optional[str], task_id: str) -> str:
prompt_template = (custom_prompt or "").strip() or DEFAULT_MINDMAP_ORGANIZE_PROMPT
_, input_budget_tokens = _get_mindmap_context_budget(prompt_template)
source_tokens = _estimate_tokens(markdown)
logger.info(
"Mindmap organize strategy task_id={} source_chars={} source_tokens_est={} input_budget_tokens={}",
task_id, len(markdown), source_tokens, input_budget_tokens
)
if source_tokens <= input_budget_tokens:
_update_task_progress(task_id, 35, "调用智能整理模型")
return _call_mindmap_llm(markdown, mode, prompt_template, task_id, "single")
chunks = _chunk_markdown_by_headings(markdown, input_budget_tokens)
logger.info("Mindmap large input split task_id={} chunks={}", task_id, len(chunks))
partial_results: list[str] = []
for index, chunk in enumerate(chunks, start=1):
progress = 20 + int(index / max(len(chunks), 1) * 55)
_update_task_progress(task_id, progress, f"智能整理分块 {index}/{len(chunks)}")
logger.info(
"Mindmap chunk organize task_id={} chunk={}/{} chars={} tokens_est={}",
task_id, index, len(chunks), len(chunk), _estimate_tokens(chunk)
)
partial = _call_mindmap_llm(chunk, mode, prompt_template, task_id, f"chunk-{index}")
partial_results.append(partial)
merged_input = "\n\n".join(
f"<!-- chunk {index} -->\n{partial}"
for index, partial in enumerate(partial_results, start=1)
)
_, merge_budget_tokens = _get_mindmap_context_budget(MINDMAP_MERGE_PROMPT)
merge_tokens = _estimate_tokens(merged_input)
if merge_tokens > merge_budget_tokens:
logger.warning(
"Mindmap merged outline still exceeds context task_id={} tokens_est={} budget={} chunks={}",
task_id, merge_tokens, merge_budget_tokens, len(partial_results)
)
merge_chunks = _chunk_markdown_by_headings(merged_input, merge_budget_tokens)
merged_round: list[str] = []
for index, chunk in enumerate(merge_chunks, start=1):
_update_task_progress(task_id, 78 + int(index / max(len(merge_chunks), 1) * 10), f"合并局部大纲 {index}/{len(merge_chunks)}")
merged_round.append(_call_mindmap_llm(chunk, mode, MINDMAP_MERGE_PROMPT, task_id, f"merge-round-{index}"))
merged_input = "\n\n".join(merged_round)
_update_task_progress(task_id, 90, "全局整理标题结构")
return _call_mindmap_llm(merged_input, mode, MINDMAP_MERGE_PROMPT, task_id, "merge")
async def _run_mindmap_organize_task(task_id: str, markdown: str, mode: str, prompt: Optional[str]):
try:
_store_task_progress(task_id, {
"progress": 10,
"stage": "准备智能整理",
"status": "processing",
"error": None,
"file_names": "",
"result_md": None,
})
organized = await asyncio.to_thread(_organize_mindmap_markdown, markdown, mode, prompt, task_id)
state = _get_task_progress(task_id) or {}
state.update({
"progress": 100,
"stage": "智能整理完成",
"status": "completed",
"error": None,
"result_md": organized,
})
_store_task_progress(task_id, state)
except Exception as exc:
logger.exception(f"Mindmap organize task failed task_id={task_id}: {exc}")
state = _get_task_progress(task_id) or {}
state.update({
"progress": 100,
"stage": "智能整理失败",
"status": "failed",
"error": str(exc),
"result_md": None,
})
_store_task_progress(task_id, state)
@api_router.post("/parse_tasks/{task_id}", status_code=201)
async def create_parse_task(task_id: str):
"""Register a task before the multipart upload starts."""
state = {
"progress": 0,
"stage": "等待上传",
"status": "pending",
"error": None,
"file_names": "",
}
_store_task_progress(task_id, state)
logger.info(f"Registered parse task pid={os.getpid()} task_id={task_id}")
return state
@api_router.get("/parse_progress/{task_id}") @api_router.get("/parse_progress/{task_id}")
async def get_parse_progress(task_id: str): async def get_parse_progress(task_id: str):
"""查询解析任务的实时进度""" """查询解析任务的实时进度"""
if task_id not in _task_progress: state = _get_task_progress(task_id)
if state is None:
logger.warning(f"Parse task not found pid={os.getpid()} task_id={task_id}")
raise HTTPException(status_code=404, detail="Task not found") raise HTTPException(status_code=404, detail="Task not found")
return _task_progress[task_id] return state
@api_router.post("/mindmap_tasks/{task_id}", status_code=201)
async def create_mindmap_task(task_id: str, request: MindmapOrganizeRequest):
"""Create an async task that organizes Markdown into summarized mindmap Markdown."""
markdown = request.markdown.strip()
if not markdown:
raise HTTPException(status_code=400, detail="Markdown content is required")
state = {
"progress": 0,
"stage": "等待智能整理",
"status": "pending",
"error": None,
"file_names": "",
"result_md": None,
}
_store_task_progress(task_id, state)
asyncio.create_task(_run_mindmap_organize_task(task_id, markdown, request.mode, request.prompt))
logger.info(
"Registered mindmap organize task pid={} task_id={} mode={} input_chars={} custom_prompt={}",
os.getpid(), task_id, request.mode, len(markdown), bool((request.prompt or "").strip())
)
return state
@api_router.get("/mindmap_progress/{task_id}")
async def get_mindmap_progress(task_id: str):
"""Query async mindmap organization progress and result."""
state = _get_task_progress(task_id)
if state is None:
logger.warning(f"Mindmap task not found pid={os.getpid()} task_id={task_id}")
raise HTTPException(status_code=404, detail="Task not found")
return state
@api_router.post(path="/file_parse", dependencies=[Depends(limit_concurrency)]) @api_router.post(path="/file_parse", dependencies=[Depends(limit_concurrency)])
@ -274,6 +758,7 @@ async def parse_pdf(
response_format_zip: bool = Form(False), response_format_zip: bool = Form(False),
start_page_id: int = Form(0), start_page_id: int = Form(0),
end_page_id: int = Form(99999), end_page_id: int = Form(99999),
form_task_id: Optional[str] = Form(None, alias="task_id"),
x_task_id: Optional[str] = Header(None), x_task_id: Optional[str] = Header(None),
): ):
# 从 app 实例状态中获取配置 (FastAPI 实例会在下方创建) # 从 app 实例状态中获取配置 (FastAPI 实例会在下方创建)
@ -281,12 +766,16 @@ async def parse_pdf(
config = getattr(app.state, "config", {}) config = getattr(app.state, "config", {})
# 初始化进度跟踪 # 初始化进度跟踪
task_id = x_task_id or str(uuid.uuid4()) task_id = x_task_id or form_task_id or str(uuid.uuid4())
file_names_str = ", ".join(f.filename or "unknown" for f in files) file_names_str = ", ".join(f.filename or "unknown" for f in files)
_task_progress[task_id] = { _store_task_progress(task_id, {
"progress": 0, "stage": "准备中", "status": "processing", "progress": 0, "stage": "准备中", "status": "processing",
"error": None, "file_names": file_names_str, "error": None, "file_names": file_names_str,
} })
logger.info(
f"Started parse task pid={os.getpid()} task_id={task_id} "
f"header_task_id={x_task_id} form_task_id={form_task_id}"
)
try: try:
unique_dir = os.path.join(output_dir, str(uuid.uuid4())) unique_dir = os.path.join(output_dir, str(uuid.uuid4()))
@ -353,7 +842,10 @@ async def parse_pdf(
_update_task_progress(task_id, 97, "生成结果文件") _update_task_progress(task_id, 97, "生成结果文件")
_update_task_progress(task_id, 100, "转换完成") _update_task_progress(task_id, 100, "转换完成")
_task_progress[task_id]["status"] = "completed" completed_state = _get_task_progress(task_id) or {}
completed_state["status"] = "completed"
_store_task_progress(task_id, completed_state)
_cleanup_runtime_memory()
# 清理日志捕获和 stderr 捕获 # 清理日志捕获和 stderr 捕获
stderr_capture.stop() stderr_capture.stop()
@ -416,9 +908,12 @@ async def parse_pdf(
_current_task_id = None _current_task_id = None
except Exception: except Exception:
pass pass
_task_progress[task_id]["status"] = "failed" failed_state = _get_task_progress(task_id) or {}
_task_progress[task_id]["error"] = str(e) failed_state["status"] = "failed"
return JSONResponse(status_code=500, content={"error": f"Internal Error: {str(e)}"}) failed_state["error"] = _format_parse_error(e)
_store_task_progress(task_id, failed_state)
_cleanup_runtime_memory()
return JSONResponse(status_code=500, content={"error": _format_parse_error(e)})
# --- FastAPI 核心应用 --- # --- FastAPI 核心应用 ---
@ -481,4 +976,4 @@ def main(ctx, host, port, reload, **kwargs):
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@ -32,11 +32,15 @@ export interface ParseResult {
export interface ParseProgress { export interface ParseProgress {
progress: number progress: number
stage: string stage: string
status: 'processing' | 'completed' | 'failed' status: 'pending' | 'processing' | 'completed' | 'failed'
error: string | null error: string | null
file_names: string file_names: string
} }
export interface MindmapOrganizeProgress extends ParseProgress {
result_md?: string | null
}
export const documentApi = { export const documentApi = {
/** /**
* *
@ -65,6 +69,9 @@ export const documentApi = {
if (params.server_url) { if (params.server_url) {
formData.append('server_url', params.server_url) formData.append('server_url', params.server_url)
} }
if (taskId) {
formData.append('task_id', taskId)
}
return request.post('/api/file_parse', formData, { return request.post('/api/file_parse', formData, {
headers: { headers: {
@ -80,6 +87,12 @@ export const documentApi = {
}) })
}, },
createParseTask(taskId: string): Promise<ParseProgress> {
return request.post(`/api/parse_tasks/${encodeURIComponent(taskId)}`).then(result => {
return result as unknown as ParseProgress
})
},
/** /**
* *
*/ */
@ -87,5 +100,21 @@ export const documentApi = {
return request.get(`/api/parse_progress/${taskId}`).then(result => { return request.get(`/api/parse_progress/${taskId}`).then(result => {
return result as unknown as ParseProgress return result as unknown as ParseProgress
}) })
},
createMindmapTask(taskId: string, markdown: string, mode = 'smart', prompt?: string): Promise<MindmapOrganizeProgress> {
return request.post(`/api/mindmap_tasks/${encodeURIComponent(taskId)}`, {
markdown,
mode,
prompt
}).then(result => {
return result as unknown as MindmapOrganizeProgress
})
},
getMindmapProgress(taskId: string): Promise<MindmapOrganizeProgress> {
return request.get(`/api/mindmap_progress/${encodeURIComponent(taskId)}`).then(result => {
return result as unknown as MindmapOrganizeProgress
})
} }
} }

View File

@ -13,7 +13,7 @@
class="slider" class="slider"
/> />
</el-form-item> </el-form-item>
<!-- 解析后端 --> <!-- 解析后端 -->
<el-form-item :label="$t('config.backend')" class="form-item"> <el-form-item :label="$t('config.backend')" class="form-item">
<el-select <el-select
@ -50,8 +50,6 @@
</div> </div>
</el-form-item> </el-form-item>
<div class="divider"></div>
<!-- 识别选项 --> <!-- 识别选项 -->
<div class="section-title">{{ $t('config.recognitionOptions') }}</div> <div class="section-title">{{ $t('config.recognitionOptions') }}</div>
@ -104,6 +102,46 @@
</div> </div>
</el-form-item> </el-form-item>
</template> </template>
<div class="divider"></div>
<div class="section-title">导出与智能整理</div>
<el-form-item label="导出分辨率" class="form-item">
<el-select v-model="exportResolutionPreset" style="width: 100%" class="select">
<el-option
v-for="option in exportResolutionOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
<el-input-number
v-if="exportResolutionPreset === 'custom'"
v-model="draftConfig.exportResolution"
:min="720"
:max="16384"
:step="100"
controls-position="right"
class="export-resolution-input"
/>
<div class="form-item-description">
PNG/JPEG 导出长边上限默认 8K导出文件会按该最大长边生效
</div>
</el-form-item>
<el-form-item label="智能整理提示词" class="form-item">
<el-input
v-model="draftConfig.mindmapPrompt"
type="textarea"
:rows="10"
resize="vertical"
class="input"
/>
<div class="form-item-description">
切换到智能整理时发送给后端大模型默认保留标题结构总结段落要点并保持原文语言
</div>
</el-form-item>
<div class="config-actions"> <div class="config-actions">
<el-button @click="resetDraft"></el-button> <el-button @click="resetDraft"></el-button>
<el-button type="primary" @click="confirmConfig"></el-button> <el-button type="primary" @click="confirmConfig"></el-button>
@ -113,9 +151,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, watch } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import type { DocumentConfig } from '@/composables/useDocumentProcessor' import { DEFAULT_DOCUMENT_CONFIG, type DocumentConfig } from '@/composables/useDocumentProcessor'
interface Props { interface Props {
modelValue: DocumentConfig modelValue: DocumentConfig
@ -126,16 +164,45 @@ interface Props {
interface Emits { interface Emits {
(e: 'update:modelValue', value: DocumentConfig): void (e: 'update:modelValue', value: DocumentConfig): void
(e: 'backendChange', backend: string): void (e: 'backendChange', backend: string): void
(e: 'close'): void
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
const exportResolutionOptions = [
{ label: '1080P', value: 1080 },
{ label: '2K', value: 2560 },
{ label: '4K', value: 3840 },
{ label: '8K', value: 7680 },
{ label: '自定义', value: 'custom' }
] as const
const createDraft = (value: DocumentConfig): DocumentConfig => ({ ...value }) const createDraft = (value: DocumentConfig): DocumentConfig => ({ ...value })
const draftConfig = reactive<DocumentConfig>(createDraft(props.modelValue)) const draftConfig = reactive<DocumentConfig>(createDraft(props.modelValue))
const getExportResolutionPreset = (resolution: number): number | 'custom' => {
const isPreset = exportResolutionOptions.some((option) => {
return typeof option.value === 'number' && option.value === resolution
})
return isPreset ? resolution : 'custom'
}
const exportResolutionPreset = ref<number | 'custom'>(getExportResolutionPreset(props.modelValue.exportResolution))
watch(exportResolutionPreset, (value) => {
if (value === 'custom') {
if (getExportResolutionPreset(draftConfig.exportResolution) !== 'custom') {
draftConfig.exportResolution = 3840
}
return
}
draftConfig.exportResolution = value
})
const syncDraft = (value: DocumentConfig) => { const syncDraft = (value: DocumentConfig) => {
Object.assign(draftConfig, createDraft(value)) Object.assign(draftConfig, createDraft(value))
exportResolutionPreset.value = getExportResolutionPreset(value.exportResolution)
} }
watch( watch(
@ -159,7 +226,12 @@ const onBackendChange = (backend: string) => {
} }
const resetDraft = () => { const resetDraft = () => {
syncDraft(props.modelValue) const defaults = createDraft(DEFAULT_DOCUMENT_CONFIG)
syncDraft(defaults)
emit('update:modelValue', defaults)
emit('backendChange', defaults.backend)
ElMessage.success('已恢复默认设置')
emit('close')
} }
const confirmConfig = () => { const confirmConfig = () => {
@ -167,6 +239,7 @@ const confirmConfig = () => {
emit('update:modelValue', confirmed) emit('update:modelValue', confirmed)
emit('backendChange', confirmed.backend) emit('backendChange', confirmed.backend)
ElMessage.success('设置已确认') ElMessage.success('设置已确认')
emit('close')
} }
const getFormulaLabel = (backend: string) => { const getFormulaLabel = (backend: string) => {

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="markdown-renderer"> <div class="markdown-renderer">
<template v-if="content"> <template v-if="content">
<div class="render-actions"> <div class="content-toolbar render-actions">
<div class="render-actions-left"> <div class="render-actions-left">
<el-segmented <el-segmented
v-if="mode === 'markdown'" v-if="mode === 'markdown'"
@ -16,12 +16,14 @@
size="small" size="small"
/> />
</div> </div>
<el-button type="primary" size="small" class="action-button primary" @click="handleDownload()"> <div class="render-actions-right">
<template #icon> <el-button type="primary" size="small" class="action-button primary" @click="handleDownload()">
<el-icon><Download /></el-icon> <template #icon>
</template> <el-icon><Download /></el-icon>
下载 </template>
</el-button> 下载
</el-button>
</div>
</div> </div>
<div class="render-content"> <div class="render-content">
<div v-if="mode === 'markdown'" class="render-shell"> <div v-if="mode === 'markdown'" class="render-shell">
@ -52,11 +54,7 @@
</div> </div>
<div v-else class="render-shell"> <div v-else class="render-shell">
<div class="richtext-stage"> <div ref="richtextRef" class="markdown-content rendered-html richtext-content" v-html="renderedContent"></div>
<article ref="richtextRef" class="richtext-document">
<div class="markdown-content richtext-content" v-html="renderedContent"></div>
</article>
</div>
</div> </div>
</div> </div>
</template> </template>
@ -390,22 +388,35 @@ async function handleDownload() {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background-color: #FFFFFF; background-color: #FFFFFF;
border-radius: 8px; border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.08); box-shadow: none;
overflow: hidden; overflow: hidden;
min-height: 0; min-height: 0;
} }
.render-actions { .content-toolbar {
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
justify-content: space-between;
gap: 12px; gap: 12px;
padding: 10px 16px;
flex-shrink: 0; flex-shrink: 0;
position: sticky; }
top: 0;
z-index: 2; .content-toolbar-title {
font-size: 14px;
font-weight: 600;
color: #343A40;
white-space: nowrap;
}
.content-toolbar-meta {
font-size: 12px;
color: #86909C;
white-space: nowrap;
}
.render-actions {
padding: 0 0 12px;
background-color: #FFFFFF; background-color: #FFFFFF;
} }
@ -414,6 +425,15 @@ async function handleDownload() {
align-items: center; align-items: center;
gap: 12px; gap: 12px;
min-height: 32px; min-height: 32px;
flex: 1;
min-width: 0;
}
.render-actions-right {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
} }
.action-button { .action-button {
@ -447,7 +467,7 @@ async function handleDownload() {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
padding: 0 16px 16px; padding: 0;
} }
.render-header { .render-header {
@ -473,7 +493,7 @@ async function handleDownload() {
.markdown-content { .markdown-content {
padding: 20px; padding: 20px;
background: white; background: white;
border-radius: 10px; border-radius: 4px;
min-height: 400px; min-height: 400px;
} }
@ -483,10 +503,9 @@ async function handleDownload() {
.pdf-stage { .pdf-stage {
padding: 20px; padding: 20px;
border-radius: 12px; border: 1px solid #E9ECEF;
background: border-radius: 4px;
radial-gradient(circle at top, rgba(22, 93, 255, 0.08), transparent 30%), background: #F8F9FA;
#eef2f7;
} }
.pdf-preview-pages { .pdf-preview-pages {
@ -522,17 +541,18 @@ async function handleDownload() {
.richtext-stage { .richtext-stage {
padding: 12px; padding: 12px;
border-radius: 12px; border: 1px solid #E9ECEF;
background: linear-gradient(180deg, #f3f4f6 0%, #eef2f7 100%); border-radius: 4px;
background: #F8F9FA;
} }
.richtext-document { .richtext-document {
min-height: 100%; min-height: 100%;
background: white; background: white;
border: 1px solid #dde3ea; border: 1px solid #dde3ea;
border-radius: 12px; border-radius: 4px;
overflow: hidden; overflow: hidden;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08); box-shadow: none;
} }
.richtext-content { .richtext-content {
@ -633,9 +653,19 @@ async function handleDownload() {
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.render-actions {
flex-direction: column;
align-items: stretch;
}
.render-actions-left { .render-actions-left {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
flex-wrap: wrap;
}
.render-actions-right {
justify-content: flex-end;
} }
.pdf-page { .pdf-page {

View File

@ -1,38 +1,43 @@
<template> <template>
<div class="mindmap-container"> <div class="mindmap-container">
<div class="mindmap-actions"> <div class="content-toolbar mindmap-actions">
<span class="mindmap-subtitle" v-if="content"> {{ nodeCount }} </span> <div class="mindmap-actions-left">
<el-dropdown @command="handleDownload"> <span class="content-toolbar-meta">{{ nodeSummary }}</span>
</div>
<div class="mindmap-actions-right">
<el-dropdown @command="handleDownload">
<el-button
type="primary"
size="small"
class="action-button primary"
:disabled="!content"
>
<template #icon>
<el-icon><Download /></el-icon>
</template>
下载
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</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-menu>
</template>
</el-dropdown>
<el-button <el-button
type="primary" type="default"
size="small" size="small"
class="action-button primary" @click="resetView"
class="action-button secondary"
> >
<template #icon> <template #icon>
<el-icon><Download /></el-icon> <el-icon><Refresh /></el-icon>
</template> </template>
下载 重置视图
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button> </el-button>
<template #dropdown> </div>
<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-menu>
</template>
</el-dropdown>
<el-button
type="default"
size="small"
@click="resetView"
class="action-button secondary"
>
<template #icon>
<el-icon><Refresh /></el-icon>
</template>
重置视图
</el-button>
</div> </div>
<div class="mindmap-content" @wheel.prevent="handleWheel" @click="handleNodeClick"> <div class="mindmap-content" @wheel.prevent="handleWheel" @click="handleNodeClick">
<div class="empty-state" v-if="!content"> <div class="empty-state" v-if="!content">
@ -62,6 +67,10 @@ const props = defineProps({
content: { content: {
type: String, type: String,
default: '' default: ''
},
exportResolution: {
type: Number,
default: 0
} }
}) })
@ -75,12 +84,66 @@ let renderTimer: number | undefined
const transformer = new Transformer() const transformer = new Transformer()
const scale = ref(1) const scale = ref(1)
const isRendering = ref(false) const isRendering = ref(false)
const totalNodeCount = ref(0)
const hiddenNodeCount = ref(0)
const nodeCount = computed(() => { type MarkmapNode = {
if (!props.content) return 0 content?: string
return (props.content.match(/^#{1,6}\s+/gm) || []).length children?: MarkmapNode[]
payload?: {
fold?: number
}
}
const nodeSummary = computed(() => {
if (!props.content) return '暂无节点'
return hiddenNodeCount.value > 0
? `已生成 ${totalNodeCount.value} 个节点,隐藏 ${hiddenNodeCount.value} 个节点`
: `已生成 ${totalNodeCount.value} 个节点`
}) })
const updateNodeStats = () => {
const root = mmInstance?.state?.data as MarkmapNode | undefined
if (!root || !props.content) {
totalNodeCount.value = 0
hiddenNodeCount.value = 0
return
}
const countSubtree = (node: MarkmapNode): number => {
const children = node.children || []
return 1 + children.reduce((sum, child) => sum + countSubtree(child), 0)
}
const walkVisible = (node: MarkmapNode): { total: number; hidden: number } => {
const children = node.children || []
const total = 1 + children.reduce((sum, child) => sum + countSubtree(child), 0)
if (node.payload?.fold && children.length > 0) {
return {
total,
hidden: children.reduce((sum, child) => sum + countSubtree(child), 0)
}
}
const childStats = children.reduce(
(stats, child) => {
const childStat = walkVisible(child)
return {
total: stats.total + childStat.total,
hidden: stats.hidden + childStat.hidden
}
},
{ total: 1, hidden: 0 }
)
return childStats
}
const stats = walkVisible(root)
totalNodeCount.value = Math.max(0, stats.total - 1)
hiddenNodeCount.value = Math.max(0, stats.hidden)
}
const initMarkmap = async () => { const initMarkmap = async () => {
if (!svgRef.value) return if (!svgRef.value) return
@ -104,11 +167,12 @@ const initMarkmap = async () => {
await nextTick() await nextTick()
const initialExpandLevel = nodeCount.value > 80 ? 2 : -1 const sourceNodeCount = (props.content.match(/^#{1,6}\s+/gm) || []).length
const initialExpandLevel = sourceNodeCount > 80 ? 2 : -1
if (mmInstance) { if (mmInstance) {
if (typeof mmInstance.setData === 'function') { if (typeof mmInstance.setData === 'function') {
mmInstance.setData(root) await mmInstance.setData(root, { initialExpandLevel })
mmInstance.fit() mmInstance.fit()
} else { } else {
mmInstance = Markmap.create(svgRef.value, { mmInstance = Markmap.create(svgRef.value, {
@ -129,6 +193,7 @@ const initMarkmap = async () => {
svgRef.value?.querySelectorAll('text').forEach((text) => { svgRef.value?.querySelectorAll('text').forEach((text) => {
text.setAttribute('data-editable-node', 'true') text.setAttribute('data-editable-node', 'true')
}) })
updateNodeStats()
} catch (error) { } catch (error) {
console.error('Error initializing markmap:', error) console.error('Error initializing markmap:', error)
mmInstance = null mmInstance = null
@ -176,14 +241,114 @@ function downloadBlob(blob: Blob, fileName: string) {
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} }
function createExportSvg() { type ExportFormat = 'svg' | 'png' | 'jpeg'
interface ExportBox {
x: number
y: number
width: number
height: number
}
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 getBoxFromSvgRect(rect: DOMRect): ExportBox {
return {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height
}
}
function transformBox(box: ExportBox, matrix: DOMMatrix): ExportBox {
const points = [
new DOMPoint(box.x, box.y).matrixTransform(matrix),
new DOMPoint(box.x + box.width, box.y).matrixTransform(matrix),
new DOMPoint(box.x, box.y + box.height).matrixTransform(matrix),
new DOMPoint(box.x + box.width, box.y + box.height).matrixTransform(matrix)
]
const xs = points.map((point) => point.x)
const ys = points.map((point) => point.y)
const minX = Math.min(...xs)
const minY = Math.min(...ys)
const maxX = Math.max(...xs)
const maxY = Math.max(...ys)
return {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY
}
}
function getIntersection(a: ExportBox, b: ExportBox): ExportBox | null {
const x = Math.max(a.x, b.x)
const y = Math.max(a.y, b.y)
const right = Math.min(a.x + a.width, b.x + b.width)
const bottom = Math.min(a.y + a.height, b.y + b.height)
if (right <= x || bottom <= y) return null
return {
x,
y,
width: right - x,
height: bottom - y
}
}
function padBox(box: ExportBox, padding: number, boundary?: ExportBox): ExportBox {
const padded = {
x: box.x - padding,
y: box.y - padding,
width: box.width + padding * 2,
height: box.height + padding * 2
}
if (!boundary) return padded
const clipped = getIntersection(padded, boundary)
return clipped || box
}
function normalizeBox(box: ExportBox): ExportBox {
const x = Math.min(box.x, box.x + box.width)
const y = Math.min(box.y, box.y + box.height)
return {
x,
y,
width: Math.abs(box.width),
height: Math.abs(box.height)
}
}
function isCompleteInViewport(contentBox: ExportBox, viewportBox: ExportBox) {
const tolerance = 4
return (
contentBox.x >= viewportBox.x - tolerance &&
contentBox.y >= viewportBox.y - tolerance &&
contentBox.x + contentBox.width <= viewportBox.x + viewportBox.width + tolerance &&
contentBox.y + contentBox.height <= viewportBox.y + viewportBox.height + tolerance
)
}
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 +360,53 @@ 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 createVisibleExportSvg() {
const svg = svgRef.value
if (!svg) return null
const contentGroup = svg.querySelector('g') as SVGGElement | null
if (!contentGroup) return createFullExportSvg()
const viewport = getViewportSize(svg)
const viewportBox = { x: 0, y: 0, width: viewport.width, height: viewport.height }
const ctm = contentGroup.getCTM()
if (!ctm) return createFullExportSvg()
const contentBox = transformBox(getBoxFromSvgRect(contentGroup.getBBox()), ctm)
if (isCompleteInViewport(contentBox, viewportBox)) {
return createFullExportSvg()
}
const svgCopy = svg.cloneNode(true) as SVGElement
cleanExportSvg(svgCopy)
const screenVisibleBox = padBox(getIntersection(contentBox, viewportBox) || viewportBox, 24, viewportBox)
const localVisibleBox = normalizeBox(transformBox(screenVisibleBox, ctm.inverse()))
const localContentBox = getBoxFromSvgRect(contentGroup.getBBox())
const clippedLocalBox = getIntersection(localVisibleBox, localContentBox) || localVisibleBox
const visibleBox = padBox(clippedLocalBox, 24)
const width = Math.ceil(Math.max(visibleBox.width, 320))
const height = Math.ceil(Math.max(visibleBox.height, 240))
const viewBox = `${Math.floor(visibleBox.x)} ${Math.floor(visibleBox.y)} ${width} ${height}`
const viewportGroupCopy = svgCopy.querySelector('g')
viewportGroupCopy?.removeAttribute('transform')
svgCopy.setAttribute('width', String(width))
svgCopy.setAttribute('height', String(height))
svgCopy.setAttribute('viewBox', viewBox)
return { return {
data: new XMLSerializer().serializeToString(svgCopy), data: new XMLSerializer().serializeToString(svgCopy),
@ -208,8 +416,8 @@ function createExportSvg() {
} }
// //
const downloadMindMap = async (format: 'svg' | 'png' | 'jpeg' = 'svg') => { const downloadMindMap = async (format: ExportFormat = 'svg') => {
const exportSvg = createExportSvg() const exportSvg = createVisibleExportSvg()
if (!exportSvg) return if (!exportSvg) return
const time = new Date().getTime() const time = new Date().getTime()
@ -218,8 +426,12 @@ const downloadMindMap = async (format: 'svg' | 'png' | 'jpeg' = 'svg') => {
return return
} }
const maxCanvasPixels = 16_000_000 const baseScale = 3
const scaleFactor = Math.min(3, Math.max(1, Math.sqrt(maxCanvasPixels / (exportSvg.width * exportSvg.height)))) const maxResolution = Number(props.exportResolution) || 0
const longEdge = Math.max(exportSvg.width, exportSvg.height)
const scaleFactor = maxResolution > 0
? Math.min(baseScale, Math.max(0.1, maxResolution / longEdge))
: baseScale
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d') const ctx = canvas.getContext('2d')
if (!ctx) return if (!ctx) return
@ -245,6 +457,10 @@ const downloadMindMap = async (format: 'svg' | 'png' | 'jpeg' = 'svg') => {
// //
const handleDownload = (command: string) => { const handleDownload = (command: string) => {
if (!props.content) {
ElMessage.warning('暂无思维导图内容可下载')
return
}
if (command === 'svg' || command === 'png' || command === 'jpeg') { if (command === 'svg' || command === 'png' || command === 'jpeg') {
downloadMindMap(command) downloadMindMap(command)
} }
@ -254,6 +470,7 @@ const handleDownload = (command: string) => {
const resetView = () => { const resetView = () => {
mmInstance?.fit() mmInstance?.fit()
scale.value = 1 scale.value = 1
window.setTimeout(updateNodeStats, 0)
} }
// //
@ -292,6 +509,7 @@ const handleWheel = (event: WheelEvent) => {
} }
const handleNodeClick = async (event: MouseEvent) => { const handleNodeClick = async (event: MouseEvent) => {
window.setTimeout(updateNodeStats, 0)
const textNode = (event.target as Element | null)?.closest?.('text') const textNode = (event.target as Element | null)?.closest?.('text')
const oldText = textNode?.textContent?.trim() const oldText = textNode?.textContent?.trim()
if (!oldText || !props.content || oldText === '文档思维导图') return if (!oldText || !props.content || oldText === '文档思维导图') return
@ -319,28 +537,52 @@ const handleNodeClick = async (event: MouseEvent) => {
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background-color: #FFFFFF; background-color: transparent;
border-radius: 8px; border-radius: 0;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.08); box-shadow: none;
overflow: hidden; overflow: hidden;
min-height: 0; min-height: 0;
} }
.mindmap-actions { .content-toolbar {
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
padding: 10px 16px; justify-content: space-between;
gap: 12px; gap: 12px;
flex-shrink: 0;
} }
.mindmap-subtitle { .mindmap-actions {
padding: 0 0 12px;
}
.mindmap-actions-left,
.mindmap-actions-right {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.mindmap-actions-left {
flex: 1;
}
.mindmap-actions-right {
flex-shrink: 0;
}
.content-toolbar-title {
font-size: 14px;
font-weight: 600;
color: #343A40;
white-space: nowrap;
}
.content-toolbar-meta {
font-size: 12px; font-size: 12px;
color: #86909C; color: #86909C;
background-color: #F2F3F5; white-space: nowrap;
padding: 2px 8px;
border-radius: 10px;
margin-right: auto;
} }
.action-button { .action-button {
@ -382,6 +624,8 @@ const handleNodeClick = async (event: MouseEvent) => {
background-color: #F9FAFC; background-color: #F9FAFC;
cursor: grab; cursor: grab;
min-height: 0; min-height: 0;
border: 1px solid #E9ECEF;
border-radius: 4px;
} }
.mindmap-content:active { .mindmap-content:active {
@ -449,16 +693,18 @@ const handleNodeClick = async (event: MouseEvent) => {
/* 响应式设计 */ /* 响应式设计 */
@media (max-width: 768px) { @media (max-width: 768px) {
.mindmap-header { .mindmap-actions {
padding: 12px 16px; flex-direction: column;
align-items: stretch;
} }
.mindmap-title { .mindmap-actions-left,
font-size: 14px; .mindmap-actions-right {
justify-content: space-between;
} }
.mindmap-subtitle { .content-toolbar-meta {
font-size: 10px; font-size: 11px;
} }
.action-button { .action-button {

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'
@ -13,6 +13,8 @@ export interface DocumentConfig {
maxPages: number maxPages: number
backend: string backend: string
serverUrl: string serverUrl: string
exportResolution: number
mindmapPrompt: string
tableEnable: boolean tableEnable: boolean
formulaEnable: boolean formulaEnable: boolean
language: string language: string
@ -26,6 +28,42 @@ export interface ProcessResult {
downloadUrl?: string downloadUrl?: string
} }
export const DOCUMENT_CONFIG_STORAGE_KEY = 'mineru.documentProcessor.config.v4'
export const DEFAULT_MINDMAP_PROMPT = `你是文档结构整理助手。请基于用户提供的 Markdown 生成适合思维导图展示的 Markdown。
1.
2.
3.
4.
5.
6. 4
7. 8
8. 使
9. Markdown`
export const DEFAULT_DOCUMENT_CONFIG: DocumentConfig = {
maxPages: 1000,
backend: 'hybrid-auto-engine',
serverUrl: 'http://localhost:30000',
exportResolution: 7680,
mindmapPrompt: DEFAULT_MINDMAP_PROMPT,
tableEnable: true,
formulaEnable: true,
language: 'ch',
forceOcr: false
}
function loadCachedConfig(): Partial<DocumentConfig> {
try {
const raw = window.localStorage.getItem(DOCUMENT_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([])
@ -33,14 +71,21 @@ export function useDocumentProcessor() {
// 配置相关 // 配置相关
const config = reactive<DocumentConfig>({ const config = reactive<DocumentConfig>({
maxPages: 1000, ...DEFAULT_DOCUMENT_CONFIG,
backend: 'hybrid-auto-engine', ...loadCachedConfig()
serverUrl: 'http://localhost:30000',
tableEnable: true,
formulaEnable: true,
language: 'ch',
forceOcr: false
}) })
watch(
config,
(value) => {
try {
window.localStorage.setItem(DOCUMENT_CONFIG_STORAGE_KEY, JSON.stringify(value))
} catch {
// 浏览器禁用本地存储时不影响当前页面使用。
}
},
{ deep: true }
)
// 结果相关 // 结果相关
const results = ref<ProcessResult | null>(null) const results = ref<ProcessResult | null>(null)
@ -82,55 +127,79 @@ 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 = []
results.value = null
error.value = null
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) // 验证文件类型 - 支持 Markdown、PDF、Word 和后端可转换为 PDF 的图片格式
const supportedImageExtensions = ['.png', '.jpg', '.jpeg', '.webp', '.bmp', '.tiff', '.gif', '.jp2']
// 验证文件类型 - 支持 PDF、图片和 Word 文档 const isImage = fileType.startsWith('image/') && supportedImageExtensions.some((ext) => fileName.endsWith(ext))
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' const isMarkdown = fileName.endsWith('.md') || fileType === 'text/markdown'
console.log('File type checks - isImage:', isImage, 'isPdf:', isPdf, 'isWord:', isWord) console.log('File type checks - isImage:', isImage, 'isPdf:', isPdf, 'isWord:', isWord, 'isMarkdown:', isMarkdown)
if (!isImage && !isPdf && !isWord) { if (!isImage && !isPdf && !isWord && !isMarkdown) {
error.value = '不支持的文件类型' error.value = '不支持的文件类型,请选择 MD、PDF、Word、JPG、PNG、WEBP、BMP、TIFF、GIF 或 JP2 文件'
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 if (isMarkdown) {
if (isWord) { try {
try { isUploading.value = true
isUploading.value = true const markdown = await file.text()
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) validFiles.push(file)
console.log('Adding file directly:', fileName) results.value = {
markdown,
source: markdown,
mindmap: buildMindmapMarkdown(markdown)
}
error.value = null
console.log('Markdown file loaded directly:', fileName, markdown.length)
} catch (err) {
error.value = 'Markdown 文件读取失败: ' + (err as Error).message
console.error('Markdown read failed:', (err as Error).message)
return
} finally {
isUploading.value = false
} }
} else 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 uploadedFiles.value = validFiles
@ -193,6 +262,14 @@ export function useDocumentProcessor() {
error.value = '请先上传文件' error.value = '请先上传文件'
return return
} }
const firstFileName = uploadedFiles.value[0]?.name.toLowerCase() || ''
if (firstFileName.endsWith('.md')) {
if (!results.value) {
error.value = 'Markdown 文件内容为空,请重新上传'
}
return
}
isProcessing.value = true isProcessing.value = true
error.value = null error.value = null
@ -200,9 +277,10 @@ export function useDocumentProcessor() {
// 生成任务ID // 生成任务ID
const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
startProgressPolling(taskId)
try { try {
await documentApi.createParseTask(taskId)
startProgressPolling(taskId)
processingStage.value = '提交文档到解析服务' processingStage.value = '提交文档到解析服务'
const params: ParseParams = { const params: ParseParams = {
files: uploadedFiles.value, files: uploadedFiles.value,

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

View File

@ -17,13 +17,15 @@
@dragleave="isDragging = false" @dragleave="isDragging = false"
@click="triggerUpload" @click="triggerUpload"
> >
<div class="upload-content" v-show="!isUploadAreaCollapsed"> <div class="upload-content" v-if="uploadedFiles.length === 0">
<el-icon class="upload-icon"><Folder /></el-icon> <el-icon class="upload-icon"><Folder /></el-icon>
<div class="upload-text">文件导入</div> <div class="upload-copy">
<div class="upload-hint">支持 PDFWordPNG 格式文件可转换为 Markdown 和思维导图也可手动粘贴 Markdown 源码</div> <div class="upload-text">文件导入</div>
<div class="upload-hint">支持 MDPDFWordJPGPNGBMPTIFF 等格式文件</div>
</div>
</div> </div>
<div class="uploaded-files"> <div class="uploaded-files" v-else>
<div class="file-item" v-for="(file, index) in uploadedFiles" :key="index"> <div class="file-item" v-for="(file, index) in uploadedFiles" :key="index">
<el-icon class="file-icon"><Document /></el-icon> <el-icon class="file-icon"><Document /></el-icon>
<span class="file-name">{{ file.name }}</span> <span class="file-name">{{ file.name }}</span>
@ -33,15 +35,15 @@
@click.stop="removeFile(index)" @click.stop="removeFile(index)"
class="remove-button" class="remove-button"
/> />
<el-button
type="primary"
:loading="isProcessing || isUploading"
@click.stop="handleProcessDocument"
>
开始转换
</el-button>
</div> </div>
</div> </div>
<div class="upload-actions" v-if="uploadedFiles.length > 0">
<el-button type="primary" :loading="isProcessing || isUploading" @click.stop="handleProcessDocument">
开始转换
</el-button>
<el-button @click.stop="clearAllFiles">清空</el-button>
</div>
</div> </div>
<div v-if="showSettings" class="settings-panel"> <div v-if="showSettings" class="settings-panel">
@ -52,10 +54,12 @@
</el-button> </el-button>
</div> </div>
<ConfigPanel <ConfigPanel
v-model="config" :model-value="config"
:backend-options="backendOptions" :backend-options="backendOptions"
:language-options="languageOptions" :language-options="languageOptions"
@update:model-value="applyConfig"
@backend-change="handleBackendChange" @backend-change="handleBackendChange"
@close="closeSettings"
/> />
</div> </div>
</div> </div>
@ -136,16 +140,35 @@
</div> </div>
<div v-show="activeTab === 'source'" class="source-content"> <div v-show="activeTab === 'source'" class="source-content">
<div class="source-toolbar"> <div class="content-toolbar source-toolbar">
<el-button <div class="content-toolbar-info source-mode-toolbar">
:type="hasActiveReplacementRules ? 'primary' : 'default'" <el-segmented
class="replacement-config-button" v-model="sourceContentMode"
@click="openReplacementDialog" :options="sourceContentModeOptions"
> size="small"
{{ hasActiveReplacementRules ? `动态模板渲染(已配置${activeReplacementRuleCount}条)` : '动态模板渲染' }} :disabled="sourceViewMode === 'ast' || isSmartOrganizing"
</el-button> />
<div v-if="templateRenderError" class="template-error-inline"> <span v-if="isSmartOrganizing" class="smart-organize-status">
{{ templateRenderError }} {{ smartOrganizeStage || '智能整理中' }} {{ smartOrganizeProgress }}%
</span>
<span v-else-if="smartOrganizeError" class="template-error-inline">
{{ smartOrganizeError }}
</span>
<span v-else-if="sourceContentMode === 'smart' && smartMarkdownContent" class="smart-organize-status">
智能整理结果
</span>
<span v-else-if="templateRenderError" class="template-error-inline">
{{ templateRenderError }}
</span>
</div>
<div class="content-toolbar-actions">
<el-button
:type="hasActiveReplacementRules ? 'primary' : 'default'"
class="replacement-config-button"
@click="openReplacementDialog"
>
{{ hasActiveReplacementRules ? `动态模板渲染(已配置${activeReplacementRuleCount}条)` : '动态模板渲染' }}
</el-button>
</div> </div>
</div> </div>
@ -163,7 +186,11 @@
<div v-show="activeTab === 'mindmap'" class="mindmap-content"> <div v-show="activeTab === 'mindmap'" class="mindmap-content">
<div class="mindmap-box"> <div class="mindmap-box">
<MindMapRenderer :content="renderedMindmapContent" @node-edit="handleMindmapNodeEdit" /> <MindMapRenderer
:content="renderedMindmapContent"
:export-resolution="config.exportResolution"
@node-edit="handleMindmapNodeEdit"
/>
</div> </div>
</div> </div>
</div> </div>
@ -174,8 +201,7 @@
<input <input
ref="fileInput" ref="fileInput"
type="file" type="file"
multiple accept=".md,.pdf,.doc,.docx,.png,.jpg,.jpeg,.webp,.bmp,.tiff,.gif,.jp2"
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,17 +245,48 @@
</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'
import MarkdownRenderer from '@/components/MarkdownRenderer.vue' import MarkdownRenderer from '@/components/MarkdownRenderer.vue'
import MindMapRenderer from '@/components/MindMapRenderer.vue' import MindMapRenderer from '@/components/MindMapRenderer.vue'
import { useDocumentProcessor } from '@/composables/useDocumentProcessor' import { documentApi } from '@/api/document'
import { DOCUMENT_CONFIG_STORAGE_KEY, useDocumentProcessor } from '@/composables/useDocumentProcessor'
import { markdownToAstString } from '@/utils/markdownAst' import { markdownToAstString } from '@/utils/markdownAst'
import { buildMindmapMarkdown, replaceFirstMindmapText } from '@/utils/mindmapMarkdown' import { buildMindmapMarkdown, replaceFirstMindmapText } from '@/utils/mindmapMarkdown'
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,16 +310,41 @@ 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 sourceContentMode = ref<'source' | 'smart'>('source')
const sourceContentModeOptions = [
{ label: '源码', value: 'source' },
{ label: '智能整理', value: 'smart' }
]
const showReplacementDialog = ref(false) const showReplacementDialog = ref(false)
const isMobileDialog = ref(false) const isMobileDialog = ref(false)
const replacementRules = ref<ReplacementRule[]>([]) const replacementRules = ref<ReplacementRule[]>([])
const draftReplacementRules = ref<ReplacementRule[]>([]) const draftReplacementRules = ref<ReplacementRule[]>([])
const smartMarkdownContent = ref('')
const smartOrganizeTaskId = ref('')
const isSmartOrganizing = ref(false)
const smartOrganizeProgress = ref(0)
const smartOrganizeStage = ref('')
const smartOrganizeError = ref('')
let smartOrganizeTimer: ReturnType<typeof setInterval> | null = null
watch(markdownRenderMode, (value) => storeValue(MARKDOWN_RENDER_MODE_KEY, value))
watch(sourceViewMode, (value) => storeValue(SOURCE_VIEW_MODE_KEY, value))
const manualMarkdownContent = computed(() => results.value?.source || '') const manualMarkdownContent = computed(() => results.value?.source || '')
const templateRenderResult = computed(() => renderMarkdownTemplate(manualMarkdownContent.value, replacementRules.value)) const activeSourceMarkdownContent = computed(() => {
if (sourceContentMode.value === 'smart' && smartMarkdownContent.value) {
return smartMarkdownContent.value
}
return manualMarkdownContent.value
})
const templateRenderResult = computed(() => renderMarkdownTemplate(activeSourceMarkdownContent.value, replacementRules.value))
const renderedMarkdownContent = computed(() => templateRenderResult.value.markdown) const renderedMarkdownContent = computed(() => templateRenderResult.value.markdown)
const renderedMindmapContent = computed(() => buildMindmapMarkdown(renderedMarkdownContent.value)) const renderedMindmapContent = computed(() => buildMindmapMarkdown(renderedMarkdownContent.value))
const templateRenderError = computed(() => templateRenderResult.value.error) const templateRenderError = computed(() => templateRenderResult.value.error)
@ -290,15 +372,105 @@ const sourcePanelContent = computed({
if (sourceViewMode.value === 'ast') { if (sourceViewMode.value === 'ast') {
return markdownToAstString(renderedMarkdownContent.value) return markdownToAstString(renderedMarkdownContent.value)
} }
return manualMarkdownContent.value return activeSourceMarkdownContent.value
}, },
set: (value: string) => { set: (value: string) => {
if (sourceViewMode.value === 'markdown' && results.value) { if (sourceViewMode.value !== 'markdown') return
if (sourceContentMode.value === 'smart') {
smartMarkdownContent.value = value
return
}
if (results.value) {
results.value.source = value results.value.source = value
} }
} }
}) })
const stopSmartOrganizePolling = () => {
if (smartOrganizeTimer) {
clearInterval(smartOrganizeTimer)
smartOrganizeTimer = null
}
}
const resetSmartOrganizeState = () => {
stopSmartOrganizePolling()
sourceContentMode.value = 'source'
smartMarkdownContent.value = ''
smartOrganizeTaskId.value = ''
isSmartOrganizing.value = false
smartOrganizeProgress.value = 0
smartOrganizeStage.value = ''
smartOrganizeError.value = ''
}
const startSmartOrganizePolling = (taskId: string) => {
stopSmartOrganizePolling()
smartOrganizeTimer = setInterval(async () => {
try {
const data = await documentApi.getMindmapProgress(taskId)
smartOrganizeProgress.value = data.progress
smartOrganizeStage.value = data.stage
if (data.status === 'completed') {
stopSmartOrganizePolling()
isSmartOrganizing.value = false
smartMarkdownContent.value = data.result_md || ''
smartOrganizeError.value = ''
sourceContentMode.value = 'smart'
ElMessage.success('智能整理完成')
} else if (data.status === 'failed') {
stopSmartOrganizePolling()
isSmartOrganizing.value = false
sourceContentMode.value = 'source'
smartOrganizeError.value = data.error || '智能整理失败'
ElMessage.error(smartOrganizeError.value)
}
} catch (err) {
stopSmartOrganizePolling()
isSmartOrganizing.value = false
sourceContentMode.value = 'source'
smartOrganizeError.value = (err as Error).message || '智能整理进度查询失败'
ElMessage.error(smartOrganizeError.value)
}
}, 1500)
}
const startSmartOrganize = async () => {
const markdown = manualMarkdownContent.value.trim()
if (!markdown) {
sourceContentMode.value = 'source'
ElMessage.warning('暂无 Markdown 源码可整理')
return
}
if (smartMarkdownContent.value) {
sourceContentMode.value = 'smart'
return
}
const taskId = `mindmap-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
smartOrganizeTaskId.value = taskId
isSmartOrganizing.value = true
smartOrganizeProgress.value = 0
smartOrganizeStage.value = '创建智能整理任务'
smartOrganizeError.value = ''
try {
await documentApi.createMindmapTask(taskId, markdown, 'hybrid', config.mindmapPrompt)
startSmartOrganizePolling(taskId)
} catch (err) {
isSmartOrganizing.value = false
sourceContentMode.value = 'source'
smartOrganizeError.value = (err as Error).message || '智能整理任务创建失败'
ElMessage.error(smartOrganizeError.value)
}
}
watch(sourceContentMode, (value) => {
if (value === 'smart') {
startSmartOrganize()
}
})
const toggleSettings = () => { const toggleSettings = () => {
if (showSettings.value) { if (showSettings.value) {
const settingsPanel = document.querySelector('.settings-panel') const settingsPanel = document.querySelector('.settings-panel')
@ -323,6 +495,12 @@ const toggleSettings = () => {
} }
} }
const closeSettings = () => {
if (showSettings.value) {
toggleSettings()
}
}
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
const settingsPanel = document.querySelector('.settings-panel') const settingsPanel = document.querySelector('.settings-panel')
const settingsButton = document.querySelector('.settings-button') const settingsButton = document.querySelector('.settings-button')
@ -347,7 +525,17 @@ const handleBackendChange = (backend: string) => {
console.log('Backend changed to:', backend) console.log('Backend changed to:', backend)
} }
const applyConfig = (nextConfig: typeof config) => {
Object.assign(config, nextConfig)
try {
window.localStorage.setItem(DOCUMENT_CONFIG_STORAGE_KEY, JSON.stringify(nextConfig))
} catch {
// 使
}
}
const handleProcessDocument = async () => { const handleProcessDocument = async () => {
resetSmartOrganizeState()
await processDocument() await processDocument()
if (error.value) { if (error.value) {
ElMessage.error(error.value) ElMessage.error(error.value)
@ -403,13 +591,18 @@ const saveReplacementRules = () => {
const handleMindmapNodeEdit = ({ oldText, newText }: { oldText: string; newText: string }) => { const handleMindmapNodeEdit = ({ oldText, newText }: { oldText: string; newText: string }) => {
if (!results.value) return if (!results.value) return
const updated = replaceFirstMindmapText(results.value.source || results.value.markdown, oldText, newText) const currentMarkdown = activeSourceMarkdownContent.value
if (updated === (results.value.source || results.value.markdown)) { const updated = replaceFirstMindmapText(currentMarkdown, oldText, newText)
if (updated === currentMarkdown) {
ElMessage.warning('未在 Markdown 中定位到该节点文本,可在源码区直接编辑') ElMessage.warning('未在 Markdown 中定位到该节点文本,可在源码区直接编辑')
return return
} }
results.value.source = updated if (sourceContentMode.value === 'smart') {
results.value.markdown = updated smartMarkdownContent.value = updated
} else {
results.value.source = updated
results.value.markdown = updated
}
ElMessage.success('节点已同步到 Markdown') ElMessage.success('节点已同步到 Markdown')
} }
@ -417,20 +610,17 @@ 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) {
await handleFileUpload(input.files) await handleFileUpload(input.files)
input.value = '' input.value = ''
if (uploadedFiles.value.length > 0) { if (uploadedFiles.value.length > 0) {
resetSmartOrganizeState()
isUploadAreaCollapsed.value = true isUploadAreaCollapsed.value = true
initializeManualResult() if (!results.value) {
resetReplacementConfig() initializeManualResult()
}
markdownRenderMode.value = 'markdown' markdownRenderMode.value = 'markdown'
sourceViewMode.value = 'markdown' sourceViewMode.value = 'markdown'
activeTab.value = 'source' activeTab.value = 'source'
@ -445,9 +635,11 @@ const handleDrop = async (event: DragEvent) => {
if (event.dataTransfer?.files) { if (event.dataTransfer?.files) {
await handleFileUpload(event.dataTransfer.files) await handleFileUpload(event.dataTransfer.files)
if (uploadedFiles.value.length > 0) { if (uploadedFiles.value.length > 0) {
resetSmartOrganizeState()
isUploadAreaCollapsed.value = true isUploadAreaCollapsed.value = true
initializeManualResult() if (!results.value) {
resetReplacementConfig() initializeManualResult()
}
markdownRenderMode.value = 'markdown' markdownRenderMode.value = 'markdown'
sourceViewMode.value = 'markdown' sourceViewMode.value = 'markdown'
activeTab.value = 'source' activeTab.value = 'source'
@ -463,8 +655,8 @@ const removeFile = (index: number) => {
} }
const clearAllFiles = () => { const clearAllFiles = () => {
resetSmartOrganizeState()
clearAll() clearAll()
resetReplacementConfig()
isUploadAreaCollapsed.value = false isUploadAreaCollapsed.value = false
} }
@ -474,6 +666,7 @@ onMounted(() => {
}) })
onUnmounted(() => { onUnmounted(() => {
stopSmartOrganizePolling()
window.removeEventListener('resize', updateViewportState) window.removeEventListener('resize', updateViewportState)
}) })
</script> </script>
@ -522,7 +715,7 @@ onUnmounted(() => {
.main-content { .main-content {
flex: 1; flex: 1;
padding: 24px; padding: 0 24px;
overflow: hidden; overflow: hidden;
min-height: 0; min-height: 0;
display: flex; display: flex;
@ -537,19 +730,23 @@ onUnmounted(() => {
.drag-upload-area { .drag-upload-area {
border: 2px dashed #CED4DA; border: 2px dashed #CED4DA;
border-radius: 8px; border-radius: 8px;
padding: 20px 24px; padding: 8px 16px;
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
transition: all 0.3s ease; transition: all 0.3s ease;
background-color: #FFFFFF; background-color: #FFFFFF;
margin-bottom: 16px; margin-bottom: 0;
overflow: hidden; overflow: hidden;
height: 52px;
min-height: 52px;
display: flex;
align-items: center;
box-sizing: border-box;
} }
.drag-upload-area.collapsed { .drag-upload-area.collapsed {
padding: 8px 16px; padding: 8px 16px;
min-height: 36px; min-height: 52px;
display: block;
} }
.drag-upload-area:hover, .drag-upload-area:hover,
@ -560,56 +757,52 @@ onUnmounted(() => {
.upload-content { .upload-content {
display: flex; display: flex;
flex-direction: column; flex-direction: row;
align-items: center; align-items: center;
gap: 12px; justify-content: center;
gap: 10px;
width: 100%;
} }
.upload-icon { .upload-icon {
font-size: 48px; font-size: 24px;
color: #165DFF; color: #165DFF;
} }
.upload-copy {
min-width: 0;
text-align: left;
}
.upload-text { .upload-text {
font-size: 16px; font-size: 14px;
font-weight: 500; font-weight: 500;
color: #343A40; color: #343A40;
} }
.upload-hint { .upload-hint {
font-size: 14px; font-size: 12px;
color: #6C757D; color: #6C757D;
line-height: 1.5; line-height: 1.4;
max-width: 400px;
} }
.uploaded-files { .uploaded-files {
margin-top: 24px;
text-align: left;
}
.upload-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
.drag-upload-area.collapsed .uploaded-files {
margin-top: 0;
width: 100%; width: 100%;
height: 100%;
text-align: left;
} }
.file-item { .file-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 6px 10px; padding: 0;
background-color: #F8F9FA; background-color: #F8F9FA;
border-radius: 4px; border-radius: 6px;
margin-bottom: 2px; margin-bottom: 0;
transition: all 0.3s ease; transition: all 0.3s ease;
width: 100%; width: 100%;
height: 100%;
box-sizing: border-box; box-sizing: border-box;
font-size: 13px; font-size: 13px;
} }
@ -625,15 +818,16 @@ onUnmounted(() => {
.file-name { .file-name {
flex: 1; flex: 1;
min-width: 0;
font-size: 13px; font-size: 13px;
color: #343A40; color: #343A40;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
max-width: calc(100% - 40px);
} }
.remove-button { .remove-button {
flex-shrink: 0;
color: #6C757D; color: #6C757D;
padding: 4px; padding: 4px;
} }
@ -710,6 +904,10 @@ onUnmounted(() => {
border-bottom: 1px solid #E9ECEF; border-bottom: 1px solid #E9ECEF;
} }
.result-tabs :deep(.el-tabs__header) {
margin: 0;
}
.result-tabs :deep(.el-tabs__nav) { .result-tabs :deep(.el-tabs__nav) {
padding-left: 16px; padding-left: 16px;
} }
@ -857,23 +1055,21 @@ onUnmounted(() => {
.markdown-box { .markdown-box {
height: 100%; height: 100%;
min-height: 0; min-height: 0;
border: 1px solid #E9ECEF; border: 0;
border-radius: 4px; border-radius: 0;
box-shadow: none; box-shadow: none;
padding: 16px; overflow: hidden;
overflow-y: auto; background-color: transparent;
overflow-x: hidden;
background-color: white;
} }
.mindmap-box { .mindmap-box {
height: 100%; height: 100%;
min-height: 0; min-height: 0;
border: 1px solid #E9ECEF; border: 0;
border-radius: 4px; border-radius: 0;
box-shadow: none; box-shadow: none;
overflow: hidden; overflow: hidden;
background-color: white; background-color: transparent;
} }
.source-content { .source-content {
@ -892,6 +1088,45 @@ onUnmounted(() => {
flex-shrink: 0; flex-shrink: 0;
} }
.content-toolbar-info,
.content-toolbar-actions {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.content-toolbar-info {
flex: 1;
}
.source-mode-toolbar {
align-items: center;
}
.smart-organize-status {
font-size: 12px;
color: #86909C;
white-space: nowrap;
}
.content-toolbar-actions {
flex-shrink: 0;
}
.content-toolbar-title {
font-size: 14px;
font-weight: 600;
color: #343A40;
white-space: nowrap;
}
.content-toolbar-meta {
font-size: 12px;
color: #86909C;
white-space: nowrap;
}
.replacement-config-button { .replacement-config-button {
border-radius: 6px; border-radius: 6px;
} }
@ -994,7 +1229,10 @@ onUnmounted(() => {
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.main-content, .main-content {
padding: 0 16px;
}
.result-content { .result-content {
padding: 16px; padding: 16px;
} }
@ -1004,11 +1242,11 @@ onUnmounted(() => {
} }
.drag-upload-area { .drag-upload-area {
padding: 32px 16px; padding: 8px 12px;
} }
.upload-icon { .upload-icon {
font-size: 32px; font-size: 22px;
} }
.upload-text { .upload-text {
@ -1033,6 +1271,14 @@ onUnmounted(() => {
align-items: stretch; align-items: stretch;
} }
.content-toolbar-info {
flex-wrap: wrap;
}
.content-toolbar-actions {
justify-content: flex-end;
}
.replacement-dialog-body { .replacement-dialog-body {
max-height: none; max-height: none;
padding-right: 0; padding-right: 0;

View File

@ -13,7 +13,7 @@ export default defineConfig({
port: 3000, port: 3000,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:8000', target: 'http://10.100.52.76:8000',
changeOrigin: true changeOrigin: true
} }
} }